chore: 更新UI 样式
This commit is contained in:
parent
17e1f25531
commit
f2866df2ea
|
|
@ -27,19 +27,24 @@
|
||||||
@send-message="sendMessage"
|
@send-message="sendMessage"
|
||||||
@delete-message="deleteMessage"
|
@delete-message="deleteMessage"
|
||||||
@regenerate-message="regenerateMessage"
|
@regenerate-message="regenerateMessage"
|
||||||
@toggle-settings="showSettings = true"
|
@toggle-settings="togglePanel('settings')"
|
||||||
@toggle-stats="showStats = true"
|
@toggle-stats="togglePanel('stats')"
|
||||||
@load-more-messages="loadMoreMessages"
|
@load-more-messages="loadMoreMessages"
|
||||||
@toggle-tools="updateToolsEnabled"
|
@toggle-tools="updateToolsEnabled"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SettingsPanel
|
<Transition name="fade">
|
||||||
v-if="showSettings"
|
<div v-if="showSettings" class="modal-overlay" @click.self="showSettings = false">
|
||||||
:visible="showSettings"
|
<div class="modal-content">
|
||||||
:conversation="currentConv"
|
<SettingsPanel
|
||||||
@close="showSettings = false"
|
:visible="showSettings"
|
||||||
@save="saveSettings"
|
:conversation="currentConv"
|
||||||
/>
|
@close="showSettings = false"
|
||||||
|
@save="saveSettings"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
<Transition name="fade">
|
<Transition name="fade">
|
||||||
<div v-if="showStats" class="modal-overlay" @click.self="showStats = false">
|
<div v-if="showStats" class="modal-overlay" @click.self="showStats = false">
|
||||||
|
|
@ -79,20 +84,42 @@ const streamThinking = ref('')
|
||||||
const streamToolCalls = ref([])
|
const streamToolCalls = ref([])
|
||||||
const streamProcessSteps = ref([])
|
const streamProcessSteps = ref([])
|
||||||
|
|
||||||
|
// 保存每个对话的流式状态
|
||||||
|
const streamStates = new Map()
|
||||||
|
|
||||||
|
// 重置当前流式状态(用于 sendMessage / regenerateMessage / onError)
|
||||||
function resetStreamState() {
|
function resetStreamState() {
|
||||||
streaming.value = false
|
streaming.value = false
|
||||||
streamContent.value = ''
|
streamContent.value = ''
|
||||||
streamThinking.value = ''
|
streamThinking.value = ''
|
||||||
streamToolCalls.value = []
|
streamToolCalls.value = []
|
||||||
streamProcessSteps.value = []
|
streamProcessSteps.value = []
|
||||||
currentStreamPromise = null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保存每个对话的流式状态
|
// 初始化流式状态(用于 sendMessage / regenerateMessage 开始时)
|
||||||
const streamStates = new Map()
|
function initStreamState() {
|
||||||
|
streaming.value = true
|
||||||
|
streamContent.value = ''
|
||||||
|
streamThinking.value = ''
|
||||||
|
streamToolCalls.value = []
|
||||||
|
streamProcessSteps.value = []
|
||||||
|
}
|
||||||
|
|
||||||
// 保存当前流式请求引用
|
// 辅助:更新当前对话或缓存的流式字段
|
||||||
let currentStreamPromise = null
|
// field: streamStates 中保存的字段名
|
||||||
|
// ref: 当前激活对话对应的 Vue ref
|
||||||
|
// valueOrUpdater: 静态值或 (current) => newValue
|
||||||
|
function updateStreamField(convId, field, ref, valueOrUpdater) {
|
||||||
|
const isCurrent = currentConvId.value === convId
|
||||||
|
const current = isCurrent ? ref.value : (streamStates.get(convId) || {})[field]
|
||||||
|
const newVal = typeof valueOrUpdater === 'function' ? valueOrUpdater(current) : valueOrUpdater
|
||||||
|
if (isCurrent) {
|
||||||
|
ref.value = newVal
|
||||||
|
} else {
|
||||||
|
const saved = streamStates.get(convId) || {}
|
||||||
|
streamStates.set(convId, { ...saved, [field]: newVal })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// -- UI state --
|
// -- UI state --
|
||||||
const showSettings = ref(false)
|
const showSettings = ref(false)
|
||||||
|
|
@ -100,6 +127,16 @@ const showStats = ref(false)
|
||||||
const toolsEnabled = ref(localStorage.getItem('tools_enabled') !== 'false') // 默认开启
|
const toolsEnabled = ref(localStorage.getItem('tools_enabled') !== 'false') // 默认开启
|
||||||
const currentProject = ref(null) // Current selected project
|
const currentProject = ref(null) // Current selected project
|
||||||
|
|
||||||
|
function togglePanel(panel) {
|
||||||
|
if (panel === 'settings') {
|
||||||
|
showSettings.value = !showSettings.value
|
||||||
|
if (showSettings.value) showStats.value = false
|
||||||
|
} else {
|
||||||
|
showStats.value = !showStats.value
|
||||||
|
if (showStats.value) showSettings.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const currentConv = computed(() =>
|
const currentConv = computed(() =>
|
||||||
conversations.value.find(c => c.id === currentConvId.value) || null
|
conversations.value.find(c => c.id === currentConvId.value) || null
|
||||||
)
|
)
|
||||||
|
|
@ -171,11 +208,7 @@ async function selectConversation(id) {
|
||||||
streamProcessSteps.value = savedState.streamProcessSteps
|
streamProcessSteps.value = savedState.streamProcessSteps
|
||||||
messages.value = savedState.messages || [] // 恢复消息列表
|
messages.value = savedState.messages || [] // 恢复消息列表
|
||||||
} else {
|
} else {
|
||||||
streaming.value = false
|
resetStreamState()
|
||||||
streamContent.value = ''
|
|
||||||
streamThinking.value = ''
|
|
||||||
streamToolCalls.value = []
|
|
||||||
streamProcessSteps.value = []
|
|
||||||
messages.value = []
|
messages.value = []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -214,70 +247,41 @@ function loadMoreMessages() {
|
||||||
function createStreamCallbacks(convId, { updateConvList = true } = {}) {
|
function createStreamCallbacks(convId, { updateConvList = true } = {}) {
|
||||||
return {
|
return {
|
||||||
onThinkingStart() {
|
onThinkingStart() {
|
||||||
if (currentConvId.value === convId) {
|
updateStreamField(convId, 'streamThinking', streamThinking, '')
|
||||||
streamThinking.value = ''
|
|
||||||
} else {
|
|
||||||
const saved = streamStates.get(convId) || {}
|
|
||||||
streamStates.set(convId, { ...saved, streamThinking: '' })
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onThinking(text) {
|
onThinking(text) {
|
||||||
if (currentConvId.value === convId) {
|
updateStreamField(convId, 'streamThinking', streamThinking, prev => (prev || '') + text)
|
||||||
streamThinking.value += text
|
|
||||||
} else {
|
|
||||||
const saved = streamStates.get(convId) || { streamThinking: '' }
|
|
||||||
streamStates.set(convId, { ...saved, streamThinking: (saved.streamThinking || '') + text })
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onMessage(text) {
|
onMessage(text) {
|
||||||
if (currentConvId.value === convId) {
|
updateStreamField(convId, 'streamContent', streamContent, prev => (prev || '') + text)
|
||||||
streamContent.value += text
|
|
||||||
} else {
|
|
||||||
const saved = streamStates.get(convId) || { streamContent: '' }
|
|
||||||
streamStates.set(convId, { ...saved, streamContent: (saved.streamContent || '') + text })
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onToolCalls(calls) {
|
onToolCalls(calls) {
|
||||||
if (currentConvId.value === convId) {
|
updateStreamField(convId, 'streamToolCalls', streamToolCalls, prev => [
|
||||||
streamToolCalls.value.push(...calls.map(c => ({ ...c, result: null })))
|
...(prev || []),
|
||||||
} else {
|
...calls.map(c => ({ ...c, result: null })),
|
||||||
const saved = streamStates.get(convId) || { streamToolCalls: [] }
|
])
|
||||||
const newCalls = [...(saved.streamToolCalls || []), ...calls.map(c => ({ ...c, result: null }))]
|
|
||||||
streamStates.set(convId, { ...saved, streamToolCalls: newCalls })
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onToolResult(result) {
|
onToolResult(result) {
|
||||||
if (currentConvId.value === convId) {
|
updateStreamField(convId, 'streamToolCalls', streamToolCalls, prev => {
|
||||||
const call = streamToolCalls.value.find(c => c.id === result.id)
|
const arr = prev ? [...prev] : []
|
||||||
|
const call = arr.find(c => c.id === result.id)
|
||||||
if (call) call.result = result.content
|
if (call) call.result = result.content
|
||||||
} else {
|
return arr
|
||||||
const saved = streamStates.get(convId) || { streamToolCalls: [] }
|
})
|
||||||
const call = saved.streamToolCalls?.find(c => c.id === result.id)
|
|
||||||
if (call) call.result = result.content
|
|
||||||
streamStates.set(convId, { ...saved })
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onProcessStep(step) {
|
onProcessStep(step) {
|
||||||
const idx = step.index
|
updateStreamField(convId, 'streamProcessSteps', streamProcessSteps, prev => {
|
||||||
if (currentConvId.value === convId) {
|
const steps = prev ? [...prev] : []
|
||||||
const newSteps = [...streamProcessSteps.value]
|
while (steps.length <= step.index) steps.push(null)
|
||||||
while (newSteps.length <= idx) newSteps.push(null)
|
steps[step.index] = step
|
||||||
newSteps[idx] = step
|
return steps
|
||||||
streamProcessSteps.value = newSteps
|
})
|
||||||
} else {
|
|
||||||
const saved = streamStates.get(convId) || { streamProcessSteps: [] }
|
|
||||||
const steps = [...(saved.streamProcessSteps || [])]
|
|
||||||
while (steps.length <= idx) steps.push(null)
|
|
||||||
steps[idx] = step
|
|
||||||
streamStates.set(convId, { ...saved, streamProcessSteps: steps })
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
async onDone(data) {
|
async onDone(data) {
|
||||||
streamStates.delete(convId)
|
streamStates.delete(convId)
|
||||||
|
|
||||||
if (currentConvId.value === convId) {
|
if (currentConvId.value === convId) {
|
||||||
streaming.value = false
|
streaming.value = false
|
||||||
currentStreamPromise = null
|
|
||||||
messages.value.push({
|
messages.value.push({
|
||||||
id: data.message_id,
|
id: data.message_id,
|
||||||
conversation_id: convId,
|
conversation_id: convId,
|
||||||
|
|
@ -343,13 +347,9 @@ async function sendMessage(data) {
|
||||||
}
|
}
|
||||||
messages.value.push(userMsg)
|
messages.value.push(userMsg)
|
||||||
|
|
||||||
streaming.value = true
|
initStreamState()
|
||||||
streamContent.value = ''
|
|
||||||
streamThinking.value = ''
|
|
||||||
streamToolCalls.value = []
|
|
||||||
streamProcessSteps.value = []
|
|
||||||
|
|
||||||
currentStreamPromise = messageApi.send(convId, { text, attachments, projectId: currentProject.value?.id }, {
|
messageApi.send(convId, { text, attachments, projectId: currentProject.value?.id }, {
|
||||||
toolsEnabled: toolsEnabled.value,
|
toolsEnabled: toolsEnabled.value,
|
||||||
...createStreamCallbacks(convId, { updateConvList: true }),
|
...createStreamCallbacks(convId, { updateConvList: true }),
|
||||||
})
|
})
|
||||||
|
|
@ -376,13 +376,9 @@ async function regenerateMessage(msgId) {
|
||||||
|
|
||||||
messages.value = messages.value.slice(0, msgIndex)
|
messages.value = messages.value.slice(0, msgIndex)
|
||||||
|
|
||||||
streaming.value = true
|
initStreamState()
|
||||||
streamContent.value = ''
|
|
||||||
streamThinking.value = ''
|
|
||||||
streamToolCalls.value = []
|
|
||||||
streamProcessSteps.value = []
|
|
||||||
|
|
||||||
currentStreamPromise = messageApi.regenerate(convId, msgId, {
|
messageApi.regenerate(convId, msgId, {
|
||||||
toolsEnabled: toolsEnabled.value,
|
toolsEnabled: toolsEnabled.value,
|
||||||
projectId: currentProject.value?.id,
|
projectId: currentProject.value?.id,
|
||||||
...createStreamCallbacks(convId, { updateConvList: false }),
|
...createStreamCallbacks(convId, { updateConvList: false }),
|
||||||
|
|
@ -447,24 +443,17 @@ onMounted(() => {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-overlay {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: var(--overlay-bg);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 200;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-content {
|
.modal-content {
|
||||||
background: var(--bg-primary);
|
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
width: 90%;
|
width: 90%;
|
||||||
max-width: 520px;
|
max-width: 520px;
|
||||||
max-height: 80vh;
|
max-height: 80vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
|
background: color-mix(in srgb, var(--bg-primary) 75%, transparent);
|
||||||
|
backdrop-filter: blur(40px);
|
||||||
|
-webkit-backdrop-filter: blur(40px);
|
||||||
|
border: 1px solid var(--border-medium);
|
||||||
|
box-shadow: 0 25px 60px rgba(0, 0, 0, 0.2);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,15 @@ const BASE = '/api'
|
||||||
// Cache for models list
|
// Cache for models list
|
||||||
let modelsCache = null
|
let modelsCache = null
|
||||||
|
|
||||||
|
function buildQueryParams(params) {
|
||||||
|
const sp = new URLSearchParams()
|
||||||
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
if (value != null && value !== '') sp.set(key, value)
|
||||||
|
}
|
||||||
|
const qs = sp.toString()
|
||||||
|
return qs ? `?${qs}` : ''
|
||||||
|
}
|
||||||
|
|
||||||
async function request(url, options = {}) {
|
async function request(url, options = {}) {
|
||||||
const res = await fetch(`${BASE}${url}`, {
|
const res = await fetch(`${BASE}${url}`, {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
|
@ -125,17 +134,13 @@ export const modelApi = {
|
||||||
|
|
||||||
export const statsApi = {
|
export const statsApi = {
|
||||||
getTokens(period = 'daily') {
|
getTokens(period = 'daily') {
|
||||||
return request(`/stats/tokens?period=${period}`)
|
return request(`/stats/tokens${buildQueryParams({ period })}`)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const conversationApi = {
|
export const conversationApi = {
|
||||||
list(cursor, limit = 20, projectId = null) {
|
list(cursor, limit = 20, projectId = null) {
|
||||||
const params = new URLSearchParams()
|
return request(`/conversations${buildQueryParams({ cursor, limit, project_id: projectId })}`)
|
||||||
if (cursor) params.set('cursor', cursor)
|
|
||||||
if (limit) params.set('limit', limit)
|
|
||||||
if (projectId) params.set('project_id', projectId)
|
|
||||||
return request(`/conversations?${params}`)
|
|
||||||
},
|
},
|
||||||
|
|
||||||
create(payload = {}) {
|
create(payload = {}) {
|
||||||
|
|
@ -163,10 +168,7 @@ export const conversationApi = {
|
||||||
|
|
||||||
export const messageApi = {
|
export const messageApi = {
|
||||||
list(convId, cursor, limit = 50) {
|
list(convId, cursor, limit = 50) {
|
||||||
const params = new URLSearchParams()
|
return request(`/conversations/${convId}/messages${buildQueryParams({ cursor, limit })}`)
|
||||||
if (cursor) params.set('cursor', cursor)
|
|
||||||
if (limit) params.set('limit', limit)
|
|
||||||
return request(`/conversations/${convId}/messages?${params}`)
|
|
||||||
},
|
},
|
||||||
|
|
||||||
send(convId, data, callbacks) {
|
send(convId, data, callbacks) {
|
||||||
|
|
@ -193,7 +195,7 @@ export const messageApi = {
|
||||||
|
|
||||||
export const projectApi = {
|
export const projectApi = {
|
||||||
list(userId) {
|
list(userId) {
|
||||||
return request(`/projects?user_id=${userId}`)
|
return request(`/projects${buildQueryParams({ user_id: userId })}`)
|
||||||
},
|
},
|
||||||
|
|
||||||
create(data) {
|
create(data) {
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,7 @@
|
||||||
ref="inputRef"
|
ref="inputRef"
|
||||||
:disabled="streaming"
|
:disabled="streaming"
|
||||||
:tools-enabled="toolsEnabled"
|
:tools-enabled="toolsEnabled"
|
||||||
@send="handleSend"
|
@send="$emit('sendMessage', $event)"
|
||||||
@toggle-tools="$emit('toggleTools', $event)"
|
@toggle-tools="$emit('toggleTools', $event)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -123,10 +123,6 @@ onMounted(async () => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function handleSend(data) {
|
|
||||||
emit('sendMessage', data)
|
|
||||||
}
|
|
||||||
|
|
||||||
function scrollToBottom(smooth = true) {
|
function scrollToBottom(smooth = true) {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
const el = scrollContainer.value
|
const el = scrollContainer.value
|
||||||
|
|
@ -149,8 +145,6 @@ watch(() => props.conversation?.id, () => {
|
||||||
nextTick(() => inputRef.value?.focus())
|
nextTick(() => inputRef.value?.focus())
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
defineExpose({ scrollToBottom })
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
@ -159,7 +153,10 @@ defineExpose({ scrollToBottom })
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
background: var(--bg-secondary);
|
background: color-mix(in srgb, var(--bg-secondary) 80%, transparent);
|
||||||
|
backdrop-filter: blur(30px);
|
||||||
|
-webkit-backdrop-filter: blur(30px);
|
||||||
|
border-left: 1px solid var(--border-light);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
@ -200,8 +197,9 @@ defineExpose({ scrollToBottom })
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 12px 24px;
|
padding: 12px 24px;
|
||||||
border-bottom: 1px solid var(--border-light);
|
border-bottom: 1px solid var(--border-light);
|
||||||
background: var(--bg-primary);
|
background: color-mix(in srgb, var(--bg-primary) 70%, transparent);
|
||||||
backdrop-filter: blur(8px);
|
backdrop-filter: blur(40px);
|
||||||
|
-webkit-backdrop-filter: blur(40px);
|
||||||
transition: background 0.2s, border-color 0.2s;
|
transition: background 0.2s, border-color 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -222,13 +220,6 @@ defineExpose({ scrollToBottom })
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge {
|
|
||||||
font-size: 11px;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 10px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model-badge {
|
.model-badge {
|
||||||
background: var(--accent-primary-medium);
|
background: var(--accent-primary-medium);
|
||||||
color: var(--accent-primary);
|
color: var(--accent-primary);
|
||||||
|
|
@ -253,23 +244,9 @@ defineExpose({ scrollToBottom })
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-icon {
|
.chat-actions .btn-icon {
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
border-radius: 8px;
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
transition: all 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-icon:hover {
|
|
||||||
background: var(--bg-hover);
|
|
||||||
color: var(--accent-primary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.messages-container {
|
.messages-container {
|
||||||
|
|
|
||||||
|
|
@ -55,8 +55,10 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, watch, onMounted, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { renderMarkdown, enhanceCodeBlocks } from '../utils/markdown'
|
import { renderMarkdown } from '../utils/markdown'
|
||||||
|
import { formatTime } from '../utils/format'
|
||||||
|
import { useCodeEnhancement } from '../composables/useCodeEnhancement'
|
||||||
import ProcessBlock from './ProcessBlock.vue'
|
import ProcessBlock from './ProcessBlock.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
|
|
@ -80,22 +82,7 @@ const renderedContent = computed(() => {
|
||||||
return renderMarkdown(props.text)
|
return renderMarkdown(props.text)
|
||||||
})
|
})
|
||||||
|
|
||||||
function enhanceCode() {
|
useCodeEnhancement(messageRef, renderedContent)
|
||||||
enhanceCodeBlocks(messageRef.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(renderedContent, () => {
|
|
||||||
enhanceCode()
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
enhanceCode()
|
|
||||||
})
|
|
||||||
|
|
||||||
function formatTime(iso) {
|
|
||||||
if (!iso) return ''
|
|
||||||
return new Date(iso).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
|
||||||
}
|
|
||||||
|
|
||||||
function copyContent() {
|
function copyContent() {
|
||||||
navigator.clipboard.writeText(props.text || '').catch(() => {})
|
navigator.clipboard.writeText(props.text || '').catch(() => {})
|
||||||
|
|
|
||||||
|
|
@ -72,8 +72,9 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch, nextTick, onMounted } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import { renderMarkdown, enhanceCodeBlocks } from '../utils/markdown'
|
import { renderMarkdown } from '../utils/markdown'
|
||||||
|
import { useCodeEnhancement } from '../composables/useCodeEnhancement'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
thinkingContent: { type: String, default: '' },
|
thinkingContent: { type: String, default: '' },
|
||||||
|
|
@ -90,17 +91,8 @@ watch(() => props.streaming, (v) => {
|
||||||
if (v) expandedKeys.value = {}
|
if (v) expandedKeys.value = {}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 增强 processBlock 内代码块
|
|
||||||
const processRef = ref(null)
|
const processRef = ref(null)
|
||||||
|
|
||||||
function enhanceCode() {
|
|
||||||
enhanceCodeBlocks(processRef.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
enhanceCode()
|
|
||||||
})
|
|
||||||
|
|
||||||
function toggleItem(key) {
|
function toggleItem(key) {
|
||||||
expandedKeys.value[key] = !expandedKeys.value[key]
|
expandedKeys.value[key] = !expandedKeys.value[key]
|
||||||
}
|
}
|
||||||
|
|
@ -230,9 +222,8 @@ const processItems = computed(() => {
|
||||||
return items
|
return items
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(processItems, () => {
|
// 增强 processBlock 内代码块(必须在 processItems 定义之后)
|
||||||
nextTick(() => enhanceCode())
|
useCodeEnhancement(processRef, processItems, { deep: true })
|
||||||
}, { deep: true })
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
|
||||||
|
|
@ -396,104 +396,11 @@ defineExpose({
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Modal styles */
|
/* Modal z-index override for nested modals */
|
||||||
.modal-overlay {
|
.modal-overlay {
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background: var(--overlay-bg);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal {
|
|
||||||
background: var(--bg-primary);
|
|
||||||
border-radius: 12px;
|
|
||||||
width: 90%;
|
|
||||||
max-width: 480px;
|
|
||||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 16px 20px;
|
|
||||||
border-bottom: 1px solid var(--border-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-header h3 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-close {
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
font-size: 24px;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-close:hover {
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-body {
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group label {
|
|
||||||
display: block;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-primary);
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group input,
|
|
||||||
.form-group textarea {
|
|
||||||
width: 100%;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border: 1px solid var(--border-input);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--bg-input);
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: 13px;
|
|
||||||
font-family: inherit;
|
|
||||||
transition: border-color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-with-action {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-with-action input {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-drop-zone {
|
.upload-drop-zone {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -525,94 +432,8 @@ defineExpose({
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-browse {
|
.form-group:last-child {
|
||||||
flex-shrink: 0;
|
margin-bottom: 0;
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
border: 1px solid var(--border-input);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 13px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-browse:hover {
|
|
||||||
background: var(--bg-hover);
|
|
||||||
color: var(--accent-primary);
|
|
||||||
border-color: var(--accent-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group input:focus,
|
|
||||||
.form-group textarea:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--accent-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group textarea {
|
|
||||||
resize: vertical;
|
|
||||||
min-height: 80px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-footer {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 16px 20px;
|
|
||||||
border-top: 1px solid var(--border-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary,
|
|
||||||
.btn-secondary,
|
|
||||||
.btn-danger {
|
|
||||||
flex: 1;
|
|
||||||
padding: 10px 16px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background: var(--accent-primary);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover:not(:disabled) {
|
|
||||||
background: var(--accent-primary-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary {
|
|
||||||
background: var(--bg-tertiary);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary:hover {
|
|
||||||
background: var(--bg-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger {
|
|
||||||
background: var(--danger-color);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger:hover:not(:disabled) {
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.warning {
|
.warning {
|
||||||
|
|
|
||||||
|
|
@ -1,104 +1,131 @@
|
||||||
<template>
|
<template>
|
||||||
<Transition name="fade">
|
<div class="settings-panel">
|
||||||
<div v-if="visible" class="settings-overlay" @click.self="$emit('close')">
|
<div class="settings-header">
|
||||||
<div class="settings-panel">
|
<div class="settings-title">
|
||||||
<div class="settings-header">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<h3>会话设置</h3>
|
<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||||
<button class="btn-close" @click="$emit('close')">
|
</svg>
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<h4>会话设置</h4>
|
||||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
</div>
|
||||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
<div class="header-actions">
|
||||||
</svg>
|
<div class="period-tabs">
|
||||||
|
<button
|
||||||
|
v-for="t in tabs"
|
||||||
|
:key="t.value"
|
||||||
|
:class="['tab', { active: activeTab === t.value }]"
|
||||||
|
@click="activeTab = t.value"
|
||||||
|
>
|
||||||
|
{{ t.label }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<button class="btn-close" @click="$emit('close')">
|
||||||
<div class="settings-body">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<div class="form-group">
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||||
<label>会话标题</label>
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||||
<input v-model="form.title" type="text" placeholder="输入标题..." />
|
</svg>
|
||||||
</div>
|
</button>
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label>模型</label>
|
|
||||||
<select v-model="form.model">
|
|
||||||
<option v-for="m in models" :key="m.id" :value="m.id">{{ m.name }}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label>系统提示词</label>
|
|
||||||
<textarea
|
|
||||||
v-model="form.system_prompt"
|
|
||||||
rows="4"
|
|
||||||
placeholder="设置 AI 的角色和行为..."
|
|
||||||
></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="form-group flex-1">
|
|
||||||
<label>
|
|
||||||
温度
|
|
||||||
<span class="value-display">{{ form.temperature.toFixed(1) }}</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
v-model.number="form.temperature"
|
|
||||||
type="range"
|
|
||||||
min="0"
|
|
||||||
max="2"
|
|
||||||
step="0.1"
|
|
||||||
/>
|
|
||||||
<div class="range-labels">
|
|
||||||
<span>精确</span>
|
|
||||||
<span>创意</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group flex-1">
|
|
||||||
<label>
|
|
||||||
最大 Token
|
|
||||||
<span class="value-display">{{ form.max_tokens }}</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
v-model.number="form.max_tokens"
|
|
||||||
type="range"
|
|
||||||
min="256"
|
|
||||||
max="65536"
|
|
||||||
step="256"
|
|
||||||
/>
|
|
||||||
<div class="range-labels">
|
|
||||||
<span>256</span>
|
|
||||||
<span>65536</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group toggle-group">
|
|
||||||
<label>启用思维链</label>
|
|
||||||
<button
|
|
||||||
class="toggle"
|
|
||||||
:class="{ on: form.thinking_enabled }"
|
|
||||||
@click="form.thinking_enabled = !form.thinking_enabled"
|
|
||||||
>
|
|
||||||
<span class="toggle-thumb"></span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="settings-divider"></div>
|
|
||||||
|
|
||||||
<div class="form-group toggle-group">
|
|
||||||
<label>夜间模式</label>
|
|
||||||
<button
|
|
||||||
class="toggle"
|
|
||||||
:class="{ on: isDark }"
|
|
||||||
@click="toggleTheme"
|
|
||||||
>
|
|
||||||
<span class="toggle-thumb"></span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
|
||||||
|
<div class="settings-body">
|
||||||
|
<!-- 基本设置 -->
|
||||||
|
<template v-if="activeTab === 'basic'">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>会话标题</label>
|
||||||
|
<input v-model="form.title" type="text" placeholder="输入标题..." />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>模型</label>
|
||||||
|
<select v-model="form.model">
|
||||||
|
<option v-for="m in models" :key="m.id" :value="m.id">{{ m.name }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>系统提示词</label>
|
||||||
|
<textarea
|
||||||
|
v-model="form.system_prompt"
|
||||||
|
rows="4"
|
||||||
|
placeholder="设置 AI 的角色和行为..."
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 模型参数 -->
|
||||||
|
<template v-if="activeTab === 'params'">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>
|
||||||
|
温度
|
||||||
|
<span class="value-display">{{ form.temperature.toFixed(1) }}</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model.number="form.temperature"
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="2"
|
||||||
|
step="0.1"
|
||||||
|
/>
|
||||||
|
<div class="range-labels">
|
||||||
|
<span>精确</span>
|
||||||
|
<span>创意</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>
|
||||||
|
最大 Token
|
||||||
|
<span class="value-display">{{ form.max_tokens.toLocaleString() }}</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model.number="form.max_tokens"
|
||||||
|
type="range"
|
||||||
|
min="256"
|
||||||
|
max="65536"
|
||||||
|
step="256"
|
||||||
|
/>
|
||||||
|
<div class="range-labels">
|
||||||
|
<span>256</span>
|
||||||
|
<span>65,536</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 偏好设置 -->
|
||||||
|
<template v-if="activeTab === 'prefs'">
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-info">
|
||||||
|
<span class="setting-label">启用思维链</span>
|
||||||
|
<span class="setting-desc">让模型展示推理过程</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="toggle"
|
||||||
|
:class="{ on: form.thinking_enabled }"
|
||||||
|
@click="form.thinking_enabled = !form.thinking_enabled"
|
||||||
|
>
|
||||||
|
<span class="toggle-thumb"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-info">
|
||||||
|
<span class="setting-label">夜间模式</span>
|
||||||
|
<span class="setting-desc">切换深色外观主题</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="toggle"
|
||||||
|
:class="{ on: isDark }"
|
||||||
|
@click="toggleTheme"
|
||||||
|
>
|
||||||
|
<span class="toggle-thumb"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="auto-save-hint">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/>
|
||||||
|
</svg>
|
||||||
|
<span>修改自动保存</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
|
@ -116,6 +143,14 @@ const emit = defineEmits(['close', 'save'])
|
||||||
const { isDark, toggleTheme } = useTheme()
|
const { isDark, toggleTheme } = useTheme()
|
||||||
const models = ref([])
|
const models = ref([])
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ value: 'basic', label: '基本' },
|
||||||
|
{ value: 'params', label: '参数' },
|
||||||
|
{ value: 'prefs', label: '偏好' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const activeTab = ref('basic')
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
title: '',
|
title: '',
|
||||||
model: '',
|
model: '',
|
||||||
|
|
@ -153,6 +188,7 @@ function syncFormFromConversation() {
|
||||||
// Sync form when panel opens or conversation changes
|
// Sync form when panel opens or conversation changes
|
||||||
watch([() => props.visible, () => props.conversation, models], () => {
|
watch([() => props.visible, () => props.conversation, models], () => {
|
||||||
if (props.visible) {
|
if (props.visible) {
|
||||||
|
activeTab.value = 'basic'
|
||||||
syncFormFromConversation()
|
syncFormFromConversation()
|
||||||
}
|
}
|
||||||
}, { deep: true })
|
}, { deep: true })
|
||||||
|
|
@ -180,148 +216,87 @@ onMounted(loadModels)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.settings-overlay {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: var(--overlay-bg);
|
|
||||||
z-index: 100;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-panel {
|
.settings-panel {
|
||||||
width: 90%;
|
|
||||||
max-width: 520px;
|
|
||||||
max-height: 85vh;
|
|
||||||
background: var(--bg-primary);
|
|
||||||
border-radius: 16px;
|
|
||||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-header {
|
.settings-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 20px 24px;
|
margin-bottom: 20px;
|
||||||
border-bottom: 1px solid var(--border-light);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-header h3 {
|
.settings-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-title svg {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-title h4 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
color: var(--text-primary);
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-close {
|
.header-actions {
|
||||||
background: none;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.period-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
background: var(--bg-input);
|
||||||
|
padding: 3px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
padding: 4px 12px;
|
||||||
border: none;
|
border: none;
|
||||||
|
background: none;
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
|
font-size: 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 4px;
|
border-radius: 6px;
|
||||||
border-radius: 4px;
|
transition: all 0.2s;
|
||||||
transition: all 0.15s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-close:hover {
|
.tab:hover {
|
||||||
color: var(--text-primary);
|
color: var(--text-secondary);
|
||||||
background: var(--bg-hover);
|
}
|
||||||
|
|
||||||
|
.tab.active {
|
||||||
|
background: var(--accent-primary);
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 1px 3px rgba(37, 99, 235, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-body {
|
.settings-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 24px;
|
display: flex;
|
||||||
overflow-y: auto;
|
flex-direction: column;
|
||||||
}
|
gap: 16px;
|
||||||
|
|
||||||
.form-group {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group label {
|
|
||||||
display: block;
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
margin-bottom: 8px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- Value display ---- */
|
||||||
.value-display {
|
.value-display {
|
||||||
float: right;
|
float: right;
|
||||||
color: var(--accent-primary);
|
color: var(--accent-primary);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group input[type="text"],
|
/* ---- Range labels ---- */
|
||||||
.form-group textarea,
|
|
||||||
.form-group select {
|
|
||||||
width: 100%;
|
|
||||||
padding: 10px 12px;
|
|
||||||
background: var(--bg-input);
|
|
||||||
border: 1px solid var(--border-input);
|
|
||||||
border-radius: 8px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: 14px;
|
|
||||||
font-family: inherit;
|
|
||||||
outline: none;
|
|
||||||
transition: border-color 0.2s, background 0.2s;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group input[type="text"]:focus,
|
|
||||||
.form-group textarea:focus,
|
|
||||||
.form-group select:focus {
|
|
||||||
border-color: var(--accent-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group textarea {
|
|
||||||
resize: vertical;
|
|
||||||
min-height: 80px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group select {
|
|
||||||
cursor: pointer;
|
|
||||||
appearance: none;
|
|
||||||
-webkit-appearance: none;
|
|
||||||
-moz-appearance: none;
|
|
||||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
background-position: right 12px center;
|
|
||||||
background-size: 16px;
|
|
||||||
padding-right: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group select:hover {
|
|
||||||
border-color: var(--accent-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group select:focus {
|
|
||||||
border-color: var(--accent-primary);
|
|
||||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group select option {
|
|
||||||
background: var(--bg-primary);
|
|
||||||
color: var(--text-primary);
|
|
||||||
padding: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="dark"] .form-group select {
|
|
||||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%23a0a0a0' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-row .form-group {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.range-labels {
|
.range-labels {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|
@ -330,16 +305,40 @@ onMounted(loadModels)
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle-group {
|
/* ---- Setting row (toggle items) ---- */
|
||||||
|
.setting-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 4px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle-group label {
|
.setting-row + .setting-row {
|
||||||
margin-bottom: 0;
|
margin-top: 4px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid var(--border-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.setting-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-label {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Toggle ---- */
|
||||||
.toggle {
|
.toggle {
|
||||||
width: 44px;
|
width: 44px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
|
|
@ -348,8 +347,9 @@ onMounted(loadModels)
|
||||||
background: var(--bg-code);
|
background: var(--bg-code);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
position: relative;
|
position: relative;
|
||||||
transition: background 0.2s;
|
transition: background 0.25s ease;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle.on {
|
.toggle.on {
|
||||||
|
|
@ -364,7 +364,7 @@ onMounted(loadModels)
|
||||||
height: 20px;
|
height: 20px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: white;
|
background: white;
|
||||||
transition: transform 0.2s;
|
transition: transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -372,9 +372,15 @@ onMounted(loadModels)
|
||||||
transform: translateX(20px);
|
transform: translateX(20px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-divider {
|
/* ---- Auto-save hint ---- */
|
||||||
height: 1px;
|
.auto-save-hint {
|
||||||
background: var(--border-light);
|
display: flex;
|
||||||
margin: 24px 0;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 12px 0 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,7 @@
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
import { formatTime } from '../utils/format'
|
||||||
import ProjectManager from './ProjectManager.vue'
|
import ProjectManager from './ProjectManager.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
|
|
@ -119,17 +120,6 @@ function onProjectDeleted(projectId) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTime(iso) {
|
|
||||||
if (!iso) return ''
|
|
||||||
const d = new Date(iso)
|
|
||||||
const now = new Date()
|
|
||||||
const isToday = d.toDateString() === now.toDateString()
|
|
||||||
if (isToday) {
|
|
||||||
return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
|
||||||
}
|
|
||||||
return d.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' })
|
|
||||||
}
|
|
||||||
|
|
||||||
function onScroll(e) {
|
function onScroll(e) {
|
||||||
const el = e.target
|
const el = e.target
|
||||||
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 50) {
|
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 50) {
|
||||||
|
|
@ -144,7 +134,9 @@ function onScroll(e) {
|
||||||
min-width: 220px;
|
min-width: 220px;
|
||||||
max-width: 320px;
|
max-width: 320px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
background: var(--bg-primary);
|
background: color-mix(in srgb, var(--bg-primary) 75%, transparent);
|
||||||
|
backdrop-filter: blur(40px);
|
||||||
|
-webkit-backdrop-filter: blur(40px);
|
||||||
border-right: 1px solid var(--border-medium);
|
border-right: 1px solid var(--border-medium);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
|
||||||
|
|
@ -223,6 +223,7 @@
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { statsApi } from '../api'
|
import { statsApi } from '../api'
|
||||||
import { useTheme } from '../composables/useTheme'
|
import { useTheme } from '../composables/useTheme'
|
||||||
|
import { formatNumber } from '../utils/format'
|
||||||
|
|
||||||
const emit = defineEmits(['close'])
|
const emit = defineEmits(['close'])
|
||||||
|
|
||||||
|
|
@ -320,13 +321,6 @@ function formatFullDate(dateStr) {
|
||||||
return `${d.getMonth() + 1}月${d.getDate()}日`
|
return `${d.getMonth() + 1}月${d.getDate()}日`
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatNumber(num) {
|
|
||||||
if (!num) return '0'
|
|
||||||
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M'
|
|
||||||
if (num >= 1000) return (num / 1000).toFixed(1) + 'K'
|
|
||||||
return num.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadStats() {
|
async function loadStats() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
|
|
@ -383,24 +377,6 @@ onMounted(loadStats)
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-close {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 4px;
|
|
||||||
border-radius: 6px;
|
|
||||||
transition: all 0.15s;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-close:hover {
|
|
||||||
color: var(--text-primary);
|
|
||||||
background: var(--bg-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.period-tabs {
|
.period-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { watch, onMounted, nextTick } from 'vue'
|
||||||
|
import { enhanceCodeBlocks } from '../utils/markdown'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable for enhancing code blocks in a container element.
|
||||||
|
* Automatically runs enhanceCodeBlocks on mount and when the dependency changes.
|
||||||
|
*
|
||||||
|
* @param {import('vue').Ref<HTMLElement|null>} templateRef - The ref to the container element
|
||||||
|
* @param {import('vue').Ref<any>} [dep] - Optional reactive dependency to trigger re-enhancement
|
||||||
|
* @param {import('vue').WatchOptions} [watchOpts] - Optional watch options (e.g. { deep: true })
|
||||||
|
*/
|
||||||
|
export function useCodeEnhancement(templateRef, dep, watchOpts) {
|
||||||
|
function enhance() {
|
||||||
|
enhanceCodeBlocks(templateRef.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(enhance)
|
||||||
|
|
||||||
|
if (dep) {
|
||||||
|
watch(dep, () => nextTick(enhance), watchOpts)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { enhance }
|
||||||
|
}
|
||||||
|
|
@ -83,6 +83,10 @@ html, body {
|
||||||
body {
|
body {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans SC', sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans SC', sans-serif;
|
||||||
background: var(--bg-tertiary);
|
background: var(--bg-tertiary);
|
||||||
|
background-image:
|
||||||
|
radial-gradient(ellipse at 20% 50%, var(--accent-primary-light) 0%, transparent 50%),
|
||||||
|
radial-gradient(ellipse at 80% 20%, rgba(168, 85, 247, 0.06) 0%, transparent 50%),
|
||||||
|
radial-gradient(ellipse at 60% 80%, rgba(52, 211, 153, 0.05) 0%, transparent 50%);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
transition: background 0.2s, color 0.2s;
|
transition: background 0.2s, color 0.2s;
|
||||||
|
|
@ -390,3 +394,197 @@ input[type="range"]::-moz-range-thumb {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============ Modal ============ */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: var(--overlay-bg);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-radius: 12px;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 480px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-top: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: all 0.15s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-close:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ Action Buttons ============ */
|
||||||
|
.btn-primary,
|
||||||
|
.btn-secondary,
|
||||||
|
.btn-danger {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent-primary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background: var(--accent-primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: var(--danger-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover:not(:disabled) {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ Form ============ */
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input,
|
||||||
|
.form-group textarea,
|
||||||
|
.form-group select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--border-input);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s, background 0.2s;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus,
|
||||||
|
.form-group textarea:focus,
|
||||||
|
.form-group select:focus {
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group select {
|
||||||
|
cursor: pointer;
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
-moz-appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 12px center;
|
||||||
|
background-size: 16px;
|
||||||
|
padding-right: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group select:hover {
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group select:focus {
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group select option {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .form-group select {
|
||||||
|
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%23a0a0a0' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row .form-group {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
/**
|
||||||
|
* Format ISO date string to a short time string.
|
||||||
|
* - Today: "14:30"
|
||||||
|
* - Other days: "03/26"
|
||||||
|
*/
|
||||||
|
export function formatTime(iso) {
|
||||||
|
if (!iso) return ''
|
||||||
|
const d = new Date(iso)
|
||||||
|
const now = new Date()
|
||||||
|
const isToday = d.toDateString() === now.toDateString()
|
||||||
|
if (isToday) {
|
||||||
|
return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
return d.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format number with K/M suffixes.
|
||||||
|
*/
|
||||||
|
export function formatNumber(num) {
|
||||||
|
if (!num) return '0'
|
||||||
|
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M'
|
||||||
|
if (num >= 1000) return (num / 1000).toFixed(1) + 'K'
|
||||||
|
return num.toString()
|
||||||
|
}
|
||||||
|
|
@ -101,18 +101,19 @@ export function enhanceCodeBlocks(container) {
|
||||||
langSpan.className = 'code-lang'
|
langSpan.className = 'code-lang'
|
||||||
langSpan.textContent = lang
|
langSpan.textContent = lang
|
||||||
|
|
||||||
|
const COPY_SVG = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>'
|
||||||
|
const CHECK_SVG = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"></polyline></svg>'
|
||||||
|
|
||||||
const copyBtn = document.createElement('button')
|
const copyBtn = document.createElement('button')
|
||||||
copyBtn.className = 'code-copy-btn'
|
copyBtn.className = 'code-copy-btn'
|
||||||
copyBtn.title = '复制'
|
copyBtn.title = '复制'
|
||||||
copyBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>'
|
copyBtn.innerHTML = COPY_SVG
|
||||||
|
|
||||||
const checkSvg = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"></polyline></svg>'
|
|
||||||
|
|
||||||
copyBtn.addEventListener('click', () => {
|
copyBtn.addEventListener('click', () => {
|
||||||
const raw = code?.textContent || ''
|
const raw = code?.textContent || ''
|
||||||
navigator.clipboard.writeText(raw).then(() => {
|
navigator.clipboard.writeText(raw).then(() => {
|
||||||
copyBtn.innerHTML = checkSvg
|
copyBtn.innerHTML = CHECK_SVG
|
||||||
setTimeout(() => { copyBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>' }, 1500)
|
setTimeout(() => { copyBtn.innerHTML = COPY_SVG }, 1500)
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
const ta = document.createElement('textarea')
|
const ta = document.createElement('textarea')
|
||||||
ta.value = raw
|
ta.value = raw
|
||||||
|
|
@ -122,8 +123,8 @@ export function enhanceCodeBlocks(container) {
|
||||||
ta.select()
|
ta.select()
|
||||||
document.execCommand('copy')
|
document.execCommand('copy')
|
||||||
document.body.removeChild(ta)
|
document.body.removeChild(ta)
|
||||||
copyBtn.innerHTML = checkSvg
|
copyBtn.innerHTML = CHECK_SVG
|
||||||
setTimeout(() => { copyBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>' }, 1500)
|
setTimeout(() => { copyBtn.innerHTML = COPY_SVG }, 1500)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue