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
@@ -2260,6 +2260,58 @@
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) {
display: block;
color: var(--text-muted);
@@ -7,6 +7,7 @@ import { ForecastTable } from "@/components/dashboard/PanelSections";
import { useChart } from "@/hooks/useChart";
import { useDashboardStore } from "@/hooks/useDashboardStore";
import { useI18n } from "@/hooks/useI18n";
import { getOfficialSourceLinks } from "@/lib/dashboard-official-sources";
import { getCityScenery } from "@/lib/dashboard-scenery";
import { CityDetail } from "@/lib/dashboard-types";
import {
@@ -147,6 +148,10 @@ export function DetailPanel() {
() => (detail ? getCityProfileStats(detail, locale) : []),
[detail, locale],
);
const officialLinks = useMemo(
() => (detail ? getOfficialSourceLinks(detail) : []),
[detail],
);
const scenery = getCityScenery(detail?.name);
const blurActiveElement = () => {
if (typeof document === "undefined") return;
@@ -336,6 +341,33 @@ export function DetailPanel() {
</div>
</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">
<h3>{t("detail.todayMiniTrend")}</h3>
{heavyContentReady ? (
+77 -2
View File
@@ -86,6 +86,17 @@ type WeeklyLeaderboardEntry = {
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) {
if (!value) return "-";
const date = new Date(value);
@@ -118,9 +129,11 @@ export function OpsDashboard() {
const [refreshedAt, setRefreshedAt] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [users, setUsers] = useState<OpsUser[]>([]);
const [memberships, setMemberships] = useState<MembershipEntry[]>([]);
const [usersLoading, setUsersLoading] = useState(false);
const [usersError, setUsersError] = useState<string | null>(null);
const [leaderboard, setLeaderboard] = useState<WeeklyLeaderboardEntry[]>([]);
const [membershipsLoading, setMembershipsLoading] = useState(false);
const [grantEmail, setGrantEmail] = useState("");
const [grantPoints, setGrantPoints] = useState("300");
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(() => {
void loadUsers("");
void loadLeaderboard();
}, [loadLeaderboard, loadUsers]);
void loadMemberships();
}, [loadLeaderboard, loadMemberships, loadUsers]);
const rolloutDecision = status?.probability?.rollout?.decision;
@@ -227,12 +255,13 @@ export function OpsDashboard() {
);
await loadUsers(searchQuery);
await loadLeaderboard();
await loadMemberships();
} catch (submitError) {
setGrantError(String(submitError));
} finally {
setGrantLoading(false);
}
}, [grantEmail, grantPoints, loadLeaderboard, loadUsers, searchQuery]);
}, [grantEmail, grantPoints, loadLeaderboard, loadMemberships, loadUsers, searchQuery]);
return (
<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>
</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]">
<Card>
<CardHeader>