feat: 新增运动节拍训练工具套件与子工具页面

新增运动节拍RepClock全套小程序页面、后端API与数据库服务,新增subhub子工具汇总页面,优化全局主页样式与配置,为web静态资源添加版本缓存参数,补充配套调试脚本
This commit is contained in:
yangxiangyuan
2026-09-09 14:43:06 +08:00
parent db7648eb4d
commit d4bf3627fd
69 changed files with 5759 additions and 297 deletions
@@ -1,41 +1,80 @@
.detail-section {
margin-bottom: 28px;
margin-bottom: 22px;
}
.detail-card {
background: rgba(32, 32, 52, 0.55);
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 18px;
padding: 24px 26px;
backdrop-filter: blur(6px);
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.18);
transition: border-color 0.25s ease, transform 0.25s ease;
}
.detail-card:hover {
border-color: rgba(108, 122, 224, 0.22);
transform: translateY(-1px);
}
.detail-section-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
font-weight: 600;
color: #8a8aa8;
color: #9a9abd;
text-transform: uppercase;
letter-spacing: 0.8px;
margin-bottom: 10px;
letter-spacing: 1px;
margin-bottom: 14px;
}
.detail-section-title::before {
content: '';
display: inline-block;
width: 4px;
height: 16px;
border-radius: 3px;
background: linear-gradient(180deg, #6c7ae0 0%, #b06be0 100%);
box-shadow: 0 0 10px rgba(108, 122, 224, 0.5);
}
.detail-card-title {
background: linear-gradient(145deg, rgba(52, 52, 86, 0.75) 0%, rgba(36, 36, 60, 0.7) 100%);
border: 1px solid rgba(108, 122, 224, 0.22);
padding-left: 30px;
padding-right: 30px;
}
.detail-title {
font-size: 26px;
font-weight: 700;
font-size: 30px;
font-weight: 800;
line-height: 1.5;
color: #f4f4fa;
letter-spacing: 0.2px;
color: #fafaff;
letter-spacing: 0.3px;
text-shadow: 0 2px 14px rgba(0, 0, 0, 0.35);
}
.detail-title.empty {
color: #6a6a88;
color: #787898;
font-weight: 500;
font-size: 20px;
font-size: 22px;
font-style: italic;
text-shadow: none;
}
.detail-card-meta {
background: rgba(28, 28, 46, 0.7);
border: 1px solid rgba(255, 255, 255, 0.06);
}
.detail-meta {
display: flex;
gap: 20px;
flex-wrap: wrap;
font-size: 13px;
color: #8a8aa8;
margin-top: 14px;
font-size: 13.5px;
color: #9a9abd;
}
.detail-meta span b {
color: #c8c8e0;
font-weight: 500;
font-weight: 600;
margin-right: 6px;
letter-spacing: 0.2px;
}
.detail-tags {
@@ -45,24 +84,30 @@
margin-top: 4px;
}
.detail-card-remark {
background: linear-gradient(145deg, rgba(44, 40, 60, 0.75) 0%, rgba(34, 30, 50, 0.7) 100%);
border: 1px solid rgba(180, 150, 220, 0.16);
}
.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;
color: #e4e4f0;
font-size: 15.5px;
line-height: 2;
white-space: pre-wrap;
word-break: break-word;
letter-spacing: 0.2px;
}
.detail-remark.empty {
color: #6a6a88;
color: #787898;
text-align: center;
font-style: italic;
padding: 40px 24px;
padding: 28px 24px;
line-height: 1.8;
}
.detail-card-images {
background: rgba(24, 24, 42, 0.7);
border: 1px solid rgba(255, 255, 255, 0.05);
}
.image-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
@@ -74,27 +119,36 @@
border-radius: 14px;
overflow: hidden;
cursor: zoom-in;
background: rgba(0, 0, 0, 0.25);
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.06);
transition: transform 0.25s ease, border-color 0.25s ease;
transition: transform 0.25s ease, border-color 0.25s ease, box-shadow 0.25s ease;
}
.image-item:hover {
transform: translateY(-3px);
border-color: rgba(108, 122, 224, 0.45);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.4);
}
.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;
padding: 5px 12px;
border-radius: 999px;
font-size: 11px;
background: rgba(0, 0, 0, 0.55);
font-size: 11.5px;
background: rgba(0, 0, 0, 0.65);
color: #fff;
backdrop-filter: blur(4px);
backdrop-filter: blur(6px);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
border: 1px solid rgba(255, 255, 255, 0.08);
}
.detail-card-files {
background: rgba(24, 24, 42, 0.7);
border: 1px solid rgba(255, 255, 255, 0.05);
}
.file-list {
display: grid;
gap: 10px;
@@ -104,51 +158,58 @@
align-items: center;
gap: 14px;
padding: 14px 18px;
background: rgba(108, 122, 224, 0.06);
border: 1px solid rgba(108, 122, 224, 0.16);
background: rgba(40, 44, 72, 0.5);
border: 1px solid rgba(108, 122, 224, 0.14);
border-radius: 14px;
transition: background 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
transition: background 0.2s ease, border-color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease;
cursor: pointer;
}
.file-item:hover {
background: rgba(108, 122, 224, 0.12);
border-color: rgba(108, 122, 224, 0.32);
background: rgba(52, 58, 96, 0.6);
border-color: rgba(108, 122, 224, 0.34);
transform: translateY(-1px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3);
}
.file-icon {
width: 44px; height: 44px;
width: 46px; height: 46px;
border-radius: 12px;
display: flex; align-items: center; justify-content: center;
background: rgba(108, 122, 224, 0.18);
background: linear-gradient(145deg, rgba(108, 122, 224, 0.3) 0%, rgba(160, 110, 224, 0.25) 100%);
flex-shrink: 0;
font-size: 22px;
border: 1px solid rgba(108, 122, 224, 0.25);
}
.file-info { flex: 1; min-width: 0; }
.file-name {
font-size: 14.5px;
font-weight: 500;
color: #e8e8f4;
font-size: 15px;
font-weight: 600;
color: #ececf6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
letter-spacing: 0.2px;
}
.file-meta { font-size: 12px; color: #8a8aa8; margin-top: 4px; }
.file-meta { font-size: 12.5px; color: #9090b0; margin-top: 5px; }
.file-download {
padding: 8px 12px;
padding: 9px 14px;
border-radius: 10px;
background: rgba(108, 122, 224, 0.2);
color: #a8b4ff;
font-size: 12px;
background: linear-gradient(135deg, rgba(108, 122, 224, 0.35) 0%, rgba(160, 110, 224, 0.3) 100%);
color: #d0d8ff;
font-size: 12.5px;
font-weight: 600;
border: 1px solid rgba(108, 122, 224, 0.3);
border: 1px solid rgba(108, 122, 224, 0.4);
flex-shrink: 0;
letter-spacing: 0.3px;
}
.file-download:hover {
background: linear-gradient(135deg, rgba(108, 122, 224, 0.5) 0%, rgba(160, 110, 224, 0.45) 100%);
transform: translateY(-1px);
}
.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);
background: rgba(0, 0, 0, 0.9);
backdrop-filter: blur(10px);
display: flex; align-items: center; justify-content: center;
padding: 24px;
}
@@ -162,48 +223,55 @@
max-width: 100%;
max-height: 100%;
object-fit: contain;
border-radius: 10px;
box-shadow: 0 30px 100px rgba(0,0,0,0.6);
border-radius: 12px;
box-shadow: 0 30px 100px rgba(0,0,0,0.7);
}
.lightbox-close, .lightbox-nav {
position: absolute;
width: 44px; height: 44px;
width: 46px; height: 46px;
border-radius: 50%;
background: rgba(255,255,255,0.08);
background: rgba(255,255,255,0.1);
color: #fff;
font-size: 20px;
display: flex; align-items: center; justify-content: center;
border: 1px solid rgba(255,255,255,0.16);
border: 1px solid rgba(255,255,255,0.2);
cursor: pointer;
backdrop-filter: blur(6px);
backdrop-filter: blur(8px);
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-close:hover, .lightbox-nav:hover { background: rgba(255,255,255,0.22); transform: scale(1.08); }
.lightbox-close { top: 18px; right: 18px; }
.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-nav:hover { transform: translateY(-50%) scale(1.08); }
.lightbox-nav.prev { left: 18px; }
.lightbox-nav.next { right: 18px; }
.lightbox-caption {
position: absolute;
bottom: 20px;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
max-width: 80vw;
padding: 8px 18px;
padding: 10px 22px;
border-radius: 999px;
background: rgba(0,0,0,0.65);
background: rgba(0,0,0,0.7);
color: #fff;
font-size: 13px;
backdrop-filter: blur(6px);
backdrop-filter: blur(8px);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
border: 1px solid rgba(255,255,255,0.1);
}
@media (max-width: 600px) {
.detail-title { font-size: 20px; }
.detail-remark { padding: 18px; font-size: 14.5px; }
.detail-card { padding: 18px 18px; border-radius: 15px; }
.detail-title { font-size: 22px; }
.detail-remark { padding: 0; font-size: 14.5px; line-height: 1.9; }
.detail-remark.empty { padding: 22px 12px; }
.image-grid { grid-template-columns: repeat(2, 1fr); gap: 10px; }
.lightbox-close, .lightbox-nav { width: 40px; height: 40px; font-size: 18px; }
.file-item { padding: 12px 14px; gap: 12px; }
.file-icon { width: 40px; height: 40px; font-size: 19px; border-radius: 10px; }
.file-name { font-size: 14px; }
.file-download { padding: 7px 11px; font-size: 12px; }
}
@@ -4,9 +4,9 @@
<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">
<link rel="stylesheet" href="/common/common.css?v=1.10">
<link rel="stylesheet" href="../../shared.css?v=1.10">
<link rel="stylesheet" href="detail.css?v=1.10">
</head>
<body>
<div class="app-shell">
@@ -43,8 +43,8 @@
<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>
<script src="../../api.js?v=1.10"></script>
<script src="../../kits/common/dialog.js?v=1.10"></script>
<script src="detail.js?v=1.10"></script>
</body>
</html>
@@ -21,6 +21,101 @@
const formatTime = dlg.formatTime
const humanSize = dlg.humanSize
function _scoreCN(str) {
if (!str) return -Infinity
let cjk = 0, asc = 0, ext = 0, weird = 0
for (let i = 0; i < str.length; i++) {
const c = str.charCodeAt(i)
if (c >= 0x4e00 && c <= 0x9fff) cjk++
else if (c >= 0x3400 && c <= 0x4dbf) cjk++
else if (c >= 0x3000 && c <= 0x303f) cjk++
else if (c >= 0xff00 && c <= 0xffef) cjk++
else if (c === 0xb7 || c === 0x2022) cjk++
else if (c >= 0x20 && c <= 0x7e) asc++
else if (c < 0x20 && c !== 9 && c !== 10 && c !== 13) weird++
else if (c >= 0x80 && c <= 0xff) ext++
else if (c >= 0x100 && c < 0x3000) ext++
else ext++
}
return cjk * 3 + asc * 1 - ext * 2 - weird * 10
}
function _decodeBytes(bytes, enc) {
try {
const dec = new TextDecoder(enc, { fatal: false })
const s = dec.decode(new Uint8Array(bytes))
return (s == null ? '' : String(s)).replace(/\u0000/g, '')
} catch { return '' }
}
function fixFileName(raw) {
const name = String(raw == null ? '' : raw).trim()
if (!name) return '未命名文件'
const em = name.match(/(\.[A-Za-z0-9]{1,10})$/)
const ext = em ? em[1] : ''
const base = ext ? name.slice(0, name.length - ext.length) : name
if (!base) return name
const cands = [{ s: name, src: 'origin' }]
const bytes = []
for (let i = 0; i < base.length; i++) {
const c = base.charCodeAt(i)
if (c >= 0xd800 && c <= 0xdbff && i + 1 < base.length) {
const low = base.charCodeAt(i + 1)
if (low >= 0xdc00 && low <= 0xdfff) {
const cp = 0x10000 + (c - 0xd800) * 0x400 + (low - 0xdc00)
bytes.push(0xff & (cp >> 8), 0xff & cp)
i++
continue
}
}
bytes.push(0xff & c)
}
const tryDec = (encList, srcPrefix) => {
encList.forEach(enc => {
try {
const s = _decodeBytes(bytes, enc)
if (s) cands.push({ s: s + ext, src: srcPrefix + '_' + enc })
} catch {}
})
}
if (bytes.length) {
tryDec(['gbk', 'gb18030', 'utf-8', 'big5', 'shift-jis', 'euc-kr'], 'raw')
const rev = bytes.slice().reverse()
const encs2 = ['gbk', 'gb18030']
encs2.forEach(enc => {
try {
const s = _decodeBytes(bytes, enc)
if (s) {
const bytes2 = []
for (let i = 0; i < s.length; i++) bytes2.push(0xff & s.charCodeAt(i))
const s2 = _decodeBytes(bytes2, 'utf-8')
if (s2) cands.push({ s: s2 + ext, src: 'doub_' + enc + '_utf8' })
}
} catch {}
})
}
let best = name, bestSc = _scoreCN(name) - (
/[ÃÂÄÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞßçèéêëìíîïðñòóôõöøùúûüýþÿ]/.test(name) ? 15 : 0
) - (/[\u4e00-\u9fa5]/.test(name) && /[çèéêëìíîïðñòóôõöøùúûüýþÿÃÂÄÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞß]/.test(name) ? 25 : 0)
cands.forEach(c => {
if (!c || !c.s) return
const s = c.s
if (/\uFFFD/.test(s) && c.src !== 'origin') return
let sc = _scoreCN(s)
if (/[ÃÂÄÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞßçèéêëìíîïðñòóôõöøùúûüýþÿ]/.test(s)) sc -= 10
if (sc > bestSc) { bestSc = sc; best = s }
})
return best.length > 255 ? best.slice(0, 250) + (ext || '') : best
}
const q = new URLSearchParams(location.search)
const id = q.get('id')
let current = null
@@ -98,17 +193,17 @@
const filePath = a.filePath || a.file_path
return {
src: a.url || api.fileUrl(filePath),
name: a.fileName || a.file_name || ''
name: fixFileName(a.fileName || a.file_name || '')
}
})
return (
`<section class="detail-section">` +
`<section class="detail-section detail-card detail-card-images">` +
`<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 name = fixFileName(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>`
@@ -121,13 +216,13 @@
function renderFileSection(files) {
if (!files || !files.length) return ''
return (
`<section class="detail-section">` +
`<section class="detail-section detail-card detail-card-files">` +
`<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 name = fixFileName(a.fileName || a.file_name || '未命名文件')
const size = Number(a.fileSize || a.file_size || 0)
const mimeType = a.mimeType || a.mime_type || ''
return (
@@ -161,15 +256,15 @@
: ''
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>`
? `<section class="detail-section detail-card detail-card-remark"><div class="detail-section-title">备注</div><div class="detail-remark">${escapeHtml(item.remark)}</div></section>`
: `<section class="detail-section detail-card detail-card-remark"><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>`
? `<section class="detail-section detail-card detail-card-title"><div class="detail-title">${escapeHtml(dispTitle)}</div></section>`
: `<section class="detail-section detail-card detail-card-title"><div class="detail-title empty">(该条收藏未设置标题)</div></section>`
const metaBlock =
`<section class="detail-section">` +
`<section class="detail-section detail-card detail-card-meta">` +
`<div class="detail-meta">` +
`<span><b>创建时间</b>${formatTime(createdAt)}</span>` +
`<span><b>更新时间</b>${formatTime(updatedAt)}</span>` +
@@ -4,9 +4,9 @@
<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">
<link rel="stylesheet" href="/common/common.css?v=1.10">
<link rel="stylesheet" href="../../shared.css?v=1.10">
<link rel="stylesheet" href="edit.css?v=1.10">
</head>
<body>
<div class="app-shell">
@@ -70,8 +70,8 @@
<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>
<script src="../../api.js?v=1.10"></script>
<script src="../../kits/common/dialog.js?v=1.10"></script>
<script src="edit.js?v=1.10"></script>
</body>
</html>
@@ -20,6 +20,100 @@
const escapeHtml = dlg.escapeHtml
const humanSize = dlg.humanSize
function _scoreCN(str) {
if (!str) return -Infinity
let cjk = 0, asc = 0, ext = 0, weird = 0
for (let i = 0; i < str.length; i++) {
const c = str.charCodeAt(i)
if (c >= 0x4e00 && c <= 0x9fff) cjk++
else if (c >= 0x3400 && c <= 0x4dbf) cjk++
else if (c >= 0x3000 && c <= 0x303f) cjk++
else if (c >= 0xff00 && c <= 0xffef) cjk++
else if (c === 0xb7 || c === 0x2022) cjk++
else if (c >= 0x20 && c <= 0x7e) asc++
else if (c < 0x20 && c !== 9 && c !== 10 && c !== 13) weird++
else if (c >= 0x80 && c <= 0xff) ext++
else if (c >= 0x100 && c < 0x3000) ext++
else ext++
}
return cjk * 3 + asc * 1 - ext * 2 - weird * 10
}
function _decodeBytes(bytes, enc) {
try {
const dec = new TextDecoder(enc, { fatal: false })
const s = dec.decode(new Uint8Array(bytes))
return (s == null ? '' : String(s)).replace(/\u0000/g, '')
} catch { return '' }
}
function fixFileName(raw) {
const name = String(raw == null ? '' : raw).trim()
if (!name) return '未命名文件'
const em = name.match(/(\.[A-Za-z0-9]{1,10})$/)
const ext = em ? em[1] : ''
const base = ext ? name.slice(0, name.length - ext.length) : name
if (!base) return name
const cands = [{ s: name, src: 'origin' }]
const bytes = []
for (let i = 0; i < base.length; i++) {
const c = base.charCodeAt(i)
if (c >= 0xd800 && c <= 0xdbff && i + 1 < base.length) {
const low = base.charCodeAt(i + 1)
if (low >= 0xdc00 && low <= 0xdfff) {
const cp = 0x10000 + (c - 0xd800) * 0x400 + (low - 0xdc00)
bytes.push(0xff & (cp >> 8), 0xff & cp)
i++
continue
}
}
bytes.push(0xff & c)
}
const tryDec = (encList, srcPrefix) => {
encList.forEach(enc => {
try {
const s = _decodeBytes(bytes, enc)
if (s) cands.push({ s: s + ext, src: srcPrefix + '_' + enc })
} catch {}
})
}
if (bytes.length) {
tryDec(['gbk', 'gb18030', 'utf-8', 'big5', 'shift-jis', 'euc-kr'], 'raw')
const encs2 = ['gbk', 'gb18030']
encs2.forEach(enc => {
try {
const s = _decodeBytes(bytes, enc)
if (s) {
const bytes2 = []
for (let i = 0; i < s.length; i++) bytes2.push(0xff & s.charCodeAt(i))
const s2 = _decodeBytes(bytes2, 'utf-8')
if (s2) cands.push({ s: s2 + ext, src: 'doub_' + enc + '_utf8' })
}
} catch {}
})
}
let best = name, bestSc = _scoreCN(name) - (
/[ÃÂÄÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞßçèéêëìíîïðñòóôõöøùúûüýþÿ]/.test(name) ? 15 : 0
) - (/[\u4e00-\u9fa5]/.test(name) && /[çèéêëìíîïðñòóôõöøùúûüýþÿÃÂÄÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞß]/.test(name) ? 25 : 0)
cands.forEach(c => {
if (!c || !c.s) return
const s = c.s
if (/\uFFFD/.test(s) && c.src !== 'origin') return
let sc = _scoreCN(s)
if (/[ÃÂÄÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞßçèéêëìíîïðñòóôõöøùúûüýþÿ]/.test(s)) sc -= 10
if (sc > bestSc) { bestSc = sc; best = s }
})
return best.length > 255 ? best.slice(0, 250) + (ext || '') : best
}
const q = new URLSearchParams(location.search)
const editId = q.get('id')
const isEdit = !!editId
@@ -81,17 +175,18 @@
const tempId = a.tempId
const isImg = !!a.isImage
const displayUrl = a.url || a.previewUrl || ''
const ext = (a.fileName || '').split('.').pop().toUpperCase()
const fn = fixFileName(a.fileName)
const ext = (fn || '').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="preview"><img src="${displayUrl}" alt="${escapeHtml(fn || '')}" loading="lazy"></div>`
: `<div class="preview"><span class="big">${fileIcon(fn)}</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-name" title="${escapeHtml(fn || '')}">${escapeHtml(fn || '未命名')}</div>` +
`<div class="attach-meta">` +
`<span>${humanSize(a.fileSize)}</span>` +
`<span class="attach-status ${a.status}">${statusText}</span>` +
@@ -212,7 +307,7 @@
let title = ''
if (total === 1) {
const only = atts[0]
const raw = only.fileName || '附件'
const raw = fixFileName(only.fileName || '附件')
const dot = raw.lastIndexOf('.')
const stem = dot > 0 ? raw.slice(0, dot) : raw
if (only.isImage) title = `图片 - ${ts}`
@@ -220,7 +315,7 @@
} 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 only = files[0]; const raw = fixFileName(only.fileName || '附件')
const dot = raw.lastIndexOf('.'); const stem = dot > 0 ? raw.slice(0, dot) : raw
title = stem || raw
} else title = `${fc} 个文件 - ${ts}`
@@ -230,7 +325,7 @@
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)})`))
atts.slice(0, 9).forEach((a, i) => lines.push(` ${i+1}. ${fixFileName(a.fileName || '未命名')}(${humanSize(a.fileSize)})`))
if (atts.length > 9) lines.push(` ... 另有 ${atts.length - 9} 个附件未列出`)
lines.push('', `整理时间:${ts}`)
return { title, remark: lines.join('\n') }
@@ -440,7 +535,7 @@
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 || '未命名',
fileName: fixFileName(a.fileName || a.file_name || '未命名'),
fileSize: Number(a.fileSize || a.file_size || 0),
previewUrl: '',
url: a.url || api.fileUrl(a.filePath || a.file_path || ''),
@@ -4,9 +4,9 @@
<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">
<link rel="stylesheet" href="/common/common.css?v=1.10">
<link rel="stylesheet" href="../../shared.css?v=1.10">
<link rel="stylesheet" href="list.css?v=1.10">
</head>
<body>
<div class="app-shell">
@@ -38,8 +38,8 @@
<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>
<script src="../../api.js?v=1.10"></script>
<script src="../../kits/common/dialog.js?v=1.10"></script>
<script src="list.js?v=1.10"></script>
</body>
</html>
@@ -16,6 +16,100 @@
const formatTime = dlg.formatTime
const humanSize = dlg.humanSize
function _scoreCN(str) {
if (!str) return -Infinity
let cjk = 0, asc = 0, ext = 0, weird = 0
for (let i = 0; i < str.length; i++) {
const c = str.charCodeAt(i)
if (c >= 0x4e00 && c <= 0x9fff) cjk++
else if (c >= 0x3400 && c <= 0x4dbf) cjk++
else if (c >= 0x3000 && c <= 0x303f) cjk++
else if (c >= 0xff00 && c <= 0xffef) cjk++
else if (c === 0xb7 || c === 0x2022) cjk++
else if (c >= 0x20 && c <= 0x7e) asc++
else if (c < 0x20 && c !== 9 && c !== 10 && c !== 13) weird++
else if (c >= 0x80 && c <= 0xff) ext++
else if (c >= 0x100 && c < 0x3000) ext++
else ext++
}
return cjk * 3 + asc * 1 - ext * 2 - weird * 10
}
function _decodeBytes(bytes, enc) {
try {
const dec = new TextDecoder(enc, { fatal: false })
const s = dec.decode(new Uint8Array(bytes))
return (s == null ? '' : String(s)).replace(/\u0000/g, '')
} catch { return '' }
}
function fixFileName(raw) {
const name = String(raw == null ? '' : raw).trim()
if (!name) return '未命名文件'
const em = name.match(/(\.[A-Za-z0-9]{1,10})$/)
const ext = em ? em[1] : ''
const base = ext ? name.slice(0, name.length - ext.length) : name
if (!base) return name
const cands = [{ s: name, src: 'origin' }]
const bytes = []
for (let i = 0; i < base.length; i++) {
const c = base.charCodeAt(i)
if (c >= 0xd800 && c <= 0xdbff && i + 1 < base.length) {
const low = base.charCodeAt(i + 1)
if (low >= 0xdc00 && low <= 0xdfff) {
const cp = 0x10000 + (c - 0xd800) * 0x400 + (low - 0xdc00)
bytes.push(0xff & (cp >> 8), 0xff & cp)
i++
continue
}
}
bytes.push(0xff & c)
}
const tryDec = (encList, srcPrefix) => {
encList.forEach(enc => {
try {
const s = _decodeBytes(bytes, enc)
if (s) cands.push({ s: s + ext, src: srcPrefix + '_' + enc })
} catch {}
})
}
if (bytes.length) {
tryDec(['gbk', 'gb18030', 'utf-8', 'big5', 'shift-jis', 'euc-kr'], 'raw')
const encs2 = ['gbk', 'gb18030']
encs2.forEach(enc => {
try {
const s = _decodeBytes(bytes, enc)
if (s) {
const bytes2 = []
for (let i = 0; i < s.length; i++) bytes2.push(0xff & s.charCodeAt(i))
const s2 = _decodeBytes(bytes2, 'utf-8')
if (s2) cands.push({ s: s2 + ext, src: 'doub_' + enc + '_utf8' })
}
} catch {}
})
}
let best = name, bestSc = _scoreCN(name) - (
/[ÃÂÄÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞßçèéêëìíîïðñòóôõöøùúûüýþÿ]/.test(name) ? 15 : 0
) - (/[\u4e00-\u9fa5]/.test(name) && /[çèéêëìíîïðñòóôõöøùúûüýþÿÃÂÄÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞß]/.test(name) ? 25 : 0)
cands.forEach(c => {
if (!c || !c.s) return
const s = c.s
if (/\uFFFD/.test(s) && c.src !== 'origin') return
let sc = _scoreCN(s)
if (/[ÃÂÄÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞßçèéêëìíîïðñòóôõöøùúûüýþÿ]/.test(s)) sc -= 10
if (sc > bestSc) { bestSc = sc; best = s }
})
return best.length > 255 ? best.slice(0, 250) + (ext || '') : best
}
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) }
@@ -43,14 +137,16 @@
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 fn = fixFileName(a.fileName)
tiles.push(`<div class="thumb"><img src="${url}" alt="${escapeHtml(fn || 'image')}" loading="lazy"></div>`)
})
const fileSlots = Math.max(0, 4 - tiles.length)
files.slice(0, fileSlots).forEach((a) => {
const fn = fixFileName(a.fileName)
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 class="thumb file" title="${escapeHtml(fn || '')}">` +
`<span class="icon">${fileIcon(fn)}</span>` +
`<span class="name">${escapeHtml(fn || '未命名')}</span>` +
`</div>`
)
})