目的:
浏览器的地址簿太厚,如下图:
开始,想给每个 Web 应用加 icon 来提高辨识度,发现很麻烦:create image, resize, 还要挑来挑去,重复性地添加代码。再看着这些密密麻麻的含有重复与有规则的字符,真刺眼!
做这个 Portal Web 应用来进行网站应用导航,docker 部署后,占用端口:9999,可以在app.py修改。
<代码有 Claudi AI 参与>
Navigator Portal 应用
1. 界面展示
2. 目录结构
navigator_portal #项目名称
│
├── app.py # Flask 应用主文件
├── requirements.txt # Python 依赖包列表
├── Dockerfile # docker部署文件
├── static/
│ ├── css/
│ │ └── style.css
│ ├── js/
│ │ └── main.js
│ ├── uploads/ # 上传的图片存储目录
│ └── favicon.jpg # 网站图标
├── templates/ # HTML files 目录
│ ├── base.html
│ ├── index.html
│ └── edit.html # 编辑页面
└── data/ # 存储目录└── nav_links.json # 导航链接数据文件
3. 完整代码
a. app.py
# app.py
from flask import Flask, render_template, request, jsonify, url_for
import json
from pathlib import Path
import os
from werkzeug.utils import secure_filenameapp = Flask(__name__)
app.secret_key = 'your_secret_key_here'# 配置文件上传
UPLOAD_FOLDER = Path('static/uploads')
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'}
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER# 确保上传目录存在
UPLOAD_FOLDER.mkdir(parents=True, exist_ok=True)# 数据文件路径
DATA_FILE = Path('data/nav_links.json')def allowed_file(filename):return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONSdef init_data_file():if not DATA_FILE.exists():default_links = [{"name": "主应用", "url": "http://davens:5000", "port": "5000", "image": "/static/images/default.png", "order": 0},] + [{"name": f"应用 {port}", "url": f"http://davens:{port}", "port": str(port),"image": "/static/images/default.png","order": i + 1}for i, port in enumerate(list(range(9001, 9012)) + [9999])]DATA_FILE.parent.mkdir(parents=True, exist_ok=True)with open(DATA_FILE, 'w', encoding='utf-8') as f:json.dump(default_links, f, indent=2, ensure_ascii=False)def load_links():try:if not DATA_FILE.exists():init_data_file()with open(DATA_FILE, 'r', encoding='utf-8') as f:links = json.load(f)return sorted(links, key=lambda x: x.get('order', 0))except Exception as e:print(f"Error loading links: {e}")return []def save_links(links):try:# 确保 data 目录存在DATA_FILE.parent.mkdir(parents=True, exist_ok=True)with open(DATA_FILE, 'w', encoding='utf-8') as f:json.dump(links, f, indent=2, ensure_ascii=False)return Trueexcept Exception as e:print(f"Error saving links: {e}")return Falsedef clean_url(url):"""清理 URL,移除域名部分只保留路径"""if url and url.startswith(('http://', 'https://')):return urlelif url and '/static/' in url:return url.split('/static/')[-1]return url@app.route('/')
def index():links = load_links()return render_template('index.html', links=links)@app.route('/edit')
def edit():links = load_links()return render_template('edit.html', links=links)@app.route('/api/upload', methods=['POST'])
def upload_file():if 'file' not in request.files:return jsonify({'error': 'No file part'}), 400file = request.files['file']if file.filename == '':return jsonify({'error': 'No selected file'}), 400if file and allowed_file(file.filename):filename = secure_filename(file.filename)filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)file.save(filepath)return jsonify({'url': f'/static/uploads/{filename}'})return jsonify({'error': 'Invalid file type'}), 400@app.route('/api/links', methods=['GET', 'POST', 'PUT', 'DELETE'])
def manage_links():try:if request.method == 'GET':return jsonify(load_links())elif request.method == 'POST':data = request.get_json()if not data:return jsonify({'status': 'error', 'message': 'No data provided'}), 400links = load_links()image_url = data.get('image', '/static/images/default.png')new_link = {'name': data.get('name', ''),'url': data.get('url', ''),'port': data.get('port', ''),'image': clean_url(image_url),'order': len(links)}links.append(new_link)if save_links(links):return jsonify({'status': 'success'})return jsonify({'status': 'error', 'message': 'Failed to save links'}), 500elif request.method == 'PUT':data = request.get_json()if not data:return jsonify({'status': 'error', 'message': 'No data provided'}), 400links = load_links()print("Received PUT data:", data) # 调试日志if 'reorder' in data:new_order = data.get('new_order', [])if not new_order:return jsonify({'status': 'error', 'message': 'Invalid order data'}), 400reordered_links = [links[i] for i in new_order]if save_links(reordered_links):return jsonify({'status': 'success'})else:try:index = int(data.get('index', -1))if index < 0 or index >= len(links):return jsonify({'status': 'error', 'message': f'Invalid index: {index}'}), 400image_url = data.get('image', links[index].get('image', '/static/images/default.png'))links[index].update({'name': data.get('name', links[index]['name']),'url': data.get('url', links[index]['url']),'port': data.get('port', links[index]['port']),'image': clean_url(image_url)})print("Updated link:", links[index]) # 调试日志if save_links(links):return jsonify({'status': 'success'})except ValueError as e:return jsonify({'status': 'error', 'message': f'Invalid data: {str(e)}'}), 400return jsonify({'status': 'error', 'message': 'Failed to update links'}), 500elif request.method == 'DELETE':try:index = int(request.args.get('index', -1))except ValueError:return jsonify({'status': 'error', 'message': 'Invalid index'}), 400if index < 0:return jsonify({'status': 'error', 'message': 'Invalid index'}), 400links = load_links()if 0 <= index < len(links):del links[index]if save_links(links):return jsonify({'status': 'success'})return jsonify({'status': 'error', 'message': 'Failed to delete link'}), 500except Exception as e:print(f"Error in manage_links: {e}") # 调试日志import tracebacktraceback.print_exc() # 打印完整的错误堆栈return jsonify({'status': 'error', 'message': str(e)}), 500if __name__ == '__main__':init_data_file()app.run(host='0.0.0.0', port=9999, debug=True)
b. templates 目录下文件
i. index.html
{% extends "base.html" %}
{% block title %}Web应用导航{% endblock %}
{% block content %}
<div class="header"><h1>Web应用导航</h1><a href="/edit" class="edit-btn">编辑导航</a>
</div><div class="grid" id="nav-grid">{% for link in links %}<!-- 将整个卡片变成链接 --><a href="{{ link.url }}" class="card" data-index="{{ loop.index0 }}"><div class="card-content"><div class="card-image-container"><img src="{{ link.image }}" alt="{{ link.name }}"></div><div class="card-title">{{ link.name }}</div><div class="port">端口: {{ link.port }}</div></div></a>{% endfor %}
</div>
{% endblock %}{% block scripts %}
<script>
document.addEventListener('DOMContentLoaded', function() {// 处理所有卡片的点击事件document.querySelectorAll('.card').forEach(card => {card.addEventListener('click', function(e) {e.preventDefault(); // 阻止默认链接行为const url = this.getAttribute('href');if (url) {// 在同一个标签页中打开链接window.location.href = url;}});});
});
</script>
{% endblock %}
ii. base.html
{% extends "base.html" %}
{% block title %}Web应用导航{% endblock %}
{% block content %}
<div class="header"><h1>Web应用导航</h1><a href="/edit" class="edit-btn">编辑导航</a>
</div><div class="grid" id="nav-grid">{% for link in links %}<!-- 将整个卡片变成链接 --><a href="{{ link.url }}" class="card" data-index="{{ loop.index0 }}"><div class="card-content"><div class="card-image-container"><img src="{{ link.image }}" alt="{{ link.name }}"></div><div class="card-title">{{ link.name }}</div><div class="port">端口: {{ link.port }}</div></div></a>{% endfor %}
</div>
{% endblock %}{% block scripts %}
<script>
document.addEventListener('DOMContentLoaded', function() {// 处理所有卡片的点击事件document.querySelectorAll('.card').forEach(card => {card.addEventListener('click', function(e) {e.preventDefault(); // 阻止默认链接行为const url = this.getAttribute('href');if (url) {// 在同一个标签页中打开链接window.location.href = url;}});});
});
</script>
{% endblock %}
iii. edit.html
# templates/edit.html
{% extends "base.html" %}
{% block title %}编辑导航{% endblock %}{% block content %}
<div class="edit-container"><div class="header"><h1>编辑导航</h1><a href="/" class="edit-btn">返回首页</a></div><div id="links-list">{% for link in links %}<div class="link-item" data-index="{{ loop.index0 }}"><i class="fas fa-grip-vertical drag-handle"></i><div class="link-image-container"><img src="{{ link.image }}" class="link-image" alt="{{ link.name }}"></div><div class="link-info"><input type="text" value="{{ link.name }}" placeholder="名称" class="name-input"><input type="text" value="{{ link.url }}" placeholder="URL" class="url-input"><input type="text" value="{{ link.port }}" placeholder="端口" class="port-input"><input type="file" class="image-input" accept="image/*" style="display: none;"><button class="btn" onclick="this.previousElementSibling.click()">更换图片</button></div><div class="link-actions"><button class="btn btn-primary" onclick="saveLink({{ loop.index0 }})">保存</button><button class="btn btn-danger" onclick="deleteLink({{ loop.index0 }})">删除</button></div></div>{% endfor %}</div><div class="form-container" style="margin-top: 20px;"><h2>添加新链接</h2><div class="form-group"><label>名称</label><input type="text" id="new-name"></div><div class="form-group"><label>URL</label><input type="text" id="new-url"></div><div class="form-group"><label>端口</label><input type="text" id="new-port"></div><div class="form-group"><label>图片</label><input type="file" id="new-image" accept="image/*"></div><button class="btn btn-primary" onclick="addNewLink()">添加</button></div>
</div>
{% endblock %}{% block scripts %}
<script>
document.addEventListener('DOMContentLoaded', function() {// 初始化拖拽排序const linksList = document.getElementById('links-list');if (linksList) {new Sortable(linksList, {handle: '.drag-handle',animation: 150,onEnd: function() {const items = document.querySelectorAll('.link-item');const newOrder = Array.from(items).map(item => parseInt(item.dataset.index));fetch('/api/links', {method: 'PUT',headers: {'Content-Type': 'application/json',},body: JSON.stringify({reorder: true,new_order: newOrder})});}});}// 处理图片上传document.querySelectorAll('.image-input').forEach(input => {input.addEventListener('change', async function(e) {const file = e.target.files[0];if (!file) return;const formData = new FormData();formData.append('file', file);try {const response = await fetch('/api/upload', {method: 'POST',body: formData});const data = await response.json();if (data.url) {const linkItem = this.closest('.link-item');if (linkItem) {linkItem.querySelector('.link-image').src = data.url;}}} catch (error) {console.error('Error uploading image:', error);alert('图片上传失败,请重试!');}});});
});
</script>
{% endblock %}
c. static 目录下文件
i. ./css/style.css
/* static/css/style.css */
body {font-family: Arial, sans-serif;margin: 0;padding: 20px;background-color: #f5f5f5;
}.header {display: flex;justify-content: space-between;align-items: center;margin-bottom: 20px;padding: 0 20px;
}.edit-btn {padding: 8px 16px;background-color: #007bff;color: white;text-decoration: none;border-radius: 4px;
}/* 导航卡片网格 */
.grid {display: grid;grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));gap: 20px;padding: 20px;
}/* 卡片样式 */
.card {background: white;border-radius: 8px;padding: 15px;box-shadow: 0 2px 4px rgba(0,0,0,0.1);transition: transform 0.2s;display: flex;flex-direction: column;text-decoration: none; /* 移除链接的默认下划线 */color: inherit; /* 继承颜色 */
}.card:hover {transform: translateY(-5px);
}.card-content {flex: 1;display: flex;flex-direction: column;pointer-events: none; /* 防止内部元素影响点击 */
}.card-image-container {width: 100%;height: 200px;display: flex;align-items: center;justify-content: center;overflow: hidden;margin-bottom: 10px;border-radius: 4px;background-color: #f8f9fa;
}.card img {max-width: 100%;max-height: 100%;width: auto;height: auto;object-fit: contain;
}.card-title {color: #333;font-weight: bold;margin-top: 10px;font-size: 1.1em;
}.port {color: #666;font-size: 0.9em;margin-top: 5px;
}/* 编辑页面样式 */
.edit-container {max-width: 800px;margin: 0 auto;
}.form-container {max-width: 800px;margin: 0 auto;
}.form-group {margin-bottom: 15px;
}.form-group label {display: block;margin-bottom: 5px;
}.form-group input {width: 100%;padding: 8px;border: 1px solid #ddd;border-radius: 4px;
}.link-item {display: flex;align-items: center;background: white;padding: 15px;margin-bottom: 10px;border-radius: 4px;box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}/* 编辑页面的图片容器 */
.link-image-container {width: 100px;height: 100px;display: flex;align-items: center;justify-content: center;margin-right: 15px;border-radius: 4px;background-color: #f8f9fa;overflow: hidden;
}/* 编辑页面的图片 */
.link-image {max-width: 100%;max-height: 100%;width: auto;height: auto;object-fit: contain;
}.link-info {flex-grow: 1;margin-right: 15px;
}.link-info input {margin-bottom: 8px;width: 100%;
}.link-actions {display: flex;gap: 10px;
}.btn {padding: 8px 16px;border: none;border-radius: 4px;cursor: pointer;transition: background-color 0.2s;
}.btn:hover {opacity: 0.9;
}.btn-primary {background-color: #007bff;color: white;
}.btn-primary:hover {background-color: #0056b3;
}.btn-danger {background-color: #dc3545;color: white;
}.btn-danger:hover {background-color: #c82333;
}.drag-handle {cursor: move;color: #666;margin-right: 10px;padding: 10px;
}/* 响应式调整 */
@media (max-width: 768px) {.grid {grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));}.link-item {flex-direction: column;align-items: flex-start;}.link-image-container {width: 100%;margin-bottom: 10px;margin-right: 0;}.link-actions {width: 100%;justify-content: flex-end;margin-top: 10px;}
}
ii. ./js/main.js
// static/js/main.js
document.addEventListener('DOMContentLoaded', function() {// 初始化拖拽排序const linksList = document.getElementById('links-list');if (linksList) {new Sortable(linksList, {handle: '.drag-handle',animation: 150,onEnd: function() {// 获取新的排序const items = document.querySelectorAll('.link-item');const newOrder = Array.from(items).map(item => parseInt(item.dataset.index));// 发送到服务器fetch('/api/links', {method: 'PUT',headers: {'Content-Type': 'application/json',},body: JSON.stringify({reorder: true,new_order: newOrder})});}});}// 处理图片上传document.querySelectorAll('.image-input').forEach(input => {input.addEventListener('change', handleImageUpload);});// 绑定新增链接的图片上传const newImageInput = document.getElementById('new-image');if (newImageInput) {newImageInput.addEventListener('change', handleImageUpload);}
});// 处理图片上传的函数
async function handleImageUpload(event) {const file = event.target.files[0];if (!file) return;if (!['image/jpeg', 'image/png', 'image/gif'].includes(file.type)) {alert('请上传 JPG、PNG 或 GIF 格式的图片!');return;}const formData = new FormData();formData.append('file', file);try {const response = await fetch('/api/upload', {method: 'POST',body: formData});if (!response.ok) {throw new Error('上传失败');}const data = await response.json();if (data.url) {const linkItem = this.closest('.link-item');if (linkItem) {linkItem.querySelector('.link-image').src = data.url;}} else {throw new Error(data.error || '上传失败');}} catch (error) {console.error('Error uploading image:', error);alert('图片上传失败:' + error.message);}
}// 保存链接
window.saveLink = async function(index) {const linkItem = document.querySelector(`.link-item[data-index="${index}"]`);const name = linkItem.querySelector('.name-input').value.trim();const url = linkItem.querySelector('.url-input').value.trim();const port = linkItem.querySelector('.port-input').value.trim();const image = linkItem.querySelector('.link-image').src;// 验证数据if (!name || !url || !port) {alert('请填写所有必需的字段!');return;}try {const response = await fetch('/api/links', {method: 'PUT',headers: {'Content-Type': 'application/json',},body: JSON.stringify({index: index,name: name,url: url,port: port,image: image})});const result = await response.json();if (response.ok && result.status === 'success') {alert('保存成功!');} else {throw new Error(result.message || '保存失败');}} catch (error) {console.error('Error saving link:', error);alert('保存失败,请重试!错误信息:' + error.message);}
};// 删除链接
window.deleteLink = async function(index) {if (!confirm('确定要删除这个链接吗?')) {return;}try {const response = await fetch(`/api/links?index=${index}`, {method: 'DELETE'});if (response.ok) {location.reload();} else {throw new Error('删除失败');}} catch (error) {console.error('Error deleting link:', error);alert('删除失败,请重试!');}
};// 添加新链接
window.addNewLink = async function() {const name = document.getElementById('new-name').value;const url = document.getElementById('new-url').value;const port = document.getElementById('new-port').value;const imageFile = document.getElementById('new-image').files[0];let image = '/static/images/default.png';try {if (imageFile) {const formData = new FormData();formData.append('file', imageFile);const response = await fetch('/api/upload', {method: 'POST',body: formData});const data = await response.json();if (data.url) {image = data.url;}}const response = await fetch('/api/links', {method: 'POST',headers: {'Content-Type': 'application/json',},body: JSON.stringify({name,url,port,image})});if (response.ok) {location.reload();} else {throw new Error('添加失败');}} catch (error) {console.error('Error adding new link:', error);alert('添加失败,请重试!');}
};
iii. favicon.jpg
d. ./uploading/ 图片文件
图片会被 网站 打上水印,就不传。
推荐从 Midjourney.com 寻找与下载, AI created 图片是没有版权的,即:随便用。
4. 部署到 QNAP NAS Docker/Container上
a. Docker 部署文件
i. Dockerfile
# Dockerfile
FROM python:3.9-slim# 工作目录
WORKDIR /app# 环境变量
ENV PYTHONDONTWRITEBYTECODE=1 \PYTHONUNBUFFERED=1 \FLASK_APP=app.py \FLASK_ENV=production# 系统依赖
RUN apt-get update && apt-get install -y --no-install-recommends \gcc \&& rm -rf /var/lib/apt/lists/*# 复制文件
COPY requirements.txt .
COPY app.py .
COPY static static/
COPY templates templates/
COPY data data/# 创建上传目录
RUN mkdir -p static/uploads && \chmod -R 777 static/uploads data# 安装Python依赖
RUN pip install --no-cache-dir -r requirements.txt# 端口
EXPOSE 9999# 启动命令
CMD ["python", "app.py"]
ii. requirements.txt min
flask
Werkzeug
b. 执行 docker 部署命令
i.CMD: docker build -t navigator_portal .
[/share/Multimedia/2024-MyProgramFiles/23.Navigator_Portal] # docker build -t navigator_portal .
DEPRECATED: The legacy builder is deprecated and will be removed in a future release.Install the buildx component to build images with BuildKit:https://docs.docker.com/go/buildx/Sending build context to Docker daemon 56.25MB
Step 1/13 : FROM python:3.9-slim---> 6a22698eab0e
Step 2/13 : WORKDIR /app
...
...---> d39c4c26f2c1
Successfully built d39c4c26f2c1
Successfully tagged navigator_portal:latest
[/share/Multimedia/2024-MyProgramFiles/23.Navigator_Portal] #
ii. CMD: docker run...
[/share/Multimedia/2024-MyProgramFiles/23.Navigator_Portal] # docker run -d -p 9999:9999 --name navigator_portal_container --restart always navigator_portal
31859f34dfc072740b38a4ebcdb9e9b6789acf95286b1e515126f2927c8467d5
[/share/Multimedia/2024-MyProgramFiles/23.Navigator_Portal] #
5. 界面功能介绍
a. 页面总览
注:第一次使用这 app 代码,会因为缺失图片文件,而可能显示如下:撕裂的文件
b. 功能:
- 鼠标移到图标,会向上移动,提醒被选中。
- 点击右上角,蓝色 “编辑导航” 按钮,可能对图标内容修改
c. 编辑页面
d. 功能:
- 图标排序:按住图标左侧的“6个点” 可以上下拖动 松手后即保存 (“编辑界面” 图1)
- 图标体:可以删除、添加 (“编辑界面” 图3)
- 图标内容可修改:描述, URL, 端口、图片更换 (“编辑界面” 图1 图2)
- 对多条图标内容修改后,需要对每个图标都要点击 “保存”
已知问题:
- 图片不是 resize 保存,最好别使用太大的文件,尤其是在非 LAN 访问
- 图片的 URL 内容结尾不要有 "/" , 在移动图标顺序时会不成功