feat(mp, web, calendar): 新增微信收藏夹与日历批量生成功能
- 新增微信小程序版个人收藏夹全套功能,包含聚合首页、收藏列表、编辑、详情页面与配套后端API - 新增web版时光助手门户与个人收藏夹工具,支持与小程序共享后端数据 - 优化日历提醒工具:替换本地bootstrap资源为公共CDN版本,新增批量生成循环提醒的前端界面与后端逻辑,新增数据库batch_id字段,更新鉴权配置文件 - 补充项目配置、站点地图、调试诊断脚本等配套文件 - 完善各模块的页面配置与样式文件
This commit is contained in:
@@ -1,7 +1,15 @@
|
||||
{
|
||||
"_comment": "日程提醒工具的前端鉴权配置",
|
||||
"enable_auth_comment": "true:启用鉴权(需要导航/登录获得 cookie);false:完全放开访问",
|
||||
"enable_auth_comment": "true:启用 tool 外层鉴权(通过九宫格导航或合法 GUID/token 进入,设置 calendar_reminder_gate cookie);false:完全放开访问",
|
||||
"enable_auth": true,
|
||||
"max_age_days_comment": "鉴权 cookie 的最大有效天数(1~365),到期后需要重新登录",
|
||||
"max_age_days": 30
|
||||
}
|
||||
"max_age_days_comment": "鉴权 cookie 的最大有效天数(1~365),到期后需要重新从九宫格或合法入口进入",
|
||||
"max_age_days": 30,
|
||||
"cookieName_comment": "外层 gate cookie 名,需与 src/server/index.js 中的 CALENDAR_AUTH_COOKIE 保持一致",
|
||||
"cookieName": "calendar_reminder_gate",
|
||||
"requireGuid_comment": "true:强制 URL 带 GUID 参数才能访问;false:允许仅凭已有 gate cookie 或导航 token 访问",
|
||||
"requireGuid": false,
|
||||
"guidParam_comment": "URL 中 GUID 参数名(requireGuid=true 时生效)",
|
||||
"guidParam": "guid",
|
||||
"guidValue_comment": "GUID 参数真值(留空表示不启用 GUID;严禁在此写真实生产 GUID,生产值来自本机私有配置)",
|
||||
"guidValue": ""
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>日历批量生成工具 · 独立窗口</title>
|
||||
<link rel="icon" href="calendar.svg" type="image/svg+xml" />
|
||||
<link rel="stylesheet" href="/lib/bootstrap.min.css?v=1.6">
|
||||
<link rel="stylesheet" href="css/styles.css?v=1.6">
|
||||
</head>
|
||||
<body class="bg-light">
|
||||
<header class="navbar navbar-dark bg-warning sticky-top">
|
||||
<div class="container-fluid">
|
||||
<span class="navbar-brand mb-0 h1">🧺 日历批量生成工具 · 仅循环场景</span>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<button id="btnBackToMain" class="btn btn-light btn-sm" title="回到日历主界面">🗓 返回主日历</button>
|
||||
<button id="btnReload" class="btn btn-light btn-sm" title="刷新批次列表">⟳ 刷新</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="container py-3" style="max-width:1280px">
|
||||
<ul class="nav nav-tabs mb-3" id="batchTabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="tab-generator" data-bs-toggle="tab" data-bs-target="#panel-generator" type="button">生成器</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="tab-batches" data-bs-toggle="tab" data-bs-target="#panel-batches" type="button" onclick="CalendarBatchTool && CalendarBatchTool.loadBatches && CalendarBatchTool.loadBatches()">批次管理</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="tab-sql" data-bs-toggle="tab" data-bs-target="#panel-sql" type="button">🧪 SQL 执行</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="tab-content" id="batchTabsContent">
|
||||
<div class="tab-pane fade show active" id="panel-generator" role="tabpanel">
|
||||
<form id="batchForm" class="vstack gap-3">
|
||||
<div class="row g-2">
|
||||
<div class="col-8">
|
||||
<label class="form-label">批次名称(可选)</label>
|
||||
<input type="text" id="batch_name" class="form-control" placeholder="如:2026Q4 洗被子提醒">
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<label class="form-label">循环模式</label>
|
||||
<select id="batch_mode" class="form-select">
|
||||
<option value="monthlyByDay" selected>每月 N 号(推荐)</option>
|
||||
<option value="monthlyByNthWeekday">每月第 N 周星期 X</option>
|
||||
<option value="weekly">每 N 周 · 星期多选</option>
|
||||
<option value="daily">每 N 天</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-2">
|
||||
<div class="col-3"><label class="form-label">时间范围 · 起</label><input type="date" id="batch_rangeStart" class="form-control" required></div>
|
||||
<div class="col-3"><label class="form-label">时间范围 · 止</label><input type="date" id="batch_rangeEnd" class="form-control" required></div>
|
||||
<div class="col-6 d-flex align-items-end gap-3">
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" id="batch_allDay">
|
||||
<label class="form-check-label" for="batch_allDay">全天(忽略时分)</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" id="batch_skipExternal">
|
||||
<label class="form-check-label" for="batch_skipExternal">不联动周报下游</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" id="batch_completed">
|
||||
<label class="form-check-label" for="batch_completed">全部标记已完成</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mode-fieldset" data-mode="daily">
|
||||
<div class="row g-2"><div class="col-4">
|
||||
<label class="form-label">每 _ 天</label>
|
||||
<input type="number" min="1" value="1" id="batch_dailyEvery" class="form-control">
|
||||
</div></div>
|
||||
</div>
|
||||
<div class="mode-fieldset" data-mode="weekly" style="display:none">
|
||||
<div class="row g-2">
|
||||
<div class="col-4"><label class="form-label">每 _ 周</label><input type="number" min="1" value="1" id="batch_weeklyEvery" class="form-control"></div>
|
||||
<div class="col-8">
|
||||
<label class="form-label">星期(可多选)</label>
|
||||
<div id="batch_weekdays" class="hstack gap-3 flex-wrap">
|
||||
<label class="form-check"><input type="checkbox" value="0" class="form-check-input batch-wd">周日</label>
|
||||
<label class="form-check"><input type="checkbox" value="1" class="form-check-input batch-wd">周一</label>
|
||||
<label class="form-check"><input type="checkbox" value="2" class="form-check-input batch-wd">周二</label>
|
||||
<label class="form-check"><input type="checkbox" value="3" class="form-check-input batch-wd">周三</label>
|
||||
<label class="form-check"><input type="checkbox" value="4" class="form-check-input batch-wd">周四</label>
|
||||
<label class="form-check"><input type="checkbox" value="5" class="form-check-input batch-wd">周五</label>
|
||||
<label class="form-check"><input type="checkbox" value="6" class="form-check-input batch-wd">周六</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mode-fieldset" data-mode="monthlyByDay">
|
||||
<div class="row g-2">
|
||||
<div class="col-4"><label class="form-label">每 _ 月</label><input type="number" min="1" value="2" id="batch_monthlyEvery" class="form-control"></div>
|
||||
<div class="col-8">
|
||||
<label class="form-label">每月哪些号(支持 1-5,15,25-28 混合写法)</label>
|
||||
<input type="text" id="batch_monthlyDays" class="form-control" value="1-5" placeholder="例如:1-5,15,25-28">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mode-fieldset" data-mode="monthlyByNthWeekday" style="display:none">
|
||||
<div class="row g-2">
|
||||
<div class="col-4"><label class="form-label">每 _ 月</label><input type="number" min="1" value="1" id="batch_monthlyEvery2" class="form-control"></div>
|
||||
<div class="col-4"><label class="form-label">第几周</label>
|
||||
<select id="batch_nthWeek" class="form-select"><option>1</option><option>2</option><option selected>3</option><option>4</option><option>5</option></select>
|
||||
</div>
|
||||
<div class="col-4"><label class="form-label">星期</label>
|
||||
<select id="batch_nthWeekday" class="form-select">
|
||||
<option value="0">周日</option><option value="1">周一</option><option value="2">周二</option>
|
||||
<option value="3">周三</option><option value="4">周四</option><option value="5">周五</option><option value="6">周六</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-2">
|
||||
<div class="col-4"><label class="form-label">开始时分</label><input type="time" id="batch_startTime" class="form-control" value="09:00"></div>
|
||||
<div class="col-4"><label class="form-label">结束时分(留空=不设)</label><input type="time" id="batch_endTime" class="form-control" value="10:00"></div>
|
||||
<div class="col-4"><label class="form-label">提醒时分(留空=不提醒)</label><input type="time" id="batch_remindTime" class="form-control" value="08:30"></div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">标题模板(支持 {date} {yyyy} {MM} {dd} {W})</label>
|
||||
<input type="text" id="batch_title" class="form-control" value="洗被子提醒({MM}-{dd})" required maxlength="150" placeholder="如:洗被子提醒({MM}-{dd})">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">内容模板(同上占位符)</label>
|
||||
<textarea id="batch_content" class="form-control" rows="3" placeholder="写备注,支持 {date} 占位符"></textarea>
|
||||
</div>
|
||||
<div class="hstack gap-2">
|
||||
<button type="button" id="batch_btnPreview" class="btn btn-outline-primary">👁 预览 SQL</button>
|
||||
<button type="button" id="batch_btnApply" class="btn btn-primary" disabled>▶ 直接入库</button>
|
||||
<button type="button" id="batch_btnDownload" class="btn btn-outline-secondary" disabled>💾 下载 SQL</button>
|
||||
<span id="batch_previewHint" class="text-muted small ms-2">请先点「预览 SQL」再入库</span>
|
||||
</div>
|
||||
</form>
|
||||
<div id="batch_previewArea" class="mt-3 d-none">
|
||||
<div class="card">
|
||||
<div class="card-header small">预览信息</div>
|
||||
<div class="card-body small">
|
||||
<div id="batch_previewStats" class="mb-2"></div>
|
||||
<div class="mb-2"><span class="text-danger fw-bold" id="batch_preExisting"></span></div>
|
||||
<div class="mb-2"><strong>样例(前 20 条):</strong></div>
|
||||
<table class="table table-sm table-hover" id="batch_sampleTable"><thead><tr><th>开始</th><th>结束</th><th>提醒</th><th>标题</th></tr></thead><tbody></tbody></table>
|
||||
<div class="mb-2"><strong>SQL(前 503 行,完整的请下载文件):</strong></div>
|
||||
<textarea id="batch_sqlText" readonly rows="14" class="form-control font-monospace small"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="panel-batches" role="tabpanel">
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<div style="width:360px"><input id="batch_search" class="form-control form-control-sm" placeholder="搜索标题 / 模式…(输入即搜)"></div>
|
||||
<button type="button" id="batch_refresh" class="btn btn-outline-secondary btn-sm">⟳ 刷新批次</button>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover align-middle" id="batch_listTable">
|
||||
<thead><tr>
|
||||
<th>批次名</th><th>模式</th><th>时间范围</th>
|
||||
<th>生成 / 入库 / 去重 / 已回滚</th>
|
||||
<th>状态</th><th>创建时间</th><th>操作</th>
|
||||
</tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="panel-sql" role="tabpanel">
|
||||
<form class="vstack gap-3">
|
||||
<div class="row g-2">
|
||||
<div class="col-6">
|
||||
<label class="form-label">批次名称(必填,便于后面批次管理/回滚查找)</label>
|
||||
<input type="text" id="sql_batch_name" class="form-control" placeholder="如:2026-09-02 手动导入客户提醒 SQL">
|
||||
</div>
|
||||
<div class="col-6 d-flex align-items-end gap-3">
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" id="sql_skipExternal" checked>
|
||||
<label class="form-check-label" for="sql_skipExternal">不联动周报下游(默认勾,避免爆量)</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" id="sql_completed">
|
||||
<label class="form-check-label" for="sql_completed">全部标记已完成</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">SQL 文本框(只支持 <code>INSERT INTO events (...) VALUES (...)</code>;单条 / 多条 / 多个语句用 ; 分开都行;每行 events 字段允许:id/title/content/start/end/allDay/remindAt/recurrence/createdAt/updatedAt/completed/deleted/batch_id/completedAt/deletedAt,其他字段一律被拒绝;禁止 UPDATE / DELETE / DROP / ALTER)</label>
|
||||
<textarea id="sql_textarea" rows="22" class="form-control font-monospace small" placeholder="INSERT INTO events (title,content,start,end,allDay,remindAt,completed,deleted,batch_id) VALUES
|
||||
('客户A回访','准备季度会议','2026-09-03 09:00','2026-09-03 10:00',0,'2026-09-03 08:30',0,0,NULL),
|
||||
('客户B回访','报价最终版本','2026-09-05 14:00','2026-09-05 15:00',0,'2026-09-05 13:30',0,0,NULL);
|
||||
"></textarea>
|
||||
</div>
|
||||
<div class="hstack gap-2">
|
||||
<button type="button" id="sql_btnCheck" class="btn btn-outline-info">🧪 SQL 语句检查</button>
|
||||
<button type="button" id="sql_btnApply" class="btn btn-warning" disabled>▶ 执行 SQL 入库(生成批次,可回滚)</button>
|
||||
<button type="button" id="sql_btnDownload" class="btn btn-outline-secondary">💾 下载 SQL</button>
|
||||
<span id="sql_hint" class="text-muted small ms-2">建议先点「检查」确认语法/重复后再执行</span>
|
||||
</div>
|
||||
<div id="sql_checkArea" class="d-none mt-3">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span>🧪 检查结果</span>
|
||||
<span id="sql_checkSummary" class="badge bg-light"></span>
|
||||
</div>
|
||||
<div class="card-body small">
|
||||
<div id="sql_checkMeta" class="mb-2"></div>
|
||||
<div id="sql_checkForbidden" class="d-none text-danger mb-2"></div>
|
||||
<div id="sql_checkSyntaxErrors" class="d-none text-danger mb-2"></div>
|
||||
<div id="sql_checkColErrors" class="d-none text-danger mb-2"></div>
|
||||
<div id="sql_checkDuplicate" class="d-none text-warning mb-2">
|
||||
<span class="fw-bold">⚠️ 检测到重复(宽松模式):</span>
|
||||
<button id="sql_dupDetail" class="btn btn-outline-warning btn-sm ms-2">查看重复明细…</button>
|
||||
</div>
|
||||
<div><strong>样例行(前 15 条):</strong></div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover"><thead><tr><th>语句#</th><th>行#</th><th>标题</th><th>开始</th><th>结束</th></tr></thead><tbody id="sql_checkSampleTbody"></tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div class="modal fade" id="batchDetailModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-xl modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header"><h5 class="modal-title">批次详情</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
|
||||
<div class="modal-body">
|
||||
<div id="batch_detailBody"></div>
|
||||
</div>
|
||||
<div class="modal-footer"><button class="btn btn-secondary" data-bs-dismiss="modal">关闭</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="sqlDuplicateModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-xl modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">⚠️ SQL 批次检测到重复(宽松模式)</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body small">
|
||||
<div id="sql_dupSummary" class="mb-2"></div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover">
|
||||
<thead><tr><th>你的 SQL 语句#-行#</th><th>你的标题</th><th>你的开始</th><th>匹配库内事件标题</th><th>匹配库内开始</th><th>相似度</th><th>操作</th></tr></thead>
|
||||
<tbody id="sql_dupTbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" id="sql_dupCancel">先不执行,回去改 SQL</button>
|
||||
<button type="button" class="btn btn-warning" data-bs-dismiss="modal" id="sql_dupConfirm">我已知悉重复,继续执行生成批次</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="js/config.js"></script>
|
||||
<script src="/lib/bootstrap.bundle.min.js"></script>
|
||||
<script src="js/batch.js?v=1.7"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,52 +0,0 @@
|
||||
*{box-sizing:border-box}
|
||||
html{font-size:14px}
|
||||
body{margin:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,'Noto Sans','Liberation Sans',sans-serif;line-height:1.5;color:#212529;overflow-x:hidden}
|
||||
.container{width:100%;max-width:1140px;margin:0 auto;padding-left:12px;padding-right:12px}
|
||||
.container-fluid{width:100%;padding-left:12px;padding-right:12px}
|
||||
.navbar{display:flex;align-items:center;padding:.5rem 0}
|
||||
.navbar{flex-wrap:wrap}
|
||||
.navbar .container-fluid{display:flex;align-items:center;justify-content:center;gap:.75rem}
|
||||
.bg-primary{background-color:#0d6efd}
|
||||
.bg-dark{background-color:#2b2b2b}
|
||||
.navbar-dark{color:#fff}
|
||||
.navbar-dark .navbar-brand{color:#fff}
|
||||
.navbar .navbar-brand{font-size:1.25rem;font-weight:500}
|
||||
.text-white{color:#fff}
|
||||
.text-muted{color:#6c757d}
|
||||
.small{font-size:.875em}
|
||||
.btn{display:inline-block;font-weight:400;line-height:1.5;color:#212529;text-align:center;text-decoration:none;vertical-align:middle;cursor:pointer;user-select:none;background-color:#f8f9fa;border:1px solid rgba(0,0,0,.125);padding:.375rem .75rem;border-radius:.375rem;transition:background-color .15s ease}
|
||||
.btn:disabled{opacity:.65}
|
||||
.btn:hover{background-color:#e9ecef}
|
||||
.btn-light{color:#212529;background-color:#f8f9fa;border-color:#ced4da}
|
||||
.btn-secondary{color:#fff;background-color:#6c757d;border-color:#6c757d}
|
||||
.btn-danger{color:#fff;background-color:#dc3545;border-color:#dc3545}
|
||||
.btn-primary{color:#fff;background-color:#0d6efd;border-color:#0d6efd}
|
||||
.btn-sm{padding:.25rem .5rem;font-size:.875rem;border-radius:.25rem}
|
||||
.card{display:block;background-color:#fff;border:1px solid rgba(0,0,0,.125);border-radius:.5rem}
|
||||
.card-header{padding:.5rem 1rem;border-bottom:1px solid rgba(0,0,0,.125);background-color:#f8f9fa;font-weight:500}
|
||||
.card-body{padding:1rem}
|
||||
.row{display:flex;flex-wrap:wrap}
|
||||
.row.g-2{gap:.5rem}
|
||||
.col-12{flex:0 0 auto;width:100%}
|
||||
.col-6{flex:0 0 auto;width:50%}
|
||||
@media (min-width:992px){.col-lg-8{flex:0 0 auto;width:66.6667%}.col-lg-4{flex:0 0 auto;width:33.3333%}}
|
||||
.form-label{margin-bottom:.25rem;font-weight:500}
|
||||
.form-control,.form-select{display:block;width:100%;padding:.5rem .75rem;font-size:1rem;line-height:1.5;color:#212529;background-color:#fff;background-clip:padding-box;border:1px solid #ced4da;border-radius:.375rem}
|
||||
.form-control:focus,.form-select:focus{outline:none;border-color:#86b7fe;box-shadow:0 0 0 .2rem rgba(13,110,253,.25)}
|
||||
.form-control::-webkit-input-placeholder{color:#6c757d}
|
||||
.form-control::-moz-placeholder{color:#6c757d}
|
||||
input[type=datetime-local],input[type=time]{padding-right:2.25rem}
|
||||
.card{box-shadow:0 8px 24px rgba(0,0,0,.08)}
|
||||
.list-group{display:block;border-radius:.5rem;overflow:hidden}
|
||||
.list-group .list-group-item{display:block;padding:.5rem 1rem;background-color:#fff;border-bottom:1px solid rgba(0,0,0,.125)}
|
||||
.list-group-flush .list-group-item{border-left:0;border-right:0}
|
||||
.hstack{display:flex;align-items:center}
|
||||
.justify-content-between{justify-content:space-between}
|
||||
.gap-2{gap:.5rem}
|
||||
.mt-2{margin-top:.5rem}
|
||||
.mb-2{margin-bottom:.5rem}
|
||||
.mb-0{margin-bottom:0}
|
||||
.py-3{padding-top:1rem;padding-bottom:1rem}
|
||||
.d-flex{display:flex}
|
||||
.align-items-center{align-items:center}
|
||||
.d-none{display:none!important}
|
||||
@@ -678,3 +678,25 @@ main {
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Batch tool (批量生成) */
|
||||
#btnOpenBatchTool { font-weight: 600; }
|
||||
.mode-fieldset {
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px 6px;
|
||||
background: #fbfcfd;
|
||||
}
|
||||
#batch_sqlText {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
white-space: pre;
|
||||
background: #1f2937;
|
||||
color: #f8fafc;
|
||||
border-color: #111827;
|
||||
}
|
||||
#batch_sampleTable { font-size: 12px; }
|
||||
#batch_listTable .badge { font-size: 11px; }
|
||||
.batch-detail-diag { margin-top: 0.5rem; display: flex; flex-wrap: wrap; gap: 0.4rem; }
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
<title>日历提醒系统 · Web</title>
|
||||
<link rel="manifest" href="manifest.json">
|
||||
<link rel="icon" href="calendar.svg" type="image/svg+xml" />
|
||||
<link rel="stylesheet" href="css/bootstrap.local.css">
|
||||
<link rel="stylesheet" href="css/styles.css?v=1.1">
|
||||
<link rel="stylesheet" href="/lib/bootstrap.min.css?v=1.6">
|
||||
<link rel="stylesheet" href="css/styles.css?v=1.6">
|
||||
<script src="js/background.js?v=1.1" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
@@ -19,6 +19,7 @@
|
||||
<button id="btnPrev" class="btn btn-light btn-sm">上一月</button>
|
||||
<div id="currentMonth" class="text-white"></div>
|
||||
<button id="btnNext" class="btn btn-light btn-sm">下一月</button>
|
||||
<button id="btnOpenBatchTool" class="btn btn-warning btn-sm" title="批量生成循环提醒(展开到每天)· 新窗口打开" target="_blank" rel="noopener">🧺 批量生成</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -127,11 +128,11 @@
|
||||
</footer>
|
||||
|
||||
<script src="js/config.js"></script>
|
||||
<script src="/assets/auth.js"></script>
|
||||
<script src="js/db.js"></script>
|
||||
<script src="js/models.js?v=1.1"></script>
|
||||
<script src="js/sync.js?v=1.1"></script>
|
||||
<script src="js/ui.js?v=1.3"></script>
|
||||
<script src="js/open_batch.js?v=1.6"></script>
|
||||
<script src="js/main.js?v=1.3"></script>
|
||||
<script src="/lib/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -0,0 +1,495 @@
|
||||
(function () {
|
||||
const $ = (id) => document.getElementById(id)
|
||||
const api = (path, opts = {}) => fetch(`/api/calendar_reminder/${path.replace(/^\//, '')}`, Object.assign({ credentials: 'same-origin', headers: { 'Content-Type': 'application/json' } }, opts, { body: (opts.body && typeof opts.body !== 'string') ? JSON.stringify(opts.body) : opts.body }))
|
||||
const copy = async (text) => {
|
||||
try { await navigator.clipboard.writeText(text); return true } catch { return false }
|
||||
}
|
||||
const setBtn = (el, loading, text) => { if (!el) return; if (loading) { el.disabled = true; el.dataset.old = el.textContent; el.textContent = text || el.textContent + ' 中…' } else { el.disabled = false; if (el.dataset.old) el.textContent = el.dataset.old } }
|
||||
const toastNode = (msg, type) => {
|
||||
if (typeof msg !== 'string' || !msg) return
|
||||
const div = document.createElement('div')
|
||||
const colorMap = { success: '#d1e7dd', error: '#f8d7da', info: '#cff4fc', warn: '#fff3cd' }
|
||||
const fgMap = { success: '#0f5132', error: '#842029', info: '#055160', warn: '#664d03' }
|
||||
const bg = colorMap[type || 'info'] || colorMap.info
|
||||
const fg = fgMap[type || 'info'] || fgMap.info
|
||||
div.textContent = msg
|
||||
div.style.cssText = `position:fixed;top:64px;right:24px;z-index:99999;padding:8px 14px;border-radius:8px;box-shadow:0 4px 18px rgba(0,0,0,0.18);background:${bg};color:${fg};font-size:13px;max-width:420px;animation:cbFadeIn 0.18s ease-out;`
|
||||
if (!document.getElementById('cbToastStyles')) {
|
||||
const s = document.createElement('style'); s.id = 'cbToastStyles'
|
||||
s.textContent = `@keyframes cbFadeIn { from { transform: translateY(-6px); opacity: 0;} to { transform: translateY(0); opacity:1; } }`
|
||||
document.head.appendChild(s)
|
||||
}
|
||||
document.body.appendChild(div)
|
||||
setTimeout(() => { div.style.transition = 'opacity 0.2s'; div.style.opacity = '0'; setTimeout(() => div.remove(), 240) }, 2200)
|
||||
}
|
||||
const toast = (msg, type) => {
|
||||
try {
|
||||
if (window.CAL && window.CAL.toast) window.CAL.toast(msg, type)
|
||||
else toastNode(msg, type)
|
||||
} catch { toastNode(msg, type) }
|
||||
}
|
||||
const readForm = () => {
|
||||
const mode = $('batch_mode').value
|
||||
const weekdays = [...document.querySelectorAll('.batch-wd:checked')].map(c => Number(c.value))
|
||||
const params = {
|
||||
batchName: $('batch_name').value.trim(),
|
||||
mode,
|
||||
rangeStart: $('batch_rangeStart').value,
|
||||
rangeEnd: $('batch_rangeEnd').value,
|
||||
allDay: $('batch_allDay').checked,
|
||||
completed: $('batch_completed').checked,
|
||||
startTime: $('batch_startTime').value || '',
|
||||
endTime: $('batch_endTime').value || '',
|
||||
remindTime: $('batch_remindTime').value || '',
|
||||
title: $('batch_title').value.trim(),
|
||||
content: $('batch_content').value
|
||||
}
|
||||
if (mode === 'daily') params.dailyEvery = Number($('batch_dailyEvery').value || 1)
|
||||
if (mode === 'weekly') { params.weeklyEvery = Number($('batch_weeklyEvery').value || 1); params.weekdays = weekdays }
|
||||
if (mode === 'monthlyByDay') { params.monthlyEvery = Number($('batch_monthlyEvery').value || 1); params.monthlyDays = $('batch_monthlyDays').value.trim() }
|
||||
if (mode === 'monthlyByNthWeekday') { params.monthlyEvery2 = Number($('batch_monthlyEvery2').value || 1); params.nthWeek = Number($('batch_nthWeek').value || 3); params.nthWeekday = Number($('batch_nthWeekday').value || 0) }
|
||||
return params
|
||||
}
|
||||
const fillForm = (params) => {
|
||||
if (!params) return
|
||||
const p = Object.assign({
|
||||
mode: 'monthlyByDay', rangeStart: '', rangeEnd: '', allDay: false, completed: false,
|
||||
startTime: '09:00', endTime: '10:00', remindTime: '08:30', title: '', content: ''
|
||||
}, params || {})
|
||||
$('batch_name').value = p.batchName || ''
|
||||
$('batch_mode').value = p.mode
|
||||
$('batch_rangeStart').value = p.rangeStart
|
||||
$('batch_rangeEnd').value = p.rangeEnd
|
||||
$('batch_allDay').checked = !!p.allDay
|
||||
$('batch_completed').checked = !!p.completed
|
||||
$('batch_startTime').value = p.startTime
|
||||
$('batch_endTime').value = p.endTime
|
||||
$('batch_remindTime').value = p.remindTime
|
||||
$('batch_title').value = p.title
|
||||
$('batch_content').value = p.content
|
||||
$('batch_dailyEvery').value = p.dailyEvery || 1
|
||||
$('batch_weeklyEvery').value = p.weeklyEvery || 1
|
||||
if (Array.isArray(p.weekdays)) document.querySelectorAll('.batch-wd').forEach(cb => { cb.checked = p.weekdays.includes(Number(cb.value)) })
|
||||
$('batch_monthlyEvery').value = p.monthlyEvery || 2
|
||||
$('batch_monthlyDays').value = p.monthlyDays || '1-5'
|
||||
$('batch_monthlyEvery2').value = p.monthlyEvery2 || 1
|
||||
$('batch_nthWeek').value = p.nthWeek || 3
|
||||
$('batch_nthWeekday').value = String(p.nthWeekday ?? 0)
|
||||
refreshModeVisibility()
|
||||
$('batch_btnApply').disabled = true
|
||||
$('batch_btnDownload').disabled = true
|
||||
$('batch_previewArea').classList.add('d-none')
|
||||
}
|
||||
const refreshModeVisibility = () => {
|
||||
const mode = $('batch_mode').value
|
||||
document.querySelectorAll('.mode-fieldset').forEach(el => {
|
||||
el.style.display = (el.dataset.mode === mode) ? '' : 'none'
|
||||
})
|
||||
}
|
||||
let latestPreview = null
|
||||
const onPreview = async () => {
|
||||
const params = readForm()
|
||||
if (!params.rangeStart || !params.rangeEnd) return toast('请选择时间范围', 'error')
|
||||
if (!params.title) return toast('请填写标题模板', 'error')
|
||||
if (params.mode === 'weekly' && (!params.weekdays || !params.weekdays.length)) return toast('每周模式请至少选一个星期几', 'error')
|
||||
if (params.mode === 'monthlyByDay' && !params.monthlyDays) return toast('请填写每月日期表达式(如 1-5)', 'error')
|
||||
const btn = $('batch_btnPreview')
|
||||
setBtn(btn, true, '预览中…')
|
||||
try {
|
||||
const r = await api('/batch/preview', { method: 'POST', body: params })
|
||||
const data = await r.json()
|
||||
if (!data.ok) throw new Error(data.error || 'preview failed')
|
||||
latestPreview = data
|
||||
$('batch_previewArea').classList.remove('d-none')
|
||||
const stats = `预生成:<b>${data.generated}</b> 条。模式:${data.summary},区间:${data.range_start} ~ ${data.range_end}。batch_id = <code>${data.batchId}</code>`
|
||||
$('batch_previewStats').innerHTML = stats
|
||||
if (data.pre_existing_count) {
|
||||
$('batch_preExisting').textContent = `⚠ 其中 ${data.pre_existing_count} 条 (title, start) 已存在,入库时将命中去重并复用原 id(不会真插入新行)`
|
||||
} else { $('batch_preExisting').textContent = '' }
|
||||
const tb = $('batch_sampleTable').querySelector('tbody')
|
||||
tb.innerHTML = ''
|
||||
;(data.sample_list || []).forEach(s => {
|
||||
const tr = document.createElement('tr')
|
||||
tr.innerHTML = `<td>${s.start || ''}</td><td>${s.end || ''}</td><td>${s.remindAt || ''}</td><td>${s.title || ''}</td>`
|
||||
tb.appendChild(tr)
|
||||
})
|
||||
$('batch_sqlText').value = data.sql_display + (data.truncated ? '\n... 超过 500 行,完整 SQL 请点下载 SQL 文件按钮' : '')
|
||||
$('batch_btnApply').disabled = false
|
||||
$('batch_btnDownload').disabled = false
|
||||
} catch (e) {
|
||||
toast('预览失败: ' + e.message, 'error')
|
||||
} finally { setBtn(btn, false) }
|
||||
}
|
||||
const onApply = async () => {
|
||||
if (!latestPreview) return toast('请先点「预览 SQL」', 'error')
|
||||
const ok = confirm(`确认将 ${latestPreview.generated} 条事件直接写入数据库?\n(可在批次管理里一键回滚逻辑删除)`)
|
||||
if (!ok) return
|
||||
const btn = $('batch_btnApply')
|
||||
setBtn(btn, true, '入库中…')
|
||||
try {
|
||||
const r = await api('/batch/apply', {
|
||||
method: 'POST',
|
||||
body: { batch_id: latestPreview.batchId, params: latestPreview.params_saved, sql_full: latestPreview.sql_full, skip_external_sync: $('batch_skipExternal').checked }
|
||||
})
|
||||
const data = await r.json()
|
||||
if (!data.ok) throw new Error(data.error || 'apply failed')
|
||||
toast(`写入成功:生成${data.generated} 入库${data.inserted} 去重${data.dedup} 预重${data.pre_existing}`, 'success')
|
||||
loadBatches()
|
||||
const tabs = bootstrap.Tab.getOrCreateInstance($('tab-batches'))
|
||||
tabs.show()
|
||||
} catch (e) {
|
||||
toast('入库失败: ' + e.message, 'error')
|
||||
} finally { setBtn(btn, false) }
|
||||
}
|
||||
const onDownload = () => {
|
||||
if (!latestPreview) return
|
||||
const blob = new Blob([latestPreview.sql_full], { type: 'text/plain;charset=utf-8' })
|
||||
const a = document.createElement('a')
|
||||
a.href = URL.createObjectURL(blob)
|
||||
a.download = `calendar_batch_preview_${latestPreview.batchId}.sql`
|
||||
a.click()
|
||||
URL.revokeObjectURL(a.href)
|
||||
}
|
||||
|
||||
/* ---------- SQL Manual Tab ---------- */
|
||||
let _sqlLatestReport = null
|
||||
let _sqlConfirmDup = false
|
||||
let _sqlConfirmForbidden = false
|
||||
|
||||
const pad2 = (n) => String(n).padStart(2, '0')
|
||||
const tsFile = () => {
|
||||
const d = new Date()
|
||||
return `${d.getFullYear()}${pad2(d.getMonth()+1)}${pad2(d.getDate())}_${pad2(d.getHours())}${pad2(d.getMinutes())}${pad2(d.getSeconds())}`
|
||||
}
|
||||
const escapeHtml = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, ch => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[ch]))
|
||||
|
||||
const sqlReadForm = () => ({
|
||||
batch_name: ($('sql_batch_name') || {}).value || '',
|
||||
skip_external_sync: !!($('sql_skipExternal') || {}).checked,
|
||||
mark_completed: !!($('sql_completed') || {}).checked,
|
||||
sql_text: ($('sql_textarea') || {}).value || ''
|
||||
})
|
||||
|
||||
const sqlRenderReport = (data) => {
|
||||
_sqlLatestReport = data || null
|
||||
const area = $('sql_checkArea')
|
||||
if (!area) return
|
||||
if (!_sqlLatestReport || !data) { area.classList.add('d-none'); return }
|
||||
area.classList.remove('d-none')
|
||||
const total = data.total_statements || 0
|
||||
const ins = data.insert_statements || 0
|
||||
const rows = data.total_rows || 0
|
||||
const ok = !!data.ok
|
||||
$('sql_checkSummary').textContent = `${ok ? '✅ 通过' : '❌ 未通过'} · ${total} 语句 · ${ins} INSERT · ${rows} 行`
|
||||
$('sql_checkSummary').className = `badge ${ok ? 'bg-success' : 'bg-danger'}`
|
||||
// Meta
|
||||
$('sql_checkMeta').innerHTML = `
|
||||
阈值:重复相似度 ≥ <b>${data.duplicate_threshold || 0.65}</b>,最多 <b>${data.max_rows_limit || 500}</b> 条,执行超时 <b>${Math.round((data.max_ms_limit || 60000)/1000)}s</b>。<br>
|
||||
检测到 <b>${data.pre_existing_count || 0}</b> 条严格匹配(title,start) 预重(入库时会自动去重复用原 id)<br>
|
||||
检测到 <b>${data.high_confidence_duplicates || 0}</b> 条宽松匹配高置信重复(LIKE % + ±10min)。时间范围:<b>${data.range_start || '未知起'}</b> ~ <b>${data.range_end || '未知止'}</b>
|
||||
`
|
||||
// Forbidden / syntax / col errors
|
||||
const renderList = (containerId, headerText, list, color) => {
|
||||
const el = $(containerId)
|
||||
if (!el) return
|
||||
if (!list || !list.length) { el.classList.add('d-none'); return }
|
||||
el.classList.remove('d-none')
|
||||
const items = list.map((e, idx) => `<li><b>#${idx+1}</b> [stmt ${e.stmt_idx ?? '-'}${e.row_idx != null ? `, row ${e.row_idx}` : ''}] ${escapeHtml(e.reason || e.message || '')}${e.preview ? `<br><small class="text-muted">${escapeHtml(e.preview)}</small>` : ''}${e.title ? `<br><small>title=${escapeHtml(e.title)}</small>` : ''}</li>`).join('')
|
||||
el.innerHTML = `<div class="fw-bold mb-1" style="color:${color}">${headerText}</div><ul style="padding-left:1.25rem">${items}</ul>`
|
||||
}
|
||||
renderList('sql_checkForbidden', '❌ 语句类型被拒绝(只允许 INSERT INTO events):', data.forbidden, '#dc3545')
|
||||
renderList('sql_checkSyntaxErrors', '❌ 语法错误(SQLite EXPLAIN 未通过):', data.syntax_errors, '#dc3545')
|
||||
renderList('sql_checkColErrors', '❌ 列 / 必填 / 时间格式错误:', data.col_errors, '#dc3545')
|
||||
// Duplicates
|
||||
const dupEl = $('sql_checkDuplicate')
|
||||
if (dupEl) {
|
||||
if ((data.high_confidence_duplicates || 0) === 0) dupEl.classList.add('d-none')
|
||||
else {
|
||||
dupEl.classList.remove('d-none')
|
||||
dupEl.querySelector('span.fw-bold').textContent = `⚠️ 检测到 ${data.high_confidence_duplicates} 条宽松匹配高置信重复(≥${data.duplicate_threshold||0.65}):`
|
||||
}
|
||||
}
|
||||
// Sample rows
|
||||
const tb = $('sql_checkSampleTbody')
|
||||
if (tb) {
|
||||
tb.innerHTML = ''
|
||||
const meta = (data.row_meta || []).slice(0, 15)
|
||||
if (!meta.length) tb.innerHTML = '<tr><td colspan="5" class="text-muted small text-center">空</td></tr>'
|
||||
else meta.forEach(r => {
|
||||
const tr = document.createElement('tr')
|
||||
tr.innerHTML = `<td>${r.stmt_idx ?? ''}</td><td>${r.row_idx ?? ''}</td><td>${escapeHtml(r.title || '')}</td><td>${escapeHtml(r.start || '')}</td><td>${escapeHtml(r.end || '')}</td>`
|
||||
tb.appendChild(tr)
|
||||
})
|
||||
}
|
||||
// 执行按钮:允许状态
|
||||
const applyBtn = $('sql_btnApply')
|
||||
if (applyBtn) applyBtn.disabled = false
|
||||
}
|
||||
|
||||
const sqlOpenDuplicateModal = () => {
|
||||
if (!_sqlLatestReport || !_sqlLatestReport.high_confidence_duplicates) return
|
||||
const rows = (_sqlLatestReport.duplicates || []).filter(d => (d.score || 0) >= (_sqlLatestReport.duplicate_threshold || 0.65) && d.match && d.match.length)
|
||||
if (!rows.length) return toast('没有高置信重复明细', 'warn')
|
||||
$('sql_dupSummary').innerHTML = `共 <b>${rows.length}</b> 条你的 SQL 行命中了库内现有事件(宽松模式)。确认要继续执行,这些行也会进入去重逻辑命中 UPDATE(而不是 INSERT 新行)。`
|
||||
const tb = $('sql_dupTbody')
|
||||
tb.innerHTML = ''
|
||||
rows.forEach(d => {
|
||||
const mainMatch = (d.match || [])[0] || {}
|
||||
const tr = document.createElement('tr')
|
||||
tr.innerHTML = `
|
||||
<td>${d.stmt_idx ?? ''}-${d.row_idx ?? ''}</td>
|
||||
<td>${escapeHtml(d.title_left || '')}</td>
|
||||
<td>${escapeHtml(d.start_left || '')}</td>
|
||||
<td>${escapeHtml(mainMatch.title || '')}</td>
|
||||
<td>${escapeHtml(mainMatch.start || '')}</td>
|
||||
<td>${Number(d.score || 0).toFixed(2)}</td>
|
||||
<td><a href="index.html" target="_blank" class="btn btn-sm btn-outline-primary">打开主日历核对</a></td>`
|
||||
tb.appendChild(tr)
|
||||
})
|
||||
const m = bootstrap.Modal.getOrCreateInstance($('sqlDuplicateModal'))
|
||||
m.show()
|
||||
}
|
||||
|
||||
const sqlOnCheck = async () => {
|
||||
const f = sqlReadForm()
|
||||
if (!f.sql_text.trim()) return toast('SQL 文本为空', 'error')
|
||||
const btn = $('sql_btnCheck')
|
||||
setBtn(btn, true, '检查中…')
|
||||
try {
|
||||
const r = await api('/batch_sql_check', { method: 'POST', body: f })
|
||||
const data = await r.json()
|
||||
sqlRenderReport(data)
|
||||
_sqlConfirmDup = (data.high_confidence_duplicates || 0) === 0
|
||||
_sqlConfirmForbidden = !!(data.ok)
|
||||
if (data.ok) toast(`检查通过:${data.insert_statements||0} INSERT / ${data.total_rows||0} 行 / ${data.high_confidence_duplicates||0} 宽松重复`, 'success')
|
||||
else toast(`检查未通过:${(data.forbidden||[]).length + (data.syntax_errors||[]).length + (data.col_errors||[]).length} 处错误`, 'error')
|
||||
} catch (e) {
|
||||
toast('检查失败: ' + e.message, 'error')
|
||||
} finally { setBtn(btn, false) }
|
||||
}
|
||||
|
||||
const sqlOnApply = async () => {
|
||||
if (!_sqlLatestReport) return toast('请先点「SQL 语句检查」', 'error')
|
||||
const f = sqlReadForm()
|
||||
if (!f.batch_name.trim()) return toast('请填写批次名称(便于回滚时查找)', 'error')
|
||||
// 先把最新状态再拉一遍(用户可能中间改过 textarea,不允许跳检查直接执行)
|
||||
const checkBtn = $('sql_btnCheck')
|
||||
if (checkBtn && checkBtn.disabled !== true) {
|
||||
// 如果检查按钮不是 loading,强制再跑一次检查,避免用户中间改了 SQL 直接点执行
|
||||
await sqlOnCheck()
|
||||
if (!_sqlLatestReport) return
|
||||
}
|
||||
const hasErr = ((_sqlLatestReport.forbidden||[]).length + (_sqlLatestReport.syntax_errors||[]).length + (_sqlLatestReport.col_errors||[]).length) > 0
|
||||
if (hasErr && !_sqlConfirmForbidden) {
|
||||
const okForb = confirm(`有 ${(_sqlLatestReport.forbidden||[]).length + (_sqlLatestReport.syntax_errors||[]).length + (_sqlLatestReport.col_errors||[]).length} 处语法/列/语句类型被拒绝错误。真的要强制继续执行?(大概率会批量 INSERT 失败;建议先改 SQL 到检查通过)`)
|
||||
if (!okForb) return
|
||||
_sqlConfirmForbidden = true
|
||||
}
|
||||
const dupCount = _sqlLatestReport.high_confidence_duplicates || 0
|
||||
if (dupCount > 0 && !_sqlConfirmDup) {
|
||||
// 先弹重复详情 Modal,用户点 Modal 里的「我已知悉」再真正执行
|
||||
sqlOpenDuplicateModal()
|
||||
return
|
||||
}
|
||||
await sqlReallyApply({ confirm_duplicates: _sqlConfirmDup, confirm_forbidden: _sqlConfirmForbidden })
|
||||
}
|
||||
|
||||
const sqlReallyApply = async (extra) => {
|
||||
const f = Object.assign(sqlReadForm(), extra || {})
|
||||
if (!f.sql_text.trim()) return toast('SQL 文本为空', 'error')
|
||||
if (!f.batch_name.trim()) return toast('请填写批次名称', 'error')
|
||||
const btn = $('sql_btnApply')
|
||||
setBtn(btn, true, '执行入库中…')
|
||||
try {
|
||||
const r = await api('/batch_sql_apply', { method: 'POST', body: f })
|
||||
const data = await r.json()
|
||||
if (!r.ok || !data.ok) {
|
||||
if (r.status === 409 && data.report) {
|
||||
// 后端强制拦:需要 confirm_forbidden / confirm_duplicates
|
||||
sqlRenderReport(data.report)
|
||||
if (data.error && String(data.error).includes('duplicates_need_confirm')) { sqlOpenDuplicateModal(); return }
|
||||
if (data.error && String(data.error).includes('forbidden_or_error_need_confirm')) {
|
||||
const ok = confirm('后端强制拦截:有错误语句 / 不允许的列。点确定 = 我已知悉,仍尝试执行(可能全部或部分失败)')
|
||||
if (!ok) return
|
||||
_sqlConfirmForbidden = true
|
||||
await sqlReallyApply({ confirm_duplicates: _sqlConfirmDup, confirm_forbidden: true })
|
||||
return
|
||||
}
|
||||
}
|
||||
throw new Error(data.error || `HTTP ${r.status}`)
|
||||
}
|
||||
toast(`SQL 批次执行成功:生成${data.generated} / 入库${data.inserted} / 去重${data.dedup} / 宽松重复${data.high_confidence_duplicates||0}。batch_id=${data.batch_id}`, 'success')
|
||||
_sqlConfirmDup = false
|
||||
_sqlConfirmForbidden = false
|
||||
await loadBatches()
|
||||
const tabs = bootstrap.Tab.getOrCreateInstance($('tab-batches'))
|
||||
tabs.show()
|
||||
} catch (e) {
|
||||
toast('SQL 批次执行失败: ' + e.message, 'error')
|
||||
} finally { setBtn(btn, false) }
|
||||
}
|
||||
|
||||
const sqlOnDownload = () => {
|
||||
const f = sqlReadForm()
|
||||
if (!f.sql_text.trim()) return toast('SQL 文本为空', 'error')
|
||||
const head = `-- 下载时间:${new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}\n-- 批次名:${f.batch_name || '(未命名)'}\n-- 标记:${f.skip_external_sync ? '不联动下游 ' : ''}${f.mark_completed ? '全部已完成' : ''}\n-- 注意:仅支持 INSERT INTO events(...)\n\n`
|
||||
const blob = new Blob(['\ufeff' + head + f.sql_text], { type: 'text/plain;charset=utf-8' })
|
||||
const a = document.createElement('a')
|
||||
a.href = URL.createObjectURL(blob)
|
||||
a.download = `calendar_events_sql_${tsFile()}.sql`
|
||||
a.click()
|
||||
setTimeout(() => URL.revokeObjectURL(a.href), 1500)
|
||||
toast('SQL 已下载(UTF-8 BOM,避免中文乱码)', 'info')
|
||||
}
|
||||
|
||||
const badgeFor = (st) => {
|
||||
const map = { draft: 'bg-secondary', inserting: 'bg-primary', inserted: 'bg-success', rolled_back: 'bg-dark', partial: 'bg-warning' }
|
||||
return `<span class="badge ${map[st] || 'bg-light text-dark'}">${st}</span>`
|
||||
}
|
||||
let batchListData = []
|
||||
const loadBatches = async () => {
|
||||
if (!$('batch_search')) return
|
||||
const q = encodeURIComponent($('batch_search').value || '')
|
||||
const r = await api(`/batches?q=${q}&limit=200`)
|
||||
const data = (await r.json()).data || []
|
||||
batchListData = data
|
||||
const tb = $('batch_listTable').querySelector('tbody')
|
||||
tb.innerHTML = ''
|
||||
if (!data.length) { tb.innerHTML = '<tr><td colspan="7" class="text-center text-muted small">暂无批次</td></tr>'; return }
|
||||
data.forEach(b => {
|
||||
const tr = document.createElement('tr')
|
||||
tr.innerHTML = `
|
||||
<td><strong>${b.title || '(未命名)'}</strong><br><small class="text-muted">${b.id.slice(0, 10)}…</small></td>
|
||||
<td>${b.summary || '-'}<br>${b.skip_external_sync ? '<small class="text-secondary">⏸不联动下游</small>' : ''}</td>
|
||||
<td>${b.range_start || ''}<br>~ ${b.range_end || ''}</td>
|
||||
<td><span class="text-primary">${b.generated}</span> / <span class="text-success">${b.inserted}</span> / <span class="text-secondary">${b.dedup}</span> / <span class="text-danger">${b.rolled_back}</span><br><small class="text-muted">预重${b.pre_existing}</small></td>
|
||||
<td>${badgeFor(b.status)}</td>
|
||||
<td><small>${(b.created_at || '').slice(0, 16).replace('T', ' ')}</small></td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button type="button" class="btn btn-outline-info" data-act="view">👁 查看</button>
|
||||
<button type="button" class="btn btn-outline-primary" data-act="copy">📋 复制</button>
|
||||
<button type="button" class="btn btn-outline-warning" data-act="rollback">♻ 回滚</button>
|
||||
<button type="button" class="btn btn-outline-secondary" data-act="export">💾 导出</button>
|
||||
</div>
|
||||
</td>`
|
||||
tr.querySelectorAll('[data-act]').forEach(btnel => btnel.addEventListener('click', (ev) => onBatchAction(ev.currentTarget.dataset.act, b.id)))
|
||||
tb.appendChild(tr)
|
||||
})
|
||||
}
|
||||
const onBatchAction = async (act, id) => {
|
||||
if (act === 'view') return showDetail(id)
|
||||
if (act === 'export') { window.open(`/api/calendar_reminder/batch/export_sql?id=${encodeURIComponent(id)}`, '_blank', 'noopener'); return }
|
||||
if (act === 'copy') {
|
||||
const found = batchListData.find(b => b.id === id)
|
||||
if (!found) return
|
||||
fillForm(Object.assign({}, found.params, { batchName: (found.title || '') + ' (复制)' }))
|
||||
const tabs = bootstrap.Tab.getOrCreateInstance($('tab-generator'))
|
||||
tabs.show()
|
||||
toast('参数已填回生成器,修改后即可预览新批次', 'info')
|
||||
return
|
||||
}
|
||||
if (act === 'rollback') {
|
||||
const hard = confirm('点"确定"=逻辑删除(推荐,deleted=1);点"取消"=改为物理删除\n(逻辑删除保留行、方便二次确认;物理删不可恢复。)') === false
|
||||
const action = hard ? '物理删除' : '逻辑删除'
|
||||
if (!confirm(`确认对批次 ${id.slice(0, 10)}… 执行 ${action} 回滚?`)) return
|
||||
const r = await api('/batch/rollback', { method: 'POST', body: { id, hard } })
|
||||
const j = await r.json()
|
||||
if (!j.ok) return toast('回滚失败: ' + j.error, 'error')
|
||||
toast(`回滚完成:${j.rolled_back} 条 ${j.hard ? '(硬删)' : '(软删)'}`, 'success')
|
||||
loadBatches()
|
||||
}
|
||||
}
|
||||
const showDetail = async (id) => {
|
||||
const r = await api(`/batch/detail?id=${encodeURIComponent(id)}`)
|
||||
const j = await r.json()
|
||||
if (!j.ok) return toast('detail failed: ' + j.error, 'error')
|
||||
const b = j.batch
|
||||
const counts = b.counts || { active: 0, deleted: 0 }
|
||||
const rows = (b.sample || []).map(s => `<tr><td>${s.start || ''}</td><td>${s.end || ''}</td><td>${s.remindAt || ''}</td><td>${s.title || ''}</td><td>${s.completed ? '✅' : ''}${s.deleted ? '🗑' : ''}</td></tr>`).join('')
|
||||
const diags = j.diagnostics || {}
|
||||
const diagBtn = (label, keyName) => `<button class="btn btn-sm btn-outline-secondary m-1" type="button" data-diag-key="${keyName}">${label}(复制)</button>`
|
||||
$('batch_detailBody').innerHTML = `
|
||||
<h6>${b.title || '(未命名)'} <small class="text-muted ms-2">id: ${b.id}</small></h6>
|
||||
<p>模式:${b.meta && b.meta.summary} · 范围 ${b.meta && b.meta.rangeStart} ~ ${b.meta && b.meta.rangeEnd} · 创建 ${(b.created_at || '').slice(0,19).replace('T',' ')}</p>
|
||||
<p>生成:${b.generated_count},入库:${b.inserted_count},去重:${b.dedup_count},预重:${b.pre_existing_count},回滚:${b.rollback_count},状态:${badgeFor(b.status)} ${b.skip_external_sync ? '<span class="badge bg-secondary">不联动下游</span>':''}</p>
|
||||
<p>当前表计数:<span class="text-success">active ${counts.active}</span> / <span class="text-danger">deleted ${counts.deleted}</span></p>
|
||||
<div class="mb-2"><strong>诊断 SQL(一键复制):</strong>
|
||||
<div class="batch-detail-diag">
|
||||
${diagBtn('按 batch_id 清单', 'by_batch_id')}
|
||||
${diagBtn('按 batch_id 计数', 'counts_by_batch')}
|
||||
${diagBtn('孤儿(batch_id 存在但 calendar_batches 中无记录)检查', 'orphans_check')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-2"><strong>样例(前 30 条):</strong></div>
|
||||
<div class="table-responsive"><table class="table table-sm table-hover"><thead><tr><th>开始</th><th>结束</th><th>提醒</th><th>标题</th><th>状态</th></tr></thead><tbody>${rows || '<tr><td colspan="5" class="text-muted small text-center">空</td></tr>'}</tbody></table></div>
|
||||
`
|
||||
$('batch_detailBody').querySelectorAll('[data-diag-key]').forEach(bx => bx.addEventListener('click', async () => {
|
||||
const k = bx.getAttribute('data-diag-key') || ''
|
||||
const ok = await copy(diags[k] || '')
|
||||
toast(ok ? '已复制到剪贴板' : '复制失败(可手动选取文本复制)', ok ? 'success' : 'error')
|
||||
}))
|
||||
const m = bootstrap.Modal.getOrCreateInstance($('batchDetailModal'))
|
||||
m.show()
|
||||
}
|
||||
|
||||
const openMainWindow = () => {
|
||||
try { window.open('index.html', '_blank', 'noopener') } catch {}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// --- 独立页面初始化:如果是 batch_tool.html ---
|
||||
const mode = $('batch_mode')
|
||||
if (mode) {
|
||||
refreshModeVisibility()
|
||||
mode.addEventListener('change', refreshModeVisibility)
|
||||
}
|
||||
const backBtn = $('btnBackToMain')
|
||||
if (backBtn) backBtn.addEventListener('click', openMainWindow)
|
||||
const topReload = $('btnReload')
|
||||
if (topReload) topReload.addEventListener('click', loadBatches)
|
||||
const pb = $('batch_btnPreview'), ab = $('batch_btnApply'), db = $('batch_btnDownload')
|
||||
if (pb) pb.addEventListener('click', onPreview)
|
||||
if (ab) ab.addEventListener('click', onApply)
|
||||
if (db) db.addEventListener('click', onDownload)
|
||||
const tabBatches = $('tab-batches')
|
||||
if (tabBatches) tabBatches.addEventListener('shown.bs.tab', loadBatches)
|
||||
const rf = $('batch_refresh')
|
||||
if (rf) rf.addEventListener('click', loadBatches)
|
||||
const search = $('batch_search')
|
||||
if (search) {
|
||||
let _t = null
|
||||
search.addEventListener('input', () => { clearTimeout(_t); _t = setTimeout(loadBatches, 280) })
|
||||
}
|
||||
// 独立页面:默认就初始化一次
|
||||
if (mode) {
|
||||
latestPreview = null
|
||||
// 默认时间范围:本月 1 号起 ~ 今年 12 月 31 日止
|
||||
if (!$('batch_rangeStart').value) {
|
||||
const d = new Date()
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
$('batch_rangeStart').value = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-01`
|
||||
$('batch_rangeEnd').value = `${d.getFullYear()}-12-31`
|
||||
}
|
||||
}
|
||||
// SQL Tab 绑定
|
||||
const sqlCheck = $('sql_btnCheck')
|
||||
const sqlApply = $('sql_btnApply')
|
||||
const sqlDownload = $('sql_btnDownload')
|
||||
if (sqlCheck) sqlCheck.addEventListener('click', sqlOnCheck)
|
||||
if (sqlApply) sqlApply.addEventListener('click', sqlOnApply)
|
||||
if (sqlDownload) sqlDownload.addEventListener('click', sqlOnDownload)
|
||||
const dupDetailBtn = $('sql_dupDetail')
|
||||
if (dupDetailBtn) dupDetailBtn.addEventListener('click', sqlOpenDuplicateModal)
|
||||
const dupConfirmBtn = $('sql_dupConfirm')
|
||||
if (dupConfirmBtn) dupConfirmBtn.addEventListener('click', async () => {
|
||||
_sqlConfirmDup = true
|
||||
await sqlReallyApply({ confirm_duplicates: true, confirm_forbidden: _sqlConfirmForbidden })
|
||||
})
|
||||
const dupCancelBtn = $('sql_dupCancel')
|
||||
if (dupCancelBtn) dupCancelBtn.addEventListener('click', () => { toast('已取消,请回去改 SQL 再试', 'warn') })
|
||||
})
|
||||
|
||||
// 暴露给 batch_tool.html 里 onclick 调用(loadBatches)
|
||||
window.CalendarBatchTool = { loadBatches, fillForm, readForm }
|
||||
})()
|
||||
@@ -0,0 +1,19 @@
|
||||
(function () {
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var btn = document.getElementById('btnOpenBatchTool');
|
||||
if (!btn) return;
|
||||
btn.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
try {
|
||||
window.open(
|
||||
'batch_tool.html',
|
||||
'calendar_batch_' + Date.now(),
|
||||
'width=1280,height=900,resizable=yes,scrollbars=yes'
|
||||
);
|
||||
} catch (err) {
|
||||
// 兜底:普通链接也能打开
|
||||
document.location.href = 'batch_tool.html';
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,154 @@
|
||||
(function () {
|
||||
const isHttps = location.protocol === 'https:'
|
||||
const API_BASE = isHttps ? location.origin : 'http://localhost:8976'
|
||||
|
||||
function buildUrl(path) {
|
||||
if (/^https?:/.test(path)) return path
|
||||
const p = path.startsWith('/') ? path : '/' + path
|
||||
return API_BASE + p
|
||||
}
|
||||
|
||||
async function request(method, path, body, opts) {
|
||||
opts = opts || {}
|
||||
const url = buildUrl(path)
|
||||
const init = {
|
||||
method: method || 'GET',
|
||||
headers: opts.headers || {},
|
||||
credentials: 'same-origin'
|
||||
}
|
||||
if (body !== null && body !== undefined) {
|
||||
if (body instanceof FormData) {
|
||||
init.body = body
|
||||
} else {
|
||||
init.headers['Content-Type'] = 'application/json'
|
||||
init.body = JSON.stringify(body)
|
||||
}
|
||||
}
|
||||
try {
|
||||
const res = await fetch(url, init)
|
||||
const text = await res.text()
|
||||
let data = null
|
||||
try { data = text ? JSON.parse(text) : null } catch { data = { ok: res.ok, raw: text } }
|
||||
if (!res.ok) {
|
||||
const msg = (data && data.error) || ('HTTP ' + res.status)
|
||||
const err = new Error(msg)
|
||||
err.status = res.status
|
||||
err.data = data
|
||||
throw err
|
||||
}
|
||||
return { ok: true, status: res.status, data }
|
||||
} catch (e) {
|
||||
const err = new Error(e.message || '请求失败')
|
||||
err.data = e.data || null
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
const hub = {
|
||||
async meta() {
|
||||
const res = await request('GET', '/api/mp_time_assistant/meta')
|
||||
const payload = (res && res.data) || {}
|
||||
return (payload && payload.ok) ? payload.data || {} : (payload || {})
|
||||
},
|
||||
async getState() {
|
||||
const res = await request('GET', '/api/mp_time_assistant/state')
|
||||
const payload = (res && res.data) || {}
|
||||
return (payload && payload.ok) ? payload.data || {} : (payload || {})
|
||||
},
|
||||
async setActiveKit(kitId) {
|
||||
const res = await request('PUT', '/api/mp_time_assistant/state/active-kit', { kitId })
|
||||
const payload = (res && res.data) || {}
|
||||
return (payload && payload.ok) ? payload.data || {} : (payload || {})
|
||||
}
|
||||
}
|
||||
|
||||
const personalCollect = {
|
||||
async list(keyword) {
|
||||
const q = new URLSearchParams()
|
||||
if (keyword) q.set('keyword', keyword)
|
||||
const suffix = q.toString() ? '?' + q.toString() : ''
|
||||
const res = await request('GET', '/api/mp_time_assistant/kits/personal_collect/items' + suffix)
|
||||
const payload = (res && res.data) || {}
|
||||
if (payload && payload.ok) return payload.data || []
|
||||
return Array.isArray(payload) ? payload : []
|
||||
},
|
||||
async get(id) {
|
||||
const res = await request('GET', '/api/mp_time_assistant/kits/personal_collect/items/' + encodeURIComponent(id))
|
||||
const payload = (res && res.data) || {}
|
||||
return (payload && payload.ok) ? (payload.data || null) : (payload || null)
|
||||
},
|
||||
async create(payload) {
|
||||
const res = await request('POST', '/api/mp_time_assistant/kits/personal_collect/items', payload)
|
||||
const body = (res && res.data) || {}
|
||||
if (body && body.ok) return body.data || body
|
||||
throw new Error((body && body.error) || '创建失败')
|
||||
},
|
||||
async update(payload) {
|
||||
const res = await request('PUT', '/api/mp_time_assistant/kits/personal_collect/items', payload)
|
||||
const body = (res && res.data) || {}
|
||||
if (body && body.ok) return body.data || body
|
||||
throw new Error((body && body.error) || '更新失败')
|
||||
},
|
||||
async remove(id) {
|
||||
const q = new URLSearchParams({ id })
|
||||
const res = await request('DELETE', '/api/mp_time_assistant/kits/personal_collect/items?' + q.toString())
|
||||
const body = (res && res.data) || {}
|
||||
if (body && body.ok) return body
|
||||
throw new Error((body && body.error) || '删除失败')
|
||||
},
|
||||
async uploadFile(file, groupId, onProgress) {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
if (groupId) form.append('groupId', groupId)
|
||||
const url = buildUrl('/api/mp_time_assistant/kits/personal_collect/upload')
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const xhr = new XMLHttpRequest()
|
||||
xhr.open('POST', url)
|
||||
xhr.withCredentials = true
|
||||
if (onProgress && typeof onProgress === 'function' && xhr.upload) {
|
||||
xhr.upload.addEventListener('progress', (e) => {
|
||||
if (e.lengthComputable) {
|
||||
onProgress({ loaded: e.loaded, total: e.total, percent: Math.round(e.loaded * 100 / Math.max(1, e.total)) })
|
||||
}
|
||||
})
|
||||
}
|
||||
xhr.onload = () => {
|
||||
try {
|
||||
const data = JSON.parse(xhr.responseText || '{}')
|
||||
if (xhr.status >= 200 && xhr.status < 300 && data && data.ok) {
|
||||
resolve({ ok: true, status: xhr.status, data })
|
||||
} else {
|
||||
const err = new Error((data && data.error) || ('HTTP ' + xhr.status))
|
||||
err.data = data
|
||||
reject(err)
|
||||
}
|
||||
} catch (e) {
|
||||
reject(new Error('响应解析失败: ' + e.message))
|
||||
}
|
||||
}
|
||||
xhr.onerror = () => reject(new Error('网络错误:上传失败'))
|
||||
xhr.ontimeout = () => reject(new Error('上传超时'))
|
||||
xhr.send(form)
|
||||
} catch (e) {
|
||||
reject(e)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const fileUrl = (relPath) => {
|
||||
if (!relPath) return ''
|
||||
if (/^https?:/.test(relPath)) return relPath
|
||||
const p = relPath.startsWith('/') ? relPath : '/' + relPath
|
||||
return buildUrl(p)
|
||||
}
|
||||
|
||||
window.__MPTA_API__ = {
|
||||
API_BASE,
|
||||
request,
|
||||
hub,
|
||||
personalCollect,
|
||||
fileUrl
|
||||
}
|
||||
})()
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"_comment": "MilesYang时光助手 Web 端(与小程序共享后端和数据库)的前端鉴权配置",
|
||||
"enable_auth_comment": "true:启用 tool 外层鉴权(默认从九宫格导航进入);false:完全放开访问",
|
||||
"enable_auth": true,
|
||||
"max_age_days_comment": "鉴权 cookie 最大有效天数(1~365)",
|
||||
"max_age_days": 30,
|
||||
"cookieName_comment": "鉴权 Cookie 名称,默认格式 <tool>_gate",
|
||||
"cookieName": "web_mp_time_assistant_gate",
|
||||
"requireGuid_comment": "true:强制 GUID 参数才能访问;false:允许仅凭 token 或已有会话访问",
|
||||
"requireGuid": false,
|
||||
"guidParam_comment": "URL 中的 GUID 参数名,建议使用小写 guid",
|
||||
"guidParam": "guid",
|
||||
"guidValue_comment": "GUID 参数值,建议使用高强度随机长串并定期更换(留空表示不启用 GUID)",
|
||||
"guidValue": ""
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
background: radial-gradient(1200px 800px at 10% 0%, #1a1a33 0%, #0f0f1a 60%, #0b0b15 100%);
|
||||
min-height: 100vh;
|
||||
color: #e8e8f0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.app-root {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 40px 24px 80px;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 48px;
|
||||
padding: 24px 32px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(108, 122, 224, 0.15);
|
||||
border-radius: 24px;
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.app-header-left .app-title {
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1px;
|
||||
background: linear-gradient(135deg, #6c7ae0 0%, #b06be0 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.app-header-left .app-subtitle {
|
||||
font-size: 14px;
|
||||
color: #8a8aa8;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.app-header-right .status-tag {
|
||||
padding: 8px 16px;
|
||||
border-radius: 999px;
|
||||
font-size: 13px;
|
||||
background: rgba(108, 122, 224, 0.12);
|
||||
color: #a8b4ff;
|
||||
border: 1px solid rgba(108, 122, 224, 0.25);
|
||||
}
|
||||
|
||||
.app-header-right .status-tag.ok {
|
||||
background: rgba(0, 216, 167, 0.1);
|
||||
color: #00d8a7;
|
||||
border-color: rgba(0, 216, 167, 0.3);
|
||||
}
|
||||
|
||||
.app-header-right .status-tag.err {
|
||||
background: rgba(255, 80, 100, 0.1);
|
||||
color: #ff8595;
|
||||
border-color: rgba(255, 80, 100, 0.3);
|
||||
}
|
||||
|
||||
.kit-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.kit-empty {
|
||||
grid-column: 1 / -1;
|
||||
text-align: center;
|
||||
padding: 80px 0;
|
||||
color: #8a8aa8;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.kit-card {
|
||||
position: relative;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 22px;
|
||||
padding: 28px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.35s cubic-bezier(0.22, 1, 0.36, 1),
|
||||
border-color 0.3s ease,
|
||||
background 0.3s ease,
|
||||
box-shadow 0.35s ease;
|
||||
backdrop-filter: blur(8px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.kit-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(135deg, rgba(108, 122, 224, 0) 0%, rgba(176, 107, 224, 0) 100%);
|
||||
transition: opacity 0.4s ease;
|
||||
opacity: 0;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.kit-card:hover {
|
||||
transform: translateY(-6px);
|
||||
border-color: rgba(108, 122, 224, 0.35);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
box-shadow: 0 20px 40px rgba(108, 122, 224, 0.15);
|
||||
}
|
||||
|
||||
.kit-card:hover::before {
|
||||
opacity: 1;
|
||||
background: linear-gradient(135deg, rgba(108, 122, 224, 0.08) 0%, rgba(176, 107, 224, 0.04) 100%);
|
||||
}
|
||||
|
||||
.kit-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 28px;
|
||||
background: linear-gradient(135deg, rgba(108, 122, 224, 0.22) 0%, rgba(176, 107, 224, 0.18) 100%);
|
||||
border: 1px solid rgba(108, 122, 224, 0.3);
|
||||
margin-bottom: 18px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.kit-name {
|
||||
font-size: 19px;
|
||||
font-weight: 600;
|
||||
color: #f0f0f8;
|
||||
margin-bottom: 8px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.kit-desc {
|
||||
font-size: 13.5px;
|
||||
color: #9b9bb5;
|
||||
line-height: 1.7;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.kit-arrow {
|
||||
margin-top: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: #a8b4ff;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
opacity: 0.75;
|
||||
transition: gap 0.3s ease, opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.kit-card:hover .kit-arrow {
|
||||
gap: 12px;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.app-root { padding: 20px 16px 60px; }
|
||||
.app-header {
|
||||
padding: 18px 20px;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
.app-header-left .app-title { font-size: 24px; }
|
||||
.kit-grid { grid-template-columns: 1fr; gap: 16px; }
|
||||
.kit-card { padding: 22px; }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>MilesYang时光助手</title>
|
||||
<link rel="stylesheet" href="/common/common.css">
|
||||
<link rel="stylesheet" href="hub.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-root">
|
||||
<header class="app-header">
|
||||
<div class="app-header-left">
|
||||
<h1 class="app-title">MilesYang时光助手</h1>
|
||||
<p class="app-subtitle">手机 · 电脑,实时联动的小工具集合</p>
|
||||
</div>
|
||||
<div class="app-header-right">
|
||||
<span id="status-tag" class="status-tag">加载中...</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="app-main">
|
||||
<div id="kit-grid" class="kit-grid">
|
||||
<div class="kit-empty">⏳ 工具列表加载中...</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="api.js"></script>
|
||||
<script src="hub.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,74 @@
|
||||
(function () {
|
||||
const api = window.__MPTA_API__
|
||||
const $grid = document.getElementById('kit-grid')
|
||||
const $status = document.getElementById('status-tag')
|
||||
|
||||
const KIT_PAGES = {
|
||||
personal_collect: 'kits/personal_collect/list.html'
|
||||
}
|
||||
|
||||
const KIT_ICONS = {
|
||||
personal_collect: '📁'
|
||||
}
|
||||
|
||||
function setStatus(text, cls) {
|
||||
$status.textContent = text
|
||||
$status.className = 'status-tag' + (cls ? ' ' + cls : '')
|
||||
}
|
||||
|
||||
function renderKits(kits) {
|
||||
if (!kits || !kits.length) {
|
||||
$grid.innerHTML = '<div class="kit-empty">暂无可用工具</div>'
|
||||
return
|
||||
}
|
||||
$grid.innerHTML = kits.map((kit) => {
|
||||
const page = KIT_PAGES[kit.id] || '#'
|
||||
const icon = KIT_ICONS[kit.id] || '🧰'
|
||||
return (
|
||||
`<div class="kit-card" data-kit="${kit.id}" data-page="${page}">` +
|
||||
`<div class="kit-icon">${icon}</div>` +
|
||||
`<div class="kit-name">${escapeHtml(kit.name || kit.id)}</div>` +
|
||||
`<div class="kit-desc">${escapeHtml(kit.description || '')}</div>` +
|
||||
`<div class="kit-arrow"><span>进入</span><span>→</span></div>` +
|
||||
`</div>`
|
||||
)
|
||||
}).join('')
|
||||
Array.from($grid.querySelectorAll('.kit-card')).forEach((card) => {
|
||||
card.addEventListener('click', () => {
|
||||
const page = card.getAttribute('data-page')
|
||||
const kitId = card.getAttribute('data-kit')
|
||||
const go = () => { window.location.href = page }
|
||||
if (api && api.hub && api.hub.setActiveKit) {
|
||||
api.hub.setActiveKit(kitId).then(go).catch(go)
|
||||
} else {
|
||||
go()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
if (s === null || s === undefined) return ''
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
if (!api || !api.hub) throw new Error('API 未加载')
|
||||
const meta = await api.hub.meta()
|
||||
const kits = (meta && meta.kits) || []
|
||||
setStatus('后端已连通', 'ok')
|
||||
renderKits(kits)
|
||||
} catch (e) {
|
||||
setStatus('后端连通失败:' + (e.message || '未知错误'), 'err')
|
||||
$grid.innerHTML = `<div class="kit-empty">❌ 加载失败:${escapeHtml(e.message || '后端无响应')}</div>`
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', main)
|
||||
})()
|
||||
@@ -0,0 +1,32 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>MilesYang时光助手</title>
|
||||
<link rel="stylesheet" href="/common/common.css">
|
||||
<link rel="stylesheet" href="hub.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-root">
|
||||
<header class="app-header">
|
||||
<div class="app-header-left">
|
||||
<h1 class="app-title">MilesYang时光助手</h1>
|
||||
<p class="app-subtitle">手机 · 电脑,实时联动的小工具集合</p>
|
||||
</div>
|
||||
<div class="app-header-right">
|
||||
<span id="status-tag" class="status-tag">加载中...</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="app-main">
|
||||
<div id="kit-grid" class="kit-grid">
|
||||
<div class="kit-empty">⏳ 工具列表加载中...</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="api.js"></script>
|
||||
<script src="hub.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,212 @@
|
||||
(function () {
|
||||
const DEFAULT_UI_MODE = 'debug'
|
||||
const AUTO_CLOSE_SEC_DEBUG = 3
|
||||
const AUTO_CLOSE_SEC_NORMAL = 2
|
||||
|
||||
function readUiMode() {
|
||||
try {
|
||||
const v = localStorage.getItem('mp_ta_ui_mode')
|
||||
if (v === 'debug' || v === 'normal') return v
|
||||
} catch {}
|
||||
return DEFAULT_UI_MODE
|
||||
}
|
||||
|
||||
function setUiMode(mode) {
|
||||
try { localStorage.setItem('mp_ta_ui_mode', mode) } catch {}
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
if (s === null || s === undefined) return ''
|
||||
return String(s)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''')
|
||||
}
|
||||
|
||||
function showToast(text, type, duration) {
|
||||
type = type || 'info'
|
||||
duration = duration || (type === 'error' ? 3000 : 2000)
|
||||
const colors = {
|
||||
info: { bg: 'rgba(56, 103, 214, 0.92)', border: 'rgba(108, 122, 224, 0.6)', color: '#fff' },
|
||||
success: { bg: 'rgba(0, 180, 140, 0.92)', border: 'rgba(0, 216, 167, 0.6)', color: '#fff' },
|
||||
error: { bg: 'rgba(220, 53, 69, 0.92)', border: 'rgba(255, 80, 100, 0.6)', color: '#fff' },
|
||||
warn: { bg: 'rgba(230, 152, 20, 0.92)', border: 'rgba(255, 193, 7, 0.6)', color: '#fff' }
|
||||
}
|
||||
const c = colors[type] || colors.info
|
||||
const t = document.createElement('div')
|
||||
t.textContent = text
|
||||
Object.assign(t.style, {
|
||||
position: 'fixed', zIndex: 99998, top: '28px', left: '50%', transform: 'translateX(-50%)',
|
||||
padding: '10px 18px', borderRadius: '12px', fontSize: '14px', fontWeight: 500,
|
||||
maxWidth: '90vw', textAlign: 'center', backdropFilter: 'blur(8px)',
|
||||
background: c.bg, border: '1px solid ' + c.border, color: c.color,
|
||||
boxShadow: '0 10px 30px rgba(0,0,0,0.35)'
|
||||
})
|
||||
document.body.appendChild(t)
|
||||
setTimeout(() => {
|
||||
t.style.transition = 'opacity 0.3s ease, transform 0.3s ease'
|
||||
t.style.opacity = '0'
|
||||
t.style.transform = 'translateX(-50%) translateY(-10px)'
|
||||
setTimeout(() => t.remove(), 320)
|
||||
}, duration)
|
||||
}
|
||||
|
||||
function confirmDialog(options, onOk, onCancel) {
|
||||
options = options || {}
|
||||
const title = options.title || '请确认操作'
|
||||
const desc = options.desc || ''
|
||||
const okText = options.okText || '确认'
|
||||
const cancelText = options.cancelText || '取消'
|
||||
const danger = options.danger === true
|
||||
const slot = document.getElementById('modal-slot') || document.body
|
||||
const mask = document.createElement('div')
|
||||
mask.className = 'modal-mask'
|
||||
mask.innerHTML =
|
||||
'<div class="modal-card">' +
|
||||
'<div class="modal-title' + (danger ? ' danger' : '') + '">' + escapeHtml(title) + '</div>' +
|
||||
'<div class="modal-desc">' + escapeHtml(desc) + '</div>' +
|
||||
'<div class="modal-actions">' +
|
||||
'<button type="button" class="btn secondary btn-sm" data-act="cancel">' + escapeHtml(cancelText) + '</button>' +
|
||||
'<button type="button" class="btn ' + (danger ? 'danger' : 'primary') + ' btn-sm" data-act="ok">' + escapeHtml(okText) + '</button>' +
|
||||
'</div>' +
|
||||
'</div>'
|
||||
slot.appendChild(mask)
|
||||
const close = (result) => {
|
||||
mask.remove()
|
||||
if (result && typeof onOk === 'function') onOk()
|
||||
if (!result && typeof onCancel === 'function') onCancel()
|
||||
}
|
||||
mask.addEventListener('click', (e) => {
|
||||
if (e.target === mask) close(false)
|
||||
const act = e.target.getAttribute && e.target.getAttribute('data-act')
|
||||
if (act === 'ok') close(true)
|
||||
if (act === 'cancel') close(false)
|
||||
})
|
||||
const okBtn = mask.querySelector('[data-act="ok"]')
|
||||
if (okBtn) okBtn.focus()
|
||||
return { close }
|
||||
}
|
||||
|
||||
function runTrackedInteraction(options, runner) {
|
||||
const uiMode = readUiMode()
|
||||
options = options || {}
|
||||
const name = options.name || '操作'
|
||||
const toastOnSuccess = options.toastOnSuccess !== false
|
||||
const normalViewAsToast = options.normalViewAsToast !== false
|
||||
const debugMode = uiMode === 'debug'
|
||||
const dlg = document.getElementById('interaction-dialog') || (() => {
|
||||
const d = document.createElement('div')
|
||||
d.id = 'interaction-dialog'
|
||||
d.className = 'interaction-dialog hidden'
|
||||
document.body.appendChild(d)
|
||||
return d
|
||||
})()
|
||||
|
||||
const startedAt = Date.now()
|
||||
const logs = []
|
||||
let timer = null
|
||||
let forceStay = false
|
||||
|
||||
const fmtTime = (ts) => {
|
||||
const d = new Date(ts)
|
||||
const p = (n) => (n < 10 ? '0' + n : '' + n)
|
||||
return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
|
||||
}
|
||||
|
||||
const push = (line) => logs.push(`[${fmtTime(Date.now())}] ${line}`)
|
||||
|
||||
const render = () => {
|
||||
const elapsedSec = ((Date.now() - startedAt) / 1000).toFixed(1)
|
||||
dlg.innerHTML =
|
||||
'<div class="id-title">' +
|
||||
'<span>' + escapeHtml(name) + ' · 交互详情</span>' +
|
||||
'<span style="cursor:pointer;color:#8a8aa8;" data-act="copy">复制</span>' +
|
||||
'</div>' +
|
||||
'<div class="id-body">' + escapeHtml(logs.join('\n') + (timer ? `\n\n倒计时关闭:${remainSec}s` : '') + `\n\n耗时:${elapsedSec}s`) + '</div>' +
|
||||
'<div class="id-actions">' +
|
||||
'<span class="id-countdown">' + (timer ? `自动关闭中:${remainSec}s` : (forceStay ? '已停留,有错误/异常' : '已完成')) + '</span>' +
|
||||
'<div style="display:flex;gap:8px;">' +
|
||||
(timer ? '<button type="button" class="btn ghost btn-sm" data-act="stay">停留</button>' : '') +
|
||||
'<button type="button" class="btn secondary btn-sm" data-act="close">关闭</button>' +
|
||||
'</div>' +
|
||||
'</div>'
|
||||
const btnCopy = dlg.querySelector('[data-act="copy"]')
|
||||
if (btnCopy) btnCopy.onclick = () => {
|
||||
try {
|
||||
const txt = logs.join('\n') + `\n\n耗时:${elapsedSec}s`
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(txt).then(() => showToast('日志已复制', 'success', 1200))
|
||||
} else {
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = txt; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove()
|
||||
showToast('日志已复制', 'success', 1200)
|
||||
}
|
||||
} catch (e) { showToast('复制失败:' + e.message, 'error', 2000) }
|
||||
}
|
||||
const btnStay = dlg.querySelector('[data-act="stay"]')
|
||||
if (btnStay) btnStay.onclick = () => { if (timer) { clearInterval(timer); timer = null; forceStay = true; render() } }
|
||||
const btnClose = dlg.querySelector('[data-act="close"]')
|
||||
if (btnClose) btnClose.onclick = () => { dlg.classList.add('hidden'); if (timer) clearInterval(timer); timer = null }
|
||||
}
|
||||
|
||||
const showDlg = () => { dlg.classList.remove('hidden'); render() }
|
||||
const closeDlg = () => { dlg.classList.add('hidden'); if (timer) { clearInterval(timer); timer = null } }
|
||||
|
||||
let remainSec = debugMode ? AUTO_CLOSE_SEC_DEBUG : AUTO_CLOSE_SEC_NORMAL
|
||||
|
||||
const startCountdown = () => {
|
||||
if (forceStay) return
|
||||
timer = setInterval(() => {
|
||||
remainSec -= 1
|
||||
if (remainSec <= 0) { closeDlg() }
|
||||
else { render() }
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
push(`开始:${name}`)
|
||||
push(`HTTP: ${options.method || 'GET'} ${options.path || options.url || '(本地)'}${options.params ? `\n参数:${JSON.stringify(options.params)}` : ''}`)
|
||||
showDlg()
|
||||
|
||||
const runnerPromise = typeof runner === 'function' ? runner() : Promise.resolve(runner || { ok: true })
|
||||
return Promise.resolve(runnerPromise).then((res) => {
|
||||
push(`返回:${JSON.stringify(res, null, 2).slice(0, 2000)}`)
|
||||
push(`状态:成功 ✔ 耗时 ${((Date.now()-startedAt)/1000).toFixed(2)}s`)
|
||||
render()
|
||||
if (debugMode) { startCountdown() }
|
||||
else if (normalViewAsToast && toastOnSuccess) { setTimeout(() => closeDlg(), 120); showToast(`${name}成功`, 'success') }
|
||||
else { startCountdown() }
|
||||
return res
|
||||
}).catch((e) => {
|
||||
forceStay = true
|
||||
push(`错误:${e && e.message ? e.message : String(e)}`)
|
||||
push(`状态:失败 ✘ 耗时 ${((Date.now()-startedAt)/1000).toFixed(2)}s`)
|
||||
render()
|
||||
showToast(`${name}失败:${(e && e.message) ? e.message : '未知错误'}`, 'error', 3500)
|
||||
throw e
|
||||
})
|
||||
}
|
||||
|
||||
function formatTime(iso) {
|
||||
if (!iso) return ''
|
||||
const d = new Date(iso)
|
||||
if (isNaN(d.getTime())) return String(iso)
|
||||
const p = (n) => (n < 10 ? '0' + n : '' + n)
|
||||
const now = new Date()
|
||||
const sameYear = d.getFullYear() === now.getFullYear()
|
||||
const y = sameYear ? '' : d.getFullYear() + '-'
|
||||
return `${y}${p(d.getMonth()+1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
|
||||
}
|
||||
|
||||
function humanSize(bytes) {
|
||||
const n = Number(bytes || 0)
|
||||
if (n < 1024) return n + ' B'
|
||||
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'
|
||||
return (n / 1024 / 1024).toFixed(2) + ' MB'
|
||||
}
|
||||
|
||||
window.__MPTA_DIALOG__ = {
|
||||
readUiMode, setUiMode,
|
||||
escapeHtml, showToast, confirmDialog, runTrackedInteraction,
|
||||
formatTime, humanSize,
|
||||
AUTO_CLOSE_SEC_DEBUG, AUTO_CLOSE_SEC_NORMAL
|
||||
}
|
||||
})()
|
||||
@@ -0,0 +1,209 @@
|
||||
.detail-section {
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.detail-section-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #8a8aa8;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
line-height: 1.5;
|
||||
color: #f4f4fa;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
.detail-title.empty {
|
||||
color: #6a6a88;
|
||||
font-weight: 500;
|
||||
font-size: 20px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.detail-meta {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 13px;
|
||||
color: #8a8aa8;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.detail-meta span b {
|
||||
color: #c8c8e0;
|
||||
font-weight: 500;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.detail-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.detail-remark {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
color: #dcdceb;
|
||||
font-size: 15px;
|
||||
line-height: 1.9;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.detail-remark.empty {
|
||||
color: #6a6a88;
|
||||
text-align: center;
|
||||
font-style: italic;
|
||||
padding: 40px 24px;
|
||||
}
|
||||
|
||||
.image-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
.image-item {
|
||||
position: relative;
|
||||
aspect-ratio: 1 / 1;
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
cursor: zoom-in;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
transition: transform 0.25s ease, border-color 0.25s ease;
|
||||
}
|
||||
.image-item:hover { transform: translateY(-2px); border-color: rgba(108, 122, 224, 0.4); }
|
||||
.image-item img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.image-item .name-badge {
|
||||
position: absolute;
|
||||
left: 8px; bottom: 8px;
|
||||
max-width: calc(100% - 16px);
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
color: #fff;
|
||||
backdrop-filter: blur(4px);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.file-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.file-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 14px 18px;
|
||||
background: rgba(108, 122, 224, 0.06);
|
||||
border: 1px solid rgba(108, 122, 224, 0.16);
|
||||
border-radius: 14px;
|
||||
transition: background 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
.file-item:hover {
|
||||
background: rgba(108, 122, 224, 0.12);
|
||||
border-color: rgba(108, 122, 224, 0.32);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.file-icon {
|
||||
width: 44px; height: 44px;
|
||||
border-radius: 12px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: rgba(108, 122, 224, 0.18);
|
||||
flex-shrink: 0;
|
||||
font-size: 22px;
|
||||
}
|
||||
.file-info { flex: 1; min-width: 0; }
|
||||
.file-name {
|
||||
font-size: 14.5px;
|
||||
font-weight: 500;
|
||||
color: #e8e8f4;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.file-meta { font-size: 12px; color: #8a8aa8; margin-top: 4px; }
|
||||
.file-download {
|
||||
padding: 8px 12px;
|
||||
border-radius: 10px;
|
||||
background: rgba(108, 122, 224, 0.2);
|
||||
color: #a8b4ff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
border: 1px solid rgba(108, 122, 224, 0.3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.file-download:hover { background: rgba(108, 122, 224, 0.3); }
|
||||
|
||||
.lightbox-mask {
|
||||
position: fixed; inset: 0; z-index: 99999;
|
||||
background: rgba(0, 0, 0, 0.88);
|
||||
backdrop-filter: blur(8px);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
.lightbox-mask.hidden { display: none; }
|
||||
.lightbox-inner {
|
||||
position: relative;
|
||||
width: 100%; height: 100%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.lightbox-img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 30px 100px rgba(0,0,0,0.6);
|
||||
}
|
||||
.lightbox-close, .lightbox-nav {
|
||||
position: absolute;
|
||||
width: 44px; height: 44px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255,255,255,0.08);
|
||||
color: #fff;
|
||||
font-size: 20px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
border: 1px solid rgba(255,255,255,0.16);
|
||||
cursor: pointer;
|
||||
backdrop-filter: blur(6px);
|
||||
transition: background 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
.lightbox-close:hover, .lightbox-nav:hover { background: rgba(255,255,255,0.18); transform: scale(1.06); }
|
||||
.lightbox-close { top: 16px; right: 16px; }
|
||||
.lightbox-nav { top: 50%; transform: translateY(-50%); }
|
||||
.lightbox-nav:hover { transform: translateY(-50%) scale(1.06); }
|
||||
.lightbox-nav.prev { left: 16px; }
|
||||
.lightbox-nav.next { right: 16px; }
|
||||
.lightbox-caption {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
max-width: 80vw;
|
||||
padding: 8px 18px;
|
||||
border-radius: 999px;
|
||||
background: rgba(0,0,0,0.65);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
backdrop-filter: blur(6px);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.detail-title { font-size: 20px; }
|
||||
.detail-remark { padding: 18px; font-size: 14.5px; }
|
||||
.image-grid { grid-template-columns: repeat(2, 1fr); gap: 10px; }
|
||||
.lightbox-close, .lightbox-nav { width: 40px; height: 40px; font-size: 18px; }
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>收藏详情 | MilesYang时光助手</title>
|
||||
<link rel="stylesheet" href="/common/common.css">
|
||||
<link rel="stylesheet" href="../../shared.css">
|
||||
<link rel="stylesheet" href="detail.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<header class="app-topbar">
|
||||
<div class="app-topbar-left">
|
||||
<button class="back-btn" id="btn-back" title="返回列表">←</button>
|
||||
<div>
|
||||
<div class="page-title" id="page-title">收藏详情</div>
|
||||
<div class="page-subtitle" id="page-subtitle">加载中...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="app-topbar-right">
|
||||
<button class="btn secondary btn-sm" id="btn-refresh">🔄 刷新</button>
|
||||
<button class="btn primary btn-sm" id="btn-edit">编辑</button>
|
||||
<button class="btn danger btn-sm" id="btn-delete">删除</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="detail-wrap">
|
||||
<div class="empty-state"><div class="empty-state-icon">⏳</div><div class="empty-state-text">加载中...</div></div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div id="lightbox-mask" class="lightbox-mask hidden">
|
||||
<div class="lightbox-inner">
|
||||
<button class="lightbox-close" id="lightbox-close">✕</button>
|
||||
<button class="lightbox-nav prev" id="lightbox-prev">‹</button>
|
||||
<img id="lightbox-img" class="lightbox-img" alt="">
|
||||
<button class="lightbox-nav next" id="lightbox-next">›</button>
|
||||
<div class="lightbox-caption" id="lightbox-caption"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="modal-slot"></div>
|
||||
<div id="interaction-dialog" class="interaction-dialog hidden"></div>
|
||||
|
||||
<script src="../../api.js"></script>
|
||||
<script src="../../kits/common/dialog.js"></script>
|
||||
<script src="detail.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,253 @@
|
||||
(function () {
|
||||
const api = window.__MPTA_API__
|
||||
const dlg = window.__MPTA_DIALOG__
|
||||
|
||||
const $wrap = document.getElementById('detail-wrap')
|
||||
const $title = document.getElementById('page-title')
|
||||
const $subtitle = document.getElementById('page-subtitle')
|
||||
const $back = document.getElementById('btn-back')
|
||||
const $refresh = document.getElementById('btn-refresh')
|
||||
const $edit = document.getElementById('btn-edit')
|
||||
const $delete = document.getElementById('btn-delete')
|
||||
|
||||
const $lbMask = document.getElementById('lightbox-mask')
|
||||
const $lbImg = document.getElementById('lightbox-img')
|
||||
const $lbCap = document.getElementById('lightbox-caption')
|
||||
const $lbClose = document.getElementById('lightbox-close')
|
||||
const $lbPrev = document.getElementById('lightbox-prev')
|
||||
const $lbNext = document.getElementById('lightbox-next')
|
||||
|
||||
const escapeHtml = dlg.escapeHtml
|
||||
const formatTime = dlg.formatTime
|
||||
const humanSize = dlg.humanSize
|
||||
|
||||
const q = new URLSearchParams(location.search)
|
||||
const id = q.get('id')
|
||||
let current = null
|
||||
let imageList = []
|
||||
let lbIdx = -1
|
||||
|
||||
function fileIcon(fileName) {
|
||||
const ext = (fileName || '').split('.').pop().toLowerCase()
|
||||
if (/png|jpe?g|gif|webp|bmp|svg|ico/.test(ext)) return '🖼️'
|
||||
if (/pdf/.test(ext)) return '📕'
|
||||
if (/docx?|docm|dotx?/.test(ext)) return '📘'
|
||||
if (/xlsx?|xlsm|xls/.test(ext)) return '📗'
|
||||
if (/pptx?|ppsm/.test(ext)) return '📙'
|
||||
if (/txt|md|log|csv/.test(ext)) return '📝'
|
||||
if (/zip|rar|7z|tar|gz/.test(ext)) return '🗜️'
|
||||
if (/mp3|wav|flac|m4a|aac/.test(ext)) return '🎵'
|
||||
if (/mp4|mov|avi|mkv|webm/.test(ext)) return '🎥'
|
||||
return '📄'
|
||||
}
|
||||
|
||||
function goBack() { window.location.href = 'list.html' }
|
||||
function goEdit() { if (id) window.location.href = 'edit.html?id=' + encodeURIComponent(id) }
|
||||
|
||||
function openLightbox(idx) {
|
||||
if (!imageList.length) return
|
||||
lbIdx = Math.max(0, Math.min(imageList.length - 1, idx | 0))
|
||||
const item = imageList[lbIdx]
|
||||
$lbImg.src = item.src
|
||||
$lbCap.textContent = item.name || ''
|
||||
$lbMask.classList.remove('hidden')
|
||||
}
|
||||
function closeLightbox() { $lbMask.classList.add('hidden'); try { $lbImg.src = '' } catch {} lbIdx = -1 }
|
||||
function navLightbox(delta) {
|
||||
if (!imageList.length) return
|
||||
lbIdx = (lbIdx + delta + imageList.length) % imageList.length
|
||||
const item = imageList[lbIdx]
|
||||
$lbImg.src = item.src
|
||||
$lbCap.textContent = item.name || ''
|
||||
}
|
||||
|
||||
$lbClose.addEventListener('click', closeLightbox)
|
||||
$lbPrev.addEventListener('click', () => navLightbox(-1))
|
||||
$lbNext.addEventListener('click', () => navLightbox(1))
|
||||
$lbMask.addEventListener('click', (e) => { if (e.target === $lbMask) closeLightbox() })
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if ($lbMask.classList.contains('hidden')) return
|
||||
if (e.key === 'Escape') closeLightbox()
|
||||
else if (e.key === 'ArrowLeft') navLightbox(-1)
|
||||
else if (e.key === 'ArrowRight') navLightbox(1)
|
||||
})
|
||||
|
||||
function doDelete() {
|
||||
if (!id || !current) return
|
||||
const title = (current.title || '').trim() || '这条无标题收藏'
|
||||
dlg.confirmDialog({
|
||||
title: '确认删除?',
|
||||
desc: `「${title}」\n\n删除后此条收藏将不再显示(软删除,后端保留记录可追溯),同时手机端也会消失。`,
|
||||
okText: '确认删除', cancelText: '取消', danger: true
|
||||
}, () => {
|
||||
dlg.runTrackedInteraction({
|
||||
name: '删除收藏',
|
||||
method: 'DELETE',
|
||||
path: '/api/mp_time_assistant/kits/personal_collect/items',
|
||||
params: { id }
|
||||
}, () => api.personalCollect.remove(id)).then(() => {
|
||||
dlg.showToast('删除成功', 'success', 1400)
|
||||
setTimeout(goBack, 600)
|
||||
}).catch(() => {})
|
||||
})
|
||||
}
|
||||
|
||||
function renderImageSection(images) {
|
||||
if (!images || !images.length) return ''
|
||||
imageList = images.map(a => {
|
||||
const filePath = a.filePath || a.file_path
|
||||
return {
|
||||
src: a.url || api.fileUrl(filePath),
|
||||
name: a.fileName || a.file_name || ''
|
||||
}
|
||||
})
|
||||
return (
|
||||
`<section class="detail-section">` +
|
||||
`<div class="detail-section-title">图片</div>` +
|
||||
`<div class="image-grid">` +
|
||||
images.map((a, i) => {
|
||||
const filePath = a.filePath || a.file_path
|
||||
const src = a.url || api.fileUrl(filePath)
|
||||
const name = a.fileName || a.file_name || ('image_' + (i+1))
|
||||
const badge = (name && name.length > 4)
|
||||
? `<div class="name-badge">${escapeHtml(name)}</div>` : ''
|
||||
return `<div class="image-item" data-img-idx="${i}" title="${escapeHtml(name)}"><img src="${src}" alt="${escapeHtml(name)}" loading="lazy">${badge}</div>`
|
||||
}).join('') +
|
||||
`</div>` +
|
||||
`</section>`
|
||||
)
|
||||
}
|
||||
|
||||
function renderFileSection(files) {
|
||||
if (!files || !files.length) return ''
|
||||
return (
|
||||
`<section class="detail-section">` +
|
||||
`<div class="detail-section-title">文件 (${files.length})</div>` +
|
||||
`<div class="file-list">` +
|
||||
files.map(a => {
|
||||
const filePath = a.filePath || a.file_path
|
||||
const src = a.url || api.fileUrl(filePath)
|
||||
const name = a.fileName || a.file_name || '未命名文件'
|
||||
const size = Number(a.fileSize || a.file_size || 0)
|
||||
const mimeType = a.mimeType || a.mime_type || ''
|
||||
return (
|
||||
`<div class="file-item" data-file="${escapeHtml(src)}" data-name="${escapeHtml(name)}">` +
|
||||
`<div class="file-icon">${fileIcon(name)}</div>` +
|
||||
`<div class="file-info">` +
|
||||
`<div class="file-name">${escapeHtml(name)}</div>` +
|
||||
`<div class="file-meta">${humanSize(size)}${mimeType ? ' · ' + escapeHtml(mimeType) : ''}</div>` +
|
||||
`</div>` +
|
||||
`<button type="button" class="file-download">下载</button>` +
|
||||
`</div>`
|
||||
)
|
||||
}).join('') +
|
||||
`</div>` +
|
||||
`</section>`
|
||||
)
|
||||
}
|
||||
|
||||
function render(item) {
|
||||
current = item
|
||||
const dispTitle = (item.title || '').trim()
|
||||
$title.textContent = dispTitle ? dispTitle : '(无标题收藏)'
|
||||
const updatedAt = item.updatedAt || item.updated_at || item.createdAt || item.created_at
|
||||
const createdAt = item.createdAt || item.created_at
|
||||
$subtitle.textContent = `ID ${(item.id || '').slice(0, 8)}... · 更新于 ${formatTime(updatedAt)}`
|
||||
|
||||
const images = (item.attachments || []).filter(a => (typeof a.isImage !== 'undefined') ? !!a.isImage : !!a.is_image)
|
||||
const files = (item.attachments || []).filter(a => (typeof a.isImage !== 'undefined') ? !a.isImage : !a.is_image)
|
||||
const tags = (item.tags && item.tags.length)
|
||||
? `<div class="detail-tags">${item.tags.map(t => `<span class="tag">${escapeHtml(t)}</span>`).join('')}</div>`
|
||||
: ''
|
||||
|
||||
const remarkBlock = (item.remark || '').trim()
|
||||
? `<section class="detail-section"><div class="detail-section-title">备注</div><div class="detail-remark">${escapeHtml(item.remark)}</div></section>`
|
||||
: `<section class="detail-section"><div class="detail-section-title">备注</div><div class="detail-remark empty">(无备注)</div></section>`
|
||||
|
||||
const titleBlock = dispTitle
|
||||
? `<section class="detail-section"><div class="detail-title">${escapeHtml(dispTitle)}</div></section>`
|
||||
: `<section class="detail-section"><div class="detail-title empty">(该条收藏未设置标题)</div></section>`
|
||||
|
||||
const metaBlock =
|
||||
`<section class="detail-section">` +
|
||||
`<div class="detail-meta">` +
|
||||
`<span><b>创建时间</b>${formatTime(createdAt)}</span>` +
|
||||
`<span><b>更新时间</b>${formatTime(updatedAt)}</span>` +
|
||||
(item.attachments ? `<span><b>附件总数</b>${item.attachments.length}</span>` : '') +
|
||||
`</div>` +
|
||||
(tags ? `<div style="margin-top:14px;">${tags}</div>` : '') +
|
||||
`</section>`
|
||||
|
||||
$wrap.innerHTML = titleBlock + metaBlock + remarkBlock + renderImageSection(images) + renderFileSection(files)
|
||||
|
||||
Array.from($wrap.querySelectorAll('[data-img-idx]')).forEach((el) => {
|
||||
el.addEventListener('click', () => openLightbox(Number(el.getAttribute('data-img-idx')) | 0))
|
||||
})
|
||||
Array.from($wrap.querySelectorAll('.file-item')).forEach((el) => {
|
||||
el.addEventListener('click', (e) => {
|
||||
if (e.target && e.target.classList.contains('file-download')) return
|
||||
const src = el.getAttribute('data-file')
|
||||
const name = el.getAttribute('data-name') || ''
|
||||
if (!src) return
|
||||
const a = document.createElement('a')
|
||||
a.href = src
|
||||
if (name) a.setAttribute('download', name)
|
||||
a.target = '_blank'
|
||||
a.rel = 'noopener'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
setTimeout(() => a.remove(), 0)
|
||||
})
|
||||
})
|
||||
Array.from($wrap.querySelectorAll('.file-download')).forEach((btn) => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation()
|
||||
const item = btn.closest && btn.closest('.file-item')
|
||||
if (!item) return
|
||||
const src = item.getAttribute('data-file')
|
||||
const name = item.getAttribute('data-name') || ''
|
||||
if (!src) return
|
||||
const a = document.createElement('a')
|
||||
a.href = src
|
||||
if (name) a.setAttribute('download', name)
|
||||
a.target = '_blank'
|
||||
a.rel = 'noopener'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
setTimeout(() => a.remove(), 0)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!id) {
|
||||
$wrap.innerHTML = `<div class="empty-state"><div class="empty-state-icon">⚠️</div><div class="empty-state-text">参数错误:缺少 id</div></div>`
|
||||
return
|
||||
}
|
||||
try {
|
||||
const data = await dlg.runTrackedInteraction({
|
||||
name: '加载收藏详情',
|
||||
method: 'GET',
|
||||
path: '/api/mp_time_assistant/kits/personal_collect/items/' + encodeURIComponent(id),
|
||||
params: { id },
|
||||
normalViewAsToast: false
|
||||
}, () => api.personalCollect.get(id))
|
||||
const item = data ? (((data && data.data) || (data && data.item)) ? (data.data || data.item) : data) : null
|
||||
const itemInner = item ? ((item && item.item) || item) : null
|
||||
if (!itemInner) {
|
||||
$wrap.innerHTML = `<div class="empty-state"><div class="empty-state-icon">😶</div><div class="empty-state-text">未找到该条收藏</div><div class="empty-state-hint">可能已被删除,或 ID 不正确</div></div>`
|
||||
return
|
||||
}
|
||||
render(itemInner)
|
||||
} catch (e) {
|
||||
$wrap.innerHTML = `<div class="empty-state"><div class="empty-state-icon">❌</div><div class="empty-state-text">加载失败</div><div class="empty-state-hint">${escapeHtml(e.message || '请检查鉴权与后端状态')}</div></div>`
|
||||
}
|
||||
}
|
||||
|
||||
$back.addEventListener('click', goBack)
|
||||
$edit.addEventListener('click', goEdit)
|
||||
$refresh.addEventListener('click', load)
|
||||
$delete.addEventListener('click', doDelete)
|
||||
|
||||
load()
|
||||
})()
|
||||
@@ -0,0 +1,232 @@
|
||||
.edit-card {
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.attach-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 14px;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.attach-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
|
||||
.drop-zone {
|
||||
width: 100%;
|
||||
min-height: 160px;
|
||||
border: 2px dashed rgba(108, 122, 224, 0.35);
|
||||
border-radius: 18px;
|
||||
background: rgba(108, 122, 224, 0.04);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
transition: border-color 0.25s ease, background 0.25s ease, transform 0.2s ease;
|
||||
outline: none;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.drop-zone:hover,
|
||||
.drop-zone:focus {
|
||||
border-color: rgba(108, 122, 224, 0.6);
|
||||
background: rgba(108, 122, 224, 0.08);
|
||||
}
|
||||
.drop-zone.drag {
|
||||
border-color: rgba(108, 122, 224, 0.9);
|
||||
background: rgba(108, 122, 224, 0.14);
|
||||
transform: scale(1.005);
|
||||
}
|
||||
.drop-text {
|
||||
text-align: center;
|
||||
}
|
||||
.drop-icon { font-size: 36px; opacity: 0.85; margin-bottom: 10px; }
|
||||
.drop-title { font-size: 16px; font-weight: 600; color: #d8d8ee; margin-bottom: 6px; }
|
||||
.drop-hint { font-size: 13px; color: #8a8aa8; line-height: 1.7; }
|
||||
.drop-hint b { color: #a8b4ff; }
|
||||
|
||||
.text-paste-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9020;
|
||||
background: rgba(8, 8, 18, 0.58);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
animation: fade-in 0.18s ease;
|
||||
}
|
||||
.text-paste-card {
|
||||
width: min(620px, 100%);
|
||||
background: linear-gradient(160deg, rgba(255,255,255,0.06), rgba(255,255,255,0.03));
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
border-radius: 20px;
|
||||
padding: 22px 24px 18px;
|
||||
box-shadow: 0 18px 50px rgba(0,0,0,0.45);
|
||||
}
|
||||
.text-paste-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.text-paste-title { font-size: 16px; font-weight: 700; color: #e8e8ff; }
|
||||
.text-paste-count { font-size: 13px; color: #9aa2c8; background: rgba(108,122,224,0.14); padding: 4px 10px; border-radius: 999px; }
|
||||
.text-paste-preview {
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
padding: 14px 16px;
|
||||
background: rgba(0,0,0,0.28);
|
||||
border: 1px solid rgba(255,255,255,0.06);
|
||||
border-radius: 14px;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.75;
|
||||
color: #c8c8e4;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
margin-bottom: 16px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
.text-paste-actions { display: flex; gap: 10px; justify-content: flex-end; flex-wrap: wrap; }
|
||||
.text-paste-actions .btn { min-width: 128px; }
|
||||
|
||||
.attach-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
.attach-list:empty { display: none; }
|
||||
|
||||
.attach-item {
|
||||
position: relative;
|
||||
background: rgba(18, 18, 31, 0.5);
|
||||
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||
border-radius: 14px;
|
||||
padding: 14px;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
.attach-item:hover { border-color: rgba(108, 122, 224, 0.3); background: rgba(18, 18, 31, 0.7); }
|
||||
|
||||
.attach-item.image .preview {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 10;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 10px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.attach-item.image .preview img {
|
||||
width: 100%; height: 100%; object-fit: cover; display: block;
|
||||
}
|
||||
|
||||
.attach-item.file .preview {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 10;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 10px;
|
||||
background: linear-gradient(135deg, rgba(108, 122, 224, 0.18) 0%, rgba(176, 107, 224, 0.12) 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.attach-item.file .preview .big { font-size: 44px; opacity: 0.9; }
|
||||
.attach-item.file .preview .ext {
|
||||
padding: 4px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: #d0d0f0;
|
||||
font-size: 11.5px;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.attach-info {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.attach-name {
|
||||
font-size: 13px;
|
||||
color: #e0e0f4;
|
||||
line-height: 1.5;
|
||||
word-break: break-all;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
min-height: 39px;
|
||||
}
|
||||
.attach-meta {
|
||||
font-size: 11.5px;
|
||||
color: #8a8aa8;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
.attach-status {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.attach-status.pending { background: rgba(255, 193, 7, 0.12); color: #ffcc66; border: 1px solid rgba(255, 193, 7, 0.3); }
|
||||
.attach-status.uploading { background: rgba(108, 122, 224, 0.14); color: #a8b4ff; border: 1px solid rgba(108, 122, 224, 0.3); }
|
||||
.attach-status.ok { background: rgba(0, 216, 167, 0.12); color: #00d8a7; border: 1px solid rgba(0, 216, 167, 0.3); }
|
||||
.attach-status.err { background: rgba(255, 80, 100, 0.12); color: #ff8595; border: 1px solid rgba(255, 80, 100, 0.3); }
|
||||
|
||||
.attach-progress {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255,255,255,0.06);
|
||||
overflow: hidden;
|
||||
margin: 8px 0 10px;
|
||||
display: none;
|
||||
}
|
||||
.attach-progress.show { display: block; }
|
||||
.attach-progress-bar {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #6c7ae0, #b06be0);
|
||||
border-radius: 999px;
|
||||
width: 0;
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
.attach-actions-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.attach-actions-row button {
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255,255,255,0.05);
|
||||
color: #c0c0d8;
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
}
|
||||
.attach-actions-row button:hover {
|
||||
background: rgba(255,255,255,0.09);
|
||||
color: #fff;
|
||||
}
|
||||
.attach-actions-row button.danger {
|
||||
background: rgba(255, 80, 100, 0.08);
|
||||
color: #ff8595;
|
||||
border-color: rgba(255, 80, 100, 0.22);
|
||||
}
|
||||
.attach-actions-row button.danger:hover { background: rgba(255, 80, 100, 0.16); }
|
||||
.attach-actions-row button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.edit-card { padding: 18px; }
|
||||
.attach-list { grid-template-columns: 1fr; }
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>新增 / 编辑收藏 | MilesYang时光助手</title>
|
||||
<link rel="stylesheet" href="/common/common.css">
|
||||
<link rel="stylesheet" href="../../shared.css">
|
||||
<link rel="stylesheet" href="edit.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<header class="app-topbar">
|
||||
<div class="app-topbar-left">
|
||||
<button class="back-btn" id="btn-back" title="返回列表">←</button>
|
||||
<div>
|
||||
<div class="page-title" id="page-title">新增收藏</div>
|
||||
<div class="page-subtitle">支持拖拽 / Ctrl+V 粘贴 / 选择本地文件</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="app-topbar-right">
|
||||
<button class="btn secondary btn-sm" id="btn-save" disabled>保存中...</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<form class="card edit-card" id="edit-form" autocomplete="off">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="f-title">标题 <span style="color:#6c7ae0;font-weight:400;">(未填时会根据附件自动生成)</span></label>
|
||||
<input id="f-title" class="form-control" maxlength="200" placeholder="建议输入简洁标题,方便以后搜索">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="f-tags">标签(逗号或空格分隔)</label>
|
||||
<input id="f-tags" class="form-control" maxlength="500" placeholder="如:工作, 重要, 合同">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="f-remark">备注文本</label>
|
||||
<textarea id="f-remark" class="form-control" maxlength="4000" placeholder="可输入详细备注、说明、关键词... (未填且有附件时,会根据附件自动生成清单)"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="attach-head">
|
||||
<label class="form-label" style="margin:0;">附件(图片 / PDF / Word 等)</label>
|
||||
<div class="attach-actions">
|
||||
<button type="button" class="btn secondary btn-sm" id="btn-pick-file">📎 选择本地文件</button>
|
||||
<button type="button" class="btn ghost btn-sm" id="btn-auto-fill">🤖 按附件自动填充</button>
|
||||
</div>
|
||||
</div>
|
||||
<input type="file" id="file-input" multiple style="display:none" accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.md,.csv,.zip,.rar,.7z,.mp3,.mp4,.mov">
|
||||
|
||||
<div class="drop-zone" id="drop-zone" tabindex="0">
|
||||
<div class="drop-text">
|
||||
<div class="drop-icon">📂</div>
|
||||
<div class="drop-title">拖拽文件到此处上传</div>
|
||||
<div class="drop-hint">或直接 <b>Ctrl+V</b> 粘贴图片 / 截图,也可以点「选择本地文件」</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="attach-list" id="attach-list"></div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:32px; display:flex; justify-content:flex-end; gap:10px;">
|
||||
<button type="button" class="btn secondary btn-sm" id="btn-cancel">取消</button>
|
||||
<button type="submit" class="btn primary btn-sm" id="btn-save2">保存收藏</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="modal-slot"></div>
|
||||
<div id="interaction-dialog" class="interaction-dialog hidden"></div>
|
||||
|
||||
<script src="../../api.js"></script>
|
||||
<script src="../../kits/common/dialog.js"></script>
|
||||
<script src="edit.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,475 @@
|
||||
(function () {
|
||||
const api = window.__MPTA_API__
|
||||
const dlg = window.__MPTA_DIALOG__
|
||||
|
||||
const $form = document.getElementById('edit-form')
|
||||
const $title = document.getElementById('f-title')
|
||||
const $tags = document.getElementById('f-tags')
|
||||
const $remark = document.getElementById('f-remark')
|
||||
const $fileInput = document.getElementById('file-input')
|
||||
const $btnPick = document.getElementById('btn-pick-file')
|
||||
const $btnAutoFill = document.getElementById('btn-auto-fill')
|
||||
const $btnCancel = document.getElementById('btn-cancel')
|
||||
const $btnSave = document.getElementById('btn-save')
|
||||
const $btnSave2 = document.getElementById('btn-save2')
|
||||
const $btnBack = document.getElementById('btn-back')
|
||||
const $drop = document.getElementById('drop-zone')
|
||||
const $attachList = document.getElementById('attach-list')
|
||||
const $pageTitle = document.getElementById('page-title')
|
||||
|
||||
const escapeHtml = dlg.escapeHtml
|
||||
const humanSize = dlg.humanSize
|
||||
|
||||
const q = new URLSearchParams(location.search)
|
||||
const editId = q.get('id')
|
||||
const isEdit = !!editId
|
||||
|
||||
let attachments = []
|
||||
let groupId = ''
|
||||
let saving = false
|
||||
|
||||
function fileIcon(fileName) {
|
||||
const ext = (fileName || '').split('.').pop().toLowerCase()
|
||||
if (/png|jpe?g|gif|webp|bmp|svg|ico/.test(ext)) return '🖼️'
|
||||
if (/pdf/.test(ext)) return '📕'
|
||||
if (/docx?|docm|dotx?/.test(ext)) return '📘'
|
||||
if (/xlsx?|xlsm|xls/.test(ext)) return '📗'
|
||||
if (/pptx?|ppsm/.test(ext)) return '📙'
|
||||
if (/txt|md|log|csv/.test(ext)) return '📝'
|
||||
if (/zip|rar|7z|tar|gz/.test(ext)) return '🗜️'
|
||||
if (/mp3|wav|flac|m4a|aac/.test(ext)) return '🎵'
|
||||
if (/mp4|mov|avi|mkv|webm/.test(ext)) return '🎥'
|
||||
return '📄'
|
||||
}
|
||||
|
||||
function isImageByFile(file) {
|
||||
try {
|
||||
if (file && file.type && /^image\//.test(file.type)) return true
|
||||
const name = (file && (file.name || '')) || ''
|
||||
return /\.(png|jpe?g|gif|webp|bmp|svg|ico)$/i.test(name)
|
||||
} catch { return false }
|
||||
}
|
||||
|
||||
function getObjectUrl(file) {
|
||||
try { return URL.createObjectURL(file) } catch { return '' }
|
||||
}
|
||||
|
||||
function parseTags(s) {
|
||||
if (!s) return []
|
||||
return String(s)
|
||||
.split(/[,,\s]+/)
|
||||
.map(x => x.trim())
|
||||
.filter(Boolean)
|
||||
.filter((v, i, arr) => arr.indexOf(v) === i)
|
||||
}
|
||||
|
||||
function genGroupId() {
|
||||
return 'web_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 8)
|
||||
}
|
||||
|
||||
function setSaving(v) {
|
||||
saving = v
|
||||
const flag = !!v
|
||||
$btnSave.disabled = flag
|
||||
$btnSave2.disabled = flag
|
||||
$btnSave.textContent = flag ? '保存中...' : '保存'
|
||||
}
|
||||
|
||||
function renderAttachments() {
|
||||
if (!attachments.length) { $attachList.innerHTML = ''; return }
|
||||
$attachList.innerHTML = attachments.map((a, idx) => {
|
||||
const tempId = a.tempId
|
||||
const isImg = !!a.isImage
|
||||
const displayUrl = a.url || a.previewUrl || ''
|
||||
const ext = (a.fileName || '').split('.').pop().toUpperCase()
|
||||
const statusTextMap = { pending: '等待上传', uploading: '上传中', ok: '上传成功', err: '上传失败' }
|
||||
const statusText = statusTextMap[a.status] || a.status
|
||||
const showProgress = a.status === 'uploading'
|
||||
return (
|
||||
`<div class="attach-item ${isImg ? 'image' : 'file'}" data-temp="${escapeHtml(tempId)}">` +
|
||||
(isImg
|
||||
? `<div class="preview"><img src="${displayUrl}" alt="${escapeHtml(a.fileName || '')}" loading="lazy"></div>`
|
||||
: `<div class="preview"><span class="big">${fileIcon(a.fileName)}</span><span class="ext">${ext || 'FILE'}</span></div>`) +
|
||||
`<div class="attach-info">` +
|
||||
`<div class="attach-name" title="${escapeHtml(a.fileName || '')}">${escapeHtml(a.fileName || '未命名')}</div>` +
|
||||
`<div class="attach-meta">` +
|
||||
`<span>${humanSize(a.fileSize)}</span>` +
|
||||
`<span class="attach-status ${a.status}">${statusText}</span>` +
|
||||
a.errorMsg ? `<span style="color:#ff8595;">${escapeHtml(a.errorMsg)}</span>` : '' +
|
||||
`</div>` +
|
||||
`</div>` +
|
||||
`<div class="attach-progress ${showProgress ? 'show' : ''}"><div class="attach-progress-bar" style="width:${a.percent || 0}%;"></div></div>` +
|
||||
`<div class="attach-actions-row">` +
|
||||
(a.status === 'err'
|
||||
? `<button type="button" data-act="retry" data-temp="${escapeHtml(tempId)}">🔄 重试</button>`
|
||||
: '') +
|
||||
`<button type="button" data-act="remove" data-temp="${escapeHtml(tempId)}" class="danger">✕ 移除</button>` +
|
||||
`</div>` +
|
||||
`</div>`
|
||||
)
|
||||
}).join('')
|
||||
|
||||
Array.from($attachList.querySelectorAll('button[data-act]')).forEach((btn) => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
const act = btn.getAttribute('data-act')
|
||||
const tempId = btn.getAttribute('data-temp')
|
||||
if (act === 'remove') removeAttachment(tempId)
|
||||
else if (act === 'retry') retryUpload(tempId)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function addAttachments(files) {
|
||||
if (!files || !files.length) return
|
||||
const pendingGroupId = groupId || genGroupId()
|
||||
if (!groupId) groupId = pendingGroupId
|
||||
Array.from(files).forEach((file) => {
|
||||
if (!file) return
|
||||
const tempId = 't_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 8)
|
||||
const isImage = isImageByFile(file)
|
||||
const previewUrl = isImage ? getObjectUrl(file) : ''
|
||||
attachments.push({
|
||||
tempId, file, isImage,
|
||||
fileName: file.name || ('未命名_' + tempId),
|
||||
fileSize: Number(file.size || 0),
|
||||
previewUrl,
|
||||
url: '', id: '', storedName: '', filePath: '', mimeType: '',
|
||||
status: 'pending', percent: 0, errorMsg: ''
|
||||
})
|
||||
})
|
||||
renderAttachments()
|
||||
autoFillFromAttachments(false)
|
||||
Promise.resolve().then(() => uploadPending())
|
||||
}
|
||||
|
||||
function removeAttachment(tempId) {
|
||||
const idx = attachments.findIndex(a => a.tempId === tempId)
|
||||
if (idx < 0) return
|
||||
const a = attachments[idx]
|
||||
if (a && a.previewUrl) { try { URL.revokeObjectURL(a.previewUrl) } catch {} }
|
||||
attachments.splice(idx, 1)
|
||||
renderAttachments()
|
||||
}
|
||||
|
||||
function retryUpload(tempId) {
|
||||
const a = attachments.find(x => x.tempId === tempId)
|
||||
if (!a) return
|
||||
a.status = 'pending'
|
||||
a.percent = 0
|
||||
a.errorMsg = ''
|
||||
renderAttachments()
|
||||
uploadPending()
|
||||
}
|
||||
|
||||
async function uploadPending() {
|
||||
const pendings = attachments.filter(a => a.status === 'pending')
|
||||
for (const a of pendings) {
|
||||
if (a.status !== 'pending') continue
|
||||
a.status = 'uploading'
|
||||
a.percent = 0
|
||||
renderAttachments()
|
||||
try {
|
||||
const res = await api.personalCollect.uploadFile(a.file, groupId, (prog) => {
|
||||
a.percent = prog.percent || 0
|
||||
const target = $attachList.querySelector(`[data-temp="${a.tempId}"]`)
|
||||
if (target) {
|
||||
const bar = target.querySelector('.attach-progress-bar')
|
||||
if (bar) bar.style.width = a.percent + '%'
|
||||
const statusEl = target.querySelector('.attach-status')
|
||||
if (statusEl) statusEl.textContent = `上传中 ${a.percent}%`
|
||||
}
|
||||
})
|
||||
const data = res && res.data ? res.data : (res || {})
|
||||
const inner = data.data ? data.data : data
|
||||
a.id = inner.id || data.id || a.tempId
|
||||
a.storedName = inner.storedName || data.storedName || inner.stored_name || a.fileName
|
||||
a.filePath = inner.filePath || data.filePath || inner.file_path || ''
|
||||
a.mimeType = inner.mimeType || data.mimeType || inner.mime_type || (a.file && a.file.type) || ''
|
||||
a.url = inner.url || data.url || (a.filePath ? api.fileUrl(a.filePath) : a.previewUrl) || ''
|
||||
if (typeof inner.isImage !== 'undefined') a.isImage = !!inner.isImage
|
||||
else if (typeof data.isImage !== 'undefined') a.isImage = !!data.isImage
|
||||
else if (typeof inner.is_image !== 'undefined') a.isImage = !!inner.is_image
|
||||
a.status = 'ok'
|
||||
a.percent = 100
|
||||
} catch (e) {
|
||||
a.status = 'err'
|
||||
a.errorMsg = (e && e.message) ? e.message : '上传失败'
|
||||
}
|
||||
renderAttachments()
|
||||
}
|
||||
}
|
||||
|
||||
function buildAutoText(list) {
|
||||
const atts = list || attachments.filter(a => a && (a.status === 'ok' || a.fileName))
|
||||
if (!atts.length) return { title: '', remark: '' }
|
||||
const images = atts.filter(a => a.isImage)
|
||||
const files = atts.filter(a => !a.isImage)
|
||||
const ic = images.length, fc = files.length, total = atts.length
|
||||
const pad = (n) => (n < 10 ? '0' + n : '' + n)
|
||||
const now = new Date()
|
||||
const ts = `${now.getFullYear()}-${pad(now.getMonth()+1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}`
|
||||
|
||||
let title = ''
|
||||
if (total === 1) {
|
||||
const only = atts[0]
|
||||
const raw = only.fileName || '附件'
|
||||
const dot = raw.lastIndexOf('.')
|
||||
const stem = dot > 0 ? raw.slice(0, dot) : raw
|
||||
if (only.isImage) title = `图片 - ${ts}`
|
||||
else title = stem || raw
|
||||
} else if (ic > 0 && fc === 0) title = `${ic} 张图片 - ${ts}`
|
||||
else if (fc > 0 && ic === 0) {
|
||||
if (fc === 1) {
|
||||
const only = files[0]; const raw = only.fileName || '附件'
|
||||
const dot = raw.lastIndexOf('.'); const stem = dot > 0 ? raw.slice(0, dot) : raw
|
||||
title = stem || raw
|
||||
} else title = `${fc} 个文件 - ${ts}`
|
||||
} else title = `${ic} 图 + ${fc} 文件 - ${ts}`
|
||||
|
||||
const lines = [`附件数量:${total}`]
|
||||
if (ic) lines.push(`图片:${ic} 张`)
|
||||
if (fc) lines.push(`文件:${fc} 个`)
|
||||
lines.push('', '附件清单:')
|
||||
atts.slice(0, 9).forEach((a, i) => lines.push(` ${i+1}. ${a.fileName || '未命名'}(${humanSize(a.fileSize)})`))
|
||||
if (atts.length > 9) lines.push(` ... 另有 ${atts.length - 9} 个附件未列出`)
|
||||
lines.push('', `整理时间:${ts}`)
|
||||
return { title, remark: lines.join('\n') }
|
||||
}
|
||||
|
||||
function autoFillFromAttachments(force) {
|
||||
if (isEdit && !force) return
|
||||
const uploadeds = attachments.filter(a => a && (a.status === 'ok' || a.status === 'pending' || a.status === 'uploading' || a.fileName))
|
||||
if (!uploadeds.length) return
|
||||
const hasTitle = !!($title.value || '').trim()
|
||||
const hasRemark = !!($remark.value || '').trim()
|
||||
const { title, remark } = buildAutoText(uploadeds)
|
||||
if (force || !hasTitle) $title.value = title || ''
|
||||
if (force || !hasRemark) $remark.value = remark || ''
|
||||
}
|
||||
|
||||
$btnAutoFill.addEventListener('click', () => {
|
||||
autoFillFromAttachments(true)
|
||||
dlg.showToast('已按附件自动填充', 'success', 1400)
|
||||
})
|
||||
|
||||
function bindDropPaste() {
|
||||
$drop.addEventListener('dragenter', (e) => { e.preventDefault(); $drop.classList.add('drag') })
|
||||
$drop.addEventListener('dragover', (e) => { e.preventDefault(); $drop.classList.add('drag') })
|
||||
$drop.addEventListener('dragleave', (e) => { e.preventDefault(); if (e.target === $drop) $drop.classList.remove('drag') })
|
||||
$drop.addEventListener('drop', (e) => {
|
||||
e.preventDefault(); $drop.classList.remove('drag')
|
||||
const list = (e.dataTransfer && e.dataTransfer.files) ? Array.from(e.dataTransfer.files) : []
|
||||
if (list.length) addAttachments(list)
|
||||
})
|
||||
|
||||
document.addEventListener('paste', (e) => {
|
||||
if (e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA')) return
|
||||
if (!e.clipboardData) return
|
||||
const items = Array.from(e.clipboardData.items || [])
|
||||
const files = items
|
||||
.filter(it => it.kind === 'file')
|
||||
.map(it => it.getAsFile())
|
||||
.filter(Boolean)
|
||||
if (files.length) { addAttachments(files); return }
|
||||
const textItem = items.find(it => it.kind === 'string' && it.type === 'text/plain')
|
||||
if (textItem) {
|
||||
textItem.getAsString((raw) => {
|
||||
const text = (raw || '').trim()
|
||||
if (text.length < 3 || text.length > 50000) return
|
||||
openTextPasteDialog(text)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function openTextPasteDialog(text) {
|
||||
const count = text.length
|
||||
const preview = text.length > 900 ? (text.slice(0, 900) + '\n\n……(已截断预览,共 ' + count + ' 字)') : text
|
||||
const mask = document.createElement('div')
|
||||
mask.className = 'text-paste-mask'
|
||||
mask.innerHTML =
|
||||
`<div class="text-paste-card">
|
||||
<div class="text-paste-head">
|
||||
<div class="text-paste-title">📝 检测到剪贴板文字</div>
|
||||
<div class="text-paste-count">${count} 字 · ${text.split('\n').length} 行</div>
|
||||
</div>
|
||||
<div class="text-paste-preview">${escapeHtml(preview)}</div>
|
||||
<div class="text-paste-actions">
|
||||
<button type="button" class="btn btn-ghost" data-act="cancel">取消</button>
|
||||
<button type="button" class="btn btn-secondary" data-act="replace">另起一条(清空附件)</button>
|
||||
<button type="button" class="btn btn-primary" data-act="append">追加到当前备注</button>
|
||||
</div>
|
||||
</div>`
|
||||
document.body.appendChild(mask)
|
||||
const close = () => { try { document.body.removeChild(mask) } catch {} }
|
||||
mask.addEventListener('click', (ev) => {
|
||||
if (ev.target === mask) { close(); return }
|
||||
const btn = ev.target.closest('button[data-act]')
|
||||
if (!btn) return
|
||||
const act = btn.getAttribute('data-act')
|
||||
if (act === 'cancel') { close(); return }
|
||||
if (act === 'append') {
|
||||
const cur = ($remark.value || '').trim()
|
||||
$remark.value = cur ? (cur + '\n\n' + text) : text
|
||||
close()
|
||||
dlg.showToast('已追加 ' + count + ' 字到备注', 'success', 1400)
|
||||
autoFillFromAttachments(false)
|
||||
return
|
||||
}
|
||||
if (act === 'replace') {
|
||||
attachments.forEach(a => { if (a.previewUrl) { try { URL.revokeObjectURL(a.previewUrl) } catch {} } })
|
||||
attachments = []
|
||||
renderAttachments()
|
||||
$remark.value = text
|
||||
const firstLine = text.split('\n').map(s => s.trim()).find(s => s.length) || ''
|
||||
const autoTitle = (firstLine || '').slice(0, 28)
|
||||
if (isEdit) {
|
||||
if (!($title.value || '').trim() && autoTitle) $title.value = autoTitle
|
||||
dlg.showToast('已应用到当前编辑', 'success', 1400)
|
||||
} else {
|
||||
$title.value = autoTitle || '文字收藏'
|
||||
dlg.showToast('已清空附件并填入文字', 'success', 1400)
|
||||
}
|
||||
close()
|
||||
return
|
||||
}
|
||||
})
|
||||
setTimeout(() => { const first = mask.querySelector('button[data-act="append"]'); if (first) first.focus() }, 30)
|
||||
}
|
||||
|
||||
function bindInputEvents() {
|
||||
$btnPick.addEventListener('click', () => { if ($fileInput) $fileInput.click() })
|
||||
$fileInput.addEventListener('change', (e) => {
|
||||
const files = Array.from((e.target && e.target.files) || [])
|
||||
if (files.length) addAttachments(files)
|
||||
$fileInput.value = ''
|
||||
})
|
||||
$drop.addEventListener('click', () => { if ($fileInput) $fileInput.click() })
|
||||
$drop.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if ($fileInput) $fileInput.click() } })
|
||||
}
|
||||
|
||||
function buildPayload() {
|
||||
const titleVal = ($title.value || '').trim()
|
||||
const remarkVal = ($remark.value || '').trim()
|
||||
const tagsArr = parseTags($tags.value)
|
||||
const uploadeds = attachments.filter(a => a && a.status === 'ok' && a.id)
|
||||
if (!titleVal && !remarkVal && !uploadeds.length) {
|
||||
throw new Error('请至少输入标题/备注/上传附件三者其中之一')
|
||||
}
|
||||
return {
|
||||
title: titleVal,
|
||||
remark: remarkVal,
|
||||
tags: tagsArr,
|
||||
attachmentIds: uploadeds.map(a => a.id)
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() { window.location.href = 'list.html' }
|
||||
|
||||
async function onSave(e) {
|
||||
if (e) e.preventDefault()
|
||||
if (saving) return
|
||||
let payload
|
||||
try { payload = buildPayload() }
|
||||
catch (err) { dlg.showToast(err.message, 'warn', 2600); return }
|
||||
|
||||
const uploadingCount = attachments.filter(a => a.status === 'pending' || a.status === 'uploading').length
|
||||
const failedList = attachments.filter(a => a.status === 'err')
|
||||
if (uploadingCount > 0) {
|
||||
dlg.confirmDialog({
|
||||
title: '仍有文件上传中',
|
||||
desc: `还有 ${uploadingCount} 个文件正在上传。\n现在保存的话,这些未完成的文件不会被绑定到此条收藏。\n\n建议等待上传结束后再保存。`,
|
||||
okText: '仍然保存', cancelText: '等待上传', danger: false
|
||||
}, () => doSave(payload))
|
||||
return
|
||||
}
|
||||
if (failedList.length > 0) {
|
||||
dlg.confirmDialog({
|
||||
title: `${failedList.length} 个文件上传失败`,
|
||||
desc: `以下文件上传失败,保存后这些文件不会被绑定:\n\n${failedList.map(a => '· ' + (a.fileName || '未命名')).join('\n')}\n\n你可以单独重试这些附件,或直接保存已成功的内容。`,
|
||||
okText: '忽略失败,只保存成功的', cancelText: '回去修', danger: true
|
||||
}, () => doSave(payload))
|
||||
return
|
||||
}
|
||||
doSave(payload)
|
||||
}
|
||||
|
||||
async function doSave(payload) {
|
||||
const opName = isEdit ? '更新收藏' : '新增收藏'
|
||||
try {
|
||||
setSaving(true)
|
||||
const saved = await dlg.runTrackedInteraction({
|
||||
name: opName,
|
||||
method: isEdit ? 'PUT' : 'POST',
|
||||
path: '/api/mp_time_assistant/kits/personal_collect/items',
|
||||
params: isEdit ? Object.assign({ id: editId }, payload) : payload
|
||||
}, () => {
|
||||
if (isEdit) return api.personalCollect.update(Object.assign({ id: editId }, payload))
|
||||
return api.personalCollect.create(payload)
|
||||
})
|
||||
dlg.showToast(`${opName}成功`, 'success', 1600)
|
||||
setTimeout(() => {
|
||||
const dataId = (saved && saved.id) || editId
|
||||
if (!isEdit && dataId) window.location.href = 'detail.html?id=' + encodeURIComponent(dataId)
|
||||
else goBack()
|
||||
}, 500)
|
||||
} catch (e) {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEditRecord() {
|
||||
try {
|
||||
const data = await dlg.runTrackedInteraction({
|
||||
name: '加载收藏详情',
|
||||
method: 'GET',
|
||||
path: '/api/mp_time_assistant/kits/personal_collect/items/' + encodeURIComponent(editId),
|
||||
params: { id: editId }
|
||||
}, () => api.personalCollect.get(editId))
|
||||
const item = data ? ((data && data.data) || data) : {}
|
||||
const itemInner = (item && item.item) || item
|
||||
$title.value = itemInner.title || ''
|
||||
$remark.value = itemInner.remark || ''
|
||||
$tags.value = (itemInner.tags && itemInner.tags.length) ? itemInner.tags.join(', ') : ''
|
||||
if (itemInner.groupId) groupId = itemInner.groupId || ''
|
||||
const rawAtts = itemInner.attachments || item.attachments || []
|
||||
const atts = rawAtts.map((a, i) => ({
|
||||
tempId: 'e_' + Date.now().toString(36) + '_' + i,
|
||||
id: a.id || '',
|
||||
storedName: a.storedName || a.stored_name || a.fileName || '',
|
||||
filePath: a.filePath || a.file_path || '',
|
||||
mimeType: a.mimeType || a.mime_type || '',
|
||||
isImage: typeof a.isImage !== 'undefined' ? !!a.isImage : !!a.is_image,
|
||||
fileName: a.fileName || a.file_name || '未命名',
|
||||
fileSize: Number(a.fileSize || a.file_size || 0),
|
||||
previewUrl: '',
|
||||
url: a.url || api.fileUrl(a.filePath || a.file_path || ''),
|
||||
status: 'ok', percent: 100, errorMsg: '', file: null
|
||||
}))
|
||||
attachments = atts
|
||||
renderAttachments()
|
||||
} catch (e) {
|
||||
dlg.showToast('加载失败:' + (e.message || '未知错误'), 'error', 3000)
|
||||
setTimeout(goBack, 1400)
|
||||
}
|
||||
}
|
||||
|
||||
function init() {
|
||||
bindDropPaste()
|
||||
bindInputEvents()
|
||||
$btnBack.addEventListener('click', goBack)
|
||||
$btnCancel.addEventListener('click', goBack)
|
||||
$form.addEventListener('submit', onSave)
|
||||
$btnSave.addEventListener('click', onSave)
|
||||
if (isEdit) {
|
||||
$pageTitle.textContent = '编辑收藏'
|
||||
loadEditRecord()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', init)
|
||||
|
||||
window.addEventListener('beforeunload', () => {
|
||||
attachments.forEach(a => { if (a.previewUrl) { try { URL.revokeObjectURL(a.previewUrl) } catch {} } })
|
||||
})
|
||||
})()
|
||||
@@ -0,0 +1,124 @@
|
||||
.list-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.fav-card {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fav-card-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.fav-card-title {
|
||||
font-size: 16.5px;
|
||||
font-weight: 600;
|
||||
color: #f0f0f8;
|
||||
line-height: 1.5;
|
||||
word-break: break-all;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fav-card-time {
|
||||
font-size: 12px;
|
||||
color: #8a8aa8;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.fav-card-remark {
|
||||
color: #b8b8d0;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.7;
|
||||
margin-bottom: 14px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 4;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.fav-card-tags {
|
||||
margin-bottom: 14px;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.fav-card-preview {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 6px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.fav-card-preview:empty { display: none; margin: 0; }
|
||||
|
||||
.fav-card-preview .thumb {
|
||||
position: relative;
|
||||
aspect-ratio: 1 / 1;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: rgba(0,0,0,0.25);
|
||||
border: 1px solid rgba(255,255,255,0.06);
|
||||
}
|
||||
.fav-card-preview .thumb img {
|
||||
width: 100%; height: 100%; object-fit: cover; display: block;
|
||||
}
|
||||
.fav-card-preview .thumb.file {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
flex-direction: column; font-size: 12px; color: #a8b4ff;
|
||||
padding: 6px; text-align: center; gap: 4px;
|
||||
}
|
||||
.fav-card-preview .thumb.file .icon { font-size: 20px; opacity: 0.8; }
|
||||
.fav-card-preview .thumb.file .name {
|
||||
font-size: 11px; color: #b8b8d0; line-height: 1.3;
|
||||
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.fav-card-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.fav-card-count {
|
||||
font-size: 12px;
|
||||
color: #8a8aa8;
|
||||
}
|
||||
|
||||
.fav-card-actions {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
padding: 7px 12px;
|
||||
font-size: 12.5px;
|
||||
border-radius: 10px;
|
||||
background: rgba(108, 122, 224, 0.1);
|
||||
color: #a8b4ff;
|
||||
border: 1px solid rgba(108, 122, 224, 0.2);
|
||||
}
|
||||
.btn-action:hover { background: rgba(108, 122, 224, 0.2); }
|
||||
.btn-action.danger {
|
||||
background: rgba(255, 80, 100, 0.08);
|
||||
color: #ff8595;
|
||||
border-color: rgba(255, 80, 100, 0.2);
|
||||
}
|
||||
.btn-action.danger:hover { background: rgba(255, 80, 100, 0.16); }
|
||||
|
||||
.fav-empty {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>微信收藏夹 | MilesYang时光助手</title>
|
||||
<link rel="stylesheet" href="/common/common.css">
|
||||
<link rel="stylesheet" href="../../shared.css">
|
||||
<link rel="stylesheet" href="list.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<header class="app-topbar">
|
||||
<div class="app-topbar-left">
|
||||
<button class="back-btn" id="btn-back" title="返回首页">←</button>
|
||||
<div>
|
||||
<div class="page-title">📁 微信收藏夹</div>
|
||||
<div class="page-subtitle">聊天附件 / 图片 / 文件,手机电脑实时同步</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="app-topbar-right">
|
||||
<button class="btn secondary btn-sm" id="btn-refresh">🔄 刷新</button>
|
||||
<button class="btn primary btn-sm" id="btn-add">+ 新增收藏</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="search-bar">
|
||||
<span class="search-icon">🔍</span>
|
||||
<input id="search-input" type="text" placeholder="搜索标题 / 备注 / 标签...(回车搜索)">
|
||||
<button class="btn ghost btn-sm" id="btn-clear-search">清空</button>
|
||||
</div>
|
||||
|
||||
<main id="list-wrap"></main>
|
||||
|
||||
<button class="fab" id="fab-add" title="新增收藏">+</button>
|
||||
</div>
|
||||
|
||||
<div id="modal-slot"></div>
|
||||
<div id="interaction-dialog" class="interaction-dialog hidden"></div>
|
||||
|
||||
<script src="../../api.js"></script>
|
||||
<script src="../../kits/common/dialog.js"></script>
|
||||
<script src="list.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,194 @@
|
||||
(function () {
|
||||
const api = window.__MPTA_API__
|
||||
const dlg = window.__MPTA_DIALOG__
|
||||
const $wrap = document.getElementById('list-wrap')
|
||||
const $search = document.getElementById('search-input')
|
||||
const $clear = document.getElementById('btn-clear-search')
|
||||
const $back = document.getElementById('btn-back')
|
||||
const $refresh = document.getElementById('btn-refresh')
|
||||
const $add = document.getElementById('btn-add')
|
||||
const $fab = document.getElementById('fab-add')
|
||||
|
||||
let currentItems = []
|
||||
let pendingKeyword = ''
|
||||
|
||||
const escapeHtml = dlg.escapeHtml
|
||||
const formatTime = dlg.formatTime
|
||||
const humanSize = dlg.humanSize
|
||||
|
||||
function goBack() { window.location.href = '../../index.html' }
|
||||
function goAdd() { window.location.href = 'edit.html' }
|
||||
function goEdit(id) { window.location.href = 'edit.html?id=' + encodeURIComponent(id) }
|
||||
function goDetail(id) { window.location.href = 'detail.html?id=' + encodeURIComponent(id) }
|
||||
|
||||
function fileIcon(fileName) {
|
||||
const ext = (fileName || '').split('.').pop().toLowerCase()
|
||||
if (/png|jpe?g|gif|webp|bmp|svg|ico/.test(ext)) return '🖼️'
|
||||
if (/pdf/.test(ext)) return '📕'
|
||||
if (/docx?|docm|dotx?/.test(ext)) return '📘'
|
||||
if (/xlsx?|xlsm|xls/.test(ext)) return '📗'
|
||||
if (/pptx?|ppsm/.test(ext)) return '📙'
|
||||
if (/txt|md|log/.test(ext)) return '📝'
|
||||
if (/zip|rar|7z|tar|gz/.test(ext)) return '🗜️'
|
||||
if (/mp3|wav|flac|m4a|aac/.test(ext)) return '🎵'
|
||||
if (/mp4|mov|avi|mkv|webm/.test(ext)) return '🎥'
|
||||
return '📄'
|
||||
}
|
||||
|
||||
function renderPreview(attachments) {
|
||||
const list = attachments || []
|
||||
if (!list.length) return ''
|
||||
const images = list.filter(a => a.isImage)
|
||||
const files = list.filter(a => !a.isImage)
|
||||
const tiles = []
|
||||
images.slice(0, 4).forEach((a) => {
|
||||
const url = api.fileUrl(a.url || a.filePath)
|
||||
tiles.push(`<div class="thumb"><img src="${url}" alt="${escapeHtml(a.fileName || 'image')}" loading="lazy"></div>`)
|
||||
})
|
||||
const fileSlots = Math.max(0, 4 - tiles.length)
|
||||
files.slice(0, fileSlots).forEach((a) => {
|
||||
tiles.push(
|
||||
`<div class="thumb file" title="${escapeHtml(a.fileName || '')}">` +
|
||||
`<span class="icon">${fileIcon(a.fileName)}</span>` +
|
||||
`<span class="name">${escapeHtml(a.fileName || '未命名')}</span>` +
|
||||
`</div>`
|
||||
)
|
||||
})
|
||||
if (images.length + files.length > 4) {
|
||||
const extra = (images.length + files.length) - 4
|
||||
tiles.push(`<div class="thumb file"><span class="icon">+${extra}</span><span class="name">更多</span></div>`)
|
||||
}
|
||||
return `<div class="fav-card-preview">${tiles.join('')}</div>`
|
||||
}
|
||||
|
||||
function renderItems(items) {
|
||||
if (!items || !items.length) {
|
||||
$wrap.innerHTML =
|
||||
`<div class="fav-empty empty-state">` +
|
||||
`<div class="empty-state-icon">📂</div>` +
|
||||
`<div class="empty-state-text">还没有收藏内容</div>` +
|
||||
`<div class="empty-state-hint">点击右上角「新增收藏」或右下角「+」开始保存</div>` +
|
||||
`</div>`
|
||||
return
|
||||
}
|
||||
$wrap.innerHTML = `<div class="list-container">` + items.map((it) => {
|
||||
const atts = it.attachments || []
|
||||
const imgCount = atts.filter(a => a.isImage).length
|
||||
const fileCount = atts.filter(a => !a.isImage).length
|
||||
const parts = []
|
||||
if (imgCount) parts.push(`${imgCount} 图`)
|
||||
if (fileCount) parts.push(`${fileCount} 文件`)
|
||||
const totalSize = atts.reduce((s, a) => s + Number(a.fileSize || 0), 0)
|
||||
if (totalSize) parts.push(humanSize(totalSize))
|
||||
const countText = parts.join(' · ')
|
||||
|
||||
const title = it.title ? escapeHtml(it.title) : '<span style="color:#8a8aa8;font-weight:500;">(无标题)</span>'
|
||||
const tags = (it.tags && it.tags.length) ? it.tags.map(t => `<span class="tag">${escapeHtml(t)}</span>`).join('') : ''
|
||||
|
||||
return (
|
||||
`<div class="card fav-card" data-id="${escapeHtml(it.id)}">` +
|
||||
`<div class="fav-card-header">` +
|
||||
`<div class="fav-card-title" data-act="detail">${title}</div>` +
|
||||
`<div class="fav-card-time">${formatTime(it.updatedAt || it.updated_at || it.createdAt || it.created_at)}</div>` +
|
||||
`</div>` +
|
||||
(it.remark ? `<div class="fav-card-remark" data-act="detail">${escapeHtml(it.remark)}</div>` : '') +
|
||||
(tags ? `<div class="fav-card-tags">${tags}</div>` : '') +
|
||||
renderPreview(atts) +
|
||||
`<div class="fav-card-footer">` +
|
||||
`<div class="fav-card-count">${countText || '纯文字笔记'}</div>` +
|
||||
`<div class="fav-card-actions">` +
|
||||
`<button class="btn-action" data-act="detail">查看</button>` +
|
||||
`<button class="btn-action" data-act="edit">编辑</button>` +
|
||||
`<button class="btn-action danger" data-act="delete">删除</button>` +
|
||||
`</div>` +
|
||||
`</div>` +
|
||||
`</div>`
|
||||
)
|
||||
}).join('') + `</div>`
|
||||
|
||||
Array.from($wrap.querySelectorAll('.fav-card')).forEach((card) => {
|
||||
const id = card.getAttribute('data-id')
|
||||
card.addEventListener('click', (e) => {
|
||||
const act = (e.target.getAttribute && e.target.getAttribute('data-act')) || 'detail'
|
||||
if (act === 'detail') goDetail(id)
|
||||
else if (act === 'edit') goEdit(id)
|
||||
else if (act === 'delete') confirmDelete(id, card)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function findTitle(id) {
|
||||
const it = currentItems.find(x => x.id === id)
|
||||
if (!it) return '该条收藏'
|
||||
return (it.title || '').trim() || '这条无标题收藏'
|
||||
}
|
||||
|
||||
function confirmDelete(id, card) {
|
||||
const title = findTitle(id)
|
||||
dlg.confirmDialog({
|
||||
title: '确认删除收藏?',
|
||||
desc: `「${title}」\n\n删除后此条收藏将不再显示(软删除,后端保留记录可追溯)。\n删除后手机端同样会消失。`,
|
||||
okText: '确认删除',
|
||||
cancelText: '取消',
|
||||
danger: true
|
||||
}, () => {
|
||||
dlg.runTrackedInteraction({
|
||||
name: `删除收藏`,
|
||||
method: 'DELETE',
|
||||
path: '/api/mp_time_assistant/kits/personal_collect/items',
|
||||
params: { id }
|
||||
}, () => api.personalCollect.remove(id)).then((res) => {
|
||||
if (card) {
|
||||
card.style.transition = 'opacity 0.25s ease, transform 0.25s ease'
|
||||
card.style.opacity = '0'
|
||||
card.style.transform = 'translateY(-6px)'
|
||||
setTimeout(() => loadList(pendingKeyword), 260)
|
||||
} else {
|
||||
loadList(pendingKeyword)
|
||||
}
|
||||
}).catch(() => {})
|
||||
})
|
||||
}
|
||||
|
||||
let searchTimer = null
|
||||
function onSearchInput() {
|
||||
const kw = ($search.value || '').trim()
|
||||
pendingKeyword = kw
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
searchTimer = setTimeout(() => loadList(kw), 250)
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
if ($search.value) { $search.value = ''; pendingKeyword = ''; loadList('') }
|
||||
}
|
||||
|
||||
function loadList(keyword) {
|
||||
pendingKeyword = keyword || ''
|
||||
dlg.runTrackedInteraction({
|
||||
name: keyword ? `搜索收藏(${keyword})` : '加载收藏列表',
|
||||
method: 'GET',
|
||||
path: '/api/mp_time_assistant/kits/personal_collect/items',
|
||||
params: { keyword: keyword || '' },
|
||||
normalViewAsToast: false
|
||||
}, () => api.personalCollect.list(keyword)).then((items) => {
|
||||
const safeItems = Array.isArray(items) ? items : []
|
||||
currentItems = safeItems
|
||||
renderItems(safeItems)
|
||||
if (keyword) {
|
||||
dlg.showToast(`搜索到 ${safeItems.length} 条`, safeItems.length ? 'success' : 'info', 1500)
|
||||
}
|
||||
}).catch(() => {
|
||||
$wrap.innerHTML = `<div class="fav-empty empty-state"><div class="empty-state-icon">❌</div><div class="empty-state-text">加载失败</div><div class="empty-state-hint">请检查后端是否已启动,并通过首页进入以完成鉴权</div></div>`
|
||||
})
|
||||
}
|
||||
|
||||
$back.addEventListener('click', goBack)
|
||||
$add.addEventListener('click', goAdd)
|
||||
$fab.addEventListener('click', goAdd)
|
||||
$refresh.addEventListener('click', () => loadList(pendingKeyword))
|
||||
$search.addEventListener('input', onSearchInput)
|
||||
$search.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); pendingKeyword = ($search.value || '').trim(); loadList(pendingKeyword) } })
|
||||
$clear.addEventListener('click', clearSearch)
|
||||
|
||||
loadList('')
|
||||
})()
|
||||
@@ -0,0 +1,296 @@
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
background: radial-gradient(1200px 800px at 10% 0%, #1a1a33 0%, #0f0f1a 60%, #0b0b15 100%);
|
||||
min-height: 100vh;
|
||||
color: #e8e8f0;
|
||||
line-height: 1.6;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
input, textarea, select {
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input::placeholder, textarea::placeholder {
|
||||
color: #555570;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 24px 80px;
|
||||
}
|
||||
|
||||
.app-topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 20px 28px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(108, 122, 224, 0.15);
|
||||
border-radius: 22px;
|
||||
backdrop-filter: blur(12px);
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.app-topbar-left { display: flex; align-items: center; gap: 14px; min-width: 0; }
|
||||
.app-topbar-right { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
|
||||
|
||||
.back-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 12px;
|
||||
background: rgba(108, 122, 224, 0.14);
|
||||
color: #a8b4ff;
|
||||
font-size: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid rgba(108, 122, 224, 0.25);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.back-btn:hover { background: rgba(108, 122, 224, 0.22); transform: translateX(-2px); }
|
||||
|
||||
.page-title {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: #f0f0f8;
|
||||
letter-spacing: 0.5px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
font-size: 13px;
|
||||
color: #8a8aa8;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 10px 18px;
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.btn.primary {
|
||||
background: linear-gradient(135deg, #6c7ae0 0%, #8a6be0 100%);
|
||||
color: #fff;
|
||||
border: 1px solid rgba(108, 122, 224, 0.5);
|
||||
box-shadow: 0 8px 20px rgba(108, 122, 224, 0.25);
|
||||
}
|
||||
.btn.primary:hover { transform: translateY(-1px); box-shadow: 0 12px 26px rgba(108, 122, 224, 0.32); }
|
||||
.btn.primary:disabled { opacity: 0.55; cursor: not-allowed; transform: none; box-shadow: none; }
|
||||
|
||||
.btn.secondary {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #cfcfe0;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.btn.secondary:hover { background: rgba(255, 255, 255, 0.08); border-color: rgba(108, 122, 224, 0.25); }
|
||||
|
||||
.btn.danger {
|
||||
background: rgba(255, 80, 100, 0.12);
|
||||
color: #ff8595;
|
||||
border: 1px solid rgba(255, 80, 100, 0.25);
|
||||
}
|
||||
.btn.danger:hover { background: rgba(255, 80, 100, 0.2); }
|
||||
|
||||
.btn.ghost {
|
||||
background: transparent;
|
||||
color: #9b9bb5;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
.btn.ghost:hover { color: #e8e8f0; background: rgba(255,255,255,0.04); }
|
||||
|
||||
.btn-sm { padding: 7px 12px; font-size: 13px; border-radius: 10px; }
|
||||
.btn-lg { padding: 14px 24px; font-size: 15px; border-radius: 14px; }
|
||||
|
||||
.card {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 22px;
|
||||
padding: 24px;
|
||||
backdrop-filter: blur(8px);
|
||||
transition: border-color 0.3s ease;
|
||||
}
|
||||
|
||||
.card:hover { border-color: rgba(108, 122, 224, 0.22); }
|
||||
|
||||
.tag {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(108, 122, 224, 0.12);
|
||||
color: #a8b4ff;
|
||||
font-size: 12px;
|
||||
border: 1px solid rgba(108, 122, 224, 0.22);
|
||||
margin-right: 6px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 100px 20px;
|
||||
color: #8a8aa8;
|
||||
}
|
||||
.empty-state-icon { font-size: 48px; margin-bottom: 16px; opacity: 0.6; }
|
||||
.empty-state-text { font-size: 15px; margin-bottom: 8px; }
|
||||
.empty-state-hint { font-size: 13px; opacity: 0.7; }
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 14px 18px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 16px;
|
||||
margin-bottom: 20px;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
.search-bar:focus-within { border-color: rgba(108, 122, 224, 0.4); }
|
||||
.search-bar .search-icon { font-size: 17px; color: #8a8aa8; flex-shrink: 0; }
|
||||
.search-bar input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #ffffff;
|
||||
font-size: 15px;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #c8c8e0;
|
||||
margin-bottom: 10px;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
.form-control {
|
||||
width: 100%;
|
||||
background: rgba(18, 18, 31, 0.7);
|
||||
border: 1px solid rgba(255, 255, 255, 0.09);
|
||||
border-radius: 14px;
|
||||
padding: 14px 18px;
|
||||
color: #ffffff !important;
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
transition: border-color 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
.form-control:focus {
|
||||
background: rgba(18, 18, 31, 0.9);
|
||||
border-color: rgba(108, 122, 224, 0.45);
|
||||
}
|
||||
textarea.form-control { resize: vertical; min-height: 140px; }
|
||||
|
||||
.modal-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
padding: 20px;
|
||||
}
|
||||
.modal-card {
|
||||
width: 100%;
|
||||
max-width: 440px;
|
||||
background: linear-gradient(180deg, #1a1a2e 0%, #141425 100%);
|
||||
border: 1px solid rgba(108, 122, 224, 0.2);
|
||||
border-radius: 20px;
|
||||
padding: 28px;
|
||||
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.modal-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #f0f0f8;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.modal-title.danger { color: #ff8595; }
|
||||
.modal-desc { color: #a8a8c0; font-size: 14px; line-height: 1.7; margin-bottom: 22px; white-space: pre-wrap; }
|
||||
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; }
|
||||
|
||||
.fab {
|
||||
position: fixed;
|
||||
right: 28px;
|
||||
bottom: 36px;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #6c7ae0 0%, #b06be0 100%);
|
||||
color: #fff;
|
||||
font-size: 28px;
|
||||
box-shadow: 0 14px 30px rgba(108, 122, 224, 0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
.fab:hover { transform: translateY(-2px) scale(1.03); box-shadow: 0 18px 38px rgba(108, 122, 224, 0.5); }
|
||||
|
||||
.interaction-dialog {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
width: min(460px, calc(100vw - 48px));
|
||||
max-height: 55vh;
|
||||
overflow-y: auto;
|
||||
background: linear-gradient(180deg, rgba(18, 18, 34, 0.98) 0%, rgba(14, 14, 28, 0.98) 100%);
|
||||
border: 1px solid rgba(108, 122, 224, 0.25);
|
||||
border-radius: 16px;
|
||||
padding: 16px 18px;
|
||||
z-index: 10000;
|
||||
backdrop-filter: blur(12px);
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.interaction-dialog.hidden { display: none; }
|
||||
.interaction-dialog .id-title {
|
||||
font-size: 13px; font-weight: 600; color: #a8b4ff;
|
||||
margin-bottom: 8px; display: flex; justify-content: space-between; align-items: center;
|
||||
}
|
||||
.interaction-dialog .id-body {
|
||||
color: #b8b8d0; white-space: pre-wrap; word-break: break-all;
|
||||
font-family: 'Consolas', 'SFMono-Regular', monospace;
|
||||
background: rgba(0,0,0,0.2); border-radius: 8px; padding: 10px; max-height: 38vh; overflow-y: auto;
|
||||
}
|
||||
.interaction-dialog .id-actions {
|
||||
margin-top: 10px; display: flex; justify-content: space-between; align-items: center; gap: 8px;
|
||||
}
|
||||
.interaction-dialog .id-countdown { color: #8a8aa8; font-size: 12px; }
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.app-shell { padding: 20px 14px 96px; }
|
||||
.app-topbar { padding: 16px 18px; flex-direction: column; align-items: flex-start; }
|
||||
.app-topbar-right { width: 100%; justify-content: flex-end; }
|
||||
.page-title { font-size: 18px; }
|
||||
.card { padding: 18px; }
|
||||
.fab { right: 20px; bottom: 26px; width: 54px; height: 54px; font-size: 24px; }
|
||||
}
|
||||
Reference in New Issue
Block a user