first commit
This commit is contained in:
6
.env.example
Normal file
6
.env.example
Normal file
@@ -0,0 +1,6 @@
|
||||
VITE_API_BASE=http://127.0.0.1:9000
|
||||
VITE_AUTH_BASE=http://127.0.0.1:9100
|
||||
VITE_LOCAL_AUTH_ONLY=false
|
||||
VITE_SENDER_ID=web-user
|
||||
VITE_CHAT_ID=web-user
|
||||
VITE_API_TOKEN=
|
||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
37
README.md
Normal file
37
README.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# web-chat-frontend
|
||||
|
||||
A minimal Vue chat client for nanobot WebChannel.
|
||||
|
||||
Now supports phone + password login before entering chat.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
cd web-chat-frontend
|
||||
npm install
|
||||
cp .env.example .env
|
||||
npm run dev
|
||||
```
|
||||
|
||||
By default it connects to `http://127.0.0.1:9000`.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
- `VITE_API_BASE`: WebChannel base URL
|
||||
- `VITE_AUTH_BASE`: Standalone auth service base URL
|
||||
- `VITE_SENDER_ID`: sender id passed to WebChannel `/message`
|
||||
- `VITE_CHAT_ID`: chat id used for SSE stream and routing
|
||||
- `VITE_API_TOKEN`: fixed bearer token for WebChannel auth
|
||||
|
||||
## Endpoints Expected From WebChannel
|
||||
|
||||
- `POST /message`
|
||||
- `GET /events/{chat_id}` (SSE)
|
||||
- `GET /history/{chat_id}`
|
||||
- `GET /health`
|
||||
|
||||
## Endpoints Expected From Auth Service
|
||||
|
||||
- `POST /auth/register`
|
||||
- `POST /auth/login`
|
||||
- `GET /auth/me`
|
||||
12
index.html
Normal file
12
index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>nanobot web chat</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
1324
package-lock.json
generated
Normal file
1324
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
package.json
Normal file
18
package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "web-chat-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.5.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.3",
|
||||
"vite": "^6.3.0"
|
||||
}
|
||||
}
|
||||
581
src/App.vue
Normal file
581
src/App.vue
Normal file
@@ -0,0 +1,581 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
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)
|
||||
const localAuthOnly = ref(String(import.meta.env.VITE_LOCAL_AUTH_ONLY || 'false').toLowerCase() === 'true')
|
||||
const senderId = ref(import.meta.env.VITE_SENDER_ID || 'web-user')
|
||||
const chatId = ref(import.meta.env.VITE_CHAT_ID || 'web-user')
|
||||
const apiToken = ref(import.meta.env.VITE_API_TOKEN || '')
|
||||
const loginPhone = ref('')
|
||||
const loginPassword = ref('')
|
||||
const verificationCode = ref('')
|
||||
const authLoading = ref(false)
|
||||
const authError = ref('')
|
||||
const authMode = ref('login')
|
||||
const currentUser = ref(null)
|
||||
const registerPending = ref(false)
|
||||
const registerRequestId = ref(0)
|
||||
const registerStatusMessage = ref('')
|
||||
const codeCountdown = ref(0)
|
||||
|
||||
const text = ref('')
|
||||
const messages = ref([])
|
||||
const sending = ref(false)
|
||||
const streamState = ref('disconnected')
|
||||
const messagesEl = ref(null)
|
||||
|
||||
let eventSource = null
|
||||
let historyPollTimer = null
|
||||
let registerPollTimer = null
|
||||
let codeCountdownTimer = null
|
||||
const seenMessages = new Set()
|
||||
|
||||
const canSend = computed(() => text.value.trim().length > 0 && !sending.value)
|
||||
const isLoggedIn = computed(() => !!currentUser.value && (localAuthOnly.value || !!apiToken.value))
|
||||
|
||||
function messageId() {
|
||||
if (globalThis.crypto && typeof globalThis.crypto.randomUUID === 'function') {
|
||||
return globalThis.crypto.randomUUID()
|
||||
}
|
||||
return `m-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
||||
}
|
||||
|
||||
function messageSignature(role, content, kind, at) {
|
||||
return `${role}|${kind}|${at}|${content}`
|
||||
}
|
||||
|
||||
function pushMessage(role, content, kind = 'message') {
|
||||
const at = new Date().toLocaleTimeString()
|
||||
const sig = messageSignature(role, content, kind, at)
|
||||
if (seenMessages.has(sig)) return
|
||||
seenMessages.add(sig)
|
||||
|
||||
messages.value.push({
|
||||
id: messageId(),
|
||||
role,
|
||||
content,
|
||||
kind,
|
||||
at,
|
||||
})
|
||||
}
|
||||
|
||||
function scrollToBottom(smooth = true) {
|
||||
nextTick(() => {
|
||||
if (!messagesEl.value) return
|
||||
messagesEl.value.scrollTo({
|
||||
top: messagesEl.value.scrollHeight,
|
||||
behavior: smooth ? 'smooth' : 'auto',
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function authHeaders() {
|
||||
if (localAuthOnly.value) return {}
|
||||
const token = apiToken.value.trim()
|
||||
return token ? { Authorization: `Bearer ${token}` } : {}
|
||||
}
|
||||
|
||||
function saveSession(token, user) {
|
||||
apiToken.value = token
|
||||
currentUser.value = user
|
||||
senderId.value = user.phone
|
||||
chatId.value = user.phone
|
||||
localStorage.setItem('nb_web_token', token)
|
||||
localStorage.setItem('nb_web_user', JSON.stringify(user))
|
||||
}
|
||||
|
||||
function clearSession() {
|
||||
apiToken.value = ''
|
||||
currentUser.value = null
|
||||
localStorage.removeItem('nb_web_token')
|
||||
localStorage.removeItem('nb_web_user')
|
||||
messages.value = []
|
||||
seenMessages.clear()
|
||||
if (eventSource) {
|
||||
eventSource.close()
|
||||
eventSource = null
|
||||
}
|
||||
if (historyPollTimer) {
|
||||
clearInterval(historyPollTimer)
|
||||
historyPollTimer = null
|
||||
}
|
||||
if (registerPollTimer) {
|
||||
clearInterval(registerPollTimer)
|
||||
registerPollTimer = null
|
||||
}
|
||||
if (codeCountdownTimer) {
|
||||
clearInterval(codeCountdownTimer)
|
||||
codeCountdownTimer = null
|
||||
}
|
||||
registerPending.value = false
|
||||
registerRequestId.value = 0
|
||||
registerStatusMessage.value = ''
|
||||
codeCountdown.value = 0
|
||||
streamState.value = 'disconnected'
|
||||
}
|
||||
|
||||
function startCodeCountdown() {
|
||||
if (codeCountdownTimer) {
|
||||
clearInterval(codeCountdownTimer)
|
||||
codeCountdownTimer = null
|
||||
}
|
||||
codeCountdown.value = 120
|
||||
codeCountdownTimer = setInterval(() => {
|
||||
if (codeCountdown.value <= 1) {
|
||||
codeCountdown.value = 0
|
||||
if (codeCountdownTimer) {
|
||||
clearInterval(codeCountdownTimer)
|
||||
codeCountdownTimer = null
|
||||
}
|
||||
return
|
||||
}
|
||||
codeCountdown.value -= 1
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
function startRegisterStatusPolling() {
|
||||
if (registerPollTimer) {
|
||||
clearInterval(registerPollTimer)
|
||||
registerPollTimer = null
|
||||
}
|
||||
if (!registerRequestId.value) return
|
||||
|
||||
registerPollTimer = setInterval(async () => {
|
||||
try {
|
||||
const resp = await fetch(`${authBase.value}/auth/register/status/${registerRequestId.value}`)
|
||||
const payload = await resp.json().catch(() => ({}))
|
||||
if (!resp.ok || !payload.ok) return
|
||||
|
||||
const status = String(payload.status || '')
|
||||
if (status === 'pending') {
|
||||
registerStatusMessage.value = '待后端验证'
|
||||
return
|
||||
}
|
||||
|
||||
if (status === 'approved') {
|
||||
if (registerPollTimer) {
|
||||
clearInterval(registerPollTimer)
|
||||
registerPollTimer = null
|
||||
}
|
||||
registerPending.value = false
|
||||
registerStatusMessage.value = '注册成功,正在登录...'
|
||||
codeCountdown.value = 0
|
||||
if (codeCountdownTimer) {
|
||||
clearInterval(codeCountdownTimer)
|
||||
codeCountdownTimer = null
|
||||
}
|
||||
await login()
|
||||
return
|
||||
}
|
||||
|
||||
if (status === 'rejected') {
|
||||
if (registerPollTimer) {
|
||||
clearInterval(registerPollTimer)
|
||||
registerPollTimer = null
|
||||
}
|
||||
registerPending.value = false
|
||||
registerStatusMessage.value = payload.review_note || '注册被拒绝'
|
||||
}
|
||||
} catch (_) {
|
||||
// Keep polling for transient network errors.
|
||||
}
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
function startHistoryPolling() {
|
||||
if (historyPollTimer) return
|
||||
historyPollTimer = setInterval(() => {
|
||||
if (isLoggedIn.value && streamState.value !== 'connected') {
|
||||
loadHistory()
|
||||
}
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
async function callAuth(endpoint) {
|
||||
if (localAuthOnly.value) {
|
||||
authError.value = ''
|
||||
const phone = loginPhone.value.trim()
|
||||
const password = loginPassword.value
|
||||
if (!/^\d{6,20}$/.test(phone)) {
|
||||
authError.value = 'phone must be 6-20 digits'
|
||||
return false
|
||||
}
|
||||
if (password.length < 1) {
|
||||
authError.value = 'password is required'
|
||||
return false
|
||||
}
|
||||
|
||||
saveSession('', { id: 1, phone })
|
||||
loginPassword.value = ''
|
||||
await loadHistory()
|
||||
connectStream()
|
||||
startHistoryPolling()
|
||||
return true
|
||||
}
|
||||
|
||||
authLoading.value = true
|
||||
authError.value = ''
|
||||
try {
|
||||
const resp = await fetch(`${authBase.value}${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
phone: loginPhone.value.trim(),
|
||||
password: loginPassword.value,
|
||||
}),
|
||||
})
|
||||
const payload = await resp.json().catch(() => ({}))
|
||||
if (!resp.ok || !payload.ok) {
|
||||
authError.value = payload.error || 'auth failed'
|
||||
return false
|
||||
}
|
||||
|
||||
saveSession(payload.access_token, payload.user)
|
||||
loginPassword.value = ''
|
||||
await loadHistory()
|
||||
connectStream()
|
||||
startHistoryPolling()
|
||||
return true
|
||||
} catch (err) {
|
||||
authError.value = String(err)
|
||||
return false
|
||||
} finally {
|
||||
authLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function login() {
|
||||
await callAuth('/auth/login')
|
||||
}
|
||||
|
||||
async function register() {
|
||||
if (localAuthOnly.value) {
|
||||
await callAuth('/auth/register')
|
||||
return
|
||||
}
|
||||
|
||||
authLoading.value = true
|
||||
authError.value = ''
|
||||
registerStatusMessage.value = ''
|
||||
try {
|
||||
const resp = await fetch(`${authBase.value}/auth/register`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
phone: loginPhone.value.trim(),
|
||||
password: loginPassword.value,
|
||||
verification_code: verificationCode.value.trim(),
|
||||
}),
|
||||
})
|
||||
const payload = await resp.json().catch(() => ({}))
|
||||
if (!resp.ok || !payload.ok) {
|
||||
authError.value = payload.detail || payload.error || 'register failed'
|
||||
return
|
||||
}
|
||||
|
||||
registerPending.value = true
|
||||
registerRequestId.value = Number(payload.request_id || 0)
|
||||
registerStatusMessage.value = '待后端验证'
|
||||
startCodeCountdown()
|
||||
startRegisterStatusPolling()
|
||||
} catch (err) {
|
||||
authError.value = String(err)
|
||||
} finally {
|
||||
authLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreSession() {
|
||||
const token = localStorage.getItem('nb_web_token') || apiToken.value
|
||||
const userRaw = localStorage.getItem('nb_web_user')
|
||||
if (!token && !localAuthOnly.value) return
|
||||
|
||||
apiToken.value = token || ''
|
||||
if (userRaw) {
|
||||
try {
|
||||
currentUser.value = JSON.parse(userRaw)
|
||||
senderId.value = currentUser.value.phone
|
||||
chatId.value = currentUser.value.phone
|
||||
} catch (_) {
|
||||
localStorage.removeItem('nb_web_user')
|
||||
}
|
||||
}
|
||||
|
||||
if (localAuthOnly.value) {
|
||||
if (!currentUser.value) return
|
||||
await loadHistory()
|
||||
connectStream()
|
||||
startHistoryPolling()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch(`${authBase.value}/auth/me`, {
|
||||
headers: {
|
||||
...authHeaders(),
|
||||
},
|
||||
})
|
||||
const payload = await resp.json().catch(() => ({}))
|
||||
if (!resp.ok || !payload.ok || !payload.user) {
|
||||
clearSession()
|
||||
return
|
||||
}
|
||||
saveSession(token, payload.user)
|
||||
await loadHistory()
|
||||
connectStream()
|
||||
startHistoryPolling()
|
||||
} catch (_) {
|
||||
clearSession()
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHistory() {
|
||||
try {
|
||||
const resp = await fetch(`${apiBase.value}/history/${encodeURIComponent(chatId.value)}`, {
|
||||
headers: {
|
||||
...authHeaders(),
|
||||
},
|
||||
})
|
||||
if (!resp.ok) return
|
||||
const payload = await resp.json()
|
||||
const history = Array.isArray(payload.messages) ? payload.messages : []
|
||||
const nextMessages = history.map((m) => ({
|
||||
id: messageId(),
|
||||
role: m.role || 'assistant',
|
||||
content: m.content || '',
|
||||
kind: m.type || 'message',
|
||||
at: m.at || '-',
|
||||
}))
|
||||
|
||||
const currentFingerprint = messages.value
|
||||
.map((m) => `${m.role}|${m.kind}|${m.at}|${m.content}`)
|
||||
.join('\n')
|
||||
const nextFingerprint = nextMessages
|
||||
.map((m) => `${m.role}|${m.kind}|${m.at}|${m.content}`)
|
||||
.join('\n')
|
||||
|
||||
if (currentFingerprint !== nextFingerprint) {
|
||||
seenMessages.clear()
|
||||
for (const m of nextMessages) {
|
||||
seenMessages.add(messageSignature(m.role, m.content, m.kind, m.at))
|
||||
}
|
||||
messages.value = nextMessages
|
||||
scrollToBottom(false)
|
||||
}
|
||||
} catch (_) {
|
||||
// Keep UI responsive even if history endpoint is unreachable.
|
||||
}
|
||||
}
|
||||
|
||||
function connectStream() {
|
||||
if (eventSource) {
|
||||
eventSource.close()
|
||||
eventSource = null
|
||||
}
|
||||
|
||||
streamState.value = 'connecting'
|
||||
const token = encodeURIComponent(apiToken.value.trim())
|
||||
const suffix = token ? `?token=${token}` : ''
|
||||
const url = `${apiBase.value}/events/${encodeURIComponent(chatId.value)}${suffix}`
|
||||
eventSource = new EventSource(url)
|
||||
|
||||
eventSource.onopen = () => {
|
||||
streamState.value = 'connected'
|
||||
}
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data)
|
||||
if (!data || typeof data.content !== 'string') return
|
||||
const role = data.role || 'assistant'
|
||||
const kind = data.type || 'message'
|
||||
const at = data.at || new Date().toLocaleTimeString()
|
||||
const sig = messageSignature(role, data.content, kind, at)
|
||||
if (seenMessages.has(sig)) return
|
||||
seenMessages.add(sig)
|
||||
messages.value.push({
|
||||
id: messageId(),
|
||||
role,
|
||||
content: data.content,
|
||||
kind,
|
||||
at,
|
||||
})
|
||||
} catch (_) {
|
||||
// Ignore malformed payload and keep the stream alive.
|
||||
}
|
||||
}
|
||||
|
||||
eventSource.onerror = () => {
|
||||
streamState.value = 'reconnecting'
|
||||
}
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
const content = text.value.trim()
|
||||
if (!content) return
|
||||
|
||||
sending.value = true
|
||||
pushMessage('user', content)
|
||||
|
||||
try {
|
||||
const resp = await fetch(`${apiBase.value}/message`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify({
|
||||
sender: senderId.value,
|
||||
chat_id: chatId.value,
|
||||
text: content,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!resp.ok) {
|
||||
const detail = await resp.text()
|
||||
pushMessage('system', `Send failed: ${detail || resp.statusText}`, 'error')
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
loadHistory()
|
||||
}, 800)
|
||||
}
|
||||
} catch (err) {
|
||||
pushMessage('system', `Send failed: ${String(err)}`, 'error')
|
||||
} finally {
|
||||
text.value = ''
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await restoreSession()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => messages.value.length,
|
||||
(curr, prev) => {
|
||||
if (curr > prev) {
|
||||
scrollToBottom(true)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (eventSource) eventSource.close()
|
||||
if (historyPollTimer) clearInterval(historyPollTimer)
|
||||
if (registerPollTimer) clearInterval(registerPollTimer)
|
||||
if (codeCountdownTimer) clearInterval(codeCountdownTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<div class="auth-actions">
|
||||
<button :disabled="authLoading" @click="login">Login</button>
|
||||
<button class="reconnect" :disabled="authLoading" @click="register">Register</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
|
||||
</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>
|
||||
</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>
|
||||
</aside>
|
||||
|
||||
<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.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>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
5
src/main.js
Normal file
5
src/main.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import './style.css'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
355
src/style.css
Normal file
355
src/style.css
Normal file
@@ -0,0 +1,355 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;700&family=JetBrains+Mono:wght@400;500&display=swap');
|
||||
|
||||
:root {
|
||||
--bg: #eef3fb;
|
||||
--bg-soft: #f6f8fd;
|
||||
--panel: rgba(255, 255, 255, 0.84);
|
||||
--panel-strong: #ffffff;
|
||||
--line: #d9e3f1;
|
||||
--ink: #1c2d43;
|
||||
--ink-soft: #6c7d94;
|
||||
--brand: #2b63d9;
|
||||
--brand-soft: #eaf1ff;
|
||||
--danger: #bf3f3f;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
color: var(--ink);
|
||||
font-family: 'Noto Sans SC', sans-serif;
|
||||
background:
|
||||
radial-gradient(1200px 700px at 0% 0%, #ffffff, transparent 70%),
|
||||
linear-gradient(180deg, #f8faff 0%, #eef3fb 55%, #e9f0fb 100%);
|
||||
}
|
||||
|
||||
.screen {
|
||||
min-height: 100vh;
|
||||
padding: 1.2rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.chat-shell {
|
||||
width: min(1280px, 100%);
|
||||
min-height: calc(100vh - 2.4rem);
|
||||
border-radius: 22px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
box-shadow: 0 24px 44px rgba(34, 68, 120, 0.12);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
min-height: calc(100vh - 2.4rem);
|
||||
display: grid;
|
||||
grid-template-columns: 320px 1fr;
|
||||
}
|
||||
|
||||
.workspace-sidebar {
|
||||
border-right: 1px solid var(--line);
|
||||
background: linear-gradient(180deg, #f8fbff, #f1f5fc 75%);
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.sidebar-brand h2 {
|
||||
margin: 0;
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.sidebar-brand p {
|
||||
margin: 0.28rem 0 0;
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.sidebar-card {
|
||||
border: 1px solid var(--line);
|
||||
background: var(--panel-strong);
|
||||
border-radius: 14px;
|
||||
padding: 0.8rem;
|
||||
display: grid;
|
||||
gap: 0.42rem;
|
||||
}
|
||||
|
||||
.card-label {
|
||||
font-size: 0.78rem;
|
||||
color: var(--ink-soft);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.sidebar-actions {
|
||||
display: flex;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.config-panel {
|
||||
border: 1px solid var(--line);
|
||||
background: var(--panel-strong);
|
||||
border-radius: 14px;
|
||||
padding: 0.75rem;
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.config-panel label {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--ink-soft);
|
||||
text-transform: uppercase;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.workspace-main {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
background: rgba(255, 255, 255, 0.58);
|
||||
}
|
||||
|
||||
.main-head {
|
||||
padding: 1rem 1.2rem 0.7rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
.main-head h1 {
|
||||
margin: 0;
|
||||
font-size: 1.08rem;
|
||||
}
|
||||
|
||||
.main-head p {
|
||||
margin: 0.28rem 0 0;
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.auth-shell {
|
||||
width: min(540px, 100%);
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
box-shadow: 0 20px 34px rgba(42, 77, 132, 0.12);
|
||||
overflow: hidden;
|
||||
animation: rise 460ms ease-out;
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
padding: 1rem 1.1rem 1.2rem;
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.auth-actions {
|
||||
display: flex;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.auth-error {
|
||||
margin: 0;
|
||||
color: var(--danger);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
@keyframes rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(18px) scale(0.985);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.top-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.8rem;
|
||||
align-items: center;
|
||||
padding: 1rem 1.1rem 0.7rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.top-bar h1 {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
text-transform: lowercase;
|
||||
}
|
||||
|
||||
.top-bar p {
|
||||
margin: 0.25rem 0 0;
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.badge {
|
||||
border-radius: 999px;
|
||||
padding: 0.38rem 0.68rem;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.77rem;
|
||||
border: 1px solid var(--line);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.badge.connected {
|
||||
background: #e7f6ef;
|
||||
color: #177b56;
|
||||
}
|
||||
|
||||
.badge.connecting,
|
||||
.badge.reconnecting {
|
||||
background: var(--brand-soft);
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.badge.disconnected {
|
||||
background: #ffecec;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
padding: 0.62rem 0.72rem;
|
||||
color: var(--ink);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
border-color: #90afe5;
|
||||
box-shadow: 0 0 0 3px rgba(89, 132, 220, 0.15);
|
||||
}
|
||||
|
||||
.messages {
|
||||
overflow: auto;
|
||||
padding: 1rem 1.15rem 1.2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
max-width: min(72ch, 82%);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
padding: 0.72rem 0.82rem;
|
||||
background: #ffffff;
|
||||
animation: fadeIn 220ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.bubble.user {
|
||||
align-self: flex-end;
|
||||
background: linear-gradient(135deg, #e8f0ff, #f0f5ff);
|
||||
border-color: #bfd1f6;
|
||||
}
|
||||
|
||||
.bubble.assistant {
|
||||
align-self: flex-start;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.bubble.system,
|
||||
.bubble.error {
|
||||
align-self: center;
|
||||
background: #fff3f3;
|
||||
}
|
||||
|
||||
.bubble p {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.bubble small {
|
||||
margin-top: 0.35rem;
|
||||
display: block;
|
||||
color: var(--ink-soft);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.composer {
|
||||
border-top: 1px solid var(--line);
|
||||
padding: 0.85rem 1.15rem 1.15rem;
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
grid-template-columns: 1fr auto;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
button {
|
||||
border: 1px solid transparent;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(120deg, #3d71e5, #2b63d9);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
padding: 0.64rem 0.95rem;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.reconnect {
|
||||
background: #fff;
|
||||
color: var(--ink);
|
||||
border: 1px solid var(--line);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.screen {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.chat-shell {
|
||||
min-height: 100vh;
|
||||
border-radius: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.workspace-sidebar {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.composer {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
10
vite.config.js
Normal file
10
vite.config.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
port: 5173,
|
||||
host: true,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user