march-control-cli 0.1.3

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.
Files changed (53) hide show
  1. package/README.md +220 -0
  2. package/core/apply.js +152 -0
  3. package/core/backup.js +53 -0
  4. package/core/constants.js +55 -0
  5. package/core/desktop-service.js +219 -0
  6. package/core/desktop-state.js +511 -0
  7. package/core/index.js +1293 -0
  8. package/core/paths.js +71 -0
  9. package/core/presets.js +171 -0
  10. package/core/probe.js +70 -0
  11. package/core/store.js +218 -0
  12. package/core/utils.js +178 -0
  13. package/core/writers/codex.js +102 -0
  14. package/core/writers/index.js +16 -0
  15. package/core/writers/openclaw.js +93 -0
  16. package/core/writers/opencode.js +91 -0
  17. package/desktop/assets/march-mark.svg +21 -0
  18. package/desktop/main.js +192 -0
  19. package/desktop/preload.js +49 -0
  20. package/desktop/renderer/app.js +327 -0
  21. package/desktop/renderer/index.html +130 -0
  22. package/desktop/renderer/styles.css +413 -0
  23. package/package.json +106 -0
  24. package/scripts/desktop-dev.mjs +90 -0
  25. package/scripts/postinstall.mjs +28 -0
  26. package/scripts/serve-site.mjs +51 -0
  27. package/site/app.js +10 -0
  28. package/site/assets/march-mark.svg +22 -0
  29. package/site/index.html +286 -0
  30. package/site/styles.css +566 -0
  31. package/src/App.tsx +1186 -0
  32. package/src/components/layout/app-sidebar.tsx +103 -0
  33. package/src/components/layout/top-toolbar.tsx +44 -0
  34. package/src/components/layout/workspace-tabs.tsx +32 -0
  35. package/src/components/providers/inspector-panel.tsx +84 -0
  36. package/src/components/providers/metric-strip.tsx +26 -0
  37. package/src/components/providers/provider-editor.tsx +87 -0
  38. package/src/components/providers/provider-table.tsx +85 -0
  39. package/src/components/ui/logo-mark.tsx +16 -0
  40. package/src/features/mcp/mcp-view.tsx +45 -0
  41. package/src/features/prompts/prompts-view.tsx +40 -0
  42. package/src/features/providers/providers-view.tsx +40 -0
  43. package/src/features/providers/types.ts +8 -0
  44. package/src/features/skills/skills-view.tsx +44 -0
  45. package/src/hooks/use-control-workspace.ts +184 -0
  46. package/src/index.css +22 -0
  47. package/src/lib/client.ts +944 -0
  48. package/src/lib/query-client.ts +3 -0
  49. package/src/lib/workspace-sections.ts +34 -0
  50. package/src/main.tsx +14 -0
  51. package/src/types.ts +76 -0
  52. package/src/vite-env.d.ts +56 -0
  53. package/src-tauri/README.md +11 -0
package/src/App.tsx ADDED
@@ -0,0 +1,1186 @@
1
+ import { useState } from "react";
2
+ import {
3
+ ArrowDownToLine,
4
+ Bot,
5
+ Check,
6
+ CircleUserRound,
7
+ FileStack,
8
+ Plus,
9
+ RefreshCw,
10
+ Sparkles,
11
+ SquarePen,
12
+ Trash2,
13
+ X
14
+ } from "lucide-react";
15
+ import { useControlWorkspace } from "./hooks/use-control-workspace";
16
+ import { workspaceSections } from "./lib/workspace-sections";
17
+ import type { PlatformId, Provider } from "./types";
18
+
19
+ const emptyForm = { name: "", baseUrl: "", apiKey: "", model: "gpt-5.4" };
20
+ const allPlatforms: PlatformId[] = ["codex", "opencode", "openclaw"];
21
+
22
+ type McpEditorForm = {
23
+ kind: "mcp";
24
+ id?: string;
25
+ name: string;
26
+ description: string;
27
+ tags: string;
28
+ homepage: string;
29
+ enabledPlatforms: PlatformId[];
30
+ error: string | null;
31
+ };
32
+
33
+ type PromptEditorForm = {
34
+ kind: "prompt";
35
+ id?: string;
36
+ name: string;
37
+ description: string;
38
+ content: string;
39
+ appType: "global" | PlatformId;
40
+ enabled: boolean;
41
+ error: string | null;
42
+ };
43
+
44
+ type SkillEditorForm = {
45
+ kind: "skill";
46
+ id?: string;
47
+ name: string;
48
+ description: string;
49
+ directory: string;
50
+ enabledPlatforms: PlatformId[];
51
+ error: string | null;
52
+ };
53
+
54
+ type ResourceEditorForm = McpEditorForm | PromptEditorForm | SkillEditorForm;
55
+
56
+ type DeleteConfirmState =
57
+ | { kind: "mcp"; id: string; name: string }
58
+ | { kind: "prompt"; id: string; name: string }
59
+ | { kind: "skill"; id: string; name: string };
60
+
61
+ function cx(...parts: Array<string | false | null | undefined>) {
62
+ return parts.filter(Boolean).join(" ");
63
+ }
64
+
65
+ function tone(id: PlatformId) {
66
+ if (id === "openclaw") return { soft: "bg-[#f97316]/10 text-[#f97316]", line: "border-[#f97316]/20" };
67
+ if (id === "opencode") return { soft: "bg-[#14b8a6]/10 text-[#0f766e]", line: "border-[#14b8a6]/20" };
68
+ return { soft: "bg-[#1677ff]/10 text-[#1677ff]", line: "border-[#1677ff]/20" };
69
+ }
70
+
71
+ function timeText(value: string) {
72
+ return new Date(value).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
73
+ }
74
+
75
+ function shortUrl(value: string) {
76
+ try {
77
+ const u = new URL(value);
78
+ return `${u.origin}${u.pathname === "/" ? "" : u.pathname}`;
79
+ } catch {
80
+ return value;
81
+ }
82
+ }
83
+
84
+ function providerArt(provider: Provider, platformId: PlatformId) {
85
+ const name = provider.name.toLowerCase();
86
+ const url = provider.baseUrl.toLowerCase();
87
+ if (name.includes("openai") || url.includes("openai.com")) return { src: "/assets/openai-small.svg", alt: "OpenAI" };
88
+ if (platformId === "openclaw" || name.includes("openclaw") || name.includes("lobster")) {
89
+ return { src: "/assets/pixel-lobster.svg", alt: "OpenClaw" };
90
+ }
91
+ return null;
92
+ }
93
+
94
+ function platformArt(platformId: PlatformId) {
95
+ if (platformId === "codex") return <img src="/assets/openai-small.svg" alt="Codex" className="h-5 w-5 object-contain" />;
96
+ if (platformId === "openclaw") return <img src="/assets/pixel-lobster.svg" alt="OpenClaw" className="h-5 w-5 object-contain" />;
97
+ return <Bot className="h-4 w-4 text-slate-600" />;
98
+ }
99
+
100
+ function promptScopeLabel(scope: "global" | PlatformId) {
101
+ if (scope === "global") return "全局";
102
+ if (scope === "codex") return "Codex";
103
+ if (scope === "opencode") return "OpenCode";
104
+ return "OpenClaw";
105
+ }
106
+
107
+ function platformLabel(id: PlatformId) {
108
+ if (id === "codex") return "Codex";
109
+ if (id === "opencode") return "OpenCode";
110
+ return "OpenClaw";
111
+ }
112
+
113
+ function BrandMark() {
114
+ return (
115
+ <div className="relative grid h-14 w-14 place-items-center overflow-hidden rounded-[20px] bg-[linear-gradient(160deg,#ff8a1f_0%,#ff6b1a_52%,#ffb347_100%)] shadow-[0_20px_48px_rgba(249,115,22,0.28)] ring-1 ring-[#fdba74]">
116
+ <div className="absolute inset-[6px] rounded-[16px] bg-[linear-gradient(180deg,rgba(255,255,255,0.24),rgba(255,255,255,0.04))]" />
117
+ <svg viewBox="0 0 64 64" className="relative h-8 w-8" fill="none" aria-hidden="true">
118
+ <path d="M14 47V20.5c0-1.38 1.12-2.5 2.5-2.5h1.9c1.03 0 1.99.51 2.58 1.35L32 35.1l11.02-15.75A3.13 3.13 0 0 1 45.58 18h1.92c1.38 0 2.5 1.12 2.5 2.5V47" stroke="white" strokeWidth="5.2" strokeLinecap="round" strokeLinejoin="round" />
119
+ <circle cx="49.5" cy="16.5" r="4.5" fill="#fff1e6" />
120
+ </svg>
121
+ </div>
122
+ );
123
+ }
124
+
125
+ export default function App() {
126
+ const workspace = useControlWorkspace();
127
+ const [dialogOpen, setDialogOpen] = useState(false);
128
+ const [candidateProbeText, setCandidateProbeText] = useState<string | null>(null);
129
+ const [targetPathMessage, setTargetPathMessage] = useState<string | null>(null);
130
+ const [resourceEditor, setResourceEditor] = useState<ResourceEditorForm | null>(null);
131
+ const [deleteConfirm, setDeleteConfirm] = useState<DeleteConfirmState | null>(null);
132
+
133
+ if (!workspace.snapshot || !workspace.platform) {
134
+ return <div className="flex min-h-screen items-center justify-center text-slate-400">正在加载工作区...</div>;
135
+ }
136
+
137
+ const platform = workspace.platform;
138
+ const styles = tone(platform.id);
139
+ const providers = platform.providers;
140
+ const active = providers.find((item) => item.isActive) ?? providers[0] ?? null;
141
+ const probes = workspace.probeQuery.data ?? [];
142
+ const editing = providers.some((item) => item.name.trim().toLowerCase() === workspace.editorForm.name.trim().toLowerCase());
143
+ const activeSection = workspace.activeSection;
144
+ const mcpServers = workspace.snapshot.mcpServers;
145
+ const prompts = workspace.snapshot.prompts.filter((item) => item.appType === "global" || item.appType === platform.id);
146
+ const skills = workspace.snapshot.skills.filter((item) => item.enabledPlatforms.includes(platform.id));
147
+ const skillRepos = workspace.snapshot.skillRepos;
148
+ const models = workspace.snapshot.models.length > 0 ? workspace.snapshot.models : [emptyForm.model];
149
+
150
+ function openCreate() {
151
+ setResourceEditor(null);
152
+ workspace.setEditorForm({ ...emptyForm, model: models[0] ?? emptyForm.model });
153
+ setCandidateProbeText(null);
154
+ setDialogOpen(true);
155
+ }
156
+
157
+ function openImport() {
158
+ setResourceEditor(null);
159
+ workspace.setEditorForm({
160
+ name: active?.name || "default",
161
+ baseUrl: active?.baseUrl || "",
162
+ apiKey: "",
163
+ model: active?.model || models[0] || emptyForm.model
164
+ });
165
+ setCandidateProbeText(null);
166
+ setDialogOpen(true);
167
+ }
168
+
169
+ function openEdit(provider: Provider) {
170
+ setResourceEditor(null);
171
+ workspace.openProvider(provider);
172
+ setCandidateProbeText(null);
173
+ setDialogOpen(true);
174
+ }
175
+
176
+ function closeDialog() {
177
+ setDialogOpen(false);
178
+ setCandidateProbeText(null);
179
+ workspace.setEditorForm({ ...emptyForm, model: models[0] ?? emptyForm.model });
180
+ }
181
+
182
+ function submit() {
183
+ workspace.saveMutation.mutate(
184
+ { platform: platform.id, ...workspace.editorForm },
185
+ {
186
+ onSuccess: () => closeDialog()
187
+ }
188
+ );
189
+ }
190
+
191
+ async function probeCandidateBaseUrl() {
192
+ if (!workspace.editorForm.baseUrl.trim()) {
193
+ setCandidateProbeText("请先输入基础地址");
194
+ return;
195
+ }
196
+ setCandidateProbeText("正在测试...");
197
+ try {
198
+ const response = await workspace.probeCandidateMutation.mutateAsync({ platform: platform.id, baseUrl: workspace.editorForm.baseUrl });
199
+ if (!response.result) {
200
+ setCandidateProbeText("未返回测速结果");
201
+ } else if (response.result.ok) {
202
+ setCandidateProbeText(`可连通,延迟 ${response.result.latency} ms`);
203
+ } else {
204
+ setCandidateProbeText(`不可用:${response.result.error || "未知错误"}`);
205
+ }
206
+ } catch (error) {
207
+ setCandidateProbeText(error instanceof Error ? error.message : "测试失败");
208
+ }
209
+ }
210
+
211
+ async function openTargetPath(targetPath: string) {
212
+ try {
213
+ const response = await workspace.openPathMutation.mutateAsync({ targetPath });
214
+ setTargetPathMessage(`已打开:${response.targetPath}`);
215
+ } catch (error) {
216
+ const message = error instanceof Error ? error.message : "打开失败";
217
+ setTargetPathMessage(`打开失败:${message}`);
218
+ }
219
+ }
220
+
221
+ function closeResourceEditor() {
222
+ setResourceEditor(null);
223
+ }
224
+
225
+ function toggleResourcePlatform(target: PlatformId) {
226
+ setResourceEditor((current) => {
227
+ if (!current || (current.kind !== "mcp" && current.kind !== "skill")) {
228
+ return current;
229
+ }
230
+
231
+ const enabledPlatforms = current.enabledPlatforms.includes(target)
232
+ ? current.enabledPlatforms.filter((item) => item !== target)
233
+ : [...current.enabledPlatforms, target];
234
+
235
+ return { ...current, enabledPlatforms, error: null };
236
+ });
237
+ }
238
+
239
+ function openMcpEditor(server?: (typeof mcpServers)[number]) {
240
+ setDialogOpen(false);
241
+ setResourceEditor({
242
+ kind: "mcp",
243
+ id: server?.id,
244
+ name: server?.name || "",
245
+ description: server?.description || "",
246
+ tags: (server?.tags || []).join(", "),
247
+ homepage: server?.homepage || "",
248
+ enabledPlatforms: server?.enabledPlatforms?.length ? [...server.enabledPlatforms] : [...allPlatforms],
249
+ error: null
250
+ });
251
+ }
252
+
253
+ function openDeleteConfirm(next: DeleteConfirmState) {
254
+ setDialogOpen(false);
255
+ setResourceEditor(null);
256
+ setDeleteConfirm(next);
257
+ }
258
+
259
+ function closeDeleteConfirm() {
260
+ setDeleteConfirm(null);
261
+ }
262
+
263
+ function removeMcp(serverId: string, name: string) {
264
+ openDeleteConfirm({ kind: "mcp", id: serverId, name });
265
+ }
266
+
267
+ function openPromptEditor(prompt?: (typeof prompts)[number]) {
268
+ setDialogOpen(false);
269
+ setResourceEditor({
270
+ kind: "prompt",
271
+ id: prompt?.id,
272
+ name: prompt?.name || "",
273
+ description: prompt?.description || "",
274
+ content: prompt?.content || "",
275
+ appType: prompt?.appType || "global",
276
+ enabled: prompt?.enabled ?? true,
277
+ error: null
278
+ });
279
+ }
280
+
281
+ function removePrompt(promptId: string, name: string) {
282
+ openDeleteConfirm({ kind: "prompt", id: promptId, name });
283
+ }
284
+
285
+ function openSkillEditor(skill?: (typeof skills)[number]) {
286
+ setDialogOpen(false);
287
+ setResourceEditor({
288
+ kind: "skill",
289
+ id: skill?.id,
290
+ name: skill?.name || "",
291
+ description: skill?.description || "",
292
+ directory: skill?.directory || "",
293
+ enabledPlatforms: skill?.enabledPlatforms?.length ? [...skill.enabledPlatforms] : [...allPlatforms],
294
+ error: null
295
+ });
296
+ }
297
+
298
+ function removeSkill(skillId: string, name: string) {
299
+ openDeleteConfirm({ kind: "skill", id: skillId, name });
300
+ }
301
+
302
+ function confirmDelete() {
303
+ if (!deleteConfirm) return;
304
+ if (deleteConfirm.kind === "mcp") {
305
+ workspace.deleteMcpMutation.mutate({ serverId: deleteConfirm.id }, { onSuccess: () => closeDeleteConfirm() });
306
+ return;
307
+ }
308
+ if (deleteConfirm.kind === "prompt") {
309
+ workspace.deletePromptMutation.mutate({ promptId: deleteConfirm.id }, { onSuccess: () => closeDeleteConfirm() });
310
+ return;
311
+ }
312
+ workspace.deleteSkillMutation.mutate({ skillId: deleteConfirm.id }, { onSuccess: () => closeDeleteConfirm() });
313
+ }
314
+
315
+ function submitResourceEditor() {
316
+ if (!resourceEditor) return;
317
+
318
+ if (resourceEditor.kind === "mcp") {
319
+ if (!resourceEditor.name.trim()) {
320
+ setResourceEditor({ ...resourceEditor, error: "请填写 MCP 名称" });
321
+ return;
322
+ }
323
+ if (!resourceEditor.description.trim()) {
324
+ setResourceEditor({ ...resourceEditor, error: "请填写 MCP 描述" });
325
+ return;
326
+ }
327
+ if (resourceEditor.enabledPlatforms.length === 0) {
328
+ setResourceEditor({ ...resourceEditor, error: "请至少选择一个启用平台" });
329
+ return;
330
+ }
331
+
332
+ const tags = resourceEditor.tags
333
+ .split(",")
334
+ .map((item) => item.trim())
335
+ .filter(Boolean);
336
+ workspace.upsertMcpMutation.mutate(
337
+ {
338
+ id: resourceEditor.id,
339
+ name: resourceEditor.name.trim(),
340
+ description: resourceEditor.description.trim(),
341
+ tags,
342
+ enabledPlatforms: resourceEditor.enabledPlatforms,
343
+ homepage: resourceEditor.homepage.trim() || undefined
344
+ },
345
+ { onSuccess: () => closeResourceEditor() }
346
+ );
347
+ return;
348
+ }
349
+
350
+ if (resourceEditor.kind === "prompt") {
351
+ if (!resourceEditor.name.trim()) {
352
+ setResourceEditor({ ...resourceEditor, error: "请填写提示词名称" });
353
+ return;
354
+ }
355
+ if (!resourceEditor.content.trim()) {
356
+ setResourceEditor({ ...resourceEditor, error: "请填写提示词内容" });
357
+ return;
358
+ }
359
+
360
+ workspace.upsertPromptMutation.mutate(
361
+ {
362
+ id: resourceEditor.id,
363
+ appType: resourceEditor.appType,
364
+ name: resourceEditor.name.trim(),
365
+ description: resourceEditor.description.trim(),
366
+ content: resourceEditor.content.trim(),
367
+ enabled: resourceEditor.enabled
368
+ },
369
+ { onSuccess: () => closeResourceEditor() }
370
+ );
371
+ return;
372
+ }
373
+
374
+ if (!resourceEditor.name.trim()) {
375
+ setResourceEditor({ ...resourceEditor, error: "请填写技能名称" });
376
+ return;
377
+ }
378
+ if (!resourceEditor.directory.trim()) {
379
+ setResourceEditor({ ...resourceEditor, error: "请填写技能目录" });
380
+ return;
381
+ }
382
+ if (resourceEditor.enabledPlatforms.length === 0) {
383
+ setResourceEditor({ ...resourceEditor, error: "请至少选择一个启用平台" });
384
+ return;
385
+ }
386
+
387
+ workspace.upsertSkillMutation.mutate(
388
+ {
389
+ id: resourceEditor.id,
390
+ name: resourceEditor.name.trim(),
391
+ description: resourceEditor.description.trim(),
392
+ directory: resourceEditor.directory.trim(),
393
+ enabledPlatforms: resourceEditor.enabledPlatforms
394
+ },
395
+ { onSuccess: () => closeResourceEditor() }
396
+ );
397
+ }
398
+
399
+ const quickAction =
400
+ activeSection === "providers"
401
+ ? { label: "新增服务商", run: openCreate }
402
+ : activeSection === "mcp"
403
+ ? { label: "新增 MCP", run: () => openMcpEditor() }
404
+ : activeSection === "prompts"
405
+ ? { label: "新增提示词", run: () => openPromptEditor() }
406
+ : { label: "新增技能", run: () => openSkillEditor() };
407
+
408
+ const resourceSubmitPending =
409
+ resourceEditor?.kind === "mcp"
410
+ ? workspace.upsertMcpMutation.isPending
411
+ : resourceEditor?.kind === "prompt"
412
+ ? workspace.upsertPromptMutation.isPending
413
+ : resourceEditor?.kind === "skill"
414
+ ? workspace.upsertSkillMutation.isPending
415
+ : false;
416
+
417
+ const deleteSubmitPending =
418
+ deleteConfirm?.kind === "mcp"
419
+ ? workspace.deleteMcpMutation.isPending
420
+ : deleteConfirm?.kind === "prompt"
421
+ ? workspace.deletePromptMutation.isPending
422
+ : deleteConfirm?.kind === "skill"
423
+ ? workspace.deleteSkillMutation.isPending
424
+ : false;
425
+
426
+ return (
427
+ <div className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(59,130,246,0.06),_transparent_22%),linear-gradient(180deg,#ffffff_0%,#f7f9fc_100%)] text-slate-900">
428
+ <div className="mx-auto flex min-h-screen max-w-[1440px] flex-col px-8 py-8">
429
+ <header className="flex flex-wrap items-start justify-between gap-6">
430
+ <div className="flex items-center gap-4">
431
+ <BrandMark />
432
+ <div>
433
+ <div className="inline-flex items-center gap-2 rounded-full bg-white px-3 py-1 text-[11px] font-medium uppercase tracking-[0.28em] text-slate-400 ring-1 ring-slate-200/80">
434
+ <Sparkles className="h-3.5 w-3.5 text-[#f97316]" />
435
+ 桌面控制台
436
+ </div>
437
+ <div className="mt-3 flex items-center gap-3">
438
+ <h1 className="text-[28px] font-semibold tracking-[-0.05em] text-slate-950">{workspace.snapshot.appName}</h1>
439
+ <span className={cx("rounded-full px-3 py-1 text-xs font-medium", styles.soft)}>{platform.label}</span>
440
+ </div>
441
+ <p className="mt-2 max-w-2xl text-sm leading-6 text-slate-500">在一个轻量控制台中完成路由切换、目标文件管理与连通性检测。</p>
442
+ </div>
443
+ </div>
444
+
445
+ <div className="flex items-center gap-3">
446
+ <button
447
+ type="button"
448
+ onClick={quickAction.run}
449
+ className="inline-flex h-11 items-center gap-2 rounded-2xl bg-[#ff7a1a] px-4 text-sm font-medium text-white shadow-[0_16px_30px_rgba(255,122,26,0.28)] transition hover:brightness-105"
450
+ >
451
+ <Plus className="h-4 w-4" />
452
+ {quickAction.label}
453
+ </button>
454
+ </div>
455
+ </header>
456
+
457
+ <section className="mt-7 flex flex-wrap items-center gap-3">
458
+ {workspace.snapshot.platforms.map((item) => (
459
+ <button
460
+ key={item.id}
461
+ type="button"
462
+ onClick={() => workspace.setActivePlatform(item.id)}
463
+ className={cx(
464
+ "inline-flex items-center gap-3 rounded-[20px] border px-4 py-3 text-left transition",
465
+ item.id === platform.id ? cx("bg-white shadow-[0_18px_38px_rgba(15,23,42,0.08)]", tone(item.id).line) : "border-slate-200/80 bg-white/70 hover:bg-white"
466
+ )}
467
+ >
468
+ <div className="grid h-10 w-10 place-items-center rounded-2xl bg-slate-50 ring-1 ring-slate-200/70">{platformArt(item.id)}</div>
469
+ <div>
470
+ <div className="text-sm font-medium text-slate-900">{item.label}</div>
471
+ <div className="text-xs text-slate-400">已保存 {item.providerCount} 条路由</div>
472
+ </div>
473
+ </button>
474
+ ))}
475
+ </section>
476
+
477
+ <section className="mt-5 flex flex-wrap items-center gap-2">
478
+ {workspaceSections.map((section) => (
479
+ <button
480
+ key={section.id}
481
+ type="button"
482
+ onClick={() => workspace.setActiveSection(section.id)}
483
+ className={cx(
484
+ "inline-flex items-center gap-2 rounded-full px-4 py-2 text-sm font-medium transition",
485
+ activeSection === section.id ? "bg-slate-950 text-white shadow-[0_10px_20px_rgba(15,23,42,0.12)]" : "bg-white text-slate-600 ring-1 ring-slate-200/80 hover:text-slate-900"
486
+ )}
487
+ >
488
+ <section.icon className="h-4 w-4" />
489
+ {section.label}
490
+ </button>
491
+ ))}
492
+ </section>
493
+
494
+ <main className="mt-7 grid flex-1 grid-cols-[minmax(0,1.45fr)_380px] gap-6">
495
+ <section className="min-w-0 rounded-[30px] border border-slate-200/80 bg-white p-6 shadow-[0_24px_80px_rgba(15,23,42,0.06)]">
496
+ <div className="flex flex-wrap items-start justify-between gap-4">
497
+ <div>
498
+ <div className="text-[12px] uppercase tracking-[0.24em] text-slate-400">{workspace.activeSectionMeta.eyebrow}</div>
499
+ <h2 className="mt-2 text-[30px] font-semibold tracking-[-0.05em] text-slate-950">
500
+ {platform.label} {workspace.activeSectionMeta.title}
501
+ </h2>
502
+ <p className="mt-2 max-w-2xl text-sm leading-6 text-slate-500">
503
+ {activeSection === "providers" ? "这里支持新增、编辑、切换、刷新和连通性检测,配置会立即回写工作区。" : "布局参考 CC Switch 的信息结构,同时保留 March 的品牌风格。"}
504
+ </p>
505
+ </div>
506
+
507
+ <div className="flex flex-wrap items-center gap-3">
508
+ <button
509
+ type="button"
510
+ onClick={() => workspace.snapshotQuery.refetch()}
511
+ className="inline-flex h-11 items-center gap-2 rounded-2xl border border-slate-200 px-4 text-sm font-medium text-slate-700 transition hover:border-slate-300 hover:bg-slate-50"
512
+ >
513
+ <RefreshCw className={cx("h-4 w-4", workspace.snapshotQuery.isFetching && "animate-spin")} />
514
+ 刷新
515
+ </button>
516
+ {activeSection === "providers" ? (
517
+ <>
518
+ <button
519
+ type="button"
520
+ onClick={openImport}
521
+ className="inline-flex h-11 items-center gap-2 rounded-2xl border border-slate-200 px-4 text-sm font-medium text-slate-700 transition hover:border-slate-300 hover:bg-slate-50"
522
+ >
523
+ <ArrowDownToLine className="h-4 w-4" />
524
+ 从当前导入
525
+ </button>
526
+ <button
527
+ type="button"
528
+ onClick={openCreate}
529
+ className="inline-flex h-11 items-center gap-2 rounded-2xl bg-[#1677ff] px-4 text-sm font-medium text-white shadow-[0_12px_24px_rgba(22,119,255,0.22)] transition hover:brightness-105"
530
+ >
531
+ <Plus className="h-4 w-4" />
532
+ 新增服务商
533
+ </button>
534
+ </>
535
+ ) : activeSection === "mcp" ? (
536
+ <button type="button" onClick={() => openMcpEditor()} className="inline-flex h-11 items-center gap-2 rounded-2xl bg-[#1677ff] px-4 text-sm font-medium text-white shadow-[0_12px_24px_rgba(22,119,255,0.22)] transition hover:brightness-105">
537
+ <Plus className="h-4 w-4" />新增 MCP
538
+ </button>
539
+ ) : activeSection === "prompts" ? (
540
+ <button type="button" onClick={() => openPromptEditor()} className="inline-flex h-11 items-center gap-2 rounded-2xl bg-[#1677ff] px-4 text-sm font-medium text-white shadow-[0_12px_24px_rgba(22,119,255,0.22)] transition hover:brightness-105">
541
+ <Plus className="h-4 w-4" />新增提示词
542
+ </button>
543
+ ) : (
544
+ <button type="button" onClick={() => openSkillEditor()} className="inline-flex h-11 items-center gap-2 rounded-2xl bg-[#1677ff] px-4 text-sm font-medium text-white shadow-[0_12px_24px_rgba(22,119,255,0.22)] transition hover:brightness-105">
545
+ <Plus className="h-4 w-4" />新增技能
546
+ </button>
547
+ )}
548
+ </div>
549
+ </div>
550
+
551
+ {activeSection === "providers" ? (
552
+ <div className="mt-6 grid gap-3">
553
+ {providers.map((provider) => {
554
+ const art = providerArt(provider, platform.id);
555
+ return (
556
+ <article key={provider.id} className="rounded-[24px] border border-slate-200/80 bg-slate-50 px-5 py-4">
557
+ <div className="flex flex-wrap items-start justify-between gap-3">
558
+ <div className="min-w-0">
559
+ <div className="flex flex-wrap items-center gap-2">
560
+ <div className="grid h-8 w-8 place-items-center rounded-xl bg-white ring-1 ring-slate-200/80">
561
+ {art ? <img src={art.src} alt={art.alt} className="h-4 w-4 object-contain" /> : <CircleUserRound className="h-4 w-4 text-slate-500" />}
562
+ </div>
563
+ <div className="text-base font-semibold text-slate-950">{provider.name}</div>
564
+ {provider.isActive ? <span className={cx("rounded-full px-2.5 py-1 text-[11px] font-medium", styles.soft)}>当前路由</span> : null}
565
+ </div>
566
+ <div className="mt-2 text-sm text-slate-500">{shortUrl(provider.baseUrl)}</div>
567
+ <div className="mt-1 text-xs text-slate-400">模型:{provider.model} / 密钥:{provider.maskedApiKey}</div>
568
+ <div className="mt-1 text-xs text-slate-400">更新时间:{timeText(provider.updatedAt)}</div>
569
+ </div>
570
+ <div className="flex flex-wrap items-center gap-2">
571
+ {!provider.isActive ? (
572
+ <button type="button" onClick={() => workspace.activateMutation.mutate({ platform: platform.id, providerId: provider.id })} className="inline-flex h-10 items-center rounded-2xl bg-slate-950 px-4 text-sm font-medium text-white transition hover:opacity-90">设为当前</button>
573
+ ) : (
574
+ <button type="button" className="inline-flex h-10 items-center gap-1 rounded-2xl border border-slate-200 bg-white px-4 text-sm font-medium text-slate-700" disabled>
575
+ <Check className="h-4 w-4" />生效中
576
+ </button>
577
+ )}
578
+ <button type="button" onClick={() => openEdit(provider)} className="inline-flex h-10 items-center gap-1 rounded-2xl border border-slate-200 bg-white px-3 text-sm font-medium text-slate-700 transition hover:bg-slate-100">
579
+ <SquarePen className="h-4 w-4" />编辑
580
+ </button>
581
+ </div>
582
+ </div>
583
+ </article>
584
+ );
585
+ })}
586
+ </div>
587
+ ) : activeSection === "mcp" ? (
588
+ <div className="mt-6 grid gap-3">
589
+ {mcpServers.map((server) => {
590
+ const enabled = server.enabledPlatforms.includes(platform.id);
591
+ return (
592
+ <article key={server.id} className="rounded-[24px] border border-slate-200/80 bg-slate-50 px-5 py-4">
593
+ <div className="flex flex-wrap items-start justify-between gap-3">
594
+ <div className="min-w-0">
595
+ <div className="flex flex-wrap items-center gap-2">
596
+ <div className="text-base font-semibold text-slate-950">{server.name}</div>
597
+ <span className={cx("rounded-full px-2.5 py-1 text-[11px] font-medium", enabled ? styles.soft : "bg-slate-200 text-slate-600")}>{enabled ? "已启用" : "未启用"}</span>
598
+ </div>
599
+ <p className="mt-2 text-sm leading-6 text-slate-500">{server.description}</p>
600
+ <div className="mt-3 flex flex-wrap gap-2">
601
+ {server.tags.map((tag) => (
602
+ <span key={tag} className="rounded-full bg-white px-2.5 py-1 text-[11px] text-slate-500 ring-1 ring-slate-200/80">{tag}</span>
603
+ ))}
604
+ </div>
605
+ {server.homepage ? <div className="mt-2 text-xs text-slate-400">主页:{server.homepage}</div> : null}
606
+ </div>
607
+ <div className="flex flex-wrap items-center gap-2">
608
+ <button
609
+ type="button"
610
+ onClick={() => workspace.toggleMcpMutation.mutate({ serverId: server.id, platform: platform.id })}
611
+ className={cx("inline-flex h-10 items-center rounded-2xl px-4 text-sm font-medium transition", enabled ? "border border-slate-200 bg-white text-slate-700 hover:bg-slate-100" : "bg-slate-950 text-white hover:opacity-90")}
612
+ >
613
+ {enabled ? "禁用" : "启用"}
614
+ </button>
615
+ <button type="button" onClick={() => openMcpEditor(server)} className="inline-flex h-10 items-center gap-1 rounded-2xl border border-slate-200 bg-white px-3 text-sm font-medium text-slate-700 transition hover:bg-slate-100">
616
+ <SquarePen className="h-4 w-4" />编辑
617
+ </button>
618
+ <button type="button" onClick={() => removeMcp(server.id, server.name)} className="inline-flex h-10 items-center gap-1 rounded-2xl border border-rose-200 bg-white px-3 text-sm font-medium text-rose-600 transition hover:bg-rose-50">
619
+ <Trash2 className="h-4 w-4" />删除
620
+ </button>
621
+ </div>
622
+ </div>
623
+ </article>
624
+ );
625
+ })}
626
+ </div>
627
+ ) : activeSection === "prompts" ? (
628
+ <div className="mt-6 grid gap-3">
629
+ {prompts.map((prompt) => (
630
+ <article key={prompt.id} className="rounded-[24px] border border-slate-200/80 bg-slate-50 px-5 py-4">
631
+ <div className="flex flex-wrap items-start justify-between gap-3">
632
+ <div className="min-w-0">
633
+ <div className="flex flex-wrap items-center gap-2">
634
+ <div className="text-base font-semibold text-slate-950">{prompt.name}</div>
635
+ <span className={cx("rounded-full px-2.5 py-1 text-[11px] font-medium", prompt.enabled ? styles.soft : "bg-slate-200 text-slate-600")}>{promptScopeLabel(prompt.appType)}</span>
636
+ </div>
637
+ <p className="mt-2 text-sm leading-6 text-slate-500">{prompt.description}</p>
638
+ <div className="mt-3 rounded-2xl bg-white px-4 py-4 text-sm leading-7 text-slate-600 ring-1 ring-slate-200/80">{prompt.content}</div>
639
+ <div className="mt-2 text-xs text-slate-400">更新时间:{timeText(prompt.updatedAt)}</div>
640
+ </div>
641
+ <div className="flex flex-wrap items-center gap-2">
642
+ <button
643
+ type="button"
644
+ onClick={() => workspace.togglePromptMutation.mutate({ promptId: prompt.id })}
645
+ className={cx("inline-flex h-10 items-center rounded-2xl px-4 text-sm font-medium transition", prompt.enabled ? "border border-slate-200 bg-white text-slate-700 hover:bg-slate-100" : "bg-slate-950 text-white hover:opacity-90")}
646
+ >
647
+ {prompt.enabled ? "禁用" : "启用"}
648
+ </button>
649
+ <button type="button" onClick={() => openPromptEditor(prompt)} className="inline-flex h-10 items-center gap-1 rounded-2xl border border-slate-200 bg-white px-3 text-sm font-medium text-slate-700 transition hover:bg-slate-100">
650
+ <SquarePen className="h-4 w-4" />编辑
651
+ </button>
652
+ <button type="button" onClick={() => removePrompt(prompt.id, prompt.name)} className="inline-flex h-10 items-center gap-1 rounded-2xl border border-rose-200 bg-white px-3 text-sm font-medium text-rose-600 transition hover:bg-rose-50">
653
+ <Trash2 className="h-4 w-4" />删除
654
+ </button>
655
+ </div>
656
+ </div>
657
+ </article>
658
+ ))}
659
+ </div>
660
+ ) : (
661
+ <div className="mt-6 grid gap-4 xl:grid-cols-[1.05fr_0.95fr]">
662
+ <div className="space-y-3">
663
+ {skills.map((skill) => (
664
+ <article key={skill.id} className="rounded-[24px] border border-slate-200/80 bg-slate-50 px-5 py-4">
665
+ <div className="flex items-start justify-between gap-3">
666
+ <div>
667
+ <div className="text-base font-semibold text-slate-950">{skill.name}</div>
668
+ <p className="mt-2 text-sm leading-6 text-slate-500">{skill.description}</p>
669
+ <div className="mt-3 text-xs text-slate-400">{skill.directory}</div>
670
+ </div>
671
+ <div className="flex items-center gap-2">
672
+ <span className={cx("rounded-full px-2.5 py-1 text-[11px] font-medium", styles.soft)}>已安装</span>
673
+ <button type="button" onClick={() => openSkillEditor(skill)} className="inline-flex h-9 items-center gap-1 rounded-xl border border-slate-200 bg-white px-2.5 text-xs font-medium text-slate-700 transition hover:bg-slate-100">
674
+ <SquarePen className="h-3.5 w-3.5" />编辑
675
+ </button>
676
+ <button type="button" onClick={() => removeSkill(skill.id, skill.name)} className="inline-flex h-9 items-center gap-1 rounded-xl border border-rose-200 bg-white px-2.5 text-xs font-medium text-rose-600 transition hover:bg-rose-50">
677
+ <Trash2 className="h-3.5 w-3.5" />删除
678
+ </button>
679
+ </div>
680
+ </div>
681
+ </article>
682
+ ))}
683
+ </div>
684
+ <div className="space-y-3">
685
+ {skillRepos.map((repo) => (
686
+ <article key={repo.id} className="rounded-[24px] border border-slate-200/80 bg-slate-50 px-5 py-4">
687
+ <div className="flex items-start justify-between gap-3">
688
+ <div>
689
+ <div className="text-base font-semibold text-slate-950">{repo.owner}/{repo.name}</div>
690
+ <div className="mt-2 text-sm text-slate-500">分支:{repo.branch}</div>
691
+ </div>
692
+ <button
693
+ type="button"
694
+ onClick={() => workspace.toggleSkillRepoMutation.mutate({ repoId: repo.id })}
695
+ className={cx("inline-flex h-10 items-center rounded-2xl px-4 text-sm font-medium transition", repo.enabled ? "border border-slate-200 bg-white text-slate-700 hover:bg-slate-100" : "bg-slate-950 text-white hover:opacity-90")}
696
+ >
697
+ {repo.enabled ? "禁用" : "启用"}
698
+ </button>
699
+ </div>
700
+ </article>
701
+ ))}
702
+ </div>
703
+ </div>
704
+ )}
705
+ </section>
706
+
707
+ <aside className="space-y-4">
708
+ <section className="rounded-[28px] border border-slate-200/80 bg-white p-5 shadow-[0_24px_70px_rgba(15,23,42,0.06)]">
709
+ <div className="text-[12px] uppercase tracking-[0.24em] text-slate-400">总览</div>
710
+ <div className="mt-4 grid grid-cols-2 gap-3">
711
+ <div className="rounded-2xl bg-slate-50 px-4 py-4 ring-1 ring-slate-200/70">
712
+ <div className="text-[11px] uppercase tracking-[0.22em] text-slate-400">{activeSection === "providers" ? "服务商" : activeSection === "mcp" ? "MCP" : activeSection === "prompts" ? "提示词" : "技能"}</div>
713
+ <div className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-slate-950">{activeSection === "providers" ? providers.length : activeSection === "mcp" ? mcpServers.length : activeSection === "prompts" ? prompts.length : skills.length}</div>
714
+ </div>
715
+ <div className="rounded-2xl bg-slate-50 px-4 py-4 ring-1 ring-slate-200/70">
716
+ <div className="text-[11px] uppercase tracking-[0.22em] text-slate-400">{activeSection === "skills" ? "仓库" : "目标文件"}</div>
717
+ <div className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-slate-950">{activeSection === "skills" ? skillRepos.length : platform.targetFiles.length}</div>
718
+ </div>
719
+ <div className="col-span-2 rounded-2xl bg-slate-50 px-4 py-4 ring-1 ring-slate-200/70">
720
+ <div className="text-[11px] uppercase tracking-[0.22em] text-slate-400">{activeSection === "providers" ? "当前路由" : "当前工作区"}</div>
721
+ <div className="mt-2 text-sm font-medium text-slate-950">{activeSection === "providers" ? platform.currentProviderName || "无" : `${platform.label} / ${workspace.activeSectionMeta.label}`}</div>
722
+ <div className="mt-1 text-xs text-slate-400">快照更新时间:{timeText(workspace.snapshot.generatedAt)}</div>
723
+ </div>
724
+ </div>
725
+ </section>
726
+
727
+ <section className="rounded-[28px] border border-slate-200/80 bg-white p-5 shadow-[0_24px_70px_rgba(15,23,42,0.06)]">
728
+ <div className="flex items-center justify-between gap-3">
729
+ <div>
730
+ <div className="text-[12px] uppercase tracking-[0.24em] text-slate-400">链路健康度</div>
731
+ <div className="mt-2 text-[22px] font-semibold tracking-[-0.04em] text-slate-950">延迟检测</div>
732
+ </div>
733
+ <button
734
+ type="button"
735
+ onClick={() => workspace.probeQuery.refetch()}
736
+ className="inline-flex h-10 items-center gap-2 rounded-2xl border border-slate-200 px-4 text-sm font-medium text-slate-700 transition hover:border-slate-300 hover:bg-slate-50"
737
+ >
738
+ <RefreshCw className={cx("h-4 w-4", workspace.probeQuery.isFetching && "animate-spin")} />测试
739
+ </button>
740
+ </div>
741
+ <div className="mt-4 space-y-3">
742
+ {probes.length > 0 ? (
743
+ probes.map((item) => (
744
+ <div key={item.baseUrl} className="rounded-2xl bg-slate-50 px-4 py-4 ring-1 ring-slate-200/70">
745
+ <div className="flex items-center justify-between gap-3">
746
+ <div className="min-w-0">
747
+ <div className="truncate text-sm font-medium text-slate-900">{shortUrl(item.baseUrl)}</div>
748
+ <div className="mt-1 text-xs text-slate-400">{item.ok ? "可连通" : item.error ? `不可用:${item.error}` : "不可用"}</div>
749
+ </div>
750
+ <div className="text-sm font-semibold text-[#1677ff]">{item.latency} ms</div>
751
+ </div>
752
+ </div>
753
+ ))
754
+ ) : (
755
+ <div className="rounded-2xl border border-dashed border-slate-200 px-4 py-6 text-sm leading-6 text-slate-500">执行一次测速后,这里会展示当前工作区的链路延迟结果。</div>
756
+ )}
757
+ </div>
758
+ </section>
759
+
760
+ <section className="rounded-[28px] border border-slate-200/80 bg-white p-5 shadow-[0_24px_70px_rgba(15,23,42,0.06)]">
761
+ <div className="text-[12px] uppercase tracking-[0.24em] text-slate-400">目标文件</div>
762
+ <div className="mt-4 space-y-3">
763
+ {platform.targetFiles.map((target) => (
764
+ <div key={target} className="rounded-2xl bg-slate-50 px-4 py-4 ring-1 ring-slate-200/70">
765
+ <div className="flex items-start justify-between gap-3">
766
+ <div className="flex min-w-0 items-start gap-3">
767
+ <FileStack className="mt-0.5 h-4 w-4 shrink-0 text-slate-400" />
768
+ <div className="min-w-0 break-all text-sm leading-6 text-slate-600">{target}</div>
769
+ </div>
770
+ <button type="button" onClick={() => openTargetPath(target)} className="inline-flex h-9 shrink-0 items-center rounded-xl border border-slate-200 bg-white px-3 text-xs font-medium text-slate-600 transition hover:border-slate-300 hover:text-slate-900">
771
+ 打开
772
+ </button>
773
+ </div>
774
+ </div>
775
+ ))}
776
+ {targetPathMessage ? <div className="text-xs text-slate-500">{targetPathMessage}</div> : null}
777
+ </div>
778
+ </section>
779
+ </aside>
780
+ </main>
781
+ </div>
782
+
783
+ {dialogOpen ? (
784
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/20 px-4 backdrop-blur-[3px]">
785
+ <div className="w-full max-w-[560px] rounded-[30px] bg-white p-7 shadow-[0_44px_120px_rgba(15,23,42,0.18)] ring-1 ring-slate-200/80">
786
+ <div className="flex items-start justify-between gap-4">
787
+ <div>
788
+ <div className={cx("inline-flex rounded-full px-3 py-1 text-xs font-medium", styles.soft)}>{editing ? "编辑服务商" : "新增服务商"}</div>
789
+ <h2 className="mt-3 text-[28px] font-semibold tracking-[-0.05em] text-slate-950">{platform.label} 路由编辑器</h2>
790
+ <p className="mt-2 text-sm leading-6 text-slate-500">这里的改动会写回当前工作区配置,并立即刷新列表。</p>
791
+ </div>
792
+ <button type="button" onClick={closeDialog} className="rounded-full p-2 text-slate-400 transition hover:bg-slate-100 hover:text-slate-700">
793
+ <X className="h-4 w-4" />
794
+ </button>
795
+ </div>
796
+
797
+ <form
798
+ className="mt-6 space-y-4"
799
+ onSubmit={(event) => {
800
+ event.preventDefault();
801
+ submit();
802
+ }}
803
+ >
804
+ <label className="block">
805
+ <div className="mb-2 text-sm font-medium text-slate-600">服务商名称</div>
806
+ <input
807
+ value={workspace.editorForm.name}
808
+ onChange={(event) => workspace.setEditorForm({ ...workspace.editorForm, name: event.target.value })}
809
+ placeholder="default"
810
+ className="h-12 w-full rounded-2xl border border-slate-200 bg-[#fbfbfc] px-4 text-sm text-slate-900 outline-none transition focus:border-[#1677ff] focus:bg-white"
811
+ />
812
+ </label>
813
+ <label className="block">
814
+ <div className="mb-2 text-sm font-medium text-slate-600">基础地址</div>
815
+ <input
816
+ value={workspace.editorForm.baseUrl}
817
+ onChange={(event) => workspace.setEditorForm({ ...workspace.editorForm, baseUrl: event.target.value })}
818
+ placeholder="https://api.openai.com"
819
+ className="h-12 w-full rounded-2xl border border-slate-200 bg-[#fbfbfc] px-4 text-sm text-slate-900 outline-none transition focus:border-[#1677ff] focus:bg-white"
820
+ />
821
+ <div className="mt-3 flex items-center justify-between gap-3">
822
+ <div className="text-xs text-slate-500">{candidateProbeText || "可先测试地址可用性,再保存。"}</div>
823
+ <button
824
+ type="button"
825
+ onClick={probeCandidateBaseUrl}
826
+ disabled={workspace.probeCandidateMutation.isPending}
827
+ className="inline-flex h-9 items-center rounded-xl border border-slate-200 bg-white px-3 text-xs font-medium text-slate-600 transition hover:border-slate-300 hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-60"
828
+ >
829
+ {workspace.probeCandidateMutation.isPending ? "测试中..." : "测试地址"}
830
+ </button>
831
+ </div>
832
+ </label>
833
+ <label className="block">
834
+ <div className="mb-2 text-sm font-medium text-slate-600">API Key</div>
835
+ <input
836
+ type="password"
837
+ value={workspace.editorForm.apiKey}
838
+ onChange={(event) => workspace.setEditorForm({ ...workspace.editorForm, apiKey: event.target.value })}
839
+ placeholder="sk-..."
840
+ className="h-12 w-full rounded-2xl border border-slate-200 bg-[#fbfbfc] px-4 text-sm text-slate-900 outline-none transition focus:border-[#1677ff] focus:bg-white"
841
+ />
842
+ </label>
843
+ <label className="block">
844
+ <div className="mb-2 text-sm font-medium text-slate-600">模型</div>
845
+ <select
846
+ value={workspace.editorForm.model}
847
+ onChange={(event) => workspace.setEditorForm({ ...workspace.editorForm, model: event.target.value })}
848
+ className="h-12 w-full rounded-2xl border border-slate-200 bg-[#fbfbfc] px-4 text-sm text-slate-900 outline-none transition focus:border-[#1677ff] focus:bg-white"
849
+ >
850
+ {models.map((model) => (
851
+ <option key={model} value={model}>
852
+ {model}
853
+ </option>
854
+ ))}
855
+ </select>
856
+ </label>
857
+ <div className="flex items-center justify-end gap-3 pt-3">
858
+ <button type="button" onClick={closeDialog} className="inline-flex h-11 items-center justify-center rounded-2xl border border-slate-200 px-5 text-sm font-medium text-slate-600 transition hover:border-slate-300 hover:text-slate-900">
859
+ 取消
860
+ </button>
861
+ <button
862
+ type="submit"
863
+ disabled={workspace.saveMutation.isPending}
864
+ className="inline-flex h-11 items-center justify-center rounded-2xl bg-[#1677ff] px-5 text-sm font-medium text-white shadow-[0_12px_24px_rgba(22,119,255,0.22)] transition hover:brightness-105 disabled:cursor-not-allowed disabled:opacity-60"
865
+ >
866
+ {workspace.saveMutation.isPending ? "保存中..." : "保存并启用"}
867
+ </button>
868
+ </div>
869
+ </form>
870
+ </div>
871
+ </div>
872
+ ) : null}
873
+
874
+ {deleteConfirm ? (
875
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/20 px-4 backdrop-blur-[3px]">
876
+ <div className="w-full max-w-[520px] rounded-[30px] bg-white p-7 shadow-[0_44px_120px_rgba(15,23,42,0.18)] ring-1 ring-slate-200/80">
877
+ <div className="flex items-start justify-between gap-4">
878
+ <div>
879
+ <div className="inline-flex rounded-full bg-rose-50 px-3 py-1 text-xs font-medium text-rose-700">删除确认</div>
880
+ <h2 className="mt-3 text-[28px] font-semibold tracking-[-0.05em] text-slate-950">
881
+ 删除{deleteConfirm.kind === "mcp" ? "MCP" : deleteConfirm.kind === "prompt" ? "提示词" : "技能"}
882
+ </h2>
883
+ <p className="mt-2 text-sm leading-6 text-slate-500">
884
+ 将删除 <span className="font-medium text-slate-900">{deleteConfirm.name}</span>。此操作不可撤销。
885
+ </p>
886
+ </div>
887
+ <button type="button" onClick={closeDeleteConfirm} className="rounded-full p-2 text-slate-400 transition hover:bg-slate-100 hover:text-slate-700">
888
+ <X className="h-4 w-4" />
889
+ </button>
890
+ </div>
891
+
892
+ <div className="mt-6 flex items-center justify-end gap-3">
893
+ <button
894
+ type="button"
895
+ onClick={closeDeleteConfirm}
896
+ disabled={deleteSubmitPending}
897
+ className="inline-flex h-11 items-center justify-center rounded-2xl border border-slate-200 px-5 text-sm font-medium text-slate-600 transition hover:border-slate-300 hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-60"
898
+ >
899
+ 取消
900
+ </button>
901
+ <button
902
+ type="button"
903
+ onClick={confirmDelete}
904
+ disabled={deleteSubmitPending}
905
+ className="inline-flex h-11 items-center justify-center rounded-2xl bg-rose-600 px-5 text-sm font-medium text-white shadow-[0_12px_24px_rgba(225,29,72,0.24)] transition hover:bg-rose-500 disabled:cursor-not-allowed disabled:opacity-60"
906
+ >
907
+ {deleteSubmitPending ? "删除中..." : "确认删除"}
908
+ </button>
909
+ </div>
910
+ </div>
911
+ </div>
912
+ ) : null}
913
+
914
+ {resourceEditor ? (
915
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/20 px-4 backdrop-blur-[3px]">
916
+ <div className="w-full max-w-[620px] rounded-[30px] bg-white p-7 shadow-[0_44px_120px_rgba(15,23,42,0.18)] ring-1 ring-slate-200/80">
917
+ <div className="flex items-start justify-between gap-4">
918
+ <div>
919
+ <div className={cx("inline-flex rounded-full px-3 py-1 text-xs font-medium", styles.soft)}>
920
+ {resourceEditor.kind === "mcp" ? "MCP 编辑" : resourceEditor.kind === "prompt" ? "提示词编辑" : "技能编辑"}
921
+ </div>
922
+ <h2 className="mt-3 text-[28px] font-semibold tracking-[-0.05em] text-slate-950">
923
+ {resourceEditor.kind === "mcp" ? "MCP 配置" : resourceEditor.kind === "prompt" ? "提示词配置" : "技能配置"}
924
+ </h2>
925
+ <p className="mt-2 text-sm leading-6 text-slate-500">修改后会直接写入当前工作区配置,并刷新当前列表。</p>
926
+ </div>
927
+ <button type="button" onClick={closeResourceEditor} className="rounded-full p-2 text-slate-400 transition hover:bg-slate-100 hover:text-slate-700">
928
+ <X className="h-4 w-4" />
929
+ </button>
930
+ </div>
931
+
932
+ <form
933
+ className="mt-6 space-y-4"
934
+ onSubmit={(event) => {
935
+ event.preventDefault();
936
+ submitResourceEditor();
937
+ }}
938
+ >
939
+ {resourceEditor.kind === "mcp" ? (
940
+ <>
941
+ <label className="block">
942
+ <div className="mb-2 text-sm font-medium text-slate-600">MCP 名称</div>
943
+ <input
944
+ value={resourceEditor.name}
945
+ onChange={(event) =>
946
+ setResourceEditor((current) =>
947
+ current && current.kind === "mcp" ? { ...current, name: event.target.value, error: null } : current
948
+ )
949
+ }
950
+ placeholder="例如:filesystem"
951
+ className="h-12 w-full rounded-2xl border border-slate-200 bg-[#fbfbfc] px-4 text-sm text-slate-900 outline-none transition focus:border-[#1677ff] focus:bg-white"
952
+ />
953
+ </label>
954
+ <label className="block">
955
+ <div className="mb-2 text-sm font-medium text-slate-600">描述</div>
956
+ <textarea
957
+ value={resourceEditor.description}
958
+ onChange={(event) =>
959
+ setResourceEditor((current) =>
960
+ current && current.kind === "mcp" ? { ...current, description: event.target.value, error: null } : current
961
+ )
962
+ }
963
+ rows={3}
964
+ className="w-full rounded-2xl border border-slate-200 bg-[#fbfbfc] px-4 py-3 text-sm text-slate-900 outline-none transition focus:border-[#1677ff] focus:bg-white"
965
+ />
966
+ </label>
967
+ <label className="block">
968
+ <div className="mb-2 text-sm font-medium text-slate-600">标签(逗号分隔)</div>
969
+ <input
970
+ value={resourceEditor.tags}
971
+ onChange={(event) =>
972
+ setResourceEditor((current) =>
973
+ current && current.kind === "mcp" ? { ...current, tags: event.target.value, error: null } : current
974
+ )
975
+ }
976
+ placeholder="files, docs"
977
+ className="h-12 w-full rounded-2xl border border-slate-200 bg-[#fbfbfc] px-4 text-sm text-slate-900 outline-none transition focus:border-[#1677ff] focus:bg-white"
978
+ />
979
+ </label>
980
+ <label className="block">
981
+ <div className="mb-2 text-sm font-medium text-slate-600">主页链接(可选)</div>
982
+ <input
983
+ value={resourceEditor.homepage}
984
+ onChange={(event) =>
985
+ setResourceEditor((current) =>
986
+ current && current.kind === "mcp" ? { ...current, homepage: event.target.value, error: null } : current
987
+ )
988
+ }
989
+ placeholder="https://example.com"
990
+ className="h-12 w-full rounded-2xl border border-slate-200 bg-[#fbfbfc] px-4 text-sm text-slate-900 outline-none transition focus:border-[#1677ff] focus:bg-white"
991
+ />
992
+ </label>
993
+ <div>
994
+ <div className="mb-2 text-sm font-medium text-slate-600">启用平台</div>
995
+ <div className="flex flex-wrap gap-2">
996
+ {allPlatforms.map((item) => {
997
+ const checked = resourceEditor.enabledPlatforms.includes(item);
998
+ return (
999
+ <button
1000
+ key={item}
1001
+ type="button"
1002
+ onClick={() => toggleResourcePlatform(item)}
1003
+ className={cx(
1004
+ "inline-flex h-10 items-center rounded-2xl px-4 text-sm font-medium transition",
1005
+ checked ? "bg-slate-950 text-white" : "border border-slate-200 bg-white text-slate-700 hover:bg-slate-100"
1006
+ )}
1007
+ >
1008
+ {platformLabel(item)}
1009
+ </button>
1010
+ );
1011
+ })}
1012
+ </div>
1013
+ </div>
1014
+ </>
1015
+ ) : resourceEditor.kind === "prompt" ? (
1016
+ <>
1017
+ <label className="block">
1018
+ <div className="mb-2 text-sm font-medium text-slate-600">提示词名称</div>
1019
+ <input
1020
+ value={resourceEditor.name}
1021
+ onChange={(event) =>
1022
+ setResourceEditor((current) =>
1023
+ current && current.kind === "prompt" ? { ...current, name: event.target.value, error: null } : current
1024
+ )
1025
+ }
1026
+ placeholder="例如:默认编码"
1027
+ className="h-12 w-full rounded-2xl border border-slate-200 bg-[#fbfbfc] px-4 text-sm text-slate-900 outline-none transition focus:border-[#1677ff] focus:bg-white"
1028
+ />
1029
+ </label>
1030
+ <label className="block">
1031
+ <div className="mb-2 text-sm font-medium text-slate-600">描述</div>
1032
+ <textarea
1033
+ value={resourceEditor.description}
1034
+ onChange={(event) =>
1035
+ setResourceEditor((current) =>
1036
+ current && current.kind === "prompt" ? { ...current, description: event.target.value, error: null } : current
1037
+ )
1038
+ }
1039
+ rows={3}
1040
+ className="w-full rounded-2xl border border-slate-200 bg-[#fbfbfc] px-4 py-3 text-sm text-slate-900 outline-none transition focus:border-[#1677ff] focus:bg-white"
1041
+ />
1042
+ </label>
1043
+ <label className="block">
1044
+ <div className="mb-2 text-sm font-medium text-slate-600">提示词内容</div>
1045
+ <textarea
1046
+ value={resourceEditor.content}
1047
+ onChange={(event) =>
1048
+ setResourceEditor((current) =>
1049
+ current && current.kind === "prompt" ? { ...current, content: event.target.value, error: null } : current
1050
+ )
1051
+ }
1052
+ rows={6}
1053
+ className="w-full rounded-2xl border border-slate-200 bg-[#fbfbfc] px-4 py-3 text-sm leading-7 text-slate-900 outline-none transition focus:border-[#1677ff] focus:bg-white"
1054
+ />
1055
+ </label>
1056
+ <label className="block">
1057
+ <div className="mb-2 text-sm font-medium text-slate-600">作用范围</div>
1058
+ <select
1059
+ value={resourceEditor.appType}
1060
+ onChange={(event) =>
1061
+ setResourceEditor((current) => {
1062
+ if (!current || current.kind !== "prompt") return current;
1063
+ const value = event.target.value as "global" | PlatformId;
1064
+ return { ...current, appType: value, error: null };
1065
+ })
1066
+ }
1067
+ className="h-12 w-full rounded-2xl border border-slate-200 bg-[#fbfbfc] px-4 text-sm text-slate-900 outline-none transition focus:border-[#1677ff] focus:bg-white"
1068
+ >
1069
+ <option value="global">全局</option>
1070
+ {allPlatforms.map((item) => (
1071
+ <option key={item} value={item}>
1072
+ {platformLabel(item)}
1073
+ </option>
1074
+ ))}
1075
+ </select>
1076
+ </label>
1077
+ <label className="flex items-center gap-3 rounded-2xl border border-slate-200 bg-[#fbfbfc] px-4 py-3">
1078
+ <input
1079
+ type="checkbox"
1080
+ checked={resourceEditor.enabled}
1081
+ onChange={(event) =>
1082
+ setResourceEditor((current) =>
1083
+ current && current.kind === "prompt" ? { ...current, enabled: event.target.checked, error: null } : current
1084
+ )
1085
+ }
1086
+ className="h-4 w-4 rounded border-slate-300 text-[#1677ff] focus:ring-[#1677ff]"
1087
+ />
1088
+ <span className="text-sm text-slate-700">保存后立即启用</span>
1089
+ </label>
1090
+ </>
1091
+ ) : (
1092
+ <>
1093
+ <label className="block">
1094
+ <div className="mb-2 text-sm font-medium text-slate-600">技能名称</div>
1095
+ <input
1096
+ value={resourceEditor.name}
1097
+ onChange={(event) =>
1098
+ setResourceEditor((current) =>
1099
+ current && current.kind === "skill" ? { ...current, name: event.target.value, error: null } : current
1100
+ )
1101
+ }
1102
+ placeholder="例如:openai-docs"
1103
+ className="h-12 w-full rounded-2xl border border-slate-200 bg-[#fbfbfc] px-4 text-sm text-slate-900 outline-none transition focus:border-[#1677ff] focus:bg-white"
1104
+ />
1105
+ </label>
1106
+ <label className="block">
1107
+ <div className="mb-2 text-sm font-medium text-slate-600">描述</div>
1108
+ <textarea
1109
+ value={resourceEditor.description}
1110
+ onChange={(event) =>
1111
+ setResourceEditor((current) =>
1112
+ current && current.kind === "skill" ? { ...current, description: event.target.value, error: null } : current
1113
+ )
1114
+ }
1115
+ rows={3}
1116
+ className="w-full rounded-2xl border border-slate-200 bg-[#fbfbfc] px-4 py-3 text-sm text-slate-900 outline-none transition focus:border-[#1677ff] focus:bg-white"
1117
+ />
1118
+ </label>
1119
+ <label className="block">
1120
+ <div className="mb-2 text-sm font-medium text-slate-600">技能目录</div>
1121
+ <input
1122
+ value={resourceEditor.directory}
1123
+ onChange={(event) =>
1124
+ setResourceEditor((current) =>
1125
+ current && current.kind === "skill" ? { ...current, directory: event.target.value, error: null } : current
1126
+ )
1127
+ }
1128
+ placeholder="~/.codex/skills/..."
1129
+ className="h-12 w-full rounded-2xl border border-slate-200 bg-[#fbfbfc] px-4 text-sm text-slate-900 outline-none transition focus:border-[#1677ff] focus:bg-white"
1130
+ />
1131
+ </label>
1132
+ <div>
1133
+ <div className="mb-2 text-sm font-medium text-slate-600">启用平台</div>
1134
+ <div className="flex flex-wrap gap-2">
1135
+ {allPlatforms.map((item) => {
1136
+ const checked = resourceEditor.enabledPlatforms.includes(item);
1137
+ return (
1138
+ <button
1139
+ key={item}
1140
+ type="button"
1141
+ onClick={() => toggleResourcePlatform(item)}
1142
+ className={cx(
1143
+ "inline-flex h-10 items-center rounded-2xl px-4 text-sm font-medium transition",
1144
+ checked ? "bg-slate-950 text-white" : "border border-slate-200 bg-white text-slate-700 hover:bg-slate-100"
1145
+ )}
1146
+ >
1147
+ {platformLabel(item)}
1148
+ </button>
1149
+ );
1150
+ })}
1151
+ </div>
1152
+ </div>
1153
+ </>
1154
+ )}
1155
+
1156
+ {resourceEditor.error ? <div className="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">{resourceEditor.error}</div> : null}
1157
+
1158
+ <div className="flex items-center justify-end gap-3 pt-3">
1159
+ <button
1160
+ type="button"
1161
+ onClick={closeResourceEditor}
1162
+ className="inline-flex h-11 items-center justify-center rounded-2xl border border-slate-200 px-5 text-sm font-medium text-slate-600 transition hover:border-slate-300 hover:text-slate-900"
1163
+ >
1164
+ 取消
1165
+ </button>
1166
+ <button
1167
+ type="submit"
1168
+ disabled={resourceSubmitPending}
1169
+ className="inline-flex h-11 items-center justify-center rounded-2xl bg-[#1677ff] px-5 text-sm font-medium text-white shadow-[0_12px_24px_rgba(22,119,255,0.22)] transition hover:brightness-105 disabled:cursor-not-allowed disabled:opacity-60"
1170
+ >
1171
+ {resourceSubmitPending
1172
+ ? "保存中..."
1173
+ : resourceEditor.kind === "mcp"
1174
+ ? "保存 MCP"
1175
+ : resourceEditor.kind === "prompt"
1176
+ ? "保存提示词"
1177
+ : "保存技能"}
1178
+ </button>
1179
+ </div>
1180
+ </form>
1181
+ </div>
1182
+ </div>
1183
+ ) : null}
1184
+ </div>
1185
+ );
1186
+ }