@allbluecn/web-app 0.27.1 → 0.27.3

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@allbluecn/web-app",
3
- "version": "0.27.1",
3
+ "version": "0.27.3",
4
4
  "license": "AGPL-3.0-or-later",
5
5
  "type": "module",
6
6
  "files": [
@@ -18,6 +18,8 @@ let captured: CaptureCb | null = null
18
18
  const unsubscribe = vi.fn()
19
19
  let noBridge = false
20
20
 
21
+ const routerInvalidate = vi.hoisted(() => vi.fn())
22
+
21
23
  vi.mock('@/lib/desktop-bridge-types', () => ({
22
24
  getDesktopBridge: () => {
23
25
  if (noBridge) return null
@@ -30,6 +32,11 @@ vi.mock('@/lib/desktop-bridge-types', () => ({
30
32
  },
31
33
  }))
32
34
 
35
+ // 需求列表/看板走 route loader(非 react-query):事件需同时 router.invalidate
36
+ vi.mock('@tanstack/react-router', () => ({
37
+ useRouter: () => ({ invalidate: routerInvalidate }),
38
+ }))
39
+
33
40
  import { QuickCaptureInvalidator } from '../quick-capture-invalidate'
34
41
 
35
42
  let client: QueryClient
@@ -61,7 +68,7 @@ describe('QuickCaptureInvalidator 主窗跨窗口失效', () => {
61
68
  expect(captured).toBeTypeOf('function')
62
69
  })
63
70
 
64
- it('桥推送记录创建事件 → invalidateQueries 被调(全量失效,无 queryKey 过滤参数)', async () => {
71
+ it('桥推送记录创建事件 → invalidateQueries 全量失效 + router.invalidate 重跑 loader', async () => {
65
72
  mount()
66
73
  const invalidateSpy = vi.spyOn(client, 'invalidateQueries')
67
74
  await act(async () => {
@@ -71,6 +78,9 @@ describe('QuickCaptureInvalidator 主窗跨窗口失效', () => {
71
78
  expect(invalidateSpy).toHaveBeenCalledTimes(1)
72
79
  // 全量失效口径(复审 C2):无 queryKey 过滤参数——零参调用
73
80
  expect(invalidateSpy).toHaveBeenCalledWith()
81
+ // 列表数据走 route loader(非 react-query):必须同时重跑 loader,
82
+ // 否则新记录不会出现在需求列表(用户会以为要手动刷新)
83
+ expect(routerInvalidate).toHaveBeenCalledTimes(1)
74
84
  })
75
85
 
76
86
  it('desktopBridge 为 undefined(浏览器直开)→ 挂载不抛错、零订阅', () => {
@@ -4,24 +4,30 @@
4
4
 
5
5
  // 主窗跨窗口失效(托盘 mini 快捷记录面板闭环第 4 步):订阅
6
6
  // desktopBridge.onQuickCaptureCreated——mini 面板保存成功 → Electron main 广播
7
- // → 本组件失效 react-query 缓存。失效口径(复审 C2):invalidateQueries() 全量
8
- // 失效(无 queryKey 过滤参数)——覆盖 projects-brief/workspaces-brief/story
9
- // 视图;低频事件成本可接受,不做精确 key 白名单。旧壳(无此方法)undefined、
10
- // 浏览器直开(无桥)null——可选链双保险优雅降级,挂载零成本零报错。
7
+ // → 本组件刷新主窗数据。两条刷新通道缺一不可:
8
+ // ① invalidateQueries() 全量失效(复审 C2 口径)——覆盖 projects-brief /
9
+ // workspaces-brief / 通知等 react-query 缓存(灵感箱首建后侧边栏即时出现);
10
+ // ② router.invalidate() 重跑 active route loader——需求列表/看板/日历的数据
11
+ // 走 route loader + 组件内 reload 原语(非 react-query),仅失效 query 缓存
12
+ // 不会让新记录出现在列表里(用户会以为「没保存,要手动刷新」)。
13
+ // 旧壳(无此方法)undefined、浏览器直开(无桥)null——可选链双保险优雅降级。
11
14
  // 挂载点:providers(Providers 全页挂载,DesktopRenamedToast 旁)。
12
15
  import { useEffect } from "react"
13
16
  import { useQueryClient } from "@tanstack/react-query"
17
+ import { useRouter } from "@tanstack/react-router"
14
18
  import { getDesktopBridge } from "@/lib/desktop-bridge-types"
15
19
 
16
20
  export function QuickCaptureInvalidator() {
17
21
  const queryClient = useQueryClient()
22
+ const router = useRouter()
18
23
 
19
24
  useEffect(() => {
20
25
  const off = getDesktopBridge()?.onQuickCaptureCreated?.(() => {
21
26
  void queryClient.invalidateQueries()
27
+ void router.invalidate()
22
28
  })
23
29
  return off
24
- }, [queryClient])
30
+ }, [queryClient, router])
25
31
 
26
32
  return null
27
33
  }
@@ -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) 保存成功:已记录 identifier、标题清空、notifyQuickCapture
26
- // 8) Esc 隐藏窗口;项目 Popover 展开时 Esc 不隐藏
27
- // 9) onMiniPanelMode 下发 inspiration 预选灵感箱;unmount 退订
28
- // 10) 无桥(浏览器直开)降级不抛错
29
- // 11) 请求在途期间再 Enter 不重复提交
30
- // 富文本编辑器与 AI 工具栏在 jsdom 以替身注入(真实组件依赖 TipTap/AI 模型链)。
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,23 @@ 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(),
58
+ openMainWindow: vi.fn(),
59
+ quitApp: vi.fn(),
48
60
  onMiniPanelMode: vi.fn((_cb: (mode: string) => void) => unsubMiniPanelMode),
61
+ onMiniPanelPinned: vi.fn((_cb: (pinned: boolean) => void) => unsubMiniPanelPinned),
49
62
  }
50
63
  })
51
64
 
@@ -57,7 +70,16 @@ const PROJECTS = [
57
70
  { id: 'p-work', name: '工作台', displayId: 'WK', icon: 'folder' },
58
71
  ]
59
72
 
60
- vi.mock('@/server/serverFns/story', () => ({ quickCaptureStoryFn: quickCaptureStoryFnMock }))
73
+ const USERS = [
74
+ { id: 'u2', username: 'bob', email: 'b@test.dev', avatarConfig: null, bgShape: null, bgColor: null },
75
+ ]
76
+ const LABELS = [{ id: 'l1', name: '前端', color: 'blue' }]
77
+
78
+ vi.mock('@/server/serverFns/story', () => ({
79
+ quickCaptureStoryFn: quickCaptureStoryFnMock,
80
+ listUsersBriefFn: listUsersBriefFnMock,
81
+ listLabelsFn: listLabelsFnMock,
82
+ }))
61
83
  vi.mock('@/server/serverFns/project', () => ({ listProjectsBriefFn: listProjectsBriefFnMock }))
62
84
  vi.mock('@/lib/desktop-bridge-types', () => ({
63
85
  getDesktopBridge: () => (noBridge ? null : bridgeStub),
@@ -70,21 +92,47 @@ vi.mock('@/components/story/ai/use-story-ai-model', () => ({
70
92
  vi.mock('@/components/shared-kit/icons/lucide-icon-picker/lucide-icon-by-name', () => ({
71
93
  LucideIconByName: () => null,
72
94
  }))
73
- // 富文本编辑器替身:一个可输入的 textarea,onChange 以 {html, markdown} 回传
95
+ // 富文本编辑器替身:textarea + 回传 {html, markdown};data-* 暴露 hideMobileToolbar
74
96
  vi.mock('@/components/tiptap/notion-like', async () => {
75
97
  const React = await import('react')
76
98
  return {
77
99
  NotionDocEditor: (props: {
78
100
  onChange?: (c: { html: string; markdown: string }) => void
101
+ hideMobileToolbar?: boolean
79
102
  }) =>
80
103
  React.createElement('textarea', {
81
104
  'data-testid': 'description-editor',
105
+ 'data-hide-mobile-toolbar': props.hideMobileToolbar ? 'true' : 'false',
82
106
  'aria-label': 'description',
83
107
  onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) =>
84
108
  props.onChange?.({ html: `<p>${e.target.value}</p>`, markdown: e.target.value }),
85
109
  }),
86
110
  }
87
111
  })
112
+ // 内联编辑器替身:渲染当前值并提供切换入口(真实组件依赖 Tooltip/Popover 链)
113
+ vi.mock('@/components/story/inline-editors', () => ({
114
+ StateEditor: ({ value, onChange }: { value: string; onChange: (v: string) => void }) => (
115
+ <button type="button" onClick={() => onChange('todo')}>
116
+ 状态:{value === 'backlog' ? '待规划' : value}
117
+ </button>
118
+ ),
119
+ PriorityEditor: ({ value, onChange }: { value: string; onChange: (v: string) => void }) => (
120
+ <button type="button" onClick={() => onChange('high')}>
121
+ 优先级:{value}
122
+ </button>
123
+ ),
124
+ AssigneeEditor: ({ onChange }: { onChange: (v: string | null) => void }) => (
125
+ <button type="button" onClick={() => onChange('u2')}>
126
+ 负责人
127
+ </button>
128
+ ),
129
+ DateEditor: ({ label }: { label: string }) => <button type="button">{label}</button>,
130
+ LabelsEditor: ({ onChange }: { onChange: (v: string[]) => void }) => (
131
+ <button type="button" onClick={() => onChange(['l1'])}>
132
+ 标签
133
+ </button>
134
+ ),
135
+ }))
88
136
 
89
137
  import { QuickCapturePanel } from '../quick-capture-panel'
90
138
 
@@ -101,14 +149,17 @@ beforeEach(() => {
101
149
  noBridge = false
102
150
  vi.clearAllMocks()
103
151
  listProjectsBriefFnMock.mockResolvedValue(PROJECTS)
152
+ listUsersBriefFnMock.mockResolvedValue(USERS)
153
+ listLabelsFnMock.mockResolvedValue(LABELS)
104
154
  })
105
155
 
106
156
  afterEach(() => cleanup())
107
157
 
108
158
  describe('QuickCapturePanel', () => {
109
- it('渲染单一「需求/灵感」标签、默认预选独立需求与快捷键文案', async () => {
159
+ it('渲染顶部栏(logo + mini 面板)、默认预选独立需求与快捷键文案', async () => {
110
160
  renderPanel()
111
- expect(screen.getByText('需求/灵感')).toBeTruthy()
161
+ expect(screen.getAllByAltText('AllBlue').length).toBeGreaterThan(0)
162
+ expect(screen.getByText('mini 面板')).toBeTruthy()
112
163
  // 不再有可点击的「需求」「灵感」双 tab
113
164
  expect(screen.queryByRole('button', { name: '需求' })).toBeNull()
114
165
  expect(screen.queryByRole('button', { name: '灵感' })).toBeNull()
@@ -117,13 +168,24 @@ describe('QuickCapturePanel', () => {
117
168
  expect(screen.getByText('Enter 记录 · Shift+Enter 换行 · Esc 关闭')).toBeTruthy()
118
169
  })
119
170
 
120
- it('标题 Enter 提交:带默认独立需求 projectId,不含描述', async () => {
171
+ it('标题 Enter 提交:带默认独立需求 projectId 与内联字段缺省值', async () => {
121
172
  const user = userEvent.setup()
122
173
  renderPanel()
123
174
  await screen.findByText('独立需求')
124
175
  await user.type(screen.getByPlaceholderText('标题'), '想法A{Enter}')
125
176
  expect(quickCaptureStoryFnMock).toHaveBeenCalledWith({
126
- data: { kind: 'story', name: '想法A', description: undefined, projectId: 'p-story' },
177
+ data: {
178
+ kind: 'story',
179
+ name: '想法A',
180
+ description: undefined,
181
+ projectId: 'p-story',
182
+ state: 'backlog',
183
+ priority: 'none',
184
+ assigneeId: undefined,
185
+ startDate: undefined,
186
+ targetDate: undefined,
187
+ labelIds: [],
188
+ },
127
189
  })
128
190
  })
129
191
 
@@ -142,11 +204,14 @@ describe('QuickCapturePanel', () => {
142
204
  await user.click(screen.getByRole('button', { name: /独立需求/ }))
143
205
  await screen.findByText('工作台')
144
206
  await user.click(screen.getByText('灵感箱'))
145
- // Popover 关闭 → 列表内容卸载(「工作台」只剩触发器外的列表项,已不再渲染)
146
207
  expect(screen.queryByText('工作台')).toBeNull()
147
208
  await user.type(screen.getByPlaceholderText('标题'), '灵感一闪{Enter}')
148
209
  expect(quickCaptureStoryFnMock).toHaveBeenCalledWith({
149
- data: { kind: 'inspiration', name: '灵感一闪', description: undefined, projectId: undefined },
210
+ data: expect.objectContaining({
211
+ kind: 'inspiration',
212
+ name: '灵感一闪',
213
+ projectId: undefined,
214
+ }),
150
215
  })
151
216
  })
152
217
 
@@ -159,7 +224,7 @@ describe('QuickCapturePanel', () => {
159
224
  expect(screen.queryByText('灵感箱')).toBeNull()
160
225
  await user.type(screen.getByPlaceholderText('标题'), '需求B{Enter}')
161
226
  expect(quickCaptureStoryFnMock).toHaveBeenCalledWith({
162
- data: { kind: 'story', name: '需求B', description: undefined, projectId: 'p-work' },
227
+ data: expect.objectContaining({ kind: 'story', name: '需求B', projectId: 'p-work' }),
163
228
  })
164
229
  })
165
230
 
@@ -171,12 +236,31 @@ describe('QuickCapturePanel', () => {
171
236
  await user.type(screen.getByTestId('description-editor'), '实现细节')
172
237
  await user.click(screen.getByRole('button', { name: '记录' }))
173
238
  expect(quickCaptureStoryFnMock).toHaveBeenCalledWith({
174
- data: {
175
- kind: 'story',
239
+ data: expect.objectContaining({
176
240
  name: '想法C',
177
241
  description: JSON.stringify({ html: '<p>实现细节</p>', markdown: '实现细节' }),
178
- projectId: 'p-story',
179
- },
242
+ }),
243
+ })
244
+ })
245
+
246
+ it('内联字段:切换状态/优先级/负责人/标签后保存载荷带新值', async () => {
247
+ const user = userEvent.setup()
248
+ renderPanel()
249
+ await screen.findByText('独立需求')
250
+ // 「待规划」默认态(backlog)可见
251
+ expect(screen.getByText('状态:待规划')).toBeTruthy()
252
+ await user.click(screen.getByText('状态:待规划'))
253
+ await user.click(screen.getByText('优先级:none'))
254
+ await user.click(screen.getByText('负责人'))
255
+ await user.click(screen.getByText('标签'))
256
+ await user.type(screen.getByPlaceholderText('标题'), '带字段{Enter}')
257
+ expect(quickCaptureStoryFnMock).toHaveBeenCalledWith({
258
+ data: expect.objectContaining({
259
+ state: 'todo',
260
+ priority: 'high',
261
+ assigneeId: 'u2',
262
+ labelIds: ['l1'],
263
+ }),
180
264
  })
181
265
  })
182
266
 
@@ -199,7 +283,6 @@ describe('QuickCapturePanel', () => {
199
283
  await screen.findByText('工作台')
200
284
  await user.keyboard('{Escape}')
201
285
  expect(bridgeStub.window).not.toHaveBeenCalled()
202
- // 关闭 Popover 后再 Esc → 收起窗口
203
286
  await user.keyboard('{Escape}')
204
287
  expect(bridgeStub.window).toHaveBeenCalledWith('hide')
205
288
  })
@@ -211,13 +294,66 @@ describe('QuickCapturePanel', () => {
211
294
  const notifyMode = bridgeStub.onMiniPanelMode.mock.calls[0]?.[0]
212
295
  expect(notifyMode).toBeTypeOf('function')
213
296
  act(() => notifyMode?.('inspiration'))
214
- await waitFor(() =>
215
- expect(screen.getByRole('button', { name: /灵感箱/ })).toBeTruthy(),
216
- )
297
+ await waitFor(() => expect(screen.getByRole('button', { name: /灵感箱/ })).toBeTruthy())
217
298
  unmount()
218
299
  expect(bridgeStub.unsubMiniPanelMode).toHaveBeenCalled()
219
300
  })
220
301
 
302
+ it('图钉:点击调 setMiniPanelPinned(true),回传 pin 态同步按钮', async () => {
303
+ const user = userEvent.setup()
304
+ renderPanel()
305
+ await screen.findByText('独立需求')
306
+ const pinBtn = screen.getByRole('button', { name: '常显示' })
307
+ expect(pinBtn.getAttribute('aria-pressed')).toBe('false')
308
+ await user.click(pinBtn)
309
+ expect(bridgeStub.setMiniPanelPinned).toHaveBeenCalledWith(true)
310
+ expect(pinBtn.getAttribute('aria-pressed')).toBe('true')
311
+ // main 回传真实态(窗口重建/托盘侧变更)
312
+ const notifyPinned = bridgeStub.onMiniPanelPinned.mock.calls[0]?.[0]
313
+ expect(notifyPinned).toBeTypeOf('function')
314
+ act(() => notifyPinned?.(true))
315
+ await waitFor(() =>
316
+ expect(screen.getByRole('button', { name: '常显示' }).getAttribute('aria-pressed')).toBe(
317
+ 'true',
318
+ ),
319
+ )
320
+ })
321
+
322
+ it('设置菜单在面板处打开(内容与托盘右键一致):打开 AllBlue / 快捷记录灵感 / 退出', async () => {
323
+ const user = userEvent.setup()
324
+ renderPanel()
325
+ await screen.findByText('独立需求')
326
+ await user.click(screen.getByRole('button', { name: '设置' }))
327
+ // 面板内嵌菜单(不弹原生托盘菜单)
328
+ expect(await screen.findByText('打开 AllBlue')).toBeTruthy()
329
+ expect(screen.getByText('快捷记录需求')).toBeTruthy()
330
+ expect(screen.getByText('快捷记录灵感')).toBeTruthy()
331
+ expect(screen.getByText('退出 AllBlue')).toBeTruthy()
332
+ expect(bridgeStub.popUpTrayMenu).not.toHaveBeenCalled()
333
+ // 快捷记录灵感 → 面板切到灵感箱
334
+ await user.click(screen.getByText('快捷记录灵感'))
335
+ await waitFor(() => expect(screen.getByRole('button', { name: /灵感箱/ })).toBeTruthy())
336
+ // 打开主窗
337
+ await user.click(screen.getByRole('button', { name: '设置' }))
338
+ await user.click(await screen.findByText('打开 AllBlue'))
339
+ expect(bridgeStub.openMainWindow).toHaveBeenCalledTimes(1)
340
+ // 退出
341
+ await user.click(screen.getByRole('button', { name: '设置' }))
342
+ await user.click(await screen.findByText('退出 AllBlue'))
343
+ expect(bridgeStub.quitApp).toHaveBeenCalledTimes(1)
344
+ })
345
+
346
+ it('标题字号做 md 适配;编辑器收到 hideMobileToolbar(隐藏窄窗底部工具栏)', async () => {
347
+ renderPanel()
348
+ await screen.findByText('独立需求')
349
+ const title = screen.getByPlaceholderText('标题')
350
+ expect(title.className).toContain('text-base')
351
+ expect(title.className).toContain('md:text-lg')
352
+ expect(screen.getByTestId('description-editor').getAttribute('data-hide-mobile-toolbar')).toBe(
353
+ 'true',
354
+ )
355
+ })
356
+
221
357
  it('无桥(浏览器直开):渲染与保存不抛错', async () => {
222
358
  const user = userEvent.setup()
223
359
  noBridge = true
@@ -225,7 +361,7 @@ describe('QuickCapturePanel', () => {
225
361
  await screen.findByText('独立需求')
226
362
  await user.type(screen.getByPlaceholderText('标题'), '想法E{Enter}')
227
363
  expect(quickCaptureStoryFnMock).toHaveBeenCalledWith({
228
- data: { kind: 'story', name: '想法E', description: undefined, projectId: 'p-story' },
364
+ data: expect.objectContaining({ name: '想法E', projectId: 'p-story' }),
229
365
  })
230
366
  expect(await screen.findByText('已记录 STORY-1')).toBeTruthy()
231
367
  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
- // 入口,字段与布局对齐主应用「创建需求弹窗」(项目选择 + 标题 + 富文本描述 + AI
20
- // 工具栏 + 记录按钮)。落库走 quickCaptureStoryFn——kind 只作为「灵感箱」懒创建标记:
21
- // 项目选择器的固定「灵感箱」项 → kind=inspiration(后端 ensureBuiltinInboxProject);
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
- // 收起窗口(项目 Popover 展开时 Esc 只关 Popover)。桥消费一律 getDesktopBridge() +
25
- // 可选链——旧壳缺少 notifyQuickCapture/onMiniPanelMode、浏览器直开无桥,全部优雅
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,32 @@ import {
37
37
  BUILTIN_INDEPENDENT_DISPLAY_ID,
38
38
  } from "@/lib/builtin-project"
39
39
  import { serializeStoryDescription } from "@/shared/story/types"
40
- import { quickCaptureStoryFn } from "@/server/serverFns/story"
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 {
48
+ DropdownMenu,
49
+ DropdownMenuContent,
50
+ DropdownMenuItem,
51
+ DropdownMenuSeparator,
52
+ DropdownMenuTrigger,
53
+ } from "@/components/ui/dropdown-menu"
45
54
  import { toast } from "@/components/ui/toast"
46
55
  import { LucideIconByName } from "@/components/shared-kit/icons/lucide-icon-picker/lucide-icon-by-name"
47
56
  import { NotionDocEditor } from "@/components/tiptap/notion-like"
48
57
  import { StoryAiToolbar } from "@/components/story/ai/story-ai-toolbar"
49
58
  import { useStoryAiModel } from "@/components/story/ai/use-story-ai-model"
59
+ import {
60
+ AssigneeEditor,
61
+ DateEditor,
62
+ LabelsEditor,
63
+ PriorityEditor,
64
+ StateEditor,
65
+ } from "@/components/story/inline-editors"
50
66
 
51
67
  export type QuickCaptureMode = "story" | "inspiration"
52
68
 
@@ -56,6 +72,8 @@ const FEEDBACK_CLEAR_MS = 1500
56
72
  /** 项目选择:普通项目 / 灵感箱固定项(后者由后端懒创建,故不携带 projectId) */
57
73
  type ProjectPick = { type: "project"; id: string } | { type: "inbox" }
58
74
 
75
+ type LabelLike = { id: string; name: string; color: string }
76
+
59
77
  interface ProjectBriefRow {
60
78
  id: string
61
79
  name: string
@@ -71,10 +89,27 @@ export function QuickCapturePanel({ initialMode }: { initialMode: QuickCaptureMo
71
89
  })
72
90
  const [projectPick, setProjectPick] = useState<ProjectPick | null>(null)
73
91
  const [projectOpen, setProjectOpen] = useState(false)
92
+ // 内联字段(与创建需求弹窗同口径)
93
+ const [state, setState] = useState<StoryState>("backlog")
94
+ const [priority, setPriority] = useState<StoryPriority>("none")
95
+ const [assigneeId, setAssigneeId] = useState<string | null>(null)
96
+ const [startDate, setStartDate] = useState("")
97
+ const [targetDate, setTargetDate] = useState("")
98
+ const [labelIds, setLabelIds] = useState<string[]>([])
99
+ // 新建标签并入本地清单(否则 chip 因字典缺名不渲染)
100
+ const [extraLabels, setExtraLabels] = useState<LabelLike[]>([])
101
+
74
102
  const [savedIdentifier, setSavedIdentifier] = useState<string | null>(null)
75
103
  const [saving, setSaving] = useState(false)
76
104
  const [aiGenerating, setAiGenerating] = useState(false)
77
105
  const [isEditorFocused, setIsEditorFocused] = useState(false)
106
+ const [pinned, setPinned] = useState(false)
107
+ // SSR/CSR 首帧一致性:AI 工具栏含 useToolbarOverflow 测量层(客户端首帧多一层
108
+ // DOM),直接 SSR 会 hydration mismatch(React 整树重建 → 输入框事件重绑,
109
+ // 表现为 Enter/Esc 时好时坏)。挂载后再渲染工具栏。
110
+ const [mounted, setMounted] = useState(false)
111
+ const [appVersion, setAppVersion] = useState("")
112
+
78
113
  const feedbackTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
79
114
  // 保存防重:请求在途(标题尚未清空)期间忽略再次提交,防重复落库
80
115
  const savingRef = useRef(false)
@@ -96,6 +131,22 @@ export function QuickCapturePanel({ initialMode }: { initialMode: QuickCaptureMo
96
131
  // 已存在时它本身就是普通项目项,选中走显式 projectId,不会出现重复入口
97
132
  const showInboxOption = !inboxProject
98
133
 
134
+ const activeProjectId = projectPick?.type === "project" ? projectPick.id : undefined
135
+
136
+ // 负责人候选(项目成员)与标签字典(全局 ∪ 本项目):项目解析后按需取
137
+ const usersQuery = useQuery({
138
+ queryKey: ["users-brief", activeProjectId ?? "none"],
139
+ queryFn: () => listUsersBriefFn({ data: { projectId: activeProjectId } }),
140
+ enabled: !!activeProjectId,
141
+ })
142
+ const users = (usersQuery.data ?? []) as UserBrief[]
143
+ const labelsQuery = useQuery({
144
+ queryKey: ["labels", activeProjectId ?? "none"],
145
+ queryFn: () => listLabelsFn({ data: { projectId: activeProjectId } }),
146
+ enabled: !!activeProjectId,
147
+ })
148
+ const labels = [...((labelsQuery.data ?? []) as LabelLike[]), ...extraLabels]
149
+
99
150
  const pickForMode = useCallback(
100
151
  (mode: QuickCaptureMode): ProjectPick | null => {
101
152
  if (mode === "inspiration") {
@@ -123,6 +174,28 @@ export function QuickCapturePanel({ initialMode }: { initialMode: QuickCaptureMo
123
174
  return () => unsubscribe?.()
124
175
  }, [pickForMode])
125
176
 
177
+ // pin 真实态(窗口重建/托盘侧变更后按钮显示正确);unsubscribe 进 cleanup
178
+ useEffect(() => {
179
+ const bridge = getDesktopBridge()
180
+ const unsubscribe = bridge?.onMiniPanelPinned?.((next) => setPinned(next === true))
181
+ return () => unsubscribe?.()
182
+ }, [])
183
+
184
+ // 首帧一致性 + 版本号(设置菜单展示;旧壳无 appInfo 时留空)
185
+ useEffect(() => {
186
+ setMounted(true)
187
+ }, [])
188
+
189
+ useEffect(() => {
190
+ const bridge = getDesktopBridge()
191
+ void bridge
192
+ ?.appInfo?.()
193
+ .then((info) => {
194
+ if (info?.version) setAppVersion(info.version)
195
+ })
196
+ .catch(() => {})
197
+ }, [])
198
+
126
199
  // 反馈定时器 unmount 清理(组件销毁后不再 setState,防泄漏)
127
200
  useEffect(() => {
128
201
  return () => {
@@ -148,6 +221,21 @@ export function QuickCapturePanel({ initialMode }: { initialMode: QuickCaptureMo
148
221
  projectPick?.type === "project" ? projects.find((p) => p.id === projectPick.id) : undefined
149
222
  const selectedLabel =
150
223
  projectPick?.type === "inbox" ? m.quick_capture_inbox() : selectedProject?.name
224
+ const selectedUser = users.find((u) => u.id === assigneeId) ?? null
225
+
226
+ const resetFields = (opts: { keepProject: boolean }) => {
227
+ setName("")
228
+ setDescription({ html: "", markdown: "" })
229
+ tiptapEditorRef.current?.commands?.clearContent?.()
230
+ setState("backlog")
231
+ setPriority("none")
232
+ setAssigneeId(null)
233
+ setStartDate("")
234
+ setTargetDate("")
235
+ setLabelIds([])
236
+ setExtraLabels([])
237
+ if (!opts.keepProject) setProjectPick(null)
238
+ }
151
239
 
152
240
  const save = async () => {
153
241
  if (savingRef.current) return
@@ -164,12 +252,17 @@ export function QuickCapturePanel({ initialMode }: { initialMode: QuickCaptureMo
164
252
  ? serializeStoryDescription(description.html, description.markdown)
165
253
  : undefined,
166
254
  projectId: projectPick?.type === "project" ? projectPick.id : undefined,
255
+ state,
256
+ priority,
257
+ assigneeId: assigneeId ?? undefined,
258
+ startDate: startDate || undefined,
259
+ targetDate: targetDate || undefined,
260
+ labelIds,
167
261
  },
168
262
  })
169
263
  setSavedIdentifier(res.identifier)
170
- setName("")
171
- setDescription({ html: "", markdown: "" })
172
- tiptapEditorRef.current?.commands?.clearContent?.()
264
+ // 连续记录:保留项目选择,其余字段复位
265
+ resetFields({ keepProject: true })
173
266
  const bridge = getDesktopBridge()
174
267
  bridge?.notifyQuickCapture?.()
175
268
  if (feedbackTimerRef.current) clearTimeout(feedbackTimerRef.current)
@@ -199,6 +292,21 @@ export function QuickCapturePanel({ initialMode }: { initialMode: QuickCaptureMo
199
292
  }
200
293
  }
201
294
 
295
+ const handlePickProject = (pick: ProjectPick) => {
296
+ // 负责人候选与标签字典随项目变化:切换项目时清空相关选择
297
+ setAssigneeId(null)
298
+ setLabelIds([])
299
+ setExtraLabels([])
300
+ setProjectPick(pick)
301
+ setProjectOpen(false)
302
+ }
303
+
304
+ const togglePinned = () => {
305
+ const next = !pinned
306
+ setPinned(next)
307
+ getDesktopBridge()?.setMiniPanelPinned?.(next)
308
+ }
309
+
202
310
  const showHint = !isEditorFocused && !description.markdown.trim()
203
311
 
204
312
  return (
@@ -206,10 +314,77 @@ export function QuickCapturePanel({ initialMode }: { initialMode: QuickCaptureMo
206
314
  className="flex h-dvh flex-col bg-background text-foreground"
207
315
  onKeyDown={handlePanelKeyDown}
208
316
  >
209
- <header className="flex items-center border-b border-border p-2">
210
- <span className="rounded-md bg-muted px-2 py-1 text-xs font-medium text-muted-foreground">
211
- {m.quick_capture_tabUnified()}
212
- </span>
317
+ <header className="flex items-center justify-between gap-2 border-b border-border p-2">
318
+ <div className="flex min-w-0 items-center gap-2">
319
+ <img src="/logo/allblue-logo-text-dark.svg" alt="AllBlue" className="h-4 dark:hidden" />
320
+ <img
321
+ src="/logo/allblue-logo-text-light.svg"
322
+ alt="AllBlue"
323
+ className="hidden h-4 dark:block"
324
+ />
325
+ <span className="shrink-0 rounded-md bg-muted px-1.5 py-0.5 text-[11px] font-medium text-muted-foreground">
326
+ {m.quick_capture_panelTitle()}
327
+ </span>
328
+ </div>
329
+ <div className="flex shrink-0 items-center gap-0.5">
330
+ <Button
331
+ type="button"
332
+ variant="ghost"
333
+ size="icon-sm"
334
+ aria-label={m.quick_capture_pin()}
335
+ aria-pressed={pinned}
336
+ className={pinned ? "text-primary" : undefined}
337
+ onClick={togglePinned}
338
+ >
339
+ {pinned ? <PinOff className="size-4" /> : <Pin className="size-4" />}
340
+ </Button>
341
+ <DropdownMenu>
342
+ <DropdownMenuTrigger
343
+ render={
344
+ <Button
345
+ type="button"
346
+ variant="ghost"
347
+ size="icon-sm"
348
+ aria-label={m.quick_capture_settings()}
349
+ />
350
+ }
351
+ >
352
+ <Settings className="size-4" />
353
+ </DropdownMenuTrigger>
354
+ {/* 设置菜单在面板处弹出,内容与菜单栏图标右键菜单一致(打开/快捷记录/版本/退出) */}
355
+ <DropdownMenuContent align="end" className="w-48">
356
+ <DropdownMenuItem onClick={() => getDesktopBridge()?.openMainWindow?.()}>
357
+ {m.quick_capture_openMain()}
358
+ </DropdownMenuItem>
359
+ <DropdownMenuItem
360
+ onClick={() => {
361
+ const pick = pickForMode("story")
362
+ if (pick) handlePickProject(pick)
363
+ }}
364
+ >
365
+ {m.quick_capture_quickStory()}
366
+ </DropdownMenuItem>
367
+ <DropdownMenuItem
368
+ onClick={() => {
369
+ const pick = pickForMode("inspiration")
370
+ if (pick) handlePickProject(pick)
371
+ }}
372
+ >
373
+ {m.quick_capture_quickIdea()}
374
+ </DropdownMenuItem>
375
+ {appVersion ? (
376
+ <>
377
+ <DropdownMenuSeparator />
378
+ <DropdownMenuItem disabled>{`v${appVersion}`}</DropdownMenuItem>
379
+ </>
380
+ ) : null}
381
+ <DropdownMenuSeparator />
382
+ <DropdownMenuItem onClick={() => getDesktopBridge()?.quitApp?.()}>
383
+ {m.quick_capture_quit()}
384
+ </DropdownMenuItem>
385
+ </DropdownMenuContent>
386
+ </DropdownMenu>
387
+ </div>
213
388
  </header>
214
389
 
215
390
  <div className="flex-1 space-y-3 overflow-y-auto p-3">
@@ -219,7 +394,7 @@ export function QuickCapturePanel({ initialMode }: { initialMode: QuickCaptureMo
219
394
  <Button
220
395
  variant="outline"
221
396
  size="sm"
222
- className="h-8 w-full justify-start gap-1.5 border-0 bg-muted/50 px-2.5 text-sm font-medium"
397
+ className="h-8 gap-1.5 border-0 bg-muted/50 px-2.5 text-sm font-medium"
223
398
  />
224
399
  }
225
400
  >
@@ -237,10 +412,7 @@ export function QuickCapturePanel({ initialMode }: { initialMode: QuickCaptureMo
237
412
  <button
238
413
  key={p.id}
239
414
  type="button"
240
- onClick={() => {
241
- setProjectPick({ type: "project", id: p.id })
242
- setProjectOpen(false)
243
- }}
415
+ onClick={() => handlePickProject({ type: "project", id: p.id })}
244
416
  className={`flex w-full items-center justify-between gap-2 rounded px-2 py-1.5 text-sm ${
245
417
  projectPick?.type === "project" && projectPick.id === p.id
246
418
  ? "bg-accent font-medium"
@@ -261,10 +433,7 @@ export function QuickCapturePanel({ initialMode }: { initialMode: QuickCaptureMo
261
433
  {showInboxOption && (
262
434
  <button
263
435
  type="button"
264
- onClick={() => {
265
- setProjectPick({ type: "inbox" })
266
- setProjectOpen(false)
267
- }}
436
+ onClick={() => handlePickProject({ type: "inbox" })}
268
437
  className={`flex w-full items-center justify-between gap-2 rounded px-2 py-1.5 text-sm ${
269
438
  projectPick?.type === "inbox" ? "bg-accent font-medium" : "hover:bg-muted"
270
439
  }`}
@@ -285,11 +454,11 @@ export function QuickCapturePanel({ initialMode }: { initialMode: QuickCaptureMo
285
454
  onKeyDown={handleTitleKeyDown}
286
455
  placeholder={m.story_column_title()}
287
456
  maxLength={200}
288
- className="h-10 text-lg"
457
+ className="h-10 text-base md:text-lg"
289
458
  />
290
459
 
291
460
  <div
292
- className={`notion-full-width relative flex max-h-60 min-h-36 flex-col overflow-y-auto rounded-md border border-border${
461
+ className={`notion-full-width relative flex max-h-44 min-h-32 flex-col overflow-y-auto rounded-md border border-border${
293
462
  showHint ? " story-editor-no-placeholder" : ""
294
463
  }`}
295
464
  ref={editorWrapRef}
@@ -304,6 +473,7 @@ export function QuickCapturePanel({ initialMode }: { initialMode: QuickCaptureMo
304
473
  showHeader={false}
305
474
  hideDragHandle
306
475
  hideAi
476
+ hideMobileToolbar
307
477
  onEditorReady={onEditorReady}
308
478
  editorClassName="story-editor !ml-0"
309
479
  disableMobileFullscreen
@@ -316,20 +486,54 @@ export function QuickCapturePanel({ initialMode }: { initialMode: QuickCaptureMo
316
486
  )}
317
487
  </div>
318
488
 
319
- <div className="mr-2 mb-1 flex shrink-0 items-center gap-1">
320
- <StoryAiToolbar
321
- description={description.markdown}
322
- title={name}
323
- mode="editor"
324
- editorRef={tiptapEditorRef}
325
- model={aiModel}
326
- groups={aiGroups}
327
- onModelChange={setAiModel}
328
- onGenerating={setAiGenerating}
329
- fill
330
- />
489
+ <div className="mr-2 mb-1 flex shrink-0 items-center justify-end gap-1">
490
+ {/* 挂载后才渲染:工具栏测量层(客户端首帧多一层 DOM)会造成 hydration
491
+ mismatch → React 整树重建 → 输入框事件重绑(快捷键时好时坏) */}
492
+ {mounted && (
493
+ <StoryAiToolbar
494
+ description={description.markdown}
495
+ title={name}
496
+ mode="editor"
497
+ editorRef={tiptapEditorRef}
498
+ model={aiModel}
499
+ groups={aiGroups}
500
+ onModelChange={setAiModel}
501
+ onGenerating={setAiGenerating}
502
+ />
503
+ )}
331
504
  </div>
332
505
  </div>
506
+
507
+ <div className="flex flex-wrap items-center gap-1.5">
508
+ <StateEditor value={state} onChange={setState} variant="button" />
509
+ <PriorityEditor value={priority} onChange={setPriority} variant="button" />
510
+ <AssigneeEditor
511
+ assignee={selectedUser}
512
+ users={users}
513
+ onChange={(v) => setAssigneeId(v)}
514
+ variant="button"
515
+ />
516
+ <DateEditor
517
+ value={startDate || null}
518
+ onChange={(v) => setStartDate(v ?? "")}
519
+ label={m.story_create_startDate()}
520
+ icon={CalendarPlus2}
521
+ />
522
+ <DateEditor
523
+ value={targetDate || null}
524
+ onChange={(v) => setTargetDate(v ?? "")}
525
+ label={m.story_create_dueDate()}
526
+ icon={CalendarCheck}
527
+ />
528
+ <LabelsEditor
529
+ projectId={activeProjectId ?? ""}
530
+ labelIds={labelIds}
531
+ labels={labels}
532
+ onChange={setLabelIds}
533
+ variant="button"
534
+ onLabelCreated={(label) => setExtraLabels((prev) => [...prev, label])}
535
+ />
536
+ </div>
333
537
  </div>
334
538
 
335
539
  <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}
@@ -51,6 +51,8 @@ async function executeFailingMutation(
51
51
 
52
52
  beforeEach(() => {
53
53
  vi.mocked(toast.error).mockClear()
54
+ // 取证钩子(handleGlobalError 内 console.error 打印具体错误)不污染测试输出
55
+ vi.spyOn(console, 'error').mockImplementation(() => {})
54
56
  })
55
57
 
56
58
  describe('QueryCache 全局 onError(web)', () => {
@@ -112,7 +112,7 @@ export const SDK_GROUPS: SdkGroup[] = [
112
112
  "items": [
113
113
  {
114
114
  "name": "@allbluecn/web-app",
115
- "version": "v0.27.1",
115
+ "version": "v0.27.3",
116
116
  "lastUpdate": "Nov 5, 2026",
117
117
  "source": "apps/web/package.json",
118
118
  "docsUrl": "https://github.com/allbluecn/allblue"
@@ -99,6 +99,23 @@ 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
113
+ /** mini 面板设置菜单(2026-11-05 追加,add-only):
114
+ * - openMainWindow:打开/前置主窗(菜单项「打开 AllBlue」);
115
+ * - quitApp:退出应用(菜单项「退出 AllBlue」,复用 before-quit 清理链)。
116
+ * 旧壳 undefined——调用方可选链降级。 */
117
+ openMainWindow?: () => void
118
+ quitApp?: () => void
102
119
  }
103
120
 
104
121
  /** A11 desktop-state 三态载荷(main 侧 desktop-state.json 持久化态的广播形状):
@@ -69,6 +69,9 @@ function handleGlobalError(
69
69
  if (isGlobalToastSuppressed(error, meta)) return
70
70
  // toast 仅浏览器端有渲染通道,SSR 静默
71
71
  if (typeof window === 'undefined') return
72
+ // 取证钩子:toast 只给通用文案,具体错误落 console 便于用户/排查时定位
73
+ // (哪个 serverFn 失败、状态码、消息;不影响 toast 行为)
74
+ console.error('[query-global-error]', error)
72
75
  if (isClientErrorStatus(extractErrorStatus(error))) {
73
76
  // 4xx(非 401):业务语义错误,展示详情;空消息回落通用文案
74
77
  toast.error(pickErrorMessage(error, new WeakSet()) ?? m.common_error_generic())
@@ -3550,7 +3550,14 @@ 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_openmain1.js'
3554
+ export * from './quick_capture_paneltitle1.js'
3555
+ export * from './quick_capture_pin.js'
3556
+ export * from './quick_capture_quickidea1.js'
3557
+ export * from './quick_capture_quickstory1.js'
3558
+ export * from './quick_capture_quit.js'
3553
3559
  export * from './quick_capture_saved.js'
3560
+ export * from './quick_capture_settings.js'
3554
3561
  export * from './quick_capture_submit.js'
3555
3562
  export * from './quick_capture_tabunified1.js'
3556
3563
  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_Openmain1Inputs */
7
+
8
+ const zh_quick_capture_openmain1 = /** @type {(inputs: Quick_Capture_Openmain1Inputs) => LocalizedString} */ () => {
9
+ return /** @type {LocalizedString} */ (`打开 AllBlue`)
10
+ };
11
+
12
+ const en_quick_capture_openmain1 = /** @type {(inputs: Quick_Capture_Openmain1Inputs) => LocalizedString} */ () => {
13
+ return /** @type {LocalizedString} */ (`Open AllBlue`)
14
+ };
15
+
16
+ /**
17
+ * | output |
18
+ * | --- |
19
+ * | "Open AllBlue" |
20
+ *
21
+ * @param {Quick_Capture_Openmain1Inputs} inputs
22
+ * @param {{ locale?: "zh" | "en" }} options
23
+ * @returns {LocalizedString}
24
+ */
25
+ const quick_capture_openmain1 = /** @type {((inputs?: Quick_Capture_Openmain1Inputs, options?: { locale?: "zh" | "en" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Quick_Capture_Openmain1Inputs, { locale?: "zh" | "en" }, {}>} */ ((inputs = {}, options = {}) => {
26
+ const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
27
+ if (locale === "en") return en_quick_capture_openmain1(inputs)
28
+ return zh_quick_capture_openmain1(inputs)
29
+ });
30
+ export { quick_capture_openmain1 as "quick_capture_openMain" }
@@ -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,30 @@
1
+ /* eslint-disable */
2
+ import { getLocale, experimentalStaticLocale } from '../runtime.js';
3
+
4
+ /** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
5
+
6
+ /** @typedef {{}} Quick_Capture_Quickidea1Inputs */
7
+
8
+ const zh_quick_capture_quickidea1 = /** @type {(inputs: Quick_Capture_Quickidea1Inputs) => LocalizedString} */ () => {
9
+ return /** @type {LocalizedString} */ (`快捷记录灵感`)
10
+ };
11
+
12
+ const en_quick_capture_quickidea1 = /** @type {(inputs: Quick_Capture_Quickidea1Inputs) => LocalizedString} */ () => {
13
+ return /** @type {LocalizedString} */ (`Quick Idea`)
14
+ };
15
+
16
+ /**
17
+ * | output |
18
+ * | --- |
19
+ * | "Quick Idea" |
20
+ *
21
+ * @param {Quick_Capture_Quickidea1Inputs} inputs
22
+ * @param {{ locale?: "zh" | "en" }} options
23
+ * @returns {LocalizedString}
24
+ */
25
+ const quick_capture_quickidea1 = /** @type {((inputs?: Quick_Capture_Quickidea1Inputs, options?: { locale?: "zh" | "en" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Quick_Capture_Quickidea1Inputs, { locale?: "zh" | "en" }, {}>} */ ((inputs = {}, options = {}) => {
26
+ const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
27
+ if (locale === "en") return en_quick_capture_quickidea1(inputs)
28
+ return zh_quick_capture_quickidea1(inputs)
29
+ });
30
+ export { quick_capture_quickidea1 as "quick_capture_quickIdea" }
@@ -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_Quickstory1Inputs */
7
+
8
+ const zh_quick_capture_quickstory1 = /** @type {(inputs: Quick_Capture_Quickstory1Inputs) => LocalizedString} */ () => {
9
+ return /** @type {LocalizedString} */ (`快捷记录需求`)
10
+ };
11
+
12
+ const en_quick_capture_quickstory1 = /** @type {(inputs: Quick_Capture_Quickstory1Inputs) => LocalizedString} */ () => {
13
+ return /** @type {LocalizedString} */ (`Quick Story`)
14
+ };
15
+
16
+ /**
17
+ * | output |
18
+ * | --- |
19
+ * | "Quick Story" |
20
+ *
21
+ * @param {Quick_Capture_Quickstory1Inputs} inputs
22
+ * @param {{ locale?: "zh" | "en" }} options
23
+ * @returns {LocalizedString}
24
+ */
25
+ const quick_capture_quickstory1 = /** @type {((inputs?: Quick_Capture_Quickstory1Inputs, options?: { locale?: "zh" | "en" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Quick_Capture_Quickstory1Inputs, { locale?: "zh" | "en" }, {}>} */ ((inputs = {}, options = {}) => {
26
+ const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
27
+ if (locale === "en") return en_quick_capture_quickstory1(inputs)
28
+ return zh_quick_capture_quickstory1(inputs)
29
+ });
30
+ export { quick_capture_quickstory1 as "quick_capture_quickStory" }
@@ -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_QuitInputs */
7
+
8
+ const zh_quick_capture_quit = /** @type {(inputs: Quick_Capture_QuitInputs) => LocalizedString} */ () => {
9
+ return /** @type {LocalizedString} */ (`退出 AllBlue`)
10
+ };
11
+
12
+ const en_quick_capture_quit = /** @type {(inputs: Quick_Capture_QuitInputs) => LocalizedString} */ () => {
13
+ return /** @type {LocalizedString} */ (`Quit AllBlue`)
14
+ };
15
+
16
+ /**
17
+ * | output |
18
+ * | --- |
19
+ * | "Quit AllBlue" |
20
+ *
21
+ * @param {Quick_Capture_QuitInputs} inputs
22
+ * @param {{ locale?: "zh" | "en" }} options
23
+ * @returns {LocalizedString}
24
+ */
25
+ export const quick_capture_quit = /** @type {((inputs?: Quick_Capture_QuitInputs, options?: { locale?: "zh" | "en" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Quick_Capture_QuitInputs, { locale?: "zh" | "en" }, {}>} */ ((inputs = {}, options = {}) => {
26
+ const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
27
+ if (locale === "en") return en_quick_capture_quit(inputs)
28
+ return zh_quick_capture_quit(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
- labelIds: [],
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,