目前组内成员都喜欢用 XMIND 写测试用例,但是 XMIND 文件无法正常上传到 JIRA 里面,XRAY 插件不支持 XMIND 文件格式,必须要转为 CSV 文件格式才可以进行上传。所以,为了解决这个痛点,也希望能实现一键上传 XMIND 文件,并自动转为 CSV 文件上传到 JIRA 里面去,就需要开发一个转换工具。
# !/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功能验证失败")
#!/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)
<!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>
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>
`;
});
});
});
<!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>
<!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>
<!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>