在公司持续推进研发效能提升与 DevOps 工程体系建设的过程中,研发流程的标准化成为关键抓手。长期以来,各项目团队虽已约定任务命名规范(如在 Jira 中使用 [task]、[study]、[research] 等前缀区分任务类型),并采用 Story Points 作为工时估算单位,但这些规范主要依赖人工遵守,缺乏系统性监控与量化评估机制,导致执行效果参差不齐,难以横向比较或持续优化。
同时,技术管理者在资源规划与过程改进中面临信息盲区:无法准确掌握各团队任务类型的分布情况、是否存在大量未按规范命名的任务、工时是否过度集中于少数人员或任务类型、是否存在资源过载或闲置等问题。这些问题制约了研发过程的透明度和管理决策的科学性。
为解决上述痛点,亟需构建一个能够自动采集、智能识别并量化分析研发任务规范性与工时投入的数据平台。在此背景下,研发任务规范性与工时智能分析平台被提出并实施,旨在将原本停留在 “口头约定” 或 “文档规范” 层面的流程要求,转化为可采集、可计算、可追溯、可对比的客观指标,从而支撑研发管理体系从经验驱动向数据驱动演进。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# =============================================================================
# MIT License © 2025
# =============================================================================
# @Time : 2025-11-13 10:52
# @File : JiraTagDashboard.py
# @Desc : Jira Story Tag Dashboard
# -----------------------------------------------------------------------------
from flask import Flask, render_template, request
import re
import plotly.graph_objects as go
from datetime import datetime, timedelta
import logging
import requests
from requests.auth import HTTPBasicAuth
import urllib.parse
# ==================== 配置区 ====================
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
JIRA_URL = "*******************"
EMAIL = "*******************"
API_TOKEN = "*******************"
PROJECT_NAME_TO_KEY = {
"CKOL": "NP24",
"CKFM": "CKFM",
"GovAPI": "QR247",
"KCDP": "DPFIEA",
"MOD": "CMSS",
"KFPS": "C3ST",
"Kedex": "CPF",
"BPM": "CMS",
"IOT": "IEDGE",
"DML": "CDML",
"DataAnalytic": "EB247",
"SFM": "KCDO",
"UserCenter": "KM",
"SalesPortal": "CCRM",
"BigScreen": "CNSERVP",
"IoTEssential": "IE",
"NewCKFM": "NCKFM",
}
PROJECT_NAMES = list(PROJECT_NAME_TO_KEY.keys())
VALID_PREFIXES = ['task', 'study', 'research', 'support', 'meeting', 'others']
SP_FIELD = "customfield_10026"
COLOR_PALETTE = [
"#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd",
"#8c564b", "#e377c2", "#7f7f7f", "#bcbd22", "#17becf",
"#aec7e8", "#ffbb78"
]
# ==================== 初始化 ====================
app = Flask(__name__)
auth = HTTPBasicAuth(EMAIL, API_TOKEN)
headers = {
"Accept": "application/json",
"Content-Type": "application/json"
}
# ==================== 工具函数 ====================
def extract_prefix(title):
if not title:
return None
match = re.search(r'[\[【]([^\]】]+)[\]】]', title)
if match:
prefix = match.group(1).strip().lower()
if prefix in VALID_PREFIXES:
return prefix
return None
def empty_result(name, key="INVALID"):
return {
'project_name': name,
'project_key': key,
'total_sp': 0,
'valid_count': 0,
'issue_count': 0,
'prefix_pct': {p: 0.0 for p in VALID_PREFIXES}
}
def parse_time_range(form_data):
time_mode = form_data.get('time_mode', 'quick')
now = datetime.now()
if time_mode == 'custom':
try:
start_str = form_data.get('start_date', '').strip()
end_str = form_data.get('end_date', '').strip()
if start_str and end_str:
start_date = datetime.strptime(start_str, '%Y-%m-%d')
end_date = datetime.strptime(end_str, '%Y-%m-%d')
if end_date > now:
end_date = now
if (end_date - start_date).days > 180:
start_date = end_date - timedelta(days=180)
if start_date > end_date:
start_date = end_date - timedelta(days=1)
return start_date, end_date, 'custom', start_str, end_str
except Exception as e:
logger.error(f"日期解析错误: {e}")
period = form_data.get('period', '1m')
days_map = {'1w': 7, '1m': 30, '3m': 90, '6m': 180}
days = days_map.get(period, 30)
days = min(days, 180)
start_date = now - timedelta(days=days)
end_date = now
return start_date, end_date, period, start_date.strftime('%Y-%m-%d'), end_date.strftime('%Y-%m-%d')
def generate_single_project_chart(data, color=None):
fig = go.Figure()
percentages = [data['prefix_pct'][p] for p in VALID_PREFIXES]
sp_values = [data['prefix_pct'][p] * data['total_sp'] / 100.0 for p in VALID_PREFIXES]
hover_texts = [
f"类别: {p}<br>工作量占比: {pct:.1f}%<br>SP 值: {sp:.1f}"
for p, pct, sp in zip(VALID_PREFIXES, percentages, sp_values)
]
bar_color = color if color is not None else '#1f77b4'
fig.add_trace(go.Bar(
x=VALID_PREFIXES,
y=percentages,
text=[f"{pct:.1f}%" if pct > 0 else "" for pct in percentages],
textposition='outside',
hovertext=hover_texts,
marker_color=bar_color
))
subtitle = f"(总工时数: {data['total_sp']:.1f},已打标Story: {data['valid_count']} / {data['issue_count']})"
full_title = f"<b>{data['project_name']}|任务分布</b> {subtitle}"
fig.update_layout(
title={'text': full_title, 'x': 0.5, 'xanchor': 'center', 'font': {'size': 16}},
xaxis=dict(title="任务类型", showgrid=False),
yaxis=dict(title="任务量占比 (%)", range=[0, 100], gridcolor='rgba(200,200,200,0.3)'),
height=400,
font=dict(family="Segoe UI, Microsoft YaHei, sans-serif"),
plot_bgcolor='rgba(0,0,0,0)',
paper_bgcolor='rgba(0,0,0,0)',
margin=dict(l=50, r=50, t=60, b=60)
)
return fig.to_html(full_html=False, include_plotlyjs='cdn')
def generate_compare_charts_html(data_list):
if not data_list:
return ""
html_parts = ['<script src="https://cdn.plot.ly/plotly-latest.min.js"></script>']
for idx, data in enumerate(data_list):
color = COLOR_PALETTE[idx % len(COLOR_PALETTE)]
chart_html = generate_single_project_chart(data, color=color)
html_parts.append(chart_html)
html_parts.append('<hr style="margin: 30px 0; border-color: #eee;">')
return "\n".join(html_parts)
# ==================== 核心数据获取函数====================
def fetch_jira_data(project_name, start_date, end_date):
project_key = PROJECT_NAME_TO_KEY.get(project_name)
if not project_key:
logger.warning(f"[{project_name}] 项目名称无效")
return empty_result(project_name)
logger.info(f"[{project_name}] 开始查询 Jira 数据,时间范围: {start_date.date()} ~ {end_date.date()}")
# 构建 JQL
jql = f'project = "{project_key}" AND (issuetype = Story OR issuetype = Task) AND created >= "{start_date.strftime("%Y-%m-%d")}" AND created <= "{end_date.strftime("%Y-%m-%d")}"'
# === 第一阶段:使用 Enhanced Search (/search/jql) 获取所有 Story/Task ===
issue_ids = []
next_page_token = None
max_results_per_page = 5000 # Enhanced Search 最大支持 5000
while True:
payload = {
"jql": jql,
"maxResults": max_results_per_page
}
if next_page_token:
payload["nextPageToken"] = next_page_token
try:
url = f"{JIRA_URL}/rest/api/3/search/jql"
resp = requests.post(url, json=payload, auth=auth, headers=headers, timeout=(10, 20))
if resp.status_code != 200:
logger.error(f"[{project_name}] Enhanced Search 失败: {resp.status_code} {resp.text}")
return empty_result(project_name, project_key)
data = resp.json()
page_ids = [issue["id"] for issue in data.get("issues", [])]
issue_ids.extend(page_ids)
if "nextPageToken" not in data:
break
next_page_token = data["nextPageToken"]
except Exception as e:
logger.error(f"[{project_name}] Enhanced Search 异常: {e}")
return empty_result(project_name, project_key)
total_issues = len(issue_ids)
if total_issues == 0:
logger.info(f"[{project_name}] 未找到任何 issue")
return empty_result(project_name, project_key)
logger.info(f"[{project_name}] 已获取 {total_issues} 个 Story/Task任务")
# === 第二阶段:使用 Bulk Fetch 获取 summary 和 SP 字段 ===
all_issues = []
batch_size = 100 # Bulk Fetch 最大支持 100 个 ID/请求
for i in range(0, total_issues, batch_size):
batch = issue_ids[i:i + batch_size]
try:
url = f"{JIRA_URL}/rest/api/3/issue/bulkfetch"
payload = {
"issueIdsOrKeys": batch,
"fields": ["summary", SP_FIELD]
}
resp = requests.post(url, json=payload, auth=auth, headers=headers, timeout=(10, 30))
if resp.status_code == 200:
issues = resp.json().get("issues", [])
all_issues.extend(issues)
else:
logger.error(
f"[{project_name}] Bulk Fetch 失败 (batch {i // batch_size + 1}): {resp.status_code} {resp.text}")
except Exception as e:
logger.error(f"[{project_name}] Bulk Fetch 异常 (batch {i // batch_size + 1}): {e}")
logger.info(f"[{project_name}] 已加载 {len(all_issues)} 个任务数(Story/Task)与工时数据(SP)")
# === 数据聚合逻辑 ===
prefix_sum = {p: 0.0 for p in VALID_PREFIXES}
total_sp = 0.0
valid_count = 0
for issue in all_issues:
try:
fields = issue.get("fields", {})
summary = fields.get("summary", "") or ""
sp_val_raw = fields.get(SP_FIELD)
sp_val = float(sp_val_raw) if sp_val_raw is not None else 0.0
prefix = extract_prefix(summary)
if prefix:
prefix_sum[prefix] += sp_val
total_sp += sp_val
valid_count += 1
except Exception:
continue
logger.info(f"[{project_name}] 其中 {valid_count} 个任务符合命名规范,视为有效 Story")
prefix_pct = {p: (prefix_sum[p] / total_sp) * 100 if total_sp > 0 else 0.0 for p in VALID_PREFIXES}
return {
'project_name': project_name,
'project_key': project_key,
'total_sp': round(total_sp, 2),
'valid_count': valid_count,
'issue_count': len(all_issues),
'prefix_pct': prefix_pct
}
# ==================== 并发辅助函数 ====================
def _fetch_safe(args):
name, start, end = args
try:
return fetch_jira_data(name, start, end)
except Exception as e:
logger.error(f"[{name}] 并发获取失败: {e}")
return empty_result(name)
# ==================== 路由 ====================
@app.route('/', methods=['GET', 'POST'])
def single_view():
now = datetime.now()
if request.method == 'POST':
selected_name = request.form.get('project', PROJECT_NAMES[0])
if selected_name not in PROJECT_NAME_TO_KEY:
selected_name = PROJECT_NAMES[0]
start_date, end_date, period_or_mode, start_str, end_str = parse_time_range(request.form)
time_mode = 'custom' if period_or_mode == 'custom' else 'quick'
data = fetch_jira_data(selected_name, start_date, end_date)
chart_html = generate_single_project_chart(data)
last_update = datetime.now().strftime('%Y-%m-%d %H:%M')
else:
selected_name = PROJECT_NAMES[0]
period_or_mode = '1m'
time_mode = 'quick'
start_str = (now - timedelta(days=30)).strftime('%Y-%m-%d')
end_str = now.strftime('%Y-%m-%d')
chart_html = None
last_update = None
return render_template(
'index.html',
tab='single',
project_names=PROJECT_NAMES,
selected_name=selected_name,
period=period_or_mode,
time_mode=time_mode,
start_date_str=start_str,
end_date_str=end_str,
chart_html=chart_html,
last_update=last_update
)
@app.route('/compare', methods=['GET', 'POST'])
def compare_view():
from concurrent.futures import ThreadPoolExecutor
now = datetime.now()
if request.method == 'POST':
selected_names = request.form.getlist('projects')
selected_names = [n for n in selected_names if n in PROJECT_NAME_TO_KEY]
start_date, end_date, period_or_mode, start_str, end_str = parse_time_range(request.form)
time_mode = 'custom' if period_or_mode == 'custom' else 'quick'
if selected_names:
with ThreadPoolExecutor(max_workers=min(5, len(selected_names))) as executor:
tasks = [(name, start_date, end_date) for name in selected_names]
futures = [executor.submit(_fetch_safe, task) for task in tasks]
data_list = [f.result() for f in futures]
chart_html = generate_compare_charts_html(data_list)
else:
chart_html = ""
last_update = datetime.now().strftime('%Y-%m-%d %H:%M')
else:
selected_names = []
period_or_mode = '1m'
time_mode = 'quick'
start_str = (now - timedelta(days=30)).strftime('%Y-%m-%d')
end_str = now.strftime('%Y-%m-%d')
chart_html = None
last_update = None
return render_template(
'index.html',
tab='compare',
project_names=PROJECT_NAMES,
selected_names=selected_names,
period=period_or_mode,
time_mode=time_mode,
start_date_str=start_str,
end_date_str=end_str,
chart_html=chart_html,
last_update=last_update
)
# ==================== 启动 ====================
if __name__ == '__main__':
app.run(host='0.0.0.0', port=9014, debug=False)
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>研发任务规范性与工时智能分析平台</title>
<link href="{{ url_for('static', filename='css/bootstrap.min.css') }}" rel="stylesheet">
<style>
body {
background-color: #f5f7fa;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
padding-top: 24px;
color: #333;
}
.page-header {
margin-bottom: 28px;
}
.page-header h1 {
font-weight: 600;
color: #2c3e50;
font-size: 1.8rem;
}
.form-card {
background: white;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
padding: 24px;
margin-bottom: 28px;
transition: box-shadow 0.2s ease;
}
.form-card:hover {
box-shadow: 0 6px 16px rgba(0,0,0,0.08);
}
.chart-card {
background: white;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
padding: 24px;
}
.section-title {
font-size: 1.1rem;
font-weight: 600;
margin-bottom: 16px;
color: #495057;
display: flex;
align-items: center;
gap: 8px;
}
.section-title::before {
content: "";
display: inline-block;
width: 4px;
height: 16px;
background: #4a90e2;
border-radius: 2px;
}
.project-checkbox-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 14px;
padding: 12px;
border: 1px solid #e0e6ed;
border-radius: 8px;
background-color: #fafcff;
margin-top: 10px;
}
.project-checkbox-item {
display: flex;
align-items: center;
padding: 10px 12px;
border: 1px solid #e9ecef;
border-radius: 8px;
background: #ffffff;
font-size: 0.95rem;
cursor: pointer;
transition: all 0.18s ease;
}
.project-checkbox-item:hover {
background: #f0f7ff;
border-color: #4a90e2;
}
.project-checkbox-item input[type="checkbox"] {
margin-right: 8px;
transform: scale(1.15);
}
.time-mode-group .form-check {
margin-right: 20px;
}
.submit-section {
margin-top: 20px;
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.btn-primary {
padding-left: 24px;
padding-right: 24px;
font-weight: 500;
}
.text-hint {
font-size: 0.875rem;
color: #6c757d;
}
@media (max-width: 768px) {
.project-checkbox-grid {
grid-template-columns: 1fr;
}
.time-mode-group .form-check {
margin-right: 0;
margin-bottom: 8px;
}
}
</style>
</head>
<body>
<div class="container">
<div class="page-header">
<h1 class="text-center fw-bold">研发任务规范性与工时智能分析平台</h1>
</div>
<ul class="nav nav-tabs mb-4">
<li class="nav-item">
<a class="nav-link {% if tab == 'single' %}active{% endif %}" href="{{ url_for('single_view') }}">单项目分析</a>
</li>
<li class="nav-item">
<a class="nav-link {% if tab == 'compare' %}active{% endif %}" href="{{ url_for('compare_view') }}">多项目对比</a>
</li>
</ul>
<div class="form-card">
<form id="analysisForm" method="post">
{% if tab == 'single' %}
<div class="mb-4">
<div class="section-title">选择项目</div>
<div class="col-md-6">
<select name="project" class="form-select" onchange="handleQuickSubmit()">
{% for name in project_names %}
<option value="{{ name }}" {% if name == selected_name %}selected{% endif %}>{{ name }}</option>
{% endfor %}
</select>
</div>
</div>
{% elif tab == 'compare' %}
<div class="mb-4">
<div class="section-title">
选择多个项目
<button type="button" class="btn btn-sm btn-outline-secondary" id="toggleSelectAll">全选</button>
</div>
<div class="col-md-12">
<div class="project-checkbox-grid" id="projectCheckboxGrid">
{% for name in project_names %}
<label class="project-checkbox-item">
<input type="checkbox" name="projects" value="{{ name }}"
{% if name in selected_names %}checked{% endif %}>
<span>{{ name }}</span>
</label>
{% endfor %}
</div>
<p class="text-hint mt-2">点击项目名称可勾选或取消。至少选择一个项目以进行分析。</p>
</div>
</div>
{% endif %}
<div class="mb-4">
<div class="section-title">时间范围设置</div>
<div class="time-mode-group">
<div class="form-check form-check-inline">
<input class="form-check-input" type="radio" name="time_mode" id="quick" value="quick"
{% if time_mode == 'quick' %}checked{% endif %}
onchange="toggleTimeInputs(); handleQuickSubmit();">
<label class="form-check-label" for="quick">快速选择</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="radio" name="time_mode" id="custom" value="custom"
{% if time_mode == 'custom' %}checked{% endif %}
onchange="toggleTimeInputs();">
<label class="form-check-label" for="custom">自定义日期</label>
</div>
</div>
</div>
<div class="mb-4" id="quickPeriodSection" style="{% if time_mode != 'quick' %}display:none;{% endif %}">
<label class="form-label">快速周期</label>
<div class="col-md-6">
<select name="period" class="form-select" onchange="handleQuickSubmit()">
<option value="1w" {% if period == '1w' %}selected{% endif %}>最近1周</option>
<option value="1m" {% if period == '1m' %}selected{% endif %}>最近1个月</option>
<option value="3m" {% if period == '3m' %}selected{% endif %}>最近3个月</option>
<option value="6m" {% if period == '6m' %}selected{% endif %}>最近6个月</option>
</select>
</div>
</div>
<div class="row mb-4" id="customDateSection" style="{% if time_mode != 'custom' %}display:none;{% endif %}">
<div class="col-md-3">
<label class="form-label">开始日期</label>
<input type="date" name="start_date" class="form-control" value="{{ start_date_str }}">
</div>
<div class="col-md-3">
<label class="form-label">结束日期</label>
<input type="date" name="end_date" class="form-control" value="{{ end_date_str }}">
</div>
</div>
<div class="submit-section">
<button type="submit" class="btn btn-primary">查询</button>
{% if last_update and time_mode != 'custom' %}
<span class="text-muted">最后更新:{{ last_update }}</span>
{% endif %}
</div>
</form>
</div>
<!-- 描述性内容 -->
{% if tab == 'single' and chart_html %}
<div class="alert alert-info mb-4">
<strong>项目说明:</strong>
当前分析基于 Jira 中 <code>{{ selected_name }}</code> 项目的 Story 类型任务,
时间范围为 {{ start_date_str }} 至 {{ end_date_str }}。
任务类型通过标题中的前缀识别([task]、[study]、[research]、[support]、[meeting]和[others]),
SP(Story Points)值来自自定义字段 <code>customfield_10026</code>。
</div>
{% elif tab == 'compare' and chart_html %}
<div class="alert alert-info mb-4">
<strong>多项目对比说明:</strong>
同时分析多个项目的 Story 任务工作量分布。每个项目独立计算其各类工作占比。
下方为各项目的独立分析图表,便于清晰查看每个项目的投入结构。
</div>
{% endif %}
<!-- 图表展示 -->
{% if chart_html %}
<div class="chart-card">
{{ chart_html|safe }}
</div>
{% elif tab == 'compare' and not selected_names %}
<div class="chart-card text-center py-5 text-muted">
<p>请选择至少一个项目以查看分析结果。</p>
</div>
{% elif not chart_html %}
<div class="chart-card text-center py-5 text-muted">
<p>请点击上方“查询”按钮以加载分析结果。</p>
</div>
{% endif %}
</div>
<script src="{{ url_for('static', filename='js/bootstrap.bundle.min.js') }}"></script>
<script>
function toggleTimeInputs() {
const isCustom = document.getElementById('custom').checked;
const quickSection = document.getElementById('quickPeriodSection');
const customSection = document.getElementById('customDateSection');
if (isCustom) {
if (quickSection) quickSection.style.display = 'none';
if (customSection) customSection.style.display = 'flex';
} else {
if (quickSection) quickSection.style.display = 'block';
if (customSection) customSection.style.display = 'none';
}
}
function handleQuickSubmit() {
}
document.getElementById('toggleSelectAll')?.addEventListener('click', function () {
const container = document.getElementById('projectCheckboxGrid');
if (!container) return;
const checkboxes = container.querySelectorAll('input[type="checkbox"]');
const allChecked = Array.from(checkboxes).every(cb => cb.checked);
const newChecked = !allChecked;
checkboxes.forEach(cb => cb.checked = newChecked);
this.textContent = newChecked ? '取消全选' : '全选';
});
document.addEventListener('DOMContentLoaded', function () {
toggleTimeInputs();
// 提交时显示“查询中...”
const form = document.getElementById('analysisForm');
if (form) {
form.addEventListener('submit', function () {
const submitBtn = form.querySelector('.btn-primary');
if (submitBtn) {
submitBtn.disabled = true;
submitBtn.textContent = '查询中...';
}
});
}
});
</script>
</body>
</html>

