This commit is contained in:
qing committed 2026-07-20 11:12:29 +08:00
commit 70d467d052
97 files changed
+10386

No files matched your search

+117
View File
@@ -0,0 +1,117 @@
# IDE
.vscode/
fsys.dist
fsys.build
*.bin
*.out
# PRJ
#config.ini
*.pem
aes_key.txt
public/users/
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
.hypothesis/
.pytest_cache/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# Jupyter Notebook
.ipynb_checkpoints
# pyenv
.python-version
# celery beat schedule file
celerybeat-schedule
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 卿松
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.
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
cd "$(dirname "$0")" || exit
source ./venv/bin/activate
# 直接运行Python应用,HTTPS/HTTP配置在app.py中处理
exec fsys.bin --config config.ini 1>/dev/null
+7
View File
@@ -0,0 +1,7 @@
#!/bin/bash
cd "$(dirname "$0")" || exit
python3 -m venv venv
#sudo chmod +x .venv/bin/activate
source venv/bin/activate
pip install -r requirements.txt -i https://mirrors.aliyun.com/pypi/simple/
nuitka --onefile fsys.py
+14
View File
@@ -0,0 +1,14 @@
[server]
host = 0.0.0.0
port = 9200
[directory]
root = ./public
[ssl]
enabled = false
cert_file = cert.pem
key_file = key.pem
[cors]
allowed_origins = *
+591
View File
@@ -0,0 +1,591 @@
# file_server.py - 极简版文件托管服务器
from flask import Flask, send_from_directory, jsonify, request, Response, send_file
from flask_cors import CORS
import os
import argparse
import datetime
import logging
from logging.handlers import RotatingFileHandler
from pathlib import Path
import configparser
# 配置日志
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
file_handler = RotatingFileHandler('app.log', maxBytes=10485760, backupCount=10)
file_handler.setFormatter(logging.Formatter(
'%(asctime)s %(levelname)s: %(message)s'
))
file_handler.setLevel(logging.INFO)
logger.addHandler(file_handler)
parser = argparse.ArgumentParser(description='File Server')
parser.add_argument('--config', type=str, default='config.ini', help='Configuration file path')
args = parser.parse_args()
app = Flask(__name__)
CORS(app)
# ==================== 配置文件加载 ====================
CONFIG_FILE = args.config
file_config = {
'server': {'host': '0.0.0.0', 'port': '5000'},
'directory': {'root': './public'},
'ssl': {'enabled': 'false', 'cert_file': 'cert.pem', 'key_file': 'key.pem'},
}
if os.path.exists(CONFIG_FILE):
config_parser = configparser.ConfigParser()
config_parser.read(CONFIG_FILE, encoding='utf-8')
for section in ['server', 'directory', 'ssl']:
if section in config_parser:
for key, value in config_parser[section].items():
if key in file_config[section]:
file_config[section][key] = value
HTML_ROOT_DIR = file_config['directory']['root']
FILESYSTEM_ENABLED = bool(HTML_ROOT_DIR and HTML_ROOT_DIR.strip())
if FILESYSTEM_ENABLED:
try:
Path(HTML_ROOT_DIR).mkdir(parents=True, exist_ok=True)
logger.info(f"Created directory: {HTML_ROOT_DIR}")
except Exception as e:
logger.error(f"Failed to create directory: {str(e)}")
FILESYSTEM_ENABLED = False
SSL_ENABLED = file_config['ssl'].get('enabled', 'false').lower() == 'true'
SSL_CERT_FILE = file_config['ssl'].get('cert_file', 'cert.pem')
SSL_KEY_FILE = file_config['ssl'].get('key_file', 'key.pem')
# ==================== 视频文件支持 ====================
VIDEO_MIME_TYPES = {
'.mp4': 'video/mp4', '.webm': 'video/webm', '.ogg': 'video/ogg',
'.avi': 'video/x-msvideo', '.mov': 'video/quicktime', '.mkv': 'video/x-matroska'
}
AUDIO_MIME_TYPES = {
'.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.ogg': 'audio/ogg',
'.flac': 'audio/flac', '.aac': 'audio/aac', '.m4a': 'audio/mp4'
}
def is_video_file(filename):
return os.path.splitext(filename)[1].lower() in VIDEO_MIME_TYPES
def is_audio_file(filename):
return os.path.splitext(filename)[1].lower() in AUDIO_MIME_TYPES
def get_media_mime_type(filename):
ext = os.path.splitext(filename)[1].lower()
return VIDEO_MIME_TYPES.get(ext) or AUDIO_MIME_TYPES.get(ext) or 'application/octet-stream'
def safe_path_join(base_dir, user_path):
if not user_path or user_path.strip() == '' or user_path.strip() == '/':
return base_dir
if user_path.startswith('/'):
user_path = user_path[1:]
full_path = os.path.normpath(os.path.join(base_dir, user_path))
base_dir_real = os.path.realpath(base_dir)
full_path_real = os.path.realpath(full_path)
if not full_path_real.startswith(base_dir_real):
return None
return full_path_real
def format_file_size(size):
for unit in ['B', 'KB', 'MB', 'GB']:
if size < 1024.0:
return f"{size:.1f}{unit}"
size /= 1024.0
return f"{size:.1f}TB"
# ==================== API 接口 ====================
@app.route('/api/filelist', methods=['GET'])
def get_file_list():
try:
if not FILESYSTEM_ENABLED:
return jsonify({'error': '文件系统未启用'}), 403
requested_path = request.args.get('path', '')
full_path = safe_path_join(HTML_ROOT_DIR, requested_path)
if full_path is None:
return jsonify({'error': '无效的路径'}), 403
if not os.path.exists(full_path) or not os.path.isdir(full_path):
return jsonify({'error': '目录不存在'}), 404
items = []
for item in os.listdir(full_path):
if item == '__pycache__':
continue
item_path = os.path.join(full_path, item)
item_stat = os.stat(item_path)
is_dir = os.path.isdir(item_path)
item_rel_path = os.path.join(requested_path, item) if requested_path else item
file_type = 'dir' if is_dir else ('video' if is_video_file(item) else ('audio' if is_audio_file(item) else 'file'))
items.append({
'name': item,
'type': file_type,
'path': item_rel_path,
'size': item_stat.st_size if not is_dir else 0,
'size_formatted': format_file_size(item_stat.st_size) if not is_dir else '',
'modified': datetime.datetime.fromtimestamp(item_stat.st_mtime).strftime('%Y-%m-%d %H:%M:%S')
})
items.sort(key=lambda x: (x['type'] != 'dir', x['name'].lower()))
return jsonify({
'success': True,
'path': requested_path or '/',
'parent_path': os.path.dirname(requested_path) if requested_path and os.path.dirname(requested_path) != '/' else '',
'items': items
})
except Exception as e:
logger.error(f"Get file list error: {str(e)}")
return jsonify({'error': str(e)}), 500
@app.route('/video/<path:file_path>', methods=['GET'])
def stream_video(file_path):
try:
if not FILESYSTEM_ENABLED:
return jsonify({'error': '文件系统未启用'}), 403
full_path = safe_path_join(HTML_ROOT_DIR, file_path)
if full_path is None or not os.path.exists(full_path) or not os.path.isfile(full_path):
return jsonify({'error': '文件不存在'}), 404
if not is_video_file(full_path) and not is_audio_file(full_path):
return jsonify({'error': '不支持的文件类型'}), 400
file_size = os.path.getsize(full_path)
mime_type = get_media_mime_type(full_path)
range_header = request.headers.get('Range', None)
if range_header:
byte_range = range_header.replace('bytes=', '').split('-')
start = int(byte_range[0]) if byte_range[0] else 0
end = int(byte_range[1]) if len(byte_range) > 1 and byte_range[1] else file_size - 1
if start >= file_size:
return jsonify({'error': '请求范围超出文件大小'}), 416
length = end - start + 1
with open(full_path, 'rb') as f:
f.seek(start)
data = f.read(length)
response = Response(data, 206, mimetype=mime_type, direct_passthrough=True)
response.headers.add('Content-Range', f'bytes {start}-{end}/{file_size}')
response.headers.add('Accept-Ranges', 'bytes')
response.headers.add('Content-Length', str(length))
response.headers.add('Cache-Control', 'no-cache')
return response
else:
return send_file(full_path, mimetype=mime_type, as_attachment=False)
except Exception as e:
logger.error(f"Video streaming error: {str(e)}")
return jsonify({'error': str(e)}), 500
# ==================== 极简版HTML页面(使用字符串拼接,避免花括号问题) ====================
def get_html_template(path, breadcrumb_html):
"""生成HTML页面"""
return f'''<!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>
<style>
* {{
margin: 0;
padding: 0;
box-sizing: border-box;
}}
body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif;
background: #f5f5f5;
color: #333;
padding: 0;
min-height: 100vh;
}}
.header {{
background: #4A90D9;
color: white;
padding: 12px 16px;
position: sticky;
top: 0;
z-index: 10;
display: flex;
justify-content: space-between;
align-items: center;
}}
.header h1 {{ font-size: 18px; font-weight: 600; }}
.header a {{
color: white;
text-decoration: none;
padding: 4px 10px;
background: rgba(255,255,255,0.2);
border-radius: 4px;
font-size: 14px;
}}
.header a:active {{ background: rgba(255,255,255,0.4); }}
.breadcrumb {{
padding: 8px 16px;
background: white;
font-size: 13px;
overflow-x: auto;
white-space: nowrap;
border-bottom: 1px solid #e0e0e0;
}}
.breadcrumb a {{ color: #4A90D9; text-decoration: none; padding: 2px 6px; }}
.breadcrumb .sep {{ color: #999; margin: 0 4px; }}
.breadcrumb .current {{ color: #333; font-weight: 500; }}
.search-box {{
padding: 8px 16px;
background: white;
border-bottom: 1px solid #e0e0e0;
}}
.search-box input {{
width: 100%;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
outline: none;
}}
.search-box input:focus {{ border-color: #4A90D9; }}
.stats {{
padding: 6px 16px;
background: #fafafa;
font-size: 12px;
color: #666;
border-bottom: 1px solid #e0e0e0;
display: flex;
gap: 12px;
flex-wrap: wrap;
}}
.container {{
padding: 8px 12px 60px 12px;
max-width: 800px;
margin: 0 auto;
}}
.item {{
background: white;
border-radius: 8px;
padding: 10px 14px;
margin-bottom: 4px;
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
border-bottom: 1px solid #f0f0f0;
}}
.item:active {{ background: #f0f0f0; }}
.item .icon {{ font-size: 24px; flex-shrink: 0; width: 36px; text-align: center; }}
.item .info {{ flex: 1; min-width: 0; }}
.item .name {{ font-size: 15px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }}
.item .meta {{ font-size: 12px; color: #999; display: flex; gap: 10px; flex-wrap: wrap; }}
.item .actions {{ display: flex; gap: 4px; flex-shrink: 0; }}
.item .actions button {{
padding: 4px 10px;
font-size: 12px;
border: none;
border-radius: 4px;
cursor: pointer;
}}
.item .actions button:active {{ transform: scale(0.95); }}
.btn-play {{ background: #4CAF50; color: white; }}
.btn-download {{ background: #4A90D9; color: white; }}
.loading {{ text-align: center; padding: 40px; color: #999; }}
.loading .spinner {{ display: inline-block; animation: spin 1s linear infinite; font-size: 28px; }}
@keyframes spin {{ 100% {{ transform: rotate(360deg); }} }}
.empty {{ text-align: center; padding: 60px 20px; color: #999; }}
.empty .icon {{ font-size: 48px; display: block; margin-bottom: 12px; }}
.error {{ text-align: center; padding: 40px 20px; color: #e74c3c; }}
@media (max-width: 480px) {{
.item {{ padding: 8px 10px; }}
.item .icon {{ font-size: 20px; width: 30px; }}
.item .name {{ font-size: 13px; }}
.item .meta {{ font-size: 11px; gap: 6px; }}
.item .actions button {{ padding: 3px 8px; font-size: 11px; }}
.header h1 {{ font-size: 16px; }}
}}
@media (prefers-color-scheme: dark) {{
body {{ background: #1a1a2e; color: #e0e0e0; }}
.header {{ background: #2d2d5e; }}
.breadcrumb {{ background: #16213e; border-color: #2a2a4a; }}
.breadcrumb .current {{ color: #e0e0e0; }}
.stats {{ background: #16213e; color: #aaa; border-color: #2a2a4a; }}
.search-box {{ background: #16213e; border-color: #2a2a4a; }}
.search-box input {{ background: #2a2a4a; border-color: #3a3a5a; color: #e0e0e0; }}
.item {{ background: #16213e; border-color: #2a2a4a; }}
.item:active {{ background: #2a2a4a; }}
.item .meta {{ color: #888; }}
}}
</style>
</head>
<body>
<div class="header">
<h1>📁 文件浏览</h1>
<div>
<a href="/">🏠</a>
<a href="#" id="btnRefresh" style="margin-left:6px;">🔄</a>
</div>
</div>
<div class="breadcrumb">
<a href="/">根目录</a>
{breadcrumb_html}
</div>
<div class="search-box">
<input type="text" id="searchInput" placeholder="搜索文件..." 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>
<div class="container">
<div class="loading" id="loading">
<span class="spinner">⏳</span>
<div>加载中...</div>
</div>
<div id="fileList"></div>
<div class="empty" id="empty" style="display:none;">
<span class="icon">📭</span>
<div>此目录为空</div>
</div>
</div>
<script>
const currentPath = "{path}";
let items = [];
let searchQuery = "";
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");
function formatSize(bytes) {{
if (bytes === 0) return "0B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return (bytes / Math.pow(k, i)).toFixed(1) + sizes[i];
}}
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();
const icons = {{
"pdf": "📄", "doc": "📝", "docx": "📝", "xls": "📊", "xlsx": "📊",
"zip": "📦", "rar": "📦", "7z": "📦",
"jpg": "🖼️", "jpeg": "🖼️", "png": "🖼️", "gif": "🖼️",
"txt": "📃", "json": "📋", "html": "🌐", "css": "🎨", "js": "⚡"
}};
return icons[ext] || "📄";
}}
function navigate(path, type) {{
if (type === "dir") window.location.href = "/" + path;
else if (type === "video" || type === "audio") window.location.href = "/video/" + path;
else window.location.href = "/" + path;
}}
function playMedia(path) {{ window.location.href = "/video/" + path; }}
function downloadFile(path) {{ window.location.href = "/" + path + "?download=true"; }}
function render() {{
const filtered = items.filter(item => {{
if (!searchQuery) return true;
return item.name.toLowerCase().includes(searchQuery.toLowerCase());
}});
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 => `
<div class="item">
<span class="icon">${{getIcon(item)}}</span>
<div class="info" onclick="navigate('${{item.path}}', '${{item.type}}')">
<div class="name">${{item.name}}</div>
<div class="meta">
${{item.type !== "dir" ? `<span>${{formatSize(item.size)}}</span>` : '<span>📁 文件夹</span>'}}
<span>${{item.modified}}</span>
</div>
</div>
<div class="actions">
${{(item.type === "video" || item.type === "audio") ?
`<button class="btn-play" onclick="event.stopPropagation(); playMedia('${{item.path}}')">▶</button>` : ''}}
<button class="btn-download" onclick="event.stopPropagation(); downloadFile('${{item.path}}')">⬇</button>
</div>
</div>
`).join("");
}}
async function loadData() {{
loading.style.display = "block";
fileList.innerHTML = "";
empty.style.display = "none";
try {{
const url = `/api/filelist?path=${{encodeURIComponent(currentPath)}}`;
const res = await fetch(url);
const data = await res.json();
if (data.success) {{
items = data.items || [];
render();
}} else {{
throw new Error(data.error || "加载失败");
}}
}} catch (e) {{
fileList.innerHTML = `<div class="error">❌ 加载失败: ${{e.message}}</div>`;
loading.style.display = "none";
}}
}}
let timeout;
searchInput.addEventListener("input", function() {{
clearTimeout(timeout);
timeout = 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", loadData);
loadData();
</script>
</body>
</html>'''
# ==================== 主路由 ====================
@app.route('/', defaults={'path': ''})
@app.route('/<path:path>')
def serve_file(path=''):
try:
if not FILESYSTEM_ENABLED:
return "文件系统未启用", 403
full_path = safe_path_join(HTML_ROOT_DIR, path)
if full_path is None:
return "无效的路径", 403
if not os.path.exists(full_path):
return "路径不存在", 404
# 文件处理
if os.path.isfile(full_path):
if request.args.get('download') == 'true':
return send_file(full_path, as_attachment=True, download_name=os.path.basename(full_path))
if is_video_file(full_path) or is_audio_file(full_path):
return send_file(full_path, mimetype=get_media_mime_type(full_path))
# 普通文件
with open(full_path, 'rb') as f:
content = f.read()
ext = os.path.splitext(full_path)[1].lower()
mime_map = {
'.html': 'text/html', '.js': 'application/javascript', '.css': 'text/css',
'.json': 'application/json', '.png': 'image/png', '.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg', '.gif': 'image/gif', '.txt': 'text/plain',
'.pdf': 'application/pdf', '.svg': 'image/svg+xml', '.ico': 'image/x-icon'
}
return Response(content, mimetype=mime_map.get(ext, 'application/octet-stream'))
# 目录处理 - 生成面包屑
breadcrumb = ''
if path:
parts = path.split('/')
current = ''
for i, part in enumerate(parts):
if i == len(parts) - 1:
breadcrumb += f'<span class="sep">›</span><span class="current">{part}</span>'
else:
current += part + '/'
breadcrumb += f'<span class="sep">›</span><a href="/{current}">{part}</a>'
# 使用函数生成HTML,避免字符串格式化问题
html = get_html_template(path, breadcrumb)
return Response(html, mimetype='text/html')
except Exception as e:
logger.error(f"Error: {str(e)}")
import traceback
traceback.print_exc()
return f"服务器内部错误: {str(e)}", 500
@app.route('/health', methods=['GET'])
def health_check():
return jsonify({
'status': 'healthy',
'filesystem': {'enabled': FILESYSTEM_ENABLED, 'root': HTML_ROOT_DIR if FILESYSTEM_ENABLED else None}
})
# ==================== 主程序 ====================
if __name__ == '__main__':
host = file_config['server']['host']
port = int(file_config['server']['port'])
print("\n" + "="*50)
print("📁 文件托管服务器 (极简版)")
print("="*50)
print(f"地址: http://{host}:{port}")
print(f"目录: {os.path.abspath(HTML_ROOT_DIR) if FILESYSTEM_ENABLED else '未启用'}")
print("="*50)
print("\n快捷键: / 或 Ctrl+F 搜索, ESC 清空搜索")
print("\n")
if SSL_ENABLED and os.path.exists(SSL_CERT_FILE) and os.path.exists(SSL_KEY_FILE):
app.run(host=host, port=port, ssl_context=(SSL_CERT_FILE, SSL_KEY_FILE), debug=False)
else:
app.run(host=host, port=port, debug=False)
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+530
View File
@@ -0,0 +1,530 @@
<!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>
+204
View File
@@ -0,0 +1,204 @@
const msgTip = document.getElementById('msgTip');
const pwdTip = document.getElementById('pwdTip');
const emlTip = document.getElementById('emlTip');
const agreeTip = document.getElementById('agreeTip');
const form = document.getElementById('form');
const usernameInput = document.getElementById('loginUsername');
const passwordInput = document.getElementById('loginPassword');
const emailInput = document.getElementById('loginEmail');
const agreeBox = document.getElementById('agreeBox');
const protocol = document.getElementById('agreement');
const submit = document.getElementById('loginBtn');
const resetUser = document.getElementById('resetUser');
const registerBtn = document.getElementById('registerBtn');
resetUser.addEventListener('click', function() {
clearTipStyle();
})
function clearTipStyle() {
msgTip.innerHTML = '';
pwdTip.innerHTML = '';
agreeTip.innerHTML = '';
emlTip.innerHTML = '';
msgTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
pwdTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
agreeTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
emlTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
protocol.classList.remove('tip-red');
protocol.classList.remove('tip-green');
}
clearTipStyle();
/**
* @returns {string|false}
*/
function checkUsername() {
const val = usernameInput.value.trim();
msgTip.innerHTML = '';
msgTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
if (!val) {
msgTip.innerHTML = '用户名不可为空';
msgTip.classList.add('tip-red');
return false;
}
if (val.length > 20) {
msgTip.innerHTML = '用户名不得长于20字符!';
msgTip.classList.add('tip-yellow');
return false;
}
msgTip.innerHTML = '√ 用户名格式正确';
msgTip.classList.add('tip-green');
return val;
}
/**
* @returns {string|false}
*/
function checkPwd() {
const val = passwordInput.value.trim();
pwdTip.innerHTML = '';
pwdTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
if (!val) {
pwdTip.innerHTML = '密码不可为空!';
pwdTip.classList.add('tip-red');
return false;
}
pwdTip.innerHTML = '河狸';
pwdTip.classList.add('tip-green');
return val;
}
/**
* @returns {string|false}
*/
function checkEml() {
const val = emailInput.value.trim();
const emlStand = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
emlTip.innerHTML = '';
emlTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
if (!val) {
emlTip.innerHTML = '邮箱不可为空!';
emlTip.classList.add('tip-red');
return false;
} else if (!emlStand.test(val)) {
emlTip.innerHTML = '邮箱格式不正确!';
emlTip.classList.add('tip-yellow');
return false;
}
emlTip.innerHTML = '河狸';
emlTip.classList.add('tip-green');
return val;
}
/**
* @returns {boolean}
*/
function checkAgree() {
agreeTip.innerHTML = '';
agreeTip.classList.remove('tip-red');
protocol.classList.remove('tip-green', 'tip-red');
if (!agreeBox.checked) {
agreeTip.innerHTML = '请同意用户协议!';
agreeTip.classList.add('tip-red');
protocol.classList.add('tip-red');
return false;
}
protocol.classList.add('tip-green');
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);
emailInput.addEventListener('focus', checkEml);
emailInput.addEventListener('input', checkEml);
emailInput.addEventListener('blur', checkEml);
agreeBox.addEventListener('change', checkAgree);
registerBtn.addEventListener('click', function() {
location.href = 'register.html';
})
/**
* @param {string}
* @returns {string}
*/
function htmlEscape(str) {
if (typeof str !== 'string') return str;
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
form.addEventListener('submit', async function(e) {
e.preventDefault();
clearTipStyle();
const userOk = checkUsername();
const pwdOk = checkPwd();
const emlOk = checkEml();
const agreeOk = checkAgree();
if (!(userOk && pwdOk && emlOk && agreeOk)) {
return;
}
const jsonBody = JSON.stringify({
username: userOk,
password: pwdOk
});
try {
const res = await fetch('/login', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: jsonBody
});
const responseText = await res.text();
let result;
try {
result = JSON.parse(responseText);
} catch (err) {
throw new Error(`服务器返回了非 JSON 内容(HTTP ${res.status}):${responseText.slice(0, 100)}`);
}
if (!res.ok) {
throw new Error(result.message || `登录失败(HTTP ${res.status})`);
}
if (result.code === 0) {
alert(`${result.message},账号:${result.data.username}`);
sessionStorage.setItem('token', result.data.token);
location.href = 'main.html';
} else {
alert(result.message || '登录失败');
}
} catch (err) {
alert(err.message || '接口请求失败,请检查后端服务是否启动');
console.error('登录请求异常:', err);
}
});
+73
View File
@@ -0,0 +1,73 @@
let sendCode = "";
let codeTarget = "";
getCode.onclick = function () {
const phoneMail = email.value.trim();
if (phoneMail === "") {
alert("请先填写邮箱号!");
return;
}
sendCode = Math.floor(100000 + Math.random() * 900000).toString();
codeTarget = phoneMail;
alert(`验证码已发送至 ${phoneMail},您的验证码:${sendCode}`);
};
regForm.onsubmit = function (e) {
e.preventDefault();
const uname = username.value.trim();
const emailVal = email.value.trim();
const codeVal = code.value.trim();
const pwdVal = psw.value;
const repwdVal = repwd.value;
if (uname === "") {
alert("请填写用户名");
return;
}
if (emailVal === "") {
alert("请填写邮箱号");
return;
}
if (sendCode === "") {
alert("请先点击获取验证码!");
return;
}
if (emailVal !== codeTarget) {
alert("邮箱已修改,请重新获取验证码!");
sendCode = "";
return;
}
if (!/^\d{6}$/.test(codeVal)) {
alert("验证码必须是6位数字");
return;
}
if (codeVal !== sendCode) {
alert("验证码错误,请核对!");
code.value = "";
return;
}
if (pwdVal.length < 6) {
alert("密码至少6位字符");
return;
}
if (pwdVal !== repwdVal) {
alert("两次输入的密码不一致");
return;
}
alert("注册成功!");
regForm.reset();
sendCode = "";
codeTarget = "";
};
+47
View File
@@ -0,0 +1,47 @@
<!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>
<style>
.tip-red {color:red;}
.tip-yellow {color: orange;}
.tip-green {color: greenyellow;}
</style>
</head>
<body>
<h2>用户登录</h2>
<form action="" id="form" method="post">
<div>
<label>用户名/推荐邮箱:</label>
<input type="text" id="loginUsername" name="username" placeholder="请输入用户名">
<span id="msgTip" ></span>
<br>
<label>密码:</label>
<input type="password" id="loginPassword" name="password" placeholder="请输入密码">
<span id="pwdTip"></span>
</div>
<div>
<label>验证邮箱:</label>
<input type="email" id="loginEmail" name="email" placeholder="请输入验证邮箱">
<span id="emlSub">
<input type="submit" value="发送验证码">
</span>
<span id="emlTip"></span>
</div>
<div>
<button type="button" id="registerBtn">注册</button>
<input type="submit" id="loginBtn" value="登录">
<input type="reset" id="resetUser" value="重置">
</div>
<div>
<input type="checkbox" id="agreeBox" name="agree" value="access">
<label id="agreement">同意该系统相关用户使用条例</label>
<span id="agreeTip"></span>
</div>
</form>
<script src="js/login.js"></script>
</body>
</html>
+102
View File
@@ -0,0 +1,102 @@
<!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>DNFS · 树状笔记</title>
<style></style>
</head>
<body>
<!-- 文章主标题 -->
<div id="article-title">
<h1>
<p>Python</p>
</h1>
</div>
<!-- 文章详情 -->
<div id="publish-time">
<p>发布时间:2026-07-11</p>
</div>
<div>
<span id="visit-count">浏览:126</span>
<span id="like-count">点赞:28</span>
<span id="share-count">分享:7</span>
</div>
<!-- 文章正文-->
<div id="article-content">
<p>
Python 的 <code>socket</code>
模块是网络编程的核心,其函数可分为<strong>核心套接字操作</strong>、<strong>套接字选项设置</strong>、<strong>辅助工具函数</strong>三大类。本文将逐一拆解所有函数的语法、参数、用法和实战示例,覆盖从基础到高级的所有场景。
</p>
<h2>一、模块初始化与核心对象</h2>
<h3>1. socket.socket():创建套接字对象</h3>
<p><strong>语法</strong>:</p>
<pre><code>socket.socket(family=AF_INET, type=SOCK_STREAM, proto=0, fileno=None)</code></pre>
<p><strong>作用</strong>:创建一个套接字对象,是所有网络操作的基础。</p>
<p><strong>参数说明</strong>:</p>
<table>
<tr>
<th>参数</th>
<th>取值</th>
<th>说明</th>
</tr>
<tr>
<td><code>family</code></td>
<td><code>AF_INET</code>(默认)</td>
<td>IPv4 协议族(最常用)</td>
</tr>
<tr>
<td></td>
<td><code>AF_INET6</code></td>
<td>IPv6 协议族</td>
</tr>
<tr>
<td></td>
<td><code>AF_UNIX</code></td>
<td>Unix 域套接字(本地进程通信)</td>
</tr>
<tr>
<td><code>type</code></td>
<td><code>SOCK_STREAM</code>(默认)</td>
<td>TCP 套接字(面向连接、可靠)</td>
</tr>
<tr>
<td></td>
<td><code>SOCK_DGRAM</code></td>
<td>UDP 套接字(无连接、数据报)</td>
</tr>
<tr>
<td></td>
<td><code>SOCK_RAW</code></td>
<td>原始套接字(可操作 IP 层,用于抓包)</td>
</tr>
<tr>
<td><code>proto</code></td>
<td>0(默认)</td>
<td>协议编号,默认自动匹配 type(TCP=6,UDP=17)</td>
</tr>
<tr>
<td><code>fileno</code></td>
<td>None(默认)</td>
<td>复用已有文件描述符,无需手动指定</td>
</tr>
</table>
</div>
<div class="other-recommend">
<h3>其他推荐</h3>
</div>
<div class="recommend-list" id="recommend-list">
<ul>
<li><a href="https://www.baidu.com">百度推荐</a></li>
<li><a href="https://www.baidu.com">百度推荐</a></li>
<li><a href="https://www.baidu.com">百度推荐</a></li>
<li><a href="https://www.baidu.com">百度推荐</a></li>
<li><a href="https://www.baidu.com">百度推荐</a></li>
</ul>
</div>
</body>
</html>
+23
View File
@@ -0,0 +1,23 @@
<!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>
</head>
<body>
<h3>注册页面</h3>
<form>
用户名/推荐邮箱:<input type="text" id="username" placeholder="请输入用户名"><br>
邮箱号:<input type="text" id="email" placeholder="请输入邮箱号"><br>
<div class="code-box">验证码:
<input type="text" id="code" placeholder="请输入6位验证码">
<input type="button" id="getCode" value="获取验证码">
</div>
密码:<input type="password" id="psw"><br>
验证密码:<input type="password" id="repwd" placeholder="请再次输入密码"><br>
<input type="submit" value="提交">
<input type="button" value="返回登录" onclick="location.href='login.html'">
<script src="register.js"></script>
</body>
</html>
+11
View File
@@ -0,0 +1,11 @@
nuitka
Flask>=2.3.0,<3.0.0
Flask-CORS>=4.0.0,<5.0.0
Flask-SQLAlchemy>=3.1.0,<4.0.0
Flask-Limiter>=3.3.0,<4.0.0
Flask-SocketIO>=5.3.0,<6.0.0
python-socketio>=5.10.0,<6.0.0
Werkzeug>=2.3.0,<3.0.0
requests>=2.31.0,<3.0.0
websocket-client>=1.6.0,<2.0.0
python-engineio>=4.7.0,<5.0.0