Open scan map preview to free users
This commit is contained in:
@@ -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");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user