feat(mp, web, calendar): 新增微信收藏夹与日历批量生成功能

- 新增微信小程序版个人收藏夹全套功能,包含聚合首页、收藏列表、编辑、详情页面与配套后端API
- 新增web版时光助手门户与个人收藏夹工具,支持与小程序共享后端数据
- 优化日历提醒工具:替换本地bootstrap资源为公共CDN版本,新增批量生成循环提醒的前端界面与后端逻辑,新增数据库batch_id字段,更新鉴权配置文件
- 补充项目配置、站点地图、调试诊断脚本等配套文件
- 完善各模块的页面配置与样式文件
This commit is contained in:
yangxiangyuan
2026-09-02 22:20:37 +08:00
parent 132d132473
commit db7648eb4d
75 changed files with 8618 additions and 70 deletions
@@ -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="可输入详细备注、说明、关键词...&#10;(未填且有附件时,会根据附件自动生成清单)"></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('')
})()