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

18
.claude/README.md Normal file
View File

@@ -0,0 +1,18 @@
# InfoGenie 仓库 — Claude / Cursor 工作区说明
本目录用于存放与 **Cursor / Claude Code** 等工具相关的本地配置(如 `settings.local.json`**不放业务代码**。
## 与文档的关系
| 文档 | 路径 | 内容 |
|------|------|------|
| Go 后端 | [`infogenie-backend-go/后端文档.md`](../infogenie-backend-go/后端文档.md) | API、MySQL、AI 流式、站点配置、功能卡片统计 |
| React 前端 | [`infogenie-frontend/前端文档.md`](../infogenie-frontend/前端文档.md) | 路由、构建、环境变量、静态 AI 小应用 |
## 仓库结构(与开发相关)
- **`infogenie-backend-go/`**:万象口袋 **主后端**Gin + GORM + MySQL。生产环境前端默认请求此服务提供的 API。
- **`infogenie-frontend/`****主站 SPA**Create React App`public/` 下含大量静态页(`aimodelapp/``smallgame/``toolbox/` 等)。
- **`infogenie-backend-java/`**Java 后端(若部署需与本仓库 Go 文档中的接口约定自行对齐)。
给助手或协作者改需求时,优先阅读上表两份 **后端文档 / 前端文档**,再下钻到具体源码路径。

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)

612
README.md
View File

@@ -1,248 +1,472 @@
# InfoGenie 万象口袋
> 跨平台多功能聚合 Web 应用资讯榜单、工具箱、小游戏、AI 工具等。
# InfoGenie 前端架构文档
## 项目概述
InfoGenie 采用**前后端分离**架构:
InfoGenie 是一个基于前后端分离架构的全栈 Web 应用,前端采用 React 单页应用SPA架构结合静态 HTML 页面实现丰富的功能模块。后端提供 RESTful API 接口支持用户认证、数据获取等核心功能。项目实现了移动优先的响应式设计通过统一的组件系统和数据流管理提供了包括API数据展示、小游戏、AI工具等多样化功能模块。
- **前端**React 18CRA+ React Router + Styled ComponentsPWA核心导航在 SPA 内,大量能力以 `public/` 下**静态 HTML** 嵌入(`FullscreenEmbed`)。
- **后端****Go + Gin + GORM + MySQL**提供健康检查、AI 代理等 REST 接口;用户登录与资料以**萌芽账户统一认证中心Sprout Gate**为准,详见仓库根目录 `SPROUT_GATE_API_DOCS.md`
- **个人中心**:仅**只读展示**认证中心同步的资料与统计;**每日签到、改资料、领萌芽币**请在认证中心完成,本站不提供签到按钮。
## 技术栈
### 部署环境(示例)
### 核心框架
- **React 18.2.0**: 前端 UI 框架,使用函数式组件和 Hooks
- **React Router DOM 6.15.0**: 客户端路由管理
- **Axios 1.5.0**: HTTP 客户端,用于后端 API 调用
| 环境 | 地址 |
|------|------|
| 前端 | https://infogenie.shumengya.top |
| 后端 API | https://infogenie.api.shumengya.top |
| 认证中心 | 见前端 `REACT_APP_AUTH_URL` 配置 |
### 样式和 UI
- **styled-components 6.0.7**: CSS-in-JS 样式解决方案
- **react-icons 4.11.0**: 图标库
- **react-hot-toast 2.4.1**: 通知提示组件
---
### 开发工具
- **Create React App**: 项目脚手架
- **ESLint**: 代码规范检查
- **Service Worker**: PWA 支持
## 技术架构
### 前端
| 技术 | 用途 |
|------|------|
| React 18、React Router 6 | UI 与路由 |
| Styled Components | 组件样式 |
| Axios | 请求封装(业务 API + 认证中心 API |
| react-hot-toast | 提示 |
| Service Worker | PWA 离线缓存 |
### 后端(`InfoGenie-go-backend/`
| 技术 | 用途 |
|------|------|
| Go、Gin | HTTP 服务 |
| GORM | MySQL ORM |
| JWT与 Sprout Gate 颁发令牌配合) | 鉴权相关逻辑以实际代码为准 |
| godotenv | 环境变量 |
### 数据库
- **MySQL**:业务数据(如 AI 使用记录等),具体表结构见 Go 迁移/模型。
### 架构特点
- **混合前端**:列表页在 React`Api60sPage``ToolboxPage`),详情以 iframe 全屏嵌入静态页,统一走 `FullscreenEmbed`
- **60s API 静态页**`public/60sapi/**.html`;路由 **`/60sapi/:itemId`**`Api60sItemPage`)通过 `Api60sConfig.js`**`API_ITEM_STATIC_HTML`** 映射到对应 HTML。内嵌时加载 **`public/60sapi/ig-embed.js`**,在 iframe 内隐藏静态页自带顶栏,避免与外层重复。
- **模块化**:前端 `components/``pages/``config/`;后端 `internal/handler``internal/service` 等分层。
---
## 功能模块(摘要)
- **60s API**:周期资讯、热门榜单、实用功能、娱乐消遣等;配置见 `infogenie-frontend/src/config/Api60sConfig.js`
- **工具箱**`public/toolbox/` 下分 **`图片处理` / `实用工具` / `网页小玩具` / `学习工具`** 四类,入口 `ToolboxPage`(分类标签 + 卡片),点击嵌入;配置项 `offlineOk` 为真的工具在卡片上标 **「可离线」**PWA 缓存后页面可本地加载,调用外网 API 或网络流仍要联网)。**`个人主页模板` 目录仅作临时资源,不参与工具箱配置与展示**,上线前可整体从 `public/` 剔除以减小体积。
- **AI 应用**:入口 `AiModelPage`,静态页在 `public/aimodelapp/`;提示词集中在 **`public/aimodelapp/shared/ai-prompts.js`**,统一请求 **`/api/aimodelapp/chat`**。上游 **API Base / Key / 默认模型** 可由管理员在 `/admin` 写入 MySQL 表 **`site_ai_runtime`**`GET/PUT /api/admin/site/ai-runtime`),优先于 `ai_config.json`
- **休闲游戏**`public/smallgame/`
- **AI 工具**`public/aimodelapp/` 等,消耗萌芽币策略以后端与认证中心对接为准。
- **管理入口**:连续点击 Header **Logo 5 次** 输入管理员令牌进入 `/admin`(详见前端实现)。
- **60s 功能展示开关**:条目定义仍在前端 `Api60sConfig.js`**是否展示**由后端 MySQL 表 `site_60s_disabled` 控制。公开接口 `GET /api/site/60s-disabled` 返回 `{ "disabled": ["itemId", ...] }`;管理保存 `PUT /api/admin/site/60s-disabled`(请求头 `X-Site-Admin-Token`)需与后端环境变量 **`INFOGENIE_SITE_ADMIN_TOKEN`** 一致(开发环境可与管理员口令相同,见 `InfoGenie-go-backend/.env.development`)。
- **60s 上游节点**:由表 **`site_60s_upstream`**`source_id`: `self` | `official`)决定;`GET /api/site/60s-source` 供前端静默拉取(**不在** `/60sapi` 列表页展示域名);管理员 `PUT /api/admin/site/60s-source`。静态页用 `sixty-runtime.js` + URL 参数 `sixty_base`,由 `Api60sItemPage` 注入 iframe。
---
## 项目结构(当前)
## 架构设计
### 整体架构
```
InfoGenie/
├── InfoGenie-go-backend/ # Go + Gin 后端
│ ├── cmd/server/ # 入口 main
│ ├── internal/ # handler / service / model / router 等
── go.mod
├── infogenie-frontend/
│ ├── public/
│ │ ├── 60sapi/ # 60s 类静态页 + ig-embed.js
│ │ ├── toolbox/
│ ├── smallgame/
│ │ ├── aimodelapp/
│ │ ├── index.html # 入口与 PWA 启动屏
│ │ └── manifest.json
│ ├── scripts/
│ │ └── inject-ig-embed.js # 批量为 60s 静态页注入 ig-embed可选
│ ├── src/
│ │ ├── App.js # 路由
│ │ ├── components/ # Header、FullscreenEmbed、…
│ │ ├── pages/ # 含 Api60sPage、Api60sItemPage、UserProfilePage、AdminPage…
│ │ ├── contexts/UserContext.js
│ │ ├── config/ # env.js、Api60sConfig.js、StaticPageConfig.js
│ │ └── utils/api.js
│ └── package.json
├── SPROUT_GATE_API_DOCS.md # 萌芽统一认证接入说明(若在本仓库)
└── README.md # 本文件
前端应用
├── React SPA (主要页面)
│ ├── 用户认证系统
│ ├── 导航和布局
── 页面路由
│ └── 用户管理
└── 静态 HTML 页面
├── API 数据展示页面
├── 小游戏页面
└── AI 模型工具页面
```
---
### 文件结构
```
src/
├── components/ # 公共组件
│ ├── Header.js # 顶部导航栏
│ ├── Navigation.js # 底部导航栏(移动端)
│ └── Footer.js # 页脚
├── pages/ # 页面组件
│ ├── HomePage.js # 首页
│ ├── LoginPage.js # 登录页面
│ ├── Api60sPage.js # API 60s 页面
│ ├── SmallGamePage.js # 小游戏页面
│ ├── AiModelPage.js # AI 模型页面
│ └── UserProfilePage.js # 用户资料页面
├── contexts/ # React Context
│ └── UserContext.js # 用户状态管理
├── config/ # 配置文件
│ └── StaticPageConfig.js # 静态页面配置
├── utils/ # 工具函数
│ └── api.js # API 调用封装
└── styles/ # 全局样式
```
## API 端点Go 后端,常见前缀 `/api`
## API 接口设计
具体以 `InfoGenie-go-backend/internal/router` 为准,常见包括:
### 基础配置
- **Base URL**: `https://infogenie.api.shumengya.top` (这是生产环境)(可通过环境变量 `REACT_APP_API_URL` 配置测试环境)
- **认证方式**: JWT Bearer Token
- **请求格式**: JSON
- **响应格式**: JSON
- **超时时间**: 10秒
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/` | 服务信息 |
| GET | `/api/health` | 健康检查 |
| GET | `/api/auth/check` 等 | 与历史兼容的鉴权检查(若仍保留) |
| POST | `/api/aimodelapp/*` | 各类 AI 能力代理 |
| GET | `/api/aimodelapp/coins` | 与 AI 消耗相关的查询 |
### 认证相关接口
用户资料、签到等以 **认证中心 OpenAPI** 为准(前端通过 `REACT_APP_AUTH_API_URL` 访问)。
#### 发送验证码
```http
POST /api/auth/send-verification
Content-Type: application/json
---
{
"email": "user@example.com"
}
```
## 快速开始
#### 验证验证码
```http
POST /api/auth/verify-code
Content-Type: application/json
### 环境要求
{
"email": "user@example.com",
"code": "123456"
}
```
- **Go** 1.21+(以后端 `go.mod` 为准)
- **Node.js** 16+、npm
- **MySQL**(开发/测试库连接串见后端 `.env` 或文档)
#### 用户登录
```http
POST /api/auth/login
Content-Type: application/json
### 后端
{
"email": "user@example.com",
"password": "password"
}
```
#### 用户注册
```http
POST /api/auth/register
Content-Type: application/json
{
"email": "user@example.com",
"password": "password",
"verification_code": "123456"
}
```
#### 用户登出
```http
POST /api/auth/logout
Authorization: Bearer <token>
```
#### 检查登录状态
```http
GET /api/auth/check
Authorization: Bearer <token>
```
### 用户管理接口
#### 获取用户资料
```http
GET /api/user/profile
Authorization: Bearer <token>
```
#### 修改密码
```http
POST /api/user/change-password
Authorization: Bearer <token>
Content-Type: application/json
{
"old_password": "old_password",
"new_password": "new_password"
}
```
#### 获取用户统计
```http
GET /api/user/stats
Authorization: Bearer <token>
```
#### 获取游戏数据
```http
GET /api/user/game-data
Authorization: Bearer <token>
```
#### 用户签到
```http
POST /api/user/checkin
Authorization: Bearer <token>
```
#### 删除账户
```http
POST /api/user/delete
Authorization: Bearer <token>
Content-Type: application/json
{
"password": "password"
}
```
### 数据展示接口
前端包含大量静态页面用于展示各种 API 数据,这些页面直接调用后端提供的公开接口:
#### 热搜榜单系列
- 百度实时热搜: `GET /v2/baidu/realtime`
- 百度贴吧话题榜: `GET /v2/baidu/tieba`
- 哔哩哔哩热搜榜: `GET /v2/bilibili/hot`
- 抖音热搜榜: `GET /v2/douyin/hot`
- 头条热搜榜: `GET /v2/toutiao/hot`
- 微博热搜榜: `GET /v2/weibo/hot`
- 小红书热点: `GET /v2/xiaohongshu/hot`
- 知乎热门话题: `GET /v2/zhihu/hot`
- Hacker News 榜单: `GET /v2/hackernews`
#### 日更资讯系列
- 必应每日壁纸: `GET /v2/bing/wallpaper`
- 历史上的今天: `GET /v2/history/today`
- 每日国际汇率: `GET /v2/exchange/rates`
- 每天60s读懂世界: `GET /v2/60s/world`
#### 实用功能系列
- 百度百科词条: `GET /v2/baike/search?keyword={keyword}`
- 公网IP地址: `GET /v2/ip/public`
- 哈希解压压缩: `POST /v2/hash/{algorithm}`
- 链接OG信息: `GET /v2/og?url={url}`
- 密码强度检测: `POST /v2/password/strength`
- 农历信息: `GET /v2/calendar/lunar?date={date}`
- 配色方案: `GET /v2/color/schemes`
- 身体健康分析: `POST /v2/health/analysis`
- 生成二维码: `POST /v2/qrcode/generate`
- 实时天气: `GET /v2/weather?location={location}`
- 随机密码生成器: `GET /v2/password/random`
- 随机颜色: `GET /v2/color/random`
- 天气预报: `GET /v2/weather/forecast?location={location}`
- 在线翻译: `POST /v2/translate`
- EpicGames免费游戏: `GET /v2/epic/free-games`
#### 娱乐消遣系列
- 随机唱歌音频: `GET /v2/entertainment/random-song`
- 随机发病文学: `GET /v2/entertainment/random-meme`
- 随机搞笑段子: `GET /v2/entertainment/random-joke`
- 随机冷笑话: `GET /v2/entertainment/random-pun`
- 随机一言: `GET /v2/entertainment/random-quote`
- 随机运势: `GET /v2/entertainment/random-fortune`
- 随机JavaScript趣味题: `GET /v2/entertainment/random-js-quiz`
- 随机KFC文案: `GET /v2/entertainment/random-kfc`
## 状态管理
### 用户状态管理
使用 React Context 进行全局状态管理:
```javascript
const UserContext = createContext();
export const UserProvider = ({ children }) => {
const [user, setUser] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [isLoggedIn, setIsLoggedIn] = useState(false);
// 用户登录、登出、状态检查等方法
};
```
### 本地存储
- 用户信息和 Token 存储在 localStorage 中
- 页面刷新后自动恢复用户状态
## 路由设计
```javascript
const App = () => {
return (
<Router>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/60sapi" element={<Api60sPage />} />
<Route path="/smallgame" element={<SmallGamePage />} />
<Route path="/aimodel" element={<AiModelPage />} />
<Route path="/profile" element={<UserProfilePage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Router>
);
};
```
## 响应式设计
- 移动优先设计理念
- 使用 CSS Grid 和 Flexbox 实现响应式布局
- 媒体查询适配不同屏幕尺寸
- 移动端使用底部导航栏,桌面端使用顶部导航
## 安全考虑
### 前端安全措施
- JWT Token 自动过期和刷新
- XSS 防护:使用 React 自动转义
- CSRF 防护:使用 SameSite Cookie
- 输入验证:前端表单验证
### API 安全要求
- 所有敏感接口需要 JWT 认证
- Token 存储在 localStorage需要后端验证
- 密码等敏感信息前端不存储明文
- API 请求包含 CORS 配置
## 部署和构建
### 构建命令
```bash
cd InfoGenie-go-backend
# 配置 .env 中数据库、密钥、认证中心等
go run ./cmd/server
# 默认示例端口见 main 或配置,常见为 :5002
npm run build # 生产环境构建
npm start # 开发环境启动
```
### 后端 Docker 部署(可选)
### 环境变量
- `REACT_APP_API_URL`: 后端 API 基础地址
- 支持 `.env` 文件配置不同环境的变量
`InfoGenie-go-backend/` 目录:
### PWA 支持
- 注册 Service Worker 实现离线缓存
- Web App Manifest 支持安装到桌面
1. 准备 **`.env.production`**(数据库、邮件、`INFOGENIE_SITE_ADMIN_TOKEN` 等),**不要**将其提交到仓库;镜像内不包含该文件。
2. 构建并启动(**宿主机端口 `12364` → 容器内 `5002`**,容器名 **`infogenie-backend-go`**
## 与后端协作要点
```bash
docker compose up -d --build
```
1. **API 接口约定**: 遵循 RESTful 设计原则,统一响应格式
2. **错误处理**: 后端返回统一的错误格式,前端统一处理
3. **认证流程**: JWT Token 的生成、验证和刷新机制
4. **数据格式**: 前后端约定清晰的数据结构
5. **跨域配置**: 后端需要配置 CORS 允许前端域名
6. **API 版本管理**: 使用 `/v2/` 前缀进行版本控制
7. **性能优化**: 考虑 API 响应时间和前端缓存策略
3. 健康检查:`http://<服务器IP>:12364/api/health`(或经反向代理后的域名)。
4. 前端 `REACT_APP_API_URL` 应指向对外的 API 地址(例如经 Nginx 反代到 `127.0.0.1:12364` 的 `https://infogenie.api.shumengya.top`)。
## 萌芽币消费系统
详见该目录下 `Dockerfile`、`docker-compose.yml`。
### 系统概述
萌芽币是平台内部的虚拟货币用于限制和管理用户对AI功能的使用频率。每次调用AI功能需消耗100萌芽币当用户萌芽币不足时无法使用AI功能。
### 前端
### 技术实现
1. **萌芽币管理器**: `/public/aimodelapp/coin-manager.js`
- 管理用户萌芽币余额和使用记录
- 提供UI组件显示萌芽币信息
- 实现API调用前的余额检查
```bash
cd infogenie-frontend
npm install
npm start
# http://localhost:3000
2. **API集成**:
-`/src/utils/api.js` 中添加萌芽币查询接口
- 所有AI功能API调用必须添加JWT Token认证
- API调用后自动刷新萌芽币余额显示
3. **用户体验**:
- 在页面右上角显示萌芽币余额和使用记录
- 当萌芽币不足时,提供友好的提示
- 引导用户通过签到等方式获取更多萌芽币
### 接口设计
```http
GET /api/aimodelapp/coins
Authorization: Bearer <token>
:
{
"success": true,
"data": {
"coins": 200,
"ai_cost": 100,
"can_use_ai": true,
"username": "",
"usage_count": 5,
"recent_usage": [
{
"api_type": "chat",
"cost": 100,
"timestamp": "2025-09-16T11:15:47.285720"
},
...
]
},
"message": ": 200"
}
```
生产构建:
### 页面集成流程
1. 引入萌芽币管理器 JavaScript 文件
2. 在API调用前检查萌芽币余额
3. 处理API响应中的萌芽币相关错误
4. API调用后刷新萌芽币信息
```bash
npm run build
```
详细集成步骤请参考 [前端萌芽币消费系统集成文档](/前端萌芽币消费系统集成文档.md)
### 前端环境变量(示例)
## 技术架构亮点
| 变量 | 说明 |
|------|------|
| `REACT_APP_API_URL` | 万象口袋 Go 后端根地址 |
| `REACT_APP_AUTH_URL` | 认证中心前端地址 |
| `REACT_APP_AUTH_API_URL` | 认证中心 API 根地址 |
### 1. 混合SPA与静态页面的创新架构
以上变量在 **构建时** 写入前端包Create React App。修改后需重新执行 `npm run build`。
InfoGenie 采用了创新的混合架构设计,将 React SPA 与大量静态 HTML 页面无缝集成,充分发挥两者的优势:
- **React SPA 核心层**:处理用户认证、全局状态管理和主要导航逻辑,确保一致的用户体验
- **静态 HTML 模块**大量功能模块API数据展示、小游戏、AI工具使用原生HTML/CSS/JS实现降低加载时间和资源消耗
- **通信机制**:通过 postMessage API 和共享环境配置实现 SPA 与静态页面的数据交换和状态同步
---
### 2. 前端性能优化策略
## 生产部署(前端)
项目实现了全面的性能优化,确保在各种设备上的流畅体验:
- **代码分割**:基于路由的动态导入,减少首屏加载时间
- **资源预加载**:关键资源预加载与懒加载策略结合
- **缓存策略**:通过 Service Worker 实现静态资源和API响应的智能缓存
- **性能监控**页面加载性能关键指标FCP、LCP、CLS的实时监控与分析
1. **安装与构建**(在 `infogenie-frontend/` 目录)
```bash
npm ci
npm run build
```
产物为 `build/`:静态资源 + 单页 `index.html`。
### 3. 模块化与组件设计
2. **构建前设置环境变量**(按线上域名填写,勿提交含密钥的 `.env` 到仓库):
- Linux/macOS`REACT_APP_API_URL=https://你的后端域名 npm run build`
- 或复制 `infogenie-frontend/.env.production.example` 为 `.env.production` 后执行 `npm run build`。
采用高度模块化的组件设计模式,提升代码可维护性和扩展性:
- **原子设计系统**:从原子级别组件到页面级别的多层组件设计体系
- **状态与UI分离**:清晰的关注点分离,提高组件复用性
- **统一样式系统**:基于 styled-components 的主题化设计系统
- **组件文档化**:关键组件的详细使用说明和示例代码
3. **Web 服务器**
- 将 `build/` 内全部文件部署到站点根目录(或子路径;若子路径需设置 CRA 的 `homepage` 并确认 `PUBLIC_URL`)。
- **必须配置 SPA 回退**:所有未命中静态文件的路径应返回 `index.html`,否则刷新 `/toolbox`、`/60sapi/xxx` 等会 404。
- **Nginx 示例**(站点根部署):
```nginx
server {
listen 443 ssl;
server_name infogenie.example.com;
root /var/www/infogenie/build;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
expires 7d;
add_header Cache-Control "public, immutable";
}
}
```
### 4. 安全与用户体验融合
4. **HTTPS**
- 全站建议 HTTPSOAuth 回调域名须与认证中心登记一致。
通过创新的用户体验设计,在保证安全性的同时提供流畅体验:
- **无感刷新认证**JWT Token 自动刷新机制,避免频繁登录
- **智能错误处理**:集中式的错误处理系统,提供用户友好的错误提示
- **渐进式数据加载**:重要数据优先加载,提升感知性能
- **离线访问支持**:关键功能支持离线访问,增强用户体验
5. **PWA**
- 生产环境会注册 Service Worker见 `src/index.js`)。更新前端后用户可能需刷新或等待 SW 更新;大版本升级可 bump `public/manifest.json` 的 `version` / `name` 便于识别。
## 后续扩展建议
6. **后端**
- 单独部署 `InfoGenie-go-backend`(示例:`https://infogenie.api.shumengya.top`),与前端站点(示例:`https://infogenie.shumengya.top`)分域时,前端构建需设置 `REACT_APP_API_URL` 为后端根地址。
- **CORS**:后端中间件 `internal/middleware/cors.go` 为**宽松策略**(放行任意 Origin 并回显),便于跨域;若需收紧可改为白名单。
1. **状态管理升级**: 可考虑引入 Redux 或 Zustand 进行更复杂的状态管理
2. **组件库**: 可引入 Ant Design 或 Material-UI 统一 UI 组件
3. **测试覆盖**: 添加单元测试和集成测试,提高代码质量和可靠性
- Jest 与 React Testing Library 进行组件测试
- Cypress 进行端到端测试
- MSW 进行 API 模拟测试
4. **性能监控**: 集成 Lighthouse CI 和 Web Vitals 进行性能监控
5. **国际化**: 使用 i18next 支持多语言切换功能
6. **萌芽币系统扩展**:
- 实现萌芽币充值功能
- 针对不同AI功能设置差异化定价
- 添加萌芽币消费统计和分析功能
7. **微前端架构**: 考虑将大型功能模块转换为微前端架构,提高扩展性和团队协作效率
8. **构建优化**: 实施更先进的构建优化策略,如 Tree Shaking、代码分割、资源压缩等
9. **渐进式Web应用升级**: 强化PWA能力增强离线使用体验和桌面安装功能
---
## 技术债务与优化建议
## 前端路由
### 1. 代码质量与一致性
| 路径 | 说明 |
|------|------|
| `/` | 首页 |
| `/login` | 跳转统一登录 |
| `/auth/callback` | OAuth 回调 |
| `/60sapi` | 60s API 分类列表 |
| `/60sapi/:itemId` | 嵌入对应静态 HTML |
| `/toolbox` | 工具箱列表 |
| `/smallgame` | 小游戏 |
| `/aimodel` | AI 应用 |
| `/profile` | 个人中心(只读) |
| `/admin` | 管理员后台(令牌) |
| `*` | 未匹配 → 首页 |
当前项目在开发过程中出现了一些不一致的编码风格和实践,需要通过以下措施进行标准化:
---
- **代码规范统一**: 引入 ESLint + Prettier 强制执行统一的代码风格
- **类型安全增强**: 考虑引入 TypeScript 或 PropTypes 进行类型检查,降低运行时错误
- **代码审查流程**: 建立明确的代码审查规范和流程,确保代码质量
- **文档完善**: 补充关键功能模块和核心组件的详细文档
## 联系方式与许可
### 2. 架构优化与重构
- **演示**https://infogenie.shumengya.top
- **ICP**蜀ICP备2025151694号以实际页脚为准
- **许可证**Apache License 2.0 — 见 [LICENSE](LICENSE)
以下是需要关注的架构层面优化点:
---
- **API 层抽象**: 当前的 API 调用过于分散,建议引入统一的 API 请求层和数据模型层
- **组件结构优化**: 部分组件承担过多职责,应按照单一职责原则进行拆分
- **状态管理重构**: 当前 Context API 的使用在大型应用中可能导致过度渲染,考虑引入更高效的状态管理方案
- **静态资源优化**: 优化图像、字体等静态资源的加载策略,减少页面加载时间
<div align="center"><strong>万象口袋</strong> · 多功能聚合应用</div>
### 3. 前端基础设施升级
为提高开发效率和项目可维护性,建议升级以下基础设施:
- **现代化构建工具**: 考虑从 Create React App 迁移至 Vite提升开发和构建速度
- **自动化测试框架**: 建立单元测试、集成测试和 E2E 测试的完整体系
- **CI/CD 流程**: 优化持续集成和部署流程,实现更敏捷的开发和发布
- **前端监控系统**: 引入错误跟踪和性能监控系统,主动发现并解决问题
## 前端技术栈演进路线
### 近期优化0-3个月
1. **架构文档完善**: 补充架构决策记录(ADR)和技术选型依据
2. **代码质量工具集成**: ESLint、Prettier、Husky 配置统一
3. **性能优化第一阶段**: 首屏加载优化、资源懒加载实现
4. **核心组件库重构**: 提取可复用组件,建立组件文档
### 中期规划3-6个月
1. **TypeScript 迁移**: 核心模块向 TypeScript 迁移
2. **状态管理升级**: 评估并引入更适合的状态管理方案
3. **自动化测试覆盖**: 关键功能的单元测试和集成测试实现
4. **微前端架构设计**: 设计微前端架构方案,逐步迁移大型功能模块
### 长期展望6-12个月
1. **新技术栈评估与升级**: 评估 React 18+ 新特性应用
2. **前端国际化支持**: 实现多语言和本地化支持
3. **无障碍访问标准实现**: 符合 WCAG 2.1 AA 级标准
4. **性能指标达成**: 优化应用性能,使所有关键指标达到业界标准

View File

@@ -53,5 +53,6 @@ func AutoMigrate() error {
&model.SiteAIRuntime{},
&model.Site60sUpstream{},
&model.SiteAIModelDisabled{},
&model.SiteFeatureCardClick{},
)
}

View File

@@ -2,6 +2,7 @@ package handler
import (
"fmt"
"io"
"log"
"net/http"
"strings"
@@ -51,8 +52,14 @@ func validateTextLen(text string, label string) (string, error) {
return t, nil
}
// POST /api/aimodelapp/chat
func (h *AIModelHandler) Chat(c *gin.Context) {
type aiModelChatInput struct {
Messages []service.ChatMessage
Provider string
Model string
}
// bindAIModelChat 绑定并校验统一 chat / chat/stream 请求体;失败时已写入 JSON 响应。
func bindAIModelChat(c *gin.Context) (aiModelChatInput, bool) {
var req struct {
Messages []service.ChatMessage `json:"messages"`
Provider string `json:"provider"`
@@ -60,7 +67,7 @@ func (h *AIModelHandler) Chat(c *gin.Context) {
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求数据为空"})
return
return aiModelChatInput{}, false
}
if req.Provider == "" {
req.Provider = "deepseek"
@@ -68,44 +75,99 @@ func (h *AIModelHandler) Chat(c *gin.Context) {
if req.Model == "" {
req.Model = "deepseek-chat"
}
// 模型白名单校验
if models, ok := allowedModels[req.Provider]; !ok || !models[req.Model] {
c.JSON(http.StatusBadRequest, gin.H{"error": "不支持的模型"})
return
return aiModelChatInput{}, false
}
if len(req.Messages) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "消息内容不能为空"})
return
return aiModelChatInput{}, false
}
if len(req.Messages) > maxChatMsgCount {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("消息数量不能超过 %d 条", maxChatMsgCount)})
return
return aiModelChatInput{}, false
}
// 校验每条消息的长度
for _, m := range req.Messages {
if len(m.Content) > maxInputLen {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("单条消息长度不能超过 %d 字符", maxInputLen)})
return
return aiModelChatInput{}, false
}
}
return aiModelChatInput{Messages: req.Messages, Provider: req.Provider, Model: req.Model}, true
}
content, err := service.CallAI(req.Provider, req.Model, req.Messages)
// POST /api/aimodelapp/chat
func (h *AIModelHandler) Chat(c *gin.Context) {
in, ok := bindAIModelChat(c)
if !ok {
return
}
content, err := service.CallAI(in.Provider, in.Model, in.Messages)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": safeAIError(err)})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"content": content,
"provider": req.Provider,
"model": req.Model,
"provider": in.Provider,
"model": in.Model,
"timestamp": time.Now().Format(time.RFC3339),
})
}
// POST /api/aimodelapp/chat/stream — SSE 透传上游 OpenAI 兼容流data: {...}\\n\\n / [DONE]
func (h *AIModelHandler) ChatStream(c *gin.Context) {
in, ok := bindAIModelChat(c)
if !ok {
return
}
upstream, statusCode, err := service.OpenAIChatStream(c.Request.Context(), in.Provider, in.Model, in.Messages)
if err != nil {
if statusCode == 0 {
c.JSON(http.StatusInternalServerError, gin.H{"error": safeAIError(err)})
return
}
c.JSON(http.StatusBadGateway, gin.H{"error": safeAIError(err)})
return
}
defer upstream.Close()
hdr := c.Writer.Header()
hdr.Set("Content-Type", "text/event-stream; charset=utf-8")
hdr.Set("Cache-Control", "no-cache")
hdr.Set("Connection", "keep-alive")
hdr.Set("X-Accel-Buffering", "no")
c.Status(http.StatusOK)
flusher, ok := c.Writer.(http.Flusher)
if !ok {
_, _ = io.Copy(c.Writer, upstream)
return
}
buf := make([]byte, 8192)
for {
select {
case <-c.Request.Context().Done():
return
default:
}
n, rerr := upstream.Read(buf)
if n > 0 {
if _, werr := c.Writer.Write(buf[:n]); werr != nil {
return
}
flusher.Flush()
}
if rerr == io.EOF {
return
}
if rerr != nil {
return
}
}
}
// POST /api/aimodelapp/name-analysis
func (h *AIModelHandler) NameAnalysis(c *gin.Context) {
var req struct {

View File

@@ -0,0 +1,95 @@
package handler
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"infogenie-backend/internal/database"
"infogenie-backend/internal/model"
)
var featureCardSections = map[string]struct{}{
"60sapi": {},
"smallgame": {},
"toolbox": {},
"aimodel": {},
}
func normalizeFeatureSection(s string) (string, bool) {
key := strings.ToLower(strings.TrimSpace(s))
if key == "" {
return "", false
}
if _, ok := featureCardSections[key]; !ok {
return "", false
}
return key, true
}
func sanitizeFeatureItemID(raw string) (string, bool) {
id := strings.TrimSpace(raw)
if id == "" || len(id) > 128 {
return "", false
}
return id, true
}
// GetFeatureCardClicks 公开:按板块返回各功能 id 的点击次数(未出现过的 id 前端视为 0
func (h *SiteConfigHandler) GetFeatureCardClicks(c *gin.Context) {
section, ok := normalizeFeatureSection(c.Query("section"))
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_section"})
return
}
var rows []model.SiteFeatureCardClick
if err := database.DB.Where("section = ?", section).Find(&rows).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "db_error"})
return
}
counts := make(map[string]uint64, len(rows))
for _, r := range rows {
counts[r.ItemID] = r.ClickCount
}
c.JSON(http.StatusOK, gin.H{"section": section, "counts": counts})
}
type postFeatureCardClickBody struct {
Section string `json:"section"`
ItemID string `json:"item_id"`
}
// PostFeatureCardClickIncrement 公开:记录一次卡片点击并返回最新计数
func (h *SiteConfigHandler) PostFeatureCardClickIncrement(c *gin.Context) {
var body postFeatureCardClickBody
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_json"})
return
}
section, ok := normalizeFeatureSection(body.Section)
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_section"})
return
}
itemID, ok := sanitizeFeatureItemID(body.ItemID)
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_item_id"})
return
}
const q = `INSERT INTO site_feature_card_clicks (section, item_id, click_count, updated_at)
VALUES (?, ?, 1, NOW())
ON DUPLICATE KEY UPDATE click_count = click_count + 1, updated_at = NOW()`
if err := database.DB.Exec(q, section, itemID).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "db_error"})
return
}
var row model.SiteFeatureCardClick
if err := database.DB.Where("section = ? AND item_id = ?", section, itemID).First(&row).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "db_error"})
return
}
c.JSON(http.StatusOK, gin.H{"section": section, "item_id": itemID, "count": row.ClickCount})
}

View File

@@ -131,6 +131,19 @@ func resolve60sUpstream(sourceID string) (id string, info sixtySrcInfo) {
return id, info
}
// EffectiveSixtyUpstream 返回当前站点配置的 60s API 根地址(读库;无记录或库不可用则回落默认 self
func EffectiveSixtyUpstream(db *gorm.DB) (sourceID string, base string, label string) {
sid := ""
if db != nil {
var row model.Site60sUpstream
if err := db.First(&row, 1).Error; err == nil {
sid = row.SourceID
}
}
id, info := resolve60sUpstream(sid)
return id, info.Base, info.Label
}
// Get60sSource 公开:当前站点使用的 60s 上游 base_url供静态页 iframe 传参)
func (h *SiteConfigHandler) Get60sSource(c *gin.Context) {
var row model.Site60sUpstream

View File

@@ -0,0 +1,13 @@
package model
import "time"
// SiteFeatureCardClick 四大板块功能卡片点击次数section + item_id 唯一)
type SiteFeatureCardClick struct {
Section string `gorm:"primaryKey;type:varchar(24);not null" json:"section"`
ItemID string `gorm:"primaryKey;type:varchar(128);not null" json:"item_id"`
ClickCount uint64 `gorm:"not null;default:0" json:"click_count"`
UpdatedAt time.Time `json:"updated_at"`
}
func (SiteFeatureCardClick) TableName() string { return "site_feature_card_clicks" }

View File

@@ -1,7 +1,10 @@
package router
import (
"context"
"fmt"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
@@ -11,6 +14,31 @@ import (
"infogenie-backend/internal/middleware"
)
func probeSixtyPublicAPI(ctx context.Context, base string) (ok bool, httpStatus int, errMsg string, ms int64, probeURL string) {
b := strings.TrimSpace(base)
if b == "" {
return false, 0, "empty_base", 0, ""
}
probeURL = strings.TrimRight(b, "/") + "/v2/ip"
client := &http.Client{Timeout: 6 * time.Second}
t0 := time.Now()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, probeURL, nil)
if err != nil {
return false, 0, err.Error(), time.Since(t0).Milliseconds(), probeURL
}
resp, err := client.Do(req)
ms = time.Since(t0).Milliseconds()
if err != nil {
return false, 0, err.Error(), ms, probeURL
}
_ = resp.Body.Close()
ok = resp.StatusCode >= 200 && resp.StatusCode < 500
if !ok && errMsg == "" {
errMsg = fmt.Sprintf("http_%d", resp.StatusCode)
}
return ok, resp.StatusCode, "", ms, probeURL
}
func Setup(r *gin.Engine) {
r.Use(middleware.CORS())
@@ -24,33 +52,66 @@ func Setup(r *gin.Engine) {
c.JSON(http.StatusOK, gin.H{
"message": "万象口袋 后端 API 服务运行中",
"description": "提供AI模型应用接口用户认证由萌芽账户认证中心提供",
"version": "3.2.0-go",
"version": "3.3.0-go",
"timestamp": time.Now().Format(time.RFC3339),
"endpoints": gin.H{
"auth": "/api/auth (via 萌芽认证中心)",
"user": "/api/user",
"aimodelapp": "/api/aimodelapp",
"site": "/api/site",
"site": "/api/site (含 feature-card-clicks)",
"admin_site": "/api/admin/site/*",
},
})
})
// 健康检查:实际检测数据库连接
// 健康检查:数据库 Ping + 当前配置的 60s 上游轻量探测GET …/v2/ip
r.GET("/api/health", func(c *gin.Context) {
ctx, cancel := context.WithTimeout(c.Request.Context(), 8*time.Second)
defer cancel()
dbStatus := "connected"
if database.DB != nil {
sqlDB, err := database.DB.DB()
if err != nil || sqlDB.Ping() != nil {
if err != nil || sqlDB.PingContext(ctx) != nil {
dbStatus = "disconnected"
}
} else {
dbStatus = "not_initialized"
}
mysqlOK := dbStatus == "connected"
sid, sixtyBase, sixtyLabel := handler.EffectiveSixtyUpstream(database.DB)
sixtyOK, sixtyHTTP, sixtyErr, sixtyMs, probeURL := probeSixtyPublicAPI(ctx, sixtyBase)
if !sixtyOK && sixtyErr == "" && sixtyHTTP >= 500 {
sixtyErr = fmt.Sprintf("http_%d", sixtyHTTP)
}
overall := "running"
if !mysqlOK || !sixtyOK {
overall = "degraded"
}
c.JSON(http.StatusOK, gin.H{
"status": "running",
"database": dbStatus,
"status": overall,
"timestamp": time.Now().Format(time.RFC3339),
"database": dbStatus,
"mysql": gin.H{
"ok": mysqlOK,
"status": dbStatus,
},
"backend_api": gin.H{
"ok": true,
},
"sixty_api": gin.H{
"ok": sixtyOK,
"source_id": sid,
"base_url": sixtyBase,
"label": sixtyLabel,
"probe_url": probeURL,
"http_status": sixtyHTTP,
"latency_ms": sixtyMs,
"error": sixtyErr,
},
})
})
@@ -68,6 +129,8 @@ func Setup(r *gin.Engine) {
r.GET("/api/site/60s-disabled", siteH.Get60sDisabled)
r.GET("/api/site/60s-source", siteH.Get60sSource)
r.GET("/api/site/ai-model-disabled", siteH.GetAIModelDisabled)
r.GET("/api/site/feature-card-clicks", siteH.GetFeatureCardClicks)
r.POST("/api/site/feature-card-clicks/increment", siteH.PostFeatureCardClickIncrement)
r.PUT("/api/admin/site/60s-disabled", siteH.Put60sDisabled)
r.PUT("/api/admin/site/60s-source", siteH.Put60sSource)
r.PUT("/api/admin/site/ai-model-disabled", siteH.PutAIModelDisabled)
@@ -77,6 +140,7 @@ func Setup(r *gin.Engine) {
ai := r.Group("/api/aimodelapp")
{
ai.POST("/chat", middleware.JWTAuth(), aiH.Chat)
ai.POST("/chat/stream", middleware.JWTAuth(), aiH.ChatStream)
ai.POST("/name-analysis", middleware.JWTAuth(), aiH.NameAnalysis)
ai.POST("/variable-naming", middleware.JWTAuth(), aiH.VariableNaming)
ai.POST("/poetry", middleware.JWTAuth(), aiH.Poetry)

View File

@@ -2,6 +2,7 @@ package service
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
@@ -75,8 +76,8 @@ func loadRuntimeDeepSeek() (apiBase, apiKey, defModel string, ok bool) {
return "", "", "", false
}
func CallDeepSeek(messages []ChatMessage, model string, maxRetries int) (string, error) {
// 首先尝试从SiteAIRuntime读取配置向后兼容
// openDeepSeekChatURL 解析 DeepSeek 兼容 /chat/completions 的完整 URL、密钥与最终落库模型名
func openDeepSeekChatURL(model string) (fullURL, apiKey, resolvedModel string, err error) {
if base, key, defModel, ok := loadRuntimeDeepSeek(); ok {
if model == "" {
model = defModel
@@ -84,65 +85,74 @@ func CallDeepSeek(messages []ChatMessage, model string, maxRetries int) (string,
if model == "" {
model = "deepseek-chat"
}
url := strings.TrimSuffix(base, "/") + "/chat/completions"
return callOpenAICompatible(url, key, model, messages, maxRetries, 90*time.Second)
return strings.TrimSuffix(base, "/") + "/chat/completions", key, model, nil
}
// 从新的AI配置表读取
if apiKey, apiBase, defaultModel, models, ok := loadAIConfig("deepseek"); ok {
if model == "" {
model = defaultModel
}
if model == "" {
model = "deepseek-chat"
}
// 验证模型是否在允许列表中
if len(models) > 0 {
allowed := false
for _, m := range models {
if m == model {
allowed = true
break
}
}
if !allowed {
model = models[0] // 使用第一个允许的模型
apiKey, apiBase, defaultModel, models, ok := loadAIConfig("deepseek")
if !ok {
return "", "", "", fmt.Errorf("DeepSeek配置未设置请在管理员后台配置API Key和Base URL")
}
if model == "" {
model = defaultModel
}
if model == "" {
model = "deepseek-chat"
}
if len(models) > 0 {
allowed := false
for _, m := range models {
if m == model {
allowed = true
break
}
}
url := strings.TrimSuffix(apiBase, "/") + "/chat/completions"
return callOpenAICompatible(url, apiKey, model, messages, maxRetries, 90*time.Second)
if !allowed {
model = models[0]
}
}
return strings.TrimSuffix(apiBase, "/") + "/chat/completions", apiKey, model, nil
}
return "", fmt.Errorf("DeepSeek配置未设置请在管理员后台配置API Key和Base URL")
func CallDeepSeek(messages []ChatMessage, model string, maxRetries int) (string, error) {
urlStr, key, m, err := openDeepSeekChatURL(model)
if err != nil {
return "", err
}
return callOpenAICompatible(urlStr, key, m, messages, maxRetries, 90*time.Second)
}
// openKimiChatURL 解析 Kimi /v1/chat/completions
func openKimiChatURL(model string) (fullURL, apiKey, resolvedModel string, err error) {
apiKey, apiBase, defaultModel, models, ok := loadAIConfig("kimi")
if !ok {
return "", "", "", fmt.Errorf("Kimi配置未设置请在管理员后台配置API Key和Base URL")
}
if model == "" {
model = defaultModel
}
if model == "" {
model = "kimi-k2-0905-preview"
}
if len(models) > 0 {
allowed := false
for _, m := range models {
if m == model {
allowed = true
break
}
}
if !allowed {
model = models[0]
}
}
return strings.TrimSuffix(apiBase, "/") + "/v1/chat/completions", apiKey, model, nil
}
func CallKimi(messages []ChatMessage, model string) (string, error) {
// 从新的AI配置表读取
if apiKey, apiBase, defaultModel, models, ok := loadAIConfig("kimi"); ok {
if model == "" {
model = defaultModel
}
if model == "" {
model = "kimi-k2-0905-preview"
}
// 验证模型是否在允许列表中
if len(models) > 0 {
allowed := false
for _, m := range models {
if m == model {
allowed = true
break
}
}
if !allowed {
model = models[0] // 使用第一个允许的模型
}
}
url := strings.TrimSuffix(apiBase, "/") + "/v1/chat/completions"
return callOpenAICompatible(url, apiKey, model, messages, 1, 30*time.Second)
urlStr, key, m, err := openKimiChatURL(model)
if err != nil {
return "", err
}
return "", fmt.Errorf("Kimi配置未设置请在管理员后台配置API Key和Base URL")
return callOpenAICompatible(urlStr, key, m, messages, 1, 30*time.Second)
}
func callOpenAICompatible(url, apiKey, model string, messages []ChatMessage, maxRetries int, timeout time.Duration) (string, error) {
@@ -211,3 +221,49 @@ func CallAI(provider, model string, messages []ChatMessage) (string, error) {
return "", fmt.Errorf("不支持的AI提供商: %s目前支持的提供商: deepseek, kimi", provider)
}
}
// OpenAIChatStream 向上游发起 stream:true 的请求;返回的 ReadCloser 需由调用方 Close。statusCode 非 200 时 body 已读完并关闭rc 为 nil。
func OpenAIChatStream(ctx context.Context, provider, model string, messages []ChatMessage) (rc io.ReadCloser, statusCode int, err error) {
var urlStr, apiKey, m string
switch provider {
case "deepseek":
urlStr, apiKey, m, err = openDeepSeekChatURL(model)
case "kimi":
urlStr, apiKey, m, err = openKimiChatURL(model)
default:
return nil, 0, fmt.Errorf("不支持的AI提供商: %s", provider)
}
if err != nil {
return nil, 0, err
}
streamBody := map[string]interface{}{
"model": m,
"messages": messages,
"temperature": 0.7,
"max_tokens": 2000,
"stream": true,
}
bodyBytes, jerr := json.Marshal(streamBody)
if jerr != nil {
return nil, 0, fmt.Errorf("序列化请求失败: %w", jerr)
}
req, rerr := http.NewRequestWithContext(ctx, "POST", urlStr, bytes.NewReader(bodyBytes))
if rerr != nil {
return nil, 0, rerr
}
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream")
client := &http.Client{}
resp, derr := client.Do(req)
if derr != nil {
return nil, 0, derr
}
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
resp.Body.Close()
return nil, resp.StatusCode, fmt.Errorf("%s", strings.TrimSpace(string(b)))
}
return resp.Body, http.StatusOK, nil
}

View File

@@ -0,0 +1,117 @@
# 万象口袋 — Go 后端文档
**技术栈**Go 1.25+ · Gin · GORM · MySQL
**模块路径**`infogenie-backend`(见 `go.mod`
**入口**`cmd/server/main.go` — 加载配置、连接数据库、`AutoMigrate`、启动 HTTP 服务。
---
## 运行与配置
- 环境由 **`APP_ENV`** 决定:`development``production`(见 `config.Load()`)。
- 若存在 **`.env.development`** / **`.env.production`**,会通过 `godotenv` 加载对应文件。
- **`APP_PORT`** 默认 **5002**(与前端 `REACT_APP_API_URL` 开发默认一致)。
- 数据库、邮件、认证中心、`INFOGENIE_SITE_ADMIN_TOKEN` 等从环境变量读取,详见 `config/config.go`
**健康检查**`GET /api/health` — 返回服务状态与数据库 `Ping` 结果。
**根路径**`GET /` — 返回服务说明与主要 endpoint 分组(`version` 当前为 **3.3.0-go**)。
---
## 数据库GORM AutoMigrate
启动时会迁移以下模型(见 `internal/database/mysql.go`
| 模型 | 用途 |
|------|------|
| `AIConfig` | 多厂商 AI Key / Base / 模型列表(如 deepseek、kimi |
| `Site60sDisabled` | 60s 功能在前端隐藏的 `feature_id` |
| `SiteAIRuntime` | DeepSeek 兼容网关Base + Key + 默认模型),优先级高于部分 AIConfig |
| `Site60sUpstream` | 60s 上游节点(单例 id=1 |
| `SiteAIModelDisabled` | AI 应用在前端隐藏的 `app_id` |
| `SiteFeatureCardClick` | 四大板块功能卡片点击统计(`section` + `item_id` 联合主键) |
---
## 路由概览(`internal/router/router.go`
### CORS
全局 `middleware.CORS()`,放行常用 Method/Header`Authorization``X-Site-Admin-Token`)。
### 认证与用户
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/auth/check` | 可选 JWT校验登录态 |
| GET | `/api/user/profile` | **需 JWT**:用户资料 |
实际登录、发 token 由 **萌芽账户认证中心** 完成;后端校验 JWT。
### 站点公开配置(无需登录)
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/site/60s-disabled` | 被隐藏的 60s `feature_id` 列表 |
| GET | `/api/site/60s-source` | 60s 上游 `source_id` / `base_url` |
| GET | `/api/site/ai-model-disabled` | 被隐藏的 AI 应用 id 列表 |
| GET | `/api/site/feature-card-clicks?section=` | 功能卡片点击次数(见下) |
| POST | `/api/site/feature-card-clicks/increment` | 上报一次点击,返回最新 count |
**`section` 合法值**`60sapi` · `smallgame` · `toolbox` · `aimodel`
**increment 请求体**`{ "section": "...", "item_id": "..." }`
### 站点管理(需 `X-Site-Admin-Token`,与环境变量 `INFOGENIE_SITE_ADMIN_TOKEN` 一致)
| 方法 | 路径 | 说明 |
|------|------|------|
| PUT | `/api/admin/site/60s-disabled` | 更新 60s 隐藏列表 |
| PUT | `/api/admin/site/60s-source` | 切换 60s 上游 |
| PUT | `/api/admin/site/ai-model-disabled` | 更新 AI 应用隐藏列表 |
| GET/PUT | `/api/admin/site/ai-runtime` | 读取/更新 DeepSeek 兼容运行时配置 |
### AI 应用(`/api/aimodelapp`,默认 **需 JWT**
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/chat` | 非流式对话JSON 返回全文 |
| POST | `/chat/stream` | **SSE 流式**:透传上游 OpenAI 兼容流(`text/event-stream` |
| POST | `/name-analysis` 等 | 各垂直能力(姓名、变量命名、写诗、翻译等) |
| GET | `/models` | 模型列表 |
**流式说明**`internal/handler/aimodel.go` + `internal/service/ai.go`
- 上游请求带 `stream: true`,成功后将上游 body **分块写入并 Flush** 到客户端。
- 支持 **deepseek**(运行时或 `AIConfig`)与 **kimi**`AIConfig`)。
-`/chat` 共用同一套 `bindAIModelChat` 校验(消息条数、长度、模型白名单等)。
**模型白名单**:见 `internal/handler/aimodel.go``allowedModels`(如 deepseek-chat、deepseek-reasoner、部分 kimi 模型)。
---
## 核心源码目录
```
cmd/server/ # main
config/ # 配置加载
internal/
database/ # MySQL 初始化、AutoMigrate
handler/ # HTTP 处理器auth、user、aimodel、siteconfig、ai_runtime、feature_card_clicks
middleware/ # CORS、JWT
model/ # GORM 模型
router/ # 路由注册
service/ # AI 调用(含 OpenAI 兼容非流式与流式)
```
---
## 与其他工程的关系
- **前端 SPA** 通过 `REACT_APP_API_URL` 指向本服务(开发默认 `http://127.0.0.1:5002`)。
- **`public/aimodelapp/*/shared/ai-chat.js`** 优先调用 `/api/aimodelapp/chat/stream`,失败时回退 `/chat`
更完整的前端集成说明见 **`infogenie-frontend/前端文档.md`**。

View File

View File

@@ -1,6 +1,6 @@
# 萌芽账户认证中心 API 文档
访问 **`GET /`** 或 **`GET /api`**(无鉴权)可得到 JSON 格式的简要说明(服务名、版本、`/api/docs``/api/health` 入口、路由前缀摘要)
访问 **`GET /`** 或 **`GET /api`**(无鉴权)可得到 JSON 格式的简要说明(服务名、版本、`links.health``routePrefixes` 等)。**运行时未挂载**返回 Markdown 的 **`GET /api/docs`**;完整 HTTP 清单以 **`sproutgate-backend/main.go`** 与本文件、**`sproutgate-backend/后端文档.md`** 为准
接入地址:
- 统一登录前端:`https://auth.shumengya.top`
@@ -8,21 +8,26 @@
- 本地开发 API`http://<host>:8080`
对外接入建议:
1. 第三方应用按钮跳转到统一登录前端。
2. 登录成功后回跳到业务站点。
3. 业务站点使用回跳带回的 `token` 调用后端 API
1. 第三方「登录」按钮跳转到统一登录前端**授权入口**(推荐 `.../authorize?...`,见下节)
2. 用户在统一登录站点完成登录与授权确认后,回跳到业务站点。
3. 业务站点使用回跳 URL **`#`** 哈希中的 `token`,由**自有后端**调用 `POST /api/auth/verify``GET /api/auth/me` 校验并建立会话
**推荐入口 URL生产** 将下列主机换为你的统一登录部署域名;子路径部署时在前端 `base` 后追加 `authorize`
示例按钮:
```html
<a href="https://auth.shumengya.top/?redirect_uri=https%3A%2F%2Fapp.example.com%2Fauth%2Fcallback&state=abc123">
<a href="https://auth.shumengya.top/authorize?redirect_uri=https%3A%2F%2Fapp.example.com%2Fauth%2Fcallback&amp;state=abc123&amp;client_id=my-app&amp;client_name=%E6%88%91%E7%9A%84%E5%BA%94%E7%94%A8">
使用萌芽统一账户认证登录
</a>
```
说明:
- 若用户打开的是站点根路径且仅携带 `redirect_uri` / `return_url` 等查询参数,前端会自动 **重定向****`/authorize`**(保留同一套 query以使用独立授权确认页。
- `client_id` / `client_name` 会由浏览器写入 session并在用户登录时提交给后端用于累计 **应用接入记录**(详见下文「应用接入记录」)。
回跳说明:
- 用户已登录时,统一登录前端会提示“继续授权”或“切换账号”
- 登录成功后会回跳到 `redirect_uri`(或 `return_url`),并在 URL **`#fragment`**(哈希)中带上令牌与用户信息(见下表)。
- 第三方应用拿到 `token` 后,建议调用 **`POST /api/auth/verify`**无副作用、适合网关鉴权)或 **`GET /api/auth/me`**(会更新访问记录,适合业务拉全量资料)校验并解析用户身份。
- **`/authorize`** 流程下,已登录用户会看到明确的授权页(应用信息、权限说明、允许/拒绝);拒绝时回跳地址的 hash 中会携带 `error=access_denied`
- 登录并「允许」后,浏览器回跳到 `redirect_uri`(或 `return_url`),并在 URL **`#fragment`**(哈希)中带上令牌与用户信息(见下表)。
- 第三方拿到 `token` 后,建议服务端调用 **`POST /api/auth/verify`**不更新「最后访问」等侧写,适合网关鉴权)或 **`GET /api/auth/me`**(会更新访问/签到相关侧写,适合拉取个人中心级资料)校验并解析用户身份。
### 统一登录前端:查询参数
@@ -32,7 +37,7 @@
| `return_url` | 同上 | 与 `redirect_uri` 同义,二者都传时优先 `redirect_uri`。 |
| `state` | 否 | OAuth 风格透传字符串;回跳时原样写入哈希参数,供业务防 CSRF 或关联会话。 |
| `prompt` | 否 | 预留;前端可读,当前可用于将来扩展交互策略。 |
| `client_id` | 否 | 第三方应用稳定标识(字母数字开头,可含 `_.:-`,最长 64。写入用户「应用接入记录」并随登录请求提交给后端。 |
| `client_id` | 否 | 第三方应用稳定标识;格式须符合后端校验(见下文「应用接入记录」)。写入 session 后随登录请求提交,用于 **应用接入记录**。 |
| `client_name` | 否 | 展示用名称(最长 128`client_id` 配对;可选。 |
### 回跳 URL`#` 哈希参数
@@ -53,7 +58,50 @@
1. **仅信服务端**:回调页将 `token` 交给自有后端,由后端请求 `POST https://<api-host>/api/auth/verify`JSON body`{"token":"..."}`),根据 `valid``user.account` 建立会话。
2. **CORS**:浏览器直连 API 时须后端已配置 CORS本服务默认允许任意 `Origin`);若从服务端发起请求则不受 CORS 限制。
3. **令牌过期**`verify` / `me` 返回 401 或 `verify``valid:false` 时,应引导用户重新走统一登录。
3. **令牌无效或封禁**`verify` / `me` 返回 **401**`verify` 因封禁返回 **403**`valid: false`时,应作废本地会话并引导用户重新走统一登录。
## 应用接入记录authClients
用户在统一登录前端的 **个人中心 → 应用接入** 中可看到已与账号发生过认证关联的第三方应用列表。每条记录对应一个稳定的 **`client_id`**,并展示展示名(若有)、**首次接入时间**、**最近接入时间**(均为 RFC3339。持久化在 **MySQL `users` 表**中对应用户的 **`auth_clients` JSON 列**;对外 API 字段名仍为 **`authClients`**(与历史 JSON 文件存储时的形状一致)。
示例展示含义(与界面文案一致):**infogenie**(万象口袋)首次 `2026-03-22T16:33:24+08:00` · 最近 `2026-03-30T19:22:45+08:00` —— 其中括号内为 `client_name` / `X-Auth-Client-Name` / 登录体 `clientName` 带来的展示名,`infogenie``client_id`
### `client_id` 与名称规则
- **`client_id`(必填才能记一条)**:长度 164必须以 **英文字母或数字** 开头;其余字符仅可为 `[A-Za-z0-9_.:-]`(与后端正则一致)。
- **`client_name` / 展示名(可选)**:任意去首尾空白后的字符串,最长 **128** 字符,超出由服务端截断;对应存储字段为 `displayName`
### 在哪些接口上累计记录
记录仅在 **令牌有效、用户未封禁、且 `client_id` 通过校验** 时写入或刷新 **`lastSeenAt`**;已存在同一 `client_id` 时更新 **`lastSeenAt`**,必要时更新 **`displayName`****不改动** **`firstSeenAt`**。
| 方式 | 接口 / 场景 | 如何传递 `client_id` 与展示名 |
|------|----------------|--------------------------------|
| **A. 统一登录 URL** | 用户经前端完成登录 | 授权页 URL 查询参数 **`client_id`**、**`client_name`**(见上表)。前端写入 session登录请求 `POST /api/auth/login` 的 JSON 中会带 **`clientId`** / **`clientName`**。 |
| **B. 资源拥有者密码登录** | `POST /api/auth/login` | JSON **`clientId`**、**`clientName`**(可选,规则同上)。 |
| **C. 服务端持 JWT 调用** | `POST /api/auth/verify` | 请求头 **`X-Auth-Client`**(必须为合法 id、**`X-Auth-Client-Name`**(可选)。**Body 仅含 `token`,不能替代请求头。** |
| **D. 服务端持 JWT 调用** | `GET /api/auth/me` | 同上:**`X-Auth-Client`**、**`X-Auth-Client-Name`**,与 **`Authorization: Bearer`** 同时使用。 |
说明:
- **A + B**:在登录成功、签发 JWT **之后**写库;`POST /api/auth/login` 的响应 `user` 为 `OwnerPublic()`**含** `authClients`(若本次写入了新记录)。
- **C**`POST /api/auth/verify` 在校验通过且未封禁后写库;响应 `user`**`Public()`,不含 `authClients`**,避免向第三方泄露用户在其他应用上的关联列表。
- **D**`GET /api/auth/me` 在校验通过且未封禁后写库;响应 `user`**`OwnerPublic()`,含 `authClients`**,与前端个人中心一致。
**封禁用户**`POST /api/auth/verify``GET /api/auth/me` 在命中封禁或令牌无效时 **不会** 更新应用接入记录(在写库逻辑之前即返回错误)。
**浏览器直连 API**:若前端或网关需带上 `X-Auth-Client`,请确认 CORS 允许该自定义头(本服务 CORS 已包含 `X-Auth-Client``X-Auth-Client-Name`)。
### 存储结构(`authClients` 数组元素)
```json
{
"clientId": "infogenie",
"displayName": "万象口袋",
"firstSeenAt": "2026-03-22T16:33:24+08:00",
"lastSeenAt": "2026-03-30T19:22:45+08:00"
}
```
## 认证与统一登录
@@ -70,7 +118,7 @@
}
```
`clientId` / `clientName` 可选;规则与请求头 `X-Auth-Client` / `X-Auth-Client-Name` 一致。传入且格式合法时,会在登录成功后写入该用户的 **应用接入记录**(见`authClients`)。
`clientId` / `clientName` 可选;规则与应用接入请求头一致。传入且格式合法时,会在登录成功后写入 **应用接入记录**(见**「应用接入记录(authClients)」**)。
响应:
```json
@@ -120,7 +168,9 @@
### 校验令牌
`POST /api/auth/verify`
请求
请求头:**`Content-Type: application/json`**。可选 **`X-Auth-Client`**、**`X-Auth-Client-Name`**(累计应用接入记录,见专章)。
请求体:
```json
{
"token": "jwt-token"
@@ -135,7 +185,7 @@
}
```
若账户已封禁,返回 **200**`valid`**false**(不返回 `user` 对象),示例
若账户已封禁,返回 **403**,正文示例(不返回 `user`
```json
{
@@ -145,28 +195,26 @@
}
```
令牌过期、签名错误、issuer 不匹配等解析失败时返回 **401**,示例:`{"valid": false, "error": "invalid token"}`
令牌过期、签名错误、issuer 不匹配`token` 被撤销(`token_epoch`等失败时返回 **401**,示例:`{"valid": false, "error": "invalid token"}``{"valid": false, "error": "token revoked"}`(以服务端实际字段为准)
`verify``me` 的取舍:**仅校验身份、不改变用户数据**时用 `verify`;需要最新资料、签到状态或写入「最后访问」时用 `GET /api/auth/me`(需 Bearer
`verify``me` 的取舍:**仅校验身份、默认不更新「最后访问」等侧写**时用 `verify`;需要最新资料、签到状态或写入「最后访问」时用 `GET /api/auth/me`(需 Bearer
**应用接入记录(可选)**:第三方**`POST /api/auth/verify`** 或 **`GET /api/auth/me`** 上携带请求头:
- `X-Auth-Client`:应用 ID格式同登录 JSON 的 `clientId`
- `X-Auth-Client-Name`:可选展示名
校验成功且用户未封禁时,服务端会更新该用户 JSON 中的 `authClients` 数组(`clientId``displayName``firstSeenAt``lastSeenAt`)。**`POST /api/auth/verify` 的响应体 `user` 仍为 `Public()`,不含 `authClients`**,避免向调用方泄露用户在其他应用的接入情况;**`GET /api/auth/me`** 与管理员列表中的 `user``OwnerPublic`**包含** `authClients`,用户可在统一登录前端的个人中心查看。
**应用接入记录****`verify` / `me`** 上使用 **`X-Auth-Client`**、**`X-Auth-Client-Name`** 的约定,以及响应 `user` 是否包含 `authClients`,见上文 **「应用接入记录authClients」**。
### 获取当前用户信息
`GET /api/auth/me`
请求头:
`Authorization: Bearer <jwt-token>`
- **`Authorization: Bearer <jwt-token>`**(必填)
可选 — **应用接入记录**(与 `verify` 相同语义,见 **「应用接入记录authClients」**
- **`X-Auth-Client`**、**`X-Auth-Client-Name`**
可选(由前端调用 `https://cf-ip-geo.smyhub.com/api` 等接口解析后传入,用于记录「最后访问 IP」与「最后显示位置」
- `X-Visit-Ip`:客户端公网 IP与地理接口返回的 `ip` 一致即可)
- `X-Visit-Location`:展示用位置文案(例如将 `geo.countryName``regionName``cityName` 拼接为 `中国 四川 成都`
**服务端回退(避免浏览器跨域导致头缺失)**:若未传 `X-Visit-Location`,后端会用 `X-Visit-Ip`;若也未传 `X-Visit-Ip`,则用连接的 `ClientIP()`(请在前置反向代理上正确传递 `X-Forwarded-For` 等,并在生产环境为 Gin 配置可信代理)。随后服务端请求 `GEO_LOOKUP_URL`(默认 `https://cf-ip-geo.smyhub.com/api?ip=<ip>`)解析展示位置并写入用户记录。
**服务端回退(避免浏览器跨域导致头缺失)**:若未传 `X-Visit-Location`,后端会用 `X-Visit-Ip`;若也未传 `X-Visit-Ip`,则用连接的 `ClientIP()`(请在前置反向代理上正确传递 `X-Forwarded-For` 等,并在生产环境为 Gin 配置可信代理)。随后服务端请求环境变量 **`GEO_LOOKUP_URL`** 指定的基址(默认 `internal/clientgeo.DefaultLookupURL` 一致:`https://cf-ip-geo.smyhub.com/api`,实际请求会附加 **`?ip=`**)解析展示位置并写入用户记录。
响应:
```json
@@ -184,7 +232,7 @@
> `user` 还会包含 `lastVisitAt``lastVisitDate``checkInDays``checkInStreak``visitDays``visitStreak` 等统计字段。
> 在登录用户本人、管理员列表等场景下,`user` 还可包含 `lastVisitIp``lastVisitDisplayLocation`(最近一次通过 `/api/auth/me` 上报的访问 IP 与位置文案)。**公开用户资料接口** `GET /api/public/users/:account`**`POST /api/auth/verify``user` 中不包含这两项**(避免公开展示或第三方校验时令牌响应携带访问隐私)。
> 在登录用户本人、管理员列表等场景下,`user` 还可包含 `lastVisitIp``lastVisitDisplayLocation`(最近一次通过 `/api/auth/me` 上报的访问 IP 与位置文案)。**`POST /api/auth/verify` 返回`user``Public()`中不包含这两项**(避免第三方校验响应携带访问隐私)。**`GET /api/public/users/:account``user``PublicProfile()`)会包含这两项**,与公开主页展示策略一致。
> 说明:密码不会返回。
@@ -237,16 +285,42 @@
## 用户广场
### 获取公开用户目录
`GET /api/public/users`
无需鉴权。返回未封禁用户的简要列表,默认按 **`createdAt`**(注册时间)升序。
响应:
```json
{
"total": 2,
"users": [
{
"account": "demo",
"username": "示例用户",
"level": 0,
"sproutCoins": 10,
"avatarUrl": "https://example.com/avatar.png",
"websiteUrl": "https://example.com",
"bio": "### 简介",
"createdAt": "2026-03-14T12:00:00+08:00"
}
]
}
```
### 获取用户公开主页
`GET /api/public/users/{account}`
说明:
- 仅支持账户名 `account`,不支持昵称查询。
- 仅支持路径参数 **账户名** `account`(可与存储大小写不同,服务端按不区分大小写匹配),不支持昵称查询。
- 适合第三方应用展示用户公开资料。
- 若该账户已被封禁,返回 **404** `{"error":"user not found"}`(与不存在账户相同,避免公开资料泄露)。
- 响应中含该用户**最近一次被服务端记录的**访问 IP`lastVisitIp`)与展示用地理位置(`lastVisitDisplayLocation`,与本人中心一致);`POST /api/auth/verify` 返回的用户 JSON **不含**上述两项
- 响应体在 `user` 之外包含累计赞数 **`profileLikeCount`**
- **`Authorization: Bearer`** 可选:若传入**有效未封禁**用户令牌且非主页主人,响应可额外包含 `viewerHasLikedToday``viewerLikesRemainingToday``profileLikeDailyMax`(当日是否已赞该主页、当日还可给多少人点赞、每自然日上限当前为 **5**,以后端常量为准)。若浏览者即主页主人,可返回 `viewerIsOwner: true`
- `user` 中含该用户**最近一次被服务端记录的**访问 IP`lastVisitIp`)与展示地理位置(`lastVisitDisplayLocation``POST /api/auth/verify` 返回的用户 JSON **不含**上述两项。
响应:
响应示例(未带 Bearer
```json
{
"user": {
@@ -259,7 +333,29 @@
"lastVisitIp": "203.0.113.1",
"lastVisitDisplayLocation": "中国 广东省 深圳市",
"bio": "### 简介"
}
},
"profileLikeCount": 12
}
```
### 公开主页点赞
`POST /api/public/users/{account}/like`
请求头:**`Authorization: Bearer <jwt-token>`**(必填)。
为路径中 **`account`** 对应用户的公开主页点赞。规则摘要(与 `internal/storage` 一致):
- 不能给自己点赞;每个点赞者对同一主页 **每个自然日最多一次**
- 每个点赞者每个自然日最多给 **5** 位**不同**用户点赞(超限返回 **400**,正文中可含 `viewerLikesRemainingToday``profileLikeDailyMax`)。
- 目标用户不存在或已封禁:**404**;点赞者封禁或令牌无效:**401** / **403** 等,以服务端 `error` 为准。
成功响应示例:
```json
{
"profileLikeCount": 13,
"viewerHasLikedToday": true,
"viewerLikesRemainingToday": 4,
"profileLikeDailyMax": 5
}
```
@@ -498,9 +594,9 @@
```
- `banned`:是否封禁;解封时请传 `false`,并可将 `banReason` 置为空字符串。
- `banReason`:仅当用户处于封禁状态时允许设为非空;封禁时若首次写入会记录 `bannedAt`RFC3339存于用户 JSON)。
- `banReason`:仅当用户处于封禁状态时允许设为非空;封禁时若首次写入会记录 `bannedAt`RFC3339存于数据库用户记录)。
管理员列表 `GET /api/admin/users` 中每条 `user` 可含 `banned``banReason`(不含 `bannedAt` 亦可从存储文件中查看)
管理员列表 `GET /api/admin/users` 中每条 `user` `OwnerPublic()`可含 `banned``banReason``bannedAt`(及 `authClients` 等)——以后端实际 JSON 为准
### 删除用户
`DELETE /api/admin/users/{account}`
@@ -512,14 +608,20 @@
## 数据存储说明
- 用户数据:`data/users/*.json`
- 注册待验证:`data/pending/*.json`
- 密码重置记录:`data/reset/*.json`
- 辅助邮箱验证:`data/secondary/*.json`
- 管理员 Token`data/config/admin.json`
- JWT 配置:`data/config/auth.json`
- 邮件配置:`data/config/email.json`
- 注册策略与邀请码:`data/config/registration.json`
当前发行版**以 MySQL 为唯一运行时数据源**GORM `AutoMigrate`),不再使用 `data/users/*.json` 等文件作为线上读写路径。表与用途概要:
| 表名 | 用途 |
|------|------|
| `users` | 用户主数据;`auth_clients` 等为 JSON 列 |
| `pending_users` | 注册邮箱待验证 |
| `password_resets` | 找回密码验证码 |
| `secondary_email_verifications` | 辅助邮箱验证 |
| `app_configs` | `admin` / `auth` / `email` / `checkin` / `registration` 等 JSON 配置 |
| `invite_codes` | 邀请码 |
| `profile_likes` | 公开主页点赞记录 |
| `profile_like_daily_quota` | 点赞每日额度辅助 |
从旧版 **`sproutgate-backend/data/`** 目录迁移时,使用 **`go run ./cmd/migrate --data-dir ./data`**(详见 **`sproutgate-backend/后端文档.md`**)。
## 快速联调用示例
@@ -535,9 +637,19 @@ curl -X POST http://localhost:8080/api/auth/login \
# 校验令牌(推荐第三方网关先调此接口)
curl -X POST http://localhost:8080/api/auth/verify \
-H 'Content-Type: application/json' \
-H 'X-Auth-Client: my-app' \
-d '{"token":"<jwt-token>"}'
# 使用令牌获取用户信息(会更新访问记录)
# 同上,并携带应用展示名(累计应用接入记录)
curl -X POST http://localhost:8080/api/auth/verify \
-H 'Content-Type: application/json' \
-H 'X-Auth-Client: infogenie' \
-H 'X-Auth-Client-Name: 万象口袋' \
-d '{"token":"<jwt-token>"}'
# 使用令牌获取用户信息(会更新访问记录);可一并携带 X-Auth-Client 刷新「应用接入」最近时间
curl http://localhost:8080/api/auth/me \
-H 'Authorization: Bearer <jwt-token>'
-H 'Authorization: Bearer <jwt-token>' \
-H 'X-Auth-Client: infogenie' \
-H 'X-Auth-Client-Name: 万象口袋'
```