generated from kevisual/vite-react-template
feat: 添加制品中心页面及相关组件,支持创建和编辑制品功能;更新README文档
This commit is contained in:
47
README.md
47
README.md
@@ -1,16 +1,57 @@
|
||||
# cnb center
|
||||
|
||||
一个应用工作台
|
||||
一个应用工作台。
|
||||
|
||||
cnb center 是对 cnb 的部分操作的可视化管理,是对 cnb 功能的补充,核心使用还是需要去使用 cnb。
|
||||
|
||||
## 功能
|
||||
|
||||
1. 对话管理
|
||||
2. 公共资源
|
||||
3. 仓库管理
|
||||
4. 云开发(cloud-env)
|
||||
4. 云端环境(cloud-env)
|
||||
5. 我的应用
|
||||
- NPC
|
||||
- Agent 管理
|
||||
- 任务管理
|
||||
6. 其他
|
||||
- 制品中心
|
||||
- 配置
|
||||
- 历史记录
|
||||
- 配置
|
||||
|
||||
|
||||
## 对话管理功能
|
||||
|
||||
列出对应的知识库,知识库是可以动态添加的, 点击后对话应用会加载对应的知识库进行对话,对话过程可以执行实际的任务, 例如调用接口,执行命令等。
|
||||
|
||||
配置routes,转为指令
|
||||
|
||||
## 公共资源
|
||||
|
||||
分享的快速启动的程序应用宝库,技能,对话,npc,agent 等等。
|
||||
|
||||
## 仓库管理
|
||||
|
||||
基本的仓库管理,添加和编辑仓库,启动云段环境。
|
||||
|
||||
## 云端环境(cloud-env)
|
||||
|
||||
单独的云端环境管理,提供云端环境的停止,查看。
|
||||
|
||||
## 我的应用
|
||||
|
||||
任务管理
|
||||
|
||||
## 其他
|
||||
|
||||
### 制品中心
|
||||
|
||||
添加对应的仓库,查看对应的制品。对 docker 镜像支持快速配置同步到制品中心。
|
||||
|
||||
### 配置
|
||||
|
||||
因为调用 cnb 需要一些配置,例如 token和 cookie等,提供一个界面来配置这些内容,方便使用。
|
||||
|
||||
### 历史记录
|
||||
|
||||
对话历史记录,任务执行历史记录等。
|
||||
3
plan.md
Normal file
3
plan.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# cnb-center
|
||||
|
||||
是对 cnb 的部分操作的可视化管理,是对 cnb 功能的补充,核心使用还是需要去使用 cnb。
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useCloudEnvStore } from './store'
|
||||
import { useRepoStore } from '../repos/store'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
@@ -25,11 +26,14 @@ import {
|
||||
Wind,
|
||||
Plane,
|
||||
Rocket,
|
||||
ExternalLink
|
||||
ExternalLink,
|
||||
Info
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { WorkspaceInfo } from '@kevisual/cnb'
|
||||
import clsx from 'clsx'
|
||||
import { WorkspaceDetailDialog } from '../repos/modules/WorkspaceDetailDialog'
|
||||
import { useShallow } from 'zustand/shallow'
|
||||
|
||||
type WorkspaceOpen = {
|
||||
url?: string
|
||||
@@ -61,13 +65,7 @@ const linkItems: LinkItem[] = [
|
||||
{ key: 'jumpUrl', label: 'Jump', icon: <ExternalLink className="w-5 h-5" />, getUrl: (d) => d.jumpUrl },
|
||||
{ key: 'webide', label: 'Web IDE', icon: <Code2 className="w-5 h-5" />, getUrl: (d) => d.webide },
|
||||
{ key: 'vscode', label: 'VS Code', icon: <Code2 className="w-5 h-5" />, getUrl: (d) => d.vscode },
|
||||
{ key: 'cursor', label: 'Cursor', icon: <MousePointer2 className="w-5 h-5" />, getUrl: (d) => d.cursor },
|
||||
{ key: 'trae-cn', label: 'Trae', icon: <Rocket className="w-5 h-5" />, getUrl: (d) => d['trae-cn'] },
|
||||
{ key: 'windsurf', label: 'Windsurf', icon: <Wind className="w-5 h-5" />, getUrl: (d) => d.windsurf },
|
||||
{ key: 'antigravity', label: 'Antigravity', icon: <Plane className="w-5 h-5" />, getUrl: (d) => d.antigravity },
|
||||
{ key: 'ssh', label: 'SSH', icon: <Lock className="w-5 h-5" />, getUrl: (d) => d.ssh },
|
||||
{ key: 'remoteSsh', label: 'Remote SSH', icon: <Radio className="w-5 h-5" />, getUrl: (d) => d.remoteSsh },
|
||||
{ key: 'codebuddycn', label: 'CodeBuddy', icon: <Zap className="w-5 h-5" />, getUrl: (d) => d.codebuddycn },
|
||||
]
|
||||
|
||||
function LinkCard({ item, workspaceData }: { item: LinkItem; workspaceData: WorkspaceOpen }) {
|
||||
@@ -127,6 +125,10 @@ function WorkspaceCard({ workspace, onStop }: { workspace: WorkspaceInfo; onStop
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [workspaceData, setWorkspaceData] = useState<WorkspaceOpen | null>(null)
|
||||
const getWorkspaceDetail = useCloudEnvStore((state) => state.getWorkspaceDetail)
|
||||
const repoStore = useRepoStore(useShallow((state) => ({
|
||||
setShowWorkspaceDialog: state.setShowWorkspaceDialog,
|
||||
setWorkspaceTab: state.setWorkspaceTab,
|
||||
})))
|
||||
|
||||
useEffect(() => {
|
||||
const fetchDetail = async () => {
|
||||
@@ -138,6 +140,16 @@ function WorkspaceCard({ workspace, onStop }: { workspace: WorkspaceInfo; onStop
|
||||
fetchDetail()
|
||||
}, [workspace, getWorkspaceDetail])
|
||||
|
||||
const handleShowDetail = async () => {
|
||||
const data = await getWorkspaceDetail(workspace)
|
||||
useRepoStore.setState({
|
||||
selectWorkspace: workspace,
|
||||
workspaceLink: data || {},
|
||||
showWorkspaceDialog: true,
|
||||
workspaceTab: 'dev'
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="p-4 space-y-4 border border-neutral-200 bg-white">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -153,15 +165,24 @@ function WorkspaceCard({ workspace, onStop }: { workspace: WorkspaceInfo; onStop
|
||||
<Badge variant="outline" className="text-xs">{workspace.branch}</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onStop(workspace)}
|
||||
className="text-red-600 border-red-200 hover:bg-red-600 hover:text-white"
|
||||
>
|
||||
<Square className="w-4 h-4 mr-1" />
|
||||
停止
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleShowDetail}
|
||||
>
|
||||
<Info className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onStop(workspace)}
|
||||
className="text-red-600 border-red-200 hover:bg-red-600 hover:text-white"
|
||||
>
|
||||
<Square className="w-4 h-4 mr-1" />
|
||||
停止
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
@@ -171,7 +192,7 @@ function WorkspaceCard({ workspace, onStop }: { workspace: WorkspaceInfo; onStop
|
||||
))}
|
||||
</div>
|
||||
) : workspaceData ? (
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{linkItems.map((item) => (
|
||||
<LinkCard key={item.key} item={item} workspaceData={workspaceData} />
|
||||
))}
|
||||
@@ -234,6 +255,7 @@ export default function CloudEnvPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<WorkspaceDetailDialog />
|
||||
</SidebarLayout>
|
||||
)
|
||||
}
|
||||
}
|
||||
105
src/pages/cnb-packages/components/CreateDialog.tsx
Normal file
105
src/pages/cnb-packages/components/CreateDialog.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { TagInput } from "./TagInput";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
export function CreateDialog({ open, onClose, onSubmit }: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (data: { title: string, tags?: string[], link?: string, summary?: string, description?: string }) => Promise<void>;
|
||||
}) {
|
||||
const [title, setTitle] = useState('');
|
||||
const [tags, setTags] = useState<string[]>([]);
|
||||
const [link, setLink] = useState('');
|
||||
const [summary, setSummary] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!title.trim()) {
|
||||
alert('请输入标题');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onSubmit({
|
||||
title: title.trim(),
|
||||
tags,
|
||||
link: link.trim(),
|
||||
summary: summary.trim(),
|
||||
description: description.trim(),
|
||||
});
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-lg!">
|
||||
<DialogHeader>
|
||||
<DialogTitle>创建制品</DialogTitle>
|
||||
<DialogDescription>填写以下信息创建一个新的制品</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="create-title">标题</Label>
|
||||
<Input
|
||||
id="create-title"
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
placeholder="请输入标题"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>标签</Label>
|
||||
<TagInput value={tags} onChange={setTags} placeholder="输入标签后按回车添加" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="create-link">链接</Label>
|
||||
<Input
|
||||
id="create-link"
|
||||
value={link}
|
||||
onChange={e => setLink(e.target.value)}
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="create-summary">摘要</Label>
|
||||
<Input
|
||||
id="create-summary"
|
||||
value={summary}
|
||||
onChange={e => setSummary(e.target.value)}
|
||||
placeholder="简要描述"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="create-description">描述</Label>
|
||||
<Textarea
|
||||
id="create-description"
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
placeholder="详细描述..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} disabled={submitting}>取消</Button>
|
||||
<Button variant="outline" onClick={handleSubmit} disabled={submitting}>
|
||||
{submitting ? '创建中...' : '创建'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
117
src/pages/cnb-packages/components/EditDialog.tsx
Normal file
117
src/pages/cnb-packages/components/EditDialog.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { TagInput } from "./TagInput";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
export function EditDialog({ open, item, onClose, onSubmit }: {
|
||||
open: boolean;
|
||||
item: any;
|
||||
onClose: () => void;
|
||||
onSubmit: (id: string, data: { title?: string, tags?: string[], link?: string, summary?: string, description?: string }) => Promise<void>;
|
||||
}) {
|
||||
const [title, setTitle] = useState('');
|
||||
const [tags, setTags] = useState<string[]>([]);
|
||||
const [link, setLink] = useState('');
|
||||
const [summary, setSummary] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && item) {
|
||||
setTitle(item.title || '');
|
||||
setTags(item.tags || []);
|
||||
setLink(item.link || '');
|
||||
setSummary(item.summary || '');
|
||||
setDescription(item.description || '');
|
||||
}
|
||||
}, [open, item]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!title.trim()) {
|
||||
alert('请输入标题');
|
||||
return;
|
||||
}
|
||||
if (!item?.id) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onSubmit(item.id, {
|
||||
title: title.trim(),
|
||||
tags,
|
||||
link: link.trim(),
|
||||
summary: summary.trim(),
|
||||
description: description.trim(),
|
||||
});
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-lg!">
|
||||
<DialogHeader>
|
||||
<DialogTitle>编辑制品</DialogTitle>
|
||||
<DialogDescription>修改制品信息</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-title">标题</Label>
|
||||
<Input
|
||||
id="edit-title"
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
placeholder="请输入标题"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>标签</Label>
|
||||
<TagInput value={tags} onChange={setTags} placeholder="输入标签后按回车添加" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-link">链接</Label>
|
||||
<Input
|
||||
id="edit-link"
|
||||
value={link}
|
||||
onChange={e => setLink(e.target.value)}
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-summary">摘要</Label>
|
||||
<Input
|
||||
id="edit-summary"
|
||||
value={summary}
|
||||
onChange={e => setSummary(e.target.value)}
|
||||
placeholder="简要描述"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-description">描述</Label>
|
||||
<Textarea
|
||||
id="edit-description"
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
placeholder="详细描述..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} disabled={submitting}>取消</Button>
|
||||
<Button variant="outline" onClick={handleSubmit} disabled={submitting}>
|
||||
{submitting ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
53
src/pages/cnb-packages/components/TagInput.tsx
Normal file
53
src/pages/cnb-packages/components/TagInput.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { useState } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { XIcon } from "lucide-react";
|
||||
|
||||
export function TagInput({ value, onChange, placeholder }: {
|
||||
value: string[];
|
||||
onChange: (tags: string[]) => void;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const [input, setInput] = useState('');
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && input.trim()) {
|
||||
e.preventDefault();
|
||||
if (!value.includes(input.trim())) {
|
||||
onChange([...value, input.trim()]);
|
||||
}
|
||||
setInput('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = (tag: string) => {
|
||||
onChange(value.filter(t => t !== tag));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder || '输入标签后按回车添加'}
|
||||
/>
|
||||
{value.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{value.map((tag) => (
|
||||
<Badge key={tag} variant="outline" className="gap-1">
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemove(tag)}
|
||||
className="hover:text-destructive"
|
||||
>
|
||||
<XIcon className="size-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
3
src/pages/cnb-packages/components/index.ts
Normal file
3
src/pages/cnb-packages/components/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { CreateDialog } from "./CreateDialog";
|
||||
export { EditDialog } from "./EditDialog";
|
||||
export { TagInput } from "./TagInput";
|
||||
153
src/pages/cnb-packages/page.tsx
Normal file
153
src/pages/cnb-packages/page.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import { usePackageStore, type PackageState } from "./store";
|
||||
import { useShallow } from "zustand/shallow";
|
||||
import { useEffect, useState } from "react";
|
||||
import dayjs from "dayjs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { CreateDialog, EditDialog } from "./components";
|
||||
import { SearchIcon, RefreshCwIcon, PlusIcon, PencilIcon, TrashIcon } from "lucide-react";
|
||||
import { SidebarLayout } from "@/pages/sidebar/components";
|
||||
|
||||
export const App = () => {
|
||||
const packageStore = usePackageStore(useShallow((state: PackageState) => {
|
||||
return {
|
||||
list: state.list,
|
||||
loading: state.loading,
|
||||
getList: state.getList,
|
||||
createItem: state.createItem,
|
||||
updateItem: state.updateItem,
|
||||
deleteItem: state.deleteItem,
|
||||
showCreateDialog: state.showCreateDialog,
|
||||
setShowCreateDialog: state.setShowCreateDialog,
|
||||
showEditDialog: state.showEditDialog,
|
||||
setShowEditDialog: state.setShowEditDialog,
|
||||
editingItem: state.editingItem,
|
||||
setEditingItem: state.setEditingItem,
|
||||
}
|
||||
}));
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
packageStore.getList({ search });
|
||||
}, [search]);
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (confirm('确定要删除这个制品吗?')) {
|
||||
await packageStore.deleteItem(id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
packageStore.getList({ search });
|
||||
};
|
||||
|
||||
const handleEdit = (item: any) => {
|
||||
packageStore.setEditingItem(item);
|
||||
packageStore.setShowEditDialog(true);
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
packageStore.setShowCreateDialog(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<SidebarLayout>
|
||||
<div className="p-5">
|
||||
<div className="flex justify-between items-center mb-5">
|
||||
<h1 className="text-2xl font-semibold">制品中心</h1>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative">
|
||||
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="搜索制品..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-8 w-48"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleRefresh}>
|
||||
<RefreshCwIcon className="size-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleCreate}>
|
||||
<PlusIcon className="size-4" />
|
||||
创建制品
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{packageStore.loading ? (
|
||||
<div className="text-center py-10 text-muted-foreground">加载中...</div>
|
||||
) : packageStore.list.length === 0 ? (
|
||||
<div className="text-center py-10 text-muted-foreground">暂无制品数据</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{packageStore.list.map((item) => (
|
||||
<Card key={item.id}>
|
||||
<CardHeader>
|
||||
<CardTitle>{item.title || '未命名'}</CardTitle>
|
||||
<CardDescription className="text-xs">ID: {item.id}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{item.tags && item.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{item.tags.map((tag, index) => (
|
||||
<Badge key={index} variant="outline">{tag}</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{item.summary && (
|
||||
<p className="text-sm text-muted-foreground">{item.summary}</p>
|
||||
)}
|
||||
{item.description && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">{item.description}</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
创建时间: {item.createdAt ? dayjs(item.createdAt).format('YYYY-MM-DD HH:mm:ss') : '-'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
更新时间: {item.updatedAt ? dayjs(item.updatedAt).format('YYYY-MM-DD HH:mm:ss') : '-'}
|
||||
</p>
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button variant="outline" size="sm" onClick={() => handleEdit(item)}>
|
||||
<PencilIcon className="size-3.5" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => handleDelete(item.id)}>
|
||||
<TrashIcon className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CreateDialog
|
||||
open={packageStore.showCreateDialog}
|
||||
onClose={() => packageStore.setShowCreateDialog(false)}
|
||||
onSubmit={packageStore.createItem}
|
||||
/>
|
||||
|
||||
<EditDialog
|
||||
open={packageStore.showEditDialog}
|
||||
item={packageStore.editingItem}
|
||||
onClose={() => {
|
||||
packageStore.setShowEditDialog(false);
|
||||
packageStore.setEditingItem(null);
|
||||
}}
|
||||
onSubmit={packageStore.updateItem}
|
||||
/>
|
||||
</div>
|
||||
</SidebarLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
154
src/pages/cnb-packages/store/index.ts
Normal file
154
src/pages/cnb-packages/store/index.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { create } from 'zustand';
|
||||
import { queryApi } from '@/modules/mark-api';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
type PackageItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
tags?: string[];
|
||||
link?: string;
|
||||
summary?: string;
|
||||
description?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
type PackageState = {
|
||||
edit: boolean;
|
||||
setEdit: (edit: boolean) => void;
|
||||
list: PackageItem[];
|
||||
loading: boolean;
|
||||
setLoading: (loading: boolean) => void;
|
||||
// Dialog states
|
||||
showCreateDialog: boolean;
|
||||
setShowCreateDialog: (show: boolean) => void;
|
||||
showEditDialog: boolean;
|
||||
setShowEditDialog: (show: boolean) => void;
|
||||
editingItem: PackageItem | null;
|
||||
setEditingItem: (item: PackageItem | null) => void;
|
||||
// Data operations
|
||||
getList: (params?: { search?: string, page?: number, pageSize?: number }) => Promise<void>;
|
||||
createItem: (data: { title: string, tags?: string[], link?: string, summary?: string, description?: string }) => Promise<void>;
|
||||
updateItem: (id: string, data: { title?: string, tags?: string[], link?: string, summary?: string, description?: string }) => Promise<void>;
|
||||
deleteItem: (id: string) => Promise<void>;
|
||||
getItem: (id: string) => Promise<PackageItem | null>;
|
||||
}
|
||||
|
||||
export type { PackageState, PackageItem };
|
||||
|
||||
export const usePackageStore = create<PackageState>((set, get) => ({
|
||||
edit: false,
|
||||
setEdit: (edit) => set({ edit }),
|
||||
list: [],
|
||||
loading: false,
|
||||
setLoading: (loading) => set({ loading }),
|
||||
showCreateDialog: false,
|
||||
setShowCreateDialog: (show) => set({ showCreateDialog: show }),
|
||||
showEditDialog: false,
|
||||
setShowEditDialog: (show) => set({ showEditDialog: show }),
|
||||
editingItem: null,
|
||||
setEditingItem: (item) => set({ editingItem: item }),
|
||||
|
||||
getList: async (params = {}) => {
|
||||
const { page = 1, pageSize = 20, search } = params;
|
||||
set({ loading: true });
|
||||
try {
|
||||
const res = await queryApi.mark.list({
|
||||
markType: 'cnb-packages',
|
||||
page,
|
||||
pageSize,
|
||||
search,
|
||||
sort: 'DESC'
|
||||
});
|
||||
if (res.code === 200) {
|
||||
set({ list: res.data?.list || [] });
|
||||
} else {
|
||||
toast.error(res.message || '获取列表失败');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取制品列表失败', e);
|
||||
toast.error('获取列表失败');
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
createItem: async (data) => {
|
||||
try {
|
||||
const res = await queryApi.mark.create({
|
||||
title: data.title,
|
||||
markType: 'cnb-packages',
|
||||
tags: data.tags || [],
|
||||
link: data.link || '',
|
||||
summary: data.summary || '',
|
||||
description: data.description || ''
|
||||
});
|
||||
if (res.code === 200) {
|
||||
toast.success('创建成功');
|
||||
get().getList();
|
||||
set({ showCreateDialog: false });
|
||||
} else {
|
||||
toast.error(res.message || '创建失败');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('创建失败', e);
|
||||
toast.error('创建失败');
|
||||
}
|
||||
},
|
||||
|
||||
updateItem: async (id, data) => {
|
||||
try {
|
||||
const res = await queryApi.mark.update({
|
||||
data: {
|
||||
// @ts-ignore
|
||||
id,
|
||||
title: data.title || '',
|
||||
tags: data.tags || [],
|
||||
link: data.link || '',
|
||||
summary: data.summary || '',
|
||||
description: data.description || ''
|
||||
}
|
||||
});
|
||||
if (res.code === 200) {
|
||||
toast.success('更新成功');
|
||||
get().getList();
|
||||
set({ showEditDialog: false, editingItem: null });
|
||||
} else {
|
||||
toast.error(res.message || '更新失败');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('更新失败', e);
|
||||
toast.error('更新失败');
|
||||
}
|
||||
},
|
||||
|
||||
deleteItem: async (id) => {
|
||||
try {
|
||||
const res = await queryApi.mark.delete({ id });
|
||||
if (res.code === 200) {
|
||||
toast.success('删除成功');
|
||||
get().getList();
|
||||
} else {
|
||||
toast.error(res.message || '删除失败');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('删除失败', e);
|
||||
toast.error('删除失败');
|
||||
}
|
||||
},
|
||||
|
||||
getItem: async (id) => {
|
||||
try {
|
||||
const res = await queryApi.mark.get({ id });
|
||||
if (res.code === 200) {
|
||||
return res.data;
|
||||
} else {
|
||||
toast.error(res.message || '获取详情失败');
|
||||
return null;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取详情失败', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}));
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FolderKanban, LayoutDashboard, Settings, PlayCircle, Cloud } from 'lucide-react'
|
||||
import { FolderKanban, LayoutDashboard, Settings, PlayCircle, Cloud, Package } from 'lucide-react'
|
||||
import { Sidebar, type NavItem } from '@/components/a/Sidebar'
|
||||
import { Logo } from './CNBBlackLogo.tsx'
|
||||
|
||||
@@ -19,15 +19,22 @@ const navItems: NavItem[] = [
|
||||
icon: <LayoutDashboard className="w-5 h-5" />,
|
||||
},
|
||||
{
|
||||
title: '应用配置',
|
||||
path: '/config',
|
||||
icon: <Settings className="w-5 h-5" />,
|
||||
title: '制品中心',
|
||||
path: '/cnb-packages',
|
||||
icon: <Package className="w-5 h-5" />,
|
||||
},
|
||||
|
||||
{
|
||||
title: '其他',
|
||||
path: '/demo',
|
||||
path: '/other',
|
||||
icon: <PlayCircle className="w-5 h-5" />,
|
||||
isDeveloping: true,
|
||||
children: [
|
||||
{
|
||||
title: '应用配置',
|
||||
path: '/config',
|
||||
icon: <Settings className="w-5 h-5" />,
|
||||
},
|
||||
]
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -102,8 +102,9 @@ export const useWorkspaceStore = create<WorkspaceState>((set, get) => ({
|
||||
updateItem: async (id, data) => {
|
||||
try {
|
||||
const res = await queryApi.mark.update({
|
||||
id,
|
||||
data: {
|
||||
// @ts-ignore
|
||||
id,
|
||||
title: data.title || '',
|
||||
tags: data.tags || [],
|
||||
link: data.link || '',
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as WorkspacesIndexRouteImport } from './routes/workspaces/index'
|
||||
import { Route as RepoIndexRouteImport } from './routes/repo/index'
|
||||
import { Route as ConfigIndexRouteImport } from './routes/config/index'
|
||||
import { Route as CnbPackagesIndexRouteImport } from './routes/cnb-packages/index'
|
||||
import { Route as CloudEnvIndexRouteImport } from './routes/cloud-env/index'
|
||||
import { Route as ConfigGiteaRouteImport } from './routes/config/gitea'
|
||||
|
||||
@@ -48,6 +49,11 @@ const ConfigIndexRoute = ConfigIndexRouteImport.update({
|
||||
path: '/config/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const CnbPackagesIndexRoute = CnbPackagesIndexRouteImport.update({
|
||||
id: '/cnb-packages/',
|
||||
path: '/cnb-packages/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const CloudEnvIndexRoute = CloudEnvIndexRouteImport.update({
|
||||
id: '/cloud-env/',
|
||||
path: '/cloud-env/',
|
||||
@@ -65,6 +71,7 @@ export interface FileRoutesByFullPath {
|
||||
'/login': typeof LoginRoute
|
||||
'/config/gitea': typeof ConfigGiteaRoute
|
||||
'/cloud-env/': typeof CloudEnvIndexRoute
|
||||
'/cnb-packages/': typeof CnbPackagesIndexRoute
|
||||
'/config/': typeof ConfigIndexRoute
|
||||
'/repo/': typeof RepoIndexRoute
|
||||
'/workspaces/': typeof WorkspacesIndexRoute
|
||||
@@ -75,6 +82,7 @@ export interface FileRoutesByTo {
|
||||
'/login': typeof LoginRoute
|
||||
'/config/gitea': typeof ConfigGiteaRoute
|
||||
'/cloud-env': typeof CloudEnvIndexRoute
|
||||
'/cnb-packages': typeof CnbPackagesIndexRoute
|
||||
'/config': typeof ConfigIndexRoute
|
||||
'/repo': typeof RepoIndexRoute
|
||||
'/workspaces': typeof WorkspacesIndexRoute
|
||||
@@ -86,6 +94,7 @@ export interface FileRoutesById {
|
||||
'/login': typeof LoginRoute
|
||||
'/config/gitea': typeof ConfigGiteaRoute
|
||||
'/cloud-env/': typeof CloudEnvIndexRoute
|
||||
'/cnb-packages/': typeof CnbPackagesIndexRoute
|
||||
'/config/': typeof ConfigIndexRoute
|
||||
'/repo/': typeof RepoIndexRoute
|
||||
'/workspaces/': typeof WorkspacesIndexRoute
|
||||
@@ -98,6 +107,7 @@ export interface FileRouteTypes {
|
||||
| '/login'
|
||||
| '/config/gitea'
|
||||
| '/cloud-env/'
|
||||
| '/cnb-packages/'
|
||||
| '/config/'
|
||||
| '/repo/'
|
||||
| '/workspaces/'
|
||||
@@ -108,6 +118,7 @@ export interface FileRouteTypes {
|
||||
| '/login'
|
||||
| '/config/gitea'
|
||||
| '/cloud-env'
|
||||
| '/cnb-packages'
|
||||
| '/config'
|
||||
| '/repo'
|
||||
| '/workspaces'
|
||||
@@ -118,6 +129,7 @@ export interface FileRouteTypes {
|
||||
| '/login'
|
||||
| '/config/gitea'
|
||||
| '/cloud-env/'
|
||||
| '/cnb-packages/'
|
||||
| '/config/'
|
||||
| '/repo/'
|
||||
| '/workspaces/'
|
||||
@@ -129,6 +141,7 @@ export interface RootRouteChildren {
|
||||
LoginRoute: typeof LoginRoute
|
||||
ConfigGiteaRoute: typeof ConfigGiteaRoute
|
||||
CloudEnvIndexRoute: typeof CloudEnvIndexRoute
|
||||
CnbPackagesIndexRoute: typeof CnbPackagesIndexRoute
|
||||
ConfigIndexRoute: typeof ConfigIndexRoute
|
||||
RepoIndexRoute: typeof RepoIndexRoute
|
||||
WorkspacesIndexRoute: typeof WorkspacesIndexRoute
|
||||
@@ -178,6 +191,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof ConfigIndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/cnb-packages/': {
|
||||
id: '/cnb-packages/'
|
||||
path: '/cnb-packages'
|
||||
fullPath: '/cnb-packages/'
|
||||
preLoaderRoute: typeof CnbPackagesIndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/cloud-env/': {
|
||||
id: '/cloud-env/'
|
||||
path: '/cloud-env'
|
||||
@@ -201,6 +221,7 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
LoginRoute: LoginRoute,
|
||||
ConfigGiteaRoute: ConfigGiteaRoute,
|
||||
CloudEnvIndexRoute: CloudEnvIndexRoute,
|
||||
CnbPackagesIndexRoute: CnbPackagesIndexRoute,
|
||||
ConfigIndexRoute: ConfigIndexRoute,
|
||||
RepoIndexRoute: RepoIndexRoute,
|
||||
WorkspacesIndexRoute: WorkspacesIndexRoute,
|
||||
|
||||
9
src/routes/cnb-packages/index.tsx
Normal file
9
src/routes/cnb-packages/index.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import App from '@/pages/cnb-packages/page'
|
||||
export const Route = createFileRoute('/cnb-packages/')({
|
||||
component: RouteComponent,
|
||||
})
|
||||
|
||||
function RouteComponent() {
|
||||
return <App />
|
||||
}
|
||||
Reference in New Issue
Block a user