Files
toolbox/resources/js/components/jira/TestMailGenerator.vue
T

251 lines
30 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="h-full overflow-y-auto bg-gray-50">
<div class="sticky top-0 z-20 border-b border-gray-200 bg-white/95 backdrop-blur">
<div class="px-4 py-3">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 class="text-xl font-bold text-gray-900">生成提测邮件</h1>
<p class="text-xs text-gray-500">动线Sprint 收件人 Jira需求 容器/数据库 八截图与九/十表格 预览下载</p>
</div>
<div class="flex gap-2">
<button @click="loadData" :disabled="loading" class="btn-primary">{{ loading ? '拉取中...' : '刷新 Jira' }}</button>
<button v-if="isAdmin" @click="openMailDraft" :disabled="draftOpening" class="btn-secondary">{{ draftOpening ? '打开中...' : '打开邮件草稿' }}</button>
<button @click="downloadEml" :disabled="downloading" class="btn-success">{{ downloading ? '生成中...' : '下载 .eml' }}</button>
</div>
</div>
<div class="mt-3 grid grid-cols-2 gap-2 text-xs text-gray-600 md:grid-cols-6">
<div v-for="step in steps" :key="step" class="rounded bg-blue-50 px-2 py-1 text-blue-700">{{ step }}</div>
</div>
</div>
</div>
<div class="grid grid-cols-1 gap-4 p-4 2xl:grid-cols-[minmax(0,1fr)_520px]">
<main class="space-y-4">
<section class="dense-card">
<div class="dense-title">1. Sprint 与邮件基础信息</div>
<div class="grid grid-cols-1 gap-3 lg:grid-cols-12">
<label class="lg:col-span-3 compact-field">
<span>Sprint下拉可选</span>
<select v-model="sprint" @change="handleSprintSelection" class="control">
<option value="">请选择 Sprint</option>
<option v-for="option in sprintOptions" :key="option.id || option.name" :value="option.id || option.name">{{ option.label || option.name || option.id }}</option>
</select>
</label>
<label class="lg:col-span-2 compact-field"><span>手工 Sprint ID</span><input v-model="sprint" class="control" placeholder="如 2324"></label>
<label class="lg:col-span-7 compact-field"><span>邮件主题</span><input v-model="subject" class="control"></label>
</div>
<div v-if="error" class="mt-3 rounded border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ error }}</div>
<div v-if="jql" class="mt-2 truncate text-xs text-gray-400" :title="jql">JQL{{ jql }}</div>
</section>
<section class="dense-card">
<div class="dense-title">2. 收件人</div>
<div class="grid grid-cols-1 gap-3 xl:grid-cols-12">
<label class="xl:col-span-3 compact-field"><span>From</span><input v-model="from" class="control"></label>
<label class="xl:col-span-4 compact-field"><span>To</span><textarea v-model="to" rows="3" class="control"></textarea></label>
<label class="xl:col-span-5 compact-field"><span>Cc</span><textarea v-model="cc" rows="3" class="control"></textarea></label>
</div>
</section>
<section class="dense-card">
<div class="mb-2 flex items-center justify-between gap-3">
<div class="dense-title !mb-0">3. Jira 需求表格自动生成{{ issues.length }} </div>
<div class="text-xs text-gray-500">邮件一需求内容直接使用此表格已去掉第一点截图入口</div>
</div>
<div class="max-h-[360px] overflow-auto rounded border">
<table class="dense-table min-w-[1180px]">
<thead><tr><th v-for="h in issueHeaders" :key="h">{{ h }}</th></tr></thead>
<tbody>
<tr v-for="issue in issues" :key="issue.key">
<td><a :href="issue.url" target="_blank" class="text-blue-600">{{ issue.key }}</a></td>
<td class="min-w-72">{{ issue.summary }}</td>
<td>{{ issue.reporter || '-' }}</td><td>{{ issue.status }}</td><td>{{ issue.developer || '-' }}</td><td>{{ issue.assignee || '-' }}</td><td>{{ issue.sprint || '-' }}</td><td>{{ issue.estimated_test_at || '' }}</td><td>{{ issue.estimated_release_at || '' }}</td>
</tr>
<tr v-if="!issues.length"><td colspan="9" class="text-center text-gray-400">请选择 Sprint 后拉取 Jira 数据</td></tr>
</tbody>
</table>
</div>
</section>
<section class="dense-card">
<div class="dense-title">4. 容器部署和版本 / 数据库自动探测</div>
<div class="grid grid-cols-1 gap-3 xl:grid-cols-3">
<div v-for="group in containerGroups" :key="group.key" class="rounded-lg border border-gray-200 bg-gray-50 p-3">
<div class="mb-2 flex items-center justify-between gap-2">
<label class="text-sm font-semibold text-gray-800"><input type="checkbox" :checked="isGroupSelected(group.key)" @change="toggleGroup(group.key, $event.target.checked)"> {{ group.label }}</label>
<input v-model="versions[group.key]" @change="refreshDatabases" class="w-28 rounded border px-2 py-1 text-xs" placeholder="版本号">
</div>
<div class="space-y-1">
<label v-for="container in group.containers" :key="container.name" class="flex items-center justify-between gap-2 text-xs text-gray-700">
<span><input type="checkbox" :value="container.name" v-model="selectedContainers"> {{ container.name }}</span>
<span class="text-gray-400">{{ container.location }}</span>
</label>
</div>
</div>
</div>
<div class="mt-3 flex items-center justify-between gap-2">
<button @click="refreshDatabases" class="rounded bg-blue-100 px-3 py-1.5 text-sm text-blue-700 hover:bg-blue-200">刷新数据库分支</button>
<span class="text-xs text-gray-500">agent / portal / portal-ticket 版本号可各自调整</span>
</div>
<div class="mt-3 overflow-auto rounded border">
<table class="dense-table min-w-[520px]"><thead><tr><th>系统</th><th>是否有数据库</th><th>分支</th></tr></thead><tbody><tr v-for="row in databases" :key="row.group"><td>{{ row.system }}</td><td>{{ row.has_database }}</td><td>{{ row.branch || '-' }}</td></tr></tbody></table>
</div>
</section>
<section class="dense-card">
<div class="dense-title">5. 可编辑补充内容</div>
<div class="grid grid-cols-1 gap-3 lg:grid-cols-12">
<label class="lg:col-span-2 compact-field"><span>冒烟通过率</span><input v-model="smokeRate" class="control"></label>
<label class="lg:col-span-5 compact-field"><span>冒烟链接</span><textarea v-model="smokeUrl" rows="2" class="control" placeholder="支持多行,每行一个链接或说明"></textarea></label>
<label class="lg:col-span-5 compact-field"><span>技术文档</span><input v-model="techDocs" class="control" placeholder="链接或说明"></label>
<label class="lg:col-span-6 compact-field"><span>紧急需求</span><textarea v-model="urgentItems" rows="2" class="control"></textarea></label>
<label class="lg:col-span-6 compact-field"><span>延期需求</span><textarea v-model="delayedItems" rows="2" class="control"></textarea></label>
</div>
</section>
<section class="dense-card">
<div class="mb-2 flex items-center justify-between"><div class="dense-title !mb-0">环境部署准备清单截图</div><button @click="clearEnvironmentScreenshots" class="rounded bg-gray-100 px-2 py-1 text-xs hover:bg-gray-200">清空截图</button></div>
<div ref="environmentPasteBox" contenteditable="true" @paste="handlePaste($event, 'environment')" class="min-h-28 rounded-lg border-2 border-dashed border-green-300 bg-green-50 p-3 text-sm focus:outline-none focus:ring-2 focus:ring-green-500">
<p class="text-gray-500">点击这里后直接粘贴环境部署准备清单截图当前 {{ environmentImages.length }} </p>
</div>
</section>
<section class="dense-card">
<div class="mb-2 flex flex-wrap items-center justify-between gap-2">
<div class="dense-title !mb-0">测试注意事项 / 其他依赖项表格填写</div>
<div class="flex items-center gap-2">
<span v-if="draftSource" class="text-xs text-gray-400">草稿来源{{ draftSourceLabel }}</span>
<button @click="generateDraftSections" :disabled="draftLoading" class="rounded bg-green-100 px-2 py-1 text-xs text-green-700 hover:bg-green-200 disabled:opacity-50">{{ draftLoading ? '生成中...' : '生成九/十草稿' }}</button>
<button @click="addNoteRow" class="rounded bg-blue-100 px-2 py-1 text-xs text-blue-700 hover:bg-blue-200">新增一行</button>
</div>
</div>
<EditableDenseTable :headers="noteHeaders" :rows="testNoteRows" :columns="noteColumns" @remove="removeNoteRow" />
</section>
<section class="dense-card">
<div class="mb-2 flex items-center justify-between gap-2"><div class="dense-title !mb-0">已知问题与风险表格填写</div><button @click="addRiskRow" class="rounded bg-blue-100 px-2 py-1 text-xs text-blue-700 hover:bg-blue-200">新增一行</button></div>
<EditableDenseTable :headers="riskHeaders" :rows="riskRows" :columns="riskColumns" @remove="removeRiskRow" />
</section>
</main>
<aside class="2xl:sticky 2xl:top-[104px] 2xl:h-[calc(100vh-120px)]">
<section class="dense-card flex h-full flex-col">
<div class="mb-3 flex items-center justify-between"><div class="dense-title !mb-0">邮件预览</div><span class="text-xs text-gray-500">Thunderbird 打开前快速校验</span></div>
<div class="min-h-0 flex-1 overflow-auto rounded-md border bg-white p-4" v-html="mailHtml"></div>
</section>
</aside>
</div>
</div>
</template>
<script>
function normalizeTestMailSprintPeriod(value) {
const text = String(value || '').trim();
if (!text) return '';
let match = text.match(/[A-Z]+(\d{4})(中|底)迭代/iu);
if (match) return `Sprint${match[1]}${match[2]}`;
match = text.match(/(?:Sprint\s*)?(\d{4})\s*月\s*(中|底)/iu);
if (match) return `Sprint${match[1]}${match[2]}`;
match = text.match(/(?:20)?(\d{2})\s*年\s*0?([1-9]|1[0-2])\s*月\s*(中|底)/u);
if (match) return `Sprint${match[1]}${String(Number(match[2])).padStart(2, '0')}${match[3]}`;
return '';
}
const LAST_SPRINT_STORAGE_KEY = 'toolbox.testMail.lastSprint';
const EditableDenseTable = {
props: ['headers', 'rows', 'columns'], emits: ['remove'],
template: `<div class="overflow-auto rounded border"><table class="dense-table min-w-[900px]"><thead><tr><th v-for="h in headers" :key="h">{{ h }}</th><th class="w-14">操作</th></tr></thead><tbody><tr v-for="(row, index) in rows" :key="row._id || index"><td v-for="col in columns" :key="col.key"><select v-if="col.type === 'select'" v-model="row[col.key]" class="table-control"><option v-for="option in col.options" :key="option" :value="option">{{ option }}</option></select><textarea v-else-if="col.type === 'textarea'" v-model="row[col.key]" rows="2" class="table-control resize-y"></textarea><input v-else v-model="row[col.key]" class="table-control"></td><td><button @click="$emit('remove', index)" class="text-xs text-red-600 hover:underline">删除</button></td></tr></tbody></table></div>`
};
export default {
name: 'TestMailGenerator', components: { EditableDenseTable },
props: {
isAdmin: {
type: Boolean,
default: false,
},
},
data() { return {
steps: ['1 Sprint','2 收件人','3 Jira 表格','4 容器/数据库','5 八截图+九/十表格','6 下载'],
sprint: '', sprintOptions: [], loading: false, downloading: false, draftLoading: false, draftOpening: false, draftSource: '', error: '', jql: '', issues: [], defaults: {}, images: [],
from: '万文山 <wanwenshan@angelalign.com>',
to: '"ouyangxiaowen@angelalign.com" <ouyangxiaowen@angelalign.com>, "yaowenying@angelalign.com" <yaowenying@angelalign.com>, "guoziliang@angelalign.com" <guoziliang@angelalign.com>, chenhui7@angelalign.com, leyunpeng@angelalign.com',
cc: '黄宇 <huangyu@angelalign.com>, "yujie2@angelalign.com" <yujie2@angelalign.com>, "lizhongyuan@angelalign.com" <lizhongyuan@angelalign.com>, "huangfang2@angelalign.com" <huangfang2@angelalign.com>, 周国辉 <zhouguohui@angelalign.com>, "yuxinli@angelalign.com" <yuxinli@angelalign.com>, "zhangzhen3@angelalign.com" <zhangzhen3@angelalign.com>, "renzhaochun@angelalign.com" <renzhaochun@angelalign.com>, "yangyunhao@angelalign.com" <yangyunhao@angelalign.com>, "xiangshang@angelalign.com" <xiangshang@angelalign.com>, "liuyuan1@angelalign.com" <liuyuan1@angelalign.com>, zhangyuan1@angelalign.com, yangjuan1@angelalign.com, wanghe2@angelalign.com',
subject: '【提测】需求提测(SP、PP、TP)', smokeRate: '100%', smokeUrl: '', techDocs: '无', urgentItems: '无', delayedItems: '无',
selectedContainers: [], selectedGroups: [], versions: {}, databases: [],
testNoteRows: [{_id:1,type:'脚本',issue:'',system:'',content:'',owner:''},{_id:2,type:'配置项',issue:'',system:'',content:'',owner:''}],
riskRows: [{_id:1,problem:'',impact:'',action:'',owner:''}],
}; },
computed: {
issueHeaders() { return ['关键字','主题','报告人','状态','研发owner','经办人','Sprint','预计提测时间','预计发布时间']; },
noteHeaders() { return ['类型','需求/事项','系统/容器','内容(脚本、配置项、依赖说明)','负责人']; },
noteColumns() { return [{key:'type',type:'select',options:['脚本','配置项','其他依赖项','测试注意事项']},{key:'issue'},{key:'system'},{key:'content',type:'textarea'},{key:'owner'}]; },
riskHeaders() { return ['已知问题/风险','影响范围','处理方案/规避措施','负责人']; },
riskColumns() { return [{key:'problem',type:'textarea'},{key:'impact',type:'textarea'},{key:'action',type:'textarea'},{key:'owner'}]; },
containerGroups() { const groups = this.defaults.container_groups || {}; return Object.keys(groups).map(key => ({ key, ...groups[key] })); },
environmentImages() { return this.images.filter(i => i.section === 'environment'); },
selectedContainerRows() { const rows=[]; for (const group of this.containerGroups) for (const c of group.containers || []) if (this.selectedContainers.includes(c.name)) rows.push({name:c.name, version:this.versions[group.key] || group.default_version || '', location:c.location}); return rows; },
draftSourceLabel() { return this.draftSource === 'ai' ? 'AI' : '规则默认'; },
mailHtml() { return `<div style="font-family:'Microsoft YaHei UI',Arial,sans-serif;font-size:14px;color:#000;line-height:1.5">${this.section('一、需求内容')}${this.issueTableHtml()}${this.section('二、技术文档')}${this.multiline(this.techDocs)}${this.section('三、冒烟测试情况:')}${this.paragraph(`冒烟通过率:${this.escape(this.smokeRate)}`)}${this.smokeLinksHtml()}${this.section('四、计划异常情况')}${this.paragraph('紧急需求:<br>&nbsp;&nbsp;&nbsp;&nbsp;' + this.escape(this.urgentItems).replace(/\n/g, '<br>&nbsp;&nbsp;&nbsp;&nbsp;'))}${this.paragraph('延期需求:<br>&nbsp;&nbsp;&nbsp;&nbsp;' + this.escape(this.delayedItems).replace(/\n/g, '<br>&nbsp;&nbsp;&nbsp;&nbsp;'))}${this.section('五、容器部署和版本')}${this.simpleTable(['容器','版本号','服务器所在地'], this.selectedContainerRows, ['name','version','location'])}${this.section('六、数据库')}${this.simpleTable(['系统','是否有数据库','分支'], this.databases, ['system','has_database','branch'])}${this.section('七、是否涉及合规')}${this.paragraph('&nbsp;&nbsp;&nbsp;&nbsp;不涉及')}${this.section('八、环境部署准备清单:')}${this.screenshotHtml('environment')}${this.section('九、测试注意事项/其他依赖项')}${this.noteTableHtml()}${this.section('十、已知问题与风险')}${this.riskTableHtml()}</div>`; }
},
watch: { sprint(value) { this.rememberSprint(value); this.updateSubjectFromSprint(); } },
async mounted() { await this.loadSprints(); await this.loadData(); },
methods: {
csrf() { return document.querySelector('meta[name="csrf-token"]').getAttribute('content'); },
async loadSprints() { try { const data = await (await fetch('/api/test-mail/sprints')).json(); if (data.success) { this.sprintOptions = data.data.sprints || []; const savedSprint = this.restoreSprint(); if (!this.sprint && savedSprint) this.sprint = savedSprint; if (!this.sprint && this.sprintOptions.length) this.sprint = this.sprintOptions[0].id || this.sprintOptions[0].name || ''; this.defaults = data.data.defaults || {}; this.initializeContainers(); this.updateSubjectFromSprint(); } } catch (e) { console.error(e); } },
initializeContainers() { const groups = this.containerGroups; this.selectedGroups = groups.map(g => g.key); this.versions = Object.fromEntries(groups.map(g => [g.key, g.default_version || ''])); this.selectedContainers = groups.flatMap(g => (g.containers || []).map(c => c.name)); this.databases = []; this.refreshDatabases(); },
async loadData() { if (!this.sprint.trim()) { this.error = '请输入 Sprint'; return; } this.loading = true; this.error = ''; try { const data = await (await fetch(`/api/test-mail/data?sprint=${encodeURIComponent(this.sprint.trim())}`)).json(); if (!data.success) throw new Error(data.message || '加载失败'); this.issues = data.data.issues || []; this.defaults = data.data.defaults || this.defaults; this.sprintOptions = data.data.sprints || this.sprintOptions; if (!Object.keys(this.versions).length) this.initializeContainers(); this.jql = data.data.jql; this.subject = data.data.suggested_subject || this.buildSubject(this.resolveSprintPeriod() || (this.sprint.trim() ? `Sprint${this.sprint.trim()}` : '')); this.generateDraftSections(false); } catch (e) { this.error = e.message; } finally { this.loading = false; } },
handleSprintSelection() { this.updateSubjectFromSprint(); this.loadData(); },
rememberSprint(value) { try { const sprint = String(value || '').trim(); if (sprint) localStorage.setItem(LAST_SPRINT_STORAGE_KEY, sprint); else localStorage.removeItem(LAST_SPRINT_STORAGE_KEY); } catch (e) { console.error(e); } },
restoreSprint() { try { return localStorage.getItem(LAST_SPRINT_STORAGE_KEY) || ''; } catch (e) { console.error(e); return ''; } },
async refreshDatabases() { try { const data = await (await fetch('/api/test-mail/databases', { method: 'POST', headers: {'Content-Type':'application/json','X-CSRF-TOKEN':this.csrf()}, body: JSON.stringify({ selected_groups: this.selectedGroups, versions: this.versions }) })).json(); if (data.success) this.databases = data.data.databases || []; } catch (e) { console.error(e); } },
isGroupSelected(key) { return this.selectedGroups.includes(key); },
toggleGroup(key, checked) { const group = this.containerGroups.find(g => g.key === key); const names = (group?.containers || []).map(c => c.name); if (checked) { if (!this.selectedGroups.includes(key)) this.selectedGroups.push(key); this.selectedContainers = Array.from(new Set([...this.selectedContainers, ...names])); } else { this.selectedGroups = this.selectedGroups.filter(k => k !== key); this.selectedContainers = this.selectedContainers.filter(n => !names.includes(n)); } this.refreshDatabases(); },
handlePaste(event, section) { for (const item of (event.clipboardData?.items || [])) if (item.type.startsWith('image/')) { event.preventDefault(); const file = item.getAsFile(); const reader = new FileReader(); reader.onload = () => { const cid = `${section}-${Date.now()}-${this.images.length}@toolbox.local`; this.images.push({cid,section,name:file.name || `${section}-${this.images.length+1}.png`,dataUrl:reader.result}); const img=document.createElement('img'); img.src=reader.result; img.style.maxWidth='100%'; img.style.display='block'; img.style.margin='8px 0'; this.$refs.environmentPasteBox.appendChild(img); }; reader.readAsDataURL(file); } },
clearEnvironmentScreenshots() { this.images = this.images.filter(i => i.section !== 'environment'); this.$refs.environmentPasteBox.innerHTML = '<p class="text-gray-500">点击这里后直接粘贴「八、环境部署准备清单」截图</p>'; },
addNoteRow() { this.testNoteRows.push({_id:Date.now()+Math.random(),type:'其他依赖项',issue:'',system:'',content:'',owner:''}); }, removeNoteRow(i) { this.testNoteRows.splice(i,1); if (!this.testNoteRows.length) this.addNoteRow(); },
addRiskRow() { this.riskRows.push({_id:Date.now()+Math.random(),problem:'',impact:'',action:'',owner:''}); }, removeRiskRow(i) { this.riskRows.splice(i,1); if (!this.riskRows.length) this.addRiskRow(); },
async generateDraftSections(showErrors = true) { this.draftLoading = true; if (showErrors) this.error = ''; try { const res = await fetch('/api/test-mail/draft-sections', { method:'POST', headers:{'Content-Type':'application/json','X-CSRF-TOKEN':this.csrf()}, body:JSON.stringify({ issues:this.issues, tech_docs:this.techDocs, selected_containers:this.selectedContainers, databases:this.databases }) }); const data = await res.json(); if (!data.success) throw new Error(data.message || '生成草稿失败'); this.testNoteRows = (data.data.test_notes || []).map((row, index) => ({_id:Date.now()+index, type:row.type || '测试注意事项', issue:row.issue || '', system:row.system || '', content:row.content || '', owner:row.owner || ''})); this.riskRows = (data.data.risks || []).map((row, index) => ({_id:Date.now()+100+index, problem:row.problem || '', impact:row.impact || '', action:row.action || '', owner:row.owner || ''})); if (!this.testNoteRows.length) this.addNoteRow(); if (!this.riskRows.length) this.addRiskRow(); this.draftSource = data.data.source || 'rules'; } catch(e) { if (showErrors) this.error = e.message; else console.error(e); } finally { this.draftLoading = false; } },
updateSubjectFromSprint() { const sprint = this.sprint.trim(); this.subject = this.buildSubject(this.resolveSprintPeriod() || (sprint ? `Sprint${sprint}` : '')); },
buildSubject(period) { return `【提测】${period ? period : ''}需求提测(SP、PP、TP)`; },
resolveSprintPeriod() { const option = this.selectedSprintOption(); if (option?.period) return option.period; const candidates = [this.sprint, option?.name, option?.label, ...this.issues.map(i => i.sprint || '')]; for (const candidate of candidates) { const period = normalizeTestMailSprintPeriod(candidate); if (period) return period; } return ''; },
selectedSprintOption() { return this.sprintOptions.find(o => String(o.id || o.name) === String(this.sprint)); },
async openMailDraft() { if (this.draftOpening) return; const to = this.extractEmails(this.to); if (!to.length) { this.error = '请先填写收件人'; return; } this.draftOpening = true; this.error = ''; const text = this.plainText(); try { const res = await fetch('/api/test-mail/open-draft', { method:'POST', headers:{'Content-Type':'application/json','X-CSRF-TOKEN':this.csrf()}, body:JSON.stringify({subject:this.subject,from:this.from,to:this.to,cc:this.cc,html:this.mailHtml,text,images:this.images}) }); const data = await res.json().catch(() => ({})); if (!res.ok || !data.success) throw new Error(data.message || 'Thunderbird 打开失败'); this.error = data.message || '已向 Thunderbird 发送打开完整邮件草稿请求'; } catch (e) { await this.copyDraftBody(text); this.openMailtoFallback(text); } finally { setTimeout(() => { this.draftOpening = false; }, 1500); } },
async copyDraftBody(text) { try { await navigator.clipboard?.writeText(text); this.error = '无法直接写入正文,已复制正文到剪贴板;草稿打开后请粘贴。'; } catch (e) { this.error = '无法直接写入正文;请使用下载 .eml 获取完整邮件。'; } },
openMailtoFallback(text) { const to = this.extractEmails(this.to); const cc = this.extractEmails(this.cc); const body = text.length > 1200 ? '正文已复制到剪贴板,请在此处粘贴。' : text; const params = new URLSearchParams({ subject:this.subject, body }); if (cc.length) params.set('cc', cc.join(',')); window.location.href = `mailto:${to.join(',')}?${params.toString()}`; },
extractEmails(value) { return Array.from(new Set(String(value || '').match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi) || [])); },
async downloadEml() { this.downloading = true; this.error = ''; try { const res = await fetch('/api/test-mail/download', { method:'POST', headers:{'Content-Type':'application/json','X-CSRF-TOKEN':this.csrf()}, body:JSON.stringify({subject:this.subject,from:this.from,to:this.to,cc:this.cc,html:this.mailHtml,text:this.plainText(),images:this.images}) }); if (!res.ok) throw new Error(await res.text()); const blob=await res.blob(); const url=URL.createObjectURL(blob); const a=document.createElement('a'); a.href=url; a.download=`${this.subject.replace(/[\\/:*?"<>|]/g,'_')}.eml`; a.click(); URL.revokeObjectURL(url); } catch(e) { this.error='生成邮件失败: '+e.message; } finally { this.downloading=false; } },
section(t){return `<h1 style="font-size:14px;margin:16px 0 6px 0;font-weight:bold">${this.escape(t)}</h1>`;}, paragraph(t){return `<div style="margin:0 0 8px 0">${t || '无'}</div>`;}, multiline(t){return `<div style="margin:0 0 8px 0;white-space:pre-wrap">${this.escape(t || '无')}</div>`;}, smokeLinksHtml(){const lines=String(this.smokeUrl||'').split(/\r?\n/).map(line=>line.trim()).filter(Boolean); if(!lines.length)return ''; return `<div style="margin:0 0 8px 0">${lines.map(line=>/^https?:\/\//i.test(line)?`<div><a href="${this.escapeAttr(line)}">${this.escape(line)}</a></div>`:`<div>${this.escape(line)}</div>`).join('')}</div>`;}, screenshotHtml(s){return this.images.filter(i=>i.section===s).map(i=>`<div><img src="cid:${this.escapeAttr(i.cid)}" style="max-width:100%;height:auto" alt=""></div>`).join('') || '<div>无</div>';},
issueTableHtml(){return this.simpleTable(this.issueHeaders,this.issues.map(i=>({key:`<a href="${this.escapeAttr(i.url)}">${this.escape(i.key)}</a>`,summary:this.escape(i.summary),reporter:this.escape(i.reporter || ''),status:this.escape(i.status),developer:this.escape(i.developer || ''),assignee:this.escape(i.assignee || ''),sprint:this.escape(i.sprint || ''),estimated_test_at:this.escape(i.estimated_test_at || ''),estimated_release_at:this.escape(i.estimated_release_at || '')})),['key','summary','reporter','status','developer','assignee','sprint','estimated_test_at','estimated_release_at'],true);},
noteTableHtml(){const rows=this.testNoteRows.filter(r=>['issue','system','content','owner'].some(k=>String(r[k]??'').trim())); return this.simpleTable(this.noteHeaders,rows,['type','issue','system','content','owner']);}, riskTableHtml(){const rows=this.riskRows.filter(r=>['problem','impact','action','owner'].some(k=>String(r[k]??'').trim())); return this.simpleTable(this.riskHeaders,rows,['problem','impact','action','owner']);},
simpleTable(headers, rows, keys, trusted=false){if(!rows.length)return '<div>无</div>'; return `<table border="1" cellspacing="0" cellpadding="4" style="border-collapse:collapse;font-size:14px;margin:6px 0 14px 0"><thead><tr>${headers.map(h=>`<th style="background:#f2f2f2">${this.escape(h)}</th>`).join('')}</tr></thead><tbody>${rows.map(r=>`<tr>${keys.map(k=>`<td>${trusted?(r[k]||''):this.escape(String(r[k]??'')).replace(/\n/g,'<br>')}</td>`).join('')}</tr>`).join('')}</tbody></table>`;},
plainText(){return this.mailHtml.replace(/<br\s*\/?>(\s*)/gi,'\n').replace(/<[^>]+>/g,'').replace(/&nbsp;/g,' ').replace(/&amp;/g,'&').replace(/&lt;/g,'<').replace(/&gt;/g,'>');}, escape(v){return String(v??'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}, escapeAttr(v){return this.escape(v).replace(/'/g,'&#39;');}
}
}
</script>
<style scoped>
.btn-primary { border-radius: 0.375rem; background: #2563eb; padding: 0.5rem 1rem; font-size: 0.875rem; font-weight: 500; color: white; }
.btn-primary:hover { background: #1d4ed8; }
.btn-primary:disabled { opacity: .5; }
.btn-secondary { border-radius: 0.375rem; background: #f3f4f6; padding: 0.5rem 1rem; font-size: 0.875rem; font-weight: 500; color: #374151; }
.btn-secondary:hover { background: #e5e7eb; }
.btn-success { border-radius: 0.375rem; background: #16a34a; padding: 0.5rem 1rem; font-size: 0.875rem; font-weight: 500; color: white; }
.btn-success:hover { background: #15803d; }
.btn-success:disabled { opacity: .5; }
.dense-card { border-radius: 0.75rem; border: 1px solid #e5e7eb; background: #fff; padding: 1rem; box-shadow: 0 1px 2px rgba(0,0,0,.04); }
.dense-title { margin-bottom: .75rem; font-size: .875rem; font-weight: 600; color: #111827; }
.compact-field { display: block; font-size: .75rem; font-weight: 500; color: #4b5563; }
.control { margin-top: .25rem; width: 100%; border-radius: .375rem; border: 1px solid #d1d5db; padding: .375rem .5rem; font-size: .875rem; color: #111827; }
.control:focus { outline: none; border-color: #3b82f6; box-shadow: 0 0 0 1px #3b82f6; }
.dense-table { width: 100%; border-collapse: collapse; background: #fff; font-size: .75rem; }
.dense-table th { position: sticky; top: 0; border-right: 1px solid #e5e7eb; border-bottom: 1px solid #e5e7eb; background: #f3f4f6; padding: .375rem .5rem; text-align: left; font-weight: 600; color: #374151; }
.dense-table td { border-right: 1px solid #f3f4f6; border-bottom: 1px solid #f3f4f6; padding: .375rem .5rem; vertical-align: top; color: #1f2937; }
.table-control { width: 100%; border-radius: .25rem; border: 1px solid #e5e7eb; padding: .25rem .375rem; font-size: .75rem; }
.table-control:focus { outline: none; border-color: #3b82f6; box-shadow: 0 0 0 1px #3b82f6; }
</style>