refactor: 精简前端

This commit is contained in:
ViperEkura 2026-04-25 17:22:18 +08:00
parent 9b5766ba65
commit 71960aed6d
11 changed files with 1 additions and 475 deletions

View File

@ -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; }

View File

@ -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'

View File

@ -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 {

View File

@ -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}`)
} }

View File

@ -1,25 +1 @@
/**
* Luxx 前端工具库
* 合并了 composablesservices 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'

View File

@ -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
}
}

View File

@ -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()) : ''
/** /**
* 格式化令牌数 * 格式化令牌数
*/ */

View File

@ -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)

View File

@ -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; }

View File

@ -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>

View File

@ -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; }