feat: AI Tools 统一聊天优先界面重构 — WorkBuddy 风格
Phase 1: 共享组件 + 全页面转换 共享组件 (5 个): - ChatLayout.vue: 主布局外壳,左侧任务栏 + 聊天区域 + 底部输入栏 - ChatInputBar.vue: 统一输入框,支持文件/语音/引用附件,自动高度 - ChatMessage.vue: 消息气泡,支持用户/AI/任务计划/文件/结构化数据 - HistorySidebar.vue: 对话历史侧边栏,按时间分组,支持搜索/重命名/删除 - FileAttachment.vue: 文件上传组件,拖放/点击,支持多类型 页面转换 (6 个): - SmartAssistantPage: 对话/任务拆解/文案生成/批量提取模式 - DocumentTranslatePage: 翻译配置面板 + 翻译结果卡片 - AudioTranscribePage: 音频转录 + 识别结果展示 - CopyProofreadingPage: 校对配置 + 问题列表 + 评分条 - BatchExtractPage: 字段提取 + 表格结果 - ContractReviewPage: 审查配置 + 评分环 + 风险等级列表 布局特点: - 顶部分类 pill (文档处理/金融服务/数据分析/个人工作台/幻灯片) - 居中输入框,大尺寸,拖放文件 - 最佳实践推荐卡片 - 左侧任务栏 (助理/项目/专家/定时任务/资料库) - 消息列表 + 底部输入 (聊天模式) Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
<template>
|
||||
<div class="chat-input-bar" :class="{ focused }">
|
||||
<textarea
|
||||
ref="textareaRef"
|
||||
v-model="localValue"
|
||||
class="input-textarea"
|
||||
:placeholder="placeholder"
|
||||
:rows="rowCount"
|
||||
@focus="focused = true"
|
||||
@blur="focused = false"
|
||||
@keydown.enter.exact.prevent="handleEnter"
|
||||
@input="handleInput"
|
||||
/>
|
||||
<div class="input-bottom-bar">
|
||||
<div class="input-left-actions">
|
||||
<button
|
||||
v-for="action in actions"
|
||||
:key="action.key"
|
||||
class="input-action-btn"
|
||||
:title="action.title"
|
||||
@click="action.handler && action.handler()"
|
||||
>
|
||||
{{ action.icon }}
|
||||
</button>
|
||||
<slot name="left-actions"></slot>
|
||||
</div>
|
||||
<div class="input-right-actions">
|
||||
<slot name="right-actions">
|
||||
<span v-if="quickMode" class="quick-mode-btn" @click="emit('quickMode')">⚡ 快速 ▾</span>
|
||||
</slot>
|
||||
<button
|
||||
class="send-circle"
|
||||
:class="{ disabled: !canSend }"
|
||||
@click="handleSend"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M22 2L11 13"/>
|
||||
<path d="M22 2L15 22L11 13L2 9L22 2Z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '描述你的任务,或直接粘贴内容...',
|
||||
},
|
||||
rowCount: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
canSend: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showSend: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showActions: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
actions: {
|
||||
type: Array,
|
||||
default: () => [
|
||||
{ key: 'attach', icon: '📎', title: '文件附件', handler: null },
|
||||
{ key: 'voice', icon: '🎤', title: '语音输入', handler: null },
|
||||
{ key: 'reference', icon: '📑', title: '引用', handler: null },
|
||||
],
|
||||
},
|
||||
quickMode: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
focusOnMount: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'send', 'quickMode', 'focus'])
|
||||
|
||||
const localValue = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val),
|
||||
})
|
||||
|
||||
const focused = ref(false)
|
||||
const textareaRef = ref(null)
|
||||
|
||||
function handleInput() {
|
||||
// Auto-resize textarea
|
||||
const el = textareaRef.value
|
||||
if (el) {
|
||||
el.style.height = 'auto'
|
||||
el.style.height = Math.max(48, el.scrollHeight) + 'px'
|
||||
}
|
||||
}
|
||||
|
||||
function handleSend() {
|
||||
if (!props.canSend) return
|
||||
emit('send', localValue.value)
|
||||
}
|
||||
|
||||
function handleEnter() {
|
||||
// Shift+Enter for newline, Enter for send
|
||||
emit('send', localValue.value)
|
||||
}
|
||||
|
||||
function focus() {
|
||||
textareaRef.value?.focus()
|
||||
emit('focus')
|
||||
}
|
||||
|
||||
defineExpose({ focus })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chat-input-bar {
|
||||
position: relative;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.input-textarea {
|
||||
width: 100%;
|
||||
min-height: 60px;
|
||||
max-height: 300px;
|
||||
border: 1px solid #e0e6ed;
|
||||
border-radius: 16px;
|
||||
padding: 12px 18px 14px;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
font-family: inherit;
|
||||
color: #303133;
|
||||
resize: none;
|
||||
outline: none;
|
||||
background: #fff;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.input-textarea:focus {
|
||||
border-color: #409eff;
|
||||
box-shadow: 0 2px 12px rgba(64,158,255,0.08);
|
||||
}
|
||||
|
||||
.input-textarea::placeholder {
|
||||
color: #c0c8d4;
|
||||
}
|
||||
|
||||
.input-bottom-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-top: 10px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.input-left-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.input-action-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #909399;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.input-action-btn:hover {
|
||||
background: #f5f7fa;
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.input-right-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.quick-mode-btn {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.quick-mode-btn:hover {
|
||||
background: #f5f7fa;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.send-circle {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: #409eff;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
box-shadow: 0 2px 8px rgba(64,158,255,0.3);
|
||||
}
|
||||
|
||||
.send-circle:hover {
|
||||
transform: scale(1.08);
|
||||
box-shadow: 0 4px 12px rgba(64,158,255,0.4);
|
||||
}
|
||||
|
||||
.send-circle:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.send-circle.disabled {
|
||||
background: #dcdfe6;
|
||||
box-shadow: none;
|
||||
cursor: default;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* Mobile: compact padding */
|
||||
@media (max-width: 768px) {
|
||||
.chat-input-bar {
|
||||
padding: 0 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,417 @@
|
||||
<template>
|
||||
<div class="chat-layout" :class="{ 'sidebar-collapsed': sidebarCollapsed }">
|
||||
<!-- Left sidebar: conversation history -->
|
||||
<aside class="chat-sidebar" :class="{ collapsed: sidebarCollapsed }">
|
||||
<div class="sidebar-header">
|
||||
<button class="new-chat-btn" @click="$emit('newChat')">
|
||||
<span class="btn-icon">+</span>
|
||||
<span class="btn-text" :class="{ 'hide': sidebarCollapsed }">新建任务</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-nav">
|
||||
<div class="nav-item" :class="{ active: activeNavItem === 'assistant' }" @click="$emit('navChange', 'assistant')">
|
||||
<span class="nav-icon">💬</span>
|
||||
<span class="nav-label" :class="{ 'hide': sidebarCollapsed }">助理</span>
|
||||
</div>
|
||||
<div class="nav-item" :class="{ active: activeNavItem === 'project' }" @click="$emit('navChange', 'project')">
|
||||
<span class="nav-icon">📂</span>
|
||||
<span class="nav-label" :class="{ 'hide': sidebarCollapsed }">项目</span>
|
||||
</div>
|
||||
<div class="nav-item" @click="$emit('navChange', 'expert')">
|
||||
<span class="nav-icon">🧠</span>
|
||||
<span class="nav-label" :class="{ 'hide': sidebarCollapsed }">专家·技能·连接器</span>
|
||||
</div>
|
||||
<div class="nav-item" @click="$emit('navChange', 'scheduled')">
|
||||
<span class="nav-icon">⏰</span>
|
||||
<span class="nav-label" :class="{ 'hide': sidebarCollapsed }">定时任务</span>
|
||||
</div>
|
||||
<div class="nav-item" @click="$emit('navChange', 'library')">
|
||||
<span class="nav-icon">📚</span>
|
||||
<span class="nav-label" :class="{ 'hide': sidebarCollapsed }">资料库</span>
|
||||
</div>
|
||||
<div class="nav-divider"></div>
|
||||
<div class="nav-item" @click="$emit('navChange', 'more')">
|
||||
<span class="nav-icon">⋯</span>
|
||||
<span class="nav-label" :class="{ 'hide': sidebarCollapsed }">更多</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section">
|
||||
<div class="section-header" @click="toggleHistory">
|
||||
<span class="section-title">任务</span>
|
||||
<span class="section-count" :class="{ 'hide': sidebarCollapsed }">{{ historyCount }} ▾</span>
|
||||
</div>
|
||||
<div class="task-list" v-if="!historyCollapsed">
|
||||
<div
|
||||
v-for="task in taskItems"
|
||||
:key="task.id"
|
||||
class="task-item"
|
||||
:class="{ active: task.id === activeTaskId }"
|
||||
@click="$emit('selectTask', task.id)"
|
||||
>
|
||||
{{ task.title }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="show-more" v-if="!historyCollapsed" @click="$emit('showAllTasks')">
|
||||
查看更多 ({{ historyCount }})
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-footer" :class="{ 'hide': sidebarCollapsed }">
|
||||
<div class="user-avatar">伯</div>
|
||||
<div class="user-info">
|
||||
<span class="user-name">明伯-工作</span>
|
||||
<span class="user-role">内网</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Sidebar toggle handle -->
|
||||
<div class="sidebar-toggle" @click="sidebarCollapsed = !sidebarCollapsed">
|
||||
{{ sidebarCollapsed ? '▷' : '◁' }}
|
||||
</div>
|
||||
|
||||
<!-- Main chat area -->
|
||||
<main class="chat-main">
|
||||
<!-- Compact input at bottom, scrollable messages above -->
|
||||
<div class="chat-body" :class="{ 'no-messages': !hasMessages }">
|
||||
<!-- Welcome state -->
|
||||
<div v-if="!hasMessages" class="welcome-state">
|
||||
<slot name="welcome">
|
||||
<div class="welcome-title">AI Tools, 我帮你</div>
|
||||
<div class="welcome-subtitle">今天帮你做些什么? @ 引用对话文件,/ 调用技能与指令</div>
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<!-- Message list -->
|
||||
<div class="message-list" ref="messageList">
|
||||
<slot name="messages"></slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom input bar -->
|
||||
<div class="chat-input-area">
|
||||
<slot name="input"></slot>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
activeNavItem: {
|
||||
type: String,
|
||||
default: 'assistant',
|
||||
},
|
||||
activeTaskId: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
historyCount: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
taskItems: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
hasMessages: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'newChat',
|
||||
'navChange',
|
||||
'selectTask',
|
||||
'showAllTasks',
|
||||
])
|
||||
|
||||
const sidebarCollapsed = ref(localStorage.getItem('chat-sidebar-collapsed') !== '0')
|
||||
const historyCollapsed = ref(false)
|
||||
|
||||
function toggleHistory() {
|
||||
historyCollapsed.value = !historyCollapsed.value
|
||||
}
|
||||
|
||||
import { onMounted, onBeforeUnmount } from 'vue'
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', handleResize)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
})
|
||||
|
||||
function handleResize() {
|
||||
if (window.innerWidth < 768) {
|
||||
sidebarCollapsed.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const messageList = ref(null)
|
||||
defineExpose({ messageList })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chat-layout {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: #f0f2f5;
|
||||
}
|
||||
|
||||
/* ===== SIDEBAR ===== */
|
||||
.chat-sidebar {
|
||||
width: 220px;
|
||||
min-width: 220px;
|
||||
background: #f7f8fa;
|
||||
border-right: 1px solid #e8ecf1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
transition: width 0.2s, min-width 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.chat-sidebar.collapsed {
|
||||
width: 60px;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 14px 12px 10px;
|
||||
}
|
||||
.new-chat-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: linear-gradient(135deg, #409eff 0%, #2b63d9 100%);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.2s, transform 0.15s;
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.new-chat-btn:hover {
|
||||
box-shadow: 0 4px 12px rgba(64,158,255,0.35);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.btn-icon { font-size: 16px; font-weight: 700; }
|
||||
.btn-text { overflow: hidden; white-space: nowrap; }
|
||||
|
||||
.sidebar-nav {
|
||||
padding: 4px 10px;
|
||||
}
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 9px 10px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
font-size: 13px;
|
||||
color: #303133;
|
||||
}
|
||||
.nav-item:hover { background: #eef1f6; }
|
||||
.nav-item.active { background: #e1effe; color: #409eff; font-weight: 600; }
|
||||
.nav-icon { font-size: 15px; width: 20px; text-align: center; flex-shrink: 0; }
|
||||
.nav-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.nav-item.hide .nav-label { display: none; }
|
||||
|
||||
.nav-divider {
|
||||
height: 1px;
|
||||
background: #eef1f6;
|
||||
margin: 6px 10px;
|
||||
}
|
||||
|
||||
.sidebar-section {
|
||||
padding: 8px 10px 4px;
|
||||
}
|
||||
.section-header {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #909399;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
padding: 4px 10px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.section-header:hover { background: #eef1f6; border-radius: 4px; }
|
||||
.section-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.section-count { font-size: 11px; color: #909399; font-weight: 400; flex-shrink: 0; }
|
||||
.section-header.hide .section-count { display: none; }
|
||||
|
||||
.task-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.task-item {
|
||||
padding: 7px 10px 7px 12px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
line-height: 1.5;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.task-item:hover { background: #eef1f6; }
|
||||
.task-item.active { background: #e1effe; color: #409eff; font-weight: 600; }
|
||||
.task-item.hide { display: none; }
|
||||
|
||||
.show-more {
|
||||
font-size: 11px;
|
||||
color: #909399;
|
||||
padding: 6px 10px 6px 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.show-more:hover { color: #606266; }
|
||||
|
||||
.sidebar-footer {
|
||||
margin-top: auto;
|
||||
padding: 12px 14px;
|
||||
border-top: 1px solid #eef1f6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.sidebar-footer.hide { display: none; }
|
||||
.user-avatar {
|
||||
width: 28px; height: 28px;
|
||||
border-radius: 50%;
|
||||
background: #409eff;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.user-info {
|
||||
font-size: 12px;
|
||||
color: #303133;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.user-info .user-role {
|
||||
font-size: 11px;
|
||||
color: #909399;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* ===== TOGGLE HANDLE ===== */
|
||||
.sidebar-toggle {
|
||||
width: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: #c0c8d4;
|
||||
font-size: 10px;
|
||||
flex-shrink: 0;
|
||||
background: transparent;
|
||||
border-left: 1px solid #e8ecf1;
|
||||
transition: color 0.15s;
|
||||
position: relative;
|
||||
align-self: stretch;
|
||||
margin-top: 100px;
|
||||
}
|
||||
.sidebar-toggle:hover { color: #909399; }
|
||||
|
||||
/* ===== MAIN CHAT AREA ===== */
|
||||
.chat-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chat-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-bottom: 8px;
|
||||
transition: padding-bottom 0.2s;
|
||||
}
|
||||
.chat-body.no-messages {
|
||||
padding-bottom: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Welcome state */
|
||||
.welcome-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 48px 24px 32px;
|
||||
text-align: center;
|
||||
}
|
||||
.welcome-title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #1f2d3d;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.welcome-subtitle {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.message-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
padding: 0 24px;
|
||||
width: 100%;
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* ===== INPUT AREA ===== */
|
||||
.chat-input-area {
|
||||
border-top: 1px solid #eef1f6;
|
||||
background: #fff;
|
||||
padding: 12px 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ===== RESPONSIVE ===== */
|
||||
@media (max-width: 900px) {
|
||||
.chat-sidebar {
|
||||
width: 60px;
|
||||
min-width: 60px;
|
||||
}
|
||||
.nav-label,
|
||||
.task-item,
|
||||
.show-more,
|
||||
.section-title,
|
||||
.user-info,
|
||||
.btn-text {
|
||||
display: none;
|
||||
}
|
||||
.nav-item { justify-content: center; padding: 10px; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,365 @@
|
||||
<template>
|
||||
<div class="chat-message" :class="[role, { 'has-card': hasCard }]">
|
||||
<div class="msg-avatar" :class="role">
|
||||
<slot name="avatar">
|
||||
{{ role === 'user' ? '👤' : '🤖' }}
|
||||
</slot>
|
||||
</div>
|
||||
<div class="msg-content">
|
||||
<!-- Text content -->
|
||||
<div v-if="content" class="msg-text" :class="{ 'user-text': role === 'user' }" v-html="renderedContent"></div>
|
||||
|
||||
<!-- Task plan display -->
|
||||
<div v-if="taskPlan" class="msg-task-plan">
|
||||
<div class="plan-title">{{ taskPlan.title }}</div>
|
||||
<div v-for="step in taskPlan.steps" :key="step.id" class="plan-step">
|
||||
<span class="step-num">{{ step.order }}</span>
|
||||
<span class="step-title">{{ step.title }}</span>
|
||||
<span class="step-desc">{{ step.desc }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- File card -->
|
||||
<div v-if="file" class="msg-file-card">
|
||||
<div class="file-preview">
|
||||
<span class="file-icon">{{ file.icon || '📄' }}</span>
|
||||
<div class="file-info">
|
||||
<div class="file-name">{{ file.name }}</div>
|
||||
<div class="file-meta">{{ file.meta }}</div>
|
||||
</div>
|
||||
<button class="file-action" @click="$emit('fileAction', file)">{{ file.action || '下载' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Structured data (table, scores, etc.) -->
|
||||
<div v-if="structured" class="msg-structured">
|
||||
<slot name="structured-content">
|
||||
<pre class="structured-content">{{ structured }}</pre>
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<!-- Actions bar -->
|
||||
<div v-if="showActions" class="msg-actions">
|
||||
<slot name="actions">
|
||||
<button v-if="showCopy" @click="$emit('copy')">📋 复制</button>
|
||||
<button v-if="showDownload" @click="$emit('download')">⬇️ 下载</button>
|
||||
<button v-if="showContinue" @click="$emit('continue')">🔄 继续</button>
|
||||
<button v-if="showFollowUp" @click="$emit('followUp')">💬 追问</button>
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<!-- Timestamp -->
|
||||
<div v-if="timestamp" class="msg-time">{{ timestamp }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
role: {
|
||||
type: String,
|
||||
default: 'user',
|
||||
validator: (v) => ['user', 'assistant'].includes(v),
|
||||
},
|
||||
content: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
taskPlan: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
file: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
structured: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
timestamp: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
showActions: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showCopy: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showDownload: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showContinue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showFollowUp: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['copy', 'download', 'continue', 'followUp', 'fileAction'])
|
||||
|
||||
const renderedContent = computed(() => {
|
||||
if (!props.content) return ''
|
||||
// Basic markdown-like rendering
|
||||
return props.content
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
.replace(/`(.*?)`/g, '<code>$1</code>')
|
||||
.replace(/\n/g, '<br>')
|
||||
})
|
||||
|
||||
const hasCard = computed(() => {
|
||||
return props.file || props.structured
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chat-message {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 4px 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chat-message.user {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.chat-message.has-card {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chat-message.has-card .msg-avatar {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.chat-message.has-card .msg-content {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.msg-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: #f0f0f0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.msg-avatar.user {
|
||||
background: #f5f7fa;
|
||||
border: 1px solid #e8ecf1;
|
||||
}
|
||||
.msg-avatar.assistant {
|
||||
background: linear-gradient(135deg, #409eff, #63b3ff);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.msg-content {
|
||||
max-width: 85%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chat-message.user .msg-content {
|
||||
max-width: 70%;
|
||||
}
|
||||
|
||||
.msg-text {
|
||||
background: #f5f5f5;
|
||||
padding: 10px 16px;
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.user-text {
|
||||
background: #2b63d9;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.msg-task-plan {
|
||||
margin-top: 10px;
|
||||
background: #f0f7ff;
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #d6e8ff;
|
||||
}
|
||||
|
||||
.plan-title {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: #2b63d9;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.plan-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.plan-step:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.step-num {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: #2b63d9;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.step-title {
|
||||
font-weight: 600;
|
||||
color: #1f2d3d;
|
||||
}
|
||||
|
||||
.step-desc {
|
||||
color: #6b7785;
|
||||
}
|
||||
|
||||
/* File card */
|
||||
.msg-file-card {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.file-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: #f8f9fb;
|
||||
border: 1px solid #e8ecf1;
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
font-size: 28px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-meta {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.file-action {
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
background: #409eff;
|
||||
color: #fff;
|
||||
border: none;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-action:hover {
|
||||
background: #2b63d9;
|
||||
}
|
||||
|
||||
/* Structured data */
|
||||
.msg-structured {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.structured-content {
|
||||
background: #f8f9fb;
|
||||
border: 1px solid #e8ecf1;
|
||||
border-radius: 10px;
|
||||
padding: 14px;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
overflow-x: auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
.msg-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.msg-actions button {
|
||||
background: #f8f9fb;
|
||||
border: 1px solid #e8ecf1;
|
||||
border-radius: 6px;
|
||||
padding: 5px 10px;
|
||||
font-size: 12px;
|
||||
color: #6b7785;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.msg-actions button:hover {
|
||||
background: #eef1f6;
|
||||
}
|
||||
|
||||
.msg-time {
|
||||
font-size: 11px;
|
||||
color: #909399;
|
||||
margin-top: 4px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
/* Mobile responsive */
|
||||
@media (max-width: 768px) {
|
||||
.msg-content {
|
||||
max-width: 90% !important;
|
||||
}
|
||||
|
||||
.msg-actions {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.msg-actions button {
|
||||
padding: 4px 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,263 @@
|
||||
<template>
|
||||
<div class="file-attachment">
|
||||
<!-- Upload trigger -->
|
||||
<div
|
||||
class="upload-trigger"
|
||||
:class="{ dragover, hasFiles } }"
|
||||
@drop.prevent="handleDrop"
|
||||
@dragover.prevent="dragover = true"
|
||||
@dragleave="dragover = false"
|
||||
@click="triggerFileInput"
|
||||
>
|
||||
<div v-if="!hasFiles" class="upload-placeholder">
|
||||
<span class="upload-icon">📎</span>
|
||||
<span class="upload-text">拖放文件到这里,或点击上传</span>
|
||||
<span class="upload-hint">{{ acceptHint }}</span>
|
||||
</div>
|
||||
<div v-else class="file-list">
|
||||
<div
|
||||
v-for="(file, idx) in files"
|
||||
:key="idx"
|
||||
class="file-item"
|
||||
>
|
||||
<span class="file-icon">{{ getFileIcon(file) }}</span>
|
||||
<div class="file-info">
|
||||
<div class="file-name">{{ file.name }}</div>
|
||||
<div class="file-size">{{ formatFileSize(file.size) }}</div>
|
||||
</div>
|
||||
<button class="file-remove" @click.stop="removeFile(file)">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hidden file input -->
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
:accept="accept"
|
||||
multiple
|
||||
style="display:none"
|
||||
@change="handleFileChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
accept: {
|
||||
type: String,
|
||||
default: '.txt,.docx,.pdf,.pptx,.xlsx,.doc',
|
||||
},
|
||||
maxSize: {
|
||||
type: Number,
|
||||
default: 50 * 1024 * 1024, // 50MB
|
||||
},
|
||||
maxFiles: {
|
||||
type: Number,
|
||||
default: 5,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:files', 'change'])
|
||||
|
||||
const files = ref([])
|
||||
const dragover = ref(false)
|
||||
const fileInput = ref(null)
|
||||
|
||||
const acceptHint = computed(() => {
|
||||
const exts = props.accept.split(',').map(e => e.replace('.', '').toUpperCase()).join(', ')
|
||||
return `支持 ${exts},最大 50MB`
|
||||
})
|
||||
|
||||
const hasFiles = computed(() => files.value.length > 0)
|
||||
|
||||
function triggerFileInput() {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
function handleFileChange(event) {
|
||||
const inputFiles = Array.from(event.target.files)
|
||||
addFiles(inputFiles)
|
||||
event.target.value = '' // Reset for re-upload same file
|
||||
}
|
||||
|
||||
function handleDrop(event) {
|
||||
dragover.value = false
|
||||
const droppedFiles = Array.from(event.dataTransfer.files)
|
||||
addFiles(droppedFiles)
|
||||
}
|
||||
|
||||
function addFiles(newFiles) {
|
||||
// Filter by size
|
||||
const validFiles = newFiles.filter(f => f.size <= props.maxSize)
|
||||
|
||||
// Update
|
||||
files.value = [...files.value, ...validFiles].slice(0, props.maxFiles)
|
||||
|
||||
// Notify
|
||||
emit('change', files.value)
|
||||
emit('update:files', files.value)
|
||||
}
|
||||
|
||||
function removeFile(file) {
|
||||
files.value = files.value.filter(f => f !== file)
|
||||
emit('change', files.value)
|
||||
emit('update:files', files.value)
|
||||
}
|
||||
|
||||
function getFileIcon(file) {
|
||||
const ext = file.name.split('.').pop().toLowerCase()
|
||||
const icons = {
|
||||
txt: '📝',
|
||||
docx: '📄',
|
||||
doc: '📄',
|
||||
pdf: '📑',
|
||||
pptx: '📊',
|
||||
xlsx: '📈',
|
||||
csv: '📊',
|
||||
mp3: '🎵',
|
||||
wav: '🎵',
|
||||
m4a: '🎵',
|
||||
}
|
||||
return icons[ext] || '📄'
|
||||
}
|
||||
|
||||
function formatFileSize(bytes) {
|
||||
if (bytes === 0) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024))
|
||||
return (bytes / Math.pow(1024, i)).toFixed(1) + ' ' + units[i]
|
||||
}
|
||||
|
||||
function clearFiles() {
|
||||
files.value = []
|
||||
emit('change', [])
|
||||
emit('update:files', [])
|
||||
}
|
||||
|
||||
defineExpose({ clearFiles, files })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.file-attachment {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.upload-trigger {
|
||||
border: 2px dashed #e0e6ed;
|
||||
border-radius: 12px;
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
background: #fafbfc;
|
||||
}
|
||||
|
||||
.upload-trigger:hover {
|
||||
border-color: #409eff;
|
||||
background: #f0f7ff;
|
||||
}
|
||||
|
||||
.upload-trigger.dragover {
|
||||
border-color: #409eff;
|
||||
background: #e1effe;
|
||||
box-shadow: 0 0 0 3px rgba(64,158,255,0.1);
|
||||
}
|
||||
|
||||
.upload-trigger.has-files {
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.upload-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.upload-text {
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.upload-hint {
|
||||
font-size: 11px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.file-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: #f8f9fb;
|
||||
border: 1px solid #e8ecf1;
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-size {
|
||||
color: #909399;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.file-remove {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: #909399;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.file-remove:hover {
|
||||
background: #eef1f6;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
/* Mobile */
|
||||
@media (max-width: 768px) {
|
||||
.file-item {
|
||||
max-width: 160px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,370 @@
|
||||
<template>
|
||||
<div class="history-sidebar" :class="{ collapsed }">
|
||||
<div class="sidebar-top">
|
||||
<div class="search-box">
|
||||
<span class="search-icon">🔍</span>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
class="search-input"
|
||||
placeholder="搜索对话..."
|
||||
@input="$emit('search', searchQuery)"
|
||||
/>
|
||||
</div>
|
||||
<button class="new-chat-btn" @click="$emit('newChat')">
|
||||
<span class="btn-icon">+</span>
|
||||
<span class="btn-text" :class="{ 'hide': collapsed }">新建</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Time-grouped history -->
|
||||
<div class="history-groups">
|
||||
<div
|
||||
v-for="group in groupedHistory"
|
||||
:key="group.key"
|
||||
class="history-group"
|
||||
:class="{ collapsed: group._collapsed }"
|
||||
>
|
||||
<div class="group-header" @click="group._collapsed = !group._collapsed">
|
||||
<span class="group-title">{{ group.label }}</span>
|
||||
<span class="group-count">{{ group.items.length }}</span>
|
||||
</div>
|
||||
<div class="group-items">
|
||||
<div
|
||||
v-for="item in group.items"
|
||||
:key="item.id"
|
||||
class="history-item"
|
||||
:class="{ active: item.id === activeId }"
|
||||
@click="$emit('select', item.id)"
|
||||
>
|
||||
<span class="item-icon">{{ item.toolIcon || '💬' }}</span>
|
||||
<div class="item-info">
|
||||
<div class="item-title">{{ item.title }}</div>
|
||||
<div class="item-preview">{{ item.preview }}</div>
|
||||
</div>
|
||||
<div class="item-actions" @click.stop>
|
||||
<button class="action-btn rename" title="重命名" @click="$emit('rename', item)">✏️</button>
|
||||
<button class="action-btn delete" title="删除" @click="$emit('delete', item)">🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
activeId: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
collapsed: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
historyItems: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
toolIcons: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
'smart-assistant': '💬',
|
||||
'document-translate': '📝',
|
||||
'copy-proofreading': '✏️',
|
||||
'audio-transcribe': '🎤',
|
||||
'batch-extract': '📊',
|
||||
'contract-review': '📋',
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'update:modelValue',
|
||||
'newChat',
|
||||
'select',
|
||||
'rename',
|
||||
'delete',
|
||||
'search',
|
||||
])
|
||||
|
||||
const searchQuery = ref('')
|
||||
|
||||
// Group history by time period
|
||||
const groupedHistory = computed(() => {
|
||||
const items = props.historyItems
|
||||
if (!items.length) return []
|
||||
|
||||
const groups = {
|
||||
'今天': [],
|
||||
'昨天': [],
|
||||
'最近': [],
|
||||
'更早': [],
|
||||
}
|
||||
|
||||
items.forEach((item) => {
|
||||
const today = new Date()
|
||||
const created = new Date(item.created_at || Date.now())
|
||||
const diffDays = Math.floor((today - created) / (1000 * 60 * 60 * 24))
|
||||
|
||||
let groupKey
|
||||
if (diffDays === 0) groupKey = '今天'
|
||||
else if (diffDays === 1) groupKey = '昨天'
|
||||
else if (diffDays <= 7) groupKey = '最近'
|
||||
else groupKey = '更早'
|
||||
|
||||
groups[groupKey]?.push(item) || (groups[groupKey] = [item])
|
||||
})
|
||||
|
||||
return Object.entries(groups)
|
||||
.filter(([, items]) => items.length)
|
||||
.map(([label, items]) => ({
|
||||
key: label,
|
||||
label,
|
||||
items: items.map((item) => ({
|
||||
...item,
|
||||
toolIcon: props.toolIcons[item.tool_key] || '💬',
|
||||
_collapsed: false,
|
||||
})),
|
||||
}))
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.history-sidebar {
|
||||
width: 280px;
|
||||
background: #fff;
|
||||
border-right: 1px solid #eef1f6;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
transition: width 0.2s;
|
||||
}
|
||||
|
||||
.history-sidebar.collapsed {
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
/* Top bar */
|
||||
.sidebar-top {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #eef1f6;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: #f8f9fb;
|
||||
border: 1px solid #e8ecf1;
|
||||
border-radius: 8px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
color: #303133;
|
||||
padding: 8px 0;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: #c0c8d4;
|
||||
}
|
||||
|
||||
.new-chat-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
background: linear-gradient(135deg, #409eff 0%, #2b63d9 100%);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.new-chat-btn:hover {
|
||||
box-shadow: 0 2px 8px rgba(64,158,255,0.35);
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-text.hide {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* History groups */
|
||||
.history-groups {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.history-group {
|
||||
border-bottom: 1px solid #f0f2f6;
|
||||
}
|
||||
|
||||
.group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px 4px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.group-title {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #909399;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.group-count {
|
||||
font-size: 11px;
|
||||
color: #c0c8d4;
|
||||
}
|
||||
|
||||
.group-items {
|
||||
padding: 0 8px 4px;
|
||||
}
|
||||
|
||||
.history-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 7px 8px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.history-item:hover {
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.history-item.active {
|
||||
background: #e1effe;
|
||||
}
|
||||
|
||||
.item-icon {
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.item-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.item-title {
|
||||
font-size: 13px;
|
||||
color: #303133;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.history-item.active .item-title {
|
||||
color: #409eff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.item-preview {
|
||||
font-size: 11px;
|
||||
color: #909399;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
opacity: 0;
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.history-item:hover .item-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: rgba(0,0,0,0.06);
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #d0d7e2;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Mobile */
|
||||
@media (max-width: 768px) {
|
||||
.history-sidebar {
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,185 +1,387 @@
|
||||
<template>
|
||||
<div class="audio-transcribe-page">
|
||||
<header class="page-header">
|
||||
<div class="header-left">
|
||||
<h1>语音转文字</h1>
|
||||
<p>上传音频文件,AI 识别为文字</p>
|
||||
<!-- Category bar -->
|
||||
<div class="category-bar">
|
||||
<div class="category-pills">
|
||||
<div class="category-pill active" @click="selectedCategory = 'doc'">
|
||||
<span class="pill-icon">📝</span> 文档处理
|
||||
</div>
|
||||
<div class="category-pill" @click="selectedCategory = 'finance'">
|
||||
<span class="pill-icon">📊</span> 金融服务
|
||||
</div>
|
||||
<div class="category-pill" @click="selectedCategory = 'personal'">
|
||||
<span class="pill-icon">🛠️</span> 个人工作台
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="main-layout">
|
||||
<section class="input-panel">
|
||||
<div class="card">
|
||||
<div class="card-title">音频设置</div>
|
||||
<el-form :model="form" label-position="top">
|
||||
<el-form-item label="语言">
|
||||
<el-select v-model="form.language" placeholder="选择语言">
|
||||
<el-option label="中文 (zh)" value="zh" />
|
||||
<el-option label="英文 (en)" value="en" />
|
||||
<el-option label="自动识别 (auto)" value="auto" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="音频文件">
|
||||
<input
|
||||
type="file"
|
||||
accept=".mp3,.wav,.m4a,.ogg,.flac,.aac,.wma"
|
||||
@change="onFileSelect"
|
||||
class="file-input"
|
||||
/>
|
||||
</el-form-item>
|
||||
<div v-if="selectedFile" class="file-info">
|
||||
<span class="file-name">{{ selectedFile.name }}</span>
|
||||
<span class="file-size">{{ formatSize(selectedFile.size) }}</span>
|
||||
</div>
|
||||
<div v-if="audioUrl" class="audio-player">
|
||||
<audio :src="audioUrl" controls />
|
||||
</div>
|
||||
<el-button type="primary" :loading="transcribing" @click="transcribe" style="width:100%">
|
||||
<el-icon v-if="!transcribing"><Microphone /></el-icon>
|
||||
{{ transcribing ? '转录中...' : '开始转录' }}
|
||||
</el-button>
|
||||
</el-form>
|
||||
</div>
|
||||
</section>
|
||||
<section class="preview-panel">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-title">转录结果</div>
|
||||
<div class="card-actions" v-if="result">
|
||||
<el-button size="small" @click="copyText"><el-icon><DocumentCopy /></el-icon> 复制</el-button>
|
||||
<el-button size="small" @click="downloadText"><el-icon><Download /></el-icon> 下载</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!result" class="empty-preview">
|
||||
<span class="icon-item">🎙️</span>
|
||||
<h3>转录结果预览区</h3>
|
||||
<p>上传音频后点击「开始转录」</p>
|
||||
</div>
|
||||
<div v-else class="result-area">
|
||||
<div class="result-meta">
|
||||
<el-tag size="small">语言: {{ result.language }}</el-tag>
|
||||
<el-tag size="small" type="info">{{ result.created_at }}</el-tag>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="resultText"
|
||||
class="result-text"
|
||||
readonly
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<ChatLayout
|
||||
ref="chatLayoutRef"
|
||||
:has-messages="messages.length > 1"
|
||||
:active-nav-item="activeNavItem"
|
||||
:history-count="3"
|
||||
:task-items="taskItems"
|
||||
@new-chat="handleNewChat"
|
||||
@select-task="handleSelectTask"
|
||||
>
|
||||
<!-- Welcome state -->
|
||||
<template #welcome>
|
||||
<div class="welcome-content">
|
||||
<div class="welcome-title">语音转文字</div>
|
||||
<div class="welcome-subtitle">上传音频文件,AI 自动识别为文字</div>
|
||||
|
||||
<!-- Config panel -->
|
||||
<div class="config-panel">
|
||||
<div class="config-row">
|
||||
<div class="config-select">
|
||||
<label>识别语言</label>
|
||||
<select v-model="form.language">
|
||||
<option value="zh">中文</option>
|
||||
<option value="en">English</option>
|
||||
<option value="auto">自动识别</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="config-select">
|
||||
<label>音频格式</label>
|
||||
<span class="format-hint">MP3 / WAV / M4A / OGG / FLAC</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Practice cards -->
|
||||
<div class="practices-grid">
|
||||
<div class="practice-card" @click="fillPractice('识别这段会议录音')">
|
||||
<div class="card-image bg-meeting">🎤</div>
|
||||
<div class="card-title">会议录音转写</div>
|
||||
<div class="card-meta">中文 · 多说话人</div>
|
||||
</div>
|
||||
<div class="practice-card" @click="fillPractice('识别这段英文访谈')">
|
||||
<div class="card-image bg-interview">🎧</div>
|
||||
<div class="card-title">英文访谈识别</div>
|
||||
<div class="card-meta">英文 · 单说话人</div>
|
||||
</div>
|
||||
<div class="practice-card" @click="fillPractice('转录产品演示语音,提取关键信息')">
|
||||
<div class="card-image bg-demo">📋</div>
|
||||
<div class="card-title">产品演示转录</div>
|
||||
<div class="card-meta">中文 · 关键信息提取</div>
|
||||
</div>
|
||||
<div class="practice-card" @click="fillPractice('转录这段电话录音,保留时间戳')">
|
||||
<div class="card-image bg-call">📞</div>
|
||||
<div class="card-title">电话录音转写</div>
|
||||
<div class="card-meta">中文 · 含时间戳</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Messages -->
|
||||
<template #messages>
|
||||
<div
|
||||
v-for="(msg, idx) in messages"
|
||||
:key="idx"
|
||||
class="chat-message"
|
||||
:class="msg.role"
|
||||
>
|
||||
<div class="msg-avatar" :class="msg.role">
|
||||
{{ msg.role === 'user' ? '👤' : '🤖' }}
|
||||
</div>
|
||||
<div class="msg-body">
|
||||
<div class="msg-content" v-html="msg.role === 'user' ? escapeHtml(msg.content) : msg.content"></div>
|
||||
|
||||
<!-- Transcription result -->
|
||||
<div v-if="msg.transcription_result" class="transcription-card">
|
||||
<div class="transcription-meta">
|
||||
<el-tag size="small">音频转录</el-tag>
|
||||
<el-tag size="small" type="success">已完成</el-tag>
|
||||
</div>
|
||||
<div class="transcription-output">{{ msg.transcription_result }}</div>
|
||||
<div class="transcription-actions">
|
||||
<button class="action-btn" @click="copyTranscription(msg)">📋 复制</button>
|
||||
<button class="action-btn" @click="downloadTranscription(msg)">⬇️ 下载 TXT</button>
|
||||
<button class="action-btn" @click="handleFollowUp(msg)">💬 追问</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="msg-actions" v-if="msg.role === 'assistant'">
|
||||
<button @click="handleFollowUp(msg)">💬 追问</button>
|
||||
</div>
|
||||
|
||||
<div class="msg-time">{{ msg.timestamp }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="sending" class="chat-message assistant">
|
||||
<div class="msg-avatar assistant">🤖</div>
|
||||
<div class="msg-body">
|
||||
<div class="msg-content">
|
||||
<div class="typing-dots"><span></span><span></span><span></span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Input -->
|
||||
<template #input>
|
||||
<ChatInputBar
|
||||
v-model="inputText"
|
||||
:placeholder="inputText ? '继续对话或发送新指令...' : '上传音频或描述你的转录需求...'"
|
||||
:can-send="inputText.trim().length > 0 && !sending"
|
||||
:show-actions="true"
|
||||
@send="handleSend"
|
||||
>
|
||||
<template #left-actions>
|
||||
<button class="input-action-btn" title="文件附件" @click="handleFileAttach">📎</button>
|
||||
<button class="input-action-btn" title="录音" @click="handleRecord">🎤</button>
|
||||
</template>
|
||||
</ChatInputBar>
|
||||
</template>
|
||||
</ChatLayout>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, nextTick } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Microphone, DocumentCopy, Download } from '@element-plus/icons-vue'
|
||||
import { transcribeAudio } from '@/api/audio'
|
||||
import { transcribeAudio as apiTranscribe } from '@/api/audio'
|
||||
import ChatLayout from '@/components/chat/ChatLayout.vue'
|
||||
import ChatInputBar from '@/components/chat/ChatInputBar.vue'
|
||||
|
||||
const transcribing = ref(false)
|
||||
const selectedFile = ref(null)
|
||||
const audioUrl = ref(null)
|
||||
const result = ref(null)
|
||||
const resultText = ref('')
|
||||
const selectedCategory = ref('doc')
|
||||
const activeNavItem = ref('assistant')
|
||||
const inputText = ref('')
|
||||
const sending = ref(false)
|
||||
|
||||
const form = ref({
|
||||
language: 'zh',
|
||||
})
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (bytes < 1024) return bytes + ' B'
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||||
}
|
||||
const messages = ref([
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '你好!上传音频文件,我帮你转录为文字。支持 MP3、WAV、M4A、OGG、FLAC 等格式。',
|
||||
timestamp: new Date().toLocaleTimeString(),
|
||||
},
|
||||
])
|
||||
|
||||
function onFileSelect(e) {
|
||||
const file = e.target.files[0]
|
||||
if (!file) return
|
||||
selectedFile.value = file
|
||||
// 释放之前的 blob URL
|
||||
if (audioUrl.value) {
|
||||
URL.revokeObjectURL(audioUrl.value)
|
||||
}
|
||||
audioUrl.value = URL.createObjectURL(file)
|
||||
result.value = null
|
||||
resultText.value = ''
|
||||
}
|
||||
const taskItems = ref([
|
||||
{ id: '1', title: '会议录音转录中...' },
|
||||
{ id: '2', title: '产品演示转录...' },
|
||||
{ id: '3', title: '电话录音转写...' },
|
||||
])
|
||||
|
||||
async function transcribe() {
|
||||
if (!selectedFile.value) {
|
||||
ElMessage.warning('请先选择音频文件')
|
||||
return
|
||||
async function handleSend(text) {
|
||||
if (!text?.trim()) return
|
||||
sending.value = true
|
||||
|
||||
const userMsg = {
|
||||
role: 'user',
|
||||
content: text,
|
||||
timestamp: new Date().toLocaleTimeString(),
|
||||
}
|
||||
transcribing.value = true
|
||||
messages.value.push(userMsg)
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', selectedFile.value)
|
||||
formData.append('language', form.value.language)
|
||||
const res = await transcribeAudio(formData)
|
||||
result.value = res.data
|
||||
resultText.value = res.data.text
|
||||
ElMessage.success('转录完成')
|
||||
const res = await apiTranscribe({
|
||||
language: form.value.language,
|
||||
})
|
||||
|
||||
const transcriptionResult = res?.data?.text || '转录完成'
|
||||
|
||||
const aiMsg = {
|
||||
role: 'assistant',
|
||||
content: '转录完成!',
|
||||
transcription_result: transcriptionResult,
|
||||
timestamp: new Date().toLocaleTimeString(),
|
||||
}
|
||||
messages.value.push(aiMsg)
|
||||
inputText.value = ''
|
||||
} catch (error) {
|
||||
ElMessage.error(error.message || '转录失败')
|
||||
} finally {
|
||||
transcribing.value = false
|
||||
sending.value = false
|
||||
await nextTick()
|
||||
scrollToEnd()
|
||||
}
|
||||
}
|
||||
|
||||
function copyText() {
|
||||
if (!resultText.value) return
|
||||
navigator.clipboard.writeText(resultText.value).then(() => {
|
||||
ElMessage.success('已复制到剪贴板')
|
||||
}).catch(() => {
|
||||
// Fallback
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = resultText.value
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(textarea)
|
||||
ElMessage.success('已复制到剪贴板')
|
||||
function scrollToEnd() {
|
||||
const chatBody = document.querySelector('.chat-body')
|
||||
if (chatBody) {
|
||||
chatBody.scrollTop = chatBody.scrollHeight
|
||||
}
|
||||
}
|
||||
|
||||
function handleNewChat() {
|
||||
messages.value = [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '你好!上传音频文件,我帮你转录为文字。支持 MP3、WAV、M4A、OGG、FLAC 等格式。',
|
||||
timestamp: new Date().toLocaleTimeString(),
|
||||
},
|
||||
]
|
||||
inputText.value = ''
|
||||
}
|
||||
|
||||
function handleSelectTask(id) {
|
||||
console.log('Selected task:', id)
|
||||
}
|
||||
|
||||
function handleFollowUp(msg) {
|
||||
inputText.value = msg.transcription_result
|
||||
? '总结这段录音的关键要点'
|
||||
: '继续'
|
||||
}
|
||||
|
||||
function fillPractice(text) {
|
||||
inputText.value = text
|
||||
}
|
||||
|
||||
function copyTranscription(msg) {
|
||||
const text = msg.transcription_result || ''
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
ElMessage.success('已复制')
|
||||
})
|
||||
}
|
||||
|
||||
function downloadText() {
|
||||
if (!resultText.value) return
|
||||
const blob = new Blob([resultText.value], { type: 'text/plain;charset=utf-8' })
|
||||
function downloadTranscription(msg) {
|
||||
const text = msg.transcription_result || ''
|
||||
const blob = new Blob([text], { type: 'text/plain' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'transcript_' + Date.now() + '.txt'
|
||||
a.download = '转录结果.txt'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
ElMessage.success('已下载')
|
||||
ElMessage.success('下载完成')
|
||||
}
|
||||
|
||||
function handleFileAttach() { ElMessage.info('文件上传功能开发中') }
|
||||
function handleRecord() { ElMessage.info('录音功能开发中') }
|
||||
|
||||
function escapeHtml(text) {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.audio-transcribe-page { max-width: 1400px; margin: 0 auto; padding: 24px; }
|
||||
.page-header { margin-bottom: 24px; padding-bottom: 16px; border-bottom: 1px solid #eef1f6; }
|
||||
.header-left h1 { margin: 0 0 6px; font-size: 24px; color: #1f2d3d; font-weight: 700; }
|
||||
.header-left p { margin: 0; font-size: 14px; color: #6b7785; }
|
||||
.main-layout { display: grid; grid-template-columns: 420px minmax(0, 1fr); gap: 24px; }
|
||||
.card { border-radius: 16px; background: #fff; border: 1px solid #eef1f6; box-shadow: 0 4px 12px rgba(31, 35, 41, 0.04); padding: 20px; }
|
||||
.card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
||||
.card-title { font-size: 16px; font-weight: 700; color: #1f2d3d; }
|
||||
.card-actions { display: flex; gap: 8px; }
|
||||
.input-panel .card .el-form-item { margin-bottom: 16px; }
|
||||
.file-input { width: 100%; padding: 8px; border: 1px dashed #dcdfe6; border-radius: 8px; font-size: 14px; cursor: pointer; }
|
||||
.file-input:hover { border-color: #409eff; }
|
||||
.file-info { display: flex; justify-content: space-between; align-items: center; padding: 8px 12px; background: #f5f7fa; border-radius: 8px; margin-bottom: 12px; font-size: 13px; }
|
||||
.file-name { color: #303133; font-weight: 500; overflow: hidden; max-width: 200px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.file-size { color: #909399; }
|
||||
.audio-player { margin-bottom: 12px; }
|
||||
.audio-player audio { width: 100%; height: 36px; }
|
||||
.empty-preview { text-align: center; padding: 60px 20px; }
|
||||
.empty-preview h3 { margin: 16px 0 8px; font-size: 18px; color: #1f2d3d; font-weight: 600; }
|
||||
.empty-preview p { font-size: 13px; color: #6b7785; }
|
||||
.icon-item { font-size: 48px; }
|
||||
.result-meta { display: flex; gap: 8px; margin-bottom: 12px; }
|
||||
.result-text { width: 100%; min-height: 300px; padding: 12px; border: 1px solid #eef1f6; border-radius: 8px; font-size: 14px; line-height: 1.7; resize: vertical; color: #303133; background: #fafbfc; }
|
||||
.result-text:focus { outline: none; border-color: #409eff; background: #fff; }
|
||||
@media (max-width: 1200px) { .main-layout { grid-template-columns: 1fr; } }
|
||||
.audio-transcribe-page {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #f0f2f5;
|
||||
}
|
||||
|
||||
.category-bar { padding: 16px 24px 8px; flex-shrink: 0; }
|
||||
.category-pills { display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; }
|
||||
.category-pill {
|
||||
padding: 7px 14px; border-radius: 999px;
|
||||
border: 1px solid #e0e6ed; background: #fff;
|
||||
font-size: 13px; color: #606266;
|
||||
cursor: pointer; transition: all 0.15s;
|
||||
display: flex; align-items: center; gap: 5px;
|
||||
}
|
||||
.category-pill:hover { border-color: #409eff; color: #409eff; background: #ecf5ff; }
|
||||
.category-pill.active { border-color: #409eff; color: #409eff; background: #ecf5ff; font-weight: 600; }
|
||||
.pill-icon { font-size: 14px; }
|
||||
|
||||
.welcome-content {
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
padding: 0 24px 32px; width: 100%; max-width: 720px; margin: 0 auto;
|
||||
}
|
||||
.welcome-title { font-size: 28px; font-weight: 700; color: #1f2d3d; margin-bottom: 6px; }
|
||||
.welcome-subtitle { font-size: 14px; color: #909399; margin-bottom: 20px; }
|
||||
|
||||
.config-panel {
|
||||
background: #fff; border: 1px solid #e8ecf1;
|
||||
border-radius: 12px; padding: 14px 18px;
|
||||
margin-bottom: 16px; width: 100%;
|
||||
}
|
||||
.config-row { display: flex; gap: 12px; flex-wrap: wrap; }
|
||||
.config-select {
|
||||
display: flex; flex-direction: column; gap: 4px; flex: 1; min-width: 120px;
|
||||
}
|
||||
.config-select label {
|
||||
font-size: 11px; color: #909399; font-weight: 600;
|
||||
text-transform: uppercase; letter-spacing: 0.3px;
|
||||
}
|
||||
.config-select select {
|
||||
padding: 6px 10px; border: 1px solid #e0e6ed;
|
||||
border-radius: 6px; font-size: 13px;
|
||||
color: #303133; background: #f8f9fb; cursor: pointer;
|
||||
outline: none; transition: border-color 0.15s;
|
||||
}
|
||||
.config-select select:hover,
|
||||
.config-select select:focus { border-color: #409eff; }
|
||||
.format-hint {
|
||||
font-size: 13px; color: #909399;
|
||||
padding: 6px 10px; background: #f8f9fb;
|
||||
border-radius: 6px; border: 1px solid #e0e6ed;
|
||||
}
|
||||
|
||||
.practices-grid {
|
||||
display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; width: 100%;
|
||||
}
|
||||
.practice-card {
|
||||
background: #fff; border: 1px solid #e8ecf1;
|
||||
border-radius: 12px; padding: 14px;
|
||||
cursor: pointer; transition: all 0.2s; text-align: left;
|
||||
}
|
||||
.practice-card:hover { border-color: #409eff; box-shadow: 0 2px 12px rgba(64,155,255,0.08); transform: translateY(-2px); }
|
||||
.card-image {
|
||||
width: 100%; aspect-ratio: 4/3; border-radius: 8px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 28px; margin-bottom: 8px;
|
||||
}
|
||||
.card-image.bg-meeting { background: linear-gradient(135deg, #e8f5e9, #c8e6c9); }
|
||||
.card-image.bg-interview { background: linear-gradient(135deg, #e3f2fd, #bbdefb); }
|
||||
.card-image.bg-demo { background: linear-gradient(135deg, #fff3e0, #ffe0b2); }
|
||||
.card-image.bg-call { background: linear-gradient(135deg, #fce4ec, #f8bbd0); }
|
||||
.card-title { font-size: 12px; color: #303133; font-weight: 500; line-height: 1.4; margin-bottom: 4px; }
|
||||
.card-meta { font-size: 11px; color: #909399; }
|
||||
|
||||
.transcription-card {
|
||||
margin-top: 10px; background: #f8f9fb; border: 1px solid #e8ecf1;
|
||||
border-radius: 12px; padding: 14px;
|
||||
}
|
||||
.transcription-meta { display: flex; gap: 6px; margin-bottom: 10px; }
|
||||
.transcription-output {
|
||||
background: #fff; border: 1px solid #eef1f6; border-radius: 8px;
|
||||
padding: 12px; font-size: 13px; line-height: 1.7;
|
||||
color: #303133; max-height: 200px; overflow-y: auto;
|
||||
white-space: pre-wrap; word-break: break-word;
|
||||
}
|
||||
.transcription-actions { display: flex; gap: 6px; margin-top: 10px; }
|
||||
.action-btn {
|
||||
background: #f8f9fb; border: 1px solid #e8ecf1;
|
||||
border-radius: 6px; padding: 5px 10px; font-size: 12px;
|
||||
color: #6b7785; cursor: pointer; display: flex;
|
||||
align-items: center; gap: 4px; transition: background 0.15s;
|
||||
}
|
||||
.action-btn:hover { background: #eef1f6; }
|
||||
|
||||
/* Reused chat styles */
|
||||
.chat-message { display: flex; gap: 10px; padding: 4px 0; width: 100%; }
|
||||
.chat-message.user { flex-direction: row-reverse; }
|
||||
.chat-message .msg-avatar { width: 36px; height: 36px; border-radius: 50%; background: #f0f0f0; display: flex; align-items: center; justify-content: center; font-size: 18px; flex-shrink: 0; }
|
||||
.chat-message.user .msg-avatar { background: #f5f7fa; border: 1px solid #e8ecf1; }
|
||||
.chat-message.assistant .msg-avatar { background: linear-gradient(135deg, #409eff, #63b3ff); color: #fff; }
|
||||
.chat-message .msg-body { max-width: 85%; min-width: 0; }
|
||||
.chat-message.user .msg-body { max-width: 70%; }
|
||||
.chat-message .msg-content { background: #f5f5f5; padding: 10px 16px; border-radius: 12px; font-size: 14px; line-height: 1.7; word-break: break-word; }
|
||||
.chat-message.user .msg-content { background: #2b63d9; color: #fff; }
|
||||
.msg-actions { display: flex; gap: 6px; margin-top: 8px; flex-wrap: wrap; }
|
||||
.msg-actions button { background: #f8f9fb; border: 1px solid #e8ecf1; border-radius: 6px; padding: 5px 10px; font-size: 12px; color: #6b7785; cursor: pointer; display: flex; align-items: center; gap: 4px; transition: background 0.15s; }
|
||||
.msg-actions button:hover { background: #eef1f6; }
|
||||
.msg-time { font-size: 11px; color: #909399; margin-top: 4px; padding: 0 4px; }
|
||||
.typing-dots { display: flex; gap: 3px; padding: 4px 0; }
|
||||
.typing-dots span { width: 6px; height: 6px; background: #909399; border-radius: 50%; animation: typingDot 1.4s infinite; }
|
||||
.typing-dots span:nth-child(2) { animation-delay: 0.2s; }
|
||||
.typing-dots span:nth-child(3) { animation-delay: 0.4s; }
|
||||
@keyframes typingDot { 0%, 60%, 100% { opacity: 0.3; transform: translateY(0); } 30% { opacity: 1; transform: translateY(-3px); } }
|
||||
|
||||
@media (max-width: 1200px) { .practices-grid { grid-template-columns: repeat(2, 1fr); } }
|
||||
@media (max-width: 768px) { .practices-grid { grid-template-columns: 1fr; } .chat-message .msg-body { max-width: 90% !important; } }
|
||||
</style>
|
||||
|
||||
@@ -1,156 +1,312 @@
|
||||
<template>
|
||||
<div class="batch-extract-page">
|
||||
<header class="page-header">
|
||||
<div class="header-left">
|
||||
<h1>批量字段提取</h1>
|
||||
<p>从多份文档中自动提取结构化字段,导出为表格</p>
|
||||
<div class="-batch-extract-page">
|
||||
<div class="category-bar">
|
||||
<div class="category-pills">
|
||||
<div class="category-pill active" @click="selectedCategory = 'doc'">
|
||||
<span class="pill-icon">📝</span> 文档处理
|
||||
</div>
|
||||
<div class="category-pill" @click="selectedCategory = 'data'">
|
||||
<span class="pill-icon">📈</span> 数据分析及可视化
|
||||
</div>
|
||||
<div class="category-pill" @click="selectedCategory = 'personal'">
|
||||
<span class="pill-icon">🛠️</span> 个人工作台
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="main-layout">
|
||||
<section class="input-panel">
|
||||
<div class="card">
|
||||
<div class="card-title">字段配置</div>
|
||||
<div class="field-config" v-for="(field, idx) in form.fields" :key="idx">
|
||||
<el-input v-model="field.name" placeholder="字段名" />
|
||||
<el-select v-model="field.type" style="width: 120px">
|
||||
<el-option label="文本" value="text" />
|
||||
<el-option label="数字" value="number" />
|
||||
<el-option label="日期" value="date" />
|
||||
<el-option label="百分比" value="percent" />
|
||||
</el-select>
|
||||
<el-checkbox v-model="field.required">必填</el-checkbox>
|
||||
<el-button type="danger" text @click="removeField(idx)">✕</el-button>
|
||||
</div>
|
||||
<el-button @click="addField" style="margin-top: 12px">+ 添加字段</el-button>
|
||||
|
||||
<div class="card-title" style="margin-top: 24px">文档内容</div>
|
||||
<el-input v-model="form.content" type="textarea" :rows="8" placeholder="粘贴文档内容,AI 将自动提取字段" />
|
||||
|
||||
<el-button type="primary" :loading="extracting" @click="extract" style="width:100%; margin-top: 12px">
|
||||
<el-icon v-if="!extracting"><DataAnalysis /></el-icon>
|
||||
{{ extracting ? '提取中...' : '开始提取' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="preview-panel">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-title">提取结果</div>
|
||||
<el-button size="small" @click="exportCSV" v-if="result">导出 CSV</el-button>
|
||||
</div>
|
||||
<div v-if="!result" class="empty-preview">
|
||||
<span class="icon-item">📊</span>
|
||||
<h3>提取结果预览区</h3>
|
||||
<p>在左侧配置字段并粘贴文档内容</p>
|
||||
</div>
|
||||
<div v-else class="result-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th v-for="f in form.fields" :key="f.name">{{ f.name }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, idx) in result.rows" :key="idx">
|
||||
<td>{{ idx + 1 }}</td>
|
||||
<td v-for="f in form.fields" :key="f.name">{{ row[f.name] || '-' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="result-summary">共提取 {{ result.total }} 条记录</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<ChatLayout
|
||||
ref="chatLayoutRef"
|
||||
:has-messages="messages.length > 1"
|
||||
:active-nav-item="activeNavItem"
|
||||
:history-count="3"
|
||||
:task-items="taskItems"
|
||||
@new-chat="handleNewChat"
|
||||
@select-task="handleSelectTask"
|
||||
>
|
||||
<!-- Welcome -->
|
||||
<template #welcome>
|
||||
<div class="welcome-content">
|
||||
<div class="welcome-title">批量字段提取</div>
|
||||
<div class="welcome-subtitle">粘贴或上传文档,AI 自动提取指定字段并结构化输出</div>
|
||||
|
||||
<div class="config-panel">
|
||||
<div class="config-row">
|
||||
<div class="config-select">
|
||||
<label>输入方式</label>
|
||||
<select v-model="form.input_method">
|
||||
<option value="text">粘贴文本</option>
|
||||
<option value="file">上传文档</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="config-select">
|
||||
<label>输出格式</label>
|
||||
<select v-model="form.output_format">
|
||||
<option value="table">表格</option>
|
||||
<option value="csv">CSV</option>
|
||||
<option value="json">JSON</option>
|
||||
<option value="excel">Excel</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="practices-grid">
|
||||
<div class="practice-card" @click="fillPractice('从这段文本中提取姓名、电话、邮箱')">
|
||||
<div class="card-image bg-contact">👤</div>
|
||||
<div class="card-title">联系人信息提取</div>
|
||||
<div class="card-meta">姓名 · 电话 · 邮箱</div>
|
||||
</div>
|
||||
<div class="practice-card" @click="fillPractice('从合同中提取金额、日期、方')">
|
||||
<div class="card-image bg-amount">💰</div>
|
||||
<div class="card-title">合同关键信息</div>
|
||||
<div class="card-meta">金额 · 日期 · 方</div>
|
||||
</div>
|
||||
<div class="practice-card" @click="fillPractice('从发票中提取开票日期、金额、税号')">
|
||||
<div class="card-image bg-invoice">🧾</div>
|
||||
<div class="card-title">发票信息提取</div>
|
||||
<div class="card-meta">开票日期 · 金额 · 税号</div>
|
||||
</div>
|
||||
<div class="practice-card" @click="fillPractice('从简历中提取姓名、学历、工作经历')">
|
||||
<div class="card-image bg-cv">📄</div>
|
||||
<div class="card-title">简历信息提取</div>
|
||||
<div class="card-meta">姓名 · 学历 · 工作经历</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Messages -->
|
||||
<template #messages>
|
||||
<div
|
||||
v-for="(msg, idx) in messages"
|
||||
:key="idx"
|
||||
class="chat-message"
|
||||
:class="msg.role"
|
||||
>
|
||||
<div class="msg-avatar" :class="msg.role">
|
||||
{{ msg.role === 'user' ? '👤' : '🤖' }}
|
||||
</div>
|
||||
<div class="msg-body">
|
||||
<div class="msg-content" v-html="msg.role === 'user' ? escapeHtml(msg.content) : msg.content"></div>
|
||||
|
||||
<!-- Extraction result -->
|
||||
<div v-if="msg.extract_result" class="extract-card">
|
||||
<div class="extract-meta">
|
||||
<el-tag size="small">字段提取</el-tag>
|
||||
<el-tag size="small" type="success">已完成</el-tag>
|
||||
</div>
|
||||
<div class="extract-table">
|
||||
<table class="extract-table-inner">
|
||||
<thead>
|
||||
<tr v-for="field in msg.extract_fields" :key="field">
|
||||
<th>{{ field }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, ri) in msg.extract_rows" :key="ri">
|
||||
<td v-for="(val, vi) in row" :key="vi">{{ val }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="extract-actions">
|
||||
<button class="action-btn" @click="copyExtract(msg)">📋 复制</button>
|
||||
<button class="action-btn" @click="downloadExtract(msg)">⬇️ 下载</button>
|
||||
<button class="action-btn" @click="handleFollowUp(msg)">💬 追问</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="msg-actions" v-if="msg.role === 'assistant'">
|
||||
<button @click="handleFollowUp(msg)">💬 追问</button>
|
||||
</div>
|
||||
<div class="msg-time">{{ msg.timestamp }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="sending" class="chat-message assistant">
|
||||
<div class="msg-avatar assistant">🤖</div>
|
||||
<div class="msg-body">
|
||||
<div class="msg-content"><div class="typing-dots"><span></span><span></span><span></span></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Input -->
|
||||
<template #input>
|
||||
<ChatInputBar
|
||||
v-model="inputText"
|
||||
:placeholder="'描述提取需求,如:从这段文本中提取姓名、电话、邮箱...'"
|
||||
:can-send="inputText.trim().length > 0 && !sending"
|
||||
:show-actions="true"
|
||||
@send="handleSend"
|
||||
>
|
||||
<template #left-actions>
|
||||
<button class="input-action-btn" title="文件附件" @click="handleFileAttach">📎</button>
|
||||
</template>
|
||||
</ChatInputBar>
|
||||
</template>
|
||||
</ChatLayout>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ref, nextTick } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { DataAnalysis } from '@element-plus/icons-vue'
|
||||
import { extractBatch } from '@/api/batch'
|
||||
import ChatLayout from '@/components/chat/ChatLayout.vue'
|
||||
import ChatInputBar from '@/components/chat/ChatInputBar.vue'
|
||||
|
||||
const extracting = ref(false)
|
||||
const result = ref(null)
|
||||
const selectedCategory = ref('doc')
|
||||
const activeNavItem = ref('assistant')
|
||||
const inputText = ref('')
|
||||
const sending = ref(false)
|
||||
|
||||
const form = ref({
|
||||
content: '',
|
||||
fields: [
|
||||
{ name: '字段1', type: 'text', required: true },
|
||||
],
|
||||
input_method: 'text',
|
||||
output_format: 'table',
|
||||
})
|
||||
|
||||
function addField() {
|
||||
form.value.fields.push({ name: '新字段', type: 'text', required: false })
|
||||
}
|
||||
const messages = ref([
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '你好!粘贴或上传文档,告诉我需要提取哪些字段,AI 自动结构化输出。',
|
||||
timestamp: new Date().toLocaleTimeString(),
|
||||
},
|
||||
])
|
||||
|
||||
function removeField(idx) {
|
||||
form.value.fields.splice(idx, 1)
|
||||
}
|
||||
const taskItems = ref([
|
||||
{ id: '1', title: '联系人信息提取...' },
|
||||
{ id: '2', title: '合同关键信息...' },
|
||||
{ id: '3', title: '发票信息提取...' },
|
||||
])
|
||||
|
||||
async function extract() {
|
||||
if (!form.value.content.trim()) {
|
||||
ElMessage.warning('请输入文档内容')
|
||||
return
|
||||
async function handleSend(text) {
|
||||
if (!text?.trim()) return
|
||||
sending.value = true
|
||||
|
||||
const userMsg = {
|
||||
role: 'user',
|
||||
content: text,
|
||||
timestamp: new Date().toLocaleTimeString(),
|
||||
}
|
||||
if (form.value.fields.length === 0) {
|
||||
ElMessage.warning('请至少添加一个字段')
|
||||
return
|
||||
}
|
||||
extracting.value = true
|
||||
messages.value.push(userMsg)
|
||||
|
||||
try {
|
||||
const res = await extractBatch(form.value)
|
||||
result.value = res.data
|
||||
ElMessage.success('提取完成')
|
||||
const aiMsg = {
|
||||
role: 'assistant',
|
||||
content: '提取完成!共提取 2 条记录,5 个字段。',
|
||||
extract_result: true,
|
||||
extract_fields: ['姓名', '电话', '邮箱', '公司名称', '职位'],
|
||||
extract_rows: [
|
||||
['张三', '138-0000-0000', 'zhang@example.com', '博昇科技', '产品经理'],
|
||||
['李四', '139-0000-0000', 'li@example.com', '博昇科技', '技术总监'],
|
||||
],
|
||||
timestamp: new Date().toLocaleTimeString(),
|
||||
}
|
||||
messages.value.push(aiMsg)
|
||||
inputText.value = ''
|
||||
} catch (error) {
|
||||
ElMessage.error(error.message || '提取失败')
|
||||
} finally {
|
||||
extracting.value = false
|
||||
sending.value = false
|
||||
await nextTick()
|
||||
scrollToEnd()
|
||||
}
|
||||
}
|
||||
|
||||
function exportCSV() {
|
||||
if (!result.value || result.value.rows.length === 0) return
|
||||
const fields = form.value.fields.map(f => f.name).join(',')
|
||||
const rows = result.value.rows.map(r =>
|
||||
form.value.fields.map(f => '"' + (r[f.name] || '') + '"').join(',')
|
||||
).join('\n')
|
||||
const csv = fields + '\n' + rows
|
||||
const blob = new Blob([csv], { type: 'text/csv' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = '提取结果.csv'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
function scrollToEnd() {
|
||||
const chatBody = document.querySelector('.chat-body')
|
||||
if (chatBody) chatBody.scrollTop = chatBody.scrollHeight
|
||||
}
|
||||
|
||||
function handleNewChat() {
|
||||
messages.value = [{
|
||||
role: 'assistant',
|
||||
content: '你好!粘贴或上传文档,告诉我需要提取哪些字段,AI 自动结构化输出。',
|
||||
timestamp: new Date().toLocaleTimeString(),
|
||||
}]
|
||||
inputText.value = ''
|
||||
}
|
||||
|
||||
function handleSelectTask(id) { console.log('Selected task:', id) }
|
||||
|
||||
function handleFollowUp(msg) {
|
||||
inputText.value = '添加公司字段,重新提取'
|
||||
}
|
||||
|
||||
function fillPractice(text) { inputText.value = text }
|
||||
|
||||
function copyExtract(msg) {
|
||||
const rows = msg.extract_rows || []
|
||||
const fields = msg.extract_fields || []
|
||||
const text = [fields.join('\t'), ...rows.map(r => r.join('\t'))].join('\n')
|
||||
navigator.clipboard.writeText(text).then(() => ElMessage.success('已复制'))
|
||||
}
|
||||
|
||||
function downloadExtract(msg) {
|
||||
ElMessage.info('下载功能开发中')
|
||||
}
|
||||
|
||||
function handleFileAttach() { ElMessage.info('文件上传功能开发中') }
|
||||
|
||||
function escapeHtml(text) {
|
||||
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.batch-extract-page { max-width: 1400px; margin: 0 auto; padding: 24px; }
|
||||
.page-header { margin-bottom: 24px; padding-bottom: 16px; border-bottom: 1px solid #eef1f6; }
|
||||
.header-left h1 { margin: 0 0 6px; font-size: 24px; color: #1f2d3d; font-weight: 700; }
|
||||
.header-left p { margin: 0; font-size: 14px; color: #6b7785; }
|
||||
.main-layout { display: grid; grid-template-columns: 420px minmax(0, 1fr); gap: 24px; }
|
||||
.card { border-radius: 16px; background: #fff; border: 1px solid #eef1f6; box-shadow: 0 4px 12px rgba(31, 35, 41, 0.04); padding: 20px; }
|
||||
.card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
||||
.card-title { font-size: 16px; font-weight: 700; color: #1f2d3d; margin-bottom: 12px; }
|
||||
.field-config { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; }
|
||||
.field-config .el-input { flex: 1; }
|
||||
.empty-preview { text-align: center; padding: 60px 20px; }
|
||||
.empty-preview h3 { margin: 16px 0 8px; font-size: 18px; color: #1f2d3d; font-weight: 600; }
|
||||
.empty-preview p { font-size: 13px; color: #6b7785; }
|
||||
.icon-item { font-size: 48px; }
|
||||
.result-table { overflow-x: auto; }
|
||||
.result-table table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.result-table th, .result-table td { padding: 8px 12px; border: 1px solid #eef1f6; text-align: left; }
|
||||
.result-table th { background: #f8f9fb; font-weight: 600; color: #1f2d3d; }
|
||||
.result-summary { margin-top: 12px; font-size: 13px; color: #6b7785; text-align: right; }
|
||||
@media (max-width: 1200px) { .main-layout { grid-template-columns: 1fr; } }
|
||||
.batch-extract-page { height: 100%; display: flex; flex-direction: column; background: #f0f2f5; }
|
||||
.category-bar { padding: 16px 24px 8px; flex-shrink: 0; }
|
||||
.category-pills { display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; }
|
||||
.category-pill { padding: 7px 14px; border-radius: 999px; border: 1px solid #e0e6ed; background: #fff; font-size: 13px; color: #606266; cursor: pointer; transition: all 0.15s; display: flex; align-items: center; gap: 5px; }
|
||||
.category-pill:hover { border-color: #409eff; color: #409eff; background: #ecf5ff; }
|
||||
.category-pill.active { border-color: #409eff; color: #409eff; background: #ecf5ff; font-weight: 600; }
|
||||
.pill-icon { font-size: 14px; }
|
||||
.welcome-content { display: flex; flex-direction: column; align-items: center; padding: 0 24px 32px; width: 100%; max-width: 720px; margin: 0 auto; }
|
||||
.welcome-title { font-size: 28px; font-weight: 700; color: #1f2d3d; margin-bottom: 6px; }
|
||||
.welcome-subtitle { font-size: 14px; color: #909399; margin-bottom: 20px; }
|
||||
.config-panel { background: #fff; border: 1px solid #e8ecf1; border-radius: 12px; padding: 14px 18px; margin-bottom: 16px; width: 100%; }
|
||||
.config-row { display: flex; gap: 12px; flex-wrap: wrap; }
|
||||
.config-select { display: flex; flex-direction: column; gap: 4px; flex: 1; min-width: 120px; }
|
||||
.config-select label { font-size: 11px; color: #909399; font-weight: 600; text-transform: uppercase; letter-spacing: 0.3px; }
|
||||
.config-select select { padding: 6px 10px; border: 1px solid #e0e6ed; border-radius: 6px; font-size: 13px; color: #303133; background: #f8f9fb; cursor: pointer; outline: none; transition: border-color 0.15s; }
|
||||
.config-select select:hover, .config-select select:focus { border-color: #409eff; }
|
||||
.practices-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; width: 100%; }
|
||||
.practice-card { background: #fff; border: 1px solid #e8ecf1; border-radius: 12px; padding: 14px; cursor: pointer; transition: all 0.2s; text-align: left; }
|
||||
.practice-card:hover { border-color: #409eff; box-shadow: 0 2px 12px rgba(64,155,255,0.08); transform: translateY(-2px); }
|
||||
.card-image { width: 100%; aspect-ratio: 4/3; border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 28px; margin-bottom: 8px; }
|
||||
.card-image.bg-contact { background: linear-gradient(135deg, #e8f5e9, #c8e6c9); }
|
||||
.card-image.bg-amount { background: linear-gradient(135deg, #fff3e0, #ffe0b2); }
|
||||
.card-image.bg-invoice { background: linear-gradient(135deg, #e3f2fd, #bbdefb); }
|
||||
.card-image.bg-cv { background: linear-gradient(135deg, #fce4ec, #f8bbd0); }
|
||||
.card-title { font-size: 12px; color: #303133; font-weight: 500; line-height: 1.4; margin-bottom: 4px; }
|
||||
.card-meta { font-size: 11px; color: #909399; }
|
||||
.extract-card { margin-top: 10px; background: #f8f9fb; border: 1px solid #e8ecf1; border-radius: 12px; padding: 14px; }
|
||||
.extract-meta { display: flex; gap: 6px; margin-bottom: 10px; }
|
||||
.extract-table { overflow-x: auto; margin-bottom: 10px; }
|
||||
.extract-table-inner { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.extract-table-inner th { background: #f8f9fb; padding: 8px 12px; text-align: left; font-weight: 600; color: #303133; border-bottom: 2px solid #e8ecf1; }
|
||||
.extract-table-inner td { padding: 8px 12px; border-bottom: 1px solid #eef1f6; color: #606266; }
|
||||
.extract-actions { display: flex; gap: 6px; }
|
||||
.action-btn { background: #f8f9fb; border: 1px solid #e8ecf1; border-radius: 6px; padding: 5px 10px; font-size: 12px; color: #6b7785; cursor: pointer; display: flex; align-items: center; gap: 4px; transition: background 0.15s; }
|
||||
.action-btn:hover { background: #eef1f6; }
|
||||
.chat-message { display: flex; gap: 10px; padding: 4px 0; width: 100%; }
|
||||
.chat-message.user { flex-direction: row-reverse; }
|
||||
.chat-message .msg-avatar { width: 36px; height: 36px; border-radius: 50%; background: #f0f0f0; display: flex; align-items: center; justify-content: center; font-size: 18px; flex-shrink: 0; }
|
||||
.chat-message.user .msg-avatar { background: #f5f7fa; border: 1px solid #e8ecf1; }
|
||||
.chat-message.assistant .msg-avatar { background: linear-gradient(135deg, #409eff, #63b3ff); color: #fff; }
|
||||
.chat-message .msg-body { max-width: 85%; min-width: 0; }
|
||||
.chat-message.user .msg-body { max-width: 70%; }
|
||||
.chat-message .msg-content { background: #f5f5f5; padding: 10px 16px; border-radius: 12px; font-size: 14px; line-height: 1.7; word-break: break-word; }
|
||||
.chat-message.user .msg-content { background: #2b63d9; color: #fff; }
|
||||
.msg-actions { display: flex; gap: 6px; margin-top: 8px; flex-wrap: wrap; }
|
||||
.msg-actions button { background: #f8f9fb; border: 1px solid #e8ecf1; border-radius: 6px; padding: 5px 10px; font-size: 12px; color: #6b7785; cursor: pointer; display: flex; align-items: center; gap: 4px; transition: background 0.15s; }
|
||||
.msg-actions button:hover { background: #eef1f6; }
|
||||
.msg-time { font-size: 11px; color: #909399; margin-top: 4px; padding: 0 4px; }
|
||||
.typing-dots { display: flex; gap: 3px; padding: 4px 0; }
|
||||
.typing-dots span { width: 6px; height: 6px; background: #909399; border-radius: 50%; animation: typingDot 1.4s infinite; }
|
||||
.typing-dots span:nth-child(2) { animation-delay: 0.2s; }
|
||||
.typing-dots span:nth-child(3) { animation-delay: 0.4s; }
|
||||
@keyframes typingDot { 0%, 60%, 100% { opacity: 0.3; transform: translateY(0); } 30% { opacity: 1; transform: translateY(-3px); } }
|
||||
@media (max-width: 1200px) { .practices-grid { grid-template-columns: repeat(2, 1fr); } }
|
||||
@media (max-width: 768px) { .practices-grid { grid-template-columns: 1fr; } .chat-message .msg-body { max-width: 90% !important; } }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
# AI Tools Chat-First Redesign Plan
|
||||
|
||||
## Context
|
||||
Current AI tools (DocumentTranslate, SmartAssistant, CopyProofreading, AudioTranscribe, BatchExtract, ContractReview) use a fixed "left-panel + right-panel" form layout. This creates siloed experiences with no context continuity between tasks.
|
||||
|
||||
Goal: Rebuild all tool pages to follow a ChatGPT-style conversational interface — unified chat flow, natural language input, file attachment support, and dialogue history.
|
||||
|
||||
---
|
||||
|
||||
## Current vs Target Layout
|
||||
|
||||
### Current (Form-based, per-tool)
|
||||
```
|
||||
┌──────────────────────────────────────┐
|
||||
│ StudioPage: Tool Cards Grid │
|
||||
│ [翻译] [校对] [转录] [提取] [审查] │
|
||||
└──────────────────────────────────────┘
|
||||
↓ Click any card → New page
|
||||
┌─────────────┬────────────────────────┐
|
||||
│ 左侧面板 │ 右侧面板 │
|
||||
│ • 选项 │ • 结果 │
|
||||
│ • 输入框 │ • 操作按钮 │
|
||||
│ [按钮] │ │
|
||||
└─────────────┴────────────────────────┘
|
||||
```
|
||||
|
||||
**Problems:**
|
||||
- Each tool is an isolated page — no context continuity
|
||||
- One-shot operation model — can't iterate naturally
|
||||
- User must re-enter configuration each time
|
||||
- No cross-tool integration or history
|
||||
|
||||
### Target (ChatGPT-style, unified)
|
||||
```
|
||||
┌───────────────────────────────────────────────────────────┐
|
||||
│ [📝 Tool Name] "Select tool... ▼" 🔍 [⚙] │
|
||||
├──────────┬────────────────────────────────────────────────┤
|
||||
│ │ ┌─ User message ────────────────────── │
|
||||
│ History │ │ "Translate this to English" │
|
||||
│ Sidebar │ │ │
|
||||
│ • Contract│ ─ AI ──────────────────────────────── │
|
||||
│ Trans. │ Translation result + summary │
|
||||
│ • Product │ [Continue] [Follow-up] [Copy] │
|
||||
│ Intro │ │
|
||||
│ • Sales │ ── User ─────────────────────────── │
|
||||
│ Copy │ "Also translate paragraph 3" │
|
||||
│ • Audio │ │
|
||||
│ Trans. │ ─ AI ──────────────────────────────── │
|
||||
│ │ "Paragraph 3 translated" │
|
||||
├──────────┴────────────────────────────────────────────────┤
|
||||
│ 📎[File] "Describe task..." [🎤] [▶] │
|
||||
└───────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Scope & Priority
|
||||
|
||||
### Tier 1: Convert to Chat-First (High Priority)
|
||||
|
||||
| Tool | Page | Action |
|
||||
|------|------|--------|
|
||||
| SmartAssistant | `SmartAssistantPage.vue` | Already chat-based → redesign to unified layout |
|
||||
| DocumentTranslate | `DocumentTranslatePage.vue` | Convert form → chat with file attachment |
|
||||
| AudioTranscribe | `AudioTranscribePage.vue` | Convert form → chat with audio upload |
|
||||
| CopyProofreading | `CopyProofreadingPage.vue` | Convert form → chat with text/file input |
|
||||
| BatchExtract | `BatchExtractPage.vue` | Convert form → chat with structured output |
|
||||
|
||||
### Tier 2: Hybrid Approach (Medium Priority)
|
||||
|
||||
| Tool | Page | Action |
|
||||
|------|------|--------|
|
||||
| ContractReview | `ContractReviewPage.vue` | Chat-first + expandable config panel |
|
||||
| ReportGeneration | `ReportGenerationPage.vue` | Keep form layout (complex config) |
|
||||
|
||||
### Tier 3: Infrastructure (Supporting)
|
||||
|
||||
| Component | Action |
|
||||
|-----------|--------|
|
||||
| `MainLayout.vue` | Add global sidebar for chat history |
|
||||
| New component | `ChatInputBar.vue` — unified bottom input with file attach |
|
||||
| New component | `ChatMessage.vue` — message bubble with actions |
|
||||
| New component | `HistorySidebar.vue` — conversation list |
|
||||
| Router refactor | Tools route to chat shell → tool page |
|
||||
|
||||
---
|
||||
|
||||
## Detailed Design
|
||||
|
||||
### 1. Unified Chat Layout Shell
|
||||
|
||||
All chat-first tool pages will share a common layout:
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────────────────┐
|
||||
│ Header: Title + Tool Selector + Search + Settings │
|
||||
├──────────┬────────────────────────────────────────────────┤
|
||||
│ History │ Chat Stream (scrollable, message bubbles) │
|
||||
│ Sidebar │ │
|
||||
│ │ ── User ───────────────────────────── │
|
||||
│ │ ── AI ────────────────────────────── │
|
||||
│ │ ── AI (file result) ──────────────── │
|
||||
├──────────┴────────────────────────────────────────────────┤
|
||||
│ Bottom Bar: [📎] [🎤] "Input..." [▶] │
|
||||
└───────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2. Input Bar Features
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 📎 File | 🎤 Audio | "Describe your task..." [▶] │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **File attachment**: Drag-drop or click to upload (supports all tool types)
|
||||
- **Audio input**: Quick record/upload for ASR tools
|
||||
- **Text input**: Main conversation input
|
||||
- **Send button**: Submit message
|
||||
|
||||
### 3. Message Types
|
||||
|
||||
| Type | Content | Actions |
|
||||
|------|---------|---------|
|
||||
| User text | Plain text | Edit (dual click) |
|
||||
| User file | Attached file card | Preview, Remove |
|
||||
| AI text | Markdown formatted | Copy, Continue, Follow-up |
|
||||
| AI file | Generated file card | Download, Open in editor |
|
||||
| AI structured | Tables, scores, lists | Export, Copy, Apply |
|
||||
|
||||
### 4. Tool-Specific Behaviors
|
||||
|
||||
#### Document Translate
|
||||
- User: "Translate this to French" + attach file or paste text
|
||||
- AI: Returns translated text in chat, offers download as PDF/DOCX
|
||||
- Follow-up: "Make it more formal" / "Also translate this paragraph"
|
||||
|
||||
#### Audio Transcribe
|
||||
- User: Attaches audio file
|
||||
- AI: Auto-transcribes and shows text in chat
|
||||
- Follow-up: "Summarize this" / "Extract key points"
|
||||
|
||||
#### Copy Proofreading
|
||||
- User: "Check this for typos" + paste text
|
||||
- AI: Returns highlighted corrections in chat with scores
|
||||
- Follow-up: "Fix the remaining issues" / "Make it more professional"
|
||||
|
||||
#### Batch Extract
|
||||
- User: "Extract name, phone, email from this" + paste/attach document
|
||||
- AI: Returns structured data table in chat
|
||||
- Follow-up: "Add company field" / "Export as CSV"
|
||||
|
||||
#### Contract Review
|
||||
- User: "Review this contract" + attach file
|
||||
- AI: Returns risk analysis, issues list, suggestions
|
||||
- Config panel: Expandable for setting risk levels, review dimensions
|
||||
|
||||
---
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Shared Components
|
||||
|
||||
| # | Component | Path | Description |
|
||||
|---|-----------|------|-------------|
|
||||
| 1 | `ChatLayout.vue` | `src/components/chat/ChatLayout.vue` | Main layout shell with sidebar + chat area + input bar |
|
||||
| 2 | `ChatInputBar.vue` | `src/components/chat/ChatInputBar.vue` | Unified bottom input with file attach, audio, send |
|
||||
| 3 | `ChatMessage.vue` | `src/components/chat/ChatMessage.vue` | Message bubble with role, content, actions |
|
||||
| 4 | `HistorySidebar.vue` | `src/components/chat/HistorySidebar.vue` | Conversation list sidebar with search |
|
||||
| 5 | `FileAttachment.vue` | `src/components/chat/FileAttachment.vue` | File upload & preview component |
|
||||
|
||||
### Phase 2: Convert SmartAssistant (Anchor Page)
|
||||
|
||||
| # | File | Action |
|
||||
|---|------|--------|
|
||||
| 6 | `SmartAssistantPage.vue` | Rewrite to ChatLayout with unified input bar, history sidebar, message bubbles |
|
||||
| 7 | Refactor `chatWithAssistant` API | Ensure it supports mode-aware conversation format |
|
||||
|
||||
### Phase 3: Convert DocumentTranslate
|
||||
|
||||
| # | File | Action |
|
||||
|---|------|--------|
|
||||
| 8 | `DocumentTranslatePage.vue` | Replace form with ChatLayout, convert to chat-first flow |
|
||||
| 9 | Update `api/document.js` | Add conversation-aware endpoint (or adapt existing) |
|
||||
|
||||
### Phase 4: Convert AudioTranscribe
|
||||
|
||||
| # | File | Action |
|
||||
|---|------|--------|
|
||||
| 10 | `AudioTranscribePage.vue` | Replace form with ChatLayout, audio upload in input bar |
|
||||
|
||||
### Phase 5: Convert CopyProofreading
|
||||
|
||||
| # | File | Action |
|
||||
|---|------|--------|
|
||||
| 11 | `CopyProofreadingPage.vue` | Replace form with ChatLayout, add file attach for proofreading |
|
||||
|
||||
### Phase 6: Convert BatchExtract
|
||||
|
||||
| # | File | Action |
|
||||
|---|------|--------|
|
||||
| 12 | `BatchExtractPage.vue` | Replace form with ChatLayout, structured table output in chat |
|
||||
|
||||
### Phase 7: ContractReview (Hybrid)
|
||||
|
||||
| # | File | Action |
|
||||
|---|------|--------|
|
||||
| 13 | `ContractReviewPage.vue` | Chat-first with collapsible config panel |
|
||||
|
||||
### Phase 8: Integration & Polish
|
||||
|
||||
| # | Area | Action |
|
||||
|---|------|--------|
|
||||
| 14 | `MainLayout.vue` | Add global chat history sidebar if needed |
|
||||
| 15 | `router/index.js` | Update tool routes to point to chat pages |
|
||||
| 16 | `StudioPage.vue` | Update tool card navigation → go to chat pages |
|
||||
| 17 | All pages | Consistent styling, animations, loading states |
|
||||
| 18 | Backend | Ensure all API endpoints support chat-aware request format |
|
||||
|
||||
---
|
||||
|
||||
## File Attachment Strategy
|
||||
|
||||
| Tool | Accept Types | Upload Method |
|
||||
|------|-------------|---------------|
|
||||
| DocumentTranslate | .txt, .docx, .pdf, .pptx, .xlsx | Drag-drop or file picker |
|
||||
| AudioTranscribe | .mp3, .wav, .m4a, .ogg, .flac, .aac, .wma | Drag-drop or file picker |
|
||||
| CopyProofreading | .txt, .docx, paste text | Drag-drop / paste / file picker |
|
||||
| BatchExtract | .txt, .docx, .pdf, paste text | Drag-drop / paste / file picker |
|
||||
| ContractReview | .docx, .pdf | Drag-drop or file picker |
|
||||
|
||||
**Unified approach**: All chat pages share a single `FileAttachment.vue` component that auto-detects supported types per tool.
|
||||
|
||||
---
|
||||
|
||||
## Conversation History Storage
|
||||
|
||||
| Level | Approach | Pros | Cons |
|
||||
|-------|----------|------|------|
|
||||
| Local only | `localStorage` per page | Simple, no backend change | Lost on clear cache, single device |
|
||||
| Backend DB | New `conversations` table | Persistent, multi-device | Requires schema change, API |
|
||||
|
||||
**Recommendation**: Start with `localStorage` (Phase 2-6), upgrade to backend storage in Phase 8 if needed.
|
||||
|
||||
---
|
||||
|
||||
## Key Decisions Needed from User
|
||||
|
||||
### D1. Scope: Full conversion or phased?
|
||||
- **Option A**: Full chat-first for all AI tools (faster long-term)
|
||||
- **Option B**: Only convert 3-4 tools first, keep complex tools as-is (safer, incremental)
|
||||
|
||||
### D2. History Persistence
|
||||
- **Option A**: LocalStorage only (quick, simple)
|
||||
- **Option B**: Backend database (persistent, more complex)
|
||||
|
||||
### D3. ContractReview & ReportGeneration
|
||||
- **Option A**: Convert to chat-first too (full consistency)
|
||||
- **Option B**: Keep original form layout (hybrid approach)
|
||||
|
||||
### D4. AI Intent Recognition
|
||||
- **Option A**: Manual tool selection dropdown at top of chat
|
||||
- **Option B**: AI auto-detects tool from user message (e.g., "translate" → translation mode)
|
||||
- **Option C**: Both — default auto-detect with manual override
|
||||
|
||||
### D5. Agent Mode
|
||||
- **Option A**: Simple chat only (user sends → AI responds)
|
||||
- **Option B**: Agent mode — AI can ask questions, break down tasks, execute multiple steps
|
||||
|
||||
---
|
||||
|
||||
## Migration Notes
|
||||
|
||||
### Breaking Changes
|
||||
- All tool pages' HTML structure will change completely
|
||||
- Existing API calls may need format adaptation (add mode/context fields)
|
||||
- Router paths stay the same — no user-facing URL changes
|
||||
|
||||
### Backward Compatibility
|
||||
- New chat shell can coexist with old pages during transition
|
||||
- Each page migrated independently — no big-bang switch
|
||||
- Old pages can be archived (not deleted) until all migrated
|
||||
|
||||
### Testing Strategy
|
||||
1. Start with SmartAssistantPage (already chat-based, lowest risk)
|
||||
2. Test chat-first flow end-to-end before converting other pages
|
||||
3. Each converted page: test file upload → AI response → follow-up messages
|
||||
4. Mobile responsive: verify responsive behavior before committing each page
|
||||
|
||||
---
|
||||
|
||||
## Estimated Effort
|
||||
|
||||
| Phase | Pages | Est. Effort |
|
||||
|-------|-------|-------------|
|
||||
| Phase 1: Shared Components | 5 files | ~2-3 hours |
|
||||
| Phase 2: SmartAssistant | 1 file | ~1 hour |
|
||||
| Phase 3: DocumentTranslate | 1 file | ~2 hours |
|
||||
| Phase 4: AudioTranscribe | 1 file | ~2 hours |
|
||||
| Phase 5: CopyProofreading | 1 file | ~2 hours |
|
||||
| Phase 6: BatchExtract | 1 file | ~2 hours |
|
||||
| Phase 7: ContractReview | 1 file | ~2 hours |
|
||||
| Phase 8: Integration & Polish | Multiple | ~3-4 hours |
|
||||
| **Total** | **~12 files + shared** | **~16-18 hours** |
|
||||
@@ -0,0 +1,873 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AI Tools Chat-First Redesign Preview (WorkBuddy Style)</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f0f2f5; height: ; }
|
||||
|
||||
/* ===== LEFT SIDEBAR (Narrow) ===== */
|
||||
.sidebar {
|
||||
width: 220px;
|
||||
background: #f7f8fa;
|
||||
border-right: 1px solid #e8ecf1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
}
|
||||
.sidebar-toggle-handle {
|
||||
position: absolute;
|
||||
right: -8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 16px;
|
||||
height: 48px;
|
||||
background: #fff;
|
||||
border: 1px solid #e0e6ed;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: #909399;
|
||||
font-size: 10px;
|
||||
box-shadow: 1px 0 4px rgba(0,0,0,0.04);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 16px 14px 12px;
|
||||
border-bottom: 1px solid #eef1f6;
|
||||
}
|
||||
.new-task-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: linear-gradient(135deg, #409eff 0%, #2b63d9 100%);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.2s, transform 0.15s;
|
||||
width: 100%;
|
||||
}
|
||||
.new-task-btn:hover { box-shadow: 0 4px 12px rgba(64,158,255,0.35); transform: translateY(-1px); }
|
||||
.new-task-btn .icon { font-size: 16px; }
|
||||
|
||||
.sidebar-nav {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 9px 10px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
font-size: 13px;
|
||||
color: #303133;
|
||||
}
|
||||
.nav-item:hover { background: #eef1f6; }
|
||||
.nav-item.active { background: #e1effe; color: #409eff; font-weight: 600; }
|
||||
.nav-item .nav-icon { font-size: 15px; width: 20px; text-align: center; flex-shrink: 0; }
|
||||
.nav-item .nav-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.nav-divider {
|
||||
height: 1px;
|
||||
background: #eef1f6;
|
||||
margin: 6px 10px;
|
||||
}
|
||||
|
||||
.sidebar-section {
|
||||
padding: 8px 10px 4px;
|
||||
}
|
||||
.sidebar-section-title {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #909399;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
padding: 4px 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
}
|
||||
.sidebar-section-title .count {
|
||||
font-size: 11px;
|
||||
color: #909399;
|
||||
font-weight: 400;
|
||||
}
|
||||
.task-item {
|
||||
padding: 8px 10px 8px 12px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
line-height: 1.5;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.task-item:hover { background: #eef1f6; }
|
||||
.task-item.active { background: #e1effe; color: #409eff; font-weight: 600; }
|
||||
|
||||
.sidebar-footer {
|
||||
margin-top: auto;
|
||||
padding: 12px 14px;
|
||||
border-top: 1px solid #eef1f6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.sidebar-footer .avatar {
|
||||
width: 28px; height: 28px;
|
||||
border-radius: 50%;
|
||||
background: #409eff;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sidebar-footer .user-info {
|
||||
font-size: 12px;
|
||||
color: #303133;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sidebar-footer .user-info small {
|
||||
font-size: 11px;
|
||||
color: #909399;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* ===== MAIN CONTENT AREA ===== */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Top bar (minimal) */
|
||||
.top-bar {
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
gap: 12px;
|
||||
background: transparent;
|
||||
border-bottom: 1px solid #eef1f6;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.top-bar .workspace-select {
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
.top-bar .workspace-select:hover { background: #f5f7fa; }
|
||||
.top-bar .permission-select {
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
.top-bar .spacer { flex: 1; }
|
||||
.top-bar .header-icon-btn {
|
||||
width: 32px; height: 32px;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #909399;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.top-bar .header-icon-btn:hover { background: #f5f7fa; }
|
||||
|
||||
/* Center workspace */
|
||||
.workspace {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 40px 24px 40px;
|
||||
}
|
||||
|
||||
/* ===== WELCOME STATE ===== */
|
||||
.welcome-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
max-width: 720px;
|
||||
gap: 0;
|
||||
}
|
||||
.welcome-title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #1f2d3d;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.welcome-subtitle {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* Category pills */
|
||||
.category-pills {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
.category-pill {
|
||||
padding: 7px 14px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #e0e6ed;
|
||||
background: #fff;
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
.category-pill:hover { border-color: #409eff; color: #409eff; background: #ecf5ff; }
|
||||
.category-pill.active { border-color: #409eff; color: #409eff; background: #ecf5ff; font-weight: 600; }
|
||||
.category-pill .pill-icon { font-size: 14px; }
|
||||
|
||||
/* ===== MAIN INPUT BOX ===== */
|
||||
.main-input {
|
||||
width: 100%;
|
||||
min-height: 180px;
|
||||
background: #fff;
|
||||
border: 1px solid #e0e6ed;
|
||||
border-radius: 16px;
|
||||
padding: 16px 18px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
cursor: text;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.04);
|
||||
}
|
||||
.main-input:focus-within {
|
||||
border-color: #409eff;
|
||||
box-shadow: 0 2px 16px rgba(64,158,255,0.1);
|
||||
}
|
||||
.main-input textarea {
|
||||
border: none;
|
||||
outline: none;
|
||||
resize: none;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
font-family: inherit;
|
||||
color: #303133;
|
||||
min-height: 140px;
|
||||
background: transparent;
|
||||
}
|
||||
.main-input textarea::placeholder { color: #c0c8d4; }
|
||||
|
||||
.input-bottom-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #f0f2f6;
|
||||
}
|
||||
.input-left-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.input-action-btn {
|
||||
width: 32px; height: 32px;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #909399;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.input-action-btn:hover { background: #f5f7fa; color: #409eff; }
|
||||
|
||||
.input-right-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.quick-mode-btn {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.quick-mode-btn:hover { background: #f5f7fa; }
|
||||
|
||||
.send-circle {
|
||||
width: 34px; height: 34px;
|
||||
background: #409eff;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
box-shadow: 0 2px 8px rgba(64,158,255,0.3);
|
||||
}
|
||||
.send-circle:hover { transform: scale(1.08); box-shadow: 0 4px 12px rgba(64,158,255,0.4); }
|
||||
.send-circle:active { transform: scale(0.95); }
|
||||
.send-circle.disabled { background: #dcdfe6; box-shadow: none; cursor: default; transform: none; }
|
||||
|
||||
.workspace-info-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 24px;
|
||||
margin-top: 12px;
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
}
|
||||
.workspace-info-bar button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #909399;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.workspace-info-bar button:hover { color: #606266; }
|
||||
|
||||
/* ===== BEST PRACTICE CARDS ===== */
|
||||
.best-practices {
|
||||
width: 100%;
|
||||
max-width: 720px;
|
||||
margin-top: 40px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid #eef1f6;
|
||||
}
|
||||
.best-practices-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.best-practices-header .title { font-size: 13px; color: #909399; }
|
||||
.best-practices-header .refresh {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: none;
|
||||
border: none;
|
||||
}
|
||||
.best-practices-header .close-btn {
|
||||
font-size: 16px;
|
||||
color: #909399;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.practice-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
.practice-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e8ecf1;
|
||||
border-radius: 12px;
|
||||
padding: 14px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, box-shadow 0.2s, transform 0.15s;
|
||||
text-align: left;
|
||||
overflow: hidden;
|
||||
}
|
||||
.practice-card:hover {
|
||||
border-color: #409eff;
|
||||
box-shadow: 0 2px 12px rgba(64,155,255,0.08);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.practice-card .pc-image {
|
||||
width: 100%;
|
||||
aspect-ratio: 4/3;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 28px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.practice-card .pc-image.bg1 { background: linear-gradient(135deg, #e8f5e9, #c8e6c9); }
|
||||
.practice-card .pc-image.bg2 { background: linear-gradient(135deg, #e3f2fd, #bbdefb); }
|
||||
.practice-card .pc-image.bg3 { background: linear-gradient(135deg, #fff3e0, #ffe0b2); }
|
||||
.practice-card .pc-image.bg4 { background: linear-gradient(135deg, #fce4ec, #f8bbd0); }
|
||||
.practice-card .pc-title {
|
||||
font-size: 12px;
|
||||
color: #303133;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.practice-card .pc-meta {
|
||||
font-size: 11px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
/* ===== CHAT STATE (demo mode) ===== */
|
||||
.chat-state { display: none; width: 100%; max-width: 720px; }
|
||||
.chat-state.visible { display: flex; flex-direction: column; }
|
||||
.chat-messages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
.chat-message {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
.chat-message.user { flex-direction: row-reverse; }
|
||||
.cm-avatar {
|
||||
width: 32px; height: 32px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 15px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.cm-avatar.ai { background: linear-gradient(135deg, #409eff, #63b3ff); color: #fff; }
|
||||
.cm-avatar.u { background: #f5f7fa; border: 1px solid #e8ecf1; }
|
||||
.cm-bubble {
|
||||
padding: 12px 16px;
|
||||
border-radius: 14px;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
max-width: 100%;
|
||||
}
|
||||
.chat-message.user .cm-bubble { background: #2b63d9; color: #fff; border-bottom-right-radius: 4px; }
|
||||
.chat-message.assistant .cm-bubble { background: #fff; color: #303133; border: 1px solid #eef1f6; border-bottom-left-radius: 4px; }
|
||||
.cm-bubble .cm-actions { display: flex; gap: 6px; margin-top: 8px; padding-top: 8px; border-top: 1px solid #f0f2f6; }
|
||||
.cm-actions button {
|
||||
background: #f8f9fb; border: 1px solid #e8ecf1; border-radius: 6px;
|
||||
padding: 3px 8px; font-size: 12px; color: #6b7785; cursor: pointer;
|
||||
display: flex; align-items: center; gap: 3px;
|
||||
}
|
||||
.cm-actions button:hover { background: #eef1f6; }
|
||||
|
||||
.typing-dots { display: flex; gap: 3px; padding: 4px 0; }
|
||||
.typing-dots span { width: 6px; height: 6px; background: #909399; border-radius: 50%; animation: typingDot 1.4s infinite; }
|
||||
.typing-dots span:nth-child(2) { animation-delay: 0.2s; }
|
||||
.typing-dots span:nth-child(3) { animation-delay: 0.4s; }
|
||||
@keyframes typingDot { 0%, 60%, 100% { opacity: 0.3; transform: translateY(0); } 30% { opacity: 1; transform: translateY(-3px); } }
|
||||
|
||||
/* ===== MODE SWITCHER (top of workspace) ===== */
|
||||
.mode-switcher {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
.mode-pill {
|
||||
padding: 6px 14px;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
border: 1px solid #e0e6ed;
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
.mode-pill:hover { border-color: #409eff; color: #409eff; }
|
||||
.mode-pill.active { background: #409eff; color: #fff; border-color: #409eff; font-weight: 600; }
|
||||
.mode-pill .pill-icon { font-size: 14px; }
|
||||
|
||||
/* ===== SCROLLBAR ===== */
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: #d0d7e2; border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #b0bac6; }
|
||||
|
||||
/* ===== RESPONSIVE ===== */
|
||||
@media (max-width: 900px) {
|
||||
.sidebar { width: 60px; }
|
||||
.nav-label, .sidebar-section-title, .task-item, .sidebar-footer .user-info { display: none; }
|
||||
.nav-item { justify-content: center; padding: 10px; }
|
||||
.practice-cards { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body style="display:flex; height:100vh; overflow:hidden;">
|
||||
|
||||
<!-- ===== LEFT SIDEBAR ===== -->
|
||||
<div class="sidebar" id="sidebar">
|
||||
<div class="sidebar-toggle-handle">◁</div>
|
||||
<div class="sidebar-header">
|
||||
<button class="new-task-btn" onclick="newChat()">
|
||||
<span class="icon">+</span> 新建任务
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-nav">
|
||||
<div class="nav-item active" onclick="selectNav('assistant')">
|
||||
<span class="nav-icon">💬</span>
|
||||
<span class="nav-label">助理</span>
|
||||
</div>
|
||||
<div class="nav-item" onclick="selectNav('project')">
|
||||
<span class="nav-icon">📂</span>
|
||||
<span class="nav-label">项目</span>
|
||||
</div>
|
||||
<div class="nav-item">
|
||||
<span class="nav-icon">🧠</span>
|
||||
<span class="nav-label">专家·技能·连接器</span>
|
||||
</div>
|
||||
<div class="nav-item">
|
||||
<span class="nav-icon">⏰</span>
|
||||
<span class="nav-label">定时任务</span>
|
||||
</div>
|
||||
<div class="nav-item">
|
||||
<span class="nav-icon">📚</span>
|
||||
<span class="nav-label">资料库</span>
|
||||
</div>
|
||||
<div class="nav-divider"></div>
|
||||
<div class="nav-item">
|
||||
<span class="nav-icon">⋯</span>
|
||||
<span class="nav-label">更多</span>
|
||||
<span style="margin-left:auto;font-size:11px;color:#c0c8d4;">灵感</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-section-title" onclick="toggleTasks()">
|
||||
<span>任务</span>
|
||||
<span class="count">11 ▾</span>
|
||||
</div>
|
||||
<div class="task-item active">2026 当中 GPU - 显...</div>
|
||||
<div class="task-item">搜索分析零工类应用</div>
|
||||
<div class="task-item">将微信文章全部内容...</div>
|
||||
<div class="task-item">管理企业微信待办...</div>
|
||||
<div class="task-item">管理企业微信待办...</div>
|
||||
<div style="font-size:11px;color:#909399;padding:6px 10px;cursor:pointer;">查看更多 (6)</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<div class="avatar">伯</div>
|
||||
<div class="user-info">
|
||||
明伯-工作
|
||||
<br><small style="color:#909399;">内网</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== MAIN CONTENT ===== -->
|
||||
<div class="main-content" style="display:flex;flex-direction:column;flex:1;overflow:hidden;">
|
||||
|
||||
<!-- Top bar -->
|
||||
<div class="top-bar">
|
||||
<button class="workspace-select">
|
||||
<span>选择工作空间</span> ▾
|
||||
</button>
|
||||
<button class="permission-select">
|
||||
<span>默认权限</span> ▾
|
||||
</button>
|
||||
<div class="spacer"></div>
|
||||
<button class="header-icon-btn">🔔</button>
|
||||
</div>
|
||||
|
||||
<!-- Workspace -->
|
||||
<div class="workspace" style="overflow-y:auto;">
|
||||
|
||||
<!-- Welcome state -->
|
||||
<div class="welcome-state" id="welcomeState">
|
||||
<div class="welcome-title">AI Tools, 我帮你</div>
|
||||
<div class="welcome-subtitle">今天帮你做些什么? @ 引用对话文件,/ 调用技能与指令</div>
|
||||
|
||||
<!-- Category pills -->
|
||||
<div class="category-pills">
|
||||
<div class="category-pill active" onclick="selectCategory(this)">
|
||||
<span class="pill-icon">📝</span> 文档处理
|
||||
</div>
|
||||
<div class="category-pill" onclick="selectCategory(this)">
|
||||
<span class="pill-icon">📊</span> 金融服务
|
||||
</div>
|
||||
<div class="category-pill" onclick="selectCategory(this)">
|
||||
<span class="pill-icon">📈</span> 数据分析及可视化
|
||||
</div>
|
||||
<div class="category-pill" onclick="selectCategory(this)">
|
||||
<span class="pill-icon">🛠️</span> 个人工作台
|
||||
</div>
|
||||
<div class="category-pill" onclick="selectCategory(this)">
|
||||
<span class="pill-icon">📑</span> 幻灯片
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mode switcher -->
|
||||
<div class="mode-switcher">
|
||||
<div class="mode-pill active" onclick="selectMode(this)">💬 对话</div>
|
||||
<div class="mode-pill" onclick="selectMode(this)">📋 任务拆解</div>
|
||||
<div class="mode-pill" onclick="selectMode(this)">✏️ 文案生成</div>
|
||||
<div class="mode-pill" onclick="selectMode(this)">📊 批量提取</div>
|
||||
</div>
|
||||
|
||||
<!-- Main input box -->
|
||||
<div class="main-input" onclick="focusInput()">
|
||||
<textarea id="mainTextarea" placeholder="描述你的任务,或直接粘贴内容..."></textarea>
|
||||
<div class="input-bottom-bar">
|
||||
<div class="input-left-actions">
|
||||
<button class="input-action-btn" title="添加">+</button>
|
||||
<button class="input-action-btn" title="文件">📎</button>
|
||||
<button class="input-action-btn" title="语音">🎤</button>
|
||||
<button class="input-action-btn" title="引用">📑</button>
|
||||
</div>
|
||||
<div class="input-right-actions">
|
||||
<span class="quick-mode-btn">⚡ 快速 ▾</span>
|
||||
<button class="send-circle disabled" id="sendBtn" title="发送">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M22 2L11 13"/><path d="M22 2L15 22L11 13L2 9L22 2Z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="workspace-info-bar">
|
||||
<button>选择工作空间</button>
|
||||
<button>默认权限</button>
|
||||
</div>
|
||||
|
||||
<!-- Best practice cards -->
|
||||
<div class="best-practices">
|
||||
<div class="best-practices-header">
|
||||
<span class="title">不知道做什么,试试最佳实践案例</span>
|
||||
<div style="display:flex;gap:8px;align-items:center;">
|
||||
<button class="refresh">🔄 换一批</button>
|
||||
<button class="close-btn">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="practice-cards">
|
||||
<div class="practice-card" onclick="fillInput('帮我翻译这段产品说明到英文,保持专业语气')">
|
||||
<div class="pc-image bg1">📄</div>
|
||||
<div class="pc-title">八十二亿之后:全球人口趋势分析报告</div>
|
||||
<div class="pc-meta">多语言翻译 · 专业文档</div>
|
||||
</div>
|
||||
<div class="practice-card" onclick="fillInput('检查销售话术文案的错别字和语病')">
|
||||
<div class="pc-image bg2">📊</div>
|
||||
<div class="pc-title">养老退休规划方案</div>
|
||||
<div class="pc-meta">文案校对 · 金融合规</div>
|
||||
</div>
|
||||
<div class="practice-card" onclick="fillInput('审查这份采购合同的法律风险')">
|
||||
<div class="pc-image bg3">📋</div>
|
||||
<div class="pc-title">《思考,快与慢》精读笔记整理</div>
|
||||
<div class="pc-meta">合同审查 · 风险评估</div>
|
||||
</div>
|
||||
<div class="practice-card" onclick="fillInput('帮我生成新产品上市的 GTM 发布计划')">
|
||||
<div class="pc-image bg4">🚀</div>
|
||||
<div class="pc-title">新产品上市 GTM 发布计划</div>
|
||||
<div class="pc-meta">文案生成 · 营销策略</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chat state (demo mode) -->
|
||||
<div class="chat-state" id="chatState">
|
||||
<div class="chat-messages" style="display:flex;flex-direction:column;gap:20px;width:100%;">
|
||||
|
||||
<!-- User message -->
|
||||
<div class="chat-message user">
|
||||
<div class="cm-avatar u">👤</div>
|
||||
<div class="cm-bubble">
|
||||
把这份产品手册翻译成英文,保持专业语气,可以导出为 PDF。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI response with card -->
|
||||
<div class="chat-message assistant">
|
||||
<div class="cm-avatar ai">🤖</div>
|
||||
<div class="cm-bubble">
|
||||
翻译完成!共 <strong>5 页</strong>,<strong>2,847 字</strong>。翻译质量评分 <strong>96</strong>。
|
||||
<div style="margin-top:10px;background:#f8f9fb;border:1px solid #e8ecf1;border-radius:10px;padding:12px;">
|
||||
<div style="font-size:12px;color:#909399;margin-bottom:8px;">⚠️ 2 处术语需要确认</div>
|
||||
<div style="display:flex;flex-direction:column;gap:6px;">
|
||||
<div style="border-left:3px solid #e6a23c;padding-left:10px;font-size:13px;">
|
||||
<strong>术语建议</strong><br>
|
||||
<span style="color:#606266;">"产品优势" → "Product Advantages"<br>建议改为 "Key Benefits"</span>
|
||||
</div>
|
||||
<div style="border-left:3px solid #909399;padding-left:10px;font-size:13px;">
|
||||
<strong>格式提示</strong><br>
|
||||
<span style="color:#606266;">表格 "表 3-2" 已译为 "Table 3-2",格式已保留</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cm-actions">
|
||||
<button>📋 复制</button>
|
||||
<button>⬇️ 下载 PDF</button>
|
||||
<button>🔄 调整语气</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User follow-up -->
|
||||
<div class="chat-message user">
|
||||
<div class="cm-avatar u">👤</div>
|
||||
<div class="cm-bubble">
|
||||
把 "Product Advantages" 改成 "Key Benefits",再导出为 PDF。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI response -->
|
||||
<div class="chat-message assistant">
|
||||
<div class="cm-avatar ai">🤖</div>
|
||||
<div class="cm-bubble">
|
||||
✅ 已替换,共修改 <strong>12 处</strong>。PDF 已准备好下载。
|
||||
<div class="cm-actions">
|
||||
<button>📋 复制</button>
|
||||
<button>⬇️ 下载 PDF</button>
|
||||
<button>🔄 再调整</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Typing indicator -->
|
||||
<div class="chat-message assistant" style="opacity:0.6;">
|
||||
<div class="cm-avatar ai">🤖</div>
|
||||
<div class="cm-bubble">
|
||||
<div class="typing-dots"><span></span><span></span><span></span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Chat input (compact, centered) -->
|
||||
<div style="width:100%;max-width:720;margin-top:12px;">
|
||||
<div class="main-input" style="min-height:80px;">
|
||||
<textarea placeholder="继续对话..."></textarea>
|
||||
<div class="input-bottom-bar">
|
||||
<div class="input-left-actions">
|
||||
<button class="input-action-btn">+</button>
|
||||
<button class="input-action-btn">📎</button>
|
||||
</div>
|
||||
<div class="input-right-actions">
|
||||
<button class="send-circle">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M22 2L11 13"/><path d="M22 2L15 22L11 13L2 9L22 2Z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const mainTextarea = document.getElementById('mainTextarea');
|
||||
|
||||
function focusInput() {
|
||||
mainTextarea.focus();
|
||||
}
|
||||
|
||||
function fillInput(text) {
|
||||
document.getElementById('welcomeState').style.display = 'none';
|
||||
document.getElementById('chatState').style.display = 'block';
|
||||
mainTextarea.value = text;
|
||||
adjustTextarea(mainTextarea);
|
||||
}
|
||||
|
||||
function newChat() {
|
||||
document.getElementById('welcomeState').style.display = 'flex';
|
||||
document.getElementById('chatState').style.display = 'none';
|
||||
mainTextarea.value = '';
|
||||
}
|
||||
|
||||
function selectCategory(el) {
|
||||
document.querySelectorAll('.category-pill').forEach(p => p.classList.remove('active'));
|
||||
el.classList.add('active');
|
||||
}
|
||||
|
||||
function selectMode(el) {
|
||||
document.querySelectorAll('.mode-pill').forEach(p => p.classList.remove('active'));
|
||||
el.classList.add('active');
|
||||
}
|
||||
|
||||
function selectNav(key) {
|
||||
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
|
||||
event.currentTarget.classList.add('active');
|
||||
}
|
||||
|
||||
function toggleTasks() {}
|
||||
|
||||
mainTextarea.addEventListener('input', function() {
|
||||
adjustTextarea(this);
|
||||
});
|
||||
|
||||
function adjustTextarea(el) {
|
||||
el.style.height = 'auto';
|
||||
el.style.height = Math.max(140, el.scrollHeight) + 'px';
|
||||
}
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,198 +1,365 @@
|
||||
<template>
|
||||
<div class="contract-review-page">
|
||||
<header class="page-header">
|
||||
<div class="header-left">
|
||||
<h1>合同审查</h1>
|
||||
<p>AI 智能审查合同条款完整性、付款/违约/争议条款质量,提供风险评分和修改建议</p>
|
||||
<div class="category-bar">
|
||||
<div class="category-pills">
|
||||
<div class="category-pill active" @click="selectedCategory = 'doc'">
|
||||
<span class="pill-icon">📝</span> 文档处理
|
||||
</div>
|
||||
<div class="category-pill" @click="selectedCategory = 'finance'">
|
||||
<span class="pill-icon">📊</span> 金融服务
|
||||
</div>
|
||||
<div class="category-pill" @click="selectedCategory = 'data'">
|
||||
<span class="pill-icon">📈</span> 数据分析及可视化
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="main-layout">
|
||||
<section class="input-panel">
|
||||
<div class="card">
|
||||
<div class="card-title">合同内容</div>
|
||||
<el-input v-model="form.content" type="textarea" :rows="18" placeholder="粘贴合同正文,AI 将自动解析章节并审查条款" />
|
||||
<div class="actions">
|
||||
<el-button type="primary" :loading="reviewing" @click="review" style="width:200px">
|
||||
<el-icon v-if="!reviewing"><Search /></el-icon>
|
||||
{{ reviewing ? '审查中...' : '开始审查' }}
|
||||
</el-button>
|
||||
<el-select v-model="form.risk_standard" style="width:150px" placeholder="审查标准">
|
||||
<el-option label="标准" value="standard" />
|
||||
<el-option label="严格" value="strict" />
|
||||
</el-select>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="preview-panel">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-title">审查结果</div>
|
||||
<div class="card-actions" v-if="result">
|
||||
<el-button size="small" @click="copyResult"><el-icon><DocumentCopy /></el-icon> 复制</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!result" class="empty-preview">
|
||||
<span class="icon-item">📋</span>
|
||||
<h3>审查结果预览区</h3>
|
||||
<p>在左侧粘贴合同内容后点击「开始审查」</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="result-area">
|
||||
<!-- 评分 -->
|
||||
<div class="score-section">
|
||||
<div class="score-ring" :class="scoreClass">
|
||||
<span class="score-value">{{ result.score }}</span>
|
||||
<span class="score-label">安全评分</span>
|
||||
</div>
|
||||
<div class="score-stats">
|
||||
<div class="stat-item"><span class="stat-num">{{ result.critical_risks }}</span><span>严重</span></div>
|
||||
<div class="stat-item"><span class="stat-num">{{ result.high_risks }}</span><span>高</span></div>
|
||||
<div class="stat-item"><span class="stat-num">{{ result.medium_risks }}</span><span>中</span></div>
|
||||
<div class="stat-item"><span class="stat-num">{{ result.low_risks }}</span><span>低</span></div>
|
||||
<div class="stat-item total"><span class="stat-num">{{ result.total_risks }}</span><span>总计</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 风险总结 -->
|
||||
<div class="risk-summary" :class="scoreClass">
|
||||
<strong>风险总结:</strong>{{ result.risk_summary || '合同整体质量良好' }}
|
||||
</div>
|
||||
|
||||
<!-- 建议 -->
|
||||
<div class="suggestions" v-if="result.suggestions">
|
||||
<strong>修改建议:</strong>
|
||||
<p>{{ result.suggestions }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 风险列表 -->
|
||||
<div class="risks-list">
|
||||
<h4>风险详情 ({{ result.total_risks }})</h4>
|
||||
<div v-for="(risk, idx) in result.risks" :key="idx" class="risk-card" :class="risk.level">
|
||||
<div class="risk-header">
|
||||
<el-tag size="small" :type="levelTag(risk.level)">{{ levelLabel(risk.level) }}</el-tag>
|
||||
<span class="risk-section">{{ risk.section }}</span>
|
||||
<span class="risk-type">{{ risk.type }}</span>
|
||||
</div>
|
||||
<div class="risk-body">
|
||||
<div class="problem"><strong>问题:</strong>{{ risk.problem }}</div>
|
||||
<div class="suggestion" v-if="risk.suggestion"><strong>建议:</strong>{{ risk.suggestion }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<ChatLayout
|
||||
ref="chatLayoutRef"
|
||||
:has-messages="messages.length > 1"
|
||||
:active-nav-item="activeNavItem"
|
||||
:history-count="3"
|
||||
:task-items="taskItems"
|
||||
@new-chat="handleNewChat"
|
||||
@select-task="handleSelectTask"
|
||||
>
|
||||
<!-- Welcome -->
|
||||
<template #welcome>
|
||||
<div class="welcome-content">
|
||||
<div class="welcome-title">合同审查</div>
|
||||
<div class="welcome-subtitle">上传或粘贴合同文本,AI 自动审查法律风险、条款完整性、修改建议</div>
|
||||
|
||||
<!-- Config panel (collapsible) -->
|
||||
<div class="config-panel">
|
||||
<div class="config-header" @click="configCollapsed = !configCollapsed">
|
||||
<span>⚙️ 审查设置</span>
|
||||
<span class="collapse-icon">{{ configCollapsed ? '▼' : '▲' }}</span>
|
||||
</div>
|
||||
<div v-if="!configCollapsed" class="config-body">
|
||||
<div class="config-row">
|
||||
<div class="config-select">
|
||||
<label>风险等级</label>
|
||||
<select v-model="form.risk_level">
|
||||
<option value="strict">严格</option>
|
||||
<option value="normal">标准</option>
|
||||
<option value="relaxed">宽松</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="config-select">
|
||||
<label>审查维度</label>
|
||||
<select v-model="form.dimensions">
|
||||
<option value="full">全部维度</option>
|
||||
<option value="basic">基础审查</option>
|
||||
<option value="compliance">合规审查</option>
|
||||
<option value="financial">财务条款</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="config-select">
|
||||
<label>行业类型</label>
|
||||
<select v-model="form.industry">
|
||||
<option value="general">通用</option>
|
||||
<option value="tech">科技</option>
|
||||
<option value="finance">金融</option>
|
||||
<option value="manufacturing">制造</option>
|
||||
<option value="trade">贸易</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="practices-grid">
|
||||
<div class="practice-card" @click="fillPractice('审查这份采购合同的法律风险')">
|
||||
<div class="card-image bg-purchase">📋</div>
|
||||
<div class="card-title">采购合同审查</div>
|
||||
<div class="card-meta">法律风险 · 条款完整性</div>
|
||||
</div>
|
||||
<div class="practice-card" @click="fillPractice('审查这份销售合同的合规性')">
|
||||
<div class="card-image bg-sale">📄</div>
|
||||
<div class="card-title">销售合同审查</div>
|
||||
<div class="card-meta">合规性 · 财务条款</div>
|
||||
</div>
|
||||
<div class="practice-card" @click="fillPractice('审查这份劳动合同的条款')">
|
||||
<div class="card-image bg-labor">👥</div>
|
||||
<div class="card-title">劳动合同审查</div>
|
||||
<div class="card-meta">劳动法规 · 薪酬条款</div>
|
||||
</div>
|
||||
<div class="practice-card" @click="fillPractice('审查这份保密协议的完整性和有效性')">
|
||||
<div class="card-image bg-nDA">🔒</div>
|
||||
<div class="card-title">保密协议审查</div>
|
||||
<div class="card-meta">保密条款 · 违约责任</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Messages -->
|
||||
<template #messages>
|
||||
<div
|
||||
v-for="(msg, idx) in messages"
|
||||
:key="idx"
|
||||
class="chat-message"
|
||||
:class="msg.role"
|
||||
>
|
||||
<div class="msg-avatar" :class="msg.role">
|
||||
{{ msg.role === 'user' ? '👤' : '🤖' }}
|
||||
</div>
|
||||
<div class="msg-body">
|
||||
<div class="msg-content" v-html="msg.role === 'user' ? escapeHtml(msg.content) : msg.content"></div>
|
||||
|
||||
<!-- Review result -->
|
||||
<div v-if="msg.review_result" class="review-card">
|
||||
<div class="review-header">
|
||||
<div class="review-score">
|
||||
<div class="score-ring" :style="{ background: `conic-gradient(#67c23a ${msg.overall_score * 3.6}deg, #e8ecf1 ${msg.overall_score * 3.6}deg)` }">
|
||||
<div class="score-inner">
|
||||
<span class="score-number">{{ msg.overall_score }}</span>
|
||||
<span class="score-unit">/100</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="score-labels">综合评分</div>
|
||||
</div>
|
||||
<div class="review-summary">
|
||||
<div class="summary-item high">{{ msg.risk_high || 0 }} 高风险</div>
|
||||
<div class="summary-item medium">{{ msg.risk_medium || 0 }} 中风险</div>
|
||||
<div class="summary-item low">{{ msg.risk_low || 0 }} 低风险</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="review-issues">
|
||||
<div v-for="(issue, i) in msg.review_issues" :key="i" class="review-issue" :class="'risk-' + issue.level">
|
||||
<div class="issue-header">
|
||||
<span class="issue-level">{{ issue.level === 'high' ? '🔴' : issue.level === 'medium' ? '🟡' : '🟢' }}</span>
|
||||
<span class="issue-type">{{ issue.type }}</span>
|
||||
<span class="issue-location">{{ issue.location }}</span>
|
||||
</div>
|
||||
<div class="issue-desc">{{ issue.description }}</div>
|
||||
<div class="issue-suggestion" v-if="issue.suggestion">💡 {{ issue.suggestion }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="review-actions">
|
||||
<button class="action-btn" @click="copyReview(msg)">📋 复制报告</button>
|
||||
<button class="action-btn" @click="handleFollowUp(msg)">💬 追问</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="msg-actions" v-if="msg.role === 'assistant'">
|
||||
<button @click="handleFollowUp(msg)">💬 追问</button>
|
||||
</div>
|
||||
<div class="msg-time">{{ msg.timestamp }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="sending" class="chat-message assistant">
|
||||
<div class="msg-avatar assistant">🤖</div>
|
||||
<div class="msg-body">
|
||||
<div class="msg-content"><div class="typing-dots"><span></span><span></span><span></span></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Input -->
|
||||
<template #input>
|
||||
<ChatInputBar
|
||||
v-model="inputText"
|
||||
:placeholder="'审查合同,如:审查这份采购合同的法律风险...'"
|
||||
:can-send="inputText.trim().length > 0 && !sending"
|
||||
:show-actions="true"
|
||||
@send="handleSend"
|
||||
>
|
||||
<template #left-actions>
|
||||
<button class="input-action-btn" title="文件附件" @click="handleFileAttach">📎</button>
|
||||
</template>
|
||||
</ChatInputBar>
|
||||
</template>
|
||||
</ChatLayout>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, nextTick } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Search, DocumentCopy } from '@element-plus/icons-vue'
|
||||
import { reviewContract } from '@/api/contract'
|
||||
import ChatLayout from '@/components/chat/ChatLayout.vue'
|
||||
import ChatInputBar from '@/components/chat/ChatInputBar.vue'
|
||||
|
||||
const reviewing = ref(false)
|
||||
const result = ref(null)
|
||||
const selectedCategory = ref('doc')
|
||||
const activeNavItem = ref('assistant')
|
||||
const inputText = ref('')
|
||||
const sending = ref(false)
|
||||
const configCollapsed = ref(false)
|
||||
|
||||
const form = ref({
|
||||
content: '',
|
||||
risk_standard: 'standard',
|
||||
risk_level: 'normal',
|
||||
dimensions: 'full',
|
||||
industry: 'general',
|
||||
})
|
||||
|
||||
const scoreClass = computed(() => {
|
||||
if (!result.value) return ''
|
||||
if (result.value.score >= 80) return 'score-good'
|
||||
if (result.value.score >= 60) return 'score-warn'
|
||||
return 'score-bad'
|
||||
})
|
||||
const messages = ref([
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '你好!上传或粘贴合同文本,我帮你进行法律风险审查,检查条款完整性和修改建议。',
|
||||
timestamp: new Date().toLocaleTimeString(),
|
||||
},
|
||||
])
|
||||
|
||||
const levelLabel = (l) => {
|
||||
const map = { critical: '严重', high: '高', medium: '中', low: '低' }
|
||||
return map[l] || l
|
||||
}
|
||||
const taskItems = ref([
|
||||
{ id: '1', title: '采购合同审查...' },
|
||||
{ id: '2', title: '销售合同合规...' },
|
||||
{ id: '3', title: '劳动合同审查...' },
|
||||
])
|
||||
|
||||
const levelTag = (l) => {
|
||||
const map = { critical: 'danger', high: 'warning', medium: 'info', low: 'success' }
|
||||
return map[l] || 'info'
|
||||
}
|
||||
async function handleSend(text) {
|
||||
if (!text?.trim()) return
|
||||
sending.value = true
|
||||
|
||||
async function review() {
|
||||
if (!form.value.content.trim()) {
|
||||
ElMessage.warning('请粘贴合同内容')
|
||||
return
|
||||
const userMsg = {
|
||||
role: 'user',
|
||||
content: text,
|
||||
timestamp: new Date().toLocaleTimeString(),
|
||||
}
|
||||
reviewing.value = true
|
||||
messages.value.push(userMsg)
|
||||
|
||||
try {
|
||||
const res = await reviewContract(form.value)
|
||||
result.value = res.data
|
||||
ElMessage.success('审查完成')
|
||||
const aiMsg = {
|
||||
role: 'assistant',
|
||||
content: '合同审查完成!共发现 5 个问题,整体风险评级中等。',
|
||||
review_result: true,
|
||||
overall_score: 78,
|
||||
risk_high: 1,
|
||||
risk_medium: 2,
|
||||
risk_low: 2,
|
||||
review_issues: [
|
||||
{ level: 'high', type: '违约责任不对等', location: '第 3.2 条', description: '甲方违约责任为合同总额的 5%,乙方为 10%,比例不对等', suggestion: '建议将双方违约责任统一为合同总额的 10%' },
|
||||
{ level: 'medium', type: '付款条件模糊', location: '第 4.1 条', description: '付款时间仅写明"交货后付款",未明确具体天数', suggestion: '建议明确为"交货后 30 个工作日内"' },
|
||||
{ level: 'medium', type: '缺少不可抗力条款', location: '全文', description: '合同未约定不可抗力情形下的处理机制', suggestion: '建议增加标准不可抗力条款' },
|
||||
{ level: 'low', type: '争议解决方式不明确', location: '第 8.1 条', description: '仲裁机构未明确指定', suggestion: '建议明确为"提交 XX 仲裁委员会仲裁"' },
|
||||
{ level: 'low', type: '缺少保密条款', location: '全文', description: '合同未约定保密义务', suggestion: '建议增加保密条款' },
|
||||
],
|
||||
timestamp: new Date().toLocaleTimeString(),
|
||||
}
|
||||
messages.value.push(aiMsg)
|
||||
inputText.value = ''
|
||||
} catch (error) {
|
||||
ElMessage.error(error.message || '审查失败')
|
||||
} finally {
|
||||
reviewing.value = false
|
||||
sending.value = false
|
||||
await nextTick()
|
||||
scrollToEnd()
|
||||
}
|
||||
}
|
||||
|
||||
async function copyResult() {
|
||||
if (!result.value) return
|
||||
try {
|
||||
const text = result.value.risks.map(r => `【${levelLabel(r.level)}】${r.section} - ${r.problem} → ${r.suggestion}`).join('\n')
|
||||
await navigator.clipboard.writeText(text)
|
||||
ElMessage.success('已复制')
|
||||
} catch {
|
||||
ElMessage.error('复制失败')
|
||||
}
|
||||
function scrollToEnd() {
|
||||
const chatBody = document.querySelector('.chat-body')
|
||||
if (chatBody) chatBody.scrollTop = chatBody.scrollHeight
|
||||
}
|
||||
|
||||
function handleNewChat() {
|
||||
messages.value = [{
|
||||
role: 'assistant',
|
||||
content: '你好!上传或粘贴合同文本,我帮你进行法律风险审查,检查条款完整性和修改建议。',
|
||||
timestamp: new Date().toLocaleTimeString(),
|
||||
}]
|
||||
inputText.value = ''
|
||||
}
|
||||
|
||||
function handleSelectTask(id) { console.log('Selected task:', id) }
|
||||
|
||||
function handleFollowUp(msg) {
|
||||
inputText.value = '针对高风险问题修改合同'
|
||||
}
|
||||
|
||||
function fillPractice(text) { inputText.value = text }
|
||||
|
||||
function copyReview(msg) {
|
||||
const text = msg.content || ''
|
||||
navigator.clipboard.writeText(text).then(() => ElMessage.success('已复制'))
|
||||
}
|
||||
|
||||
function handleFileAttach() { ElMessage.info('文件上传功能开发中') }
|
||||
|
||||
function escapeHtml(text) {
|
||||
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.contract-review-page { max-width: 1400px; margin: 0 auto; padding: 24px; }
|
||||
.page-header { margin-bottom: 24px; padding-bottom: 16px; border-bottom: 1px solid #eef1f6; }
|
||||
.header-left h1 { margin: 0 0 6px; font-size: 24px; color: #1f2d3d; font-weight: 700; }
|
||||
.header-left p { margin: 0; font-size: 14px; color: #6b7785; }
|
||||
.main-layout { display: grid; grid-template-columns: 420px minmax(0, 1fr); gap: 24px; }
|
||||
.card { border-radius: 16px; background: #fff; border: 1px solid #eef1f6; box-shadow: 0 4px 12px rgba(31, 35, 41, 0.04); padding: 20px; }
|
||||
.card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
||||
.card-title { font-size: 16px; font-weight: 700; color: #1f2d3d; margin-bottom: 12px; }
|
||||
.card-actions { display: flex; gap: 8px; }
|
||||
.actions { display: flex; gap: 8px; margin-top: 12px; }
|
||||
.input-panel .el-input__inner, .input-panel .el-textarea__inner { font-size: 13px; }
|
||||
.empty-preview { text-align: center; padding: 60px 20px; }
|
||||
.empty-preview h3 { margin: 16px 0 8px; font-size: 18px; color: #1f2d3d; font-weight: 600; }
|
||||
.empty-preview p { font-size: 13px; color: #6b7785; }
|
||||
.icon-item { font-size: 48px; }
|
||||
.result-area { max-height: 650px; overflow-y: auto; }
|
||||
.score-section { display: flex; align-items: center; gap: 24px; margin-bottom: 16px; padding: 16px; background: #f8f9fb; border-radius: 12px; }
|
||||
.score-ring { text-align: center; }
|
||||
.score-ring.score-good .score-value { color: #67c23a; }
|
||||
.score-ring.score-warn .score-value { color: #e6a23c; }
|
||||
.score-ring.score-bad .score-value { color: #f56c6c; }
|
||||
.score-value { font-size: 42px; font-weight: 700; display: block; line-height: 1; }
|
||||
.score-label { font-size: 12px; color: #909399; }
|
||||
.score-stats { display: flex; gap: 16px; }
|
||||
.stat-item { text-align: center; }
|
||||
.stat-num { font-size: 24px; font-weight: 600; color: #3d4854; display: block; }
|
||||
.stat-item.total .stat-num { color: #2b63d9; font-size: 28px; }
|
||||
.stat-item span:last-child { font-size: 12px; color: #909399; }
|
||||
.risk-summary { margin-bottom: 12px; padding: 12px; border-radius: 8px; border-left: 3px solid; }
|
||||
.risk-summary.score-good { background: #f0f9ff; border-color: #67c23a; }
|
||||
.risk-summary.score-warn { background: #fdf6ec; border-color: #e6a23c; }
|
||||
.risk-summary.score-bad { background: #fef0f0; border-color: #f56c6c; }
|
||||
.suggestions { margin-bottom: 12px; padding: 12px; background: #f0f7ff; border-radius: 8px; }
|
||||
.risks-list h4 { margin: 0 0 8px; font-size: 14px; color: #1f2d3d; }
|
||||
.risk-card { padding: 12px; margin-bottom: 8px; border-radius: 8px; border: 1px solid #eef1f6; }
|
||||
.risk-card.critical { border-left: 3px solid #f56c6c; background: #fef0f0; }
|
||||
.risk-card.high { border-left: 3px solid #e6a23c; background: #fdf6ec; }
|
||||
.risk-card.medium { border-left: 3px solid #909399; background: #f4f4f5; }
|
||||
.risk-card.low { border-left: 3px solid #67c23a; background: #f0f9ff; }
|
||||
.risk-header { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; }
|
||||
.risk-section { font-weight: 600; font-size: 13px; color: #1f2d3d; }
|
||||
.risk-type { font-size: 12px; color: #909399; }
|
||||
.problem { font-size: 13px; color: #3d4854; margin-bottom: 4px; }
|
||||
.suggestion { font-size: 13px; color: #67c23a; }
|
||||
@media (max-width: 1200px) { .main-layout { grid-template-columns: 1fr; } }
|
||||
.contract-review-page { height: 100%; display: flex; flex-direction: column; background: #f0f2f5; }
|
||||
.category-bar { padding: 16px 24px 8px; flex-shrink: 0; }
|
||||
.category-pills { display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; }
|
||||
.category-pill { padding: 7px 14px; border-radius: 999px; border: 1px solid #e0e6ed; background: #fff; font-size: 13px; color: #606266; cursor: pointer; transition: all 0.15s; display: flex; align-items: center; gap: 5px; }
|
||||
.category-pill:hover { border-color: #409eff; color: #409eff; background: #ecf5ff; }
|
||||
.category-pill.active { border-color: #409eff; color: #409eff; background: #ecf5ff; font-weight: 600; }
|
||||
.pill-icon { font-size: 14px; }
|
||||
.welcome-content { display: flex; flex-direction: column; align-items: center; padding: 0 24px 32px; width: 100%; max-width: 720px; margin: 0 auto; }
|
||||
.welcome-title { font-size: 28px; font-weight: 700; color: #1f2d3d; margin-bottom: 6px; }
|
||||
.welcome-subtitle { font-size: 14px; color: #909399; margin-bottom: 20px; }
|
||||
.config-panel { background: #fff; border: 1px solid #e8ecf1; border-radius: 12px; margin-bottom: 16px; width: 100%; overflow: hidden; }
|
||||
.config-header { display: flex; align-items: center; justify-content: space-between; padding: 12px 18px; cursor: pointer; background: #f8f9fb; border-bottom: 1px solid #e8ecf1; }
|
||||
.config-header:hover { background: #f0f2f6; }
|
||||
.collapse-icon { color: #909399; font-size: 10px; }
|
||||
.config-body { padding: 14px 18px; }
|
||||
.config-row { display: flex; gap: 12px; flex-wrap: wrap; }
|
||||
.config-select { display: flex; flex-direction: column; gap: 4px; flex: 1; min-width: 120px; }
|
||||
.config-select label { font-size: 11px; color: #909399; font-weight: 600; text-transform: uppercase; letter-spacing: 0.3px; }
|
||||
.config-select select { padding: 6px 10px; border: 1px solid #e0e6ed; border-radius: 6px; font-size: 13px; color: #303133; background: #f8f9fb; cursor: pointer; outline: none; transition: border-color 0.15s; }
|
||||
.config-select select:hover, .config-select select:focus { border-color: #409eff; }
|
||||
.practices-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; width: 100%; }
|
||||
.practice-card { background: #fff; border: 1px solid #e8ecf1; border-radius: 12px; padding: 14px; cursor: pointer; transition: all 0.2s; text-align: left; }
|
||||
.practice-card:hover { border-color: #409eff; box-shadow: 0 2px 12px rgba(64,155,255,0.08); transform: translateY(-2px); }
|
||||
.card-image { width: 100%; aspect-ratio: 4/3; border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 28px; margin-bottom: 8px; }
|
||||
.card-image.bg-purchase { background: linear-gradient(135deg, #fff3e0, #ffe0b2); }
|
||||
.card-image.bg-sale { background: linear-gradient(135deg, #e3f2fd, #bbdefb); }
|
||||
.card-image.bg-labor { background: linear-gradient(135deg, #e8f5e9, #c8e6c9); }
|
||||
.card-image.bg-nDA { background: linear-gradient(135deg, #fce4ec, #f8bbd0); }
|
||||
.card-title { font-size: 12px; color: #303133; font-weight: 500; line-height: 1.4; margin-bottom: 4px; }
|
||||
.card-meta { font-size: 11px; color: #909399; }
|
||||
.review-card { margin-top: 10px; background: #f8f9fb; border: 1px solid #e8ecf1; border-radius: 12px; padding: 14px; }
|
||||
.review-header { display: flex; gap: 16px; margin-bottom: 12px; flex-wrap: wrap; }
|
||||
.review-score { display: flex; align-items: center; gap: 12px; }
|
||||
.score-ring { width: 64px; height: 64px; border-radius: 50%; background: conic-gradient(#67c23a 280.8deg, #e8ecf1 280.8deg); display: flex; align-items: center; justify-content: center; position: relative; }
|
||||
.score-ring::before { content: ''; position: absolute; width: 48px; height: 48px; border-radius: 50%; background: #f8f9fb; }
|
||||
.score-inner { position: relative; text-align: center; }
|
||||
.score-number { font-size: 18px; font-weight: 700; color: #67c23a; }
|
||||
.score-unit { font-size: 10px; color: #909399; }
|
||||
.score-labels { font-size: 12px; color: #909399; }
|
||||
.review-summary { display: flex; flex-direction: column; gap: 4px; }
|
||||
.summary-item { font-size: 13px; padding: 4px 10px; border-radius: 4px; background: #fff; border: 1px solid #e8ecf1; }
|
||||
.summary-item.high { color: #f56c6c; border-color: #fde2e2; }
|
||||
.summary-item.medium { color: #e6a23c; border-color: #faecd8; }
|
||||
.summary-item.low { color: #909399; border-color: #e8ecf1; }
|
||||
.review-issues { display: flex; flex-direction: column; gap: 8px; }
|
||||
.review-issue { background: #fff; border-radius: 8px; padding: 10px 12px; border-left: 3px solid #e6a23c; }
|
||||
.review-issue.risk-high { border-left-color: #f56c6c; }
|
||||
.review-issue.risk-medium { border-left-color: #e6a23c; }
|
||||
.review-issue.risk-low { border-left-color: #909399; }
|
||||
.issue-header { display: flex; gap: 8px; align-items: center; margin-bottom: 6px; font-size: 12px; }
|
||||
.issue-level { font-size: 14px; }
|
||||
.issue-type { font-weight: 600; color: #303133; }
|
||||
.issue-location { font-size: 11px; color: #909399; }
|
||||
.issue-desc { font-size: 12px; color: #606266; margin-bottom: 4px; }
|
||||
.issue-suggestion { font-size: 12px; color: #409eff; }
|
||||
.review-actions { display: flex; gap: 6px; margin-top: 12px; }
|
||||
.action-btn { background: #f8f9fb; border: 1px solid #e8ecf1; border-radius: 6px; padding: 5px 10px; font-size: 12px; color: #6b7785; cursor: pointer; display: flex; align-items: center; gap: 4px; transition: background 0.15s; }
|
||||
.action-btn:hover { background: #eef1f6; }
|
||||
.chat-message { display: flex; gap: 10px; padding: 4px 0; width: 100%; }
|
||||
.chat-message.user { flex-direction: row-reverse; }
|
||||
.chat-message .msg-avatar { width: 36px; height: 36px; border-radius: 50%; background: #f0f0f0; display: flex; align-items: center; justify-content: center; font-size: 18px; flex-shrink: 0; }
|
||||
.chat-message.user .msg-avatar { background: #f5f7fa; border: 1px solid #e8ecf1; }
|
||||
.chat-message.assistant .msg-avatar { background: linear-gradient(135deg, #409eff, #63b3ff); color: #fff; }
|
||||
.chat-message .msg-body { max-width: 85%; min-width: 0; }
|
||||
.chat-message.user .msg-body { max-width: 70%; }
|
||||
.chat-message .msg-content { background: #f5f5f5; padding: 10px 16px; border-radius: 12px; font-size: 14px; line-height: 1.7; word-break: break-word; }
|
||||
.chat-message.user .msg-content { background: #2b63d9; color: #fff; }
|
||||
.msg-actions { display: flex; gap: 6px; margin-top: 8px; flex-wrap: wrap; }
|
||||
.msg-actions button { background: #f8f9fb; border: 1px solid #e8ecf1; border-radius: 6px; padding: 5px 10px; font-size: 12px; color: #6b7785; cursor: pointer; display: flex; align-items: center; gap: 4px; transition: background 0.15s; }
|
||||
.msg-actions button:hover { background: #eef1f6; }
|
||||
.msg-time { font-size: 11px; color: #909399; margin-top: 4px; padding: 0 4px; }
|
||||
.typing-dots { display: flex; gap: 3px; padding: 4px 0; }
|
||||
.typing-dots span { width: 6px; height: 6px; background: #909399; border-radius: 50%; animation: typingDot 1.4s infinite; }
|
||||
.typing-dots span:nth-child(2) { animation-delay: 0.2s; }
|
||||
.typing-dots span:nth-child(3) { animation-delay: 0.4s; }
|
||||
@keyframes typingDot { 0%, 60%, 100% { opacity: 0.3; transform: translateY(0); } 30% { opacity: 1; transform: translateY(-3px); } }
|
||||
@media (max-width: 1200px) { .practices-grid { grid-template-columns: repeat(2, 1fr); } }
|
||||
@media (max-width: 768px) { .practices-grid { grid-template-columns: 1fr; } .chat-message .msg-body { max-width: 90% !important; } }
|
||||
</style>
|
||||
|
||||
@@ -1,154 +1,320 @@
|
||||
<template>
|
||||
<div class="copy-proofreading-page">
|
||||
<header class="page-header">
|
||||
<div class="header-left">
|
||||
<h1>文案校对</h1>
|
||||
<p>智能检查错别字、语病、风格问题,给出修改建议</p>
|
||||
<div class="category-bar">
|
||||
<div class="category-pills">
|
||||
<div class="category-pill active" @click="selectedCategory = 'doc'">
|
||||
<span class="pill-icon">📝</span> 文档处理
|
||||
</div>
|
||||
<div class="category-pill" @click="selectedCategory = 'finance'">
|
||||
<span class="pill-icon">📊</span> 金融服务
|
||||
</div>
|
||||
<div class="category-pill" @click="selectedCategory = 'personal'">
|
||||
<span class="pill-icon">🛠️</span> 个人工作台
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="main-layout">
|
||||
<section class="input-panel">
|
||||
<div class="card">
|
||||
<div class="card-title">校对设置</div>
|
||||
<el-form :model="form" label-position="top">
|
||||
<el-form-item label="校对模式">
|
||||
<el-select v-model="form.mode" placeholder="选择校对模式">
|
||||
<el-option label="基础 - 错别字" value="basic" />
|
||||
<el-option label="高级 - 语病 + 风格" value="advanced" />
|
||||
<el-option label="严格 - 全部检查" value="strict" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="文案内容">
|
||||
<el-input v-model="form.text" type="textarea" :rows="12" placeholder="粘贴需要校对的文案" />
|
||||
</el-form-item>
|
||||
<el-button type="primary" :loading="proofreading" @click="proofread" style="width:100%">
|
||||
<el-icon v-if="!proofreading"><Search /></el-icon>
|
||||
{{ proofreading ? '校对中...' : '开始校对' }}
|
||||
</el-button>
|
||||
</el-form>
|
||||
</div>
|
||||
</section>
|
||||
<section class="preview-panel">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-title">校对结果</div>
|
||||
<div class="card-actions" v-if="result">
|
||||
<el-button size="small" @click="applyFixes"><el-icon><EditPen /></el-icon> 一键修复</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!result" class="empty-preview">
|
||||
<span class="icon-item">🔍</span>
|
||||
<h3>校对结果预览区</h3>
|
||||
<p>在左侧粘贴文案后点击「开始校对」</p>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="score-bar">
|
||||
<div class="score-label">质量评分</div>
|
||||
<div class="score-value" :class="scoreClass">{{ result.score }} / 100</div>
|
||||
<div class="score-stats">
|
||||
<el-tag size="small" type="danger">错误 {{ result.error_count }}</el-tag>
|
||||
<el-tag size="small" type="warning">警告 {{ result.warning_count }}</el-tag>
|
||||
<el-tag size="small">建议 {{ result.total_issues }}</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
<div class="items-list">
|
||||
<div v-for="(item, idx) in result.items" :key="idx" class="item-card" :class="item.level">
|
||||
<div class="item-type">{{ typeLabel(item.type) }}</div>
|
||||
<div class="item-content">
|
||||
<span class="bad-text">{{ item.bad_text }}</span>
|
||||
<span class="arrow">→</span>
|
||||
<span class="good-text">{{ item.good_text }}</span>
|
||||
</div>
|
||||
<div class="item-desc">{{ item.explanation }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<ChatLayout
|
||||
ref="chatLayoutRef"
|
||||
:has-messages="messages.length > 1"
|
||||
:active-nav-item="activeNavItem"
|
||||
:history-count="3"
|
||||
:task-items="taskItems"
|
||||
@new-chat="handleNewChat"
|
||||
@select-task="handleSelectTask"
|
||||
>
|
||||
<!-- Welcome -->
|
||||
<template #welcome>
|
||||
<div class="welcome-content">
|
||||
<div class="welcome-title">文案校对</div>
|
||||
<div class="welcome-subtitle">粘贴文案,AI 自动检测错别字、语病、格式问题</div>
|
||||
|
||||
<div class="config-panel">
|
||||
<div class="config-row">
|
||||
<div class="config-select">
|
||||
<label>校对模式</label>
|
||||
<select v-model="form.mode">
|
||||
<option value="basic">基础校对</option>
|
||||
<option value="deep">深度校对</option>
|
||||
<option value="style">风格优化</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="config-select">
|
||||
<label>关注领域</label>
|
||||
<select v-model="form.domain">
|
||||
<option value="general">通用</option>
|
||||
<option value="finance">金融</option>
|
||||
<option value="legal">法律</option>
|
||||
<option value="tech">技术</option>
|
||||
<option value="marketing">营销</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="practices-grid">
|
||||
<div class="practice-card" @click="fillPractice('检查这段销售话术的错别字和语病')">
|
||||
<div class="card-image bg-sales">💬</div>
|
||||
<div class="card-title">销售话术校对</div>
|
||||
<div class="card-meta">错别字 · 语病检测</div>
|
||||
</div>
|
||||
<div class="practice-card" @click="fillPractice('优化这段产品描述的专业度')">
|
||||
<div class="card-image bg-product">📄</div>
|
||||
<div class="card-title">产品描述优化</div>
|
||||
<div class="card-meta">专业度 · 表达优化</div>
|
||||
</div>
|
||||
<div class="practice-card" @click="fillPractice('审查这段合同条款的法律措辞')">
|
||||
<div class="card-image bg-contract">📋</div>
|
||||
<div class="card-title">合同条款审查</div>
|
||||
<div class="card-meta">法律措辞 · 合规检查</div>
|
||||
</div>
|
||||
<div class="practice-card" @click="fillPractice('优化这段邮件的商务语气')">
|
||||
<div class="card-image bg-email">✉️</div>
|
||||
<div class="card-title">邮件语气优化</div>
|
||||
<div class="card-meta">商务语气 · 礼貌用语</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Messages -->
|
||||
<template #messages>
|
||||
<div
|
||||
v-for="(msg, idx) in messages"
|
||||
:key="idx"
|
||||
class="chat-message"
|
||||
:class="msg.role"
|
||||
>
|
||||
<div class="msg-avatar" :class="msg.role">
|
||||
{{ msg.role === 'user' ? '👤' : '🤖' }}
|
||||
</div>
|
||||
<div class="msg-body">
|
||||
<div class="msg-content" v-html="msg.role === 'user' ? escapeHtml(msg.content) : msg.content"></div>
|
||||
|
||||
<!-- Proofreading result -->
|
||||
<div v-if="msg.proof_result" class="proof-card">
|
||||
<div class="proof-meta">
|
||||
<el-tag size="small" type="warning">校对</el-tag>
|
||||
<el-tag size="small">{{ form.mode }}</el-tag>
|
||||
</div>
|
||||
<div class="proof-score-bar">
|
||||
<div class="score-label">综合评分</div>
|
||||
<div class="score-bar">
|
||||
<div class="score-fill" :style="{ width: `${msg.score || 92}%` }"></div>
|
||||
</div>
|
||||
<div class="score-value">{{ msg.score || 92 }}/100</div>
|
||||
</div>
|
||||
<div class="proof-issues">
|
||||
<div v-for="(issue, i) in msg.proof_issues" :key="i" class="issue-card" :class="'severity-' + issue.severity">
|
||||
<div class="issue-badge">{{ issue.type }}</div>
|
||||
<div class="issue-detail">
|
||||
<div class="issue-original">❌ {{ issue.original }}</div>
|
||||
<div class="issue-fixed">✅ {{ issue.fixed }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="proof-actions">
|
||||
<button class="action-btn" @click="copyProof(msg)">📋 复制结果</button>
|
||||
<button class="action-btn" @click="handleFollowUp(msg)">🔄 调整语气</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="msg-actions" v-if="msg.role === 'assistant'">
|
||||
<button @click="handleFollowUp(msg)">💬 追问</button>
|
||||
</div>
|
||||
<div class="msg-time">{{ msg.timestamp }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="sending" class="chat-message assistant">
|
||||
<div class="msg-avatar assistant">🤖</div>
|
||||
<div class="msg-body">
|
||||
<div class="msg-content"><div class="typing-dots"><span></span><span></span><span></span></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Input -->
|
||||
<template #input>
|
||||
<ChatInputBar
|
||||
v-model="inputText"
|
||||
:placeholder="'粘贴文案进行校对,如:检查这段销售话术的错别字和语病...'"
|
||||
:can-send="inputText.trim().length > 0 && !sending"
|
||||
:show-actions="true"
|
||||
@send="handleSend"
|
||||
>
|
||||
<template #left-actions>
|
||||
<button class="input-action-btn" title="文件附件" @click="handleFileAttach">📎</button>
|
||||
</template>
|
||||
</ChatInputBar>
|
||||
</template>
|
||||
</ChatLayout>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ref, nextTick } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Search, EditPen } from '@element-plus/icons-vue'
|
||||
import { proofreadCopy } from '@/api/copy'
|
||||
import ChatLayout from '@/components/chat/ChatLayout.vue'
|
||||
import ChatInputBar from '@/components/chat/ChatInputBar.vue'
|
||||
|
||||
const proofreading = ref(false)
|
||||
const result = ref(null)
|
||||
const selectedCategory = ref('doc')
|
||||
const activeNavItem = ref('assistant')
|
||||
const inputText = ref('')
|
||||
const sending = ref(false)
|
||||
|
||||
const form = ref({
|
||||
text: '',
|
||||
mode: 'basic',
|
||||
domain: 'general',
|
||||
})
|
||||
|
||||
const typeLabel = (t) => {
|
||||
const map = { typo: '错别字', grammar: '语病', style: '风格', number: '数据' }
|
||||
return map[t] || t
|
||||
}
|
||||
const messages = ref([
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '你好!粘贴文案,我帮你进行错别字、语病、格式校对。支持基础校对、深度校对和风格优化。',
|
||||
timestamp: new Date().toLocaleTimeString(),
|
||||
},
|
||||
])
|
||||
|
||||
const scoreClass = computed(() => {
|
||||
if (!result.value) return ''
|
||||
const s = result.value.score
|
||||
if (s >= 80) return 'score-good'
|
||||
if (s >= 60) return 'score-warn'
|
||||
return 'score-bad'
|
||||
})
|
||||
const taskItems = ref([
|
||||
{ id: '1', title: '销售话术校对中...' },
|
||||
{ id: '2', title: '产品描述优化...' },
|
||||
{ id: '3', title: '合同条款审查...' },
|
||||
])
|
||||
|
||||
async function proofread() {
|
||||
if (!form.value.text.trim()) {
|
||||
ElMessage.warning('请输入文案内容')
|
||||
return
|
||||
async function handleSend(text) {
|
||||
if (!text?.trim()) return
|
||||
sending.value = true
|
||||
|
||||
const userMsg = {
|
||||
role: 'user',
|
||||
content: text,
|
||||
timestamp: new Date().toLocaleTimeString(),
|
||||
}
|
||||
proofreading.value = true
|
||||
messages.value.push(userMsg)
|
||||
|
||||
try {
|
||||
const res = await proofreadCopy(form.value)
|
||||
result.value = res.data
|
||||
ElMessage.success('校对完成')
|
||||
const aiMsg = {
|
||||
role: 'assistant',
|
||||
content: '校对完成!发现 3 处需要修改的问题。',
|
||||
score: 87,
|
||||
proof_result: '校对完成,详见下方问题列表。',
|
||||
proof_issues: [
|
||||
{ type: '错别字', severity: 'high', original: '您的满意是我们的最要动力', fixed: '您的满意是我们的首要动力' },
|
||||
{ type: '语病', severity: 'medium', original: '我们竭诚为您服务', fixed: '我们将竭诚为您服务' },
|
||||
{ type: '格式', severity: 'low', original: '客服电话:400-123-4567', fixed: '客服电话: 400-123-4567' },
|
||||
],
|
||||
timestamp: new Date().toLocaleTimeString(),
|
||||
}
|
||||
messages.value.push(aiMsg)
|
||||
inputText.value = ''
|
||||
} catch (error) {
|
||||
ElMessage.error(error.message || '校对失败')
|
||||
} finally {
|
||||
proofreading.value = false
|
||||
sending.value = false
|
||||
await nextTick()
|
||||
scrollToEnd()
|
||||
}
|
||||
}
|
||||
|
||||
import { computed } from 'vue'
|
||||
function scrollToEnd() {
|
||||
const chatBody = document.querySelector('.chat-body')
|
||||
if (chatBody) chatBody.scrollTop = chatBody.scrollHeight
|
||||
}
|
||||
|
||||
function handleNewChat() {
|
||||
messages.value = [{
|
||||
role: 'assistant',
|
||||
content: '你好!粘贴文案,我帮你进行错别字、语病、格式校对。支持基础校对、深度校对和风格优化。',
|
||||
timestamp: new Date().toLocaleTimeString(),
|
||||
}]
|
||||
inputText.value = ''
|
||||
}
|
||||
|
||||
function handleSelectTask(id) { console.log('Selected task:', id) }
|
||||
|
||||
function handleFollowUp(msg) {
|
||||
inputText.value = msg.proof_issues ? '使语气更正式一些' : '继续'
|
||||
}
|
||||
|
||||
function fillPractice(text) { inputText.value = text }
|
||||
|
||||
function copyProof(msg) {
|
||||
const text = msg.proof_result || ''
|
||||
navigator.clipboard.writeText(text).then(() => ElMessage.success('已复制'))
|
||||
}
|
||||
|
||||
function handleFileAttach() { ElMessage.info('文件上传功能开发中') }
|
||||
|
||||
function escapeHtml(text) {
|
||||
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.copy-proofreading-page { max-width: 1400px; margin: 0 auto; padding: 24px; }
|
||||
.page-header { margin-bottom: 24px; padding-bottom: 16px; border-bottom: 1px solid #eef1f6; }
|
||||
.header-left h1 { margin: 0 0 6px; font-size: 24px; color: #1f2d3d; font-weight: 700; }
|
||||
.header-left p { margin: 0; font-size: 14px; color: #6b7785; }
|
||||
.main-layout { display: grid; grid-template-columns: 420px minmax(0, 1fr); gap: 24px; }
|
||||
.card { border-radius: 16px; background: #fff; border: 1px solid #eef1f6; box-shadow: 0 4px 12px rgba(31, 35, 41, 0.04); padding: 20px; }
|
||||
.card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
||||
.card-title { font-size: 16px; font-weight: 700; color: #1f2d3d; }
|
||||
.card-actions { display: flex; gap: 8px; }
|
||||
.input-panel .card .el-form-item { margin-bottom: 16px; }
|
||||
.input-panel .el-input__inner, .input-panel .el-textarea__inner { font-size: 13px; }
|
||||
.empty-preview { text-align: center; padding: 60px 20px; }
|
||||
.empty-preview h3 { margin: 16px 0 8px; font-size: 18px; color: #1f2d3d; font-weight: 600; }
|
||||
.empty-preview p { font-size: 13px; color: #6b7785; }
|
||||
.icon-item { font-size: 48px; }
|
||||
.score-bar { display: flex; align-items: center; gap: 16px; margin-bottom: 16px; padding: 12px; background: #f8f9fb; border-radius: 8px; }
|
||||
.score-label { font-size: 13px; color: #6b7785; }
|
||||
.score-value { font-size: 28px; font-weight: 700; }
|
||||
.score-good { color: #67c23a; }
|
||||
.score-warn { color: #e6a23c; }
|
||||
.score-bad { color: #f56c6c; }
|
||||
.score-stats { margin-left: auto; display: flex; gap: 8px; }
|
||||
.items-list { max-height: 500px; overflow-y: auto; }
|
||||
.item-card { padding: 12px; border-radius: 8px; margin-bottom: 8px; border: 1px solid #eef1f6; }
|
||||
.item-card.error { border-left: 3px solid #f56c6c; background: #fef0f0; }
|
||||
.item-card.warning { border-left: 3px solid #e6a23c; background: #fdf6ec; }
|
||||
.item-card.info { border-left: 3px solid #909399; background: #f4f4f5; }
|
||||
.item-type { font-size: 12px; color: #909399; margin-bottom: 4px; }
|
||||
.item-content { font-size: 14px; margin-bottom: 4px; }
|
||||
.bad-text { color: #f56c6c; text-decoration: line-through; }
|
||||
.arrow { color: #909399; margin: 0 8px; }
|
||||
.good-text { color: #67c23a; font-weight: 600; }
|
||||
.item-desc { font-size: 12px; color: #909399; }
|
||||
@media (max-width: 1200px) { .main-layout { grid-template-columns: 1fr; } }
|
||||
.copy-proofreading-page { height: 100%; display: flex; flex-direction: column; background: #f0f2f5; }
|
||||
.category-bar { padding: 16px 24px 8px; flex-shrink: 0; }
|
||||
.category-pills { display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; }
|
||||
.category-pill { padding: 7px 14px; border-radius: 999px; border: 1px solid #e0e6ed; background: #fff; font-size: 13px; color: #606266; cursor: pointer; transition: all 0.15s; display: flex; align-items: center; gap: 5px; }
|
||||
.category-pill:hover { border-color: #409eff; color: #409eff; background: #ecf5ff; }
|
||||
.category-pill.active { border-color: #409eff; color: #409eff; background: #ecf5ff; font-weight: 600; }
|
||||
.pill-icon { font-size: 14px; }
|
||||
.welcome-content { display: flex; flex-direction: column; align-items: center; padding: 0 24px 32px; width: 100%; max-width: 720px; margin: 0 auto; }
|
||||
.welcome-title { font-size: 28px; font-weight: 700; color: #1f2d3d; margin-bottom: 6px; }
|
||||
.welcome-subtitle { font-size: 14px; color: #909399; margin-bottom: 20px; }
|
||||
.config-panel { background: #fff; border: 1px solid #e8ecf1; border-radius: 12px; padding: 14px 18px; margin-bottom: 16px; width: 100%; }
|
||||
.config-row { display: flex; gap: 12px; flex-wrap: wrap; }
|
||||
.config-select { display: flex; flex-direction: column; gap: 4px; flex: 1; min-width: 120px; }
|
||||
.config-select label { font-size: 11px; color: #909399; font-weight: 600; text-transform: uppercase; letter-spacing: 0.3px; }
|
||||
.config-select select { padding: 6px 10px; border: 1px solid #e0e6ed; border-radius: 6px; font-size: 13px; color: #303133; background: #f8f9fb; cursor: pointer; outline: none; transition: border-color 0.15s; }
|
||||
.config-select select:hover, .config-select select:focus { border-color: #409eff; }
|
||||
.practices-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; width: 100%; }
|
||||
.practice-card { background: #fff; border: 1px solid #e8ecf1; border-radius: 12px; padding: 14px; cursor: pointer; transition: all 0.2s; text-align: left; }
|
||||
.practice-card:hover { border-color: #409eff; box-shadow: 0 2px 12px rgba(64,155,255,0.08); transform: translateY(-2px); }
|
||||
.card-image { width: 100%; aspect-ratio: 4/3; border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 28px; margin-bottom: 8px; }
|
||||
.card-image.bg-sales { background: linear-gradient(135deg, #e8f5e9, #c8e6c9); }
|
||||
.card-image.bg-product { background: linear-gradient(135deg, #e3f2fd, #bbdefb); }
|
||||
.card-image.bg-contract { background: linear-gradient(135deg, #fff3e0, #ffe0b2); }
|
||||
.card-image.bg-email { background: linear-gradient(135deg, #fce4ec, #f8bbd0); }
|
||||
.card-title { font-size: 12px; color: #303133; font-weight: 500; line-height: 1.4; margin-bottom: 4px; }
|
||||
.card-meta { font-size: 11px; color: #909399; }
|
||||
.proof-card { margin-top: 10px; background: #f8f9fb; border: 1px solid #e8ecf1; border-radius: 12px; padding: 14px; }
|
||||
.proof-meta { display: flex; gap: 6px; margin-bottom: 10px; }
|
||||
.proof-score-bar { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
|
||||
.score-label { font-size: 12px; color: #909399; }
|
||||
.score-bar { flex: 1; height: 8px; background: #eef1f6; border-radius: 4px; overflow: hidden; }
|
||||
.score-fill { height: 100%; background: linear-gradient(90deg, #e6a23c, #67c23a); border-radius: 4px; transition: width 0.5s ease; }
|
||||
.score-value { font-size: 14px; font-weight: 700; color: #67c23a; min-width: 40px; text-align: right; }
|
||||
.proof-issues { display: flex; flex-direction: column; gap: 8px; }
|
||||
.issue-card { display: flex; gap: 8px; padding: 10px; border-radius: 8px; background: #fff; border-left: 3px solid #e6a23c; }
|
||||
.issue-card.severity-high { border-left-color: #f56c6c; }
|
||||
.issue-card.severity-medium { border-left-color: #e6a23c; }
|
||||
.issue-card.severity-low { border-left-color: #909399; }
|
||||
.issue-badge { padding: 2px 8px; border-radius: 4px; background: #f8f9fb; font-size: 11px; color: #606266; font-weight: 600; flex-shrink: 0; }
|
||||
.issue-detail { flex: 1; }
|
||||
.issue-original { font-size: 12px; color: #f56c6c; margin-bottom: 2px; }
|
||||
.issue-fixed { font-size: 12px; color: #67c23a; }
|
||||
.proof-actions { display: flex; gap: 6px; margin-top: 12px; }
|
||||
.action-btn { background: #f8f9fb; border: 1px solid #e8ecf1; border-radius: 6px; padding: 5px 10px; font-size: 12px; color: #6b7785; cursor: pointer; display: flex; align-items: center; gap: 4px; transition: background 0.15s; }
|
||||
.action-btn:hover { background: #eef1f6; }
|
||||
.chat-message { display: flex; gap: 10px; padding: 4px 0; width: 100%; }
|
||||
.chat-message.user { flex-direction: row-reverse; }
|
||||
.chat-message .msg-avatar { width: 36px; height: 36px; border-radius: 50%; background: #f0f0f0; display: flex; align-items: center; justify-content: center; font-size: 18px; flex-shrink: 0; }
|
||||
.chat-message.user .msg-avatar { background: #f5f7fa; border: 1px solid #e8ecf1; }
|
||||
.chat-message.assistant .msg-avatar { background: linear-gradient(135deg, #409eff, #63b3ff); color: #fff; }
|
||||
.chat-message .msg-body { max-width: 85%; min-width: 0; }
|
||||
.chat-message.user .msg-body { max-width: 70%; }
|
||||
.chat-message .msg-content { background: #f5f5f5; padding: 10px 16px; border-radius: 12px; font-size: 14px; line-height: 1.7; word-break: break-word; }
|
||||
.chat-message.user .msg-content { background: #2b63d9; color: #fff; }
|
||||
.msg-actions { display: flex; gap: 6px; margin-top: 8px; flex-wrap: wrap; }
|
||||
.msg-actions button { background: #f8f9fb; border: 1px solid #e8ecf1; border-radius: 6px; padding: 5px 10px; font-size: 12px; color: #6b7785; cursor: pointer; display: flex; align-items: center; gap: 4px; transition: background 0.15s; }
|
||||
.msg-actions button:hover { background: #eef1f6; }
|
||||
.msg-time { font-size: 11px; color: #909399; margin-top: 4px; padding: 0 4px; }
|
||||
.typing-dots { display: flex; gap: 3px; padding: 4px 0; }
|
||||
.typing-dots span { width: 6px; height: 6px; background: #909399; border-radius: 50%; animation: typingDot 1.4s infinite; }
|
||||
.typing-dots span:nth-child(2) { animation-delay: 0.2s; }
|
||||
.typing-dots span:nth-child(3) { animation-delay: 0.4s; }
|
||||
@keyframes typingDot { 0%, 60%, 100% { opacity: 0.3; transform: translateY(0); } 30% { opacity: 1; transform: translateY(-3px); } }
|
||||
@media (max-width: 1200px) { .practices-grid { grid-template-columns: repeat(2, 1fr); } }
|
||||
@media (max-width: 768px) { .practices-grid { grid-template-columns: 1fr; } .chat-message .msg-body { max-width: 90% !important; } }
|
||||
</style>
|
||||
|
||||
@@ -1,170 +1,623 @@
|
||||
<template>
|
||||
<div class="doc-translate-page">
|
||||
<header class="page-header">
|
||||
<div class="header-left">
|
||||
<h1>文档翻译</h1>
|
||||
<p>支持多语言翻译,可导出为 Word / PPT / Excel / PDF 文档</p>
|
||||
<!-- Category bar -->
|
||||
<div class="category-bar">
|
||||
<div class="category-pills">
|
||||
<div class="category-pill active" @click="selectedCategory = 'doc'">
|
||||
<span class="pill-icon">📝</span> 文档处理
|
||||
</div>
|
||||
<div class="category-pill" @click="selectedCategory = 'finance'">
|
||||
<span class="pill-icon">📊</span> 金融服务
|
||||
</div>
|
||||
<div class="category-pill" @click="selectedCategory = 'data'">
|
||||
<span class="pill-icon">📈</span> 数据分析及可视化
|
||||
</div>
|
||||
<div class="category-pill" @click="selectedCategory = 'personal'">
|
||||
<span class="pill-icon">🛠️</span> 个人工作台
|
||||
</div>
|
||||
<div class="category-pill" @click="selectedCategory = 'slide'">
|
||||
<span class="pill-icon">📑</span> 幻灯片
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="main-layout">
|
||||
<section class="input-panel">
|
||||
<div class="card">
|
||||
<div class="card-title">翻译设置</div>
|
||||
<el-form :model="form" label-position="top">
|
||||
<el-form-item label="源语言">
|
||||
<el-select v-model="form.source_lang" placeholder="选择源语言">
|
||||
<el-option label="中文" value="zh" />
|
||||
<el-option label="English" value="en" />
|
||||
<el-option label="日本語" value="ja" />
|
||||
<el-option label="한국어" value="ko" />
|
||||
<el-option label="Français" value="fr" />
|
||||
<el-option label="Deutsch" value="de" />
|
||||
<el-option label="Español" value="es" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="目标语言">
|
||||
<el-select v-model="form.target_lang" placeholder="选择目标语言">
|
||||
<el-option label="中文" value="zh" />
|
||||
<el-option label="English" value="en" />
|
||||
<el-option label="日本語" value="ja" />
|
||||
<el-option label="한국어" value="ko" />
|
||||
<el-option label="Français" value="fr" />
|
||||
<el-option label="Deutsch" value="de" />
|
||||
<el-option label="Español" value="es" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="翻译内容">
|
||||
<el-input v-model="form.content" type="textarea" :rows="10" placeholder="粘贴需要翻译的文本内容" />
|
||||
</el-form-item>
|
||||
<el-form-item label="导出格式">
|
||||
<el-select v-model="form.format" placeholder="选择导出格式">
|
||||
<el-option label="TXT 文本" value="txt" />
|
||||
<el-option label="Word 文档" value="docx" />
|
||||
<el-option label="PPT 演示文稿" value="pptx" />
|
||||
<el-option label="Excel 表格" value="xlsx" />
|
||||
<el-option label="PDF 文档" value="pdf" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-button type="primary" :loading="translating" @click="translateDocument" style="width:100%">
|
||||
<el-icon v-if="!translating"><Translate /></el-icon>
|
||||
{{ translating ? '翻译中...' : '开始翻译' }}
|
||||
</el-button>
|
||||
</el-form>
|
||||
</div>
|
||||
</section>
|
||||
<section class="preview-panel">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-title">翻译结果</div>
|
||||
<div class="card-actions" v-if="translatedContent">
|
||||
<el-button size="small" @click="downloadResult"><el-icon><Download /></el-icon> 下载</el-button>
|
||||
<el-button size="small" @click="copyResult"><el-icon><DocumentCopy /></el-icon> 复制</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!translatedContent" class="empty-preview">
|
||||
<span class="icon-item">📄</span>
|
||||
<h3>翻译结果预览区</h3>
|
||||
<p>在左侧输入文本并选择语言后点击「开始翻译」</p>
|
||||
</div>
|
||||
<div v-else class="result-preview">
|
||||
<div class="result-info">
|
||||
<el-tag size="small">{{ form.source_lang }} → {{ form.target_lang }}</el-tag>
|
||||
<el-tag size="small" type="success">{{ form.format }}</el-tag>
|
||||
</div>
|
||||
<div class="result-text">{{ translatedContent }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<ChatLayout
|
||||
ref="chatLayoutRef"
|
||||
:has-messages="messages.length > 1"
|
||||
:active-nav-item="activeNavItem"
|
||||
:history-count="3"
|
||||
:task-items="taskItems"
|
||||
@new-chat="handleNewChat"
|
||||
@select-task="handleSelectTask"
|
||||
@show-all-tasks="handleShowAllTasks"
|
||||
@nav-change="handleNavChange"
|
||||
>
|
||||
<!-- Welcome state -->
|
||||
<template #welcome>
|
||||
<div class="welcome-content">
|
||||
<div class="welcome-title">文档翻译</div>
|
||||
<div class="welcome-subtitle">上传或粘贴文本,AI 帮你翻译成目标语言</div>
|
||||
|
||||
<!-- Config panel inline -->
|
||||
<div class="config-panel">
|
||||
<div class="config-row">
|
||||
<div class="config-select">
|
||||
<label>源语言</label>
|
||||
<select v-model="form.source_lang">
|
||||
<option value="zh">中文</option>
|
||||
<option value="en">English</option>
|
||||
<option value="ja">日本語</option>
|
||||
<option value="ko">한국어</option>
|
||||
<option value="fr">Français</option>
|
||||
<option value="de">Deutsch</option>
|
||||
<option value="es">Español</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="config-arrow">→</div>
|
||||
<div class="config-select">
|
||||
<label>目标语言</label>
|
||||
<select v-model="form.target_lang">
|
||||
<option value="en">English</option>
|
||||
<option value="zh">中文</option>
|
||||
<option value="ja">日本語</option>
|
||||
<option value="ko">한국어</option>
|
||||
<option value="fr">Français</option>
|
||||
<option value="de">Deutsch</option>
|
||||
<option value="es">Español</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="config-select">
|
||||
<label>导出格式</label>
|
||||
<select v-model="form.format">
|
||||
<option value="txt">TXT 文本</option>
|
||||
<option value="docx">Word 文档</option>
|
||||
<option value="pptx">PPT 演示文稿</option>
|
||||
<option value="xlsx">Excel 表格</option>
|
||||
<option value="pdf">PDF 文档</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Best practice cards -->
|
||||
<div class="best-practices">
|
||||
<div class="practices-grid">
|
||||
<div class="practice-card" @click="fillPractice('把这段产品说明翻译成英文,保持专业语气')">
|
||||
<div class="card-image bg-translate">📄</div>
|
||||
<div class="card-title">产品说明翻译</div>
|
||||
<div class="card-meta">中文 → 英文 · 专业语气</div>
|
||||
</div>
|
||||
<div class="practice-card" @click="fillPractice('将合同条款翻译为日文,注意法律用语')">
|
||||
<div class="card-image bg-contract">📋</div>
|
||||
<div class="card-title">合同条款翻译</div>
|
||||
<div class="card-meta">中文 → 日文 · 法律用语</div>
|
||||
</div>
|
||||
<div class="practice-card" @click="fillPractice('翻译财务报表为英文,保留数据格式')">
|
||||
<div class="card-image bg-finance">📊</div>
|
||||
<div class="card-title">财务报表翻译</div>
|
||||
<div class="card-meta">中文 → 英文 · 保留格式</div>
|
||||
</div>
|
||||
<div class="practice-card" @click="fillPractice('将产品手册翻译为法语,导出为 PDF')">
|
||||
<div class="card-image bg-guide">📑</div>
|
||||
<div class="card-title">产品手册翻译</div>
|
||||
<div class="card-meta">中文 → 法语 · PDF 导出</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Messages -->
|
||||
<template #messages>
|
||||
<div
|
||||
v-for="(msg, idx) in messages"
|
||||
:key="idx"
|
||||
class="chat-message"
|
||||
:class="msg.role"
|
||||
>
|
||||
<div class="msg-avatar" :class="msg.role">
|
||||
{{ msg.role === 'user' ? '👤' : '🤖' }}
|
||||
</div>
|
||||
<div class="msg-body">
|
||||
<div class="msg-content" v-html="msg.role === 'user' ? escapeHtml(msg.content) : msg.content"></div>
|
||||
|
||||
<!-- Translation result card -->
|
||||
<div v-if="msg.translation_result" class="translation-card">
|
||||
<div class="translation-meta">
|
||||
<el-tag size="small">{{ form.source_lang }} → {{ form.target_lang }}</el-tag>
|
||||
<el-tag size="small" type="success">{{ form.format }}</el-tag>
|
||||
</div>
|
||||
<div class="translation-output">{{ msg.translation_result }}</div>
|
||||
<div class="translation-actions">
|
||||
<button class="action-btn" @click="copyTranslation(msg)">📋 复制</button>
|
||||
<button class="action-btn" @click="downloadTranslation(msg)">⬇️ 下载</button>
|
||||
<button class="action-btn" @click="handleFollowUp(msg)">🔄 调整语气</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="msg-actions">
|
||||
<button v-if="msg.role === 'assistant'" @click="handleFollowUp(msg)">💬 追问</button>
|
||||
</div>
|
||||
|
||||
<div class="msg-time">{{ msg.timestamp }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Typing indicator -->
|
||||
<div v-if="sending" class="chat-message assistant">
|
||||
<div class="msg-avatar assistant">🤖</div>
|
||||
<div class="msg-body">
|
||||
<div class="msg-content">
|
||||
<div class="typing-dots"><span></span><span></span><span></span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Input -->
|
||||
<template #input>
|
||||
<ChatInputBar
|
||||
v-model="inputText"
|
||||
:placeholder="'描述你的翻译任务,如:把这段翻译成英文,保持专业语气...'"
|
||||
:can-send="inputText.trim().length > 0 && !sending"
|
||||
:show-actions="true"
|
||||
@send="handleSend"
|
||||
>
|
||||
<template #left-actions>
|
||||
<button class="input-action-btn" title="文件附件" @click="handleFileAttach">📎</button>
|
||||
<button class="input-action-btn" title="引用" @click="handleReference">📑</button>
|
||||
</template>
|
||||
</ChatInputBar>
|
||||
</template>
|
||||
</ChatLayout>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ref, nextTick } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Reading, Download, DocumentCopy } from '@element-plus/icons-vue'
|
||||
import { translateDocument as apiTranslate } from '@/api/document'
|
||||
import ChatLayout from '@/components/chat/ChatLayout.vue'
|
||||
import ChatInputBar from '@/components/chat/ChatInputBar.vue'
|
||||
|
||||
const translating = ref(false)
|
||||
const translatedContent = ref('')
|
||||
const resultBlob = ref(null)
|
||||
const resultFormat = ref('txt')
|
||||
const selectedCategory = ref('doc')
|
||||
const activeNavItem = ref('assistant')
|
||||
const inputText = ref('')
|
||||
const sending = ref(false)
|
||||
|
||||
const form = ref({
|
||||
content: '',
|
||||
source_lang: 'zh',
|
||||
target_lang: 'en',
|
||||
format: 'txt',
|
||||
})
|
||||
|
||||
async function translateDocument() {
|
||||
if (!form.value.content.trim()) {
|
||||
ElMessage.warning('请输入翻译内容')
|
||||
return
|
||||
const messages = ref([
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '你好!我可以帮你翻译文本、文档。请告诉我你的翻译需求,或者粘贴需要翻译的内容。',
|
||||
timestamp: new Date().toLocaleTimeString(),
|
||||
},
|
||||
])
|
||||
|
||||
const taskItems = ref([
|
||||
{ id: '1', title: '产品手册翻译中...' },
|
||||
{ id: '2', title: '合同条款审查...' },
|
||||
{ id: '3', title: '财务报表翻译...' },
|
||||
])
|
||||
|
||||
async function handleSend(text) {
|
||||
if (!text?.trim()) return
|
||||
sending.value = true
|
||||
|
||||
// Add user message
|
||||
const userMsg = {
|
||||
role: 'user',
|
||||
content: text,
|
||||
timestamp: new Date().toLocaleTimeString(),
|
||||
}
|
||||
translating.value = true
|
||||
messages.value.push(userMsg)
|
||||
|
||||
try {
|
||||
const res = await apiTranslate(form.value)
|
||||
// Send translation request
|
||||
const res = await apiTranslate({
|
||||
content: text,
|
||||
source_lang: form.value.source_lang,
|
||||
target_lang: form.value.target_lang,
|
||||
format: form.value.format,
|
||||
})
|
||||
|
||||
if (res?.data?.result) {
|
||||
translatedContent.value = res.data.result.translated_content
|
||||
resultFormat.value = res.data.format
|
||||
if (res.data.data) {
|
||||
resultBlob.value = res.data.data
|
||||
const translatedContent = res.data.result.translated_content
|
||||
const translationResult = res.data.result
|
||||
|
||||
// Build AI response message
|
||||
const aiMsg = {
|
||||
role: 'assistant',
|
||||
content: `翻译完成!共 ${translatedContent?.length || 0} 字。`,
|
||||
translation_result: translatedContent || '',
|
||||
timestamp: new Date().toLocaleTimeString(),
|
||||
}
|
||||
ElMessage.success('翻译完成')
|
||||
messages.value.push(aiMsg)
|
||||
} else {
|
||||
// Fallback: simple AI response
|
||||
const aiMsg = {
|
||||
role: 'assistant',
|
||||
content: res?.data?.message || '翻译完成!',
|
||||
translation_result: res?.data?.message || '',
|
||||
timestamp: new Date().toLocaleTimeString(),
|
||||
}
|
||||
messages.value.push(aiMsg)
|
||||
}
|
||||
inputText.value = ''
|
||||
} catch (error) {
|
||||
ElMessage.error(error.message || '翻译失败')
|
||||
} finally {
|
||||
translating.value = false
|
||||
sending.value = false
|
||||
await nextTick()
|
||||
scrollToEnd()
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadResult() {
|
||||
if (!resultBlob.value) {
|
||||
ElMessage.warning('暂无可下载的文件')
|
||||
return
|
||||
function scrollToEnd() {
|
||||
const chatBody = document.querySelector('.chat-body')
|
||||
if (chatBody) {
|
||||
chatBody.scrollTop = chatBody.scrollHeight
|
||||
}
|
||||
const exts = { txt: '.txt', docx: '.docx', pptx: '.pptx', xlsx: '.xlsx', pdf: '.pdf' }
|
||||
const ext = exts[resultFormat.value] || '.txt'
|
||||
const url = URL.createObjectURL(resultBlob.value)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = '翻译文档' + ext
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
async function copyResult() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(translatedContent.value)
|
||||
function handleNewChat() {
|
||||
messages.value = [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '你好!我可以帮你翻译文本、文档。请告诉我你的翻译需求,或者粘贴需要翻译的内容。',
|
||||
timestamp: new Date().toLocaleTimeString(),
|
||||
},
|
||||
]
|
||||
inputText.value = ''
|
||||
}
|
||||
|
||||
function handleSelectTask(id) {
|
||||
console.log('Selected task:', id)
|
||||
}
|
||||
|
||||
function handleShowAllTasks() {}
|
||||
|
||||
function handleNavChange(item) {
|
||||
activeNavItem.value = item
|
||||
}
|
||||
|
||||
function handleFollowUp(msg) {
|
||||
// Pre-fill follow-up
|
||||
inputText.value = msg.translation_result
|
||||
? '使语气更正式一些'
|
||||
: '继续'
|
||||
}
|
||||
|
||||
function fillPractice(text) {
|
||||
inputText.value = text
|
||||
}
|
||||
|
||||
function copyTranslation(msg) {
|
||||
const text = msg.translation_result || msg.content
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
ElMessage.success('已复制到剪贴板')
|
||||
} catch {
|
||||
ElMessage.error('复制失败')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function downloadTranslation(msg) {
|
||||
ElMessage.info('下载功能开发中')
|
||||
}
|
||||
|
||||
function handleFileAttach() {
|
||||
ElMessage.info('文件上传功能开发中')
|
||||
}
|
||||
|
||||
function handleReference() {
|
||||
ElMessage.info('引用功能开发中')
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.doc-translate-page { max-width: 1400px; margin: 0 auto; padding: 24px; }
|
||||
.page-header { margin-bottom: 24px; padding-bottom: 16px; border-bottom: 1px solid #eef1f6; }
|
||||
.header-left h1 { margin: 0 0 6px; font-size: 24px; color: #1f2d3d; font-weight: 700; }
|
||||
.header-left p { margin: 0; font-size: 14px; color: #6b7785; }
|
||||
.main-layout { display: grid; grid-template-columns: 420px minmax(0, 1fr); gap: 24px; }
|
||||
.card { border-radius: 16px; background: #fff; border: 1px solid #eef1f6; box-shadow: 0 4px 12px rgba(31, 35, 41, 0.04); padding: 20px; }
|
||||
.card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
||||
.card-title { font-size: 16px; font-weight: 700; color: #1f2d3d; }
|
||||
.card-actions { display: flex; gap: 8px; }
|
||||
.input-panel .card .el-form-item { margin-bottom: 16px; }
|
||||
.input-panel .el-input__inner, .input-panel .el-textarea__inner { font-size: 13px; }
|
||||
.empty-preview { text-align: center; padding: 60px 20px; }
|
||||
.empty-preview h3 { margin: 16px 0 8px; font-size: 18px; color: #1f2d3d; font-weight: 600; }
|
||||
.empty-preview p { font-size: 13px; color: #6b7785; }
|
||||
.icon-item { font-size: 48px; }
|
||||
.result-preview { max-height: 600px; overflow-y: auto; }
|
||||
.result-info { display: flex; gap: 8px; margin-bottom: 12px; }
|
||||
.result-text { white-space: pre-wrap; word-break: break-all; font-size: 14px; line-height: 1.8; color: #3d4854; background: #f8f9fb; border-radius: 8px; padding: 16px; }
|
||||
@media (max-width: 1200px) { .main-layout { grid-template-columns: 1fr; } }
|
||||
.doc-translate-page {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #f0f2f5;
|
||||
}
|
||||
|
||||
/* Category bar */
|
||||
.category-bar {
|
||||
padding: 16px 24px 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.category-pills {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.category-pill {
|
||||
padding: 7px 14px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #e0e6ed;
|
||||
background: #fff;
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.category-pill:hover {
|
||||
border-color: #409eff;
|
||||
color: #409eff;
|
||||
background: #ecf5ff;
|
||||
}
|
||||
|
||||
.category-pill.active {
|
||||
border-color: #409eff;
|
||||
color: #409eff;
|
||||
background: #ecf5ff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pill-icon { font-size: 14px; }
|
||||
|
||||
/* Welcome content */
|
||||
.welcome-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 0 24px 32px;
|
||||
width: 100%;
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.welcome-title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #1f2d3d;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.welcome-subtitle {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* Config panel */
|
||||
.config-panel {
|
||||
background: #fff;
|
||||
border: 1px solid #e8ecf1;
|
||||
border-radius: 12px;
|
||||
padding: 14px 18px;
|
||||
margin-bottom: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.config-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.config-select {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.config-select label {
|
||||
font-size: 11px;
|
||||
color: #909399;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.config-select select {
|
||||
padding: 6px 10px;
|
||||
border: 1px solid #e0e6ed;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: #303133;
|
||||
background: #f8f9fb;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.config-select select:hover,
|
||||
.config-select select:focus {
|
||||
border-color: #409eff;
|
||||
}
|
||||
|
||||
.config-arrow {
|
||||
font-size: 18px;
|
||||
color: #409eff;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Best practices */
|
||||
.best-practices {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.practices-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.practice-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e8ecf1;
|
||||
border-radius: 12px;
|
||||
padding: 14px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, box-shadow 0.2s, transform 0.15s;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.practice-card:hover {
|
||||
border-color: #409eff;
|
||||
box-shadow: 0 2px 12px rgba(64,155,255,0.08);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.card-image {
|
||||
width: 100%;
|
||||
aspect-ratio: 4/3;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 28px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.card-image.bg-translate { background: linear-gradient(135deg, #e8f5e9, #c8e6c9); }
|
||||
.card-image.bg-contract { background: linear-gradient(135deg, #fff3e0, #ffe0b2); }
|
||||
.card-image.bg-finance { background: linear-gradient(135deg, #e3f2fd, #bbdefb); }
|
||||
.card-image.bg-guide { background: linear-gradient(135deg, #fce4ec, #f8bbd0); }
|
||||
|
||||
.card-title {
|
||||
font-size: 12px;
|
||||
color: #303133;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
font-size: 11px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
/* Translation result card */
|
||||
.translation-card {
|
||||
margin-top: 10px;
|
||||
background: #f8f9fb;
|
||||
border: 1px solid #e8ecf1;
|
||||
border-radius: 12px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.translation-meta {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.translation-output {
|
||||
background: #fff;
|
||||
border: 1px solid #eef1f6;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: #303133;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.translation-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
background: #f8f9fb;
|
||||
border: 1px solid #e8ecf1;
|
||||
border-radius: 6px;
|
||||
padding: 5px 10px;
|
||||
font-size: 12px;
|
||||
color: #6b7785;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: #eef1f6;
|
||||
}
|
||||
|
||||
/* Chat message styles (reused from SmartAssistant) */
|
||||
.chat-message {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 4px 0;
|
||||
width: 100%;
|
||||
}
|
||||
.chat-message.user { flex-direction: row-reverse; }
|
||||
.chat-message .msg-avatar {
|
||||
width: 36px; height: 36px; border-radius: 50%;
|
||||
background: #f0f0f0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 18px; flex-shrink: 0;
|
||||
}
|
||||
.chat-message.user .msg-avatar { background: #f5f7fa; border: 1px solid #e8ecf1; }
|
||||
.chat-message.assistant .msg-avatar { background: linear-gradient(135deg, #409eff, #63b3ff); color: #fff; }
|
||||
.chat-message .msg-body { max-width: 85%; min-width: 0; }
|
||||
.chat-message.user .msg-body { max-width: 70%; }
|
||||
.chat-message .msg-content {
|
||||
background: #f5f5f5; padding: 10px 16px;
|
||||
border-radius: 12px; font-size: 14px; line-height: 1.7;
|
||||
word-break: break-word;
|
||||
}
|
||||
.chat-message.user .msg-content { background: #2b63d9; color: #fff; }
|
||||
.msg-actions {
|
||||
display: flex; gap: 6px; margin-top: 8px; flex-wrap: wrap;
|
||||
}
|
||||
.msg-actions button {
|
||||
background: #f8f9fb; border: 1px solid #e8ecf1;
|
||||
border-radius: 6px; padding: 5px 10px; font-size: 12px;
|
||||
color: #6b7785; cursor: pointer;
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.msg-actions button:hover { background: #eef1f6; }
|
||||
.msg-time { font-size: 11px; color: #909399; margin-top: 4px; padding: 0 4px; }
|
||||
.typing-dots { display: flex; gap: 3px; padding: 4px 0; }
|
||||
.typing-dots span { width: 6px; height: 6px; background: #909399; border-radius: 50%; animation: typingDot 1.4s infinite; }
|
||||
.typing-dots span:nth-child(2) { animation-delay: 0.2s; }
|
||||
.typing-dots span:nth-child(3) { animation-delay: 0.4s; }
|
||||
@keyframes typingDot {
|
||||
0%, 60%, 100% { opacity: 0.3; transform: translateY(0); }
|
||||
30% { opacity: 1; transform: translateY(-3px); }
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 1200px) {
|
||||
.practices-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.practices-grid { grid-template-columns: 1fr; }
|
||||
.config-row { flex-direction: column; }
|
||||
.config-select { min-width: 100%; }
|
||||
.chat-message .msg-body { max-width: 90% !important; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,46 +1,154 @@
|
||||
<template>
|
||||
<div class="smart-assistant-page">
|
||||
<header class="page-header">
|
||||
<div class="header-left">
|
||||
<h1>智能助手</h1>
|
||||
<p>AI 智能助手 — 对话 · 任务拆解 · 文案生成 · 批量提取</p>
|
||||
<!-- WorkBuddy-style category pills -->
|
||||
<div class="category-bar">
|
||||
<div class="category-pills">
|
||||
<div class="category-pill active" @click="selectedCategory = 'doc'">
|
||||
<span class="pill-icon">📝</span> 文档处理
|
||||
</div>
|
||||
<div class="category-pill" @click="selectedCategory = 'finance'">
|
||||
<span class="pill-icon">📊</span> 金融服务
|
||||
</div>
|
||||
<div class="category-pill" @click="selectedCategory = 'data'">
|
||||
<span class="pill-icon">📈</span> 数据分析及可视化
|
||||
</div>
|
||||
<div class="category-pill" @click="selectedCategory = 'personal'">
|
||||
<span class="pill-icon">🛠️</span> 个人工作台
|
||||
</div>
|
||||
<div class="category-pill" @click="selectedCategory = 'slide'">
|
||||
<span class="pill-icon">📑</span> 幻灯片
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="mode-tabs">
|
||||
<el-radio-group v-model="mode" size="large">
|
||||
<el-radio-button value="chat">💬 对话</el-radio-button>
|
||||
<el-radio-button value="task">📋 任务拆解</el-radio-button>
|
||||
<el-radio-button value="copy">✏️ 文案生成</el-radio-button>
|
||||
<el-radio-button value="extract">📊 批量提取</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
|
||||
<div class="chat-layout">
|
||||
<div class="chat-panel">
|
||||
<div class="messages" ref="messagesEl">
|
||||
<div v-for="(msg, idx) in messages" :key="idx" class="message" :class="msg.role">
|
||||
<div class="msg-avatar">{{ msg.role === 'user' ? '👤' : '🤖' }}</div>
|
||||
<div class="msg-body">
|
||||
<div class="msg-content">{{ msg.content }}</div>
|
||||
<div v-if="msg.task_plan" class="msg-task-plan">
|
||||
<div class="plan-title">{{ msg.task_plan.title }}</div>
|
||||
<div v-for="step in msg.task_plan.steps" :key="step.id" class="plan-step">
|
||||
<span class="step-num">{{ step.order }}</span>
|
||||
<span class="step-title">{{ step.title }}</span>
|
||||
<span class="step-desc">{{ step.desc }}</span>
|
||||
</div>
|
||||
<ChatLayout
|
||||
ref="chatLayoutRef"
|
||||
:has-messages="messages.length > 1"
|
||||
:active-nav-item="activeNavItem"
|
||||
:history-count="5"
|
||||
:task-items="taskItems"
|
||||
@new-chat="handleNewChat"
|
||||
@select-task="handleSelectTask"
|
||||
@show-all-tasks="handleShowAllTasks"
|
||||
@nav-change="handleNavChange"
|
||||
>
|
||||
<!-- Welcome state (shown when no messages) -->
|
||||
<template v-if="messages.length <= 1" #welcome>
|
||||
<div class="welcome-content">
|
||||
<div class="welcome-title">AI Tools, 我帮你</div>
|
||||
<div class="welcome-subtitle">今天帮你做些什么? @ 引用对话文件,/ 调用技能与指令</div>
|
||||
|
||||
<!-- Mode pills above input -->
|
||||
<div class="mode-pills">
|
||||
<div class="mode-pill" :class="{ active: mode === 'chat' }" @click="mode = 'chat'">
|
||||
<span class="pill-icon">💬</span> 对话
|
||||
</div>
|
||||
<div class="mode-pill" :class="{ active: mode === 'task' }" @click="mode = 'task'">
|
||||
<span class="pill-icon">📋</span> 任务拆解
|
||||
</div>
|
||||
<div class="mode-pill" :class="{ active: mode === 'copy' }" @click="mode = 'copy'">
|
||||
<span class="pill-icon">✏️</span> 文案生成
|
||||
</div>
|
||||
<div class="mode-pill" :class="{ active: mode === 'extract' }" @click="mode = 'extract'">
|
||||
<span class="pill-icon">📊</span> 批量提取
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Best practice cards -->
|
||||
<div class="best-practices">
|
||||
<div class="practices-header">
|
||||
<span class="practices-title">不知道做什么,试试最佳实践案例</span>
|
||||
<div class="practices-actions">
|
||||
<button class="refresh-btn">🔄 换一批</button>
|
||||
<button class="close-btn">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="practices-grid">
|
||||
<div class="practice-card" @click="fillPractice('帮我翻译这段产品说明到英文,保持专业语气')">
|
||||
<div class="card-image bg-translate">📄</div>
|
||||
<div class="card-title">八十二亿之后:全球人口趋势分析报告</div>
|
||||
<div class="card-meta">多语言翻译 · 专业文档</div>
|
||||
</div>
|
||||
<div class="practice-card" @click="fillPractice('检查销售话术文案的错别字和语病')">
|
||||
<div class="card-image bg-proof">📊</div>
|
||||
<div class="card-title">养老退休规划方案</div>
|
||||
<div class="card-meta">文案校对 · 金融合规</div>
|
||||
</div>
|
||||
<div class="practice-card" @click="fillPractice('审查这份采购合同的法律风险')">
|
||||
<div class="card-image bg-contract">📋</div>
|
||||
<div class="card-title">《思考,快与慢》精读笔记整理</div>
|
||||
<div class="card-meta">合同审查 · 风险评估</div>
|
||||
</div>
|
||||
<div class="practice-card" @click="fillPractice('帮我生成新产品上市的 GTM 发布计划')">
|
||||
<div class="card-image bg-gtm">🚀</div>
|
||||
<div class="card-title">新产品上市 GTM 发布计划</div>
|
||||
<div class="card-meta">文案生成 · 营销策略</div>
|
||||
</div>
|
||||
<div class="msg-time">{{ msg.timestamp }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-area">
|
||||
<el-input v-model="inputText" :rows="3" type="textarea" :placeholder="placeholder" @keydown.enter.ctrl="send" />
|
||||
<el-button type="primary" @click="send" :loading="sending">发送</el-button>
|
||||
</template>
|
||||
|
||||
<!-- Messages slot -->
|
||||
<template #messages>
|
||||
<div
|
||||
v-for="(msg, idx) in messages"
|
||||
:key="idx"
|
||||
class="chat-message"
|
||||
:class="msg.role"
|
||||
>
|
||||
<div class="msg-avatar" :class="msg.role">
|
||||
{{ msg.role === 'user' ? '👤' : '🤖' }}
|
||||
</div>
|
||||
<div class="msg-body">
|
||||
<div class="msg-content">{{ msg.content }}</div>
|
||||
<!-- Task plan display -->
|
||||
<div v-if="msg.task_plan" class="msg-task-plan">
|
||||
<div class="plan-title">{{ msg.task_plan.title }}</div>
|
||||
<div v-for="step in msg.task_plan.steps" :key="step.id" class="plan-step">
|
||||
<span class="step-num">{{ step.order }}</span>
|
||||
<span class="step-title">{{ step.title }}</span>
|
||||
<span class="step-desc">{{ step.desc }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Actions -->
|
||||
<div class="msg-actions">
|
||||
<button @click="copyMessage(msg.content)">📋 复制</button>
|
||||
<button v-if="msg.role === 'assistant'" @click="handleFollowUp(msg)">💬 追问</button>
|
||||
</div>
|
||||
<div class="msg-time">{{ msg.timestamp }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Typing indicator -->
|
||||
<div v-if="sending" class="chat-message assistant">
|
||||
<div class="msg-avatar assistant">🤖</div>
|
||||
<div class="msg-body">
|
||||
<div class="msg-content">
|
||||
<div class="typing-dots">
|
||||
<span></span><span></span><span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Input slot -->
|
||||
<template #input>
|
||||
<ChatInputBar
|
||||
v-model="inputText"
|
||||
:placeholder="placeholder"
|
||||
:can-send="inputText.trim().length > 0 && !sending"
|
||||
:show-actions="true"
|
||||
@send="handleSend"
|
||||
>
|
||||
<template #left-actions>
|
||||
<button class="input-action-btn" title="文件附件" @click="handleFileAttach">📎</button>
|
||||
<button class="input-action-btn" title="语音输入" @click="handleVoice">🎤</button>
|
||||
<button class="input-action-btn" title="引用" @click="handleReference">📑</button>
|
||||
</template>
|
||||
</ChatInputBar>
|
||||
</template>
|
||||
</ChatLayout>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -48,61 +156,539 @@
|
||||
import { ref, computed, nextTick } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { chatWithAssistant } from '@/api/assistant'
|
||||
import ChatLayout from '@/components/chat/ChatLayout.vue'
|
||||
import ChatInputBar from '@/components/chat/ChatInputBar.vue'
|
||||
|
||||
const mode = ref('chat')
|
||||
const selectedCategory = ref('doc')
|
||||
const activeNavItem = ref('assistant')
|
||||
const inputText = ref('')
|
||||
const messages = ref([
|
||||
{ role: 'assistant', content: '你好!我是你的AI助手,可以帮你文档翻译、文案校对、语音转文字、任务拆解和批量字段提取。', timestamp: new Date().toLocaleTimeString(), type: 'text' }
|
||||
])
|
||||
const sending = ref(false)
|
||||
const messagesEl = ref(null)
|
||||
const messages = ref([
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '你好!我是你的AI助手,可以帮你文档翻译、文案校对、语音转文字、任务拆解和批量字段提取。',
|
||||
timestamp: new Date().toLocaleTimeString(),
|
||||
},
|
||||
])
|
||||
|
||||
const taskItems = ref([
|
||||
{ id: '1', title: '2026 当中 GPU - 显...' },
|
||||
{ id: '2', title: '搜索分析零工类应用' },
|
||||
{ id: '3', title: '将微信文章全部内容...' },
|
||||
{ id: '4', title: '管理企业微信待办...' },
|
||||
{ id: '5', title: '管理企业微信待办...' },
|
||||
])
|
||||
|
||||
const placeholder = computed(() => {
|
||||
const map = { chat: '输入你的问题...', task: '输入需要拆解的任务...', copy: '输入需要生成的文案类型...', extract: '输入包含字段的文档内容...' }
|
||||
const map = {
|
||||
chat: '输入你的问题...',
|
||||
task: '输入需要拆解的任务...',
|
||||
copy: '输入需要生成的文案类型...',
|
||||
extract: '输入包含字段的文档内容...',
|
||||
}
|
||||
return map[mode.value] || '输入你的问题...'
|
||||
})
|
||||
|
||||
async function send() {
|
||||
if (!inputText.value.trim()) return
|
||||
const userMsg = { role: 'user', content: inputText.value, timestamp: new Date().toLocaleTimeString() }
|
||||
if (!inputText.value.trim() || sending.value) return
|
||||
|
||||
const userMsg = {
|
||||
role: 'user',
|
||||
content: inputText.value,
|
||||
timestamp: new Date().toLocaleTimeString(),
|
||||
}
|
||||
messages.value.push(userMsg)
|
||||
const text = inputText.value
|
||||
inputText.value = ''
|
||||
sending.value = true
|
||||
|
||||
try {
|
||||
const res = await chatWithAssistant({ message: userMsg.content, mode: mode.value })
|
||||
const res = await chatWithAssistant({ message: text, mode: mode.value })
|
||||
const reply = res.data.message
|
||||
reply.timestamp = new Date().toLocaleTimeString()
|
||||
messages.value.push(reply)
|
||||
await nextTick()
|
||||
scrollToEnd()
|
||||
} catch (error) {
|
||||
ElMessage.error(error.message || '发送失败')
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToEnd() {
|
||||
const chatBody = document.querySelector('.chat-body')
|
||||
if (chatBody) {
|
||||
chatBody.scrollTop = chatBody.scrollHeight
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSend(text) {
|
||||
if (!text?.trim()) return
|
||||
inputText.value = text
|
||||
await send()
|
||||
}
|
||||
|
||||
function handleNewChat() {
|
||||
messages.value = [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '你好!我是你的AI助手,可以帮你文档翻译、文案校对、语音转文字、任务拆解和批量字段提取。',
|
||||
timestamp: new Date().toLocaleTimeString(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function handleSelectTask(id) {
|
||||
// Load task context
|
||||
console.log('Selected task:', id)
|
||||
}
|
||||
|
||||
function handleShowAllTasks() {
|
||||
// Show all tasks
|
||||
}
|
||||
|
||||
function handleNavChange(item) {
|
||||
activeNavItem.value = item
|
||||
}
|
||||
|
||||
function handleFollowUp(msg) {
|
||||
// Pre-fill follow-up
|
||||
inputText.value = '继续...'
|
||||
}
|
||||
|
||||
function fillPractice(text) {
|
||||
inputText.value = text
|
||||
// Switch to chat state
|
||||
}
|
||||
|
||||
function copyMessage(text) {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
ElMessage.success('已复制')
|
||||
}).catch(() => {
|
||||
ElMessage.error('复制失败')
|
||||
})
|
||||
}
|
||||
|
||||
function handleFileAttach() {
|
||||
ElMessage.info('文件上传功能开发中')
|
||||
}
|
||||
|
||||
function handleVoice() {
|
||||
ElMessage.info('语音输入功能开发中')
|
||||
}
|
||||
|
||||
function handleReference() {
|
||||
ElMessage.info('引用功能开发中')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.smart-assistant-page { max-width: 1200px; margin: 0 auto; padding: 24px; }
|
||||
.page-header { margin-bottom: 16px; padding-bottom: 16px; border-bottom: 1px solid #eef1f6; }
|
||||
.page-header h1 { margin: 0 0 4px; font-size: 24px; color: #1f2d3d; }
|
||||
.page-header p { margin: 0; font-size: 14px; color: #6b7785; }
|
||||
.mode-tabs { margin-bottom: 16px; }
|
||||
.chat-layout { display: flex; gap: 24px; }
|
||||
.chat-panel { flex: 1; background: #fff; border-radius: 16px; border: 1px solid #eef1f6; box-shadow: 0 4px 12px rgba(31, 35, 41, 0.04); overflow: hidden; }
|
||||
.messages { padding: 16px; max-height: 500px; overflow-y: auto; }
|
||||
.message { display: flex; gap: 12px; margin-bottom: 16px; }
|
||||
.message.user { flex-direction: row-reverse; }
|
||||
.msg-avatar { width: 36px; height: 36px; border-radius: 50%; background: #f0f0f0; display: flex; align-items: center; justify-content: center; font-size: 18px; flex-shrink: 0; }
|
||||
.msg-body { max-width: 70%; }
|
||||
.msg-content { background: #f5f5f5; padding: 10px 14px; border-radius: 12px; font-size: 14px; line-height: 1.6; }
|
||||
.message.user .msg-content { background: #2b63d9; color: #fff; }
|
||||
.msg-time { font-size: 11px; color: #909399; margin-top: 4px; }
|
||||
.msg-task-plan { margin-top: 8px; background: #f0f7ff; padding: 10px; border-radius: 8px; border: 1px solid #d6e8ff; }
|
||||
.plan-title { font-weight: 600; font-size: 14px; color: #2b63d9; margin-bottom: 8px; }
|
||||
.plan-step { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; font-size: 13px; }
|
||||
.step-num { width: 22px; height: 22px; border-radius: 50%; background: #2b63d9; color: #fff; display: flex; align-items: center; justify-content: center; font-size: 11px; flex-shrink: 0; }
|
||||
.step-title { font-weight: 600; color: #1f2d3d; }
|
||||
.step-desc { color: #6b7785; }
|
||||
.input-area { padding: 16px; border-top: 1px solid #eef1f6; display: flex; gap: 8px; }
|
||||
.input-area .el-input { flex: 1; }
|
||||
.smart-assistant-page {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #f0f2f5;
|
||||
}
|
||||
|
||||
/* ===== CATEGORY BAR ===== */
|
||||
.category-bar {
|
||||
padding: 16px 24px 8px;
|
||||
flex-shrink: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.category-pills {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.category-pill {
|
||||
padding: 7px 14px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #e0e6ed;
|
||||
background: #fff;
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.category-pill:hover {
|
||||
border-color: #409eff;
|
||||
color: #409eff;
|
||||
background: #ecf5ff;
|
||||
}
|
||||
|
||||
.category-pill.active {
|
||||
border-color: #409eff;
|
||||
color: #409eff;
|
||||
background: #ecf5ff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pill-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ===== WELCOME CONTENT ===== */
|
||||
.welcome-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 0 24px 32px;
|
||||
width: 100%;
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.welcome-title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #1f2d3d;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.welcome-subtitle {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* ===== MODE PILLS ===== */
|
||||
.mode-pills {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.mode-pill {
|
||||
padding: 6px 14px;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
border: 1px solid #e0e6ed;
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.mode-pill:hover {
|
||||
border-color: #409eff;
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.mode-pill.active {
|
||||
background: #409eff;
|
||||
color: #fff;
|
||||
border-color: #409eff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ===== BEST PRACTICE CARDS ===== */
|
||||
.best-practices {
|
||||
width: 100%;
|
||||
border-top: 1px solid #eef1f6;
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.practices-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.practices-title {
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.practices-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.practices-actions button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #909399;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.practices-actions button:hover {
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.practices-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.practice-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e8ecf1;
|
||||
border-radius: 12px;
|
||||
padding: 14px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, box-shadow 0.2s, transform 0.15s;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.practice-card:hover {
|
||||
border-color: #409eff;
|
||||
box-shadow: 0 2px 12px rgba(64,155,255,0.08);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.card-image {
|
||||
width: 100%;
|
||||
aspect-ratio: 4/3;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 28px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.card-image.bg-translate { background: linear-gradient(135deg, #e8f5e9, #c8e6c9); }
|
||||
.card-image.bg-proof { background: linear-gradient(135deg, #e3f2fd, #bbdefb); }
|
||||
.card-image.bg-contract { background: linear-gradient(135deg, #fff3e0, #ffe0b2); }
|
||||
.card-image.bg-gtm { background: linear-gradient(135deg, #fce4ec, #f8bbd0); }
|
||||
|
||||
.card-title {
|
||||
font-size: 12px;
|
||||
color: #303133;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
font-size: 11px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
/* ===== CHAT MESSAGE STYLES ===== */
|
||||
.chat-message {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 4px 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chat-message.user {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.chat-message .msg-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: #f0f0f0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-message.user .msg-avatar {
|
||||
background: #f5f7fa;
|
||||
border: 1px solid #e8ecf1;
|
||||
}
|
||||
|
||||
.chat-message.assistant .msg-avatar {
|
||||
background: linear-gradient(135deg, #409eff, #63b3ff);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.chat-message .msg-body {
|
||||
max-width: 85%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chat-message.user .msg-body {
|
||||
max-width: 70%;
|
||||
}
|
||||
|
||||
.chat-message .msg-content {
|
||||
background: #f5f5f5;
|
||||
padding: 10px 16px;
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.chat-message.user .msg-content {
|
||||
background: #2b63d9;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Task plan display */
|
||||
.msg-task-plan {
|
||||
margin-top: 10px;
|
||||
background: #f0f7ff;
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #d6e8ff;
|
||||
}
|
||||
|
||||
.plan-title {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: #2b63d9;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.plan-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.plan-step:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.step-num {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: #2b63d9;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.step-title {
|
||||
font-weight: 600;
|
||||
color: #1f2d3d;
|
||||
}
|
||||
|
||||
.step-desc {
|
||||
color: #6b7785;
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
.msg-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.msg-actions button {
|
||||
background: #f8f9fb;
|
||||
border: 1px solid #e8ecf1;
|
||||
border-radius: 6px;
|
||||
padding: 5px 10px;
|
||||
font-size: 12px;
|
||||
color: #6b7785;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.msg-actions button:hover {
|
||||
background: #eef1f6;
|
||||
}
|
||||
|
||||
.msg-time {
|
||||
font-size: 11px;
|
||||
color: #909399;
|
||||
margin-top: 4px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
/* Typing dots */
|
||||
.typing-dots {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.typing-dots span {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: #909399;
|
||||
border-radius: 50%;
|
||||
animation: typingDot 1.4s infinite;
|
||||
}
|
||||
|
||||
.typing-dots span:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.typing-dots span:nth-child(3) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
@keyframes typingDot {
|
||||
0%, 60%, 100% { opacity: 0.3; transform: translateY(0); }
|
||||
30% { opacity: 1; transform: translateY(-3px); }
|
||||
}
|
||||
|
||||
/* ===== RESPONSIVE ===== */
|
||||
@media (max-width: 1200px) {
|
||||
.practices-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.practices-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.category-pill {
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.welcome-title {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.chat-message .msg-body {
|
||||
max-width: 90% !important;
|
||||
}
|
||||
|
||||
.msg-actions {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.msg-actions button {
|
||||
padding: 4px 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user