427 lines
14 KiB
Vue
427 lines
14 KiB
Vue
<script setup>
|
||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||
import { useChat } from '@/composables/useChat'
|
||
import { useAppStore } from '@/stores/app'
|
||
import { renderMarkdown } from '@/utils/markdown'
|
||
import assistantAvatar from '@/assets/sysA.jpeg'
|
||
|
||
const store = useAppStore()
|
||
const {
|
||
sessions, currentSessionId, messages, streaming,
|
||
loadSessions, selectSession, createSession, deleteSession, sendQuestion,
|
||
uploadImage,
|
||
} = useChat()
|
||
|
||
const question = ref('')
|
||
const messagesEl = ref(null)
|
||
const fileInput = ref(null)
|
||
const pendingImages = ref([])
|
||
const sessionFilter = ref('')
|
||
const deepThinking = ref(false)
|
||
const lightboxSrc = ref('')
|
||
|
||
function onBubbleClick(e) {
|
||
if (e.target?.tagName === 'IMG' && e.target.src) {
|
||
lightboxSrc.value = e.target.src
|
||
}
|
||
}
|
||
|
||
function scrollToEnd() {
|
||
nextTick(() => {
|
||
const el = messagesEl.value
|
||
if (el) el.scrollTop = el.scrollHeight
|
||
})
|
||
}
|
||
|
||
async function pickImages() {
|
||
fileInput.value?.click()
|
||
}
|
||
|
||
async function onFilesPicked(event) {
|
||
const files = Array.from(event.target.files || [])
|
||
event.target.value = ''
|
||
for (const f of files) {
|
||
if (!/^image\/(png|jpe?g)$/.test(f.type)) {
|
||
store.toast(`${f.name} 不是 PNG/JPG`, 'warning')
|
||
continue
|
||
}
|
||
if (f.size > 8 * 1024 * 1024) {
|
||
store.toast(`${f.name} 超过 8MB`, 'warning')
|
||
continue
|
||
}
|
||
try {
|
||
const att = await uploadImage(f)
|
||
const data_url = await readAsDataUrl(f)
|
||
pendingImages.value.push({ id: att.id, file_type: f.type, data_url })
|
||
} catch (err) {
|
||
store.toast(`上传失败:${err.message}`, 'error')
|
||
}
|
||
}
|
||
}
|
||
|
||
function readAsDataUrl(file) {
|
||
return new Promise((resolve, reject) => {
|
||
const r = new FileReader()
|
||
r.onload = () => resolve(r.result)
|
||
r.onerror = () => reject(r.error)
|
||
r.readAsDataURL(file)
|
||
})
|
||
}
|
||
|
||
function removePendingImage(idx) {
|
||
pendingImages.value.splice(idx, 1)
|
||
}
|
||
|
||
async function submit() {
|
||
const text = question.value
|
||
const attachments = pendingImages.value.slice()
|
||
if (!text.trim() && attachments.length === 0) return
|
||
question.value = ''
|
||
pendingImages.value = []
|
||
try {
|
||
await sendQuestion(text, attachments)
|
||
} catch (err) {
|
||
store.toast(err.message)
|
||
}
|
||
}
|
||
|
||
function onComposerKeydown(e) {
|
||
if (e.key !== 'Enter') return
|
||
if (e.shiftKey || e.ctrlKey || e.metaKey || e.altKey) return
|
||
if (e.isComposing || e.keyCode === 229) return
|
||
e.preventDefault()
|
||
if (streaming.value) return
|
||
submit()
|
||
}
|
||
|
||
async function onCreateSession() {
|
||
try {
|
||
await createSession()
|
||
} catch (err) {
|
||
store.toast(err.message)
|
||
}
|
||
}
|
||
|
||
async function onDeleteSession(session) {
|
||
try {
|
||
await deleteSession(session)
|
||
store.toast('会话已删除')
|
||
} catch (err) {
|
||
store.toast(err.message)
|
||
}
|
||
}
|
||
|
||
async function onSelectSession(id) {
|
||
try {
|
||
await selectSession(id)
|
||
} catch (err) {
|
||
store.toast(err.message)
|
||
}
|
||
}
|
||
|
||
function copyText(text) {
|
||
if (!text) return
|
||
navigator.clipboard?.writeText(text).then(
|
||
() => store.toast('已复制', 'success'),
|
||
() => store.toast('复制失败', 'warning'),
|
||
)
|
||
}
|
||
|
||
function startOfDay(d) {
|
||
const x = new Date(d)
|
||
x.setHours(0, 0, 0, 0)
|
||
return x.getTime()
|
||
}
|
||
|
||
const sessionGroups = computed(() => {
|
||
const todayStart = startOfDay(new Date())
|
||
const yesterdayStart = todayStart - 86400000
|
||
const kw = sessionFilter.value.trim().toLowerCase()
|
||
const groups = { today: [], yesterday: [], earlier: [] }
|
||
for (const s of sessions.value) {
|
||
if (kw && !(s.title || '').toLowerCase().includes(kw)) continue
|
||
const ts = new Date(s.last_message_at || s.created_at).getTime()
|
||
if (ts >= todayStart) groups.today.push(s)
|
||
else if (ts >= yesterdayStart) groups.yesterday.push(s)
|
||
else groups.earlier.push(s)
|
||
}
|
||
return groups
|
||
})
|
||
|
||
function formatSessionTime(s) {
|
||
const d = new Date(s.last_message_at || s.created_at)
|
||
if (Number.isNaN(d.getTime())) return ''
|
||
const todayStart = startOfDay(new Date())
|
||
if (d.getTime() >= todayStart) {
|
||
return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
||
}
|
||
return d.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' }).replace(/\//g, '/')
|
||
}
|
||
|
||
function formatBubbleTime(msg) {
|
||
const ts = msg?.created_at
|
||
const d = ts ? new Date(ts) : new Date()
|
||
if (Number.isNaN(d.getTime())) return ''
|
||
return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
||
}
|
||
|
||
const userAvatarInitial = computed(() => (store.currentUser?.name || '我')[0].toUpperCase())
|
||
|
||
onMounted(async () => {
|
||
if (store.token) {
|
||
try { await loadSessions() } catch (err) { store.toast(err.message) }
|
||
}
|
||
})
|
||
|
||
watch(() => store.token, async (val) => {
|
||
if (val) await loadSessions()
|
||
else {
|
||
sessions.value = []
|
||
messages.value = []
|
||
currentSessionId.value = null
|
||
}
|
||
})
|
||
|
||
watch(messages, scrollToEnd, { deep: true })
|
||
</script>
|
||
|
||
<template>
|
||
<section class="view is-visible chat-layout">
|
||
<div class="welcome-banner">
|
||
<div class="welcome-text">
|
||
<div class="welcome-greeting">您好,欢迎回来!</div>
|
||
<h1 class="welcome-title">我是您的财税<em>AI</em>助手</h1>
|
||
<p class="welcome-subtitle">专注财税咨询 · 智能问答 · 专业可靠</p>
|
||
</div>
|
||
<div class="welcome-art">
|
||
<div class="welcome-art-bubble b1"></div>
|
||
<div class="welcome-art-bubble b2"></div>
|
||
<div class="welcome-art-bubble b3"></div>
|
||
<div class="welcome-art-bubble b4"></div>
|
||
<span class="welcome-art-text">AI</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="chat-layout-body">
|
||
<section class="conversation-panel">
|
||
<div class="panel-head">
|
||
<h2>历史会话</h2>
|
||
</div>
|
||
<div class="session-toolbar">
|
||
<button class="primary-btn session-new" type="button" @click="onCreateSession">
|
||
<span>+</span>
|
||
<span>新建对话</span>
|
||
</button>
|
||
<div class="session-search">
|
||
<span class="session-search-icon">🔍</span>
|
||
<input v-model="sessionFilter" placeholder="筛选" />
|
||
</div>
|
||
</div>
|
||
<div class="session-list">
|
||
<div v-if="!sessions.length" class="empty-state small">
|
||
{{ store.token ? '暂无会话' : '请先登录' }}
|
||
</div>
|
||
<template v-else>
|
||
<template v-for="group in [
|
||
{ key: 'today', label: '今天', items: sessionGroups.today },
|
||
{ key: 'yesterday', label: '昨天', items: sessionGroups.yesterday },
|
||
{ key: 'earlier', label: '更早', items: sessionGroups.earlier },
|
||
]" :key="group.key">
|
||
<div v-if="group.items.length" class="session-group">
|
||
<div class="session-group-title">{{ group.label }}</div>
|
||
<div
|
||
v-for="session in group.items"
|
||
:key="session.id"
|
||
class="session-item"
|
||
:class="{ active: session.id === currentSessionId }"
|
||
>
|
||
<button class="session-body" type="button" @click="onSelectSession(session.id)">
|
||
<div class="session-title">{{ session.title || '未命名会话' }}</div>
|
||
</button>
|
||
<span class="session-meta">{{ formatSessionTime(session) }}</span>
|
||
<button
|
||
class="session-delete"
|
||
type="button"
|
||
title="删除会话"
|
||
aria-label="删除会话"
|
||
@click.stop="onDeleteSession(session)"
|
||
>×</button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</template>
|
||
</div>
|
||
<div class="session-footer">
|
||
<button class="ghost-btn session-view-all" type="button">⌖ 查看全部记录</button>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="chat-panel">
|
||
<div ref="messagesEl" class="messages">
|
||
<div v-if="!messages.length" class="empty-state">
|
||
输入一个财务制度或流程问题开始问答
|
||
</div>
|
||
<div
|
||
v-for="(msg, idx) in messages"
|
||
:key="idx"
|
||
class="message"
|
||
:class="msg.role"
|
||
>
|
||
<div v-if="msg.role === 'assistant'" class="msg-avatar assistant">
|
||
<img :src="assistantAvatar" alt="AI助手" />
|
||
</div>
|
||
<div class="bubble-col">
|
||
<div class="bubble">
|
||
<template v-if="msg.role === 'user'">
|
||
<div v-if="msg.attachments && msg.attachments.length" class="bubble-images">
|
||
<a
|
||
v-for="(att, aIdx) in msg.attachments"
|
||
:key="aIdx"
|
||
:href="att.data_url"
|
||
target="_blank"
|
||
rel="noopener"
|
||
class="bubble-image-link"
|
||
>
|
||
<img :src="att.data_url" :alt="`附件${aIdx + 1}`" />
|
||
</a>
|
||
</div>
|
||
<div v-if="msg.text" class="bubble-body">{{ msg.text }}</div>
|
||
</template>
|
||
<template v-else>
|
||
<div
|
||
v-if="!msg.text && streaming && idx === messages.length - 1"
|
||
class="typing-indicator"
|
||
aria-label="AI 正在生成回答"
|
||
>
|
||
<span></span><span></span><span></span>
|
||
</div>
|
||
<div
|
||
v-else
|
||
class="bubble-body markdown-body"
|
||
v-html="renderMarkdown(msg.text)"
|
||
@click="onBubbleClick"
|
||
/>
|
||
<div v-if="msg.references && msg.references.length" class="bubble-references">
|
||
<div class="bubble-references-title">参考来源</div>
|
||
<div class="bubble-references-body">
|
||
<details
|
||
v-for="(ref, rIdx) in msg.references"
|
||
:key="rIdx"
|
||
class="bubble-reference-item"
|
||
>
|
||
<summary>
|
||
<span class="ref-icon">📄</span>
|
||
<span class="ref-title">{{ rIdx + 1 }}.{{ ref.source_title || '未知来源' }}</span>
|
||
<small v-if="ref.page_no" class="ref-page">第 {{ ref.page_no }} 页</small>
|
||
<span class="ref-arrow">›</span>
|
||
</summary>
|
||
<a
|
||
v-if="ref.source_url"
|
||
:href="ref.source_url"
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
class="ref-url-line"
|
||
@click.stop
|
||
>🔗 {{ ref.source_url }}</a>
|
||
<div v-if="ref.snippet" class="ref-snippet">{{ ref.snippet.trim() }}</div>
|
||
</details>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
<div class="bubble-foot">
|
||
<template v-if="msg.role === 'assistant'">
|
||
<div class="bubble-actions">
|
||
<button type="button" title="复制" @click="copyText(msg.text)">⎘</button>
|
||
<button type="button" title="赞">👍</button>
|
||
<button type="button" title="踩">👎</button>
|
||
<button type="button" title="分享">↗</button>
|
||
</div>
|
||
<span class="bubble-time">{{ formatBubbleTime(msg) }}</span>
|
||
</template>
|
||
<template v-else>
|
||
<span class="bubble-time">{{ formatBubbleTime(msg) }}</span>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
<div v-if="msg.role === 'user'" class="msg-avatar user">{{ userAvatarInitial }}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<form class="composer" @submit.prevent="submit">
|
||
<div v-if="pendingImages.length" class="composer-images">
|
||
<div
|
||
v-for="(img, idx) in pendingImages"
|
||
:key="img.id"
|
||
class="composer-image"
|
||
>
|
||
<img :src="img.data_url" :alt="`图片${idx + 1}`" />
|
||
<button
|
||
class="composer-image-remove"
|
||
type="button"
|
||
title="移除"
|
||
@click="removePendingImage(idx)"
|
||
>×</button>
|
||
</div>
|
||
</div>
|
||
<textarea
|
||
v-model="question"
|
||
rows="2"
|
||
placeholder="输入问题,或上传图片/文件,AI 将为您解答..."
|
||
:disabled="streaming"
|
||
@keydown="onComposerKeydown"
|
||
/>
|
||
<div class="composer-row">
|
||
<div class="composer-chips">
|
||
<button type="button" class="composer-chip" :disabled="streaming" @click="pickImages">
|
||
<span>📎</span><span>上传文件</span>
|
||
</button>
|
||
<button type="button" class="composer-chip" :disabled="streaming" @click="pickImages">
|
||
<span>🖼️</span><span>图片识别</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="composer-chip"
|
||
:class="{ active: deepThinking }"
|
||
@click="deepThinking = !deepThinking"
|
||
>
|
||
<span>🧠</span><span>深度思考</span>
|
||
</button>
|
||
</div>
|
||
<button class="composer-send" type="submit" :disabled="streaming" title="发送">
|
||
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
|
||
<path d="M22 2 11 13" />
|
||
<path d="M22 2l-7 20-4-9-9-4 20-7z" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
<input
|
||
ref="fileInput"
|
||
type="file"
|
||
accept="image/png,image/jpeg"
|
||
multiple
|
||
hidden
|
||
@change="onFilesPicked"
|
||
/>
|
||
</form>
|
||
</section>
|
||
</div>
|
||
|
||
<div
|
||
v-if="lightboxSrc"
|
||
class="image-lightbox"
|
||
role="dialog"
|
||
aria-label="预览图片"
|
||
@click="lightboxSrc = ''"
|
||
>
|
||
<img :src="lightboxSrc" alt="预览" @click.stop />
|
||
<button
|
||
class="image-lightbox-close"
|
||
type="button"
|
||
aria-label="关闭预览"
|
||
@click="lightboxSrc = ''"
|
||
>×</button>
|
||
</div>
|
||
</section>
|
||
</template>
|