修改前端
This commit is contained in:
294
src/App.vue
294
src/App.vue
@@ -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>
|
||||
|
||||
21
src/assets/baidu-cloud-mark.svg
Normal file
21
src/assets/baidu-cloud-mark.svg
Normal 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
BIN
src/assets/baidu2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 202 KiB |
124
src/style.css
124
src/style.css
@@ -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%;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user