
code.js
javascript
console.log("work")
window.Asc.plugin.init = function init() { }
window.Asc.plugin.button = function button(id) {
this.executeCommand("close", "")
}
let isShowMenu = false
if (isShowMenu) {
window.Asc.plugin.event_onContextMenuShow = function event_onContextMenuShow(options) {
switch (options.type) {
case "Selection":
this.executeMethod("AddContextMenuItem", [{
guid: this.guid,
items: [
{
id: "onClickAddToChat",
text: '添加到对话',
},
{
id: "onClickTranslation",
text: '翻译',
},
],
}])
break
default:
break
}
}
window.Asc.plugin.event_onContextMenuClick = (id) => {
const pluginObj = window.Asc.plugin
let itemId = id
let itemData = undefined
const itemPos = itemId.indexOf("_oo_sep_")
if (itemPos !== -1) {
itemData = itemId.slice(itemPos + 8)
itemId = itemId.slice(0, itemPos)
}
if (pluginObj.contextMenuEvents && pluginObj.contextMenuEvents[itemId]) {
pluginObj.contextMenuEvents[itemId].call(pluginObj, itemData)
}
}
window.Asc.plugin.attachContextMenuClickEvent("onClickAddToChat", () => {
window.Asc.plugin.executeMethod("GetSelectedText", [], function (text) {
const message = {
type: 'ADD_TO_CHAT',
text: text,
timestamp: Date.now()
};
try {
if (window.top && window.top !== window) {
window.top.postMessage(message, '*');
}
} catch (e) {
console.log('无法访问 window.top:', e);
}
});
})
window.Asc.plugin.attachContextMenuClickEvent("onClickTranslation", () => {
window.Asc.plugin.executeMethod("GetSelectedText", [], function (text) {
const message = {
type: 'TRANSLATE_TEXT',
text: text,
timestamp: Date.now()
};
try {
if (window.top && window.top !== window) {
window.top.postMessage(message, '*');
}
} catch (e) {
console.log('无法访问 window.top:', e);
}
});
})
}
let isSelectMonitor = true;
if (isSelectMonitor) {
window.Asc.plugin.event_onContextMenuShow = function (options) {
sendHideSelectionToParent();
}
window.Asc.plugin.event_onFocusOut = function () {
checkAndSendSelection();
}
window.Asc.plugin.event_onBlur = function () {
checkAndSendSelection();
}
function checkAndSendSelection() {
window.Asc.plugin.executeMethod("GetSelectedText", [], function (text) {
if (text && text.trim()) {
sendSelectionToParent(text);
}
});
}
function sendSelectionToParent(text, position) {
const message = {
type: 'TEXT_SELECTED',
text: text,
timestamp: Date.now()
};
try {
if (window.top && window.top !== window) {
window.top.postMessage(message, '*');
}
} catch (e) {
console.log('无法访问 window.top:', e);
}
try {
if (window.parent && window.parent !== window) {
window.parent.postMessage(message, '*');
console.log('已发送到 window.parent');
}
} catch (e) {
console.log('无法访问 window.parent:', e);
}
try {
if (window.parent.parent && window.parent.parent !== window) {
window.parent.parent.postMessage(message, '*');
console.log('已发送到 window.parent.parent');
}
} catch (e) {
console.log('无法访问 window.parent.parent:', e);
}
}
function sendHideSelectionToParent(text, position) {
const message = {
type: 'HIDE_TEXT_SELECTED',
timestamp: Date.now()
};
try {
if (window.top && window.top !== window) {
window.top.postMessage(message, '*');
}
} catch (e) {
console.log('无法访问 window.top:', e);
}
try {
if (window.parent && window.parent !== window) {
window.parent.postMessage(message, '*');
console.log('已发送到 window.parent');
}
} catch (e) {
console.log('无法访问 window.parent:', e);
}
try {
if (window.parent.parent && window.parent.parent !== window) {
window.parent.parent.postMessage(message, '*');
console.log('已发送到 window.parent.parent');
}
} catch (e) {
console.log('无法访问 window.parent.parent:', e);
}
}
let lastSelectedText = '';
let selectionCheckInterval = null;
function startSelectionMonitoring() {
if (selectionCheckInterval) {
clearInterval(selectionCheckInterval);
}
selectionCheckInterval = setInterval(() => {
window.Asc.plugin.executeMethod("GetSelectedText", [], function (text) {
if (text && text.trim() && text !== lastSelectedText) {
lastSelectedText = text;
sendSelectionToParent(text);
} else if (!text || !text.trim()) {
sendHideSelectionToParent()
lastSelectedText = '';
}
});
}, 500);
}
startSelectionMonitoring();
}
index.html
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>example context menu and events</title>
<script type="text/javascript" src="https://onlyoffice.github.io/sdkjs-plugins/v1/plugins.js"></script>
<script src="scripts/code.js"></script>
</head>
<body>
</body>
</html>
config.json
javascript
{
"name": "工具",
"guid": "asc.008",
"variations": [
{
"description": "example context menu and events",
"url": "index.html",
"icons": ["resources/light/icon.png", "resources/light/icon@2x.png"],
"icons2": [
{
"style": "light",
"100%": {
"normal": "resources/light/icon.png"
},
"125%": {
"normal": "resources/light/icon@1.25x.png"
},
"150%": {
"normal": "resources/light/icon@1.5x.png"
},
"175%": {
"normal": "resources/light/icon@1.75x.png"
},
"200%": {
"normal": "resources/light/icon@2x.png"
}
},
{
"style": "dark",
"100%": {
"normal": "resources/dark/icon.png"
},
"125%": {
"normal": "resources/dark/icon@1.25x.png"
},
"150%": {
"normal": "resources/dark/icon@1.5x.png"
},
"175%": {
"normal": "resources/dark/icon@1.75x.png"
},
"200%": {
"normal": "resources/dark/icon@2x.png"
}
}
],
"isViewer": false,
"EditorsSupport": ["word", "cell", "slide"],
"isVisual": false,
"isModal": false,
"isInsideMode": false,
"isSystem": false,
"initDataType": "none",
"initData": "",
"buttons": [],
"events": [
"onContextMenuShow",
"onContextMenuClick",
"onTargetPositionChanged",
"onFocusOut",
"onBlur"
]
}
]
}
父页面:
javascript
'use client'
import { useState, useRef, useEffect } from 'react'
import Header from '@/components/header'
import {
ArrowLeft,
Upload,
FileText,
File,
CheckCircle2,
AlertCircle,
Loader2,
X,
FolderOpen,
Sparkles,
Copy,
Search,
BookOpen,
MessageSquare,
Wand2,
} from 'lucide-react'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import Script from 'next/script'
import { cn } from '@/lib/utils'
// 声明全局 DocsAPI 类型
declare global {
interface Window {
DocsAPI?: {
DocEditor: new (id: string, config: any) => any
}
}
}
// OnlyOffice Editor 实例类型
interface DocEditorInstance {
destroyEditor: () => void
downloadAs: (format: string) => void
requestClose: () => void
}
// 支持的文件格式
const SUPPORTED_FORMATS = {
documents: [
'.doc',
'.docx',
'.docm',
'.dot',
'.dotx',
'.dotm',
'.odt',
'.fodt',
'.ott',
'.rtf',
'.txt',
'.html',
'.htm',
'.mht',
'.pdf',
'.djvu',
'.fb2',
'.epub',
'.xps',
],
spreadsheets: [
'.xls',
'.xlsx',
'.xlsm',
'.xlt',
'.xltx',
'.xltm',
'.ods',
'.fods',
'.ots',
'.csv',
],
presentations: [
'.pps',
'.ppsx',
'.ppsm',
'.ppt',
'.pptx',
'.pptm',
'.pot',
'.potx',
'.potm',
'.odp',
'.fodp',
'.otp',
],
}
const ALL_FORMATS = [
...SUPPORTED_FORMATS.documents,
...SUPPORTED_FORMATS.spreadsheets,
...SUPPORTED_FORMATS.presentations,
]
interface UploadedFile {
file: File
name: string
size: number
type: string
uploadTime: number
previewUrl?: string
}
export default function OnlyOfficePage() {
const router = useRouter()
const [uploadedFile, setUploadedFile] = useState<UploadedFile | null>(null)
const [isUploading, setIsUploading] = useState(false)
const [isDragOver, setIsDragOver] = useState(false)
const [error, setError] = useState<string | null>(null)
const [isPreviewReady, setIsPreviewReady] = useState(false)
const [scriptLoaded, setScriptLoaded] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const editorContainerRef = useRef<HTMLDivElement>(null)
const docEditorRef = useRef<DocEditorInstance | null>(null)
// 鼠标位置追踪
const mousePositionRef = useRef({ x: 0, y: 0 })
// 上下文菜单状态
const [contextMenu, setContextMenu] = useState<{
visible: boolean
x: number
y: number
selectedText: string
}>({
visible: false,
x: 0,
y: 0,
selectedText: '',
})
// 格式化文件大小
const formatBytes = (bytes: number): string => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]
}
// 检查文件格式
const isFileSupported = (fileName: string): boolean => {
const ext = '.' + fileName.split('.').pop()?.toLowerCase()
return ALL_FORMATS.includes(ext)
}
// 获取文件类型
const getFileType = (
fileName: string,
): 'word' | 'cell' | 'slide' | 'unknown' => {
const ext = '.' + fileName.split('.').pop()?.toLowerCase()
if (SUPPORTED_FORMATS.documents.includes(ext)) return 'word'
if (SUPPORTED_FORMATS.spreadsheets.includes(ext)) return 'cell'
if (SUPPORTED_FORMATS.presentations.includes(ext)) return 'slide'
return 'unknown'
}
// 处理文件选择
const handleFileSelect = async (file: File) => {
setError(null)
setIsPreviewReady(false)
if (!isFileSupported(file.name)) {
setError(
`不支持的文件格式。支持的格式包括:${ALL_FORMATS.slice(0, 10).join(', ')} 等`,
)
return
}
setIsUploading(true)
try {
// 模拟上传过程
await new Promise((resolve) => setTimeout(resolve, 800))
const uploadedFileData: UploadedFile = {
file,
name: file.name,
size: file.size,
type: getFileType(file.name),
uploadTime: Date.now(),
previewUrl: URL.createObjectURL(file),
}
setUploadedFile(uploadedFileData)
// 延迟显示预览准备就绪
setTimeout(() => {
setIsPreviewReady(true)
initOnlyOfficeEditor(uploadedFileData)
}, 500)
} catch (err: any) {
console.error('上传失败:', err)
setError(err.message || '上传失败,请重试')
} finally {
setIsUploading(false)
}
}
// 初始化 OnlyOffice 编辑器
const initOnlyOfficeEditor = (fileData: any, type: any) => {
if (!editorContainerRef.current || !window.DocsAPI) {
console.warn('OnlyOffice API 未加载或容器未准备好')
return
}
let url = 'https://static.xutongbao.top/onlyoffice/3.docx'
if (type === '1') {
fileData = {
type: 'word',
uploadTime: 1768789600204,
name: '测试.docx',
}
url = 'https://static.xutongbao.top/onlyoffice/3.docx'
} else if (type === '2') {
fileData = {
type: 'cell',
uploadTime: 1768789600204,
name: '测试.xlsx',
}
url = 'https://static.xutongbao.top/onlyoffice/1.xlsx'
} else if (type === '3') {
fileData = {
type: 'slide',
uploadTime: 1768789600204,
name: '测试.pptx',
}
url = 'https://static.xutongbao.top/onlyoffice/1.pptx'
}
// 实际项目中需要配置 OnlyOffice Document Server
// 示例配置(需要根据实际情况调整):
const config = {
documentType: fileData.type,
document: {
fileType: fileData.name.split('.').pop(),
key: `${fileData.uploadTime}-${Math.random().toString(36).substring(7)}`,
title: fileData.name,
url,
},
editorConfig: {
mode: 'edit', // 编辑模式
lang: 'zh-CN',
user: {
id: '690313ca3814f11a1fb7cbd7',
name: '徐同保',
},
// 启用文本选中监听插件
plugins: {
autostart: ['asc.008'],
},
},
width: '100%',
height: '100%',
events: {
onDocumentReady: () => {
console.log('OnlyOffice 文档加载完成')
},
onAppReady: () => {
console.log('OnlyOffice 应用已准备就绪')
},
},
}
const editor = new window.DocsAPI.DocEditor('onlyoffice-editor', config)
docEditorRef.current = editor
}
// 监听 OnlyOffice API 脚本加载
useEffect(() => {
if (window.DocsAPI && scriptLoaded && uploadedFile && isPreviewReady) {
// initOnlyOfficeEditor(uploadedFile)
}
}, [scriptLoaded, uploadedFile, isPreviewReady])
// 监听鼠标移动,追踪鼠标坐标
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
mousePositionRef.current = { x: e.clientX, y: e.clientY }
console.log('鼠标位置更新:', mousePositionRef.current)
}
window.addEventListener('mousemove', handleMouseMove)
return () => {
window.removeEventListener('mousemove', handleMouseMove)
}
}, [])
// 监听来自 OnlyOffice 的消息
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
// 监听来自 OnlyOffice iframe 的消息
if (event.data && typeof event.data === 'object') {
// 添加到对话消息
if (event.data.type === 'ADD_TO_CHAT') {
console.log('✓ 收到添加到对话请求:', event.data.text)
// 这里可以调用你的对话功能
alert(`添加到对话:\n${event.data.text}`)
}
// 翻译消息
if (event.data.type === 'TRANSLATE_TEXT') {
console.log('✓ 收到翻译请求:', event.data.text)
// 这里可以调用你的翻译功能
alert(`翻译文本:\n${event.data.text}`)
}
// 文本选中消息 - 使用父页面捕获的鼠标坐标
if (event.data.type === 'TEXT_SELECTED') {
const mousePos = mousePositionRef.current
const selectedText = event.data.text
console.log('✓ 收到文本选中事件:', {
text: selectedText,
mousePosition: mousePos,
timestamp: event.data.timestamp,
})
// 保存选中的文本到 state 并显示悬浮菜单(固定到屏幕中心)
setContextMenu({
visible: true,
x: 0, // 不再使用鼠标坐标
y: 0,
selectedText: selectedText,
})
console.log('选中文本已保存到 state:', selectedText)
console.log('悬浮菜单显示在坐标:', mousePos)
}
// 隐藏文本选中菜单消息
if (event.data.type === 'HIDE_TEXT_SELECTED') {
console.log('✓ 收到隐藏菜单事件:', {
timestamp: event.data.timestamp,
})
setContextMenu({
visible: false,
x: 0,
y: 0,
selectedText: '',
})
console.log('悬浮菜单已隐藏')
}
}
}
window.addEventListener('message', handleMessage)
return () => {
window.removeEventListener('message', handleMessage)
}
}, [])
// 监听全局点击事件,点击菜单外部时隐藏菜单
useEffect(() => {
if (!contextMenu.visible) return
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement
// 检查点击的元素是否在菜单内部
const menuElement = document.getElementById('context-menu')
if (menuElement && !menuElement.contains(target)) {
setContextMenu((prev) => ({ ...prev, visible: false }))
}
}
// 延迟添加事件监听,避免菜单刚显示就被关闭
const timer = setTimeout(() => {
document.addEventListener('click', handleClickOutside)
}, 100)
return () => {
clearTimeout(timer)
document.removeEventListener('click', handleClickOutside)
}
}, [contextMenu.visible])
// 添加到对话
const handleAddToChat = () => {
console.log('添加到对话 - 选中的文本:', contextMenu.selectedText)
setContextMenu((prev) => ({ ...prev, visible: false }))
}
// 翻译
const handleTranslateText = () => {
console.log('翻译 - 选中的文本:', contextMenu.selectedText)
setContextMenu((prev) => ({ ...prev, visible: false }))
}
const handleTest = (type: any) => {
setError(null)
setIsPreviewReady(true)
setIsUploading(false)
setUploadedFile({})
setTimeout(() => {
setIsPreviewReady(true)
initOnlyOfficeEditor({}, type)
}, 500)
}
// 拖拽事件处理
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault()
setIsDragOver(true)
}
const handleDragLeave = () => {
setIsDragOver(false)
}
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
setIsDragOver(false)
const file = e.dataTransfer.files[0]
if (file) {
handleFileSelect(file)
}
}
// 清除文件
const handleClearFile = () => {
setUploadedFile(null)
setIsPreviewReady(false)
setError(null)
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}
return (
<>
{/* 加载 OnlyOffice API 脚本 */}
<Script
// src="https://demo.jdyos.com:7070/web-apps/apps/api/documents/api.js"
src='http://localhost/web-apps/apps/api/documents/api.js'
strategy='afterInteractive'
onLoad={() => {
console.log('OnlyOffice API 加载完成')
setScriptLoaded(true)
}}
onError={(e) => {
console.error('OnlyOffice API 加载失败:', e)
setError('OnlyOffice API 加载失败,请检查网络连接或服务器配置')
}}
/>
<Header />
<main className='m-only-office min-h-screen bg-gradient-to-br from-primary/5 via-background to-secondary/5 relative overflow-hidden'>
{/* 背景装饰 */}
<div className='absolute inset-0 overflow-hidden pointer-events-none'>
<div className='absolute top-20 left-1/4 w-96 h-96 bg-primary/5 rounded-full blur-3xl animate-pulse-slow' />
<div
className='absolute bottom-20 right-1/4 w-96 h-96 bg-secondary/5 rounded-full blur-3xl animate-pulse-slow'
style={{ animationDelay: '2s' }}
/>
<div className='absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[800px] h-[800px] bg-gradient-to-r from-primary/3 to-secondary/3 rounded-full blur-3xl animate-spin-slow' />
</div>
{/* 内容区域 */}
<div className='relative max-w-7xl mx-auto px-4 py-8'>
{/* 返回按钮 */}
<button
onClick={() => router.push('/light')}
className='group mb-8 flex items-center gap-2 px-4 py-3 rounded-2xl bg-card/80 backdrop-blur-xl border-2 border-border hover:border-primary shadow-lg hover:shadow-xl transition-all duration-300 hover:scale-105 animate-fade-in'
>
<div className='relative'>
<div className='absolute inset-0 bg-primary/20 rounded-full blur-md scale-0 group-hover:scale-150 transition-transform duration-500' />
<ArrowLeft className='relative w-5 h-5 text-primary group-hover:text-primary transition-all duration-300 group-hover:-translate-x-1' />
</div>
<span className='text-sm font-medium text-foreground group-hover:text-primary transition-colors duration-300'>
返回
</span>
</button>
{/* 主标题卡片 */}
<div className='mb-8 p-8 rounded-3xl bg-gradient-to-br from-card/95 to-card/80 backdrop-blur-2xl border-2 border-border/50 shadow-2xl animate-fade-in'>
<div className='flex items-center gap-4 mb-3'>
<div className='relative'>
<div className='absolute inset-0 bg-primary/20 rounded-2xl blur-xl animate-pulse-slow' />
<div className='relative w-16 h-16 rounded-2xl bg-gradient-to-br from-primary to-primary/70 flex items-center justify-center shadow-lg'>
<FileText className='w-8 h-8 text-primary-foreground' />
</div>
</div>
<div className='flex-1'>
<h1 className='text-3xl md:text-4xl font-bold bg-gradient-to-r from-primary via-secondary to-accent bg-clip-text text-transparent'>
OnlyOffice 文档预览
</h1>
<p className='text-sm text-muted-foreground mt-1'>
支持 Word、Excel、PowerPoint 等多种格式的文档在线预览
</p>
</div>
</div>
{/* 支持格式说明 */}
<div className='mt-4 p-4 rounded-xl bg-muted/30 border border-border/50'>
<div className='flex items-center gap-2 mb-2'>
<Sparkles className='w-4 h-4 text-primary' />
<span className='text-sm font-semibold text-foreground'>
支持的文件格式
</span>
</div>
<div className='grid grid-cols-1 md:grid-cols-3 gap-2 text-xs text-muted-foreground'>
<div>
<span className='font-medium text-foreground'>文档:</span>
DOC, DOCX, ODT, RTF, TXT, PDF 等
</div>
<div>
<span className='font-medium text-foreground'>表格:</span>
XLS, XLSX, ODS, CSV 等
</div>
<div>
<span className='font-medium text-foreground'>演示:</span>
PPT, PPTX, ODP 等
</div>
</div>
</div>
</div>
<div style={{ display: 'flex' }}>
<button
onClick={() => handleTest('1')}
className='group mb-8 flex items-center gap-2 px-4 py-3 rounded-2xl bg-card/80 backdrop-blur-xl border-2 border-border hover:border-primary shadow-lg hover:shadow-xl transition-all duration-300 hover:scale-105 animate-fade-in'
>
<span className='text-sm font-medium text-foreground group-hover:text-primary transition-colors duration-300'>
测试1
</span>
</button>
<button
onClick={() => handleTest('2')}
className='group mb-8 flex items-center gap-2 px-4 py-3 rounded-2xl bg-card/80 backdrop-blur-xl border-2 border-border hover:border-primary shadow-lg hover:shadow-xl transition-all duration-300 hover:scale-105 animate-fade-in'
>
<span className='text-sm font-medium text-foreground group-hover:text-primary transition-colors duration-300'>
测试2
</span>
</button>
<button
onClick={() => handleTest('3')}
className='group mb-8 flex items-center gap-2 px-4 py-3 rounded-2xl bg-card/80 backdrop-blur-xl border-2 border-border hover:border-primary shadow-lg hover:shadow-xl transition-all duration-300 hover:scale-105 animate-fade-in'
>
<span className='text-sm font-medium text-foreground group-hover:text-primary transition-colors duration-300'>
测试3
</span>
</button>
</div>
{/* 上传区域或文件预览 */}
{!uploadedFile ? (
<div className='p-8 rounded-3xl bg-gradient-to-br from-card/95 to-card/80 backdrop-blur-2xl border-2 border-border/50 shadow-2xl animate-fade-in-up'>
<div
className={`
border-2 border-dashed rounded-2xl p-16 text-center cursor-pointer
transition-all duration-300 relative overflow-hidden
${
isDragOver
? 'border-primary bg-primary/10 scale-[1.02]'
: 'border-border hover:border-primary/50 hover:bg-muted/30'
}
`}
onClick={() => fileInputRef.current?.click()}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{/* 装饰性背景 */}
<div className='absolute inset-0 overflow-hidden pointer-events-none'>
<div className='absolute top-0 left-1/4 w-32 h-32 bg-primary/5 rounded-full blur-2xl animate-float' />
<div
className='absolute bottom-0 right-1/4 w-32 h-32 bg-secondary/5 rounded-full blur-2xl animate-float'
style={{ animationDelay: '1s' }}
/>
</div>
<div className='relative flex flex-col items-center gap-6'>
<div
className={`
w-24 h-24 rounded-full flex items-center justify-center
transition-all duration-500
${
isDragOver
? 'bg-primary scale-110 shadow-2xl shadow-primary/50'
: 'bg-gradient-to-br from-primary/20 to-secondary/20 shadow-lg'
}
`}
>
{isUploading ? (
<Loader2 className='w-12 h-12 text-primary animate-spin' />
) : (
<Upload
className={`w-12 h-12 transition-all duration-300 ${
isDragOver ? 'text-white scale-110' : 'text-primary'
}`}
/>
)}
</div>
<div>
<div className='text-2xl font-bold text-foreground mb-2'>
{isUploading
? '上传中...'
: isDragOver
? '松开以上传文件'
: '点击或拖拽文件到此处'}
</div>
<div className='text-sm text-muted-foreground mb-4'>
支持 Word、Excel、PowerPoint、PDF 等多种格式
</div>
<div className='inline-flex items-center gap-2 px-4 py-2 rounded-full bg-primary/10 border border-primary/20'>
<FolderOpen className='w-4 h-4 text-primary' />
<span className='text-xs font-medium text-primary'>
单个文件,最大支持 100MB
</span>
</div>
</div>
</div>
</div>
<input
ref={fileInputRef}
type='file'
accept={ALL_FORMATS.join(',')}
className='hidden'
onChange={(e) => {
const file = e.target.files?.[0]
if (file) handleFileSelect(file)
}}
/>
{/* 错误提示 */}
{error && (
<div className='mt-6 p-4 rounded-xl bg-destructive/10 border-2 border-destructive/30 flex items-start gap-3 animate-shake'>
<AlertCircle className='w-5 h-5 text-destructive mt-0.5 flex-shrink-0' />
<div className='flex-1'>
<div className='font-semibold text-destructive mb-1'>
上传失败
</div>
<div className='text-sm text-destructive/80'>{error}</div>
</div>
</div>
)}
</div>
) : (
<>
{/* 文件信息卡片 */}
<div className='mb-6 p-6 rounded-3xl bg-gradient-to-br from-card/95 to-card/80 backdrop-blur-2xl border-2 border-primary/30 shadow-2xl animate-fade-in'>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-4 flex-1'>
<div className='relative'>
<div className='absolute inset-0 bg-green-500/20 rounded-xl blur-lg animate-pulse-slow' />
<div className='relative w-12 h-12 rounded-xl bg-gradient-to-br from-green-500 to-green-600 flex items-center justify-center shadow-lg'>
<File className='w-6 h-6 text-white' />
</div>
</div>
<div className='flex-1 min-w-0'>
<div className='flex items-center gap-2 mb-1'>
<h3 className='text-lg font-semibold text-foreground truncate'>
{uploadedFile.name}
</h3>
{isPreviewReady && (
<div className='flex items-center gap-1 px-2 py-1 rounded-full bg-green-500/10 border border-green-500/30'>
<CheckCircle2 className='w-3 h-3 text-green-500' />
<span className='text-xs font-medium text-green-500'>
预览就绪
</span>
</div>
)}
</div>
<div className='flex items-center gap-3 text-xs text-muted-foreground'>
<span>{formatBytes(uploadedFile.size)}</span>
<span>•</span>
<span className='capitalize'>
{uploadedFile.type} 文档
</span>
<span>•</span>
<span>
{new Date(
uploadedFile.uploadTime,
).toLocaleTimeString()}
</span>
</div>
</div>
</div>
<Button
onClick={handleClearFile}
variant='outline'
size='sm'
className='rounded-xl border-2 hover:border-destructive hover:bg-destructive/5 hover:text-destructive transition-all duration-300'
>
<X className='w-4 h-4 mr-1' />
关闭
</Button>
</div>
</div>
{/* 预览区域 */}
<div className='rounded-3xl bg-gradient-to-br from-card/95 to-card/80 backdrop-blur-2xl border-2 border-border/50 shadow-2xl animate-fade-in-up overflow-hidden'>
{!isPreviewReady ? (
<div className='w-full h-[calc(100vh-300px)] min-h-[700px] flex flex-col items-center justify-center gap-4 bg-muted/20'>
<Loader2 className='w-12 h-12 text-primary animate-spin' />
<div className='text-lg font-semibold text-foreground'>
正在加载预览...
</div>
<div className='text-sm text-muted-foreground'>
请稍候片刻
</div>
</div>
) : (
<div className='relative'>
<div
ref={editorContainerRef}
id='onlyoffice-editor'
className='w-full h-[calc(100vh-300px)] min-h-[700px] bg-white'
>
{/* OnlyOffice 编辑器将在这里加载 */}
</div>
</div>
)}
</div>
</>
)}
</div>
</main>
{/* 上下文菜单 */}
{contextMenu.visible && (
<div
id='context-menu'
className='fixed z-50'
style={{
left: '50%',
top: '50%',
transform: 'translate(-50%, -50%)',
}}
>
<div className='px-4 py-3 rounded-xl bg-gradient-to-br from-card/95 to-card/90 backdrop-blur-2xl border-2 border-border/50 shadow-2xl'>
<div className='flex items-center gap-2'>
{/* 添加到对话按钮 */}
<button
onClick={handleAddToChat}
className='group relative px-4 py-2.5 rounded-lg hover:bg-primary/10 transition-all duration-200 flex items-center gap-2'
title='添加到对话'
>
<div className='absolute inset-0 bg-primary/20 rounded-lg blur-md scale-0 group-hover:scale-100 transition-transform duration-300' />
<MessageSquare className='relative w-4 h-4 text-foreground group-hover:text-primary transition-colors duration-200' />
<span className='relative text-sm text-foreground group-hover:text-primary transition-colors duration-200'>
添加到对话
</span>
</button>
{/* 翻译按钮 */}
<button
onClick={handleTranslateText}
className='group relative px-4 py-2.5 rounded-lg hover:bg-primary/10 transition-all duration-200 flex items-center gap-2'
title='翻译'
>
<div className='absolute inset-0 bg-primary/20 rounded-lg blur-md scale-0 group-hover:scale-100 transition-transform duration-300' />
<BookOpen className='relative w-4 h-4 text-foreground group-hover:text-primary transition-colors duration-200' />
<span className='relative text-sm text-foreground group-hover:text-primary transition-colors duration-200'>
翻译
</span>
</button>
</div>
</div>
</div>
)}
{/* 自定义动画样式 */}
<style jsx global>{`
@keyframes pulse-slow {
0%,
100% {
opacity: 0.3;
transform: scale(1);
}
50% {
opacity: 0.6;
transform: scale(1.05);
}
}
@keyframes spin-slow {
from {
transform: translate(-50%, -50%) rotate(0deg);
}
to {
transform: translate(-50%, -50%) rotate(360deg);
}
}
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fade-in-up {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes float {
0%,
100% {
transform: translateY(0px);
}
50% {
transform: translateY(-20px);
}
}
@keyframes shake {
0%,
100% {
transform: translateX(0);
}
10%,
30%,
50%,
70%,
90% {
transform: translateX(-2px);
}
20%,
40%,
60%,
80% {
transform: translateX(2px);
}
}
.animate-pulse-slow {
animation: pulse-slow 4s ease-in-out infinite;
}
.animate-spin-slow {
animation: spin-slow 20s linear infinite;
}
.animate-fade-in {
animation: fade-in 0.5s ease-out forwards;
}
.animate-fade-in-up {
animation: fade-in-up 0.6s ease-out forwards;
}
.animate-float {
animation: float 3s ease-in-out infinite;
}
.animate-shake {
animation: shake 0.5s ease-in-out;
}
/* OnlyOffice 编辑器 iframe 样式 */
.m-only-office iframe {
width: 100% !important;
height: 100% !important;
min-height: 700px !important;
border: none !important;
}
`}</style>
</>
)
}
