更新首页

This commit is contained in:
cheney
2026-08-06 16:41:47 +08:00
parent 547181d333
commit 4d02439ecb
32 changed files with 5470 additions and 93 deletions
+1
View File
@@ -34,3 +34,4 @@ dist/*
# local env (contains API keys)
/.env
.reasonix/*
+15
View File
@@ -52,6 +52,21 @@ export type News = Prisma.NewsModel
*
*/
export type Alert = Prisma.AlertModel
/**
* Model ServerNode
*
*/
export type ServerNode = Prisma.ServerNodeModel
/**
* Model ServerNodeHistory
*
*/
export type ServerNodeHistory = Prisma.ServerNodeHistoryModel
/**
* Model ServiceCheck
*
*/
export type ServiceCheck = Prisma.ServiceCheckModel
/**
* Model Econ_SowInventory
*
+15
View File
@@ -76,6 +76,21 @@ export type News = Prisma.NewsModel
*
*/
export type Alert = Prisma.AlertModel
/**
* Model ServerNode
*
*/
export type ServerNode = Prisma.ServerNodeModel
/**
* Model ServerNodeHistory
*
*/
export type ServerNodeHistory = Prisma.ServerNodeHistoryModel
/**
* Model ServiceCheck
*
*/
export type ServiceCheck = Prisma.ServiceCheckModel
/**
* Model Econ_SowInventory
*
+77 -23
View File
@@ -205,6 +205,56 @@ export type StringNullableFilter<$PrismaModel = never> = {
not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null
}
export type SortOrderInput = {
sort: Prisma.SortOrder
nulls?: Prisma.NullsOrder
}
export type StringNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
in?: string[] | null
notIn?: string[] | null
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
search?: string
not?: Prisma.NestedStringNullableWithAggregatesFilter<$PrismaModel> | string | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedStringNullableFilter<$PrismaModel>
_max?: Prisma.NestedStringNullableFilter<$PrismaModel>
}
export type BigIntFilter<$PrismaModel = never> = {
equals?: bigint | number | Prisma.BigIntFieldRefInput<$PrismaModel>
in?: bigint[] | number[]
notIn?: bigint[] | number[]
lt?: bigint | number | Prisma.BigIntFieldRefInput<$PrismaModel>
lte?: bigint | number | Prisma.BigIntFieldRefInput<$PrismaModel>
gt?: bigint | number | Prisma.BigIntFieldRefInput<$PrismaModel>
gte?: bigint | number | Prisma.BigIntFieldRefInput<$PrismaModel>
not?: Prisma.NestedBigIntFilter<$PrismaModel> | bigint | number
}
export type BigIntWithAggregatesFilter<$PrismaModel = never> = {
equals?: bigint | number | Prisma.BigIntFieldRefInput<$PrismaModel>
in?: bigint[] | number[]
notIn?: bigint[] | number[]
lt?: bigint | number | Prisma.BigIntFieldRefInput<$PrismaModel>
lte?: bigint | number | Prisma.BigIntFieldRefInput<$PrismaModel>
gt?: bigint | number | Prisma.BigIntFieldRefInput<$PrismaModel>
gte?: bigint | number | Prisma.BigIntFieldRefInput<$PrismaModel>
not?: Prisma.NestedBigIntWithAggregatesFilter<$PrismaModel> | bigint | number
_count?: Prisma.NestedIntFilter<$PrismaModel>
_avg?: Prisma.NestedFloatFilter<$PrismaModel>
_sum?: Prisma.NestedBigIntFilter<$PrismaModel>
_min?: Prisma.NestedBigIntFilter<$PrismaModel>
_max?: Prisma.NestedBigIntFilter<$PrismaModel>
}
export type JsonNullableFilter<$PrismaModel = never> =
| Prisma.PatchUndefined<
Prisma.Either<Required<JsonNullableFilterBase<$PrismaModel>>, Exclude<keyof Required<JsonNullableFilterBase<$PrismaModel>>, 'path'>>,
@@ -229,29 +279,6 @@ export type JsonNullableFilterBase<$PrismaModel = never> = {
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
}
export type SortOrderInput = {
sort: Prisma.SortOrder
nulls?: Prisma.NullsOrder
}
export type StringNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
in?: string[] | null
notIn?: string[] | null
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
search?: string
not?: Prisma.NestedStringNullableWithAggregatesFilter<$PrismaModel> | string | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedStringNullableFilter<$PrismaModel>
_max?: Prisma.NestedStringNullableFilter<$PrismaModel>
}
export type JsonNullableWithAggregatesFilter<$PrismaModel = never> =
| Prisma.PatchUndefined<
Prisma.Either<Required<JsonNullableWithAggregatesFilterBase<$PrismaModel>>, Exclude<keyof Required<JsonNullableWithAggregatesFilterBase<$PrismaModel>>, 'path'>>,
@@ -472,6 +499,33 @@ export type NestedIntNullableFilter<$PrismaModel = never> = {
not?: Prisma.NestedIntNullableFilter<$PrismaModel> | number | null
}
export type NestedBigIntFilter<$PrismaModel = never> = {
equals?: bigint | number | Prisma.BigIntFieldRefInput<$PrismaModel>
in?: bigint[] | number[]
notIn?: bigint[] | number[]
lt?: bigint | number | Prisma.BigIntFieldRefInput<$PrismaModel>
lte?: bigint | number | Prisma.BigIntFieldRefInput<$PrismaModel>
gt?: bigint | number | Prisma.BigIntFieldRefInput<$PrismaModel>
gte?: bigint | number | Prisma.BigIntFieldRefInput<$PrismaModel>
not?: Prisma.NestedBigIntFilter<$PrismaModel> | bigint | number
}
export type NestedBigIntWithAggregatesFilter<$PrismaModel = never> = {
equals?: bigint | number | Prisma.BigIntFieldRefInput<$PrismaModel>
in?: bigint[] | number[]
notIn?: bigint[] | number[]
lt?: bigint | number | Prisma.BigIntFieldRefInput<$PrismaModel>
lte?: bigint | number | Prisma.BigIntFieldRefInput<$PrismaModel>
gt?: bigint | number | Prisma.BigIntFieldRefInput<$PrismaModel>
gte?: bigint | number | Prisma.BigIntFieldRefInput<$PrismaModel>
not?: Prisma.NestedBigIntWithAggregatesFilter<$PrismaModel> | bigint | number
_count?: Prisma.NestedIntFilter<$PrismaModel>
_avg?: Prisma.NestedFloatFilter<$PrismaModel>
_sum?: Prisma.NestedBigIntFilter<$PrismaModel>
_min?: Prisma.NestedBigIntFilter<$PrismaModel>
_max?: Prisma.NestedBigIntFilter<$PrismaModel>
}
export type NestedJsonNullableFilter<$PrismaModel = never> =
| Prisma.PatchUndefined<
Prisma.Either<Required<NestedJsonNullableFilterBase<$PrismaModel>>, Exclude<keyof Required<NestedJsonNullableFilterBase<$PrismaModel>>, 'path'>>,
File diff suppressed because one or more lines are too long
+315 -9
View File
@@ -391,6 +391,9 @@ export const ModelName = {
IndicatorPoint: 'IndicatorPoint',
News: 'News',
Alert: 'Alert',
ServerNode: 'ServerNode',
ServerNodeHistory: 'ServerNodeHistory',
ServiceCheck: 'ServiceCheck',
Econ_SowInventory: 'Econ_SowInventory',
ScraperCache: 'ScraperCache'
} as const
@@ -408,7 +411,7 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
omit: GlobalOmitOptions
}
meta: {
modelProps: "user" | "article" | "strategy" | "indicator" | "indicatorPoint" | "news" | "alert" | "econ_SowInventory" | "scraperCache"
modelProps: "user" | "article" | "strategy" | "indicator" | "indicatorPoint" | "news" | "alert" | "serverNode" | "serverNodeHistory" | "serviceCheck" | "econ_SowInventory" | "scraperCache"
txIsolationLevel: TransactionIsolationLevel
}
model: {
@@ -874,6 +877,204 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
}
}
}
ServerNode: {
payload: Prisma.$ServerNodePayload<ExtArgs>
fields: Prisma.ServerNodeFieldRefs
operations: {
findUnique: {
args: Prisma.ServerNodeFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ServerNodePayload> | null
}
findUniqueOrThrow: {
args: Prisma.ServerNodeFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ServerNodePayload>
}
findFirst: {
args: Prisma.ServerNodeFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ServerNodePayload> | null
}
findFirstOrThrow: {
args: Prisma.ServerNodeFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ServerNodePayload>
}
findMany: {
args: Prisma.ServerNodeFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ServerNodePayload>[]
}
create: {
args: Prisma.ServerNodeCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ServerNodePayload>
}
createMany: {
args: Prisma.ServerNodeCreateManyArgs<ExtArgs>
result: BatchPayload
}
delete: {
args: Prisma.ServerNodeDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ServerNodePayload>
}
update: {
args: Prisma.ServerNodeUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ServerNodePayload>
}
deleteMany: {
args: Prisma.ServerNodeDeleteManyArgs<ExtArgs>
result: BatchPayload
}
updateMany: {
args: Prisma.ServerNodeUpdateManyArgs<ExtArgs>
result: BatchPayload
}
upsert: {
args: Prisma.ServerNodeUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ServerNodePayload>
}
aggregate: {
args: Prisma.ServerNodeAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateServerNode>
}
groupBy: {
args: Prisma.ServerNodeGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.ServerNodeGroupByOutputType>[]
}
count: {
args: Prisma.ServerNodeCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.ServerNodeCountAggregateOutputType> | number
}
}
}
ServerNodeHistory: {
payload: Prisma.$ServerNodeHistoryPayload<ExtArgs>
fields: Prisma.ServerNodeHistoryFieldRefs
operations: {
findUnique: {
args: Prisma.ServerNodeHistoryFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ServerNodeHistoryPayload> | null
}
findUniqueOrThrow: {
args: Prisma.ServerNodeHistoryFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ServerNodeHistoryPayload>
}
findFirst: {
args: Prisma.ServerNodeHistoryFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ServerNodeHistoryPayload> | null
}
findFirstOrThrow: {
args: Prisma.ServerNodeHistoryFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ServerNodeHistoryPayload>
}
findMany: {
args: Prisma.ServerNodeHistoryFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ServerNodeHistoryPayload>[]
}
create: {
args: Prisma.ServerNodeHistoryCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ServerNodeHistoryPayload>
}
createMany: {
args: Prisma.ServerNodeHistoryCreateManyArgs<ExtArgs>
result: BatchPayload
}
delete: {
args: Prisma.ServerNodeHistoryDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ServerNodeHistoryPayload>
}
update: {
args: Prisma.ServerNodeHistoryUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ServerNodeHistoryPayload>
}
deleteMany: {
args: Prisma.ServerNodeHistoryDeleteManyArgs<ExtArgs>
result: BatchPayload
}
updateMany: {
args: Prisma.ServerNodeHistoryUpdateManyArgs<ExtArgs>
result: BatchPayload
}
upsert: {
args: Prisma.ServerNodeHistoryUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ServerNodeHistoryPayload>
}
aggregate: {
args: Prisma.ServerNodeHistoryAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateServerNodeHistory>
}
groupBy: {
args: Prisma.ServerNodeHistoryGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.ServerNodeHistoryGroupByOutputType>[]
}
count: {
args: Prisma.ServerNodeHistoryCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.ServerNodeHistoryCountAggregateOutputType> | number
}
}
}
ServiceCheck: {
payload: Prisma.$ServiceCheckPayload<ExtArgs>
fields: Prisma.ServiceCheckFieldRefs
operations: {
findUnique: {
args: Prisma.ServiceCheckFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ServiceCheckPayload> | null
}
findUniqueOrThrow: {
args: Prisma.ServiceCheckFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ServiceCheckPayload>
}
findFirst: {
args: Prisma.ServiceCheckFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ServiceCheckPayload> | null
}
findFirstOrThrow: {
args: Prisma.ServiceCheckFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ServiceCheckPayload>
}
findMany: {
args: Prisma.ServiceCheckFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ServiceCheckPayload>[]
}
create: {
args: Prisma.ServiceCheckCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ServiceCheckPayload>
}
createMany: {
args: Prisma.ServiceCheckCreateManyArgs<ExtArgs>
result: BatchPayload
}
delete: {
args: Prisma.ServiceCheckDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ServiceCheckPayload>
}
update: {
args: Prisma.ServiceCheckUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ServiceCheckPayload>
}
deleteMany: {
args: Prisma.ServiceCheckDeleteManyArgs<ExtArgs>
result: BatchPayload
}
updateMany: {
args: Prisma.ServiceCheckUpdateManyArgs<ExtArgs>
result: BatchPayload
}
upsert: {
args: Prisma.ServiceCheckUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ServiceCheckPayload>
}
aggregate: {
args: Prisma.ServiceCheckAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateServiceCheck>
}
groupBy: {
args: Prisma.ServiceCheckGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.ServiceCheckGroupByOutputType>[]
}
count: {
args: Prisma.ServiceCheckCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.ServiceCheckCountAggregateOutputType> | number
}
}
}
Econ_SowInventory: {
payload: Prisma.$Econ_SowInventoryPayload<ExtArgs>
fields: Prisma.Econ_SowInventoryFieldRefs
@@ -1114,6 +1315,7 @@ export const NewsScalarFieldEnum = {
id: 'id',
title: 'title',
summary: 'summary',
content: 'content',
source: 'source',
url: 'url',
publishedAt: 'publishedAt',
@@ -1140,6 +1342,70 @@ export const AlertScalarFieldEnum = {
export type AlertScalarFieldEnum = (typeof AlertScalarFieldEnum)[keyof typeof AlertScalarFieldEnum]
export const ServerNodeScalarFieldEnum = {
id: 'id',
uuid: 'uuid',
name: 'name',
cpuName: 'cpuName',
os: 'os',
region: 'region',
memTotal: 'memTotal',
diskTotal: 'diskTotal',
cpuCores: 'cpuCores',
cpuUsage: 'cpuUsage',
ramUsed: 'ramUsed',
swapUsed: 'swapUsed',
load1: 'load1',
load5: 'load5',
load15: 'load15',
diskUsed: 'diskUsed',
netIn: 'netIn',
netOut: 'netOut',
uptime: 'uptime',
process: 'process',
connections: 'connections',
status: 'status',
recordedAt: 'recordedAt',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type ServerNodeScalarFieldEnum = (typeof ServerNodeScalarFieldEnum)[keyof typeof ServerNodeScalarFieldEnum]
export const ServerNodeHistoryScalarFieldEnum = {
id: 'id',
uuid: 'uuid',
period: 'period',
cpu: 'cpu',
ram: 'ram',
load: 'load',
disk: 'disk',
netIn: 'netIn',
netOut: 'netOut',
process: 'process',
connections: 'connections',
createdAt: 'createdAt'
} as const
export type ServerNodeHistoryScalarFieldEnum = (typeof ServerNodeHistoryScalarFieldEnum)[keyof typeof ServerNodeHistoryScalarFieldEnum]
export const ServiceCheckScalarFieldEnum = {
id: 'id',
name: 'name',
url: 'url',
status: 'status',
latencyMs: 'latencyMs',
message: 'message',
lastCheckAt: 'lastCheckAt',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type ServiceCheckScalarFieldEnum = (typeof ServiceCheckScalarFieldEnum)[keyof typeof ServiceCheckScalarFieldEnum]
export const Econ_SowInventoryScalarFieldEnum = {
id: 'id',
month: 'month',
@@ -1250,9 +1516,18 @@ export const IndicatorPointOrderByRelevanceFieldEnum = {
export type IndicatorPointOrderByRelevanceFieldEnum = (typeof IndicatorPointOrderByRelevanceFieldEnum)[keyof typeof IndicatorPointOrderByRelevanceFieldEnum]
export const NullsOrder = {
first: 'first',
last: 'last'
} as const
export type NullsOrder = (typeof NullsOrder)[keyof typeof NullsOrder]
export const NewsOrderByRelevanceFieldEnum = {
title: 'title',
summary: 'summary',
content: 'content',
source: 'source',
url: 'url'
} as const
@@ -1270,6 +1545,35 @@ export const AlertOrderByRelevanceFieldEnum = {
export type AlertOrderByRelevanceFieldEnum = (typeof AlertOrderByRelevanceFieldEnum)[keyof typeof AlertOrderByRelevanceFieldEnum]
export const ServerNodeOrderByRelevanceFieldEnum = {
uuid: 'uuid',
name: 'name',
cpuName: 'cpuName',
os: 'os',
region: 'region',
status: 'status'
} as const
export type ServerNodeOrderByRelevanceFieldEnum = (typeof ServerNodeOrderByRelevanceFieldEnum)[keyof typeof ServerNodeOrderByRelevanceFieldEnum]
export const ServerNodeHistoryOrderByRelevanceFieldEnum = {
uuid: 'uuid'
} as const
export type ServerNodeHistoryOrderByRelevanceFieldEnum = (typeof ServerNodeHistoryOrderByRelevanceFieldEnum)[keyof typeof ServerNodeHistoryOrderByRelevanceFieldEnum]
export const ServiceCheckOrderByRelevanceFieldEnum = {
name: 'name',
url: 'url',
status: 'status',
message: 'message'
} as const
export type ServiceCheckOrderByRelevanceFieldEnum = (typeof ServiceCheckOrderByRelevanceFieldEnum)[keyof typeof ServiceCheckOrderByRelevanceFieldEnum]
export const Econ_SowInventoryOrderByRelevanceFieldEnum = {
month: 'month'
} as const
@@ -1277,14 +1581,6 @@ export const Econ_SowInventoryOrderByRelevanceFieldEnum = {
export type Econ_SowInventoryOrderByRelevanceFieldEnum = (typeof Econ_SowInventoryOrderByRelevanceFieldEnum)[keyof typeof Econ_SowInventoryOrderByRelevanceFieldEnum]
export const NullsOrder = {
first: 'first',
last: 'last'
} as const
export type NullsOrder = (typeof NullsOrder)[keyof typeof NullsOrder]
export const ScraperCacheOrderByRelevanceFieldEnum = {
scraperName: 'scraperName',
lastDataHash: 'lastDataHash'
@@ -1347,6 +1643,13 @@ export type BooleanFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel,
export type FloatFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'Float'>
/**
* Reference to a field of type 'BigInt'
*/
export type BigIntFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'BigInt'>
/**
* Batch Payload for updateMany & deleteMany & createMany
*/
@@ -1449,6 +1752,9 @@ export type GlobalOmitConfig = {
indicatorPoint?: Prisma.IndicatorPointOmit
news?: Prisma.NewsOmit
alert?: Prisma.AlertOmit
serverNode?: Prisma.ServerNodeOmit
serverNodeHistory?: Prisma.ServerNodeHistoryOmit
serviceCheck?: Prisma.ServiceCheckOmit
econ_SowInventory?: Prisma.Econ_SowInventoryOmit
scraperCache?: Prisma.ScraperCacheOmit
}
@@ -58,6 +58,9 @@ export const ModelName = {
IndicatorPoint: 'IndicatorPoint',
News: 'News',
Alert: 'Alert',
ServerNode: 'ServerNode',
ServerNodeHistory: 'ServerNodeHistory',
ServiceCheck: 'ServiceCheck',
Econ_SowInventory: 'Econ_SowInventory',
ScraperCache: 'ScraperCache'
} as const
@@ -147,6 +150,7 @@ export const NewsScalarFieldEnum = {
id: 'id',
title: 'title',
summary: 'summary',
content: 'content',
source: 'source',
url: 'url',
publishedAt: 'publishedAt',
@@ -173,6 +177,70 @@ export const AlertScalarFieldEnum = {
export type AlertScalarFieldEnum = (typeof AlertScalarFieldEnum)[keyof typeof AlertScalarFieldEnum]
export const ServerNodeScalarFieldEnum = {
id: 'id',
uuid: 'uuid',
name: 'name',
cpuName: 'cpuName',
os: 'os',
region: 'region',
memTotal: 'memTotal',
diskTotal: 'diskTotal',
cpuCores: 'cpuCores',
cpuUsage: 'cpuUsage',
ramUsed: 'ramUsed',
swapUsed: 'swapUsed',
load1: 'load1',
load5: 'load5',
load15: 'load15',
diskUsed: 'diskUsed',
netIn: 'netIn',
netOut: 'netOut',
uptime: 'uptime',
process: 'process',
connections: 'connections',
status: 'status',
recordedAt: 'recordedAt',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type ServerNodeScalarFieldEnum = (typeof ServerNodeScalarFieldEnum)[keyof typeof ServerNodeScalarFieldEnum]
export const ServerNodeHistoryScalarFieldEnum = {
id: 'id',
uuid: 'uuid',
period: 'period',
cpu: 'cpu',
ram: 'ram',
load: 'load',
disk: 'disk',
netIn: 'netIn',
netOut: 'netOut',
process: 'process',
connections: 'connections',
createdAt: 'createdAt'
} as const
export type ServerNodeHistoryScalarFieldEnum = (typeof ServerNodeHistoryScalarFieldEnum)[keyof typeof ServerNodeHistoryScalarFieldEnum]
export const ServiceCheckScalarFieldEnum = {
id: 'id',
name: 'name',
url: 'url',
status: 'status',
latencyMs: 'latencyMs',
message: 'message',
lastCheckAt: 'lastCheckAt',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type ServiceCheckScalarFieldEnum = (typeof ServiceCheckScalarFieldEnum)[keyof typeof ServiceCheckScalarFieldEnum]
export const Econ_SowInventoryScalarFieldEnum = {
id: 'id',
month: 'month',
@@ -283,9 +351,18 @@ export const IndicatorPointOrderByRelevanceFieldEnum = {
export type IndicatorPointOrderByRelevanceFieldEnum = (typeof IndicatorPointOrderByRelevanceFieldEnum)[keyof typeof IndicatorPointOrderByRelevanceFieldEnum]
export const NullsOrder = {
first: 'first',
last: 'last'
} as const
export type NullsOrder = (typeof NullsOrder)[keyof typeof NullsOrder]
export const NewsOrderByRelevanceFieldEnum = {
title: 'title',
summary: 'summary',
content: 'content',
source: 'source',
url: 'url'
} as const
@@ -303,6 +380,35 @@ export const AlertOrderByRelevanceFieldEnum = {
export type AlertOrderByRelevanceFieldEnum = (typeof AlertOrderByRelevanceFieldEnum)[keyof typeof AlertOrderByRelevanceFieldEnum]
export const ServerNodeOrderByRelevanceFieldEnum = {
uuid: 'uuid',
name: 'name',
cpuName: 'cpuName',
os: 'os',
region: 'region',
status: 'status'
} as const
export type ServerNodeOrderByRelevanceFieldEnum = (typeof ServerNodeOrderByRelevanceFieldEnum)[keyof typeof ServerNodeOrderByRelevanceFieldEnum]
export const ServerNodeHistoryOrderByRelevanceFieldEnum = {
uuid: 'uuid'
} as const
export type ServerNodeHistoryOrderByRelevanceFieldEnum = (typeof ServerNodeHistoryOrderByRelevanceFieldEnum)[keyof typeof ServerNodeHistoryOrderByRelevanceFieldEnum]
export const ServiceCheckOrderByRelevanceFieldEnum = {
name: 'name',
url: 'url',
status: 'status',
message: 'message'
} as const
export type ServiceCheckOrderByRelevanceFieldEnum = (typeof ServiceCheckOrderByRelevanceFieldEnum)[keyof typeof ServiceCheckOrderByRelevanceFieldEnum]
export const Econ_SowInventoryOrderByRelevanceFieldEnum = {
month: 'month'
} as const
@@ -310,14 +416,6 @@ export const Econ_SowInventoryOrderByRelevanceFieldEnum = {
export type Econ_SowInventoryOrderByRelevanceFieldEnum = (typeof Econ_SowInventoryOrderByRelevanceFieldEnum)[keyof typeof Econ_SowInventoryOrderByRelevanceFieldEnum]
export const NullsOrder = {
first: 'first',
last: 'last'
} as const
export type NullsOrder = (typeof NullsOrder)[keyof typeof NullsOrder]
export const ScraperCacheOrderByRelevanceFieldEnum = {
scraperName: 'scraperName',
lastDataHash: 'lastDataHash'
+3
View File
@@ -15,6 +15,9 @@ 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/ServerNode.ts'
export type * from './models/ServerNodeHistory.ts'
export type * from './models/ServiceCheck.ts'
export type * from './models/Econ_SowInventory.ts'
export type * from './models/ScraperCache.ts'
export type * from './commonInputTypes.ts'
+31 -1
View File
@@ -40,6 +40,7 @@ export type NewsMinAggregateOutputType = {
id: number | null
title: string | null
summary: string | null
content: string | null
source: string | null
url: string | null
publishedAt: Date | null
@@ -51,6 +52,7 @@ export type NewsMaxAggregateOutputType = {
id: number | null
title: string | null
summary: string | null
content: string | null
source: string | null
url: string | null
publishedAt: Date | null
@@ -62,6 +64,7 @@ export type NewsCountAggregateOutputType = {
id: number
title: number
summary: number
content: number
source: number
url: number
publishedAt: number
@@ -85,6 +88,7 @@ export type NewsMinAggregateInputType = {
id?: true
title?: true
summary?: true
content?: true
source?: true
url?: true
publishedAt?: true
@@ -96,6 +100,7 @@ export type NewsMaxAggregateInputType = {
id?: true
title?: true
summary?: true
content?: true
source?: true
url?: true
publishedAt?: true
@@ -107,6 +112,7 @@ export type NewsCountAggregateInputType = {
id?: true
title?: true
summary?: true
content?: true
source?: true
url?: true
publishedAt?: true
@@ -205,6 +211,7 @@ export type NewsGroupByOutputType = {
id: number
title: string
summary: string
content: string | null
source: string
url: string
publishedAt: Date
@@ -239,6 +246,7 @@ export type NewsWhereInput = {
id?: Prisma.IntFilter<"News"> | number
title?: Prisma.StringFilter<"News"> | string
summary?: Prisma.StringFilter<"News"> | string
content?: Prisma.StringNullableFilter<"News"> | string | null
source?: Prisma.StringFilter<"News"> | string
url?: Prisma.StringFilter<"News"> | string
publishedAt?: Prisma.DateTimeFilter<"News"> | Date | string
@@ -250,6 +258,7 @@ export type NewsOrderByWithRelationInput = {
id?: Prisma.SortOrder
title?: Prisma.SortOrder
summary?: Prisma.SortOrder
content?: Prisma.SortOrderInput | Prisma.SortOrder
source?: Prisma.SortOrder
url?: Prisma.SortOrder
publishedAt?: Prisma.SortOrder
@@ -266,6 +275,7 @@ export type NewsWhereUniqueInput = Prisma.AtLeast<{
NOT?: Prisma.NewsWhereInput | Prisma.NewsWhereInput[]
title?: Prisma.StringFilter<"News"> | string
summary?: Prisma.StringFilter<"News"> | string
content?: Prisma.StringNullableFilter<"News"> | string | null
source?: Prisma.StringFilter<"News"> | string
url?: Prisma.StringFilter<"News"> | string
publishedAt?: Prisma.DateTimeFilter<"News"> | Date | string
@@ -277,6 +287,7 @@ export type NewsOrderByWithAggregationInput = {
id?: Prisma.SortOrder
title?: Prisma.SortOrder
summary?: Prisma.SortOrder
content?: Prisma.SortOrderInput | Prisma.SortOrder
source?: Prisma.SortOrder
url?: Prisma.SortOrder
publishedAt?: Prisma.SortOrder
@@ -296,6 +307,7 @@ export type NewsScalarWhereWithAggregatesInput = {
id?: Prisma.IntWithAggregatesFilter<"News"> | number
title?: Prisma.StringWithAggregatesFilter<"News"> | string
summary?: Prisma.StringWithAggregatesFilter<"News"> | string
content?: Prisma.StringNullableWithAggregatesFilter<"News"> | string | null
source?: Prisma.StringWithAggregatesFilter<"News"> | string
url?: Prisma.StringWithAggregatesFilter<"News"> | string
publishedAt?: Prisma.DateTimeWithAggregatesFilter<"News"> | Date | string
@@ -306,6 +318,7 @@ export type NewsScalarWhereWithAggregatesInput = {
export type NewsCreateInput = {
title: string
summary?: string
content?: string | null
source?: string
url?: string
publishedAt: Date | string
@@ -317,6 +330,7 @@ export type NewsUncheckedCreateInput = {
id?: number
title: string
summary?: string
content?: string | null
source?: string
url?: string
publishedAt: Date | string
@@ -327,6 +341,7 @@ export type NewsUncheckedCreateInput = {
export type NewsUpdateInput = {
title?: Prisma.StringFieldUpdateOperationsInput | string
summary?: Prisma.StringFieldUpdateOperationsInput | string
content?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
source?: Prisma.StringFieldUpdateOperationsInput | string
url?: Prisma.StringFieldUpdateOperationsInput | string
publishedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@@ -338,6 +353,7 @@ export type NewsUncheckedUpdateInput = {
id?: Prisma.IntFieldUpdateOperationsInput | number
title?: Prisma.StringFieldUpdateOperationsInput | string
summary?: Prisma.StringFieldUpdateOperationsInput | string
content?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
source?: Prisma.StringFieldUpdateOperationsInput | string
url?: Prisma.StringFieldUpdateOperationsInput | string
publishedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@@ -349,6 +365,7 @@ export type NewsCreateManyInput = {
id?: number
title: string
summary?: string
content?: string | null
source?: string
url?: string
publishedAt: Date | string
@@ -359,6 +376,7 @@ export type NewsCreateManyInput = {
export type NewsUpdateManyMutationInput = {
title?: Prisma.StringFieldUpdateOperationsInput | string
summary?: Prisma.StringFieldUpdateOperationsInput | string
content?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
source?: Prisma.StringFieldUpdateOperationsInput | string
url?: Prisma.StringFieldUpdateOperationsInput | string
publishedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@@ -370,6 +388,7 @@ export type NewsUncheckedUpdateManyInput = {
id?: Prisma.IntFieldUpdateOperationsInput | number
title?: Prisma.StringFieldUpdateOperationsInput | string
summary?: Prisma.StringFieldUpdateOperationsInput | string
content?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
source?: Prisma.StringFieldUpdateOperationsInput | string
url?: Prisma.StringFieldUpdateOperationsInput | string
publishedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@@ -392,6 +411,7 @@ export type NewsCountOrderByAggregateInput = {
id?: Prisma.SortOrder
title?: Prisma.SortOrder
summary?: Prisma.SortOrder
content?: Prisma.SortOrder
source?: Prisma.SortOrder
url?: Prisma.SortOrder
publishedAt?: Prisma.SortOrder
@@ -408,6 +428,7 @@ export type NewsMaxOrderByAggregateInput = {
id?: Prisma.SortOrder
title?: Prisma.SortOrder
summary?: Prisma.SortOrder
content?: Prisma.SortOrder
source?: Prisma.SortOrder
url?: Prisma.SortOrder
publishedAt?: Prisma.SortOrder
@@ -419,6 +440,7 @@ export type NewsMinOrderByAggregateInput = {
id?: Prisma.SortOrder
title?: Prisma.SortOrder
summary?: Prisma.SortOrder
content?: Prisma.SortOrder
source?: Prisma.SortOrder
url?: Prisma.SortOrder
publishedAt?: Prisma.SortOrder
@@ -431,12 +453,17 @@ export type NewsSumOrderByAggregateInput = {
impactRank?: Prisma.SortOrder
}
export type NullableStringFieldUpdateOperationsInput = {
set?: string | null
}
export type NewsSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
id?: boolean
title?: boolean
summary?: boolean
content?: boolean
source?: boolean
url?: boolean
publishedAt?: boolean
@@ -450,6 +477,7 @@ export type NewsSelectScalar = {
id?: boolean
title?: boolean
summary?: boolean
content?: boolean
source?: boolean
url?: boolean
publishedAt?: boolean
@@ -457,7 +485,7 @@ export type NewsSelectScalar = {
createdAt?: boolean
}
export type NewsOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "title" | "summary" | "source" | "url" | "publishedAt" | "impactRank" | "createdAt", ExtArgs["result"]["news"]>
export type NewsOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "title" | "summary" | "content" | "source" | "url" | "publishedAt" | "impactRank" | "createdAt", ExtArgs["result"]["news"]>
export type $NewsPayload<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
name: "News"
@@ -466,6 +494,7 @@ export type $NewsPayload<ExtArgs extends runtime.Types.Extensions.InternalArgs =
id: number
title: string
summary: string
content: string | null
source: string
url: string
publishedAt: Date
@@ -843,6 +872,7 @@ export interface NewsFieldRefs {
readonly id: Prisma.FieldRef<"News", 'Int'>
readonly title: Prisma.FieldRef<"News", 'String'>
readonly summary: Prisma.FieldRef<"News", 'String'>
readonly content: Prisma.FieldRef<"News", 'String'>
readonly source: Prisma.FieldRef<"News", 'String'>
readonly url: Prisma.FieldRef<"News", 'String'>
readonly publishedAt: Prisma.FieldRef<"News", 'DateTime'>
-4
View File
@@ -391,10 +391,6 @@ export type ScraperCacheSumOrderByAggregateInput = {
id?: Prisma.SortOrder
}
export type NullableStringFieldUpdateOperationsInput = {
set?: string | null
}
export type ScraperCacheSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+23 -1
View File
@@ -24,7 +24,8 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.26.0",
"recharts": "^3.8.1"
"recharts": "^3.8.1",
"ws": "^8.21.2"
},
"devDependencies": {
"@tauri-apps/api": "^2.0.0",
@@ -6360,6 +6361,27 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/ws": {
"version": "8.21.2",
"resolved": "https://registry.npmmirror.com/ws/-/ws-8.21.2.tgz",
"integrity": "sha512-54dMVAo4WIe6SKy3vBgN+9bJZqqQ8IMRevAkOLQALhi49qkkQDQfWdAZ8KQlXiEabw88ARXXdUrlvtbKQX+aKw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmmirror.com/y18n/-/y18n-5.0.8.tgz",
+2 -1
View File
@@ -41,7 +41,8 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.26.0",
"recharts": "^3.8.1"
"recharts": "^3.8.1",
"ws": "^8.21.2"
},
"devDependencies": {
"@tauri-apps/api": "^2.0.0",
@@ -0,0 +1,68 @@
-- 服务器监控 + 服务健康检查
-- CreateTable
CREATE TABLE `ServerNode` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`uuid` VARCHAR(191) NOT NULL,
`name` VARCHAR(191) NOT NULL,
`cpuName` VARCHAR(191) NOT NULL DEFAULT '',
`os` VARCHAR(191) NOT NULL DEFAULT '',
`region` VARCHAR(191) NOT NULL DEFAULT '',
`memTotal` BIGINT NOT NULL DEFAULT 0,
`diskTotal` BIGINT NOT NULL DEFAULT 0,
`cpuCores` INTEGER NOT NULL DEFAULT 0,
`cpuUsage` DOUBLE NOT NULL DEFAULT 0,
`ramUsed` BIGINT NOT NULL DEFAULT 0,
`swapUsed` BIGINT NOT NULL DEFAULT 0,
`load1` DOUBLE NOT NULL DEFAULT 0,
`load5` DOUBLE NOT NULL DEFAULT 0,
`load15` DOUBLE NOT NULL DEFAULT 0,
`diskUsed` BIGINT NOT NULL DEFAULT 0,
`netIn` BIGINT NOT NULL DEFAULT 0,
`netOut` BIGINT NOT NULL DEFAULT 0,
`uptime` BIGINT NOT NULL DEFAULT 0,
`process` INTEGER NOT NULL DEFAULT 0,
`connections` INTEGER NOT NULL DEFAULT 0,
`status` VARCHAR(191) NOT NULL DEFAULT 'unknown',
`recordedAt` DATETIME(3) NOT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `ServerNode_uuid_key`(`uuid`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `ServerNodeHistory` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`uuid` VARCHAR(191) NOT NULL,
`period` DATETIME(3) NOT NULL,
`cpu` DOUBLE NOT NULL DEFAULT 0,
`ram` BIGINT NOT NULL DEFAULT 0,
`load` DOUBLE NOT NULL DEFAULT 0,
`disk` BIGINT NOT NULL DEFAULT 0,
`netIn` BIGINT NOT NULL DEFAULT 0,
`netOut` BIGINT NOT NULL DEFAULT 0,
`process` INTEGER NOT NULL DEFAULT 0,
`connections` INTEGER NOT NULL DEFAULT 0,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE INDEX `ServerNodeHistory_uuid_period_key`(`uuid`, `period`),
INDEX `ServerNodeHistory_uuid_period_idx`(`uuid`, `period`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `ServiceCheck` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`name` VARCHAR(191) NOT NULL,
`url` VARCHAR(191) NOT NULL,
`status` VARCHAR(191) NOT NULL DEFAULT 'unknown',
`latencyMs` INTEGER NOT NULL DEFAULT 0,
`message` VARCHAR(191) NOT NULL DEFAULT '',
`lastCheckAt` DATETIME(3) NOT NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
UNIQUE INDEX `ServiceCheck_name_key`(`name`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
@@ -0,0 +1,2 @@
-- AlterTable (news.content 全文, 须在 news_and_alerts 建表之后执行)
ALTER TABLE `News` ADD COLUMN `content` TEXT NULL;
+64
View File
@@ -72,6 +72,7 @@ model News {
id Int @id @default(autoincrement())
title String
summary String @default("")
content String? @db.Text
source String @default("")
url String @default("")
publishedAt DateTime
@@ -98,6 +99,69 @@ model Alert {
@@index([createdAt])
}
// 服务器监控: 对接 komari 公开 API
model ServerNode {
id Int @id @default(autoincrement())
uuid String @unique
name String
cpuName String @default("")
os String @default("")
region String @default("")
memTotal BigInt @default(0)
diskTotal BigInt @default(0)
cpuCores Int @default(0)
// 最新实时快照
cpuUsage Float @default(0)
ramUsed BigInt @default(0)
swapUsed BigInt @default(0)
load1 Float @default(0)
load5 Float @default(0)
load15 Float @default(0)
diskUsed BigInt @default(0)
netIn BigInt @default(0)
netOut BigInt @default(0)
uptime BigInt @default(0)
process Int @default(0)
connections Int @default(0)
status String @default("unknown") // online | offline | unknown
recordedAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// 时序历史: 每节点每分钟一条 (komari records 1 分钟粒度)
model ServerNodeHistory {
id Int @id @default(autoincrement())
uuid String
period DateTime
cpu Float @default(0)
ram BigInt @default(0)
load Float @default(0)
disk BigInt @default(0)
netIn BigInt @default(0)
netOut BigInt @default(0)
process Int @default(0)
connections Int @default(0)
createdAt DateTime @default(now())
@@unique([uuid, period])
@@index([uuid, period])
}
// 服务健康检查: 本应用自检 (健康端点 / DB / 第三方)
model ServiceCheck {
id Int @id @default(autoincrement())
name String @unique
url String
status String @default("unknown") // up | down | unknown
latencyMs Int @default(0)
message String @default("")
lastCheckAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Econ_SowInventory {
id Int @id @default(autoincrement())
month String @unique
+72
View File
@@ -0,0 +1,72 @@
const fs = require('fs')
const content = `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 拉取新闻 + 跨源报警检测')
}
// 每 2 分钟: 拉取 komari 节点 + 服务自检
function scheduleMonitors() {
cron.schedule('*/2 * * * *', async () => {
try {
const k = require('./komari-monitor.cjs')
const r = await k.runOnce()
console.log('[cron] komari: nodes=' + r.nodes + ' snapshots=' + r.snapshots + ' history=' + r.history + ' failed=' + r.failed)
} catch (e) { console.error('[cron] komari 任务失败:', e.message) }
try {
const h = require('./health-check.cjs')
const arr = await h.runOnce()
const down = arr.filter(x => x.status !== 'up').map(x => x.name)
console.log('[cron] health: ' + arr.length + ' 项, down=' + (down.length ? down.join(',') : '无'))
} catch (e) { console.error('[cron] health 任务失败:', e.message) }
})
console.log('[cron] 已注册: 每 2 分钟 komari + 服务自检')
}
// 每小时: 清理 24h 前的 komari 历史
function scheduleMonitorCleanup() {
cron.schedule('0 * * * *', async () => {
try {
const k = require('./komari-monitor.cjs')
const n = await k.cleanupOld()
console.log('[cron] 监控历史清理: 删除 ' + n + ' 条')
} catch (e) { console.error('[cron] 监控清理失败:', e.message) }
})
console.log('[cron] 已注册: 每小时清理 24h 前监控历史')
}
scheduleNews()
scheduleMonitors()
scheduleMonitorCleanup()
`
fs.writeFileSync('server/cron.js', content, 'utf8')
console.log('OK len=' + content.length)
+22 -13
View File
@@ -50,15 +50,17 @@ function parseNewsSheet(sheet, impactBase = 0) {
const out = []
for (let i = 0; i < sheet.items.length; i++) {
const r = sheet.items[i]
const title = String(r[0] || '').trim()
const title = String(r[0] || '').trim().slice(0, 190)
if (!title) continue
const publishedAt = parsePublishedAt(r[2])
if (!publishedAt) continue
const body = String(r[1] || '').trim()
out.push({
title,
summary: String(r[1] || '').trim().slice(0, 180),
summary: body.slice(0, 180),
content: body.slice(0, 8000), // 正文全文 (MCP "摘要"列实际是正文), 上限防异常超长
source: String(r[3] || '').trim().slice(0, 80),
url: String(r[4] || '').trim().slice(0, 400),
url: String(r[4] || '').trim().slice(0, 190), // 列宽 varchar(191), 超长会被 MySQL 拒绝
publishedAt,
impactRank: impactBase + i + 1,
})
@@ -103,20 +105,27 @@ 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
let inserted = 0, skipped = 0, failed = 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++
try {
// 注意: content 列依赖迁移 add_news_content 已应用, 发布顺序须先迁移后跑本脚本
// 重复命中 (title, publishedAt) 时只回填仍为 NULL 的 content, 避免覆盖已有全文
const [r] = await conn.execute(
'INSERT INTO `News` (`title`, `summary`, `content`, `source`, `url`, `publishedAt`, `impactRank`, `createdAt`) ' +
'VALUES (?, ?, ?, ?, ?, ?, ?, NOW(3)) ' +
'AS new ON DUPLICATE KEY UPDATE `impactRank` = new.`impactRank`, `content` = IFNULL(News.`content`, new.`content`)',
[n.title, n.summary, n.content || null, n.source, n.url, n.publishedAt, n.impactRank],
)
if (r.affectedRows === 1) inserted++
else skipped++
} catch (e) {
failed++
console.error('[News] 单条入库失败:', n.title, e.message)
}
}
} finally {
await conn.end()
}
return { inserted, skipped }
return { inserted, skipped, failed }
}
+1 -1
View File
@@ -7,6 +7,6 @@ async function main() {
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 + ' 条')
console.log('[News] 入库: 新增 ' + r.inserted + ' 条, 重复跳过 ' + r.skipped + ' 条' + (r.failed ? ', 失败 ' + r.failed + ' 条' : ''))
}
main().catch((e) => { console.error('[News] 失败:', e); process.exit(1) })
+32
View File
@@ -33,4 +33,36 @@ function scheduleNews() {
console.log('[cron] 已注册: 每天 03:00 拉取新闻 + 跨源报警检测')
}
// 每 2 分钟: 拉取 komari 节点 + 服务自检
function scheduleMonitors() {
cron.schedule('*/2 * * * *', async () => {
try {
const k = require('./komari-monitor.cjs')
const r = await k.runOnce()
console.log('[cron] komari: nodes=' + r.nodes + ' snapshots=' + r.snapshots + ' history=' + r.history + ' failed=' + r.failed)
} catch (e) { console.error('[cron] komari 任务失败:', e.message) }
try {
const h = require('./health-check.cjs')
const arr = await h.runOnce()
const down = arr.filter(x => x.status !== 'up').map(x => x.name)
console.log('[cron] health: ' + arr.length + ' 项, down=' + (down.length ? down.join(',') : '无'))
} catch (e) { console.error('[cron] health 任务失败:', e.message) }
})
console.log('[cron] 已注册: 每 2 分钟 komari + 服务自检')
}
// 每小时: 清理 24h 前的 komari 历史
function scheduleMonitorCleanup() {
cron.schedule('0 * * * *', async () => {
try {
const k = require('./komari-monitor.cjs')
const n = await k.cleanupOld()
console.log('[cron] 监控历史清理: 删除 ' + n + ' 条')
} catch (e) { console.error('[cron] 监控清理失败:', e.message) }
})
console.log('[cron] 已注册: 每小时清理 24h 前监控历史')
}
scheduleNews()
scheduleMonitors()
scheduleMonitorCleanup()
+55
View File
@@ -0,0 +1,55 @@
// 服务健康检查: 探测本应用各关键端点, 写入 ServiceCheck 表
const mysql = require('mysql2/promise')
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(/^\//, '') } }
const TARGETS = [
{ name: 'api-health', url: 'http://127.0.0.1:' + (process.env.PORT || 3001) + '/api/health' },
{ name: 'auth-login', url: 'http://127.0.0.1:' + (process.env.PORT || 3001) + '/api/auth/login' },
]
async function probeOne(target) {
const t0 = Date.now()
try {
const ctrl = new AbortController()
setTimeout(() => ctrl.abort(), 8000)
const r = await fetch(target.url, { method: 'GET', signal: ctrl.signal })
const latency = Date.now() - t0
const status = (r.ok || r.status === 400 || r.status === 401) ? 'up' : 'down'
const msg = 'HTTP ' + r.status
return { name: target.name, url: target.url, status, latencyMs: latency, message: msg }
} catch (e) {
return { name: target.name, url: target.url, status: 'down', latencyMs: Date.now() - t0, message: e.message || String(e) }
}
}
async function checkDb() {
const t0 = Date.now()
try {
const conn = await mysql.createConnection(parseUrl(process.env.DATABASE_URL))
await conn.execute('SELECT 1')
await conn.end()
return { name: 'database', url: 'mysql://' + (new URL(process.env.DATABASE_URL)).host, status: 'up', latencyMs: Date.now() - t0, message: 'ok' }
} catch (e) {
return { name: 'database', url: 'mysql', status: 'down', latencyMs: Date.now() - t0, message: e.message }
}
}
async function runOnce() {
const results = await Promise.all([...TARGETS.map(probeOne), checkDb()])
const conn = await mysql.createConnection(parseUrl(process.env.DATABASE_URL))
try {
for (const r of results) {
await conn.execute(
'INSERT INTO `ServiceCheck` (`name`,`url`,`status`,`latencyMs`,`message`,`lastCheckAt`,`createdAt`,`updatedAt`) VALUES (?,?,?,?,?,NOW(3),NOW(3),NOW(3)) ' +
'ON DUPLICATE KEY UPDATE `url`=VALUES(`url`),`status`=VALUES(`status`),`latencyMs`=VALUES(`latencyMs`),`message`=VALUES(`message`),`lastCheckAt`=NOW(3),`updatedAt`=NOW(3)',
[r.name, r.url, r.status, r.latencyMs, r.message.slice(0, 180)],
)
}
} finally {
await conn.end()
}
return results
}
module.exports = { runOnce, TARGETS }
+120
View File
@@ -0,0 +1,120 @@
// komari 服务器监控拉取: 公开 API (无需 key)
// GET /api/nodes 节点列表
// GET /api/recent/{uuid} 最近实时状态 (数组, 多个时间点)
// GET /api/records/load?uuid=... 1 分钟粒度历史 (保留 24h)
const mysql = require('mysql2/promise')
const BASE = process.env.KOMARI_BASE || 'https://komari.honor3.com'
const TIMEOUT = 20000
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 jget(path) {
const ctrl = new AbortController()
setTimeout(() => ctrl.abort(), TIMEOUT)
const r = await fetch(BASE + path, { headers: { 'User-Agent': 'iboard-monitor/1.0' }, signal: ctrl.signal })
if (!r.ok) throw new Error('komari ' + path + ' HTTP ' + r.status)
const j = await r.json()
if (j.status && j.status !== 'success') throw new Error('komari ' + path + ' status: ' + j.status + ' ' + (j.message || ''))
return j.data
}
// 拉一次所有节点 + 最近状态 + 1 分钟历史, 入库
async function runOnce() {
const conn = await mysql.createConnection(parseUrl(process.env.DATABASE_URL))
const stats = { nodes: 0, snapshots: 0, history: 0, failed: 0 }
try {
const nodes = await jget('/api/nodes')
if (!Array.isArray(nodes)) throw new Error('nodes 格式异常')
stats.nodes = nodes.length
for (const n of nodes) {
const uuid = n.uuid
if (!uuid) continue
try {
const recent = await jget('/api/recent/' + uuid)
let snap = null
if (Array.isArray(recent) && recent.length) snap = recent[recent.length - 1]
const recordedAt = snap ? new Date(snap.updated_at) : new Date()
const ramTotal = snap && snap.ram ? Number(snap.ram.total) : Number(n.mem_total || 0)
const diskTotal = snap && snap.disk ? Number(snap.disk.total) : Number(n.disk_total || 0)
await conn.execute(
'INSERT INTO `ServerNode` (`uuid`,`name`,`cpuName`,`os`,`region`,`memTotal`,`diskTotal`,`cpuCores`,' +
'`cpuUsage`,`ramUsed`,`swapUsed`,`load1`,`load5`,`load15`,`diskUsed`,`netIn`,`netOut`,`uptime`,`process`,`connections`,`status`,`recordedAt`,`createdAt`,`updatedAt`) ' +
'VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,NOW(3),NOW(3)) ' +
'ON DUPLICATE KEY UPDATE `name`=VALUES(`name`),`cpuName`=VALUES(`cpuName`),`os`=VALUES(`os`),`region`=VALUES(`region`),' +
'`memTotal`=VALUES(`memTotal`),`diskTotal`=VALUES(`diskTotal`),`cpuCores`=VALUES(`cpuCores`),' +
'`cpuUsage`=VALUES(`cpuUsage`),`ramUsed`=VALUES(`ramUsed`),`swapUsed`=VALUES(`swapUsed`),' +
'`load1`=VALUES(`load1`),`load5`=VALUES(`load5`),`load15`=VALUES(`load15`),`diskUsed`=VALUES(`diskUsed`),' +
'`netIn`=VALUES(`netIn`),`netOut`=VALUES(`netOut`),`uptime`=VALUES(`uptime`),' +
'`process`=VALUES(`process`),`connections`=VALUES(`connections`),`status`=VALUES(`status`),' +
'`recordedAt`=VALUES(`recordedAt`),`updatedAt`=NOW(3)',
[
uuid, n.name || '', n.cpu_name || '', n.os || '', n.region || '',
ramTotal, diskTotal, Number(n.cpu_cores || 0),
snap ? Number(snap.cpu?.usage || 0) : 0,
snap ? Number(snap.ram?.used || 0) : 0,
snap ? Number(snap.swap?.used || 0) : 0,
snap ? Number(snap.load?.load1 || 0) : 0,
snap ? Number(snap.load?.load5 || 0) : 0,
snap ? Number(snap.load?.load15 || 0) : 0,
snap ? Number(snap.disk?.used || 0) : 0,
snap ? Number(snap.network?.down || 0) : 0,
snap ? Number(snap.network?.up || 0) : 0,
snap ? Number(snap.uptime || 0) : 0,
snap ? Number(snap.process || 0) : 0,
snap ? Number(snap.connections?.tcp || 0) : 0,
snap ? 'online' : 'unknown',
recordedAt,
],
)
stats.snapshots++
let rec = null
try { rec = await jget('/api/records/load?uuid=' + uuid) } catch (e) { /* 节点未开启 record */ }
if (rec && Array.isArray(rec.records)) {
for (const r of rec.records) {
const period = new Date(r.time)
if (isNaN(period.getTime())) continue
const [res] = await conn.execute(
'INSERT INTO `ServerNodeHistory` (`uuid`,`period`,`cpu`,`ram`,`load`,`disk`,`netIn`,`netOut`,`process`,`connections`,`createdAt`) ' +
'VALUES (?,?,?,?,?,?,?,?,?,?,NOW(3)) ' +
'ON DUPLICATE KEY UPDATE `cpu`=VALUES(`cpu`),`ram`=VALUES(`ram`),`load`=VALUES(`load`),`disk`=VALUES(`disk`),' +
'`netIn`=VALUES(`netIn`),`netOut`=VALUES(`netOut`),`process`=VALUES(`process`),`connections`=VALUES(`connections`)',
[
uuid, period,
Number(r.cpu || 0), Number(r.ram || 0), Number(r.load || 0),
Number(r.disk || 0), Number(r.net_in || 0), Number(r.net_out || 0),
Number(r.process || 0), Number(r.connections || 0),
],
)
if (res.affectedRows === 1) stats.history++
}
}
} catch (e) {
stats.failed++
console.error('[komari] 节点 ' + uuid + ' 失败:', e.message)
}
}
} finally {
await conn.end()
}
return stats
}
// 清理 24h 之前的历史
async function cleanupOld() {
const conn = await mysql.createConnection(parseUrl(process.env.DATABASE_URL))
try {
const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000)
const [r] = await conn.execute('DELETE FROM `ServerNodeHistory` WHERE `period` < ?', [cutoff])
return r.affectedRows
} finally {
await conn.end()
}
}
module.exports = { runOnce, cleanupOld, BASE }
+13 -8
View File
@@ -8,16 +8,21 @@ 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 rawLimit = Number(req.query.limit)
const limit = Number.isFinite(rawLimit) && rawLimit >= 1 ? Math.min(Math.floor(rawLimit), 50) : 10
const rawCursor = Number(req.query.cursor)
const cursor = Number.isInteger(rawCursor) && rawCursor > 0 ? rawCursor : null
const where = cursor ? { id: { lt: cursor } } : {}
const items = await prisma.alert.findMany({
where,
orderBy: { id: 'desc' },
take: limit,
})
const [items, total] = await Promise.all([
prisma.alert.findMany({
where,
orderBy: { id: 'desc' },
take: limit,
}),
prisma.alert.count(),
])
const nextCursor = items.length === limit ? String(items[items.length - 1].id) : null
res.json({ items, nextCursor })
res.json({ items, nextCursor, total })
} catch (e) { next(e) }
})
+42
View File
@@ -0,0 +1,42 @@
import { Router } from 'express'
import { prisma } from '../db.js'
import { authRequired } from '../middleware.js'
const router = Router()
router.use(authRequired)
// 服务器节点列表: 元数据 + 最新快照
router.get('/servers', async (_req, res, next) => {
try {
const rows = await prisma.serverNode.findMany({
orderBy: { name: 'asc' },
})
res.json({ items: rows })
} catch (e) { next(e) }
})
// 节点时序: ?uuid=...&hours=24
router.get('/servers/:uuid/history', async (req, res, next) => {
try {
const hours = Math.min(Math.max(Number(req.query.hours) || 24, 1), 168)
const since = new Date(Date.now() - hours * 3600 * 1000)
const rows = await prisma.serverNodeHistory.findMany({
where: { uuid: req.params.uuid, period: { gte: since } },
orderBy: { period: 'asc' },
take: 2000,
})
res.json({ items: rows })
} catch (e) { next(e) }
})
// 服务健康列表
router.get('/services', async (_req, res, next) => {
try {
const rows = await prisma.serviceCheck.findMany({
orderBy: { name: 'asc' },
})
res.json({ items: rows })
} catch (e) { next(e) }
})
export default router
+32 -6
View File
@@ -5,21 +5,47 @@ import { authRequired } from '../middleware.js'
const router = Router()
router.use(authRequired)
// 新闻列表: 按时间最新优先, cursor 分页 (cursor = publishedAt ISO 字符串)
// 用 prisma cursor 配合 id 兜底 (同一秒发布的新闻)
// 新闻列表: 按时间最新优先, cursor 分页
// cursor 格式: "<publishedAt ISO>|<lastId>", 兼容旧版纯时间戳 (不带 |)
// 同秒多条时用 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 rawLimit = Number(req.query.limit)
const limit = Number.isFinite(rawLimit) && rawLimit >= 1 ? Math.min(Math.floor(rawLimit), 50) : 10
let where = {}
const cursor = req.query.cursor || ''
if (cursor) {
const [ts, idRaw] = cursor.split('|')
const c = new Date(ts)
if (!isNaN(c.getTime())) {
const id = Number(idRaw)
where = id > 0
? { OR: [{ publishedAt: { lt: c } }, { publishedAt: c, id: { lt: id } }] }
: { publishedAt: { lt: c } }
}
}
const items = await prisma.news.findMany({
select: { id: true, title: true, summary: true, source: true, url: true, publishedAt: true, impactRank: true },
where,
orderBy: [{ publishedAt: 'desc' }, { id: 'desc' }],
take: limit,
})
const nextCursor = items.length === limit ? items[items.length - 1].publishedAt.toISOString() : null
const nextCursor = items.length === limit && items.length > 0
? items[items.length - 1].publishedAt.toISOString() + '|' + items[items.length - 1].id
: null
res.json({ items, nextCursor })
} catch (e) { next(e) }
})
// 新闻详情: 含正文 content
router.get('/news/:id', async (req, res, next) => {
try {
const id = Number(req.params.id)
if (!Number.isInteger(id) || id <= 0) return res.status(400).json({ error: '无效的新闻 id' })
const item = await prisma.news.findUnique({ where: { id } })
if (!item) return res.status(404).json({ error: '新闻不存在' })
res.json({ item })
} catch (e) { next(e) }
})
export default router
+2
View File
@@ -2,6 +2,7 @@ import { Routes, Route, Navigate, useLocation } from 'react-router-dom'
import MobileShell from './layouts/MobileShell.jsx'
import LoginPage from './pages/LoginPage.jsx'
import HomePage from './pages/HomePage.jsx'
import NewsDetail from './pages/news/NewsDetail.jsx'
import MacroPage from './pages/MacroPage.jsx'
import MonitorPage from './pages/MonitorPage.jsx'
import MePage from './pages/MePage.jsx'
@@ -30,6 +31,7 @@ export default function App() {
<Route path="/login" element={<RedirectIfAuthed><LoginPage /></RedirectIfAuthed>} />
<Route element={<RequireAuth><MobileShell /></RequireAuth>}>
<Route path="/" element={<HomePage />} />
<Route path="/news/:id" element={<NewsDetail />} />
<Route path="/macro/*" element={<MacroPage />} />
<Route path="/monitor" element={<MonitorPage />} />
<Route path="/me" element={<MePage />} />
+36 -9
View File
@@ -1,6 +1,8 @@
import { useEffect, useState, useRef, useCallback } from 'react'
import { NavBar, List, Card, Empty, DotLoading } from 'antd-mobile'
import { useNavigate } from 'react-router-dom'
import { NavBar, Card, Empty, DotLoading, Badge } from 'antd-mobile'
import { api } from '../api/client.js'
import SwipeTabs from '../components/SwipeTabs.jsx'
// 首页: 报警模块 + 新闻模块
// - 报警: World Bank 与妙想 MCP 跨源同 (指标, 周期) 数据差异超阈值 (按时间倒序)
@@ -22,41 +24,62 @@ function fmtDate(s) {
}
export default function HomePage() {
const [active, setActive] = useState('news') // 默认选中新闻
const [alertCount, setAlertCount] = useState(0)
const handleAlertCount = useCallback((n) => setAlertCount(n), [])
const tabs = [
{
key: 'alerts',
label: (
<span className="tab-label">
报警
{alertCount > 0 && <Badge content={alertCount} />}
</span>
),
render: () => <AlertSection onCount={handleAlertCount} />,
},
{ key: 'news', label: '新闻', render: () => <NewsSection /> },
]
return (
<div className="page-with-navbar">
<NavBar back={null}>首页</NavBar>
<div className="home-content">
<AlertSection />
<NewsSection />
<SwipeTabs tabs={tabs} active={active} onChange={setActive} />
</div>
</div>
)
}
// 报警模块
function AlertSection() {
function AlertSection({ onCount }) {
const [items, setItems] = useState([])
const [loading, setLoading] = useState(false)
const [done, setDone] = useState(false)
const cursorRef = useRef(null)
const sentinelRef = useRef(null)
const inFlightRef = useRef(false)
const fetchPage = useCallback(async () => {
if (loading || done) return
if (inFlightRef.current || done) return
inFlightRef.current = true
setLoading(true)
try {
const url = '/alerts?limit=10' + (cursorRef.current ? '&cursor=' + cursorRef.current : '')
const data = await api(url)
setItems((prev) => prev.concat(data.items || []))
onCount?.(data.total ?? (data.items?.length || 0))
cursorRef.current = data.nextCursor
if (!data.nextCursor) setDone(true)
} catch (e) {
console.error('alerts load error', e)
setDone(true)
} finally {
inFlightRef.current = false
setLoading(false)
}
}, [loading, done])
}, [done, onCount])
useEffect(() => { fetchPage() }, [])
useEffect(() => {
@@ -106,9 +129,12 @@ function NewsSection() {
const [done, setDone] = useState(false)
const cursorRef = useRef(null)
const sentinelRef = useRef(null)
const inFlightRef = useRef(false)
const navigate = useNavigate()
const fetchPage = useCallback(async () => {
if (loading || done) return
if (inFlightRef.current || done) return
inFlightRef.current = true
setLoading(true)
try {
const url = '/news?limit=10' + (cursorRef.current ? '&cursor=' + encodeURIComponent(cursorRef.current) : '')
@@ -120,9 +146,10 @@ function NewsSection() {
console.error('news load error', e)
setDone(true)
} finally {
inFlightRef.current = false
setLoading(false)
}
}, [loading, done])
}, [done])
useEffect(() => { fetchPage() }, [])
useEffect(() => {
@@ -144,7 +171,7 @@ function NewsSection() {
) : (
<div className="section-list">
{items.map((n) => (
<Card key={n.id} className="news-card">
<Card key={n.id} className="news-card" onClick={() => navigate('/news/' + n.id)}>
<div className="news-title">{n.title}</div>
{n.summary && <div className="news-summary">{n.summary}</div>}
<div className="news-meta">
+56
View File
@@ -0,0 +1,56 @@
import { useEffect, useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { NavBar } from 'antd-mobile'
import { api } from '../../api/client.js'
// 新闻详情: /news/:id
// 正文来自 News.content (MCP 返回的完整正文), 旧数据可能无 content, 兜底显示摘要
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())
}
export default function NewsDetail() {
const { id } = useParams()
const navigate = useNavigate()
const [item, setItem] = useState(null)
const [err, setErr] = useState('')
const [loading, setLoading] = useState(true)
useEffect(() => {
let cancelled = false
setLoading(true)
api('/news/' + encodeURIComponent(id))
.then((data) => { if (!cancelled) setItem(data.item) })
.catch((e) => !cancelled && setErr(e.message))
.finally(() => !cancelled && setLoading(false))
return () => { cancelled = true }
}, [id])
return (
<div className="article-page">
<NavBar onBack={() => navigate(-1)}>新闻详情</NavBar>
{loading && <div className="loading">加载中...</div>}
{err && <div className="error-banner">{err}</div>}
{!loading && !err && !item && <div className="empty">新闻不存在</div>}
{!loading && !err && item && (
<div className="news-detail">
<h1>{item.title}</h1>
<div className="meta">
{item.source || '未知来源'} · {fmtDate(item.publishedAt)} · #{item.impactRank}
{/^https?:\/\//i.test(item.url || '') && (
<a className="news-origin" href={item.url} target="_blank" rel="noreferrer">原文链接</a>
)}
</div>
<div className="news-content">
{item.content ? item.content : item.summary}
{!item.content && <div className="news-content-empty">(本条暂无全文,仅摘要)</div>}
</div>
</div>
)}
</div>
)
}
+10
View File
@@ -197,6 +197,8 @@ input, textarea, select { font: inherit; }
.me-logout { color: var(--color-danger); }
/* 首页模块: 报警 + 新闻 */
.home-content { padding: 0 0 16px; }
.home-content .adm-tabs-content { --content-padding: 0; }
.home-content .tab-label { display: inline-flex; align-items: center; gap: 4px; }
.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; }
@@ -214,4 +216,12 @@ input, textarea, select { font: inherit; }
.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; }
.news-card:active .adm-card-body { background: #f5f5f5; }
/* 新闻详情页 */
.news-detail { background: #fff; padding: 18px 16px 24px; }
.news-detail h1 { font-size: 20px; line-height: 1.35; margin-bottom: 8px; }
.news-detail .meta { font-size: 12px; color: var(--color-muted); margin-bottom: 14px; padding-bottom: 10px; border-bottom: 1px solid var(--color-border); display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
.news-origin { color: var(--color-primary); margin-left: auto; }
.news-content { font-size: 15px; line-height: 1.75; color: #333; white-space: pre-wrap; word-break: break-word; }
.news-content-empty { margin-top: 12px; font-size: 12px; color: var(--color-muted); }
.scroll-sentinel { text-align: center; padding: 14px 0 4px; color: var(--color-muted); font-size: 12px; }