diff --git a/Dockerfile b/Dockerfile index e658070..1b49026 100644 --- a/Dockerfile +++ b/Dockerfile @@ -54,6 +54,9 @@ COPY --from=frontend-builder /build/fronted-2024/dist ./themes/2024 COPY --from=frontend-builder /build/fronted-2026/dist ./themes/2026 COPY --from=frontend-builder /build/fronted-2023/dist ./themes/2023 +# 应用管理后台详情页取件记录展开/收起补丁 +RUN python scripts/patch_admin_timeline_ui.py + # 安装 Python 依赖 RUN pip install --no-cache-dir -r requirements.txt diff --git a/apps/admin/services.py b/apps/admin/services.py index 3f8edba..19bc5e2 100644 --- a/apps/admin/services.py +++ b/apps/admin/services.py @@ -1301,16 +1301,18 @@ class FileService: download_stats = await TransferStats.filter( action="download", file_code_id=file_code.id ).order_by("created_at") - download_stats = self._dedupe_download_stats(download_stats) + download_event_count = len(download_stats) + display_download_count = max(int(file_code.used_count or 0), download_event_count) timeline.append( { "key": "retrieved", - "status": "done" if file_code.used_count > 0 else "pending", - "severity": "success" if file_code.used_count > 0 else "neutral", + "status": "done" if display_download_count > 0 else "pending", + "severity": "success" if display_download_count > 0 else "neutral", "timestamp": ( download_stats[-1].created_at if download_stats else file_code.created_at ), - "value": file_code.used_count, + "value": display_download_count, + "event_count": download_event_count, } ) for index, stat in enumerate(download_stats, start=1): diff --git a/scripts/patch_admin_timeline_ui.py b/scripts/patch_admin_timeline_ui.py new file mode 100644 index 0000000..eb32154 --- /dev/null +++ b/scripts/patch_admin_timeline_ui.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +from pathlib import Path +import gzip + +ASSETS_ROOT = Path('/app') + + +def rewrite(path: Path, text: str) -> None: + path.write_text(text, encoding='utf-8') + gz_path = path.with_suffix(path.suffix + '.gz') + if gz_path.exists(): + with gzip.open(gz_path, 'wb', compresslevel=9) as gz: + gz.write(text.encode('utf-8')) + + +def patch_file_manage(path: Path) -> bool: + text = path.read_text(encoding='utf-8') + old = text + if 'detailTimeline:It(s.timeline||[])' in text and 'fcbxTimelineCollapsed' not in text: + text = text.replace( + 'detailTimeline:It(s.timeline||[])}},nt=t=>', + 'detailTimeline:It(s.timeline||[]),fcbxTimelineCollapsed:!0}},nt=t=>', + 1, + ) + render_old = 'N(e(b).detailTimeline,(n,K)=>(p(),f("div",{key:`${n.key}-${K}`,class:"grid grid-cols-[auto_minmax(0,1fr)] gap-3"}' + render_new = 'N((e(b).fcbxTimelineCollapsed&&e(b).detailTimeline.filter(ga=>ga.key==="retrieved_event").length>3)?e(b).detailTimeline.filter((ga,Qa)=>ga.key!=="retrieved_event"||e(b).detailTimeline.filter(Ja=>Ja.key==="retrieved_event").indexOf(ga)<3):e(b).detailTimeline,(n,K)=>(p(),f("div",{key:`${n.key}-${K}`,class:"grid grid-cols-[auto_minmax(0,1fr)] gap-3"}' + if render_old in text and 'ga.key==="retrieved_event"' not in text: + text = text.replace(render_old, render_new, 1) + close_old = ']))),128))],2)])):j("",!0),l("section",kn,' + close_new = ']))),128)),e(b).detailTimeline.filter(n=>n.key==="retrieved_event").length>3?(p(),f("button",{key:0,type:"button",class:o(["mt-3 rounded-lg border px-3 py-2 text-sm font-medium transition",[e(u)?"border-gray-600 text-gray-200 hover:bg-gray-700":"border-gray-200 text-gray-700 hover:bg-gray-50"]]),onClick:m[99]||(m[99]=n=>e(b).fcbxTimelineCollapsed=!e(b).fcbxTimelineCollapsed)},r(e(b).fcbxTimelineCollapsed?`展开全部取件记录(共 ${e(b).detailTimeline.filter(n=>n.key==="retrieved_event").length} 次)":"收起取件记录"),3)):j("",!0)],2)])):j("",!0),l("section",kn,' + if close_old in text and '展开全部取件记录' not in text: + text = text.replace(close_old, close_new, 1) + if text != old: + rewrite(path, text) + return True + return False + + +def patch_clipboard(path: Path) -> bool: + text = path.read_text(encoding='utf-8') + old = text + marker = 'const y=async(a,e={})=>{' + if marker not in text or 'fcbx-copy-fallback' in text: + return False + start = text.index(marker) + end_marker = '},T=async(a,e={})=>{' + end = text.index(end_marker, start) + 1 + replacement = '''const y=async(a,e={})=>{const{successMsg:t="复制成功",errorMsg:n="复制失败,请手动复制",showMsg:s=!0,notify:c}=e,l=(r,d)=>{s&&c?.(r,d)},q=()=>{try{window.prompt("复制下面内容",a)}catch{}};try{if(navigator.clipboard&&window.isSecureContext)try{await navigator.clipboard.writeText(a);return l(t,"success"),!0}catch{}const r=document.createElement("textarea");r.value=a,r.setAttribute("readonly","");r.className="fcbx-copy-fallback";r.style.cssText="position:fixed;left:0;top:0;width:1px;height:1px;opacity:0;z-index:-1";document.body.appendChild(r);r.focus();r.select();r.setSelectionRange(0,r.value.length);let d=!1;try{d=document.execCommand("copy")}finally{document.body.removeChild(r)}if(d)return l(t,"success"),!0;q();l("已打开手动复制窗口","warning");return!1}catch(r){return console.error("复制失败:",r),q(),l(n,"error"),!1}}''' + text = text[:start] + replacement + text[end:] + if text != old: + rewrite(path, text) + return True + return False + + +def write_detail_collapse_script(theme_dir: Path) -> bool: + assets = theme_dir / 'assets' + index = theme_dir / 'index.html' + if not assets.exists() or not index.exists(): + return False + js_path = assets / 'fcb-detail-collapse.js' + js = r'''(() => { + const EVENT_RE = /(第\s*\d+\s*次取件|取件\s*[##]\s*\d+|Retrieval\s*#\s*\d+)/i; + const LIFE_RE = /(生命周期|Lifecycle)/i; + const markRows = () => { + document.querySelectorAll('section').forEach(section => { + const title = section.querySelector('h1,h2,h3,h4,h5'); + if (!title || !LIFE_RE.test(title.textContent || '')) return; + const rows = [...section.querySelectorAll('div.grid')].filter(row => EVENT_RE.test(row.textContent || '')); + if (rows.length <= 3) return; + if (section.dataset.fcbxLifecycleReady !== '1') { + section.dataset.fcbxLifecycleReady = '1'; + section.dataset.fcbxLifecycleCollapsed = '1'; + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'fcbx-life-toggle mt-3 rounded-lg border px-3 py-2 text-sm font-medium transition'; + btn.style.cssText = 'border-color:rgba(148,163,184,.55);display:inline-flex;align-items:center;gap:.35rem;'; + btn.addEventListener('click', event => { + event.preventDefault(); + event.stopPropagation(); + section.dataset.fcbxLifecycleCollapsed = section.dataset.fcbxLifecycleCollapsed === '1' ? '0' : '1'; + apply(section); + }); + (section.lastElementChild || section).appendChild(btn); + } + apply(section); + }); + }; + const apply = section => { + const rows = [...section.querySelectorAll('div.grid')].filter(row => EVENT_RE.test(row.textContent || '')); + const collapsed = section.dataset.fcbxLifecycleCollapsed !== '0'; + rows.forEach((row, idx) => { row.style.display = collapsed && idx >= 3 ? 'none' : ''; }); + const btn = section.querySelector('.fcbx-life-toggle'); + if (btn) btn.textContent = collapsed ? `展开全部取件记录(共 ${rows.length} 次)` : '收起取件记录'; + }; + let timer = 0; + const schedule = () => { clearTimeout(timer); timer = setTimeout(markRows, 80); }; + window.addEventListener('hashchange', schedule); + document.addEventListener('DOMContentLoaded', schedule); + new MutationObserver(schedule).observe(document.documentElement, { childList: true, subtree: true }); + schedule(); +})();''' + rewrite(js_path, js) + html = index.read_text(encoding='utf-8') + tag = '' + if 'fcb-detail-collapse.js' not in html: + html = html.replace('', tag + '\n') if '' in html else html + '\n' + tag + '\n' + index.write_text(html, encoding='utf-8') + gz = index.with_suffix(index.suffix + '.gz') + if gz.exists(): + with gzip.open(gz, 'wb', compresslevel=9) as fh: + fh.write(html.encode('utf-8')) + return True + + +def main() -> None: + changed = [] + theme = ASSETS_ROOT / 'themes/2026' + assets = theme / 'assets' + if assets.exists(): + # 只用独立 DOM 脚本处理生命周期收起,避免再改编译后的 FileManageView, + # 否则 Vue 内部状态和外部补丁会互相抢状态,导致展开/收起需要点多次。 + for path in assets.glob('clipboard-*.js'): + if patch_clipboard(path): + changed.append(str(path.relative_to(ASSETS_ROOT))) + if write_detail_collapse_script(theme): + changed.append(str((assets / 'fcb-detail-collapse.js').relative_to(ASSETS_ROOT))) + if not changed: + raise SystemExit('admin timeline/clipboard patch markers not found or already patched') + print('patched admin assets:', ', '.join(changed)) + + +if __name__ == '__main__': + main()