@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.
- package/package.json +239 -30
- package/src/components/iteration/auto-schedule-dialog.tsx +150 -0
- package/src/components/iteration/burndown-chart.tsx +63 -0
- package/src/components/iteration/constants.ts +28 -0
- package/src/components/iteration/create-iteration-dialog.tsx +140 -0
- package/src/components/iteration/iteration-card.tsx +60 -0
- package/src/components/iteration/iteration-editor.tsx +116 -0
- package/src/components/iteration/iteration-progress-bar.tsx +44 -0
- package/src/components/iteration/iteration-status-badge.tsx +33 -0
- package/src/components/knowledge/dialog/knowledge-form-dialog.tsx +21 -30
- package/src/components/knowledge/knowledge-toolbar.tsx +124 -1
- package/src/components/layout/sidebar-03/__tests__/nav-workspace.test.tsx +2 -2
- package/src/components/layout/sidebar-03/nav-workspace.tsx +2 -2
- package/src/components/project/create-project-dialog.tsx +44 -57
- package/src/components/shared/__tests__/lucide-curated-icons.test.ts +83 -0
- package/src/components/shared/__tests__/lucide-icon-data.test.ts +131 -0
- package/src/components/shared/__tests__/lucide-icon-picker.test.tsx +197 -0
- package/src/components/shared/lucide-icon-picker/curated-icons.ts +478 -0
- package/src/components/shared/lucide-icon-picker/icon-data.ts +109 -0
- package/src/components/shared/lucide-icon-picker/index.tsx +82 -0
- package/src/components/shared/lucide-icon-picker/lucide-icon-by-name.tsx +78 -0
- package/src/components/shared/lucide-icon-picker/panel.tsx +163 -0
- package/src/components/story/__tests__/constants.test.ts +255 -0
- package/src/components/story/ai/story-ai-panel.tsx +23 -15
- package/src/components/story/ai/story-ai-preview-list.tsx +19 -0
- package/src/components/story/constants.ts +43 -1
- package/src/components/story/create-story-dialog.tsx +42 -16
- package/src/components/story/detail/__tests__/associated-attachments-panel.test.tsx +114 -0
- package/src/components/story/detail/__tests__/categories.test.tsx +62 -0
- package/src/components/story/detail/__tests__/dependencies-button.test.tsx +49 -0
- package/src/components/story/detail/activity-timeline.tsx +26 -4
- package/src/components/story/detail/story-detail-body.tsx +90 -38
- package/src/components/story/detail/story-detail-toolbar/associated-attachments-panel.tsx +857 -0
- package/src/components/story/detail/story-detail-toolbar/categories.tsx +23 -18
- package/src/components/story/detail/story-detail-toolbar/children.tsx +9 -4
- package/src/components/story/detail/story-detail-toolbar/dependency-panel.tsx +95 -27
- package/src/components/story/detail/story-detail-toolbar.tsx +159 -8
- package/src/components/story/detail/story-properties.tsx +21 -4
- package/src/components/story/display/__tests__/group-fields.test.ts +107 -0
- package/src/components/story/display/group-resolver.tsx +2 -2
- package/src/components/story/inline-editors/__tests__/dependency-type-select.test.tsx +155 -0
- package/src/components/story/inline-editors/__tests__/labels-editor.test.tsx +155 -0
- package/src/components/story/inline-editors/__tests__/priority-editor.test.tsx +173 -0
- package/src/components/story/inline-editors/__tests__/state-editor.test.tsx +174 -0
- package/src/components/story/inline-editors/dependency-type-select.tsx +180 -0
- package/src/components/story/inline-editors/index.ts +2 -1
- package/src/components/story/inline-editors/labels-editor.tsx +159 -3
- package/src/components/story/inline-editors/story-select-dialog.tsx +41 -63
- package/src/components/story/peek-panel.tsx +121 -21
- package/src/components/story/relations/__tests__/attachment-manage-dialog.test.tsx +171 -0
- package/src/components/story/relations/__tests__/knowledge-select-dialog.test.tsx +174 -0
- package/src/components/story/relations/__tests__/link-manage-dialog.test.tsx +93 -0
- package/src/components/story/relations/attachment-manage-dialog.tsx +490 -0
- package/src/components/story/relations/file-preview.tsx +173 -0
- package/src/components/story/relations/format.ts +120 -0
- package/src/components/story/relations/general-groups.ts +32 -0
- package/src/components/story/relations/knowledge-select-dialog.tsx +403 -0
- package/src/components/story/relations/link-manage-dialog.tsx +192 -0
- package/src/components/story/relations/story-ref-row.tsx +17 -27
- package/src/components/story/story-panel-header.tsx +25 -19
- package/src/components/story/story-toolbar.tsx +3 -3
- package/src/components/story/transfer-story-popover.tsx +2 -2
- package/src/components/story/types.ts +14 -0
- package/src/components/story/views/create-view-dialog.tsx +3 -3
- package/src/components/story/views/story-list/__tests__/group-header.test.tsx +219 -0
- package/src/components/story/views/story-list/group.tsx +1 -1
- package/src/components/story/views/story-list/inline-story-creator.tsx +2 -2
- package/src/components/story/views/story-list/row.tsx +1 -1
- package/src/components/workspace/create-workspace-dialog.tsx +41 -49
- package/src/lib/__tests__/project-display-id.test.ts +8 -5
- package/src/lib/changelog/sdk-versions.ts +20 -20
- package/src/lib/iteration/__tests__/status.test.ts +41 -0
- package/src/lib/iteration/status.ts +28 -0
- package/src/lib/project-display-id.ts +14 -5
- package/src/lib/project-module-meta.ts +77 -0
- package/src/lib/story/__tests__/video-embed.test.ts +77 -0
- package/src/lib/story/video-embed.ts +59 -0
- package/src/lib/utils.d.ts +1 -1
- package/src/plugins/.gen-manifest.json +13 -0
- package/src/plugins/modules.gen.ts +20 -0
- package/src/plugins/plugins.gen.ts +20 -0
- package/src/plugins/ui.gen.ts +8 -0
- package/src/providers/index.tsx +6 -0
- package/src/routeTree.gen.ts +96 -0
- package/src/routes/__root.tsx +0 -8
- package/src/routes/_nav/inspiration/route.tsx +8 -0
- package/src/routes/_nav/knowledge/index.lazy.tsx +2 -2
- package/src/routes/_nav/prd/$id/route.tsx +9 -0
- package/src/routes/_nav/prd/index.tsx +9 -0
- package/src/routes/_nav/prd/route.tsx +8 -0
- package/src/routes/_nav/projects/$projectId/$storyId/route.lazy.tsx +52 -25
- package/src/routes/_nav/projects/$projectId/$storyId/route.tsx +6 -3
- package/src/routes/_nav/projects/$projectId/index.lazy.tsx +32 -15
- package/src/routes/_nav/projects/$projectId/iterations/$iterationId/route.lazy.tsx +289 -0
- package/src/routes/_nav/projects/$projectId/iterations/$iterationId/route.tsx +27 -0
- package/src/routes/_nav/projects/$projectId/iterations/index.lazy.tsx +111 -0
- package/src/routes/_nav/projects/$projectId/iterations/index.tsx +24 -0
- package/src/routes/_nav/projects/$projectId/route.tsx +4 -2
- package/src/routes/_nav/projects/$projectId/settings/route.lazy.tsx +78 -7
- package/src/routes/_nav/projects/$projectId/settings/route.tsx +6 -2
- package/src/routes/_nav/stories/$storyId/route.lazy.tsx +5 -1
- package/src/routes/_nav/stories/$storyId/route.tsx +4 -0
- package/src/routes/_nav/tags/$id/route.tsx +8 -0
- package/src/routes/_nav/tags/index.tsx +8 -0
- package/src/routes/_nav/tags/route.tsx +8 -0
- package/src/routes/_nav/tags/settings/route.tsx +9 -0
- package/src/routes/_nav/workspaces/$workspaceId/route.lazy.tsx +3 -3
- package/src/routes/_nav/workspaces/$workspaceId/settings/route.lazy.tsx +4 -4
- package/src/routes/_nav/workspaces/index.lazy.tsx +3 -3
- package/src/routes/api/attachments/$id/content.ts +60 -0
- package/src/routes/api/attachments/upload.ts +148 -0
- package/src/routes/share/prd/$prdId/route.tsx +10 -0
- package/src/server/serverFns/__tests__/project-kb.test.ts +147 -0
- package/src/server/serverFns/iteration/__tests__/auto-schedule-core.test.ts +62 -0
- package/src/server/serverFns/iteration/__tests__/burndown-core.test.ts +90 -0
- package/src/server/serverFns/iteration/__tests__/iteration-crud.test.ts +211 -0
- package/src/server/serverFns/iteration/__tests__/iteration-stories.test.ts +155 -0
- package/src/server/serverFns/iteration/__tests__/iteration-transfer.test.ts +136 -0
- package/src/server/serverFns/iteration/auto-schedule-core.ts +54 -0
- package/src/server/serverFns/iteration/auto-schedule-internal.ts +88 -0
- package/src/server/serverFns/iteration/burndown-core.ts +60 -0
- package/src/server/serverFns/iteration/index.ts +22 -0
- package/src/server/serverFns/iteration/iteration-auto-schedule.ts +66 -0
- package/src/server/serverFns/iteration/iteration-burndown.ts +70 -0
- package/src/server/serverFns/iteration/iteration-crud.ts +208 -0
- package/src/server/serverFns/iteration/iteration-stories.ts +111 -0
- package/src/server/serverFns/iteration/iteration-transfer.ts +42 -0
- package/src/server/serverFns/iteration/schemas.ts +58 -0
- package/src/server/serverFns/iteration/transfer-internal.ts +62 -0
- package/src/server/serverFns/knowledge.ts +50 -4
- package/src/server/serverFns/project/module-overview.ts +109 -0
- package/src/server/serverFns/project.ts +45 -2
- package/src/server/serverFns/story/__tests__/kb-scope.test.ts +56 -0
- package/src/server/serverFns/story/__tests__/label-suggest-fn.test.ts +100 -0
- package/src/server/serverFns/story/__tests__/labels.test.ts +67 -0
- package/src/server/serverFns/story/__tests__/story-assoc.test.ts +222 -0
- package/src/server/serverFns/story/__tests__/story-dependencies.test.ts +132 -2
- package/src/server/serverFns/story/index.ts +9 -4
- package/src/server/serverFns/story/kb-general.ts +39 -0
- package/src/server/serverFns/story/kb-scope.ts +52 -0
- package/src/server/serverFns/story/link-preview.ts +115 -0
- package/src/server/serverFns/story/story-assoc.ts +372 -51
- package/src/server/serverFns/story/story-attachments.ts +32 -45
- package/src/{components/project/project-icon.tsx → server/serverFns/story/story-crud/label-palette.ts} +16 -19
- package/src/server/serverFns/story/story-crud/label-suggest-core.ts +88 -0
- package/src/server/serverFns/story/story-crud/label-suggest.ts +38 -0
- package/src/{components/knowledge/knowledge-icon.tsx → server/serverFns/story/story-crud/label-upsert.ts} +13 -19
- package/src/server/serverFns/story/story-crud/labels.ts +3 -5
- package/src/server/serverFns/story/story-crud.ts +53 -6
- package/src/server/serverFns/story/story-dependencies.ts +158 -5
- package/src/server/serverFns/story/story-links.ts +43 -7
- package/src/server/serverFns/story/story-transfer.ts +5 -4
- package/src/server/serverFns/story/story-tree.ts +28 -1
- package/src/server/serverFns/story/types.ts +38 -0
- package/src/styles/globals.css +25 -0
- package/src/types/project.ts +5 -5
- package/vite.shared.ts +2 -0
- package/src/components/project/project-animated-icon.tsx +0 -73
- package/src/components/shared/icon-packs/index.ts +0 -1
- package/src/components/shared/icon-packs/knowledge.ts +0 -1
- package/src/components/shared/icon-packs/prd.ts +0 -1
- package/src/components/shared/icon-packs/project.ts +0 -1
- package/src/components/shared/icon-picker.tsx +0 -1
- package/src/components/story/detail/story-detail-toolbar/attachments.tsx +0 -73
- package/src/components/story/detail/story-detail-toolbar/knowledge.tsx +0 -73
- package/src/components/story/detail/story-detail-toolbar/links.tsx +0 -73
- package/src/components/story/relations/attachments-panel.tsx +0 -179
- package/src/components/story/relations/knowledge-panel.tsx +0 -284
- package/src/components/story/relations/links-panel.tsx +0 -240
- 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/
|
|
67
|
-
|
|
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 {
|
|
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
|
-
<
|
|
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 {
|
|
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: "
|
|
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", "
|
|
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-
|
|
152
|
-
<DialogHeader
|
|
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
|
|
158
|
-
<div className="flex
|
|
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
|
-
<
|
|
161
|
-
|
|
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="
|
|
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="
|
|
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
|
-
<
|
|
231
|
-
<
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
});
|