🚚 移动前端
This commit is contained in:
1 parent
c8f2152d0a
commit
b851a96a66
57 files changed
+214
-418
No files matched your search
@@ -1,44 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<title>用户使用条例</title>
|
||||
<link rel="stylesheet" href="css/theme.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="card" style="max-width:640px;text-align:left">
|
||||
<h2>用户使用条例</h2>
|
||||
<p style="font-size:13px;color:var(--fg-secondary);margin-bottom:16px">生效日期:2026 年 7 月 · 本平台为开源社区</p>
|
||||
|
||||
<h3 style="font-size:15px;margin:16px 0 6px">一、社区定位</h3>
|
||||
<p style="font-size:14px;line-height:1.8;color:var(--fg-secondary)">本社区是一个以开源精神为基础的技术分享与交流平台,鼓励自由分享、协作共建、互相尊重。</p>
|
||||
|
||||
<h3 style="font-size:15px;margin:16px 0 6px">二、内容规范</h3>
|
||||
<ol style="margin:0 0 0 20px;font-size:14px;line-height:1.8;color:var(--fg-secondary)">
|
||||
<li>遵守国家法律法规,不得发布色情、赌博、诈骗、侵权、暴力恐怖等违法违规内容。</li>
|
||||
<li>发布的内容将经 AI 审核,违规内容将被拒绝展示或删除,恕不另行通知。</li>
|
||||
<li>不得利用本平台从事破坏、攻击、爬取扰序等影响服务稳定运行的行为。</li>
|
||||
</ol>
|
||||
|
||||
<h3 style="font-size:15px;margin:16px 0 6px">三、开源精神</h3>
|
||||
<ol style="margin:0 0 0 20px;font-size:14px;line-height:1.8;color:var(--fg-secondary)">
|
||||
<li>尊重他人劳动成果,转载或引用请注明出处与作者。</li>
|
||||
<li>鼓励以开源许可证分享作品,并遵守相应许可证条款。</li>
|
||||
<li>友善交流,就事论事,不人身攻击。</li>
|
||||
</ol>
|
||||
|
||||
<h3 style="font-size:15px;margin:16px 0 6px">四、账号责任</h3>
|
||||
<p style="font-size:14px;line-height:1.8;color:var(--fg-secondary)">妥善保管账号与密码,账号下的全部行为由注册人本人负责。如发现账号被盗用,请及时通过找回密码功能重置。</p>
|
||||
|
||||
<h3 style="font-size:15px;margin:16px 0 6px">五、服务说明</h3>
|
||||
<p style="font-size:14px;line-height:1.8;color:var(--fg-secondary)">本社区按"现状"提供服务,不对服务的持续性、可用性作担保。对违反本条例的账号,平台有权采取内容删除、功能限制、账号封禁等措施。</p>
|
||||
|
||||
<h3 style="font-size:15px;margin:16px 0 6px">六、条例修订</h3>
|
||||
<p style="font-size:14px;line-height:1.8;color:var(--fg-secondary)">本条例可能适时修订并公示于本页面,继续使用本平台即视为接受最新版本。</p>
|
||||
</div>
|
||||
<footer class="footer">
|
||||
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener">蜀ICP备2024107064号-2</a>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 11 KiB |
@@ -1,53 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=yes">
|
||||
<title>树菜单 · 文件浏览</title>
|
||||
<link rel="stylesheet" href="/css/view.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="header">
|
||||
<h1>📁 树菜单 · 文件浏览</h1>
|
||||
<nav class="header-actions">
|
||||
<a href="/" title="根目录">🏠</a>
|
||||
<a href="#" id="btnRefresh" title="刷新">🔄</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<nav class="breadcrumb" id="breadcrumb">
|
||||
<a href="/">根目录</a>
|
||||
</nav>
|
||||
|
||||
<div class="search-box">
|
||||
<input type="text" id="searchInput" placeholder="搜索文件…(快捷键 / 或 Ctrl+F)" autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div class="stats">
|
||||
<span>📊 共 <span id="totalItems">0</span> 项</span>
|
||||
<span>📁 <span id="totalDirs">0</span></span>
|
||||
<span>📄 <span id="totalFiles">0</span></span>
|
||||
<span>🎬 <span id="totalVideos">0</span></span>
|
||||
<span>🎵 <span id="totalAudios">0</span></span>
|
||||
</div>
|
||||
|
||||
<main class="container">
|
||||
<div class="loading" id="loading">
|
||||
<span class="spinner">⏳</span>
|
||||
<div>加载中...</div>
|
||||
</div>
|
||||
<div id="fileList" class="file-grid"></div>
|
||||
<div class="empty" id="empty" style="display:none;">
|
||||
<span class="icon">📭</span>
|
||||
<div>此目录为空</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener">蜀ICP备2024107064号-2</a>
|
||||
</footer>
|
||||
|
||||
<script src="/js/validation.js"></script>
|
||||
<script src="/js/view.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,38 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<!-- viewport-fit=cover:配合 safe-area 适配刘海屏;maximum-scale 防输入框聚焦自动缩放 -->
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=no,viewport-fit=cover">
|
||||
<title>aibox 智能体</title>
|
||||
<link rel="stylesheet" href="css/theme.css">
|
||||
<link rel="stylesheet" href="css/aibox.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<header id="topbar">
|
||||
<span class="title">🤖 aibox 智能体</span>
|
||||
<span id="conn-status" class="disconnected"><span class="dot"></span><b id="status-text">未连接</b></span>
|
||||
<button id="task-toggle" type="button">📋 任务<span id="task-badge"></span></button>
|
||||
<button id="conn-btn" type="button">连接</button>
|
||||
</header>
|
||||
<div id="admin-tip" style="display:none"></div>
|
||||
<main id="chat"></main>
|
||||
<div id="input-bar">
|
||||
<button id="interrupt-btn" type="button" style="display:none" title="中断当前生成">⏸</button>
|
||||
<!-- enterkeyhint=send:移动端软键盘回车键显示"发送" -->
|
||||
<textarea id="input" rows="1" enterkeyhint="send" placeholder="输入消息,/help 查看命令"></textarea>
|
||||
<button id="send-btn" type="button">发送</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 任务面板:始终为抽屉式覆盖层(移动端唯一形态) -->
|
||||
<aside id="task-panel">
|
||||
<header>任务面板<button id="task-close" type="button" aria-label="关闭">×</button></header>
|
||||
<div id="task-list"></div>
|
||||
</aside>
|
||||
<div id="backdrop"></div>
|
||||
<script src="js/markdown-it-12_0_0.js"></script>
|
||||
<script src="js/token_manager.js"></script>
|
||||
<script src="js/aibox.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,530 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=yes, viewport-fit=cover">
|
||||
<title>中国传统色彩 - 移动端适配版</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", Roboto, Helvetica, Arial, sans-serif;
|
||||
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
|
||||
min-height: 100vh;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
border-radius: 24px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 32px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 1.9rem;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
.header p {
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* 移动端选项卡优化 */
|
||||
.tabs {
|
||||
display: flex;
|
||||
background: #f8f9fa;
|
||||
border-bottom: 2px solid #e9ecef;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
padding: 14px 8px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
color: #6c757d;
|
||||
border: none;
|
||||
background: none;
|
||||
position: relative;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: #667eea;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tab.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, #667eea, #764ba2);
|
||||
border-radius: 3px 3px 0 0;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
display: none;
|
||||
padding: 20px 16px;
|
||||
}
|
||||
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
animation: fadeIn 0.25s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* 移动端:色彩网格改为2列布局,卡片更易点击 */
|
||||
.color-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
/* 针对小屏手机强制2列 */
|
||||
@media (max-width: 480px) {
|
||||
.color-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
.header h1 {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
.tab {
|
||||
font-size: 0.85rem;
|
||||
padding: 12px 4px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 中等屏幕自动调整 */
|
||||
@media (min-width: 481px) and (max-width: 768px) {
|
||||
.color-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 769px) {
|
||||
.color-grid {
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.color-card {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.color-card:active {
|
||||
transform: scale(0.97);
|
||||
transition: 0.05s;
|
||||
}
|
||||
|
||||
.color-swatch {
|
||||
height: 100px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: white;
|
||||
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.2);
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
.color-info {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.color-name {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: #1e293b;
|
||||
margin-bottom: 4px;
|
||||
white-space: nowrap;
|
||||
overflow-x: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.color-code {
|
||||
font-size: 0.7rem;
|
||||
color: #6c757d;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
/* Toast 提示优化 - 移动端居中底部 */
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(100px);
|
||||
background: #1e293b;
|
||||
color: white;
|
||||
padding: 12px 24px;
|
||||
border-radius: 40px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
opacity: 0;
|
||||
transition: all 0.25s ease;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.2);
|
||||
backdrop-filter: blur(4px);
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.toast {
|
||||
white-space: normal;
|
||||
max-width: 80%;
|
||||
text-align: center;
|
||||
padding: 10px 18px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
}
|
||||
|
||||
.toast.show {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
|
||||
/* 配色方案区域 - 移动端卡片布局优化 */
|
||||
#palettes-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
#palettes-container {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.palette-section {
|
||||
margin-bottom: 0;
|
||||
background: #fefefe;
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
|
||||
transition: 0.2s;
|
||||
}
|
||||
|
||||
.palette-title {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
margin: 0;
|
||||
padding: 12px 16px 8px 16px;
|
||||
background: #fafcff;
|
||||
border-bottom: 1px solid #eef2f6;
|
||||
}
|
||||
|
||||
.color-palette {
|
||||
display: flex;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border-radius: 0 0 16px 16px;
|
||||
overflow: hidden;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.color-palette:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.palette-color {
|
||||
flex: 1;
|
||||
min-width: 0; /* 防止flex溢出 */
|
||||
height: 80px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
transition: 0.1s;
|
||||
}
|
||||
|
||||
/* 移动端让颜色代码折行更友好 */
|
||||
.palette-color span:first-child {
|
||||
font-size: 0.7rem;
|
||||
font-family: monospace;
|
||||
background: rgba(0,0,0,0.3);
|
||||
padding: 2px 6px;
|
||||
border-radius: 30px;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.palette-color span:last-child {
|
||||
font-size: 0.6rem;
|
||||
opacity: 0.9;
|
||||
background: rgba(0,0,0,0.2);
|
||||
padding: 2px 6px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
/* 说明文字区域移动端优化 */
|
||||
.palettes-desc {
|
||||
background: #f1f5f9;
|
||||
margin: 0 16px 16px 16px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.8rem;
|
||||
color: #475569;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.palettes-desc strong {
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
/* 增加触摸反馈 */
|
||||
.color-card, .color-palette {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 滚动条隐藏但保持功能 (移动端美观) */
|
||||
::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>🎨 中国传统色彩</h1>
|
||||
<p>轻触颜色块,复制色值</p>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<button class="tab active" data-tab="traditional">中国传统色</button>
|
||||
<button class="tab" data-tab="chinese">中文色彩</button>
|
||||
<button class="tab" data-tab="palettes">色彩搭配</button>
|
||||
</div>
|
||||
|
||||
<div class="tab-content active" id="traditional">
|
||||
<div class="color-grid" id="traditional-grid"></div>
|
||||
</div>
|
||||
|
||||
<div class="tab-content" id="chinese">
|
||||
<div class="color-grid" id="chinese-grid"></div>
|
||||
</div>
|
||||
|
||||
<div class="tab-content" id="palettes">
|
||||
<div class="palettes-desc">
|
||||
💡 <strong>主色</strong>(第一色)品牌核心色 · <strong>辅助色</strong>(第二色)点缀强调 · <strong>中性色</strong>(第三色)文字/背景<br>
|
||||
✨ 点击配色块,一键复制整套色彩代码(三个色值)。
|
||||
</div>
|
||||
<div id="palettes-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast">🎨 颜色代码已复制</div>
|
||||
|
||||
<script>
|
||||
// 原始数据保持不变(完全未修改接口/功能)
|
||||
const traditionalColors = [
|
||||
{ name: '蔚蓝', hex: '#70f3ff' }, { name: '蓝', hex: '#44cef6' }, { name: '碧蓝', hex: '#3eede7' }, { name: '石青', hex: '#1685a9' }, { name: '靛青', hex: '#177cb0' },
|
||||
{ name: '靛蓝', hex: '#065279' }, { name: '花青', hex: '#003472' }, { name: '宝蓝', hex: '#4b5cc4' }, { name: '蓝灰色', hex: '#a1afc9' }, { name: '藏青', hex: '#2e4e7e' },
|
||||
{ name: '藏蓝', hex: '#3b2e7e' }, { name: '黛', hex: '#4a4266' }, { name: '黛绿', hex: '#426666' }, { name: '黛蓝', hex: '#425066' }, { name: '黛紫', hex: '#574266' },
|
||||
{ name: '紫色', hex: '#8d4bbb' }, { name: '紫酱', hex: '#815463' }, { name: '酱紫', hex: '#815476' }, { name: '紫檀', hex: '#4c221b' }, { name: '绀青', hex: '#003371' },
|
||||
{ name: '紫棠', hex: '#56004f' }, { name: '青莲', hex: '#801dae' }, { name: '群青', hex: '#4c8dae' }, { name: '雪青', hex: '#b0a4e3' }, { name: '丁香色', hex: '#cca4e3' },
|
||||
{ name: '藕色', hex: '#edd1d8' }, { name: '藕荷色', hex: '#e4c6d0' }, { name: '朱砂', hex: '#ff461f' }, { name: '火红', hex: '#ff2d51' }, { name: '朱膘', hex: '#f36838' },
|
||||
{ name: '妃色', hex: '#ed5736' }, { name: '洋红', hex: '#ff4777' }, { name: '品红', hex: '#f00056' }, { name: '粉红', hex: '#ffb3a7' }, { name: '桃红', hex: '#f47983' },
|
||||
{ name: '海棠红', hex: '#db5a6b' }, { name: '樱桃色', hex: '#c93756' }, { name: '酡颜', hex: '#f9906f' }, { name: '银红', hex: '#f05654' }, { name: '大红', hex: '#ff2121' },
|
||||
{ name: '石榴红', hex: '#f20c00' }, { name: '绛紫', hex: '#8c4356' }, { name: '绯红', hex: '#c83c23' }, { name: '胭脂', hex: '#9d2933' }, { name: '朱红', hex: '#ff4c00' },
|
||||
{ name: '丹', hex: '#ff4e20' }, { name: '彤', hex: '#f35336' }, { name: '酡红', hex: '#dc3023' }, { name: '炎', hex: '#ff3300' }, { name: '茜色', hex: '#cb3a56' },
|
||||
{ name: '绾', hex: '#a98175' }, { name: '檀', hex: '#b36d61' }, { name: '嫣红', hex: '#ef7a82' }, { name: '枣红', hex: '#c32136' }, { name: '殷红', hex: '#be002f' },
|
||||
{ name: '赫赤', hex: '#c91f37' }, { name: '银朱', hex: '#bf242a' }, { name: '赤', hex: '#c3272b' }, { name: '栗色', hex: '#60281e' }, { name: '玄色', hex: '#622a1d' },
|
||||
{ name: '松花色', hex: '#bce672' }, { name: '柳黄', hex: '#c9dd22' }, { name: '嫩绿', hex: '#bddd22' }, { name: '柳绿', hex: '#afdd22' }, { name: '葱黄', hex: '#a3d900' },
|
||||
{ name: '葱绿', hex: '#9ed900' }, { name: '豆绿', hex: '#9ed048' }, { name: '豆青', hex: '#96ce54' }, { name: '油绿', hex: '#00bc12' }, { name: '葱倩', hex: '#0eb83a' },
|
||||
{ name: '葱青', hex: '#0eb83a' }, { name: '青葱', hex: '#0aa344' }, { name: '石绿', hex: '#16a951' }, { name: '松柏绿', hex: '#21a675' }, { name: '松花绿', hex: '#057748' },
|
||||
{ name: '绿沈', hex: '#0c8918' }, { name: '绿色', hex: '#00e500' }, { name: '草绿', hex: '#40de5a' }, { name: '青翠', hex: '#00e079' }, { name: '青色', hex: '#00e09e' },
|
||||
{ name: '翡翠色', hex: '#3de1ad' }, { name: '碧绿', hex: '#2add9c' }, { name: '玉色', hex: '#2edfa3' }, { name: '缥', hex: '#7fecad' }, { name: '艾绿', hex: '#a4e2c6' },
|
||||
{ name: '碧色', hex: '#1bd1a5' }, { name: '青碧', hex: '#48c0a3' }, { name: '铜绿', hex: '#549688' }, { name: '竹青', hex: '#789262' }, { name: '墨灰', hex: '#758a99' },
|
||||
{ name: '墨色', hex: '#50616d' }, { name: '鸦青', hex: '#424c50' }, { name: '黯', hex: '#41555d' }, { name: '樱草色', hex: '#eaff56' }, { name: '鹅黄', hex: '#fff143' },
|
||||
{ name: '鸭黄', hex: '#faff72' }, { name: '杏黄', hex: '#ffa631' }, { name: '橙黄', hex: '#ffa400' }, { name: '橙色', hex: '#fa8c35' }, { name: '杏红', hex: '#ff8c31' },
|
||||
{ name: '橘黄', hex: '#ff8936' }, { name: '橘红', hex: '#ff7500' }, { name: '藤黄', hex: '#ffb61e' }, { name: '姜黄', hex: '#ffc773' }, { name: '雌黄', hex: '#ffc64b' },
|
||||
{ name: '赤金', hex: '#f2be45' }, { name: '缃色', hex: '#f0c239' }, { name: '雄黄', hex: '#e9bb1d' }, { name: '秋香色', hex: '#d9b611' }, { name: '金色', hex: '#eacd76' },
|
||||
{ name: '牙色', hex: '#eedeb0' }, { name: '枯黄', hex: '#d3b17d' }, { name: '黄栌', hex: '#e29c45' }, { name: '乌金', hex: '#a78e44' }, { name: '昏黄', hex: '#c89b40' },
|
||||
{ name: '棕黄', hex: '#ae7000' }, { name: '琥珀', hex: '#ca6924' }, { name: '棕色', hex: '#b25d25' }, { name: '茶色', hex: '#b35c44' }, { name: '棕红', hex: '#9b4400' },
|
||||
{ name: '赭', hex: '#9c5333' }, { name: '驼色', hex: '#a88462' }, { name: '秋色', hex: '#896c39' }, { name: '棕绿', hex: '#827100' }, { name: '褐色', hex: '#6e511e' },
|
||||
{ name: '棕黑', hex: '#7c4b00' }, { name: '赭色', hex: '#955539' }, { name: '赭石', hex: '#845a33' }, { name: '精白', hex: '#ffffff' }, { name: '银白', hex: '#e9e7ef' },
|
||||
{ name: '铅白', hex: '#f0f0f4' }, { name: '霜色', hex: '#e9f1f6' }, { name: '雪白', hex: '#f0fcff' }, { name: '莹白', hex: '#e3f9fd' }, { name: '月白', hex: '#d6ecf0' },
|
||||
{ name: '象牙白', hex: '#fffbf0' }, { name: '缟', hex: '#f2ecde' }, { name: '鱼肚白', hex: '#fcefe8' }, { name: '白粉', hex: '#fff2df' }, { name: '荼白', hex: '#f3f9f1' },
|
||||
{ name: '鸭卵青', hex: '#e0eee8' }, { name: '素', hex: '#e0f0e9' }, { name: '青白', hex: '#c0ebd7' }, { name: '蟹壳青', hex: '#bbcdc5' }, { name: '花白', hex: '#c2ccd0' },
|
||||
{ name: '老银', hex: '#bacac6' }, { name: '灰色', hex: '#808080' }, { name: '苍色', hex: '#75878a' }, { name: '水色', hex: '#88ada6' }, { name: '黝', hex: '#6b6882' },
|
||||
{ name: '乌色', hex: '#725e82' }, { name: '玄青', hex: '#3d3b4f' }, { name: '乌黑', hex: '#392f41' }, { name: '黎', hex: '#75664d' }, { name: '黧', hex: '#5d513c' },
|
||||
{ name: '黝黑', hex: '#665757' }, { name: '缁色', hex: '#493131' }, { name: '煤黑', hex: '#312520' }, { name: '漆黑', hex: '#161823' }, { name: '黑色', hex: '#000000' }
|
||||
];
|
||||
|
||||
const chineseColors = [
|
||||
{ name: '鸨色', hex: '#f7acbc' }, { name: '踯躅色', hex: '#ef5b9c' }, { name: '桜色', hex: '#feeeed' }, { name: '蔷薇色', hex: '#f05b72' }, { name: '韩红', hex: '#f15b6c' },
|
||||
{ name: '珊瑚色', hex: '#f8aba6' }, { name: '红梅色', hex: '#f69c9f' }, { name: '桃色', hex: '#f58f98' }, { name: '薄柿', hex: '#ca8687' }, { name: '薄红梅', hex: '#f391a9' },
|
||||
{ name: '曙色', hex: '#bd6758' }, { name: '红色', hex: '#d71345' }, { name: '赤丹', hex: '#d64f44' }, { name: '红赤', hex: '#d93a49' }, { name: '臙脂色', hex: '#b3424a' },
|
||||
{ name: '真赭', hex: '#c76968' }, { name: '今様色', hex: '#bb505d' }, { name: '梅染', hex: '#987165' }, { name: '退红色', hex: '#ac6767' }, { name: '苏芳', hex: '#973c3f' },
|
||||
{ name: '茜色', hex: '#b22c46' }, { name: '红', hex: '#a7324a' }, { name: '银朱', hex: '#aa363d' }, { name: '赤', hex: '#ed1941' }, { name: '朱色', hex: '#f26522' },
|
||||
{ name: '洗朱', hex: '#d2553d' }, { name: '红桦色', hex: '#b4534b' }, { name: '红绯', hex: '#ef4136' }, { name: '桦色', hex: '#c63c26' }, { name: '铅丹色', hex: '#f3715c' },
|
||||
{ name: '赭', hex: '#a7573b' }, { name: '绯色', hex: '#aa2116' }, { name: '丹', hex: '#b64533' }, { name: '土', hex: '#b54334' }, { name: '焦香', hex: '#853f04' },
|
||||
{ name: '真红', hex: '#840228' }, { name: '绯', hex: '#7a1723' }, { name: '红海老茶', hex: '#a03939' }, { name: '浅苏芳', hex: '#8a2e3b' }, { name: '鸢色', hex: '#8e453f' },
|
||||
{ name: '小豆色', hex: '#8f4b4a' }, { name: '弁柄色', hex: '#892f1b' }, { name: '栗梅', hex: '#6b2c25' }, { name: '海老茶', hex: '#733a31' }, { name: '深绯', hex: '#54211d' },
|
||||
{ name: '赤铜色', hex: '#78331e' }, { name: '赤褐色', hex: '#53261f' }, { name: '金赤', hex: '#f15a22' }, { name: '赤茶', hex: '#b4533c' }, { name: '赤锖色', hex: '#84331f' },
|
||||
{ name: '黄丹', hex: '#f47a55' }, { name: '赤橙', hex: '#f15a22' }, { name: '柿色', hex: '#f3704b' }, { name: '肉桂色', hex: '#da765b' }, { name: '炼瓦色', hex: '#ae5039' },
|
||||
{ name: '锖色', hex: '#6a3427' }, { name: '桧皮色', hex: '#8f4b38' }, { name: '栗色', hex: '#8e3e1f' }, { name: '黄赤', hex: '#f36c21' }, { name: '代赭', hex: '#b4532a' },
|
||||
{ name: '骆驼色', hex: '#b7704f' }, { name: '黄茶', hex: '#de773f' }, { name: '洗柿', hex: '#c99979' }, { name: '赤白橡', hex: '#deab8a' }, { name: '肌色', hex: '#fedcbd' },
|
||||
{ name: '橙色', hex: '#f47920' }, { name: '灰茶', hex: '#905a3d' }, { name: '茶色', hex: '#8f4b2e' }, { name: '桦茶色', hex: '#87481f' }, { name: '枯茶', hex: '#5f3c23' },
|
||||
{ name: '焦茶', hex: '#6b473c' }, { name: '柑子色', hex: '#faa755' }, { name: '杏色', hex: '#fab27b' }, { name: '蜜柑色', hex: '#f58220' }, { name: '褐色', hex: '#843900' },
|
||||
{ name: '路考茶', hex: '#905d1d' }, { name: '饴色', hex: '#8a5d19' }, { name: '丁子色', hex: '#8c531b' }, { name: '丁子茶', hex: '#826858' }, { name: '黄栌', hex: '#64492b' },
|
||||
{ name: '土器色', hex: '#ae6642' }, { name: '黄枯茶', hex: '#56452d' }, { name: '狐色', hex: '#96582a' }, { name: '黄橡', hex: '#705628' }, { name: '银煤竹', hex: '#4a3113' },
|
||||
{ name: '涅色', hex: '#412f1f' }, { name: '胡桃色', hex: '#845538' }, { name: '香色', hex: '#8e7437' }, { name: '国防色', hex: '#69541b' }, { name: '练色', hex: '#d5c59f' },
|
||||
{ name: '肉色', hex: '#cd9a5b' }, { name: '人色', hex: '#cd9a5b' }, { name: '土色', hex: '#b36d41' }, { name: '小麦色', hex: '#df9464' }, { name: '琥珀色', hex: '#b76f40' },
|
||||
{ name: '木兰色', hex: '#ad8b3d' }, { name: '栀子色', hex: '#dea32c' }, { name: '朽叶', hex: '#d1923f' }, { name: '萱草色', hex: '#c88400' }, { name: '黄金', hex: '#c37e00' },
|
||||
{ name: '金色', hex: '#c37e00' }, { name: '金茶', hex: '#e0861a' }, { name: '卵色', hex: '#ffce7b' }, { name: '山吹色', hex: '#fcaf17' }, { name: '黄土色', hex: '#ba8448' },
|
||||
{ name: '朽叶色', hex: '#896a45' }, { name: '空五倍子色', hex: '#76624c' }, { name: '莺茶', hex: '#6d5826' }, { name: '向日葵色', hex: '#ffc20e' }, { name: '郁金色', hex: '#fdb933' },
|
||||
{ name: '砂色', hex: '#d3c6a6' }, { name: '芥子色', hex: '#c7a252' }, { name: '淡黄', hex: '#dec674' }, { name: '亜麻色', hex: '#b69968' }, { name: '枯色', hex: '#c1a173' }
|
||||
];
|
||||
|
||||
const palettes = [
|
||||
{ title: '清新自然', colors: ['#FFFFCC', '#CCFFFF', '#FFCCCC'] }, { title: '雅致灰调', colors: ['#99CCCC', '#FFCC99', '#FFCCCC'] }, { title: '粉紫梦境', colors: ['#FF9999', '#996699', '#FFCCCC'] },
|
||||
{ title: '大地色系', colors: ['#CC9999', '#FFFFCC', '#CCCC99'] }, { title: '柔和暖阳', colors: ['#FFCCCC', '#FFFF99', '#CCCCFF'] }, { title: '海蓝印象', colors: ['#0099CC', '#CCCCCC', '#FF6666'] },
|
||||
{ title: '珊瑚橘粉', colors: ['#FF9966', '#FF6666', '#FFCCCC'] }, { title: '木质复古', colors: ['#CC9966', '#666666', '#CC9999'] }, { title: '翠绿春意', colors: ['#FF6666', '#FFFF66', '#99CC66'] },
|
||||
{ title: '深海静寂', colors: ['#CC3333', '#CCCCCC', '#003366'] }, { title: '紫罗兰夜', colors: ['#993333', '#CCCC00', '#663366'] }, { title: '燕麦拿铁', colors: ['#CCCC99', '#666666', '#CC9999'] },
|
||||
{ title: '柠檬水岸', colors: ['#FF6666', '#FFFF00', '#0066CC'] }, { title: '红黑经典', colors: ['#CC0033', '#333333', '#CCCC00'] }, { title: '森林与酒', colors: ['#336633', '#990033', '#FFCC99'] }
|
||||
];
|
||||
|
||||
function showToast(msg) {
|
||||
const toast = document.getElementById('toast');
|
||||
toast.textContent = msg || '🎨 颜色代码已复制';
|
||||
toast.classList.add('show');
|
||||
setTimeout(() => toast.classList.remove('show'), 1800);
|
||||
}
|
||||
|
||||
function copyToClipboard(text, customMsg) {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
showToast(customMsg || `✅ 已复制: ${text}`);
|
||||
}).catch(() => {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
showToast(customMsg || `📋 已复制: ${text}`);
|
||||
});
|
||||
}
|
||||
|
||||
function getContrastColor(hex) {
|
||||
let r = parseInt(hex.slice(1, 3), 16);
|
||||
let g = parseInt(hex.slice(3, 5), 16);
|
||||
let b = parseInt(hex.slice(5, 7), 16);
|
||||
const brightness = (r * 299 + g * 587 + b * 114) / 1000;
|
||||
return brightness > 128 ? '#1e1e2f' : '#ffffff';
|
||||
}
|
||||
|
||||
function createColorCard(color) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'color-card';
|
||||
card.onclick = () => copyToClipboard(color.hex, `🎨 ${color.name} → ${color.hex}`);
|
||||
const textColor = getContrastColor(color.hex);
|
||||
card.innerHTML = `
|
||||
<div class="color-swatch" style="background: ${color.hex}; color: ${textColor};">${color.hex}</div>
|
||||
<div class="color-info">
|
||||
<div class="color-name">${color.name}</div>
|
||||
<div class="color-code">${color.hex}</div>
|
||||
</div>
|
||||
`;
|
||||
return card;
|
||||
}
|
||||
|
||||
function createPalette(palette) {
|
||||
const section = document.createElement('div');
|
||||
section.className = 'palette-section';
|
||||
const title = document.createElement('div');
|
||||
title.className = 'palette-title';
|
||||
title.textContent = palette.title;
|
||||
const paletteDiv = document.createElement('div');
|
||||
paletteDiv.className = 'color-palette';
|
||||
paletteDiv.onclick = () => {
|
||||
const allCodes = palette.colors.join(' ');
|
||||
copyToClipboard(allCodes, `🎨 配色方案「${palette.title}」已复制: ${allCodes}`);
|
||||
};
|
||||
const roles = ['主色', '辅色', '中性'];
|
||||
palette.colors.forEach((color, idx) => {
|
||||
const colorDiv = document.createElement('div');
|
||||
colorDiv.className = 'palette-color';
|
||||
colorDiv.style.background = color;
|
||||
colorDiv.style.color = getContrastColor(color);
|
||||
const codeSpan = document.createElement('span');
|
||||
codeSpan.textContent = color;
|
||||
const roleSpan = document.createElement('span');
|
||||
roleSpan.textContent = roles[idx] || '';
|
||||
colorDiv.appendChild(codeSpan);
|
||||
colorDiv.appendChild(roleSpan);
|
||||
paletteDiv.appendChild(colorDiv);
|
||||
});
|
||||
section.appendChild(title);
|
||||
section.appendChild(paletteDiv);
|
||||
return section;
|
||||
}
|
||||
|
||||
function init() {
|
||||
const traditionalGrid = document.getElementById('traditional-grid');
|
||||
traditionalColors.forEach(c => traditionalGrid.appendChild(createColorCard(c)));
|
||||
const chineseGrid = document.getElementById('chinese-grid');
|
||||
chineseColors.forEach(c => chineseGrid.appendChild(createColorCard(c)));
|
||||
const palettesContainer = document.getElementById('palettes-container');
|
||||
palettes.forEach(p => palettesContainer.appendChild(createPalette(p)));
|
||||
}
|
||||
|
||||
document.querySelectorAll('.tab').forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
||||
tab.classList.add('active');
|
||||
const tabId = tab.dataset.tab;
|
||||
document.getElementById(tabId).classList.add('active');
|
||||
});
|
||||
});
|
||||
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,208 +0,0 @@
|
||||
/* ===== 移动端全屏应用布局(覆盖 theme.css 的居中卡片页默认) ===== */
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
display: block;
|
||||
padding: 0;
|
||||
height: 100dvh; /* 移动端动态视口高度,规避地址栏遮挡 */
|
||||
overflow: hidden;
|
||||
overscroll-behavior: none; /* 禁止下拉刷新/回弹手势干扰聊天滚动 */
|
||||
}
|
||||
#app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100dvh;
|
||||
background: var(--bg);
|
||||
}
|
||||
/* 移动端触控优化:去点击高亮、去双击缩放延迟 */
|
||||
button, textarea { -webkit-tap-highlight-color: transparent; }
|
||||
button { touch-action: manipulation; }
|
||||
|
||||
/* ===== 顶栏 ===== */
|
||||
#topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
padding-top: calc(10px + env(safe-area-inset-top));
|
||||
background: var(--card);
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
flex: none;
|
||||
}
|
||||
#topbar .title { font-size: 1rem; font-weight: 700; flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
#conn-status { display: flex; align-items: center; gap: 5px; font-size: 0.75rem; color: var(--fg-secondary); flex: none; }
|
||||
#conn-status .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); }
|
||||
#conn-status.connected .dot { background: var(--success); box-shadow: 0 0 6px var(--success); }
|
||||
#conn-status.connecting .dot { background: var(--warning); }
|
||||
#conn-status.disconnected .dot { background: var(--error); }
|
||||
#topbar button { flex: none; padding: 0.45rem 0.8rem; font-size: 0.8rem; min-height: 36px; }
|
||||
#task-toggle { background: var(--accent-soft); color: var(--accent-dark); position: relative; }
|
||||
#task-badge {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: -6px; right: -6px;
|
||||
min-width: 16px; height: 16px;
|
||||
border-radius: 8px;
|
||||
background: var(--error);
|
||||
color: #fff;
|
||||
font-size: 0.62rem;
|
||||
line-height: 16px;
|
||||
text-align: center;
|
||||
padding: 0 4px;
|
||||
}
|
||||
#conn-btn { background: var(--accent-soft); color: var(--accent-dark); }
|
||||
#conn-btn.on { background: var(--error-bg); color: var(--error); }
|
||||
|
||||
/* ===== 非管理员提示条 ===== */
|
||||
#admin-tip {
|
||||
flex: none;
|
||||
padding: 8px 12px;
|
||||
font-size: 0.78rem;
|
||||
background: var(--error-bg);
|
||||
color: var(--error);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ===== 聊天区 ===== */
|
||||
#chat {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 14px 12px;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior: contain; /* 滚动到底不连带拉动 body */
|
||||
}
|
||||
.msg { margin-bottom: 12px; overflow-wrap: break-word; }
|
||||
.msg-user {
|
||||
margin-left: 15%;
|
||||
padding: 0.65rem 0.9rem;
|
||||
background: var(--accent-soft);
|
||||
border-radius: var(--radius-md) var(--radius-md) 4px var(--radius-md);
|
||||
font-size: 0.92rem;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.msg-ai {
|
||||
margin-right: 6%;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius-md) var(--radius-md) var(--radius-md) 4px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.65;
|
||||
}
|
||||
.msg-ai p { margin: 0.4em 0; }
|
||||
.msg-ai p:first-child { margin-top: 0; }
|
||||
.msg-ai p:last-child { margin-bottom: 0; }
|
||||
.msg-ai pre {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.6rem;
|
||||
overflow-x: auto;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.msg-ai code { font-size: 0.82em; background: var(--accent-soft); border-radius: 4px; padding: 0.1em 0.3em; }
|
||||
.msg-ai pre code { background: none; padding: 0; }
|
||||
.msg-ai a { color: var(--accent); }
|
||||
.msg-ai.streaming::after {
|
||||
content: '▌';
|
||||
color: var(--accent);
|
||||
animation: blink 0.9s steps(1) infinite;
|
||||
}
|
||||
@keyframes blink { 50% { opacity: 0; } }
|
||||
.msg-system, .msg-error { text-align: center; font-size: 0.78rem; color: var(--fg-secondary); }
|
||||
.msg-error { color: var(--error); }
|
||||
|
||||
/* 思考区域:灰色斜体可折叠 */
|
||||
.msg-reasoning {
|
||||
margin-right: 6%;
|
||||
border-left: 3px solid var(--border);
|
||||
padding: 0.4rem 0.8rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
font-style: italic;
|
||||
}
|
||||
.reasoning-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
font-style: normal;
|
||||
font-size: 0.75rem;
|
||||
color: var(--fg-secondary);
|
||||
min-height: 28px;
|
||||
}
|
||||
.reasoning-body { white-space: pre-wrap; margin-top: 4px; }
|
||||
.reasoning-body.folded { display: none; }
|
||||
|
||||
/* ===== 输入栏 ===== */
|
||||
#input-bar {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
padding-bottom: calc(10px + env(safe-area-inset-bottom));
|
||||
background: var(--card);
|
||||
border-top: 1px solid var(--border-light);
|
||||
flex: none;
|
||||
}
|
||||
#input {
|
||||
flex: 1;
|
||||
min-height: 44px;
|
||||
max-height: 120px;
|
||||
resize: none;
|
||||
font-size: 16px; /* iOS <16px 会聚焦自动缩放 */
|
||||
line-height: 1.4;
|
||||
padding: 0.65rem 0.9rem;
|
||||
}
|
||||
#send-btn { flex: none; min-height: 44px; background: var(--accent); color: #fff; }
|
||||
#interrupt-btn { flex: none; min-height: 44px; min-width: 44px; padding: 0.45rem 0.7rem; background: var(--error-bg); color: var(--error); }
|
||||
#send-btn:disabled, #interrupt-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
/* ===== 任务面板:抽屉式覆盖层 ===== */
|
||||
#task-panel {
|
||||
position: fixed;
|
||||
top: 0; right: 0; bottom: 0;
|
||||
width: min(85vw, 340px);
|
||||
background: var(--card);
|
||||
border-left: 1px solid var(--border-light);
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.25s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 100;
|
||||
padding-top: env(safe-area-inset-top);
|
||||
}
|
||||
#task-panel.open { transform: translateX(0); }
|
||||
#task-panel header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
flex: none;
|
||||
}
|
||||
#task-close { background: none; border: none; font-size: 1.3rem; color: var(--fg-secondary); padding: 4px 8px; min-height: 36px; min-width: 36px; }
|
||||
#task-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 10px 12px;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: break-word;
|
||||
color: var(--fg-secondary);
|
||||
}
|
||||
#backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.25s;
|
||||
z-index: 99;
|
||||
}
|
||||
#backdrop.show { opacity: 1; pointer-events: auto; }
|
||||
@@ -1,125 +0,0 @@
|
||||
/* auth.css — 注册/重置密码页专属美化(在 theme.css 之后加载,视觉语言与首页 index.css 对齐) */
|
||||
|
||||
::selection {
|
||||
background: var(--accent-glow);
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
/* ===== 页面背景:点阵底纹 + 暖色环境光(固定,不随滚动) ===== */
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
background-image: radial-gradient(var(--border) 1.2px, transparent 1.2px);
|
||||
background-size: 26px 26px;
|
||||
-webkit-mask-image: radial-gradient(ellipse 78% 68% at 50% 38%, #000 18%, transparent 72%);
|
||||
mask-image: radial-gradient(ellipse 78% 68% at 50% 38%, #000 18%, transparent 72%);
|
||||
pointer-events: none;
|
||||
}
|
||||
body::after {
|
||||
content: "";
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
top: 32%;
|
||||
width: 560px;
|
||||
height: 560px;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: -1;
|
||||
background: radial-gradient(circle, var(--accent-glow), transparent 62%);
|
||||
opacity: 0.35;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ===== 卡片:顶部品牌渐变条 + 加深阴影 ===== */
|
||||
.card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 10px 30px rgba(74, 55, 40, 0.1);
|
||||
}
|
||||
.card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, var(--accent), var(--accent-dark));
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.card { box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35); }
|
||||
}
|
||||
|
||||
/* ===== 品牌 eyebrow + 居中标题(渐变短杠,对齐首页章节标题) ===== */
|
||||
.auth-eyebrow {
|
||||
text-align: center;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.26em;
|
||||
text-indent: 0.26em; /* 视觉居中补偿字距 */
|
||||
color: var(--accent);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.card h2 {
|
||||
text-align: center;
|
||||
}
|
||||
.card h2::after {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 40px;
|
||||
height: 3px;
|
||||
margin: 0.55rem auto 0;
|
||||
border-radius: 2px;
|
||||
background: linear-gradient(90deg, var(--accent), var(--accent-dark));
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* ===== 按钮:主按钮品牌渐变 + 上浮;次按钮描边反馈 ===== */
|
||||
.card input[type="submit"] {
|
||||
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-dark) 100%);
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 10px rgba(232, 133, 58, 0.32);
|
||||
transition: transform 0.18s, box-shadow 0.22s, opacity 0.15s;
|
||||
}
|
||||
.card input[type="submit"]:not(:disabled):hover {
|
||||
opacity: 1;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 18px rgba(232, 133, 58, 0.4);
|
||||
}
|
||||
.card input[type="submit"]:not(:disabled):active { transform: scale(0.96); }
|
||||
|
||||
.card input[type="button"] {
|
||||
border: 1px solid var(--accent-glow);
|
||||
transition: transform 0.18s, border-color 0.18s, opacity 0.15s;
|
||||
}
|
||||
.card input[type="button"]:not(:disabled):hover {
|
||||
opacity: 1;
|
||||
transform: translateY(-1px);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.card input[type="button"]:not(:disabled):active { transform: scale(0.96); }
|
||||
|
||||
.card input[type="reset"] { transition: transform 0.18s, color 0.18s, border-color 0.18s, opacity 0.15s; }
|
||||
.card input[type="reset"]:hover {
|
||||
opacity: 1;
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ===== 图形验证码:hover 描边 + 阴影 ===== */
|
||||
.code-box .captcha-img {
|
||||
transition: border-color 0.18s, box-shadow 0.18s;
|
||||
}
|
||||
.code-box .captcha-img:hover {
|
||||
border-color: var(--accent);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
/* ===== 入场动画 ===== */
|
||||
@keyframes auth-in {
|
||||
from { opacity: 0; transform: translateY(14px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.card { animation: auth-in 0.5s cubic-bezier(0.22, 1, 0.36, 1) both; }
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}.hljs{color:#abb2bf;background:#282c34}.hljs-comment,.hljs-quote{color:#5c6370;font-style:italic}.hljs-doctag,.hljs-formula,.hljs-keyword{color:#c678dd}.hljs-deletion,.hljs-name,.hljs-section,.hljs-selector-tag,.hljs-subst{color:#e06c75}.hljs-literal{color:#56b6c2}.hljs-addition,.hljs-attribute,.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#98c379}.hljs-attr,.hljs-number,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-pseudo,.hljs-template-variable,.hljs-type,.hljs-variable{color:#d19a66}.hljs-bullet,.hljs-link,.hljs-meta,.hljs-selector-id,.hljs-symbol,.hljs-title{color:#61aeee}.hljs-built_in,.hljs-class .hljs-title,.hljs-title.class_{color:#e6c07b}.hljs-emphasis{font-style:italic}.hljs-strong{font-weight:700}.hljs-link{text-decoration:underline}pre code.hljs{font-family: 'JetBrains Maple Mono','Fira Code','Consolas',monospace;}code{font-family: 'JetBrains Maple Mono','Fira Code','Consolas',monospace;}
|
||||
@@ -1,127 +0,0 @@
|
||||
/* dnfs.css — 树状笔记页专属美化(在 theme.css / codeblock.css 之后加载,视觉语言与首页 index.css 对齐) */
|
||||
|
||||
::selection {
|
||||
background: var(--accent-glow);
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
/* ===== 页面底纹:极淡点阵 + 顶部暖光,增加层次不影响阅读 ===== */
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
background-image: radial-gradient(var(--border) 1px, transparent 1px);
|
||||
background-size: 26px 26px;
|
||||
-webkit-mask-image: linear-gradient(180deg, #000 0%, transparent 55%);
|
||||
mask-image: linear-gradient(180deg, #000 0%, transparent 55%);
|
||||
pointer-events: none;
|
||||
}
|
||||
body::after {
|
||||
content: "";
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
top: -160px;
|
||||
width: 720px;
|
||||
height: 480px;
|
||||
transform: translateX(-50%);
|
||||
z-index: -1;
|
||||
background: radial-gradient(ellipse, var(--accent-glow), transparent 65%);
|
||||
opacity: 0.3;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ===== 节点卡片入场:逐张淡入(JS 动态插入时播放) =====
|
||||
* 防御性约定:只动画 opacity、不带 transform——祖先 transform 会成为 fixed 后代的包含块。
|
||||
* 全屏特效 iframe 现已直接挂载 body(见 dnfs.js renderSpJs),不再受卡片动画影响 */
|
||||
@keyframes card-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
/* 带位移的入场仅用于不含 fixed 后代的骨架元素;
|
||||
* 不含 .minimap——它自身依赖 transform: translateY(-50%) 垂直居中,动画会覆盖 */
|
||||
@keyframes rise-in {
|
||||
from { opacity: 0; transform: translateY(12px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.recommend-list .card { animation: card-in 0.45s ease both; }
|
||||
.recommend-list .card:nth-of-type(2) { animation-delay: 0.05s; }
|
||||
.recommend-list .card:nth-of-type(3) { animation-delay: 0.1s; }
|
||||
.recommend-list .card:nth-of-type(4) { animation-delay: 0.15s; }
|
||||
.recommend-list .card:nth-of-type(5) { animation-delay: 0.2s; }
|
||||
.recommend-list .card:nth-of-type(6) { animation-delay: 0.25s; }
|
||||
.recommend-list .card:nth-of-type(7) { animation-delay: 0.3s; }
|
||||
.recommend-list .card:nth-of-type(n+8) { animation-delay: 0.35s; }
|
||||
|
||||
/* 首屏骨架元素入场 */
|
||||
.searchbox, .node-form { animation: rise-in 0.4s ease both; }
|
||||
.node-form { animation-delay: 0.1s; }
|
||||
.userpanel, .minimap { animation: card-in 0.4s ease both; }
|
||||
}
|
||||
|
||||
/* ===== 搜索框:hover 升温(focus 高亮由 theme.css 提供) ===== */
|
||||
.searchbox:hover {
|
||||
border-color: var(--border);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
/* ===== 节点编辑卡:品牌渐变顶条 + 聚焦光环 ===== */
|
||||
.node-form {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.node-form::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, var(--accent), var(--accent-dark));
|
||||
opacity: 0.85;
|
||||
}
|
||||
.node-form:focus-within {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-glow), var(--shadow-md);
|
||||
}
|
||||
|
||||
/* ===== 编辑卡按钮:提交渐变 / 重置柔色 / 删除警示 ===== */
|
||||
#input-note-submit {
|
||||
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-dark) 100%);
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 10px rgba(232, 133, 58, 0.3);
|
||||
transition: transform 0.18s, box-shadow 0.22s, opacity 0.15s;
|
||||
}
|
||||
#input-note-submit:not(:disabled):hover {
|
||||
opacity: 1;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 5px 16px rgba(232, 133, 58, 0.38);
|
||||
}
|
||||
#input-note-submit:not(:disabled):active { transform: scale(0.96); }
|
||||
|
||||
#input-note-clear {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent-dark);
|
||||
border: 1px solid var(--accent-glow);
|
||||
transition: transform 0.18s, border-color 0.18s, opacity 0.15s;
|
||||
}
|
||||
#input-note-clear:not(:disabled):hover {
|
||||
opacity: 1;
|
||||
transform: translateY(-1px);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
#input-note-clear:not(:disabled):active { transform: scale(0.96); }
|
||||
|
||||
#delete-note {
|
||||
background: var(--error-bg);
|
||||
color: var(--error);
|
||||
border: 1px solid transparent;
|
||||
transition: transform 0.18s, border-color 0.18s, opacity 0.15s;
|
||||
}
|
||||
#delete-note:not(:disabled):hover {
|
||||
opacity: 1;
|
||||
transform: translateY(-1px);
|
||||
border-color: var(--error);
|
||||
}
|
||||
#delete-note:not(:disabled):active { transform: scale(0.96); }
|
||||
@@ -1,773 +0,0 @@
|
||||
/* index.css — 社区首页样式(通用主题见 theme.css)
|
||||
*
|
||||
* 设计原则:
|
||||
* 1. 统一版心 .wrap(max-width 1024px),所有区块对齐同一内容栏
|
||||
* 2. 品牌节点树 motif 贯穿 Hero / CTA / About 装饰,形成视觉呼应
|
||||
* 3. 柔和的间距节奏(4.5rem 基准间距,2.25rem 组件间距)
|
||||
* 4. 动效克制:入场渐显 + 品牌树描边,尊重 prefers-reduced-motion
|
||||
*/
|
||||
|
||||
body {
|
||||
align-items: stretch;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--accent-glow);
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
html { scroll-behavior: smooth; }
|
||||
}
|
||||
|
||||
/* ===== 版心 ===== */
|
||||
.wrap {
|
||||
width: 100%;
|
||||
max-width: 1024px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1.25rem;
|
||||
}
|
||||
|
||||
#main-page { width: 100%; }
|
||||
|
||||
/* ===== 按钮 ===== */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.55rem 1.3rem;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
line-height: 1.4;
|
||||
transition: transform 0.2s ease, background 0.2s, border-color 0.2s, box-shadow 0.25s;
|
||||
}
|
||||
.btn:active { transform: scale(0.96); }
|
||||
|
||||
.btn-primary {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-dark) 100%);
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 8px rgba(232, 133, 58, 0.25);
|
||||
}
|
||||
.btn-primary::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0; left: -80%;
|
||||
width: 50%; height: 100%;
|
||||
background: linear-gradient(100deg, transparent, rgba(255,255,255,0.3), transparent);
|
||||
transform: skewX(-20deg);
|
||||
transition: left 0.5s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 5px 16px rgba(232, 133, 58, 0.35);
|
||||
}
|
||||
.btn-primary:hover::after { left: 130%; }
|
||||
|
||||
.btn-ghost {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent-dark);
|
||||
border-color: var(--accent-glow);
|
||||
}
|
||||
.btn-ghost:hover { border-color: var(--accent); transform: translateY(-1px); }
|
||||
.btn-lg { padding: 0.72rem 1.8rem; font-size: 0.95rem; border-radius: 999px; letter-spacing: 0.02em; }
|
||||
|
||||
/* ===== 头部 ===== */
|
||||
.header {
|
||||
position: sticky;
|
||||
top: 0.75rem;
|
||||
z-index: 100;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
.header-inner {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: var(--radius-lg);
|
||||
background: rgba(253, 246, 238, 0.75);
|
||||
border: 1px solid var(--border-light);
|
||||
backdrop-filter: blur(18px) saturate(1.5);
|
||||
-webkit-backdrop-filter: blur(18px) saturate(1.5);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: background 0.25s, border-color 0.25s, box-shadow 0.25s;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.header-inner {
|
||||
background: rgba(30, 23, 18, 0.78);
|
||||
border-color: var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.header h1 svg { color: var(--accent); flex: none; }
|
||||
.header h1:hover { color: var(--accent); }
|
||||
|
||||
.right-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
#avatar {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid var(--border);
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
#name {
|
||||
font-size: 0.85rem;
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ===== Hero ===== */
|
||||
.hero {
|
||||
position: relative;
|
||||
text-align: center;
|
||||
padding: 5rem 0 6rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
/* 点阵底纹 */
|
||||
.hero::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: radial-gradient(var(--border) 1px, transparent 1px);
|
||||
background-size: 28px 28px;
|
||||
-webkit-mask-image: radial-gradient(ellipse 65% 55% at 50% 45%, #000 20%, transparent 70%);
|
||||
mask-image: radial-gradient(ellipse 65% 55% at 50% 45%, #000 20%, transparent 70%);
|
||||
pointer-events: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
/* 暖色光晕 */
|
||||
.hero::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 50%; top: 44%;
|
||||
width: 600px; height: 600px;
|
||||
transform: translate(-50%, -50%);
|
||||
background: radial-gradient(circle, var(--accent-glow), transparent 55%),
|
||||
radial-gradient(ellipse 50% 35% at 50% 60%, var(--accent-soft), transparent 65%);
|
||||
opacity: 0.45;
|
||||
pointer-events: none;
|
||||
}
|
||||
.hero-inner { position: relative; z-index: 1; }
|
||||
|
||||
/* 品牌树装饰 */
|
||||
.hero-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
.hero-tree {
|
||||
width: min(700px, 92%);
|
||||
height: auto;
|
||||
color: var(--accent);
|
||||
opacity: 0.13;
|
||||
margin-bottom: -2%;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.hero-tree { opacity: 0.18; }
|
||||
}
|
||||
|
||||
/* 徽章 */
|
||||
.hero-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.32rem 0.9rem;
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--accent-dark);
|
||||
background: var(--accent-soft);
|
||||
border: 1px solid var(--accent-glow);
|
||||
border-radius: 999px;
|
||||
}
|
||||
.badge-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: clamp(2rem, 6vw, 3rem);
|
||||
font-weight: 800;
|
||||
line-height: 1.3;
|
||||
letter-spacing: 0.02em;
|
||||
text-wrap: balance;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.accent-text {
|
||||
background: linear-gradient(120deg, var(--accent) 15%, var(--accent-dark) 45%, var(--accent) 85%);
|
||||
background-size: 220% auto;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
.hero-sub {
|
||||
font-size: 1rem;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--fg-secondary);
|
||||
max-width: 30rem;
|
||||
margin: 0 auto;
|
||||
line-height: 1.85;
|
||||
}
|
||||
.hero-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.85rem;
|
||||
margin-top: 2.2rem;
|
||||
}
|
||||
|
||||
.scroll-hint {
|
||||
position: absolute;
|
||||
left: 50%; bottom: 1.2rem;
|
||||
transform: translateX(-50%);
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
color: var(--muted);
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.scroll-hint:hover { color: var(--accent); }
|
||||
.scroll-hint svg { width: 20px; height: 20px; }
|
||||
|
||||
/* ===== 章节节奏与标题组 ===== */
|
||||
.section { padding-top: 5rem; }
|
||||
#features { scroll-margin-top: 5.5rem; }
|
||||
|
||||
.section-eyebrow {
|
||||
text-align: center;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.3em;
|
||||
text-indent: 0.3em;
|
||||
color: var(--accent);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.section-title {
|
||||
text-align: center;
|
||||
margin-top: 0.65rem;
|
||||
margin-bottom: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.section-title::after {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 40px;
|
||||
height: 2.5px;
|
||||
margin: 0.6rem auto 0;
|
||||
border-radius: 2px;
|
||||
background: linear-gradient(90deg, var(--accent), var(--accent-dark));
|
||||
opacity: 0.85;
|
||||
}
|
||||
.section-sub {
|
||||
text-align: center;
|
||||
margin-top: 0.85rem;
|
||||
font-size: 0.92rem;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--fg-secondary);
|
||||
}
|
||||
|
||||
/* ===== 特性卡片 ===== */
|
||||
.features {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1.15rem;
|
||||
margin-top: 2.5rem;
|
||||
}
|
||||
.feature-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--card);
|
||||
background-image: radial-gradient(240px circle at var(--mx, 50%) var(--my, 0%), var(--accent-soft), transparent 75%);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.6rem 1.4rem 1.3rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
overflow: hidden;
|
||||
transition: transform 0.25s ease, box-shadow 0.3s ease, border-color 0.3s ease;
|
||||
}
|
||||
/* 顶部渐变线(hover 显现) */
|
||||
.feature-card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0;
|
||||
height: 2.5px;
|
||||
background: linear-gradient(90deg, var(--accent), var(--accent-dark));
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
.feature-card:hover {
|
||||
transform: translateY(-4px);
|
||||
border-color: var(--accent-glow);
|
||||
box-shadow: 0 8px 24px rgba(74, 55, 40, 0.08);
|
||||
}
|
||||
.feature-card:hover::before { opacity: 0.85; }
|
||||
|
||||
.feature-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
margin-bottom: 0.85rem;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, var(--accent-soft), rgba(232, 133, 58, 0.12));
|
||||
color: var(--accent-dark);
|
||||
transition: transform 0.3s, background 0.3s, color 0.3s, box-shadow 0.3s;
|
||||
}
|
||||
.feature-icon svg { width: 20px; height: 20px; }
|
||||
.feature-card:hover .feature-icon {
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-dark));
|
||||
color: #fff;
|
||||
transform: translateY(-2px) scale(1.05);
|
||||
box-shadow: 0 5px 12px rgba(232, 133, 58, 0.3);
|
||||
}
|
||||
|
||||
.feature-body { flex: 1; }
|
||||
.feature-body h3 { margin-bottom: 0.35rem; font-size: 1rem; letter-spacing: 0.01em; }
|
||||
.feature-body p {
|
||||
font-size: 0.86rem;
|
||||
line-height: 1.75;
|
||||
color: var(--fg-secondary);
|
||||
}
|
||||
|
||||
.feature-tag {
|
||||
align-self: flex-start;
|
||||
margin-top: 0.9rem;
|
||||
padding: 0.2rem 0.65rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--accent-dark);
|
||||
background: var(--accent-soft);
|
||||
border: 1px solid var(--accent-glow);
|
||||
border-radius: 999px;
|
||||
transition: color 0.25s, border-color 0.25s;
|
||||
}
|
||||
.feature-card:hover .feature-tag {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ===== 关于本站 ===== */
|
||||
.about-section { position: relative; }
|
||||
|
||||
.about-panel {
|
||||
position: relative;
|
||||
text-align: center;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
padding: 2.2rem 2.2rem 1.8rem;
|
||||
margin-top: 2.5rem;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.3s, box-shadow 0.3s;
|
||||
}
|
||||
.about-panel:hover {
|
||||
border-color: var(--border);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
/* 顶部品牌线 */
|
||||
.about-panel::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0;
|
||||
height: 2.5px;
|
||||
background: linear-gradient(90deg, var(--accent), var(--accent-dark));
|
||||
opacity: 0.8;
|
||||
}
|
||||
/* 装饰同心圆 */
|
||||
.about-deco {
|
||||
position: absolute;
|
||||
right: -40px; bottom: -40px;
|
||||
width: 180px; height: 180px;
|
||||
color: var(--accent);
|
||||
pointer-events: none;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.site-intro {
|
||||
position: relative;
|
||||
max-width: 38rem;
|
||||
margin: 0 auto;
|
||||
font-size: 0.94rem;
|
||||
line-height: 2;
|
||||
color: var(--fg-secondary);
|
||||
}
|
||||
|
||||
.about-stats {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
gap: 1rem;
|
||||
margin-top: 1.6rem;
|
||||
padding-top: 1.4rem;
|
||||
border-top: 1px solid var(--border-light);
|
||||
}
|
||||
.about-stats li {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.about-stats b {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 800;
|
||||
line-height: 1.1;
|
||||
letter-spacing: 0.02em;
|
||||
background: linear-gradient(120deg, var(--accent), var(--accent-dark));
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
.about-stats span {
|
||||
font-size: 0.76rem;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.quick-links {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.85rem;
|
||||
margin-top: 1.8rem;
|
||||
}
|
||||
.quick-links a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.48rem 1.1rem;
|
||||
font-size: 0.84rem;
|
||||
font-weight: 600;
|
||||
color: var(--fg-secondary);
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
text-decoration: none;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: color 0.2s, border-color 0.2s, transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.quick-links a::after {
|
||||
content: "→";
|
||||
font-size: 0.85em;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
.quick-links a:hover {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
.quick-links a:hover::after { transform: translateX(3px); }
|
||||
|
||||
/* ===== CTA 横幅 ===== */
|
||||
.cta-band {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
padding: 3.2rem 1.5rem 3.4rem;
|
||||
border-radius: var(--radius-lg);
|
||||
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-dark) 100%);
|
||||
color: #fff;
|
||||
box-shadow: 0 12px 30px rgba(232, 133, 58, 0.22);
|
||||
}
|
||||
/* 底部点阵 */
|
||||
.cta-band::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: radial-gradient(rgba(255,255,255,0.18) 1px, transparent 1px);
|
||||
background-size: 24px 24px;
|
||||
-webkit-mask-image: radial-gradient(ellipse 70% 75% at 50% 110%, #000 15%, transparent 68%);
|
||||
mask-image: radial-gradient(ellipse 70% 75% at 50% 110%, #000 15%, transparent 68%);
|
||||
pointer-events: none;
|
||||
}
|
||||
/* 顶部高光线 */
|
||||
.cta-band::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0; right: 0; top: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.45), transparent);
|
||||
}
|
||||
|
||||
.cta-tree {
|
||||
position: absolute;
|
||||
right: -10px; bottom: -16px;
|
||||
width: min(300px, 50%);
|
||||
height: auto;
|
||||
color: #fff;
|
||||
opacity: 0.12;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.cta-title {
|
||||
position: relative;
|
||||
font-size: clamp(1.4rem, 3.5vw, 1.85rem);
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.03em;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.cta-sub {
|
||||
position: relative;
|
||||
margin-top: 0.6rem;
|
||||
font-size: 0.92rem;
|
||||
letter-spacing: 0.04em;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.btn-cta {
|
||||
position: relative;
|
||||
margin-top: 1.5rem;
|
||||
background: #fff;
|
||||
color: var(--accent-dark);
|
||||
box-shadow: 0 5px 14px rgba(0,0,0,0.12);
|
||||
}
|
||||
.btn-cta:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px rgba(0,0,0,0.16);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.cta-band {
|
||||
color: #38220f;
|
||||
box-shadow: 0 12px 30px rgba(0,0,0,0.4);
|
||||
}
|
||||
.cta-band::before {
|
||||
background-image: radial-gradient(rgba(56,34,15,0.14) 1px, transparent 1px);
|
||||
}
|
||||
.cta-band::after {
|
||||
background: linear-gradient(90deg, transparent, rgba(56,34,15,0.3), transparent);
|
||||
}
|
||||
.cta-tree { color: #38220f; opacity: 0.14; }
|
||||
.btn-cta { background: #38220f; color: #f7ead9; }
|
||||
}
|
||||
|
||||
/* ===== 页脚 ===== */
|
||||
.footer {
|
||||
width: 100%;
|
||||
margin-top: auto;
|
||||
padding-top: 5rem;
|
||||
padding-bottom: 1.5rem;
|
||||
}
|
||||
.footer-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
padding-top: 1.6rem;
|
||||
border-top: 1px solid var(--border-light);
|
||||
text-align: center;
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
.footer-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--fg);
|
||||
}
|
||||
.footer-brand svg { color: var(--accent); }
|
||||
.footer-nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 0.35rem 1.5rem;
|
||||
}
|
||||
.footer-nav a {
|
||||
font-size: 0.82rem;
|
||||
color: var(--fg-secondary);
|
||||
text-decoration: none;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.footer-nav a:hover { color: var(--accent); }
|
||||
.footer-icp { margin-top: 0.1rem; }
|
||||
.footer-icp a {
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.footer-icp a:hover { color: var(--accent); }
|
||||
|
||||
/* ===== 动画 ===== */
|
||||
@keyframes fade-up {
|
||||
from { opacity: 0; transform: translateY(12px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
@keyframes tree-draw { to { stroke-dashoffset: 0; } }
|
||||
@keyframes node-pop {
|
||||
from { opacity: 0; transform: scale(0); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
@keyframes tree-sway {
|
||||
0%, 100% { transform: rotate(0deg); }
|
||||
50% { transform: rotate(0.5deg); }
|
||||
}
|
||||
@keyframes text-shine { to { background-position: 220% center; } }
|
||||
@keyframes badge-pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 var(--accent-glow); }
|
||||
50% { box-shadow: 0 0 0 4px transparent; }
|
||||
}
|
||||
@keyframes hint-bounce {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(4px); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
/* Hero 入场 */
|
||||
.hero-inner > * {
|
||||
animation: fade-up 0.5s ease both;
|
||||
}
|
||||
.hero-badge { animation-delay: 0s; }
|
||||
.hero-title { animation-delay: 0.06s; }
|
||||
.hero-sub { animation-delay: 0.12s; }
|
||||
.hero-actions { animation-delay: 0.2s; }
|
||||
|
||||
.accent-text { animation: text-shine 8s linear infinite; }
|
||||
.badge-dot { animation: badge-pulse 2.8s ease-in-out infinite; }
|
||||
.scroll-hint svg { animation: hint-bounce 2.2s ease-in-out infinite; }
|
||||
|
||||
/* 品牌树描绘 */
|
||||
.hero-tree {
|
||||
animation: tree-sway 12s ease-in-out 3s infinite;
|
||||
transform-origin: 50% 100%;
|
||||
}
|
||||
.tree-branches path {
|
||||
stroke-dasharray: 100;
|
||||
stroke-dashoffset: 100;
|
||||
animation: tree-draw 0.8s ease-out forwards;
|
||||
}
|
||||
.tree-branches path:nth-of-type(1) { animation-delay: 0.1s; }
|
||||
.tree-branches path:nth-of-type(2),
|
||||
.tree-branches path:nth-of-type(3) { animation-delay: 0.6s; }
|
||||
.tree-branches path:nth-of-type(n+4) { animation-delay: 1.1s; }
|
||||
|
||||
.tree-nodes circle {
|
||||
opacity: 0;
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
animation: node-pop 0.4s ease-out forwards;
|
||||
}
|
||||
.tree-nodes circle:nth-of-type(1) { animation-delay: 0.15s; }
|
||||
.tree-nodes circle:nth-of-type(2) { animation-delay: 0.7s; }
|
||||
.tree-nodes circle:nth-of-type(3),
|
||||
.tree-nodes circle:nth-of-type(4) { animation-delay: 1.2s; }
|
||||
.tree-nodes circle:nth-of-type(n+5) { animation-delay: 1.7s; }
|
||||
}
|
||||
|
||||
/* ===== 滚动渐显 ===== */
|
||||
.reveal {
|
||||
opacity: 0;
|
||||
transform: translateY(16px);
|
||||
transition: opacity 0.55s ease, transform 0.55s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
.reveal.reveal-in { opacity: 1; transform: none; }
|
||||
|
||||
/* ===== 响应式 ===== */
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
.btn { min-height: 44px; }
|
||||
.quick-links a { padding: 0.55rem 1rem; }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.hero { padding: 4rem 0 4.5rem; }
|
||||
.hero::after { width: 400px; height: 400px; opacity: 0.35; }
|
||||
.hero-tree { opacity: 0.1; }
|
||||
.section { padding-top: 4rem; }
|
||||
.footer { padding-top: 4rem; }
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.features { grid-template-columns: 1fr; }
|
||||
.feature-card:hover { transform: none; }
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.wrap { padding: 0 0.85rem; }
|
||||
|
||||
.header { top: 0.5rem; margin-top: 0.5rem; }
|
||||
.header-inner { padding: 0.4rem 0.8rem; gap: 0.35rem; }
|
||||
.header h1 { font-size: 0.98rem; }
|
||||
.header h1 svg { display: none; }
|
||||
.right-group { gap: 0.3rem; }
|
||||
.right-group .btn { padding: 0.48rem 0.9rem; font-size: 0.82rem; }
|
||||
#name { max-width: 72px; }
|
||||
|
||||
.hero { padding: 2.8rem 0 2.8rem; }
|
||||
.hero::after { width: 280px; height: 280px; opacity: 0.3; }
|
||||
.hero-tree { opacity: 0.08; }
|
||||
.hero-badge { font-size: 0.72rem; padding: 0.28rem 0.75rem; margin-bottom: 1.1rem; }
|
||||
.hero-title { margin-bottom: 0.7rem; }
|
||||
.hero-sub { font-size: 0.92rem; }
|
||||
.hero-sub br { display: none; }
|
||||
.hero-actions {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.hero-actions .btn-lg { width: 100%; padding: 0.82rem 1.4rem; }
|
||||
.scroll-hint { display: none; }
|
||||
|
||||
.section { padding-top: 3rem; }
|
||||
.section-title { font-size: 1.25rem; }
|
||||
.features { margin-top: 1.8rem; gap: 0.9rem; }
|
||||
.feature-card { padding: 1.3rem 1.2rem 1.1rem; }
|
||||
|
||||
.about-panel { padding: 1.5rem 1.2rem 1.3rem; margin-top: 1.8rem; }
|
||||
.about-stats { margin-top: 1.3rem; padding-top: 1.1rem; gap: 0.5rem; }
|
||||
.quick-links { gap: 0.55rem 0.7rem; margin-top: 1.4rem; }
|
||||
|
||||
.cta-band { padding: 2.5rem 1.1rem 2.6rem; }
|
||||
.cta-tree { width: 70%; opacity: 0.08; }
|
||||
|
||||
.footer { padding-top: 3rem; }
|
||||
}
|
||||
|
||||
@media (max-width: 360px) {
|
||||
.header h1 { font-size: 0.92rem; }
|
||||
.right-group .btn { padding: 0.42rem 0.75rem; font-size: 0.78rem; }
|
||||
.hero { padding: 2.2rem 0 2.2rem; }
|
||||
}
|
||||
@@ -1,686 +0,0 @@
|
||||
/* ===== Design Tokens ===== */
|
||||
:root {
|
||||
--bg: #fdf6ee;
|
||||
--fg: #4a3728;
|
||||
--fg-secondary: #8c7260;
|
||||
--card: #ffffff;
|
||||
--card-hover: #fff7ef;
|
||||
--border: #f0e0cc;
|
||||
--border-light: #f7ecdd;
|
||||
--muted: #b89b7a;
|
||||
--accent: #e8853a;
|
||||
--accent-dark: #cf6b1f;
|
||||
--accent-soft: rgba(232, 133, 58, 0.10);
|
||||
--accent-glow: rgba(232, 133, 58, 0.25);
|
||||
--error: #d94f3d;
|
||||
--error-bg: #fef0ee;
|
||||
--success: #5a9e6f;
|
||||
--warning: #d4922a;
|
||||
--shadow-sm: 0 1px 3px rgba(74, 55, 40, 0.06);
|
||||
--shadow-md: 0 4px 12px rgba(74, 55, 40, 0.08);
|
||||
--radius-sm: 10px;
|
||||
--radius-md: 14px;
|
||||
--radius-lg: 20px;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #1e1712;
|
||||
--fg: #f0e4d8;
|
||||
--fg-secondary: #a08c78;
|
||||
--card: #2a2119;
|
||||
--card-hover: #342a20;
|
||||
--border: #3e3228;
|
||||
--border-light: #342a20;
|
||||
--muted: #8c7a66;
|
||||
--accent: #e8934a;
|
||||
--accent-dark: #f0a060;
|
||||
--accent-soft: rgba(232, 147, 74, 0.12);
|
||||
--accent-glow: rgba(232, 147, 74, 0.20);
|
||||
--error: #e8685a;
|
||||
--error-bg: #3a2220;
|
||||
--success: #6fb87f;
|
||||
--warning: #e0a84a;
|
||||
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Base ===== */
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', Roboto, Arial, sans-serif;
|
||||
padding: 2rem 1rem;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
body > .card,
|
||||
body > .box { margin: auto 0; }
|
||||
|
||||
/* ===== Layout ===== */
|
||||
.box { width: 90%; max-width: 720px; }
|
||||
.card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.75rem 1.5rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
color: var(--fg);
|
||||
transition: box-shadow 0.2s, border-color 0.2s;
|
||||
overflow-wrap: break-word;
|
||||
width: 90%;
|
||||
max-width: 440px;
|
||||
}
|
||||
.card:hover {
|
||||
border-color: var(--border);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
.box > .card { width: 100%; max-width: none; }
|
||||
|
||||
/* ===== Typography ===== */
|
||||
h1 { font-size: 1.4rem; font-weight: 700; }
|
||||
h2 { font-size: 1.25rem; font-weight: 700; margin-bottom: 1.25rem; }
|
||||
h3 { font-size: 1.05rem; font-weight: 600; margin-bottom: 0.5rem; }
|
||||
|
||||
/* ===== Form Elements ===== */
|
||||
label {
|
||||
display: block;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--fg-secondary);
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
form > div > label:first-child { margin-top: 0; }
|
||||
|
||||
input:not([type="checkbox"]):not([type="submit"]):not([type="reset"]):not([type="button"]),
|
||||
textarea {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.6rem 0.9rem;
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
outline: none;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
input:not([type="checkbox"]):not([type="submit"]):not([type="reset"]):not([type="button"]):focus,
|
||||
textarea:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-glow);
|
||||
}
|
||||
input::placeholder, textarea::placeholder { color: var(--muted); }
|
||||
textarea { min-height: 80px; resize: vertical; }
|
||||
|
||||
select {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.6rem 0.9rem;
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
outline: none;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
select:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-glow); }
|
||||
|
||||
/* ===== Buttons ===== */
|
||||
button, input[type="submit"], input[type="reset"], input[type="button"] {
|
||||
padding: 0.55rem 1.3rem;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s, transform 0.1s;
|
||||
line-height: 1.4;
|
||||
}
|
||||
button:hover, input[type="submit"]:hover,
|
||||
input[type="reset"]:hover, input[type="button"]:hover { opacity: 0.85; }
|
||||
button:active, input[type="submit"]:active,
|
||||
input[type="reset"]:active, input[type="button"]:active { transform: scale(0.96); }
|
||||
|
||||
input[type="submit"] { background: var(--accent); color: #fff; }
|
||||
input[type="reset"] { background: transparent; color: var(--muted); border: 1.5px solid var(--border); }
|
||||
button[type="button"], input[type="button"] { background: var(--accent-soft); color: var(--accent-dark); }
|
||||
|
||||
#emlSub { display: inline-block; margin-top: 0.4rem; }
|
||||
#emlSub input[type="submit"] {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent-dark);
|
||||
font-size: 0.82rem;
|
||||
padding: 0.45rem 1rem;
|
||||
}
|
||||
|
||||
/* ===== Form Layout ===== */
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
/* ===== 防重复点击:请求进行中按钮禁用态反馈 ===== */
|
||||
button:disabled,
|
||||
input[type="submit"]:disabled,
|
||||
input[type="button"]:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.form-check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.form-check input[type="checkbox"] { width: 16px; height: 16px; accent-color: var(--accent); }
|
||||
.form-check label {
|
||||
display: inline;
|
||||
margin: 0;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
color: var(--fg-secondary);
|
||||
}
|
||||
|
||||
/* ===== Code Box (register) ===== */
|
||||
.code-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.4rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--fg-secondary);
|
||||
}
|
||||
.code-box input[type="text"] { width: auto; flex: 1; min-width: 100px; display: inline-block; }
|
||||
.code-box input[type="button"] { white-space: nowrap; }
|
||||
/* 图形验证码图片(register 图形/邮箱验证码同排布局) */
|
||||
.code-box .captcha-img {
|
||||
width: 120px;
|
||||
height: 38px;
|
||||
flex: none;
|
||||
cursor: pointer;
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: 10px;
|
||||
box-sizing: border-box;
|
||||
object-fit: cover;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
/* ===== Validation Tips ===== */
|
||||
.tip-red { color: var(--error); font-size: 0.78rem; }
|
||||
.tip-yellow { color: var(--warning); font-size: 0.78rem; }
|
||||
.tip-green { color: var(--success); font-size: 0.78rem; }
|
||||
|
||||
/* ===== Article (dnfs) ===== */
|
||||
.article-header { text-align: center; margin-bottom: 1.25rem; }
|
||||
.article-header h1 { margin-bottom: 0.5rem; }
|
||||
.article-meta { font-size: 0.82rem; color: var(--muted); margin-bottom: 0.3rem; }
|
||||
.article-stats {
|
||||
font-size: 0.82rem;
|
||||
color: var(--muted);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.article-stats span { font-weight: 600; color: var(--fg-secondary); }
|
||||
#article-content { font-size: 0.95rem; line-height: 1.7; margin-bottom: 1.25rem; }
|
||||
|
||||
/* ===== Search Box (dnfs 顶部搜索) ===== */
|
||||
.searchbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.45rem 0.55rem 0.45rem 0.9rem;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
margin-bottom: 1rem;
|
||||
transition: border-color 0.2s, box-shadow 0.2s, background 0.2s;
|
||||
}
|
||||
.searchbox:focus-within {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-glow), var(--shadow-md);
|
||||
}
|
||||
.searchbox-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex: none;
|
||||
color: var(--muted);
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.searchbox:focus-within .searchbox-icon {
|
||||
color: var(--accent);
|
||||
}
|
||||
.searchbox-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: none !important;
|
||||
background: transparent !important;
|
||||
padding: 0.45rem 0.25rem !important;
|
||||
font-size: 0.92rem !important;
|
||||
box-shadow: none !important;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
.searchbox-input:focus {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
.searchbox-btn {
|
||||
flex: none;
|
||||
padding: 0.5rem 1rem !important;
|
||||
font-size: 0.85rem !important;
|
||||
font-weight: 600;
|
||||
background: var(--accent) !important;
|
||||
color: #fff !important;
|
||||
border: none !important;
|
||||
border-radius: var(--radius-sm) !important;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s, transform 0.1s, background 0.2s;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.searchbox-btn:hover { opacity: 0.88; }
|
||||
.searchbox-btn:active { transform: scale(0.96); }
|
||||
@media (max-width: 480px) {
|
||||
.searchbox { padding: 0.35rem 0.4rem 0.35rem 0.75rem; gap: 0.35rem; }
|
||||
.searchbox-icon { width: 16px; height: 16px; }
|
||||
.searchbox-input { font-size: 0.88rem !important; }
|
||||
.searchbox-btn { padding: 0.45rem 0.75rem !important; font-size: 0.82rem !important; }
|
||||
}
|
||||
|
||||
/* ===== Recommend List (dnfs) ===== */
|
||||
/* 卡片尺寸随内容自适应:宽度包裹内容(fit-content),上限为容器宽度;高度天然随内容伸缩 */
|
||||
.recommend-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
}
|
||||
.recommend-list br { display: none; } /* JS 追加的 <br> 分隔符,改由 flex gap 统一控制间距 */
|
||||
.recommend-list .card {
|
||||
width: fit-content;
|
||||
min-width: min(240px, 100%);
|
||||
max-width: 100%;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
/* 防止内容中的大图撑破自适应宽度 */
|
||||
.recommend-list .card img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.recommend-list center {
|
||||
padding: 0.4rem;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
/* 仅标题可点击进入子节点:手型光标 + hover 高亮集中在标题 h3 上 */
|
||||
.recommend-list .card h3 {
|
||||
cursor: pointer;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.recommend-list .card h3:hover { color: var(--accent); }
|
||||
.recommend-list .card p { font-size: 0.9rem; line-height: 1.6; color: var(--fg-secondary); }
|
||||
|
||||
/* ===== Owner Chip (dnfs 发帖用户) ===== */
|
||||
.card-owner {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
.owner-avatar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent-dark);
|
||||
border: 1px solid var(--accent);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
user-select: none;
|
||||
}
|
||||
.owner-name {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--fg-secondary);
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ===== Card Stats (dnfs 互动统计:观看/点赞/分享) ===== */
|
||||
.card-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1.1rem;
|
||||
margin-top: 0.45rem;
|
||||
}
|
||||
.stat-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
.stat-item svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.stat-num {
|
||||
font-weight: 600;
|
||||
color: var(--fg-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ===== Card Actions (dnfs 编辑/删除按钮) ===== */
|
||||
.card-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
.act-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.7rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid transparent;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent-dark);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, opacity 0.15s, transform 0.1s;
|
||||
}
|
||||
.act-btn svg { width: 12px; height: 12px; flex-shrink: 0; }
|
||||
.act-btn:hover { border-color: var(--accent); }
|
||||
.act-del { background: var(--error-bg); color: var(--error); }
|
||||
.act-del:hover { border-color: var(--error); }
|
||||
.act-move { background: transparent; color: var(--fg-secondary); border-color: var(--border); }
|
||||
.act-move:hover { border-color: var(--accent); color: var(--accent-dark); }
|
||||
|
||||
/* ===== Move Bar (dnfs 节点移动条) ===== */
|
||||
.movebar {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: 1.25rem;
|
||||
transform: translateX(-50%);
|
||||
z-index: 110;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.8rem;
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(253, 246, 238, 0.85);
|
||||
border: 1px solid var(--accent);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
box-shadow: var(--shadow-md);
|
||||
font-size: 0.82rem;
|
||||
max-width: 92vw;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.movebar { background: rgba(30, 23, 18, 0.85); }
|
||||
}
|
||||
.movebar:empty { display: none; }
|
||||
.move-title {
|
||||
max-width: 180px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--fg-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ===== User Panel (dnfs 左上角用户信息,镜像 minimap 样式) ===== */
|
||||
.userpanel {
|
||||
position: fixed;
|
||||
left: 1rem;
|
||||
top: 4.5rem;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(253, 246, 238, 0.72);
|
||||
border: 1px solid var(--border-light);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
box-shadow: var(--shadow-md);
|
||||
transition: background 0.2s, border-color 0.2s;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.userpanel {
|
||||
background: rgba(30, 23, 18, 0.72);
|
||||
border-color: var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Minimap (dnfs) ===== */
|
||||
.minimap {
|
||||
position: fixed;
|
||||
right: 1rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(253, 246, 238, 0.72);
|
||||
border: 1px solid var(--border-light);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
box-shadow: var(--shadow-md);
|
||||
transition: background 0.2s, border-color 0.2s;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.minimap {
|
||||
background: rgba(30, 23, 18, 0.72);
|
||||
border-color: var(--border);
|
||||
}
|
||||
}
|
||||
.minimap:empty, .userpanel:empty { display: none; }
|
||||
/* userpanel 纯展示项(用户名/角色):不可点击,无手型光标与 hover 变色 */
|
||||
.userpanel .tag.no-hover {
|
||||
cursor: default;
|
||||
}
|
||||
.userpanel .tag.no-hover:hover {
|
||||
color: var(--fg-secondary);
|
||||
background: rgba(232, 133, 58, 0.08);
|
||||
border-color: transparent;
|
||||
}
|
||||
.minimap .tag, .userpanel .tag {
|
||||
display: inline-block;
|
||||
max-width: 140px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
padding: 0.25rem 0.6rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
color: var(--fg-secondary);
|
||||
background: rgba(232, 133, 58, 0.08);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: color 0.15s, background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.minimap::-webkit-scrollbar { width: 4px; }
|
||||
.minimap::-webkit-scrollbar-track { background: transparent; }
|
||||
.minimap::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.minimap::-webkit-scrollbar-thumb:hover { background: var(--muted); }
|
||||
.minimap .tag:hover, .userpanel .tag:hover {
|
||||
color: var(--accent-dark);
|
||||
background: var(--accent-soft);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.minimap .tag.back, .userpanel .tag.back {
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
border-color: var(--accent);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.minimap .tag.fold-btn, .userpanel .tag.fold-btn {
|
||||
color: var(--fg-secondary);
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.3rem;
|
||||
text-align: center;
|
||||
max-width: none;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.minimap .tag.fold-btn:hover, .userpanel .tag.fold-btn:hover {
|
||||
color: var(--accent-dark);
|
||||
background: var(--accent-soft);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ===== Special Nodes (dnfs,管理员发布的特殊节点) ===== */
|
||||
.sp-link { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.sp-favicon { width: 20px; height: 20px; flex: none; border-radius: 4px; }
|
||||
.sp-anchor { color: var(--accent); word-break: break-all; }
|
||||
.sp-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.4rem; }
|
||||
.sp-code {
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
.sp-image { display: block; max-width: 100%; border-radius: var(--radius-sm); }
|
||||
.sp-run {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.sp-log {
|
||||
margin-top: 0.5rem;
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
padding: 0.5rem;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
.sp-note { font-size: 0.82rem; color: var(--fg-secondary); margin-top: 0.4rem; }
|
||||
#sp-panel { margin-top: 0.4rem; }
|
||||
#sp-panel select { margin-bottom: 0.4rem; }
|
||||
#sp-file-tip { font-size: 0.78rem; }
|
||||
|
||||
/* ===== Node Form (dnfs) ===== */
|
||||
.node-form { margin-top: 1rem; }
|
||||
.node-form .form-actions { margin-top: 0.75rem; }
|
||||
.node-form #input-note-title {
|
||||
padding: 0.8rem 1rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.node-form #input-note-content {
|
||||
padding: 0.9rem 1rem;
|
||||
font-size: 0.95rem;
|
||||
min-height: 120px;
|
||||
max-height: 400px;
|
||||
line-height: 1.7;
|
||||
overflow-y: hidden;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
/* ===== Markdown Content ===== */
|
||||
code {
|
||||
color: var(--error);
|
||||
background: var(--error-bg);
|
||||
padding: 2px 6px;
|
||||
border-radius: 5px;
|
||||
font-size: 0.88em;
|
||||
}
|
||||
/*
|
||||
pre > code {
|
||||
display: block;
|
||||
color: #f0e4d8;
|
||||
background: #3d3229;
|
||||
padding: 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
overflow-x: auto;
|
||||
} */
|
||||
blockquote {
|
||||
margin-right: 0;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--accent-soft);
|
||||
border-left: 3px solid var(--accent);
|
||||
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
|
||||
color: var(--fg-secondary);
|
||||
}
|
||||
|
||||
/* ===== Footer ===== */
|
||||
.footer {
|
||||
margin-top: auto;
|
||||
padding-top: 1.5rem;
|
||||
text-align: center;
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
.footer a {
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.footer a:hover { color: var(--accent); }
|
||||
|
||||
/* ===== Responsive ===== */
|
||||
@media (max-width: 480px) {
|
||||
body { padding: 1rem 0.8rem; }
|
||||
.card { padding: 1.25rem 1rem; border-radius: 16px; width: 95%; }
|
||||
.box { width: 95%; }
|
||||
h2 { font-size: 1.1rem; }
|
||||
.form-actions button, .form-actions input { flex: 1; padding: 0.5rem 0.6rem; font-size: 0.82rem; }
|
||||
}
|
||||
@media (max-width: 360px) {
|
||||
.card { padding: 1rem 0.8rem; border-radius: var(--radius-sm); }
|
||||
}
|
||||
@@ -1,374 +0,0 @@
|
||||
:root {
|
||||
--bg: #fdf6ee;
|
||||
--fg: #4a3728;
|
||||
--fg-secondary: #8c7260;
|
||||
--card: #ffffff;
|
||||
--card-hover: #fff7ef;
|
||||
--card-active: #ffeedd;
|
||||
--border: #f0e0cc;
|
||||
--border-light: #f7ecdd;
|
||||
--muted: #b89b7a;
|
||||
--accent: #e8853a;
|
||||
--accent-dark: #cf6b1f;
|
||||
--accent-soft: rgba(232, 133, 58, 0.10);
|
||||
--accent-glow: rgba(232, 133, 58, 0.25);
|
||||
--play: #f0a030;
|
||||
--download: #e8853a;
|
||||
--error: #d94f3d;
|
||||
--error-bg: #fef0ee;
|
||||
--icon-bg: #faf0e4;
|
||||
--icon-border: #f0e0cc;
|
||||
--shadow-sm: 0 1px 3px rgba(74, 55, 40, 0.06);
|
||||
--shadow-md: 0 4px 12px rgba(74, 55, 40, 0.08);
|
||||
--shadow-lg: 0 8px 24px rgba(74, 55, 40, 0.12);
|
||||
--radius-sm: 10px;
|
||||
--radius-md: 14px;
|
||||
--radius-lg: 20px;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #1e1712;
|
||||
--fg: #f0e4d8;
|
||||
--fg-secondary: #a08c78;
|
||||
--card: #2a2119;
|
||||
--card-hover: #342a20;
|
||||
--card-active: #3e3228;
|
||||
--border: #3e3228;
|
||||
--border-light: #342a20;
|
||||
--muted: #8c7a66;
|
||||
--accent: #e8934a;
|
||||
--accent-dark: #f0a060;
|
||||
--accent-soft: rgba(232, 147, 74, 0.12);
|
||||
--accent-glow: rgba(232, 147, 74, 0.20);
|
||||
--play: #f0a848;
|
||||
--download: #e8934a;
|
||||
--error: #e8685a;
|
||||
--error-bg: #3a2220;
|
||||
--icon-bg: #342a20;
|
||||
--icon-border: #4a3c30;
|
||||
--shadow-sm: 0 1px 3px rgba(0,0,0,0.2);
|
||||
--shadow-md: 0 4px 12px rgba(0,0,0,0.3);
|
||||
--shadow-lg: 0 8px 24px rgba(0,0,0,0.4);
|
||||
}
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', Roboto, Arial, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
min-height: 100vh;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* ===== 顶栏 ===== */
|
||||
.header {
|
||||
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-dark) 100%);
|
||||
color: #fff;
|
||||
padding: 0 20px;
|
||||
height: 54px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-shadow: 0 2px 12px rgba(207, 107, 31, 0.3);
|
||||
}
|
||||
.header h1 {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.header-actions { display: flex; gap: 4px; }
|
||||
.header-actions a {
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 16px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.header-actions a:hover { background: rgba(255,255,255,0.18); }
|
||||
.header-actions a:active { background: rgba(255,255,255,0.30); }
|
||||
|
||||
/* ===== 面包屑 ===== */
|
||||
.breadcrumb {
|
||||
padding: 10px 20px;
|
||||
background: var(--card);
|
||||
font-size: 13px;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.breadcrumb::-webkit-scrollbar { display: none; }
|
||||
.breadcrumb a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
padding: 4px 10px;
|
||||
border-radius: 8px;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
font-weight: 500;
|
||||
}
|
||||
.breadcrumb a:hover { background: var(--accent-soft); }
|
||||
.breadcrumb .sep { color: var(--muted); margin: 0 2px; font-size: 11px; }
|
||||
.breadcrumb .current {
|
||||
color: var(--fg);
|
||||
font-weight: 600;
|
||||
padding: 4px 10px;
|
||||
}
|
||||
|
||||
/* ===== 搜索栏 ===== */
|
||||
.search-box {
|
||||
padding: 12px 20px;
|
||||
background: var(--card);
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
.search-box input {
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
padding: 10px 18px;
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.search-box input::placeholder { color: var(--muted); }
|
||||
.search-box input:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-glow);
|
||||
}
|
||||
|
||||
/* ===== 统计条 ===== */
|
||||
.stats {
|
||||
padding: 10px 20px;
|
||||
font-size: 12px;
|
||||
color: var(--fg-secondary);
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
.stats span {
|
||||
padding: 4px 12px;
|
||||
background: var(--card);
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--border-light);
|
||||
box-shadow: var(--shadow-sm);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ===== 主容器 ===== */
|
||||
.container {
|
||||
padding: 16px 20px 40px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* ===== 文件网格 ===== */
|
||||
.file-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(155px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.item {
|
||||
background: var(--card);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 18px 14px 14px;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--border-light);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: box-shadow 0.2s, transform 0.15s, border-color 0.2s, background 0.15s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 10px;
|
||||
position: relative;
|
||||
}
|
||||
.item:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--border);
|
||||
background: var(--card-hover);
|
||||
}
|
||||
.item:active {
|
||||
transform: translateY(0) scale(0.98);
|
||||
background: var(--card-active);
|
||||
}
|
||||
|
||||
/* 方块图标 */
|
||||
.item .icon {
|
||||
font-size: 34px;
|
||||
width: 62px;
|
||||
height: 62px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--icon-bg);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--icon-border);
|
||||
transition: transform 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.item:hover .icon { transform: scale(1.06); }
|
||||
|
||||
.item .info {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.item .name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.item .meta {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* 操作按钮 */
|
||||
.item .actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
justify-content: center;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.item .actions button {
|
||||
padding: 5px 14px;
|
||||
font-size: 12px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
transition: opacity 0.15s, transform 0.1s;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.item .actions button:hover { opacity: 0.85; }
|
||||
.item .actions button:active { transform: scale(0.93); }
|
||||
.btn-play { background: var(--play); }
|
||||
.btn-download { background: var(--download); }
|
||||
|
||||
/* ===== 加载态 ===== */
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
.loading .spinner {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: spin 1.2s cubic-bezier(0.45, 0.05, 0.55, 0.95) infinite;
|
||||
font-size: 28px;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
background: var(--card);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-light);
|
||||
box-shadow: var(--shadow-sm);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
@keyframes spin { 100% { transform: rotate(360deg); } }
|
||||
|
||||
/* ===== 空态 ===== */
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 80px 20px;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
.empty .icon {
|
||||
font-size: 44px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: var(--card);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border-light);
|
||||
box-shadow: var(--shadow-sm);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* ===== 错误态 ===== */
|
||||
.error {
|
||||
text-align: center;
|
||||
padding: 40px 24px;
|
||||
color: var(--error);
|
||||
background: var(--error-bg);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--error);
|
||||
margin: 20px 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ===== 页脚 ===== */
|
||||
.footer {
|
||||
text-align: center;
|
||||
padding: 24px 20px 32px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.footer a {
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.footer a:hover { color: var(--accent); }
|
||||
|
||||
/* ===== 响应式 ===== */
|
||||
@media (max-width: 480px) {
|
||||
.header { padding: 0 14px; height: 50px; }
|
||||
.header h1 { font-size: 15px; }
|
||||
.breadcrumb { padding: 8px 14px; }
|
||||
.search-box { padding: 10px 14px; }
|
||||
.stats { padding: 8px 14px; gap: 6px; }
|
||||
.container { padding: 12px 14px 32px; }
|
||||
.file-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.item {
|
||||
padding: 14px 10px 12px;
|
||||
border-radius: 16px;
|
||||
gap: 8px;
|
||||
}
|
||||
.item .icon {
|
||||
font-size: 28px;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.item .name { font-size: 12px; }
|
||||
.item .meta { font-size: 10px; gap: 4px; }
|
||||
.item .actions button { padding: 4px 10px; font-size: 11px; border-radius: 8px; }
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<!-- 移动端适配核心标签 -->
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=no">
|
||||
<title>树状笔记</title>
|
||||
<link rel="stylesheet" href="css/theme.css">
|
||||
<link rel="stylesheet" href="css/codeblock.css">
|
||||
<link rel="stylesheet" href="css/dnfs.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="box">
|
||||
<div id="searchbox" class="searchbox">
|
||||
<svg class="searchbox-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="7"/>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||
</svg>
|
||||
<input type="text" placeholder="在当前节点内搜索..." id="searchtext" class="searchbox-input" autocomplete="off">
|
||||
<button type="button" id="searchsubmit" class="searchbox-btn" aria-label="搜索">搜索</button>
|
||||
</div>
|
||||
<div id="userpanel" class="userpanel"></div>
|
||||
<div id="minimap" class="minimap"></div>
|
||||
<div id="movebar" class="movebar"></div>
|
||||
<div class="recommend-list" id="recommend-list"></div>
|
||||
<div class="card node-form">
|
||||
<h3>节点操作</h3>
|
||||
<label>新增节点</label><input id="input-note-title">
|
||||
<!-- 特殊节点面板:仅管理员可见(dnfs.js 按 /whoami is_admin 显示) -->
|
||||
<div id="sp-panel" style="display:none">
|
||||
<label>节点类型</label>
|
||||
<select id="sp-type">
|
||||
<option value="text">普通文本</option>
|
||||
<option value="link">链接(自动显示站点图标)</option>
|
||||
<option value="js">前端代码(可审计/可运行)</option>
|
||||
<option value="image">图片(自动显示)</option>
|
||||
<option value="file">文件(可下载)</option>
|
||||
</select>
|
||||
<input type="file" id="sp-file" style="display:none">
|
||||
<span id="sp-file-tip"></span>
|
||||
</div>
|
||||
<label>节点内容</label><textarea id="input-note-content"></textarea>
|
||||
<div class="form-actions">
|
||||
<button id="input-note-submit">提交</button>
|
||||
<button id="input-note-clear">重置</button>
|
||||
<button id="delete-note">删除当前节点</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- <script src="https://cdnjs.cloudflare.com/ajax/libs/markdown-it/12.0.0/markdown-it.min.js"></script> -->
|
||||
<!-- <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script> -->
|
||||
<script src="js/highlight_11_9_0.js"></script>
|
||||
<script src="js/markdown-it-12_0_0.js"></script>
|
||||
<script src="js/validation.js"></script>
|
||||
<script src="js/token_manager.js"></script>
|
||||
<script src="js/dnfs.js"></script>
|
||||
</div>
|
||||
<footer class="footer">
|
||||
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener">蜀ICP备2024107064号-2</a>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 47 KiB |
@@ -1,340 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=no">
|
||||
<title>重置密码</title>
|
||||
<link rel="stylesheet" href="css/theme.css">
|
||||
<link rel="stylesheet" href="css/auth.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<p class="auth-eyebrow">DNFS 树状笔记</p>
|
||||
<h2>重置密码页面</h2>
|
||||
<form id="form">
|
||||
<div>
|
||||
<label>邮箱号:</label>
|
||||
<input type="email" id="regemail" placeholder="请输入已注册的邮箱号">
|
||||
<span id="emlTip"></span>
|
||||
<div class="code-box">图形验证码:
|
||||
<input type="text" id="captchaAnswer" placeholder="请输入图形验证码" maxlength="4" autocomplete="off">
|
||||
<img id="captchaImg" class="captcha-img" alt="加载中…" title="点击刷新验证码">
|
||||
<span id="captchaTip"></span>
|
||||
</div>
|
||||
<div class="code-box">邮箱验证码:
|
||||
<input type="text" id="code" placeholder="请输入6位验证码" inputmode="numeric" maxlength="6">
|
||||
<input type="button" id="getCode" value="获取验证码">
|
||||
<span id="codeTip"></span>
|
||||
</div>
|
||||
<label>新密码:</label>
|
||||
<input type="password" id="regpsw" placeholder="至少6位,需含大小写/数字/特殊字符中至少3类">
|
||||
<label>确认密码:</label>
|
||||
<input type="password" id="repwd" placeholder="请再次输入新密码">
|
||||
<span id="pwdTip"></span>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<input id="formsub" type="submit" value="重置密码">
|
||||
<input id="resetUser" type="reset" value="重置">
|
||||
<input type="button" value="返回登录" onclick="location.href='login.html'">
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<footer class="footer">
|
||||
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener">蜀ICP备2024107064号-2</a>
|
||||
</footer>
|
||||
<script src="js/validation.js"></script>
|
||||
<script>
|
||||
const form = document.getElementById('form');
|
||||
const emailInput = document.getElementById('regemail');
|
||||
const codeInput = document.getElementById('code');
|
||||
const getCodeButton = document.getElementById('getCode');
|
||||
const passwordInput = document.getElementById('regpsw');
|
||||
const repeatPasswordInput = document.getElementById('repwd');
|
||||
const emlTip = document.getElementById('emlTip');
|
||||
const pwdTip = document.getElementById('pwdTip');
|
||||
const codeTip = document.getElementById('codeTip');
|
||||
const captchaImg = document.getElementById('captchaImg');
|
||||
const captchaInput = document.getElementById('captchaAnswer');
|
||||
const captchaTip = document.getElementById('captchaTip');
|
||||
const submitButton = document.getElementById('formsub');
|
||||
|
||||
let codeTarget = ''; // 已发送验证码的目标邮箱(验证码本体由服务端校验,前端不经手)
|
||||
let cooldownTimer = null; // 重发倒计时(与服务端 smtp 同邮箱 60s 发送冷却对齐)
|
||||
|
||||
/* ── 图形验证码(gateway 代理 captcha 服务:GET /captcha/image) ── */
|
||||
let captchaToken = '';
|
||||
let captchaLoading = false; // 防重复点击:加载中忽略再次触发
|
||||
|
||||
async function refreshCaptcha(preserveInput) {
|
||||
if (captchaLoading) return;
|
||||
captchaLoading = true;
|
||||
captchaToken = '';
|
||||
if (!preserveInput) captchaInput.value = ''; // 默认清空输入;preserveInput=true 时保留(获取验证码后)
|
||||
captchaImg.style.opacity = '0.5'; // 视觉提示:正在刷新
|
||||
try {
|
||||
const res = await fetch('/captcha/image', { cache: 'no-store' });
|
||||
if (res.status === 403) { // WAF 拦截:body 非图片,提示后待用户点击重试
|
||||
captchaImg.removeAttribute('src');
|
||||
captchaImg.alt = '验证码加载被安全规则拦截,点击重试';
|
||||
return;
|
||||
}
|
||||
if (!res.ok) throw new Error('captcha load failed');
|
||||
captchaToken = res.headers.get('X-Captcha-Token') || '';
|
||||
const blob = await res.blob();
|
||||
if (captchaImg.src && captchaImg.src.startsWith('blob:')) URL.revokeObjectURL(captchaImg.src);
|
||||
captchaImg.src = URL.createObjectURL(blob);
|
||||
captchaImg.alt = '点击刷新';
|
||||
captchaImg.style.opacity = ''; // 恢复透明度
|
||||
} catch (e) {
|
||||
captchaImg.removeAttribute('src');
|
||||
captchaImg.alt = '加载失败,点击重试';
|
||||
} finally {
|
||||
captchaLoading = false;
|
||||
}
|
||||
}
|
||||
captchaImg.addEventListener('click', refreshCaptcha);
|
||||
refreshCaptcha();
|
||||
|
||||
// 发送成功后进入倒计时,避免用户触发服务端 smtp 同邮箱 60s 发送冷却
|
||||
function startResendCooldown(seconds) {
|
||||
clearInterval(cooldownTimer);
|
||||
let remain = seconds;
|
||||
getCodeButton.disabled = true;
|
||||
getCodeButton.value = `${remain}s 后重发`;
|
||||
cooldownTimer = setInterval(function () {
|
||||
remain -= 1;
|
||||
if (remain <= 0) {
|
||||
clearInterval(cooldownTimer);
|
||||
cooldownTimer = null;
|
||||
getCodeButton.disabled = false;
|
||||
getCodeButton.value = '获取验证码';
|
||||
} else {
|
||||
getCodeButton.value = `${remain}s 后重发`;
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
form.addEventListener('reset', function () {
|
||||
clearTipStyle();
|
||||
codeTarget = '';
|
||||
captchaInput.value = '';
|
||||
refreshCaptcha();
|
||||
});
|
||||
|
||||
function clearTipStyle() {
|
||||
emlTip.innerHTML = '';
|
||||
pwdTip.innerHTML = '';
|
||||
codeTip.innerHTML = '';
|
||||
captchaTip.innerHTML = '';
|
||||
|
||||
emlTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
|
||||
pwdTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
|
||||
codeTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
|
||||
captchaTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
|
||||
}
|
||||
clearTipStyle();
|
||||
|
||||
function checkEmail() {
|
||||
const result = FormValidators.email(emailInput.value);
|
||||
emlTip.innerHTML = '';
|
||||
emlTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
|
||||
emlTip.textContent = result.message;
|
||||
emlTip.classList.add(result.className);
|
||||
return result.valid ? result.value : false;
|
||||
}
|
||||
|
||||
function checkPassword() {
|
||||
const result = FormValidators.password(passwordInput.value, 6);
|
||||
pwdTip.innerHTML = '';
|
||||
pwdTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
|
||||
|
||||
if (!result.valid) {
|
||||
pwdTip.textContent = result.message;
|
||||
pwdTip.classList.add(result.className);
|
||||
return false;
|
||||
}
|
||||
if (result.value !== repeatPasswordInput.value) {
|
||||
pwdTip.textContent = '两次输入的密码不一致!';
|
||||
pwdTip.classList.add('tip-yellow');
|
||||
return false;
|
||||
}
|
||||
|
||||
pwdTip.textContent = result.message;
|
||||
pwdTip.classList.add(result.className);
|
||||
return result.value;
|
||||
}
|
||||
|
||||
function checkCode() {
|
||||
const code = codeInput.value.trim();
|
||||
codeTip.innerHTML = '';
|
||||
codeTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
|
||||
|
||||
if (!codeTarget) {
|
||||
codeTip.textContent = '请先获取验证码!';
|
||||
codeTip.classList.add('tip-red');
|
||||
return false;
|
||||
}
|
||||
if (emailInput.value.trim() !== codeTarget) {
|
||||
codeTip.textContent = '邮箱已修改,请重新获取验证码!';
|
||||
codeTip.classList.add('tip-red');
|
||||
return false;
|
||||
}
|
||||
if (!/^\d{6}$/.test(code)) {
|
||||
codeTip.textContent = '验证码必须是6位数字!';
|
||||
codeTip.classList.add('tip-yellow');
|
||||
return false;
|
||||
}
|
||||
// 验证码正确性由服务端在重置提交时校验(一次性消费)
|
||||
return code;
|
||||
}
|
||||
|
||||
function checkCaptcha() {
|
||||
captchaTip.innerHTML = '';
|
||||
captchaTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
|
||||
const v = captchaInput.value.trim();
|
||||
if (!v) {
|
||||
captchaTip.textContent = '请输入图形验证码!';
|
||||
captchaTip.classList.add('tip-red');
|
||||
return false;
|
||||
}
|
||||
if (!captchaToken) {
|
||||
captchaTip.textContent = '图形验证码未加载,请点击图片刷新';
|
||||
captchaTip.classList.add('tip-red');
|
||||
return false;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
passwordInput.addEventListener('focus', checkPassword);
|
||||
repeatPasswordInput.addEventListener('focus', checkPassword);
|
||||
|
||||
passwordInput.addEventListener('input', checkPassword);
|
||||
repeatPasswordInput.addEventListener('input', checkPassword);
|
||||
|
||||
passwordInput.addEventListener('blur', checkPassword);
|
||||
repeatPasswordInput.addEventListener('blur', checkPassword);
|
||||
|
||||
emailInput.addEventListener('focus', checkEmail);
|
||||
emailInput.addEventListener('input', function () {
|
||||
checkEmail();
|
||||
if (codeTarget) {
|
||||
checkCode();
|
||||
}
|
||||
});
|
||||
emailInput.addEventListener('blur', checkEmail);
|
||||
|
||||
codeInput.addEventListener('focus', checkCode);
|
||||
codeInput.addEventListener('input', checkCode);
|
||||
codeInput.addEventListener('blur', checkCode);
|
||||
|
||||
// 发送重置密码验证码:/reset/send_code(CAPTCHA 必填,gateway 强制校验防邮件轰炸)
|
||||
getCodeButton.addEventListener('click', async function () {
|
||||
if (getCodeButton.disabled) return; // 防重复点击
|
||||
const email = checkEmail();
|
||||
if (!email) return;
|
||||
const captchaAnswer = checkCaptcha();
|
||||
if (!captchaAnswer) return;
|
||||
|
||||
getCodeButton.disabled = true;
|
||||
codeTip.textContent = '验证码发送中…';
|
||||
codeTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
|
||||
codeTip.classList.add('tip-yellow');
|
||||
let httpStatus = 0;
|
||||
try {
|
||||
const res = await fetch('/reset/send_code', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
captcha_token: captchaToken,
|
||||
captcha_answer: captchaAnswer
|
||||
})
|
||||
});
|
||||
httpStatus = res.status;
|
||||
const responseText = await res.text();
|
||||
if (window.WafTip && WafTip.isBlocked(res, responseText)) throw new Error(WafTip.MESSAGE);
|
||||
let result;
|
||||
try { result = JSON.parse(responseText); } catch { throw new Error('服务器响应格式异常'); }
|
||||
if (!res.ok || !result.success) throw new Error(result.message || '验证码发送失败');
|
||||
|
||||
codeTarget = email;
|
||||
codeInput.value = '';
|
||||
codeTip.textContent = '验证码已发送至邮箱,请查收(10 分钟内有效)';
|
||||
codeTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
|
||||
codeTip.classList.add('tip-green');
|
||||
startResendCooldown(60);
|
||||
} catch (err) {
|
||||
codeTip.textContent = err.message || '验证码发送失败,请稍后重试';
|
||||
codeTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
|
||||
codeTip.classList.add('tip-red');
|
||||
if (httpStatus === 429) startResendCooldown(60); // 命中冷却/限流,倒计时对齐
|
||||
} finally {
|
||||
// CAPTCHA Token 一次性消费,无论成败都刷新图形验证码;
|
||||
// 刷新时保留已输入的图形验证码答案,避免误清空用户输入
|
||||
refreshCaptcha(true);
|
||||
if (!cooldownTimer) getCodeButton.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
// 提交重置密码:/reset_password(创建用户改为更新密码;用户不存在时服务端统一返回成功防枚举)
|
||||
form.addEventListener('submit', async function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
const email = checkEmail();
|
||||
const password = checkPassword();
|
||||
const code = checkCode();
|
||||
|
||||
if (!email || !password || !code) {
|
||||
return;
|
||||
}
|
||||
const jsonBody = JSON.stringify({
|
||||
username: email,
|
||||
password,
|
||||
email_code: code
|
||||
});
|
||||
|
||||
try {
|
||||
submitButton.disabled = true;
|
||||
const res = await fetch('/reset_password', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: jsonBody
|
||||
});
|
||||
|
||||
const responseText = await res.text();
|
||||
if (window.WafTip && WafTip.isBlocked(res, responseText)) throw new Error(WafTip.MESSAGE);
|
||||
let result;
|
||||
|
||||
try {
|
||||
result = JSON.parse(responseText);
|
||||
} catch {
|
||||
console.error(`重置密码接口返回非 JSON 内容(HTTP ${res.status}):`, responseText);
|
||||
throw new Error('服务器响应格式异常,请稍后重试');
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(result.message || `重置密码失败(HTTP ${res.status})`);
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
alert('重置成功,请使用新密码登录');
|
||||
form.reset();
|
||||
codeTarget = '';
|
||||
location.href = 'login.html';
|
||||
return;
|
||||
}
|
||||
|
||||
alert(result.message || '重置失败');
|
||||
|
||||
} catch (err) {
|
||||
console.error('重置密码请求异常:', err);
|
||||
alert(err.message || '接口请求失败,请检查后端服务是否启动');
|
||||
} finally {
|
||||
submitButton.disabled = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 卿松
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,345 +0,0 @@
|
||||
<!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>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
font-family: 'Arial Rounded MT Bold', 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
background: linear-gradient(135deg, #6a11cb 0%, #2575fc 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 800px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2.8rem;
|
||||
margin-bottom: 10px;
|
||||
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.game-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin: 20px 0;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
padding: 15px;
|
||||
border-radius: 15px;
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.info-item {
|
||||
font-size: 1.2rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.game-board {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 15px;
|
||||
margin: 0 auto;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.card {
|
||||
height: 120px;
|
||||
perspective: 1000px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card-inner {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
transition: transform 0.6s;
|
||||
transform-style: preserve-3d;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.card.flipped .card-inner {
|
||||
transform: rotateY(180deg);
|
||||
}
|
||||
|
||||
.card-front, .card-back {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
-webkit-backface-visibility: hidden;
|
||||
backface-visibility: hidden;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.card-front {
|
||||
background: linear-gradient(135deg, #ff9a9e 0%, #fad0c4 100%);
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.card-back {
|
||||
background: linear-gradient(135deg, #a1c4fd 0%, #c2e9fb 100%);
|
||||
color: white;
|
||||
transform: rotateY(180deg);
|
||||
}
|
||||
|
||||
.card.matched .card-back {
|
||||
background: linear-gradient(135deg, #56ab2f 0%, #a8e6cf 100%);
|
||||
}
|
||||
|
||||
.controls {
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
button {
|
||||
background: linear-gradient(135deg, #ff9a9e 0%, #fecfef 100%);
|
||||
border: none;
|
||||
padding: 12px 30px;
|
||||
font-size: 1.1rem;
|
||||
border-radius: 50px;
|
||||
cursor: pointer;
|
||||
color: #333;
|
||||
font-weight: bold;
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
|
||||
transition: all 0.3s ease;
|
||||
margin: 0 10px;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
button:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.message {
|
||||
margin-top: 20px;
|
||||
font-size: 1.3rem;
|
||||
font-weight: bold;
|
||||
min-height: 30px;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.game-board {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.card {
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 400px) {
|
||||
.game-board {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.card {
|
||||
height: 90px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>记忆翻牌游戏</h1>
|
||||
<p>找出所有匹配的卡片对,考验你的记忆力!</p>
|
||||
|
||||
<div class="game-info">
|
||||
<div class="info-item">时间: <span id="timer">0</span>秒</div>
|
||||
<div class="info-item">移动次数: <span id="moves">0</span></div>
|
||||
<div class="info-item">匹配: <span id="matches">0</span>/8</div>
|
||||
</div>
|
||||
|
||||
<div class="game-board" id="gameBoard"></div>
|
||||
|
||||
<div class="message" id="message"></div>
|
||||
|
||||
<div class="controls">
|
||||
<button id="startBtn">开始游戏</button>
|
||||
<button id="resetBtn">重新开始</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const gameBoard = document.getElementById('gameBoard');
|
||||
const startBtn = document.getElementById('startBtn');
|
||||
const resetBtn = document.getElementById('resetBtn');
|
||||
const timerEl = document.getElementById('timer');
|
||||
const movesEl = document.getElementById('moves');
|
||||
const matchesEl = document.getElementById('matches');
|
||||
const messageEl = document.getElementById('message');
|
||||
|
||||
let cards = [];
|
||||
let flippedCards = [];
|
||||
let matchedPairs = 0;
|
||||
let moves = 0;
|
||||
let timer = 0;
|
||||
let timerInterval;
|
||||
let gameStarted = false;
|
||||
|
||||
// 卡片符号
|
||||
const cardSymbols = ['🍎', '🍌', '🍒', '🍇', '🍊', '🍓', '🍑', '🥭'];
|
||||
|
||||
// 初始化游戏
|
||||
function initGame() {
|
||||
// 创建卡片数组(每对两张)
|
||||
cards = [...cardSymbols, ...cardSymbols];
|
||||
|
||||
// 洗牌算法
|
||||
for (let i = cards.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[cards[i], cards[j]] = [cards[j], cards[i]];
|
||||
}
|
||||
|
||||
// 清空游戏板
|
||||
gameBoard.innerHTML = '';
|
||||
|
||||
// 创建卡片元素
|
||||
cards.forEach((symbol, index) => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card';
|
||||
card.dataset.index = index;
|
||||
card.dataset.symbol = symbol;
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="card-inner">
|
||||
<div class="card-front">?</div>
|
||||
<div class="card-back">${symbol}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
card.addEventListener('click', flipCard);
|
||||
gameBoard.appendChild(card);
|
||||
});
|
||||
|
||||
// 重置游戏状态
|
||||
flippedCards = [];
|
||||
matchedPairs = 0;
|
||||
moves = 0;
|
||||
timer = 0;
|
||||
gameStarted = false;
|
||||
|
||||
// 更新显示
|
||||
movesEl.textContent = moves;
|
||||
matchesEl.textContent = matchedPairs;
|
||||
timerEl.textContent = timer;
|
||||
messageEl.textContent = '点击"开始游戏"按钮开始';
|
||||
}
|
||||
|
||||
// 翻转卡片
|
||||
function flipCard() {
|
||||
if (!gameStarted) return;
|
||||
|
||||
const card = this;
|
||||
|
||||
// 如果卡片已经匹配或已经翻转,则忽略点击
|
||||
if (card.classList.contains('matched') || card.classList.contains('flipped')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 翻转卡片
|
||||
card.classList.add('flipped');
|
||||
flippedCards.push(card);
|
||||
|
||||
// 如果翻转了两张卡片,检查是否匹配
|
||||
if (flippedCards.length === 2) {
|
||||
moves++;
|
||||
movesEl.textContent = moves;
|
||||
|
||||
const card1 = flippedCards[0];
|
||||
const card2 = flippedCards[1];
|
||||
|
||||
if (card1.dataset.symbol === card2.dataset.symbol) {
|
||||
// 匹配成功
|
||||
card1.classList.add('matched');
|
||||
card2.classList.add('matched');
|
||||
matchedPairs++;
|
||||
matchesEl.textContent = matchedPairs;
|
||||
|
||||
flippedCards = [];
|
||||
|
||||
// 检查游戏是否结束
|
||||
if (matchedPairs === cardSymbols.length) {
|
||||
endGame();
|
||||
}
|
||||
} else {
|
||||
// 不匹配,翻转回去
|
||||
setTimeout(() => {
|
||||
card1.classList.remove('flipped');
|
||||
card2.classList.remove('flipped');
|
||||
flippedCards = [];
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 开始游戏
|
||||
function startGame() {
|
||||
if (gameStarted) return;
|
||||
|
||||
gameStarted = true;
|
||||
messageEl.textContent = '游戏进行中...';
|
||||
|
||||
// 启动计时器
|
||||
timerInterval = setInterval(() => {
|
||||
timer++;
|
||||
timerEl.textContent = timer;
|
||||
}, 1000);
|
||||
|
||||
// 禁用开始按钮
|
||||
startBtn.disabled = true;
|
||||
}
|
||||
|
||||
// 结束游戏
|
||||
function endGame() {
|
||||
clearInterval(timerInterval);
|
||||
messageEl.textContent = `恭喜!你用了${moves}步和${timer}秒完成了游戏!`;
|
||||
startBtn.disabled = false;
|
||||
}
|
||||
|
||||
// 重置游戏
|
||||
function resetGame() {
|
||||
clearInterval(timerInterval);
|
||||
initGame();
|
||||
startBtn.disabled = false;
|
||||
}
|
||||
|
||||
// 事件监听
|
||||
startBtn.addEventListener('click', startGame);
|
||||
resetBtn.addEventListener('click', resetGame);
|
||||
|
||||
// 初始化游戏
|
||||
initGame();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,813 +0,0 @@
|
||||
<!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>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
background: linear-gradient(135deg, #0f2027, #203a43, #2c5364);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
color: white;
|
||||
overflow-x: hidden;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 800px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 10px;
|
||||
text-shadow: 0 0 10px #00ffff, 0 0 20px #00ffff;
|
||||
letter-spacing: 3px;
|
||||
background: linear-gradient(to right, #00ffff, #ff00ff);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 20px;
|
||||
color: #a0e7ff;
|
||||
}
|
||||
|
||||
.game-container {
|
||||
position: relative;
|
||||
margin: 20px auto;
|
||||
border: 3px solid #00ffff;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 0 20px #00ffff, inset 0 0 20px rgba(0, 255, 255, 0.1);
|
||||
overflow: hidden;
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
#gameCanvas {
|
||||
background: #000;
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.game-ui {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin: 15px 0;
|
||||
padding: 15px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border-radius: 10px;
|
||||
border: 1px solid #00ffff;
|
||||
}
|
||||
|
||||
.ui-item {
|
||||
font-size: 1.2rem;
|
||||
font-weight: bold;
|
||||
text-shadow: 0 0 5px #00ffff;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 15px;
|
||||
margin: 20px 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
button {
|
||||
background: linear-gradient(135deg, #00ffff, #ff00ff);
|
||||
border: none;
|
||||
padding: 12px 25px;
|
||||
font-size: 1.1rem;
|
||||
border-radius: 50px;
|
||||
cursor: pointer;
|
||||
color: #000;
|
||||
font-weight: bold;
|
||||
box-shadow: 0 0 15px rgba(0, 255, 255, 0.7);
|
||||
transition: all 0.3s ease;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 0 20px rgba(0, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
button:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
background: #555;
|
||||
color: #888;
|
||||
box-shadow: none;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.instructions {
|
||||
margin-top: 25px;
|
||||
padding: 15px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border-radius: 10px;
|
||||
border: 1px solid #00ffff;
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.instructions h3 {
|
||||
color: #00ffff;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.instructions p {
|
||||
margin: 8px 0;
|
||||
color: #a0e7ff;
|
||||
}
|
||||
|
||||
.game-over {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
padding: 30px;
|
||||
border-radius: 15px;
|
||||
border: 2px solid #ff00ff;
|
||||
text-align: center;
|
||||
box-shadow: 0 0 30px #ff00ff;
|
||||
z-index: 10;
|
||||
display: none;
|
||||
width: 80%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.game-over h2 {
|
||||
font-size: 2.2rem;
|
||||
color: #ff00ff;
|
||||
margin-bottom: 15px;
|
||||
text-shadow: 0 0 10px #ff00ff;
|
||||
}
|
||||
|
||||
.game-over p {
|
||||
font-size: 1.2rem;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.stars {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.star {
|
||||
position: absolute;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
animation: twinkle 5s infinite;
|
||||
}
|
||||
|
||||
@keyframes twinkle {
|
||||
0%, 100% { opacity: 0.2; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
h1 {
|
||||
font-size: 2.2rem;
|
||||
}
|
||||
|
||||
.game-ui {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.controls {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
max-width: 250px;
|
||||
}
|
||||
|
||||
.instructions {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
h1 {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.ui-item {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.game-over h2 {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.game-over p {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="stars" id="stars"></div>
|
||||
|
||||
<div class="container">
|
||||
<h1>太空入侵者</h1>
|
||||
<p class="subtitle">保卫地球,击退外星入侵者!</p>
|
||||
|
||||
<div class="game-ui">
|
||||
<div class="ui-item">分数: <span id="score">0</span></div>
|
||||
<div class="ui-item">生命: <span id="lives">3</span></div>
|
||||
<div class="ui-item">等级: <span id="level">1</span></div>
|
||||
</div>
|
||||
|
||||
<div class="game-container">
|
||||
<canvas id="gameCanvas" width="800" height="500"></canvas>
|
||||
|
||||
<div class="game-over" id="gameOver">
|
||||
<h2>游戏结束</h2>
|
||||
<p>你的最终分数: <span id="finalScore">0</span></p>
|
||||
<p id="gameMessage">地球需要你,再试一次!</p>
|
||||
<button id="restartBtn">重新开始</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<button id="startBtn">开始游戏</button>
|
||||
<button id="pauseBtn">暂停游戏</button>
|
||||
</div>
|
||||
|
||||
<div class="instructions">
|
||||
<h3>游戏说明</h3>
|
||||
<p>← → 键: 移动飞船</p>
|
||||
<p>空格键: 发射激光</p>
|
||||
<p>躲避敌人的攻击,击落所有外星飞船!</p>
|
||||
<p>每关敌人数量和速度会增加,坚持越久得分越高!</p>
|
||||
<p>每得1000分升一级,敌人速度会加快!</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// 创建星空背景
|
||||
createStars();
|
||||
|
||||
const canvas = document.getElementById('gameCanvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const startBtn = document.getElementById('startBtn');
|
||||
const pauseBtn = document.getElementById('pauseBtn');
|
||||
const restartBtn = document.getElementById('restartBtn');
|
||||
const scoreEl = document.getElementById('score');
|
||||
const livesEl = document.getElementById('lives');
|
||||
const levelEl = document.getElementById('level');
|
||||
const gameOverEl = document.getElementById('gameOver');
|
||||
const finalScoreEl = document.getElementById('finalScore');
|
||||
const gameMessageEl = document.getElementById('gameMessage');
|
||||
|
||||
// 游戏状态
|
||||
let game = {
|
||||
running: false,
|
||||
paused: false,
|
||||
score: 0,
|
||||
lives: 3,
|
||||
level: 1,
|
||||
enemies: [],
|
||||
bullets: [],
|
||||
enemyBullets: [],
|
||||
particles: [],
|
||||
lastEnemySpawn: 0,
|
||||
lastEnemyShoot: 0
|
||||
};
|
||||
|
||||
// 玩家飞船
|
||||
const player = {
|
||||
x: canvas.width / 2 - 25,
|
||||
y: canvas.height - 70,
|
||||
width: 50,
|
||||
height: 60,
|
||||
speed: 8,
|
||||
color: '#00ffff',
|
||||
lastShot: 0,
|
||||
shotDelay: 300
|
||||
};
|
||||
|
||||
// 键盘状态
|
||||
const keys = {};
|
||||
|
||||
// 初始化游戏
|
||||
function initGame() {
|
||||
game = {
|
||||
running: true,
|
||||
paused: false,
|
||||
score: 0,
|
||||
lives: 3,
|
||||
level: 1,
|
||||
enemies: [],
|
||||
bullets: [],
|
||||
enemyBullets: [],
|
||||
particles: [],
|
||||
lastEnemySpawn: 0,
|
||||
lastEnemyShoot: 0
|
||||
};
|
||||
|
||||
player.x = canvas.width / 2 - 25;
|
||||
player.y = canvas.height - 70;
|
||||
|
||||
updateUI();
|
||||
gameOverEl.style.display = 'none';
|
||||
pauseBtn.textContent = '暂停游戏';
|
||||
|
||||
// 开始游戏循环
|
||||
if (!game.paused) {
|
||||
requestAnimationFrame(gameLoop);
|
||||
}
|
||||
}
|
||||
|
||||
// 创建星空背景
|
||||
function createStars() {
|
||||
const starsContainer = document.getElementById('stars');
|
||||
const starCount = 150;
|
||||
|
||||
for (let i = 0; i < starCount; i++) {
|
||||
const star = document.createElement('div');
|
||||
star.className = 'star';
|
||||
|
||||
const size = Math.random() * 3;
|
||||
star.style.width = `${size}px`;
|
||||
star.style.height = `${size}px`;
|
||||
|
||||
star.style.left = `${Math.random() * 100}%`;
|
||||
star.style.top = `${Math.random() * 100}%`;
|
||||
|
||||
star.style.animationDelay = `${Math.random() * 5}s`;
|
||||
|
||||
starsContainer.appendChild(star);
|
||||
}
|
||||
}
|
||||
|
||||
// 绘制玩家飞船
|
||||
function drawPlayer() {
|
||||
ctx.fillStyle = player.color;
|
||||
|
||||
// 飞船主体
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(player.x + player.width / 2, player.y);
|
||||
ctx.lineTo(player.x + player.width, player.y + player.height);
|
||||
ctx.lineTo(player.x, player.y + player.height);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
// 飞船引擎
|
||||
ctx.fillStyle = '#ff9900';
|
||||
ctx.fillRect(player.x + player.width / 2 - 10, player.y + player.height, 20, 10);
|
||||
|
||||
// 飞船细节
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(player.x + player.width / 2 - 5, player.y + 10, 10, 15);
|
||||
}
|
||||
|
||||
// 生成敌人
|
||||
function spawnEnemy() {
|
||||
const now = Date.now();
|
||||
if (now - game.lastEnemySpawn > 1000 - game.level * 50) {
|
||||
const enemy = {
|
||||
x: Math.random() * (canvas.width - 40),
|
||||
y: -40,
|
||||
width: 40,
|
||||
height: 40,
|
||||
speed: 1 + game.level * 0.2,
|
||||
color: `hsl(${Math.random() * 360}, 100%, 60%)`,
|
||||
health: 1
|
||||
};
|
||||
|
||||
game.enemies.push(enemy);
|
||||
game.lastEnemySpawn = now;
|
||||
}
|
||||
}
|
||||
|
||||
// 绘制敌人
|
||||
function drawEnemies() {
|
||||
game.enemies.forEach(enemy => {
|
||||
ctx.fillStyle = enemy.color;
|
||||
|
||||
// 敌人飞船主体
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(enemy.x + enemy.width / 2, enemy.y + enemy.height);
|
||||
ctx.lineTo(enemy.x + enemy.width, enemy.y);
|
||||
ctx.lineTo(enemy.x, enemy.y);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
// 敌人飞船细节
|
||||
ctx.fillStyle = '#ff0000';
|
||||
ctx.fillRect(enemy.x + enemy.width / 2 - 5, enemy.y + 10, 10, 10);
|
||||
});
|
||||
}
|
||||
|
||||
// 绘制子弹
|
||||
function drawBullets() {
|
||||
// 玩家子弹
|
||||
ctx.fillStyle = '#00ffff';
|
||||
game.bullets.forEach(bullet => {
|
||||
ctx.fillRect(bullet.x, bullet.y, bullet.width, bullet.height);
|
||||
});
|
||||
|
||||
// 敌人子弹
|
||||
ctx.fillStyle = '#ff0000';
|
||||
game.enemyBullets.forEach(bullet => {
|
||||
ctx.fillRect(bullet.x, bullet.y, bullet.width, bullet.height);
|
||||
});
|
||||
}
|
||||
|
||||
// 绘制粒子效果
|
||||
function drawParticles() {
|
||||
game.particles.forEach(particle => {
|
||||
ctx.fillStyle = particle.color;
|
||||
ctx.globalAlpha = particle.alpha;
|
||||
ctx.fillRect(particle.x, particle.y, particle.size, particle.size);
|
||||
});
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
// 玩家射击
|
||||
function playerShoot() {
|
||||
const now = Date.now();
|
||||
if (now - player.lastShot > player.shotDelay) {
|
||||
const bullet = {
|
||||
x: player.x + player.width / 2 - 2,
|
||||
y: player.y,
|
||||
width: 4,
|
||||
height: 15,
|
||||
speed: 10,
|
||||
color: '#00ffff'
|
||||
};
|
||||
|
||||
game.bullets.push(bullet);
|
||||
player.lastShot = now;
|
||||
|
||||
// 射击音效
|
||||
playLaserSound();
|
||||
}
|
||||
}
|
||||
|
||||
// 敌人射击
|
||||
function enemyShoot() {
|
||||
const now = Date.now();
|
||||
if (now - game.lastEnemyShoot > 2000 - game.level * 100 && game.enemies.length > 0) {
|
||||
const shooter = game.enemies[Math.floor(Math.random() * game.enemies.length)];
|
||||
|
||||
const bullet = {
|
||||
x: shooter.x + shooter.width / 2 - 2,
|
||||
y: shooter.y + shooter.height,
|
||||
width: 4,
|
||||
height: 15,
|
||||
speed: 5,
|
||||
color: '#ff0000'
|
||||
};
|
||||
|
||||
game.enemyBullets.push(bullet);
|
||||
game.lastEnemyShoot = now;
|
||||
}
|
||||
}
|
||||
|
||||
// 创建爆炸粒子
|
||||
function createExplosion(x, y, color) {
|
||||
for (let i = 0; i < 15; i++) {
|
||||
const particle = {
|
||||
x: x,
|
||||
y: y,
|
||||
size: Math.random() * 5 + 2,
|
||||
speedX: (Math.random() - 0.5) * 5,
|
||||
speedY: (Math.random() - 0.5) * 5,
|
||||
color: color,
|
||||
alpha: 1,
|
||||
decay: 0.02
|
||||
};
|
||||
|
||||
game.particles.push(particle);
|
||||
}
|
||||
}
|
||||
|
||||
// 更新游戏对象位置
|
||||
function updatePositions() {
|
||||
// 更新玩家子弹
|
||||
game.bullets = game.bullets.filter(bullet => {
|
||||
bullet.y -= bullet.speed;
|
||||
return bullet.y > 0;
|
||||
});
|
||||
|
||||
// 更新敌人子弹
|
||||
game.enemyBullets = game.enemyBullets.filter(bullet => {
|
||||
bullet.y += bullet.speed;
|
||||
return bullet.y < canvas.height;
|
||||
});
|
||||
|
||||
// 更新敌人
|
||||
game.enemies = game.enemies.filter(enemy => {
|
||||
enemy.y += enemy.speed;
|
||||
|
||||
// 敌人超出屏幕
|
||||
if (enemy.y > canvas.height) {
|
||||
game.lives--;
|
||||
updateUI();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// 更新粒子
|
||||
game.particles = game.particles.filter(particle => {
|
||||
particle.x += particle.speedX;
|
||||
particle.y += particle.speedY;
|
||||
particle.alpha -= particle.decay;
|
||||
return particle.alpha > 0;
|
||||
});
|
||||
}
|
||||
|
||||
// 检测碰撞
|
||||
function checkCollisions() {
|
||||
// 玩家子弹与敌人碰撞
|
||||
game.bullets.forEach((bullet, bulletIndex) => {
|
||||
game.enemies.forEach((enemy, enemyIndex) => {
|
||||
if (
|
||||
bullet.x < enemy.x + enemy.width &&
|
||||
bullet.x + bullet.width > enemy.x &&
|
||||
bullet.y < enemy.y + enemy.height &&
|
||||
bullet.y + bullet.height > enemy.y
|
||||
) {
|
||||
// 击中敌人
|
||||
game.bullets.splice(bulletIndex, 1);
|
||||
game.enemies.splice(enemyIndex, 1);
|
||||
game.score += 100;
|
||||
createExplosion(enemy.x + enemy.width / 2, enemy.y + enemy.height / 2, enemy.color);
|
||||
updateUI();
|
||||
|
||||
// 爆炸音效
|
||||
playExplosionSound();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 敌人子弹与玩家碰撞
|
||||
game.enemyBullets.forEach((bullet, bulletIndex) => {
|
||||
if (
|
||||
bullet.x < player.x + player.width &&
|
||||
bullet.x + bullet.width > player.x &&
|
||||
bullet.y < player.y + player.height &&
|
||||
bullet.y + bullet.height > player.y
|
||||
) {
|
||||
// 玩家被击中
|
||||
game.enemyBullets.splice(bulletIndex, 1);
|
||||
game.lives--;
|
||||
createExplosion(player.x + player.width / 2, player.y + player.height / 2, player.color);
|
||||
updateUI();
|
||||
|
||||
if (game.lives <= 0) {
|
||||
endGame(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 敌人与玩家碰撞
|
||||
game.enemies.forEach((enemy, enemyIndex) => {
|
||||
if (
|
||||
enemy.x < player.x + player.width &&
|
||||
enemy.x + enemy.width > player.x &&
|
||||
enemy.y < player.y + player.height &&
|
||||
enemy.y + enemy.height > player.y
|
||||
) {
|
||||
// 玩家与敌人相撞
|
||||
game.enemies.splice(enemyIndex, 1);
|
||||
game.lives--;
|
||||
createExplosion(player.x + player.width / 2, player.y + player.height / 2, player.color);
|
||||
updateUI();
|
||||
|
||||
if (game.lives <= 0) {
|
||||
endGame(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 更新等级
|
||||
function updateLevel() {
|
||||
if (game.score >= game.level * 1000) {
|
||||
game.level++;
|
||||
levelEl.textContent = game.level;
|
||||
|
||||
// 升级音效
|
||||
playLevelUpSound();
|
||||
}
|
||||
}
|
||||
|
||||
// 游戏结束
|
||||
function endGame(win) {
|
||||
game.running = false;
|
||||
finalScoreEl.textContent = game.score;
|
||||
|
||||
if (win) {
|
||||
gameMessageEl.textContent = "恭喜!你成功保卫了地球!";
|
||||
} else {
|
||||
gameMessageEl.textContent = "地球需要你,再试一次!";
|
||||
}
|
||||
|
||||
gameOverEl.style.display = 'block';
|
||||
}
|
||||
|
||||
// 更新UI
|
||||
function updateUI() {
|
||||
scoreEl.textContent = game.score;
|
||||
livesEl.textContent = game.lives;
|
||||
levelEl.textContent = game.level;
|
||||
}
|
||||
|
||||
// 游戏主循环
|
||||
function gameLoop() {
|
||||
if (!game.running || game.paused) return;
|
||||
|
||||
// 清空画布
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.2)';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// 生成敌人
|
||||
spawnEnemy();
|
||||
|
||||
// 敌人射击
|
||||
enemyShoot();
|
||||
|
||||
// 更新位置
|
||||
updatePositions();
|
||||
|
||||
// 检测碰撞
|
||||
checkCollisions();
|
||||
|
||||
// 更新等级
|
||||
updateLevel();
|
||||
|
||||
// 绘制游戏对象
|
||||
drawEnemies();
|
||||
drawBullets();
|
||||
drawParticles();
|
||||
drawPlayer();
|
||||
|
||||
// 处理玩家移动
|
||||
if (keys['ArrowLeft'] && player.x > 0) {
|
||||
player.x -= player.speed;
|
||||
}
|
||||
if (keys['ArrowRight'] && player.x < canvas.width - player.width) {
|
||||
player.x += player.speed;
|
||||
}
|
||||
if (keys[' ']) {
|
||||
playerShoot();
|
||||
}
|
||||
|
||||
// 继续游戏循环
|
||||
requestAnimationFrame(gameLoop);
|
||||
}
|
||||
|
||||
// 音效函数
|
||||
function playLaserSound() {
|
||||
// 在实际应用中,这里会播放音效文件
|
||||
// 为了简化,我们只创建音频上下文并生成音效
|
||||
try {
|
||||
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||
const oscillator = audioContext.createOscillator();
|
||||
const gainNode = audioContext.createGain();
|
||||
|
||||
oscillator.connect(gainNode);
|
||||
gainNode.connect(audioContext.destination);
|
||||
|
||||
oscillator.frequency.value = 800;
|
||||
oscillator.type = 'square';
|
||||
|
||||
gainNode.gain.setValueAtTime(0.1, audioContext.currentTime);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.1);
|
||||
|
||||
oscillator.start(audioContext.currentTime);
|
||||
oscillator.stop(audioContext.currentTime + 0.1);
|
||||
} catch (e) {
|
||||
// 音频上下文不支持时的降级处理
|
||||
console.log("音效播放失败");
|
||||
}
|
||||
}
|
||||
|
||||
function playExplosionSound() {
|
||||
// 类似上面的实现,但使用不同的音调
|
||||
try {
|
||||
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||
const oscillator = audioContext.createOscillator();
|
||||
const gainNode = audioContext.createGain();
|
||||
|
||||
oscillator.connect(gainNode);
|
||||
gainNode.connect(audioContext.destination);
|
||||
|
||||
oscillator.frequency.value = 100;
|
||||
oscillator.type = 'sawtooth';
|
||||
|
||||
gainNode.gain.setValueAtTime(0.3, audioContext.currentTime);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.5);
|
||||
|
||||
oscillator.start(audioContext.currentTime);
|
||||
oscillator.stop(audioContext.currentTime + 0.5);
|
||||
} catch (e) {
|
||||
console.log("音效播放失败");
|
||||
}
|
||||
}
|
||||
|
||||
function playLevelUpSound() {
|
||||
// 类似上面的实现
|
||||
try {
|
||||
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||
const oscillator = audioContext.createOscillator();
|
||||
const gainNode = audioContext.createGain();
|
||||
|
||||
oscillator.connect(gainNode);
|
||||
gainNode.connect(audioContext.destination);
|
||||
|
||||
oscillator.frequency.setValueAtTime(300, audioContext.currentTime);
|
||||
oscillator.frequency.exponentialRampToValueAtTime(600, audioContext.currentTime + 0.2);
|
||||
oscillator.type = 'sine';
|
||||
|
||||
gainNode.gain.setValueAtTime(0.2, audioContext.currentTime);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.3);
|
||||
|
||||
oscillator.start(audioContext.currentTime);
|
||||
oscillator.stop(audioContext.currentTime + 0.3);
|
||||
} catch (e) {
|
||||
console.log("音效播放失败");
|
||||
}
|
||||
}
|
||||
|
||||
// 事件监听
|
||||
window.addEventListener('keydown', (e) => {
|
||||
keys[e.key] = true;
|
||||
|
||||
// 防止空格键滚动页面
|
||||
if (e.key === ' ') {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('keyup', (e) => {
|
||||
keys[e.key] = false;
|
||||
});
|
||||
|
||||
startBtn.addEventListener('click', () => {
|
||||
if (!game.running) {
|
||||
initGame();
|
||||
startBtn.disabled = true;
|
||||
setTimeout(() => {
|
||||
startBtn.disabled = false;
|
||||
}, 1000);
|
||||
}
|
||||
});
|
||||
|
||||
pauseBtn.addEventListener('click', () => {
|
||||
if (game.running) {
|
||||
game.paused = !game.paused;
|
||||
pauseBtn.textContent = game.paused ? '继续游戏' : '暂停游戏';
|
||||
|
||||
if (!game.paused) {
|
||||
requestAnimationFrame(gameLoop);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
restartBtn.addEventListener('click', () => {
|
||||
initGame();
|
||||
});
|
||||
|
||||
// 初始UI更新
|
||||
updateUI();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,707 +0,0 @@
|
||||
<!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>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
background: linear-gradient(135deg, #1a2a6c, #b21f1f, #fdbb2d);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
color: white;
|
||||
overflow-x: hidden;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 800px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2.8rem;
|
||||
margin-bottom: 10px;
|
||||
text-shadow: 0 0 10px #00ff00, 0 0 20px #00ff00;
|
||||
letter-spacing: 2px;
|
||||
background: linear-gradient(to right, #00ff00, #ffff00);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 15px;
|
||||
color: #a0ffa0;
|
||||
}
|
||||
|
||||
.game-container {
|
||||
position: relative;
|
||||
margin: 15px auto;
|
||||
border: 3px solid #00ff00;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 0 20px #00ff00, inset 0 0 20px rgba(0, 255, 0, 0.1);
|
||||
overflow: hidden;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
#gameCanvas {
|
||||
background: #000;
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.game-ui {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin: 10px 0;
|
||||
padding: 12px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border-radius: 10px;
|
||||
border: 1px solid #00ff00;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ui-item {
|
||||
font-size: 1.1rem;
|
||||
font-weight: bold;
|
||||
text-shadow: 0 0 5px #00ff00;
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
margin: 15px 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
button {
|
||||
background: linear-gradient(135deg, #00ff00, #ffff00);
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
font-size: 1rem;
|
||||
border-radius: 50px;
|
||||
cursor: pointer;
|
||||
color: #000;
|
||||
font-weight: bold;
|
||||
box-shadow: 0 0 15px rgba(0, 255, 0, 0.7);
|
||||
transition: all 0.3s ease;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 0 20px rgba(0, 255, 0, 0.9);
|
||||
}
|
||||
|
||||
button:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
background: #555;
|
||||
color: #888;
|
||||
box-shadow: none;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.instructions {
|
||||
margin-top: 20px;
|
||||
padding: 15px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border-radius: 10px;
|
||||
border: 1px solid #00ff00;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.instructions h3 {
|
||||
color: #00ff00;
|
||||
margin-bottom: 10px;
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.instructions p {
|
||||
margin: 8px 0;
|
||||
color: #a0ffa0;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.game-over {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
padding: 25px;
|
||||
border-radius: 15px;
|
||||
border: 2px solid #ff0000;
|
||||
text-align: center;
|
||||
box-shadow: 0 0 30px #ff0000;
|
||||
z-index: 10;
|
||||
display: none;
|
||||
width: 85%;
|
||||
max-width: 350px;
|
||||
}
|
||||
|
||||
.game-over h2 {
|
||||
font-size: 1.8rem;
|
||||
color: #ff0000;
|
||||
margin-bottom: 12px;
|
||||
text-shadow: 0 0 10px #ff0000;
|
||||
}
|
||||
|
||||
.game-over p {
|
||||
font-size: 1.1rem;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.mobile-controls {
|
||||
display: none;
|
||||
margin-top: 15px;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-rows: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
max-width: 180px;
|
||||
}
|
||||
|
||||
.mobile-controls button {
|
||||
width: 55px;
|
||||
height: 55px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.3rem;
|
||||
padding: 0;
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.mobile-controls .up {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.mobile-controls .left {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
.mobile-controls .right {
|
||||
grid-column: 3;
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
.mobile-controls .down {
|
||||
grid-column: 2;
|
||||
grid-row: 3;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
h1 {
|
||||
font-size: 2.2rem;
|
||||
}
|
||||
|
||||
.game-ui {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.controls {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
max-width: 220px;
|
||||
padding: 8px 15px;
|
||||
}
|
||||
|
||||
.mobile-controls {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.instructions {
|
||||
margin-top: 15px;
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
h1 {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.ui-item {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.game-over h2 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.game-over p {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.instructions h3 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.instructions p {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 400px) {
|
||||
body {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
.game-container {
|
||||
margin: 10px auto;
|
||||
}
|
||||
|
||||
.controls {
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
button {
|
||||
font-size: 0.9rem;
|
||||
padding: 7px 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>贪吃蛇大作战</h1>
|
||||
<p class="subtitle">控制小蛇吃食物,变得越来越长!</p>
|
||||
|
||||
<div class="game-ui">
|
||||
<div class="ui-item">分数: <span id="score">0</span></div>
|
||||
<div class="ui-item">长度: <span id="length">1</span></div>
|
||||
<div class="ui-item">速度: <span id="speed">1</span></div>
|
||||
</div>
|
||||
|
||||
<div class="game-container">
|
||||
<canvas id="gameCanvas" width="600" height="400"></canvas>
|
||||
|
||||
<div class="game-over" id="gameOver">
|
||||
<h2>游戏结束</h2>
|
||||
<p>你的最终分数: <span id="finalScore">0</span></p>
|
||||
<p>蛇的长度: <span id="finalLength">0</span></p>
|
||||
<p id="gameMessage">再试一次,创造新纪录!</p>
|
||||
<button id="restartBtn">重新开始</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mobile-controls">
|
||||
<button class="up">↑</button>
|
||||
<button class="left">←</button>
|
||||
<button class="right">→</button>
|
||||
<button class="down">↓</button>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<button id="startBtn">开始游戏</button>
|
||||
<button id="pauseBtn">暂停游戏</button>
|
||||
<button id="speedUpBtn">加速</button>
|
||||
<button id="speedDownBtn">减速</button>
|
||||
</div>
|
||||
|
||||
<div class="instructions">
|
||||
<h3>游戏说明</h3>
|
||||
<p>使用方向键或屏幕按钮控制蛇的移动方向</p>
|
||||
<p>吃到食物可以增加蛇的长度和得分</p>
|
||||
<p>撞到墙壁或自己的身体会导致游戏结束</p>
|
||||
<p>每得100分速度会增加一级</p>
|
||||
<p>看看你能让蛇变得多长!</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const canvas = document.getElementById('gameCanvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const startBtn = document.getElementById('startBtn');
|
||||
const pauseBtn = document.getElementById('pauseBtn');
|
||||
const restartBtn = document.getElementById('restartBtn');
|
||||
const speedUpBtn = document.getElementById('speedUpBtn');
|
||||
const speedDownBtn = document.getElementById('speedDownBtn');
|
||||
const scoreEl = document.getElementById('score');
|
||||
const lengthEl = document.getElementById('length');
|
||||
const speedEl = document.getElementById('speed');
|
||||
const gameOverEl = document.getElementById('gameOver');
|
||||
const finalScoreEl = document.getElementById('finalScore');
|
||||
const finalLengthEl = document.getElementById('finalLength');
|
||||
const gameMessageEl = document.getElementById('gameMessage');
|
||||
|
||||
// 移动控制按钮
|
||||
const upBtn = document.querySelector('.mobile-controls .up');
|
||||
const leftBtn = document.querySelector('.mobile-controls .left');
|
||||
const rightBtn = document.querySelector('.mobile-controls .right');
|
||||
const downBtn = document.querySelector('.mobile-controls .down');
|
||||
|
||||
// 游戏配置
|
||||
const gridSize = 20;
|
||||
const gridWidth = canvas.width / gridSize;
|
||||
const gridHeight = canvas.height / gridSize;
|
||||
|
||||
// 游戏状态
|
||||
let game = {
|
||||
running: false,
|
||||
paused: false,
|
||||
score: 0,
|
||||
speed: 1,
|
||||
baseSpeed: 8,
|
||||
gameLoop: null
|
||||
};
|
||||
|
||||
// 蛇的状态
|
||||
let snake = {
|
||||
body: [],
|
||||
direction: 'right',
|
||||
nextDirection: 'right',
|
||||
color: '#00ff00',
|
||||
headColor: '#ffff00'
|
||||
};
|
||||
|
||||
// 食物状态
|
||||
let food = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
color: '#ff0000'
|
||||
};
|
||||
|
||||
// 初始化游戏
|
||||
function initGame() {
|
||||
// 初始化蛇
|
||||
snake.body = [
|
||||
{x: Math.floor(gridWidth / 2), y: Math.floor(gridHeight / 2)}
|
||||
];
|
||||
snake.direction = 'right';
|
||||
snake.nextDirection = 'right';
|
||||
|
||||
// 生成第一个食物
|
||||
generateFood();
|
||||
|
||||
// 重置游戏状态
|
||||
game.running = true;
|
||||
game.paused = false;
|
||||
game.score = 0;
|
||||
game.speed = 1;
|
||||
|
||||
updateUI();
|
||||
gameOverEl.style.display = 'none';
|
||||
pauseBtn.textContent = '暂停游戏';
|
||||
|
||||
// 开始游戏循环
|
||||
if (game.gameLoop) clearInterval(game.gameLoop);
|
||||
game.gameLoop = setInterval(updateGame, 1000 / (game.baseSpeed * game.speed));
|
||||
|
||||
// 初始绘制
|
||||
drawGame();
|
||||
}
|
||||
|
||||
// 生成食物
|
||||
function generateFood() {
|
||||
let newFoodPosition;
|
||||
let foodOnSnake;
|
||||
|
||||
do {
|
||||
foodOnSnake = false;
|
||||
newFoodPosition = {
|
||||
x: Math.floor(Math.random() * gridWidth),
|
||||
y: Math.floor(Math.random() * gridHeight)
|
||||
};
|
||||
|
||||
// 检查食物是否生成在蛇身上
|
||||
for (let segment of snake.body) {
|
||||
if (segment.x === newFoodPosition.x && segment.y === newFoodPosition.y) {
|
||||
foodOnSnake = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} while (foodOnSnake);
|
||||
|
||||
food.x = newFoodPosition.x;
|
||||
food.y = newFoodPosition.y;
|
||||
}
|
||||
|
||||
// 更新游戏状态
|
||||
function updateGame() {
|
||||
if (!game.running || game.paused) return;
|
||||
|
||||
// 更新蛇的方向
|
||||
snake.direction = snake.nextDirection;
|
||||
|
||||
// 计算新的头部位置
|
||||
const head = {...snake.body[0]};
|
||||
|
||||
switch (snake.direction) {
|
||||
case 'up':
|
||||
head.y -= 1;
|
||||
break;
|
||||
case 'down':
|
||||
head.y += 1;
|
||||
break;
|
||||
case 'left':
|
||||
head.x -= 1;
|
||||
break;
|
||||
case 'right':
|
||||
head.x += 1;
|
||||
break;
|
||||
}
|
||||
|
||||
// 检查游戏结束条件
|
||||
if (checkCollision(head)) {
|
||||
endGame();
|
||||
return;
|
||||
}
|
||||
|
||||
// 添加新的头部
|
||||
snake.body.unshift(head);
|
||||
|
||||
// 检查是否吃到食物
|
||||
if (head.x === food.x && head.y === food.y) {
|
||||
// 吃到食物,增加分数
|
||||
game.score += 10;
|
||||
|
||||
// 每100分增加速度
|
||||
if (game.score % 100 === 0) {
|
||||
game.speed = Math.min(game.speed + 0.5, 5);
|
||||
clearInterval(game.gameLoop);
|
||||
game.gameLoop = setInterval(updateGame, 1000 / (game.baseSpeed * game.speed));
|
||||
}
|
||||
|
||||
// 生成新食物
|
||||
generateFood();
|
||||
} else {
|
||||
// 没吃到食物,移除尾部
|
||||
snake.body.pop();
|
||||
}
|
||||
|
||||
// 绘制游戏
|
||||
drawGame();
|
||||
updateUI();
|
||||
}
|
||||
|
||||
// 检查碰撞
|
||||
function checkCollision(head) {
|
||||
// 检查墙壁碰撞
|
||||
if (head.x < 0 || head.x >= gridWidth || head.y < 0 || head.y >= gridHeight) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查自身碰撞
|
||||
for (let i = 1; i < snake.body.length; i++) {
|
||||
if (head.x === snake.body[i].x && head.y === snake.body[i].y) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// 绘制游戏
|
||||
function drawGame() {
|
||||
// 清空画布
|
||||
ctx.fillStyle = '#000';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// 绘制网格
|
||||
ctx.strokeStyle = '#222';
|
||||
ctx.lineWidth = 0.5;
|
||||
for (let x = 0; x < canvas.width; x += gridSize) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, 0);
|
||||
ctx.lineTo(x, canvas.height);
|
||||
ctx.stroke();
|
||||
}
|
||||
for (let y = 0; y < canvas.height; y += gridSize) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y);
|
||||
ctx.lineTo(canvas.width, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// 绘制食物
|
||||
ctx.fillStyle = food.color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(
|
||||
food.x * gridSize + gridSize / 2,
|
||||
food.y * gridSize + gridSize / 2,
|
||||
gridSize / 2 - 1,
|
||||
0,
|
||||
Math.PI * 2
|
||||
);
|
||||
ctx.fill();
|
||||
|
||||
// 绘制蛇
|
||||
snake.body.forEach((segment, index) => {
|
||||
if (index === 0) {
|
||||
// 蛇头
|
||||
ctx.fillStyle = snake.headColor;
|
||||
} else {
|
||||
// 蛇身
|
||||
ctx.fillStyle = snake.color;
|
||||
}
|
||||
|
||||
ctx.fillRect(
|
||||
segment.x * gridSize + 1,
|
||||
segment.y * gridSize + 1,
|
||||
gridSize - 2,
|
||||
gridSize - 2
|
||||
);
|
||||
|
||||
// 绘制蛇身圆角
|
||||
ctx.fillStyle = index === 0 ? snake.headColor : snake.color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(
|
||||
segment.x * gridSize + gridSize / 2,
|
||||
segment.y * gridSize + gridSize / 2,
|
||||
gridSize / 2 - 1,
|
||||
0,
|
||||
Math.PI * 2
|
||||
);
|
||||
ctx.fill();
|
||||
});
|
||||
}
|
||||
|
||||
// 更新UI
|
||||
function updateUI() {
|
||||
scoreEl.textContent = game.score;
|
||||
lengthEl.textContent = snake.body.length;
|
||||
speedEl.textContent = game.speed.toFixed(1);
|
||||
}
|
||||
|
||||
// 游戏结束
|
||||
function endGame() {
|
||||
game.running = false;
|
||||
clearInterval(game.gameLoop);
|
||||
|
||||
finalScoreEl.textContent = game.score;
|
||||
finalLengthEl.textContent = snake.body.length;
|
||||
|
||||
// 根据分数给出不同消息
|
||||
if (game.score >= 200) {
|
||||
gameMessageEl.textContent = "太厉害了!你是贪吃蛇大师!";
|
||||
} else if (game.score >= 100) {
|
||||
gameMessageEl.textContent = "很棒!继续努力!";
|
||||
} else {
|
||||
gameMessageEl.textContent = "再试一次,创造新纪录!";
|
||||
}
|
||||
|
||||
gameOverEl.style.display = 'block';
|
||||
}
|
||||
|
||||
// 改变方向
|
||||
function changeDirection(newDirection) {
|
||||
// 防止直接反向移动
|
||||
if (
|
||||
(newDirection === 'up' && snake.direction !== 'down') ||
|
||||
(newDirection === 'down' && snake.direction !== 'up') ||
|
||||
(newDirection === 'left' && snake.direction !== 'right') ||
|
||||
(newDirection === 'right' && snake.direction !== 'left')
|
||||
) {
|
||||
snake.nextDirection = newDirection;
|
||||
}
|
||||
}
|
||||
|
||||
// 改变速度
|
||||
function changeSpeed(change) {
|
||||
if (!game.running) return;
|
||||
|
||||
game.speed = Math.max(0.5, Math.min(game.speed + change, 5));
|
||||
speedEl.textContent = game.speed.toFixed(1);
|
||||
|
||||
clearInterval(game.gameLoop);
|
||||
game.gameLoop = setInterval(updateGame, 1000 / (game.baseSpeed * game.speed));
|
||||
}
|
||||
|
||||
// 事件监听
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (!game.running) return;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
changeDirection('up');
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
changeDirection('down');
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault();
|
||||
changeDirection('left');
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
e.preventDefault();
|
||||
changeDirection('right');
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// 移动控制按钮事件
|
||||
upBtn.addEventListener('click', () => changeDirection('up'));
|
||||
downBtn.addEventListener('click', () => changeDirection('down'));
|
||||
leftBtn.addEventListener('click', () => changeDirection('left'));
|
||||
rightBtn.addEventListener('click', () => changeDirection('right'));
|
||||
|
||||
// 控制按钮事件
|
||||
startBtn.addEventListener('click', () => {
|
||||
if (!game.running) {
|
||||
initGame();
|
||||
startBtn.disabled = true;
|
||||
setTimeout(() => {
|
||||
startBtn.disabled = false;
|
||||
}, 1000);
|
||||
}
|
||||
});
|
||||
|
||||
pauseBtn.addEventListener('click', () => {
|
||||
if (game.running) {
|
||||
game.paused = !game.paused;
|
||||
pauseBtn.textContent = game.paused ? '继续游戏' : '暂停游戏';
|
||||
}
|
||||
});
|
||||
|
||||
restartBtn.addEventListener('click', () => {
|
||||
initGame();
|
||||
});
|
||||
|
||||
speedUpBtn.addEventListener('click', () => changeSpeed(0.5));
|
||||
speedDownBtn.addEventListener('click', () => changeSpeed(-0.5));
|
||||
|
||||
// 初始绘制
|
||||
drawGame();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,846 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>2048数字拼图</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
background: linear-gradient(135deg, #1a2a6c, #b21f1f, #fdbb2d);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
color: white;
|
||||
overflow-x: hidden;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 500px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 10px;
|
||||
text-shadow: 0 0 10px #ff9900, 0 0 20px #ff9900;
|
||||
letter-spacing: 2px;
|
||||
background: linear-gradient(to right, #ff9900, #ffff00);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 15px;
|
||||
color: #ffcc80;
|
||||
}
|
||||
|
||||
.game-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin: 15px 0;
|
||||
padding: 12px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border-radius: 10px;
|
||||
border: 1px solid #ff9900;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
font-size: 1.1rem;
|
||||
font-weight: bold;
|
||||
text-shadow: 0 0 5px #ff9900;
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
.game-container {
|
||||
position: relative;
|
||||
margin: 15px auto;
|
||||
border: 3px solid #ff9900;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 0 20px #ff9900, inset 0 0 20px rgba(255, 153, 0, 0.1);
|
||||
overflow: hidden;
|
||||
max-width: 450px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.grid-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
grid-template-rows: repeat(4, 1fr);
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
aspect-ratio: 1/1;
|
||||
background: rgba(187, 173, 160, 0.3);
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.grid-cell {
|
||||
background: rgba(238, 228, 218, 0.35);
|
||||
border-radius: 3px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.8rem;
|
||||
font-weight: bold;
|
||||
color: #776e65;
|
||||
}
|
||||
|
||||
.tile {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.8rem;
|
||||
font-weight: bold;
|
||||
border-radius: 3px;
|
||||
transition: all 0.15s ease;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.tile-2 { background: #eee4da; color: #776e65; }
|
||||
.tile-4 { background: #ede0c8; color: #776e65; }
|
||||
.tile-8 { background: #f2b179; color: white; }
|
||||
.tile-16 { background: #f59563; color: white; }
|
||||
.tile-32 { background: #f67c5f; color: white; }
|
||||
.tile-64 { background: #f65e3b; color: white; }
|
||||
.tile-128 { background: #edcf72; color: white; font-size: 1.5rem; }
|
||||
.tile-256 { background: #edcc61; color: white; font-size: 1.5rem; }
|
||||
.tile-512 { background: #edc850; color: white; font-size: 1.5rem; }
|
||||
.tile-1024 { background: #edc53f; color: white; font-size: 1.2rem; }
|
||||
.tile-2048 { background: #edc22e; color: white; font-size: 1.2rem; }
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
margin: 15px 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
button {
|
||||
background: linear-gradient(135deg, #ff9900, #ffff00);
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
font-size: 1rem;
|
||||
border-radius: 50px;
|
||||
cursor: pointer;
|
||||
color: #000;
|
||||
font-weight: bold;
|
||||
box-shadow: 0 0 15px rgba(255, 153, 0, 0.7);
|
||||
transition: all 0.3s ease;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 0 20px rgba(255, 153, 0, 0.9);
|
||||
}
|
||||
|
||||
button:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
background: #555;
|
||||
color: #888;
|
||||
box-shadow: none;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.instructions {
|
||||
margin-top: 20px;
|
||||
padding: 15px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border-radius: 10px;
|
||||
border: 1px solid #ff9900;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.instructions h3 {
|
||||
color: #ff9900;
|
||||
margin-bottom: 10px;
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.instructions p {
|
||||
margin: 8px 0;
|
||||
color: #ffcc80;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.game-over {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
padding: 25px;
|
||||
border-radius: 15px;
|
||||
border: 2px solid #ff0000;
|
||||
text-align: center;
|
||||
box-shadow: 0 0 30px #ff0000;
|
||||
z-index: 20;
|
||||
display: none;
|
||||
width: 85%;
|
||||
max-width: 350px;
|
||||
}
|
||||
|
||||
.game-over h2 {
|
||||
font-size: 1.8rem;
|
||||
color: #ff0000;
|
||||
margin-bottom: 12px;
|
||||
text-shadow: 0 0 10px #ff0000;
|
||||
}
|
||||
|
||||
.game-over p {
|
||||
font-size: 1.1rem;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.win-message {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
padding: 25px;
|
||||
border-radius: 15px;
|
||||
border: 2px solid #00ff00;
|
||||
text-align: center;
|
||||
box-shadow: 0 0 30px #00ff00;
|
||||
z-index: 20;
|
||||
display: none;
|
||||
width: 85%;
|
||||
max-width: 350px;
|
||||
}
|
||||
|
||||
.win-message h2 {
|
||||
font-size: 1.8rem;
|
||||
color: #00ff00;
|
||||
margin-bottom: 12px;
|
||||
text-shadow: 0 0 10px #00ff00;
|
||||
}
|
||||
|
||||
.win-message p {
|
||||
font-size: 1.1rem;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.mobile-controls {
|
||||
display: none;
|
||||
margin-top: 15px;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-rows: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
max-width: 180px;
|
||||
}
|
||||
|
||||
.mobile-controls button {
|
||||
width: 55px;
|
||||
height: 55px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.3rem;
|
||||
padding: 0;
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.mobile-controls .up {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.mobile-controls .left {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
.mobile-controls .right {
|
||||
grid-column: 3;
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
.mobile-controls .down {
|
||||
grid-column: 2;
|
||||
grid-row: 3;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
h1 {
|
||||
font-size: 2.2rem;
|
||||
}
|
||||
|
||||
.game-info {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.controls {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
max-width: 220px;
|
||||
padding: 8px 15px;
|
||||
}
|
||||
|
||||
.mobile-controls {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.instructions {
|
||||
margin-top: 15px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.tile {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.tile-128, .tile-256, .tile-512 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.tile-1024, .tile-2048 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 400px) {
|
||||
body {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.game-container {
|
||||
margin: 10px auto;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.controls {
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
button {
|
||||
font-size: 0.9rem;
|
||||
padding: 7px 12px;
|
||||
}
|
||||
|
||||
.grid-cell, .tile {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>2048数字拼图</h1>
|
||||
<p class="subtitle">合并相同数字,挑战2048!</p>
|
||||
|
||||
<div class="game-info">
|
||||
<div class="info-item">分数: <span id="score">0</span></div>
|
||||
<div class="info-item">步数: <span id="moves">0</span></div>
|
||||
<div class="info-item">最高分: <span id="best">0</span></div>
|
||||
</div>
|
||||
|
||||
<div class="game-container">
|
||||
<div class="grid-container" id="grid">
|
||||
<!-- 网格将由JavaScript生成 -->
|
||||
</div>
|
||||
|
||||
<div class="game-over" id="gameOver">
|
||||
<h2>游戏结束</h2>
|
||||
<p>你的最终分数: <span id="finalScore">0</span></p>
|
||||
<p id="gameMessage">再试一次,创造新纪录!</p>
|
||||
<button id="restartBtn">重新开始</button>
|
||||
</div>
|
||||
|
||||
<div class="win-message" id="winMessage">
|
||||
<h2>恭喜你赢了!</h2>
|
||||
<p>你成功合成了2048!</p>
|
||||
<p>分数: <span id="winScore">0</span></p>
|
||||
<button id="continueBtn">继续游戏</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mobile-controls">
|
||||
<button class="up">↑</button>
|
||||
<button class="left">←</button>
|
||||
<button class="right">→</button>
|
||||
<button class="down">↓</button>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<button id="startBtn">开始游戏</button>
|
||||
<button id="undoBtn">撤销</button>
|
||||
<button id="hintBtn">提示</button>
|
||||
</div>
|
||||
|
||||
<div class="instructions">
|
||||
<h3>游戏说明</h3>
|
||||
<p>使用方向键或屏幕按钮移动方块</p>
|
||||
<p>相同数字的方块相撞时会合并成为它们的和</p>
|
||||
<p>每次移动后会在空白处随机生成一个2或4的方块</p>
|
||||
<p>当棋盘被填满且无法移动时游戏结束</p>
|
||||
<p>成功合成2048即可获胜,但可以继续挑战更高分!</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const gridContainer = document.getElementById('grid');
|
||||
const startBtn = document.getElementById('startBtn');
|
||||
const undoBtn = document.getElementById('undoBtn');
|
||||
const hintBtn = document.getElementById('hintBtn');
|
||||
const restartBtn = document.getElementById('restartBtn');
|
||||
const continueBtn = document.getElementById('continueBtn');
|
||||
const scoreEl = document.getElementById('score');
|
||||
const movesEl = document.getElementById('moves');
|
||||
const bestEl = document.getElementById('best');
|
||||
const gameOverEl = document.getElementById('gameOver');
|
||||
const winMessageEl = document.getElementById('winMessage');
|
||||
const finalScoreEl = document.getElementById('finalScore');
|
||||
const winScoreEl = document.getElementById('winScore');
|
||||
const gameMessageEl = document.getElementById('gameMessage');
|
||||
|
||||
// 移动控制按钮
|
||||
const upBtn = document.querySelector('.mobile-controls .up');
|
||||
const leftBtn = document.querySelector('.mobile-controls .left');
|
||||
const rightBtn = document.querySelector('.mobile-controls .right');
|
||||
const downBtn = document.querySelector('.mobile-controls .down');
|
||||
|
||||
// 游戏状态
|
||||
let game = {
|
||||
grid: [],
|
||||
score: 0,
|
||||
moves: 0,
|
||||
best: localStorage.getItem('2048-best') || 0,
|
||||
gameOver: false,
|
||||
won: false,
|
||||
history: []
|
||||
};
|
||||
|
||||
// 初始化游戏
|
||||
function initGame() {
|
||||
// 创建4x4网格
|
||||
game.grid = Array(4).fill().map(() => Array(4).fill(0));
|
||||
game.score = 0;
|
||||
game.moves = 0;
|
||||
game.gameOver = false;
|
||||
game.won = false;
|
||||
game.history = [];
|
||||
|
||||
// 生成网格HTML
|
||||
gridContainer.innerHTML = '';
|
||||
for (let i = 0; i < 16; i++) {
|
||||
const cell = document.createElement('div');
|
||||
cell.className = 'grid-cell';
|
||||
gridContainer.appendChild(cell);
|
||||
}
|
||||
|
||||
// 添加两个初始方块
|
||||
addRandomTile();
|
||||
addRandomTile();
|
||||
|
||||
updateUI();
|
||||
gameOverEl.style.display = 'none';
|
||||
winMessageEl.style.display = 'none';
|
||||
|
||||
// 保存初始状态
|
||||
saveState();
|
||||
|
||||
// 渲染方块
|
||||
renderTiles();
|
||||
}
|
||||
|
||||
// 添加随机方块
|
||||
function addRandomTile() {
|
||||
const emptyCells = [];
|
||||
|
||||
// 找到所有空格子
|
||||
for (let i = 0; i < 4; i++) {
|
||||
for (let j = 0; j < 4; j++) {
|
||||
if (game.grid[i][j] === 0) {
|
||||
emptyCells.push({x: i, y: j});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (emptyCells.length > 0) {
|
||||
// 随机选择一个空格子
|
||||
const cell = emptyCells[Math.floor(Math.random() * emptyCells.length)];
|
||||
// 90%概率生成2,10%概率生成4
|
||||
game.grid[cell.x][cell.y] = Math.random() < 0.9 ? 2 : 4;
|
||||
}
|
||||
}
|
||||
|
||||
// 保存游戏状态
|
||||
function saveState() {
|
||||
game.history.push({
|
||||
grid: JSON.parse(JSON.stringify(game.grid)),
|
||||
score: game.score,
|
||||
moves: game.moves
|
||||
});
|
||||
|
||||
// 只保留最近10步的历史
|
||||
if (game.history.length > 10) {
|
||||
game.history.shift();
|
||||
}
|
||||
}
|
||||
|
||||
// 撤销上一步
|
||||
function undoMove() {
|
||||
if (game.history.length > 1) {
|
||||
game.history.pop(); // 移除当前状态
|
||||
const prevState = game.history[game.history.length - 1];
|
||||
game.grid = JSON.parse(JSON.stringify(prevState.grid));
|
||||
game.score = prevState.score;
|
||||
game.moves = prevState.moves;
|
||||
game.gameOver = false;
|
||||
game.won = false;
|
||||
|
||||
updateUI();
|
||||
renderTiles();
|
||||
}
|
||||
}
|
||||
|
||||
// 移动方块
|
||||
function move(direction) {
|
||||
if (game.gameOver) return;
|
||||
|
||||
const prevGrid = JSON.parse(JSON.stringify(game.grid));
|
||||
let moved = false;
|
||||
|
||||
// 根据方向处理移动
|
||||
switch (direction) {
|
||||
case 'up':
|
||||
moved = moveUp();
|
||||
break;
|
||||
case 'down':
|
||||
moved = moveDown();
|
||||
break;
|
||||
case 'left':
|
||||
moved = moveLeft();
|
||||
break;
|
||||
case 'right':
|
||||
moved = moveRight();
|
||||
break;
|
||||
}
|
||||
|
||||
// 如果有移动,更新游戏状态
|
||||
if (moved) {
|
||||
game.moves++;
|
||||
addRandomTile();
|
||||
saveState();
|
||||
updateUI();
|
||||
renderTiles();
|
||||
|
||||
// 检查游戏是否结束
|
||||
if (isGameOver()) {
|
||||
game.gameOver = true;
|
||||
finalScoreEl.textContent = game.score;
|
||||
gameOverEl.style.display = 'block';
|
||||
}
|
||||
|
||||
// 检查是否获胜
|
||||
if (!game.won && hasWon()) {
|
||||
game.won = true;
|
||||
winScoreEl.textContent = game.score;
|
||||
winMessageEl.style.display = 'block';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 向上移动
|
||||
function moveUp() {
|
||||
let moved = false;
|
||||
|
||||
for (let j = 0; j < 4; j++) {
|
||||
for (let i = 1; i < 4; i++) {
|
||||
if (game.grid[i][j] !== 0) {
|
||||
let row = i;
|
||||
while (row > 0 && game.grid[row - 1][j] === 0) {
|
||||
game.grid[row - 1][j] = game.grid[row][j];
|
||||
game.grid[row][j] = 0;
|
||||
row--;
|
||||
moved = true;
|
||||
}
|
||||
|
||||
if (row > 0 && game.grid[row - 1][j] === game.grid[row][j]) {
|
||||
game.grid[row - 1][j] *= 2;
|
||||
game.score += game.grid[row - 1][j];
|
||||
game.grid[row][j] = 0;
|
||||
moved = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return moved;
|
||||
}
|
||||
|
||||
// 向下移动
|
||||
function moveDown() {
|
||||
let moved = false;
|
||||
|
||||
for (let j = 0; j < 4; j++) {
|
||||
for (let i = 2; i >= 0; i--) {
|
||||
if (game.grid[i][j] !== 0) {
|
||||
let row = i;
|
||||
while (row < 3 && game.grid[row + 1][j] === 0) {
|
||||
game.grid[row + 1][j] = game.grid[row][j];
|
||||
game.grid[row][j] = 0;
|
||||
row++;
|
||||
moved = true;
|
||||
}
|
||||
|
||||
if (row < 3 && game.grid[row + 1][j] === game.grid[row][j]) {
|
||||
game.grid[row + 1][j] *= 2;
|
||||
game.score += game.grid[row + 1][j];
|
||||
game.grid[row][j] = 0;
|
||||
moved = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return moved;
|
||||
}
|
||||
|
||||
// 向左移动
|
||||
function moveLeft() {
|
||||
let moved = false;
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
for (let j = 1; j < 4; j++) {
|
||||
if (game.grid[i][j] !== 0) {
|
||||
let col = j;
|
||||
while (col > 0 && game.grid[i][col - 1] === 0) {
|
||||
game.grid[i][col - 1] = game.grid[i][col];
|
||||
game.grid[i][col] = 0;
|
||||
col--;
|
||||
moved = true;
|
||||
}
|
||||
|
||||
if (col > 0 && game.grid[i][col - 1] === game.grid[i][col]) {
|
||||
game.grid[i][col - 1] *= 2;
|
||||
game.score += game.grid[i][col - 1];
|
||||
game.grid[i][col] = 0;
|
||||
moved = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return moved;
|
||||
}
|
||||
|
||||
// 向右移动
|
||||
function moveRight() {
|
||||
let moved = false;
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
for (let j = 2; j >= 0; j--) {
|
||||
if (game.grid[i][j] !== 0) {
|
||||
let col = j;
|
||||
while (col < 3 && game.grid[i][col + 1] === 0) {
|
||||
game.grid[i][col + 1] = game.grid[i][col];
|
||||
game.grid[i][col] = 0;
|
||||
col++;
|
||||
moved = true;
|
||||
}
|
||||
|
||||
if (col < 3 && game.grid[i][col + 1] === game.grid[i][col]) {
|
||||
game.grid[i][col + 1] *= 2;
|
||||
game.score += game.grid[i][col + 1];
|
||||
game.grid[i][col] = 0;
|
||||
moved = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return moved;
|
||||
}
|
||||
|
||||
// 检查游戏是否结束
|
||||
function isGameOver() {
|
||||
// 检查是否有空格子
|
||||
for (let i = 0; i < 4; i++) {
|
||||
for (let j = 0; j < 4; j++) {
|
||||
if (game.grid[i][j] === 0) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否有可以合并的相邻格子
|
||||
for (let i = 0; i < 4; i++) {
|
||||
for (let j = 0; j < 4; j++) {
|
||||
const value = game.grid[i][j];
|
||||
if (
|
||||
(i < 3 && game.grid[i + 1][j] === value) ||
|
||||
(j < 3 && game.grid[i][j + 1] === value)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查是否获胜
|
||||
function hasWon() {
|
||||
for (let i = 0; i < 4; i++) {
|
||||
for (let j = 0; j < 4; j++) {
|
||||
if (game.grid[i][j] === 2048) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 渲染方块
|
||||
function renderTiles() {
|
||||
// 移除所有现有的方块
|
||||
const existingTiles = document.querySelectorAll('.tile');
|
||||
existingTiles.forEach(tile => tile.remove());
|
||||
|
||||
// 获取网格容器的实际尺寸
|
||||
const containerWidth = gridContainer.offsetWidth;
|
||||
const containerHeight = gridContainer.offsetHeight;
|
||||
|
||||
// 计算每个格子的实际尺寸
|
||||
const cellWidth = (containerWidth - 50) / 4; // 减去内边距和间隙
|
||||
const cellHeight = (containerHeight - 50) / 4;
|
||||
|
||||
// 添加新的方块
|
||||
for (let i = 0; i < 4; i++) {
|
||||
for (let j = 0; j < 4; j++) {
|
||||
const value = game.grid[i][j];
|
||||
if (value !== 0) {
|
||||
const tile = document.createElement('div');
|
||||
tile.className = `tile tile-${value}`;
|
||||
tile.textContent = value;
|
||||
|
||||
// 使用像素精确定位
|
||||
const left = 10 + j * (cellWidth + 10);
|
||||
const top = 10 + i * (cellHeight + 10);
|
||||
|
||||
tile.style.left = `${left}px`;
|
||||
tile.style.top = `${top}px`;
|
||||
tile.style.width = `${cellWidth}px`;
|
||||
tile.style.height = `${cellHeight}px`;
|
||||
|
||||
gridContainer.appendChild(tile);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新UI
|
||||
function updateUI() {
|
||||
scoreEl.textContent = game.score;
|
||||
movesEl.textContent = game.moves;
|
||||
|
||||
// 更新最高分
|
||||
if (game.score > game.best) {
|
||||
game.best = game.score;
|
||||
localStorage.setItem('2048-best', game.best);
|
||||
}
|
||||
bestEl.textContent = game.best;
|
||||
}
|
||||
|
||||
// 事件监听
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (game.gameOver && !game.won) return;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
move('up');
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
move('down');
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault();
|
||||
move('left');
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
e.preventDefault();
|
||||
move('right');
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// 移动控制按钮事件
|
||||
upBtn.addEventListener('click', () => move('up'));
|
||||
downBtn.addEventListener('click', () => move('down'));
|
||||
leftBtn.addEventListener('click', () => move('left'));
|
||||
rightBtn.addEventListener('click', () => move('right'));
|
||||
|
||||
// 控制按钮事件
|
||||
startBtn.addEventListener('click', () => {
|
||||
initGame();
|
||||
});
|
||||
|
||||
undoBtn.addEventListener('click', () => {
|
||||
undoMove();
|
||||
});
|
||||
|
||||
hintBtn.addEventListener('click', () => {
|
||||
alert('提示:尽量保持最大数字在角落,按顺序合并小数字!');
|
||||
});
|
||||
|
||||
restartBtn.addEventListener('click', () => {
|
||||
initGame();
|
||||
});
|
||||
|
||||
continueBtn.addEventListener('click', () => {
|
||||
winMessageEl.style.display = 'none';
|
||||
});
|
||||
|
||||
// 窗口大小改变时重新渲染
|
||||
window.addEventListener('resize', () => {
|
||||
if (game.grid && game.grid.length > 0) {
|
||||
renderTiles();
|
||||
}
|
||||
});
|
||||
|
||||
// 初始化游戏
|
||||
initGame();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,739 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||
<title>末日生存 · 二维射击游戏</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
background: linear-gradient(135deg, #0a1f1a 0%, #1a2f1f 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-family: 'Segoe UI', 'Arial', sans-serif;
|
||||
padding: 20px;
|
||||
margin: 0;
|
||||
}
|
||||
.game-container {
|
||||
background: #2d3a2c;
|
||||
border-radius: 28px;
|
||||
box-shadow: 0 20px 30px rgba(0, 0, 0, 0.6), inset 0 0 15px rgba(0,0,0,0.4);
|
||||
padding: 15px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
canvas {
|
||||
display: block;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 0 25px rgba(0, 180, 0, 0.3);
|
||||
background: #1e2b1c;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-width: 800px;
|
||||
aspect-ratio: 800 / 600;
|
||||
cursor: crosshair;
|
||||
}
|
||||
.info-panel {
|
||||
width: 100%;
|
||||
margin-top: 15px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
color: #e0f0d8;
|
||||
font-weight: bold;
|
||||
background: #1e2b1c;
|
||||
border-radius: 40px;
|
||||
padding: 12px 25px;
|
||||
box-shadow: inset 0 2px 8px rgba(0,0,0,0.5);
|
||||
flex-wrap: wrap;
|
||||
gap: 15px;
|
||||
}
|
||||
.health-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.health-fill {
|
||||
width: 180px;
|
||||
height: 14px;
|
||||
background: #3a2e2e;
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
box-shadow: inset 0 2px 5px rgba(0,0,0,0.7);
|
||||
}
|
||||
.fill-level {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: linear-gradient(90deg, #e74c3c, #f1c40f);
|
||||
border-radius: 20px;
|
||||
transition: width 0.15s ease;
|
||||
}
|
||||
.score-board {
|
||||
background: #0f1a0f;
|
||||
padding: 6px 20px;
|
||||
border-radius: 30px;
|
||||
font-size: 1.2rem;
|
||||
letter-spacing: 1px;
|
||||
color: #d4ed5e;
|
||||
box-shadow: 0 0 12px #5a8f4a;
|
||||
}
|
||||
button {
|
||||
background: #d4b34c;
|
||||
border: none;
|
||||
color: #1e2b1c;
|
||||
font-weight: bold;
|
||||
font-size: 1rem;
|
||||
padding: 10px 25px;
|
||||
border-radius: 30px;
|
||||
cursor: pointer;
|
||||
letter-spacing: 1px;
|
||||
background: linear-gradient(180deg, #e9d584 0%, #b38b40 100%);
|
||||
box-shadow: 0 5px 0 #6b4e1d, 0 8px 15px rgba(0,0,0,0.4);
|
||||
transition: all 0.08s ease;
|
||||
border: 1px solid #f3e3a7;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
button:active {
|
||||
transform: translateY(3px);
|
||||
box-shadow: 0 2px 0 #4b3a1a, 0 5px 10px rgba(0,0,0,0.5);
|
||||
}
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
color: #b9d0a0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
@media (max-width: 650px) {
|
||||
.info-panel {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div style="display: flex; flex-direction: column; align-items: center;">
|
||||
<div class="game-container">
|
||||
<canvas id="gameCanvas" width="800" height="600" aria-label="二维射击游戏地图"></canvas>
|
||||
<div class="info-panel">
|
||||
<div class="health-bar">
|
||||
<span style="color:#f1c40f;">❤️ 生命</span>
|
||||
<div class="health-fill">
|
||||
<div id="healthFillLevel" class="fill-level" style="width: 100%;"></div>
|
||||
</div>
|
||||
<span id="healthText" style="min-width: 50px;">100</span>
|
||||
</div>
|
||||
<div class="score-board">
|
||||
🎯 <span id="scoreDisplay">0</span>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<span>🖱️ 鼠标瞄准 | 左键射击</span>
|
||||
<button id="restartButton">↺ 重新开始</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="color:#8aa87d; margin-top: 8px; font-size: 0.9rem;">
|
||||
⌨️ WASD / 方向键 移动人物
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const canvas = document.getElementById('gameCanvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// UI元素
|
||||
const healthFill = document.getElementById('healthFillLevel');
|
||||
const healthText = document.getElementById('healthText');
|
||||
const scoreSpan = document.getElementById('scoreDisplay');
|
||||
|
||||
// 固定尺寸
|
||||
const W = 800, H = 600;
|
||||
|
||||
// ----- 游戏状态 -----
|
||||
let player = {
|
||||
x: W / 2,
|
||||
y: H / 2,
|
||||
radius: 18,
|
||||
health: 100,
|
||||
maxHealth: 100,
|
||||
speed: 4.8,
|
||||
};
|
||||
|
||||
let monsters = [];
|
||||
let bullets = [];
|
||||
let score = 0;
|
||||
|
||||
// 射击冷却 (防止按住左键瞬间连射太夸张)
|
||||
const SHOOT_COOLDOWN_MAX = 18; // 帧数 (约60fps => 0.3秒)
|
||||
let shootCooldown = 0;
|
||||
|
||||
// 怪物生成控制
|
||||
let monsterSpawnTimer = 0;
|
||||
const SPAWN_DELAY = 45; // 大约每0.75秒生成一只 (60fps)
|
||||
const MAX_MONSTERS = 14;
|
||||
|
||||
// 控制标志
|
||||
let keys = {
|
||||
w: false, up: false,
|
||||
s: false, down: false,
|
||||
a: false, left: false,
|
||||
d: false, right: false
|
||||
};
|
||||
|
||||
// 鼠标坐标 (用于瞄准方向)
|
||||
let mouseX = player.x + 30;
|
||||
let mouseY = player.y;
|
||||
let mouseInside = true;
|
||||
|
||||
// 游戏循环控制
|
||||
let gameRunning = true;
|
||||
let animationFrameId = null;
|
||||
|
||||
// ----- 辅助函数:随机范围 -----
|
||||
const rand = (min, max) => Math.random() * (max - min) + min;
|
||||
const randInt = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min;
|
||||
|
||||
// ----- 重置游戏 -----
|
||||
function restartGame() {
|
||||
player = {
|
||||
x: W / 2,
|
||||
y: H / 2,
|
||||
radius: 18,
|
||||
health: 100,
|
||||
maxHealth: 100,
|
||||
speed: 4.8,
|
||||
};
|
||||
monsters = [];
|
||||
bullets = [];
|
||||
score = 0;
|
||||
shootCooldown = 0;
|
||||
monsterSpawnTimer = 0;
|
||||
gameRunning = true;
|
||||
|
||||
// 重置鼠标位置到玩家附近
|
||||
mouseX = player.x + 20;
|
||||
mouseY = player.y;
|
||||
updateUI();
|
||||
}
|
||||
|
||||
// ----- 生成怪物 (在屏幕边缘附近) -----
|
||||
function spawnMonster() {
|
||||
if (monsters.length >= MAX_MONSTERS) return;
|
||||
|
||||
// 随机选择边缘:0=上, 1=下, 2=左, 3=右
|
||||
const edge = randInt(0, 3);
|
||||
let spawnX, spawnY;
|
||||
const padding = 25; // 略微在屏幕外生成,自然走入
|
||||
|
||||
switch (edge) {
|
||||
case 0: // 上
|
||||
spawnX = rand(padding, W - padding);
|
||||
spawnY = -padding;
|
||||
break;
|
||||
case 1: // 下
|
||||
spawnX = rand(padding, W - padding);
|
||||
spawnY = H + padding;
|
||||
break;
|
||||
case 2: // 左
|
||||
spawnX = -padding;
|
||||
spawnY = rand(padding, H - padding);
|
||||
break;
|
||||
case 3: // 右
|
||||
spawnX = W + padding;
|
||||
spawnY = rand(padding, H - padding);
|
||||
break;
|
||||
default:
|
||||
spawnX = -padding;
|
||||
spawnY = rand(padding, H - padding);
|
||||
}
|
||||
|
||||
// 怪物类型:基础速度 + 大小变化
|
||||
const type = Math.random();
|
||||
let radius, speed, health, color;
|
||||
if (type < 0.6) {
|
||||
// 普通僵尸
|
||||
radius = 16 + rand(0, 6);
|
||||
speed = 1.1 + rand(0, 0.9);
|
||||
health = 1;
|
||||
color = '#6b8c5c';
|
||||
} else if (type < 0.88) {
|
||||
// 快速怪物
|
||||
radius = 13 + rand(0, 5);
|
||||
speed = 2.0 + rand(0, 1.3);
|
||||
health = 1;
|
||||
color = '#b5876a';
|
||||
} else {
|
||||
// 大型怪物 (需要两枪)
|
||||
radius = 22 + rand(0, 7);
|
||||
speed = 0.7 + rand(0, 0.7);
|
||||
health = 2;
|
||||
color = '#8b3a3a';
|
||||
}
|
||||
|
||||
monsters.push({
|
||||
x: spawnX,
|
||||
y: spawnY,
|
||||
radius: radius,
|
||||
speed: speed,
|
||||
health: health,
|
||||
maxHealth: health,
|
||||
color: color,
|
||||
});
|
||||
}
|
||||
|
||||
// ----- 玩家移动 (基于键盘) -----
|
||||
function movePlayer() {
|
||||
if (!gameRunning) return;
|
||||
let dx = 0, dy = 0;
|
||||
|
||||
if (keys.w || keys.up) dy -= 1;
|
||||
if (keys.s || keys.down) dy += 1;
|
||||
if (keys.a || keys.left) dx -= 1;
|
||||
if (keys.d || keys.right) dx += 1;
|
||||
|
||||
if (dx !== 0 || dy !== 0) {
|
||||
const len = Math.sqrt(dx * dx + dy * dy);
|
||||
dx = (dx / len) * player.speed;
|
||||
dy = (dy / len) * player.speed;
|
||||
|
||||
let newX = player.x + dx;
|
||||
let newY = player.y + dy;
|
||||
|
||||
// 边界限制 (考虑玩家半径)
|
||||
const minBound = player.radius;
|
||||
const maxBoundX = W - player.radius;
|
||||
const maxBoundY = H - player.radius;
|
||||
|
||||
newX = Math.max(minBound, Math.min(maxBoundX, newX));
|
||||
newY = Math.max(minBound, Math.min(maxBoundY, newY));
|
||||
|
||||
player.x = newX;
|
||||
player.y = newY;
|
||||
}
|
||||
}
|
||||
|
||||
// ----- 怪物移动 (朝向玩家) -----
|
||||
function moveMonsters() {
|
||||
for (let m of monsters) {
|
||||
const dx = player.x - m.x;
|
||||
const dy = player.y - m.y;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
if (dist < 0.5) continue; // 已重叠
|
||||
|
||||
const moveX = (dx / dist) * m.speed;
|
||||
const moveY = (dy / dist) * m.speed;
|
||||
m.x += moveX;
|
||||
m.y += moveY;
|
||||
}
|
||||
}
|
||||
|
||||
// ----- 玩家射击 (生成子弹) -----
|
||||
function shootBullet(targetX, targetY) {
|
||||
if (!gameRunning) return;
|
||||
// 计算方向向量 (从玩家指向鼠标)
|
||||
const dx = targetX - player.x;
|
||||
const dy = targetY - player.y;
|
||||
const len = Math.sqrt(dx * dx + dy * dy);
|
||||
if (len < 0.001) return; // 避免零向量
|
||||
|
||||
const normX = dx / len;
|
||||
const normY = dy / len;
|
||||
|
||||
bullets.push({
|
||||
x: player.x,
|
||||
y: player.y,
|
||||
vx: normX * 7.5, // 子弹速度
|
||||
vy: normY * 7.5,
|
||||
radius: 4.5,
|
||||
active: true,
|
||||
damage: 1,
|
||||
});
|
||||
}
|
||||
|
||||
// ----- 更新子弹位置与碰撞-----
|
||||
function updateBullets() {
|
||||
for (let i = bullets.length - 1; i >= 0; i--) {
|
||||
const b = bullets[i];
|
||||
b.x += b.vx;
|
||||
b.y += b.vy;
|
||||
|
||||
// 边界消除 (超出画布太远)
|
||||
if (b.x < -20 || b.x > W + 20 || b.y < -20 || b.y > H + 20) {
|
||||
bullets.splice(i, 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
let bulletHit = false;
|
||||
|
||||
// 检测子弹与怪物碰撞
|
||||
for (let j = monsters.length - 1; j >= 0; j--) {
|
||||
const m = monsters[j];
|
||||
const d = Math.sqrt((b.x - m.x) ** 2 + (b.y - m.y) ** 2);
|
||||
if (d < m.radius + b.radius) {
|
||||
// 击中怪物
|
||||
m.health -= b.damage;
|
||||
bulletHit = true;
|
||||
|
||||
if (m.health <= 0) {
|
||||
// 击杀怪物,增加分数
|
||||
score += (m.maxHealth === 2 ? 15 : 10);
|
||||
monsters.splice(j, 1);
|
||||
}
|
||||
break; // 一颗子弹只击中一个怪物
|
||||
}
|
||||
}
|
||||
|
||||
if (bulletHit) {
|
||||
bullets.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----- 怪物攻击玩家 (碰撞检测) -----
|
||||
function checkMonsterCollision() {
|
||||
if (!gameRunning) return;
|
||||
for (let i = monsters.length - 1; i >= 0; i--) {
|
||||
const m = monsters[i];
|
||||
const dist = Math.sqrt((m.x - player.x) ** 2 + (m.y - player.y) ** 2);
|
||||
if (dist < player.radius + m.radius) {
|
||||
// 怪物对玩家造成伤害 (每次碰撞扣血,并推开怪物防止连续帧多次伤害)
|
||||
const damageAmount = 8 + (m.maxHealth === 2 ? 4 : 0); // 大型怪物伤害更高
|
||||
player.health -= damageAmount;
|
||||
|
||||
// 推开怪物:沿连线方向移动怪物到刚好不重叠
|
||||
const angle = Math.atan2(m.y - player.y, m.x - player.x);
|
||||
const overlap = player.radius + m.radius - dist;
|
||||
if (overlap > 0) {
|
||||
m.x += Math.cos(angle) * overlap;
|
||||
m.y += Math.sin(angle) * overlap;
|
||||
}
|
||||
|
||||
// 检查玩家死亡
|
||||
if (player.health <= 0) {
|
||||
player.health = 0;
|
||||
gameRunning = false;
|
||||
updateUI();
|
||||
break; // 停止检测其他怪物
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 再次确保玩家生命值不超限
|
||||
if (player.health <= 0) {
|
||||
player.health = 0;
|
||||
gameRunning = false;
|
||||
}
|
||||
updateUI();
|
||||
}
|
||||
|
||||
// ----- 更新UI元素-----
|
||||
function updateUI() {
|
||||
let healthPercent = Math.max(0, player.health) / player.maxHealth * 100;
|
||||
healthFill.style.width = `${healthPercent}%`;
|
||||
healthText.textContent = `${Math.max(0, player.health)}`;
|
||||
scoreSpan.textContent = score;
|
||||
}
|
||||
|
||||
// ----- 游戏主循环更新-----
|
||||
function update() {
|
||||
if (gameRunning) {
|
||||
// 减少射击冷却
|
||||
if (shootCooldown > 0) shootCooldown--;
|
||||
|
||||
// 移动玩家
|
||||
movePlayer();
|
||||
|
||||
// 怪物移动
|
||||
moveMonsters();
|
||||
|
||||
// 子弹更新与碰撞
|
||||
updateBullets();
|
||||
|
||||
// 怪物攻击玩家
|
||||
checkMonsterCollision();
|
||||
|
||||
// 怪物生成逻辑
|
||||
if (gameRunning) {
|
||||
monsterSpawnTimer--;
|
||||
if (monsterSpawnTimer <= 0 && monsters.length < MAX_MONSTERS) {
|
||||
spawnMonster();
|
||||
monsterSpawnTimer = SPAWN_DELAY + randInt(-8, 10);
|
||||
}
|
||||
}
|
||||
|
||||
// 再次检查玩家死亡 (可能由碰撞导致)
|
||||
if (player.health <= 0) {
|
||||
gameRunning = false;
|
||||
player.health = 0;
|
||||
}
|
||||
|
||||
updateUI();
|
||||
}
|
||||
}
|
||||
|
||||
// ----- 绘制图形 (所有视觉元素) -----
|
||||
function draw() {
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
|
||||
// 绘制网格/地面纹理
|
||||
ctx.strokeStyle = '#3a4a2e';
|
||||
ctx.lineWidth = 0.6;
|
||||
for (let i = 0; i < W; i += 40) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(i, 0);
|
||||
ctx.lineTo(i, H);
|
||||
ctx.strokeStyle = '#3a5535';
|
||||
ctx.stroke();
|
||||
}
|
||||
for (let j = 0; j < H; j += 40) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, j);
|
||||
ctx.lineTo(W, j);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// ----- 绘制怪物 -----
|
||||
for (let m of monsters) {
|
||||
// 怪物身体
|
||||
ctx.beginPath();
|
||||
ctx.arc(m.x, m.y, m.radius, 0, Math.PI * 2);
|
||||
ctx.fillStyle = m.color;
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = '#1a1a1a';
|
||||
ctx.lineWidth = 2.2;
|
||||
ctx.stroke();
|
||||
|
||||
// 怪物眼睛 (白色+瞳孔朝向玩家)
|
||||
const eyeOffset = m.radius * 0.5;
|
||||
const angleToPlayer = Math.atan2(player.y - m.y, player.x - m.x);
|
||||
const leftEyeX = m.x + Math.cos(angleToPlayer - 0.5) * eyeOffset;
|
||||
const leftEyeY = m.y + Math.sin(angleToPlayer - 0.5) * eyeOffset;
|
||||
const rightEyeX = m.x + Math.cos(angleToPlayer + 0.5) * eyeOffset;
|
||||
const rightEyeY = m.y + Math.sin(angleToPlayer + 0.5) * eyeOffset;
|
||||
|
||||
ctx.fillStyle = 'white';
|
||||
ctx.beginPath();
|
||||
ctx.arc(leftEyeX, leftEyeY, m.radius * 0.27, 0, 2 * Math.PI);
|
||||
ctx.fill();
|
||||
ctx.beginPath();
|
||||
ctx.arc(rightEyeX, rightEyeY, m.radius * 0.27, 0, 2 * Math.PI);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#111';
|
||||
ctx.beginPath();
|
||||
ctx.arc(leftEyeX + Math.cos(angleToPlayer) * 1.8, leftEyeY + Math.sin(angleToPlayer) * 1.8, m.radius * 0.12, 0, 2 * Math.PI);
|
||||
ctx.fill();
|
||||
ctx.beginPath();
|
||||
ctx.arc(rightEyeX + Math.cos(angleToPlayer) * 1.8, rightEyeY + Math.sin(angleToPlayer) * 1.8, m.radius * 0.12, 0, 2 * Math.PI);
|
||||
ctx.fill();
|
||||
|
||||
// 如果生命值>1 显示护甲标记
|
||||
if (m.maxHealth === 2 && m.health === 2) {
|
||||
ctx.fillStyle = '#f0d050';
|
||||
ctx.beginPath();
|
||||
ctx.arc(m.x, m.y - m.radius - 4, 4, 0, 2 * Math.PI);
|
||||
ctx.fill();
|
||||
} else if (m.maxHealth === 2 && m.health === 1) {
|
||||
ctx.fillStyle = '#b5773a';
|
||||
ctx.beginPath();
|
||||
ctx.arc(m.x, m.y - m.radius - 4, 4, 0, 2 * Math.PI);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
// ----- 绘制子弹 -----
|
||||
for (let b of bullets) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(b.x, b.y, b.radius, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = '#f1e05a';
|
||||
ctx.shadowColor = '#ffbb00';
|
||||
ctx.shadowBlur = 8;
|
||||
ctx.fill();
|
||||
ctx.shadowColor = 'transparent';
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.strokeStyle = '#b38b20';
|
||||
ctx.lineWidth = 1.2;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// ----- 绘制玩家 (英雄) -----
|
||||
ctx.save();
|
||||
ctx.translate(player.x, player.y);
|
||||
// 玩家面向鼠标方向
|
||||
const aimAngle = Math.atan2(mouseY - player.y, mouseX - player.x);
|
||||
ctx.rotate(aimAngle);
|
||||
|
||||
// 身体
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, player.radius, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = '#3b7bb7';
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = '#102840';
|
||||
ctx.lineWidth = 2.5;
|
||||
ctx.stroke();
|
||||
|
||||
// 护甲/装备线条
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, player.radius - 3, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = '#c0daf5';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
|
||||
// 武器/枪口
|
||||
ctx.fillStyle = '#4a4a5a';
|
||||
ctx.fillRect(8, -3.5, 15, 7);
|
||||
ctx.fillStyle = '#2b2b35';
|
||||
ctx.fillRect(19, -2.8, 7, 5.6);
|
||||
|
||||
// 眼睛
|
||||
ctx.fillStyle = 'white';
|
||||
ctx.beginPath();
|
||||
ctx.arc(5, -7, 5, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.beginPath();
|
||||
ctx.arc(5, 7, 5, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#0a0a14';
|
||||
ctx.beginPath();
|
||||
ctx.arc(7, -7, 2.5, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.beginPath();
|
||||
ctx.arc(7, 7, 2.5, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
ctx.restore();
|
||||
|
||||
// 玩家血条 (头顶)
|
||||
if (gameRunning) {
|
||||
const healthPercent = player.health / player.maxHealth;
|
||||
ctx.fillStyle = '#333';
|
||||
ctx.fillRect(player.x - 22, player.y - player.radius - 13, 44, 8);
|
||||
ctx.fillStyle = healthPercent > 0.6 ? '#6f9' : (healthPercent > 0.25 ? '#f4c542' : '#e03a3a');
|
||||
ctx.fillRect(player.x - 22, player.y - player.radius - 13, 44 * healthPercent, 8);
|
||||
}
|
||||
|
||||
// 游戏结束画面
|
||||
if (!gameRunning) {
|
||||
ctx.globalAlpha = 0.8;
|
||||
ctx.fillStyle = '#0f1a0f';
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = '#e0d7b0';
|
||||
ctx.font = 'bold 38px "Segoe UI", Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('💀 你倒下了', W/2, H/2-20);
|
||||
ctx.font = '18px "Segoe UI"';
|
||||
ctx.fillText('点击"重新开始"继续战斗', W/2, H/2+50);
|
||||
}
|
||||
}
|
||||
|
||||
// ----- 动画循环 -----
|
||||
function gameLoop() {
|
||||
update();
|
||||
draw();
|
||||
animationFrameId = requestAnimationFrame(gameLoop);
|
||||
}
|
||||
|
||||
// ----- 事件监听 -----
|
||||
function bindEvents() {
|
||||
// 键盘按下
|
||||
window.addEventListener('keydown', (e) => {
|
||||
const key = e.key.toLowerCase();
|
||||
// 防止页面滚动 / 默认行为
|
||||
if (key === 'w' || key === 'a' || key === 's' || key === 'd' ||
|
||||
key === 'arrowup' || key === 'arrowdown' || key === 'arrowleft' || key === 'arrowright') {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
if (key === 'w') keys.w = true;
|
||||
if (key === 'a') keys.a = true;
|
||||
if (key === 's') keys.s = true;
|
||||
if (key === 'd') keys.d = true;
|
||||
if (key === 'arrowup') keys.up = true;
|
||||
if (key === 'arrowdown') keys.down = true;
|
||||
if (key === 'arrowleft') keys.left = true;
|
||||
if (key === 'arrowright') keys.right = true;
|
||||
});
|
||||
|
||||
window.addEventListener('keyup', (e) => {
|
||||
const key = e.key.toLowerCase();
|
||||
if (key === 'w' || key === 'a' || key === 's' || key === 'd' ||
|
||||
key === 'arrowup' || key === 'arrowdown' || key === 'arrowleft' || key === 'arrowright') {
|
||||
e.preventDefault();
|
||||
}
|
||||
if (key === 'w') keys.w = false;
|
||||
if (key === 'a') keys.a = false;
|
||||
if (key === 's') keys.s = false;
|
||||
if (key === 'd') keys.d = false;
|
||||
if (key === 'arrowup') keys.up = false;
|
||||
if (key === 'arrowdown') keys.down = false;
|
||||
if (key === 'arrowleft') keys.left = false;
|
||||
if (key === 'arrowright') keys.right = false;
|
||||
});
|
||||
|
||||
// 鼠标移动 (追踪瞄准位置)
|
||||
canvas.addEventListener('mousemove', (e) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scaleX = W / rect.width; // canvas 物理像素比例
|
||||
const scaleY = H / rect.height;
|
||||
const relativeX = (e.clientX - rect.left) * scaleX;
|
||||
const relativeY = (e.clientY - rect.top) * scaleY;
|
||||
mouseX = Math.min(W, Math.max(0, relativeX));
|
||||
mouseY = Math.min(H, Math.max(0, relativeY));
|
||||
mouseInside = true;
|
||||
});
|
||||
|
||||
canvas.addEventListener('mouseleave', () => {
|
||||
// 离开画布时瞄准点设为玩家右侧默认方向
|
||||
mouseX = player.x + 30;
|
||||
mouseY = player.y;
|
||||
mouseInside = false;
|
||||
});
|
||||
|
||||
// 射击:鼠标按下 (左键)
|
||||
canvas.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault();
|
||||
if (e.button === 0) { // 左键
|
||||
if (gameRunning && shootCooldown <= 0) {
|
||||
shootBullet(mouseX, mouseY);
|
||||
shootCooldown = SHOOT_COOLDOWN_MAX;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 防止右键菜单弹出
|
||||
canvas.addEventListener('contextmenu', (e) => e.preventDefault());
|
||||
|
||||
// 重新开始按钮
|
||||
document.getElementById('restartButton').addEventListener('click', () => {
|
||||
restartGame();
|
||||
});
|
||||
|
||||
// 窗口失去焦点时重置按键状态 (防止卡方向)
|
||||
window.addEventListener('blur', () => {
|
||||
keys.w = keys.a = keys.s = keys.d = false;
|
||||
keys.up = keys.down = keys.left = keys.right = false;
|
||||
});
|
||||
}
|
||||
|
||||
// 启动一切
|
||||
bindEvents();
|
||||
restartGame(); // 初始状态
|
||||
gameLoop();
|
||||
|
||||
// 清理动画 (非必须,但良好实践)
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (animationFrameId) cancelAnimationFrame(animationFrameId);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,718 +0,0 @@
|
||||
<!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>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
body {
|
||||
background: linear-gradient(135deg, #0f2027, #203a43, #2c5364);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
color: white;
|
||||
overflow-x: hidden;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 860px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2.6rem;
|
||||
margin-bottom: 6px;
|
||||
letter-spacing: 4px;
|
||||
background: linear-gradient(to right, #00e5ff, #7c4dff, #ff4081);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
text-shadow: 0 0 30px rgba(0, 229, 255, .35);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 1rem;
|
||||
margin-bottom: 16px;
|
||||
color: #8fd8ff;
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
gap: 18px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: rgba(255, 255, 255, .06);
|
||||
border: 1px solid rgba(255, 255, 255, .12);
|
||||
border-radius: 14px;
|
||||
padding: 14px 16px;
|
||||
backdrop-filter: blur(6px);
|
||||
min-width: 130px;
|
||||
}
|
||||
|
||||
.panel h3 {
|
||||
font-size: .85rem;
|
||||
letter-spacing: 2px;
|
||||
color: #00e5ff;
|
||||
margin-bottom: 8px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
text-shadow: 0 0 12px rgba(0, 229, 255, .5);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: .72rem;
|
||||
color: #9fb3c8;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.board-wrap {
|
||||
position: relative;
|
||||
border-radius: 14px;
|
||||
padding: 8px;
|
||||
background: rgba(0, 0, 0, .45);
|
||||
border: 1px solid rgba(0, 229, 255, .25);
|
||||
box-shadow: 0 0 40px rgba(0, 229, 255, .15), inset 0 0 30px rgba(0, 0, 0, .6);
|
||||
}
|
||||
|
||||
canvas#board {
|
||||
display: block;
|
||||
border-radius: 8px;
|
||||
background: #05080f;
|
||||
}
|
||||
|
||||
canvas.preview {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
background: rgba(0, 0, 0, .35);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
border-radius: 10px;
|
||||
padding: 10px 22px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #04121a;
|
||||
background: linear-gradient(135deg, #00e5ff, #2979ff);
|
||||
box-shadow: 0 4px 18px rgba(0, 229, 255, .35);
|
||||
transition: transform .12s ease, box-shadow .12s ease, filter .12s;
|
||||
}
|
||||
|
||||
.btn:hover { transform: translateY(-2px); filter: brightness(1.1); }
|
||||
.btn:active { transform: translateY(1px) scale(.97); }
|
||||
|
||||
.btn.ghost {
|
||||
background: rgba(255, 255, 255, .08);
|
||||
color: #cfeaff;
|
||||
border: 1px solid rgba(255, 255, 255, .2);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.controls-hint {
|
||||
margin-top: 16px;
|
||||
font-size: .82rem;
|
||||
color: #9fb3c8;
|
||||
line-height: 1.9;
|
||||
}
|
||||
|
||||
kbd {
|
||||
background: rgba(255, 255, 255, .1);
|
||||
border: 1px solid rgba(255, 255, 255, .25);
|
||||
border-bottom-width: 3px;
|
||||
border-radius: 6px;
|
||||
padding: 2px 8px;
|
||||
font-family: inherit;
|
||||
font-size: .78rem;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* 覆盖层(开始 / 暂停 / 结束) */
|
||||
.overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
background: rgba(3, 8, 15, .85);
|
||||
border-radius: 14px;
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 10;
|
||||
transition: opacity .25s ease;
|
||||
}
|
||||
|
||||
.overlay.hidden {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.overlay h2 {
|
||||
font-size: 1.8rem;
|
||||
letter-spacing: 3px;
|
||||
color: #00e5ff;
|
||||
text-shadow: 0 0 18px rgba(0, 229, 255, .6);
|
||||
}
|
||||
|
||||
.overlay p { color: #cfeaff; font-size: .95rem; }
|
||||
|
||||
/* 手机触屏按键 */
|
||||
.touch-controls {
|
||||
display: none;
|
||||
margin-top: 16px;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 10px;
|
||||
max-width: 420px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.touch-controls button {
|
||||
border: 1px solid rgba(0, 229, 255, .35);
|
||||
background: rgba(0, 229, 255, .1);
|
||||
color: #aef;
|
||||
font-size: 1.4rem;
|
||||
border-radius: 12px;
|
||||
padding: 14px 0;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.touch-controls button:active {
|
||||
background: rgba(0, 229, 255, .35);
|
||||
transform: scale(.95);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
h1 { font-size: 1.9rem; }
|
||||
.touch-controls { display: grid; }
|
||||
.controls-hint { display: none; }
|
||||
.side { flex-direction: row; flex-wrap: wrap; justify-content: center; }
|
||||
.panel { min-width: 105px; padding: 10px 12px; }
|
||||
.stat-value { font-size: 1.25rem; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>俄罗斯方块</h1>
|
||||
<p class="subtitle">经典消除 · 挑战极限手速</p>
|
||||
|
||||
<div class="layout">
|
||||
<!-- 左侧面板 -->
|
||||
<div class="side">
|
||||
<div class="panel">
|
||||
<h3>暂存 Hold</h3>
|
||||
<canvas class="preview" id="holdCanvas" width="96" height="72"></canvas>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="stat-value" id="score">0</div>
|
||||
<div class="stat-label">分数 SCORE</div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="stat-value" id="highScore">0</div>
|
||||
<div class="stat-label">最高分 BEST</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 游戏主区域 -->
|
||||
<div class="board-wrap">
|
||||
<canvas id="board" width="300" height="600"></canvas>
|
||||
|
||||
<div class="overlay" id="startOverlay">
|
||||
<h2>俄罗斯方块</h2>
|
||||
<p>消除整行得分,连续消除分数更高!</p>
|
||||
<button class="btn" id="startBtn">开始游戏</button>
|
||||
</div>
|
||||
|
||||
<div class="overlay hidden" id="pauseOverlay">
|
||||
<h2>已暂停</h2>
|
||||
<p>按 P 或点击按钮继续</p>
|
||||
<button class="btn" id="resumeBtn">继续游戏</button>
|
||||
</div>
|
||||
|
||||
<div class="overlay hidden" id="overOverlay">
|
||||
<h2>游戏结束</h2>
|
||||
<p id="finalScore">本局得分:0</p>
|
||||
<p id="newRecord" style="color:#ffd54f;display:none;">🏆 新纪录!</p>
|
||||
<button class="btn" id="restartBtn">再来一局</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧面板 -->
|
||||
<div class="side">
|
||||
<div class="panel">
|
||||
<h3>下一个 Next</h3>
|
||||
<canvas class="preview" id="nextCanvas" width="96" height="216"></canvas>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="stat-value" id="lines">0</div>
|
||||
<div class="stat-label">消除行数 LINES</div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="stat-value" id="level">1</div>
|
||||
<div class="stat-label">等级 LEVEL</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:18px;">
|
||||
<button class="btn ghost" id="pauseBtn">暂停 (P)</button>
|
||||
<button class="btn ghost" id="resetBtn">重新开始</button>
|
||||
</div>
|
||||
|
||||
<!-- 触屏按键(移动端显示) -->
|
||||
<div class="touch-controls">
|
||||
<button data-act="left">◀</button>
|
||||
<button data-act="rotate">⟳</button>
|
||||
<button data-act="right">▶</button>
|
||||
<button data-act="drop">⤓</button>
|
||||
<button data-act="hold">暂存</button>
|
||||
<button data-act="down">▼</button>
|
||||
<button data-act="pause">⏸</button>
|
||||
<button data-act="reset">↺</button>
|
||||
</div>
|
||||
|
||||
<p class="controls-hint">
|
||||
<kbd>←</kbd><kbd>→</kbd> 移动 <kbd>↓</kbd> 软降 <kbd>↑</kbd>/<kbd>X</kbd> 旋转
|
||||
<kbd>空格</kbd> 硬降 <kbd>C</kbd> 暂存 <kbd>P</kbd> 暂停
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
// ---------- 常量 ----------
|
||||
const COLS = 10, ROWS = 20, BLOCK = 30;
|
||||
const boardCanvas = document.getElementById('board');
|
||||
const bctx = boardCanvas.getContext('2d');
|
||||
const nextCanvas = document.getElementById('nextCanvas');
|
||||
const nctx = nextCanvas.getContext('2d');
|
||||
const holdCanvas = document.getElementById('holdCanvas');
|
||||
const hctx = holdCanvas.getContext('2d');
|
||||
|
||||
// 7 种方块:I J L O S T Z(形状矩阵 + 颜色)
|
||||
const SHAPES = [
|
||||
[[0,0,0,0],[1,1,1,1],[0,0,0,0],[0,0,0,0]], // I
|
||||
[[1,0,0],[1,1,1],[0,0,0]], // J
|
||||
[[0,0,1],[1,1,1],[0,0,0]], // L
|
||||
[[1,1],[1,1]], // O
|
||||
[[0,1,1],[1,1,0],[0,0,0]], // S
|
||||
[[0,1,0],[1,1,1],[0,0,0]], // T
|
||||
[[1,1,0],[0,1,1],[0,0,0]] // Z
|
||||
];
|
||||
const COLORS = ['#00e5ff', '#2979ff', '#ff9100', '#ffd600', '#00e676', '#b388ff', '#ff4081'];
|
||||
|
||||
const LINE_SCORES = [0, 100, 300, 500, 800]; // 0~4 行基础分
|
||||
|
||||
// ---------- 状态 ----------
|
||||
let grid, piece, nextQueue, holdType, canHold;
|
||||
let score, lines, level, highScore;
|
||||
let dropCounter, dropInterval, lastTime;
|
||||
let running = false, paused = false, gameOver = false;
|
||||
let rafId = null;
|
||||
|
||||
highScore = parseInt(localStorage.getItem('tetris_best') || '0', 10);
|
||||
document.getElementById('highScore').textContent = highScore;
|
||||
|
||||
// ---------- 工具 ----------
|
||||
function rotate(matrix) {
|
||||
const n = matrix.length;
|
||||
const res = Array.from({length: n}, () => Array(n).fill(0));
|
||||
for (let y = 0; y < n; y++)
|
||||
for (let x = 0; x < n; x++)
|
||||
res[x][n - 1 - y] = matrix[y][x];
|
||||
return res;
|
||||
}
|
||||
|
||||
// 7-bag 随机发牌,保证每种方块均匀出现
|
||||
let bag = [];
|
||||
function nextType() {
|
||||
if (bag.length === 0) {
|
||||
bag = [0,1,2,3,4,5,6];
|
||||
for (let i = bag.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[bag[i], bag[j]] = [bag[j], bag[i]];
|
||||
}
|
||||
}
|
||||
return bag.pop();
|
||||
}
|
||||
|
||||
function spawn(type) {
|
||||
const shape = SHAPES[type].map(row => row.slice());
|
||||
return {
|
||||
type,
|
||||
shape,
|
||||
color: COLORS[type],
|
||||
x: Math.floor((COLS - shape[0].length) / 2),
|
||||
y: 0
|
||||
};
|
||||
}
|
||||
|
||||
function collides(shape, ox, oy) {
|
||||
for (let y = 0; y < shape.length; y++) {
|
||||
for (let x = 0; x < shape[y].length; x++) {
|
||||
if (!shape[y][x]) continue;
|
||||
const nx = ox + x, ny = oy + y;
|
||||
if (nx < 0 || nx >= COLS || ny >= ROWS) return true;
|
||||
if (ny >= 0 && grid[ny][nx] !== 0) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function merge() {
|
||||
piece.shape.forEach((row, y) => row.forEach((v, x) => {
|
||||
if (v && piece.y + y >= 0) grid[piece.y + y][piece.x + x] = piece.type + 1;
|
||||
}));
|
||||
}
|
||||
|
||||
function clearLines() {
|
||||
let cleared = 0;
|
||||
outer: for (let y = ROWS - 1; y >= 0; y--) {
|
||||
for (let x = 0; x < COLS; x++) if (!grid[y][x]) continue outer;
|
||||
grid.splice(y, 1);
|
||||
grid.unshift(Array(COLS).fill(0));
|
||||
cleared++;
|
||||
y++; // 同一行再检查一次
|
||||
}
|
||||
if (cleared > 0) {
|
||||
score += LINE_SCORES[cleared] * level;
|
||||
lines += cleared;
|
||||
const newLevel = Math.floor(lines / 10) + 1;
|
||||
if (newLevel !== level) {
|
||||
level = newLevel;
|
||||
dropInterval = Math.max(60, 1000 * Math.pow(0.8, level - 1));
|
||||
}
|
||||
updateStats();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 操作 ----------
|
||||
function move(dir) {
|
||||
if (!canPlay()) return;
|
||||
if (!collides(piece.shape, piece.x + dir, piece.y)) piece.x += dir;
|
||||
}
|
||||
|
||||
function softDrop() {
|
||||
if (!canPlay()) return;
|
||||
if (!collides(piece.shape, piece.x, piece.y + 1)) {
|
||||
piece.y++;
|
||||
score += 1;
|
||||
updateStats();
|
||||
} else {
|
||||
lockPiece();
|
||||
}
|
||||
dropCounter = 0;
|
||||
}
|
||||
|
||||
function hardDrop() {
|
||||
if (!canPlay()) return;
|
||||
let d = 0;
|
||||
while (!collides(piece.shape, piece.x, piece.y + 1)) { piece.y++; d++; }
|
||||
score += d * 2;
|
||||
lockPiece();
|
||||
}
|
||||
|
||||
function rotatePiece() {
|
||||
if (!canPlay()) return;
|
||||
const rotated = rotate(piece.shape);
|
||||
// 简单踢墙:依次尝试原地、左1、右1、左2、右2、上1
|
||||
const kicks = [0, -1, 1, -2, 2];
|
||||
for (const k of kicks) {
|
||||
if (!collides(rotated, piece.x + k, piece.y)) {
|
||||
piece.shape = rotated;
|
||||
piece.x += k;
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!collides(rotated, piece.x, piece.y - 1)) {
|
||||
piece.shape = rotated;
|
||||
piece.y -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
function holdPiece() {
|
||||
if (!canPlay() || !canHold) return;
|
||||
if (holdType === null) {
|
||||
holdType = piece.type;
|
||||
piece = spawn(nextQueue.shift());
|
||||
nextQueue.push(nextType());
|
||||
} else {
|
||||
[holdType, piece] = [piece.type, spawn(holdType)];
|
||||
}
|
||||
canHold = false;
|
||||
drawHold();
|
||||
drawNext();
|
||||
if (collides(piece.shape, piece.x, piece.y)) endGame();
|
||||
}
|
||||
|
||||
function lockPiece() {
|
||||
merge();
|
||||
clearLines();
|
||||
piece = spawn(nextQueue.shift());
|
||||
nextQueue.push(nextType());
|
||||
canHold = true;
|
||||
drawNext();
|
||||
if (collides(piece.shape, piece.x, piece.y)) endGame();
|
||||
}
|
||||
|
||||
function ghostY() {
|
||||
let gy = piece.y;
|
||||
while (!collides(piece.shape, piece.x, gy + 1)) gy++;
|
||||
return gy;
|
||||
}
|
||||
|
||||
function canPlay() { return running && !paused && !gameOver; }
|
||||
|
||||
// ---------- 绘制 ----------
|
||||
function drawBlock(ctx, x, y, size, color, ghost = false) {
|
||||
if (ghost) {
|
||||
ctx.strokeStyle = color;
|
||||
ctx.globalAlpha = 0.35;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeRect(x + 1, y + 1, size - 2, size - 2);
|
||||
ctx.globalAlpha = 1;
|
||||
return;
|
||||
}
|
||||
const g = ctx.createLinearGradient(x, y, x, y + size);
|
||||
g.addColorStop(0, lighten(color, 30));
|
||||
g.addColorStop(1, color);
|
||||
ctx.fillStyle = g;
|
||||
ctx.fillRect(x + 1, y + 1, size - 2, size - 2);
|
||||
ctx.fillStyle = 'rgba(255,255,255,.25)';
|
||||
ctx.fillRect(x + 3, y + 3, size - 6, 4);
|
||||
}
|
||||
|
||||
function lighten(hex, amt) {
|
||||
const n = parseInt(hex.slice(1), 16);
|
||||
const r = Math.min(255, (n >> 16) + amt);
|
||||
const g = Math.min(255, ((n >> 8) & 255) + amt);
|
||||
const b = Math.min(255, (n & 255) + amt);
|
||||
return `rgb(${r},${g},${b})`;
|
||||
}
|
||||
|
||||
function draw() {
|
||||
bctx.clearRect(0, 0, boardCanvas.width, boardCanvas.height);
|
||||
|
||||
// 网格线
|
||||
bctx.strokeStyle = 'rgba(255,255,255,.05)';
|
||||
bctx.lineWidth = 1;
|
||||
for (let x = 1; x < COLS; x++) {
|
||||
bctx.beginPath(); bctx.moveTo(x * BLOCK, 0); bctx.lineTo(x * BLOCK, ROWS * BLOCK); bctx.stroke();
|
||||
}
|
||||
for (let y = 1; y < ROWS; y++) {
|
||||
bctx.beginPath(); bctx.moveTo(0, y * BLOCK); bctx.lineTo(COLS * BLOCK, y * BLOCK); bctx.stroke();
|
||||
}
|
||||
|
||||
// 已固定方块
|
||||
for (let y = 0; y < ROWS; y++)
|
||||
for (let x = 0; x < COLS; x++)
|
||||
if (grid[y][x]) drawBlock(bctx, x * BLOCK, y * BLOCK, BLOCK, COLORS[grid[y][x] - 1]);
|
||||
|
||||
if (piece && !gameOver) {
|
||||
// 落点虚影
|
||||
const gy = ghostY();
|
||||
piece.shape.forEach((row, y) => row.forEach((v, x) => {
|
||||
if (v && gy + y >= 0) drawBlock(bctx, (piece.x + x) * BLOCK, (gy + y) * BLOCK, BLOCK, piece.color, true);
|
||||
}));
|
||||
// 当前方块
|
||||
piece.shape.forEach((row, y) => row.forEach((v, x) => {
|
||||
if (v && piece.y + y >= 0) drawBlock(bctx, (piece.x + x) * BLOCK, (piece.y + y) * BLOCK, BLOCK, piece.color);
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
function drawPreviewShape(ctx, canvas, type, offsetY, cellSize) {
|
||||
const shape = SHAPES[type];
|
||||
const color = COLORS[type];
|
||||
let minX = 4, maxX = -1, minY = 4, maxY = -1;
|
||||
shape.forEach((row, y) => row.forEach((v, x) => {
|
||||
if (v) { minX = Math.min(minX, x); maxX = Math.max(maxX, x); minY = Math.min(minY, y); maxY = Math.max(maxY, y); }
|
||||
}));
|
||||
const w = (maxX - minX + 1) * cellSize, h = (maxY - minY + 1) * cellSize;
|
||||
const ox = (canvas.width - w) / 2, oy = offsetY + (72 - h) / 2;
|
||||
shape.forEach((row, y) => row.forEach((v, x) => {
|
||||
if (v) drawBlock(ctx, ox + (x - minX) * cellSize, oy + (y - minY) * cellSize, cellSize, color);
|
||||
}));
|
||||
}
|
||||
|
||||
function drawNext() {
|
||||
nctx.clearRect(0, 0, nextCanvas.width, nextCanvas.height);
|
||||
nextQueue.slice(0, 3).forEach((t, i) => drawPreviewShape(nctx, nextCanvas, t, i * 72, 18));
|
||||
}
|
||||
|
||||
function drawHold() {
|
||||
hctx.clearRect(0, 0, holdCanvas.width, holdCanvas.height);
|
||||
if (holdType !== null) {
|
||||
hctx.globalAlpha = canHold ? 1 : 0.35;
|
||||
drawPreviewShape(hctx, holdCanvas, holdType, 0, 18);
|
||||
hctx.globalAlpha = 1;
|
||||
}
|
||||
}
|
||||
|
||||
function updateStats() {
|
||||
document.getElementById('score').textContent = score;
|
||||
document.getElementById('lines').textContent = lines;
|
||||
document.getElementById('level').textContent = level;
|
||||
}
|
||||
|
||||
// ---------- 主循环 ----------
|
||||
function loop(time = 0) {
|
||||
rafId = requestAnimationFrame(loop);
|
||||
if (!running || paused || gameOver) { draw(); return; }
|
||||
const delta = time - lastTime;
|
||||
lastTime = time;
|
||||
dropCounter += delta;
|
||||
if (dropCounter > dropInterval) {
|
||||
if (!collides(piece.shape, piece.x, piece.y + 1)) {
|
||||
piece.y++;
|
||||
} else {
|
||||
lockPiece();
|
||||
}
|
||||
dropCounter = 0;
|
||||
}
|
||||
draw();
|
||||
}
|
||||
|
||||
// ---------- 流程控制 ----------
|
||||
function resetGame() {
|
||||
grid = Array.from({length: ROWS}, () => Array(COLS).fill(0));
|
||||
bag = [];
|
||||
nextQueue = [nextType(), nextType(), nextType()];
|
||||
piece = spawn(nextQueue.shift());
|
||||
nextQueue.push(nextType());
|
||||
holdType = null;
|
||||
canHold = true;
|
||||
score = 0; lines = 0; level = 1;
|
||||
dropCounter = 0; dropInterval = 1000; lastTime = 0;
|
||||
gameOver = false; paused = false;
|
||||
updateStats();
|
||||
drawNext();
|
||||
drawHold();
|
||||
}
|
||||
|
||||
function startGame() {
|
||||
resetGame();
|
||||
running = true;
|
||||
hideOverlays();
|
||||
if (!rafId) loop();
|
||||
}
|
||||
|
||||
function endGame() {
|
||||
gameOver = true;
|
||||
running = false;
|
||||
let record = false;
|
||||
if (score > highScore) {
|
||||
highScore = score;
|
||||
localStorage.setItem('tetris_best', String(highScore));
|
||||
document.getElementById('highScore').textContent = highScore;
|
||||
record = true;
|
||||
}
|
||||
document.getElementById('finalScore').textContent = '本局得分:' + score;
|
||||
document.getElementById('newRecord').style.display = record ? 'block' : 'none';
|
||||
show('overOverlay');
|
||||
draw();
|
||||
}
|
||||
|
||||
function togglePause() {
|
||||
if (!running || gameOver) return;
|
||||
paused = !paused;
|
||||
lastTime = performance.now();
|
||||
document.getElementById('pauseBtn').textContent = paused ? '继续 (P)' : '暂停 (P)';
|
||||
paused ? show('pauseOverlay') : hide('pauseOverlay');
|
||||
}
|
||||
|
||||
function show(id) { document.getElementById(id).classList.remove('hidden'); }
|
||||
function hide(id) { document.getElementById(id).classList.add('hidden'); }
|
||||
function hideOverlays() { ['startOverlay', 'pauseOverlay', 'overOverlay'].forEach(hide); }
|
||||
|
||||
// ---------- 输入 ----------
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.repeat && e.code === 'Space') return;
|
||||
switch (e.code) {
|
||||
case 'ArrowLeft': e.preventDefault(); move(-1); break;
|
||||
case 'ArrowRight': e.preventDefault(); move(1); break;
|
||||
case 'ArrowDown': e.preventDefault(); softDrop(); break;
|
||||
case 'ArrowUp':
|
||||
case 'KeyX': e.preventDefault(); rotatePiece(); break;
|
||||
case 'Space': e.preventDefault(); hardDrop(); break;
|
||||
case 'KeyC': holdPiece(); break;
|
||||
case 'KeyP': togglePause(); break;
|
||||
case 'Enter':
|
||||
if (!running || gameOver) startGame();
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// 触屏按钮(支持按住连发)
|
||||
document.querySelectorAll('.touch-controls button').forEach(btn => {
|
||||
const act = btn.dataset.act;
|
||||
let timer = null;
|
||||
const fire = () => {
|
||||
if (act === 'left') move(-1);
|
||||
else if (act === 'right') move(1);
|
||||
else if (act === 'down') softDrop();
|
||||
else if (act === 'rotate') rotatePiece();
|
||||
else if (act === 'drop') hardDrop();
|
||||
else if (act === 'hold') holdPiece();
|
||||
else if (act === 'pause') togglePause();
|
||||
else if (act === 'reset') startGame();
|
||||
};
|
||||
btn.addEventListener('pointerdown', e => {
|
||||
e.preventDefault();
|
||||
fire();
|
||||
if (['left', 'right', 'down'].includes(act)) {
|
||||
timer = setInterval(fire, act === 'down' ? 60 : 110);
|
||||
}
|
||||
});
|
||||
['pointerup', 'pointerleave', 'pointercancel'].forEach(ev =>
|
||||
btn.addEventListener(ev, () => { if (timer) { clearInterval(timer); timer = null; } })
|
||||
);
|
||||
});
|
||||
|
||||
document.getElementById('startBtn').addEventListener('click', startGame);
|
||||
document.getElementById('restartBtn').addEventListener('click', startGame);
|
||||
document.getElementById('resetBtn').addEventListener('click', startGame);
|
||||
document.getElementById('resumeBtn').addEventListener('click', togglePause);
|
||||
document.getElementById('pauseBtn').addEventListener('click', togglePause);
|
||||
|
||||
// 初始画面
|
||||
resetGame();
|
||||
running = false;
|
||||
loop();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,556 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover, shrink-to-fit=yes">
|
||||
<title>天空冲刺 · 手机躲避游戏</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
user-select: none;
|
||||
}
|
||||
body {
|
||||
background: linear-gradient(180deg, #0b1a3a 0%, #1b3b5c 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||
padding: 10px;
|
||||
margin: 0;
|
||||
}
|
||||
.game-container {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
background: radial-gradient(circle at 50% 30%, #35637c, #0e2a3f);
|
||||
border-radius: 48px;
|
||||
box-shadow: 0 25px 50px rgba(0,0,0,0.6), inset 0 0 20px rgba(255,255,255,0.2);
|
||||
padding: 16px 12px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: 32px;
|
||||
background: radial-gradient(ellipse at 50% 20%, #7fc9ff 0%, #3a6d9c 70%);
|
||||
box-shadow: inset 0 -8px 20px rgba(0,20,40,0.7), 0 12px 25px rgba(0,0,0,0.5);
|
||||
touch-action: none; /* 禁止默认手势,优化触摸响应 */
|
||||
}
|
||||
.info-panel {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 14px;
|
||||
padding: 0 10px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
text-shadow: 0 2px 8px rgba(0,0,0,0.6);
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
.score-board {
|
||||
background: rgba(10, 25, 45, 0.7);
|
||||
backdrop-filter: blur(12px);
|
||||
border-radius: 50px;
|
||||
padding: 8px 22px;
|
||||
letter-spacing: 1px;
|
||||
box-shadow: 0 8px 18px rgba(0,0,0,0.5);
|
||||
border: 1px solid rgba(255,255,255,0.25);
|
||||
}
|
||||
.restart-btn {
|
||||
background: #ffb84d;
|
||||
border: none;
|
||||
color: #0a1f32;
|
||||
font-weight: bold;
|
||||
font-size: 1.2rem;
|
||||
padding: 10px 25px;
|
||||
border-radius: 40px;
|
||||
background: linear-gradient(180deg, #ffd966 0%, #f0a500 100%);
|
||||
box-shadow: 0 8px 0 #b76e00, 0 12px 22px rgba(0,0,0,0.4);
|
||||
transition: all 0.08s ease;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
cursor: pointer;
|
||||
border: 1px solid rgba(255,255,200,0.7);
|
||||
touch-action: manipulation;
|
||||
}
|
||||
.restart-btn:active {
|
||||
transform: translateY(4px);
|
||||
box-shadow: 0 4px 0 #b76e00, 0 8px 14px rgba(0,0,0,0.5);
|
||||
background: linear-gradient(180deg, #f5c45e 0%, #d18d00 100%);
|
||||
}
|
||||
.hint {
|
||||
color: rgba(255,255,255,0.8);
|
||||
font-size: 0.8rem;
|
||||
margin-top: 6px;
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
text-shadow: 0 2px 5px black;
|
||||
}
|
||||
@media (max-width: 400px) {
|
||||
.game-container {
|
||||
padding: 12px 8px 16px;
|
||||
}
|
||||
.info-panel {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="game-container">
|
||||
<canvas id="gameCanvas" width="360" height="600" aria-label="天空冲刺游戏画面"></canvas>
|
||||
<div class="info-panel">
|
||||
<div class="score-board" id="scoreDisplay">0</div>
|
||||
<button class="restart-btn" id="restartButton">↺ 起飞</button>
|
||||
</div>
|
||||
<div class="hint">👆 手指滑动或拖动飞机 | 躲避石块</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const canvas = document.getElementById('gameCanvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const scoreSpan = document.getElementById('scoreDisplay');
|
||||
|
||||
// 固定尺寸,适配高清显示
|
||||
const CW = 360;
|
||||
const CH = 600;
|
||||
canvas.width = CW;
|
||||
canvas.height = CH;
|
||||
|
||||
// ---------- 游戏状态 ----------
|
||||
let gameActive = true;
|
||||
let score = 0;
|
||||
|
||||
// 玩家飞机属性
|
||||
const PLAYER_WIDTH = 38;
|
||||
const PLAYER_HEIGHT = 44;
|
||||
let playerX = CW / 2 - PLAYER_WIDTH/2;
|
||||
const playerY = CH - 90; // 垂直位置固定(玩家只水平移动)
|
||||
|
||||
// 障碍物数组
|
||||
let obstacles = [];
|
||||
|
||||
// 障碍物设置
|
||||
const OBSTACLE_WIDTH = 36;
|
||||
const OBSTACLE_HEIGHT = 48;
|
||||
const OBSTACLE_SPEED = 4.2; // 每帧下落速度
|
||||
const SPAWN_FRAME_INTERVAL = 38; // 大约每38帧生成一个
|
||||
let frameCounter = 0;
|
||||
|
||||
// 移动控制 (触摸/鼠标)
|
||||
let targetPlayerX = playerX; // 用于平滑跟随或直接设置
|
||||
|
||||
// 触摸状态
|
||||
let activeTouchId = null; // 追踪当前触摸点
|
||||
|
||||
// ---------- 辅助函数:限制玩家位置 ----------
|
||||
function clampPlayerX(value) {
|
||||
return Math.max(5, Math.min(CW - PLAYER_WIDTH - 5, value));
|
||||
}
|
||||
|
||||
// ---------- 生成新障碍物 ----------
|
||||
function spawnObstacle() {
|
||||
// 随机x位置,确保不超出边界
|
||||
const maxX = CW - OBSTACLE_WIDTH;
|
||||
const x = Math.random() * maxX;
|
||||
obstacles.push({
|
||||
x: x,
|
||||
y: -OBSTACLE_HEIGHT - Math.random() * 20, // 从顶外出现
|
||||
width: OBSTACLE_WIDTH,
|
||||
height: OBSTACLE_HEIGHT,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- 重置游戏 ----------
|
||||
function restartGame() {
|
||||
gameActive = true;
|
||||
score = 0;
|
||||
obstacles = [];
|
||||
frameCounter = 0;
|
||||
playerX = CW / 2 - PLAYER_WIDTH/2;
|
||||
targetPlayerX = playerX;
|
||||
activeTouchId = null;
|
||||
updateScoreDisplay();
|
||||
}
|
||||
|
||||
// ---------- 游戏结束 ----------
|
||||
function endGame() {
|
||||
if (!gameActive) return; // 防止重复触发
|
||||
gameActive = false;
|
||||
// 可以添加震动反馈(如果支持)
|
||||
if (window.navigator && window.navigator.vibrate) {
|
||||
window.navigator.vibrate(30);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 更新分数显示 ----------
|
||||
function updateScoreDisplay() {
|
||||
scoreSpan.textContent = score;
|
||||
}
|
||||
|
||||
// ---------- 碰撞检测 (矩形重叠) ----------
|
||||
function checkCollision(playerRect, obs) {
|
||||
return (playerRect.x < obs.x + obs.width &&
|
||||
playerRect.x + playerRect.width > obs.x &&
|
||||
playerRect.y < obs.y + obs.height &&
|
||||
playerRect.y + playerRect.height > obs.y);
|
||||
}
|
||||
|
||||
// ---------- 更新逻辑 ----------
|
||||
function update() {
|
||||
if (!gameActive) return;
|
||||
|
||||
// 1. 玩家位置平滑移动到目标位置 (使操作更跟手)
|
||||
const DX = targetPlayerX - playerX;
|
||||
// 快速跟随,但保留手感
|
||||
if (Math.abs(DX) > 0.3) {
|
||||
playerX += DX * 0.35;
|
||||
// 避免微小抖动
|
||||
if (Math.abs(DX) < 0.5) {
|
||||
playerX = targetPlayerX;
|
||||
}
|
||||
} else {
|
||||
playerX = targetPlayerX;
|
||||
}
|
||||
playerX = clampPlayerX(playerX);
|
||||
// 同步目标位置以防超出边界后不一致
|
||||
targetPlayerX = clampPlayerX(targetPlayerX);
|
||||
|
||||
// 2. 移动障碍物 & 碰撞检测 & 移除屏幕外
|
||||
const playerRect = {
|
||||
x: playerX,
|
||||
y: playerY,
|
||||
width: PLAYER_WIDTH,
|
||||
height: PLAYER_HEIGHT,
|
||||
};
|
||||
|
||||
for (let i = obstacles.length - 1; i >= 0; i--) {
|
||||
const obs = obstacles[i];
|
||||
obs.y += OBSTACLE_SPEED;
|
||||
|
||||
// 碰撞检测 (仅当游戏活跃时)
|
||||
if (gameActive && checkCollision(playerRect, obs)) {
|
||||
endGame();
|
||||
return; // 立即停止更新,避免继续处理
|
||||
}
|
||||
|
||||
// 移除超出底部的障碍物,并增加分数
|
||||
if (obs.y > CH + 50) {
|
||||
obstacles.splice(i, 1);
|
||||
if (gameActive) {
|
||||
score += 1;
|
||||
updateScoreDisplay();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 生成新障碍物 (游戏活跃时才生成)
|
||||
if (gameActive) {
|
||||
frameCounter++;
|
||||
if (frameCounter >= SPAWN_FRAME_INTERVAL) {
|
||||
spawnObstacle();
|
||||
frameCounter = 0;
|
||||
// 动态略微调整生成间隔,增加随机性
|
||||
// 保持在30~45之间变化
|
||||
// 但为了稳定,使用固定基准加小波动
|
||||
const variation = Math.floor(Math.random() * 12) - 4; // -4~7
|
||||
frameCounter = -variation; // 技巧:让下次间隔变化
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 绘制图形 ----------
|
||||
function draw() {
|
||||
ctx.clearRect(0, 0, CW, CH);
|
||||
|
||||
// ------- 绘制装饰云朵 (背景) -------
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.2)';
|
||||
drawCloud(40, 120, 30);
|
||||
drawCloud(280, 200, 35);
|
||||
drawCloud(70, 320, 28);
|
||||
drawCloud(300, 430, 32);
|
||||
drawCloud(150, 70, 38);
|
||||
|
||||
// ------- 绘制障碍物 (石块/陨石) -------
|
||||
for (let obs of obstacles) {
|
||||
// 石块渐变
|
||||
const gradient = ctx.createLinearGradient(obs.x, obs.y, obs.x + 10, obs.y + obs.height);
|
||||
gradient.addColorStop(0, '#5d4a3a');
|
||||
gradient.addColorStop(0.5, '#4a3728');
|
||||
gradient.addColorStop(1, '#2f231a');
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.shadowColor = 'rgba(0,0,0,0.6)';
|
||||
ctx.shadowBlur = 8;
|
||||
ctx.beginPath();
|
||||
// 不规则石块效果
|
||||
const w = obs.width;
|
||||
const h = obs.height;
|
||||
const x = obs.x;
|
||||
const y = obs.y;
|
||||
ctx.moveTo(x + 5, y);
|
||||
ctx.lineTo(x + w - 6, y + 4);
|
||||
ctx.lineTo(x + w, y + h/2);
|
||||
ctx.lineTo(x + w - 5, y + h - 5);
|
||||
ctx.lineTo(x + w/2, y + h);
|
||||
ctx.lineTo(x + 3, y + h - 7);
|
||||
ctx.lineTo(x, y + h/3);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
// 高光线
|
||||
ctx.shadowBlur = 4;
|
||||
ctx.fillStyle = '#7d6b5c';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + 8, y + 5);
|
||||
ctx.lineTo(x + w - 12, y + 8);
|
||||
ctx.lineTo(x + w - 9, y + h/2 - 2);
|
||||
ctx.lineTo(x + 6, y + h/2 - 3);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
// 重置阴影
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.shadowColor = 'transparent';
|
||||
|
||||
// 裂纹
|
||||
ctx.strokeStyle = '#1a120a';
|
||||
ctx.lineWidth = 1.2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + 10, y + 12);
|
||||
ctx.lineTo(x + 18, y + 25);
|
||||
ctx.lineTo(x + 13, y + 34);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// ------- 绘制玩家飞机 -------
|
||||
ctx.shadowColor = '#00000080';
|
||||
ctx.shadowBlur = 12;
|
||||
const px = playerX;
|
||||
const py = playerY;
|
||||
|
||||
// 机身主色
|
||||
const planeGrad = ctx.createLinearGradient(px, py, px + PLAYER_WIDTH*0.8, py + PLAYER_HEIGHT);
|
||||
planeGrad.addColorStop(0, '#ffd966');
|
||||
planeGrad.addColorStop(0.6, '#f0a500');
|
||||
planeGrad.addColorStop(1, '#c47a00');
|
||||
ctx.fillStyle = planeGrad;
|
||||
|
||||
// 绘制飞机主体 (喷气式风格)
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(px + PLAYER_WIDTH/2, py - 4); // 机头
|
||||
ctx.lineTo(px + PLAYER_WIDTH-8, py + PLAYER_HEIGHT-12); // 右尾翼
|
||||
ctx.lineTo(px + PLAYER_WIDTH-4, py + PLAYER_HEIGHT-6);
|
||||
ctx.lineTo(px + PLAYER_WIDTH/2, py + PLAYER_HEIGHT-10); // 尾部中心
|
||||
ctx.lineTo(px + 4, py + PLAYER_HEIGHT-6);
|
||||
ctx.lineTo(px + 8, py + PLAYER_HEIGHT-12); // 左尾翼
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
// 机翼
|
||||
ctx.fillStyle = '#e59400';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(px + PLAYER_WIDTH/2, py + 12);
|
||||
ctx.lineTo(px - 3, py + 28);
|
||||
ctx.lineTo(px + 10, py + 32);
|
||||
ctx.lineTo(px + PLAYER_WIDTH/2, py + 24);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(px + PLAYER_WIDTH/2, py + 12);
|
||||
ctx.lineTo(px + PLAYER_WIDTH+3, py + 28);
|
||||
ctx.lineTo(px + PLAYER_WIDTH-10, py + 32);
|
||||
ctx.lineTo(px + PLAYER_WIDTH/2, py + 24);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
// 驾驶舱
|
||||
ctx.fillStyle = '#aee2ff';
|
||||
ctx.shadowColor = '#ffffff80';
|
||||
ctx.shadowBlur = 8;
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(px + PLAYER_WIDTH/2, py + 10, 7, 9, 0, 0, Math.PI*2);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(px + PLAYER_WIDTH/2-2, py + 8, 2.5, 3.5, 0, 0, Math.PI*2);
|
||||
ctx.fill();
|
||||
|
||||
// 引擎火焰
|
||||
ctx.shadowBlur = 14;
|
||||
ctx.fillStyle = '#ff7b24';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(px + PLAYER_WIDTH/2-5, py + PLAYER_HEIGHT-8);
|
||||
ctx.lineTo(px + PLAYER_WIDTH/2, py + PLAYER_HEIGHT+6);
|
||||
ctx.lineTo(px + PLAYER_WIDTH/2+5, py + PLAYER_HEIGHT-8);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#ffdd55';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(px + PLAYER_WIDTH/2-3, py + PLAYER_HEIGHT-6);
|
||||
ctx.lineTo(px + PLAYER_WIDTH/2, py + PLAYER_HEIGHT);
|
||||
ctx.lineTo(px + PLAYER_WIDTH/2+3, py + PLAYER_HEIGHT-6);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
// 重置阴影
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.shadowColor = 'transparent';
|
||||
|
||||
// ------- 游戏结束画面 -------
|
||||
if (!gameActive) {
|
||||
ctx.globalAlpha = 0.8;
|
||||
ctx.fillStyle = '#0a1424';
|
||||
ctx.fillRect(0, 0, CW, CH);
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = 'white';
|
||||
ctx.font = 'bold 28px "Segoe UI", system-ui';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.shadowColor = 'black';
|
||||
ctx.shadowBlur = 16;
|
||||
ctx.fillText('💥 坠毁', CW/2, CH/2-20);
|
||||
ctx.font = '18px system-ui';
|
||||
ctx.fillText('点击"起飞"继续', CW/2, CH/2+45);
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.shadowColor = 'transparent';
|
||||
}
|
||||
}
|
||||
|
||||
function drawCloud(x, y, size) {
|
||||
ctx.fillStyle = 'rgba(255, 255, 250, 0.25)';
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, size*0.6, 0, Math.PI*2);
|
||||
ctx.arc(x+size*0.5, y-size*0.2, size*0.7, 0, Math.PI*2);
|
||||
ctx.arc(x-size*0.4, y-size*0.1, size*0.55, 0, Math.PI*2);
|
||||
ctx.arc(x+size*0.2, y+size*0.2, size*0.5, 0, Math.PI*2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// ---------- 游戏循环 ----------
|
||||
function gameLoop() {
|
||||
update();
|
||||
draw();
|
||||
requestAnimationFrame(gameLoop);
|
||||
}
|
||||
|
||||
// ---------- 事件处理 (触摸/鼠标适配) ----------
|
||||
function handleStart(e) {
|
||||
e.preventDefault();
|
||||
if (!gameActive) return; // 游戏结束时不处理移动
|
||||
|
||||
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
|
||||
const canvasRect = canvas.getBoundingClientRect();
|
||||
// 计算相对于canvas内部的x坐标 (按比例缩放)
|
||||
const scaleX = CW / canvasRect.width; // canvas物理像素与CSS宽度比例
|
||||
const relativeX = (clientX - canvasRect.left) * scaleX;
|
||||
|
||||
// 设定目标位置:飞机中心对准触摸点
|
||||
let newTarget = relativeX - PLAYER_WIDTH/2;
|
||||
targetPlayerX = clampPlayerX(newTarget);
|
||||
playerX = targetPlayerX; // 立即吸附,提升响应
|
||||
|
||||
// 记录触摸点id用于移动跟踪
|
||||
if (e.touches) {
|
||||
activeTouchId = e.touches[0].identifier;
|
||||
}
|
||||
}
|
||||
|
||||
function handleMove(e) {
|
||||
e.preventDefault();
|
||||
if (!gameActive) return;
|
||||
|
||||
// 对于触摸事件,需要找到匹配的触摸点
|
||||
if (e.touches) {
|
||||
if (activeTouchId === null) return;
|
||||
let currentTouch = null;
|
||||
for (let i = 0; i < e.touches.length; i++) {
|
||||
if (e.touches[i].identifier === activeTouchId) {
|
||||
currentTouch = e.touches[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!currentTouch) return;
|
||||
|
||||
const canvasRect = canvas.getBoundingClientRect();
|
||||
const scaleX = CW / canvasRect.width;
|
||||
const relativeX = (currentTouch.clientX - canvasRect.left) * scaleX;
|
||||
let newTarget = relativeX - PLAYER_WIDTH/2;
|
||||
targetPlayerX = clampPlayerX(newTarget);
|
||||
} else {
|
||||
// 鼠标移动 (用于桌面调试)
|
||||
const canvasRect = canvas.getBoundingClientRect();
|
||||
const scaleX = CW / canvasRect.width;
|
||||
const relativeX = (e.clientX - canvasRect.left) * scaleX;
|
||||
let newTarget = relativeX - PLAYER_WIDTH/2;
|
||||
targetPlayerX = clampPlayerX(newTarget);
|
||||
}
|
||||
}
|
||||
|
||||
function handleEnd(e) {
|
||||
e.preventDefault();
|
||||
// 触摸结束,清除活动触摸点
|
||||
if (e.touches) {
|
||||
// 如果剩余触摸点为空,则重置activeTouchId
|
||||
if (e.touches.length === 0) {
|
||||
activeTouchId = null;
|
||||
} else {
|
||||
// 仍有其他手指,尝试切换到第一个
|
||||
activeTouchId = e.touches[0].identifier;
|
||||
}
|
||||
} else {
|
||||
// 鼠标松开不做特殊处理
|
||||
activeTouchId = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 绑定事件
|
||||
function bindEvents() {
|
||||
// 触摸事件
|
||||
canvas.addEventListener('touchstart', handleStart, { passive: false });
|
||||
canvas.addEventListener('touchmove', handleMove, { passive: false });
|
||||
canvas.addEventListener('touchend', handleEnd);
|
||||
canvas.addEventListener('touchcancel', handleEnd);
|
||||
|
||||
// 鼠标事件 (便于开发调试和部分设备)
|
||||
canvas.addEventListener('mousedown', handleStart);
|
||||
window.addEventListener('mousemove', (e) => {
|
||||
// 只有按下鼠标左键时才处理移动
|
||||
if (e.buttons === 1 && gameActive) {
|
||||
e.preventDefault();
|
||||
const canvasRect = canvas.getBoundingClientRect();
|
||||
const scaleX = CW / canvasRect.width;
|
||||
const relativeX = (e.clientX - canvasRect.left) * scaleX;
|
||||
let newTarget = relativeX - PLAYER_WIDTH/2;
|
||||
targetPlayerX = clampPlayerX(newTarget);
|
||||
}
|
||||
});
|
||||
window.addEventListener('mouseup', (e) => {
|
||||
// 不做特殊操作
|
||||
});
|
||||
|
||||
// 重启按钮
|
||||
document.getElementById('restartButton').addEventListener('click', (e) => {
|
||||
restartGame();
|
||||
});
|
||||
document.getElementById('restartButton').addEventListener('touchstart', (e) => {
|
||||
e.preventDefault();
|
||||
restartGame();
|
||||
});
|
||||
}
|
||||
|
||||
// 启动一切
|
||||
bindEvents();
|
||||
restartGame(); // 初始状态
|
||||
gameLoop();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,479 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover, shrink-to-fit=yes">
|
||||
<title>深渊矿工 · 收集宝石游戏</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
user-select: none;
|
||||
}
|
||||
body {
|
||||
background: linear-gradient(180deg, #1a0e08 0%, #3b2a1a 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-family: 'Segoe UI', system-ui, sans-serif;
|
||||
padding: 10px;
|
||||
margin: 0;
|
||||
}
|
||||
.game-container {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
background: radial-gradient(circle at 50% 40%, #5e3e1a, #2d1c0b);
|
||||
border-radius: 48px;
|
||||
box-shadow: 0 25px 45px rgba(0,0,0,0.7), inset 0 0 20px rgba(255,215,0,0.2);
|
||||
padding: 16px 12px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
border: 1px solid #7a5c3a;
|
||||
}
|
||||
canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: 32px;
|
||||
background: radial-gradient(ellipse at 50% 10%, #4a3620 0%, #1e1308 85%);
|
||||
box-shadow: inset 0 -12px 25px rgba(0,0,0,0.8), 0 10px 20px black;
|
||||
touch-action: none;
|
||||
}
|
||||
.info-panel {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 14px;
|
||||
padding: 0 10px;
|
||||
color: #ffd78b;
|
||||
font-weight: bold;
|
||||
text-shadow: 0 2px 6px black;
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
.score-board {
|
||||
background: rgba(0,0,0,0.6);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 50px;
|
||||
padding: 8px 22px;
|
||||
letter-spacing: 1px;
|
||||
box-shadow: 0 6px 14px rgba(0,0,0,0.7);
|
||||
border: 1px solid #c9a23b;
|
||||
color: #ffd966;
|
||||
}
|
||||
.restart-btn {
|
||||
background: #e6b422;
|
||||
border: none;
|
||||
color: #2b1a04;
|
||||
font-weight: bold;
|
||||
font-size: 1.2rem;
|
||||
padding: 10px 24px;
|
||||
border-radius: 40px;
|
||||
background: linear-gradient(180deg, #f9e076 0%, #ca9620 100%);
|
||||
box-shadow: 0 7px 0 #7a5200, 0 10px 20px black;
|
||||
transition: all 0.08s ease;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
cursor: pointer;
|
||||
border: 1px solid #ffe28c;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
.restart-btn:active {
|
||||
transform: translateY(4px);
|
||||
box-shadow: 0 3px 0 #7a5200, 0 7px 12px black;
|
||||
background: linear-gradient(180deg, #ebc85c 0%, #ad7d18 100%);
|
||||
}
|
||||
.hint {
|
||||
color: #dbbc6c;
|
||||
font-size: 0.8rem;
|
||||
margin-top: 6px;
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="game-container">
|
||||
<canvas id="gameCanvas" width="360" height="600" aria-label="深渊矿工游戏画面"></canvas>
|
||||
<div class="info-panel">
|
||||
<div class="score-board" id="scoreDisplay">💎 0</div>
|
||||
<button class="restart-btn" id="restartButton">⛏️ 新矿洞</button>
|
||||
</div>
|
||||
<div class="hint">👆 左右滑动矿车 | 接宝石 ✦ 躲岩石</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const canvas = document.getElementById('gameCanvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const scoreSpan = document.getElementById('scoreDisplay');
|
||||
|
||||
const CW = 360;
|
||||
const CH = 600;
|
||||
canvas.width = CW;
|
||||
canvas.height = CH;
|
||||
|
||||
// ----- 游戏状态 -----
|
||||
let gameActive = true;
|
||||
let score = 0;
|
||||
|
||||
// 矿车属性
|
||||
const CART_WIDTH = 56;
|
||||
const CART_HEIGHT = 40;
|
||||
let cartX = CW / 2 - CART_WIDTH/2;
|
||||
const cartY = CH - 85; // 固定垂直位置
|
||||
|
||||
// 下落物品数组
|
||||
let items = [];
|
||||
|
||||
// 物品类型
|
||||
const TYPE_GEM = 'gem'; // 宝石 (+1分)
|
||||
const TYPE_ROCK = 'rock'; // 岩石 (碰撞结束)
|
||||
|
||||
const ITEM_SIZE = 34; // 统一尺寸
|
||||
const FALL_SPEED = 3.8;
|
||||
const SPAWN_RATE = 32; // 帧间隔
|
||||
let frameCount = 0;
|
||||
|
||||
// 触摸控制
|
||||
let targetCartX = cartX;
|
||||
let activeTouchId = null;
|
||||
|
||||
// ----- 辅助函数 -----
|
||||
function clampCart(value) {
|
||||
return Math.max(4, Math.min(CW - CART_WIDTH - 4, value));
|
||||
}
|
||||
|
||||
// 生成物品 (宝石或岩石)
|
||||
function spawnItem() {
|
||||
const maxX = CW - ITEM_SIZE;
|
||||
const x = Math.random() * maxX;
|
||||
// 70%概率宝石,30%岩石
|
||||
const type = Math.random() < 0.7 ? TYPE_GEM : TYPE_ROCK;
|
||||
items.push({
|
||||
x: x,
|
||||
y: -ITEM_SIZE - Math.random() * 15,
|
||||
size: ITEM_SIZE,
|
||||
type: type,
|
||||
});
|
||||
}
|
||||
|
||||
// 重置游戏
|
||||
function restartGame() {
|
||||
gameActive = true;
|
||||
score = 0;
|
||||
items = [];
|
||||
frameCount = 0;
|
||||
cartX = CW / 2 - CART_WIDTH/2;
|
||||
targetCartX = cartX;
|
||||
activeTouchId = null;
|
||||
updateScoreDisplay();
|
||||
}
|
||||
|
||||
function endGame() {
|
||||
if (!gameActive) return;
|
||||
gameActive = false;
|
||||
if (window.navigator && window.navigator.vibrate) {
|
||||
window.navigator.vibrate(40);
|
||||
}
|
||||
}
|
||||
|
||||
function updateScoreDisplay() {
|
||||
scoreSpan.textContent = `💎 ${score}`;
|
||||
}
|
||||
|
||||
// 碰撞检测
|
||||
function rectCollide(r1, r2) {
|
||||
return !(r2.x > r1.x + r1.w || r2.x + r2.w < r1.x ||
|
||||
r2.y > r1.y + r1.h || r2.y + r2.h < r1.y);
|
||||
}
|
||||
|
||||
// ----- 更新逻辑 -----
|
||||
function update() {
|
||||
if (!gameActive) return;
|
||||
|
||||
// 矿车平滑移动
|
||||
const diff = targetCartX - cartX;
|
||||
if (Math.abs(diff) > 0.3) {
|
||||
cartX += diff * 0.3;
|
||||
if (Math.abs(diff) < 0.6) cartX = targetCartX;
|
||||
} else {
|
||||
cartX = targetCartX;
|
||||
}
|
||||
cartX = clampCart(cartX);
|
||||
targetCartX = clampCart(targetCartX);
|
||||
|
||||
// 矿车碰撞矩形
|
||||
const cartRect = {
|
||||
x: cartX,
|
||||
y: cartY,
|
||||
w: CART_WIDTH,
|
||||
h: CART_HEIGHT,
|
||||
};
|
||||
|
||||
// 处理物品下落
|
||||
for (let i = items.length - 1; i >= 0; i--) {
|
||||
const item = items[i];
|
||||
item.y += FALL_SPEED;
|
||||
|
||||
const itemRect = {
|
||||
x: item.x,
|
||||
y: item.y,
|
||||
w: item.size,
|
||||
h: item.size,
|
||||
};
|
||||
|
||||
// 碰撞检测
|
||||
if (gameActive && rectCollide(cartRect, itemRect)) {
|
||||
if (item.type === TYPE_GEM) {
|
||||
// 收集宝石
|
||||
score++;
|
||||
updateScoreDisplay();
|
||||
items.splice(i, 1);
|
||||
continue;
|
||||
} else if (item.type === TYPE_ROCK) {
|
||||
// 撞到岩石,游戏结束
|
||||
endGame();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 移除超出屏幕的物品
|
||||
if (item.y > CH + 50) {
|
||||
items.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// 生成新物品
|
||||
if (gameActive) {
|
||||
frameCount++;
|
||||
if (frameCount >= SPAWN_RATE) {
|
||||
spawnItem();
|
||||
frameCount = 0;
|
||||
// 动态微调
|
||||
const variation = Math.floor(Math.random() * 10) - 3;
|
||||
frameCount = -variation;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----- 绘制图形 -----
|
||||
function draw() {
|
||||
ctx.clearRect(0, 0, CW, CH);
|
||||
|
||||
// 绘制岩壁纹理
|
||||
ctx.fillStyle = '#2f2416';
|
||||
for (let i = 0; i < 8; i++) {
|
||||
ctx.fillRect(0, i * 85 + (Date.now()*0.01 % 85), 8, 25);
|
||||
ctx.fillRect(CW-8, i * 85 + 30 + (Date.now()*0.01 % 70), 8, 25);
|
||||
}
|
||||
|
||||
// 绘制物品
|
||||
for (let item of items) {
|
||||
const { x, y, size, type } = item;
|
||||
ctx.save();
|
||||
ctx.shadowColor = '#00000080';
|
||||
ctx.shadowBlur = 8;
|
||||
|
||||
if (type === TYPE_GEM) {
|
||||
// 绘制宝石 (多面体)
|
||||
const grad = ctx.createLinearGradient(x, y, x+size, y+size);
|
||||
grad.addColorStop(0, '#b5f2ff');
|
||||
grad.addColorStop(0.4, '#3cc7e0');
|
||||
grad.addColorStop(1, '#0f5b6e');
|
||||
ctx.fillStyle = grad;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + size/2, y);
|
||||
ctx.lineTo(x + size, y + size*0.6);
|
||||
ctx.lineTo(x + size*0.7, y + size);
|
||||
ctx.lineTo(x + size*0.3, y + size);
|
||||
ctx.lineTo(x, y + size*0.6);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#ffffffcc';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + size/2, y+2);
|
||||
ctx.lineTo(x + size*0.8, y + size*0.55);
|
||||
ctx.lineTo(x + size/2, y + size*0.7);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
} else {
|
||||
// 绘制岩石
|
||||
const grad = ctx.createLinearGradient(x, y, x+6, y+size);
|
||||
grad.addColorStop(0, '#7d6b5a');
|
||||
grad.addColorStop(0.7, '#3e3325');
|
||||
ctx.fillStyle = grad;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x+5, y);
|
||||
ctx.lineTo(x+size-6, y+3);
|
||||
ctx.lineTo(x+size, y+size/2);
|
||||
ctx.lineTo(x+size-4, y+size-5);
|
||||
ctx.lineTo(x+size/2, y+size);
|
||||
ctx.lineTo(x+3, y+size-6);
|
||||
ctx.lineTo(x, y+size/3);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#5c5040';
|
||||
ctx.beginPath();
|
||||
ctx.arc(x+size/2-2, y+size/2-2, 4, 0, Math.PI*2);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// 绘制矿车
|
||||
ctx.save();
|
||||
ctx.shadowColor = '#000000';
|
||||
ctx.shadowBlur = 14;
|
||||
const cx = cartX;
|
||||
const cy = cartY;
|
||||
|
||||
// 矿车车身
|
||||
const cartGrad = ctx.createLinearGradient(cx, cy, cx, cy+CART_HEIGHT);
|
||||
cartGrad.addColorStop(0, '#b3823e');
|
||||
cartGrad.addColorStop(0.7, '#613d1c');
|
||||
ctx.fillStyle = cartGrad;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx+8, cy);
|
||||
ctx.lineTo(cx+CART_WIDTH-8, cy);
|
||||
ctx.lineTo(cx+CART_WIDTH-2, cy+CART_HEIGHT-8);
|
||||
ctx.lineTo(cx+CART_WIDTH-2, cy+CART_HEIGHT-2);
|
||||
ctx.lineTo(cx+2, cy+CART_HEIGHT-2);
|
||||
ctx.lineTo(cx+2, cy+CART_HEIGHT-8);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
// 车轮
|
||||
ctx.fillStyle = '#2b1a0a';
|
||||
ctx.shadowBlur = 8;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx+10, cy+CART_HEIGHT-6, 8, 0, Math.PI*2);
|
||||
ctx.fill();
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx+CART_WIDTH-10, cy+CART_HEIGHT-6, 8, 0, Math.PI*2);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#5a5a5a';
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx+10, cy+CART_HEIGHT-6, 4, 0, Math.PI*2);
|
||||
ctx.fill();
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx+CART_WIDTH-10, cy+CART_HEIGHT-6, 4, 0, Math.PI*2);
|
||||
ctx.fill();
|
||||
|
||||
// 矿石点缀
|
||||
ctx.fillStyle = '#f9e076';
|
||||
ctx.shadowBlur = 6;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx+15, cy+12, 5, 0, Math.PI*2);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#ffb703';
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx+35, cy+18, 5, 0, Math.PI*2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
|
||||
// 游戏结束画面
|
||||
if (!gameActive) {
|
||||
ctx.globalAlpha = 0.8;
|
||||
ctx.fillStyle = '#0f0803';
|
||||
ctx.fillRect(0, 0, CW, CH);
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = '#f5d78c';
|
||||
ctx.font = 'bold 28px "Segoe UI", system-ui';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.shadowColor = 'black';
|
||||
ctx.shadowBlur = 18;
|
||||
ctx.fillText('⛔ 矿洞塌方', CW/2, CH/2-20);
|
||||
ctx.font = '18px system-ui';
|
||||
ctx.fillText('点击"新矿洞"继续', CW/2, CH/2+45);
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.shadowColor = 'transparent';
|
||||
}
|
||||
}
|
||||
|
||||
// ----- 游戏循环 -----
|
||||
function gameLoop() {
|
||||
update();
|
||||
draw();
|
||||
requestAnimationFrame(gameLoop);
|
||||
}
|
||||
|
||||
// ----- 事件处理 -----
|
||||
function handleStart(e) {
|
||||
e.preventDefault();
|
||||
if (!gameActive) return;
|
||||
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scaleX = CW / rect.width;
|
||||
const relX = (clientX - rect.left) * scaleX;
|
||||
targetCartX = clampCart(relX - CART_WIDTH/2);
|
||||
cartX = targetCartX;
|
||||
if (e.touches) activeTouchId = e.touches[0].identifier;
|
||||
}
|
||||
|
||||
function handleMove(e) {
|
||||
e.preventDefault();
|
||||
if (!gameActive) return;
|
||||
if (e.touches) {
|
||||
if (activeTouchId === null) return;
|
||||
let touch = null;
|
||||
for (let i=0; i<e.touches.length; i++) {
|
||||
if (e.touches[i].identifier === activeTouchId) { touch = e.touches[i]; break; }
|
||||
}
|
||||
if (!touch) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scaleX = CW / rect.width;
|
||||
const relX = (touch.clientX - rect.left) * scaleX;
|
||||
targetCartX = clampCart(relX - CART_WIDTH/2);
|
||||
} else {
|
||||
if (e.buttons === 1) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scaleX = CW / rect.width;
|
||||
const relX = (e.clientX - rect.left) * scaleX;
|
||||
targetCartX = clampCart(relX - CART_WIDTH/2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleEnd(e) {
|
||||
e.preventDefault();
|
||||
if (e.touches && e.touches.length === 0) {
|
||||
activeTouchId = null;
|
||||
} else if (e.touches) {
|
||||
activeTouchId = e.touches[0].identifier;
|
||||
}
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
canvas.addEventListener('touchstart', handleStart, { passive: false });
|
||||
canvas.addEventListener('touchmove', handleMove, { passive: false });
|
||||
canvas.addEventListener('touchend', handleEnd);
|
||||
canvas.addEventListener('touchcancel', handleEnd);
|
||||
canvas.addEventListener('mousedown', handleStart);
|
||||
window.addEventListener('mousemove', (e) => {
|
||||
if (e.buttons === 1 && gameActive) {
|
||||
e.preventDefault();
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scaleX = CW / rect.width;
|
||||
const relX = (e.clientX - rect.left) * scaleX;
|
||||
targetCartX = clampCart(relX - CART_WIDTH/2);
|
||||
}
|
||||
});
|
||||
document.getElementById('restartButton').addEventListener('click', restartGame);
|
||||
document.getElementById('restartButton').addEventListener('touchstart', (e) => {
|
||||
e.preventDefault();
|
||||
restartGame();
|
||||
});
|
||||
}
|
||||
|
||||
bindEvents();
|
||||
restartGame();
|
||||
gameLoop();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,562 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover, shrink-to-fit=yes">
|
||||
<title>泡泡射手 · 消除游戏</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
user-select: none;
|
||||
}
|
||||
body {
|
||||
background: linear-gradient(180deg, #1a2f4a 0%, #3c5f7c 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-family: 'Segoe UI', system-ui, sans-serif;
|
||||
padding: 10px;
|
||||
margin: 0;
|
||||
}
|
||||
.game-container {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
background: radial-gradient(circle at 50% 30%, #5b8cbf, #1e3b4f);
|
||||
border-radius: 48px;
|
||||
box-shadow: 0 25px 50px rgba(0,0,0,0.6), inset 0 0 25px rgba(255,255,255,0.2);
|
||||
padding: 16px 12px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: 32px;
|
||||
background: radial-gradient(ellipse at 50% 15%, #a3d0ff 0%, #32638a 80%);
|
||||
box-shadow: inset 0 -10px 25px rgba(0,20,40,0.7), 0 12px 25px rgba(0,0,0,0.5);
|
||||
touch-action: none;
|
||||
}
|
||||
.info-panel {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 14px;
|
||||
padding: 0 10px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
text-shadow: 0 2px 8px rgba(0,0,0,0.6);
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
.score-board {
|
||||
background: rgba(20, 40, 60, 0.7);
|
||||
backdrop-filter: blur(12px);
|
||||
border-radius: 50px;
|
||||
padding: 8px 22px;
|
||||
letter-spacing: 1px;
|
||||
box-shadow: 0 8px 18px rgba(0,0,0,0.5);
|
||||
border: 1px solid rgba(255,255,255,0.3);
|
||||
}
|
||||
.restart-btn {
|
||||
background: #ff7b9c;
|
||||
border: none;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
font-size: 1.2rem;
|
||||
padding: 10px 25px;
|
||||
border-radius: 40px;
|
||||
background: linear-gradient(180deg, #ffb3c6 0%, #e85d7a 100%);
|
||||
box-shadow: 0 8px 0 #a8324e, 0 12px 22px rgba(0,0,0,0.4);
|
||||
transition: all 0.08s ease;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
cursor: pointer;
|
||||
border: 1px solid #ffd1dc;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
.restart-btn:active {
|
||||
transform: translateY(4px);
|
||||
box-shadow: 0 4px 0 #a8324e, 0 8px 14px rgba(0,0,0,0.5);
|
||||
background: linear-gradient(180deg, #f595aa 0%, #c94a62 100%);
|
||||
}
|
||||
.hint {
|
||||
color: rgba(255,255,255,0.85);
|
||||
font-size: 0.8rem;
|
||||
margin-top: 6px;
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="game-container">
|
||||
<canvas id="gameCanvas" width="360" height="600" aria-label="泡泡射手游戏画面"></canvas>
|
||||
<div class="info-panel">
|
||||
<div class="score-board" id="scoreDisplay">🫧 0</div>
|
||||
<button class="restart-btn" id="restartButton">🔁 新一局</button>
|
||||
</div>
|
||||
<div class="hint">👆 点击屏幕发射泡泡 | 消除三个同色</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const canvas = document.getElementById('gameCanvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const scoreSpan = document.getElementById('scoreDisplay');
|
||||
|
||||
const CW = 360;
|
||||
const CH = 600;
|
||||
canvas.width = CW;
|
||||
canvas.height = CH;
|
||||
|
||||
// ----- 游戏配置 -----
|
||||
const ROWS = 7; // 泡泡网格行数
|
||||
const COLS = 8; // 列数
|
||||
const BUBBLE_RADIUS = 18; // 泡泡半径
|
||||
const CELL_SIZE = BUBBLE_RADIUS * 2 + 2; // 间距
|
||||
const GRID_OFFSET_X = (CW - COLS * CELL_SIZE) / 2 + BUBBLE_RADIUS;
|
||||
const GRID_OFFSET_Y = 65;
|
||||
|
||||
// 发射器位置
|
||||
const SHOOTER_Y = CH - 50;
|
||||
let shooterX = CW / 2;
|
||||
|
||||
// 颜色池
|
||||
const COLORS = ['#FF6B6B', '#4ECDC4', '#FFE66D', '#A37EF0', '#FF8A5C'];
|
||||
|
||||
// 游戏状态
|
||||
let gameActive = true;
|
||||
let score = 0;
|
||||
let grid = []; // 二维数组存储泡泡颜色(null为空)
|
||||
let currentBubbleColor = null;
|
||||
let nextBubbleColor = null;
|
||||
let projectile = null; // 飞行中的泡泡 { x, y, vx, vy, color }
|
||||
|
||||
// 触摸追踪
|
||||
let aiming = false;
|
||||
let aimStartX = 0;
|
||||
let aimStartY = 0;
|
||||
|
||||
// ----- 初始化网格 -----
|
||||
function createGrid() {
|
||||
grid = [];
|
||||
for (let r = 0; r < ROWS; r++) {
|
||||
grid[r] = [];
|
||||
for (let c = 0; c < COLS; c++) {
|
||||
// 偏移行 (交错效果)
|
||||
const colOffset = (r % 2 === 1) ? 0 : 0;
|
||||
// 只填充前几行
|
||||
if (r < 4) {
|
||||
const colorIndex = Math.floor(Math.random() * COLORS.length);
|
||||
grid[r][c] = COLORS[colorIndex];
|
||||
} else {
|
||||
grid[r][c] = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 随机颜色
|
||||
function randomColor() {
|
||||
return COLORS[Math.floor(Math.random() * COLORS.length)];
|
||||
}
|
||||
|
||||
// 重置游戏
|
||||
function restartGame() {
|
||||
gameActive = true;
|
||||
score = 0;
|
||||
createGrid();
|
||||
currentBubbleColor = randomColor();
|
||||
nextBubbleColor = randomColor();
|
||||
projectile = null;
|
||||
shooterX = CW / 2;
|
||||
updateScoreDisplay();
|
||||
}
|
||||
|
||||
function endGame() {
|
||||
if (!gameActive) return;
|
||||
gameActive = false;
|
||||
if (window.navigator && window.navigator.vibrate) {
|
||||
window.navigator.vibrate(25);
|
||||
}
|
||||
}
|
||||
|
||||
function updateScoreDisplay() {
|
||||
scoreSpan.textContent = `🫧 ${score}`;
|
||||
}
|
||||
|
||||
// 根据行列计算泡泡中心坐标
|
||||
function getBubblePos(row, col) {
|
||||
const x = GRID_OFFSET_X + col * CELL_SIZE + (row % 2 === 1 ? CELL_SIZE/2 : 0);
|
||||
const y = GRID_OFFSET_Y + row * CELL_SIZE * 0.88;
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
// 查找相邻同色泡泡 (用于消除)
|
||||
function findConnected(row, col, color, visited = new Set()) {
|
||||
const key = `${row},${col}`;
|
||||
if (row < 0 || row >= ROWS || col < 0 || col >= COLS || visited.has(key)) return [];
|
||||
if (grid[row][col] !== color) return [];
|
||||
|
||||
visited.add(key);
|
||||
const cluster = [{ row, col }];
|
||||
|
||||
// 六个方向 (考虑交错行)
|
||||
const directionsEven = [
|
||||
[-1, -1], [-1, 0], [0, -1], [0, 1], [1, -1], [1, 0]
|
||||
];
|
||||
const directionsOdd = [
|
||||
[-1, 0], [-1, 1], [0, -1], [0, 1], [1, 0], [1, 1]
|
||||
];
|
||||
const dirs = row % 2 === 0 ? directionsEven : directionsOdd;
|
||||
|
||||
for (let [dr, dc] of dirs) {
|
||||
const nr = row + dr;
|
||||
const nc = col + dc;
|
||||
cluster.push(...findConnected(nr, nc, color, visited));
|
||||
}
|
||||
return cluster;
|
||||
}
|
||||
|
||||
// 移除泡泡并更新分数
|
||||
function removeCluster(cluster) {
|
||||
if (cluster.length < 3) return false;
|
||||
for (let { row, col } of cluster) {
|
||||
grid[row][col] = null;
|
||||
}
|
||||
score += cluster.length * 10;
|
||||
updateScoreDisplay();
|
||||
return true;
|
||||
}
|
||||
|
||||
// 处理悬浮泡泡掉落 (没有连接到顶部的泡泡全部掉落)
|
||||
function dropFloatingBubbles() {
|
||||
const visited = new Set();
|
||||
// 从顶行开始标记所有连接的泡泡
|
||||
function markConnected(row, col) {
|
||||
const key = `${row},${col}`;
|
||||
if (row < 0 || row >= ROWS || col < 0 || col >= COLS || visited.has(key)) return;
|
||||
if (grid[row][col] === null) return;
|
||||
visited.add(key);
|
||||
const dirsEven = [[-1,-1],[-1,0],[0,-1],[0,1],[1,-1],[1,0]];
|
||||
const dirsOdd = [[-1,0],[-1,1],[0,-1],[0,1],[1,0],[1,1]];
|
||||
const dirs = row % 2 === 0 ? dirsEven : dirsOdd;
|
||||
for (let [dr, dc] of dirs) {
|
||||
markConnected(row + dr, col + dc);
|
||||
}
|
||||
}
|
||||
|
||||
for (let c = 0; c < COLS; c++) {
|
||||
if (grid[0][c] !== null) markConnected(0, c);
|
||||
}
|
||||
|
||||
let dropped = 0;
|
||||
for (let r = 0; r < ROWS; r++) {
|
||||
for (let c = 0; c < COLS; c++) {
|
||||
if (grid[r][c] !== null && !visited.has(`${r},${c}`)) {
|
||||
grid[r][c] = null;
|
||||
dropped++;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (dropped > 0) {
|
||||
score += dropped * 15;
|
||||
updateScoreDisplay();
|
||||
}
|
||||
}
|
||||
|
||||
// 检查游戏是否结束 (泡泡到达底部警戒线)
|
||||
function checkGameOver() {
|
||||
for (let r = ROWS - 1; r >= 0; r--) {
|
||||
for (let c = 0; c < COLS; c++) {
|
||||
if (grid[r][c] !== null) {
|
||||
const pos = getBubblePos(r, c);
|
||||
if (pos.y + BUBBLE_RADIUS > SHOOTER_Y - 15) {
|
||||
endGame();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 发射泡泡
|
||||
function shootBubble(targetX, targetY) {
|
||||
if (!gameActive || projectile) return;
|
||||
if (!currentBubbleColor) return;
|
||||
|
||||
const dx = targetX - shooterX;
|
||||
const dy = targetY - SHOOTER_Y;
|
||||
const length = Math.sqrt(dx * dx + dy * dy);
|
||||
if (length < 5) return; // 太近不发射
|
||||
|
||||
const speed = 9.5;
|
||||
const vx = (dx / length) * speed;
|
||||
const vy = (dy / length) * speed;
|
||||
|
||||
projectile = {
|
||||
x: shooterX,
|
||||
y: SHOOTER_Y,
|
||||
vx: vx,
|
||||
vy: vy,
|
||||
color: currentBubbleColor,
|
||||
radius: BUBBLE_RADIUS
|
||||
};
|
||||
|
||||
currentBubbleColor = nextBubbleColor;
|
||||
nextBubbleColor = randomColor();
|
||||
}
|
||||
|
||||
// 更新弹丸位置与碰撞
|
||||
function updateProjectile() {
|
||||
if (!projectile || !gameActive) return;
|
||||
|
||||
projectile.x += projectile.vx;
|
||||
projectile.y += projectile.vy;
|
||||
|
||||
// 边界反弹
|
||||
if (projectile.x - BUBBLE_RADIUS < 0) {
|
||||
projectile.x = BUBBLE_RADIUS;
|
||||
projectile.vx *= -0.9;
|
||||
}
|
||||
if (projectile.x + BUBBLE_RADIUS > CW) {
|
||||
projectile.x = CW - BUBBLE_RADIUS;
|
||||
projectile.vx *= -0.9;
|
||||
}
|
||||
|
||||
// 顶部碰撞
|
||||
if (projectile.y - BUBBLE_RADIUS < GRID_OFFSET_Y - BUBBLE_RADIUS) {
|
||||
placeProjectile();
|
||||
return;
|
||||
}
|
||||
|
||||
// 与网格泡泡碰撞检测
|
||||
for (let r = 0; r < ROWS; r++) {
|
||||
for (let c = 0; c < COLS; c++) {
|
||||
if (grid[r][c] === null) continue;
|
||||
const pos = getBubblePos(r, c);
|
||||
const dist = Math.hypot(projectile.x - pos.x, projectile.y - pos.y);
|
||||
if (dist < BUBBLE_RADIUS * 1.9) {
|
||||
placeProjectile();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 掉出底部 (失败情况,但不直接结束,只是移除弹丸)
|
||||
if (projectile.y > CH + 50) {
|
||||
projectile = null;
|
||||
}
|
||||
}
|
||||
|
||||
function placeProjectile() {
|
||||
if (!projectile) return;
|
||||
|
||||
// 找到最近的网格位置
|
||||
let bestRow = -1, bestCol = -1, bestDist = Infinity;
|
||||
for (let r = 0; r < ROWS; r++) {
|
||||
for (let c = 0; c < COLS; c++) {
|
||||
if (grid[r][c] !== null) continue;
|
||||
const pos = getBubblePos(r, c);
|
||||
const dist = Math.hypot(projectile.x - pos.x, projectile.y - pos.y);
|
||||
if (dist < bestDist) {
|
||||
bestDist = dist;
|
||||
bestRow = r;
|
||||
bestCol = c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bestRow !== -1 && bestCol !== -1 && bestDist < CELL_SIZE * 1.2) {
|
||||
grid[bestRow][bestCol] = projectile.color;
|
||||
|
||||
// 检查消除
|
||||
const cluster = findConnected(bestRow, bestCol, projectile.color);
|
||||
if (cluster.length >= 3) {
|
||||
removeCluster(cluster);
|
||||
dropFloatingBubbles();
|
||||
}
|
||||
|
||||
checkGameOver();
|
||||
}
|
||||
|
||||
projectile = null;
|
||||
}
|
||||
|
||||
// ----- 绘制 -----
|
||||
function draw() {
|
||||
ctx.clearRect(0, 0, CW, CH);
|
||||
|
||||
// 绘制网格泡泡
|
||||
for (let r = 0; r < ROWS; r++) {
|
||||
for (let c = 0; c < COLS; c++) {
|
||||
if (grid[r][c] === null) continue;
|
||||
const { x, y } = getBubblePos(r, c);
|
||||
drawBubble(x, y, grid[r][c]);
|
||||
}
|
||||
}
|
||||
|
||||
// 绘制飞行弹丸
|
||||
if (projectile) {
|
||||
drawBubble(projectile.x, projectile.y, projectile.color);
|
||||
}
|
||||
|
||||
// 绘制发射器
|
||||
ctx.save();
|
||||
ctx.shadowColor = '#00000060';
|
||||
ctx.shadowBlur = 12;
|
||||
// 发射台
|
||||
ctx.fillStyle = '#445566';
|
||||
ctx.beginPath();
|
||||
ctx.arc(shooterX, SHOOTER_Y, 14, 0, Math.PI*2);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#2c3e50';
|
||||
ctx.beginPath();
|
||||
ctx.arc(shooterX, SHOOTER_Y, 10, 0, Math.PI*2);
|
||||
ctx.fill();
|
||||
|
||||
// 当前泡泡预览
|
||||
if (currentBubbleColor) {
|
||||
drawBubble(shooterX, SHOOTER_Y - 2, currentBubbleColor);
|
||||
}
|
||||
|
||||
// 下一个泡泡预览
|
||||
if (nextBubbleColor) {
|
||||
ctx.fillStyle = '#ffffff30';
|
||||
ctx.beginPath();
|
||||
ctx.arc(shooterX + 38, SHOOTER_Y - 8, 14, 0, Math.PI*2);
|
||||
ctx.fill();
|
||||
drawBubble(shooterX + 38, SHOOTER_Y - 8, nextBubbleColor, 12);
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
// 引导线
|
||||
if (aiming && gameActive && !projectile) {
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = '#ffffff80';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.setLineDash([6, 8]);
|
||||
ctx.moveTo(shooterX, SHOOTER_Y);
|
||||
ctx.lineTo(aimStartX, aimStartY);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
|
||||
// 游戏结束
|
||||
if (!gameActive) {
|
||||
ctx.globalAlpha = 0.75;
|
||||
ctx.fillStyle = '#0a1a2a';
|
||||
ctx.fillRect(0, 0, CW, CH);
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = 'white';
|
||||
ctx.font = 'bold 28px "Segoe UI"';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('🫧 结束', CW/2, CH/2-20);
|
||||
}
|
||||
}
|
||||
|
||||
function drawBubble(x, y, color, radius = BUBBLE_RADIUS) {
|
||||
ctx.save();
|
||||
ctx.shadowColor = '#00000040';
|
||||
ctx.shadowBlur = 8;
|
||||
const grad = ctx.createRadialGradient(x-4, y-4, radius*0.1, x, y, radius);
|
||||
grad.addColorStop(0, '#ffffff');
|
||||
grad.addColorStop(0.4, color);
|
||||
grad.addColorStop(1, '#00000040');
|
||||
ctx.fillStyle = grad;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, radius, 0, Math.PI*2);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#ffffff90';
|
||||
ctx.beginPath();
|
||||
ctx.arc(x-3, y-4, radius*0.3, 0, Math.PI*2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// ----- 游戏循环 -----
|
||||
function gameLoop() {
|
||||
if (gameActive) {
|
||||
updateProjectile();
|
||||
}
|
||||
draw();
|
||||
requestAnimationFrame(gameLoop);
|
||||
}
|
||||
|
||||
// ----- 事件处理 -----
|
||||
function handleStart(e) {
|
||||
e.preventDefault();
|
||||
if (!gameActive) return;
|
||||
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
|
||||
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scaleX = CW / rect.width;
|
||||
const scaleY = CH / rect.height;
|
||||
const canvasX = (clientX - rect.left) * scaleX;
|
||||
const canvasY = (clientY - rect.top) * scaleY;
|
||||
|
||||
// 如果点击靠近发射器区域,开始瞄准
|
||||
if (Math.abs(canvasY - SHOOTER_Y) < 60 && !projectile) {
|
||||
aiming = true;
|
||||
aimStartX = canvasX;
|
||||
aimStartY = canvasY;
|
||||
}
|
||||
}
|
||||
|
||||
function handleMove(e) {
|
||||
e.preventDefault();
|
||||
if (!aiming || !gameActive) return;
|
||||
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
|
||||
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scaleX = CW / rect.width;
|
||||
const scaleY = CH / rect.height;
|
||||
aimStartX = (clientX - rect.left) * scaleX;
|
||||
aimStartY = (clientY - rect.top) * scaleY;
|
||||
}
|
||||
|
||||
function handleEnd(e) {
|
||||
e.preventDefault();
|
||||
if (aiming && gameActive && !projectile) {
|
||||
shootBubble(aimStartX, aimStartY);
|
||||
}
|
||||
aiming = false;
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
canvas.addEventListener('touchstart', handleStart, { passive: false });
|
||||
canvas.addEventListener('touchmove', handleMove, { passive: false });
|
||||
canvas.addEventListener('touchend', handleEnd);
|
||||
canvas.addEventListener('touchcancel', handleEnd);
|
||||
canvas.addEventListener('mousedown', handleStart);
|
||||
window.addEventListener('mousemove', (e) => {
|
||||
if (aiming && gameActive) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scaleX = CW / rect.width;
|
||||
const scaleY = CH / rect.height;
|
||||
aimStartX = (e.clientX - rect.left) * scaleX;
|
||||
aimStartY = (e.clientY - rect.top) * scaleY;
|
||||
}
|
||||
});
|
||||
window.addEventListener('mouseup', handleEnd);
|
||||
document.getElementById('restartButton').addEventListener('click', restartGame);
|
||||
document.getElementById('restartButton').addEventListener('touchstart', (e) => {
|
||||
e.preventDefault();
|
||||
restartGame();
|
||||
});
|
||||
}
|
||||
|
||||
bindEvents();
|
||||
restartGame();
|
||||
gameLoop();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,448 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover, shrink-to-fit=yes">
|
||||
<title>节奏光剑 · 反应游戏</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
user-select: none;
|
||||
}
|
||||
body {
|
||||
background: linear-gradient(180deg, #0a0a1a 0%, #1a1a3a 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-family: 'Segoe UI', system-ui, sans-serif;
|
||||
padding: 10px;
|
||||
margin: 0;
|
||||
}
|
||||
.game-container {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
background: radial-gradient(circle at 50% 40%, #2a2a5a, #0d0d2b);
|
||||
border-radius: 48px;
|
||||
box-shadow: 0 25px 50px rgba(0,0,0,0.8), inset 0 0 30px rgba(100,100,255,0.2);
|
||||
padding: 16px 12px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
border: 1px solid #4a4a8a;
|
||||
}
|
||||
canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: 32px;
|
||||
background: radial-gradient(ellipse at 50% 0%, #1e1e4a 0%, #08081a 80%);
|
||||
box-shadow: inset 0 -10px 30px rgba(0,0,0,0.9), 0 12px 30px black;
|
||||
touch-action: none;
|
||||
}
|
||||
.info-panel {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 14px;
|
||||
padding: 0 10px;
|
||||
color: #d0d0ff;
|
||||
font-weight: bold;
|
||||
text-shadow: 0 2px 10px #000066;
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
.score-board {
|
||||
background: rgba(10, 10, 40, 0.8);
|
||||
backdrop-filter: blur(15px);
|
||||
border-radius: 50px;
|
||||
padding: 8px 22px;
|
||||
letter-spacing: 1px;
|
||||
box-shadow: 0 8px 20px rgba(0,0,0,0.7);
|
||||
border: 1px solid #6a6aff;
|
||||
color: #ccccff;
|
||||
}
|
||||
.restart-btn {
|
||||
background: #7b4aff;
|
||||
border: none;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
font-size: 1.2rem;
|
||||
padding: 10px 25px;
|
||||
border-radius: 40px;
|
||||
background: linear-gradient(180deg, #9f7aff 0%, #5a2ae0 100%);
|
||||
box-shadow: 0 8px 0 #361a8c, 0 12px 24px rgba(0,0,0,0.6);
|
||||
transition: all 0.08s ease;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
cursor: pointer;
|
||||
border: 1px solid #b89fff;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
.restart-btn:active {
|
||||
transform: translateY(4px);
|
||||
box-shadow: 0 4px 0 #361a8c, 0 8px 16px black;
|
||||
background: linear-gradient(180deg, #8a65f0 0%, #421db0 100%);
|
||||
}
|
||||
.hint {
|
||||
color: #aaaadd;
|
||||
font-size: 0.8rem;
|
||||
margin-top: 6px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="game-container">
|
||||
<canvas id="gameCanvas" width="360" height="600" aria-label="节奏光剑游戏画面"></canvas>
|
||||
<div class="info-panel">
|
||||
<div class="score-board" id="scoreDisplay">⚡ 0</div>
|
||||
<button class="restart-btn" id="restartButton">🔮 重启</button>
|
||||
</div>
|
||||
<div class="hint">👇 点击下方轨道区域 | 击中光点</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const canvas = document.getElementById('gameCanvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const scoreSpan = document.getElementById('scoreDisplay');
|
||||
|
||||
const CW = 360;
|
||||
const CH = 600;
|
||||
canvas.width = CW;
|
||||
canvas.height = CH;
|
||||
|
||||
// ----- 轨道设置 -----
|
||||
const NUM_TRACKS = 4;
|
||||
const TRACK_WIDTH = CW / NUM_TRACKS;
|
||||
const HIT_ZONE_Y = CH - 75; // 判定线Y坐标
|
||||
const HIT_TOLERANCE = 42; // 判定范围
|
||||
|
||||
// 游戏状态
|
||||
let gameActive = true;
|
||||
let score = 0;
|
||||
let combo = 0;
|
||||
let maxCombo = 0;
|
||||
|
||||
// 音符数组
|
||||
let notes = [];
|
||||
|
||||
// 粒子效果
|
||||
let particles = [];
|
||||
|
||||
// 节拍控制
|
||||
const NOTE_SPEED = 5.2;
|
||||
const SPAWN_INTERVAL = 28; // 帧间隔
|
||||
let frameCounter = 0;
|
||||
|
||||
// 轨道闪烁效果
|
||||
let trackFlash = Array(NUM_TRACKS).fill(0);
|
||||
|
||||
// ----- 辅助函数 -----
|
||||
function randomTrack() {
|
||||
return Math.floor(Math.random() * NUM_TRACKS);
|
||||
}
|
||||
|
||||
function spawnNote() {
|
||||
const track = randomTrack();
|
||||
const x = track * TRACK_WIDTH + TRACK_WIDTH/2;
|
||||
notes.push({
|
||||
x: x,
|
||||
y: -30,
|
||||
track: track,
|
||||
radius: 14,
|
||||
active: true,
|
||||
});
|
||||
}
|
||||
|
||||
function restartGame() {
|
||||
gameActive = true;
|
||||
score = 0;
|
||||
combo = 0;
|
||||
maxCombo = 0;
|
||||
notes = [];
|
||||
particles = [];
|
||||
frameCounter = 0;
|
||||
trackFlash = Array(NUM_TRACKS).fill(0);
|
||||
updateScoreDisplay();
|
||||
}
|
||||
|
||||
function endGame() {
|
||||
if (!gameActive) return;
|
||||
gameActive = false;
|
||||
if (window.navigator && window.navigator.vibrate) {
|
||||
window.navigator.vibrate(50);
|
||||
}
|
||||
}
|
||||
|
||||
function updateScoreDisplay() {
|
||||
scoreSpan.textContent = `⚡ ${score}`;
|
||||
}
|
||||
|
||||
// 添加粒子
|
||||
function spawnParticles(x, y, color) {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const speed = 2 + Math.random() * 5;
|
||||
particles.push({
|
||||
x: x,
|
||||
y: y,
|
||||
vx: Math.cos(angle) * speed,
|
||||
vy: Math.sin(angle) * speed,
|
||||
life: 1.0,
|
||||
decay: 0.02 + Math.random() * 0.04,
|
||||
color: color,
|
||||
size: 2 + Math.random() * 4,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 处理点击轨道
|
||||
function hitTrack(trackIndex) {
|
||||
if (!gameActive) return;
|
||||
|
||||
let hitSuccess = false;
|
||||
let bestNoteIndex = -1;
|
||||
let bestDistance = HIT_TOLERANCE;
|
||||
|
||||
// 寻找最近的音符
|
||||
for (let i = notes.length - 1; i >= 0; i--) {
|
||||
const note = notes[i];
|
||||
if (!note.active || note.track !== trackIndex) continue;
|
||||
const distToZone = Math.abs(note.y - HIT_ZONE_Y);
|
||||
if (distToZone < bestDistance) {
|
||||
bestDistance = distToZone;
|
||||
bestNoteIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestNoteIndex !== -1) {
|
||||
const note = notes[bestNoteIndex];
|
||||
notes.splice(bestNoteIndex, 1);
|
||||
combo++;
|
||||
if (combo > maxCombo) maxCombo = combo;
|
||||
|
||||
// 根据准确度加分
|
||||
const accuracy = 1 - (bestDistance / HIT_TOLERANCE);
|
||||
const points = Math.floor(10 + accuracy * 30);
|
||||
score += points;
|
||||
|
||||
spawnParticles(note.x, note.y, '#ffff88');
|
||||
trackFlash[trackIndex] = 1.0;
|
||||
hitSuccess = true;
|
||||
}
|
||||
|
||||
if (!hitSuccess) {
|
||||
// 失误:重置连击
|
||||
combo = 0;
|
||||
trackFlash[trackIndex] = 0.4;
|
||||
spawnParticles(
|
||||
trackIndex * TRACK_WIDTH + TRACK_WIDTH/2,
|
||||
HIT_ZONE_Y,
|
||||
'#ff5555'
|
||||
);
|
||||
}
|
||||
|
||||
updateScoreDisplay();
|
||||
}
|
||||
|
||||
// ----- 更新逻辑 -----
|
||||
function update() {
|
||||
if (!gameActive) return;
|
||||
|
||||
// 更新音符位置
|
||||
for (let i = notes.length - 1; i >= 0; i--) {
|
||||
const note = notes[i];
|
||||
note.y += NOTE_SPEED;
|
||||
|
||||
// 超出屏幕底部移除,并断连击
|
||||
if (note.y > CH + 50) {
|
||||
notes.splice(i, 1);
|
||||
combo = 0;
|
||||
updateScoreDisplay();
|
||||
}
|
||||
}
|
||||
|
||||
// 更新粒子
|
||||
for (let i = particles.length - 1; i >= 0; i--) {
|
||||
const p = particles[i];
|
||||
p.x += p.vx;
|
||||
p.y += p.vy;
|
||||
p.life -= p.decay;
|
||||
if (p.life <= 0) particles.splice(i, 1);
|
||||
}
|
||||
|
||||
// 更新轨道闪烁
|
||||
for (let t = 0; t < NUM_TRACKS; t++) {
|
||||
if (trackFlash[t] > 0) trackFlash[t] -= 0.05;
|
||||
}
|
||||
|
||||
// 生成音符
|
||||
if (gameActive) {
|
||||
frameCounter++;
|
||||
if (frameCounter >= SPAWN_INTERVAL) {
|
||||
spawnNote();
|
||||
frameCounter = 0;
|
||||
const variation = Math.floor(Math.random() * 8) - 2;
|
||||
frameCounter = -variation;
|
||||
}
|
||||
}
|
||||
|
||||
// 检查游戏结束条件 (可选:漏掉太多音符?这里不使用,纯无限)
|
||||
}
|
||||
|
||||
// ----- 绘制图形 -----
|
||||
function draw() {
|
||||
ctx.clearRect(0, 0, CW, CH);
|
||||
|
||||
// 绘制轨道线
|
||||
for (let t = 0; t < NUM_TRACKS; t++) {
|
||||
const trackX = t * TRACK_WIDTH;
|
||||
ctx.fillStyle = trackFlash[t] > 0.1
|
||||
? `rgba(180, 180, 255, ${0.15 + trackFlash[t]*0.4})`
|
||||
: 'rgba(40, 40, 80, 0.4)';
|
||||
ctx.fillRect(trackX + 4, 0, TRACK_WIDTH - 8, CH);
|
||||
|
||||
ctx.strokeStyle = '#3a3a7a';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([12, 20]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(trackX + TRACK_WIDTH/2, 0);
|
||||
ctx.lineTo(trackX + TRACK_WIDTH/2, CH);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
|
||||
// 判定线
|
||||
ctx.strokeStyle = '#ffdd44';
|
||||
ctx.lineWidth = 3;
|
||||
ctx.shadowColor = '#ffaa00';
|
||||
ctx.shadowBlur = 15;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, HIT_ZONE_Y);
|
||||
ctx.lineTo(CW, HIT_ZONE_Y);
|
||||
ctx.stroke();
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// 绘制音符
|
||||
for (let note of notes) {
|
||||
if (!note.active) continue;
|
||||
ctx.save();
|
||||
ctx.shadowColor = '#ffffff';
|
||||
ctx.shadowBlur = 16;
|
||||
|
||||
const gradient = ctx.createRadialGradient(note.x-2, note.y-2, 2, note.x, note.y, note.radius);
|
||||
gradient.addColorStop(0, '#ffffff');
|
||||
gradient.addColorStop(0.5, '#aaccff');
|
||||
gradient.addColorStop(1, '#3355aa');
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.beginPath();
|
||||
ctx.arc(note.x, note.y, note.radius, 0, Math.PI*2);
|
||||
ctx.fill();
|
||||
|
||||
// 内核高光
|
||||
ctx.fillStyle = '#ffffffdd';
|
||||
ctx.beginPath();
|
||||
ctx.arc(note.x-2, note.y-3, note.radius*0.4, 0, Math.PI*2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// 绘制粒子
|
||||
for (let p of particles) {
|
||||
ctx.globalAlpha = p.life;
|
||||
ctx.fillStyle = p.color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, p.size, 0, Math.PI*2);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
// 连击显示
|
||||
if (combo > 2) {
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = 'bold 22px "Segoe UI"';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.shadowColor = '#ffaa00';
|
||||
ctx.shadowBlur = 20;
|
||||
ctx.fillText(`${combo} COMBO`, CW/2, 65);
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
|
||||
// 游戏结束
|
||||
if (!gameActive) {
|
||||
ctx.globalAlpha = 0.8;
|
||||
ctx.fillStyle = '#05051a';
|
||||
ctx.fillRect(0, 0, CW, CH);
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = '#d0d0ff';
|
||||
ctx.font = 'bold 28px "Segoe UI"';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('🌙 结束', CW/2, CH/2-25);
|
||||
ctx.font = '16px system-ui';
|
||||
ctx.fillText(`最高连击: ${maxCombo}`, CW/2, CH/2+30);
|
||||
}
|
||||
}
|
||||
|
||||
// ----- 游戏循环 -----
|
||||
function gameLoop() {
|
||||
update();
|
||||
draw();
|
||||
requestAnimationFrame(gameLoop);
|
||||
}
|
||||
|
||||
// ----- 事件处理 -----
|
||||
function handleCanvasTouch(e) {
|
||||
e.preventDefault();
|
||||
if (!gameActive) return;
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scaleX = CW / rect.width;
|
||||
|
||||
// 支持多点触摸处理多个轨道
|
||||
if (e.touches) {
|
||||
for (let i = 0; i < e.touches.length; i++) {
|
||||
const touch = e.touches[i];
|
||||
const canvasX = (touch.clientX - rect.left) * scaleX;
|
||||
const trackIndex = Math.floor(canvasX / TRACK_WIDTH);
|
||||
if (trackIndex >= 0 && trackIndex < NUM_TRACKS) {
|
||||
hitTrack(trackIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseDown(e) {
|
||||
e.preventDefault();
|
||||
if (!gameActive) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scaleX = CW / rect.width;
|
||||
const canvasX = (e.clientX - rect.left) * scaleX;
|
||||
const trackIndex = Math.floor(canvasX / TRACK_WIDTH);
|
||||
if (trackIndex >= 0 && trackIndex < NUM_TRACKS) {
|
||||
hitTrack(trackIndex);
|
||||
}
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
canvas.addEventListener('touchstart', handleCanvasTouch, { passive: false });
|
||||
canvas.addEventListener('mousedown', handleMouseDown);
|
||||
|
||||
document.getElementById('restartButton').addEventListener('click', restartGame);
|
||||
document.getElementById('restartButton').addEventListener('touchstart', (e) => {
|
||||
e.preventDefault();
|
||||
restartGame();
|
||||
});
|
||||
}
|
||||
|
||||
bindEvents();
|
||||
restartGame();
|
||||
gameLoop();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,459 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover, shrink-to-fit=yes">
|
||||
<title>像素守卫 · 射击游戏</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
user-select: none;
|
||||
}
|
||||
body {
|
||||
background: linear-gradient(180deg, #1a3a1a 0%, #0d260d 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-family: 'Segoe UI', system-ui, sans-serif;
|
||||
padding: 10px;
|
||||
margin: 0;
|
||||
}
|
||||
.game-container {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
background: radial-gradient(circle at 50% 30%, #3a6b3a, #1a331a);
|
||||
border-radius: 48px;
|
||||
box-shadow: 0 25px 50px rgba(0,0,0,0.7), inset 0 0 25px rgba(150,255,150,0.15);
|
||||
padding: 16px 12px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
border: 1px solid #5a8a5a;
|
||||
}
|
||||
canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: 32px;
|
||||
background: radial-gradient(ellipse at 50% 20%, #4a7a4a 0%, #1a2e1a 85%);
|
||||
box-shadow: inset 0 -8px 25px rgba(0,0,0,0.8), 0 12px 28px black;
|
||||
touch-action: none;
|
||||
}
|
||||
.info-panel {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 14px;
|
||||
padding: 0 10px;
|
||||
color: #c0ffc0;
|
||||
font-weight: bold;
|
||||
text-shadow: 0 2px 8px black;
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
.score-board {
|
||||
background: rgba(0,20,0,0.8);
|
||||
backdrop-filter: blur(12px);
|
||||
border-radius: 50px;
|
||||
padding: 8px 22px;
|
||||
letter-spacing: 1px;
|
||||
box-shadow: 0 8px 18px rgba(0,0,0,0.6);
|
||||
border: 1px solid #6aaa6a;
|
||||
}
|
||||
.restart-btn {
|
||||
background: #4caf50;
|
||||
border: none;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
font-size: 1.2rem;
|
||||
padding: 10px 25px;
|
||||
border-radius: 40px;
|
||||
background: linear-gradient(180deg, #6fbf6f 0%, #2e7d32 100%);
|
||||
box-shadow: 0 8px 0 #1b5e20, 0 12px 22px rgba(0,0,0,0.5);
|
||||
transition: all 0.08s ease;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
cursor: pointer;
|
||||
border: 1px solid #a5d6a7;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
.restart-btn:active {
|
||||
transform: translateY(4px);
|
||||
box-shadow: 0 4px 0 #1b5e20, 0 8px 14px rgba(0,0,0,0.6);
|
||||
background: linear-gradient(180deg, #5aa55a 0%, #1f5a22 100%);
|
||||
}
|
||||
.hint {
|
||||
color: #a0d0a0;
|
||||
font-size: 0.8rem;
|
||||
margin-top: 6px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="game-container">
|
||||
<canvas id="gameCanvas" width="360" height="600" aria-label="像素守卫射击游戏"></canvas>
|
||||
<div class="info-panel">
|
||||
<div class="score-board" id="scoreDisplay">🗡️ 0</div>
|
||||
<button class="restart-btn" id="restartButton">🏰 守城</button>
|
||||
</div>
|
||||
<div class="hint">👈 滑动瞄准 | 点击射击敌人</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const canvas = document.getElementById('gameCanvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const scoreSpan = document.getElementById('scoreDisplay');
|
||||
|
||||
const CW = 360;
|
||||
const CH = 600;
|
||||
canvas.width = CW;
|
||||
canvas.height = CH;
|
||||
|
||||
// ----- 炮台设置 -----
|
||||
const TOWER_X = CW / 2;
|
||||
const TOWER_Y = CH - 55;
|
||||
let aimAngle = -Math.PI / 2; // 默认向上
|
||||
|
||||
// 游戏状态
|
||||
let gameActive = true;
|
||||
let score = 0;
|
||||
let health = 100;
|
||||
const MAX_HEALTH = 100;
|
||||
|
||||
// 敌人数组
|
||||
let enemies = [];
|
||||
|
||||
// 子弹数组
|
||||
let bullets = [];
|
||||
|
||||
// 粒子效果
|
||||
let particles = [];
|
||||
|
||||
// 生成控制
|
||||
const ENEMY_SPAWN_INTERVAL = 40;
|
||||
let frameCounter = 0;
|
||||
let difficultyTimer = 0;
|
||||
|
||||
// ----- 辅助函数 -----
|
||||
function spawnEnemy() {
|
||||
const side = Math.random() < 0.5 ? 'left' : 'right';
|
||||
const x = side === 'left' ? -20 : CW + 20;
|
||||
const y = 40 + Math.random() * 250;
|
||||
const speedX = side === 'left' ? 1.2 + Math.random() * 1.5 : -(1.2 + Math.random() * 1.5);
|
||||
const speedY = (Math.random() - 0.5) * 1.2;
|
||||
|
||||
enemies.push({
|
||||
x: x,
|
||||
y: y,
|
||||
vx: speedX,
|
||||
vy: speedY,
|
||||
radius: 14,
|
||||
health: 1,
|
||||
color: '#ff5555',
|
||||
});
|
||||
}
|
||||
|
||||
function restartGame() {
|
||||
gameActive = true;
|
||||
score = 0;
|
||||
health = MAX_HEALTH;
|
||||
enemies = [];
|
||||
bullets = [];
|
||||
particles = [];
|
||||
frameCounter = 0;
|
||||
difficultyTimer = 0;
|
||||
aimAngle = -Math.PI / 2;
|
||||
updateScoreDisplay();
|
||||
}
|
||||
|
||||
function endGame() {
|
||||
if (!gameActive) return;
|
||||
gameActive = false;
|
||||
if (window.navigator && window.navigator.vibrate) {
|
||||
window.navigator.vibrate(60);
|
||||
}
|
||||
}
|
||||
|
||||
function updateScoreDisplay() {
|
||||
scoreSpan.textContent = `🗡️ ${score} ❤️ ${health}`;
|
||||
}
|
||||
|
||||
function damagePlayer(amount) {
|
||||
health -= amount;
|
||||
if (health <= 0) {
|
||||
health = 0;
|
||||
endGame();
|
||||
}
|
||||
updateScoreDisplay();
|
||||
}
|
||||
|
||||
// 发射子弹
|
||||
function shootBullet() {
|
||||
if (!gameActive) return;
|
||||
const bulletSpeed = 8;
|
||||
const vx = Math.cos(aimAngle) * bulletSpeed;
|
||||
const vy = Math.sin(aimAngle) * bulletSpeed;
|
||||
bullets.push({
|
||||
x: TOWER_X,
|
||||
y: TOWER_Y,
|
||||
vx: vx,
|
||||
vy: vy,
|
||||
radius: 5,
|
||||
life: 80,
|
||||
});
|
||||
}
|
||||
|
||||
// ----- 更新逻辑 -----
|
||||
function update() {
|
||||
if (!gameActive) return;
|
||||
|
||||
// 更新子弹
|
||||
for (let i = bullets.length - 1; i >= 0; i--) {
|
||||
const b = bullets[i];
|
||||
b.x += b.vx;
|
||||
b.y += b.vy;
|
||||
b.life--;
|
||||
|
||||
if (b.life <= 0 || b.x < -30 || b.x > CW + 30 || b.y < -30 || b.y > CH + 30) {
|
||||
bullets.splice(i, 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 子弹与敌人碰撞
|
||||
let bulletRemoved = false;
|
||||
for (let j = enemies.length - 1; j >= 0; j--) {
|
||||
const enemy = enemies[j];
|
||||
const dist = Math.hypot(b.x - enemy.x, b.y - enemy.y);
|
||||
if (dist < enemy.radius + b.radius) {
|
||||
// 消灭敌人
|
||||
enemies.splice(j, 1);
|
||||
bulletRemoved = true;
|
||||
score += 20;
|
||||
updateScoreDisplay();
|
||||
spawnParticles(enemy.x, enemy.y, '#ffaa00', 8);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (bulletRemoved) {
|
||||
bullets.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// 更新敌人
|
||||
for (let i = enemies.length - 1; i >= 0; i--) {
|
||||
const enemy = enemies[i];
|
||||
enemy.x += enemy.vx;
|
||||
enemy.y += enemy.vy;
|
||||
|
||||
// 敌人与炮台碰撞
|
||||
const distToTower = Math.hypot(enemy.x - TOWER_X, enemy.y - TOWER_Y);
|
||||
if (distToTower < 35) {
|
||||
enemies.splice(i, 1);
|
||||
damagePlayer(12);
|
||||
spawnParticles(TOWER_X, TOWER_Y, '#ff3333', 12);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 移除离开屏幕的敌人
|
||||
if (enemy.x < -60 || enemy.x > CW + 60 || enemy.y < -60 || enemy.y > CH + 60) {
|
||||
enemies.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// 更新粒子
|
||||
for (let i = particles.length - 1; i >= 0; i--) {
|
||||
const p = particles[i];
|
||||
p.x += p.vx;
|
||||
p.y += p.vy;
|
||||
p.life -= 0.03;
|
||||
if (p.life <= 0) particles.splice(i, 1);
|
||||
}
|
||||
|
||||
// 生成敌人 (随难度增加)
|
||||
if (gameActive) {
|
||||
frameCounter++;
|
||||
difficultyTimer++;
|
||||
const adjustedInterval = Math.max(18, ENEMY_SPAWN_INTERVAL - Math.floor(difficultyTimer / 300));
|
||||
if (frameCounter >= adjustedInterval) {
|
||||
spawnEnemy();
|
||||
frameCounter = 0;
|
||||
if (Math.random() < 0.3) spawnEnemy(); // 偶尔双倍
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function spawnParticles(x, y, color, count) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const speed = 2 + Math.random() * 5;
|
||||
particles.push({
|
||||
x: x, y: y,
|
||||
vx: Math.cos(angle) * speed,
|
||||
vy: Math.sin(angle) * speed,
|
||||
life: 1.0,
|
||||
color: color,
|
||||
size: 3 + Math.random() * 4,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ----- 绘制图形 -----
|
||||
function draw() {
|
||||
ctx.clearRect(0, 0, CW, CH);
|
||||
|
||||
// 绘制地面网格
|
||||
ctx.strokeStyle = '#2a4a2a';
|
||||
ctx.lineWidth = 0.8;
|
||||
for (let i = 0; i < CW; i += 30) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(i, 0);
|
||||
ctx.lineTo(i, CH);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// 绘制敌人
|
||||
for (let enemy of enemies) {
|
||||
ctx.save();
|
||||
ctx.shadowColor = '#ff0000aa';
|
||||
ctx.shadowBlur = 14;
|
||||
ctx.fillStyle = enemy.color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(enemy.x, enemy.y, enemy.radius, 0, Math.PI*2);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.beginPath();
|
||||
ctx.arc(enemy.x-3, enemy.y-4, 4, 0, Math.PI*2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// 绘制子弹
|
||||
for (let b of bullets) {
|
||||
ctx.fillStyle = '#ffff66';
|
||||
ctx.shadowColor = '#ffaa00';
|
||||
ctx.shadowBlur = 10;
|
||||
ctx.beginPath();
|
||||
ctx.arc(b.x, b.y, b.radius, 0, Math.PI*2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// 绘制粒子
|
||||
for (let p of particles) {
|
||||
ctx.globalAlpha = p.life;
|
||||
ctx.fillStyle = p.color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, p.size, 0, Math.PI*2);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// 绘制炮台
|
||||
ctx.save();
|
||||
ctx.translate(TOWER_X, TOWER_Y);
|
||||
// 基座
|
||||
ctx.fillStyle = '#5a5a7a';
|
||||
ctx.shadowColor = '#000000';
|
||||
ctx.shadowBlur = 10;
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, 18, 0, Math.PI*2);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#7a7aaa';
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, 12, 0, Math.PI*2);
|
||||
ctx.fill();
|
||||
|
||||
// 炮管
|
||||
ctx.rotate(aimAngle);
|
||||
ctx.fillStyle = '#ccccdd';
|
||||
ctx.fillRect(12, -5, 24, 10);
|
||||
ctx.fillStyle = '#aaaacc';
|
||||
ctx.fillRect(30, -3, 10, 6);
|
||||
ctx.restore();
|
||||
|
||||
// 生命条
|
||||
ctx.fillStyle = '#331111';
|
||||
ctx.fillRect(15, CH-18, CW-30, 14);
|
||||
const healthPercent = health / MAX_HEALTH;
|
||||
const healthColor = healthPercent > 0.5 ? '#44cc44' : '#ff6644';
|
||||
ctx.fillStyle = healthColor;
|
||||
ctx.fillRect(15, CH-18, (CW-30) * healthPercent, 14);
|
||||
ctx.strokeStyle = '#ffffff';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeRect(15, CH-18, CW-30, 14);
|
||||
|
||||
// 游戏结束
|
||||
if (!gameActive) {
|
||||
ctx.globalAlpha = 0.8;
|
||||
ctx.fillStyle = '#0a0f0a';
|
||||
ctx.fillRect(0, 0, CW, CH);
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = '#ffcccc';
|
||||
ctx.font = 'bold 28px "Segoe UI"';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('💀 失守', CW/2, CH/2-20);
|
||||
}
|
||||
}
|
||||
|
||||
// ----- 游戏循环 -----
|
||||
function gameLoop() {
|
||||
update();
|
||||
draw();
|
||||
requestAnimationFrame(gameLoop);
|
||||
}
|
||||
|
||||
// ----- 事件处理 -----
|
||||
function handleAim(e) {
|
||||
e.preventDefault();
|
||||
if (!gameActive) return;
|
||||
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
|
||||
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scaleX = CW / rect.width;
|
||||
const scaleY = CH / rect.height;
|
||||
const canvasX = (clientX - rect.left) * scaleX;
|
||||
const canvasY = (clientY - rect.top) * scaleY;
|
||||
|
||||
aimAngle = Math.atan2(canvasY - TOWER_Y, canvasX - TOWER_X);
|
||||
}
|
||||
|
||||
function handleShoot(e) {
|
||||
e.preventDefault();
|
||||
if (!gameActive) return;
|
||||
shootBullet();
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
canvas.addEventListener('touchmove', handleAim, { passive: false });
|
||||
canvas.addEventListener('touchstart', (e) => {
|
||||
handleAim(e);
|
||||
handleShoot(e);
|
||||
});
|
||||
canvas.addEventListener('mousemove', (e) => {
|
||||
if (e.buttons === 1) handleAim(e);
|
||||
});
|
||||
canvas.addEventListener('mousedown', (e) => {
|
||||
handleAim(e);
|
||||
handleShoot(e);
|
||||
});
|
||||
|
||||
document.getElementById('restartButton').addEventListener('click', restartGame);
|
||||
document.getElementById('restartButton').addEventListener('touchstart', (e) => {
|
||||
e.preventDefault();
|
||||
restartGame();
|
||||
});
|
||||
}
|
||||
|
||||
bindEvents();
|
||||
restartGame();
|
||||
gameLoop();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,227 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="description" content="DNFS 树状笔记:以树状结构组织知识的笔记社区,支持 Markdown、链接卡片、图片与文件分享。">
|
||||
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#fdf6ee">
|
||||
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#1e1712">
|
||||
<link rel="icon" href="favicon.ico">
|
||||
<link rel="stylesheet" href="css/theme.css">
|
||||
<link rel="stylesheet" href="css/index.css">
|
||||
<title>DNFS 树状笔记 — 让知识自然成体系</title>
|
||||
</head>
|
||||
<body>
|
||||
<header class="header">
|
||||
<div class="wrap">
|
||||
<div class="header-inner">
|
||||
<h1 id="logo">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="10.51" x2="15.42" y2="6.51"/><line x1="8.59" y1="13.49" x2="15.42" y2="17.49"/></svg>
|
||||
<span>DNFS 树状笔记</span>
|
||||
</h1>
|
||||
<nav class="right-group" aria-label="用户操作">
|
||||
<img src="favicon.ico" alt="用户头像" id="avatar" style="display:none">
|
||||
<b id="name" style="display:none"></b>
|
||||
<button id="enterBtn" class="btn btn-primary" style="display:none">进入笔记</button>
|
||||
<button id="logoutBtn" class="btn btn-ghost" style="display:none">退出</button>
|
||||
<button id="loginBtn" class="btn btn-primary">登录</button>
|
||||
<button id="registerBtn" class="btn btn-ghost">注册</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="main-page">
|
||||
<!-- ===== Hero ===== -->
|
||||
<section class="hero">
|
||||
<div class="hero-bg" aria-hidden="true">
|
||||
<svg class="hero-tree" viewBox="0 0 800 440" fill="none">
|
||||
<defs>
|
||||
<radialGradient id="glow" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stop-color="currentColor" stop-opacity="0.18"/>
|
||||
<stop offset="100%" stop-color="currentColor" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<circle cx="400" cy="210" r="180" fill="url(#glow)"/>
|
||||
<g class="tree-branches" stroke="currentColor" stroke-linecap="round">
|
||||
<path pathLength="100" d="M400 400 L400 295" stroke-width="2.5"/>
|
||||
<path pathLength="100" d="M400 295 C 365 265, 310 240, 260 205" stroke-width="2"/>
|
||||
<path pathLength="100" d="M400 295 C 435 265, 490 240, 540 205" stroke-width="2"/>
|
||||
<path pathLength="100" d="M260 205 C 238 178, 210 152, 185 120" stroke-width="1.5"/>
|
||||
<path pathLength="100" d="M260 205 C 278 178, 300 152, 318 120" stroke-width="1.5"/>
|
||||
<path pathLength="100" d="M540 205 C 518 178, 496 152, 478 120" stroke-width="1.5"/>
|
||||
<path pathLength="100" d="M540 205 C 562 178, 590 152, 615 120" stroke-width="1.5"/>
|
||||
<path pathLength="100" d="M185 120 C 172 100, 160 82, 150 60" stroke-width="1"/>
|
||||
<path pathLength="100" d="M318 120 C 328 100, 338 82, 345 60" stroke-width="1"/>
|
||||
<path pathLength="100" d="M478 120 C 468 100, 458 82, 452 60" stroke-width="1"/>
|
||||
<path pathLength="100" d="M615 120 C 628 100, 640 82, 650 60" stroke-width="1"/>
|
||||
</g>
|
||||
<g class="tree-nodes" fill="currentColor">
|
||||
<circle cx="400" cy="400" r="7" opacity=".9"/>
|
||||
<circle cx="400" cy="295" r="5.5"/>
|
||||
<circle cx="260" cy="205" r="5"/>
|
||||
<circle cx="540" cy="205" r="5"/>
|
||||
<circle cx="185" cy="120" r="4"/>
|
||||
<circle cx="318" cy="120" r="4"/>
|
||||
<circle cx="478" cy="120" r="4"/>
|
||||
<circle cx="615" cy="120" r="4"/>
|
||||
<circle cx="150" cy="60" r="3" opacity=".65"/>
|
||||
<circle cx="345" cy="60" r="3" opacity=".65"/>
|
||||
<circle cx="452" cy="60" r="3" opacity=".65"/>
|
||||
<circle cx="650" cy="60" r="3" opacity=".65"/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="wrap hero-inner">
|
||||
<p class="hero-badge"><span class="badge-dot"></span>树状结构 · Markdown · 多媒体分享</p>
|
||||
<h2 class="hero-title">让知识像树一样<br><span class="accent-text">自然生长</span></h2>
|
||||
<p class="hero-sub">每个节点都可以生长出子节点,像树枝一样层层延展<br>让零散的想法汇聚成体系</p>
|
||||
<div class="hero-actions">
|
||||
<button class="btn btn-primary btn-lg" data-cta>开始记录</button>
|
||||
<a class="btn btn-ghost btn-lg" href="#features">了解更多</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a class="scroll-hint" href="#features" aria-label="下滑了解特性">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
</a>
|
||||
</section>
|
||||
|
||||
<!-- ===== 核心特性 ===== -->
|
||||
<section id="features" class="section features-section">
|
||||
<div class="wrap">
|
||||
<p class="section-eyebrow">核心特性</p>
|
||||
<h2 class="section-title">为什么选择树状笔记</h2>
|
||||
<p class="section-sub">从灵感到体系,让知识在树上自然生长</p>
|
||||
<div class="features">
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="10.51" x2="15.42" y2="6.51"/><line x1="8.59" y1="13.49" x2="15.42" y2="17.49"/></svg>
|
||||
</div>
|
||||
<div class="feature-body">
|
||||
<h3>树状结构组织</h3>
|
||||
<p>每个节点都能生长出子节点,像树枝般层层延展,让零散的想法自然汇聚成体系。</p>
|
||||
</div>
|
||||
<span class="feature-tag">无限层级</span>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
|
||||
</div>
|
||||
<div class="feature-body">
|
||||
<h3>Markdown 写作</h3>
|
||||
<p>原生支持 Markdown 渲染,代码块、引用、列表一应俱全,专注内容本身。</p>
|
||||
</div>
|
||||
<span class="feature-tag">专注写作</span>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
|
||||
</div>
|
||||
<div class="feature-body">
|
||||
<h3>多媒体分享</h3>
|
||||
<p>链接卡片、图片直显、文件下载,多种内容形态一站承载,分享更自由。</p>
|
||||
</div>
|
||||
<span class="feature-tag">自由分享</span>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
|
||||
</div>
|
||||
<div class="feature-body">
|
||||
<h3>深色模式</h3>
|
||||
<p>跟随系统偏好自动切换明暗主题,深夜记录也不刺眼,日夜皆宜。</p>
|
||||
</div>
|
||||
<span class="feature-tag">日夜皆宜</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== 关于本站 ===== -->
|
||||
<section class="section about-section">
|
||||
<div class="wrap">
|
||||
<p class="section-eyebrow">我们的理念</p>
|
||||
<h2 class="section-title">关于本站</h2>
|
||||
<div class="about-panel">
|
||||
<svg class="about-deco" viewBox="0 0 200 200" fill="none" aria-hidden="true">
|
||||
<circle cx="100" cy="100" r="90" stroke="currentColor" stroke-width=".6" opacity=".15"/>
|
||||
<circle cx="100" cy="100" r="60" stroke="currentColor" stroke-width=".5" opacity=".10"/>
|
||||
<circle cx="100" cy="100" r="30" stroke="currentColor" stroke-width=".4" opacity=".08"/>
|
||||
</svg>
|
||||
<p class="site-intro">
|
||||
DNFS 树状笔记是一个以树状结构组织内容的笔记社区:每个节点都可以生长出子节点,
|
||||
像树枝一样层层延展,让知识自然成体系。支持 Markdown 文本、链接卡片、图片与文件分享,
|
||||
注册登录后即可创建属于你自己的知识树。
|
||||
</p>
|
||||
<ul class="about-stats">
|
||||
<li><b>∞</b><span>节点层级无上限</span></li>
|
||||
<li><b>MD</b><span>原生 Markdown 渲染</span></li>
|
||||
<li><b>4+</b><span>内容形态一站承载</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
<nav class="quick-links" aria-label="快捷链接">
|
||||
<a href="allcolor.html">调色板</a>
|
||||
<a href="games/">小游戏</a>
|
||||
<a href="LICENSE.html">用户使用条例</a>
|
||||
</nav>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== 结尾 CTA ===== -->
|
||||
<section class="section cta-section" aria-label="立即开始">
|
||||
<div class="wrap">
|
||||
<div class="cta-band">
|
||||
<svg class="cta-tree" viewBox="0 0 400 200" fill="none" aria-hidden="true">
|
||||
<g stroke="currentColor" stroke-width="1.2" stroke-linecap="round" opacity=".6">
|
||||
<path d="M200 190 L200 140"/>
|
||||
<path d="M200 140 C 175 120, 145 105, 120 85"/>
|
||||
<path d="M200 140 C 225 120, 255 105, 280 85"/>
|
||||
<path d="M120 85 C 105 65, 90 50, 80 30"/>
|
||||
<path d="M120 85 C 135 65, 148 50, 155 30"/>
|
||||
<path d="M280 85 C 265 65, 252 50, 245 30"/>
|
||||
<path d="M280 85 C 295 65, 310 50, 320 30"/>
|
||||
</g>
|
||||
<g fill="currentColor" opacity=".5">
|
||||
<circle cx="200" cy="190" r="4"/>
|
||||
<circle cx="200" cy="140" r="3"/>
|
||||
<circle cx="120" cy="85" r="2.5"/>
|
||||
<circle cx="280" cy="85" r="2.5"/>
|
||||
<circle cx="80" cy="30" r="2"/>
|
||||
<circle cx="155" cy="30" r="2"/>
|
||||
<circle cx="245" cy="30" r="2"/>
|
||||
<circle cx="320" cy="30" r="2"/>
|
||||
</g>
|
||||
</svg>
|
||||
<h2 class="cta-title">准备好让知识生长了吗?</h2>
|
||||
<p class="cta-sub">注册一个账号,种下你的第一棵知识树</p>
|
||||
<button class="btn btn-cta btn-lg" data-cta>立即开始</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<div class="wrap">
|
||||
<div class="footer-inner">
|
||||
<div class="footer-brand">
|
||||
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="10.51" x2="15.42" y2="6.51"/><line x1="8.59" y1="13.49" x2="15.42" y2="17.49"/></svg>
|
||||
<span>DNFS 树状笔记</span>
|
||||
</div>
|
||||
<nav class="footer-nav" aria-label="页脚导航">
|
||||
<a href="dnfs.html">树状笔记</a>
|
||||
<a href="allcolor.html">调色板</a>
|
||||
<a href="games/">小游戏</a>
|
||||
<a href="LICENSE.html">用户使用条例</a>
|
||||
</nav>
|
||||
<p class="footer-icp">
|
||||
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener">蜀ICP备2024107064号-2</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<script src="js/token_manager.js"></script>
|
||||
<script src="js/index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,435 +0,0 @@
|
||||
'use strict';
|
||||
/* ============================================================
|
||||
* aibox Web 客户端 · 移动端版(经 gateway WS 反向代理,仅管理员可用)
|
||||
* 协议与 BotOps/aibox client.py 完全兼容:
|
||||
* 发送:{"type":"message","content":...} / {"type":"command","command":"/..."} / {"type":"interrupt",...}
|
||||
* 接收:chunk(增量片段) / reasoning_start / reasoning / reasoning_end / ai_break /
|
||||
* complete / line(过程行) / task_process / task_status / task_error / task_added /
|
||||
* dialogue_ended / dialogue_reset / interrupt_ack / error / info /
|
||||
* command_result / connection_ack / notification / pong
|
||||
* ============================================================ */
|
||||
|
||||
/* gateway [WsProxy] routes 中配置的路径(HttpOnly q4_token Cookie 随握手自动携带,JS 不经手 Token)。
|
||||
* 需与 gateway.ini 的 [WsProxy] routes 条目一致,如:routes = /ws/aibox=ws://127.0.0.1:8765; */
|
||||
const WS_URL = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws/aibox';
|
||||
const RECONNECT_MIN = 2000; /* 指数退避起点(2s→30s 封顶) */
|
||||
const RECONNECT_MAX = 30000;
|
||||
const REASONING_TAIL = 3000; /* 思考区域超长时仅显示末尾字符数 */
|
||||
|
||||
/* ---------- Markdown 渲染(链接一律新标签打开且不带 referrer) ---------- */
|
||||
const md = window.markdownit({ html: false, linkify: true, breaks: true });
|
||||
const defaultLinkOpen = md.renderer.rules.link_open || ((tokens, idx, options, env, self) => self.renderToken(tokens, idx, options));
|
||||
md.renderer.rules.link_open = (tokens, idx, options, env, self) => {
|
||||
tokens[idx].attrSet('target', '_blank');
|
||||
tokens[idx].attrSet('rel', 'noopener noreferrer');
|
||||
return defaultLinkOpen(tokens, idx, options, env, self);
|
||||
};
|
||||
|
||||
/* ---------- DOM ---------- */
|
||||
const chatEl = document.getElementById('chat');
|
||||
const inputEl = document.getElementById('input');
|
||||
const sendBtn = document.getElementById('send-btn');
|
||||
const interruptBtn = document.getElementById('interrupt-btn');
|
||||
const connBtn = document.getElementById('conn-btn');
|
||||
const statusEl = document.getElementById('conn-status');
|
||||
const statusText = document.getElementById('status-text');
|
||||
const taskPanel = document.getElementById('task-panel');
|
||||
const taskList = document.getElementById('task-list');
|
||||
const taskToggle = document.getElementById('task-toggle');
|
||||
const taskBadge = document.getElementById('task-badge');
|
||||
const backdrop = document.getElementById('backdrop');
|
||||
|
||||
/* ---------- 状态 ---------- */
|
||||
let ws = null;
|
||||
let manualClose = false; /* 手动断开则抑制自动重连 */
|
||||
let reconnectDelay = RECONNECT_MIN;
|
||||
let reconnectTimer = null;
|
||||
let heartbeatTimer = null; /* 心跳定时器,10s 发送 ping 保持 WS 连接 */
|
||||
const HEARTBEAT_INTERVAL = 10000; /* 心跳间隔:需低于前置代理(Nginx/CDN)的 proxy_read_timeout */
|
||||
|
||||
function startHeartbeat() {
|
||||
stopHeartbeat();
|
||||
heartbeatTimer = setInterval(() => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'ping' }));
|
||||
}
|
||||
}, HEARTBEAT_INTERVAL);
|
||||
}
|
||||
|
||||
function stopHeartbeat() {
|
||||
if (heartbeatTimer) { clearTimeout(heartbeatTimer); heartbeatTimer = null; }
|
||||
}
|
||||
|
||||
/* 流式状态:不预创建空气泡,第一个 chunk 到来才挂载(保证思考区必然在正式回答之前) */
|
||||
let aiBubble = null, aiText = '', aiActive = false;
|
||||
let reasoningBox = null, reasoningBody = null, reasoningHead = null, reasoningText = '';
|
||||
let unreadTasks = 0;
|
||||
|
||||
/* ---------- 滚动(距底 80px 内才自动跟随,用户上翻阅读不打断) ---------- */
|
||||
function nearBottom() {
|
||||
return chatEl.scrollHeight - chatEl.scrollTop - chatEl.clientHeight < 80;
|
||||
}
|
||||
function scrollToBottom(force) {
|
||||
if (force || nearBottom()) chatEl.scrollTop = chatEl.scrollHeight;
|
||||
}
|
||||
|
||||
/* ---------- 系统消息 / 任务面板行 ---------- */
|
||||
function addSystem(text, isError) {
|
||||
const div = document.createElement('div');
|
||||
div.className = isError ? 'msg msg-error' : 'msg msg-system';
|
||||
div.textContent = text;
|
||||
chatEl.appendChild(div);
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
function addTaskLine(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
taskList.appendChild(div);
|
||||
taskList.scrollTop = taskList.scrollHeight;
|
||||
if (!taskPanel.classList.contains('open')) {
|
||||
unreadTasks += 1;
|
||||
taskBadge.style.display = '';
|
||||
taskBadge.textContent = unreadTasks > 99 ? '99+' : String(unreadTasks);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 消息气泡 ---------- */
|
||||
function addUserBubble(text) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'msg msg-user';
|
||||
div.textContent = text;
|
||||
chatEl.appendChild(div);
|
||||
scrollToBottom(true);
|
||||
}
|
||||
|
||||
function ensureAiBubble() {
|
||||
if (aiBubble) return;
|
||||
aiBubble = document.createElement('div');
|
||||
aiBubble.className = 'msg msg-ai streaming';
|
||||
chatEl.appendChild(aiBubble);
|
||||
scrollToBottom(true);
|
||||
}
|
||||
|
||||
/* 服务端 chunk 为增量片段(发送后即清零),直接拼接 */
|
||||
function appendAi(delta) {
|
||||
if (!delta || aiActive === false) return;
|
||||
ensureAiBubble();
|
||||
aiText += delta;
|
||||
aiBubble.innerHTML = md.render(aiText);
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
/* complete 为收尾信号:chunk 流式是内容权威来源,仅异常丢帧时用 full_text 补建 */
|
||||
function finishAi(fullText) {
|
||||
if (aiActive === false && !aiBubble && !reasoningBox) return;
|
||||
if (!aiBubble && fullText) {
|
||||
ensureAiBubble();
|
||||
aiText = fullText;
|
||||
aiBubble.innerHTML = md.render(aiText);
|
||||
}
|
||||
if (aiBubble) aiBubble.classList.remove('streaming');
|
||||
aiBubble = null; aiText = ''; aiActive = false;
|
||||
reasoningBox = null; reasoningBody = null; reasoningHead = null; reasoningText = '';
|
||||
setStreaming(false);
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
/* ---------- 思考区域(可折叠) ---------- */
|
||||
function startReasoning(hint) {
|
||||
if (reasoningBox) return;
|
||||
reasoningText = '';
|
||||
reasoningBox = document.createElement('div');
|
||||
reasoningBox.className = 'msg msg-reasoning';
|
||||
reasoningHead = document.createElement('div');
|
||||
reasoningHead.className = 'reasoning-head';
|
||||
reasoningHead.textContent = '💭 ' + (hint || '思考中...');
|
||||
reasoningBody = document.createElement('div');
|
||||
reasoningBody.className = 'reasoning-body';
|
||||
reasoningHead.addEventListener('click', () => reasoningBody.classList.toggle('folded'));
|
||||
reasoningBox.appendChild(reasoningHead);
|
||||
reasoningBox.appendChild(reasoningBody);
|
||||
chatEl.appendChild(reasoningBox);
|
||||
scrollToBottom(true);
|
||||
}
|
||||
function appendReasoning(delta) {
|
||||
if (!delta) return;
|
||||
if (aiActive === false) return; /* 已收尾(中断/完成),丢弃残留片段 */
|
||||
if (!reasoningBox) startReasoning();
|
||||
reasoningText += delta;
|
||||
reasoningBody.textContent = reasoningText.length > REASONING_TAIL
|
||||
? '...(前略)\n' + reasoningText.slice(-REASONING_TAIL)
|
||||
: reasoningText;
|
||||
scrollToBottom();
|
||||
}
|
||||
function endReasoning() {
|
||||
if (reasoningHead && reasoningText) {
|
||||
reasoningHead.textContent = '💭 思考过程(' + reasoningText.length + ' 字符,点击折叠/展开)';
|
||||
reasoningBody.classList.add('folded'); /* 结束后默认折叠,避免占屏 */
|
||||
}
|
||||
/* 清除引用(DOM元素保留显示),下一轮 reasoning_start 创建新的思考区域 */
|
||||
reasoningBox = null; reasoningHead = null; reasoningBody = null; reasoningText = '';
|
||||
}
|
||||
function abortReasoning() {
|
||||
/* 用户中断:更新思考区域标签为已中断,保留已展示内容 */
|
||||
if (reasoningHead) {
|
||||
if (reasoningText) {
|
||||
reasoningHead.textContent = '🛑 思考已中断(' + reasoningText.length + ' 字符)';
|
||||
} else {
|
||||
reasoningHead.textContent = '🛑 思考已中断';
|
||||
}
|
||||
}
|
||||
/* 清除引用,DOM元素保留显示 */
|
||||
reasoningBox = null; reasoningHead = null; reasoningBody = null; reasoningText = '';
|
||||
}
|
||||
|
||||
/* ---------- AI 文本分段(工具调用时 breakAi,后续 chunk 创建新气泡) ---------- */
|
||||
function breakAi() {
|
||||
if (aiBubble) {
|
||||
aiBubble.classList.remove('streaming');
|
||||
aiBubble = null; aiText = '';
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 连接状态 ---------- */
|
||||
function setStatus(cls, text) {
|
||||
statusEl.className = cls;
|
||||
statusText.textContent = text;
|
||||
}
|
||||
function setStreaming(on) {
|
||||
interruptBtn.style.display = on ? '' : 'none';
|
||||
sendBtn.disabled = false; /* 流式期间仍可发送(服务端排队),仅中断按钮显隐 */
|
||||
}
|
||||
|
||||
/* ---------- WebSocket 生命周期(指数退避自动重连,手动断开抑制) ---------- */
|
||||
function connect() {
|
||||
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return;
|
||||
clearTimeout(reconnectTimer);
|
||||
manualClose = false;
|
||||
setStatus('connecting', '连接中');
|
||||
try {
|
||||
ws = new WebSocket(WS_URL);
|
||||
} catch (e) {
|
||||
addSystem('连接失败:' + e.message, true);
|
||||
scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
ws.onopen = () => {
|
||||
reconnectDelay = RECONNECT_MIN;
|
||||
setStatus('connected', '已连接');
|
||||
connBtn.textContent = '断开';
|
||||
connBtn.classList.add('on');
|
||||
startHeartbeat();
|
||||
};
|
||||
ws.onmessage = (ev) => {
|
||||
let data;
|
||||
try { data = JSON.parse(ev.data); }
|
||||
catch (e) { data = { type: 'chunk', content: ev.data }; }
|
||||
routeMessage(data);
|
||||
};
|
||||
ws.onclose = () => {
|
||||
stopHeartbeat();
|
||||
setStatus('disconnected', '已断开');
|
||||
connBtn.textContent = '连接';
|
||||
connBtn.classList.remove('on');
|
||||
finishAi('');
|
||||
ws = null;
|
||||
if (!manualClose) scheduleReconnect();
|
||||
};
|
||||
ws.onerror = () => { /* onclose 随后触发,统一在 onclose 处理 */ };
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (manualClose) return;
|
||||
setStatus('connecting', Math.round(reconnectDelay / 1000) + 's 后重连');
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX);
|
||||
connect();
|
||||
}, reconnectDelay);
|
||||
}
|
||||
|
||||
function disconnect(manual) {
|
||||
manualClose = manual !== false;
|
||||
clearTimeout(reconnectTimer);
|
||||
stopHeartbeat();
|
||||
if (ws) { ws.close(); ws = null; }
|
||||
setStatus('disconnected', manualClose ? '已手动断开' : '已断开');
|
||||
connBtn.textContent = '连接';
|
||||
connBtn.classList.remove('on');
|
||||
}
|
||||
|
||||
/* ---------- aibox 协议消息路由 ---------- */
|
||||
function routeMessage(data) {
|
||||
const t = data.type || '';
|
||||
const content = typeof data.content === 'string' ? data.content : (data.content == null ? '' : String(data.content));
|
||||
|
||||
if (t === 'chunk') {
|
||||
appendAi(content);
|
||||
} else if (t === 'reasoning_start') {
|
||||
startReasoning(content || '深度思考中...');
|
||||
} else if (t === 'reasoning') {
|
||||
appendReasoning(content);
|
||||
} else if (t === 'reasoning_end') {
|
||||
endReasoning();
|
||||
} else if (t === 'ai_break') {
|
||||
/* 工具调用前分段:结束当前AI文本段(保留已显示内容),后续chunk创建新气泡 */
|
||||
breakAi();
|
||||
} else if (t === 'line') {
|
||||
/* 过程行:工具调用提示 / 工具输出 → 任务面板;助手前缀 / 思考提示等聊天区已有承载,跳过 */
|
||||
const stripped = content.trim();
|
||||
if (!stripped) return;
|
||||
if (/^(💬 助手|💬 Assistant|🤔 思考中|🤔 Thinking|🤔 深度思考中|🤔 分析工具结果|🤔 Analyzing|🔧 需要调用工具|🔧 Need to call)/.test(stripped)) return;
|
||||
addTaskLine(stripped);
|
||||
} else if (t === 'complete') {
|
||||
finishAi(content);
|
||||
} else if (t === 'dialogue_ended') {
|
||||
finishAi(aiText);
|
||||
if (content) addSystem('✅ ' + content);
|
||||
} else if (t === 'task_added') {
|
||||
addSystem('➕ ' + content);
|
||||
} else if (t === 'dialogue_reset') {
|
||||
if (content) addSystem('🔄 ' + content);
|
||||
} else if (t === 'pong') {
|
||||
/* 心跳响应,忽略 */
|
||||
} else if (t === 'task_process') {
|
||||
const sub = data.msg_type || '';
|
||||
const title = data.task_title || '未知任务';
|
||||
if (sub === 'chunk') {
|
||||
const tail = content.slice(-80);
|
||||
if (tail.trim()) addTaskLine('⚡ ' + title + ': ' + tail);
|
||||
} else if (sub === 'complete') {
|
||||
addTaskLine('✅ 任务完成: ' + title);
|
||||
}
|
||||
} else if (t === 'task_status') {
|
||||
const icon = { executing: '⚙️', complete: '✅', failed: '❌', added: '➕', batch_start: '🚀', batch_complete: '🏁' }[data.status] || '📋';
|
||||
addTaskLine(icon + ' [' + (data.task_id || '?') + '] ' + (data.title || '') + ': ' + (data.message || content));
|
||||
} else if (t === 'task_error') {
|
||||
addTaskLine('❌ [' + (data.task_id || '?') + '] ' + (data.task_title || '?') + ': ' + (data.error || '未知错误'));
|
||||
} else if (t === 'interrupt_ack') {
|
||||
/* 服务端确认中断:立即收尾前端状态 */
|
||||
abortReasoning();
|
||||
finishAi('');
|
||||
addSystem('🛑 ' + (content || '已中断'));
|
||||
} else if (t === 'error') {
|
||||
addSystem('❌ ' + content, true);
|
||||
} else if (t === 'info') {
|
||||
/* 过滤隐式通知(对话开始等),避免污染聊天流 */
|
||||
if (!/(Conversation started|对话开始|🎭)/.test(content)) addSystem('ℹ️ ' + content);
|
||||
} else if (t === 'command_result') {
|
||||
addSystem(content);
|
||||
} else if (t === 'connection_ack') {
|
||||
addSystem('✅ ' + content);
|
||||
} else if (t === 'notification') {
|
||||
addSystem('🔔 ' + content);
|
||||
} else if (content) {
|
||||
/* 未知消息类型不追加到AI聊天区(避免非AI内容污染回复),记入任务面板 */
|
||||
addTaskLine('[未知消息 type=' + t + '] ' + content.slice(0, 200));
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 发送 ---------- */
|
||||
function wsSend(obj) {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||
addSystem('未连接到服务器', true);
|
||||
return false;
|
||||
}
|
||||
ws.send(JSON.stringify(obj));
|
||||
return true;
|
||||
}
|
||||
|
||||
/* 本地命令表:命中则本地处理,其余 / 命令透传服务器 */
|
||||
const LOCAL_COMMANDS = {
|
||||
'/clear'() {
|
||||
chatEl.replaceChildren();
|
||||
finishAi('');
|
||||
},
|
||||
'/help'() {
|
||||
addSystem('本地命令:/clear 清屏 · /connect 连接 · /disconnect 断开 · /interrupt 中断 · 其余 / 命令透传服务器');
|
||||
},
|
||||
'/connect'() { connect(); },
|
||||
'/disconnect'() { disconnect(true); },
|
||||
'/interrupt'() { sendInterrupt(); },
|
||||
};
|
||||
|
||||
function sendCurrentInput() {
|
||||
const text = inputEl.value.trim();
|
||||
if (!text) return;
|
||||
if (text.startsWith('/')) {
|
||||
const local = LOCAL_COMMANDS[text.split(/\s/)[0]];
|
||||
if (local) {
|
||||
local();
|
||||
inputEl.value = '';
|
||||
autoResize();
|
||||
return;
|
||||
}
|
||||
}
|
||||
/* 与 BotOps 一致:/ 开头按 command 发送,否则 message */
|
||||
const data = text.startsWith('/')
|
||||
? { type: 'command', command: text }
|
||||
: { type: 'message', content: text };
|
||||
if (!wsSend(data)) return;
|
||||
addUserBubble(text);
|
||||
aiActive = true; /* 开始接收本轮 AI 流 */
|
||||
setStreaming(true);
|
||||
inputEl.value = '';
|
||||
autoResize();
|
||||
inputEl.focus();
|
||||
}
|
||||
|
||||
function sendInterrupt() {
|
||||
wsSend({ type: 'interrupt', content: '用户按下了 Ctrl+C 中断当前操作' });
|
||||
}
|
||||
|
||||
/* ---------- 输入区自适应高度 ---------- */
|
||||
function autoResize() {
|
||||
inputEl.style.height = 'auto';
|
||||
inputEl.style.height = Math.min(inputEl.scrollHeight, 120) + 'px';
|
||||
}
|
||||
inputEl.addEventListener('input', autoResize);
|
||||
inputEl.addEventListener('keydown', (e) => {
|
||||
/* Enter 发送,Shift+Enter 换行;isComposing 防中文输入法选词误发 */
|
||||
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
|
||||
e.preventDefault();
|
||||
sendCurrentInput();
|
||||
}
|
||||
});
|
||||
/* 软键盘弹出动画结束后保持聊天区停留底部 */
|
||||
inputEl.addEventListener('focus', () => setTimeout(() => scrollToBottom(true), 300));
|
||||
sendBtn.addEventListener('click', sendCurrentInput);
|
||||
interruptBtn.addEventListener('click', sendInterrupt);
|
||||
|
||||
/* ---------- 连接按钮与任务面板(抽屉开关) ---------- */
|
||||
connBtn.addEventListener('click', () => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) disconnect(true);
|
||||
else connect();
|
||||
});
|
||||
|
||||
function openTaskPanel(open) {
|
||||
taskPanel.classList.toggle('open', open);
|
||||
backdrop.classList.toggle('show', open);
|
||||
if (open) {
|
||||
unreadTasks = 0;
|
||||
taskBadge.style.display = 'none';
|
||||
}
|
||||
}
|
||||
taskToggle.addEventListener('click', () => openTaskPanel(!taskPanel.classList.contains('open')));
|
||||
document.getElementById('task-close').addEventListener('click', () => openTaskPanel(false));
|
||||
backdrop.addEventListener('click', () => openTaskPanel(false));
|
||||
|
||||
/* ---------- 启动:管理员预检(WS 握手由 gateway 强制 is_admin,此处仅提示) ---------- */
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch('/whoami', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
|
||||
const data = res.ok ? await res.json() : null;
|
||||
const tip = document.getElementById('admin-tip');
|
||||
if (!data || !data.login) {
|
||||
tip.textContent = '未登录:aibox 仅管理员可用,请先登录管理员账号';
|
||||
tip.style.display = '';
|
||||
} else if (!data.is_admin) {
|
||||
tip.textContent = '当前账号不是管理员,连接将被网关拒绝';
|
||||
tip.style.display = '';
|
||||
}
|
||||
} catch (e) { /* 预检失败不阻塞,由 WS 握手兜底 */ }
|
||||
connect();
|
||||
})();
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1,103 +0,0 @@
|
||||
/* index.js — 社区首页逻辑
|
||||
*
|
||||
* 登录态检测:POST /whoami(HttpOnly q4_token Cookie 自动携带,JS 不经手 Token)
|
||||
* 已登录:显示头像/用户名与"进入笔记/退出"按钮,首屏 CTA 指向笔记页
|
||||
* 未登录:显示"登录/注册",首屏 CTA 指向登录页
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const nameEl = document.getElementById('name');
|
||||
const avatarEl = document.getElementById('avatar');
|
||||
const enterBtn = document.getElementById('enterBtn');
|
||||
const logoutBtn = document.getElementById('logoutBtn');
|
||||
const loginBtn = document.getElementById('loginBtn');
|
||||
const registerBtn = document.getElementById('registerBtn');
|
||||
const ctaBtns = document.querySelectorAll('[data-cta]'); // 首屏与页尾 CTA 按钮
|
||||
const logoEl = document.getElementById('logo');
|
||||
|
||||
let loggedIn = false;
|
||||
|
||||
function go(url) { window.location.href = url; }
|
||||
|
||||
logoEl.addEventListener('click', () => go('index.html'));
|
||||
enterBtn.addEventListener('click', () => go('dnfs.html'));
|
||||
loginBtn.addEventListener('click', () => go('login.html'));
|
||||
registerBtn.addEventListener('click', () => go('register.html'));
|
||||
ctaBtns.forEach(function (btn) {
|
||||
btn.addEventListener('click', () => go(loggedIn ? 'dnfs.html' : 'login.html'));
|
||||
});
|
||||
|
||||
async function checkLogin() {
|
||||
try {
|
||||
const res = await fetch('/whoami', { method: 'POST' });
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
if (data && data.login && data.user) {
|
||||
loggedIn = true;
|
||||
nameEl.textContent = String(data.user);
|
||||
nameEl.style.display = '';
|
||||
avatarEl.style.display = '';
|
||||
enterBtn.style.display = '';
|
||||
logoutBtn.style.display = '';
|
||||
loginBtn.style.display = 'none';
|
||||
registerBtn.style.display = 'none';
|
||||
ctaBtns.forEach(function (btn) { btn.textContent = '进入我的知识树'; });
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('index: 登录态检测失败', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
logoutBtn.addEventListener('click', async function () {
|
||||
try {
|
||||
await fetch('/logout', { method: 'POST' });
|
||||
} catch (e) {
|
||||
console.warn('index: 退出登录请求异常', e.message);
|
||||
}
|
||||
location.reload();
|
||||
});
|
||||
|
||||
checkLogin();
|
||||
})();
|
||||
|
||||
/* ===== 视觉增强:滚动渐显 + 卡片光标光斑(装饰性,与登录逻辑解耦) ===== */
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const reduceMotion = !window.matchMedia('(prefers-reduced-motion: no-preference)').matches;
|
||||
|
||||
/* 滚动进入视口渐显:由 JS 添加 .reveal 隐藏初始态,无 JS / 减弱动效时内容天然可见 */
|
||||
if (!reduceMotion && 'IntersectionObserver' in window) {
|
||||
const targets = document.querySelectorAll('.section-eyebrow, .section-title, .section-sub, .feature-card, .site-intro, .about-stats, .quick-links, .cta-band');
|
||||
targets.forEach(function (el, i) {
|
||||
el.classList.add('reveal');
|
||||
el.style.transitionDelay = (i % 4) * 70 + 'ms';
|
||||
});
|
||||
const io = new IntersectionObserver(function (entries) {
|
||||
entries.forEach(function (entry) {
|
||||
if (!entry.isIntersecting) return;
|
||||
const el = entry.target;
|
||||
el.classList.add('reveal-in');
|
||||
io.unobserve(el);
|
||||
/* 动画结束后移除类与延迟,把 transition 控制权交还 hover 等交互样式 */
|
||||
setTimeout(function () {
|
||||
el.classList.remove('reveal', 'reveal-in');
|
||||
el.style.transitionDelay = '';
|
||||
}, 900);
|
||||
});
|
||||
}, { threshold: 0.15, rootMargin: '0px 0px -8% 0px' });
|
||||
targets.forEach(function (el) { io.observe(el); });
|
||||
}
|
||||
|
||||
/* 特性卡片光标光斑:鼠标位置写入 CSS 变量,由 index.css 径向渐变消费(仅精确指针设备) */
|
||||
if (window.matchMedia('(hover: hover) and (pointer: fine)').matches) {
|
||||
document.querySelectorAll('.feature-card').forEach(function (card) {
|
||||
card.addEventListener('mousemove', function (e) {
|
||||
const rect = card.getBoundingClientRect();
|
||||
card.style.setProperty('--mx', (e.clientX - rect.left) + 'px');
|
||||
card.style.setProperty('--my', (e.clientY - rect.top) + 'px');
|
||||
});
|
||||
});
|
||||
}
|
||||
})();
|
||||
@@ -1,153 +0,0 @@
|
||||
const msgTip = document.getElementById('msgTip');
|
||||
const pwdTip = document.getElementById('pwdTip');
|
||||
const agreeTip = document.getElementById('agreeTip');
|
||||
const captchaTip = document.getElementById('captchaTip');
|
||||
const form = document.getElementById('form');
|
||||
const usernameInput = document.getElementById('loginUsername');
|
||||
const passwordInput = document.getElementById('loginPassword');
|
||||
const captchaInput = document.getElementById('captchaAnswer');
|
||||
const captchaImg = document.getElementById('captchaImg');
|
||||
const agreeBox = document.getElementById('agreeBox');
|
||||
const resetUser = document.getElementById('resetUser');
|
||||
const registerBtn = document.getElementById('registerBtn');
|
||||
const loginButton = document.getElementById('loginBtn');
|
||||
|
||||
/* ── 图形验证码(gateway 代理 captcha 服务:GET /captcha/image) ── */
|
||||
let captchaToken = '';
|
||||
let captchaLoading = false; // 防重复点击:加载中忽略再次触发
|
||||
|
||||
async function refreshCaptcha() {
|
||||
if (captchaLoading) return;
|
||||
captchaLoading = true;
|
||||
captchaToken = '';
|
||||
try {
|
||||
const res = await fetch('/captcha/image', { cache: 'no-store' });
|
||||
if (res.status === 403) { // WAF 拦截:body 非图片,提示后待用户点击重试
|
||||
captchaImg.removeAttribute('src');
|
||||
captchaImg.alt = '验证码加载被安全规则拦截,点击重试';
|
||||
return;
|
||||
}
|
||||
if (!res.ok) throw new Error('captcha load failed');
|
||||
captchaToken = res.headers.get('X-Captcha-Token') || '';
|
||||
const blob = await res.blob();
|
||||
if (captchaImg.src && captchaImg.src.startsWith('blob:')) URL.revokeObjectURL(captchaImg.src);
|
||||
captchaImg.src = URL.createObjectURL(blob);
|
||||
captchaImg.alt = '点击刷新';
|
||||
} catch (e) {
|
||||
captchaImg.removeAttribute('src');
|
||||
captchaImg.alt = '加载失败,点击重试';
|
||||
} finally {
|
||||
captchaLoading = false;
|
||||
}
|
||||
}
|
||||
captchaImg.addEventListener('click', refreshCaptcha);
|
||||
refreshCaptcha();
|
||||
|
||||
resetUser.addEventListener('click', function() { clearTipStyle(); });
|
||||
|
||||
function clearTipStyle() {
|
||||
msgTip.innerHTML = '';
|
||||
pwdTip.innerHTML = '';
|
||||
agreeTip.innerHTML = '';
|
||||
captchaTip.innerHTML = '';
|
||||
msgTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
|
||||
pwdTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
|
||||
agreeTip.classList.remove('tip-red');
|
||||
captchaTip.classList.remove('tip-red');
|
||||
}
|
||||
clearTipStyle();
|
||||
|
||||
function checkUsername() {
|
||||
const result = FormValidators.username(usernameInput.value);
|
||||
msgTip.textContent = result.message;
|
||||
msgTip.className = result.className;
|
||||
return result.valid ? result.value : false;
|
||||
}
|
||||
|
||||
function checkPwd() {
|
||||
const result = FormValidators.password(passwordInput.value);
|
||||
pwdTip.textContent = result.message;
|
||||
pwdTip.className = result.className;
|
||||
return result.valid ? result.value : false;
|
||||
}
|
||||
|
||||
function checkCaptcha() {
|
||||
captchaTip.innerHTML = '';
|
||||
captchaTip.classList.remove('tip-red');
|
||||
const v = captchaInput.value.trim();
|
||||
if (!v) {
|
||||
captchaTip.innerHTML = '请输入验证码!';
|
||||
captchaTip.classList.add('tip-red');
|
||||
return false;
|
||||
}
|
||||
if (!captchaToken) {
|
||||
captchaTip.innerHTML = '验证码未加载,请点击图片刷新';
|
||||
captchaTip.classList.add('tip-red');
|
||||
return false;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
function checkAgree() {
|
||||
agreeTip.innerHTML = '';
|
||||
agreeTip.classList.remove('tip-red');
|
||||
if (!agreeBox.checked) {
|
||||
agreeTip.innerHTML = '请同意用户协议!';
|
||||
agreeTip.classList.add('tip-red');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
usernameInput.addEventListener('focus', checkUsername);
|
||||
passwordInput.addEventListener('focus', checkPwd);
|
||||
usernameInput.addEventListener('input', checkUsername);
|
||||
passwordInput.addEventListener('input', checkPwd);
|
||||
usernameInput.addEventListener('blur', checkUsername);
|
||||
passwordInput.addEventListener('blur', checkPwd);
|
||||
agreeBox.addEventListener('change', checkAgree);
|
||||
registerBtn.addEventListener('click', function() { location.href = 'register.html'; });
|
||||
|
||||
form.addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
clearTipStyle();
|
||||
const userOk = checkUsername();
|
||||
const pwdOk = checkPwd();
|
||||
const captchaOk = checkCaptcha();
|
||||
const agreeOk = checkAgree();
|
||||
if (!userOk || !pwdOk || !captchaOk || !agreeOk) return;
|
||||
|
||||
const jsonBody = JSON.stringify({
|
||||
username: userOk,
|
||||
password: pwdOk,
|
||||
captcha_token: captchaToken,
|
||||
captcha_answer: captchaOk
|
||||
});
|
||||
try {
|
||||
loginButton.disabled = true;
|
||||
const res = await fetch('/login', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: jsonBody
|
||||
});
|
||||
const responseText = await res.text();
|
||||
if (window.WafTip && WafTip.isBlocked(res, responseText)) throw new Error(WafTip.MESSAGE);
|
||||
let result;
|
||||
try { result = JSON.parse(responseText); } catch { throw new Error('服务器响应格式异常'); }
|
||||
if (!res.ok) throw new Error(result.message || '登录失败');
|
||||
if (result.success) {
|
||||
// Token 已由网关通过 HttpOnly Cookie 下发,JS 不再经手
|
||||
alert('登录成功');
|
||||
location.href = '/dnfs.html';
|
||||
} else {
|
||||
alert(result.message || '登录失败');
|
||||
}
|
||||
} catch (err) {
|
||||
alert(err.message || '接口请求失败');
|
||||
} finally {
|
||||
// 无论成败都刷新验证码:Token 已被一次性消费,或需防重试
|
||||
captchaInput.value = '';
|
||||
refreshCaptcha();
|
||||
loginButton.disabled = false;
|
||||
}
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,289 +0,0 @@
|
||||
const form = document.getElementById('form');
|
||||
const emailInput = document.getElementById('regemail');
|
||||
const codeInput = document.getElementById('code');
|
||||
const getCodeButton = document.getElementById('getCode');
|
||||
const passwordInput = document.getElementById('regpsw');
|
||||
const repeatPasswordInput = document.getElementById('repwd');
|
||||
const emlTip = document.getElementById('emlTip');
|
||||
const pwdTip = document.getElementById('pwdTip');
|
||||
const codeTip = document.getElementById('codeTip');
|
||||
const captchaImg = document.getElementById('captchaImg');
|
||||
const captchaInput = document.getElementById('captchaAnswer');
|
||||
const captchaTip = document.getElementById('captchaTip');
|
||||
const submitButton = document.getElementById('formsub');
|
||||
|
||||
let codeTarget = ''; // 已发送验证码的目标邮箱(验证码本体由服务端校验,前端不经手)
|
||||
let cooldownTimer = null; // 重发倒计时(与服务端同邮箱 60s 发送冷却对齐)
|
||||
|
||||
/* ── 图形验证码(gateway 代理 captcha 服务:GET /captcha/image) ── */
|
||||
let captchaToken = '';
|
||||
let captchaLoading = false; // 防重复点击:加载中忽略再次触发
|
||||
|
||||
async function refreshCaptcha(preserveInput) {
|
||||
if (captchaLoading) return;
|
||||
captchaLoading = true;
|
||||
captchaToken = '';
|
||||
if (!preserveInput) captchaInput.value = ''; // 默认清空输入;preserveInput=true 时保留(获取验证码后)
|
||||
captchaImg.style.opacity = '0.5'; // 视觉提示:正在刷新
|
||||
try {
|
||||
const res = await fetch('/captcha/image', { cache: 'no-store' });
|
||||
if (res.status === 403) { // WAF 拦截:body 非图片,提示后待用户点击重试
|
||||
captchaImg.removeAttribute('src');
|
||||
captchaImg.alt = '验证码加载被安全规则拦截,点击重试';
|
||||
return;
|
||||
}
|
||||
if (!res.ok) throw new Error('captcha load failed');
|
||||
captchaToken = res.headers.get('X-Captcha-Token') || '';
|
||||
const blob = await res.blob();
|
||||
if (captchaImg.src && captchaImg.src.startsWith('blob:')) URL.revokeObjectURL(captchaImg.src);
|
||||
captchaImg.src = URL.createObjectURL(blob);
|
||||
captchaImg.alt = '点击刷新';
|
||||
captchaImg.style.opacity = ''; // 恢复透明度
|
||||
} catch (e) {
|
||||
captchaImg.removeAttribute('src');
|
||||
captchaImg.alt = '加载失败,点击重试';
|
||||
} finally {
|
||||
captchaLoading = false;
|
||||
}
|
||||
}
|
||||
captchaImg.addEventListener('click', refreshCaptcha);
|
||||
refreshCaptcha();
|
||||
|
||||
// 发送成功后进入倒计时,避免用户触发服务端同邮箱 60s 发送冷却
|
||||
function startResendCooldown(seconds) {
|
||||
clearInterval(cooldownTimer);
|
||||
let remain = seconds;
|
||||
getCodeButton.disabled = true;
|
||||
getCodeButton.value = `${remain}s 后重发`;
|
||||
cooldownTimer = setInterval(function () {
|
||||
remain -= 1;
|
||||
if (remain <= 0) {
|
||||
clearInterval(cooldownTimer);
|
||||
cooldownTimer = null;
|
||||
getCodeButton.disabled = false;
|
||||
getCodeButton.value = '获取验证码';
|
||||
} else {
|
||||
getCodeButton.value = `${remain}s 后重发`;
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
form.addEventListener('reset', function () {
|
||||
clearTipStyle();
|
||||
codeTarget = '';
|
||||
captchaInput.value = '';
|
||||
refreshCaptcha();
|
||||
});
|
||||
|
||||
function clearTipStyle() {
|
||||
emlTip.innerHTML = '';
|
||||
pwdTip.innerHTML = '';
|
||||
codeTip.innerHTML = '';
|
||||
captchaTip.innerHTML = '';
|
||||
|
||||
emlTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
|
||||
pwdTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
|
||||
codeTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
|
||||
captchaTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
|
||||
}
|
||||
clearTipStyle();
|
||||
|
||||
function checkEmail() {
|
||||
const result = FormValidators.email(emailInput.value);
|
||||
emlTip.innerHTML = '';
|
||||
emlTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
|
||||
emlTip.textContent = result.message;
|
||||
emlTip.classList.add(result.className);
|
||||
return result.valid ? result.value : false;
|
||||
}
|
||||
|
||||
function checkPassword() {
|
||||
const result = FormValidators.password(passwordInput.value, 6);
|
||||
pwdTip.innerHTML = '';
|
||||
pwdTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
|
||||
|
||||
if (!result.valid) {
|
||||
pwdTip.textContent = result.message;
|
||||
pwdTip.classList.add(result.className);
|
||||
return false;
|
||||
}
|
||||
if (result.value !== repeatPasswordInput.value) {
|
||||
pwdTip.textContent = '两次输入的密码不一致!';
|
||||
pwdTip.classList.add('tip-yellow');
|
||||
return false;
|
||||
}
|
||||
|
||||
pwdTip.textContent = result.message;
|
||||
pwdTip.classList.add(result.className);
|
||||
return result.value;
|
||||
}
|
||||
|
||||
function checkCode() {
|
||||
const code = codeInput.value.trim();
|
||||
codeTip.innerHTML = '';
|
||||
codeTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
|
||||
|
||||
if (!codeTarget) {
|
||||
codeTip.textContent = '请先获取验证码!';
|
||||
codeTip.classList.add('tip-red');
|
||||
return false;
|
||||
}
|
||||
if (emailInput.value.trim() !== codeTarget) {
|
||||
codeTip.textContent = '邮箱已修改,请重新获取验证码!';
|
||||
codeTip.classList.add('tip-red');
|
||||
return false;
|
||||
}
|
||||
if (!/^\d{6}$/.test(code)) {
|
||||
codeTip.textContent = '验证码必须是6位数字!';
|
||||
codeTip.classList.add('tip-yellow');
|
||||
return false;
|
||||
}
|
||||
// 验证码正确性由服务端在注册提交时校验(一次性消费)
|
||||
return code;
|
||||
}
|
||||
|
||||
function checkCaptcha() {
|
||||
captchaTip.innerHTML = '';
|
||||
captchaTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
|
||||
const v = captchaInput.value.trim();
|
||||
if (!v) {
|
||||
captchaTip.textContent = '请输入图形验证码!';
|
||||
captchaTip.classList.add('tip-red');
|
||||
return false;
|
||||
}
|
||||
if (!captchaToken) {
|
||||
captchaTip.textContent = '图形验证码未加载,请点击图片刷新';
|
||||
captchaTip.classList.add('tip-red');
|
||||
return false;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
passwordInput.addEventListener('focus', checkPassword);
|
||||
repeatPasswordInput.addEventListener('focus', checkPassword);
|
||||
|
||||
passwordInput.addEventListener('input', checkPassword);
|
||||
repeatPasswordInput.addEventListener('input', checkPassword);
|
||||
|
||||
passwordInput.addEventListener('blur', checkPassword);
|
||||
repeatPasswordInput.addEventListener('blur', checkPassword);
|
||||
|
||||
emailInput.addEventListener('focus', checkEmail);
|
||||
emailInput.addEventListener('input', function () {
|
||||
checkEmail();
|
||||
if (codeTarget) {
|
||||
checkCode();
|
||||
}
|
||||
});
|
||||
emailInput.addEventListener('blur', checkEmail);
|
||||
|
||||
codeInput.addEventListener('focus', checkCode);
|
||||
codeInput.addEventListener('input', checkCode);
|
||||
codeInput.addEventListener('blur', checkCode);
|
||||
|
||||
getCodeButton.addEventListener('click', async function () {
|
||||
if (getCodeButton.disabled) return; // 防重复点击
|
||||
const email = checkEmail();
|
||||
if (!email) return;
|
||||
const captchaAnswer = checkCaptcha();
|
||||
if (!captchaAnswer) return;
|
||||
|
||||
getCodeButton.disabled = true;
|
||||
codeTip.textContent = '验证码发送中…';
|
||||
codeTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
|
||||
codeTip.classList.add('tip-yellow');
|
||||
let httpStatus = 0;
|
||||
try {
|
||||
const res = await fetch('/register/send_code', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
captcha_token: captchaToken,
|
||||
captcha_answer: captchaAnswer
|
||||
})
|
||||
});
|
||||
httpStatus = res.status;
|
||||
const responseText = await res.text();
|
||||
if (window.WafTip && WafTip.isBlocked(res, responseText)) throw new Error(WafTip.MESSAGE);
|
||||
let result;
|
||||
try { result = JSON.parse(responseText); } catch { throw new Error('服务器响应格式异常'); }
|
||||
if (!res.ok || !result.success) throw new Error(result.message || '验证码发送失败');
|
||||
|
||||
codeTarget = email;
|
||||
codeInput.value = '';
|
||||
codeTip.textContent = '验证码已发送至邮箱,请查收(10 分钟内有效)';
|
||||
codeTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
|
||||
codeTip.classList.add('tip-green');
|
||||
startResendCooldown(60);
|
||||
} catch (err) {
|
||||
codeTip.textContent = err.message || '验证码发送失败,请稍后重试';
|
||||
codeTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
|
||||
codeTip.classList.add('tip-red');
|
||||
if (httpStatus === 429) startResendCooldown(60); // 命中冷却/限流,倒计时对齐
|
||||
} finally {
|
||||
// CAPTCHA Token 一次性消费,无论成败都刷新图形验证码;
|
||||
// 刷新时保留已输入的图形验证码答案,避免误清空用户输入
|
||||
refreshCaptcha(true);
|
||||
if (!cooldownTimer) getCodeButton.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
form.addEventListener('submit', async function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
const email = checkEmail();
|
||||
const password = checkPassword();
|
||||
const code = checkCode();
|
||||
|
||||
if (!email || !password || !code) {
|
||||
return;
|
||||
}
|
||||
const jsonBody = JSON.stringify({
|
||||
username: email,
|
||||
password,
|
||||
email_code: code
|
||||
});
|
||||
|
||||
try {
|
||||
submitButton.disabled = true;
|
||||
const res = await fetch('/register', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: jsonBody
|
||||
});
|
||||
|
||||
const responseText = await res.text();
|
||||
if (window.WafTip && WafTip.isBlocked(res, responseText)) throw new Error(WafTip.MESSAGE);
|
||||
let result;
|
||||
|
||||
try {
|
||||
result = JSON.parse(responseText);
|
||||
} catch {
|
||||
console.error(`注册接口返回非 JSON 内容(HTTP ${res.status}):`, responseText);
|
||||
throw new Error('服务器响应格式异常,请稍后重试');
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(result.message || `注册失败(HTTP ${res.status})`);
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
alert('注册成功');
|
||||
form.reset();
|
||||
codeTarget = '';
|
||||
location.href = 'login.html';
|
||||
return;
|
||||
}
|
||||
|
||||
alert(result.message || '注册失败');
|
||||
|
||||
} catch (err) {
|
||||
console.error('注册请求异常:', err);
|
||||
alert(err.message || '接口请求失败,请检查后端服务是否启动');
|
||||
} finally {
|
||||
submitButton.disabled = false;
|
||||
}
|
||||
});
|
||||
@@ -1,119 +0,0 @@
|
||||
/**
|
||||
* Token 生命周期管理器
|
||||
* - 定时静默刷新:在 Token 80% 生命周期时提前调 /refresh_token
|
||||
* - 全局 fetch 拦截器:收到 401 先尝试刷新一次,成功则重试原请求,失败则原样返回 401
|
||||
* - 依赖:gateway POST /refresh_token(Cookie q4_token 自动携带)
|
||||
* - 无侵入:与 dnfs.js 完全解耦
|
||||
*/
|
||||
|
||||
let _tokenExpireAt = null;
|
||||
let _refreshTimer = null;
|
||||
|
||||
const REFRESH_ADVANCE = 0.8; // 在 80% 生命周期时提前刷新
|
||||
const REFRESH_MIN_GAP = 60; // 两次刷新最少间隔(秒)
|
||||
const RETRY_COOLDOWN = 15; // 刷新失败后冷却期(秒)
|
||||
|
||||
/* ---------- 过期时间估算:通过 /whoami 获取 iat + keep_time ---------- */
|
||||
|
||||
async function estimateExpiry() {
|
||||
try {
|
||||
const res = await _rawFetch('/whoami', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: '{}'
|
||||
});
|
||||
if (!res.ok) return false;
|
||||
const data = await res.json();
|
||||
if (!data.login) return false;
|
||||
|
||||
// 优先 created_at(刷新时更新),回退 iat(兼容旧 Token)
|
||||
// 网关以 created_at + keep_time 判定令牌有效性,iat 刷新后不变,不能用于估算过期
|
||||
const issueStr = data.created_at || data.iat;
|
||||
const keepTime = data.keep_time;
|
||||
if (!issueStr || !keepTime) return false;
|
||||
|
||||
const issueDate = new Date(issueStr.replace(' ', 'T') + 'Z');
|
||||
if (isNaN(issueDate.getTime())) return false;
|
||||
|
||||
_tokenExpireAt = issueDate.getTime() + keepTime * 1000;
|
||||
scheduleRefresh();
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.warn('TokenManager: 过期时间估算失败', e.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 定时刷新 ---------- */
|
||||
|
||||
function scheduleRefresh() {
|
||||
clearTimeout(_refreshTimer);
|
||||
if (!_tokenExpireAt) return;
|
||||
|
||||
const remainingSec = (_tokenExpireAt - Date.now()) / 1000;
|
||||
if (remainingSec <= 0) {
|
||||
// Token 已过期,立即尝试刷新
|
||||
tryRefresh();
|
||||
return;
|
||||
}
|
||||
|
||||
// 80% 位置触发,但至少等 REFRESH_MIN_GAP 秒
|
||||
const delaySec = Math.max(remainingSec * (1 - REFRESH_ADVANCE), REFRESH_MIN_GAP);
|
||||
_refreshTimer = setTimeout(tryRefresh, delaySec * 1000);
|
||||
}
|
||||
|
||||
async function tryRefresh() {
|
||||
try {
|
||||
const res = await _rawFetch('/refresh_token', { method: 'POST' });
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (data.message) console.warn('TokenManager: 刷新被拒 -', data.message);
|
||||
// 冷却后重新估算
|
||||
_refreshTimer = setTimeout(() => estimateExpiry(), RETRY_COOLDOWN * 1000);
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
if (data.success && data.data) {
|
||||
console.log(`TokenManager: 已刷新 (第${data.data.refresh_count}次, 剩余${data.data.refreshes_remaining}次)`);
|
||||
}
|
||||
await estimateExpiry();
|
||||
} catch (e) {
|
||||
console.warn('TokenManager: 刷新网络异常', e.message);
|
||||
_refreshTimer = setTimeout(() => estimateExpiry(), RETRY_COOLDOWN * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 全局 fetch 401 拦截器 ---------- */
|
||||
|
||||
const _rawFetch = window.fetch;
|
||||
|
||||
window.fetch = async function (url, options) {
|
||||
let resp = await _rawFetch(url, options);
|
||||
|
||||
// 仅处理 401;/refresh_token 自身不重试防循环
|
||||
if (resp.status !== 401 || (typeof url === 'string' && url.includes('/refresh_token'))) {
|
||||
return resp;
|
||||
}
|
||||
|
||||
try {
|
||||
const refreshRes = await _rawFetch('/refresh_token', { method: 'POST' });
|
||||
if (!refreshRes.ok) return resp;
|
||||
|
||||
const data = await refreshRes.json();
|
||||
if (!data.success) return resp;
|
||||
|
||||
console.log('TokenManager: 401 拦截 → 刷新成功,重试原请求');
|
||||
await estimateExpiry();
|
||||
|
||||
// Cookie 已由 gateway Set-Cookie 自动更新,直接重试
|
||||
return await _rawFetch(url, options);
|
||||
} catch (e) {
|
||||
console.warn('TokenManager: 401 刷新异常', e.message);
|
||||
}
|
||||
|
||||
return resp;
|
||||
};
|
||||
|
||||
/* ---------- 启动 ---------- */
|
||||
|
||||
estimateExpiry();
|
||||
@@ -1,82 +0,0 @@
|
||||
(function () {
|
||||
function username(value) {
|
||||
const normalizedValue = value.trim();
|
||||
|
||||
if (!normalizedValue) {
|
||||
return { valid: false, message: '用户名不可为空', className: 'tip-red' };
|
||||
}
|
||||
if (normalizedValue.length > 20) {
|
||||
return { valid: false, message: '用户名不得长于20字符!', className: 'tip-yellow' };
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
value: normalizedValue,
|
||||
message: '用户名格式正确',
|
||||
className: 'tip-green'
|
||||
};
|
||||
}
|
||||
|
||||
function password(value, minLength) {
|
||||
const requiredLength = minLength || 1;
|
||||
|
||||
if (!value) {
|
||||
return { valid: false, message: '密码不可为空!', className: 'tip-red' };
|
||||
}
|
||||
if (value.length < requiredLength) {
|
||||
return {
|
||||
valid: false,
|
||||
message: `密码至少${requiredLength}位字符!`,
|
||||
className: 'tip-red'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
value,
|
||||
message: '密码格式正确',
|
||||
className: 'tip-green'
|
||||
};
|
||||
}
|
||||
|
||||
function email(value) {
|
||||
const normalizedValue = value.trim();
|
||||
const emailPattern = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
|
||||
|
||||
if (!normalizedValue) {
|
||||
return { valid: false, message: '邮箱不可为空!', className: 'tip-red' };
|
||||
}
|
||||
if (!emailPattern.test(normalizedValue)) {
|
||||
return { valid: false, message: '邮箱格式不正确!', className: 'tip-yellow' };
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
value: normalizedValue,
|
||||
message: '邮箱格式正确',
|
||||
className: 'tip-green'
|
||||
};
|
||||
}
|
||||
|
||||
function note_title(value) {
|
||||
return true;
|
||||
}
|
||||
|
||||
function note_content(value) {
|
||||
return true;
|
||||
}
|
||||
|
||||
window.FormValidators = { username, password, email };
|
||||
}());
|
||||
|
||||
/* WAF 拦截识别:waf 命中规则时返回 403 + 纯文本 "blocked by WAF"
|
||||
* (见 waf/internal/proxy/proxy.go block());gateway 的 403(Target-Url
|
||||
* 白名单拒绝)body 为 JSON 错误消息,不含此特征,不会误判。 */
|
||||
window.WafTip = {
|
||||
MESSAGE: '请求被 WAF 安全规则拦截,请检查输入内容',
|
||||
isBlocked(res, responseText) {
|
||||
return res.status === 403
|
||||
&& typeof responseText === 'string'
|
||||
&& responseText.indexOf('blocked by WAF') !== -1;
|
||||
}
|
||||
};
|
||||
@@ -1,190 +0,0 @@
|
||||
// ===== 路径解析:从 URL 取当前目录(去除首尾斜杠) =====
|
||||
const currentPath = decodeURIComponent(window.location.pathname).replace(/^\/+|\/+$/g, '');
|
||||
|
||||
// ===== DOM 引用(id 与原版本完全一致,保持后端/前端契约不变) =====
|
||||
const fileList = document.getElementById("fileList");
|
||||
const loading = document.getElementById("loading");
|
||||
const empty = document.getElementById("empty");
|
||||
const searchInput = document.getElementById("searchInput");
|
||||
const totalItems = document.getElementById("totalItems");
|
||||
const totalDirs = document.getElementById("totalDirs");
|
||||
const totalFiles = document.getElementById("totalFiles");
|
||||
const totalVideos = document.getElementById("totalVideos");
|
||||
const totalAudios = document.getElementById("totalAudios");
|
||||
const btnRefresh = document.getElementById("btnRefresh");
|
||||
const breadcrumb = document.getElementById("breadcrumb");
|
||||
|
||||
// ===== 状态 =====
|
||||
let items = [];
|
||||
let searchQuery = "";
|
||||
|
||||
// ===== 工具函数 =====
|
||||
const SIZE_UNITS = ["B", "KB", "MB", "GB"];
|
||||
function formatSize(bytes) {
|
||||
if (bytes === 0) return "0B";
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
return (bytes / Math.pow(1024, i)).toFixed(1) + SIZE_UNITS[i];
|
||||
}
|
||||
|
||||
const FILE_ICONS = {
|
||||
pdf: "📄", doc: "📝", docx: "📝", xls: "📊", xlsx: "📊",
|
||||
zip: "📦", rar: "📦", "7z": "📦",
|
||||
jpg: "🖼️", jpeg: "🖼️", png: "🖼️", gif: "🖼️",
|
||||
txt: "📃", json: "📋", html: "🌐", css: "🎨", js: "⚡"
|
||||
};
|
||||
function getIcon(item) {
|
||||
if (item.type === "dir") return "📁";
|
||||
if (item.type === "video") return "🎬";
|
||||
if (item.type === "audio") return "🎵";
|
||||
const ext = item.name.split(".").pop().toLowerCase();
|
||||
return FILE_ICONS[ext] || "📄";
|
||||
}
|
||||
|
||||
// 转义文本,防止文件名含特殊字符导致 XSS 或模板断裂
|
||||
function esc(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// ===== 导航行为(与原版本一致) =====
|
||||
function navigate(path, type) {
|
||||
if (type === "dir") window.location.href = "/" + path;
|
||||
else if (type === "video" || type === "audio") window.location.href = "/video/" + path;
|
||||
else if (/\.html?$/i.test(path)) window.open("/" + path, "_blank");
|
||||
else window.location.href = "/" + path;
|
||||
}
|
||||
function playMedia(path) { window.location.href = "/video/" + path; }
|
||||
function downloadFile(path) { window.location.href = "/" + path + "?download=true"; }
|
||||
|
||||
// ===== 面包屑 =====
|
||||
function renderBreadcrumb() {
|
||||
const parts = currentPath ? currentPath.split('/') : [];
|
||||
const frags = ['<a href="/">根目录</a>'];
|
||||
let acc = '';
|
||||
parts.forEach((part, i) => {
|
||||
if (i === parts.length - 1) {
|
||||
frags.push(`<span class="sep">›</span><span class="current">${esc(part)}</span>`);
|
||||
} else {
|
||||
acc += (i === 0 ? '' : '/') + part;
|
||||
frags.push(`<span class="sep">›</span><a href="/${encodeURIComponent(acc)}/">${esc(part)}</a>`);
|
||||
}
|
||||
});
|
||||
breadcrumb.innerHTML = frags.join('');
|
||||
}
|
||||
|
||||
// ===== 列表渲染(使用转义,避免文件名注入) =====
|
||||
function render() {
|
||||
const q = searchQuery.toLowerCase();
|
||||
const filtered = searchQuery
|
||||
? items.filter(i => i.name.toLowerCase().includes(q))
|
||||
: items;
|
||||
|
||||
totalItems.textContent = filtered.length;
|
||||
totalDirs.textContent = filtered.filter(i => i.type === "dir").length;
|
||||
totalFiles.textContent = filtered.filter(i => i.type === "file").length;
|
||||
totalVideos.textContent = filtered.filter(i => i.type === "video").length;
|
||||
totalAudios.textContent = filtered.filter(i => i.type === "audio").length;
|
||||
|
||||
if (filtered.length === 0) {
|
||||
fileList.innerHTML = "";
|
||||
empty.style.display = "block";
|
||||
loading.style.display = "none";
|
||||
return;
|
||||
}
|
||||
empty.style.display = "none";
|
||||
loading.style.display = "none";
|
||||
|
||||
fileList.innerHTML = filtered.map(item => {
|
||||
const path = esc(item.path);
|
||||
const type = esc(item.type);
|
||||
const name = esc(item.name);
|
||||
const sizeOrDir = item.type !== "dir"
|
||||
? `<span>${formatSize(item.size)}</span>`
|
||||
: '<span>📁 文件夹</span>';
|
||||
const playBtn = (item.type === "video" || item.type === "audio")
|
||||
? `<button class="btn-play" onclick="event.stopPropagation(); playMedia('${path}')">▶</button>`
|
||||
: '';
|
||||
return `
|
||||
<div class="item" onclick="navigate('${path}', '${type}')">
|
||||
<span class="icon">${getIcon(item)}</span>
|
||||
<div class="info">
|
||||
<div class="name">${name}</div>
|
||||
<div class="meta">
|
||||
${sizeOrDir}
|
||||
<span>${esc(item.modified)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
${playBtn}
|
||||
<button class="btn-download" onclick="event.stopPropagation(); downloadFile('${path}')">⬇</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
// ===== 数据拉取(接口与参数与原版本一致) =====
|
||||
let isLoading = false; // 防重复点击:请求进行中忽略再次触发
|
||||
async function loadData() {
|
||||
if (isLoading) return;
|
||||
isLoading = true;
|
||||
loading.style.display = "block";
|
||||
fileList.innerHTML = "";
|
||||
empty.style.display = "none";
|
||||
|
||||
try {
|
||||
const url = `/api/filelist?path=${encodeURIComponent(currentPath)}`;
|
||||
const res = await fetch(url);
|
||||
const responseText = await res.text();
|
||||
if (window.WafTip && WafTip.isBlocked(res, responseText)) throw new Error(WafTip.MESSAGE);
|
||||
const data = JSON.parse(responseText);
|
||||
if (data.success) {
|
||||
items = data.items || [];
|
||||
render();
|
||||
} else {
|
||||
throw new Error(data.error || "加载失败");
|
||||
}
|
||||
} catch (e) {
|
||||
fileList.innerHTML = `<div class="error">❌ 加载失败: ${esc(e.message)}</div>`;
|
||||
loading.style.display = "none";
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 事件绑定(快捷键 / 搜索 / 刷新 与原版本一致) =====
|
||||
let searchTimer;
|
||||
searchInput.addEventListener("input", function () {
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => {
|
||||
searchQuery = this.value.trim();
|
||||
render();
|
||||
}, 200);
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", function (e) {
|
||||
if ((e.ctrlKey && e.key === "f") ||
|
||||
(e.key === "/" && !e.ctrlKey && !e.metaKey)) {
|
||||
e.preventDefault();
|
||||
searchInput.focus();
|
||||
searchInput.select();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
searchInput.value = "";
|
||||
searchQuery = "";
|
||||
render();
|
||||
searchInput.blur();
|
||||
}
|
||||
});
|
||||
|
||||
btnRefresh.addEventListener("click", function (e) {
|
||||
e.preventDefault(); // <a href="#"> 默认会跳页顶,拦截
|
||||
loadData();
|
||||
});
|
||||
|
||||
// ===== 启动 =====
|
||||
renderBreadcrumb();
|
||||
loadData();
|
||||
@@ -1,86 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=no">
|
||||
<title>登录</title>
|
||||
<link rel="stylesheet" href="css/theme.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h2>用户登录</h2>
|
||||
<form id="form" novalidate>
|
||||
<div>
|
||||
<label>邮箱:</label>
|
||||
<input type="text" id="loginUsername" placeholder="请输入邮箱">
|
||||
<span id="msgTip"></span>
|
||||
<label>密码:</label>
|
||||
<input type="password" id="loginPassword" placeholder="请输入密码">
|
||||
<span id="pwdTip"></span>
|
||||
<label>验证码:</label>
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
<input type="text" id="captchaAnswer" placeholder="请输入验证码" maxlength="4" autocomplete="off" style="flex:1;min-width:0;width:auto">
|
||||
<img id="captchaImg" alt="加载中…" title="点击刷新验证码" style="width:120px;height:38px;flex:none;cursor:pointer;border:1.5px solid var(--border);border-radius:10px;box-sizing:border-box">
|
||||
</div>
|
||||
<span id="captchaTip"></span>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="button" id="registerBtn">注册</button>
|
||||
<input type="submit" id="loginBtn" value="登录">
|
||||
<input type="reset" id="resetUser" value="重置">
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input type="checkbox" id="agreeBox" name="agree" value="access">
|
||||
<label id="agreement" for="agreeBox">我已阅读并同意</label>
|
||||
<a id="agreementLink" href="LICENSE.html" rel="noopener" style="color:var(--accent);font-size:14px;text-decoration:none;cursor:pointer">《用户使用条例》</a>
|
||||
<span id="agreeTip"></span>
|
||||
</div>
|
||||
</form>
|
||||
<a href="forgot.html" style="display:block;text-align:center;color:#999;font-size:13px;margin:8px 0">忘记密码?</a>
|
||||
<div style="text-align:center;margin:12px 0;position:relative"><div style="position:absolute;top:50%;left:0;right:0;height:1px;background:#e0d6cc"></div><span style="background:#fff;padding:0 12px;position:relative;color:#999;font-size:13px">或</span></div>
|
||||
<a href="https://yhdet.top/yhdet_auth?callback=https://qsont.me/yhdet_callback" class="yhdet-login-btn">开源社区 快捷登录</a>
|
||||
<style>.yhdet-login-btn{display:block;width:100%;padding:12px;border:1.5px solid #1a73e8;border-radius:8px;background:white;color:#1a73e8;font-size:15px;font-weight:600;text-decoration:none;text-align:center;box-sizing:border-box;margin-bottom:12px}.yhdet-login-btn:hover{background:#e8f0fe}</style>
|
||||
|
||||
<!-- 用户使用条例弹窗 -->
|
||||
<div id="licenseModal" class="license-modal-overlay" style="display:none">
|
||||
<div class="license-modal-box">
|
||||
<button id="licenseClose" class="license-modal-close" aria-label="关闭">×</button>
|
||||
<iframe id="licenseFrame" src="about:blank" title="用户使用条例"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
<style>
|
||||
.license-modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center;padding:16px;z-index:1000}
|
||||
.license-modal-box{position:relative;width:100%;max-width:680px;max-height:86vh;background:#fff;border-radius:14px;overflow:hidden;box-shadow:0 10px 40px rgba(0,0,0,.25)}
|
||||
.license-modal-box iframe{width:100%;height:86vh;border:0;display:block}
|
||||
.license-modal-close{position:absolute;top:8px;right:10px;width:32px;height:32px;border:0;background:rgba(255,255,255,.9);border-radius:50%;font-size:22px;line-height:1;cursor:pointer;color:#555;z-index:2}
|
||||
.license-modal-close:hover{background:#fff;color:#000}
|
||||
</style>
|
||||
</div>
|
||||
<footer class="footer">
|
||||
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener">蜀ICP备2024107064号-2</a>
|
||||
</footer>
|
||||
<script src="js/validation.js"></script>
|
||||
<script src="js/login.js"></script>
|
||||
<script>
|
||||
(function(){
|
||||
var link = document.getElementById('agreementLink');
|
||||
var modal = document.getElementById('licenseModal');
|
||||
var frame = document.getElementById('licenseFrame');
|
||||
var close = document.getElementById('licenseClose');
|
||||
function show(){
|
||||
frame.src = 'LICENSE.html';
|
||||
modal.style.display = 'flex';
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
function hide(){
|
||||
modal.style.display = 'none';
|
||||
frame.src = 'about:blank';
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
link.addEventListener('click', function(e){ e.preventDefault(); show(); });
|
||||
close.addEventListener('click', hide);
|
||||
modal.addEventListener('click', function(e){ if(e.target === modal) hide(); });
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Whitespace-only changes.
@@ -1,12 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Document</title>
|
||||
<link rel="icon" href="data:;base64,=">
|
||||
</head>
|
||||
<body>
|
||||
log
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,35 +0,0 @@
|
||||
.top-bar-con {
|
||||
width: 230px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.newBtn,
|
||||
.flushBtn,
|
||||
.removeBtn {
|
||||
width: 84px;
|
||||
height: 42px;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
letter-spacing: 1px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.newBtn {
|
||||
background-color: #5cff007d;
|
||||
}
|
||||
|
||||
.flushBtn {
|
||||
background-color: #5757577d;
|
||||
}
|
||||
|
||||
.removeBtn {
|
||||
background-color: #ff00007d;
|
||||
color: #000000b8;
|
||||
}
|
||||
|
||||
td {
|
||||
width: 180px;
|
||||
text-align: center;
|
||||
font-size: 20px;
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Document</title>
|
||||
<link rel="icon" href="data:;base64,=">
|
||||
<link rel="stylesheet" href="module.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="top-bar-con">
|
||||
<button class="newBtn">新建</button>
|
||||
<button class="flushBtn">刷新</button>
|
||||
</div>
|
||||
|
||||
<p>模块列表需 mgmt Flask 后端 API 提供,静态页面不展示。</p>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,74 +0,0 @@
|
||||
.newBtn,
|
||||
.flushBtn,
|
||||
.stopBtn {
|
||||
width: 84px;
|
||||
height: 42px;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
letter-spacing: 1px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.newBtn {
|
||||
background-color: #5cff007d;
|
||||
}
|
||||
|
||||
.flushBtn {
|
||||
background-color: #5757577d;
|
||||
margin-left: 60px;
|
||||
}
|
||||
|
||||
.stopBtn {
|
||||
background-color: #ff00007d;
|
||||
}
|
||||
|
||||
.text,
|
||||
.title {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
td,
|
||||
th {
|
||||
width: 180px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#inProgressText {
|
||||
font-size: 20px;
|
||||
margin-top: 5px;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
/* modal window style */
|
||||
.modal-con {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(68, 94, 65, 0.77);
|
||||
position: absolute;
|
||||
left: 0px;
|
||||
top: 0px;
|
||||
display: none;
|
||||
justify-content: center;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.modal-con .window {
|
||||
width: 200px;
|
||||
height: 120px;
|
||||
background-color: #5cff007d;
|
||||
padding: 0px 10px;
|
||||
}
|
||||
|
||||
.modal-con .window .top-bar {
|
||||
display: flex;
|
||||
justify-content: end;
|
||||
}
|
||||
|
||||
.modal-con .window .top-bar .close-btn {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.modal-con .window .create-btn,
|
||||
.modal-con .window .input .input-area {
|
||||
margin-top: 5px;
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Document</title>
|
||||
<link rel="icon" href="data:;base64,=">
|
||||
<link rel="stylesheet" href="process.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- stop, new flush -->
|
||||
<button class="newBtn" onclick="newBtn()">新建</button>
|
||||
<button class="flushBtn" onclick="flushBtn()">刷新</button>
|
||||
<table border="1">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="title">PID</th>
|
||||
<th class="title">状态</th>
|
||||
<th class="title">命令</th>
|
||||
<th class="title">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tBody"></tbody>
|
||||
</table>
|
||||
<div id="inProgressText"></div>
|
||||
|
||||
<div class="modal-con" id="modal-con">
|
||||
<div class="window">
|
||||
<div class="top-bar">
|
||||
<div class="close-btn" onclick="closeModal()">X</div>
|
||||
</div>
|
||||
|
||||
<div class="input">
|
||||
<div class="prompt">输入命令:</div>
|
||||
<input id="input-area" class="input-area" type="text">
|
||||
</div>
|
||||
<button class="create-btn" onclick="createProcess()">创建</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// core 进程管理接口经 gateway 代理(Target-Url 指向 core:5200)
|
||||
const CORE_TARGET = "http://127.0.0.1:5200";
|
||||
|
||||
function handleUnauthorized(res) {
|
||||
if (res.status === 401) {
|
||||
alert("未授权或管理员 Token 已失效,请重新登录");
|
||||
setTimeout(() => { window.location.href = "../index.html"; }, 800);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function api(path, body) {
|
||||
const res = await fetch(path, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Target-Url': CORE_TARGET
|
||||
},
|
||||
body: body ? JSON.stringify(body) : '{}'
|
||||
});
|
||||
if (handleUnauthorized(res)) return null;
|
||||
const text = await res.text();
|
||||
let result;
|
||||
try {
|
||||
result = JSON.parse(text);
|
||||
} catch (e) {
|
||||
console.error('接口返回非 JSON:', text);
|
||||
throw new Error('服务器响应格式异常 (HTTP ' + res.status + ')');
|
||||
}
|
||||
if (!res.ok || !result.success) {
|
||||
throw new Error(result.message || '请求失败 (HTTP ' + res.status + ')');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// 列表接口返回的 data,可能是数组,也可能是 ptree 序列化后的
|
||||
// {"": {...}, "": {...}} 对象(boost property_tree 对数组的表示)
|
||||
function toProcessArray(data) {
|
||||
if (Array.isArray(data)) return data;
|
||||
if (data && typeof data === "object") {
|
||||
return Object.values(data);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
async function loadProcesses() {
|
||||
const tbody = document.getElementById("tBody");
|
||||
const progress = document.getElementById("inProgressText");
|
||||
tbody.innerHTML = "";
|
||||
progress.innerText = "加载中...";
|
||||
try {
|
||||
const result = await api('/list');
|
||||
const list = toProcessArray(result.data);
|
||||
tbody.innerHTML = "";
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
const p = list[i];
|
||||
const tr = document.createElement("tr");
|
||||
const pid = p.pid;
|
||||
const status = p.running === true ? "运行中" : (p.running === false ? "已停止" : "未知");
|
||||
tr.innerHTML = `
|
||||
<td>${pid}</td>
|
||||
<td>${status}</td>
|
||||
<td>${p.command || "-"}</td>
|
||||
<td><button class="stopBtn" onclick="stopBtn(${pid})">停止</button></td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
progress.innerText = "";
|
||||
} catch (err) {
|
||||
progress.innerText = "";
|
||||
tbody.innerHTML = "";
|
||||
alert(err.message || '加载进程列表失败');
|
||||
}
|
||||
}
|
||||
|
||||
function newBtn() {
|
||||
const modal = document.getElementById("modal-con")
|
||||
modal.style.display = "flex"
|
||||
}
|
||||
|
||||
async function createProcess() {
|
||||
const moduleName = document.getElementById("input-area").value.trim()
|
||||
if (!moduleName) {
|
||||
alert("请输入要执行的命令");
|
||||
return;
|
||||
}
|
||||
const progress = document.getElementById("inProgressText");
|
||||
progress.innerText = "启动中...";
|
||||
try {
|
||||
await api('/exec', { command: moduleName });
|
||||
alert("启动成功");
|
||||
closeModal();
|
||||
await loadProcesses();
|
||||
} catch (err) {
|
||||
progress.innerText = "";
|
||||
alert(err.message || '启动失败');
|
||||
}
|
||||
}
|
||||
|
||||
function flushBtn() {
|
||||
loadProcesses();
|
||||
}
|
||||
|
||||
async function stopBtn(pid) {
|
||||
if (!confirm("确认停止该服务?")) {
|
||||
return
|
||||
}
|
||||
const progress = document.getElementById("inProgressText");
|
||||
progress.innerText = "停止中...";
|
||||
try {
|
||||
await api('/kill', { pid: pid });
|
||||
alert("已停止");
|
||||
await loadProcesses();
|
||||
} catch (err) {
|
||||
progress.innerText = "";
|
||||
alert(err.message || '停止失败');
|
||||
}
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
const modal = document.getElementById("modal-con")
|
||||
const input = document.getElementById("input-area")
|
||||
|
||||
input.value = ""
|
||||
modal.style.display = "none"
|
||||
}
|
||||
|
||||
// 页面加载时自动拉取进程列表
|
||||
window.onload = () => {
|
||||
loadProcesses();
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,41 +0,0 @@
|
||||
.top-bar-con {
|
||||
width: 230px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
td {
|
||||
width: 180px;
|
||||
text-align: center;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.addUserBtn,
|
||||
.flushBtn,
|
||||
.changePasswdBtn,
|
||||
.deleteUserBtn {
|
||||
width: 84px;
|
||||
height: 42px;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
letter-spacing: 1px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.addUserBtn {
|
||||
background-color: #5cff007d;
|
||||
}
|
||||
|
||||
.flushBtn {
|
||||
background-color: #5757577d;
|
||||
}
|
||||
|
||||
.changePasswdBtn {
|
||||
background-color: #e6db407d;
|
||||
color: #fb0202b0;
|
||||
}
|
||||
|
||||
.deleteUserBtn {
|
||||
background-color: #ff00007d;
|
||||
color: #000000b8;
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Document</title>
|
||||
<link rel="icon" href="data:;base64,=">
|
||||
<link rel="stylesheet" href="user.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="top-bar-con">
|
||||
<button class="addUserBtn" onclick="addUser()">添加用户</button>
|
||||
<button class="flushBtn" onclick="flush()">刷新</button>
|
||||
</div>
|
||||
|
||||
<p>用户列表需 mgmt Flask 后端 API 提供,静态页面不展示。</p>
|
||||
|
||||
<script>
|
||||
function addUser() {
|
||||
alert("注意:这是静态页面 - 添加用户需要后端 API 支持,请通过 mgmt Flask 后端访问");
|
||||
}
|
||||
|
||||
function changePasswd(id) {
|
||||
alert("注意:这是静态页面 - 修改密码需要后端 API 支持,请通过 mgmt Flask 后端访问");
|
||||
}
|
||||
|
||||
function deleteUser(id) {
|
||||
alert("注意:这是静态页面 - 删除用户需要后端 API 支持,请通过 mgmt Flask 后端访问");
|
||||
}
|
||||
|
||||
function flush() {
|
||||
window.location.reload()
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,39 +0,0 @@
|
||||
body {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.main {
|
||||
background-color: rgb(233, 233, 233);
|
||||
padding: 20px 10px;
|
||||
}
|
||||
|
||||
.main .title {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.main .name-con,
|
||||
.main .passwd-con {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.main .name-con {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.main .passwd-con {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.main .name-con .user-name-text,
|
||||
.main .passwd-con .user-passwd-text {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.main .login-con {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 10px;
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>pmc management</title>
|
||||
<link rel="icon" href="data:;base64,=">
|
||||
<link rel="stylesheet" href="index.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="main">
|
||||
<div class="title">Welcome to pmc management system</div>
|
||||
<div class="name-con">
|
||||
<div class="user-name-text">Name: </div>
|
||||
<input class="user-name-input" id="user-name" type="text">
|
||||
</div>
|
||||
<div class="passwd-con">
|
||||
<div class="user-passwd-text">Password: </div>
|
||||
<input class="user-passwd-input" id="user-passwd" type="password">
|
||||
</div>
|
||||
<div class="login-con">
|
||||
<button onclick="login()">login</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function login() {
|
||||
const name = document.getElementById("user-name").value
|
||||
const passwd = document.getElementById("user-passwd").value
|
||||
const request = new XMLHttpRequest()
|
||||
const REQUEST_URL = "/api/login"
|
||||
|
||||
// if user not input any information
|
||||
if (name === "" || passwd === "") {
|
||||
alert("please input name or passwd")
|
||||
return
|
||||
}
|
||||
|
||||
// request api
|
||||
let requestData = new URLSearchParams()
|
||||
requestData.append("user-name", name)
|
||||
requestData.append("user-passwd", passwd)
|
||||
request.open("post", REQUEST_URL, true)
|
||||
request.send(requestData)
|
||||
|
||||
request.onload = () => {
|
||||
if (request.status === 401) {
|
||||
alert(request.responseText)
|
||||
return
|
||||
}
|
||||
|
||||
if (request.status === 404) {
|
||||
alert(request.responseText)
|
||||
return
|
||||
}
|
||||
|
||||
if (request.status === 500) {
|
||||
alert("服务端错误")
|
||||
return
|
||||
}
|
||||
|
||||
if (request.status === 200 && request.readyState === 4) {
|
||||
const token = JSON.parse(request.responseText).token
|
||||
let date = new Date()
|
||||
date.setTime(date.getTime() + (3 * 24 * 60 * 60 * 1000))
|
||||
const UTCTime = date.toUTCString()
|
||||
document.cookie = `token=${token}; path=/; expires=${UTCTime}`
|
||||
window.location = JSON.parse(request.responseText).redirect;
|
||||
return
|
||||
} else {
|
||||
alert(`意外错误 (${request.status})`)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key != "Enter") {
|
||||
return
|
||||
}
|
||||
login()
|
||||
})
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,54 +0,0 @@
|
||||
:root {
|
||||
--listMargin: 15%;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0px;
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
.top-con {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.top-con >*{
|
||||
margin-left: 25px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.top-con div button {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.main {
|
||||
width: 100%;
|
||||
height: 600px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.feature-list {
|
||||
width: 150px;
|
||||
height: 100%;
|
||||
background-color: #FFFFCC;
|
||||
padding: 0px 10px 0px 10px;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
.feature-list .item {
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: #0066cc;
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
margin-top: var(--listMargin);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pages {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>management page</title>
|
||||
<link rel="icon" href="data:;base64,=">
|
||||
<link rel="stylesheet" href="mgmt.css">
|
||||
|
||||
<script>
|
||||
async function loadUserInfo() {
|
||||
try {
|
||||
const res = await fetch('/whoami', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: '{}'
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.login && data.user) {
|
||||
document.getElementById("current-user").innerText = "当前用户: " + data.user;
|
||||
document.getElementById("current-role").innerText = data.is_admin ? "当前角色: 管理员" : "当前角色: 普通用户";
|
||||
if (!data.is_admin) {
|
||||
alert("需要管理员权限才能访问此页面");
|
||||
}
|
||||
} else {
|
||||
document.getElementById("current-user").innerText = "当前用户: 未登录";
|
||||
document.getElementById("current-role").innerText = "当前角色: 未知";
|
||||
setTimeout(() => {
|
||||
window.location = "index.html";
|
||||
}, 1000);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取用户信息失败:', err);
|
||||
document.getElementById("current-user").innerText = "当前用户: 未知";
|
||||
document.getElementById("current-role").innerText = "当前角色: 未知";
|
||||
}
|
||||
}
|
||||
|
||||
window.onload = () => {
|
||||
loadUserInfo();
|
||||
|
||||
const featureList = [
|
||||
{
|
||||
name: "进程",
|
||||
url: "features/process.html"
|
||||
},
|
||||
{
|
||||
name: "模块",
|
||||
url: "features/module.html"
|
||||
},
|
||||
// {
|
||||
// name: "日志",
|
||||
// url: "features/log.html"
|
||||
// },
|
||||
{
|
||||
name: "用户",
|
||||
url: "features/user.html"
|
||||
}
|
||||
]
|
||||
|
||||
const featureListEle = document.getElementById("feature-list")
|
||||
for (let i = 0; i < featureList.length; i++) {
|
||||
// get name and url
|
||||
const name = featureList[i].name
|
||||
const url = featureList[i].url
|
||||
|
||||
// Create a item
|
||||
let listItemEle = document.createElement("div")
|
||||
listItemEle.setAttribute("class", "item")
|
||||
listItemEle.innerText = name
|
||||
listItemEle.setAttribute("onclick", `switchPage('${url}')`)
|
||||
|
||||
// append element into feature list
|
||||
featureListEle.append(listItemEle)
|
||||
}
|
||||
|
||||
// set default page in iframe
|
||||
const DEFAULT_PAGE = "<!DOCTYPE html><h1>请在左侧选择功能</h1>"
|
||||
document.getElementById("page").contentDocument.write(DEFAULT_PAGE)
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="top-con">
|
||||
<div id="current-user">当前用户: 未登录</div>
|
||||
<div id="current-role">当前角色: 未知</div>
|
||||
<div class="button-con">
|
||||
<button onclick="logout()">退出登陆</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="main">
|
||||
<div class="feature-list" id="feature-list"></div>
|
||||
<div class="pages">
|
||||
<iframe id="page" src="" frameborder="1" width="100%" height="100%"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// change the iframe's url
|
||||
function switchPage(url) {
|
||||
const page = document.getElementById("page")
|
||||
page.setAttribute("src", url)
|
||||
}
|
||||
|
||||
function logout() {
|
||||
if (!confirm("确认退出?")) {
|
||||
return
|
||||
}
|
||||
document.cookie = "token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
|
||||
window.location = "index.html"
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,48 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=no">
|
||||
<title>注册</title>
|
||||
<link rel="stylesheet" href="css/theme.css">
|
||||
<link rel="stylesheet" href="css/auth.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<p class="auth-eyebrow">DNFS 树状笔记</p>
|
||||
<h2>注册页面</h2>
|
||||
<form id="form">
|
||||
<div>
|
||||
<label>邮箱号:</label>
|
||||
<input type="email" id="regemail" placeholder="请输入邮箱号">
|
||||
<span id="emlTip"></span>
|
||||
<div class="code-box">图形验证码:
|
||||
<input type="text" id="captchaAnswer" placeholder="请输入图形验证码" maxlength="4" autocomplete="off">
|
||||
<img id="captchaImg" class="captcha-img" alt="加载中…" title="点击刷新验证码">
|
||||
<span id="captchaTip"></span>
|
||||
</div>
|
||||
<div class="code-box">邮箱验证码:
|
||||
<input type="text" id="code" placeholder="请输入6位验证码" inputmode="numeric" maxlength="6">
|
||||
<input type="button" id="getCode" value="获取验证码">
|
||||
<span id="codeTip"></span>
|
||||
</div>
|
||||
<label>密码:</label>
|
||||
<input type="password" id="regpsw" placeholder="密码至少6位">
|
||||
<label>验证密码:</label>
|
||||
<input type="password" id="repwd" placeholder="请再次输入密码">
|
||||
<span id="pwdTip"></span>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<input id="formsub" type="submit" value="提交">
|
||||
<input id="resetUser" type="reset" value="重置">
|
||||
<input type="button" value="返回登录" onclick="location.href='login.html'">
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<footer class="footer">
|
||||
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener">蜀ICP备2024107064号-2</a>
|
||||
</footer>
|
||||
<script src="js/validation.js"></script>
|
||||
<script src="js/register.js?v=7"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user