security: stop tracking env files; admin gate via VITE_SITE_ADMIN_GATE; Redis/cache/docs

Remove InfoGenie-frontend and Go .env from version control; add .env.example templates; ignore .claude local settings. Admin UI reads site gate from env only. Note: rotate secrets if repo history was ever public.

Made-with: Cursor
This commit is contained in:
2026-04-03 16:10:12 +08:00
parent 284b5a5260
commit 6b3fcc1791
25 changed files with 1078 additions and 972 deletions

View File

@@ -1,7 +0,0 @@
# 复制为 .env.local 后可按需覆盖本地开发值
VITE_API_URL=http://127.0.0.1:5002
VITE_AUTH_URL=https://auth.shumengya.top
VITE_AUTH_API_URL=https://auth.api.shumengya.top
VITE_NAME=InfoGenie
VITE_VERSION=1.0.0
VITE_DEBUG=true

View File

@@ -0,0 +1,7 @@
# 复制为 .env.development / .env.production 后填写;勿提交真实密钥
VITE_API_URL=http://127.0.0.1:5002
VITE_AUTH_URL=https://auth.shumengya.top
VITE_AUTH_API_URL=https://auth.api.shumengya.top
VITE_DEBUG=true
# 须与 Go 后端 INFOGENIE_SITE_ADMIN_TOKEN 一致
VITE_SITE_ADMIN_GATE=

View File

@@ -1,7 +0,0 @@
# 生产前端环境变量
VITE_API_URL=https://infogenie.api.shumengya.top
VITE_AUTH_URL=https://auth.shumengya.top
VITE_AUTH_API_URL=https://auth.api.shumengya.top
VITE_NAME=InfoGenie
VITE_VERSION=1.0.0
VITE_DEBUG=false

View File

@@ -4,11 +4,10 @@ import { FiUser, FiMenu, FiX, FiLogOut } from 'react-icons/fi';
import { useUser } from '../contexts/UserContext';
import toast from 'react-hot-toast';
import clsx from 'clsx';
import { ENV_CONFIG } from '../config/env';
const logoSrc = '/assets/logo.png';
const ADMIN_TOKEN = 'shumengya520';
const Header = () => {
const { user, isLoggedIn, logout } = useUser();
const [isMenuOpen, setIsMenuOpen] = useState(false);
@@ -27,7 +26,7 @@ const Header = () => {
if (clickCountRef.current >= 5) {
clickCountRef.current = 0;
if (localStorage.getItem('admin_token') === ADMIN_TOKEN) {
if (ENV_CONFIG.SITE_ADMIN_GATE && localStorage.getItem('admin_token') === ENV_CONFIG.SITE_ADMIN_GATE) {
navigate('/admin');
} else {
setAdminInput('');
@@ -44,8 +43,8 @@ const Header = () => {
}, [navigate]);
const handleAdminSubmit = () => {
if (adminInput === ADMIN_TOKEN) {
localStorage.setItem('admin_token', ADMIN_TOKEN);
if (ENV_CONFIG.SITE_ADMIN_GATE && adminInput === ENV_CONFIG.SITE_ADMIN_GATE) {
localStorage.setItem('admin_token', ENV_CONFIG.SITE_ADMIN_GATE);
setShowAdminModal(false);
toast.success('管理员验证通过');
navigate('/admin');

View File

@@ -1,146 +1,31 @@
import React from 'react';
const accent = '#22c55e';
const soft = 'rgba(74, 222, 128, 0.18)';
const stroke = 1.8;
/** 与 StaticPageConfig SMALL_GAMES 的 id 对齐 */
const GAME_EMOJI = {
'2048': { emoji: '🔢', label: '2048' },
'white-tile': { emoji: '🎹', label: '别踩白方块' },
tetris: { emoji: '🧱', label: '俄罗斯方块' },
snake: { emoji: '🐍', label: '贪吃蛇' },
minesweeper: { emoji: '💣', label: '扫雷' },
'dodge-leaves': { emoji: '🍃', label: '躲树叶' },
plane: { emoji: '✈️', label: '打飞机' },
parkour: { emoji: '🏃', label: '跑酷' },
floppybird: { emoji: '🐦', label: 'Floppy Bird' },
h5cube: { emoji: '🧩', label: '网页魔方' },
sokoban: { emoji: '📦', label: '推箱子' },
};
function SvgBox({ children, ...rest }) {
export function SmallGameIcon({ gameId }) {
const meta = GAME_EMOJI[gameId] || { emoji: '🎮', label: '小游戏' };
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden
{...rest}
<span
role="img"
aria-label={meta.label}
className="inline-flex items-center justify-center text-[1.65rem] sm:text-[1.35rem] leading-none select-none"
>
{children}
</svg>
{meta.emoji}
</span>
);
}
/** 按 SMALL_GAMES 的 id 渲染矢量图标(纯 SVG无 Emoji */
export function SmallGameIcon({ gameId }) {
switch (gameId) {
case '2048':
return (
<SvgBox>
<rect x="3" y="3" width="7" height="7" rx="1.5" fill={accent} opacity="0.9" />
<rect x="14" y="3" width="7" height="7" rx="1.5" fill={accent} opacity="0.6" />
<rect x="3" y="14" width="7" height="7" rx="1.5" fill={accent} opacity="0.75" />
<rect x="14" y="14" width="7" height="7" rx="1.5" fill={accent} opacity="0.45" />
</SvgBox>
);
case 'white-tile':
return (
<SvgBox>
<rect x="4" y="4" width="16" height="16" rx="2" fill={soft} stroke={accent} strokeWidth={stroke} />
<rect x="7" y="7" width="10" height="10" rx="1" fill={accent} opacity="0.3" />
<path d="M12 10v4M10 12h4" stroke={accent} strokeWidth="1.5" strokeLinecap="round" />
</SvgBox>
);
case 'tetris':
return (
<SvgBox>
<rect x="2" y="10" width="6" height="6" rx="1" fill={accent} />
<rect x="9" y="10" width="6" height="6" rx="1" fill={accent} opacity="0.8" />
<rect x="16" y="10" width="6" height="6" rx="1" fill={accent} />
<rect x="9" y="3" width="6" height="6" rx="1" fill={accent} opacity="0.9" />
</SvgBox>
);
case 'snake':
return (
<SvgBox>
<path
d="M4 16c0-4 3-7 7-7h4c3 0 5-1 7-4"
stroke={accent}
strokeWidth="2.5"
strokeLinecap="round"
fill="none"
/>
<circle cx="19" cy="5" r="2.5" fill={accent} />
<circle cx="6" cy="16" r="2" fill={accent} opacity="0.6" />
</SvgBox>
);
case 'minesweeper':
return (
<SvgBox>
<rect x="3" y="3" width="18" height="18" rx="2" fill={soft} stroke={accent} strokeWidth={stroke} />
<path d="M3 9h18M3 15h18M9 3v18M15 3v18" stroke={accent} strokeOpacity="0.3" strokeWidth="1" />
<circle cx="15" cy="15" r="3" fill="#a855f7" />
<path d="M15 12v6M12 15h6" stroke="#fff" strokeWidth="1.2" strokeLinecap="round" />
</SvgBox>
);
case 'dodge-leaves':
return (
<SvgBox>
<path
d="M12 3c-5 3-8 8-8 14h16c0-6-3-11-8-14z"
fill={soft}
stroke={accent}
strokeWidth={stroke}
strokeLinejoin="round"
/>
<path d="M12 8v9M8 14l4-3 4 3" stroke={accent} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none" />
</SvgBox>
);
case 'plane':
return (
<SvgBox>
<path
d="M4 14l8-6 8 6-8-2z"
fill={soft}
stroke={accent}
strokeWidth={stroke}
strokeLinejoin="round"
/>
<path d="M12 8v10M12 18l-3 3M12 18l3 3" stroke={accent} strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
<circle cx="20" cy="10" r="2" fill="#ef4444" />
</SvgBox>
);
case 'parkour':
return (
<SvgBox>
<circle cx="8" cy="6" r="3" fill={soft} stroke={accent} strokeWidth={stroke} />
<path d="M8 9v5l-4 4M8 14h6l4 4" stroke={accent} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none" />
<rect x="16" y="16" width="5" height="4" rx="1" fill={accent} opacity="0.4" />
<circle cx="19" cy="8" r="3" fill="#fbbf24" />
</SvgBox>
);
case 'floppybird':
return (
<SvgBox>
<ellipse cx="12" cy="12" rx="8" ry="5" fill={soft} stroke={accent} strokeWidth={stroke} transform="rotate(-20 12 12)" />
<circle cx="9" cy="11" r="2" fill={accent} />
<path d="M16 10l4-2M16 12l4 2" stroke="#f97316" strokeWidth="1.5" strokeLinecap="round" />
</SvgBox>
);
case 'h5cube':
return (
<SvgBox>
<path d="M4 8l8-4 8 4-8 4z" fill={soft} stroke={accent} strokeWidth={stroke} />
<path d="M4 8v8l8 4v-8M12 20l8-4V8" stroke={accent} strokeWidth={stroke} fill="none" />
<path d="M12 4v8" stroke={accent} strokeWidth="1" opacity="0.5" />
</SvgBox>
);
case 'sokoban':
return (
<SvgBox>
<rect x="4" y="4" width="16" height="16" rx="2" fill={soft} stroke={accent} strokeWidth={stroke} />
<rect x="12" y="5" width="7" height="7" rx="1" fill={accent} opacity="0.5" />
<rect x="5" y="12" width="7" height="7" rx="1" fill={accent} opacity="0.7" />
<circle cx="8.5" cy="15.5" r="2.5" fill="#3b82f6" />
</SvgBox>
);
default:
return (
<SvgBox>
<rect x="4" y="4" width="16" height="16" rx="4" fill={soft} stroke={accent} strokeWidth={stroke} />
<circle cx="12" cy="12" r="4" fill={accent} />
</SvgBox>
);
}
}
export default SmallGameIcon;

View File

@@ -45,6 +45,9 @@ const config = {
LOG_LEVEL: 'debug',
};
/** 与后端 INFOGENIE_SITE_ADMIN_TOKEN 一致;不设则无法进入 /admin */
const siteAdminGate = String(import.meta.env.VITE_SITE_ADMIN_GATE || '').trim();
export const ENV_CONFIG = {
API_URL: config.API_URL,
AUTH_URL: config.AUTH_URL,
@@ -55,6 +58,7 @@ export const ENV_CONFIG = {
APP_VERSION: '2.2.3',
CLIENT_ID: 'infogenie',
CLIENT_NAME: '万象口袋',
SITE_ADMIN_GATE: siteAdminGate,
};
export const getEnvVar = (key, defaultValue = '') => {

View File

@@ -11,8 +11,6 @@ import { ENV_CONFIG } from '../config/env';
import { API_CATEGORIES, TOOLBOX_ITEMS, API_SOURCES } from '../config/Api60sConfig';
import { AI_MODEL_APPS } from '../config/StaticPageConfig';
const ADMIN_TOKEN = 'shumengya520';
const InfoCard = ({ children }) => (
<div className="bg-white/95 rounded-[14px] p-6 shadow-[0_4px_12px_rgba(0,0,0,0.08)] mb-6">{children}</div>
);
@@ -52,6 +50,7 @@ function normalizeHealthPayload(data) {
: data.database === 'connected';
const mysqlStatus = mysql?.status || data.database || (mysqlOk ? 'connected' : 'unknown');
const sixty = data.sixty_api && typeof data.sixty_api === 'object' ? data.sixty_api : null;
const redis = data.redis && typeof data.redis === 'object' ? data.redis : null;
return {
overall: data.status || 'unknown',
ts: data.timestamp,
@@ -59,11 +58,13 @@ function normalizeHealthPayload(data) {
mysqlOk,
mysqlStatus,
sixty,
redis,
};
}
const StatusPill = ({ ok, pending, unknown }) => {
const StatusPill = ({ ok, pending, unknown, muted, mutedText = '未启用' }) => {
if (pending) return <span className="text-xs font-semibold text-gray-400">检测中</span>;
if (muted) return <span className="text-xs font-semibold text-gray-700 bg-gray-100 px-2 py-0.5 rounded-md">{mutedText}</span>;
if (unknown) return <span className="text-xs font-semibold text-amber-800 bg-amber-100 px-2 py-0.5 rounded-md">未上报</span>;
return (
<span
@@ -77,8 +78,15 @@ const StatusPill = ({ ok, pending, unknown }) => {
);
};
function formatPasswordConfigured(v) {
if (v === true) return '已配置';
if (v === false) return '未配置';
return '—';
}
const ADMIN_SECTIONS = [
{ id: 'overview', label: '概览', short: '概览', Icon: FiHome },
{ id: 'monitoring', label: '状态监控', short: '状态', Icon: FiActivity },
{ id: 'ai-upstream', label: 'AI 上游配置', short: 'AI 上游', Icon: FiCpu },
{ id: 'sixty-node', label: '60s 数据源', short: '60s 节点', Icon: FiRadio },
{ id: 'sixty-display', label: '60s 前台展示', short: '60s 展示', Icon: FiGrid },
@@ -114,6 +122,8 @@ const AdminPage = () => {
const [healthRaw, setHealthRaw] = useState(null);
const [healthLoading, setHealthLoading] = useState(false);
const [healthFetchError, setHealthFetchError] = useState(null);
const [diagnostics, setDiagnostics] = useState(null);
const [diagError, setDiagError] = useState(null);
const disabled60sSet = useMemo(() => new Set(disabled60s), [disabled60s]);
const disabledAIModelsSet = useMemo(() => new Set(disabledAIModels), [disabledAIModels]);
@@ -145,18 +155,46 @@ const AdminPage = () => {
setLoading(false);
}, []);
const fetchAggregateHealth = useCallback(async () => {
const fetchMonitoringData = useCallback(async () => {
setHealthLoading(true);
setHealthFetchError(null);
try {
const r = await fetch(`${ENV_CONFIG.API_URL}/api/health`);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const d = await r.json();
setHealthRaw(d);
} catch (e) {
console.error(e);
setDiagError(null);
const token = localStorage.getItem('admin_token') || '';
const healthP = fetch(`${ENV_CONFIG.API_URL}/api/health`).then(async (r) => {
if (!r.ok) throw new Error(`健康检查 HTTP ${r.status}`);
return r.json();
});
const diagP = fetch(`${ENV_CONFIG.API_URL}/api/admin/site/diagnostics`, {
headers: { 'X-Site-Admin-Token': token },
}).then(async (r) => {
if (!r.ok) {
let msg = `HTTP ${r.status}`;
try {
const j = await r.json();
if (j.error === 'forbidden') {
msg = '诊断接口拒绝访问X-Site-Admin-Token 与后端 INFOGENIE_SITE_ADMIN_TOKEN 不一致)';
} else if (j.message) {
msg = j.message;
}
} catch { /* ignore */ }
throw new Error(msg);
}
return r.json();
});
const [hSettled, dSettled] = await Promise.allSettled([healthP, diagP]);
if (hSettled.status === 'fulfilled') {
setHealthRaw(hSettled.value);
setHealthFetchError(null);
} else {
setHealthRaw(null);
setHealthFetchError(e.message || '请求失败');
setHealthFetchError(hSettled.reason?.message || '健康检查失败');
}
if (dSettled.status === 'fulfilled') {
setDiagnostics(dSettled.value);
setDiagError(null);
} else {
setDiagnostics(null);
setDiagError(dSettled.reason?.message || '诊断加载失败');
}
setHealthLoading(false);
}, []);
@@ -164,7 +202,7 @@ const AdminPage = () => {
const healthNorm = useMemo(() => normalizeHealthPayload(healthRaw), [healthRaw]);
const loadAiRuntime = useCallback(async () => {
if (localStorage.getItem('admin_token') !== ADMIN_TOKEN) return;
if (localStorage.getItem('admin_token') !== ENV_CONFIG.SITE_ADMIN_GATE || !ENV_CONFIG.SITE_ADMIN_GATE) return;
setLoadingAi(true);
try {
const token = localStorage.getItem('admin_token') || '';
@@ -186,7 +224,7 @@ const AdminPage = () => {
}, []);
useEffect(() => { fetchSysInfo(); }, [fetchSysInfo]);
useEffect(() => { fetchAggregateHealth(); }, [fetchAggregateHealth]);
useEffect(() => { fetchMonitoringData(); }, [fetchMonitoringData]);
useEffect(() => { load60sConfig(); }, [load60sConfig]);
useEffect(() => { loadAiRuntime(); }, [loadAiRuntime]);
@@ -319,7 +357,7 @@ const AdminPage = () => {
};
const storedToken = localStorage.getItem('admin_token');
if (storedToken !== ADMIN_TOKEN) return <Navigate to="/" replace />;
if (!ENV_CONFIG.SITE_ADMIN_GATE || storedToken !== ENV_CONFIG.SITE_ADMIN_GATE) return <Navigate to="/" replace />;
const handleLogout = () => {
localStorage.removeItem('admin_token');
@@ -440,96 +478,6 @@ const AdminPage = () => {
))}
</div>
<h3 className="text-base text-white mb-3 [text-shadow:0_1px_4px_rgba(0,0,0,0.12)] flex items-center gap-2">
<FiActivity className="opacity-90" />
状态监控
</h3>
<InfoCard>
<div className="flex flex-wrap items-start justify-between gap-3 mb-4">
<p className="text-sm text-gray-500 m-0 leading-snug max-w-xl">
数据源<code className="text-xs bg-gray-100 px-1 rounded">GET /api/health</code>
MySQL Ping 与当前配置的 60s 上游探测请求其公开接口 <code className="text-xs bg-gray-100 px-1 rounded">/v2/ip</code>
{healthNorm?.overall === 'degraded' && (
<span className="text-amber-700 font-semibold"> 当前整体为 degraded</span>
)}
</p>
<button
type="button"
onClick={fetchAggregateHealth}
disabled={healthLoading}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-semibold border border-gray-200 bg-white text-gray-800 hover:bg-gray-50 disabled:opacity-50 shrink-0"
>
<FiRefreshCw size={16} className={healthLoading ? 'animate-spin' : ''} />
{healthLoading ? '检测中…' : '重新检测'}
</button>
</div>
{healthFetchError && (
<p className="text-sm text-red-600 mb-3 m-0">
无法连接健康检查接口{healthFetchError}请确认 Go 后端已启动且已更新到包含聚合健康字段的版本
</p>
)}
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2 py-2 border-b border-gray-100">
<div>
<div className="text-gray-800 font-semibold text-sm">万象后端 APIInfoGenie Go</div>
<div className="text-gray-400 text-xs mt-0.5">{ENV_CONFIG.API_URL}</div>
</div>
<StatusPill
ok={!healthFetchError && (healthNorm?.backendApiOk !== false)}
pending={healthLoading && !healthRaw && !healthFetchError}
unknown={false}
/>
</div>
<div className="flex flex-wrap items-center justify-between gap-2 py-2 border-b border-gray-100">
<div>
<div className="text-gray-800 font-semibold text-sm">MySQL 数据库</div>
<div className="text-gray-500 text-xs mt-0.5">
{healthNorm ? `状态字段:${healthNorm.mysqlStatus}` : '—'}
</div>
</div>
<StatusPill
ok={!!healthNorm?.mysqlOk}
pending={healthLoading && !healthRaw && !healthFetchError}
unknown={!healthFetchError && !healthLoading && !healthRaw}
/>
</div>
<div className="flex flex-wrap items-start justify-between gap-2 py-2">
<div className="min-w-0 flex-1">
<div className="text-gray-800 font-semibold text-sm">60s API 上游当前节点</div>
{healthNorm?.sixty ? (
<div className="text-gray-500 text-xs mt-1 space-y-0.5 break-all">
<div>
{healthNorm.sixty.label || healthNorm.sixty.source_id}
{healthNorm.sixty.base_url ? ` · ${healthNorm.sixty.base_url}` : ''}
</div>
{typeof healthNorm.sixty.latency_ms === 'number' && (
<div>探测耗时 {healthNorm.sixty.latency_ms} ms</div>
)}
{healthNorm.sixty.probe_url && (
<div className="text-gray-400">探测 URL{healthNorm.sixty.probe_url}</div>
)}
{healthNorm.sixty.error ? (
<div className="text-red-600 font-medium">错误{String(healthNorm.sixty.error)}</div>
) : null}
{typeof healthNorm.sixty.http_status === 'number' && healthNorm.sixty.http_status > 0 ? (
<div>HTTP {healthNorm.sixty.http_status}</div>
) : null}
</div>
) : (
<div className="text-gray-500 text-xs mt-1">
{!healthFetchError && healthRaw ? '响应中无 sixty_api 字段,请升级后端。' : '—'}
</div>
)}
</div>
<StatusPill
ok={!!healthNorm?.sixty?.ok}
pending={healthLoading && !healthRaw && !healthFetchError}
unknown={!healthFetchError && !!healthRaw && !healthNorm?.sixty}
/>
</div>
</div>
</InfoCard>
<h3 className="text-base text-white mb-3 [text-shadow:0_1px_4px_rgba(0,0,0,0.12)]">系统信息</h3>
<InfoCard>
<InfoRow label="前端环境" value={ENV_CONFIG.ENV || 'development'} />
@@ -557,6 +505,223 @@ const AdminPage = () => {
</>
)}
{adminSection === 'monitoring' && (
<InfoCard>
<div className="flex flex-wrap items-start justify-between gap-3 mb-4">
<p className="text-sm text-gray-500 m-0 leading-snug max-w-2xl">
<strong>实时探测</strong>
<code className="text-xs bg-gray-100 px-1 rounded">GET /api/health</code>
MySQL Ping60s 上游 <code className="text-xs bg-gray-100 px-1 rounded">/v2/ip</code>
可选 Redis Ping
<strong className="font-semibold"> 配置快照</strong>
<code className="text-xs bg-gray-100 px-1 rounded">GET /api/admin/site/diagnostics</code>
监听地址MySQL/Redis 连接目标库名与用户SMTP 与认证中心 URL <span className="text-gray-600">不含密码明文</span>
{healthNorm?.overall === 'degraded' && (
<span className="text-amber-700 font-semibold"> 当前整体为 degraded</span>
)}
</p>
<button
type="button"
onClick={fetchMonitoringData}
disabled={healthLoading}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-semibold border border-gray-200 bg-white text-gray-800 hover:bg-gray-50 disabled:opacity-50 shrink-0"
>
<FiRefreshCw size={16} className={healthLoading ? 'animate-spin' : ''} />
{healthLoading ? '刷新中…' : '刷新探测与配置'}
</button>
</div>
{healthFetchError && (
<p className="text-sm text-red-600 mb-3 m-0">
健康检查失败{healthFetchError}
</p>
)}
{diagError && (
<p className="text-sm text-amber-900 bg-amber-50 border border-amber-100 rounded-lg px-3 py-2 mb-3 m-0">
配置快照失败{diagError}
</p>
)}
<div className="space-y-4">
<div className="pb-3 border-b border-gray-100">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="text-gray-800 font-semibold text-sm">万象后端 APIGo 进程</div>
<StatusPill
ok={!healthFetchError && (healthNorm?.backendApiOk !== false)}
pending={healthLoading && !healthRaw && !healthFetchError}
unknown={false}
/>
</div>
<div className="text-gray-500 text-xs mt-2 space-y-1 break-all">
<div><span className="text-gray-400">前端当前 API 基址</span>{ENV_CONFIG.API_URL}</div>
{diagnostics?.app && (
<>
<div>
<span className="text-gray-400">进程监听</span>
{diagnostics.app.listen_addr || `0.0.0.0:${diagnostics.app.listen_port || '—'}`}
{diagnostics.app.listen_port ? `(端口 ${diagnostics.app.listen_port}` : ''}
</div>
<div><span className="text-gray-400">APP_ENV</span>{diagnostics.app.env || ''}</div>
</>
)}
</div>
</div>
<div className="pb-3 border-b border-gray-100">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="text-gray-800 font-semibold text-sm">MySQL 数据库</div>
<StatusPill
ok={!!healthNorm?.mysqlOk}
pending={healthLoading && !healthRaw && !healthFetchError}
unknown={!healthFetchError && !healthLoading && !healthRaw}
/>
</div>
<div className="text-gray-500 text-xs mt-2 space-y-1">
{healthNorm && (
<div><span className="text-gray-400">实时状态字段</span>{healthNorm.mysqlStatus}</div>
)}
{diagnostics?.mysql && (
<>
<div className="break-all">
<span className="text-gray-400">连接</span>
{diagnostics.mysql.host || '—'}:{diagnostics.mysql.port || '—'}
</div>
<div><span className="text-gray-400">数据库名</span>{diagnostics.mysql.database || ''}</div>
<div><span className="text-gray-400">用户</span>{diagnostics.mysql.user || ''}</div>
<div>
<span className="text-gray-400">密码环境变量</span>
{formatPasswordConfigured(diagnostics.mysql.password_configured)}
</div>
</>
)}
</div>
</div>
<div className="pb-3 border-b border-gray-100">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="text-gray-800 font-semibold text-sm">60s API 上游</div>
<StatusPill
ok={!!healthNorm?.sixty?.ok}
pending={healthLoading && !healthRaw && !healthFetchError}
unknown={!healthFetchError && !!healthRaw && !healthNorm?.sixty}
/>
</div>
<div className="text-gray-500 text-xs mt-2 space-y-1 break-all">
{diagnostics?.sixty_upstream && (
<div>
<span className="text-gray-400">配置的节点</span>
{diagnostics.sixty_upstream.label || diagnostics.sixty_upstream.source_id}
{diagnostics.sixty_upstream.base_url ? ` · ${diagnostics.sixty_upstream.base_url}` : ''}
</div>
)}
{healthNorm?.sixty ? (
<>
<div>
<span className="text-gray-400">探测目标</span>
{healthNorm.sixty.label || healthNorm.sixty.source_id}
{healthNorm.sixty.base_url ? ` · ${healthNorm.sixty.base_url}` : ''}
</div>
{typeof healthNorm.sixty.latency_ms === 'number' && (
<div>探测耗时 {healthNorm.sixty.latency_ms} ms</div>
)}
{healthNorm.sixty.probe_url && (
<div className="text-gray-400">探测 URL{healthNorm.sixty.probe_url}</div>
)}
{healthNorm.sixty.error ? (
<div className="text-red-600 font-medium">错误{String(healthNorm.sixty.error)}</div>
) : null}
{typeof healthNorm.sixty.http_status === 'number' && healthNorm.sixty.http_status > 0 ? (
<div>HTTP {healthNorm.sixty.http_status}</div>
) : null}
</>
) : (
<div className="mt-1">
{!healthFetchError && healthRaw ? '响应中无 sixty_api 字段,请升级后端。' : '—'}
</div>
)}
</div>
</div>
<div className="pb-3 border-b border-gray-100">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="text-gray-800 font-semibold text-sm">Redis 缓存</div>
{healthLoading && !healthRaw && !healthFetchError ? (
<StatusPill pending />
) : healthNorm?.redis == null && healthRaw && !healthFetchError ? (
<StatusPill unknown />
) : healthNorm?.redis && healthNorm.redis.enabled === false ? (
<StatusPill muted mutedText="未启用" />
) : healthNorm?.redis && healthNorm.redis.enabled === true ? (
<StatusPill ok={healthNorm.redis.ok === true} unknown={false} />
) : (
<StatusPill unknown />
)}
</div>
<div className="text-gray-500 text-xs mt-2 space-y-1 break-all">
{!healthFetchError && healthRaw && healthNorm && healthNorm.redis == null && (
<div>响应中无 redis 字段请升级 Go 后端</div>
)}
{healthNorm?.redis && healthNorm.redis.enabled === false && (
<div>
后端未启用<code className="text-xs bg-gray-100 px-1 rounded">REDIS_ENABLED</code> MySQL
</div>
)}
{healthNorm?.redis && healthNorm.redis.enabled === true && healthNorm.redis.ok === false && healthNorm.redis.error && (
<div className="text-red-600 font-medium">Ping 错误{String(healthNorm.redis.error)}</div>
)}
{healthNorm?.redis && healthNorm.redis.enabled === true && healthNorm.redis.ok === true && (
<div>健康检查 Redis Ping 成功与业务共用同一套连接参数</div>
)}
{diagnostics?.redis && (
<>
<div><span className="text-gray-400">开关</span>{diagnostics.redis.enabled ? '' : ''}</div>
{diagnostics.redis.enabled ? (
<>
<div><span className="text-gray-400">地址host:port</span>{diagnostics.redis.addr || ''}</div>
<div><span className="text-gray-400">逻辑库编号</span>{typeof diagnostics.redis.logical_db === 'number' ? diagnostics.redis.logical_db : ''}</div>
<div><span className="text-gray-400">Key 前缀</span>{diagnostics.redis.key_prefix || ''}</div>
<div>
<span className="text-gray-400">站点缓存 TTL</span>
{typeof diagnostics.redis.site_cache_ttl_sec === 'number'
? `${diagnostics.redis.site_cache_ttl_sec} s`
: '—'}
</div>
<div>
<span className="text-gray-400">密码环境变量</span>
{formatPasswordConfigured(diagnostics.redis.password_configured)}
</div>
</>
) : null}
</>
)}
</div>
</div>
{(diagnostics?.auth_center || diagnostics?.mail) && (
<div className="space-y-3 pt-1">
<div className="text-xs font-semibold text-gray-400 uppercase tracking-wide">其他依赖快照</div>
{diagnostics.auth_center && (
<div className="rounded-lg bg-gray-50 border border-gray-100 px-3 py-2 text-xs text-gray-600 space-y-1">
<div className="font-semibold text-gray-700">萌芽认证中心 API</div>
<div className="break-all">{diagnostics.auth_center.api_url || '—'}</div>
</div>
)}
{diagnostics.mail && (
<div className="rounded-lg bg-gray-50 border border-gray-100 px-3 py-2 text-xs text-gray-600 space-y-1">
<div className="font-semibold text-gray-700">邮件 SMTP</div>
<div>
{diagnostics.mail.host || '—'}:{diagnostics.mail.port ?? '—'}
</div>
<div className="break-all">发件账号{diagnostics.mail.username || '—'}</div>
<div>
密码环境变量{formatPasswordConfigured(diagnostics.mail.password_configured)}
</div>
</div>
)}
</div>
)}
</div>
</InfoCard>
)}
{adminSection === 'ai-upstream' && (
<InfoCard>
<p className="text-sm text-gray-500 leading-[1.55] mb-3">

View File

@@ -10,7 +10,7 @@
| 命令 | 说明 |
|------|------|
| `npm run dev` / `npm start` | 开发服务器(默认端口 3000 |
| `npm run dev` / `npm start` | 开发服务器(Vite默认端口 3000 |
| `npm run build` | 生产构建,输出 `dist/` |
| `npm run preview` | 本地预览 `dist/` 构建结果 |
@@ -23,7 +23,7 @@ infogenie-frontend/
├── index.html ← Vite HTML 入口(根目录,非 public/
├── vite.config.js ← Vite 配置React 插件、Tailwind 插件、路径别名)
├── package.json
├── .env.development ← 开发环境变量
├── .env.development ← 开发环境变量VITE_*
├── .env.production ← 生产环境变量
├── public/ ← 静态资源(不参与构建,直接复制到 dist/
│ ├── assets/logo.png
@@ -34,12 +34,12 @@ infogenie-frontend/
└── src/
├── index.js ← React 根挂载
├── App.js ← 路由、布局、全局 Provider
├── components/ ← 公共组件
├── pages/ ← 页面组件
├── components/ ← 公共组件(含 FullscreenEmbed、SmallGameIcons 等)
├── pages/ ← 页面组件(含 AdminPage、SmallGamePage
├── contexts/ ← 全局 Context
├── hooks/ ← 自定义 Hooks
├── hooks/ ← 自定义 Hooks(如 useFeatureCardClickStats
├── utils/ ← Axios 封装、可见性过滤等
├── config/ ← 环境变量解析、路由/内容配置
├── config/ ← 环境变量解析、Api60sConfig、StaticPageConfig
└── styles/
├── index.css ← Tailwind 入口 + 全局 base 样式 + 自定义动画
└── shared.js ← 设计系统组件Tailwind 函数组件)
@@ -52,7 +52,7 @@ infogenie-frontend/
| 特性 | 说明 |
|------|------|
| `@vitejs/plugin-react` | React Fast Refresh + JSX 转换(含 `.js` 扩展名支持) |
| `@tailwindcss/vite` | Tailwind CSS v4 Vite 插件,零配置文件 |
| `@tailwindcss/vite` | Tailwind CSS v4 Vite 插件 |
| `treat-js-as-jsx` | 内联插件,使 `.js` 文件中的 JSX 正常编译 |
| `resolve.alias['@']` | `@/` 映射到 `src/` |
| `base: '/'` | 部署根路径 |
@@ -62,18 +62,19 @@ infogenie-frontend/
## 环境变量(`src/config/env.js`
变量名使用 Vite 规范的 `VITE_` 前缀(通过 `import.meta.env` 访问):
变量名使用 Vite 规范的 **`VITE_`** 前缀(通过 `import.meta.env` 访问):
| 变量 | 作用 | 开发默认 / 生产默认 |
|------|------|---------------------|
| `VITE_API_URL` | 万象口袋 **Go 后端** 根地址 | dev`http://127.0.0.1:5002`prod`https://infogenie.api.shumengya.top` |
| `VITE_API_URL` | 万象口袋 **Go 后端** 根地址 | dev`http://127.0.0.1:5002`prod`.env.production` |
| `VITE_AUTH_URL` | 认证中心 **页面** 域名 | `https://auth.shumengya.top` |
| `VITE_AUTH_API_URL` | 认证中心 **API** 根地址 | `https://auth.api.shumengya.top` |
| `VITE_DEBUG` | 调试开关 | `'true'` 开启 |
| `VITE_SITE_ADMIN_GATE` | 管理员后台口令,须与后端 `INFOGENIE_SITE_ADMIN_TOKEN` 一致 | 本地写在 `.env.development`,勿提交 |
运行时可通过 `window.ENV_CONFIG` 查看解析结果。
运行时可通过 `window.ENV_CONFIG` 查看解析结果(若已注入)
> **迁移注意** CRA 迁移时原 `REACT_APP_*` 变量已全部重命名为 `VITE_*`。
> **迁移注意** CRA 迁出后,原 `REACT_APP_*` 已统一为 `VITE_*`。
---
@@ -91,13 +92,27 @@ infogenie-frontend/
| `/toolbox` | 工具箱 |
| `/aimodel` | AI 应用(需登录) |
| `/profile` | 个人中心 |
| `/admin` | 管理 |
| `/admin` | **管理员后台**(本地 `admin_token` 校验) |
| `*` | 重定向首页 |
### 关键组件
### 管理员后台(`src/pages/AdminPage.js`
- **`RandomSiteBackground`**:全站随机背景(`https://randbg.api.smyhub.com/api/random?format=json`),毛玻璃模糊,会话级缓存
- **`Header` / `Footer`**:半透明绿 + `backdrop-filter`**移动端 `Navigation`** 底栏为不透明渐变 + 圆角顶
- 浏览器 **`localStorage.admin_token`** 与内置口令一致方可进入(与后端 **`INFOGENIE_SITE_ADMIN_TOKEN`** 用于接口时是同一套站点管理员概念)
- **功能分区**(左侧):概览、**状态监控**、AI 上游、60s 数据源、60s 前台展示、AI 前台展示
- **状态监控**
- 并行请求 **`GET /api/health`**MySQL / 60s 上游 / Redis Ping**`GET /api/admin/site/diagnostics`**进程监听、MySQL/Redis 连接目标、库名/用户、SMTP、认证中心 URL 等;**不含密码**)。
- 请求诊断接口时携带头 **`X-Site-Admin-Token: <admin_token>`**。
- 「刷新探测与配置」会同时刷新上述两个数据源。
### 休闲游戏页(`src/pages/SmallGamePage.js`
- 游戏列表来自 **`src/config/StaticPageConfig.js`** 的 `SMALL_GAMES`
- 卡片图标由 **`src/components/SmallGameIcons.js`** 按 `game.id` 渲染 **Emoji**(如 2048→🔢、俄罗斯方块→🧱 等),嵌入在 **`FeatureCardIcon`** 内。
### 其他关键组件
- **`RandomSiteBackground`**:全站随机背景,毛玻璃模糊,会话级缓存。
- **`Header` / `Footer` / `Navigation`**:导航与布局。
- **`FullscreenEmbed`**:全屏 iframe游戏、工具箱、AI 静态页),支持注入 token、加载超时提示。
- **`ParticleEffect`**:全局点击粒子动画。
@@ -119,17 +134,12 @@ infogenie-frontend/
### 设计系统(`src/styles/shared.js`
所有组件均为 **React 函数组件**,接受 `className` prop可与额外 Tailwind 类合并):
所有组件均为 **React 函数组件**,接受 `className` prop可与 Tailwind 类合并):
- **`PageWrapper`**:页面容器,带 `animate-page-enter` 入场动画
- **`Container`**:最大宽度容器,`$narrow`800px/ 默认1200px
- **`FeatureGrid`**:响应式 5→4→3→2 列网格,带 `animate-fade-up`
- **`CatalogCard`** / **`FeatureCard`**:统一卡片样式,顶条渐变色动态注入(`$c` prop
- **`PageWrapper`**、**`Container`**、**`FeatureGrid`**、**`CatalogCard`**、**`FeatureCard`**
- **`FeatureCardUseCount`**:卡片右上角点击次数角标
- **`ModuleCard`**:首页大模块横向卡片(包装 `react-router-dom` `Link`
- **`accentFromGradient`**工具函数,从 gradient 字符串提取首色
> 动态样式(渐变色、动态 grid 列数等)通过 inline `style` prop 注入Tailwind 负责静态部分。
- **`ModuleCard`**:首页大模块卡片(`Link`
- **`accentFromGradient`**:从 gradient 字符串提取首色
---
@@ -146,9 +156,9 @@ infogenie-frontend/
与 Vite 主站并列的大量**免构建**页面:
- **`public/aimodelapp/<应用名>/`**AI 小应用HTML + `script.js` + 可选 `env.js`)。
- 统一聊天入口:**`public/aimodelapp/shared/ai-chat.js`**`AiChat.complete()`
- 统一聊天入口:**`public/aimodelapp/shared/ai-chat.js`**。
- **优先**请求 **`POST /api/aimodelapp/chat/stream`**SSE失败或无内容时回退 **`POST /api/aimodelapp/chat`**。
- **`public/smallgame/`**、**`public/toolbox/`**:独立小游戏与工具页,由 `FullscreenEmbed` 打开。
- **`public/smallgame/`**、**`public/toolbox/`**:独立页,由 **`FullscreenEmbed`** 打开。
`aimodelapp` 子目录的 **`env.js`** / **`API_CONFIG`** 需指向与主站一致的 Go 后端地址(通常与 `VITE_API_URL` 同源或同网段)。
@@ -156,14 +166,15 @@ infogenie-frontend/
## 与后端协作要点
1. **登录**token 存 `localStorage`AI 与受保护接口依赖 JWT。
2. **AI**:静态页通过 **同源或配置的 API** 调 Go 的 `/api/aimodelapp/*`;流式响应类型**`text/event-stream`**。
3. **站点开关**60s / AI 应用显隐由 `GET /api/site/*-disabled` 等驱动(见后端文档)
4. **卡片统计**四大板块列表页 `CatalogCard` 已接 `feature-card-clicks` 接口。
1. **登录**token 存 `localStorage`AI 与受保护接口依赖 JWT(萌芽认证中心签发)
2. **AI**:静态页通过配置的 API 调 Go 的 `/api/aimodelapp/*`;流式为 **`text/event-stream`**。
3. **站点开关**60s / AI 应用显隐由 `GET /api/site/*-disabled` 等驱动。
4. **卡片统计**:列表页 `CatalogCard` **`feature-card-clicks`** 接口对接;可选 Redis 缓存由后端控制
5. **管理员**:写配置类接口需后端配置 **`INFOGENIE_SITE_ADMIN_TOKEN`**,与前端管理员口令一致。
---
## 其他文档
- **Go 后端 API 与表结构**[`infogenie-backend-go/后端文档.md`](../infogenie-backend-go/后端文档.md)
- **仓库与工具说明**[`../.claude/README.md`](../.claude/README.md)
- **Go 后端 API、Redis、健康与诊断**[`infogenie-backend-go/后端文档.md`](../infogenie-backend-go/后端文档.md)
- **仓库总览与多模块说明**[`../README.md`](../README.md)