Add ops membership list and official source links

This commit is contained in:
2569718930@qq.com
2026-03-21 13:02:07 +08:00
parent b11836a378
commit 75c2baf1d1
8 changed files with 706 additions and 3 deletions
+42
View File
@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
try {
const auth = await buildBackendRequestHeaders(req);
const url = new URL(`${API_BASE}/api/ops/memberships`);
const limit = req.nextUrl.searchParams.get("limit");
if (limit) url.searchParams.set("limit", limit);
const res = await fetch(url.toString(), {
headers: auth.headers,
cache: "no-store",
});
const raw = await res.text();
const response = new NextResponse(raw, {
status: res.status,
headers: {
"Content-Type": res.headers.get("content-type") || "application/json",
"Cache-Control": "no-store",
},
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return NextResponse.json(
{ error: "Failed to fetch memberships", detail: String(error) },
{ status: 500 },
);
}
}
@@ -2260,6 +2260,58 @@
padding: 12px; padding: 12px;
} }
.root :global(.detail-source-list) {
display: grid;
grid-template-columns: 1fr;
gap: 10px;
}
.root :global(.detail-source-note) {
margin: 8px 0 12px;
color: var(--text-muted);
font-size: 12px;
line-height: 1.6;
}
.root :global(.detail-source-link) {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
border-radius: 12px;
border: 1px solid var(--border-subtle);
background: rgba(255, 255, 255, 0.03);
color: var(--text-main);
text-decoration: none;
transition:
background 0.18s ease,
border-color 0.18s ease,
transform 0.18s ease;
}
.root :global(.detail-source-link:hover) {
background: rgba(34, 211, 238, 0.08);
border-color: rgba(34, 211, 238, 0.28);
transform: translateY(-1px);
}
.root :global(.detail-source-kind) {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.12em;
color: var(--accent-cyan);
text-transform: uppercase;
flex-shrink: 0;
}
.root :global(.detail-source-label) {
font-size: 13px;
font-weight: 600;
color: var(--text-main);
text-align: right;
}
.root :global(.detail-label) { .root :global(.detail-label) {
display: block; display: block;
color: var(--text-muted); color: var(--text-muted);
@@ -7,6 +7,7 @@ import { ForecastTable } from "@/components/dashboard/PanelSections";
import { useChart } from "@/hooks/useChart"; import { useChart } from "@/hooks/useChart";
import { useDashboardStore } from "@/hooks/useDashboardStore"; import { useDashboardStore } from "@/hooks/useDashboardStore";
import { useI18n } from "@/hooks/useI18n"; import { useI18n } from "@/hooks/useI18n";
import { getOfficialSourceLinks } from "@/lib/dashboard-official-sources";
import { getCityScenery } from "@/lib/dashboard-scenery"; import { getCityScenery } from "@/lib/dashboard-scenery";
import { CityDetail } from "@/lib/dashboard-types"; import { CityDetail } from "@/lib/dashboard-types";
import { import {
@@ -147,6 +148,10 @@ export function DetailPanel() {
() => (detail ? getCityProfileStats(detail, locale) : []), () => (detail ? getCityProfileStats(detail, locale) : []),
[detail, locale], [detail, locale],
); );
const officialLinks = useMemo(
() => (detail ? getOfficialSourceLinks(detail) : []),
[detail],
);
const scenery = getCityScenery(detail?.name); const scenery = getCityScenery(detail?.name);
const blurActiveElement = () => { const blurActiveElement = () => {
if (typeof document === "undefined") return; if (typeof document === "undefined") return;
@@ -336,6 +341,33 @@ export function DetailPanel() {
</div> </div>
</section> </section>
{officialLinks.length > 0 ? (
<section className="detail-section">
<h3>{locale === "en-US" ? "Official Sources" : "官方参考"}</h3>
<p className="detail-source-note">
{locale === "en-US"
? "AGENCY = national meteorological service, METAR = airport observation, AIRPORT = airport official page."
: "AGENCY = 国家气象机构,METAR = 机场实测报文,AIRPORT = 机场官网页面。"}
</p>
<div className="detail-source-list">
{officialLinks.map((link) => (
<a
key={`${link.label}-${link.href}`}
className="detail-source-link"
href={link.href}
target="_blank"
rel="noreferrer"
>
<span className="detail-source-kind">
{link.kind.toUpperCase()}
</span>
<span className="detail-source-label">{link.label}</span>
</a>
))}
</div>
</section>
) : null}
<section className="detail-section rounded-2xl"> <section className="detail-section rounded-2xl">
<h3>{t("detail.todayMiniTrend")}</h3> <h3>{t("detail.todayMiniTrend")}</h3>
{heavyContentReady ? ( {heavyContentReady ? (
+77 -2
View File
@@ -86,6 +86,17 @@ type WeeklyLeaderboardEntry = {
weekly_points?: number; weekly_points?: number;
}; };
type MembershipEntry = {
user_id: string;
email?: string | null;
telegram_id?: number | null;
username?: string | null;
registered_at?: string | null;
plan_code?: string | null;
starts_at?: string | null;
expires_at?: string | null;
};
function formatDateTime(value?: string | null) { function formatDateTime(value?: string | null) {
if (!value) return "-"; if (!value) return "-";
const date = new Date(value); const date = new Date(value);
@@ -118,9 +129,11 @@ export function OpsDashboard() {
const [refreshedAt, setRefreshedAt] = useState<string | null>(null); const [refreshedAt, setRefreshedAt] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [users, setUsers] = useState<OpsUser[]>([]); const [users, setUsers] = useState<OpsUser[]>([]);
const [memberships, setMemberships] = useState<MembershipEntry[]>([]);
const [usersLoading, setUsersLoading] = useState(false); const [usersLoading, setUsersLoading] = useState(false);
const [usersError, setUsersError] = useState<string | null>(null); const [usersError, setUsersError] = useState<string | null>(null);
const [leaderboard, setLeaderboard] = useState<WeeklyLeaderboardEntry[]>([]); const [leaderboard, setLeaderboard] = useState<WeeklyLeaderboardEntry[]>([]);
const [membershipsLoading, setMembershipsLoading] = useState(false);
const [grantEmail, setGrantEmail] = useState(""); const [grantEmail, setGrantEmail] = useState("");
const [grantPoints, setGrantPoints] = useState("300"); const [grantPoints, setGrantPoints] = useState("300");
const [grantStatus, setGrantStatus] = useState<string | null>(null); const [grantStatus, setGrantStatus] = useState<string | null>(null);
@@ -183,10 +196,25 @@ export function OpsDashboard() {
} }
}, []); }, []);
const loadMemberships = useCallback(async () => {
setMembershipsLoading(true);
try {
const data = await readJson<{ memberships?: MembershipEntry[] }>(
"/api/ops/memberships?limit=200",
);
setMemberships(data.memberships || []);
} catch {
setMemberships([]);
} finally {
setMembershipsLoading(false);
}
}, []);
useEffect(() => { useEffect(() => {
void loadUsers(""); void loadUsers("");
void loadLeaderboard(); void loadLeaderboard();
}, [loadLeaderboard, loadUsers]); void loadMemberships();
}, [loadLeaderboard, loadMemberships, loadUsers]);
const rolloutDecision = status?.probability?.rollout?.decision; const rolloutDecision = status?.probability?.rollout?.decision;
@@ -227,12 +255,13 @@ export function OpsDashboard() {
); );
await loadUsers(searchQuery); await loadUsers(searchQuery);
await loadLeaderboard(); await loadLeaderboard();
await loadMemberships();
} catch (submitError) { } catch (submitError) {
setGrantError(String(submitError)); setGrantError(String(submitError));
} finally { } finally {
setGrantLoading(false); setGrantLoading(false);
} }
}, [grantEmail, grantPoints, loadLeaderboard, loadUsers, searchQuery]); }, [grantEmail, grantPoints, loadLeaderboard, loadMemberships, loadUsers, searchQuery]);
return ( return (
<main className="min-h-screen bg-slate-950 px-4 py-8 text-slate-100 sm:px-6 lg:px-8"> <main className="min-h-screen bg-slate-950 px-4 py-8 text-slate-100 sm:px-6 lg:px-8">
@@ -400,6 +429,52 @@ export function OpsDashboard() {
</Card> </Card>
</section> </section>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-4 text-sm text-slate-300">
<div className="flex items-center justify-between gap-3">
<div className="text-xs text-slate-500">
{memberships.length}
</div>
<Button variant="secondary" onClick={() => void loadMemberships()} disabled={membershipsLoading}>
{membershipsLoading ? "加载中" : "刷新会员"}
</Button>
</div>
<div className="overflow-x-auto rounded-2xl border border-slate-800 bg-slate-950/70">
<table className="min-w-full divide-y divide-slate-800 text-left text-sm">
<thead className="bg-slate-900/80 text-xs uppercase tracking-[0.14em] text-slate-500">
<tr>
<th className="px-4 py-3"></th>
<th className="px-4 py-3"></th>
<th className="px-4 py-3"></th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800">
{memberships.map((item) => (
<tr key={`${item.user_id}-${item.expires_at || ""}`}>
<td className="px-4 py-3">{item.email || "-"}</td>
<td className="px-4 py-3">{item.username || "-"}</td>
<td className="px-4 py-3">{formatDateTime(item.registered_at)}</td>
<td className="px-4 py-3">{formatDateTime(item.expires_at)}</td>
</tr>
))}
{!memberships.length ? (
<tr>
<td className="px-4 py-4 text-slate-500" colSpan={4}>
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</CardContent>
</Card>
<section className="grid gap-4 xl:grid-cols-[1.1fr_0.9fr]"> <section className="grid gap-4 xl:grid-cols-[1.1fr_0.9fr]">
<Card> <Card>
<CardHeader> <CardHeader>
+402
View File
@@ -0,0 +1,402 @@
import type { CityDetail } from "@/lib/dashboard-types";
export type OfficialSourceLink = {
label: string;
href: string;
kind: "agency" | "airport" | "metar";
};
const CITY_SPECIFIC_SOURCES: Record<string, OfficialSourceLink[]> = {
singapore: [
{
label: "MSS 官方天气",
href: "https://www.weather.gov.sg/",
kind: "agency",
},
{
label: "樟宜机场",
href: "https://www.changiairport.com/",
kind: "airport",
},
{
label: "WSSS METAR",
href: "https://aviationweather.gov/data/metar/?id=WSSS&decoded=1&taf=1",
kind: "metar",
},
],
wellington: [
{
label: "MetService",
href: "https://www.metservice.com/",
kind: "agency",
},
{
label: "Wellington Airport",
href: "https://www.wellingtonairport.co.nz/",
kind: "airport",
},
{
label: "NZWN METAR",
href: "https://aviationweather.gov/data/metar/?id=NZWN&decoded=1&taf=1",
kind: "metar",
},
],
"hong kong": [
{
label: "香港天文台",
href: "https://www.hko.gov.hk/en/index.html",
kind: "agency",
},
{
label: "香港国际机场",
href: "https://www.hongkongairport.com/",
kind: "airport",
},
{
label: "VHHH METAR",
href: "https://aviationweather.gov/data/metar/?id=VHHH&decoded=1&taf=1",
kind: "metar",
},
],
taipei: [
{
label: "CWA 中央气象署",
href: "https://www.cwa.gov.tw/V8/E/",
kind: "agency",
},
{
label: "桃园机场",
href: "https://www.taoyuan-airport.com/",
kind: "airport",
},
{
label: "RCSS METAR",
href: "https://aviationweather.gov/data/metar/?id=RCSS&decoded=1&taf=1",
kind: "metar",
},
],
london: [
{
label: "Met Office",
href: "https://www.metoffice.gov.uk/",
kind: "agency",
},
{
label: "EGLC METAR",
href: "https://aviationweather.gov/data/metar/?id=EGLC&decoded=1&taf=1",
kind: "metar",
},
],
"new york": [
{
label: "NWS",
href: "https://www.weather.gov/",
kind: "agency",
},
{
label: "KLGA METAR",
href: "https://aviationweather.gov/data/metar/?id=KLGA&decoded=1&taf=1",
kind: "metar",
},
],
ankara: [
{
label: "MGM",
href: "https://www.mgm.gov.tr/",
kind: "agency",
},
{
label: "LTAC METAR",
href: "https://aviationweather.gov/data/metar/?id=LTAC&decoded=1&taf=1",
kind: "metar",
},
],
paris: [
{
label: "Météo-France",
href: "https://meteofrance.com/",
kind: "agency",
},
{
label: "LFPG METAR",
href: "https://aviationweather.gov/data/metar/?id=LFPG&decoded=1&taf=1",
kind: "metar",
},
],
seoul: [
{
label: "KMA",
href: "https://www.weather.go.kr/",
kind: "agency",
},
{
label: "RKSI METAR",
href: "https://aviationweather.gov/data/metar/?id=RKSI&decoded=1&taf=1",
kind: "metar",
},
],
shanghai: [
{
label: "中国天气网",
href: "https://www.weather.com.cn/",
kind: "agency",
},
{
label: "ZSPD METAR",
href: "https://aviationweather.gov/data/metar/?id=ZSPD&decoded=1&taf=1",
kind: "metar",
},
],
tokyo: [
{
label: "JMA",
href: "https://www.jma.go.jp/jma/indexe.html",
kind: "agency",
},
{
label: "RJTT METAR",
href: "https://aviationweather.gov/data/metar/?id=RJTT&decoded=1&taf=1",
kind: "metar",
},
],
"tel aviv": [
{
label: "IMS",
href: "https://ims.gov.il/en",
kind: "agency",
},
{
label: "LLBG METAR",
href: "https://aviationweather.gov/data/metar/?id=LLBG&decoded=1&taf=1",
kind: "metar",
},
],
toronto: [
{
label: "Environment Canada",
href: "https://weather.gc.ca/",
kind: "agency",
},
{
label: "CYYZ METAR",
href: "https://aviationweather.gov/data/metar/?id=CYYZ&decoded=1&taf=1",
kind: "metar",
},
],
"buenos aires": [
{
label: "SMN",
href: "https://www.smn.gob.ar/",
kind: "agency",
},
{
label: "SAEZ METAR",
href: "https://aviationweather.gov/data/metar/?id=SAEZ&decoded=1&taf=1",
kind: "metar",
},
],
chicago: [
{
label: "NWS Chicago",
href: "https://www.weather.gov/lot/",
kind: "agency",
},
{
label: "KORD METAR",
href: "https://aviationweather.gov/data/metar/?id=KORD&decoded=1&taf=1",
kind: "metar",
},
],
dallas: [
{
label: "NWS Fort Worth",
href: "https://www.weather.gov/fwd/",
kind: "agency",
},
{
label: "KDAL METAR",
href: "https://aviationweather.gov/data/metar/?id=KDAL&decoded=1&taf=1",
kind: "metar",
},
],
miami: [
{
label: "NWS Miami",
href: "https://www.weather.gov/mfl/",
kind: "agency",
},
{
label: "KMIA METAR",
href: "https://aviationweather.gov/data/metar/?id=KMIA&decoded=1&taf=1",
kind: "metar",
},
],
atlanta: [
{
label: "NWS Peachtree City",
href: "https://www.weather.gov/ffc/",
kind: "agency",
},
{
label: "KATL METAR",
href: "https://aviationweather.gov/data/metar/?id=KATL&decoded=1&taf=1",
kind: "metar",
},
],
seattle: [
{
label: "NWS Seattle",
href: "https://www.weather.gov/sew/",
kind: "agency",
},
{
label: "KSEA METAR",
href: "https://aviationweather.gov/data/metar/?id=KSEA&decoded=1&taf=1",
kind: "metar",
},
],
lucknow: [
{
label: "IMD",
href: "https://mausam.imd.gov.in/",
kind: "agency",
},
{
label: "VILK METAR",
href: "https://aviationweather.gov/data/metar/?id=VILK&decoded=1&taf=1",
kind: "metar",
},
],
"sao paulo": [
{
label: "INMET",
href: "https://portal.inmet.gov.br/",
kind: "agency",
},
{
label: "SBGR METAR",
href: "https://aviationweather.gov/data/metar/?id=SBGR&decoded=1&taf=1",
kind: "metar",
},
],
munich: [
{
label: "DWD",
href: "https://www.dwd.de/EN/Home/home_node.html",
kind: "agency",
},
{
label: "EDDM METAR",
href: "https://aviationweather.gov/data/metar/?id=EDDM&decoded=1&taf=1",
kind: "metar",
},
],
milan: [
{
label: "MeteoAM",
href: "https://www.meteoam.it/en/home",
kind: "agency",
},
{
label: "LIMC METAR",
href: "https://aviationweather.gov/data/metar/?id=LIMC&decoded=1&taf=1",
kind: "metar",
},
],
warsaw: [
{
label: "IMGW",
href: "https://meteo.imgw.pl/",
kind: "agency",
},
{
label: "EPWA METAR",
href: "https://aviationweather.gov/data/metar/?id=EPWA&decoded=1&taf=1",
kind: "metar",
},
],
madrid: [
{
label: "AEMET",
href: "https://www.aemet.es/en/portada",
kind: "agency",
},
{
label: "LEMD METAR",
href: "https://aviationweather.gov/data/metar/?id=LEMD&decoded=1&taf=1",
kind: "metar",
},
],
chengdu: [
{
label: "中国天气网",
href: "https://www.weather.com.cn/",
kind: "agency",
},
{
label: "ZUUU METAR",
href: "https://aviationweather.gov/data/metar/?id=ZUUU&decoded=1&taf=1",
kind: "metar",
},
],
chongqing: [
{
label: "中国天气网",
href: "https://www.weather.com.cn/",
kind: "agency",
},
{
label: "ZUCK METAR",
href: "https://aviationweather.gov/data/metar/?id=ZUCK&decoded=1&taf=1",
kind: "metar",
},
],
shenzhen: [
{
label: "中国天气网",
href: "https://www.weather.com.cn/",
kind: "agency",
},
{
label: "ZGSZ METAR",
href: "https://aviationweather.gov/data/metar/?id=ZGSZ&decoded=1&taf=1",
kind: "metar",
},
],
beijing: [
{
label: "中国天气网",
href: "https://www.weather.com.cn/",
kind: "agency",
},
{
label: "ZBAA METAR",
href: "https://aviationweather.gov/data/metar/?id=ZBAA&decoded=1&taf=1",
kind: "metar",
},
],
wuhan: [
{
label: "中国天气网",
href: "https://www.weather.com.cn/",
kind: "agency",
},
{
label: "ZHHH METAR",
href: "https://aviationweather.gov/data/metar/?id=ZHHH&decoded=1&taf=1",
kind: "metar",
},
],
};
export function getOfficialSourceLinks(detail: CityDetail): OfficialSourceLink[] {
const cityKey = String(detail.name || "").trim().toLowerCase();
const links = [...(CITY_SPECIFIC_SOURCES[cityKey] || [])];
const seen = new Set<string>();
return links.filter((link) => {
const key = `${link.label}|${link.href}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
+35 -1
View File
@@ -5,7 +5,7 @@ import threading
import time import time
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Dict, Optional from typing import Dict, List, Optional
import requests import requests
from loguru import logger from loguru import logger
@@ -231,5 +231,39 @@ class SupabaseEntitlementService:
return True return True
return self._query_active_subscription(user_id) return self._query_active_subscription(user_id)
def list_active_subscriptions(self, limit: int = 200) -> List[Dict[str, object]]:
if not self.service_role_key:
logger.warning("SUPABASE_SERVICE_ROLE_KEY is missing")
return []
try:
safe_limit = max(1, min(int(limit or 200), 1000))
now_iso = datetime.now(timezone.utc).isoformat()
params = {
"select": "id,user_id,status,plan_code,starts_at,expires_at",
"status": "eq.active",
"expires_at": f"gt.{now_iso}",
"order": "expires_at.asc",
"limit": str(safe_limit),
}
response = requests.get(
self._subscription_endpoint(),
headers=self._request_headers_for_service_role(),
params=params,
timeout=self.timeout_sec,
)
if response.status_code != 200:
logger.warning(
"supabase active subscriptions query failed status={}",
response.status_code,
)
return []
data = response.json() if response.content else []
if not isinstance(data, list):
return []
return [row for row in data if isinstance(row, dict)]
except Exception as exc:
logger.warning(f"supabase active subscriptions query error: {exc}")
return []
SUPABASE_ENTITLEMENT = SupabaseEntitlementService() SUPABASE_ENTITLEMENT = SupabaseEntitlementService()
+36
View File
@@ -406,6 +406,42 @@ class DBManager:
).fetchall() ).fetchall()
return [dict(row) for row in rows] return [dict(row) for row in rows]
def get_users_by_supabase_user_ids(
self,
supabase_user_ids: List[str],
) -> Dict[str, Dict[str, Any]]:
keys = [
str(item or "").strip().lower()
for item in (supabase_user_ids or [])
if str(item or "").strip()
]
if not keys:
return {}
placeholders = ",".join("?" for _ in keys)
with self._get_connection() as conn:
conn.row_factory = sqlite3.Row
rows = conn.execute(
f"""
SELECT
lower(trim(COALESCE(supabase_user_id, ''))) AS supabase_user_id,
telegram_id,
username,
supabase_email,
created_at,
points,
weekly_points,
message_count
FROM users
WHERE lower(trim(COALESCE(supabase_user_id, ''))) IN ({placeholders})
""",
tuple(keys),
).fetchall()
return {
str(row["supabase_user_id"] or "").strip().lower(): dict(row)
for row in rows
if str(row["supabase_user_id"] or "").strip()
}
def get_points_by_supabase_user_id(self, supabase_user_id: str) -> int: def get_points_by_supabase_user_id(self, supabase_user_id: str) -> int:
user = self.get_user_by_supabase_user_id(supabase_user_id) user = self.get_user_by_supabase_user_id(supabase_user_id)
if not user: if not user:
+30
View File
@@ -248,6 +248,36 @@ async def ops_weekly_leaderboard(request: Request, limit: int = 20):
return {"leaderboard": db.get_weekly_leaderboard(limit=limit)} return {"leaderboard": db.get_weekly_leaderboard(limit=limit)}
@router.get("/api/ops/memberships")
async def ops_memberships(request: Request, limit: int = 200):
_assert_entitlement(request)
_require_ops_admin(request)
from src.database.db_manager import DBManager
db = DBManager()
subscriptions = SUPABASE_ENTITLEMENT.list_active_subscriptions(limit=limit)
user_map = db.get_users_by_supabase_user_ids(
[str(item.get("user_id") or "") for item in subscriptions]
)
rows = []
for item in subscriptions:
user_id = str(item.get("user_id") or "").strip().lower()
local_user = user_map.get(user_id, {})
rows.append(
{
"user_id": user_id,
"email": str(local_user.get("supabase_email") or ""),
"telegram_id": local_user.get("telegram_id"),
"username": local_user.get("username"),
"registered_at": local_user.get("created_at"),
"plan_code": item.get("plan_code"),
"starts_at": item.get("starts_at"),
"expires_at": item.get("expires_at"),
}
)
return {"memberships": rows}
@router.post("/api/ops/users/grant-points") @router.post("/api/ops/users/grant-points")
async def ops_grant_points(request: Request, body: GrantPointsRequest): async def ops_grant_points(request: Request, body: GrantPointsRequest):
_assert_entitlement(request) _assert_entitlement(request)