Open scan map preview to free users

This commit is contained in:
2569718930@qq.com
2026-04-25 00:08:21 +08:00
parent 6c4867b757
commit 55461c20c2
3 changed files with 129 additions and 126 deletions
@@ -9551,6 +9551,11 @@
cursor: pointer; cursor: pointer;
} }
.root :global(.scan-list-tabs button:disabled) {
opacity: 0.42;
cursor: not-allowed;
}
.root :global(.scan-list-tabs button.active) { .root :global(.scan-list-tabs button.active) {
color: #fff; color: #fff;
} }
@@ -9678,9 +9683,24 @@
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
gap: 16px; gap: 16px;
align-items: center; align-items: center;
width: 100%;
padding: 16px 18px 14px; padding: 16px 18px 14px;
border: 0;
border-bottom: 1px solid rgba(90, 123, 166, 0.1); border-bottom: 1px solid rgba(90, 123, 166, 0.1);
background: rgba(13, 28, 48, 0.62); background: rgba(13, 28, 48, 0.62);
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
.root :global(.scan-opportunity-group-head:hover) {
background: rgba(18, 38, 64, 0.78);
}
.root :global(.scan-opportunity-group-head:focus-visible) {
outline: 2px solid rgba(34, 211, 238, 0.72);
outline-offset: -2px;
} }
.root :global(.scan-opportunity-city) { .root :global(.scan-opportunity-city) {
@@ -170,7 +170,8 @@ function getDistributionPreview(row: ScanOpportunityRow, tempSymbol?: string | n
: normalizeTemperatureLabel(row.target_label, tempSymbol) || "--"; : normalizeTemperatureLabel(row.target_label, tempSymbol) || "--";
preview.push({ preview.push({
label: targetLabel, label: targetLabel,
model_probability: row.model_event_probability, model_probability:
row.raw_model_event_probability ?? row.model_event_probability,
market_probability: row.market_event_probability, market_probability: row.market_event_probability,
highlighted: true, highlighted: true,
}); });
@@ -331,7 +332,14 @@ export const OpportunityTable = React.memo(function OpportunityTable({
<div className="scan-table-body scan-opportunity-groups"> <div className="scan-table-body scan-opportunity-groups">
{groups.map((group) => ( {groups.map((group) => (
<section key={group.key} className="scan-opportunity-group"> <section key={group.key} className="scan-opportunity-group">
<div className="scan-opportunity-group-head"> <button
type="button"
className="scan-opportunity-group-head"
onClick={() => {
const firstRow = group.rows[0];
if (firstRow) onSelectRow?.(firstRow);
}}
>
<div className="scan-opportunity-city"> <div className="scan-opportunity-city">
<strong>{group.cityName}</strong> <strong>{group.cityName}</strong>
<div className="scan-opportunity-models"> <div className="scan-opportunity-models">
@@ -358,7 +366,7 @@ export const OpportunityTable = React.memo(function OpportunityTable({
</b> </b>
<em>{formatWindowMinutes(group.remainingMinutes, locale)}</em> <em>{formatWindowMinutes(group.remainingMinutes, locale)}</em>
</div> </div>
</div> </button>
<div className="scan-opportunity-items"> <div className="scan-opportunity-items">
{group.rows.map((row, rowIndex) => { {group.rows.map((row, rowIndex) => {
@@ -366,16 +374,12 @@ export const OpportunityTable = React.memo(function OpportunityTable({
const selected = selectedRowId === row.id; const selected = selectedRowId === row.id;
const side = String(row.side || "").toLowerCase(); const side = String(row.side || "").toLowerCase();
const modelProbability = const modelProbability =
row.model_probability != null row.raw_model_event_probability != null
? row.model_probability * 100 ? row.raw_model_event_probability * 100
: row.model_event_probability != null : row.model_event_probability != null
? row.model_event_probability * 100 ? row.model_event_probability * 100
: null; : null;
const modelLabel = row.cluster_adjusted const modelLabel = "EMOS";
? isEn
? "Model"
: "模型"
: "EMOS";
const priceLabel = side === "no" ? "NO" : isEn ? "Market" : "市场"; const priceLabel = side === "no" ? "NO" : isEn ? "Market" : "市场";
const edgePositive = Number(row.edge_percent || 0) >= 0; const edgePositive = Number(row.edge_percent || 0) >= 0;
const aiMeta = getAiMeta(row, locale); const aiMeta = getAiMeta(row, locale);
@@ -226,27 +226,11 @@ function CalendarView({
); );
} }
function OverviewMapView({ locale }: { locale: string }) {
const store = useDashboardStore();
return (
<div className="scan-map-view">
<div className="scan-map-shell">
<MapCanvas />
</div>
<div className="scan-map-caption">
{locale === "en-US"
? `Monitoring ${store.cities.length} cities on the original map canvas.`
: `正在用原地图画布监控 ${store.cities.length} 个城市。`}
</div>
</div>
);
}
function ScanTerminalScreen() { function ScanTerminalScreen() {
const store = useDashboardStore(); const store = useDashboardStore();
const { locale, toggleLocale } = useI18n(); const { locale, toggleLocale } = useI18n();
const isEn = locale === "en-US"; const isEn = locale === "en-US";
const isPro = store.proAccess.subscriptionActive;
const accountHref = store.proAccess.authenticated const accountHref = store.proAccess.authenticated
? "/account" ? "/account"
: "/auth/login?next=%2Faccount"; : "/auth/login?next=%2Faccount";
@@ -368,7 +352,7 @@ function ScanTerminalScreen() {
]); ]);
const fetchTerminal = async (filters: FilterState, force = false) => { const fetchTerminal = async (filters: FilterState, force = false) => {
if (!store.proAccess.subscriptionActive) return; if (!isPro) return;
setLoading(true); setLoading(true);
setAiError(null); setAiError(null);
try { try {
@@ -419,17 +403,23 @@ function ScanTerminalScreen() {
}; };
useEffect(() => { useEffect(() => {
if (!store.proAccess.subscriptionActive) return; if (!isPro) return;
void fetchTerminal(DEFAULT_FILTERS, false); void fetchTerminal(DEFAULT_FILTERS, false);
}, [store.proAccess.subscriptionActive]); }, [isPro]);
useEffect(() => { useEffect(() => {
if (!store.proAccess.subscriptionActive) return; if (!isPro) return;
const intervalId = window.setInterval(() => { const intervalId = window.setInterval(() => {
void fetchTerminal(activeFilters, false); void fetchTerminal(activeFilters, false);
}, SCAN_AUTO_REFRESH_MS); }, SCAN_AUTO_REFRESH_MS);
return () => window.clearInterval(intervalId); return () => window.clearInterval(intervalId);
}, [activeFilters, store.proAccess.subscriptionActive]); }, [activeFilters, isPro]);
useEffect(() => {
if (!isPro && activeView !== "map") {
setActiveView("map");
}
}, [activeView, isPro]);
useEffect(() => { useEffect(() => {
setUserLocalTime(formatUserLocalTime()); setUserLocalTime(formatUserLocalTime());
@@ -510,10 +500,21 @@ function ScanTerminalScreen() {
selectionMode="select" selectionMode="select"
/> />
</div> </div>
<div className="scan-map-caption"> </div>
{locale === "en-US" );
? `Monitoring ${store.cities.length} cities on the original map canvas.` }
: `正在用原地图画布监控 ${store.cities.length} 个城市。`} if (!isPro) {
return (
<div className="scan-table-shell empty">
<div className="scan-empty-state">
<div className="scan-empty-title">
{isEn ? "Scan is available on Pro" : "扫描功能需 Pro 权限"}
</div>
<div className="scan-empty-copy">
{isEn
? "Distribution view and city briefing remain available."
: "分布视图和右侧城市简报仍可查看。"}
</div>
</div> </div>
</div> </div>
); );
@@ -560,53 +561,6 @@ function ScanTerminalScreen() {
); );
} }
if (!store.proAccess.subscriptionActive) {
return (
<div className={clsx(styles.root, detailChromeStyles.root, modalChromeStyles.root)}>
<div className={clsx("scan-terminal", themeMode === "light" && "light")}>
<main className="scan-data-grid">
<div className="scan-topbar">
<div className="scan-topbar-title">
<strong>{isEn ? "Market Scan Terminal" : "市场扫描台"}</strong>
<span>{isEn ? "Pro access required" : "需要 Pro 权限"}</span>
</div>
<div className="scan-topbar-actions">
<button
type="button"
className="scan-locale-switch"
aria-label={isEn ? "Switch to Chinese" : "切换到英文"}
title={isEn ? "Switch to Chinese" : "切换到英文"}
onClick={toggleLocale}
>
<span className={clsx(locale === "zh-CN" && "active")}></span>
<span className={clsx(locale === "en-US" && "active")}>EN</span>
</button>
<Link href={accountHref} className="scan-primary-button">
<UserRound size={14} />
{store.proAccess.authenticated
? isEn ? "Upgrade Pro" : "升级 Pro"
: isEn ? "Sign in" : "登录"}
</Link>
</div>
</div>
<div className="scan-table-shell empty">
<div className="scan-empty-state">
<div className="scan-empty-title">
{isEn ? "Market scan is a Pro feature" : "市场扫描是 Pro 功能"}
</div>
<div className="scan-empty-copy">
{isEn
? "Free accounts do not trigger rule scans or V4-Flash scans."
: "免费用户不会触发规则扫描,也不会消耗 V4-Flash 扫描资源。"}
</div>
</div>
</div>
</main>
</div>
</div>
);
}
return ( return (
<div className={clsx(styles.root, detailChromeStyles.root, modalChromeStyles.root)}> <div className={clsx(styles.root, detailChromeStyles.root, modalChromeStyles.root)}>
<div className={clsx("scan-terminal", themeMode === "light" && "light")}> <div className={clsx("scan-terminal", themeMode === "light" && "light")}>
@@ -624,8 +578,12 @@ function ScanTerminalScreen() {
? "Showing the last successful snapshot" ? "Showing the last successful snapshot"
: "当前显示上次成功快照" : "当前显示上次成功快照"
: isEn : isEn
? "Read-only market scan with peak-first main signal" ? isPro
: "只读市场扫描,主信号按 EMOS 主峰优先"} ? "Read-only market scan with peak-first main signal"
: "Free preview: distribution view and city briefing"
: isPro
? "只读市场扫描,主信号按 EMOS 主峰优先"
: "免费预览:分布视图和城市简报可查看"}
</span> </span>
</div> </div>
<div className="scan-topbar-actions"> <div className="scan-topbar-actions">
@@ -642,37 +600,48 @@ function ScanTerminalScreen() {
<span className="scan-topbar-time"> <span className="scan-topbar-time">
{userLocalTime} {userLocalTime}
</span> </span>
<button {isPro ? (
type="button" <>
className="scan-primary-button" <button
onClick={() => void fetchTerminal(activeFilters, true)} type="button"
disabled={loading} className="scan-primary-button"
> onClick={() => void fetchTerminal(activeFilters, true)}
<Search size={14} /> disabled={loading}
{loading >
? isEn <Search size={14} />
? "Scanning..." {loading
: "扫描中..." ? isEn
: isEn ? "Scanning..."
? "Start Scan" : "扫描中..."
: "开始扫描"} : isEn
</button> ? "Start Scan"
<button : "开始扫描"}
type="button" </button>
className="scan-ai-button" <button
onClick={() => void runAiReview()} type="button"
disabled={aiLoading || loading || !terminalData?.rows.length} className="scan-ai-button"
title={isEn ? "Run V4-Flash deep scan" : "运行 V4-Flash 深度扫描"} onClick={() => void runAiReview()}
> disabled={aiLoading || loading || !terminalData?.rows.length}
<Sparkles size={14} className={aiLoading ? "spin" : undefined} /> title={isEn ? "Run V4-Flash deep scan" : "运行 V4-Flash 深度扫描"}
{aiLoading >
? isEn <Sparkles size={14} className={aiLoading ? "spin" : undefined} />
? "V4..." {aiLoading
: "V4 扫描中" ? isEn
: isEn ? "V4..."
? "AI Deep Scan" : "V4 扫描中"
: "AI 深度扫描"} : isEn
</button> ? "AI Deep Scan"
: "AI 深度扫描"}
</button>
</>
) : (
<Link href={accountHref} className="scan-primary-button">
<UserRound size={14} />
{store.proAccess.authenticated
? isEn ? "Upgrade Pro" : "升级 Pro"
: isEn ? "Sign in" : "登录"}
</Link>
)}
<button <button
type="button" type="button"
className="scan-theme-button" className="scan-theme-button"
@@ -682,10 +651,12 @@ function ScanTerminalScreen() {
> >
{themeMode === "light" ? <Moon size={15} /> : <Sun size={15} />} {themeMode === "light" ? <Moon size={15} /> : <Sun size={15} />}
</button> </button>
<button type="button" className="scan-ghost-button" onClick={() => void fetchTerminal(activeFilters, true)}> {isPro ? (
<RefreshCw size={14} className={loading ? "spin" : undefined} /> <button type="button" className="scan-ghost-button" onClick={() => void fetchTerminal(activeFilters, true)}>
{isEn ? "Refresh" : "刷新"} <RefreshCw size={14} className={loading ? "spin" : undefined} />
</button> {isEn ? "Refresh" : "刷新"}
</button>
) : null}
<Link <Link
href={accountHref} href={accountHref}
className="scan-account-button" className="scan-account-button"
@@ -697,12 +668,14 @@ function ScanTerminalScreen() {
</div> </div>
</div> </div>
<ScanKPIBar {isPro ? (
response={terminalData} <ScanKPIBar
rows={timeSortedRows} response={terminalData}
totalCities={store.cities.length} rows={timeSortedRows}
loading={loading} totalCities={store.cities.length}
/> loading={loading}
/>
) : null}
<section className="scan-list-section"> <section className="scan-list-section">
<div className="scan-list-header"> <div className="scan-list-header">
@@ -719,7 +692,10 @@ function ScanTerminalScreen() {
<button <button
type="button" type="button"
className={resolvedView === "list" ? "active" : ""} className={resolvedView === "list" ? "active" : ""}
disabled={!isPro}
title={!isPro ? (isEn ? "Pro scan required" : "扫描需 Pro") : undefined}
onClick={() => { onClick={() => {
if (!isPro) return;
setActiveView("list"); setActiveView("list");
}} }}
> >
@@ -728,7 +704,10 @@ function ScanTerminalScreen() {
<button <button
type="button" type="button"
className={resolvedView === "calendar" ? "active" : ""} className={resolvedView === "calendar" ? "active" : ""}
disabled={!isPro}
title={!isPro ? (isEn ? "Pro scan required" : "扫描需 Pro") : undefined}
onClick={() => { onClick={() => {
if (!isPro) return;
setActiveView("calendar"); setActiveView("calendar");
}} }}
> >