#feature: add test mail generator
This commit is contained in:
@@ -0,0 +1,250 @@
|
||||
<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> ' + this.escape(this.urgentItems).replace(/\n/g, '<br> '))}${this.paragraph('延期需求:<br> ' + this.escape(this.delayedItems).replace(/\n/g, '<br> '))}${this.section('五、容器部署和版本')}${this.simpleTable(['容器','版本号','服务器所在地'], this.selectedContainerRows, ['name','version','location'])}${this.section('六、数据库')}${this.simpleTable(['系统','是否有数据库','分支'], this.databases, ['system','has_database','branch'])}${this.section('七、是否涉及合规')}${this.paragraph(' 不涉及')}${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(/ /g,' ').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}, escape(v){return String(v??'').replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}, escapeAttr(v){return this.escape(v).replace(/'/g,''');}
|
||||
}
|
||||
}
|
||||
</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>
|
||||
Reference in New Issue
Block a user