@allbluecn/web-app 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. package/package.json +239 -30
  2. package/src/components/iteration/auto-schedule-dialog.tsx +150 -0
  3. package/src/components/iteration/burndown-chart.tsx +63 -0
  4. package/src/components/iteration/constants.ts +28 -0
  5. package/src/components/iteration/create-iteration-dialog.tsx +140 -0
  6. package/src/components/iteration/iteration-card.tsx +60 -0
  7. package/src/components/iteration/iteration-editor.tsx +116 -0
  8. package/src/components/iteration/iteration-progress-bar.tsx +44 -0
  9. package/src/components/iteration/iteration-status-badge.tsx +33 -0
  10. package/src/components/knowledge/dialog/knowledge-form-dialog.tsx +21 -30
  11. package/src/components/knowledge/knowledge-toolbar.tsx +124 -1
  12. package/src/components/layout/sidebar-03/__tests__/nav-workspace.test.tsx +2 -2
  13. package/src/components/layout/sidebar-03/nav-workspace.tsx +2 -2
  14. package/src/components/project/create-project-dialog.tsx +44 -57
  15. package/src/components/shared/__tests__/lucide-curated-icons.test.ts +83 -0
  16. package/src/components/shared/__tests__/lucide-icon-data.test.ts +131 -0
  17. package/src/components/shared/__tests__/lucide-icon-picker.test.tsx +197 -0
  18. package/src/components/shared/lucide-icon-picker/curated-icons.ts +478 -0
  19. package/src/components/shared/lucide-icon-picker/icon-data.ts +109 -0
  20. package/src/components/shared/lucide-icon-picker/index.tsx +82 -0
  21. package/src/components/shared/lucide-icon-picker/lucide-icon-by-name.tsx +78 -0
  22. package/src/components/shared/lucide-icon-picker/panel.tsx +163 -0
  23. package/src/components/story/__tests__/constants.test.ts +255 -0
  24. package/src/components/story/ai/story-ai-panel.tsx +23 -15
  25. package/src/components/story/ai/story-ai-preview-list.tsx +19 -0
  26. package/src/components/story/constants.ts +43 -1
  27. package/src/components/story/create-story-dialog.tsx +42 -16
  28. package/src/components/story/detail/__tests__/associated-attachments-panel.test.tsx +114 -0
  29. package/src/components/story/detail/__tests__/categories.test.tsx +62 -0
  30. package/src/components/story/detail/__tests__/dependencies-button.test.tsx +49 -0
  31. package/src/components/story/detail/activity-timeline.tsx +26 -4
  32. package/src/components/story/detail/story-detail-body.tsx +90 -38
  33. package/src/components/story/detail/story-detail-toolbar/associated-attachments-panel.tsx +857 -0
  34. package/src/components/story/detail/story-detail-toolbar/categories.tsx +23 -18
  35. package/src/components/story/detail/story-detail-toolbar/children.tsx +9 -4
  36. package/src/components/story/detail/story-detail-toolbar/dependency-panel.tsx +95 -27
  37. package/src/components/story/detail/story-detail-toolbar.tsx +159 -8
  38. package/src/components/story/detail/story-properties.tsx +21 -4
  39. package/src/components/story/display/__tests__/group-fields.test.ts +107 -0
  40. package/src/components/story/display/group-resolver.tsx +2 -2
  41. package/src/components/story/inline-editors/__tests__/dependency-type-select.test.tsx +155 -0
  42. package/src/components/story/inline-editors/__tests__/labels-editor.test.tsx +155 -0
  43. package/src/components/story/inline-editors/__tests__/priority-editor.test.tsx +173 -0
  44. package/src/components/story/inline-editors/__tests__/state-editor.test.tsx +174 -0
  45. package/src/components/story/inline-editors/dependency-type-select.tsx +180 -0
  46. package/src/components/story/inline-editors/index.ts +2 -1
  47. package/src/components/story/inline-editors/labels-editor.tsx +159 -3
  48. package/src/components/story/inline-editors/story-select-dialog.tsx +41 -63
  49. package/src/components/story/peek-panel.tsx +121 -21
  50. package/src/components/story/relations/__tests__/attachment-manage-dialog.test.tsx +171 -0
  51. package/src/components/story/relations/__tests__/knowledge-select-dialog.test.tsx +174 -0
  52. package/src/components/story/relations/__tests__/link-manage-dialog.test.tsx +93 -0
  53. package/src/components/story/relations/attachment-manage-dialog.tsx +490 -0
  54. package/src/components/story/relations/file-preview.tsx +173 -0
  55. package/src/components/story/relations/format.ts +120 -0
  56. package/src/components/story/relations/general-groups.ts +32 -0
  57. package/src/components/story/relations/knowledge-select-dialog.tsx +403 -0
  58. package/src/components/story/relations/link-manage-dialog.tsx +192 -0
  59. package/src/components/story/relations/story-ref-row.tsx +17 -27
  60. package/src/components/story/story-panel-header.tsx +25 -19
  61. package/src/components/story/story-toolbar.tsx +3 -3
  62. package/src/components/story/transfer-story-popover.tsx +2 -2
  63. package/src/components/story/types.ts +14 -0
  64. package/src/components/story/views/create-view-dialog.tsx +3 -3
  65. package/src/components/story/views/story-list/__tests__/group-header.test.tsx +219 -0
  66. package/src/components/story/views/story-list/group.tsx +1 -1
  67. package/src/components/story/views/story-list/inline-story-creator.tsx +2 -2
  68. package/src/components/story/views/story-list/row.tsx +1 -1
  69. package/src/components/workspace/create-workspace-dialog.tsx +41 -49
  70. package/src/lib/__tests__/project-display-id.test.ts +8 -5
  71. package/src/lib/changelog/sdk-versions.ts +20 -20
  72. package/src/lib/iteration/__tests__/status.test.ts +41 -0
  73. package/src/lib/iteration/status.ts +28 -0
  74. package/src/lib/project-display-id.ts +14 -5
  75. package/src/lib/project-module-meta.ts +77 -0
  76. package/src/lib/story/__tests__/video-embed.test.ts +77 -0
  77. package/src/lib/story/video-embed.ts +59 -0
  78. package/src/lib/utils.d.ts +1 -1
  79. package/src/plugins/.gen-manifest.json +13 -0
  80. package/src/plugins/modules.gen.ts +20 -0
  81. package/src/plugins/plugins.gen.ts +20 -0
  82. package/src/plugins/ui.gen.ts +8 -0
  83. package/src/providers/index.tsx +6 -0
  84. package/src/routeTree.gen.ts +96 -0
  85. package/src/routes/__root.tsx +0 -8
  86. package/src/routes/_nav/inspiration/route.tsx +8 -0
  87. package/src/routes/_nav/knowledge/index.lazy.tsx +2 -2
  88. package/src/routes/_nav/prd/$id/route.tsx +9 -0
  89. package/src/routes/_nav/prd/index.tsx +9 -0
  90. package/src/routes/_nav/prd/route.tsx +8 -0
  91. package/src/routes/_nav/projects/$projectId/$storyId/route.lazy.tsx +52 -25
  92. package/src/routes/_nav/projects/$projectId/$storyId/route.tsx +6 -3
  93. package/src/routes/_nav/projects/$projectId/index.lazy.tsx +32 -15
  94. package/src/routes/_nav/projects/$projectId/iterations/$iterationId/route.lazy.tsx +289 -0
  95. package/src/routes/_nav/projects/$projectId/iterations/$iterationId/route.tsx +27 -0
  96. package/src/routes/_nav/projects/$projectId/iterations/index.lazy.tsx +111 -0
  97. package/src/routes/_nav/projects/$projectId/iterations/index.tsx +24 -0
  98. package/src/routes/_nav/projects/$projectId/route.tsx +4 -2
  99. package/src/routes/_nav/projects/$projectId/settings/route.lazy.tsx +78 -7
  100. package/src/routes/_nav/projects/$projectId/settings/route.tsx +6 -2
  101. package/src/routes/_nav/stories/$storyId/route.lazy.tsx +5 -1
  102. package/src/routes/_nav/stories/$storyId/route.tsx +4 -0
  103. package/src/routes/_nav/tags/$id/route.tsx +8 -0
  104. package/src/routes/_nav/tags/index.tsx +8 -0
  105. package/src/routes/_nav/tags/route.tsx +8 -0
  106. package/src/routes/_nav/tags/settings/route.tsx +9 -0
  107. package/src/routes/_nav/workspaces/$workspaceId/route.lazy.tsx +3 -3
  108. package/src/routes/_nav/workspaces/$workspaceId/settings/route.lazy.tsx +4 -4
  109. package/src/routes/_nav/workspaces/index.lazy.tsx +3 -3
  110. package/src/routes/api/attachments/$id/content.ts +60 -0
  111. package/src/routes/api/attachments/upload.ts +148 -0
  112. package/src/routes/share/prd/$prdId/route.tsx +10 -0
  113. package/src/server/serverFns/__tests__/project-kb.test.ts +147 -0
  114. package/src/server/serverFns/iteration/__tests__/auto-schedule-core.test.ts +62 -0
  115. package/src/server/serverFns/iteration/__tests__/burndown-core.test.ts +90 -0
  116. package/src/server/serverFns/iteration/__tests__/iteration-crud.test.ts +211 -0
  117. package/src/server/serverFns/iteration/__tests__/iteration-stories.test.ts +155 -0
  118. package/src/server/serverFns/iteration/__tests__/iteration-transfer.test.ts +136 -0
  119. package/src/server/serverFns/iteration/auto-schedule-core.ts +54 -0
  120. package/src/server/serverFns/iteration/auto-schedule-internal.ts +88 -0
  121. package/src/server/serverFns/iteration/burndown-core.ts +60 -0
  122. package/src/server/serverFns/iteration/index.ts +22 -0
  123. package/src/server/serverFns/iteration/iteration-auto-schedule.ts +66 -0
  124. package/src/server/serverFns/iteration/iteration-burndown.ts +70 -0
  125. package/src/server/serverFns/iteration/iteration-crud.ts +208 -0
  126. package/src/server/serverFns/iteration/iteration-stories.ts +111 -0
  127. package/src/server/serverFns/iteration/iteration-transfer.ts +42 -0
  128. package/src/server/serverFns/iteration/schemas.ts +58 -0
  129. package/src/server/serverFns/iteration/transfer-internal.ts +62 -0
  130. package/src/server/serverFns/knowledge.ts +50 -4
  131. package/src/server/serverFns/project/module-overview.ts +109 -0
  132. package/src/server/serverFns/project.ts +45 -2
  133. package/src/server/serverFns/story/__tests__/kb-scope.test.ts +56 -0
  134. package/src/server/serverFns/story/__tests__/label-suggest-fn.test.ts +100 -0
  135. package/src/server/serverFns/story/__tests__/labels.test.ts +67 -0
  136. package/src/server/serverFns/story/__tests__/story-assoc.test.ts +222 -0
  137. package/src/server/serverFns/story/__tests__/story-dependencies.test.ts +132 -2
  138. package/src/server/serverFns/story/index.ts +9 -4
  139. package/src/server/serverFns/story/kb-general.ts +39 -0
  140. package/src/server/serverFns/story/kb-scope.ts +52 -0
  141. package/src/server/serverFns/story/link-preview.ts +115 -0
  142. package/src/server/serverFns/story/story-assoc.ts +372 -51
  143. package/src/server/serverFns/story/story-attachments.ts +32 -45
  144. package/src/{components/project/project-icon.tsx → server/serverFns/story/story-crud/label-palette.ts} +16 -19
  145. package/src/server/serverFns/story/story-crud/label-suggest-core.ts +88 -0
  146. package/src/server/serverFns/story/story-crud/label-suggest.ts +38 -0
  147. package/src/{components/knowledge/knowledge-icon.tsx → server/serverFns/story/story-crud/label-upsert.ts} +13 -19
  148. package/src/server/serverFns/story/story-crud/labels.ts +3 -5
  149. package/src/server/serverFns/story/story-crud.ts +53 -6
  150. package/src/server/serverFns/story/story-dependencies.ts +158 -5
  151. package/src/server/serverFns/story/story-links.ts +43 -7
  152. package/src/server/serverFns/story/story-transfer.ts +5 -4
  153. package/src/server/serverFns/story/story-tree.ts +28 -1
  154. package/src/server/serverFns/story/types.ts +38 -0
  155. package/src/styles/globals.css +25 -0
  156. package/src/types/project.ts +5 -5
  157. package/vite.shared.ts +2 -0
  158. package/src/components/project/project-animated-icon.tsx +0 -73
  159. package/src/components/shared/icon-packs/index.ts +0 -1
  160. package/src/components/shared/icon-packs/knowledge.ts +0 -1
  161. package/src/components/shared/icon-packs/prd.ts +0 -1
  162. package/src/components/shared/icon-packs/project.ts +0 -1
  163. package/src/components/shared/icon-picker.tsx +0 -1
  164. package/src/components/story/detail/story-detail-toolbar/attachments.tsx +0 -73
  165. package/src/components/story/detail/story-detail-toolbar/knowledge.tsx +0 -73
  166. package/src/components/story/detail/story-detail-toolbar/links.tsx +0 -73
  167. package/src/components/story/relations/attachments-panel.tsx +0 -179
  168. package/src/components/story/relations/knowledge-panel.tsx +0 -284
  169. package/src/components/story/relations/links-panel.tsx +0 -240
  170. package/src/lib/project-mock.ts +0 -156
@@ -72,6 +72,10 @@ import { toast } from '@/components/ui/sonner'
72
72
  import { KnowledgeFormDialog } from '@/components/knowledge/dialog/knowledge-form-dialog'
73
73
  import { cn } from '@/lib/utils'
74
74
  import { useTranslation } from 'react-i18next'
75
+ import { Checkbox } from '@/components/ui/checkbox'
76
+ import { Link as LinkIcon } from 'lucide-react'
77
+ import { listKbProjectsFn, setKbProjectsFn } from '@/server/serverFns/knowledge'
78
+ import { listProjectsBriefFn } from '@/server/serverFns/project'
75
79
 
76
80
  interface KbData {
77
81
  id: string
@@ -116,6 +120,10 @@ export function KnowledgeToolbar({ kb, onUpdate, breadcrumb, docType, treeCollap
116
120
  const [createPopover, setCreatePopover] = useState(false)
117
121
  const [createType, setCreateType] = useState('doc')
118
122
  const [createTitle, setCreateTitle] = useState('')
123
+ const [projectsDialogOpen, setProjectsDialogOpen] = useState(false)
124
+ const [projectCandidates, setProjectCandidates] = useState<{ id: string; name: string; icon: string }[]>([])
125
+ const [projectChecked, setProjectChecked] = useState<Set<string>>(new Set())
126
+ const [savingProjects, setSavingProjects] = useState(false)
119
127
  const createInputRef = useRef<HTMLInputElement>(null)
120
128
 
121
129
  useEffect(() => {
@@ -148,13 +156,40 @@ export function KnowledgeToolbar({ kb, onUpdate, breadcrumb, docType, treeCollap
148
156
  if (value === localVisibility) return
149
157
  setLocalVisibility(value)
150
158
  try {
151
- await updateKnowledgeFn({ data: { id: kb.id, visibility: value as 'private' | 'team' } })
159
+ await updateKnowledgeFn({ data: { id: kb.id, visibility: value as 'private' | 'team' | 'general' } })
152
160
  onUpdate?.()
153
161
  } catch (e) {
154
162
  console.error('Update visibility failed:', e instanceof Error ? e.message : String(e))
155
163
  }
156
164
  }
157
165
 
166
+ const openProjectsDialog = async () => {
167
+ setProjectsDialogOpen(true);
168
+ try {
169
+ const [linked, all] = await Promise.all([
170
+ listKbProjectsFn({ data: { kbId: kb.id } }),
171
+ listProjectsBriefFn(),
172
+ ]);
173
+ setProjectCandidates(all);
174
+ setProjectChecked(new Set(linked.map((p) => p.id)));
175
+ } catch {
176
+ toast.error('加载项目列表失败');
177
+ }
178
+ };
179
+
180
+ const saveProjects = async () => {
181
+ setSavingProjects(true);
182
+ try {
183
+ await setKbProjectsFn({ data: { kbId: kb.id, projectIds: [...projectChecked] } });
184
+ toast.success('项目关联已更新');
185
+ setProjectsDialogOpen(false);
186
+ } catch (err) {
187
+ toast.error(err instanceof Error ? err.message : '保存失败');
188
+ } finally {
189
+ setSavingProjects(false);
190
+ }
191
+ };
192
+
158
193
  const handleDelete = async () => {
159
194
  if (deleteConfirm !== kb.name) return
160
195
  setDeleting(true)
@@ -426,6 +461,56 @@ export function KnowledgeToolbar({ kb, onUpdate, breadcrumb, docType, treeCollap
426
461
  {localVisibility === 'team' ? t('toolbar.team') : t('toolbar.private')}
427
462
  </Badge>
428
463
 
464
+ {localVisibility === 'team' && (
465
+ <Button
466
+ variant="ghost"
467
+ size="sm"
468
+ className="h-7 gap-1.5 px-2.5 text-xs font-normal hover:bg-muted rounded-md shrink-0"
469
+ onClick={openProjectsDialog}
470
+ >
471
+ <LinkIcon className="size-3.5 text-muted-foreground" />
472
+ {t('toolbar.linkProjects')}
473
+ </Button>
474
+ )}
475
+
476
+ {localVisibility === 'general' && (
477
+ <Tooltip>
478
+ <TooltipTrigger asChild>
479
+ <Button
480
+ variant="ghost"
481
+ size="sm"
482
+ className="h-7 gap-1.5 px-2.5 text-xs font-normal hover:bg-muted rounded-md shrink-0"
483
+ disabled
484
+ >
485
+ <LinkIcon className="size-3.5 text-muted-foreground" />
486
+ {t('toolbar.linkProjects')}
487
+ </Button>
488
+ </TooltipTrigger>
489
+ <TooltipContent>
490
+ <p>{t('toolbar.generalLinkDisabled')}</p>
491
+ </TooltipContent>
492
+ </Tooltip>
493
+ )}
494
+
495
+ {localVisibility === 'private' && (
496
+ <Tooltip>
497
+ <TooltipTrigger asChild>
498
+ <Button
499
+ variant="ghost"
500
+ size="sm"
501
+ className="h-7 gap-1.5 px-2.5 text-xs font-normal hover:bg-muted rounded-md shrink-0"
502
+ disabled
503
+ >
504
+ <LinkIcon className="size-3.5 text-muted-foreground" />
505
+ {t('toolbar.linkProjects')}
506
+ </Button>
507
+ </TooltipTrigger>
508
+ <TooltipContent>
509
+ <p>{t('toolbar.privateLinkDisabled')}</p>
510
+ </TooltipContent>
511
+ </Tooltip>
512
+ )}
513
+
429
514
  <div className="ml-auto">
430
515
  <DropdownMenu>
431
516
  <DropdownMenuTrigger asChild>
@@ -544,6 +629,44 @@ export function KnowledgeToolbar({ kb, onUpdate, breadcrumb, docType, treeCollap
544
629
  kb={{ id: kb.id, name: kb.name, description: kb.description ?? null, icon: kb.icon ?? 'notebook' }}
545
630
  onSuccess={onUpdate}
546
631
  />
632
+
633
+ <Dialog open={projectsDialogOpen} onOpenChange={setProjectsDialogOpen}>
634
+ <DialogContent className="sm:max-w-260 -mt-50">
635
+ <DialogHeader>
636
+ <DialogTitle>{t('toolbar.linkProjects')}</DialogTitle>
637
+ </DialogHeader>
638
+ <div className="flex flex-col gap-2 max-h-96 overflow-y-auto">
639
+ {projectCandidates.length === 0 ? (
640
+ <p className="text-sm text-muted-foreground">暂无项目</p>
641
+ ) : (
642
+ projectCandidates.map((proj) => (
643
+ <label key={proj.id} className="flex items-center gap-2 rounded-md border px-3 py-2 text-sm cursor-pointer">
644
+ <Checkbox
645
+ checked={projectChecked.has(proj.id)}
646
+ onCheckedChange={(v) =>
647
+ setProjectChecked((prev) => {
648
+ const next = new Set(prev);
649
+ if (v) next.add(proj.id);
650
+ else next.delete(proj.id);
651
+ return next;
652
+ })
653
+ }
654
+ />
655
+ <span className="flex-1">{proj.name}</span>
656
+ </label>
657
+ ))
658
+ )}
659
+ </div>
660
+ <DialogFooter>
661
+ <Button variant="outline" onClick={() => setProjectsDialogOpen(false)}>
662
+ {t('knowledge:toolbar.cancel')}
663
+ </Button>
664
+ <Button onClick={saveProjects} disabled={savingProjects}>
665
+ {savingProjects ? '保存中...' : t('settings.kbSection.save')}
666
+ </Button>
667
+ </DialogFooter>
668
+ </DialogContent>
669
+ </Dialog>
547
670
  </div>
548
671
  )
549
672
  }
@@ -63,8 +63,8 @@ vi.mock("@/lib/sidebar-utils", () => ({
63
63
  isParentActive: (pathname: string, target: string) => pathname.startsWith(target),
64
64
  }));
65
65
 
66
- vi.mock("@/components/project/project-icon", () => ({
67
- ProjectIconDisplay: ({ name }: any) => <span data-testid="project-icon">{name}</span>,
66
+ vi.mock("@/components/shared/lucide-icon-picker/lucide-icon-by-name", () => ({
67
+ LucideIconByName: ({ name }: any) => <span data-testid="project-icon">{name}</span>,
68
68
  }));
69
69
 
70
70
  vi.mock("@/components/workspace/create-workspace-dialog", () => ({
@@ -28,7 +28,7 @@ import {
28
28
  } from "@/components/ui/sidebar";
29
29
  import { cn } from "@/lib/utils";
30
30
  import { isParentActive } from "@/lib/sidebar-utils";
31
- import { ProjectIconDisplay } from "@/components/project/project-icon";
31
+ import { LucideIconByName } from "@/components/shared/lucide-icon-picker/lucide-icon-by-name";
32
32
  import { CreateWorkspaceDialog } from "@/components/workspace/create-workspace-dialog";
33
33
  import { useLiveQuery } from "@tanstack/react-db";
34
34
  import { workspacesCollection } from "@/lib/collections/reference/workspaces-collection";
@@ -74,7 +74,7 @@ function WorkspaceLinks({ pathname, t }: { pathname: string; t: any }) {
74
74
  params={{ workspaceId: ws.id }}
75
75
  className="flex w-full items-center"
76
76
  >
77
- <ProjectIconDisplay
77
+ <LucideIconByName
78
78
  name={ws.icon}
79
79
  size={16}
80
80
  className="text-sidebar-foreground"
@@ -29,17 +29,14 @@ import {
29
29
  } from "@/components/ui/select";
30
30
  import { Button } from "@/components/ui/button";
31
31
  import { Input } from "@/components/ui/input";
32
- import { Textarea } from "@/components/ui/textarea";
33
32
  import {
34
33
  Dialog,
35
34
  DialogContent,
36
- DialogDescription,
37
35
  DialogFooter,
38
36
  DialogHeader,
39
37
  DialogTitle,
40
38
  } from "@/components/ui/dialog";
41
- import { IconPicker } from "@/components/shared/icon-picker";
42
- import { PROJECT_ICONS } from "@/components/shared/icon-packs/project";
39
+ import { LucideIconPicker } from "@/components/shared/lucide-icon-picker";
43
40
  import { extractDisplayId, validateDisplayId } from "@/lib/project-display-id";
44
41
  import { checkDisplayIdAvailableFn, createProjectFn } from "@/server/serverFns/project";
45
42
  import { listWorkspacesBriefFn } from "@/server/serverFns/workspace";
@@ -77,7 +74,7 @@ export function CreateProjectDialog({
77
74
  defaultValues: {
78
75
  name: "",
79
76
  description: "",
80
- icon: "folder-kanban",
77
+ icon: "",
81
78
  displayId: "",
82
79
  workspaceId: defaultWorkspaceId ?? "",
83
80
  },
@@ -134,7 +131,7 @@ export function CreateProjectDialog({
134
131
 
135
132
  const handleOpenChange = (newOpen: boolean) => {
136
133
  if (newOpen) {
137
- form.setFieldValue("icon", "folder-kanban");
134
+ form.setFieldValue("icon", "");
138
135
  form.setFieldValue("name", "");
139
136
  form.setFieldValue("description", "");
140
137
  form.setFieldValue("displayId", "");
@@ -148,25 +145,20 @@ export function CreateProjectDialog({
148
145
 
149
146
  return (
150
147
  <Dialog open={open} onOpenChange={handleOpenChange}>
151
- <DialogContent className="sm:max-w-200">
152
- <DialogHeader className="p-4">
153
- <DialogTitle>{t("project.create.title")}</DialogTitle>
154
- <DialogDescription>{t("project.create.description")}</DialogDescription>
148
+ <DialogContent className="sm:max-w-260 -mt-50">
149
+ <DialogHeader>
150
+ <DialogTitle className="text-xl">{t("project.create.title")}</DialogTitle>
155
151
  </DialogHeader>
156
152
 
157
- <div className="space-y-4 p-4">
158
- <div className="flex flex-col gap-3 mb-20">
153
+ <div className="space-y-3 overflow-y-auto px-4 py-2 max-h-[60vh]">
154
+ <div className="flex gap-2">
159
155
  <form.Field name="icon" children={(field) => (
160
- <IconPicker
161
- icons={PROJECT_ICONS}
162
- value={field.value ?? "folder-kanban"}
156
+ <LucideIconPicker
157
+ value={field.value}
163
158
  onChange={(v) => field.handleChange(v)}
159
+ modal
164
160
  />
165
161
  )} />
166
- </div>
167
-
168
- <div className="flex flex-col gap-3">
169
- <label className="text-sm font-medium ml-2">{t("project.create.nameLabel")} *</label>
170
162
  <form.Field name="name" children={(field) => (
171
163
  <Input
172
164
  value={field.value ?? ""}
@@ -174,36 +166,11 @@ export function CreateProjectDialog({
174
166
  onBlur={field.handleBlur}
175
167
  placeholder={t("project.create.namePlaceholder")}
176
168
  maxLength={100}
169
+ className="h-10 min-w-0 flex-1"
177
170
  />
178
171
  )} />
179
- </div>
180
-
181
- <div className="flex flex-col gap-3">
182
- <label className="text-sm font-medium ml-2">{t("project.create.idLabel")} *</label>
183
- <form.Field name="displayId" children={(field) => (
184
- <Input
185
- value={field.value ?? ""}
186
- onChange={(e) => handleDisplayIdChange(e.target.value)}
187
- onBlur={async () => {
188
- field.handleBlur();
189
- if (!field.value || validateDisplayId(field.value)) return;
190
- const res = await checkDisplayIdAvailableFn({ data: { displayId: field.value } });
191
- setDisplayIdError(res.available ? "" : res.reason ?? t("project.create.idUnavailable"));
192
- }}
193
- placeholder={t("project.create.idPlaceholder")}
194
- maxLength={10}
195
- className={displayIdError ? "border-destructive" : ""}
196
- />
197
- )} />
198
- {displayIdError && (
199
- <span className="ml-2 text-xs text-destructive">{displayIdError}</span>
200
- )}
201
- </div>
202
-
203
- <div className="flex flex-col gap-3">
204
- <label className="text-sm font-medium ml-2">{t("project.create.workspaceLabel")} *</label>
205
172
  {defaultWorkspaceId ? (
206
- <span className="ml-2 px-3 py-2 rounded-md bg-muted border text-sm">
173
+ <span className="flex h-10 shrink-0 max-w-60 items-center truncate rounded-md border bg-muted px-3 text-sm">
207
174
  {defaultWorkspaceName ?? t("project.create.workspaceSelected")}
208
175
  </span>
209
176
  ) : (
@@ -212,7 +179,7 @@ if (!field.value || validateDisplayId(field.value)) return;
212
179
  value={field.value ?? ""}
213
180
  onValueChange={(v) => field.handleChange(v)}
214
181
  >
215
- <SelectTrigger className="ml-2">
182
+ <SelectTrigger className="h-10 w-44 shrink-0">
216
183
  <SelectValue placeholder={t("project.create.workspacePlaceholder")} />
217
184
  </SelectTrigger>
218
185
  <SelectContent>
@@ -227,29 +194,49 @@ if (!field.value || validateDisplayId(field.value)) return;
227
194
  )}
228
195
  </div>
229
196
 
230
- <div className="space-y-2">
231
- <label className="text-sm font-medium ml-2">{t("project.create.introLabel")}</label>
232
- <form.Field name="description" children={(field) => (
233
- <Textarea
197
+ <form.Field name="displayId" children={(field) => (
198
+ <div>
199
+ <Input
200
+ value={field.value ?? ""}
201
+ onChange={(e) => handleDisplayIdChange(e.target.value)}
202
+ onBlur={async () => {
203
+ field.handleBlur();
204
+ if (!field.value || validateDisplayId(field.value)) return;
205
+ const res = await checkDisplayIdAvailableFn({ data: { displayId: field.value } });
206
+ setDisplayIdError(res.available ? "" : res.reason ?? t("project.create.idUnavailable"));
207
+ }}
208
+ placeholder={t("project.create.idPlaceholder")}
209
+ maxLength={10}
210
+ className={`h-10 font-mono ${displayIdError ? "border-destructive" : ""}`}
211
+ />
212
+ {displayIdError && (
213
+ <p className="mt-1.5 text-xs text-destructive">{displayIdError}</p>
214
+ )}
215
+ </div>
216
+ )} />
217
+
218
+ <form.Field name="description" children={(field) => (
219
+ <div className="notion-full-width min-h-50 max-h-150 flex flex-col overflow-y-auto border rounded-md border-border">
220
+ <textarea
234
221
  value={field.value ?? ""}
235
222
  onChange={(e) => field.handleChange(e.target.value)}
236
223
  onBlur={field.handleBlur}
237
224
  placeholder={t("project.create.introPlaceholder")}
238
225
  maxLength={500}
239
- rows={3}
226
+ className="flex-1 min-h-0 resize-none bg-transparent px-3 py-2 text-sm outline-none placeholder:text-muted-foreground"
240
227
  />
241
- )} />
242
- </div>
228
+ </div>
229
+ )} />
243
230
 
244
231
  {error && <p className="text-sm text-destructive break-words">{error}</p>}
245
232
  </div>
246
233
 
247
- <DialogFooter>
234
+ <DialogFooter className="items-center gap-3">
248
235
  <Button variant="outline" onClick={() => handleOpenChange(false)}>
249
- {t("button.cancel")}
236
+ {t("common:button.cancel")}
250
237
  </Button>
251
238
  <Button onClick={handleSubmit} disabled={loading}>
252
- {loading ? t("project.create.creating") : t("button.create")}
239
+ {loading ? t("project.create.creating") : t("common:button.create")}
253
240
  </Button>
254
241
  </DialogFooter>
255
242
  </DialogContent>
@@ -0,0 +1,83 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+ //
5
+ // This program is free software: you can redistribute it and/or modify
6
+ // it under the terms of the GNU Affero General Public License as published by
7
+ // the Free Software Foundation, either version 3 of the License, or
8
+ // (at your option) any later version.
9
+ //
10
+ // This program is distributed in the hope that it will be useful,
11
+ // but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ // GNU Affero General Public License for more details.
14
+ //
15
+ // You should have received a copy of the GNU Affero General Public License
16
+ // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+ import { describe, expect, it } from "vitest";
19
+ import { icons } from "lucide-react";
20
+ import { CURATED_ICONS } from "@/components/shared/lucide-icon-picker/curated-icons";
21
+ import { pascalToKebab } from "@/components/shared/lucide-icon-picker/icon-data";
22
+
23
+ const BANNED_SUFFIX = /-(off|slash|ban|x)$/;
24
+ const BANNED_NUMBER_TAIL = /-[0-9]+$/;
25
+ /** lucide 官方正名含数字且常用的豁免清单(历史改名产物,非编号家族) */
26
+ const NUMBER_TAIL_ALLOWLIST = new Set([
27
+ "undo-2",
28
+ "redo-2",
29
+ "share-2",
30
+ "volume-1",
31
+ "volume-2",
32
+ "gamepad-2",
33
+ "rows-3",
34
+ "columns-3",
35
+ ]);
36
+
37
+ describe("CURATED_ICONS 精选名单完整性", () => {
38
+ it("数量在目标区间 400-520", () => {
39
+ expect(CURATED_ICONS.length).toBeGreaterThanOrEqual(400);
40
+ expect(CURATED_ICONS.length).toBeLessThanOrEqual(520);
41
+ });
42
+
43
+ it("name 无重复", () => {
44
+ const names = CURATED_ICONS.map((i) => i.name);
45
+ expect(new Set(names).size).toBe(names.length);
46
+ });
47
+
48
+ it("不含否定/关闭变体后缀", () => {
49
+ const bad = CURATED_ICONS.filter((i) => BANNED_SUFFIX.test(i.name));
50
+ expect(bad.map((i) => i.name)).toEqual([]);
51
+ });
52
+
53
+ it("不含数字结尾编号变体(豁免清单除外)", () => {
54
+ const bad = CURATED_ICONS.filter(
55
+ (i) => BANNED_NUMBER_TAIL.test(i.name) && !NUMBER_TAIL_ALLOWLIST.has(i.name)
56
+ );
57
+ expect(bad.map((i) => i.name)).toEqual([]);
58
+ });
59
+
60
+ it("name 与 lucide 官方组件名一一对应(pascalToKebab 校验)", () => {
61
+ const kebabSet = new Set(Object.keys(icons).map(pascalToKebab));
62
+ const bad = CURATED_ICONS.filter((i) => !kebabSet.has(i.name));
63
+ expect(bad.map((i) => i.name)).toEqual([]);
64
+ });
65
+
66
+ it("Icon 引用与 name 相互一致(反查 icons map 键再转 kebab 相等)", () => {
67
+ const entryByPascal = new Map(
68
+ Object.entries(icons).map(([pascal, Comp]) => [Comp, pascal])
69
+ );
70
+ const bad = CURATED_ICONS.filter((i) => {
71
+ const pascal = entryByPascal.get(i.Icon);
72
+ return !pascal || pascalToKebab(pascal) !== i.name;
73
+ });
74
+ expect(bad.map((i) => i.name)).toEqual([]);
75
+ });
76
+
77
+ it("每项 zh 关键词非空且无空字符串", () => {
78
+ const bad = CURATED_ICONS.filter(
79
+ (i) => i.zh.length === 0 || i.zh.some((k) => !k.trim())
80
+ );
81
+ expect(bad.map((i) => i.name)).toEqual([]);
82
+ });
83
+ });
@@ -0,0 +1,131 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+ //
5
+ // This program is free software: you can redistribute it and/or modify
6
+ // it under the terms of the GNU Affero General Public License as published by
7
+ // the Free Software Foundation, either version 3 of the License, or
8
+ // (at your option) any later version.
9
+ //
10
+ // This program is distributed in the hope that it will be useful,
11
+ // but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ // GNU Affero General Public License for more details.
14
+ //
15
+ // You should have received a copy of the GNU Affero General Public License
16
+ // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+ import { afterEach, describe, expect, it, vi } from "vitest";
19
+ import {
20
+ buildSearchIndex,
21
+ filterIcons,
22
+ getRecentIcons,
23
+ isCJK,
24
+ pascalToKebab,
25
+ pushRecentIcon,
26
+ type CuratedIcon,
27
+ } from "@/components/shared/lucide-icon-picker/icon-data";
28
+ import { Star, Clock, FolderKanban } from "lucide-react";
29
+
30
+ const fakeIcons: CuratedIcon[] = [
31
+ { name: "star", Icon: Star, zh: ["星标", "收藏", "评分"] },
32
+ { name: "clock", Icon: Clock, zh: ["时钟", "时间"] },
33
+ { name: "folder-kanban", Icon: FolderKanban, zh: ["看板", "项目"] },
34
+ ];
35
+
36
+ describe("pascalToKebab", () => {
37
+ it("常规 PascalCase 转换", () => {
38
+ expect(pascalToKebab("FolderKanban")).toBe("folder-kanban");
39
+ expect(pascalToKebab("Star")).toBe("star");
40
+ });
41
+ it("连续大写开头(AArrowDown)", () => {
42
+ expect(pascalToKebab("AArrowDown")).toBe("a-arrow-down");
43
+ });
44
+ it("字母后接数字(Grid2x2 → grid-2x-2)", () => {
45
+ expect(pascalToKebab("Grid2x2")).toBe("grid-2x-2");
46
+ });
47
+ it("全小写单词(Github)", () => {
48
+ expect(pascalToKebab("Github")).toBe("github");
49
+ });
50
+ });
51
+
52
+ describe("isCJK", () => {
53
+ it("中文判真", () => {
54
+ expect(isCJK("星标")).toBe(true);
55
+ expect(isCJK("收藏")).toBe(true);
56
+ });
57
+ it("英文判假", () => {
58
+ expect(isCJK("star")).toBe(false);
59
+ expect(isCJK("")).toBe(false);
60
+ });
61
+ });
62
+
63
+ describe("buildSearchIndex + filterIcons", () => {
64
+ const index = buildSearchIndex(fakeIcons);
65
+
66
+ it("英文子串匹配", () => {
67
+ const names = filterIcons("star", index).map((e) => e.name);
68
+ expect(names).toEqual(["star"]);
69
+ });
70
+ it("kebab 分词匹配(folder-kanban 可用 kanban 命中)", () => {
71
+ const names = filterIcons("kanban", index).map((e) => e.name);
72
+ expect(names).toEqual(["folder-kanban"]);
73
+ });
74
+ it("大小写不敏感", () => {
75
+ const names = filterIcons("STAR", index).map((e) => e.name);
76
+ expect(names).toEqual(["star"]);
77
+ });
78
+ it("中文关键词匹配", () => {
79
+ const names = filterIcons("时间", index).map((e) => e.name);
80
+ expect(names).toEqual(["clock"]);
81
+ });
82
+ it("中文部分子串匹配", () => {
83
+ const names = filterIcons("星", index).map((e) => e.name);
84
+ expect(names).toEqual(["star"]);
85
+ });
86
+ it("无结果返回空数组", () => {
87
+ expect(filterIcons("zzz不存在", index)).toEqual([]);
88
+ });
89
+ it("结果保留 Icon 引用供渲染", () => {
90
+ const results = filterIcons("star", index);
91
+ expect(results[0].Icon).toBe(Star);
92
+ });
93
+ });
94
+
95
+ describe("recent icons (localStorage)", () => {
96
+ afterEach(() => {
97
+ // 顺序关键:先恢复被 stub 的 window,再操作 localStorage——
98
+ // SSR 用例 stubGlobal("window", undefined) 后若先 clear 会抛 TypeError
99
+ vi.unstubAllGlobals();
100
+ window.localStorage.clear();
101
+ });
102
+
103
+ it("空存储返回空数组", () => {
104
+ expect(getRecentIcons()).toEqual([]);
105
+ });
106
+ it("写入后最新在前", () => {
107
+ pushRecentIcon("star");
108
+ pushRecentIcon("clock");
109
+ expect(getRecentIcons()).toEqual(["clock", "star"]);
110
+ });
111
+ it("重复写入去重并前移", () => {
112
+ pushRecentIcon("star");
113
+ pushRecentIcon("clock");
114
+ pushRecentIcon("star");
115
+ expect(getRecentIcons()).toEqual(["star", "clock"]);
116
+ });
117
+ it("上限 16 截断", () => {
118
+ for (let i = 0; i < 20; i++) pushRecentIcon(`icon-${i}`);
119
+ expect(getRecentIcons()).toHaveLength(16);
120
+ expect(getRecentIcons()[0]).toBe("icon-19");
121
+ });
122
+ it("损坏 JSON 返回空数组不抛错", () => {
123
+ window.localStorage.setItem("allblue.ui.lucide-icon-picker.recent", "{broken");
124
+ expect(getRecentIcons()).toEqual([]);
125
+ });
126
+ it("SSR 无 window 安全", () => {
127
+ vi.stubGlobal("window", undefined);
128
+ expect(getRecentIcons()).toEqual([]);
129
+ expect(pushRecentIcon("star")).toEqual(["star"]);
130
+ });
131
+ });