细节优化

This commit is contained in:
amb
2026-09-02 18:04:41 +08:00
parent cecb5f2714
commit 8ec7856bfc
21 changed files with 1260 additions and 569 deletions
+32 -9
View File
@@ -1,37 +1,60 @@
/**
* 兼容所有浏览器的复制到剪贴板函数
* 解决 iOS Safari 不支持 navigator.clipboard.writeText() 的问题
*
* 兼容矩阵:
* - Chrome 66+ / Edge 79+ / Firefox 63+Clipboard API(方法1
* - Safari 13.1+ / iOS SafariClipboard API 或 execCommand(方法2,已做 iOS 特殊处理)
* - 老版本浏览器(Chrome <66 / Firefox <63 / Safari <13.1 / IE):execCommand(方法2/3
*/
export async function copyToClipboard(text) {
// 方法1: 现代 Clipboard APIChrome/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: 传统 execCommandiOS Safari 兼容方案
// 方法2: 隐藏 textarea + execCommand(兼容绝大多数老浏览器,含 iOS Safari
try {
const textarea = document.createElement('textarea');
textarea.value = text;
// 防止页面滚动
textarea.setAttribute('readonly', '');
// 不可见但可选中;不设 display:noneSafari 会取消选中)
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;
}
+32 -10
View File
@@ -1,24 +1,29 @@
/**
* 兼容所有浏览器的复制到剪贴板函数
* 解决 iOS Safari 不支持 navigator.clipboard.writeText() 的问题
*
* 兼容矩阵:
* - Chrome 66+ / Edge 79+ / Firefox 63+Clipboard API(方法1
* - Safari 13.1+ / iOS SafariClipboard 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 APIChrome/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: 传统 execCommandiOS Safari 兼容方案
// 方法2: 隐藏 textarea + execCommand(兼容绝大多数老浏览器,含 iOS Safari
try {
const textarea = document.createElement('textarea')
textarea.value = text
textarea.setAttribute('readonly', '')
// 防止页面滚动
// 不可见但可选中;不设 display:noneSafari 会取消选中)
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
}
+88 -9
View File
@@ -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>
File diff suppressed because it is too large Load Diff
+7 -2
View File
@@ -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>
+8 -2
View File
@@ -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 */ }
}