更新
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
VITE_API_PROXY_TARGET=http://127.0.0.1:8080
|
||||
VITE_ADMIN_URL=http://localhost:5174
|
||||
@@ -0,0 +1,2 @@
|
||||
# 生产环境:管理后台入口(同域子路径)
|
||||
VITE_ADMIN_URL=/admin
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.DS_Store
|
||||
*.local
|
||||
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>AI Chat</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1767
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "ai-chat-member",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build && node ../scripts/deploy-static.js member",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.7.9",
|
||||
"dompurify": "^3.2.4",
|
||||
"highlight.js": "^11.11.1",
|
||||
"marked": "^15.0.6",
|
||||
"pinia": "^2.3.0",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"vite": "^6.0.7"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||
<rect width="32" height="32" rx="8" fill="#10a37f"/>
|
||||
<path d="M16 8c-4.4 0-8 3.1-8 7 0 2.2 1.1 4.2 2.9 5.5-.3.9-.9 2.4-1.5 3.5 1.3-.2 3-.8 4.2-1.4 0 0 .3.1.4.1 4.4 0 8-3.1 8-7s-3.6-7-8-7z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 288 B |
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
@@ -0,0 +1,36 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 60000
|
||||
})
|
||||
|
||||
api.interceptors.request.use(config => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
api.interceptors.response.use(
|
||||
res => res,
|
||||
err => {
|
||||
if (axios.isCancel(err)) {
|
||||
return Promise.reject(err)
|
||||
}
|
||||
|
||||
const message = err.response?.data?.message || '请求失败'
|
||||
if (err.response?.status === 401) {
|
||||
localStorage.removeItem('token')
|
||||
if (!window.location.pathname.includes('/login')) {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
}
|
||||
const wrapped = new Error(message)
|
||||
wrapped.code = err.code
|
||||
return Promise.reject(wrapped)
|
||||
}
|
||||
)
|
||||
|
||||
export default api
|
||||
@@ -0,0 +1,232 @@
|
||||
@import 'highlight.js/styles/github-dark.css';
|
||||
|
||||
:root {
|
||||
--bg-primary: #212121;
|
||||
--bg-secondary: #171717;
|
||||
--bg-tertiary: #2f2f2f;
|
||||
--bg-hover: #3a3a3a;
|
||||
--text-primary: #ececec;
|
||||
--text-secondary: #b4b4b4;
|
||||
--text-muted: #8e8e8e;
|
||||
--accent: #10a37f;
|
||||
--accent-hover: #0d8a6a;
|
||||
--border: #3a3a3a;
|
||||
--danger: #ef4444;
|
||||
--sidebar-width: 260px;
|
||||
--header-height: 56px;
|
||||
--input-max-width: 768px;
|
||||
--radius: 12px;
|
||||
--shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body, #app {
|
||||
height: 100%;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
input, textarea {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--bg-hover);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.btn-ghost:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
color: var(--danger);
|
||||
}
|
||||
.btn-danger:hover {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
/* Form */
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.form-error {
|
||||
color: var(--danger);
|
||||
font-size: 13px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
/* Markdown content */
|
||||
.markdown-body {
|
||||
line-height: 1.7;
|
||||
font-size: 15px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.markdown-body p {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.markdown-body p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* 单个换行(非空行分段)时也留出呼吸空间,避免像 OCR 结果那样一行行挤在一起 */
|
||||
.markdown-body br {
|
||||
content: '';
|
||||
display: block;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.markdown-body pre {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
overflow-x: auto;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.markdown-body code {
|
||||
font-family: 'SF Mono', Monaco, Consolas, monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.markdown-body :not(pre) > code {
|
||||
background: var(--bg-tertiary);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.markdown-body ul, .markdown-body ol {
|
||||
padding-left: 24px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.markdown-body blockquote {
|
||||
border-left: 3px solid var(--accent);
|
||||
padding-left: 16px;
|
||||
color: var(--text-secondary);
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.markdown-body table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.markdown-body th, .markdown-body td {
|
||||
border: 1px solid var(--border);
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.markdown-body th {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.markdown-body a {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.markdown-body img {
|
||||
max-width: 100%;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* Overlay for mobile sidebar */
|
||||
.sidebar-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 90;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sidebar-overlay.active {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
<template>
|
||||
<div class="chat-input-area">
|
||||
<!-- 附件预览 -->
|
||||
<div v-if="pendingAttachments.length || uploadingFiles.length" class="attachment-preview">
|
||||
<div v-for="(att, i) in pendingAttachments" :key="'done-' + i" class="preview-item">
|
||||
<img v-if="att?.type === 'image'" :src="att.preview" class="preview-thumb" />
|
||||
<span v-else class="preview-file">{{ att?.name || '未知文件' }}</span>
|
||||
<button class="remove-btn" @click="removeAttachment(i)">×</button>
|
||||
</div>
|
||||
|
||||
<div v-for="u in uploadingFiles" :key="u.id" class="preview-item preview-uploading">
|
||||
<span class="upload-name" :title="u.name">{{ u.name }}</span>
|
||||
<span class="upload-percent">{{ u.progress }}%</span>
|
||||
<div class="upload-progress-track">
|
||||
<div class="upload-progress-fill" :style="{ width: u.progress + '%' }" />
|
||||
</div>
|
||||
<button class="remove-btn" title="取消上传" @click="cancelUpload(u.id)">×</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input-wrapper">
|
||||
<div class="input-actions">
|
||||
<button
|
||||
v-if="canUploadImage || canUploadVideo || canUploadFile"
|
||||
class="action-btn"
|
||||
title="上传文件"
|
||||
@click="triggerUpload"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="features.emoji"
|
||||
class="action-btn"
|
||||
title="表情"
|
||||
@click="showEmoji = !showEmoji"
|
||||
>
|
||||
😊
|
||||
</button>
|
||||
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
hidden
|
||||
:accept="acceptTypes"
|
||||
@change="handleFileSelect"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 表情面板 -->
|
||||
<div v-if="showEmoji" class="emoji-panel">
|
||||
<button
|
||||
v-for="emoji in EMOJIS"
|
||||
:key="emoji"
|
||||
class="emoji-btn"
|
||||
@click="insertEmoji(emoji)"
|
||||
>{{ emoji }}</button>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
ref="textareaRef"
|
||||
v-model="content"
|
||||
class="input-textarea"
|
||||
placeholder="输入消息... (Shift+Enter 换行)"
|
||||
rows="1"
|
||||
@keydown="handleKeydown"
|
||||
@input="autoResize"
|
||||
@paste="handlePaste"
|
||||
/>
|
||||
|
||||
<button
|
||||
class="send-btn"
|
||||
:disabled="!canSend || sending"
|
||||
@click="handleSend"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="uploadingFiles.length" class="input-hint">正在上传 {{ uploadingFiles.length }} 个文件…</p>
|
||||
<p v-else-if="imageBlockedByModel" class="input-hint input-hint-warn">当前模型不支持图片识别,已隐藏图片上传</p>
|
||||
<p v-else class="input-hint">AI 可能会犯错,请核实重要信息</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useChatStore } from '@/stores/chat'
|
||||
import { EMOJIS } from '@/utils/markdown'
|
||||
import api from '@/api'
|
||||
|
||||
const emit = defineEmits(['send'])
|
||||
|
||||
const settings = useSettingsStore()
|
||||
const auth = useAuthStore()
|
||||
const chat = useChatStore()
|
||||
|
||||
const content = ref('')
|
||||
const pendingAttachments = ref([])
|
||||
const uploadingFiles = ref([])
|
||||
const showEmoji = ref(false)
|
||||
const sending = ref(false)
|
||||
const fileInput = ref(null)
|
||||
const textareaRef = ref(null)
|
||||
|
||||
const features = computed(() => settings.features)
|
||||
const perms = computed(() => auth.user?.membership_permissions || {})
|
||||
|
||||
const activeModel = computed(() => {
|
||||
const conv = chat.conversations.find(c => c.id === chat.currentId)
|
||||
const modelId = conv?.model_id
|
||||
if (modelId) {
|
||||
return settings.models.find(m => m.id === modelId) || null
|
||||
}
|
||||
return settings.models.find(m => m.is_default) || settings.models[0] || null
|
||||
})
|
||||
|
||||
const modelSupportsImage = computed(() => {
|
||||
if (!activeModel.value) return true
|
||||
return activeModel.value.support_image !== 0 && activeModel.value.support_image !== false
|
||||
})
|
||||
|
||||
const canUploadImage = computed(() => features.value.upload_image && perms.value.can_upload_image && modelSupportsImage.value)
|
||||
const canUploadVideo = computed(() => features.value.upload_video && perms.value.can_upload_video)
|
||||
const canUploadFile = computed(() => features.value.upload_file && perms.value.can_upload_file)
|
||||
|
||||
const imageBlockedByModel = computed(() => {
|
||||
return features.value.upload_image && perms.value.can_upload_image && !modelSupportsImage.value
|
||||
})
|
||||
|
||||
const acceptTypes = computed(() => {
|
||||
const types = []
|
||||
if (canUploadImage.value) types.push('image/*')
|
||||
if (canUploadVideo.value) types.push('video/*')
|
||||
if (canUploadFile.value) types.push('.pdf,.doc,.docx,.txt,.md')
|
||||
return types.join(',')
|
||||
})
|
||||
|
||||
const canSend = computed(() => {
|
||||
return (content.value.trim() || pendingAttachments.value.length) &&
|
||||
!sending.value &&
|
||||
!uploadingFiles.value.length
|
||||
})
|
||||
|
||||
function autoResize() {
|
||||
const el = textareaRef.value
|
||||
if (!el) return
|
||||
el.style.height = 'auto'
|
||||
el.style.height = Math.min(el.scrollHeight, 200) + 'px'
|
||||
}
|
||||
|
||||
function handleKeydown(e) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
}
|
||||
|
||||
function triggerUpload() {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
async function handleFileSelect(e) {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
await uploadFile(file)
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
async function handlePaste(e) {
|
||||
if (!features.value.paste_image || !canUploadImage.value) return
|
||||
|
||||
const items = e.clipboardData?.items
|
||||
if (!items) return
|
||||
|
||||
for (const item of items) {
|
||||
if (item?.type?.startsWith('image/')) {
|
||||
e.preventDefault()
|
||||
const file = item.getAsFile()
|
||||
if (file) await uploadFile(file)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadFile(file) {
|
||||
const id = `up-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
const controller = new AbortController()
|
||||
const entry = { id, name: file.name, progress: 0, controller }
|
||||
uploadingFiles.value.push(entry)
|
||||
|
||||
const localPreview = file.type.startsWith('image/') ? URL.createObjectURL(file) : null
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
try {
|
||||
const res = await api.post('/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
signal: controller.signal,
|
||||
onUploadProgress: (evt) => {
|
||||
if (evt.total) {
|
||||
entry.progress = Math.round((evt.loaded / evt.total) * 100)
|
||||
}
|
||||
}
|
||||
})
|
||||
const data = res.data?.data
|
||||
if (!data) {
|
||||
throw new Error('上传失败:服务器未返回文件信息')
|
||||
}
|
||||
pendingAttachments.value.push({
|
||||
...data,
|
||||
preview: data.type === 'image' ? (localPreview || data.url) : null,
|
||||
_localPreview: localPreview
|
||||
})
|
||||
} catch (err) {
|
||||
if (localPreview) URL.revokeObjectURL(localPreview)
|
||||
if (err.code !== 'ERR_CANCELED' && err.name !== 'CanceledError') {
|
||||
alert(err.message || '上传失败')
|
||||
}
|
||||
} finally {
|
||||
uploadingFiles.value = uploadingFiles.value.filter(u => u.id !== id)
|
||||
}
|
||||
}
|
||||
|
||||
function cancelUpload(id) {
|
||||
const entry = uploadingFiles.value.find(u => u.id === id)
|
||||
entry?.controller.abort()
|
||||
}
|
||||
|
||||
function removeAttachment(index) {
|
||||
const att = pendingAttachments.value[index]
|
||||
if (att?._localPreview) URL.revokeObjectURL(att._localPreview)
|
||||
pendingAttachments.value.splice(index, 1)
|
||||
}
|
||||
|
||||
function insertEmoji(emoji) {
|
||||
content.value += emoji
|
||||
showEmoji.value = false
|
||||
textareaRef.value?.focus()
|
||||
}
|
||||
|
||||
async function handleSend() {
|
||||
if (!canSend.value) return
|
||||
|
||||
sending.value = true
|
||||
const text = content.value.trim()
|
||||
const attachments = pendingAttachments.value.map(a => ({
|
||||
type: a.type,
|
||||
url: a.url,
|
||||
name: a.name,
|
||||
size: a.size,
|
||||
mime: a.mime
|
||||
}))
|
||||
|
||||
pendingAttachments.value.forEach(a => {
|
||||
if (a._localPreview) URL.revokeObjectURL(a._localPreview)
|
||||
})
|
||||
|
||||
content.value = ''
|
||||
pendingAttachments.value = []
|
||||
showEmoji.value = false
|
||||
|
||||
if (textareaRef.value) {
|
||||
textareaRef.value.style.height = 'auto'
|
||||
}
|
||||
|
||||
emit('send', { content: text, attachments })
|
||||
sending.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chat-input-area {
|
||||
padding: 12px 16px 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.attachment-preview {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
max-width: var(--input-max-width);
|
||||
margin: 0 auto 8px;
|
||||
}
|
||||
|
||||
.preview-item {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.preview-thumb {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.preview-file {
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.preview-uploading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
width: 140px;
|
||||
padding: 8px 10px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.upload-name {
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
padding-right: 14px;
|
||||
}
|
||||
|
||||
.upload-percent {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.upload-progress-track {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: var(--bg-hover);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.upload-progress-fill {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: 2px;
|
||||
transition: width 0.15s ease;
|
||||
}
|
||||
|
||||
.remove-btn {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: -6px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: var(--danger);
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
max-width: var(--input-max-width);
|
||||
margin: 0 auto;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.input-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 6px;
|
||||
border-radius: 6px;
|
||||
color: var(--text-muted);
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
.action-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.emoji-panel {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 0;
|
||||
margin-bottom: 8px;
|
||||
padding: 8px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(10, 1fr);
|
||||
gap: 2px;
|
||||
box-shadow: var(--shadow);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.emoji-btn {
|
||||
padding: 4px;
|
||||
font-size: 20px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.emoji-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.input-textarea {
|
||||
flex: 1;
|
||||
resize: none;
|
||||
max-height: 200px;
|
||||
padding: 6px 0;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
flex-shrink: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.send-btn:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
.send-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.input-hint {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.input-hint-warn {
|
||||
color: var(--danger);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,238 @@
|
||||
<template>
|
||||
<aside class="sidebar" :class="{ open: chat.sidebarOpen }">
|
||||
<div class="sidebar-top">
|
||||
<button class="new-chat-btn" @click="handleNewChat">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 5v14M5 12h14"/>
|
||||
</svg>
|
||||
新对话
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="conversation-list">
|
||||
<div
|
||||
v-for="conv in chat.conversations"
|
||||
:key="conv.id"
|
||||
class="conversation-item"
|
||||
:class="{ active: conv.id === chat.currentId }"
|
||||
@click="selectConv(conv.id)"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/>
|
||||
</svg>
|
||||
<span class="conv-title">{{ conv.title }}</span>
|
||||
<button class="delete-btn" @click.stop="handleDelete(conv.id)" title="删除">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M18 6L6 18M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="!chat.conversations.length" class="empty-tip">暂无对话,点击上方开始</p>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<div class="user-info">
|
||||
<div class="avatar">{{ avatarLetter }}</div>
|
||||
<div class="user-meta">
|
||||
<span class="user-name">{{ auth.user?.nickname || auth.user?.username }}</span>
|
||||
<span class="user-level">{{ auth.user?.membership_name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
v-if="auth.user?.role === 'admin'"
|
||||
:href="adminUrl"
|
||||
target="_blank"
|
||||
class="admin-entry"
|
||||
>⚙️ 进入管理后台</a>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useChatStore } from '@/stores/chat'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const chat = useChatStore()
|
||||
|
||||
const avatarLetter = computed(() => {
|
||||
const name = auth.user?.nickname || auth.user?.username || '?'
|
||||
return name.charAt(0).toUpperCase()
|
||||
})
|
||||
|
||||
const adminUrl = import.meta.env.VITE_ADMIN_URL || `${window.location.protocol}//${window.location.hostname}:5174`
|
||||
|
||||
async function handleNewChat() {
|
||||
await chat.createConversation()
|
||||
chat.closeSidebar()
|
||||
}
|
||||
|
||||
async function selectConv(id) {
|
||||
await chat.selectConversation(id)
|
||||
chat.closeSidebar()
|
||||
}
|
||||
|
||||
async function handleDelete(id) {
|
||||
if (confirm('确定删除此对话?')) {
|
||||
await chat.deleteConversation(id)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sidebar {
|
||||
width: var(--sidebar-width);
|
||||
background: var(--bg-secondary);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.sidebar-top {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.new-chat-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.new-chat-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.conversation-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.conversation-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.conversation-item:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.conversation-item.active {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.conv-title {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
opacity: 0;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.conversation-item:hover .delete-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
.delete-btn:hover {
|
||||
color: var(--danger);
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.empty-tip {
|
||||
text-align: center;
|
||||
padding: 24px 16px;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.user-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.user-level {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.admin-entry {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
padding: 10px 12px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: var(--accent);
|
||||
background: rgba(16, 163, 127, 0.1);
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
}
|
||||
.admin-entry:hover {
|
||||
background: rgba(16, 163, 127, 0.2);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
z-index: 100;
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
.sidebar.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,265 @@
|
||||
<template>
|
||||
<div class="message" :class="message.role">
|
||||
<div class="message-avatar">
|
||||
{{ message.role === 'user' ? 'U' : 'AI' }}
|
||||
</div>
|
||||
<div class="message-body">
|
||||
<!-- 附件 -->
|
||||
<div v-if="attachments.length" class="attachments">
|
||||
<template v-for="(att, i) in attachments" :key="i">
|
||||
<img
|
||||
v-if="att?.type === 'image' && features.image && !brokenImages[i]"
|
||||
:src="att.url"
|
||||
:alt="att.name"
|
||||
class="att-image"
|
||||
@error="markBroken(i)"
|
||||
@click="previewImage(att.url)"
|
||||
/>
|
||||
<a
|
||||
v-else-if="att?.type === 'image'"
|
||||
:href="att.url"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="att-document"
|
||||
>
|
||||
🖼️ {{ att.name || '图片' }}(点击打开)
|
||||
</a>
|
||||
<video
|
||||
v-else-if="att?.type === 'video' && features.video"
|
||||
:src="att.url"
|
||||
controls
|
||||
class="att-video"
|
||||
/>
|
||||
<audio
|
||||
v-else-if="att?.type === 'audio' && features.voice"
|
||||
:src="att.url"
|
||||
controls
|
||||
class="att-audio"
|
||||
/>
|
||||
<a
|
||||
v-else-if="att?.type === 'document' && features.document"
|
||||
:href="att.url"
|
||||
target="_blank"
|
||||
class="att-document"
|
||||
>
|
||||
{{ documentIcon(att) }} {{ att.name }} ({{ formatFileSize(att.size) }})
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 文本内容 -->
|
||||
<div
|
||||
v-if="message.content"
|
||||
class="message-content"
|
||||
:class="{ 'markdown-body': useMarkdown }"
|
||||
v-html="renderedContent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive } from 'vue'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { renderMarkdown, formatFileSize } from '@/utils/markdown'
|
||||
|
||||
const props = defineProps({
|
||||
message: { type: Object, required: true },
|
||||
isStreaming: Boolean
|
||||
})
|
||||
|
||||
const settings = useSettingsStore()
|
||||
const features = computed(() => settings.features)
|
||||
|
||||
const attachments = computed(() => {
|
||||
const raw = props.message.attachments
|
||||
const list = Array.isArray(raw) ? raw : (raw && typeof raw === 'object' ? Object.values(raw) : [])
|
||||
return list.filter(att => att && typeof att === 'object')
|
||||
})
|
||||
|
||||
function documentIcon(att) {
|
||||
const name = (att?.name || '').toLowerCase()
|
||||
const mime = att?.mime || ''
|
||||
if (name.endsWith('.pdf') || mime.includes('pdf')) return '📕'
|
||||
if (name.endsWith('.doc') || name.endsWith('.docx') || mime.includes('word')) return '📘'
|
||||
return '📄'
|
||||
}
|
||||
|
||||
const useMarkdown = computed(() => {
|
||||
return features.value.markdown && props.message.role === 'assistant'
|
||||
})
|
||||
|
||||
const renderedContent = computed(() => {
|
||||
if (!props.message.content) return ''
|
||||
if (useMarkdown.value) {
|
||||
return renderMarkdown(props.message.content)
|
||||
}
|
||||
return escapeHtml(props.message.content).replace(/\n/g, '<br>')
|
||||
})
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div')
|
||||
div.textContent = text
|
||||
return div.innerHTML
|
||||
}
|
||||
|
||||
const brokenImages = reactive({})
|
||||
|
||||
function markBroken(index) {
|
||||
brokenImages[index] = true
|
||||
}
|
||||
|
||||
function previewImage(url) {
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.message {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
max-width: var(--input-max-width);
|
||||
margin: 0 auto 20px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.message.assistant {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.message.user {
|
||||
flex-direction: row-reverse;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.message-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.message.user .message-avatar {
|
||||
background: #5436da;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.message.assistant .message-avatar {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.message-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
max-width: 78%;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.message.user .message-body {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.message.assistant .message-body {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
font-size: 15px;
|
||||
line-height: 1.7;
|
||||
padding: 10px 14px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.message.user .message-content {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-radius: 16px 16px 4px 16px;
|
||||
}
|
||||
|
||||
.message.assistant .message-content {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
border-radius: 16px 16px 16px 4px;
|
||||
}
|
||||
|
||||
.message-content :deep(pre) {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.message.user .message-content :deep(pre),
|
||||
.message.user .message-content :deep(:not(pre) > code) {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.message.user .message-content :deep(a) {
|
||||
color: #fff;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.attachments {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.message.user .attachments {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.att-image {
|
||||
max-width: 240px;
|
||||
max-height: 240px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.att-video {
|
||||
max-width: 360px;
|
||||
max-height: 240px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.att-audio {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.att-document {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.att-document:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.message {
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.message-body {
|
||||
max-width: 85%;
|
||||
}
|
||||
.att-image {
|
||||
max-width: 180px;
|
||||
max-height: 180px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,120 @@
|
||||
<template>
|
||||
<div class="message-list" ref="listRef">
|
||||
<div v-if="!visibleMessages.length && !loading && !sending && !streaming.trim()" class="welcome">
|
||||
<div class="welcome-icon">💬</div>
|
||||
<h2>有什么可以帮您的?</h2>
|
||||
<p>输入消息开始对话,支持 Markdown、图片、文件等</p>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading-state">加载中...</div>
|
||||
|
||||
<MessageItem
|
||||
v-for="msg in visibleMessages"
|
||||
:key="msg.id"
|
||||
:message="msg"
|
||||
/>
|
||||
|
||||
<MessageItem
|
||||
v-if="streaming.trim()"
|
||||
:message="{ role: 'assistant', content: streaming, content_type: 'markdown' }"
|
||||
:is-streaming="true"
|
||||
/>
|
||||
|
||||
<div v-if="sending && !streaming" class="typing-indicator">
|
||||
<span></span><span></span><span></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, nextTick, computed } from 'vue'
|
||||
import MessageItem from './MessageItem.vue'
|
||||
|
||||
const props = defineProps({
|
||||
messages: { type: Array, default: () => [] },
|
||||
loading: Boolean,
|
||||
streaming: { type: String, default: '' },
|
||||
sending: Boolean
|
||||
})
|
||||
|
||||
const listRef = ref(null)
|
||||
|
||||
const visibleMessages = computed(() => {
|
||||
return props.messages.filter(msg => {
|
||||
const hasContent = !!(msg.content && String(msg.content).trim())
|
||||
const attachments = msg.attachments || []
|
||||
const hasAttachments = Array.isArray(attachments) && attachments.length > 0
|
||||
return hasContent || hasAttachments
|
||||
})
|
||||
})
|
||||
|
||||
watch(
|
||||
() => [props.messages.length, props.streaming],
|
||||
async () => {
|
||||
await nextTick()
|
||||
if (listRef.value) {
|
||||
listRef.value.scrollTop = listRef.value.scrollHeight
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.message-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 24px 16px;
|
||||
}
|
||||
|
||||
.welcome {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.welcome-icon {
|
||||
font-size: 64px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.welcome h2 {
|
||||
font-size: 24px;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.welcome p {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.typing-indicator {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 16px 24px;
|
||||
}
|
||||
|
||||
.typing-indicator span {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: var(--text-muted);
|
||||
border-radius: 50%;
|
||||
animation: bounce 1.4s infinite ease-in-out both;
|
||||
}
|
||||
.typing-indicator span:nth-child(1) { animation-delay: -0.32s; }
|
||||
.typing-indicator span:nth-child(2) { animation-delay: -0.16s; }
|
||||
|
||||
@keyframes bounce {
|
||||
0%, 80%, 100% { transform: scale(0); }
|
||||
40% { transform: scale(1); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './assets/main.css'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,50 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('@/views/LoginView.vue'),
|
||||
meta: { guest: true }
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
name: 'Register',
|
||||
component: () => import('@/views/RegisterView.vue'),
|
||||
meta: { guest: true }
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
name: 'Chat',
|
||||
component: () => import('@/views/ChatView.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
})
|
||||
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
const auth = useAuthStore()
|
||||
|
||||
if (!auth.initialized) {
|
||||
await auth.init()
|
||||
}
|
||||
|
||||
if (to.meta.requiresAuth && !auth.isLoggedIn) {
|
||||
next({ name: 'Login', query: { redirect: to.fullPath } })
|
||||
return
|
||||
}
|
||||
|
||||
if (to.meta.guest && auth.isLoggedIn) {
|
||||
next({ name: 'Chat' })
|
||||
return
|
||||
}
|
||||
|
||||
next()
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,47 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import api from '@/api'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const token = ref(localStorage.getItem('token') || '')
|
||||
const user = ref(null)
|
||||
const initialized = ref(false)
|
||||
|
||||
const isLoggedIn = computed(() => !!token.value && !!user.value)
|
||||
|
||||
async function init() {
|
||||
if (token.value) {
|
||||
try {
|
||||
const res = await api.get('/auth/me')
|
||||
user.value = res.data.data
|
||||
} catch {
|
||||
logout()
|
||||
}
|
||||
}
|
||||
initialized.value = true
|
||||
}
|
||||
|
||||
async function login(account, password) {
|
||||
const res = await api.post('/auth/login', { account, password })
|
||||
token.value = res.data.data.token
|
||||
user.value = res.data.data.user
|
||||
localStorage.setItem('token', token.value)
|
||||
return res.data
|
||||
}
|
||||
|
||||
async function register(username, email, password) {
|
||||
const res = await api.post('/auth/register', { username, email, password })
|
||||
token.value = res.data.data.token
|
||||
user.value = res.data.data.user
|
||||
localStorage.setItem('token', token.value)
|
||||
return res.data
|
||||
}
|
||||
|
||||
function logout() {
|
||||
token.value = ''
|
||||
user.value = null
|
||||
localStorage.removeItem('token')
|
||||
}
|
||||
|
||||
return { token, user, initialized, isLoggedIn, init, login, register, logout }
|
||||
})
|
||||
@@ -0,0 +1,216 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import api from '@/api'
|
||||
|
||||
function parseSseBlock(block) {
|
||||
const lines = block.split('\n')
|
||||
let event = 'message'
|
||||
let data = null
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event:')) {
|
||||
event = line.slice(6).trim()
|
||||
} else if (line.startsWith('data:')) {
|
||||
const raw = line.slice(5).trim()
|
||||
try {
|
||||
data = JSON.parse(raw)
|
||||
} catch {
|
||||
data = { content: raw }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { event, data }
|
||||
}
|
||||
|
||||
function normalizeList(data) {
|
||||
if (Array.isArray(data)) return data
|
||||
if (data && typeof data === 'object') {
|
||||
if (Array.isArray(data.list)) return data.list
|
||||
return Object.values(data)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function isValidMessage(msg) {
|
||||
if (!msg || typeof msg !== 'object') return false
|
||||
const hasContent = !!(msg.content && String(msg.content).trim())
|
||||
const attachments = msg.attachments || []
|
||||
const hasAttachments = Array.isArray(attachments) ? attachments.length > 0 : false
|
||||
return hasContent || hasAttachments
|
||||
}
|
||||
|
||||
export const useChatStore = defineStore('chat', () => {
|
||||
const conversations = ref([])
|
||||
const currentId = ref(null)
|
||||
const messages = ref([])
|
||||
const loading = ref(false)
|
||||
const sending = ref(false)
|
||||
const streamingContent = ref('')
|
||||
const sidebarOpen = ref(false)
|
||||
|
||||
async function fetchConversations() {
|
||||
const res = await api.get('/conversations')
|
||||
const data = res.data?.data
|
||||
conversations.value = normalizeList(data?.list ?? data)
|
||||
}
|
||||
|
||||
async function loadMessages(id = currentId.value) {
|
||||
if (!id) return
|
||||
const res = await api.get(`/conversations/${id}/messages`)
|
||||
const list = normalizeList(res.data?.data)
|
||||
messages.value = list.filter(isValidMessage)
|
||||
}
|
||||
|
||||
async function createConversation(modelId = null) {
|
||||
const res = await api.post('/conversations', { model_id: modelId })
|
||||
const conv = res.data.data
|
||||
conversations.value.unshift(conv)
|
||||
currentId.value = conv.id
|
||||
messages.value = []
|
||||
return conv
|
||||
}
|
||||
|
||||
async function selectConversation(id) {
|
||||
currentId.value = id
|
||||
loading.value = true
|
||||
try {
|
||||
await loadMessages(id)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteConversation(id) {
|
||||
await api.delete(`/conversations/${id}`)
|
||||
conversations.value = conversations.value.filter(c => c.id !== id)
|
||||
if (currentId.value === id) {
|
||||
currentId.value = null
|
||||
messages.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function sendMessage(content, attachments = []) {
|
||||
if (!currentId.value) {
|
||||
await createConversation()
|
||||
}
|
||||
|
||||
const userMsg = {
|
||||
id: Date.now(),
|
||||
role: 'user',
|
||||
content,
|
||||
attachments,
|
||||
created_at: new Date().toISOString()
|
||||
}
|
||||
messages.value.push(userMsg)
|
||||
sending.value = true
|
||||
streamingContent.value = ''
|
||||
|
||||
try {
|
||||
await streamChat(content, attachments)
|
||||
await loadMessages()
|
||||
} catch (e) {
|
||||
// 发送失败时移除本地临时用户消息,随后从服务端重新同步
|
||||
messages.value = messages.value.filter(m => m.id !== userMsg.id)
|
||||
await loadMessages().catch(() => {})
|
||||
throw e
|
||||
} finally {
|
||||
sending.value = false
|
||||
streamingContent.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function streamChat(content, attachments) {
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await fetch('/api/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
conversation_id: currentId.value,
|
||||
content,
|
||||
attachments,
|
||||
stream: true
|
||||
})
|
||||
})
|
||||
|
||||
const contentType = response.headers.get('content-type') || ''
|
||||
|
||||
if (!contentType.includes('text/event-stream')) {
|
||||
const json = await response.json().catch(() => null)
|
||||
const msg = json?.message || `请求失败 (${response.status})`
|
||||
throw new Error(msg)
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error('浏览器不支持流式响应')
|
||||
}
|
||||
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
let fullContent = ''
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const blocks = buffer.split('\n\n')
|
||||
buffer = blocks.pop() || ''
|
||||
|
||||
for (const block of blocks) {
|
||||
if (!block.trim()) continue
|
||||
|
||||
const { event, data } = parseSseBlock(block)
|
||||
|
||||
if (event === 'error') {
|
||||
throw new Error(data?.message || 'AI 请求失败')
|
||||
}
|
||||
|
||||
if (event === 'message' && data?.content) {
|
||||
fullContent += data.content
|
||||
streamingContent.value = fullContent
|
||||
}
|
||||
|
||||
if (event === 'done' && data?.content && !fullContent) {
|
||||
fullContent = data.content
|
||||
streamingContent.value = fullContent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!fullContent.trim()) {
|
||||
throw new Error('AI 未返回内容,请在管理后台测试模型配置是否正确')
|
||||
}
|
||||
|
||||
await fetchConversations()
|
||||
}
|
||||
|
||||
function toggleSidebar() {
|
||||
sidebarOpen.value = !sidebarOpen.value
|
||||
}
|
||||
|
||||
function closeSidebar() {
|
||||
sidebarOpen.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
conversations,
|
||||
currentId,
|
||||
messages,
|
||||
loading,
|
||||
sending,
|
||||
streamingContent,
|
||||
sidebarOpen,
|
||||
fetchConversations,
|
||||
createConversation,
|
||||
selectConversation,
|
||||
deleteConversation,
|
||||
sendMessage,
|
||||
toggleSidebar,
|
||||
closeSidebar
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import api from '@/api'
|
||||
|
||||
export const useSettingsStore = defineStore('settings', () => {
|
||||
const features = ref({
|
||||
markdown: true,
|
||||
image: true,
|
||||
video: true,
|
||||
voice: true,
|
||||
document: true,
|
||||
emoji: true,
|
||||
upload_image: true,
|
||||
upload_video: true,
|
||||
upload_file: true,
|
||||
paste_image: true
|
||||
})
|
||||
const siteName = ref('AI Chat')
|
||||
const allowRegister = ref(true)
|
||||
const models = ref([])
|
||||
const loaded = ref(false)
|
||||
|
||||
async function loadPublic() {
|
||||
const res = await api.get('/settings/public')
|
||||
const data = res.data.data
|
||||
features.value = data.features
|
||||
siteName.value = data.site_name
|
||||
allowRegister.value = data.allow_register
|
||||
loaded.value = true
|
||||
}
|
||||
|
||||
async function loadModels() {
|
||||
const res = await api.get('/models')
|
||||
models.value = res.data.data
|
||||
}
|
||||
|
||||
return { features, siteName, allowRegister, models, loaded, loadPublic, loadModels }
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
import { marked } from 'marked'
|
||||
import DOMPurify from 'dompurify'
|
||||
import hljs from 'highlight.js'
|
||||
|
||||
marked.setOptions({
|
||||
highlight(code, lang) {
|
||||
if (lang && hljs.getLanguage(lang)) {
|
||||
return hljs.highlight(code, { language: lang }).value
|
||||
}
|
||||
return hljs.highlightAuto(code).value
|
||||
},
|
||||
breaks: true,
|
||||
gfm: true
|
||||
})
|
||||
|
||||
// 识别形如 "label [1, 2, 3, 4]" 的坐标/检测框数据(常见于 OCR 类模型输出),
|
||||
// 加粗字段名并用等宽字体展示坐标,避免整段文字挤成一堆不好看
|
||||
function formatDetectionLines(content) {
|
||||
return content.replace(
|
||||
/^([A-Za-z_\u4e00-\u9fa5][\w\u4e00-\u9fa5]*)\s+(\[\s*-?\d+(?:\s*,\s*-?\d+)+\s*\])/gm,
|
||||
'**$1** `$2`'
|
||||
)
|
||||
}
|
||||
|
||||
export function renderMarkdown(content) {
|
||||
if (!content) return ''
|
||||
const html = marked.parse(formatDetectionLines(content))
|
||||
return DOMPurify.sanitize(html, {
|
||||
ADD_TAGS: ['iframe'],
|
||||
ADD_ATTR: ['target', 'rel']
|
||||
})
|
||||
}
|
||||
|
||||
export function formatTime(dateStr) {
|
||||
const date = new Date(dateStr)
|
||||
const now = new Date()
|
||||
const diff = now - date
|
||||
|
||||
if (diff < 60000) return '刚刚'
|
||||
if (diff < 3600000) return `${Math.floor(diff / 60000)} 分钟前`
|
||||
if (diff < 86400000) return `${Math.floor(diff / 3600000)} 小时前`
|
||||
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
export function formatFileSize(bytes) {
|
||||
if (bytes < 1024) return bytes + ' B'
|
||||
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB'
|
||||
return (bytes / 1048576).toFixed(1) + ' MB'
|
||||
}
|
||||
|
||||
const EMOJIS = [
|
||||
'😀', '😃', '😄', '😁', '😊', '🙂', '😉', '😍', '🥰', '😘',
|
||||
'🤔', '😮', '😢', '😭', '😡', '👍', '👎', '👏', '🙏', '💪',
|
||||
'❤️', '🔥', '✨', '🎉', '💡', '✅', '❌', '⭐', '🚀', '💯'
|
||||
]
|
||||
|
||||
export { EMOJIS }
|
||||
@@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<div class="chat-layout">
|
||||
<div
|
||||
class="sidebar-overlay"
|
||||
:class="{ active: chat.sidebarOpen }"
|
||||
@click="chat.closeSidebar"
|
||||
/>
|
||||
|
||||
<ChatSidebar />
|
||||
|
||||
<main class="chat-main">
|
||||
<header class="chat-header">
|
||||
<button class="menu-btn" @click="chat.toggleSidebar">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M3 12h18M3 6h18M3 18h18"/>
|
||||
</svg>
|
||||
</button>
|
||||
<h2 class="chat-title">{{ currentTitle }}</h2>
|
||||
<div class="header-actions">
|
||||
<a
|
||||
v-if="auth.user?.role === 'admin'"
|
||||
:href="adminUrl"
|
||||
target="_blank"
|
||||
class="btn btn-ghost btn-sm admin-link"
|
||||
>管理后台</a>
|
||||
<button class="btn btn-ghost btn-sm" @click="handleLogout">退出</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<MessageList
|
||||
:messages="chat.messages"
|
||||
:loading="chat.loading"
|
||||
:streaming="chat.streamingContent"
|
||||
:sending="chat.sending"
|
||||
/>
|
||||
|
||||
<ChatInput @send="handleSend" />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useChatStore } from '@/stores/chat'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import ChatSidebar from '@/components/ChatSidebar.vue'
|
||||
import MessageList from '@/components/MessageList.vue'
|
||||
import ChatInput from '@/components/ChatInput.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const chat = useChatStore()
|
||||
const settings = useSettingsStore()
|
||||
|
||||
const currentTitle = computed(() => {
|
||||
const conv = chat.conversations.find(c => c.id === chat.currentId)
|
||||
return conv?.title || '新对话'
|
||||
})
|
||||
|
||||
const adminUrl = import.meta.env.VITE_ADMIN_URL || `${window.location.protocol}//${window.location.hostname}:5174`
|
||||
|
||||
onMounted(async () => {
|
||||
await settings.loadPublic()
|
||||
await settings.loadModels()
|
||||
await chat.fetchConversations()
|
||||
})
|
||||
|
||||
async function handleSend({ content, attachments }) {
|
||||
try {
|
||||
await chat.sendMessage(content, attachments)
|
||||
} catch (e) {
|
||||
alert(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
auth.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chat-layout {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
height: var(--header-height);
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.menu-btn {
|
||||
display: none;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.menu-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.chat-title {
|
||||
flex: 1;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 6px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.menu-btn {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,117 @@
|
||||
<template>
|
||||
<div class="auth-page">
|
||||
<div class="auth-card">
|
||||
<div class="auth-header">
|
||||
<div class="logo">💬</div>
|
||||
<h1>{{ settings.siteName }}</h1>
|
||||
<p>登录您的账户</p>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleLogin">
|
||||
<div class="form-group">
|
||||
<label>账号</label>
|
||||
<input v-model="account" class="form-input" placeholder="用户名或邮箱" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>密码</label>
|
||||
<input v-model="password" type="password" class="form-input" placeholder="请输入密码" required />
|
||||
</div>
|
||||
<p v-if="error" class="form-error">{{ error }}</p>
|
||||
<button type="submit" class="btn btn-primary auth-btn" :disabled="loading">
|
||||
{{ loading ? '登录中...' : '登录' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="auth-footer">
|
||||
还没有账户?
|
||||
<router-link to="/register">立即注册</router-link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const auth = useAuthStore()
|
||||
const settings = useSettingsStore()
|
||||
|
||||
const account = ref('')
|
||||
const password = ref('')
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
onMounted(() => settings.loadPublic())
|
||||
|
||||
async function handleLogin() {
|
||||
error.value = ''
|
||||
loading.value = true
|
||||
try {
|
||||
await auth.login(account.value, password.value)
|
||||
router.push(route.query.redirect || '/')
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.auth-page {
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
padding: 40px 32px;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.auth-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 48px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.auth-header h1 {
|
||||
font-size: 24px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.auth-header p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.auth-btn {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.auth-footer {
|
||||
text-align: center;
|
||||
margin-top: 24px;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,125 @@
|
||||
<template>
|
||||
<div class="auth-page">
|
||||
<div class="auth-card">
|
||||
<div class="auth-header">
|
||||
<div class="logo">💬</div>
|
||||
<h1>{{ settings.siteName }}</h1>
|
||||
<p>创建新账户</p>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleRegister">
|
||||
<div class="form-group">
|
||||
<label>用户名</label>
|
||||
<input v-model="username" class="form-input" placeholder="3-50 个字符" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>邮箱</label>
|
||||
<input v-model="email" type="email" class="form-input" placeholder="your@email.com" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>密码</label>
|
||||
<input v-model="password" type="password" class="form-input" placeholder="至少 6 位" required />
|
||||
</div>
|
||||
<p v-if="error" class="form-error">{{ error }}</p>
|
||||
<button type="submit" class="btn btn-primary auth-btn" :disabled="loading">
|
||||
{{ loading ? '注册中...' : '注册' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="auth-footer">
|
||||
已有账户?
|
||||
<router-link to="/login">立即登录</router-link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const settings = useSettingsStore()
|
||||
|
||||
const username = ref('')
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
onMounted(() => settings.loadPublic())
|
||||
|
||||
async function handleRegister() {
|
||||
if (!settings.allowRegister) {
|
||||
error.value = '当前不允许注册'
|
||||
return
|
||||
}
|
||||
error.value = ''
|
||||
loading.value = true
|
||||
try {
|
||||
await auth.register(username.value, email.value, password.value)
|
||||
router.push('/')
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.auth-page {
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
padding: 40px 32px;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.auth-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 48px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.auth-header h1 {
|
||||
font-size: 24px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.auth-header p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.auth-btn {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.auth-footer {
|
||||
text-align: center;
|
||||
margin-top: 24px;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,40 @@
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), '')
|
||||
const apiTarget = env.VITE_API_PROXY_TARGET || 'http://127.0.0.1:8080'
|
||||
|
||||
return {
|
||||
base: '/',
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
}
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
sourcemap: false
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: apiTarget,
|
||||
changeOrigin: true,
|
||||
configure: (proxy) => {
|
||||
proxy.on('proxyRes', (proxyRes, req) => {
|
||||
if (req.url?.includes('/chat/completions')) {
|
||||
proxyRes.headers['cache-control'] = 'no-cache'
|
||||
proxyRes.headers['x-accel-buffering'] = 'no'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user