作者 邓超

新增 邮件导出页面(cmd/export_mail 的 web 化)

- controller/MailExport.php:密码访问(mailexport)、公司项目搜索、count/start/download
- service/MailExport.php:从 cmd/export_mail.php 抽出,支持 postid/导出条件/数量上限,附带导出清单与 zip 打包
- views/mail_export.html:单页界面(选公司项目 → 选条件 → 导出下载)
- route.php 注册路由;.gitignore 忽略 storage/export

顺带修掉老脚本的问题:中文标题被清洗成空文件名、EML 出现两个 Date 头
... ... @@ -13,6 +13,7 @@ log
public/.user.ini
public/.well-known
public/storage
storage/export
cmd/*.log
*.log
.htaccess
... ...
<?php
namespace Controller;
use Service\MailExport as MailExportService;
/**
* 邮件导出页面
*
* 需要一个固定密码才能访问,密码:mailexport
*
* 路由:
* mail-export 页面(GET 展示 / POST 校验密码)
* mail-export/logout 退出
* mail-export/projects 公司项目搜索(按名称或 postid)
* mail-export/count 查询命中的邮件数量
* mail-export/start 执行导出(同步,导出完成后返回下载地址)
* mail-export/download 下载导出的 zip
*
* @author:dc
* @time 2026/10/28 11:40
* Class MailExport
* @package Controller
*/
class MailExport extends Base {
/**
* 访问密码(固定)
*/
private const PASSWORD = 'mailexport';
/**
* 登录态 cookie 名称
*/
private const COOKIE_NAME = 'mail_export_token';
/**
* 登录态签名盐值,cookie 里不存明文密码
*/
private const COOKIE_SALT = 'mail_export_2026';
/**
* 登录态有效期,7 天
*/
private const COOKIE_TTL = 604800;
/**
* 页面
* GET :未登录返回密码框,已登录返回页面
* POST :校验密码,成功后写入登录态并返回页面
*
* @return string
* @author:dc
* @time 2026/10/28 11:40
*/
public function index(){
if(app()->requestHas('password')){
$password = (string)app()->request('password');
if(!hash_equals(self::PASSWORD, $password)){
logs('mail-export 密码错误 IP:'.($_SERVER['REMOTE_ADDR'] ?? ''));
return $this->loginHtml('密码不正确');
}
$this->setAuthCookie();
return $this->pageHtml();
}
if($this->isAuthed()){
return $this->pageHtml();
}
return $this->loginHtml('');
}
/**
* 退出
* @return string
* @author:dc
* @time 2026/10/28 11:40
*/
public function logout(){
setcookie(self::COOKIE_NAME, '', [
'expires' => time() - 3600,
'path' => '/',
'httponly' => true,
'samesite' => 'Lax',
]);
return $this->loginHtml('已退出');
}
/**
* 搜索公司项目
* 支持按公司名称模糊、按 postid 精确
* @return array
* @throws \Lib\Err
* @author:dc
* @time 2026/10/28 11:40
*/
public function projects(){
$this->needAuth();
$keyword = trim((string)app()->request('keyword', ''));
if($keyword === ''){
return ['item' => []];
}
$db = fob_mysql();
// 名称模糊 + postid 精确
$where = "`company` like '%".addslashes($keyword)."%'";
if(ctype_digit($keyword)){
$where = "(`postid` = ".intval($keyword)." or ".$where.")";
}
$rows = $db->throw()->all(
"select `postid`,`company`,`plan` from `projects` where {$where} order by `postid` desc limit 20"
);
if(!$rows){
return ['item' => []];
}
// 已绑定邮箱数量,方便确认选的是哪个项目
$bindCount = [];
$postids = array_map('intval', array_column($rows, 'postid'));
try {
$counts = $db->throw()->all(
"select `post_id`,count(*) as `num` from `e_mail_binds` where `post_id` in (".implode(',', $postids).") group by `post_id`"
);
$bindCount = array_column($counts, 'num', 'post_id');
}catch (\Throwable $e){
logs('mail-export 统计绑定邮箱失败:'.$e->getMessage());
}
$item = [];
foreach($rows as $row){
$item[] = [
'postid' => intval($row['postid']),
'company' => (string)$row['company'],
'plan' => (string)$row['plan'],
'bind' => intval($bindCount[$row['postid']] ?? 0),
];
}
return ['item' => $item];
}
/**
* 查询命中的邮件数量
* @return array
* @throws \Lib\Err
* @author:dc
* @time 2026/10/28 11:40
*/
public function count(){
$this->needAuth();
$postid = intval(app()->request('postid', 0));
$scope = $this->scope();
if(!$postid){
app()->e('请先选择公司项目');
}
return [
'count' => MailExportService::count($postid, $scope),
'scope' => $scope,
];
}
/**
* 执行导出
* @return array
* @throws \Lib\Err
* @author:dc
* @time 2026/10/28 11:40
*/
public function start(){
$this->needAuth();
$postid = intval(app()->request('postid', 0));
$scope = $this->scope();
$limit = intval(app()->request('limit', MailExportService::DEFAULT_LIMIT));
if(!$postid){
app()->e('请先选择公司项目');
}
$project = fob_mysql()->throw()->first(
"select `postid`,`company` from `projects` where `postid` = {$postid} limit 1"
);
if(!$project){
app()->e('公司项目不存在,请重新搜索');
}
// 导出是同步执行的,数量大时需要更长时间
set_time_limit(0);
ignore_user_abort(true);
$ret = (new MailExportService())->export($postid, $scope, $limit);
$ret['company'] = (string)$project['company'];
logs('邮件导出 postid:'.$postid.' 范围:'.$scope.' 数量:'.$ret['total']
.' 成功:'.$ret['success'].' 失败:'.$ret['fail'].' 耗时:'.$ret['cost'].'s IP:'.($_SERVER['REMOTE_ADDR'] ?? ''));
return $ret;
}
/**
* 下载导出的 zip
* @return string
* @throws \Lib\Err
* @author:dc
* @time 2026/10/28 11:40
*/
public function download(){
$this->needAuth();
$f = (string)app()->request('f', '');
// 只允许 目录/文件名.zip 的形式
if(!preg_match('#^(\d{1,12}_\d{8}_\d{6}_[0-9a-f]{6})/([0-9a-zA-Z_\-\.]+\.zip)$#', $f, $match)){
app()->e('文件地址不合法');
}
$path = MailExportService::baseDir().'/'.$match[1].'/'.$match[2];
if(!is_file($path)){
app()->e('文件不存在,可能已被清理,请重新导出');
}
app()->header('Content-Type', 'application/zip');
app()->header('Content-Disposition', 'attachment; filename="'.$match[2].'"');
app()->header('Content-Length', filesize($path));
app()->header('Cache-Control', 'no-store');
return file_get_contents($path);
}
/**
* 导出条件
* @return string
* @throws \Lib\Err
* @author:dc
* @time 2026/10/28 11:40
*/
private function scope():string {
$scope = (string)app()->request('scope', '');
if(!isset(MailExportService::scopeMap()[$scope])){
app()->e('导出条件不正确');
}
return $scope;
}
/**
* 未登录直接报错,前端收到后刷新页面
* @throws \Lib\Err
* @author:dc
* @time 2026/10/28 11:40
*/
private function needAuth(){
if(!$this->isAuthed()){
app()->e('登录已失效,请刷新页面重新输入密码', 401);
}
}
/**
* 是否已登录
* @return bool
* @author:dc
* @time 2026/10/28 11:40
*/
private function isAuthed():bool {
return hash_equals($this->token(), (string)($_COOKIE[self::COOKIE_NAME] ?? ''));
}
/**
* 登录态令牌
* @return string
* @author:dc
* @time 2026/10/28 11:40
*/
private function token():string {
return hash_hmac('sha256', self::PASSWORD, self::COOKIE_SALT);
}
/**
* 写入登录态
* @author:dc
* @time 2026/10/28 11:40
*/
private function setAuthCookie(){
setcookie(self::COOKIE_NAME, $this->token(), [
'expires' => time() + self::COOKIE_TTL,
'path' => '/',
'httponly' => true,
'samesite' => 'Lax',
'secure' => !empty($_SERVER['HTTPS']) && strtolower($_SERVER['HTTPS']) !== 'off',
]);
}
/**
* 页面 html
* @return string
* @author:dc
* @time 2026/10/28 11:40
*/
private function pageHtml():string {
$file = ROOT_PATH.'/views/mail_export.html';
if(!is_file($file)){
return $this->loginHtml('页面模版不存在:views/mail_export.html');
}
return str_replace(
['<!--{#host#}-->', '<!--{#time#}-->'],
[APP_HOST, app()->nowDateTime()],
file_get_contents($file)
);
}
/**
* 密码输入页面
* @param string $error
* @return string
* @author:dc
* @time 2026/10/28 11:40
*/
private function loginHtml(string $error = ''):string {
$errorHtml = $error ? '<p class="error">'.$error.'</p>' : '';
return '<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<title>邮件导出 · 访问验证</title>
<style>
*{box-sizing:border-box;}
body{margin:0;height:100vh;display:flex;align-items:center;justify-content:center;
background:#eef3fa;font:14px/1.6 -apple-system,"Segoe UI","Microsoft YaHei",sans-serif;color:#243447;}
.box{width:340px;padding:32px 28px;background:#fff;border-radius:12px;
box-shadow:0 8px 30px rgba(31,63,107,.12);}
h1{margin:0 0 6px;font-size:18px;font-weight:600;}
.sub{margin:0 0 22px;color:#8a97a8;font-size:12px;}
input{width:100%;height:40px;padding:0 12px;border:1px solid #d5dde8;border-radius:8px;
font-size:14px;outline:none;transition:.15s;}
input:focus{border-color:#4a7fd6;box-shadow:0 0 0 3px rgba(74,127,214,.12);}
button{width:100%;height:40px;margin-top:14px;border:0;border-radius:8px;background:#2f6bd8;
color:#fff;font-size:14px;cursor:pointer;transition:.15s;}
button:hover{background:#255cc0;}
.error{margin:12px 0 0;color:#d0342c;font-size:12px;}
</style>
</head>
<body>
<form class="box" method="post" action="/mail-export" autocomplete="off">
<h1>邮件导出</h1>
<p class="sub">该页面需要密码才可访问</p>
<input type="password" name="password" placeholder="请输入访问密码" autofocus>
<button type="submit">进入</button>
'.$errorHtml.'
</form>
</body>
</html>';
}
}
... ...
... ... @@ -108,6 +108,14 @@ return [
,'api-doc' => [\Controller\ApiDoc::class,'index']
,'api-doc/logout' => [\Controller\ApiDoc::class,'logout']
// 邮件导出(需要密码访问)
,'mail-export' => [\Controller\MailExport::class,'index']
,'mail-export/logout' => [\Controller\MailExport::class,'logout']
,'mail-export/projects' => [\Controller\MailExport::class,'projects']
,'mail-export/count' => [\Controller\MailExport::class,'count']
,'mail-export/start' => [\Controller\MailExport::class,'start']
,'mail-export/download' => [\Controller\MailExport::class,'download']
];
... ...
<?php
namespace Service;
use Lib\Err;
use PHPMailer\PHPMailer\PHPMailer;
/**
* 邮件导出为 eml 文件
*
* 逻辑来源 cmd/export_mail.php,这里抽成服务类,支持传入
* postid(公司项目)、导出范围、数量上限,供页面调用。
*
* @author:dc
* @time 2026/10/28 11:40
* Class MailExport
* @package Service
*/
class MailExport {
/**
* 星标邮件(所有文件夹)
*/
public const SCOPE_STARRED = 'starred';
/**
* 收件箱全部
*/
public const SCOPE_INBOX = 'inbox';
/**
* 默认导出数量
*/
public const DEFAULT_LIMIT = 300;
/**
* 单次最大导出数量
*/
public const MAX_LIMIT = 2000;
/**
* 清单文件名
*/
private const MANIFEST_NAME = '导出清单.csv';
/**
* @var int 项目id
*/
private int $postid = 0;
/**
* @var string 导出范围
*/
private string $scope = '';
/**
* @var int 数量上限
*/
private int $limit = 0;
/**
* @var string 本次导出目录
*/
private string $dir = '';
/**
* @var array es _id 去重
*/
private array $ids = [];
/**
* @var array 已生成的 eml 文件
*/
private array $files = [];
/**
* @var array 已使用过的文件名,避免同一批里重名
*/
private array $usedNames = [];
/**
* @var array 清单数据
*/
private array $manifest = [];
/**
* @var array 失败记录
*/
private array $errors = [];
/**
* @var int 成功数量
*/
private int $success = 0;
/**
* @var int 失败数量
*/
private int $fail = 0;
/**
* 导出范围
* @return string[]
* @author:dc
* @time 2026/10/28 11:40
*/
public static function scopeMap():array {
return [
self::SCOPE_STARRED => '星标邮件(所有文件夹)',
self::SCOPE_INBOX => '收件箱全部',
];
}
/**
* 导出文件根目录
* 说明:不放在 public 下面,只能通过页面鉴权后下载
* @return string
* @author:dc
* @time 2026/10/28 11:40
*/
public static function baseDir():string {
return ROOT_PATH.'/storage/export';
}
/**
* 查询命中的邮件数量
* 返回 -1 表示 es 查询异常,与「0 封」区分开
* @param int $postid
* @param string $scope
* @return int
* @author:dc
* @time 2026/10/28 11:40
*/
public static function count(int $postid,string $scope):int {
try {
$count = es('email_lists_branch_'.$postid)->count(['query' => self::query($scope)], true);
return max(0, intval($count));
}catch (\Throwable $e){
logs('邮件导出统计失败 postid:'.$postid.' '.$e->getMessage());
return -1;
}
}
/**
* es 查询条件
* 与 cmd/export_mail.php 保持一致:只取 ai 邮箱(source=2)、未删除
* @param string $scope
* @return array
* @author:dc
* @time 2026/10/28 11:40
*/
public static function query(string $scope):array {
$must = [
['term' => ['source' => 2]],
['term' => ['deleted' => 0]],
];
if($scope === self::SCOPE_STARRED){
// 星标:不限文件夹
$must[] = ['term' => ['flagged' => 1]];
$must[] = ['terms' => ['folder_as_int' => [1, 2, 3, 4, 5]]];
}else{
// 收件箱全部(含星标、含未读已读)
$must[] = ['term' => ['folder_as_int' => 1]];
}
return ['constant_score' => ['filter' => ['bool' => ['must' => $must]]]];
}
/**
* 文件名净化,保留中文
* @param string $name
* @return string
* @author:dc
* @time 2026/10/28 11:40
*/
public static function sanitize(string $name):string {
$name = preg_replace('/[^\p{L}\p{N}_\-\.\(\)]+/u', '_', $name);
$name = preg_replace('/_+/u', '_', (string)$name);
return trim((string)$name, '_ .');
}
/**
* 执行导出
* @param int $postid 项目id
* @param string $scope 导出范围
* @param int $limit 数量上限
* @return array
* @throws Err
* @author:dc
* @time 2026/10/28 11:40
*/
public function export(int $postid,string $scope,int $limit):array {
if(!$postid){
throw new Err('缺少公司项目');
}
if(!isset(self::scopeMap()[$scope])){
throw new Err('导出条件不支持');
}
$this->postid = $postid;
$this->scope = $scope;
$this->limit = max(1, min($limit ?: self::DEFAULT_LIMIT, self::MAX_LIMIT));
$startTime = microtime(true);
// 导出目录,带随机后缀,避免重复
$this->dir = self::baseDir().'/'.$postid.'_'.date('Ymd_His').'_'.substr(md5(uniqid('', true)), 0, 6);
if(!is_dir($this->dir) && !@mkdir($this->dir, 0775, true) && !is_dir($this->dir)){
throw new Err('导出目录创建失败,请检查 storage 目录权限');
}
$hits = $this->search();
if(!$hits){
$this->removeDir($this->dir);
// 没有数据时再确认一次,区分「es 挂了」和「真的没有邮件」
if(self::count($this->postid, $this->scope) < 0){
throw new Err('es 查询失败,请稍后再试(已记录日志)');
}
throw new Err('没有命中任何邮件,换个条件再试');
}
foreach($hits as $hit){
$this->saveOne($hit);
}
if(!$this->files){
$this->removeDir($this->dir);
$detail = $this->errors ? $this->errors[0]['message'] : '未知原因';
throw new Err('全部邮件导出失败:'.$detail);
}
// 清单
$this->writeManifest();
// 打包
$zipName = $this->postid.'_'.date('Ymd_His').'.zip';
$zipFile = $this->dir.'/'.$zipName;
$this->zip($zipFile);
return [
'postid' => $this->postid,
'scope' => $this->scope,
'scope_label'=> self::scopeMap()[$this->scope],
'limit' => $this->limit,
'total' => count($hits),
'success' => $this->success,
'fail' => $this->fail,
'errors' => array_slice($this->errors, 0, 20),
'dir' => str_replace(ROOT_PATH, '', $this->dir),
'zip_name' => $zipName,
'zip_size' => is_file($zipFile) ? filesize($zipFile) : 0,
'download' => '/mail-export/download?f='.rawurlencode(basename($this->dir).'/'.$zipName),
'cost' => round(microtime(true) - $startTime, 1),
];
}
/**
* 查询 es
* @return array
* @author:dc
* @time 2026/10/28 11:40
*/
private function search():array {
$res = es('email_lists_branch_'.$this->postid)->search(
['query' => self::query($this->scope)],
0,
$this->limit,
['udate' => 'desc']
);
return $res['hits']['hits'] ?? [];
}
/**
* 导出一封
* @param array $hit
* @author:dc
* @time 2026/10/28 11:40
*/
private function saveOne(array $hit):void {
$esId = (string)($hit['_id'] ?? '');
if($esId === '' || isset($this->ids[$esId])){
return;
}
$this->ids[$esId] = 1;
$src = $hit['_source'] ?? [];
$file = $this->fileName($src);
$subject = (string)($src['subject'] ?? '');
try {
$this->saveEml($src, $file);
if(!is_file($file) || filesize($file) <= 0){
throw new \Exception('eml 文件为空');
}
$this->files[] = $file;
$this->success++;
$this->manifest[] = [
'subject' => $subject,
'from' => $this->addressText($src['from'] ?? []),
'to' => $this->toText($src),
'date' => empty($src['udate']) ? '' : date('Y-m-d H:i:s', $src['udate']),
'uuid' => (string)($src['uuid'] ?? ''),
'file' => basename($file),
'status' => '成功',
];
}catch (\Throwable $e){
// 半成品删掉
if(is_file($file)){
@unlink($file);
}
$this->fail++;
$this->errors[] = [
'subject' => $subject,
'uuid' => (string)($src['uuid'] ?? ''),
'message' => $e->getMessage(),
];
logs('邮件导出失败 '.(string)($src['uuid'] ?? '').' : '.$e->getMessage());
}
}
/**
* 生成 eml(逻辑同 cmd/export_mail.php 的 save_eml)
* @param array $src es 数据
* @param string $file
* @throws \Exception
* @author:dc
* @time 2026/10/28 11:40
*/
private function saveEml(array $src,string $file):void {
$mail = new PHPMailer(true);
$from = $src['from'] ?? [];
$fromEmail = is_array($from) ? ($from['email'] ?? '') : (string)$from;
$fromName = is_array($from) ? ($from['name'] ?? '') : '';
if(!$fromEmail){
throw new \Exception('发件人为空');
}
$mail->setFrom($fromEmail, $fromName);
// 收件人
$toName = @json_decode((string)($src['to_name'] ?? ''), true);
if(is_array($toName) && $toName){
foreach($toName as $item){
if(empty($item['email'])) continue;
try {
$mail->addAddress($item['email'], $item['name'] ?? '');
}catch (\Throwable $e){}
}
}
if(!$mail->getToAddresses() && !empty($src['to'])){
foreach(explode(',', (string)$src['to']) as $item){
$item = trim($item);
if($item === '') continue;
try {
$mail->addAddress($item);
}catch (\Throwable $e){}
}
}
$mail->Subject = (string)($src['subject'] ?? '');
$uuid = (string)($src['uuid'] ?? '');
// uuid 实际是 lists 表的自增id
$body = ctype_digit($uuid) ? getMailBody((int)$uuid) : [];
$html = getBodyHtml($body);
// 没有 body,尝试同步一次
if(!$html && ctype_digit($uuid) && !empty($src['email_id']) && !empty($src['uid'])){
try {
$folder = db()->value(\Model\folderSql::first($src['folder_id'] ?? 0, 'origin_folder'));
(new SyncMail($src['email_id']))->mail($folder, [(string)$src['uid']], true);
$body = getMailBody((int)$uuid);
$html = getBodyHtml($body);
}catch (\Throwable $e){
logs('邮件导出补拉正文失败 '.$uuid.' : '.$e->getMessage());
}
}
// 正文为空也要能生成 eml(否则 PHPMailer 会报 Message body empty)
$mail->Body = $html ?: '<div></div>';
$mail->Encoding = 'quoted-printable';
$mail->CharSet = 'utf-8';
// 用 MessageDate 保留原始收发时间(不要用 addCustomHeader,会出两个 Date 头)
if(!empty($src['udate'])){
$mail->MessageDate = date('D, d M Y H:i:s O', intval($src['udate']));
}
if(!empty($src['uuid'])){
$mail->addCustomHeader('Uuid', (string)$src['uuid']);
}
// 附件
foreach((array)$body as $item){
if(!empty($item['path']) && is_file($item['path'])){
try {
$mail->addAttachment($item['path'], $item['filename'] ?? '');
}catch (\Throwable $e){}
}
}
// 抄送
foreach($this->addressList($src['cc'] ?? '') as $cc){
try {
$mail->addCC($cc['email'], $cc['name']);
}catch (\Throwable $e){}
}
// 密送
foreach($this->addressList($src['bcc'] ?? '') as $bcc){
try {
$mail->addBCC($bcc['email'], $bcc['name']);
}catch (\Throwable $e){}
}
$mail->isHTML(true);
$mail->preSend();
$content = $mail->getSentMIMEMessage();
if(!$content){
throw new \Exception('eml 内容生成失败');
}
if(file_put_contents($file, $content) === false){
throw new \Exception('eml 写入失败');
}
}
/**
* cc / bcc 解析
* @param mixed $value
* @return array
* @author:dc
* @time 2026/10/28 11:40
*/
private function addressList(mixed $value):array {
if(empty($value)){
return [];
}
$list = is_array($value) ? $value : (@json_decode((string)$value, true) ?: []);
$ret = [];
foreach($list as $item){
if(!is_array($item)){
$item = ['email' => (string)$item];
}
if(empty($item['email'])) continue;
$ret[] = ['email' => $item['email'], 'name' => $item['name'] ?? ''];
}
return $ret;
}
/**
* 发件人文本
* @param mixed $from
* @return string
* @author:dc
* @time 2026/10/28 11:40
*/
private function addressText(mixed $from):string {
if(is_array($from)){
$email = $from['email'] ?? '';
$name = $from['name'] ?? '';
return $name ? "{$name} <{$email}>" : (string)$email;
}
return (string)$from;
}
/**
* 收件人文本
* @param array $src
* @return string
* @author:dc
* @time 2026/10/28 11:40
*/
private function toText(array $src):string {
$toName = @json_decode((string)($src['to_name'] ?? ''), true);
if(is_array($toName) && $toName){
$arr = [];
foreach($toName as $item){
if(empty($item['email'])) continue;
$arr[] = empty($item['name']) ? $item['email'] : "{$item['name']} <{$item['email']}>";
}
if($arr){
return implode('; ', $arr);
}
}
return (string)($src['to'] ?? '');
}
/**
* 生成不重复的文件名
* @param array $src
* @return string
* @author:dc
* @time 2026/10/28 11:40
*/
private function fileName(array $src):string {
$name = self::sanitize((string)($src['subject'] ?? ''));
if($name === ''){
$name = '无主题';
}
$name = mb_substr($name, 0, 80);
$date = empty($src['udate']) ? '' : date('Ymd_His', $src['udate']);
$base = trim(($date ? $date.'_' : '').$name, '_');
$fileName = $base.'.eml';
if(isset($this->usedNames[$fileName]) || is_file($this->dir.'/'.$fileName)){
$i = 1;
while(isset($this->usedNames[$base.'('.$i.').eml']) || is_file($this->dir.'/'.$base.'('.$i.').eml')){
$i++;
}
$fileName = $base.'('.$i.').eml';
}
$this->usedNames[$fileName] = 1;
return $this->dir.'/'.$fileName;
}
/**
* 写导出清单
* @author:dc
* @time 2026/10/28 11:40
*/
private function writeManifest():void {
$file = $this->dir.'/'.self::MANIFEST_NAME;
$fp = @fopen($file, 'wb');
if(!$fp){
return;
}
// excel 打开不乱码
fwrite($fp, "\xEF\xBB\xBF");
fputcsv($fp, ['序号', '主题', '发件人', '收件人', '日期', 'UUID', '文件名', '状态']);
$i = 0;
foreach($this->manifest as $item){
fputcsv($fp, [
++$i,
$item['subject'],
$item['from'],
$item['to'],
$item['date'],
$item['uuid'],
$item['file'],
$item['status'],
]);
}
// 失败的也记一下
foreach($this->errors as $item){
fputcsv($fp, [++$i, $item['subject'], '', '', '', $item['uuid'], '', '失败:'.$item['message']]);
}
fclose($fp);
$this->files[] = $file;
}
/**
* 打包
* @param string $zipFile
* @throws Err
* @author:dc
* @time 2026/10/28 11:40
*/
private function zip(string $zipFile):void {
if(class_exists('\ZipArchive')){
$zip = new \ZipArchive();
if($zip->open($zipFile, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) === true){
foreach($this->files as $file){
$zip->addFile($file, basename($file));
}
if($zip->close() && is_file($zipFile) && filesize($zipFile) > 0){
return;
}
}
}
// 没有 zip 扩展时用不压缩的 zip
$this->storeZip($zipFile);
if(!is_file($zipFile) || filesize($zipFile) <= 0){
throw new Err('打包失败,请检查 php-zip 扩展或目录权限');
}
}
/**
* 不压缩的 zip(无 php-zip 扩展时的兜底)
* @param string $zipFile
* @throws Err
* @author:dc
* @time 2026/10/28 11:40
*/
private function storeZip(string $zipFile):void {
$fp = @fopen($zipFile, 'wb');
if(!$fp){
throw new Err('zip 文件创建失败');
}
$central = '';
$offset = 0;
$count = 0;
foreach($this->files as $file){
$data = @file_get_contents($file);
if($data === false){
continue;
}
$name = basename($file);
$crc = crc32($data);
$size = strlen($data);
$nameLen = strlen($name);
$mtime = @filemtime($file) ?: time();
$dosTime = (int)((date('H', $mtime) << 11) | (date('i', $mtime) << 5) | (date('s', $mtime) / 2));
$dosDate = (int)(((date('Y', $mtime) - 1980) << 9) | (date('n', $mtime) << 5) | date('j', $mtime));
// 本地文件头,0x0800 表示文件名是 utf-8
$local = pack('VvvvvvVVVvv', 0x04034b50, 20, 0x0800, 0, $dosTime, $dosDate, $crc, $size, $size, $nameLen, 0).$name;
fwrite($fp, $local);
fwrite($fp, $data);
// 中央目录
$central .= pack('VvvvvvvVVVvvvvvVV', 0x02014b50, 20, 20, 0x0800, 0, $dosTime, $dosDate, $crc, $size, $size, $nameLen, 0, 0, 0, 0, 0, $offset).$name;
$offset += strlen($local) + $size;
$count++;
}
fwrite($fp, $central);
fwrite($fp, pack('VvvvvVVv', 0x06054b50, 0, 0, $count, $count, strlen($central), $offset, 0));
fclose($fp);
}
/**
* 删除本次导出目录
* @param string $dir
* @author:dc
* @time 2026/10/28 11:40
*/
private function removeDir(string $dir):void {
if(!$dir || !is_dir($dir)){
return;
}
foreach((array)@scandir($dir) as $item){
if($item === '.' || $item === '..' || $item === false){
continue;
}
$path = $dir.'/'.$item;
if(is_file($path)){
@unlink($path);
}
}
@rmdir($dir);
}
}
... ...
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<title>邮件导出</title>
<style>
*{box-sizing:border-box;margin:0;padding:0;}
body{background:#f6f8fb;color:#22303f;font:14px/1.7 -apple-system,"Segoe UI","Microsoft YaHei",sans-serif;}
.hide{display:none !important;}
/* 顶栏 */
.top{position:sticky;top:0;z-index:20;display:flex;align-items:center;gap:16px;
height:56px;padding:0 22px;background:#fff;border-bottom:1px solid #e4eaf2;}
.top .logo{display:flex;align-items:center;gap:10px;font-size:15px;font-weight:600;white-space:nowrap;}
.top .logo i{display:inline-block;width:8px;height:18px;border-radius:3px;background:#2f6bd8;}
.top .meta{color:#93a1b3;font-size:12px;white-space:nowrap;}
.top .meta b{color:#5b6b7e;font-weight:500;}
.top .grow{flex:1;}
.top .quit{color:#8a97a8;font-size:12px;text-decoration:none;border:1px solid #e4eaf2;
border-radius:6px;padding:4px 10px;transition:.15s;}
.top .quit:hover{color:#d0342c;border-color:#f0c9c6;background:#fdf5f4;}
main{max-width:940px;margin:0 auto;padding:24px 22px 60px;}
/* 卡片 */
.card{background:#fff;border:1px solid #e4eaf2;border-radius:12px;padding:20px 22px;margin-bottom:16px;}
.hd{display:flex;align-items:center;gap:10px;margin-bottom:16px;flex-wrap:wrap;}
.hd .num{width:20px;height:20px;border-radius:50%;background:#eaf1fd;color:#2f6bd8;
font-size:12px;font-weight:600;display:inline-flex;align-items:center;justify-content:center;}
.hd h2{font-size:15px;font-weight:600;}
.hd .tip{color:#93a1b3;font-size:12px;}
/* 公司项目搜索 */
.search{position:relative;}
.search input{width:100%;height:42px;padding:0 14px;border:1px solid #dde5ef;border-radius:9px;
font-size:14px;outline:none;transition:.15s;background:#fcfdff;color:#22303f;}
.search input:focus{border-color:#7ba4e8;box-shadow:0 0 0 3px rgba(47,107,216,.1);background:#fff;}
.dropdown{position:absolute;left:0;right:0;top:46px;z-index:30;background:#fff;border:1px solid #e4eaf2;
border-radius:10px;box-shadow:0 12px 30px rgba(31,63,107,.12);max-height:320px;overflow:auto;}
.dd-item{display:flex;align-items:center;gap:12px;padding:10px 14px;cursor:pointer;border-bottom:1px solid #f2f6fb;}
.dd-item:last-child{border-bottom:0;}
.dd-item:hover,.dd-item.on{background:#f4f8ff;}
.dd-main{flex:1;min-width:0;}
.dd-company{font-size:13.5px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.dd-sub{color:#93a1b3;font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.dd-sub span+span:before{content:"·";margin:0 6px;color:#c9d4e2;}
.dd-bind{color:#8a97a8;font-size:12px;white-space:nowrap;}
.dd-bind b{color:#2f6bd8;font-weight:600;}
.dd-empty{padding:14px;color:#93a1b3;font-size:13px;text-align:center;}
.picked{display:flex;align-items:center;gap:14px;padding:12px 14px;margin-top:12px;
background:#f4f8ff;border:1px solid #dbe7fb;border-radius:10px;}
.picked .picked-main{flex:1;min-width:0;}
.picked .company{font-size:14px;font-weight:600;color:#1b3a66;}
.picked .sub{color:#6b7c90;font-size:12px;margin-top:2px;}
.picked .sub span+span:before{content:"·";margin:0 6px;color:#c9d4e2;}
.picked .link{border:0;background:none;color:#2f6bd8;font-size:12.5px;cursor:pointer;padding:4px 2px;}
.picked .link:hover{text-decoration:underline;}
/* 导出条件 */
.scopes{display:flex;gap:12px;flex-wrap:wrap;}
.scope{flex:1;min-width:240px;display:flex;gap:10px;align-items:flex-start;padding:14px 16px;
border:1px solid #e2e9f3;border-radius:10px;cursor:pointer;transition:.15s;background:#fcfdff;}
.scope:hover{border-color:#c8d9f3;background:#fafcff;}
.scope input{margin-top:4px;accent-color:#2f6bd8;}
.scope b{display:block;font-size:13.5px;font-weight:600;}
.scope span{display:block;color:#8a97a8;font-size:12px;margin-top:2px;}
.scope.on{border-color:#7ba4e8;background:#f4f8ff;box-shadow:0 0 0 3px rgba(47,107,216,.08);}
.row{display:flex;align-items:center;gap:18px;margin-top:16px;flex-wrap:wrap;}
.field{display:flex;align-items:center;gap:8px;font-size:13px;color:#5b6b7e;}
.field input{width:96px;height:34px;padding:0 10px;border:1px solid #dde5ef;border-radius:8px;
font-size:13px;outline:none;color:#22303f;}
.field input:focus{border-color:#7ba4e8;box-shadow:0 0 0 3px rgba(47,107,216,.1);}
.field em{font-style:normal;color:#93a1b3;}
.hit{font-size:13px;color:#5b6b7e;}
.hit b{color:#2f6bd8;font-weight:600;}
.hit .zero{color:#c07a00;}
/* 操作 */
.action{display:flex;align-items:center;gap:14px;margin:4px 0 20px;flex-wrap:wrap;}
.primary{height:42px;padding:0 30px;border:0;border-radius:9px;background:#2f6bd8;color:#fff;
font-size:14px;font-weight:500;cursor:pointer;transition:.15s;}
.primary:hover{background:#255cc0;}
.primary:disabled{background:#c3d2e6;cursor:not-allowed;}
.hint{color:#93a1b3;font-size:12px;}
/* 结果 */
.result .ok{display:flex;align-items:center;gap:10px;font-size:15px;font-weight:600;color:#1a9c62;}
.result .ok i{width:20px;height:20px;border-radius:50%;background:#e6f6ee;color:#1a9c62;
display:inline-flex;align-items:center;justify-content:center;font-size:12px;font-style:normal;}
.stats{display:flex;gap:26px;margin:16px 0 4px;flex-wrap:wrap;}
.stat{font-size:12px;color:#8a97a8;}
.stat b{display:block;font-size:19px;color:#22303f;font-weight:600;line-height:1.4;}
.stat b.warn{color:#d0342c;}
.dl{display:inline-flex;align-items:center;gap:8px;height:42px;padding:0 24px;margin-top:16px;
border-radius:9px;background:#2f6bd8;color:#fff;font-size:14px;font-weight:500;text-decoration:none;
transition:.15s;}
.dl:hover{background:#255cc0;}
.path{margin-top:12px;color:#93a1b3;font-size:12px;word-break:break-all;}
.path code{color:#5b6b7e;background:#f4f7fb;border-radius:4px;padding:2px 6px;}
.errbox{margin-top:16px;border-top:1px dashed #e4eaf2;padding-top:14px;}
.errbox h3{font-size:13px;font-weight:600;color:#d0342c;margin-bottom:8px;}
.errlist{max-height:220px;overflow:auto;border:1px solid #f0e2e1;border-radius:8px;background:#fffbfb;}
table{width:100%;border-collapse:collapse;font-size:12.5px;}
th,td{text-align:left;padding:7px 10px;border-bottom:1px solid #f4ebea;vertical-align:top;}
th{color:#a08a89;font-weight:500;background:#fdf6f5;position:sticky;top:0;}
td.sub{color:#5b6b7e;}
/* 遮罩 */
.mask{position:fixed;inset:0;z-index:50;background:rgba(229,237,247,.72);backdrop-filter:blur(2px);
display:flex;align-items:center;justify-content:center;}
.mask-box{background:#fff;border:1px solid #e4eaf2;border-radius:14px;padding:26px 34px;text-align:center;
box-shadow:0 18px 50px rgba(31,63,107,.16);min-width:260px;}
.spinner{width:30px;height:30px;margin:0 auto 14px;border:3px solid #e3ecfa;border-top-color:#2f6bd8;
border-radius:50%;animation:spin .8s linear infinite;}
@keyframes spin{to{transform:rotate(360deg);}}
.mask-box p{font-size:13.5px;color:#22303f;}
.mask-box .mask-sub{margin-top:6px;color:#93a1b3;font-size:12px;}
@media (max-width:640px){
.top .meta{display:none;}
main{padding:16px 12px 40px;}
.card{padding:16px;}
}
</style>
</head>
<body>
<header class="top">
<div class="logo"><i></i>邮件导出</div>
<div class="meta">导出为 eml · 打包 zip 下载</div>
<div class="grow"></div>
<div class="meta"><b><!--{#host#}--></b> · <!--{#time#}--></div>
<a class="quit" href="/mail-export/logout">退出</a>
</header>
<main>
<section class="card">
<div class="hd">
<span class="num">1</span>
<h2>选择公司项目</h2>
<span class="tip">输入公司名称模糊搜索,或直接输入 postid</span>
</div>
<div class="search" id="searchWrap">
<input id="keyword" type="text" placeholder="公司项目名称 / postid" autocomplete="off" spellcheck="false">
<div class="dropdown hide" id="dropdown"></div>
</div>
<div class="picked hide" id="picked">
<div class="picked-main">
<div class="company" id="pickedCompany"></div>
<div class="sub">
<span id="pickedPostid"></span>
<span id="pickedPlan"></span>
<span id="pickedBind"></span>
</div>
</div>
<button type="button" class="link" id="clearProject">重新选择</button>
</div>
</section>
<section class="card">
<div class="hd">
<span class="num">2</span>
<h2>导出条件</h2>
<span class="tip">数据源与命令行脚本一致:仅 AI 邮箱,已排除删除的邮件</span>
</div>
<div class="scopes" id="scopes">
<label class="scope" data-scope="starred">
<input type="radio" name="scope" value="starred">
<div>
<b>导出星标</b>
<span>所有文件夹里带星标的邮件</span>
</div>
</label>
<label class="scope on" data-scope="inbox">
<input type="radio" name="scope" value="inbox" checked>
<div>
<b>收件箱所有</b>
<span>收件箱里全部邮件,含星标、未读</span>
</div>
</label>
</div>
<div class="row">
<label class="field">
<span>本次最多导出</span>
<input id="limit" type="number" min="1" max="2000" value="300">
<em>封</em>
</label>
<div class="hit" id="hit">选择公司项目后可查看命中数量</div>
</div>
</section>
<div class="action">
<button class="primary" id="start" disabled>开始导出</button>
<span class="hint">同步执行,数量越多越久,期间请不要关闭页面</span>
</div>
<section class="card result hide" id="result">
<div class="hd" style="margin-bottom:10px;">
<span class="num" style="background:#e6f6ee;color:#1a9c62;">✓</span>
<h2>导出完成</h2>
<span class="tip" id="resCompany"></span>
</div>
<div class="stats">
<div class="stat">命中邮件<b id="resTotal">0</b></div>
<div class="stat">成功导出<b id="resSuccess">0</b></div>
<div class="stat">失败<b id="resFail" class="warn">0</b></div>
<div class="stat">耗时<b id="resCost">0s</b></div>
<div class="stat">压缩包<b id="resSize">0</b></div>
</div>
<a class="dl" id="resDownload" href="#" download>下载 zip 压缩包</a>
<div class="path">导出目录:<code id="resDir"></code></div>
<div class="errbox hide" id="errBox">
<h3>部分邮件导出失败</h3>
<div class="errlist">
<table>
<thead><tr><th style="width:52%">主题</th><th>UUID</th><th>原因</th></tr></thead>
<tbody id="errBody"></tbody>
</table>
</div>
</div>
</section>
</main>
<div class="mask hide" id="mask">
<div class="mask-box">
<div class="spinner"></div>
<p id="maskText">正在导出…</p>
<p class="mask-sub" id="maskSub">已用时 0s</p>
</div>
</div>
<script>
(function () {
'use strict';
var API = {
projects: '/mail-export/projects',
count: '/mail-export/count',
start: '/mail-export/start'
};
var el = {
keyword: document.getElementById('keyword'),
dropdown: document.getElementById('dropdown'),
picked: document.getElementById('picked'),
pickedCompany: document.getElementById('pickedCompany'),
pickedPostid: document.getElementById('pickedPostid'),
pickedPlan: document.getElementById('pickedPlan'),
pickedBind: document.getElementById('pickedBind'),
clearProject: document.getElementById('clearProject'),
scopes: document.getElementById('scopes'),
limit: document.getElementById('limit'),
hit: document.getElementById('hit'),
start: document.getElementById('start'),
result: document.getElementById('result'),
mask: document.getElementById('mask'),
maskText: document.getElementById('maskText'),
maskSub: document.getElementById('maskSub')
};
var state = {
project: null,
scope: 'inbox',
items: [],
active: -1,
seq: 0,
timer: null,
running: false
};
/* ---------- 请求 ---------- */
function api(url, params, method) {
method = method || 'GET';
var opt = {credentials: 'same-origin', headers: {}};
var target = url;
if (method === 'GET') {
var qs = new URLSearchParams(params || {}).toString();
if (qs) target += '?' + qs;
} else {
opt.method = 'POST';
opt.headers['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8';
opt.body = new URLSearchParams(params || {}).toString();
}
return fetch(target, opt).then(function (res) {
return res.text().then(function (text) {
var data;
try {
data = JSON.parse(text);
} catch (e) {
throw new Error('服务返回异常(HTTP ' + res.status + '),请稍后重试');
}
if (data && data.status === 401) {
location.reload();
throw new Error('登录已失效');
}
if (data && data.error_message) {
throw new Error(data.error_message);
}
return data || {};
});
});
}
function esc(str) {
return String(str == null ? '' : str).replace(/[&<>"']/g, function (c) {
return {'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'}[c];
});
}
function sizeText(n) {
n = Number(n) || 0;
if (n < 1024) return n + ' B';
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
if (n < 1024 * 1024 * 1024) return (n / 1024 / 1024).toFixed(1) + ' MB';
return (n / 1024 / 1024 / 1024).toFixed(2) + ' GB';
}
/* ---------- 公司项目搜索 ---------- */
var searchTimer = null;
el.keyword.addEventListener('input', function () {
var v = el.keyword.value.trim();
clearTimeout(searchTimer);
if (!v) {
closeDropdown();
return;
}
searchTimer = setTimeout(function () { doSearch(v); }, 280);
});
el.keyword.addEventListener('keydown', function (e) {
if (el.dropdown.classList.contains('hide')) return;
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
var total = state.items.length;
if (!total) return;
state.active = e.key === 'ArrowDown'
? (state.active + 1) % total
: (state.active - 1 + total) % total;
highlight();
} else if (e.key === 'Enter') {
e.preventDefault();
if (state.active >= 0 && state.items[state.active]) {
pick(state.items[state.active]);
}
} else if (e.key === 'Escape') {
closeDropdown();
}
});
el.keyword.addEventListener('focus', function () {
if (el.keyword.value.trim() && state.items.length) {
el.dropdown.classList.remove('hide');
}
});
document.addEventListener('click', function (e) {
if (!document.getElementById('searchWrap').contains(e.target)) {
closeDropdown();
}
});
function doSearch(keyword) {
var seq = ++state.seq;
el.dropdown.classList.remove('hide');
el.dropdown.innerHTML = '<div class="dd-empty">搜索中…</div>';
api(API.projects, {keyword: keyword}).then(function (data) {
if (seq !== state.seq) return;
state.items = data.item || [];
state.active = state.items.length ? 0 : -1;
renderDropdown();
}).catch(function (err) {
if (seq !== state.seq) return;
state.items = [];
el.dropdown.innerHTML = '<div class="dd-empty">' + esc(err.message) + '</div>';
});
}
function renderDropdown() {
if (!state.items.length) {
el.dropdown.innerHTML = '<div class="dd-empty">没有匹配的公司项目</div>';
el.dropdown.classList.remove('hide');
return;
}
var html = '';
state.items.forEach(function (item, i) {
html += '<div class="dd-item' + (i === state.active ? ' on' : '') + '" data-i="' + i + '">'
+ '<div class="dd-main">'
+ '<div class="dd-company">' + esc(item.company) + '</div>'
+ '<div class="dd-sub"><span>postid ' + item.postid + '</span>'
+ (item.plan ? '<span>' + esc(item.plan) + '</span>' : '')
+ '</div></div>'
+ '<div class="dd-bind">绑定邮箱 <b>' + item.bind + '</b></div>'
+ '</div>';
});
el.dropdown.innerHTML = html;
el.dropdown.classList.remove('hide');
[].forEach.call(el.dropdown.querySelectorAll('.dd-item'), function (node) {
node.addEventListener('click', function () {
pick(state.items[parseInt(node.getAttribute('data-i'), 10)]);
});
});
}
function highlight() {
[].forEach.call(el.dropdown.querySelectorAll('.dd-item'), function (node, i) {
node.classList.toggle('on', i === state.active);
});
var on = el.dropdown.querySelector('.dd-item.on');
if (on && on.scrollIntoView) on.scrollIntoView({block: 'nearest'});
}
function closeDropdown() {
el.dropdown.classList.add('hide');
}
function pick(item) {
if (!item) return;
state.project = item;
closeDropdown();
el.keyword.value = '';
el.keyword.blur();
el.picked.classList.remove('hide');
el.pickedCompany.textContent = item.company;
el.pickedPostid.textContent = 'postid ' + item.postid;
el.pickedPlan.textContent = item.plan || '';
el.pickedPlan.style.display = item.plan ? '' : 'none';
el.pickedBind.textContent = '绑定邮箱 ' + item.bind;
el.start.disabled = false;
el.result.classList.add('hide');
refreshCount();
}
el.clearProject.addEventListener('click', function () {
state.project = null;
el.picked.classList.add('hide');
el.start.disabled = true;
el.hit.textContent = '选择公司项目后可查看命中数量';
el.keyword.focus();
});
/* ---------- 条件 / 数量 ---------- */
[].forEach.call(el.scopes.querySelectorAll('.scope'), function (node) {
node.addEventListener('click', function () {
[].forEach.call(el.scopes.querySelectorAll('.scope'), function (n) {
n.classList.remove('on');
});
node.classList.add('on');
state.scope = node.getAttribute('data-scope');
refreshCount();
});
});
el.limit.addEventListener('input', function () {
if (state.project && state.count) refreshCount(true);
});
function refreshCount(silent) {
if (!state.project) return;
if (!silent) el.hit.textContent = '正在统计命中数量…';
api(API.count, {
postid: state.project.postid,
scope: state.scope
}).then(function (data) {
state.count = Number(data.count);
if (isNaN(state.count)) state.count = 0;
var limit = parseInt(el.limit.value, 10) || 0;
if (state.count < 0) {
el.hit.innerHTML = '<b class="zero">统计失败</b>,es 查询异常,可直接尝试导出';
el.start.disabled = false;
return;
}
if (!state.count) {
el.hit.innerHTML = '<b class="zero">0</b> 封命中,当前条件没有可导出的邮件';
} else if (limit && state.count > limit) {
el.hit.innerHTML = '命中 <b>' + state.count + '</b> 封,本次导出最新 <b>' + limit + '</b> 封';
} else {
el.hit.innerHTML = '命中 <b>' + state.count + '</b> 封,全部导出';
}
el.start.disabled = !state.count || state.running;
}).catch(function (err) {
el.hit.textContent = '统计失败:' + err.message;
el.start.disabled = false;
});
}
/* ---------- 开始导出 ---------- */
el.start.addEventListener('click', function () {
if (!state.project || state.running) return;
var limit = parseInt(el.limit.value, 10);
if (!limit || limit < 1) {
alert('请填写本次最多导出的数量');
return;
}
if (limit > 2000) {
alert('单次最多导出 2000 封,请调小数量或分批导出');
return;
}
runExport(state.project, state.scope, limit);
});
function runExport(project, scope, limit) {
state.running = true;
el.start.disabled = true;
el.result.classList.add('hide');
el.maskText.textContent = '正在导出「' + project.company + '」的邮件…';
el.mask.classList.remove('hide');
var begin = Date.now();
state.timer = setInterval(function () {
el.maskSub.textContent = '已用时 ' + Math.floor((Date.now() - begin) / 1000) + 's';
}, 1000);
el.maskSub.textContent = '已用时 0s';
api(API.start, {
postid: project.postid,
scope: scope,
limit: limit
}, 'POST').then(function (data) {
showResult(data);
refreshCount(true);
}).catch(function (err) {
alert(err.message);
}).then(function () {
stopTimer();
state.running = false;
el.start.disabled = !state.count;
});
}
function stopTimer() {
if (state.timer) clearInterval(state.timer);
state.timer = null;
el.mask.classList.add('hide');
}
function showResult(data) {
document.getElementById('resCompany').textContent = (data.company || '') + ' · ' + (data.scope_label || '');
document.getElementById('resTotal').textContent = data.total || 0;
document.getElementById('resSuccess').textContent = data.success || 0;
document.getElementById('resFail').textContent = data.fail || 0;
document.getElementById('resCost').textContent = (data.cost || 0) + 's';
document.getElementById('resSize').textContent = sizeText(data.zip_size);
document.getElementById('resDir').textContent = data.dir || '';
var dl = document.getElementById('resDownload');
dl.setAttribute('href', data.download || '#');
dl.setAttribute('download', data.zip_name || '');
var errors = data.errors || [];
document.getElementById('errBox').classList.toggle('hide', !errors.length);
var body = document.getElementById('errBody');
body.innerHTML = '';
errors.forEach(function (item) {
var tr = document.createElement('tr');
tr.innerHTML = '<td>' + esc(item.subject || '(无主题)') + '</td>'
+ '<td class="sub">' + esc(item.uuid || '') + '</td>'
+ '<td class="sub">' + esc(item.message || '') + '</td>';
body.appendChild(tr);
});
el.result.classList.remove('hide');
el.result.scrollIntoView({behavior: 'smooth', block: 'start'});
}
el.keyword.focus();
})();
</script>
</body>
</html>
... ...