diff --git a/components/src/lpw/types.ts b/components/src/lpw/types.ts
index d8d9dad..f89e1c6 100644
--- a/components/src/lpw/types.ts
+++ b/components/src/lpw/types.ts
@@ -289,6 +289,7 @@ export interface LpwCardsProps {
export interface LpwMermaidProps {
content: string
caption?: string
+ width?: string
}
export type LpwChartType =
diff --git a/internal/handler/pages.go b/internal/handler/pages.go
index bd2877f..96ade4d 100644
--- a/internal/handler/pages.go
+++ b/internal/handler/pages.go
@@ -23,6 +23,7 @@ var _ = apiCommon.BaseResponse{}
// @Param Authorization header string true "Bearer Access Token"
// @Param project_id query string false "项目ID"
// @Param workspace_id query string false "空间ID"
+// @Param status query string false "状态筛选(published/archived)"
// @Param page query int false "页码"
// @Param size query int false "每页数量"
// @Success 200 {object} apiCommon.BaseResponse{data=apiPages.PageListResponse} "查询成功"
@@ -33,7 +34,7 @@ func (h *PagesHandler) ListPages(ctx *gin.Context) {
if !BindQuery(ctx, &req) {
return
}
- resp, xErr := h.service.pagesLogic.List(ctx.Request.Context(), req.ProjectID, req.WorkspaceID, req.Page, req.Size)
+ resp, xErr := h.service.pagesLogic.List(ctx.Request.Context(), req.ProjectID, req.WorkspaceID, req.Status, req.Page, req.Size)
if xErr != nil {
_ = ctx.Error(xErr)
return
diff --git a/internal/handler/project.go b/internal/handler/project.go
index fdca9d8..e27c54b 100644
--- a/internal/handler/project.go
+++ b/internal/handler/project.go
@@ -52,6 +52,9 @@ func (h *ProjectHandler) CreateProject(ctx *gin.Context) {
// @Param page query int false "页码"
// @Param size query int false "每页数量"
// @Param workspace_id query string false "所属空间ID"
+// @Param search query string false "项目名称/别名模糊搜索"
+// @Param page query int false "页码(默认 1)"
+// @Param size query int false "每页数量(默认 10)"
// @Success 200 {object} apiCommon.BaseResponse{data=apiProject.ProjectListResponse} "获取成功"
// @Failure 401 {object} apiCommon.BaseResponse "未授权"
// @Router /api/v1/project [GET]
@@ -63,7 +66,7 @@ func (h *ProjectHandler) ListProjects(ctx *gin.Context) {
return
}
- resp, xErr := h.service.projectLogic.List(ctx, req.Page, req.Size, req.WorkspaceID)
+ resp, xErr := h.service.projectLogic.List(ctx, req.Page, req.Size, req.WorkspaceID, req.Search)
if xErr != nil {
_ = ctx.Error(xErr)
return
diff --git a/internal/logic/pages_logic.go b/internal/logic/pages_logic.go
index 413d9db..0936c99 100644
--- a/internal/logic/pages_logic.go
+++ b/internal/logic/pages_logic.go
@@ -67,8 +67,8 @@ func (l *PagesLogic) AuthToken() *service.PageAuthTokenService {
}
// List 分页列出页面
-func (l *PagesLogic) List(ctx context.Context, projectID, workspaceID xSnowflake.SnowflakeID, page, size int) (*apiPages.PageListResponse, *xError.Error) {
- pages, total, xErr := l.repo.page.List(ctx, projectID, workspaceID, page, size)
+func (l *PagesLogic) List(ctx context.Context, projectID, workspaceID xSnowflake.SnowflakeID, status string, page, size int) (*apiPages.PageListResponse, *xError.Error) {
+ pages, total, xErr := l.repo.page.List(ctx, projectID, workspaceID, status, page, size)
if xErr != nil {
return nil, xErr
}
diff --git a/internal/logic/project.go b/internal/logic/project.go
index 984431a..28ea3f0 100644
--- a/internal/logic/project.go
+++ b/internal/logic/project.go
@@ -116,15 +116,15 @@ func (l *ProjectLogic) GetByID(ctx context.Context, id string) (*apiProject.Proj
return l.toResponse(project), nil
}
-// List 分页获取项目列表;workspaceID 为零时不过滤
-func (l *ProjectLogic) List(ctx context.Context, page, size int, workspaceID xSnowflake.SnowflakeID) (*apiProject.ProjectListResponse, *xError.Error) {
- l.log.Info(ctx, fmt.Sprintf("List - 获取项目列表 [page=%d, size=%d, workspace=%d]", page, size, workspaceID.Int64()))
+// List 分页获取项目列表;workspaceID 为零时不过滤,search 不为空时模糊搜索
+func (l *ProjectLogic) List(ctx context.Context, page, size int, workspaceID xSnowflake.SnowflakeID, search string) (*apiProject.ProjectListResponse, *xError.Error) {
+ l.log.Info(ctx, fmt.Sprintf("List - 获取项目列表 [page=%d, size=%d, workspace=%d, search=%s]", page, size, workspaceID.Int64(), search))
pageReq := xModels.PageRequest{Page: int64(page), Size: int64(size)}.Normalize()
page = int(pageReq.Page)
size = int(pageReq.Size)
- projects, total, xErr := l.repo.project.List(ctx, page, size, workspaceID)
+ projects, total, xErr := l.repo.project.List(ctx, page, size, workspaceID, search)
if xErr != nil {
return nil, xErr
}
diff --git a/internal/logic/project_test.go b/internal/logic/project_test.go
index df69198..57e1733 100644
--- a/internal/logic/project_test.go
+++ b/internal/logic/project_test.go
@@ -133,7 +133,7 @@ func TestProjectLogic_List(t *testing.T) {
}
// 分页查询
- listResp, xErr := l.List(ctx, 1, 10, 0)
+ listResp, xErr := l.List(ctx, 1, 10, 0, "")
if xErr != nil {
t.Fatalf("List failed: %v", xErr)
}
diff --git a/internal/logic/project_workspace_test.go b/internal/logic/project_workspace_test.go
index 938b8e8..9a57cf7 100644
--- a/internal/logic/project_workspace_test.go
+++ b/internal/logic/project_workspace_test.go
@@ -126,11 +126,11 @@ func TestProjectUpdateWorkspacePreservesProjectAndLists(t *testing.T) {
if resp.ID.String() != "100000000000000003" || resp.AliasName != "alias" || resp.Description != "original" || len(resp.MatchPath) != 1 || resp.MatchPath[0] != "/test" {
t.Fatalf("migration changed project identity or fields: %+v", resp)
}
- oldList, xErr := l.List(ctx, 1, 10, 100000000000000001)
+ oldList, xErr := l.List(ctx, 1, 10, 100000000000000001, "")
if xErr != nil || oldList.Total != 0 {
t.Fatalf("old list = %+v, error = %v", oldList, xErr)
}
- newList, xErr := l.List(ctx, 1, 10, 100000000000000002)
+ newList, xErr := l.List(ctx, 1, 10, 100000000000000002, "")
if xErr != nil || newList.Total != 1 || newList.Items[0].ID != resp.ID {
t.Fatalf("new list = %+v, error = %v", newList, xErr)
}
diff --git a/internal/mcp/pages_tools.go b/internal/mcp/pages_tools.go
index 7e3a1a9..d1fe756 100644
--- a/internal/mcp/pages_tools.go
+++ b/internal/mcp/pages_tools.go
@@ -124,7 +124,7 @@ func handlePagesList(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallTo
if s, ok := args["size"].(float64); ok && s > 0 && s <= 100 {
size = int(s)
}
- resp, xErr := pagesLogic.List(ctx, projectID, 0, page, size)
+ resp, xErr := pagesLogic.List(ctx, projectID, 0, "", page, size)
if xErr != nil {
return previewErrorResult(fmt.Sprintf("获取页面列表失败: %s", xErr.Error())), nil
}
diff --git a/internal/mcp/preview_lpw_spec.go b/internal/mcp/preview_lpw_spec.go
index 780c8aa..160bc35 100644
--- a/internal/mcp/preview_lpw_spec.go
+++ b/internal/mcp/preview_lpw_spec.go
@@ -90,7 +90,7 @@ func GetLpwTextSpec(kindFilter, typeFilter string) string {
sb.WriteString(" image: props: src*(str), alt*(str), caption(str), width(str)\n")
sb.WriteString(" divider: props: {} (无必填字段)\n")
sb.WriteString(" cards: props: items*([ {title*, description, href} ], 1..12)\n")
- sb.WriteString(" mermaid: props: content*(str: Mermaid 语法, 1..8192), caption(str)\n")
+ sb.WriteString(" mermaid: props: content*(str: Mermaid 语法, 1..8192), caption(str), width(str)\n")
sb.WriteString(" comparison: props: plans*([ {name*, recommended(bool)} ]), rows*([ {dimension*, values*([ {text*, verdict:\"good\"|\"warn\"|\"bad\"} ])} ]), title\n")
sb.WriteString(" progress: props: items*([ {label*, value*(0..100), status:\"wait\"|\"process\"|\"finish\"|\"error\"} ], 1..12), title\n")
sb.WriteString(" tree: props: nodes*([ {label*, note, children:[treeNode]} ]), title\n")
@@ -240,7 +240,8 @@ func getSpecificTypeSpec(nodeType string) string {
属性:
props: {
content: string (合法的 Mermaid 源码文本,如 graph TD / sequenceDiagram)*,
- caption: string (图表注脚说明)
+ caption: string (图表注脚说明),
+ width: string (可选宽度,如 "80%" 或 "600px", 默认 "80%")
}`
case "table":
diff --git a/internal/mcp/project_tools.go b/internal/mcp/project_tools.go
index 612e176..6dd7889 100644
--- a/internal/mcp/project_tools.go
+++ b/internal/mcp/project_tools.go
@@ -248,7 +248,7 @@ func handleProjectList(_ context.Context, req *mcp.CallToolRequest) (*mcp.CallTo
if errMsg != "" {
return errorTextResult(errMsg), nil
}
- resp, xErr := projectLogic.List(context.Background(), page, size, workspaceID)
+ resp, xErr := projectLogic.List(context.Background(), page, size, workspaceID, "")
if xErr != nil {
return errorTextResult(fmt.Sprintf("获取项目列表失败: %s", xErr.Error())), nil
}
diff --git a/internal/repository/page.go b/internal/repository/page.go
index 4bce975..16a6f27 100644
--- a/internal/repository/page.go
+++ b/internal/repository/page.go
@@ -85,11 +85,11 @@ func (r *PageRepo) GetByIDs(ctx context.Context, ids []xSnowflake.SnowflakeID) (
return pages, nil
}
-// List 分页获取页面列表(按更新时间降序;projectID / workspaceID 为零值时不过滤)
-func (r *PageRepo) List(ctx context.Context, projectID, workspaceID xSnowflake.SnowflakeID, page, size int) ([]*entity.Page, int64, *xError.Error) {
+// List 分页获取页面列表(按更新时间降序;projectID / workspaceID 为零值时不过滤;status 为空不过滤)
+func (r *PageRepo) List(ctx context.Context, projectID, workspaceID xSnowflake.SnowflakeID, status string, page, size int) ([]*entity.Page, int64, *xError.Error) {
pageReq := xModels.PageRequest{Page: int64(page), Size: int64(size)}.Normalize()
page, size = int(pageReq.Page), int(pageReq.Size)
- r.log.Info(ctx, fmt.Sprintf("List - 分页获取页面列表 [projectID=%d, workspace=%d, page=%d, size=%d]", projectID.Int64(), workspaceID.Int64(), page, size))
+ r.log.Info(ctx, fmt.Sprintf("List - 分页获取页面列表 [projectID=%d, workspace=%d, status=%s, page=%d, size=%d]", projectID.Int64(), workspaceID.Int64(), status, page, size))
query := r.db.WithContext(ctx).Model(&entity.Page{})
if !projectID.IsZero() {
@@ -99,6 +99,9 @@ func (r *PageRepo) List(ctx context.Context, projectID, workspaceID xSnowflake.S
projects := r.db.WithContext(ctx).Model(&entity.Project{}).Select("id").Where("workspace_id = ?", workspaceID)
query = query.Where("project_id IN (?)", projects)
}
+ if status != "" {
+ query = query.Where("status = ?", status)
+ }
var total int64
if err := query.Count(&total).Error; err != nil {
diff --git a/internal/repository/project.go b/internal/repository/project.go
index 8ca492f..15e7c37 100644
--- a/internal/repository/project.go
+++ b/internal/repository/project.go
@@ -134,14 +134,18 @@ func (r *ProjectRepo) GetByName(ctx context.Context, name string) (*entity.Proje
// List 分页获取项目列表(按创建时间降序)
//
-// workspaceID 为零值时不过滤空间,与现网行为相同。
-func (r *ProjectRepo) List(ctx context.Context, page, size int, workspaceID xSnowflake.SnowflakeID) ([]*entity.Project, int64, *xError.Error) {
- r.log.Info(ctx, fmt.Sprintf("List - 分页获取项目列表 [page=%d, size=%d, workspace=%d]", page, size, workspaceID.Int64()))
+// workspaceID 为零值时不过滤空间,search 不为空时按名称/别名模糊匹配
+func (r *ProjectRepo) List(ctx context.Context, page, size int, workspaceID xSnowflake.SnowflakeID, search string) ([]*entity.Project, int64, *xError.Error) {
+ r.log.Info(ctx, fmt.Sprintf("List - 分页获取项目列表 [page=%d, size=%d, workspace=%d, search=%s]", page, size, workspaceID.Int64(), search))
query := r.db.WithContext(ctx).Model(&entity.Project{})
if !workspaceID.IsZero() {
query = query.Where("workspace_id = ?", workspaceID)
}
+ if search != "" {
+ like := "%" + search + "%"
+ query = query.Where("name ILIKE ? OR alias_name ILIKE ?", like, like)
+ }
var total int64
if err := query.Count(&total).Error; err != nil {
diff --git a/internal/repository/project_test.go b/internal/repository/project_test.go
index 643d41d..9c4d623 100644
--- a/internal/repository/project_test.go
+++ b/internal/repository/project_test.go
@@ -107,7 +107,7 @@ func TestProjectRepo_List(t *testing.T) {
repo := setupTestRepo(t)
ctx := context.Background()
- projects, total, xErr := repo.List(ctx, 1, 10, 0)
+ projects, total, xErr := repo.List(ctx, 1, 10, 0, "")
if xErr != nil {
t.Fatalf("List failed: %v", xErr)
}
diff --git a/resources/lpw/schema/v1.1.json b/resources/lpw/schema/v1.1.json
index 8672a6a..815e6a6 100644
--- a/resources/lpw/schema/v1.1.json
+++ b/resources/lpw/schema/v1.1.json
@@ -1362,6 +1362,11 @@
"caption": {
"type": "string",
"maxLength": 200
+ },
+ "width": {
+ "type": "string",
+ "maxLength": 16,
+ "pattern": "^(auto|[0-9]{1,4}(px|%)?)$"
}
}
},
diff --git a/web/src/components/llm/model-workbench.tsx b/web/src/components/llm/model-workbench.tsx
new file mode 100644
index 0000000..593e008
--- /dev/null
+++ b/web/src/components/llm/model-workbench.tsx
@@ -0,0 +1,504 @@
+import { useEffect, useMemo, useRef, useState } from 'react'
+import { Plus, Search, Trash2 } from 'lucide-react'
+import { Button } from '@lumina/components/ui/button'
+import { Input } from '@lumina/components/ui/input'
+import { Label } from '@lumina/components/ui/label'
+import { Badge } from '@lumina/components/ui/badge'
+import { Switch } from '@lumina/components/ui/switch'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@lumina/components/ui/select'
+import { ProviderOption } from '#/components/llm/provider-option'
+import {
+ useCreateModel,
+ useDeleteModel,
+ useModels,
+ useProviders,
+ useUpdateModel,
+} from '#/hooks/useLlmConfig'
+import { ConfirmDeleteDialog } from '#/components/confirm-delete-dialog'
+import { SkeletonTable } from '#/components/skeleton-table'
+import type { Model } from '#/lib/models/response/llm'
+import { toast } from 'sonner'
+
+export function ModelWorkbench() {
+ const { data: modelsData, isLoading: modelsLoading } = useModels()
+ const { data: providersData, isLoading: providersLoading } = useProviders()
+ const createMutation = useCreateModel()
+ const updateMutation = useUpdateModel()
+ const deleteMutation = useDeleteModel()
+
+ const rawModels = modelsData?.data?.items ?? []
+ const providers = providersData?.data?.items ?? []
+
+ // 当前选中的模型,null 表示「新建模式」
+ const [selectedId, setSelectedId] = useState
(null)
+ const [isCreating, setIsCreating] = useState(false)
+ const [searchTerm, setSearchTerm] = useState('')
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
+ const [modelToDelete, setModelToDelete] = useState(null)
+
+ // 标记是否已执行过初次装载默认选中
+ const hasInitialized = useRef(false)
+
+ // 表单状态
+ const [providerId, setProviderId] = useState('')
+ const [modelName, setModelName] = useState('')
+ const [displayName, setDisplayName] = useState('')
+ const [maxTokens, setMaxTokens] = useState('32000')
+ const [contextWindow, setContextWindow] = useState('128000')
+ const [temperature, setTemperature] = useState('0.3')
+ const [description, setDescription] = useState('')
+ const [isActive, setIsActive] = useState(true)
+
+ // 过滤后的模型列表
+ const filteredModels = useMemo(() => {
+ if (!searchTerm.trim()) return rawModels
+ const t = searchTerm.toLowerCase().trim()
+ return rawModels.filter(
+ (m) =>
+ m.model_name.toLowerCase().includes(t) ||
+ m.display_name.toLowerCase().includes(t),
+ )
+ }, [rawModels, searchTerm])
+
+ const selectedModel = useMemo(
+ () => rawModels.find((m) => m.id === selectedId) ?? null,
+ [rawModels, selectedId],
+ )
+
+ // 初始化或切换选中项时回填表单
+ useEffect(() => {
+ if (!isCreating && selectedModel) {
+ setProviderId(selectedModel.provider_id)
+ setModelName(selectedModel.model_name)
+ setDisplayName(selectedModel.display_name)
+ setMaxTokens(String(selectedModel.max_tokens))
+ setContextWindow(String(selectedModel.context_window))
+ setTemperature(String(selectedModel.temperature))
+ setDescription(selectedModel.description || '')
+ setIsActive(selectedModel.is_active)
+ } else if (isCreating) {
+ // 默认切换到新建模式,自动绑定第一个 Provider
+ setProviderId(providers[0]?.id || '')
+ setModelName('')
+ setDisplayName('')
+ setMaxTokens('32000')
+ setContextWindow('128000')
+ setTemperature('0.3')
+ setDescription('')
+ setIsActive(true)
+ }
+ }, [selectedModel, providers, isCreating])
+
+ // 仅在首次拉取到数据时默认选中第一个已有模型
+ useEffect(() => {
+ if (!hasInitialized.current && rawModels.length > 0) {
+ setSelectedId(rawModels[0].id)
+ setIsCreating(false)
+ hasInitialized.current = true
+ } else if (rawModels.length === 0 && !isCreating) {
+ setIsCreating(true)
+ setSelectedId(null)
+ }
+ }, [rawModels, isCreating])
+
+ const handleStartCreate = () => {
+ setIsCreating(true)
+ setSelectedId(null)
+ setProviderId(providers[0]?.id || '')
+ setModelName('')
+ setDisplayName('')
+ setMaxTokens('32000')
+ setContextWindow('128000')
+ setTemperature('0.3')
+ setDescription('')
+ setIsActive(true)
+ }
+
+ const handleSelectModel = (id: string) => {
+ setIsCreating(false)
+ setSelectedId(id)
+ }
+
+ const handleCancelCreate = () => {
+ setIsCreating(false)
+ if (rawModels.length > 0) {
+ setSelectedId(rawModels[0].id)
+ }
+ }
+
+ const handleSave = () => {
+ if (!providerId || !modelName.trim() || !displayName.trim()) {
+ toast.error('表单校验未通过', {
+ description: 'Provider、模型标识与显示名称为必填项',
+ })
+ return
+ }
+
+ if (isCreating) {
+ createMutation.mutate(
+ {
+ provider_id: providerId,
+ model_name: modelName.trim(),
+ display_name: displayName.trim(),
+ max_tokens: parseInt(maxTokens, 10) || 32000,
+ context_window: parseInt(contextWindow, 10) || 128000,
+ temperature: parseFloat(temperature) || 0.3,
+ description: description.trim() || '',
+ },
+ {
+ onSuccess: (res) => {
+ toast.success('模型创建成功', {
+ description: `已成功配置模型 [${displayName}]`,
+ })
+ setIsCreating(false)
+ if (res.data?.id) {
+ setSelectedId(res.data.id)
+ }
+ },
+ onError: (err) => {
+ toast.error('创建失败', { description: err.message })
+ },
+ },
+ )
+ } else if (selectedModel) {
+ updateMutation.mutate(
+ {
+ id: selectedModel.id,
+ data: {
+ provider_id: providerId,
+ model_name: modelName.trim(),
+ display_name: displayName.trim(),
+ max_tokens: parseInt(maxTokens, 10) || 32000,
+ context_window: parseInt(contextWindow, 10) || 128000,
+ temperature: parseFloat(temperature) || 0.3,
+ is_active: isActive,
+ description: description.trim(),
+ },
+ },
+ {
+ onSuccess: () => {
+ toast.success('模型已更新', { description: `配置已生效同步` })
+ },
+ onError: (err) => {
+ toast.error('更新失败', { description: err.message })
+ },
+ },
+ )
+ }
+ }
+
+ const handleDeleteClick = (e: React.MouseEvent, m: Model) => {
+ e.stopPropagation()
+ setModelToDelete(m)
+ setDeleteDialogOpen(true)
+ }
+
+ if (modelsLoading || providersLoading) {
+ return
+ }
+
+ return (
+
+ {/* ── 左侧:模型 Master 列表工作区 (占 5 列) ── */}
+
+ {/* 顶部工具栏 */}
+
+
+
+ setSearchTerm(e.target.value)}
+ placeholder="搜索模型名称或标识..."
+ className="h-8 pl-8 text-xs bg-foam"
+ />
+
+
+
+
+ {/* 模型列表项 */}
+
+ {filteredModels.length === 0 ? (
+
+ 暂无匹配的模型。点击右上角「新建模型」添加。
+
+ ) : (
+ filteredModels.map((m) => {
+ const isSelected = selectedId === m.id
+ const p = providers.find((item) => item.id === m.provider_id)
+ return (
+
handleSelectModel(m.id)}
+ className={`group relative flex flex-col gap-1.5 p-3.5 border transition-all cursor-pointer ${
+ isSelected
+ ? 'border-lagoon bg-foam shadow-sm border-l-4'
+ : 'border-line bg-surface hover:bg-sand/60'
+ }`}
+ >
+
+
+
+ {m.display_name}
+
+
+ {m.model_name}
+
+
+
+
+ {m.is_active ? '已启用' : '已停用'}
+
+
+
+
+
+ {/* 胶囊参数行 */}
+
+
+ {p?.name || '未知 Provider'}
+
+ •
+ {m.context_window.toLocaleString()} ctx
+ •
+ T={m.temperature}
+
+
+ )
+ })
+ )}
+
+
+
+ {/* ── 右侧:专属编辑与创建工作台 (占 7 列) ── */}
+
+ {/* 头部标题区 */}
+
+
+
+
+ {isCreating
+ ? '新建 LLM 模型配置'
+ : `配置模型 · ${selectedModel?.display_name || ''}`}
+
+
+
+ {isCreating && rawModels.length > 0 && (
+
+ )}
+
+ {isCreating ? 'CREATE' : 'EDIT'}
+
+
+
+
+ {/* 表单主体 */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {!isCreating ? (
+
+
+
+ 模型激活状态
+
+
+ 停用后,Agent 将无法调度该模型
+
+
+
+
+ ) : null}
+
+ {/* 底部专属操作栏 */}
+
+
+ {isCreating
+ ? '新模型将在保存后自动绑定到当前系统目录'
+ : `正在编辑 ID: ${selectedModel?.id || ''}`}
+
+
+ {!isCreating ? (
+
+ ) : null}
+
+
+
+
+
+
+ {/* 删除确认对话框(仅限破坏性删除) */}
+
{
+ if (!modelToDelete) return
+ deleteMutation.mutate(modelToDelete.id, {
+ onSuccess: () => {
+ setDeleteDialogOpen(false)
+ if (selectedId === modelToDelete.id) {
+ setSelectedId(null)
+ }
+ },
+ })
+ }}
+ isPending={deleteMutation.isPending}
+ />
+
+ )
+}
diff --git a/web/src/components/llm/provider-workbench.tsx b/web/src/components/llm/provider-workbench.tsx
new file mode 100644
index 0000000..09f830b
--- /dev/null
+++ b/web/src/components/llm/provider-workbench.tsx
@@ -0,0 +1,447 @@
+import { useEffect, useMemo, useRef, useState } from 'react'
+import { Plus, Search, Trash2 } from 'lucide-react'
+import { Button } from '@lumina/components/ui/button'
+import { Input } from '@lumina/components/ui/input'
+import { Label } from '@lumina/components/ui/label'
+import { Badge } from '@lumina/components/ui/badge'
+import { Switch } from '@lumina/components/ui/switch'
+import {
+ useCreateProvider,
+ useDeleteProvider,
+ useProviders,
+ useUpdateProvider,
+} from '#/hooks/useLlmConfig'
+import { ConfirmDeleteDialog } from '#/components/confirm-delete-dialog'
+import { SkeletonTable } from '#/components/skeleton-table'
+import type { Provider } from '#/lib/models/response/llm'
+import { toast } from 'sonner'
+
+export function ProviderWorkbench() {
+ const { data: providersData, isLoading: providersLoading } = useProviders()
+ const createMutation = useCreateProvider()
+ const updateMutation = useUpdateProvider()
+ const deleteMutation = useDeleteProvider()
+
+ const rawProviders = providersData?.data?.items ?? []
+
+ const [selectedId, setSelectedId] = useState(null)
+ const [isCreating, setIsCreating] = useState(false)
+ const [searchTerm, setSearchTerm] = useState('')
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
+ const [providerToDelete, setProviderToDelete] = useState(
+ null,
+ )
+
+ // 标记是否已执行过初次装载默认选中
+ const hasInitialized = useRef(false)
+
+ // 表单状态
+ const [name, setName] = useState('')
+ const [protocol, setProtocol] = useState('openai')
+ const [baseUrl, setBaseUrl] = useState('')
+ const [apiKey, setApiKey] = useState('')
+ const [description, setDescription] = useState('')
+ const [isActive, setIsActive] = useState(true)
+
+ const filteredProviders = useMemo(() => {
+ if (!searchTerm.trim()) return rawProviders
+ const t = searchTerm.toLowerCase().trim()
+ return rawProviders.filter(
+ (p) =>
+ p.name.toLowerCase().includes(t) ||
+ p.protocol.toLowerCase().includes(t),
+ )
+ }, [rawProviders, searchTerm])
+
+ const selectedProvider = useMemo(
+ () => rawProviders.find((p) => p.id === selectedId) ?? null,
+ [rawProviders, selectedId],
+ )
+
+ useEffect(() => {
+ if (!isCreating && selectedProvider) {
+ setName(selectedProvider.name)
+ setProtocol(selectedProvider.protocol)
+ setBaseUrl(selectedProvider.base_url || '')
+ setApiKey('') // 密钥不回显,留空表示不修改
+ setDescription(selectedProvider.description || '')
+ setIsActive(selectedProvider.is_active)
+ } else if (isCreating) {
+ setName('')
+ setProtocol('openai')
+ setBaseUrl('https://api.openai.com/v1')
+ setApiKey('')
+ setDescription('')
+ setIsActive(true)
+ }
+ }, [selectedProvider, isCreating])
+
+ // 仅在首次拉取到数据时默认选中第一个
+ useEffect(() => {
+ if (!hasInitialized.current && rawProviders.length > 0) {
+ setSelectedId(rawProviders[0].id)
+ setIsCreating(false)
+ hasInitialized.current = true
+ } else if (rawProviders.length === 0 && !isCreating) {
+ setIsCreating(true)
+ setSelectedId(null)
+ }
+ }, [rawProviders, isCreating])
+
+ const handleStartCreate = () => {
+ setIsCreating(true)
+ setSelectedId(null)
+ setName('')
+ setProtocol('openai')
+ setBaseUrl('https://api.openai.com/v1')
+ setApiKey('')
+ setDescription('')
+ setIsActive(true)
+ }
+
+ const handleSelectProvider = (id: string) => {
+ setIsCreating(false)
+ setSelectedId(id)
+ }
+
+ const handleCancelCreate = () => {
+ setIsCreating(false)
+ if (rawProviders.length > 0) {
+ setSelectedId(rawProviders[0].id)
+ }
+ }
+
+ const handleSave = () => {
+ if (!name.trim() || !protocol.trim()) {
+ toast.error('表单校验未通过', { description: '名称与协议为必填项' })
+ return
+ }
+
+ if (isCreating) {
+ if (!apiKey.trim()) {
+ toast.error('表单校验未通过', {
+ description: '创建 Provider 时 API 密钥为必填项',
+ })
+ return
+ }
+ createMutation.mutate(
+ {
+ name: name.trim(),
+ protocol: protocol.trim(),
+ base_url: baseUrl.trim(),
+ api_key: apiKey.trim(),
+ description: description.trim(),
+ },
+ {
+ onSuccess: (res) => {
+ toast.success('Provider 创建成功', {
+ description: `已成功接入 ${name}`,
+ })
+ setIsCreating(false)
+ if (res.data?.id) {
+ setSelectedId(res.data.id)
+ }
+ },
+ onError: (err) => {
+ toast.error('创建失败', { description: err.message })
+ },
+ },
+ )
+ } else if (selectedProvider) {
+ updateMutation.mutate(
+ {
+ id: selectedProvider.id,
+ data: {
+ name: name.trim(),
+ protocol: protocol.trim(),
+ base_url: baseUrl.trim() || undefined,
+ api_key: apiKey.trim() || undefined,
+ is_active: isActive,
+ description: description.trim() || undefined,
+ },
+ },
+ {
+ onSuccess: () => {
+ toast.success('Provider 已更新', { description: '配置已生效保存' })
+ },
+ onError: (err) => {
+ toast.error('更新失败', { description: err.message })
+ },
+ },
+ )
+ }
+ }
+
+ const handleDeleteClick = (e: React.MouseEvent, p: Provider) => {
+ e.stopPropagation()
+ setProviderToDelete(p)
+ setDeleteDialogOpen(true)
+ }
+
+ if (providersLoading) {
+ return
+ }
+
+ return (
+
+ {/* ── 左侧:Provider Master 列表 (占 5 列) ── */}
+
+
+
+
+ setSearchTerm(e.target.value)}
+ placeholder="搜索供应方名称或协议..."
+ className="h-8 pl-8 text-xs bg-foam"
+ />
+
+
+
+
+
+ {filteredProviders.length === 0 ? (
+
+ 暂无匹配的 Provider。点击右上角新建。
+
+ ) : (
+ filteredProviders.map((p) => {
+ const isSelected = selectedId === p.id
+ return (
+
handleSelectProvider(p.id)}
+ className={`group relative flex flex-col gap-1.5 p-3.5 border transition-all cursor-pointer ${
+ isSelected
+ ? 'border-lagoon bg-foam shadow-sm border-l-4'
+ : 'border-line bg-surface hover:bg-sand/60'
+ }`}
+ >
+
+
+
+ {p.name}
+
+
+ {p.base_url || '默认 BaseURL'}
+
+
+
+
+ {p.is_active ? '启用' : '停用'}
+
+
+
+
+
+
+
+ {p.protocol}
+
+ •
+ {p.has_key ? '已加密存盘' : '无密钥'}
+
+
+ )
+ })
+ )}
+
+
+
+ {/* ── 右侧:专属编辑与创建工作台 (占 7 列) ── */}
+
+
+
+
+
+ {isCreating
+ ? '接入新的 LLM 供应方'
+ : `配置供应方 · ${selectedProvider?.name || ''}`}
+
+
+
+ {isCreating && rawProviders.length > 0 && (
+
+ )}
+
+ {isCreating ? 'CREATE' : 'EDIT'}
+
+
+
+
+
+
+
+
+
+ setBaseUrl(e.target.value)}
+ placeholder="如 https://api.openai.com/v1"
+ className="bg-sand/30 font-mono text-xs"
+ />
+
+
+
+
+
setApiKey(e.target.value)}
+ placeholder={
+ isCreating
+ ? '输入 API Key(将经 AES-256-GCM 加密落库)'
+ : '留空表示保持现有加密密钥不变'
+ }
+ className="bg-sand/30 font-mono text-xs"
+ />
+
+ 密钥将经由系统 AES-256-GCM 加密存储,绝不以明文暴露。
+
+
+
+
+
+
+
+ {!isCreating ? (
+
+
+
+ 供应方激活状态
+
+
+ 停用后,其下所有模型将自动处于不可调度状态
+
+
+
+
+ ) : null}
+
+
+
+ {isCreating
+ ? '创建后可立即为其添加专属模型'
+ : `ID: ${selectedProvider?.id || ''}`}
+
+
+ {!isCreating ? (
+
+ ) : null}
+
+
+
+
+
+
+
{
+ if (!providerToDelete) return
+ deleteMutation.mutate(providerToDelete.id, {
+ onSuccess: () => {
+ setDeleteDialogOpen(false)
+ if (selectedId === providerToDelete.id) {
+ setSelectedId(null)
+ }
+ },
+ })
+ }}
+ isPending={deleteMutation.isPending}
+ />
+
+ )
+}
diff --git a/web/src/components/pages/page-detail-sheet.tsx b/web/src/components/pages/page-detail-sheet.tsx
new file mode 100644
index 0000000..d895b68
--- /dev/null
+++ b/web/src/components/pages/page-detail-sheet.tsx
@@ -0,0 +1,497 @@
+import { useEffect, useState } from 'react'
+import {
+ Check,
+ Copy,
+ ExternalLink,
+ GitFork,
+ History,
+ Lock,
+ Shield,
+ Unlock,
+} from 'lucide-react'
+import {
+ Sheet,
+ SheetContent,
+ SheetHeader,
+ SheetTitle,
+ SheetDescription,
+} from '@lumina/components/ui/sheet'
+import { Button } from '@lumina/components/ui/button'
+import { Input } from '@lumina/components/ui/input'
+import { Label } from '@lumina/components/ui/label'
+import {
+ useForkPage,
+ usePageVersions,
+ useSwitchActiveVersion,
+ useUpdateAccessPolicy,
+} from '#/hooks/usePages'
+import { formatDateTime } from '#/lib/format-date'
+import type { PageItem, PageVersionItem } from '#/lib/models/response/pages'
+import { toast } from 'sonner'
+
+interface PageDetailSheetProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ item: PageItem | null
+ initialMode?: 'view' | 'policy'
+}
+
+export function PageDetailSheet({
+ open,
+ onOpenChange,
+ item,
+ initialMode = 'view',
+}: PageDetailSheetProps) {
+ const [copiedSlug, setCopiedSlug] = useState(false)
+ const [currentItem, setCurrentItem] = useState(item)
+ const [isEditingPolicy, setIsEditingPolicy] = useState(
+ initialMode === 'policy',
+ )
+ const [accessMode, setAccessMode] = useState<'public' | 'password'>('public')
+ const [password, setPassword] = useState('')
+
+ const { data: versionsData, isLoading: versionsLoading } = usePageVersions(
+ currentItem?.id,
+ )
+ const versions: PageVersionItem[] = versionsData?.data?.items ?? []
+
+ const updatePolicy = useUpdateAccessPolicy()
+ const switchVersion = useSwitchActiveVersion()
+ const forkMutation = useForkPage()
+
+ useEffect(() => {
+ if (open && item) {
+ setCurrentItem(item)
+ setIsEditingPolicy(initialMode === 'policy')
+ setAccessMode(item.access_mode)
+ setPassword('')
+ setCopiedSlug(false)
+ }
+ }, [open, item, initialMode])
+
+ if (!currentItem) return null
+
+ const isPasswordProtected = currentItem.access_mode === 'password'
+ const isPublished = currentItem.status === 'published'
+ const publicUrl = `/pages/${currentItem.project_name || currentItem.project_id}/${currentItem.slug}`
+
+ const handleCopyUrl = () => {
+ const full = `${window.location.origin}${publicUrl}`
+ void navigator.clipboard.writeText(full)
+ setCopiedSlug(true)
+ setTimeout(() => setCopiedSlug(false), 2000)
+ toast.success('页面公开访问地址已复制')
+ }
+
+ const handleSavePolicy = () => {
+ if (accessMode === 'password' && !password.trim()) {
+ toast.error('表单校验未通过', {
+ description: '启用密码门时必须输入有效口令',
+ })
+ return
+ }
+ updatePolicy.mutate(
+ {
+ id: currentItem.id,
+ access_mode: accessMode,
+ password: accessMode === 'password' ? password : undefined,
+ },
+ {
+ onSuccess: () => {
+ setIsEditingPolicy(false)
+ setPassword('')
+ setCurrentItem((prev) =>
+ prev ? { ...prev, access_mode: accessMode } : null,
+ )
+ },
+ },
+ )
+ }
+
+ const handleSwitchVersion = (versionId: string) => {
+ switchVersion.mutate(
+ {
+ id: currentItem.id,
+ versionId,
+ },
+ {
+ onSuccess: () => {
+ setCurrentItem((prev) =>
+ prev ? { ...prev, latest_version_id: versionId } : null,
+ )
+ },
+ },
+ )
+ }
+
+ const handleFork = (versionId?: string) => {
+ forkMutation.mutate(
+ { id: currentItem.id, versionId },
+ {
+ onSuccess: (res) => {
+ toast.success('已从该快照派生新 Preview 沙盒会话')
+ const d = res.data
+ const url =
+ d?.preview_url ||
+ (d?.session.hash ? `/preview/${d.session.hash}/index.html` : null)
+ if (url) {
+ window.open(url, '_blank')
+ }
+ },
+ },
+ )
+ }
+
+ return (
+
+
+
+
+
+
+
+ {isEditingPolicy ? '配置访问策略' : '即时页面全景资产'}
+
+
+
+ {isEditingPolicy ? 'POLICY' : 'PAGE'}
+
+
+
+ {isEditingPolicy
+ ? '配置不可变页面快照的对外访问权限与密码门保护'
+ : '管理生效版本指针、访问策略、查看快照版本时间线与 Fork 派生'}
+
+
+
+
+ {isEditingPolicy ? (
+ /* ── 策略编辑模式 ── */
+
+
+
+ 密码门访问策略说明:
+
+
+ 密码门策略通过安全 HMAC Cookie
+ 签发会话凭据。启用密码保护后,外部访客需在门锁页输入口令方可访问。
+
+
+
+
+
+
+
setAccessMode('public')}
+ className={`cursor-pointer border p-3 flex flex-col gap-1 transition-colors ${
+ accessMode === 'public'
+ ? 'border-lagoon bg-foam shadow-sm'
+ : 'border-line bg-surface hover:bg-sand/60'
+ }`}
+ >
+
+
+ 公开直出
+
+
+ 任何访客均可自由直接访问
+
+
+
+
setAccessMode('password')}
+ className={`cursor-pointer border p-3 flex flex-col gap-1 transition-colors ${
+ accessMode === 'password'
+ ? 'border-lagoon bg-foam shadow-sm'
+ : 'border-line bg-surface hover:bg-sand/60'
+ }`}
+ >
+
+
+ 密码保护门
+
+
+ 需输入指定访问口令解锁
+
+
+
+
+
+ {accessMode === 'password' && (
+
+
+
setPassword(e.target.value)}
+ placeholder="输入新的安全访问口令..."
+ className="bg-sand/40 text-xs font-mono"
+ />
+
+ 保存后立即生效并使此前签发的过期会话凭据失效。
+
+
+ )}
+
+ ) : (
+ /* ── 查看全景模式 ── */
+ <>
+ {/* Slug 标识与状态 */}
+
+
+
+ 页面 Slug 标识与状态
+
+
+
+ {isPublished ? '已上线' : '已归档'}
+
+
+ {isPasswordProtected ? '密码保护门' : '公开直出'}
+
+
+
+
+
+ {publicUrl}
+
+
+
+
+
+ {/* 归属项目与访问策略 */}
+
+
+
+ 所属项目
+
+
+ {currentItem.project_name || '未关联项目'}
+
+
+
+
+ 线上版本 ID
+
+
+ {currentItem.latest_version_id
+ ? currentItem.latest_version_id.slice(0, 12)
+ : '无版本'}
+
+
+
+
+ {/* 版本快照历史时间线 */}
+
+
+
+
+
+ 不可变版本快照历史 ({versions.length})
+
+
+
+ 支持一键切换生效指针
+
+
+
+ {versionsLoading ? (
+
+ 加载版本时间线中...
+
+ ) : versions.length === 0 ? (
+
+ 暂无历史版本快照
+
+ ) : (
+
+ {versions.map((ver) => {
+ const isCurrentActive =
+ currentItem.latest_version_id === ver.id
+ return (
+
+
+
+
+ {ver.version}
+
+ {isCurrentActive && (
+
+ CURRENT ACTIVE
+
+ )}
+
+
+ {formatDateTime(ver.created_at)}
+
+
+
+ {ver.changelog && (
+
+ {ver.changelog}
+
+ )}
+
+
+
+ {ver.file_count} 个静态文件
+
+
+
+ {!isCurrentActive && (
+
+ )}
+
+
+
+ )
+ })}
+
+ )}
+
+
+ {/* 审计时间 */}
+
+
+ 实体 ID
+
+ {currentItem.id}
+
+
+
+ 创建时间
+
+ {formatDateTime(currentItem.created_at)}
+
+
+
+ 更新时间
+
+ {formatDateTime(currentItem.updated_at)}
+
+
+
+ >
+ )}
+
+
+ {/* 底部操作工具栏 */}
+
+ {isEditingPolicy ? (
+ <>
+
+
+ >
+ ) : (
+ <>
+
+ {isPublished ? (
+
+ ) : (
+
+ )}
+ >
+ )}
+
+
+
+ )
+}
diff --git a/web/src/components/pin/pin-detail-sheet.tsx b/web/src/components/pin/pin-detail-sheet.tsx
new file mode 100644
index 0000000..ca7ea46
--- /dev/null
+++ b/web/src/components/pin/pin-detail-sheet.tsx
@@ -0,0 +1,374 @@
+import { useEffect, useState } from 'react'
+import { Check, Copy, Pencil } from 'lucide-react'
+import {
+ Sheet,
+ SheetContent,
+ SheetHeader,
+ SheetTitle,
+ SheetDescription,
+} from '@lumina/components/ui/sheet'
+import { Button } from '@lumina/components/ui/button'
+import { Label } from '@lumina/components/ui/label'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@lumina/components/ui/select'
+import { useUpdatePin } from '#/hooks/usePin'
+import { useProjectNameMap } from '#/hooks/useProject'
+import { formatDateTime } from '#/lib/format-date'
+import type { PinItem } from '#/lib/models/response/pin'
+import { toast } from 'sonner'
+
+interface PinDetailSheetProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ item: PinItem | null
+ initialMode?: 'view' | 'edit'
+}
+
+const CATEGORY_OPTIONS = [
+ { value: 'notice', label: '注意事项' },
+ { value: 'dependency', label: '依赖约束' },
+ { value: 'api_change', label: '接口变更' },
+ { value: 'other', label: '其他' },
+] as const
+
+const PRIORITY_OPTIONS = [
+ { value: 'high', label: '高优先级 (High)' },
+ { value: 'medium', label: '中优先级 (Medium)' },
+ { value: 'low', label: '低优先级 (Low)' },
+] as const
+
+const categoryLabels: Record = {
+ notice: '注意事项',
+ dependency: '依赖约束',
+ api_change: '接口变更',
+ other: '其他',
+}
+
+const priorityLabels: Record = {
+ high: '高',
+ medium: '中',
+ low: '低',
+}
+
+export function PinDetailSheet({
+ open,
+ onOpenChange,
+ item,
+ initialMode = 'view',
+}: PinDetailSheetProps) {
+ const [copiedId, setCopiedId] = useState(false)
+ const [currentPin, setCurrentPin] = useState(item)
+ const [isEditing, setIsEditing] = useState(initialMode === 'edit')
+
+ const [category, setCategory] = useState('notice')
+ const [priority, setPriority] = useState('medium')
+
+ const { names } = useProjectNameMap()
+ const updateMutation = useUpdatePin()
+
+ useEffect(() => {
+ if (open && item) {
+ setCurrentPin(item)
+ setIsEditing(initialMode === 'edit')
+ setCategory(item.category || 'notice')
+ setPriority(item.priority || 'medium')
+ setCopiedId(false)
+ }
+ }, [open, item, initialMode])
+
+ if (!currentPin) return null
+
+ const fromName =
+ names[currentPin.from_project_id] ?? currentPin.from_project_id
+ const toName = names[currentPin.to_project_id] ?? currentPin.to_project_id
+ const isPending = currentPin.status === 'pending'
+
+ const handleCopyId = () => {
+ void navigator.clipboard.writeText(currentPin.id)
+ setCopiedId(true)
+ setTimeout(() => setCopiedId(false), 2000)
+ toast.success('Pin 实体雪花 ID 已复制到剪贴板')
+ }
+
+ const handleSaveEdit = () => {
+ updateMutation.mutate(
+ {
+ id: currentPin.id,
+ data: {
+ category,
+ priority,
+ },
+ },
+ {
+ onSuccess: () => {
+ toast.success('Pin 约束属性已更新')
+ setIsEditing(false)
+ setCurrentPin((prev) =>
+ prev ? { ...prev, category, priority } : null,
+ )
+ },
+ onError: (err) => {
+ toast.error('更新失败', { description: err.message })
+ },
+ },
+ )
+ }
+
+ return (
+
+
+
+
+
+
+
+ {isEditing ? '调整约束属性' : 'Pin 约束全景详情'}
+
+
+
+ {isEditing ? 'EDITING' : 'VIEW'}
+
+
+
+ {isEditing
+ ? '就地修改该约束的分类属性与优先级评级'
+ : '查看跨项目依赖约束契约、投递流向与不可变约束正文'}
+
+
+
+
+ {isEditing ? (
+ /* ── 编辑模式 ── */
+
+
+
+ 约束不可变原则:
+
+ 约束标题与正文作为跨项目交付契约已归档上链,仅支持就地调整分发属性与优先级。
+
+
+
+
+
+
+
+
+
+
+
+
+ ) : (
+ /* ── 查看模式 ── */
+ <>
+ {/* 约束标题与状态 */}
+
+
+
+ 约束主题 (Title)
+
+
+ {isPending
+ ? '待下游消费 (PENDING)'
+ : '已消费完成 (CONSUMED)'}
+
+
+
+ {currentPin.title}
+
+
+
+ {/* 实体雪花 ID */}
+
+
+ 实体雪花 ID
+
+
+
+ {currentPin.id}
+
+
+
+
+
+ {/* 流向关系 */}
+
+
+ 依赖流向关系 (Stream)
+
+
+
+
+ 来源项目 (From)
+
+
+ {fromName}
+
+
+
➔
+
+
+ 目标项目 (To)
+
+ {toName}
+
+
+
+
+ {/* 属性胶囊 */}
+
+
+
+ 分类
+
+
+ {categoryLabels[currentPin.category] ||
+ currentPin.category ||
+ '其他'}
+
+
+
+
+ 优先级
+
+
+ {priorityLabels[currentPin.priority] ||
+ currentPin.priority ||
+ '未定义'}
+
+
+
+
+ {/* 约束正文完整展示 */}
+
+
+ 约束正文内容 (Content)
+
+
+ {currentPin.content || '(无详细约束正文)'}
+
+
+
+ {/* 时间信息 */}
+
+
+ 创建时间
+
+ {formatDateTime(currentPin.created_at)}
+
+
+
+ 更新时间
+
+ {formatDateTime(currentPin.updated_at)}
+
+
+
+ >
+ )}
+
+
+ {/* 底部操作栏 */}
+
+ {isEditing ? (
+ <>
+
+
+ >
+ ) : (
+
+ )}
+
+
+
+ )
+}
diff --git a/web/src/components/preview/session-detail-drawer.tsx b/web/src/components/preview/session-detail-drawer.tsx
index 74c1f9e..b107226 100644
--- a/web/src/components/preview/session-detail-drawer.tsx
+++ b/web/src/components/preview/session-detail-drawer.tsx
@@ -8,8 +8,12 @@ import {
SheetTitle,
} from '@lumina/components/ui/sheet'
import { Button } from '@lumina/components/ui/button'
+import { ConfirmDeleteDialog } from '#/components/confirm-delete-dialog'
import { usePreviewWebSocket } from '#/hooks/usePreviewWebSocket'
-import { useDeletePreviewFile } from '#/hooks/usePreviewAdmin'
+import {
+ useDeletePreviewFile,
+ usePreviewSessionDetail,
+} from '#/hooks/usePreviewAdmin'
import { formatDateTime } from '#/lib/format-date'
import type {
PreviewFileItem,
@@ -35,148 +39,204 @@ export function PreviewSessionDetailDrawer({
const hash = session?.hash ?? null
const [detail, setDetail] = useState(null)
+ const [fileToDelete, setFileToDelete] = useState(null)
+
+ // 1. REST API 作为静态快照基础兜底
+ const {
+ data: restDetailData,
+ isLoading: restLoading,
+ error: restError,
+ refetch: restRefetch,
+ } = usePreviewSessionDetail(session ? hash : null)
+
+ // 会话切换或抽屉关闭时首先重置详情缓存,避免展示上一会话的残留数据
+ useEffect(() => {
+ setDetail(null)
+ setFileToDelete(null)
+ }, [hash])
- // WS 实时同步:详情与文件列表由 preview_sync 消息驱动(连接快照 / 文件变更推送)
+ useEffect(() => {
+ const payload = restDetailData?.data
+ if (payload && payload.session.hash === hash) {
+ setDetail((prev) => {
+ // 如果 WebSocket 已经推过当前 hash 的最新快照,则保留 WS 数据
+ if (prev && prev.session.hash === hash) return prev
+ return {
+ session: payload.session,
+ files: payload.files,
+ }
+ })
+ }
+ }, [restDetailData, hash])
+
+ // 2. WS 实时同步:详情与文件列表由 preview_sync 消息驱动(连接快照 / 文件变更推送)
const { status } = usePreviewWebSocket(hash, {
onSync: (data) => {
if (!data?.session) return
const syncData = data as PreviewSyncData
- setDetail(syncData)
+ if (syncData.session.hash === hash) {
+ setDetail(syncData)
+ }
},
})
- // 会话切换或抽屉关闭时重置详情缓存,避免展示上一会话的残留数据
- useEffect(() => {
- setDetail(null)
- }, [hash])
-
- // WS 状态驱动加载态:idle / connecting 视为加载中
- const isLoading = status === 'idle' || status === 'connecting'
+ // 🌟 R-03 严格校验 detail 必须完全属于当前展开会话的 hash,彻底消除切换会话时的瞬时旧数据残留
+ const currentDetail = detail?.session.hash === hash ? detail : null
+ const isLoading = !currentDetail && restLoading
return (
- {
- if (!open) onClose()
- }}
- >
-
+ {
+ if (!open) onClose()
+ }}
>
-
- 预览会话详情
- 查看该会话的前端文件并管理
-
+
+
+ 预览会话详情
+ 查看该会话的前端文件并管理
+
-
- {status === 'rejected' ? (
-
- 会话不存在
-
- ) : isLoading || !detail ? (
-
- 加载中…
-
- ) : (
-
-
-
- 标题
-
- {detail.session.title}
-
-
-
- 状态
-
- {detail.session.status === 'active' ? '活跃' : '已过期'}
-
-
-
- 过期时间
-
- {formatDateTime(detail.session.expires_at)}
-
-
-
- 创建时间
-
- {formatDateTime(detail.session.created_at)}
-
-
+
+ {status === 'rejected' ? (
+
+ 会话不存在
+ ) : restError && !currentDetail ? (
+
+
+ 获取预览会话详情失败:{restError.message}
+
+
+
+ ) : isLoading || !currentDetail ? (
+
+ 加载中…
+
+ ) : (
+
+
+
+ 标题
+
+ {currentDetail.session.title || '(未命名)'}
+
+
+
+ Hash
+
+ {currentDetail.session.hash}
+
+
+
+ 状态
+
+ {currentDetail.session.status}
+
+
+
+ 过期时间
+
+ {formatDateTime(currentDetail.session.expires_at)}
+
+
+
- {(() => {
- const isExpired =
- detail.session.status !== 'active' ||
- (detail.session.expires_at
- ? new Date(detail.session.expires_at).getTime() <=
- Date.now()
- : false)
- return (
-
- )
- })()}
+ {(() => {
+ const isExpired =
+ new Date(currentDetail.session.expires_at).getTime() <=
+ Date.now()
+ return (
+
+ )
+ })()}
-
-
- 文件列表 ({detail.files.length})
-
- {detail.files.length === 0 ? (
-
- 暂无文件
-
- ) : (
-
-
- )}
-
-
-
+ )}
+
+
+
+
+ {/* 🌟 Q-08 文件删除确认弹窗保护 */}
+
{
+ if (!open) setFileToDelete(null)
+ }}
+ title="确认删除预览文件"
+ description={`确定要从该会话中删除文件「${fileToDelete?.filename ?? ''}」吗?此操作将立即生效并同步刷新工作台。`}
+ onConfirm={() => {
+ if (!fileToDelete) return
+ deleteFileMutation.mutate(fileToDelete.id, {
+ onSuccess: () => setFileToDelete(null),
+ })
+ }}
+ isPending={deleteFileMutation.isPending}
+ />
+ >
)
}
diff --git a/web/src/components/project/create-dialog.tsx b/web/src/components/project/create-dialog.tsx
index 39473a4..83b36b3 100644
--- a/web/src/components/project/create-dialog.tsx
+++ b/web/src/components/project/create-dialog.tsx
@@ -1,4 +1,5 @@
import { useState } from 'react'
+import { ChevronDown, ChevronRight, FolderCode, Plus, X } from 'lucide-react'
import { Button } from '@lumina/components/ui/button'
import {
Dialog,
@@ -21,23 +22,34 @@ interface CreateDialogProps {
export function CreateDialog({ open, onOpenChange }: CreateDialogProps) {
const [name, setName] = useState('')
const [aliasName, setAliasName] = useState('')
- const [matchPathInput, setMatchPathInput] = useState('')
const [description, setDescription] = useState('')
+ const [paths, setPaths] = useState([])
+ const [newPathInput, setNewPathInput] = useState('')
+ const [showAdvanced, setShowAdvanced] = useState(false)
const { current } = useCurrentWorkspace()
const createMutation = useCreateProject()
+ const handleAddPath = () => {
+ const trimmed = newPathInput.trim()
+ if (!trimmed) return
+ if (!paths.includes(trimmed)) {
+ setPaths([...paths, trimmed])
+ }
+ setNewPathInput('')
+ }
+
+ const handleRemovePath = (index: number) => {
+ setPaths(paths.filter((_, i) => i !== index))
+ }
+
const handleSubmit = () => {
if (!name.trim() || !current?.id) return
- const matchPaths = matchPathInput
- .split(',')
- .map((s) => s.trim())
- .filter(Boolean)
createMutation.mutate(
{
name: name.trim(),
alias_name: aliasName.trim() || undefined,
- match_path: matchPaths.length > 0 ? matchPaths : undefined,
+ match_path: paths.length > 0 ? paths : undefined,
description: description.trim() || undefined,
workspace_id: current.id,
},
@@ -50,71 +62,180 @@ export function CreateDialog({ open, onOpenChange }: CreateDialogProps) {
const handleClose = () => {
setName('')
setAliasName('')
- setMatchPathInput('')
setDescription('')
+ setPaths([])
+ setNewPathInput('')
+ setShowAdvanced(false)
onOpenChange(false)
}
return (