work-agent 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +234 -0
- package/app/(admin)/approvals/page.tsx +16 -0
- package/app/(admin)/audit/page.tsx +18 -0
- package/app/(admin)/layout.tsx +47 -0
- package/app/(admin)/scheduled-tasks/page.tsx +17 -0
- package/app/(admin)/settings/page.tsx +46 -0
- package/app/(admin)/skills/[name]/page.tsx +378 -0
- package/app/(admin)/skills/page.tsx +406 -0
- package/app/(admin)/statistics/page.tsx +416 -0
- package/app/(admin)/tickets/[id]/page.tsx +348 -0
- package/app/(admin)/tickets/new/page.tsx +309 -0
- package/app/(admin)/tickets/page.tsx +27 -0
- package/app/api/audit/route.ts +30 -0
- package/app/api/auth/feishu/callback/route.ts +72 -0
- package/app/api/auth/feishu/login/route.ts +17 -0
- package/app/api/auth/feishu/sso/route.ts +78 -0
- package/app/api/auth/login/route.ts +85 -0
- package/app/api/auth/oauth/route.ts +168 -0
- package/app/api/config/providers/route.ts +105 -0
- package/app/api/config/route.ts +115 -0
- package/app/api/config/status/route.ts +56 -0
- package/app/api/config/test/route.ts +212 -0
- package/app/api/documents/[id]/route.ts +88 -0
- package/app/api/documents/route.ts +53 -0
- package/app/api/health/route.ts +32 -0
- package/app/api/knowledge/[id]/route.ts +152 -0
- package/app/api/knowledge/from-session/route.ts +27 -0
- package/app/api/knowledge/route.ts +100 -0
- package/app/api/market/knowledge/[id]/route.ts +92 -0
- package/app/api/market/knowledge/route.ts +130 -0
- package/app/api/marketplace/skills/[id]/approve/route.ts +68 -0
- package/app/api/marketplace/skills/[id]/certify/route.ts +54 -0
- package/app/api/marketplace/skills/[id]/install/route.ts +180 -0
- package/app/api/marketplace/skills/[id]/promote-to-system/route.ts +219 -0
- package/app/api/marketplace/skills/[id]/rate/route.ts +90 -0
- package/app/api/marketplace/skills/[id]/ratings/route.ts +55 -0
- package/app/api/marketplace/skills/[id]/reject/route.ts +68 -0
- package/app/api/marketplace/skills/[id]/route.ts +177 -0
- package/app/api/marketplace/skills/route.ts +235 -0
- package/app/api/memory/route.ts +40 -0
- package/app/api/my/files/[id]/route.ts +52 -0
- package/app/api/my/files/route.ts +230 -0
- package/app/api/my/knowledge/route.ts +36 -0
- package/app/api/pi-chat/route.ts +443 -0
- package/app/api/recommend/route.ts +38 -0
- package/app/api/scheduled-tasks/[id]/execute/route.ts +132 -0
- package/app/api/scheduled-tasks/[id]/route.ts +165 -0
- package/app/api/scheduled-tasks/[id]/toggle/route.ts +53 -0
- package/app/api/scheduled-tasks/route.ts +101 -0
- package/app/api/sessions/[id]/messages/route.ts +212 -0
- package/app/api/sessions/route.ts +101 -0
- package/app/api/share/file/[id]/route.ts +37 -0
- package/app/api/skills/[name]/execute/route.ts +121 -0
- package/app/api/skills/[name]/route.ts +167 -0
- package/app/api/skills/create/route.ts +65 -0
- package/app/api/skills/generate/route.ts +405 -0
- package/app/api/skills/installed/route.ts +151 -0
- package/app/api/skills/route.ts +174 -0
- package/app/api/skills/translate/route.ts +40 -0
- package/app/api/skills/user/[name]/route.ts +159 -0
- package/app/api/skills/user/route.ts +90 -0
- package/app/api/statistics/route.ts +94 -0
- package/app/api/task-executions/[id]/route.ts +34 -0
- package/app/api/task-executions/route.ts +29 -0
- package/app/api/tickets/[id]/approve/route.ts +129 -0
- package/app/api/tickets/[id]/execute/route.ts +201 -0
- package/app/api/tickets/[id]/route.ts +127 -0
- package/app/api/tickets/route.ts +103 -0
- package/app/api/user/skills/route.ts +175 -0
- package/app/api/users/route.ts +80 -0
- package/app/chat/page.tsx +5 -0
- package/app/globals.css +84 -0
- package/app/h5/layout.tsx +5 -0
- package/app/h5/mobile-approvals-page.tsx +167 -0
- package/app/h5/mobile-chat-page.tsx +951 -0
- package/app/h5/mobile-profile-page.tsx +147 -0
- package/app/h5/mobile-tickets-page.tsx +121 -0
- package/app/h5/page.tsx +23 -0
- package/app/h5/ticket-action-buttons.tsx +80 -0
- package/app/layout.tsx +26 -0
- package/app/login/page.tsx +318 -0
- package/app/market/knowledge/[id]/page.tsx +77 -0
- package/app/market/knowledge/page.tsx +358 -0
- package/app/market/layout.tsx +29 -0
- package/app/market/page.tsx +18 -0
- package/app/market/skills/page.tsx +397 -0
- package/app/my/files/page.tsx +511 -0
- package/app/my/knowledge/[id]/page.tsx +271 -0
- package/app/my/knowledge/new/page.tsx +234 -0
- package/app/my/knowledge/page.tsx +248 -0
- package/app/my/layout.tsx +32 -0
- package/app/my/memory/page.tsx +164 -0
- package/app/my/page.tsx +18 -0
- package/app/my/scheduled-tasks/[id]/edit/page.tsx +290 -0
- package/app/my/scheduled-tasks/[id]/executions/page.tsx +275 -0
- package/app/my/scheduled-tasks/[id]/page.tsx +284 -0
- package/app/my/scheduled-tasks/new/page.tsx +230 -0
- package/app/my/scheduled-tasks/page.tsx +27 -0
- package/app/my/skills/[name]/page.tsx +320 -0
- package/app/my/skills/new/page.tsx +394 -0
- package/app/my/skills/page.tsx +303 -0
- package/app/page.tsx +2288 -0
- package/app/share/[sessionId]/page.tsx +226 -0
- package/app/share/file/[id]/page.tsx +140 -0
- package/bin/README.md +63 -0
- package/bin/generate-api-system +300 -0
- package/bin/postinstall.js +95 -0
- package/bin/work-agent.js +173 -0
- package/components/ai-elements/agent.tsx +142 -0
- package/components/ai-elements/artifact.tsx +149 -0
- package/components/ai-elements/attachments.tsx +427 -0
- package/components/ai-elements/audio-player.tsx +232 -0
- package/components/ai-elements/canvas.tsx +26 -0
- package/components/ai-elements/chain-of-thought.tsx +223 -0
- package/components/ai-elements/checkpoint.tsx +72 -0
- package/components/ai-elements/code-block.tsx +555 -0
- package/components/ai-elements/commit.tsx +449 -0
- package/components/ai-elements/confirmation.tsx +173 -0
- package/components/ai-elements/connection.tsx +28 -0
- package/components/ai-elements/context.tsx +410 -0
- package/components/ai-elements/controls.tsx +19 -0
- package/components/ai-elements/conversation.tsx +167 -0
- package/components/ai-elements/edge.tsx +144 -0
- package/components/ai-elements/environment-variables.tsx +325 -0
- package/components/ai-elements/file-tree.tsx +298 -0
- package/components/ai-elements/image.tsx +25 -0
- package/components/ai-elements/inline-citation.tsx +294 -0
- package/components/ai-elements/jsx-preview.tsx +250 -0
- package/components/ai-elements/message.tsx +367 -0
- package/components/ai-elements/mic-selector.tsx +372 -0
- package/components/ai-elements/model-selector.tsx +214 -0
- package/components/ai-elements/node.tsx +72 -0
- package/components/ai-elements/open-in-chat.tsx +367 -0
- package/components/ai-elements/package-info.tsx +235 -0
- package/components/ai-elements/panel.tsx +16 -0
- package/components/ai-elements/persona.tsx +280 -0
- package/components/ai-elements/plan.tsx +144 -0
- package/components/ai-elements/prompt-input.tsx +1341 -0
- package/components/ai-elements/queue.tsx +275 -0
- package/components/ai-elements/reasoning.tsx +355 -0
- package/components/ai-elements/sandbox.tsx +133 -0
- package/components/ai-elements/schema-display.tsx +473 -0
- package/components/ai-elements/shimmer.tsx +78 -0
- package/components/ai-elements/snippet.tsx +141 -0
- package/components/ai-elements/sources.tsx +78 -0
- package/components/ai-elements/speech-input.tsx +324 -0
- package/components/ai-elements/stack-trace.tsx +531 -0
- package/components/ai-elements/suggestion.tsx +58 -0
- package/components/ai-elements/task.tsx +88 -0
- package/components/ai-elements/terminal.tsx +277 -0
- package/components/ai-elements/test-results.tsx +497 -0
- package/components/ai-elements/tool.tsx +174 -0
- package/components/ai-elements/toolbar.tsx +17 -0
- package/components/ai-elements/transcription.tsx +126 -0
- package/components/ai-elements/voice-selector.tsx +525 -0
- package/components/ai-elements/web-preview.tsx +282 -0
- package/components/audit-log-list.tsx +114 -0
- package/components/chat/EmptyPreviewState.tsx +12 -0
- package/components/chat/KnowledgePickerDialog.tsx +464 -0
- package/components/chat/KnowledgePreview.tsx +70 -0
- package/components/chat/KnowledgePreviewPanel.tsx +86 -0
- package/components/chat/MentionInput.tsx +309 -0
- package/components/chat/OrganizeDialog.tsx +258 -0
- package/components/chat/RecommendationBanner.tsx +94 -0
- package/components/chat/SaveToKnowledgeDialog.tsx +193 -0
- package/components/chat/SkillSelector.tsx +305 -0
- package/components/chat/SkillSwitcher.tsx +163 -0
- package/components/client-layout.tsx +15 -0
- package/components/knowledge/KnowledgeMetadataPanel.tsx +293 -0
- package/components/layout-wrapper.tsx +18 -0
- package/components/mobile-layout.tsx +62 -0
- package/components/scheduled-task-list.tsx +356 -0
- package/components/setup-guide.tsx +484 -0
- package/components/sub-nav.tsx +54 -0
- package/components/ticket-detail-content.tsx +383 -0
- package/components/ticket-list.tsx +366 -0
- package/components/top-nav.tsx +132 -0
- package/components/ui/accordion.tsx +58 -0
- package/components/ui/alert.tsx +59 -0
- package/components/ui/avatar.tsx +50 -0
- package/components/ui/badge.tsx +36 -0
- package/components/ui/button-group.tsx +83 -0
- package/components/ui/button.tsx +57 -0
- package/components/ui/card.tsx +91 -0
- package/components/ui/carousel.tsx +262 -0
- package/components/ui/collapsible.tsx +11 -0
- package/components/ui/command.tsx +153 -0
- package/components/ui/dialog.tsx +122 -0
- package/components/ui/dropdown-menu.tsx +200 -0
- package/components/ui/hover-card.tsx +29 -0
- package/components/ui/input-group.tsx +170 -0
- package/components/ui/input.tsx +22 -0
- package/components/ui/label.tsx +26 -0
- package/components/ui/popover.tsx +31 -0
- package/components/ui/progress.tsx +28 -0
- package/components/ui/scroll-area.tsx +48 -0
- package/components/ui/select.tsx +174 -0
- package/components/ui/separator.tsx +31 -0
- package/components/ui/spinner.tsx +16 -0
- package/components/ui/switch.tsx +29 -0
- package/components/ui/table.tsx +120 -0
- package/components/ui/tabs.tsx +55 -0
- package/components/ui/textarea.tsx +22 -0
- package/components/ui/tooltip.tsx +30 -0
- package/components/welcome-guide.tsx +182 -0
- package/components.json +24 -0
- package/lib/command-parser.ts +331 -0
- package/lib/dangerous-commands.ts +672 -0
- package/lib/db.ts +2250 -0
- package/lib/feishu-auth.ts +135 -0
- package/lib/file-storage.ts +306 -0
- package/lib/file-tool.ts +583 -0
- package/lib/knowledge-tool.ts +152 -0
- package/lib/knowledge-types.ts +66 -0
- package/lib/market-client.ts +313 -0
- package/lib/market-db.ts +736 -0
- package/lib/market-types.ts +51 -0
- package/lib/memory-tool.ts +211 -0
- package/lib/memory.ts +197 -0
- package/lib/pi-config.ts +436 -0
- package/lib/pi-session.ts +799 -0
- package/lib/pinyin.ts +13 -0
- package/lib/recommendation.ts +227 -0
- package/lib/risk-estimator.ts +350 -0
- package/lib/scheduled-task-tool.ts +184 -0
- package/lib/scheduler-init.ts +43 -0
- package/lib/scheduler.ts +416 -0
- package/lib/secure-bash-tool.ts +413 -0
- package/lib/skill-engine.ts +396 -0
- package/lib/skill-generator.ts +269 -0
- package/lib/skill-loader.ts +234 -0
- package/lib/skill-tool.ts +188 -0
- package/lib/skill-types.ts +82 -0
- package/lib/skills-init.ts +58 -0
- package/lib/ticket-tool.ts +246 -0
- package/lib/user-skill-types.ts +30 -0
- package/lib/user-skills.ts +362 -0
- package/lib/utils.ts +6 -0
- package/lib/workflow.ts +154 -0
- package/lib/zip-tool.ts +191 -0
- package/next.config.js +8 -0
- package/package.json +106 -0
- package/public/.gitkeep +1 -0
- package/public/icon.svg +1 -0
- package/tsconfig.json +42 -0
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect } from 'react';
|
|
4
|
+
import Link from 'next/link';
|
|
5
|
+
import { Terminal, Wrench, CheckCircle, XCircle, Clock, Zap, Trash2 } from 'lucide-react';
|
|
6
|
+
import type { Ticket } from '@/lib/db';
|
|
7
|
+
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
|
8
|
+
import { Button } from '@/components/ui/button';
|
|
9
|
+
import { Input } from '@/components/ui/input';
|
|
10
|
+
import { Badge } from '@/components/ui/badge';
|
|
11
|
+
import {
|
|
12
|
+
Select,
|
|
13
|
+
SelectContent,
|
|
14
|
+
SelectItem,
|
|
15
|
+
SelectTrigger,
|
|
16
|
+
SelectValue,
|
|
17
|
+
} from '@/components/ui/select';
|
|
18
|
+
|
|
19
|
+
export function TicketList({ status }: { status?: string }) {
|
|
20
|
+
const [tickets, setTickets] = useState<Ticket[]>([]);
|
|
21
|
+
const [filteredTickets, setFilteredTickets] = useState<Ticket[]>([]);
|
|
22
|
+
const [loading, setLoading] = useState(true);
|
|
23
|
+
const [error, setError] = useState<string | null>(null);
|
|
24
|
+
|
|
25
|
+
// 搜索和筛选状态
|
|
26
|
+
const [searchQuery, setSearchQuery] = useState('');
|
|
27
|
+
const [statusFilter, setStatusFilter] = useState(status || 'all');
|
|
28
|
+
const [typeFilter, setTypeFilter] = useState('all');
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
async function fetchTickets() {
|
|
32
|
+
try {
|
|
33
|
+
const response = await fetch('/api/tickets');
|
|
34
|
+
if (!response.ok) throw new Error('获取工单失败');
|
|
35
|
+
|
|
36
|
+
const data = await response.json();
|
|
37
|
+
setTickets(data.tickets);
|
|
38
|
+
setFilteredTickets(data.tickets);
|
|
39
|
+
} catch (err) {
|
|
40
|
+
setError(err instanceof Error ? err.message : '未知错误');
|
|
41
|
+
} finally {
|
|
42
|
+
setLoading(false);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
fetchTickets();
|
|
47
|
+
}, []);
|
|
48
|
+
|
|
49
|
+
// 筛选和搜索逻辑
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
let filtered = tickets;
|
|
52
|
+
|
|
53
|
+
// 状态筛选
|
|
54
|
+
if (statusFilter !== 'all') {
|
|
55
|
+
filtered = filtered.filter(t => t.status === statusFilter);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 类型筛选
|
|
59
|
+
if (typeFilter !== 'all') {
|
|
60
|
+
filtered = filtered.filter(t => t.type === typeFilter);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// 搜索
|
|
64
|
+
if (searchQuery) {
|
|
65
|
+
const query = searchQuery.toLowerCase();
|
|
66
|
+
filtered = filtered.filter(t =>
|
|
67
|
+
t.title.toLowerCase().includes(query) ||
|
|
68
|
+
t.description?.toLowerCase().includes(query) ||
|
|
69
|
+
t.id.toLowerCase().includes(query) ||
|
|
70
|
+
t.command?.toLowerCase().includes(query) ||
|
|
71
|
+
t.skillName?.toLowerCase().includes(query) ||
|
|
72
|
+
t.scriptContent?.toLowerCase().includes(query)
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
setFilteredTickets(filtered);
|
|
77
|
+
}, [tickets, statusFilter, typeFilter, searchQuery]);
|
|
78
|
+
|
|
79
|
+
if (loading) {
|
|
80
|
+
return (
|
|
81
|
+
<div className="flex items-center justify-center py-16">
|
|
82
|
+
<div className="flex flex-col items-center gap-4">
|
|
83
|
+
<div className="w-10 h-10 border-4 border-primary border-t-transparent rounded-full animate-spin"></div>
|
|
84
|
+
<p className="text-muted-foreground">加载中...</p>
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (error) {
|
|
91
|
+
return (
|
|
92
|
+
<div className="bg-destructive/10 border border-destructive/20 rounded-xl p-6">
|
|
93
|
+
<p className="text-destructive">❌ {error}</p>
|
|
94
|
+
</div>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<div className="space-y-4">
|
|
100
|
+
{/* 搜索和筛选栏 */}
|
|
101
|
+
<div className="bg-card rounded-xl border shadow-sm p-4">
|
|
102
|
+
<div className="flex flex-col md:flex-row gap-4">
|
|
103
|
+
<div className="flex-1">
|
|
104
|
+
<Input
|
|
105
|
+
type="text"
|
|
106
|
+
value={searchQuery}
|
|
107
|
+
onChange={(e) => setSearchQuery(e.target.value)}
|
|
108
|
+
placeholder="搜索工单标题、描述、ID、集群或资源名称..."
|
|
109
|
+
className="w-full"
|
|
110
|
+
/>
|
|
111
|
+
</div>
|
|
112
|
+
<div className="flex gap-2">
|
|
113
|
+
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
|
114
|
+
<SelectTrigger className="w-[140px]">
|
|
115
|
+
<SelectValue placeholder="所有状态" />
|
|
116
|
+
</SelectTrigger>
|
|
117
|
+
<SelectContent>
|
|
118
|
+
<SelectItem value="all">所有状态</SelectItem>
|
|
119
|
+
<SelectItem value="draft">草稿</SelectItem>
|
|
120
|
+
<SelectItem value="pending">待审核</SelectItem>
|
|
121
|
+
<SelectItem value="approved">已批准</SelectItem>
|
|
122
|
+
<SelectItem value="executing">执行中</SelectItem>
|
|
123
|
+
<SelectItem value="completed">已完成</SelectItem>
|
|
124
|
+
<SelectItem value="failed">失败</SelectItem>
|
|
125
|
+
<SelectItem value="rejected">已拒绝</SelectItem>
|
|
126
|
+
</SelectContent>
|
|
127
|
+
</Select>
|
|
128
|
+
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
|
129
|
+
<SelectTrigger className="w-[140px]">
|
|
130
|
+
<SelectValue placeholder="所有类型" />
|
|
131
|
+
</SelectTrigger>
|
|
132
|
+
<SelectContent>
|
|
133
|
+
<SelectItem value="all">所有类型</SelectItem>
|
|
134
|
+
<SelectItem value="bash-execute">💻 Bash 命令</SelectItem>
|
|
135
|
+
<SelectItem value="skill-script">🛠️ Skill 脚本</SelectItem>
|
|
136
|
+
</SelectContent>
|
|
137
|
+
</Select>
|
|
138
|
+
</div>
|
|
139
|
+
</div>
|
|
140
|
+
{filteredTickets.length !== tickets.length && (
|
|
141
|
+
<p className="text-sm text-muted-foreground mt-2">
|
|
142
|
+
找到 {filteredTickets.length} 个工单(共 {tickets.length} 个)
|
|
143
|
+
</p>
|
|
144
|
+
)}
|
|
145
|
+
</div>
|
|
146
|
+
|
|
147
|
+
{/* 工单表格 */}
|
|
148
|
+
{filteredTickets.length === 0 ? (
|
|
149
|
+
<div className="text-center py-16 bg-card rounded-xl border shadow-sm">
|
|
150
|
+
<div className="text-6xl mb-4">📭</div>
|
|
151
|
+
<p className="text-muted-foreground text-lg">暂无工单</p>
|
|
152
|
+
<p className="text-muted-foreground/70 text-sm mt-2">
|
|
153
|
+
{searchQuery || statusFilter !== 'all' || typeFilter !== 'all'
|
|
154
|
+
? '没有符合条件的工单,请调整筛选条件'
|
|
155
|
+
: '点击上方"新建工单"创建第一个工单'}
|
|
156
|
+
</p>
|
|
157
|
+
</div>
|
|
158
|
+
) : (
|
|
159
|
+
<div className="bg-card rounded-xl border shadow-sm overflow-hidden">
|
|
160
|
+
<Table>
|
|
161
|
+
<TableHeader>
|
|
162
|
+
<TableRow>
|
|
163
|
+
<TableHead>工单</TableHead>
|
|
164
|
+
<TableHead>状态</TableHead>
|
|
165
|
+
<TableHead>类型</TableHead>
|
|
166
|
+
<TableHead>优先级</TableHead>
|
|
167
|
+
<TableHead>风险等级</TableHead>
|
|
168
|
+
<TableHead>创建时间</TableHead>
|
|
169
|
+
<TableHead className="text-right">操作</TableHead>
|
|
170
|
+
</TableRow>
|
|
171
|
+
</TableHeader>
|
|
172
|
+
<TableBody>
|
|
173
|
+
{filteredTickets.map((ticket) => (
|
|
174
|
+
<TableRow key={ticket.id}>
|
|
175
|
+
<TableCell>
|
|
176
|
+
<div className="flex items-start gap-3">
|
|
177
|
+
<TypeIcon type={ticket.type} />
|
|
178
|
+
<div className="flex-1 min-w-0">
|
|
179
|
+
<Link
|
|
180
|
+
href={`/tickets/${ticket.id}`}
|
|
181
|
+
className="font-medium hover:text-primary transition-colors block truncate"
|
|
182
|
+
>
|
|
183
|
+
{ticket.title}
|
|
184
|
+
</Link>
|
|
185
|
+
<p className="text-xs text-muted-foreground truncate">{ticket.id}</p>
|
|
186
|
+
</div>
|
|
187
|
+
</div>
|
|
188
|
+
</TableCell>
|
|
189
|
+
<TableCell>
|
|
190
|
+
<StatusBadge status={ticket.status} />
|
|
191
|
+
</TableCell>
|
|
192
|
+
<TableCell>
|
|
193
|
+
<TypeBadge type={ticket.type} />
|
|
194
|
+
</TableCell>
|
|
195
|
+
<TableCell>
|
|
196
|
+
<PriorityBadge priority={ticket.priority} />
|
|
197
|
+
</TableCell>
|
|
198
|
+
<TableCell>
|
|
199
|
+
<RiskBadge risk={ticket.riskLevel} />
|
|
200
|
+
</TableCell>
|
|
201
|
+
<TableCell>
|
|
202
|
+
<span className="text-sm text-muted-foreground">
|
|
203
|
+
{new Date(ticket.createdAt).toLocaleDateString('zh-CN')}
|
|
204
|
+
</span>
|
|
205
|
+
</TableCell>
|
|
206
|
+
<TableCell className="text-right">
|
|
207
|
+
<div className="flex items-center justify-end gap-1">
|
|
208
|
+
{ticket.status === 'approved' && (
|
|
209
|
+
<Button
|
|
210
|
+
variant="ghost"
|
|
211
|
+
size="icon"
|
|
212
|
+
onClick={async () => {
|
|
213
|
+
if (!confirm(`确定执行工单 "${ticket.title}" 吗?`)) return;
|
|
214
|
+
try {
|
|
215
|
+
const response = await fetch(`/api/tickets/${ticket.id}/execute`, {
|
|
216
|
+
method: 'POST',
|
|
217
|
+
headers: { 'Content-Type': 'application/json' },
|
|
218
|
+
body: JSON.stringify({ ticketId: ticket.id }),
|
|
219
|
+
});
|
|
220
|
+
if (response.ok) {
|
|
221
|
+
const data = await response.json();
|
|
222
|
+
setTickets(tickets.map(t => t.id === ticket.id ? data.ticket : t));
|
|
223
|
+
}
|
|
224
|
+
} catch (err) {
|
|
225
|
+
console.error(err);
|
|
226
|
+
}
|
|
227
|
+
}}
|
|
228
|
+
className="h-8 w-8 text-green-600 hover:text-green-700 hover:bg-green-50"
|
|
229
|
+
title="执行工单"
|
|
230
|
+
>
|
|
231
|
+
<Zap className="h-4 w-4" />
|
|
232
|
+
</Button>
|
|
233
|
+
)}
|
|
234
|
+
{ticket.status === 'pending' && (
|
|
235
|
+
<>
|
|
236
|
+
<Button
|
|
237
|
+
variant="ghost"
|
|
238
|
+
size="icon"
|
|
239
|
+
onClick={async () => {
|
|
240
|
+
if (!confirm(`批准工单 "${ticket.title}"?`)) return;
|
|
241
|
+
try {
|
|
242
|
+
const response = await fetch(`/api/tickets/${ticket.id}/approve`, {
|
|
243
|
+
method: 'POST',
|
|
244
|
+
headers: { 'Content-Type': 'application/json' },
|
|
245
|
+
body: JSON.stringify({ action: 'approve', approver: 'user', comment: '' }),
|
|
246
|
+
});
|
|
247
|
+
if (response.ok) {
|
|
248
|
+
const data = await response.json();
|
|
249
|
+
setTickets(tickets.map(t => t.id === ticket.id ? data.ticket : t));
|
|
250
|
+
}
|
|
251
|
+
} catch (err) {
|
|
252
|
+
console.error(err);
|
|
253
|
+
}
|
|
254
|
+
}}
|
|
255
|
+
className="h-8 w-8 text-green-600 hover:text-green-700 hover:bg-green-50"
|
|
256
|
+
title="批准"
|
|
257
|
+
>
|
|
258
|
+
✓
|
|
259
|
+
</Button>
|
|
260
|
+
<Button
|
|
261
|
+
variant="ghost"
|
|
262
|
+
size="icon"
|
|
263
|
+
onClick={async () => {
|
|
264
|
+
if (!confirm(`拒绝工单 "${ticket.title}"?`)) return;
|
|
265
|
+
try {
|
|
266
|
+
const response = await fetch(`/api/tickets/${ticket.id}/approve`, {
|
|
267
|
+
method: 'POST',
|
|
268
|
+
headers: { 'Content-Type': 'application/json' },
|
|
269
|
+
body: JSON.stringify({ action: 'reject', approver: 'user', comment: '' }),
|
|
270
|
+
});
|
|
271
|
+
if (response.ok) {
|
|
272
|
+
const data = await response.json();
|
|
273
|
+
setTickets(tickets.map(t => t.id === ticket.id ? data.ticket : t));
|
|
274
|
+
}
|
|
275
|
+
} catch (err) {
|
|
276
|
+
console.error(err);
|
|
277
|
+
}
|
|
278
|
+
}}
|
|
279
|
+
className="h-8 w-8 text-destructive hover:text-destructive hover:bg-destructive/10"
|
|
280
|
+
title="拒绝"
|
|
281
|
+
>
|
|
282
|
+
✗
|
|
283
|
+
</Button>
|
|
284
|
+
</>
|
|
285
|
+
)}
|
|
286
|
+
<Button
|
|
287
|
+
variant="ghost"
|
|
288
|
+
size="icon"
|
|
289
|
+
asChild
|
|
290
|
+
className="h-8 w-8"
|
|
291
|
+
>
|
|
292
|
+
<Link href={`/tickets/${ticket.id}`} title="查看详情">
|
|
293
|
+
→
|
|
294
|
+
</Link>
|
|
295
|
+
</Button>
|
|
296
|
+
</div>
|
|
297
|
+
</TableCell>
|
|
298
|
+
</TableRow>
|
|
299
|
+
))}
|
|
300
|
+
</TableBody>
|
|
301
|
+
</Table>
|
|
302
|
+
</div>
|
|
303
|
+
)}
|
|
304
|
+
</div>
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// 工单类型图标组件
|
|
309
|
+
function TypeIcon({ type }: { type: Ticket['type'] }) {
|
|
310
|
+
const iconMap: Record<string, typeof Terminal> = {
|
|
311
|
+
'bash-execute': Terminal,
|
|
312
|
+
'skill-script': Wrench,
|
|
313
|
+
};
|
|
314
|
+
const Icon = iconMap[type] || Terminal;
|
|
315
|
+
return <Icon className="h-5 w-5 text-muted-foreground flex-shrink-0" />;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// 类型徽章组件
|
|
319
|
+
function TypeBadge({ type }: { type: Ticket['type'] }) {
|
|
320
|
+
const config: Record<string, { label: string; variant: 'secondary' | 'outline' | 'default' | 'destructive' }> = {
|
|
321
|
+
'bash-execute': { label: 'Bash', variant: 'secondary' },
|
|
322
|
+
'skill-script': { label: 'Skill', variant: 'outline' },
|
|
323
|
+
};
|
|
324
|
+
const { label, variant } = config[type] || { label: type, variant: 'secondary' };
|
|
325
|
+
return <Badge variant={variant}>{label}</Badge>;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// 状态徽章组件
|
|
329
|
+
function StatusBadge({ status }: { status: Ticket['status'] }) {
|
|
330
|
+
const config = {
|
|
331
|
+
draft: { label: '草稿', variant: 'secondary' as const },
|
|
332
|
+
pending: { label: '待审核', variant: 'outline' as const },
|
|
333
|
+
approved: { label: '已批准', variant: 'default' as const },
|
|
334
|
+
rejected: { label: '已拒绝', variant: 'destructive' as const },
|
|
335
|
+
executing: { label: '执行中', variant: 'outline' as const },
|
|
336
|
+
completed: { label: '已完成', variant: 'default' as const },
|
|
337
|
+
failed: { label: '失败', variant: 'destructive' as const },
|
|
338
|
+
deleted: { label: '已删除', variant: 'secondary' as const },
|
|
339
|
+
};
|
|
340
|
+
const { label, variant } = config[status] || config.draft;
|
|
341
|
+
return <Badge variant={variant}>{label}</Badge>;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// 优先级徽章组件
|
|
345
|
+
function PriorityBadge({ priority }: { priority: Ticket['priority'] }) {
|
|
346
|
+
const config: Record<string, { label: string; variant: 'secondary' | 'outline' | 'default' | 'destructive' }> = {
|
|
347
|
+
low: { label: '低', variant: 'secondary' },
|
|
348
|
+
medium: { label: '中', variant: 'outline' },
|
|
349
|
+
high: { label: '高', variant: 'destructive' },
|
|
350
|
+
critical: { label: '极高', variant: 'destructive' },
|
|
351
|
+
};
|
|
352
|
+
const { label, variant } = config[priority] || config.medium;
|
|
353
|
+
return <Badge variant={variant}>{label}</Badge>;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// 风险等级徽章组件
|
|
357
|
+
function RiskBadge({ risk }: { risk: Ticket['riskLevel'] }) {
|
|
358
|
+
const config: Record<string, { label: string; variant: 'secondary' | 'outline' | 'default' | 'destructive' }> = {
|
|
359
|
+
low: { label: '低风险', variant: 'secondary' },
|
|
360
|
+
medium: { label: '中风险', variant: 'outline' },
|
|
361
|
+
high: { label: '高风险', variant: 'destructive' },
|
|
362
|
+
critical: { label: '极高风险', variant: 'destructive' },
|
|
363
|
+
};
|
|
364
|
+
const { label, variant } = config[risk] || config.medium;
|
|
365
|
+
return <Badge variant={variant}>{label}</Badge>;
|
|
366
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import Link from 'next/link';
|
|
4
|
+
import { usePathname } from 'next/navigation';
|
|
5
|
+
import { useState, useEffect } from 'react';
|
|
6
|
+
import {
|
|
7
|
+
Bot,
|
|
8
|
+
ClipboardList,
|
|
9
|
+
CheckCircle,
|
|
10
|
+
FileText,
|
|
11
|
+
Settings,
|
|
12
|
+
BookOpen,
|
|
13
|
+
Clock,
|
|
14
|
+
Package,
|
|
15
|
+
User,
|
|
16
|
+
LogOut,
|
|
17
|
+
Shield,
|
|
18
|
+
Sparkles,
|
|
19
|
+
} from 'lucide-react';
|
|
20
|
+
|
|
21
|
+
export function TopNav() {
|
|
22
|
+
const pathname = usePathname() || '';
|
|
23
|
+
const [role, setRole] = useState<'admin' | 'guest'>('guest');
|
|
24
|
+
const [username, setUsername] = useState('');
|
|
25
|
+
const [avatar, setAvatar] = useState('');
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
const savedRole = localStorage.getItem('role');
|
|
29
|
+
if (savedRole === 'admin') {
|
|
30
|
+
setRole('admin');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const savedUsername = localStorage.getItem('username');
|
|
34
|
+
if (savedUsername) {
|
|
35
|
+
setUsername(savedUsername);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const savedAvatar = localStorage.getItem('feishuAvatar');
|
|
39
|
+
if (savedAvatar) {
|
|
40
|
+
setAvatar(savedAvatar);
|
|
41
|
+
}
|
|
42
|
+
}, []);
|
|
43
|
+
|
|
44
|
+
const handleLogout = () => {
|
|
45
|
+
localStorage.removeItem('userId');
|
|
46
|
+
localStorage.removeItem('username');
|
|
47
|
+
localStorage.removeItem('role');
|
|
48
|
+
window.location.href = '/login';
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const isActive = (path: string) => {
|
|
52
|
+
if (path === '/') return pathname === '/';
|
|
53
|
+
return pathname.startsWith(path);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const navItems = [
|
|
57
|
+
{ href: '/', label: 'AI助手', icon: Sparkles },
|
|
58
|
+
{ href: '/market', label: '市场', icon: Package },
|
|
59
|
+
{ href: '/my/knowledge', label: '我的', icon: User },
|
|
60
|
+
{
|
|
61
|
+
href: role === 'admin' ? '/tickets' : '/',
|
|
62
|
+
label: '系统',
|
|
63
|
+
icon: Settings,
|
|
64
|
+
adminOnly: true
|
|
65
|
+
},
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<header className="bg-white border-b h-12 flex items-center px-4 gap-1 justify-between">
|
|
70
|
+
<div className="flex items-center gap-1">
|
|
71
|
+
<Link href="/" className="flex items-center gap-2 mr-6 no-underline">
|
|
72
|
+
<div className="w-7 h-7 rounded-md bg-blue-600 flex items-center justify-center">
|
|
73
|
+
<Bot className="h-4 w-4 text-white" />
|
|
74
|
+
</div>
|
|
75
|
+
<span className="font-semibold text-sm text-gray-900">智能工作助手</span>
|
|
76
|
+
</Link>
|
|
77
|
+
|
|
78
|
+
{navItems.map((item) => {
|
|
79
|
+
const Icon = item.icon;
|
|
80
|
+
// 管理员显示所有,非管理员隐藏系统
|
|
81
|
+
if (item.adminOnly && role !== 'admin') return null;
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<Link
|
|
85
|
+
key={item.href}
|
|
86
|
+
href={item.href}
|
|
87
|
+
className={`flex items-center gap-2 px-3 py-1.5 rounded-md text-sm no-underline ${
|
|
88
|
+
isActive(item.href)
|
|
89
|
+
? 'bg-blue-600 text-white'
|
|
90
|
+
: 'text-gray-600 hover:bg-gray-100'
|
|
91
|
+
}`}
|
|
92
|
+
>
|
|
93
|
+
<Icon className="h-4 w-4" />
|
|
94
|
+
{item.label}
|
|
95
|
+
</Link>
|
|
96
|
+
);
|
|
97
|
+
})}
|
|
98
|
+
</div>
|
|
99
|
+
|
|
100
|
+
<div className="flex items-center gap-3">
|
|
101
|
+
<div className="flex items-center gap-2 px-2 py-1 bg-gray-50 rounded text-xs">
|
|
102
|
+
{avatar ? (
|
|
103
|
+
<img
|
|
104
|
+
src={avatar}
|
|
105
|
+
alt={username}
|
|
106
|
+
className="w-5 h-5 rounded-full object-cover"
|
|
107
|
+
/>
|
|
108
|
+
) : role === 'admin' ? (
|
|
109
|
+
<Shield className="h-3 w-3 text-blue-600" />
|
|
110
|
+
) : (
|
|
111
|
+
<User className="h-3 w-3 text-gray-500" />
|
|
112
|
+
)}
|
|
113
|
+
<span className="text-gray-700">{username}</span>
|
|
114
|
+
{role === 'admin' && (
|
|
115
|
+
<>
|
|
116
|
+
<span className="text-gray-400">|</span>
|
|
117
|
+
<span className="text-blue-600 font-medium">管理员</span>
|
|
118
|
+
</>
|
|
119
|
+
)}
|
|
120
|
+
</div>
|
|
121
|
+
<button
|
|
122
|
+
onClick={handleLogout}
|
|
123
|
+
className="flex items-center gap-1 px-2 py-1 text-xs text-gray-500 hover:text-red-600 hover:bg-red-50 rounded transition-colors"
|
|
124
|
+
title="退出登录"
|
|
125
|
+
>
|
|
126
|
+
<LogOut className="h-3 w-3" />
|
|
127
|
+
退出
|
|
128
|
+
</button>
|
|
129
|
+
</div>
|
|
130
|
+
</header>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import * as AccordionPrimitive from "@radix-ui/react-accordion"
|
|
5
|
+
import { ChevronDown } from "lucide-react"
|
|
6
|
+
|
|
7
|
+
import { cn } from "@/lib/utils"
|
|
8
|
+
|
|
9
|
+
const Accordion = AccordionPrimitive.Root
|
|
10
|
+
|
|
11
|
+
const AccordionItem = React.forwardRef<
|
|
12
|
+
React.ElementRef<typeof AccordionPrimitive.Item>,
|
|
13
|
+
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
|
|
14
|
+
>(({ className, ...props }, ref) => (
|
|
15
|
+
<AccordionPrimitive.Item
|
|
16
|
+
ref={ref}
|
|
17
|
+
className={cn("border-b", className)}
|
|
18
|
+
{...props}
|
|
19
|
+
/>
|
|
20
|
+
))
|
|
21
|
+
AccordionItem.displayName = "AccordionItem"
|
|
22
|
+
|
|
23
|
+
const AccordionTrigger = React.forwardRef<
|
|
24
|
+
React.ElementRef<typeof AccordionPrimitive.Trigger>,
|
|
25
|
+
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
|
|
26
|
+
>(({ className, children, ...props }, ref) => (
|
|
27
|
+
<AccordionPrimitive.Header className="flex">
|
|
28
|
+
<AccordionPrimitive.Trigger
|
|
29
|
+
ref={ref}
|
|
30
|
+
className={cn(
|
|
31
|
+
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
|
|
32
|
+
className
|
|
33
|
+
)}
|
|
34
|
+
{...props}
|
|
35
|
+
>
|
|
36
|
+
{children}
|
|
37
|
+
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
|
|
38
|
+
</AccordionPrimitive.Trigger>
|
|
39
|
+
</AccordionPrimitive.Header>
|
|
40
|
+
))
|
|
41
|
+
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
|
|
42
|
+
|
|
43
|
+
const AccordionContent = React.forwardRef<
|
|
44
|
+
React.ElementRef<typeof AccordionPrimitive.Content>,
|
|
45
|
+
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
|
|
46
|
+
>(({ className, children, ...props }, ref) => (
|
|
47
|
+
<AccordionPrimitive.Content
|
|
48
|
+
ref={ref}
|
|
49
|
+
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
|
50
|
+
{...props}
|
|
51
|
+
>
|
|
52
|
+
<div className={cn("pb-4 pt-0", className)}>{children}</div>
|
|
53
|
+
</AccordionPrimitive.Content>
|
|
54
|
+
))
|
|
55
|
+
|
|
56
|
+
AccordionContent.displayName = AccordionPrimitive.Content.displayName
|
|
57
|
+
|
|
58
|
+
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import { cva, type VariantProps } from "class-variance-authority"
|
|
3
|
+
|
|
4
|
+
import { cn } from "@/lib/utils"
|
|
5
|
+
|
|
6
|
+
const alertVariants = cva(
|
|
7
|
+
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
|
|
8
|
+
{
|
|
9
|
+
variants: {
|
|
10
|
+
variant: {
|
|
11
|
+
default: "bg-background text-foreground",
|
|
12
|
+
destructive:
|
|
13
|
+
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
defaultVariants: {
|
|
17
|
+
variant: "default",
|
|
18
|
+
},
|
|
19
|
+
}
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
const Alert = React.forwardRef<
|
|
23
|
+
HTMLDivElement,
|
|
24
|
+
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
|
25
|
+
>(({ className, variant, ...props }, ref) => (
|
|
26
|
+
<div
|
|
27
|
+
ref={ref}
|
|
28
|
+
role="alert"
|
|
29
|
+
className={cn(alertVariants({ variant }), className)}
|
|
30
|
+
{...props}
|
|
31
|
+
/>
|
|
32
|
+
))
|
|
33
|
+
Alert.displayName = "Alert"
|
|
34
|
+
|
|
35
|
+
const AlertTitle = React.forwardRef<
|
|
36
|
+
HTMLParagraphElement,
|
|
37
|
+
React.HTMLAttributes<HTMLHeadingElement>
|
|
38
|
+
>(({ className, ...props }, ref) => (
|
|
39
|
+
<h5
|
|
40
|
+
ref={ref}
|
|
41
|
+
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
|
42
|
+
{...props}
|
|
43
|
+
/>
|
|
44
|
+
))
|
|
45
|
+
AlertTitle.displayName = "AlertTitle"
|
|
46
|
+
|
|
47
|
+
const AlertDescription = React.forwardRef<
|
|
48
|
+
HTMLParagraphElement,
|
|
49
|
+
React.HTMLAttributes<HTMLParagraphElement>
|
|
50
|
+
>(({ className, ...props }, ref) => (
|
|
51
|
+
<div
|
|
52
|
+
ref={ref}
|
|
53
|
+
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
|
54
|
+
{...props}
|
|
55
|
+
/>
|
|
56
|
+
))
|
|
57
|
+
AlertDescription.displayName = "AlertDescription"
|
|
58
|
+
|
|
59
|
+
export { Alert, AlertTitle, AlertDescription }
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
|
5
|
+
|
|
6
|
+
import { cn } from "@/lib/utils"
|
|
7
|
+
|
|
8
|
+
const Avatar = React.forwardRef<
|
|
9
|
+
React.ElementRef<typeof AvatarPrimitive.Root>,
|
|
10
|
+
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
|
11
|
+
>(({ className, ...props }, ref) => (
|
|
12
|
+
<AvatarPrimitive.Root
|
|
13
|
+
ref={ref}
|
|
14
|
+
className={cn(
|
|
15
|
+
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
|
16
|
+
className
|
|
17
|
+
)}
|
|
18
|
+
{...props}
|
|
19
|
+
/>
|
|
20
|
+
))
|
|
21
|
+
Avatar.displayName = AvatarPrimitive.Root.displayName
|
|
22
|
+
|
|
23
|
+
const AvatarImage = React.forwardRef<
|
|
24
|
+
React.ElementRef<typeof AvatarPrimitive.Image>,
|
|
25
|
+
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
|
26
|
+
>(({ className, ...props }, ref) => (
|
|
27
|
+
<AvatarPrimitive.Image
|
|
28
|
+
ref={ref}
|
|
29
|
+
className={cn("aspect-square h-full w-full", className)}
|
|
30
|
+
{...props}
|
|
31
|
+
/>
|
|
32
|
+
))
|
|
33
|
+
AvatarImage.displayName = AvatarPrimitive.Image.displayName
|
|
34
|
+
|
|
35
|
+
const AvatarFallback = React.forwardRef<
|
|
36
|
+
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
|
37
|
+
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
|
38
|
+
>(({ className, ...props }, ref) => (
|
|
39
|
+
<AvatarPrimitive.Fallback
|
|
40
|
+
ref={ref}
|
|
41
|
+
className={cn(
|
|
42
|
+
"flex h-full w-full items-center justify-center rounded-full bg-muted",
|
|
43
|
+
className
|
|
44
|
+
)}
|
|
45
|
+
{...props}
|
|
46
|
+
/>
|
|
47
|
+
))
|
|
48
|
+
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
|
|
49
|
+
|
|
50
|
+
export { Avatar, AvatarImage, AvatarFallback }
|