更新首页

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

5
.gitignore vendored
View File

@ -31,6 +31,7 @@ lerna-debug.log*
*.swo
*~
dist/*
# local env (contains API keys)
# local env (contains API keys)
/.env
.reasonix/*

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
*

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
*

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

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
}

View File

@ -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'

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'

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

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

24
package-lock.json generated
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",

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",

View File

@ -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;

View File

@ -0,0 +1,2 @@
-- AlterTable (news.content 全文, 须在 news_and_alerts 建表之后执行)
ALTER TABLE `News` ADD COLUMN `content` TEXT NULL;

View File

@ -1,3 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "mysql"
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "mysql"

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
scripts/.fix-cron.cjs Normal file
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)

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

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) })

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
server/health-check.cjs Normal file
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
server/komari-monitor.cjs Normal file
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 }

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
server/routes/monitors.js Normal file
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

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

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 />} />

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

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>
)
}

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