学习笔记——测试进阶之路 工作笔记:CDT 研发效能质量分析平台

大海 · March 26, 2026 · 298 hits

背景介绍

在公司深化 DevOps 体系建设的背景下,研发管理正面临 “工时估算黑盒” 与 “转测质量模糊” 的双重挑战。当前,JIRA 中的 Story 工时数据虽已记录,但缺乏对 “估算与实际偏差” 的深度量化分析,管理者难以通过单项目洞察或多项目横向对比来识别资源瓶颈与排期风险;同时,开发转测环节因缺少统一的数字化门禁,常导致文档缺失、冒烟失败或规范不符的版本流入测试阶段,造成返工与延期,且这些过程质量数据无法沉淀为可追溯的历史资产。

为此,CDT 研发效能质量分析平台应运而生,旨在通过数据驱动实现管理闭环。平台一方面深度集成 JIRA 数据,提供灵活的时间周期选择与多项目对比视图,直观呈现工时偏差趋势以辅助精准排期;另一方面构建标准化的转测质量打分体系,将开发文档、设计评审、冒烟测试、API 规范及 Git Flow 执行五大维度转化为百分制实时评分,并自动标记转测延迟状态。通过将隐性的研发过程显性化、主观的评价客观化,平台将有效打破效能与质量的数据孤岛,推动研发团队从 “经验驱动” 向 “数据驱动” 转型,实现交付效率与产品质量的同步提升。

主函数代码

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# =============================================================================
#  MIT License  © 2025
# =============================================================================
# @Time    : 2025-11-13 10:52
# @File    : app.py
# @Desc    : 主函数代码
# -----------------------------------------------------------------------------

from flask import Flask, jsonify, render_template, request, url_for, redirect
import report plotly.graph_objects as go
from datetime import datetime, timedelta
import logging
import requests
from requests.auth import HTTPBasicAuth
import urllib.parse
import sqlite3
import os
from concurrent.futures import ThreadPoolExecutor

# ==================== 配置区 ====================
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

JIRA_URL = "https://XXXXXX.atlassian.net"
EMAIL = "XXXXXX@XXXXXX.com"
API_TOKEN = "XXXXXX"

# 【MOD 项目聚合】
MOD_DISPLAY_NAME = "MOD"    
MOD_REAL_JIRA_KEY = "CMSS"      
MOD_SUB_PROJECTS = [      
    "MOD PRM",
    "MOD KPP",
    "MOD FLCAD",
    "MOD KOSP",
    "MOD CMSS"
]

# 原始映射表 (保持完整,用于质量评分页面和内部逻辑)
PROJECT_NAME_TO_KEY = {
    "CKOL": "NP24",
    "CKFM": "CKFM",
    "GovAPI": "QR247",
    "KCDP": "DPFIEA",
    "KFPS": "C3ST",
    "Kedex": "CPF",
    "BPM": "CMS",
    "IOT": "IEDGE",
    "DML": "CDML",
    "DataAnalytic": "EB247",
    "SFM": "KCDO",
    "UserCenter": "KM",
    "SalesPortal": "CCRM",
    "BigScreen": "CNSERVP",
    "IoTEssential": "IE",
    # MOD 子项目映射 (Key 都是 CMSS)
    "MOD PRM": "CMSS",
    "MOD KPP": "CMSS",
    "MOD FLCAD": "CMSS",
    "MOD KOSP": "CMSS",
    "MOD CMSS": "CMSS",
}

# 生成两个不同的项目列表供不同页面使用

# 1. 用于“工时分析”页面的列表
ANALYSIS_PROJECT_NAMES = []
for name in PROJECT_NAME_TO_KEY.keys():
    if name in MOD_SUB_PROJECTS:
        continue  # 跳过子项
    ANALYSIS_PROJECT_NAMES.append(name)
ANALYSIS_PROJECT_NAMES.append(MOD_DISPLAY_NAME) # 添加聚合后的 MOD
# 可选:排序
# ANALYSIS_PROJECT_NAMES.sort()

# 2. 用于“质量评分”页面的列表
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"
}

# ==================== 数据库初始化 ====================
DB_PATH = 'quality_scores.db'

def init_db():
    """初始化 SQLite 数据库,创建转测质量记录表,并安全添加 delay 字段"""
    conn = sqlite3.connect(DB_PATH)
    c = conn.cursor()
    c.execute('''
        CREATE TABLE IF NOT EXISTS quality_records (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            project TEXT NOT NULL,
            trans_date TEXT NOT NULL,
            version TEXT NOT NULL,
            score_doc INTEGER DEFAULT 0,
            score_design INTEGER DEFAULT 0,
            score_smoke INTEGER DEFAULT 0,
            score_api INTEGER DEFAULT 0,
            score_runbook INTEGER DEFAULT 0,
            total_score INTEGER NOT NULL,
            created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
        )
    ''')

    # 添加 delay 字段(仅当不存在时)
    c.execute("PRAGMA table_info(quality_records)")
    columns = [col[1] for col in c.fetchall()]
    if 'delay' not in columns:
        c.execute("ALTER TABLE quality_records ADD COLUMN delay TEXT DEFAULT 'no'")
        logger.info("Added 'delay' column to quality_records table")

    conn.commit()
    conn.close()
    logger.info(f"Database initialized at {DB_PATH}")

# 启动时初始化数据库
init_db()

# ==================== 工具函数 ====================
def extract_prefix(title):
    if not title or not isinstance(title, str):
        return None
    tags = re.findall(r'[\[【]([^\]】]+)[\]】]', title)
    for tag in tags:
        clean_tag = re.sub(r'\s+', '', tag.strip().lower())
        if clean_tag in VALID_PREFIXES:
            return clean_tag
    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},已打标任务:{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="{{ url_for(\'static\', filename=\'js/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):
    """
    获取 Jira 数据。
    【优化逻辑】如果 project_name 是 'MOD',直接使用真实的 Key 'CMSS' 进行单次查询。
    """
    real_key = ""
    display_name = project_name

    # 特殊处理 MOD 聚合项目
    if project_name == MOD_DISPLAY_NAME:
        real_key = MOD_REAL_JIRA_KEY
        logger.info(f"[{display_name}] 映射到真实 Jira Key: {real_key} (单次查询)")
    else:
        real_key = PROJECT_NAME_TO_KEY.get(project_name)
        if not real_key:
            logger.warning(f"[{project_name}] 项目名称无效,无法找到对应的 Jira Key")
            return empty_result(project_name)

    logger.info(f"[{display_name}] 开始查询 Jira 数据,时间范围:{start_date.date()} ~ {end_date.date()}")

    # 根据项目名动态设置 issuetype 过滤条件
    # 注意:如果是 MOD (CMSS),这里沿用默认逻辑 (Story),如有特殊需求可在此追加判断
    if project_name == "CKFM":
        issue_type_clause = "issuetype = Sub-task"
    else:
        issue_type_clause = "issuetype = Story"

    jql = f'project = "{real_key}" AND {issue_type_clause} AND (status = Done) AND resolved >= "{start_date.strftime("%Y-%m-%d")}" AND resolved <= "{end_date.strftime("%Y-%m-%d")}"'

    issue_ids = []
    next_page_token = None
    max_results_per_page = 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"[{display_name}] Enhanced Search 失败:{resp.status_code} {resp.text}")
                return empty_result(display_name, real_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"[{display_name}] Enhanced Search 异常:{e}")
            return empty_result(display_name, real_key)

    total_issues = len(issue_ids)
    if total_issues == 0:
        logger.info(f"[{display_name}] 未找到任何 issue")
        return empty_result(display_name, real_key)

    logger.info(f"[{display_name}] 已获取 {total_issues} 个 Story/Task/Sub-task 任务")
    all_issues = []
    batch_size = 100

    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"[{display_name}] Bulk Fetch 失败 (batch {i // batch_size + 1}): {resp.status_code} {resp.text}")
        except Exception as e:
            logger.error(f"[{display_name}] Bulk Fetch 异常 (batch {i // batch_size + 1}): {e}")

    logger.info(f"[{display_name}] 已加载 {len(all_issues)} 个任务数(Story/Task/Sub-task)")
    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"[{display_name}] 其中 {valid_count} 个任务符合命名规范,视为有效任务")
    prefix_pct = {p: (prefix_sum[p] / total_sp) * 100 if total_sp > 0 else 0.0 for p in VALID_PREFIXES}

    return {
        'project_name': display_name,
        'project_key': real_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'])
def index_redirect():
    return single_view()

@app.route('/single', methods=['GET', 'POST'])
def single_view():
    now = datetime.now()  
    # 使用简化后的项目列表 (包含聚合的 MOD)
    current_project_list = ANALYSIS_PROJECT_NAMES

    if request.method == 'POST':
        selected_name = request.form.get('project', current_project_list[0])
        if selected_name not in current_project_list:
            selected_name = current_project_list[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 = current_project_list[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(
        'analysis.html',
        sub_tab='single',
        project_names=current_project_list, # 传递简化列表
        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,
        now=now 
    )


@app.route('/compare', methods=['GET', 'POST'])
def compare_view():
    now = datetime.now()  
    # 使用简化后的项目列表
    current_project_list = ANALYSIS_PROJECT_NAMES

    if request.method == 'POST':
        selected_names = request.form.getlist('projects')
        # 过滤:只保留简化列表中存在的项目
        selected_names = [n for n in selected_names if n in current_project_list]

        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(
        'analysis.html',
        sub_tab='compare',
        project_names=current_project_list, # 传递简化列表
        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,
        now=now 
    )


@app.route('/quality', methods=['GET', 'POST'])
def quality_view():
    """转测质量评分卡(已支持 delay 字段)"""
    now = datetime.now()
    if request.method == 'POST':
        try:
            if request.is_json:
                data = request.get_json()
            else:
                data = request.get_json(force=True, silent=True)
            if not data:
                data = request.form.to_dict()
        except Exception as e:
            logger.error(f"解析请求数据失败:{e}")
            return {'success': False, 'message': '数据格式错误'}, 400

        project = data.get('project')
        trans_date = data.get('date')
        version = data.get('version')

        try:
            total = int(data.get('total', 0))
        except:
            total = 0

        scores = data.get('scores', {})
        if not isinstance(scores, dict): scores = {}

        try:
            s_doc = int(scores.get('doc', 0))
            s_design = int(scores.get('design', 0))
            s_smoke = int(scores.get('smoke', 0))
            s_api = int(scores.get('api', 0))
            s_runbook = int(scores.get('runbook', 0))
        except:
            s_doc = s_design = s_smoke = s_api = s_runbook = 0

        # 获取 delay 字段,默认为 'no'
        delay = data.get('delay', 'no')
        if delay not in ('yes', 'no'):
            delay = 'no'

        if not project or not trans_date or not version:
            logger.warning(f"保存失败:缺少字段。收到数据:{data}")
            return {'success': False, 'message': '缺少必要字段 (项目、时间或版本)'}, 400

        try:
            conn = sqlite3.connect(DB_PATH)
            c = conn.cursor()
            # 插入 delay 字段
            c.execute('''
                INSERT INTO quality_records 
                (project, trans_date, version, score_doc, score_design, score_smoke, score_api, score_runbook, total_score, delay)
                VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
            ''', (project, trans_date, version, s_doc, s_design, s_smoke, s_api, s_runbook, total, delay))
            conn.commit()
            conn.close()

            logger.info(f"成功保存记录:{project} - {version} ({total}分, delay={delay})")

            if request.is_json or request.content_type == 'application/json':
                return {'success': True, 'message': '保存成功', 'id': c.lastrowid}
            else:
                return redirect(url_for('quality_view'))
        except Exception as e:
            logger.error(f"数据库写入失败:{e}")
            return {'success': False, 'message': str(e)}, 500

    # 这里必须传 PROJECT_NAMES (全量),这样质量页面才能看到 MOD PRM, MOD KPP 等 5 个独立选项
    return render_template(
        'quality.html',
        project_names=PROJECT_NAMES, 
        now=now
    )

# API: 获取历史数据 (支持过滤)
@app.route('/api/quality/history', methods=['GET'])
def get_quality_history():
    """
    获取历史记录(支持过滤,已返回 delay 字段)
    参数: 
      - project: 可选,项目名称
      - months: 可选,最近几个月,默认3
    """
    try:
        project_filter = request.args.get('project')
        months = int(request.args.get('months', 3))

        end_date = datetime.now()
        start_date = end_date - timedelta(days=months * 30)

        conn = sqlite3.connect(DB_PATH)
        conn.row_factory = sqlite3.Row  # 启用列名访问
        c = conn.cursor()

        query = '''
            SELECT * FROM quality_records 
            WHERE trans_date >= ?
        '''
        params = [start_date.strftime('%Y-%m-%d')]

        if project_filter:
            query += ' AND project = ?'
            params.append(project_filter)

        query += ' ORDER BY trans_date DESC, id DESC LIMIT 500'

        c.execute(query, params)
        rows = c.fetchall()
        conn.close()

        data = []
        for row in rows:
            # 安全获取 delay 字段
            columns = row.keys()
            delay_value = row['delay'] if 'delay' in columns else 'no'

            data.append({
                'id': row['id'],
                'project': row['project'],
                'date': row['trans_date'],
                'version': row['version'],
                'scores': {
                    'doc': row['score_doc'],
                    'design': row['score_design'],
                    'smoke': row['score_smoke'],
                    'api': row['score_api'],
                    'runbook': row['score_runbook']
                },
                'total': row['total_score'],
                'delay': delay_value
            })

        return {'success': True, 'data': data}
    except Exception as e:
        logger.error(f"获取历史记录失败:{e}")
        return {'success': False, 'message': str(e)}, 500


# API: 获取图表数据
@app.route('/api/quality/chart', methods=['GET'])
def get_quality_chart_data():
    """
    获取图表数据
    参数:
      - project: 必填,项目名称
      - start_date: 必填,开始日期
      - end_date: 必填,结束日期
    """
    try:
        project = request.args.get('project')
        start_date = request.args.get('start_date')
        end_date = request.args.get('end_date')

        if not all([project, start_date, end_date]):
            return {'success': False, 'message': '缺少必要参数'}, 400

        conn = sqlite3.connect(DB_PATH)
        conn.row_factory = sqlite3.Row
        c = conn.cursor()

        c.execute('''
            SELECT trans_date, version, total_score 
            FROM quality_records 
            WHERE project = ? AND trans_date >= ? AND trans_date <= ?
            ORDER BY trans_date ASC, id ASC
        ''', (project, start_date, end_date))

        rows = c.fetchall()
        conn.close()

        labels = []
        scores = []
        versions = []

        for row in rows:
            labels.append(row['trans_date'])
            scores.append(row['total_score'])
            versions.append(row['version'])

        return {
            'success': True,
            'labels': labels,
            'scores': scores,
            'versions': versions
        }
    except Exception as e:
        return {'success': False, 'message': str(e)}, 500

@app.route('/api/quality/delete', methods=['POST'])
def delete_quality_record():
    """
    管理员删除记录接口
    需要传入 JSON: { "id": 记录 ID, "password": "管理员密码" }
    """
    ADMIN_PASSWORD = '123456' 

    data = request.json
    if not data:
        return jsonify({'success': False, 'message': '无效请求'}), 400

    if data.get('password') != ADMIN_PASSWORD:
        return jsonify({'success': False, 'message': '❌ 密码错误,无权操作'}), 403

    record_id = data.get('id')
    if not record_id:
        return jsonify({'success': False, 'message': '缺少记录 ID'}), 400

    try:
        conn = sqlite3.connect(DB_PATH)
        c = conn.cursor()
        c.execute("DELETE FROM quality_records WHERE id = ?", (record_id,))
        conn.commit()
        deleted_count = c.rowcount
        conn.close()

        if deleted_count > 0:
            logger.info(f"管理员删除了记录 ID: {record_id}")
            return jsonify({'success': True, 'message': '✅ 记录已成功删除'})
        else:
            return jsonify({'success': False, 'message': '未找到该记录,可能已被删除'}), 404

    except Exception as e:
        logger.error(f"删除失败:{e}")
        return jsonify({'success': False, 'message': f'服务器错误:{str(e)}'}), 500


# ==================== 启动 ====================
if __name__ == '__main__':
    app.run(host='0.0.0.0', port=9014, debug=False)

base.html

全局布局 (左侧导航 + 右侧内容)

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{% block title %}CDT 研发效能质量分析平台{% endblock %}</title>
    <link href="{{ url_for('static', filename='css/bootstrap.min.css') }}" rel="stylesheet">

    <!-- 页面特有脚本注入点 (如 Chart.js) -->
    {% block extra_head %}{% endblock %}

    <style>
        /* ================= 全局布局 (左侧导航 + 右侧内容) ================= */
        :root {
            --sidebar-width: 240px;
            --header-height: 60px;
            --primary-color: #4a90e2;
            --bg-color: #f5f7fa;
        }
        body {
            background-color: var(--bg-color);
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
            margin: 0; padding: 0; display: flex; height: 100vh; overflow: hidden;
            color: #333;
        }
        /* 左侧导航栏 */
        .sidebar {
            width: var(--sidebar-width);
            background-color: #2c3e50;
            color: white;
            display: flex;
            flex-direction: column;
            flex-shrink: 0;
            box-shadow: 2px 0 10px rgba(0,0,0,0.1);
            z-index: 1000;
        }
        .sidebar-header {
            height: var(--header-height);
            display: flex; align-items: center; justify-content: center;
            font-size: 1.1rem; font-weight: 700;
            border-bottom: 1px solid rgba(255,255,255,0.1);
            background-color: #1a252f;
        }
        .nav-menu { list-style: none; padding: 20px 0; margin: 0; }
        .nav-item { margin-bottom: 5px; }
        .nav-link {
            display: flex; align-items: center; padding: 14px 20px;
            color: #bdc3c7; text-decoration: none; transition: all 0.3s;
            font-weight: 500; border-left: 4px solid transparent;
        }
        .nav-link:hover { background-color: rgba(255,255,255,0.05); color: white; }
        .nav-link.active {
            background-color: rgba(74, 144, 226, 0.15);
            color: white; border-left-color: var(--primary-color);
        }
        .nav-icon { margin-right: 12px; font-size: 1.2rem; width: 24px; text-align: center; }

        /* 右侧主内容区 */
        .main-content {
            flex-grow: 1;
            display: flex;
            flex-direction: column;
            overflow: hidden;
            background-color: #f5f7fa;
        }
        .top-bar {
            height: var(--header-height);
            background: white;
            border-bottom: 1px solid #e0e6ed;
            display: flex; align-items: center; justify-content: space-between;
            padding: 0 30px;
            flex-shrink: 0;
        }
        .page-title { font-size: 1.4rem; font-weight: 600; color: #2c3e50; margin: 0; }

        .content-scroll-area {
            flex-grow: 1;
            overflow-y: auto;
            padding: 24px;
        }

        /* ================= 项目对比模块样式 ================= */
        .legacy-container { max-width: 960px; margin: 0 auto; }
        .legacy-page-header { margin-bottom: 28px; text-align: center; }
        .legacy-tabs { margin-bottom: 24px; border-bottom: 1px solid #dee2e6; }
        .legacy-tabs .nav-link { color: #495057; border: 1px solid transparent; border-top-left-radius: 0.5rem; border-top-right-radius: 0.5rem; padding: 10px 20px; font-weight: 500; }
        .legacy-tabs .nav-link.active { color: var(--primary-color); background-color: #fff; border-color: #dee2e6 #dee2e6 #fff; border-bottom: 2px solid white; margin-bottom: -1px; }
        .legacy-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; }
        .legacy-form-card:hover { box-shadow: 0 6px 16px rgba(0,0,0,0.08); }
        .legacy-chart-card { background: white; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.06); padding: 24px; margin-bottom: 28px; }
        .legacy-section-title { font-size: 1.1rem; font-weight: 600; margin-bottom: 16px; color: #495057; display: flex; align-items: center; gap: 8px; }
        .legacy-section-title::before { content: ""; display: inline-block; width: 4px; height: 16px; background: #4a90e2; border-radius: 2px; }
        .legacy-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; }
        .legacy-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; }
        .legacy-checkbox-item:hover { background: #f0f7ff; border-color: #4a90e2; }
        .legacy-checkbox-item input[type="checkbox"] { margin-right: 8px; transform: scale(1.15); }
        .legacy-time-group .form-check { margin-right: 20px; }
        .legacy-submit-section { margin-top: 20px; display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
        .legacy-btn-primary { padding-left: 24px; padding-right: 24px; font-weight: 500; background-color: #4a90e2; border-color: #4a90e2; color: white; }
        .legacy-btn-primary:hover { background-color: #357abd; }
        .text-hint { font-size: 0.875rem; color: #6c757d; }
        @media (max-width: 768px) { .legacy-checkbox-grid { grid-template-columns: 1fr; } .legacy-time-group .form-check { margin-right: 0; margin-bottom: 8px; } }

        /* ================= 转测质量专属样式 ================= */
        .qa-row { display: flex; flex-wrap: wrap; margin: 0 -15px; }
        .qa-col-12 { flex: 0 0 100%; max-width: 100%; padding: 0 15px; }
        .qa-card { background: white; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.06); margin-bottom: 30px; border: none; }
        .qa-card-header { background-color: #fff !important; border-bottom: 1px solid #eee; font-size: 1.1rem; font-weight: 600; padding: 15px 20px; }
        .qa-section-title { font-size: 1.1rem; font-weight: 600; margin-bottom: 16px; color: #495057; display: flex; align-items: center; gap: 8px; }
        .qa-section-title::before { content: ""; display: inline-block; width: 4px; height: 18px; background: #4a90e2; border-radius: 2px; }
        .qa-dim-badge { display: inline-block; padding: 6px 12px; border-radius: 4px; color: white; font-weight: 600; font-size: 0.85rem; min-width: 160px; text-align: left; line-height: 1.4; }
        .qa-dim-blue { background-color: #1f77b4; }
        .qa-dim-green { background-color: #2ca02c; }
        .qa-dim-red { background-color: #d62728; }
        .qa-dim-yellow { background-color: #ff7f0e; color: #333; }
        .qa-dim-purple { background-color: #9467bd; }
        .qa-table-modern { width: 100%; border-collapse: separate; border-spacing: 0; margin-bottom: 20px; font-size: 0.95rem; background: #fff; }
        .qa-table-modern th { background-color: #f8f9fa; color: #495057; font-weight: 600; padding: 12px 16px; border-bottom: 2px solid #e9ecef; border-top: none; border-left: none; border-right: none; }
        .qa-table-modern td { padding: 12px 16px; border-bottom: 1px solid #e9ecef; border-top: none; border-left: none; border-right: none; vertical-align: middle; }
        .qa-table-modern tbody tr:last-child td { border-bottom: none; }
        .qa-table-modern tbody tr:hover td { background-color: #f8f9fa; }
        .qa-checkbox { width: 18px; height: 18px; cursor: pointer; accent-color: #4a90e2; margin-right: 8px; }
        .qa-score-row { cursor: pointer; }
        .qa-score-row:hover { background-color: #f1f3f5; }
        .qa-template-link { color: #4a90e2; text-decoration: none; font-size: 0.85rem; }
        .qa-template-link:hover { text-decoration: underline; }

        /* ================= 自定义 Modal 样式 ================= */
        .modal-content { border: none; border-radius: 16px; overflow: hidden; box-shadow: 0 10px 30px rgba(0,0,0,0.2); }
        .modal-header { border-bottom: none; padding: 24px 24px 0 24px; }
        .modal-body { padding: 16px 24px 24px 24px; }
        .modal-footer { border-top: none; padding: 0 24px 24px 24px; justify-content: space-between; }
        .warning-alert { background-color: #fff5f5; color: #b91c1c; border: 1px solid #fecaca; }
    </style>

    <!-- 子页面额外样式注入点 -->
    {% block extra_style %}{% endblock %}
</head>
<body>

<!-- 左侧导航栏 (所有页面共用) -->
<aside class="sidebar">
    <div class="sidebar-header">CDT 研发效能质量分析平台</div>
    <ul class="nav-menu">
        <li class="nav-item">
            <!-- 根据当前路由名称高亮 -->
            <a class="nav-link {% if request.endpoint in ['single_view', 'compare_view'] %}active{% endif %}" href="{{ url_for('single_view') }}">
                <span class="nav-icon">📊</span> 项目工时分析
            </a>
        </li>
        <li class="nav-item">
            <a class="nav-link {% if request.endpoint == 'quality_view' %}active{% endif %}" href="{{ url_for('quality_view') }}">
                <span class="nav-icon"></span> 转测质量评分
            </a>
        </li>
    </ul>
</aside>

<!-- 右侧主内容区 -->
<main class="main-content">
    <div class="top-bar">
        <h1 class="page-title">{% block page_title %}欢迎使用{% endblock %}</h1>
        <div><small class="text-muted">{{ now.strftime('%Y-%m-%d') }}</small></div>
    </div>

    <div class="content-scroll-area">
        <!-- 子页面具体内容注入这里 -->
        {% block content %}{% endblock %}
    </div>
</main>

<!-- 保存确认 Modal (所有页面可能需要,放在 base 中) -->
<div class="modal fade" id="saveConfirmModal" tabindex="-1" aria-hidden="true" data-bs-backdrop="static" data-bs-keyboard="false">
    <div class="modal-dialog modal-dialog-centered">
        <div class="modal-content">
            <div style="height: 8px; background: linear-gradient(90deg, #dc3545, #ff6b6b);"></div>
            <div class="modal-header">
                <div class="d-flex align-items-center gap-3">
                    <div class="flex-shrink-0" style="width: 48px; height: 48px; background: #fff3cd; border-radius: 50%; display: flex; align-items: center; justify-content: center;">
                        <span style="font-size: 24px;">⚠️</span>
                    </div>
                    <div>
                        <h5 class="modal-title fw-bold text-dark m-0" style="font-size: 1.25rem;">最终安全确认</h5>
                        <p class="text-muted m-0 small">请仔细核对以下信息,提交后无法撤回</p>
                    </div>
                </div>
            </div>
            <div class="modal-body">
                <div class="card bg-light border-0 mb-3">
                    <div class="card-body p-3">
                        <div class="row g-2 align-items-center">
                            <div class="col-4 text-muted small fw-bold">项目组:</div>
                            <div class="col-8 text-end fw-bold text-primary" id="modalProjectName">-</div>
                            <div class="col-4 text-muted small fw-bold">版本号:</div>
                            <div class="col-8 text-end fw-bold" id="modalVersion">-</div>
                            <div class="col-4 text-muted small fw-bold">转测时间:</div>
                            <div class="col-8 text-end fw-bold" id="modalDate">-</div>
                            <div class="col-4 text-muted small fw-bold">当前得分:</div>
                            <div class="col-8 text-end">
                                <span class="badge rounded-pill px-3 py-2" id="modalScore" style="font-size: 1em;">-</span>
                            </div>
                        </div>
                    </div>
                </div>
                <div class="alert warning-alert d-flex align-items-start gap-2 mb-0 small" role="alert">
                    <span style="font-size: 1.2em; line-height: 1.2;">🛑</span>
                    <div>
                        <strong>重要提示:</strong><br>
                        数据一旦保存将<strong>永久存入数据库</strong><br>
                        请确保以上信息完全正确后再点击确认。
                    </div>
                </div>
            </div>
            <div class="modal-footer">
                <button type="button" class="btn btn-light px-4" onclick="closeSaveModal()" style="border-radius: 8px;">再检查一下</button>
                <button type="button" class="btn btn-danger px-4 shadow-sm" id="finalConfirmBtn" style="border-radius: 8px; background: #dc3545; border: none;">
                    ✅ 确认无误,立即保存
                </button>
            </div>
        </div>
    </div>
</div>

<script src="{{ url_for('static', filename='js/bootstrap.bundle.min.js') }}"></script>
<!-- 子页面额外脚本注入点 -->
{% block extra_script %}{% endblock %}
</body>
</html>

analysis.html

项目工时与规范性分析

{% extends "base.html" %}

{% block title %}项目工时与规范性分析 - CDT 平台{% endblock %}
{% block page_title %}项目工时与规范性分析{% endblock %}

{% block content %}
<div class="legacy-container">
    <div class="legacy-page-header">
        <ul class="nav nav-tabs legacy-tabs">
            <li class="nav-item">
                <a class="nav-link {% if sub_tab == 'single' %}active{% endif %}" href="{{ url_for('single_view') }}">单项目分析</a>
            </li>
            <li class="nav-item">
                <a class="nav-link {% if sub_tab == 'compare' %}active{% endif %}" href="{{ url_for('compare_view') }}">多项目对比</a>
            </li>
        </ul>
    </div>

    <div class="legacy-form-card">
        <form id="analysisForm" method="post">
            {% if sub_tab == 'single' %}
            <div class="mb-4">
                <div class="legacy-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 sub_tab == 'compare' %}
            <div class="mb-4">
                <div class="legacy-section-title">选择多个项目</div>
                <div class="d-flex justify-content-between align-items-center mb-2">
                    <span class="text-hint">勾选多个项目进行横向对比</span>
                    <button type="button" class="btn btn-sm btn-outline-secondary" id="toggleSelectAll">全选/反选</button>
                </div>
                <div class="legacy-checkbox-grid" id="projectCheckboxGrid">
                    {% for name in project_names %}
                    <label class="legacy-checkbox-item">
                        <input type="checkbox" name="projects" value="{{ name }}" {% if name in selected_names %}checked{% endif %}>
                        <span>{{ name }}</span>
                    </label>
                    {% endfor %}
                </div>
            </div>
            {% endif %}

            <div class="mb-4">
                <div class="legacy-section-title">时间范围设置</div>
                <div class="legacy-time-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="legacy-submit-section">
                <button type="submit" class="btn legacy-btn-primary">查询</button>
                {% if last_update %}<span class="text-hint">最后更新:{{ last_update }}</span>{% endif %}
            </div>
        </form>
    </div>

    {% if chart_html %}
        <div class="legacy-chart-card">{{ chart_html|safe }}</div>
    {% elif sub_tab == 'compare' and not selected_names %}
        <div class="legacy-chart-card text-center py-5 text-muted"><p>请勾选至少一个项目以查看对比结果。</p></div>
    {% elif not chart_html %}
        <div class="legacy-chart-card text-center py-5 text-muted"><p>请点击上方“查询”按钮以加载分析结果。</p></div>
    {% endif %}
</div>
{% endblock %}

{% block extra_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);
    checkboxes.forEach(cb => cb.checked = !allChecked);
});

document.addEventListener('DOMContentLoaded', function () {
    toggleTimeInputs();
    const form = document.getElementById('analysisForm');
    if (form) {
        form.addEventListener('submit', function () {
            const submitBtn = form.querySelector('.legacy-btn-primary');
            if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = '查询中...'; }
        });
    }
});
</script>
{% endblock %}

quality.html

转测质量评分卡

{% extends "base.html" %}

{% block title %}转测质量评分卡 - CDT 平台{% endblock %}
{% block page_title %}转测质量评分卡{% endblock %}

{% block extra_head %}
<script src="{{ url_for('static', filename='js/chart.umd.min.js') }}"></script>
{% endblock %}

{% block content %}
<div class="qa-row">
    <!-- 1. 打分录入区 -->
    <div class="qa-col-12">
        <div class="qa-card shadow-sm mb-4">
            <div class="qa-card-header d-flex justify-content-between align-items-center">
                <span>📝 转测质量自查与录入</span>
                <small class="text-muted fw-normal">请项目组负责人如实勾选</small>
            </div>
            <div class="card-body p-4">
                <div class="row mb-4">
                    <div class="col-md-4">
                        <label class="form-label fw-bold">1. 选择项目组</label>
                        <select id="qualityProjectSelect" class="form-select form-select-lg" onchange="resetQualityForm()">
                            <!-- 默认提示选项,禁用且选中 -->
                            <option value="" disabled selected>请选择项目组...</option>
                            {% for name in project_names %}
                            <option value="{{ name }}">{{ name }}</option>
                            {% endfor %}
                        </select>
                    </div>
                    <div class="col-md-4">
                        <label class="form-label fw-bold">2. 转测时间 <span class="text-danger">*</span></label>
                        <!-- 修改:默认值为空 -->
                        <input type="date" id="qualityDate" class="form-control form-control-lg" value="">
                    </div>
                    <div class="col-md-4">
                        <label class="form-label fw-bold">3. 转测目标版本 <span class="text-danger">*</span></label>
                        <!-- 修改:默认值为空 -->
                        <input type="text" id="qualityVersion" class="form-control form-control-lg" placeholder="例如:v1.0.1" value="">
                    </div>
                </div>
                <hr class="my-4">
                <div class="mb-3">
                    <h5 class="fw-bold mb-3">4. 质量标准自查 (请勾选)</h5>
                    <table class="qa-table-modern">
                        <thead class="table-light">
                            <tr><th style="width:25%">质量维度</th><th style="width:45%">评分项</th><th style="width:10%">满分</th><th style="width:20%">模板链接</th></tr>
                        </thead>
                        <tbody>
                            <tr><td rowspan="2">🟦 开发转测文档(20分)</td><td class="qa-score-row"><input type="checkbox" class="qa-checkbox" data-score="10" data-group="doc" checked><span class="ms-2">文档符合模板要求</span></td><td>10</td><td><a href="https://kone.atlassian.net/wiki/spaces/CDTPMO/pages/614597824" target="_blank" class="qa-template-link">开发转测模板</a></td></tr>
                            <tr><td class="qa-score-row"><input type="checkbox" class="qa-checkbox" data-score="10" data-group="doc" checked><span class="ms-2">文档内容完整</span></td><td>10</td><td><span class="text-muted small">(无)</span></td></tr>

                            <tr><td rowspan="2">🟩 设计文档(20分)</td><td class="qa-score-row"><input type="checkbox" class="qa-checkbox" data-score="10" data-group="design" checked><span class="ms-2">文档符合模板要求</span></td><td>10</td><td><a href="https://kone.atlassian.net/wiki/spaces/KGD/pages/586022916" target="_blank" class="qa-template-link">架构设计模板</a></td></tr>
                            <tr><td class="qa-score-row"><input type="checkbox" class="qa-checkbox" data-score="10" data-group="design" checked><span class="ms-2">文档内容完整</span></td><td>10</td><td><span class="text-muted small">(无)</span></td></tr>

                            <tr><td>🟥 冒烟测试通过率(20分)</td><td class="qa-score-row"><input type="checkbox" class="qa-checkbox" data-score="20" data-group="smoke" checked><span class="ms-2">没有发现 Highest bug</span></td><td>20</td><td><span class="text-muted small">(无)</span></td></tr>

                            <tr><td rowspan="2">🟨 API 接口文档(20分)</td><td class="qa-score-row"><input type="checkbox" class="qa-checkbox" data-score="10" data-group="api" checked><span class="ms-2">API 接口文档完整</span></td><td>10</td><td><span class="text-muted small">(无)</span></td></tr>
                            <tr><td class="qa-score-row"><input type="checkbox" class="qa-checkbox" data-score="10" data-group="api" checked><span class="ms-2">提供 Postman API collection</span></td><td>10</td><td><span class="text-muted small">(无)</span></td></tr>

                            <tr><td rowspan="2">🟪 迭代开始定义的人员管理情况,分支,feature文档(20分)</td><td class="qa-score-row"><input type="checkbox" class="qa-checkbox" data-score="10" data-group="runbook" checked><span class="ms-2">文档符合模板要求</span></td><td>10</td><td><a href="https://kone.atlassian.net/wiki/spaces/KGD/pages/557975129" target="_blank" class="qa-template-link">GIT FLOW</a></td></tr>
                            <tr><td class="qa-score-row"><input type="checkbox" class="qa-checkbox" data-score="10" data-group="runbook" checked><span class="ms-2">文档内容完整</span></td><td>10</td><td><span class="text-muted small">(无)</span></td></tr>

                            <tr><td>🟧 转测延迟</td><td class="qa-score-row">
                                <div class="form-check form-check-inline">
                                    <input class="form-check-input" type="radio" name="delayStatus" id="delayYes" value="yes">
                                    <label class="form-check-label text-danger fw-bold" for="delayYes"></label>
                                </div>
                                <div class="form-check form-check-inline">
                                    <input class="form-check-input" type="radio" name="delayStatus" id="delayNo" value="no" checked>
                                    <label class="form-check-label text-success fw-bold" for="delayNo"></label>
                                </div>
                                <span class="ms-2 text-muted small">(此选项不记入总分,仅做标记)</span>
                            </td><td>-</td><td><span class="text-muted small">(无)</span></td></tr>

                            <tr style="background-color: #eef2f7 !important;"><td colspan="4" class="text-center fw-bold text-primary" style="font-size: 1.1rem; padding: 16px;">⭐ 标准总分(100分)</td></tr>
                        </tbody>
                    </table>
                </div>
                <div class="card bg-light border-0 mb-3">
                    <div class="card-body d-flex justify-content-between align-items-center flex-wrap gap-3">
                        <!-- 修改:默认显示“未选择” -->
                        <div><h5 class="mb-1">当前项目:<span id="currentProjectNameDisplay" class="fw-bold text-primary">未选择</span></h5><small class="text-muted">实时计算得分</small></div>
                        <div class="text-center"><span class="display-4 fw-bold" id="realTimeScore" style="color: #d62728;">0</span><span class="text-muted fs-5">/ 100</span></div>
                        <div class="d-flex gap-2">
                            <button type="button" class="btn btn-primary btn-lg px-4" onclick="saveQualityRecord()">💾 保存记录</button>
                            <button type="button" class="btn btn-outline-secondary btn-lg" onclick="resetQualityForm()">🔄 重置</button>
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </div>

    <!-- 2. 趋势图表 -->
    <div class="qa-col-12">
        <div class="qa-card shadow-sm mb-4">
            <div class="qa-card-header d-flex justify-content-between align-items-center flex-wrap gap-3">
                <span>📈 转测质量趋势分析</span>
                <div class="d-flex gap-2 align-items-center">
                    <select id="chartProjectFilter" class="form-select form-select-sm" style="width:200px;">
                        {% for name in project_names %}<option value="{{ name }}">{{ name }}</option>{% endfor %}
                    </select>
                    <input type="date" id="chartStartDate" class="form-control form-control-sm" style="width:auto;">
                    <span class="text-muted"></span>
                    <input type="date" id="chartEndDate" class="form-control form-control-sm" style="width:auto;">
                    <button type="button" class="btn btn-sm btn-primary" onclick="updateChart()">查询</button>
                </div>
            </div>
            <div class="card-body">
                <div style="height:350px; position:relative;"><canvas id="qualityTrendChart"></canvas></div>
                <div id="chartNoData" class="text-center py-5 text-muted" style="display:none;">暂无数据。</div>
            </div>
        </div>
    </div>

    <!-- 3. 历史记录 -->
    <div class="qa-col-12">
        <div class="qa-card shadow-sm">
            <div class="qa-card-header d-flex justify-content-between align-items-center">
                <span>📋 转测质量历史记录 (最近 3 个月)</span>
                <button type="button" class="btn btn-sm btn-outline-secondary" onclick="loadQualityHistory()">🔄 刷新</button>
            </div>
            <div class="card-body p-0">
                <div class="table-responsive" style="max-height:500px; overflow-y:auto;">
                    <table class="table table-hover table-striped mb-0" style="min-width:1000px;">
                        <thead class="table-light sticky-top">
                            <tr>
                                <th style="width:10%">项目组*</th><th style="width:10%">转测时间*</th><th style="width:10%">转测目标版本</th>
                                <th class="text-center">开发转测文档<br><small>(20分)</small></th><th class="text-center">设计文档<br><small>(20分)</small></th>
                                <th class="text-center">冒烟测试通过率<br><small>(20分)</small></th><th class="text-center">API 接口文档<br><small>(20分)</small></th>
                                <th class="text-center">Git Flow文档<br><small>(20分)</small></th>
                                <th class="text-center" style="background:#fff3cd;">⭐ 总分<br><small>(100分)</small></th>
                                <th class="text-center" style="background:#ffe6e6;">转测延迟</th>
                                <th class="text-center" style="width:6%; background:#f8f9fa;">操作</th>
                            </tr>
                        </thead>
                        <tbody id="historyTableBody"></tbody>
                    </table>
                </div>
            </div>
        </div>
    </div>
</div>

<div class="alert alert-warning mt-3">
    <strong>⚠️ 重要提示:</strong> 数据一旦保存,将<strong>永久存入数据库</strong>。如需删除请联系管理员或使用删除功能。
</div>
{% endblock %}

{% block extra_script %}
<script>
let qualityRecords = [];
let trendChartInstance = null;
let pendingSaveData = null;

document.addEventListener('DOMContentLoaded', function() {
    const today = new Date();
    const threeMonthsAgo = new Date();
    threeMonthsAgo.setMonth(today.getMonth() - 3);
    const endDateInput = document.getElementById('chartEndDate');
    const startDateInput = document.getElementById('chartStartDate');
    if(endDateInput) endDateInput.valueAsDate = today;
    if(startDateInput) startDateInput.valueAsDate = threeMonthsAgo;

    const projSelect = document.getElementById('qualityProjectSelect');
    const chartProjSelect = document.getElementById('chartProjectFilter');

    // 只有当项目选择框有值时,才同步给图表过滤器
    if(projSelect && chartProjSelect && projSelect.value) {
        chartProjSelect.value = projSelect.value;
    }

    initQualityScorecard();

    const confirmBtn = document.getElementById('finalConfirmBtn');
    if(confirmBtn) {
        confirmBtn.onclick = function() {
            if (pendingSaveData) {
                executeSave(pendingSaveData);
                closeSaveModal();
            }
        };
    }
});

function initQualityScorecard() {
    document.querySelectorAll('.qa-checkbox').forEach(cb => cb.addEventListener('change', calculateRealTimeScore));
    const projectSelect = document.getElementById('qualityProjectSelect');
    if(projectSelect) {
        projectSelect.addEventListener('change', function() {
            // 更新显示的项目名称
            const projectName = this.value ? this.value : '未选择';
            document.getElementById('currentProjectNameDisplay').textContent = projectName;

            // 同步图表过滤器
            const chartProjSelect = document.getElementById('chartProjectFilter');
            if(chartProjSelect && this.value) {
                chartProjSelect.value = this.value;
                updateChart();
            }

            // 切换项目时重置表单(但不自动填日期了)
            resetQualityForm();
        });
        // 初始化显示
        const initialName = projectSelect.value ? projectSelect.value : '未选择';
        document.getElementById('currentProjectNameDisplay').textContent = initialName;
    }
    loadQualityHistory();
    // 如果没有默认选中的项目,不主动更新图表
    if(projectSelect && projectSelect.value) {
        updateChart();
    }
}

function calculateRealTimeScore() {
    let total = 0, scores = { doc: 0, design: 0, smoke: 0, api: 0, runbook: 0 };
    document.querySelectorAll('.qa-checkbox:checked').forEach(cb => {
        const score = parseInt(cb.getAttribute('data-score')) || 0;
        const group = cb.getAttribute('data-group');
        total += score;
        if(group && scores[group] !== undefined) scores[group] += score;
    });
    const scoreDisplay = document.getElementById('realTimeScore');
    if (scoreDisplay) {
        scoreDisplay.textContent = total;
        scoreDisplay.style.color = total === 100 ? '#2ca02c' : (total >= 80 ? '#1f77b4' : (total >= 60 ? '#ff7f0e' : '#d62728'));
    }
    return { total, scores };
}

function openSaveModal(data) {
    document.getElementById('modalProjectName').textContent = data.project;
    document.getElementById('modalVersion').textContent = data.version;
    document.getElementById('modalDate').textContent = data.date;
    const scoreBadge = document.getElementById('modalScore');
    scoreBadge.textContent = data.total + ' / 100';
    if(data.total === 100) scoreBadge.className = 'badge rounded-pill px-3 py-2 bg-success';
    else if (data.total >= 80) scoreBadge.className = 'badge rounded-pill px-3 py-2 bg-primary';
    else if (data.total >= 60) scoreBadge.className = 'badge rounded-pill px-3 py-2 bg-warning text-dark';
    else scoreBadge.className = 'badge rounded-pill px-3 py-2 bg-danger';
    pendingSaveData = data;
    const modalEl = document.getElementById('saveConfirmModal');
    const modal = new bootstrap.Modal(modalEl);
    modal.show();
}

function closeSaveModal() {
    const modalEl = document.getElementById('saveConfirmModal');
    const modal = bootstrap.Modal.getInstance(modalEl);
    if (modal) modal.hide();
    pendingSaveData = null;
}

// 【核心修改】保存校验逻辑升级
async function saveQualityRecord() {
    const projectSelect = document.getElementById('qualityProjectSelect');
    const project = projectSelect.value;
    const date = document.getElementById('qualityDate').value;
    const version = document.getElementById('qualityVersion').value;

    // 第一步:校验项目组
    if (!project) { 
        alert("❌ 请先选择项目组!"); 
        projectSelect.focus(); 
        return; 
    }

    // 第二步:校验时间
    if (!date) { 
        alert("❌ 请选择转测时间!"); 
        document.getElementById('qualityDate').focus();
        return; 
    }

    // 第三步:校验版本
    if (!version) { 
        alert("❌ 请填写转测目标版本!"); 
        document.getElementById('qualityVersion').focus();
        return; 
    }

    const result = calculateRealTimeScore();
    if(result.total === 0) { 
        if(!confirm("⚠️ 当前得分为 0 分,确定要保存吗?")) return; 
    }

    // 获取转测延迟状态
    const delayStatus = document.querySelector('input[name="delayStatus"]:checked')?.value || 'no';

    const saveData = { project, date, version, total: result.total, scores: result.scores, delay: delayStatus };
    openSaveModal(saveData);
}

async function executeSave(data) {
    try {
        const btn = document.getElementById('finalConfirmBtn');
        const originalText = btn.innerHTML;
        btn.disabled = true;
        btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>保存中...';
        const response = await fetch('/quality', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
            body: JSON.stringify(data)
        });
        const resData = await response.json();
        btn.disabled = false;
        btn.innerHTML = originalText;
        if (response.ok && resData.success) {
            alert("✅ 保存成功!数据已永久归档。");
            loadQualityHistory();
            updateChart();
            resetQualityForm();
        } else {
            alert("❌ 保存失败:" + (resData.message || "未知错误"));
        }
    } catch (error) {
        console.error("保存错误:", error);
        alert("❌ 网络错误,保存失败");
        const btn = document.getElementById('finalConfirmBtn');
        if(btn) { btn.disabled = false; btn.innerHTML = '✅ 确认无误,立即保存'; }
    }
}

async function loadQualityHistory() {
    try {
        const res = await fetch('/api/quality/history?months=3');
        const result = await res.json();
        if (result.success) { qualityRecords = result.data; renderHistoryTable(); }
    } catch (e) { console.error(e); }
}

function renderHistoryTable() {
    const tbody = document.getElementById('historyTableBody');
    if (!tbody) return;
    tbody.innerHTML = '';
    if (qualityRecords.length === 0) {
        tbody.innerHTML = '<tr><td colspan="11" class="text-center py-4 text-muted">暂无记录。</td></tr>';
        return;
    }
    qualityRecords.forEach(r => {
        const tr = document.createElement('tr');
        const getVal = g => (r.scores[g] > 0) ? `<span class="fw-bold">${r.scores[g]}</span>` : '<span class="text-muted">-</span>';
        const color = r.total === 100 ? '#2ca02c' : (r.total >= 80 ? '#1f77b4' : '#d62728');
        // 转测延迟显示:是=红色,否=绿色
        const delayDisplay = r.delay === 'yes'
            ? '<span class="badge rounded-pill bg-danger">是</span>'
            : '<span class="badge rounded-pill bg-success">否</span>';
        tr.innerHTML = `<td><strong>${r.project}</strong></td><td>${r.date}</td><td><span class="badge bg-secondary">${r.version}</span></td>
            <td class="text-center">${getVal('doc')}</td><td class="text-center">${getVal('design')}</td>
            <td class="text-center">${getVal('smoke')}</td><td class="text-center">${getVal('api')}</td>
            <td class="text-center">${getVal('runbook')}</td>
            <td class="text-center"><span class="badge rounded-pill" style="background:${color}">${r.total}</span></td>
            <td class="text-center">${delayDisplay}</td>
            <td class="text-center">
                <button type="button" class="btn btn-sm btn-outline-danger border-0"
                        onclick="promptDelete(${r.id}, '${r.project}', '${r.version}')"
                        title="删除此记录">🗑️</button>
            </td>`;
        tbody.appendChild(tr);
    });
}

function promptDelete(id, project, version) {
    if (!confirm(`⚠️ 警告:您即将删除以下记录\n\n项目:${project}\n版本:${version}\n\n此操作不可恢复,确定继续吗?`)) return;
    const password = prompt("🔒 请输入管理员密码以确认删除:");
    if (!password) return;
    executeDelete(id, password);
}

async function executeDelete(id, password) {
    try {
        const response = await fetch('/api/quality/delete', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ id: id, password: password })
        });
        const result = await response.json();
        if (response.ok && result.success) {
            alert("" + result.message);
            loadQualityHistory();
        } else {
            alert("❌ 删除失败:" + result.message);
        }
    } catch (error) {
        console.error("删除错误:", error);
        alert("❌ 网络错误,删除失败");
    }
}

async function updateChart() {
    const project = document.getElementById('chartProjectFilter').value;
    const start = document.getElementById('chartStartDate').value;
    const end = document.getElementById('chartEndDate').value;
    // 如果缺少必要参数,直接返回,不执行查询
    if (!project || !start || !end) { 
        return; 
    }
    try {
        const res = await fetch(`/api/quality/chart?project=${project}&start_date=${start}&end_date=${end}`);
        const result = await res.json();
        const ctx = document.getElementById('qualityTrendChart').getContext('2d');
        const noData = document.getElementById('chartNoData');
        const canvas = document.getElementById('qualityTrendChart');
        if (!result.success || result.labels.length === 0) {
            if (trendChartInstance) { trendChartInstance.destroy(); trendChartInstance = null; }
            canvas.style.display = 'none'; noData.style.display = 'block';
            return;
        }
        canvas.style.display = 'block'; noData.style.display = 'none';
        if (trendChartInstance) trendChartInstance.destroy();
        trendChartInstance = new Chart(ctx, {
            type: 'line',
            data: {
                labels: result.labels,
                datasets: [{
                    label: `${project} 质量得分趋势`,
                    data: result.scores,
                    borderColor: '#4a90e2', backgroundColor: 'rgba(74, 144, 226, 0.1)',
                    borderWidth: 3, pointRadius: 6, fill: true, tension: 0.3
                }]
            },
            options: {
                responsive: true, maintainAspectRatio: false,
                scales: { y: { beginAtZero: true, max: 100, ticks: { stepSize: 20 } } },
                plugins: { tooltip: { callbacks: { label: c => `得分:${c.parsed.y} (版本:${result.versions[c.dataIndex]})` } } }
            }
        });
    } catch (e) { console.error(e); }
}

// 重置表单时,不再自动填充日期
function resetQualityForm() {
    document.querySelectorAll('.qa-checkbox').forEach(cb => cb.checked = true);
    // 重置转测延迟为"否"
    document.getElementById('delayNo').checked = true;
    document.getElementById('delayYes').checked = false;
    calculateRealTimeScore();
    // 全部清空,不自动填充
    document.getElementById('qualityDate').value = '';
    document.getElementById('qualityVersion').value = '';
}
</script>
{% endblock %}

实际效果

如果觉得我的文章对您有用,请随意打赏。您的支持将鼓励我继续创作!
No Reply at the moment.
需要 Sign In 后方可回复, 如果你还没有账号请点击这里 Sign Up