Restyle the web UI in a light theme, step 2: remaining pages

- News: show the list and the article detail side by side; the drawer
  becomes a persistent detail panel with a revision picker and +/- line
  counts.
- Crawler console: one card per source instead of tabs, plus a merged
  live log panel filterable by source and level.
- Chat: segmented Chat / CONTEXT.md switch, message cards with times,
  bordered tool-call rows and a context usage bar.
- Login, Skills and Settings move to the panel style; diff and highlight
  colors follow the new palette. The market K-line area stays dark.
This commit is contained in:
eezd committed 2026-10-15 22:26:04 +08:00
1 parent c018b3693a
commit 916a2dee73
15 files changed
+685 -477

No files matched your search

+1 -1
View File
@@ -127,7 +127,7 @@ function onCollapse(item: TreeItem) {
<template #default="{ data }">
<div
class="group w-full flex items-center gap-1 overflow-hidden rounded pr-1 text-sm"
:class="{ 'opacity-50': !data.data.enabled, 'bg-yellow-100': highlightIds?.includes(data.id) }"
:class="{ 'opacity-50': !data.data.enabled, 'bg-amber-100': highlightIds?.includes(data.id) }"
>
<span>{{ data.data.kind === "folder" ? "📁" : "📄" }}</span>
<span class="truncate">{{ data.data.name }}</span>
+4 -4
View File
@@ -50,8 +50,8 @@ async function publish(version: SkillVersion) {
<el-scrollbar class="w-60 shrink-0">
<div
v-for="v in versions" :key="v.id"
class="mb-1 cursor-pointer border rounded border-solid px-2 py-1 text-sm"
:class="v.id === selectedId ? 'border-[var(--el-color-primary)] bg-[var(--el-color-primary-light-9)]' : 'border-[var(--el-border-color-lighter)]'"
class="mb-2 cursor-pointer border rounded-lg border-solid px-3 py-2 text-sm"
:class="v.id === selectedId ? 'border-[var(--el-color-primary)] bg-[var(--el-color-primary-light-9)]' : 'border-slate-200 hover:border-slate-300'"
@click="selectedId = v.id"
>
<div class="flex items-center gap-1">
@@ -81,11 +81,11 @@ async function publish(version: SkillVersion) {
发布此版本
</el-button>
</div>
<el-scrollbar class="min-h-0 flex-1 border rounded border-solid border-[var(--el-border-color-lighter)]">
<el-scrollbar class="min-h-0 flex-1 border border-slate-200 rounded-lg border-solid">
<div class="p-2 text-xs font-mono">
<div
v-for="(line, i) in diff" :key="i" class="whitespace-pre-wrap"
:class="{ 'bg-green-100': line.type === 'add', 'bg-red-100 line-through': line.type === 'del' }"
:class="{ 'bg-emerald-50 text-emerald-900': line.type === 'add', 'bg-rose-50 text-rose-900 line-through': line.type === 'del' }"
>
{{ line.type === "add" ? "+ " : line.type === "del" ? "- " : " " }}{{ line.text }}
</div>
+5 -5
View File
@@ -52,9 +52,9 @@ load()
</script>
<template>
<div class="h-full flex flex-col gap-2">
<el-card shadow="never" class="h-full flex flex-col" body-class="min-h-0 flex-1 flex flex-col gap-3">
<div class="flex items-center gap-2 text-sm">
<span class="text-[var(--el-text-color-secondary)]">
<span class="text-slate-500">
跨会话的上下文持久化:每轮都会放进 Chat 的 prompt,Chat 也会用 update_context 更新它。
{{ current?.created_at ? `当前版本:${formatTime(current.created_at)}(${SOURCE_LABELS[current.source!]})` : "当前为空" }}
</span>
@@ -80,11 +80,11 @@ load()
<div class="min-h-0 flex flex-1 gap-3">
<textarea
v-model="content" placeholder="目标、已确认的结论、待办…(Markdown)"
class="box-border flex-1 resize-none border rounded border-solid border-[var(--el-border-color)] p-3 text-sm leading-6 font-mono outline-none focus:border-[var(--el-color-primary)]"
class="box-border flex-1 resize-none border border-slate-300 rounded-lg border-solid p-3 text-sm text-slate-800 leading-6 font-mono outline-none focus:border-[var(--el-color-primary)]"
/>
<el-scrollbar class="flex-1 border rounded border-solid border-[var(--el-border-color-lighter)]">
<el-scrollbar class="flex-1 border border-slate-200 rounded-lg border-solid bg-slate-50/50">
<div class="markdown-body p-3" v-html="preview" />
</el-scrollbar>
</div>
</div>
</el-card>
</template>
+68 -28
View File
@@ -1,5 +1,7 @@
<script lang="ts" setup>
import { CircleCheck, Cpu, Loading } from "@element-plus/icons-vue"
import { renderMarkdown } from "@/utils/markdown"
import { formatTime } from "@/utils/text"
export interface ToolCard {
id: string
@@ -20,6 +22,8 @@ export interface DisplayItem {
closed?: boolean
/** 生成中:模型正在思考,还没有输出文字 */
thinking?: boolean
/** 已落库的消息时间;生成中的没有 */
time?: string
}
defineProps<{ items: DisplayItem[] }>()
@@ -49,43 +53,79 @@ function prettyResult(result: string) {
</script>
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-5">
<div v-for="item in items" :key="item.key" :class="item.kind === 'user' ? 'self-end max-w-3/4' : 'self-stretch'">
<div v-if="item.kind === 'user'" class="whitespace-pre-wrap rounded-lg bg-[var(--el-color-primary-light-9)] px-3 py-2 text-sm">
{{ item.text }}
<div v-if="item.kind === 'user'" class="border border-slate-200 rounded-lg border-solid bg-white px-4 py-3 shadow-sm">
<div class="mb-1 flex justify-between gap-6 text-xs text-slate-500">
<span class="text-[var(--el-color-primary)] font-semibold">你</span>
<span class="num">{{ item.time ? formatTime(item.time) : "" }}</span>
</div>
<div class="whitespace-pre-wrap text-sm text-slate-800 leading-relaxed">
{{ item.text }}
</div>
</div>
<div v-else class="flex flex-col gap-2">
<!-- markdown-it 已关闭 html,模型输出中的标签按文本显示 -->
<div v-if="item.thinking" class="text-xs text-[var(--el-text-color-secondary)]">
思考中…
<div v-else class="flex gap-3">
<span class="h-8 w-8 flex-center shrink-0 rounded-full bg-[var(--el-color-primary)] text-white">
<el-icon><Cpu /></el-icon>
</span>
<div class="min-w-0 flex flex-1 flex-col gap-2">
<div class="flex items-center gap-2 text-xs">
<span class="text-sm text-[var(--el-color-primary)] font-semibold">AlphaPulse AI</span>
<span class="num text-slate-400">{{ item.time ? formatTime(item.time) : "" }}</span>
</div>
<div v-if="item.thinking" class="flex items-center gap-2 text-xs text-slate-500">
<span class="h-2 w-2 animate-pulse rounded-full bg-amber-500" />
思考中…
</div>
<!-- markdown-it 已关闭 html,模型输出中的标签按文本显示 -->
<div v-if="item.text" class="markdown-body" v-html="renderMarkdown(item.text)" />
<el-collapse v-if="item.toolCalls.length" class="tool-cards">
<el-collapse-item v-for="call in item.toolCalls" :key="call.id" :name="call.id">
<template #title>
<div class="flex items-center gap-2 overflow-hidden text-xs">
<el-icon v-if="call.result === null" class="is-loading text-amber-500">
<Loading />
</el-icon>
<el-icon v-else class="text-emerald-600">
<CircleCheck />
</el-icon>
<span class="shrink-0 text-[var(--el-color-primary)] font-semibold">{{ TOOL_LABELS[call.name] ?? call.name }}</span>
<span class="truncate text-slate-500 font-mono">{{ JSON.stringify(call.arguments) }}</span>
</div>
</template>
<pre class="m-0 max-h-80 overflow-auto whitespace-pre-wrap break-all rounded bg-slate-50 p-2 text-xs">{{ call.result === null ? "等待结果…" : prettyResult(call.result) }}</pre>
</el-collapse-item>
</el-collapse>
<div v-if="item.stopped" class="text-xs text-slate-500">
(已停止生成)
</div>
<el-alert v-if="item.error" :title="item.error" type="error" :closable="false" show-icon />
</div>
<div v-if="item.text" class="markdown-body" v-html="renderMarkdown(item.text)" />
<el-collapse v-if="item.toolCalls.length" class="tool-cards">
<el-collapse-item v-for="call in item.toolCalls" :key="call.id" :name="call.id">
<template #title>
<div class="flex items-center gap-2 overflow-hidden text-xs">
<el-tag size="small" :type="call.result === null ? 'warning' : 'info'">
{{ call.result === null ? "执行中" : "工具" }}
</el-tag>
<span class="font-bold">{{ TOOL_LABELS[call.name] ?? call.name }}</span>
<span class="truncate text-[var(--el-text-color-secondary)] font-mono">{{ JSON.stringify(call.arguments) }}</span>
</div>
</template>
<pre class="m-0 max-h-80 overflow-auto whitespace-pre-wrap break-all rounded bg-[var(--el-fill-color-light)] p-2 text-xs">{{ call.result === null ? "等待结果…" : prettyResult(call.result) }}</pre>
</el-collapse-item>
</el-collapse>
<div v-if="item.stopped" class="text-xs text-[var(--el-text-color-secondary)]">
(已停止生成)
</div>
<el-alert v-if="item.error" :title="item.error" type="error" :closable="false" show-icon />
</div>
</div>
</div>
</template>
<style scoped>
.tool-cards {
border: none;
}
.tool-cards :deep(.el-collapse-item) {
margin-bottom: 6px;
overflow: hidden;
border: 1px solid var(--el-border-color);
border-radius: 8px;
}
.tool-cards :deep(.el-collapse-item__header) {
height: 32px;
line-height: 32px;
height: 34px;
padding: 0 12px;
border-bottom: none;
line-height: 34px;
}
.tool-cards :deep(.el-collapse-item__wrap) {
border-bottom: none;
}
.tool-cards :deep(.el-collapse-item__content) {
padding: 0 12px 10px;
}
</style>
+86 -64
View File
@@ -3,6 +3,7 @@ import type { ChatMessage } from "@/apis/chat"
import type { Effort, Provider } from "@/apis/settings"
import type { SkillNode } from "@/apis/skills"
import type { DisplayItem } from "@/pages/chat/components/MessageList.vue"
import { RefreshLeft } from "@element-plus/icons-vue"
import { ElNotification } from "element-plus"
import { getChatSession, resetChat } from "@/apis/chat"
import { EFFORTS, listProviders } from "@/apis/settings"
@@ -39,11 +40,11 @@ function toItems(list: ChatMessage[]): DisplayItem[] {
const calls = new Map<string, DisplayItem["toolCalls"][number]>()
for (const m of list) {
if (m.role === "user") {
items.push({ key: `m${m.id}`, kind: "user", text: m.content.text, toolCalls: [] })
items.push({ key: `m${m.id}`, kind: "user", text: m.content.text, toolCalls: [], time: m.created_at })
} else if (m.role === "assistant") {
const toolCalls = (m.content.tool_calls ?? []).map((c: any) => ({ ...c, result: null }))
toolCalls.forEach((c: any) => calls.set(c.id, c))
items.push({ key: `m${m.id}`, kind: "assistant", text: m.content.text, toolCalls, stopped: m.content.stopped, error: m.content.error })
items.push({ key: `m${m.id}`, kind: "assistant", text: m.content.text, toolCalls, stopped: m.content.stopped, error: m.content.error, time: m.created_at })
} else {
const call = calls.get(m.content.tool_call_id)
if (call)
@@ -214,68 +215,86 @@ loadSkills()
</script>
<template>
<div class="h-full flex flex-col">
<el-tabs v-model="activeTab" class="chat-tabs min-h-0 flex flex-1 flex-col">
<el-tab-pane label="Chat" name="chat" class="h-full">
<div class="h-full flex gap-3">
<el-card class="w-72 shrink-0" body-class="h-full box-border flex flex-col gap-2" shadow="never">
<div class="flex items-center justify-between text-sm font-bold">
Skills
<el-button link size="small" @click="router.push('/skills')">
去管理
</el-button>
</div>
<div class="text-xs text-[var(--el-text-color-secondary)]">
点击查看草稿与已发布版本的差异并发布;黄色为本次 Chat 刚改过的
</div>
<div class="min-h-0 flex-1">
<SkillTree :nodes="skills" :highlight-ids="highlightIds" :selected-id="drawerSkill?.id" @select="openSkill" />
</div>
</el-card>
<div class="h-full flex flex-col gap-3">
<el-segmented
v-model="activeTab" :options="[{ label: 'Chat', value: 'chat' }, { label: 'CONTEXT.md', value: 'context' }]"
class="self-start"
/>
<el-card class="min-w-0 flex flex-1 flex-col" body-class="min-h-0 flex-1 flex flex-col gap-3" shadow="never">
<div class="flex flex-wrap items-center gap-3">
<el-select v-model="providerId" placeholder="选择模型配置" class="w-60" :disabled="streaming">
<el-option v-for="p in providers" :key="p.id" :value="p.id" :label="`${p.name}(${p.model})`" />
</el-select>
<el-select v-model="effort" class="w-44" :disabled="streaming" clearable :value-on-clear="null" :placeholder="`思考强度:跟随配置(${provider?.reasoning_effort ?? '不设置'})`">
<el-option v-for="e in EFFORTS" :key="e" :value="e" :label="`思考强度:${e}`" />
</el-select>
<el-tooltip content="下一轮发给模型的 system prompt(含已发布 skills、CONTEXT.md)与历史消息的估算大小" placement="bottom">
<span class="text-xs" :class="tokenRatio > 0.8 ? 'text-red-500 font-bold' : 'text-[var(--el-text-color-secondary)]'">
上下文约 {{ estimatedTokens.toLocaleString() }}{{ provider ? ` / ${provider.context_window.toLocaleString()}` : "" }} token
</span>
</el-tooltip>
<el-button class="ml-auto" type="danger" plain :disabled="streaming" @click="resetVisible = true">
Reset
</el-button>
</div>
<div ref="listBox" class="min-h-0 flex-1 overflow-auto pr-2">
<el-empty v-if="items.length === 0" description="提问,例如:长鑫 4F² 架构对三星、海力士有冲击吗?在哪里抄底、止损?" />
<MessageList v-else :items="items" />
</div>
<div class="flex items-end gap-2">
<el-input
v-model="input" type="textarea" :autosize="{ minRows: 2, maxRows: 8 }" resize="none"
placeholder="输入问题,Ctrl+Enter 发送" @keydown="onKeydown"
/>
<el-button v-if="streaming" type="warning" @click="stop">
停止
</el-button>
<el-button v-else type="primary" :disabled="!input.trim()" @click="submit">
发送
</el-button>
</div>
</el-card>
<div v-show="activeTab === 'chat'" class="min-h-0 flex flex-1 gap-4">
<el-card class="w-72 flex shrink-0 flex-col" body-class="min-h-0 flex-1 flex flex-col gap-2" shadow="never">
<template #header>
<div class="flex items-center justify-between">
Skills
<el-button link type="primary" size="small" @click="router.push('/skills')">
去管理
</el-button>
</div>
</template>
<div class="text-xs text-slate-500">
点击查看草稿与已发布版本的差异并发布;黄色为本次 Chat 刚改过的
</div>
</el-tab-pane>
<div class="min-h-0 flex-1">
<SkillTree :nodes="skills" :highlight-ids="highlightIds" :selected-id="drawerSkill?.id" @select="openSkill" />
</div>
</el-card>
<el-tab-pane label="CONTEXT.md" name="context" class="h-full">
<ContextTab ref="contextTab" />
</el-tab-pane>
</el-tabs>
<el-card class="min-w-0 flex flex-1 flex-col" body-class="min-h-0 flex-1 flex flex-col gap-3" shadow="never">
<template #header>
<div class="flex flex-wrap items-center gap-3 font-normal">
<span class="text-xs text-slate-500">模型</span>
<el-select v-model="providerId" placeholder="选择模型配置" size="small" class="w-56" :disabled="streaming">
<el-option v-for="p in providers" :key="p.id" :value="p.id" :label="`${p.name}(${p.model})`" />
</el-select>
<span class="text-xs text-slate-500">思考强度</span>
<el-select v-model="effort" size="small" class="w-40" :disabled="streaming" clearable :value-on-clear="null" :placeholder="`跟随配置(${provider?.reasoning_effort ?? '不设置'})`">
<el-option v-for="e in EFFORTS" :key="e" :value="e" :label="e" />
</el-select>
<div class="flex-1" />
<el-tooltip content="下一轮发给模型的 system prompt(含已发布 skills、CONTEXT.md)与历史消息的估算大小" placement="bottom">
<div class="w-56 text-xs" :class="tokenRatio > 0.8 ? 'text-rose-600 font-bold' : 'text-slate-500'">
<div class="num text-right">
上下文约 {{ estimatedTokens.toLocaleString() }}{{ provider ? ` / ${provider.context_window.toLocaleString()}` : "" }} token
</div>
<el-progress
:percentage="Math.min(100, tokenRatio * 100)" :show-text="false" :stroke-width="4"
:status="tokenRatio > 0.8 ? 'exception' : undefined" class="mt-1"
/>
</div>
</el-tooltip>
<el-button type="danger" plain size="small" :icon="RefreshLeft" :disabled="streaming" @click="resetVisible = true">
Reset
</el-button>
</div>
</template>
<div ref="listBox" class="min-h-0 flex-1 overflow-auto pr-2">
<el-empty v-if="items.length === 0" description="提问,例如:长鑫 4F² 架构对三星、海力士有冲击吗?在哪里抄底、止损?" />
<MessageList v-else :items="items" />
</div>
<div class="chat-input border border-slate-300 rounded-lg border-solid bg-white">
<el-input
v-model="input" type="textarea" :autosize="{ minRows: 2, maxRows: 8 }" resize="none"
placeholder="输入问题,Ctrl+Enter 发送" @keydown="onKeydown"
/>
<div class="flex items-center gap-2 border-t border-t-slate-100 border-t-solid px-3 py-2">
<span class="text-xs text-slate-400">Ctrl+Enter 发送</span>
<div class="flex-1" />
<el-button v-if="streaming" type="warning" @click="stop">
停止
</el-button>
<el-button v-else type="primary" :disabled="!input.trim()" @click="submit">
发送
</el-button>
</div>
</div>
</el-card>
</div>
<div v-show="activeTab === 'context'" class="min-h-0 flex-1">
<ContextTab ref="contextTab" />
</div>
<SkillVersionDrawer v-model="drawerVisible" :skill="drawerSkill" @published="onPublished" />
@@ -299,8 +318,11 @@ loadSkills()
</template>
<style scoped>
.chat-tabs :deep(.el-tabs__content) {
flex: 1;
min-height: 0;
.chat-input:focus-within {
border-color: var(--el-color-primary);
}
.chat-input :deep(.el-textarea__inner) {
padding: 10px 12px;
box-shadow: none;
}
</style>
+111 -95
View File
@@ -1,39 +1,29 @@
<script lang="ts" setup>
import type { CrawlLog, SourceInfo } from "@/apis/crawler"
import type { NewsBrief } from "@/apis/news"
import { CircleCheck, RefreshRight } from "@element-plus/icons-vue"
import dayjs from "dayjs"
import { patchSource } from "@/apis/crawler"
import { listNews } from "@/apis/news"
import { formatTime } from "@/utils/text"
/** logs 只用来发现「本轮完成」以刷新最新入库;alert 为连续失败达到告警阈值 */
const props = defineProps<{
source: SourceInfo
logs: CrawlLog[]
restarting: boolean
alert: boolean
}>()
const emit = defineEmits<{
restart: []
changed: []
}>()
const LEVELS = ["INFO", "WARNING", "ERROR"]
const levels = ref([...LEVELS])
const autoScroll = ref(true)
const logBox = ref<HTMLElement>()
const latest = ref<NewsBrief[]>([])
const interval = ref(props.source.interval_seconds)
const visibleLogs = computed(() => props.logs.filter(log => levels.value.includes(log.level) || (log.level === "CRITICAL" && levels.value.includes("ERROR"))))
watch(() => props.logs.length, async () => {
if (!autoScroll.value)
return
await nextTick()
logBox.value?.scrollTo({ top: logBox.value.scrollHeight })
}, { immediate: true })
async function loadLatest() {
latest.value = (await listNews({ source: [props.source.code], limit: 15 })).items
latest.value = (await listNews({ source: [props.source.code], limit: 5 })).items
}
// 本来源有新的“本轮完成”日志时刷新最新入库列表
@@ -107,95 +97,121 @@ loadLatest()
</script>
<template>
<div class="h-full flex flex-col gap-3">
<el-descriptions :column="4" border size="small">
<template #extra>
<el-button type="warning" :loading="restarting" @click="emit('restart')">
重启该来源
</el-button>
</template>
<el-descriptions-item label="状态">
<el-tag :type="statusTag.type" size="small">
{{ statusTag.text }}
</el-tag>
<span v-if="source.process?.pid" class="ml-2 text-xs text-[var(--el-text-color-secondary)]">pid {{ source.process.pid }}</span>
</el-descriptions-item>
<el-descriptions-item label="上次成功">
{{ formatTime(source.last_success_at) }}
</el-descriptions-item>
<el-descriptions-item label="连续失败">
<span :class="{ 'text-red-500 font-bold': source.consecutive_failures > 0 }">{{ source.consecutive_failures }}</span>
</el-descriptions-item>
<el-descriptions-item label="下次运行">
{{ formatTime(source.paused_until && source.paused_until > (source.process?.next_run_at ?? "") ? source.paused_until : source.process?.next_run_at) }}
</el-descriptions-item>
<el-descriptions-item label="今日新增 / 修订">
{{ source.today_new }} / {{ source.today_revised }}
</el-descriptions-item>
<el-descriptions-item label="累计条目">
{{ source.total_items }}
</el-descriptions-item>
<el-descriptions-item label="启用">
<el-switch :model-value="source.enabled" @change="toggleEnabled" />
</el-descriptions-item>
<el-descriptions-item label="采集间隔">
<div
class="flex flex-col gap-3 border rounded-lg border-solid p-4 shadow-sm"
:class="alert ? 'border-rose-300 bg-rose-50/50' : 'border-slate-200 bg-white'"
>
<div class="flex items-center gap-2">
<span class="text-base text-slate-900 font-bold">{{ source.name }}</span>
<el-tag v-if="alert" type="danger" effect="dark" size="small">
告警
</el-tag>
<div class="flex-1" />
<el-switch :model-value="source.enabled" @change="toggleEnabled" />
</div>
<div class="flex items-center gap-2 text-xs">
<span class="h-2 w-2 rounded-full" :style="{ background: `var(--el-color-${statusTag.type})` }" />
<span class="font-semibold" :style="{ color: `var(--el-color-${statusTag.type})` }">{{ statusTag.text }}</span>
<span v-if="source.process?.pid" class="num text-slate-500">PID {{ source.process.pid }}</span>
</div>
<div class="grid grid-cols-2 gap-x-4 gap-y-2 border border-slate-100 rounded-lg border-solid bg-slate-50 p-3 text-xs text-slate-500">
<div>
上次成功
<div class="num text-sm text-slate-900">
{{ formatTime(source.last_success_at) }}
</div>
</div>
<div>
连续失败
<div class="num text-sm" :class="source.consecutive_failures > 0 ? 'text-rose-600 font-bold' : 'text-slate-900'">
{{ source.consecutive_failures }} 次
</div>
</div>
<div>
下次运行
<div class="num text-sm text-[var(--el-color-primary)]">
{{ formatTime(source.paused_until && source.paused_until > (source.process?.next_run_at ?? "") ? source.paused_until : source.process?.next_run_at) }}
</div>
</div>
<div>
累计条目
<div class="num text-sm text-slate-900">
{{ source.total_items.toLocaleString() }}
</div>
</div>
</div>
<div class="flex justify-between text-xs text-slate-500">
<span>今日新增 <b class="num text-[var(--el-color-primary)]">+{{ source.today_new }}</b></span>
<span>今日修订 <b class="num text-slate-900">{{ source.today_revised }}</b></span>
</div>
<div class="flex items-center justify-between border-t border-t-slate-100 border-t-solid pt-3 text-xs text-slate-500">
采集间隔
<span class="flex items-center gap-1">
<el-input-number v-model="interval" :min="5" :max="86400" size="small" controls-position="right" class="w-28" @change="saveInterval" />
秒
</el-descriptions-item>
<el-descriptions-item label="历史回补" :span="4">
<span v-if="!source.supports_history" class="text-xs text-[var(--el-text-color-secondary)]">该来源的列表接口只能翻最近几页,无法回补历史</span>
<div v-else class="flex items-center gap-3">
<el-date-picker v-model="historyDate" type="date" value-format="YYYY-MM-DD" :clearable="false" size="small" class="!w-36" />
<el-button size="small" type="primary" @click="startHistory">
</span>
</div>
<div class="border border-slate-200 rounded-lg border-solid bg-slate-50/60 p-3 text-xs text-slate-500">
<div class="mb-2 flex items-center justify-between">
历史回补
<span v-if="source.supports_history" class="num text-[var(--el-color-primary)]">{{ history.percent }}%</span>
<span v-else>不可用</span>
</div>
<template v-if="!source.supports_history">
该来源的列表接口只能翻最近几页,无法回补历史
</template>
<template v-else>
<div class="flex items-center gap-2">
<el-date-picker v-model="historyDate" type="date" value-format="YYYY-MM-DD" :clearable="false" size="small" class="w-32!" />
<el-button size="small" type="primary" plain class="flex-1" @click="startHistory">
{{ source.history_position ? "继续回补" : "开始回补" }}
</el-button>
<el-button v-if="source.history_target" size="small" @click="cancelHistory">
<el-button v-if="source.history_target" size="small" class="ml-0!" @click="cancelHistory">
取消
</el-button>
<el-progress :percentage="history.percent" :status="history.status" class="w-60" />
<span class="text-xs">{{ history.text }}</span>
</div>
</el-descriptions-item>
<el-descriptions-item v-if="source.last_error" label="最近错误" :span="4">
<span class="whitespace-pre-wrap text-xs text-red-500">{{ source.last_error }}</span>
</el-descriptions-item>
</el-descriptions>
<div class="min-h-0 flex flex-1 gap-3">
<el-card class="w-2/5 flex flex-col" body-class="min-h-0 flex-1 overflow-auto" shadow="never">
<template #header>
<span>最新入库</span>
<el-link :href="source.list_url" target="_blank" type="primary" class="ml-2">
原站
</el-link>
</template>
<div v-for="item in latest" :key="item.id" class="mb-2 text-sm">
<span class="mr-2 text-xs text-[var(--el-text-color-secondary)]">{{ formatTime(item.published_at, "MM-DD HH:mm") }}</span>
<el-link :href="item.url" target="_blank">
{{ item.title || item.excerpt.slice(0, 60) }}
</el-link>
<el-progress :percentage="history.percent" :status="history.status" :show-text="false" :stroke-width="4" class="mt-2" />
<div class="mt-1">
{{ history.text }}
</div>
</el-card>
<el-card class="w-3/5 flex flex-col" body-class="min-h-0 flex-1 flex flex-col" shadow="never">
<template #header>
<div class="flex items-center gap-3">
<span>实时日志</span>
<el-checkbox-group v-model="levels" size="small">
<el-checkbox-button v-for="level in LEVELS" :key="level" :value="level">
{{ level }}
</el-checkbox-button>
</el-checkbox-group>
<el-switch v-model="autoScroll" active-text="自动滚动" size="small" />
</div>
</template>
<div ref="logBox" class="min-h-0 flex-1 overflow-auto rounded bg-[#1e1e1e] p-2 text-xs text-[#d4d4d4] font-mono leading-5">
<div v-for="log in visibleLogs" :key="log.id" class="whitespace-pre-wrap break-all">
<span class="text-[#808080]">{{ formatTime(log.ts) }}</span>
<span :class="{ 'text-yellow-400': log.level === 'WARNING', 'text-red-400': log.level === 'ERROR' || log.level === 'CRITICAL' }" class="mx-2">{{ log.level }}</span>{{ log.message }}
</div>
</div>
</el-card>
</template>
</div>
<div v-if="source.last_error" class="border border-rose-200 rounded-lg border-solid bg-rose-50 p-2 text-xs">
<div class="mb-1 text-rose-700 font-semibold">
最近错误
</div>
<div class="num max-h-24 overflow-auto whitespace-pre-wrap break-all text-rose-800">
{{ source.last_error }}
</div>
</div>
<div v-else class="flex items-center gap-1 text-xs text-slate-500">
<el-icon class="text-emerald-600">
<CircleCheck />
</el-icon>
最近错误:无
</div>
<div class="border border-slate-200 rounded-lg border-solid p-2 text-xs">
<div class="mb-1 flex items-center justify-between text-slate-500">
最新入库
<el-link :href="source.list_url" target="_blank" type="primary" class="text-xs!">
原站链接
</el-link>
</div>
<div v-for="item in latest" :key="item.id" class="truncate leading-6">
<span class="num mr-2 text-slate-400">{{ formatTime(item.published_at, "MM-DD HH:mm") }}</span>
<a :href="item.url" target="_blank" class="text-slate-700 no-underline hover:text-[var(--el-color-primary)]">{{ item.title || item.excerpt.slice(0, 60) }}</a>
</div>
<div v-if="!latest.length" class="text-slate-400">
暂无
</div>
</div>
<el-button :type="alert ? 'danger' : 'default'" :icon="RefreshRight" :loading="restarting" class="mt-auto w-full" @click="emit('restart')">
重启该来源
</el-button>
</div>
</template>
+94 -37
View File
@@ -1,23 +1,50 @@
<script lang="ts" setup>
import type { CrawlLog, SourcesInfo } from "@/apis/crawler"
import { Monitor, RefreshRight } from "@element-plus/icons-vue"
import { listLogs, listSources, restartCrawler } from "@/apis/crawler"
import { useEventSource } from "@/composables/useEventSource"
import SourcePanel from "@/pages/crawler/components/SourcePanel.vue"
import { formatTime } from "@/utils/text"
const MAX_LOGS = 1000
// 连续失败达到该次数时 TAB 标红
// 连续失败达到该次数时卡片告警
const FAILURE_ALERT = 3
const RESTART_TIMEOUT_MS = 60_000
const LEVELS = ["INFO", "WARNING", "ERROR"]
const info = ref<SourcesInfo>()
const activeTab = ref<string>()
const logs = reactive<Record<string, CrawlLog[]>>({})
// 正在等待重新上线的来源
const restarting = reactive(new Set<string>())
// 日志面板筛选:空字符串为全部来源
const logSource = ref("")
const levels = ref([...LEVELS])
const autoScroll = ref(true)
const logBox = ref<HTMLElement>()
async function refresh() {
info.value = await listSources()
activeTab.value ??= info.value.sources[0]?.code
}
const sourceNames = computed(() => Object.fromEntries(info.value?.sources.map(s => [s.code, s.name]) ?? []))
// 各来源日志按全局自增 id 合并
const visibleLogs = computed(() => Object.values(logs).flat().filter(log => (!logSource.value || log.source_code === logSource.value)
&& (levels.value.includes(log.level) || (log.level === "CRITICAL" && levels.value.includes("ERROR")))).sort((a, b) => a.id - b.id).slice(-MAX_LOGS))
watch(() => visibleLogs.value.at(-1)?.id, async () => {
if (!autoScroll.value)
return
await nextTick()
logBox.value?.scrollTo({ top: logBox.value.scrollHeight })
})
function levelClass(level: string) {
if (level === "WARNING")
return "bg-amber-500/20 text-amber-300"
if (level === "ERROR" || level === "CRITICAL")
return "bg-rose-500/20 text-rose-300"
return "bg-sky-500/20 text-sky-300"
}
function appendLog(log: CrawlLog) {
@@ -72,43 +99,73 @@ refresh().then(() => Promise.all(info.value!.sources.map(s => loadHistory(s.code
</script>
<template>
<div v-if="info" class="h-full flex flex-col gap-3">
<div class="flex items-center gap-3">
<el-tag :type="info.manager_alive ? 'success' : 'danger'">
{{ info.manager_alive ? "管理进程运行中" : "管理进程未运行" }}
</el-tag>
<el-tag :type="connected ? 'success' : 'danger'">
{{ connected ? "日志推送已连接" : "日志推送断开" }}
</el-tag>
<span class="text-sm text-[var(--el-text-color-secondary)]">数据库 {{ formatBytes(info.db_size_bytes) }}</span>
<div v-if="info" class="flex flex-col gap-4">
<el-card shadow="never" body-class="flex flex-wrap items-center gap-x-6 gap-y-2">
<div class="flex items-center gap-2 text-sm text-slate-900 font-semibold">
<el-icon><Monitor /></el-icon>
全局状态
</div>
<div class="flex items-center gap-2 text-xs text-slate-500">
采集管理进程
<el-tag :type="info.manager_alive ? 'success' : 'danger'" size="small">
● {{ info.manager_alive ? "运行中" : "未运行" }}
</el-tag>
</div>
<div class="flex items-center gap-2 text-xs text-slate-500">
实时日志推送
<el-tag :type="connected ? 'success' : 'danger'" size="small">
● {{ connected ? "已连接" : "断开" }}
</el-tag>
</div>
<div class="flex items-center gap-2 text-xs text-slate-500">
数据库占用
<span class="num text-sm text-slate-900">{{ formatBytes(info.db_size_bytes) }}</span>
</div>
<div class="flex-1" />
<el-button type="danger" :loading="restarting.size > 0" @click="restart('all')">
<el-button type="danger" plain :icon="RefreshRight" :loading="restarting.size > 0" @click="restart('all')">
重启全部采集
</el-button>
</el-card>
<div class="text-sm text-slate-900 font-semibold">
采集来源({{ info.sources.length }})
</div>
<el-tabs v-model="activeTab" type="border-card" class="crawler-tabs min-h-0 flex flex-1 flex-col">
<el-tab-pane v-for="source in info.sources" :key="source.code" :name="source.code" class="h-full">
<template #label>
<span :class="{ 'text-red-500 font-bold': source.consecutive_failures >= FAILURE_ALERT }">
{{ source.name }}
</span>
<el-badge v-if="source.consecutive_failures >= FAILURE_ALERT" :value="source.consecutive_failures" class="ml-1" />
</template>
<SourcePanel
:source="source"
:logs="logs[source.code] ?? []"
:restarting="restarting.has(source.code)"
@restart="restart(source.code)"
@changed="refresh"
/>
</el-tab-pane>
</el-tabs>
<div class="grid grid-cols-[repeat(auto-fill,minmax(320px,1fr))] gap-4">
<SourcePanel
v-for="source in info.sources" :key="source.code"
:source="source"
:logs="logs[source.code] ?? []"
:restarting="restarting.has(source.code)"
:alert="source.consecutive_failures >= FAILURE_ALERT"
@restart="restart(source.code)"
@changed="refresh"
/>
</div>
<el-card shadow="never">
<template #header>
<div class="flex flex-wrap items-center gap-3">
实时日志
<div class="flex-1" />
<el-select v-model="logSource" size="small" class="w-36" :empty-values="[null, undefined]">
<el-option value="" label="全部来源" />
<el-option v-for="s in info.sources" :key="s.code" :value="s.code" :label="s.name" />
</el-select>
<el-checkbox-group v-model="levels" size="small">
<el-checkbox-button v-for="level in LEVELS" :key="level" :value="level">
{{ level }}
</el-checkbox-button>
</el-checkbox-group>
<el-switch v-model="autoScroll" active-text="自动滚动" size="small" />
</div>
</template>
<div ref="logBox" class="h-100 overflow-auto rounded-lg bg-slate-900 p-3 text-xs text-slate-200 font-mono leading-6">
<div v-for="log in visibleLogs" :key="log.id" class="whitespace-pre-wrap break-all">
<span class="text-slate-500">{{ formatTime(log.ts) }}</span>
<span class="mx-2 rounded px-1" :class="levelClass(log.level)">{{ log.level }}</span>
<span class="mr-2 text-teal-300">[{{ sourceNames[log.source_code] ?? log.source_code }}]</span>{{ log.message }}
</div>
</div>
</el-card>
</div>
</template>
<style scoped>
.crawler-tabs :deep(.el-tabs__content) {
flex: 1;
min-height: 0;
}
</style>
+13 -6
View File
@@ -1,4 +1,5 @@
<script lang="ts" setup>
import { TrendCharts } from "@element-plus/icons-vue"
import { useUserStore } from "@/pinia/user"
const route = useRoute()
@@ -20,13 +21,19 @@ async function submit() {
</script>
<template>
<div class="h-full flex-center">
<el-card class="w-90">
<template #header>
<div class="text-center text-lg font-bold">
<div class="h-full flex-center bg-slate-50">
<el-card class="w-90" shadow="never" body-class="px-8 pb-8 pt-6">
<div class="mb-6 text-center">
<div class="flex-center gap-2 text-2xl text-slate-900 font-bold">
<el-icon :size="26" color="var(--el-color-primary)">
<TrendCharts />
</el-icon>
AlphaPulse
</div>
</template>
<div class="mt-1 text-xs text-slate-500">
加密货币交易研究工具
</div>
</div>
<el-form :model="form" @submit.prevent="submit">
<el-form-item>
<el-input v-model="form.username" placeholder="用户名" autocomplete="username" />
@@ -34,7 +41,7 @@ async function submit() {
<el-form-item>
<el-input v-model="form.password" type="password" placeholder="密码" show-password autocomplete="current-password" />
</el-form-item>
<el-button type="primary" native-type="submit" :loading="loading" class="w-full">
<el-button type="primary" native-type="submit" size="large" :loading="loading" class="mt-2 w-full">
登录
</el-button>
</el-form>
+1 -1
View File
@@ -189,7 +189,7 @@ onBeforeUnmount(() => {
</script>
<template>
<div class="market h-full flex flex-col overflow-hidden rounded bg-[#161a1e] text-[#eaecef]">
<div class="market h-full flex flex-col overflow-hidden rounded-lg bg-[#161a1e] text-[#eaecef]">
<!-- 顶栏:交易对与 24h 行情 -->
<div class="flex flex-wrap items-center gap-6 border-b border-b-solid border-[#2b3139] px-4 py-2">
<el-select
@@ -0,0 +1,123 @@
<script lang="ts" setup>
import type { NewsDetail, Revision } from "@/apis/news"
import { Link } from "@element-plus/icons-vue"
import { getNews, listRevisions } from "@/apis/news"
import { useSourceNames } from "@/composables/useSourceNames"
import { diffLines, formatTime, htmlToLines } from "@/utils/text"
const props = defineProps<{ newsId: number | null }>()
const sourceNames = useSourceNames()
const detail = ref<NewsDetail>()
const revisions = ref<Revision[]>([])
const activeTab = ref("content")
// 当前查看的修订序号,与上一版对比
const selectedRevision = ref<number>()
watch(() => props.newsId, async (id) => {
if (id == null)
return
activeTab.value = "content"
detail.value = undefined
const [news, revs] = await Promise.all([getNews(id), listRevisions(id)])
detail.value = news
revisions.value = revs
selectedRevision.value = revs[0]?.revision_no
})
// 第三方正文放进无脚本权限的 sandbox iframe,避免其中的脚本访问本站
const srcdoc = computed(() => detail.value && `<!doctype html><html><head><meta charset="utf-8">
<meta name="referrer" content="no-referrer"><base target="_blank">
<style>body{font:15px/1.8 -apple-system,"PingFang SC","Microsoft YaHei",sans-serif;color:#334155;margin:0 4px}img{max-width:100%;height:auto}</style>
</head><body>${detail.value.content_html}</body></html>`)
const diff = computed(() => {
const index = revisions.value.findIndex(r => r.revision_no === selectedRevision.value)
if (index < 0)
return []
const current = revisions.value[index]
const previous = revisions.value[index + 1]
const toLines = (r?: Revision) => r ? [r.title, r.summary, ...htmlToLines(r.content_html)].filter(Boolean) : []
return diffLines(toLines(previous), toLines(current))
})
const diffStats = computed(() => ({
add: diff.value.filter(line => line.type === "add").length,
del: diff.value.filter(line => line.type === "del").length
}))
</script>
<template>
<el-card shadow="never" class="flex flex-col" body-class="min-h-0 flex-1 flex flex-col gap-3">
<el-empty v-if="newsId == null" description="点击左侧新闻查看详情" class="m-auto" />
<template v-else-if="detail">
<div class="flex flex-wrap items-center gap-2">
<el-tag size="small">
{{ sourceNames[detail.source_code] ?? detail.source_code }}
</el-tag>
<el-tag v-if="detail.is_paywalled" size="small" type="warning">
付费(仅公开部分)
</el-tag>
</div>
<div class="text-xl text-slate-900 font-bold leading-snug">
{{ detail.title || detail.excerpt.slice(0, 60) }}
</div>
<div class="grid grid-cols-2 gap-x-6 gap-y-2 border border-slate-200 rounded-lg border-solid bg-slate-50 px-3 py-2 text-sm">
<div>
<span class="label">发布时间</span><span class="num">{{ formatTime(detail.published_at, "YYYY-MM-DD HH:mm:ss") }}</span>
</div>
<div>
<span class="label">作者</span>{{ detail.author || "-" }}
</div>
<div class="flex flex-wrap items-center gap-1">
<span class="label">标签</span>
<el-tag v-for="tag in detail.tags" :key="tag" size="small" type="info">
{{ tag }}
</el-tag>
<span v-if="!detail.tags.length">-</span>
</div>
<div class="text-right">
<el-link :href="detail.url" target="_blank" type="primary" :icon="Link">
原文链接
</el-link>
</div>
</div>
<el-tabs v-model="activeTab" class="min-h-0 flex flex-1 flex-col">
<el-tab-pane label="正文" name="content" class="h-full">
<iframe :srcdoc="srcdoc" sandbox="allow-popups allow-popups-to-escape-sandbox" class="h-full w-full border-0" />
</el-tab-pane>
<el-tab-pane :label="`修订(${revisions.length})`" name="revisions" class="h-full overflow-auto">
<div class="mb-3 flex flex-wrap items-center gap-3 border border-slate-200 rounded-lg border-solid bg-slate-50 px-3 py-2">
<span class="text-xs text-slate-500">比对版本</span>
<el-select v-model="selectedRevision" size="small" class="w-64">
<el-option
v-for="(r, i) in revisions" :key="r.revision_no" :value="r.revision_no"
:label="`v${r.revision_no} · ${formatTime(r.captured_at)}${revisions[i + 1] ? ` vs v${revisions[i + 1].revision_no}` : '(首版)'}`"
/>
</el-select>
<span class="num text-xs text-emerald-700">+{{ diffStats.add }} 行</span>
<span class="num text-xs text-rose-700">−{{ diffStats.del }} 行</span>
</div>
<div class="overflow-hidden border border-slate-200 rounded-lg border-solid text-sm leading-7">
<div
v-for="(line, i) in diff" :key="i" class="flex gap-2 px-3"
:class="{ 'bg-emerald-50 text-emerald-900': line.type === 'add', 'bg-rose-50 text-rose-900 line-through': line.type === 'del' }"
>
<span class="num w-3 shrink-0 text-slate-400">{{ line.type === "add" ? "+" : line.type === "del" ? "−" : "" }}</span>
<span class="min-w-0">{{ line.text }}</span>
</div>
</div>
</el-tab-pane>
</el-tabs>
</template>
<div v-else v-loading="true" class="h-full" />
</el-card>
</template>
<style scoped>
.label {
margin-right: 8px;
color: var(--el-text-color-secondary);
font-size: 12px;
}
</style>
@@ -1,90 +0,0 @@
<script lang="ts" setup>
import type { NewsDetail, Revision } from "@/apis/news"
import { getNews, listRevisions } from "@/apis/news"
import { useSourceNames } from "@/composables/useSourceNames"
import { diffLines, formatTime, htmlToLines } from "@/utils/text"
const newsId = defineModel<number | null>({ required: true })
const sourceNames = useSourceNames()
const detail = ref<NewsDetail>()
const revisions = ref<Revision[]>([])
const activeTab = ref("content")
// 当前查看的修订序号,与上一版对比
const selectedRevision = ref<number>()
watch(newsId, async (id) => {
if (id == null)
return
activeTab.value = "content"
detail.value = undefined
const [news, revs] = await Promise.all([getNews(id), listRevisions(id)])
detail.value = news
revisions.value = revs
selectedRevision.value = revs[0]?.revision_no
})
// 第三方正文放进无脚本权限的 sandbox iframe,避免其中的脚本访问本站
const srcdoc = computed(() => detail.value && `<!doctype html><html><head><meta charset="utf-8">
<meta name="referrer" content="no-referrer"><base target="_blank">
<style>body{font:15px/1.8 -apple-system,"PingFang SC","Microsoft YaHei",sans-serif;color:#303133;margin:0 4px}img{max-width:100%;height:auto}</style>
</head><body>${detail.value.content_html}</body></html>`)
const diff = computed(() => {
const index = revisions.value.findIndex(r => r.revision_no === selectedRevision.value)
if (index < 0)
return []
const current = revisions.value[index]
const previous = revisions.value[index + 1]
const toLines = (r?: Revision) => r ? [r.title, r.summary, ...htmlToLines(r.content_html)].filter(Boolean) : []
return diffLines(toLines(previous), toLines(current))
})
</script>
<template>
<el-drawer :model-value="newsId != null" size="50%" :with-header="false" @close="newsId = null">
<div v-if="detail" class="h-full flex flex-col gap-3">
<div class="text-lg font-bold">
{{ detail.title || detail.excerpt.slice(0, 60) }}
</div>
<div class="flex flex-wrap items-center gap-2 text-sm text-[var(--el-text-color-secondary)]">
<el-tag size="small">
{{ sourceNames[detail.source_code] ?? detail.source_code }}
</el-tag>
<span>{{ formatTime(detail.published_at, "YYYY-MM-DD HH:mm:ss") }}</span>
<span v-if="detail.author">{{ detail.author }}</span>
<el-tag v-if="detail.is_paywalled" size="small" type="warning">
付费(仅公开部分)
</el-tag>
<el-tag v-for="tag in detail.tags" :key="tag" size="small" type="info">
{{ tag }}
</el-tag>
<el-link :href="detail.url" target="_blank" type="primary">
原文
</el-link>
</div>
<el-tabs v-model="activeTab" class="min-h-0 flex flex-1 flex-col">
<el-tab-pane label="正文" name="content" class="h-full">
<iframe :srcdoc="srcdoc" sandbox="allow-popups allow-popups-to-escape-sandbox" class="h-full w-full border-0" />
</el-tab-pane>
<el-tab-pane :label="`修订(${revisions.length})`" name="revisions" class="h-full overflow-auto">
<el-radio-group v-model="selectedRevision" size="small" class="mb-3">
<el-radio-button v-for="r in revisions" :key="r.revision_no" :value="r.revision_no">
v{{ r.revision_no }} · {{ formatTime(r.captured_at) }}
</el-radio-button>
</el-radio-group>
<div class="text-sm leading-7">
<div
v-for="(line, i) in diff" :key="i"
:class="{ 'bg-green-100': line.type === 'add', 'bg-red-100 line-through': line.type === 'del' }"
class="px-2"
>
{{ line.type === "add" ? "+ " : line.type === "del" ? "- " : " " }}{{ line.text }}
</div>
</div>
</el-tab-pane>
</el-tabs>
</div>
<div v-else v-loading="true" class="h-full" />
</el-drawer>
</template>
+72 -50
View File
@@ -1,9 +1,10 @@
<script lang="ts" setup>
import type { NewsBrief, NewsEvent } from "@/apis/news"
import { Refresh, Search } from "@element-plus/icons-vue"
import { listNews } from "@/apis/news"
import { useEventSource } from "@/composables/useEventSource"
import { useSourceNames } from "@/composables/useSourceNames"
import NewsDrawer from "@/pages/news/components/NewsDrawer.vue"
import NewsDetail from "@/pages/news/components/NewsDetail.vue"
import { usePersisted } from "@/utils/storage"
import { formatTime } from "@/utils/text"
@@ -71,64 +72,85 @@ load()
<template>
<div class="h-full flex flex-col gap-3">
<div class="flex flex-wrap items-center gap-3">
<div class="flex items-center gap-3">
<el-tag :type="connected ? 'success' : 'danger'">
● {{ connected ? "实时推送已连接" : "实时推送断开" }}
</el-tag>
<div class="flex-1" />
<div
v-if="pendingCount > 0"
class="flex cursor-pointer items-center gap-1 border border-sky-200 rounded-full border-solid bg-sky-50 px-4 py-1 text-sm text-[var(--el-color-primary)] hover:bg-sky-100"
@click="search"
>
<el-icon><Refresh /></el-icon>
有 {{ pendingCount }} 条新消息,点击刷新
</div>
</div>
<el-card shadow="never" body-class="flex flex-wrap items-center gap-3">
<span class="text-xs text-slate-500">来源</span>
<el-checkbox-group v-model="filters.source" @change="search">
<el-checkbox-button v-for="(name, code) in sourceNames" :key="code" :value="code">
<el-checkbox v-for="(name, code) in sourceNames" :key="code" :value="code" border class="mr-2!">
{{ name }}
</el-checkbox-button>
</el-checkbox>
</el-checkbox-group>
<el-input v-model="filters.q" placeholder="关键字" clearable class="w-60" @keyup.enter="search" @clear="search" />
<el-input v-model="filters.q" :prefix-icon="Search" placeholder="关键字" clearable class="w-60" @keyup.enter="search" @clear="search" />
<el-date-picker
v-model="filters.range" type="datetimerange" start-placeholder="开始" end-placeholder="结束"
class="max-w-90" @change="search"
/>
<el-button type="primary" @click="search">
<el-button type="primary" :icon="Search" @click="search">
查询
</el-button>
<el-tag :type="connected ? 'success' : 'danger'" size="small">
{{ connected ? "实时推送已连接" : "实时推送断开" }}
</el-tag>
</el-card>
<div class="min-h-0 flex flex-1 gap-4">
<el-card shadow="never" class="w-2/5 flex shrink-0 flex-col" body-class="min-h-0 flex-1 flex flex-col p-0">
<template #header>
<div class="flex items-center justify-between">
消息流
<span class="num text-xs text-slate-500 font-normal">共 {{ total.toLocaleString() }} 条</span>
</div>
</template>
<div ref="listBox" v-loading="loading" class="min-h-0 flex flex-1 flex-col gap-2 overflow-auto p-3">
<div
v-for="item in items" :key="item.id"
class="cursor-pointer border rounded-lg border-solid p-3 transition-colors"
:class="item.id === openedId ? 'border-[var(--el-color-primary)] bg-sky-50/50' : 'border-slate-200 hover:border-slate-300 hover:bg-slate-50'"
@click="openedId = item.id"
>
<div class="mb-1 flex items-center gap-2 text-xs text-slate-500">
<el-tag size="small">
{{ sourceNames[item.source_code] ?? item.source_code }}
</el-tag>
<span class="num">{{ formatTime(item.published_at) }}</span>
<div class="flex-1" />
<el-tag v-if="item.revision_no > 1" size="small" type="warning">
已修订 {{ item.revision_no - 1 }} 次
</el-tag>
<el-tag v-if="item.is_paywalled" size="small" type="info">
付费
</el-tag>
</div>
<div v-if="item.title" class="mb-1 text-slate-900 font-semibold">
{{ item.title }}
</div>
<div class="line-clamp-3 text-sm text-slate-600 leading-relaxed">
{{ item.summary || item.excerpt }}
</div>
</div>
<el-empty v-if="!loading && items.length === 0" description="没有符合条件的新闻" />
</div>
<div class="border-t border-t-slate-200 border-t-solid px-3 py-2">
<el-pagination
v-model:current-page="page" v-model:page-size="pageSize" :total="total" :page-sizes="[20, 30, 50, 100]" :pager-count="5"
layout="sizes, prev, pager, next, jumper" size="small" background
@current-change="load" @size-change="changePageSize"
/>
</div>
</el-card>
<NewsDetail :news-id="openedId" class="min-w-0 flex-1" />
</div>
<el-alert
v-if="pendingCount > 0" :title="`有 ${pendingCount} 条新消息,点击刷新`" type="info" :closable="false"
class="cursor-pointer" @click="search"
/>
<div ref="listBox" v-loading="loading" class="min-h-0 flex-1 overflow-auto">
<div
v-for="item in items" :key="item.id"
class="cursor-pointer border-b border-b-solid border-[var(--el-border-color-lighter)] px-2 py-3 hover:bg-[var(--el-fill-color-light)]"
@click="openedId = item.id"
>
<div class="mb-1 flex items-center gap-2 text-xs text-[var(--el-text-color-secondary)]">
<el-tag size="small">
{{ sourceNames[item.source_code] ?? item.source_code }}
</el-tag>
<span>{{ formatTime(item.published_at) }}</span>
<el-tag v-if="item.revision_no > 1" size="small" type="warning">
已修订 {{ item.revision_no - 1 }} 次
</el-tag>
<el-tag v-if="item.is_paywalled" size="small" type="info">
付费
</el-tag>
</div>
<div v-if="item.title" class="font-bold">
{{ item.title }}
</div>
<div class="line-clamp-3 text-sm text-[var(--el-text-color-regular)]">
{{ item.summary || item.excerpt }}
</div>
</div>
<el-empty v-if="!loading && items.length === 0" description="没有符合条件的新闻" />
</div>
<el-pagination
v-model:current-page="page" v-model:page-size="pageSize" :total="total" :page-sizes="[20, 30, 50, 100]"
layout="total, sizes, prev, pager, next, jumper" background class="justify-end"
@current-change="load" @size-change="changePageSize"
/>
<NewsDrawer v-model="openedId" />
</div>
</template>
+97 -89
View File
@@ -1,5 +1,6 @@
<script lang="ts" setup>
import type { Exchange, Provider, ProviderForm } from "@/apis/settings"
import { Plus } from "@element-plus/icons-vue"
import { createProvider, deleteExchange, deleteProvider, EFFORTS, getExchange, listProviders, patchProvider, putExchange, testExchange, testProvider } from "@/apis/settings"
import { formatTime } from "@/utils/text"
@@ -130,65 +131,68 @@ loadExchange()
</script>
<template>
<div class="flex flex-col gap-3">
<div class="flex items-center justify-between">
<div>
<span class="text-lg font-bold">模型配置</span>
<span class="ml-3 text-xs text-[var(--el-text-color-secondary)]">通过 Sub2API 等中转接入;API key 加密存储,页面只显示末 4 位</span>
</div>
<el-button type="primary" @click="openCreate">
新增配置
</el-button>
</div>
<el-table :data="providers" border>
<el-table-column label="名称" min-width="120">
<template #default="{ row }">
{{ row.name }}
<el-tag v-if="row.is_default" size="small" type="success" class="ml-1">
默认
</el-tag>
</template>
</el-table-column>
<el-table-column label="协议" min-width="200">
<template #default="{ row }">
{{ PROTOCOL_LABELS[row.protocol as keyof typeof PROTOCOL_LABELS] }}
</template>
</el-table-column>
<el-table-column prop="base_url" label="Base URL" min-width="200" />
<el-table-column prop="model" label="模型" min-width="150" />
<el-table-column label="思考强度" width="90">
<template #default="{ row }">
{{ (row as Provider).reasoning_effort ?? "-" }}
</template>
</el-table-column>
<el-table-column label="API Key" width="100">
<template #default="{ row }">
****{{ row.api_key_hint }}
</template>
</el-table-column>
<el-table-column label="更新时间" width="130">
<template #default="{ row }">
{{ formatTime(row.updated_at, "MM-DD HH:mm") }}
</template>
</el-table-column>
<el-table-column label="操作" width="250" fixed="right">
<template #default="{ row }">
<el-button link type="primary" :loading="testing === row.id" @click="test(row as Provider)">
测试连接
<div class="flex flex-col gap-4">
<el-card shadow="never" body-class="p-0">
<template #header>
<div class="flex items-center justify-between">
<div>
模型配置
<span class="ml-3 text-xs text-slate-500 font-normal">通过 Sub2API 等中转接入;API key 加密存储,页面只显示末 4 位</span>
</div>
<el-button type="primary" :icon="Plus" @click="openCreate">
新增配置
</el-button>
<el-button link @click="openEdit(row as Provider)">
编辑
</el-button>
<el-button v-if="!row.is_default" link @click="setDefault(row as Provider)">
设为默认
</el-button>
<el-button link type="danger" @click="remove(row as Provider)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</template>
<el-table :data="providers">
<el-table-column label="名称" min-width="120">
<template #default="{ row }">
{{ row.name }}
<el-tag v-if="row.is_default" size="small" type="success" class="ml-1">
默认
</el-tag>
</template>
</el-table-column>
<el-table-column label="协议" min-width="200">
<template #default="{ row }">
{{ PROTOCOL_LABELS[row.protocol as keyof typeof PROTOCOL_LABELS] }}
</template>
</el-table-column>
<el-table-column prop="base_url" label="Base URL" min-width="200" />
<el-table-column prop="model" label="模型" min-width="150" />
<el-table-column label="思考强度" width="90">
<template #default="{ row }">
{{ (row as Provider).reasoning_effort ?? "-" }}
</template>
</el-table-column>
<el-table-column label="API Key" width="100">
<template #default="{ row }">
****{{ row.api_key_hint }}
</template>
</el-table-column>
<el-table-column label="更新时间" width="130">
<template #default="{ row }">
{{ formatTime(row.updated_at, "MM-DD HH:mm") }}
</template>
</el-table-column>
<el-table-column label="操作" width="250" fixed="right">
<template #default="{ row }">
<el-button link type="primary" :loading="testing === row.id" @click="test(row as Provider)">
测试连接
</el-button>
<el-button link @click="openEdit(row as Provider)">
编辑
</el-button>
<el-button v-if="!row.is_default" link @click="setDefault(row as Provider)">
设为默认
</el-button>
<el-button link type="danger" @click="remove(row as Provider)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog v-model="dialogVisible" :title="editingId === null ? '新增模型配置' : '编辑模型配置'" width="560px">
<el-form :model="form" label-width="100px">
@@ -245,37 +249,41 @@ loadExchange()
</template>
</el-dialog>
<div class="mt-4 flex items-center justify-between">
<div>
<span class="text-lg font-bold">币安模拟盘</span>
<span class="ml-3 text-xs text-[var(--el-text-color-secondary)]">只连接币安模拟盘(demo-fapi.binance.com);在 demo.binance.com 的 API 管理创建 key,并开启合约交易权限</span>
</div>
<div>
<el-button :disabled="!exchange?.configured" :loading="exchangeTesting" @click="runExchangeTest">
测试连接
</el-button>
<el-button type="primary" :disabled="exchange?.running" @click="openExchangeEdit">
{{ exchange?.configured ? "修改 key" : "填写 key" }}
</el-button>
<el-button v-if="exchange?.configured" type="danger" plain :disabled="exchange.running" @click="removeExchange">
删除
</el-button>
</div>
</div>
<el-descriptions v-if="exchange" :column="3" border>
<el-descriptions-item label="API Key">
{{ exchange.configured ? `****${exchange.api_key_hint}` : "未配置" }}
</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag v-if="exchange.running" type="success" size="small">
交易中(停止后才能修改 key)
</el-tag>
<span v-else>未运行</span>
</el-descriptions-item>
<el-descriptions-item label="更新时间">
{{ exchange.updated_at ? formatTime(exchange.updated_at, "MM-DD HH:mm") : "-" }}
</el-descriptions-item>
</el-descriptions>
<el-card shadow="never">
<template #header>
<div class="flex items-center justify-between">
<div>
币安模拟盘
<span class="ml-3 text-xs text-slate-500 font-normal">只连接币安模拟盘(demo-fapi.binance.com);在 demo.binance.com 的 API 管理创建 key,并开启合约交易权限</span>
</div>
<div>
<el-button :disabled="!exchange?.configured" :loading="exchangeTesting" @click="runExchangeTest">
测试连接
</el-button>
<el-button type="primary" :disabled="exchange?.running" @click="openExchangeEdit">
{{ exchange?.configured ? "修改 key" : "填写 key" }}
</el-button>
<el-button v-if="exchange?.configured" type="danger" plain :disabled="exchange.running" @click="removeExchange">
删除
</el-button>
</div>
</div>
</template>
<el-descriptions v-if="exchange" :column="3" border>
<el-descriptions-item label="API Key">
{{ exchange.configured ? `****${exchange.api_key_hint}` : "未配置" }}
</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag v-if="exchange.running" type="success" size="small">
交易中(停止后才能修改 key)
</el-tag>
<span v-else>未运行</span>
</el-descriptions-item>
<el-descriptions-item label="更新时间">
{{ exchange.updated_at ? formatTime(exchange.updated_at, "MM-DD HH:mm") : "-" }}
</el-descriptions-item>
</el-descriptions>
</el-card>
<el-dialog v-model="exchangeDialogVisible" title="币安模拟盘 API key" width="560px">
<el-form :model="exchangeForm" label-width="100px">
+9 -6
View File
@@ -94,8 +94,11 @@ loadTree()
</script>
<template>
<div class="h-full flex gap-3">
<el-card class="w-80 shrink-0" body-class="h-full box-border" shadow="never">
<div class="h-full flex gap-4">
<el-card class="w-80 flex shrink-0 flex-col" body-class="min-h-0 flex-1" shadow="never">
<template #header>
Skills
</template>
<SkillTree :nodes="nodes" :selected-id="selectedId" editable @select="select" @changed="loadTree" />
</el-card>
@@ -103,7 +106,7 @@ loadTree()
<el-empty v-if="!selected" description="在左侧选择或新建一个 skill" />
<template v-else-if="selected.kind === 'folder'">
<div class="text-lg font-bold">
<div class="text-lg text-slate-900 font-bold">
📁 {{ selected.path }}
</div>
<div class="flex items-center gap-2 text-sm">
@@ -115,7 +118,7 @@ loadTree()
<template v-else>
<div class="flex flex-wrap items-center gap-3">
<span class="text-lg font-bold">📄 {{ selected.path }}</span>
<span class="text-lg text-slate-900 font-bold">📄 {{ selected.path }}</span>
<el-tag v-if="selected.published_version_no === null" type="info">
未发布
</el-tag>
@@ -145,9 +148,9 @@ loadTree()
<div class="min-h-0 flex flex-1 gap-3">
<textarea
v-model="content" placeholder="用 Markdown 写下投资经验、判断流程或输出规范"
class="box-border flex-1 resize-none border rounded border-solid border-[var(--el-border-color)] p-3 text-sm leading-6 font-mono outline-none focus:border-[var(--el-color-primary)]"
class="box-border flex-1 resize-none border border-slate-300 rounded-lg border-solid p-3 text-sm text-slate-800 leading-6 font-mono outline-none focus:border-[var(--el-color-primary)]"
/>
<el-scrollbar class="flex-1 border rounded border-solid border-[var(--el-border-color-lighter)]">
<el-scrollbar class="flex-1 border border-slate-200 rounded-lg border-solid bg-slate-50/50">
<!-- markdown-it 已关闭 html,内容中的标签按文本显示 -->
<div class="markdown-body p-3" v-html="preview" />
</el-scrollbar>
+1 -1
View File
@@ -12,7 +12,6 @@ export {}
declare module 'vue' {
export interface GlobalComponents {
ElAlert: typeof import('element-plus/es')['ElAlert']
ElBadge: typeof import('element-plus/es')['ElBadge']
ElButton: typeof import('element-plus/es')['ElButton']
ElCard: typeof import('element-plus/es')['ElCard']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
@@ -46,6 +45,7 @@ declare module 'vue' {
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
ElSegmented: typeof import('element-plus/es')['ElSegmented']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSelectV2: typeof import('element-plus/es')['ElSelectV2']
ElSwitch: typeof import('element-plus/es')['ElSwitch']