Initial commit

This commit is contained in:
wanghep
2026-03-20 22:59:54 +08:00
commit 68b9e253e2
63 changed files with 8116 additions and 0 deletions

13
frontend/index.html Normal file
View 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

File diff suppressed because it is too large Load Diff

20
frontend/package.json Normal file
View 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"
}
}

View 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
View 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>

View 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;
}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
View 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')
},
}

View 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
View File

@ -0,0 +1,6 @@
import { createApp } from 'vue'
import App from './App.vue'
import './assets/main.css'
createApp(App).mount('#app')

View 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
View File

@ -0,0 +1,2 @@
/// <reference types="vite/client" />

20
frontend/tsconfig.json Normal file
View 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" }]
}

View 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
View File

@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
})