@yolk_vat-y/dsh-project-memory 0.4.3 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +59 -0
- package/README.md +43 -5
- package/README.zh-CN.md +44 -7
- package/client/client.js +1439 -464
- package/client/client.js.map +1 -1
- package/package.json +2 -2
- package/src/auto-inject.js +214 -0
- package/src/client/MemoryView.tsx +349 -0
- package/src/client/TaskCommandNode.tsx +41 -30
- package/src/client/TaskComponents.tsx +388 -0
- package/src/client/TaskPanel.module.css +225 -40
- package/src/client/TaskPanel.tsx +174 -391
- package/src/client/client.ts +4 -4
- package/src/client/locales.ts +74 -0
- package/src/client/task-data-store.ts +152 -0
- package/src/client/task-hooks.ts +169 -0
- package/src/client/task-ui-store.ts +118 -0
- package/src/commands/insight-actions.js +390 -0
- package/src/commands/task-actions.js +17 -7
- package/src/commands/tasks.js +6 -3
- package/src/global-seed.js +41 -0
- package/src/index.js +42 -7
- package/src/insight-store.js +558 -0
- package/src/project-profile.js +78 -0
- package/src/reflection-pipeline.js +146 -0
- package/src/similarity.js +52 -0
- package/src/store.js +73 -0
- package/src/tools/lesson-tools.js +101 -0
- package/src/tools/task-tools.js +31 -2
- package/src/types.js +56 -0
- package/src/client/task-store.ts +0 -192
package/src/client/client.ts
CHANGED
|
@@ -20,7 +20,7 @@ const NS = 'dsh-project-memory'
|
|
|
20
20
|
export const name = NS
|
|
21
21
|
|
|
22
22
|
/** Required client services: slots registry, session scopes, commands remote (data 通道). */
|
|
23
|
-
export const inject = ['slots', 'sessions', 'remote', 'remote.commands']
|
|
23
|
+
export const inject = ['slots', 'sessions', 'remote', 'remote.commands', 'locale']
|
|
24
24
|
|
|
25
25
|
export function apply(ctx: any): void {
|
|
26
26
|
const slots = ctx?.slots
|
|
@@ -38,9 +38,9 @@ export function apply(ctx: any): void {
|
|
|
38
38
|
() => createElement(TaskPanelEntry, { ctx })
|
|
39
39
|
))
|
|
40
40
|
|
|
41
|
-
// 会话内 /tasks、/task 命令节点:按命令名 key 接管渲染,
|
|
42
|
-
// 替换内置大段文本卡片(文本含 JSON
|
|
43
|
-
const nodeKeys = ['tasks', 'task']
|
|
41
|
+
// 会话内 /tasks、/task、/insight 命令节点:按命令名 key 接管渲染,
|
|
42
|
+
// 替换内置大段文本卡片(文本含 JSON 载荷,只有面板需要它),避免载荷刷屏对话。
|
|
43
|
+
const nodeKeys = ['tasks', 'task', 'insight']
|
|
44
44
|
for (const key of nodeKeys) {
|
|
45
45
|
slots.inject('conversation.chat.commandview', () => slots.register(
|
|
46
46
|
{ name: 'conversation.chat.commandview', key },
|
package/src/client/locales.ts
CHANGED
|
@@ -15,6 +15,9 @@ export const zh = {
|
|
|
15
15
|
'style.mono': '终端',
|
|
16
16
|
'panel.syncing': '同步中…',
|
|
17
17
|
'panel.sync-failed': '同步失败',
|
|
18
|
+
'panel.hints-on': '显示提示信息',
|
|
19
|
+
'panel.hints-off': '隐藏提示信息',
|
|
20
|
+
'drag.cancel': '取消拖拽',
|
|
18
21
|
'panel.empty': '暂无任务',
|
|
19
22
|
'panel.empty-desc': '让模型开始工作并维护 todo 清单后会自动建档',
|
|
20
23
|
'panel.no-session': '还没有会话',
|
|
@@ -37,6 +40,40 @@ export const zh = {
|
|
|
37
40
|
'minibar.click-expand': '点击展开并同步',
|
|
38
41
|
'minibar.tasks': '个任务',
|
|
39
42
|
'minibar.no-task': '暂无任务 · 点击查看',
|
|
43
|
+
'view.cycle': '切换视图:任务 / 项目记忆 / 全局记忆',
|
|
44
|
+
'view.task': '任务',
|
|
45
|
+
'view.project': '项目记忆',
|
|
46
|
+
'view.global': '全局记忆',
|
|
47
|
+
'mem.scope.task': '本任务草稿',
|
|
48
|
+
'mem.scope.project': '项目记忆',
|
|
49
|
+
'mem.scope.global': '全局记忆',
|
|
50
|
+
'mem.empty': '暂无记忆条目(反思或 save_lesson 后出现)',
|
|
51
|
+
'mem.confirm': '审核',
|
|
52
|
+
'mem.promote': '提升',
|
|
53
|
+
'mem.demote': '降级',
|
|
54
|
+
'mem.archive': '归档',
|
|
55
|
+
'mem.restore': '恢复',
|
|
56
|
+
'mem.delete': '删除',
|
|
57
|
+
'mem.draft': '草稿',
|
|
58
|
+
'mem.archived': '已归档',
|
|
59
|
+
'mem.loading': '加载中…',
|
|
60
|
+
'mem.error': '同步失败',
|
|
61
|
+
'mem.kind.lesson': '坑',
|
|
62
|
+
'mem.kind.decision': '权衡',
|
|
63
|
+
'mem.kind.procedure': '指南',
|
|
64
|
+
'mem.kind.experience': '经验',
|
|
65
|
+
'mem.needs-bound': '需绑定任务后可降级',
|
|
66
|
+
'mem.new': '新建',
|
|
67
|
+
'mem.save': '保存',
|
|
68
|
+
'mem.cancel': '取消',
|
|
69
|
+
'mem.f.title': '标题(教训模式/主题/指南名)',
|
|
70
|
+
'mem.f.kind': '类型',
|
|
71
|
+
'mem.f.body': '做法 / 理由 / 解法',
|
|
72
|
+
'mem.f.steps': '步骤(每行一步)',
|
|
73
|
+
'mem.f.trigger': '作为 Skill:触发关键词(逗号分隔)',
|
|
74
|
+
'mem.saved': '已保存',
|
|
75
|
+
'mem.edit': '编辑',
|
|
76
|
+
'mem.section-label': '任务记忆',
|
|
40
77
|
}
|
|
41
78
|
|
|
42
79
|
export const en = {
|
|
@@ -53,6 +90,9 @@ export const en = {
|
|
|
53
90
|
'style.mono': 'Mono',
|
|
54
91
|
'panel.syncing': 'Syncing…',
|
|
55
92
|
'panel.sync-failed': 'Sync failed',
|
|
93
|
+
'panel.hints-on': 'Show hints',
|
|
94
|
+
'panel.hints-off': 'Hide hints',
|
|
95
|
+
'drag.cancel': 'Cancel drag',
|
|
56
96
|
'panel.empty': 'No Tasks',
|
|
57
97
|
'panel.empty-desc': 'Tasks are created automatically when the model maintains a todo list',
|
|
58
98
|
'panel.no-session': 'No session yet',
|
|
@@ -75,6 +115,40 @@ export const en = {
|
|
|
75
115
|
'minibar.click-expand': 'Click to expand & sync',
|
|
76
116
|
'minibar.tasks': 'tasks',
|
|
77
117
|
'minibar.no-task': 'No tasks · click to view',
|
|
118
|
+
'view.cycle': 'Switch view: tasks / project memory / global memory',
|
|
119
|
+
'view.task': 'Tasks',
|
|
120
|
+
'view.project': 'Project Memory',
|
|
121
|
+
'view.global': 'Global Memory',
|
|
122
|
+
'mem.scope.task': 'Task drafts',
|
|
123
|
+
'mem.scope.project': 'Project memory',
|
|
124
|
+
'mem.scope.global': 'Global memory',
|
|
125
|
+
'mem.empty': 'No memory entries yet (created by reflection or save_lesson)',
|
|
126
|
+
'mem.confirm': 'Approve',
|
|
127
|
+
'mem.promote': 'Promote',
|
|
128
|
+
'mem.demote': 'Demote',
|
|
129
|
+
'mem.archive': 'Archive',
|
|
130
|
+
'mem.restore': 'Restore',
|
|
131
|
+
'mem.delete': 'Delete',
|
|
132
|
+
'mem.draft': 'draft',
|
|
133
|
+
'mem.archived': 'archived',
|
|
134
|
+
'mem.loading': 'Loading…',
|
|
135
|
+
'mem.error': 'Sync failed',
|
|
136
|
+
'mem.kind.lesson': 'lesson',
|
|
137
|
+
'mem.kind.decision': 'decision',
|
|
138
|
+
'mem.kind.procedure': 'procedure',
|
|
139
|
+
'mem.kind.experience': 'experience',
|
|
140
|
+
'mem.needs-bound': 'Bind a task to demote',
|
|
141
|
+
'mem.new': 'New',
|
|
142
|
+
'mem.save': 'Save',
|
|
143
|
+
'mem.cancel': 'Cancel',
|
|
144
|
+
'mem.f.title': 'Title (pattern / topic / procedure name)',
|
|
145
|
+
'mem.f.kind': 'Kind',
|
|
146
|
+
'mem.f.body': 'Fix / reason / solution',
|
|
147
|
+
'mem.f.steps': 'Steps (one per line)',
|
|
148
|
+
'mem.f.trigger': 'As Skill: trigger keywords (comma separated)',
|
|
149
|
+
'mem.saved': 'Saved',
|
|
150
|
+
'mem.edit': 'Edit',
|
|
151
|
+
'mem.section-label': 'Task memory',
|
|
78
152
|
}
|
|
79
153
|
|
|
80
154
|
export type LocaleDict = typeof zh
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task Data Store — 仅管服务端同步的数据(tasks、boundTaskId、archivedCount)
|
|
3
|
+
* - 单向数据流:命令执行结果 → setTasks → 广播给其他标签页
|
|
4
|
+
* - 无 UI 状态,无 localStorage,纯内存 + BroadcastChannel 同步
|
|
5
|
+
*/
|
|
6
|
+
import { useSyncExternalStore } from 'react'
|
|
7
|
+
|
|
8
|
+
export interface TaskStep {
|
|
9
|
+
content: string
|
|
10
|
+
status: 'pending' | 'in_progress' | 'completed'
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface TaskFile {
|
|
14
|
+
path: string
|
|
15
|
+
line?: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface TaskInsight {
|
|
19
|
+
id: string
|
|
20
|
+
kind: string
|
|
21
|
+
title: string
|
|
22
|
+
draft?: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface Task {
|
|
26
|
+
id: string
|
|
27
|
+
title: string
|
|
28
|
+
steps: TaskStep[]
|
|
29
|
+
files: TaskFile[]
|
|
30
|
+
lastActiveAt: string
|
|
31
|
+
updatedAt: string
|
|
32
|
+
archived?: boolean
|
|
33
|
+
insights?: TaskInsight[]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface TaskPayload {
|
|
37
|
+
tasks: Task[]
|
|
38
|
+
boundId: string | null
|
|
39
|
+
archived: number
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function parseTaskPayloadText(text?: string | null): TaskPayload | null {
|
|
43
|
+
if (!text) return null
|
|
44
|
+
let jsonBlock: string | null = null
|
|
45
|
+
const fenced = text.match(/```(?:json)?\s*\n([\s\S]*?)\n```/)
|
|
46
|
+
if (fenced) jsonBlock = fenced[1]
|
|
47
|
+
else {
|
|
48
|
+
const lastBrace = text.lastIndexOf('{')
|
|
49
|
+
if (lastBrace !== -1) jsonBlock = text.slice(lastBrace)
|
|
50
|
+
}
|
|
51
|
+
if (!jsonBlock) return null
|
|
52
|
+
try {
|
|
53
|
+
const data = JSON.parse(jsonBlock)
|
|
54
|
+
if (!Array.isArray(data.tasks)) return null
|
|
55
|
+
return { tasks: data.tasks, boundId: data.boundId ?? null, archived: data.archived ?? 0 }
|
|
56
|
+
} catch {
|
|
57
|
+
return null
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface TaskDataState {
|
|
62
|
+
tasks: Task[]
|
|
63
|
+
boundTaskId: string | null
|
|
64
|
+
archivedCount: number
|
|
65
|
+
lastUpdate: number
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const SYNC_CHANNEL = typeof window !== 'undefined' ? new BroadcastChannel('dsh-pm-tasks-data') : null
|
|
69
|
+
|
|
70
|
+
let dataState: TaskDataState = {
|
|
71
|
+
tasks: [],
|
|
72
|
+
boundTaskId: null,
|
|
73
|
+
archivedCount: 0,
|
|
74
|
+
lastUpdate: 0,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const dataListeners = new Set<() => void>()
|
|
78
|
+
|
|
79
|
+
function setDataState(next: TaskDataState | ((prev: TaskDataState) => TaskDataState)): void {
|
|
80
|
+
// 兼容对象与函数式 updater(BroadcastChannel 曾误把 updater 当对象写入,
|
|
81
|
+
// 导致 dataState 变成函数、tasks 丢失 → 面板崩溃)。写入一律归一化为完整 shape。
|
|
82
|
+
const patch = typeof next === 'function' ? next(dataState) : next
|
|
83
|
+
dataState = {
|
|
84
|
+
tasks: Array.isArray(patch?.tasks) ? patch.tasks : [],
|
|
85
|
+
boundTaskId: patch?.boundTaskId ?? null,
|
|
86
|
+
archivedCount: typeof patch?.archivedCount === 'number' ? patch.archivedCount : 0,
|
|
87
|
+
lastUpdate: typeof patch?.lastUpdate === 'number' ? patch.lastUpdate : 0,
|
|
88
|
+
}
|
|
89
|
+
for (const listener of dataListeners) listener()
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function broadcastDataUpdate(tasks: Task[], archivedCount: number): void {
|
|
93
|
+
if (!SYNC_CHANNEL) return
|
|
94
|
+
SYNC_CHANNEL.postMessage({
|
|
95
|
+
type: 'PROJECT_TASKS_UPDATED',
|
|
96
|
+
payload: { tasks: Array.isArray(tasks) ? tasks : [], archivedCount },
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (SYNC_CHANNEL) {
|
|
101
|
+
SYNC_CHANNEL.onmessage = (event) => {
|
|
102
|
+
const msg = event.data
|
|
103
|
+
if (msg?.type === 'PROJECT_TASKS_UPDATED' && msg.payload) {
|
|
104
|
+
const { tasks, archivedCount } = msg.payload
|
|
105
|
+
setDataState(prev => ({
|
|
106
|
+
...prev,
|
|
107
|
+
tasks: Array.isArray(tasks) ? tasks : [],
|
|
108
|
+
archivedCount: typeof archivedCount === 'number' ? archivedCount : prev.archivedCount,
|
|
109
|
+
lastUpdate: Date.now(),
|
|
110
|
+
}))
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export const taskDataStore = {
|
|
116
|
+
subscribe(listener: () => void): () => void {
|
|
117
|
+
dataListeners.add(listener)
|
|
118
|
+
return () => { dataListeners.delete(listener) }
|
|
119
|
+
},
|
|
120
|
+
getSnapshot(): TaskDataState {
|
|
121
|
+
return dataState
|
|
122
|
+
},
|
|
123
|
+
actions: {
|
|
124
|
+
setTasks(payload: { tasks: Task[]; boundTaskId: string | null; archivedCount: number }): void {
|
|
125
|
+
const nextTasks = payload.tasks || []
|
|
126
|
+
const nextArchived = payload.archivedCount ?? 0
|
|
127
|
+
setDataState({
|
|
128
|
+
tasks: nextTasks,
|
|
129
|
+
boundTaskId: payload.boundTaskId ?? null,
|
|
130
|
+
archivedCount: nextArchived,
|
|
131
|
+
lastUpdate: Date.now(),
|
|
132
|
+
})
|
|
133
|
+
broadcastDataUpdate(nextTasks, nextArchived)
|
|
134
|
+
},
|
|
135
|
+
reset(): void {
|
|
136
|
+
setDataState({
|
|
137
|
+
tasks: [],
|
|
138
|
+
boundTaskId: null,
|
|
139
|
+
archivedCount: 0,
|
|
140
|
+
lastUpdate: 0,
|
|
141
|
+
})
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function useTaskData(): TaskDataState {
|
|
147
|
+
return useSyncExternalStore(taskDataStore.subscribe, taskDataStore.getSnapshot, taskDataStore.getSnapshot)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function useTaskDataActions() {
|
|
151
|
+
return taskDataStore.actions
|
|
152
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task Panel Hooks — 步骤拖拽交互逻辑(useTaskDrag)。
|
|
3
|
+
* 只依赖 react + task-data-store 类型,不碰数据/UI store。
|
|
4
|
+
*/
|
|
5
|
+
import { useEffect, useRef, useState, useCallback } from 'react'
|
|
6
|
+
import { TaskStep } from './task-data-store.ts'
|
|
7
|
+
|
|
8
|
+
interface DragState {
|
|
9
|
+
fromIndex: number
|
|
10
|
+
stepContent: string
|
|
11
|
+
clientX: number
|
|
12
|
+
clientY: number
|
|
13
|
+
dragging: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* useTaskDrag — 步骤拖拽重排(绑定任务内,同一任务卡的步骤行间移动)。
|
|
18
|
+
*
|
|
19
|
+
* 交互模型(拖动全程按住鼠标):
|
|
20
|
+
* - 步骤行上按下(左键、非按钮/输入区域)→ 进入预览;
|
|
21
|
+
* - 按住 350ms 不松,或按住后移动超过 6px → 唤起跟随鼠标的浮动幽灵卡片;
|
|
22
|
+
* - 幽灵悬停到本任务卡内的步骤行时显示蓝色插入线(上半=插到该行前,下半=插到该行后);
|
|
23
|
+
* - 松手(mouseup):落在有效位置 → onReorder 落子;落在卡外/别的任务卡/原行
|
|
24
|
+
* (插入自身前后)/按钮输入等控件上 → 取消;
|
|
25
|
+
* - 350ms 内原地松手 = 普通点击,不产生幽灵;Esc → 取消。
|
|
26
|
+
*/
|
|
27
|
+
export function useTaskDrag(
|
|
28
|
+
taskId: string,
|
|
29
|
+
steps: TaskStep[],
|
|
30
|
+
isBound: boolean,
|
|
31
|
+
onReorder: (fromIndex: number, toIndex: number) => void
|
|
32
|
+
) {
|
|
33
|
+
const [dragState, setDragState] = useState<DragState | null>(null)
|
|
34
|
+
const [dropTargetIndex, setDropTargetIndex] = useState<number | null>(null)
|
|
35
|
+
|
|
36
|
+
// 最新值镜像:拖拽期间 window 监听只在“会话开始/结束”时挂载/卸载,
|
|
37
|
+
// 避免每次 mousemove 更新状态导致监听器重挂、连 350ms 定时器都被清掉。
|
|
38
|
+
const dragRef = useRef(dragState)
|
|
39
|
+
dragRef.current = dragState
|
|
40
|
+
const longPressTimer = useRef<number | undefined>(undefined)
|
|
41
|
+
const dragOrigin = useRef<{ x: number; y: number } | null>(null)
|
|
42
|
+
|
|
43
|
+
const clearTimer = useCallback(() => {
|
|
44
|
+
if (longPressTimer.current !== undefined) {
|
|
45
|
+
window.clearTimeout(longPressTimer.current)
|
|
46
|
+
longPressTimer.current = undefined
|
|
47
|
+
}
|
|
48
|
+
}, [])
|
|
49
|
+
|
|
50
|
+
const cancelDrag = useCallback(() => {
|
|
51
|
+
clearTimer()
|
|
52
|
+
dragOrigin.current = null
|
|
53
|
+
document.body.style.userSelect = ''
|
|
54
|
+
setDropTargetIndex(null)
|
|
55
|
+
setDragState(null)
|
|
56
|
+
}, [clearTimer])
|
|
57
|
+
|
|
58
|
+
const startDrag = useCallback((
|
|
59
|
+
fromIndex: number,
|
|
60
|
+
stepContent: string,
|
|
61
|
+
e: React.MouseEvent
|
|
62
|
+
) => {
|
|
63
|
+
if (!isBound || e.button !== 0) return
|
|
64
|
+
if (dragRef.current) return // 已有预览/拖拽会话
|
|
65
|
+
e.preventDefault()
|
|
66
|
+
e.stopPropagation()
|
|
67
|
+
document.body.style.userSelect = 'none'
|
|
68
|
+
dragOrigin.current = { x: e.clientX, y: e.clientY }
|
|
69
|
+
setDropTargetIndex(null)
|
|
70
|
+
setDragState({ fromIndex, stepContent, clientX: e.clientX, clientY: e.clientY, dragging: false })
|
|
71
|
+
clearTimer()
|
|
72
|
+
longPressTimer.current = window.setTimeout(() => {
|
|
73
|
+
longPressTimer.current = undefined
|
|
74
|
+
// 350ms 长按 → 唤起幽灵卡片
|
|
75
|
+
setDragState(prev => (prev ? { ...prev, dragging: true } : null))
|
|
76
|
+
}, 350)
|
|
77
|
+
}, [isBound, clearTimer])
|
|
78
|
+
|
|
79
|
+
/** 计算某行上 y 坐标对应的插入位(行数下标:上半=该行前,下半=该行后);非本任务卡返回 null */
|
|
80
|
+
const dropIndexAt = useCallback((row: Element, clientY: number): number | null => {
|
|
81
|
+
const parentOl = row.closest('ol')
|
|
82
|
+
if (!parentOl || !parentOl.closest(`article[data-task-id="${taskId}"]`)) return null
|
|
83
|
+
const index = Array.from(parentOl.children).indexOf(row)
|
|
84
|
+
const rect = row.getBoundingClientRect()
|
|
85
|
+
return clientY < rect.top + rect.height / 2 ? index : index + 1
|
|
86
|
+
}, [taskId])
|
|
87
|
+
|
|
88
|
+
/** 在 (x,y) 处结算:命中有效落点则重排,否则取消;并结束本次拖拽 */
|
|
89
|
+
const commitDrop = useCallback((x: number, y: number) => {
|
|
90
|
+
const cur = dragRef.current
|
|
91
|
+
if (!cur || !cur.dragging) return
|
|
92
|
+
const el = document.elementFromPoint(x, y) as HTMLElement | null
|
|
93
|
+
// 松手点在按钮/输入等交互控件上:不落子,直接取消
|
|
94
|
+
if (el && !el.closest('button, input, textarea, a')) {
|
|
95
|
+
const row = el.closest('[data-step-row]')
|
|
96
|
+
if (row) {
|
|
97
|
+
const drop = dropIndexAt(row, y)
|
|
98
|
+
if (drop !== null) {
|
|
99
|
+
// 行下标形式的落点:插到目标行前/后;落在自身原位视为取消(不产生空推送)
|
|
100
|
+
const effective = drop > cur.fromIndex ? drop - 1 : drop
|
|
101
|
+
if (effective !== cur.fromIndex) onReorder(cur.fromIndex, drop)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
cancelDrag()
|
|
106
|
+
}, [dropIndexAt, onReorder, cancelDrag])
|
|
107
|
+
|
|
108
|
+
const active = dragState !== null
|
|
109
|
+
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
if (!active) return
|
|
112
|
+
|
|
113
|
+
const onMouseMove = (e: MouseEvent) => {
|
|
114
|
+
const cur = dragRef.current
|
|
115
|
+
if (!cur) return
|
|
116
|
+
if (!cur.dragging) {
|
|
117
|
+
// 未到 350ms:移动超过阈值视为拖拽开始(不必死等长按)
|
|
118
|
+
const origin = dragOrigin.current
|
|
119
|
+
if (!origin) return
|
|
120
|
+
if (Math.hypot(e.clientX - origin.x, e.clientY - origin.y) < 6) return
|
|
121
|
+
clearTimer()
|
|
122
|
+
setDragState(prev => (prev ? { ...prev, dragging: true, clientX: e.clientX, clientY: e.clientY } : null))
|
|
123
|
+
} else {
|
|
124
|
+
setDragState(prev => (prev ? { ...prev, clientX: e.clientX, clientY: e.clientY } : null))
|
|
125
|
+
}
|
|
126
|
+
const row = document.elementFromPoint(e.clientX, e.clientY)?.closest('[data-step-row]')
|
|
127
|
+
setDropTargetIndex(row ? dropIndexAt(row, e.clientY) : null)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const onMouseUp = (e: MouseEvent) => {
|
|
131
|
+
const cur = dragRef.current
|
|
132
|
+
if (!cur) return
|
|
133
|
+
if (cur.dragging) {
|
|
134
|
+
// 拖拽中松手 = 落子/取消
|
|
135
|
+
commitDrop(e.clientX, e.clientY)
|
|
136
|
+
} else {
|
|
137
|
+
// 350ms 内原地松手 = 普通点击
|
|
138
|
+
cancelDrag()
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const onClick = (e: MouseEvent) => {
|
|
143
|
+
// 兜底:mouseup 发生在窗口外等情况时,下一次点击直接结算
|
|
144
|
+
const cur = dragRef.current
|
|
145
|
+
if (!cur || !cur.dragging) return
|
|
146
|
+
commitDrop(e.clientX, e.clientY)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
150
|
+
if (e.key === 'Escape') cancelDrag()
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
window.addEventListener('mousemove', onMouseMove)
|
|
154
|
+
window.addEventListener('mouseup', onMouseUp)
|
|
155
|
+
window.addEventListener('click', onClick, true)
|
|
156
|
+
window.addEventListener('keydown', onKeyDown)
|
|
157
|
+
return () => {
|
|
158
|
+
window.removeEventListener('mousemove', onMouseMove)
|
|
159
|
+
window.removeEventListener('mouseup', onMouseUp)
|
|
160
|
+
window.removeEventListener('click', onClick, true)
|
|
161
|
+
window.removeEventListener('keydown', onKeyDown)
|
|
162
|
+
document.body.style.userSelect = ''
|
|
163
|
+
}
|
|
164
|
+
}, [active, taskId, dropIndexAt, onReorder, cancelDrag, commitDrop])
|
|
165
|
+
|
|
166
|
+
useEffect(() => () => clearTimer(), [clearTimer])
|
|
167
|
+
|
|
168
|
+
return { dragState, dropTargetIndex, startDrag, cancelDrag }
|
|
169
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task UI Store — 仅管本地 UI 状态(closed、minimized、position、expandedIds、theme)
|
|
3
|
+
* - 持久化到 localStorage(key: dsh-pm-task-panel-ui)
|
|
4
|
+
* - 不跨标签页同步(每个标签页独立)
|
|
5
|
+
* - 启动/刷新强制 closed: true,面板默认不显示
|
|
6
|
+
*/
|
|
7
|
+
import { useSyncExternalStore } from 'react'
|
|
8
|
+
|
|
9
|
+
const STORAGE_KEY = 'dsh-pm-task-panel-ui'
|
|
10
|
+
|
|
11
|
+
interface TaskUIState {
|
|
12
|
+
expandedTaskIds: string[]
|
|
13
|
+
panelPosition: { x: number; y: number }
|
|
14
|
+
minimized: boolean
|
|
15
|
+
closed: boolean
|
|
16
|
+
theme: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function defaultPosition(): { x: number; y: number } {
|
|
20
|
+
if (typeof window !== 'undefined') {
|
|
21
|
+
return { x: Math.max(16, window.innerWidth - 408), y: 72 }
|
|
22
|
+
}
|
|
23
|
+
return { x: 0, y: 0 }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 启动时的初始状态:
|
|
28
|
+
* - closed 恒为 true(不读取 localStorage 的 closed),刷新后面板隐藏;
|
|
29
|
+
* - 其余(展开项/位置/主题)可恢复上次会话偏好,minimized 默认折叠成迷你条。
|
|
30
|
+
*/
|
|
31
|
+
function getDefaultUIState(): TaskUIState {
|
|
32
|
+
if (typeof window !== 'undefined') {
|
|
33
|
+
try {
|
|
34
|
+
const saved = localStorage.getItem(STORAGE_KEY)
|
|
35
|
+
if (saved) {
|
|
36
|
+
const parsed = JSON.parse(saved)
|
|
37
|
+
return {
|
|
38
|
+
expandedTaskIds: Array.isArray(parsed.expandedTaskIds) ? parsed.expandedTaskIds : [],
|
|
39
|
+
panelPosition: parsed.panelPosition ?? defaultPosition(),
|
|
40
|
+
minimized: parsed.minimized !== false,
|
|
41
|
+
closed: true,
|
|
42
|
+
theme: typeof parsed.theme === 'string' ? parsed.theme : 'native',
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
} catch { /* corrupt state — fall through to defaults */ }
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
expandedTaskIds: [],
|
|
49
|
+
panelPosition: defaultPosition(),
|
|
50
|
+
minimized: true,
|
|
51
|
+
closed: true,
|
|
52
|
+
theme: 'native',
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function persistUI(state: TaskUIState): void {
|
|
57
|
+
if (typeof window === 'undefined') return
|
|
58
|
+
try {
|
|
59
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
|
|
60
|
+
} catch { /* storage full/unavailable — non-fatal */ }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let uiState: TaskUIState = getDefaultUIState()
|
|
64
|
+
const uiListeners = new Set<() => void>()
|
|
65
|
+
|
|
66
|
+
function setUIState(next: TaskUIState): void {
|
|
67
|
+
uiState = next
|
|
68
|
+
persistUI(next)
|
|
69
|
+
for (const listener of uiListeners) listener()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const taskUIStore = {
|
|
73
|
+
subscribe(listener: () => void): () => void {
|
|
74
|
+
uiListeners.add(listener)
|
|
75
|
+
return () => { uiListeners.delete(listener) }
|
|
76
|
+
},
|
|
77
|
+
getSnapshot(): TaskUIState {
|
|
78
|
+
return uiState
|
|
79
|
+
},
|
|
80
|
+
actions: {
|
|
81
|
+
toggleTaskExpanded(taskId: string): void {
|
|
82
|
+
const prev = uiState
|
|
83
|
+
const ids = prev.expandedTaskIds.includes(taskId)
|
|
84
|
+
? prev.expandedTaskIds.filter((id) => id !== taskId)
|
|
85
|
+
: [...prev.expandedTaskIds, taskId]
|
|
86
|
+
setUIState({ ...prev, expandedTaskIds: ids })
|
|
87
|
+
},
|
|
88
|
+
expandAll(taskIds: string[]): void {
|
|
89
|
+
setUIState({ ...uiState, expandedTaskIds: taskIds })
|
|
90
|
+
},
|
|
91
|
+
setPanelPosition(pos: { x: number; y: number }): void {
|
|
92
|
+
setUIState({ ...uiState, panelPosition: pos })
|
|
93
|
+
},
|
|
94
|
+
open(): void {
|
|
95
|
+
setUIState({ ...uiState, minimized: false, closed: false })
|
|
96
|
+
},
|
|
97
|
+
minimize(): void {
|
|
98
|
+
setUIState({ ...uiState, minimized: true, closed: false })
|
|
99
|
+
},
|
|
100
|
+
close(): void {
|
|
101
|
+
setUIState({ ...uiState, minimized: true, closed: true })
|
|
102
|
+
},
|
|
103
|
+
setTheme(theme: string): void {
|
|
104
|
+
setUIState({ ...uiState, theme })
|
|
105
|
+
},
|
|
106
|
+
reset(): void {
|
|
107
|
+
setUIState(getDefaultUIState())
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function useTaskUI(): TaskUIState {
|
|
113
|
+
return useSyncExternalStore(taskUIStore.subscribe, taskUIStore.getSnapshot, taskUIStore.getSnapshot)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function useTaskUIActions() {
|
|
117
|
+
return taskUIStore.actions
|
|
118
|
+
}
|