<Project-23 Navigator Portal> Python flask web 网站导航应用 可编辑界面:添加图片、URL、描述、位置移动

news/2024/11/18 23:08:44/

目的:

浏览器的地址簿太厚,如下图:

开始,想给每个 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)
  • 对多条图标内容修改后,需要对每个图标都要点击 “保存”

已知问题:

  1. 图片不是 resize 保存,最好别使用太大的文件,尤其是在非 LAN 访问
  2. 图片的 URL 内容结尾不要有 "/" , 在移动图标顺序时会不成功


http://www.ppmy.cn/news/1548093.html

相关文章

neo4j desktop基本入门

下载安装不在赘述&#xff0c;本文只记述一些neo4j的基本入门操作 连接本地neo4j数据库 1. 点击ADD添加连接 端口一般是7687 账户名和密码忘记了&#xff0c;可以通过neo4j web&#xff08;默认为neo4jneo4j://localhost:7687/neo4j - Neo4j Browser&#xff09;重置密码 AL…

C++和OpenGL实现3D游戏编程【连载18】——加载OBJ三维模型

1、本节课要实现的内容 以前我们加载过立方体木箱,立方体的顶点数据都是在程序运行时临时定义的。但后期如果模型数量增多,模型逐步复杂,我们就必须加载外部模型文件。这节课我们就先了解一下加载OBJ模型文件的方法,这样可以让编程和设计进行分工合作,极大丰富我们游戏效…

开发语言中,堆区和栈区的区别

非javascript 1. 存储方式 栈区&#xff1a;栈区&#xff08;Stack&#xff09;是由系统自动分配的内存区域&#xff0c;通常用于存储函数的局部变量、参数、返回地址等。栈区的内存按照先进后出的顺序进行管理。堆区&#xff1a;堆区&#xff08;Heap&#xff09;是由程序员…

技术理论||01无人机倾斜摄影原理

1.1 无人机倾斜摄影测量原理 倾斜摄影测量技术是在摄影测量的基础上发展而来的,它突破了传统摄影测量只能从单一的垂直角度拍摄影像的局限性,能在同一高度从多个角度获取地物信息。   该技术通过在同一飞行平台上搭载多视角倾斜相机(通常为五镜头相机),在同一航高从垂直和倾斜…

第八章 利用CSS制作导航菜单

8.1 水平顶部导航栏 1.简单水平导航栏的设计和实现 1.导航栏的创建 <nav>标签是HIML5新增的文档结构标签&#xff0c;用于标记导航栏&#xff0c;以便后续与网站的其他内容整合&#xff0c;所以常用<nav>标签在页面上创建导航栏菜单区域 代码 <!DOCTYPE ht…

Axure设计之文本编辑器制作教程

文本编辑器是一个功能强大的工具&#xff0c;允许用户在图形界面中创建和编辑文本的格式和布局&#xff0c;如字体样式、大小、颜色、对齐方式等&#xff0c;在Web端实际项目中&#xff0c;文本编辑器的使用非常频繁。以下是在Axure中模拟web端富文本编辑器&#xff0c;来制作文…

【计算机网络】UDP网络程序

一、服务端 1.udpServer.hpp 此文件负责实现一个udp服务器 #pragma once#include <iostream> #include <string> #include <cstdlib> #include <cstring> #include <functional> #include <strings.h> #include <unistd.h> #incl…

【Jenkins实战】Windows安装服务启动失败

写此篇短文&#xff0c;望告诫后人。 如果你之前装过Jenkins&#xff0c;出于换域账号/本地帐号的原因想重新安装&#xff0c;你大概率会遇上一次Jenkins服务启动失败提示&#xff1a; Jenkins failed to start - Verify that you have sufficient privileges to start system…