Kaynağa Gözat

测试上传

liuq 2 hafta önce
ebeveyn
işleme
4a9769c58c
5 değiştirilmiş dosya ile 544 ekleme ve 3 silme
  1. 133 2
      api/main.py
  2. 1 1
      flask_api.py
  3. 248 0
      templates/attachment_test/index.html
  4. 3 0
      templates/base.html
  5. 159 0
      templates/js/im-sdk.js

+ 133 - 2
api/main.py

@@ -1,5 +1,11 @@
-from flask import Blueprint, render_template, send_from_directory, current_app, redirect, url_for, request, jsonify
-from api.utils import login_required, load_led_config
+import os
+import time
+import uuid
+
+from flask import Blueprint, render_template, send_from_directory, current_app, redirect, url_for, request, jsonify, session
+from werkzeug.utils import secure_filename
+
+from api.utils import login_required, load_led_config, get_server_ip
 from application.scheduler_service import scheduler_service
 from application.self_check_service import run_all_checks
 from application.uap_message_service import send_self_check_notification
@@ -7,6 +13,54 @@ from utils.logger_config import logger
 
 main_bp = Blueprint('main', __name__)
 
+ATTACHMENT_TEST_SESSION_KEY = 'attachment_test_filename'
+# 附件测试页允许的类型(单附件,新上传会替换)
+ATTACHMENT_TEST_EXTENSIONS = {
+    'png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'heic', 'heif',
+    'mp4', 'mov', 'webm', 'mkv', 'avi',
+    'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx',
+    'txt', 'csv', 'zip', 'rar', '7z',
+    'mp3', 'wav', 'm4a',
+}
+
+
+def _attachment_test_public_url(filename):
+    if not filename:
+        return None
+    host = request.host
+    if 'localhost' in host or '127.0.0.1' in host:
+        server_ip = get_server_ip()
+        port = request.environ.get('SERVER_PORT', '5000')
+        host = f"{server_ip}:{port}"
+    scheme = request.headers.get('X-Forwarded-Proto', request.scheme)
+    return f"{scheme}://{host}/uploads/{filename}"
+
+
+def _attachment_test_remove_stored():
+    """删除 session 中记录的文件(若存在)。"""
+    name = session.pop(ATTACHMENT_TEST_SESSION_KEY, None)
+    if not name:
+        return
+    path = os.path.join(current_app.config['UPLOAD_FOLDER'], name)
+    try:
+        if os.path.isfile(path):
+            os.remove(path)
+    except OSError as e:
+        logger.warning(f"删除附件测试文件失败 {path}: {e}")
+
+
+def _guess_ext_from_content_type(content_type):
+    if not content_type:
+        return None
+    ct = content_type.lower()
+    mapping = {
+        'image/png': 'png', 'image/jpeg': 'jpg', 'image/jpg': 'jpg', 'image/gif': 'gif',
+        'image/webp': 'webp', 'image/bmp': 'bmp', 'image/heic': 'heic', 'image/heif': 'heif',
+        'video/mp4': 'mp4', 'video/quicktime': 'mov', 'video/webm': 'webm',
+        'application/pdf': 'pdf',
+    }
+    return mapping.get(ct.split(';')[0].strip())
+
 @main_bp.route('/')
 @login_required
 def index():
@@ -44,6 +98,83 @@ def self_check_page():
     """设备自检页面"""
     return render_template('self_check.html', active_page='self_check')
 
+
+@main_bp.route('/attachment_upload_test')
+@login_required
+def attachment_test_page():
+    """附件上传测试页面(单附件,会话内替换)"""
+    return render_template('attachment_test/index.html', active_page='attachment_test')
+
+
+@main_bp.route('/api/attachment_test/status', methods=['GET'])
+@login_required
+def attachment_test_status():
+    """当前会话中的附件信息"""
+    fn = session.get(ATTACHMENT_TEST_SESSION_KEY)
+    if not fn:
+        return jsonify({'success': True, 'has_file': False})
+    folder = current_app.config['UPLOAD_FOLDER']
+    path = os.path.join(folder, fn)
+    if not os.path.isfile(path):
+        session.pop(ATTACHMENT_TEST_SESSION_KEY, None)
+        return jsonify({'success': True, 'has_file': False})
+    return jsonify({
+        'success': True,
+        'has_file': True,
+        'filename': fn,
+        'url': _attachment_test_public_url(fn),
+    })
+
+
+@main_bp.route('/api/attachment_test/upload', methods=['POST'])
+@login_required
+def attachment_test_upload():
+    """上传单个附件,覆盖会话中已有文件"""
+    if 'file' not in request.files:
+        return jsonify({'success': False, 'message': '缺少文件字段 file'}), 400
+    file = request.files['file']
+    if not file or file.filename == '':
+        return jsonify({'success': False, 'message': '未选择文件'}), 400
+
+    original = file.filename
+    base = secure_filename(original)
+    ext = None
+    if base and '.' in base:
+        ext = base.rsplit('.', 1)[1].lower()
+    if not ext or ext not in ATTACHMENT_TEST_EXTENSIONS:
+        ext = _guess_ext_from_content_type(file.content_type or '')
+    if not ext or ext not in ATTACHMENT_TEST_EXTENSIONS:
+        return jsonify({
+            'success': False,
+            'message': f'不支持的文件类型,允许: {", ".join(sorted(ATTACHMENT_TEST_EXTENSIONS))}',
+        }), 400
+
+    _attachment_test_remove_stored()
+
+    new_name = f"att_{int(time.time() * 1000)}_{uuid.uuid4().hex[:8]}.{ext}"
+    folder = current_app.config['UPLOAD_FOLDER']
+    os.makedirs(folder, exist_ok=True)
+    filepath = os.path.join(folder, new_name)
+    file.save(filepath)
+    session[ATTACHMENT_TEST_SESSION_KEY] = new_name
+
+    return jsonify({
+        'success': True,
+        'message': '上传成功',
+        'filename': new_name,
+        'url': _attachment_test_public_url(new_name),
+    })
+
+
+@main_bp.route('/api/attachment_test', methods=['DELETE'])
+@login_required
+def attachment_test_delete():
+    """删除当前会话中的附件"""
+    if not session.get(ATTACHMENT_TEST_SESSION_KEY):
+        return jsonify({'success': True, 'message': '没有可删除的附件'})
+    _attachment_test_remove_stored()
+    return jsonify({'success': True, 'message': '已删除'})
+
 @main_bp.route('/api/send_report', methods=['POST'])
 @login_required
 def send_report_api():

+ 1 - 1
flask_api.py

@@ -23,7 +23,7 @@ CORS(app)  # 允许跨域请求
 
 # 配置上传文件夹
 app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
-app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024  # 限制上传文件大小为16MB
+app.config['MAX_CONTENT_LENGTH'] = 100 * 1024 * 1024  # 限制上传文件大小为 100MB(含视频附件)
 
 # 确保上传文件夹存在
 if not os.path.exists(UPLOAD_FOLDER):

+ 248 - 0
templates/attachment_test/index.html

@@ -0,0 +1,248 @@
+{% extends "base.html" %}
+
+{% block title %}附件上传测试{% endblock %}
+
+{% block extra_styles %}
+<style>
+    .attachment-upload-grid {
+        display: flex;
+        flex-wrap: wrap;
+        gap: 10px;
+        margin-bottom: 20px;
+    }
+    .attachment-upload-grid .btn {
+        flex: 1 1 auto;
+        min-width: 140px;
+    }
+    .attachment-preview-wrap {
+        background: #f8f9fa;
+        border: 1px solid #eee;
+        border-radius: 10px;
+        padding: 20px;
+        min-height: 120px;
+    }
+    .attachment-preview-wrap.empty {
+        color: #888;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+    }
+    .attachment-preview-wrap img,
+    .attachment-preview-wrap video {
+        max-width: 100%;
+        max-height: 360px;
+        border-radius: 8px;
+        display: block;
+    }
+    .attachment-file-meta {
+        margin-top: 12px;
+        word-break: break-all;
+        font-size: 0.9em;
+        color: #555;
+    }
+    .attachment-actions {
+        display: flex;
+        flex-wrap: wrap;
+        gap: 10px;
+        margin-top: 16px;
+    }
+    .attachment-url-box {
+        font-size: 0.85em;
+        color: #667eea;
+        word-break: break-all;
+        margin-top: 8px;
+    }
+</style>
+{% endblock %}
+
+{% block content %}
+    <div class="module-header">
+        <h2>📎 附件上传测试</h2>
+    </div>
+
+    <div class="sub-section">
+        <h2 style="font-size: 1.1em; margin-bottom: 16px;">选择来源</h2>
+        <p style="color: #666; margin-bottom: 12px; font-size: 0.95em;">每次仅保留一个附件;新上传会替换当前附件。</p>
+
+        <div class="attachment-upload-grid">
+            <button type="button" class="btn btn-primary" onclick="document.getElementById('inpCameraPhoto').click()">拍照</button>
+            <button type="button" class="btn btn-primary" onclick="document.getElementById('inpCameraVideo').click()">拍视频</button>
+            <button type="button" class="btn btn-info" onclick="document.getElementById('inpGalleryImage').click()">相册图片</button>
+            <button type="button" class="btn btn-info" onclick="document.getElementById('inpGalleryVideo').click()">相册视频</button>
+            <button type="button" class="btn btn-warning" onclick="document.getElementById('inpAnyFile').click()">选择文件</button>
+        </div>
+
+        <input type="file" id="inpCameraPhoto" accept="image/*" capture="environment" style="display:none" />
+        <input type="file" id="inpCameraVideo" accept="video/*" capture="environment" style="display:none" />
+        <input type="file" id="inpGalleryImage" accept="image/*" style="display:none" />
+        <input type="file" id="inpGalleryVideo" accept="video/*" style="display:none" />
+        <input type="file" id="inpAnyFile" style="display:none" />
+
+        <h2 style="font-size: 1.1em; margin: 24px 0 12px;">当前附件</h2>
+        <div id="previewWrap" class="attachment-preview-wrap empty">暂无附件,请使用上方按钮上传</div>
+        <div id="previewActions" class="attachment-actions" style="display: none;">
+            <button type="button" class="btn btn-primary" id="btnShare">分享链接</button>
+            <button type="button" class="btn btn-secondary" id="btnDelete">删除</button>
+        </div>
+        <div id="urlBox" class="attachment-url-box" style="display: none;"></div>
+    </div>
+{% endblock %}
+
+{% block scripts %}
+<script>
+(function () {
+    let currentUrl = '';
+    let currentFilename = '';
+
+    const previewWrap = document.getElementById('previewWrap');
+    const previewActions = document.getElementById('previewActions');
+    const urlBox = document.getElementById('urlBox');
+
+    function isImageName(name) {
+        return /\.(png|jpe?g|gif|bmp|webp|heic|heif)$/i.test(name || '');
+    }
+    function isVideoName(name) {
+        return /\.(mp4|mov|webm|mkv|avi)$/i.test(name || '');
+    }
+
+    function clearInputs() {
+        ['inpCameraPhoto', 'inpCameraVideo', 'inpGalleryImage', 'inpGalleryVideo', 'inpAnyFile'].forEach(function (id) {
+            const el = document.getElementById(id);
+            if (el) el.value = '';
+        });
+    }
+
+    function renderPreview(data) {
+        currentUrl = data.url || '';
+        currentFilename = data.filename || '';
+        urlBox.style.display = 'none';
+        urlBox.textContent = '';
+
+        if (!data.has_file || !data.url) {
+            previewWrap.className = 'attachment-preview-wrap empty';
+            previewWrap.textContent = '暂无附件,请使用上方按钮上传';
+            previewActions.style.display = 'none';
+            return;
+        }
+
+        previewWrap.className = 'attachment-preview-wrap';
+        previewWrap.innerHTML = '';
+
+        if (isImageName(data.filename)) {
+            const img = document.createElement('img');
+            img.src = data.url;
+            img.alt = data.filename;
+            previewWrap.appendChild(img);
+        } else if (isVideoName(data.filename)) {
+            const v = document.createElement('video');
+            v.src = data.url;
+            v.controls = true;
+            v.playsInline = true;
+            previewWrap.appendChild(v);
+        } else {
+            const p = document.createElement('p');
+            p.textContent = '已上传文件(非预览类型)';
+            previewWrap.appendChild(p);
+        }
+        const meta = document.createElement('div');
+        meta.className = 'attachment-file-meta';
+        meta.textContent = data.filename;
+        previewWrap.appendChild(meta);
+
+        previewActions.style.display = 'flex';
+    }
+
+    async function refreshStatus() {
+        try {
+            const r = await fetch('/api/attachment_test/status');
+            const data = await r.json();
+            if (data.success) {
+                renderPreview(data);
+            }
+        } catch (e) {
+            showMessage('加载状态失败: ' + e.message, 'error');
+        }
+    }
+
+    async function uploadFile(file) {
+        if (!file) return;
+        const fd = new FormData();
+        fd.append('file', file);
+        showLoading(true);
+        try {
+            const r = await fetch('/api/attachment_test/upload', { method: 'POST', body: fd });
+            const data = await r.json();
+            if (data.success) {
+                showMessage(data.message || '上传成功');
+                renderPreview({
+                    has_file: true,
+                    filename: data.filename,
+                    url: data.url,
+                });
+            } else {
+                showMessage(data.message || '上传失败', 'error');
+            }
+        } catch (e) {
+            showMessage('上传失败: ' + e.message, 'error');
+        } finally {
+            showLoading(false);
+            clearInputs();
+        }
+    }
+
+    function wireInput(id) {
+        const el = document.getElementById(id);
+        if (!el) return;
+        el.addEventListener('change', function () {
+            const f = this.files && this.files[0];
+            if (f) uploadFile(f);
+        });
+    }
+    ['inpCameraPhoto', 'inpCameraVideo', 'inpGalleryImage', 'inpGalleryVideo', 'inpAnyFile'].forEach(wireInput);
+
+    document.getElementById('btnShare').addEventListener('click', async function () {
+        if (!currentUrl) {
+            showMessage('没有可分享的链接', 'error');
+            return;
+        }
+        if (navigator.share) {
+            try {
+                await navigator.share({ title: '附件链接', text: currentFilename, url: currentUrl });
+                return;
+            } catch (e) {
+                if (e.name === 'AbortError') return;
+            }
+        }
+        try {
+            await navigator.clipboard.writeText(currentUrl);
+            showMessage('链接已复制到剪贴板');
+        } catch (e) {
+            urlBox.style.display = 'block';
+            urlBox.textContent = currentUrl;
+            showMessage('请手动复制上方链接', 'error');
+        }
+    });
+
+    document.getElementById('btnDelete').addEventListener('click', async function () {
+        if (!confirm('确定删除当前附件?')) return;
+        showLoading(true);
+        try {
+            const r = await fetch('/api/attachment_test', { method: 'DELETE' });
+            const data = await r.json();
+            if (data.success) {
+                showMessage(data.message || '已删除');
+                renderPreview({ has_file: false });
+            } else {
+                showMessage(data.message || '删除失败', 'error');
+            }
+        } catch (e) {
+            showMessage('删除失败: ' + e.message, 'error');
+        } finally {
+            showLoading(false);
+        }
+    });
+
+    refreshStatus();
+})();
+</script>
+{% endblock %}

+ 3 - 0
templates/base.html

@@ -459,6 +459,9 @@
             <a href="{{ url_for('main.self_check_page') }}" class="nav-item {% if active_page == 'self_check' %}active{% endif %}">
                 <span>🛡️</span> 设备自检
             </a>
+            <a href="{{ url_for('main.attachment_test_page') }}" class="nav-item {% if active_page == 'attachment_test' %}active{% endif %}">
+                <span>📎</span> 附件上传测试
+            </a>
         </nav>
         <div class="sidebar-footer">
             <a href="/logout" class="logout-btn">退出登录</a>

+ 159 - 0
templates/js/im-sdk.js

@@ -0,0 +1,159 @@
+/**
+ * IM 平台全端通用 Web-SDK (im-sdk.js)
+ * 作用:统一子应用调用原生设备能力的接口,防止 WebView 直接调用引发的内存崩溃 (OOM)。
+ * 版本:v1.0.0
+ */
+(function(window, document) {
+    // ---------------------------------------------------------
+    // 1. 环境准备:确保注入 DCloud 官方 Webview 通信 SDK
+    // ---------------------------------------------------------
+    if (!window.uni || !window.uni.webView) {
+        var script = document.createElement('script');
+        script.type = 'text/javascript';
+        // 动态引入 uni.webview.js
+        script.src = 'https://js.cdn.aliyun.dcloud.net.cn/dev/uni-app/uni.webview.1.5.2.js';
+        document.head.appendChild(script);
+    }
+
+    // ---------------------------------------------------------
+    // 2. 通信基建:回调注册与响应机制
+    // ---------------------------------------------------------
+    window.imBridgeCallbacks = {};
+
+    /**
+     * 供 App 壳子 (uni-app) 调用的全局方法,用于将原生执行结果塞回给 H5
+     * @param {string} callbackId - 唯一回调标志
+     * @param {object} data - 原生层返回的数据
+     */
+    window.imBridgeInvoke = function(callbackId, data) {
+        if (window.imBridgeCallbacks[callbackId]) {
+            // 执行业务代码中传入的 success/fail 回调
+            window.imBridgeCallbacks[callbackId](data);
+            // 执行完毕,销毁内存中的回调函数
+            delete window.imBridgeCallbacks[callbackId];
+        }
+    };
+
+    /**
+     * 内部核心方法:向 App 壳子发送指令
+     */
+    function sendCommandToApp(action, params, callback) {
+        var callbackId = 'cb_' + action + '_' + Date.now() + '_' + Math.floor(Math.random() * 1000);
+        
+        if (typeof callback === 'function') {
+            window.imBridgeCallbacks[callbackId] = callback;
+        }
+
+        // 确保 uni.webView 已经加载完毕再发送
+        var checkAndSend = function() {
+            if (window.uni && window.uni.webView) {
+                window.uni.webView.postMessage({
+                    data: Object.assign({ action: action, callbackId: callbackId }, params)
+                });
+            } else {
+                setTimeout(checkAndSend, 100); // 轮询等待 SDK 加载
+            }
+        };
+        checkAndSend();
+    }
+
+    // ---------------------------------------------------------
+    // 3. 平台标准 API (供子应用开发者主动调用)
+    // ---------------------------------------------------------
+    window.imSDK = {
+        /**
+         * 调起原生相机拍照或选择相册 (返回本地路径)
+         */
+        chooseImage: function(options) {
+            options = options || {};
+            sendCommandToApp('chooseImage', {
+                count: options.count || 1,
+                sourceType: options.sourceType || ['camera', 'album']
+            }, options.success);
+        },
+
+        /**
+         * 调起原生相机录像或选择视频 (返回本地路径)
+         */
+        chooseVideo: function(options) {
+            options = options || {};
+            sendCommandToApp('chooseVideo', {
+                sourceType: options.sourceType || ['camera', 'album'],
+                maxDuration: options.maxDuration || 60,
+                camera: options.camera || 'back'
+            }, options.success);
+        },
+
+        /**
+         * 选择系统文件 (返回本地路径)
+         */
+        chooseFile: function(options) {
+            options = options || {};
+            sendCommandToApp('chooseFile', {
+                count: options.count || 1,
+                extension: options.extension || [] 
+            }, options.success);
+        },
+
+        /**
+         * 【核心】将本地路径委托给原生 App 进行上传,防止大文件导致 H5 内存溢出
+         */
+        uploadFile: function(options) {
+            options = options || {};
+            if (!options.url || !options.filePath) {
+                console.error('[imSDK] uploadFile 缺少必填参数: url 或 filePath');
+                return;
+            }
+            sendCommandToApp('uploadFile', {
+                url: options.url,
+                filePath: options.filePath,
+                name: options.name || 'file',
+                formData: options.formData || {},
+                header: options.header || {}
+            }, options.success);
+        }
+    };
+
+    // ---------------------------------------------------------
+    // 4. 终极防线:全局 DOM 劫持 (保护老旧/不规范的 H5 页面)
+    // ---------------------------------------------------------
+    document.addEventListener('click', function(event) {
+        var target = event.target;
+
+        // 锁定目标:点击了 <input type="file">
+        if (target.tagName && target.tagName.toUpperCase() === 'INPUT' && target.type === 'file') {
+            
+            // 粗略判断是否在 App 内 (如果不在,则放行,走普通浏览器逻辑)
+            // 建议:在你的 App 壳子 webview 加载时,给 userAgent 拼上一个自定义标识,如 'MyIMPlatform'
+            var isInsideApp = /uni-app/i.test(navigator.userAgent); 
+            
+            if (isInsideApp) {
+                // 【核心防御】拦截浏览器默认调起系统相册/相机的行为
+                event.preventDefault();
+                event.stopPropagation();
+                
+                console.log('[imSDK] 已拦截危险的原生 input 调用,正转交底层处理...');
+
+                // 判断 input 是想选图片还是视频
+                var isVideo = target.accept && target.accept.indexOf('video') !== -1;
+                var action = isVideo ? 'chooseVideo' : 'chooseImage';
+                var sourceType = target.hasAttribute('capture') ? ['camera'] : ['camera', 'album'];
+
+                // 强制转为调用我们的标准 API
+                window.imSDK[action]({
+                    sourceType: sourceType,
+                    success: function(res) {
+                        // 注意:这里拿到的是 App 传回的本地路径 (tempFilePath)。
+                        // 如果是老旧 HTML 页面,强行重写 DOM 属性可能受限于浏览器跨域安全策略。
+                        // 这里派发一个自定义事件,供高级开发者捕获;普通业务建议直接改用 imSDK.chooseImage。
+                        console.log('[imSDK] 劫持执行完毕,获取到原生路径:', res);
+                        
+                        var customEvent = new CustomEvent('im-file-choosed', { detail: res });
+                        target.dispatchEvent(customEvent);
+                    }
+                });
+            }
+        }
+    }, true); // true: 捕获阶段拦截,最高优先级
+
+})(window, document);