Feat/web UI fixes v2 (#2152)

* fix(web): 修复复制按钮和插件安装对话框UI问题

- 新增 clipboard.ts 工具函数支持 Clipboard API 降级
- 修复添加机器人页面 Webhook URL 复制按钮未生效
- 修复 API 集成对话框 API Key 复制按钮未生效
- 修复 Bot 会话监控用户 ID 复制按钮未生效
- 修复插件安装进度状态框横向溢出和小屏缩放问题

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(web): improve clipboard copy with Selection API fallback

Replace navigator.clipboard.writeText with Selection API + execCommand
for reliable copying in non-secure contexts. Remove duplicate dialog.
Fix scanProviderModels type signature to accept rerank model type.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(web): revert package-lock.json to match upstream

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(web): fix prettier formatting errors

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(web): unify all clipboard copy to use copyToClipboard utility

- Fix embed code copy button not working in non-secure contexts
- Add copy animation (check icon) to embed code button via EmbedCodeField component
- Replace raw navigator.clipboard calls in plugins/page.tsx and BotLogCard.tsx
- Remove duplicated inline fallback implementations

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Junyan Qin <rockchinq@gmail.com>
This commit is contained in:
sheetung
2026-04-26 01:57:54 +08:00
committed by GitHub
parent 69b87a0d8a
commit c1168745b7
8 changed files with 171 additions and 155 deletions
@@ -1,5 +1,5 @@
import * as React from 'react';
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { Copy, Check, Trash2, Plus } from 'lucide-react';
@@ -86,6 +86,9 @@ export default function ApiIntegrationDialog({
const [newWebhookDescription, setNewWebhookDescription] = useState('');
const [newWebhookEnabled, setNewWebhookEnabled] = useState(true);
const [deleteWebhookId, setDeleteWebhookId] = useState<number | null>(null);
const copiedTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(
undefined,
);
const [copiedKey, setCopiedKey] = useState<string | null>(null);
// Sync URL with dialog state
@@ -182,10 +185,29 @@ export default function ApiIntegrationDialog({
}
};
const copyToClipboard = (text: string) => {
const el = document.createElement('span');
el.textContent = text;
el.style.cssText =
'position:fixed;opacity:0;pointer-events:none;white-space:pre;';
document.body.appendChild(el);
const range = document.createRange();
range.selectNodeContents(el);
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(range);
document.execCommand('copy');
sel?.removeAllRanges();
document.body.removeChild(el);
};
const handleCopyKey = (key: string) => {
navigator.clipboard.writeText(key);
try {
copyToClipboard(key);
} catch {}
clearTimeout(copiedTimerRef.current);
setCopiedKey(key);
setTimeout(() => setCopiedKey(null), 2000);
copiedTimerRef.current = setTimeout(() => setCopiedKey(null), 2000);
};
const maskApiKey = (key: string) => {
@@ -330,21 +352,21 @@ export default function ApiIntegrationDialog({
</TableRow>
</TableHeader>
<TableBody>
{apiKeys.map((key) => (
<TableRow key={key.id}>
{apiKeys.map((item) => (
<TableRow key={item.id}>
<TableCell>
<div>
<div className="font-medium">{key.name}</div>
{key.description && (
<div className="font-medium">{item.name}</div>
{item.description && (
<div className="text-sm text-muted-foreground">
{key.description}
{item.description}
</div>
)}
</div>
</TableCell>
<TableCell>
<code className="text-sm bg-muted px-2 py-1 rounded">
{maskApiKey(key.key)}
{maskApiKey(item.key)}
</code>
</TableCell>
<TableCell>
@@ -352,10 +374,11 @@ export default function ApiIntegrationDialog({
<Button
variant="ghost"
size="sm"
onClick={() => handleCopyKey(key.key)}
type="button"
onClick={() => handleCopyKey(item.key)}
title={t('common.copyApiKey')}
>
{copiedKey === key.key ? (
{copiedKey === item.key ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4" />
@@ -364,7 +387,7 @@ export default function ApiIntegrationDialog({
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteKeyId(key.id)}
onClick={() => setDeleteKeyId(item.id)}
title={t('common.delete')}
>
<Trash2 className="h-4 w-4" />
@@ -630,44 +653,6 @@ export default function ApiIntegrationDialog({
</DialogContent>
</Dialog>
{/* Delete API Key Confirmation Dialog */}
<Dialog open={!!createdKey} onOpenChange={() => setCreatedKey(null)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t('common.apiKeyCreated')}</DialogTitle>
<DialogDescription>
{t('common.apiKeyCreatedMessage')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<label className="text-sm font-medium">
{t('common.apiKeyValue')}
</label>
<div className="flex gap-2 mt-1">
<Input value={createdKey?.key || ''} readOnly />
<Button
onClick={() => createdKey && handleCopyKey(createdKey.key)}
variant="outline"
size="icon"
>
{copiedKey === createdKey?.key ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
</div>
</div>
<DialogFooter>
<Button onClick={() => setCreatedKey(null)}>
{t('common.close')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<AlertDialog open={!!deleteKeyId}>
<AlertDialogPortal>
@@ -18,6 +18,7 @@ import { cn } from '@/lib/utils';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Copy, Check, Globe } from 'lucide-react';
import { copyToClipboard } from '@/app/utils/clipboard';
import { systemInfo } from '@/app/infra/http';
/**
@@ -44,6 +45,54 @@ function resolveShowIfValue(
return externalDependentValues?.[field];
}
/**
* Display-only component for embed code fields with copy animation.
*/
function EmbedCodeField({
label,
description,
snippet,
}: {
label: string;
description?: string;
snippet: string;
}) {
const [copied, setCopied] = useState(false);
const handleCopy = () => {
copyToClipboard(snippet).catch(() => {});
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="space-y-2">
<label className="text-sm font-medium leading-none">{label}</label>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
<div className="flex items-center gap-2">
<pre className="flex-1 overflow-x-auto rounded-md bg-muted p-3 text-sm font-mono select-all">
<code>{snippet}</code>
</pre>
<Button
type="button"
variant="outline"
size="icon"
className="shrink-0"
onClick={handleCopy}
>
{copied ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="size-4" />
)}
</Button>
</div>
</div>
);
}
/**
* Display-only component for webhook URL fields.
* Rendered outside of react-hook-form binding since the value is
@@ -65,15 +114,9 @@ function WebhookUrlField({
const { t } = useTranslation();
const handleCopy = (text: string, setter: (v: boolean) => void) => {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard
.writeText(text)
.then(() => {
setter(true);
setTimeout(() => setter(false), 2000);
})
.catch(() => {});
}
copyToClipboard(text).catch(() => {});
setter(true);
setTimeout(() => setter(false), 2000);
};
return (
@@ -467,32 +510,16 @@ export default function DynamicFormComponent({
const embedSnippet = `<script data-title="${safeTitle}" src="${baseUrl}/api/v1/embed/${botUuid}/widget.js"><\/script>`;
return (
<div key={config.id} className="space-y-2">
<label className="text-sm font-medium leading-none">
{extractI18nObject(config.label)}
</label>
{config.description && (
<p className="text-sm text-muted-foreground">
{extractI18nObject(config.description)}
</p>
)}
<div className="flex items-center gap-2">
<pre className="flex-1 overflow-x-auto rounded-md bg-muted p-3 text-sm font-mono select-all">
<code>{embedSnippet}</code>
</pre>
<Button
type="button"
variant="outline"
size="icon"
className="shrink-0"
onClick={() => {
navigator.clipboard.writeText(embedSnippet);
}}
>
<Copy className="size-4" />
</Button>
</div>
</div>
<EmbedCodeField
key={config.id}
label={extractI18nObject(config.label)}
description={
config.description
? extractI18nObject(config.description)
: undefined
}
snippet={embedSnippet}
/>
);
}