docs(ui05,ui06): admin analysis+HTML preview, app-preview evidence, UI06 unified visual plan

- admin-preview-2026-09-22/ 20页新版后台HTML原型+截图+validation(UI05 交付)
- app-preview-2026-09-22/ 截图/validation/check 脚本入库及小改
- admin-redesign-concept 概念图
- UI05 管理后台分析与新版设计说明、用户列表分页计划文档
- UI06 全端视觉统一实施方案(以两个 HTML 预览为唯一视觉基准)
- services/api g4-sync-once 手动同步脚本

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-22 22:42:00 +08:00
parent dbf958613f
commit 37d88da4bc
85 changed files with 1149 additions and 3 deletions
@@ -0,0 +1 @@
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>PhotoLive · 登录</title><link rel="stylesheet" href="styles.css"></head><body data-page="1"><div id="root"></div><script src="icons.js"></script><script src="pages.js"></script><script src="admin.js"></script></body></html>
@@ -0,0 +1 @@
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>PhotoLive · 相册管理</title><link rel="stylesheet" href="styles.css"></head><body data-page="2"><div id="root"></div><script src="icons.js"></script><script src="pages.js"></script><script src="admin.js"></script></body></html>
@@ -0,0 +1 @@
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>PhotoLive · 相册照片</title><link rel="stylesheet" href="styles.css"></head><body data-page="3"><div id="root"></div><script src="icons.js"></script><script src="pages.js"></script><script src="admin.js"></script></body></html>
@@ -0,0 +1 @@
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>PhotoLive · 相册成员</title><link rel="stylesheet" href="styles.css"></head><body data-page="4"><div id="root"></div><script src="icons.js"></script><script src="pages.js"></script><script src="admin.js"></script></body></html>
@@ -0,0 +1 @@
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>PhotoLive · 分类管理</title><link rel="stylesheet" href="styles.css"></head><body data-page="5"><div id="root"></div><script src="icons.js"></script><script src="pages.js"></script><script src="admin.js"></script></body></html>
@@ -0,0 +1 @@
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>PhotoLive · 水印配置</title><link rel="stylesheet" href="styles.css"></head><body data-page="6"><div id="root"></div><script src="icons.js"></script><script src="pages.js"></script><script src="admin.js"></script></body></html>
@@ -0,0 +1 @@
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>PhotoLive · 相册分享</title><link rel="stylesheet" href="styles.css"></head><body data-page="7"><div id="root"></div><script src="icons.js"></script><script src="pages.js"></script><script src="admin.js"></script></body></html>
@@ -0,0 +1 @@
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>PhotoLive · 新建相册</title><link rel="stylesheet" href="styles.css"></head><body data-page="8"><div id="root"></div><script src="icons.js"></script><script src="pages.js"></script><script src="admin.js"></script></body></html>
@@ -0,0 +1 @@
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>PhotoLive · 编辑相册</title><link rel="stylesheet" href="styles.css"></head><body data-page="9"><div id="root"></div><script src="icons.js"></script><script src="pages.js"></script><script src="admin.js"></script></body></html>
@@ -0,0 +1 @@
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>PhotoLive · 账号管理</title><link rel="stylesheet" href="styles.css"></head><body data-page="10"><div id="root"></div><script src="icons.js"></script><script src="pages.js"></script><script src="admin.js"></script></body></html>
@@ -0,0 +1 @@
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>PhotoLive · 用户同步</title><link rel="stylesheet" href="styles.css"></head><body data-page="11"><div id="root"></div><script src="icons.js"></script><script src="pages.js"></script><script src="admin.js"></script></body></html>
@@ -0,0 +1 @@
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>PhotoLive · 存储与用量</title><link rel="stylesheet" href="styles.css"></head><body data-page="12"><div id="root"></div><script src="icons.js"></script><script src="pages.js"></script><script src="admin.js"></script></body></html>
@@ -0,0 +1 @@
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>PhotoLive · 网站展示位</title><link rel="stylesheet" href="styles.css"></head><body data-page="13"><div id="root"></div><script src="icons.js"></script><script src="pages.js"></script><script src="admin.js"></script></body></html>
@@ -0,0 +1 @@
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>PhotoLive · 配置展示位</title><link rel="stylesheet" href="styles.css"></head><body data-page="14"><div id="root"></div><script src="icons.js"></script><script src="pages.js"></script><script src="admin.js"></script></body></html>
@@ -0,0 +1 @@
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>PhotoLive · 宣传图与资产</title><link rel="stylesheet" href="styles.css"></head><body data-page="15"><div id="root"></div><script src="icons.js"></script><script src="pages.js"></script><script src="admin.js"></script></body></html>
@@ -0,0 +1 @@
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>PhotoLive · 照片大图</title><link rel="stylesheet" href="styles.css"></head><body data-page="16"><div id="root"></div><script src="icons.js"></script><script src="pages.js"></script><script src="admin.js"></script></body></html>
@@ -0,0 +1 @@
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>PhotoLive · 无权限</title><link rel="stylesheet" href="styles.css"></head><body data-page="17"><div id="root"></div><script src="icons.js"></script><script src="pages.js"></script><script src="admin.js"></script></body></html>
@@ -0,0 +1 @@
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>PhotoLive · 页面不存在</title><link rel="stylesheet" href="styles.css"></head><body data-page="18"><div id="root"></div><script src="icons.js"></script><script src="pages.js"></script><script src="admin.js"></script></body></html>
@@ -0,0 +1 @@
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>PhotoLive · 服务异常</title><link rel="stylesheet" href="styles.css"></head><body data-page="19"><div id="root"></div><script src="icons.js"></script><script src="pages.js"></script><script src="admin.js"></script></body></html>
@@ -0,0 +1 @@
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>PhotoLive · 同步批次详情</title><link rel="stylesheet" href="styles.css"></head><body data-page="20"><div id="root"></div><script src="icons.js"></script><script src="pages.js"></script><script src="admin.js"></script></body></html>
@@ -0,0 +1,63 @@
# PhotoLive 管理后台新版 HTML 原型
打开 **index.html**,使用左侧菜单浏览;点击“全部页面目录”查看20个入口。每个页面也有独立HTML文件,可双击打开。无需启动服务或访问互联网。
视觉基于Art Design Pro / Element Plus的后台布局,但此目录是独立原生HTML原型,没有改动 `apps/admin`。
## 交付文件
- `index.html`:默认相册管理入口。
- `01-*.html` 至 `20-*.html`:独立页面,见下表。
- `styles.css`、`admin.js`、`icons.js`、`pages.js`:共享外观、交互及索引。
- `assets/`:离线示例图片与演示二维码。
- `screenshots/01.png` 至 `20.png`:实际HTML渲染截图;mobile前缀为窄屏检查。
- `validation.json`:本次浏览器检查结果。
- `image-prompt.txt`:概念图完整生成提示词。
- `../../plans/2026-09-22-UI05-管理后台分析与新版设计说明.md`:现状分析、接口差异及实施映射。
## 页面目录
| 编号 | 内容 | 文件 |
|---|---|---|
| 01 | 账号密码登录 | 01-login.html |
| 02 | 相册卡片/表格列表 | 02-albums.html |
| 03 | 相册照片网格/表格 | 03-photos.html |
| 04 | 成员与所有者交接 | 04-members.html |
| 05 | 双语分类 | 05-categories.html |
| 06 | 水印预览、生成任务 | 06-watermark.html |
| 07 | 分享卡、二维码、链接 | 07-sharing.html |
| 08 | 新建相册表单 | 08-create-album.html |
| 09 | 编辑资料、访问开关 | 09-edit-album.html |
| 10 | 账号、角色与治理 | 10-users.html |
| 11 | 用户目录同步 | 11-user-sync.html |
| 12 | 用量、阈值与告警 | 12-usage.html |
| 13 | 展示位、绑定、接入代码 | 13-embed-slots.html |
| 14 | v1/v2配置与小窗预览 | 14-embed-editor.html |
| 15 | 宣传图与资产入口 | 15-branding.html |
| 16 | 管理大图及操作 | 16-photo-viewer.html |
| 17 | 403 | 17-403.html |
| 18 | 404 | 18-404.html |
| 19 | 500 | 19-500.html |
| 20 | 同步批次失败明细 | 20-sync-detail.html |
## 可以体验
- 中英文切换、相册搜索、卡片/表格切换、照片分类/状态筛选。
- 查看大图、单张隐藏/恢复/软删除、选择已公开照片批量隐藏。
- 创建和编辑示例相册、邀请内部示例用户、交接所有者、分类增改。
- 水印文字/位置/透明度/字号即时预览。
- 账号搜索、角色修改、重置密码流程、激活/锁定;自身账号角色不可改。
- 同步运行模拟、失败详情、用量阈值对话框。
- 展示位开关/绑定、v1/v2设置、固定195×280预览、收起展开、模拟气泡、复制示例代码。
## 重要边界
所有数据和操作为样例,不调用生产接口、不真正重置密码或上传/删除文件。请勿输入真实账号密码。原型状态只存在当前标签页内存,重新打开独立页会重置。
摄影局部来自本次生成的后台概念图,`assets/photo-reference.png` 保留离线来源。界面文字、表单、卡片和表格均为真实DOM。样例文件名、时间、人数和数据不能抄进生产默认值。
分享QR指向 `https://example.com/photolive-preview`,仅用于展示,不是相册链接。跨原型链接依赖上一层已生成的 `viewer-preview-2026-09-22.html` / `embed-preview-2026-09-22.html`;搬运目录时请一同携带,或修改为实际目标。
嵌入配置中的预览为简化CSS展示;正式组件须复用现有UI04代码。此次未完成真实角色权限、OSS、网络异常、水印生成、用户同步或Art Design Pro运行集成验收。
`build-preview.cjs` / `check-preview.cjs` 是本仓库的生成和检查辅助脚本。普通浏览无需执行它们。
@@ -0,0 +1,65 @@
/* PhotoLive admin design prototype. All writes are local sample-state changes. */
const $=s=>document.querySelector(s),$$=s=>[...document.querySelectorAll(s)];
const icon=n=>`<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="${window.ICONS[n]||window.ICONS.photo}"/></svg>`;
const esc=s=>String(s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
let lang='zh',page=2,albumIndex=0,albumMode='grid',photoMode='grid',photoFilter='all',photoCategory='all',photoQuery='',albumQuery='',albumStatus='all',currentPhoto=0,selected=new Set(),toastTimer,syncRunning=false,wmVersion=3,wmPosition='bottom-right',slotIndex=0;
const T=(en,zh)=>lang==='zh'?zh:en;
const albums=[{title:'Canton Fair · Autumn',zh:'广交会 · 秋季',date:'2026.10.15 – 10.19',venue:'Guangzhou',owner:'Alex Morgan',public:true,upload:true,photo:0},{title:'Product Launch 2026',zh:'2026 新品发布会',date:'2026.09.28',venue:'Shenzhen',owner:'Emma Chen',public:true,upload:true,photo:5},{title:'Team Stories',zh:'团队故事',date:'2026.09.22',venue:'Shanghai',owner:'Daniel Lee',public:false,upload:true,photo:2},{title:'Design Summit',zh:'设计峰会',date:'2026.10.08 – 10.09',venue:'Hangzhou',owner:'Alex Morgan',public:true,upload:false,photo:4},{title:'Partner Conference',zh:'合作伙伴大会',date:'2026.11.12',venue:'Singapore',owner:'Emma Chen',public:false,upload:true,photo:1},{title:'Innovation Week',zh:'创新周',date:'2026.11.20 – 11.24',venue:'Guangzhou',owner:'Daniel Lee',public:true,upload:true,photo:3}];
const people=[{name:'Alex Morgan',account:'alex.morgan',role:'admin',source:'local',credential:'active',locked:false},{name:'Emma Chen',account:'emma.chen',role:'staff',source:'active',credential:'active',locked:false},{name:'Daniel Lee',account:'daniel.lee',role:'staff',source:'active',credential:'pending',locked:false},{name:'Olivia Wang',account:'olivia.wang',role:'staff',source:'disabled',credential:'active',locked:true},{name:'James Wilson',account:'james.wilson',role:'staff',source:'active',credential:'active',locked:false}];
let memberIds=[0,1,2],cats=[{en:'Exhibition',zh:'展会现场'},{en:'People',zh:'人物交流'},{en:'Highlights',zh:'精彩瞬间'}];
const photoStates=Array.from({length:12},(_,i)=>i===6?'hidden':i===10?'unpublished':i===11?'deleted':'published');
const slots=[{name:'Corporate website',zh:'企业官网',album:0,enabled:true,mode:'v2',version:4,speed:16},{name:'Event landing page',zh:'活动专题页',album:1,enabled:true,mode:'v1',version:2,speed:16},{name:'Partner portal',zh:'合作伙伴门户',album:null,enabled:false,mode:'v2',version:1,speed:16}];
// Photographic regions only; the full source board is never used as the UI.
const pics=[[292,165,393,149],[612,565,292,117],[931,565,292,117],[1247,565,289,117],[279,786,295,118],[608,786,295,118],[934,786,289,118],[1250,786,287,118]];
function photo(i,cls=''){const[x,y,w,h]=pics[(i+pics.length)%pics.length];return `<div class="art ${cls}" style="aspect-ratio:${w}/${h}"><svg width="100%" height="100%" viewBox="${x} ${y} ${w} ${h}" preserveAspectRatio="xMidYMid slice" aria-hidden="true" style="display:block"><image href="assets/photo-reference.png" width="1586" height="992"/></svg></div>`}
function btn(label,action,cls='',ic=''){return `<button class="btn ${cls}" data-action="${action}">${ic?icon(ic):''}${label}</button>`}
function pill(label,cls=''){return `<span class="pill ${cls}"><span class="dot"></span>${label}</span>`}
function title(a=albums[albumIndex]){return esc(lang==='zh'?a.zh:a.title)}
function avatar(name,cls=''){return `<span class="avatar ${cls}">${esc(name.split(' ').map(x=>x[0]).join(''))}</span>`}
function field(label,id,value='',type='text',note=''){return `<label class="field">${label}<input id="${id}" type="${type}" value="${esc(value)}">${note?`<small>${note}</small>`:''}</label>`}
function select(label,id,opts,value){return `<label class="field">${label}<select id="${id}">${opts.map(([v,s])=>`<option value="${v}" ${v===value?'selected':''}>${s}</option>`).join('')}</select></label>`}
function switchRow(label,desc,id,on){return `<label class="switch-row"><span><strong>${label}</strong><p>${desc}</p></span><input class="switch" type="checkbox" id="${id}" ${on?'checked':''}></label>`}
function table(headers,rows){return `<div class="data-wrap"><table class="data-table"><thead><tr>${headers.map(x=>`<th>${x}</th>`).join('')}</tr></thead><tbody>${rows.map(cols=>`<tr>${cols.map(x=>`<td>${x}</td>`).join('')}</tr>`).join('')}</tbody></table></div>`}
function heading(name,desc,actions=''){return `<div class="page-heading"><div><h1>${name}</h1><p>${desc}</p></div><div class="actions">${actions}</div></div>`}
function tabs(){return `<nav class="tabs">${[[3,'Photos','照片'],[4,'Members','成员'],[5,'Categories','分类'],[6,'Watermark','水印'],[7,'Sharing','分享'],[15,'Branding','宣传图与资产']].map(([id,en,zh])=>`<a href="#${id}" class="${page===id?'active':''}">${T(en,zh)}</a>`).join('')}</nav>`}
function albumHeader(){const a=albums[albumIndex];return heading(title(),T('Album workspace · Manage the moments that matter','相册工作区 · 管理活动中的精彩瞬间'),btn(T('Edit album','编辑相册'),'go:9','outline','edit')+btn(T('Share album','分享相册'),'go:7','primary','share'))+`<div class="card album-banner">${photo(a.photo,'cover')}<div class="info-columns"><div class="meta-item">${icon('calendar')}<div><strong>${a.date}</strong><p>${T('Event time · Asia/Shanghai','活动时间 · Asia/Shanghai')}</p></div></div><div class="meta-item">${icon('user')}<div><strong>${T('Owner','创建者')}</strong><p>${a.owner}</p></div></div><div class="meta-item">${icon('pin')}<div><strong>${a.venue}</strong><p>${T('Event venue','活动地点')}</p></div></div><div class="meta-item">${icon('globe')}<div><strong>${a.public?T('Public access on','允许公开访问'):T('Public access off','公开访问关闭')}</strong><p>${a.upload?T('Staff uploads enabled','工作人员上传已开启'):T('Staff uploads disabled','工作人员上传已关闭')}</p></div></div></div></div>`+tabs()}
function shell(content){const selectedRoute=[2,3,4,5,6,7,8,9,15,16].includes(page)?2:[13,14].includes(page)?13:page===20?11:page;const link=(id,ic,en,zh)=>`<button class="side-link ${selectedRoute===id?'active':''}" data-action="go:${id}">${icon(ic)}${T(en,zh)}</button>`;return `<aside class="sidebar" id="sidebar"><div class="brand">${icon('logo')}PhotoLive</div><div class="group-label">${T('WORKSPACE','工作空间')}</div>${link(2,'album','Albums','相册管理')}${link(13,'photo','Embed widgets','网站展示位')}<div class="group-label">${T('ORGANIZATION','组织与治理')}</div>${link(10,'users','Accounts','账号管理')}${link(11,'flip','User sync','用户同步')}${link(12,'upload','Storage & usage','存储与用量')}<button class="preview-directory" data-action="directory">${T('Preview all pages ↗','全部页面目录 ↗')}</button><div class="side-bottom"><div class="person">${avatar('Alex Morgan')}<div><strong style="font-size:12px">Alex Morgan</strong><p class="muted small">${T('Company administrator','公司管理员')}</p></div></div><button class="text-btn" style="margin:8px 0 0 36px" data-action="go:1">${T('Sign out','退出登录')}</button></div></aside><div class="shell"><header class="topbar"><div class="breadcrumbs"><button class="mobile-menu" data-action="menu" aria-label="Menu">${icon('more')}</button><span>PhotoLive</span><span>/</span><span>${window.ADMIN_PAGES.find(p=>p.id===page)?.[lang]||T('Workspace','工作空间')}</span></div><div class="top-actions"><span class="demo-pill">${T('DESIGN PREVIEW · SAMPLE DATA','设计预览 · 示例数据')}</span><select id="language" aria-label="Language"><option value="zh" ${lang==='zh'?'selected':''}>中文</option><option value="en" ${lang==='en'?'selected':''}>EN</option></select><div class="person">${avatar('Alex Morgan')}<span class="small">Alex Morgan</span></div></div></header><main class="workspace">${content}</main></div>`}
function login(){return `<div class="login"><section class="login-visual">${photo(0)}<div class="brand">${icon('logo')}PhotoLive</div><div class="login-copy"><p style="font-size:10px;letter-spacing:3px;margin-bottom:20px">EVERY EVENT. EVERY MOMENT.</p><h1>${T('Bring your events into focus.','每个精彩现场,<br>从这里开始。')}</h1><p>${T('One workspace for your event albums, team and shared moments.','集中管理活动相册、协作团队与照片,让每个精彩瞬间及时抵达观众。')}</p></div><p style="position:relative;z-index:2;color:#8093af;font-size:11px">PhotoLive · Staff workspace</p></section><section class="login-form-area"><form id="loginForm" class="login-form"><div class="brand">${icon('logo')}PhotoLive</div><h1>${T('Welcome back','欢迎回来')}</h1><p>${T('Sign in to your company workspace','登录企业影像工作空间')}</p>${field(T('Account','账号'),'loginAccount','demo.admin')}${field(T('Password','密码'),'loginPassword','','password')}<div class="error" id="formError"></div><button type="submit" class="btn primary">${T('Sign in to preview','进入预览')}</button><p class="small muted" style="margin-top:22px">${T('Accounts are provided by your administrator. No registration or Feishu sign-in.','账号由企业提供,不支持自行注册或飞书登录。')}</p><div class="info-strip" style="margin-top:26px">${icon('help')}${T('Offline prototype. Do not enter real credentials.','离线交互原型,请勿输入真实账号密码。')}</div></form></section></div>`}
function albumList(){const rows=albums.map((a,i)=>({a,i})).filter(({a})=>(!albumQuery||JSON.stringify(a).toLowerCase().includes(albumQuery.toLowerCase()))&&(albumStatus==='all'||String(a.public)===albumStatus));return heading(T('Albums','相册管理'),T('A clear view of every event and the team behind it.','以活动为中心,管理相册、团队和每一张照片。'),btn(T('New album','新建相册'),'go:8','primary','plus'))+`<div class="toolbar"><div class="search">${icon('photo')}<input id="albumSearch" value="${esc(albumQuery)}" placeholder="${T('Search loaded albums','搜索当前已加载相册')}"></div><select id="albumStatus" aria-label="Album status"><option value="all">${T('All access states','全部公开状态')}</option><option value="true" ${albumStatus==='true'?'selected':''}>${T('Public','公开')}</option><option value="false" ${albumStatus==='false'?'selected':''}>${T('Private','未公开')}</option></select><div class="push seg"><button data-action="albumMode:grid" class="${albumMode==='grid'?'active':''}">${icon('photo')}</button><button data-action="albumMode:list" class="${albumMode==='list'?'active':''}">${icon('album')}</button></div></div><div class="summary-line"><span>${T('Loaded sample albums','已加载示例相册')} · ${rows.length}</span><span>${T('Sorted by creation time · cursor-based loading','按创建时间排序 · 沿用游标加载')}</span></div>${albumMode==='grid'?`<div class="album-grid">${rows.map(({a,i})=>`<article class="album"><button class="album-cover" data-action="album:${i}" aria-label="${title(a)}">${photo(a.photo)}${pill(a.public?T('Public','公开'):T('Private','未公开'),a.public?'green':'gray')}</button><div class="album-info"><h2><button data-action="album:${i}" style="padding:0;font-weight:650">${title(a)}</button></h2><p class="meta">${a.date} · ${a.venue}</p><div class="album-footer"><div class="person">${avatar(a.owner)}<span>${a.owner}</span></div>${pill(a.upload?T('Uploads on','可上传'):T('Uploads off','上传关闭'),a.upload?'':'gray')}</div></div></article>`).join('')}</div>`:`<div class="card">${table([T('Album','相册'),T('Owner','创建者'),T('Event date','活动日期'),T('Public access','公开访问'),T('Actions','操作')],rows.map(({a,i})=>[title(a),a.owner,a.date,pill(a.public?T('Public','公开'):T('Private','未公开'),a.public?'green':'gray'),btn(T('Manage','管理'),'album:'+i,'small outline')]))}</div>`}<div class="load-more">${rows.length?T('All sample albums are loaded','示例相册已全部加载'):T('No albums match these filters','没有符合筛选条件的相册')}</div>`}
function visibility(state){return {published:T('Published','已公开'),hidden:T('Hidden','已隐藏'),unpublished:T('Unpublished','未公开'),deleted:T('Deleted','已删除')}[state]}
function filteredPhotos(){return Array.from({length:12},(_,i)=>i).filter(i=>(photoFilter==='all'||photoStates[i]===photoFilter)&&(photoCategory==='all'||i%3===Number(photoCategory))&&(!photoQuery||('DSC0'+(4872+i)+'.jpg').toLowerCase().includes(photoQuery.toLowerCase())))}
function photosPage(){const list=filteredPhotos();return albumHeader()+`<div class="toolbar"><div class="seg">${['all','published','hidden','unpublished','deleted'].map(s=>`<button class="${photoFilter===s?'active':''}" data-action="photoFilter:${s}">${s==='all'?T('All photos','全部照片'):visibility(s)}</button>`).join('')}</div><select id="photoCategory" aria-label="Category"><option value="all">${T('All categories','全部分类')}</option>${cats.map((c,i)=>`<option value="${i}" ${String(i)===photoCategory?'selected':''}>${esc(c[lang])}</option>`).join('')}</select><div class="search">${icon('photo')}<input id="photoSearch" value="${esc(photoQuery)}" placeholder="${T('Search sample filenames','搜索示例文件名')}"></div><div class="seg push"><button data-action="photoMode:grid" class="${photoMode==='grid'?'active':''}">${icon('photo')}</button><button data-action="photoMode:list" class="${photoMode==='list'?'active':''}">${icon('album')}</button></div></div><div class="selection-bar" id="selectionBar" ${selected.size?'':'hidden'}><span id="selectedCount">${selected.size} ${T('selected','张已选')}</span>${btn(T('Hide selected','隐藏选中照片'),'batchHide','small outline','eye')}<span class="small">${T('Only published photos can be selected','仅已公开照片可批量选择')}</span></div>${photoMode==='grid'?`<div class="photo-grid">${list.map(i=>`<article class="photo-item"><div class="photo-image ${selected.has(i)?'selected':''}"><button data-action="photo:${i}" aria-label="${T('View photo','查看照片')} ${i+1}">${photoStates[i]==='deleted'?`<div class="empty">${T('Deleted','已删除')}</div>`:photo(i)}</button><input class="photo-check" type="checkbox" value="${i}" aria-label="${T('Select photo','选择照片')} ${i+1}" ${selected.has(i)?'checked':''} ${photoStates[i]!=='published'?'disabled':''}>${pill(visibility(photoStates[i]),photoStates[i]==='published'?'green':'gray')}</div><div class="photo-caption"><div><strong>DSC0${4872+i}.jpg</strong><p>${people[i%3].name} · Oct 15, 2026</p></div><button data-action="photo:${i}" aria-label="${T('Photo actions','照片操作')}">${icon('more')}</button></div></article>`).join('')}</div>`:`<div class="card">${table([T('Photo','照片'),T('Processing','处理状态'),T('Visibility','可见性'),T('Uploader','上传者'),T('Likes (replica)','点赞数(副本)'),T('Actions','操作')],list.map(i=>[`<div class="person">${photo(i,'tiny-photo')}DSC0${4872+i}.jpg</div>`,pill(i===10?T('Processing','处理中'):T('Ready','已就绪'),i===10?'':'green'),pill(visibility(photoStates[i]),photoStates[i]==='published'?'green':'gray'),people[i%3].name,[128,86,54,102][i%4],btn(T('Details','详情'),'photo:'+i,'small')]))}</div>`}<div class="load-more">${T('Sample photos · Statistics replica updated at 14:32 · No live API connection','示例照片 · 统计副本更新时间 14:32 · 未连接真实接口')}</div>`}
function membersPage(){return albumHeader()+`<div class="card"><div class="card-head"><div><h2>${T('Album members','相册成员')}</h2><p class="small muted" style="margin-top:6px">${T('Owners manage all photos. Collaborators manage their own uploads.','创建者管理全部照片;协作者管理自己上传的照片。')}</p></div>${btn(T('Invite colleague','邀请同事'),'invite','primary','users')}</div>${table([T('Member','成员'),T('Account','账号'),T('Role','角色'),T('Scope','权限范围'),T('Actions','操作')],memberIds.map((id,index)=>{const p=people[id];return [`<div class="person">${avatar(p.name)}<strong>${p.name}</strong></div>`,p.account,pill(index===0?T('Owner','创建者'):T('Collaborator','协作者'),index===0?'':'gray'),index===0?T('All photos and members','全部照片及成员'):T('Own uploaded photos','自己上传的照片'),index===0?btn(T('Transfer ownership','交接相册'),'transfer','small'):btn(T('Remove','移除'),'removeMember:'+id,'small danger')]}))}</div>`}
function categoriesPage(){return albumHeader()+`<div class="card"><div class="card-head"><div><h2>${T('Photo categories','照片分类')}</h2><p class="small muted">${T('Keep both content languages together when saving.','保存时保留所有语言内容,避免覆盖其他语言。')}</p></div>${btn(T('New category','新增分类'),'category:new','primary','plus')}</div>${table([T('Category','分类名称'),T('Chinese','中文'),T('English','English'),T('Actions','操作')],cats.map((c,i)=>[`<div class="person"><span class="avatar">${icon('tag')}</span><strong>${esc(c[lang])}</strong></div>`,esc(c.zh),esc(c.en),btn(T('Edit','编辑'),'category:'+i,'small')+' '+btn(T('Delete','删除'),'deleteCategory:'+i,'small danger')]))}<p class="small muted" style="margin-top:18px">${T('A category with photos cannot be deleted.','已有照片的分类不能直接删除。')}</p></div>`}
function watermarkPage(){return albumHeader()+`<div class="two-col equal"><div class="card"><div class="card-head"><h2>${T('Watermark settings','水印配置')}</h2>${pill('v'+wmVersion,'green')}</div><form id="watermarkForm">${switchRow(T('Enable watermark','启用水印'),T('Apply to generated viewing variants','应用于生成的浏览变体'),'wmOn',true)}<div class="form-grid" style="margin-top:20px">${field(T('Watermark text','水印文字'),'wmText','PhotoLive')}${select(T('Position','位置'),'wmPosition',[['bottom-right',T('Bottom right','右下')],['bottom-left',T('Bottom left','左下')],['top-right',T('Top right','右上')],['top-left',T('Top left','左上')],['center',T('Center','居中')]],wmPosition)}<label class="field">${T('Opacity','透明度')}<input id="wmOpacity" type="range" min="0" max="100" value="70"></label>${field(T('Font size','字号'),'wmFont','20','number')}</div><button type="submit" class="btn primary">${T('Save and generate','保存并生成')}</button><span class="small muted"> ${T('Demo job only','仅演示任务')}</span></form></div><div class="card"><div class="card-head"><h2>${T('Visual preview','效果预览')}</h2><span class="small muted">CSS ${T('simulation','示意')}</span></div><div class="watermark-preview">${photo(1)}<span class="watermark-text" id="wmPreview">PhotoLive</span></div><p class="small muted" style="margin-top:14px">${T('Preview placement only. Final rendering is produced by the server.','此处仅示意位置,最终水印由服务端生成。')}</p></div></div><div class="card"><div class="card-head"><h2>${T('Generation history','生成任务')}</h2>${btn(T('Regenerate','重新生成'),'regenerate','outline','flip')}</div>${table([T('Version','版本'),T('State','状态'),T('Processed','已处理'),T('Failed','失败'),T('Created','创建时间')],[['v'+wmVersion,pill(T('Completed','已完成'),'green'),'124 / 124','0','2026-09-22 14:20'],['v2',pill(T('Completed','已完成'),'green'),'118 / 118','0','2026-09-21 16:42']])}</div>`}
function sharingPage(){return albumHeader()+`<div class="two-col equal"><div class="card share-card">${photo(albums[albumIndex].photo,'cover')}<h2>${title()}</h2><p>${albums[albumIndex].date} · ${albums[albumIndex].venue}</p><img class="qr" src="assets/demo-qr.svg" alt="Demonstration QR code"><p>${T('Demo code · example.com · not a public event link','演示二维码 · 指向 example.com · 非真实活动链接')}</p></div><div class="card"><h2>${T('Share your event','分享活动相册')}</h2><p class="muted" style="margin:14px 0 22px">${T('Visitors browse without signing in. Public access must be enabled.','观众凭链接或扫码免登录浏览,须先开启相册公开访问。')}</p><div class="info-strip ${albums[albumIndex].public?'':'warn'}">${icon('globe')}${albums[albumIndex].public?T('Public access is enabled in this sample.','当前示例已开启公开访问。'):T('Public access is disabled in this sample.','当前示例已关闭公开访问。')}</div>${field(T('Preview link','预览链接'),'shareLink',new URL('../viewer-preview-2026-09-22.html',location.href).href)}<div class="actions">${btn(T('Copy preview link','复制预览链接'),'copyShare','primary','link')}${btn(T('Open viewer','打开观众页'),'viewer','outline')}</div><p class="small muted" style="margin-top:24px">${T('Local file links work on this computer only. Production QR codes must use the public album URL.','本地链接仅用于本机预览,生产二维码必须使用真实公开相册 URL。')}</p></div></div>`}
function albumForm(edit){const a=albums[albumIndex];return heading(edit?T('Edit album','编辑相册'):T('Create album','新建相册'),T('Event details, content languages and public access in one place.','统一配置活动资料、多语言内容与访问权限。'),btn(T('Cancel','取消'),'go:2'))+`<form id="albumForm"><div class="two-col"><div class="card"><div class="card-head"><h2>${T('Event details','活动资料')}</h2>${pill(T('Chinese + English','中文 + English'))}</div><div class="form-grid">${field(T('Title (Chinese)','标题(中文)'),'titleZh',edit?a.zh:'')}${field(T('Title (English)','标题(English)'),'titleEn',edit?a.title:'')}${field(T('Start date','开始日期'),'startDate','2026-10-15','date')}${field(T('End date','结束日期'),'endDate','2026-10-19','date')}${select(T('Time zone','时区'),'timezone',[['Asia/Shanghai','Asia/Shanghai'],['Europe/London','Europe/London'],['America/New_York','America/New_York']],'Asia/Shanghai')}${select(T('Default language','默认语言'),'contentLang',[['zh','中文'],['en','English']],'zh')}${field(T('Venue (Chinese)','地点(中文)'),'venueZh',edit?'广州':'')}${field(T('Venue (English)','地点(English)'),'venueEn',edit?a.venue:'')}<label class="field wide">${T('Description (Chinese)','说明(中文)')}<textarea id="descriptionZh">${edit?'欢迎浏览本次活动的精彩照片。':''}</textarea></label><label class="field wide">${T('Description (English)','说明(English)')}<textarea id="descriptionEn">${edit?'Discover the moments from this event.':''}</textarea></label></div></div><div><div class="card"><h2>${T('Access settings','访问设置')}</h2>${switchRow(T('Staff uploads','工作人员上传'),T('Team members can upload photos','团队成员可上传照片'),'albumUpload',a.upload)}${switchRow(T('Public access','公开访问'),T('Visitors with the link can browse','凭链接免登录浏览'),'albumPublic',a.public)}${switchRow(T('Photo downloads','允许下载'),T('Download viewing variants','观众可下载浏览图'),'albumDownload',true)}${switchRow(T('Original downloads','原图下载'),T('Requires photo downloads','须先允许下载'),'albumOriginal',false)}</div><div class="info-strip" style="margin-top:18px">${icon('help')}${T('Preview changes are stored in this tab only.','预览变更仅保留在当前标签页内。')}</div><div id="formError" class="error"></div><button class="btn primary" style="width:100%" type="submit">${T('Save album','保存相册')}</button></div></div></form>`}
function usersPage(){return heading(T('Accounts','账号管理'),T('Provisioned company accounts, credential state and local access controls.','统一查看企业来源状态、登录凭据与本地账号治理。'),btn(T('View user sync','查看用户同步'),'go:11','outline','flip'))+`<div class="info-strip">${icon('help')}${T('User information is pulled from the company directory. No public registration. Source state and local lock are independent.','用户信息来自企业接口定期拉取,不开放注册;来源状态与本地锁定相互独立。')}</div><div class="card"><div class="toolbar"><div class="search">${icon('user')}<input id="userSearch" placeholder="${T('Search account or name','搜索账号或姓名')}"></div><select id="userStatus" aria-label="Account status"><option value="all">${T('All states','全部状态')}</option><option value="locked">${T('Locally locked','本地锁定')}</option><option value="pending">${T('Pending activation','待激活')}</option></select>${btn(T('Reset filters','重置筛选'),'resetUserFilters')}</div><div id="usersTable">${usersTable()}</div></div>`}
function usersTable(query='',status='all'){return table([T('User','用户'),T('Role','角色'),T('Source status','来源状态'),T('Credentials','凭据状态'),T('Local lock','本地锁定'),T('Actions','操作')],people.map((p,i)=>({p,i})).filter(({p})=>(p.name+p.account).toLowerCase().includes(query.toLowerCase())&&(status==='all'||status==='locked'&&p.locked||status==='pending'&&p.credential==='pending')).map(({p,i})=>[`<div class="person">${avatar(p.name)}<div><strong>${p.name}</strong>${i===0?' '+pill(T('You','当前用户')):''}<p>${p.account}</p></div></div>`,pill(p.role==='admin'?T('Admin','管理员'):T('Staff','员工'),p.role==='admin'?'':'gray'),pill(p.source==='local'?T('Local','本地'):p.source==='active'?T('Active','来源有效'):T('Disabled','来源停用'),p.source==='disabled'?'red':'gray'),pill(p.credential==='pending'?T('Pending','待激活'):T('Active','已激活'),p.credential==='pending'?'amber':'green'),pill(p.locked?T('Locked','已锁定'):T('Unlocked','未锁定'),p.locked?'red':'gray'),`<div class="actions">${btn(T('Edit','编辑'),'userEdit:'+i,'small')}${btn(T('Reset password','重置密码'),'resetPassword:'+i,'small')}${i>0?btn(p.credential==='pending'?T('Activate','激活'):p.locked?T('Unlock','解锁'):T('Lock','锁定'),'userState:'+i,'small'):''}</div>`]))}
function syncPage(){return heading(T('User sync','用户同步'),T('Review scheduled directory pulls and resolve failed records.','查看企业用户接口的定时拉取结果,定位失败记录。'),btn(syncRunning?T('Running…','运行中…'):T('Pull now','手动补拉'),'sync','primary','flip'))+`<div class="stats"><div class="stat"><p>${T('Engine state','同步引擎状态')}</p><strong style="font-size:23px">${syncRunning?T('Running','运行中'):T('Idle','空闲')}</strong><small>${T('Last success','最近成功')} 2026-09-22 14:30</small></div><div class="stat"><p>${T('Last batch','最近批次')}</p><strong>128 <span style="font-size:13px;color:#8294ae">${T('processed','条处理')}</span></strong><small>${T('3 created · 124 updated · 1 failed','新增 3 · 更新 124 · 失败 1')}</small></div><div class="stat"><p>${T('Source version','来源版本')}</p><strong style="font-size:23px">20260922.1430</strong><small>${T('User directory watermark, not photo replication','用户目录水位,并非照片跨境同步')}</small></div></div><div class="card"><div class="card-head"><h2>${T('Run history','运行历史')}</h2><span class="small muted">${T('Sample scheduled jobs','示例定时任务')}</span></div>${table([T('Triggered','触发方式'),T('State','运行状态'),T('Started','开始时间'),T('Created','新增'),T('Updated','更新'),T('Failed','失败'),T('Details','详情')],Array.from({length:4},(_,i)=>[pill(i===1?T('Manual','手动'):T('Scheduled','定时'),'gray'),pill(i===0?T('Partial failure','部分失败'):T('Completed','完成'),i===0?'amber':'green'),`2026-09-22 ${14-i}:30`,i===0?'3':'0',i===0?'124':'128',i===0?'1':'0',btn(T('View run','查看批次'),'syncDetail:'+i,'small outline')]))}</div>`}
function usagePage(){return heading(T('Storage & usage','存储与用量'),T('Understand retained assets and act before thresholds are exceeded.','查看长期保留的照片资产与告警阈值。'),btn(T('Threshold settings','告警阈值'),'thresholds','outline','settings'))+`<div class="stats"><div class="stat"><p>${T('Total photos','照片总数')}</p><strong>18,420</strong><small>${T('Sample aggregate from the usage response','示例用量接口汇总')}</small></div><div class="stat"><p>${T('Stored assets','存储用量')}</p><strong>148.6 <span style="font-size:17px">GB</span></strong><small>${T('Originals + viewing variants','原图 + 浏览变体')}</small></div><div class="stat"><p>${T('Storage alert threshold','总存储告警阈值')}</p><strong id="thresholdValue">200 <span style="font-size:17px">GB</span></strong><small>${T('An alert threshold, not a hard storage quota','告警阈值,并非存储硬配额')}</small></div></div><div class="card"><div class="card-head"><h2>${T('Asset composition','资产构成')}</h2><span class="small muted">${T('OSS · retained by default','OSS · 默认长期保留')}</span></div><div class="storage-bar"><span style="width:68%"></span><span style="width:20%"></span></div><div class="legend"><span>● ${T('Originals','原图')} 114.8 GB</span><span>● ${T('Variants','变体')} 33.8 GB</span></div></div><div class="card"><div class="card-head"><h2>${T('Usage by album','相册用量')}</h2></div>${table([T('Album','相册'),T('Photos','照片数'),T('Originals','原图'),T('Variants','变体'),T('Total','合计')],albums.slice(0,4).map((a,i)=>[title(a),[6280,4210,3150,4780][i],[42.5,26.1,18.7,27.5][i]+' GB',[12.3,8.2,5.8,7.5][i]+' GB',[54.8,34.3,24.5,35][i]+' GB']))}</div><div class="card"><div class="card-head"><h2>${T('Alerts','用量告警')}</h2></div>${table([T('State','状态'),T('Kind','类别'),T('Message','说明'),T('Time','时间')],[[pill(T('Active','未解决'),'amber'),T('Per-album storage','单相册存储'),T('Canton Fair exceeds the 50 GB sample threshold.','广交会相册超过示例阈值 50 GB。'),'2026-09-22 14:20'],[pill(T('Resolved','已解决'),'gray'),T('Storage','存储'),T('Previous threshold alert resolved.','历史阈值告警已解除。'),'2026-09-20 09:30']])}</div>`}
function slotsPage(){return heading(T('Embed widgets','网站展示位'),T('Publish a small live photo window on your websites.','在企业网站中展示活动照片,管理绑定与展示配置。'),btn(T('New widget','新增展示位'),'newSlot','primary','plus'))+`<div class="info-strip">${icon('photo')}${T('New design: 195 × 280 px on desktop and mobile. v1 carousel and v2 vertical stream remain distinct.','新版照片窗:电脑与手机统一 195 × 280 px;v1 轮播与 v2 上滚模式分开管理。')}</div><div class="card">${table([T('Widget','展示位名称'),T('Bound album','绑定相册'),T('Mode','展示模式'),T('Enabled','启用'),T('Config version','配置版本'),T('Actions','操作')],slots.map((s,i)=>[esc(lang==='zh'?s.zh:s.name),s.album===null?pill(T('Unbound','未绑定'),'gray'):title(albums[s.album]),pill(s.mode==='v2'?T('v2 · Vertical stream','v2 · 照片上滚'):T('v1 · Carousel','v1 · 旧版轮播')),`<input class="switch" type="checkbox" data-slot-toggle="${i}" aria-label="${esc(s.name)}" ${s.enabled?'checked':''}>`,'v'+s.version,`<div class="actions">${btn(T('Configure','配置'),'slot:'+i,'small outline')}${btn(T('Bind album','绑定相册'),'bind:'+i,'small')}${btn(T('Get code','获取代码'),'code:'+i,'small')}</div>`]))}</div>`}
function embedPreview(){const seq=[0,1,2,0,1,2];return `<div class="preview-frame"><div class="mock-site">COMPANY WEBSITE / PREVIEW</div><div class="site-lines"><div></div><div style="width:64%"></div><div style="width:82%"></div><div style="width:56%"></div></div><div class="embedded" id="embedded"><div class="embed-track" id="embedTrack">${seq.map(i=>photo(i)).join('')}</div><span class="embed-hearts" id="embedHearts">♥</span><button class="embed-minus" data-action="collapseEmbed" aria-label="Collapse preview">−</button></div><button id="expandEmbed" class="btn" style="position:absolute;right:16px;bottom:24px" hidden data-action="expandEmbed">${T('Photos','照片')}</button></div>`}
function slotEditor(){const s=slots[slotIndex];return heading(T('Configure widget','配置展示位'),esc(lang==='zh'?s.zh:s.name),btn(T('Back to widgets','返回展示位'),'go:13'))+`<div class="two-col equal"><div class="card"><form id="slotForm">${field(T('Name','名称'),'slotName',lang==='zh'?s.zh:s.name)}${select(T('Display mode','展示模式'),'slotMode',[['v2',T('v2 · Vertical stream','v2 · 照片上滚')],['v1',T('v1 · Existing carousel','v1 · 保留旧轮播')]],s.mode)}<div id="v2Fields"><div class="form-grid">${field(T('Width (fixed)','宽度(固定)'),'slotWidth','195','number')}${field(T('Height (fixed)','高度(固定)'),'slotHeight','280','number')}${field(T('Scroll speed (px/s)','滚动速度(px/s)'),'slotSpeed',s.speed,'number')}${select(T('Position','悬浮位置'),'slotPosition',[['right',T('Right','右侧')],['left',T('Left','左侧')]],'right')}</div>${switchRow(T('Like bubbles','点赞气泡'),T('Real reactions in production; simulated here','正式版使用真实事件,此处仅模拟'),'slotReactions',true)}</div><div id="v1Fields" hidden>${field(T('Width','宽度'),'legacyWidth','320','number')}${field(T('Interval (ms)','切图间隔(ms)'),'legacyInterval','5000','number')}<p class="small muted">${T('Legacy settings are retained. The preview at right depicts the new design.','旧版配置继续保留。右侧展示新版设计参考。')}</p></div><div class="form-grid" style="margin-top:18px">${field(T('Horizontal offset','水平偏移'),'offsetX','16','number')}${field(T('Bottom offset','底部偏移'),'offsetY','24','number')}</div>${switchRow(T('Start collapsed on mobile','手机默认收起'),T('User choice has priority','用户主动选择优先'),'slotCollapsed',true)}<div class="error" id="formError"></div><button class="btn primary" type="submit">${T('Save configuration','保存配置')}</button></form></div><div class="card"><div class="card-head"><h2>${T('Unsaved preview','未保存预览')}</h2>${pill('195 × 280 px')}</div>${embedPreview()}<div class="actions" style="margin-top:15px">${btn(T('Simulate a reaction','模拟点赞事件'),'embedHeart','small outline')}${btn(T('Open full widget demo','打开完整组件原型'),'widgetDemo','small')}</div><p class="small muted" style="margin-top:14px">${T('Presentation mock only. The standalone widget demo implements the precise 16 px/s stream.','此面板为样式示意;独立组件原型实现精确 16 px/s 上滚。')}</p></div></div>`}
function brandingPage(){return albumHeader()+`<div class="two-col equal"><div class="card"><h2>${T('Public album banner','观众页宣传图')}</h2><p class="small muted" style="margin:9px 0 20px">${T('Prefer a wide image. Do not crop away embedded poster text.','建议使用横幅图片,保留海报中的文字内容。')}</p>${photo(albums[albumIndex].photo,'cover')}<div class="actions" style="margin-top:18px">${btn(T('Choose sample banner','选择示例宣传图'),'banner','primary','photo')}${btn(T('Preview public album','预览观众相册'),'viewer','outline')}</div></div><div class="card"><h2>${T('Asset workflow','资产管理')}</h2><div class="info-strip" style="margin-top:20px">${icon('upload')}${T('Init → OSS direct upload → completion verification','初始化凭证 → OSS 直传 → 完成核验')}</div><p class="muted">${T('The existing API accepts banner, cover and logo asset types. Production must keep signature expiry and upload verification behavior. This prototype only swaps sample photographs.','现有接口支持 banner、cover、logo 资产类型。正式实现保留签名过期与上传核验流程。本原型仅切换示例照片。')}</p><h3 style="margin:24px 0 10px">${T('Current sample','当前示例')}</h3><p class="small muted">event-banner.jpg · image/jpeg · ${T('Sample preview','示意预览')}</p></div></div>`}
function exceptionPage(code){const text={403:[T('Access restricted','暂无访问权限'),T('Your current account cannot access this workspace.','当前账号无权访问此页面,请联系公司管理员。')],404:[T('Page not found','页面不存在'),T('The link may have changed or the resource is unavailable.','链接可能已失效,或资源暂不可用。')],500:[T('Something went wrong','服务暂时不可用'),T('Please try again. Your saved data has not been changed by this preview.','请稍后重试,已保存的数据不会因本次预览发生变化。')]}[code];return `<section class="exception"><strong>${code}</strong><h1>${text[0]}</h1><p>${text[1]}</p>${btn(T('Back to albums','返回相册'),'go:2','primary')}</section>`}
function modal(name,body,footer='',wide=false){$('.modal')?.remove();const wrap=document.createElement('div');wrap.className='modal';wrap.innerHTML=`<section class="dialog ${wide?'wide':''}" role="dialog" aria-modal="true" aria-label="${esc(name)}"><div class="dialog-head"><h2>${name}</h2><button class="btn icon-only" data-action="close" aria-label="Close">${icon('close')}</button></div>${body}${footer?`<div class="dialog-foot">${footer}</div>`:''}</section>`;document.body.append(wrap);$('#root').inert=true;wrap.onclick=e=>{if(e.target===wrap)closeModal()};wrap.querySelector('input,button,select')?.focus();return wrap}
function closeModal(){ $('.modal')?.remove();$('#root').inert=false;if(page===16)go(3);if(page===20)go(11)}
function toast(text){$('.toast')?.remove();const el=document.createElement('div');el.className='toast';el.setAttribute('role','status');el.textContent=text;document.body.append(el);clearTimeout(toastTimer);toastTimer=setTimeout(()=>el.remove(),3200)}
function go(id){if(page===Number(id)){render();return}location.hash=id}
function render(){page=Number(location.hash.slice(1))||Number(document.body.dataset.page)||2;if(page<1||page>20)page=18;$('.modal')?.remove();$('#root').inert=false;document.documentElement.lang=lang==='zh'?'zh-CN':'en';if(page===1){$('#root').innerHTML=login();bind();return}let content='';switch(page){case 2:content=albumList();break;case 3:case 16:content=photosPage();break;case 4:content=membersPage();break;case 5:content=categoriesPage();break;case 6:content=watermarkPage();break;case 7:content=sharingPage();break;case 8:content=albumForm(false);break;case 9:content=albumForm(true);break;case 10:content=usersPage();break;case 11:case 20:content=syncPage();break;case 12:content=usagePage();break;case 13:content=slotsPage();break;case 14:content=slotEditor();break;case 15:content=brandingPage();break;case 17:content=exceptionPage(403);break;case 18:content=exceptionPage(404);break;case 19:content=exceptionPage(500);break}$('#root').innerHTML=shell(content);bind();if(page===16)openPhoto(currentPhoto);if(page===20)syncDetail(0);document.title='PhotoLive · '+(window.ADMIN_PAGES.find(p=>p.id===page)?.[lang]||'Preview')}
function openPhoto(i){currentPhoto=i;const state=photoStates[i];if(state==='deleted'){toast(T('This sample photo has been deleted.','该示例照片已删除。'));return}const wrap=modal(T('Photo details','照片详情'),'');wrap.querySelector('.dialog').className='dialog viewer-dialog';wrap.querySelector('.dialog').innerHTML=`<div class="viewer-main"><button class="viewer-close btn icon-only" data-action="close" aria-label="Close">${icon('close')}</button>${photo(i)}</div><div class="viewer-info"><h2>DSC0${4872+i}.jpg</h2><p>${people[i%3].name}</p><p>2026-10-15 10:42<br>2400 × 1600 · 2.8 MB</p>${pill(visibility(state),state==='published'?'green':'gray')}<p>${T('Likes (statistics replica)','点赞数(统计副本)')} · ${[128,86,54,102][i%4]}</p>${state!=='unpublished'?btn(state==='hidden'?T('Publish again','恢复公开'):T('Hide photo','隐藏照片'),'visibility:'+i,'outline','eye'):''}${btn(T('Delete photo','删除照片'),'deletePhoto:'+i,'danger','trash')}<p class="small">${T('Staff management actions only. No public like or download controls.','此处为工作人员管理操作,不提供观众点赞或下载按钮。')}</p></div>`}
function syncDetail(i){modal(T('Sync run details','同步批次详情'),`<div class="info-strip ${i===0?'warn':''}">${icon('flip')}${i===0?T('127 succeeded · 1 failed','127 条成功 · 1 条失败'):T('128 succeeded · 0 failed','128 条成功 · 0 条失败')}</div>${table([T('Record','记录'),T('Result','结果'),T('Reason','原因')],i===0?[['sample.user.129',pill(T('Failed','失败'),'red'),T('Missing required account field','缺少必填账号字段')]]:[['—',pill(T('Completed','完成'),'green'),T('No failed records','没有失败记录')]])}<p style="margin-top:18px">${T('Example failure details are redacted. Correct the source data before the next run.','失败明细为脱敏示例,请修正来源数据后再进行补拉。')}</p>`,btn(T('Close','关闭'),'close'))}
function filterInput(id,current,assign){const el=$('#'+id);if(el)el.oninput=e=>{const start=e.target.selectionStart;assign(e.target.value);render();const next=$('#'+id);next.focus();next.setSelectionRange(start,start)}}
function bind(){const locale=$('#language');if(locale)locale.onchange=e=>{lang=e.target.value;render()};const loginForm=$('#loginForm');if(loginForm)loginForm.onsubmit=e=>{e.preventDefault();if(!$('#loginAccount').value.trim()){$('#formError').textContent=T('Enter a sample account.','请输入演示账号。');return}go(2)};filterInput('albumSearch',albumQuery,x=>albumQuery=x);filterInput('photoSearch',photoQuery,x=>photoQuery=x);if($('#albumStatus'))$('#albumStatus').onchange=e=>{albumStatus=e.target.value;render()};if($('#photoCategory'))$('#photoCategory').onchange=e=>{photoCategory=e.target.value;render()};$$('.photo-check').forEach(el=>el.onchange=()=>{const i=Number(el.value);if(el.checked)selected.add(i);else selected.delete(i);render()});const filterUsers=()=>$('#usersTable').innerHTML=usersTable($('#userSearch').value,$('#userStatus').value);if($('#userSearch')){$('#userSearch').oninput=filterUsers;$('#userStatus').onchange=filterUsers}$$('[data-slot-toggle]').forEach(el=>el.onchange=()=>{const s=slots[Number(el.dataset.slotToggle)];s.enabled=el.checked;s.version++;render();toast(T('Sample widget state updated','示例展示位状态已更新'))});
const form=$('#albumForm');if(form){$('#albumDownload').onchange=e=>{$('#albumOriginal').disabled=!e.target.checked;if(!e.target.checked)$('#albumOriginal').checked=false};form.onsubmit=e=>{e.preventDefault();if(!$('#titleZh').value.trim()||!$('#titleEn').value.trim()||!$('#startDate').value||$('#endDate').value<$('#startDate').value){$('#formError').textContent=T('Enter both sample titles and a valid date range.','请填写示例双语标题及有效日期范围。');return}const data={title:$('#titleEn').value,zh:$('#titleZh').value,date:$('#startDate').value+' – '+$('#endDate').value,venue:$('#venueEn').value||$('#venueZh').value,owner:'Alex Morgan',public:$('#albumPublic').checked,upload:$('#albumUpload').checked,photo:0};if(page===8){albums.unshift(data);albumIndex=0}else albums[albumIndex]={...albums[albumIndex],...data};go(3)}}
if($('#watermarkForm')){const preview=()=>{const el=$('#wmPreview');el.textContent=$('#wmText').value;el.hidden=!$('#wmOn').checked;el.style.opacity=$('#wmOpacity').value/100;el.style.fontSize=Math.min(72,Math.max(8,Number($('#wmFont').value)||20))+'px';const pos=$('#wmPosition').value;el.style.top=pos.startsWith('top')?'18px':pos==='center'?'50%':'auto';el.style.bottom=pos.startsWith('bottom')?'18px':'auto';el.style.left=pos.endsWith('left')?'18px':pos==='center'?'50%':'auto';el.style.right=pos.endsWith('right')?'18px':'auto';el.style.transform=pos==='center'?'translate(-50%,-50%)':''};['wmText','wmOpacity','wmFont','wmPosition','wmOn'].forEach(id=>$('#'+id).oninput=preview);$('#watermarkForm').onsubmit=e=>{e.preventDefault();wmVersion++;toast(T('Sample generation job accepted. No images are modified.','示例生成任务已受理,未修改任何图片。'))}}
if($('#slotForm')){const s=slots[slotIndex];$('#slotWidth').readOnly=true;$('#slotHeight').readOnly=true;$('#slotSpeed').min=8;$('#slotSpeed').max=32;const mode=()=>{$('#v2Fields').hidden=$('#slotMode').value!=='v2';$('#v1Fields').hidden=$('#slotMode').value!=='v1'};$('#slotMode').onchange=mode;mode();$('#offsetX').oninput=()=>{$('#embedded').style.right=Math.max(0,Number($('#offsetX').value))+'px'};$('#offsetY').oninput=()=>{$('#embedded').style.bottom=Math.max(0,Number($('#offsetY').value))+'px'};$('#slotPosition').onchange=e=>{$('#embedded').style.left=e.target.value==='left'?'16px':'auto';$('#embedded').style.right=e.target.value==='right'?'16px':'auto'};$('#slotForm').onsubmit=e=>{e.preventDefault();const speed=Number($('#slotSpeed').value);if(!$('#slotName').value.trim()||speed<8||speed>32){$('#formError').textContent=T('Name required. Speed must be between 8 and 32.','请填写名称,速度须在 8–32 之间。');return}s.name=$('#slotName').value;s.zh=s.name;s.speed=speed;s.mode=$('#slotMode').value;s.version++;go(13)}}}
function confirm(titleText,body,callback){modal(titleText,`<p>${body}</p>`,btn(T('Cancel','取消'),'close')+btn(T('Confirm','确认'),'confirm','primary'));$('[data-action="confirm"]').onclick=()=>{closeModal();callback()}}
function action(raw){const [name,...rest]=raw.split(':'),value=rest.join(':');switch(name){case 'go':go(value);break;case 'close':closeModal();break;case 'menu':$('#sidebar').classList.toggle('open');break;case 'directory':modal(T('All preview pages','全部预览页面'),`<div class="form-grid">${window.ADMIN_PAGES.map(p=>btn(String(p.id).padStart(2,'0')+' · '+p[lang],'go:'+p.id,'')).join('')}</div>`,'',true);break;case 'album':albumIndex=Number(value);go(3);break;case 'albumMode':albumMode=value;render();break;case 'photoMode':photoMode=value;render();break;case 'photoFilter':photoFilter=value;render();break;case 'photo':openPhoto(Number(value));break;case 'batchHide':if(!selected.size)return;confirm(T('Hide selected photos?','隐藏选中的照片?'),T('This changes sample visibility only. Real batches must report per-photo success and failure.','仅修改示例可见性。真实批处理需逐项反馈成功和失败。'),()=>{selected.forEach(i=>photoStates[i]='hidden');selected.clear();render();toast(T('Sample photos hidden','示例照片已隐藏'))});break;case 'visibility':{const i=Number(value);confirm(photoStates[i]==='hidden'?T('Publish this photo?','恢复公开这张照片?'):T('Hide this photo?','隐藏这张照片?'),T('Public visibility changes. The original remains stored.','改变公开可见性,原图仍保留在存储中。'),()=>{photoStates[i]=photoStates[i]==='hidden'?'published':'hidden';selected.delete(i);render()});break}case 'deletePhoto':confirm(T('Delete photo?','删除照片?'),T('Soft deletion removes the sample from active management. This does not immediately physically delete OSS files.','软删除会让照片退出有效管理列表,不代表立即物理删除 OSS 文件。'),()=>{photoStates[Number(value)]='deleted';selected.delete(Number(value));render()});break;
case 'invite':{modal(T('Invite colleague','邀请同事'),`<div class="search" style="margin-bottom:15px">${icon('user')}<input id="inviteSearch" placeholder="${T('Search internal accounts','搜索企业内部账号')}"></div><div id="inviteRows">${people.map((p,i)=>!memberIds.includes(i)?`<button class="side-link candidate" data-action="addMember:${i}">${avatar(p.name)}<span>${p.name}<small class="muted"> · ${p.account}</small></span></button>`:'').join('')}</div><p>${T('No external email or registration invitation is sent.','仅选择内部账号,不发送外部邮件或注册邀请。')}</p>`);$('#inviteSearch').oninput=e=>$$('.candidate').forEach(el=>el.hidden=!el.textContent.toLowerCase().includes(e.target.value.toLowerCase()));break}case 'addMember':memberIds.push(Number(value));closeModal();render();break;case 'removeMember':confirm(T('Remove collaborator?','移除协作者?'),T('The sample account loses collaborator access to this album.','该示例账号将不再作为本相册协作者。'),()=>{memberIds=memberIds.filter(i=>i!==Number(value));render()});break;case 'transfer':modal(T('Transfer album ownership','交接相册'),select(T('New owner','新创建者'),'newOwner',people.slice(1).map((p,i)=>[String(i+1),p.name]),'1')+`<p>${T('Production must recheck the target account and creator permissions.','正式版需再次校验目标账号与创建者权限。')}</p>`,btn(T('Cancel','取消'),'close')+btn(T('Transfer sample','交接示例'),'confirmTransfer','primary'));break;case 'confirmTransfer':{const id=Number($('#newOwner').value);albums[albumIndex].owner=people[id].name;memberIds=[id,...memberIds.filter(i=>i!==id)];closeModal();render();break}
case 'category':{const i=value==='new'?-1:Number(value);modal(i<0?T('New category','新增分类'):T('Edit category','编辑分类'),`<form id="categoryForm">${field('中文','catZh',i<0?'':cats[i].zh)}${field('English','catEn',i<0?'':cats[i].en)}<button class="btn primary" type="submit">${T('Save category','保存分类')}</button></form>`);$('#categoryForm').onsubmit=e=>{e.preventDefault();if(!$('#catZh').value.trim()||!$('#catEn').value.trim()){toast(T('Complete both sample language fields','请填写示例双语名称'));return}const item={zh:$('#catZh').value,en:$('#catEn').value};if(i<0)cats.push(item);else cats[i]=item;closeModal();render()};break}case 'deleteCategory':if(Number(value)<3)toast(T('This sample category contains photos and cannot be deleted.','该示例分类已有照片,不能删除。'));else confirm(T('Delete empty category?','删除空分类?'),T('Only this empty sample category is removed.','仅移除该空示例分类。'),()=>{cats.splice(Number(value),1);render()});break;case 'regenerate':confirm(T('Regenerate viewing variants?','重新生成浏览变体?'),T('Production creates an asynchronous job. This preview does not modify OSS.','正式版创建异步生成任务,此预览不修改 OSS。'),()=>{wmVersion++;render();toast(T('Sample job created','示例任务已创建'))});break;case 'copyShare':copy($('#shareLink').value);break;case 'viewer':window.open('../viewer-preview-2026-09-22.html','_blank','noopener,noreferrer');break;
case 'userEdit':{const i=Number(value),p=people[i];modal(T('Edit account','编辑账号'),`<form id="userEditForm">${field(T('Display name','显示名称'),'userName',p.name)}${select(T('Role','角色'),'userRole',[['admin',T('Administrator','管理员')],['staff',T('Staff','员工')]],p.role)}<p>${i===0?T('Your own role cannot be changed here.','不能在这里修改自己的角色。'):T('Source status remains controlled by the company directory.','来源状态仍由企业用户接口控制。')}</p><button type="submit" class="btn primary" style="margin-top:20px">${T('Save','保存')}</button></form>`);$('#userRole').disabled=i===0;$('#userEditForm').onsubmit=e=>{e.preventDefault();p.name=$('#userName').value||p.name;if(i!==0)p.role=$('#userRole').value;closeModal();render()};break}case 'resetPassword':{const p=people[Number(value)];modal(T('Reset password','重置密码'),`<p style="margin-bottom:16px">${p.name} · ${p.account}</p><form id="resetForm">${field(T('Temporary password (demo only)','临时密码(仅演示)'),'tempPassword','','password')}<p>${T('Do not enter real credentials. Nothing is stored or sent.','请勿输入真实凭据,此处不保存或发送密码。')}</p><button class="btn primary" type="submit" style="margin-top:20px">${T('Confirm reset preview','确认重置演示')}</button></form>`);$('#resetForm').onsubmit=e=>{e.preventDefault();closeModal();toast(T('Password reset flow previewed. No credentials changed.','已演示密码重置流程,未修改真实凭据。'))};break}case 'userState':{const i=Number(value);if(i===0)return;const p=people[i];confirm(T('Change local account state?','修改本地账号状态?'),T('The company directory state remains unchanged.','来源接口状态不会随本地治理动作改变。'),()=>{if(p.credential==='pending')p.credential='active';else p.locked=!p.locked;render()});break}case 'resetUserFilters':$('#userSearch').value='';$('#userStatus').value='all';$('#usersTable').innerHTML=usersTable();break;case 'sync':if(syncRunning)return;confirm(T('Run a sample pull?','模拟一次用户补拉?'),T('This is a local animation; no company directory is contacted.','仅在本地演示运行状态,不请求企业用户接口。'),()=>{syncRunning=true;render();setTimeout(()=>{syncRunning=false;if([11,20].includes(page))render();toast(T('Sample pull completed','模拟补拉已完成'))},1800)});break;case 'syncDetail':syncDetail(Number(value));break;case 'thresholds':modal(T('Alert thresholds','告警阈值'),`<form id="thresholdForm">${field(T('Total storage threshold (GB)','总存储阈值(GB)'),'totalThreshold','200','number')}${field(T('Per-album threshold (GB)','单相册阈值(GB)'),'albumThreshold','50','number')}<p>${T('Thresholds trigger alerts, not automatic deletion.','达到阈值仅触发告警,不自动删除照片。')}</p><button class="btn primary" style="margin-top:20px" type="submit">${T('Save sample thresholds','保存示例阈值')}</button></form>`);$('#thresholdForm').onsubmit=e=>{e.preventDefault();const value=Number($('#totalThreshold').value);if(value<0){toast(T('Use a non-negative number','请输入非负数字'));return}closeModal();if($('#thresholdValue'))$('#thresholdValue').textContent=value+' GB';toast(T('Sample threshold updated','示例阈值已更新'))};break;
case 'newSlot':slots.push({name:'New widget',zh:'新展示位',album:null,enabled:false,mode:'v2',version:1,speed:16});slotIndex=slots.length-1;go(14);break;case 'slot':slotIndex=Number(value);go(14);break;case 'bind':{slotIndex=Number(value);modal(T('Bind album','绑定相册'),select(T('Album','相册'),'bindAlbum',[['none',T('Unbind','解除绑定')],...albums.map((a,i)=>[String(i),lang==='zh'?a.zh:a.title])],slots[slotIndex].album===null?'none':String(slots[slotIndex].album))+`<p>${T('The same embedded slot will display the newly bound album after configuration refresh.','配置刷新后,使用此展示位的网站将展示新绑定的相册。')}</p>`,btn(T('Cancel','取消'),'close')+btn(T('Confirm binding','确认绑定'),'confirmBind','primary'));break}case 'confirmBind':slots[slotIndex].album=$('#bindAlbum').value==='none'?null:Number($('#bindAlbum').value);slots[slotIndex].version++;closeModal();render();break;case 'code':{const s=slots[Number(value)];const code='<script src="https://example.com/embed/'+s.mode+'/loader.js" data-slot="DEMO_SLOT" defer><'+'/script>';modal(T('Embed code','嵌入代码'),`<p>${T('Illustrative integration code. Replace example.com and DEMO_SLOT with real deployment values.','接入格式示例,须替换 example.com 和 DEMO_SLOT 为真实部署信息。')}</p><pre class="mono" id="embedCode">${esc(code)}</pre>`,btn(T('Copy sample code','复制示例代码'),'copyCode','primary'));break}case 'copyCode':copy($('#embedCode').textContent);break;case 'collapseEmbed':$('#embedded').hidden=true;$('#expandEmbed').hidden=false;break;case 'expandEmbed':$('#embedded').hidden=false;$('#expandEmbed').hidden=true;break;case 'embedHeart':if(!$('#slotReactions').checked)return;$('#embedded').hidden=false;$('#expandEmbed').hidden=true;$('#embedHearts').classList.remove('play');void $('#embedHearts').offsetWidth;$('#embedHearts').classList.add('play');break;case 'widgetDemo':window.open('../embed-preview-2026-09-22.html','_blank','noopener,noreferrer');break;case 'banner':modal(T('Choose sample banner','选择示例宣传图'),`<div class="album-grid">${[0,3,5].map(i=>`<button class="album-cover" data-action="setBanner:${i}">${photo(i)}</button>`).join('')}</div><p style="margin-top:20px">${T('No files are uploaded in this prototype.','此原型不会上传文件。')}</p>`,'',true);break;case 'setBanner':albums[albumIndex].photo=Number(value);closeModal();render();break}}
function copy(text){if(navigator.clipboard)navigator.clipboard.writeText(text).then(()=>toast(T('Copied','已复制'))).catch(()=>prompt(T('Copy this text','复制以下内容'),text));else prompt(T('Copy this text','复制以下内容'),text)}
document.addEventListener('click',e=>{const b=e.target.closest('[data-action]');if(b&&!b.disabled)action(b.dataset.action)});document.addEventListener('keydown',e=>{const modalEl=$('.modal');if(!modalEl)return;if(e.key==='Escape')closeModal();if(e.key==='Tab'){const focus=[...modalEl.querySelectorAll('button,input,select,textarea,a')].filter(el=>!el.disabled);if(e.shiftKey&&document.activeElement===focus[0]){e.preventDefault();focus.at(-1)?.focus()}else if(!e.shiftKey&&document.activeElement===focus.at(-1)){e.preventDefault();focus[0]?.focus()}}});window.addEventListener('hashchange',render);render();
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 33 33" shape-rendering="crispEdges"><path fill="#ffffff" d="M0 0h33v33H0z"/><path stroke="#172033" d="M2 2.5h7m2 0h4m1 0h1m1 0h5m1 0h7M2 3.5h1m5 0h1m2 0h3m1 0h5m4 0h1m5 0h1M2 4.5h1m1 0h3m1 0h1m1 0h3m3 0h3m5 0h1m1 0h3m1 0h1M2 5.5h1m1 0h3m1 0h1m1 0h5m2 0h1m1 0h1m1 0h1m2 0h1m1 0h3m1 0h1M2 6.5h1m1 0h3m1 0h1m1 0h2m3 0h1m2 0h1m2 0h2m1 0h1m1 0h3m1 0h1M2 7.5h1m5 0h1m1 0h1m1 0h1m2 0h4m5 0h1m5 0h1M2 8.5h7m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h7M10 9.5h2m1 0h1m1 0h1m1 0h1m2 0h1M2 10.5h1m1 0h5m2 0h1m1 0h1m2 0h1m2 0h1m2 0h1m1 0h5M3 11.5h1m5 0h2m3 0h1m3 0h5m1 0h3m3 0h1M4 12.5h3m1 0h2m2 0h2m3 0h1m1 0h1m2 0h2m1 0h2M2 13.5h5m2 0h1m1 0h1m4 0h1m1 0h1m2 0h3m1 0h1m1 0h1m1 0h1M2 14.5h1m2 0h1m2 0h1m1 0h1m6 0h3m1 0h1m5 0h2M2 15.5h2m3 0h1m1 0h1m1 0h2m2 0h2m1 0h4m1 0h4m3 0h1M3 16.5h3m1 0h2m1 0h2m3 0h4m3 0h1m1 0h5M6 17.5h2m1 0h1m2 0h1m2 0h1m1 0h2m1 0h1m1 0h2m2 0h1m2 0h1M2 18.5h2m2 0h1m1 0h5m1 0h1m1 0h2m1 0h1m1 0h1m5 0h2M2 19.5h1m1 0h1m1 0h2m1 0h1m1 0h4m3 0h9m1 0h1m1 0h1M2 20.5h1m1 0h1m2 0h2m8 0h1m2 0h1m1 0h1m3 0h1m1 0h1M2 21.5h1m1 0h1m6 0h2m1 0h1m1 0h1m4 0h2m6 0h1M2 22.5h1m2 0h4m3 0h2m3 0h3m1 0h6m1 0h3M10 23.5h2m1 0h1m1 0h2m1 0h1m3 0h1m3 0h5M2 24.5h7m3 0h6m1 0h1m1 0h2m1 0h1m1 0h3M2 25.5h1m5 0h1m1 0h1m1 0h2m1 0h1m1 0h1m2 0h3m3 0h1M2 26.5h1m1 0h3m1 0h1m1 0h5m1 0h4m2 0h5m1 0h1M2 27.5h1m1 0h3m1 0h1m1 0h3m1 0h2m2 0h1m1 0h4m1 0h1m1 0h4M2 28.5h1m1 0h3m1 0h1m1 0h1m1 0h4m1 0h2m4 0h7M2 29.5h1m5 0h1m2 0h2m3 0h3m3 0h1m1 0h4m1 0h1M2 30.5h7m1 0h3m1 0h2m3 0h3m1 0h4m1 0h1"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

@@ -0,0 +1,6 @@
const fs=require('fs'),path=require('path'),root=__dirname;
const pages=[['login','登录','Login'],['albums','相册管理','Albums'],['photos','相册照片','Album photos'],['members','相册成员','Members'],['categories','分类管理','Categories'],['watermark','水印配置','Watermark'],['sharing','相册分享','Sharing'],['create-album','新建相册','New album'],['edit-album','编辑相册','Edit album'],['users','账号管理','Accounts'],['user-sync','用户同步','User sync'],['usage','存储与用量','Storage & usage'],['embed-slots','网站展示位','Embed widgets'],['embed-editor','配置展示位','Widget configuration'],['branding','宣传图与资产','Album branding'],['photo-viewer','照片大图','Photo details'],['403','无权限','Access restricted'],['404','页面不存在','Page not found'],['500','服务异常','Server error'],['sync-detail','同步批次详情','Sync run details']].map(([slug,zh,en],i)=>({id:i+1,file:String(i+1).padStart(2,'0')+'-'+slug+'.html',zh,en}));
fs.mkdirSync(path.join(root,'assets'),{recursive:true});fs.copyFileSync(path.join(root,'../admin-redesign-concept-2026-09-22.png'),path.join(root,'assets/photo-reference.png'));fs.copyFileSync(path.join(root,'../app-preview-2026-09-22/assets/demo-qr.svg'),path.join(root,'assets/demo-qr.svg'));
const app=fs.readFileSync(path.join(root,'../app-preview-2026-09-22/app.js'),'utf8');const paths=app.match(/const paths=(\{.*?\});/s)[1];fs.writeFileSync(path.join(root,'icons.js'),'window.ICONS='+paths+';');fs.writeFileSync(path.join(root,'pages.js'),'window.ADMIN_PAGES='+JSON.stringify(pages,null,2)+';');
const html=p=>`<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>PhotoLive · ${p.zh}</title><link rel="stylesheet" href="styles.css"></head><body data-page="${p.id}"><div id="root"></div><script src="icons.js"></script><script src="pages.js"></script><script src="admin.js"></script></body></html>`;
for(const p of pages)fs.writeFileSync(path.join(root,p.file),html(p));fs.writeFileSync(path.join(root,'index.html'),html(pages[1]));console.log('Generated 20 admin pages + index.');
@@ -0,0 +1,5 @@
const fs=require('fs'),path=require('path');const {chromium}=require(require.resolve('@playwright/test',{paths:[path.resolve(__dirname,'../../../apps/admin')]}));
(async()=>{const browser=await chromium.launch({headless:true});const page=await browser.newPage({viewport:{width:1440,height:1000}});const errors=[];page.on('pageerror',e=>errors.push(e.message));fs.mkdirSync(path.join(__dirname,'screenshots'),{recursive:true});const files=fs.readdirSync(__dirname).filter(x=>/^\d\d-.*\.html$/.test(x)).sort(),results=[];const url=file=>'file:///'+path.join(__dirname,file).replaceAll('\\','/');for(const file of files){await page.goto(url(file));await page.evaluate(()=>Promise.all([...document.images].map(i=>i.decode().catch(()=>{}))));await page.screenshot({path:path.join(__dirname,'screenshots',file.slice(0,2)+'.png'),fullPage:true});results.push({file,overflow:await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),brokenImages:await page.evaluate(()=>[...document.images].filter(i=>!i.complete||!i.naturalWidth).length)})}
await page.goto(url('02-albums.html'));await page.locator('#albumSearch').fill('Product');if(await page.locator('.album').count()!==1)throw Error('Album search failed');await page.locator('.album-cover').click();await page.waitForURL('**#3');await page.locator('.photo-check').first().check();await page.locator('[data-action="batchHide"]').click();await page.locator('[data-action="confirm"]').click();await page.locator('[data-action="photoFilter:hidden"]').click();if(await page.locator('.photo-item').count()!==2)throw Error('Batch hide failed');await page.evaluate(()=>location.hash='10');await page.locator('#userSearch').fill('emma');if(await page.locator('.data-table tbody tr').count()!==1)throw Error('User search failed');await page.selectOption('#language','en');await page.waitForFunction(()=>document.documentElement.lang==='en');await page.goto(url('14-embed-editor.html'));const rect=await page.locator('#embedded').boundingBox();if(rect.width!==195||rect.height!==280)throw Error('Widget dimension mismatch');await page.locator('[data-action="collapseEmbed"]').click();await page.locator('[data-action="expandEmbed"]').click();await page.selectOption('#slotMode','v1');if(!await page.locator('#v1Fields').isVisible())throw Error('Legacy mode missing');await page.goto(url('06-watermark.html'));await page.locator('#wmText').fill('DEMO');if(await page.locator('#wmPreview').innerText()!=='DEMO')throw Error('Watermark preview mismatch');
const narrow=[];await page.setViewportSize({width:390,height:844});for(const file of ['02-albums.html','03-photos.html','10-users.html','14-embed-editor.html']){await page.goto(url(file));narrow.push({file,overflow:await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth)});await page.screenshot({path:path.join(__dirname,'screenshots','mobile-'+file.slice(0,2)+'.png'),fullPage:true})}
const result={date:'2026-09-22',scope:'Offline HTML only; production admin untouched',pages:results,narrow,errors,widget:{width:rect.width,height:rect.height},interactions:['album search','batch hide','visibility filter','account search','language','widget collapse','v1 mode','watermark preview']};fs.writeFileSync(path.join(__dirname,'validation.json'),JSON.stringify(result,null,2));console.log(JSON.stringify(result));await browser.close();if(errors.length||results.some(p=>p.overflow||p.brokenImages)||narrow.some(p=>p.overflow))process.exitCode=1})().catch(e=>{console.error(e);process.exit(1)});
@@ -0,0 +1 @@
window.ICONS={back:'m15 18-6-6 6-6',next:'m9 18 6-6-6-6',close:'m6 6 12 12M6 18 18 6',plus:'M12 5v14M5 12h14',more:'M5 12h.01M12 12h.01M19 12h.01',album:'M3 7h7l2-3h9v16H3ZM3 9h18',photo:'M3 4h18v16H3ZM4 16l5-5 4 4 3-3 5 5M15 8h.01',upload:'M7 18H6a4 4 0 0 1-1-8 7 7 0 0 1 13-2 5 5 0 0 1 0 10h-1M12 19V9m-4 4 4-4 4 4',settings:'m9 3-.5 3-2 1-3-.8L2 10l2.5 2L4 15l2 3 3-.5 2 3 4-1 .5-3 3-1 2-4-2-2V6l-4-.5L13 3ZM9 12a3 3 0 1 0 6 0 3 3 0 1 0-6 0',user:'M8 7a4 4 0 1 0 8 0 4 4 0 1 0-8 0M4 21v-3a8 8 0 0 1 16 0v3',users:'M4 8a3 3 0 1 0 6 0 3 3 0 1 0-6 0M14 8a3 3 0 1 0 6 0 3 3 0 1 0-6 0M1 21v-3a6 6 0 0 1 12 0v3M14 14a6 6 0 0 1 9 4v3',lock:'M5 10h14v11H5ZM8 10V7a4 4 0 0 1 8 0v3M12 14v3',eye:'M2 12s3-7 10-7 10 7 10 7-3 7-10 7S2 12 2 12ZM9 12a3 3 0 1 0 6 0 3 3 0 1 0-6 0',globe:'M2 12h20M12 2a10 10 0 1 0 0 20 10 10 0 1 0 0-20ZM12 2c-6 6-6 14 0 20 6-6 6-14 0-20',help:'M9 9a3 3 0 1 1 5 2c-2 1-2 2-2 3M12 17h.01M12 2a10 10 0 1 0 0 20 10 10 0 1 0 0-20',logout:'M9 3H3v18h6M9 12h13m-5-5 5 5-5 5',calendar:'M4 5h16v16H4ZM4 10h16M8 3v4M16 3v4M8 14h2M14 14h2',pin:'M12 22S4 13 4 9a8 8 0 0 1 16 0c0 4-8 13-8 13ZM9 9a3 3 0 1 0 6 0 3 3 0 1 0-6 0',tag:'M3 3h8l10 10-8 8L3 11ZM7 7h.01',share:'M9 8l6-3M9 15l6 4M3 12a3 3 0 1 0 6 0 3 3 0 1 0-6 0M15 4a3 3 0 1 0 6 0 3 3 0 1 0-6 0M15 20a3 3 0 1 0 6 0 3 3 0 1 0-6 0',edit:'m4 16 12-12 4 4L8 20H4ZM13 7l4 4',camera:'M3 7h4l2-3h6l2 3h4v14H3ZM8 14a4 4 0 1 0 8 0 4 4 0 1 0-8 0',flip:'M3 11a9 9 0 0 1 16-6M19 1v5h-5M21 13a9 9 0 0 1-16 6M5 23v-5h5',clock:'M12 2a10 10 0 1 0 0 20 10 10 0 1 0 0-20ZM12 6v6l4 2',check:'m5 12 4 4L19 6',trash:'M3 6h18M9 6V3h6v3M5 6l1 15h12l1-15M10 10v7M14 10v7',link:'m9 15 6-6M8 16l-2 2a4 4 0 0 1-6-6l5-5a4 4 0 0 1 6 0M16 8l2-2a4 4 0 0 1 6 6l-5 5a4 4 0 0 1-6 0',logo:'M12 2 21 7v10l-9 5-9-5V7ZM12 2l4 7-4 7-8 1M21 7h-9l-4 7 4 8M21 17l-5-8H8L3 7'};
@@ -0,0 +1,5 @@
Generator: built-in image_gen, imagegen skill. Generated 2026-09-22.
Use case: ui-mockup. Create a polished high fidelity desktop admin web interface concept screenshot for PhotoLive, enterprise event photo live streaming management. One single full desktop screen, flat front-on 16:10 composition, about 1600x1000; no devices, no perspective, no outside annotations. It is a redesign on Art Design Pro / Element Plus design language, calm mature SaaS, not a marketing dashboard. White 216px sidebar, pale cool #F5F7FA main canvas, navy #172033 typography, blue #1A73E8 actions, thin #E5EAF1 borders, 12px rounded cards, subtle shadows, spacious structured hierarchy. Left sidebar PhotoLive blue aperture mark, small WORKSPACE label then Albums selected, Embed widgets; ORGANIZATION label then Accounts, User sync, Storage & usage; bottom administrator avatar and company workspace. Topbar breadcrumb Workspace / Albums / Canton Fair, language EN and avatar. Main title Canton Fair · Autumn with small Published and Uploads on status pills, right outlined Edit album and primary Share album. Beneath: compact horizontal hero info card, left venue photograph around 320x145, right event date Oct 15–19, 2026, Guangzhou China, owner Alex Morgan, timezone Asia/Shanghai, muted small Stats updated 2 min ago. Under hero: tabs Photos selected blue underline, Members, Categories, Watermark, Sharing. Photo toolbar with All photos, Published, Hidden, category dropdown, photo filename search, grid/list toggle. Actual main photo grid 4 columns by 2 rows of beautiful documentary international trade fair photos: venue, networking business people, robotics, audience, speakers. Each thumbnail has small checkbox top left and a subtle Published status, below images tiny filename and uploader; one image selected with blue border and checkbox. A small blue subdued selection toolbar '1 selected' and 'Hide selected' may appear within photo area, not floating over website. No giant charts, no fake revenue, no ecommerce, no public like button, no comment/upload/social buttons, no technical token as page headline. Text crisp legible English, consistent thin outline icons and clean typography. All images and counts are illustrative. Overall feel premium restrained professional photography management tool, photos dominate, realistic implementable HTML layout.
Observed variation: the generated image used an illustrative numeric uploads badge instead of the requested Uploads on text. The HTML uses access-state semantics and must take precedence for behavior and data interpretation. Counts and text in the concept image are not production requirements.
@@ -0,0 +1 @@
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>PhotoLive · 相册管理</title><link rel="stylesheet" href="styles.css"></head><body data-page="2"><div id="root"></div><script src="icons.js"></script><script src="pages.js"></script><script src="admin.js"></script></body></html>
@@ -0,0 +1,122 @@
window.ADMIN_PAGES=[
{
"id": 1,
"file": "01-login.html",
"zh": "登录",
"en": "Login"
},
{
"id": 2,
"file": "02-albums.html",
"zh": "相册管理",
"en": "Albums"
},
{
"id": 3,
"file": "03-photos.html",
"zh": "相册照片",
"en": "Album photos"
},
{
"id": 4,
"file": "04-members.html",
"zh": "相册成员",
"en": "Members"
},
{
"id": 5,
"file": "05-categories.html",
"zh": "分类管理",
"en": "Categories"
},
{
"id": 6,
"file": "06-watermark.html",
"zh": "水印配置",
"en": "Watermark"
},
{
"id": 7,
"file": "07-sharing.html",
"zh": "相册分享",
"en": "Sharing"
},
{
"id": 8,
"file": "08-create-album.html",
"zh": "新建相册",
"en": "New album"
},
{
"id": 9,
"file": "09-edit-album.html",
"zh": "编辑相册",
"en": "Edit album"
},
{
"id": 10,
"file": "10-users.html",
"zh": "账号管理",
"en": "Accounts"
},
{
"id": 11,
"file": "11-user-sync.html",
"zh": "用户同步",
"en": "User sync"
},
{
"id": 12,
"file": "12-usage.html",
"zh": "存储与用量",
"en": "Storage & usage"
},
{
"id": 13,
"file": "13-embed-slots.html",
"zh": "网站展示位",
"en": "Embed widgets"
},
{
"id": 14,
"file": "14-embed-editor.html",
"zh": "配置展示位",
"en": "Widget configuration"
},
{
"id": 15,
"file": "15-branding.html",
"zh": "宣传图与资产",
"en": "Album branding"
},
{
"id": 16,
"file": "16-photo-viewer.html",
"zh": "照片大图",
"en": "Photo details"
},
{
"id": 17,
"file": "17-403.html",
"zh": "无权限",
"en": "Access restricted"
},
{
"id": 18,
"file": "18-404.html",
"zh": "页面不存在",
"en": "Page not found"
},
{
"id": 19,
"file": "19-500.html",
"zh": "服务异常",
"en": "Server error"
},
{
"id": 20,
"file": "20-sync-detail.html",
"zh": "同步批次详情",
"en": "Sync run details"
}
];
Binary file not shown.

After

Width:  |  Height:  |  Size: 314 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 688 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 955 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 369 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 335 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 360 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 804 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 648 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 598 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

File diff suppressed because one or more lines are too long
@@ -0,0 +1,139 @@
{
"date": "2026-09-22",
"scope": "Offline HTML only; production admin untouched",
"pages": [
{
"file": "01-login.html",
"overflow": false,
"brokenImages": 0
},
{
"file": "02-albums.html",
"overflow": false,
"brokenImages": 0
},
{
"file": "03-photos.html",
"overflow": false,
"brokenImages": 0
},
{
"file": "04-members.html",
"overflow": false,
"brokenImages": 0
},
{
"file": "05-categories.html",
"overflow": false,
"brokenImages": 0
},
{
"file": "06-watermark.html",
"overflow": false,
"brokenImages": 0
},
{
"file": "07-sharing.html",
"overflow": false,
"brokenImages": 0
},
{
"file": "08-create-album.html",
"overflow": false,
"brokenImages": 0
},
{
"file": "09-edit-album.html",
"overflow": false,
"brokenImages": 0
},
{
"file": "10-users.html",
"overflow": false,
"brokenImages": 0
},
{
"file": "11-user-sync.html",
"overflow": false,
"brokenImages": 0
},
{
"file": "12-usage.html",
"overflow": false,
"brokenImages": 0
},
{
"file": "13-embed-slots.html",
"overflow": false,
"brokenImages": 0
},
{
"file": "14-embed-editor.html",
"overflow": false,
"brokenImages": 0
},
{
"file": "15-branding.html",
"overflow": false,
"brokenImages": 0
},
{
"file": "16-photo-viewer.html",
"overflow": false,
"brokenImages": 0
},
{
"file": "17-403.html",
"overflow": false,
"brokenImages": 0
},
{
"file": "18-404.html",
"overflow": false,
"brokenImages": 0
},
{
"file": "19-500.html",
"overflow": false,
"brokenImages": 0
},
{
"file": "20-sync-detail.html",
"overflow": false,
"brokenImages": 0
}
],
"narrow": [
{
"file": "02-albums.html",
"overflow": false
},
{
"file": "03-photos.html",
"overflow": false
},
{
"file": "10-users.html",
"overflow": false
},
{
"file": "14-embed-editor.html",
"overflow": false
}
],
"errors": [],
"widget": {
"width": 195,
"height": 280
},
"interactions": [
"album search",
"batch hide",
"visibility filter",
"account search",
"language",
"widget collapse",
"v1 mode",
"watermark preview"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

+2 -2
View File
@@ -4,8 +4,8 @@ const paths={back:'m15 18-6-6 6-6',next:'m9 18 6-6-6-6',close:'m6 6 12 12M6 18 1
function icon(n){return `<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="${paths[n]||paths.photo}"/></svg>`}
let lang='en',page=1,returnPage=2,albumTitle='Canton Fair · Autumn',editTab='access',uploadFilter='all',photoFilter='all',currentPhoto=1,categoryNames=['Exhibition','People','Highlights'],members=['Emma Chen','Daniel Lee'],extraTasks=0,hiddenPhotos=new Set([6]),removedPhotos=new Set(),retried=false,publicAccess=true,uploadAllowed=true,downloads=true,original=false,draftLanguage='en',toastTimer;
const T=(en,zh)=>lang==='zh'?zh:en, esc=s=>String(s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const pics=[[262,486,230,128],[845,248,358,435],[264,661,224,122],[506,656,185,142],[265,824,224,126],[504,823,181,129],[260,100,477,193],[848,300,345,378]];
function photo(i,cls='',height=''){const r=pics[((i%pics.length)+pics.length)%pics.length],[x,y,w,h]=r;return `<div class="art ${cls}" style="aspect-ratio:${w}/${h};${height?`height:${height};`:''}"><img src="assets/reference.png" alt="" style="width:${1536/w*100}%;height:${1024/h*100}%;left:${-x/w*100}%;top:${-y/h*100}%"></div>`}
const pics=[[262,530,230,105],[845,248,358,435],[264,661,224,122],[506,656,185,142],[265,824,224,126],[504,823,181,129],[260,100,477,193],[848,300,345,378]];
function photo(i,cls='',height=''){const r=pics[((i%pics.length)+pics.length)%pics.length],[x,y,w,h]=r;return `<div class="art ${cls}" style="aspect-ratio:${w}/${h};${height?`height:${height};`:''}"><svg width="100%" height="100%" viewBox="${x} ${y} ${w} ${h}" preserveAspectRatio="xMidYMid slice" aria-hidden="true" style="display:block"><image href="assets/reference.png" width="1536" height="1024"/></svg></div>`}
function button(label,action,cls='primary',ic=''){return `<button class="btn ${cls}" data-action="${action}">${ic?icon(ic):''}${label}</button>`}
function row(ic,label,sub,action='',trailing=''){return `<${action?'button':'div'} class="row" ${action?`data-action="${action}"`:''}>${icon(ic)}<span class="grow"><strong>${label}</strong>${sub?`<p>${sub}</p>`:''}</span>${trailing?`<span class="trailing">${trailing}</span>`:''}${action?`<span class="arrow">${icon('next')}</span>`:''}</${action?'button':'div'}>`}
function bar(title,back=5,right='',root=false){return `<div class="safe-top"></div><header class="bar ${root?'root':''} ${[13,18,20].includes(page)?'dark':''}">${root?`<h1>${title}</h1>`:`<button class="back" data-go="${back}" aria-label="${T('Back','返回')}">${icon('back')}</button><span class="bar-title">${title}</span>`}${right}</header>`}
@@ -0,0 +1,5 @@
const fs=require('fs'),path=require('path');
const {chromium}=require(require.resolve('@playwright/test',{paths:[path.resolve(__dirname,'../../../apps/viewer')]}));
(async()=>{const browser=await chromium.launch({headless:true});const page=await browser.newPage({viewport:{width:390,height:844}});const errors=[];page.on('pageerror',e=>errors.push(e.message));const files=fs.readdirSync(__dirname).filter(x=>/^\d\d-.*\.html$/.test(x)).sort();fs.mkdirSync(path.join(__dirname,'screenshots'),{recursive:true});const rows=[];for(const file of files){await page.goto('file:///'+path.join(__dirname,file).replaceAll('\\','/'));await page.locator('#app').waitFor();await page.evaluate(()=>Promise.all([...document.images].map(i=>i.decode().catch(()=>{}))));await page.screenshot({path:path.join(__dirname,'screenshots',file.slice(0,2)+'.png')});rows.push({file,overflow:await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),buttons:await page.locator('button').count()})}
await page.goto('file:///'+path.join(__dirname,'01-login.html').replaceAll('\\','/'));await page.locator('#loginForm button[type=submit]').click();await page.waitForURL('**#05');await page.locator('.album-card').first().click();await page.waitForURL('**#06');await page.locator('[data-action="go:13"]').click();await page.waitForURL('**#13');await page.locator('.shutter').click();if(!(await page.locator('#cameraNote').textContent()).includes('1'))throw Error('Capture mock queue failed');await page.evaluate(()=>location.hash='20');await page.locator('[data-action="confirmHide"]').click();await page.waitForURL('**#18');await page.waitForFunction(()=>document.querySelector('.photo-meta')?.textContent.includes('Hidden'));await page.locator('[data-action="publish"]').click();await page.evaluate(()=>location.hash='04');await page.locator('input[value="zh"]').check();await page.locator('[data-action="saveLanguage"]').click();await page.waitForURL('**#02');await page.waitForFunction(()=>document.querySelector('h1')?.textContent.includes('设置'));
const narrow=[];await page.setViewportSize({width:320,height:568});for(const id of [1,9,14,18,19]){await page.goto('file:///'+path.join(__dirname,files[id-1]).replaceAll('\\','/'));narrow.push({id,overflow:await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth)})}const result={date:'2026-09-22',type:'HTML prototype only',pages:rows,narrow,errors,interactions:['login','album navigation','simulated capture','hide','publish','language'],nativeAppTested:false};fs.writeFileSync(path.join(__dirname,'validation.json'),JSON.stringify(result,null,2));console.log(JSON.stringify(result));await browser.close();if(errors.length||rows.some(x=>x.overflow)||narrow.some(x=>x.overflow))process.exitCode=1})().catch(e=>{console.error(e);process.exit(1)});
Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 440 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

File diff suppressed because one or more lines are too long
@@ -0,0 +1,138 @@
{
"date": "2026-09-22",
"type": "HTML prototype only",
"pages": [
{
"file": "01-login.html",
"overflow": false,
"buttons": 3
},
{
"file": "02-settings.html",
"overflow": false,
"buttons": 7
},
{
"file": "03-password.html",
"overflow": false,
"buttons": 5
},
{
"file": "04-language.html",
"overflow": false,
"buttons": 8
},
{
"file": "05-albums.html",
"overflow": false,
"buttons": 7
},
{
"file": "06-album-detail.html",
"overflow": false,
"buttons": 18
},
{
"file": "07-album-actions.html",
"overflow": false,
"buttons": 23
},
{
"file": "08-empty-album.html",
"overflow": false,
"buttons": 4
},
{
"file": "09-create-album.html",
"overflow": false,
"buttons": 2
},
{
"file": "10-edit-album.html",
"overflow": false,
"buttons": 5
},
{
"file": "11-members.html",
"overflow": false,
"buttons": 5
},
{
"file": "12-categories.html",
"overflow": false,
"buttons": 9
},
{
"file": "13-camera.html",
"overflow": false,
"buttons": 6
},
{
"file": "14-uploads.html",
"overflow": false,
"buttons": 7
},
{
"file": "15-upload-errors.html",
"overflow": false,
"buttons": 10
},
{
"file": "16-upload-history.html",
"overflow": false,
"buttons": 1
},
{
"file": "17-photos.html",
"overflow": false,
"buttons": 22
},
{
"file": "18-photo-detail.html",
"overflow": false,
"buttons": 6
},
{
"file": "19-share.html",
"overflow": false,
"buttons": 3
},
{
"file": "20-hide-photo.html",
"overflow": false,
"buttons": 8
}
],
"narrow": [
{
"id": 1,
"overflow": false
},
{
"id": 9,
"overflow": false
},
{
"id": 14,
"overflow": false
},
{
"id": 18,
"overflow": false
},
{
"id": 19,
"overflow": false
}
],
"errors": [],
"interactions": [
"login",
"album navigation",
"simulated capture",
"hide",
"publish",
"language"
],
"nativeAppTested": false
}
@@ -0,0 +1,161 @@
# UI-05:管理后台现状分析与新版设计说明
日期:2026-09-22。交付类型:代码静态分析、概念效果图、可运行离线 HTML 原型。**未修改管理后台业务源码,未连接真实账号、数据库、OSS 或生产接口。**
## 1. 交付入口
- [新版后台 HTML 入口](../designs/admin-preview-2026-09-22/index.html)
- [后台概念效果图](../designs/admin-redesign-concept-2026-09-22.png)
- [HTML 使用说明与页面索引](../designs/admin-preview-2026-09-22/README.md)
- [HTML 实际截图](../designs/admin-preview-2026-09-22/screenshots/)
- [自动检查记录](../designs/admin-preview-2026-09-22/validation.json)
- [前一项 App 20 页原型](../designs/app-preview-2026-09-22/index.html)
双击 HTML 入口即可离线查看。左侧“全部页面目录”可直达20个页面与关键状态;相册、成员、分类、水印、账号等按钮可在示例状态中操作。图片、二维码和脚本均保存在本地目录,不依赖 CDN。
## 2. 分析依据与结论
### 2.1 实际框架
`apps/admin/package.json` 明确记录 Art Design Pro **v3.0.2** 裁剪版,实际使用 Vue 3、Element Plus、Pinia、vue-i18n、Tailwind、Vite。模板的演示视图和图表等依赖已裁剪。
布局保留 `ArtSidebarMenu`、`ArtHeaderBar`、`ArtPageContent` 和 `ArtGlobalComponent`。因此本次设计适合通过模板主题 token、业务组件及页面布局改造实现,无须换后台框架,也不应重新安装完整演示模板覆盖已有代码。
### 2.2 已核对的入口
| 当前入口 | 已有业务 | 设计处理 |
|---|---|---|
| `router/modules/albums.ts` | 相册管理、隐藏的相册详情路由 | 保留相册为主要工作入口,详情保持所属菜单高亮 |
| `views/albums/manage/index.vue` | 文字表格、公开 token、状态、开关、游标加载 | 默认摄影封面卡片,可切换表格;token收敛到分享/高级资料 |
| `views/albums/detail/index.vue` | 资料描述表、宣传图上传、照片/成员/分类/水印/分享页签 | 紧凑活动摘要在上,照片区成为主内容;资产编辑独立页签 |
| `AlbumPhotos.vue` | 处理/可见性状态表格、点赞副本、批量隐藏、软删除 | 增加照片网格和管理大图,保留表格、筛选、部分成功语义 |
| `AlbumMembers.vue` / `AlbumTransferDialog.vue` | 内部账号邀请、成员管理、所有者交接 | 成员列表简洁化,交接独立确认,不改权限 |
| `AlbumCategories.vue` | 分类多语言 CRUD | 双语同屏编辑,避免另一个语言被全量替换丢失 |
| `AlbumWatermark.vue` | 文本水印配置、示意预览、生成任务与重生成 | 参数与摄影预览双栏,任务记录保持独立 |
| `system/users` | 搜索、角色/凭据/来源/本地锁定、密码重置与激活 | 状态分列,治理操作更清楚,保留自身账号保护 |
| `system/user-sync` | 定时拉取状态、手动补拉、批次历史、失败明细 | 状态摘要、历史表格、失败详情弹层 |
| `system/usage` | 原图/变体/合计、相册用量、阈值与告警 | 单位一致的汇总卡、资产构成、告警优先级 |
| `system/embed-slots` | 展示位、绑定、启停、配置版本、接入代码、预览 | 列表与独立配置页,保留v1并突出v2固定小窗 |
| `auth/login` / `exception` | 账号密码登录、403/404/500 | 统一品牌与回退路径,不增加注册、飞书 |
本次是静态代码和契约核查;没有以生产运行截图作为现状证据,没有宣称测试了当前后台的真实登录、权限和 API 链路。`views/index/index.vue` 是布局壳,不是已有业务仪表盘。
## 3. 当前值得调整的地方
### 3.1 照片业务却以信息表格为第一视觉
相册列表首先呈现标题、token和多个开关标签,活动识别需要读文字。详情的大型 `ElDescriptions` 和宣传图上传占据照片之前的区域。摄影业务应先让工作人员认出活动和照片,再进入参数编辑。
调整:相册封面卡片默认、表格备选;详情摘要高度受控,日期地点、创建者与访问状态简洁排列;编辑、分享为固定主操作;照片网格可切回信息表格。
### 3.2 技术信息与高频操作没有分层
公开 token、内部ownerId和配置信息不应成为主视觉字段。操作人员常用的是进入相册、查看照片、管理成员、分享与关闭公开访问。
调整:token保留在链接和代码生成流程;内部ID按需展开;保留处理状态和可见性差异,不能把照片处理完成等同于已公开。
### 3.3 状态很多,需要保持语义独立
公开/未公开、上传允许、相册active/offline、处理ready/failed、账号sourceStatus/credentialStatus/localLocked不可合并为一个“启用”标签。
调整:相册主卡只放主要状态;详情与表格提供完整事实。账号治理分列。用量阈值是告警而非硬配额;“用户同步”是企业账号目录同步,不能画成已验证的跨境照片同步监控。
### 3.4 局部页面已经具备保护,重做样式不能损失
- `useCursorList` 已为游标API设计,不要换成要求总数和随机页码的通用分页。
- 批量隐藏已有逐项成功/失败结果,不要用全成功动画掩盖局部失败。
- 账号来源状态只读,自己的角色/锁定存在保护。
- 水印为异步任务,受理不等于全部生成成功。
- 展示位无删除端点;停用、解绑、收起含义不同。
- 同步手动补拉的受理、运行、最终结果是不同状态。
## 4. 已确认的契约对齐项
### 4.1 相册封面:契约存在,管理端类型尚未对齐
`packages/contracts/openapi.yaml` 的 Album 定义已有可空 `cover` 短期签名URL摘要,明确按页批量组装。所读 `apps/admin/src/types/api/album.d.ts` 的 `Summary` 还未声明 `cover`。
正式实施先核查服务实际响应,再对齐管理端类型和展示组件;不需要凭空发明封面服务,也不应为了卡片每个相册额外请求一次详情。无封面用中性占位,不偷用另一个活动图片。
### 4.2 管理照片预览:契约已有权限约束,不能绕过
OpenAPI 的 `AdminPhoto.preview` 已提供 `thumb/browse` 签名URL,可为null;隐藏照片预览仅admin/owner签发。当前管理端 `AdminPhoto` 类型还未声明此字段,现有 `AlbumPhotos.vue` 以资料表为主。
正式实现:补齐可空类型,使用该预览字段;为空呈现无预览/处理中/无权限的真实状态;签名过期经受控重取刷新。不能用公开原图URL、猜OSS对象键或匿名观众接口绕过管理权限。
### 4.3 搜索与相册创建不能按原型直接照搬API
- `fetchAlbumList` 当前参数仅cursor/limit,没有全局标题搜索和任意排序。原型明确写“搜索当前已加载相册”;生产若要全局搜索,应兼容扩展API再上线。
- `fetchAlbumPhotos` 当前支持visibility/categoryId/cursor/limit,没有按文件名全局搜索。原型文件名是示例,正式无文件名时显示可用安全回退,不强行编造原文件名。
- 相册列表只有ownerId,没有直接提供owner显示名;生产需评估批量显示名投影/缓存,不能N+1查人。
- `CreatePayload` 当前只接受下载相关开关,公开/上传开关在更新载荷中。原型创建表单展示完整视觉模型,正式新建页应按契约禁用或解释这些字段,不能直接把UI字段塞进创建请求;也不能静默创建后再自动多次PATCH。
- 生产只要求默认语言内容满足真实校验;原型为展示双语同时要求填写两列,不构成新增产品必填政策。
- `translations` 为全量替换,修改单语言必须合并保留其他语言。
### 4.4 展示位v2类型已有,配置表单应复核调用链
此次读取的 `types/api/embedSlot.d.ts` 已有 `displayMode/scrollSpeed/reactionsEnabled`,但同次读取的 `SlotFormDialog.vue` 仍主要呈现size/intervalMs/imageFit等旧配置。工作树存在其他进行中的UI04改动,实施前必须重新核对最新版本。
正式新版明确区分v1与v2:v2固定195×280,不让size恢复320px;v1保留旧字段;settings整体替换时保留全部字段。后台最终应复用真正的展示组件,不能把此HTML中的简化预览当生产组件。
## 5. 新版视觉规范
| 项目 | 规格 |
|---|---|
| 布局 | 左侧216px固定导航,顶部64px,内容最大宽1660px |
| 工作区 | 背景#F5F7FA,常规左右30px,卡片间距20–22px |
| 主色 | #1A73E8;蓝色主操作,白色次级操作 |
| 字体 | 系统无衬线,Windows中文优先Microsoft YaHei |
| 标题 | 页面27px;卡片17px;表单/正文12–13px;辅助10–11px |
| 边框与圆角 | #E5EAF1,卡片12px、控件8px,不使用重阴影 |
| 相册列表 | 1440宽下三列封面卡;可切换表格;无假全局总数 |
| 相册详情 | 300×144封面+简洁资料,页签承载管理功能 |
| 照片网格 | 桌面四列,选择框、状态、文件回退名、上传者;无公众点赞按钮 |
| 治理表格 | 表头浅底、轻分隔、长内容局部横滚,不能撑破整个页面 |
| 状态 | 绿成功/公开,橙待处理,红危险/失败,灰非活跃;同时提供文字 |
| 窄屏 | 侧栏抽屉、主栏换行、表格局部滚动;不把桌面整页缩成缩略图 |
| 动态 | 减少无意义动效;焦点可见、弹窗限制焦点、Escape可关闭 |
概念效果图中的“246 uploads”等数字只是图像生成的示例,不是新的数据口径。HTML采用“可上传/上传关闭”及已有数据语义;正式实现不照抄概念图中的数量、人员、文件名和统计。
## 6. 20页与关键交互覆盖
01登录;02相册卡片/表格;03相册照片网格/表格;04成员;05分类;06水印;07分享;08新建;09编辑;10账号治理;11用户同步;12存储用量;13展示位列表;14展示位配置;15宣传图资产;16照片大图;17/18/19对应403/404/500;20同步批次详情。
同时提供可操作弹层:邀请/移除成员、所有者交接、分类增改删、照片隐藏/恢复/软删除、批量隐藏、账号编辑/重置/激活/锁定、阈值设置、展示位绑定及接入代码。
入口的菜单重组是展示层优化:相册和展示位归工作空间;账号、用户同步、用量归组织治理。可以保留真实 `system/embed-slots` 路由,不必为导航名称改数据库或路由权限体系。
## 7. Art Design Pro 实施映射
1. 在主题层增加或对齐主色、背景、圆角、边框与字号,不引入另一套UI框架。
2. `ArtSidebarMenu/ArtHeaderBar` 保留菜单权限、响应式、语言、账号与原有导航行为;重排必要展示元素。
3. `albums/manage` 提取 AlbumCoverCard 与列表/表格切换组件,保留游标数据源。
4. `albums/detail` 提取 AlbumSummaryHeader,删除重复描述区;把现有BannerUpload收进资产页签。
5. `AlbumPhotos` 共用一份照片序列,添加PhotoGrid/PhotoPreview,不复制新的状态存储;保留现有表格和批量结果处理。
6. 成员、分类和分享复用现有API;统一表单footer、危险操作确认和错误定位。
7. 治理页面继续复用Element Plus Table/Dialog/Drawer/Switch;保持账号保护与任务轮询清理。
8. 展示位页面对齐UI04最终实现,参数与预览使用同一来源,不维护两个不同逻辑的“漂亮预览”。
## 8. 原型与生产实现的边界
- 本HTML没有引入Art Design Pro运行依赖,是供确认视觉与交互的独立原型,不是替代模板的生产实现。
- 示例数据仅存在当前文档内存。刷新或独立打开另页会重置;无真实写请求。
- 默认以公司管理员视角展示全部页面;员工/协作者角色的正式权限验收未实现于此原型。
- 摄影来自已有/本次生成图的局部,适用于说明布局。不得当成公司真实活动素材。
- 二维码编码的是example.com演示地址;复制链接和“打开观众页”指向本机观众页原型。
- 展示位配置内的CSS上滚只用于说明样式,不声明是生产有限队列、真实气泡或严格速度实现;精确滚动可另开此前组件HTML查看。
- 没有实现生产背景任务、密码重置、真实目录同步、OSS上传、海外复制、真实错误恢复或全套系统深色主题。
- 统计由样例构成;不添加尚不存在的营业额、趋势、在线人数等仪表盘指标。
## 9. 检查结果与落地验收建议
HTML检查通过:20页在1440×1000渲染,无页面JS异常、图片加载失败或文档横向溢出;390宽代表页检查通过。已验证相册搜索、批量隐藏、可见性筛选、账号搜索、语言切换、展示位195×280尺寸/收起/v1配置、水印预览。
原生App原型亦已补完:20页390×844渲染;5类320宽代表页;登录→相册→拍摄模拟入队、隐藏→恢复、语言提交均通过。两套结果均只代表HTML,不代表真实Flutter或管理后台上线验收。
正式后台实施仍需补:角色矩阵、真实API响应、签名过期、403/404/断网、批处理部分失败、水印异步任务、账号自保护、settings全量替换、真实v1/v2预览一致性。保留当前所有已有测试,在隔离环境验证,不自动部署或操作真实云资源。
## 10. 生成方式
概念图使用内置 image_gen(imagegen 技能),非CLI。完整提示词随设计资产保存于 `../designs/admin-preview-2026-09-22/image-prompt.txt`。HTML与截图由本地实现和Playwright生成。生成图与运行截图分别保留,不互相冒充。
@@ -0,0 +1,219 @@
# UI-06:全端视觉统一实施方案(管理后台 + App,以 HTML 示例为唯一视觉基准)
版本:1.0
日期:2026-09-22
状态:待执行。本文只规划,不代表页面已还原。
目标:把 `apps/admin`(Vue3 + Element Plus / Art Design Pro 裁剪版)与 `apps/mobile`(Flutter)的实际界面,逐页还原到两个 HTML 示例的视觉效果,并以截图对照 + 测试服务器部署作为验收证据。
## 0. 给执行者的强制说明
**两个 HTML 预览目录是唯一视觉真相源。** 用户已明确:「要以示例 HTML 效果为主」。任何规格表、旧方案、设计板、组件库默认值与示例冲突时,一律以示例为准。
- [管理后台示例](../designs/admin-preview-2026-09-22/index.html)(20 页,`screenshots/01.png`–`20.png` 为渲染证据)
- [App 示例](../designs/app-preview-2026-09-22/index.html)(20 页,`screenshots/01.png`–`20.png` 为渲染证据)
裁决优先级(高→低):
1. HTML 示例的实际渲染效果(以 `screenshots/` 与浏览器打开实物为准)
2. UI-05(后台)/ UI-03R(App)中与示例一致的数值规格、契约对齐项、保护项
3. 组件库(Element Plus / Material)默认值——**不得作为不还原的理由**
还原 = 修改真实代码。两个预览目录本身只读,不得为迁就实现反向修改示例。
其他强制项:
- 不得用「测试/类型检查通过」代替视觉验收;每页必须真实截图并对照示例。
- 不得只换颜色不改结构:示例中相册封面卡、照片网格、整页表单、菜单分组均为结构性改动,必须落地。
- 不得覆盖用户当前未提交改动;每批开工前核对 `git status`,异常先停下确认。
- 不自动提交推送打标签;每批完成提交需用户可见信息。不自动部署生产、不操作云资源;测试服务器部署按 §4.3 既有流程。
- 示例中的样例数据(人数、文件名、"82 uploaded"、演示二维码)不得抄进生产默认值。
## 1. 基线与前置
### 1.1 基线提交
开工前把工作树中未入库的成果先提交(属上一阶段产物,不属本方案改动):
- `docs/designs/admin-preview-2026-09-22/`(含 screenshots、validation.json、image-prompt.txt)
- `docs/designs/app-preview-2026-09-22/`(含 screenshots、validation.json、check-preview.cjs 及 app.js/styles.css 未提交小改)
- `docs/designs/admin-redesign-concept-2026-09-22.png`
- `docs/plans/2026-09-22-UI05-管理后台分析与新版设计说明.md`
- `docs/plans/2026-09-22-用户列表分页与同步信息展示-实施计划.md`
- `services/api/src/scripts/g4-sync-once.ts`
基线提交后新建 `docs/plans/progress/UI06-验收记录.md` 骨架。
### 1.2 Before 截图
本地起 admin(`pnpm --filter @photo/admin dev`,接口指向测试服务器 cn API),用 Playwright 对现有页面截图存入 `docs/designs/ui06-implementation/before/`,命名与示例编号对齐(`02-albums.png` 等)。App 侧 before 不直接复用 UI-03 截图作为证据(仅覆盖 10/20 页且为 540×960 设备分辨率)——A0 阶段在固定 390×844 画布对全部 20 页补拍当前态。
## 2. 管理后台线(先行)
视觉规范依据示例与 UI-05 §5:主色 `#1A73E8`;工作区背景 `#F5F7FA`;边框 `#E5EAF1`;卡片圆角 12、控件 8;左侧导航 216px、顶部 64px、内容最大宽 1660px、常规左右边距 30px、卡间距 20–22px;页面标题 27px、卡片标题 17px、正文 12–13px、辅助 10–11px;字体为系统无衬线栈、Windows 中文优先 Microsoft YaHei;状态色绿=成功/公开、橙=待处理、红=危险/失败、灰=非活跃,且必须同时有文字。
### 2.1 二十页映射与结构性改动
| # | 示例页 | 代码入口 | 结构性改动(以示例为准) |
|---|---|---|---|
| 01 | 登录 | `views/auth/login/index.vue` | 整页重做:摄影拼贴主视觉 + 居中表单;摄影素材先以示例 assets 概念图裁片作占位入库,**标记为占位**,待用户提供正式品牌素材后替换 |
| 02 | 相册列表 | `views/albums/manage/index.vue` | **默认封面卡片三列**(1440 宽),可切表格;搜索按当前已加载过滤(契约无全局搜索,UI-05 §4.3);token 收敛进分享/高级区 |
| 03 | 照片网格 | `views/albums/detail/AlbumPhotos.vue` | 新增照片**网格视图 + 管理大图**,保留表格/筛选/批量语义切换 |
| 04 | 成员 | `detail/AlbumMembers.vue` + `AlbumTransferDialog.vue` | 列表视觉简化;交接独立确认步骤 |
| 05 | 分类 | `detail/AlbumCategories.vue` | 双语同屏编辑,禁止全量替换丢语言 |
| 06 | 水印 | `detail/AlbumWatermark.vue` | 参数左、摄影预览右双栏;即时预览 |
| 07 | 分享 | `detail/AlbumShareCard.vue` | 分享卡视觉对齐:封面、二维码、复制链接布局 |
| 08 | 新建表单 | `manage/AlbumFormDialog.vue` → **改为整页表单路由** | 从对话框改整页(示例 08);新增隐藏路由(沿用相册详情隐藏路由先例,保持父菜单高亮);字段分组、footer 按钮对齐;`manage/index.vue` 与 `detail/index.vue` 两处消费入口一并改造 |
| 09 | 编辑表单 | 同上(edit 模式,含 `detail/index.vue:153` 编辑入口重接) | 整页 + 资料/访问分区;`translations` 合并保留其他语言 |
| 10 | 账号治理 | `system/users/` | 状态分列(凭据/来源/锁定);**保留 2026-09-22 刚上线的分页 + 同步档案列**,仅重排视觉 |
| 11 | 用户同步 | `system/user-sync/` | 状态摘要卡 + 历史 + 失败明细弹层视觉对齐 |
| 12 | 用量 | `system/usage/` | 汇总卡单位一致、资产构成、阈值对话框对齐 |
| 13 | 展示位列表 | `system/embed-slots/index.vue` | 列表视觉对齐;启停/绑定/收起语义不变 |
| 14 | 展示位配置 | `SlotFormDialog.vue` 等 | **参数与预览复用 UI-04 真实组件**(v2 固定 195×280);不搬示例里的简化 CSS 预览(UI-05 §4.4) |
| 15 | 品牌资产 | `detail/AlbumBannerUpload.vue` | 收进相册详情资产页签,独立视觉区 |
| 16 | 照片大图 | AlbumPhotos 新增 PhotoPreview | 深色舞台 + 元信息 + 隐藏/恢复/软删除动作条 |
| 17–19 | 403/404/500 | `views/exception/` | 品牌统一、回退路径视觉对齐 |
| 20 | 同步批次详情 | `system/user-sync/index.vue` 失败明细抽屉(:165 ElDrawer) | 抽屉视觉对齐示例 20 |
菜单分组(仅展示层重排,路由与权限不动,UI-05 §6):工作空间(相册管理、网站展示位)/ 组织与治理(账号管理、用户同步、存储与用量)。
### 2.2 契约核对(B0 内完成,结论写进验收记录)
对测试服务器实际响应核对,不得凭空发明:
- Album `cover`(可空签名 URL 摘要):核对测试服务器 `fetchAlbumList` 实际响应 → 补 `apps/admin/src/types/api/album.d.ts` `Summary.cover` → 卡片使用。**分支处理**:(a) 服务端已返回、仅部分相册无封面 → 中性占位验收;(b) 服务端响应根本无此字段(未实现)→ 属服务端补齐(OpenAPI 已定义、超出纯前端范围),**停下向用户确认**后再做,不得假造封面或 N+1 拉详情。
- `AdminPhoto.preview`(thumb/browse 可空):核对响应 → 补类型 → 网格/大图使用;为空呈现真实空态;签名过期经受控重取。
- 相册创建载荷仅下载开关;公开/上传开关属更新载荷:整页表单按契约禁用或解释,不得塞进创建请求。
- 展示位 v2 `displayMode/scrollSpeed/reactionsEnabled` 已有类型;实施前重核 `SlotFormDialog.vue` 最新版本(工作树 UI-04 改动已提交,以 HEAD 为准)。
### 2.3 任务清单
### B0 基线(半天)
- [ ] §1.1 基线提交;`git status` 干净后开工。
- [ ] 编写可复用 Playwright 截图脚本(登录 → 逐页 capture → 存 `docs/designs/ui06-implementation/`),后续 before/comparisons 全部用它;需有效测试服务器管理员账号(向用户获取,凭据不写入仓库)。
- [ ] before 截图(admin dev + 测试服务器 API)。
- [ ] 契约核对 §2.2 四项,结论记入验收记录。
- [ ] 核对 Element Plus 主题变量入口(`assets/styles/core/el-light.scss`、`el-ui.scss`)与 Art 布局组件(`components/core/layouts/`)现状。
- 出口:偏差清单 + 契约结论齐,才进 B1。
### B1 主题 + 布局壳(1 天)
- [ ] token 落地:以 admin-preview `styles.css` 的 `:root` CSS 变量块为对照来源,映射到 Element Plus 主题变量(`--el-color-primary` 等)与 Art token;字体按 §2(系统无衬线栈,Windows 中文优先 Microsoft YaHei);同库双部署不受影响。
- [ ] `ArtSidebarMenu` 216px + 菜单分组标题;`ArtHeaderBar` 64px;保留权限、响应式抽屉、语言切换、账号菜单行为。菜单分组实现 = 调整 `router/modules` 的 meta(标题/分组/顺序),路径与权限标识不变。
- [ ] 全部新增文案(菜单分组名、状态标签、表单字段等)走 vue-i18n zh/en 双语 key,不得硬编码中文或英文。
- [ ] 深色模式(Art 模板自带)保持可用不重设计——示例仅浅色;token 改动后在深色下做可用性抽查,偏差登记验收记录。
- [ ] 全局卡片/表格/按钮/输入/开关/标签视觉对齐示例(状态色 + 文字)。
- [ ] 01 登录页重做(摄影拼贴素材以示例概念图裁片占位入库,验收记录登记"素材为占位")。
- [ ] 抽查截图:布局壳 + 登录页 vs `screenshots/01.png`。
- 出口:全局壳一致;未动业务数据流。
### B2 共享业务组件(1–2 天)
- [ ] `AlbumCoverCard`:封面 16:9 区域、标题、日期地点、状态行(Public/Uploads on 语义分开)、主操作(进入相册/分享);无封面中性占位;不显示 owner 显示名(契约无投影、避免 N+1,UI-05 §4.3)。
- [ ] 列表/表格视图切换(保留游标 `useCursorList` 数据源,不换随机分页)。
- [ ] `PhotoGrid`(桌面四列、选择框、状态角标、文件回退名、上传者)+ `PhotoPreview` 大图(签名 URL、隐藏/恢复/软删除入口)。
- [ ] `AlbumSummaryHeader`(300×144 封面 + 紧凑资料,编辑/分享固定主操作)。
- [ ] 统一状态标签、表单 footer、危险操作确认、错误定位样式。
- 出口:组件级截图核对;AlbumPhotos 仍共用一份照片序列,无重复状态存储。
### B3 页面批次(每批:完成 → lint/test 绿 → **本地提交(不推送)** → 构建部署测试服务器 → 用户 32005 验收 → 通过后进下一批;不通过则修复后重新提交部署)
> 部署构建用 `git archive HEAD`,只含已提交内容——所以**必须先提交再部署**。B1/B2 为本地截图验收,不部署服务器;部署从 B3 批次开始。
- **B3a 核心批**:02 相册列表 + 03 照片网格/表格(含 16 大图)+ **详情页壳(AlbumSummaryHeader 落地 detail/index.vue、页签重组)**——页签宿主先改好,B3b/c 的各页签内容才有稳定挂载点。
- **B3b 管理批**:04 成员 + 05 分类 + 06 水印 + 07 分享。
- **B3c 表单批**:08 新建 + 09 编辑(整页表单路由,含 15 资产页签收纳)。
- **B3d 治理批**:10 账号 + 11 同步(含 20 批次明细)+ 12 用量。
- **B3e 收尾批**:13/14 展示位(对齐 UI-04 组件)+ 17/18/19 异常页。
- 每批出口:该批页面 1440×1000 截图与示例并排对照入 `docs/designs/ui06-implementation/comparisons/`;窄屏 390 抽查一页;`pnpm --filter @photo/admin lint typecheck test` 绿;admin 相关 e2e 保持绿(UI-04 已于 8c4906c 全绿收尾,无"既有失败"豁免——本方案改动若致 e2e 失败须当批修复或登记偏差,不得沿用旧豁免说法)。
### B4 回归与验收(半天)
- [ ] 全 20 页截图对照齐;`pnpm --filter @photo/admin lint typecheck test` 全绿。
- [ ] 部署测试服务器全量复核(§4.3);真实账号登录、相册卡片、照片大图、账号治理冒烟。
- [ ] UI06 验收记录(后台部分)完成;偏差逐条列出(已修/接受+原因)。
## 3. App 线(管理后台验收后启动)
工程框架沿用 UI-03R(素材清单、固定 390×844 fixture、双轨证据、可量化阈值 ≤4dp、逐页截图流程);**视觉参考从五张设计板切换为 app-preview HTML(20 页实物)**,两者同源,冲突以 HTML 为准。五张设计板降级为辅助参考。
页面映射沿用 UI-03 验收记录 §2(20 页 → `lib/pages/` 各入口),不重复列出。
### A0 复核基线
- [ ] 固定 390×844 画布补拍当前 App 全部 20 页 before 截图(UI-03 截图仅 10/20 页且分辨率不符,只作历史参考)。
- [ ] 逐页对照 app-preview `screenshots/` 与补拍 before,重建「参考/当前/偏差/目标」表(UI-03R §1.2 表更新为 HTML 基准)。
- [ ] 确认 Flutter(仓库固定版本)、字体、素材、截图工具可用。
- 出口:不沿用 UI-03 全通过结论;新偏差表入库。
### A1 素材与 fixture
- [ ] 按 UI-03R §4/§7 建 `docs/designs/ui03r-assets-manifest.md`。**素材分两轨**:测试/fixture 素材可复用 app-preview `assets/`(低分辨率局部裁片,仅限固定画布验收);**生产素材(登录拼贴、相册封面 ≥720px)必须另行准备合规资源**——app-preview README 明确其素材"不作为生产图片素材",UI-03R §4.1 要求生产拼贴随包合规资源。生产素材未落实时在清单中列为阻塞项,不得以低清裁片顶替上线。
- [ ] 固定英文数据、12 张照片、正常/异常状态 fixture 注入真实组件;生产隔离。
### A2 公共组件
- [ ] theme/tokens 显式化。**注意 UI-03R §5.1 色板与 app-preview CSS 存在五处色值差异**(softBlue #EEF5FF↔#EDF4FF、textSecondary #647084↔#69778E、successText #16794B↔#279966、errorText #B42318↔#E74E58、waitingText #996300↔#D89020)——按 §0 优先级**以 HTML 示例实测值为准**,不得以"两处一致"为由跳过核对;无胶囊根导航、两种分段、输入、按钮、Sheet、封面卡按示例重核。
### A3 三个样板页
- [ ] 01 登录、05 相册列表、14 上传中心对照 app-preview 达 UI-03R §8.1 阈值后推广。
### A4–A6 其余 17 页
- 按 UI-03R R04(02/03/04/06/07/08)、R05(09–12)、R06(13/15/16)、R07(17–20)分组推进,每页固定画布截图 + 对照 + 测量。
### A7 全量验收
- 视口矩阵(390 全量;320/430/横屏/平板/大字体/zh-CN/dark 抽查按 UI-03R §10.1)。
- `flutter analyze` / `flutter test` 全绿(执行前 `source scripts/flutter-env.sh`)。
- UI06 验收记录(App 部分)+ UI-03R 记录衔接说明。
## 4. 验收方法
### 4.1 后台逐页对照
1. 打开示例对应页(HTML 实物)与本页实现,1440×1000 截图并排存 `docs/designs/ui06-implementation/comparisons/`。
2. 核对项:布局结构、色彩、字号层级、卡片/控件形态、状态语义、操作入口位置。
3. 允许偏差:真实数据与示例样例数据的差异(如实际封面图不同);须记录,不得借「数据不同」掩饰布局偏差。
4. 窄屏:每批抽 1 页 390 宽检查(侧栏抽屉、表格横滚,不整页缩略)。
### 4.2 App 逐页对照
UI-03R §8 流程不变(参考裁片等比 390、固定画布截图、左右对照、几何测量表),参考来源换为 app-preview 截图;§8.1 阈值(≤4dp、字号 ≤1sp 等)继续有效。
### 4.3 测试服务器部署(每批后台改动后)
**后台前端产物在 web 镜像(`docker/web.Dockerfile`,nginx 承载 viewer + /admin/),不在 server 镜像**(server.Dockerfile 不构建 apps/admin)。后台视觉批次的部署流程:
1. `git archive HEAD | docker build -f docker/web.Dockerfile -t photo-live-web:ui06-<sha>-<日期> -`(`git archive HEAD` 只含已提交内容,**部署前必须已提交该批改动**;VITE_* 构建参数集见 `docs/plans/2026-09-21-测试服务器部署方案.md`)。
2. save/gzip → scp → docker load(与既有流程相同)。
3. `SERVER_IMAGE=<保持当前 server tag> WEB_IMAGE=photo-live-web:ui06-<sha>-<日期> docker compose -f docker-compose.cn.yml up -d`(WEB_IMAGE 须带完整 `photo-live-web:` 仓库名——compose 默认值 `photo-live-web:latest` 同格式)→ **restart web 容器**(否则 nginx 缓存旧上游 IP 502)。
4. 验证入口 `http://219.157.255.213:32005/admin/`;仅当本批同时改了 API/服务端才重建 server 镜像。
5. 回滚 = 用上一批 WEB_IMAGE tag 重复步骤 3(镜像均在服务器本地保留)。
App 线无服务器部署,走本地固定画布 + 模拟器双轨证据。
## 5. 红线与保护项(两端通用,违反即回退)
- 游标分页 `useCursorList` 语义保留;后台用户页 page/pageSize 新分页不回退。
- 批量操作逐项成功/失败结果保留,不得全成功动画掩盖。
- 账号自保护(自身角色/锁定不可改)、来源状态只读。
- 水印/同步等异步任务「受理≠完成」语义保留。
- 展示位 settings 整体替换时保留全部字段;v1/v2 区分,v2 固定 195×280。
- `translations` 全量替换时合并保留其他语言。
- App 上传引擎、权限契约、凭证续签零语义改动(`lib/features` 不动,样式工程如触及控制逻辑须补测试并登记)。
- 同步账本(outbox/sync_inbox/sync_state)任何情况不清理不重置。
- viewer/embed(UI-02/UI-04 线)不在本方案范围。
## 6. 交付物
```text
docs/plans/2026-09-22-UI06-全端视觉统一实施方案.md (本文)
docs/plans/progress/UI06-验收记录.md
docs/designs/ui06-implementation/
before/ 后台改动前截图
comparisons/ 每页示例 vs 实现并排对照
README.md 索引与复现命令
docs/designs/ui03r-assets-manifest.md (App 线,沿用 UI-03R 要求)
```
完成门槛:
- 后台 20 页对照证据齐 + 测试服务器部署复核通过;App 20 页固定画布截图与结论齐(双轨)。
- 无本方案列明的默认样式残留(紫胶囊、黑框分段、灰占位主视觉、ID 裸露等)。
- 全部测试与类型检查绿;保护项回归通过。
- 素材/真机等外部关口未完成时如实标注「部分完成」,不得宣称 100%。
## 7. 给 Claude 的执行指令
> 请执行 docs/plans/2026-09-22-UI06-全端视觉统一实施方案.md。视觉唯一基准是 docs/designs/admin-preview-2026-09-22 与 app-preview-2026-09-22 两个 HTML 示例,冲突一律以示例为准。先做 §1.1 基线提交与 B0(截图脚本 + before 截图 + 契约核对),再按 B1→B2→B3a–B3e 顺序推进管理后台(B1/B2 本地验收;B3 每批:lint/test 绿 → 本地提交不推送 → 按 §4.3 构建部署 web 镜像(restart web 容器)→ 用户在 32005 验收 → 通过后进下一批)。后台 B4 验收后启动 App 线 A0–A7(继承 UI-03R 工程标准,参考换 HTML)。每页必须截图对照,不得以测试通过代替视觉验收;保护项(§5)零损失;不覆盖用户未提交改动,不自动部署生产。
@@ -0,0 +1,123 @@
# 用户列表分页与同步信息展示 实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 管理后台用户列表换成标准页码分页(页码/每页条数/总数),并把同步源带来的工号/邮箱/公司/头像等档案信息落库展示。
**Architecture:** 同步快照存 `external_user_mappings.profile`(新增 nullable JSON 列——守「users 表不加源身份列」红线,源数据隔离在映射表);端口 `ExternalUserRecord` 增可选 `sourceProfile`,引擎建/更映射时随版本写入。`listAdminUsers` 管理列表模式新增 `page/pageSize` 偏移分页并返回 `total`(游标模式原样保留,契约只增不改);`AdminUser` 增可选 `synced` 对象。前端用户列表换 `ElPagination` 页码分页 + 新列(工号/邮箱/公司,头像并入姓名列)。
**Tech Stack:** Prisma 手写迁移(utf8mb4_090_bin 约定)、openapi.yaml + `pnpm gen`、NestJS、Vue3 + Element Plus、i18n(apps/admin/src/locales/langs/{zh,en}.json)。
**红线遵循:** users 表零改动;同步决策绝不依赖 profile(纯展示快照,坏 profile 不影响行应用);契约字段全部 optional/nullable(旧客户端零 break);ov 栈不涉及(用户域 cn 单域)。
## 文件结构
| 文件 | 动作 |
|---|---|
| `packages/db-cn/prisma/schema.prisma` + 新迁移目录 | external_user_mappings += `profile Json?` |
| `services/api/src/user-sync/user-source.port.ts` | `ExternalUserProfile` + `sourceProfile?` |
| `services/api/src/user-sync/http-user-source.ts` | 填充 sourceProfile |
| `services/api/src/user-sync/mock-user-source.ts` | MockExternalRecord += sourceProfile?(透传) |
| `services/api/src/user-sync/user-sync.service.ts` | 映射 create/update 写 profile |
| `packages/contracts/openapi.yaml` + gen | AdminUser.synced、AdminUserList.total、page/pageSize |
| `services/api/src/admin/admin-users.service.ts` | page 模式(COUNT+OFFSET)+ synced 合并 helper;治理回显带 synced |
| `services/api/src/admin/users-lookup.controller.ts` | page/pageSize 解析透传 |
| `services/api/test/user-sync.e2e-spec.ts` | profile 落库/随版本刷新断言 |
| `services/api/test/admin-users.e2e-spec.ts`(或 users-lookup) | page 分页 + synced 展示断言 |
| `apps/admin/src/types/api/adminUser.d.ts`、`api/adminUsers.ts` | 类型与参数 |
| `apps/admin/src/views/system/users/index.vue` | ElPagination + 新列 |
| `apps/admin/src/locales/langs/{zh,en}.json` | 新文案 |
---
### Task 1: DB 迁移 + 端口/引擎/适配器写入 profile
- [ ] schema.prisma `external_user_mappings` 加 `profile Json?`(注释:同步源档案快照,纯展示,不参与同步决策)
- [ ] 新迁移 `packages/db-cn/prisma/migrations/20260922<HHMMSS>_mapping_profile/migration.sql`(手写,格式对齐 20260920114149 先例):
```sql
-- G4 后续:映射表增 profile JSON 列——同步源档案快照(工号/邮箱/公司/头像),
-- 纯展示维度:不参与同步决策(引擎幂等仍只看 sourceVersion),users 表零改动
--(源身份隔离红线)。nullable:mock 源/历史行无快照。
ALTER TABLE `external_user_mappings` ADD COLUMN `profile` JSON NULL;
```
- [ ] user-source.port.ts:
```ts
/** 同步源档案快照(纯展示,不参与同步决策;http 源填充,mock 可不填)。 */
export interface ExternalUserProfile {
/** 工号。 */
sn?: string;
/** 企业邮箱。 */
email?: string;
/** 公司全称。 */
companyName?: string;
/** 公司简称。 */
companyAbbr?: string;
/** 头像 URL。 */
avatar?: string;
}
```
`ExternalUserRecord` 加 `sourceProfile?: ExternalUserProfile;`(注释:引擎原样落 mapping.profile,绝不据此做同步决策)。
- [ ] http-user-source.ts records 映射加:
```ts
sourceProfile: {
sn: raw.sn || undefined,
email: raw.email || undefined,
companyName: raw.company_name || undefined,
companyAbbr: raw.company_abbr || undefined,
avatar: raw.avatar || undefined,
},
```
- [ ] mock-user-source.ts:`MockExternalRecord` 加 `sourceProfile?: ExternalUserProfile`;items 映射透传 `sourceProfile: user.sourceProfile`。
- [ ] user-sync.service.ts **四处**映射写入(新建关联 / 既有更新 / 重建改指 / 全新用户建映射)统一加 `profile: item.sourceProfile ?? undefined`(undefined=Prisma 不改列)。
- [ ] **幂等跳过路径的 profile 回填**(存量 230 用户已处于当前版本,版本门直接 return——不补写则 profile 永远 NULL):`validated.version <= mapping.sourceVersion` 分支内,若 `item.sourceProfile` 非空且与 `mapping.profile` 深比较不同 → 仅 `external_user_mappings.update({ profile })`(**不动 sourceVersion/users 行/会话/counts,bucket 仍 null**——展示维度补偿写,不违反「同步决策不依赖 profile」红线);文件头登记。
- [ ] 验证:`pnpm --filter @photo/api-cn typecheck`;本地库应用迁移(prisma migrate dev 或 db push 对齐项目流程后跑 user-sync e2e)。
- [ ] Commit: `feat(api,db-cn): persist synced source profile snapshot on mappings`
### Task 2: 契约扩展 + 服务端 page 分页与 synced 合并
- [ ] openapi.yaml:
- `AdminUser.properties` 加 `synced`(`type: [object, 'null']`,readOnly,字段 sn/email/companyName/companyAbbr/avatar 均 string;描述:同步源档案快照,非同步用户为 null)。
- `AdminUserList` allOf 内对象加 `total`(`type: [integer, 'null']`,描述:仅 page 模式返回的总数;游标模式为 null)。
- `listAdminUsers` parameters 加 `page`(integer ≥1)与 `pageSize`(integer 1..100)均 optional;描述注明:提供 cursor 走游标模式(忽略 page);提供 page(无 cursor)走偏移分页并返回 total(此时 nextCursor=null 且 hasMore=false——守 CursorPageMeta「hasMore⟺nextCursor≠null」不变式,翻页由 total 驱动)。
- `pnpm --filter @photo/contracts gen` 重新生成类型(根上无 gen 脚本)。
- [ ] users-lookup.controller.ts:`parsePage`(**未提供 → undefined**,提供则 ≥1)/`parsePageSize`(未提供 → undefined,提供则 1..100);page 模式内的缺省值(1/20)在服务层 page 模式选定后才应用——保证「未带 page 的旧客户端仍走游标模式」。
- [ ] admin-users.service.ts:
- `list(user, query, status, cursor, limit, page?: number, pageSize?: number)`:`page !== undefined && cursor === undefined` → page 模式:同一 WHERE(去游标条件)`COUNT(*)` + `LIMIT pageSize OFFSET (page-1)*pageSize`,`nextCursor=null`、`hasMore=false`、`total`;否则原游标逻辑(total=null)。
- 导出 `attachSyncedProfiles(items: AdminUserItem[]): Promise<AdminUserItem[]>`:按 id 批查 `external_user_mappings`(`userId IN`),每用户取 `sourceVersion` 最大行,`profile` → `synced`(无映射/profile → `synced: null`);list 两种模式与治理回显(update/activate/deactivate)统一套用;前缀 lookup 模式不套(邀请候选无需档案,文件头注明)。
- [ ] e2e:admin-users/users-lookup spec 增用例——直种 2 用户 + 1 映射带 profile → `?page=1&pageSize=1` 返回 total=2、单条且 `synced` 字段正确;`?page=2` 返回第二条、`hasMore=false`;游标模式(不带 page)`total` 为 null;无映射用户 `synced` null;前缀模式(accountPrefix)响应干净不含 synced/total。user-sync e2e:U2 场景行加 `sourceProfile` → ① 断言 mapping.profile 落库;② 改名场景换 companyAbbr → 断言 profile 随版本刷新;③ **同版本重跑(版本门跳过)+ profile 此前为空 → profile 被回填且 counts 全 0**(存量用户回填路径)。
- [ ] 验证:typecheck + `test -- http-user-source` + `test:e2e -- user-sync admin-users users-lookup`。
- [ ] Commit: `feat(contracts,api): page-mode pagination + synced profile on admin users list`
### Task 3: 管理后台前端(分页器 + 新列)
- [ ] types/api/adminUser.d.ts:`Item` 加 `synced?: {...} | null`;`ListResponse` 加 `total?: number | null`;`ListParams` 加 `page?/pageSize?`。
- [ ] api/adminUsers.ts:params 直传(类型已覆盖)。
- [ ] views/system/users/index.vue:
- 去 `useCursorList`/loadMore,改 `items/total/page/pageSize/loading/error` + `loadPage()`;`search()/resetFilters()` 重置 page=1;分页器 `ElPagination`(layout `total, sizes, prev, pager, next`,sizes [20,50,100])。
- 列调整:姓名列并入头像(ElAvatar src=synced?.avatar,无则首字符);新增 工号(synced.sn)、邮箱(synced.email)、公司(companyAbbr || companyName)三列;「—」占位非同步用户。
- 行内治理操作(编辑/重置/激活/停用/解锁)原样保留,回显 replaceItem 兼容。
- [ ] locales zh/en:`usersManage.list.colSn/colEmail/colCompany`、分页相关沿用 Element 内建文案。
- [ ] 验证:admin 前端 typecheck/build(`pnpm --filter admin build` 或项目等价脚本)。
- [ ] Commit: `feat(admin): standard pagination + synced profile columns on users list`
### Task 4: 部署测试服务器(server + cn web 双镜像 + 迁移)
- [ ] server 镜像 `git archive HEAD | docker build -f docker/server.Dockerfile`,tag `<sha>-20260922b`;web 镜像同法 `docker/web.Dockerfile`,tag `admin-<sha>-20260922b`(ov 栈 web 保持 viewer-fix 不动——本任务无 viewer 改动,避免吞掉未提交 viewer 工作)。
- [ ] save/gzip → scp → load。
- [ ] 服务器**先迁移**:`docker run --rm --network 1panel-network --env-file .env.production-cn photo-live-server:<tag> migrate-deploy all`。
- [ ] cn 栈 `SERVER_IMAGE=<新> WEB_IMAGE=photo-live-web:admin-<sha>-20260922b up -d`;ov 栈仅换 SERVER_IMAGE(WEB_IMAGE=viewer-fix)。
- [ ] 验证:`http://219.157.255.213:32005/admin/` 用户列表分页可翻页、工号/邮箱/公司/头像显示(230 真实用户);`/v1/admin/users?page=1&pageSize=2` 带 total;user-sync status 正常(下一 cron 批不因 profile 列失败)。
- [ ] 补验收记录(G4 记录追加或新条目),Commit:`docs: ...`
## 明确不做(YAGNI)
- 相册等其他列表的分页交互改造(同为游标"加载更多"模式)——用户列表先行,需要再说。
- synced 信息进用户编辑弹窗(列表展示已覆盖诉求)。
- ov 栈/观众页任何改动。
+69
View File
@@ -0,0 +1,69 @@
// services/api/src/scripts/g4-sync-once.ts —— G4 一次性真实用户同步(临时验证
// 脚本,跑完即删,不入库)。走真实 AppCnModule 装配(USER_SYNC_SOURCE=http
// 时 HttpUserSource 生效),engine.tick() 同步执行完整批次,结束打印批次账本/
// 坏行/单行状态/用户统计供人工核验。
// 用法(仓库根):
// USER_SYNC_SOURCE=http OPEN_API_USERS_URL=<url> OPEN_API_USERS_KEY=<key> \
// DOTENV_CONFIG_PATH=.env node -r dotenv/config services/api/dist/scripts/g4-sync-once.js
import 'reflect-metadata';
import { NestFactory } from '@nestjs/core';
import { PrismaClient } from '@photo/db-cn';
import { AppCnModule } from '../app-cn.module';
import { UserSyncEngine } from '../user-sync/user-sync.service';
async function main(): Promise<void> {
const app = await NestFactory.createApplicationContext(AppCnModule, {
bufferLogs: true,
});
const engine = app.get(UserSyncEngine);
const startedAt = new Date();
await engine.tick(); // 同步执行一批(撞锁则跳过——正常情况无并发)
const prisma = new PrismaClient();
try {
const run = await prisma.user_sync_runs.findFirst({
where: { startedAt: { gte: startedAt } },
orderBy: [{ startedAt: 'desc' }, { id: 'desc' }],
});
const errors = run
? await prisma.user_sync_errors.findMany({ where: { runId: run.id } })
: [];
const state = await prisma.user_sync_state.findFirst();
const uniserviceMappings = await prisma.external_user_mappings.count({
where: { sourceSystem: 'uniservice' },
});
const totalUsers = await prisma.users.count();
const sample = await prisma.users.findMany({
where: { account: { startsWith: '1' } },
orderBy: { createdAt: 'desc' },
take: 5,
select: {
account: true,
displayName: true,
role: true,
sourceStatus: true,
credentialStatus: true,
},
});
console.log(
JSON.stringify(
{
run,
errorCount: errors.length,
errors: errors.slice(0, 10),
state,
uniserviceMappings,
totalUsers,
sample,
},
null,
2,
),
);
} finally {
await prisma.$disconnect();
await app.close();
}
}
void main();