chore: 前端代码优化 - ESLint 配置、Prettier 格式化、加载骨架屏、React 组件规范
- 更新 ESLint 配置添加 react-hooks 规则 - 运行 Prettier 格式化所有前端文件 - 为 users、apps、audit、quotas 页面添加 Skeleton 加载骨架屏 - 修复动态组件渲染问题(CategoryIcon/TypeIcon 使用 React.createElement) - 修复 useRef 渲染期间访问问题(改用 useMemo) - 修复 effect 中 setState 同步调用问题 - 新增 global-not-found.tsx 404 页面 - 修复 knowledge 页面引号转义问题
This commit is contained in:
@@ -19,12 +19,7 @@ const eslintConfig = defineConfig([
|
||||
"react-hooks/refs": "off",
|
||||
},
|
||||
},
|
||||
globalIgnores([
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
globalIgnores([".next/**", "out/**", "build/**", "next-env.d.ts"]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
export default eslintConfig;
|
||||
|
||||
@@ -113,10 +113,7 @@ export default function AdminAppsPage() {
|
||||
return (
|
||||
<div>
|
||||
{/* 操作确认弹窗 */}
|
||||
<AlertDialog
|
||||
open={!!actionTarget}
|
||||
onOpenChange={(open) => !open && setActionTarget(null)}
|
||||
>
|
||||
<AlertDialog open={!!actionTarget} onOpenChange={(open) => !open && setActionTarget(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
@@ -195,7 +192,11 @@ export default function AdminAppsPage() {
|
||||
<tr key={app.id} className="border-t hover:bg-muted/30 transition-colors">
|
||||
<td className="p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<AppIcon iconUrl={app.icon_url} size={20} className="shrink-0 text-muted-foreground" />
|
||||
<AppIcon
|
||||
iconUrl={app.icon_url}
|
||||
size={20}
|
||||
className="shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium">{app.name}</div>
|
||||
<div className="text-xs text-muted-foreground line-clamp-1">
|
||||
@@ -211,9 +212,7 @@ export default function AdminAppsPage() {
|
||||
</td>
|
||||
<td className="p-3 text-muted-foreground">{app.creator_name}</td>
|
||||
<td className="p-3">
|
||||
<Badge variant={statusColors[app.status]}>
|
||||
{statusLabels[app.status]}
|
||||
</Badge>
|
||||
<Badge variant={statusColors[app.status]}>{statusLabels[app.status]}</Badge>
|
||||
</td>
|
||||
<td className="p-3 text-muted-foreground">
|
||||
{visibilityLabels[app.visibility] || app.visibility}
|
||||
|
||||
@@ -90,9 +90,7 @@ export default function AuditPage() {
|
||||
</td>
|
||||
<td className="p-3">{log.user_name || log.user_id.slice(0, 8)}</td>
|
||||
<td className="p-3">
|
||||
<Badge variant={actionColors[log.action] ?? "outline"}>
|
||||
{log.action}
|
||||
</Badge>
|
||||
<Badge variant={actionColors[log.action] ?? "outline"}>{log.action}</Badge>
|
||||
</td>
|
||||
<td className="p-3 text-muted-foreground">
|
||||
{log.resource_type}/{log.resource_id.slice(0, 8)}
|
||||
|
||||
@@ -4,7 +4,15 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import api from "@/lib/api";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Users, AppWindow, Activity, MessageCircle, Target, DollarSign, type LucideIcon } from "lucide-react";
|
||||
import {
|
||||
Users,
|
||||
AppWindow,
|
||||
Activity,
|
||||
MessageCircle,
|
||||
Target,
|
||||
DollarSign,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
interface OverviewStats {
|
||||
total_users: number;
|
||||
@@ -15,7 +23,15 @@ interface OverviewStats {
|
||||
monthly_cost: number;
|
||||
}
|
||||
|
||||
function StatCard({ title, value, icon: Icon }: { title: string; value: string | number; icon: LucideIcon }) {
|
||||
function StatCard({
|
||||
title,
|
||||
value,
|
||||
icon: Icon,
|
||||
}: {
|
||||
title: string;
|
||||
value: string | number;
|
||||
icon: LucideIcon;
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
@@ -63,7 +79,11 @@ export default function DashboardPage() {
|
||||
<StatCard title="总用户数" value={stats?.total_users || 0} icon={Users} />
|
||||
<StatCard title="已上架应用" value={stats?.total_apps || 0} icon={AppWindow} />
|
||||
<StatCard title="今日活跃用户" value={stats?.active_users || 0} icon={Activity} />
|
||||
<StatCard title="今日对话次数" value={formatNumber(stats?.total_conversations || 0)} icon={MessageCircle} />
|
||||
<StatCard
|
||||
title="今日对话次数"
|
||||
value={formatNumber(stats?.total_conversations || 0)}
|
||||
icon={MessageCircle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
|
||||
|
||||
@@ -75,9 +75,11 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
/>
|
||||
)}
|
||||
|
||||
<aside className={`fixed inset-y-[3.5rem] left-0 z-50 w-56 border-r bg-background transition-transform duration-200 md:static md:inset-y-0 md:translate-x-0 ${
|
||||
sidebarOpen ? "translate-x-0" : "-translate-x-full"
|
||||
}`}>
|
||||
<aside
|
||||
className={`fixed inset-y-[3.5rem] left-0 z-50 w-56 border-r bg-background transition-transform duration-200 md:static md:inset-y-0 md:translate-x-0 ${
|
||||
sidebarOpen ? "translate-x-0" : "-translate-x-full"
|
||||
}`}
|
||||
>
|
||||
<nav className="p-3 space-y-1">
|
||||
{adminNavItems.map((item) => (
|
||||
<Link
|
||||
@@ -86,9 +88,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-2 rounded-md text-sm transition-colors",
|
||||
pathname === item.href
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "hover:bg-muted"
|
||||
pathname === item.href ? "bg-primary text-primary-foreground" : "hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
<item.icon className="h-4 w-4" />
|
||||
|
||||
@@ -10,10 +10,38 @@ const modelGroups = [
|
||||
description: "用于智能对话、公文写作、政策分析等核心功能",
|
||||
icon: MessageSquare,
|
||||
models: [
|
||||
{ name: "qwen-plus", displayName: "通义千问-Plus", provider: "阿里云百炼", type: "对话", status: "active", desc: "主力模型,适用于复杂推理和长文本生成" },
|
||||
{ name: "qwen-turbo", displayName: "通义千问-Turbo", provider: "阿里云百炼", type: "对话", status: "active", desc: "快速响应模型,适用于简单对话和问答" },
|
||||
{ name: "qwen-max", displayName: "通义千问-Max", provider: "阿里云百炼", type: "对话", status: "standby", desc: "旗舰模型,适用于高精度分析场景" },
|
||||
{ name: "qwen-long", displayName: "通义千问-Long", provider: "阿里云百炼", type: "对话", status: "standby", desc: "长上下文模型,支持百万Token输入" },
|
||||
{
|
||||
name: "qwen-plus",
|
||||
displayName: "通义千问-Plus",
|
||||
provider: "阿里云百炼",
|
||||
type: "对话",
|
||||
status: "active",
|
||||
desc: "主力模型,适用于复杂推理和长文本生成",
|
||||
},
|
||||
{
|
||||
name: "qwen-turbo",
|
||||
displayName: "通义千问-Turbo",
|
||||
provider: "阿里云百炼",
|
||||
type: "对话",
|
||||
status: "active",
|
||||
desc: "快速响应模型,适用于简单对话和问答",
|
||||
},
|
||||
{
|
||||
name: "qwen-max",
|
||||
displayName: "通义千问-Max",
|
||||
provider: "阿里云百炼",
|
||||
type: "对话",
|
||||
status: "standby",
|
||||
desc: "旗舰模型,适用于高精度分析场景",
|
||||
},
|
||||
{
|
||||
name: "qwen-long",
|
||||
displayName: "通义千问-Long",
|
||||
provider: "阿里云百炼",
|
||||
type: "对话",
|
||||
status: "standby",
|
||||
desc: "长上下文模型,支持百万Token输入",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -21,7 +49,14 @@ const modelGroups = [
|
||||
description: "用于知识库文档检索和语义搜索",
|
||||
icon: Cpu,
|
||||
models: [
|
||||
{ name: "text-embedding-v3", displayName: "通义文本向量V3", provider: "阿里云百炼", type: "嵌入", status: "active", desc: "1024维向量,高精度语义匹配" },
|
||||
{
|
||||
name: "text-embedding-v3",
|
||||
displayName: "通义文本向量V3",
|
||||
provider: "阿里云百炼",
|
||||
type: "嵌入",
|
||||
status: "active",
|
||||
desc: "1024维向量,高精度语义匹配",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -29,12 +64,22 @@ const modelGroups = [
|
||||
description: "用于长文档分析、政策解读等场景",
|
||||
icon: FileText,
|
||||
models: [
|
||||
{ name: "qwen-plus", displayName: "通义千问-Plus", provider: "阿里云百炼", type: "文档", status: "active", desc: "支持文档理解和内容提取" },
|
||||
{
|
||||
name: "qwen-plus",
|
||||
displayName: "通义千问-Plus",
|
||||
provider: "阿里云百炼",
|
||||
type: "文档",
|
||||
status: "active",
|
||||
desc: "支持文档理解和内容提取",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const statusConfig: Record<string, { label: string; variant: "default" | "secondary" | "outline" }> = {
|
||||
const statusConfig: Record<
|
||||
string,
|
||||
{ label: string; variant: "default" | "secondary" | "outline" }
|
||||
> = {
|
||||
active: { label: "运行中", variant: "default" },
|
||||
standby: { label: "待启用", variant: "secondary" },
|
||||
inactive: { label: "未配置", variant: "outline" },
|
||||
@@ -46,11 +91,15 @@ export default function ModelsPage() {
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">模型管理</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">当前使用阿里云百炼平台(DashScope)提供的通义千问系列模型</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
当前使用阿里云百炼平台(DashScope)提供的通义千问系列模型
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-orange-500" />
|
||||
<Badge variant="secondary" className="gap-1">全部国产模型</Badge>
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
全部国产模型
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -83,13 +132,17 @@ export default function ModelsPage() {
|
||||
<tr key={model.name + model.type} className="border-t">
|
||||
<td className="p-3">
|
||||
<div className="font-medium">{model.displayName}</div>
|
||||
<div className="text-xs text-muted-foreground font-mono">{model.name}</div>
|
||||
<div className="text-xs text-muted-foreground font-mono">
|
||||
{model.name}
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-3 text-muted-foreground">{model.provider}</td>
|
||||
<td className="p-3">
|
||||
<Badge variant="outline">{model.type}</Badge>
|
||||
</td>
|
||||
<td className="p-3 text-muted-foreground text-xs max-w-xs">{model.desc}</td>
|
||||
<td className="p-3 text-muted-foreground text-xs max-w-xs">
|
||||
{model.desc}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Badge variant={st.variant}>{st.label}</Badge>
|
||||
</td>
|
||||
|
||||
@@ -128,10 +128,14 @@ export default function ReviewsPage() {
|
||||
rows={4}
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setRejectDialog(null)}>取消</Button>
|
||||
<Button variant="outline" onClick={() => setRejectDialog(null)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => rejectDialog && reject.mutate({ id: rejectDialog, comment: rejectComment })}
|
||||
onClick={() =>
|
||||
rejectDialog && reject.mutate({ id: rejectDialog, comment: rejectComment })
|
||||
}
|
||||
disabled={!rejectComment.trim() || reject.isPending}
|
||||
>
|
||||
确认驳回
|
||||
|
||||
@@ -6,6 +6,7 @@ import api from "@/lib/api";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -84,7 +85,51 @@ export default function UsersPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
{data?.items == null && !search ? (
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="text-left p-3">用户</th>
|
||||
<th className="text-left p-3">角色</th>
|
||||
<th className="text-left p-3">状态</th>
|
||||
<th className="text-left p-3">登录次数</th>
|
||||
<th className="text-left p-3">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<tr key={i} className="border-t">
|
||||
<td className="p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-8 w-8 rounded-full" />
|
||||
<div className="space-y-1">
|
||||
<Skeleton className="h-3 w-24" />
|
||||
<Skeleton className="h-2 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Skeleton className="h-5 w-16 rounded" />
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Skeleton className="h-5 w-12 rounded" />
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Skeleton className="h-3 w-8" />
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-7 w-20 rounded" />
|
||||
<Skeleton className="h-7 w-12 rounded" />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
@@ -111,9 +156,7 @@ export default function UsersPage() {
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Badge variant={roleColors[user.role]}>
|
||||
{roleLabels[user.role]}
|
||||
</Badge>
|
||||
<Badge variant={roleColors[user.role]}>{roleLabels[user.role]}</Badge>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Badge variant={user.status === "active" ? "default" : "destructive"}>
|
||||
@@ -154,7 +197,7 @@ export default function UsersPage() {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -57,18 +57,12 @@ export default function LoginPage() {
|
||||
const user = useAuthStore.getState().user;
|
||||
// 平台管理员不绑定机构,登录后保留 super_admin 身份;
|
||||
// 仅机构管理员在所选机构与自身归属不一致时才触发切换
|
||||
if (
|
||||
user &&
|
||||
user.role === "admin" &&
|
||||
user.org_id !== selectedOrg
|
||||
) {
|
||||
if (user && user.role === "admin" && user.org_id !== selectedOrg) {
|
||||
await switchOrg(selectedOrg);
|
||||
}
|
||||
router.push(user?.role === "super_admin" ? "/platform/overview" : "/store");
|
||||
} catch (err) {
|
||||
setErrorMsg(
|
||||
err instanceof Error ? err.message : "登录失败,请检查账号和密码"
|
||||
);
|
||||
setErrorMsg(err instanceof Error ? err.message : "登录失败,请检查账号和密码");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -85,162 +79,144 @@ export default function LoginPage() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-blue-950 via-blue-900 to-blue-800 px-6 py-12">
|
||||
<div className="flex w-full max-w-[1000px] items-center gap-16 lg:gap-20">
|
||||
{/* 左侧品牌区 - 桌面端显示 */}
|
||||
<div className="hidden lg:flex lg:flex-1 flex-col">
|
||||
<div className="max-w-md">
|
||||
<div className="flex items-center gap-3 mb-8">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-white/10 backdrop-blur-sm border border-white/20">
|
||||
<Shield className="h-8 w-8 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white tracking-tight">
|
||||
AI 智能应用平台
|
||||
</h1>
|
||||
<p className="text-blue-200/80 text-sm mt-0.5">
|
||||
提升效能 · 赋能智慧办公
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-blue-100/70 text-lg leading-relaxed mb-10">
|
||||
面向政务与高校场景的一站式 AI
|
||||
应用平台,集成文档生成、智能问答、数据分析等核心能力,助力组织数智化转型。
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{features.map(({ icon: Icon, text }) => (
|
||||
<div key={text} className="flex items-center gap-4">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-white/10 backdrop-blur-sm">
|
||||
<Icon className="h-5 w-5 text-blue-200" />
|
||||
</div>
|
||||
<span className="text-blue-100/90 text-base">{text}</span>
|
||||
{/* 左侧品牌区 - 桌面端显示 */}
|
||||
<div className="hidden lg:flex lg:flex-1 flex-col">
|
||||
<div className="max-w-md">
|
||||
<div className="flex items-center gap-3 mb-8">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-white/10 backdrop-blur-sm border border-white/20">
|
||||
<Shield className="h-8 w-8 text-white" />
|
||||
</div>
|
||||
))}
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white tracking-tight">AI 智能应用平台</h1>
|
||||
<p className="text-blue-200/80 text-sm mt-0.5">提升效能 · 赋能智慧办公</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-blue-100/70 text-lg leading-relaxed mb-10">
|
||||
面向政务与高校场景的一站式 AI
|
||||
应用平台,集成文档生成、智能问答、数据分析等核心能力,助力组织数智化转型。
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{features.map(({ icon: Icon, text }) => (
|
||||
<div key={text} className="flex items-center gap-4">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-white/10 backdrop-blur-sm">
|
||||
<Icon className="h-5 w-5 text-blue-200" />
|
||||
</div>
|
||||
<span className="text-blue-100/90 text-base">{text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧登录区 */}
|
||||
<div className="flex w-full lg:w-auto lg:shrink-0 items-center justify-center">
|
||||
<div className="w-full max-w-[460px] rounded-2xl bg-white shadow-2xl border border-white/20 overflow-hidden">
|
||||
{/* 移动端标题 - 仅在小屏显示 */}
|
||||
<div className="lg:hidden bg-gradient-to-r from-blue-900 to-blue-800 px-8 pt-8 pb-6 text-center">
|
||||
<Shield className="h-10 w-10 text-white mx-auto mb-3" />
|
||||
<h1 className="text-xl font-bold text-white">AI 智能应用平台</h1>
|
||||
<p className="text-blue-200/80 text-sm mt-1">
|
||||
提升效能 · 赋能智慧办公
|
||||
</p>
|
||||
</div>
|
||||
{/* 右侧登录区 */}
|
||||
<div className="flex w-full lg:w-auto lg:shrink-0 items-center justify-center">
|
||||
<div className="w-full max-w-[460px] rounded-2xl bg-white shadow-2xl border border-white/20 overflow-hidden">
|
||||
{/* 移动端标题 - 仅在小屏显示 */}
|
||||
<div className="lg:hidden bg-gradient-to-r from-blue-900 to-blue-800 px-8 pt-8 pb-6 text-center">
|
||||
<Shield className="h-10 w-10 text-white mx-auto mb-3" />
|
||||
<h1 className="text-xl font-bold text-white">AI 智能应用平台</h1>
|
||||
<p className="text-blue-200/80 text-sm mt-1">提升效能 · 赋能智慧办公</p>
|
||||
</div>
|
||||
|
||||
{/* 表单区域 */}
|
||||
<div className="px-8 sm:px-10 py-8 sm:py-10">
|
||||
<h2 className="hidden lg:block text-2xl font-semibold text-gray-900 mb-1">
|
||||
欢迎登录
|
||||
</h2>
|
||||
<p className="hidden lg:block text-sm text-gray-500 mb-8">
|
||||
请选择机构并输入账号密码
|
||||
</p>
|
||||
{/* 表单区域 */}
|
||||
<div className="px-8 sm:px-10 py-8 sm:py-10">
|
||||
<h2 className="hidden lg:block text-2xl font-semibold text-gray-900 mb-1">
|
||||
欢迎登录
|
||||
</h2>
|
||||
<p className="hidden lg:block text-sm text-gray-500 mb-8">请选择机构并输入账号密码</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{errorMsg && (
|
||||
<div className="rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-700">
|
||||
{errorMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="org" className="text-sm font-medium text-gray-700">
|
||||
<span className="flex items-center gap-2">
|
||||
<Building2 className="h-4 w-4 text-gray-400" />
|
||||
所属机构
|
||||
</span>
|
||||
</Label>
|
||||
{orgs.length > 0 ? (
|
||||
<select
|
||||
id="org"
|
||||
value={selectedOrg}
|
||||
onChange={(e) => setSelectedOrg(e.target.value)}
|
||||
className="flex h-11 w-full rounded-xl border border-gray-200 bg-gray-50/50 px-4 py-2 text-sm shadow-sm transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500/40 focus:border-blue-400 hover:border-gray-300"
|
||||
>
|
||||
{orgs.map((org) => (
|
||||
<option key={org.id} value={org.id}>
|
||||
{org.short_name || org.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<Input
|
||||
disabled
|
||||
placeholder="正在加载机构列表..."
|
||||
className="h-11 rounded-xl"
|
||||
/>
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{errorMsg && (
|
||||
<div className="rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-700">
|
||||
{errorMsg}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="email"
|
||||
className="text-sm font-medium text-gray-700"
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="org" className="text-sm font-medium text-gray-700">
|
||||
<span className="flex items-center gap-2">
|
||||
<Building2 className="h-4 w-4 text-gray-400" />
|
||||
所属机构
|
||||
</span>
|
||||
</Label>
|
||||
{orgs.length > 0 ? (
|
||||
<select
|
||||
id="org"
|
||||
value={selectedOrg}
|
||||
onChange={(e) => setSelectedOrg(e.target.value)}
|
||||
className="flex h-11 w-full rounded-xl border border-gray-200 bg-gray-50/50 px-4 py-2 text-sm shadow-sm transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500/40 focus:border-blue-400 hover:border-gray-300"
|
||||
>
|
||||
{orgs.map((org) => (
|
||||
<option key={org.id} value={org.id}>
|
||||
{org.short_name || org.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<Input disabled placeholder="正在加载机构列表..." className="h-11 rounded-xl" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email" className="text-sm font-medium text-gray-700">
|
||||
账号
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="your@gov.cn"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
autoComplete="email"
|
||||
required
|
||||
className="h-11 rounded-xl border-gray-200 bg-gray-50/50 px-4 focus:ring-2 focus:ring-blue-500/40 focus:border-blue-400 hover:border-gray-300"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password" className="text-sm font-medium text-gray-700">
|
||||
密码
|
||||
</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
className="h-11 rounded-xl border-gray-200 bg-gray-50/50 px-4 focus:ring-2 focus:ring-blue-500/40 focus:border-blue-400 hover:border-gray-300"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full h-12 text-base font-medium rounded-xl bg-blue-900 hover:bg-blue-800 transition-all duration-200 shadow-lg shadow-blue-900/25 hover:shadow-xl hover:shadow-blue-900/30 mt-2"
|
||||
disabled={loading}
|
||||
>
|
||||
账号
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="your@gov.cn"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
autoComplete="email"
|
||||
required
|
||||
className="h-11 rounded-xl border-gray-200 bg-gray-50/50 px-4 focus:ring-2 focus:ring-blue-500/40 focus:border-blue-400 hover:border-gray-300"
|
||||
/>
|
||||
</div>
|
||||
{loading ? "登录中..." : "登 录"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="password"
|
||||
className="text-sm font-medium text-gray-700"
|
||||
<div className="mt-6 text-center text-sm text-gray-500">
|
||||
还没有账号?{" "}
|
||||
<Link
|
||||
href="/register"
|
||||
className="text-blue-600 font-medium hover:text-blue-700 underline-offset-4 hover:underline"
|
||||
>
|
||||
密码
|
||||
</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
className="h-11 rounded-xl border-gray-200 bg-gray-50/50 px-4 focus:ring-2 focus:ring-blue-500/40 focus:border-blue-400 hover:border-gray-300"
|
||||
/>
|
||||
申请注册
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full h-12 text-base font-medium rounded-xl bg-blue-900 hover:bg-blue-800 transition-all duration-200 shadow-lg shadow-blue-900/25 hover:shadow-xl hover:shadow-blue-900/30 mt-2"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "登录中..." : "登 录"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center text-sm text-gray-500">
|
||||
还没有账号?{" "}
|
||||
<Link
|
||||
href="/register"
|
||||
className="text-blue-600 font-medium hover:text-blue-700 underline-offset-4 hover:underline"
|
||||
>
|
||||
申请注册
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 text-center text-xs text-gray-400 border-t border-gray-100 pt-5">
|
||||
本系统仅限授权人员使用 · 数据安全等级:机构内部
|
||||
<div className="mt-8 text-center text-xs text-gray-400 border-t border-gray-100 pt-5">
|
||||
本系统仅限授权人员使用 · 数据安全等级:机构内部
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -39,7 +39,12 @@ export default function RegisterPage() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.post<{
|
||||
user: { id: string; name: string; email: string; role: "user" | "super_admin" | "admin" | "creator" };
|
||||
user: {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: "user" | "super_admin" | "admin" | "creator";
|
||||
};
|
||||
access_token: string;
|
||||
}>("/api/v1/auth/register", { name, email, password });
|
||||
|
||||
|
||||
@@ -18,10 +18,7 @@ const ANALYSIS_SLUGS = new Set(["analysis-agent"]);
|
||||
export default function AppPage() {
|
||||
const { appId: slugOrId } = useParams<{ appId: string }>();
|
||||
|
||||
const isUUID =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
|
||||
slugOrId
|
||||
);
|
||||
const isUUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(slugOrId);
|
||||
|
||||
const { data: appBySlug, isLoading: slugLoading } = useQuery({
|
||||
queryKey: ["chatApp", slugOrId],
|
||||
@@ -32,9 +29,7 @@ export default function AppPage() {
|
||||
const { data: appById, isLoading: idLoading } = useQuery({
|
||||
queryKey: ["chatAppById", slugOrId],
|
||||
queryFn: async () => {
|
||||
const results = await api.get<{ items: App[] }>(
|
||||
`/api/v1/store/apps?page_size=50`
|
||||
);
|
||||
const results = await api.get<{ items: App[] }>(`/api/v1/store/apps?page_size=50`);
|
||||
return results.items?.find((a) => a.id === slugOrId) || null;
|
||||
},
|
||||
enabled: isUUID,
|
||||
|
||||
@@ -10,12 +10,7 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -35,16 +30,7 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import type { App, Category, KnowledgeBase } from "@/lib/types";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Send,
|
||||
Eye,
|
||||
BookOpen,
|
||||
Settings2,
|
||||
BarChart3,
|
||||
} from "lucide-react";
|
||||
import { Plus, Pencil, Trash2, Send, Eye, BookOpen, Settings2, BarChart3 } from "lucide-react";
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
draft: "草稿",
|
||||
@@ -54,10 +40,7 @@ const statusLabels: Record<string, string> = {
|
||||
archived: "已归档",
|
||||
};
|
||||
|
||||
const statusColors: Record<
|
||||
string,
|
||||
"default" | "secondary" | "destructive" | "outline"
|
||||
> = {
|
||||
const statusColors: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
|
||||
draft: "outline",
|
||||
pending_review: "secondary",
|
||||
approved: "default",
|
||||
@@ -68,11 +51,11 @@ const statusColors: Record<
|
||||
import { appTypeConfigs } from "@/lib/app-type-config";
|
||||
|
||||
const appTypeLabels: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(appTypeConfigs).map(([k, v]) => [k, v.label])
|
||||
Object.entries(appTypeConfigs).map(([k, v]) => [k, v.label]),
|
||||
);
|
||||
|
||||
const appTypeColors: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(appTypeConfigs).map(([k, v]) => [k, v.badgeColor])
|
||||
Object.entries(appTypeConfigs).map(([k, v]) => [k, v.badgeColor]),
|
||||
);
|
||||
|
||||
const visibilityLabels: Record<string, string> = {
|
||||
@@ -165,9 +148,7 @@ export default function CreatePage() {
|
||||
id: string;
|
||||
name: string;
|
||||
} | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<
|
||||
"basic" | "prompt" | "runtime" | "advanced"
|
||||
>("basic");
|
||||
const [activeTab, setActiveTab] = useState<"basic" | "prompt" | "runtime" | "advanced">("basic");
|
||||
|
||||
const { data: myApps } = useQuery({
|
||||
queryKey: ["creatorApps"],
|
||||
@@ -182,7 +163,9 @@ export default function CreatePage() {
|
||||
const { data: knowledgeBases } = useQuery({
|
||||
queryKey: ["knowledgeBases", user?.org_id],
|
||||
queryFn: () =>
|
||||
api.get<KnowledgeBase[]>(`/api/v1/knowledge${user?.org_id ? `?org_id=${user.org_id}` : ""}`).catch(() => []),
|
||||
api
|
||||
.get<KnowledgeBase[]>(`/api/v1/knowledge${user?.org_id ? `?org_id=${user.org_id}` : ""}`)
|
||||
.catch(() => []),
|
||||
});
|
||||
|
||||
const createApp = useMutation({
|
||||
@@ -206,8 +189,7 @@ export default function CreatePage() {
|
||||
});
|
||||
|
||||
const submitReview = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
api.post(`/api/v1/creator/apps/${id}/submit-review`),
|
||||
mutationFn: (id: string) => api.post(`/api/v1/creator/apps/${id}/submit-review`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["creatorApps"] });
|
||||
toast.success("已提交审核");
|
||||
@@ -216,8 +198,7 @@ export default function CreatePage() {
|
||||
});
|
||||
|
||||
const withdrawReview = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
api.post(`/api/v1/creator/apps/${id}/withdraw`),
|
||||
mutationFn: (id: string) => api.post(`/api/v1/creator/apps/${id}/withdraw`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["creatorApps"] });
|
||||
toast.success("已撤回审核");
|
||||
@@ -226,8 +207,7 @@ export default function CreatePage() {
|
||||
});
|
||||
|
||||
const requestDelist = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
api.post(`/api/v1/creator/apps/${id}/request-delist`),
|
||||
mutationFn: (id: string) => api.post(`/api/v1/creator/apps/${id}/request-delist`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["creatorApps"] });
|
||||
toast.success("已下架");
|
||||
@@ -263,42 +243,39 @@ export default function CreatePage() {
|
||||
setShowForm(true);
|
||||
}, []);
|
||||
|
||||
const openEdit = useCallback(
|
||||
async (appId: string) => {
|
||||
try {
|
||||
const app = await api.get<App>(`/api/v1/creator/apps/${appId}`);
|
||||
const config = parseAppConfig(app.app_config);
|
||||
setEditingId(appId);
|
||||
setForm({
|
||||
name: app.name || "",
|
||||
description: app.description || "",
|
||||
long_description: app.long_description || "",
|
||||
system_prompt: (config.system_prompt as string) || "",
|
||||
welcome_message: app.welcome_message || "",
|
||||
suggested_prompts: parseSuggestedPrompts(app.suggested_prompts),
|
||||
category_id: app.category_id || "",
|
||||
visibility: app.visibility || "private",
|
||||
app_type: app.dify_app_type || "chatbot",
|
||||
model: (config.model as string) || "",
|
||||
temperature: app.temperature ?? 0.7,
|
||||
max_tokens: app.max_tokens ?? 4096,
|
||||
knowledge_base_id: app.knowledge_base_id || "",
|
||||
tools: Array.isArray(config.tools) ? (config.tools as string[]) : [],
|
||||
data_sources: Array.isArray(config.data_sources) ? (config.data_sources as string[]) : [],
|
||||
template_set: (config.template_set as string) || "",
|
||||
input_label: (config.input_label as string) || "",
|
||||
output_label: (config.output_label as string) || "",
|
||||
input_placeholder: (config.input_placeholder as string) || "",
|
||||
format_templates: (config.format_templates as AppForm["format_templates"]) || {},
|
||||
});
|
||||
setActiveTab("basic");
|
||||
setShowForm(true);
|
||||
} catch {
|
||||
toast.error("获取应用详情失败");
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
const openEdit = useCallback(async (appId: string) => {
|
||||
try {
|
||||
const app = await api.get<App>(`/api/v1/creator/apps/${appId}`);
|
||||
const config = parseAppConfig(app.app_config);
|
||||
setEditingId(appId);
|
||||
setForm({
|
||||
name: app.name || "",
|
||||
description: app.description || "",
|
||||
long_description: app.long_description || "",
|
||||
system_prompt: (config.system_prompt as string) || "",
|
||||
welcome_message: app.welcome_message || "",
|
||||
suggested_prompts: parseSuggestedPrompts(app.suggested_prompts),
|
||||
category_id: app.category_id || "",
|
||||
visibility: app.visibility || "private",
|
||||
app_type: app.dify_app_type || "chatbot",
|
||||
model: (config.model as string) || "",
|
||||
temperature: app.temperature ?? 0.7,
|
||||
max_tokens: app.max_tokens ?? 4096,
|
||||
knowledge_base_id: app.knowledge_base_id || "",
|
||||
tools: Array.isArray(config.tools) ? (config.tools as string[]) : [],
|
||||
data_sources: Array.isArray(config.data_sources) ? (config.data_sources as string[]) : [],
|
||||
template_set: (config.template_set as string) || "",
|
||||
input_label: (config.input_label as string) || "",
|
||||
output_label: (config.output_label as string) || "",
|
||||
input_placeholder: (config.input_placeholder as string) || "",
|
||||
format_templates: (config.format_templates as AppForm["format_templates"]) || {},
|
||||
});
|
||||
setActiveTab("basic");
|
||||
setShowForm(true);
|
||||
} catch {
|
||||
toast.error("获取应用详情失败");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const addPrompt = useCallback(() => {
|
||||
const v = promptInput.trim();
|
||||
@@ -331,10 +308,7 @@ export default function CreatePage() {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-7xl px-6 lg:px-8 py-6">
|
||||
{/* 删除确认 */}
|
||||
<AlertDialog
|
||||
open={!!deleteTarget}
|
||||
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
||||
>
|
||||
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
||||
@@ -359,9 +333,7 @@ export default function CreatePage() {
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">应用管理</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{isAdmin
|
||||
? "管理全部AI应用(管理员模式)"
|
||||
: "创建、编辑和管理你的AI应用"}
|
||||
{isAdmin ? "管理全部AI应用(管理员模式)" : "创建、编辑和管理你的AI应用"}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={openCreate} className="gap-1.5">
|
||||
@@ -379,22 +351,17 @@ export default function CreatePage() {
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{myApps?.map((app) => (
|
||||
<Card
|
||||
key={app.id}
|
||||
className="hover:shadow-md transition-shadow group"
|
||||
>
|
||||
<Card key={app.id} className="hover:shadow-md transition-shadow group">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base truncate pr-2">
|
||||
{app.name}
|
||||
</CardTitle>
|
||||
<CardTitle className="text-base truncate pr-2">{app.name}</CardTitle>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded-full ${appTypeColors[app.dify_app_type || "chatbot"]}`}>
|
||||
<span
|
||||
className={`text-[10px] px-1.5 py-0.5 rounded-full ${appTypeColors[app.dify_app_type || "chatbot"]}`}
|
||||
>
|
||||
{appTypeLabels[app.dify_app_type || "chatbot"]}
|
||||
</span>
|
||||
<Badge variant={statusColors[app.status]}>
|
||||
{statusLabels[app.status]}
|
||||
</Badge>
|
||||
<Badge variant={statusColors[app.status]}>{statusLabels[app.status]}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
@@ -446,9 +413,7 @@ export default function CreatePage() {
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs text-destructive hover:text-destructive"
|
||||
onClick={() =>
|
||||
setDeleteTarget({ id: app.id, name: app.name })
|
||||
}
|
||||
onClick={() => setDeleteTarget({ id: app.id, name: app.name })}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
@@ -468,11 +433,12 @@ export default function CreatePage() {
|
||||
|
||||
{/* 创建/编辑对话框 */}
|
||||
<Dialog open={showForm} onOpenChange={(open) => !open && closeForm()}>
|
||||
<DialogContent className="sm:max-w-5xl max-h-[85vh] overflow-hidden flex flex-col" style={{ maxWidth: "min(1024px, calc(100vw - 2rem))" }}>
|
||||
<DialogContent
|
||||
className="sm:max-w-5xl max-h-[85vh] overflow-hidden flex flex-col"
|
||||
style={{ maxWidth: "min(1024px, calc(100vw - 2rem))" }}
|
||||
>
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle>
|
||||
{editingId ? "编辑应用" : "创建AI应用"}
|
||||
</DialogTitle>
|
||||
<DialogTitle>{editingId ? "编辑应用" : "创建AI应用"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Tab切换 */}
|
||||
@@ -520,9 +486,7 @@ export default function CreatePage() {
|
||||
</Label>
|
||||
<Input
|
||||
value={form.name}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, name: e.target.value })
|
||||
}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
placeholder="例如:政策法规问答"
|
||||
/>
|
||||
</div>
|
||||
@@ -530,9 +494,7 @@ export default function CreatePage() {
|
||||
<Label>简要描述</Label>
|
||||
<Input
|
||||
value={form.description}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, description: e.target.value })
|
||||
}
|
||||
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||
placeholder="一句话描述应用功能"
|
||||
/>
|
||||
</div>
|
||||
@@ -540,9 +502,7 @@ export default function CreatePage() {
|
||||
<Label>详细描述</Label>
|
||||
<Textarea
|
||||
value={form.long_description}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, long_description: e.target.value })
|
||||
}
|
||||
onChange={(e) => setForm({ ...form, long_description: e.target.value })}
|
||||
placeholder="详细介绍应用的功能、使用场景等"
|
||||
rows={3}
|
||||
/>
|
||||
@@ -552,9 +512,7 @@ export default function CreatePage() {
|
||||
<Label>应用类型</Label>
|
||||
<Select
|
||||
value={form.app_type}
|
||||
onValueChange={(v) =>
|
||||
v && setForm({ ...form, app_type: v })
|
||||
}
|
||||
onValueChange={(v) => v && setForm({ ...form, app_type: v })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<span>{appTypeLabels[form.app_type] || "对话型"}</span>
|
||||
@@ -572,9 +530,7 @@ export default function CreatePage() {
|
||||
<Label>可见范围</Label>
|
||||
<Select
|
||||
value={form.visibility}
|
||||
onValueChange={(v) =>
|
||||
v && setForm({ ...form, visibility: v })
|
||||
}
|
||||
onValueChange={(v) => v && setForm({ ...form, visibility: v })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<span>{visibilityLabels[form.visibility] || "仅自己"}</span>
|
||||
@@ -593,7 +549,11 @@ export default function CreatePage() {
|
||||
<Label>分类</Label>
|
||||
{(() => {
|
||||
const currentCat = categories?.find((c) => c.id === form.category_id);
|
||||
const catDisplay = currentCat ? currentCat.name : form.category_id ? "加载中..." : "未分类";
|
||||
const catDisplay = currentCat
|
||||
? currentCat.name
|
||||
: form.category_id
|
||||
? "加载中..."
|
||||
: "未分类";
|
||||
return (
|
||||
<Select
|
||||
value={form.category_id || "__none__"}
|
||||
@@ -626,10 +586,14 @@ export default function CreatePage() {
|
||||
当前类型:<strong>{appTypeLabels[form.app_type]}</strong>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{form.app_type === "chatbot" && "多轮对话,支持知识库检索(RAG)、上下文记忆。适合:政策问答、咨询服务。"}
|
||||
{form.app_type === "agent" && "可调用工具和数据源,支持多步推理和报告生成。适合:数据分析、综合研判。"}
|
||||
{form.app_type === "completion" && "单次输入生成结果,无对话上下文。适合:文档摘要、翻译、格式转换。"}
|
||||
{form.app_type === "workflow" && "按预定流程分步处理,用户依次输入。适合:项目评估、政策分析。"}
|
||||
{form.app_type === "chatbot" &&
|
||||
"多轮对话,支持知识库检索(RAG)、上下文记忆。适合:政策问答、咨询服务。"}
|
||||
{form.app_type === "agent" &&
|
||||
"可调用工具和数据源,支持多步推理和报告生成。适合:数据分析、综合研判。"}
|
||||
{form.app_type === "completion" &&
|
||||
"单次输入生成结果,无对话上下文。适合:文档摘要、翻译、格式转换。"}
|
||||
{form.app_type === "workflow" &&
|
||||
"按预定流程分步处理,用户依次输入。适合:项目评估、政策分析。"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -639,17 +603,15 @@ export default function CreatePage() {
|
||||
</p>
|
||||
<Textarea
|
||||
value={form.system_prompt}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, system_prompt: e.target.value })
|
||||
}
|
||||
onChange={(e) => setForm({ ...form, system_prompt: e.target.value })}
|
||||
placeholder={
|
||||
form.app_type === "chatbot"
|
||||
? "你是一位AI助手,专业、准确地解答相关问题..."
|
||||
: form.app_type === "agent"
|
||||
? "你是一位智能助手,可以调用工具完成复杂任务..."
|
||||
: form.app_type === "completion"
|
||||
? "根据用户输入的主题,生成一份规范的公文..."
|
||||
: "按照以下步骤处理用户的请求..."
|
||||
? "你是一位智能助手,可以调用工具完成复杂任务..."
|
||||
: form.app_type === "completion"
|
||||
? "根据用户输入的主题,生成一份规范的公文..."
|
||||
: "按照以下步骤处理用户的请求..."
|
||||
}
|
||||
rows={8}
|
||||
className="font-mono text-sm"
|
||||
@@ -660,9 +622,7 @@ export default function CreatePage() {
|
||||
<Label>欢迎消息</Label>
|
||||
<Textarea
|
||||
value={form.welcome_message}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, welcome_message: e.target.value })
|
||||
}
|
||||
onChange={(e) => setForm({ ...form, welcome_message: e.target.value })}
|
||||
placeholder="用户打开应用时看到的第一条消息"
|
||||
rows={3}
|
||||
/>
|
||||
@@ -671,16 +631,12 @@ export default function CreatePage() {
|
||||
{(form.app_type === "chatbot" || form.app_type === "agent") && (
|
||||
<div className="space-y-2">
|
||||
<Label>推荐问题</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
用户可一键发送的预设问题
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">用户可一键发送的预设问题</p>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={promptInput}
|
||||
onChange={(e) => setPromptInput(e.target.value)}
|
||||
onKeyDown={(e) =>
|
||||
e.key === "Enter" && (e.preventDefault(), addPrompt())
|
||||
}
|
||||
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), addPrompt())}
|
||||
placeholder="输入推荐问题,按回车添加"
|
||||
/>
|
||||
<Button
|
||||
@@ -721,8 +677,8 @@ export default function CreatePage() {
|
||||
const displayValue = currentKb
|
||||
? `${currentKb.name}(${currentKb.document_count} 篇文档)`
|
||||
: form.knowledge_base_id
|
||||
? `加载中... (${form.knowledge_base_id.slice(0, 8)}...)`
|
||||
: "不关联知识库";
|
||||
? `加载中... (${form.knowledge_base_id.slice(0, 8)}...)`
|
||||
: "不关联知识库";
|
||||
return (
|
||||
<Select
|
||||
value={form.knowledge_base_id || "__none__"}
|
||||
@@ -759,10 +715,14 @@ export default function CreatePage() {
|
||||
运行配置决定应用如何处理用户请求
|
||||
</p>
|
||||
<p className="text-xs text-blue-600/80 dark:text-blue-400/80">
|
||||
{form.app_type === "chatbot" && "对话型应用通过知识库RAG和上下文记忆提供智能问答,可在「提示词与知识库」中配置。"}
|
||||
{form.app_type === "agent" && "智能体应用通过调用工具和数据源完成复杂任务,请配置可用的工具和数据源。"}
|
||||
{form.app_type === "completion" && "补全型应用将用户输入转换为结构化输出,请配置输入输出标签。"}
|
||||
{form.app_type === "workflow" && "工作流应用按预定流程分步处理,请配置处理步骤和参数。"}
|
||||
{form.app_type === "chatbot" &&
|
||||
"对话型应用通过知识库RAG和上下文记忆提供智能问答,可在「提示词与知识库」中配置。"}
|
||||
{form.app_type === "agent" &&
|
||||
"智能体应用通过调用工具和数据源完成复杂任务,请配置可用的工具和数据源。"}
|
||||
{form.app_type === "completion" &&
|
||||
"补全型应用将用户输入转换为结构化输出,请配置输入输出标签。"}
|
||||
{form.app_type === "workflow" &&
|
||||
"工作流应用按预定流程分步处理,请配置处理步骤和参数。"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -774,7 +734,11 @@ export default function CreatePage() {
|
||||
{
|
||||
group: "经济分析类",
|
||||
tools: [
|
||||
{ id: "数据检索", label: "数据检索", desc: "查询区县经济指标等结构化数据" },
|
||||
{
|
||||
id: "数据检索",
|
||||
label: "数据检索",
|
||||
desc: "查询区县经济指标等结构化数据",
|
||||
},
|
||||
{ id: "趋势分析", label: "趋势分析", desc: "分析多年数据变化趋势" },
|
||||
{ id: "对比分析", label: "对比分析", desc: "多区县/多维度横向对比" },
|
||||
{ id: "图表生成", label: "图表生成", desc: "生成数据可视化图表" },
|
||||
@@ -792,7 +756,11 @@ export default function CreatePage() {
|
||||
{
|
||||
group: "通用工具",
|
||||
tools: [
|
||||
{ id: "政策检索", label: "政策法规检索", desc: "全文检索国家和地方政策法规" },
|
||||
{
|
||||
id: "政策检索",
|
||||
label: "政策法规检索",
|
||||
desc: "全文检索国家和地方政策法规",
|
||||
},
|
||||
{ id: "公文生成", label: "公文生成", desc: "按公文模板生成规范公文" },
|
||||
{ id: "报告生成", label: "报告生成", desc: "生成综合分析报告" },
|
||||
{ id: "报告汇总", label: "报告汇总", desc: "汇总多份子报告为总报告" },
|
||||
@@ -804,7 +772,11 @@ export default function CreatePage() {
|
||||
{
|
||||
group: "经济数据",
|
||||
sources: [
|
||||
{ id: "区域经济数据", label: "区域经济数据", desc: "各区县GDP、财政收入等指标" },
|
||||
{
|
||||
id: "区域经济数据",
|
||||
label: "区域经济数据",
|
||||
desc: "各区县GDP、财政收入等指标",
|
||||
},
|
||||
{ id: "统计年鉴", label: "统计年鉴", desc: "年度统计数据汇总" },
|
||||
{ id: "政府工作报告", label: "政府工作报告", desc: "年度政府工作报告" },
|
||||
],
|
||||
@@ -813,7 +785,11 @@ export default function CreatePage() {
|
||||
group: "干部管理",
|
||||
sources: [
|
||||
{ id: "干部信息库", label: "干部信息库", desc: "干部基本信息和履历" },
|
||||
{ id: "绩效考核数据", label: "绩效考核数据", desc: "年度绩效考核评分数据" },
|
||||
{
|
||||
id: "绩效考核数据",
|
||||
label: "绩效考核数据",
|
||||
desc: "年度绩效考核评分数据",
|
||||
},
|
||||
{ id: "民主测评数据", label: "民主测评数据", desc: "民主测评投票结果" },
|
||||
{ id: "培训记录", label: "培训记录", desc: "干部教育培训记录" },
|
||||
],
|
||||
@@ -840,11 +816,15 @@ export default function CreatePage() {
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label>可调用工具</Label>
|
||||
<p className="text-xs text-muted-foreground">选择应用可以使用的工具能力,不同类型应用应选择对应领域的工具</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
选择应用可以使用的工具能力,不同类型应用应选择对应领域的工具
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
{toolGroups.map((group) => (
|
||||
<div key={group.group}>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1.5 pl-0.5">{group.group}</p>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1.5 pl-0.5">
|
||||
{group.group}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{group.tools.map((tool) => (
|
||||
<label
|
||||
@@ -868,7 +848,9 @@ export default function CreatePage() {
|
||||
/>
|
||||
<div>
|
||||
<div className="text-sm font-medium">{tool.label}</div>
|
||||
<div className="text-xs text-muted-foreground">{tool.desc}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{tool.desc}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
@@ -880,11 +862,15 @@ export default function CreatePage() {
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>数据源</Label>
|
||||
<p className="text-xs text-muted-foreground">选择应用可以访问的数据源,不同应用使用不同领域的数据</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
选择应用可以访问的数据源,不同应用使用不同领域的数据
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
{dataSourceGroups.map((group) => (
|
||||
<div key={group.group}>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1.5 pl-0.5">{group.group}</p>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1.5 pl-0.5">
|
||||
{group.group}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{group.sources.map((ds) => (
|
||||
<label
|
||||
@@ -908,7 +894,9 @@ export default function CreatePage() {
|
||||
/>
|
||||
<div>
|
||||
<div className="text-sm font-medium">{ds.label}</div>
|
||||
<div className="text-xs text-muted-foreground">{ds.desc}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{ds.desc}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
@@ -920,7 +908,9 @@ export default function CreatePage() {
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>报告模板集</Label>
|
||||
<p className="text-xs text-muted-foreground">选择应用使用的分析报告模板</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
选择应用使用的分析报告模板
|
||||
</p>
|
||||
<Select
|
||||
value={form.template_set || "__none__"}
|
||||
onValueChange={(v) =>
|
||||
@@ -928,12 +918,18 @@ export default function CreatePage() {
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<span>{form.template_set ? templateLabels[form.template_set] || form.template_set : "选择模板集"}</span>
|
||||
<span>
|
||||
{form.template_set
|
||||
? templateLabels[form.template_set] || form.template_set
|
||||
: "选择模板集"}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">不使用模板</SelectItem>
|
||||
{Object.entries(templateLabels).map(([k, v]) => (
|
||||
<SelectItem key={k} value={k}>{v}</SelectItem>
|
||||
<SelectItem key={k} value={k}>
|
||||
{v}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -991,7 +987,9 @@ export default function CreatePage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>输出格式模板</Label>
|
||||
<p className="text-xs text-muted-foreground">用户可选择不同的输出格式,系统按模板规范生成内容</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
用户可选择不同的输出格式,系统按模板规范生成内容
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -1065,7 +1063,9 @@ export default function CreatePage() {
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-1">内容章节(逗号分隔)</p>
|
||||
<p className="text-xs text-muted-foreground mb-1">
|
||||
内容章节(逗号分隔)
|
||||
</p>
|
||||
<Input
|
||||
value={tpl.sections.join("、")}
|
||||
onChange={(e) =>
|
||||
@@ -1113,10 +1113,14 @@ export default function CreatePage() {
|
||||
<>
|
||||
<div className="rounded-lg bg-muted/40 p-3 mb-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{form.app_type === "chatbot" && "对话型推荐 通义千问-Plus(16K输出,支持知识库RAG)或 通义千问-Max(高质量推理)"}
|
||||
{form.app_type === "agent" && "智能体型推荐 通义千问-Plus(16K输出,支持长报告生成)"}
|
||||
{form.app_type === "completion" && "补全型推荐 通义千问-Turbo(快速响应)或 通义千问-Plus(高质量输出)"}
|
||||
{form.app_type === "workflow" && "工作流型推荐 通义千问-Max(精确推理)或 通义千问-Plus(详细输出)"}
|
||||
{form.app_type === "chatbot" &&
|
||||
"对话型推荐 通义千问-Plus(16K输出,支持知识库RAG)或 通义千问-Max(高质量推理)"}
|
||||
{form.app_type === "agent" &&
|
||||
"智能体型推荐 通义千问-Plus(16K输出,支持长报告生成)"}
|
||||
{form.app_type === "completion" &&
|
||||
"补全型推荐 通义千问-Turbo(快速响应)或 通义千问-Plus(高质量输出)"}
|
||||
{form.app_type === "workflow" &&
|
||||
"工作流型推荐 通义千问-Max(精确推理)或 通义千问-Plus(详细输出)"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -1136,12 +1140,18 @@ export default function CreatePage() {
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<span>{form.model ? (modelLabels[form.model] || form.model) : "系统默认(通义千问-Plus)"}</span>
|
||||
<span>
|
||||
{form.model
|
||||
? modelLabels[form.model] || form.model
|
||||
: "系统默认(通义千问-Plus)"}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__default__">系统默认(通义千问-Plus)</SelectItem>
|
||||
{Object.entries(modelLabels).map(([k, v]) => (
|
||||
<SelectItem key={k} value={k}>{v}</SelectItem>
|
||||
<SelectItem key={k} value={k}>
|
||||
{v}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -1175,9 +1185,7 @@ export default function CreatePage() {
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>最大输出长度</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
单次回复的最大字符数(Token数)
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">单次回复的最大字符数(Token数)</p>
|
||||
<Input
|
||||
type="number"
|
||||
value={form.max_tokens}
|
||||
@@ -1199,15 +1207,8 @@ export default function CreatePage() {
|
||||
<Button variant="outline" onClick={closeForm}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!form.name.trim() || isPending}
|
||||
>
|
||||
{isPending
|
||||
? "保存中..."
|
||||
: editingId
|
||||
? "保存修改"
|
||||
: "创建应用"}
|
||||
<Button onClick={handleSave} disabled={!form.name.trim() || isPending}>
|
||||
{isPending ? "保存中..." : editingId ? "保存修改" : "创建应用"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
@@ -12,15 +12,7 @@ import { Textarea } from "@/components/ui/textarea";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { toast } from "sonner";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import {
|
||||
BookOpen,
|
||||
Upload,
|
||||
FileText,
|
||||
Trash2,
|
||||
Plus,
|
||||
Database,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
import { BookOpen, Upload, FileText, Trash2, Plus, Database, Search } from "lucide-react";
|
||||
|
||||
interface KnowledgeBase {
|
||||
id: string;
|
||||
@@ -64,9 +56,12 @@ function getStatusBadge(status: string) {
|
||||
|
||||
function getVisibilityLabel(v: string) {
|
||||
switch (v) {
|
||||
case "public": return "全单位";
|
||||
case "department": return "本科室";
|
||||
default: return "私有";
|
||||
case "public":
|
||||
return "全单位";
|
||||
case "department":
|
||||
return "本科室";
|
||||
default:
|
||||
return "私有";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,16 +142,19 @@ export default function KnowledgePage() {
|
||||
fileInputRef.current?.click();
|
||||
}, []);
|
||||
|
||||
const onFileChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
uploadDoc.mutate(file);
|
||||
e.target.value = "";
|
||||
}
|
||||
}, [uploadDoc]);
|
||||
const onFileChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
uploadDoc.mutate(file);
|
||||
e.target.value = "";
|
||||
}
|
||||
},
|
||||
[uploadDoc],
|
||||
);
|
||||
|
||||
const filteredKBs = knowledgeBases?.filter(
|
||||
(kb) => !searchTerm || kb.name.includes(searchTerm) || kb.description?.includes(searchTerm)
|
||||
(kb) => !searchTerm || kb.name.includes(searchTerm) || kb.description?.includes(searchTerm),
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -229,7 +227,9 @@ export default function KnowledgePage() {
|
||||
<BookOpen className="h-4 w-4 text-blue-600" />
|
||||
{kb.name}
|
||||
</CardTitle>
|
||||
<Badge variant="secondary" className="text-xs">{kb.document_count} 文档</Badge>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{kb.document_count} 文档
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -238,7 +238,9 @@ export default function KnowledgePage() {
|
||||
</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-xs">{getVisibilityLabel(kb.visibility)}</Badge>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{getVisibilityLabel(kb.visibility)}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(kb.updated_at).toLocaleDateString("zh-CN")}
|
||||
</span>
|
||||
@@ -275,7 +277,9 @@ export default function KnowledgePage() {
|
||||
<p className="text-sm text-muted-foreground mt-1">{selectedKB.description}</p>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Badge variant="outline">{getVisibilityLabel(selectedKB.visibility)}</Badge>
|
||||
<span className="text-xs text-muted-foreground">{selectedKB.document_count} 个文档</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{selectedKB.document_count} 个文档
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
@@ -286,7 +290,11 @@ export default function KnowledgePage() {
|
||||
accept=".txt,.md,.pdf,.docx,.csv,.xlsx"
|
||||
onChange={onFileChange}
|
||||
/>
|
||||
<Button onClick={handleFileUpload} disabled={uploadDoc.isPending} className="gap-2">
|
||||
<Button
|
||||
onClick={handleFileUpload}
|
||||
disabled={uploadDoc.isPending}
|
||||
className="gap-2"
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
{uploadDoc.isPending ? "上传中..." : "上传文档"}
|
||||
</Button>
|
||||
@@ -320,10 +328,16 @@ export default function KnowledgePage() {
|
||||
<FileText className="h-4 w-4 text-blue-500 shrink-0" />
|
||||
<span className="truncate max-w-[200px]">{doc.filename}</span>
|
||||
</td>
|
||||
<td className="p-3 text-muted-foreground uppercase text-xs">{doc.file_type || "-"}</td>
|
||||
<td className="p-3 text-muted-foreground">{formatFileSize(doc.file_size)}</td>
|
||||
<td className="p-3 text-muted-foreground uppercase text-xs">
|
||||
{doc.file_type || "-"}
|
||||
</td>
|
||||
<td className="p-3 text-muted-foreground">
|
||||
{formatFileSize(doc.file_size)}
|
||||
</td>
|
||||
<td className="p-3">{getStatusBadge(doc.status)}</td>
|
||||
<td className="p-3 text-muted-foreground text-xs">{new Date(doc.created_at).toLocaleString("zh-CN")}</td>
|
||||
<td className="p-3 text-muted-foreground text-xs">
|
||||
{new Date(doc.created_at).toLocaleString("zh-CN")}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -347,7 +361,7 @@ export default function KnowledgePage() {
|
||||
<CardContent className="py-20 text-center text-muted-foreground">
|
||||
<Database className="h-12 w-12 mx-auto text-muted-foreground/30 mb-4" />
|
||||
<p className="text-sm">请从左侧选择一个知识库查看文档</p>
|
||||
<p className="text-xs mt-1">或点击"新建知识库"创建政策法规资源库</p>
|
||||
<p className="text-xs mt-1">或点击「新建知识库」创建政策法规资源库</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
@@ -403,7 +417,9 @@ export default function KnowledgePage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="outline" onClick={() => setShowCreate(false)}>取消</Button>
|
||||
<Button variant="outline" onClick={() => setShowCreate(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => createKB.mutate()}
|
||||
disabled={!form.name.trim() || createKB.isPending}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
@@ -13,25 +14,11 @@ import { Separator } from "@/components/ui/separator";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Heart,
|
||||
MessageSquare,
|
||||
Star,
|
||||
Users,
|
||||
Clock,
|
||||
Play,
|
||||
} from "lucide-react";
|
||||
import { ArrowLeft, Heart, MessageSquare, Star, Users, Clock, Play } from "lucide-react";
|
||||
import { getCategoryIcon, getCategoryColor } from "@/lib/category-config";
|
||||
import { getAppTypeConfig } from "@/lib/app-type-config";
|
||||
|
||||
function StarRatingDisplay({
|
||||
rating,
|
||||
count,
|
||||
}: {
|
||||
rating: number;
|
||||
count: number;
|
||||
}) {
|
||||
function StarRatingDisplay({ rating, count }: { rating: number; count: number }) {
|
||||
const stars = Math.round(rating);
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
@@ -39,18 +26,12 @@ function StarRatingDisplay({
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Star
|
||||
key={i}
|
||||
className={`h-4 w-4 ${
|
||||
i < stars
|
||||
? "fill-amber-400 text-amber-400"
|
||||
: "text-gray-300"
|
||||
}`}
|
||||
className={`h-4 w-4 ${i < stars ? "fill-amber-400 text-amber-400" : "text-gray-300"}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-sm font-medium">{rating.toFixed(1)}</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
({count} 评分)
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">({count} 评分)</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -61,8 +42,7 @@ function RatingInput({ appId }: { appId: string }) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const rate = useMutation({
|
||||
mutationFn: (score: number) =>
|
||||
api.post(`/api/v1/apps/${appId}/rating`, { score }),
|
||||
mutationFn: (score: number) => api.post(`/api/v1/apps/${appId}/rating`, { score }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["appDetail"] });
|
||||
toast.success("评分成功");
|
||||
@@ -94,9 +74,7 @@ function RatingInput({ appId }: { appId: string }) {
|
||||
</button>
|
||||
))}
|
||||
{selected > 0 && (
|
||||
<span className="text-sm text-muted-foreground ml-1">
|
||||
已评 {selected} 分
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground ml-1">已评 {selected} 分</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -144,26 +122,20 @@ export default function AppDetailPage() {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-7xl px-6 lg:px-8 py-20 text-center">
|
||||
<p className="text-lg text-muted-foreground">应用不存在</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mt-4"
|
||||
onClick={() => router.push("/store")}
|
||||
>
|
||||
<Button variant="ghost" className="mt-4" onClick={() => router.push("/store")}>
|
||||
返回应用中心
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const CategoryIcon = getCategoryIcon(app.category_slug);
|
||||
const CategoryIconComponent = getCategoryIcon(app.category_slug);
|
||||
const categoryColor = getCategoryColor(app.category_slug);
|
||||
const isFavorited = app.is_favorited;
|
||||
const typeConfig = getAppTypeConfig(app.dify_app_type);
|
||||
const TypeIcon = typeConfig.icon;
|
||||
const TypeIconComponent = typeConfig.icon;
|
||||
|
||||
const longDesc = app.long_description
|
||||
? app.long_description.replace(/\\n/g, "\n")
|
||||
: null;
|
||||
const longDesc = app.long_description ? app.long_description.replace(/\\n/g, "\n") : null;
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-7xl px-6 lg:px-8 py-8">
|
||||
@@ -183,18 +155,20 @@ export default function AppDetailPage() {
|
||||
<div
|
||||
className={`flex h-16 w-16 items-center justify-center rounded-2xl ${categoryColor} shrink-0`}
|
||||
>
|
||||
<CategoryIcon className="h-8 w-8" />
|
||||
{CategoryIconComponent
|
||||
? React.createElement(CategoryIconComponent, { className: "h-8 w-8" })
|
||||
: null}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-2xl font-bold">{app.name}</h1>
|
||||
<p className="text-muted-foreground mt-1.5 leading-relaxed">
|
||||
{app.description}
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1.5 leading-relaxed">{app.description}</p>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 mt-3">
|
||||
<Badge variant="secondary">{app.category_name || "其他"}</Badge>
|
||||
<Badge className={`${typeConfig.badgeColor} gap-1`}>
|
||||
<TypeIcon className="h-3 w-3" />
|
||||
{TypeIconComponent
|
||||
? React.createElement(TypeIconComponent, { className: "h-3 w-3" })
|
||||
: null}
|
||||
{typeConfig.label}
|
||||
</Badge>
|
||||
<span className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
@@ -209,18 +183,14 @@ export default function AppDetailPage() {
|
||||
)}
|
||||
{app.published_at && (
|
||||
<span className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
v{app.version}
|
||||
<Clock className="h-3.5 w-3.5" />v{app.version}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{app.avg_rating > 0 && (
|
||||
<div className="mt-3">
|
||||
<StarRatingDisplay
|
||||
rating={app.avg_rating}
|
||||
count={app.rating_count}
|
||||
/>
|
||||
<StarRatingDisplay rating={app.avg_rating} count={app.rating_count} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -241,11 +211,7 @@ export default function AppDetailPage() {
|
||||
className="gap-2"
|
||||
onClick={() => toggleFav.mutate(!!isFavorited)}
|
||||
>
|
||||
<Heart
|
||||
className={`h-4 w-4 ${
|
||||
isFavorited ? "fill-red-500 text-red-500" : ""
|
||||
}`}
|
||||
/>
|
||||
<Heart className={`h-4 w-4 ${isFavorited ? "fill-red-500 text-red-500" : ""}`} />
|
||||
{isFavorited ? "已收藏" : "收藏"}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -261,7 +227,8 @@ export default function AppDetailPage() {
|
||||
<span className="inline-block w-1 h-5 bg-blue-800 rounded-full" />
|
||||
详细介绍
|
||||
</h2>
|
||||
<div className="prose prose-sm max-w-none dark:prose-invert
|
||||
<div
|
||||
className="prose prose-sm max-w-none dark:prose-invert
|
||||
prose-p:text-muted-foreground prose-p:leading-relaxed prose-p:my-2
|
||||
prose-headings:text-foreground prose-headings:font-semibold
|
||||
[&_h2]:text-base [&_h2]:mt-5 [&_h2]:mb-2 [&_h2]:flex [&_h2]:items-center [&_h2]:gap-2
|
||||
@@ -277,10 +244,9 @@ export default function AppDetailPage() {
|
||||
[&_li]:border [&_li]:border-blue-100 [&_li]:dark:border-blue-900/30
|
||||
[&_li:before]:content-['✦'] [&_li:before]:text-blue-600 [&_li:before]:text-xs [&_li:before]:mt-0.5 [&_li:before]:shrink-0
|
||||
[&_strong]:text-blue-800 [&_strong]:dark:text-blue-300
|
||||
">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{longDesc}
|
||||
</ReactMarkdown>
|
||||
"
|
||||
>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{longDesc}</ReactMarkdown>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -28,12 +28,13 @@ export default function CategoryPage() {
|
||||
const orgId = user?.org_id || "";
|
||||
const orgParam = orgId ? `&org_id=${orgId}` : "";
|
||||
const [sort, setSort] = useState<SortOption>(
|
||||
(searchParams.get("sort") as SortOption) || "popular"
|
||||
(searchParams.get("sort") as SortOption) || "popular",
|
||||
);
|
||||
|
||||
const { data: categories } = useQuery({
|
||||
queryKey: ["categories", orgId],
|
||||
queryFn: () => api.get<Category[]>(`/api/v1/store/categories?${orgId ? `org_id=${orgId}` : ""}`),
|
||||
queryFn: () =>
|
||||
api.get<Category[]>(`/api/v1/store/categories?${orgId ? `org_id=${orgId}` : ""}`),
|
||||
});
|
||||
|
||||
const currentCategory = categories?.find((c) => c.slug === slug);
|
||||
@@ -42,7 +43,7 @@ export default function CategoryPage() {
|
||||
queryKey: ["category-apps", slug, sort, orgId],
|
||||
queryFn: () =>
|
||||
api.get<{ items: App[] }>(
|
||||
`/api/v1/store/apps?category=${encodeURIComponent(slug)}&sort=${sort}&page_size=50${orgParam}`
|
||||
`/api/v1/store/apps?category=${encodeURIComponent(slug)}&sort=${sort}&page_size=50${orgParam}`,
|
||||
),
|
||||
});
|
||||
|
||||
@@ -57,13 +58,9 @@ export default function CategoryPage() {
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">
|
||||
{currentCategory?.name || slug}
|
||||
</h1>
|
||||
<h1 className="text-2xl font-bold">{currentCategory?.name || slug}</h1>
|
||||
{currentCategory?.description && (
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
{currentCategory.description}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">{currentCategory.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -76,16 +76,15 @@ export default function StorePage() {
|
||||
|
||||
const { data: searchResults, isLoading: searchLoading } = useQuery({
|
||||
queryKey: ["search", query, orgId],
|
||||
queryFn: () => api.get<{ items: App[] }>(`/api/v1/store/apps?q=${encodeURIComponent(query)}&${orgParam}`),
|
||||
queryFn: () =>
|
||||
api.get<{ items: App[] }>(`/api/v1/store/apps?q=${encodeURIComponent(query)}&${orgParam}`),
|
||||
enabled: !!query,
|
||||
});
|
||||
|
||||
if (query) {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-7xl px-3 md:px-6 lg:px-8 py-4 md:py-6">
|
||||
<h1 className="text-xl font-bold mb-4">
|
||||
搜索结果:“{query}”
|
||||
</h1>
|
||||
<h1 className="text-xl font-bold mb-4">搜索结果:“{query}”</h1>
|
||||
{searchLoading ? (
|
||||
<AppGridSkeleton count={8} />
|
||||
) : searchResults?.items?.length ? (
|
||||
@@ -95,9 +94,7 @@ export default function StorePage() {
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
未找到相关政务应用
|
||||
</div>
|
||||
<div className="text-center py-12 text-muted-foreground">未找到相关政务应用</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -124,7 +121,9 @@ export default function StorePage() {
|
||||
<SectionHeader title="应用分类" icon={LayoutGrid} />
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link href="/store">
|
||||
<Badge variant="default" className="cursor-pointer">全部</Badge>
|
||||
<Badge variant="default" className="cursor-pointer">
|
||||
全部
|
||||
</Badge>
|
||||
</Link>
|
||||
{categories?.map((cat) => (
|
||||
<Link key={cat.id} href={`/store/category/${cat.slug}`}>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { FileQuestion, Home, Search } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function GlobalNotFound() {
|
||||
return (
|
||||
<html lang="zh-CN" suppressHydrationWarning>
|
||||
<body>
|
||||
<div className="flex min-h-screen flex-col items-center justify-center px-4">
|
||||
<div className="flex flex-col items-center text-center max-w-md">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted mb-6">
|
||||
<FileQuestion className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold mb-2">页面未找到</h2>
|
||||
<p className="text-sm text-muted-foreground mb-6">
|
||||
您访问的页面不存在或已被移除,请检查链接是否正确。
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<Link href="/store">
|
||||
<Button className="gap-2">
|
||||
<Home className="h-4 w-4" />
|
||||
返回应用中心
|
||||
</Button>
|
||||
</Link>
|
||||
<Button variant="outline" className="gap-2" onClick={() => window.history.back()}>
|
||||
<Search className="h-4 w-4" />
|
||||
返回上一页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { toast } from "sonner";
|
||||
import { Star, Archive } from "lucide-react";
|
||||
import { Pagination } from "@/components/ui/pagination";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
draft: "草稿",
|
||||
@@ -99,7 +100,13 @@ export default function PlatformAppsPage() {
|
||||
<div className="flex flex-wrap items-center gap-3 mb-4">
|
||||
<Select value={orgFilter} onValueChange={resetOrg}>
|
||||
<SelectTrigger className="w-40">
|
||||
<span>{orgFilter === "all" ? "全部机构" : (orgs?.find((o) => o.id === orgFilter)?.short_name || orgs?.find((o) => o.id === orgFilter)?.name || orgFilter)}</span>
|
||||
<span>
|
||||
{orgFilter === "all"
|
||||
? "全部机构"
|
||||
: orgs?.find((o) => o.id === orgFilter)?.short_name ||
|
||||
orgs?.find((o) => o.id === orgFilter)?.name ||
|
||||
orgFilter}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部机构</SelectItem>
|
||||
@@ -112,7 +119,9 @@ export default function PlatformAppsPage() {
|
||||
</Select>
|
||||
<Select value={statusFilter} onValueChange={resetStatus}>
|
||||
<SelectTrigger className="w-32">
|
||||
<span>{statusFilter === "all" ? "全部状态" : (statusLabels[statusFilter] || statusFilter)}</span>
|
||||
<span>
|
||||
{statusFilter === "all" ? "全部状态" : statusLabels[statusFilter] || statusFilter}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部状态</SelectItem>
|
||||
@@ -132,7 +141,55 @@ export default function PlatformAppsPage() {
|
||||
onChange={setPage}
|
||||
/>
|
||||
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
{data?.items == null && !statusFilter && orgFilter === "all" ? (
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="text-left p-3">应用</th>
|
||||
<th className="text-left p-3">所属机构</th>
|
||||
<th className="text-left p-3">创建者</th>
|
||||
<th className="text-left p-3">状态</th>
|
||||
<th className="text-left p-3">使用次数</th>
|
||||
<th className="text-left p-3">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<tr key={i} className="border-t">
|
||||
<td className="p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-5 w-5 rounded" />
|
||||
<div className="space-y-1">
|
||||
<Skeleton className="h-3 w-28" />
|
||||
<Skeleton className="h-2 w-40" />
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Skeleton className="h-5 w-16 rounded" />
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Skeleton className="h-3 w-16" />
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Skeleton className="h-5 w-16 rounded" />
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Skeleton className="h-3 w-12" />
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<div className="flex gap-1">
|
||||
<Skeleton className="h-6 w-14 rounded" />
|
||||
<Skeleton className="h-6 w-16 rounded" />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
@@ -149,11 +206,17 @@ export default function PlatformAppsPage() {
|
||||
<tr key={app.id} className="border-t hover:bg-muted/30">
|
||||
<td className="p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<AppIcon iconUrl={app.icon_url} size={20} className="shrink-0 text-muted-foreground" />
|
||||
<AppIcon
|
||||
iconUrl={app.icon_url}
|
||||
size={20}
|
||||
className="shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium flex items-center gap-1">
|
||||
{app.name}
|
||||
{app.is_featured && <Star className="h-3 w-3 fill-yellow-400 text-yellow-400" />}
|
||||
{app.is_featured && (
|
||||
<Star className="h-3 w-3 fill-yellow-400 text-yellow-400" />
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground line-clamp-1">
|
||||
{app.description}
|
||||
@@ -206,8 +269,7 @@ export default function PlatformAppsPage() {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import api from "@/lib/api";
|
||||
import type { PlatformAuditLog, PlatformOrg } from "@/lib/types";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -66,7 +67,13 @@ export default function PlatformAuditPage() {
|
||||
/>
|
||||
<Select value={orgFilter} onValueChange={resetOrg}>
|
||||
<SelectTrigger className="w-40">
|
||||
<span>{orgFilter === "all" ? "全部机构" : (orgs?.find((o) => o.id === orgFilter)?.short_name || orgs?.find((o) => o.id === orgFilter)?.name || orgFilter)}</span>
|
||||
<span>
|
||||
{orgFilter === "all"
|
||||
? "全部机构"
|
||||
: orgs?.find((o) => o.id === orgFilter)?.short_name ||
|
||||
orgs?.find((o) => o.id === orgFilter)?.name ||
|
||||
orgFilter}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部机构</SelectItem>
|
||||
@@ -86,7 +93,46 @@ export default function PlatformAuditPage() {
|
||||
onChange={setPage}
|
||||
/>
|
||||
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
{data?.items == null && !search && orgFilter === "all" ? (
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="text-left p-3">时间</th>
|
||||
<th className="text-left p-3">机构</th>
|
||||
<th className="text-left p-3">用户</th>
|
||||
<th className="text-left p-3">操作</th>
|
||||
<th className="text-left p-3">资源</th>
|
||||
<th className="text-left p-3">IP</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
<tr key={i} className="border-t">
|
||||
<td className="p-3">
|
||||
<Skeleton className="h-3 w-32" />
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Skeleton className="h-5 w-16 rounded" />
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Skeleton className="h-8 w-24" />
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Skeleton className="h-3 w-20" />
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Skeleton className="h-3 w-20" />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
@@ -127,8 +173,7 @@ export default function PlatformAuditPage() {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -98,9 +98,7 @@ export default function PlatformLayout({ children }: { children: React.ReactNode
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-2 rounded-md text-sm transition-colors",
|
||||
pathname === item.href
|
||||
? "bg-amber-600 text-white"
|
||||
: "hover:bg-muted",
|
||||
pathname === item.href ? "bg-amber-600 text-white" : "hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
<item.icon className="h-4 w-4" />
|
||||
|
||||
@@ -9,12 +9,7 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -120,9 +115,7 @@ export default function PlatformOrgsPage() {
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">机构管理</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
管理平台所有入驻机构(委办局/单位)
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">管理平台所有入驻机构(委办局/单位)</p>
|
||||
</div>
|
||||
<Button onClick={() => setEditing({ ...emptyForm })} className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
@@ -130,19 +123,11 @@ export default function PlatformOrgsPage() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
page={page}
|
||||
pageSize={PAGE_SIZE}
|
||||
total={orgs?.length ?? 0}
|
||||
onChange={setPage}
|
||||
/>
|
||||
<Pagination page={page} pageSize={PAGE_SIZE} total={orgs?.length ?? 0} onChange={setPage} />
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{pagedOrgs?.map((org) => (
|
||||
<div
|
||||
key={org.id}
|
||||
className="border rounded-lg p-4 hover:shadow-sm transition-shadow"
|
||||
>
|
||||
<div key={org.id} className="border rounded-lg p-4 hover:shadow-sm transition-shadow">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-9 w-9 rounded-md bg-amber-50 flex items-center justify-center">
|
||||
@@ -158,9 +143,7 @@ export default function PlatformOrgsPage() {
|
||||
</Badge>
|
||||
</div>
|
||||
{org.description && (
|
||||
<p className="text-xs text-muted-foreground line-clamp-2 mb-3">
|
||||
{org.description}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground line-clamp-2 mb-3">{org.description}</p>
|
||||
)}
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground mb-3">
|
||||
<span>{org.user_count} 用户</span>
|
||||
@@ -274,10 +257,7 @@ export default function PlatformOrgsPage() {
|
||||
<Button variant="outline" onClick={() => setEditing(null)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={create.isPending || update.isPending}
|
||||
>
|
||||
<Button onClick={handleSubmit} disabled={create.isPending || update.isPending}>
|
||||
{editing?.id ? "保存" : "创建"}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -291,7 +271,8 @@ export default function PlatformOrgsPage() {
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除机构</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
将永久删除「{deleteTarget?.name}」。如果该机构下还有用户或应用,删除会被拒绝,请先迁移或停用。
|
||||
将永久删除「{deleteTarget?.name}
|
||||
」。如果该机构下还有用户或应用,删除会被拒绝,请先迁移或停用。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
|
||||
@@ -9,12 +9,7 @@ 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 {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -43,7 +38,7 @@ const emptyForm: ProviderForm = {
|
||||
name: "",
|
||||
base_url: "",
|
||||
api_key: "",
|
||||
models: '[]',
|
||||
models: "[]",
|
||||
is_active: true,
|
||||
priority: 0,
|
||||
};
|
||||
@@ -130,7 +125,7 @@ export default function PlatformProvidersPage() {
|
||||
const testConnection = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
api.post<{ success: boolean; message?: string; response?: string }>(
|
||||
`/api/v1/platform/providers/${id}/test`
|
||||
`/api/v1/platform/providers/${id}/test`,
|
||||
),
|
||||
onSuccess: (data) => {
|
||||
if (data.success) {
|
||||
@@ -211,7 +206,11 @@ export default function PlatformProvidersPage() {
|
||||
) : (
|
||||
<XCircle className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
)}
|
||||
<span className={p.is_active ? "text-green-700 dark:text-green-400" : "text-muted-foreground"}>
|
||||
<span
|
||||
className={
|
||||
p.is_active ? "text-green-700 dark:text-green-400" : "text-muted-foreground"
|
||||
}
|
||||
>
|
||||
{p.is_active ? "已启用" : "已停用"}
|
||||
</span>
|
||||
</div>
|
||||
@@ -221,7 +220,11 @@ export default function PlatformProvidersPage() {
|
||||
<div className="flex flex-wrap gap-1 mb-3 min-h-[1.5rem]">
|
||||
{Array.isArray(p.models) && p.models.length > 0 ? (
|
||||
p.models.slice(0, 6).map((m) => (
|
||||
<Badge key={m.name} variant="outline" className="text-[10px] py-0 px-1.5 font-mono">
|
||||
<Badge
|
||||
key={m.name}
|
||||
variant="outline"
|
||||
className="text-[10px] py-0 px-1.5 font-mono"
|
||||
>
|
||||
{m.display_name || m.name}
|
||||
</Badge>
|
||||
))
|
||||
@@ -229,7 +232,9 @@ export default function PlatformProvidersPage() {
|
||||
<span className="text-xs text-muted-foreground italic">暂无模型配置</span>
|
||||
)}
|
||||
{Array.isArray(p.models) && p.models.length > 6 && (
|
||||
<span className="text-[10px] text-muted-foreground self-center">+{p.models.length - 6} 个</span>
|
||||
<span className="text-[10px] text-muted-foreground self-center">
|
||||
+{p.models.length - 6} 个
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
|
||||
@@ -8,21 +8,12 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from "@/components/ui/select";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
|
||||
import { toast } from "sonner";
|
||||
import { Plus, Pencil, Trash2 } from "lucide-react";
|
||||
import { Pagination } from "@/components/ui/pagination";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
interface QuotaForm {
|
||||
id?: string;
|
||||
@@ -81,9 +72,10 @@ export default function PlatformQuotasPage() {
|
||||
api.get<{ items: PlatformUser[] }>("/api/v1/platform/users?page=1&page_size=200"),
|
||||
});
|
||||
|
||||
const allModels = providersData
|
||||
?.flatMap((p) => (Array.isArray(p.models) ? p.models : []))
|
||||
.filter((m, i, arr) => arr.findIndex((x) => x.name === m.name) === i) ?? [];
|
||||
const allModels =
|
||||
providersData
|
||||
?.flatMap((p) => (Array.isArray(p.models) ? p.models : []))
|
||||
.filter((m, i, arr) => arr.findIndex((x) => x.name === m.name) === i) ?? [];
|
||||
|
||||
const pagedQuotas = quotas?.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
||||
|
||||
@@ -133,12 +125,7 @@ export default function PlatformQuotasPage() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
page={page}
|
||||
pageSize={PAGE_SIZE}
|
||||
total={quotas?.length ?? 0}
|
||||
onChange={setPage}
|
||||
/>
|
||||
<Pagination page={page} pageSize={PAGE_SIZE} total={quotas?.length ?? 0} onChange={setPage} />
|
||||
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
@@ -155,7 +142,39 @@ export default function PlatformQuotasPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pagedQuotas?.length ? (
|
||||
{quotas == null ? (
|
||||
Array.from({ length: 8 }).map((_, i) => (
|
||||
<tr key={i} className="border-t">
|
||||
<td className="p-3">
|
||||
<Skeleton className="h-5 w-12 rounded" />
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Skeleton className="h-3 w-20" />
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Skeleton className="h-3 w-12" />
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Skeleton className="h-3 w-12" />
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Skeleton className="h-3 w-12" />
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Skeleton className="h-5 w-10 rounded" />
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<div className="flex gap-1">
|
||||
<Skeleton className="h-6 w-12 rounded" />
|
||||
<Skeleton className="h-6 w-12 rounded" />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : pagedQuotas?.length ? (
|
||||
pagedQuotas.map((q) => (
|
||||
<tr key={q.id} className="border-t">
|
||||
<td className="p-3">
|
||||
@@ -236,8 +255,7 @@ export default function PlatformQuotasPage() {
|
||||
<Select
|
||||
value={editing?.target_type || "global"}
|
||||
onValueChange={(v) =>
|
||||
v &&
|
||||
setEditing({ ...editing!, target_type: v as QuotaForm["target_type"] })
|
||||
v && setEditing({ ...editing!, target_type: v as QuotaForm["target_type"] })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
@@ -260,9 +278,10 @@ export default function PlatformQuotasPage() {
|
||||
<SelectTrigger>
|
||||
<span>
|
||||
{editing?.target_id
|
||||
? (usersData?.items?.find((u) => u.id === editing.target_id)?.name +
|
||||
? usersData?.items?.find((u) => u.id === editing.target_id)?.name +
|
||||
" (" +
|
||||
(usersData?.items?.find((u) => u.id === editing.target_id)?.email ?? "") + ")")
|
||||
(usersData?.items?.find((u) => u.id === editing.target_id)?.email ?? "") +
|
||||
")"
|
||||
: "请选择用户"}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
@@ -298,8 +317,8 @@ export default function PlatformQuotasPage() {
|
||||
<SelectTrigger>
|
||||
<span>
|
||||
{editing?.model_name
|
||||
? (allModels.find((m) => m.name === editing.model_name)?.display_name ||
|
||||
editing.model_name)
|
||||
? allModels.find((m) => m.name === editing.model_name)?.display_name ||
|
||||
editing.model_name
|
||||
: "所有模型"}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
@@ -319,9 +338,7 @@ export default function PlatformQuotasPage() {
|
||||
<Input
|
||||
type="number"
|
||||
value={editing?.daily_token_limit || ""}
|
||||
onChange={(e) =>
|
||||
setEditing({ ...editing!, daily_token_limit: e.target.value })
|
||||
}
|
||||
onChange={(e) => setEditing({ ...editing!, daily_token_limit: e.target.value })}
|
||||
placeholder="留空=不限"
|
||||
/>
|
||||
</div>
|
||||
@@ -330,9 +347,7 @@ export default function PlatformQuotasPage() {
|
||||
<Input
|
||||
type="number"
|
||||
value={editing?.monthly_token_limit || ""}
|
||||
onChange={(e) =>
|
||||
setEditing({ ...editing!, monthly_token_limit: e.target.value })
|
||||
}
|
||||
onChange={(e) => setEditing({ ...editing!, monthly_token_limit: e.target.value })}
|
||||
placeholder="留空=不限"
|
||||
/>
|
||||
</div>
|
||||
@@ -342,9 +357,7 @@ export default function PlatformQuotasPage() {
|
||||
<Input
|
||||
type="number"
|
||||
value={editing?.daily_request_limit || ""}
|
||||
onChange={(e) =>
|
||||
setEditing({ ...editing!, daily_request_limit: e.target.value })
|
||||
}
|
||||
onChange={(e) => setEditing({ ...editing!, daily_request_limit: e.target.value })}
|
||||
placeholder="留空=不限"
|
||||
/>
|
||||
</div>
|
||||
@@ -364,10 +377,7 @@ export default function PlatformQuotasPage() {
|
||||
<Button variant="outline" onClick={() => setEditing(null)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => editing && upsert.mutate(editing)}
|
||||
disabled={upsert.isPending}
|
||||
>
|
||||
<Button onClick={() => editing && upsert.mutate(editing)} disabled={upsert.isPending}>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -15,12 +15,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { toast } from "sonner";
|
||||
import { Building2 } from "lucide-react";
|
||||
import { Pagination } from "@/components/ui/pagination";
|
||||
@@ -120,9 +115,7 @@ export default function PlatformUsersPage() {
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h1 className="text-2xl font-bold">全局用户</h1>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
共 {data?.total ?? 0} 条
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">共 {data?.total ?? 0} 条</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 mb-4">
|
||||
@@ -134,7 +127,13 @@ export default function PlatformUsersPage() {
|
||||
/>
|
||||
<Select value={orgFilter} onValueChange={resetOrg}>
|
||||
<SelectTrigger className="w-40">
|
||||
<span>{orgFilter === "all" ? "全部机构" : (orgs?.find((o) => o.id === orgFilter)?.short_name || orgs?.find((o) => o.id === orgFilter)?.name || orgFilter)}</span>
|
||||
<span>
|
||||
{orgFilter === "all"
|
||||
? "全部机构"
|
||||
: orgs?.find((o) => o.id === orgFilter)?.short_name ||
|
||||
orgs?.find((o) => o.id === orgFilter)?.name ||
|
||||
orgFilter}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部机构</SelectItem>
|
||||
@@ -147,7 +146,7 @@ export default function PlatformUsersPage() {
|
||||
</Select>
|
||||
<Select value={roleFilter} onValueChange={resetRole}>
|
||||
<SelectTrigger className="w-36">
|
||||
<span>{roleFilter === "all" ? "全部角色" : (roleLabels[roleFilter] || roleFilter)}</span>
|
||||
<span>{roleFilter === "all" ? "全部角色" : roleLabels[roleFilter] || roleFilter}</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部角色</SelectItem>
|
||||
@@ -211,9 +210,7 @@ export default function PlatformUsersPage() {
|
||||
{u.status === "active" ? "正常" : "禁用"}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-3 text-muted-foreground text-xs">
|
||||
{u.login_count} 次
|
||||
</td>
|
||||
<td className="p-3 text-muted-foreground text-xs">{u.login_count} 次</td>
|
||||
<td className="p-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Select
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -21,9 +22,7 @@ function StarRating({ rating }: { rating: number }) {
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Star
|
||||
key={i}
|
||||
className={`h-3 w-3 ${
|
||||
i < stars ? "fill-amber-400 text-amber-400" : "text-gray-300"
|
||||
}`}
|
||||
className={`h-3 w-3 ${i < stars ? "fill-amber-400 text-amber-400" : "text-gray-300"}`}
|
||||
/>
|
||||
))}
|
||||
<span className="ml-0.5 text-muted-foreground">{rating.toFixed(1)}</span>
|
||||
@@ -32,10 +31,10 @@ function StarRating({ rating }: { rating: number }) {
|
||||
}
|
||||
|
||||
export function AppCard({ app }: { app: App }) {
|
||||
const Icon = getCategoryIcon(app.category_slug);
|
||||
const IconComponent = getCategoryIcon(app.category_slug);
|
||||
const colorClass = getCategoryColor(app.category_slug);
|
||||
const typeConfig = getAppTypeConfig(app.dify_app_type);
|
||||
const TypeIcon = typeConfig.icon;
|
||||
const TypeIconComponent = typeConfig.icon;
|
||||
|
||||
return (
|
||||
<Link href={`/store/apps/${app.slug}`}>
|
||||
@@ -46,7 +45,7 @@ export function AppCard({ app }: { app: App }) {
|
||||
<div
|
||||
className={`flex h-10 w-10 items-center justify-center rounded-xl ${colorClass} shrink-0`}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
{IconComponent ? React.createElement(IconComponent, { className: "h-5 w-5" }) : null}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-sm truncate group-hover:text-blue-800 transition-colors">
|
||||
@@ -62,8 +61,12 @@ export function AppCard({ app }: { app: App }) {
|
||||
<Badge variant="secondary" className="text-xs font-normal">
|
||||
{app.category_name || "其他"}
|
||||
</Badge>
|
||||
<span className={`inline-flex items-center gap-0.5 text-[10px] px-1.5 py-0.5 rounded-full ${typeConfig.badgeColor}`}>
|
||||
<TypeIcon className="h-2.5 w-2.5" />
|
||||
<span
|
||||
className={`inline-flex items-center gap-0.5 text-[10px] px-1.5 py-0.5 rounded-full ${typeConfig.badgeColor}`}
|
||||
>
|
||||
{TypeIconComponent
|
||||
? React.createElement(TypeIconComponent, { className: "h-2.5 w-2.5" })
|
||||
: null}
|
||||
{typeConfig.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useState, useRef, useEffect, useCallback, memo, useMemo } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
@@ -56,7 +57,10 @@ function parseToolCalls(content: string): { cleanContent: string; tools: ToolCal
|
||||
const tool = tools.find((t) => t.name === name);
|
||||
if (tool) tool.status = "done";
|
||||
}
|
||||
cleanContent = cleanContent.replace(/\[工具调用:\s*.+?\]/g, "").replace(/\[工具结果:\s*.+?\]/g, "").trim();
|
||||
cleanContent = cleanContent
|
||||
.replace(/\[工具调用:\s*.+?\]/g, "")
|
||||
.replace(/\[工具结果:\s*.+?\]/g, "")
|
||||
.trim();
|
||||
return { cleanContent, tools };
|
||||
}
|
||||
|
||||
@@ -96,10 +100,16 @@ const AgentMessage = memo(function AgentMessage({
|
||||
<div
|
||||
key={tool.name}
|
||||
className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs ${
|
||||
tool.status === "done" ? "bg-emerald-50 text-emerald-700" : "bg-amber-50 text-amber-700"
|
||||
tool.status === "done"
|
||||
? "bg-emerald-50 text-emerald-700"
|
||||
: "bg-amber-50 text-amber-700"
|
||||
}`}
|
||||
>
|
||||
{tool.status === "done" ? <CheckCircle2 className="h-3 w-3" /> : <Loader2 className="h-3 w-3 animate-spin" />}
|
||||
{tool.status === "done" ? (
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
) : (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
)}
|
||||
{tool.name}
|
||||
</div>
|
||||
))}
|
||||
@@ -130,7 +140,11 @@ interface AgentUIProps {
|
||||
export default function AgentUI({ app }: AgentUIProps) {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [messages, setMessages] = useState<Message[]>(
|
||||
app.welcome_message && !conversationId
|
||||
? [{ id: "welcome", role: "assistant", content: app.welcome_message }]
|
||||
: [],
|
||||
);
|
||||
const [input, setInput] = useState("");
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [conversationId, setConversationId] = useState<string | undefined>();
|
||||
@@ -153,7 +167,9 @@ export default function AgentUI({ app }: AgentUIProps) {
|
||||
try {
|
||||
if (typeof app.app_config === "string") return JSON.parse(app.app_config);
|
||||
return app.app_config || {};
|
||||
} catch { return {}; }
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}, [app.app_config]);
|
||||
|
||||
const tools: string[] = appConfig.tools || [];
|
||||
@@ -171,39 +187,43 @@ export default function AgentUI({ app }: AgentUIProps) {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, []);
|
||||
|
||||
useEffect(() => { scrollToBottom(); }, [messages, scrollToBottom]);
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages, scrollToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
if (app.welcome_message && messages.length === 0 && !conversationId) {
|
||||
setMessages([{ id: "welcome", role: "assistant", content: app.welcome_message }]);
|
||||
}
|
||||
}, [app.welcome_message, messages.length, conversationId]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => { abortRef.current?.abort(); };
|
||||
return () => {
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const loadConversation = useCallback(async (convId: string) => {
|
||||
setConversationId(convId);
|
||||
try {
|
||||
const data = await api.get<{ data: Message[] }>(`/api/v1/apps/${app.id}/conversations/${convId}/messages`);
|
||||
setMessages(data.data || []);
|
||||
} catch {
|
||||
setMessages([]);
|
||||
}
|
||||
}, [app.id]);
|
||||
const loadConversation = useCallback(
|
||||
async (convId: string) => {
|
||||
setConversationId(convId);
|
||||
try {
|
||||
const data = await api.get<{ data: Message[] }>(
|
||||
`/api/v1/apps/${app.id}/conversations/${convId}/messages`,
|
||||
);
|
||||
setMessages(data.data || []);
|
||||
} catch {
|
||||
setMessages([]);
|
||||
}
|
||||
},
|
||||
[app.id],
|
||||
);
|
||||
|
||||
const CategoryIcon = getCategoryIcon(app.category_slug);
|
||||
const CategoryIconComponent = getCategoryIcon(app.category_slug);
|
||||
const categoryColor = getCategoryColor(app.category_slug);
|
||||
|
||||
const suggestedPrompts = useRef(
|
||||
(() => {
|
||||
try {
|
||||
if (typeof app.suggested_prompts === "string") return JSON.parse(app.suggested_prompts) as string[];
|
||||
return (app.suggested_prompts as string[]) || [];
|
||||
} catch { return []; }
|
||||
})()
|
||||
).current;
|
||||
const suggestedPrompts = useMemo(() => {
|
||||
try {
|
||||
if (typeof app.suggested_prompts === "string")
|
||||
return JSON.parse(app.suggested_prompts) as string[];
|
||||
return (app.suggested_prompts as string[]) || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}, [app.suggested_prompts]);
|
||||
|
||||
const copyText = useCallback((text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
@@ -275,13 +295,13 @@ export default function AgentUI({ app }: AgentUIProps) {
|
||||
const snap = accumulated;
|
||||
setMessages((prev) =>
|
||||
prev.map((m, i) =>
|
||||
i === prev.length - 1 && m.role === "assistant"
|
||||
? { ...m, content: snap }
|
||||
: m
|
||||
)
|
||||
i === prev.length - 1 && m.role === "assistant" ? { ...m, content: snap } : m,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
} catch {
|
||||
/* skip */
|
||||
}
|
||||
}
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ["conversations", app.id] });
|
||||
@@ -291,8 +311,8 @@ export default function AgentUI({ app }: AgentUIProps) {
|
||||
prev.map((m, i) =>
|
||||
i === prev.length - 1 && m.role === "assistant" && !m.content
|
||||
? { ...m, content: "抱歉,系统处理异常,请稍后重试。" }
|
||||
: m
|
||||
)
|
||||
: m,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
abortRef.current = null;
|
||||
@@ -300,9 +320,15 @@ export default function AgentUI({ app }: AgentUIProps) {
|
||||
}
|
||||
}, [input, isStreaming, app.id, conversationId, queryClient]);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendMessage(); }
|
||||
}, [sendMessage]);
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
},
|
||||
[sendMessage],
|
||||
);
|
||||
|
||||
const startNewConversation = useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
@@ -324,15 +350,20 @@ export default function AgentUI({ app }: AgentUIProps) {
|
||||
setSelectedIds(new Set(conversations.map((c) => c.id)));
|
||||
}, [conversations]);
|
||||
|
||||
const confirmDeleteSingle = useCallback(async (convId: string) => {
|
||||
try {
|
||||
await api.delete(`/api/v1/apps/${app.id}/conversations/${convId}`);
|
||||
if (conversationId === convId) startNewConversation();
|
||||
queryClient.invalidateQueries({ queryKey: ["conversations", app.id] });
|
||||
toast.success("对话已删除");
|
||||
} catch { toast.error("删除失败"); }
|
||||
setDeleteTarget(null);
|
||||
}, [app.id, conversationId, startNewConversation, queryClient]);
|
||||
const confirmDeleteSingle = useCallback(
|
||||
async (convId: string) => {
|
||||
try {
|
||||
await api.delete(`/api/v1/apps/${app.id}/conversations/${convId}`);
|
||||
if (conversationId === convId) startNewConversation();
|
||||
queryClient.invalidateQueries({ queryKey: ["conversations", app.id] });
|
||||
toast.success("对话已删除");
|
||||
} catch {
|
||||
toast.error("删除失败");
|
||||
}
|
||||
setDeleteTarget(null);
|
||||
},
|
||||
[app.id, conversationId, startNewConversation, queryClient],
|
||||
);
|
||||
|
||||
const confirmBatchDelete = useCallback(async () => {
|
||||
if (selectedIds.size === 0) return;
|
||||
@@ -345,18 +376,17 @@ export default function AgentUI({ app }: AgentUIProps) {
|
||||
setSelectMode(false);
|
||||
queryClient.invalidateQueries({ queryKey: ["conversations", app.id] });
|
||||
toast.success(`已删除 ${selectedIds.size} 个对话`);
|
||||
} catch { toast.error("批量删除失败"); }
|
||||
} catch {
|
||||
toast.error("批量删除失败");
|
||||
}
|
||||
setDeleteTarget(null);
|
||||
}, [selectedIds, app.id, conversationId, startNewConversation, queryClient]);
|
||||
|
||||
const startRename = useCallback(
|
||||
(convId: string, currentName: string) => {
|
||||
setEditingConvId(convId);
|
||||
setEditingName(currentName);
|
||||
setTimeout(() => renameInputRef.current?.focus(), 50);
|
||||
},
|
||||
[]
|
||||
);
|
||||
const startRename = useCallback((convId: string, currentName: string) => {
|
||||
setEditingConvId(convId);
|
||||
setEditingName(currentName);
|
||||
setTimeout(() => renameInputRef.current?.focus(), 50);
|
||||
}, []);
|
||||
|
||||
const saveRename = useCallback(async () => {
|
||||
if (!editingConvId || !editingName.trim()) {
|
||||
@@ -364,10 +394,9 @@ export default function AgentUI({ app }: AgentUIProps) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.put(
|
||||
`/api/v1/apps/${app.id}/conversations/${editingConvId}/name`,
|
||||
{ name: editingName.trim() }
|
||||
);
|
||||
await api.put(`/api/v1/apps/${app.id}/conversations/${editingConvId}/name`, {
|
||||
name: editingName.trim(),
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ["conversations", app.id] });
|
||||
toast.success("已重命名");
|
||||
} catch {
|
||||
@@ -379,10 +408,7 @@ export default function AgentUI({ app }: AgentUIProps) {
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-3.5rem)] overflow-hidden">
|
||||
{/* 删除确认弹窗 */}
|
||||
<AlertDialog
|
||||
open={!!deleteTarget}
|
||||
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
||||
>
|
||||
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
||||
@@ -419,12 +445,23 @@ export default function AgentUI({ app }: AgentUIProps) {
|
||||
)}
|
||||
|
||||
{/* 侧边栏 */}
|
||||
<div className={`fixed inset-y-[3.5rem] left-0 z-50 w-64 border-r bg-background flex flex-col shrink-0 overflow-hidden transition-transform duration-200 md:static md:inset-y-0 md:translate-x-0 ${sidebarOpen ? "translate-x-0" : "-translate-x-full"}`}>
|
||||
<div
|
||||
className={`fixed inset-y-[3.5rem] left-0 z-50 w-64 border-r bg-background flex flex-col shrink-0 overflow-hidden transition-transform duration-200 md:static md:inset-y-0 md:translate-x-0 ${sidebarOpen ? "translate-x-0" : "-translate-x-full"}`}
|
||||
>
|
||||
<div className="p-3 border-b space-y-2 shrink-0">
|
||||
<Button variant="ghost" size="sm" className="w-full justify-start gap-1.5 text-muted-foreground" onClick={() => router.push("/store")}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start gap-1.5 text-muted-foreground"
|
||||
onClick={() => router.push("/store")}
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" /> 返回应用中心
|
||||
</Button>
|
||||
<Button onClick={startNewConversation} className="w-full gap-1.5 bg-blue-900 hover:bg-blue-800 text-white" size="sm">
|
||||
<Button
|
||||
onClick={startNewConversation}
|
||||
className="w-full gap-1.5 bg-blue-900 hover:bg-blue-800 text-white"
|
||||
size="sm"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" /> 新对话
|
||||
</Button>
|
||||
</div>
|
||||
@@ -453,7 +490,14 @@ export default function AgentUI({ app }: AgentUIProps) {
|
||||
<div className="flex items-center gap-1">
|
||||
{selectMode ? (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" className="h-6 px-1.5 text-xs" onClick={selectAll}>全选</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-1.5 text-xs"
|
||||
onClick={selectAll}
|
||||
>
|
||||
全选
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -463,12 +507,25 @@ export default function AgentUI({ app }: AgentUIProps) {
|
||||
>
|
||||
<Trash2 className="h-3 w-3 mr-0.5" /> 删除
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-6 px-1.5 text-xs" onClick={() => { setSelectMode(false); setSelectedIds(new Set()); }}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-1.5 text-xs"
|
||||
onClick={() => {
|
||||
setSelectMode(false);
|
||||
setSelectedIds(new Set());
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button variant="ghost" size="sm" className="h-6 px-1.5 text-xs" onClick={() => setSelectMode(true)}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-1.5 text-xs"
|
||||
onClick={() => setSelectMode(true)}
|
||||
>
|
||||
<CheckSquare className="h-3 w-3 mr-0.5" /> 管理
|
||||
</Button>
|
||||
)}
|
||||
@@ -485,7 +542,11 @@ export default function AgentUI({ app }: AgentUIProps) {
|
||||
<div key={conv.id} className="group flex items-center gap-1">
|
||||
{selectMode && (
|
||||
<button onClick={() => toggleSelect(conv.id)} className="shrink-0 p-0.5">
|
||||
{selectedIds.has(conv.id) ? <CheckSquare className="h-3.5 w-3.5 text-primary" /> : <Square className="h-3.5 w-3.5 text-muted-foreground" />}
|
||||
{selectedIds.has(conv.id) ? (
|
||||
<CheckSquare className="h-3.5 w-3.5 text-primary" />
|
||||
) : (
|
||||
<Square className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{editingConvId === conv.id ? (
|
||||
@@ -554,12 +615,18 @@ export default function AgentUI({ app }: AgentUIProps) {
|
||||
>
|
||||
<PanelLeftOpen className="h-4 w-4" />
|
||||
</button>
|
||||
<div className={`flex h-9 w-9 items-center justify-center rounded-xl ${categoryColor} shrink-0`}>
|
||||
<CategoryIcon className="h-4.5 w-4.5" />
|
||||
<div
|
||||
className={`flex h-9 w-9 items-center justify-center rounded-xl ${categoryColor} shrink-0`}
|
||||
>
|
||||
{CategoryIconComponent
|
||||
? React.createElement(CategoryIconComponent, { className: "h-4.5 w-4.5" })
|
||||
: null}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h1 className="font-semibold text-sm truncate">{app.name}</h1>
|
||||
<p className="text-xs text-muted-foreground truncate max-w-[150px] md:max-w-none">{app.description}</p>
|
||||
<p className="text-xs text-muted-foreground truncate max-w-[150px] md:max-w-none">
|
||||
{app.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-1 md:gap-2">
|
||||
{messages.length > 1 && (
|
||||
@@ -586,7 +653,16 @@ export default function AgentUI({ app }: AgentUIProps) {
|
||||
{messages.length <= 1 && suggestedPrompts.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mt-4">
|
||||
{suggestedPrompts.map((prompt: string, i: number) => (
|
||||
<Button key={i} variant="outline" size="sm" className="text-xs" onClick={() => { setInput(prompt); textareaRef.current?.focus(); }}>
|
||||
<Button
|
||||
key={i}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
onClick={() => {
|
||||
setInput(prompt);
|
||||
textareaRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
{prompt}
|
||||
</Button>
|
||||
))}
|
||||
@@ -608,8 +684,16 @@ export default function AgentUI({ app }: AgentUIProps) {
|
||||
rows={1}
|
||||
disabled={isStreaming}
|
||||
/>
|
||||
<Button onClick={sendMessage} disabled={!input.trim() || isStreaming} className="shrink-0 gap-1.5">
|
||||
{isStreaming ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
||||
<Button
|
||||
onClick={sendMessage}
|
||||
disabled={!input.trim() || isStreaming}
|
||||
className="shrink-0 gap-1.5"
|
||||
>
|
||||
{isStreaming ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)}
|
||||
{isStreaming ? "思考中" : "发送"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useState,
|
||||
useRef,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useMemo,
|
||||
memo,
|
||||
} from "react";
|
||||
import React from "react";
|
||||
import { useState, useRef, useEffect, useCallback, useMemo, memo } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type {
|
||||
@@ -242,24 +236,20 @@ export default function AnalysisUI({ app }: AnalysisUIProps) {
|
||||
const { data: templates = [], isLoading: templatesLoading } = useQuery({
|
||||
queryKey: ["analysis-templates"],
|
||||
queryFn: () =>
|
||||
api.get<{ data: AnalysisTemplate[] }>("/api/v1/analysis-templates").then(
|
||||
(r) => r.data || []
|
||||
),
|
||||
api.get<{ data: AnalysisTemplate[] }>("/api/v1/analysis-templates").then((r) => r.data || []),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const { data: conversations = [] } = useQuery({
|
||||
queryKey: ["conversations", app.id],
|
||||
queryFn: async () => {
|
||||
const data = await api.get<{ data: Conversation[] }>(
|
||||
`/api/v1/apps/${app.id}/conversations`
|
||||
);
|
||||
const data = await api.get<{ data: Conversation[] }>(`/api/v1/apps/${app.id}/conversations`);
|
||||
return data.data || [];
|
||||
},
|
||||
staleTime: 10_000,
|
||||
});
|
||||
|
||||
const CategoryIcon = getCategoryIcon(app.category_slug);
|
||||
const CategoryIconComponent = getCategoryIcon(app.category_slug);
|
||||
const categoryColor = getCategoryColor(app.category_slug);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
@@ -277,30 +267,24 @@ export default function AnalysisUI({ app }: AnalysisUIProps) {
|
||||
}, []);
|
||||
|
||||
/* ---------- template selection ---------- */
|
||||
const selectTemplate = useCallback(
|
||||
(tpl: AnalysisTemplate) => {
|
||||
setSelectedTemplate(tpl);
|
||||
const defaults: Record<string, string | string[]> = {};
|
||||
tpl.steps.forEach((s) =>
|
||||
s.fields.forEach((f) => {
|
||||
if (f.type === "multiselect") defaults[f.key] = [];
|
||||
else defaults[f.key] = f.default || "";
|
||||
})
|
||||
);
|
||||
setFieldData(defaults);
|
||||
setCurrentStep(0);
|
||||
setPhase("wizard");
|
||||
},
|
||||
[]
|
||||
);
|
||||
const selectTemplate = useCallback((tpl: AnalysisTemplate) => {
|
||||
setSelectedTemplate(tpl);
|
||||
const defaults: Record<string, string | string[]> = {};
|
||||
tpl.steps.forEach((s) =>
|
||||
s.fields.forEach((f) => {
|
||||
if (f.type === "multiselect") defaults[f.key] = [];
|
||||
else defaults[f.key] = f.default || "";
|
||||
}),
|
||||
);
|
||||
setFieldData(defaults);
|
||||
setCurrentStep(0);
|
||||
setPhase("wizard");
|
||||
}, []);
|
||||
|
||||
/* ---------- field updates ---------- */
|
||||
const updateField = useCallback(
|
||||
(key: string, value: string | string[]) => {
|
||||
setFieldData((prev) => ({ ...prev, [key]: value }));
|
||||
},
|
||||
[]
|
||||
);
|
||||
const updateField = useCallback((key: string, value: string | string[]) => {
|
||||
setFieldData((prev) => ({ ...prev, [key]: value }));
|
||||
}, []);
|
||||
|
||||
/* ---------- step validation ---------- */
|
||||
const isStepValid = useMemo(() => {
|
||||
@@ -324,9 +308,7 @@ export default function AnalysisUI({ app }: AnalysisUIProps) {
|
||||
const flatData: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(fieldData)) {
|
||||
if (Array.isArray(v)) {
|
||||
const step = selectedTemplate.steps.find((s) =>
|
||||
s.fields.find((f) => f.key === k)
|
||||
);
|
||||
const step = selectedTemplate.steps.find((s) => s.fields.find((f) => f.key === k));
|
||||
const field = step?.fields.find((f) => f.key === k);
|
||||
if (field?.options) {
|
||||
flatData[k] = v
|
||||
@@ -336,9 +318,7 @@ export default function AnalysisUI({ app }: AnalysisUIProps) {
|
||||
flatData[k] = v.join("、");
|
||||
}
|
||||
} else {
|
||||
const step = selectedTemplate.steps.find((s) =>
|
||||
s.fields.find((f) => f.key === k)
|
||||
);
|
||||
const step = selectedTemplate.steps.find((s) => s.fields.find((f) => f.key === k));
|
||||
const field = step?.fields.find((f) => f.key === k);
|
||||
if (field?.options) {
|
||||
flatData[k] = field.options.find((o) => o.value === v)?.label || v;
|
||||
@@ -353,7 +333,7 @@ export default function AnalysisUI({ app }: AnalysisUIProps) {
|
||||
s.fields.forEach((f) => {
|
||||
const val = flatData[f.key];
|
||||
if (val) summaryParts.push(`${f.label}:${val}`);
|
||||
})
|
||||
}),
|
||||
);
|
||||
const userContent = `[${selectedTemplate.name}] ${summaryParts.join(";")}`;
|
||||
|
||||
@@ -377,7 +357,7 @@ export default function AnalysisUI({ app }: AnalysisUIProps) {
|
||||
app.id,
|
||||
selectedTemplate.id,
|
||||
flatData,
|
||||
controller.signal
|
||||
controller.signal,
|
||||
);
|
||||
if (!res.ok) throw new Error("请求失败");
|
||||
const reader = res.body?.getReader();
|
||||
@@ -404,10 +384,8 @@ export default function AnalysisUI({ app }: AnalysisUIProps) {
|
||||
const snap = accumulated;
|
||||
setMessages((prev) =>
|
||||
prev.map((m, i) =>
|
||||
i === prev.length - 1 && m.role === "assistant"
|
||||
? { ...m, content: snap }
|
||||
: m
|
||||
)
|
||||
i === prev.length - 1 && m.role === "assistant" ? { ...m, content: snap } : m,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
@@ -423,8 +401,8 @@ export default function AnalysisUI({ app }: AnalysisUIProps) {
|
||||
prev.map((m, i) =>
|
||||
i === prev.length - 1 && m.role === "assistant" && !m.content
|
||||
? { ...m, content: "抱歉,报告生成失败,请稍后重试。" }
|
||||
: m
|
||||
)
|
||||
: m,
|
||||
),
|
||||
);
|
||||
setPhase("result");
|
||||
} finally {
|
||||
@@ -441,14 +419,14 @@ export default function AnalysisUI({ app }: AnalysisUIProps) {
|
||||
setSelectedTemplate(null);
|
||||
try {
|
||||
const data = await api.get<{ data: Message[] }>(
|
||||
`/api/v1/apps/${app.id}/conversations/${convId}/messages`
|
||||
`/api/v1/apps/${app.id}/conversations/${convId}/messages`,
|
||||
);
|
||||
setMessages(data.data || []);
|
||||
} catch {
|
||||
setMessages([]);
|
||||
}
|
||||
},
|
||||
[app.id]
|
||||
[app.id],
|
||||
);
|
||||
|
||||
const startNewReport = useCallback(() => {
|
||||
@@ -468,9 +446,7 @@ export default function AnalysisUI({ app }: AnalysisUIProps) {
|
||||
}, []);
|
||||
|
||||
const exportReport = useCallback(() => {
|
||||
const assistantMsgs = messages.filter(
|
||||
(m) => m.role === "assistant" && m.content
|
||||
);
|
||||
const assistantMsgs = messages.filter((m) => m.role === "assistant" && m.content);
|
||||
if (assistantMsgs.length === 0) return;
|
||||
const text = assistantMsgs.map((m) => m.content).join("\n\n");
|
||||
const tplName = selectedTemplate?.name || "研判报告";
|
||||
@@ -491,9 +467,7 @@ export default function AnalysisUI({ app }: AnalysisUIProps) {
|
||||
}, [messages, selectedTemplate]);
|
||||
|
||||
const exportAsWord = useCallback(() => {
|
||||
const assistantMsgs = messages.filter(
|
||||
(m) => m.role === "assistant" && m.content
|
||||
);
|
||||
const assistantMsgs = messages.filter((m) => m.role === "assistant" && m.content);
|
||||
if (assistantMsgs.length === 0) return;
|
||||
let content = assistantMsgs[assistantMsgs.length - 1].content;
|
||||
const fenceMatch = content.trim().match(/^```[\w]*\s*\n([\s\S]*?)```\s*$/);
|
||||
@@ -580,7 +554,7 @@ export default function AnalysisUI({ app }: AnalysisUIProps) {
|
||||
}
|
||||
setDeleteTarget(null);
|
||||
},
|
||||
[app.id, conversationId, startNewReport, queryClient]
|
||||
[app.id, conversationId, startNewReport, queryClient],
|
||||
);
|
||||
|
||||
const confirmBatchDelete = useCallback(async () => {
|
||||
@@ -602,14 +576,11 @@ export default function AnalysisUI({ app }: AnalysisUIProps) {
|
||||
setDeleteTarget(null);
|
||||
}, [selectedIds, app.id, conversationId, startNewReport, queryClient]);
|
||||
|
||||
const startRename = useCallback(
|
||||
(convId: string, currentName: string) => {
|
||||
setEditingConvId(convId);
|
||||
setEditingName(currentName);
|
||||
setTimeout(() => renameInputRef.current?.focus(), 50);
|
||||
},
|
||||
[]
|
||||
);
|
||||
const startRename = useCallback((convId: string, currentName: string) => {
|
||||
setEditingConvId(convId);
|
||||
setEditingName(currentName);
|
||||
setTimeout(() => renameInputRef.current?.focus(), 50);
|
||||
}, []);
|
||||
|
||||
const saveRename = useCallback(async () => {
|
||||
if (!editingConvId || !editingName.trim()) {
|
||||
@@ -617,10 +588,9 @@ export default function AnalysisUI({ app }: AnalysisUIProps) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.put(
|
||||
`/api/v1/apps/${app.id}/conversations/${editingConvId}/name`,
|
||||
{ name: editingName.trim() }
|
||||
);
|
||||
await api.put(`/api/v1/apps/${app.id}/conversations/${editingConvId}/name`, {
|
||||
name: editingName.trim(),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["conversations", app.id],
|
||||
});
|
||||
@@ -673,13 +643,13 @@ export default function AnalysisUI({ app }: AnalysisUIProps) {
|
||||
const snap = accumulated;
|
||||
setMessages((prev) =>
|
||||
prev.map((m, i) =>
|
||||
i === prev.length - 1 && m.role === "assistant"
|
||||
? { ...m, content: snap }
|
||||
: m
|
||||
)
|
||||
i === prev.length - 1 && m.role === "assistant" ? { ...m, content: snap } : m,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
} catch {
|
||||
/* skip */
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -688,8 +658,8 @@ export default function AnalysisUI({ app }: AnalysisUIProps) {
|
||||
prev.map((m, i) =>
|
||||
i === prev.length - 1 && m.role === "assistant" && !m.content
|
||||
? { ...m, content: "抱歉,处理异常,请稍后重试。" }
|
||||
: m
|
||||
)
|
||||
: m,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
abortRef.current = null;
|
||||
@@ -704,10 +674,7 @@ export default function AnalysisUI({ app }: AnalysisUIProps) {
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-3.5rem)] overflow-hidden">
|
||||
{/* Delete dialog */}
|
||||
<AlertDialog
|
||||
open={!!deleteTarget}
|
||||
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
||||
>
|
||||
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
||||
@@ -742,7 +709,9 @@ export default function AnalysisUI({ app }: AnalysisUIProps) {
|
||||
)}
|
||||
|
||||
{/* ========= sidebar ========= */}
|
||||
<div className={`fixed inset-y-[3.5rem] left-0 z-50 w-64 border-r bg-background flex flex-col shrink-0 overflow-hidden transition-transform duration-200 md:static md:inset-y-0 md:translate-x-0 ${sidebarOpen ? "translate-x-0" : "-translate-x-full"}`}>
|
||||
<div
|
||||
className={`fixed inset-y-[3.5rem] left-0 z-50 w-64 border-r bg-background flex flex-col shrink-0 overflow-hidden transition-transform duration-200 md:static md:inset-y-0 md:translate-x-0 ${sidebarOpen ? "translate-x-0" : "-translate-x-full"}`}
|
||||
>
|
||||
<div className="p-3 border-b space-y-2 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -752,7 +721,11 @@ export default function AnalysisUI({ app }: AnalysisUIProps) {
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" /> 返回应用中心
|
||||
</Button>
|
||||
<Button onClick={startNewReport} className="w-full gap-1.5 bg-blue-900 hover:bg-blue-800 text-white" size="sm">
|
||||
<Button
|
||||
onClick={startNewReport}
|
||||
className="w-full gap-1.5 bg-blue-900 hover:bg-blue-800 text-white"
|
||||
size="sm"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" /> 新建报告
|
||||
</Button>
|
||||
</div>
|
||||
@@ -760,9 +733,7 @@ export default function AnalysisUI({ app }: AnalysisUIProps) {
|
||||
{conversations.length > 0 && (
|
||||
<div className="px-3 py-2 border-b flex items-center justify-between shrink-0">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{selectMode
|
||||
? `已选 ${selectedIds.size} 个`
|
||||
: `${conversations.length} 条记录`}
|
||||
{selectMode ? `已选 ${selectedIds.size} 个` : `${conversations.length} 条记录`}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{selectMode ? (
|
||||
@@ -812,18 +783,13 @@ export default function AnalysisUI({ app }: AnalysisUIProps) {
|
||||
|
||||
<div className="flex-1 overflow-y-auto min-h-0 p-2">
|
||||
{conversations.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground text-center py-4">
|
||||
暂无报告历史
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground text-center py-4">暂无报告历史</p>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{conversations.map((conv) => (
|
||||
<div key={conv.id} className="group flex items-center gap-1">
|
||||
{selectMode && (
|
||||
<button
|
||||
onClick={() => toggleSelect(conv.id)}
|
||||
className="shrink-0 p-0.5"
|
||||
>
|
||||
<button onClick={() => toggleSelect(conv.id)} className="shrink-0 p-0.5">
|
||||
{selectedIds.has(conv.id) ? (
|
||||
<CheckSquare className="h-3.5 w-3.5 text-primary" />
|
||||
) : (
|
||||
@@ -846,16 +812,10 @@ export default function AnalysisUI({ app }: AnalysisUIProps) {
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
onClick={() =>
|
||||
!selectMode && loadConversation(conv.id)
|
||||
}
|
||||
onDoubleClick={() =>
|
||||
!selectMode && startRename(conv.id, conv.name)
|
||||
}
|
||||
onClick={() => !selectMode && loadConversation(conv.id)}
|
||||
onDoubleClick={() => !selectMode && startRename(conv.id, conv.name)}
|
||||
className={`flex-1 text-left p-2 rounded-md text-sm truncate transition-colors ${
|
||||
conversationId === conv.id
|
||||
? "bg-muted font-medium"
|
||||
: "hover:bg-muted/60"
|
||||
conversationId === conv.id ? "bg-muted font-medium" : "hover:bg-muted/60"
|
||||
}`}
|
||||
title={`${conv.name}\n双击重命名`}
|
||||
>
|
||||
@@ -911,7 +871,9 @@ export default function AnalysisUI({ app }: AnalysisUIProps) {
|
||||
<div
|
||||
className={`flex h-9 w-9 items-center justify-center rounded-xl ${categoryColor} shrink-0`}
|
||||
>
|
||||
<CategoryIcon className="h-4.5 w-4.5" />
|
||||
{CategoryIconComponent
|
||||
? React.createElement(CategoryIconComponent, { className: "h-4.5 w-4.5" })
|
||||
: null}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h1 className="font-semibold text-sm truncate">{app.name}</h1>
|
||||
@@ -972,8 +934,7 @@ export default function AnalysisUI({ app }: AnalysisUIProps) {
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{templates.map((tpl) => {
|
||||
const Icon =
|
||||
REPORT_ICONS[tpl.report_type] || BarChart3;
|
||||
const Icon = REPORT_ICONS[tpl.report_type] || BarChart3;
|
||||
return (
|
||||
<button
|
||||
key={tpl.id}
|
||||
@@ -1020,17 +981,11 @@ export default function AnalysisUI({ app }: AnalysisUIProps) {
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{i < currentStep ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
i + 1
|
||||
)}
|
||||
{i < currentStep ? <Check className="h-4 w-4" /> : i + 1}
|
||||
</div>
|
||||
<span
|
||||
className={`text-xs truncate hidden sm:block ${
|
||||
i === currentStep
|
||||
? "font-medium text-foreground"
|
||||
: "text-muted-foreground"
|
||||
i === currentStep ? "font-medium text-foreground" : "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{s.title}
|
||||
@@ -1055,9 +1010,7 @@ export default function AnalysisUI({ app }: AnalysisUIProps) {
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">{step.title}</h3>
|
||||
{step.description && (
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{step.description}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">{step.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1068,9 +1021,7 @@ export default function AnalysisUI({ app }: AnalysisUIProps) {
|
||||
<MultiSelectField
|
||||
key={field.key}
|
||||
field={field}
|
||||
value={
|
||||
(fieldData[field.key] as string[]) || []
|
||||
}
|
||||
value={(fieldData[field.key] as string[]) || []}
|
||||
onChange={(v) => updateField(field.key, v)}
|
||||
/>
|
||||
);
|
||||
@@ -1153,9 +1104,7 @@ export default function AnalysisUI({ app }: AnalysisUIProps) {
|
||||
<div className="flex justify-end group/msg">
|
||||
<div className="max-w-[80%]">
|
||||
<div className="rounded-2xl px-4 py-2.5 bg-primary text-primary-foreground rounded-br-md shadow-sm">
|
||||
<p className="text-sm whitespace-pre-wrap">
|
||||
{msg.content}
|
||||
</p>
|
||||
<p className="text-sm whitespace-pre-wrap">{msg.content}</p>
|
||||
</div>
|
||||
<div className="flex justify-end mt-1 opacity-0 group-hover/msg:opacity-100 transition-opacity">
|
||||
<button
|
||||
@@ -1171,8 +1120,7 @@ export default function AnalysisUI({ app }: AnalysisUIProps) {
|
||||
<div className="flex justify-start group/msg">
|
||||
<div className="max-w-full w-full space-y-2">
|
||||
{isStreaming &&
|
||||
msg.id ===
|
||||
messages[messages.length - 1]?.id &&
|
||||
msg.id === messages[messages.length - 1]?.id &&
|
||||
!msg.content && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect, useCallback, memo } from "react";
|
||||
import React from "react";
|
||||
import { useState, useEffect, useCallback, memo, useMemo } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { App, Conversation, Message, Chunk } from "@/lib/types";
|
||||
@@ -97,7 +98,11 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const { user } = useAuthStore();
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [messages, setMessages] = useState<Message[]>(
|
||||
app.welcome_message && !conversationId
|
||||
? [{ id: "welcome", role: "assistant", content: app.welcome_message }]
|
||||
: [],
|
||||
);
|
||||
const [input, setInput] = useState("");
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [conversationId, setConversationId] = useState<string | undefined>();
|
||||
@@ -124,9 +129,7 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
|
||||
const { data: conversations = [] } = useQuery({
|
||||
queryKey: ["conversations", app.id, user?.id],
|
||||
queryFn: async () => {
|
||||
const data = await api.get<{ data: Conversation[] }>(
|
||||
`/api/v1/apps/${app.id}/conversations`
|
||||
);
|
||||
const data = await api.get<{ data: Conversation[] }>(`/api/v1/apps/${app.id}/conversations`);
|
||||
return data.data || [];
|
||||
},
|
||||
staleTime: 10_000,
|
||||
@@ -140,60 +143,47 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
|
||||
scrollToBottom();
|
||||
}, [messages, scrollToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
if (app.welcome_message && messages.length === 0 && !conversationId) {
|
||||
setMessages([
|
||||
{ id: "welcome", role: "assistant", content: app.welcome_message },
|
||||
]);
|
||||
}
|
||||
}, [app.welcome_message, messages.length, conversationId]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const CategoryIcon = getCategoryIcon(app.category_slug);
|
||||
const CategoryIconComponent = getCategoryIcon(app.category_slug);
|
||||
const categoryColor = getCategoryColor(app.category_slug);
|
||||
|
||||
const suggestedPrompts = useRef(
|
||||
(() => {
|
||||
try {
|
||||
if (typeof app.suggested_prompts === "string")
|
||||
return JSON.parse(app.suggested_prompts) as string[];
|
||||
return (app.suggested_prompts as string[]) || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
})()
|
||||
).current;
|
||||
const suggestedPrompts = useMemo(() => {
|
||||
try {
|
||||
if (typeof app.suggested_prompts === "string")
|
||||
return JSON.parse(app.suggested_prompts) as string[];
|
||||
return (app.suggested_prompts as string[]) || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}, [app.suggested_prompts]);
|
||||
|
||||
const loadConversation = useCallback(
|
||||
async (convId: string) => {
|
||||
setConversationId(convId);
|
||||
try {
|
||||
const data = await api.get<{ data: Message[] }>(
|
||||
`/api/v1/apps/${app.id}/conversations/${convId}/messages`
|
||||
`/api/v1/apps/${app.id}/conversations/${convId}/messages`,
|
||||
);
|
||||
setMessages(data.data || []);
|
||||
} catch {
|
||||
setMessages([]);
|
||||
}
|
||||
},
|
||||
[app.id]
|
||||
[app.id],
|
||||
);
|
||||
|
||||
const copyText = useCallback(
|
||||
(text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
const id = `${Date.now()}`;
|
||||
setCopiedId(id);
|
||||
toast.success("已复制到剪贴板");
|
||||
setTimeout(() => setCopiedId(null), 2000);
|
||||
},
|
||||
[]
|
||||
);
|
||||
const copyText = useCallback((text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
const id = `${Date.now()}`;
|
||||
setCopiedId(id);
|
||||
toast.success("已复制到剪贴板");
|
||||
setTimeout(() => setCopiedId(null), 2000);
|
||||
}, []);
|
||||
|
||||
const exportConversation = useCallback(() => {
|
||||
if (messages.length === 0) return;
|
||||
@@ -279,12 +269,7 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
|
||||
abortRef.current = controller;
|
||||
|
||||
try {
|
||||
const res = await streamChat(
|
||||
app.id,
|
||||
fullMessage,
|
||||
conversationId,
|
||||
controller.signal
|
||||
);
|
||||
const res = await streamChat(app.id, fullMessage, conversationId, controller.signal);
|
||||
if (!res.ok) throw new Error("请求失败");
|
||||
const reader = res.body?.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
@@ -304,8 +289,7 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
|
||||
if (raw === "[DONE]") break;
|
||||
try {
|
||||
const event = JSON.parse(raw);
|
||||
if (event.conversation_id)
|
||||
setConversationId(event.conversation_id);
|
||||
if (event.conversation_id) setConversationId(event.conversation_id);
|
||||
// 解析首包的 chunks 映射表
|
||||
if (event.chunks) {
|
||||
console.log("[SSE] Received chunks:", event.chunks);
|
||||
@@ -316,10 +300,8 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
|
||||
const snap = accumulated;
|
||||
setMessages((prev) =>
|
||||
prev.map((m, i) =>
|
||||
i === prev.length - 1 && m.role === "assistant"
|
||||
? { ...m, content: snap }
|
||||
: m
|
||||
)
|
||||
i === prev.length - 1 && m.role === "assistant" ? { ...m, content: snap } : m,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
@@ -340,8 +322,8 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
|
||||
prev.map((m, i) =>
|
||||
i === prev.length - 1 && m.role === "assistant" && !m.content
|
||||
? { ...m, content: "抱歉,系统处理异常,请稍后重试。" }
|
||||
: m
|
||||
)
|
||||
: m,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
abortRef.current = null;
|
||||
@@ -356,7 +338,7 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
|
||||
sendMessage();
|
||||
}
|
||||
},
|
||||
[sendMessage]
|
||||
[sendMessage],
|
||||
);
|
||||
|
||||
const startNewConversation = useCallback(() => {
|
||||
@@ -393,7 +375,7 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
|
||||
}
|
||||
setDeleteTarget(null);
|
||||
},
|
||||
[app.id, conversationId, startNewConversation, queryClient]
|
||||
[app.id, conversationId, startNewConversation, queryClient],
|
||||
);
|
||||
|
||||
const confirmBatchDelete = useCallback(async () => {
|
||||
@@ -402,8 +384,7 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
|
||||
await api.post(`/api/v1/apps/${app.id}/conversations/batch-delete`, {
|
||||
conversation_ids: Array.from(selectedIds),
|
||||
});
|
||||
if (conversationId && selectedIds.has(conversationId))
|
||||
startNewConversation();
|
||||
if (conversationId && selectedIds.has(conversationId)) startNewConversation();
|
||||
setSelectedIds(new Set());
|
||||
setSelectMode(false);
|
||||
queryClient.invalidateQueries({
|
||||
@@ -414,22 +395,13 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
|
||||
toast.error("批量删除失败");
|
||||
}
|
||||
setDeleteTarget(null);
|
||||
}, [
|
||||
selectedIds,
|
||||
app.id,
|
||||
conversationId,
|
||||
startNewConversation,
|
||||
queryClient,
|
||||
]);
|
||||
}, [selectedIds, app.id, conversationId, startNewConversation, queryClient]);
|
||||
|
||||
const startRename = useCallback(
|
||||
(convId: string, currentName: string) => {
|
||||
setEditingConvId(convId);
|
||||
setEditingName(currentName);
|
||||
setTimeout(() => renameInputRef.current?.focus(), 50);
|
||||
},
|
||||
[]
|
||||
);
|
||||
const startRename = useCallback((convId: string, currentName: string) => {
|
||||
setEditingConvId(convId);
|
||||
setEditingName(currentName);
|
||||
setTimeout(() => renameInputRef.current?.focus(), 50);
|
||||
}, []);
|
||||
|
||||
const saveRename = useCallback(async () => {
|
||||
if (!editingConvId || !editingName.trim()) {
|
||||
@@ -437,10 +409,9 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.put(
|
||||
`/api/v1/apps/${app.id}/conversations/${editingConvId}/name`,
|
||||
{ name: editingName.trim() }
|
||||
);
|
||||
await api.put(`/api/v1/apps/${app.id}/conversations/${editingConvId}/name`, {
|
||||
name: editingName.trim(),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["conversations", app.id],
|
||||
});
|
||||
@@ -454,10 +425,7 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-3.5rem)] overflow-hidden">
|
||||
{/* 删除确认弹窗 */}
|
||||
<AlertDialog
|
||||
open={!!deleteTarget}
|
||||
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
||||
>
|
||||
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
||||
@@ -494,9 +462,11 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
|
||||
)}
|
||||
|
||||
{/* 侧边栏 */}
|
||||
<div className={`fixed inset-y-[3.5rem] left-0 z-50 w-64 border-r bg-background flex flex-col shrink-0 overflow-hidden transition-transform duration-200 md:static md:inset-y-0 md:translate-x-0 ${
|
||||
sidebarOpen ? "translate-x-0" : "-translate-x-full"
|
||||
}`}>
|
||||
<div
|
||||
className={`fixed inset-y-[3.5rem] left-0 z-50 w-64 border-r bg-background flex flex-col shrink-0 overflow-hidden transition-transform duration-200 md:static md:inset-y-0 md:translate-x-0 ${
|
||||
sidebarOpen ? "translate-x-0" : "-translate-x-full"
|
||||
}`}
|
||||
>
|
||||
<div className="p-3 border-b space-y-2 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -518,9 +488,7 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
|
||||
{conversations.length > 0 && (
|
||||
<div className="px-3 py-2 border-b flex items-center justify-between shrink-0">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{selectMode
|
||||
? `已选 ${selectedIds.size} 个`
|
||||
: `${conversations.length} 个对话`}
|
||||
{selectMode ? `已选 ${selectedIds.size} 个` : `${conversations.length} 个对话`}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{selectMode ? (
|
||||
@@ -537,9 +505,7 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-1.5 text-xs text-destructive hover:text-destructive"
|
||||
onClick={() =>
|
||||
setDeleteTarget({ type: "batch" })
|
||||
}
|
||||
onClick={() => setDeleteTarget({ type: "batch" })}
|
||||
disabled={selectedIds.size === 0}
|
||||
>
|
||||
<Trash2 className="h-3 w-3 mr-0.5" /> 删除
|
||||
@@ -572,18 +538,13 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
|
||||
|
||||
<div className="flex-1 overflow-y-auto min-h-0 p-2">
|
||||
{conversations.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground text-center py-4">
|
||||
暂无对话历史
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground text-center py-4">暂无对话历史</p>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{conversations.map((conv) => (
|
||||
<div key={conv.id} className="group flex items-center gap-1">
|
||||
{selectMode && (
|
||||
<button
|
||||
onClick={() => toggleSelect(conv.id)}
|
||||
className="shrink-0 p-0.5"
|
||||
>
|
||||
<button onClick={() => toggleSelect(conv.id)} className="shrink-0 p-0.5">
|
||||
{selectedIds.has(conv.id) ? (
|
||||
<CheckSquare className="h-3.5 w-3.5 text-primary" />
|
||||
) : (
|
||||
@@ -607,13 +568,9 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
|
||||
) : (
|
||||
<button
|
||||
onClick={() => !selectMode && loadConversation(conv.id)}
|
||||
onDoubleClick={() =>
|
||||
!selectMode && startRename(conv.id, conv.name)
|
||||
}
|
||||
onDoubleClick={() => !selectMode && startRename(conv.id, conv.name)}
|
||||
className={`flex-1 text-left p-2 rounded-md text-sm truncate transition-colors ${
|
||||
conversationId === conv.id
|
||||
? "bg-muted font-medium"
|
||||
: "hover:bg-muted/60"
|
||||
conversationId === conv.id ? "bg-muted font-medium" : "hover:bg-muted/60"
|
||||
}`}
|
||||
title={`${conv.name}\n双击重命名`}
|
||||
>
|
||||
@@ -668,7 +625,9 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
|
||||
<div
|
||||
className={`flex h-9 w-9 items-center justify-center rounded-xl ${categoryColor} shrink-0`}
|
||||
>
|
||||
<CategoryIcon className="h-4.5 w-4.5" />
|
||||
{CategoryIconComponent
|
||||
? React.createElement(CategoryIconComponent, { className: "h-4.5 w-4.5" })
|
||||
: null}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h1 className="font-semibold text-sm truncate">{app.name}</h1>
|
||||
@@ -728,7 +687,10 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
|
||||
<FileText className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">{fileName}</span>
|
||||
<button
|
||||
onClick={() => { setFileContent(null); setFileName(null); }}
|
||||
onClick={() => {
|
||||
setFileContent(null);
|
||||
setFileName(null);
|
||||
}}
|
||||
className="ml-auto p-0.5 hover:bg-blue-100 rounded"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
@@ -758,7 +720,11 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={fileName ? "输入审查要求... (Enter 发送)" : "输入消息... (Enter 发送,Shift+Enter 换行)"}
|
||||
placeholder={
|
||||
fileName
|
||||
? "输入审查要求... (Enter 发送)"
|
||||
: "输入消息... (Enter 发送,Shift+Enter 换行)"
|
||||
}
|
||||
className="resize-none min-h-[44px] max-h-32"
|
||||
rows={1}
|
||||
disabled={isStreaming}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useState, useMemo, useCallback, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
@@ -9,15 +10,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Sparkles,
|
||||
RotateCcw,
|
||||
Copy,
|
||||
Check,
|
||||
Loader2,
|
||||
FileText,
|
||||
ListChecks,
|
||||
} from "lucide-react";
|
||||
import { Sparkles, RotateCcw, Copy, Check, Loader2, FileText, ListChecks } from "lucide-react";
|
||||
import { getCategoryIcon, getCategoryColor } from "@/lib/category-config";
|
||||
import GovMarkdown from "@/components/ui/gov-markdown";
|
||||
import { toast } from "sonner";
|
||||
@@ -50,7 +43,7 @@ export default function CompletionUI({ app }: CompletionUIProps) {
|
||||
setConversationId(convId);
|
||||
try {
|
||||
const data = await api.get<{ data: Message[] }>(
|
||||
`/api/v1/apps/${app.id}/conversations/${convId}/messages`
|
||||
`/api/v1/apps/${app.id}/conversations/${convId}/messages`,
|
||||
);
|
||||
const msgs = data.data || [];
|
||||
const userMsg = msgs.find((m) => m.role === "user");
|
||||
@@ -62,14 +55,16 @@ export default function CompletionUI({ app }: CompletionUIProps) {
|
||||
setOutput("");
|
||||
}
|
||||
},
|
||||
[app.id]
|
||||
[app.id],
|
||||
);
|
||||
|
||||
const appConfig = useMemo(() => {
|
||||
try {
|
||||
if (typeof app.app_config === "string") return JSON.parse(app.app_config);
|
||||
return app.app_config || {};
|
||||
} catch { return {}; }
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}, [app.app_config]);
|
||||
|
||||
const inputLabel = appConfig.input_label || "输入内容";
|
||||
@@ -78,7 +73,7 @@ export default function CompletionUI({ app }: CompletionUIProps) {
|
||||
const formatTemplates: Record<string, FormatTemplate> = appConfig.format_templates || {};
|
||||
const hasFormats = Object.keys(formatTemplates).length > 0;
|
||||
|
||||
const CategoryIcon = getCategoryIcon(app.category_slug);
|
||||
const CategoryIconComponent = getCategoryIcon(app.category_slug);
|
||||
const categoryColor = getCategoryColor(app.category_slug);
|
||||
|
||||
const handleGenerate = useCallback(async () => {
|
||||
@@ -121,7 +116,9 @@ export default function CompletionUI({ app }: CompletionUIProps) {
|
||||
accumulated += event.answer;
|
||||
setOutput(accumulated);
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
} catch {
|
||||
/* skip */
|
||||
}
|
||||
}
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ["conversations", app.id] });
|
||||
@@ -163,8 +160,12 @@ export default function CompletionUI({ app }: CompletionUIProps) {
|
||||
|
||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
<div className="border-b px-3 md:px-5 py-3 flex items-center gap-2 md:gap-3 shrink-0">
|
||||
<div className={`flex h-8 w-8 items-center justify-center rounded-lg ${categoryColor} shrink-0`}>
|
||||
<CategoryIcon className="h-4 w-4" />
|
||||
<div
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-lg ${categoryColor} shrink-0`}
|
||||
>
|
||||
{CategoryIconComponent
|
||||
? React.createElement(CategoryIconComponent, { className: "h-4 w-4" })
|
||||
: null}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h1 className="font-semibold text-sm truncate">{app.name}</h1>
|
||||
@@ -177,99 +178,114 @@ export default function CompletionUI({ app }: CompletionUIProps) {
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="mx-auto w-full max-w-7xl px-3 md:px-6 lg:px-8 py-4 md:py-6 space-y-4 md:space-y-6">
|
||||
{/* 格式选择区域 */}
|
||||
{hasFormats && (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<ListChecks className="h-4 w-4 text-muted-foreground" />
|
||||
<label className="text-sm font-medium">选择输出格式</label>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-2">
|
||||
{Object.entries(formatTemplates).map(([key, fmt]) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setSelectedFormat(selectedFormat === key ? "" : key)}
|
||||
className={`text-left p-3 rounded-lg border transition-all ${
|
||||
selectedFormat === key
|
||||
? "border-emerald-500 bg-emerald-50 ring-1 ring-emerald-500"
|
||||
: "border-border hover:border-emerald-300 hover:bg-emerald-50/50"
|
||||
}`}
|
||||
>
|
||||
<div className="text-sm font-medium truncate">{fmt.name}</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{fmt.description}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{selectedFormat && formatTemplates[selectedFormat] && (
|
||||
<div className="mt-3 p-2.5 rounded-md bg-emerald-50 border border-emerald-200/60">
|
||||
<p className="text-xs text-emerald-700">
|
||||
<span className="font-medium">包含章节:</span>
|
||||
{formatTemplates[selectedFormat].sections.join(" → ")}
|
||||
</p>
|
||||
<div className="mx-auto w-full max-w-7xl px-3 md:px-6 lg:px-8 py-4 md:py-6 space-y-4 md:space-y-6">
|
||||
{/* 格式选择区域 */}
|
||||
{hasFormats && (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<ListChecks className="h-4 w-4 text-muted-foreground" />
|
||||
<label className="text-sm font-medium">选择输出格式</label>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-2">
|
||||
{Object.entries(formatTemplates).map(([key, fmt]) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setSelectedFormat(selectedFormat === key ? "" : key)}
|
||||
className={`text-left p-3 rounded-lg border transition-all ${
|
||||
selectedFormat === key
|
||||
? "border-emerald-500 bg-emerald-50 ring-1 ring-emerald-500"
|
||||
: "border-border hover:border-emerald-300 hover:bg-emerald-50/50"
|
||||
}`}
|
||||
>
|
||||
<div className="text-sm font-medium truncate">{fmt.name}</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
|
||||
{fmt.description}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{selectedFormat && formatTemplates[selectedFormat] && (
|
||||
<div className="mt-3 p-2.5 rounded-md bg-emerald-50 border border-emerald-200/60">
|
||||
<p className="text-xs text-emerald-700">
|
||||
<span className="font-medium">包含章节:</span>
|
||||
{formatTemplates[selectedFormat].sections.join(" → ")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-5 space-y-3">
|
||||
<label className="text-sm font-medium">{inputLabel}</label>
|
||||
<Textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder={inputPlaceholder}
|
||||
className="min-h-[160px] resize-none"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={handleGenerate} disabled={!input.trim() || isLoading} className="gap-2">
|
||||
{isLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
|
||||
{isLoading ? "生成中..." : "生成"}
|
||||
</Button>
|
||||
{selectedFormat && formatTemplates[selectedFormat] && (
|
||||
<span className="text-xs text-emerald-600 bg-emerald-50 px-2 py-1 rounded">
|
||||
格式:{formatTemplates[selectedFormat].name}
|
||||
</span>
|
||||
)}
|
||||
{(output || input) && (
|
||||
<Button variant="outline" onClick={handleReset} className="gap-2">
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
重置
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{isLoading && !output && (
|
||||
<Card>
|
||||
<CardContent className="p-5 space-y-3">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{output && (
|
||||
<Card className="border-emerald-200/60 bg-emerald-50/30">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<label className="text-sm font-medium text-emerald-800">{outputLabel}</label>
|
||||
<Button variant="ghost" size="sm" onClick={handleCopy} className="gap-1.5 text-xs h-7">
|
||||
{copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
|
||||
{copied ? "已复制" : "复制"}
|
||||
<label className="text-sm font-medium">{inputLabel}</label>
|
||||
<Textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder={inputPlaceholder}
|
||||
className="min-h-[160px] resize-none"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={handleGenerate}
|
||||
disabled={!input.trim() || isLoading}
|
||||
className="gap-2"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="h-4 w-4" />
|
||||
)}
|
||||
{isLoading ? "生成中..." : "生成"}
|
||||
</Button>
|
||||
{selectedFormat && formatTemplates[selectedFormat] && (
|
||||
<span className="text-xs text-emerald-600 bg-emerald-50 px-2 py-1 rounded">
|
||||
格式:{formatTemplates[selectedFormat].name}
|
||||
</span>
|
||||
)}
|
||||
{(output || input) && (
|
||||
<Button variant="outline" onClick={handleReset} className="gap-2">
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
重置
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<GovMarkdown content={output} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading && !output && (
|
||||
<Card>
|
||||
<CardContent className="p-5 space-y-3">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{output && (
|
||||
<Card className="border-emerald-200/60 bg-emerald-50/30">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<label className="text-sm font-medium text-emerald-800">{outputLabel}</label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCopy}
|
||||
className="gap-1.5 text-xs h-7"
|
||||
>
|
||||
{copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
|
||||
{copied ? "已复制" : "复制"}
|
||||
</Button>
|
||||
</div>
|
||||
<GovMarkdown content={output} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -65,9 +65,7 @@ export default function ConversationSidebar({
|
||||
const { data: conversations = [] } = useQuery({
|
||||
queryKey: ["conversations", appId, user?.id],
|
||||
queryFn: async () => {
|
||||
const data = await api.get<{ data: Conversation[] }>(
|
||||
`/api/v1/apps/${appId}/conversations`
|
||||
);
|
||||
const data = await api.get<{ data: Conversation[] }>(`/api/v1/apps/${appId}/conversations`);
|
||||
return data.data || [];
|
||||
},
|
||||
staleTime: 10_000,
|
||||
@@ -100,7 +98,7 @@ export default function ConversationSidebar({
|
||||
}
|
||||
setDeleteTarget(null);
|
||||
},
|
||||
[appId, currentConvId, onNewConversation, queryClient]
|
||||
[appId, currentConvId, onNewConversation, queryClient],
|
||||
);
|
||||
|
||||
const confirmBatchDelete = useCallback(async () => {
|
||||
@@ -109,8 +107,7 @@ export default function ConversationSidebar({
|
||||
await api.post(`/api/v1/apps/${appId}/conversations/batch-delete`, {
|
||||
conversation_ids: Array.from(selectedIds),
|
||||
});
|
||||
if (currentConvId && selectedIds.has(currentConvId))
|
||||
onNewConversation();
|
||||
if (currentConvId && selectedIds.has(currentConvId)) onNewConversation();
|
||||
setSelectedIds(new Set());
|
||||
setSelectMode(false);
|
||||
queryClient.invalidateQueries({
|
||||
@@ -123,14 +120,11 @@ export default function ConversationSidebar({
|
||||
setDeleteTarget(null);
|
||||
}, [selectedIds, appId, currentConvId, onNewConversation, queryClient]);
|
||||
|
||||
const startRename = useCallback(
|
||||
(convId: string, currentName: string) => {
|
||||
setEditingConvId(convId);
|
||||
setEditingName(currentName);
|
||||
setTimeout(() => renameInputRef.current?.focus(), 50);
|
||||
},
|
||||
[]
|
||||
);
|
||||
const startRename = useCallback((convId: string, currentName: string) => {
|
||||
setEditingConvId(convId);
|
||||
setEditingName(currentName);
|
||||
setTimeout(() => renameInputRef.current?.focus(), 50);
|
||||
}, []);
|
||||
|
||||
const saveRename = useCallback(async () => {
|
||||
if (!editingConvId || !editingName.trim()) {
|
||||
@@ -138,10 +132,9 @@ export default function ConversationSidebar({
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.put(
|
||||
`/api/v1/apps/${appId}/conversations/${editingConvId}/name`,
|
||||
{ name: editingName.trim() }
|
||||
);
|
||||
await api.put(`/api/v1/apps/${appId}/conversations/${editingConvId}/name`, {
|
||||
name: editingName.trim(),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["conversations", appId],
|
||||
});
|
||||
@@ -155,10 +148,7 @@ export default function ConversationSidebar({
|
||||
return (
|
||||
<>
|
||||
{/* 删除确认弹窗 */}
|
||||
<AlertDialog
|
||||
open={!!deleteTarget}
|
||||
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
||||
>
|
||||
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
||||
@@ -229,9 +219,7 @@ export default function ConversationSidebar({
|
||||
{conversations.length > 0 && (
|
||||
<div className="px-3 py-2 border-b flex items-center justify-between shrink-0">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{selectMode
|
||||
? `已选 ${selectedIds.size} 个`
|
||||
: `${conversations.length} 个对话`}
|
||||
{selectMode ? `已选 ${selectedIds.size} 个` : `${conversations.length} 个对话`}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{selectMode ? (
|
||||
@@ -281,18 +269,13 @@ export default function ConversationSidebar({
|
||||
|
||||
<div className="flex-1 overflow-y-auto min-h-0 p-2">
|
||||
{conversations.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground text-center py-4">
|
||||
暂无对话历史
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground text-center py-4">暂无对话历史</p>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{conversations.map((conv) => (
|
||||
<div key={conv.id} className="group flex items-center gap-1">
|
||||
{selectMode && (
|
||||
<button
|
||||
onClick={() => toggleSelect(conv.id)}
|
||||
className="shrink-0 p-0.5"
|
||||
>
|
||||
<button onClick={() => toggleSelect(conv.id)} className="shrink-0 p-0.5">
|
||||
{selectedIds.has(conv.id) ? (
|
||||
<CheckSquare className="h-3.5 w-3.5 text-primary" />
|
||||
) : (
|
||||
@@ -315,16 +298,10 @@ export default function ConversationSidebar({
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
onClick={() =>
|
||||
!selectMode && onSelectConversation(conv.id)
|
||||
}
|
||||
onDoubleClick={() =>
|
||||
!selectMode && startRename(conv.id, conv.name)
|
||||
}
|
||||
onClick={() => !selectMode && onSelectConversation(conv.id)}
|
||||
onDoubleClick={() => !selectMode && startRename(conv.id, conv.name)}
|
||||
className={`flex-1 text-left p-2 rounded-md text-sm truncate transition-colors ${
|
||||
currentConvId === conv.id
|
||||
? "bg-muted font-medium"
|
||||
: "hover:bg-muted/60"
|
||||
currentConvId === conv.id ? "bg-muted font-medium" : "hover:bg-muted/60"
|
||||
}`}
|
||||
title={`${conv.name}\n双击重命名`}
|
||||
>
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useState, useRef, useEffect, useCallback, useMemo, useTransition, memo } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { App, Conversation, Message, DocTemplate, TemplateField, SelectOption } from "@/lib/types";
|
||||
import type {
|
||||
App,
|
||||
Conversation,
|
||||
Message,
|
||||
DocTemplate,
|
||||
TemplateField,
|
||||
SelectOption,
|
||||
} from "@/lib/types";
|
||||
import api, { streamChat, streamGenerateDoc } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
@@ -100,7 +108,7 @@ const FormField = memo(function FormField({
|
||||
}) {
|
||||
if (field.type === "select" && field.options) {
|
||||
const normalizedOptions = field.options.map((opt) =>
|
||||
typeof opt === "string" ? { value: opt, label: opt } : opt
|
||||
typeof opt === "string" ? { value: opt, label: opt } : opt,
|
||||
);
|
||||
const selectedLabel = normalizedOptions.find((o) => o.value === value)?.label || "";
|
||||
return (
|
||||
@@ -115,7 +123,9 @@ const FormField = memo(function FormField({
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{normalizedOptions.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -198,9 +208,7 @@ export default function DocWriterUI({ app }: DocWriterUIProps) {
|
||||
const { data: conversations = [] } = useQuery({
|
||||
queryKey: ["conversations", app.id],
|
||||
queryFn: async () => {
|
||||
const data = await api.get<{ data: Conversation[] }>(
|
||||
`/api/v1/apps/${app.id}/conversations`
|
||||
);
|
||||
const data = await api.get<{ data: Conversation[] }>(`/api/v1/apps/${app.id}/conversations`);
|
||||
return data.data || [];
|
||||
},
|
||||
staleTime: 10_000,
|
||||
@@ -220,7 +228,7 @@ export default function DocWriterUI({ app }: DocWriterUIProps) {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const CategoryIcon = getCategoryIcon(app.category_slug);
|
||||
const CategoryIconComponent = getCategoryIcon(app.category_slug);
|
||||
const categoryColor = getCategoryColor(app.category_slug);
|
||||
|
||||
const loadConversation = useCallback(
|
||||
@@ -229,14 +237,14 @@ export default function DocWriterUI({ app }: DocWriterUIProps) {
|
||||
setPhase("chat");
|
||||
try {
|
||||
const data = await api.get<{ data: Message[] }>(
|
||||
`/api/v1/apps/${app.id}/conversations/${convId}/messages`
|
||||
`/api/v1/apps/${app.id}/conversations/${convId}/messages`,
|
||||
);
|
||||
setMessages(data.data || []);
|
||||
} catch {
|
||||
setMessages([]);
|
||||
}
|
||||
},
|
||||
[app.id]
|
||||
[app.id],
|
||||
);
|
||||
|
||||
const copyText = useCallback((text: string) => {
|
||||
@@ -292,8 +300,7 @@ export default function DocWriterUI({ app }: DocWriterUIProps) {
|
||||
return `<p style="font-size:16pt;font-family:黑体,SimHei;font-weight:bold;text-indent:2em;line-height:1.8;">${trimmed}</p>`;
|
||||
if (/^[((][一二三四五六七八九十]+[))]/.test(trimmed))
|
||||
return `<p style="font-size:16pt;font-family:楷体,KaiTi;font-weight:bold;text-indent:2em;line-height:1.8;">${trimmed}</p>`;
|
||||
if (/^---+$/.test(trimmed))
|
||||
return `<hr style="border-top:1px solid #000;margin:0.5cm 0;" />`;
|
||||
if (/^---+$/.test(trimmed)) return `<hr style="border-top:1px solid #000;margin:0.5cm 0;" />`;
|
||||
const formatted = trimmed
|
||||
.replace(/\*\*(.+?)\*\*/g, "<b>$1</b>")
|
||||
.replace(/\*(.+?)\*/g, "<i>$1</i>");
|
||||
@@ -365,17 +372,20 @@ ${paragraphs.join("\n")}
|
||||
setFieldData((prev) => ({ ...prev, [key]: val }));
|
||||
}, []);
|
||||
|
||||
const selectTemplate = useCallback((tpl: DocTemplate) => {
|
||||
setSelectedTemplate(tpl);
|
||||
const defaults: Record<string, string> = {};
|
||||
for (const f of tpl.fields) {
|
||||
if (f.default) defaults[f.key] = f.default;
|
||||
}
|
||||
setFieldData(defaults);
|
||||
startTransition(() => {
|
||||
setPhase("form");
|
||||
});
|
||||
}, [startTransition]);
|
||||
const selectTemplate = useCallback(
|
||||
(tpl: DocTemplate) => {
|
||||
setSelectedTemplate(tpl);
|
||||
const defaults: Record<string, string> = {};
|
||||
for (const f of tpl.fields) {
|
||||
if (f.default) defaults[f.key] = f.default;
|
||||
}
|
||||
setFieldData(defaults);
|
||||
startTransition(() => {
|
||||
setPhase("form");
|
||||
});
|
||||
},
|
||||
[startTransition],
|
||||
);
|
||||
|
||||
const handleGenerateDoc = useCallback(async () => {
|
||||
if (!selectedTemplate || isStreaming) return;
|
||||
@@ -399,7 +409,12 @@ ${paragraphs.join("\n")}
|
||||
abortRef.current = controller;
|
||||
|
||||
try {
|
||||
const res = await streamGenerateDoc(app.id, selectedTemplate.id, fieldData, controller.signal);
|
||||
const res = await streamGenerateDoc(
|
||||
app.id,
|
||||
selectedTemplate.id,
|
||||
fieldData,
|
||||
controller.signal,
|
||||
);
|
||||
if (!res.ok) throw new Error("请求失败");
|
||||
const reader = res.body?.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
@@ -425,13 +440,13 @@ ${paragraphs.join("\n")}
|
||||
const snap = accumulated;
|
||||
setMessages((prev) =>
|
||||
prev.map((m, i) =>
|
||||
i === prev.length - 1 && m.role === "assistant"
|
||||
? { ...m, content: snap }
|
||||
: m
|
||||
)
|
||||
i === prev.length - 1 && m.role === "assistant" ? { ...m, content: snap } : m,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
} catch {
|
||||
/* skip */
|
||||
}
|
||||
}
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ["conversations", app.id] });
|
||||
@@ -441,8 +456,8 @@ ${paragraphs.join("\n")}
|
||||
prev.map((m, i) =>
|
||||
i === prev.length - 1 && m.role === "assistant" && !m.content
|
||||
? { ...m, content: "抱歉,生成公文时发生异常,请重试。" }
|
||||
: m
|
||||
)
|
||||
: m,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
abortRef.current = null;
|
||||
@@ -490,13 +505,13 @@ ${paragraphs.join("\n")}
|
||||
const snap = accumulated;
|
||||
setMessages((prev) =>
|
||||
prev.map((m, i) =>
|
||||
i === prev.length - 1 && m.role === "assistant"
|
||||
? { ...m, content: snap }
|
||||
: m
|
||||
)
|
||||
i === prev.length - 1 && m.role === "assistant" ? { ...m, content: snap } : m,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
} catch {
|
||||
/* skip */
|
||||
}
|
||||
}
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ["conversations", app.id] });
|
||||
@@ -506,8 +521,8 @@ ${paragraphs.join("\n")}
|
||||
prev.map((m, i) =>
|
||||
i === prev.length - 1 && m.role === "assistant" && !m.content
|
||||
? { ...m, content: "抱歉,系统处理异常,请稍后重试。" }
|
||||
: m
|
||||
)
|
||||
: m,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
abortRef.current = null;
|
||||
@@ -522,7 +537,7 @@ ${paragraphs.join("\n")}
|
||||
sendMessage();
|
||||
}
|
||||
},
|
||||
[sendMessage]
|
||||
[sendMessage],
|
||||
);
|
||||
|
||||
const startNewDoc = useCallback(() => {
|
||||
@@ -560,7 +575,7 @@ ${paragraphs.join("\n")}
|
||||
}
|
||||
setDeleteTarget(null);
|
||||
},
|
||||
[app.id, conversationId, startNewDoc, queryClient]
|
||||
[app.id, conversationId, startNewDoc, queryClient],
|
||||
);
|
||||
|
||||
const confirmBatchDelete = useCallback(async () => {
|
||||
@@ -580,14 +595,11 @@ ${paragraphs.join("\n")}
|
||||
setDeleteTarget(null);
|
||||
}, [selectedIds, app.id, conversationId, startNewDoc, queryClient]);
|
||||
|
||||
const startRename = useCallback(
|
||||
(convId: string, currentName: string) => {
|
||||
setEditingConvId(convId);
|
||||
setEditingName(currentName);
|
||||
setTimeout(() => renameInputRef.current?.focus(), 50);
|
||||
},
|
||||
[]
|
||||
);
|
||||
const startRename = useCallback((convId: string, currentName: string) => {
|
||||
setEditingConvId(convId);
|
||||
setEditingName(currentName);
|
||||
setTimeout(() => renameInputRef.current?.focus(), 50);
|
||||
}, []);
|
||||
|
||||
const saveRename = useCallback(async () => {
|
||||
if (!editingConvId || !editingName.trim()) {
|
||||
@@ -595,10 +607,9 @@ ${paragraphs.join("\n")}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.put(
|
||||
`/api/v1/apps/${app.id}/conversations/${editingConvId}/name`,
|
||||
{ name: editingName.trim() }
|
||||
);
|
||||
await api.put(`/api/v1/apps/${app.id}/conversations/${editingConvId}/name`, {
|
||||
name: editingName.trim(),
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ["conversations", app.id] });
|
||||
toast.success("已重命名");
|
||||
} catch {
|
||||
@@ -609,17 +620,12 @@ ${paragraphs.join("\n")}
|
||||
|
||||
const filledRequired = useMemo(() => {
|
||||
if (!selectedTemplate) return false;
|
||||
return selectedTemplate.fields
|
||||
.filter((f) => f.required)
|
||||
.every((f) => fieldData[f.key]?.trim());
|
||||
return selectedTemplate.fields.filter((f) => f.required).every((f) => fieldData[f.key]?.trim());
|
||||
}, [selectedTemplate, fieldData]);
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-3.5rem)] overflow-hidden">
|
||||
<AlertDialog
|
||||
open={!!deleteTarget}
|
||||
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
||||
>
|
||||
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
||||
@@ -656,7 +662,9 @@ ${paragraphs.join("\n")}
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className={`fixed inset-y-[3.5rem] left-0 z-50 w-64 border-r bg-background flex flex-col shrink-0 overflow-hidden transition-transform duration-200 md:static md:inset-y-0 md:translate-x-0 ${sidebarOpen ? "translate-x-0" : "-translate-x-full"}`}>
|
||||
<div
|
||||
className={`fixed inset-y-[3.5rem] left-0 z-50 w-64 border-r bg-background flex flex-col shrink-0 overflow-hidden transition-transform duration-200 md:static md:inset-y-0 md:translate-x-0 ${sidebarOpen ? "translate-x-0" : "-translate-x-full"}`}
|
||||
>
|
||||
<div className="p-3 border-b space-y-2 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -666,7 +674,11 @@ ${paragraphs.join("\n")}
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" /> 返回应用中心
|
||||
</Button>
|
||||
<Button onClick={startNewDoc} className="w-full gap-1.5 bg-blue-900 hover:bg-blue-800 text-white" size="sm">
|
||||
<Button
|
||||
onClick={startNewDoc}
|
||||
className="w-full gap-1.5 bg-blue-900 hover:bg-blue-800 text-white"
|
||||
size="sm"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" /> 新建公文
|
||||
</Button>
|
||||
</div>
|
||||
@@ -679,7 +691,12 @@ ${paragraphs.join("\n")}
|
||||
<div className="flex items-center gap-1">
|
||||
{selectMode ? (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" className="h-6 px-1.5 text-xs" onClick={selectAll}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-1.5 text-xs"
|
||||
onClick={selectAll}
|
||||
>
|
||||
全选
|
||||
</Button>
|
||||
<Button
|
||||
@@ -695,13 +712,21 @@ ${paragraphs.join("\n")}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-1.5 text-xs"
|
||||
onClick={() => { setSelectMode(false); setSelectedIds(new Set()); }}
|
||||
onClick={() => {
|
||||
setSelectMode(false);
|
||||
setSelectedIds(new Set());
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button variant="ghost" size="sm" className="h-6 px-1.5 text-xs" onClick={() => setSelectMode(true)}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-1.5 text-xs"
|
||||
onClick={() => setSelectMode(true)}
|
||||
>
|
||||
<CheckSquare className="h-3 w-3 mr-0.5" /> 管理
|
||||
</Button>
|
||||
)}
|
||||
@@ -711,9 +736,7 @@ ${paragraphs.join("\n")}
|
||||
|
||||
<div className="flex-1 overflow-y-auto min-h-0 p-2">
|
||||
{conversations.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground text-center py-4">
|
||||
暂无生成记录
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground text-center py-4">暂无生成记录</p>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{conversations.map((conv) => (
|
||||
@@ -794,8 +817,12 @@ ${paragraphs.join("\n")}
|
||||
>
|
||||
<PanelLeftOpen className="h-4 w-4" />
|
||||
</button>
|
||||
<div className={`flex h-9 w-9 items-center justify-center rounded-xl ${categoryColor} shrink-0`}>
|
||||
<CategoryIcon className="h-4.5 w-4.5" />
|
||||
<div
|
||||
className={`flex h-9 w-9 items-center justify-center rounded-xl ${categoryColor} shrink-0`}
|
||||
>
|
||||
{CategoryIconComponent
|
||||
? React.createElement(CategoryIconComponent, { className: "h-4.5 w-4.5" })
|
||||
: null}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h1 className="font-semibold text-sm truncate">{app.name}</h1>
|
||||
@@ -806,10 +833,20 @@ ${paragraphs.join("\n")}
|
||||
<div className="ml-auto flex items-center gap-1 md:gap-2">
|
||||
{messages.some((m) => m.role === "assistant" && m.content) && (
|
||||
<>
|
||||
<Button variant="outline" size="sm" className="h-7 gap-1.5 text-xs" onClick={exportAsWord}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 gap-1.5 text-xs"
|
||||
onClick={exportAsWord}
|
||||
>
|
||||
<Download className="h-3 w-3" /> 下载Word
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 gap-1.5 text-xs" onClick={exportConversation}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 gap-1.5 text-xs"
|
||||
onClick={exportConversation}
|
||||
>
|
||||
<Download className="h-3 w-3" /> 导出TXT
|
||||
</Button>
|
||||
</>
|
||||
@@ -832,10 +869,13 @@ ${paragraphs.join("\n")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={`grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 ${isPending ? "opacity-60 pointer-events-none" : ""}`}>
|
||||
<div
|
||||
className={`grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 ${isPending ? "opacity-60 pointer-events-none" : ""}`}
|
||||
>
|
||||
{templates.map((tpl) => {
|
||||
const Icon = iconMap[tpl.icon] || FileText;
|
||||
const colorClass = typeColorMap[tpl.doc_type] || "bg-gray-50 text-gray-700 border-gray-200";
|
||||
const colorClass =
|
||||
typeColorMap[tpl.doc_type] || "bg-gray-50 text-gray-700 border-gray-200";
|
||||
return (
|
||||
<button
|
||||
key={tpl.id}
|
||||
@@ -865,7 +905,12 @@ ${paragraphs.join("\n")}
|
||||
{phase === "form" && selectedTemplate && (
|
||||
<div className="p-4 md:p-6 max-w-3xl mx-auto">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Button variant="ghost" size="sm" onClick={() => setPhase("select")} className="gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setPhase("select")}
|
||||
className="gap-1"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" /> 返回选择
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -878,7 +923,8 @@ ${paragraphs.join("\n")}
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground mb-5">
|
||||
{selectedTemplate.description}。请填写以下信息,<span className="text-destructive">*</span> 为必填项。
|
||||
{selectedTemplate.description}。请填写以下信息,
|
||||
<span className="text-destructive">*</span> 为必填项。
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useState, useMemo, useCallback, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
@@ -100,7 +101,9 @@ export default function WorkflowUI({ app }: WorkflowUIProps) {
|
||||
try {
|
||||
if (typeof app.app_config === "string") return JSON.parse(app.app_config);
|
||||
return app.app_config || {};
|
||||
} catch { return {}; }
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}, [app.app_config]);
|
||||
|
||||
const steps: WorkflowStep[] = appConfig.steps || [];
|
||||
@@ -125,7 +128,7 @@ export default function WorkflowUI({ app }: WorkflowUIProps) {
|
||||
setConversationId(convId);
|
||||
try {
|
||||
const data = await api.get<{ data: Message[] }>(
|
||||
`/api/v1/apps/${app.id}/conversations/${convId}/messages`
|
||||
`/api/v1/apps/${app.id}/conversations/${convId}/messages`,
|
||||
);
|
||||
const msgs = data.data || [];
|
||||
const aiMsg = msgs.find((m) => m.role === "assistant");
|
||||
@@ -137,10 +140,10 @@ export default function WorkflowUI({ app }: WorkflowUIProps) {
|
||||
setOutput("");
|
||||
}
|
||||
},
|
||||
[app.id]
|
||||
[app.id],
|
||||
);
|
||||
|
||||
const CategoryIcon = getCategoryIcon(app.category_slug);
|
||||
const CategoryIconComponent = getCategoryIcon(app.category_slug);
|
||||
const categoryColor = getCategoryColor(app.category_slug);
|
||||
|
||||
const currentStepData = steps[currentStep];
|
||||
@@ -241,8 +244,12 @@ export default function WorkflowUI({ app }: WorkflowUIProps) {
|
||||
|
||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
<div className="border-b px-3 md:px-5 py-3 flex items-center gap-2 md:gap-3 shrink-0">
|
||||
<div className={`flex h-8 w-8 items-center justify-center rounded-lg ${categoryColor} shrink-0`}>
|
||||
<CategoryIcon className="h-4 w-4" />
|
||||
<div
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-lg ${categoryColor} shrink-0`}
|
||||
>
|
||||
{CategoryIconComponent
|
||||
? React.createElement(CategoryIconComponent, { className: "h-4 w-4" })
|
||||
: null}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h1 className="font-semibold text-sm truncate">{app.name}</h1>
|
||||
@@ -255,129 +262,185 @@ export default function WorkflowUI({ app }: WorkflowUIProps) {
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="mx-auto w-full max-w-4xl px-3 md:px-6 lg:px-8 py-4 md:py-6">
|
||||
<div className="flex items-center gap-2 mb-8 overflow-x-auto pb-2">
|
||||
{steps.map((step, idx) => {
|
||||
const done = isComplete || idx < currentStep;
|
||||
const active = idx === currentStep && !isComplete;
|
||||
return (
|
||||
<div key={step.key} className="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
onClick={() => !isComplete && !isLoading && setCurrentStep(idx)}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-sm transition-colors ${
|
||||
active ? "bg-purple-100 text-purple-700 font-medium" : done ? "bg-emerald-50 text-emerald-700" : "text-muted-foreground hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
{done ? <CheckCircle2 className="h-4 w-4" /> : active ? <CircleDot className="h-4 w-4" /> : <Circle className="h-4 w-4" />}
|
||||
{step.label}
|
||||
</button>
|
||||
{idx < steps.length - 1 && <ChevronRight className="h-4 w-4 text-muted-foreground/50 shrink-0" />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{isComplete && (
|
||||
<>
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground/50 shrink-0" />
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-emerald-100 text-emerald-700 font-medium text-sm shrink-0">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
完成
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isComplete && !isLoading && currentStepData && (
|
||||
<Card>
|
||||
<CardContent className="p-6 space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">步骤 {currentStep + 1}:{currentStepData.label}</h2>
|
||||
{currentStepData.description && <p className="text-sm text-muted-foreground mt-1">{currentStepData.description}</p>}
|
||||
</div>
|
||||
|
||||
{currentStepData.type === "select" && currentStepData.options ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
{currentStepData.options.map((opt, idx) => {
|
||||
const Icon = getOptionIcon(opt);
|
||||
const color = getOptionColor(idx);
|
||||
const isSelected = formData[currentStepData.key] === opt;
|
||||
return (
|
||||
<button
|
||||
key={opt}
|
||||
onClick={() => setFormData((prev) => ({ ...prev, [currentStepData.key]: opt }))}
|
||||
className={`flex flex-col items-center gap-2 p-4 rounded-xl border-2 transition-all text-center hover:shadow-md hover:scale-[1.02] ${
|
||||
isSelected
|
||||
? "ring-2 ring-purple-400 ring-offset-2 shadow-md scale-[1.02] " + color
|
||||
: color
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-7 w-7" />
|
||||
<span className="font-medium text-sm">{opt}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<Textarea
|
||||
value={formData[currentStepData.key] || ""}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, [currentStepData.key]: e.target.value }))}
|
||||
placeholder={currentStepData.placeholder || "请输入..."}
|
||||
className="min-h-[140px] resize-none"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<Button variant="outline" onClick={handleBack} disabled={currentStep === 0} className="gap-1.5">
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
上一步
|
||||
</Button>
|
||||
<Button onClick={handleNext} disabled={!canProceed} className="gap-1.5">
|
||||
{isLastStep ? (<><Play className="h-4 w-4" /> 运行</>) : (<>下一步 <ChevronRight className="h-4 w-4" /></>)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{isLoading && !output && (
|
||||
<Card className="border-purple-200/60">
|
||||
<CardContent className="p-8 flex flex-col items-center gap-4">
|
||||
<Loader2 className="h-10 w-10 animate-spin text-purple-500" />
|
||||
<div className="text-center">
|
||||
<p className="font-medium">正在处理中...</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">AI 正在根据您提供的信息生成结果</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{(isComplete || (isLoading && output)) && output && (
|
||||
<Card className={isComplete ? "border-emerald-200/60 bg-emerald-50/30" : "border-purple-200/60 bg-purple-50/20"}>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className={`text-lg font-semibold ${isComplete ? "text-emerald-800" : "text-purple-800"}`}>
|
||||
{isComplete ? "处理结果" : "正在生成..."}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
{isLoading && <Loader2 className="h-4 w-4 animate-spin text-purple-500" />}
|
||||
{isComplete && (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" onClick={handleCopy} className="gap-1.5 text-xs h-7">
|
||||
{copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
|
||||
{copied ? "已复制" : "复制"}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleReset} className="gap-1.5 text-xs h-7">
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
重新开始
|
||||
</Button>
|
||||
</>
|
||||
<div className="mx-auto w-full max-w-4xl px-3 md:px-6 lg:px-8 py-4 md:py-6">
|
||||
<div className="flex items-center gap-2 mb-8 overflow-x-auto pb-2">
|
||||
{steps.map((step, idx) => {
|
||||
const done = isComplete || idx < currentStep;
|
||||
const active = idx === currentStep && !isComplete;
|
||||
return (
|
||||
<div key={step.key} className="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
onClick={() => !isComplete && !isLoading && setCurrentStep(idx)}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-sm transition-colors ${
|
||||
active
|
||||
? "bg-purple-100 text-purple-700 font-medium"
|
||||
: done
|
||||
? "bg-emerald-50 text-emerald-700"
|
||||
: "text-muted-foreground hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
{done ? (
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
) : active ? (
|
||||
<CircleDot className="h-4 w-4" />
|
||||
) : (
|
||||
<Circle className="h-4 w-4" />
|
||||
)}
|
||||
{step.label}
|
||||
</button>
|
||||
{idx < steps.length - 1 && (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground/50 shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<GovMarkdown content={output} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{isComplete && (
|
||||
<>
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground/50 shrink-0" />
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-emerald-100 text-emerald-700 font-medium text-sm shrink-0">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
完成
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isComplete && !isLoading && currentStepData && (
|
||||
<Card>
|
||||
<CardContent className="p-6 space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">
|
||||
步骤 {currentStep + 1}:{currentStepData.label}
|
||||
</h2>
|
||||
{currentStepData.description && (
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{currentStepData.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{currentStepData.type === "select" && currentStepData.options ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
{currentStepData.options.map((opt, idx) => {
|
||||
const Icon = getOptionIcon(opt);
|
||||
const color = getOptionColor(idx);
|
||||
const isSelected = formData[currentStepData.key] === opt;
|
||||
return (
|
||||
<button
|
||||
key={opt}
|
||||
onClick={() =>
|
||||
setFormData((prev) => ({ ...prev, [currentStepData.key]: opt }))
|
||||
}
|
||||
className={`flex flex-col items-center gap-2 p-4 rounded-xl border-2 transition-all text-center hover:shadow-md hover:scale-[1.02] ${
|
||||
isSelected
|
||||
? "ring-2 ring-purple-400 ring-offset-2 shadow-md scale-[1.02] " +
|
||||
color
|
||||
: color
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-7 w-7" />
|
||||
<span className="font-medium text-sm">{opt}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<Textarea
|
||||
value={formData[currentStepData.key] || ""}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({ ...prev, [currentStepData.key]: e.target.value }))
|
||||
}
|
||||
placeholder={currentStepData.placeholder || "请输入..."}
|
||||
className="min-h-[140px] resize-none"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleBack}
|
||||
disabled={currentStep === 0}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
上一步
|
||||
</Button>
|
||||
<Button onClick={handleNext} disabled={!canProceed} className="gap-1.5">
|
||||
{isLastStep ? (
|
||||
<>
|
||||
<Play className="h-4 w-4" /> 运行
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
下一步 <ChevronRight className="h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{isLoading && !output && (
|
||||
<Card className="border-purple-200/60">
|
||||
<CardContent className="p-8 flex flex-col items-center gap-4">
|
||||
<Loader2 className="h-10 w-10 animate-spin text-purple-500" />
|
||||
<div className="text-center">
|
||||
<p className="font-medium">正在处理中...</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
AI 正在根据您提供的信息生成结果
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{(isComplete || (isLoading && output)) && output && (
|
||||
<Card
|
||||
className={
|
||||
isComplete
|
||||
? "border-emerald-200/60 bg-emerald-50/30"
|
||||
: "border-purple-200/60 bg-purple-50/20"
|
||||
}
|
||||
>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2
|
||||
className={`text-lg font-semibold ${isComplete ? "text-emerald-800" : "text-purple-800"}`}
|
||||
>
|
||||
{isComplete ? "处理结果" : "正在生成..."}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
{isLoading && <Loader2 className="h-4 w-4 animate-spin text-purple-500" />}
|
||||
{isComplete && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCopy}
|
||||
className="gap-1.5 text-xs h-7"
|
||||
>
|
||||
{copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
|
||||
{copied ? "已复制" : "复制"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleReset}
|
||||
className="gap-1.5 text-xs h-7"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
重新开始
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<GovMarkdown content={output} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -20,7 +20,19 @@ import { Input } from "@/components/ui/input";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Search, Shield, Building2, Check, Menu, X, Store, LayoutDashboard, PenSquare, BookOpen, Settings } from "lucide-react";
|
||||
import {
|
||||
Search,
|
||||
Shield,
|
||||
Building2,
|
||||
Check,
|
||||
Menu,
|
||||
X,
|
||||
Store,
|
||||
LayoutDashboard,
|
||||
PenSquare,
|
||||
BookOpen,
|
||||
Settings,
|
||||
} from "lucide-react";
|
||||
import { useAuthStore as _useAuthStore } from "@/stores/auth";
|
||||
|
||||
/**
|
||||
@@ -42,7 +54,10 @@ export function Header() {
|
||||
const brandName = getOrgBrand(user?.org);
|
||||
|
||||
useEffect(() => {
|
||||
api.get<Organization[]>("/api/v1/organizations").then(setOrgs).catch(() => {});
|
||||
api
|
||||
.get<Organization[]>("/api/v1/organizations")
|
||||
.then(setOrgs)
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
@@ -74,9 +89,7 @@ export function Header() {
|
||||
|
||||
<Link href="/store" className="flex items-center gap-2 font-bold text-lg shrink-0">
|
||||
<Shield className="h-5 w-5 text-amber-400" />
|
||||
<span className="text-white tracking-wide">
|
||||
{brandName}
|
||||
</span>
|
||||
<span className="text-white tracking-wide">{brandName}</span>
|
||||
</Link>
|
||||
|
||||
<div className="hidden md:block h-5 w-px bg-white/20 mx-1" />
|
||||
@@ -86,33 +99,58 @@ export function Header() {
|
||||
{!isSuperAdmin && (
|
||||
<>
|
||||
<Link href="/store">
|
||||
<Button variant="ghost" size="sm" className="gap-1.5 text-blue-100 hover:text-white hover:bg-white/10">
|
||||
<Store className="h-4 w-4" />应用中心
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-1.5 text-blue-100 hover:text-white hover:bg-white/10"
|
||||
>
|
||||
<Store className="h-4 w-4" />
|
||||
应用中心
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/workspace">
|
||||
<Button variant="ghost" size="sm" className="gap-1.5 text-blue-100 hover:text-white hover:bg-white/10">
|
||||
<LayoutDashboard className="h-4 w-4" />我的工作台
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-1.5 text-blue-100 hover:text-white hover:bg-white/10"
|
||||
>
|
||||
<LayoutDashboard className="h-4 w-4" />
|
||||
我的工作台
|
||||
</Button>
|
||||
</Link>
|
||||
{isCreator && (
|
||||
<Link href="/create">
|
||||
<Button variant="ghost" size="sm" className="gap-1.5 text-blue-100 hover:text-white hover:bg-white/10">
|
||||
<PenSquare className="h-4 w-4" />应用管理
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-1.5 text-blue-100 hover:text-white hover:bg-white/10"
|
||||
>
|
||||
<PenSquare className="h-4 w-4" />
|
||||
应用管理
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
{isCreator && (
|
||||
<Link href="/knowledge">
|
||||
<Button variant="ghost" size="sm" className="gap-1.5 text-blue-100 hover:text-white hover:bg-white/10">
|
||||
<BookOpen className="h-4 w-4" />知识库
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-1.5 text-blue-100 hover:text-white hover:bg-white/10"
|
||||
>
|
||||
<BookOpen className="h-4 w-4" />
|
||||
知识库
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<Link href="/dashboard">
|
||||
<Button variant="ghost" size="sm" className="gap-1.5 text-blue-100 hover:text-white hover:bg-white/10">
|
||||
<Settings className="h-4 w-4" />管理控制台
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-1.5 text-blue-100 hover:text-white hover:bg-white/10"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
管理控制台
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
@@ -121,13 +159,23 @@ export function Header() {
|
||||
{isSuperAdmin && (
|
||||
<>
|
||||
<Link href="/platform/overview">
|
||||
<Button variant="ghost" size="sm" className="gap-1.5 text-amber-200 hover:text-white hover:bg-amber-500/20 border border-amber-500/30">
|
||||
<Shield className="h-4 w-4" />平台管理
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-1.5 text-amber-200 hover:text-white hover:bg-amber-500/20 border border-amber-500/30"
|
||||
>
|
||||
<Shield className="h-4 w-4" />
|
||||
平台管理
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/knowledge">
|
||||
<Button variant="ghost" size="sm" className="gap-1.5 text-blue-100 hover:text-white hover:bg-white/10">
|
||||
<BookOpen className="h-4 w-4" />知识库
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-1.5 text-blue-100 hover:text-white hover:bg-white/10"
|
||||
>
|
||||
<BookOpen className="h-4 w-4" />
|
||||
知识库
|
||||
</Button>
|
||||
</Link>
|
||||
</>
|
||||
@@ -155,7 +203,9 @@ export function Header() {
|
||||
<DropdownMenuTrigger className="relative h-8 w-8 rounded-full focus:outline-none ring-offset-blue-900">
|
||||
<Avatar className="h-8 w-8 border border-white/30">
|
||||
<AvatarImage src={user?.avatar_url} alt={user?.name} />
|
||||
<AvatarFallback className="bg-blue-800 text-white text-sm">{user?.name?.charAt(0) || "U"}</AvatarFallback>
|
||||
<AvatarFallback className="bg-blue-800 text-white text-sm">
|
||||
{user?.name?.charAt(0) || "U"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56" align="end">
|
||||
@@ -190,9 +240,7 @@ export function Header() {
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleLogout}>
|
||||
退出登录
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleLogout}>退出登录</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
@@ -202,7 +250,13 @@ export function Header() {
|
||||
{mobileMenuOpen && (
|
||||
<div className="md:hidden border-t border-white/10 bg-blue-950/95 backdrop-blur-sm">
|
||||
<div className="px-3 py-2">
|
||||
<form onSubmit={(e) => { handleSearch(e); setMobileMenuOpen(false); }} className="relative mb-2 sm:hidden">
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
handleSearch(e);
|
||||
setMobileMenuOpen(false);
|
||||
}}
|
||||
className="relative mb-2 sm:hidden"
|
||||
>
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-blue-300" />
|
||||
<Input
|
||||
placeholder="搜索政务应用..."
|
||||
@@ -221,33 +275,58 @@ export function Header() {
|
||||
{!isSuperAdmin && (
|
||||
<>
|
||||
<Link href="/store" onClick={() => setMobileMenuOpen(false)}>
|
||||
<Button variant="ghost" size="sm" className="w-full justify-start gap-2 text-blue-100 hover:text-white hover:bg-white/10">
|
||||
<Store className="h-4 w-4" />应用中心
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start gap-2 text-blue-100 hover:text-white hover:bg-white/10"
|
||||
>
|
||||
<Store className="h-4 w-4" />
|
||||
应用中心
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/workspace" onClick={() => setMobileMenuOpen(false)}>
|
||||
<Button variant="ghost" size="sm" className="w-full justify-start gap-2 text-blue-100 hover:text-white hover:bg-white/10">
|
||||
<LayoutDashboard className="h-4 w-4" />我的工作台
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start gap-2 text-blue-100 hover:text-white hover:bg-white/10"
|
||||
>
|
||||
<LayoutDashboard className="h-4 w-4" />
|
||||
我的工作台
|
||||
</Button>
|
||||
</Link>
|
||||
{isCreator && (
|
||||
<Link href="/create" onClick={() => setMobileMenuOpen(false)}>
|
||||
<Button variant="ghost" size="sm" className="w-full justify-start gap-2 text-blue-100 hover:text-white hover:bg-white/10">
|
||||
<PenSquare className="h-4 w-4" />应用管理
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start gap-2 text-blue-100 hover:text-white hover:bg-white/10"
|
||||
>
|
||||
<PenSquare className="h-4 w-4" />
|
||||
应用管理
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
{isCreator && (
|
||||
<Link href="/knowledge" onClick={() => setMobileMenuOpen(false)}>
|
||||
<Button variant="ghost" size="sm" className="w-full justify-start gap-2 text-blue-100 hover:text-white hover:bg-white/10">
|
||||
<BookOpen className="h-4 w-4" />知识库
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start gap-2 text-blue-100 hover:text-white hover:bg-white/10"
|
||||
>
|
||||
<BookOpen className="h-4 w-4" />
|
||||
知识库
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<Link href="/dashboard" onClick={() => setMobileMenuOpen(false)}>
|
||||
<Button variant="ghost" size="sm" className="w-full justify-start gap-2 text-blue-100 hover:text-white hover:bg-white/10">
|
||||
<Settings className="h-4 w-4" />管理控制台
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start gap-2 text-blue-100 hover:text-white hover:bg-white/10"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
管理控制台
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
@@ -256,13 +335,23 @@ export function Header() {
|
||||
{isSuperAdmin && (
|
||||
<>
|
||||
<Link href="/platform/overview" onClick={() => setMobileMenuOpen(false)}>
|
||||
<Button variant="ghost" size="sm" className="w-full justify-start gap-2 text-amber-200 hover:text-white hover:bg-amber-500/20">
|
||||
<Shield className="h-4 w-4" />平台管理
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start gap-2 text-amber-200 hover:text-white hover:bg-amber-500/20"
|
||||
>
|
||||
<Shield className="h-4 w-4" />
|
||||
平台管理
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/knowledge" onClick={() => setMobileMenuOpen(false)}>
|
||||
<Button variant="ghost" size="sm" className="w-full justify-start gap-2 text-blue-100 hover:text-white hover:bg-white/10">
|
||||
<BookOpen className="h-4 w-4" />知识库
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start gap-2 text-blue-100 hover:text-white hover:bg-white/10"
|
||||
>
|
||||
<BookOpen className="h-4 w-4" />
|
||||
知识库
|
||||
</Button>
|
||||
</Link>
|
||||
</>
|
||||
|
||||
@@ -1,41 +1,34 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"
|
||||
import * as React from "react";
|
||||
import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||
)
|
||||
return <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||
)
|
||||
return <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />;
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: AlertDialogPrimitive.Backdrop.Props) {
|
||||
function AlertDialogOverlay({ className, ...props }: AlertDialogPrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Backdrop
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 isolate z-50 bg-black/10 supports-backdrop-filter:backdrop-blur-xs",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
@@ -43,7 +36,7 @@ function AlertDialogContent({
|
||||
size = "default",
|
||||
...props
|
||||
}: AlertDialogPrimitive.Popup.Props & {
|
||||
size?: "default" | "sm"
|
||||
size?: "default" | "sm";
|
||||
}) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
@@ -53,60 +46,51 @@ function AlertDialogContent({
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-popover-foreground ring-1 ring-foreground/10 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
className={cn(
|
||||
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn(
|
||||
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogMedia({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
function AlertDialogMedia({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-media"
|
||||
className={cn(
|
||||
"mb-2 inline-flex size-10 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
@@ -118,11 +102,11 @@ function AlertDialogTitle({
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn(
|
||||
"font-heading text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
@@ -134,24 +118,15 @@ function AlertDialogDescription({
|
||||
data-slot="alert-dialog-description"
|
||||
className={cn(
|
||||
"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogAction({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
return (
|
||||
<Button
|
||||
data-slot="alert-dialog-action"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
function AlertDialogAction({ className, ...props }: React.ComponentProps<typeof Button>) {
|
||||
return <Button data-slot="alert-dialog-action" className={cn(className)} {...props} />;
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
@@ -168,7 +143,7 @@ function AlertDialogCancel({
|
||||
render={<Button variant={variant} size={size} />}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -184,4 +159,4 @@ export {
|
||||
AlertDialogPortal,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
|
||||
import * as React from "react";
|
||||
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: AvatarPrimitive.Root.Props & {
|
||||
size?: "default" | "sm" | "lg"
|
||||
size?: "default" | "sm" | "lg";
|
||||
}) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
@@ -18,40 +18,34 @@ function Avatar({
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn(
|
||||
"aspect-square size-full rounded-full object-cover",
|
||||
className
|
||||
)}
|
||||
className={cn("aspect-square size-full rounded-full object-cover", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
className,
|
||||
...props
|
||||
}: AvatarPrimitive.Fallback.Props) {
|
||||
function AvatarFallback({ className, ...props }: AvatarPrimitive.Fallback.Props) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn(
|
||||
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
|
||||
@@ -63,11 +57,11 @@ function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
|
||||
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
|
||||
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
|
||||
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -76,34 +70,24 @@ function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
data-slot="avatar-group"
|
||||
className={cn(
|
||||
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarGroupCount({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
function AvatarGroupCount({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group-count"
|
||||
className={cn(
|
||||
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Avatar,
|
||||
AvatarImage,
|
||||
AvatarFallback,
|
||||
AvatarGroup,
|
||||
AvatarGroupCount,
|
||||
AvatarBadge,
|
||||
}
|
||||
export { Avatar, AvatarImage, AvatarFallback, AvatarGroup, AvatarGroupCount, AvatarBadge };
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { mergeProps } from "@base-ui/react/merge-props";
|
||||
import { useRender } from "@base-ui/react/use-render";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||
@@ -10,22 +10,19 @@ const badgeVariants = cva(
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||
secondary: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||
outline:
|
||||
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||
outline: "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||
ghost: "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
@@ -39,14 +36,14 @@ function Badge({
|
||||
{
|
||||
className: cn(badgeVariants({ variant }), className),
|
||||
},
|
||||
props
|
||||
props,
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "badge",
|
||||
variant,
|
||||
},
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
export { Badge, badgeVariants };
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Button as ButtonPrimitive } from "@base-ui/react/button";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
@@ -37,8 +37,8 @@ const buttonVariants = cva(
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
function Button({
|
||||
className,
|
||||
@@ -52,7 +52,7 @@ function Button({
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
export { Button, buttonVariants };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react"
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Card({
|
||||
className,
|
||||
@@ -13,11 +13,11 @@ function Card({
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -26,11 +26,11 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -39,11 +39,11 @@ function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
data-slot="card-title"
|
||||
className={cn(
|
||||
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -53,20 +53,17 @@ function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -76,7 +73,7 @@ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -85,19 +82,11 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
data-slot="card-footer"
|
||||
className={cn(
|
||||
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent };
|
||||
|
||||
@@ -1,36 +1,30 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import { Command as CommandPrimitive } from "cmdk"
|
||||
import * as React from "react";
|
||||
import { Command as CommandPrimitive } from "cmdk";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
} from "@/components/ui/input-group"
|
||||
import { SearchIcon, CheckIcon } from "lucide-react"
|
||||
} from "@/components/ui/dialog";
|
||||
import { InputGroup, InputGroupAddon } from "@/components/ui/input-group";
|
||||
import { SearchIcon, CheckIcon } from "lucide-react";
|
||||
|
||||
function Command({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||
function Command({ className, ...props }: React.ComponentProps<typeof CommandPrimitive>) {
|
||||
return (
|
||||
<CommandPrimitive
|
||||
data-slot="command"
|
||||
className={cn(
|
||||
"flex size-full flex-col overflow-hidden rounded-xl! bg-popover p-1 text-popover-foreground",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CommandDialog({
|
||||
@@ -41,11 +35,11 @@ function CommandDialog({
|
||||
showCloseButton = false,
|
||||
...props
|
||||
}: Omit<React.ComponentProps<typeof Dialog>, "children"> & {
|
||||
title?: string
|
||||
description?: string
|
||||
className?: string
|
||||
showCloseButton?: boolean
|
||||
children: React.ReactNode
|
||||
title?: string;
|
||||
description?: string;
|
||||
className?: string;
|
||||
showCloseButton?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
@@ -54,16 +48,13 @@ function CommandDialog({
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
"top-1/3 translate-y-0 overflow-hidden rounded-xl! p-0",
|
||||
className
|
||||
)}
|
||||
className={cn("top-1/3 translate-y-0 overflow-hidden rounded-xl! p-0", className)}
|
||||
showCloseButton={showCloseButton}
|
||||
>
|
||||
{children}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CommandInput({
|
||||
@@ -77,7 +68,7 @@ function CommandInput({
|
||||
data-slot="command-input"
|
||||
className={cn(
|
||||
"w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
@@ -86,23 +77,20 @@ function CommandInput({
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CommandList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||
function CommandList({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||
return (
|
||||
<CommandPrimitive.List
|
||||
data-slot="command-list"
|
||||
className={cn(
|
||||
"no-scrollbar max-h-72 scroll-py-1 overflow-x-hidden overflow-y-auto outline-none",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CommandEmpty({
|
||||
@@ -115,7 +103,7 @@ function CommandEmpty({
|
||||
className={cn("py-6 text-center text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CommandGroup({
|
||||
@@ -127,11 +115,11 @@ function CommandGroup({
|
||||
data-slot="command-group"
|
||||
className={cn(
|
||||
"overflow-hidden p-1 text-foreground **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CommandSeparator({
|
||||
@@ -144,7 +132,7 @@ function CommandSeparator({
|
||||
className={cn("-mx-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CommandItem({
|
||||
@@ -157,30 +145,27 @@ function CommandItem({
|
||||
data-slot="command-item"
|
||||
className={cn(
|
||||
"group/command-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:*:[svg]:text-foreground",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<CheckIcon className="ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
|
||||
</CommandPrimitive.Item>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CommandShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
function CommandShortcut({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="command-shortcut"
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground group-data-selected/command-item:text-foreground",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -193,4 +178,4 @@ export {
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,42 +1,39 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
|
||||
import * as React from "react";
|
||||
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { XIcon } from "lucide-react"
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { XIcon } from "lucide-react";
|
||||
|
||||
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||
}
|
||||
|
||||
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: DialogPrimitive.Backdrop.Props) {
|
||||
function DialogOverlay({ className, ...props }: DialogPrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Backdrop
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 isolate z-50 bg-black/10 supports-backdrop-filter:backdrop-blur-xs",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
@@ -45,7 +42,7 @@ function DialogContent({
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: DialogPrimitive.Popup.Props & {
|
||||
showCloseButton?: boolean
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
@@ -54,7 +51,7 @@ function DialogContent({
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 outline-none sm:max-w-sm",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -62,32 +59,21 @@ function DialogContent({
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-2 right-2"
|
||||
size="icon-sm"
|
||||
/>
|
||||
}
|
||||
render={<Button variant="ghost" className="absolute top-2 right-2" size="icon-sm" />}
|
||||
>
|
||||
<XIcon
|
||||
/>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Popup>
|
||||
</DialogPortal>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
<div data-slot="dialog-header" className={cn("flex flex-col gap-2", className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
@@ -96,54 +82,46 @@ function DialogFooter({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close render={<Button variant="outline" />}>
|
||||
Close
|
||||
</DialogPrimitive.Close>
|
||||
<DialogPrimitive.Close render={<Button variant="outline" />}>Close</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn(
|
||||
"font-heading text-base leading-none font-medium",
|
||||
className
|
||||
)}
|
||||
className={cn("font-heading text-base leading-none font-medium", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: DialogPrimitive.Description.Props) {
|
||||
function DialogDescription({ className, ...props }: DialogPrimitive.Description.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn(
|
||||
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -157,4 +135,4 @@ export {
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
|
||||
import * as React from "react";
|
||||
import { Menu as MenuPrimitive } from "@base-ui/react/menu";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronRightIcon, CheckIcon } from "lucide-react"
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronRightIcon, CheckIcon } from "lucide-react";
|
||||
|
||||
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
|
||||
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
|
||||
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
|
||||
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
|
||||
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
@@ -26,10 +26,7 @@ function DropdownMenuContent({
|
||||
className,
|
||||
...props
|
||||
}: MenuPrimitive.Popup.Props &
|
||||
Pick<
|
||||
MenuPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
Pick<MenuPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset">) {
|
||||
return (
|
||||
<MenuPrimitive.Portal>
|
||||
<MenuPrimitive.Positioner
|
||||
@@ -41,16 +38,19 @@ function DropdownMenuContent({
|
||||
>
|
||||
<MenuPrimitive.Popup
|
||||
data-slot="dropdown-menu-content"
|
||||
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
className={cn(
|
||||
"z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</MenuPrimitive.Positioner>
|
||||
</MenuPrimitive.Portal>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
|
||||
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
@@ -58,7 +58,7 @@ function DropdownMenuLabel({
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.GroupLabel.Props & {
|
||||
inset?: boolean
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.GroupLabel
|
||||
@@ -66,11 +66,11 @@ function DropdownMenuLabel({
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
@@ -79,8 +79,8 @@ function DropdownMenuItem({
|
||||
variant = "default",
|
||||
...props
|
||||
}: MenuPrimitive.Item.Props & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
inset?: boolean;
|
||||
variant?: "default" | "destructive";
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.Item
|
||||
@@ -89,15 +89,15 @@ function DropdownMenuItem({
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
|
||||
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
|
||||
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
@@ -106,7 +106,7 @@ function DropdownMenuSubTrigger({
|
||||
children,
|
||||
...props
|
||||
}: MenuPrimitive.SubmenuTrigger.Props & {
|
||||
inset?: boolean
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.SubmenuTrigger
|
||||
@@ -114,14 +114,14 @@ function DropdownMenuSubTrigger({
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto" />
|
||||
</MenuPrimitive.SubmenuTrigger>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
@@ -135,14 +135,17 @@ function DropdownMenuSubContent({
|
||||
return (
|
||||
<DropdownMenuContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn("w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
className={cn(
|
||||
"w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className,
|
||||
)}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
@@ -152,7 +155,7 @@ function DropdownMenuCheckboxItem({
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.CheckboxItem.Props & {
|
||||
inset?: boolean
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.CheckboxItem
|
||||
@@ -160,7 +163,7 @@ function DropdownMenuCheckboxItem({
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
@@ -170,22 +173,16 @@ function DropdownMenuCheckboxItem({
|
||||
data-slot="dropdown-menu-checkbox-item-indicator"
|
||||
>
|
||||
<MenuPrimitive.CheckboxItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
<CheckIcon />
|
||||
</MenuPrimitive.CheckboxItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenuPrimitive.CheckboxItem>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
|
||||
return (
|
||||
<MenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return <MenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
@@ -194,7 +191,7 @@ function DropdownMenuRadioItem({
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.RadioItem.Props & {
|
||||
inset?: boolean
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.RadioItem
|
||||
@@ -202,7 +199,7 @@ function DropdownMenuRadioItem({
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -211,42 +208,35 @@ function DropdownMenuRadioItem({
|
||||
data-slot="dropdown-menu-radio-item-indicator"
|
||||
>
|
||||
<MenuPrimitive.RadioItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
<CheckIcon />
|
||||
</MenuPrimitive.RadioItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenuPrimitive.RadioItem>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: MenuPrimitive.Separator.Props) {
|
||||
function DropdownMenuSeparator({ className, ...props }: MenuPrimitive.Separator.Props) {
|
||||
return (
|
||||
<MenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -265,4 +255,4 @@ export {
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -25,12 +25,8 @@ const mdComponents: Components = {
|
||||
{children}
|
||||
</h3>
|
||||
),
|
||||
p: ({ children }) => (
|
||||
<p className="text-sm leading-7 text-foreground/90 my-2">{children}</p>
|
||||
),
|
||||
ul: ({ children }) => (
|
||||
<ul className="my-2 ml-1 space-y-1">{children}</ul>
|
||||
),
|
||||
p: ({ children }) => <p className="text-sm leading-7 text-foreground/90 my-2">{children}</p>,
|
||||
ul: ({ children }) => <ul className="my-2 ml-1 space-y-1">{children}</ul>,
|
||||
ol: ({ children }) => (
|
||||
<ol className="my-2 ml-1 space-y-1 list-decimal list-inside">{children}</ol>
|
||||
),
|
||||
@@ -50,26 +46,16 @@ const mdComponents: Components = {
|
||||
<table className="w-full text-sm">{children}</table>
|
||||
</div>
|
||||
),
|
||||
thead: ({ children }) => (
|
||||
<thead className="bg-primary/5 border-b">{children}</thead>
|
||||
),
|
||||
thead: ({ children }) => <thead className="bg-primary/5 border-b">{children}</thead>,
|
||||
th: ({ children }) => (
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold text-primary/80 uppercase tracking-wider">
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({ children }) => (
|
||||
<td className="px-3 py-2 text-sm border-b border-muted">{children}</td>
|
||||
),
|
||||
hr: () => (
|
||||
<hr className="my-4 border-t-2 border-dashed border-primary/10" />
|
||||
),
|
||||
strong: ({ children }) => (
|
||||
<strong className="font-semibold text-foreground">{children}</strong>
|
||||
),
|
||||
em: ({ children }) => (
|
||||
<em className="text-primary/80 not-italic font-medium">{children}</em>
|
||||
),
|
||||
td: ({ children }) => <td className="px-3 py-2 text-sm border-b border-muted">{children}</td>,
|
||||
hr: () => <hr className="my-4 border-t-2 border-dashed border-primary/10" />,
|
||||
strong: ({ children }) => <strong className="font-semibold text-foreground">{children}</strong>,
|
||||
em: ({ children }) => <em className="text-primary/80 not-italic font-medium">{children}</em>,
|
||||
a: ({ href, children }) => {
|
||||
if (href === "#cite-kb") {
|
||||
const label = String(children).replace(/^知识库:/, "");
|
||||
@@ -92,12 +78,15 @@ const mdComponents: Components = {
|
||||
if (href?.startsWith("#cite-app:")) {
|
||||
const slug = href.replace("#cite-app:", "");
|
||||
const appName = String(children);
|
||||
return (
|
||||
<AppLinkBadge slug={slug} name={appName} />
|
||||
);
|
||||
return <AppLinkBadge slug={slug} name={appName} />;
|
||||
}
|
||||
return (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer" className="text-primary underline underline-offset-2 hover:text-primary/80">
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline underline-offset-2 hover:text-primary/80"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
@@ -204,7 +193,10 @@ function preprocessCitations(content: string, chunks?: Chunk[]): string {
|
||||
result = result.replace(/^(\s*\>?\s*)AI建议[::]\s*$/gm, "$1[AI建议](#cite-ai)");
|
||||
|
||||
// 推荐应用:[[推荐应用:应用名称:slug]] → 可点击跳转链接
|
||||
result = result.replace(/\[\[推荐应用:([^:]+):([^\]]+)\]\]/g, (_, name, slug) => `[${name}](#cite-app:${slug})`);
|
||||
result = result.replace(
|
||||
/\[\[推荐应用:([^:]+):([^\]]+)\]\]/g,
|
||||
(_, name, slug) => `[${name}](#cite-app:${slug})`,
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -228,4 +220,4 @@ const GovMarkdown = memo(function GovMarkdown({ content, className, chunks }: Go
|
||||
);
|
||||
});
|
||||
|
||||
export default GovMarkdown;
|
||||
export default GovMarkdown;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
@@ -15,11 +15,11 @@ function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
role="group"
|
||||
className={cn(
|
||||
"group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const inputGroupAddonVariants = cva(
|
||||
@@ -27,10 +27,8 @@ const inputGroupAddonVariants = cva(
|
||||
{
|
||||
variants: {
|
||||
align: {
|
||||
"inline-start":
|
||||
"order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
|
||||
"inline-end":
|
||||
"order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
|
||||
"inline-start": "order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
|
||||
"inline-end": "order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
|
||||
"block-start":
|
||||
"order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
|
||||
"block-end":
|
||||
@@ -40,8 +38,8 @@ const inputGroupAddonVariants = cva(
|
||||
defaultVariants: {
|
||||
align: "inline-start",
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
function InputGroupAddon({
|
||||
className,
|
||||
@@ -56,32 +54,28 @@ function InputGroupAddon({
|
||||
className={cn(inputGroupAddonVariants({ align }), className)}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("button")) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
e.currentTarget.parentElement?.querySelector("input")?.focus()
|
||||
e.currentTarget.parentElement?.querySelector("input")?.focus();
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const inputGroupButtonVariants = cva(
|
||||
"flex items-center gap-2 text-sm shadow-none",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
|
||||
sm: "",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0",
|
||||
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
|
||||
},
|
||||
const inputGroupButtonVariants = cva("flex items-center gap-2 text-sm shadow-none", {
|
||||
variants: {
|
||||
size: {
|
||||
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
|
||||
sm: "",
|
||||
"icon-xs": "size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0",
|
||||
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "xs",
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "xs",
|
||||
},
|
||||
});
|
||||
|
||||
function InputGroupButton({
|
||||
className,
|
||||
@@ -91,7 +85,7 @@ function InputGroupButton({
|
||||
...props
|
||||
}: Omit<React.ComponentProps<typeof Button>, "size" | "type"> &
|
||||
VariantProps<typeof inputGroupButtonVariants> & {
|
||||
type?: "button" | "submit" | "reset"
|
||||
type?: "button" | "submit" | "reset";
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
@@ -101,7 +95,7 @@ function InputGroupButton({
|
||||
className={cn(inputGroupButtonVariants({ size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
|
||||
@@ -109,43 +103,37 @@ function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
|
||||
<span
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function InputGroupInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"input">) {
|
||||
function InputGroupInput({ className, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<Input
|
||||
data-slot="input-group-control"
|
||||
className={cn(
|
||||
"flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function InputGroupTextarea({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"textarea">) {
|
||||
function InputGroupTextarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<Textarea
|
||||
data-slot="input-group-control"
|
||||
className={cn(
|
||||
"flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -155,4 +143,4 @@ export {
|
||||
InputGroupText,
|
||||
InputGroupInput,
|
||||
InputGroupTextarea,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from "react"
|
||||
import { Input as InputPrimitive } from "@base-ui/react/input"
|
||||
import * as React from "react";
|
||||
import { Input as InputPrimitive } from "@base-ui/react/input";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
@@ -10,11 +10,11 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Input }
|
||||
export { Input };
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Label({ className, ...props }: React.ComponentProps<"label">) {
|
||||
return (
|
||||
@@ -10,11 +10,11 @@ function Label({ className, ...props }: React.ComponentProps<"label">) {
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Label }
|
||||
export { Label };
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
|
||||
import * as React from "react";
|
||||
import { Popover as PopoverPrimitive } from "@base-ui/react/popover";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Popover({ ...props }: PopoverPrimitive.Root.Props) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
|
||||
}
|
||||
|
||||
function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
@@ -21,10 +21,7 @@ function PopoverContent({
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: PopoverPrimitive.Popup.Props &
|
||||
Pick<
|
||||
PopoverPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
Pick<PopoverPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset">) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Positioner
|
||||
@@ -38,13 +35,13 @@ function PopoverContent({
|
||||
data-slot="popover-content"
|
||||
className={cn(
|
||||
"z-50 flex w-72 origin-(--transform-origin) flex-col gap-2.5 rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Positioner>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -54,7 +51,7 @@ function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
className={cn("flex flex-col gap-0.5 text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
|
||||
@@ -64,27 +61,17 @@ function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
|
||||
className={cn("font-medium", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function PopoverDescription({
|
||||
className,
|
||||
...props
|
||||
}: PopoverPrimitive.Description.Props) {
|
||||
function PopoverDescription({ className, ...props }: PopoverPrimitive.Description.Props) {
|
||||
return (
|
||||
<PopoverPrimitive.Description
|
||||
data-slot="popover-description"
|
||||
className={cn("text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverDescription,
|
||||
PopoverHeader,
|
||||
PopoverTitle,
|
||||
PopoverTrigger,
|
||||
}
|
||||
export { Popover, PopoverContent, PopoverDescription, PopoverHeader, PopoverTitle, PopoverTrigger };
|
||||
|
||||
@@ -1,21 +1,18 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ProgressProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
value?: number
|
||||
value?: number;
|
||||
}
|
||||
|
||||
const Progress: React.FC<ProgressProps> = React.forwardRef<HTMLDivElement, ProgressProps>(
|
||||
({ className, value = 0, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
|
||||
className
|
||||
)}
|
||||
className={cn("relative h-2 w-full overflow-hidden rounded-full bg-primary/20", className)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
@@ -23,8 +20,8 @@ const Progress: React.FC<ProgressProps> = React.forwardRef<HTMLDivElement, Progr
|
||||
style={{ width: `${Math.min(100, Math.max(0, value))}%` }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
)
|
||||
Progress.displayName = "Progress"
|
||||
),
|
||||
);
|
||||
Progress.displayName = "Progress";
|
||||
|
||||
export { Progress }
|
||||
export { Progress };
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
|
||||
import * as React from "react";
|
||||
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ScrollAreaPrimitive.Root.Props) {
|
||||
function ScrollArea({ className, children, ...props }: ScrollAreaPrimitive.Root.Props) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
@@ -25,7 +21,7 @@ function ScrollArea({
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
@@ -40,7 +36,7 @@ function ScrollBar({
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -49,7 +45,7 @@ function ScrollBar({
|
||||
className="relative flex-1 rounded-full bg-border"
|
||||
/>
|
||||
</ScrollAreaPrimitive.Scrollbar>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
export { ScrollArea, ScrollBar };
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import { Select as SelectPrimitive } from "@base-ui/react/select"
|
||||
import * as React from "react";
|
||||
import { Select as SelectPrimitive } from "@base-ui/react/select";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react";
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
const Select = SelectPrimitive.Root;
|
||||
|
||||
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
|
||||
return (
|
||||
@@ -15,7 +15,7 @@ function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
|
||||
className={cn("scroll-my-1 p-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
|
||||
@@ -25,7 +25,7 @@ function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
|
||||
className={cn("flex flex-1 text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
@@ -34,7 +34,7 @@ function SelectTrigger({
|
||||
children,
|
||||
...props
|
||||
}: SelectPrimitive.Trigger.Props & {
|
||||
size?: "sm" | "default"
|
||||
size?: "sm" | "default";
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
@@ -42,18 +42,16 @@ function SelectTrigger({
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon
|
||||
render={
|
||||
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
||||
}
|
||||
render={<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />}
|
||||
/>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
@@ -83,7 +81,10 @@ function SelectContent({
|
||||
<SelectPrimitive.Popup
|
||||
data-slot="select-content"
|
||||
data-align-trigger={alignItemWithTrigger}
|
||||
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
className={cn(
|
||||
"relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
@@ -92,33 +93,26 @@ function SelectContent({
|
||||
</SelectPrimitive.Popup>
|
||||
</SelectPrimitive.Positioner>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: SelectPrimitive.GroupLabel.Props) {
|
||||
function SelectLabel({ className, ...props }: SelectPrimitive.GroupLabel.Props) {
|
||||
return (
|
||||
<SelectPrimitive.GroupLabel
|
||||
data-slot="select-label"
|
||||
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SelectPrimitive.Item.Props) {
|
||||
function SelectItem({ className, children, ...props }: SelectPrimitive.Item.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -133,20 +127,17 @@ function SelectItem({
|
||||
<CheckIcon className="pointer-events-none" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: SelectPrimitive.Separator.Props) {
|
||||
function SelectSeparator({ className, ...props }: SelectPrimitive.Separator.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
@@ -158,14 +149,13 @@ function SelectScrollUpButton({
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon
|
||||
/>
|
||||
<ChevronUpIcon />
|
||||
</SelectPrimitive.ScrollUpArrow>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
@@ -177,14 +167,13 @@ function SelectScrollDownButton({
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon
|
||||
/>
|
||||
<ChevronDownIcon />
|
||||
</SelectPrimitive.ScrollDownArrow>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -198,4 +187,4 @@ export {
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,25 +1,21 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
|
||||
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: SeparatorPrimitive.Props) {
|
||||
function Separator({ className, orientation = "horizontal", ...props }: SeparatorPrimitive.Props) {
|
||||
return (
|
||||
<SeparatorPrimitive
|
||||
data-slot="separator"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
export { Separator };
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
|
||||
import * as React from "react";
|
||||
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { XIcon } from "lucide-react"
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { XIcon } from "lucide-react";
|
||||
|
||||
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
|
||||
}
|
||||
|
||||
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
|
||||
}
|
||||
|
||||
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
|
||||
}
|
||||
|
||||
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
|
||||
@@ -29,11 +29,11 @@ function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/10 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
@@ -43,8 +43,8 @@ function SheetContent({
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: SheetPrimitive.Popup.Props & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
showCloseButton?: boolean
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
@@ -54,7 +54,7 @@ function SheetContent({
|
||||
data-side={side}
|
||||
className={cn(
|
||||
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -62,22 +62,15 @@ function SheetContent({
|
||||
{showCloseButton && (
|
||||
<SheetPrimitive.Close
|
||||
data-slot="sheet-close"
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-3 right-3"
|
||||
size="icon-sm"
|
||||
/>
|
||||
}
|
||||
render={<Button variant="ghost" className="absolute top-3 right-3" size="icon-sm" />}
|
||||
>
|
||||
<XIcon
|
||||
/>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
)}
|
||||
</SheetPrimitive.Popup>
|
||||
</SheetPortal>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -87,7 +80,7 @@ function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
className={cn("flex flex-col gap-0.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@@ -97,33 +90,27 @@ function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn(
|
||||
"font-heading text-base font-medium text-foreground",
|
||||
className
|
||||
)}
|
||||
className={cn("font-heading text-base font-medium text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: SheetPrimitive.Description.Props) {
|
||||
function SheetDescription({ className, ...props }: SheetPrimitive.Description.Props) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -135,4 +122,4 @@ export {
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
@@ -7,7 +7,7 @@ function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
export { Skeleton };
|
||||
|
||||
@@ -1,32 +1,28 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner, type ToasterProps } from "sonner"
|
||||
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
|
||||
import { useTheme } from "next-themes";
|
||||
import { Toaster as Sonner, type ToasterProps } from "sonner";
|
||||
import {
|
||||
CircleCheckIcon,
|
||||
InfoIcon,
|
||||
TriangleAlertIcon,
|
||||
OctagonXIcon,
|
||||
Loader2Icon,
|
||||
} from "lucide-react";
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
const { theme = "system" } = useTheme();
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
icons={{
|
||||
success: (
|
||||
<CircleCheckIcon className="size-4" />
|
||||
),
|
||||
info: (
|
||||
<InfoIcon className="size-4" />
|
||||
),
|
||||
warning: (
|
||||
<TriangleAlertIcon className="size-4" />
|
||||
),
|
||||
error: (
|
||||
<OctagonXIcon className="size-4" />
|
||||
),
|
||||
loading: (
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
),
|
||||
success: <CircleCheckIcon className="size-4" />,
|
||||
info: <InfoIcon className="size-4" />,
|
||||
warning: <TriangleAlertIcon className="size-4" />,
|
||||
error: <OctagonXIcon className="size-4" />,
|
||||
loading: <Loader2Icon className="size-4 animate-spin" />,
|
||||
}}
|
||||
style={
|
||||
{
|
||||
@@ -43,7 +39,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export { Toaster }
|
||||
export { Toaster };
|
||||
|
||||
@@ -1,26 +1,19 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: TabsPrimitive.Root.Props) {
|
||||
function Tabs({ className, orientation = "horizontal", ...props }: TabsPrimitive.Root.Props) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
data-orientation={orientation}
|
||||
className={cn(
|
||||
"group/tabs flex gap-2 data-horizontal:flex-col",
|
||||
className
|
||||
)}
|
||||
className={cn("group/tabs flex gap-2 data-horizontal:flex-col", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const tabsListVariants = cva(
|
||||
@@ -35,8 +28,8 @@ const tabsListVariants = cva(
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
@@ -50,7 +43,7 @@ function TabsList({
|
||||
className={cn(tabsListVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
|
||||
@@ -62,11 +55,11 @@ function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
||||
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
|
||||
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
|
||||
@@ -76,7 +69,7 @@ function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
|
||||
className={cn("flex-1 text-sm outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react"
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
@@ -8,11 +8,11 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
export { Textarea };
|
||||
|
||||
@@ -1,28 +1,19 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
|
||||
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function TooltipProvider({
|
||||
delay = 0,
|
||||
...props
|
||||
}: TooltipPrimitive.Provider.Props) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delay={delay}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
function TooltipProvider({ delay = 0, ...props }: TooltipPrimitive.Provider.Props) {
|
||||
return <TooltipPrimitive.Provider data-slot="tooltip-provider" delay={delay} {...props} />;
|
||||
}
|
||||
|
||||
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
|
||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
|
||||
}
|
||||
|
||||
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
@@ -34,10 +25,7 @@ function TooltipContent({
|
||||
children,
|
||||
...props
|
||||
}: TooltipPrimitive.Popup.Props &
|
||||
Pick<
|
||||
TooltipPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
Pick<TooltipPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset">) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Positioner
|
||||
@@ -51,7 +39,7 @@ function TooltipContent({
|
||||
data-slot="tooltip-content"
|
||||
className={cn(
|
||||
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -60,7 +48,7 @@ function TooltipContent({
|
||||
</TooltipPrimitive.Popup>
|
||||
</TooltipPrimitive.Positioner>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
export {
|
||||
useSSEStream,
|
||||
updateLastAssistantMessage,
|
||||
setStreamErrorMessage,
|
||||
} from "./use-sse-stream";
|
||||
export { useSSEStream, updateLastAssistantMessage, setStreamErrorMessage } from "./use-sse-stream";
|
||||
export { useCopyToClipboard } from "./use-copy-clipboard";
|
||||
export { useScrollToBottom } from "./use-scroll-bottom";
|
||||
export { useFileExport } from "./use-file-export";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useRef } from "react";
|
||||
import { useMemo } from "react";
|
||||
import type { App } from "@/lib/types";
|
||||
|
||||
/**
|
||||
@@ -10,8 +10,7 @@ import type { App } from "@/lib/types";
|
||||
export function useAppConfig(app: App): Record<string, unknown> {
|
||||
return useMemo(() => {
|
||||
try {
|
||||
if (typeof app.app_config === "string")
|
||||
return JSON.parse(app.app_config);
|
||||
if (typeof app.app_config === "string") return JSON.parse(app.app_config);
|
||||
return app.app_config || {};
|
||||
} catch {
|
||||
return {};
|
||||
@@ -24,15 +23,13 @@ export function useAppConfig(app: App): Record<string, unknown> {
|
||||
* chatbot-ui / agent-ui 共用。
|
||||
*/
|
||||
export function useSuggestedPrompts(app: App): string[] {
|
||||
return useRef(
|
||||
(() => {
|
||||
try {
|
||||
if (typeof app.suggested_prompts === "string")
|
||||
return JSON.parse(app.suggested_prompts) as string[];
|
||||
return (app.suggested_prompts as string[]) || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
})(),
|
||||
).current;
|
||||
return useMemo(() => {
|
||||
try {
|
||||
if (typeof app.suggested_prompts === "string")
|
||||
return JSON.parse(app.suggested_prompts) as string[];
|
||||
return (app.suggested_prompts as string[]) || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}, [app.suggested_prompts]);
|
||||
}
|
||||
|
||||
@@ -8,24 +8,21 @@ import { toast } from "sonner";
|
||||
* chatbot-ui / agent-ui / doc-writer-ui / analysis-ui 的导出功能共用。
|
||||
*/
|
||||
export function useFileExport() {
|
||||
const download = useCallback(
|
||||
(content: string, filename: string, successMsg = "已导出") => {
|
||||
const blob = new Blob([content], { type: "application/octet-stream" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.style.display = "none";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}, 30000);
|
||||
toast.success(successMsg);
|
||||
},
|
||||
[],
|
||||
);
|
||||
const download = useCallback((content: string, filename: string, successMsg = "已导出") => {
|
||||
const blob = new Blob([content], { type: "application/octet-stream" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.style.display = "none";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}, 30000);
|
||||
toast.success(successMsg);
|
||||
}, []);
|
||||
|
||||
return { download };
|
||||
}
|
||||
|
||||
@@ -21,10 +21,7 @@ export function useSSEStream(options: UseSSEStreamOptions = {}) {
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const processStream = useCallback(
|
||||
async (
|
||||
response: Response,
|
||||
onChunk: (accumulated: string) => void,
|
||||
) => {
|
||||
async (response: Response, onChunk: (accumulated: string) => void) => {
|
||||
if (!response.ok) throw new Error("请求失败");
|
||||
const reader = response.body?.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
@@ -90,14 +87,9 @@ export function useSSEStream(options: UseSSEStreamOptions = {}) {
|
||||
* 更新消息列表中最后一条 assistant 消息的内容。
|
||||
* chatbot-ui / agent-ui / doc-writer-ui / analysis-ui 共用此逻辑。
|
||||
*/
|
||||
export function updateLastAssistantMessage(
|
||||
prev: Message[],
|
||||
content: string,
|
||||
): Message[] {
|
||||
export function updateLastAssistantMessage(prev: Message[], content: string): Message[] {
|
||||
return prev.map((m, i) =>
|
||||
i === prev.length - 1 && m.role === "assistant"
|
||||
? { ...m, content }
|
||||
: m,
|
||||
i === prev.length - 1 && m.role === "assistant" ? { ...m, content } : m,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+11
-21
@@ -42,10 +42,7 @@ function getAuthHeaders(): Record<string, string> {
|
||||
return {};
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
path: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const url = `${API_BASE}${path}`;
|
||||
const { headers: optHeaders, ...restOptions } = options;
|
||||
const res = await fetch(url, {
|
||||
@@ -92,7 +89,10 @@ async function request<T>(
|
||||
}
|
||||
|
||||
if (!res.ok || json.code !== 0) {
|
||||
throw new APIError(json.code || res.status, json.message || httpErrors[res.status] || "请求失败,请稍后重试");
|
||||
throw new APIError(
|
||||
json.code || res.status,
|
||||
json.message || httpErrors[res.status] || "请求失败,请稍后重试",
|
||||
);
|
||||
}
|
||||
return json.data;
|
||||
}
|
||||
@@ -110,7 +110,7 @@ export function streamChat(
|
||||
appId: string,
|
||||
message: string,
|
||||
conversationId?: string,
|
||||
signal?: AbortSignal
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const url = `${STREAM_BASE}/api/v1/apps/${appId}/chat`;
|
||||
return fetch(url, {
|
||||
@@ -134,11 +134,7 @@ export async function completionRequest(appId: string, message: string, signal?:
|
||||
});
|
||||
}
|
||||
|
||||
export function streamCompletion(
|
||||
appId: string,
|
||||
message: string,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
export function streamCompletion(appId: string, message: string, signal?: AbortSignal) {
|
||||
const url = `${STREAM_BASE}/api/v1/apps/${appId}/completion`;
|
||||
return fetch(url, {
|
||||
method: "POST",
|
||||
@@ -152,7 +148,7 @@ export function streamGenerateDoc(
|
||||
appId: string,
|
||||
templateId: string,
|
||||
fieldData: Record<string, string>,
|
||||
signal?: AbortSignal
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const url = `${STREAM_BASE}/api/v1/apps/${appId}/generate-doc`;
|
||||
return fetch(url, {
|
||||
@@ -167,7 +163,7 @@ export function streamGenerateAnalysis(
|
||||
appId: string,
|
||||
templateId: string,
|
||||
fieldData: Record<string, string>,
|
||||
signal?: AbortSignal
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const url = `${STREAM_BASE}/api/v1/apps/${appId}/generate-analysis`;
|
||||
return fetch(url, {
|
||||
@@ -210,11 +206,7 @@ export async function createPPTTask(data: {
|
||||
return api.post<{ task_id: string; status: string }>("/api/v1/ppt/tasks", data);
|
||||
}
|
||||
|
||||
export async function createPPTTaskWithFile(
|
||||
file: File,
|
||||
title: string,
|
||||
config: PPTTaskConfig
|
||||
) {
|
||||
export async function createPPTTaskWithFile(file: File, title: string, config: PPTTaskConfig) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("title", title);
|
||||
@@ -242,9 +234,7 @@ export async function listPPTTasks() {
|
||||
}
|
||||
|
||||
export function getPPTDownloadURL(taskId: string) {
|
||||
const base = typeof window !== "undefined"
|
||||
? process.env.NEXT_PUBLIC_API_URL || ""
|
||||
: "";
|
||||
const base = typeof window !== "undefined" ? process.env.NEXT_PUBLIC_API_URL || "" : "";
|
||||
return `${base}/api/v1/ppt/tasks/${taskId}/download`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,52 @@
|
||||
import {
|
||||
Scale, MessageSquareText, FileSearch, FilePenLine, FilePlus, ScanText,
|
||||
ShieldAlert, ClipboardCheck, ShieldCheck, BrainCircuit, FileCheck,
|
||||
ClipboardList, BarChart3, Building2, Bot, Cpu, Layers, Star,
|
||||
BookOpen, FileText, Mic, Image, Database, Search, BarChart2,
|
||||
PieChart, TrendingUp, Globe, Mail, Phone, Users, Map, Calendar,
|
||||
Clock, AlertCircle, CheckCircle, XCircle, Info, Settings, Home,
|
||||
FolderOpen, Download, Upload, Share2, Edit, Trash, Plus, Minus,
|
||||
Scale,
|
||||
MessageSquareText,
|
||||
FileSearch,
|
||||
FilePenLine,
|
||||
FilePlus,
|
||||
ScanText,
|
||||
ShieldAlert,
|
||||
ClipboardCheck,
|
||||
ShieldCheck,
|
||||
BrainCircuit,
|
||||
FileCheck,
|
||||
ClipboardList,
|
||||
BarChart3,
|
||||
Building2,
|
||||
Bot,
|
||||
Cpu,
|
||||
Layers,
|
||||
Star,
|
||||
BookOpen,
|
||||
FileText,
|
||||
Mic,
|
||||
Image,
|
||||
Database,
|
||||
Search,
|
||||
BarChart2,
|
||||
PieChart,
|
||||
TrendingUp,
|
||||
Globe,
|
||||
Mail,
|
||||
Phone,
|
||||
Users,
|
||||
Map,
|
||||
Calendar,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Info,
|
||||
Settings,
|
||||
Home,
|
||||
FolderOpen,
|
||||
Download,
|
||||
Upload,
|
||||
Share2,
|
||||
Edit,
|
||||
Trash,
|
||||
Plus,
|
||||
Minus,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
@@ -77,7 +118,11 @@ export function AppIcon({ iconUrl, size = 20, className = "" }: AppIconProps) {
|
||||
return <Bot style={{ width: size, height: size }} className={className} />;
|
||||
}
|
||||
if (isEmoji(iconUrl)) {
|
||||
return <span style={{ fontSize: size }} className={className}>{iconUrl}</span>;
|
||||
return (
|
||||
<span style={{ fontSize: size }} className={className}>
|
||||
{iconUrl}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const Icon = ICON_MAP[iconUrl.toLowerCase()] ?? Bot;
|
||||
return <Icon style={{ width: size, height: size }} className={className} />;
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
MessagesSquare,
|
||||
PenTool,
|
||||
Workflow,
|
||||
BrainCircuit,
|
||||
} from "lucide-react";
|
||||
import { MessagesSquare, PenTool, Workflow, BrainCircuit } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
export type AppType = "chatbot" | "completion" | "workflow" | "agent";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
@@ -63,4 +63,4 @@ export default {
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
} satisfies Config;
|
||||
} satisfies Config;
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.4 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
Reference in New Issue
Block a user