@@ -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>