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:
eaiadmin
2026-09-14 01:44:12 +08:00
co-authored by Claude Code
parent 9de09dfa72
commit 5c0870af97
13 changed files with 5343 additions and 764 deletions
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
}
</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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
</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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
</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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
</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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
}
</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>