学习笔记——测试进阶之路 XMind 上传 Jira 小工具

大海 · 2025年09月23日 · 最后由 大海 回复于 2025年09月25日 · 1514 次阅读

需求背景

目前组内成员都喜欢用 XMIND 写测试用例,但是 XMIND 文件无法正常上传到 JIRA 里面,XRAY 插件不支持 XMIND 文件格式,必须要转为 CSV 文件格式才可以进行上传。所以,为了解决这个痛点,也希望能实现一键上传 XMIND 文件,并自动转为 CSV 文件上传到 JIRA 里面去,就需要开发一个转换工具。

本工具的功能如下:

  • 提供 XMind 文件转 CSV 格式功能,转换后的 CSV 文件可以点击下载
  • 将 CSV 文件直接上传到 Jira 系统中,直接生成对应的测试用例数据
  • XMIND 的编写规范,参考范例

代码结构

脚本代码

JiraTokenFetcher.py

# !/usr/bin/python
# -*- coding: utf-8 -*-

"""
@File    : JiraTokenFetcher.py
@Create Time:  2025/5/28 10:38
@Description:  Get jira X-Acpt value or JMT value
"""


import json
import logging
import os
import requests
from seleniumwire import webdriver  # Import from seleniumwire
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.chrome.options import Options
import time

# 配置日志记录
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')

# 定义 token 存储文件的路径
TOKEN_STORE_FILE = r'C:\XMindToJiraConverterTool\jiraToken\jira_token_store.txt'

class TokenFetcher:
    def __init__(self, chrome_driver_path):
        self.chrome_driver_path = chrome_driver_path
        self.seleniumwire_options = {'proxy': {}}
        self.chrome_options = Options()
        self.chrome_options.add_experimental_option("detach", False)  # 不保持浏览器窗口打开
        # self.chrome_options.add_argument("--headless")  # 启用 headless 模式
        self.chrome_options.add_argument("--disable-gpu")  # 禁用 GPU 加速
        self.chrome_options.add_argument("--no-sandbox")  # 禁用沙盒模式
        self.chrome_options.add_argument("--disable-dev-shm-usage")  # 禁用 /dev/shm 的使用

    def load_token_from_file(self):
        """从文件加载 token"""
        if not os.path.exists(TOKEN_STORE_FILE):
            logging.info("Token 文件不存在,需要获取新的 token")
            return None

        try:
            with open(TOKEN_STORE_FILE, 'r') as file:
                token_data = json.load(file)
                logging.info(f"已加载 token, token: {token_data}")
                return token_data.get('token')
        except Exception as e:
            logging.error(f"加载 token 文件失败: {e}")
            return None

    def save_token_to_file(self, token):
        """
        将 token 保存到文件
        :param token:
        :return:
        """
        if not token:
            logging.error("Token为空,无法保存")
            return

        # 确保文件夹存在
        os.makedirs(os.path.dirname(TOKEN_STORE_FILE), exist_ok=True)

        try:
            with open(TOKEN_STORE_FILE, 'w') as file:
                json.dump({'token': token}, file)
            logging.info(f"Token 已保存到文件: {TOKEN_STORE_FILE}")
        except Exception as e:
            logging.error(f"保存 token 文件失败: {e}")

    def validate_token(self, token):
        """
        获取有效的 token,优先使用现有 token
        :param token:
        :return:
        """
        if not token:
            return False

        try:
            # 验证 token 是否可以通过验证接口
            validate_url = "https://us.xray.cloud.getxray.app/api/internal/test-repository/get-tests"

            headers = {
                'X-Acpt': token,
                'Accept': 'application/json, text/plain, */*',
                'Accept-Encoding': 'gzip, deflate, br, zstd',
                'Accept-Language': 'zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7',
                'Content-Type': 'application/json;charset=UTF-8',
                'Origin': 'https://us.xray.cloud.getxray.app',
                'Host': 'us.xray.cloud.getxray.app'
            }
            payload = {
                "folderIds": [
                    "6528f6753506ca9792cba495"
                ],
                "projectId": "10650"
            }
            response = requests.post(validate_url, headers=headers, json=payload)

            if response.status_code == 200:
                logging.info("Token 验证成功")
                return True
            else:
                logging.warning(f"Token 验证失败,状态码: {response.status_code}")
                return False
        except Exception as e:
            logging.error(f"Token 验证过程中发生错误: {e}")
            return False

    def get_valid_token(self):
        """
        获取有效的 token,优先使用现有 token
        :return:
        """
        # 尝试从文件加载 token
        token = self.load_token_from_file()

        # 如果 token 存在且有效,直接返回
        if token and self.validate_token(token):
            logging.info("使用现有的有效 token")
            return token

        # 如果现有 token 无效或不存在,重新获取 token
        logging.info("现有的 token 无效或不存在,正在获取新的 token")
        new_token = self.get_token(max_retries=3)

        # 如果成功获取新 token,保存并返回
        if new_token:
            self.save_token_to_file(new_token)
            return new_token

        # 如果获取新 token 失败,返回 None
        return None

    def wait_for_element(self, driver, by, value, timeout=300):
        """ 等待元素出现 """
        try:
            return WebDriverWait(driver, timeout).until(EC.presence_of_element_located((by, value)))
        except Exception as e:
            logging.error(f"元素 {value} 出现超时: {e}")
            raise

    def click_element(self, driver, by, value, timeout=10):
        """ 点击元素 """
        try:
            element = WebDriverWait(driver, timeout).until(EC.element_to_be_clickable((by, value)))
            element.click()
        except Exception as e:
            logging.error(f"点击元素 {value} 失败: {e}")
            raise

    def get_token(self, max_retries=3):
        token = None
        driver = None
        retries = 0

        while retries < max_retries:
            try:
                # 创建 Chrome 驱动
                driver = webdriver.Chrome(
                    service=Service(self.chrome_driver_path),
                    options=self.chrome_options,
                    seleniumwire_options=self.seleniumwire_options
                )

                # 设置浏览器窗口全屏
                driver.maximize_window()
                logging.info(">>>>>>>>>>>>>>>>>>>>>> 浏览器窗口已设置为全屏 <<<<<<<<<<<<<<<<<<<<<<<")

                # 打开网页
                driver.get('https://XXXXXX/projects/QR247?selectedItem=com.atlassian.plugins.atlassian-connect-plugin:com.xpandit.plugins.xray__testing-board')
                logging.info(">>>>>>>>>>>>>>>>>>>>>> 页面已打开 <<<<<<<<<<<<<<<<<<<<<<<")

                # 输入用户名
                try:
                    username_input = self.wait_for_element(driver, By.ID, 'username-uid1')
                except:
                    username_input = self.wait_for_element(driver, By.CSS_SELECTOR, '[data-testid="username"]')
                username_input.send_keys('xxxxxx')
                logging.info(">>>>>>>>>>>>>>>>>>>>>> 用户名已输入 <<<<<<<<<<<<<<<<<<<<<<<")

                # 点击继续按钮
                self.click_element(driver, By.XPATH, '//span[text()="Continue"]')
                logging.info(">>>>>>>>>>>>>>>>>>>>>> 继续按钮已点击 <<<<<<<<<<<<<<<<<<<<<<<")

                # 等待页面加载完成并检查是否找到 Testing Board 按钮
                testing_board_button = self.wait_for_element(driver, By.CSS_SELECTOR,
                                                             'h2[data-testid="navigation-kit-ui-tab.ui.link-tab.non-interactive-tab"][aria-current="page"] span')
                if testing_board_button.text == "Testing Board":
                    logging.info("-------------------- Testing Board 按钮已找到 --------------------")
                else:
                    logging.error("-------------------- Testing Board 按钮未找到 --------------------")
                    time.sleep(2)
                    retries += 1
                    if retries < max_retries:
                        logging.info(f"重试获取token,剩余尝试次数: {max_retries - retries}")
                        continue
                    else:
                        logging.error("达到最大重试次数,未能获取到有效的token")
                        return token

                # 遍历所有请求,查找包含 X-Acpt 或 JWT 的请求
                for request in driver.requests:
                    logging.debug(f"Request URL: {request.url}")
                    logging.debug(f"Request headers: {request.headers}")
                    if 'x-acpt' in request.headers and request.headers['x-acpt']:
                        token = request.headers['x-acpt']
                        logging.info(f"获取到 X-Acpt Token: {token}")
                        break
                    elif 'jwt' in request.url:
                        token = request.url.split('=')[-1]
                        logging.info(f"获取到 JWT Token: {token}")
                        break

                if not token:
                    logging.error("获取token失败")
                    retries += 1
                    if retries < max_retries:
                        logging.info(f"重试获取token,剩余尝试次数: {max_retries - retries}")
                        continue
                    else:
                        logging.error("达到最大重试次数,未能获取到有效的token")
                        return token

                # 如果成功获取token,退出循环
                break

            except Exception as e:
                logging.error(f"获取token过程中发生错误: {str(e)}")
                retries += 1
                if retries < max_retries:
                    logging.info(f"重试获取token,剩余尝试次数: {max_retries - retries}")
                else:
                    logging.error("达到最大重试次数,未能获取到有效的token")
                    return token
            finally:
                # 关闭浏览器
                if driver:
                    driver.quit()
                    logging.info("-------------------- 浏览器已关闭 --------------------")

        return token


if __name__ == "__main__":
    fetcher = TokenFetcher(chrome_driver_path = r"C:/Program Files/Google/Chrome/Application/chromedriver.exe")

    # 验证从文件加载Token功能
    loaded_token = fetcher.load_token_from_file()
    if loaded_token:
        logging.info("从文件加载Token功能验证成功")
        # 验证加载的Token是否有效
        is_valid = fetcher.validate_token(loaded_token)
        if is_valid:
            logging.info("加载的Token有效且在有效期内")
        else:
            logging.warning("加载的Token无效或已过期")
    else:
        logging.info("文件中不存在有效的Token,将尝试获取新的Token")

    # 验证获取新的Token功能
    new_token = fetcher.get_token(max_retries=3)
    if new_token:
        logging.info("获取新的Token功能验证成功")
        # 验证获取到的Token是否有效
        is_valid = fetcher.validate_token(new_token)
        if is_valid:
            logging.info("获取到的Token有效且在有效期内")
        else:
            logging.warning("获取到的Token无效或已过期")
    else:
        logging.error("获取新的Token功能验证失败")

    # 验证保存Token到文件功能
    if new_token:
        fetcher.save_token_to_file(new_token)
        # 验证保存后文件中的Token
        saved_token = fetcher.load_token_from_file()
        if saved_token == new_token:
            logging.info("保存Token到文件功能验证成功")
        else:
            logging.error("保存Token到文件功能验证失败")
    else:
        logging.error("由于未获取到新的Token,无法验证保存Token到文件功能")

    # 验证获取有效Token功能
    valid_token = fetcher.get_valid_token()
    if valid_token:
        logging.info("获取有效Token功能验证成功")
        # 验证获取到的有效Token是否有效
        is_valid = fetcher.validate_token(valid_token)
        if is_valid:
            logging.info("获取到的有效Token有效且在有效期内")
        else:
            logging.warning("获取到的有效Token无效或已过期")
    else:
        logging.error("获取有效Token功能验证失败")

converter.py

#!/usr/bin/python
# -*- coding: utf-8 -*-

"""
@File    : converter.py
@Create Time:  2025/4/25 10:14
@Description: 主函数入口
"""

import logging
from flask import Flask, request, render_template, jsonify, send_from_directory, url_for
import os
import pandas as pd
import xmindparser
from werkzeug.utils import secure_filename
import time
import requests
import json
from typing import List, Dict
from JiraTokenFetcher import TokenFetcher

app = Flask(__name__)
UPLOAD_FOLDER = 'uploads'
ALLOWED_EXTENSIONS = {'xmind'}
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER

# JIRA API 配置
app.config['JIRA_API_URL'] = 'https://us.xray.cloud.getxray.app/api/internal/import/tests'
app.config['JIRA_HEADERS'] = {
    'Accept': 'application/json, text/plain, */*',
    'Accept-Encoding': 'gzip, deflate, br, zstd',
    'Accept-Language': 'zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7',
    'Content-Type': 'application/json;charset=UTF-8',
    'Origin': 'https://us.xray.cloud.getxray.app',
    'Host': 'us.xray.cloud.getxray.app'
}

# 配置日志
logging.basicConfig(level=logging.DEBUG)
app.logger.addHandler(logging.StreamHandler())
app.logger.setLevel(logging.DEBUG)

if not os.path.exists(UPLOAD_FOLDER):
    os.makedirs(UPLOAD_FOLDER)

# 上传记录日志文件
UPLOAD_HISTORY_LOG = './log/upload_history.log'
# 确保日志文件夹存在
os.makedirs(os.path.dirname(UPLOAD_HISTORY_LOG), exist_ok=True)

# 初始化单例TokenFetcher(使用元类实现)
class Singleton(type):
    _instances = {}
    def __call__(cls, *args, **kwargs):
        if cls not in cls._instances:
            cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs)
        return cls._instances[cls]

class TokenFetcherSingleton(TokenFetcher, metaclass=Singleton):
    pass

# 初始化TokenFetcher
token_fetcher = TokenFetcher(chrome_driver_path="C:/Program Files/Google/Chrome/Application/chromedriver.exe")

def allowed_file(filename):
    return filename.endswith('.xmind')

@app.route('/')
def index():
    return render_template('index.html')

@app.route('/help')
def help():
    return render_template('help.html')

@app.route('/upload-history')
def upload_history():
    history = []
    if os.path.exists(UPLOAD_HISTORY_LOG):
        with open(UPLOAD_HISTORY_LOG, 'r', encoding='utf-8') as f:
            for line in f:
                parts = line.strip().split(',')
                if len(parts) == 3:
                    history.append({
                        'filename': parts[0],
                        'timestamp': parts[1],
                        'status': parts[2]
                    })
    return render_template('upload_history.html', history=history)

@app.route('/upload', methods=['POST'])
def upload_file():
    app.logger.debug("Received POST request at /upload")
    if 'file' not in request.files:
        app.logger.error("No file part in the request")
        return jsonify({'status': 'error', 'message': 'No file part'})
    file = request.files['file']
    if file.filename == '':
        app.logger.error("No selected file")
        return jsonify({'status': 'error', 'message': 'No selected file'})
    if file and allowed_file(file.filename):
        filename = file.filename
        filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
        file.save(filepath)
        app.logger.info(f"File saved to {filepath}")
        try:
            csv_path = parse_xmind_to_csv(filepath)
            app.logger.info(f"CSV file saved to {csv_path}")
            download_url = url_for('download_file', filename=os.path.basename(csv_path), _external=True)

            with open(UPLOAD_HISTORY_LOG, 'a', encoding='utf-8') as f:
                timestamp = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime())
                f.write(f"{filename},{timestamp},Success\n")

            return jsonify({
                'status': 'success',
                'message': 'File uploaded and converted successfully.',
                'data': {'csv_path': download_url}
            })
        except Exception as e:
            app.logger.error(f"Error during file processing: {str(e)}")

            with open(UPLOAD_HISTORY_LOG, 'a', encoding='utf-8') as f:
                timestamp = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime())
                f.write(f"{filename},{timestamp},Failed\n")

            return jsonify({
                'status': 'error',
                'message': str(e)
            })
    else:
        app.logger.error("The XMind filename is missing the '.xmind' extension!")
        return jsonify({'status': 'error', 'message': 'The XMind filename is missing the \'.xmind\' extension!'})

def parse_xmind_to_csv(xmind_path):
    app.logger.info(f"Processing XMind file: {xmind_path}")
    xmind_data = xmindparser.xmind_to_dict(xmind_path)[0]
    root_topic = xmind_data['topic']

    issue_id = 1

    def extract_leaf_topics(topic, parent_title=""):
        nonlocal issue_id
        leaf_topics_list = []
        title = topic['title']
        full_title = f"{parent_title} -> {title}" if parent_title else title

        sub_topics = topic.get('topics', [])
        if not sub_topics:
            if " -> " in full_title:
                parent_parts = full_title.split(" -> ")
                if len(parent_parts) >= 4:
                    summary = f"{' -> '.join(parent_parts[:-3])}"
                    action = parent_parts[-3]
                    expected_result = parent_parts[-2]
                    priority_name = parent_parts[-1]
                else:
                    summary = full_title
                    action = topic.get('note', title)
                    expected_result = topic.get('note', title)
                    priority_name = "Medium"
            else:
                summary = full_title
                action = topic.get('note', title)
                expected_result = topic.get('note', title)
                priority_name = "Medium"

            test_data = {
                "Issue ID": issue_id,
                "Test type": "Manual",
                "Priority name": priority_name,
                "summary": summary,
                "Action": action,
                "Expected Result": expected_result
            }
            leaf_topics_list.append(test_data)
            issue_id += 1

        else:
            for sub_topic in sub_topics:
                leaf_topics_list.extend(extract_leaf_topics(sub_topic, full_title))

        return leaf_topics_list

    all_leaf_topics = extract_leaf_topics(root_topic)
    df = pd.DataFrame(all_leaf_topics)

    original_filename = os.path.splitext(os.path.basename(xmind_path))[0]
    csv_filename = f"{original_filename}.csv"
    csv_path = os.path.join(app.config['UPLOAD_FOLDER'], csv_filename)
    app.logger.info(f"Saving CSV file to {csv_path}")

    try:
        df.to_csv(csv_path, index=False, encoding='utf-8-sig')
        app.logger.info(f"CSV file saved successfully: {csv_path}")
    except Exception as e:
        app.logger.error(f"Failed to save CSV file: {str(e)}")
        raise

    return csv_path

@app.route('/download/<path:filename>', methods=['GET'])
def download_file(filename):
    return send_from_directory(app.config['UPLOAD_FOLDER'], filename, as_attachment=True)

@app.route('/clear-history', methods=['POST'])
def clear_history():
    try:
        if os.path.exists(UPLOAD_HISTORY_LOG):
            open(UPLOAD_HISTORY_LOG, 'w', encoding='utf-8').close()
        return jsonify({'status': 'success', 'message': 'History cleared successfully.'})
    except Exception as e:
        return jsonify({'status': 'error', 'message': str(e)})

@app.route('/upload_to_jira', methods=['POST'])
def upload_to_jira():
    app.logger.debug("Received POST request at /upload_to_jira")

    try:
        data = request.get_json()
        csv_path = data.get('csv_path', '')
        project_key = data.get('project_key', 'QR247')

        if not csv_path:
            return jsonify({'status': 'error', 'message': 'CSV path is required.'}), 400

        csv_data = pd.read_csv(csv_path)
        json_data = convert_to_jira_format(csv_data)

        # 获取有效的 token
        x_acpt_value = token_fetcher.get_valid_token()
        if not x_acpt_value:
            return jsonify({'status': 'error', 'message': 'Failed to retrieve X-Acpt token.'}), 500

        jira_response = call_jira_api(json_data, project_key, x_acpt_value)

        if 'jobId' in jira_response:
            view_url = f"https://XXXXXX/jira/software/c/projects/{project_key}/issues/?jql=project%20%3D%20%22{project_key}%22"
            return jsonify({
                'status': 'success',
                'message': f'Upload to Jira successful, please wait for 5 seconds to check! <a href="{view_url}" target="_blank" class="text-blue-600 font-bold underline hover:text-blue-800">Click here to view the result</a>',
                'data': jira_response
            })
        else:
            return jsonify({
                'status': 'error',
                'message': 'Failed to upload to Jira, no jobId returned.',
                'data': jira_response
            }), 500

    except Exception as e:
        app.logger.error(f"Error during upload to Jira: {str(e)}")
        return jsonify({'status': 'error', 'message': str(e)}), 500

def convert_to_jira_format(csv_data):
    jira_format = []
    for _, row in csv_data.iterrows():
        jira_item = {
            "fields": {
                "priority": {
                    "name": row['Priority name']
                },
                "summary": row['summary']
            },
            "xray_testtype": row['Test type'],
            "steps": [
                {
                    "action": row['Action'],
                    "result": row['Expected Result']
                }
            ]
        }
        jira_format.append(jira_item)
    return jira_format

def call_jira_api(json_data: List[Dict], project_key: str, x_acpt_value: str) -> Dict:
    """
    带重试机制的Jira API调用
    尝试多次发送请求,直到成功或达到最大重试次数
    """
    url = f"{app.config['JIRA_API_URL']}?project={project_key}"
    headers = app.config['JIRA_HEADERS'].copy()
    headers['X-Acpt'] = x_acpt_value  # 使用获取到的Token进行认证

    for attempt in range(3):  # 最多重试3次
        try:
            response = requests.post(
                url,
                headers=headers,
                json=json_data,
                timeout=30  # 设置超时时间
            )
            response.raise_for_status()  # 如果响应状态码表示错误,抛出异常
            app.logger.info(f"JIRA API Response StatusCode: {response.status_code}")
            app.logger.info(f"JIRA API Response TextContent: {response.text}")
            return response.json()  # 返回响应内容
        except requests.exceptions.RequestException as e:
            app.logger.warning(f"第{attempt+1}次请求失败: {str(e)}")
            if attempt < 2:  # 如果还有重试机会,等待后重试
                time.sleep(2 ** attempt)  # 指数退避策略
            else:
                app.logger.error("所有重试均失败")
                return {'error': '请求失败'}






if __name__ == '__main__':
    app.run(debug=True, host='0.0.0.0', port=6060)

index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>XMind to Jira Converter</title>
    <link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
    <style>
        #progress-bar, #jira-progress-bar {
            transition: width 0.3s ease;
        }
    </style>
</head>
<body class="bg-custom text-custom font-inter">
    <div class="container mx-auto max-w-2xl mt-10 p-6 bg-white shadow-lg rounded-lg">
        <div class="flex justify-between items-center mb-6">
            <h1 class="text-3xl font-bold">XMind to Jira Converter</h1>
            <div class="flex items-center space-x-4">
                <a href="/help" class="text-indigo-600 hover:text-indigo-800 transition-colors duration-300">Help</a>
                <a href="/upload-history" class="text-indigo-600 hover:text-indigo-800 transition-colors duration-300">Upload History</a>
            </div>
        </div>

        <form id="uploadForm" enctype="multipart/form-data" class="space-y-6">
            <div class="relative">
                <label for="fileInput" class="block text-sm font-medium text-gray-700">Select an XMind file (Max size: 16MB):</label>
                <input type="file" id="fileInput" name="file" accept=".xmind" required class="mt-1 py-3 px-4 border border-custom rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm">
                <div class="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
                    <svg class="h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                        <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-11a1 1 0 10-2 0v2H7a1 1 0 100 2h2v2a1 1 0 102 0v-2h2a1 1 0 100-2h-2V7z" clip-rule="evenodd" />
                    </svg>
                </div>
            </div>

            <!-- 新增的下拉框 -->
            <div class="relative">
                <label for="projectSelect" class="block text-sm font-medium text-gray-700">Select Project:</label>
                <select id="projectSelect" class="mt-1 py-3 px-4 border border-custom rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm">
                    <option value="" disabled selected>Select a project</option>
                    <!-- 选项将通过 JavaScript 动态填充 -->
                </select>
            </div>

            <div class="flex justify-between items-center">
                <button type="submit" class="w-1/2 inline-flex justify-center py-3 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 btn">Convert to CSV</button>
                <button type="button" id="uploadToJira" class="w-1/2 inline-flex justify-center py-3 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 btn">Upload to Jira</button>
            </div>
        </form>

        <!-- 修改后的清除按钮位置 -->
        <button id="clearAll" class="mt-4 py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500">
            Clear All
        </button>

        <div id="progress" class="hidden mt-6">
            <div class="text-sm font-medium text-blue-600">Processing...</div>
            <div class="w-full bg-gray-200 rounded-full h-3 mt-2">
                <div id="progress-bar" class="bg-blue-600 h-3 rounded-full progress-bar" style="width: 0%"></div>
            </div>
        </div>

        <div id="jiraProgress" class="hidden mt-6">
            <div class="text-sm font-medium text-green-600">Uploading to Jira...</div>
            <div class="w-full bg-gray-200 rounded-full h-3 mt-2">
                <div id="jira-progress-bar" class="bg-green-600 h-3 rounded-full progress-bar" style="width: 0%"></div>
            </div>
        </div>

        <div id="tokenMessage" class="hidden mt-6">
            <div class="text-sm font-medium text-blue-600">Fetching Jira access token...</div>
        </div>

        <div id="result" class="hidden mt-6">
            <!-- Result will be displayed here -->
        </div>
    </div>

    <script src="{{ url_for('static', filename='js/scripts.js') }}"></script>
</body>
</html>

script.js

document.addEventListener('DOMContentLoaded', function() {
    // 硬编码的项目数据
    const projects = [
        { id: '10862', key: 'KCDO', name: 'SMART FIELD MOBILITY' },
        { id: '11429', key: 'AA', name: 'Ask Assistant (CDT)' },
        { id: '10694', key: 'C3ST', name: 'KONE Field Productivity Solution (CDT)' },
        { id: '10756', key: 'CCRM', name: 'China CRM (CDT)' },
        { id: '10866', key: 'CDML', name: 'China DML (CDT)' },
        { id: '10638', key: 'CEM', name: 'China E2E Monitoring (CDT)' },
        { id: '10751', key: 'CKFM', name: 'China KFM (CDT)' },
        { id: '10853', key: 'CMS', name: 'CMS-Contract Management System (CDT)' },
        { id: '10693', key: 'CMSS', name: 'MOD Care (CDT)' },
        { id: '10833', key: 'CPF', name: 'China People Flow (CDT)' },
        { id: '10198', key: 'EB247', name: '24/7 CN intelligence analytic (CDT)' },
        { id: '10424', key: 'HDC', name: 'DevOps China (CDT)' },
        { id: '12056', key: 'IE', name: 'IoT Essential (CDT)' },
        { id: '12023', key: 'KCCD', name: 'KONE China Car Designer (CDT)' },
        { id: '11264', key: 'KM', name: 'KONE Microservice (CDT)' },
        { id: '10797', key: 'NFM', name: 'New Field Mobility (CDT)' },
        { id: '10865', key: 'NGDTU', name: 'Next Gen DTU (CDT)' },
        { id: '10188', key: 'NP24', name: '24/7 China user application (CDT)' },
        { id: '10650', key: 'QR247', name: '24/7 China Government API Platform (CDT)' }
    ];

    // 填充下拉框
    const projectSelect = document.getElementById('projectSelect');
    projects.forEach(project => {
        const option = document.createElement('option');
        option.value = project.key;
        option.textContent = project.name;
        projectSelect.appendChild(option);
    });

    // 重置页面状态的函数
    function resetPageState() {
        document.getElementById('uploadForm').reset();
        document.getElementById('progress').classList.add('hidden');
        document.getElementById('jiraProgress').classList.add('hidden');
        document.getElementById('result').classList.add('hidden');
        document.getElementById('progress-bar').style.width = '0%';
        document.getElementById('jira-progress-bar').style.width = '0%';
        document.getElementById('result').innerHTML = '';
        localStorage.removeItem('lastConvertedExcel');
    }

    // 清除按钮点击事件
    document.getElementById('clearAll').addEventListener('click', resetPageState);

    document.getElementById('uploadForm').addEventListener('submit', function(event) {
        event.preventDefault();
        const form = new FormData(this);

        // Show progress bar
        document.getElementById('progress').classList.remove('hidden');
        document.getElementById('result').classList.add('hidden');
        document.getElementById('progress-bar').style.width = '0%';

        fetch('/upload', {
            method: 'POST',
            mode: 'cors',
            credentials: 'include',
            body: form
        })
        .then(response => {
            if (!response.ok) {
                return response.text().then(text => {
                    throw new Error(`HTTP error! status: ${response.status}, body: ${text}`);
                });
            }
            return response.json();
        })
        .then(data => {
            // Hide progress bar
            document.getElementById('progress').classList.add('hidden');
            document.getElementById('result').classList.remove('hidden');

            if (data.status === 'success') {
                // Update result area with success message
                document.getElementById('result').innerHTML = `
                    <div class="bg-green-100 border-l-4 border-green-500 p-4">
                        <div class="flex">
                            <div class="flex-shrink-0">
                                <svg class="h-5 w-5 text-green-500" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                                    <path fill-rule="evenodd" d="M5 2.5A2.5 2.5 0 017.5 0h6A2.5 2.5 0 0116 2.5v4.5A2.5 2.5 0 0113.5 9h-6A2.5 2.5 0 015 6.5v-4zM9 16.5A1 1 0 117 15.5a1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
                                </svg>
                            </div>
                            <div class="ml-3">
                                <p class="text-sm font-medium text-green-800">${data.message}</p>
                            </div>
                        </div>
                    </div>
                    <pre class="mt-4 p-4 bg-gray-900 text-white rounded-md">${JSON.stringify(data.data, null, 4)}</pre>
                    <a href="${data.data.csv_path}" download class="mt-2 inline-block bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 transition-colors duration-300">Download CSV</a>
                `;
                document.getElementById('uploadToJira').style.display = 'inline-flex';

                // 保存 CSV 文件的下载链接到 localStorage
                localStorage.setItem('lastConvertedExcel', data.data.csv_path);
            } else {
                // Update result area with error message
                document.getElementById('result').innerHTML = `
                    <div class="bg-red-100 border-l-4 border-red-500 p-4">
                        <div class="flex">
                            <div class="flex-shrink-0">
                                <svg class="h-5 w-5 text-red-500" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                                    <path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
                                </svg>
                            </div>
                            <div class="ml-3">
                                <p class="text-sm font-medium text-red-800">${data.message}</p>
                            </div>
                        </div>
                    </div>
                `;
                document.getElementById('uploadToJira').style.display = 'none';
            }
        })
        .catch(error => {
            // Error handling
            document.getElementById('progress').classList.add('hidden');
            document.getElementById('result').classList.remove('hidden');
            document.getElementById('result').innerHTML = `
                <div class="bg-red-100 border-l-4 border-red-500 p-4">
                    <div class="flex">
                        <div class="flex-shrink-0">
                            <svg class="h-5 w-5 text-red-500" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                                <path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
                            </svg>
                        </div>
                        <div class="ml-3">
                            <p class="text-sm font-medium text-red-800">An error occurred: ${error.message}</p>
                        </div>
                    </div>
                </div>
            `;
            document.getElementById('uploadForm').reset();
            document.getElementById('uploadToJira').style.display = 'none';
        });
    });

    document.getElementById('uploadToJira').addEventListener('click', function() {
        const csvPath = localStorage.getItem('lastConvertedExcel');
        const selectedProjectKey = document.getElementById('projectSelect').value;

        if (!csvPath) {
            alert('Please convert an XMind file to CSV first.');
            return;
        }

        if (!selectedProjectKey) {
            alert('Please select a project before uploading to Jira.');
            return;
        }

        // 显示获取token的提示信息
        document.getElementById('tokenMessage').classList.remove('hidden');
        document.getElementById('jiraProgress').classList.add('hidden');

        fetch('/upload_to_jira', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json'
            },
            body: JSON.stringify({ csv_path: csvPath, project_key: selectedProjectKey })
        })
        .then(response => response.json())
        .then(jiraData => {
            // 隐藏获取token的提示信息
            document.getElementById('tokenMessage').classList.add('hidden');
            // 显示上传进度
            document.getElementById('jiraProgress').classList.remove('hidden');
            document.getElementById('jira-progress-bar').style.width = '0%';

            if (jiraData.status === 'success') {
                document.getElementById('jira-progress-bar').style.width = '100%';
                document.getElementById('result').innerHTML += `
                    <div class="mt-6 bg-blue-100 border-l-4 border-blue-500 p-4">
                        <div class="flex">
                            <div class="flex-shrink-0">
                                <svg class="h-5 w-5 text-blue-500" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                                    <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9V6z" clip-rule="evenodd" />
                                </svg>
                            </div>
                            <div class="ml-3">
                                <p class="text-sm font-medium text-blue-800">${jiraData.message}</p>
                            </div>
                        </div>
                    </div>
                `;
            } else {
                document.getElementById('result').innerHTML += `
                    <div class="mt-6 bg-red-100 border-l-4 border-red-500 p-4">
                        <div class="flex">
                            <div class="flex-shrink-0">
                                <svg class="h-5 w-5 text-red-500" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                                    <path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
                                </svg>
                            </div>
                            <div class="ml-3">
                                <p class="text-sm font-medium text-red-800">Error uploading to Jira: ${jiraData.message}</p>
                            </div>
                        </div>
                    </div>
                `;
            }
        })
        .catch(error => {
            // 隐藏获取token的提示信息
            document.getElementById('tokenMessage').classList.add('hidden');
            document.getElementById('jira-progress-bar').style.width = '100%';
            document.getElementById('result').innerHTML += `
                <div class="mt-6 bg-red-100 border-l-4 border-red-500 p-4">
                    <div class="flex">
                        <div class="flex-shrink-0">
                            <svg class="h-5 w-5 text-red-500" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
                                <path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
                            </svg>
                        </div>
                        <div class="ml-3">
                            <p class="text-sm font-medium text-red-800">Error uploading to Jira: ${error.message}</p>
                        </div>
                    </div>
                </div>
            `;
        });
    });
});

result.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Upload Result</title>
    <link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
    <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body class="bg-gray-100 font-sans">
    <div class="container mx-auto max-w-md mt-10 p-6 bg-white shadow-lg rounded-lg">
        <h1 class="text-3xl font-bold text-center mb-6">Upload Result</h1>
        {% if result.status == 'success' %}
            <div class="alert alert-success text-center mb-4">
                <svg class="w-6 h-6 inline text-green-600 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                    <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
                </svg>
                {{ result.message }}
            </div>
            <pre class="bg-gray-900 text-white p-4 rounded-md mt-4">{{ result.data | tojson(indent=4) }}</pre>
        {% elif result.status == 'error' %}
            <div class="alert alert-danger text-center mb-4">
                <svg class="w-6 h-6 inline text-red-600 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                    <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
                </svg>
                {{ result.message }}
            </div>
        {% endif %}
        <div class="text-center">
            <a href="{{ url_for('index') }}" class="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">Go Back</a>
        </div>
    </div>
</body>
</html>

upload_history.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Upload History - XMind to CSV Converter</title>
    <link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
</head>
<body class="bg-gray-100 font-sans">
    <div class="container mx-auto max-w-2xl mt-10 p-6 bg-white shadow-lg rounded-lg">
        <h1 class="text-3xl font-bold mb-6">Upload History</h1>

        <div class="mb-6">
            <button id="clearHistory" class="bg-red-600 text-white px-4 py-2 rounded hover:bg-red-700 transition-colors duration-300">
                Clear All History
            </button>
        </div>

        <div class="overflow-x-auto">
            <table class="table-auto w-full divide-y divide-gray-200">
                <thead class="bg-gray-50">
                    <tr>
                        <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">File Name</th>
                        <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Upload Date</th>
                        <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
                        <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
                    </tr>
                </thead>
                <tbody class="bg-white divide-y divide-gray-200">
                    {% for item in history %}
                    <tr>
                        <td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{{ item.filename }}</td>
                        <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ item.timestamp }}</td>
                        <td class="px-6 py-4 whitespace-nowrap text-sm font-medium {% if item.status == 'Success' %}text-green-600{% else %}text-red-600{% endif %}">{{ item.status }}</td>
                        <td class="px-6 py-4 whitespace-nowrap">
                            {% if item.status == 'Success' %}
                            <a href="/download/{{ item.filename | replace('.xmind', '.csv') }}" class="text-indigo-600 hover:text-indigo-800 text-sm font-medium mr-2 transition-colors duration-300">Download CSV</a>
                            <a href="#" class="text-indigo-600 hover:text-indigo-800 text-sm font-medium transition-colors duration-300">Upload to Jira</a>
                            {% else %}
                            <a href="#" class="text-indigo-600 hover:text-indigo-800 text-sm font-medium transition-colors duration-300">View Error</a>
                            {% endif %}
                        </td>
                    </tr>
                    {% endfor %}
                </tbody>
            </table>
        </div>

        <div class="mt-6 text-center">
            <a href="/" class="text-indigo-600 hover:text-indigo-800 font-medium transition-colors duration-300">Back to Converter</a>
        </div>
    </div>

    <script>
        document.getElementById('clearHistory').addEventListener('click', function() {
            if (confirm('Are you sure you want to clear all upload history? This action cannot be undone.')) {
                fetch('/clear-history', {
                    method: 'POST'
                })
                .then(response => response.json())
                .then(data => {
                    if (data.status === 'success') {
                        // Refresh the page to show cleared history
                        location.reload();
                    } else {
                        alert('Failed to clear history: ' + data.message);
                    }
                })
                .catch(error => {
                    alert('Error clearing history: ' + error.message);
                });
            }
        });
    </script>
</body>
</html>

help.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Help - XMind to CSV Converter</title>
    <link href="https://unpkg.com/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
</head>
<body class="bg-gray-100 font-sans">
    <div class="container mx-auto max-w-2xl mt-10 p-6 bg-white shadow-lg rounded-lg">
        <h1 class="text-3xl font-bold mb-6">Help</h1>

        <div class="space-y-6">
            <div class="bg-blue-50 p-6 rounded-lg">
                <h2 class="text-xl font-semibold text-blue-800 mb-2">How to Use</h2>
                <p class="text-gray-600">
                    This tool allows you to convert XMind files (.xmind) to CSV format. Follow these simple steps:
                </p>
                <ol class="list-decimal list-inside mt-4 space-y-2 text-gray-600">
                    <li>Click the "Select an XMind file" button to choose your XMind file from your device.</li>
                    <li>Click the "Convert to CSV" button to process the file.</li>
                    <li>Wait for the conversion process to complete (this may take a few seconds).</li>
                    <li>Once the conversion is complete, you can download the CSV file by clicking the "Download CSV" button.</li>
                    <li>Optionally, you can upload the CSV file to Jira by clicking the "Upload to Jira" button.</li>
                </ol>
            </div>

            <div class="bg-green-50 p-6 rounded-lg">
                <h2 class="text-xl font-semibold text-green-800 mb-2">File Requirements</h2>
                <p class="text-gray-600">
                    Ensure your XMind file meets the following requirements for successful conversion:
                </p>
                <ul class="list-disc list-inside mt-4 space-y-2 text-gray-600">
                    <li>The XMind file should have a hierarchical structure that can be mapped to CSV rows and columns.</li>
                    <li>The file size should not exceed 16MB.</li>
                    <li>The file should not be corrupted and should have a valid XMind structure.</li>
                    <li>The file should contain at least one topic with a title.</li>
                </ul>
            </div>

            <div class="bg-yellow-50 p-6 rounded-lg">
                <h2 class="text-xl font-semibold text-yellow-800 mb-2">Common Issues and Solutions</h2>
                <p class="text-gray-600">
                    If you encounter any issues, refer to the following solutions:
                </p>
                <ul class="list-disc list-inside mt-4 space-y-2 text-gray-600">
                    <li><strong>Conversion Failed:</strong> Ensure your XMind file has a valid structure and meets the file requirements. Check that the file is not corrupted.</li>
                    <li><strong>File Not Found:</strong> Make sure the file is correctly selected and not moved or deleted from its original location.</li>
                    <li><strong>Upload to Jira Failed:</strong> Verify that the CSV file was successfully generated before attempting to upload to Jira. Check your Jira connection settings.</li>
                    <li><strong>Empty CSV:</strong> Ensure your XMind file contains topics with titles and content.</li>
                </ul>
            </div>

            <div class="bg-red-50 p-6 rounded-lg">
                <h2 class="text-xl font-semibold text-red-800 mb-2">Contact Support</h2>
                <p class="text-gray-600">
                    If you need further assistance or have questions about the tool, feel free to contact me:
                </p>
                <div class="mt-4">
                    <p class="text-gray-600">Email: XX@XX.com</p>
                    <p class="text-gray-600">Phone: +(86) 100-0000-0000</p>
                </div>
            </div>
        </div>

        <div class="mt-6 text-center">
            <a href="/" class="text-indigo-600 hover:text-indigo-800 font-medium transition-colors duration-300">Back to Converter</a>
        </div>
    </div>
</body>
</html>

最终效果






如果觉得我的文章对您有用,请随意打赏。您的支持将鼓励我继续创作!
共收到 2 条回复 时间 点赞

csv 是什么结构的呢 和 xmind 一样的么?

竹卒 回复

不一样,经过我的逻辑处理,类似用 excel 写测试用例那样的形式

需要 登录 后方可回复, 如果你还没有账号请点击这里 注册