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:
1 parent
c018b3693a
commit
916a2dee73
15 files changed
+514
-306
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
<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 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>
|
||||
<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-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>
|
||||
<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-[var(--el-fill-color-light)] p-2 text-xs">{{ call.result === null ? "等待结果…" : prettyResult(call.result) }}</pre>
|
||||
<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-[var(--el-text-color-secondary)]">
|
||||
<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>
|
||||
</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>
|
||||
@@ -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,18 +215,23 @@ 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">
|
||||
<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"
|
||||
/>
|
||||
|
||||
<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 size="small" @click="router.push('/skills')">
|
||||
<el-button link type="primary" size="small" @click="router.push('/skills')">
|
||||
去管理
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="text-xs text-[var(--el-text-color-secondary)]">
|
||||
</template>
|
||||
<div class="text-xs text-slate-500">
|
||||
点击查看草稿与已发布版本的差异并发布;黄色为本次 Chat 刚改过的
|
||||
</div>
|
||||
<div class="min-h-0 flex-1">
|
||||
@@ -234,33 +240,47 @@ loadSkills()
|
||||
</el-card>
|
||||
|
||||
<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">
|
||||
<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>
|
||||
<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}`" />
|
||||
<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">
|
||||
<span class="text-xs" :class="tokenRatio > 0.8 ? 'text-red-500 font-bold' : 'text-[var(--el-text-color-secondary)]'">
|
||||
<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
|
||||
</span>
|
||||
</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 class="ml-auto" type="danger" plain :disabled="streaming" @click="resetVisible = true">
|
||||
<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="flex items-end gap-2">
|
||||
<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>
|
||||
@@ -268,14 +288,13 @@ loadSkills()
|
||||
发送
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="CONTEXT.md" name="context" class="h-full">
|
||||
<div v-show="activeTab === 'context'" class="min-h-0 flex-1">
|
||||
<ContextTab ref="contextTab" />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</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>
|
||||
@@ -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 }}
|
||||
<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>
|
||||
<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="启用">
|
||||
<div class="flex-1" />
|
||||
<el-switch :model-value="source.enabled" @change="toggleEnabled" />
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="采集间隔">
|
||||
</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>
|
||||
<el-progress :percentage="history.percent" :status="history.status" :show-text="false" :stroke-width="4" class="mt-2" />
|
||||
<div class="mt-1">
|
||||
{{ history.text }}
|
||||
</div>
|
||||
</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) }}
|
||||
</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>
|
||||
</el-card>
|
||||
<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-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>
|
||||
</div>
|
||||
<el-button :type="alert ? 'danger' : 'default'" :icon="RefreshRight" :loading="restarting" class="mt-auto w-full" @click="emit('restart')">
|
||||
重启该来源
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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 ? "管理进程运行中" : "管理进程未运行" }}
|
||||
<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>
|
||||
<el-tag :type="connected ? 'success' : 'danger'">
|
||||
{{ connected ? "日志推送已连接" : "日志推送断开" }}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs text-slate-500">
|
||||
实时日志推送
|
||||
<el-tag :type="connected ? 'success' : 'danger'" size="small">
|
||||
● {{ connected ? "已连接" : "断开" }}
|
||||
</el-tag>
|
||||
<span class="text-sm text-[var(--el-text-color-secondary)]">数据库 {{ formatBytes(info.db_size_bytes) }}</span>
|
||||
</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>
|
||||
<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"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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,41 +72,59 @@ 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>
|
||||
|
||||
<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">
|
||||
</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-b border-b-solid border-[var(--el-border-color-lighter)] px-2 py-3 hover:bg-[var(--el-fill-color-light)]"
|
||||
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-[var(--el-text-color-secondary)]">
|
||||
<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>{{ formatTime(item.published_at) }}</span>
|
||||
<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>
|
||||
@@ -113,22 +132,25 @@ load()
|
||||
付费
|
||||
</el-tag>
|
||||
</div>
|
||||
<div v-if="item.title" class="font-bold">
|
||||
<div v-if="item.title" class="mb-1 text-slate-900 font-semibold">
|
||||
{{ item.title }}
|
||||
</div>
|
||||
<div class="line-clamp-3 text-sm text-[var(--el-text-color-regular)]">
|
||||
<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]"
|
||||
layout="total, sizes, prev, pager, next, jumper" background class="justify-end"
|
||||
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>
|
||||
|
||||
<NewsDrawer v-model="openedId" />
|
||||
<NewsDetail :news-id="openedId" class="min-w-0 flex-1" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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,18 +131,20 @@ loadExchange()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-3">
|
||||
<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="text-lg font-bold">模型配置</span>
|
||||
<span class="ml-3 text-xs text-[var(--el-text-color-secondary)]">通过 Sub2API 等中转接入;API key 加密存储,页面只显示末 4 位</span>
|
||||
模型配置
|
||||
<span class="ml-3 text-xs text-slate-500 font-normal">通过 Sub2API 等中转接入;API key 加密存储,页面只显示末 4 位</span>
|
||||
</div>
|
||||
<el-button type="primary" @click="openCreate">
|
||||
<el-button type="primary" :icon="Plus" @click="openCreate">
|
||||
新增配置
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="providers" border>
|
||||
</template>
|
||||
<el-table :data="providers">
|
||||
<el-table-column label="名称" min-width="120">
|
||||
<template #default="{ row }">
|
||||
{{ row.name }}
|
||||
@@ -189,6 +192,7 @@ loadExchange()
|
||||
</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,10 +249,12 @@ loadExchange()
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<div class="mt-4 flex items-center justify-between">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<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)]">只连接币安模拟盘(demo-fapi.binance.com);在 demo.binance.com 的 API 管理创建 key,并开启合约交易权限</span>
|
||||
币安模拟盘
|
||||
<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">
|
||||
@@ -262,6 +268,7 @@ loadExchange()
|
||||
</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}` : "未配置" }}
|
||||
@@ -276,6 +283,7 @@ loadExchange()
|
||||
{{ 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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
Vendored
+1
-1
@@ -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']
|
||||
|
||||
Reference in new issue
Block a user