@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 +1 -1
- package/src/components/desktop/__tests__/quick-capture-invalidate.test.tsx +11 -1
- package/src/components/desktop/quick-capture-invalidate.tsx +11 -5
- package/src/components/quick-capture/__tests__/quick-capture-panel.test.tsx +162 -26
- package/src/components/quick-capture/quick-capture-panel.tsx +243 -39
- package/src/components/tiptap/notion-like/notion-like-editor.tsx +11 -3
- package/src/lib/__tests__/query-global-error.test.ts +2 -0
- package/src/lib/changelog/sdk-versions.ts +1 -1
- package/src/lib/desktop-bridge-types.ts +17 -0
- package/src/lib/query-global-error.ts +3 -0
- package/src/paraglide/messages/_index.js +7 -0
- package/src/paraglide/messages/quick_capture_openmain1.js +30 -0
- package/src/paraglide/messages/quick_capture_paneltitle1.js +30 -0
- package/src/paraglide/messages/quick_capture_pin.js +29 -0
- package/src/paraglide/messages/quick_capture_quickidea1.js +30 -0
- package/src/paraglide/messages/quick_capture_quickstory1.js +30 -0
- package/src/paraglide/messages/quick_capture_quit.js +29 -0
- package/src/paraglide/messages/quick_capture_settings.js +29 -0
- package/src/server/serverFns/story/__tests__/quick-capture.test.ts +45 -0
- package/src/server/serverFns/story/story-crud/quick-capture.ts +17 -3
package/package.json
CHANGED
|
@@ -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
|
|
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
|
-
// →
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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)
|
|
26
|
-
// 8)
|
|
27
|
-
// 9)
|
|
28
|
-
// 10)
|
|
29
|
-
// 11)
|
|
30
|
-
//
|
|
25
|
+
// 7) 内联字段:mock 编辑器切换 state/标签后保存载荷带新值
|
|
26
|
+
// 8) 保存成功:已记录 identifier、标题清空、notifyQuickCapture
|
|
27
|
+
// 9) Esc 隐藏窗口;项目 Popover 展开时 Esc 不隐藏
|
|
28
|
+
// 10) onMiniPanelMode 下发 inspiration 预选灵感箱;unmount 退订
|
|
29
|
+
// 11) 图钉切换:setMiniPanelPinned 调用 + onMiniPanelPinned 回传同步按钮态
|
|
30
|
+
// 12) 设置按钮:popUpTrayMenu 被调
|
|
31
|
+
// 13) 标题字号 md 适配 + NotionDocEditor 收到 hideMobileToolbar
|
|
32
|
+
// 14) 无桥(浏览器直开)降级不抛错
|
|
33
|
+
// 15) 请求在途期间再 Enter 不重复提交
|
|
34
|
+
// 富文本编辑器 / AI 工具栏 / 内联编辑器在 jsdom 以替身注入。
|
|
31
35
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
32
36
|
import { act, cleanup, render, screen, waitFor } from '@testing-library/react'
|
|
33
37
|
import userEvent from '@testing-library/user-event'
|
|
@@ -38,14 +42,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
|
-
|
|
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
|
-
//
|
|
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('
|
|
159
|
+
it('渲染顶部栏(logo + mini 面板)、默认预选独立需求与快捷键文案', async () => {
|
|
110
160
|
renderPanel()
|
|
111
|
-
expect(screen.
|
|
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
|
|
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: {
|
|
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: {
|
|
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',
|
|
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
|
-
|
|
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: {
|
|
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
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
19
|
+
// 入口,字段与布局对齐主应用「创建需求弹窗」(顶部栏 + 项目选择 + 标题 + 富文本描述
|
|
20
|
+
// + 内联字段 chip 行 + 记录按钮)。顶部栏右侧:钉(常显示,main 侧抑制 blur 隐藏并
|
|
21
|
+
// 置顶)/ 设置(弹出与菜单栏图标右键同一份托盘菜单)。落库走 quickCaptureStoryFn——
|
|
22
|
+
// kind 只作为「灵感箱」懒创建标记:项目选择器的固定「灵感箱」项 → kind=inspiration;
|
|
22
23
|
// 其余 → kind=story + 显式 projectId(或默认独立需求)。键盘约定:标题框 Enter 提交
|
|
23
|
-
// (描述编辑器内 Enter/Shift+Enter 由 TipTap 自然换行)、Esc 经桥 window("hide")
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
// 降级不抛错。
|
|
24
|
+
// (描述编辑器内 Enter/Shift+Enter 由 TipTap 自然换行)、Esc 经桥 window("hide") 收起
|
|
25
|
+
// 窗口(项目 Popover 展开时 Esc 只关 Popover)。桥消费一律 getDesktopBridge() + 可选
|
|
26
|
+
// 链——旧壳缺少新方法、浏览器直开无桥,全部优雅降级不抛错。
|
|
27
27
|
import { useCallback, useEffect, useRef, useState } from "react"
|
|
28
28
|
import type { KeyboardEvent } from "react"
|
|
29
|
-
import { Sprout } from "lucide-react"
|
|
29
|
+
import { CalendarCheck, CalendarPlus2, Pin, PinOff, Settings, Sprout } from "lucide-react"
|
|
30
30
|
import { useQuery } from "@tanstack/react-query"
|
|
31
31
|
|
|
32
32
|
import { m } from "@/paraglide/messages.js"
|
|
@@ -37,16 +37,32 @@ import {
|
|
|
37
37
|
BUILTIN_INDEPENDENT_DISPLAY_ID,
|
|
38
38
|
} from "@/lib/builtin-project"
|
|
39
39
|
import { serializeStoryDescription } from "@/shared/story/types"
|
|
40
|
-
import {
|
|
40
|
+
import type { UserBrief } from "@/shared/story/types"
|
|
41
|
+
import type { StoryPriority, StoryState } from "@/shared/story/constants"
|
|
42
|
+
import { quickCaptureStoryFn, listUsersBriefFn, listLabelsFn } from "@/server/serverFns/story"
|
|
41
43
|
import { listProjectsBriefFn } from "@/server/serverFns/project"
|
|
42
44
|
import { Button } from "@/components/ui/button"
|
|
43
45
|
import { Input } from "@/components/ui/input"
|
|
44
46
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
|
47
|
+
import {
|
|
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
|
-
|
|
171
|
-
|
|
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
|
-
<
|
|
211
|
-
|
|
212
|
-
|
|
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
|
|
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-
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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.
|
|
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
|
-
|
|
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,
|