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

server/2024/11/19 1:36:14/

目的:

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

开始,想给每个 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/server/143042.html

相关文章

HbuilderX 插件开发-模板创建

实现思路 使用HbuilderX 打开某个文档时右键点击的时候获取当前打开的文档内容使用 API 替换为自己的模板 示例 package.json {"id": "SL-HbuilderX-Tool","name": "SL-HbuilderX-Tool","description": "快速创建h…

微信小程序自定义顶部导航栏(适配各种机型)

效果图 1.pages.js&#xff0c;需要自定义导航栏的页面设置"navigationStyle": "custom" 2.App.vue,获取设备高度及胶囊位置 onLaunch: function () {// 系统信息const systemInfo uni.getSystemInfoSync()// 胶囊按钮位置信息const menuButtonInfo uni.…

【免越狱】iOS砸壳 可下载AppStore任意版本 旧版本IPA下载

软件介绍 下载iOS旧版应用&#xff0c;简化繁琐的抓包流程。 一键生成去更新IPA&#xff08;手机安装后&#xff0c;去除App Store的更新检测&#xff09;。 软件界面 支持系统 Windows 10/Windows 8/Windows 7&#xff08;由于使用了Fiddler库&#xff0c;因此需要.Net环境…

excel-VLOOKUP函数使用/XVLOOKUP使用

多个窗口同时编辑表格&#xff0c;方便对照操作 使用开始-视图-新建窗口 将战区信息表的三列数据匹配到成交数据表上 可以使用VLOOKUP函数 有4个参数&#xff08;必须要查找的值&#xff0c; 要查找的区域&#xff0c;要返回区域的第几列数据&#xff0c;一个可选参数查找匹…

android studio new flutter project-运行第一个flutter项目

android studio new flutter project win10系统&#xff0c;由于之前尝试学习RN的时候已经安装了android studio 所以在尝试运行Flutter项目省去了一些步骤 这里说一下如何在android studio创建第一个flutter project 下载flutter sdk 到 https://docs.flutter.cn/release/a…

python核心语法

目录 核⼼语法第⼀节 变量0.变量名规则1.下⾯这些都是不合法的变量名2.关键字3.变量赋值4.变量的销毁 第⼆节 数据类型0.数值1.字符串2.布尔值(boolean, bool)3.空值 None 核⼼语法 第⼀节 变量 变量的定义变量就是可变的量&#xff0c;对于⼀些有可能会经常变化的数据&#…

flutter下拉刷新上拉加载的简单实现方式三

使用 CustomScrollView 结合 SliverList 实现了一个支持下拉刷新和上拉加载更多功能的滚动列表&#xff0c;对下面代码进行解析学习。 import dart:math;import package:flutter/material.dart;import custom_pull/gsy_refresh_sliver.dart; import package:flutter/cupertino…

华为HCIP——MSTP/RSTP与STP的兼容性

一、MSTP/RSTP与STP的兼容性的原理&#xff1a; 1.BPDU版本号识别&#xff1a;运行MSTP/RSTP协议的交换机会根据收到的BPDU&#xff08;Bridge Protocol Data Unit&#xff0c;桥协议数据单元&#xff09;版本号信息自动判断与之相连的交换机的运行模式。如果收到的是STP BPDU…