学习笔记——测试进阶之路 XMind 上传 Jira 小工具
「All right reserved, any unauthorized reproduction or transfer is prohibitted」
需求背景
目前组内成员都喜欢用 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>
最终效果
TesterHome 为用户提供「保留所有权利,禁止转载」的选项。
除非获得原作者的单独授权,任何第三方不得转载标注了「All right reserved, any unauthorized reproduction or transfer is prohibitted」的内容,否则均视为侵权。
具体请参见TesterHome 知识产权保护协议。
如果觉得我的文章对您有用,请随意打赏。您的支持将鼓励我继续创作!