@allbluecn/web-app 0.27.1 → 0.27.2
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/package.json +1 -1
- package/src/components/quick-capture/__tests__/quick-capture-panel.test.tsx +143 -26
- package/src/components/quick-capture/quick-capture-panel.tsx +180 -29
- package/src/components/tiptap/notion-like/notion-like-editor.tsx +11 -3
- package/src/lib/changelog/sdk-versions.ts +1 -1
- package/src/lib/desktop-bridge-types.ts +11 -0
- package/src/paraglide/messages/_index.js +3 -0
- package/src/paraglide/messages/quick_capture_paneltitle1.js +30 -0
- package/src/paraglide/messages/quick_capture_pin.js +29 -0
- package/src/paraglide/messages/quick_capture_settings.js +29 -0
- package/src/server/serverFns/story/__tests__/quick-capture.test.ts +45 -0
- package/src/server/serverFns/story/story-crud/quick-capture.ts +17 -3
package/package.json
CHANGED
|
@@ -16,18 +16,22 @@
|
|
|
16
16
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
17
17
|
|
|
18
18
|
// 桌面托盘 mini 快捷记录面板(QuickCapturePanel):
|
|
19
|
-
// 1)
|
|
20
|
-
// 2) 标题 Enter 提交:quickCaptureStoryFn 收到显式 projectId
|
|
19
|
+
// 1) 顶部栏(logo + 「mini 面板」)+ 默认预选独立需求 + 标题占位 + 快捷键文案
|
|
20
|
+
// 2) 标题 Enter 提交:quickCaptureStoryFn 收到显式 projectId 与内联字段缺省值
|
|
21
21
|
// 3) 纯空白标题 Enter 不保存
|
|
22
22
|
// 4) 项目下拉选中「灵感箱」(懒创建固定项)→ Popover 关闭,保存传 kind=inspiration
|
|
23
23
|
// 5) 项目下拉选中普通项目 → 保存传该 projectId
|
|
24
24
|
// 6) 描述内容序列化为 {html,markdown} JSON 透传
|
|
25
|
-
// 7)
|
|
26
|
-
// 8)
|
|
27
|
-
// 9)
|
|
28
|
-
// 10)
|
|
29
|
-
// 11)
|
|
30
|
-
//
|
|
25
|
+
// 7) 内联字段:mock 编辑器切换 state/标签后保存载荷带新值
|
|
26
|
+
// 8) 保存成功:已记录 identifier、标题清空、notifyQuickCapture
|
|
27
|
+
// 9) Esc 隐藏窗口;项目 Popover 展开时 Esc 不隐藏
|
|
28
|
+
// 10) onMiniPanelMode 下发 inspiration 预选灵感箱;unmount 退订
|
|
29
|
+
// 11) 图钉切换:setMiniPanelPinned 调用 + onMiniPanelPinned 回传同步按钮态
|
|
30
|
+
// 12) 设置按钮:popUpTrayMenu 被调
|
|
31
|
+
// 13) 标题字号 md 适配 + NotionDocEditor 收到 hideMobileToolbar
|
|
32
|
+
// 14) 无桥(浏览器直开)降级不抛错
|
|
33
|
+
// 15) 请求在途期间再 Enter 不重复提交
|
|
34
|
+
// 富文本编辑器 / AI 工具栏 / 内联编辑器在 jsdom 以替身注入。
|
|
31
35
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
32
36
|
import { act, cleanup, render, screen, waitFor } from '@testing-library/react'
|
|
33
37
|
import userEvent from '@testing-library/user-event'
|
|
@@ -38,14 +42,21 @@ const quickCaptureStoryFnMock = vi.hoisted(() =>
|
|
|
38
42
|
)
|
|
39
43
|
|
|
40
44
|
const listProjectsBriefFnMock = vi.hoisted(() => vi.fn())
|
|
45
|
+
const listUsersBriefFnMock = vi.hoisted(() => vi.fn())
|
|
46
|
+
const listLabelsFnMock = vi.hoisted(() => vi.fn())
|
|
41
47
|
|
|
42
48
|
const bridgeStub = vi.hoisted(() => {
|
|
43
49
|
const unsubMiniPanelMode = vi.fn()
|
|
50
|
+
const unsubMiniPanelPinned = vi.fn()
|
|
44
51
|
return {
|
|
45
52
|
unsubMiniPanelMode,
|
|
53
|
+
unsubMiniPanelPinned,
|
|
46
54
|
window: vi.fn(),
|
|
47
55
|
notifyQuickCapture: vi.fn(),
|
|
56
|
+
setMiniPanelPinned: vi.fn(),
|
|
57
|
+
popUpTrayMenu: vi.fn(),
|
|
48
58
|
onMiniPanelMode: vi.fn((_cb: (mode: string) => void) => unsubMiniPanelMode),
|
|
59
|
+
onMiniPanelPinned: vi.fn((_cb: (pinned: boolean) => void) => unsubMiniPanelPinned),
|
|
49
60
|
}
|
|
50
61
|
})
|
|
51
62
|
|
|
@@ -57,7 +68,16 @@ const PROJECTS = [
|
|
|
57
68
|
{ id: 'p-work', name: '工作台', displayId: 'WK', icon: 'folder' },
|
|
58
69
|
]
|
|
59
70
|
|
|
60
|
-
|
|
71
|
+
const USERS = [
|
|
72
|
+
{ id: 'u2', username: 'bob', email: 'b@test.dev', avatarConfig: null, bgShape: null, bgColor: null },
|
|
73
|
+
]
|
|
74
|
+
const LABELS = [{ id: 'l1', name: '前端', color: 'blue' }]
|
|
75
|
+
|
|
76
|
+
vi.mock('@/server/serverFns/story', () => ({
|
|
77
|
+
quickCaptureStoryFn: quickCaptureStoryFnMock,
|
|
78
|
+
listUsersBriefFn: listUsersBriefFnMock,
|
|
79
|
+
listLabelsFn: listLabelsFnMock,
|
|
80
|
+
}))
|
|
61
81
|
vi.mock('@/server/serverFns/project', () => ({ listProjectsBriefFn: listProjectsBriefFnMock }))
|
|
62
82
|
vi.mock('@/lib/desktop-bridge-types', () => ({
|
|
63
83
|
getDesktopBridge: () => (noBridge ? null : bridgeStub),
|
|
@@ -70,21 +90,47 @@ vi.mock('@/components/story/ai/use-story-ai-model', () => ({
|
|
|
70
90
|
vi.mock('@/components/shared-kit/icons/lucide-icon-picker/lucide-icon-by-name', () => ({
|
|
71
91
|
LucideIconByName: () => null,
|
|
72
92
|
}))
|
|
73
|
-
//
|
|
93
|
+
// 富文本编辑器替身:textarea + 回传 {html, markdown};data-* 暴露 hideMobileToolbar
|
|
74
94
|
vi.mock('@/components/tiptap/notion-like', async () => {
|
|
75
95
|
const React = await import('react')
|
|
76
96
|
return {
|
|
77
97
|
NotionDocEditor: (props: {
|
|
78
98
|
onChange?: (c: { html: string; markdown: string }) => void
|
|
99
|
+
hideMobileToolbar?: boolean
|
|
79
100
|
}) =>
|
|
80
101
|
React.createElement('textarea', {
|
|
81
102
|
'data-testid': 'description-editor',
|
|
103
|
+
'data-hide-mobile-toolbar': props.hideMobileToolbar ? 'true' : 'false',
|
|
82
104
|
'aria-label': 'description',
|
|
83
105
|
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) =>
|
|
84
106
|
props.onChange?.({ html: `<p>${e.target.value}</p>`, markdown: e.target.value }),
|
|
85
107
|
}),
|
|
86
108
|
}
|
|
87
109
|
})
|
|
110
|
+
// 内联编辑器替身:渲染当前值并提供切换入口(真实组件依赖 Tooltip/Popover 链)
|
|
111
|
+
vi.mock('@/components/story/inline-editors', () => ({
|
|
112
|
+
StateEditor: ({ value, onChange }: { value: string; onChange: (v: string) => void }) => (
|
|
113
|
+
<button type="button" onClick={() => onChange('todo')}>
|
|
114
|
+
状态:{value === 'backlog' ? '待规划' : value}
|
|
115
|
+
</button>
|
|
116
|
+
),
|
|
117
|
+
PriorityEditor: ({ value, onChange }: { value: string; onChange: (v: string) => void }) => (
|
|
118
|
+
<button type="button" onClick={() => onChange('high')}>
|
|
119
|
+
优先级:{value}
|
|
120
|
+
</button>
|
|
121
|
+
),
|
|
122
|
+
AssigneeEditor: ({ onChange }: { onChange: (v: string | null) => void }) => (
|
|
123
|
+
<button type="button" onClick={() => onChange('u2')}>
|
|
124
|
+
负责人
|
|
125
|
+
</button>
|
|
126
|
+
),
|
|
127
|
+
DateEditor: ({ label }: { label: string }) => <button type="button">{label}</button>,
|
|
128
|
+
LabelsEditor: ({ onChange }: { onChange: (v: string[]) => void }) => (
|
|
129
|
+
<button type="button" onClick={() => onChange(['l1'])}>
|
|
130
|
+
标签
|
|
131
|
+
</button>
|
|
132
|
+
),
|
|
133
|
+
}))
|
|
88
134
|
|
|
89
135
|
import { QuickCapturePanel } from '../quick-capture-panel'
|
|
90
136
|
|
|
@@ -101,14 +147,17 @@ beforeEach(() => {
|
|
|
101
147
|
noBridge = false
|
|
102
148
|
vi.clearAllMocks()
|
|
103
149
|
listProjectsBriefFnMock.mockResolvedValue(PROJECTS)
|
|
150
|
+
listUsersBriefFnMock.mockResolvedValue(USERS)
|
|
151
|
+
listLabelsFnMock.mockResolvedValue(LABELS)
|
|
104
152
|
})
|
|
105
153
|
|
|
106
154
|
afterEach(() => cleanup())
|
|
107
155
|
|
|
108
156
|
describe('QuickCapturePanel', () => {
|
|
109
|
-
it('
|
|
157
|
+
it('渲染顶部栏(logo + mini 面板)、默认预选独立需求与快捷键文案', async () => {
|
|
110
158
|
renderPanel()
|
|
111
|
-
expect(screen.
|
|
159
|
+
expect(screen.getAllByAltText('AllBlue').length).toBeGreaterThan(0)
|
|
160
|
+
expect(screen.getByText('mini 面板')).toBeTruthy()
|
|
112
161
|
// 不再有可点击的「需求」「灵感」双 tab
|
|
113
162
|
expect(screen.queryByRole('button', { name: '需求' })).toBeNull()
|
|
114
163
|
expect(screen.queryByRole('button', { name: '灵感' })).toBeNull()
|
|
@@ -117,13 +166,24 @@ describe('QuickCapturePanel', () => {
|
|
|
117
166
|
expect(screen.getByText('Enter 记录 · Shift+Enter 换行 · Esc 关闭')).toBeTruthy()
|
|
118
167
|
})
|
|
119
168
|
|
|
120
|
-
it('标题 Enter 提交:带默认独立需求 projectId
|
|
169
|
+
it('标题 Enter 提交:带默认独立需求 projectId 与内联字段缺省值', async () => {
|
|
121
170
|
const user = userEvent.setup()
|
|
122
171
|
renderPanel()
|
|
123
172
|
await screen.findByText('独立需求')
|
|
124
173
|
await user.type(screen.getByPlaceholderText('标题'), '想法A{Enter}')
|
|
125
174
|
expect(quickCaptureStoryFnMock).toHaveBeenCalledWith({
|
|
126
|
-
data: {
|
|
175
|
+
data: {
|
|
176
|
+
kind: 'story',
|
|
177
|
+
name: '想法A',
|
|
178
|
+
description: undefined,
|
|
179
|
+
projectId: 'p-story',
|
|
180
|
+
state: 'backlog',
|
|
181
|
+
priority: 'none',
|
|
182
|
+
assigneeId: undefined,
|
|
183
|
+
startDate: undefined,
|
|
184
|
+
targetDate: undefined,
|
|
185
|
+
labelIds: [],
|
|
186
|
+
},
|
|
127
187
|
})
|
|
128
188
|
})
|
|
129
189
|
|
|
@@ -142,11 +202,14 @@ describe('QuickCapturePanel', () => {
|
|
|
142
202
|
await user.click(screen.getByRole('button', { name: /独立需求/ }))
|
|
143
203
|
await screen.findByText('工作台')
|
|
144
204
|
await user.click(screen.getByText('灵感箱'))
|
|
145
|
-
// Popover 关闭 → 列表内容卸载(「工作台」只剩触发器外的列表项,已不再渲染)
|
|
146
205
|
expect(screen.queryByText('工作台')).toBeNull()
|
|
147
206
|
await user.type(screen.getByPlaceholderText('标题'), '灵感一闪{Enter}')
|
|
148
207
|
expect(quickCaptureStoryFnMock).toHaveBeenCalledWith({
|
|
149
|
-
data: {
|
|
208
|
+
data: expect.objectContaining({
|
|
209
|
+
kind: 'inspiration',
|
|
210
|
+
name: '灵感一闪',
|
|
211
|
+
projectId: undefined,
|
|
212
|
+
}),
|
|
150
213
|
})
|
|
151
214
|
})
|
|
152
215
|
|
|
@@ -159,7 +222,7 @@ describe('QuickCapturePanel', () => {
|
|
|
159
222
|
expect(screen.queryByText('灵感箱')).toBeNull()
|
|
160
223
|
await user.type(screen.getByPlaceholderText('标题'), '需求B{Enter}')
|
|
161
224
|
expect(quickCaptureStoryFnMock).toHaveBeenCalledWith({
|
|
162
|
-
data: { kind: 'story', name: '需求B',
|
|
225
|
+
data: expect.objectContaining({ kind: 'story', name: '需求B', projectId: 'p-work' }),
|
|
163
226
|
})
|
|
164
227
|
})
|
|
165
228
|
|
|
@@ -171,12 +234,31 @@ describe('QuickCapturePanel', () => {
|
|
|
171
234
|
await user.type(screen.getByTestId('description-editor'), '实现细节')
|
|
172
235
|
await user.click(screen.getByRole('button', { name: '记录' }))
|
|
173
236
|
expect(quickCaptureStoryFnMock).toHaveBeenCalledWith({
|
|
174
|
-
data: {
|
|
175
|
-
kind: 'story',
|
|
237
|
+
data: expect.objectContaining({
|
|
176
238
|
name: '想法C',
|
|
177
239
|
description: JSON.stringify({ html: '<p>实现细节</p>', markdown: '实现细节' }),
|
|
178
|
-
|
|
179
|
-
|
|
240
|
+
}),
|
|
241
|
+
})
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it('内联字段:切换状态/优先级/负责人/标签后保存载荷带新值', async () => {
|
|
245
|
+
const user = userEvent.setup()
|
|
246
|
+
renderPanel()
|
|
247
|
+
await screen.findByText('独立需求')
|
|
248
|
+
// 「待规划」默认态(backlog)可见
|
|
249
|
+
expect(screen.getByText('状态:待规划')).toBeTruthy()
|
|
250
|
+
await user.click(screen.getByText('状态:待规划'))
|
|
251
|
+
await user.click(screen.getByText('优先级:none'))
|
|
252
|
+
await user.click(screen.getByText('负责人'))
|
|
253
|
+
await user.click(screen.getByText('标签'))
|
|
254
|
+
await user.type(screen.getByPlaceholderText('标题'), '带字段{Enter}')
|
|
255
|
+
expect(quickCaptureStoryFnMock).toHaveBeenCalledWith({
|
|
256
|
+
data: expect.objectContaining({
|
|
257
|
+
state: 'todo',
|
|
258
|
+
priority: 'high',
|
|
259
|
+
assigneeId: 'u2',
|
|
260
|
+
labelIds: ['l1'],
|
|
261
|
+
}),
|
|
180
262
|
})
|
|
181
263
|
})
|
|
182
264
|
|
|
@@ -199,7 +281,6 @@ describe('QuickCapturePanel', () => {
|
|
|
199
281
|
await screen.findByText('工作台')
|
|
200
282
|
await user.keyboard('{Escape}')
|
|
201
283
|
expect(bridgeStub.window).not.toHaveBeenCalled()
|
|
202
|
-
// 关闭 Popover 后再 Esc → 收起窗口
|
|
203
284
|
await user.keyboard('{Escape}')
|
|
204
285
|
expect(bridgeStub.window).toHaveBeenCalledWith('hide')
|
|
205
286
|
})
|
|
@@ -211,13 +292,49 @@ describe('QuickCapturePanel', () => {
|
|
|
211
292
|
const notifyMode = bridgeStub.onMiniPanelMode.mock.calls[0]?.[0]
|
|
212
293
|
expect(notifyMode).toBeTypeOf('function')
|
|
213
294
|
act(() => notifyMode?.('inspiration'))
|
|
214
|
-
await waitFor(() =>
|
|
215
|
-
expect(screen.getByRole('button', { name: /灵感箱/ })).toBeTruthy(),
|
|
216
|
-
)
|
|
295
|
+
await waitFor(() => expect(screen.getByRole('button', { name: /灵感箱/ })).toBeTruthy())
|
|
217
296
|
unmount()
|
|
218
297
|
expect(bridgeStub.unsubMiniPanelMode).toHaveBeenCalled()
|
|
219
298
|
})
|
|
220
299
|
|
|
300
|
+
it('图钉:点击调 setMiniPanelPinned(true),回传 pin 态同步按钮', async () => {
|
|
301
|
+
const user = userEvent.setup()
|
|
302
|
+
renderPanel()
|
|
303
|
+
await screen.findByText('独立需求')
|
|
304
|
+
const pinBtn = screen.getByRole('button', { name: '常显示' })
|
|
305
|
+
expect(pinBtn.getAttribute('aria-pressed')).toBe('false')
|
|
306
|
+
await user.click(pinBtn)
|
|
307
|
+
expect(bridgeStub.setMiniPanelPinned).toHaveBeenCalledWith(true)
|
|
308
|
+
expect(pinBtn.getAttribute('aria-pressed')).toBe('true')
|
|
309
|
+
// main 回传真实态(窗口重建/托盘侧变更)
|
|
310
|
+
const notifyPinned = bridgeStub.onMiniPanelPinned.mock.calls[0]?.[0]
|
|
311
|
+
expect(notifyPinned).toBeTypeOf('function')
|
|
312
|
+
act(() => notifyPinned?.(true))
|
|
313
|
+
await waitFor(() =>
|
|
314
|
+
expect(screen.getByRole('button', { name: '常显示' }).getAttribute('aria-pressed')).toBe(
|
|
315
|
+
'true',
|
|
316
|
+
),
|
|
317
|
+
)
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
it('设置按钮:请求弹出托盘菜单(与菜单栏右键同一份)', async () => {
|
|
321
|
+
const user = userEvent.setup()
|
|
322
|
+
renderPanel()
|
|
323
|
+
await user.click(screen.getByRole('button', { name: '设置' }))
|
|
324
|
+
expect(bridgeStub.popUpTrayMenu).toHaveBeenCalledTimes(1)
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
it('标题字号做 md 适配;编辑器收到 hideMobileToolbar(隐藏窄窗底部工具栏)', async () => {
|
|
328
|
+
renderPanel()
|
|
329
|
+
await screen.findByText('独立需求')
|
|
330
|
+
const title = screen.getByPlaceholderText('标题')
|
|
331
|
+
expect(title.className).toContain('text-base')
|
|
332
|
+
expect(title.className).toContain('md:text-lg')
|
|
333
|
+
expect(screen.getByTestId('description-editor').getAttribute('data-hide-mobile-toolbar')).toBe(
|
|
334
|
+
'true',
|
|
335
|
+
)
|
|
336
|
+
})
|
|
337
|
+
|
|
221
338
|
it('无桥(浏览器直开):渲染与保存不抛错', async () => {
|
|
222
339
|
const user = userEvent.setup()
|
|
223
340
|
noBridge = true
|
|
@@ -225,7 +342,7 @@ describe('QuickCapturePanel', () => {
|
|
|
225
342
|
await screen.findByText('独立需求')
|
|
226
343
|
await user.type(screen.getByPlaceholderText('标题'), '想法E{Enter}')
|
|
227
344
|
expect(quickCaptureStoryFnMock).toHaveBeenCalledWith({
|
|
228
|
-
data: {
|
|
345
|
+
data: expect.objectContaining({ name: '想法E', projectId: 'p-story' }),
|
|
229
346
|
})
|
|
230
347
|
expect(await screen.findByText('已记录 STORY-1')).toBeTruthy()
|
|
231
348
|
await user.keyboard('{Escape}')
|
|
@@ -16,17 +16,17 @@
|
|
|
16
16
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
17
17
|
|
|
18
18
|
// 桌面托盘 mini 快捷记录面板(/quick-capture 独立窗口的整页组件):单一「需求/灵感」
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
19
|
+
// 入口,字段与布局对齐主应用「创建需求弹窗」(顶部栏 + 项目选择 + 标题 + 富文本描述
|
|
20
|
+
// + 内联字段 chip 行 + 记录按钮)。顶部栏右侧:钉(常显示,main 侧抑制 blur 隐藏并
|
|
21
|
+
// 置顶)/ 设置(弹出与菜单栏图标右键同一份托盘菜单)。落库走 quickCaptureStoryFn——
|
|
22
|
+
// kind 只作为「灵感箱」懒创建标记:项目选择器的固定「灵感箱」项 → kind=inspiration;
|
|
22
23
|
// 其余 → kind=story + 显式 projectId(或默认独立需求)。键盘约定:标题框 Enter 提交
|
|
23
|
-
// (描述编辑器内 Enter/Shift+Enter 由 TipTap 自然换行)、Esc 经桥 window("hide")
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
// 降级不抛错。
|
|
24
|
+
// (描述编辑器内 Enter/Shift+Enter 由 TipTap 自然换行)、Esc 经桥 window("hide") 收起
|
|
25
|
+
// 窗口(项目 Popover 展开时 Esc 只关 Popover)。桥消费一律 getDesktopBridge() + 可选
|
|
26
|
+
// 链——旧壳缺少新方法、浏览器直开无桥,全部优雅降级不抛错。
|
|
27
27
|
import { useCallback, useEffect, useRef, useState } from "react"
|
|
28
28
|
import type { KeyboardEvent } from "react"
|
|
29
|
-
import { Sprout } from "lucide-react"
|
|
29
|
+
import { CalendarCheck, CalendarPlus2, Pin, PinOff, Settings, Sprout } from "lucide-react"
|
|
30
30
|
import { useQuery } from "@tanstack/react-query"
|
|
31
31
|
|
|
32
32
|
import { m } from "@/paraglide/messages.js"
|
|
@@ -37,16 +37,26 @@ import {
|
|
|
37
37
|
BUILTIN_INDEPENDENT_DISPLAY_ID,
|
|
38
38
|
} from "@/lib/builtin-project"
|
|
39
39
|
import { serializeStoryDescription } from "@/shared/story/types"
|
|
40
|
-
import {
|
|
40
|
+
import type { UserBrief } from "@/shared/story/types"
|
|
41
|
+
import type { StoryPriority, StoryState } from "@/shared/story/constants"
|
|
42
|
+
import { quickCaptureStoryFn, listUsersBriefFn, listLabelsFn } from "@/server/serverFns/story"
|
|
41
43
|
import { listProjectsBriefFn } from "@/server/serverFns/project"
|
|
42
44
|
import { Button } from "@/components/ui/button"
|
|
43
45
|
import { Input } from "@/components/ui/input"
|
|
44
46
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
|
47
|
+
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
|
45
48
|
import { toast } from "@/components/ui/toast"
|
|
46
49
|
import { LucideIconByName } from "@/components/shared-kit/icons/lucide-icon-picker/lucide-icon-by-name"
|
|
47
50
|
import { NotionDocEditor } from "@/components/tiptap/notion-like"
|
|
48
51
|
import { StoryAiToolbar } from "@/components/story/ai/story-ai-toolbar"
|
|
49
52
|
import { useStoryAiModel } from "@/components/story/ai/use-story-ai-model"
|
|
53
|
+
import {
|
|
54
|
+
AssigneeEditor,
|
|
55
|
+
DateEditor,
|
|
56
|
+
LabelsEditor,
|
|
57
|
+
PriorityEditor,
|
|
58
|
+
StateEditor,
|
|
59
|
+
} from "@/components/story/inline-editors"
|
|
50
60
|
|
|
51
61
|
export type QuickCaptureMode = "story" | "inspiration"
|
|
52
62
|
|
|
@@ -56,6 +66,8 @@ const FEEDBACK_CLEAR_MS = 1500
|
|
|
56
66
|
/** 项目选择:普通项目 / 灵感箱固定项(后者由后端懒创建,故不携带 projectId) */
|
|
57
67
|
type ProjectPick = { type: "project"; id: string } | { type: "inbox" }
|
|
58
68
|
|
|
69
|
+
type LabelLike = { id: string; name: string; color: string }
|
|
70
|
+
|
|
59
71
|
interface ProjectBriefRow {
|
|
60
72
|
id: string
|
|
61
73
|
name: string
|
|
@@ -71,10 +83,22 @@ export function QuickCapturePanel({ initialMode }: { initialMode: QuickCaptureMo
|
|
|
71
83
|
})
|
|
72
84
|
const [projectPick, setProjectPick] = useState<ProjectPick | null>(null)
|
|
73
85
|
const [projectOpen, setProjectOpen] = useState(false)
|
|
86
|
+
// 内联字段(与创建需求弹窗同口径)
|
|
87
|
+
const [state, setState] = useState<StoryState>("backlog")
|
|
88
|
+
const [priority, setPriority] = useState<StoryPriority>("none")
|
|
89
|
+
const [assigneeId, setAssigneeId] = useState<string | null>(null)
|
|
90
|
+
const [startDate, setStartDate] = useState("")
|
|
91
|
+
const [targetDate, setTargetDate] = useState("")
|
|
92
|
+
const [labelIds, setLabelIds] = useState<string[]>([])
|
|
93
|
+
// 新建标签并入本地清单(否则 chip 因字典缺名不渲染)
|
|
94
|
+
const [extraLabels, setExtraLabels] = useState<LabelLike[]>([])
|
|
95
|
+
|
|
74
96
|
const [savedIdentifier, setSavedIdentifier] = useState<string | null>(null)
|
|
75
97
|
const [saving, setSaving] = useState(false)
|
|
76
98
|
const [aiGenerating, setAiGenerating] = useState(false)
|
|
77
99
|
const [isEditorFocused, setIsEditorFocused] = useState(false)
|
|
100
|
+
const [pinned, setPinned] = useState(false)
|
|
101
|
+
|
|
78
102
|
const feedbackTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
79
103
|
// 保存防重:请求在途(标题尚未清空)期间忽略再次提交,防重复落库
|
|
80
104
|
const savingRef = useRef(false)
|
|
@@ -96,6 +120,22 @@ export function QuickCapturePanel({ initialMode }: { initialMode: QuickCaptureMo
|
|
|
96
120
|
// 已存在时它本身就是普通项目项,选中走显式 projectId,不会出现重复入口
|
|
97
121
|
const showInboxOption = !inboxProject
|
|
98
122
|
|
|
123
|
+
const activeProjectId = projectPick?.type === "project" ? projectPick.id : undefined
|
|
124
|
+
|
|
125
|
+
// 负责人候选(项目成员)与标签字典(全局 ∪ 本项目):项目解析后按需取
|
|
126
|
+
const usersQuery = useQuery({
|
|
127
|
+
queryKey: ["users-brief", activeProjectId ?? "none"],
|
|
128
|
+
queryFn: () => listUsersBriefFn({ data: { projectId: activeProjectId } }),
|
|
129
|
+
enabled: !!activeProjectId,
|
|
130
|
+
})
|
|
131
|
+
const users = (usersQuery.data ?? []) as UserBrief[]
|
|
132
|
+
const labelsQuery = useQuery({
|
|
133
|
+
queryKey: ["labels", activeProjectId ?? "none"],
|
|
134
|
+
queryFn: () => listLabelsFn({ data: { projectId: activeProjectId } }),
|
|
135
|
+
enabled: !!activeProjectId,
|
|
136
|
+
})
|
|
137
|
+
const labels = [...((labelsQuery.data ?? []) as LabelLike[]), ...extraLabels]
|
|
138
|
+
|
|
99
139
|
const pickForMode = useCallback(
|
|
100
140
|
(mode: QuickCaptureMode): ProjectPick | null => {
|
|
101
141
|
if (mode === "inspiration") {
|
|
@@ -123,6 +163,13 @@ export function QuickCapturePanel({ initialMode }: { initialMode: QuickCaptureMo
|
|
|
123
163
|
return () => unsubscribe?.()
|
|
124
164
|
}, [pickForMode])
|
|
125
165
|
|
|
166
|
+
// pin 真实态(窗口重建/托盘侧变更后按钮显示正确);unsubscribe 进 cleanup
|
|
167
|
+
useEffect(() => {
|
|
168
|
+
const bridge = getDesktopBridge()
|
|
169
|
+
const unsubscribe = bridge?.onMiniPanelPinned?.((next) => setPinned(next === true))
|
|
170
|
+
return () => unsubscribe?.()
|
|
171
|
+
}, [])
|
|
172
|
+
|
|
126
173
|
// 反馈定时器 unmount 清理(组件销毁后不再 setState,防泄漏)
|
|
127
174
|
useEffect(() => {
|
|
128
175
|
return () => {
|
|
@@ -148,6 +195,21 @@ export function QuickCapturePanel({ initialMode }: { initialMode: QuickCaptureMo
|
|
|
148
195
|
projectPick?.type === "project" ? projects.find((p) => p.id === projectPick.id) : undefined
|
|
149
196
|
const selectedLabel =
|
|
150
197
|
projectPick?.type === "inbox" ? m.quick_capture_inbox() : selectedProject?.name
|
|
198
|
+
const selectedUser = users.find((u) => u.id === assigneeId) ?? null
|
|
199
|
+
|
|
200
|
+
const resetFields = (opts: { keepProject: boolean }) => {
|
|
201
|
+
setName("")
|
|
202
|
+
setDescription({ html: "", markdown: "" })
|
|
203
|
+
tiptapEditorRef.current?.commands?.clearContent?.()
|
|
204
|
+
setState("backlog")
|
|
205
|
+
setPriority("none")
|
|
206
|
+
setAssigneeId(null)
|
|
207
|
+
setStartDate("")
|
|
208
|
+
setTargetDate("")
|
|
209
|
+
setLabelIds([])
|
|
210
|
+
setExtraLabels([])
|
|
211
|
+
if (!opts.keepProject) setProjectPick(null)
|
|
212
|
+
}
|
|
151
213
|
|
|
152
214
|
const save = async () => {
|
|
153
215
|
if (savingRef.current) return
|
|
@@ -164,12 +226,17 @@ export function QuickCapturePanel({ initialMode }: { initialMode: QuickCaptureMo
|
|
|
164
226
|
? serializeStoryDescription(description.html, description.markdown)
|
|
165
227
|
: undefined,
|
|
166
228
|
projectId: projectPick?.type === "project" ? projectPick.id : undefined,
|
|
229
|
+
state,
|
|
230
|
+
priority,
|
|
231
|
+
assigneeId: assigneeId ?? undefined,
|
|
232
|
+
startDate: startDate || undefined,
|
|
233
|
+
targetDate: targetDate || undefined,
|
|
234
|
+
labelIds,
|
|
167
235
|
},
|
|
168
236
|
})
|
|
169
237
|
setSavedIdentifier(res.identifier)
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
tiptapEditorRef.current?.commands?.clearContent?.()
|
|
238
|
+
// 连续记录:保留项目选择,其余字段复位
|
|
239
|
+
resetFields({ keepProject: true })
|
|
173
240
|
const bridge = getDesktopBridge()
|
|
174
241
|
bridge?.notifyQuickCapture?.()
|
|
175
242
|
if (feedbackTimerRef.current) clearTimeout(feedbackTimerRef.current)
|
|
@@ -199,6 +266,21 @@ export function QuickCapturePanel({ initialMode }: { initialMode: QuickCaptureMo
|
|
|
199
266
|
}
|
|
200
267
|
}
|
|
201
268
|
|
|
269
|
+
const handlePickProject = (pick: ProjectPick) => {
|
|
270
|
+
// 负责人候选与标签字典随项目变化:切换项目时清空相关选择
|
|
271
|
+
setAssigneeId(null)
|
|
272
|
+
setLabelIds([])
|
|
273
|
+
setExtraLabels([])
|
|
274
|
+
setProjectPick(pick)
|
|
275
|
+
setProjectOpen(false)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const togglePinned = () => {
|
|
279
|
+
const next = !pinned
|
|
280
|
+
setPinned(next)
|
|
281
|
+
getDesktopBridge()?.setMiniPanelPinned?.(next)
|
|
282
|
+
}
|
|
283
|
+
|
|
202
284
|
const showHint = !isEditorFocused && !description.markdown.trim()
|
|
203
285
|
|
|
204
286
|
return (
|
|
@@ -206,10 +288,54 @@ export function QuickCapturePanel({ initialMode }: { initialMode: QuickCaptureMo
|
|
|
206
288
|
className="flex h-dvh flex-col bg-background text-foreground"
|
|
207
289
|
onKeyDown={handlePanelKeyDown}
|
|
208
290
|
>
|
|
209
|
-
<header className="flex items-center border-b border-border p-2">
|
|
210
|
-
<
|
|
211
|
-
|
|
212
|
-
|
|
291
|
+
<header className="flex items-center justify-between gap-2 border-b border-border p-2">
|
|
292
|
+
<div className="flex min-w-0 items-center gap-2">
|
|
293
|
+
<img src="/logo/allblue-logo-text-dark.svg" alt="AllBlue" className="h-4 dark:hidden" />
|
|
294
|
+
<img
|
|
295
|
+
src="/logo/allblue-logo-text-light.svg"
|
|
296
|
+
alt="AllBlue"
|
|
297
|
+
className="hidden h-4 dark:block"
|
|
298
|
+
/>
|
|
299
|
+
<span className="shrink-0 rounded-md bg-muted px-1.5 py-0.5 text-[11px] font-medium text-muted-foreground">
|
|
300
|
+
{m.quick_capture_panelTitle()}
|
|
301
|
+
</span>
|
|
302
|
+
</div>
|
|
303
|
+
<div className="flex shrink-0 items-center gap-0.5">
|
|
304
|
+
<Tooltip>
|
|
305
|
+
<TooltipTrigger
|
|
306
|
+
render={
|
|
307
|
+
<Button
|
|
308
|
+
type="button"
|
|
309
|
+
variant="ghost"
|
|
310
|
+
size="icon-sm"
|
|
311
|
+
aria-label={m.quick_capture_pin()}
|
|
312
|
+
aria-pressed={pinned}
|
|
313
|
+
className={pinned ? "text-primary" : undefined}
|
|
314
|
+
onClick={togglePinned}
|
|
315
|
+
/>
|
|
316
|
+
}
|
|
317
|
+
>
|
|
318
|
+
{pinned ? <PinOff className="size-4" /> : <Pin className="size-4" />}
|
|
319
|
+
</TooltipTrigger>
|
|
320
|
+
<TooltipContent>{m.quick_capture_pin()}</TooltipContent>
|
|
321
|
+
</Tooltip>
|
|
322
|
+
<Tooltip>
|
|
323
|
+
<TooltipTrigger
|
|
324
|
+
render={
|
|
325
|
+
<Button
|
|
326
|
+
type="button"
|
|
327
|
+
variant="ghost"
|
|
328
|
+
size="icon-sm"
|
|
329
|
+
aria-label={m.quick_capture_settings()}
|
|
330
|
+
onClick={() => getDesktopBridge()?.popUpTrayMenu?.()}
|
|
331
|
+
/>
|
|
332
|
+
}
|
|
333
|
+
>
|
|
334
|
+
<Settings className="size-4" />
|
|
335
|
+
</TooltipTrigger>
|
|
336
|
+
<TooltipContent>{m.quick_capture_settings()}</TooltipContent>
|
|
337
|
+
</Tooltip>
|
|
338
|
+
</div>
|
|
213
339
|
</header>
|
|
214
340
|
|
|
215
341
|
<div className="flex-1 space-y-3 overflow-y-auto p-3">
|
|
@@ -219,7 +345,7 @@ export function QuickCapturePanel({ initialMode }: { initialMode: QuickCaptureMo
|
|
|
219
345
|
<Button
|
|
220
346
|
variant="outline"
|
|
221
347
|
size="sm"
|
|
222
|
-
className="h-8
|
|
348
|
+
className="h-8 gap-1.5 border-0 bg-muted/50 px-2.5 text-sm font-medium"
|
|
223
349
|
/>
|
|
224
350
|
}
|
|
225
351
|
>
|
|
@@ -237,10 +363,7 @@ export function QuickCapturePanel({ initialMode }: { initialMode: QuickCaptureMo
|
|
|
237
363
|
<button
|
|
238
364
|
key={p.id}
|
|
239
365
|
type="button"
|
|
240
|
-
onClick={() => {
|
|
241
|
-
setProjectPick({ type: "project", id: p.id })
|
|
242
|
-
setProjectOpen(false)
|
|
243
|
-
}}
|
|
366
|
+
onClick={() => handlePickProject({ type: "project", id: p.id })}
|
|
244
367
|
className={`flex w-full items-center justify-between gap-2 rounded px-2 py-1.5 text-sm ${
|
|
245
368
|
projectPick?.type === "project" && projectPick.id === p.id
|
|
246
369
|
? "bg-accent font-medium"
|
|
@@ -261,10 +384,7 @@ export function QuickCapturePanel({ initialMode }: { initialMode: QuickCaptureMo
|
|
|
261
384
|
{showInboxOption && (
|
|
262
385
|
<button
|
|
263
386
|
type="button"
|
|
264
|
-
onClick={() => {
|
|
265
|
-
setProjectPick({ type: "inbox" })
|
|
266
|
-
setProjectOpen(false)
|
|
267
|
-
}}
|
|
387
|
+
onClick={() => handlePickProject({ type: "inbox" })}
|
|
268
388
|
className={`flex w-full items-center justify-between gap-2 rounded px-2 py-1.5 text-sm ${
|
|
269
389
|
projectPick?.type === "inbox" ? "bg-accent font-medium" : "hover:bg-muted"
|
|
270
390
|
}`}
|
|
@@ -285,11 +405,11 @@ export function QuickCapturePanel({ initialMode }: { initialMode: QuickCaptureMo
|
|
|
285
405
|
onKeyDown={handleTitleKeyDown}
|
|
286
406
|
placeholder={m.story_column_title()}
|
|
287
407
|
maxLength={200}
|
|
288
|
-
className="h-10 text-lg"
|
|
408
|
+
className="h-10 text-base md:text-lg"
|
|
289
409
|
/>
|
|
290
410
|
|
|
291
411
|
<div
|
|
292
|
-
className={`notion-full-width relative flex max-h-
|
|
412
|
+
className={`notion-full-width relative flex max-h-44 min-h-32 flex-col overflow-y-auto rounded-md border border-border${
|
|
293
413
|
showHint ? " story-editor-no-placeholder" : ""
|
|
294
414
|
}`}
|
|
295
415
|
ref={editorWrapRef}
|
|
@@ -304,6 +424,7 @@ export function QuickCapturePanel({ initialMode }: { initialMode: QuickCaptureMo
|
|
|
304
424
|
showHeader={false}
|
|
305
425
|
hideDragHandle
|
|
306
426
|
hideAi
|
|
427
|
+
hideMobileToolbar
|
|
307
428
|
onEditorReady={onEditorReady}
|
|
308
429
|
editorClassName="story-editor !ml-0"
|
|
309
430
|
disableMobileFullscreen
|
|
@@ -316,7 +437,7 @@ export function QuickCapturePanel({ initialMode }: { initialMode: QuickCaptureMo
|
|
|
316
437
|
)}
|
|
317
438
|
</div>
|
|
318
439
|
|
|
319
|
-
<div className="mr-2 mb-1 flex shrink-0 items-center gap-1">
|
|
440
|
+
<div className="mr-2 mb-1 flex shrink-0 items-center justify-end gap-1">
|
|
320
441
|
<StoryAiToolbar
|
|
321
442
|
description={description.markdown}
|
|
322
443
|
title={name}
|
|
@@ -326,10 +447,40 @@ export function QuickCapturePanel({ initialMode }: { initialMode: QuickCaptureMo
|
|
|
326
447
|
groups={aiGroups}
|
|
327
448
|
onModelChange={setAiModel}
|
|
328
449
|
onGenerating={setAiGenerating}
|
|
329
|
-
fill
|
|
330
450
|
/>
|
|
331
451
|
</div>
|
|
332
452
|
</div>
|
|
453
|
+
|
|
454
|
+
<div className="flex flex-wrap items-center gap-1.5">
|
|
455
|
+
<StateEditor value={state} onChange={setState} variant="button" />
|
|
456
|
+
<PriorityEditor value={priority} onChange={setPriority} variant="button" />
|
|
457
|
+
<AssigneeEditor
|
|
458
|
+
assignee={selectedUser}
|
|
459
|
+
users={users}
|
|
460
|
+
onChange={(v) => setAssigneeId(v)}
|
|
461
|
+
variant="button"
|
|
462
|
+
/>
|
|
463
|
+
<DateEditor
|
|
464
|
+
value={startDate || null}
|
|
465
|
+
onChange={(v) => setStartDate(v ?? "")}
|
|
466
|
+
label={m.story_create_startDate()}
|
|
467
|
+
icon={CalendarPlus2}
|
|
468
|
+
/>
|
|
469
|
+
<DateEditor
|
|
470
|
+
value={targetDate || null}
|
|
471
|
+
onChange={(v) => setTargetDate(v ?? "")}
|
|
472
|
+
label={m.story_create_dueDate()}
|
|
473
|
+
icon={CalendarCheck}
|
|
474
|
+
/>
|
|
475
|
+
<LabelsEditor
|
|
476
|
+
projectId={activeProjectId ?? ""}
|
|
477
|
+
labelIds={labelIds}
|
|
478
|
+
labels={labels}
|
|
479
|
+
onChange={setLabelIds}
|
|
480
|
+
variant="button"
|
|
481
|
+
onLabelCreated={(label) => setExtraLabels((prev) => [...prev, label])}
|
|
482
|
+
/>
|
|
483
|
+
</div>
|
|
333
484
|
</div>
|
|
334
485
|
|
|
335
486
|
<footer className="space-y-1.5 border-t border-border p-2">
|
|
@@ -108,6 +108,11 @@ export interface NotionDocEditorProps {
|
|
|
108
108
|
hideAi?: boolean
|
|
109
109
|
/** 禁用 480px 窄屏全屏样式(用于嵌入式场景如需求详情,避免编辑器抢占整个视口) */
|
|
110
110
|
disableMobileFullscreen?: boolean
|
|
111
|
+
/**
|
|
112
|
+
* 隐藏 480px 窄屏的底部移动端工具栏(MobileToolbar,portal 到 body)——桌面 mini
|
|
113
|
+
* 面板等 360px 宽的嵌入式窄窗会被它盖住底部按钮;默认 false,不影响其他消费方
|
|
114
|
+
*/
|
|
115
|
+
hideMobileToolbar?: boolean
|
|
111
116
|
documentSuggestion?: DocumentSuggestion
|
|
112
117
|
mentionUserSource?: MentionUserSource
|
|
113
118
|
aiModelState?: AiModelState
|
|
@@ -194,7 +199,7 @@ function DocReferenceOverlay({
|
|
|
194
199
|
/**
|
|
195
200
|
* EditorContent component that renders the actual editor
|
|
196
201
|
*/
|
|
197
|
-
export function EditorContentArea({ hideDragHandle, hideInsertButton, hideAi }: { hideDragHandle?: boolean; hideInsertButton?: boolean; hideAi?: boolean }) {
|
|
202
|
+
export function EditorContentArea({ hideDragHandle, hideInsertButton, hideAi, hideMobileToolbar }: { hideDragHandle?: boolean; hideInsertButton?: boolean; hideAi?: boolean; hideMobileToolbar?: boolean }) {
|
|
198
203
|
const { editor } = useContext(EditorContext)!
|
|
199
204
|
const kbId = useContext(KbIdContext)
|
|
200
205
|
const [containerEl, setContainerEl] = useState<HTMLElement | null>(null)
|
|
@@ -254,7 +259,7 @@ export function EditorContentArea({ hideDragHandle, hideInsertButton, hideAi }:
|
|
|
254
259
|
<ReferenceDropdownMenu currentKbId={kbId} />
|
|
255
260
|
<SlashDropdownMenu config={hideAi ? { enabledItems: SLASH_NON_AI_ITEMS } : undefined} />
|
|
256
261
|
<NotionToolbarFloating hideAi={hideAi} />
|
|
257
|
-
{createPortal(<MobileToolbar />, document.body)}
|
|
262
|
+
{hideMobileToolbar ? null : createPortal(<MobileToolbar />, document.body)}
|
|
258
263
|
</EditorContent>
|
|
259
264
|
<DocReferenceOverlay editor={editor} containerEl={containerEl} />
|
|
260
265
|
</>
|
|
@@ -280,6 +285,7 @@ function NotionDocEditorContent({
|
|
|
280
285
|
editorClassName,
|
|
281
286
|
hideAi,
|
|
282
287
|
disableMobileFullscreen,
|
|
288
|
+
hideMobileToolbar,
|
|
283
289
|
documentSuggestion,
|
|
284
290
|
mentionUserSource,
|
|
285
291
|
aiModelState,
|
|
@@ -396,7 +402,7 @@ function NotionDocEditorContent({
|
|
|
396
402
|
<EditorContext.Provider value={{ editor }}>
|
|
397
403
|
{showHeader && <NotionEditorHeader />}
|
|
398
404
|
<div className="notion-like-editor-layout ">
|
|
399
|
-
<EditorContentArea hideDragHandle={hideDragHandle} hideInsertButton={hideInsertButton} hideAi={hideAi} />
|
|
405
|
+
<EditorContentArea hideDragHandle={hideDragHandle} hideInsertButton={hideInsertButton} hideAi={hideAi} hideMobileToolbar={hideMobileToolbar} />
|
|
400
406
|
{showTocSidebar && <TocSidebar className={tocSidebarClassName} topOffset={48} />}
|
|
401
407
|
</div>
|
|
402
408
|
|
|
@@ -437,6 +443,7 @@ export function NotionDocEditor({
|
|
|
437
443
|
editorClassName,
|
|
438
444
|
hideAi,
|
|
439
445
|
disableMobileFullscreen,
|
|
446
|
+
hideMobileToolbar,
|
|
440
447
|
documentSuggestion,
|
|
441
448
|
mentionUserSource,
|
|
442
449
|
aiModelState,
|
|
@@ -461,6 +468,7 @@ export function NotionDocEditor({
|
|
|
461
468
|
editorClassName={editorClassName}
|
|
462
469
|
hideAi={hideAi}
|
|
463
470
|
disableMobileFullscreen={disableMobileFullscreen}
|
|
471
|
+
hideMobileToolbar={hideMobileToolbar}
|
|
464
472
|
documentSuggestion={documentSuggestion}
|
|
465
473
|
mentionUserSource={mentionUserSource}
|
|
466
474
|
aiModelState={aiModelState}
|
|
@@ -112,7 +112,7 @@ export const SDK_GROUPS: SdkGroup[] = [
|
|
|
112
112
|
"items": [
|
|
113
113
|
{
|
|
114
114
|
"name": "@allbluecn/web-app",
|
|
115
|
-
"version": "v0.27.
|
|
115
|
+
"version": "v0.27.2",
|
|
116
116
|
"lastUpdate": "Nov 5, 2026",
|
|
117
117
|
"source": "apps/web/package.json",
|
|
118
118
|
"docsUrl": "https://github.com/allbluecn/allblue"
|
|
@@ -99,6 +99,17 @@ export interface DesktopBridgeApi {
|
|
|
99
99
|
notifyQuickCapture?: () => void
|
|
100
100
|
onQuickCaptureCreated?: (cb: () => void) => () => void
|
|
101
101
|
onMiniPanelMode?: (cb: (mode: "story" | "inspiration") => void) => () => void
|
|
102
|
+
/** mini 面板顶部栏(2026-11-05 追加,add-only):
|
|
103
|
+
* - setMiniPanelPinned:切换「常显示」——main 侧抑制 blur 自动隐藏并置顶
|
|
104
|
+
* (setAlwaysOnTop('floating')),取消恢复;
|
|
105
|
+
* - onMiniPanelPinned:main 侧回传真实 pin 态(窗口重建/托盘侧变更时同步
|
|
106
|
+
* 按钮显示),订阅返回 unsubscribe;
|
|
107
|
+
* - popUpTrayMenu:请求 main 弹出与菜单栏图标右键**同一份**托盘菜单
|
|
108
|
+
* (原生菜单出现在菜单栏图标处;弹出期间 main 侧抑制面板 blur 收起)。
|
|
109
|
+
* 旧壳(无此三方法)undefined——调用方 optional-chaining 优雅降级。 */
|
|
110
|
+
setMiniPanelPinned?: (pinned: boolean) => void
|
|
111
|
+
onMiniPanelPinned?: (cb: (pinned: boolean) => void) => () => void
|
|
112
|
+
popUpTrayMenu?: () => void
|
|
102
113
|
}
|
|
103
114
|
|
|
104
115
|
/** A11 desktop-state 三态载荷(main 侧 desktop-state.json 持久化态的广播形状):
|
|
@@ -3550,7 +3550,10 @@ export * from './prototypepanel_title1.js'
|
|
|
3550
3550
|
export * from './quick_capture_failed.js'
|
|
3551
3551
|
export * from './quick_capture_hint.js'
|
|
3552
3552
|
export * from './quick_capture_inbox.js'
|
|
3553
|
+
export * from './quick_capture_paneltitle1.js'
|
|
3554
|
+
export * from './quick_capture_pin.js'
|
|
3553
3555
|
export * from './quick_capture_saved.js'
|
|
3556
|
+
export * from './quick_capture_settings.js'
|
|
3554
3557
|
export * from './quick_capture_submit.js'
|
|
3555
3558
|
export * from './quick_capture_tabunified1.js'
|
|
3556
3559
|
export * from './rightsidebar_collapse1.js'
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/* eslint-disable */
|
|
2
|
+
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
|
3
|
+
|
|
4
|
+
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
|
5
|
+
|
|
6
|
+
/** @typedef {{}} Quick_Capture_Paneltitle1Inputs */
|
|
7
|
+
|
|
8
|
+
const zh_quick_capture_paneltitle1 = /** @type {(inputs: Quick_Capture_Paneltitle1Inputs) => LocalizedString} */ () => {
|
|
9
|
+
return /** @type {LocalizedString} */ (`mini 面板`)
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const en_quick_capture_paneltitle1 = /** @type {(inputs: Quick_Capture_Paneltitle1Inputs) => LocalizedString} */ () => {
|
|
13
|
+
return /** @type {LocalizedString} */ (`Mini Panel`)
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* | output |
|
|
18
|
+
* | --- |
|
|
19
|
+
* | "Mini Panel" |
|
|
20
|
+
*
|
|
21
|
+
* @param {Quick_Capture_Paneltitle1Inputs} inputs
|
|
22
|
+
* @param {{ locale?: "zh" | "en" }} options
|
|
23
|
+
* @returns {LocalizedString}
|
|
24
|
+
*/
|
|
25
|
+
const quick_capture_paneltitle1 = /** @type {((inputs?: Quick_Capture_Paneltitle1Inputs, options?: { locale?: "zh" | "en" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Quick_Capture_Paneltitle1Inputs, { locale?: "zh" | "en" }, {}>} */ ((inputs = {}, options = {}) => {
|
|
26
|
+
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
|
27
|
+
if (locale === "en") return en_quick_capture_paneltitle1(inputs)
|
|
28
|
+
return zh_quick_capture_paneltitle1(inputs)
|
|
29
|
+
});
|
|
30
|
+
export { quick_capture_paneltitle1 as "quick_capture_panelTitle" }
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/* eslint-disable */
|
|
2
|
+
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
|
3
|
+
|
|
4
|
+
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
|
5
|
+
|
|
6
|
+
/** @typedef {{}} Quick_Capture_PinInputs */
|
|
7
|
+
|
|
8
|
+
const zh_quick_capture_pin = /** @type {(inputs: Quick_Capture_PinInputs) => LocalizedString} */ () => {
|
|
9
|
+
return /** @type {LocalizedString} */ (`常显示`)
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const en_quick_capture_pin = /** @type {(inputs: Quick_Capture_PinInputs) => LocalizedString} */ () => {
|
|
13
|
+
return /** @type {LocalizedString} */ (`Always visible`)
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* | output |
|
|
18
|
+
* | --- |
|
|
19
|
+
* | "Always visible" |
|
|
20
|
+
*
|
|
21
|
+
* @param {Quick_Capture_PinInputs} inputs
|
|
22
|
+
* @param {{ locale?: "zh" | "en" }} options
|
|
23
|
+
* @returns {LocalizedString}
|
|
24
|
+
*/
|
|
25
|
+
export const quick_capture_pin = /** @type {((inputs?: Quick_Capture_PinInputs, options?: { locale?: "zh" | "en" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Quick_Capture_PinInputs, { locale?: "zh" | "en" }, {}>} */ ((inputs = {}, options = {}) => {
|
|
26
|
+
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
|
27
|
+
if (locale === "en") return en_quick_capture_pin(inputs)
|
|
28
|
+
return zh_quick_capture_pin(inputs)
|
|
29
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/* eslint-disable */
|
|
2
|
+
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
|
3
|
+
|
|
4
|
+
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
|
5
|
+
|
|
6
|
+
/** @typedef {{}} Quick_Capture_SettingsInputs */
|
|
7
|
+
|
|
8
|
+
const zh_quick_capture_settings = /** @type {(inputs: Quick_Capture_SettingsInputs) => LocalizedString} */ () => {
|
|
9
|
+
return /** @type {LocalizedString} */ (`设置`)
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const en_quick_capture_settings = /** @type {(inputs: Quick_Capture_SettingsInputs) => LocalizedString} */ () => {
|
|
13
|
+
return /** @type {LocalizedString} */ (`Settings`)
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* | output |
|
|
18
|
+
* | --- |
|
|
19
|
+
* | "Settings" |
|
|
20
|
+
*
|
|
21
|
+
* @param {Quick_Capture_SettingsInputs} inputs
|
|
22
|
+
* @param {{ locale?: "zh" | "en" }} options
|
|
23
|
+
* @returns {LocalizedString}
|
|
24
|
+
*/
|
|
25
|
+
export const quick_capture_settings = /** @type {((inputs?: Quick_Capture_SettingsInputs, options?: { locale?: "zh" | "en" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Quick_Capture_SettingsInputs, { locale?: "zh" | "en" }, {}>} */ ((inputs = {}, options = {}) => {
|
|
26
|
+
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
|
27
|
+
if (locale === "en") return en_quick_capture_settings(inputs)
|
|
28
|
+
return zh_quick_capture_settings(inputs)
|
|
29
|
+
});
|
|
@@ -90,6 +90,7 @@ const mocks = vi.hoisted(() => ({
|
|
|
90
90
|
ensureBuiltinIndependentProject: vi.fn(),
|
|
91
91
|
ensureBuiltinInboxProject: vi.fn(),
|
|
92
92
|
assertProjectMembership: vi.fn(),
|
|
93
|
+
validateAssignee: vi.fn(),
|
|
93
94
|
createStoryInternal: vi.fn(),
|
|
94
95
|
}));
|
|
95
96
|
|
|
@@ -111,6 +112,10 @@ vi.mock("@/server/lib/project-membership", () => ({
|
|
|
111
112
|
assertProjectMembership: mocks.assertProjectMembership,
|
|
112
113
|
}));
|
|
113
114
|
|
|
115
|
+
vi.mock("@/server/lib/validate-assignee", () => ({
|
|
116
|
+
validateAssignee: mocks.validateAssignee,
|
|
117
|
+
}));
|
|
118
|
+
|
|
114
119
|
// 说明符从 __tests__ 出发解析到 quick-capture.ts 实际 import 的同一模块文件
|
|
115
120
|
// (quick-capture.ts 内写作 "./create-story-internal"),不照抄简报注释字面
|
|
116
121
|
vi.mock("../story-crud/create-story-internal", () => ({
|
|
@@ -218,4 +223,44 @@ describe("quickCaptureStoryFn(托盘 mini 快捷记录)", () => {
|
|
|
218
223
|
expect(mocks.ensureBuiltinIndependentProject).not.toHaveBeenCalled();
|
|
219
224
|
expect(mocks.createStoryInternal).not.toHaveBeenCalled();
|
|
220
225
|
});
|
|
226
|
+
|
|
227
|
+
it("内联字段透传:state/priority/assigneeId/startDate/targetDate/labelIds 全量落库前校验负责人", async () => {
|
|
228
|
+
await quickCaptureStoryFn({
|
|
229
|
+
data: {
|
|
230
|
+
kind: "story",
|
|
231
|
+
name: "带字段",
|
|
232
|
+
state: "todo",
|
|
233
|
+
priority: "high",
|
|
234
|
+
assigneeId: "u2",
|
|
235
|
+
startDate: "2026-11-06",
|
|
236
|
+
targetDate: "2026-11-10",
|
|
237
|
+
labelIds: ["l1", "l2"],
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
expect(mocks.validateAssignee).toHaveBeenCalledWith(INDEPENDENT_PROJECT_ID, "u2");
|
|
241
|
+
const input = mocks.createStoryInternal.mock.calls[0][1];
|
|
242
|
+
expect(input).toMatchObject({
|
|
243
|
+
state: "todo",
|
|
244
|
+
priority: "high",
|
|
245
|
+
assigneeId: "u2",
|
|
246
|
+
startDate: "2026-11-06",
|
|
247
|
+
targetDate: "2026-11-10",
|
|
248
|
+
labelIds: ["l1", "l2"],
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("内联字段缺省:state=backlog / priority=none / labelIds=[],不调用负责人校验", async () => {
|
|
253
|
+
await quickCaptureStoryFn({ data: { kind: "story", name: "缺省" } });
|
|
254
|
+
expect(mocks.validateAssignee).toHaveBeenCalledWith(INDEPENDENT_PROJECT_ID, undefined);
|
|
255
|
+
const input = mocks.createStoryInternal.mock.calls[0][1];
|
|
256
|
+
expect(input).toMatchObject({ state: "backlog", priority: "none", labelIds: [] });
|
|
257
|
+
expect(input.assigneeId).toBeUndefined();
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("非法 state:validator 拒绝,不触达发号事务", async () => {
|
|
261
|
+
await expect(
|
|
262
|
+
quickCaptureStoryFn({ data: { kind: "story", name: "x", state: "unknown" as never } }),
|
|
263
|
+
).rejects.toThrow();
|
|
264
|
+
expect(mocks.createStoryInternal).not.toHaveBeenCalled();
|
|
265
|
+
});
|
|
221
266
|
});
|
|
@@ -28,6 +28,7 @@ import { z } from "zod";
|
|
|
28
28
|
import { authContextMiddleware } from "@/server/middlewares/auth-context";
|
|
29
29
|
import { ensureBuiltinIndependentProject, ensureBuiltinInboxProject } from "@/server/builtin-project";
|
|
30
30
|
import { assertProjectMembership } from "@/server/lib/project-membership";
|
|
31
|
+
import { validateAssignee } from "@/server/lib/validate-assignee";
|
|
31
32
|
import { withSequenceNumberRetry } from "@/server/lib/sequence-number-retry";
|
|
32
33
|
import { createStoryInternal } from "./create-story-internal";
|
|
33
34
|
|
|
@@ -37,6 +38,13 @@ const quickCaptureSchema = z.object({
|
|
|
37
38
|
// 与 createStoryFn 同口径(story-crud.ts:87):序列化后的 {html,markdown} JSON 串
|
|
38
39
|
description: z.string().max(20000).optional(),
|
|
39
40
|
projectId: z.string().optional(),
|
|
41
|
+
// 内联字段(mini 面板与创建需求弹窗对齐,均与 createSchema 同口径)
|
|
42
|
+
state: z.enum(["backlog", "todo", "in_progress", "done", "cancelled"]).optional(),
|
|
43
|
+
priority: z.enum(["urgent", "high", "medium", "low", "none"]).optional(),
|
|
44
|
+
assigneeId: z.string().nullable().optional(),
|
|
45
|
+
startDate: z.string().optional(),
|
|
46
|
+
targetDate: z.string().optional(),
|
|
47
|
+
labelIds: z.array(z.string()).optional(),
|
|
40
48
|
});
|
|
41
49
|
|
|
42
50
|
export const quickCaptureStoryFn = createServerFn({ method: "POST" })
|
|
@@ -60,6 +68,9 @@ export const quickCaptureStoryFn = createServerFn({ method: "POST" })
|
|
|
60
68
|
projectId = await ensureBuiltinIndependentProject(userId);
|
|
61
69
|
}
|
|
62
70
|
|
|
71
|
+
// 负责人必须是团队成员(与 createStoryFn:115 同口径)
|
|
72
|
+
await validateAssignee(projectId, data.assigneeId);
|
|
73
|
+
|
|
63
74
|
// 计数器回滚使重试对「落后存量」类冲突无效——该类冲突由 create-story-internal
|
|
64
75
|
// 发号时以存量 max 为下界自愈;此重试仅兜真实并发竞态(同 createStoryFn 注记)
|
|
65
76
|
return withSequenceNumberRetry(
|
|
@@ -71,9 +82,12 @@ export const quickCaptureStoryFn = createServerFn({ method: "POST" })
|
|
|
71
82
|
name,
|
|
72
83
|
projectId,
|
|
73
84
|
description: data.description,
|
|
74
|
-
state: "backlog",
|
|
75
|
-
priority: "none",
|
|
76
|
-
|
|
85
|
+
state: data.state ?? "backlog",
|
|
86
|
+
priority: data.priority ?? "none",
|
|
87
|
+
assigneeId: data.assigneeId ?? undefined,
|
|
88
|
+
startDate: data.startDate,
|
|
89
|
+
targetDate: data.targetDate,
|
|
90
|
+
labelIds: data.labelIds ?? [],
|
|
77
91
|
isAI: false,
|
|
78
92
|
},
|
|
79
93
|
userId,
|