feat: 更新项目代码

This commit is contained in:
2026-04-01 22:03:57 +08:00
parent 1c81d4e6ea
commit 284b5a5260
53 changed files with 6240 additions and 22665 deletions

View File

@@ -1,7 +1,7 @@
# 复制为 .env.local 后可按需覆盖本地开发值
REACT_APP_API_URL=http://127.0.0.1:5002
REACT_APP_AUTH_URL=https://auth.shumengya.top
REACT_APP_AUTH_API_URL=https://auth.api.shumengya.top
REACT_APP_NAME=InfoGenie
REACT_APP_VERSION=1.0.0
REACT_APP_DEBUG=true
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

@@ -1,7 +1,7 @@
# 生产前端环境变量
REACT_APP_API_URL=https://infogenie.api.shumengya.top
REACT_APP_AUTH_URL=https://auth.shumengya.top
REACT_APP_AUTH_API_URL=https://auth.api.shumengya.top
REACT_APP_NAME=InfoGenie
REACT_APP_VERSION=1.0.0
REACT_APP_DEBUG=false
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

@@ -15,7 +15,7 @@
<meta name="apple-mobile-web-app-title" content="万象口袋" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="msapplication-TileColor" content="#81c784" />
<meta name="msapplication-TileImage" content="%PUBLIC_URL%/icons/icon-192.png" />
<meta name="msapplication-TileImage" content="/icons/icon-192.png" />
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website" />
@@ -28,14 +28,14 @@
<meta property="twitter:description" content="🎨一个跨平台的多功能聚合软件应用" />
<!-- Favicon -->
<link rel="icon" type="image/png" sizes="32x32" href="%PUBLIC_URL%/icons/favicon-32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="%PUBLIC_URL%/icons/favicon-16.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/icons/favicon-16.png" />
<!-- Apple Touch Icon -->
<link rel="apple-touch-icon" sizes="180x180" href="%PUBLIC_URL%/icons/apple-touch-icon.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/icons/apple-touch-icon.png" />
<!-- Manifest -->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<link rel="manifest" href="/manifest.json" />
<!-- Preload fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
@@ -183,7 +183,7 @@
<!-- PWA 启动动画 -->
<div id="splash">
<div class="splash-logo-wrap">
<img class="splash-logo" src="%PUBLIC_URL%/assets/logo.png" alt="万象口袋" />
<img class="splash-logo" src="/assets/logo.png" alt="万象口袋" />
<div class="splash-ring"></div>
<div class="splash-ring"></div>
<div class="splash-ring"></div>
@@ -199,6 +199,8 @@
<div id="root"></div>
<script type="module" src="/src/index.js"></script>
<script>
window.addEventListener('load', function() {
setTimeout(function() {

File diff suppressed because it is too large Load Diff

View File

@@ -4,41 +4,36 @@
"description": "万象口袋 - 前端React应用",
"keywords": [
"react",
"api",
"vite",
"tailwindcss",
"mobile-first",
"responsive"
],
"author": "万象口袋",
"license": "MIT",
"private": true,
"homepage": "/",
"type": "module",
"dependencies": {
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^14.5.2",
"axios": "^1.5.0",
"clsx": "^2.1.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hot-toast": "^2.4.1",
"react-icons": "^4.11.0",
"react-router-dom": "^6.15.0",
"react-scripts": "5.0.1",
"react-transition-group": "^4.4.5",
"styled-components": "^6.0.7",
"web-vitals": "^2.1.4"
"react-transition-group": "^4.4.5"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@vitejs/plugin-react": "^4.3.4",
"tailwindcss": "^4.0.0",
"vite": "^6.0.0"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject",
"dev": "react-scripts start"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
"dev": "vite",
"start": "vite",
"build": "vite build",
"preview": "vite preview"
},
"browserslist": {
"production": [

View File

@@ -1,27 +1,127 @@
/**
* 统一调用后端 /api/aimodelapp/chat(提示词由前端 ai-prompts.js 组装)
* 统一调用后端 /api/aimodelapp/chat,优先流式 /api/aimodelapp/chat/stream 实时反馈进度
*/
(function (global) {
global.AiChat = {
/**
* @param {{ role: string, content: string }[]} messages
* @param {{ provider?: string, model?: string }} [opts]
* @returns {Promise<string>} assistant 文本
*/
async complete(messages, opts) {
opts = opts || {};
const token =
(global.AUTH_CONFIG && typeof global.AUTH_CONFIG.getToken === 'function'
? global.AUTH_CONFIG.getToken()
: null) || global.localStorage.getItem('token');
if (!token) {
throw new Error('未登录请先登录后使用AI功能');
const DEFAULT_BAR_ID = '__ai_stream_status_bar';
function getToken() {
return (
(global.AUTH_CONFIG && typeof global.AUTH_CONFIG.getToken === 'function'
? global.AUTH_CONFIG.getToken()
: null) || global.localStorage.getItem('token')
);
}
function getBaseUrl() {
return (global.API_CONFIG && global.API_CONFIG.baseUrl) || '';
}
function ensureDefaultStreamBar() {
if (typeof document === 'undefined') return null;
let el = document.getElementById(DEFAULT_BAR_ID);
if (!el) {
el = document.createElement('div');
el.id = DEFAULT_BAR_ID;
el.setAttribute('role', 'status');
el.style.cssText = [
'position:fixed',
'bottom:0',
'left:0',
'right:0',
'z-index:2147483646',
'padding:10px 14px',
'font-size:13px',
'line-height:1.4',
'background:rgba(17,24,39,.94)',
'color:#e5e7eb',
'border-top:1px solid #374151',
'box-shadow:0 -4px 20px rgba(0,0,0,.15)',
].join(';');
document.body.appendChild(el);
}
return el;
}
function hideDefaultStreamBar() {
if (typeof document === 'undefined') return;
const el = document.getElementById(DEFAULT_BAR_ID);
if (el) {
el.style.display = 'none';
el.textContent = '';
}
}
function showDefaultProgress(text) {
const el = ensureDefaultStreamBar();
if (!el) return;
el.style.display = 'block';
el.textContent = text;
}
async function nonStreamComplete(messages, opts) {
opts = opts || {};
const token = getToken();
if (!token) throw new Error('未登录请先登录后使用AI功能');
const base = getBaseUrl();
const res = await fetch(base + '/api/aimodelapp/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer ' + token,
},
body: JSON.stringify({
messages,
provider: opts.provider || 'deepseek',
model: opts.model || 'deepseek-chat',
}),
});
if (!res.ok) {
let errMsg = res.statusText;
try {
const errData = await res.json();
errMsg = errData.error || errMsg;
} catch (e) {
/* ignore */
}
const base = (global.API_CONFIG && global.API_CONFIG.baseUrl) || '';
const res = await fetch(base + '/api/aimodelapp/chat', {
throw new Error(errMsg || 'API 请求失败');
}
const data = await res.json();
if (data.success && data.content != null) {
return typeof data.content === 'string' ? data.content : String(data.content);
}
throw new Error(data.error || 'API 响应异常');
}
/**
* @param {{ role: string, content: string }[]} messages
* @param {{ provider?: string, model?: string }} [opts]
* @param {{ onStatus?: (s: string) => void, onDelta?: (chunk: string, full: string) => void } | null} [progress]
*/
async function tryStreamThenFallback(messages, opts, progress) {
opts = opts || {};
const token = getToken();
if (!token) throw new Error('未登录请先登录后使用AI功能');
const base = getBaseUrl();
const useExternal = progress && typeof progress === 'object';
const onStatus = useExternal && typeof progress.onStatus === 'function' ? progress.onStatus : showDefaultProgress;
const onDelta = useExternal && typeof progress.onDelta === 'function' ? progress.onDelta : null;
const fireStatus = (s) => {
try {
onStatus(s);
} catch (e) {
/* ignore */
}
};
let res;
try {
fireStatus('正在连接模型…');
res = await fetch(base + '/api/aimodelapp/chat/stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'text/event-stream',
Authorization: 'Bearer ' + token,
},
body: JSON.stringify({
@@ -30,19 +130,86 @@
model: opts.model || 'deepseek-chat',
}),
});
if (!res.ok) {
let errMsg = res.statusText;
} catch (e) {
fireStatus('网络异常,改用普通请求…');
return nonStreamComplete(messages, opts);
}
if (!res.ok || !res.body) {
fireStatus('流式通道不可用,改用普通请求…');
return nonStreamComplete(messages, opts);
}
fireStatus('正在生成…');
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let full = '';
let gotContent = false;
let tick = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (!line.startsWith('data:')) continue;
const payload = line.slice(5).trim();
if (payload === '[DONE]') continue;
let json;
try {
const errData = await res.json();
errMsg = errData.error || errMsg;
} catch (e) { /* ignore */ }
throw new Error(errMsg || 'API 请求失败');
json = JSON.parse(payload);
} catch (err) {
continue;
}
const choice = json.choices && json.choices[0];
const delta = choice && choice.delta && choice.delta.content;
if (typeof delta === 'string' && delta.length) {
gotContent = true;
full += delta;
if (onDelta) {
try {
onDelta(delta, full);
} catch (e) {
/* ignore */
}
}
}
tick += 1;
if (tick % 8 === 0) {
fireStatus('正在生成… 已输出 ' + full.length + ' 字');
}
}
const data = await res.json();
if (data.success && data.content != null) {
return typeof data.content === 'string' ? data.content : String(data.content);
}
if (!full.trim() && !gotContent) {
fireStatus('未收到流式内容,改用普通请求…');
return nonStreamComplete(messages, opts);
}
fireStatus('生成完成');
return full;
}
global.AiChat = {
/**
* @param {{ role: string, content: string }[]} messages
* @param {{ provider?: string, model?: string }} [opts]
* @param {{ onStatus?: (s: string) => void, onDelta?: (chunk: string, full: string) => void } | null} [progress] 可选;不传则使用底部默认状态条
* @returns {Promise<string>} assistant 文本
*/
async complete(messages, opts, progress) {
try {
return await tryStreamThenFallback(messages, opts, progress);
} finally {
hideDefaultStreamBar();
}
throw new Error(data.error || 'API 响应异常');
},
/** 仅非流式(兼容旧逻辑) */
completeNonStream: nonStreamComplete,
};
})(typeof window !== 'undefined' ? window : this);

View File

@@ -0,0 +1,342 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PNG ↔ JPG 图片转换器</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;700&display=swap');
body { font-family: 'Noto Sans SC
', system-ui, sans-serif; }
.drop-zone {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.drop-zone.dragover {
border-color: #3b82f6;
background-color: #eff6ff;
transform: scale(1.02);
}
canvas { display: none; }
</style>
</head>
<body class="bg-gradient-to-br from-blue-50 to-indigo-50 min-h-screen py-12">
<div class="max-w-6xl mx-auto px-6">
<!-- 头部 -->
<div class="text-center mb-10">
<div class="inline-flex items-center gap-3 bg-white shadow-md rounded-3xl px-8 py-4">
<div class="w-12 h-12 bg-gradient-to-br from-blue-500 to-purple-600 rounded-2xl flex items-center justify-center text-white text-3xl">🖼️</div>
<h1 class="text-4xl font-bold text-gray-900 tracking-tight">PNG ↔ JPG 转换器</h1>
</div>
<p class="mt-4 text-gray-600 max-w-md mx-auto">纯浏览器本地转换 • 零上传 • 安全隐私 • 秒级处理</p>
</div>
<div class="bg-white rounded-3xl shadow-2xl overflow-hidden">
<div class="p-8">
<!-- 上传区域 -->
<div id="drop-zone"
class="drop-zone border-4 border-dashed border-gray-300 rounded-3xl p-16 text-center cursor-pointer hover:border-blue-500">
<input type="file" id="file-input" accept="image/png,image/jpeg" class="hidden">
<div class="mx-auto w-20 h-20 bg-blue-100 rounded-full flex items-center justify-center mb-6">
<svg xmlns="http://www.w3.org/2000/svg" class="w-10 h-10 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16v-4m0 0l4 4m-4-4l4-4m12 4v4m0 0l-4-4m4 4l-4 4" />
</svg>
</div>
<p class="text-2xl font-semibold text-gray-700">点击或拖拽图片到此区域</p>
<p class="text-sm text-gray-500 mt-3">支持 PNG / JPG 格式,最大 50MB</p>
</div>
<!-- 主内容区 -->
<div id="main-content" class="hidden mt-12 grid grid-cols-1 lg:grid-cols-12 gap-8">
<!-- 原图 -->
<div class="lg:col-span-5 bg-gray-50 rounded-3xl p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="font-semibold text-lg text-gray-800 flex items-center gap-2">
<span class="w-3 h-3 bg-green-500 rounded-full"></span>
原图
</h3>
<span id="original-info" class="text-xs text-gray-500 font-mono"></span>
</div>
<div class="aspect-video bg-white rounded-2xl overflow-hidden border border-gray-200 flex items-center justify-center">
<img id="original-img" class="max-h-full max-w-full object-contain" alt="原图预览">
</div>
</div>
<!-- 控制面板 -->
<div class="lg:col-span-2 flex flex-col justify-center gap-6">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">转换目标格式</label>
<div class="flex gap-2">
<button id="btn-jpg"
class="flex-1 py-3 px-6 rounded-2xl font-medium transition-all active:scale-95 border-2 border-transparent bg-white shadow-sm hover:shadow-md data-[active=true]:border-blue-600 data-[active=true]:bg-blue-50">
转为 JPG
</button>
<button id="btn-png"
class="flex-1 py-3 px-6 rounded-2xl font-medium transition-all active:scale-95 border-2 border-transparent bg-white shadow-sm hover:shadow-md data-[active=true]:border-blue-600 data-[active=true]:bg-blue-50">
转为 PNG
</button>
</div>
</div>
<!-- JPG 选项 -->
<div id="jpg-options" class="hidden space-y-5">
<div>
<div class="flex justify-between text-sm mb-1.5">
<span class="font-medium text-gray-700">JPG 质量</span>
<span id="quality-value" class="font-mono text-blue-600">92%</span>
</div>
<input type="range" id="quality-slider" min="10" max="100" value="92"
class="w-full accent-blue-600">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1.5">透明背景填充颜色PNG→JPG</label>
<div class="flex gap-3 items-center">
<input type="color" id="bg-color" value="#ffffff"
class="w-12 h-10 rounded-xl border border-gray-300 cursor-pointer">
<div id="bg-color-hex" class="font-mono text-sm text-gray-500">#ffffff</div>
</div>
</div>
</div>
<button onclick="convertImage()"
class="mt-4 w-full bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 text-white font-semibold py-4 rounded-3xl shadow-lg transition-all active:scale-95 flex items-center justify-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7-7 7" />
</svg>
开始转换
</button>
</div>
<!-- 转换后 -->
<div class="lg:col-span-5 bg-gray-50 rounded-3xl p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="font-semibold text-lg text-gray-800 flex items-center gap-2">
<span class="w-3 h-3 bg-purple-500 rounded-full"></span>
转换结果
</h3>
<span id="converted-info" class="text-xs text-gray-500 font-mono"></span>
</div>
<div class="aspect-video bg-white rounded-2xl overflow-hidden border border-gray-200 flex items-center justify-center relative">
<img id="converted-img" class="max-h-full max-w-full object-contain" alt="转换后预览">
<div id="converted-placeholder"
class="absolute inset-0 flex flex-col items-center justify-center text-gray-400">
<div class="w-16 h-16 border-4 border-gray-200 border-t-blue-500 rounded-full animate-spin mb-4"></div>
<p class="text-sm">转换后图片将显示在这里</p>
</div>
</div>
</div>
</div>
</div>
<!-- 下载栏 -->
<div id="download-bar" class="hidden border-t bg-gray-50 px-8 py-5 flex items-center justify-between">
<div class="text-sm text-gray-600">
文件已转换完成 • <span id="file-size" class="font-mono"></span>
</div>
<button onclick="downloadImage()"
class="bg-emerald-600 hover:bg-emerald-700 text-white px-8 py-3 rounded-2xl font-semibold flex items-center gap-3 transition-all active:scale-95">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v-4m0 0l4 4m-4-4l4-4m12 4v4m0 0l-4-4m4 4l-4 4" />
</svg>
下载转换后的图片
</button>
</div>
</div>
<div class="text-center mt-8 text-xs text-gray-400">
完全本地运行 • 无需联网 • 图片不会离开您的浏览器
</div>
</div>
<canvas id="canvas" class="hidden"></canvas>
<script>
// 全局变量
let originalFile = null;
let originalDataURL = null;
let convertedDataURL = null;
let outputFormat = 'image/jpeg';
let currentQuality = 0.92;
let canvas = document.getElementById('canvas');
let ctx = canvas.getContext('2d', { willReadFrequently: true });
// Tailwind 脚本已加载,无需额外初始化
// 拖拽与点击上传
const dropZone = document.getElementById('drop-zone');
const fileInput = document.getElementById('file-input');
dropZone.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) handleFile(e.target.files[0]);
});
// 拖拽事件
['dragover', 'dragenter'].forEach(evt => {
dropZone.addEventListener(evt, (e) => {
e.preventDefault();
dropZone.classList.add('dragover');
});
});
['dragleave', 'dragend'].forEach(evt => {
dropZone.addEventListener(evt, () => dropZone.classList.remove('dragover'));
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('dragover');
if (e.dataTransfer.files.length > 0) {
handleFile(e.dataTransfer.files[0]);
}
});
function handleFile(file) {
if (!file.type.startsWith('image/') || !['image/png', 'image/jpeg'].includes(file.type)) {
alert('❌ 请上传 PNG 或 JPG 格式的图片!');
return;
}
if (file.size > 50 * 1024 * 1024) {
alert('❌ 文件过大!请上传小于 50MB 的图片。');
return;
}
originalFile = file;
const reader = new FileReader();
reader.onload = function(e) {
originalDataURL = e.target.result;
document.getElementById('original-img').src = originalDataURL;
// 显示信息
const img = new Image();
img.onload = () => {
const sizeKB = (file.size / 1024).toFixed(1);
document.getElementById('original-info').textContent =
`${img.width}×${img.height}${sizeKB} KB`;
};
img.src = originalDataURL;
// 显示主界面
document.getElementById('main-content').classList.remove('hidden');
document.getElementById('download-bar').classList.add('hidden');
document.getElementById('converted-img').src = '';
document.getElementById('converted-placeholder').style.display = 'flex';
};
reader.readAsDataURL(file);
}
// 格式切换
const btnJPG = document.getElementById('btn-jpg');
const btnPNG = document.getElementById('btn-png');
const jpgOptions = document.getElementById('jpg-options');
btnJPG.addEventListener('click', () => {
outputFormat = 'image/jpeg';
btnJPG.dataset.active = 'true';
btnPNG.dataset.active = 'false';
jpgOptions.classList.remove('hidden');
});
btnPNG.addEventListener('click', () => {
outputFormat = 'image/png';
btnPNG.dataset.active = 'true';
btnJPG.dataset.active = 'false';
jpgOptions.classList.add('hidden');
});
// 默认选择 JPG
btnJPG.click();
// 质量滑块
const qualitySlider = document.getElementById('quality-slider');
const qualityValue = document.getElementById('quality-value');
qualitySlider.addEventListener('input', () => {
currentQuality = qualitySlider.value / 100;
qualityValue.textContent = qualitySlider.value + '%';
});
// 背景颜色
const bgColorInput = document.getElementById('bg-color');
const bgColorHex = document.getElementById('bg-color-hex');
bgColorInput.addEventListener('input', () => {
bgColorHex.textContent = bgColorInput.value.toUpperCase();
});
// 转换函数
window.convertImage = function() {
if (!originalDataURL) {
alert('请先上传图片!');
return;
}
const loadingPlaceholder = document.getElementById('converted-placeholder');
loadingPlaceholder.style.display = 'none';
const img = new Image();
img.onload = function() {
canvas.width = img.width;
canvas.height = img.height;
// 清空画布
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (outputFormat === 'image/jpeg') {
// PNG→JPG 时填充背景色
ctx.fillStyle = bgColorInput.value;
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
// 绘制图片
ctx.drawImage(img, 0, 0);
// 生成新 DataURL
convertedDataURL = canvas.toDataURL(outputFormat, currentQuality);
// 显示转换结果
document.getElementById('converted-img').src = convertedDataURL;
// 计算新大小
const newSize = Math.round((convertedDataURL.length * 3 / 4) / 1024);
document.getElementById('converted-info').textContent =
`${img.width}×${img.height}${newSize} KB`;
// 显示下载栏
document.getElementById('download-bar').classList.remove('hidden');
document.getElementById('file-size').textContent = `${newSize} KB`;
};
img.src = originalDataURL;
};
// 下载函数
window.downloadImage = function() {
if (!convertedDataURL) return;
const link = document.createElement('a');
link.href = convertedDataURL;
// 生成新文件名
let originalName = originalFile.name;
const ext = outputFormat === 'image/jpeg' ? '.jpg' : '.png';
const baseName = originalName.substring(0, originalName.lastIndexOf('.')) || originalName;
link.download = baseName + ext;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
// 键盘快捷键
document.addEventListener('keydown', (e) => {
if (e.key === '/' && document.getElementById('main-content').classList.contains('hidden')) {
fileInput.click();
}
});
</script>
</body>
</html>

View File

@@ -0,0 +1,728 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>水印工具 · WaterMark</title>
<link href="https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Noto+Sans+SC:wght@300;400;500&display=swap" rel="stylesheet">
<style>
:root {
--bg: #0e0e0f;
--surface: #18181b;
--surface2: #222226;
--border: #2e2e34;
--accent: #f0a500;
--accent2: #e07b00;
--text: #e8e8e8;
--muted: #888;
--danger: #e05555;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: 'Noto Sans SC', sans-serif;
font-weight: 300;
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* ── Header ── */
header {
display: flex;
align-items: center;
gap: 14px;
padding: 18px 28px;
border-bottom: 1px solid var(--border);
background: var(--surface);
}
header svg { flex-shrink: 0; }
.brand {
font-family: 'Bebas Neue', cursive;
font-size: 1.7rem;
letter-spacing: 2px;
color: var(--accent);
line-height: 1;
}
.brand span { color: var(--text); }
.subtitle {
font-size: .7rem;
color: var(--muted);
letter-spacing: 3px;
text-transform: uppercase;
margin-left: 2px;
}
/* ── Layout ── */
main {
flex: 1;
display: grid;
grid-template-columns: 340px 1fr;
gap: 0;
height: calc(100vh - 65px);
}
/* ── Sidebar ── */
aside {
background: var(--surface);
border-right: 1px solid var(--border);
overflow-y: auto;
display: flex;
flex-direction: column;
}
.panel {
padding: 20px;
border-bottom: 1px solid var(--border);
}
.panel-title {
font-size: .65rem;
letter-spacing: 3px;
text-transform: uppercase;
color: var(--accent);
margin-bottom: 14px;
font-weight: 500;
}
/* Upload zone */
.upload-zone {
border: 1.5px dashed var(--border);
border-radius: 10px;
padding: 28px 16px;
text-align: center;
cursor: pointer;
transition: all .25s;
position: relative;
}
.upload-zone:hover, .upload-zone.drag { border-color: var(--accent); background: rgba(240,165,0,.05); }
.upload-zone input { position: absolute; inset: 0; opacity: 0; cursor: pointer; width: 100%; }
.upload-icon { font-size: 2rem; margin-bottom: 8px; }
.upload-zone p { font-size: .82rem; color: var(--muted); line-height: 1.6; }
.upload-zone strong { color: var(--accent); }
/* Controls */
.field { margin-bottom: 14px; }
.field label {
display: block;
font-size: .72rem;
color: var(--muted);
margin-bottom: 6px;
letter-spacing: 1px;
}
.field input[type="text"],
.field input[type="number"],
.field select,
.field textarea {
width: 100%;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 7px;
padding: 9px 12px;
color: var(--text);
font-size: .85rem;
font-family: inherit;
outline: none;
transition: border-color .2s;
}
.field input:focus, .field select:focus, .field textarea:focus { border-color: var(--accent); }
.field textarea { resize: vertical; min-height: 64px; }
.row2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.row3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; }
/* Slider */
.slider-wrap { display: flex; align-items: center; gap: 10px; }
.slider-wrap input[type="range"] {
flex: 1;
-webkit-appearance: none;
height: 4px;
background: var(--border);
border-radius: 4px;
outline: none;
cursor: pointer;
}
.slider-wrap input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 16px; height: 16px;
border-radius: 50%;
background: var(--accent);
cursor: pointer;
}
.slider-val {
min-width: 36px;
text-align: right;
font-size: .8rem;
color: var(--accent);
font-weight: 500;
}
/* Color input */
.color-wrap { display: flex; align-items: center; gap: 10px; }
.color-wrap input[type="color"] {
width: 40px; height: 36px;
border: 1px solid var(--border);
border-radius: 7px;
padding: 2px;
background: var(--surface2);
cursor: pointer;
flex-shrink: 0;
}
.color-wrap input[type="text"] { flex: 1; }
/* Checkbox toggle */
.toggle-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.toggle-label { font-size: .82rem; color: var(--text); }
.toggle {
position: relative;
width: 42px; height: 24px;
flex-shrink: 0;
}
.toggle input { opacity: 0; width: 0; height: 0; }
.toggle-track {
position: absolute; inset: 0;
background: var(--border);
border-radius: 24px;
cursor: pointer;
transition: background .2s;
}
.toggle-track::after {
content: '';
position: absolute;
width: 18px; height: 18px;
background: #fff;
border-radius: 50%;
top: 3px; left: 3px;
transition: transform .2s;
}
.toggle input:checked + .toggle-track { background: var(--accent); }
.toggle input:checked + .toggle-track::after { transform: translateX(18px); }
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 11px 20px;
border-radius: 8px;
font-size: .85rem;
font-family: inherit;
font-weight: 500;
cursor: pointer;
border: none;
transition: all .2s;
width: 100%;
}
.btn-primary {
background: var(--accent);
color: #000;
}
.btn-primary:hover { background: var(--accent2); }
.btn-primary:disabled { opacity: .4; cursor: not-allowed; }
.btn-ghost {
background: transparent;
border: 1px solid var(--border);
color: var(--muted);
}
.btn-ghost:hover { border-color: var(--accent); color: var(--accent); }
.actions { padding: 16px 20px; margin-top: auto; display: flex; flex-direction: column; gap: 8px; }
/* ── Canvas area ── */
.canvas-area {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: var(--bg);
position: relative;
overflow: hidden;
padding: 24px;
}
.canvas-bg {
position: absolute;
inset: 0;
background-image:
linear-gradient(var(--border) 1px, transparent 1px),
linear-gradient(90deg, var(--border) 1px, transparent 1px);
background-size: 32px 32px;
opacity: .35;
pointer-events: none;
}
.empty-state {
text-align: center;
position: relative;
z-index: 1;
}
.empty-state .big-icon {
font-size: 4rem;
margin-bottom: 16px;
opacity: .3;
}
.empty-state p { color: var(--muted); font-size: .9rem; line-height: 1.7; }
#previewWrap {
position: relative;
z-index: 1;
display: none;
box-shadow: 0 20px 60px rgba(0,0,0,.6);
border-radius: 4px;
overflow: hidden;
max-width: 100%;
max-height: calc(100vh - 140px);
}
#preview {
display: block;
max-width: 100%;
max-height: calc(100vh - 140px);
object-fit: contain;
}
/* info bar */
.info-bar {
position: absolute;
bottom: 16px;
left: 50%;
transform: translateX(-50%);
background: rgba(0,0,0,.7);
backdrop-filter: blur(8px);
border: 1px solid var(--border);
border-radius: 20px;
padding: 6px 16px;
font-size: .7rem;
color: var(--muted);
white-space: nowrap;
z-index: 10;
display: none;
}
.info-bar span { color: var(--text); }
/* Mobile */
@media (max-width: 768px) {
main {
grid-template-columns: 1fr;
height: auto;
}
aside {
border-right: none;
border-bottom: 1px solid var(--border);
}
.canvas-area {
min-height: 55vw;
padding: 16px;
}
header { padding: 14px 16px; }
.brand { font-size: 1.4rem; }
}
</style>
</head>
<body>
<header>
<svg width="32" height="32" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="8" fill="#f0a500" opacity=".15"/>
<rect x="4" y="8" width="24" height="16" rx="3" stroke="#f0a500" stroke-width="1.5" fill="none"/>
<path d="M4 13h24" stroke="#f0a500" stroke-width="1.2" stroke-dasharray="3 2"/>
<text x="16" y="23" text-anchor="middle" font-size="7" fill="#f0a500" font-family="serif" font-style="italic">WM</text>
</svg>
<div>
<div class="brand">Water<span>Mark</span></div>
<div class="subtitle">图片文字水印工具</div>
</div>
</header>
<main>
<aside>
<!-- Upload -->
<div class="panel">
<div class="panel-title">01 · 上传图片</div>
<div class="upload-zone" id="dropZone">
<input type="file" id="fileInput" accept="image/*">
<div class="upload-icon">🖼️</div>
<p><strong>点击选择</strong>或拖拽图片到此处<br>支持 JPG · PNG · WEBP · GIF</p>
</div>
</div>
<!-- Text -->
<div class="panel">
<div class="panel-title">02 · 水印文字</div>
<div class="field">
<label>水印内容</label>
<textarea id="wmText" placeholder="输入水印文字…">© 版权所有</textarea>
</div>
<div class="row2">
<div class="field">
<label>字号 (px)</label>
<input type="number" id="wmSize" value="36" min="8" max="300">
</div>
<div class="field">
<label>字体</label>
<select id="wmFont">
<option value="'Noto Sans SC', sans-serif">思源黑体</option>
<option value="serif">宋体</option>
<option value="'Noto Serif SC', serif">思源宋体</option>
<option value="monospace">等宽体</option>
<option value="cursive">手写体</option>
</select>
</div>
</div>
<div class="field">
<label>颜色</label>
<div class="color-wrap">
<input type="color" id="wmColorPicker" value="#ffffff">
<input type="text" id="wmColorHex" value="#ffffff" maxlength="9" placeholder="#ffffff">
</div>
</div>
<div class="field">
<label>透明度</label>
<div class="slider-wrap">
<input type="range" id="wmOpacity" min="1" max="100" value="40">
<div class="slider-val" id="opacityVal">40%</div>
</div>
</div>
</div>
<!-- Position -->
<div class="panel">
<div class="panel-title">03 · 位置 & 旋转</div>
<div class="field">
<label>预设位置</label>
<select id="wmPosition">
<option value="center">居中</option>
<option value="top-left">左上角</option>
<option value="top-right">右上角</option>
<option value="bottom-left">左下角</option>
<option value="bottom-right" selected>右下角</option>
<option value="tile">平铺</option>
</select>
</div>
<div class="row2">
<div class="field">
<label>旋转角度 (°)</label>
<div class="slider-wrap">
<input type="range" id="wmAngle" min="-180" max="180" value="-30">
<div class="slider-val" id="angleVal">-30°</div>
</div>
</div>
</div>
<div class="field">
<label>边距 (px)</label>
<input type="number" id="wmPadding" value="30" min="0" max="500">
</div>
<!-- Tile spacing (only when tile) -->
<div class="field" id="tileField" style="display:none">
<label>平铺间距 (px)</label>
<div class="row2">
<input type="number" id="tileX" value="200" min="20">
<input type="number" id="tileY" value="160" min="20">
</div>
</div>
<div class="field" style="margin-top:10px">
<div class="toggle-row">
<span class="toggle-label">描边效果</span>
<label class="toggle">
<input type="checkbox" id="wmStroke">
<div class="toggle-track"></div>
</label>
</div>
</div>
<div class="field" id="strokeField" style="display:none">
<label>描边颜色</label>
<div class="color-wrap">
<input type="color" id="strokeColorPicker" value="#000000">
<input type="text" id="strokeColorHex" value="#000000" maxlength="9">
</div>
</div>
<div class="field">
<div class="toggle-row">
<span class="toggle-label">阴影效果</span>
<label class="toggle">
<input type="checkbox" id="wmShadow" checked>
<div class="toggle-track"></div>
</label>
</div>
</div>
</div>
<!-- Output -->
<div class="panel">
<div class="panel-title">04 · 输出设置</div>
<div class="row2">
<div class="field">
<label>格式</label>
<select id="outFormat">
<option value="image/jpeg">JPEG</option>
<option value="image/png">PNG</option>
<option value="image/webp">WEBP</option>
</select>
</div>
<div class="field">
<label>质量</label>
<div class="slider-wrap">
<input type="range" id="outQuality" min="10" max="100" value="92">
<div class="slider-val" id="qualityVal">92</div>
</div>
</div>
</div>
</div>
<!-- Actions -->
<div class="actions">
<button class="btn btn-primary" id="applyBtn" disabled>
✦ 生成水印预览
</button>
<button class="btn btn-ghost" id="downloadBtn" style="display:none">
↓ 下载图片
</button>
</div>
</aside>
<!-- Preview -->
<div class="canvas-area" id="canvasArea">
<div class="canvas-bg"></div>
<div class="empty-state" id="emptyState">
<div class="big-icon">🖼️</div>
<p>上传图片后<br>在左侧调整参数<br>即可预览水印效果</p>
</div>
<div id="previewWrap">
<img id="preview" alt="预览">
</div>
<canvas id="canvas" style="display:none"></canvas>
<div class="info-bar" id="infoBar">尺寸:<span id="dimInfo"></span> &nbsp;|&nbsp; 文件:<span id="sizeInfo"></span></div>
</div>
</main>
<script>
const $ = id => document.getElementById(id);
const fileInput = $('fileInput');
const dropZone = $('dropZone');
const applyBtn = $('applyBtn');
const downloadBtn = $('downloadBtn');
const preview = $('preview');
const previewWrap = $('previewWrap');
const emptyState = $('emptyState');
const canvas = $('canvas');
const ctx = canvas.getContext('2d');
const infoBar = $('infoBar');
let originalImage = null;
let currentBlob = null;
// ── Sliders ──────────────────────────────────────
function linkSlider(sliderId, valId, suffix) {
const s = $(sliderId), v = $(valId);
s.addEventListener('input', () => { v.textContent = s.value + suffix; debounceRender(); });
}
linkSlider('wmOpacity', 'opacityVal', '%');
linkSlider('wmAngle', 'angleVal', '°');
linkSlider('outQuality','qualityVal', '');
// ── Color sync ───────────────────────────────────
function syncColor(pickerId, hexId) {
const picker = $(pickerId), hex = $(hexId);
picker.addEventListener('input', () => { hex.value = picker.value; debounceRender(); });
hex.addEventListener('input', () => {
if (/^#[0-9a-fA-F]{6}$/.test(hex.value)) { picker.value = hex.value; debounceRender(); }
});
}
syncColor('wmColorPicker', 'wmColorHex');
syncColor('strokeColorPicker', 'strokeColorHex');
// ── Toggle stroke ─────────────────────────────────
$('wmStroke').addEventListener('change', e => {
$('strokeField').style.display = e.target.checked ? 'block' : 'none';
debounceRender();
});
// ── Toggle tile ───────────────────────────────────
$('wmPosition').addEventListener('change', e => {
$('tileField').style.display = e.target.value === 'tile' ? 'block' : 'none';
debounceRender();
});
// ── Auto render on any change ─────────────────────
let renderTimer;
function debounceRender() {
if (!originalImage) return;
clearTimeout(renderTimer);
renderTimer = setTimeout(render, 200);
}
document.querySelectorAll('input,select,textarea').forEach(el => {
if (el.type !== 'file') el.addEventListener('input', debounceRender);
});
// ── File handling ─────────────────────────────────
function loadFile(file) {
if (!file || !file.type.startsWith('image/')) return;
const reader = new FileReader();
reader.onload = e => {
const img = new Image();
img.onload = () => {
originalImage = img;
applyBtn.disabled = false;
render();
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
}
fileInput.addEventListener('change', e => loadFile(e.target.files[0]));
dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag'); });
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag'));
dropZone.addEventListener('drop', e => {
e.preventDefault();
dropZone.classList.remove('drag');
loadFile(e.dataTransfer.files[0]);
});
// ── Paste image ───────────────────────────────────
document.addEventListener('paste', e => {
const item = [...e.clipboardData.items].find(i => i.type.startsWith('image/'));
if (item) loadFile(item.getAsFile());
});
// ── Render ────────────────────────────────────────
applyBtn.addEventListener('click', render);
function hexToRgba(hex, alpha) {
const r = parseInt(hex.slice(1,3),16);
const g = parseInt(hex.slice(3,5),16);
const b = parseInt(hex.slice(5,7),16);
return `rgba(${r},${g},${b},${alpha})`;
}
function render() {
if (!originalImage) return;
applyBtn.disabled = true;
applyBtn.textContent = '⟳ 渲染中…';
requestAnimationFrame(() => {
canvas.width = originalImage.naturalWidth;
canvas.height = originalImage.naturalHeight;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(originalImage, 0, 0);
const text = $('wmText').value || '水印';
const size = parseInt($('wmSize').value) || 36;
const font = $('wmFont').value;
const color = $('wmColorHex').value;
const opacity = parseInt($('wmOpacity').value) / 100;
const angle = parseInt($('wmAngle').value) * Math.PI / 180;
const pos = $('wmPosition').value;
const pad = parseInt($('wmPadding').value) || 30;
const stroke = $('wmStroke').checked;
const strokeC = $('strokeColorHex').value;
const shadow = $('wmShadow').checked;
ctx.font = `${size}px ${font}`;
ctx.globalAlpha = opacity;
const tw = ctx.measureText(text).width;
const th = size;
if (shadow) {
ctx.shadowColor = 'rgba(0,0,0,0.5)';
ctx.shadowBlur = size * 0.2;
ctx.shadowOffsetX = size * 0.05;
ctx.shadowOffsetY = size * 0.05;
} else {
ctx.shadowColor = 'transparent';
ctx.shadowBlur = 0;
}
function drawText(x, y) {
ctx.save();
ctx.translate(x, y);
ctx.rotate(angle);
ctx.fillStyle = hexToRgba(color, opacity);
ctx.globalAlpha = 1;
if (shadow) {
ctx.shadowColor = 'rgba(0,0,0,0.5)';
ctx.shadowBlur = size * 0.2;
ctx.shadowOffsetX = size * 0.05;
ctx.shadowOffsetY = size * 0.05;
}
if (stroke) {
ctx.strokeStyle = hexToRgba(strokeC, opacity);
ctx.lineWidth = size * 0.06;
ctx.lineJoin = 'round';
ctx.strokeText(text, 0, 0);
}
ctx.fillStyle = hexToRgba(color, opacity);
ctx.fillText(text, 0, 0);
ctx.restore();
}
ctx.textBaseline = 'middle';
const W = canvas.width, H = canvas.height;
if (pos === 'tile') {
const gx = parseInt($('tileX').value) || 200;
const gy = parseInt($('tileY').value) || 160;
for (let y = gy/2; y < H + gy; y += gy) {
for (let x = gx/2; x < W + gx; x += gx) {
drawText(x, y);
}
}
} else {
let x, y;
if (pos === 'center') { x = W/2 - tw/2; y = H/2; }
else if (pos === 'top-left') { x = pad; y = pad + th/2; }
else if (pos === 'top-right') { x = W - tw - pad; y = pad + th/2; }
else if (pos === 'bottom-left') { x = pad; y = H - pad - th/2; }
else { x = W - tw - pad; y = H - pad - th/2; }
drawText(x, y);
}
// export
const fmt = $('outFormat').value;
const quality = parseInt($('outQuality').value) / 100;
canvas.toBlob(blob => {
currentBlob = blob;
const url = URL.createObjectURL(blob);
preview.src = url;
previewWrap.style.display = 'block';
emptyState.style.display = 'none';
downloadBtn.style.display = 'block';
infoBar.style.display = 'block';
$('dimInfo').textContent = `${W} × ${H}`;
$('sizeInfo').textContent = formatBytes(blob.size);
applyBtn.disabled = false;
applyBtn.textContent = '✦ 重新渲染';
}, fmt, quality);
});
}
// ── Download ──────────────────────────────────────
downloadBtn.addEventListener('click', () => {
if (!currentBlob) return;
const fmt = $('outFormat').value;
const ext = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp' }[fmt] || 'jpg';
const a = document.createElement('a');
a.href = URL.createObjectURL(currentBlob);
a.download = `watermarked.${ext}`;
a.click();
});
function formatBytes(b) {
if (b < 1024) return b + ' B';
if (b < 1024*1024) return (b/1024).toFixed(1) + ' KB';
return (b/1024/1024).toFixed(2) + ' MB';
}
</script>
</body>
</html>

View File

@@ -0,0 +1,359 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>在线图片压缩工具</title>
<style>
:root {
--primary-color: #007aff;
--bg-color: #f5f5f7;
--card-bg: #ffffff;
--text-main: #333333;
--text-muted: #888888;
--border-color: #e5e5ea;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background-color: var(--bg-color);
color: var(--text-main);
line-height: 1.6;
padding: 20px;
}
.container {
max-width: 800px;
margin: 0 auto;
background: var(--card-bg);
border-radius: 12px;
padding: 30px;
box-shadow: 0 4px 6px rgba(0,0,0,0.05);
}
h1 {
text-align: center;
margin-bottom: 30px;
font-size: 24px;
}
.upload-area {
border: 2px dashed var(--border-color);
border-radius: 8px;
padding: 40px 20px;
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
margin-bottom: 20px;
}
.upload-area:hover, .upload-area.dragover {
border-color: var(--primary-color);
background-color: rgba(0, 122, 255, 0.05);
}
#fileInput {
display: none;
}
.controls {
display: flex;
flex-wrap: wrap;
gap: 20px;
margin-bottom: 30px;
padding: 20px;
background: #fafafa;
border-radius: 8px;
display: none; /* 默认隐藏,上传后显示 */
}
.control-group {
flex: 1;
min-width: 200px;
}
.control-group label {
display: block;
margin-bottom: 8px;
font-weight: 500;
}
input[type="range"] {
width: 100%;
margin-bottom: 5px;
}
select {
width: 100%;
padding: 8px;
border: 1px solid var(--border-color);
border-radius: 6px;
background: white;
}
.preview-area {
display: none; /* 默认隐藏 */
gap: 20px;
margin-bottom: 20px;
}
@media (min-width: 600px) {
.preview-area {
display: flex; /* 电脑端并排 */
}
}
.preview-box {
flex: 1;
background: #fafafa;
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 15px;
text-align: center;
margin-bottom: 20px; /* 手机端间距 */
}
.preview-box img {
max-width: 100%;
max-height: 300px;
border-radius: 4px;
margin-top: 10px;
object-fit: contain;
}
.info {
font-size: 14px;
color: var(--text-muted);
margin-top: 10px;
}
.btn {
display: inline-block;
background-color: var(--primary-color);
color: white;
padding: 12px 24px;
border: none;
border-radius: 6px;
font-size: 16px;
cursor: pointer;
text-decoration: none;
transition: background-color 0.3s;
width: 100%;
text-align: center;
}
.btn:hover {
background-color: #005bb5;
}
.btn:disabled {
background-color: #ccc;
cursor: not-allowed;
}
#downloadBtn {
display: none;
}
</style>
</head>
<body>
<div class="container">
<h1>图片压缩工具</h1>
<div class="upload-area" id="uploadArea">
<p>点击这里或将图片拖拽到此处上传</p>
<p style="font-size: 12px; color: #888; margin-top: 8px;">支持 JPG, PNG, WebP 等常见格式</p>
<input type="file" id="fileInput" accept="image/*">
</div>
<div class="controls" id="controls">
<div class="control-group">
<label for="qualitySlider">压缩质量: <span id="qualityValue">0.8</span></label>
<input type="range" id="qualitySlider" min="0.1" max="1" step="0.1" value="0.8">
<div style="font-size: 12px; color: #888;">数值越小,体积越小,画质越低</div>
</div>
<div class="control-group">
<label for="formatSelect">输出格式:</label>
<select id="formatSelect">
<option value="image/jpeg">JPEG (体积较小)</option>
<option value="image/webp">WebP (推荐,体积最小)</option>
<option value="image/png">PNG (保留透明度,体积较大)</option>
</select>
</div>
</div>
<div class="preview-area" id="previewArea">
<div class="preview-box">
<h3>原图</h3>
<div class="info" id="originalInfo">等待上传...</div>
<img id="originalImg" src="" alt="原图预览" style="display: none;">
</div>
<div class="preview-box">
<h3>压缩后</h3>
<div class="info" id="compressedInfo">等待处理...</div>
<img id="compressedImg" src="" alt="压缩后预览" style="display: none;">
</div>
</div>
<a id="downloadBtn" class="btn" download="compressed_image.jpg">下载压缩后的图片</a>
</div>
<script>
const uploadArea = document.getElementById('uploadArea');
const fileInput = document.getElementById('fileInput');
const controls = document.getElementById('controls');
const previewArea = document.getElementById('previewArea');
const originalImg = document.getElementById('originalImg');
const compressedImg = document.getElementById('compressedImg');
const originalInfo = document.getElementById('originalInfo');
const compressedInfo = document.getElementById('compressedInfo');
const qualitySlider = document.getElementById('qualitySlider');
const qualityValue = document.getElementById('qualityValue');
const formatSelect = document.getElementById('formatSelect');
const downloadBtn = document.getElementById('downloadBtn');
let currentFile = null;
// 格式化文件大小
function formatBytes(bytes, decimals = 2) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
}
// 处理上传点击和拖拽
uploadArea.addEventListener('click', () => fileInput.click());
uploadArea.addEventListener('dragover', (e) => {
e.preventDefault();
uploadArea.classList.add('dragover');
});
uploadArea.addEventListener('dragleave', () => {
uploadArea.classList.remove('dragover');
});
uploadArea.addEventListener('drop', (e) => {
e.preventDefault();
uploadArea.classList.remove('dragover');
if (e.dataTransfer.files.length > 0) {
handleFile(e.dataTransfer.files[0]);
}
});
fileInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) {
handleFile(e.target.files[0]);
}
});
// 接收文件并读取
function handleFile(file) {
if (!file.type.startsWith('image/')) {
alert('请上传图片文件!');
return;
}
currentFile = file;
// 显示原图信息
originalInfo.innerText = `大小: ${formatBytes(file.size)} \n格式: ${file.type.split('/')[1].toUpperCase()}`;
const reader = new FileReader();
reader.onload = (e) => {
originalImg.src = e.target.result;
originalImg.style.display = 'inline-block';
// 显示控制面板和预览区
controls.style.display = 'flex';
previewArea.style.display = 'flex';
// 默认选中 WebP如果原图是 PNG 且用户可能需要透明度,可以在这里做额外判断
if(file.type === 'image/png') {
formatSelect.value = 'image/png';
} else {
formatSelect.value = 'image/jpeg';
}
compressImage();
};
reader.readAsDataURL(file);
}
// 监听调节事件
qualitySlider.addEventListener('input', (e) => {
qualityValue.innerText = e.target.value;
});
qualitySlider.addEventListener('change', compressImage);
formatSelect.addEventListener('change', compressImage);
// 核心压缩逻辑 (利用 Canvas)
function compressImage() {
if (!currentFile || !originalImg.src) return;
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
// 创建一个新的 Image 对象用来绘制
const img = new Image();
img.onload = () => {
// 保持原始宽高
canvas.width = img.width;
canvas.height = img.height;
// 如果导出为 JPEG将透明背景填充为白色
if (formatSelect.value === 'image/jpeg') {
ctx.fillStyle = '#fff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
const quality = parseFloat(qualitySlider.value);
const outputFormat = formatSelect.value;
// 将 canvas 转为 Blob
canvas.toBlob((blob) => {
if(!blob) return;
// 显示压缩后信息
compressedInfo.innerText = `大小: ${formatBytes(blob.size)} \n格式: ${outputFormat.split('/')[1].toUpperCase()}`;
// 计算压缩率
const ratio = ((1 - (blob.size / currentFile.size)) * 100).toFixed(1);
if(ratio > 0) {
compressedInfo.innerText += `\n节省了 ${ratio}% 的空间`;
}
// 创建预览 URL 和下载链接
const compressUrl = URL.createObjectURL(blob);
compressedImg.src = compressUrl;
compressedImg.style.display = 'inline-block';
downloadBtn.style.display = 'block';
downloadBtn.href = compressUrl;
// 设置下载文件名
const ext = outputFormat.split('/')[1];
const originalName = currentFile.name.split('.')[0];
downloadBtn.download = `${originalName}_compressed.${ext}`;
}, outputFormat, quality);
};
img.src = originalImg.src;
}
</script>
</body>
</html>

View File

@@ -0,0 +1,46 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Wasmer Clang in Browser</title>
</head>
<body>
<h1>浏览器里直接编译 C → WASM</h1>
<textarea id="code" rows="10" cols="80">#include <stdio.h>
int main() {
printf("Hello from Clang in WASM!\n");
return 0;
}</textarea>
<br>
<button onclick="compileC()">编译并运行</button>
<pre id="output"></pre>
<script type="module">
import { init, Wasmer, Directory } from "https://unpkg.com/@wasmer/sdk@latest/dist/index.mjs";
async function compileC() {
await init(); // 加载 Wasmer SDK
const clang = await Wasmer.fromRegistry("clang/clang");
const project = new Directory();
const source = document.getElementById("code").value;
await project.writeFile("hello.c", source);
// 调用 Clang 编译成 WASM
const compileResult = await clang.entrypoint.run({
args: ["hello.c", "-o", "hello.wasm"],
mount: { "/project": project },
});
await compileResult.wait();
// 读取生成的 WASM 并运行
const wasmBytes = await project.readFile("hello.wasm");
const instance = await Wasmer.fromFile(wasmBytes);
const runResult = await instance.entrypoint.run();
const output = await runResult.wait();
document.getElementById("output").textContent = output.stdout || "编译成功!";
}
</script>
</body>
</html>

View File

@@ -1,9 +1,7 @@
import React from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { Toaster } from 'react-hot-toast';
import styled from 'styled-components';
// 页面组件
import HomePage from './pages/HomePage';
import LoginPage from './pages/LoginPage';
import AuthCallbackPage from './pages/AuthCallbackPage';
@@ -15,42 +13,27 @@ import UserProfilePage from './pages/UserProfilePage';
import AdminPage from './pages/AdminPage';
import ToolboxPage from './pages/ToolboxPage';
// 公共组件
import Header from './components/Header';
import Navigation from './components/Navigation';
import Footer from './components/Footer';
import ParticleEffect from './components/ParticleEffect';
import RandomSiteBackground from './components/RandomSiteBackground';
import ScrollToTop from './components/ScrollToTop';
// 上下文
import { UserProvider } from './contexts/UserContext';
// 样式
import './styles/global.css';
const AppContainer = styled.div`
min-height: 100vh;
display: flex;
flex-direction: column;
background: linear-gradient(135deg, #a8e6cf 0%, #dcedc1 50%, #ffd3a5 100%);
`;
const MainContent = styled.main`
flex: 1;
padding: 0;
margin: 0;
`;
import './styles/index.css';
function App() {
return (
<UserProvider>
<Router>
<ScrollToTop />
<AppContainer>
<RandomSiteBackground />
<div className="relative z-[1] min-h-screen flex flex-col bg-transparent">
<Header />
<MainContent>
<main className="flex-1 p-0 m-0">
<Routes>
{/* 主要页面 */}
<Route path="/" element={<HomePage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/auth/callback" element={<AuthCallbackPage />} />
@@ -61,14 +44,12 @@ function App() {
<Route path="/toolbox" element={<ToolboxPage />} />
<Route path="/profile" element={<UserProfilePage />} />
<Route path="/admin" element={<AdminPage />} />
{/* 通配符路由 - 所有未匹配的路径都重定向到首页 */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</MainContent>
</main>
<Navigation />
<Footer />
{/* 全局提示组件 */}
<Toaster
position="top-center"
toastOptions={{
@@ -78,26 +59,15 @@ function App() {
color: '#fff',
borderRadius: '10px',
padding: '16px',
fontSize: '14px'
fontSize: '14px',
},
success: {
iconTheme: {
primary: '#4ade80',
secondary: '#fff'
}
},
error: {
iconTheme: {
primary: '#ef4444',
secondary: '#fff'
}
}
success: { iconTheme: { primary: '#4ade80', secondary: '#fff' } },
error: { iconTheme: { primary: '#ef4444', secondary: '#fff' } },
}}
/>
{/* 全局粒子效果 */}
<ParticleEffect />
</AppContainer>
</div>
</Router>
</UserProvider>
);

View File

@@ -1,74 +1,33 @@
import React from 'react';
import styled from 'styled-components';
const FooterContainer = styled.footer`
background: linear-gradient(135deg, #81c784 0%, #66bb6a 100%);
color: white;
padding: 40px 0 80px; /* 底部留出导航栏空间 */
margin-top: 60px;
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1);
@media (min-width: 769px) {
padding: 40px 0 20px;
}
`;
const FooterContent = styled.div`
max-width: 1200px;
margin: 0 auto;
padding: 0 16px;
`;
const FooterInfo = styled.div`
text-align: center;
margin-bottom: 24px;
`;
const FooterTitle = styled.h3`
color: white;
margin-bottom: 12px;
font-size: 20px;
font-weight: bold;
`;
const FooterDivider = styled.div`
height: 1px;
background: rgba(255, 255, 255, 0.2);
margin: 24px 0;
`;
const FooterBottom = styled.div`
text-align: center;
font-size: 12px;
color: rgba(255, 255, 255, 0.8);
line-height: 1.5;
`;
const Copyright = styled.p`
margin-bottom: 8px;
`;
const Footer = () => {
const currentYear = new Date().getFullYear();
return (
<FooterContainer>
<FooterContent>
<FooterInfo>
<FooterTitle>万象口袋</FooterTitle>
</FooterInfo>
<footer
className={[
'text-white mt-[60px]',
'bg-[linear-gradient(135deg,rgba(129,199,132,0.62)_0%,rgba(102,187,106,0.62)_100%)]',
'backdrop-blur-[16px] [backdrop-filter:blur(16px)_saturate(125%)]',
'shadow-[0_-2px_16px_rgba(0,0,0,0.08)]',
'border-t border-white/35',
'pt-10 pb-20 md:pb-5',
].join(' ')}
>
<div className="max-w-[1200px] mx-auto px-4">
<div className="text-center mb-6">
<h3 className="text-white text-xl font-bold mb-3">万象口袋</h3>
</div>
<div className="h-px bg-white/20 my-6" />
<FooterDivider />
<FooterBottom>
<Copyright>
<strong>蜀ICP备2025151694号 | Copyright © 2025-{currentYear}</strong>
</Copyright>
</FooterBottom>
</FooterContent>
</FooterContainer>
<div className="text-center text-xs text-white/80 leading-relaxed">
<p className="mb-2">
<strong>蜀ICP备2025151694号 | Copyright © 2025-{currentYear}</strong>
</p>
</div>
</div>
</footer>
);
};

View File

@@ -1,175 +1,7 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { createPortal } from 'react-dom';
import styled, { keyframes, css } from 'styled-components';
import { FiArrowLeft, FiRefreshCw, FiAlertCircle } from 'react-icons/fi';
const slideUp = keyframes`
from { transform: translateY(100%); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
`;
const slideDown = keyframes`
from { transform: translateY(0); opacity: 1; }
to { transform: translateY(100%); opacity: 0; }
`;
const spin = keyframes`
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
`;
const fadeIn = keyframes`
from { opacity: 0; }
to { opacity: 1; }
`;
const Overlay = styled.div`
position: fixed;
inset: 0;
z-index: 999999;
display: flex;
flex-direction: column;
background: #fff;
font-family: inherit;
animation: ${({ $closing }) => $closing ? css`${slideDown} 0.25s ease-in forwards` : css`${slideUp} 0.3s ease-out`};
`;
const Header = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 16px;
height: 52px;
flex-shrink: 0;
background: ${({ $color }) => $color};
color: #fff;
box-shadow: 0 2px 8px rgba(0,0,0,0.12);
user-select: none;
-webkit-tap-highlight-color: transparent;
`;
const HeaderTitle = styled.h1`
flex: 1;
margin: 0;
font-size: 16px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`;
const HeaderBtn = styled.button`
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
height: 34px;
padding: 0 14px;
border: none;
border-radius: 8px;
background: rgba(255,255,255,0.18);
color: #fff;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
flex-shrink: 0;
&:hover { background: rgba(255,255,255,0.3); }
&:active { background: rgba(255,255,255,0.1); }
`;
const IframeWrapper = styled.div`
position: relative;
flex: 1;
overflow: hidden;
`;
const StyledIframe = styled.iframe`
display: block;
width: 100%;
height: 100%;
border: none;
background: #fff;
`;
const LoadingOverlay = styled.div`
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
background: #fff;
animation: ${fadeIn} 0.15s ease-out;
z-index: 2;
`;
const Spinner = styled.div`
width: 36px;
height: 36px;
border: 3px solid #e5e7eb;
border-top-color: ${({ $color }) => $color};
border-radius: 50%;
animation: ${spin} 0.7s linear infinite;
`;
const LoadingText = styled.p`
margin: 0;
font-size: 14px;
color: #9ca3af;
`;
const ErrorOverlay = styled.div`
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
background: #fff;
z-index: 2;
padding: 24px;
text-align: center;
`;
const ErrorIcon = styled(FiAlertCircle)`
color: #ef4444;
font-size: 48px;
`;
const ErrorTitle = styled.h2`
margin: 0;
font-size: 18px;
font-weight: 600;
color: #1f2937;
`;
const ErrorDesc = styled.p`
margin: 0;
font-size: 14px;
color: #6b7280;
max-width: 320px;
line-height: 1.5;
`;
const RetryButton = styled.button`
display: flex;
align-items: center;
gap: 6px;
padding: 10px 24px;
border: none;
border-radius: 10px;
background: linear-gradient(135deg, #4ade80 0%, #22c55e 100%);
color: #fff;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: transform 0.15s, box-shadow 0.15s;
&:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(74,222,128,0.35); }
`;
import clsx from 'clsx';
const LOAD_TIMEOUT_MS = 20000;
@@ -255,20 +87,57 @@ const FullscreenEmbed = ({
}, [url, loading]);
return createPortal(
<Overlay $closing={closing}>
<Header $color={headerColor}>
<HeaderBtn onClick={handleClose} aria-label="返回">
<div
className={clsx(
'fixed inset-0 z-[999999] flex flex-col bg-white font-[inherit]',
closing
? 'animate-[slideDown_0.25s_ease-in_forwards]'
: 'animate-[slideUp_0.3s_ease-out]',
)}
style={{
'--tw-animate-slideUp': 'slideUp 0.3s ease-out',
'--tw-animate-slideDown': 'slideDown 0.25s ease-in forwards',
}}
>
<style>{`
@keyframes slideUp { from { transform: translateY(100%); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
@keyframes slideDown { from { transform: translateY(0); opacity: 1; } to { transform: translateY(100%); opacity: 0; } }
@keyframes spinOnce { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
@keyframes fIn { from { opacity: 0; } to { opacity: 1; } }
.embed-overlay-enter { animation: slideUp 0.3s ease-out; }
.embed-overlay-exit { animation: slideDown 0.25s ease-in forwards; }
.embed-spinner { animation: spinOnce 0.7s linear infinite; }
.embed-fade { animation: fIn 0.15s ease-out; }
`}</style>
{/* Header */}
<div
className="flex items-center justify-between gap-3 px-4 h-[52px] flex-shrink-0 text-white shadow-[0_2px_8px_rgba(0,0,0,0.12)] select-none"
style={{ background: headerColor }}
>
<button
onClick={handleClose}
aria-label="返回"
className="flex items-center justify-center gap-1.5 h-[34px] px-3.5 border-none rounded-lg bg-white/[0.18] text-white text-sm font-medium cursor-pointer transition-colors flex-shrink-0 hover:bg-white/30 active:bg-white/10"
>
<FiArrowLeft size={16} />
返回
</HeaderBtn>
<HeaderTitle>{title}</HeaderTitle>
<HeaderBtn onClick={handleRetry} aria-label="刷新">
</button>
<h1 className="flex-1 m-0 text-base font-semibold whitespace-nowrap overflow-hidden text-ellipsis">
{title}
</h1>
<button
onClick={handleRetry}
aria-label="刷新"
className="flex items-center justify-center gap-1.5 h-[34px] px-3.5 border-none rounded-lg bg-white/[0.18] text-white text-sm font-medium cursor-pointer transition-colors flex-shrink-0 hover:bg-white/30 active:bg-white/10"
>
<FiRefreshCw size={14} />
</HeaderBtn>
</Header>
</button>
</div>
<IframeWrapper>
<StyledIframe
{/* Iframe Wrapper */}
<div className="relative flex-1 overflow-hidden">
<iframe
ref={iframeRef}
src={url}
title={title}
@@ -277,29 +146,38 @@ const FullscreenEmbed = ({
loading="eager"
onLoad={handleIframeLoaded}
onError={() => { clearTimeout(timerRef.current); setError(true); }}
className="block w-full h-full border-none bg-white"
/>
{loading && !error && (
<LoadingOverlay>
<Spinner $color={headerColor} />
<LoadingText>正在加载 {title}</LoadingText>
</LoadingOverlay>
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-white embed-fade z-[2]">
<div
className="embed-spinner w-9 h-9 border-[3px] border-gray-200 rounded-full"
style={{ borderTopColor: headerColor }}
/>
<p className="m-0 text-sm text-gray-400">正在加载 {title}</p>
</div>
)}
{error && (
<ErrorOverlay>
<ErrorIcon />
<ErrorTitle>页面加载失败</ErrorTitle>
<ErrorDesc>无法加载{title}请检查网络连接后重试</ErrorDesc>
<RetryButton onClick={handleRetry}>
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-white z-[2] p-6 text-center">
<FiAlertCircle className="text-red-500 text-5xl" />
<h2 className="m-0 text-lg font-semibold text-gray-800">页面加载失败</h2>
<p className="m-0 text-sm text-gray-500 max-w-[320px] leading-relaxed">
无法加载{title}请检查网络连接后重试
</p>
<button
onClick={handleRetry}
className="flex items-center gap-1.5 px-6 py-2.5 border-none rounded-[10px] bg-gradient-to-br from-[#4ade80] to-[#22c55e] text-white text-sm font-semibold cursor-pointer transition-all hover:-translate-y-px hover:shadow-[0_4px_12px_rgba(74,222,128,0.35)]"
>
<FiRefreshCw size={14} />
重新加载
</RetryButton>
</ErrorOverlay>
</button>
</div>
)}
</IframeWrapper>
</Overlay>,
document.body
</div>
</div>,
document.body,
);
};

View File

@@ -1,376 +1,11 @@
import React, { useState, useRef, useCallback } from 'react';
import { Link, useNavigate, useLocation } from 'react-router-dom';
import styled from 'styled-components';
import { FiUser, FiMenu, FiX, FiLogOut } from 'react-icons/fi';
import { useUser } from '../contexts/UserContext';
import toast from 'react-hot-toast';
import clsx from 'clsx';
const logoSrc = `${process.env.PUBLIC_URL}/assets/logo.png`;
const HeaderContainer = styled.header`
background: linear-gradient(135deg, #81c784 0%, #66bb6a 100%);
color: white;
padding: 12px 0;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
position: sticky;
top: 0;
z-index: 1000;
`;
const HeaderContent = styled.div`
max-width: 1200px;
margin: 0 auto;
padding: 0 16px;
display: flex;
align-items: center;
justify-content: space-between;
`;
const Logo = styled.div`
display: flex;
align-items: center;
font-size: 20px;
font-weight: bold;
text-decoration: none;
color: white;
cursor: pointer;
user-select: none;
.logo-icon {
margin-right: 8px;
width: 36px;
height: 36px;
object-fit: cover;
}
@media (max-width: 768px) {
font-size: 18px;
.logo-icon {
width: 30px;
height: 30px;
}
}
`;
const Nav = styled.nav`
display: flex;
align-items: center;
gap: 24px;
@media (max-width: 768px) {
display: none;
}
`;
const NavLink = styled(Link).withConfig({
shouldForwardProp: (prop) => prop !== 'isActive'
})`
color: ${props => props.isActive ? 'white' : 'rgba(255, 255, 255, 0.9)'};
text-decoration: none;
padding: 10px 18px;
border-radius: 12px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
font-weight: ${props => props.isActive ? '600' : '500'};
background: ${props => props.isActive ? 'rgba(255, 255, 255, 0.2)' : 'transparent'};
box-shadow: ${props => props.isActive ? '0 4px 12px rgba(0, 0, 0, 0.15)' : 'none'};
transform: ${props => props.isActive ? 'translateY(-1px)' : 'translateY(0)'};
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0.1) 100%);
border-radius: 12px;
opacity: 0;
transition: opacity 0.3s ease;
z-index: -1;
}
&:hover {
background: rgba(255, 255, 255, 0.15);
color: white;
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.2);
&::before {
opacity: 1;
}
}
&:active {
transform: translateY(0);
transition: transform 0.1s ease;
}
`;
const UserSection = styled.div`
display: flex;
align-items: center;
gap: 12px;
`;
const UserButton = styled.button`
display: flex;
align-items: center;
gap: 8px;
background: rgba(255, 255, 255, 0.1);
color: white;
border: none;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: rgba(255, 255, 255, 0.2);
}
@media (max-width: 768px) {
padding: 8px 12px;
.user-text {
display: none;
}
}
`;
const UserAvatar = styled.img`
width: 24px;
height: 24px;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.3);
object-fit: cover;
`;
const UserInfo = styled.div`
display: flex;
align-items: center;
gap: 8px;
background: rgba(255, 255, 255, 0.1);
color: white;
padding: 6px 12px;
border-radius: 6px;
@media (max-width: 768px) {
.user-name {
display: none;
}
}
`;
const MobileMenuButton = styled.button`
display: none;
background: none;
border: none;
color: white;
font-size: 24px;
cursor: pointer;
@media (max-width: 768px) {
display: block;
}
`;
const MobileMenu = styled.div.withConfig({
shouldForwardProp: (prop) => prop !== 'isOpen'
})`
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 2000;
display: ${props => props.isOpen ? 'block' : 'none'};
@media (min-width: 769px) {
display: none;
}
`;
const MobileMenuContent = styled.div.withConfig({
shouldForwardProp: (prop) => prop !== 'isOpen'
})`
position: absolute;
top: 0;
right: 0;
width: 280px;
height: 100vh;
background: linear-gradient(135deg,
rgba(255, 240, 245, 0.95) 0%, /* 淡粉红色 */
rgba(255, 253, 240, 0.95) 35%, /* 淡黄色 */
rgba(240, 255, 240, 0.95) 70%, /* 淡绿色 */
rgba(248, 250, 252, 0.95) 100% /* 接近白色 */
);
backdrop-filter: blur(10px);
transform: translateX(${props => props.isOpen ? '0' : '100%'});
transition: transform 0.3s ease;
padding: 20px;
overflow-y: auto;
`;
const MobileMenuHeader = styled.div`
display: flex;
justify-content: between;
align-items: center;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid #e5e7eb;
`;
const MobileMenuTitle = styled.h3`
color: #1f2937;
margin: 0;
`;
const CloseButton = styled.button`
background: none;
border: none;
font-size: 24px;
color: #6b7280;
cursor: pointer;
`;
const MobileNavLink = styled(Link).withConfig({
shouldForwardProp: (prop) => prop !== 'isActive'
})`
display: block;
color: ${props => props.isActive ? '#4ade80' : '#374151'};
text-decoration: none;
padding: 16px 20px;
margin: 4px 0;
border-radius: 12px;
border-bottom: none;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
font-weight: ${props => props.isActive ? '600' : '500'};
background: ${props => props.isActive ? 'rgba(74, 222, 128, 0.1)' : 'transparent'};
transform: ${props => props.isActive ? 'translateX(8px)' : 'translateX(0)'};
&::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: ${props => props.isActive ? '4px' : '0'};
height: 60%;
background: linear-gradient(135deg, #4ade80 0%, #22c55e 100%);
border-radius: 0 2px 2px 0;
transition: width 0.3s ease;
}
&::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg, rgba(74, 222, 128, 0.05) 0%, rgba(34, 197, 94, 0.05) 100%);
border-radius: 12px;
opacity: 0;
transition: opacity 0.3s ease;
z-index: -1;
}
&:hover {
color: #4ade80;
background: rgba(74, 222, 128, 0.08);
transform: translateX(12px);
box-shadow: 0 4px 12px rgba(74, 222, 128, 0.15);
&::after {
opacity: 1;
}
&::before {
width: 4px;
}
}
&:active {
transform: translateX(6px);
transition: transform 0.1s ease;
}
`;
const AdminModal = styled.div`
position: fixed;
inset: 0;
background: rgba(0,0,0,0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
backdrop-filter: blur(4px);
`;
const AdminModalContent = styled.div`
background: linear-gradient(135deg, #e8f5e8 0%, #f1f8e9 100%);
padding: 32px;
border-radius: 18px;
width: 340px;
max-width: 90vw;
box-shadow: 0 20px 40px rgba(0,0,0,0.25);
text-align: center;
`;
const AdminModalTitle = styled.h3`
margin: 0 0 8px;
font-size: 20px;
color: #1b5e20;
`;
const AdminModalDesc = styled.p`
margin: 0 0 20px;
font-size: 13px;
color: #6b7280;
`;
const AdminInput = styled.input`
width: 100%;
padding: 12px 16px;
border: 2px solid #e5e7eb;
border-radius: 10px;
font-size: 15px;
font-family: inherit;
transition: border-color 0.2s;
&:focus { border-color: #4ade80; outline: none; }
`;
const AdminSubmit = styled.button`
width: 100%;
margin-top: 14px;
padding: 12px;
border: none;
border-radius: 10px;
background: linear-gradient(135deg, #4ade80, #22c55e);
color: #fff;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: transform 0.15s;
&:hover { transform: translateY(-1px); }
`;
const AdminCancel = styled.button`
width: 100%;
margin-top: 8px;
padding: 10px;
border: none;
border-radius: 10px;
background: transparent;
color: #6b7280;
font-size: 13px;
cursor: pointer;
&:hover { color: #374151; }
`;
const logoSrc = '/assets/logo.png';
const ADMIN_TOKEN = 'shumengya520';
@@ -382,7 +17,6 @@ const Header = () => {
const navigate = useNavigate();
const location = useLocation();
// logo 五次点击检测
const clickCountRef = useRef(0);
const clickTimerRef = useRef(null);
@@ -393,7 +27,6 @@ const Header = () => {
if (clickCountRef.current >= 5) {
clickCountRef.current = 0;
// 如果已登录管理员,直接进入
if (localStorage.getItem('admin_token') === ADMIN_TOKEN) {
navigate('/admin');
} else {
@@ -403,7 +36,6 @@ const Header = () => {
return;
}
// 2 秒内未达到 5 次则重置,并正常跳转首页
clickTimerRef.current = setTimeout(() => {
clickCountRef.current = 0;
}, 2000);
@@ -423,9 +55,7 @@ const Header = () => {
}
};
const isActive = (path) => {
return location.pathname.startsWith(path);
};
const isActive = (path) => location.pathname.startsWith(path);
const handleLogout = async () => {
await logout();
@@ -433,156 +63,212 @@ const Header = () => {
navigate('/');
};
const handleMenuToggle = () => {
setIsMenuOpen(!isMenuOpen);
};
const handleMenuClose = () => {
setIsMenuOpen(false);
};
return (
<>
<HeaderContainer>
<HeaderContent>
<Logo onClick={handleLogoClick}>
<img className="logo-icon" src={logoSrc} alt="InfoGenie Logo" />
<header
className={[
'sticky top-0 z-[1000] text-white py-3',
'bg-[linear-gradient(135deg,rgba(129,199,132,0.62)_0%,rgba(102,187,106,0.62)_100%)]',
'[backdrop-filter:blur(16px)_saturate(125%)]',
'shadow-[0_2px_16px_rgba(0,0,0,0.08)]',
'border-b border-white/35',
].join(' ')}
>
<div className="max-w-[1200px] mx-auto px-4 flex items-center justify-between">
{/* Logo */}
<div
onClick={handleLogoClick}
className="flex items-center text-xl font-bold text-white cursor-pointer select-none"
>
<img
src={logoSrc}
alt="InfoGenie Logo"
className="mr-2 w-9 h-9 object-cover md:w-[30px] md:h-[30px]"
/>
万象口袋
</Logo>
</div>
<Nav>
<NavLink to="/60sapi" isActive={isActive('/60sapi')}>60sAPI</NavLink>
<NavLink to="/smallgame" isActive={isActive('/smallgame')}>休闲游戏</NavLink>
<NavLink to="/toolbox" isActive={isActive('/toolbox')}>工具箱</NavLink>
<NavLink to="/aimodel" isActive={isActive('/aimodel')}>AI应用</NavLink>
<NavLink to="/profile" isActive={isActive('/profile')}>个人中心</NavLink>
</Nav>
{/* Desktop Nav */}
<nav className="hidden md:flex items-center gap-6">
{[
{ to: '/60sapi', label: '60sAPI' },
{ to: '/smallgame', label: '休闲游戏' },
{ to: '/toolbox', label: '工具箱' },
{ to: '/aimodel', label: 'AI应用' },
{ to: '/profile', label: '个人中心' },
].map(({ to, label }) => (
<Link
key={to}
to={to}
className={clsx(
'no-underline px-[18px] py-2.5 rounded-xl transition-all duration-300 relative',
isActive(to)
? 'text-white font-semibold bg-white/20 shadow-[0_4px_12px_rgba(0,0,0,0.15)] -translate-y-px'
: 'text-white/90 font-medium hover:bg-white/15 hover:text-white hover:-translate-y-0.5 hover:shadow-[0_6px_20px_rgba(0,0,0,0.2)]',
)}
>
{label}
</Link>
))}
</nav>
<UserSection>
{/* User Section */}
<div className="flex items-center gap-3">
{isLoggedIn && user ? (
<>
<UserInfo>
<div className="flex items-center gap-2 bg-white/10 text-white px-3 py-1.5 rounded-md">
{user.avatarUrl ? (
<UserAvatar
src={user.avatarUrl}
<img
src={user.avatarUrl}
alt="头像"
onError={(e) => {
e.target.style.display = 'none';
e.target.nextSibling.style.display = 'inline';
}}
className="w-6 h-6 rounded-full border-2 border-white/30 object-cover"
onError={(e) => { e.target.style.display = 'none'; }}
/>
) : null}
<FiUser style={{ display: user.avatarUrl ? 'none' : 'inline' }} />
<span className="user-name">{user.username || user.account}</span>
</UserInfo>
<UserButton onClick={handleLogout}>
<span className="hidden md:inline">{user.username || user.account}</span>
</div>
<button
onClick={handleLogout}
className="flex items-center gap-2 bg-white/10 text-white px-4 py-2 rounded-md transition-all hover:bg-white/20"
>
<FiLogOut size={16} />
<span className="user-text">退出</span>
</UserButton>
<span className="hidden md:inline">退出</span>
</button>
</>
) : (
<UserButton as={Link} to="/login">
<Link
to="/login"
className="flex items-center gap-2 bg-white/10 text-white px-4 py-2 rounded-md transition-all hover:bg-white/20"
>
<FiUser />
<span className="user-text">登录</span>
</UserButton>
<span className="hidden md:inline">登录</span>
</Link>
)}
<MobileMenuButton onClick={handleMenuToggle}>
<button
onClick={() => setIsMenuOpen(!isMenuOpen)}
className="md:hidden bg-transparent border-none text-white text-2xl cursor-pointer"
>
<FiMenu />
</MobileMenuButton>
</UserSection>
</HeaderContent>
</HeaderContainer>
</button>
</div>
</div>
</header>
<MobileMenu isOpen={isMenuOpen} onClick={handleMenuClose}>
<MobileMenuContent isOpen={isMenuOpen} onClick={(e) => e.stopPropagation()}>
<MobileMenuHeader>
<MobileMenuTitle>菜单</MobileMenuTitle>
<CloseButton onClick={handleMenuClose}>
{/* Mobile Menu Overlay */}
<div
className={clsx(
'fixed inset-0 bg-black/50 z-[2000] md:hidden',
isMenuOpen ? 'block' : 'hidden',
)}
onClick={() => setIsMenuOpen(false)}
>
<div
className={clsx(
'absolute top-0 right-0 w-72 h-screen overflow-y-auto p-5',
'backdrop-blur-[10px] transition-transform duration-300',
'bg-[linear-gradient(135deg,rgba(255,240,245,0.95)_0%,rgba(255,253,240,0.95)_35%,rgba(240,255,240,0.95)_70%,rgba(248,250,252,0.95)_100%)]',
isMenuOpen ? 'translate-x-0' : 'translate-x-full',
)}
onClick={(e) => e.stopPropagation()}
>
<div className="flex justify-between items-center mb-6 pb-4 border-b border-gray-200">
<h3 className="text-gray-800 m-0">菜单</h3>
<button
onClick={() => setIsMenuOpen(false)}
className="bg-transparent border-none text-2xl text-gray-500 cursor-pointer"
>
<FiX />
</CloseButton>
</MobileMenuHeader>
</button>
</div>
<MobileNavLink to="/" onClick={handleMenuClose} isActive={location.pathname === '/'}>
首页
</MobileNavLink>
<MobileNavLink to="/60sapi" onClick={handleMenuClose} isActive={isActive('/60sapi')}>
60sAPI
</MobileNavLink>
<MobileNavLink to="/smallgame" onClick={handleMenuClose} isActive={isActive('/smallgame')}>
休闲游戏
</MobileNavLink>
<MobileNavLink to="/toolbox" onClick={handleMenuClose} isActive={isActive('/toolbox')}>
工具箱
</MobileNavLink>
<MobileNavLink to="/aimodel" onClick={handleMenuClose} isActive={isActive('/aimodel')}>
AI应用
</MobileNavLink>
<MobileNavLink to="/profile" onClick={handleMenuClose} isActive={isActive('/profile')}>
个人中心
</MobileNavLink>
{[
{ to: '/', label: '首页', exact: true },
{ to: '/60sapi', label: '60sAPI' },
{ to: '/smallgame', label: '休闲游戏' },
{ to: '/toolbox', label: '工具箱' },
{ to: '/aimodel', label: 'AI应用' },
{ to: '/profile', label: '个人中心' },
].map(({ to, label, exact }) => {
const active = exact ? location.pathname === to : isActive(to);
return (
<Link
key={to}
to={to}
onClick={() => setIsMenuOpen(false)}
className={clsx(
'block no-underline px-5 py-4 my-1 rounded-xl',
'transition-all duration-300 font-medium relative',
active
? 'text-[#4ade80] font-semibold bg-[rgba(74,222,128,0.1)] translate-x-2'
: 'text-gray-700 hover:text-[#4ade80] hover:bg-[rgba(74,222,128,0.08)] hover:translate-x-3',
)}
>
{active && (
<span className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-[60%] bg-gradient-to-b from-[#4ade80] to-[#22c55e] rounded-r-sm" />
)}
{label}
</Link>
);
})}
{isLoggedIn && user ? (
{isLoggedIn && user && (
<>
<div style={{
padding: '12px 0',
borderBottom: '1px solid #f3f4f6',
display: 'flex',
alignItems: 'center',
gap: '12px'
}}>
<div className="flex items-center gap-3 py-3 border-b border-gray-100">
{user.avatarUrl ? (
<UserAvatar
src={user.avatarUrl}
alt="头像"
style={{ width: '32px', height: '32px' }}
/>
<img src={user.avatarUrl} alt="头像" className="w-8 h-8 rounded-full" />
) : (
<FiUser size={24} color="#666" />
)}
<span style={{ color: '#374151', fontWeight: '500' }}>{user.username || user.account}</span>
<span className="text-gray-700 font-medium">{user.username || user.account}</span>
</div>
<MobileNavLink as="button"
style={{
background: 'none',
border: 'none',
width: '100%',
textAlign: 'left',
color: '#ef4444'
}}
onClick={() => {
handleLogout();
handleMenuClose();
}}
<button
className="block w-full text-left px-5 py-4 my-1 rounded-xl text-red-500 font-medium bg-transparent border-none cursor-pointer hover:bg-red-50"
onClick={() => { handleLogout(); setIsMenuOpen(false); }}
>
🚪 退出登录
</MobileNavLink>
</button>
</>
) : (
<MobileNavLink to="/login" onClick={handleMenuClose}>
登录注册
</MobileNavLink>
)}
</MobileMenuContent>
</MobileMenu>
</div>
</div>
{/* Admin Modal */}
{showAdminModal && (
<AdminModal onClick={() => setShowAdminModal(false)}>
<AdminModalContent onClick={e => e.stopPropagation()}>
<AdminModalTitle>🔐 管理员验证</AdminModalTitle>
<AdminModalDesc>请输入管理员令牌以进入后台</AdminModalDesc>
<AdminInput
<div
className="fixed inset-0 bg-black/60 flex items-center justify-center z-[10000] backdrop-blur-[4px]"
onClick={() => setShowAdminModal(false)}
>
<div
className="bg-gradient-to-br from-[#e8f5e8] to-[#f1f8e9] p-8 rounded-[18px] w-[340px] max-w-[90vw] shadow-[0_20px_40px_rgba(0,0,0,0.25)] text-center"
onClick={(e) => e.stopPropagation()}
>
<h3 className="m-0 mb-2 text-xl text-[#1b5e20]">🔐 管理员验证</h3>
<p className="m-0 mb-5 text-sm text-gray-500">请输入管理员令牌以进入后台</p>
<input
type="password"
placeholder="请输入管理员令牌"
value={adminInput}
onChange={e => setAdminInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleAdminSubmit()}
onChange={(e) => setAdminInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleAdminSubmit()}
autoFocus
className="w-full px-4 py-3 border-2 border-gray-200 rounded-[10px] text-[15px] font-[inherit] transition-colors focus:border-[#4ade80] focus:outline-none"
/>
<AdminSubmit onClick={handleAdminSubmit}>验证并进入</AdminSubmit>
<AdminCancel onClick={() => setShowAdminModal(false)}>取消</AdminCancel>
</AdminModalContent>
</AdminModal>
<button
onClick={handleAdminSubmit}
className="w-full mt-3.5 py-3 border-none rounded-[10px] bg-gradient-to-br from-[#4ade80] to-[#22c55e] text-white text-[15px] font-semibold cursor-pointer transition-transform hover:-translate-y-px"
>
验证并进入
</button>
<button
onClick={() => setShowAdminModal(false)}
className="w-full mt-2 py-2.5 border-none rounded-[10px] bg-transparent text-gray-500 text-sm cursor-pointer hover:text-gray-700"
>
取消
</button>
</div>
</div>
)}
</>
);

View File

@@ -1,61 +1,7 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import styled from 'styled-components';
import { FiUser } from 'react-icons/fi';
const Wrapper = styled.div`
background: white;
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
min-height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
padding: 60px 40px;
`;
const Icon = styled.div`
font-size: 64px;
margin-bottom: 24px;
`;
const Title = styled.h2`
font-size: 24px;
font-weight: bold;
color: #2e7d32;
margin-bottom: 16px;
`;
const Text = styled.p`
color: #666;
font-size: 16px;
line-height: 1.6;
margin-bottom: 24px;
`;
const Button = styled.button`
background: linear-gradient(135deg, #81c784 0%, #a5d6a7 100%);
color: white;
border: none;
padding: 14px 32px;
border-radius: 16px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
display: inline-flex;
align-items: center;
gap: 8px;
box-shadow: 0 4px 16px rgba(129, 199, 132, 0.3);
&:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(129, 199, 132, 0.4);
}
`;
const LoginRequired = ({
title = '需要登录访问',
description = '该功能需要登录后才能使用,请先登录您的账户。',
@@ -64,17 +10,26 @@ const LoginRequired = ({
const navigate = useNavigate();
return (
<Wrapper>
<div className="bg-white absolute inset-0 min-h-screen flex flex-col justify-center items-center text-center px-10 py-[60px]">
<div>
<Icon>{icon}</Icon>
<Title>{title}</Title>
<Text>{description}</Text>
<Button onClick={() => navigate('/login')}>
<div className="text-[64px] mb-6">{icon}</div>
<h2 className="text-2xl font-bold text-[#2e7d32] mb-4">{title}</h2>
<p className="text-gray-500 text-base leading-relaxed mb-6">{description}</p>
<button
onClick={() => navigate('/login')}
className={[
'bg-gradient-to-br from-[#81c784] to-[#a5d6a7] text-white border-none',
'px-8 py-3.5 rounded-2xl text-base font-semibold cursor-pointer',
'transition-all duration-300 inline-flex items-center gap-2',
'shadow-[0_4px_16px_rgba(129,199,132,0.3)]',
'hover:-translate-y-0.5 hover:shadow-[0_8px_24px_rgba(129,199,132,0.4)]',
].join(' ')}
>
<FiUser />
立即登录
</Button>
</button>
</div>
</Wrapper>
</div>
);
};

View File

@@ -1,161 +1,16 @@
import React, { useEffect, useRef } from 'react';
import { Link, useLocation } from 'react-router-dom';
import styled from 'styled-components';
import { FiHome, FiActivity, FiGrid, FiTool, FiCpu, FiUser } from 'react-icons/fi';
import clsx from 'clsx';
const NavigationContainer = styled.nav`
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: rgba(255, 255, 255, 0.95);
border-top: 1px solid rgba(168, 230, 207, 0.3);
padding: 8px 0 calc(8px + env(safe-area-inset-bottom));
z-index: 1000;
box-shadow: 0 -4px 20px rgba(168, 230, 207, 0.2);
backdrop-filter: blur(15px);
@media (min-width: 769px) {
display: none;
}
`;
const NavList = styled.div`
display: flex;
flex-direction: row;
flex-wrap: nowrap;
justify-content: flex-start;
align-items: center;
gap: 6px;
width: 100%;
max-width: none;
margin: 0;
padding: 0 10px 0 10px;
box-sizing: border-box;
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
`;
const NavItem = styled(Link).withConfig({
shouldForwardProp: (prop) => prop !== 'isActive'
})`
display: flex;
flex-direction: column;
align-items: center;
text-decoration: none;
color: ${props => props.isActive ? '#66bb6a' : '#6b7280'};
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
flex-shrink: 0;
padding: 8px 12px;
border-radius: 14px;
min-width: 64px;
position: relative;
overflow: hidden;
/* 基础状态 */
background: ${props => props.isActive
? 'linear-gradient(135deg, rgba(102, 187, 106, 0.15), rgba(129, 199, 132, 0.1))'
: 'transparent'};
box-shadow: ${props => props.isActive
? '0 2px 8px rgba(102, 187, 106, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.3)'
: '0 0 0 rgba(0, 0, 0, 0)'};
transform: ${props => props.isActive ? 'translateY(-2px)' : 'translateY(0)'};
/* 伪元素用于悬停效果 */
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg, rgba(102, 187, 106, 0.1), rgba(129, 199, 132, 0.05));
opacity: 0;
transition: opacity 0.3s ease;
border-radius: 16px;
}
&:hover {
color: #66bb6a;
transform: translateY(-3px);
box-shadow: 0 4px 12px rgba(102, 187, 106, 0.25),
inset 0 1px 0 rgba(255, 255, 255, 0.4);
&::before {
opacity: 1;
}
}
&:active {
transform: translateY(-1px);
transition: all 0.1s ease;
}
.nav-icon {
font-size: 20px;
margin-bottom: 4px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
z-index: 1;
filter: ${props => props.isActive ? 'drop-shadow(0 1px 2px rgba(102, 187, 106, 0.3))' : 'none'};
}
.nav-text {
font-size: 10px;
font-weight: ${props => props.isActive ? '600' : '500'};
line-height: 1.15;
white-space: nowrap;
position: relative;
z-index: 1;
text-shadow: ${props => props.isActive ? '0 1px 2px rgba(102, 187, 106, 0.2)' : 'none'};
}
${props => props.isActive && `
.nav-icon {
transform: scale(1.15) rotate(5deg);
animation: bounce 0.6s ease;
}
.nav-text {
animation: fadeInUp 0.4s ease 0.1s both;
}
`}
&:hover .nav-icon {
transform: scale(1.2) rotate(-2deg);
}
@keyframes bounce {
0%, 20%, 53%, 80%, 100% {
transform: scale(1.15) rotate(5deg) translateY(0);
}
40%, 43% {
transform: scale(1.15) rotate(5deg) translateY(-4px);
}
70% {
transform: scale(1.15) rotate(5deg) translateY(-2px);
}
90% {
transform: scale(1.15) rotate(5deg) translateY(-1px);
}
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
`;
const navItems = [
{ path: '/', icon: FiHome, text: '首页', exact: true },
{ path: '/60sapi', icon: FiActivity, text: '60sAPI' },
{ path: '/smallgame', icon: FiGrid, text: '休闲游戏' },
{ path: '/toolbox', icon: FiTool, text: '工具箱' },
{ path: '/aimodel', icon: FiCpu, text: 'AI应用' },
{ path: '/profile', icon: FiUser, text: '个人中心' },
];
const Navigation = () => {
const location = useLocation();
@@ -167,68 +22,64 @@ const Navigation = () => {
el.scrollIntoView({ inline: 'center', behavior: 'smooth', block: 'nearest' });
}, [location.pathname]);
const navItems = [
{
path: '/',
icon: FiHome,
text: '首页',
exact: true
},
{
path: '/60sapi',
icon: FiActivity,
text: '60sAPI'
},
{
path: '/smallgame',
icon: FiGrid,
text: '休闲游戏'
},
{
path: '/toolbox',
icon: FiTool,
text: '工具箱'
},
{
path: '/aimodel',
icon: FiCpu,
text: 'AI应用'
},
{
path: '/profile',
icon: FiUser,
text: '个人中心'
}
];
const isActive = (path, exact = false) => {
if (exact) {
return location.pathname === path;
}
return location.pathname.startsWith(path);
};
const isActive = (path, exact = false) =>
exact ? location.pathname === path : location.pathname.startsWith(path);
return (
<NavigationContainer>
<NavList>
<nav
className={[
'fixed bottom-0 left-0 right-0 z-[1000] md:hidden',
'bg-[linear-gradient(180deg,#f8fdf9_0%,#ecfdf3_100%)]',
'border-t border-[rgba(129,199,132,0.45)]',
'rounded-[18px_18px_0_0]',
'pt-2 pb-[calc(8px+env(safe-area-inset-bottom))]',
'shadow-[0_-6px_24px_rgba(15,80,40,0.12)]',
].join(' ')}
>
<div
className={[
'flex flex-row flex-nowrap items-center',
'gap-1.5 w-full px-2.5 overflow-x-auto overflow-y-hidden',
'-webkit-overflow-scrolling-touch',
].join(' ')}
>
{navItems.map((item) => {
const IconComponent = item.icon;
const active = isActive(item.path, item.exact);
return (
<NavItem
<Link
key={item.path}
ref={active ? activeItemRef : undefined}
to={item.path}
isActive={active}
className={clsx(
'flex flex-col items-center no-underline flex-shrink-0',
'px-3 py-2 rounded-[14px] min-w-16 relative overflow-hidden',
'transition-all duration-300',
active
? 'text-[#66bb6a] bg-[linear-gradient(135deg,rgba(102,187,106,0.15),rgba(129,199,132,0.1))] shadow-[0_2px_8px_rgba(102,187,106,0.2),inset_0_1px_0_rgba(255,255,255,0.3)] -translate-y-0.5'
: 'text-gray-500 hover:text-[#66bb6a] hover:-translate-y-[3px]',
)}
>
<IconComponent className="nav-icon" />
<span className="nav-text">{item.text}</span>
</NavItem>
<IconComponent
className={clsx(
'text-xl mb-1 relative z-[1] transition-all duration-300',
active && '[transform:scale(1.15)_rotate(5deg)]',
)}
/>
<span
className={clsx(
'text-[10px] leading-[1.15] whitespace-nowrap relative z-[1]',
active ? 'font-semibold' : 'font-medium',
)}
>
{item.text}
</span>
</Link>
);
})}
</NavList>
</NavigationContainer>
</div>
</nav>
);
};

View File

@@ -1,16 +1,4 @@
import React, { useEffect, useRef, useCallback, useMemo } from 'react';
import styled from 'styled-components';
const ParticleContainer = styled.div`
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 9999;
overflow: hidden;
`;
const ParticleEffect = () => {
const containerRef = useRef(null);
@@ -21,36 +9,30 @@ const ParticleEffect = () => {
'#66bb6a', '#8bc34a', '#cddc39', '#ffeb3b',
'#ffc107', '#ff9800', '#ff5722', '#e91e63',
'#9c27b0', '#673ab7', '#3f51b5', '#2196f3',
'#03a9f4', '#00bcd4', '#009688', '#4caf50'
'#03a9f4', '#00bcd4', '#009688', '#4caf50',
], []);
const createParticle = useCallback((x, y) => {
if (!containerRef.current) return;
const particleCount = Math.random() * 8 + 6; // 6-14个粒子
const particleCount = Math.random() * 8 + 6;
for (let i = 0; i < particleCount; i++) {
const particle = document.createElement('div');
particle.className = 'click-particle';
particle.id = `particle-${particleId.current++}`;
// 随机颜色
const color = colors[Math.floor(Math.random() * colors.length)];
// 随机大小
const size = Math.random() * 8 + 4; // 4-12px
// 随机方向和距离
const size = Math.random() * 8 + 4;
const angle = (Math.PI * 2 * i) / particleCount + Math.random() * 0.5;
const distance = Math.random() * 100 + 50; // 50-150px
const distance = Math.random() * 100 + 50;
const dx = Math.cos(angle) * distance;
const dy = Math.sin(angle) * distance - Math.random() * 50; // 向上偏移
// 设置样式
const dy = Math.sin(angle) * distance - Math.random() * 50;
particle.style.cssText = `
position: absolute;
left: ${x - size/2}px;
top: ${y - size/2}px;
left: ${x - size / 2}px;
top: ${y - size / 2}px;
width: ${size}px;
height: ${size}px;
background: ${color};
@@ -62,10 +44,9 @@ const ParticleEffect = () => {
animation: particleAnimation 1.2s ease-out forwards;
z-index: 9999;
`;
containerRef.current.appendChild(particle);
// 动画结束后移除粒子
setTimeout(() => {
if (particle && particle.parentNode) {
particle.parentNode.removeChild(particle);
@@ -80,9 +61,9 @@ const ParticleEffect = () => {
const ripple = document.createElement('div');
ripple.className = 'click-ripple';
ripple.id = `ripple-${particleId.current++}`;
const color = colors[Math.floor(Math.random() * colors.length)];
ripple.style.cssText = `
position: absolute;
left: ${x}px;
@@ -97,31 +78,21 @@ const ParticleEffect = () => {
z-index: 9998;
transform: translate(-50%, -50%);
`;
// 添加涟漪动画
const style = document.createElement('style');
style.textContent = `
@keyframes rippleAnimation {
0% {
width: 0;
height: 0;
opacity: 1;
}
100% {
width: 100px;
height: 100px;
opacity: 0;
}
}
`;
if (!document.querySelector('#ripple-animation-style')) {
const style = document.createElement('style');
style.id = 'ripple-animation-style';
style.textContent = `
@keyframes rippleAnimation {
0% { width: 0; height: 0; opacity: 1; }
100% { width: 100px; height: 100px; opacity: 0; }
}
`;
document.head.appendChild(style);
}
containerRef.current.appendChild(ripple);
setTimeout(() => {
if (ripple && ripple.parentNode) {
ripple.parentNode.removeChild(ripple);
@@ -130,65 +101,38 @@ const ParticleEffect = () => {
}, [colors]);
const handleClick = useCallback((event) => {
const x = event.clientX;
const y = event.clientY;
// 创建粒子效果
createParticle(x, y);
// 创建涟漪效果
createRipple(x, y);
createParticle(event.clientX, event.clientY);
createRipple(event.clientX, event.clientY);
}, [createParticle, createRipple]);
useEffect(() => {
// 添加全局点击监听器
document.addEventListener('click', handleClick);
// 添加粒子动画样式
const style = document.createElement('style');
style.id = 'particle-animation-style';
style.textContent = `
@keyframes particleAnimation {
0% {
transform: translate(0, 0) scale(1);
opacity: 1;
}
100% {
transform: translate(var(--dx), var(--dy)) scale(0);
opacity: 0;
}
}
.click-particle {
animation: particleAnimation 1.2s ease-out forwards;
}
.click-ripple {
animation: rippleAnimation 0.8s ease-out forwards;
}
`;
if (!document.querySelector('#particle-animation-style')) {
const style = document.createElement('style');
style.id = 'particle-animation-style';
style.textContent = `
@keyframes particleAnimation {
0% { transform: translate(0, 0) scale(1); opacity: 1; }
100% { transform: translate(var(--dx), var(--dy)) scale(0); opacity: 0; }
}
`;
document.head.appendChild(style);
}
return () => {
document.removeEventListener('click', handleClick);
// 清理样式
const existingStyle = document.querySelector('#particle-animation-style');
if (existingStyle) {
existingStyle.remove();
}
const rippleStyle = document.querySelector('#ripple-animation-style');
if (rippleStyle) {
rippleStyle.remove();
}
document.querySelector('#particle-animation-style')?.remove();
document.querySelector('#ripple-animation-style')?.remove();
};
}, [handleClick]);
return <ParticleContainer ref={containerRef} />;
return (
<div
ref={containerRef}
className="fixed inset-0 pointer-events-none z-[9999] overflow-hidden"
/>
);
};
export default ParticleEffect;
export default ParticleEffect;

View File

@@ -0,0 +1,58 @@
import React, { useEffect, useState } from 'react';
const RANDOM_BG_JSON = 'https://randbg.api.smyhub.com/api/random?format=json&mode=auto';
const SESSION_KEY = 'infogenie_randbg_url';
const BG_GAUSSIAN_BLUR = 'calc(40px * 0.1)';
const RandomSiteBackground = () => {
const [url, setUrl] = useState(() => {
try {
return sessionStorage.getItem(SESSION_KEY) || '';
} catch {
return '';
}
});
useEffect(() => {
let cancelled = false;
const apply = (next) => {
if (cancelled || !next) return;
try {
sessionStorage.setItem(SESSION_KEY, next);
} catch { /* ignore */ }
setUrl(next);
};
if (url) return undefined;
(async () => {
try {
const res = await fetch(RANDOM_BG_JSON);
if (!res.ok) return;
const data = await res.json();
const next = typeof data?.url === 'string' ? data.url : '';
apply(next);
} catch { /* 失败时仅显示 FallbackGradient */ }
})();
return () => { cancelled = true; };
}, [url]);
return (
<div aria-hidden className="fixed inset-0 z-0 pointer-events-none overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-br from-[#a8e6cf] via-[#dcedc1] to-[#ffd3a5]" />
<div
className="absolute -top-6 -right-6 -bottom-6 -left-6 bg-cover bg-center transition-opacity duration-[450ms] ease-[ease]"
style={{
backgroundImage: url ? `url(${url})` : 'none',
filter: `blur(${BG_GAUSSIAN_BLUR})`,
transform: 'scale(1.04)',
opacity: url ? 1 : 0,
}}
/>
</div>
);
};
export default RandomSiteBackground;

View File

@@ -1,8 +1,8 @@
const resolveApiUrl = () => {
const env = process.env.NODE_ENV;
const envApiUrl = process.env.REACT_APP_API_URL;
const mode = import.meta.env.MODE;
const envApiUrl = import.meta.env.VITE_API_URL;
if (env === 'production') {
if (mode === 'production') {
if (envApiUrl && envApiUrl.trim() !== '') return envApiUrl;
return 'https://infogenie.api.shumengya.top';
}
@@ -12,10 +12,10 @@ const resolveApiUrl = () => {
};
const resolveAuthUrl = () => {
const env = process.env.NODE_ENV;
const envAuthUrl = process.env.REACT_APP_AUTH_URL;
const mode = import.meta.env.MODE;
const envAuthUrl = import.meta.env.VITE_AUTH_URL;
if (env === 'production') {
if (mode === 'production') {
if (envAuthUrl && envAuthUrl.trim() !== '') return envAuthUrl;
return 'https://auth.shumengya.top';
}
@@ -25,10 +25,10 @@ const resolveAuthUrl = () => {
};
const resolveAuthApiUrl = () => {
const env = process.env.NODE_ENV;
const envAuthApiUrl = process.env.REACT_APP_AUTH_API_URL;
const mode = import.meta.env.MODE;
const envAuthApiUrl = import.meta.env.VITE_AUTH_API_URL;
if (env === 'production') {
if (mode === 'production') {
if (envAuthApiUrl && envAuthApiUrl.trim() !== '') return envAuthApiUrl;
return 'https://auth.api.shumengya.top';
}
@@ -41,7 +41,7 @@ const config = {
API_URL: resolveApiUrl(),
AUTH_URL: resolveAuthUrl(),
AUTH_API_URL: resolveAuthApiUrl(),
DEBUG: process.env.REACT_APP_DEBUG === 'true',
DEBUG: import.meta.env.VITE_DEBUG === 'true',
LOG_LEVEL: 'debug',
};
@@ -59,10 +59,10 @@ export const ENV_CONFIG = {
export const getEnvVar = (key, defaultValue = '') => {
switch (key) {
case 'REACT_APP_API_URL': return ENV_CONFIG.API_URL;
case 'REACT_APP_AUTH_URL': return ENV_CONFIG.AUTH_URL;
case 'REACT_APP_AUTH_API_URL': return ENV_CONFIG.AUTH_API_URL;
default: return process.env[key] || defaultValue;
case 'VITE_API_URL': return ENV_CONFIG.API_URL;
case 'VITE_AUTH_URL': return ENV_CONFIG.AUTH_URL;
case 'VITE_AUTH_API_URL': return ENV_CONFIG.AUTH_API_URL;
default: return defaultValue;
}
};

View File

@@ -0,0 +1,7 @@
/** 与 Go 后端 handler.featureCardSections 一致 */
export const FEATURE_CARD_SECTION = {
API_60S: '60sapi',
SMALLGAME: 'smallgame',
TOOLBOX: 'toolbox',
AIMODEL: 'aimodel',
};

View File

@@ -0,0 +1,39 @@
import { useState, useEffect, useCallback } from 'react';
import { fetchFeatureCardCounts, incrementFeatureCardClick } from '../utils/featureCardClicks';
/**
* @param {string} section FEATURE_CARD_SECTION.*
* @param {boolean} [enabled=true] 为 false 时不请求统计(如未登录页不拉取)
*/
export function useFeatureCardClickStats(section, enabled = true) {
const [counts, setCounts] = useState({});
useEffect(() => {
if (!section || enabled === false) return undefined;
let cancelled = false;
fetchFeatureCardCounts(section).then((c) => {
if (!cancelled) setCounts(c || {});
});
return () => {
cancelled = true;
};
}, [section, enabled]);
const bump = useCallback(
(itemId) => {
if (!itemId || !section || enabled === false) return;
setCounts((prev) => {
const cur = Number(prev[itemId]) || 0;
return { ...prev, [itemId]: cur + 1 };
});
incrementFeatureCardClick(section, itemId).then((serverCount) => {
if (serverCount != null) {
setCounts((prev) => ({ ...prev, [itemId]: serverCount }));
}
});
},
[section, enabled],
);
return { counts, bump };
}

View File

@@ -1,200 +1,89 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { useNavigate, Navigate } from 'react-router-dom';
import styled from 'styled-components';
import toast from 'react-hot-toast';
import { FiShield, FiLogOut, FiMonitor, FiUsers, FiCpu, FiGrid, FiBox, FiTool, FiRefreshCw, FiSave } from 'react-icons/fi';
import { PageWrapper, Container } from '../styles/shared';
import {
FiShield, FiLogOut, FiMonitor, FiUsers, FiCpu, FiGrid, FiBox, FiTool, FiRefreshCw, FiSave,
FiHome, FiRadio, FiLayers, FiActivity,
} from 'react-icons/fi';
import clsx from 'clsx';
import { PageWrapper } from '../styles/shared';
import { ENV_CONFIG } from '../config/env';
import { API_CATEGORIES, TOOLBOX_ITEMS, API_SOURCES } from '../config/Api60sConfig';
import { AI_MODEL_APPS } from '../config/StaticPageConfig';
import { fetchAIModelDisabledSet, filterAIModelApps } from '../utils/aiModelVisibility';
const ADMIN_TOKEN = 'shumengya520';
const AdminHeader = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 0;
border-bottom: 2px solid rgba(255,255,255,0.2);
margin-bottom: 30px;
`;
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>
);
const AdminTitle = styled.h1`
display: flex;
align-items: center;
gap: 12px;
font-size: 24px;
color: #fff;
text-shadow: 0 2px 8px rgba(0,0,0,0.2);
`;
const InfoRow = ({ label, value, valueStyle }) => (
<div className="flex justify-between py-2.5 border-b border-gray-50 last:border-b-0">
<span className="text-gray-500 text-sm">{label}</span>
<span className="text-gray-800 font-semibold text-sm" style={valueStyle}>{value}</span>
</div>
);
const LogoutBtn = styled.button`
display: flex;
align-items: center;
gap: 6px;
padding: 10px 20px;
border: none;
border-radius: 10px;
background: rgba(239,68,68,0.8);
color: #fff;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
&:hover { background: rgba(239,68,68,1); }
`;
const ActionBtn = ({ children, onClick, disabled, type = 'button' }) => (
<button
type={type}
onClick={onClick}
disabled={disabled}
className="flex items-center justify-center gap-2 px-5 py-3.5 border-none rounded-xl bg-gradient-to-br from-[#4ade80] to-[#22c55e] text-white text-sm font-semibold cursor-pointer transition-all hover:-translate-y-0.5 hover:shadow-[0_6px_16px_rgba(74,222,128,0.3)] disabled:opacity-50 disabled:cursor-not-allowed disabled:translate-y-0"
>
{children}
</button>
);
const StatsGrid = styled.div`
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
margin-bottom: 30px;
`;
const SixtyItem = ({ children, htmlFor }) => (
<label
htmlFor={htmlFor}
className="flex items-center gap-2 text-sm text-gray-700 cursor-pointer select-none px-2 py-1.5 rounded-lg bg-gray-50 border border-gray-100 [&_input]:flex-shrink-0 [&_input]:[accent-color:#22c55e]"
>
{children}
</label>
);
const StatCard = styled.div`
background: rgba(255,255,255,0.95);
border-radius: 14px;
padding: 20px;
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
display: flex;
align-items: center;
gap: 16px;
`;
function normalizeHealthPayload(data) {
if (!data || typeof data !== 'object') return null;
const mysql = data.mysql;
const mysqlOk = typeof mysql?.ok === 'boolean'
? mysql.ok
: 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;
return {
overall: data.status || 'unknown',
ts: data.timestamp,
backendApiOk: data.backend_api && typeof data.backend_api === 'object' ? data.backend_api.ok !== false : true,
mysqlOk,
mysqlStatus,
sixty,
};
}
const StatIcon = styled.div`
width: 48px;
height: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
color: #fff;
background: ${({ $bg }) => $bg || 'linear-gradient(135deg, #4ade80, #22c55e)'};
flex-shrink: 0;
`;
const StatusPill = ({ ok, pending, unknown }) => {
if (pending) return <span className="text-xs font-semibold text-gray-400">检测中</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
className={clsx(
'text-xs font-bold px-2 py-0.5 rounded-md',
ok ? 'bg-emerald-100 text-emerald-800' : 'bg-red-100 text-red-700',
)}
>
{ok ? '正常' : '异常'}
</span>
);
};
const StatInfo = styled.div``;
const StatLabel = styled.div`font-size: 13px; color: #6b7280;`;
const StatValue = styled.div`font-size: 22px; font-weight: 700; color: #1f2937;`;
const SectionTitle = styled.h2`
font-size: 18px;
color: #fff;
margin-bottom: 16px;
text-shadow: 0 1px 4px rgba(0,0,0,0.15);
`;
const InfoCard = styled.div`
background: rgba(255,255,255,0.95);
border-radius: 14px;
padding: 24px;
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
margin-bottom: 24px;
`;
const InfoRow = styled.div`
display: flex;
justify-content: space-between;
padding: 10px 0;
border-bottom: 1px solid #f3f4f6;
&:last-child { border-bottom: none; }
`;
const InfoLabel = styled.span`color: #6b7280; font-size: 14px;`;
const InfoValue = styled.span`color: #1f2937; font-weight: 600; font-size: 14px;`;
const ActionGrid = styled.div`
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
margin-bottom: 24px;
`;
const ActionBtn = styled.button`
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 14px 20px;
border: none;
border-radius: 12px;
background: linear-gradient(135deg, #4ade80, #22c55e);
color: #fff;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: transform 0.15s, box-shadow 0.15s;
&:hover { transform: translateY(-2px); box-shadow: 0 6px 16px rgba(74,222,128,0.3); }
&:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
`;
const SixtyHint = styled.p`
font-size: 13px;
color: #6b7280;
line-height: 1.55;
margin: 0 0 12px;
`;
const SixtyCatTitle = styled.h3`
font-size: 15px;
color: #166534;
margin: 18px 0 10px;
&:first-of-type { margin-top: 4px; }
`;
const SixtyGrid = styled.div`
display: grid;
grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
gap: 8px 14px;
`;
const SixtyItem = styled.label`
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #374151;
cursor: pointer;
user-select: none;
padding: 6px 8px;
border-radius: 8px;
background: #f9fafb;
border: 1px solid #f3f4f6;
input { flex-shrink: 0; accent-color: #22c55e; }
`;
const SixtyActions = styled.div`
display: flex;
gap: 12px;
margin-top: 20px;
flex-wrap: wrap;
align-items: center;
`;
const AiField = styled.div`
margin-bottom: 14px;
max-width: 560px;
`;
const AiLabel = styled.label`
display: block;
font-size: 12px;
font-weight: 600;
color: #374151;
margin-bottom: 6px;
`;
const AiInput = styled.input`
width: 100%;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid #e5e7eb;
font-size: 14px;
box-sizing: border-box;
font-family: inherit;
`;
const ADMIN_SECTIONS = [
{ id: 'overview', label: '概览', short: '概览', Icon: FiHome },
{ 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 },
{ id: 'ai-display', label: 'AI 前台展示', short: 'AI 展示', Icon: FiLayers },
];
const AdminPage = () => {
const navigate = useNavigate();
@@ -215,6 +104,16 @@ const AdminPage = () => {
const [disabledAIModels, setDisabledAIModels] = useState([]);
const [loadingAIModels, setLoadingAIModels] = useState(true);
const [savingAIModels, setSavingAIModels] = useState(false);
const [adminSection, setAdminSection] = useState(() => {
try {
const s = localStorage.getItem('infogenie_admin_section');
if (s && ADMIN_SECTIONS.some((x) => x.id === s)) return s;
} catch { /* ignore */ }
return 'overview';
});
const [healthRaw, setHealthRaw] = useState(null);
const [healthLoading, setHealthLoading] = useState(false);
const [healthFetchError, setHealthFetchError] = useState(null);
const disabled60sSet = useMemo(() => new Set(disabled60s), [disabled60s]);
const disabledAIModelsSet = useMemo(() => new Set(disabledAIModels), [disabledAIModels]);
@@ -246,6 +145,24 @@ const AdminPage = () => {
setLoading(false);
}, []);
const fetchAggregateHealth = 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);
setHealthRaw(null);
setHealthFetchError(e.message || '请求失败');
}
setHealthLoading(false);
}, []);
const healthNorm = useMemo(() => normalizeHealthPayload(healthRaw), [healthRaw]);
const loadAiRuntime = useCallback(async () => {
if (localStorage.getItem('admin_token') !== ADMIN_TOKEN) return;
setLoadingAi(true);
@@ -269,6 +186,7 @@ const AdminPage = () => {
}, []);
useEffect(() => { fetchSysInfo(); }, [fetchSysInfo]);
useEffect(() => { fetchAggregateHealth(); }, [fetchAggregateHealth]);
useEffect(() => { load60sConfig(); }, [load60sConfig]);
useEffect(() => { loadAiRuntime(); }, [loadAiRuntime]);
@@ -300,19 +218,12 @@ const AdminPage = () => {
try {
const r = await fetch(`${ENV_CONFIG.API_URL}/api/admin/site/60s-disabled`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-Site-Admin-Token': token,
},
headers: { 'Content-Type': 'application/json', 'X-Site-Admin-Token': token },
body: JSON.stringify({ disabled: disabled60s }),
});
let data = {};
try {
data = await r.json();
} catch { /* ignore */ }
if (!r.ok) {
throw new Error(data.message || data.error || `HTTP ${r.status}`);
}
try { data = await r.json(); } catch { /* ignore */ }
if (!r.ok) throw new Error(data.message || data.error || `HTTP ${r.status}`);
toast.success(`已保存到后端(当前隐藏 ${disabled60s.length} 项)`);
} catch (e) {
toast.error(
@@ -330,10 +241,7 @@ const AdminPage = () => {
try {
const r = await fetch(`${ENV_CONFIG.API_URL}/api/admin/site/ai-runtime`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-Site-Admin-Token': token,
},
headers: { 'Content-Type': 'application/json', 'X-Site-Admin-Token': token },
body: JSON.stringify({
api_base: aiBase.trim(),
api_key: aiKey.trim(),
@@ -357,10 +265,7 @@ const AdminPage = () => {
try {
const r = await fetch(`${ENV_CONFIG.API_URL}/api/admin/site/60s-source`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-Site-Admin-Token': token,
},
headers: { 'Content-Type': 'application/json', 'X-Site-Admin-Token': token },
body: JSON.stringify({ source_id: sixtySrcId }),
});
const data = await r.json().catch(() => ({}));
@@ -396,10 +301,7 @@ const AdminPage = () => {
try {
const r = await fetch(`${ENV_CONFIG.API_URL}/api/admin/site/ai-model-disabled`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-Site-Admin-Token': token,
},
headers: { 'Content-Type': 'application/json', 'X-Site-Admin-Token': token },
body: JSON.stringify({ disabled: disabledAIModels }),
});
const data = await r.json().catch(() => ({}));
@@ -412,18 +314,12 @@ const AdminPage = () => {
};
const setAIModelVisible = (appId, visible) => {
if (visible) {
setDisabledAIModels(prev => prev.filter(id => id !== appId));
} else {
setDisabledAIModels(prev => [...prev, appId]);
}
if (visible) setDisabledAIModels((prev) => prev.filter((id) => id !== appId));
else setDisabledAIModels((prev) => [...prev, appId]);
};
// 所有 Hook 调用完毕后再做条件判断
const storedToken = localStorage.getItem('admin_token');
if (storedToken !== ADMIN_TOKEN) {
return <Navigate to="/" replace />;
}
if (storedToken !== ADMIN_TOKEN) return <Navigate to="/" replace />;
const handleLogout = () => {
localStorage.removeItem('admin_token');
@@ -432,9 +328,7 @@ const AdminPage = () => {
const handleClearBuildCache = () => {
if ('caches' in window) {
caches.keys().then(names => {
names.forEach(name => caches.delete(name));
});
caches.keys().then((names) => { names.forEach((name) => caches.delete(name)); });
}
alert('缓存已清除,请刷新页面');
};
@@ -453,276 +347,324 @@ const AdminPage = () => {
const toolboxCount = TOOLBOX_ITEMS.length;
const aiCount = AI_MODEL_APPS.filter((a) => a.IsShow !== false).length;
const inputCls = 'w-full px-3 py-2.5 rounded-lg border border-gray-200 text-sm box-border font-[inherit] focus:outline-none focus:border-[#4ade80]';
const selectSection = (id) => {
setAdminSection(id);
try {
localStorage.setItem('infogenie_admin_section', id);
} catch { /* ignore */ }
};
const activeMeta = ADMIN_SECTIONS.find((s) => s.id === adminSection) || ADMIN_SECTIONS[0];
return (
<PageWrapper>
<Container>
<AdminHeader>
<AdminTitle><FiShield /> 管理员后台</AdminTitle>
<LogoutBtn onClick={handleLogout}><FiLogOut size={16} /> 退出后台</LogoutBtn>
</AdminHeader>
<div className="max-w-[1400px] mx-auto px-4 pb-10 w-full">
<div className="flex flex-wrap items-center justify-between gap-3 py-5 border-b-2 border-white/20 mb-5">
<h1 className="flex items-center gap-3 text-2xl text-white [text-shadow:0_2px_8px_rgba(0,0,0,0.2)] m-0">
<FiShield /> 管理员后台
</h1>
<button
type="button"
onClick={handleLogout}
className="flex items-center gap-1.5 px-5 py-2.5 border-none rounded-[10px] bg-red-500/80 text-white text-sm font-semibold cursor-pointer transition-colors hover:bg-red-500"
>
<FiLogOut size={16} /> 退出后台
</button>
</div>
<StatsGrid>
<StatCard>
<StatIcon $bg="linear-gradient(135deg, #4ade80, #22c55e)"><FiGrid /></StatIcon>
<StatInfo>
<StatLabel>60sAPI</StatLabel>
<StatValue>{apiCount}</StatValue>
</StatInfo>
</StatCard>
<StatCard>
<StatIcon $bg="linear-gradient(135deg, #60a5fa, #3b82f6)"><FiBox /></StatIcon>
<StatInfo>
<StatLabel>休闲游戏</StatLabel>
<StatValue>{gameCount}</StatValue>
</StatInfo>
</StatCard>
<StatCard>
<StatIcon $bg="linear-gradient(135deg, #a78bfa, #8b5cf6)"><FiTool /></StatIcon>
<StatInfo>
<StatLabel>工具箱</StatLabel>
<StatValue>{toolboxCount}</StatValue>
</StatInfo>
</StatCard>
<StatCard>
<StatIcon $bg="linear-gradient(135deg, #f472b6, #ec4899)"><FiCpu /></StatIcon>
<StatInfo>
<StatLabel>AI 应用</StatLabel>
<StatValue>{aiCount}</StatValue>
</StatInfo>
</StatCard>
<StatCard>
<StatIcon $bg="linear-gradient(135deg, #fbbf24, #f59e0b)"><FiUsers /></StatIcon>
<StatInfo>
<StatLabel>认证中心</StatLabel>
<StatValue>已接入</StatValue>
</StatInfo>
</StatCard>
</StatsGrid>
<SectionTitle>系统信息</SectionTitle>
<InfoCard>
<InfoRow>
<InfoLabel>前端环境</InfoLabel>
<InfoValue>{ENV_CONFIG.ENV || 'development'}</InfoValue>
</InfoRow>
<InfoRow>
<InfoLabel>API 地址</InfoLabel>
<InfoValue>{ENV_CONFIG.API_URL}</InfoValue>
</InfoRow>
<InfoRow>
<InfoLabel>认证中心</InfoLabel>
<InfoValue>{ENV_CONFIG.AUTH_URL || '未配置'}</InfoValue>
</InfoRow>
<InfoRow>
<InfoLabel>认证中心 API</InfoLabel>
<InfoValue>{ENV_CONFIG.AUTH_API_URL || '未配置'}</InfoValue>
</InfoRow>
<InfoRow>
<InfoLabel>后端状态</InfoLabel>
<InfoValue style={{ color: sysInfo ? '#22c55e' : '#ef4444' }}>
{loading ? '检测中...' : sysInfo ? '在线' : '离线'}
</InfoValue>
</InfoRow>
{sysInfo && (
<>
<InfoRow>
<InfoLabel>后端版本</InfoLabel>
<InfoValue>{sysInfo.version || sysInfo.name || '-'}</InfoValue>
</InfoRow>
<InfoRow>
<InfoLabel>后端描述</InfoLabel>
<InfoValue>{sysInfo.description || sysInfo.message || '-'}</InfoValue>
</InfoRow>
</>
)}
<InfoRow>
<InfoLabel>浏览器</InfoLabel>
<InfoValue>{navigator.userAgent.split(' ').slice(-2).join(' ')}</InfoValue>
</InfoRow>
<InfoRow>
<InfoLabel>屏幕分辨率</InfoLabel>
<InfoValue>{window.screen.width} × {window.screen.height}</InfoValue>
</InfoRow>
</InfoCard>
<SectionTitle>快捷操作</SectionTitle>
<ActionGrid>
<ActionBtn onClick={fetchSysInfo} disabled={loading}>
<FiRefreshCw size={16} /> 刷新后端状态
</ActionBtn>
<ActionBtn onClick={handleClearBuildCache}>
<FiMonitor size={16} /> 清除 Service Worker 缓存
</ActionBtn>
<ActionBtn onClick={handleClearLocalStorage}>
<FiBox size={16} /> 清理本地存储
</ActionBtn>
<ActionBtn onClick={() => window.open(ENV_CONFIG.AUTH_URL, '_blank')}>
<FiUsers size={16} /> 打开认证中心
</ActionBtn>
</ActionGrid>
<SectionTitle>AI 应用上游DeepSeek 兼容 OpenAI Chat</SectionTitle>
<InfoCard>
<SixtyHint>
此处配置会写入数据库表 <code>site_ai_runtime</code><strong></strong> <code>ai_config.json</code>
API Base 示例<code>https://api.deepseek.com</code>(后端会自动拼接 <code>/chat/completions</code>)。
AI 应用的<strong>提示词</strong> <code>public/aimodelapp/shared/ai-prompts.js</code> <code>/api/aimodelapp/chat</code>
密钥留空表示<strong>不修改</strong> API Key
</SixtyHint>
{loadingAi ? (
<SixtyHint>正在加载</SixtyHint>
) : (
<>
<AiField>
<AiLabel htmlFor="ai-base">API Base URL</AiLabel>
<AiInput
id="ai-base"
value={aiBase}
onChange={(e) => setAiBase(e.target.value)}
placeholder="https://api.deepseek.com"
autoComplete="off"
/>
</AiField>
<AiField>
<AiLabel htmlFor="ai-key">API Key{aiKeySet ? `(已配置:${aiKeyHint}` : ''}</AiLabel>
<AiInput
id="ai-key"
type="password"
value={aiKey}
onChange={(e) => setAiKey(e.target.value)}
placeholder={aiKeySet ? '留空保留原密钥;填写则覆盖' : '必填,保存后仅显示脱敏尾号'}
autoComplete="new-password"
/>
</AiField>
<AiField>
<AiLabel htmlFor="ai-model">默认模型 ID</AiLabel>
<AiInput
id="ai-model"
value={aiModel}
onChange={(e) => setAiModel(e.target.value)}
placeholder="deepseek-chat"
autoComplete="off"
/>
</AiField>
<SixtyActions>
<ActionBtn type="button" onClick={saveAiRuntime} disabled={savingAi}>
<FiSave size={16} /> {savingAi ? '保存中…' : '保存 AI 配置'}
</ActionBtn>
<ActionBtn type="button" onClick={loadAiRuntime} disabled={loadingAi}>
<FiRefreshCw size={16} /> 重新加载
</ActionBtn>
</SixtyActions>
</>
)}
</InfoCard>
<SectionTitle>60s 数据源节点</SectionTitle>
<InfoCard>
<SixtyHint>
选择 60s 类接口的<strong>上游根地址</strong> <code>Api60sConfig.API_SOURCES</code> iframe base
</SixtyHint>
<SixtyGrid style={{ maxWidth: 520 }}>
{API_SOURCES.map((s) => (
<SixtyItem key={s.id} htmlFor={`admin-sixty-${s.id}`}>
<input
type="radio"
id={`admin-sixty-${s.id}`}
name="admin_sixty_src"
checked={sixtySrcId === s.id}
onChange={() => setSixtySrcId(s.id)}
/>
<span>
{s.label}{' '}
<span style={{ color: '#9ca3af', fontSize: 11, fontWeight: 500 }}>
({s.baseUrl.replace(/^https?:\/\//, '')})
</span>
</span>
</SixtyItem>
<div className="flex flex-col md:flex-row gap-4 md:gap-6 md:items-start">
<aside
className={clsx(
'flex-shrink-0 md:w-56',
'flex flex-row md:flex-col gap-1.5',
'overflow-x-auto md:overflow-visible pb-1 md:pb-0',
'-mx-1 px-1 md:mx-0 md:px-0',
'[scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
'md:sticky md:top-[72px] md:self-start',
)}
>
<p className="hidden md:block text-xs font-semibold text-white/70 uppercase tracking-wide mb-2 px-2">
功能分区
</p>
{ADMIN_SECTIONS.map(({ id, short, Icon }) => (
<button
key={id}
type="button"
onClick={() => selectSection(id)}
className={clsx(
'flex items-center gap-2.5 w-full min-w-max md:min-w-0 text-left',
'px-3 py-2.5 rounded-xl text-sm font-medium transition-all border',
'whitespace-nowrap md:whitespace-normal',
adminSection === id
? 'bg-white/95 text-[#166534] border-white shadow-md'
: 'bg-white/15 text-white border-white/25 hover:bg-white/25',
)}
>
<Icon size={18} className="flex-shrink-0 opacity-90" />
<span>{short}</span>
</button>
))}
</SixtyGrid>
<SixtyActions>
<ActionBtn type="button" onClick={save60sSource} disabled={savingSixtySrc}>
<FiSave size={16} /> {savingSixtySrc ? '保存中…' : '保存节点'}
</ActionBtn>
<ActionBtn type="button" onClick={load60sSource}>
<FiRefreshCw size={16} /> 重新加载
</ActionBtn>
</SixtyActions>
</InfoCard>
</aside>
<SectionTitle>60s API 前台展示</SectionTitle>
<InfoCard>
<SixtyHint>
勾选表示<strong>在前台展示</strong> <code>site_60s_disabled</code> URL
保存时需在后端配置环境变量 <code>INFOGENIE_SITE_ADMIN_TOKEN</code> Logo <strong></strong>
</SixtyHint>
{loading60s ? (
<SixtyHint>正在加载配置</SixtyHint>
<main className="flex-1 min-w-0 space-y-1 max-h-[calc(100vh-220px)] md:max-h-[calc(100vh-180px)] overflow-y-auto pr-1 [scrollbar-width:thin]">
<div className="mb-3">
<h2 className="text-lg text-white [text-shadow:0_1px_4px_rgba(0,0,0,0.15)] m-0">
{activeMeta.label}
</h2>
<p className="text-sm text-white/80 m-0 mt-1">
在左侧切换分区窄屏下在顶部横向滑动选择
</p>
</div>
{adminSection === 'overview' && (
<>
<div className="grid grid-cols-[repeat(auto-fit,minmax(200px,1fr))] gap-3 mb-6">
{[
{ icon: FiGrid, bg: 'linear-gradient(135deg,#4ade80,#22c55e)', label: '60sAPI', value: apiCount },
{ icon: FiBox, bg: 'linear-gradient(135deg,#60a5fa,#3b82f6)', label: '休闲游戏', value: gameCount },
{ icon: FiTool, bg: 'linear-gradient(135deg,#a78bfa,#8b5cf6)', label: '工具箱', value: toolboxCount },
{ icon: FiCpu, bg: 'linear-gradient(135deg,#f472b6,#ec4899)', label: 'AI 应用', value: aiCount },
{ icon: FiUsers, bg: 'linear-gradient(135deg,#fbbf24,#f59e0b)', label: '认证中心', value: '已接入' },
].map(({ icon: Icon, bg, label, value }) => (
<div key={label} className="bg-white/95 rounded-[14px] p-5 shadow-[0_4px_12px_rgba(0,0,0,0.08)] flex items-center gap-4">
<div className="w-12 h-12 rounded-xl flex items-center justify-center text-[22px] text-white flex-shrink-0" style={{ background: bg }}>
<Icon />
</div>
<div>
<div className="text-sm text-gray-500">{label}</div>
<div className="text-[22px] font-bold text-gray-800">{value}</div>
</div>
</div>
))}
</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'} />
<InfoRow label="API 地址" value={ENV_CONFIG.API_URL} />
<InfoRow label="认证中心" value={ENV_CONFIG.AUTH_URL || '未配置'} />
<InfoRow label="认证中心 API" value={ENV_CONFIG.AUTH_API_URL || '未配置'} />
<InfoRow label="后端状态" value={loading ? '检测中...' : sysInfo ? '在线' : '离线'} valueStyle={{ color: sysInfo ? '#22c55e' : '#ef4444' }} />
{sysInfo && (
<>
<InfoRow label="后端版本" value={sysInfo.version || sysInfo.name || '-'} />
<InfoRow label="后端描述" value={sysInfo.description || sysInfo.message || '-'} />
</>
)}
<InfoRow label="浏览器" value={navigator.userAgent.split(' ').slice(-2).join(' ')} />
<InfoRow label="屏幕分辨率" value={`${window.screen.width} × ${window.screen.height}`} />
</InfoCard>
<h3 className="text-base text-white mb-3 mt-2 [text-shadow:0_1px_4px_rgba(0,0,0,0.12)]">快捷操作</h3>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3 mb-2">
<ActionBtn onClick={fetchSysInfo} disabled={loading}><FiRefreshCw size={16} /> 刷新后端状态</ActionBtn>
<ActionBtn onClick={handleClearBuildCache}><FiMonitor size={16} /> 清除 SW 缓存</ActionBtn>
<ActionBtn onClick={handleClearLocalStorage}><FiBox size={16} /> 清理本地存储</ActionBtn>
<ActionBtn onClick={() => window.open(ENV_CONFIG.AUTH_URL, '_blank')}><FiUsers size={16} /> 打开认证中心</ActionBtn>
</div>
</>
)}
{adminSection === 'ai-upstream' && (
<InfoCard>
<p className="text-sm text-gray-500 leading-[1.55] mb-3">
此处配置会写入数据库表 <code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded text-gray-700">site_ai_runtime</code><strong></strong> <code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded text-gray-700">ai_config.json</code>
密钥留空表示<strong>不修改</strong> API Key
</p>
{loadingAi ? (
<p className="text-sm text-gray-500">正在加载</p>
) : (
<>
{API_CATEGORIES.map((cat) => (
<div key={cat.id}>
<SixtyCatTitle>{cat.icon} {cat.title}</SixtyCatTitle>
<SixtyGrid>
{cat.items.map((item) => (
<SixtyItem key={item.id}>
<input
type="checkbox"
checked={!disabled60sSet.has(item.id)}
onChange={(e) => setItemVisible(item.id, e.target.checked)}
/>
<span>{item.icon} {item.title}</span>
</SixtyItem>
))}
</SixtyGrid>
{[
{ id: 'ai-base', label: 'API Base URL', type: 'text', value: aiBase, onChange: setAiBase, placeholder: 'https://api.deepseek.com' },
{ id: 'ai-key', label: `API Key${aiKeySet ? `(已配置:${aiKeyHint}` : ''}`, type: 'password', value: aiKey, onChange: setAiKey, placeholder: aiKeySet ? '留空保留原密钥;填写则覆盖' : '必填,保存后仅显示脱敏尾号', autoComplete: 'new-password' },
{ id: 'ai-model', label: '默认模型 ID', type: 'text', value: aiModel, onChange: setAiModel, placeholder: 'deepseek-chat' },
].map(({ id, label, type, value, onChange, placeholder, autoComplete }) => (
<div key={id} className="mb-3.5 max-w-[560px]">
<label htmlFor={id} className="block text-xs font-semibold text-gray-700 mb-1.5">{label}</label>
<input id={id} type={type} value={value} onChange={(e) => onChange(e.target.value)} placeholder={placeholder} autoComplete={autoComplete} className={inputCls} />
</div>
))}
<SixtyActions>
<ActionBtn type="button" onClick={save60sConfig} disabled={saving60s}>
<FiSave size={16} /> {saving60s ? '保存中…' : '保存到后端'}
</ActionBtn>
<ActionBtn type="button" onClick={load60sConfig} disabled={loading60s}>
<FiRefreshCw size={16} /> 重新加载
</ActionBtn>
</SixtyActions>
<div className="flex gap-3 mt-5 flex-wrap items-center">
<ActionBtn onClick={saveAiRuntime} disabled={savingAi}><FiSave size={16} /> {savingAi ? '保存中…' : '保存 AI 配置'}</ActionBtn>
<ActionBtn onClick={loadAiRuntime} disabled={loadingAi}><FiRefreshCw size={16} /> 重新加载</ActionBtn>
</div>
</>
)}
</InfoCard>
</InfoCard>
)}
<SectionTitle>AI 应用前台展示</SectionTitle>
<InfoCard>
<SixtyHint>
勾选表示<strong>在前台展示</strong>AI <code>site_ai_model_disabled</code>
保存时需在后端配置环境变量 <code>INFOGENIE_SITE_ADMIN_TOKEN</code> Logo <strong></strong>
</SixtyHint>
{loadingAIModels ? (
<SixtyHint>正在加载配置</SixtyHint>
) : (
<>
<SixtyGrid>
{AI_MODEL_APPS.map((app) => (
<SixtyItem key={app.id}>
<input
type="checkbox"
checked={!disabledAIModelsSet.has(app.id)}
onChange={(e) => setAIModelVisible(app.id, e.target.checked)}
/>
<span>{app.icon} {app.title}</span>
</SixtyItem>
))}
</SixtyGrid>
<SixtyActions>
<ActionBtn type="button" onClick={saveAIModelConfig} disabled={savingAIModels}>
<FiSave size={16} /> {savingAIModels ? '保存中…' : '保存到后端'}
</ActionBtn>
<ActionBtn type="button" onClick={loadAIModelConfig} disabled={loadingAIModels}>
<FiRefreshCw size={16} /> 重新加载
</ActionBtn>
</SixtyActions>
</>
)}
</InfoCard>
</Container>
{adminSection === 'sixty-node' && (
<InfoCard>
<p className="text-sm text-gray-500 leading-[1.55] mb-3">
选择 60s 类接口的<strong>上游根地址</strong> <code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded text-gray-700">Api60sConfig.API_SOURCES</code>
</p>
<div className="grid grid-cols-[repeat(auto-fill,minmax(210px,1fr))] gap-[8px_14px] max-w-[520px]">
{API_SOURCES.map((s) => (
<SixtyItem key={s.id} htmlFor={`admin-sixty-${s.id}`}>
<input type="radio" id={`admin-sixty-${s.id}`} name="admin_sixty_src" checked={sixtySrcId === s.id} onChange={() => setSixtySrcId(s.id)} />
<span>{s.label} <span className="text-gray-400 text-[11px] font-medium">({s.baseUrl.replace(/^https?:\/\//, '')})</span></span>
</SixtyItem>
))}
</div>
<div className="flex gap-3 mt-5 flex-wrap items-center">
<ActionBtn onClick={save60sSource} disabled={savingSixtySrc}><FiSave size={16} /> {savingSixtySrc ? '保存中…' : '保存节点'}</ActionBtn>
<ActionBtn onClick={load60sSource}><FiRefreshCw size={16} /> 重新加载</ActionBtn>
</div>
</InfoCard>
)}
{adminSection === 'sixty-display' && (
<InfoCard>
<p className="text-sm text-gray-500 leading-[1.55] mb-3">
勾选表示<strong>在前台展示</strong> <code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded text-gray-700">site_60s_disabled</code>
</p>
{loading60s ? (
<p className="text-sm text-gray-500">正在加载配置</p>
) : (
<>
{API_CATEGORIES.map((cat) => (
<div key={cat.id}>
<h3 className="text-[15px] text-[#166534] mt-[18px] mb-2.5 first:mt-1">{cat.icon} {cat.title}</h3>
<div className="grid grid-cols-[repeat(auto-fill,minmax(210px,1fr))] gap-[8px_14px]">
{cat.items.map((item) => (
<SixtyItem key={item.id}>
<input type="checkbox" checked={!disabled60sSet.has(item.id)} onChange={(e) => setItemVisible(item.id, e.target.checked)} />
<span>{item.icon} {item.title}</span>
</SixtyItem>
))}
</div>
</div>
))}
<div className="flex gap-3 mt-5 flex-wrap items-center">
<ActionBtn onClick={save60sConfig} disabled={saving60s}><FiSave size={16} /> {saving60s ? '保存中…' : '保存到后端'}</ActionBtn>
<ActionBtn onClick={load60sConfig} disabled={loading60s}><FiRefreshCw size={16} /> 重新加载</ActionBtn>
</div>
</>
)}
</InfoCard>
)}
{adminSection === 'ai-display' && (
<InfoCard>
<p className="text-sm text-gray-500 leading-[1.55] mb-3">
勾选表示<strong>在前台展示</strong>AI <code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded text-gray-700">site_ai_model_disabled</code>
</p>
{loadingAIModels ? (
<p className="text-sm text-gray-500">正在加载配置</p>
) : (
<>
<div className="grid grid-cols-[repeat(auto-fill,minmax(210px,1fr))] gap-[8px_14px]">
{AI_MODEL_APPS.map((app) => (
<SixtyItem key={app.id}>
<input type="checkbox" checked={!disabledAIModelsSet.has(app.id)} onChange={(e) => setAIModelVisible(app.id, e.target.checked)} />
<span>{app.icon} {app.title}</span>
</SixtyItem>
))}
</div>
<div className="flex gap-3 mt-5 flex-wrap items-center">
<ActionBtn onClick={saveAIModelConfig} disabled={savingAIModels}><FiSave size={16} /> {savingAIModels ? '保存中…' : '保存到后端'}</ActionBtn>
<ActionBtn onClick={loadAIModelConfig} disabled={loadingAIModels}><FiRefreshCw size={16} /> 重新加载</ActionBtn>
</div>
</>
)}
</InfoCard>
)}
</main>
</div>
</div>
</PageWrapper>
);
};

View File

@@ -1,5 +1,4 @@
import React, { useState, useEffect } from 'react';
import styled from 'styled-components';
import { useUser } from '../contexts/UserContext';
import { AI_MODEL_APPS } from '../config/StaticPageConfig';
import { fetchAIModelDisabledSet, filterAIModelApps } from '../utils/aiModelVisibility';
@@ -11,42 +10,23 @@ import {
PageHeader,
PageTitle,
FeatureGrid,
FeatureCard,
CatalogCard,
FeatureCardIcon,
FeatureCardTitle,
FeatureCardDesc,
FeatureCardUseCount,
accentFromGradient,
} from '../styles/shared';
import { FEATURE_CARD_SECTION } from '../config/featureCardSections';
import { useFeatureCardClickStats } from '../hooks/useFeatureCardClickStats';
import FullscreenEmbed from '../components/FullscreenEmbed';
import LoginRequired from '../components/LoginRequired';
const Grid = styled(FeatureGrid)``;
const Card = styled(FeatureCard)`
&::before {
background: ${({ $gradient }) => $gradient || 'linear-gradient(90deg, #4ade80, #86efac)'};
}
`;
const CardEmoji = styled(FeatureCardIcon)`
font-size: 32px;
background: transparent;
border: none;
width: auto;
height: auto;
`;
const CardTitle = styled(FeatureCardTitle)``;
const CardDesc = styled(FeatureCardDesc)``;
const StatusCard = styled.div`
background: white;
border-radius: 16px;
padding: 60px 40px;
text-align: center;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
margin-bottom: 32px;
`;
const StatusCard = ({ children }) => (
<div className="bg-white rounded-2xl px-10 py-[60px] text-center shadow-[0_2px_8px_rgba(0,0,0,0.06)] mb-8">
{children}
</div>
);
const AiModelPage = () => {
const { isLoggedIn, isLoading } = useUser();
@@ -55,17 +35,17 @@ const AiModelPage = () => {
const [loadingApps, setLoadingApps] = useState(false);
const [error, setError] = useState(null);
const [embeddedApp, setEmbeddedApp] = useState(null);
const { counts, bump } = useFeatureCardClickStats(
FEATURE_CARD_SECTION.AIMODEL,
isLoggedIn && !isLoading,
);
useEffect(() => {
if (isLoggedIn) {
fetchDisabledSet();
}
if (isLoggedIn) fetchDisabledSet();
}, [isLoggedIn]);
useEffect(() => {
if (disabledSet !== null) {
fetchApps();
}
if (disabledSet !== null) fetchApps();
}, [disabledSet]);
const fetchDisabledSet = async () => {
@@ -74,7 +54,7 @@ const AiModelPage = () => {
setDisabledSet(set);
} catch (err) {
console.error('获取AI应用禁用配置失败:', err);
setDisabledSet(new Set()); // 失败时使用空集合,显示所有应用
setDisabledSet(new Set());
}
};
@@ -91,16 +71,12 @@ const AiModelPage = () => {
}
};
const handleLaunchApp = (app) => {
setEmbeddedApp({ ...app, link: app.link });
};
if (isLoading) {
return (
<PageWrapper>
<Container>
<div style={{ textAlign: 'center', padding: '60px 0' }}>
<p style={{ color: '#6b7280' }}>加载中...</p>
<div className="text-center py-[60px]">
<p className="text-gray-500">加载中...</p>
</div>
</Container>
</PageWrapper>
@@ -127,37 +103,45 @@ const AiModelPage = () => {
{loadingApps ? (
<StatusCard>
<div style={{ fontSize: '48px', marginBottom: '16px' }}>🤖</div>
<p style={{ color: '#6b7280' }}>加载 AI 应用中...</p>
<div className="text-5xl mb-4">🤖</div>
<p className="text-gray-500">加载 AI 应用中...</p>
</StatusCard>
) : error ? (
<StatusCard>
<div style={{ fontSize: '48px', marginBottom: '16px' }}>😅</div>
<p style={{ color: '#6b7280' }}>{error}</p>
<button onClick={fetchApps} style={{
background: 'linear-gradient(135deg, #4ade80, #22c55e)',
color: 'white', border: 'none', padding: '8px 16px',
borderRadius: '8px', cursor: 'pointer', marginTop: '12px', fontSize: '13px'
}}>重新加载</button>
<div className="text-5xl mb-4">😅</div>
<p className="text-gray-500">{error}</p>
<button
onClick={fetchApps}
className="bg-gradient-to-br from-[#4ade80] to-[#22c55e] text-white border-none px-4 py-2 rounded-lg cursor-pointer mt-3 text-sm"
>
重新加载
</button>
</StatusCard>
) : apps.length > 0 ? (
<Grid>
<FeatureGrid>
{apps.map((app) => (
<Card key={app.id} $gradient={app.gradient} onClick={() => handleLaunchApp(app)}>
<CardEmoji>{app.icon}</CardEmoji>
<CardTitle>{app.title}</CardTitle>
<CardDesc>{app.description}</CardDesc>
</Card>
<CatalogCard
key={app.id}
$c={accentFromGradient(app.gradient)}
onClick={() => {
bump(app.id);
setEmbeddedApp({ ...app, link: app.link });
}}
>
<FeatureCardUseCount>{Number(counts[app.id]) || 0}</FeatureCardUseCount>
<FeatureCardIcon>{app.icon}</FeatureCardIcon>
<FeatureCardTitle>{app.title}</FeatureCardTitle>
<FeatureCardDesc>{app.description}</FeatureCardDesc>
</CatalogCard>
))}
</Grid>
</FeatureGrid>
) : (
<StatusCard>
<div style={{ fontSize: '48px', marginBottom: '16px' }}>🎯</div>
<p style={{ color: '#6b7280' }}>暂无 AI 应用</p>
<div className="text-5xl mb-4">🎯</div>
<p className="text-gray-500">暂无 AI 应用</p>
</StatusCard>
)}
{embeddedApp && (
<FullscreenEmbed
title={embeddedApp.title}

View File

@@ -67,7 +67,7 @@ const Api60sItemPage = () => {
if (hiddenCheck === null || sixtyBase == null || !embedUrl) {
return (
<PageWrapper>
<p style={{ textAlign: 'center', color: '#fff', padding: '48px 16px', textShadow: '0 1px 4px rgba(0,0,0,0.2)' }}>
<p className="text-center text-white px-4 py-12 [text-shadow:0_1px_4px_rgba(0,0,0,0.2)]">
正在加载
</p>
</PageWrapper>

View File

@@ -3,83 +3,29 @@
* 展示分类标签和 API 卡片网格,点击卡片跳转到对应子页面
*/
import React, { useState, useEffect, useMemo } from 'react';
import styled, { keyframes } from 'styled-components';
import { useNavigate } from 'react-router-dom';
import clsx from 'clsx';
import { API_CATEGORIES } from '../config/Api60sConfig';
import {
PageWrapper,
PageHeader,
PageTitle,
FeatureGrid,
FeatureCard,
CatalogCard,
FeatureCardIcon,
FeatureCardTitle,
FeatureCardDesc,
FeatureCardUseCount,
} from '../styles/shared';
import { FEATURE_CARD_SECTION } from '../config/featureCardSections';
import { useFeatureCardClickStats } from '../hooks/useFeatureCardClickStats';
import { fetch60sDisabledSet, filterApi60sCategories } from '../utils/site60sVisibility';
const fadeUp = keyframes`from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}`;
const Tabs = styled.div`
display: flex;
gap: 10px;
margin: 0 auto 18px;
max-width: 1200px;
padding: 0 20px;
overflow-x: auto;
justify-content: center;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
@media (max-width: 640px) {
justify-content: flex-start;
padding: 0 16px;
gap: 8px;
}
`;
const Tab = styled.button`
flex-shrink: 0;
display: flex;
align-items: center;
gap: 6px;
padding: 10px 20px;
border: 1px solid ${({ $on }) => ($on ? 'transparent' : 'rgba(255,255,255,0.55)')};
border-radius: 12px;
font-size: 14px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
background: ${({ $on, $c }) =>
$on ? `linear-gradient(135deg, ${$c} 0%, #22c55e 100%)` : 'rgba(255,255,255,0.92)'};
color: ${({ $on }) => ($on ? '#fff' : '#166534')};
text-shadow: ${({ $on }) => ($on ? '0 1px 2px rgba(0,0,0,0.12)' : 'none')};
box-shadow: ${({ $on }) =>
$on ? '0 4px 16px rgba(34,197,94,0.35)' : '0 2px 8px rgba(255,255,255,0.5)'};
&:hover {
transform: translateY(-1px);
border-color: ${({ $on }) => ($on ? 'transparent' : 'rgba(129,199,132,0.8)')};
}
@media (max-width: 640px) {
padding: 9px 16px;
font-size: 13px;
border-radius: 10px;
}
`;
const Grid = styled(FeatureGrid)``;
const Card = styled(FeatureCard)`
&::before {
background: linear-gradient(90deg, ${({ $c }) => $c || '#4ade80'}, #86efac);
}
`;
const Api60sPage = () => {
const [catId, setCatId] = useState(API_CATEGORIES[0].id);
const [disabledSet, setDisabledSet] = useState(null);
const navigate = useNavigate();
const { counts, bump } = useFeatureCardClickStats(FEATURE_CARD_SECTION.API_60S);
useEffect(() => {
let cancel = false;
@@ -101,7 +47,7 @@ const Api60sPage = () => {
}
}, [visibleCategories, catId]);
const cat = visibleCategories?.find(c => c.id === catId);
const cat = visibleCategories?.find((c) => c.id === catId);
return (
<PageWrapper>
@@ -110,47 +56,61 @@ const Api60sPage = () => {
</PageHeader>
{disabledSet === null && (
<div style={{
textAlign: 'center',
color: 'rgba(255, 255, 255, 0.9)',
fontSize: '15px',
padding: '40px 16px'
}}>
<div className="text-center text-white/90 text-[15px] py-10 px-4">
正在同步站点配置
</div>
)}
{visibleCategories && visibleCategories.length === 0 && (
<div style={{
textAlign: 'center',
color: 'rgba(255, 255, 255, 0.9)',
fontSize: '15px',
padding: '40px 16px',
opacity: 0.95
}}>
<div className="text-center text-white/90 text-[15px] py-10 px-4 opacity-95">
当前站点暂未开放任何 60s 功能请联系管理员
</div>
)}
{visibleCategories && visibleCategories.length > 0 && (
<>
<Tabs>
{visibleCategories.map(c => (
<Tab key={c.id} $on={catId === c.id} $c={c.color} onClick={() => setCatId(c.id)}>
{/* Tabs */}
<div className="flex gap-2.5 mx-auto mb-[18px] max-w-[1200px] px-5 overflow-x-auto justify-center sm:justify-start sm:px-4 sm:gap-2 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden">
{visibleCategories.map((c) => (
<button
key={c.id}
type="button"
onClick={() => setCatId(c.id)}
className={clsx(
'flex-shrink-0 flex items-center gap-1.5 px-5 py-2.5 rounded-xl text-sm font-semibold font-[inherit] cursor-pointer transition-all whitespace-nowrap border',
'sm:px-4 sm:py-[9px] sm:text-[13px] sm:rounded-[10px]',
catId === c.id
? 'border-transparent text-white [text-shadow:0_1px_2px_rgba(0,0,0,0.12)] shadow-[0_4px_16px_rgba(34,197,94,0.35)] hover:-translate-y-px'
: 'border-white/55 bg-white/92 text-[#166534] hover:-translate-y-px hover:border-[rgba(129,199,132,0.8)]',
)}
style={
catId === c.id
? { background: `linear-gradient(135deg, ${c.color} 0%, #22c55e 100%)` }
: {}
}
>
{c.icon} {c.title}
</Tab>
</button>
))}
</Tabs>
<Grid key={catId}>
{cat?.items.map(item => (
<Card key={item.id} $c={cat.color} onClick={() => navigate(`/60sapi/${item.id}`)}>
</div>
<FeatureGrid key={catId}>
{cat?.items.map((item) => (
<CatalogCard
key={item.id}
$c={cat.color}
onClick={() => {
bump(item.id);
navigate(`/60sapi/${item.id}`);
}}
>
<FeatureCardUseCount>{Number(counts[item.id]) || 0}</FeatureCardUseCount>
<FeatureCardIcon>{item.icon}</FeatureCardIcon>
<FeatureCardTitle>{item.title}</FeatureCardTitle>
<FeatureCardDesc>{item.desc || '点击查看详情'}</FeatureCardDesc>
</Card>
</CatalogCard>
))}
</Grid>
</FeatureGrid>
</>
)}
</PageWrapper>

View File

@@ -1,37 +1,9 @@
import React, { useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import styled from 'styled-components';
import toast from 'react-hot-toast';
import { useUser } from '../contexts/UserContext';
import { PageWrapper } from '../styles/shared';
const CenterWrap = styled(PageWrapper)`
display: flex;
align-items: center;
justify-content: center;
text-align: center;
`;
const Dots = styled.div`
display: flex;
gap: 8px;
justify-content: center;
margin-bottom: 16px;
`;
const Dot = styled.div`
width: 10px; height: 10px;
border-radius: 50%;
background: #81c784;
animation: dotBounce 1.4s ease-in-out infinite;
animation-delay: ${props => props.$delay || '0s'};
@keyframes dotBounce {
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
40% { transform: scale(1.2); opacity: 1; }
}
`;
const AuthCallbackPage = () => {
const navigate = useNavigate();
const { handleSSOCallback, fetchMe, clearSession } = useUser();
@@ -74,16 +46,28 @@ const AuthCallbackPage = () => {
}, [navigate, handleSSOCallback, fetchMe, clearSession]);
return (
<CenterWrap>
<PageWrapper className="flex items-center justify-center text-center">
<div>
<Dots>
<Dot $delay="0s" />
<Dot $delay="0.2s" />
<Dot $delay="0.4s" />
</Dots>
<p style={{ color: '#666', fontSize: '16px' }}>正在登录请稍候...</p>
<div className="flex gap-2 justify-center mb-4">
{[0, 0.2, 0.4].map((delay, i) => (
<div
key={i}
className="w-2.5 h-2.5 rounded-full bg-[#81c784]"
style={{
animation: `dotBounce 1.4s ease-in-out infinite ${delay}s`,
}}
/>
))}
</div>
<p className="text-gray-500 text-base">正在登录请稍候...</p>
</div>
</CenterWrap>
<style>{`
@keyframes dotBounce {
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
40% { transform: scale(1.2); opacity: 1; }
}
`}</style>
</PageWrapper>
);
};

View File

@@ -1,5 +1,4 @@
import React from 'react';
import styled from 'styled-components';
import { FiActivity, FiGrid, FiTool, FiCpu } from 'react-icons/fi';
import {
PageWrapper,
@@ -13,49 +12,71 @@ import {
ModuleTag,
} from '../styles/shared';
const Wrap = styled.div`
max-width:900px;margin:0 auto;padding:0 16px 40px;
@media(max-width:640px){padding:0 12px 32px}
`;
const Hero = styled.div`text-align:center;padding:32px 0 28px;@media(max-width:640px){padding:18px 0 16px}`;
const Title = styled.h1`
font-size:44px;font-weight:800;color:#1f2937;margin:0 0 12px;
text-shadow:0 2px 8px rgba(0,0,0,0.1);letter-spacing:0.02em;
@media(max-width:640px){font-size:30px;margin-bottom:10px}
`;
const Grid = styled(ModuleGrid)``;
const modules = [
{ path: '/60sapi', icon: FiActivity, title: '60s API', desc: '热搜榜单 · 资讯 · 实用工具', bg: 'linear-gradient(135deg,#22c55e,#4ade80)', tags: ['资讯', '榜单', '工具', '娱乐'] },
{ path: '/smallgame', icon: FiGrid, title: '休闲游戏', desc: '经典小游戏,即点即玩', bg: 'linear-gradient(135deg,#3b82f6,#60a5fa)', tags: ['2048', '俄罗斯方块', '贪吃蛇'] },
{ path: '/toolbox', icon: FiTool, title: '工具箱', desc: '本地静态小工具,部分支持 PWA 离线', bg: 'linear-gradient(135deg,#f59e0b,#fbbf24)', tags: ['工具', '记事本', 'JSON'] },
{ path: '/aimodel', icon: FiCpu, title: 'AI 应用', desc: '大语言模型智能应用', bg: 'linear-gradient(135deg,#8b5cf6,#a78bfa)', tags: ['翻译', '写作', '对话'] },
{
path: '/60sapi',
icon: FiActivity,
title: '60s API',
desc: '热搜榜单 · 资讯 · 实用工具',
bg: 'linear-gradient(135deg,#22c55e,#4ade80)',
tags: ['资讯', '榜单', '工具', '娱乐'],
},
{
path: '/smallgame',
icon: FiGrid,
title: '休闲游戏',
desc: '经典小游戏,即点即玩',
bg: 'linear-gradient(135deg,#3b82f6,#60a5fa)',
tags: ['2048', '俄罗斯方块', '贪吃蛇'],
},
{
path: '/toolbox',
icon: FiTool,
title: '工具箱',
desc: '本地静态小工具,部分支持 PWA 离线',
bg: 'linear-gradient(135deg,#f59e0b,#fbbf24)',
tags: ['工具', '记事本', 'JSON'],
},
{
path: '/aimodel',
icon: FiCpu,
title: 'AI 应用',
desc: '大语言模型智能应用',
bg: 'linear-gradient(135deg,#8b5cf6,#a78bfa)',
tags: ['翻译', '写作', '对话'],
},
];
const HomePage = () => (
<PageWrapper>
<Wrap>
<Hero>
<Title>万象口袋</Title>
</Hero>
<Grid>
{modules.map(m => {
<div className="max-w-[900px] mx-auto px-4 pb-10 sm:px-3 sm:pb-8">
<div className="text-center pt-8 pb-7 sm:pt-[18px] sm:pb-4">
<h1 className="text-[44px] sm:text-[30px] font-extrabold text-gray-800 mb-0 [text-shadow:0_2px_8px_rgba(0,0,0,0.1)] tracking-[0.02em]">
万象口袋
</h1>
</div>
<ModuleGrid>
{modules.map((m) => {
const Icon = m.icon;
return (
<ModuleCard key={m.path} to={m.path}>
<ModuleIconBox $bg={m.bg}><Icon /></ModuleIconBox>
<ModuleIconBox $bg={m.bg}>
<Icon />
</ModuleIconBox>
<ModuleInfo>
<ModuleTitle>{m.title}</ModuleTitle>
<ModuleDesc>{m.desc}</ModuleDesc>
<ModuleTags>{m.tags.map(t => <ModuleTag key={t}>{t}</ModuleTag>)}</ModuleTags>
<ModuleTags>
{m.tags.map((t) => (
<ModuleTag key={t}>{t}</ModuleTag>
))}
</ModuleTags>
</ModuleInfo>
</ModuleCard>
);
})}
</Grid>
</Wrap>
</ModuleGrid>
</div>
</PageWrapper>
);

View File

@@ -1,111 +1,58 @@
import React from 'react';
import styled from 'styled-components';
import { FiLogIn, FiShield } from 'react-icons/fi';
import { ENV_CONFIG } from '../config/env';
import { PageWrapper } from '../styles/shared';
const logoSrc = `${process.env.PUBLIC_URL}/assets/logo.png`;
const LoginWrapper = styled(PageWrapper)`
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
`;
const LoginCard = styled.div`
background: rgba(255,255,255,0.95);
border-radius: 20px;
padding: 48px 40px;
box-shadow: 0 15px 35px rgba(168,230,207,0.3);
width: 100%;
max-width: 440px;
backdrop-filter: blur(15px);
border: 1px solid rgba(168,230,207,0.3);
text-align: center;
`;
const LogoWrap = styled.div`
margin-bottom: 24px;
img {
width: 72px; height: 72px;
border-radius: 16px;
box-shadow: 0 4px 16px rgba(129,199,132,0.3);
}
`;
const Title = styled.h1`
color: #2e7d32;
font-size: 28px;
font-weight: 700;
margin-bottom: 8px;
`;
const Subtitle = styled.p`
color: #666;
font-size: 15px;
margin-bottom: 32px;
line-height: 1.6;
`;
const SSOButton = styled.a`
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
width: 100%;
padding: 16px 24px;
background: linear-gradient(135deg, #81c784 0%, #66bb6a 100%);
color: white;
border: none;
border-radius: 14px;
font-size: 17px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
text-decoration: none;
box-shadow: 0 4px 20px rgba(129,199,132,0.3);
&:hover {
transform: translateY(-2px);
box-shadow: 0 8px 30px rgba(129,199,132,0.4);
}
`;
const InfoRow = styled.div`
margin-top: 24px;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
color: #999;
font-size: 13px;
`;
const logoSrc = '/assets/logo.png';
const LoginPage = () => {
const callbackUrl = `${window.location.origin}/auth/callback`;
const ssoUrl = `${ENV_CONFIG.AUTH_URL}/?redirect_uri=${encodeURIComponent(callbackUrl)}&client_id=${ENV_CONFIG.CLIENT_ID}&client_name=${encodeURIComponent(ENV_CONFIG.CLIENT_NAME)}`;
return (
<LoginWrapper>
<LoginCard>
<LogoWrap>
<img src={logoSrc} alt="万象口袋" />
</LogoWrap>
<Title>欢迎使用万象口袋</Title>
<Subtitle>
<PageWrapper className="flex items-center justify-center p-5">
<div
className={[
'bg-white/95 rounded-[20px] px-10 py-12 sm:px-8',
'shadow-[0_15px_35px_rgba(168,230,207,0.3)]',
'w-full max-w-[440px]',
'backdrop-blur-[15px] border border-[rgba(168,230,207,0.3)]',
'text-center',
].join(' ')}
>
<div className="mb-6">
<img
src={logoSrc}
alt="万象口袋"
className="w-[72px] h-[72px] rounded-2xl shadow-[0_4px_16px_rgba(129,199,132,0.3)]"
/>
</div>
<h1 className="text-[#2e7d32] text-[28px] font-bold mb-2">欢迎使用万象口袋</h1>
<p className="text-gray-500 text-[15px] mb-8 leading-relaxed">
使用萌芽统一账户登录一个账号畅享所有萌芽系列应用
</Subtitle>
<SSOButton href={ssoUrl}>
</p>
<a
href={ssoUrl}
className={[
'inline-flex items-center justify-center gap-2.5',
'w-full px-6 py-4',
'bg-gradient-to-br from-[#81c784] to-[#66bb6a]',
'text-white no-underline',
'border-none rounded-[14px] text-[17px] font-semibold cursor-pointer',
'transition-all duration-300',
'shadow-[0_4px_20px_rgba(129,199,132,0.3)]',
'hover:-translate-y-0.5 hover:shadow-[0_8px_30px_rgba(129,199,132,0.4)]',
].join(' ')}
>
<FiLogIn size={20} />
使用萌芽账户登录
</SSOButton>
<InfoRow>
</a>
<div className="mt-6 flex items-center justify-center gap-1.5 text-gray-400 text-sm">
<FiShield size={14} />
由萌芽账户认证中心提供安全认证
</InfoRow>
</LoginCard>
</LoginWrapper>
</div>
</div>
</PageWrapper>
);
};

View File

@@ -1,5 +1,4 @@
import React, { useState, useEffect } from 'react';
import styled from 'styled-components';
import { SMALL_GAMES } from '../config/StaticPageConfig';
import {
PageWrapper,
@@ -7,26 +6,36 @@ import {
PageHeader,
PageTitle,
FeatureGrid,
FeatureCard,
CatalogCard,
FeatureCardIcon,
FeatureCardTitle,
FeatureCardDesc,
FeatureCardUseCount,
} from '../styles/shared';
import { FEATURE_CARD_SECTION } from '../config/featureCardSections';
import { useFeatureCardClickStats } from '../hooks/useFeatureCardClickStats';
import FullscreenEmbed from '../components/FullscreenEmbed';
import { SmallGameIcon } from '../components/SmallGameIcons';
const StatusBox = ({ children }) => (
<div className="bg-white rounded-2xl px-8 py-12 text-center shadow-[0_4px_12px_rgba(0,0,0,0.08)] mb-8">
{children}
</div>
);
const SmallGamePage = () => {
const [games, setGames] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [embeddedGame, setEmbeddedGame] = useState(null);
const { counts, bump } = useFeatureCardClickStats(FEATURE_CARD_SECTION.SMALLGAME);
useEffect(() => { fetchGames(); }, []);
const fetchGames = async () => {
try {
setLoading(true);
const visibleGames = SMALL_GAMES.filter(game => game.IsShow !== false);
const visibleGames = SMALL_GAMES.filter((game) => game.IsShow !== false);
setGames(visibleGames);
} catch (err) {
console.error('获取游戏列表失败:', err);
@@ -36,10 +45,6 @@ const SmallGamePage = () => {
}
};
const handlePlayGame = (game) => {
setEmbeddedGame({ ...game, link: game.link });
};
return (
<PageWrapper>
<Container>
@@ -48,72 +53,47 @@ const SmallGamePage = () => {
</PageHeader>
{loading ? (
<div style={{
background: 'white',
borderRadius: '16px',
padding: '48px 32px',
textAlign: 'center',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.08)',
marginBottom: '32px'
}}>
<h2 style={{ fontSize: '22px', fontWeight: '700', color: '#1f2937', marginBottom: '12px' }}>加载游戏中...</h2>
<p style={{ color: '#6b7280', fontSize: '15px', lineHeight: '1.6' }}>正在为您准备精彩的小游戏请稍候...</p>
</div>
<StatusBox>
<h2 className="text-[22px] font-bold text-gray-800 mb-3">加载游戏中...</h2>
<p className="text-gray-500 text-[15px] leading-relaxed">正在为您准备精彩的小游戏请稍候...</p>
</StatusBox>
) : error ? (
<div style={{
background: 'white',
borderRadius: '16px',
padding: '48px 32px',
textAlign: 'center',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.08)',
marginBottom: '32px'
}}>
<h2 style={{ fontSize: '22px', fontWeight: '700', color: '#1f2937', marginBottom: '12px' }}>加载失败</h2>
<p style={{ color: '#6b7280', fontSize: '15px', lineHeight: '1.6', marginBottom: '20px' }}>
{error}
<br />
<button
type="button"
onClick={fetchGames}
style={{
background: 'linear-gradient(135deg, #4ade80 0%, #22c55e 100%)',
color: 'white',
border: 'none',
padding: '10px 18px',
borderRadius: '10px',
cursor: 'pointer',
marginTop: '16px',
fontWeight: 600
}}
>
重新加载
</button>
</p>
</div>
<StatusBox>
<h2 className="text-[22px] font-bold text-gray-800 mb-3">加载失败</h2>
<p className="text-gray-500 text-[15px] leading-relaxed mb-5">{error}</p>
<button
type="button"
onClick={fetchGames}
className="bg-gradient-to-br from-[#4ade80] to-[#22c55e] text-white border-none px-[18px] py-2.5 rounded-[10px] cursor-pointer font-semibold"
>
重新加载
</button>
</StatusBox>
) : games.length > 0 ? (
<FeatureGrid>
{games.map((game) => (
<FeatureCard key={game.id} onClick={() => handlePlayGame(game)}>
<CatalogCard
key={game.id}
$c="#4ade80"
onClick={() => {
bump(game.id);
setEmbeddedGame({ ...game, link: game.link });
}}
>
<FeatureCardUseCount>{Number(counts[game.id]) || 0}</FeatureCardUseCount>
<FeatureCardIcon>
<SmallGameIcon gameId={game.id} />
</FeatureCardIcon>
<FeatureCardTitle>{game.title}</FeatureCardTitle>
<FeatureCardDesc>{game.description}</FeatureCardDesc>
</FeatureCard>
</CatalogCard>
))}
</FeatureGrid>
) : (
<div style={{
background: 'white',
borderRadius: '16px',
padding: '48px 32px',
textAlign: 'center',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.08)',
marginBottom: '32px'
}}>
<h2 style={{ fontSize: '22px', fontWeight: '700', color: '#1f2937', marginBottom: '12px' }}>暂无游戏</h2>
<p style={{ color: '#6b7280', fontSize: '15px', lineHeight: '1.6' }}>目前还没有可用的游戏请稍后再来查看</p>
</div>
<StatusBox>
<h2 className="text-[22px] font-bold text-gray-800 mb-3">暂无游戏</h2>
<p className="text-gray-500 text-[15px] leading-relaxed">目前还没有可用的游戏请稍后再来查看</p>
</StatusBox>
)}
{embeddedGame && (

View File

@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import styled, { keyframes } from 'styled-components';
import { FiHardDrive, FiCloud } from 'react-icons/fi';
import clsx from 'clsx';
import { TOOLBOX_CATEGORIES } from '../config/Api60sConfig';
import FullscreenEmbed from '../components/FullscreenEmbed';
import {
@@ -8,114 +8,15 @@ import {
PageHeader,
PageTitle,
FeatureGrid,
FeatureCard,
CatalogCard,
FeatureCardIcon,
FeatureCardTitle,
FeatureCardDesc,
FeatureCardUseCount,
} from '../styles/shared';
import { FEATURE_CARD_SECTION } from '../config/featureCardSections';
import { useFeatureCardClickStats } from '../hooks/useFeatureCardClickStats';
const fadeUp = keyframes`from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}`;
const Tabs = styled.div`
display: flex;
gap: 10px;
margin: 0 auto 18px;
max-width: 1200px;
padding: 0 20px;
overflow-x: auto;
justify-content: center;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
@media (max-width: 640px) {
justify-content: flex-start;
padding: 0 16px;
gap: 8px;
}
`;
const Tab = styled.button`
flex-shrink: 0;
display: flex;
align-items: center;
gap: 6px;
padding: 10px 20px;
border: 1px solid ${({ $on }) => ($on ? 'transparent' : 'rgba(255,255,255,0.55)')};
border-radius: 12px;
font-size: 14px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
background: ${({ $on, $c }) =>
$on ? `linear-gradient(135deg, ${$c} 0%, #22c55e 100%)` : 'rgba(255,255,255,0.92)'};
color: ${({ $on }) => ($on ? '#fff' : '#166534')};
text-shadow: ${({ $on }) => ($on ? '0 1px 2px rgba(0,0,0,0.12)' : 'none')};
box-shadow: ${({ $on }) =>
$on ? '0 4px 16px rgba(34,197,94,0.35)' : '0 2px 8px rgba(255,255,255,0.5)'};
&:hover {
transform: translateY(-1px);
border-color: ${({ $on }) => ($on ? 'transparent' : 'rgba(129,199,132,0.8)')};
}
@media (max-width: 640px) {
padding: 9px 16px;
font-size: 13px;
border-radius: 10px;
}
`;
const Grid = styled(FeatureGrid)``;
const Card = styled(FeatureCard)`
&::before {
background: linear-gradient(90deg, ${({ $c }) => $c || '#4ade80'}, #86efac);
}
`;
const CardIcon = styled(FeatureCardIcon)``;
const CardTitle = styled(FeatureCardTitle)``;
const CardDesc = styled(FeatureCardDesc)``;
/** 右下角:可离线(本地缓存即可用) / 需联网(云端) */
const CardCornerHint = styled.span`
position: absolute;
right: 7px;
bottom: 7px;
width: 30px;
height: 30px;
border-radius: 9px;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
background: ${({ $variant }) =>
$variant === 'offline' ? 'rgba(236, 253, 245, 0.98)' : 'rgba(255, 251, 235, 0.98)'};
color: ${({ $variant }) => ($variant === 'offline' ? '#15803d' : '#c2410c')};
border: 1px solid
${({ $variant }) =>
$variant === 'offline' ? 'rgba(34, 197, 94, 0.28)' : 'rgba(251, 146, 60, 0.35)'};
box-shadow: 0 1px 4px rgba(15, 80, 40, 0.06);
svg {
width: 16px;
height: 16px;
stroke-width: 2.25;
}
@media (max-width: 640px) {
width: 26px;
height: 26px;
right: 5px;
bottom: 5px;
border-radius: 8px;
svg {
width: 14px;
height: 14px;
}
}
`;
/** 根据工具项解析所属分类色(嵌入打开后切换 Tab 仍正确) */
function headerColorForItem(item) {
if (!item) return '#4ade80';
for (const c of TOOLBOX_CATEGORIES) {
@@ -127,6 +28,7 @@ function headerColorForItem(item) {
const ToolboxPage = () => {
const [catId, setCatId] = useState(TOOLBOX_CATEGORIES[0].id);
const [embedItem, setEmbedItem] = useState(null);
const { counts, bump } = useFeatureCardClickStats(FEATURE_CARD_SECTION.TOOLBOX);
const cat = TOOLBOX_CATEGORIES.find((c) => c.id === catId) || TOOLBOX_CATEGORIES[0];
@@ -136,50 +38,69 @@ const ToolboxPage = () => {
<PageTitle>工具箱</PageTitle>
</PageHeader>
<Tabs>
{/* Tabs */}
<div className="flex gap-2.5 mx-auto mb-[18px] max-w-[1200px] px-5 overflow-x-auto justify-center sm:justify-start sm:px-4 sm:gap-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{TOOLBOX_CATEGORIES.map((c) => (
<Tab
<button
key={c.id}
type="button"
$on={catId === c.id}
$c={c.color}
onClick={() => setCatId(c.id)}
className={clsx(
'flex-shrink-0 flex items-center gap-1.5 px-5 py-2.5 rounded-xl text-sm font-semibold font-[inherit] cursor-pointer transition-all whitespace-nowrap border',
'sm:px-4 sm:py-[9px] sm:text-[13px] sm:rounded-[10px]',
catId === c.id
? 'border-transparent text-white [text-shadow:0_1px_2px_rgba(0,0,0,0.12)] shadow-[0_4px_16px_rgba(34,197,94,0.35)] hover:-translate-y-px'
: 'border-white/55 bg-white/92 text-[#166534] hover:-translate-y-px hover:border-[rgba(129,199,132,0.8)]',
)}
style={catId === c.id ? { background: `linear-gradient(135deg, ${c.color} 0%, #22c55e 100%)` } : {}}
>
{c.icon} {c.title}
</Tab>
</button>
))}
</Tabs>
</div>
<Grid key={catId}>
<FeatureGrid key={catId}>
{cat.items.map((item) => (
<Card
<CatalogCard
key={item.id}
$c={cat.color}
onClick={() => setEmbedItem(item)}
onClick={() => {
bump(item.id);
setEmbedItem(item);
}}
>
<CardIcon>{item.icon}</CardIcon>
<CardTitle>{item.title}</CardTitle>
<CardDesc>{item.desc}</CardDesc>
{item.offlineOk ? (
<CardCornerHint
$variant="offline"
title="可离线PWA 缓存后页面与脚本可本地加载(使用网络流/API 时仍需联网)"
aria-label="可离线"
>
<FiHardDrive aria-hidden />
</CardCornerHint>
) : (
<CardCornerHint
$variant="online"
title="需联网:依赖云端服务"
aria-label="需联网"
>
<FiCloud aria-hidden />
</CardCornerHint>
)}
</Card>
<FeatureCardUseCount>{Number(counts[item.id]) || 0}</FeatureCardUseCount>
<FeatureCardIcon>{item.icon}</FeatureCardIcon>
<FeatureCardTitle>{item.title}</FeatureCardTitle>
<FeatureCardDesc>{item.desc}</FeatureCardDesc>
{/* 右下角 离线/联网 标识 */}
<span
className={clsx(
'absolute right-[7px] bottom-[7px] w-[30px] h-[30px] rounded-[9px]',
'flex items-center justify-center pointer-events-none',
'border shadow-[0_1px_4px_rgba(15,80,40,0.06)]',
'sm:w-[26px] sm:h-[26px] sm:right-[5px] sm:bottom-[5px] sm:rounded-[8px]',
item.offlineOk
? 'bg-[rgba(236,253,245,0.98)] text-[#15803d] border-[rgba(34,197,94,0.28)]'
: 'bg-[rgba(255,251,235,0.98)] text-[#c2410c] border-[rgba(251,146,60,0.35)]',
)}
title={
item.offlineOk
? '可离线PWA 缓存后页面与脚本可本地加载(使用网络流/API 时仍需联网)'
: '需联网:依赖云端服务'
}
aria-label={item.offlineOk ? '可离线' : '需联网'}
>
{item.offlineOk ? (
<FiHardDrive className="w-4 h-4 sm:w-3.5 sm:h-3.5" strokeWidth={2.25} aria-hidden />
) : (
<FiCloud className="w-4 h-4 sm:w-3.5 sm:h-3.5" strokeWidth={2.25} aria-hidden />
)}
</span>
</CatalogCard>
))}
</Grid>
</FeatureGrid>
{embedItem && (
<FullscreenEmbed

View File

@@ -1,401 +1,14 @@
import React, { useState } from 'react';
import styled, { keyframes } from 'styled-components';
import { useUser } from '../contexts/UserContext';
import {
FiUser, FiStar, FiAward, FiMail,
FiGlobe, FiClock, FiMapPin, FiEdit3, FiExternalLink,
FiGift, FiCalendar, FiShield
FiGift, FiCalendar, FiShield,
} from 'react-icons/fi';
import { ENV_CONFIG } from '../config/env';
import { PageWrapper } from '../styles/shared';
import LoginRequired from '../components/LoginRequired';
const shimmer = keyframes`
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
`;
const float = keyframes`
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-6px); }
`;
const Container = styled.div`
max-width: 920px;
margin: 0 auto;
padding: 0 16px 48px;
`;
const HeroSection = styled.div`
position: relative;
background: linear-gradient(135deg, #065f46 0%, #047857 30%, #059669 60%, #34d399 100%);
border-radius: 24px;
padding: 40px 32px 32px;
margin-bottom: 24px;
overflow: hidden;
box-shadow: 0 12px 40px rgba(5,150,105,0.3);
&::before {
content: '';
position: absolute;
top: -50%;
right: -20%;
width: 300px;
height: 300px;
border-radius: 50%;
background: rgba(255,255,255,0.06);
}
&::after {
content: '';
position: absolute;
bottom: -30%;
left: -10%;
width: 200px;
height: 200px;
border-radius: 50%;
background: rgba(255,255,255,0.04);
}
@media (max-width: 768px) {
padding: 28px 20px 24px;
border-radius: 20px;
}
`;
const HeroTop = styled.div`
display: flex;
align-items: center;
gap: 24px;
position: relative;
z-index: 1;
@media (max-width: 480px) {
flex-direction: column;
text-align: center;
}
`;
const AvatarWrap = styled.div`
flex-shrink: 0;
width: 96px;
height: 96px;
border-radius: 50%;
background: rgba(255,255,255,0.2);
padding: 4px;
animation: ${float} 4s ease-in-out infinite;
@media (max-width: 480px) {
width: 80px;
height: 80px;
}
`;
const AvatarInner = styled.div`
width: 100%;
height: 100%;
border-radius: 50%;
background: linear-gradient(135deg, #81c784, #a5d6a7);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 36px;
overflow: hidden;
@media (max-width: 480px) { font-size: 28px; }
`;
const AvatarImg = styled.img`
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 50%;
`;
const HeroInfo = styled.div`
flex: 1;
min-width: 0;
`;
const HeroName = styled.h1`
color: #fff;
font-size: 28px;
font-weight: 700;
margin: 0 0 4px;
text-shadow: 0 2px 8px rgba(0,0,0,0.15);
@media (max-width: 480px) { font-size: 22px; }
`;
const HeroAccount = styled.div`
color: rgba(255,255,255,0.7);
font-size: 14px;
margin-bottom: 8px;
`;
const HeroBio = styled.p`
color: rgba(255,255,255,0.85);
font-size: 14px;
line-height: 1.6;
margin: 0;
max-width: 400px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
@media (max-width: 480px) { max-width: 100%; }
`;
const HeroActions = styled.div`
display: flex;
gap: 10px;
margin-top: 20px;
position: relative;
z-index: 1;
@media (max-width: 480px) { justify-content: center; }
`;
const HeroBtn = styled.a`
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 18px;
border-radius: 10px;
font-size: 13px;
font-weight: 600;
text-decoration: none;
cursor: pointer;
transition: all 0.2s;
background: ${({ $primary }) => $primary ? 'rgba(255,255,255,0.95)' : 'rgba(255,255,255,0.15)'};
color: ${({ $primary }) => $primary ? '#059669' : '#fff'};
border: 1px solid ${({ $primary }) => $primary ? 'transparent' : 'rgba(255,255,255,0.25)'};
&:hover {
transform: translateY(-1px);
background: ${({ $primary }) => $primary ? '#fff' : 'rgba(255,255,255,0.25)'};
}
`;
/* 桌面一行 6 格;手机 3 列×2 行 */
const StatsRow = styled.div`
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 12px;
margin-bottom: 28px;
@media (max-width: 899px) {
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
`;
const StatCard = styled.div`
background: rgba(255,255,255,0.97);
border-radius: 14px;
padding: 12px 6px 10px;
text-align: center;
box-shadow: 0 2px 12px rgba(0,0,0,0.05);
border: 1px solid rgba(168,230,207,0.22);
transition: transform 0.2s ease, box-shadow 0.2s ease;
min-height: 0;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
&:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0,0,0,0.08);
}
@media (min-width: 900px) {
padding: 14px 8px 12px;
border-radius: 16px;
}
@media (max-width: 899px) {
padding: 12px 6px 10px;
border-radius: 12px;
}
`;
const StatIconWrap = styled.div`
width: 34px;
height: 34px;
border-radius: 9px;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 6px;
font-size: 15px;
color: #fff;
flex-shrink: 0;
background: ${({ $bg }) => $bg || 'linear-gradient(135deg,#4ade80,#22c55e)'};
@media (min-width: 900px) {
width: 36px;
height: 36px;
font-size: 16px;
margin-bottom: 8px;
}
`;
const StatValue = styled.div`
font-size: 17px;
font-weight: 700;
color: #1f2937;
margin-bottom: 1px;
line-height: 1.15;
@media (min-width: 900px) {
font-size: 18px;
}
`;
const StatLabel = styled.div`
font-size: 10px;
color: #94a3b8;
line-height: 1.35;
margin: 0 auto;
padding: 0 2px;
letter-spacing: 0.02em;
`;
const StatsSectionHead = styled.div`
font-size: 13px;
font-weight: 700;
color: #14532d;
margin-bottom: 12px;
padding-left: 4px;
letter-spacing: 0.04em;
opacity: 0.92;
@media (max-width: 480px) {
font-size: 12px;
margin-bottom: 10px;
}
`;
const SectionCard = styled.div`
background: rgba(255,255,255,0.97);
border-radius: 20px;
padding: 26px 28px 28px;
margin-bottom: 22px;
box-shadow: 0 4px 20px rgba(0,0,0,0.05);
border: 1px solid rgba(168,230,207,0.18);
@media (max-width: 768px) { padding: 20px 18px 22px; border-radius: 16px; }
`;
const SectionTitle = styled.h3`
font-size: 18px;
font-weight: 700;
color: #1f2937;
margin: 0 0 20px;
display: flex;
align-items: center;
gap: 10px;
padding-bottom: 14px;
border-bottom: 1px solid #eef2f7;
`;
const InfoGrid = styled.div`
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
@media (max-width: 640px) {
grid-template-columns: 1fr;
}
`;
const InfoItem = styled.div`
display: flex;
align-items: flex-start;
gap: 14px;
padding: 14px 16px;
background: linear-gradient(180deg, #fafbfc 0%, #f4f6f8 100%);
border-radius: 14px;
border: 1px solid #eef2f7;
transition: box-shadow 0.2s ease, border-color 0.2s ease;
&:hover {
border-color: #d8e8dc;
box-shadow: 0 2px 10px rgba(0,0,0,0.04);
}
`;
const InfoIcon = styled.div`
width: 40px;
height: 40px;
border-radius: 11px;
display: flex;
align-items: center;
justify-content: center;
font-size: 17px;
flex-shrink: 0;
margin-top: 2px;
background: ${({ $color }) => $color || '#f0fdf4'};
color: ${({ $textColor }) => $textColor || '#16a34a'};
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
`;
const InfoContent = styled.div`
min-width: 0;
`;
const InfoLabel = styled.div`
font-size: 11px;
color: #9ca3af;
margin-bottom: 4px;
letter-spacing: 0.02em;
`;
const InfoValue = styled.div`
font-size: 14px;
font-weight: 600;
color: #1f2937;
line-height: 1.5;
word-break: break-word;
overflow-wrap: anywhere;
`;
const AuthHint = styled.p`
font-size: 13px;
color: #6b7280;
line-height: 1.65;
margin: 0 0 16px;
code {
font-size: 12px;
background: #f3f4f6;
padding: 2px 6px;
border-radius: 6px;
color: #374151;
}
`;
const CardActionRow = styled.div`
display: flex;
flex-wrap: wrap;
gap: 10px;
`;
const CardLinkBtn = styled.a`
display: inline-flex;
align-items: center;
gap: 6px;
padding: 10px 18px;
border-radius: 10px;
font-size: 13px;
font-weight: 600;
text-decoration: none;
cursor: pointer;
transition: all 0.2s;
background: ${({ $primary }) => $primary ? 'linear-gradient(135deg, #4ade80, #22c55e)' : '#f3f4f6'};
color: ${({ $primary }) => $primary ? '#fff' : '#374151'};
border: 1px solid ${({ $primary }) => $primary ? 'transparent' : '#e5e7eb'};
&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
}
`;
const LoadingSkeleton = styled.div`
height: ${({ $h }) => $h || '200px'};
border-radius: 20px;
background: linear-gradient(90deg, #f3f4f6 25%, #e5e7eb 50%, #f3f4f6 75%);
background-size: 200% 100%;
animation: ${shimmer} 1.5s infinite;
margin-bottom: ${({ $mb }) => $mb || '24px'};
`;
const formatDateTime = (dateStr) => {
if (!dateStr) return '暂无';
try {
@@ -404,6 +17,54 @@ const formatDateTime = (dateStr) => {
} catch { return dateStr; }
};
const SectionCard = ({ children, className = '' }) => (
<div className={`bg-white/[0.97] rounded-[20px] px-7 pt-[26px] pb-7 mb-[22px] shadow-[0_4px_20px_rgba(0,0,0,0.05)] border border-[rgba(168,230,207,0.18)] md:px-5 md:pt-5 md:pb-[22px] md:rounded-2xl ${className}`}>
{children}
</div>
);
const SectionTitle = ({ icon: Icon, children }) => (
<h3 className="text-lg font-bold text-gray-800 mt-0 mb-5 flex items-center gap-2.5 pb-3.5 border-b border-gray-100">
{Icon && <Icon size={18} />}
{children}
</h3>
);
const InfoItem = ({ icon: Icon, iconBg, iconColor, label, value }) => (
<div className="flex items-start gap-3.5 p-3.5 px-4 bg-gradient-to-b from-[#fafbfc] to-[#f4f6f8] rounded-[14px] border border-[#eef2f7] transition-all hover:border-[#d8e8dc] hover:shadow-[0_2px_10px_rgba(0,0,0,0.04)]">
<div
className="w-10 h-10 rounded-[11px] flex items-center justify-center text-[17px] flex-shrink-0 mt-0.5 shadow-[0_1px_3px_rgba(0,0,0,0.04)]"
style={{ background: iconBg, color: iconColor }}
>
<Icon />
</div>
<div className="min-w-0">
<div className="text-[11px] text-gray-400 mb-1 tracking-[0.02em]">{label}</div>
<div className="text-sm font-semibold text-gray-800 leading-[1.5] break-words overflow-wrap-anywhere">{value}</div>
</div>
</div>
);
const StatCard = ({ icon: Icon, iconBg, value, label }) => (
<div className="bg-white/[0.97] rounded-[14px] p-3 text-center shadow-[0_2px_12px_rgba(0,0,0,0.05)] border border-[rgba(168,230,207,0.22)] transition-all hover:-translate-y-0.5 hover:shadow-[0_8px_24px_rgba(0,0,0,0.08)] flex flex-col items-center justify-center min-h-0 min-w-0 md:rounded-2xl md:p-3.5">
<div
className="w-[34px] h-[34px] rounded-[9px] flex items-center justify-center mx-auto mb-1.5 text-[15px] text-white flex-shrink-0 md:w-9 md:h-9 md:text-base md:mb-2"
style={{ background: iconBg }}
>
<Icon />
</div>
<div className="text-[17px] font-bold text-gray-800 mb-px leading-[1.15] md:text-lg">{value}</div>
<div className="text-[10px] text-slate-400 leading-[1.35] mx-auto px-0.5 tracking-[0.02em]">{label}</div>
</div>
);
const LoadingSkeleton = ({ h = '200px', mb = '24px' }) => (
<div
className="rounded-[20px] animate-pulse bg-gradient-to-r from-gray-100 via-gray-200 to-gray-100"
style={{ height: h, marginBottom: mb }}
/>
);
const UserProfilePage = () => {
const { user, isLoggedIn, isLoading } = useUser();
const [avatarErr, setAvatarErr] = useState(false);
@@ -411,11 +72,11 @@ const UserProfilePage = () => {
if (isLoading) {
return (
<PageWrapper>
<Container>
<LoadingSkeleton $h="200px" />
<LoadingSkeleton $h="100px" />
<LoadingSkeleton $h="160px" $mb="0" />
</Container>
<div className="max-w-[920px] mx-auto px-4 pb-12">
<LoadingSkeleton h="200px" />
<LoadingSkeleton h="100px" />
<LoadingSkeleton h="160px" mb="0" />
</div>
</PageWrapper>
);
}
@@ -433,161 +94,138 @@ const UserProfilePage = () => {
return (
<PageWrapper>
<Container>
{/* 个人资料卡片 */}
<HeroSection>
<HeroTop>
<AvatarWrap>
<AvatarInner>
<div className="max-w-[920px] mx-auto px-4 pb-12">
{/* Hero Section */}
<div
className={[
'relative rounded-3xl px-8 pt-10 pb-8 mb-6 overflow-hidden',
'bg-gradient-to-br from-[#065f46] via-[#047857] via-[30%] via-[#059669] via-[60%] to-[#34d399]',
'shadow-[0_12px_40px_rgba(5,150,105,0.3)]',
'md:px-5 md:pt-7 md:pb-6 md:rounded-[20px]',
].join(' ')}
>
<div className="absolute -top-1/2 -right-1/5 w-[300px] h-[300px] rounded-full bg-white/[0.06]" />
<div className="absolute -bottom-[30%] -left-[10%] w-[200px] h-[200px] rounded-full bg-white/[0.04]" />
<div className="flex items-center gap-6 relative z-[1] sm:flex-col sm:text-center">
<div
className="flex-shrink-0 w-24 h-24 rounded-full bg-white/20 p-1 sm:w-20 sm:h-20"
style={{ animation: 'float 4s ease-in-out infinite' }}
>
<div className="w-full h-full rounded-full bg-gradient-to-br from-[#81c784] to-[#a5d6a7] flex items-center justify-center text-white text-4xl overflow-hidden sm:text-3xl">
{user?.avatarUrl && !avatarErr ? (
<AvatarImg src={user.avatarUrl} alt="头像" onError={() => setAvatarErr(true)} />
<img
src={user.avatarUrl}
alt="头像"
className="w-full h-full object-cover rounded-full"
onError={() => setAvatarErr(true)}
/>
) : (
<FiUser />
)}
</AvatarInner>
</AvatarWrap>
<HeroInfo>
<HeroName>{user?.username || user?.account || '用户'}</HeroName>
<HeroAccount>@{user?.account || 'unknown'}</HeroAccount>
{user?.bio && <HeroBio>{user.bio.replace(/[#*`]/g, '')}</HeroBio>}
</HeroInfo>
</HeroTop>
<HeroActions>
<HeroBtn
$primary
</div>
</div>
<div className="flex-1 min-w-0">
<h1 className="text-white text-[28px] font-bold m-0 mb-1 [text-shadow:0_2px_8px_rgba(0,0,0,0.15)] sm:text-[22px]">
{user?.username || user?.account || '用户'}
</h1>
<div className="text-white/70 text-sm mb-2">@{user?.account || 'unknown'}</div>
{user?.bio && (
<p className="text-white/85 text-sm leading-relaxed m-0 max-w-[400px] line-clamp-2 sm:max-w-full">
{user.bio.replace(/[#*`]/g, '')}
</p>
)}
</div>
</div>
<div className="flex gap-2.5 mt-5 relative z-[1] sm:justify-center flex-wrap">
<a
href={`${ENV_CONFIG.AUTH_URL}/profile`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 px-[18px] py-2 rounded-[10px] text-sm font-semibold no-underline cursor-pointer transition-all bg-white/95 text-[#059669] border border-transparent hover:-translate-y-px hover:bg-white"
>
<FiEdit3 size={14} /> 编辑资料
</HeroBtn>
</a>
{user?.websiteUrl && (
<HeroBtn href={user.websiteUrl} target="_blank" rel="noopener noreferrer">
<a
href={user.websiteUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 px-[18px] py-2 rounded-[10px] text-sm font-semibold no-underline cursor-pointer transition-all bg-white/15 text-white border border-white/25 hover:-translate-y-px hover:bg-white/25"
>
<FiExternalLink size={14} /> 个人主页
</HeroBtn>
</a>
)}
</HeroActions>
</HeroSection>
</div>
</div>
<StatsSectionHead>数据概览</StatsSectionHead>
{/* 认证中心统计(与 GET /api/auth/me 字段对齐) */}
<StatsRow>
<StatCard>
<StatIconWrap $bg="linear-gradient(135deg,#fbbf24,#f59e0b)"><FiAward /></StatIconWrap>
<StatValue>{user?.level ?? 0}</StatValue>
<StatLabel>用户等级</StatLabel>
</StatCard>
<StatCard>
<StatIconWrap $bg="linear-gradient(135deg,#4ade80,#22c55e)"><FiStar /></StatIconWrap>
<StatValue>{user?.sproutCoins ?? 0}</StatValue>
<StatLabel>萌芽币余额</StatLabel>
</StatCard>
<StatCard>
<StatIconWrap $bg="linear-gradient(135deg,#38bdf8,#0ea5e9)"><FiCalendar /></StatIconWrap>
<StatValue>{user?.checkInStreak ?? 0}</StatValue>
<StatLabel>连续签到</StatLabel>
</StatCard>
<StatCard>
<StatIconWrap $bg="linear-gradient(135deg,#c084fc,#a855f7)"><FiGift /></StatIconWrap>
<StatValue>{user?.checkInDays ?? 0}</StatValue>
<StatLabel>累计签到天数</StatLabel>
</StatCard>
<StatCard>
<StatIconWrap $bg="linear-gradient(135deg,#a78bfa,#8b5cf6)"><FiClock /></StatIconWrap>
<StatValue>{user?.visitDays ?? 0}</StatValue>
<StatLabel>累计访问天数</StatLabel>
</StatCard>
<StatCard>
<StatIconWrap $bg="linear-gradient(135deg,#fb923c,#f97316)"><FiMapPin /></StatIconWrap>
<StatValue>{user?.visitStreak ?? 0}</StatValue>
<StatLabel>连续访问</StatLabel>
</StatCard>
</StatsRow>
<style>{`@keyframes float { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-6px); } }`}</style>
{/* 账户信息 */}
{/* Stats */}
<div className="text-xs font-bold text-[#14532d] mb-3 pl-1 tracking-[0.04em] opacity-[0.92] sm:text-[12px] sm:mb-2.5">
数据概览
</div>
<div className="grid grid-cols-3 md:grid-cols-6 gap-2.5 md:gap-3 mb-7">
{[
{ icon: FiAward, bg: 'linear-gradient(135deg,#fbbf24,#f59e0b)', value: user?.level ?? 0, label: '用户等级' },
{ icon: FiStar, bg: 'linear-gradient(135deg,#4ade80,#22c55e)', value: user?.sproutCoins ?? 0, label: '萌芽币余额' },
{ icon: FiCalendar, bg: 'linear-gradient(135deg,#38bdf8,#0ea5e9)', value: user?.checkInStreak ?? 0, label: '连续签到' },
{ icon: FiGift, bg: 'linear-gradient(135deg,#c084fc,#a855f7)', value: user?.checkInDays ?? 0, label: '累计签到天数' },
{ icon: FiClock, bg: 'linear-gradient(135deg,#a78bfa,#8b5cf6)', value: user?.visitDays ?? 0, label: '累计访问天数' },
{ icon: FiMapPin, bg: 'linear-gradient(135deg,#fb923c,#f97316)', value: user?.visitStreak ?? 0, label: '连续访问' },
].map((s, i) => (
<StatCard key={i} icon={s.icon} iconBg={s.bg} value={s.value} label={s.label} />
))}
</div>
{/* Account Info */}
<SectionCard>
<SectionTitle><FiUser size={18} />账户信息</SectionTitle>
<InfoGrid>
<InfoItem>
<InfoIcon $color="#eff6ff" $textColor="#3b82f6"><FiMail /></InfoIcon>
<InfoContent>
<InfoLabel>主邮箱</InfoLabel>
<InfoValue title={user?.email}>{user?.email || '未绑定'}</InfoValue>
</InfoContent>
</InfoItem>
<InfoItem>
<InfoIcon $color="#fef3c7" $textColor="#d97706"><FiMail /></InfoIcon>
<InfoContent>
<InfoLabel>辅助邮箱</InfoLabel>
<InfoValue title={Array.isArray(user?.secondaryEmails) ? user.secondaryEmails.join('、') : ''}>
{Array.isArray(user?.secondaryEmails) && user.secondaryEmails.length > 0
? user.secondaryEmails.join('、')
: '未设置'}
</InfoValue>
</InfoContent>
</InfoItem>
<InfoItem>
<InfoIcon $color="#f0fdf4" $textColor="#16a34a"><FiGlobe /></InfoIcon>
<InfoContent>
<InfoLabel>个人网站</InfoLabel>
<InfoValue title={user?.websiteUrl}>{user?.websiteUrl || '未设置'}</InfoValue>
</InfoContent>
</InfoItem>
<InfoItem>
<InfoIcon $color="#f5f3ff" $textColor="#7c3aed"><FiClock /></InfoIcon>
<InfoContent>
<InfoLabel>注册时间</InfoLabel>
<InfoValue>{formatDateTime(user?.createdAt)}</InfoValue>
</InfoContent>
</InfoItem>
<SectionTitle icon={FiUser}>账户信息</SectionTitle>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-1">
<InfoItem icon={FiMail} iconBg="#eff6ff" iconColor="#3b82f6" label="主邮箱" value={user?.email || '未绑定'} />
<InfoItem
icon={FiMail} iconBg="#fef3c7" iconColor="#d97706" label="辅助邮箱"
value={Array.isArray(user?.secondaryEmails) && user.secondaryEmails.length > 0 ? user.secondaryEmails.join('、') : '未设置'}
/>
<InfoItem icon={FiGlobe} iconBg="#f0fdf4" iconColor="#16a34a" label="个人网站" value={user?.websiteUrl || '未设置'} />
<InfoItem icon={FiClock} iconBg="#f5f3ff" iconColor="#7c3aed" label="注册时间" value={formatDateTime(user?.createdAt)} />
{user?.lastVisitDisplayLocation && (
<InfoItem>
<InfoIcon $color="#fff7ed" $textColor="#ea580c"><FiMapPin /></InfoIcon>
<InfoContent>
<InfoLabel>最近访问位置</InfoLabel>
<InfoValue>{user.lastVisitDisplayLocation}</InfoValue>
</InfoContent>
</InfoItem>
<InfoItem icon={FiMapPin} iconBg="#fff7ed" iconColor="#ea580c" label="最近访问位置" value={user.lastVisitDisplayLocation} />
)}
{user?.lastVisitIp && (
<InfoItem>
<InfoIcon $color="#fef2f2" $textColor="#dc2626"><FiGlobe /></InfoIcon>
<InfoContent>
<InfoLabel>最近访问 IP</InfoLabel>
<InfoValue>{user.lastVisitIp}</InfoValue>
</InfoContent>
</InfoItem>
<InfoItem icon={FiGlobe} iconBg="#fef2f2" iconColor="#dc2626" label="最近访问 IP" value={user.lastVisitIp} />
)}
</InfoGrid>
</div>
</SectionCard>
{/* 统一认证中心说明 */}
{/* Auth Center */}
<SectionCard>
<SectionTitle><FiShield size={18} /> 萌芽账户统一认证中心</SectionTitle>
<AuthHint>
<SectionTitle icon={FiShield}>萌芽账户统一认证中心</SectionTitle>
<p className="text-sm text-gray-500 leading-[1.65] mb-4">
您的登录令牌密码修改安全设置等均由<strong>萌芽账户统一认证中心</strong>
万象口袋<strong>不提供签到入口</strong> <code>GET /api/auth/me</code>
</AuthHint>
<CardActionRow>
<CardLinkBtn
$primary
万象口袋<strong>不提供签到入口</strong> <code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded-md text-gray-700">GET /api/auth/me</code>
</p>
<div className="flex flex-wrap gap-2.5">
<a
href={ENV_CONFIG.AUTH_URL}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 px-[18px] py-2.5 rounded-[10px] text-sm font-semibold no-underline cursor-pointer transition-all bg-gradient-to-br from-[#4ade80] to-[#22c55e] text-white border border-transparent hover:-translate-y-px hover:shadow-[0_4px_12px_rgba(0,0,0,0.08)]"
>
<FiExternalLink size={14} /> 打开认证中心首页
</CardLinkBtn>
<CardLinkBtn
</a>
<a
href={`${ENV_CONFIG.AUTH_URL}/profile`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 px-[18px] py-2.5 rounded-[10px] text-sm font-semibold no-underline cursor-pointer transition-all bg-gray-100 text-gray-700 border border-gray-200 hover:-translate-y-px hover:shadow-[0_4px_12px_rgba(0,0,0,0.08)]"
>
<FiEdit3 size={14} /> 管理安全与资料
</CardLinkBtn>
</CardActionRow>
</a>
</div>
</SectionCard>
</Container>
</div>
</PageWrapper>
);
};

View File

@@ -1,23 +0,0 @@
/* 粒子效果样式 */
.click-particle {
will-change: transform, opacity;
animation-timing-function: cubic-bezier(0.25, 0.46, 0.45, 0.94);
filter: blur(0.5px);
transform: translateZ(0);
backface-visibility: hidden;
}
.click-ripple {
will-change: transform, opacity;
animation-timing-function: cubic-bezier(0.25, 0.46, 0.45, 0.94);
transform: translateZ(0);
backface-visibility: hidden;
}
/* 减少动画偏好 */
@media (prefers-reduced-motion: reduce) {
.click-particle,
.click-ripple {
animation-duration: 0.3s;
}
}

View File

@@ -1,44 +1,82 @@
/* 全局样式重置 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
@import "tailwindcss";
@theme {
--animate-page-enter: pageEnter 0.8s ease-out forwards;
--animate-fade-up: fadeUp 0.35s ease-out;
}
html {
font-size: 18px;
-webkit-text-size-adjust: 100%;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@keyframes pageEnter {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
body {
font-family: 'KaiTi', '楷体', 'STKaiti', 'AR PL UKai CN', 'AR PL KaitiM GB',
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
line-height: 1.6;
color: #333;
background: #f5f7fa;
overflow-x: hidden;
scrollbar-width: none;
-ms-overflow-style: none;
@keyframes fadeUp {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
/* 隐藏所有滚动条 */
body::-webkit-scrollbar { display: none; }
* { scrollbar-width: none; -ms-overflow-style: none; }
*::-webkit-scrollbar { display: none; }
@layer base {
* {
margin: 0;
padding: 0;
box-sizing: border-box;
scrollbar-width: none;
-ms-overflow-style: none;
}
/* 移动端适配 */
@media (max-width: 768px) {
html { font-size: 16px; }
*::-webkit-scrollbar {
display: none;
}
html {
font-size: 18px;
-webkit-text-size-adjust: 100%;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
@media (max-width: 768px) {
html { font-size: 16px; }
}
body {
font-family: 'KaiTi', '楷体', 'STKaiti', 'AR PL UKai CN', 'AR PL KaitiM GB',
-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
line-height: 1.6;
color: #333;
background: transparent;
overflow-x: hidden;
}
a { color: inherit; text-decoration: none; }
button { border: none; background: none; cursor: pointer; font-family: inherit; }
input, textarea { border: none; outline: none; font-family: inherit; }
ul, ol { list-style: none; }
img { max-width: 100%; height: auto; }
}
/* 基础元素重置 */
a { color: inherit; text-decoration: none; }
button { border: none; background: none; cursor: pointer; font-family: inherit; }
input, textarea { border: none; outline: none; font-family: inherit; }
ul, ol { list-style: none; }
img { max-width: 100%; height: auto; }
/* 粒子效果样式 */
.click-particle {
will-change: transform, opacity;
animation-timing-function: cubic-bezier(0.25, 0.46, 0.45, 0.94);
filter: blur(0.5px);
transform: translateZ(0);
backface-visibility: hidden;
}
.click-ripple {
will-change: transform, opacity;
animation-timing-function: cubic-bezier(0.25, 0.46, 0.45, 0.94);
transform: translateZ(0);
backface-visibility: hidden;
}
@media (prefers-reduced-motion: reduce) {
.click-particle,
.click-ripple {
animation-duration: 0.3s;
}
}

View File

@@ -1,301 +1,251 @@
import styled, { keyframes } from 'styled-components';
import React from 'react';
import { Link } from 'react-router-dom';
import clsx from 'clsx';
const fadeUp = keyframes`from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}`;
export const PageWrapper = ({ children, className = '', style }) => (
<div className={clsx('min-h-[calc(100vh-140px)] py-5 animate-page-enter', className)} style={style}>
{children}
</div>
);
export const PageWrapper = styled.div`
min-height: calc(100vh - 140px);
padding: 20px 0;
opacity: 0;
transform: translateY(20px);
animation: pageEnter 0.8s ease-out forwards;
position: relative;
export const Container = ({ children, $narrow, $px, className = '', style }) => (
<div
className={clsx('mx-auto', $narrow ? 'max-w-3xl' : 'max-w-[1200px]', className)}
style={{ padding: `0 ${$px || '16px'}`, ...style }}
>
{children}
</div>
);
@keyframes pageEnter {
0% { opacity: 0; transform: translateY(20px); }
100% { opacity: 1; transform: translateY(0); }
}
`;
export const PageHeader = ({ children, className = '', style }) => (
<div className={clsx('text-center mb-10', className)} style={style}>
{children}
</div>
);
export const Container = styled.div`
max-width: ${props => props.$narrow ? '800px' : '1200px'};
margin: 0 auto;
padding: 0 ${props => props.$px || '16px'};
`;
export const PageTitle = ({ children, className = '' }) => (
<h1
className={clsx(
'text-white text-[33.6px] md:text-[40px] font-bold mb-2.5',
'[text-shadow:0_2px_10px_rgba(0,0,0,0.3)]',
className,
)}
>
{children}
</h1>
);
export const PageHeader = styled.div`
text-align: center;
margin-bottom: 40px;
`;
export const PageSubtitle = ({ children, className = '' }) => (
<p className={clsx('text-white/80 text-lg max-w-[600px] mx-auto', className)}>{children}</p>
);
export const PageTitle = styled.h1`
color: white;
font-size: 40px;
font-weight: 700;
margin-bottom: 10px;
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
export const Card = ({ children, $padding, $mb, className = '', style }) => (
<div
className={clsx(
'bg-white/95 rounded-[20px] shadow-[0_8px_32px_rgba(168,230,207,0.3)]',
'backdrop-blur-[10px] border border-[rgba(168,230,207,0.2)]',
className,
)}
style={{ padding: $padding || '32px', marginBottom: $mb || '24px', ...style }}
>
{children}
</div>
);
@media (max-width: 768px) {
font-size: 33.6px;
}
`;
export const ItemGrid = ({ children, $cols = 5, $gap = '16px', $mb = '40px', className = '' }) => (
<div
className={clsx('grid', className)}
style={{ gridTemplateColumns: `repeat(${$cols}, 1fr)`, gap: $gap, marginBottom: $mb }}
>
{children}
</div>
);
export const PageSubtitle = styled.p`
color: rgba(255, 255, 255, 0.8);
font-size: 18px;
max-width: 600px;
margin: 0 auto;
`;
export const FeatureGrid = ({ children, className = '' }) => (
<div
className={clsx(
'grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-5',
'gap-[10px] sm:gap-3 md:gap-4',
'max-w-[1200px] mx-auto px-3 sm:px-5 pb-8',
'animate-fade-up',
className,
)}
>
{children}
</div>
);
export const Card = styled.div`
background: rgba(255, 255, 255, 0.95);
border-radius: 20px;
padding: ${props => props.$padding || '32px'};
box-shadow: 0 8px 32px rgba(168, 230, 207, 0.3);
backdrop-filter: blur(10px);
border: 1px solid rgba(168, 230, 207, 0.2);
margin-bottom: ${props => props.$mb || '24px'};
`;
export const FeatureCard = ({ children, $color, onClick, className = '' }) => (
<div
className={clsx(
'group relative overflow-hidden',
'bg-white/[0.96] rounded-2xl sm:rounded-xl',
'px-3 pt-5 pb-[18px] sm:px-2.5 sm:pt-4 sm:pb-3.5',
'min-h-[140px] sm:min-h-[120px]',
'flex flex-col items-center text-center',
'cursor-pointer transition-all duration-[250ms]',
'shadow-[0_2px_12px_rgba(0,0,0,0.06)] border border-black/[0.04]',
'hover:-translate-y-1 hover:shadow-[0_10px_28px_rgba(0,0,0,0.12)] hover:border-black/[0.08]',
'active:-translate-y-0.5',
className,
)}
onClick={onClick}
>
<div
className="absolute top-0 left-0 right-0 h-[3px] group-hover:h-1 transition-all duration-200 opacity-90 group-hover:opacity-100"
style={{ background: $color || 'linear-gradient(90deg, #4ade80, #86efac)' }}
/>
{children}
</div>
);
export const CatalogCard = ({ children, $c, onClick, className = '' }) => {
const gradient = `linear-gradient(90deg, ${$c || '#4ade80'}, #86efac)`;
return (
<div
className={clsx(
'group relative overflow-hidden',
'bg-white/[0.96] rounded-2xl sm:rounded-xl',
'px-3 pt-5 pb-[18px] sm:px-2.5 sm:pt-4 sm:pb-3.5',
'min-h-[140px] sm:min-h-[120px]',
'flex flex-col items-center text-center',
'cursor-pointer transition-all duration-[250ms]',
'shadow-[0_2px_12px_rgba(0,0,0,0.06)] border border-black/[0.04]',
'hover:-translate-y-1 hover:shadow-[0_10px_28px_rgba(0,0,0,0.12)] hover:border-black/[0.08]',
'active:-translate-y-0.5',
className,
)}
onClick={onClick}
>
<div
className="absolute top-0 left-0 right-0 h-[3px] group-hover:h-1 transition-all duration-200 opacity-90 group-hover:opacity-100"
style={{ background: gradient }}
/>
{children}
</div>
);
};
export const ItemGrid = styled.div`
display: grid;
grid-template-columns: repeat(${props => props.$cols || 5}, 1fr);
gap: ${props => props.$gap || '16px'};
margin-bottom: ${props => props.$mb || '40px'};
export const FeatureCardUseCount = ({ children }) => (
<span className="absolute top-px right-0.5 z-[4] text-sm leading-none text-black pointer-events-none select-none font-normal">
{children}
</span>
);
@media (max-width: 1024px) {
grid-template-columns: repeat(4, 1fr);
gap: 14px;
}
export const FeatureCardIcon = ({ children, $bg, $border, $iconColor, className = '' }) => (
<div
className={clsx(
'text-[32px] mb-2.5 leading-none flex items-center justify-center',
'w-12 h-12 rounded-xl',
'sm:text-[26px] sm:w-10 sm:h-10 sm:rounded-[10px] sm:mb-2',
'[&_svg]:block [&_svg]:w-6 [&_svg]:h-6 sm:[&_svg]:w-5 sm:[&_svg]:h-5',
className,
)}
style={{
background: $bg || 'rgba(74, 222, 128, 0.1)',
border: `1px solid ${$border || 'rgba(74, 222, 128, 0.25)'}`,
color: $iconColor || '#22c55e',
}}
>
{children}
</div>
);
@media (max-width: 768px) {
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
export const FeatureCardTitle = ({ children, className = '' }) => (
<div className={clsx('text-[17px] font-bold text-gray-800 leading-[1.3] mb-2 w-full sm:text-[15px]', className)}>
{children}
</div>
);
@media (max-width: 480px) {
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
`;
export const FeatureCardDesc = ({ children, className = '' }) => (
<div
className={clsx(
'text-sm text-gray-400 leading-[1.4] line-clamp-2 px-1 w-full flex-1 sm:text-xs',
className,
)}
>
{children}
</div>
);
/**
* 统一功能卡片组件 - 用于60sAPI/休闲游戏/工具箱/AI应用四个板块
*/
export const FeatureGrid = styled.div`
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 16px;
max-width: 1200px;
margin: 0 auto;
padding: 0 20px 40px;
animation: ${fadeUp} 0.35s ease-out;
export const FeatureCardTag = ({ children, className = '' }) => (
<span
className={clsx('inline-block text-[10px] text-gray-500 bg-gray-100 px-2 py-0.5 rounded-md mt-2 font-medium', className)}
>
{children}
</span>
);
@media (max-width: 1100px) { grid-template-columns: repeat(4, 1fr); }
@media (max-width: 768px) { grid-template-columns: repeat(3, 1fr); gap: 12px; }
@media (max-width: 480px) { grid-template-columns: repeat(2, 1fr); gap: 10px; padding: 0 12px 32px; }
`;
/** 从 linear-gradient 配置串中提取首色 */
export function accentFromGradient(gradient) {
if (!gradient || typeof gradient !== 'string') return '#4ade80';
const s = gradient.replace(/\s+/g, ' ').trim();
const m = s.match(/#[0-9a-fA-F]{3,8}\b|rgba?\([^)]*\)/);
return m ? m[0] : '#4ade80';
}
export const FeatureCard = styled.div`
background: rgba(255,255,255,0.96);
border-radius: 16px;
padding: 20px 12px 18px;
text-align: center;
cursor: pointer;
transition: all 0.25s ease;
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
border: 1px solid rgba(0,0,0,0.04);
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
align-items: center;
min-height: 140px;
export const ModuleGrid = ({ children, className = '' }) => (
<div
className={clsx(
'grid grid-cols-1 min-[520px]:grid-cols-2',
'gap-3 min-[520px]:gap-3.5',
'max-w-[900px] mx-auto px-3 min-[520px]:px-4',
className,
)}
>
{children}
</div>
);
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: ${({ $color }) => $color || 'linear-gradient(90deg, #4ade80, #86efac)'};
opacity: 0.9;
transition: opacity 0.2s, height 0.2s;
}
export const ModuleCard = ({ children, to, className = '' }) => (
<Link
to={to}
className={clsx(
'flex items-center gap-4 p-5 sm:p-4 sm:gap-3',
'bg-white/95 rounded-2xl',
'no-underline text-inherit',
'shadow-[0_2px_8px_rgba(0,0,0,0.04)] border border-black/[0.04]',
'transition-[transform,box-shadow] duration-200',
'hover:-translate-y-[3px] hover:shadow-[0_6px_20px_rgba(0,0,0,0.08)]',
'active:-translate-y-px',
className,
)}
>
{children}
</Link>
);
&:hover {
transform: translateY(-4px);
box-shadow: 0 10px 28px rgba(0,0,0,0.12);
border-color: rgba(0,0,0,0.08);
&::before { opacity: 1; height: 4px; }
}
export const ModuleIconBox = ({ children, $bg, className = '' }) => (
<div
className={clsx(
'flex-shrink-0 w-12 h-12 rounded-[14px] flex items-center justify-center text-white text-xl',
'sm:w-[42px] sm:h-[42px] sm:rounded-[12px] sm:text-lg',
className,
)}
style={{ background: $bg || 'linear-gradient(135deg,#81c784,#a5d6a7)' }}
>
{children}
</div>
);
&:active { transform: translateY(-2px); }
export const ModuleInfo = ({ children, className = '' }) => (
<div className={clsx('flex-1 min-w-0 text-left', className)}>{children}</div>
);
@media (max-width: 640px) {
padding: 16px 10px 14px;
border-radius: 14px;
min-height: 120px;
}
`;
export const ModuleTitle = ({ children, className = '' }) => (
<div className={clsx('text-base font-bold text-gray-800 mb-[3px] sm:text-sm', className)}>{children}</div>
);
export const FeatureCardIcon = styled.div`
font-size: 32px;
margin-bottom: 10px;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: 12px;
background: ${({ $bg }) => $bg || 'rgba(74, 222, 128, 0.1)'};
border: 1px solid ${({ $border }) => $border || 'rgba(74, 222, 128, 0.25)'};
export const ModuleDesc = ({ children, className = '' }) => (
<div className={clsx('text-xs text-gray-500 leading-[1.4] sm:text-[11px]', className)}>{children}</div>
);
svg {
display: block;
width: 24px;
height: 24px;
color: ${({ $iconColor }) => $iconColor || '#22c55e'};
}
export const ModuleTags = ({ children, className = '' }) => (
<div className={clsx('flex gap-1 flex-wrap mt-1.5', className)}>{children}</div>
);
@media (max-width: 640px) {
font-size: 26px;
width: 40px;
height: 40px;
border-radius: 10px;
margin-bottom: 8px;
svg { width: 20px; height: 20px; }
}
`;
export const FeatureCardTitle = styled.div`
font-size: 17px;
font-weight: 700;
color: #1f2937;
line-height: 1.3;
margin-bottom: 8px;
width: 100%;
@media (max-width: 640px) { font-size: 15px; }
`;
export const FeatureCardDesc = styled.div`
font-size: 14px;
color: #9ca3af;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
padding: 0 4px;
width: 100%;
flex: 1;
@media (max-width: 640px) { font-size: 12px; }
`;
export const FeatureCardTag = styled.span`
display: inline-block;
font-size: 10px;
color: #6b7280;
background: #f3f4f6;
padding: 2px 8px;
border-radius: 6px;
margin-top: 8px;
font-weight: 500;
`;
/**
* 横向卡片 - 用于首页大模块入口
*/
export const ModuleGrid = styled.div`
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 14px;
max-width: 900px;
margin: 0 auto;
padding: 0 16px;
@media (max-width: 520px) { grid-template-columns: 1fr; gap: 12px; padding: 0 12px; }
`;
export const ModuleCard = styled(Link)`
display: flex;
align-items: center;
gap: 16px;
padding: 20px;
background: rgba(255,255,255,0.95);
border-radius: 16px;
text-decoration: none;
color: inherit;
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
border: 1px solid rgba(0,0,0,0.04);
transition: transform 0.2s, box-shadow 0.2s;
&:hover {
transform: translateY(-3px);
box-shadow: 0 6px 20px rgba(0,0,0,0.08);
}
&:active { transform: translateY(-1px); }
@media (max-width: 640px) { padding: 16px; gap: 12px; }
`;
export const ModuleIconBox = styled.div`
flex-shrink: 0;
width: 48px;
height: 48px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
background: ${({ $bg }) => $bg || 'linear-gradient(135deg,#81c784,#a5d6a7)'};
color: #fff;
font-size: 20px;
@media (max-width: 640px) {
width: 42px;
height: 42px;
border-radius: 12px;
font-size: 18px;
}
`;
export const ModuleInfo = styled.div`
flex: 1;
min-width: 0;
text-align: left;
`;
export const ModuleTitle = styled.div`
font-size: 16px;
font-weight: 700;
color: #1f2937;
margin-bottom: 3px;
@media (max-width: 640px) { font-size: 14px; }
`;
export const ModuleDesc = styled.div`
font-size: 12px;
color: #6b7280;
line-height: 1.4;
@media (max-width: 640px) { font-size: 11px; }
`;
export const ModuleTags = styled.div`
display: flex;
gap: 4px;
flex-wrap: wrap;
margin-top: 6px;
`;
export const ModuleTag = styled.span`
font-size: 10px;
color: #6b7280;
background: #f3f4f6;
padding: 2px 6px;
border-radius: 4px;
`;
export const ModuleTag = ({ children, className = '' }) => (
<span className={clsx('text-[10px] text-gray-500 bg-gray-100 px-1.5 py-0.5 rounded', className)}>
{children}
</span>
);

View File

@@ -14,8 +14,7 @@ const authCenterAPI = axios.create({
headers: { 'Content-Type': 'application/json' },
});
// 仅开发环境打印;生产构建绝不输出,避免泄露基础设施地址
if (process.env.NODE_ENV === 'development') {
if (import.meta.env.DEV) {
console.log('🌐 Go Backend URL:', ENV_CONFIG.API_URL);
console.log('🔐 Auth Center URL:', ENV_CONFIG.AUTH_API_URL);
}
@@ -32,6 +31,9 @@ authCenterAPI.interceptors.request.use(addToken, (e) => Promise.reject(e));
api.interceptors.response.use(
(res) => res,
(error) => {
if (error.config?.skipErrorToast) {
return Promise.reject(error);
}
const message = error.response?.data?.message || error.response?.data?.error || '网络错误,请稍后重试';
if (error.response?.status === 401) {
localStorage.removeItem('token');

View File

@@ -0,0 +1,28 @@
import api from './api';
export async function fetchFeatureCardCounts(section) {
try {
const { data } = await api.get('/api/site/feature-card-clicks', {
params: { section },
skipErrorToast: true,
});
return data?.counts && typeof data.counts === 'object' && !Array.isArray(data.counts)
? data.counts
: {};
} catch {
return {};
}
}
export async function incrementFeatureCardClick(section, itemId) {
try {
const { data } = await api.post(
'/api/site/feature-card-clicks/increment',
{ section, item_id: itemId },
{ skipErrorToast: true },
);
return typeof data?.count === 'number' ? data.count : null;
} catch {
return null;
}
}

View File

@@ -0,0 +1,32 @@
import { defineConfig, transformWithEsbuild } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
plugins: [
// Allow JSX in .js files (all files under src/)
{
name: 'treat-js-as-jsx',
async transform(code, id) {
if (!id.match(/\/src\/.*\.js$/)) return null;
return transformWithEsbuild(code, id, { loader: 'jsx' });
},
},
react({ include: '**/*.{jsx,js}' }),
tailwindcss(),
],
resolve: {
alias: { '@': path.resolve(__dirname, './src') },
},
base: '/',
server: { port: 3000 },
optimizeDeps: {
esbuildOptions: {
loader: { '.js': 'jsx' },
},
},
});

View File

@@ -0,0 +1,169 @@
# 万象口袋 — 前端文档
**技术栈**React 18 · React Router 6 · Tailwind CSS v4 · axios · Vite 6
**包名**`infogenie-frontend`(见 `package.json`
---
## 命令
| 命令 | 说明 |
|------|------|
| `npm run dev` / `npm start` | 开发服务器(默认端口 3000 |
| `npm run build` | 生产构建,输出 `dist/` |
| `npm run preview` | 本地预览 `dist/` 构建结果 |
---
## 项目结构(关键文件)
```
infogenie-frontend/
├── index.html ← Vite HTML 入口(根目录,非 public/
├── vite.config.js ← Vite 配置React 插件、Tailwind 插件、路径别名)
├── package.json
├── .env.development ← 开发环境变量
├── .env.production ← 生产环境变量
├── public/ ← 静态资源(不参与构建,直接复制到 dist/
│ ├── assets/logo.png
│ ├── icons/
│ ├── aimodelapp/ ← AI 小应用静态页
│ ├── smallgame/ ← 小游戏静态页
│ └── toolbox/ ← 工具箱静态页
└── src/
├── index.js ← React 根挂载
├── App.js ← 路由、布局、全局 Provider
├── components/ ← 公共组件
├── pages/ ← 页面组件
├── contexts/ ← 全局 Context
├── hooks/ ← 自定义 Hooks
├── utils/ ← Axios 封装、可见性过滤等
├── config/ ← 环境变量解析、路由/内容配置
└── styles/
├── index.css ← Tailwind 入口 + 全局 base 样式 + 自定义动画
└── shared.js ← 设计系统组件Tailwind 函数组件)
```
---
## Vite 配置(`vite.config.js`
| 特性 | 说明 |
|------|------|
| `@vitejs/plugin-react` | React Fast Refresh + JSX 转换(含 `.js` 扩展名支持) |
| `@tailwindcss/vite` | Tailwind CSS v4 Vite 插件,零配置文件 |
| `treat-js-as-jsx` | 内联插件,使 `.js` 文件中的 JSX 正常编译 |
| `resolve.alias['@']` | `@/` 映射到 `src/` |
| `base: '/'` | 部署根路径 |
| `server.port: 3000` | 开发服务器端口 |
---
## 环境变量(`src/config/env.js`
变量名使用 Vite 规范的 `VITE_` 前缀(通过 `import.meta.env` 访问):
| 变量 | 作用 | 开发默认 / 生产默认 |
|------|------|---------------------|
| `VITE_API_URL` | 万象口袋 **Go 后端** 根地址 | dev`http://127.0.0.1:5002`prod`https://infogenie.api.shumengya.top` |
| `VITE_AUTH_URL` | 认证中心 **页面** 域名 | `https://auth.shumengya.top` |
| `VITE_AUTH_API_URL` | 认证中心 **API** 根地址 | `https://auth.api.shumengya.top` |
| `VITE_DEBUG` | 调试开关 | `'true'` 开启 |
运行时可通过 `window.ENV_CONFIG` 查看解析结果。
> **迁移注意**:从 CRA 迁移时原 `REACT_APP_*` 变量已全部重命名为 `VITE_*`。
---
## 主应用结构(`src/`
### 路由(`src/App.js`
| 路径 | 页面 |
|------|------|
| `/` | 首页 |
| `/login` | 登录 |
| `/auth/callback` | 认证回调 |
| `/60sapi` · `/60sapi/:itemId` | 60s API 列表与详情 |
| `/smallgame` | 休闲游戏 |
| `/toolbox` | 工具箱 |
| `/aimodel` | AI 应用(需登录) |
| `/profile` | 个人中心 |
| `/admin` | 管理 |
| `*` | 重定向首页 |
### 关键组件
- **`RandomSiteBackground`**:全站随机背景(`https://randbg.api.smyhub.com/api/random?format=json`),毛玻璃模糊,会话级缓存。
- **`Header` / `Footer`**:半透明绿 + `backdrop-filter`**移动端 `Navigation`** 底栏为不透明渐变 + 圆角顶。
- **`FullscreenEmbed`**:全屏 iframe游戏、工具箱、AI 静态页),支持注入 token、加载超时提示。
- **`ParticleEffect`**:全局点击粒子动画。
---
## 样式系统(`src/styles/`
### Tailwind CSS v4
- **入口**`src/styles/index.css`,顶部 `@import "tailwindcss"`
- **配置**Tailwind v4 CSS-first无独立 `tailwind.config.js`,自定义动画通过 `@theme` 块定义
自定义动画(可直接在 className 中使用):
| 类名 | 效果 | 用途 |
|------|------|------|
| `animate-page-enter` | `opacity: 0→1, translateY: 20px→0, 0.8s` | 页面进入 |
| `animate-fade-up` | `opacity: 0→1, translateY: 12px→0, 0.35s` | 卡片网格出现 |
### 设计系统(`src/styles/shared.js`
所有组件均为 **React 函数组件**,接受 `className` prop可与额外 Tailwind 类合并):
- **`PageWrapper`**:页面容器,带 `animate-page-enter` 入场动画
- **`Container`**:最大宽度容器,`$narrow`800px/ 默认1200px
- **`FeatureGrid`**:响应式 5→4→3→2 列网格,带 `animate-fade-up`
- **`CatalogCard`** / **`FeatureCard`**:统一卡片样式,顶条渐变色动态注入(`$c` prop
- **`FeatureCardUseCount`**:卡片右上角点击次数角标
- **`ModuleCard`**:首页大模块横向卡片(包装 `react-router-dom` `Link`
- **`accentFromGradient`**:工具函数,从 gradient 字符串提取首色
> 动态样式(渐变色、动态 grid 列数等)通过 inline `style` prop 注入Tailwind 负责静态部分。
---
## HTTP`src/utils/api.js`
- axios 实例默认 `baseURL = ENV_CONFIG.API_URL`,请求头自动带 `Bearer token`
- `import.meta.env.DEV` 控制仅开发环境打印 URL
- 支持 `skipErrorToast: true`,用于静默失败场景(如点击统计)
---
## 静态资源(`public/`
与 Vite 主站并列的大量**免构建**页面:
- **`public/aimodelapp/<应用名>/`**AI 小应用HTML + `script.js` + 可选 `env.js`)。
- 统一聊天入口:**`public/aimodelapp/shared/ai-chat.js`** 的 `AiChat.complete()`
- **优先**请求 **`POST /api/aimodelapp/chat/stream`**SSE失败或无内容时回退 **`POST /api/aimodelapp/chat`**。
- **`public/smallgame/`**、**`public/toolbox/`**:独立小游戏与工具页,由 `FullscreenEmbed` 打开。
`aimodelapp` 子目录的 **`env.js`** / **`API_CONFIG`** 需指向与主站一致的 Go 后端地址(通常与 `VITE_API_URL` 同源或同网段)。
---
## 与后端协作要点
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` 接口。
---
## 其他文档
- **Go 后端 API 与表结构**[`infogenie-backend-go/后端文档.md`](../infogenie-backend-go/后端文档.md)
- **仓库与工具说明**[`../.claude/README.md`](../.claude/README.md)