更新首页
This commit is contained in:
@@ -42,6 +42,16 @@ export type Indicator = Prisma.IndicatorModel
|
|||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
export type IndicatorPoint = Prisma.IndicatorPointModel
|
export type IndicatorPoint = Prisma.IndicatorPointModel
|
||||||
|
/**
|
||||||
|
* Model News
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export type News = Prisma.NewsModel
|
||||||
|
/**
|
||||||
|
* Model Alert
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export type Alert = Prisma.AlertModel
|
||||||
/**
|
/**
|
||||||
* Model Econ_SowInventory
|
* Model Econ_SowInventory
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -66,6 +66,16 @@ export type Indicator = Prisma.IndicatorModel
|
|||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
export type IndicatorPoint = Prisma.IndicatorPointModel
|
export type IndicatorPoint = Prisma.IndicatorPointModel
|
||||||
|
/**
|
||||||
|
* Model News
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export type News = Prisma.NewsModel
|
||||||
|
/**
|
||||||
|
* Model Alert
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export type Alert = Prisma.AlertModel
|
||||||
/**
|
/**
|
||||||
* Model Econ_SowInventory
|
* Model Econ_SowInventory
|
||||||
*
|
*
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -389,6 +389,8 @@ export const ModelName = {
|
|||||||
Strategy: 'Strategy',
|
Strategy: 'Strategy',
|
||||||
Indicator: 'Indicator',
|
Indicator: 'Indicator',
|
||||||
IndicatorPoint: 'IndicatorPoint',
|
IndicatorPoint: 'IndicatorPoint',
|
||||||
|
News: 'News',
|
||||||
|
Alert: 'Alert',
|
||||||
Econ_SowInventory: 'Econ_SowInventory',
|
Econ_SowInventory: 'Econ_SowInventory',
|
||||||
ScraperCache: 'ScraperCache'
|
ScraperCache: 'ScraperCache'
|
||||||
} as const
|
} as const
|
||||||
@@ -406,7 +408,7 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
|
|||||||
omit: GlobalOmitOptions
|
omit: GlobalOmitOptions
|
||||||
}
|
}
|
||||||
meta: {
|
meta: {
|
||||||
modelProps: "user" | "article" | "strategy" | "indicator" | "indicatorPoint" | "econ_SowInventory" | "scraperCache"
|
modelProps: "user" | "article" | "strategy" | "indicator" | "indicatorPoint" | "news" | "alert" | "econ_SowInventory" | "scraperCache"
|
||||||
txIsolationLevel: TransactionIsolationLevel
|
txIsolationLevel: TransactionIsolationLevel
|
||||||
}
|
}
|
||||||
model: {
|
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: {
|
Econ_SowInventory: {
|
||||||
payload: Prisma.$Econ_SowInventoryPayload<ExtArgs>
|
payload: Prisma.$Econ_SowInventoryPayload<ExtArgs>
|
||||||
fields: Prisma.Econ_SowInventoryFieldRefs
|
fields: Prisma.Econ_SowInventoryFieldRefs
|
||||||
@@ -976,6 +1110,36 @@ export const IndicatorPointScalarFieldEnum = {
|
|||||||
export type IndicatorPointScalarFieldEnum = (typeof IndicatorPointScalarFieldEnum)[keyof typeof 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 = {
|
export const Econ_SowInventoryScalarFieldEnum = {
|
||||||
id: 'id',
|
id: 'id',
|
||||||
month: 'month',
|
month: 'month',
|
||||||
@@ -1086,6 +1250,26 @@ export const IndicatorPointOrderByRelevanceFieldEnum = {
|
|||||||
export type IndicatorPointOrderByRelevanceFieldEnum = (typeof IndicatorPointOrderByRelevanceFieldEnum)[keyof typeof 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 = {
|
export const Econ_SowInventoryOrderByRelevanceFieldEnum = {
|
||||||
month: 'month'
|
month: 'month'
|
||||||
} as const
|
} as const
|
||||||
@@ -1263,6 +1447,8 @@ export type GlobalOmitConfig = {
|
|||||||
strategy?: Prisma.StrategyOmit
|
strategy?: Prisma.StrategyOmit
|
||||||
indicator?: Prisma.IndicatorOmit
|
indicator?: Prisma.IndicatorOmit
|
||||||
indicatorPoint?: Prisma.IndicatorPointOmit
|
indicatorPoint?: Prisma.IndicatorPointOmit
|
||||||
|
news?: Prisma.NewsOmit
|
||||||
|
alert?: Prisma.AlertOmit
|
||||||
econ_SowInventory?: Prisma.Econ_SowInventoryOmit
|
econ_SowInventory?: Prisma.Econ_SowInventoryOmit
|
||||||
scraperCache?: Prisma.ScraperCacheOmit
|
scraperCache?: Prisma.ScraperCacheOmit
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ export const ModelName = {
|
|||||||
Strategy: 'Strategy',
|
Strategy: 'Strategy',
|
||||||
Indicator: 'Indicator',
|
Indicator: 'Indicator',
|
||||||
IndicatorPoint: 'IndicatorPoint',
|
IndicatorPoint: 'IndicatorPoint',
|
||||||
|
News: 'News',
|
||||||
|
Alert: 'Alert',
|
||||||
Econ_SowInventory: 'Econ_SowInventory',
|
Econ_SowInventory: 'Econ_SowInventory',
|
||||||
ScraperCache: 'ScraperCache'
|
ScraperCache: 'ScraperCache'
|
||||||
} as const
|
} as const
|
||||||
@@ -141,6 +143,36 @@ export const IndicatorPointScalarFieldEnum = {
|
|||||||
export type IndicatorPointScalarFieldEnum = (typeof IndicatorPointScalarFieldEnum)[keyof typeof 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 = {
|
export const Econ_SowInventoryScalarFieldEnum = {
|
||||||
id: 'id',
|
id: 'id',
|
||||||
month: 'month',
|
month: 'month',
|
||||||
@@ -251,6 +283,26 @@ export const IndicatorPointOrderByRelevanceFieldEnum = {
|
|||||||
export type IndicatorPointOrderByRelevanceFieldEnum = (typeof IndicatorPointOrderByRelevanceFieldEnum)[keyof typeof 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 = {
|
export const Econ_SowInventoryOrderByRelevanceFieldEnum = {
|
||||||
month: 'month'
|
month: 'month'
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export type * from './models/Article.ts'
|
|||||||
export type * from './models/Strategy.ts'
|
export type * from './models/Strategy.ts'
|
||||||
export type * from './models/Indicator.ts'
|
export type * from './models/Indicator.ts'
|
||||||
export type * from './models/IndicatorPoint.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/Econ_SowInventory.ts'
|
||||||
export type * from './models/ScraperCache.ts'
|
export type * from './models/ScraperCache.ts'
|
||||||
export type * from './commonInputTypes.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",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"mariadb": "^3.5.3",
|
"mariadb": "^3.5.3",
|
||||||
"marked": "^17.0.6",
|
"marked": "^17.0.6",
|
||||||
|
"node-cron": "^3.0.3",
|
||||||
"prisma": "^7.7.0",
|
"prisma": "^7.7.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
@@ -4880,6 +4881,18 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/node-fetch-native": {
|
||||||
"version": "1.6.7",
|
"version": "1.6.7",
|
||||||
"resolved": "https://registry.npmmirror.com/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
|
"resolved": "https://registry.npmmirror.com/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
|
||||||
@@ -6134,6 +6147,15 @@
|
|||||||
"node": ">= 0.4.0"
|
"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": {
|
"node_modules/valibot": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmmirror.com/valibot/-/valibot-1.2.0.tgz",
|
"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:dev": "tauri android dev",
|
||||||
"tauri:android:build": "tauri android build",
|
"tauri:android:build": "tauri android build",
|
||||||
"tauri:android:apk": "tauri android build --apk",
|
"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": {
|
"dependencies": {
|
||||||
"@huolala-tech/page-spy": "^1.5.7",
|
"@huolala-tech/page-spy": "^1.5.7",
|
||||||
@@ -35,6 +36,7 @@
|
|||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"mariadb": "^3.5.3",
|
"mariadb": "^3.5.3",
|
||||||
"marked": "^17.0.6",
|
"marked": "^17.0.6",
|
||||||
|
"node-cron": "^3.0.3",
|
||||||
"prisma": "^7.7.0",
|
"prisma": "^7.7.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^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])
|
@@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 {
|
model Econ_SowInventory {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
month String @unique
|
month String @unique
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
### 报警模块
|
### 报警模块
|
||||||
World Bank 中的数据只用来对比妙想 MCP 拉取的数据正确性。当同一时间的同一项数据不同时,记录一个报警。
|
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 (数据保留, 不展示)')
|
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) {
|
if (allItems.length === 0) {
|
||||||
console.log('[Refresh] 无指标待写, 不写库 (DB 优先策略生效)')
|
console.log('[Refresh] 无指标待写, 不写库 (DB 优先策略生效)')
|
||||||
return
|
return
|
||||||
@@ -163,7 +170,7 @@ async function main() {
|
|||||||
totalPoints += points
|
totalPoints += points
|
||||||
console.log('[Refresh] ' + it.name + (hidden ? ' (hidden)' : '') + ': ' + points + ' 个明细点')
|
console.log('[Refresh] ' + it.name + (hidden ? ' (hidden)' : '') + ': ' + points + ' 个明细点')
|
||||||
}
|
}
|
||||||
console.log('[Refresh] 写入完成: ' + ins + ' 个指标有数据, ' + upd + ' 个无数据, 共 ' + totalPoints + ' 个明细点\n')
|
|
||||||
|
|
||||||
} finally {
|
} finally {
|
||||||
await conn.end()
|
await conn.end()
|
||||||
@@ -175,3 +182,4 @@ main().catch((e) => {
|
|||||||
process.exit(1)
|
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 cors from 'cors'
|
||||||
import authRouter from './routes/auth.js'
|
import authRouter from './routes/auth.js'
|
||||||
import dataRouter from './routes/data.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'
|
import articlesRouter from './routes/articles.js'
|
||||||
|
|
||||||
const PORT = Number(process.env.PORT) || 3001
|
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/auth', authRouter)
|
||||||
app.use('/api', dataRouter)
|
app.use('/api', dataRouter)
|
||||||
|
app.use('/api', newsRouter)
|
||||||
|
app.use('/api', alertsRouter)
|
||||||
app.use('/api', articlesRouter)
|
app.use('/api', articlesRouter)
|
||||||
|
|
||||||
// 静态资源 (Vite build 产物)
|
// 静态资源 (Vite build 产物)
|
||||||
@@ -41,6 +45,9 @@ app.use((err, _req, res, _next) => {
|
|||||||
res.status(500).json({ error: err.message || '服务器内部错误' })
|
res.status(500).json({ error: err.message || '服务器内部错误' })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 启动定时任务: 每天凌晨 3 点拉取新闻
|
||||||
|
import('./cron.js').catch((e) => console.error('[cron] 加载失败:', e.message))
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`[iboard-api] listening on http://127.0.0.1:${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)'}`)
|
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
|
||||||
+157
-9
@@ -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() {
|
export default function HomePage() {
|
||||||
return (
|
return (
|
||||||
<div className="page-with-navbar">
|
<div className="page-with-navbar">
|
||||||
<NavBar back={null}>首页</NavBar>
|
<NavBar back={null}>首页</NavBar>
|
||||||
<ErrorBlock
|
<div className="home-content">
|
||||||
status="empty"
|
<AlertSection />
|
||||||
title="首页"
|
<NewsSection />
|
||||||
description="后续接入: 监控告警 / 个性化推荐 / 收藏的指标"
|
</div>
|
||||||
/>
|
|
||||||
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
+21
-1
@@ -194,4 +194,24 @@ input, textarea, select { font: inherit; }
|
|||||||
.me-email { font-size: 13px; opacity: .85; }
|
.me-email { font-size: 13px; opacity: .85; }
|
||||||
.me-list { margin-top: 12px; }
|
.me-list { margin-top: 12px; }
|
||||||
.me-list .adm-list-item { padding-left: 16px; padding-right: 16px; }
|
.me-list .adm-list-item { padding-left: 16px; padding-right: 16px; }
|
||||||
.me-logout { color: var(--color-danger); }
|
.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