Initial commit
This commit is contained in:
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>财经内容日报网站</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
1500
frontend/package-lock.json
generated
Normal file
1500
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
frontend/package.json
Normal file
20
frontend/package.json
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "wechat-finance-daily-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1 --port 2000",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview --host 127.0.0.1 --port 2001"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.5.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.4",
|
||||
"typescript": "^5.8.2",
|
||||
"vite": "^6.3.5",
|
||||
"vue-tsc": "^2.2.10"
|
||||
}
|
||||
}
|
||||
4
frontend/public/favicon.svg
Normal file
4
frontend/public/favicon.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<rect width="64" height="64" rx="16" fill="#8f5a2d" />
|
||||
<path d="M18 18h12c8.5 0 14 5.2 14 13.2 0 8.2-5.5 13.8-14 13.8h-5.8V54H18V18Zm6.2 5.6v15.8h5.1c5 0 8.3-3 8.3-8 0-4.8-3.3-7.8-8.3-7.8h-5.1Z" fill="#fff7ef"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 284 B |
240
frontend/src/App.vue
Normal file
240
frontend/src/App.vue
Normal file
@ -0,0 +1,240 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import AppSidebar from './components/dashboard/AppSidebar.vue'
|
||||
import ClsNewsPanel from './components/dashboard/ClsNewsPanel.vue'
|
||||
import InputPanel from './components/dashboard/InputPanel.vue'
|
||||
import OpinionPanel from './components/dashboard/OpinionPanel.vue'
|
||||
import { useFinanceDashboard } from './composables/useFinanceDashboard'
|
||||
import { formatDate } from './lib/format'
|
||||
|
||||
const dashboard = useFinanceDashboard()
|
||||
const {
|
||||
activeSection,
|
||||
selectedDate,
|
||||
statusMessage,
|
||||
clsNews,
|
||||
opinionReport,
|
||||
inputAccounts,
|
||||
availableDates,
|
||||
loading,
|
||||
initializeDashboard,
|
||||
setSection,
|
||||
setSelectedDate,
|
||||
updateAccountLinks,
|
||||
saveInput,
|
||||
generateReport,
|
||||
refreshNews,
|
||||
} = dashboard
|
||||
|
||||
onMounted(() => {
|
||||
void initializeDashboard()
|
||||
})
|
||||
|
||||
const sectionTitle = computed(() => ({
|
||||
cls: '财联社新闻',
|
||||
opinions: '大V观点',
|
||||
input: '日报录入',
|
||||
}[activeSection.value]))
|
||||
|
||||
const sectionSubtitle = computed(() => ({
|
||||
cls: '左侧日期会同步联动热点概览和 7x24 资讯列表,仅查看所选日期当日内容。',
|
||||
opinions: '按日期拆分日报,顶部保留摘要,正文按文章展开,并清晰展示对应板块。',
|
||||
input: '按公众号补录当日文章链接,支持多链接、去重保存和直接生成日报。',
|
||||
}[activeSection.value]))
|
||||
|
||||
const sidebarSummary = computed(() => {
|
||||
if (activeSection.value === 'cls') {
|
||||
return clsNews.value?.summary.overview ?? '等待财联社资讯数据。'
|
||||
}
|
||||
if (activeSection.value === 'opinions') {
|
||||
return opinionReport.value?.summary ?? '选择日期后查看对应日报内容。'
|
||||
}
|
||||
return '录入页支持同一账号多链接补录,空链接不保存,重复链接自动去重。'
|
||||
})
|
||||
|
||||
const sidebarSectors = computed(() => {
|
||||
if (activeSection.value === 'cls') {
|
||||
return clsNews.value?.summary.watch_list ?? []
|
||||
}
|
||||
return opinionReport.value?.focus_sectors ?? []
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-shell">
|
||||
<AppSidebar
|
||||
:active-section="activeSection"
|
||||
:selected-date="selectedDate"
|
||||
:available-dates="availableDates"
|
||||
:summary="sidebarSummary"
|
||||
:focus-sectors="sidebarSectors"
|
||||
@select-date="setSelectedDate"
|
||||
@select-section="setSection"
|
||||
/>
|
||||
|
||||
<main class="workspace">
|
||||
<header class="workspace-head">
|
||||
<div class="head-copy">
|
||||
<p class="workspace-label">Finance Dashboard</p>
|
||||
<h2 class="workspace-title">{{ sectionTitle }}</h2>
|
||||
<p class="workspace-subtitle">{{ sectionSubtitle }}</p>
|
||||
</div>
|
||||
<div class="status-card">
|
||||
<span class="status-label">当前日期</span>
|
||||
<strong class="status-date">{{ formatDate(selectedDate) }}</strong>
|
||||
<p class="status-copy">{{ statusMessage }}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="workspace-stage">
|
||||
<ClsNewsPanel
|
||||
v-if="activeSection === 'cls'"
|
||||
:news="clsNews"
|
||||
:loading="loading.news"
|
||||
:selected-date="selectedDate"
|
||||
@refresh="refreshNews"
|
||||
/>
|
||||
|
||||
<OpinionPanel
|
||||
v-else-if="activeSection === 'opinions'"
|
||||
:report="opinionReport"
|
||||
:loading="loading.report"
|
||||
:selected-date="selectedDate"
|
||||
:available-dates="availableDates"
|
||||
@select-date="setSelectedDate"
|
||||
/>
|
||||
|
||||
<InputPanel
|
||||
v-else
|
||||
:selected-date="selectedDate"
|
||||
:accounts="inputAccounts"
|
||||
:loading="loading.input"
|
||||
:saving="loading.save"
|
||||
:generating="loading.generate"
|
||||
@select-date="setSelectedDate"
|
||||
@update-links="updateAccountLinks($event.accountId, $event.links)"
|
||||
@save="saveInput"
|
||||
@generate="generateReport"
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 320px minmax(0, 1fr);
|
||||
gap: 20px;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
max-height: 100vh;
|
||||
padding: 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.workspace-head {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.head-copy {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 14px 18px;
|
||||
border: 1px solid var(--line-soft);
|
||||
border-radius: var(--radius-xl);
|
||||
background: rgba(255, 251, 245, 0.96);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.workspace-label {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.workspace-title {
|
||||
margin: 0;
|
||||
font-family: "Palatino Linotype", "STSong", "Songti SC", serif;
|
||||
font-size: 30px;
|
||||
line-height: 1;
|
||||
color: var(--ink-strong);
|
||||
}
|
||||
|
||||
.workspace-subtitle {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.status-card {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 4px;
|
||||
min-width: 230px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--line-soft);
|
||||
border-radius: var(--radius-xl);
|
||||
background: rgba(255, 251, 245, 0.96);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.status-label {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.status-date {
|
||||
font-size: 20px;
|
||||
color: var(--ink-strong);
|
||||
}
|
||||
|
||||
.status-copy {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: var(--ink-main);
|
||||
}
|
||||
|
||||
.workspace-stage {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.workspace-stage > * {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 1160px) {
|
||||
.app-shell {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: minmax(240px, auto) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.workspace-head {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.status-card {
|
||||
min-width: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
77
frontend/src/assets/main.css
Normal file
77
frontend/src/assets/main.css
Normal file
@ -0,0 +1,77 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
font-family: "IBM Plex Sans", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
--bg-base: #f1ebe1;
|
||||
--bg-soft: #f7f2ea;
|
||||
--bg-panel: rgba(255, 251, 245, 0.94);
|
||||
--bg-panel-strong: rgba(255, 250, 244, 0.98);
|
||||
--ink-strong: #171412;
|
||||
--ink-main: #2a241d;
|
||||
--ink-soft: #6a6257;
|
||||
--line-soft: rgba(39, 30, 20, 0.08);
|
||||
--line-strong: rgba(39, 30, 20, 0.16);
|
||||
--accent: #8f5a2d;
|
||||
--accent-soft: rgba(143, 90, 45, 0.12);
|
||||
--positive: #1b7a5e;
|
||||
--negative: #9a4b45;
|
||||
--neutral: #67645d;
|
||||
--shadow-soft: 0 10px 28px rgba(59, 44, 27, 0.08);
|
||||
--radius-xl: 32px;
|
||||
--radius-lg: 24px;
|
||||
--radius-md: 18px;
|
||||
--radius-sm: 12px;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
color: var(--ink-main);
|
||||
background: linear-gradient(180deg, #f5eee4 0%, #efe5d9 100%);
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button,
|
||||
input {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.hide-scrollbar {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.hide-scrollbar::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
}
|
||||
145
frontend/src/components/dashboard/AccountInputCard.vue
Normal file
145
frontend/src/components/dashboard/AccountInputCard.vue
Normal file
@ -0,0 +1,145 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { DailyInputAccount } from '../../types'
|
||||
|
||||
interface Props {
|
||||
account: DailyInputAccount
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
updateLinks: [{ accountId: string; links: string[] }]
|
||||
}>()
|
||||
|
||||
const displayLinks = computed(() => (
|
||||
props.account.links.length > 0 ? props.account.links : ['']
|
||||
))
|
||||
|
||||
const filledCount = computed(() => props.account.links.filter((link) => link.trim()).length)
|
||||
|
||||
function commit(links: string[]) {
|
||||
emit('updateLinks', { accountId: props.account.account_id, links })
|
||||
}
|
||||
|
||||
function updateLink(index: number, value: string) {
|
||||
const nextLinks = displayLinks.value.slice()
|
||||
nextLinks[index] = value
|
||||
commit(nextLinks)
|
||||
}
|
||||
|
||||
function addLink() {
|
||||
commit([...props.account.links, ''])
|
||||
}
|
||||
|
||||
function removeLink(index: number) {
|
||||
const nextLinks = displayLinks.value.slice()
|
||||
nextLinks.splice(index, 1)
|
||||
commit(nextLinks)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="account-card">
|
||||
<div class="card-head">
|
||||
<div>
|
||||
<h3 class="card-title">{{ props.account.account_name }}</h3>
|
||||
<p class="card-note">已录入 {{ filledCount }} 条链接</p>
|
||||
</div>
|
||||
<button class="add-button" type="button" :disabled="props.disabled" @click="addLink">
|
||||
+ 添加
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="link-list hide-scrollbar">
|
||||
<div v-for="(link, index) in displayLinks" :key="`${props.account.account_id}-${index}`" class="link-row">
|
||||
<input
|
||||
class="link-input"
|
||||
:disabled="props.disabled"
|
||||
:value="link"
|
||||
type="url"
|
||||
placeholder="粘贴公众号文章链接"
|
||||
@input="updateLink(index, ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
<button
|
||||
class="remove-button"
|
||||
type="button"
|
||||
:disabled="props.disabled"
|
||||
@click="removeLink(index)"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.account-card {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
gap: 14px;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
padding: 18px;
|
||||
border: 1px solid var(--line-soft);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(255, 249, 241, 0.92);
|
||||
}
|
||||
|
||||
.card-head {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
color: var(--ink-strong);
|
||||
}
|
||||
|
||||
.card-note {
|
||||
margin: 6px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.add-button,
|
||||
.remove-button {
|
||||
flex: 0 0 auto;
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
color: var(--ink-main);
|
||||
}
|
||||
|
||||
.link-list {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 10px;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.link-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.link-input {
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--line-soft);
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 255, 255, 0.75);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.link-input:focus {
|
||||
border-color: rgba(143, 90, 45, 0.36);
|
||||
}
|
||||
</style>
|
||||
250
frontend/src/components/dashboard/AppSidebar.vue
Normal file
250
frontend/src/components/dashboard/AppSidebar.vue
Normal file
@ -0,0 +1,250 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { formatDateCompact } from '../../lib/format'
|
||||
import type { DashboardSection } from '../../types'
|
||||
|
||||
interface Props {
|
||||
activeSection: DashboardSection
|
||||
selectedDate: string
|
||||
availableDates: string[]
|
||||
summary: string
|
||||
focusSectors: string[]
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
selectSection: [DashboardSection]
|
||||
selectDate: [string]
|
||||
}>()
|
||||
|
||||
const navItems: Array<{
|
||||
key: DashboardSection
|
||||
label: string
|
||||
note: string
|
||||
}> = [
|
||||
{ key: 'cls', label: '财联社 7x24', note: '按日期查看全天资讯与热点概览' },
|
||||
{ key: 'opinions', label: '大V日报', note: '按日期查看文章观点与对应板块' },
|
||||
{ key: 'input', label: '日报录入', note: '按公众号补录当天文章链接' },
|
||||
]
|
||||
|
||||
const recentDates = computed(() => props.availableDates.slice(0, 6))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-top">
|
||||
<p class="eyebrow">Finance Daily</p>
|
||||
<h1 class="brand-title">财经内容日报</h1>
|
||||
<p class="brand-copy">
|
||||
面向日常资讯跟踪、观点汇总和日报录入,保持结构清晰,减少无效装饰。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<nav class="nav-list">
|
||||
<button
|
||||
v-for="item in navItems"
|
||||
:key="item.key"
|
||||
class="nav-button"
|
||||
:class="{ 'nav-button--active': item.key === props.activeSection }"
|
||||
type="button"
|
||||
@click="emit('selectSection', item.key)"
|
||||
>
|
||||
<span class="nav-label">{{ item.label }}</span>
|
||||
<span class="nav-note">{{ item.note }}</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-block">
|
||||
<label class="block-label" for="sidebar-date">观察日期</label>
|
||||
<input
|
||||
id="sidebar-date"
|
||||
class="date-input"
|
||||
:value="props.selectedDate"
|
||||
type="date"
|
||||
@input="emit('selectDate', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
<div class="date-pills hide-scrollbar">
|
||||
<button
|
||||
v-for="dateValue in recentDates"
|
||||
:key="dateValue"
|
||||
class="date-pill"
|
||||
:class="{ 'date-pill--active': dateValue === props.selectedDate }"
|
||||
type="button"
|
||||
@click="emit('selectDate', dateValue)"
|
||||
>
|
||||
{{ formatDateCompact(dateValue) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-block sidebar-block--dense">
|
||||
<p class="block-label">当前摘要</p>
|
||||
<p class="summary-copy">{{ props.summary }}</p>
|
||||
<div class="sector-tags">
|
||||
<span v-for="sector in props.focusSectors.slice(0, 6)" :key="sector" class="sector-tag">
|
||||
{{ sector }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sidebar {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto auto 1fr;
|
||||
gap: 18px;
|
||||
min-height: 0;
|
||||
padding: 24px;
|
||||
border: 1px solid var(--line-soft);
|
||||
border-radius: var(--radius-xl);
|
||||
background: rgba(255, 252, 247, 0.96);
|
||||
box-shadow: var(--shadow-soft);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.sidebar-top {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.24em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
margin: 0;
|
||||
font-family: "Palatino Linotype", "STSong", "Songti SC", serif;
|
||||
font-size: 30px;
|
||||
line-height: 1.08;
|
||||
color: var(--ink-strong);
|
||||
}
|
||||
|
||||
.brand-copy {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.nav-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.nav-button {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 16px 18px;
|
||||
text-align: left;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(255, 249, 241, 0.68);
|
||||
transition: border-color 160ms ease, background 160ms ease;
|
||||
}
|
||||
|
||||
.nav-button:hover {
|
||||
border-color: var(--line-strong);
|
||||
}
|
||||
|
||||
.nav-button--active {
|
||||
border-color: rgba(143, 90, 45, 0.24);
|
||||
background: linear-gradient(135deg, rgba(143, 90, 45, 0.12), rgba(143, 90, 45, 0.04));
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
font-size: 16px;
|
||||
color: var(--ink-strong);
|
||||
}
|
||||
|
||||
.nav-note {
|
||||
font-size: 13px;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.sidebar-block {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 16px 18px;
|
||||
border: 1px solid var(--line-soft);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(255, 251, 245, 0.76);
|
||||
}
|
||||
|
||||
.sidebar-block--dense {
|
||||
align-content: start;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.block-label {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.date-input {
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--line-soft);
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.date-input:focus {
|
||||
border-color: rgba(143, 90, 45, 0.36);
|
||||
}
|
||||
|
||||
.date-pills {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
overflow: auto hidden;
|
||||
}
|
||||
|
||||
.date-pill {
|
||||
flex: 0 0 auto;
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
color: var(--ink-soft);
|
||||
background: rgba(248, 240, 229, 0.9);
|
||||
}
|
||||
|
||||
.date-pill--active {
|
||||
color: white;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.summary-copy {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: var(--ink-main);
|
||||
}
|
||||
|
||||
.sector-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sector-tag {
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
@media (max-width: 1160px) {
|
||||
.sidebar {
|
||||
grid-template-rows: auto auto auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
603
frontend/src/components/dashboard/ClsNewsPanel.vue
Normal file
603
frontend/src/components/dashboard/ClsNewsPanel.vue
Normal file
@ -0,0 +1,603 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, shallowRef } from 'vue'
|
||||
import { formatDate, formatDateCompact, formatDateTime } from '../../lib/format'
|
||||
import type { ClsNewsDocument, ClsNewsItem, ClsSectorImpact, Sentiment } from '../../types'
|
||||
|
||||
interface Props {
|
||||
news: ClsNewsDocument | null
|
||||
loading: boolean
|
||||
selectedDate: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
refresh: []
|
||||
}>()
|
||||
|
||||
const keyword = shallowRef('')
|
||||
const selectedSource = shallowRef('全部来源')
|
||||
|
||||
const sourceOptions = computed(() => {
|
||||
const sources = new Set((props.news?.items ?? []).map((item) => item.source))
|
||||
return ['全部来源', ...sources]
|
||||
})
|
||||
|
||||
const filteredItems = computed<ClsNewsItem[]>(() => {
|
||||
const items = props.news?.items ?? []
|
||||
const normalizedKeyword = keyword.value.trim().toLowerCase()
|
||||
|
||||
return items.filter((item) => {
|
||||
const matchSource = selectedSource.value === '全部来源' || item.source === selectedSource.value
|
||||
const matchKeyword =
|
||||
!normalizedKeyword ||
|
||||
item.title.toLowerCase().includes(normalizedKeyword) ||
|
||||
item.summary.toLowerCase().includes(normalizedKeyword)
|
||||
return matchSource && matchKeyword
|
||||
})
|
||||
})
|
||||
|
||||
const visibleImpacts = computed<ClsSectorImpact[]>(() => {
|
||||
const impacts = props.news?.sector_impacts ?? []
|
||||
if (!filteredItems.value.length) {
|
||||
return impacts
|
||||
}
|
||||
const visibleSectorSet = new Set(filteredItems.value.flatMap((item) => item.sectors))
|
||||
const matched = impacts.filter((impact) => visibleSectorSet.has(impact.sector))
|
||||
return matched.length ? matched : impacts
|
||||
})
|
||||
|
||||
const watchList = computed(() => props.news?.summary.watch_list ?? [])
|
||||
|
||||
function sentimentClass(sentiment: Sentiment) {
|
||||
return {
|
||||
'sentiment--positive': sentiment === '看多',
|
||||
'sentiment--negative': sentiment === '看空',
|
||||
'sentiment--neutral': sentiment === '中性',
|
||||
}
|
||||
}
|
||||
|
||||
function impactCardClass(impact: ClsSectorImpact) {
|
||||
return {
|
||||
'impact-item--positive': impact.sentiment === '看多',
|
||||
'impact-item--negative': impact.sentiment === '看空',
|
||||
'impact-item--neutral': impact.sentiment === '中性',
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<div class="panel-title-group">
|
||||
<p class="eyebrow">CLS Live Monitor</p>
|
||||
<h2 class="panel-title">{{ formatDate(selectedDate) }} 财联社 7x24</h2>
|
||||
<p class="panel-subtitle">左侧日期会同步联动热点概览和资讯列表,仅展示所选日期当日资讯。</p>
|
||||
</div>
|
||||
<button class="refresh-button" type="button" @click="emit('refresh')">
|
||||
{{ loading ? '刷新中...' : '刷新当日资讯' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="news" class="panel-grid">
|
||||
<article class="summary-card">
|
||||
<div class="summary-top">
|
||||
<div class="summary-heading">
|
||||
<p class="summary-label">{{ news.window_label }}</p>
|
||||
<h3 class="summary-title">热点概览</h3>
|
||||
</div>
|
||||
<div class="summary-meta">
|
||||
<span class="summary-meta-item">{{ formatDateCompact(news.date) }}</span>
|
||||
<span class="summary-meta-item">最近更新 {{ formatDateTime(news.updated_at) }}</span>
|
||||
<span class="summary-meta-item">3 分钟更新</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="summary-copy-group">
|
||||
<p class="summary-copy">{{ news.summary.overview }}</p>
|
||||
<p class="summary-copy summary-copy--secondary">{{ news.summary.hot_topics }}</p>
|
||||
</div>
|
||||
|
||||
<div class="watch-strip">
|
||||
<span class="watch-label">关键方向</span>
|
||||
<div class="watch-tags">
|
||||
<span v-for="sector in watchList" :key="sector" class="sector-tag">{{ sector }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="impact-list">
|
||||
<article
|
||||
v-for="impact in visibleImpacts"
|
||||
:key="impact.sector"
|
||||
class="impact-item"
|
||||
:class="impactCardClass(impact)"
|
||||
>
|
||||
<div class="impact-head">
|
||||
<div class="impact-copy">
|
||||
<strong class="impact-sector">{{ impact.sector }}</strong>
|
||||
<p class="impact-reason">{{ impact.reason }}</p>
|
||||
</div>
|
||||
<span class="sentiment" :class="sentimentClass(impact.sentiment)">{{ impact.sentiment }}</span>
|
||||
</div>
|
||||
|
||||
<ul class="impact-links">
|
||||
<li v-for="title in impact.related_titles" :key="title" class="impact-link-item">
|
||||
{{ title }}
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<div v-if="visibleImpacts.length === 0" class="impact-empty">
|
||||
当前筛选条件下没有可展示的热点概览。
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div class="feed-card">
|
||||
<div class="feed-head">
|
||||
<div class="feed-head-copy">
|
||||
<span class="feed-title">资讯列表</span>
|
||||
<span class="feed-caption">展示 {{ formatDate(selectedDate) }} 全天财联社推送,可直接滚轮浏览</span>
|
||||
</div>
|
||||
<span class="feed-count">{{ filteredItems.length }} / {{ news.items.length }} 条</span>
|
||||
</div>
|
||||
|
||||
<div class="filter-row">
|
||||
<input
|
||||
v-model="keyword"
|
||||
class="filter-input"
|
||||
type="text"
|
||||
placeholder="按资讯标题或摘要筛选"
|
||||
/>
|
||||
<select v-model="selectedSource" class="filter-select">
|
||||
<option v-for="source in sourceOptions" :key="source" :value="source">
|
||||
{{ source }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="feed-list">
|
||||
<article v-for="item in filteredItems" :key="item.id" class="feed-item">
|
||||
<div class="item-meta">
|
||||
<span>{{ item.source }}</span>
|
||||
<span>{{ formatDateTime(item.published_at) }}</span>
|
||||
<span class="sentiment" :class="sentimentClass(item.sentiment)">{{ item.sentiment }}</span>
|
||||
</div>
|
||||
<h3 class="item-title">{{ item.title }}</h3>
|
||||
<p class="item-summary">{{ item.summary }}</p>
|
||||
<div class="item-foot">
|
||||
<div class="sector-row">
|
||||
<span v-for="sector in item.sectors" :key="sector" class="sector-tag">{{ sector }}</span>
|
||||
</div>
|
||||
<a class="link-button" :href="item.reference_url" target="_blank" rel="noreferrer">
|
||||
参考链接
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div v-if="filteredItems.length === 0" class="feed-empty">
|
||||
当前筛选条件下没有匹配的资讯。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty-state">
|
||||
<p>{{ loading ? '资讯加载中...' : '当前日期暂无资讯数据。' }}</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.panel {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
gap: 12px;
|
||||
min-height: 0;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--line-soft);
|
||||
border-radius: var(--radius-xl);
|
||||
background: var(--bg-panel);
|
||||
box-shadow: var(--shadow-soft);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.panel-title-group {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
margin: 0;
|
||||
font-family: "Palatino Linotype", "STSong", "Songti SC", serif;
|
||||
font-size: 24px;
|
||||
color: var(--ink-strong);
|
||||
}
|
||||
|
||||
.panel-subtitle {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.refresh-button {
|
||||
flex: 0 0 auto;
|
||||
padding: 8px 14px;
|
||||
border-radius: 999px;
|
||||
color: white;
|
||||
background: linear-gradient(135deg, #9f6531, #7e4c26);
|
||||
box-shadow: 0 10px 30px rgba(126, 76, 38, 0.22);
|
||||
}
|
||||
|
||||
.panel-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(320px, 0.78fr) minmax(0, 1.22fr);
|
||||
gap: 12px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.summary-card,
|
||||
.feed-card {
|
||||
min-height: 0;
|
||||
border: 1px solid var(--line-soft);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--bg-panel-strong);
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto auto 1fr;
|
||||
gap: 10px;
|
||||
padding: 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.summary-top {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 20px;
|
||||
background:
|
||||
linear-gradient(145deg, rgba(159, 101, 49, 0.12), rgba(255, 255, 255, 0.84)),
|
||||
rgba(255, 249, 242, 0.92);
|
||||
}
|
||||
|
||||
.summary-heading {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.summary-title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
color: var(--ink-strong);
|
||||
}
|
||||
|
||||
.summary-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.summary-meta-item {
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
color: var(--ink-soft);
|
||||
background: rgba(255, 255, 255, 0.74);
|
||||
}
|
||||
|
||||
.summary-copy-group {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.summary-copy {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.58;
|
||||
color: var(--ink-main);
|
||||
}
|
||||
|
||||
.summary-copy--secondary {
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.watch-strip {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.watch-label {
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.watch-tags,
|
||||
.sector-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sector-tag {
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.impact-list {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 8px;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.impact-item {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--line-soft);
|
||||
border-left-width: 4px;
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 248, 239, 0.88);
|
||||
}
|
||||
|
||||
.impact-item--positive {
|
||||
border-left-color: rgba(27, 122, 94, 0.64);
|
||||
}
|
||||
|
||||
.impact-item--negative {
|
||||
border-left-color: rgba(154, 75, 69, 0.64);
|
||||
}
|
||||
|
||||
.impact-item--neutral {
|
||||
border-left-color: rgba(103, 100, 93, 0.46);
|
||||
}
|
||||
|
||||
.impact-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.impact-copy {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.impact-sector {
|
||||
font-size: 15px;
|
||||
color: var(--ink-strong);
|
||||
}
|
||||
|
||||
.impact-reason {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--ink-main);
|
||||
}
|
||||
|
||||
.impact-links {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.impact-link-item {
|
||||
font-size: 12px;
|
||||
line-height: 1.58;
|
||||
}
|
||||
|
||||
.impact-empty,
|
||||
.feed-empty {
|
||||
padding: 18px 16px;
|
||||
border: 1px dashed var(--line-strong);
|
||||
border-radius: 18px;
|
||||
color: var(--ink-soft);
|
||||
background: rgba(255, 252, 248, 0.72);
|
||||
}
|
||||
|
||||
.feed-card {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto 1fr;
|
||||
gap: 10px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.feed-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.feed-head-copy {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.feed-title {
|
||||
font-size: 16px;
|
||||
color: var(--ink-strong);
|
||||
}
|
||||
|
||||
.feed-caption {
|
||||
font-size: 12px;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.feed-count {
|
||||
flex: 0 0 auto;
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
color: var(--ink-main);
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 180px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.filter-input,
|
||||
.filter-select {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--line-soft);
|
||||
border-radius: 14px;
|
||||
color: var(--ink-main);
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.filter-input:focus,
|
||||
.filter-select:focus {
|
||||
border-color: rgba(143, 90, 45, 0.36);
|
||||
}
|
||||
|
||||
.feed-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.feed-item {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--line-soft);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(255, 248, 239, 0.84);
|
||||
}
|
||||
|
||||
.item-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.sentiment {
|
||||
flex: 0 0 auto;
|
||||
padding: 3px 9px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.sentiment--positive {
|
||||
color: var(--positive);
|
||||
background: rgba(27, 122, 94, 0.12);
|
||||
}
|
||||
|
||||
.sentiment--negative {
|
||||
color: var(--negative);
|
||||
background: rgba(154, 75, 69, 0.12);
|
||||
}
|
||||
|
||||
.sentiment--neutral {
|
||||
color: var(--neutral);
|
||||
background: rgba(103, 100, 93, 0.12);
|
||||
}
|
||||
|
||||
.item-title {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
line-height: 1.38;
|
||||
color: var(--ink-strong);
|
||||
}
|
||||
|
||||
.item-summary {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.56;
|
||||
color: var(--ink-main);
|
||||
}
|
||||
|
||||
.item-foot {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.link-button {
|
||||
flex: 0 0 auto;
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
color: var(--ink-strong);
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 240px;
|
||||
border: 1px dashed var(--line-strong);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.panel-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.panel-head,
|
||||
.feed-head,
|
||||
.impact-head {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.refresh-button,
|
||||
.feed-count,
|
||||
.sentiment {
|
||||
align-self: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
194
frontend/src/components/dashboard/InputPanel.vue
Normal file
194
frontend/src/components/dashboard/InputPanel.vue
Normal file
@ -0,0 +1,194 @@
|
||||
<script setup lang="ts">
|
||||
import AccountInputCard from './AccountInputCard.vue'
|
||||
import type { DailyInputAccount } from '../../types'
|
||||
|
||||
interface Props {
|
||||
selectedDate: string
|
||||
accounts: DailyInputAccount[]
|
||||
loading: boolean
|
||||
saving: boolean
|
||||
generating: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
selectDate: [string]
|
||||
updateLinks: [{ accountId: string; links: string[] }]
|
||||
save: []
|
||||
generate: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<p class="eyebrow">Daily Input</p>
|
||||
<h2 class="panel-title">按公众号录入当日链接</h2>
|
||||
</div>
|
||||
<label class="date-box" for="input-date">
|
||||
<span class="date-label">录入日期</span>
|
||||
<input
|
||||
id="input-date"
|
||||
class="date-input"
|
||||
:value="props.selectedDate"
|
||||
type="date"
|
||||
@input="emit('selectDate', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="rule-strip">
|
||||
<span>空链接不会保存</span>
|
||||
<span>重复链接自动去重</span>
|
||||
<span>同一公众号支持多条文章</span>
|
||||
</div>
|
||||
|
||||
<div class="account-grid hide-scrollbar">
|
||||
<AccountInputCard
|
||||
v-for="account in props.accounts"
|
||||
:key="account.account_id"
|
||||
:account="account"
|
||||
:disabled="props.saving || props.generating || props.loading"
|
||||
@update-links="emit('updateLinks', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="action-row">
|
||||
<button
|
||||
class="action-button action-button--secondary"
|
||||
type="button"
|
||||
:disabled="props.saving || props.generating"
|
||||
@click="emit('save')"
|
||||
>
|
||||
{{ props.saving ? '保存中...' : '保存录入' }}
|
||||
</button>
|
||||
<button
|
||||
class="action-button action-button--primary"
|
||||
type="button"
|
||||
:disabled="props.generating"
|
||||
@click="emit('generate')"
|
||||
>
|
||||
{{ props.generating ? '生成中...' : '生成日报' }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.panel {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto 1fr auto;
|
||||
gap: 18px;
|
||||
min-height: 0;
|
||||
padding: 24px;
|
||||
border: 1px solid var(--line-soft);
|
||||
border-radius: var(--radius-xl);
|
||||
background: var(--bg-panel);
|
||||
box-shadow: var(--shadow-soft);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 8px;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
margin: 0;
|
||||
font-family: "Palatino Linotype", "STSong", "Songti SC", serif;
|
||||
font-size: 30px;
|
||||
color: var(--ink-strong);
|
||||
}
|
||||
|
||||
.date-box {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.date-label {
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.date-input {
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--line-soft);
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 255, 255, 0.75);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.rule-strip {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
padding: 14px 18px;
|
||||
border: 1px solid var(--line-soft);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(255, 250, 244, 0.76);
|
||||
font-size: 13px;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.account-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
grid-auto-rows: minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.action-row {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
padding: 12px 18px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.action-button--secondary {
|
||||
color: var(--ink-main);
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.action-button--primary {
|
||||
color: white;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
@media (max-width: 1120px) {
|
||||
.panel-head {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.date-box {
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.account-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
425
frontend/src/components/dashboard/OpinionPanel.vue
Normal file
425
frontend/src/components/dashboard/OpinionPanel.vue
Normal file
@ -0,0 +1,425 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { formatClock, formatDateCompact, formatDateTime } from '../../lib/format'
|
||||
import type { ReportDocument, Sentiment } from '../../types'
|
||||
|
||||
interface Props {
|
||||
report: ReportDocument | null
|
||||
loading: boolean
|
||||
selectedDate: string
|
||||
availableDates: string[]
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
selectDate: [string]
|
||||
}>()
|
||||
|
||||
const visibleDates = computed(() => props.availableDates.slice(0, 8))
|
||||
|
||||
function sentimentClass(sentiment: Sentiment) {
|
||||
return {
|
||||
'sentiment--positive': sentiment === '看多',
|
||||
'sentiment--negative': sentiment === '看空',
|
||||
'sentiment--neutral': sentiment === '中性',
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<div class="head-copy">
|
||||
<p class="eyebrow">Opinion Daily</p>
|
||||
<h2 class="panel-title">大V日报</h2>
|
||||
<p class="panel-subtitle">每篇文章独立展示账号、时间、情绪、摘要和对应板块,减少错位和重复信息。</p>
|
||||
</div>
|
||||
|
||||
<div class="date-tabs hide-scrollbar">
|
||||
<button
|
||||
v-for="dateValue in visibleDates"
|
||||
:key="dateValue"
|
||||
class="date-tab"
|
||||
:class="{ 'date-tab--active': dateValue === props.selectedDate }"
|
||||
type="button"
|
||||
@click="emit('selectDate', dateValue)"
|
||||
>
|
||||
{{ formatDateCompact(dateValue) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="props.report" class="content-shell">
|
||||
<article class="summary-strip">
|
||||
<div class="summary-topline">
|
||||
<p class="summary-date">生成时间 {{ formatDateTime(props.report.generated_at) }}</p>
|
||||
<div class="summary-stats">
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">文章数</span>
|
||||
<strong class="stat-value">{{ props.report.article_count }}</strong>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">账号数</span>
|
||||
<strong class="stat-value">{{ props.report.account_count }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="summary-body">
|
||||
<p class="summary-copy">{{ props.report.summary }}</p>
|
||||
<div class="focus-panel">
|
||||
<span class="focus-label">核心板块</span>
|
||||
<div class="sector-row">
|
||||
<span v-for="sector in props.report.focus_sectors" :key="sector" class="sector-tag">
|
||||
{{ sector }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div class="article-list">
|
||||
<article v-for="article in props.report.articles" :key="article.id" class="article-card">
|
||||
<div class="article-head">
|
||||
<div class="title-group">
|
||||
<h3 class="article-title">{{ article.title }}</h3>
|
||||
<div class="meta-line">
|
||||
<span>{{ article.account_name }}</span>
|
||||
<span>{{ article.article_type }}</span>
|
||||
<span>{{ formatClock(article.published_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="sentiment" :class="sentimentClass(article.sentiment)">{{ article.sentiment }}</span>
|
||||
</div>
|
||||
|
||||
<div class="article-body">
|
||||
<div class="summary-panel">
|
||||
<span class="panel-label">观点摘要</span>
|
||||
<p class="article-summary">{{ article.summary }}</p>
|
||||
</div>
|
||||
|
||||
<div class="sector-panel">
|
||||
<span class="panel-label">对应板块</span>
|
||||
<div class="sector-row">
|
||||
<span v-for="sector in article.sectors" :key="sector" class="sector-tag">
|
||||
{{ sector }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="article-foot">
|
||||
<span class="article-link-copy">查看原文链接</span>
|
||||
<a class="link-button" :href="article.source_url" target="_blank" rel="noreferrer">
|
||||
打开原文
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty-state">
|
||||
<p>{{ props.loading ? '日报加载中...' : '当前日期暂无日报内容。' }}</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.panel {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
gap: 10px;
|
||||
min-height: 0;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--line-soft);
|
||||
border-radius: var(--radius-xl);
|
||||
background: var(--bg-panel);
|
||||
box-shadow: var(--shadow-soft);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.head-copy {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
margin: 0;
|
||||
font-family: "Palatino Linotype", "STSong", "Songti SC", serif;
|
||||
font-size: 24px;
|
||||
color: var(--ink-strong);
|
||||
}
|
||||
|
||||
.panel-subtitle {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.date-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
overflow: auto hidden;
|
||||
}
|
||||
|
||||
.date-tab {
|
||||
flex: 0 0 auto;
|
||||
padding: 9px 14px;
|
||||
border-radius: 999px;
|
||||
color: var(--ink-soft);
|
||||
background: rgba(252, 244, 233, 0.88);
|
||||
}
|
||||
|
||||
.date-tab--active {
|
||||
color: white;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.content-shell {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
gap: 10px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.summary-strip {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--line-soft);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--bg-panel-strong);
|
||||
}
|
||||
|
||||
.summary-topline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.summary-date {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.summary-body {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.6fr) minmax(240px, 0.75fr);
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.summary-copy {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.58;
|
||||
color: var(--ink-main);
|
||||
}
|
||||
|
||||
.summary-stats {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
display: grid;
|
||||
align-content: center;
|
||||
gap: 4px;
|
||||
min-width: 82px;
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(248, 240, 229, 0.9);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 20px;
|
||||
color: var(--ink-strong);
|
||||
}
|
||||
|
||||
.focus-panel {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 252, 247, 0.72);
|
||||
}
|
||||
|
||||
.focus-label,
|
||||
.panel-label {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.article-list {
|
||||
display: grid;
|
||||
gap: 9px;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.article-card {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--line-soft);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(255, 248, 239, 0.84);
|
||||
}
|
||||
|
||||
.article-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.title-group {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.article-title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
line-height: 1.36;
|
||||
color: var(--ink-strong);
|
||||
}
|
||||
|
||||
.meta-line {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.article-body {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.5fr) minmax(220px, 0.7fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.summary-panel,
|
||||
.sector-panel {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 6px;
|
||||
padding: 9px 11px;
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 252, 247, 0.72);
|
||||
}
|
||||
|
||||
.article-summary {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.58;
|
||||
color: var(--ink-main);
|
||||
}
|
||||
|
||||
.sector-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sector-tag {
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.article-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.article-link-copy {
|
||||
font-size: 12px;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.link-button {
|
||||
flex: 0 0 auto;
|
||||
padding: 7px 12px;
|
||||
border-radius: 999px;
|
||||
color: var(--ink-strong);
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.sentiment {
|
||||
flex: 0 0 auto;
|
||||
width: fit-content;
|
||||
padding: 3px 9px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.sentiment--positive {
|
||||
color: var(--positive);
|
||||
background: rgba(27, 122, 94, 0.12);
|
||||
}
|
||||
|
||||
.sentiment--negative {
|
||||
color: var(--negative);
|
||||
background: rgba(154, 75, 69, 0.12);
|
||||
}
|
||||
|
||||
.sentiment--neutral {
|
||||
color: var(--neutral);
|
||||
background: rgba(103, 100, 93, 0.12);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 240px;
|
||||
border: 1px dashed var(--line-strong);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
@media (max-width: 1260px) {
|
||||
.summary-body,
|
||||
.article-body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.summary-topline,
|
||||
.article-head,
|
||||
.article-foot {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
269
frontend/src/composables/useFinanceDashboard.ts
Normal file
269
frontend/src/composables/useFinanceDashboard.ts
Normal file
@ -0,0 +1,269 @@
|
||||
import { computed, reactive, ref, shallowRef, watch } from 'vue'
|
||||
import { ApiError, api } from '../lib/api'
|
||||
import { todayIso } from '../lib/format'
|
||||
import type {
|
||||
Account,
|
||||
ClsNewsDocument,
|
||||
DailyInputAccount,
|
||||
DailyInputDocument,
|
||||
DashboardSection,
|
||||
ReportDocument,
|
||||
ReportListItem,
|
||||
} from '../types'
|
||||
|
||||
function buildEmptyDailyInput(date: string, accounts: Account[]): DailyInputDocument {
|
||||
return {
|
||||
date,
|
||||
updated_at: '',
|
||||
accounts: accounts.map((account) => ({
|
||||
account_id: account.id,
|
||||
account_name: account.name,
|
||||
links: [],
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
function formatError(error: unknown): string {
|
||||
if (error instanceof ApiError) {
|
||||
return `接口请求失败:${error.message}`
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message
|
||||
}
|
||||
return '出现未预期错误,请稍后再试。'
|
||||
}
|
||||
|
||||
function isFulfilled<T>(result: PromiseSettledResult<T>): result is PromiseFulfilledResult<T> {
|
||||
return result.status === 'fulfilled'
|
||||
}
|
||||
|
||||
export function useFinanceDashboard() {
|
||||
const activeSection = shallowRef<DashboardSection>('cls')
|
||||
const selectedDate = shallowRef(todayIso())
|
||||
const statusMessage = shallowRef('系统已加载,可直接查看资讯、切换日期或录入当日链接。')
|
||||
const initialized = shallowRef(false)
|
||||
|
||||
const accounts = ref<Account[]>([])
|
||||
const clsNews = ref<ClsNewsDocument | null>(null)
|
||||
const reportList = ref<ReportListItem[]>([])
|
||||
const opinionReport = ref<ReportDocument | null>(null)
|
||||
const dailyInput = ref<DailyInputDocument | null>(null)
|
||||
|
||||
const loading = reactive({
|
||||
boot: false,
|
||||
news: false,
|
||||
report: false,
|
||||
input: false,
|
||||
save: false,
|
||||
generate: false,
|
||||
})
|
||||
|
||||
const availableDates = computed(() => {
|
||||
const dateSet = new Set(reportList.value.map((item) => item.date))
|
||||
dateSet.add(selectedDate.value)
|
||||
return [...dateSet].sort((left, right) => right.localeCompare(left))
|
||||
})
|
||||
|
||||
const inputAccounts = computed<DailyInputAccount[]>(() => {
|
||||
if (dailyInput.value) {
|
||||
return dailyInput.value.accounts
|
||||
}
|
||||
return buildEmptyDailyInput(selectedDate.value, accounts.value).accounts
|
||||
})
|
||||
|
||||
async function refreshStaticResources() {
|
||||
const [accountData, reportData] = await Promise.all([
|
||||
api.getAccounts(),
|
||||
api.getReportList(),
|
||||
])
|
||||
|
||||
accounts.value = accountData
|
||||
reportList.value = reportData
|
||||
}
|
||||
|
||||
async function refreshDateBundle(date: string) {
|
||||
const [inputResult, reportResult, newsResult] = await Promise.allSettled([
|
||||
api.getDailyInput(date),
|
||||
api.getOpinionReport(date),
|
||||
api.getClsNews(date),
|
||||
])
|
||||
|
||||
if (isFulfilled(inputResult)) {
|
||||
dailyInput.value = inputResult.value
|
||||
}
|
||||
if (isFulfilled(reportResult)) {
|
||||
opinionReport.value = reportResult.value
|
||||
}
|
||||
if (isFulfilled(newsResult)) {
|
||||
clsNews.value = newsResult.value
|
||||
}
|
||||
|
||||
const failures = [inputResult, reportResult, newsResult].filter((result) => result.status === 'rejected')
|
||||
if (failures.length > 0) {
|
||||
throw failures[0].reason
|
||||
}
|
||||
}
|
||||
|
||||
async function initializeDashboard() {
|
||||
loading.boot = true
|
||||
try {
|
||||
await refreshStaticResources()
|
||||
await refreshDateBundle(selectedDate.value)
|
||||
initialized.value = true
|
||||
statusMessage.value = '系统已就绪,支持查看财联社 7x24、浏览大V日报和录入链接。'
|
||||
} catch (error) {
|
||||
statusMessage.value = formatError(error)
|
||||
} finally {
|
||||
loading.boot = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
selectedDate,
|
||||
async (date, _previous, onCleanup) => {
|
||||
if (!initialized.value) {
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
onCleanup(() => {
|
||||
cancelled = true
|
||||
})
|
||||
|
||||
dailyInput.value = null
|
||||
opinionReport.value = null
|
||||
clsNews.value = null
|
||||
loading.input = true
|
||||
loading.report = true
|
||||
loading.news = true
|
||||
try {
|
||||
const [inputResult, reportResult, newsResult] = await Promise.allSettled([
|
||||
api.getDailyInput(date),
|
||||
api.getOpinionReport(date),
|
||||
api.getClsNews(date),
|
||||
])
|
||||
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
if (isFulfilled(inputResult)) {
|
||||
dailyInput.value = inputResult.value
|
||||
}
|
||||
if (isFulfilled(reportResult)) {
|
||||
opinionReport.value = reportResult.value
|
||||
}
|
||||
if (isFulfilled(newsResult)) {
|
||||
clsNews.value = newsResult.value
|
||||
}
|
||||
|
||||
const failures = [inputResult, reportResult, newsResult].filter((result) => result.status === 'rejected')
|
||||
if (failures.length > 0) {
|
||||
statusMessage.value = formatError(failures[0].reason)
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
statusMessage.value = formatError(error)
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
loading.input = false
|
||||
loading.report = false
|
||||
loading.news = false
|
||||
}
|
||||
}
|
||||
},
|
||||
{ flush: 'post' }
|
||||
)
|
||||
|
||||
function setSection(section: DashboardSection) {
|
||||
activeSection.value = section
|
||||
}
|
||||
|
||||
function setSelectedDate(date: string) {
|
||||
selectedDate.value = date
|
||||
}
|
||||
|
||||
function updateAccountLinks(accountId: string, links: string[]) {
|
||||
const current = dailyInput.value ?? buildEmptyDailyInput(selectedDate.value, accounts.value)
|
||||
dailyInput.value = {
|
||||
...current,
|
||||
accounts: current.accounts.map((account) =>
|
||||
account.account_id === accountId
|
||||
? { ...account, links }
|
||||
: account
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
async function saveInput(): Promise<boolean> {
|
||||
loading.save = true
|
||||
try {
|
||||
const payload = {
|
||||
accounts: inputAccounts.value.map((account) => ({
|
||||
account_id: account.account_id,
|
||||
links: account.links,
|
||||
})),
|
||||
}
|
||||
dailyInput.value = await api.saveDailyInput(selectedDate.value, payload)
|
||||
statusMessage.value = `已保存 ${selectedDate.value} 的录入内容。`
|
||||
return true
|
||||
} catch (error) {
|
||||
statusMessage.value = formatError(error)
|
||||
return false
|
||||
} finally {
|
||||
loading.save = false
|
||||
}
|
||||
}
|
||||
|
||||
async function generateReport() {
|
||||
loading.generate = true
|
||||
try {
|
||||
const saved = await saveInput()
|
||||
if (!saved) {
|
||||
return
|
||||
}
|
||||
opinionReport.value = await api.generateOpinionReport(selectedDate.value)
|
||||
reportList.value = await api.getReportList()
|
||||
activeSection.value = 'opinions'
|
||||
statusMessage.value = `已生成 ${selectedDate.value} 的日报,可直接查看。`
|
||||
} catch (error) {
|
||||
statusMessage.value = formatError(error)
|
||||
} finally {
|
||||
loading.generate = false
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshNews() {
|
||||
loading.news = true
|
||||
try {
|
||||
clsNews.value = await api.refreshClsNews(selectedDate.value)
|
||||
statusMessage.value = `财联社资讯已按 ${selectedDate.value} 刷新。`
|
||||
} catch (error) {
|
||||
statusMessage.value = formatError(error)
|
||||
} finally {
|
||||
loading.news = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
activeSection,
|
||||
selectedDate,
|
||||
statusMessage,
|
||||
accounts,
|
||||
clsNews,
|
||||
reportList,
|
||||
opinionReport,
|
||||
dailyInput,
|
||||
inputAccounts,
|
||||
availableDates,
|
||||
loading,
|
||||
initializeDashboard,
|
||||
setSection,
|
||||
setSelectedDate,
|
||||
updateAccountLinks,
|
||||
saveInput,
|
||||
generateReport,
|
||||
refreshNews,
|
||||
}
|
||||
}
|
||||
96
frontend/src/lib/api.ts
Normal file
96
frontend/src/lib/api.ts
Normal file
@ -0,0 +1,96 @@
|
||||
import type {
|
||||
Account,
|
||||
ClsNewsDocument,
|
||||
DailyInputDocument,
|
||||
DailyInputUpsertPayload,
|
||||
ReportDocument,
|
||||
ReportListItem,
|
||||
} from '../types'
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? 'http://127.0.0.1:3000'
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number
|
||||
|
||||
constructor(message: string, status: number) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => {
|
||||
window.setTimeout(resolve, ms)
|
||||
})
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const method = init?.method ?? 'GET'
|
||||
const execute = async () => {
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
...init,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text()
|
||||
throw new ApiError(text || `Request failed with status ${response.status}`, response.status)
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>
|
||||
}
|
||||
|
||||
try {
|
||||
return await execute()
|
||||
} catch (error) {
|
||||
if (method === 'GET') {
|
||||
await sleep(250)
|
||||
return execute()
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function withDateQuery(path: string, date?: string): string {
|
||||
if (!date) {
|
||||
return path
|
||||
}
|
||||
const separator = path.includes('?') ? '&' : '?'
|
||||
return `${path}${separator}date=${encodeURIComponent(date)}`
|
||||
}
|
||||
|
||||
export const api = {
|
||||
getAccounts() {
|
||||
return request<Account[]>('/api/accounts')
|
||||
},
|
||||
getClsNews(date?: string) {
|
||||
return request<ClsNewsDocument>(withDateQuery('/api/cls-news', date))
|
||||
},
|
||||
refreshClsNews(date?: string) {
|
||||
return request<ClsNewsDocument>(withDateQuery('/api/cls-news/refresh', date), { method: 'POST' })
|
||||
},
|
||||
getDailyInput(date: string) {
|
||||
return request<DailyInputDocument>(`/api/daily-inputs/${date}`)
|
||||
},
|
||||
saveDailyInput(date: string, payload: DailyInputUpsertPayload) {
|
||||
return request<DailyInputDocument>(`/api/daily-inputs/${date}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
},
|
||||
getOpinionReport(date: string) {
|
||||
return request<ReportDocument>(`/api/opinions/${date}`)
|
||||
},
|
||||
generateOpinionReport(date: string) {
|
||||
return request<ReportDocument>(`/api/reports/${date}/generate`, {
|
||||
method: 'POST',
|
||||
})
|
||||
},
|
||||
getReportList() {
|
||||
return request<ReportListItem[]>('/api/reports')
|
||||
},
|
||||
}
|
||||
40
frontend/src/lib/format.ts
Normal file
40
frontend/src/lib/format.ts
Normal file
@ -0,0 +1,40 @@
|
||||
export function todayIso(): string {
|
||||
return new Date().toLocaleDateString('en-CA')
|
||||
}
|
||||
|
||||
export function formatDate(dateValue: string): string {
|
||||
const date = new Date(dateValue)
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
weekday: 'short',
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
export function formatDateCompact(dateValue: string): string {
|
||||
const date = new Date(dateValue)
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
export function formatDateTime(dateValue: string): string {
|
||||
const date = new Date(dateValue)
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
export function formatClock(dateValue: string): string {
|
||||
const date = new Date(dateValue)
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}).format(date)
|
||||
}
|
||||
6
frontend/src/main.ts
Normal file
6
frontend/src/main.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import './assets/main.css'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
|
||||
91
frontend/src/types/index.ts
Normal file
91
frontend/src/types/index.ts
Normal file
@ -0,0 +1,91 @@
|
||||
export type DashboardSection = 'cls' | 'opinions' | 'input'
|
||||
export type Sentiment = '看多' | '看空' | '中性'
|
||||
|
||||
export interface Account {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface DailyInputAccount {
|
||||
account_id: string
|
||||
account_name: string
|
||||
links: string[]
|
||||
}
|
||||
|
||||
export interface DailyInputDocument {
|
||||
date: string
|
||||
updated_at: string
|
||||
accounts: DailyInputAccount[]
|
||||
}
|
||||
|
||||
export interface DailyInputUpsertPayload {
|
||||
accounts: Array<{
|
||||
account_id: string
|
||||
links: string[]
|
||||
}>
|
||||
}
|
||||
|
||||
export interface OpinionArticle {
|
||||
id: string
|
||||
account_id: string
|
||||
account_name: string
|
||||
title: string
|
||||
published_at: string
|
||||
summary: string
|
||||
source_url: string
|
||||
sectors: string[]
|
||||
sentiment: Sentiment
|
||||
article_type: string
|
||||
}
|
||||
|
||||
export interface ReportDocument {
|
||||
date: string
|
||||
generated_at: string
|
||||
summary: string
|
||||
focus_sectors: string[]
|
||||
article_count: number
|
||||
account_count: number
|
||||
articles: OpinionArticle[]
|
||||
}
|
||||
|
||||
export interface ReportListItem {
|
||||
date: string
|
||||
generated_at: string
|
||||
summary: string
|
||||
article_count: number
|
||||
focus_sectors: string[]
|
||||
}
|
||||
|
||||
export interface ClsNewsItem {
|
||||
id: string
|
||||
title: string
|
||||
published_at: string
|
||||
source: string
|
||||
summary: string
|
||||
reference_url: string
|
||||
sectors: string[]
|
||||
sentiment: Sentiment
|
||||
}
|
||||
|
||||
export interface ClsNewsSummary {
|
||||
overview: string
|
||||
hot_topics: string
|
||||
watch_list: string[]
|
||||
}
|
||||
|
||||
export interface ClsSectorImpact {
|
||||
sector: string
|
||||
sentiment: Sentiment
|
||||
reason: string
|
||||
related_titles: string[]
|
||||
}
|
||||
|
||||
export interface ClsNewsDocument {
|
||||
date: string
|
||||
updated_at: string
|
||||
window_label: string
|
||||
summary: ClsNewsSummary
|
||||
sector_impacts: ClsSectorImpact[]
|
||||
items: ClsNewsItem[]
|
||||
}
|
||||
2
frontend/src/vite-env.d.ts
vendored
Normal file
2
frontend/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
20
frontend/tsconfig.json
Normal file
20
frontend/tsconfig.json
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"types": ["vite/client"],
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
|
||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
|
||||
7
frontend/vite.config.ts
Normal file
7
frontend/vite.config.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user