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

View File

@@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next';
import { Check, ChevronDown, ChevronRight, Copy } from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import { copyToClipboard } from '@/app/utils/clipboard';
const LEVEL_STYLES: Record<string, string> = {
error: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
@@ -31,36 +32,19 @@ export function BotLogCard({
function copySessionId() {
const text = botLog.message_session_id;
if (navigator.clipboard?.writeText) {
navigator.clipboard
.writeText(text)
.then(() => {
copyToClipboard(text)
.then((ok) => {
if (ok) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
toast.success(t('common.copySuccess'));
})
.catch(() => fallbackCopy(text));
} else {
fallbackCopy(text);
}
}
function fallbackCopy(text: string) {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;left:-9999px;top:-9999px';
document.body.appendChild(ta);
ta.focus();
ta.select();
try {
document.execCommand('copy');
setCopied(true);
setTimeout(() => setCopied(false), 2000);
toast.success(t('common.copySuccess'));
} catch {
toast.error(t('common.copyFailed'));
}
document.body.removeChild(ta);
} else {
toast.error(t('common.copyFailed'));
}
})
.catch(() => {
toast.error(t('common.copyFailed'));
});
}
function formatTime(timestamp: number) {

View File

@@ -19,6 +19,7 @@ import {
ThumbsUp,
ThumbsDown,
} from 'lucide-react';
import { copyToClipboard } from '@/app/utils/clipboard';
import {
MessageChainComponent,
Plain,
@@ -108,10 +109,9 @@ const BotSessionMonitor = forwardRef<
};
const copyUserId = (userId: string) => {
navigator.clipboard.writeText(userId).then(() => {
setCopiedUserId(true);
setTimeout(() => setCopiedUserId(false), 2000);
});
copyToClipboard(userId).catch(() => {});
setCopiedUserId(true);
setTimeout(() => setCopiedUserId(false), 2000);
};
const loadSessions = useCallback(async () => {

View File

@@ -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>

View File

@@ -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}
/>
);
}

View File

@@ -85,7 +85,7 @@ function StageRow({
return (
<div
className={cn(
'flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all duration-300',
'flex items-center gap-2 sm:gap-3 px-2 sm:px-3 py-2 sm:py-2.5 rounded-lg transition-all duration-300',
isActive &&
!isError &&
'bg-blue-50 dark:bg-blue-950/30 border border-blue-200 dark:border-blue-800',
@@ -154,7 +154,7 @@ function StageRow({
{detail && (
<div
className={cn(
'text-xs mt-0.5',
'text-xs mt-0.5 break-words',
isCompleted
? 'text-green-600/70 dark:text-green-400/70'
: 'text-blue-600/70 dark:text-blue-400/70',
@@ -256,7 +256,7 @@ function TaskProgressContent({ task }: { task: PluginInstallTask }) {
<>
<span>{parts.join(' · ')}</span>
<br />
<span className="opacity-70">{currentDep}</span>
<span className="opacity-70 break-words">{currentDep}</span>
</>
);
}
@@ -277,10 +277,10 @@ function TaskProgressContent({ task }: { task: PluginInstallTask }) {
<div className="space-y-4">
{/* Overall progress bar — always blue */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center justify-between gap-2">
<span
className={cn(
'text-sm font-medium',
'text-sm font-medium shrink-0',
isDone
? 'text-green-700 dark:text-green-300'
: 'text-blue-700 dark:text-blue-300',
@@ -360,8 +360,8 @@ function TaskProgressContent({ task }: { task: PluginInstallTask }) {
{/* Done banner */}
{isDone && (
<div className="flex items-center gap-2 px-3 py-2.5 rounded-lg bg-green-50 dark:bg-green-950/30 border border-green-200 dark:border-green-900">
<CheckCircle2 className="w-5 h-5 text-green-600 dark:text-green-400" />
<span className="text-sm text-green-700 dark:text-green-300 font-medium">
<CheckCircle2 className="w-5 h-5 shrink-0 text-green-600 dark:text-green-400" />
<span className="text-sm text-green-700 dark:text-green-300 font-medium break-words">
{t('plugins.installProgress.installComplete')}
</span>
</div>
@@ -406,13 +406,13 @@ export default function PluginInstallProgressDialog() {
return (
<Dialog open={open} onOpenChange={(o) => !o && handleClose()}>
<DialogContent
className="w-[460px] max-h-[80vh] p-6 bg-white dark:bg-[#1a1a1e] overflow-y-auto"
className="sm:max-w-lg w-[90vw] max-h-[80vh] p-4 sm:p-6 bg-white dark:bg-[#1a1a1e] overflow-y-auto overflow-x-hidden"
hideCloseButton
>
<DialogHeader>
<DialogTitle className="flex items-center gap-3">
<Download className="size-5" />
<span className="truncate">
<DialogTitle className="flex items-start gap-3">
<Download className="size-5 shrink-0 mt-0.5" />
<span className="break-words">
{selectedTask
? t('plugins.installProgress.title', {
name: selectedTask.pluginName,

View File

@@ -18,6 +18,7 @@ import {
Check,
Bug,
} from 'lucide-react';
import { copyToClipboard } from '@/app/utils/clipboard';
import {
DropdownMenu,
DropdownMenuContent,
@@ -466,33 +467,13 @@ function PluginListView() {
};
const handleCopyDebugInfo = (text: string, type: 'url' | 'key') => {
try {
navigator.clipboard.writeText(text);
if (type === 'url') {
setCopiedDebugUrl(true);
setTimeout(() => setCopiedDebugUrl(false), 2000);
} else {
setCopiedDebugKey(true);
setTimeout(() => setCopiedDebugKey(false), 2000);
}
} catch {
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
textArea.style.top = '-999999px';
document.body.appendChild(textArea);
textArea.select();
textArea.setSelectionRange(0, 99999);
const success = document.execCommand('copy');
document.body.removeChild(textArea);
if (success) {
setCopiedDebugUrl(true);
setTimeout(() => setCopiedDebugUrl(false), 2000);
} else {
setCopiedDebugKey(true);
setTimeout(() => setCopiedDebugKey(false), 2000);
}
copyToClipboard(text).catch(() => {});
if (type === 'url') {
setCopiedDebugUrl(true);
setTimeout(() => setCopiedDebugUrl(false), 2000);
} else {
setCopiedDebugKey(true);
setTimeout(() => setCopiedDebugKey(false), 2000);
}
};

View File

@@ -112,7 +112,7 @@ export class BackendClient extends BaseHttpClient {
public scanProviderModels(
uuid: string,
modelType?: 'llm' | 'embedding',
modelType?: 'llm' | 'embedding' | 'rerank',
): Promise<ApiRespScannedProviderModels> {
const params = modelType ? { type: modelType } : {};
return this.get(`/api/v1/provider/providers/${uuid}/scan-models`, params);

View File

@@ -0,0 +1,39 @@
/**
* Copy text to clipboard with fallback support
* Tries to use modern Clipboard API first, falls back to execCommand if not available
*
* @param text - The text to copy to clipboard
* @returns Promise<boolean> - true if successful, false otherwise
*/
export async function copyToClipboard(text: string): Promise<boolean> {
// Try modern Clipboard API first
if (navigator.clipboard && navigator.clipboard.writeText) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch (err) {
console.error('[Clipboard] Modern API failed, trying fallback:', err);
// Fall through to legacy method
}
}
// Fallback to legacy execCommand method
try {
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
textArea.style.top = '-999999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
const successful = document.execCommand('copy');
document.body.removeChild(textArea);
return successful;
} catch (err) {
console.error('[Clipboard] Fallback method failed:', err);
return false;
}
}