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,348 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect } from 'react';
|
|
4
|
+
import Link from 'next/link';
|
|
5
|
+
import { useParams } from 'next/navigation';
|
|
6
|
+
import type { Ticket } from '@/lib/db';
|
|
7
|
+
import { TicketDetailContent } from '@/components/ticket-detail-content';
|
|
8
|
+
|
|
9
|
+
export default function TicketDetailPage() {
|
|
10
|
+
const params = useParams();
|
|
11
|
+
const ticketId = params.id as string;
|
|
12
|
+
|
|
13
|
+
const [ticket, setTicket] = useState<Ticket | null>(null);
|
|
14
|
+
const [loading, setLoading] = useState(true);
|
|
15
|
+
const [error, setError] = useState<string | null>(null);
|
|
16
|
+
const [approving, setApproving] = useState(false);
|
|
17
|
+
const [executing, setExecuting] = useState(false);
|
|
18
|
+
const [submitting, setSubmitting] = useState(false);
|
|
19
|
+
const [comment, setComment] = useState('');
|
|
20
|
+
const [isOperationLoading, setIsOperationLoading] = useState(false);
|
|
21
|
+
const [deleting, setDeleting] = useState(false);
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
async function fetchTicket() {
|
|
25
|
+
try {
|
|
26
|
+
const response = await fetch(`/api/tickets/${ticketId}`);
|
|
27
|
+
if (!response.ok) {
|
|
28
|
+
if (response.status === 404) throw new Error('工单不存在');
|
|
29
|
+
throw new Error('获取工单失败');
|
|
30
|
+
}
|
|
31
|
+
const data = await response.json();
|
|
32
|
+
setTicket(data.ticket);
|
|
33
|
+
} catch (err) {
|
|
34
|
+
setError(err instanceof Error ? err.message : '未知错误');
|
|
35
|
+
} finally {
|
|
36
|
+
setLoading(false);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
fetchTicket();
|
|
41
|
+
}, [ticketId]);
|
|
42
|
+
|
|
43
|
+
// Auto-refresh for executing tickets
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
if (ticket?.status !== 'executing') return;
|
|
46
|
+
|
|
47
|
+
const interval = setInterval(async () => {
|
|
48
|
+
try {
|
|
49
|
+
const response = await fetch(`/api/tickets/${ticketId}`);
|
|
50
|
+
if (response.ok) {
|
|
51
|
+
const data = await response.json();
|
|
52
|
+
setTicket(data.ticket);
|
|
53
|
+
}
|
|
54
|
+
} catch (err) {
|
|
55
|
+
console.error('Failed to refresh ticket:', err);
|
|
56
|
+
}
|
|
57
|
+
}, 2000); // Poll every 2 seconds
|
|
58
|
+
|
|
59
|
+
return () => clearInterval(interval);
|
|
60
|
+
}, [ticketId, ticket?.status]);
|
|
61
|
+
|
|
62
|
+
const handleApprove = async () => {
|
|
63
|
+
setApproving(true);
|
|
64
|
+
setIsOperationLoading(true);
|
|
65
|
+
setError(null);
|
|
66
|
+
try {
|
|
67
|
+
const response = await fetch(`/api/tickets/${ticketId}/approve`, {
|
|
68
|
+
method: 'POST',
|
|
69
|
+
headers: { 'Content-Type': 'application/json' },
|
|
70
|
+
body: JSON.stringify({
|
|
71
|
+
action: 'approve',
|
|
72
|
+
approver: 'user',
|
|
73
|
+
comment,
|
|
74
|
+
}),
|
|
75
|
+
});
|
|
76
|
+
if (!response.ok) {
|
|
77
|
+
const data = await response.json();
|
|
78
|
+
throw new Error(data.error || '操作失败');
|
|
79
|
+
}
|
|
80
|
+
const data = await response.json();
|
|
81
|
+
setTicket(data.ticket);
|
|
82
|
+
setComment('');
|
|
83
|
+
} catch (err) {
|
|
84
|
+
setError(err instanceof Error ? err.message : '未知错误');
|
|
85
|
+
} finally {
|
|
86
|
+
setApproving(false);
|
|
87
|
+
setIsOperationLoading(false);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const handleReject = async () => {
|
|
92
|
+
setApproving(true);
|
|
93
|
+
setIsOperationLoading(true);
|
|
94
|
+
setError(null);
|
|
95
|
+
try {
|
|
96
|
+
const response = await fetch(`/api/tickets/${ticketId}/approve`, {
|
|
97
|
+
method: 'POST',
|
|
98
|
+
headers: { 'Content-Type': 'application/json' },
|
|
99
|
+
body: JSON.stringify({
|
|
100
|
+
action: 'reject',
|
|
101
|
+
approver: 'user',
|
|
102
|
+
comment,
|
|
103
|
+
}),
|
|
104
|
+
});
|
|
105
|
+
if (!response.ok) {
|
|
106
|
+
const data = await response.json();
|
|
107
|
+
throw new Error(data.error || '操作失败');
|
|
108
|
+
}
|
|
109
|
+
const data = await response.json();
|
|
110
|
+
setTicket(data.ticket);
|
|
111
|
+
setComment('');
|
|
112
|
+
} catch (err) {
|
|
113
|
+
setError(err instanceof Error ? err.message : '未知错误');
|
|
114
|
+
} finally {
|
|
115
|
+
setApproving(false);
|
|
116
|
+
setIsOperationLoading(false);
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const handleSubmit = async () => {
|
|
121
|
+
setSubmitting(true);
|
|
122
|
+
setError(null);
|
|
123
|
+
try {
|
|
124
|
+
const response = await fetch(`/api/tickets/${ticketId}`, {
|
|
125
|
+
method: 'PUT',
|
|
126
|
+
headers: { 'Content-Type': 'application/json' },
|
|
127
|
+
body: JSON.stringify({ status: 'pending' }),
|
|
128
|
+
});
|
|
129
|
+
if (!response.ok) {
|
|
130
|
+
const data = await response.json();
|
|
131
|
+
throw new Error(data.error || '操作失败');
|
|
132
|
+
}
|
|
133
|
+
const data = await response.json();
|
|
134
|
+
setTicket(data.ticket);
|
|
135
|
+
} catch (err) {
|
|
136
|
+
setError(err instanceof Error ? err.message : '未知错误');
|
|
137
|
+
} finally {
|
|
138
|
+
setSubmitting(false);
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const handleExecute = async () => {
|
|
143
|
+
setExecuting(true);
|
|
144
|
+
setIsOperationLoading(true);
|
|
145
|
+
setError(null);
|
|
146
|
+
|
|
147
|
+
// Immediately update UI to show executing state
|
|
148
|
+
setTicket(prev => prev ? { ...prev, status: 'executing' as const } : null);
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
const response = await fetch(`/api/tickets/${ticketId}/execute`, {
|
|
152
|
+
method: 'POST',
|
|
153
|
+
headers: { 'Content-Type': 'application/json' },
|
|
154
|
+
body: JSON.stringify({ ticketId }),
|
|
155
|
+
});
|
|
156
|
+
const data = await response.json();
|
|
157
|
+
if (!response.ok) {
|
|
158
|
+
throw new Error(data.error || '执行失败');
|
|
159
|
+
}
|
|
160
|
+
// Update with the latest ticket data from server
|
|
161
|
+
if (data.ticket) {
|
|
162
|
+
setTicket(data.ticket);
|
|
163
|
+
}
|
|
164
|
+
} catch (err) {
|
|
165
|
+
setError(err instanceof Error ? err.message : '未知错误');
|
|
166
|
+
// Refresh ticket to get current state on error
|
|
167
|
+
try {
|
|
168
|
+
const refreshResponse = await fetch(`/api/tickets/${ticketId}`);
|
|
169
|
+
if (refreshResponse.ok) {
|
|
170
|
+
const refreshData = await refreshResponse.json();
|
|
171
|
+
setTicket(refreshData.ticket);
|
|
172
|
+
}
|
|
173
|
+
} catch (refreshErr) {
|
|
174
|
+
// Ignore refresh errors
|
|
175
|
+
}
|
|
176
|
+
} finally {
|
|
177
|
+
setExecuting(false);
|
|
178
|
+
setIsOperationLoading(false);
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const handleDelete = async () => {
|
|
183
|
+
if (!confirm('确定要删除这个草稿工单吗?此操作不可恢复。')) {
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
setDeleting(true);
|
|
188
|
+
setError(null);
|
|
189
|
+
try {
|
|
190
|
+
const response = await fetch(`/api/tickets/${ticketId}`, {
|
|
191
|
+
method: 'DELETE',
|
|
192
|
+
});
|
|
193
|
+
if (!response.ok) {
|
|
194
|
+
const data = await response.json();
|
|
195
|
+
throw new Error(data.error || '删除失败');
|
|
196
|
+
}
|
|
197
|
+
window.location.href = '/tickets';
|
|
198
|
+
} catch (err) {
|
|
199
|
+
setError(err instanceof Error ? err.message : '未知错误');
|
|
200
|
+
setDeleting(false);
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
if (loading) {
|
|
205
|
+
return (
|
|
206
|
+
<div className="flex items-center justify-center h-full">
|
|
207
|
+
<div className="w-10 h-10 border-4 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
|
|
208
|
+
</div>
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (error || !ticket) {
|
|
213
|
+
return (
|
|
214
|
+
<div className="flex items-center justify-center h-full">
|
|
215
|
+
<div className="bg-red-50 border border-red-200 rounded-xl p-8 max-w-md">
|
|
216
|
+
<p className="text-red-800 text-center">{error || '工单不存在'}</p>
|
|
217
|
+
<Link href="/tickets" className="block mt-4 text-center text-blue-600 hover:underline">
|
|
218
|
+
← 返回工单列表
|
|
219
|
+
</Link>
|
|
220
|
+
</div>
|
|
221
|
+
</div>
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return (
|
|
226
|
+
<div className="p-6">
|
|
227
|
+
<div className="max-w-4xl mx-auto">
|
|
228
|
+
<Link href="/tickets" className="inline-flex items-center text-blue-600 hover:underline text-sm mb-6">
|
|
229
|
+
← 返回工单列表
|
|
230
|
+
</Link>
|
|
231
|
+
|
|
232
|
+
{/* 草稿状态的特殊处理 */}
|
|
233
|
+
{ticket.status === 'draft' && (
|
|
234
|
+
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 mb-4">
|
|
235
|
+
<h3 className="text-lg font-semibold text-slate-900 mb-4">草稿操作</h3>
|
|
236
|
+
<div className="bg-slate-50 border border-slate-200 rounded-lg p-4 mb-4">
|
|
237
|
+
<p className="text-sm text-slate-700">
|
|
238
|
+
<span className="font-medium">📝 草稿状态</span>
|
|
239
|
+
<br />
|
|
240
|
+
<span className="text-xs text-slate-600">此工单尚未提交审核。您可以编辑命令、提交审核或删除工单。</span>
|
|
241
|
+
</p>
|
|
242
|
+
</div>
|
|
243
|
+
<div className="flex gap-3">
|
|
244
|
+
<Link
|
|
245
|
+
href={`/tickets/new?edit=${ticketId}`}
|
|
246
|
+
className="flex-1 px-4 py-3 bg-slate-600 text-white rounded-lg hover:bg-slate-700 text-center text-sm font-medium transition-colors"
|
|
247
|
+
>
|
|
248
|
+
✏️ 编辑命令
|
|
249
|
+
</Link>
|
|
250
|
+
<button
|
|
251
|
+
onClick={handleSubmit}
|
|
252
|
+
disabled={submitting}
|
|
253
|
+
className="flex-1 px-4 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed text-sm font-medium transition-colors flex items-center justify-center gap-2"
|
|
254
|
+
>
|
|
255
|
+
{submitting ? (
|
|
256
|
+
<>
|
|
257
|
+
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
|
|
258
|
+
提交中...
|
|
259
|
+
</>
|
|
260
|
+
) : (
|
|
261
|
+
<>
|
|
262
|
+
<span>📤</span>
|
|
263
|
+
提交审核
|
|
264
|
+
</>
|
|
265
|
+
)}
|
|
266
|
+
</button>
|
|
267
|
+
</div>
|
|
268
|
+
<div className="mt-3">
|
|
269
|
+
<button
|
|
270
|
+
onClick={handleDelete}
|
|
271
|
+
disabled={deleting}
|
|
272
|
+
className="w-full px-4 py-3 bg-red-50 border border-red-200 text-red-700 rounded-lg hover:bg-red-100 disabled:opacity-50 disabled:cursor-not-allowed text-sm font-medium transition-colors flex items-center justify-center gap-2"
|
|
273
|
+
>
|
|
274
|
+
{deleting ? (
|
|
275
|
+
<>
|
|
276
|
+
<div className="w-4 h-4 border-2 border-red-600 border-t-transparent rounded-full animate-spin"></div>
|
|
277
|
+
删除中...
|
|
278
|
+
</>
|
|
279
|
+
) : (
|
|
280
|
+
<>
|
|
281
|
+
<span>🗑️</span>
|
|
282
|
+
删除工单
|
|
283
|
+
</>
|
|
284
|
+
)}
|
|
285
|
+
</button>
|
|
286
|
+
</div>
|
|
287
|
+
</div>
|
|
288
|
+
)}
|
|
289
|
+
|
|
290
|
+
{/* 执行状态的显示 */}
|
|
291
|
+
{(ticket.status === 'executing' || ticket.status === 'completed' || ticket.status === 'failed') && (
|
|
292
|
+
<div className={`rounded-lg p-4 mb-4 ${
|
|
293
|
+
ticket.status === 'executing' ? 'bg-blue-50 border border-blue-200' :
|
|
294
|
+
ticket.status === 'completed' ? 'bg-green-50 border border-green-200' :
|
|
295
|
+
'bg-red-50 border border-red-200'
|
|
296
|
+
}`}>
|
|
297
|
+
<div className="flex items-center gap-3">
|
|
298
|
+
{ticket.status === 'executing' && (
|
|
299
|
+
<div className="w-5 h-5 border-2 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
|
|
300
|
+
)}
|
|
301
|
+
{ticket.status === 'completed' && <span className="text-2xl">✅</span>}
|
|
302
|
+
{ticket.status === 'failed' && <span className="text-2xl">❌</span>}
|
|
303
|
+
<div>
|
|
304
|
+
<p className={`font-medium ${
|
|
305
|
+
ticket.status === 'executing' ? 'text-blue-900' :
|
|
306
|
+
ticket.status === 'completed' ? 'text-green-900' :
|
|
307
|
+
'text-red-900'
|
|
308
|
+
}`}>
|
|
309
|
+
{ticket.status === 'executing' && '正在执行...'}
|
|
310
|
+
{ticket.status === 'completed' && '执行完成'}
|
|
311
|
+
{ticket.status === 'failed' && '执行失败'}
|
|
312
|
+
</p>
|
|
313
|
+
<p className={`text-sm mt-1 ${
|
|
314
|
+
ticket.status === 'executing' ? 'text-blue-700' :
|
|
315
|
+
ticket.status === 'completed' ? 'text-green-700' :
|
|
316
|
+
'text-red-700'
|
|
317
|
+
}`}>
|
|
318
|
+
{ticket.status === 'executing' && '正在对 Kubernetes 集群执行操作,请稍候...'}
|
|
319
|
+
{ticket.status === 'completed' && '工单已成功执行,集群已更新。'}
|
|
320
|
+
{ticket.status === 'failed' && '执行过程中发生错误,请检查审计日志获取详细信息。'}
|
|
321
|
+
</p>
|
|
322
|
+
</div>
|
|
323
|
+
</div>
|
|
324
|
+
{(ticket.status === 'completed' || ticket.status === 'failed') && (
|
|
325
|
+
<Link
|
|
326
|
+
href="/audit"
|
|
327
|
+
className="mt-4 block text-center text-sm text-blue-600 hover:text-blue-700"
|
|
328
|
+
>
|
|
329
|
+
查看审计日志 →
|
|
330
|
+
</Link>
|
|
331
|
+
)}
|
|
332
|
+
</div>
|
|
333
|
+
)}
|
|
334
|
+
|
|
335
|
+
{/* 主要内容区域使用 TicketDetailContent */}
|
|
336
|
+
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
|
|
337
|
+
<TicketDetailContent
|
|
338
|
+
ticket={ticket}
|
|
339
|
+
onApprove={handleApprove}
|
|
340
|
+
onReject={handleReject}
|
|
341
|
+
onExecute={handleExecute}
|
|
342
|
+
loading={approving || executing || isOperationLoading}
|
|
343
|
+
/>
|
|
344
|
+
</div>
|
|
345
|
+
</div>
|
|
346
|
+
</div>
|
|
347
|
+
);
|
|
348
|
+
}
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, Suspense } from 'react';
|
|
4
|
+
import Link from 'next/link';
|
|
5
|
+
import { useRouter, useSearchParams } from 'next/navigation';
|
|
6
|
+
import type { Ticket } from '@/lib/db';
|
|
7
|
+
|
|
8
|
+
function NewTicketPageContent() {
|
|
9
|
+
const router = useRouter();
|
|
10
|
+
const searchParams = useSearchParams();
|
|
11
|
+
const editId = searchParams.get('edit');
|
|
12
|
+
const [loading, setLoading] = useState(false);
|
|
13
|
+
const [saving, setSaving] = useState(false);
|
|
14
|
+
const [error, setError] = useState<string | null>(null);
|
|
15
|
+
const [initializing, setInitializing] = useState(!!editId);
|
|
16
|
+
const [formData, setFormData] = useState<{
|
|
17
|
+
type: 'bash-execute' | 'skill-script';
|
|
18
|
+
title: string;
|
|
19
|
+
description: string;
|
|
20
|
+
priority: 'low' | 'medium' | 'high' | 'critical';
|
|
21
|
+
command: string;
|
|
22
|
+
commandType: string;
|
|
23
|
+
skillName: string;
|
|
24
|
+
scriptContent: string;
|
|
25
|
+
scriptPath: string;
|
|
26
|
+
}>({
|
|
27
|
+
type: 'bash-execute',
|
|
28
|
+
title: '',
|
|
29
|
+
description: '',
|
|
30
|
+
priority: 'medium',
|
|
31
|
+
command: '',
|
|
32
|
+
commandType: 'kubectl',
|
|
33
|
+
skillName: '',
|
|
34
|
+
scriptContent: '',
|
|
35
|
+
scriptPath: '',
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
async function loadTicket() {
|
|
40
|
+
if (!editId) return;
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const response = await fetch(`/api/tickets/${editId}`);
|
|
44
|
+
if (!response.ok) throw new Error('加载工单失败');
|
|
45
|
+
|
|
46
|
+
const data = await response.json();
|
|
47
|
+
const ticket: Ticket = data.ticket;
|
|
48
|
+
|
|
49
|
+
setFormData({
|
|
50
|
+
type: ticket.type,
|
|
51
|
+
title: ticket.title,
|
|
52
|
+
description: ticket.description || '',
|
|
53
|
+
priority: ticket.priority,
|
|
54
|
+
command: ticket.command || '',
|
|
55
|
+
commandType: ticket.commandType || 'kubectl',
|
|
56
|
+
skillName: ticket.skillName || '',
|
|
57
|
+
scriptContent: ticket.scriptContent || '',
|
|
58
|
+
scriptPath: ticket.scriptPath || '',
|
|
59
|
+
});
|
|
60
|
+
} catch (err) {
|
|
61
|
+
setError(err instanceof Error ? err.message : '加载工单失败');
|
|
62
|
+
} finally {
|
|
63
|
+
setInitializing(false);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
loadTicket();
|
|
68
|
+
}, [editId]);
|
|
69
|
+
|
|
70
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
71
|
+
e.preventDefault();
|
|
72
|
+
setSaving(true);
|
|
73
|
+
setError(null);
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
if (editId) {
|
|
77
|
+
const response = await fetch(`/api/tickets/${editId}`, {
|
|
78
|
+
method: 'PUT',
|
|
79
|
+
headers: { 'Content-Type': 'application/json' },
|
|
80
|
+
body: JSON.stringify(formData),
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
if (!response.ok) {
|
|
84
|
+
const data = await response.json();
|
|
85
|
+
throw new Error(data.error || '保存工单失败');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
router.push(`/tickets/${editId}`);
|
|
89
|
+
} else {
|
|
90
|
+
const response = await fetch('/api/tickets', {
|
|
91
|
+
method: 'POST',
|
|
92
|
+
headers: { 'Content-Type': 'application/json' },
|
|
93
|
+
body: JSON.stringify({
|
|
94
|
+
...formData,
|
|
95
|
+
status: 'draft',
|
|
96
|
+
createdBy: 'user',
|
|
97
|
+
}),
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
if (!response.ok) {
|
|
101
|
+
const data = await response.json();
|
|
102
|
+
throw new Error(data.error || '创建工单失败');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
router.push('/tickets');
|
|
106
|
+
}
|
|
107
|
+
} catch (err) {
|
|
108
|
+
setError(err instanceof Error ? err.message : '未知错误');
|
|
109
|
+
} finally {
|
|
110
|
+
setSaving(false);
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<>
|
|
116
|
+
{(saving || initializing) && (
|
|
117
|
+
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
|
118
|
+
<div className="bg-white rounded-xl p-8 flex flex-col items-center gap-4">
|
|
119
|
+
<div className="w-10 h-10 border-4 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
|
|
120
|
+
<p className="text-slate-700">{saving ? '保存中...' : '加载中...'}</p>
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
)}
|
|
124
|
+
<div className="p-6">
|
|
125
|
+
<div className="max-w-3xl mx-auto">
|
|
126
|
+
<Link href={editId ? `/tickets/${editId}` : '/tickets'} className="inline-flex items-center text-blue-600 hover:underline text-sm mb-6">
|
|
127
|
+
← 返回工单详情
|
|
128
|
+
</Link>
|
|
129
|
+
|
|
130
|
+
<div className="mb-6">
|
|
131
|
+
<h1 className="text-2xl font-bold text-slate-900">{editId ? '编辑命令' : '创建新工单'}</h1>
|
|
132
|
+
<p className="text-slate-500 text-sm mt-1">{editId ? '修改命令内容后保存' : '填写以下信息创建新的操作工单'}</p>
|
|
133
|
+
</div>
|
|
134
|
+
|
|
135
|
+
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
|
|
136
|
+
{error && (
|
|
137
|
+
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
|
|
138
|
+
<p className="text-red-800 text-sm">❌ {error}</p>
|
|
139
|
+
</div>
|
|
140
|
+
)}
|
|
141
|
+
|
|
142
|
+
<form onSubmit={handleSubmit} className="space-y-6">
|
|
143
|
+
<div className="grid grid-cols-2 gap-4">
|
|
144
|
+
<div>
|
|
145
|
+
<label htmlFor="type" className="block text-sm font-medium text-slate-700 mb-2">
|
|
146
|
+
执行类型 <span className="text-red-500">*</span>
|
|
147
|
+
</label>
|
|
148
|
+
<select
|
|
149
|
+
id="type"
|
|
150
|
+
value={formData.type}
|
|
151
|
+
onChange={(e) => setFormData({ ...formData, type: e.target.value as 'bash-execute' | 'skill-script' })}
|
|
152
|
+
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
153
|
+
required
|
|
154
|
+
>
|
|
155
|
+
<option value="bash-execute">💻 Bash 命令</option>
|
|
156
|
+
<option value="skill-script">🛠️ Skill 脚本</option>
|
|
157
|
+
</select>
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
<div>
|
|
161
|
+
<label htmlFor="priority" className="block text-sm font-medium text-slate-700 mb-2">
|
|
162
|
+
优先级
|
|
163
|
+
</label>
|
|
164
|
+
<select
|
|
165
|
+
id="priority"
|
|
166
|
+
value={formData.priority}
|
|
167
|
+
onChange={(e) => setFormData({ ...formData, priority: e.target.value as 'low' | 'medium' | 'high' | 'critical' })}
|
|
168
|
+
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
169
|
+
>
|
|
170
|
+
<option value="low">🟢 低风险</option>
|
|
171
|
+
<option value="medium">🟡 中风险</option>
|
|
172
|
+
<option value="high">🟠 高风险</option>
|
|
173
|
+
<option value="critical">🔴 极高风险</option>
|
|
174
|
+
</select>
|
|
175
|
+
</div>
|
|
176
|
+
</div>
|
|
177
|
+
|
|
178
|
+
<div>
|
|
179
|
+
<label htmlFor="title" className="block text-sm font-medium text-slate-700 mb-2">
|
|
180
|
+
工单标题 <span className="text-red-500">*</span>
|
|
181
|
+
</label>
|
|
182
|
+
<input
|
|
183
|
+
id="title"
|
|
184
|
+
type="text"
|
|
185
|
+
value={formData.title}
|
|
186
|
+
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
|
187
|
+
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
188
|
+
placeholder="例如:查看集群状态"
|
|
189
|
+
required
|
|
190
|
+
/>
|
|
191
|
+
</div>
|
|
192
|
+
|
|
193
|
+
<div>
|
|
194
|
+
<label htmlFor="description" className="block text-sm font-medium text-slate-700 mb-2">
|
|
195
|
+
描述
|
|
196
|
+
</label>
|
|
197
|
+
<textarea
|
|
198
|
+
id="description"
|
|
199
|
+
value={formData.description}
|
|
200
|
+
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
|
201
|
+
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
202
|
+
rows={3}
|
|
203
|
+
placeholder="描述这次操作的详细信息和目的..."
|
|
204
|
+
/>
|
|
205
|
+
</div>
|
|
206
|
+
|
|
207
|
+
{formData.type === 'bash-execute' && (
|
|
208
|
+
<>
|
|
209
|
+
<div>
|
|
210
|
+
<label htmlFor="commandType" className="block text-sm font-medium text-slate-700 mb-2">
|
|
211
|
+
命令类型
|
|
212
|
+
</label>
|
|
213
|
+
<select
|
|
214
|
+
id="commandType"
|
|
215
|
+
value={formData.commandType}
|
|
216
|
+
onChange={(e) => setFormData({ ...formData, commandType: e.target.value })}
|
|
217
|
+
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
218
|
+
>
|
|
219
|
+
<option value="kubectl">kubectl</option>
|
|
220
|
+
<option value="docker">docker</option>
|
|
221
|
+
<option value="git">git</option>
|
|
222
|
+
<option value="other">其他</option>
|
|
223
|
+
</select>
|
|
224
|
+
</div>
|
|
225
|
+
|
|
226
|
+
<div>
|
|
227
|
+
<label htmlFor="command" className="block text-sm font-medium text-slate-700 mb-2">
|
|
228
|
+
执行命令 <span className="text-red-500">*</span>
|
|
229
|
+
</label>
|
|
230
|
+
<textarea
|
|
231
|
+
id="command"
|
|
232
|
+
value={formData.command}
|
|
233
|
+
onChange={(e) => setFormData({ ...formData, command: e.target.value })}
|
|
234
|
+
className="w-full h-32 px-3 py-2 border border-gray-200 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
235
|
+
placeholder="例如:kubectl get pods -n default"
|
|
236
|
+
required
|
|
237
|
+
/>
|
|
238
|
+
</div>
|
|
239
|
+
</>
|
|
240
|
+
)}
|
|
241
|
+
|
|
242
|
+
{formData.type === 'skill-script' && (
|
|
243
|
+
<>
|
|
244
|
+
<div>
|
|
245
|
+
<label htmlFor="skillName" className="block text-sm font-medium text-slate-700 mb-2">
|
|
246
|
+
Skill 名称 <span className="text-red-500">*</span>
|
|
247
|
+
</label>
|
|
248
|
+
<input
|
|
249
|
+
id="skillName"
|
|
250
|
+
type="text"
|
|
251
|
+
value={formData.skillName}
|
|
252
|
+
onChange={(e) => setFormData({ ...formData, skillName: e.target.value })}
|
|
253
|
+
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
254
|
+
placeholder="例如:k8s-ops"
|
|
255
|
+
required
|
|
256
|
+
/>
|
|
257
|
+
</div>
|
|
258
|
+
|
|
259
|
+
<div>
|
|
260
|
+
<label htmlFor="scriptContent" className="block text-sm font-medium text-slate-700 mb-2">
|
|
261
|
+
脚本内容 <span className="text-red-500">*</span>
|
|
262
|
+
</label>
|
|
263
|
+
<textarea
|
|
264
|
+
id="scriptContent"
|
|
265
|
+
value={formData.scriptContent}
|
|
266
|
+
onChange={(e) => setFormData({ ...formData, scriptContent: e.target.value })}
|
|
267
|
+
className="w-full h-48 px-3 py-2 border border-gray-200 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
268
|
+
placeholder="#!/bin/bash echo 'Hello World'"
|
|
269
|
+
required
|
|
270
|
+
/>
|
|
271
|
+
</div>
|
|
272
|
+
</>
|
|
273
|
+
)}
|
|
274
|
+
|
|
275
|
+
<div className="flex gap-3 pt-4 border-t border-gray-100">
|
|
276
|
+
<button
|
|
277
|
+
type="submit"
|
|
278
|
+
disabled={saving}
|
|
279
|
+
className="flex-1 px-4 py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed font-medium transition-colors"
|
|
280
|
+
>
|
|
281
|
+
{saving ? '保存中...' : (editId ? '保存修改' : '创建工单')}
|
|
282
|
+
</button>
|
|
283
|
+
<button
|
|
284
|
+
type="button"
|
|
285
|
+
onClick={() => router.push(editId ? `/tickets/${editId}` : '/tickets')}
|
|
286
|
+
className="px-4 py-2.5 border border-gray-200 text-slate-700 rounded-lg hover:bg-gray-50 font-medium transition-colors"
|
|
287
|
+
>
|
|
288
|
+
取消
|
|
289
|
+
</button>
|
|
290
|
+
</div>
|
|
291
|
+
</form>
|
|
292
|
+
</div>
|
|
293
|
+
</div>
|
|
294
|
+
</div>
|
|
295
|
+
</>
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export default function NewTicketPage() {
|
|
300
|
+
return (
|
|
301
|
+
<Suspense fallback={
|
|
302
|
+
<div className="flex items-center justify-center h-full">
|
|
303
|
+
<div className="text-slate-500">加载中...</div>
|
|
304
|
+
</div>
|
|
305
|
+
}>
|
|
306
|
+
<NewTicketPageContent />
|
|
307
|
+
</Suspense>
|
|
308
|
+
);
|
|
309
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import Link from 'next/link';
|
|
2
|
+
import { Plus } from 'lucide-react';
|
|
3
|
+
import { TicketList } from '@/components/ticket-list';
|
|
4
|
+
import { Button } from '@/components/ui/button';
|
|
5
|
+
|
|
6
|
+
export default function TicketsPage() {
|
|
7
|
+
return (
|
|
8
|
+
<div className="p-6">
|
|
9
|
+
<div className="max-w-7xl mx-auto">
|
|
10
|
+
<div className="flex items-center justify-between mb-6">
|
|
11
|
+
<div>
|
|
12
|
+
<h1 className="text-2xl font-bold">工单管理</h1>
|
|
13
|
+
<p className="text-muted-foreground text-sm mt-1">创建和管理 Kubernetes 操作工单</p>
|
|
14
|
+
</div>
|
|
15
|
+
<Button asChild>
|
|
16
|
+
<Link href="/tickets/new">
|
|
17
|
+
<Plus className="mr-2 h-4 w-4" />
|
|
18
|
+
新建工单
|
|
19
|
+
</Link>
|
|
20
|
+
</Button>
|
|
21
|
+
</div>
|
|
22
|
+
|
|
23
|
+
<TicketList />
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
26
|
+
);
|
|
27
|
+
}
|