更新首页
This commit is contained in:
@@ -42,6 +42,16 @@ export type Indicator = Prisma.IndicatorModel
|
||||
*
|
||||
*/
|
||||
export type IndicatorPoint = Prisma.IndicatorPointModel
|
||||
/**
|
||||
* Model News
|
||||
*
|
||||
*/
|
||||
export type News = Prisma.NewsModel
|
||||
/**
|
||||
* Model Alert
|
||||
*
|
||||
*/
|
||||
export type Alert = Prisma.AlertModel
|
||||
/**
|
||||
* Model Econ_SowInventory
|
||||
*
|
||||
|
||||
@@ -66,6 +66,16 @@ export type Indicator = Prisma.IndicatorModel
|
||||
*
|
||||
*/
|
||||
export type IndicatorPoint = Prisma.IndicatorPointModel
|
||||
/**
|
||||
* Model News
|
||||
*
|
||||
*/
|
||||
export type News = Prisma.NewsModel
|
||||
/**
|
||||
* Model Alert
|
||||
*
|
||||
*/
|
||||
export type Alert = Prisma.AlertModel
|
||||
/**
|
||||
* Model Econ_SowInventory
|
||||
*
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -389,6 +389,8 @@ export const ModelName = {
|
||||
Strategy: 'Strategy',
|
||||
Indicator: 'Indicator',
|
||||
IndicatorPoint: 'IndicatorPoint',
|
||||
News: 'News',
|
||||
Alert: 'Alert',
|
||||
Econ_SowInventory: 'Econ_SowInventory',
|
||||
ScraperCache: 'ScraperCache'
|
||||
} as const
|
||||
@@ -406,7 +408,7 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
|
||||
omit: GlobalOmitOptions
|
||||
}
|
||||
meta: {
|
||||
modelProps: "user" | "article" | "strategy" | "indicator" | "indicatorPoint" | "econ_SowInventory" | "scraperCache"
|
||||
modelProps: "user" | "article" | "strategy" | "indicator" | "indicatorPoint" | "news" | "alert" | "econ_SowInventory" | "scraperCache"
|
||||
txIsolationLevel: TransactionIsolationLevel
|
||||
}
|
||||
model: {
|
||||
@@ -740,6 +742,138 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
|
||||
}
|
||||
}
|
||||
}
|
||||
News: {
|
||||
payload: Prisma.$NewsPayload<ExtArgs>
|
||||
fields: Prisma.NewsFieldRefs
|
||||
operations: {
|
||||
findUnique: {
|
||||
args: Prisma.NewsFindUniqueArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$NewsPayload> | null
|
||||
}
|
||||
findUniqueOrThrow: {
|
||||
args: Prisma.NewsFindUniqueOrThrowArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$NewsPayload>
|
||||
}
|
||||
findFirst: {
|
||||
args: Prisma.NewsFindFirstArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$NewsPayload> | null
|
||||
}
|
||||
findFirstOrThrow: {
|
||||
args: Prisma.NewsFindFirstOrThrowArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$NewsPayload>
|
||||
}
|
||||
findMany: {
|
||||
args: Prisma.NewsFindManyArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$NewsPayload>[]
|
||||
}
|
||||
create: {
|
||||
args: Prisma.NewsCreateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$NewsPayload>
|
||||
}
|
||||
createMany: {
|
||||
args: Prisma.NewsCreateManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
delete: {
|
||||
args: Prisma.NewsDeleteArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$NewsPayload>
|
||||
}
|
||||
update: {
|
||||
args: Prisma.NewsUpdateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$NewsPayload>
|
||||
}
|
||||
deleteMany: {
|
||||
args: Prisma.NewsDeleteManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
updateMany: {
|
||||
args: Prisma.NewsUpdateManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
upsert: {
|
||||
args: Prisma.NewsUpsertArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$NewsPayload>
|
||||
}
|
||||
aggregate: {
|
||||
args: Prisma.NewsAggregateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.AggregateNews>
|
||||
}
|
||||
groupBy: {
|
||||
args: Prisma.NewsGroupByArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.NewsGroupByOutputType>[]
|
||||
}
|
||||
count: {
|
||||
args: Prisma.NewsCountArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.NewsCountAggregateOutputType> | number
|
||||
}
|
||||
}
|
||||
}
|
||||
Alert: {
|
||||
payload: Prisma.$AlertPayload<ExtArgs>
|
||||
fields: Prisma.AlertFieldRefs
|
||||
operations: {
|
||||
findUnique: {
|
||||
args: Prisma.AlertFindUniqueArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$AlertPayload> | null
|
||||
}
|
||||
findUniqueOrThrow: {
|
||||
args: Prisma.AlertFindUniqueOrThrowArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$AlertPayload>
|
||||
}
|
||||
findFirst: {
|
||||
args: Prisma.AlertFindFirstArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$AlertPayload> | null
|
||||
}
|
||||
findFirstOrThrow: {
|
||||
args: Prisma.AlertFindFirstOrThrowArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$AlertPayload>
|
||||
}
|
||||
findMany: {
|
||||
args: Prisma.AlertFindManyArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$AlertPayload>[]
|
||||
}
|
||||
create: {
|
||||
args: Prisma.AlertCreateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$AlertPayload>
|
||||
}
|
||||
createMany: {
|
||||
args: Prisma.AlertCreateManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
delete: {
|
||||
args: Prisma.AlertDeleteArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$AlertPayload>
|
||||
}
|
||||
update: {
|
||||
args: Prisma.AlertUpdateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$AlertPayload>
|
||||
}
|
||||
deleteMany: {
|
||||
args: Prisma.AlertDeleteManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
updateMany: {
|
||||
args: Prisma.AlertUpdateManyArgs<ExtArgs>
|
||||
result: BatchPayload
|
||||
}
|
||||
upsert: {
|
||||
args: Prisma.AlertUpsertArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.PayloadToResult<Prisma.$AlertPayload>
|
||||
}
|
||||
aggregate: {
|
||||
args: Prisma.AlertAggregateArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.AggregateAlert>
|
||||
}
|
||||
groupBy: {
|
||||
args: Prisma.AlertGroupByArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.AlertGroupByOutputType>[]
|
||||
}
|
||||
count: {
|
||||
args: Prisma.AlertCountArgs<ExtArgs>
|
||||
result: runtime.Types.Utils.Optional<Prisma.AlertCountAggregateOutputType> | number
|
||||
}
|
||||
}
|
||||
}
|
||||
Econ_SowInventory: {
|
||||
payload: Prisma.$Econ_SowInventoryPayload<ExtArgs>
|
||||
fields: Prisma.Econ_SowInventoryFieldRefs
|
||||
@@ -976,6 +1110,36 @@ export const IndicatorPointScalarFieldEnum = {
|
||||
export type IndicatorPointScalarFieldEnum = (typeof IndicatorPointScalarFieldEnum)[keyof typeof IndicatorPointScalarFieldEnum]
|
||||
|
||||
|
||||
export const NewsScalarFieldEnum = {
|
||||
id: 'id',
|
||||
title: 'title',
|
||||
summary: 'summary',
|
||||
source: 'source',
|
||||
url: 'url',
|
||||
publishedAt: 'publishedAt',
|
||||
impactRank: 'impactRank',
|
||||
createdAt: 'createdAt'
|
||||
} as const
|
||||
|
||||
export type NewsScalarFieldEnum = (typeof NewsScalarFieldEnum)[keyof typeof NewsScalarFieldEnum]
|
||||
|
||||
|
||||
export const AlertScalarFieldEnum = {
|
||||
id: 'id',
|
||||
indicatorName: 'indicatorName',
|
||||
period: 'period',
|
||||
valueA: 'valueA',
|
||||
valueB: 'valueB',
|
||||
sourceA: 'sourceA',
|
||||
sourceB: 'sourceB',
|
||||
diff: 'diff',
|
||||
diffRatio: 'diffRatio',
|
||||
createdAt: 'createdAt'
|
||||
} as const
|
||||
|
||||
export type AlertScalarFieldEnum = (typeof AlertScalarFieldEnum)[keyof typeof AlertScalarFieldEnum]
|
||||
|
||||
|
||||
export const Econ_SowInventoryScalarFieldEnum = {
|
||||
id: 'id',
|
||||
month: 'month',
|
||||
@@ -1086,6 +1250,26 @@ export const IndicatorPointOrderByRelevanceFieldEnum = {
|
||||
export type IndicatorPointOrderByRelevanceFieldEnum = (typeof IndicatorPointOrderByRelevanceFieldEnum)[keyof typeof IndicatorPointOrderByRelevanceFieldEnum]
|
||||
|
||||
|
||||
export const NewsOrderByRelevanceFieldEnum = {
|
||||
title: 'title',
|
||||
summary: 'summary',
|
||||
source: 'source',
|
||||
url: 'url'
|
||||
} as const
|
||||
|
||||
export type NewsOrderByRelevanceFieldEnum = (typeof NewsOrderByRelevanceFieldEnum)[keyof typeof NewsOrderByRelevanceFieldEnum]
|
||||
|
||||
|
||||
export const AlertOrderByRelevanceFieldEnum = {
|
||||
indicatorName: 'indicatorName',
|
||||
period: 'period',
|
||||
sourceA: 'sourceA',
|
||||
sourceB: 'sourceB'
|
||||
} as const
|
||||
|
||||
export type AlertOrderByRelevanceFieldEnum = (typeof AlertOrderByRelevanceFieldEnum)[keyof typeof AlertOrderByRelevanceFieldEnum]
|
||||
|
||||
|
||||
export const Econ_SowInventoryOrderByRelevanceFieldEnum = {
|
||||
month: 'month'
|
||||
} as const
|
||||
@@ -1263,6 +1447,8 @@ export type GlobalOmitConfig = {
|
||||
strategy?: Prisma.StrategyOmit
|
||||
indicator?: Prisma.IndicatorOmit
|
||||
indicatorPoint?: Prisma.IndicatorPointOmit
|
||||
news?: Prisma.NewsOmit
|
||||
alert?: Prisma.AlertOmit
|
||||
econ_SowInventory?: Prisma.Econ_SowInventoryOmit
|
||||
scraperCache?: Prisma.ScraperCacheOmit
|
||||
}
|
||||
|
||||
@@ -56,6 +56,8 @@ export const ModelName = {
|
||||
Strategy: 'Strategy',
|
||||
Indicator: 'Indicator',
|
||||
IndicatorPoint: 'IndicatorPoint',
|
||||
News: 'News',
|
||||
Alert: 'Alert',
|
||||
Econ_SowInventory: 'Econ_SowInventory',
|
||||
ScraperCache: 'ScraperCache'
|
||||
} as const
|
||||
@@ -141,6 +143,36 @@ export const IndicatorPointScalarFieldEnum = {
|
||||
export type IndicatorPointScalarFieldEnum = (typeof IndicatorPointScalarFieldEnum)[keyof typeof IndicatorPointScalarFieldEnum]
|
||||
|
||||
|
||||
export const NewsScalarFieldEnum = {
|
||||
id: 'id',
|
||||
title: 'title',
|
||||
summary: 'summary',
|
||||
source: 'source',
|
||||
url: 'url',
|
||||
publishedAt: 'publishedAt',
|
||||
impactRank: 'impactRank',
|
||||
createdAt: 'createdAt'
|
||||
} as const
|
||||
|
||||
export type NewsScalarFieldEnum = (typeof NewsScalarFieldEnum)[keyof typeof NewsScalarFieldEnum]
|
||||
|
||||
|
||||
export const AlertScalarFieldEnum = {
|
||||
id: 'id',
|
||||
indicatorName: 'indicatorName',
|
||||
period: 'period',
|
||||
valueA: 'valueA',
|
||||
valueB: 'valueB',
|
||||
sourceA: 'sourceA',
|
||||
sourceB: 'sourceB',
|
||||
diff: 'diff',
|
||||
diffRatio: 'diffRatio',
|
||||
createdAt: 'createdAt'
|
||||
} as const
|
||||
|
||||
export type AlertScalarFieldEnum = (typeof AlertScalarFieldEnum)[keyof typeof AlertScalarFieldEnum]
|
||||
|
||||
|
||||
export const Econ_SowInventoryScalarFieldEnum = {
|
||||
id: 'id',
|
||||
month: 'month',
|
||||
@@ -251,6 +283,26 @@ export const IndicatorPointOrderByRelevanceFieldEnum = {
|
||||
export type IndicatorPointOrderByRelevanceFieldEnum = (typeof IndicatorPointOrderByRelevanceFieldEnum)[keyof typeof IndicatorPointOrderByRelevanceFieldEnum]
|
||||
|
||||
|
||||
export const NewsOrderByRelevanceFieldEnum = {
|
||||
title: 'title',
|
||||
summary: 'summary',
|
||||
source: 'source',
|
||||
url: 'url'
|
||||
} as const
|
||||
|
||||
export type NewsOrderByRelevanceFieldEnum = (typeof NewsOrderByRelevanceFieldEnum)[keyof typeof NewsOrderByRelevanceFieldEnum]
|
||||
|
||||
|
||||
export const AlertOrderByRelevanceFieldEnum = {
|
||||
indicatorName: 'indicatorName',
|
||||
period: 'period',
|
||||
sourceA: 'sourceA',
|
||||
sourceB: 'sourceB'
|
||||
} as const
|
||||
|
||||
export type AlertOrderByRelevanceFieldEnum = (typeof AlertOrderByRelevanceFieldEnum)[keyof typeof AlertOrderByRelevanceFieldEnum]
|
||||
|
||||
|
||||
export const Econ_SowInventoryOrderByRelevanceFieldEnum = {
|
||||
month: 'month'
|
||||
} as const
|
||||
|
||||
@@ -13,6 +13,8 @@ export type * from './models/Article.ts'
|
||||
export type * from './models/Strategy.ts'
|
||||
export type * from './models/Indicator.ts'
|
||||
export type * from './models/IndicatorPoint.ts'
|
||||
export type * from './models/News.ts'
|
||||
export type * from './models/Alert.ts'
|
||||
export type * from './models/Econ_SowInventory.ts'
|
||||
export type * from './models/ScraperCache.ts'
|
||||
export type * from './commonInputTypes.ts'
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Generated
+22
@@ -19,6 +19,7 @@
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mariadb": "^3.5.3",
|
||||
"marked": "^17.0.6",
|
||||
"node-cron": "^3.0.3",
|
||||
"prisma": "^7.7.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
@@ -4880,6 +4881,18 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/node-cron": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/node-cron/-/node-cron-3.0.3.tgz",
|
||||
"integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"uuid": "8.3.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-fetch-native": {
|
||||
"version": "1.6.7",
|
||||
"resolved": "https://registry.npmmirror.com/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
|
||||
@@ -6134,6 +6147,15 @@
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmmirror.com/uuid/-/uuid-8.3.2.tgz",
|
||||
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/valibot": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/valibot/-/valibot-1.2.0.tgz",
|
||||
|
||||
+3
-1
@@ -21,7 +21,8 @@
|
||||
"tauri:android:dev": "tauri android dev",
|
||||
"tauri:android:build": "tauri android build",
|
||||
"tauri:android:apk": "tauri android build --apk",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"data:news": "node --no-warnings scripts/refresh-news.cjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@huolala-tech/page-spy": "^1.5.7",
|
||||
@@ -35,6 +36,7 @@
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mariadb": "^3.5.3",
|
||||
"marked": "^17.0.6",
|
||||
"node-cron": "^3.0.3",
|
||||
"prisma": "^7.7.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE `News` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`title` VARCHAR(191) NOT NULL,
|
||||
`summary` VARCHAR(191) NOT NULL DEFAULT '',
|
||||
`source` VARCHAR(191) NOT NULL DEFAULT '',
|
||||
`url` VARCHAR(191) NOT NULL DEFAULT '',
|
||||
`publishedAt` DATETIME(3) NOT NULL,
|
||||
`impactRank` INTEGER NOT NULL,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
|
||||
UNIQUE INDEX `News_title_publishedAt_key`(`title`, `publishedAt`),
|
||||
INDEX `News_publishedAt_idx`(`publishedAt`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `Alert` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`indicatorName` VARCHAR(191) NOT NULL,
|
||||
`period` VARCHAR(191) NOT NULL,
|
||||
`valueA` DOUBLE NOT NULL,
|
||||
`valueB` DOUBLE NOT NULL,
|
||||
`sourceA` VARCHAR(191) NOT NULL,
|
||||
`sourceB` VARCHAR(191) NOT NULL,
|
||||
`diff` DOUBLE NOT NULL,
|
||||
`diffRatio` DOUBLE NOT NULL,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
|
||||
INDEX `Alert_createdAt_idx`(`createdAt`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
@@ -66,6 +66,38 @@ model IndicatorPoint {
|
||||
@@unique([indicatorId, period])
|
||||
}
|
||||
|
||||
|
||||
// 新闻: 每天 3 点由 mx_finance_search_news 拉取, 按影响大小排序, 累加多日供首页无限滚动
|
||||
model News {
|
||||
id Int @id @default(autoincrement())
|
||||
title String
|
||||
summary String @default("")
|
||||
source String @default("")
|
||||
url String @default("")
|
||||
publishedAt DateTime
|
||||
impactRank Int
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([title, publishedAt])
|
||||
@@index([publishedAt])
|
||||
}
|
||||
|
||||
// 报警: refresh 写库时比对同 (indicatorName, period) 的跨源数据, 差异超阈值记录
|
||||
model Alert {
|
||||
id Int @id @default(autoincrement())
|
||||
indicatorName String
|
||||
period String
|
||||
valueA Float
|
||||
valueB Float
|
||||
sourceA String
|
||||
sourceB String
|
||||
diff Float
|
||||
diffRatio Float
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
model Econ_SowInventory {
|
||||
id Int @id @default(autoincrement())
|
||||
month String @unique
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
### 报警模块
|
||||
World Bank 中的数据只用来对比妙想 MCP 拉取的数据正确性。当同一时间的同一项数据不同时,记录一个报警。
|
||||
|
||||
|
||||
|
||||
## 监控
|
||||
监控分为服务器监控和服务监控两部分。
|
||||
服务器监控对接 komari,地址:https://komari.honor3.com/
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
// 新闻 fetcher: 每天 3 点调用 mx_finance_search_news 拉国内外经济政治新闻
|
||||
// 按影响大小排序, 取前 10 条 (LLM 可能多/少几条, 全部入 DB, 按 impactRank 标记)
|
||||
// 数据直接写 News 表, 与 Indicator 解耦
|
||||
import axios from 'axios'
|
||||
import mysql from 'mysql2/promise'
|
||||
|
||||
const MCP_BASE_DEFAULT = 'https://mxapi.eastmoney.com/mxds/mcp'
|
||||
const TIMEOUT = 90000
|
||||
const TOP_N = 10
|
||||
|
||||
const QUERIES = [
|
||||
'今天国内外最新重大经济政治新闻, 按影响大小排序, 给出前 ' + TOP_N + ' 条, 每条含标题、摘要、发布时间、来源、链接',
|
||||
]
|
||||
|
||||
class McpClient {
|
||||
constructor(baseUrl, apiKey) { this.baseUrl = baseUrl; this.apiKey = apiKey; this.id = 0 }
|
||||
async _request(method, params) {
|
||||
const payload = { jsonrpc: '2.0', id: ++this.id, method, ...(params ? { params } : {}) }
|
||||
const resp = await axios.post(this.baseUrl, payload, {
|
||||
timeout: TIMEOUT,
|
||||
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json, text/event-stream', 'em_api_key': this.apiKey },
|
||||
responseType: 'text', transformResponse: [(d) => d], validateStatus: () => true,
|
||||
})
|
||||
if (resp.status >= 400) throw new Error('HTTP ' + resp.status + ': ' + String(resp.data).slice(0, 300))
|
||||
const parsed = JSON.parse(String(resp.data || ''))
|
||||
if (parsed.error) throw new Error('MCP error ' + parsed.error.code + ': ' + parsed.error.message)
|
||||
return parsed.result || parsed
|
||||
}
|
||||
async callTool(name, args = {}) {
|
||||
const r = await this._request('tools/call', { name, arguments: args })
|
||||
if (r.isError) throw new Error('tool error: ' + (r.content || []).map((c) => c.text || '').join('; '))
|
||||
return r
|
||||
}
|
||||
}
|
||||
|
||||
function parsePublishedAt(s) {
|
||||
if (!s) return null
|
||||
const d = new Date(String(s).replace(/-/g, '/'))
|
||||
return isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
|
||||
function pickNewsSheet(sheets) {
|
||||
if (!Array.isArray(sheets) || sheets.length === 0) return null
|
||||
return sheets.find((s) => s.sheetName && (s.sheetName.includes('资讯') || s.sheetName.includes('新闻'))) || sheets[0]
|
||||
}
|
||||
|
||||
// items[i] = [title, summary, publishedAt, source, url]
|
||||
function parseNewsSheet(sheet, impactBase = 0) {
|
||||
if (!sheet || !sheet.items) return []
|
||||
const out = []
|
||||
for (let i = 0; i < sheet.items.length; i++) {
|
||||
const r = sheet.items[i]
|
||||
const title = String(r[0] || '').trim()
|
||||
if (!title) continue
|
||||
const publishedAt = parsePublishedAt(r[2])
|
||||
if (!publishedAt) continue
|
||||
out.push({
|
||||
title,
|
||||
summary: String(r[1] || '').trim().slice(0, 180),
|
||||
source: String(r[3] || '').trim().slice(0, 80),
|
||||
url: String(r[4] || '').trim().slice(0, 400),
|
||||
publishedAt,
|
||||
impactRank: impactBase + i + 1,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export const META = { name: 'news-mcp', description: '妙想 MCP 新闻资讯 (mx_finance_search_news)' }
|
||||
|
||||
export async function fetchNewsList() {
|
||||
const key = process.env.EM_API_KEY
|
||||
if (!key) throw new Error('缺少 EM_API_KEY')
|
||||
const base = process.env.EASTMONEY_MCP_BASE || MCP_BASE_DEFAULT
|
||||
const client = new McpClient(base, key)
|
||||
const all = []
|
||||
let impactBase = 0
|
||||
for (const q of QUERIES) {
|
||||
try {
|
||||
console.log('[News] 调用: ' + q)
|
||||
const r = await client.callTool('mx_finance_search_news', { query: q })
|
||||
const text = (r.content || []).map((c) => c.text || '').join('')
|
||||
const payload = JSON.parse(text)
|
||||
const sheets = payload.data || (Array.isArray(payload) ? payload : [payload])
|
||||
const sheet = pickNewsSheet(sheets)
|
||||
const news = parseNewsSheet(sheet, impactBase)
|
||||
console.log('[News] 解析 ' + news.length + ' 条')
|
||||
all.push(...news)
|
||||
impactBase = all.length
|
||||
} catch (e) {
|
||||
console.error('[News] 拉取失败:', e.message)
|
||||
}
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
function parseUrl(url) {
|
||||
const u = new URL(url)
|
||||
return { host: u.hostname, port: Number(u.port) || 3306, user: decodeURIComponent(u.username), password: decodeURIComponent(u.password), database: u.pathname.replace(/^\//, '') }
|
||||
}
|
||||
|
||||
export async function persistNews(newsList) {
|
||||
if (!process.env.DATABASE_URL) throw new Error('缺少 DATABASE_URL')
|
||||
if (!newsList || newsList.length === 0) return { inserted: 0, skipped: 0 }
|
||||
const conn = await mysql.createConnection(parseUrl(process.env.DATABASE_URL))
|
||||
let inserted = 0, skipped = 0
|
||||
try {
|
||||
for (const n of newsList) {
|
||||
const [r] = await conn.execute(
|
||||
'INSERT INTO `News` (`title`, `summary`, `source`, `url`, `publishedAt`, `impactRank`, `createdAt`) ' +
|
||||
'VALUES (?, ?, ?, ?, ?, ?, NOW(3)) ' +
|
||||
'ON DUPLICATE KEY UPDATE `impactRank` = VALUES(`impactRank`)',
|
||||
[n.title, n.summary, n.source, n.url, n.publishedAt, n.impactRank],
|
||||
)
|
||||
if (r.affectedRows === 1) inserted++
|
||||
else skipped++
|
||||
}
|
||||
} finally {
|
||||
await conn.end()
|
||||
}
|
||||
return { inserted, skipped }
|
||||
}
|
||||
@@ -147,6 +147,13 @@ async function main() {
|
||||
console.log('[Refresh] 同步隐藏标记: ' + names.length + ' 个指标 hidden=true (数据保留, 不展示)')
|
||||
}
|
||||
|
||||
// 6) 跨源对比报警: 全量跑一次 (即便无新指标也跑, 防止上次漏检)
|
||||
try {
|
||||
const m = await import('../server/alert-engine.js')
|
||||
const a = await m.checkAfterRefresh(conn)
|
||||
console.log('[Refresh] 报警检测: 新增 ' + a.inserted + ' 条 (扫描 ' + a.scanned + ')')
|
||||
} catch (e) { console.error('[Refresh] 报警检测异常:', e.message) }
|
||||
|
||||
if (allItems.length === 0) {
|
||||
console.log('[Refresh] 无指标待写, 不写库 (DB 优先策略生效)')
|
||||
return
|
||||
@@ -163,7 +170,7 @@ async function main() {
|
||||
totalPoints += points
|
||||
console.log('[Refresh] ' + it.name + (hidden ? ' (hidden)' : '') + ': ' + points + ' 个明细点')
|
||||
}
|
||||
console.log('[Refresh] 写入完成: ' + ins + ' 个指标有数据, ' + upd + ' 个无数据, 共 ' + totalPoints + ' 个明细点\n')
|
||||
|
||||
|
||||
} finally {
|
||||
await conn.end()
|
||||
@@ -175,3 +182,4 @@ main().catch((e) => {
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
require('dotenv').config()
|
||||
async function main() {
|
||||
console.log('[News] 开始拉取...')
|
||||
const t0 = Date.now()
|
||||
const mod = await import('./fetchers/news.fetcher.js')
|
||||
const list = await mod.fetchNewsList()
|
||||
console.log('[News] 拉取 ' + list.length + ' 条, 耗时 ' + ((Date.now() - t0) / 1000).toFixed(1) + 's')
|
||||
if (list.length === 0) { console.log('[News] 无数据, 跳过'); return }
|
||||
const r = await mod.persistNews(list)
|
||||
console.log('[News] 入库: 新增 ' + r.inserted + ' 条, 重复跳过 ' + r.skipped + ' 条')
|
||||
}
|
||||
main().catch((e) => { console.error('[News] 失败:', e); process.exit(1) })
|
||||
@@ -0,0 +1,46 @@
|
||||
import { prisma } from './db.js'
|
||||
|
||||
// 跨源对比: 同 (indicatorName, period) 多源数据差异超阈值则插 Alert
|
||||
// conn: mysql2 连接
|
||||
// 阈值: 绝对差 >= 0.01 且 相对差 >= 1%
|
||||
// 返回 { inserted, scanned }
|
||||
const ABS_TOL = 0.01
|
||||
const REL_TOL = 0.01
|
||||
|
||||
export async function checkAfterRefresh(conn) {
|
||||
// conn 是 mysql2 连接, 走原始 SQL
|
||||
const [rows] = await conn.execute(`
|
||||
SELECT ia.name AS indicatorName, a.period, ia.source, a.value
|
||||
FROM IndicatorPoint a
|
||||
JOIN Indicator ia ON a.indicatorId = ia.id
|
||||
ORDER BY ia.name, a.period
|
||||
`)
|
||||
const groups = new Map()
|
||||
for (const r of rows) {
|
||||
const k = r.indicatorName + '|' + r.period
|
||||
if (!groups.has(k)) groups.set(k, [])
|
||||
groups.get(k).push({ source: r.source, value: Number(r.value) })
|
||||
}
|
||||
let inserted = 0
|
||||
for (const [k, arr] of groups) {
|
||||
if (arr.length < 2) continue
|
||||
const sources = new Set(arr.map((x) => x.source))
|
||||
if (sources.size < 2) continue
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
for (let j = i + 1; j < arr.length; j++) {
|
||||
const A = arr[i], B = arr[j]
|
||||
const diff = Math.abs(A.value - B.value)
|
||||
const base = Math.max(Math.abs(A.value), Math.abs(B.value), 1e-9)
|
||||
const ratio = diff / base
|
||||
if (diff < ABS_TOL || ratio < REL_TOL) continue
|
||||
const [name, period] = k.split('|')
|
||||
const [r] = await conn.execute(
|
||||
'INSERT INTO `Alert` (`indicatorName`, `period`, `valueA`, `valueB`, `sourceA`, `sourceB`, `diff`, `diffRatio`, `createdAt`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW(3))',
|
||||
[name, period, A.value, B.value, A.source, B.source, diff, ratio],
|
||||
)
|
||||
if (r.affectedRows === 1) inserted++
|
||||
}
|
||||
}
|
||||
}
|
||||
return { inserted, scanned: rows.length }
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import cron from 'node-cron'
|
||||
import { prisma } from './db.js'
|
||||
import mysql from 'mysql2/promise'
|
||||
import { fetchNewsList, persistNews } from '../scripts/fetchers/news.fetcher.js'
|
||||
import { checkAfterRefresh } from './alert-engine.js'
|
||||
|
||||
function parseUrl(url) {
|
||||
const u = new URL(url)
|
||||
return { host: u.hostname, port: Number(u.port) || 3306, user: decodeURIComponent(u.username), password: decodeURIComponent(u.password), database: u.pathname.replace(/^\//, '') }
|
||||
}
|
||||
|
||||
async function openConn() {
|
||||
return mysql.createConnection(parseUrl(process.env.DATABASE_URL))
|
||||
}
|
||||
|
||||
// 每天凌晨 3:00 (本地时间) 拉取新闻 + 跑跨源对比
|
||||
function scheduleNews() {
|
||||
cron.schedule('0 3 * * *', async () => {
|
||||
console.log('[cron] 3:00 触发')
|
||||
try {
|
||||
const list = await fetchNewsList()
|
||||
const r = await persistNews(list)
|
||||
console.log('[cron] 新闻入库: 新增 ' + r.inserted + ' / 跳过 ' + r.skipped)
|
||||
const conn = await openConn()
|
||||
try {
|
||||
const a = await checkAfterRefresh(conn)
|
||||
console.log('[cron] 报警检测: 新增 ' + a.inserted + ' 条 (扫描 ' + a.scanned + ')')
|
||||
} finally { await conn.end() }
|
||||
} catch (e) {
|
||||
console.error('[cron] 任务失败:', e.message)
|
||||
}
|
||||
})
|
||||
console.log('[cron] 已注册: 每天 03:00 拉取新闻 + 跨源报警检测')
|
||||
}
|
||||
|
||||
scheduleNews()
|
||||
@@ -5,6 +5,8 @@ import express from 'express'
|
||||
import cors from 'cors'
|
||||
import authRouter from './routes/auth.js'
|
||||
import dataRouter from './routes/data.js'
|
||||
import newsRouter from './routes/news.js'
|
||||
import alertsRouter from './routes/alerts.js'
|
||||
import articlesRouter from './routes/articles.js'
|
||||
|
||||
const PORT = Number(process.env.PORT) || 3001
|
||||
@@ -21,6 +23,8 @@ app.get('/api/health', (_req, res) => res.json({ ok: true, ts: Date.now() }))
|
||||
// 业务路由
|
||||
app.use('/api/auth', authRouter)
|
||||
app.use('/api', dataRouter)
|
||||
app.use('/api', newsRouter)
|
||||
app.use('/api', alertsRouter)
|
||||
app.use('/api', articlesRouter)
|
||||
|
||||
// 静态资源 (Vite build 产物)
|
||||
@@ -41,6 +45,9 @@ app.use((err, _req, res, _next) => {
|
||||
res.status(500).json({ error: err.message || '服务器内部错误' })
|
||||
})
|
||||
|
||||
// 启动定时任务: 每天凌晨 3 点拉取新闻
|
||||
import('./cron.js').catch((e) => console.error('[cron] 加载失败:', e.message))
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`[iboard-api] listening on http://127.0.0.1:${PORT}`)
|
||||
console.log(`[iboard-api] static dist: ${HAS_DIST ? DIST_DIR : '(not built, API only)'}`)
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Router } from 'express'
|
||||
import { prisma } from '../db.js'
|
||||
import { authRequired } from '../middleware.js'
|
||||
|
||||
const router = Router()
|
||||
router.use(authRequired)
|
||||
|
||||
// 报警列表: 按时间最新优先, cursor 分页 (cursor = id)
|
||||
router.get('/alerts', async (req, res, next) => {
|
||||
try {
|
||||
const limit = Math.min(Number(req.query.limit) || 10, 50)
|
||||
const cursor = req.query.cursor ? Number(req.query.cursor) : null
|
||||
const where = cursor ? { id: { lt: cursor } } : {}
|
||||
const items = await prisma.alert.findMany({
|
||||
where,
|
||||
orderBy: { id: 'desc' },
|
||||
take: limit,
|
||||
})
|
||||
const nextCursor = items.length === limit ? String(items[items.length - 1].id) : null
|
||||
res.json({ items, nextCursor })
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Router } from 'express'
|
||||
import { prisma } from '../db.js'
|
||||
import { authRequired } from '../middleware.js'
|
||||
|
||||
const router = Router()
|
||||
router.use(authRequired)
|
||||
|
||||
// 新闻列表: 按时间最新优先, cursor 分页 (cursor = publishedAt ISO 字符串)
|
||||
// 用 prisma cursor 配合 id 兜底 (同一秒发布的新闻)
|
||||
router.get('/news', async (req, res, next) => {
|
||||
try {
|
||||
const limit = Math.min(Number(req.query.limit) || 10, 50)
|
||||
const cursor = req.query.cursor || null
|
||||
const where = cursor ? { publishedAt: { lt: new Date(cursor) } } : {}
|
||||
const items = await prisma.news.findMany({
|
||||
where,
|
||||
orderBy: [{ publishedAt: 'desc' }, { id: 'desc' }],
|
||||
take: limit,
|
||||
})
|
||||
const nextCursor = items.length === limit ? items[items.length - 1].publishedAt.toISOString() : null
|
||||
res.json({ items, nextCursor })
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
export default router
|
||||
+156
-8
@@ -1,16 +1,164 @@
|
||||
import { ErrorBlock, NavBar } from 'antd-mobile'
|
||||
import { useEffect, useState, useRef, useCallback } from 'react'
|
||||
import { NavBar, List, Card, Empty, DotLoading } from 'antd-mobile'
|
||||
import { api } from '../api/client.js'
|
||||
|
||||
// 首页: 报警模块 + 新闻模块
|
||||
// - 报警: World Bank 与妙想 MCP 跨源同 (指标, 周期) 数据差异超阈值 (按时间倒序)
|
||||
// - 新闻: mx_finance_search_news 每日 3 点拉取国内外经济政治新闻 (按时间倒序)
|
||||
// - 两个模块均无限滚动, 触底自动加载下一页
|
||||
function fmtDateTime(s) {
|
||||
if (!s) return ''
|
||||
const d = new Date(s)
|
||||
if (isNaN(d.getTime())) return s
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes())
|
||||
}
|
||||
function fmtDate(s) {
|
||||
if (!s) return ''
|
||||
const d = new Date(s)
|
||||
if (isNaN(d.getTime())) return s
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate())
|
||||
}
|
||||
|
||||
// 首页: 暂留占位, 后续接入监控告警 / 个性化推荐
|
||||
// 使用 antd-mobile ErrorBlock 仿原生空状态页
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<div className="page-with-navbar">
|
||||
<NavBar back={null}>首页</NavBar>
|
||||
<ErrorBlock
|
||||
status="empty"
|
||||
title="首页"
|
||||
description="后续接入: 监控告警 / 个性化推荐 / 收藏的指标"
|
||||
/>
|
||||
<div className="home-content">
|
||||
<AlertSection />
|
||||
<NewsSection />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 报警模块
|
||||
function AlertSection() {
|
||||
const [items, setItems] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [done, setDone] = useState(false)
|
||||
const cursorRef = useRef(null)
|
||||
const sentinelRef = useRef(null)
|
||||
|
||||
const fetchPage = useCallback(async () => {
|
||||
if (loading || done) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const url = '/alerts?limit=10' + (cursorRef.current ? '&cursor=' + cursorRef.current : '')
|
||||
const data = await api(url)
|
||||
setItems((prev) => prev.concat(data.items || []))
|
||||
cursorRef.current = data.nextCursor
|
||||
if (!data.nextCursor) setDone(true)
|
||||
} catch (e) {
|
||||
console.error('alerts load error', e)
|
||||
setDone(true)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [loading, done])
|
||||
|
||||
useEffect(() => { fetchPage() }, [])
|
||||
useEffect(() => {
|
||||
if (done || items.length === 0) return
|
||||
const el = sentinelRef.current
|
||||
if (!el) return
|
||||
const io = new IntersectionObserver((entries) => {
|
||||
if (entries[0].isIntersecting) fetchPage()
|
||||
}, { rootMargin: '200px' })
|
||||
io.observe(el)
|
||||
return () => io.disconnect()
|
||||
}, [items.length, done, fetchPage])
|
||||
|
||||
return (
|
||||
<section className="home-section">
|
||||
<div className="section-title">报警</div>
|
||||
{items.length === 0 && !loading ? (
|
||||
<Empty description="暂无报警" />
|
||||
) : (
|
||||
<div className="section-list">
|
||||
{items.map((a) => (
|
||||
<Card key={a.id} className="alert-card">
|
||||
<div className="alert-title">
|
||||
<span className="alert-name">{a.indicatorName}</span>
|
||||
<span className="alert-period">{a.period}</span>
|
||||
</div>
|
||||
<div className="alert-diff">
|
||||
{a.sourceA} = {a.valueA} vs {a.sourceB} = {a.valueB}
|
||||
<span className="alert-ratio"> 差异 {(a.diffRatio * 100).toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="alert-time">{fmtDateTime(a.createdAt)}</div>
|
||||
</Card>
|
||||
))}
|
||||
<div ref={sentinelRef} className="scroll-sentinel">
|
||||
{loading ? <DotLoading /> : (done ? '— 已加载全部 —' : '')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// 新闻模块
|
||||
function NewsSection() {
|
||||
const [items, setItems] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [done, setDone] = useState(false)
|
||||
const cursorRef = useRef(null)
|
||||
const sentinelRef = useRef(null)
|
||||
|
||||
const fetchPage = useCallback(async () => {
|
||||
if (loading || done) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const url = '/news?limit=10' + (cursorRef.current ? '&cursor=' + encodeURIComponent(cursorRef.current) : '')
|
||||
const data = await api(url)
|
||||
setItems((prev) => prev.concat(data.items || []))
|
||||
cursorRef.current = data.nextCursor
|
||||
if (!data.nextCursor) setDone(true)
|
||||
} catch (e) {
|
||||
console.error('news load error', e)
|
||||
setDone(true)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [loading, done])
|
||||
|
||||
useEffect(() => { fetchPage() }, [])
|
||||
useEffect(() => {
|
||||
if (done || items.length === 0) return
|
||||
const el = sentinelRef.current
|
||||
if (!el) return
|
||||
const io = new IntersectionObserver((entries) => {
|
||||
if (entries[0].isIntersecting) fetchPage()
|
||||
}, { rootMargin: '200px' })
|
||||
io.observe(el)
|
||||
return () => io.disconnect()
|
||||
}, [items.length, done, fetchPage])
|
||||
|
||||
return (
|
||||
<section className="home-section">
|
||||
<div className="section-title">新闻</div>
|
||||
{items.length === 0 && !loading ? (
|
||||
<Empty description="暂无新闻" />
|
||||
) : (
|
||||
<div className="section-list">
|
||||
{items.map((n) => (
|
||||
<Card key={n.id} className="news-card">
|
||||
<div className="news-title">{n.title}</div>
|
||||
{n.summary && <div className="news-summary">{n.summary}</div>}
|
||||
<div className="news-meta">
|
||||
<span className="news-source">{n.source || '未知来源'}</span>
|
||||
<span className="news-time">{fmtDate(n.publishedAt)}</span>
|
||||
<span className="news-rank">#{n.impactRank}</span>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
<div ref={sentinelRef} className="scroll-sentinel">
|
||||
{loading ? <DotLoading /> : (done ? '— 已加载全部 —' : '')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -195,3 +195,23 @@ input, textarea, select { font: inherit; }
|
||||
.me-list { margin-top: 12px; }
|
||||
.me-list .adm-list-item { padding-left: 16px; padding-right: 16px; }
|
||||
.me-logout { color: var(--color-danger); }
|
||||
/* 首页模块: 报警 + 新闻 */
|
||||
.home-content { padding: 0 0 16px; }
|
||||
.home-section { padding: 0 12px 16px; }
|
||||
.section-title { font-size: 16px; font-weight: 700; padding: 14px 4px 8px; color: var(--color-text); }
|
||||
.section-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.alert-card.adm-card { border-radius: 10px; margin: 0; }
|
||||
.alert-card .adm-card-body { padding: 10px 12px; }
|
||||
.alert-title { display: flex; justify-content: space-between; font-weight: 600; font-size: 14px; }
|
||||
.alert-name { color: var(--color-danger); }
|
||||
.alert-period { color: var(--color-muted); font-weight: 400; }
|
||||
.alert-diff { font-size: 12px; color: var(--color-text); margin-top: 4px; }
|
||||
.alert-ratio { color: var(--color-danger); font-weight: 600; margin-left: 6px; }
|
||||
.alert-time { font-size: 11px; color: var(--color-muted); margin-top: 4px; }
|
||||
.news-card.adm-card { border-radius: 10px; margin: 0; }
|
||||
.news-card .adm-card-body { padding: 10px 12px; }
|
||||
.news-title { font-size: 14px; font-weight: 600; line-height: 1.4; color: var(--color-text); }
|
||||
.news-summary { font-size: 12px; color: var(--color-muted); margin-top: 4px; line-height: 1.5; max-height: 3.6em; overflow: hidden; }
|
||||
.news-meta { display: flex; gap: 10px; font-size: 11px; color: var(--color-muted); margin-top: 6px; }
|
||||
.news-rank { color: var(--color-primary); font-weight: 600; }
|
||||
.scroll-sentinel { text-align: center; padding: 14px 0 4px; color: var(--color-muted); font-size: 12px; }
|
||||
|
||||
Reference in New Issue
Block a user