feat: 完善 signals 热度筛选与展示交互

This commit is contained in:
2026-02-24 17:31:43 +08:00
parent f157174341
commit ff6f12be21
12 changed files with 541 additions and 134 deletions
+15 -11
View File
@@ -21,17 +21,18 @@ export default async function SignalsPage({ params }: SignalsPageProps) {
const t = await getTranslations({ locale, namespace: 'signals' })
return (
<main className="container mx-auto max-w-7xl px-4 py-12 md:py-16">
<section className="neo-card p-7 md:p-10 mb-8 relative overflow-hidden">
<div className="absolute -right-6 -top-6 h-24 w-24 rotate-12 border-2 border-black opacity-20 dark:border-gray-500" />
<div className="absolute -bottom-6 -left-6 h-14 w-28 -rotate-12 border-2 border-black opacity-20 dark:border-gray-500" />
<p className="inline-flex items-center gap-2 border-2 border-black bg-primary px-3 py-2 font-display text-xs font-bold uppercase text-black shadow-neo-sm">
{t('heroEyebrow')}
</p>
<h1 className="mt-5 font-display text-4xl font-bold leading-tight tracking-tight md:text-5xl">
{t('heroTitle')}
</h1>
<p className="mt-4 max-w-4xl text-base leading-relaxed text-gray-700 dark:text-gray-300 md:text-lg">
<main className="container mx-auto max-w-7xl px-4 py-6 md:py-8">
<section className="neo-card mb-4 p-4 md:p-5 relative overflow-hidden">
<div className="absolute -right-4 -top-4 h-16 w-16 rotate-12 border-2 border-black opacity-15 dark:border-gray-500" />
<div className="flex flex-wrap items-center gap-2.5">
<p className="inline-flex items-center gap-2 border-2 border-black bg-primary px-2.5 py-1.5 font-display text-[10px] font-bold uppercase text-black shadow-neo-sm">
{t('heroEyebrow')}
</p>
<h1 className="font-display text-2xl font-bold leading-tight tracking-tight md:text-3xl">
{t('heroTitle')}
</h1>
</div>
<p className="mt-2 text-sm leading-relaxed text-gray-700 dark:text-gray-300 md:text-base">
{t('heroDescription')}
</p>
</section>
@@ -54,6 +55,9 @@ export default async function SignalsPage({ params }: SignalsPageProps) {
sourceHuggingFace: t('sourceHuggingFace'),
sourceReddit: t('sourceReddit'),
sourceProductHunt: t('sourceProductHunt'),
hotBadge: t('hotBadge'),
totalCountHint: t('totalCountHint'),
hotCountHint: t('hotCountHint'),
loadMore: t('loadMore'),
loading: t('loading'),
loadFailed: t('loadFailed'),
+95 -28
View File
@@ -1,7 +1,8 @@
import { NextRequest, NextResponse } from 'next/server'
import type { Prisma } from '@prisma/client'
import { Prisma } from '@prisma/client'
import { ZodError } from 'zod'
import { prisma } from '@/lib/prisma'
import { computeSignalHotness, isSignalHotColumnMissingError } from '@/lib/signal-hotness'
import {
SignalQuerySchema,
type SignalQuery,
@@ -24,6 +25,8 @@ interface SignalView {
source: SignalSource
sourceUrl: string
engagement: number
hotScore: number
isHot: boolean
publishedAt: string
topic: string
tags: string[]
@@ -143,6 +146,8 @@ function toSignalView(row: {
tags: Prisma.JsonValue
sections: Prisma.JsonValue
engagement: number
hotScore?: number | null
isHot?: boolean | null
publishedAt: Date
}, locale: SignalQuery['locale']): SignalView | null {
if (!isSignalSource(row.source)) {
@@ -151,6 +156,13 @@ function toSignalView(row: {
const title = pickLocalizedText(locale, row.title, row.titleEn)
const summary = pickLocalizedText(locale, row.summary, row.summaryEn)
const fallbackHotness = computeSignalHotness({
source: row.source,
engagement: row.engagement,
publishedAt: row.publishedAt,
})
const hotScore = typeof row.hotScore === 'number' && row.hotScore > 0 ? row.hotScore : fallbackHotness.hotScore
const isHot = typeof row.isHot === 'boolean' ? row.isHot || fallbackHotness.isHot : fallbackHotness.isHot
return {
id: row.id,
@@ -160,6 +172,8 @@ function toSignalView(row: {
source: row.source,
sourceUrl: row.sourceUrl,
engagement: row.engagement,
hotScore,
isHot,
publishedAt: row.publishedAt.toISOString(),
topic: pickLocalizedText(locale, row.topic || '', row.topicEn || undefined),
tags: parseTags(row.tags),
@@ -215,35 +229,88 @@ export async function GET(request: NextRequest) {
const orderBy: Prisma.SignalOrderByWithRelationInput[] =
parsed.sort === 'hot'
? [{ engagement: 'desc' }, { publishedAt: 'desc' }, { id: 'desc' }]
? [{ isHot: 'desc' }, { hotScore: 'desc' }, { engagement: 'desc' }, { publishedAt: 'desc' }, { id: 'desc' }]
: [{ publishedAt: 'desc' }, { id: 'desc' }]
const rows = await prisma.signal.findMany({
where,
orderBy,
take: parsed.limit + 1,
...(parsed.cursor
? {
cursor: { id: parsed.cursor },
skip: 1,
}
: {}),
select: {
id: true,
source: true,
sourceUrl: true,
title: true,
titleEn: true,
summary: true,
summaryEn: true,
topic: true,
topicEn: true,
tags: true,
sections: true,
engagement: true,
publishedAt: true,
},
})
const cursorArgs = parsed.cursor
? {
cursor: { id: parsed.cursor },
skip: 1 as const,
}
: {}
let rows: Array<{
id: string
source: string
sourceUrl: string
title: string
titleEn: string | null
summary: string
summaryEn: string | null
topic: string | null
topicEn: string | null
tags: Prisma.JsonValue
sections: Prisma.JsonValue
engagement: number
hotScore?: number | null
isHot?: boolean | null
publishedAt: Date
}>
try {
rows = await prisma.signal.findMany({
where,
orderBy,
take: parsed.limit + 1,
...cursorArgs,
select: {
id: true,
source: true,
sourceUrl: true,
title: true,
titleEn: true,
summary: true,
summaryEn: true,
topic: true,
topicEn: true,
tags: true,
sections: true,
engagement: true,
hotScore: true,
isHot: true,
publishedAt: true,
},
})
} catch (error) {
if (!isSignalHotColumnMissingError(error)) {
throw error
}
const fallbackOrderBy: Prisma.SignalOrderByWithRelationInput[] =
parsed.sort === 'hot' ? [{ engagement: 'desc' }, { publishedAt: 'desc' }, { id: 'desc' }] : [{ publishedAt: 'desc' }, { id: 'desc' }]
rows = await prisma.signal.findMany({
where,
orderBy: fallbackOrderBy,
take: parsed.limit + 1,
...cursorArgs,
select: {
id: true,
source: true,
sourceUrl: true,
title: true,
titleEn: true,
summary: true,
summaryEn: true,
topic: true,
topicEn: true,
tags: true,
sections: true,
engagement: true,
publishedAt: true,
},
})
}
const hasMore = rows.length > parsed.limit
const pageRows = hasMore ? rows.slice(0, parsed.limit) : rows
+47 -22
View File
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
import type { Prisma } from '@prisma/client'
import { prisma } from '@/lib/prisma'
import { isValidApiKey } from '@/lib/auth'
import { computeSignalHotness, isSignalHotColumnMissingError } from '@/lib/signal-hotness'
import {
SignalIngestionInputSchema,
SignalWebhookPayloadSchema,
@@ -84,6 +85,27 @@ export async function POST(request: NextRequest) {
try {
const validSignal = itemValidation.data as SignalIngestionInput
const computedHotness = computeSignalHotness({
source: validSignal.source,
engagement: validSignal.engagement,
publishedAt: validSignal.publishedAt,
})
const hotScore =
typeof validSignal.hotScore === 'number' ? validSignal.hotScore : computedHotness.hotScore
const isHot = typeof validSignal.isHot === 'boolean' ? validSignal.isHot : computedHotness.isHot
const baseData = {
title: validSignal.title,
titleEn: validSignal.titleEn || null,
summary: validSignal.summary,
summaryEn: validSignal.summaryEn || null,
topic: validSignal.topic || null,
topicEn: validSignal.topicEn || null,
tags: toTagsJson(validSignal.tags),
sections: toSectionsJson(validSignal.sections),
engagement: validSignal.engagement,
publishedAt: new Date(validSignal.publishedAt),
isActive: validSignal.isActive,
}
const existing = await prisma.signal.findUnique({
where: {
@@ -103,33 +125,36 @@ export async function POST(request: NextRequest) {
},
},
update: {
title: validSignal.title,
titleEn: validSignal.titleEn || null,
summary: validSignal.summary,
summaryEn: validSignal.summaryEn || null,
topic: validSignal.topic || null,
topicEn: validSignal.topicEn || null,
tags: toTagsJson(validSignal.tags),
sections: toSectionsJson(validSignal.sections),
engagement: validSignal.engagement,
publishedAt: new Date(validSignal.publishedAt),
isActive: validSignal.isActive,
...baseData,
hotScore,
isHot,
},
create: {
source: validSignal.source,
sourceUrl: validSignal.sourceUrl,
title: validSignal.title,
titleEn: validSignal.titleEn || null,
summary: validSignal.summary,
summaryEn: validSignal.summaryEn || null,
topic: validSignal.topic || null,
topicEn: validSignal.topicEn || null,
tags: toTagsJson(validSignal.tags),
sections: toSectionsJson(validSignal.sections),
engagement: validSignal.engagement,
publishedAt: new Date(validSignal.publishedAt),
isActive: validSignal.isActive,
...baseData,
hotScore,
isHot,
},
}).catch(async (error) => {
if (!isSignalHotColumnMissingError(error)) {
throw error
}
await prisma.signal.upsert({
where: {
source_sourceUrl: {
source: validSignal.source,
sourceUrl: validSignal.sourceUrl,
},
},
update: baseData,
create: {
source: validSignal.source,
sourceUrl: validSignal.sourceUrl,
...baseData,
},
})
})
if (existing) {
+163 -69
View File
@@ -1,12 +1,15 @@
'use client'
import Link from 'next/link'
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import {
ArrowUpRight,
ChevronDown,
Flame,
FlaskConical,
Globe2,
Github,
List,
MessageCircle,
Newspaper,
Rocket,
@@ -41,6 +44,8 @@ export interface IdeaSignal {
source: SignalSource
sourceUrl: string
engagement: number
hotScore: number
isHot: boolean
publishedAt: string
topic: string
tags: string[]
@@ -76,6 +81,9 @@ interface SignalFeedClientProps {
sourceHuggingFace: string
sourceReddit: string
sourceProductHunt: string
hotBadge: string
totalCountHint: string
hotCountHint: string
loadMore: string
loading: string
loadFailed: string
@@ -216,7 +224,9 @@ export function SignalFeedClient({ locale, translations }: SignalFeedClientProps
const [search, setSearch] = useState('')
const [debouncedSearch, setDebouncedSearch] = useState('')
const [source, setSource] = useState<SourceFilter>('all')
const [sort, setSort] = useState<SortKey>('latest')
const [sort, setSort] = useState<SortKey>('hot')
const [isSortMenuOpen, setIsSortMenuOpen] = useState(false)
const sortMenuRef = useRef<HTMLDivElement | null>(null)
const [signals, setSignals] = useState<IdeaSignal[]>([])
const [nextCursor, setNextCursor] = useState<string | null>(null)
@@ -283,6 +293,35 @@ export function SignalFeedClient({ locale, translations }: SignalFeedClientProps
}
}, [debouncedSearch, locale, sort, source])
useEffect(() => {
if (!isSortMenuOpen) {
return
}
function handlePointerDown(event: PointerEvent) {
if (!sortMenuRef.current) {
return
}
if (event.target instanceof Node && !sortMenuRef.current.contains(event.target)) {
setIsSortMenuOpen(false)
}
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') {
setIsSortMenuOpen(false)
}
}
window.addEventListener('pointerdown', handlePointerDown)
window.addEventListener('keydown', handleKeyDown)
return () => {
window.removeEventListener('pointerdown', handlePointerDown)
window.removeEventListener('keydown', handleKeyDown)
}
}, [isSortMenuOpen])
async function handleLoadMore() {
if (!hasMore || !nextCursor || isLoadingMore) {
return
@@ -344,64 +383,120 @@ export function SignalFeedClient({ locale, translations }: SignalFeedClientProps
'data-[active=true]:bg-black data-[active=true]:text-white dark:data-[active=true]:bg-primary dark:data-[active=true]:text-black',
}
const activeSortLabel = sortOptions.find((option) => option.key === sort)?.label || translations.sortLatest
const hotCount = signals.filter((item) => item.isHot).length
return (
<div className="space-y-4">
<article className="neo-card p-3.5 md:p-4">
<div className="grid grid-cols-1 gap-2.5 lg:grid-cols-2">
<label className="flex items-center gap-3 neo-input p-3">
<Search className="h-4 w-4" aria-hidden="true" />
<span className="sr-only">{translations.searchLabel}</span>
<input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder={translations.searchPlaceholder}
className="w-full bg-transparent outline-none font-sans text-sm"
/>
</label>
<div className="flex items-center gap-2 neo-input p-2">
<TrendingUp className="h-4 w-4 ml-1" aria-hidden="true" />
<label htmlFor="signal-sort" className="font-display text-xs font-bold uppercase">
{translations.sortLabel}
<div className="space-y-3">
<article className="neo-card bg-white/95 p-2.5 backdrop-blur dark:bg-surface-dark/95 md:p-3">
<div className="border-2 border-black bg-orange-50/70 p-2 dark:border-gray-600 dark:bg-gray-900/50 md:p-2.5">
<div className="grid grid-cols-1 gap-2 xl:grid-cols-[minmax(0,1fr)_220px_auto]">
<label className="flex h-10 items-center gap-2.5 border-2 border-black bg-white px-3 dark:border-gray-500 dark:bg-surface-dark">
<Search className="h-4 w-4 shrink-0" aria-hidden="true" />
<span className="sr-only">{translations.searchLabel}</span>
<input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder={translations.searchPlaceholder}
className="w-full bg-transparent outline-none font-sans text-sm"
/>
</label>
<select
id="signal-sort"
value={sort}
onChange={(event) => setSort(event.target.value as SortKey)}
className="ml-auto bg-transparent outline-none text-sm font-display font-bold cursor-pointer"
>
{sortOptions.map((option) => (
<option key={option.key} value={option.key}>
{option.label}
</option>
))}
</select>
</div>
</div>
<div className="mt-2.5">
<p className="mb-2 font-display text-xs font-bold uppercase">{translations.sourceLabel}</p>
<div className="flex flex-wrap gap-2">
{sourceOptions.map((option) => {
const active = source === option
const meta = option === 'all' ? allSourcesMeta : sourceMeta[option]
const SourceIcon = meta.icon
<div ref={sortMenuRef} className="relative">
<button
id="signal-sort"
type="button"
aria-label={translations.sortLabel}
aria-expanded={isSortMenuOpen}
aria-haspopup="listbox"
onClick={() => setIsSortMenuOpen((previous) => !previous)}
className="neo-btn flex h-10 w-full items-center justify-between bg-white px-2.5 dark:bg-surface-dark"
>
<span className="inline-flex min-w-0 items-center gap-1.5 font-display text-xs font-bold uppercase text-black dark:text-gray-100">
{sort === 'hot' ? <TrendingUp className="h-3.5 w-3.5 shrink-0" aria-hidden="true" /> : null}
<span className="truncate">{activeSortLabel}</span>
</span>
<ChevronDown
className={`h-3.5 w-3.5 shrink-0 transition-transform ${isSortMenuOpen ? 'rotate-180' : ''}`}
aria-hidden="true"
/>
</button>
return (
<button
key={option}
type="button"
data-active={active}
onClick={() => setSource(option)}
className={`min-h-10 border-2 border-black bg-white px-3 py-1.5 font-display text-xs font-bold uppercase transition-colors cursor-pointer hover:bg-gray-100 dark:border-gray-500 dark:bg-surface-dark dark:hover:bg-gray-800 ${meta.filterClass}`}
{isSortMenuOpen ? (
<div
role="listbox"
aria-labelledby="signal-sort"
className="neo-card absolute left-0 right-0 top-[calc(100%+6px)] z-30 overflow-hidden"
>
<span className="inline-flex items-center gap-1.5">
<SourceIcon className="h-3.5 w-3.5" aria-hidden="true" />
{sourceLabels[option]}
</span>
</button>
)
})}
{sortOptions.map((option) => (
<button
key={option.key}
type="button"
role="option"
aria-selected={sort === option.key}
onClick={() => {
setSort(option.key)
setIsSortMenuOpen(false)
}}
className={`flex w-full items-center border-b-2 border-black px-2.5 py-2 text-left font-display text-xs font-bold uppercase last:border-b-0 dark:border-gray-600 ${
sort === option.key
? 'bg-primary text-black'
: 'bg-white text-black hover:bg-gray-100 dark:bg-surface-dark dark:text-gray-100 dark:hover:bg-gray-800'
}`}
>
<span className="inline-flex items-center gap-1.5">
{option.key === 'hot' ? <TrendingUp className="h-3.5 w-3.5 shrink-0" aria-hidden="true" /> : null}
<span>{option.label}</span>
</span>
</button>
))}
</div>
) : null}
</div>
<div className="flex flex-wrap items-center justify-start gap-1.5 xl:justify-end">
<span
title={translations.totalCountHint}
aria-label={`${translations.totalCountHint}: ${signals.length}`}
className="inline-flex h-8 items-center gap-1 border-2 border-black bg-primary px-2 py-1 font-display text-[10px] font-bold uppercase text-black"
>
<List className="h-3 w-3" aria-hidden="true" />
{signals.length}
</span>
<span
title={translations.hotCountHint}
aria-label={`${translations.hotCountHint}: ${hotCount}`}
className="inline-flex h-8 items-center gap-1 border-2 border-black bg-red-200 px-2 py-1 font-display text-[10px] font-bold uppercase text-black"
>
<Flame className="h-3 w-3" aria-hidden="true" />
{hotCount}
</span>
</div>
</div>
<div className="mt-2 min-w-0 overflow-x-auto pb-1">
<div className="flex min-w-max gap-1.5">
{sourceOptions.map((option) => {
const active = source === option
const meta = option === 'all' ? allSourcesMeta : sourceMeta[option]
const SourceIcon = meta.icon
return (
<button
key={option}
type="button"
data-active={active}
onClick={() => setSource(option)}
className={`h-8 border-2 border-black bg-white px-2.5 font-display text-[11px] font-bold uppercase transition-colors cursor-pointer hover:bg-gray-100 dark:border-gray-500 dark:bg-surface-dark dark:hover:bg-gray-800 ${meta.filterClass}`}
>
<span className="inline-flex items-center gap-1.5">
<SourceIcon className="h-3.5 w-3.5" aria-hidden="true" />
{sourceLabels[option]}
</span>
</button>
)
})}
</div>
</div>
</div>
</article>
@@ -414,20 +509,25 @@ export function SignalFeedClient({ locale, translations }: SignalFeedClientProps
<article className="neo-card p-8 text-center font-display font-bold">{translations.noResults}</article>
) : (
<div className="space-y-3">
<div className="grid grid-cols-1 gap-2.5 lg:grid-cols-2">
<div className="grid grid-cols-1 gap-2.5">
{signals.map((signal) => {
const sourceInfo = sourceMeta[signal.source]
const SourceIcon = sourceInfo.icon
const visibleTags = signal.tags.slice(0, 2)
const hiddenTagCount = Math.max(0, signal.tags.length - visibleTags.length)
return (
<article key={signal.id} className="neo-card relative p-3.5">
<div className="flex items-start justify-between gap-3">
<h2 className="line-clamp-2 font-display text-lg font-bold leading-tight md:text-xl">{signal.title}</h2>
<span className="font-display text-xs font-bold uppercase text-gray-500 dark:text-gray-400 shrink-0">
{formatDate(signal.publishedAt, locale)}
</span>
<div className="shrink-0 text-right">
{signal.isHot ? (
<span className="inline-flex border-2 border-black bg-red-300 px-1.5 py-0.5 font-display text-[10px] font-bold uppercase text-black">
{translations.hotBadge}
</span>
) : null}
<div className="mt-1 font-display text-xs font-bold uppercase text-gray-500 dark:text-gray-400">
{formatDate(signal.publishedAt, locale)}
</div>
</div>
</div>
<p className="mt-1.5 line-clamp-3 text-sm leading-relaxed text-gray-700 dark:text-gray-300">
@@ -442,29 +542,23 @@ export function SignalFeedClient({ locale, translations }: SignalFeedClientProps
>
<p className="mb-1 font-display text-[10px] font-bold uppercase">{section.title}</p>
<div className="space-y-1">
{section.items.slice(0, 2).map((item) => (
{section.items.map((item) => (
<p key={item} className="line-clamp-2 text-xs leading-relaxed text-gray-800 dark:text-gray-200">
{item}
</p>
))}
</div>
{section.items.length > 2 ? (
<p className="mt-1 font-display text-[10px] font-bold uppercase text-gray-600 dark:text-gray-400">
+{section.items.length - 2}
</p>
) : null}
</div>
))}
</div>
<div className="mt-2 border-t-2 border-black pt-2 dark:border-gray-600">
<div className="flex min-h-8 flex-wrap items-center justify-end gap-1.5 pl-28">
{visibleTags.map((tag) => (
{signal.tags.map((tag) => (
<span key={tag} className="neo-tag">
{tag}
</span>
))}
{hiddenTagCount > 0 ? <span className="neo-tag">+{hiddenTagCount}</span> : null}
<Link
href={signal.sourceUrl}
target="_blank"
+86
View File
@@ -0,0 +1,86 @@
import type { SignalSource } from '@/lib/validations'
import { Prisma } from '@prisma/client'
interface HotnessInput {
source: SignalSource
engagement: number
publishedAt: Date | string
}
interface HotnessResult {
hotScore: number
isHot: boolean
}
interface HotConfig {
engagementBaseline: number
minEngagement: number
threshold: number
}
const HOT_CONFIG: Record<SignalSource, HotConfig> = {
hacker_news: { engagementBaseline: 140, minEngagement: 45, threshold: 64 },
github: { engagementBaseline: 1200, minEngagement: 120, threshold: 66 },
arxiv: { engagementBaseline: 20, minEngagement: 12, threshold: 72 },
hugging_face: { engagementBaseline: 55, minEngagement: 20, threshold: 68 },
reddit: { engagementBaseline: 160, minEngagement: 50, threshold: 65 },
product_hunt: { engagementBaseline: 120, minEngagement: 35, threshold: 65 },
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value))
}
function normalizeEngagement(engagement: number, baseline: number): number {
if (engagement <= 0) {
return 0
}
const capped = Math.min(engagement, baseline * 3)
const score = (Math.log1p(capped) / Math.log1p(baseline)) * 100
return clamp(score, 0, 100)
}
function recencyScore(publishedAt: Date | string): number {
const date = publishedAt instanceof Date ? publishedAt : new Date(publishedAt)
if (Number.isNaN(date.getTime())) {
return 0
}
const ageHours = (Date.now() - date.getTime()) / (1000 * 60 * 60)
if (ageHours <= 0) {
return 100
}
const score = 100 - ageHours * 2.4
return clamp(score, 0, 100)
}
export function computeSignalHotness(input: HotnessInput): HotnessResult {
const safeEngagement = Number.isFinite(input.engagement) && input.engagement > 0 ? Math.floor(input.engagement) : 0
const config = HOT_CONFIG[input.source]
const engagementPart = normalizeEngagement(safeEngagement, config.engagementBaseline)
const recencyPart = recencyScore(input.publishedAt)
const hotScore = Math.round(engagementPart * 0.72 + recencyPart * 0.28)
const isHot = safeEngagement >= config.minEngagement && hotScore >= config.threshold
return {
hotScore: clamp(hotScore, 0, 100),
isHot,
}
}
export function isSignalHotColumnMissingError(error: unknown): boolean {
if (!(error instanceof Prisma.PrismaClientKnownRequestError)) {
return false
}
if (error.code !== 'P2022') {
return false
}
const column = String(error.meta?.column || '')
return column.includes('hotScore') || column.includes('isHot')
}
+2
View File
@@ -197,6 +197,8 @@ export const SignalIngestionInputSchema = z.object({
tags: z.array(z.string().min(1).max(40)).max(20).default([]),
sections: z.array(SignalSectionSchema).min(1).max(6),
engagement: z.coerce.number().int().nonnegative().default(0),
hotScore: z.coerce.number().int().min(0).max(100).optional(),
isHot: z.boolean().optional(),
publishedAt: z.string().datetime({ offset: true }),
isActive: z.boolean().default(true),
});
+3
View File
@@ -183,6 +183,9 @@
"discussionAnglesTitle": "Disagreements",
"sortLatest": "Latest First",
"sortHot": "Most Discussed",
"hotBadge": "HOT",
"totalCountHint": "Total discussions under current filters",
"hotCountHint": "High-heat discussions under current filters",
"sourceAll": "All Sources",
"sourceHackerNews": "Hacker News",
"sourceGithub": "GitHub",
+3
View File
@@ -183,6 +183,9 @@
"discussionAnglesTitle": "争议与分歧",
"sortLatest": "最新优先",
"sortHot": "热度优先",
"hotBadge": "HOT",
"totalCountHint": "当前筛选条件下的讨论总数",
"hotCountHint": "当前筛选条件下的高热度讨论数量",
"sourceAll": "全部来源",
"sourceHackerNews": "Hacker News",
"sourceGithub": "GitHub",