细节优化
This commit is contained in:
@@ -1,37 +1,60 @@
|
||||
/**
|
||||
* 兼容所有浏览器的复制到剪贴板函数
|
||||
* 解决 iOS Safari 不支持 navigator.clipboard.writeText() 的问题
|
||||
*
|
||||
* 兼容矩阵:
|
||||
* - Chrome 66+ / Edge 79+ / Firefox 63+:Clipboard API(方法1)
|
||||
* - Safari 13.1+ / iOS Safari:Clipboard API 或 execCommand(方法2,已做 iOS 特殊处理)
|
||||
* - 老版本浏览器(Chrome <66 / Firefox <63 / Safari <13.1 / IE):execCommand(方法2/3)
|
||||
*/
|
||||
export async function copyToClipboard(text) {
|
||||
// 方法1: 现代 Clipboard API(Chrome/Firefox/Edge 桌面端)
|
||||
// 方法1: 现代 Clipboard API(需 HTTPS 或 localhost)
|
||||
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
}
|
||||
catch {
|
||||
// Safari 可能抛出 NotAllowedError,继续尝试 fallback
|
||||
/* 权限被拒或非安全上下文,继续降级 */
|
||||
}
|
||||
}
|
||||
// 方法2: 传统 execCommand(iOS Safari 兼容方案)
|
||||
// 方法2: 隐藏 textarea + execCommand(兼容绝大多数老浏览器,含 iOS Safari)
|
||||
try {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
// 防止页面滚动
|
||||
textarea.setAttribute('readonly', '');
|
||||
// 不可见但可选中;不设 display:none(Safari 会取消选中)
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.left = '-9999px';
|
||||
textarea.style.top = '-9999px';
|
||||
textarea.style.opacity = '0';
|
||||
document.body.appendChild(textarea);
|
||||
// iOS Safari 需要设置 selection range
|
||||
// iOS Safari 必须手动设置选区
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
textarea.setSelectionRange(0, textarea.value.length);
|
||||
const success = document.execCommand('copy');
|
||||
let success = false;
|
||||
try {
|
||||
success = document.execCommand('copy');
|
||||
}
|
||||
catch {
|
||||
success = false;
|
||||
}
|
||||
document.body.removeChild(textarea);
|
||||
return success;
|
||||
if (success)
|
||||
return true;
|
||||
}
|
||||
catch {
|
||||
return false;
|
||||
/* 继续降级 */
|
||||
}
|
||||
// 方法3: IE 专有 API(极老浏览器兜底)
|
||||
const ieClipboard = window.clipboardData;
|
||||
if (ieClipboard) {
|
||||
try {
|
||||
return ieClipboard.setData('Text', text);
|
||||
}
|
||||
catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,24 +1,29 @@
|
||||
/**
|
||||
* 兼容所有浏览器的复制到剪贴板函数
|
||||
* 解决 iOS Safari 不支持 navigator.clipboard.writeText() 的问题
|
||||
*
|
||||
* 兼容矩阵:
|
||||
* - Chrome 66+ / Edge 79+ / Firefox 63+:Clipboard API(方法1)
|
||||
* - Safari 13.1+ / iOS Safari:Clipboard API 或 execCommand(方法2,已做 iOS 特殊处理)
|
||||
* - 老版本浏览器(Chrome <66 / Firefox <63 / Safari <13.1 / IE):execCommand(方法2/3)
|
||||
*/
|
||||
export async function copyToClipboard(text: string): Promise<boolean> {
|
||||
// 方法1: 现代 Clipboard API(Chrome/Firefox/Edge 桌面端)
|
||||
// 方法1: 现代 Clipboard API(需 HTTPS 或 localhost)
|
||||
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
return true
|
||||
} catch {
|
||||
// Safari 可能抛出 NotAllowedError,继续尝试 fallback
|
||||
/* 权限被拒或非安全上下文,继续降级 */
|
||||
}
|
||||
}
|
||||
|
||||
// 方法2: 传统 execCommand(iOS Safari 兼容方案)
|
||||
// 方法2: 隐藏 textarea + execCommand(兼容绝大多数老浏览器,含 iOS Safari)
|
||||
try {
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = text
|
||||
textarea.setAttribute('readonly', '')
|
||||
|
||||
// 防止页面滚动
|
||||
// 不可见但可选中;不设 display:none(Safari 会取消选中)
|
||||
textarea.style.position = 'fixed'
|
||||
textarea.style.left = '-9999px'
|
||||
textarea.style.top = '-9999px'
|
||||
@@ -26,15 +31,32 @@ export async function copyToClipboard(text: string): Promise<boolean> {
|
||||
|
||||
document.body.appendChild(textarea)
|
||||
|
||||
// iOS Safari 需要设置 selection range
|
||||
// iOS Safari 必须手动设置选区
|
||||
textarea.focus()
|
||||
textarea.select()
|
||||
textarea.setSelectionRange(0, textarea.value.length)
|
||||
|
||||
const success = document.execCommand('copy')
|
||||
let success = false
|
||||
try {
|
||||
success = document.execCommand('copy')
|
||||
} catch {
|
||||
success = false
|
||||
}
|
||||
document.body.removeChild(textarea)
|
||||
return success
|
||||
if (success) return true
|
||||
} catch {
|
||||
return false
|
||||
/* 继续降级 */
|
||||
}
|
||||
}
|
||||
|
||||
// 方法3: IE 专有 API(极老浏览器兜底)
|
||||
const ieClipboard = (window as unknown as { clipboardData?: { setData: (t: string, v: string) => boolean } }).clipboardData
|
||||
if (ieClipboard) {
|
||||
try {
|
||||
return ieClipboard.setData('Text', text)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -82,10 +82,55 @@ async function loadDocs() {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
// 链接有效期
|
||||
const expiryOptions = [
|
||||
{ label: '10 分钟', value: 10 },
|
||||
{ label: '30 分钟', value: 30 },
|
||||
{ label: '1 小时', value: 60 },
|
||||
{ label: '3 小时', value: 180 },
|
||||
{ label: '24 小时', value: 1440 },
|
||||
{ label: '长期有效', value: 0 },
|
||||
]
|
||||
const linkExpiry = ref<{ expires_at: string | null; is_expired: boolean }>({
|
||||
expires_at: null,
|
||||
is_expired: false,
|
||||
})
|
||||
const expiryText = computed(() => {
|
||||
if (linkExpiry.value.is_expired) return '⛔ 已失效'
|
||||
if (!linkExpiry.value.expires_at) return '♾️ 长期有效'
|
||||
return `⏱️ 有效期至 ${linkExpiry.value.expires_at}`
|
||||
})
|
||||
|
||||
async function loadLink() {
|
||||
try {
|
||||
const { data } = await apiClient.get(`/knowledge-bases/${kbId}/link`)
|
||||
aiUrl.value = `${window.location.origin}/k/${data.token}`
|
||||
linkExpiry.value = {
|
||||
expires_at: data.expires_at,
|
||||
is_expired: data.is_expired,
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// 设置链接有效期(0 = 长期有效,需二次确认)
|
||||
async function handleExpiryChange(val: number) {
|
||||
if (val === 0) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'长期有效的链接一旦泄露,任何持有该链接的人都可永久访问此知识库,存在泄露风险。确定设为长期有效?',
|
||||
'⚠️ 泄露风险提示',
|
||||
{ type: 'warning', confirmButtonText: '仍要设为长期有效', cancelButtonText: '取消' },
|
||||
)
|
||||
} catch {
|
||||
return // 用户取消
|
||||
}
|
||||
}
|
||||
try {
|
||||
await apiClient.post(`/knowledge-bases/${kbId}/set-expiry`, {
|
||||
expires_in_minutes: val === 0 ? null : val,
|
||||
})
|
||||
ElMessage.success('链接有效期已更新。')
|
||||
loadLink()
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
@@ -121,6 +166,12 @@ async function handleUpload(options: any) {
|
||||
uploading.value = false
|
||||
}
|
||||
|
||||
// 打开添加文本对话框(预填左侧选中的目录,对话框内的选择优先)
|
||||
function openTextDialog() {
|
||||
textForm.value.category_id = selectedCategoryId.value
|
||||
showTextDialog.value = true
|
||||
}
|
||||
|
||||
// 创建文本内容
|
||||
async function handleCreateText() {
|
||||
if (!textForm.value.title.trim() || !textForm.value.content.trim()) {
|
||||
@@ -134,7 +185,7 @@ async function handleCreateText() {
|
||||
title: textForm.value.title,
|
||||
content: textForm.value.content,
|
||||
content_format: textForm.value.content_format,
|
||||
category_id: selectedCategoryId.value || textForm.value.category_id,
|
||||
category_id: textForm.value.category_id,
|
||||
})
|
||||
ElMessage.success('文本内容已创建!')
|
||||
showTextDialog.value = false
|
||||
@@ -255,6 +306,16 @@ function getCategoryName(catId: string) {
|
||||
const cat = allCategoriesFlat.value.find((c: any) => c.id === catId)
|
||||
return cat ? cat.name : '未分类'
|
||||
}
|
||||
|
||||
// AI 链接打码:显示域名,token 部分用 • 代替
|
||||
const maskedAiUrl = computed(() => {
|
||||
if (!aiUrl.value) return '尚未生成'
|
||||
const idx = aiUrl.value.indexOf('/k/')
|
||||
if (idx === -1) return '••••••••'
|
||||
const prefix = aiUrl.value.slice(0, idx + 3)
|
||||
const tokenLen = aiUrl.value.length - idx - 3
|
||||
return prefix + '•'.repeat(tokenLen)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -315,22 +376,40 @@ function getCategoryName(catId: string) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI 链接 -->
|
||||
<!-- AI 链接(打码显示,只能复制获取) -->
|
||||
<el-card style="margin-bottom: 16px" shadow="hover">
|
||||
<div style="display: flex; align-items: center; gap: 10px; flex-wrap: wrap">
|
||||
<span style="font-weight: bold; font-size: 14px">AI 链接:</span>
|
||||
<el-input :model-value="aiUrl" readonly style="flex: 1; min-width: 200px" size="large">
|
||||
<template #append>
|
||||
<el-button @click="handleCopyLink">复制</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
<span style="flex: 1; min-width: 200px; font-size: 14px; color: #666; font-family: monospace; user-select: none">
|
||||
{{ maskedAiUrl }}
|
||||
</span>
|
||||
<el-select
|
||||
:model-value="null"
|
||||
placeholder="⏱️ 设置有效期"
|
||||
style="width: 150px"
|
||||
@change="(val: number) => handleExpiryChange(val)"
|
||||
>
|
||||
<el-option
|
||||
v-for="opt in expiryOptions"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
<el-button type="primary" @click="handleCopyLink" size="large">📋 复制 AI 链接</el-button>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; flex-wrap: wrap; gap: 8px; margin-top: 8px; font-size: 12px">
|
||||
<span :style="{ color: linkExpiry.is_expired ? '#f56c6c' : '#67c23a', fontWeight: linkExpiry.is_expired ? 'bold' : 'normal' }">
|
||||
{{ expiryText }}
|
||||
</span>
|
||||
<span style="color: #999">🔒 链接即访问凭证,已隐藏显示;仅可通过复制按钮获取。请勿公开传播。</span>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 手机端目录按钮 + 操作按钮 -->
|
||||
<div class="mobile-actions">
|
||||
<el-button @click="showMobileSidebar = true" style="flex: 1">📁 目录</el-button>
|
||||
<el-button @click="showTextDialog = true" style="flex: 1">✏️ 添加文本</el-button>
|
||||
<el-button @click="openTextDialog" style="flex: 1">✏️ 添加文本</el-button>
|
||||
<el-upload :show-file-list="false" :http-request="handleUpload" accept=".docx,.pdf" style="flex: 1">
|
||||
<el-button type="primary" :loading="uploading" style="width: 100%">📤 上传</el-button>
|
||||
</el-upload>
|
||||
@@ -346,7 +425,7 @@ function getCategoryName(catId: string) {
|
||||
</el-tag>
|
||||
</span>
|
||||
<div class="desktop-actions">
|
||||
<el-button @click="showTextDialog = true">✏️ 添加文本</el-button>
|
||||
<el-button @click="openTextDialog">✏️ 添加文本</el-button>
|
||||
<el-upload :show-file-list="false" :http-request="handleUpload" accept=".docx,.pdf">
|
||||
<el-button type="primary" :loading="uploading">📤 上传文档</el-button>
|
||||
</el-upload>
|
||||
|
||||
+615
-523
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import apiClient from '@/api/client'
|
||||
import { copyToClipboard } from '@/utils/clipboard'
|
||||
|
||||
const router = useRouter()
|
||||
const kbs = ref<any[]>([])
|
||||
@@ -60,8 +61,12 @@ async function handleCopyLink(kb: any) {
|
||||
try {
|
||||
const { data } = await apiClient.get(`/knowledge-bases/${kb.id}/link`)
|
||||
const url = `${window.location.origin}${data.ai_url}`
|
||||
await navigator.clipboard.writeText(url)
|
||||
ElMessage.success('AI 链接已复制到剪贴板!')
|
||||
const success = await copyToClipboard(url)
|
||||
if (success) {
|
||||
ElMessage.success('AI 链接已复制到剪贴板!')
|
||||
} else {
|
||||
ElMessage.error('复制失败,请稍后重试。')
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ref, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import apiClient from '@/api/client';
|
||||
import { copyToClipboard } from '@/utils/clipboard';
|
||||
const router = useRouter();
|
||||
const kbs = ref([]);
|
||||
const loading = ref(false);
|
||||
@@ -55,8 +56,13 @@ async function handleCopyLink(kb) {
|
||||
try {
|
||||
const { data } = await apiClient.get(`/knowledge-bases/${kb.id}/link`);
|
||||
const url = `${window.location.origin}${data.ai_url}`;
|
||||
await navigator.clipboard.writeText(url);
|
||||
ElMessage.success('AI 链接已复制到剪贴板!');
|
||||
const success = await copyToClipboard(url);
|
||||
if (success) {
|
||||
ElMessage.success('AI 链接已复制到剪贴板!');
|
||||
}
|
||||
else {
|
||||
ElMessage.error('复制失败,请稍后重试。');
|
||||
}
|
||||
}
|
||||
catch { /* ignore */ }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user