新增 邮件导出页面(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 头
正在显示
5 个修改的文件
包含
1620 行增加
和
0 行删除
controller/MailExport.php
0 → 100644
| 1 | +<?php | ||
| 2 | + | ||
| 3 | +namespace Controller; | ||
| 4 | + | ||
| 5 | +use Service\MailExport as MailExportService; | ||
| 6 | + | ||
| 7 | +/** | ||
| 8 | + * 邮件导出页面 | ||
| 9 | + * | ||
| 10 | + * 需要一个固定密码才能访问,密码:mailexport | ||
| 11 | + * | ||
| 12 | + * 路由: | ||
| 13 | + * mail-export 页面(GET 展示 / POST 校验密码) | ||
| 14 | + * mail-export/logout 退出 | ||
| 15 | + * mail-export/projects 公司项目搜索(按名称或 postid) | ||
| 16 | + * mail-export/count 查询命中的邮件数量 | ||
| 17 | + * mail-export/start 执行导出(同步,导出完成后返回下载地址) | ||
| 18 | + * mail-export/download 下载导出的 zip | ||
| 19 | + * | ||
| 20 | + * @author:dc | ||
| 21 | + * @time 2026/10/28 11:40 | ||
| 22 | + * Class MailExport | ||
| 23 | + * @package Controller | ||
| 24 | + */ | ||
| 25 | +class MailExport extends Base { | ||
| 26 | + | ||
| 27 | + /** | ||
| 28 | + * 访问密码(固定) | ||
| 29 | + */ | ||
| 30 | + private const PASSWORD = 'mailexport'; | ||
| 31 | + | ||
| 32 | + /** | ||
| 33 | + * 登录态 cookie 名称 | ||
| 34 | + */ | ||
| 35 | + private const COOKIE_NAME = 'mail_export_token'; | ||
| 36 | + | ||
| 37 | + /** | ||
| 38 | + * 登录态签名盐值,cookie 里不存明文密码 | ||
| 39 | + */ | ||
| 40 | + private const COOKIE_SALT = 'mail_export_2026'; | ||
| 41 | + | ||
| 42 | + /** | ||
| 43 | + * 登录态有效期,7 天 | ||
| 44 | + */ | ||
| 45 | + private const COOKIE_TTL = 604800; | ||
| 46 | + | ||
| 47 | + /** | ||
| 48 | + * 页面 | ||
| 49 | + * GET :未登录返回密码框,已登录返回页面 | ||
| 50 | + * POST :校验密码,成功后写入登录态并返回页面 | ||
| 51 | + * | ||
| 52 | + * @return string | ||
| 53 | + * @author:dc | ||
| 54 | + * @time 2026/10/28 11:40 | ||
| 55 | + */ | ||
| 56 | + public function index(){ | ||
| 57 | + if(app()->requestHas('password')){ | ||
| 58 | + $password = (string)app()->request('password'); | ||
| 59 | + if(!hash_equals(self::PASSWORD, $password)){ | ||
| 60 | + logs('mail-export 密码错误 IP:'.($_SERVER['REMOTE_ADDR'] ?? '')); | ||
| 61 | + return $this->loginHtml('密码不正确'); | ||
| 62 | + } | ||
| 63 | + $this->setAuthCookie(); | ||
| 64 | + return $this->pageHtml(); | ||
| 65 | + } | ||
| 66 | + | ||
| 67 | + if($this->isAuthed()){ | ||
| 68 | + return $this->pageHtml(); | ||
| 69 | + } | ||
| 70 | + | ||
| 71 | + return $this->loginHtml(''); | ||
| 72 | + } | ||
| 73 | + | ||
| 74 | + /** | ||
| 75 | + * 退出 | ||
| 76 | + * @return string | ||
| 77 | + * @author:dc | ||
| 78 | + * @time 2026/10/28 11:40 | ||
| 79 | + */ | ||
| 80 | + public function logout(){ | ||
| 81 | + setcookie(self::COOKIE_NAME, '', [ | ||
| 82 | + 'expires' => time() - 3600, | ||
| 83 | + 'path' => '/', | ||
| 84 | + 'httponly' => true, | ||
| 85 | + 'samesite' => 'Lax', | ||
| 86 | + ]); | ||
| 87 | + | ||
| 88 | + return $this->loginHtml('已退出'); | ||
| 89 | + } | ||
| 90 | + | ||
| 91 | + /** | ||
| 92 | + * 搜索公司项目 | ||
| 93 | + * 支持按公司名称模糊、按 postid 精确 | ||
| 94 | + * @return array | ||
| 95 | + * @throws \Lib\Err | ||
| 96 | + * @author:dc | ||
| 97 | + * @time 2026/10/28 11:40 | ||
| 98 | + */ | ||
| 99 | + public function projects(){ | ||
| 100 | + $this->needAuth(); | ||
| 101 | + | ||
| 102 | + $keyword = trim((string)app()->request('keyword', '')); | ||
| 103 | + if($keyword === ''){ | ||
| 104 | + return ['item' => []]; | ||
| 105 | + } | ||
| 106 | + | ||
| 107 | + $db = fob_mysql(); | ||
| 108 | + | ||
| 109 | + // 名称模糊 + postid 精确 | ||
| 110 | + $where = "`company` like '%".addslashes($keyword)."%'"; | ||
| 111 | + if(ctype_digit($keyword)){ | ||
| 112 | + $where = "(`postid` = ".intval($keyword)." or ".$where.")"; | ||
| 113 | + } | ||
| 114 | + | ||
| 115 | + $rows = $db->throw()->all( | ||
| 116 | + "select `postid`,`company`,`plan` from `projects` where {$where} order by `postid` desc limit 20" | ||
| 117 | + ); | ||
| 118 | + | ||
| 119 | + if(!$rows){ | ||
| 120 | + return ['item' => []]; | ||
| 121 | + } | ||
| 122 | + | ||
| 123 | + // 已绑定邮箱数量,方便确认选的是哪个项目 | ||
| 124 | + $bindCount = []; | ||
| 125 | + $postids = array_map('intval', array_column($rows, 'postid')); | ||
| 126 | + try { | ||
| 127 | + $counts = $db->throw()->all( | ||
| 128 | + "select `post_id`,count(*) as `num` from `e_mail_binds` where `post_id` in (".implode(',', $postids).") group by `post_id`" | ||
| 129 | + ); | ||
| 130 | + $bindCount = array_column($counts, 'num', 'post_id'); | ||
| 131 | + }catch (\Throwable $e){ | ||
| 132 | + logs('mail-export 统计绑定邮箱失败:'.$e->getMessage()); | ||
| 133 | + } | ||
| 134 | + | ||
| 135 | + $item = []; | ||
| 136 | + foreach($rows as $row){ | ||
| 137 | + $item[] = [ | ||
| 138 | + 'postid' => intval($row['postid']), | ||
| 139 | + 'company' => (string)$row['company'], | ||
| 140 | + 'plan' => (string)$row['plan'], | ||
| 141 | + 'bind' => intval($bindCount[$row['postid']] ?? 0), | ||
| 142 | + ]; | ||
| 143 | + } | ||
| 144 | + | ||
| 145 | + return ['item' => $item]; | ||
| 146 | + } | ||
| 147 | + | ||
| 148 | + /** | ||
| 149 | + * 查询命中的邮件数量 | ||
| 150 | + * @return array | ||
| 151 | + * @throws \Lib\Err | ||
| 152 | + * @author:dc | ||
| 153 | + * @time 2026/10/28 11:40 | ||
| 154 | + */ | ||
| 155 | + public function count(){ | ||
| 156 | + $this->needAuth(); | ||
| 157 | + | ||
| 158 | + $postid = intval(app()->request('postid', 0)); | ||
| 159 | + $scope = $this->scope(); | ||
| 160 | + | ||
| 161 | + if(!$postid){ | ||
| 162 | + app()->e('请先选择公司项目'); | ||
| 163 | + } | ||
| 164 | + | ||
| 165 | + return [ | ||
| 166 | + 'count' => MailExportService::count($postid, $scope), | ||
| 167 | + 'scope' => $scope, | ||
| 168 | + ]; | ||
| 169 | + } | ||
| 170 | + | ||
| 171 | + /** | ||
| 172 | + * 执行导出 | ||
| 173 | + * @return array | ||
| 174 | + * @throws \Lib\Err | ||
| 175 | + * @author:dc | ||
| 176 | + * @time 2026/10/28 11:40 | ||
| 177 | + */ | ||
| 178 | + public function start(){ | ||
| 179 | + $this->needAuth(); | ||
| 180 | + | ||
| 181 | + $postid = intval(app()->request('postid', 0)); | ||
| 182 | + $scope = $this->scope(); | ||
| 183 | + $limit = intval(app()->request('limit', MailExportService::DEFAULT_LIMIT)); | ||
| 184 | + | ||
| 185 | + if(!$postid){ | ||
| 186 | + app()->e('请先选择公司项目'); | ||
| 187 | + } | ||
| 188 | + | ||
| 189 | + $project = fob_mysql()->throw()->first( | ||
| 190 | + "select `postid`,`company` from `projects` where `postid` = {$postid} limit 1" | ||
| 191 | + ); | ||
| 192 | + if(!$project){ | ||
| 193 | + app()->e('公司项目不存在,请重新搜索'); | ||
| 194 | + } | ||
| 195 | + | ||
| 196 | + // 导出是同步执行的,数量大时需要更长时间 | ||
| 197 | + set_time_limit(0); | ||
| 198 | + ignore_user_abort(true); | ||
| 199 | + | ||
| 200 | + $ret = (new MailExportService())->export($postid, $scope, $limit); | ||
| 201 | + | ||
| 202 | + $ret['company'] = (string)$project['company']; | ||
| 203 | + | ||
| 204 | + logs('邮件导出 postid:'.$postid.' 范围:'.$scope.' 数量:'.$ret['total'] | ||
| 205 | + .' 成功:'.$ret['success'].' 失败:'.$ret['fail'].' 耗时:'.$ret['cost'].'s IP:'.($_SERVER['REMOTE_ADDR'] ?? '')); | ||
| 206 | + | ||
| 207 | + return $ret; | ||
| 208 | + } | ||
| 209 | + | ||
| 210 | + /** | ||
| 211 | + * 下载导出的 zip | ||
| 212 | + * @return string | ||
| 213 | + * @throws \Lib\Err | ||
| 214 | + * @author:dc | ||
| 215 | + * @time 2026/10/28 11:40 | ||
| 216 | + */ | ||
| 217 | + public function download(){ | ||
| 218 | + $this->needAuth(); | ||
| 219 | + | ||
| 220 | + $f = (string)app()->request('f', ''); | ||
| 221 | + // 只允许 目录/文件名.zip 的形式 | ||
| 222 | + if(!preg_match('#^(\d{1,12}_\d{8}_\d{6}_[0-9a-f]{6})/([0-9a-zA-Z_\-\.]+\.zip)$#', $f, $match)){ | ||
| 223 | + app()->e('文件地址不合法'); | ||
| 224 | + } | ||
| 225 | + | ||
| 226 | + $path = MailExportService::baseDir().'/'.$match[1].'/'.$match[2]; | ||
| 227 | + if(!is_file($path)){ | ||
| 228 | + app()->e('文件不存在,可能已被清理,请重新导出'); | ||
| 229 | + } | ||
| 230 | + | ||
| 231 | + app()->header('Content-Type', 'application/zip'); | ||
| 232 | + app()->header('Content-Disposition', 'attachment; filename="'.$match[2].'"'); | ||
| 233 | + app()->header('Content-Length', filesize($path)); | ||
| 234 | + app()->header('Cache-Control', 'no-store'); | ||
| 235 | + | ||
| 236 | + return file_get_contents($path); | ||
| 237 | + } | ||
| 238 | + | ||
| 239 | + /** | ||
| 240 | + * 导出条件 | ||
| 241 | + * @return string | ||
| 242 | + * @throws \Lib\Err | ||
| 243 | + * @author:dc | ||
| 244 | + * @time 2026/10/28 11:40 | ||
| 245 | + */ | ||
| 246 | + private function scope():string { | ||
| 247 | + $scope = (string)app()->request('scope', ''); | ||
| 248 | + if(!isset(MailExportService::scopeMap()[$scope])){ | ||
| 249 | + app()->e('导出条件不正确'); | ||
| 250 | + } | ||
| 251 | + return $scope; | ||
| 252 | + } | ||
| 253 | + | ||
| 254 | + /** | ||
| 255 | + * 未登录直接报错,前端收到后刷新页面 | ||
| 256 | + * @throws \Lib\Err | ||
| 257 | + * @author:dc | ||
| 258 | + * @time 2026/10/28 11:40 | ||
| 259 | + */ | ||
| 260 | + private function needAuth(){ | ||
| 261 | + if(!$this->isAuthed()){ | ||
| 262 | + app()->e('登录已失效,请刷新页面重新输入密码', 401); | ||
| 263 | + } | ||
| 264 | + } | ||
| 265 | + | ||
| 266 | + /** | ||
| 267 | + * 是否已登录 | ||
| 268 | + * @return bool | ||
| 269 | + * @author:dc | ||
| 270 | + * @time 2026/10/28 11:40 | ||
| 271 | + */ | ||
| 272 | + private function isAuthed():bool { | ||
| 273 | + return hash_equals($this->token(), (string)($_COOKIE[self::COOKIE_NAME] ?? '')); | ||
| 274 | + } | ||
| 275 | + | ||
| 276 | + /** | ||
| 277 | + * 登录态令牌 | ||
| 278 | + * @return string | ||
| 279 | + * @author:dc | ||
| 280 | + * @time 2026/10/28 11:40 | ||
| 281 | + */ | ||
| 282 | + private function token():string { | ||
| 283 | + return hash_hmac('sha256', self::PASSWORD, self::COOKIE_SALT); | ||
| 284 | + } | ||
| 285 | + | ||
| 286 | + /** | ||
| 287 | + * 写入登录态 | ||
| 288 | + * @author:dc | ||
| 289 | + * @time 2026/10/28 11:40 | ||
| 290 | + */ | ||
| 291 | + private function setAuthCookie(){ | ||
| 292 | + setcookie(self::COOKIE_NAME, $this->token(), [ | ||
| 293 | + 'expires' => time() + self::COOKIE_TTL, | ||
| 294 | + 'path' => '/', | ||
| 295 | + 'httponly' => true, | ||
| 296 | + 'samesite' => 'Lax', | ||
| 297 | + 'secure' => !empty($_SERVER['HTTPS']) && strtolower($_SERVER['HTTPS']) !== 'off', | ||
| 298 | + ]); | ||
| 299 | + } | ||
| 300 | + | ||
| 301 | + /** | ||
| 302 | + * 页面 html | ||
| 303 | + * @return string | ||
| 304 | + * @author:dc | ||
| 305 | + * @time 2026/10/28 11:40 | ||
| 306 | + */ | ||
| 307 | + private function pageHtml():string { | ||
| 308 | + $file = ROOT_PATH.'/views/mail_export.html'; | ||
| 309 | + if(!is_file($file)){ | ||
| 310 | + return $this->loginHtml('页面模版不存在:views/mail_export.html'); | ||
| 311 | + } | ||
| 312 | + | ||
| 313 | + return str_replace( | ||
| 314 | + ['<!--{#host#}-->', '<!--{#time#}-->'], | ||
| 315 | + [APP_HOST, app()->nowDateTime()], | ||
| 316 | + file_get_contents($file) | ||
| 317 | + ); | ||
| 318 | + } | ||
| 319 | + | ||
| 320 | + /** | ||
| 321 | + * 密码输入页面 | ||
| 322 | + * @param string $error | ||
| 323 | + * @return string | ||
| 324 | + * @author:dc | ||
| 325 | + * @time 2026/10/28 11:40 | ||
| 326 | + */ | ||
| 327 | + private function loginHtml(string $error = ''):string { | ||
| 328 | + $errorHtml = $error ? '<p class="error">'.$error.'</p>' : ''; | ||
| 329 | + | ||
| 330 | + return '<!DOCTYPE html> | ||
| 331 | +<html lang="zh-CN"> | ||
| 332 | +<head> | ||
| 333 | +<meta charset="UTF-8"> | ||
| 334 | +<meta name="viewport" content="width=device-width, initial-scale=1"> | ||
| 335 | +<meta name="robots" content="noindex,nofollow"> | ||
| 336 | +<title>邮件导出 · 访问验证</title> | ||
| 337 | +<style> | ||
| 338 | + *{box-sizing:border-box;} | ||
| 339 | + body{margin:0;height:100vh;display:flex;align-items:center;justify-content:center; | ||
| 340 | + background:#eef3fa;font:14px/1.6 -apple-system,"Segoe UI","Microsoft YaHei",sans-serif;color:#243447;} | ||
| 341 | + .box{width:340px;padding:32px 28px;background:#fff;border-radius:12px; | ||
| 342 | + box-shadow:0 8px 30px rgba(31,63,107,.12);} | ||
| 343 | + h1{margin:0 0 6px;font-size:18px;font-weight:600;} | ||
| 344 | + .sub{margin:0 0 22px;color:#8a97a8;font-size:12px;} | ||
| 345 | + input{width:100%;height:40px;padding:0 12px;border:1px solid #d5dde8;border-radius:8px; | ||
| 346 | + font-size:14px;outline:none;transition:.15s;} | ||
| 347 | + input:focus{border-color:#4a7fd6;box-shadow:0 0 0 3px rgba(74,127,214,.12);} | ||
| 348 | + button{width:100%;height:40px;margin-top:14px;border:0;border-radius:8px;background:#2f6bd8; | ||
| 349 | + color:#fff;font-size:14px;cursor:pointer;transition:.15s;} | ||
| 350 | + button:hover{background:#255cc0;} | ||
| 351 | + .error{margin:12px 0 0;color:#d0342c;font-size:12px;} | ||
| 352 | +</style> | ||
| 353 | +</head> | ||
| 354 | +<body> | ||
| 355 | +<form class="box" method="post" action="/mail-export" autocomplete="off"> | ||
| 356 | + <h1>邮件导出</h1> | ||
| 357 | + <p class="sub">该页面需要密码才可访问</p> | ||
| 358 | + <input type="password" name="password" placeholder="请输入访问密码" autofocus> | ||
| 359 | + <button type="submit">进入</button> | ||
| 360 | + '.$errorHtml.' | ||
| 361 | +</form> | ||
| 362 | +</body> | ||
| 363 | +</html>'; | ||
| 364 | + } | ||
| 365 | + | ||
| 366 | +} |
| @@ -108,6 +108,14 @@ return [ | @@ -108,6 +108,14 @@ return [ | ||
| 108 | ,'api-doc' => [\Controller\ApiDoc::class,'index'] | 108 | ,'api-doc' => [\Controller\ApiDoc::class,'index'] |
| 109 | ,'api-doc/logout' => [\Controller\ApiDoc::class,'logout'] | 109 | ,'api-doc/logout' => [\Controller\ApiDoc::class,'logout'] |
| 110 | 110 | ||
| 111 | + // 邮件导出(需要密码访问) | ||
| 112 | + ,'mail-export' => [\Controller\MailExport::class,'index'] | ||
| 113 | + ,'mail-export/logout' => [\Controller\MailExport::class,'logout'] | ||
| 114 | + ,'mail-export/projects' => [\Controller\MailExport::class,'projects'] | ||
| 115 | + ,'mail-export/count' => [\Controller\MailExport::class,'count'] | ||
| 116 | + ,'mail-export/start' => [\Controller\MailExport::class,'start'] | ||
| 117 | + ,'mail-export/download' => [\Controller\MailExport::class,'download'] | ||
| 118 | + | ||
| 111 | 119 | ||
| 112 | 120 | ||
| 113 | ]; | 121 | ]; |
service/MailExport.php
0 → 100644
| 1 | +<?php | ||
| 2 | + | ||
| 3 | +namespace Service; | ||
| 4 | + | ||
| 5 | +use Lib\Err; | ||
| 6 | +use PHPMailer\PHPMailer\PHPMailer; | ||
| 7 | + | ||
| 8 | +/** | ||
| 9 | + * 邮件导出为 eml 文件 | ||
| 10 | + * | ||
| 11 | + * 逻辑来源 cmd/export_mail.php,这里抽成服务类,支持传入 | ||
| 12 | + * postid(公司项目)、导出范围、数量上限,供页面调用。 | ||
| 13 | + * | ||
| 14 | + * @author:dc | ||
| 15 | + * @time 2026/10/28 11:40 | ||
| 16 | + * Class MailExport | ||
| 17 | + * @package Service | ||
| 18 | + */ | ||
| 19 | +class MailExport { | ||
| 20 | + | ||
| 21 | + /** | ||
| 22 | + * 星标邮件(所有文件夹) | ||
| 23 | + */ | ||
| 24 | + public const SCOPE_STARRED = 'starred'; | ||
| 25 | + | ||
| 26 | + /** | ||
| 27 | + * 收件箱全部 | ||
| 28 | + */ | ||
| 29 | + public const SCOPE_INBOX = 'inbox'; | ||
| 30 | + | ||
| 31 | + /** | ||
| 32 | + * 默认导出数量 | ||
| 33 | + */ | ||
| 34 | + public const DEFAULT_LIMIT = 300; | ||
| 35 | + | ||
| 36 | + /** | ||
| 37 | + * 单次最大导出数量 | ||
| 38 | + */ | ||
| 39 | + public const MAX_LIMIT = 2000; | ||
| 40 | + | ||
| 41 | + /** | ||
| 42 | + * 清单文件名 | ||
| 43 | + */ | ||
| 44 | + private const MANIFEST_NAME = '导出清单.csv'; | ||
| 45 | + | ||
| 46 | + /** | ||
| 47 | + * @var int 项目id | ||
| 48 | + */ | ||
| 49 | + private int $postid = 0; | ||
| 50 | + | ||
| 51 | + /** | ||
| 52 | + * @var string 导出范围 | ||
| 53 | + */ | ||
| 54 | + private string $scope = ''; | ||
| 55 | + | ||
| 56 | + /** | ||
| 57 | + * @var int 数量上限 | ||
| 58 | + */ | ||
| 59 | + private int $limit = 0; | ||
| 60 | + | ||
| 61 | + /** | ||
| 62 | + * @var string 本次导出目录 | ||
| 63 | + */ | ||
| 64 | + private string $dir = ''; | ||
| 65 | + | ||
| 66 | + /** | ||
| 67 | + * @var array es _id 去重 | ||
| 68 | + */ | ||
| 69 | + private array $ids = []; | ||
| 70 | + | ||
| 71 | + /** | ||
| 72 | + * @var array 已生成的 eml 文件 | ||
| 73 | + */ | ||
| 74 | + private array $files = []; | ||
| 75 | + | ||
| 76 | + /** | ||
| 77 | + * @var array 已使用过的文件名,避免同一批里重名 | ||
| 78 | + */ | ||
| 79 | + private array $usedNames = []; | ||
| 80 | + | ||
| 81 | + /** | ||
| 82 | + * @var array 清单数据 | ||
| 83 | + */ | ||
| 84 | + private array $manifest = []; | ||
| 85 | + | ||
| 86 | + /** | ||
| 87 | + * @var array 失败记录 | ||
| 88 | + */ | ||
| 89 | + private array $errors = []; | ||
| 90 | + | ||
| 91 | + /** | ||
| 92 | + * @var int 成功数量 | ||
| 93 | + */ | ||
| 94 | + private int $success = 0; | ||
| 95 | + | ||
| 96 | + /** | ||
| 97 | + * @var int 失败数量 | ||
| 98 | + */ | ||
| 99 | + private int $fail = 0; | ||
| 100 | + | ||
| 101 | + /** | ||
| 102 | + * 导出范围 | ||
| 103 | + * @return string[] | ||
| 104 | + * @author:dc | ||
| 105 | + * @time 2026/10/28 11:40 | ||
| 106 | + */ | ||
| 107 | + public static function scopeMap():array { | ||
| 108 | + return [ | ||
| 109 | + self::SCOPE_STARRED => '星标邮件(所有文件夹)', | ||
| 110 | + self::SCOPE_INBOX => '收件箱全部', | ||
| 111 | + ]; | ||
| 112 | + } | ||
| 113 | + | ||
| 114 | + /** | ||
| 115 | + * 导出文件根目录 | ||
| 116 | + * 说明:不放在 public 下面,只能通过页面鉴权后下载 | ||
| 117 | + * @return string | ||
| 118 | + * @author:dc | ||
| 119 | + * @time 2026/10/28 11:40 | ||
| 120 | + */ | ||
| 121 | + public static function baseDir():string { | ||
| 122 | + return ROOT_PATH.'/storage/export'; | ||
| 123 | + } | ||
| 124 | + | ||
| 125 | + /** | ||
| 126 | + * 查询命中的邮件数量 | ||
| 127 | + * 返回 -1 表示 es 查询异常,与「0 封」区分开 | ||
| 128 | + * @param int $postid | ||
| 129 | + * @param string $scope | ||
| 130 | + * @return int | ||
| 131 | + * @author:dc | ||
| 132 | + * @time 2026/10/28 11:40 | ||
| 133 | + */ | ||
| 134 | + public static function count(int $postid,string $scope):int { | ||
| 135 | + try { | ||
| 136 | + $count = es('email_lists_branch_'.$postid)->count(['query' => self::query($scope)], true); | ||
| 137 | + return max(0, intval($count)); | ||
| 138 | + }catch (\Throwable $e){ | ||
| 139 | + logs('邮件导出统计失败 postid:'.$postid.' '.$e->getMessage()); | ||
| 140 | + return -1; | ||
| 141 | + } | ||
| 142 | + } | ||
| 143 | + | ||
| 144 | + /** | ||
| 145 | + * es 查询条件 | ||
| 146 | + * 与 cmd/export_mail.php 保持一致:只取 ai 邮箱(source=2)、未删除 | ||
| 147 | + * @param string $scope | ||
| 148 | + * @return array | ||
| 149 | + * @author:dc | ||
| 150 | + * @time 2026/10/28 11:40 | ||
| 151 | + */ | ||
| 152 | + public static function query(string $scope):array { | ||
| 153 | + $must = [ | ||
| 154 | + ['term' => ['source' => 2]], | ||
| 155 | + ['term' => ['deleted' => 0]], | ||
| 156 | + ]; | ||
| 157 | + | ||
| 158 | + if($scope === self::SCOPE_STARRED){ | ||
| 159 | + // 星标:不限文件夹 | ||
| 160 | + $must[] = ['term' => ['flagged' => 1]]; | ||
| 161 | + $must[] = ['terms' => ['folder_as_int' => [1, 2, 3, 4, 5]]]; | ||
| 162 | + }else{ | ||
| 163 | + // 收件箱全部(含星标、含未读已读) | ||
| 164 | + $must[] = ['term' => ['folder_as_int' => 1]]; | ||
| 165 | + } | ||
| 166 | + | ||
| 167 | + return ['constant_score' => ['filter' => ['bool' => ['must' => $must]]]]; | ||
| 168 | + } | ||
| 169 | + | ||
| 170 | + /** | ||
| 171 | + * 文件名净化,保留中文 | ||
| 172 | + * @param string $name | ||
| 173 | + * @return string | ||
| 174 | + * @author:dc | ||
| 175 | + * @time 2026/10/28 11:40 | ||
| 176 | + */ | ||
| 177 | + public static function sanitize(string $name):string { | ||
| 178 | + $name = preg_replace('/[^\p{L}\p{N}_\-\.\(\)]+/u', '_', $name); | ||
| 179 | + $name = preg_replace('/_+/u', '_', (string)$name); | ||
| 180 | + return trim((string)$name, '_ .'); | ||
| 181 | + } | ||
| 182 | + | ||
| 183 | + /** | ||
| 184 | + * 执行导出 | ||
| 185 | + * @param int $postid 项目id | ||
| 186 | + * @param string $scope 导出范围 | ||
| 187 | + * @param int $limit 数量上限 | ||
| 188 | + * @return array | ||
| 189 | + * @throws Err | ||
| 190 | + * @author:dc | ||
| 191 | + * @time 2026/10/28 11:40 | ||
| 192 | + */ | ||
| 193 | + public function export(int $postid,string $scope,int $limit):array { | ||
| 194 | + if(!$postid){ | ||
| 195 | + throw new Err('缺少公司项目'); | ||
| 196 | + } | ||
| 197 | + if(!isset(self::scopeMap()[$scope])){ | ||
| 198 | + throw new Err('导出条件不支持'); | ||
| 199 | + } | ||
| 200 | + | ||
| 201 | + $this->postid = $postid; | ||
| 202 | + $this->scope = $scope; | ||
| 203 | + $this->limit = max(1, min($limit ?: self::DEFAULT_LIMIT, self::MAX_LIMIT)); | ||
| 204 | + | ||
| 205 | + $startTime = microtime(true); | ||
| 206 | + | ||
| 207 | + // 导出目录,带随机后缀,避免重复 | ||
| 208 | + $this->dir = self::baseDir().'/'.$postid.'_'.date('Ymd_His').'_'.substr(md5(uniqid('', true)), 0, 6); | ||
| 209 | + if(!is_dir($this->dir) && !@mkdir($this->dir, 0775, true) && !is_dir($this->dir)){ | ||
| 210 | + throw new Err('导出目录创建失败,请检查 storage 目录权限'); | ||
| 211 | + } | ||
| 212 | + | ||
| 213 | + $hits = $this->search(); | ||
| 214 | + if(!$hits){ | ||
| 215 | + $this->removeDir($this->dir); | ||
| 216 | + // 没有数据时再确认一次,区分「es 挂了」和「真的没有邮件」 | ||
| 217 | + if(self::count($this->postid, $this->scope) < 0){ | ||
| 218 | + throw new Err('es 查询失败,请稍后再试(已记录日志)'); | ||
| 219 | + } | ||
| 220 | + throw new Err('没有命中任何邮件,换个条件再试'); | ||
| 221 | + } | ||
| 222 | + | ||
| 223 | + foreach($hits as $hit){ | ||
| 224 | + $this->saveOne($hit); | ||
| 225 | + } | ||
| 226 | + | ||
| 227 | + if(!$this->files){ | ||
| 228 | + $this->removeDir($this->dir); | ||
| 229 | + $detail = $this->errors ? $this->errors[0]['message'] : '未知原因'; | ||
| 230 | + throw new Err('全部邮件导出失败:'.$detail); | ||
| 231 | + } | ||
| 232 | + | ||
| 233 | + // 清单 | ||
| 234 | + $this->writeManifest(); | ||
| 235 | + | ||
| 236 | + // 打包 | ||
| 237 | + $zipName = $this->postid.'_'.date('Ymd_His').'.zip'; | ||
| 238 | + $zipFile = $this->dir.'/'.$zipName; | ||
| 239 | + $this->zip($zipFile); | ||
| 240 | + | ||
| 241 | + return [ | ||
| 242 | + 'postid' => $this->postid, | ||
| 243 | + 'scope' => $this->scope, | ||
| 244 | + 'scope_label'=> self::scopeMap()[$this->scope], | ||
| 245 | + 'limit' => $this->limit, | ||
| 246 | + 'total' => count($hits), | ||
| 247 | + 'success' => $this->success, | ||
| 248 | + 'fail' => $this->fail, | ||
| 249 | + 'errors' => array_slice($this->errors, 0, 20), | ||
| 250 | + 'dir' => str_replace(ROOT_PATH, '', $this->dir), | ||
| 251 | + 'zip_name' => $zipName, | ||
| 252 | + 'zip_size' => is_file($zipFile) ? filesize($zipFile) : 0, | ||
| 253 | + 'download' => '/mail-export/download?f='.rawurlencode(basename($this->dir).'/'.$zipName), | ||
| 254 | + 'cost' => round(microtime(true) - $startTime, 1), | ||
| 255 | + ]; | ||
| 256 | + } | ||
| 257 | + | ||
| 258 | + /** | ||
| 259 | + * 查询 es | ||
| 260 | + * @return array | ||
| 261 | + * @author:dc | ||
| 262 | + * @time 2026/10/28 11:40 | ||
| 263 | + */ | ||
| 264 | + private function search():array { | ||
| 265 | + $res = es('email_lists_branch_'.$this->postid)->search( | ||
| 266 | + ['query' => self::query($this->scope)], | ||
| 267 | + 0, | ||
| 268 | + $this->limit, | ||
| 269 | + ['udate' => 'desc'] | ||
| 270 | + ); | ||
| 271 | + | ||
| 272 | + return $res['hits']['hits'] ?? []; | ||
| 273 | + } | ||
| 274 | + | ||
| 275 | + /** | ||
| 276 | + * 导出一封 | ||
| 277 | + * @param array $hit | ||
| 278 | + * @author:dc | ||
| 279 | + * @time 2026/10/28 11:40 | ||
| 280 | + */ | ||
| 281 | + private function saveOne(array $hit):void { | ||
| 282 | + $esId = (string)($hit['_id'] ?? ''); | ||
| 283 | + if($esId === '' || isset($this->ids[$esId])){ | ||
| 284 | + return; | ||
| 285 | + } | ||
| 286 | + $this->ids[$esId] = 1; | ||
| 287 | + | ||
| 288 | + $src = $hit['_source'] ?? []; | ||
| 289 | + $file = $this->fileName($src); | ||
| 290 | + $subject = (string)($src['subject'] ?? ''); | ||
| 291 | + | ||
| 292 | + try { | ||
| 293 | + $this->saveEml($src, $file); | ||
| 294 | + | ||
| 295 | + if(!is_file($file) || filesize($file) <= 0){ | ||
| 296 | + throw new \Exception('eml 文件为空'); | ||
| 297 | + } | ||
| 298 | + | ||
| 299 | + $this->files[] = $file; | ||
| 300 | + $this->success++; | ||
| 301 | + $this->manifest[] = [ | ||
| 302 | + 'subject' => $subject, | ||
| 303 | + 'from' => $this->addressText($src['from'] ?? []), | ||
| 304 | + 'to' => $this->toText($src), | ||
| 305 | + 'date' => empty($src['udate']) ? '' : date('Y-m-d H:i:s', $src['udate']), | ||
| 306 | + 'uuid' => (string)($src['uuid'] ?? ''), | ||
| 307 | + 'file' => basename($file), | ||
| 308 | + 'status' => '成功', | ||
| 309 | + ]; | ||
| 310 | + }catch (\Throwable $e){ | ||
| 311 | + // 半成品删掉 | ||
| 312 | + if(is_file($file)){ | ||
| 313 | + @unlink($file); | ||
| 314 | + } | ||
| 315 | + $this->fail++; | ||
| 316 | + $this->errors[] = [ | ||
| 317 | + 'subject' => $subject, | ||
| 318 | + 'uuid' => (string)($src['uuid'] ?? ''), | ||
| 319 | + 'message' => $e->getMessage(), | ||
| 320 | + ]; | ||
| 321 | + logs('邮件导出失败 '.(string)($src['uuid'] ?? '').' : '.$e->getMessage()); | ||
| 322 | + } | ||
| 323 | + } | ||
| 324 | + | ||
| 325 | + /** | ||
| 326 | + * 生成 eml(逻辑同 cmd/export_mail.php 的 save_eml) | ||
| 327 | + * @param array $src es 数据 | ||
| 328 | + * @param string $file | ||
| 329 | + * @throws \Exception | ||
| 330 | + * @author:dc | ||
| 331 | + * @time 2026/10/28 11:40 | ||
| 332 | + */ | ||
| 333 | + private function saveEml(array $src,string $file):void { | ||
| 334 | + $mail = new PHPMailer(true); | ||
| 335 | + | ||
| 336 | + $from = $src['from'] ?? []; | ||
| 337 | + $fromEmail = is_array($from) ? ($from['email'] ?? '') : (string)$from; | ||
| 338 | + $fromName = is_array($from) ? ($from['name'] ?? '') : ''; | ||
| 339 | + if(!$fromEmail){ | ||
| 340 | + throw new \Exception('发件人为空'); | ||
| 341 | + } | ||
| 342 | + $mail->setFrom($fromEmail, $fromName); | ||
| 343 | + | ||
| 344 | + // 收件人 | ||
| 345 | + $toName = @json_decode((string)($src['to_name'] ?? ''), true); | ||
| 346 | + if(is_array($toName) && $toName){ | ||
| 347 | + foreach($toName as $item){ | ||
| 348 | + if(empty($item['email'])) continue; | ||
| 349 | + try { | ||
| 350 | + $mail->addAddress($item['email'], $item['name'] ?? ''); | ||
| 351 | + }catch (\Throwable $e){} | ||
| 352 | + } | ||
| 353 | + } | ||
| 354 | + if(!$mail->getToAddresses() && !empty($src['to'])){ | ||
| 355 | + foreach(explode(',', (string)$src['to']) as $item){ | ||
| 356 | + $item = trim($item); | ||
| 357 | + if($item === '') continue; | ||
| 358 | + try { | ||
| 359 | + $mail->addAddress($item); | ||
| 360 | + }catch (\Throwable $e){} | ||
| 361 | + } | ||
| 362 | + } | ||
| 363 | + | ||
| 364 | + $mail->Subject = (string)($src['subject'] ?? ''); | ||
| 365 | + | ||
| 366 | + $uuid = (string)($src['uuid'] ?? ''); | ||
| 367 | + // uuid 实际是 lists 表的自增id | ||
| 368 | + $body = ctype_digit($uuid) ? getMailBody((int)$uuid) : []; | ||
| 369 | + $html = getBodyHtml($body); | ||
| 370 | + | ||
| 371 | + // 没有 body,尝试同步一次 | ||
| 372 | + if(!$html && ctype_digit($uuid) && !empty($src['email_id']) && !empty($src['uid'])){ | ||
| 373 | + try { | ||
| 374 | + $folder = db()->value(\Model\folderSql::first($src['folder_id'] ?? 0, 'origin_folder')); | ||
| 375 | + (new SyncMail($src['email_id']))->mail($folder, [(string)$src['uid']], true); | ||
| 376 | + $body = getMailBody((int)$uuid); | ||
| 377 | + $html = getBodyHtml($body); | ||
| 378 | + }catch (\Throwable $e){ | ||
| 379 | + logs('邮件导出补拉正文失败 '.$uuid.' : '.$e->getMessage()); | ||
| 380 | + } | ||
| 381 | + } | ||
| 382 | + | ||
| 383 | + // 正文为空也要能生成 eml(否则 PHPMailer 会报 Message body empty) | ||
| 384 | + $mail->Body = $html ?: '<div></div>'; | ||
| 385 | + $mail->Encoding = 'quoted-printable'; | ||
| 386 | + $mail->CharSet = 'utf-8'; | ||
| 387 | + // 用 MessageDate 保留原始收发时间(不要用 addCustomHeader,会出两个 Date 头) | ||
| 388 | + if(!empty($src['udate'])){ | ||
| 389 | + $mail->MessageDate = date('D, d M Y H:i:s O', intval($src['udate'])); | ||
| 390 | + } | ||
| 391 | + if(!empty($src['uuid'])){ | ||
| 392 | + $mail->addCustomHeader('Uuid', (string)$src['uuid']); | ||
| 393 | + } | ||
| 394 | + | ||
| 395 | + // 附件 | ||
| 396 | + foreach((array)$body as $item){ | ||
| 397 | + if(!empty($item['path']) && is_file($item['path'])){ | ||
| 398 | + try { | ||
| 399 | + $mail->addAttachment($item['path'], $item['filename'] ?? ''); | ||
| 400 | + }catch (\Throwable $e){} | ||
| 401 | + } | ||
| 402 | + } | ||
| 403 | + | ||
| 404 | + // 抄送 | ||
| 405 | + foreach($this->addressList($src['cc'] ?? '') as $cc){ | ||
| 406 | + try { | ||
| 407 | + $mail->addCC($cc['email'], $cc['name']); | ||
| 408 | + }catch (\Throwable $e){} | ||
| 409 | + } | ||
| 410 | + // 密送 | ||
| 411 | + foreach($this->addressList($src['bcc'] ?? '') as $bcc){ | ||
| 412 | + try { | ||
| 413 | + $mail->addBCC($bcc['email'], $bcc['name']); | ||
| 414 | + }catch (\Throwable $e){} | ||
| 415 | + } | ||
| 416 | + | ||
| 417 | + $mail->isHTML(true); | ||
| 418 | + $mail->preSend(); | ||
| 419 | + | ||
| 420 | + $content = $mail->getSentMIMEMessage(); | ||
| 421 | + if(!$content){ | ||
| 422 | + throw new \Exception('eml 内容生成失败'); | ||
| 423 | + } | ||
| 424 | + | ||
| 425 | + if(file_put_contents($file, $content) === false){ | ||
| 426 | + throw new \Exception('eml 写入失败'); | ||
| 427 | + } | ||
| 428 | + } | ||
| 429 | + | ||
| 430 | + /** | ||
| 431 | + * cc / bcc 解析 | ||
| 432 | + * @param mixed $value | ||
| 433 | + * @return array | ||
| 434 | + * @author:dc | ||
| 435 | + * @time 2026/10/28 11:40 | ||
| 436 | + */ | ||
| 437 | + private function addressList(mixed $value):array { | ||
| 438 | + if(empty($value)){ | ||
| 439 | + return []; | ||
| 440 | + } | ||
| 441 | + $list = is_array($value) ? $value : (@json_decode((string)$value, true) ?: []); | ||
| 442 | + $ret = []; | ||
| 443 | + foreach($list as $item){ | ||
| 444 | + if(!is_array($item)){ | ||
| 445 | + $item = ['email' => (string)$item]; | ||
| 446 | + } | ||
| 447 | + if(empty($item['email'])) continue; | ||
| 448 | + $ret[] = ['email' => $item['email'], 'name' => $item['name'] ?? '']; | ||
| 449 | + } | ||
| 450 | + return $ret; | ||
| 451 | + } | ||
| 452 | + | ||
| 453 | + /** | ||
| 454 | + * 发件人文本 | ||
| 455 | + * @param mixed $from | ||
| 456 | + * @return string | ||
| 457 | + * @author:dc | ||
| 458 | + * @time 2026/10/28 11:40 | ||
| 459 | + */ | ||
| 460 | + private function addressText(mixed $from):string { | ||
| 461 | + if(is_array($from)){ | ||
| 462 | + $email = $from['email'] ?? ''; | ||
| 463 | + $name = $from['name'] ?? ''; | ||
| 464 | + return $name ? "{$name} <{$email}>" : (string)$email; | ||
| 465 | + } | ||
| 466 | + return (string)$from; | ||
| 467 | + } | ||
| 468 | + | ||
| 469 | + /** | ||
| 470 | + * 收件人文本 | ||
| 471 | + * @param array $src | ||
| 472 | + * @return string | ||
| 473 | + * @author:dc | ||
| 474 | + * @time 2026/10/28 11:40 | ||
| 475 | + */ | ||
| 476 | + private function toText(array $src):string { | ||
| 477 | + $toName = @json_decode((string)($src['to_name'] ?? ''), true); | ||
| 478 | + if(is_array($toName) && $toName){ | ||
| 479 | + $arr = []; | ||
| 480 | + foreach($toName as $item){ | ||
| 481 | + if(empty($item['email'])) continue; | ||
| 482 | + $arr[] = empty($item['name']) ? $item['email'] : "{$item['name']} <{$item['email']}>"; | ||
| 483 | + } | ||
| 484 | + if($arr){ | ||
| 485 | + return implode('; ', $arr); | ||
| 486 | + } | ||
| 487 | + } | ||
| 488 | + return (string)($src['to'] ?? ''); | ||
| 489 | + } | ||
| 490 | + | ||
| 491 | + /** | ||
| 492 | + * 生成不重复的文件名 | ||
| 493 | + * @param array $src | ||
| 494 | + * @return string | ||
| 495 | + * @author:dc | ||
| 496 | + * @time 2026/10/28 11:40 | ||
| 497 | + */ | ||
| 498 | + private function fileName(array $src):string { | ||
| 499 | + $name = self::sanitize((string)($src['subject'] ?? '')); | ||
| 500 | + if($name === ''){ | ||
| 501 | + $name = '无主题'; | ||
| 502 | + } | ||
| 503 | + $name = mb_substr($name, 0, 80); | ||
| 504 | + | ||
| 505 | + $date = empty($src['udate']) ? '' : date('Ymd_His', $src['udate']); | ||
| 506 | + $base = trim(($date ? $date.'_' : '').$name, '_'); | ||
| 507 | + | ||
| 508 | + $fileName = $base.'.eml'; | ||
| 509 | + if(isset($this->usedNames[$fileName]) || is_file($this->dir.'/'.$fileName)){ | ||
| 510 | + $i = 1; | ||
| 511 | + while(isset($this->usedNames[$base.'('.$i.').eml']) || is_file($this->dir.'/'.$base.'('.$i.').eml')){ | ||
| 512 | + $i++; | ||
| 513 | + } | ||
| 514 | + $fileName = $base.'('.$i.').eml'; | ||
| 515 | + } | ||
| 516 | + | ||
| 517 | + $this->usedNames[$fileName] = 1; | ||
| 518 | + | ||
| 519 | + return $this->dir.'/'.$fileName; | ||
| 520 | + } | ||
| 521 | + | ||
| 522 | + /** | ||
| 523 | + * 写导出清单 | ||
| 524 | + * @author:dc | ||
| 525 | + * @time 2026/10/28 11:40 | ||
| 526 | + */ | ||
| 527 | + private function writeManifest():void { | ||
| 528 | + $file = $this->dir.'/'.self::MANIFEST_NAME; | ||
| 529 | + $fp = @fopen($file, 'wb'); | ||
| 530 | + if(!$fp){ | ||
| 531 | + return; | ||
| 532 | + } | ||
| 533 | + // excel 打开不乱码 | ||
| 534 | + fwrite($fp, "\xEF\xBB\xBF"); | ||
| 535 | + fputcsv($fp, ['序号', '主题', '发件人', '收件人', '日期', 'UUID', '文件名', '状态']); | ||
| 536 | + | ||
| 537 | + $i = 0; | ||
| 538 | + foreach($this->manifest as $item){ | ||
| 539 | + fputcsv($fp, [ | ||
| 540 | + ++$i, | ||
| 541 | + $item['subject'], | ||
| 542 | + $item['from'], | ||
| 543 | + $item['to'], | ||
| 544 | + $item['date'], | ||
| 545 | + $item['uuid'], | ||
| 546 | + $item['file'], | ||
| 547 | + $item['status'], | ||
| 548 | + ]); | ||
| 549 | + } | ||
| 550 | + // 失败的也记一下 | ||
| 551 | + foreach($this->errors as $item){ | ||
| 552 | + fputcsv($fp, [++$i, $item['subject'], '', '', '', $item['uuid'], '', '失败:'.$item['message']]); | ||
| 553 | + } | ||
| 554 | + | ||
| 555 | + fclose($fp); | ||
| 556 | + $this->files[] = $file; | ||
| 557 | + } | ||
| 558 | + | ||
| 559 | + /** | ||
| 560 | + * 打包 | ||
| 561 | + * @param string $zipFile | ||
| 562 | + * @throws Err | ||
| 563 | + * @author:dc | ||
| 564 | + * @time 2026/10/28 11:40 | ||
| 565 | + */ | ||
| 566 | + private function zip(string $zipFile):void { | ||
| 567 | + if(class_exists('\ZipArchive')){ | ||
| 568 | + $zip = new \ZipArchive(); | ||
| 569 | + if($zip->open($zipFile, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) === true){ | ||
| 570 | + foreach($this->files as $file){ | ||
| 571 | + $zip->addFile($file, basename($file)); | ||
| 572 | + } | ||
| 573 | + if($zip->close() && is_file($zipFile) && filesize($zipFile) > 0){ | ||
| 574 | + return; | ||
| 575 | + } | ||
| 576 | + } | ||
| 577 | + } | ||
| 578 | + | ||
| 579 | + // 没有 zip 扩展时用不压缩的 zip | ||
| 580 | + $this->storeZip($zipFile); | ||
| 581 | + | ||
| 582 | + if(!is_file($zipFile) || filesize($zipFile) <= 0){ | ||
| 583 | + throw new Err('打包失败,请检查 php-zip 扩展或目录权限'); | ||
| 584 | + } | ||
| 585 | + } | ||
| 586 | + | ||
| 587 | + /** | ||
| 588 | + * 不压缩的 zip(无 php-zip 扩展时的兜底) | ||
| 589 | + * @param string $zipFile | ||
| 590 | + * @throws Err | ||
| 591 | + * @author:dc | ||
| 592 | + * @time 2026/10/28 11:40 | ||
| 593 | + */ | ||
| 594 | + private function storeZip(string $zipFile):void { | ||
| 595 | + $fp = @fopen($zipFile, 'wb'); | ||
| 596 | + if(!$fp){ | ||
| 597 | + throw new Err('zip 文件创建失败'); | ||
| 598 | + } | ||
| 599 | + | ||
| 600 | + $central = ''; | ||
| 601 | + $offset = 0; | ||
| 602 | + $count = 0; | ||
| 603 | + | ||
| 604 | + foreach($this->files as $file){ | ||
| 605 | + $data = @file_get_contents($file); | ||
| 606 | + if($data === false){ | ||
| 607 | + continue; | ||
| 608 | + } | ||
| 609 | + $name = basename($file); | ||
| 610 | + $crc = crc32($data); | ||
| 611 | + $size = strlen($data); | ||
| 612 | + $nameLen = strlen($name); | ||
| 613 | + $mtime = @filemtime($file) ?: time(); | ||
| 614 | + $dosTime = (int)((date('H', $mtime) << 11) | (date('i', $mtime) << 5) | (date('s', $mtime) / 2)); | ||
| 615 | + $dosDate = (int)(((date('Y', $mtime) - 1980) << 9) | (date('n', $mtime) << 5) | date('j', $mtime)); | ||
| 616 | + | ||
| 617 | + // 本地文件头,0x0800 表示文件名是 utf-8 | ||
| 618 | + $local = pack('VvvvvvVVVvv', 0x04034b50, 20, 0x0800, 0, $dosTime, $dosDate, $crc, $size, $size, $nameLen, 0).$name; | ||
| 619 | + fwrite($fp, $local); | ||
| 620 | + fwrite($fp, $data); | ||
| 621 | + | ||
| 622 | + // 中央目录 | ||
| 623 | + $central .= pack('VvvvvvvVVVvvvvvVV', 0x02014b50, 20, 20, 0x0800, 0, $dosTime, $dosDate, $crc, $size, $size, $nameLen, 0, 0, 0, 0, 0, $offset).$name; | ||
| 624 | + | ||
| 625 | + $offset += strlen($local) + $size; | ||
| 626 | + $count++; | ||
| 627 | + } | ||
| 628 | + | ||
| 629 | + fwrite($fp, $central); | ||
| 630 | + fwrite($fp, pack('VvvvvVVv', 0x06054b50, 0, 0, $count, $count, strlen($central), $offset, 0)); | ||
| 631 | + fclose($fp); | ||
| 632 | + } | ||
| 633 | + | ||
| 634 | + /** | ||
| 635 | + * 删除本次导出目录 | ||
| 636 | + * @param string $dir | ||
| 637 | + * @author:dc | ||
| 638 | + * @time 2026/10/28 11:40 | ||
| 639 | + */ | ||
| 640 | + private function removeDir(string $dir):void { | ||
| 641 | + if(!$dir || !is_dir($dir)){ | ||
| 642 | + return; | ||
| 643 | + } | ||
| 644 | + foreach((array)@scandir($dir) as $item){ | ||
| 645 | + if($item === '.' || $item === '..' || $item === false){ | ||
| 646 | + continue; | ||
| 647 | + } | ||
| 648 | + $path = $dir.'/'.$item; | ||
| 649 | + if(is_file($path)){ | ||
| 650 | + @unlink($path); | ||
| 651 | + } | ||
| 652 | + } | ||
| 653 | + @rmdir($dir); | ||
| 654 | + } | ||
| 655 | + | ||
| 656 | +} |
views/mail_export.html
0 → 100644
| 1 | +<!DOCTYPE html> | ||
| 2 | +<html lang="zh-CN"> | ||
| 3 | +<head> | ||
| 4 | +<meta charset="UTF-8"> | ||
| 5 | +<meta name="viewport" content="width=device-width, initial-scale=1"> | ||
| 6 | +<meta name="robots" content="noindex,nofollow"> | ||
| 7 | +<title>邮件导出</title> | ||
| 8 | +<style> | ||
| 9 | + *{box-sizing:border-box;margin:0;padding:0;} | ||
| 10 | + body{background:#f6f8fb;color:#22303f;font:14px/1.7 -apple-system,"Segoe UI","Microsoft YaHei",sans-serif;} | ||
| 11 | + .hide{display:none !important;} | ||
| 12 | + | ||
| 13 | + /* 顶栏 */ | ||
| 14 | + .top{position:sticky;top:0;z-index:20;display:flex;align-items:center;gap:16px; | ||
| 15 | + height:56px;padding:0 22px;background:#fff;border-bottom:1px solid #e4eaf2;} | ||
| 16 | + .top .logo{display:flex;align-items:center;gap:10px;font-size:15px;font-weight:600;white-space:nowrap;} | ||
| 17 | + .top .logo i{display:inline-block;width:8px;height:18px;border-radius:3px;background:#2f6bd8;} | ||
| 18 | + .top .meta{color:#93a1b3;font-size:12px;white-space:nowrap;} | ||
| 19 | + .top .meta b{color:#5b6b7e;font-weight:500;} | ||
| 20 | + .top .grow{flex:1;} | ||
| 21 | + .top .quit{color:#8a97a8;font-size:12px;text-decoration:none;border:1px solid #e4eaf2; | ||
| 22 | + border-radius:6px;padding:4px 10px;transition:.15s;} | ||
| 23 | + .top .quit:hover{color:#d0342c;border-color:#f0c9c6;background:#fdf5f4;} | ||
| 24 | + | ||
| 25 | + main{max-width:940px;margin:0 auto;padding:24px 22px 60px;} | ||
| 26 | + | ||
| 27 | + /* 卡片 */ | ||
| 28 | + .card{background:#fff;border:1px solid #e4eaf2;border-radius:12px;padding:20px 22px;margin-bottom:16px;} | ||
| 29 | + .hd{display:flex;align-items:center;gap:10px;margin-bottom:16px;flex-wrap:wrap;} | ||
| 30 | + .hd .num{width:20px;height:20px;border-radius:50%;background:#eaf1fd;color:#2f6bd8; | ||
| 31 | + font-size:12px;font-weight:600;display:inline-flex;align-items:center;justify-content:center;} | ||
| 32 | + .hd h2{font-size:15px;font-weight:600;} | ||
| 33 | + .hd .tip{color:#93a1b3;font-size:12px;} | ||
| 34 | + | ||
| 35 | + /* 公司项目搜索 */ | ||
| 36 | + .search{position:relative;} | ||
| 37 | + .search input{width:100%;height:42px;padding:0 14px;border:1px solid #dde5ef;border-radius:9px; | ||
| 38 | + font-size:14px;outline:none;transition:.15s;background:#fcfdff;color:#22303f;} | ||
| 39 | + .search input:focus{border-color:#7ba4e8;box-shadow:0 0 0 3px rgba(47,107,216,.1);background:#fff;} | ||
| 40 | + .dropdown{position:absolute;left:0;right:0;top:46px;z-index:30;background:#fff;border:1px solid #e4eaf2; | ||
| 41 | + border-radius:10px;box-shadow:0 12px 30px rgba(31,63,107,.12);max-height:320px;overflow:auto;} | ||
| 42 | + .dd-item{display:flex;align-items:center;gap:12px;padding:10px 14px;cursor:pointer;border-bottom:1px solid #f2f6fb;} | ||
| 43 | + .dd-item:last-child{border-bottom:0;} | ||
| 44 | + .dd-item:hover,.dd-item.on{background:#f4f8ff;} | ||
| 45 | + .dd-main{flex:1;min-width:0;} | ||
| 46 | + .dd-company{font-size:13.5px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} | ||
| 47 | + .dd-sub{color:#93a1b3;font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} | ||
| 48 | + .dd-sub span+span:before{content:"·";margin:0 6px;color:#c9d4e2;} | ||
| 49 | + .dd-bind{color:#8a97a8;font-size:12px;white-space:nowrap;} | ||
| 50 | + .dd-bind b{color:#2f6bd8;font-weight:600;} | ||
| 51 | + .dd-empty{padding:14px;color:#93a1b3;font-size:13px;text-align:center;} | ||
| 52 | + | ||
| 53 | + .picked{display:flex;align-items:center;gap:14px;padding:12px 14px;margin-top:12px; | ||
| 54 | + background:#f4f8ff;border:1px solid #dbe7fb;border-radius:10px;} | ||
| 55 | + .picked .picked-main{flex:1;min-width:0;} | ||
| 56 | + .picked .company{font-size:14px;font-weight:600;color:#1b3a66;} | ||
| 57 | + .picked .sub{color:#6b7c90;font-size:12px;margin-top:2px;} | ||
| 58 | + .picked .sub span+span:before{content:"·";margin:0 6px;color:#c9d4e2;} | ||
| 59 | + .picked .link{border:0;background:none;color:#2f6bd8;font-size:12.5px;cursor:pointer;padding:4px 2px;} | ||
| 60 | + .picked .link:hover{text-decoration:underline;} | ||
| 61 | + | ||
| 62 | + /* 导出条件 */ | ||
| 63 | + .scopes{display:flex;gap:12px;flex-wrap:wrap;} | ||
| 64 | + .scope{flex:1;min-width:240px;display:flex;gap:10px;align-items:flex-start;padding:14px 16px; | ||
| 65 | + border:1px solid #e2e9f3;border-radius:10px;cursor:pointer;transition:.15s;background:#fcfdff;} | ||
| 66 | + .scope:hover{border-color:#c8d9f3;background:#fafcff;} | ||
| 67 | + .scope input{margin-top:4px;accent-color:#2f6bd8;} | ||
| 68 | + .scope b{display:block;font-size:13.5px;font-weight:600;} | ||
| 69 | + .scope span{display:block;color:#8a97a8;font-size:12px;margin-top:2px;} | ||
| 70 | + .scope.on{border-color:#7ba4e8;background:#f4f8ff;box-shadow:0 0 0 3px rgba(47,107,216,.08);} | ||
| 71 | + | ||
| 72 | + .row{display:flex;align-items:center;gap:18px;margin-top:16px;flex-wrap:wrap;} | ||
| 73 | + .field{display:flex;align-items:center;gap:8px;font-size:13px;color:#5b6b7e;} | ||
| 74 | + .field input{width:96px;height:34px;padding:0 10px;border:1px solid #dde5ef;border-radius:8px; | ||
| 75 | + font-size:13px;outline:none;color:#22303f;} | ||
| 76 | + .field input:focus{border-color:#7ba4e8;box-shadow:0 0 0 3px rgba(47,107,216,.1);} | ||
| 77 | + .field em{font-style:normal;color:#93a1b3;} | ||
| 78 | + .hit{font-size:13px;color:#5b6b7e;} | ||
| 79 | + .hit b{color:#2f6bd8;font-weight:600;} | ||
| 80 | + .hit .zero{color:#c07a00;} | ||
| 81 | + | ||
| 82 | + /* 操作 */ | ||
| 83 | + .action{display:flex;align-items:center;gap:14px;margin:4px 0 20px;flex-wrap:wrap;} | ||
| 84 | + .primary{height:42px;padding:0 30px;border:0;border-radius:9px;background:#2f6bd8;color:#fff; | ||
| 85 | + font-size:14px;font-weight:500;cursor:pointer;transition:.15s;} | ||
| 86 | + .primary:hover{background:#255cc0;} | ||
| 87 | + .primary:disabled{background:#c3d2e6;cursor:not-allowed;} | ||
| 88 | + .hint{color:#93a1b3;font-size:12px;} | ||
| 89 | + | ||
| 90 | + /* 结果 */ | ||
| 91 | + .result .ok{display:flex;align-items:center;gap:10px;font-size:15px;font-weight:600;color:#1a9c62;} | ||
| 92 | + .result .ok i{width:20px;height:20px;border-radius:50%;background:#e6f6ee;color:#1a9c62; | ||
| 93 | + display:inline-flex;align-items:center;justify-content:center;font-size:12px;font-style:normal;} | ||
| 94 | + .stats{display:flex;gap:26px;margin:16px 0 4px;flex-wrap:wrap;} | ||
| 95 | + .stat{font-size:12px;color:#8a97a8;} | ||
| 96 | + .stat b{display:block;font-size:19px;color:#22303f;font-weight:600;line-height:1.4;} | ||
| 97 | + .stat b.warn{color:#d0342c;} | ||
| 98 | + .dl{display:inline-flex;align-items:center;gap:8px;height:42px;padding:0 24px;margin-top:16px; | ||
| 99 | + border-radius:9px;background:#2f6bd8;color:#fff;font-size:14px;font-weight:500;text-decoration:none; | ||
| 100 | + transition:.15s;} | ||
| 101 | + .dl:hover{background:#255cc0;} | ||
| 102 | + .path{margin-top:12px;color:#93a1b3;font-size:12px;word-break:break-all;} | ||
| 103 | + .path code{color:#5b6b7e;background:#f4f7fb;border-radius:4px;padding:2px 6px;} | ||
| 104 | + .errbox{margin-top:16px;border-top:1px dashed #e4eaf2;padding-top:14px;} | ||
| 105 | + .errbox h3{font-size:13px;font-weight:600;color:#d0342c;margin-bottom:8px;} | ||
| 106 | + .errlist{max-height:220px;overflow:auto;border:1px solid #f0e2e1;border-radius:8px;background:#fffbfb;} | ||
| 107 | + table{width:100%;border-collapse:collapse;font-size:12.5px;} | ||
| 108 | + th,td{text-align:left;padding:7px 10px;border-bottom:1px solid #f4ebea;vertical-align:top;} | ||
| 109 | + th{color:#a08a89;font-weight:500;background:#fdf6f5;position:sticky;top:0;} | ||
| 110 | + td.sub{color:#5b6b7e;} | ||
| 111 | + | ||
| 112 | + /* 遮罩 */ | ||
| 113 | + .mask{position:fixed;inset:0;z-index:50;background:rgba(229,237,247,.72);backdrop-filter:blur(2px); | ||
| 114 | + display:flex;align-items:center;justify-content:center;} | ||
| 115 | + .mask-box{background:#fff;border:1px solid #e4eaf2;border-radius:14px;padding:26px 34px;text-align:center; | ||
| 116 | + box-shadow:0 18px 50px rgba(31,63,107,.16);min-width:260px;} | ||
| 117 | + .spinner{width:30px;height:30px;margin:0 auto 14px;border:3px solid #e3ecfa;border-top-color:#2f6bd8; | ||
| 118 | + border-radius:50%;animation:spin .8s linear infinite;} | ||
| 119 | + @keyframes spin{to{transform:rotate(360deg);}} | ||
| 120 | + .mask-box p{font-size:13.5px;color:#22303f;} | ||
| 121 | + .mask-box .mask-sub{margin-top:6px;color:#93a1b3;font-size:12px;} | ||
| 122 | + | ||
| 123 | + @media (max-width:640px){ | ||
| 124 | + .top .meta{display:none;} | ||
| 125 | + main{padding:16px 12px 40px;} | ||
| 126 | + .card{padding:16px;} | ||
| 127 | + } | ||
| 128 | +</style> | ||
| 129 | +</head> | ||
| 130 | +<body> | ||
| 131 | + | ||
| 132 | +<header class="top"> | ||
| 133 | + <div class="logo"><i></i>邮件导出</div> | ||
| 134 | + <div class="meta">导出为 eml · 打包 zip 下载</div> | ||
| 135 | + <div class="grow"></div> | ||
| 136 | + <div class="meta"><b><!--{#host#}--></b> · <!--{#time#}--></div> | ||
| 137 | + <a class="quit" href="/mail-export/logout">退出</a> | ||
| 138 | +</header> | ||
| 139 | + | ||
| 140 | +<main> | ||
| 141 | + <section class="card"> | ||
| 142 | + <div class="hd"> | ||
| 143 | + <span class="num">1</span> | ||
| 144 | + <h2>选择公司项目</h2> | ||
| 145 | + <span class="tip">输入公司名称模糊搜索,或直接输入 postid</span> | ||
| 146 | + </div> | ||
| 147 | + <div class="search" id="searchWrap"> | ||
| 148 | + <input id="keyword" type="text" placeholder="公司项目名称 / postid" autocomplete="off" spellcheck="false"> | ||
| 149 | + <div class="dropdown hide" id="dropdown"></div> | ||
| 150 | + </div> | ||
| 151 | + <div class="picked hide" id="picked"> | ||
| 152 | + <div class="picked-main"> | ||
| 153 | + <div class="company" id="pickedCompany"></div> | ||
| 154 | + <div class="sub"> | ||
| 155 | + <span id="pickedPostid"></span> | ||
| 156 | + <span id="pickedPlan"></span> | ||
| 157 | + <span id="pickedBind"></span> | ||
| 158 | + </div> | ||
| 159 | + </div> | ||
| 160 | + <button type="button" class="link" id="clearProject">重新选择</button> | ||
| 161 | + </div> | ||
| 162 | + </section> | ||
| 163 | + | ||
| 164 | + <section class="card"> | ||
| 165 | + <div class="hd"> | ||
| 166 | + <span class="num">2</span> | ||
| 167 | + <h2>导出条件</h2> | ||
| 168 | + <span class="tip">数据源与命令行脚本一致:仅 AI 邮箱,已排除删除的邮件</span> | ||
| 169 | + </div> | ||
| 170 | + <div class="scopes" id="scopes"> | ||
| 171 | + <label class="scope" data-scope="starred"> | ||
| 172 | + <input type="radio" name="scope" value="starred"> | ||
| 173 | + <div> | ||
| 174 | + <b>导出星标</b> | ||
| 175 | + <span>所有文件夹里带星标的邮件</span> | ||
| 176 | + </div> | ||
| 177 | + </label> | ||
| 178 | + <label class="scope on" data-scope="inbox"> | ||
| 179 | + <input type="radio" name="scope" value="inbox" checked> | ||
| 180 | + <div> | ||
| 181 | + <b>收件箱所有</b> | ||
| 182 | + <span>收件箱里全部邮件,含星标、未读</span> | ||
| 183 | + </div> | ||
| 184 | + </label> | ||
| 185 | + </div> | ||
| 186 | + <div class="row"> | ||
| 187 | + <label class="field"> | ||
| 188 | + <span>本次最多导出</span> | ||
| 189 | + <input id="limit" type="number" min="1" max="2000" value="300"> | ||
| 190 | + <em>封</em> | ||
| 191 | + </label> | ||
| 192 | + <div class="hit" id="hit">选择公司项目后可查看命中数量</div> | ||
| 193 | + </div> | ||
| 194 | + </section> | ||
| 195 | + | ||
| 196 | + <div class="action"> | ||
| 197 | + <button class="primary" id="start" disabled>开始导出</button> | ||
| 198 | + <span class="hint">同步执行,数量越多越久,期间请不要关闭页面</span> | ||
| 199 | + </div> | ||
| 200 | + | ||
| 201 | + <section class="card result hide" id="result"> | ||
| 202 | + <div class="hd" style="margin-bottom:10px;"> | ||
| 203 | + <span class="num" style="background:#e6f6ee;color:#1a9c62;">✓</span> | ||
| 204 | + <h2>导出完成</h2> | ||
| 205 | + <span class="tip" id="resCompany"></span> | ||
| 206 | + </div> | ||
| 207 | + <div class="stats"> | ||
| 208 | + <div class="stat">命中邮件<b id="resTotal">0</b></div> | ||
| 209 | + <div class="stat">成功导出<b id="resSuccess">0</b></div> | ||
| 210 | + <div class="stat">失败<b id="resFail" class="warn">0</b></div> | ||
| 211 | + <div class="stat">耗时<b id="resCost">0s</b></div> | ||
| 212 | + <div class="stat">压缩包<b id="resSize">0</b></div> | ||
| 213 | + </div> | ||
| 214 | + <a class="dl" id="resDownload" href="#" download>下载 zip 压缩包</a> | ||
| 215 | + <div class="path">导出目录:<code id="resDir"></code></div> | ||
| 216 | + <div class="errbox hide" id="errBox"> | ||
| 217 | + <h3>部分邮件导出失败</h3> | ||
| 218 | + <div class="errlist"> | ||
| 219 | + <table> | ||
| 220 | + <thead><tr><th style="width:52%">主题</th><th>UUID</th><th>原因</th></tr></thead> | ||
| 221 | + <tbody id="errBody"></tbody> | ||
| 222 | + </table> | ||
| 223 | + </div> | ||
| 224 | + </div> | ||
| 225 | + </section> | ||
| 226 | +</main> | ||
| 227 | + | ||
| 228 | +<div class="mask hide" id="mask"> | ||
| 229 | + <div class="mask-box"> | ||
| 230 | + <div class="spinner"></div> | ||
| 231 | + <p id="maskText">正在导出…</p> | ||
| 232 | + <p class="mask-sub" id="maskSub">已用时 0s</p> | ||
| 233 | + </div> | ||
| 234 | +</div> | ||
| 235 | + | ||
| 236 | +<script> | ||
| 237 | +(function () { | ||
| 238 | + 'use strict'; | ||
| 239 | + | ||
| 240 | + var API = { | ||
| 241 | + projects: '/mail-export/projects', | ||
| 242 | + count: '/mail-export/count', | ||
| 243 | + start: '/mail-export/start' | ||
| 244 | + }; | ||
| 245 | + | ||
| 246 | + var el = { | ||
| 247 | + keyword: document.getElementById('keyword'), | ||
| 248 | + dropdown: document.getElementById('dropdown'), | ||
| 249 | + picked: document.getElementById('picked'), | ||
| 250 | + pickedCompany: document.getElementById('pickedCompany'), | ||
| 251 | + pickedPostid: document.getElementById('pickedPostid'), | ||
| 252 | + pickedPlan: document.getElementById('pickedPlan'), | ||
| 253 | + pickedBind: document.getElementById('pickedBind'), | ||
| 254 | + clearProject: document.getElementById('clearProject'), | ||
| 255 | + scopes: document.getElementById('scopes'), | ||
| 256 | + limit: document.getElementById('limit'), | ||
| 257 | + hit: document.getElementById('hit'), | ||
| 258 | + start: document.getElementById('start'), | ||
| 259 | + result: document.getElementById('result'), | ||
| 260 | + mask: document.getElementById('mask'), | ||
| 261 | + maskText: document.getElementById('maskText'), | ||
| 262 | + maskSub: document.getElementById('maskSub') | ||
| 263 | + }; | ||
| 264 | + | ||
| 265 | + var state = { | ||
| 266 | + project: null, | ||
| 267 | + scope: 'inbox', | ||
| 268 | + items: [], | ||
| 269 | + active: -1, | ||
| 270 | + seq: 0, | ||
| 271 | + timer: null, | ||
| 272 | + running: false | ||
| 273 | + }; | ||
| 274 | + | ||
| 275 | + /* ---------- 请求 ---------- */ | ||
| 276 | + function api(url, params, method) { | ||
| 277 | + method = method || 'GET'; | ||
| 278 | + var opt = {credentials: 'same-origin', headers: {}}; | ||
| 279 | + var target = url; | ||
| 280 | + | ||
| 281 | + if (method === 'GET') { | ||
| 282 | + var qs = new URLSearchParams(params || {}).toString(); | ||
| 283 | + if (qs) target += '?' + qs; | ||
| 284 | + } else { | ||
| 285 | + opt.method = 'POST'; | ||
| 286 | + opt.headers['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8'; | ||
| 287 | + opt.body = new URLSearchParams(params || {}).toString(); | ||
| 288 | + } | ||
| 289 | + | ||
| 290 | + return fetch(target, opt).then(function (res) { | ||
| 291 | + return res.text().then(function (text) { | ||
| 292 | + var data; | ||
| 293 | + try { | ||
| 294 | + data = JSON.parse(text); | ||
| 295 | + } catch (e) { | ||
| 296 | + throw new Error('服务返回异常(HTTP ' + res.status + '),请稍后重试'); | ||
| 297 | + } | ||
| 298 | + if (data && data.status === 401) { | ||
| 299 | + location.reload(); | ||
| 300 | + throw new Error('登录已失效'); | ||
| 301 | + } | ||
| 302 | + if (data && data.error_message) { | ||
| 303 | + throw new Error(data.error_message); | ||
| 304 | + } | ||
| 305 | + return data || {}; | ||
| 306 | + }); | ||
| 307 | + }); | ||
| 308 | + } | ||
| 309 | + | ||
| 310 | + function esc(str) { | ||
| 311 | + return String(str == null ? '' : str).replace(/[&<>"']/g, function (c) { | ||
| 312 | + return {'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}[c]; | ||
| 313 | + }); | ||
| 314 | + } | ||
| 315 | + | ||
| 316 | + function sizeText(n) { | ||
| 317 | + n = Number(n) || 0; | ||
| 318 | + if (n < 1024) return n + ' B'; | ||
| 319 | + if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'; | ||
| 320 | + if (n < 1024 * 1024 * 1024) return (n / 1024 / 1024).toFixed(1) + ' MB'; | ||
| 321 | + return (n / 1024 / 1024 / 1024).toFixed(2) + ' GB'; | ||
| 322 | + } | ||
| 323 | + | ||
| 324 | + /* ---------- 公司项目搜索 ---------- */ | ||
| 325 | + var searchTimer = null; | ||
| 326 | + | ||
| 327 | + el.keyword.addEventListener('input', function () { | ||
| 328 | + var v = el.keyword.value.trim(); | ||
| 329 | + clearTimeout(searchTimer); | ||
| 330 | + if (!v) { | ||
| 331 | + closeDropdown(); | ||
| 332 | + return; | ||
| 333 | + } | ||
| 334 | + searchTimer = setTimeout(function () { doSearch(v); }, 280); | ||
| 335 | + }); | ||
| 336 | + | ||
| 337 | + el.keyword.addEventListener('keydown', function (e) { | ||
| 338 | + if (el.dropdown.classList.contains('hide')) return; | ||
| 339 | + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { | ||
| 340 | + e.preventDefault(); | ||
| 341 | + var total = state.items.length; | ||
| 342 | + if (!total) return; | ||
| 343 | + state.active = e.key === 'ArrowDown' | ||
| 344 | + ? (state.active + 1) % total | ||
| 345 | + : (state.active - 1 + total) % total; | ||
| 346 | + highlight(); | ||
| 347 | + } else if (e.key === 'Enter') { | ||
| 348 | + e.preventDefault(); | ||
| 349 | + if (state.active >= 0 && state.items[state.active]) { | ||
| 350 | + pick(state.items[state.active]); | ||
| 351 | + } | ||
| 352 | + } else if (e.key === 'Escape') { | ||
| 353 | + closeDropdown(); | ||
| 354 | + } | ||
| 355 | + }); | ||
| 356 | + | ||
| 357 | + el.keyword.addEventListener('focus', function () { | ||
| 358 | + if (el.keyword.value.trim() && state.items.length) { | ||
| 359 | + el.dropdown.classList.remove('hide'); | ||
| 360 | + } | ||
| 361 | + }); | ||
| 362 | + | ||
| 363 | + document.addEventListener('click', function (e) { | ||
| 364 | + if (!document.getElementById('searchWrap').contains(e.target)) { | ||
| 365 | + closeDropdown(); | ||
| 366 | + } | ||
| 367 | + }); | ||
| 368 | + | ||
| 369 | + function doSearch(keyword) { | ||
| 370 | + var seq = ++state.seq; | ||
| 371 | + el.dropdown.classList.remove('hide'); | ||
| 372 | + el.dropdown.innerHTML = '<div class="dd-empty">搜索中…</div>'; | ||
| 373 | + | ||
| 374 | + api(API.projects, {keyword: keyword}).then(function (data) { | ||
| 375 | + if (seq !== state.seq) return; | ||
| 376 | + state.items = data.item || []; | ||
| 377 | + state.active = state.items.length ? 0 : -1; | ||
| 378 | + renderDropdown(); | ||
| 379 | + }).catch(function (err) { | ||
| 380 | + if (seq !== state.seq) return; | ||
| 381 | + state.items = []; | ||
| 382 | + el.dropdown.innerHTML = '<div class="dd-empty">' + esc(err.message) + '</div>'; | ||
| 383 | + }); | ||
| 384 | + } | ||
| 385 | + | ||
| 386 | + function renderDropdown() { | ||
| 387 | + if (!state.items.length) { | ||
| 388 | + el.dropdown.innerHTML = '<div class="dd-empty">没有匹配的公司项目</div>'; | ||
| 389 | + el.dropdown.classList.remove('hide'); | ||
| 390 | + return; | ||
| 391 | + } | ||
| 392 | + | ||
| 393 | + var html = ''; | ||
| 394 | + state.items.forEach(function (item, i) { | ||
| 395 | + html += '<div class="dd-item' + (i === state.active ? ' on' : '') + '" data-i="' + i + '">' | ||
| 396 | + + '<div class="dd-main">' | ||
| 397 | + + '<div class="dd-company">' + esc(item.company) + '</div>' | ||
| 398 | + + '<div class="dd-sub"><span>postid ' + item.postid + '</span>' | ||
| 399 | + + (item.plan ? '<span>' + esc(item.plan) + '</span>' : '') | ||
| 400 | + + '</div></div>' | ||
| 401 | + + '<div class="dd-bind">绑定邮箱 <b>' + item.bind + '</b></div>' | ||
| 402 | + + '</div>'; | ||
| 403 | + }); | ||
| 404 | + | ||
| 405 | + el.dropdown.innerHTML = html; | ||
| 406 | + el.dropdown.classList.remove('hide'); | ||
| 407 | + | ||
| 408 | + [].forEach.call(el.dropdown.querySelectorAll('.dd-item'), function (node) { | ||
| 409 | + node.addEventListener('click', function () { | ||
| 410 | + pick(state.items[parseInt(node.getAttribute('data-i'), 10)]); | ||
| 411 | + }); | ||
| 412 | + }); | ||
| 413 | + } | ||
| 414 | + | ||
| 415 | + function highlight() { | ||
| 416 | + [].forEach.call(el.dropdown.querySelectorAll('.dd-item'), function (node, i) { | ||
| 417 | + node.classList.toggle('on', i === state.active); | ||
| 418 | + }); | ||
| 419 | + var on = el.dropdown.querySelector('.dd-item.on'); | ||
| 420 | + if (on && on.scrollIntoView) on.scrollIntoView({block: 'nearest'}); | ||
| 421 | + } | ||
| 422 | + | ||
| 423 | + function closeDropdown() { | ||
| 424 | + el.dropdown.classList.add('hide'); | ||
| 425 | + } | ||
| 426 | + | ||
| 427 | + function pick(item) { | ||
| 428 | + if (!item) return; | ||
| 429 | + state.project = item; | ||
| 430 | + closeDropdown(); | ||
| 431 | + el.keyword.value = ''; | ||
| 432 | + el.keyword.blur(); | ||
| 433 | + | ||
| 434 | + el.picked.classList.remove('hide'); | ||
| 435 | + el.pickedCompany.textContent = item.company; | ||
| 436 | + el.pickedPostid.textContent = 'postid ' + item.postid; | ||
| 437 | + el.pickedPlan.textContent = item.plan || ''; | ||
| 438 | + el.pickedPlan.style.display = item.plan ? '' : 'none'; | ||
| 439 | + el.pickedBind.textContent = '绑定邮箱 ' + item.bind; | ||
| 440 | + | ||
| 441 | + el.start.disabled = false; | ||
| 442 | + el.result.classList.add('hide'); | ||
| 443 | + refreshCount(); | ||
| 444 | + } | ||
| 445 | + | ||
| 446 | + el.clearProject.addEventListener('click', function () { | ||
| 447 | + state.project = null; | ||
| 448 | + el.picked.classList.add('hide'); | ||
| 449 | + el.start.disabled = true; | ||
| 450 | + el.hit.textContent = '选择公司项目后可查看命中数量'; | ||
| 451 | + el.keyword.focus(); | ||
| 452 | + }); | ||
| 453 | + | ||
| 454 | + /* ---------- 条件 / 数量 ---------- */ | ||
| 455 | + [].forEach.call(el.scopes.querySelectorAll('.scope'), function (node) { | ||
| 456 | + node.addEventListener('click', function () { | ||
| 457 | + [].forEach.call(el.scopes.querySelectorAll('.scope'), function (n) { | ||
| 458 | + n.classList.remove('on'); | ||
| 459 | + }); | ||
| 460 | + node.classList.add('on'); | ||
| 461 | + state.scope = node.getAttribute('data-scope'); | ||
| 462 | + refreshCount(); | ||
| 463 | + }); | ||
| 464 | + }); | ||
| 465 | + | ||
| 466 | + el.limit.addEventListener('input', function () { | ||
| 467 | + if (state.project && state.count) refreshCount(true); | ||
| 468 | + }); | ||
| 469 | + | ||
| 470 | + function refreshCount(silent) { | ||
| 471 | + if (!state.project) return; | ||
| 472 | + if (!silent) el.hit.textContent = '正在统计命中数量…'; | ||
| 473 | + | ||
| 474 | + api(API.count, { | ||
| 475 | + postid: state.project.postid, | ||
| 476 | + scope: state.scope | ||
| 477 | + }).then(function (data) { | ||
| 478 | + state.count = Number(data.count); | ||
| 479 | + if (isNaN(state.count)) state.count = 0; | ||
| 480 | + var limit = parseInt(el.limit.value, 10) || 0; | ||
| 481 | + | ||
| 482 | + if (state.count < 0) { | ||
| 483 | + el.hit.innerHTML = '<b class="zero">统计失败</b>,es 查询异常,可直接尝试导出'; | ||
| 484 | + el.start.disabled = false; | ||
| 485 | + return; | ||
| 486 | + } | ||
| 487 | + if (!state.count) { | ||
| 488 | + el.hit.innerHTML = '<b class="zero">0</b> 封命中,当前条件没有可导出的邮件'; | ||
| 489 | + } else if (limit && state.count > limit) { | ||
| 490 | + el.hit.innerHTML = '命中 <b>' + state.count + '</b> 封,本次导出最新 <b>' + limit + '</b> 封'; | ||
| 491 | + } else { | ||
| 492 | + el.hit.innerHTML = '命中 <b>' + state.count + '</b> 封,全部导出'; | ||
| 493 | + } | ||
| 494 | + | ||
| 495 | + el.start.disabled = !state.count || state.running; | ||
| 496 | + }).catch(function (err) { | ||
| 497 | + el.hit.textContent = '统计失败:' + err.message; | ||
| 498 | + el.start.disabled = false; | ||
| 499 | + }); | ||
| 500 | + } | ||
| 501 | + | ||
| 502 | + /* ---------- 开始导出 ---------- */ | ||
| 503 | + el.start.addEventListener('click', function () { | ||
| 504 | + if (!state.project || state.running) return; | ||
| 505 | + | ||
| 506 | + var limit = parseInt(el.limit.value, 10); | ||
| 507 | + if (!limit || limit < 1) { | ||
| 508 | + alert('请填写本次最多导出的数量'); | ||
| 509 | + return; | ||
| 510 | + } | ||
| 511 | + if (limit > 2000) { | ||
| 512 | + alert('单次最多导出 2000 封,请调小数量或分批导出'); | ||
| 513 | + return; | ||
| 514 | + } | ||
| 515 | + | ||
| 516 | + runExport(state.project, state.scope, limit); | ||
| 517 | + }); | ||
| 518 | + | ||
| 519 | + function runExport(project, scope, limit) { | ||
| 520 | + state.running = true; | ||
| 521 | + el.start.disabled = true; | ||
| 522 | + el.result.classList.add('hide'); | ||
| 523 | + | ||
| 524 | + el.maskText.textContent = '正在导出「' + project.company + '」的邮件…'; | ||
| 525 | + el.mask.classList.remove('hide'); | ||
| 526 | + | ||
| 527 | + var begin = Date.now(); | ||
| 528 | + state.timer = setInterval(function () { | ||
| 529 | + el.maskSub.textContent = '已用时 ' + Math.floor((Date.now() - begin) / 1000) + 's'; | ||
| 530 | + }, 1000); | ||
| 531 | + el.maskSub.textContent = '已用时 0s'; | ||
| 532 | + | ||
| 533 | + api(API.start, { | ||
| 534 | + postid: project.postid, | ||
| 535 | + scope: scope, | ||
| 536 | + limit: limit | ||
| 537 | + }, 'POST').then(function (data) { | ||
| 538 | + showResult(data); | ||
| 539 | + refreshCount(true); | ||
| 540 | + }).catch(function (err) { | ||
| 541 | + alert(err.message); | ||
| 542 | + }).then(function () { | ||
| 543 | + stopTimer(); | ||
| 544 | + state.running = false; | ||
| 545 | + el.start.disabled = !state.count; | ||
| 546 | + }); | ||
| 547 | + } | ||
| 548 | + | ||
| 549 | + function stopTimer() { | ||
| 550 | + if (state.timer) clearInterval(state.timer); | ||
| 551 | + state.timer = null; | ||
| 552 | + el.mask.classList.add('hide'); | ||
| 553 | + } | ||
| 554 | + | ||
| 555 | + function showResult(data) { | ||
| 556 | + document.getElementById('resCompany').textContent = (data.company || '') + ' · ' + (data.scope_label || ''); | ||
| 557 | + document.getElementById('resTotal').textContent = data.total || 0; | ||
| 558 | + document.getElementById('resSuccess').textContent = data.success || 0; | ||
| 559 | + document.getElementById('resFail').textContent = data.fail || 0; | ||
| 560 | + document.getElementById('resCost').textContent = (data.cost || 0) + 's'; | ||
| 561 | + document.getElementById('resSize').textContent = sizeText(data.zip_size); | ||
| 562 | + document.getElementById('resDir').textContent = data.dir || ''; | ||
| 563 | + | ||
| 564 | + var dl = document.getElementById('resDownload'); | ||
| 565 | + dl.setAttribute('href', data.download || '#'); | ||
| 566 | + dl.setAttribute('download', data.zip_name || ''); | ||
| 567 | + | ||
| 568 | + var errors = data.errors || []; | ||
| 569 | + document.getElementById('errBox').classList.toggle('hide', !errors.length); | ||
| 570 | + | ||
| 571 | + var body = document.getElementById('errBody'); | ||
| 572 | + body.innerHTML = ''; | ||
| 573 | + errors.forEach(function (item) { | ||
| 574 | + var tr = document.createElement('tr'); | ||
| 575 | + tr.innerHTML = '<td>' + esc(item.subject || '(无主题)') + '</td>' | ||
| 576 | + + '<td class="sub">' + esc(item.uuid || '') + '</td>' | ||
| 577 | + + '<td class="sub">' + esc(item.message || '') + '</td>'; | ||
| 578 | + body.appendChild(tr); | ||
| 579 | + }); | ||
| 580 | + | ||
| 581 | + el.result.classList.remove('hide'); | ||
| 582 | + el.result.scrollIntoView({behavior: 'smooth', block: 'start'}); | ||
| 583 | + } | ||
| 584 | + | ||
| 585 | + el.keyword.focus(); | ||
| 586 | +})(); | ||
| 587 | +</script> | ||
| 588 | +</body> | ||
| 589 | +</html> |
-
请 注册 或 登录 后发表评论