自学内容网 自学内容网

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

目的:

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

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

已知问题:

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


原文地址:https://blog.csdn.net/davenian/article/details/143774997

免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!