修改前端

This commit is contained in:
龙澳
2026-03-23 15:24:25 +08:00
parent 92bc02142d
commit a0c56dc32c
4 changed files with 351 additions and 88 deletions

View File

@@ -1,5 +1,7 @@
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import baiduMark from './assets/baidu-cloud-mark.svg'
import baiduChatMark from './assets/baidu2.png'
const apiBase = ref(import.meta.env.VITE_API_BASE || 'http://127.0.0.1:9000')
const authBase = ref(import.meta.env.VITE_AUTH_BASE || apiBase.value)
@@ -18,10 +20,13 @@ const registerPending = ref(false)
const registerRequestId = ref(0)
const registerStatusMessage = ref('')
const codeCountdown = ref(0)
const hasSentVerificationCode = ref(false)
const text = ref('')
const messages = ref([])
const sending = ref(false)
const assistantThinking = ref(false)
const assistantBaselineCount = ref(0)
const streamState = ref('disconnected')
const messagesEl = ref(null)
@@ -60,6 +65,19 @@ function pushMessage(role, content, kind = 'message') {
})
}
function countAssistantMessages(list) {
return list.filter((m) => m.role === 'assistant' && m.kind !== 'error').length
}
function startAssistantThinking() {
assistantBaselineCount.value = countAssistantMessages(messages.value)
assistantThinking.value = true
}
function stopAssistantThinking() {
assistantThinking.value = false
}
function scrollToBottom(smooth = true) {
nextTick(() => {
if (!messagesEl.value) return
@@ -113,6 +131,23 @@ function clearSession() {
registerStatusMessage.value = ''
codeCountdown.value = 0
streamState.value = 'disconnected'
assistantThinking.value = false
assistantBaselineCount.value = 0
}
function switchAuthMode(mode) {
authMode.value = mode
authError.value = ''
registerStatusMessage.value = ''
registerPending.value = false
if (mode === 'login') {
hasSentVerificationCode.value = false
codeCountdown.value = 0
if (codeCountdownTimer) {
clearInterval(codeCountdownTimer)
codeCountdownTimer = null
}
}
}
function startCodeCountdown() {
@@ -120,7 +155,7 @@ function startCodeCountdown() {
clearInterval(codeCountdownTimer)
codeCountdownTimer = null
}
codeCountdown.value = 120
codeCountdown.value = 180
codeCountdownTimer = setInterval(() => {
if (codeCountdown.value <= 1) {
codeCountdown.value = 0
@@ -134,6 +169,45 @@ function startCodeCountdown() {
}, 1000)
}
async function sendVerificationCode() {
const phone = loginPhone.value.trim()
const password = loginPassword.value
if (!/^\d{6,20}$/.test(phone)) {
authError.value = '请先输入有效手机号'
return
}
if (!password) {
authError.value = '请先输入密码'
return
}
if (!localAuthOnly.value) {
try {
const resp = await fetch(`${authBase.value}/auth/send-code`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phone, password }),
})
const payload = await resp.json().catch(() => ({}))
if (!resp.ok || !payload.ok) {
authError.value = payload.detail || payload.error || '验证码发送失败'
return
}
registerStatusMessage.value = payload.message || '验证码已发送,请在 180 秒内完成注册'
} catch (err) {
authError.value = String(err)
return
}
}
authError.value = ''
hasSentVerificationCode.value = true
if (localAuthOnly.value) {
registerStatusMessage.value = '验证码已发送,请在 180 秒内完成注册'
}
startCodeCountdown()
}
function startRegisterStatusPolling() {
if (registerPollTimer) {
clearInterval(registerPollTimer)
@@ -258,6 +332,11 @@ async function register() {
authLoading.value = true
authError.value = ''
registerStatusMessage.value = ''
if (!hasSentVerificationCode.value) {
authError.value = '请先点击“发送”获取验证码'
authLoading.value = false
return
}
try {
const resp = await fetch(`${authBase.value}/auth/register`, {
method: 'POST',
@@ -277,7 +356,7 @@ async function register() {
registerPending.value = true
registerRequestId.value = Number(payload.request_id || 0)
registerStatusMessage.value = '待后端验证'
startCodeCountdown()
hasSentVerificationCode.value = false
startRegisterStatusPolling()
} catch (err) {
authError.value = String(err)
@@ -361,6 +440,9 @@ async function loadHistory() {
seenMessages.add(messageSignature(m.role, m.content, m.kind, m.at))
}
messages.value = nextMessages
if (assistantThinking.value && countAssistantMessages(nextMessages) > assistantBaselineCount.value) {
stopAssistantThinking()
}
scrollToBottom(false)
}
} catch (_) {
@@ -401,6 +483,9 @@ function connectStream() {
kind,
at,
})
if (role === 'assistant') {
stopAssistantThinking()
}
} catch (_) {
// Ignore malformed payload and keep the stream alive.
}
@@ -431,13 +516,16 @@ async function sendMessage() {
if (!resp.ok) {
const detail = await resp.text()
stopAssistantThinking()
pushMessage('system', `Send failed: ${detail || resp.statusText}`, 'error')
} else {
startAssistantThinking()
setTimeout(() => {
loadHistory()
}, 800)
}
} catch (err) {
stopAssistantThinking()
pushMessage('system', `Send failed: ${String(err)}`, 'error')
} finally {
text.value = ''
@@ -458,6 +546,15 @@ watch(
},
)
watch(
() => assistantThinking.value,
(thinking) => {
if (thinking) {
scrollToBottom(true)
}
},
)
onBeforeUnmount(() => {
if (eventSource) eventSource.close()
if (historyPollTimer) clearInterval(historyPollTimer)
@@ -470,112 +567,133 @@ onBeforeUnmount(() => {
<main class="screen">
<section v-if="!isLoggedIn" class="auth-shell">
<header class="top-bar">
<div>
<h1>nanobot login</h1>
<p>Use phone + password to access your chat workspace</p>
<div class="auth-headline">
<img class="auth-logo" :src="baiduMark" alt="Baidu AI+" />
<div>
<h1>欢迎使用百度 AI+ 教育智能助手</h1>
<p>请输入手机号和密码登录首次注册需输入验证码并验证</p>
</div>
</div>
</header>
<div class="auth-form">
<label>
phone
<input v-model="loginPhone" placeholder="e.g. 13800000000" />
</label>
<label>
password
<input v-model="loginPassword" type="password" placeholder="at least 6 chars" />
</label>
<label v-if="!localAuthOnly">
verification code
<input v-model="verificationCode" placeholder="请输入验证码" />
</label>
<template v-if="authMode === 'login'">
<label>
手机号
<input v-model="loginPhone" placeholder="请输入手机号,例如 13800000000" />
</label>
<label>
密码
<input v-model="loginPassword" type="password" placeholder="请输入登录密码" />
</label>
</template>
<template v-else>
<label>
手机号
<input v-model="loginPhone" placeholder="请输入手机号,例如 13800000000" />
</label>
<label>
密码
<input v-model="loginPassword" type="password" placeholder="请设置登录密码" />
</label>
<label v-if="!localAuthOnly">
验证码
<div class="inline-field">
<input v-model="verificationCode" placeholder="请输入验证码" />
<button
type="button"
class="send-code-btn"
:disabled="codeCountdown > 0 || authLoading"
@click="sendVerificationCode"
>
{{ codeCountdown > 0 ? `${codeCountdown}s` : '发送' }}
</button>
</div>
</label>
</template>
<div class="ai-tags">
<span class="ai-tag">百度 AI+</span>
<span class="ai-tag">智能问答</span>
<span class="ai-tag">实时会话</span>
</div>
<div class="auth-actions">
<button :disabled="authLoading" @click="login">Login</button>
<button class="reconnect" :disabled="authLoading" @click="register">Register</button>
<button v-if="authMode === 'login'" :disabled="authLoading" @click="login">登录</button>
<button v-if="authMode === 'register'" :disabled="authLoading || registerPending" @click="register">提交注册</button>
<button
v-if="authMode === 'login'"
type="button"
class="reconnect"
:disabled="authLoading"
@click="switchAuthMode('register')"
>
注册账号
</button>
<button
v-if="authMode === 'register'"
type="button"
class="reconnect"
:disabled="authLoading"
@click="switchAuthMode('login')"
>
返回登录
</button>
</div>
<p class="auth-error" v-if="authError">{{ authError }}</p>
<p class="auth-error" v-if="registerPending || registerStatusMessage">
{{ registerStatusMessage || '后端验证' }}
</p>
<p class="auth-error" v-if="registerPending && codeCountdown > 0">
验证码倒计时{{ codeCountdown }}s
{{ registerStatusMessage || '请等待验证' }}
</p>
</div>
</section>
<section v-else class="chat-shell">
<div class="workspace">
<aside class="workspace-sidebar">
<div class="sidebar-brand">
<h2>nanobot</h2>
<p>AI workspace</p>
</div>
<div class="sidebar-card">
<span class="card-label">Account</span>
<strong>{{ currentUser?.phone }}</strong>
<span class="badge" :class="streamState">{{ streamState }}</span>
<div class="sidebar-actions">
<button class="reconnect" @click="connectStream">Reconnect</button>
<button class="reconnect" @click="clearSession">Logout</button>
<section class="workspace-main">
<header class="main-head">
<div class="main-head-brand">
<img class="chat-logo" :src="baiduChatMark" alt="Baidu" />
<div>
<h1>百度 AI+ 教育智能助手</h1>
<p>欢迎与我实时交流</p>
</div>
</div>
<div class="config-panel">
<label>
API
<input v-model="apiBase" placeholder="http://127.0.0.1:9000" />
</label>
<label v-if="!localAuthOnly">
AUTH API
<input v-model="authBase" placeholder="http://127.0.0.1:9100" />
</label>
<label>
sender
<input v-model="senderId" placeholder="web-user" />
</label>
<label>
chat
<input v-model="chatId" placeholder="web-user" />
</label>
<label v-if="!localAuthOnly">
token
<input v-model="apiToken" placeholder="Bearer token" readonly />
</label>
<div class="main-head-actions">
<span class="badge" :class="streamState">{{ streamState }}</span>
<button class="reconnect" @click="connectStream">重连</button>
<button class="reconnect" @click="clearSession">退出</button>
</div>
</aside>
</header>
<section class="workspace-main">
<header class="main-head">
<h1>智能对话</h1>
<p> nanobot 的实时会话</p>
</header>
<div class="messages" ref="messagesEl">
<article
v-for="msg in messages"
:key="msg.id"
class="bubble"
:class="[msg.role, msg.kind]"
>
<p>{{ msg.content }}</p>
<small>{{ msg.at }}</small>
</article>
<article v-if="assistantThinking" class="bubble assistant thinking">
<p>
<span class="thinking-spinner" aria-hidden="true"></span>
智能助手正在思考...
</p>
</article>
</div>
<div class="messages" ref="messagesEl">
<article
v-for="msg in messages"
:key="msg.id"
class="bubble"
:class="[msg.role, msg.kind]"
>
<p>{{ msg.content }}</p>
<small>{{ msg.role }} · {{ msg.at }}</small>
</article>
</div>
<footer class="composer">
<textarea
v-model="text"
rows="2"
placeholder="给 nanobot 发送消息"
@keydown.enter.exact.prevent="sendMessage"
/>
<button :disabled="!canSend" @click="sendMessage">发送</button>
</footer>
</section>
</div>
<footer class="composer">
<textarea
v-model="text"
rows="2"
placeholder="给 nanobot 发送消息"
@keydown.enter.exact.prevent="sendMessage"
/>
<button :disabled="!canSend" @click="sendMessage">发送</button>
</footer>
</section>
</section>
</main>
</template>

View File

@@ -0,0 +1,21 @@
<svg width="240" height="180" viewBox="0 0 240 180" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Baidu Cloud style mark">
<defs>
<linearGradient id="b1" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#31C8E8"/>
<stop offset="100%" stop-color="#1B96DE"/>
</linearGradient>
<linearGradient id="b2" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#FF6B8A"/>
<stop offset="100%" stop-color="#F04A79"/>
</linearGradient>
</defs>
<circle cx="120" cy="60" r="36" fill="none" stroke="url(#b1)" stroke-width="16"/>
<circle cx="73" cy="92" r="30" fill="none" stroke="url(#b1)" stroke-width="16"/>
<path d="M 130 96 C 165 96 175 135 151 148" fill="none" stroke="url(#b1)" stroke-width="16" stroke-linecap="round"/>
<path d="M 93 80 C 98 90 110 95 120 95 C 130 95 142 90 147 80" fill="none" stroke="url(#b2)" stroke-width="14" stroke-linecap="round"/>
<text x="120" y="168" text-anchor="middle" font-size="26" font-family="'Noto Sans SC', sans-serif" fill="#2C67D9" font-weight="700">
百度 AI+
</text>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
src/assets/baidu2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

View File

@@ -113,14 +113,38 @@ body {
display: grid;
grid-template-rows: auto 1fr auto;
background: rgba(255, 255, 255, 0.58);
min-height: calc(100vh - 2.4rem);
}
.main-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.9rem;
padding: 1rem 1.2rem 0.7rem;
border-bottom: 1px solid var(--line);
background: rgba(255, 255, 255, 0.72);
}
.main-head-brand {
display: flex;
align-items: center;
gap: 0.72rem;
}
.chat-logo {
width: 168px;
height: 44px;
object-fit: contain;
flex-shrink: 0;
}
.main-head-actions {
display: flex;
align-items: center;
gap: 0.45rem;
}
.main-head h1 {
margin: 0;
font-size: 1.08rem;
@@ -148,6 +172,69 @@ body {
gap: 0.7rem;
}
.auth-headline {
display: flex;
gap: 0.85rem;
align-items: center;
}
.auth-logo {
width: 82px;
height: 64px;
object-fit: contain;
flex-shrink: 0;
}
.auth-headline h1 {
margin: 0;
font-size: 1.06rem;
line-height: 1.35;
}
.auth-headline p {
margin: 0.22rem 0 0;
color: var(--ink-soft);
font-size: 0.85rem;
line-height: 1.4;
}
.ai-tags {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.ai-tag {
border: 1px solid #c8d9fb;
background: #f1f6ff;
color: #2f67dc;
padding: 0.22rem 0.52rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
}
.inline-field {
display: grid;
gap: 0.5rem;
grid-template-columns: 1fr auto;
align-items: center;
}
.send-code-btn {
min-width: 84px;
background: #f3f7ff;
color: var(--brand);
border: 1px solid #c7d8fb;
font-weight: 600;
}
.send-code-btn:disabled {
background: #eef1f7;
color: #93a2bb;
border-color: #d6deea;
}
.auth-actions {
display: flex;
gap: 0.55rem;
@@ -280,6 +367,32 @@ textarea:focus {
background: #fff3f3;
}
.bubble.thinking {
display: inline-flex;
align-items: center;
gap: 0.5rem;
background: #f7faff;
border-color: #c8d8f6;
}
.thinking-spinner {
width: 0.95rem;
height: 0.95rem;
border-radius: 999px;
border: 2px solid #bed0f3;
border-top-color: #2b63d9;
display: inline-block;
margin-right: 0.45rem;
animation: spin 0.8s linear infinite;
vertical-align: -0.1rem;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.bubble p {
margin: 0;
white-space: pre-wrap;
@@ -349,6 +462,17 @@ button:disabled {
grid-template-columns: 1fr;
}
.main-head {
flex-direction: column;
align-items: flex-start;
}
.main-head-actions {
width: 100%;
justify-content: flex-start;
flex-wrap: wrap;
}
.bubble {
max-width: 100%;
}