@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@allbluecn/web-app",
3
- "version": "0.27.1",
3
+ "version": "0.27.2",
4
4
  "license": "AGPL-3.0-or-later",
5
5
  "type": "module",
6
6
  "files": [
@@ -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,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
- vi.mock('@/server/serverFns/story', () => ({ quickCaptureStoryFn: quickCaptureStoryFnMock }))
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
- // 富文本编辑器替身:一个可输入的 textarea,onChange 以 {html, markdown} 回传
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('渲染单一「需求/灵感」标签、默认预选独立需求与快捷键文案', async () => {
157
+ it('渲染顶部栏(logo + mini 面板)、默认预选独立需求与快捷键文案', async () => {
110
158
  renderPanel()
111
- expect(screen.getByText('需求/灵感')).toBeTruthy()
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,不含描述', async () => {
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: { kind: 'story', name: '想法A', description: undefined, projectId: 'p-story' },
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: { kind: 'inspiration', name: '灵感一闪', description: undefined, projectId: undefined },
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', description: undefined, projectId: 'p-work' },
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
- projectId: 'p-story',
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: { kind: 'story', name: '想法E', description: undefined, projectId: 'p-story' },
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
- // 入口,字段与布局对齐主应用「创建需求弹窗」(项目选择 + 标题 + 富文本描述 + 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,26 @@ 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 { 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
- setName("")
171
- setDescription({ html: "", markdown: "" })
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
- <span className="rounded-md bg-muted px-2 py-1 text-xs font-medium text-muted-foreground">
211
- {m.quick_capture_tabUnified()}
212
- </span>
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 w-full justify-start gap-1.5 border-0 bg-muted/50 px-2.5 text-sm font-medium"
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-60 min-h-36 flex-col overflow-y-auto rounded-md border border-border${
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.1",
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
- 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,