mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-18 03:34:20 +00:00
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:
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user