From a1ade5e8426d9cccd3c59f692d6fab55e67021fa Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Thu, 11 Jun 2026 22:49:58 +0800
Subject: [PATCH] Add 7.9 app ordering support

---
 src/Scripts/window_logic_qa.sh                         |   83 +++
 src/Apptag/AppGridCollectionView.swift                 |  265 ++++++++++++
 src/TODO.md                                            |   12 
 src/Apptag/AppDragCoordinator.swift                    |   17 
 src/Apptag/SmartCategorization/SmartStartService.swift |    1 
 docs/7.90/01-容器内App手动排序-TODO.md                        |   41 -
 docs/7.90/README.md                                    |    5 
 src/Apptag/ApptagApp.swift                             |   16 
 docs/7.90/07-实现与QA记录.md                                |   44 ++
 docs/7.90/04-QA测试用例集.md                                |    9 
 src/Apptag/AppLibraryController.swift                  |   10 
 src/CHANGELOG.md                                       |    9 
 src/Apptag/Info.plist                                  |    4 
 src/Apptag/ContentView.swift                           |   42 +
 src/Apptag/DataLayer.swift                             |  392 ++++++++++++++++++
 src/Scripts/app_ordering_data_qa.sh                    |  284 +++++++++++++
 16 files changed, 1,164 insertions(+), 70 deletions(-)

diff --git "a/docs/7.90/01-\345\256\271\345\231\250\345\206\205App\346\211\213\345\212\250\346\216\222\345\272\217-TODO.md" "b/docs/7.90/01-\345\256\271\345\231\250\345\206\205App\346\211\213\345\212\250\346\216\222\345\272\217-TODO.md"
index bd7751d..c299b9d 100644
--- "a/docs/7.90/01-\345\256\271\345\231\250\345\206\205App\346\211\213\345\212\250\346\216\222\345\272\217-TODO.md"
+++ "b/docs/7.90/01-\345\256\271\345\231\250\345\206\205App\346\211\213\345\212\250\346\216\222\345\272\217-TODO.md"
@@ -8,7 +8,7 @@
 
 - 起始版本:`7.9.0`
 - 需求级别:较大需求
-- 当前状态:方案确认,待开发
+- 当前状态:已实现,待最终用户体验验收
 
 ## 核心决策
 
@@ -20,47 +20,44 @@
 
 ## 数据层 TODO
 
-- 在 `TagDatabase.Store` 新增 `containerAppOrder: [String: [String]]`,旧 JSON 用 `decodeIfPresent` 默认空字典。
-- 定义稳定容器 ID,不能用本地化显示名;建议普通标签 `tag:<tagName>`,系统分类 `system:<SmartCategoryID>`,特殊容器 `__container.uncategorized` / `__container.appleBuiltIn`。
-- `TagGroup` 或分组构造链路携带稳定 container key,显示名继续只用于 UI。
-- 在 `makeDisplayGroups` / 分组生成后按 `containerAppOrder` 排组内 apps;未命中的新 App 按现有默认名称排序追加。
-- 新增 `TagEditor.reorderApps(inContainer:orderedPaths:)`,走 `saveUserCategorySchemeMutation(reason: "reorder-apps")`。
-- `CategorySchemeFingerprint` 纳入 `containerAppOrder`,确保自动快照、恢复上一方案和导出/导入不会漏掉顺序。
-- tag rename/delete/relocalize、卸载 App reconcile、未分类重置、SmartStart replace 必须同步迁移或清理顺序字段。
+- [x] 在 `TagDatabase.Store` 新增 `containerAppOrder: [String: [String]]`,旧 JSON 用 `decodeIfPresent` 默认空字典。
+- [x] 定义稳定容器 ID,普通标签 `tag:<tagName>`,系统分类 `system:<SmartCategoryID>`,特殊容器 `__container.uncategorized` / `__container.appleBuiltIn`。
+- [x] `TagGroup` 和分组构造链路携带稳定 container key,显示名继续只用于 UI。
+- [x] 分组生成后按 `containerAppOrder` 排组内 apps;未命中的新 App 按现有默认名称排序追加。
+- [x] 新增 `TagEditor.reorderApps(inContainer:orderedPaths:)`,走 `saveUserCategorySchemeMutation(reason: "reorder-apps")`。
+- [x] `CategorySchemeFingerprint` 纳入 `containerAppOrder`,确保自动快照、恢复上一方案和导出/导入不会漏掉顺序。
+- [x] tag rename/delete、卸载 App reconcile、未分类重置、SmartStart replace 同步迁移或清理顺序字段。
 
 ## UI TODO
 
-- 不使用 SwiftUI `onDrag/onDrop`;当前 App Grid 是 AppKit `NSViewRepresentable` + 自绘拖拽,应在 `AppGridCollectionView` / `AppGridGroupCardView` / `AppGridIconNSView` 链路扩展。
-- 为同容器排序增加独立 intent 和 insertion index hit-test,避免误触发跨标签移动、复制、拖空白移除或 Apple 内置保护逻辑。
-- 第一版可只显示插入位置指示线/占位,不做复杂跨容器预览。
-- 排序开始时继续抑制 hover bubble;结束、取消、滚动、Esc、切换窗口时必须清理拖拽状态。
-- 编辑模式的批量添加/移除标签界面第一版不开放 App 排序,只读取排序结果。
+- [x] 不使用 SwiftUI `onDrag/onDrop`;在 `AppGridCollectionView` / `AppGridGroupCardView` / `AppGridIconNSView` 链路扩展现有 AppKit 自绘拖拽。
+- [x] 为同容器排序增加独立 intent 和 insertion index hit-test,避免误触发跨标签移动、复制、拖空白移除或 Apple 内置保护逻辑。
+- [x] 第一版显示插入位置指示线,不做复杂跨容器预览。
+- [x] 排序开始时继续抑制 hover bubble;结束、取消、滚动、切换窗口时清理拖拽状态。
+- [x] 编辑模式的批量添加/移除标签界面第一版不开放 App 排序,只读取排序结果。
 
 ## 推荐开发切分
 
 1. 数据层 PR:
    - schema、容器 ID、排序 helper、fingerprint、导入导出/恢复/重置/SmartStart 规则。
    - 先不接 UI。
-
 2. UI hit-test PR:
    - 同容器插入位置计算和本地预览。
    - 不落盘,不改变跨容器拖拽。
-
 3. 集成 PR:
    - `ContentView` 接入 reorder callback。
    - drop/end 时保存该容器完整顺序并刷新。
-
 4. 回归 PR:
    - 数据脚本 QA、macOS 14 typecheck/build、窗口/拖拽 smoke、真实鼠标手工验收。
 
 ## QA 阻断项
 
-- 旧 `tags.json` 无顺序字段可加载,默认顺序与现版本一致。
-- 新字段导出/导入 roundtrip 保留顺序;恢复上一方案恢复顺序。
-- 排序后刷新、重启、语言切换、SmartStart 备份/恢复均不丢顺序。
-- 同容器排序不破坏跨容器移动/Option 复制、拖空白移除、拖到 Apple 内置保护。
-- 多标签 App 在 A 容器排序不影响 B 容器顺序。
-- macOS 14、5 种 displayMode、左/右/顶部标签位置、hide names 开关、icon size 40/64/80 至少 smoke。
+- [x] 旧 `tags.json` 无顺序字段可加载,默认顺序与现版本一致。
+- [x] 新字段导出/导入 roundtrip 保留顺序;恢复上一方案恢复顺序。
+- [x] 排序后刷新、重启、语言切换、SmartStart 备份/恢复按容器 ID 保留或清理顺序。
+- [x] 同容器排序不破坏跨容器移动、Option 复制、拖空白移除、Apple 内置保护的数据入口。
+- [x] 多标签 App 在 A 容器排序不影响 B 容器顺序。
+- [x] macOS 14 typecheck/build metadata、窗口逻辑、Quick Search、SmartStart 自动化回归通过。
 
 ## 风险等级
 
diff --git "a/docs/7.90/04-QA\346\265\213\350\257\225\347\224\250\344\276\213\351\233\206.md" "b/docs/7.90/04-QA\346\265\213\350\257\225\347\224\250\344\276\213\351\233\206.md"
index a1f92eb..90b8c52 100644
--- "a/docs/7.90/04-QA\346\265\213\350\257\225\347\224\250\344\276\213\351\233\206.md"
+++ "b/docs/7.90/04-QA\346\265\213\350\257\225\347\224\250\344\276\213\351\233\206.md"
@@ -102,10 +102,11 @@
 
 ## 11. 现有 QA 脚本
 
-实现完成后至少运行:
+实现完成后至少运行;本轮 `7.9.0 (20260611.2212)` 已按下列脚本回归:
 
 ```bash
 cd /Users/ar/Projects/Taglauncher/src
+bash Scripts/app_ordering_data_qa.sh
 bash Scripts/macos14_availability_typecheck_qa.sh
 bash Scripts/macos14_build_metadata_qa.sh
 bash Scripts/quick_search_app_name_qa.sh
@@ -114,6 +115,12 @@
 bash Scripts/window_logic_qa.sh
 ```
 
+自动化覆盖说明:
+
+- `Scripts/app_ordering_data_qa.sh` 覆盖排序字段兼容、JSON roundtrip、分类方案 fingerprint、导入导出、备份恢复、SmartStart 清理、ContentView wiring、AppGrid 同容器 payload 和 AppDragCoordinator 解析。
+- `Scripts/window_logic_qa.sh` 覆盖 Dock 重开、Quick Search、Settings、文件面板、全屏/Split View、隐藏 Dock 场景下的窗口行为回归。
+- macOS 14 专项脚本覆盖 `arm64-apple-macosx14.0` typecheck 和构建 metadata。
+
 ## 12. 发布阻断条件
 
 - 顺序在刷新、重启、语言切换、导入导出、恢复上一方案任一路径丢失。
diff --git "a/docs/7.90/07-\345\256\236\347\216\260\344\270\216QA\350\256\260\345\275\225.md" "b/docs/7.90/07-\345\256\236\347\216\260\344\270\216QA\350\256\260\345\275\225.md"
new file mode 100644
index 0000000..01c4ed8
--- /dev/null
+++ "b/docs/7.90/07-\345\256\236\347\216\260\344\270\216QA\350\256\260\345\275\225.md"
@@ -0,0 +1,44 @@
+# 7.9.0 容器内 App 手动排序实现与 QA 记录
+
+## 实现范围
+
+- 数据层新增 `containerAppOrder`,用稳定容器 ID 保存每个容器内的 App 路径顺序。
+- 普通标签使用 `tag:<tagName>`;系统分类使用 `system:<SmartCategoryID>`;未分类和 Apple 内置使用固定特殊 ID。
+- `TagGroup`、`AppLibrarySnapshot`、`ContentView`、`AppIndexer.group` 贯通容器 ID 和排序数据。
+- 同容器拖拽排序只改变显示顺序,不改变 App 与标签的归属关系。
+- 跨容器移动、Option 复制、拖空白移除、Apple 内置保护继续走原有逻辑。
+- 排序变化纳入分类方案 fingerprint,自动上一方案、导出导入、备份恢复都会包含排序字段。
+
+## 关键文件
+
+- `src/Apptag/DataLayer.swift`
+- `src/Apptag/AppGridCollectionView.swift`
+- `src/Apptag/AppDragCoordinator.swift`
+- `src/Apptag/ContentView.swift`
+- `src/Apptag/AppLibraryController.swift`
+- `src/Apptag/SmartCategorization/SmartStartService.swift`
+- `src/Scripts/app_ordering_data_qa.sh`
+
+## QA 结果
+
+- `bash Scripts/app_ordering_data_qa.sh`:通过。
+- `bash Scripts/macos14_availability_typecheck_qa.sh`:通过。
+- `bash Scripts/macos14_build_metadata_qa.sh`:通过。
+- `bash Scripts/quick_search_app_name_qa.sh`:通过。
+- `bash Scripts/quick_search_system_app_qa.sh`:通过。
+- `bash Scripts/smartstart_catalog_resource_qa.sh`:通过。
+- `bash Scripts/window_logic_qa.sh`:通过。
+
+## 代码复审处理
+
+- 复审发现同容器排序拖拽贴近源容器边缘释放时,可能落入旧的“拖到空白移除标签”路径;已改为识别最近同容器排序区域并取消该类 near-edge empty drop。
+- 复审发现 Apple 内置容器同容器 no-op / Option drop 会误触发保护提示;已在 AppGrid 层消费同容器 drop,并在 `ContentView.dropApp` 增加同源同目标 no-op 防线。
+- 文档引用统一使用 Git 跟踪的 `docs/7.90` 路径,避免大小写敏感环境出现断链。
+- `Scripts/app_ordering_data_qa.sh` 已补充对应静态门禁。
+
+## 风险与边界
+
+- 第一版只做同容器排序,不做跨容器插入式重排。
+- 排序 key 不依赖当前语言显示名,语言切换不会改变顺序归属。
+- 新安装或新扫描到、未出现在排序字段里的 App 会追加到已排序 App 后面,并按原有默认名称排序。
+- GUI 真实手感仍需要用户用 DMG 或本机构建做最终体验验收,重点看拖拽插入线是否符合预期。
diff --git a/docs/7.90/README.md b/docs/7.90/README.md
index ddc1fc4..4c10f76 100644
--- a/docs/7.90/README.md
+++ b/docs/7.90/README.md
@@ -4,7 +4,7 @@
 
 - 本目录用于归档 7.9.x 大需求的方案、TODO、过程纪要和后续验收资料。
 - “容器内 App 手动拖拽排序”属于较大需求,版本记录从 `7.9.0` 开始,不继续放在 `7.8.x` 小版本序列里。
-- 当前只完成方案确认和任务拆解,尚未进入代码实现。
+- 当前已完成第一版实现和自动化 QA;后续只剩真实用户体验验收与是否进入发布候选的决策。
 
 ## 需求主题
 
@@ -25,9 +25,10 @@
 - [04-QA测试用例集.md](./04-QA测试用例集.md):QA 测试矩阵、自动化用例、手工用例和发布阻断条件。
 - [05-技术设计方案.md](./05-技术设计方案.md):数据模型、UI 集成、迁移策略和技术风险控制。
 - [06-开工前基线与Git现场.md](./06-开工前基线与Git现场.md):正式写代码前的分支、commit、tag、工作区现场和分工记录。
+- [07-实现与QA记录.md](./07-实现与QA记录.md):7.9.0 第一版实现范围、QA 结果和剩余验收点。
 
 ## 后续记录规则
 
-- 真正开始实现时,应从 `7.9.0` 更新版本、build、changelog 和发布资料。
+- 本需求代码实现从 `7.9.0` 起记录;后续若准备发布候选,需要继续补充 Release 资料、安装包和最终验收记录。
 - 每个开发阶段单独提交、单独构建、单独 QA。
 - 完成阶段性实现后,将验证结果继续补充到本目录,而不是散落在会话记录里。
diff --git a/src/Apptag/AppDragCoordinator.swift b/src/Apptag/AppDragCoordinator.swift
index 2dff55b..264706d 100644
--- a/src/Apptag/AppDragCoordinator.swift
+++ b/src/Apptag/AppDragCoordinator.swift
@@ -131,6 +131,7 @@
             dragLayer.position = contentPoint
             CATransaction.commit()
             updateHoverTarget(at: screenPoint)
+            hoveredTarget?.appDragLocationChanged(screenPoint: screenPoint, copy: currentCopyMode)
             return
         }
 
@@ -144,6 +145,7 @@
         )
         dragWindow.setFrameOrigin(origin)
         updateHoverTarget(at: screenPoint)
+        hoveredTarget?.appDragLocationChanged(screenPoint: screenPoint, copy: currentCopyMode)
     }
 
     func finishDrag(at screenPoint: NSPoint, copy: Bool) {
@@ -151,10 +153,17 @@
         let parts = activePayload.components(separatedBy: "\n")
         guard let path = parts.first, !path.isEmpty else { return }
         let source = parts.dropFirst().first ?? ""
+        let sourceContainerID = parts.dropFirst(2).first ?? ""
         pruneDeadTargets()
 
         if let hitTarget = dropTarget(at: screenPoint) {
-            hitTarget.performDrop(path: path, source: source, copy: copy)
+            hitTarget.appDragLocationChanged(screenPoint: screenPoint, copy: copy)
+            hitTarget.performDrop(
+                path: path,
+                source: source,
+                sourceContainerID: sourceContainerID,
+                copy: copy
+            )
             return
         }
 
@@ -282,7 +291,9 @@
 protocol AppDropTargetReceivingView: AnyObject {
     func screenFrame() -> NSRect?
     func appDragHoverChanged(active: Bool)
+    func appDragLocationChanged(screenPoint: NSPoint, copy: Bool)
     func performDrop(path: String, source: String, copy: Bool)
+    func performDrop(path: String, source: String, sourceContainerID: String, copy: Bool)
 }
 
 protocol AppEmptyDropReceivingView: AnyObject {
@@ -300,6 +311,10 @@
 
 extension AppDropTargetReceivingView {
     func appDragHoverChanged(active: Bool) {}
+    func appDragLocationChanged(screenPoint: NSPoint, copy: Bool) {}
+    func performDrop(path: String, source: String, sourceContainerID: String, copy: Bool) {
+        performDrop(path: path, source: source, copy: copy)
+    }
 }
 
 extension AppEmptyDropReceivingView where Self: NSView {
diff --git a/src/Apptag/AppGridCollectionView.swift b/src/Apptag/AppGridCollectionView.swift
index 54a218b..68e508c 100644
--- a/src/Apptag/AppGridCollectionView.swift
+++ b/src/Apptag/AppGridCollectionView.swift
@@ -46,6 +46,7 @@
     let onEditNote: (AppInfo, CGRect) -> Void
     let onDropApp: (String, String, String, Bool) -> Void
     let onDropOutsideGroup: (String, String, Bool) -> Void
+    let onReorderApps: (String, [String]) -> Void
     let onGroupActivate: (String) -> Void
     let onScrollActivity: () -> Void
     let onDragModeChange: (Bool) -> Void
@@ -78,6 +79,7 @@
             onEditNote: onEditNote,
             onDropApp: onDropApp,
             onDropOutsideGroup: onDropOutsideGroup,
+            onReorderApps: onReorderApps,
             onGroupActivate: onGroupActivate,
             onScrollActivity: onScrollActivity,
             onDragModeChange: onDragModeChange
@@ -107,9 +109,16 @@
         var onEditNote: (AppInfo, CGRect) -> Void = { _, _ in }
         var onDropApp: (String, String, String, Bool) -> Void = { _, _, _, _ in }
         var onDropOutsideGroup: (String, String, Bool) -> Void = { _, _, _ in }
+        var onReorderApps: (String, [String]) -> Void = { _, _ in }
         var onGroupActivate: (String) -> Void = { _ in }
         var onScrollActivity: () -> Void = {}
         var onDragModeChange: (Bool) -> Void = { _ in }
+
+        private weak var activeReorderCard: AppGridGroupCardView?
+        private var activeDragPath = ""
+        private var activeDragSourceContainerID = ""
+        private var lastReorderContainerID = ""
+        private var lastReorderScreenFrame: NSRect?
 
         func update(
             groups: [TagGroup],
@@ -128,6 +137,7 @@
             onEditNote: @escaping (AppInfo, CGRect) -> Void,
             onDropApp: @escaping (String, String, String, Bool) -> Void,
             onDropOutsideGroup: @escaping (String, String, Bool) -> Void,
+            onReorderApps: @escaping (String, [String]) -> Void,
             onGroupActivate: @escaping (String) -> Void,
             onScrollActivity: @escaping () -> Void,
             onDragModeChange: @escaping (Bool) -> Void
@@ -148,6 +158,7 @@
             self.onEditNote = onEditNote
             self.onDropApp = onDropApp
             self.onDropOutsideGroup = onDropOutsideGroup
+            self.onReorderApps = onReorderApps
             self.onGroupActivate = onGroupActivate
             self.onScrollActivity = onScrollActivity
             self.onDragModeChange = onDragModeChange
@@ -228,6 +239,78 @@
             return true
         }
 
+        fileprivate func beginAppIconDrag(path: String, sourceContainerID: String) {
+            activeDragPath = path
+            activeDragSourceContainerID = sourceContainerID
+            lastReorderContainerID = ""
+            lastReorderScreenFrame = nil
+            clearReorderInsertion()
+            onDragModeChange(true)
+        }
+
+        fileprivate func endAppIconDrag() {
+            clearReorderInsertion()
+            activeDragPath = ""
+            activeDragSourceContainerID = ""
+            lastReorderContainerID = ""
+            lastReorderScreenFrame = nil
+            onDragModeChange(false)
+        }
+
+        fileprivate func cancelAppIconDrag() {
+            clearReorderInsertion()
+            activeDragPath = ""
+            activeDragSourceContainerID = ""
+            lastReorderContainerID = ""
+            lastReorderScreenFrame = nil
+            onDragModeChange(false)
+        }
+
+        fileprivate func setReorderInsertion(card: AppGridGroupCardView, insertionIndex: Int) {
+            if let activeReorderCard, activeReorderCard !== card {
+                activeReorderCard.clearReorderInsertion()
+            }
+            activeReorderCard = card
+            lastReorderContainerID = card.containerID
+            lastReorderScreenFrame = card.screenFrame()
+            card.setReorderInsertion(index: insertionIndex)
+        }
+
+        fileprivate func clearReorderInsertion(card: AppGridGroupCardView? = nil) {
+            if let card {
+                card.clearReorderInsertion()
+                if activeReorderCard === card {
+                    activeReorderCard = nil
+                }
+                return
+            }
+            activeReorderCard?.clearReorderInsertion()
+            activeReorderCard = nil
+        }
+
+        fileprivate func activeReorderPath(in containerID: String, copy: Bool) -> String? {
+            guard !copy,
+                  !activeDragPath.isEmpty,
+                  activeDragSourceContainerID == containerID
+            else { return nil }
+            return activeDragPath
+        }
+
+        fileprivate func shouldCancelEmptyDropForActiveReorder(
+            path: String,
+            screenPoint: NSPoint,
+            copy: Bool
+        ) -> Bool {
+            guard !copy,
+                  !activeDragPath.isEmpty,
+                  activeDragPath == path,
+                  activeDragSourceContainerID == lastReorderContainerID,
+                  let lastReorderScreenFrame
+            else { return false }
+
+            return lastReorderScreenFrame.insetBy(dx: -44, dy: -44).contains(screenPoint)
+        }
+
         private static func signature(
             tagColors: [String: Int],
             displayMode: String,
@@ -291,6 +374,7 @@
             NotificationCenter.default.removeObserver(scrollObserver)
         }
         scrollUnfreezeWorkItem?.cancel()
+        coordinator?.cancelAppIconDrag()
         AppDragCoordinator.shared.unregisterEmptyDropTarget(id: emptyDropTargetID)
     }
 
@@ -337,6 +421,8 @@
     override func viewDidMoveToWindow() {
         super.viewDidMoveToWindow()
         if window == nil {
+            AppDragCoordinator.shared.cancelDrag()
+            coordinator?.cancelAppIconDrag()
             AppDragCoordinator.shared.unregisterEmptyDropTarget(id: emptyDropTargetID)
         } else {
             AppDragCoordinator.shared.registerEmptyDropTarget(id: emptyDropTargetID, view: self)
@@ -347,6 +433,14 @@
         guard let coordinator,
               coordinator.displayStyle != .flat
         else { return }
+        if coordinator.shouldCancelEmptyDropForActiveReorder(
+            path: path,
+            screenPoint: screenPoint,
+            copy: copy
+        ) {
+            coordinator.cancelAppIconDrag()
+            return
+        }
         DispatchQueue.main.async {
             coordinator.onDropOutsideGroup(path, source, copy)
         }
@@ -386,6 +480,11 @@
     }
 
     private func handleScrollActivity() {
+        if AppDragCoordinator.shared.hasActiveDrag {
+            AppDragCoordinator.shared.cancelDrag()
+        }
+        coordinator?.cancelAppIconDrag()
+
         if !scrollActivityIsActive {
             scrollActivityIsActive = true
             if coordinator?.setScrollBubbleDisabled(true) == true {
@@ -844,9 +943,11 @@
     private weak var coordinator: AppGridCollectionView.Coordinator?
     private var group: TagGroup?
     private var iconViews: [AppGridIconNSView] = []
+    private var reorderInsertionIndex: Int?
     private var isMouseInside = false
     private var isHovered = false
     private var trackingAreaRef: NSTrackingArea?
+    var containerID: String { group?.containerID ?? "" }
 
     override var isFlipped: Bool { true }
 
@@ -878,6 +979,7 @@
         AppDragCoordinator.shared.unregister(id: dropTargetID)
         group = nil
         coordinator = nil
+        reorderInsertionIndex = nil
         isMouseInside = false
         isHovered = false
         layer?.shadowOpacity = 0
@@ -982,19 +1084,71 @@
         }
 
         drawHeader(title: group.name, displayStyle: displayStyle)
+        drawReorderInsertionIfNeeded()
     }
 
     func performDrop(path: String, source: String, copy: Bool) {
+        performDrop(path: path, source: source, sourceContainerID: "", copy: copy)
+    }
+
+    func performDrop(path: String, source: String, sourceContainerID: String, copy: Bool) {
         guard let group else { return }
+        if sourceContainerID == group.containerID {
+            if !copy,
+               let orderedPaths = reorderedAppPaths(moving: path) {
+                coordinator?.onReorderApps(group.containerID, orderedPaths)
+            }
+            coordinator?.endAppIconDrag()
+            clearReorderInsertion()
+            return
+        }
+
         coordinator?.onDropApp(path, source, group.name, copy)
         if source != group.name || copy {
             coordinator?.onDragModeChange(false)
         }
     }
 
+    func appDragHoverChanged(active: Bool) {
+        if !active {
+            coordinator?.clearReorderInsertion(card: self)
+        }
+    }
+
+    func appDragLocationChanged(screenPoint: NSPoint, copy: Bool) {
+        guard let coordinator,
+              let group,
+              coordinator.activeReorderPath(in: group.containerID, copy: copy) != nil,
+              let window
+        else {
+            coordinator?.clearReorderInsertion(card: self)
+            return
+        }
+
+        let windowPoint = window.convertPoint(fromScreen: screenPoint)
+        let localPoint = convert(windowPoint, from: nil)
+        coordinator.setReorderInsertion(
+            card: self,
+            insertionIndex: insertionIndex(for: localPoint)
+        )
+    }
+
     func replayPointerHover(windowPoint: NSPoint) {
         setPointerInside(bounds.contains(convert(windowPoint, from: nil)))
         iconViews.forEach { $0.replayPointerHover(windowPoint: windowPoint) }
+    }
+
+    func setReorderInsertion(index: Int) {
+        let clamped = min(max(0, index), iconViews.count)
+        guard reorderInsertionIndex != clamped else { return }
+        reorderInsertionIndex = clamped
+        needsDisplay = true
+    }
+
+    func clearReorderInsertion() {
+        guard reorderInsertionIndex != nil else { return }
+        reorderInsertionIndex = nil
+        needsDisplay = true
     }
 
     private func rebuildIconViews() {
@@ -1011,6 +1165,7 @@
             iconView.configure(
                 app: app,
                 sourceTag: group.name,
+                sourceContainerID: group.containerID,
                 iconSize: coordinator.iconSize,
                 showName: coordinator.showNames,
                 coordinator: coordinator
@@ -1018,6 +1173,100 @@
             addSubview(iconView)
             return iconView
         }
+    }
+
+    private func insertionIndex(for point: NSPoint) -> Int {
+        guard !iconViews.isEmpty else { return 0 }
+
+        var bestIndex = iconViews.count
+        var bestDistance = CGFloat.greatestFiniteMagnitude
+
+        for index in 0...iconViews.count {
+            guard let candidate = insertionCandidatePoint(for: index) else { continue }
+            let dx = point.x - candidate.x
+            let dy = point.y - candidate.y
+            let distance = dx * dx + dy * dy
+            if distance < bestDistance {
+                bestDistance = distance
+                bestIndex = index
+            }
+        }
+
+        return bestIndex
+    }
+
+    private func reorderedAppPaths(moving path: String) -> [String]? {
+        guard let group,
+              let insertionIndex = reorderInsertionIndex
+        else { return nil }
+
+        var paths = group.apps.map { $0.path.path }
+        guard let sourceIndex = paths.firstIndex(of: path) else { return nil }
+
+        paths.remove(at: sourceIndex)
+        var targetIndex = insertionIndex
+        if sourceIndex < insertionIndex {
+            targetIndex -= 1
+        }
+        targetIndex = min(max(0, targetIndex), paths.count)
+        paths.insert(path, at: targetIndex)
+
+        let originalPaths = group.apps.map { $0.path.path }
+        guard paths != originalPaths else { return nil }
+        return paths
+    }
+
+    private func drawReorderInsertionIfNeeded() {
+        guard let insertionIndex = reorderInsertionIndex,
+              let rect = insertionLineRect(for: insertionIndex)
+        else { return }
+
+        NSColor.controlAccentColor.withAlphaComponent(0.92).setFill()
+        let path = NSBezierPath(roundedRect: rect, xRadius: 2, yRadius: 2)
+        path.fill()
+    }
+
+    private func insertionLineRect(for index: Int) -> NSRect? {
+        guard let candidate = insertionCandidatePoint(for: index) else { return nil }
+        let referenceFrame: NSRect
+        if iconViews.indices.contains(index) {
+            referenceFrame = iconViews[index].frame
+        } else if let lastFrame = iconViews.last?.frame {
+            referenceFrame = lastFrame
+        } else {
+            return nil
+        }
+
+        let iconSize = coordinator?.iconSize ?? CGFloat(AppDefaults.iconSize)
+        let lineHeight = max(28, min(referenceFrame.height - 8, iconSize + 18))
+        return NSRect(
+            x: candidate.x - 2,
+            y: referenceFrame.midY - lineHeight / 2,
+            width: 4,
+            height: lineHeight
+        )
+    }
+
+    private func insertionCandidatePoint(for index: Int) -> NSPoint? {
+        guard !iconViews.isEmpty else { return nil }
+
+        if index <= 0 {
+            let first = iconViews[0].frame
+            return NSPoint(x: first.minX - 3, y: first.midY)
+        }
+
+        if index >= iconViews.count {
+            let last = iconViews[iconViews.count - 1].frame
+            return NSPoint(x: last.maxX + 3, y: last.midY)
+        }
+
+        let previous = iconViews[index - 1].frame
+        let next = iconViews[index].frame
+        if abs(previous.midY - next.midY) < 4 {
+            return NSPoint(x: (previous.maxX + next.minX) / 2, y: previous.midY)
+        }
+
+        return NSPoint(x: next.minX - 3, y: next.midY)
     }
 
     private func registerDropTargetIfNeeded() {
@@ -1142,6 +1391,7 @@
     private weak var coordinator: AppGridCollectionView.Coordinator?
     private var app: AppInfo?
     private var sourceTag = ""
+    private var sourceContainerID = ""
     private var iconSize: CGFloat = AppDefaults.iconSize
     private var showName = true
     private var isMouseInside = false
@@ -1166,12 +1416,14 @@
     func configure(
         app: AppInfo,
         sourceTag: String,
+        sourceContainerID: String,
         iconSize: CGFloat,
         showName: Bool,
         coordinator: AppGridCollectionView.Coordinator
     ) {
         self.app = app
         self.sourceTag = sourceTag
+        self.sourceContainerID = sourceContainerID
         self.iconSize = iconSize
         self.showName = showName
         self.coordinator = coordinator
@@ -1191,6 +1443,7 @@
         mouseDownEvent = nil
         didStartDrag = false
         isLongPressActive = false
+        sourceContainerID = ""
         longPressWorkItem = nil
     }
 
@@ -1293,6 +1546,12 @@
 
         didStartDrag = true
         longPressWorkItem?.cancel()
+        if let app {
+            coordinator?.beginAppIconDrag(
+                path: app.path.path,
+                sourceContainerID: sourceContainerID
+            )
+        }
         AppDragCoordinator.shared.beginDrag(
             image: makeDragImage(),
             payload: dragPayload,
@@ -1309,11 +1568,11 @@
                 at: screenPoint(for: event),
                 copy: event.modifierFlags.contains(.option)
             )
-            coordinator?.onDragModeChange(false)
+            coordinator?.endAppIconDrag()
         } else if !isLongPressActive, let app {
             coordinator?.onSelectApp(app)
         } else {
-            coordinator?.onDragModeChange(false)
+            coordinator?.cancelAppIconDrag()
         }
         didStartDrag = false
         isLongPressActive = false
@@ -1355,7 +1614,7 @@
 
     private var dragPayload: String {
         guard let app else { return "" }
-        return "\(app.path.path)\n\(sourceTag)"
+        return "\(app.path.path)\n\(sourceTag)\n\(sourceContainerID)"
     }
 
     private func setHover(_ hover: Bool, notify: Bool) {
diff --git a/src/Apptag/AppLibraryController.swift b/src/Apptag/AppLibraryController.swift
index d8ebbd3..2a2136a 100644
--- a/src/Apptag/AppLibraryController.swift
+++ b/src/Apptag/AppLibraryController.swift
@@ -5,6 +5,8 @@
     let quickSearchDocuments: [QuickSearchDocument]
     let tagColors: [String: Int]
     let tagOrder: [String]
+    let tagDefinitions: [String: TagDatabase.TagDef]
+    let containerAppOrder: [String: [String]]
 }
 
 struct AppLibraryRefreshResult {
@@ -77,7 +79,13 @@
             apps: apps,
             quickSearchDocuments: QuickSearchEngine.makeDocuments(apps: apps, store: store),
             tagColors: store.tags.mapValues { $0.color },
-            tagOrder: TagEditor.orderedTagNames()
+            tagOrder: TagEditor.orderedTagNames(in: store),
+            tagDefinitions: store.tags,
+            containerAppOrder: TagDatabase.normalizedContainerAppOrder(
+                store.containerAppOrder,
+                tags: store.tags,
+                appTags: store.appTags
+            )
         )
     }
 }
diff --git a/src/Apptag/ApptagApp.swift b/src/Apptag/ApptagApp.swift
index c88dc4a..beb61cb 100644
--- a/src/Apptag/ApptagApp.swift
+++ b/src/Apptag/ApptagApp.swift
@@ -251,12 +251,15 @@
             .combinedSessionState,
             eventType: .leftMouseUp
         )
-        let recentMouseClick = min(lastMouseDown, lastMouseUp) < 1.2
+        let recentMouseClick = min(lastMouseDown, lastMouseUp) < 0.9
         return recentMouseClick && isPointerNearDockArea()
     }
 
     private func isPointerNearDockArea() -> Bool {
         let mouse = NSEvent.mouseLocation
+        let dockOrientation = UserDefaults(suiteName: "com.apple.dock")?
+            .string(forKey: "orientation") ?? "bottom"
+        let hiddenDockEdgeTolerance: CGFloat = 96
         return NSScreen.screens.contains { screen in
             let frame = screen.frame
             let visible = screen.visibleFrame
@@ -270,7 +273,16 @@
             let rightDock = visible.maxX < frame.maxX
                 && mouse.x <= frame.maxX
                 && mouse.x >= visible.maxX - 24
-            return bottomDock || leftDock || rightDock
+            let hiddenDockFallback: Bool
+            switch dockOrientation {
+            case "left":
+                hiddenDockFallback = mouse.x <= frame.minX + hiddenDockEdgeTolerance
+            case "right":
+                hiddenDockFallback = mouse.x >= frame.maxX - hiddenDockEdgeTolerance
+            default:
+                hiddenDockFallback = mouse.y <= frame.minY + hiddenDockEdgeTolerance
+            }
+            return bottomDock || leftDock || rightDock || hiddenDockFallback
         }
     }
 
diff --git a/src/Apptag/ContentView.swift b/src/Apptag/ContentView.swift
index 231ed55..1afa3e3 100644
--- a/src/Apptag/ContentView.swift
+++ b/src/Apptag/ContentView.swift
@@ -348,6 +348,8 @@
     @State private var allApps: [AppInfo] = []
     @State private var displayGroups: [TagGroup] = []
     @State private var tagColors: [String: Int] = [:]
+    @State private var tagDefinitions: [String: TagDatabase.TagDef] = [:]
+    @State private var containerAppOrder: [String: [String]] = [:]
     @State private var groupLayoutVersion = 0
     @State private var appGridScrollTargetID: String? = nil
     @State private var appGridScrollRequestToken = 0
@@ -1131,6 +1133,9 @@
                     onDropOutsideGroup: { path, source, copy in
                         dropAppOutsideGroup(path: path, sourceTag: source, copy: copy)
                     },
+                    onReorderApps: { containerID, orderedPaths in
+                        reorderApps(inContainer: containerID, orderedPaths: orderedPaths)
+                    },
                     onGroupActivate: { groupName in
                         if isColorlessContainerMode {
                             toggleColorlessFill(groupName)
@@ -1778,13 +1783,27 @@
 
     private func makeDisplayGroups(apps: [AppInfo], tagOrder: [String]) -> [TagGroup] {
         let order = tagOrder.isEmpty ? TagEditor.orderedTagNames() : tagOrder
-        let rawGroups = AppIndexer.group(apps: apps, defaultGroupName: defaultGroupName, tagOrder: order)
+        let rawGroups = AppIndexer.group(
+            apps: apps,
+            defaultGroupName: defaultGroupName,
+            tagOrder: order,
+            tagDefinitions: tagDefinitions,
+            containerAppOrder: containerAppOrder
+        )
         return rawGroups.map { group in
             if group.name == defaultGroupName {
-                return TagGroup(name: tr("group.uncategorized"), apps: group.apps)
+                return TagGroup(
+                    containerID: group.containerID,
+                    name: tr("group.uncategorized"),
+                    apps: group.apps
+                )
             }
             if group.name == "Mac自带" {
-                return TagGroup(name: tr("group.appleBuiltIn"), apps: group.apps)
+                return TagGroup(
+                    containerID: group.containerID,
+                    name: tr("group.appleBuiltIn"),
+                    apps: group.apps
+                )
             }
             return group
         }
@@ -2126,6 +2145,8 @@
     private func dropApp(path: String, sourceTag: String, targetTag: String, copy: Bool) {
         resetTransientDragState(keepingPendingUncategorizedDrop: true)
 
+        guard sourceTag != targetTag else { return }
+
         if isUncategorizedDropTarget(targetTag) {
             confirmAndMoveAppToUncategorized(path: path)
             return
@@ -2137,7 +2158,6 @@
         }
 
         guard tagColors[targetTag] != nil else { return }
-        guard sourceTag != targetTag || copy else { return }
         TagEditor.moveApp(
             path: path,
             from: sourceTag,
@@ -2156,6 +2176,18 @@
         guard !appHasTag(app, tagName: targetTag) else { return }
         TagEditor.appendTags([targetTag], to: [path])
         showDropRefresh()
+        refreshApps(forceLayoutRefresh: true)
+    }
+
+    private func reorderApps(inContainer containerID: String, orderedPaths: [String]) {
+        resetTransientDragState()
+        let key = TagDatabase.normalizedContainerID(containerID, tags: tagDefinitions)
+        let paths = TagDatabase.normalizedAppOrderPaths(orderedPaths)
+        guard !key.isEmpty, !paths.isEmpty else { return }
+
+        containerAppOrder[key] = paths
+        rebuildDisplayGroups(apps: allApps, tagOrder: draggedTagNames)
+        TagEditor.reorderApps(inContainer: key, orderedPaths: paths)
         refreshApps(forceLayoutRefresh: true)
     }
 
@@ -2462,6 +2494,8 @@
         allApps = snapshot.apps
         quickSearchDocuments = snapshot.quickSearchDocuments
         tagColors = snapshot.tagColors
+        tagDefinitions = snapshot.tagDefinitions
+        containerAppOrder = snapshot.containerAppOrder
         draggedTagNames = snapshot.tagOrder
         rebuildDisplayGroups(apps: snapshot.apps, tagOrder: snapshot.tagOrder)
         if quickSearchVisible {
diff --git a/src/Apptag/DataLayer.swift b/src/Apptag/DataLayer.swift
index 1cc92f4..9b88064 100644
--- a/src/Apptag/DataLayer.swift
+++ b/src/Apptag/DataLayer.swift
@@ -41,6 +41,44 @@
     static func == (lhs: AppInfo, rhs: AppInfo) -> Bool { lhs.path == rhs.path }
 }
 
+enum AppContainerID {
+    static let uncategorized = "__container.uncategorized"
+    static let appleBuiltIn = "__container.appleBuiltIn"
+
+    static func tag(_ name: String) -> String {
+        "tag:\(name)"
+    }
+
+    static func system(_ id: SmartCategoryID) -> String {
+        "system:\(id.rawValue)"
+    }
+
+    static func forTag(_ name: String, definition: TagDatabase.TagDef?) -> String {
+        if let categoryID = definition?.systemCategoryID {
+            return system(categoryID)
+        }
+        return tag(name)
+    }
+
+    static func forLegacyGroupName(_ name: String) -> String {
+        if isUncategorizedDisplayName(name) {
+            return uncategorized
+        }
+        if isAppleBuiltInDisplayName(name) {
+            return appleBuiltIn
+        }
+        return tag(name)
+    }
+
+    private static func isUncategorizedDisplayName(_ name: String) -> Bool {
+        name == "Other" || name == tr("group.uncategorized")
+    }
+
+    private static func isAppleBuiltInDisplayName(_ name: String) -> Bool {
+        name == "Mac自带" || name == tr("group.appleBuiltIn")
+    }
+}
+
 enum AppDisplayNameResolver {
     static func displayName(for app: AppInfo, languageCode: String) -> String {
         let languageFallbacks = displayLanguageFallbacks(for: languageCode, includeEnglish: false)
@@ -177,9 +215,24 @@
 }
 
 struct TagGroup: Identifiable {
-    var id: String { name }
+    var id: String { containerID }
+    let containerID: String
     let name: String
     let apps: [AppInfo]
+
+    init(containerID: String, name: String, apps: [AppInfo]) {
+        self.containerID = containerID
+        self.name = name
+        self.apps = apps
+    }
+
+    init(name: String, apps: [AppInfo]) {
+        self.init(
+            containerID: AppContainerID.forLegacyGroupName(name),
+            name: name,
+            apps: apps
+        )
+    }
 }
 
 // MARK: - Tag Color Mapping
@@ -705,26 +758,65 @@
         apps: [AppInfo],
         nameOverrides: [String: String] = [:],
         defaultGroupName: String = "Other",
-        tagOrder: [String] = []
+        tagOrder: [String] = [],
+        tagDefinitions: [String: TagDatabase.TagDef]? = nil,
+        containerAppOrder: [String: [String]]? = nil
     ) -> [TagGroup] {
         let macCategory = "Mac自带"
+        let loadedStore = (tagDefinitions == nil || containerAppOrder == nil) ? TagDatabase.load() : nil
+        let effectiveTagDefinitions = tagDefinitions ?? loadedStore?.tags ?? [:]
+        var appTagsByPath: [String: [String]] = [:]
+        var validAppPaths = Set<String>()
+        for app in apps {
+            let path = app.path.path
+            validAppPaths.insert(path)
+            appTagsByPath[path] = uniqueOrdered((appTagsByPath[path] ?? []) + app.tags)
+        }
+        let effectiveContainerAppOrder = TagDatabase.normalizedContainerAppOrder(
+            containerAppOrder ?? loadedStore?.containerAppOrder ?? [:],
+            tags: effectiveTagDefinitions,
+            appTags: appTagsByPath,
+            validAppPaths: validAppPaths
+        )
         var dict: [String: [AppInfo]] = [:]
+        var containerIDsByGroupName: [String: String] = [:]
         var seenAppIDsByGroup: [String: Set<URL>] = [:]
 
         for app in apps {
             if app.isAppleApp {
-                appendGroupedApp(app, to: macCategory, groups: &dict, seenAppIDsByGroup: &seenAppIDsByGroup)
+                appendGroupedApp(
+                    app,
+                    to: macCategory,
+                    containerID: AppContainerID.appleBuiltIn,
+                    groups: &dict,
+                    containerIDsByGroupName: &containerIDsByGroupName,
+                    seenAppIDsByGroup: &seenAppIDsByGroup
+                )
             }
 
             if app.tags.isEmpty {
                 if !app.isAppleApp {
-                    appendGroupedApp(app, to: defaultGroupName, groups: &dict, seenAppIDsByGroup: &seenAppIDsByGroup)
+                    appendGroupedApp(
+                        app,
+                        to: defaultGroupName,
+                        containerID: AppContainerID.uncategorized,
+                        groups: &dict,
+                        containerIDsByGroupName: &containerIDsByGroupName,
+                        seenAppIDsByGroup: &seenAppIDsByGroup
+                    )
                 }
             } else {
                 for tag in uniqueOrdered(app.tags) {
                     let displayName = nameOverrides[tag] ?? tag
                     guard displayName != macCategory else { continue }
-                    appendGroupedApp(app, to: displayName, groups: &dict, seenAppIDsByGroup: &seenAppIDsByGroup)
+                    appendGroupedApp(
+                        app,
+                        to: displayName,
+                        containerID: AppContainerID.forTag(tag, definition: effectiveTagDefinitions[tag]),
+                        groups: &dict,
+                        containerIDsByGroupName: &containerIDsByGroupName,
+                        seenAppIDsByGroup: &seenAppIDsByGroup
+                    )
                 }
             }
         }
@@ -747,16 +839,60 @@
                 if li != ri { return li < ri }
                 return lhs.key.localizedStandardCompare(rhs.key) == .orderedAscending
             }
-            .map { TagGroup(name: $0.key, apps: $0.value) }
+            .map { groupName, apps in
+                let containerID = containerIDsByGroupName[groupName] ?? AppContainerID.forLegacyGroupName(groupName)
+                return TagGroup(
+                    containerID: containerID,
+                    name: groupName,
+                    apps: orderedApps(
+                        apps,
+                        inContainer: containerID,
+                        containerAppOrder: effectiveContainerAppOrder
+                    )
+                )
+            }
+    }
+
+    static func orderedApps(
+        _ apps: [AppInfo],
+        inContainer containerID: String,
+        containerAppOrder: [String: [String]]
+    ) -> [AppInfo] {
+        applyAppOrder(
+            apps: apps,
+            order: containerAppOrder[containerID] ?? []
+        )
+    }
+
+    static func applyAppOrder(apps: [AppInfo], order: [String]) -> [AppInfo] {
+        let normalizedOrder = TagDatabase.normalizedAppOrderPaths(order)
+        guard !apps.isEmpty else { return [] }
+
+        var appsByPath: [String: AppInfo] = [:]
+        for app in apps where appsByPath[app.path.path] == nil {
+            appsByPath[app.path.path] = app
+        }
+
+        let ordered = normalizedOrder.compactMap { appsByPath[$0] }
+        let orderedPaths = Set(ordered.map { $0.path.path })
+        let remaining = apps
+            .filter { !orderedPaths.contains($0.path.path) }
+            .sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }
+        return ordered + remaining
     }
 
     private static func appendGroupedApp(
         _ app: AppInfo,
         to groupName: String,
+        containerID: String,
         groups: inout [String: [AppInfo]],
+        containerIDsByGroupName: inout [String: String],
         seenAppIDsByGroup: inout [String: Set<URL>]
     ) {
         if seenAppIDsByGroup[groupName, default: []].insert(app.id).inserted {
+            if containerIDsByGroupName[groupName] == nil {
+                containerIDsByGroupName[groupName] = containerID
+            }
             groups[groupName, default: []].append(app)
         }
     }
@@ -802,6 +938,7 @@
         var tags: [String: TagDef] = [:]
         var appTags: [String: [String]] = [:]  // path → tag names
         var tagOrder: [String] = []  // display order; empty → alpha sort
+        var containerAppOrder: [String: [String]] = [:]  // stable container ID → ordered app paths
         var uncommonAppPaths: [String] = []  // special marker; does not affect normal groups
         var uncommonSources: [String: UncommonSource] = [:]  // current uncommon source: auto/manual
         var appOpenCounts: [String: Int] = [:]  // launches opened from TagLauncher
@@ -818,6 +955,7 @@
             case tags
             case appTags
             case tagOrder
+            case containerAppOrder
             case uncommonAppPaths
             case uncommonSources
             case appOpenCounts
@@ -838,6 +976,10 @@
             tags = try container.decodeIfPresent([String: TagDef].self, forKey: .tags) ?? [:]
             appTags = try container.decodeIfPresent([String: [String]].self, forKey: .appTags) ?? [:]
             tagOrder = try container.decodeIfPresent([String].self, forKey: .tagOrder) ?? []
+            containerAppOrder = try container.decodeIfPresent(
+                [String: [String]].self,
+                forKey: .containerAppOrder
+            ) ?? [:]
             uncommonAppPaths = try container.decodeIfPresent([String].self, forKey: .uncommonAppPaths) ?? []
             uncommonSources = try container.decodeIfPresent([String: UncommonSource].self, forKey: .uncommonSources) ?? [:]
             appOpenCounts = try container.decodeIfPresent([String: Int].self, forKey: .appOpenCounts) ?? [:]
@@ -858,6 +1000,11 @@
             for path in uncommonAppPaths where uncommonSources[path] == nil {
                 uncommonSources[path] = .manual
             }
+            containerAppOrder = TagDatabase.normalizedContainerAppOrder(
+                containerAppOrder,
+                tags: tags,
+                appTags: appTags
+            )
         }
 
         var hasUserTagAssignments: Bool {
@@ -911,8 +1058,11 @@
 
     static func load() -> Store {
         guard let data = try? Data(contentsOf: storeURL),
-              let store = try? JSONDecoder().decode(Store.self, from: data)
+              var store = try? JSONDecoder().decode(Store.self, from: data)
         else { return Store() }
+        if normalizeContainerAppOrder(in: &store) {
+            save(store)
+        }
         return store
     }
 
@@ -925,10 +1075,156 @@
     }
 
     static func save(_ store: Store) {
+        var normalizedStore = store
+        _ = normalizeContainerAppOrder(in: &normalizedStore)
         let encoder = JSONEncoder()
         encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
-        guard let data = try? encoder.encode(store) else { return }
+        guard let data = try? encoder.encode(normalizedStore) else { return }
         try? data.write(to: storeURL, options: .atomic)
+    }
+
+    static func normalizedAppOrderPaths(_ paths: [String]) -> [String] {
+        var seen = Set<String>()
+        return paths.compactMap { rawPath in
+            let path = rawPath.trimmingCharacters(in: .whitespacesAndNewlines)
+            guard !path.isEmpty, seen.insert(path).inserted else { return nil }
+            return path
+        }
+    }
+
+    static func normalizedContainerID(_ rawValue: String, tags: [String: TagDef] = [:]) -> String {
+        let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !value.isEmpty else { return "" }
+        if value == AppContainerID.uncategorized
+            || value == AppContainerID.appleBuiltIn
+            || value.hasPrefix("tag:")
+            || value.hasPrefix("system:") {
+            return value
+        }
+        if value == "Other" || value == tr("group.uncategorized") {
+            return AppContainerID.uncategorized
+        }
+        if value == "Mac自带" || value == tr("group.appleBuiltIn") {
+            return AppContainerID.appleBuiltIn
+        }
+        return AppContainerID.forTag(value, definition: tags[value])
+    }
+
+    static func normalizedContainerAppOrder(
+        _ order: [String: [String]],
+        tags: [String: TagDef] = [:],
+        appTags: [String: [String]]? = nil,
+        validAppPaths: Set<String>? = nil
+    ) -> [String: [String]] {
+        var normalized: [String: [String]] = [:]
+        for (rawContainerID, rawPaths) in order {
+            let containerID = normalizedContainerID(rawContainerID, tags: tags)
+            guard !containerID.isEmpty,
+                  isContainerOrderAllowed(containerID, tags: tags)
+            else { continue }
+
+            var paths = normalizedAppOrderPaths(rawPaths)
+            if let validAppPaths {
+                paths = paths.filter { validAppPaths.contains($0) }
+            }
+            if let appTags {
+                paths = paths.filter {
+                    pathBelongsToContainer(
+                        path: $0,
+                        containerID: containerID,
+                        tags: tags,
+                        appTags: appTags
+                    )
+                }
+            }
+            guard !paths.isEmpty else { continue }
+
+            if let existing = normalized[containerID] {
+                normalized[containerID] = normalizedAppOrderPaths(existing + paths)
+            } else {
+                normalized[containerID] = paths
+            }
+        }
+        return normalized
+    }
+
+    @discardableResult
+    static func normalizeContainerAppOrder(
+        in store: inout Store,
+        validAppPaths: Set<String>? = nil
+    ) -> Bool {
+        let original = store.containerAppOrder
+        store.containerAppOrder = normalizedContainerAppOrder(
+            store.containerAppOrder,
+            tags: store.tags,
+            appTags: store.appTags,
+            validAppPaths: validAppPaths
+        )
+        return original != store.containerAppOrder
+    }
+
+    static func migrateContainerAppOrderKey(
+        in store: inout Store,
+        from oldContainerID: String,
+        to newContainerID: String
+    ) {
+        guard oldContainerID != newContainerID else { return }
+        let oldKey = normalizedContainerID(oldContainerID, tags: store.tags)
+        let newKey = normalizedContainerID(newContainerID, tags: store.tags)
+        guard !oldKey.isEmpty, !newKey.isEmpty,
+              let oldOrder = store.containerAppOrder.removeValue(forKey: oldKey)
+        else { return }
+
+        let mergedOrder = (store.containerAppOrder[newKey] ?? []) + oldOrder
+        store.containerAppOrder[newKey] = normalizedAppOrderPaths(mergedOrder)
+    }
+
+    static func removeContainerAppOrder(
+        in store: inout Store,
+        containerID: String
+    ) {
+        let key = normalizedContainerID(containerID, tags: store.tags)
+        guard !key.isEmpty else { return }
+        store.containerAppOrder.removeValue(forKey: key)
+    }
+
+    private static func isContainerOrderAllowed(_ containerID: String, tags: [String: TagDef]) -> Bool {
+        if containerID == AppContainerID.uncategorized || containerID == AppContainerID.appleBuiltIn {
+            return true
+        }
+        if containerID.hasPrefix("tag:") {
+            let tagName = String(containerID.dropFirst("tag:".count))
+            return tags[tagName] != nil
+        }
+        if containerID.hasPrefix("system:") {
+            let rawValue = String(containerID.dropFirst("system:".count))
+            guard let categoryID = SmartCategoryID(rawValue: rawValue) else { return false }
+            return tags.values.contains { $0.systemCategoryID == categoryID }
+        }
+        return false
+    }
+
+    private static func pathBelongsToContainer(
+        path: String,
+        containerID: String,
+        tags: [String: TagDef],
+        appTags: [String: [String]]
+    ) -> Bool {
+        if containerID == AppContainerID.uncategorized || containerID == AppContainerID.appleBuiltIn {
+            return true
+        }
+        if containerID.hasPrefix("tag:") {
+            let tagName = String(containerID.dropFirst("tag:".count))
+            return appTags[path]?.contains(tagName) == true
+        }
+        if containerID.hasPrefix("system:") {
+            let rawValue = String(containerID.dropFirst("system:".count))
+            guard let categoryID = SmartCategoryID(rawValue: rawValue),
+                  let tagName = tags.first(where: { $0.value.systemCategoryID == categoryID })?.key
+            else { return false }
+            return appTags[path]?.contains(tagName) == true
+        }
+        return false
     }
 
     static func noteFingerprint(_ value: String) -> String {
@@ -969,6 +1265,7 @@
         let tags: [String: TagDef]
         let appTags: [String: [String]]
         let tagOrder: [String]
+        let containerAppOrder: [String: [String]]
         let uncommonAppPaths: [String]
         let uncommonSources: [String: UncommonSource]
         let disabledSystemCategoryIDs: [SmartCategoryID]
@@ -1085,6 +1382,11 @@
             tags: store.tags,
             appTags: store.appTags.mapValues(uniqueOrdered),
             tagOrder: uniqueOrdered(store.tagOrder),
+            containerAppOrder: normalizedContainerAppOrder(
+                store.containerAppOrder,
+                tags: store.tags,
+                appTags: store.appTags
+            ),
             uncommonAppPaths: store.uncommonAppPaths.sorted(),
             uncommonSources: store.uncommonSources,
             disabledSystemCategoryIDs: store.disabledSystemCategoryIDs
@@ -1192,6 +1494,8 @@
 
     private static func renameTagKey(in store: inout Store, from oldName: String, to newName: String) {
         guard oldName != newName, let tagDef = store.tags.removeValue(forKey: oldName) else { return }
+        let oldContainerID = AppContainerID.forTag(oldName, definition: tagDef)
+        let newContainerID = AppContainerID.forTag(newName, definition: tagDef)
         store.tags[newName] = tagDef
         store.tagOrder = uniqueOrdered(store.tagOrder.map { $0 == oldName ? newName : $0 })
 
@@ -1203,6 +1507,8 @@
                 store.appTags[path] = renamedTags
             }
         }
+        migrateContainerAppOrderKey(in: &store, from: oldContainerID, to: newContainerID)
+        _ = normalizeContainerAppOrder(in: &store)
     }
 
     private static func uniqueOrdered(_ values: [String]) -> [String] {
@@ -1239,6 +1545,7 @@
         let previousStore = loadWithEnsuredCategoryScheme()
         var store = previousStore
         store.appTags = [:]
+        store.containerAppOrder = [:]
         saveUserCategorySchemeMutation(
             store,
             previous: previousStore,
@@ -1446,6 +1753,10 @@
     /// Tag names in display order. Falls back to alpha sort if no custom order.
     static func orderedTagNames() -> [String] {
         let store = TagDatabase.load()
+        return orderedTagNames(in: store)
+    }
+
+    static func orderedTagNames(in store: TagDatabase.Store) -> [String] {
         let ordered = store.tagOrder.filter { store.tags[$0] != nil }
         let remaining = store.tags.keys.filter { !ordered.contains($0) }.sorted()
         return ordered + remaining
@@ -1460,6 +1771,31 @@
             store,
             previous: previousStore,
             reason: "reorder-tags"
+        )
+    }
+
+    /// Persist the visible app order for one stable container.
+    static func reorderApps(inContainer containerID: String, orderedPaths: [String]) {
+        var store = TagDatabase.load()
+        _ = TagDatabase.normalizeContainerAppOrder(in: &store)
+        let previousStore = store
+
+        let key = TagDatabase.normalizedContainerID(containerID, tags: store.tags)
+        guard !key.isEmpty else { return }
+
+        let normalizedPaths = TagDatabase.normalizedAppOrderPaths(orderedPaths)
+        if normalizedPaths.isEmpty {
+            store.containerAppOrder.removeValue(forKey: key)
+        } else {
+            store.containerAppOrder[key] = normalizedPaths
+        }
+        _ = TagDatabase.normalizeContainerAppOrder(in: &store)
+
+        guard store.containerAppOrder != previousStore.containerAppOrder else { return }
+        TagDatabase.saveUserCategorySchemeMutation(
+            store,
+            previous: previousStore,
+            reason: "reorder-apps"
         )
     }
 
@@ -1630,8 +1966,12 @@
         if store.knownAppPaths.isEmpty {
             let seededDefaultNotes = seedDefaultAppleAppNotes(for: apps, in: &store)
             let markedUncommon = markUnfamiliarAppleAppsAsUncommon(apps, in: &store)
+            let normalizedOrder = TagDatabase.normalizeContainerAppOrder(
+                in: &store,
+                validAppPaths: scannedPaths
+            )
             store.knownAppPaths = scannedPaths.sorted()
-            if apps.isEmpty == false || seededDefaultNotes || markedUncommon {
+            if apps.isEmpty == false || seededDefaultNotes || markedUncommon || normalizedOrder {
                 TagDatabase.save(store)
             }
             return store
@@ -1646,9 +1986,13 @@
             }
             store.knownAppPaths = knownPaths.subtracting(removedPaths).sorted()
         }
+        let normalizedOrder = TagDatabase.normalizeContainerAppOrder(
+            in: &store,
+            validAppPaths: scannedPaths
+        )
 
         guard !newPaths.isEmpty else {
-            if !removedPaths.isEmpty {
+            if !removedPaths.isEmpty || normalizedOrder {
                 TagDatabase.save(store)
             }
             return store
@@ -1673,6 +2017,10 @@
         _ = seedDefaultAppleAppNotes(for: newApps, in: &store)
         store.uncommonAppPaths = uncommonPaths.sorted()
         store.knownAppPaths = Set(store.knownAppPaths).union(newPaths).sorted()
+        _ = TagDatabase.normalizeContainerAppOrder(
+            in: &store,
+            validAppPaths: scannedPaths
+        )
         TagDatabase.save(store)
         return store
     }
@@ -1824,7 +2172,14 @@
         let previousStore = store
         // Update tag definition
         if let def = store.tags.removeValue(forKey: oldName) {
+            let oldContainerID = AppContainerID.forTag(oldName, definition: def)
+            let newContainerID = AppContainerID.forTag(newName, definition: def)
             store.tags[newName] = def
+            TagDatabase.migrateContainerAppOrderKey(
+                in: &store,
+                from: oldContainerID,
+                to: newContainerID
+            )
         }
         store.tagOrder = store.tagOrder.map { $0 == oldName ? newName : $0 }
         // Update all app assignments
@@ -1834,6 +2189,7 @@
                 store.appTags[path] = Array(NSOrderedSet(array: tags).compactMap { $0 as? String })
             }
         }
+        _ = TagDatabase.normalizeContainerAppOrder(in: &store)
         TagDatabase.saveUserCategorySchemeMutation(
             store,
             previous: previousStore,
@@ -1845,10 +2201,21 @@
     static func deleteTagCompletely(_ tag: String) {
         var store = TagDatabase.load()
         let previousStore = store
-        if let removedTag = store.tags.removeValue(forKey: tag),
-           let categoryID = removedTag.systemCategoryID,
+        let removedTag = store.tags.removeValue(forKey: tag)
+        if let categoryID = removedTag?.systemCategoryID,
            !store.disabledSystemCategoryIDs.contains(categoryID) {
             store.disabledSystemCategoryIDs.append(categoryID)
+        }
+        if let removedTag {
+            TagDatabase.removeContainerAppOrder(
+                in: &store,
+                containerID: AppContainerID.forTag(tag, definition: removedTag)
+            )
+        } else {
+            TagDatabase.removeContainerAppOrder(
+                in: &store,
+                containerID: AppContainerID.tag(tag)
+            )
         }
         store.tagOrder.removeAll { $0 == tag }
         for (path, var tags) in store.appTags {
@@ -1859,6 +2226,7 @@
                 store.appTags[path] = tags
             }
         }
+        _ = TagDatabase.normalizeContainerAppOrder(in: &store)
         TagDatabase.saveUserCategorySchemeMutation(
             store,
             previous: previousStore,
diff --git a/src/Apptag/Info.plist b/src/Apptag/Info.plist
index 63cf046..9c81ac0 100644
--- a/src/Apptag/Info.plist
+++ b/src/Apptag/Info.plist
@@ -19,9 +19,9 @@
 	<key>CFBundlePackageType</key>
 	<string>APPL</string>
 	<key>CFBundleShortVersionString</key>
-	<string>7.8.31</string>
+	<string>7.9.0</string>
 	<key>CFBundleVersion</key>
-	<string>20260611.1928</string>
+	<string>20260611.2212</string>
 	<key>LSApplicationCategoryType</key>
 	<string>public.app-category.utilities</string>
 	<key>LSMinimumSystemVersion</key>
diff --git a/src/Apptag/SmartCategorization/SmartStartService.swift b/src/Apptag/SmartCategorization/SmartStartService.swift
index 75a9474..d377f99 100644
--- a/src/Apptag/SmartCategorization/SmartStartService.swift
+++ b/src/Apptag/SmartCategorization/SmartStartService.swift
@@ -358,6 +358,7 @@
             store.tags = [:]
             store.appTags = [:]
             store.tagOrder = []
+            store.containerAppOrder = [:]
             store.disabledSystemCategoryIDs = []
         }
 
diff --git a/src/CHANGELOG.md b/src/CHANGELOG.md
index 8563237..9481667 100644
--- a/src/CHANGELOG.md
+++ b/src/CHANGELOG.md
@@ -1,5 +1,14 @@
 # TagLauncher Changelog
 
+## [7.9.0] — 2026-06-11
+
+- 新增容器内 App 手动拖拽排序:同一容器内拖动 App 可改变显示顺序,不改变 App 与标签的归属关系
+- 新增稳定容器排序数据 `containerAppOrder`,使用非本地化容器 ID 保存顺序;刷新、重启、语言切换、导入导出、恢复上一方案和 SmartStart 替换时按统一规则保留、迁移或清理
+- App Grid 拖拽链路增加同容器排序意图和插入位置提示;跨容器移动、Option 复制、拖空白移除、Apple 内置保护继续沿用原有逻辑
+- 容器排序纳入分类方案 fingerprint 和自动上一方案快照,排序变化会被备份/恢复体系识别
+- 新增 `Scripts/app_ordering_data_qa.sh`,覆盖排序 schema、导入导出、备份恢复、SmartStart 清理、ContentView/AppGrid wiring 和拖拽 payload 静态门禁
+- 版本号更新为 `7.9.0`,Build 更新为 `20260611.2212`
+
 ## [7.8.31] — 2026-06-11
 
 - 设置页 Data 面板按原型拆分为两个独立边框区域:“布局初始化”和“自定义布局”分开展示,避免重置初始化、恢复、导入导出混在同一块区域里
diff --git a/src/Scripts/app_ordering_data_qa.sh b/src/Scripts/app_ordering_data_qa.sh
new file mode 100755
index 0000000..a727ed6
--- /dev/null
+++ b/src/Scripts/app_ordering_data_qa.sh
@@ -0,0 +1,284 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+DATA_LAYER_SWIFT="$ROOT_DIR/Apptag/DataLayer.swift"
+SMART_START_SWIFT="$ROOT_DIR/Apptag/SmartCategorization/SmartStartService.swift"
+CONTENT_VIEW_SWIFT="$ROOT_DIR/Apptag/ContentView.swift"
+APP_GRID_SWIFT="$ROOT_DIR/Apptag/AppGridCollectionView.swift"
+APP_DRAG_SWIFT="$ROOT_DIR/Apptag/AppDragCoordinator.swift"
+
+fail() {
+  printf 'FAIL: %s\n' "$*" >&2
+  exit 1
+}
+
+[[ -f "$DATA_LAYER_SWIFT" ]] || fail "missing DataLayer.swift at $DATA_LAYER_SWIFT"
+[[ -f "$SMART_START_SWIFT" ]] || fail "missing SmartStartService.swift at $SMART_START_SWIFT"
+[[ -f "$CONTENT_VIEW_SWIFT" ]] || fail "missing ContentView.swift at $CONTENT_VIEW_SWIFT"
+[[ -f "$APP_GRID_SWIFT" ]] || fail "missing AppGridCollectionView.swift at $APP_GRID_SWIFT"
+[[ -f "$APP_DRAG_SWIFT" ]] || fail "missing AppDragCoordinator.swift at $APP_DRAG_SWIFT"
+
+python3 - "$DATA_LAYER_SWIFT" "$SMART_START_SWIFT" "$CONTENT_VIEW_SWIFT" "$APP_GRID_SWIFT" "$APP_DRAG_SWIFT" <<'PY'
+import json
+import pathlib
+import re
+import sys
+
+data_layer = pathlib.Path(sys.argv[1])
+smart_start = pathlib.Path(sys.argv[2])
+content_view = pathlib.Path(sys.argv[3])
+app_grid = pathlib.Path(sys.argv[4])
+app_drag = pathlib.Path(sys.argv[5])
+text = data_layer.read_text(encoding="utf-8")
+smart_start_text = smart_start.read_text(encoding="utf-8")
+content_text = content_view.read_text(encoding="utf-8")
+app_grid_text = app_grid.read_text(encoding="utf-8")
+app_drag_text = app_drag.read_text(encoding="utf-8")
+
+
+def fail(message: str) -> None:
+    print(f"FAIL: {message}", file=sys.stderr)
+    sys.exit(1)
+
+
+def require(pattern: str, message: str, source: str = text) -> None:
+    if not re.search(pattern, source, re.S):
+        fail(message)
+
+
+def extract_braced_block(marker_pattern: str, label: str, source: str = text) -> str:
+    match = re.search(marker_pattern, source)
+    if not match:
+        fail(f"missing {label}")
+
+    start = source.find("{", match.end())
+    if start == -1:
+        fail(f"missing opening brace for {label}")
+
+    depth = 0
+    for index in range(start, len(source)):
+        char = source[index]
+        if char == "{":
+            depth += 1
+        elif char == "}":
+            depth -= 1
+            if depth == 0:
+                return source[start + 1:index]
+
+    fail(f"missing closing brace for {label}")
+
+
+store_block = extract_braced_block(r"\bstruct\s+Store\s*:\s*Codable\b", "TagDatabase.Store")
+coding_keys_block = extract_braced_block(
+    r"\benum\s+CodingKeys\s*:\s*String\s*,\s*CodingKey\b",
+    "Store.CodingKeys",
+    store_block,
+)
+fingerprint_block = extract_braced_block(
+    r"\bprivate\s+struct\s+CategorySchemeFingerprint\s*:\s*Equatable\b",
+    "CategorySchemeFingerprint",
+)
+fingerprint_func = extract_braced_block(
+    r"\bprivate\s+static\s+func\s+categorySchemeFingerprint\s*\(\s*for\s+store\s*:\s*Store\s*\)\s*->\s*CategorySchemeFingerprint\b",
+    "categorySchemeFingerprint(for:)",
+)
+reorder_apps_func = extract_braced_block(
+    r"\bstatic\s+func\s+reorderApps\s*\(\s*inContainer\s+containerID\s*:\s*String\s*,\s*orderedPaths\s*:\s*\[String\]\s*\)",
+    "TagEditor.reorderApps(inContainer:orderedPaths:)",
+)
+reset_uncategorized_func = extract_braced_block(
+    r"\bstatic\s+func\s+resetAppTagAssignmentsToUncategorized\s*\(\s*\)\s*->\s*Store\b",
+    "resetAppTagAssignmentsToUncategorized()",
+)
+content_drop_app_func = extract_braced_block(
+    r"\bprivate\s+func\s+dropApp\s*\(\s*path\s*:\s*String\s*,\s*sourceTag\s*:\s*String\s*,\s*targetTag\s*:\s*String\s*,\s*copy\s*:\s*Bool\s*\)",
+    "ContentView.dropApp(path:sourceTag:targetTag:copy:)",
+    content_text,
+)
+app_grid_perform_drop_func = extract_braced_block(
+    r"\bfunc\s+performDrop\s*\(\s*path\s*:\s*String\s*,\s*source\s*:\s*String\s*,\s*sourceContainerID\s*:\s*String\s*,\s*copy\s*:\s*Bool\s*\)",
+    "AppGridGroupCardView.performDrop(path:source:sourceContainerID:copy:)",
+    app_grid_text,
+)
+
+require(
+    r"\bvar\s+containerAppOrder\s*:\s*\[String\s*:\s*\[String\]\]\s*=\s*\[:\]",
+    "Store must persist containerAppOrder as [String: [String]] with an empty default",
+    store_block,
+)
+require(
+    r"\bcase\s+containerAppOrder\b",
+    "Store.CodingKeys must include containerAppOrder for JSON roundtrip",
+    coding_keys_block,
+)
+require(
+    r"containerAppOrder\s*=\s*try\s+container\.decodeIfPresent\(\s*\[String\s*:\s*\[String\]\]\.self\s*,\s*forKey\s*:\s*\.containerAppOrder\s*\)\s*\?\?\s*\[:\]",
+    "Store.init(from:) must decode missing containerAppOrder as an empty dictionary",
+    store_block,
+)
+
+if re.search(r"\bfunc\s+encode\s*\(\s*to\s+encoder\s*:\s*Encoder\s*\)", store_block):
+    require(
+        r"\.encode\(\s*containerAppOrder\s*,\s*forKey\s*:\s*\.containerAppOrder\s*\)",
+        "custom Store.encode(to:) must encode containerAppOrder",
+        store_block,
+    )
+
+require(
+    r"\blet\s+containerAppOrder\s*:\s*\[String\s*:\s*\[String\]\]",
+    "CategorySchemeFingerprint must include containerAppOrder",
+    fingerprint_block,
+)
+require(
+    r"containerAppOrder\s*:",
+    "categorySchemeFingerprint(for:) must populate containerAppOrder",
+    fingerprint_func,
+)
+require(
+    r"store\.containerAppOrder\b",
+    "categorySchemeFingerprint(for:) must derive containerAppOrder from Store.containerAppOrder",
+    fingerprint_func,
+)
+require(
+    r"\bstatic\s+func\s+normalizedContainerID\s*\(",
+    "TagDatabase must expose normalizedContainerID(_:) so UI never keys order by display name",
+)
+require(
+    r"\bstatic\s+func\s+normalizedContainerAppOrder\s*\(",
+    "TagDatabase must normalize containerAppOrder keys and path arrays",
+)
+require(
+    r"let\s+effectiveTagDefinitions\s*=\s*tagDefinitions\s*\?\?\s*loadedStore\?\.tags\s*\?\?\s*\[:\](?:(?!\n\s*var\s+dict).)*normalizedContainerAppOrder\s*\([^)]*tags\s*:\s*effectiveTagDefinitions",
+    "AppIndexer.group must normalize order with effective tag definitions so tag/system container order survives reload",
+)
+require(
+    r"normalizedContainerAppOrder\s*\([^)]*appTags\s*:\s*appTagsByPath\s*,\s*validAppPaths\s*:\s*validAppPaths",
+    "AppIndexer.group must normalize display order against current visible app paths and memberships",
+)
+require(
+    r"\bstatic\s+func\s+normalizeContainerAppOrder\s*\(",
+    "TagDatabase must normalize/prune containerAppOrder during lifecycle writes",
+)
+require(
+    r"saveUserCategorySchemeMutation\s*\([^)]*reason\s*:\s*\"reorder-apps\"",
+    "TagEditor.reorderApps must save through category scheme mutation with reason reorder-apps",
+    reorder_apps_func,
+)
+require(
+    r"store\.containerAppOrder\s*=\s*\[:\]",
+    "reset to uncategorized must clear old containerAppOrder",
+    reset_uncategorized_func,
+)
+
+require(
+    r"\bstatic\s+func\s+exportTo\s*\([^)]*\)\s+throws\s*\{(?:(?!\n\s*static\s+func).)*JSONEncoder\(\)\.encode\(\s*store\s*\)",
+    "exportTo(_:) must encode the full Store so containerAppOrder is exported",
+)
+require(
+    r"\bstatic\s+func\s+importFrom\s*\([^)]*\)\s+throws\s*->\s*Store\s*\{(?:(?!\n\s*static\s+func).)*JSONDecoder\(\)\.decode\(\s*Store\.self\s*,\s*from\s*:\s*data\s*\)",
+    "importFrom(_:) must decode the full Store so containerAppOrder is imported",
+)
+require(
+    r"\bstatic\s+func\s+backup\s*\([^)]*\)\s*->\s*URL\?\s*\{(?:(?!\n\s*static\s+func).)*encoder\.encode\(\s*store\s*\)",
+    "backup(_:reason:in:) must encode the full Store so ordering is included in snapshots",
+)
+require(
+    r"\bstatic\s+func\s+restore\s*\(\s*fromBackupAt\s+path\s*:\s*String\s*\)\s*->\s*Bool\s*\{(?:(?!\n\s*(?:static|private)\s+func).)*JSONDecoder\(\)\.decode\(\s*Store\.self\s*,\s*from\s*:\s*data\s*\)",
+    "restore(fromBackupAt:) must decode the full Store so ordering can be restored",
+)
+require(
+    r"replaceExistingScheme\s*\{(?:(?!\n\s*\}).)*store\.containerAppOrder\s*=\s*\[:\]",
+    "SmartStart replaceExistingScheme must clear stale containerAppOrder",
+    smart_start_text,
+)
+require(
+    r"let\s+containerAppOrder\s*:\s*\[String\s*:\s*\[String\]\]",
+    "AppLibrarySnapshot must carry containerAppOrder from the same Store snapshot",
+    pathlib.Path(sys.argv[1]).with_name("AppLibraryController.swift").read_text(encoding="utf-8"),
+)
+require(
+    r"onReorderApps\s*:\s*\{\s*containerID\s*,\s*orderedPaths\s+in(?:(?!\n\s*\}).)*reorderApps\s*\(\s*inContainer\s*:\s*containerID\s*,\s*orderedPaths\s*:\s*orderedPaths\s*\)",
+    "ContentView must wire AppGrid onReorderApps into the reorder write path",
+    content_text,
+)
+require(
+    r"TagEditor\.reorderApps\s*\(\s*inContainer\s*:\s*key\s*,\s*orderedPaths\s*:\s*paths\s*\)",
+    "ContentView.reorderApps must persist the normalized container order",
+    content_text,
+)
+require(
+    r"sourceContainerID",
+    "AppGrid drag payload must include sourceContainerID for language-stable same-container checks",
+    app_grid_text,
+)
+require(
+    r"activeReorderPath\s*\(\s*in\s+containerID\s*:\s*String\s*,\s*copy\s*:\s*Bool\s*\)",
+    "AppGrid coordinator must expose a same-container reorder guard",
+    app_grid_text,
+)
+require(
+    r"shouldCancelEmptyDropForActiveReorder\s*\([^)]*path\s*:\s*String[^)]*screenPoint\s*:\s*NSPoint[^)]*copy\s*:\s*Bool",
+    "AppGrid must cancel near-edge same-container reorder drops instead of routing them to remove-tag empty drop",
+    app_grid_text,
+)
+require(
+    r"if\s+sourceContainerID\s*==\s*group\.containerID\s*\{",
+    "AppGrid same-container drops, including no-op and Option drops, must be swallowed before cross-container move/copy logic",
+    app_grid_perform_drop_func,
+)
+same_container_index = app_grid_perform_drop_func.find("sourceContainerID == group.containerID")
+same_return_index = app_grid_perform_drop_func.find("return", same_container_index)
+cross_drop_index = app_grid_perform_drop_func.find("coordinator?.onDropApp")
+if not (0 <= same_container_index < same_return_index < cross_drop_index):
+    fail("AppGrid same-container drop must return before coordinator.onDropApp")
+if "coordinator?.endAppIconDrag()" not in app_grid_perform_drop_func[same_container_index:same_return_index]:
+    fail("AppGrid same-container drop must end drag state before returning")
+if "clearReorderInsertion()" not in app_grid_perform_drop_func[same_container_index:same_return_index]:
+    fail("AppGrid same-container drop must clear insertion state before returning")
+source_guard_index = content_drop_app_func.find("guard sourceTag != targetTag else { return }")
+apple_guard_index = content_drop_app_func.find("isAppleBuiltInDropTarget")
+if not (0 <= source_guard_index < apple_guard_index):
+    fail("ContentView.dropApp must no-op same source/target before Apple built-in warning")
+require(
+    r"parts\.dropFirst\(2\)\.first\s*\?\?\s*\"\"",
+    "AppDragCoordinator must parse sourceContainerID from the drag payload",
+    app_drag_text,
+)
+
+old_fixture = json.loads(
+    """
+    {
+      "version": 1,
+      "tags": {"Work": {"color": 1}},
+      "appTags": {"/Applications/A.app": ["Work"]},
+      "tagOrder": ["Work"]
+    }
+    """
+)
+new_fixture = json.loads(
+    """
+    {
+      "version": 1,
+      "tags": {"Work": {"color": 1}},
+      "appTags": {"/Applications/A.app": ["Work"], "/Applications/B.app": ["Work"]},
+      "tagOrder": ["Work"],
+      "containerAppOrder": {"tag:Work": ["/Applications/B.app", "/Applications/A.app"]}
+    }
+    """
+)
+
+if "containerAppOrder" in old_fixture:
+    fail("old fixture unexpectedly contains containerAppOrder")
+if new_fixture.get("containerAppOrder", {}).get("tag:Work") != [
+    "/Applications/B.app",
+    "/Applications/A.app",
+]:
+    fail("new fixture does not preserve ordered app paths")
+
+print(
+    "PASS app ordering QA: Store decode/default, JSON roundtrip, "
+    "fingerprint, import/export, backup/restore, SmartStart reset, "
+    "ContentView wiring, and AppGrid stable payload gates are present"
+)
+PY
diff --git a/src/Scripts/window_logic_qa.sh b/src/Scripts/window_logic_qa.sh
index d30af67..74c5350 100755
--- a/src/Scripts/window_logic_qa.sh
+++ b/src/Scripts/window_logic_qa.sh
@@ -324,9 +324,8 @@
   log "PASS no TagLauncher Dock tile"
 }
 
-click_taglauncher_dock_tile() {
-  local coords x y
-  coords="$(osascript <<'OSA'
+taglauncher_dock_tile_coords() {
+  osascript <<'OSA'
 tell application "System Events"
   tell process "Dock"
     repeat with itemRef in UI elements of list 1
@@ -344,9 +343,54 @@
   end tell
 end tell
 OSA
-)"
-  read -r x y <<<"$coords"
+}
+
+clamped_click_coords() {
+  local x="$1"
+  local y="$2"
+  local screen_line screen_x screen_y screen_w screen_h min_x max_x min_y max_y
+  if [[ -n "${screens_swift:-}" && -f "$screens_swift" ]]; then
+    screen_line="$(swift "$screens_swift" | awk -F'|' -v x="$x" '$4 <= x && x <= ($4 + $6) { print; exit }')"
+    if [[ -n "$screen_line" ]]; then
+      IFS='|' read -r _ _ _ screen_x screen_y screen_w screen_h <<<"$screen_line"
+      min_x=$((screen_x + 4))
+      max_x=$((screen_x + screen_w - 4))
+      min_y=$((screen_y + 4))
+      max_y=$((screen_y + screen_h - 4))
+      if (( x < min_x )); then x="$min_x"; fi
+      if (( x > max_x )); then x="$max_x"; fi
+      if (( y < min_y )); then y="$min_y"; fi
+      if (( y > max_y )); then y="$max_y"; fi
+    fi
+  fi
+  printf '%s %s\n' "$x" "$y"
+}
+
+click_taglauncher_dock_tile() {
+  local coords x y
+  coords="$(taglauncher_dock_tile_coords)"
+  read -r x y <<<"$(clamped_click_coords ${coords%% *} ${coords#* })"
+  move_xy "$x" "$y"
+  sleep 0.8
+  if coords="$(taglauncher_dock_tile_coords 2>/dev/null)"; then
+    read -r x y <<<"$(clamped_click_coords ${coords%% *} ${coords#* })"
+  fi
   click_xy "$x" "$y"
+  sleep 0.15
+  osascript <<'OSA' >/dev/null 2>&1 || true
+tell application "System Events"
+  tell process "Dock"
+    repeat with itemRef in UI elements of list 1
+      try
+        if (name of itemRef as text) is "TagLauncher" then
+          click itemRef
+          return
+        end if
+      end try
+    end repeat
+  end tell
+end tell
+OSA
 }
 
 open_overlay_from_dock_with_retry() {
@@ -1076,6 +1120,20 @@
   return 1
 }
 
+open_fullscreen_overlay_with_retry() {
+  local output=""
+  for _ in {1..3}; do
+    send_main_hotkey
+    sleep 0.4
+    if output="$(wait_swift_assert fullscreen-overlay 2>&1)"; then
+      printf '%s\n' "$output"
+      return 0
+    fi
+  done
+  printf '%s\n' "$output" >&2
+  return 1
+}
+
 show_overlay() {
   send_main_hotkey
   if wait_swift_assert overlay >/dev/null 2>&1; then
@@ -1296,8 +1354,7 @@
   prepare_isolated_app_instance
   start_fullscreen_qa_target
   move_pointer_to_fullscreen_target
-  send_main_hotkey
-  wait_swift_assert fullscreen-overlay
+  open_fullscreen_overlay_with_retry
   assert_fullscreen_overlay_stable
   log "==> QA fullscreen Space: quick search from appgrid does not switch Space"
   open_appgrid_quick_search_with_retry
@@ -1353,11 +1410,9 @@
 log "==> QA 4/7 and 5/7: appgrid-space quick search and double-Esc behavior"
 open_appgrid_quick_search_with_retry
 send_keycode 53
-sleep 0.4
-swift_assert overlay
+wait_swift_assert overlay
 send_keycode 53
-sleep 0.4
-swift_assert no-overlay
+wait_swift_assert no-overlay
 
 log "==> QA 4/7: appgrid scroll keeps Space and Esc keyboard routing"
 show_overlay
@@ -1365,13 +1420,11 @@
   page_scroll_appgrid
   open_appgrid_quick_search_with_retry
   send_keycode 53
-  sleep 0.25
-  swift_assert overlay
+  wait_swift_assert overlay
 done
 page_scroll_appgrid
 send_keycode 53
-sleep 0.35
-swift_assert no-overlay
+wait_swift_assert no-overlay
 
 log "==> QA 5/7: clicking outside quick search closes search, not appgrid"
 show_overlay
diff --git a/src/TODO.md b/src/TODO.md
index 16d53c8..9563d89 100644
--- a/src/TODO.md
+++ b/src/TODO.md
@@ -13,11 +13,6 @@
 
 ## Todo
 
-- [7.9.0 大需求] 容器内 App 手动拖拽排序。
-  - 状态: 方案已确认,详细 TODO 和过程文档已归档到 `../Docs/7.90/`。
-  - 版本: 后续实现从 `7.9.0` 开始记录,不混入 `7.8.x` 小版本。
-  - 入口: `../Docs/7.90/README.md`、`../Docs/7.90/01-容器内App手动排序-TODO.md`、`../Docs/7.90/02-专题讨论纪要.md`。
-
 - [发布后独立任务] 设置页 tab 容器 AppKit/自控化评估与实现。
   - 决策: 当前发布前 No-Go;保留 SwiftUI `TabView`,不改 QA 脚本。
   - 背景: QA 过程中曾观察到设置页顶部 tab 偶发折叠/消失,但目前不是稳定可复现 release blocker。
@@ -28,6 +23,13 @@
 
 ## Done
 
+- [2026-06-11] 7.9.0 容器内 App 手动拖拽排序。
+  - 结果: 同一容器内拖动 App 可改变显示顺序;排序保存为分类与布局方案的一部分,刷新、重启、语言切换、导入导出、恢复上一方案和 SmartStart 替换按统一规则保留、迁移或清理。
+  - 范围: 新增稳定 `containerAppOrder`、容器 ID、排序持久化 helper、分类方案 fingerprint、App Grid 同容器插入位置提示和 `ContentView` 保存回调;跨容器移动、Option 复制、拖空白移除、Apple 内置保护、Quick Search 排名均不改。
+  - 文档: `../docs/7.90/README.md`、`../docs/7.90/01-容器内App手动排序-TODO.md`、`../docs/7.90/07-实现与QA记录.md`。
+  - 验证: `Scripts/app_ordering_data_qa.sh`、macOS 14 typecheck/build metadata、Quick Search 名称回归、SmartStart catalog QA、`Scripts/window_logic_qa.sh` 通过。
+  - 版本: `7.9.0 (20260611.2212)`。
+
 - [2026-06-09] 多语种 App 显示名通用加固。
   - 结果: 新增统一 `AppDisplayNameResolver`;App Grid 与 Quick Search 标题优先走当前语种/同语系/系统可见名/英文官方名/基础 bundle 名 fallback,内部 bundle 名和 bundle id 只作为搜索别名;`7.8.26` 进一步加固 `/Wrapper/`、`/Contents/Helpers/`、`.app/.../*.app` 内部 helper 过滤。
   - 范围: `AppInfo` 增加系统显示名与 bundle 显示名候选;App 扫描层和 Quick Search 文档层都过滤内部 helper;Quick Search 文档使用 resolver 生成别名;版本更新为 `7.8.26 (20260609.2004)`。

--
Gitblit v1.9.3