This commit is contained in:
nojava
2026-05-23 23:33:27 +08:00
parent 8b23d02531
commit 8b638f29c6
27 changed files with 2749 additions and 1 deletions
+17
View File
@@ -0,0 +1,17 @@
import request from '@/utils/request'
export function createAiChatSession() {
return request.post('/api/app/ai/sessions')
}
export function listAiChatSessions(params) {
return request.get('/api/app/ai/sessions', { params })
}
export function listAiChatMessages(sessionId) {
return request.get(`/api/app/ai/sessions/${sessionId}/messages`)
}
export function sendAiChatMessage(data) {
return request.post('/api/app/ai/chat', data, { timeout: 35000 })
}
+2
View File
@@ -7,6 +7,8 @@ export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
AiChatPanel: typeof import('./components/AiChatPanel.vue')['default']
AiChatSidebar: typeof import('./components/AiChatSidebar.vue')['default']
ElAside: typeof import('element-plus/es')['ElAside']
ElAvatar: typeof import('element-plus/es')['ElAvatar']
ElButton: typeof import('element-plus/es')['ElButton']
+201
View File
@@ -0,0 +1,201 @@
<template>
<section class="chat-panel">
<div class="chat-header">
<span class="chat-title">{{ currentTitle }}</span>
<span v-if="isLoggedIn && currentSessionId" class="round-badge">{{ roundCount }}/{{ maxRounds }}</span>
</div>
<div ref="messagesRef" class="messages">
<div v-if="messages.length === 0 && !sending" class="welcome">
<p>我是 Jog AI 助手</p>
<p class="welcome-sub">可以回答博客写作技术问题等输入问题后点击发送开始对话</p>
</div>
<div
v-for="msg in messages"
:key="msg.id ?? msg._localId"
class="message-row"
:class="msg.role"
>
<div class="bubble">{{ msg.content }}</div>
</div>
<div v-if="sending" class="message-row assistant">
<div class="bubble thinking">正在思考</div>
</div>
</div>
<div v-if="readOnly" class="readonly-banner">
当前会话已满 {{ maxRounds }} 请点击左侧新会话开始新的对话
</div>
<div class="input-area">
<el-input
v-model="inputText"
type="textarea"
:rows="2"
:autosize="{ minRows: 2, maxRows: 6 }"
placeholder="请输入您的问题…"
:disabled="inputDisabled"
@keydown="onKeydown"
/>
<el-button
type="primary"
:loading="sending"
:disabled="sendDisabled"
@click="onSend"
>
发送
</el-button>
</div>
</section>
</template>
<script setup>
import { ref, computed, watch, nextTick } from 'vue'
const props = defineProps({
currentTitle: { type: String, default: 'AI 助手' },
messages: { type: Array, default: () => [] },
roundCount: { type: Number, default: 0 },
maxRounds: { type: Number, default: 5 },
readOnly: { type: Boolean, default: false },
sending: { type: Boolean, default: false },
currentSessionId: { type: [Number, String], default: null },
isLoggedIn: { type: Boolean, default: false },
})
const emit = defineEmits(['send'])
const inputText = ref('')
const messagesRef = ref(null)
const inputDisabled = computed(() => {
if (!props.isLoggedIn) return true
if (props.readOnly) return true
if (props.sending) return true
return false
})
const sendDisabled = computed(() => {
if (inputDisabled.value) return true
if (props.roundCount >= props.maxRounds) return true
return !inputText.value.trim()
})
function onSend() {
const text = inputText.value.trim()
if (!text || sendDisabled.value) return
emit('send', text)
inputText.value = ''
}
function onKeydown(e) {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault()
onSend()
}
}
async function scrollToBottom() {
await nextTick()
const el = messagesRef.value
if (el) el.scrollTop = el.scrollHeight
}
watch(() => props.messages, () => scrollToBottom(), { deep: true })
watch(() => props.sending, () => { if (props.sending) scrollToBottom() })
</script>
<style scoped>
.chat-panel {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
max-width: 720px;
margin: 0 auto;
width: 100%;
}
.chat-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--spacing-md);
padding-bottom: var(--spacing-sm);
border-bottom: 1px solid var(--border-color-light);
}
.chat-title {
font-weight: var(--font-weight-bold);
font-size: var(--font-size-lg);
}
.round-badge {
font-size: var(--font-size-sm);
color: var(--text-secondary);
background: var(--color-primary-light);
padding: 2px 10px;
border-radius: var(--radius-full);
}
.readonly-banner {
background: #fff7e6;
border: 1px solid #ffd591;
color: #ad6800;
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius-md);
font-size: var(--font-size-sm);
margin-bottom: var(--spacing-md);
}
.messages {
flex: 1;
overflow-y: auto;
padding: var(--spacing-md) 0;
min-height: 280px;
}
.welcome {
text-align: center;
color: var(--text-secondary);
padding: var(--spacing-2xl) var(--spacing-md);
}
.welcome-sub {
font-size: var(--font-size-sm);
margin-top: var(--spacing-sm);
}
.message-row {
display: flex;
margin-bottom: var(--spacing-md);
}
.message-row.user {
justify-content: flex-end;
}
.message-row.assistant {
justify-content: flex-start;
}
.bubble {
max-width: 85%;
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius-lg);
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.message-row.user .bubble {
background: var(--color-primary);
color: #fff;
}
.message-row.assistant .bubble {
background: var(--border-color-light);
color: var(--text-regular);
}
.bubble.thinking {
color: var(--text-secondary);
font-style: italic;
}
.input-area {
display: flex;
gap: var(--spacing-sm);
align-items: flex-end;
padding-top: var(--spacing-md);
border-top: 1px solid var(--border-color-light);
}
.input-area .el-textarea {
flex: 1;
}
</style>
@@ -0,0 +1,117 @@
<template>
<aside class="sidebar">
<el-button type="primary" class="new-session-btn" @click="$emit('new-session')" :disabled="disabled">
新会话
</el-button>
<div v-if="!showContent" class="sidebar-hint">登录后查看历史对话</div>
<div v-else-if="sessions.length === 0" class="sidebar-hint">暂无对话点击新会话开始</div>
<ul v-else class="session-list">
<li
v-for="item in sessions"
:key="item.id"
class="session-item"
:class="{ active: item.id === currentSessionId }"
@click="$emit('select-session', item.id)"
>
<div class="session-title">{{ item.title || '新会话' }}</div>
<div class="session-meta">{{ formatTime(item.lastMessageAt) }}</div>
</li>
</ul>
<el-button
v-if="showContent && hasMore"
text
class="load-more"
:loading="loadingSessions"
@click="$emit('load-more')"
>
加载更多
</el-button>
</aside>
</template>
<script setup>
defineProps({
sessions: { type: Array, default: () => [] },
currentSessionId: { type: [Number, String], default: null },
loadingSessions: { type: Boolean, default: false },
hasMore: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
showContent: { type: Boolean, default: false },
});
defineEmits(['new-session', 'select-session', 'load-more']);
function formatTime(iso) {
if (!iso) return '';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
const pad = (n) => String(n).padStart(2, '0');
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
</script>
<style scoped>
.sidebar {
width: 260px;
flex-shrink: 0;
display: flex;
flex-direction: column;
border-right: 1px solid var(--border-color-light);
padding-right: var(--spacing-md);
}
.new-session-btn {
width: 100%;
margin-bottom: var(--spacing-md);
}
.sidebar-hint {
color: var(--text-secondary);
font-size: var(--font-size-sm);
padding: var(--spacing-sm);
}
.session-list {
list-style: none;
margin: 0;
padding: 0;
overflow-y: auto;
flex: 1;
}
.session-item {
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius-md);
cursor: pointer;
margin-bottom: var(--spacing-xs);
transition: background var(--transition-fast);
}
.session-item:hover {
background: var(--color-primary-light);
}
.session-item.active {
background: var(--color-primary-light);
border-left: 3px solid var(--color-primary);
}
.session-title {
font-size: var(--font-size-sm);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.session-meta {
font-size: 12px;
color: var(--text-secondary);
margin-top: 4px;
}
.load-more {
width: 100%;
margin-top: var(--spacing-sm);
}
@media (max-width: 768px) {
.sidebar {
width: 100%;
border-right: none;
border-bottom: 1px solid var(--border-color-light);
padding-right: 0;
padding-bottom: var(--spacing-md);
max-height: 200px;
}
}
</style>
+3
View File
@@ -7,6 +7,9 @@
<el-menu-item index="home">
<router-link to="/">首页</router-link>
</el-menu-item>
<el-menu-item index="ai-assistant">
<router-link to="/ai-assistant">AI 助手</router-link>
</el-menu-item>
</el-menu>
<div class="header-right">
<template v-if="userStore.isLoggedIn">
+6
View File
@@ -29,6 +29,12 @@ const routes = [
component: () => import('@/views/ArticleDetail.vue'),
meta: { title: '文章详情' },
},
{
path: 'ai-assistant',
name: 'AiAssistant',
component: () => import('@/views/AiAssistant.vue'),
meta: { title: 'AI 助手' },
},
],
},
{
+222
View File
@@ -0,0 +1,222 @@
<template>
<div class="ai-assistant">
<AiChatSidebar
:sessions="sessions"
:current-session-id="currentSessionId"
:loading-sessions="loadingSessions"
:has-more="hasMore"
:disabled="!userStore.isLoggedIn"
:show-content="userStore.isLoggedIn"
@new-session="handleNewSession"
@select-session="selectSessionById"
@load-more="loadMoreSessions"
/>
<AiChatPanel
:current-title="currentTitle"
:messages="messages"
:round-count="roundCount"
:max-rounds="maxRounds"
:read-only="readOnly"
:sending="sending"
:current-session-id="currentSessionId"
:is-logged-in="userStore.isLoggedIn"
@send="handleSend"
/>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/stores/user'
import {
createAiChatSession,
listAiChatSessions,
listAiChatMessages,
sendAiChatMessage,
} from '@/api/aiChat'
import AiChatSidebar from '@/components/AiChatSidebar.vue'
import AiChatPanel from '@/components/AiChatPanel.vue'
const userStore = useUserStore()
const router = useRouter()
const maxRounds = 5
const sessions = ref([])
const currentSessionId = ref(null)
const messages = ref([])
const roundCount = ref(0)
const sending = ref(false)
const readOnly = ref(false)
const loadingSessions = ref(false)
const sessionPage = ref(1)
const sessionTotal = ref(0)
const pageSize = 50
let localIdSeq = 0
const currentTitle = computed(() => {
if (!currentSessionId.value) return 'AI 助手'
const s = sessions.value.find((x) => x.id === currentSessionId.value)
return s?.title || '新会话'
})
const hasMore = computed(() => sessions.value.length < sessionTotal.value)
onMounted(async () => {
if (!userStore.isLoggedIn) return
await loadSessions(1, false)
if (sessions.value.length > 0) {
await selectSession(sessions.value[0])
}
})
async function loadSessions(page, append) {
loadingSessions.value = true
try {
const res = await listAiChatSessions({ page, size: pageSize })
const data = res.data
sessionTotal.value = data.total
if (append) {
sessions.value = [...sessions.value, ...data.records]
} else {
sessions.value = data.records || []
}
sessionPage.value = page
} catch {
/* 错误已由拦截器提示 */
} finally {
loadingSessions.value = false
}
}
function loadMoreSessions() {
loadSessions(sessionPage.value + 1, true)
}
function selectSessionById(id) {
const item = sessions.value.find((s) => s.id === id)
if (item) selectSession(item)
}
async function handleNewSession() {
if (!userStore.isLoggedIn) {
ElMessage.warning('请先登录后使用 AI 助手')
router.push({ name: 'Login', query: { redirect: '/ai-assistant' } })
return
}
try {
const res = await createAiChatSession()
const sessionId = res.data.sessionId
const item = {
id: sessionId,
title: '新会话',
roundCount: 0,
lastMessageAt: null,
}
sessions.value = [item, ...sessions.value.filter((s) => s.id !== sessionId)]
currentSessionId.value = sessionId
messages.value = []
roundCount.value = 0
readOnly.value = false
} catch {
/* handled */
}
}
async function selectSession(item) {
if (!userStore.isLoggedIn) return
currentSessionId.value = item.id
roundCount.value = item.roundCount ?? 0
readOnly.value = roundCount.value >= maxRounds
try {
const res = await listAiChatMessages(item.id)
messages.value = res.data || []
} catch {
messages.value = []
}
}
async function handleSend(text) {
if (!userStore.isLoggedIn) {
ElMessage.warning('请先登录后使用 AI 助手')
router.push({ name: 'Login', query: { redirect: '/ai-assistant' } })
return
}
if (roundCount.value >= maxRounds) {
ElMessage.warning('当前会话已满 5 轮,请点击左侧「新会话」开始新的对话')
return
}
if (text.length > 2000) {
ElMessage.warning('问题长度不能超过 2000 字符')
return
}
if (!currentSessionId.value) {
await handleNewSession()
if (!currentSessionId.value) return
}
const last = messages.value[messages.value.length - 1]
if (!last || last.role !== 'user' || last.content !== text) {
messages.value.push({
_localId: `local-${++localIdSeq}`,
role: 'user',
content: text,
})
}
sending.value = true
try {
const res = await sendAiChatMessage({
sessionId: currentSessionId.value,
message: text,
})
const data = res.data
messages.value = messages.value.filter((m) => !m._localId)
const reload = await listAiChatMessages(currentSessionId.value)
messages.value = reload.data || []
roundCount.value = data.roundCount
readOnly.value = roundCount.value >= maxRounds
const idx = sessions.value.findIndex((s) => s.id === currentSessionId.value)
if (idx >= 0) {
sessions.value[idx] = {
...sessions.value[idx],
roundCount: data.roundCount,
title: sessions.value[idx].title === '新会话' && messages.value.length > 0
? truncateTitle(messages.value.find((m) => m.role === 'user')?.content)
: sessions.value[idx].title,
lastMessageAt: new Date().toISOString(),
}
const updated = sessions.value.splice(idx, 1)[0]
sessions.value.unshift(updated)
}
} catch {
/* 保留 user 气泡,允许重试 */
} finally {
sending.value = false
}
}
function truncateTitle(text) {
if (!text) return '新会话'
const t = text.trim()
return t.length <= 30 ? t : `${t.slice(0, 30)}`
}
</script>
<style scoped>
.ai-assistant {
display: flex;
gap: var(--spacing-lg);
min-height: calc(100vh - 180px);
margin: calc(-1 * var(--spacing-xl));
padding: var(--spacing-lg);
}
@media (max-width: 768px) {
.ai-assistant {
flex-direction: column;
}
}
</style>