refactor: 精简前端
This commit is contained in:
parent
9b5766ba65
commit
71960aed6d
|
|
@ -1,5 +1,5 @@
|
||||||
<template>
|
<template>
|
||||||
<div ref="processRef" class="process-block" :class="{ 'is-streaming': streaming }">
|
<div class="process-block" :class="{ 'is-streaming': streaming }">
|
||||||
<!-- Single loop: render all steps in index order for proper alternation -->
|
<!-- Single loop: render all steps in index order for proper alternation -->
|
||||||
<template v-for="item in orderedItems">
|
<template v-for="item in orderedItems">
|
||||||
<!-- Thinking Step -->
|
<!-- Thinking Step -->
|
||||||
|
|
@ -77,7 +77,6 @@ const props = defineProps({
|
||||||
streaming: { type: Boolean, default: false },
|
streaming: { type: Boolean, default: false },
|
||||||
})
|
})
|
||||||
|
|
||||||
const processRef = ref(null)
|
|
||||||
const expandedKeys = ref(new Set())
|
const expandedKeys = ref(new Set())
|
||||||
|
|
||||||
// 构建 processItems 从 processSteps
|
// 构建 processItems 从 processSteps
|
||||||
|
|
@ -191,24 +190,6 @@ const orderedItems = computed(() =>
|
||||||
[...allItems.value].sort((a, b) => (a.index || 0) - (b.index || 0))
|
[...allItems.value].sort((a, b) => (a.index || 0) - (b.index || 0))
|
||||||
)
|
)
|
||||||
|
|
||||||
const orderedThinkingItems = computed(() =>
|
|
||||||
allItems.value
|
|
||||||
.filter(i => i.type === 'thinking')
|
|
||||||
.sort((a, b) => (a.index || 0) - (b.index || 0))
|
|
||||||
)
|
|
||||||
|
|
||||||
const orderedToolCallItems = computed(() =>
|
|
||||||
allItems.value
|
|
||||||
.filter(i => i.type === 'tool_call')
|
|
||||||
.sort((a, b) => (a.index || 0) - (b.index || 0))
|
|
||||||
)
|
|
||||||
|
|
||||||
const orderedTextItems = computed(() =>
|
|
||||||
allItems.value
|
|
||||||
.filter(i => i.type === 'text')
|
|
||||||
.sort((a, b) => (a.index || 0) - (b.index || 0))
|
|
||||||
)
|
|
||||||
|
|
||||||
const hasContent = computed(() => allItems.value.length > 0)
|
const hasContent = computed(() => allItems.value.length > 0)
|
||||||
const lastThinkingKey = computed(() => {
|
const lastThinkingKey = computed(() => {
|
||||||
const items = allItems.value.filter(i => i.type === 'thinking')
|
const items = allItems.value.filter(i => i.type === 'thinking')
|
||||||
|
|
@ -377,8 +358,6 @@ const alertIcon = `<svg viewBox="0 0 24 24" width="16" height="16" fill="none" s
|
||||||
animation: pulse 1s ease-in-out infinite;
|
animation: pulse 1s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool_call.loading .step-header { background: var(--bg-hover); }
|
|
||||||
|
|
||||||
.step-content { padding: 12px; margin-top: 4px; background: var(--bg-code); border: 1px solid var(--border-light); border-radius: 8px; overflow: hidden; }
|
.step-content { padding: 12px; margin-top: 4px; background: var(--bg-code); border: 1px solid var(--border-light); border-radius: 8px; overflow: hidden; }
|
||||||
.thinking-text { font-size: 13px; color: var(--text-secondary); line-height: 1.6; white-space: pre-wrap; }
|
.thinking-text { font-size: 13px; color: var(--text-secondary); line-height: 1.6; white-space: pre-wrap; }
|
||||||
.tool-detail { font-size: 13px; }
|
.tool-detail { font-size: 13px; }
|
||||||
|
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
// 导出组件
|
|
||||||
export { default as AppHeader } from './components/AppHeader.vue'
|
|
||||||
export { default as ProcessBlock } from './components/ProcessBlock.vue'
|
|
||||||
export { default as MessageBubble } from './components/MessageBubble.vue'
|
|
||||||
export { default as MessageNav } from './components/MessageNav.vue'
|
|
||||||
|
|
@ -386,20 +386,6 @@ body {
|
||||||
transition: background 0.2s, border-color 0.2s;
|
transition: background 0.2s, border-color 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============ App Layout ============ */
|
|
||||||
.app {
|
|
||||||
height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-panel {
|
|
||||||
flex: 1 1 0;
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============ Transitions ============ */
|
/* ============ Transitions ============ */
|
||||||
.fade-enter-active,
|
.fade-enter-active,
|
||||||
.fade-leave-active {
|
.fade-leave-active {
|
||||||
|
|
|
||||||
|
|
@ -36,121 +36,6 @@ api.interceptors.response.use(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
|
||||||
* SSE 流式请求处理器
|
|
||||||
* @param {string} url - API URL (不含 baseURL 前缀)
|
|
||||||
* @param {object} body - 请求体
|
|
||||||
* @param {object} callbacks - 事件回调: { onProcessStep, onDone, onError }
|
|
||||||
* @returns {{ abort: () => void }}
|
|
||||||
*/
|
|
||||||
export function createSSEStream(url, body, { onProcessStep, onDone, onError }) {
|
|
||||||
const token = localStorage.getItem('access_token')
|
|
||||||
const controller = new AbortController()
|
|
||||||
|
|
||||||
const promise = (async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api${url}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${token}`
|
|
||||||
},
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
signal: controller.signal
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const err = await res.json().catch(() => ({}))
|
|
||||||
throw new Error(err.message || `HTTP ${res.status}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const reader = res.body.getReader()
|
|
||||||
const decoder = new TextDecoder()
|
|
||||||
let buffer = ''
|
|
||||||
let completed = false
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read()
|
|
||||||
|
|
||||||
// 处理数据
|
|
||||||
if (value) {
|
|
||||||
buffer += decoder.decode(value, { stream: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
// 流结束时,先处理 buffer 中的剩余数据,再 break
|
|
||||||
if (done) {
|
|
||||||
// 处理 buffer 中剩余的数据
|
|
||||||
const lines = buffer.split('\n')
|
|
||||||
buffer = ''
|
|
||||||
|
|
||||||
let currentEvent = ''
|
|
||||||
for (const line of lines) {
|
|
||||||
if (line.startsWith('event: ')) {
|
|
||||||
currentEvent = line.slice(7).trim()
|
|
||||||
} else if (line.startsWith('data: ')) {
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(line.slice(6))
|
|
||||||
if (currentEvent === 'process_step' && onProcessStep) {
|
|
||||||
onProcessStep(data.step)
|
|
||||||
} else if (currentEvent === 'done' && onDone) {
|
|
||||||
completed = true
|
|
||||||
onDone(data)
|
|
||||||
} else if (currentEvent === 'error' && onError) {
|
|
||||||
onError(data.content)
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('SSE parse error:', e, 'line:', line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果没有收到 done 事件,触发错误
|
|
||||||
if (!completed && onError) {
|
|
||||||
onError('stream ended without done event')
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines = buffer.split('\n')
|
|
||||||
buffer = lines.pop() || ''
|
|
||||||
|
|
||||||
let currentEvent = ''
|
|
||||||
for (const line of lines) {
|
|
||||||
if (line.startsWith('event: ')) {
|
|
||||||
currentEvent = line.slice(7).trim()
|
|
||||||
} else if (line.startsWith('data: ')) {
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(line.slice(6))
|
|
||||||
if (currentEvent === 'process_step' && onProcessStep) {
|
|
||||||
onProcessStep(data.step)
|
|
||||||
} else if (currentEvent === 'done' && onDone) {
|
|
||||||
completed = true
|
|
||||||
onDone(data)
|
|
||||||
} else if (currentEvent === 'error' && onError) {
|
|
||||||
onError(data.content)
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// 忽略解析错误
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 流结束但没有收到 done 事件,才报错
|
|
||||||
if (!completed && onError) {
|
|
||||||
onError('stream ended unexpectedly')
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (e.name !== 'AbortError' && onError) {
|
|
||||||
onError(e.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
|
|
||||||
promise.abort = () => controller.abort()
|
|
||||||
return promise
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============ 认证接口 ============
|
// ============ 认证接口 ============
|
||||||
|
|
||||||
export const authAPI = {
|
export const authAPI = {
|
||||||
|
|
@ -177,17 +62,6 @@ export const conversationsAPI = {
|
||||||
export const messagesAPI = {
|
export const messagesAPI = {
|
||||||
list: (conversationId, params) => api.get('/messages/', { params: { conversation_id: conversationId, ...params } }),
|
list: (conversationId, params) => api.get('/messages/', { params: { conversation_id: conversationId, ...params } }),
|
||||||
send: (data) => api.post('/messages/', data),
|
send: (data) => api.post('/messages/', data),
|
||||||
|
|
||||||
// 发送消息(流式)
|
|
||||||
sendStream: (data, callbacks) => {
|
|
||||||
return createSSEStream('/messages/stream', {
|
|
||||||
conversation_id: data.conversation_id,
|
|
||||||
content: data.content,
|
|
||||||
thinking_enabled: data.thinking_enabled || false,
|
|
||||||
enabled_tools: data.enabled_tools || []
|
|
||||||
}, callbacks)
|
|
||||||
},
|
|
||||||
|
|
||||||
delete: (id) => api.delete(`/messages/${id}`)
|
delete: (id) => api.delete(`/messages/${id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1 @@
|
||||||
/**
|
|
||||||
* Luxx 前端工具库
|
|
||||||
* 合并了 composables、services 和 stores 的统一导出
|
|
||||||
*/
|
|
||||||
|
|
||||||
// ============ API 服务 ============
|
|
||||||
export { default as api, authAPI, conversationsAPI, messagesAPI, toolsAPI, providersAPI, createSSEStream } from './api.js'
|
|
||||||
|
|
||||||
// ============ Pinia 状态管理 ============
|
|
||||||
export { default as pinia } from './store.js'
|
export { default as pinia } from './store.js'
|
||||||
|
|
||||||
// ============ 认证相关 ============
|
|
||||||
export { useAuth } from './useAuth.js'
|
|
||||||
|
|
||||||
// ============ API 请求组合式函数 ============
|
|
||||||
export { useApi, usePagination, useForm } from './useApi.js'
|
|
||||||
|
|
||||||
// ============ 格式化工具 ============
|
|
||||||
export { formatDate, formatNumber, truncate, formatFileSize, capitalize, formatTokens } from './useFormatters.js'
|
|
||||||
|
|
||||||
// ============ 通用工具函数 ============
|
|
||||||
export { debounce, throttle, deepClone, generateId, storage, getDeviceType, copyToClipboard } from './useUtils.js'
|
|
||||||
|
|
||||||
// ============ Markdown 渲染 ============
|
|
||||||
export { renderMarkdown } from './markdown.js'
|
|
||||||
|
|
|
||||||
|
|
@ -1,171 +0,0 @@
|
||||||
/**
|
|
||||||
* API 请求组合式函数
|
|
||||||
* 提供统一的错误处理和加载状态管理
|
|
||||||
*/
|
|
||||||
import { ref } from 'vue'
|
|
||||||
|
|
||||||
export function useApi() {
|
|
||||||
const loading = ref(false)
|
|
||||||
const error = ref(null)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 执行 API 请求
|
|
||||||
* @param {Function} apiFn - API 函数
|
|
||||||
* @param {Object} options - 配置选项
|
|
||||||
* @returns {Promise<Object>} 响应数据
|
|
||||||
*/
|
|
||||||
const request = async (apiFn, options = {}) => {
|
|
||||||
const { showLoading = true, errorMessage = '请求失败' } = options
|
|
||||||
|
|
||||||
loading.value = true
|
|
||||||
error.value = null
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await apiFn()
|
|
||||||
|
|
||||||
if (response.success) {
|
|
||||||
return response.data
|
|
||||||
} else {
|
|
||||||
throw new Error(response.message || errorMessage)
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
error.value = err.message || errorMessage
|
|
||||||
throw err
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 重置状态
|
|
||||||
*/
|
|
||||||
const reset = () => {
|
|
||||||
loading.value = false
|
|
||||||
error.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
request,
|
|
||||||
reset
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 分页组合式函数
|
|
||||||
*/
|
|
||||||
export function usePagination(initialPage = 1, initialPageSize = 20) {
|
|
||||||
const page = ref(initialPage)
|
|
||||||
const pageSize = ref(initialPageSize)
|
|
||||||
const total = ref(0)
|
|
||||||
|
|
||||||
const totalPages = () => Math.ceil(total.value / pageSize.value)
|
|
||||||
|
|
||||||
const nextPage = () => {
|
|
||||||
if (page.value < totalPages()) {
|
|
||||||
page.value++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const prevPage = () => {
|
|
||||||
if (page.value > 1) {
|
|
||||||
page.value--
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const goToPage = (p) => {
|
|
||||||
if (p >= 1 && p <= totalPages()) {
|
|
||||||
page.value = p
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const resetPagination = () => {
|
|
||||||
page.value = initialPage
|
|
||||||
total.value = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
page,
|
|
||||||
pageSize,
|
|
||||||
total,
|
|
||||||
totalPages,
|
|
||||||
nextPage,
|
|
||||||
prevPage,
|
|
||||||
goToPage,
|
|
||||||
resetPagination
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 表单组合式函数
|
|
||||||
*/
|
|
||||||
export function useForm(initialData = {}) {
|
|
||||||
const formData = ref({ ...initialData })
|
|
||||||
const errors = ref({})
|
|
||||||
const submitting = ref(false)
|
|
||||||
|
|
||||||
const updateField = (field, value) => {
|
|
||||||
formData.value[field] = value
|
|
||||||
// 清除字段错误
|
|
||||||
if (errors.value[field]) {
|
|
||||||
delete errors.value[field]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const setFieldError = (field, message) => {
|
|
||||||
errors.value[field] = message
|
|
||||||
}
|
|
||||||
|
|
||||||
const setErrors = (errorObj) => {
|
|
||||||
errors.value = errorObj
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearErrors = () => {
|
|
||||||
errors.value = {}
|
|
||||||
}
|
|
||||||
|
|
||||||
const resetForm = (newData = {}) => {
|
|
||||||
formData.value = { ...initialData, ...newData }
|
|
||||||
errors.value = {}
|
|
||||||
}
|
|
||||||
|
|
||||||
const validate = (rules) => {
|
|
||||||
const newErrors = {}
|
|
||||||
|
|
||||||
for (const [field, rule] of Object.entries(rules)) {
|
|
||||||
const value = formData.value[field]
|
|
||||||
|
|
||||||
if (rule.required && !value) {
|
|
||||||
newErrors[field] = rule.message || `${field}不能为空`
|
|
||||||
}
|
|
||||||
|
|
||||||
if (rule.minLength && value && value.length < rule.minLength) {
|
|
||||||
newErrors[field] = rule.message || `${field}长度不能少于${rule.minLength}位`
|
|
||||||
}
|
|
||||||
|
|
||||||
if (rule.maxLength && value && value.length > rule.maxLength) {
|
|
||||||
newErrors[field] = rule.message || `${field}长度不能超过${rule.maxLength}位`
|
|
||||||
}
|
|
||||||
|
|
||||||
if (rule.pattern && value && !rule.pattern.test(value)) {
|
|
||||||
newErrors[field] = rule.message || `${field}格式不正确`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setErrors(newErrors)
|
|
||||||
return Object.keys(newErrors).length === 0
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
formData,
|
|
||||||
errors,
|
|
||||||
submitting,
|
|
||||||
updateField,
|
|
||||||
setFieldError,
|
|
||||||
setErrors,
|
|
||||||
clearErrors,
|
|
||||||
resetForm,
|
|
||||||
validate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -40,27 +40,6 @@ export const formatNumber = (num, options = {}) => {
|
||||||
}).format(num) + suffix
|
}).format(num) + suffix
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 截断文本
|
|
||||||
*/
|
|
||||||
export const truncate = (text, maxLength = 50, suffix = '...') =>
|
|
||||||
!text || text.length <= maxLength ? text : text.slice(0, maxLength - suffix.length) + suffix
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 格式化文件大小
|
|
||||||
*/
|
|
||||||
export const formatFileSize = (bytes) => {
|
|
||||||
if (!bytes) return '0 B'
|
|
||||||
const k = 1024, sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
|
||||||
return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 首字母大写
|
|
||||||
*/
|
|
||||||
export const capitalize = (str) => str ? str.replace(/^./, c => c.toUpperCase()) : ''
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 格式化令牌数
|
* 格式化令牌数
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
/**
|
|
||||||
* 防抖函数
|
|
||||||
* @param {Function} func - 要执行的函数
|
|
||||||
* @param {number} wait - 等待时间(毫秒)
|
|
||||||
*/
|
|
||||||
export const debounce = (fn, wait = 300) => {
|
|
||||||
let t
|
|
||||||
return (...args) => (clearTimeout(t), t = setTimeout(() => fn(...args), wait))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 节流函数
|
|
||||||
* @param {Function} func - 要执行的函数
|
|
||||||
* @param {number} limit - 时间限制(毫秒)
|
|
||||||
*/
|
|
||||||
export const throttle = (fn, limit = 300) => {
|
|
||||||
let t
|
|
||||||
return (...args) => !t && (fn(...args), t = setTimeout(() => t = null, limit))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 深拷贝(使用原生 API)
|
|
||||||
* @param {any} obj - 要拷贝的对象
|
|
||||||
*/
|
|
||||||
export const deepClone = (obj) => structuredClone(obj)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 生成随机 Id
|
|
||||||
* @param {number} length - 长度
|
|
||||||
*/
|
|
||||||
export const generateId = (length = 8) =>
|
|
||||||
Math.random().toString(36).slice(2, 2 + length)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 本地存储工具
|
|
||||||
*/
|
|
||||||
export const storage = {
|
|
||||||
get: (key, defaultValue = null) => {
|
|
||||||
try {
|
|
||||||
const item = localStorage.getItem(key)
|
|
||||||
return item ? JSON.parse(item) : defaultValue
|
|
||||||
} catch { return defaultValue }
|
|
||||||
},
|
|
||||||
set: (key, value) => { try { localStorage.setItem(key, JSON.stringify(value)); return true } catch { return false } },
|
|
||||||
remove: (key) => { try { localStorage.removeItem(key); return true } catch { return false } },
|
|
||||||
clear: () => { try { localStorage.clear(); return true } catch { return false } }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 检测设备类型
|
|
||||||
*/
|
|
||||||
export const getDeviceType = () => {
|
|
||||||
const ua = navigator.userAgent
|
|
||||||
if (/(tablet|ipad|playbook|silk)|(android(?!.*mobi))/i.test(ua)) return 'tablet'
|
|
||||||
if (/Mobile|Android|iP(hone|od)|IEMobile|BlackBerry|Kindle|Silk-Accelerated|(hpw|web)OS|Opera M(obi|ini)/.test(ua)) return 'mobile'
|
|
||||||
return 'desktop'
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 复制到剪贴板
|
|
||||||
* @param {string} text - 要复制的文本
|
|
||||||
*/
|
|
||||||
export const copyToClipboard = (text) => navigator.clipboard.writeText(text).then(() => true).catch(() => false)
|
|
||||||
|
|
@ -202,8 +202,6 @@ const form = ref({ title: '', provider_id: null, model: '' })
|
||||||
const newMessage = ref('')
|
const newMessage = ref('')
|
||||||
const messagesContainer = ref(null)
|
const messagesContainer = ref(null)
|
||||||
const activeMessageId = ref(null)
|
const activeMessageId = ref(null)
|
||||||
let scrollObserver = null
|
|
||||||
const observedElements = new Set()
|
|
||||||
|
|
||||||
const editConv = ref(null)
|
const editConv = ref(null)
|
||||||
|
|
||||||
|
|
@ -314,7 +312,6 @@ onMounted(() => {
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
scrollObserver?.disconnect()
|
|
||||||
cleanup()
|
cleanup()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -385,9 +382,6 @@ onUnmounted(() => {
|
||||||
|
|
||||||
/* 聊天视图容器 */
|
/* 聊天视图容器 */
|
||||||
.chat-view-container { flex: 1; display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
.chat-view-container { flex: 1; display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
||||||
.chat-header { display: flex; justify-content: flex-end; padding: 8px; border-bottom: 1px solid var(--border-light); }
|
|
||||||
.btn-nav-toggle { background: none; border: none; font-size: 18px; cursor: pointer; padding: 4px 8px; border-radius: 4px; transition: background 0.15s; }
|
|
||||||
.btn-nav-toggle:hover { background: var(--bg-hover); }
|
|
||||||
.chat-messages { flex: 1; overflow-y: auto; padding: 0.75rem; }
|
.chat-messages { flex: 1; overflow-y: auto; padding: 0.75rem; }
|
||||||
.chat-message { display: flex; gap: 0.75rem; margin-bottom: 0.75rem; }
|
.chat-message { display: flex; gap: 0.75rem; margin-bottom: 0.75rem; }
|
||||||
.chat-message.user { flex-direction: row-reverse; }
|
.chat-message.user { flex-direction: row-reverse; }
|
||||||
|
|
@ -396,21 +390,10 @@ onUnmounted(() => {
|
||||||
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.6; } }
|
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.6; } }
|
||||||
.message-content { max-width: 80%; width: 80%; }
|
.message-content { max-width: 80%; width: 80%; }
|
||||||
.chat-message.user .message-content { max-width: 80%; width: auto; }
|
.chat-message.user .message-content { max-width: 80%; width: auto; }
|
||||||
.message-text { padding: 0.65rem 0.9rem; border-radius: 12px; font-size: 0.9rem; line-height: 1.5; background: var(--bg-secondary); color: var(--text-primary); word-break: break-word; }
|
|
||||||
.chat-message.user .message-text { background: var(--accent-primary); color: white; }
|
|
||||||
.chat-empty { flex: 1; display: flex; align-items: center; justify-content: center; color: var(--text-tertiary); font-size: 0.85rem; }
|
.chat-empty { flex: 1; display: flex; align-items: center; justify-content: center; color: var(--text-tertiary); font-size: 0.85rem; }
|
||||||
.loading-messages { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 2rem; color: var(--text-secondary); font-size: 0.85rem; }
|
.loading-messages { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 2rem; color: var(--text-secondary); font-size: 0.85rem; }
|
||||||
.loading-messages .spinner-small { margin-bottom: 0.5rem; }
|
.loading-messages .spinner-small { margin-bottom: 0.5rem; }
|
||||||
|
|
||||||
/* Markdown 内容样式 */
|
|
||||||
.message-text { line-height: 1.6; }
|
|
||||||
.message-text :deep(pre) { background: var(--bg-code); border-radius: 8px; padding: 0.75rem; overflow-x: auto; margin: 0.5rem 0; }
|
|
||||||
.message-text :deep(code) { background: var(--bg-code); padding: 0.15rem 0.35rem; border-radius: 4px; font-size: 0.85em; }
|
|
||||||
.message-text :deep(pre code) { background: none; padding: 0; }
|
|
||||||
.message-text :deep(p) { margin: 0.5rem 0; }
|
|
||||||
.message-text :deep(p:first-child) { margin-top: 0; }
|
|
||||||
.message-text :deep(p:last-child) { margin-bottom: 0; }
|
|
||||||
|
|
||||||
/* 聊天输入区 */
|
/* 聊天输入区 */
|
||||||
.chat-input-area { padding: 1rem; border-top: 1px solid var(--border-light); display: flex; gap: 0.75rem; }
|
.chat-input-area { padding: 1rem; border-top: 1px solid var(--border-light); display: flex; gap: 0.75rem; }
|
||||||
.chat-input { flex: 1; padding: 0.65rem 0.9rem; border: 1px solid var(--border-input); border-radius: 8px; background: var(--bg-input); color: var(--text-primary); font-size: 0.9rem; }
|
.chat-input { flex: 1; padding: 0.65rem 0.9rem; border: 1px solid var(--border-input); border-radius: 8px; background: var(--bg-input); color: var(--text-primary); font-size: 0.9rem; }
|
||||||
|
|
|
||||||
|
|
@ -9,17 +9,14 @@
|
||||||
|
|
||||||
<div class="stats-section">
|
<div class="stats-section">
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-icon"></div>
|
|
||||||
<div class="stat-number">{{ stats.conversations }}</div>
|
<div class="stat-number">{{ stats.conversations }}</div>
|
||||||
<div class="stat-label">会话</div>
|
<div class="stat-label">会话</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-icon"></div>
|
|
||||||
<div class="stat-number">{{ stats.tools }}</div>
|
<div class="stat-number">{{ stats.tools }}</div>
|
||||||
<div class="stat-label">工具</div>
|
<div class="stat-label">工具</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-icon"></div>
|
|
||||||
<div class="stat-number">{{ formatTokens(stats.totalTokens) }}</div>
|
<div class="stat-number">{{ formatTokens(stats.totalTokens) }}</div>
|
||||||
<div class="stat-label">Tokens</div>
|
<div class="stat-label">Tokens</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -613,8 +613,6 @@ onMounted(() => {
|
||||||
.section-title { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.75rem; padding: 0.5rem 0; }
|
.section-title { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.75rem; padding: 0.5rem 0; }
|
||||||
.section-icon { font-size: 1rem; }
|
.section-icon { font-size: 1rem; }
|
||||||
.section-text { font-size: 1rem; font-weight: 700; color: var(--text-primary); }
|
.section-text { font-size: 1rem; font-weight: 700; color: var(--text-primary); }
|
||||||
.btn-add { margin-left: auto; padding: 0.4rem 0.8rem; background: var(--accent-primary); color: white; border: none; border-radius: 6px; font-size: 0.8rem; cursor: pointer; transition: all 0.2s; }
|
|
||||||
.btn-add:hover { background: var(--accent-primary-hover); }
|
|
||||||
|
|
||||||
/* 设置卡片 */
|
/* 设置卡片 */
|
||||||
.settings-card { background: var(--bg-primary); border: 1px solid var(--border-light); border-radius: 12px; overflow: hidden; }
|
.settings-card { background: var(--bg-primary); border: 1px solid var(--border-light); border-radius: 12px; overflow: hidden; }
|
||||||
|
|
@ -648,7 +646,6 @@ textarea { width: 100%; padding: 0.75rem; border: 1px solid var(--border-input);
|
||||||
.name-col { width: 15%; min-width: 120px; }
|
.name-col { width: 15%; min-width: 120px; }
|
||||||
.info-col { width: 60%; min-width: 200px; }
|
.info-col { width: 60%; min-width: 200px; }
|
||||||
.switch-col { text-align: center; width: 80px; }
|
.switch-col { text-align: center; width: 80px; }
|
||||||
.action-col { text-align: center; width: 80px; }
|
|
||||||
.ops-col { width: 15%; min-width: 180px; text-align: center; }
|
.ops-col { width: 15%; min-width: 180px; text-align: center; }
|
||||||
|
|
||||||
/* Provider 单元格 */
|
/* Provider 单元格 */
|
||||||
|
|
@ -703,12 +700,6 @@ textarea { width: 100%; padding: 0.75rem; border: 1px solid var(--border-input);
|
||||||
.form-group label { display: block; margin-bottom: 0.5rem; font-weight: 500; color: var(--text-primary); font-size: 0.9rem; }
|
.form-group label { display: block; margin-bottom: 0.5rem; font-weight: 500; color: var(--text-primary); font-size: 0.9rem; }
|
||||||
.form-group input { width: 100%; padding: 0.65rem; border: 1px solid var(--border-input); border-radius: 8px; background: var(--bg-input); box-sizing: border-box; color: var(--text-primary); font-size: 0.9rem; }
|
.form-group input { width: 100%; padding: 0.65rem; border: 1px solid var(--border-input); border-radius: 8px; background: var(--bg-input); box-sizing: border-box; color: var(--text-primary); font-size: 0.9rem; }
|
||||||
.form-group .hint { font-size: 0.75rem; color: var(--text-tertiary); margin-top: 4px; display: block; }
|
.form-group .hint { font-size: 0.75rem; color: var(--text-tertiary); margin-top: 4px; display: block; }
|
||||||
.switch-card { display: flex; align-items: center; justify-content: space-between; padding: 1rem 1.25rem; background: var(--bg-secondary); border: 1px solid var(--border-light); border-radius: 10px; cursor: pointer; transition: all 0.2s; }
|
|
||||||
.switch-card:hover { border-color: var(--accent-primary); }
|
|
||||||
.switch-card.active { border-color: var(--accent-primary); background: var(--accent-primary-light); }
|
|
||||||
.switch-content { display: flex; flex-direction: column; gap: 0.25rem; }
|
|
||||||
.switch-title { font-weight: 600; color: var(--text-primary); }
|
|
||||||
.switch-desc { font-size: 0.8rem; color: var(--text-secondary); }
|
|
||||||
.optional { color: var(--text-tertiary); font-weight: normal; font-size: 0.8rem; }
|
.optional { color: var(--text-tertiary); font-weight: normal; font-size: 0.8rem; }
|
||||||
.error { color: var(--danger-color); background: var(--danger-bg); padding: 0.75rem; border-radius: 8px; margin-top: 0.75rem; font-size: 0.85rem; }
|
.error { color: var(--danger-color); background: var(--danger-bg); padding: 0.75rem; border-radius: 8px; margin-top: 0.75rem; font-size: 0.85rem; }
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue