目的:
浏览器的地址簿太厚,如下图:
开始,想给每个 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_filename
app = 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_EXTENSIONS
def 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 True
except Exception as e:
print(f"Error saving links: {e}")
return False
def clean_url(url):
"""清理 URL,移除域名部分只保留路径"""
if url and url.startswith(('http://', 'https://')):
return url
elif 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'}), 400
file = request.files['file']
if file.filename == '':
return jsonify({'error': 'No selected file'}), 400
if 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'}), 400
links = 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'}), 500
elif request.method == 'PUT':
data = request.get_json()
if not data:
return jsonify({'status': 'error', 'message': 'No data provided'}), 400
links = 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'}), 400
reordered_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}'}), 400
image_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)}'}), 400
return jsonify({'status': 'error', 'message': 'Failed to update links'}), 500
elif request.method == 'DELETE':
try:
index = int(request.args.get('index', -1))
except ValueError:
return jsonify({'status': 'error', 'message': 'Invalid index'}), 400
if index < 0:
return jsonify({'status': 'error', 'message': 'Invalid index'}), 400
links = 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'}), 500
except Exception as e:
print(f"Error in manage_links: {e}") # 调试日志
import traceback
traceback.print_exc() # 打印完整的错误堆栈
return jsonify({'status': 'error', 'message': str(e)}), 500
if __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 内容结尾不要有 "/" , 在移动图标顺序时会不成功