Block Editor增加AI结果弹窗卡片,用于显示翻译和摘要
This commit is contained in:
parent
dce881eefa
commit
4218ae70ba
@ -26,6 +26,7 @@ import ColumnsOverlay from './menus/ColumnsOverlay.vue'
|
||||
import AIInputBar from './menus/AIInputBar.vue'
|
||||
import AIReviewBar from './menus/AIReviewBar.vue'
|
||||
import AIReviewPopover from './menus/AIReviewPopover.vue'
|
||||
import AIResultCard from './menus/AIResultCard.vue'
|
||||
import ThinkingDots from '@/shared/components/ThinkingDots.vue'
|
||||
|
||||
// ── AI 集成 ────────────────────────────────────────────────────────────────────
|
||||
@ -133,6 +134,7 @@ const {
|
||||
editorInstanceId,
|
||||
requestAIAction,
|
||||
requestAIGeneration,
|
||||
requestAICardAction,
|
||||
syncSelectionToAI,
|
||||
} = useAICommand(editorRef, {
|
||||
fieldname: computed(() => props.df?.fieldname || ''),
|
||||
@ -353,6 +355,25 @@ const _handleAudioPicker = (e: Event) => {
|
||||
|
||||
// ── AI 事件处理器 ────────────────────────────────────────────────────────────────
|
||||
|
||||
// ── 卡片模式 AI 动作(translate / summarize)────────────────────────────
|
||||
|
||||
/** 需要以独立卡片展示的 AI 动作类型 */
|
||||
const CARD_ACTIONS = new Set(['translate', 'summarize'])
|
||||
|
||||
const cardVisible = ref(false)
|
||||
const cardStyle = ref<Record<string, string>>({})
|
||||
const cardTitle = ref('')
|
||||
const cardContent = ref('')
|
||||
const cardLoading = ref(false)
|
||||
/** 保存触发卡片时的选区范围,用于 replace / insert-below 操作 */
|
||||
const cardSelection = ref<{ from: number; to: number } | null>(null)
|
||||
|
||||
/** 卡片标题映射 */
|
||||
const CARD_TITLE_MAP: Record<string, string> = {
|
||||
translate: 'Translation',
|
||||
summarize: 'Summary',
|
||||
}
|
||||
|
||||
/** 处理 AI 动作请求(来自 BubbleMenu 或 Slash 命令) */
|
||||
const _handleAIAction = async (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail
|
||||
@ -363,9 +384,58 @@ const _handleAIAction = async (e: Event) => {
|
||||
const { action, params } = detail
|
||||
if (!action) return
|
||||
|
||||
// 保存选中文本用于审阅对比
|
||||
const editor = editorRef.value
|
||||
const selectedText = editor && !editor.state.selection.empty
|
||||
if (!editor) return
|
||||
|
||||
// ── 卡片模式:translate / summarize ──
|
||||
if (CARD_ACTIONS.has(action)) {
|
||||
const selectedText = !editor.state.selection.empty
|
||||
? editor.state.doc.textBetween(editor.state.selection.from, editor.state.selection.to, ' ', '\n')
|
||||
: ''
|
||||
if (!selectedText) return
|
||||
|
||||
// 保存选区
|
||||
const { from, to } = editor.state.selection
|
||||
cardSelection.value = { from, to }
|
||||
cardTitle.value = CARD_TITLE_MAP[action] || 'Result'
|
||||
cardContent.value = ''
|
||||
cardLoading.value = true
|
||||
|
||||
// 计算卡片定位(选区下方)
|
||||
try {
|
||||
const coords = editor.view.coordsAtPos(to)
|
||||
cardStyle.value = {
|
||||
position: 'fixed',
|
||||
top: `${coords.bottom + 4}px`,
|
||||
left: `${Math.max(coords.left, 16)}px`,
|
||||
}
|
||||
} catch {
|
||||
cardStyle.value = {}
|
||||
}
|
||||
|
||||
cardVisible.value = true
|
||||
|
||||
await requestAICardAction(action, params, {
|
||||
onChunk: (chunk) => {
|
||||
cardContent.value += chunk
|
||||
},
|
||||
onDone: () => {
|
||||
cardLoading.value = false
|
||||
},
|
||||
onError: (msg) => {
|
||||
cardLoading.value = false
|
||||
console.error('[BE AI Card]', msg)
|
||||
},
|
||||
onThinkingStart: () => { cardLoading.value = true },
|
||||
onThinkingEnd: () => { /* streaming 开始后 loading 仍为 true 直到 onDone */ },
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// ── 审阅模式:其他 AI 动作(improve / extend / shorten 等) ──
|
||||
|
||||
// 保存选中文本用于审阅对比
|
||||
const selectedText = !editor.state.selection.empty
|
||||
? editor.state.doc.textBetween(editor.state.selection.from, editor.state.selection.to, ' ', '\n')
|
||||
: ''
|
||||
|
||||
@ -533,6 +603,32 @@ function handleAICancel() {
|
||||
editorRef.value?.chain().focus().run()
|
||||
}
|
||||
|
||||
// ── 卡片操作处理 ────────────────────────────────────────────────────────────
|
||||
|
||||
/** 用卡片结果替换选中文本 */
|
||||
function handleCardReplace() {
|
||||
const editor = editorRef.value
|
||||
const sel = cardSelection.value
|
||||
if (!editor || !sel || !cardContent.value) return
|
||||
editor.chain().focus().deleteRange({ from: sel.from, to: sel.to }).insertContent(cardContent.value).run()
|
||||
cardVisible.value = false
|
||||
}
|
||||
|
||||
/** 在选区后插入卡片结果 */
|
||||
function handleCardInsertBelow() {
|
||||
const editor = editorRef.value
|
||||
const sel = cardSelection.value
|
||||
if (!editor || !sel || !cardContent.value) return
|
||||
editor.chain().focus().insertContentAt(sel.to, '\n' + cardContent.value).run()
|
||||
cardVisible.value = false
|
||||
}
|
||||
|
||||
/** 关闭卡片 */
|
||||
function handleCardDismiss() {
|
||||
cardVisible.value = false
|
||||
editorRef.value?.chain().focus().run()
|
||||
}
|
||||
|
||||
// ── Tab/Esc 快捷审阅 ────────────────────────────────────────────────────
|
||||
|
||||
function handleReviewKeydown(e: KeyboardEvent) {
|
||||
@ -689,6 +785,18 @@ onBeforeUnmount(() => {
|
||||
@reject="handlePopoverReject"
|
||||
@close="reviewClearActiveSegment"
|
||||
/>
|
||||
<!-- AI 结果卡片(translate / summarize) -->
|
||||
<AIResultCard
|
||||
:visible="cardVisible"
|
||||
:title="cardTitle"
|
||||
:content="cardContent"
|
||||
:loading="cardLoading"
|
||||
:style="cardStyle"
|
||||
:t="t"
|
||||
@replace="handleCardReplace"
|
||||
@insert-below="handleCardInsertBelow"
|
||||
@dismiss="handleCardDismiss"
|
||||
/>
|
||||
<!-- Toolbar Toggle -->
|
||||
<button
|
||||
v-if="editorRef && isEditable"
|
||||
|
||||
@ -214,12 +214,159 @@ export function useAICommand(
|
||||
})
|
||||
}
|
||||
|
||||
// ── 卡片模式 AI 动作(不写入编辑器,流式输出到回调) ────────────────────
|
||||
|
||||
/** 卡片模式回调接口 */
|
||||
interface AICardCallbacks {
|
||||
/** 每个 SSE delta chunk */
|
||||
onChunk: (chunk: string) => void
|
||||
/** 流式完成 */
|
||||
onDone: (fullContent: string) => void
|
||||
/** 出错 */
|
||||
onError: (error: string) => void
|
||||
/** 请求发出后触发(显示 thinking) */
|
||||
onThinkingStart?: () => void
|
||||
/** 首个 chunk 到达或出错时触发 */
|
||||
onThinkingEnd?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行 AI 动作,结果通过 SSE 流式输出到回调(不写入编辑器)。
|
||||
* 用于 translate / summarize 等"参考信息"类操作的独立卡片展示。
|
||||
*/
|
||||
async function requestAICardAction(
|
||||
action: AIActionType,
|
||||
params?: Record<string, string>,
|
||||
callbacks?: AICardCallbacks,
|
||||
): Promise<void> {
|
||||
const editor = editorRef.value
|
||||
if (!editor) return
|
||||
|
||||
const selectedText = getSelectedText()
|
||||
if (!selectedText) {
|
||||
callbacks?.onError?.('请先选中要处理的文本')
|
||||
return
|
||||
}
|
||||
|
||||
const config = AI_ACTION_REGISTRY.find(a => a.id === action)
|
||||
if (!config) {
|
||||
callbacks?.onError?.(`未知的 AI 动作: ${action}`)
|
||||
return
|
||||
}
|
||||
|
||||
// 构建提示词
|
||||
let prompt = config.prompt
|
||||
if (action === 'translate' && params?.target_lang) {
|
||||
prompt = prompt.replace('{target_lang}', params.target_lang)
|
||||
}
|
||||
const fullPrompt = prompt + selectedText
|
||||
|
||||
const fullContent = JSON.stringify(editor.getJSON())
|
||||
|
||||
// Thinking 指示器
|
||||
let thinkingActive = false
|
||||
function startThinking() {
|
||||
thinkingActive = true
|
||||
callbacks?.onThinkingStart?.()
|
||||
}
|
||||
function endThinking() {
|
||||
if (!thinkingActive) return
|
||||
thinkingActive = false
|
||||
callbacks?.onThinkingEnd?.()
|
||||
}
|
||||
|
||||
try {
|
||||
startThinking()
|
||||
|
||||
const response = await fetch('/api/action/jingrow.ai.api.editor_ai.block_editor_action', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
prompt: fullPrompt,
|
||||
action,
|
||||
context: {
|
||||
selectedText,
|
||||
fullContent,
|
||||
fieldname: unref(fieldContext?.fieldname) || '',
|
||||
pagetype: unref(fieldContext?.pagetype) || '',
|
||||
pagename: unref(fieldContext?.pagename) || '',
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
let errMsg = `AI 请求失败 (${response.status})`
|
||||
try {
|
||||
const errData = await response.json()
|
||||
errMsg = errData?.message || errMsg
|
||||
} catch { /* ignore */ }
|
||||
endThinking()
|
||||
callbacks?.onError?.(errMsg)
|
||||
return
|
||||
}
|
||||
|
||||
const reader = response.body!.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
let accumulated = ''
|
||||
let currentEvent = ''
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() || ''
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) {
|
||||
currentEvent = ''
|
||||
continue
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('event: ')) {
|
||||
currentEvent = trimmed.slice(7)
|
||||
continue
|
||||
}
|
||||
if (!trimmed.startsWith('data: ')) continue
|
||||
|
||||
const dataStr = trimmed.slice(6)
|
||||
try {
|
||||
const parsed = JSON.parse(dataStr)
|
||||
|
||||
if (currentEvent === 'delta' || parsed.delta) {
|
||||
const chunk = parsed.delta || ''
|
||||
accumulated += chunk
|
||||
// 首个 chunk:结束 thinking
|
||||
endThinking()
|
||||
callbacks?.onChunk?.(chunk)
|
||||
} else if (currentEvent === 'error' || parsed.error) {
|
||||
endThinking()
|
||||
callbacks?.onError?.(parsed.error || 'AI 处理出错')
|
||||
}
|
||||
} catch { /* ignore non-JSON lines */ }
|
||||
}
|
||||
}
|
||||
|
||||
endThinking()
|
||||
if (accumulated) {
|
||||
callbacks?.onDone?.(accumulated)
|
||||
}
|
||||
} catch (e: any) {
|
||||
endThinking()
|
||||
callbacks?.onError?.(e.message || 'AI 调用异常')
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
editorInstanceId,
|
||||
getSelectedText,
|
||||
getContextBefore,
|
||||
requestAIAction,
|
||||
requestAIGeneration,
|
||||
requestAICardAction,
|
||||
syncSelectionToAI,
|
||||
getAIActions,
|
||||
getAIActionsByGroup,
|
||||
|
||||
472
frontend/src/core/features/block_editor/menus/AIResultCard.vue
Normal file
472
frontend/src/core/features/block_editor/menus/AIResultCard.vue
Normal file
@ -0,0 +1,472 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* AIResultCard.vue — Notion 风格独立结果卡片
|
||||
*
|
||||
* 用于 translate / summarize 等"参考信息"类 AI 动作。
|
||||
* 原文保持不变,结果在独立浮动卡片中流式展示。
|
||||
* 用户可自行选择:替换选区、插入到下方、复制、关闭。
|
||||
*/
|
||||
import { ref, computed, watch, nextTick, onBeforeUnmount } from 'vue'
|
||||
import ThinkingDots from '@/shared/components/ThinkingDots.vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
/** 是否显示 */
|
||||
visible?: boolean
|
||||
/** 卡片标题(如 "Translation" / "Summary") */
|
||||
title?: string
|
||||
/** 流式内容(实时更新) */
|
||||
content?: string
|
||||
/** 是否正在加载(thinking 或 streaming 中) */
|
||||
loading?: boolean
|
||||
/** 卡片的固定定位样式 */
|
||||
style?: Record<string, string>
|
||||
/** 国际化函数 */
|
||||
t?: (key: string) => string
|
||||
}>(), {
|
||||
visible: false,
|
||||
title: 'Result',
|
||||
content: '',
|
||||
loading: false,
|
||||
style: () => ({}),
|
||||
t: (key: string) => key,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** 用结果替换选中文本 */
|
||||
replace: []
|
||||
/** 在选区后插入新段落 */
|
||||
'insert-below': []
|
||||
/** 复制到剪贴板 */
|
||||
copy: []
|
||||
/** 关闭卡片 */
|
||||
dismiss: []
|
||||
}>()
|
||||
|
||||
// ── 内部状态 ──────────────────────────────────────────────────────────────
|
||||
|
||||
const cardRef = ref<HTMLElement | null>(null)
|
||||
const copied = ref(false)
|
||||
|
||||
// ── 简单 Markdown 渲染(段落/列表/粗体/斜体/行内代码) ────────────────────
|
||||
|
||||
const renderedHTML = computed(() => {
|
||||
if (!props.content) return ''
|
||||
return simpleMarkdownToHTML(props.content)
|
||||
})
|
||||
|
||||
function simpleMarkdownToHTML(text: string): string {
|
||||
const lines = text.split('\n')
|
||||
const html: string[] = []
|
||||
let inUL = false
|
||||
let inOL = false
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
|
||||
// Close lists if line doesn't continue them
|
||||
if (inUL && !/^[-*]\s/.test(trimmed)) {
|
||||
html.push('</ul>')
|
||||
inUL = false
|
||||
}
|
||||
if (inOL && !/^\d+\.\s/.test(trimmed)) {
|
||||
html.push('</ol>')
|
||||
inOL = false
|
||||
}
|
||||
|
||||
if (!trimmed) {
|
||||
html.push('<br/>')
|
||||
continue
|
||||
}
|
||||
|
||||
// Unordered list
|
||||
const ulMatch = trimmed.match(/^[-*]\s+(.+)$/)
|
||||
if (ulMatch) {
|
||||
if (!inUL) { html.push('<ul>'); inUL = true }
|
||||
html.push(`<li>${inlineFormat(ulMatch[1])}</li>`)
|
||||
continue
|
||||
}
|
||||
|
||||
// Ordered list
|
||||
const olMatch = trimmed.match(/^\d+\.\s+(.+)$/)
|
||||
if (olMatch) {
|
||||
if (!inOL) { html.push('<ol>'); inOL = true }
|
||||
html.push(`<li>${inlineFormat(olMatch[1])}</li>`)
|
||||
continue
|
||||
}
|
||||
|
||||
// Normal paragraph
|
||||
html.push(`<p>${inlineFormat(trimmed)}</p>`)
|
||||
}
|
||||
|
||||
if (inUL) html.push('</ul>')
|
||||
if (inOL) html.push('</ol>')
|
||||
|
||||
return html.join('')
|
||||
}
|
||||
|
||||
/** 行内格式:bold, italic, inline code */
|
||||
function inlineFormat(text: string): string {
|
||||
return text
|
||||
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
}
|
||||
|
||||
// ── 复制操作 ──────────────────────────────────────────────────────────────
|
||||
|
||||
async function handleCopy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(props.content)
|
||||
copied.value = true
|
||||
setTimeout(() => { copied.value = false }, 1500)
|
||||
} catch {
|
||||
// fallback
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = props.content
|
||||
ta.style.position = 'fixed'
|
||||
ta.style.opacity = '0'
|
||||
document.body.appendChild(ta)
|
||||
ta.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(ta)
|
||||
copied.value = true
|
||||
setTimeout(() => { copied.value = false }, 1500)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 视口安全定位 ──────────────────────────────────────────────────────────
|
||||
|
||||
const adjustedStyle = ref<Record<string, string>>({})
|
||||
|
||||
watch(() => props.visible, async (visible) => {
|
||||
if (!visible) {
|
||||
adjustedStyle.value = {}
|
||||
return
|
||||
}
|
||||
await nextTick()
|
||||
const el = cardRef.value
|
||||
if (!el) {
|
||||
adjustedStyle.value = { ...props.style }
|
||||
return
|
||||
}
|
||||
|
||||
// 测量自然尺寸
|
||||
adjustedStyle.value = {
|
||||
position: 'fixed',
|
||||
top: '0px',
|
||||
left: '-9999px',
|
||||
opacity: '0',
|
||||
pointerEvents: 'none',
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
const elW = el.offsetWidth
|
||||
const elH = el.offsetHeight
|
||||
|
||||
const PAD = 16
|
||||
const vw = window.innerWidth
|
||||
const vh = window.innerHeight
|
||||
|
||||
const anchorTop = parseFloat(props.style.top) || 0
|
||||
const anchorLeft = parseFloat(props.style.left) || 0
|
||||
|
||||
// 水平:确保不溢出
|
||||
let left = anchorLeft
|
||||
if (left + elW > vw - PAD) left = vw - PAD - elW
|
||||
if (left < PAD) left = PAD
|
||||
|
||||
// 垂直:显示在锚点下方,空间不足时显示在上方
|
||||
let top = anchorTop + 8
|
||||
if (top + elH > vh - PAD) top = anchorTop - elH - 8
|
||||
if (top < PAD) top = PAD
|
||||
|
||||
adjustedStyle.value = {
|
||||
position: 'fixed',
|
||||
top: `${top}px`,
|
||||
left: `${left}px`,
|
||||
}
|
||||
})
|
||||
|
||||
// ── 点击外部关闭 ──────────────────────────────────────────────────────────
|
||||
|
||||
function handleMousedown(e: MouseEvent) {
|
||||
if (!props.visible) return
|
||||
const el = cardRef.value
|
||||
if (!el) return
|
||||
const target = e.target as Node
|
||||
if (!el.contains(target)) {
|
||||
emit('dismiss')
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleMousedown, true)
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('mousedown', handleMousedown, true)
|
||||
})
|
||||
|
||||
// ── Esc 键关闭 ───────────────────────────────────────────────────────────
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && props.visible) {
|
||||
emit('dismiss')
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="ai-card">
|
||||
<div
|
||||
v-if="visible"
|
||||
ref="cardRef"
|
||||
class="ai-result-card"
|
||||
:style="adjustedStyle"
|
||||
>
|
||||
<!-- 头部:标题 + 关闭按钮 -->
|
||||
<div class="arc-header">
|
||||
<div class="arc-title">
|
||||
<svg class="arc-title-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z" />
|
||||
<path d="M2 17l10 5 10-5" />
|
||||
<path d="M2 12l10 5 10-5" />
|
||||
</svg>
|
||||
{{ t(title) }}
|
||||
</div>
|
||||
<button
|
||||
class="arc-close"
|
||||
:title="t('关闭')"
|
||||
@mousedown.prevent="$emit('dismiss')"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 内容区:流式文本 -->
|
||||
<div class="arc-body">
|
||||
<!-- Thinking 状态 -->
|
||||
<div v-if="loading && !content" class="arc-thinking">
|
||||
<ThinkingDots :dot-size="5" :gap="3" color="#8b5cf6" />
|
||||
</div>
|
||||
<!-- 流式内容 -->
|
||||
<div
|
||||
v-else
|
||||
class="arc-content"
|
||||
v-html="renderedHTML"
|
||||
/>
|
||||
<!-- Streaming 中追加 thinking -->
|
||||
<div v-if="loading && content" class="arc-streaming-dots">
|
||||
<ThinkingDots :dot-size="4" :gap="2" color="#9ca3af" :duration="1" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮栏 -->
|
||||
<div v-if="content" class="arc-actions">
|
||||
<button
|
||||
class="arc-btn arc-btn-secondary"
|
||||
:title="t('替换选中文本')"
|
||||
@mousedown.prevent="$emit('replace')"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
|
||||
</svg>
|
||||
{{ t('替换') }}
|
||||
</button>
|
||||
<button
|
||||
class="arc-btn arc-btn-secondary"
|
||||
:title="t('插入到下方')"
|
||||
@mousedown.prevent="$emit('insert-below')"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<polyline points="19 12 12 19 5 12" />
|
||||
</svg>
|
||||
{{ t('插入下方') }}
|
||||
</button>
|
||||
<button
|
||||
class="arc-btn arc-btn-secondary"
|
||||
:title="t('复制')"
|
||||
@mousedown.prevent="handleCopy"
|
||||
>
|
||||
<svg v-if="!copied" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||||
</svg>
|
||||
<svg v-else width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#16a34a" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
{{ copied ? t('已复制') : t('复制') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ── 卡片容器 ── */
|
||||
.ai-result-card {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
width: clamp(320px, 44vw, 480px);
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 30px rgba(0,0,0,0.12), 0 2px 8px rgba(0,0,0,0.04);
|
||||
padding: 14px;
|
||||
animation: ai-card-in 0.2s ease-out both;
|
||||
}
|
||||
|
||||
@keyframes ai-card-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 头部 ── */
|
||||
.arc-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.arc-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
.arc-title-icon {
|
||||
color: #8b5cf6;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.arc-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #9ca3af;
|
||||
cursor: pointer;
|
||||
transition: all 0.12s ease;
|
||||
}
|
||||
.arc-close:hover {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
/* ── 内容区 ── */
|
||||
.arc-body {
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 10px;
|
||||
padding: 10px 12px;
|
||||
background: #f9fafb;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #f3f4f6;
|
||||
}
|
||||
.arc-thinking {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 48px;
|
||||
}
|
||||
.arc-content {
|
||||
font-size: 14px;
|
||||
line-height: 1.65;
|
||||
color: #1f2937;
|
||||
word-break: break-word;
|
||||
}
|
||||
.arc-content :deep(p) {
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
.arc-content :deep(p:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.arc-content :deep(ul),
|
||||
.arc-content :deep(ol) {
|
||||
padding-left: 20px;
|
||||
margin: 4px 0;
|
||||
}
|
||||
.arc-content :deep(li) {
|
||||
margin: 2px 0;
|
||||
}
|
||||
.arc-content :deep(code) {
|
||||
background: #f1f5f9;
|
||||
border-radius: 3px;
|
||||
padding: 1px 5px;
|
||||
font-size: 0.875em;
|
||||
color: #cf222e;
|
||||
}
|
||||
.arc-content :deep(strong) {
|
||||
font-weight: 600;
|
||||
}
|
||||
.arc-streaming-dots {
|
||||
display: inline-flex;
|
||||
margin-left: 4px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* ── 操作按钮栏 ── */
|
||||
.arc-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.arc-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 5px 12px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: all 0.12s ease;
|
||||
line-height: 1;
|
||||
}
|
||||
.arc-btn:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
.arc-btn-secondary {
|
||||
background: #f3f4f6;
|
||||
border-color: #e5e7eb;
|
||||
color: #374151;
|
||||
}
|
||||
.arc-btn-secondary:hover {
|
||||
background: #e5e7eb;
|
||||
border-color: #d1d5db;
|
||||
}
|
||||
|
||||
/* ── 过渡动画 ── */
|
||||
.ai-card-enter-active,
|
||||
.ai-card-leave-active {
|
||||
transition: opacity 0.18s ease, transform 0.18s ease;
|
||||
}
|
||||
.ai-card-enter-from,
|
||||
.ai-card-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
</style>
|
||||
Loading…
x
Reference in New Issue
Block a user