fix: 修复前后端同步问题
This commit is contained in:
parent
6aea98554f
commit
836ee8ac9d
|
|
@ -1,29 +1,38 @@
|
||||||
"""Token statistics API routes"""
|
"""Token statistics API routes"""
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta, datetime, timezone
|
||||||
from flask import Blueprint, request, g
|
from flask import Blueprint, request, g
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func, extract
|
||||||
from backend.models import TokenUsage
|
from backend.models import TokenUsage, Message, Conversation
|
||||||
from backend.utils.helpers import ok, err
|
from backend.utils.helpers import ok, err
|
||||||
|
from backend import db
|
||||||
|
|
||||||
bp = Blueprint("stats", __name__)
|
bp = Blueprint("stats", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _utc_today():
|
||||||
|
"""Get today's date in UTC to match stored timestamps."""
|
||||||
|
return datetime.now(timezone.utc).date()
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/api/stats/tokens", methods=["GET"])
|
@bp.route("/api/stats/tokens", methods=["GET"])
|
||||||
def token_stats():
|
def token_stats():
|
||||||
"""Get token usage statistics"""
|
"""Get token usage statistics"""
|
||||||
user = g.current_user
|
user = g.current_user
|
||||||
period = request.args.get("period", "daily")
|
period = request.args.get("period", "daily")
|
||||||
|
|
||||||
today = date.today()
|
today = _utc_today()
|
||||||
|
|
||||||
if period == "daily":
|
if period == "daily":
|
||||||
stats = TokenUsage.query.filter_by(user_id=user.id, date=today).all()
|
stats = TokenUsage.query.filter_by(user_id=user.id, date=today).all()
|
||||||
|
# Hourly breakdown from Message table
|
||||||
|
hourly = _build_hourly_stats(user.id, today)
|
||||||
result = {
|
result = {
|
||||||
"period": "daily",
|
"period": "daily",
|
||||||
"date": today.isoformat(),
|
"date": today.isoformat(),
|
||||||
"prompt_tokens": sum(s.prompt_tokens for s in stats),
|
"prompt_tokens": sum(s.prompt_tokens for s in stats),
|
||||||
"completion_tokens": sum(s.completion_tokens for s in stats),
|
"completion_tokens": sum(s.completion_tokens for s in stats),
|
||||||
"total_tokens": sum(s.total_tokens for s in stats),
|
"total_tokens": sum(s.total_tokens for s in stats),
|
||||||
|
"hourly": hourly,
|
||||||
"by_model": {
|
"by_model": {
|
||||||
s.model: {
|
s.model: {
|
||||||
"prompt": s.prompt_tokens,
|
"prompt": s.prompt_tokens,
|
||||||
|
|
@ -92,3 +101,37 @@ def _build_period_result(stats, period, start_date, end_date, days):
|
||||||
"daily": daily_data,
|
"daily": daily_data,
|
||||||
"by_model": by_model,
|
"by_model": by_model,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_hourly_stats(user_id, day):
|
||||||
|
"""Build hourly token breakdown for a given day (UTC) from Message table."""
|
||||||
|
day_start = datetime.combine(day, datetime.min.time()).replace(tzinfo=timezone.utc)
|
||||||
|
day_end = datetime.combine(day, datetime.max.time()).replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
conv_ids = (
|
||||||
|
db.session.query(Conversation.id)
|
||||||
|
.filter(Conversation.user_id == user_id)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = (
|
||||||
|
db.session.query(
|
||||||
|
extract("hour", Message.created_at).label("hour"),
|
||||||
|
func.sum(Message.token_count).label("total"),
|
||||||
|
)
|
||||||
|
.filter(
|
||||||
|
Message.conversation_id.in_(conv_ids),
|
||||||
|
Message.role == "assistant",
|
||||||
|
Message.created_at >= day_start,
|
||||||
|
Message.created_at <= day_end,
|
||||||
|
)
|
||||||
|
.group_by(extract("hour", Message.created_at))
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
hourly = {}
|
||||||
|
for r in rows:
|
||||||
|
h = int(r.hour)
|
||||||
|
hourly[str(h)] = {"total": r.total or 0}
|
||||||
|
|
||||||
|
return hourly
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,8 @@ Usage:
|
||||||
result = registry.execute("web_search", {"query": "Python"})
|
result = registry.execute("web_search", {"query": "Python"})
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from backend.tools.core import ToolDefinition, ToolResult, ToolRegistry, registry
|
from backend.tools.core import registry
|
||||||
from backend.tools.factory import tool, register_tool
|
from backend.tools.factory import tool
|
||||||
from backend.tools.executor import ToolExecutor
|
from backend.tools.executor import ToolExecutor
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -47,16 +47,12 @@ def init_tools() -> None:
|
||||||
|
|
||||||
# Public API exports
|
# Public API exports
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# Core classes
|
|
||||||
"ToolDefinition",
|
|
||||||
"ToolResult",
|
|
||||||
"ToolRegistry",
|
|
||||||
"ToolExecutor",
|
|
||||||
# Instances
|
# Instances
|
||||||
"registry",
|
"registry",
|
||||||
# Factory functions
|
# Factory functions
|
||||||
"tool",
|
"tool",
|
||||||
"register_tool",
|
# Classes
|
||||||
|
"ToolExecutor",
|
||||||
# Initialization
|
# Initialization
|
||||||
"init_tools",
|
"init_tools",
|
||||||
# Service locator
|
# Service locator
|
||||||
|
|
|
||||||
|
|
@ -51,10 +51,6 @@ class ToolExecutor:
|
||||||
return record["result"]
|
return record["result"]
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def clear_history(self) -> None:
|
|
||||||
"""Clear call history (call this at start of new conversation turn)"""
|
|
||||||
self._call_history.clear()
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _inject_context(name: str, args: dict, context: Optional[dict]) -> None:
|
def _inject_context(name: str, args: dict, context: Optional[dict]) -> None:
|
||||||
"""Inject context fields into tool arguments in-place.
|
"""Inject context fields into tool arguments in-place.
|
||||||
|
|
|
||||||
|
|
@ -34,30 +34,3 @@ def tool(
|
||||||
return func
|
return func
|
||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
def register_tool(
|
|
||||||
name: str,
|
|
||||||
handler: Callable,
|
|
||||||
description: str,
|
|
||||||
parameters: dict,
|
|
||||||
category: str = "general"
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Register a tool directly (without decorator)
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
register_tool(
|
|
||||||
name="my_tool",
|
|
||||||
handler=my_function,
|
|
||||||
description="Description",
|
|
||||||
parameters={...}
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
tool_def = ToolDefinition(
|
|
||||||
name=name,
|
|
||||||
description=description,
|
|
||||||
parameters=parameters,
|
|
||||||
handler=handler,
|
|
||||||
category=category
|
|
||||||
)
|
|
||||||
registry.register(tool_def)
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
"""Backend utilities"""
|
"""Backend utilities"""
|
||||||
from backend.utils.helpers import ok, err, to_dict, get_or_create_default_user, record_token_usage, build_messages
|
from backend.utils.helpers import ok, err, to_dict, message_to_dict, record_token_usage, build_messages
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"ok",
|
"ok",
|
||||||
"err",
|
"err",
|
||||||
"to_dict",
|
"to_dict",
|
||||||
"get_or_create_default_user",
|
"message_to_dict",
|
||||||
"record_token_usage",
|
"record_token_usage",
|
||||||
"build_messages",
|
"build_messages",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@
|
||||||
"@codemirror/lang-xml": "^6.1.0",
|
"@codemirror/lang-xml": "^6.1.0",
|
||||||
"@codemirror/lang-yaml": "^6.1.2",
|
"@codemirror/lang-yaml": "^6.1.2",
|
||||||
"@codemirror/theme-one-dark": "^6.1.2",
|
"@codemirror/theme-one-dark": "^6.1.2",
|
||||||
|
"chart.js": "^4.5.1",
|
||||||
"codemirror": "^6.0.1",
|
"codemirror": "^6.0.1",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"katex": "^0.16.40",
|
"katex": "^0.16.40",
|
||||||
|
|
@ -791,6 +792,12 @@
|
||||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@kurkle/color": {
|
||||||
|
"version": "0.3.4",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@kurkle/color/-/color-0.3.4.tgz",
|
||||||
|
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@lezer/common": {
|
"node_modules/@lezer/common": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"resolved": "https://registry.npmmirror.com/@lezer/common/-/common-1.5.1.tgz",
|
"resolved": "https://registry.npmmirror.com/@lezer/common/-/common-1.5.1.tgz",
|
||||||
|
|
@ -1430,6 +1437,18 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Python-2.0"
|
"license": "Python-2.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/chart.js": {
|
||||||
|
"version": "4.5.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/chart.js/-/chart.js-4.5.1.tgz",
|
||||||
|
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@kurkle/color": "^0.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"pnpm": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/codemirror": {
|
"node_modules/codemirror": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmmirror.com/codemirror/-/codemirror-6.0.2.tgz",
|
"resolved": "https://registry.npmmirror.com/codemirror/-/codemirror-6.0.2.tgz",
|
||||||
|
|
|
||||||
|
|
@ -9,21 +9,22 @@
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"codemirror": "^6.0.1",
|
|
||||||
"@codemirror/theme-one-dark": "^6.1.2",
|
|
||||||
"@codemirror/lang-markdown": "^6.3.2",
|
|
||||||
"@codemirror/lang-javascript": "^6.2.3",
|
|
||||||
"@codemirror/lang-python": "^6.1.7",
|
|
||||||
"@codemirror/lang-html": "^6.4.9",
|
|
||||||
"@codemirror/lang-css": "^6.3.1",
|
|
||||||
"@codemirror/lang-json": "^6.0.1",
|
|
||||||
"@codemirror/lang-yaml": "^6.1.2",
|
|
||||||
"@codemirror/lang-java": "^6.0.1",
|
|
||||||
"@codemirror/lang-cpp": "^6.0.2",
|
"@codemirror/lang-cpp": "^6.0.2",
|
||||||
"@codemirror/lang-rust": "^6.0.1",
|
"@codemirror/lang-css": "^6.3.1",
|
||||||
"@codemirror/lang-go": "^6.0.1",
|
"@codemirror/lang-go": "^6.0.1",
|
||||||
|
"@codemirror/lang-html": "^6.4.9",
|
||||||
|
"@codemirror/lang-java": "^6.0.1",
|
||||||
|
"@codemirror/lang-javascript": "^6.2.3",
|
||||||
|
"@codemirror/lang-json": "^6.0.1",
|
||||||
|
"@codemirror/lang-markdown": "^6.3.2",
|
||||||
|
"@codemirror/lang-python": "^6.1.7",
|
||||||
|
"@codemirror/lang-rust": "^6.0.1",
|
||||||
"@codemirror/lang-sql": "^6.8.0",
|
"@codemirror/lang-sql": "^6.8.0",
|
||||||
"@codemirror/lang-xml": "^6.1.0",
|
"@codemirror/lang-xml": "^6.1.0",
|
||||||
|
"@codemirror/lang-yaml": "^6.1.2",
|
||||||
|
"@codemirror/theme-one-dark": "^6.1.2",
|
||||||
|
"chart.js": "^4.5.1",
|
||||||
|
"codemirror": "^6.0.1",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"katex": "^0.16.40",
|
"katex": "^0.16.40",
|
||||||
"marked": "^15.0.12",
|
"marked": "^15.0.12",
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@
|
||||||
:messages="messages"
|
:messages="messages"
|
||||||
:streaming="streaming"
|
:streaming="streaming"
|
||||||
:streaming-process-steps="streamProcessSteps"
|
:streaming-process-steps="streamProcessSteps"
|
||||||
|
:model-name-map="modelNameMap"
|
||||||
:has-more-messages="hasMoreMessages"
|
:has-more-messages="hasMoreMessages"
|
||||||
:loading-more="loadingMessages"
|
:loading-more="loadingMessages"
|
||||||
:tools-enabled="toolsEnabled"
|
:tools-enabled="toolsEnabled"
|
||||||
|
|
@ -59,8 +60,11 @@
|
||||||
<div v-if="showSettings" class="modal-overlay" @click.self="showSettings = false">
|
<div v-if="showSettings" class="modal-overlay" @click.self="showSettings = false">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<SettingsPanel
|
<SettingsPanel
|
||||||
|
:key="currentConvId || '__none__'"
|
||||||
:visible="showSettings"
|
:visible="showSettings"
|
||||||
:conversation="currentConv"
|
:conversation="currentConv"
|
||||||
|
:models="models"
|
||||||
|
:default-model="defaultModel"
|
||||||
@close="showSettings = false"
|
@close="showSettings = false"
|
||||||
@save="saveSettings"
|
@save="saveSettings"
|
||||||
/>
|
/>
|
||||||
|
|
@ -120,10 +124,29 @@ import { useModal } from './composables/useModal'
|
||||||
|
|
||||||
const SettingsPanel = defineAsyncComponent(() => import('./components/SettingsPanel.vue'))
|
const SettingsPanel = defineAsyncComponent(() => import('./components/SettingsPanel.vue'))
|
||||||
const StatsPanel = defineAsyncComponent(() => import('./components/StatsPanel.vue'))
|
const StatsPanel = defineAsyncComponent(() => import('./components/StatsPanel.vue'))
|
||||||
import { conversationApi, messageApi, projectApi } from './api'
|
import { conversationApi, messageApi, projectApi, modelApi } from './api'
|
||||||
|
|
||||||
const modal = useModal()
|
const modal = useModal()
|
||||||
|
|
||||||
|
// -- Models state (preloaded) --
|
||||||
|
const models = ref([])
|
||||||
|
const modelNameMap = ref({})
|
||||||
|
const defaultModel = computed(() => models.value.length > 0 ? models.value[0].id : '')
|
||||||
|
|
||||||
|
async function loadModels() {
|
||||||
|
try {
|
||||||
|
const res = await modelApi.getCached()
|
||||||
|
models.value = res.data || []
|
||||||
|
const map = {}
|
||||||
|
for (const m of models.value) {
|
||||||
|
if (m.id && m.name) map[m.id] = m.name
|
||||||
|
}
|
||||||
|
modelNameMap.value = map
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load models:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// -- Conversations state --
|
// -- Conversations state --
|
||||||
const conversations = shallowRef([])
|
const conversations = shallowRef([])
|
||||||
const currentConvId = ref(null)
|
const currentConvId = ref(null)
|
||||||
|
|
@ -258,6 +281,7 @@ async function createConversationInProject(project) {
|
||||||
const res = await conversationApi.create({
|
const res = await conversationApi.create({
|
||||||
title: '新对话',
|
title: '新对话',
|
||||||
project_id: project.id || null,
|
project_id: project.id || null,
|
||||||
|
model: defaultModel.value || undefined,
|
||||||
})
|
})
|
||||||
conversations.value = [res.data, ...conversations.value]
|
conversations.value = [res.data, ...conversations.value]
|
||||||
await selectConversation(res.data.id)
|
await selectConversation(res.data.id)
|
||||||
|
|
@ -602,7 +626,8 @@ async function deleteProject(project) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Init --
|
// -- Init --
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
|
await loadModels()
|
||||||
loadProjects()
|
loadProjects()
|
||||||
loadConversations()
|
loadConversations()
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -79,13 +79,13 @@ import MessageBubble from './MessageBubble.vue'
|
||||||
import MessageInput from './MessageInput.vue'
|
import MessageInput from './MessageInput.vue'
|
||||||
import MessageNav from './MessageNav.vue'
|
import MessageNav from './MessageNav.vue'
|
||||||
import ProcessBlock from './ProcessBlock.vue'
|
import ProcessBlock from './ProcessBlock.vue'
|
||||||
import { modelApi } from '../api'
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
conversation: { type: Object, default: null },
|
conversation: { type: Object, default: null },
|
||||||
messages: { type: Array, required: true },
|
messages: { type: Array, required: true },
|
||||||
streaming: { type: Boolean, default: false },
|
streaming: { type: Boolean, default: false },
|
||||||
streamingProcessSteps: { type: Array, default: () => [] },
|
streamingProcessSteps: { type: Array, default: () => [] },
|
||||||
|
modelNameMap: { type: Object, default: () => ({}) },
|
||||||
hasMoreMessages: { type: Boolean, default: false },
|
hasMoreMessages: { type: Boolean, default: false },
|
||||||
loadingMore: { type: Boolean, default: false },
|
loadingMore: { type: Boolean, default: false },
|
||||||
toolsEnabled: { type: Boolean, default: true },
|
toolsEnabled: { type: Boolean, default: true },
|
||||||
|
|
@ -95,27 +95,15 @@ const emit = defineEmits(['sendMessage', 'stopStreaming', 'deleteMessage', 'rege
|
||||||
|
|
||||||
const scrollContainer = ref(null)
|
const scrollContainer = ref(null)
|
||||||
const inputRef = ref(null)
|
const inputRef = ref(null)
|
||||||
const modelNameMap = ref({})
|
|
||||||
const activeMessageId = ref(null)
|
const activeMessageId = ref(null)
|
||||||
let scrollObserver = null
|
let scrollObserver = null
|
||||||
const observedElements = new WeakSet()
|
const observedElements = new WeakSet()
|
||||||
|
|
||||||
function formatModelName(modelId) {
|
function formatModelName(modelId) {
|
||||||
return modelNameMap.value[modelId] || modelId
|
return props.modelNameMap[modelId] || modelId
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(() => {
|
||||||
try {
|
|
||||||
const res = await modelApi.getCached()
|
|
||||||
const map = {}
|
|
||||||
for (const m of res.data) {
|
|
||||||
if (m.id && m.name) map[m.id] = m.name
|
|
||||||
}
|
|
||||||
modelNameMap.value = map
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Failed to load model names:', e)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (scrollContainer.value) {
|
if (scrollContainer.value) {
|
||||||
scrollObserver = new IntersectionObserver(
|
scrollObserver = new IntersectionObserver(
|
||||||
(entries) => {
|
(entries) => {
|
||||||
|
|
@ -257,16 +245,6 @@ watch(() => props.conversation?.id, () => {
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.thinking-badge {
|
|
||||||
background: rgba(245, 158, 11, 0.12);
|
|
||||||
color: #d97706;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="dark"] .thinking-badge {
|
|
||||||
background: rgba(245, 158, 11, 0.18);
|
|
||||||
color: #fbbf24;
|
|
||||||
}
|
|
||||||
|
|
||||||
.messages-container {
|
.messages-container {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|
@ -313,4 +291,5 @@ watch(() => props.conversation?.id, () => {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -123,19 +123,20 @@
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { reactive, ref, watch, onMounted } from 'vue'
|
import { reactive, ref, watch, onMounted } from 'vue'
|
||||||
import { modelApi, conversationApi } from '../api'
|
import { conversationApi } from '../api'
|
||||||
import { useTheme } from '../composables/useTheme'
|
import { useTheme } from '../composables/useTheme'
|
||||||
import { icons } from '../utils/icons'
|
import { icons } from '../utils/icons'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
visible: { type: Boolean, default: false },
|
visible: { type: Boolean, default: false },
|
||||||
conversation: { type: Object, default: null },
|
conversation: { type: Object, default: null },
|
||||||
|
models: { type: Array, default: () => [] },
|
||||||
|
defaultModel: { type: String, default: '' },
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['close', 'save'])
|
const emit = defineEmits(['close', 'save'])
|
||||||
|
|
||||||
const { isDark, toggleTheme } = useTheme()
|
const { isDark, toggleTheme } = useTheme()
|
||||||
const models = ref([])
|
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ value: 'basic', label: '基本' },
|
{ value: 'basic', label: '基本' },
|
||||||
|
|
@ -154,15 +155,6 @@ const form = reactive({
|
||||||
thinking_enabled: false,
|
thinking_enabled: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
async function loadModels() {
|
|
||||||
try {
|
|
||||||
const res = await modelApi.getCached()
|
|
||||||
models.value = res.data || []
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to load models:', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncFormFromConversation() {
|
function syncFormFromConversation() {
|
||||||
if (props.conversation) {
|
if (props.conversation) {
|
||||||
form.title = props.conversation.title || ''
|
form.title = props.conversation.title || ''
|
||||||
|
|
@ -170,27 +162,61 @@ function syncFormFromConversation() {
|
||||||
form.temperature = props.conversation.temperature ?? 1.0
|
form.temperature = props.conversation.temperature ?? 1.0
|
||||||
form.max_tokens = props.conversation.max_tokens ?? 65536
|
form.max_tokens = props.conversation.max_tokens ?? 65536
|
||||||
form.thinking_enabled = props.conversation.thinking_enabled ?? false
|
form.thinking_enabled = props.conversation.thinking_enabled ?? false
|
||||||
// model: 优先使用 conversation 的值,其次 models 列表第一个
|
// model: 优先使用 conversation 的值,其次 defaultModel,最后 models 列表第一个
|
||||||
if (props.conversation.model) {
|
if (props.conversation.model) {
|
||||||
form.model = props.conversation.model
|
form.model = props.conversation.model
|
||||||
} else if (models.value.length > 0) {
|
} else if (props.defaultModel) {
|
||||||
form.model = models.value[0].id
|
form.model = props.defaultModel
|
||||||
|
} else if (props.models.length > 0) {
|
||||||
|
form.model = props.models[0].id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sync form when panel opens or conversation changes
|
// Track which conversation the form is synced to, to avoid saving stale data
|
||||||
watch([() => props.visible, () => props.conversation, models], () => {
|
let syncedConvId = null
|
||||||
if (props.visible) {
|
let isSyncing = false
|
||||||
activeTab.value = 'basic'
|
|
||||||
syncFormFromConversation()
|
|
||||||
}
|
|
||||||
}, { deep: true })
|
|
||||||
|
|
||||||
// Auto-save with debounce when form changes
|
function doSync() {
|
||||||
|
if (!props.conversation) return
|
||||||
|
isSyncing = true
|
||||||
|
syncFormFromConversation()
|
||||||
|
syncedConvId = props.conversation.id
|
||||||
|
// Defer resetting flag to after all watchers flush
|
||||||
|
setTimeout(() => { isSyncing = false }, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync form when panel opens or conversation switches
|
||||||
|
watch([() => props.visible, () => props.conversation?.id, () => props.models, () => props.defaultModel], () => {
|
||||||
|
if (props.visible && props.conversation) {
|
||||||
|
activeTab.value = 'basic'
|
||||||
|
if (saveTimer) clearTimeout(saveTimer)
|
||||||
|
saveTimer = null
|
||||||
|
doSync()
|
||||||
|
} else if (!props.visible) {
|
||||||
|
syncedConvId = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sync when conversation data updates (e.g. auto-generated title after stream)
|
||||||
|
watch(
|
||||||
|
() => props.conversation,
|
||||||
|
(conv) => {
|
||||||
|
if (!props.visible || !conv || syncedConvId !== conv.id) return
|
||||||
|
doSync()
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
// Initial sync on mount (component may be recreated via :key)
|
||||||
|
onMounted(() => {
|
||||||
|
if (props.visible && props.conversation) doSync()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Auto-save with debounce when user edits form
|
||||||
let saveTimer = null
|
let saveTimer = null
|
||||||
watch(form, () => {
|
watch(form, () => {
|
||||||
if (props.visible && props.conversation) {
|
if (props.visible && props.conversation && syncedConvId === props.conversation.id && !isSyncing) {
|
||||||
if (saveTimer) clearTimeout(saveTimer)
|
if (saveTimer) clearTimeout(saveTimer)
|
||||||
saveTimer = setTimeout(saveChanges, 500)
|
saveTimer = setTimeout(saveChanges, 500)
|
||||||
}
|
}
|
||||||
|
|
@ -205,8 +231,6 @@ async function saveChanges() {
|
||||||
console.error('Failed to save settings:', e)
|
console.error('Failed to save settings:', e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(loadModels)
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
|
||||||
|
|
@ -60,107 +60,10 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 趋势图 -->
|
<!-- 趋势图 -->
|
||||||
<div v-if="period !== 'daily' && stats.daily && chartData.length > 0" class="stats-chart">
|
<div v-if="chartData.length > 0" class="stats-chart">
|
||||||
<div class="chart-title">每日趋势</div>
|
<div class="chart-title">{{ period === 'daily' ? '今日趋势' : '每日趋势' }}</div>
|
||||||
<div class="chart-container">
|
<div class="chart-container">
|
||||||
<svg class="line-chart" :viewBox="`0 0 ${chartWidth} ${chartHeight}`">
|
<canvas ref="chartCanvas"></canvas>
|
||||||
<defs>
|
|
||||||
<linearGradient id="areaGradient" x1="0%" y1="0%" x2="0%" y2="100%">
|
|
||||||
<stop offset="0%" :stop-color="accentColor" stop-opacity="0.25"/>
|
|
||||||
<stop offset="100%" :stop-color="accentColor" stop-opacity="0.02"/>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<!-- 网格线 -->
|
|
||||||
<line
|
|
||||||
v-for="i in 4"
|
|
||||||
:key="'grid-' + i"
|
|
||||||
:x1="padding"
|
|
||||||
:y1="padding + (chartHeight - 2 * padding) * (i - 1) / 3"
|
|
||||||
:x2="chartWidth - padding"
|
|
||||||
:y2="padding + (chartHeight - 2 * padding) * (i - 1) / 3"
|
|
||||||
stroke="var(--border-light)"
|
|
||||||
stroke-dasharray="3,3"
|
|
||||||
/>
|
|
||||||
<!-- Y轴标签 -->
|
|
||||||
<text
|
|
||||||
v-for="i in 4"
|
|
||||||
:key="'yl-' + i"
|
|
||||||
:x="padding - 4"
|
|
||||||
:y="padding + (chartHeight - 2 * padding) * (i - 1) / 3 + 3"
|
|
||||||
text-anchor="end"
|
|
||||||
class="y-label"
|
|
||||||
>{{ formatNumber(maxValue - (maxValue * (i - 1)) / 3) }}</text>
|
|
||||||
<!-- 填充区域 -->
|
|
||||||
<path :d="areaPath" fill="url(#areaGradient)"/>
|
|
||||||
<!-- 折线 -->
|
|
||||||
<path
|
|
||||||
:d="linePath"
|
|
||||||
fill="none"
|
|
||||||
:stroke="accentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
/>
|
|
||||||
<!-- 数据点 -->
|
|
||||||
<circle
|
|
||||||
v-for="(point, idx) in chartPoints"
|
|
||||||
:key="idx"
|
|
||||||
:cx="point.x"
|
|
||||||
:cy="point.y"
|
|
||||||
r="3"
|
|
||||||
:fill="accentColor"
|
|
||||||
stroke="var(--bg-primary)"
|
|
||||||
stroke-width="2"
|
|
||||||
class="data-point"
|
|
||||||
@mouseenter="hoveredPoint = idx"
|
|
||||||
@mouseleave="hoveredPoint = null"
|
|
||||||
/>
|
|
||||||
<!-- 竖线指示 -->
|
|
||||||
<line
|
|
||||||
v-if="hoveredPoint !== null && chartPoints[hoveredPoint]"
|
|
||||||
:x1="chartPoints[hoveredPoint].x"
|
|
||||||
:y1="padding"
|
|
||||||
:x2="chartPoints[hoveredPoint].x"
|
|
||||||
:y2="chartHeight - padding"
|
|
||||||
stroke="var(--border-medium)"
|
|
||||||
stroke-dasharray="3,3"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<!-- X轴标签 -->
|
|
||||||
<div class="x-labels">
|
|
||||||
<span
|
|
||||||
v-for="(point, idx) in chartPoints"
|
|
||||||
:key="idx"
|
|
||||||
class="x-label"
|
|
||||||
:class="{ active: hoveredPoint === idx }"
|
|
||||||
>
|
|
||||||
{{ formatDateLabel(point.date) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 悬浮提示 -->
|
|
||||||
<Transition name="fade">
|
|
||||||
<div
|
|
||||||
v-if="hoveredPoint !== null && chartPoints[hoveredPoint]"
|
|
||||||
class="tooltip"
|
|
||||||
:style="{
|
|
||||||
left: chartPoints[hoveredPoint].x + 'px',
|
|
||||||
top: (chartPoints[hoveredPoint].y - 52) + 'px'
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<div class="tooltip-date">{{ formatFullDate(chartPoints[hoveredPoint].date) }}</div>
|
|
||||||
<div class="tooltip-row">
|
|
||||||
<span class="tooltip-dot prompt"></span>
|
|
||||||
输入 {{ formatNumber(chartPoints[hoveredPoint].prompt) }}
|
|
||||||
</div>
|
|
||||||
<div class="tooltip-row">
|
|
||||||
<span class="tooltip-dot completion"></span>
|
|
||||||
输出 {{ formatNumber(chartPoints[hoveredPoint].completion) }}
|
|
||||||
</div>
|
|
||||||
<div class="tooltip-total">{{ formatNumber(chartPoints[hoveredPoint].value) }} tokens</div>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -197,11 +100,14 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||||
|
import { Chart, registerables } from 'chart.js'
|
||||||
import { statsApi } from '../api'
|
import { statsApi } from '../api'
|
||||||
import { formatNumber } from '../utils/format'
|
import { formatNumber } from '../utils/format'
|
||||||
import { icons } from '../utils/icons'
|
import { icons } from '../utils/icons'
|
||||||
|
|
||||||
|
Chart.register(...registerables)
|
||||||
|
|
||||||
defineEmits(['close'])
|
defineEmits(['close'])
|
||||||
|
|
||||||
const periods = [
|
const periods = [
|
||||||
|
|
@ -213,15 +119,8 @@ const periods = [
|
||||||
const period = ref('daily')
|
const period = ref('daily')
|
||||||
const stats = ref(null)
|
const stats = ref(null)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const hoveredPoint = ref(null)
|
const chartCanvas = ref(null)
|
||||||
|
let chartInstance = null
|
||||||
const accentColor = computed(() => {
|
|
||||||
return getComputedStyle(document.documentElement).getPropertyValue('--accent-primary').trim() || '#2563eb'
|
|
||||||
})
|
|
||||||
|
|
||||||
const chartWidth = 320
|
|
||||||
const chartHeight = 140
|
|
||||||
const padding = 32
|
|
||||||
|
|
||||||
const sortedDaily = computed(() => {
|
const sortedDaily = computed(() => {
|
||||||
if (!stats.value?.daily) return {}
|
if (!stats.value?.daily) return {}
|
||||||
|
|
@ -231,18 +130,36 @@ const sortedDaily = computed(() => {
|
||||||
})
|
})
|
||||||
|
|
||||||
const chartData = computed(() => {
|
const chartData = computed(() => {
|
||||||
const data = sortedDaily.value
|
if (period.value === 'daily' && stats.value?.hourly) {
|
||||||
return Object.entries(data).map(([date, val]) => ({
|
const hourly = stats.value.hourly
|
||||||
date,
|
let minH = 24, maxH = -1
|
||||||
value: val.total,
|
for (const h of Object.keys(hourly)) {
|
||||||
prompt: val.prompt || 0,
|
const hour = parseInt(h)
|
||||||
completion: val.completion || 0,
|
if (hour < minH) minH = hour
|
||||||
}))
|
if (hour > maxH) maxH = hour
|
||||||
})
|
}
|
||||||
|
if (minH > maxH) return []
|
||||||
|
const start = Math.max(0, minH)
|
||||||
|
const end = Math.min(23, maxH)
|
||||||
|
return Array.from({ length: end - start + 1 }, (_, i) => {
|
||||||
|
const h = start + i
|
||||||
|
return {
|
||||||
|
label: `${h}:00`,
|
||||||
|
value: hourly[String(h)]?.total || 0,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const maxValue = computed(() => {
|
const data = sortedDaily.value
|
||||||
if (chartData.value.length === 0) return 100
|
return Object.entries(data).map(([date, val]) => {
|
||||||
return Math.max(100, ...chartData.value.map(d => d.value))
|
const d = new Date(date)
|
||||||
|
return {
|
||||||
|
label: `${d.getMonth() + 1}/${d.getDate()}`,
|
||||||
|
value: val.total,
|
||||||
|
prompt: val.prompt || 0,
|
||||||
|
completion: val.completion || 0,
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const maxModelTokens = computed(() => {
|
const maxModelTokens = computed(() => {
|
||||||
|
|
@ -250,54 +167,136 @@ const maxModelTokens = computed(() => {
|
||||||
return Math.max(1, ...Object.values(stats.value.by_model).map(d => d.total))
|
return Math.max(1, ...Object.values(stats.value.by_model).map(d => d.total))
|
||||||
})
|
})
|
||||||
|
|
||||||
const chartPoints = computed(() => {
|
function getAccentColor() {
|
||||||
const data = chartData.value
|
return getComputedStyle(document.documentElement).getPropertyValue('--accent-primary').trim() || '#2563eb'
|
||||||
if (data.length === 0) return []
|
|
||||||
|
|
||||||
const xRange = chartWidth - 2 * padding
|
|
||||||
const yRange = chartHeight - 2 * padding
|
|
||||||
|
|
||||||
return data.map((d, i) => ({
|
|
||||||
x: data.length === 1
|
|
||||||
? chartWidth / 2
|
|
||||||
: padding + (i / Math.max(1, data.length - 1)) * xRange,
|
|
||||||
y: chartHeight - padding - (d.value / maxValue.value) * yRange,
|
|
||||||
date: d.date,
|
|
||||||
value: d.value,
|
|
||||||
prompt: d.prompt,
|
|
||||||
completion: d.completion,
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
const linePath = computed(() => {
|
|
||||||
const points = chartPoints.value
|
|
||||||
if (points.length === 0) return ''
|
|
||||||
return points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`).join(' ')
|
|
||||||
})
|
|
||||||
|
|
||||||
const areaPath = computed(() => {
|
|
||||||
const points = chartPoints.value
|
|
||||||
if (points.length === 0) return ''
|
|
||||||
|
|
||||||
const baseY = chartHeight - padding
|
|
||||||
|
|
||||||
let path = `M ${points[0].x} ${baseY} `
|
|
||||||
path += points.map(p => `L ${p.x} ${p.y}`).join(' ')
|
|
||||||
path += ` L ${points[points.length - 1].x} ${baseY} Z`
|
|
||||||
|
|
||||||
return path
|
|
||||||
})
|
|
||||||
|
|
||||||
function formatDateLabel(dateStr) {
|
|
||||||
const d = new Date(dateStr)
|
|
||||||
return `${d.getMonth() + 1}/${d.getDate()}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatFullDate(dateStr) {
|
function getTextColor(alpha = 1) {
|
||||||
const d = new Date(dateStr)
|
const c = getComputedStyle(document.documentElement).getPropertyValue('--text-tertiary').trim() || '#888'
|
||||||
return `${d.getMonth() + 1}月${d.getDate()}日`
|
if (alpha === 1) return c
|
||||||
|
// Convert hex to rgba
|
||||||
|
if (c.startsWith('#')) {
|
||||||
|
const r = parseInt(c.slice(1, 3), 16)
|
||||||
|
const g = parseInt(c.slice(3, 5), 16)
|
||||||
|
const b = parseInt(c.slice(5, 7), 16)
|
||||||
|
return `rgba(${r},${g},${b},${alpha})`
|
||||||
|
}
|
||||||
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function destroyChart() {
|
||||||
|
if (chartInstance) {
|
||||||
|
chartInstance.destroy()
|
||||||
|
chartInstance = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildChart() {
|
||||||
|
if (!chartCanvas.value || chartData.value.length === 0) return
|
||||||
|
|
||||||
|
destroyChart()
|
||||||
|
|
||||||
|
const accent = getAccentColor()
|
||||||
|
const ctx = chartCanvas.value.getContext('2d')
|
||||||
|
|
||||||
|
// Gradient fill
|
||||||
|
const gradient = ctx.createLinearGradient(0, 0, 0, 200)
|
||||||
|
gradient.addColorStop(0, accent + '40')
|
||||||
|
gradient.addColorStop(1, accent + '05')
|
||||||
|
|
||||||
|
const labels = chartData.value.map(d => d.label)
|
||||||
|
const values = chartData.value.map(d => d.value)
|
||||||
|
|
||||||
|
// Determine max ticks for x-axis
|
||||||
|
const maxTicks = chartData.value.length <= 8 ? chartData.value.length : 6
|
||||||
|
|
||||||
|
chartInstance = new Chart(ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels,
|
||||||
|
datasets: [{
|
||||||
|
data: values,
|
||||||
|
borderColor: accent,
|
||||||
|
backgroundColor: gradient,
|
||||||
|
borderWidth: 2,
|
||||||
|
pointRadius: 0,
|
||||||
|
pointHoverRadius: 4,
|
||||||
|
pointHoverBackgroundColor: accent,
|
||||||
|
pointHoverBorderColor: '#fff',
|
||||||
|
pointHoverBorderWidth: 2,
|
||||||
|
fill: true,
|
||||||
|
tension: 0,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
animation: { duration: 300 },
|
||||||
|
layout: {
|
||||||
|
padding: { top: 4, right: 4, bottom: 0, left: 0 },
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
grid: { display: false },
|
||||||
|
border: { display: false },
|
||||||
|
ticks: {
|
||||||
|
color: getTextColor(),
|
||||||
|
font: { size: 10 },
|
||||||
|
maxTicksLimit: maxTicks,
|
||||||
|
maxRotation: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
grid: {
|
||||||
|
color: getTextColor(0.15),
|
||||||
|
drawBorder: false,
|
||||||
|
},
|
||||||
|
border: { display: false },
|
||||||
|
ticks: {
|
||||||
|
color: getTextColor(),
|
||||||
|
font: { size: 9 },
|
||||||
|
maxTicksLimit: 4,
|
||||||
|
callback: (v) => formatNumber(v),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false },
|
||||||
|
tooltip: {
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.8)',
|
||||||
|
titleColor: '#fff',
|
||||||
|
bodyColor: '#ccc',
|
||||||
|
titleFont: { size: 11, weight: '500' },
|
||||||
|
bodyFont: { size: 11 },
|
||||||
|
padding: 8,
|
||||||
|
cornerRadius: 6,
|
||||||
|
displayColors: false,
|
||||||
|
callbacks: {
|
||||||
|
title: (items) => {
|
||||||
|
const idx = items[0].dataIndex
|
||||||
|
const d = chartData.value[idx]
|
||||||
|
if (period.value === 'daily') {
|
||||||
|
return `${d.label} - ${parseInt(d.label) + 1}:00`
|
||||||
|
}
|
||||||
|
return d.label
|
||||||
|
},
|
||||||
|
label: (item) => `${formatNumber(item.raw)} tokens`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
interaction: {
|
||||||
|
mode: 'index',
|
||||||
|
intersect: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(chartData, () => {
|
||||||
|
nextTick(buildChart)
|
||||||
|
})
|
||||||
|
|
||||||
async function loadStats() {
|
async function loadStats() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
|
|
@ -312,11 +311,11 @@ async function loadStats() {
|
||||||
|
|
||||||
function changePeriod(p) {
|
function changePeriod(p) {
|
||||||
period.value = p
|
period.value = p
|
||||||
hoveredPoint.value = null
|
|
||||||
loadStats()
|
loadStats()
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(loadStats)
|
onMounted(loadStats)
|
||||||
|
onBeforeUnmount(destroyChart)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
@ -324,8 +323,6 @@ onMounted(loadStats)
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* panel-header, panel-title, header-actions now in global.css */
|
|
||||||
|
|
||||||
.stats-loading {
|
.stats-loading {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -430,99 +427,9 @@ onMounted(loadStats)
|
||||||
background: var(--bg-input);
|
background: var(--bg-input);
|
||||||
border: 1px solid var(--border-light);
|
border: 1px solid var(--border-light);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 12px 8px 8px 8px;
|
padding: 10px;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
height: 180px;
|
||||||
}
|
|
||||||
|
|
||||||
.line-chart {
|
|
||||||
width: 100%;
|
|
||||||
height: 140px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.y-label {
|
|
||||||
fill: var(--text-tertiary);
|
|
||||||
font-size: 9px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.data-point {
|
|
||||||
cursor: pointer;
|
|
||||||
transition: r 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.data-point:hover {
|
|
||||||
r: 5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.x-labels {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-top: 6px;
|
|
||||||
padding: 0 28px 0 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.x-label {
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
transition: color 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.x-label.active {
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 提示框 */
|
|
||||||
.tooltip {
|
|
||||||
position: absolute;
|
|
||||||
background: var(--bg-primary);
|
|
||||||
border: 1px solid var(--border-medium);
|
|
||||||
padding: 8px 10px;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 11px;
|
|
||||||
pointer-events: none;
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
|
|
||||||
transform: translateX(-50%);
|
|
||||||
z-index: 10;
|
|
||||||
min-width: 120px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip-date {
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
font-size: 10px;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip-dot {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 50%;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip-dot.prompt {
|
|
||||||
background: #3b82f6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip-dot.completion {
|
|
||||||
background: #a855f7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip-total {
|
|
||||||
margin-top: 4px;
|
|
||||||
padding-top: 4px;
|
|
||||||
border-top: 1px solid var(--border-light);
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 模型分布 */
|
/* 模型分布 */
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue