chore: 全量推送 · 累积页面改动 + Next.js 工程骨架 + v1/ 归档 + 文档

页面 (电商AI平台/)
- account / team / settings / index / products / projects: 累积迭代
- restraint.css: 设计 token 补充
- login.html / register.html: 新增登录注册页
- _ARCHIVE.md: 归档说明

Next.js 工程骨架
- app/ + components/: 新一代 SPA 雏形 (page / layout / sidebar / topbar / GridBg / Icon)
- package.json / package-lock.json / next.config.mjs / tsconfig.json / postcss.config.mjs / next-env.d.ts

历史归档 / 文档
- v1/: 原 V1 静态稿镜像 (含 mockup-A/B/C)
- PRD.md / deployment-guide.md / _check.html
- ui参考/ / screenshots/

杂项
- .gitignore 调整
- 删除根 README.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
iye
2026-05-21 21:16:46 +08:00
co-authored by Claude Opus 4.7
parent 8a783ca36f
commit f420af2069
82 changed files with 15367 additions and 418 deletions
+345 -74
View File
@@ -11,10 +11,16 @@
.settings-nav { position: sticky; top: 16px; }
.settings-nav .nav-h { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .06em; text-transform: uppercase; padding: 0 12px 8px; }
.settings-nav a { display: flex; align-items: center; gap: 10px; padding: 10px 12px; font-size: 13px; color: var(--accent-black); border-radius: var(--r-md); border: 1px solid transparent; cursor: pointer; text-decoration: none; transition: background var(--t-base), border-color var(--t-base); }
.settings-nav a { display: flex; align-items: center; gap: 10px; padding: 10px 12px; font-size: 13px; color: var(--accent-black); border-radius: var(--r-md); border: 1px solid transparent; cursor: pointer; text-decoration: none; transition: background var(--t-base), border-color var(--t-base); position: relative; }
.settings-nav a:hover { background: var(--background-lighter); }
.settings-nav a:focus-visible { outline: 2px solid var(--heat); outline-offset: 2px; }
.settings-nav a.active { background: var(--heat-12); color: var(--heat); border-color: var(--heat-20); font-weight: 600; }
.settings-nav a svg { width: 16px; height: 16px; stroke-width: 1.5; }
.settings-nav a .nav-badge { margin-left: auto; font-family: var(--font-mono); font-size: 10px; color: var(--black-alpha-48); padding: 1px 6px; background: var(--background-lighter); border: 1px solid var(--border-faint); border-radius: var(--r-pill); letter-spacing: .02em; line-height: 14px; }
.settings-nav a.active .nav-badge { color: var(--heat); background: var(--accent-white); border-color: var(--heat-20); }
.settings-nav a .nav-dot { position: absolute; right: 10px; top: 50%; transform: translateY(-50%); width: 6px; height: 6px; border-radius: 50%; background: var(--heat); display: none; }
.settings-nav a.has-changes .nav-dot { display: block; }
.settings-nav a.active .nav-dot { right: -4px; }
/* ─── pane ─── */
.pane { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 24px; margin-bottom: 16px; }
@@ -73,6 +79,23 @@
.device-row .meta { font-size: 11.5px; color: var(--black-alpha-48); font-family: var(--font-mono); margin-top: 2px; letter-spacing: .02em; }
.device-row .tag-cur { font-family: var(--font-mono); font-size: 10.5px; padding: 1px 6px; background: var(--accent-forest); color: var(--accent-white); border-radius: var(--r-sm); margin-left: 8px; letter-spacing: .04em; font-weight: 600; }
.device-row .spacer { margin-left: auto; }
/* ─── 头像上传 modal · V2.1 Restraint ─── */
.av-up-modal { width: min(440px, 92vw); max-width: min(440px, 92vw); }
/* 预览区:左圆头像 + 右 mono 元数据 · 装订线点划线分隔 */
.av-up-preview-row { display: flex; align-items: center; gap: 14px; padding-bottom: 14px; margin-bottom: 14px; position: relative; }
.av-up-preview-row::after { content: ''; position: absolute; left: 0; right: 0; bottom: 0; height: 1px; background: repeating-linear-gradient(to right, var(--border-faint) 0, var(--border-faint) 4px, transparent 4px, transparent 8px); }
.av-up-preview { width: 64px; height: 64px; border-radius: 50%; background: var(--background-lighter); border: 1px solid var(--border-faint); display: grid; place-items: center; font-size: 22px; font-weight: 600; color: var(--accent-black); overflow: hidden; flex: 0 0 64px; }
.av-up-preview img { width: 100%; height: 100%; object-fit: cover; display: block; }
.av-up-preview-meta { min-width: 0; }
.av-up-preview-meta .t { font-size: 12.5px; font-weight: 600; color: var(--accent-black); margin-bottom: 3px; letter-spacing: .01em; }
.av-up-preview-meta .d { font-size: 11px; color: var(--black-alpha-48); font-family: var(--font-mono); letter-spacing: .02em; line-height: 1.55; }
/* 规则文本 · 纯 mono, 装订线分隔 */
.av-up-rules { margin-top: 12px; padding-top: 10px; border-top: 1px dashed var(--border-faint); font-size: 11px; color: var(--black-alpha-56); font-family: var(--font-mono); letter-spacing: .02em; line-height: 1.7; }
.av-up-rules .li { display: flex; gap: 8px; }
.av-up-rules .li::before { content: '//'; color: var(--black-alpha-32); flex: 0 0 auto; }
</style>
</head>
<body>
@@ -94,33 +117,40 @@
<div class="settings-grid">
<!-- 左侧 nav -->
<aside class="settings-nav">
<aside class="settings-nav" role="tablist" aria-label="设置分区">
<div class="nav-h">个人</div>
<a href="#sec-profile" class="active" data-jump="sec-profile">
<a href="#sec-profile" class="active" data-jump="sec-profile" role="tab" aria-controls="sec-profile">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 21c0-4.4 3.6-8 8-8s8 3.6 8 8"/></svg>
个人信息
<span>个人信息</span>
<span class="nav-dot" aria-hidden="true"></span>
</a>
<a href="#sec-security" data-jump="sec-security">
<a href="#sec-security" data-jump="sec-security" role="tab" aria-controls="sec-security">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
安全
<span>安全</span>
<span class="nav-badge" data-count-source="sec-security">3</span>
<span class="nav-dot" aria-hidden="true"></span>
</a>
<a href="#sec-notify" data-jump="sec-notify">
<a href="#sec-notify" data-jump="sec-notify" role="tab" aria-controls="sec-notify">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9M10 21a2 2 0 0 0 4 0"/></svg>
通知
<span>通知</span>
<span class="nav-badge" data-count-source="sec-notify">5</span>
<span class="nav-dot" aria-hidden="true"></span>
</a>
<div class="nav-h" style="margin-top: 16px;">偏好</div>
<a href="#sec-pref" data-jump="sec-pref">
<a href="#sec-pref" data-jump="sec-pref" role="tab" aria-controls="sec-pref">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="m3 7 9-4 9 4-9 4-9-4z"/><path d="m3 12 9 4 9-4"/></svg>
创作默认
<span>创作默认</span>
<span class="nav-dot" aria-hidden="true"></span>
</a>
<a href="#sec-display" data-jump="sec-display">
<a href="#sec-display" data-jump="sec-display" role="tab" aria-controls="sec-display">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="M2 10h20"/></svg>
显示
<span>显示</span>
<span class="nav-dot" aria-hidden="true"></span>
</a>
<div class="nav-h" style="margin-top: 16px;">账号</div>
<a href="#sec-danger" data-jump="sec-danger" style="color: var(--accent-crimson);">
<a href="#sec-danger" data-jump="sec-danger" role="tab" aria-controls="sec-danger" style="color: var(--accent-crimson);">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><path d="M12 9v4M12 17h.01"/></svg>
危险操作
<span>危险操作</span>
</a>
</aside>
@@ -135,10 +165,10 @@
<div class="lbl">头像</div>
<div class="val">
<div class="avatar-edit">
<div class="av-big"></div>
<div class="av-big" id="prof-avatar-preview"></div>
<div class="av-actions">
<button class="btn btn-sm" onclick="Shell.toast('上传头像', '占位 · 选择本地图片')">上传新头像</button>
<button class="btn btn-ghost btn-sm" onclick="Shell.toast('恢复默认头像', '已重置')">恢复默认</button>
<button class="btn btn-sm" type="button" onclick="Shell.openModal('avatar-up-bg')">上传新头像</button>
<button class="btn btn-ghost btn-sm" type="button" id="prof-avatar-reset">恢复默认</button>
</div>
</div>
</div>
@@ -416,6 +446,51 @@
</main>
</div>
<!-- ─── 上传头像 modal ─── -->
<div class="modal-bg" id="avatar-up-bg" onclick="if(event.target===this)Shell.closeModal('avatar-up-bg')">
<div class="modal av-up-modal">
<span class="corner-tr" aria-hidden></span><span class="corner-bl" aria-hidden></span>
<div class="modal-h">
<div class="ic-m">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 21c0-4.4 3.6-8 8-8s8 3.6 8 8"/></svg>
</div>
<div class="ti">上传头像<span>// 用于个人主页、评论与团队展示</span></div>
</div>
<div class="modal-b np-body">
<div class="av-up-preview-row">
<div class="av-up-preview" id="av-up-preview"></div>
<div class="av-up-preview-meta">
<div class="t" id="av-up-preview-name">当前头像 · 默认</div>
<div class="d" id="av-up-preview-info">// 系统生成 · 取姓氏首字</div>
</div>
</div>
<div class="upload-zone" id="av-up-zone" tabindex="0" role="button" aria-label="点击或拖入图片上传">
<span class="uz-ic">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
</span>
<div><strong>点击选择</strong> · 或拖入图片</div>
<span class="uz-hint">JPG / PNG / WebP · ≤ 2 MB · 推荐 256 × 256</span>
<input type="file" id="av-up-file" accept="image/jpeg,image/png,image/webp" hidden>
</div>
<div class="av-up-rules">
<div class="li">最大 2 MB · 长宽比建议 1:1 · 系统会自动裁切为圆形</div>
<div class="li">不要上传含他人肖像的图片,违规可能导致账号封停</div>
</div>
</div>
<div class="modal-f">
<button class="btn" type="button" onclick="Shell.closeModal('avatar-up-bg')">取消</button>
<button class="btn btn-primary" type="button" id="av-up-confirm">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12l5 5L20 6"/></svg>
确认使用
</button>
</div>
</div>
</div>
</div>
<script src="assets/shell.js?v=202605211643"></script>
<script>
@@ -424,50 +499,60 @@ Shell.render({
crumbs: [{ label: '工作台', href: 'index.html' }, { label: '设置' }]
});
/* ─── 侧边 nav 高亮 + 滚动联动 ─── */
const sections = ['sec-profile', 'sec-security', 'sec-notify', 'sec-pref', 'sec-display', 'sec-danger'];
/* ─── 配置 ─── */
const SECTIONS = ['sec-profile', 'sec-security', 'sec-notify', 'sec-pref', 'sec-display', 'sec-danger'];
const navLinks = document.querySelectorAll('.settings-nav a[data-jump]');
/* ─── 1. 点击 nav → 只显示对应 section,其余隐藏 + 同步 URL hash ─── */
function showSection(id) {
if (!SECTIONS.includes(id)) id = SECTIONS[0];
SECTIONS.forEach(sid => {
const el = document.getElementById(sid);
if (el) el.style.display = (sid === id) ? '' : 'none';
});
navLinks.forEach(a => a.classList.toggle('active', a.dataset.jump === id));
if (location.hash.slice(1) !== id) {
history.replaceState(null, '', '#' + id);
}
// 切换面板后回到顶端,避免长面板留下的滚动位置错乱
window.scrollTo({ top: 0, behavior: 'instant' });
}
navLinks.forEach(a => {
a.addEventListener('click', e => {
e.preventDefault();
const id = a.dataset.jump;
const el = document.getElementById(id);
if (el) {
const contentEl = document.getElementById('page-content') || document.querySelector('.content');
const offset = 16;
if (contentEl) {
contentEl.scrollTo({ top: el.offsetTop - contentEl.offsetTop - offset, behavior: 'smooth' });
} else {
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}
showSection(a.dataset.jump);
a.focus();
});
a.addEventListener('keydown', e => {
const idx = [...navLinks].indexOf(a);
if (e.key === 'ArrowDown') { e.preventDefault(); navLinks[(idx + 1) % navLinks.length].focus(); }
else if (e.key === 'ArrowUp') { e.preventDefault(); navLinks[(idx - 1 + navLinks.length) % navLinks.length].focus(); }
else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); a.click(); }
});
});
const contentEl = document.getElementById('page-content') || document.querySelector('.content');
const observer = new IntersectionObserver(entries => {
entries.forEach(en => {
if (en.isIntersecting) {
const id = en.target.id;
navLinks.forEach(a => a.classList.toggle('active', a.dataset.jump === id));
}
});
}, { root: contentEl || null, rootMargin: '-20% 0px -60% 0px', threshold: 0 });
sections.forEach(id => {
const el = document.getElementById(id);
if (el) observer.observe(el);
/* ─── 2. 初始加载:读 URL hash 显示对应 section(无 hash 时默认 sec-profile) ─── */
(function initSection() {
const hash = location.hash.slice(1);
showSection(SECTIONS.includes(hash) ? hash : 'sec-profile');
})();
/* hash 外部变化(如浏览器前进后退)也跟着切 */
window.addEventListener('hashchange', () => {
const hash = location.hash.slice(1);
if (SECTIONS.includes(hash)) showSection(hash);
});
/* ─── 偏好 chip 选择 ─── */
function bindChoice(containerId, label) {
/* ─── 4. 偏好 chip 选择 ─── */
function bindChoice(containerId) {
const ct = document.getElementById(containerId);
if (!ct) return;
ct.querySelectorAll('.pref-choice').forEach(c => {
c.addEventListener('click', () => {
ct.querySelectorAll('.pref-choice').forEach(x => x.classList.remove('selected'));
c.classList.add('selected');
markDirty();
markDirty(c);
});
});
}
@@ -478,46 +563,232 @@ document.querySelectorAll('#pref-duration .dur-chip').forEach(c => {
c.addEventListener('click', () => {
document.querySelectorAll('#pref-duration .dur-chip').forEach(x => x.classList.remove('selected'));
c.classList.add('selected');
markDirty();
markDirty(c);
});
});
/* ─── dirty state ─── */
let dirty = false;
function markDirty() {
if (dirty) return;
dirty = true;
const saveBtn = document.getElementById('save-btn');
const cancelBtn = document.getElementById('save-cancel');
[saveBtn, cancelBtn].forEach(b => {
b.disabled = false;
b.style.opacity = '';
b.style.cursor = '';
});
/* ─── 5. dirty state · 追踪改了哪几节 + save btn 显示变更条数 ─── */
const dirtyFields = new Set(); // 改过的字段 id 集合
const dirtySections = new Set(); // 涉及的 section id 集合
const saveBtn = document.getElementById('save-btn');
const cancelBtn = document.getElementById('save-cancel');
const saveBtnDefaultLabel = saveBtn.innerHTML;
function sectionOf(el) {
const sec = el.closest('section[id]');
return sec ? sec.id : null;
}
function syncSaveBtn() {
const n = dirtyFields.size;
if (n === 0) {
saveBtn.disabled = true;
cancelBtn.disabled = true;
saveBtn.style.opacity = '.5';
saveBtn.style.cursor = 'not-allowed';
cancelBtn.style.opacity = '.5';
cancelBtn.style.cursor = 'not-allowed';
saveBtn.innerHTML = saveBtnDefaultLabel;
} else {
saveBtn.disabled = false;
cancelBtn.disabled = false;
saveBtn.style.opacity = '';
saveBtn.style.cursor = '';
cancelBtn.style.opacity = '';
cancelBtn.style.cursor = '';
saveBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12l5 5L20 6"/></svg> 保存所有变更 <span style="opacity:.75;font-family:var(--font-mono);font-size:11px;margin-left:4px;">· ${n} 项</span>`;
}
// nav 上加 dirty dot
navLinks.forEach(a => a.classList.toggle('has-changes', dirtySections.has(a.dataset.jump)));
}
function markDirty(el) {
const id = el.id || (el.dataset && el.dataset.v) || ('anon-' + Math.random().toString(36).slice(2, 8));
if (dirtyFields.has(id)) return;
dirtyFields.add(id);
const sec = sectionOf(el);
if (sec) dirtySections.add(sec);
syncSaveBtn();
}
function clearDirty() {
dirty = false;
const saveBtn = document.getElementById('save-btn');
const cancelBtn = document.getElementById('save-cancel');
[saveBtn, cancelBtn].forEach(b => {
b.disabled = true;
b.style.opacity = '.5';
b.style.cursor = 'not-allowed';
});
dirtyFields.clear();
dirtySections.clear();
syncSaveBtn();
}
document.querySelectorAll('[data-track], input[type="checkbox"], select').forEach(el => {
el.addEventListener('change', markDirty);
if (el.tagName === 'INPUT' && el.type !== 'checkbox') el.addEventListener('input', markDirty);
el.addEventListener('change', () => markDirty(el));
if (el.tagName === 'INPUT' && el.type !== 'checkbox') el.addEventListener('input', () => markDirty(el));
});
document.getElementById('save-btn').addEventListener('click', () => {
if (!dirty) return;
Shell.toast('设置已保存', '所有变更已生效');
/* ─── 6. 表单校验:必填 + 邮箱格式 ─── */
const VALIDATORS = {
'prof-name': v => v.trim().length === 0 ? '显示名称不能为空' : (v.length > 20 ? '显示名称最多 20 字' : null),
'prof-email': v => !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v.trim()) ? '邮箱格式不正确' : null,
'prof-phone': v => !/^\d{3}\*{4}\d{4}$|^1\d{10}$/.test(v.trim()) ? '手机号格式不正确' : null,
};
function validate() {
const errors = [];
Object.entries(VALIDATORS).forEach(([id, fn]) => {
const el = document.getElementById(id);
if (!el) return;
const err = fn(el.value);
el.classList.toggle('invalid', !!err);
if (err) errors.push(`${id}: ${err}`);
});
return errors;
}
/* ─── 7. 保存 / 取消 ─── */
saveBtn.addEventListener('click', () => {
if (dirtyFields.size === 0) return;
const errors = validate();
if (errors.length) {
Shell.toast('校验未通过', errors[0].split(': ')[1] + ' · 共 ' + errors.length + ' 项');
return;
}
Shell.toast('设置已保存', `${dirtyFields.size} 项变更已生效 · 涉及 ${dirtySections.size} 个分区`);
clearDirty();
});
document.getElementById('save-cancel').addEventListener('click', () => {
if (!dirty) return;
if (confirm('放弃未保存的变更?')) location.reload();
cancelBtn.addEventListener('click', () => {
if (dirtyFields.size === 0) return;
if (confirm(`放弃 ${dirtyFields.size}未保存的变更?`)) location.reload();
});
/* ─── 8. 离开页面前提醒(有未保存变更时)─── */
window.addEventListener('beforeunload', e => {
if (dirtyFields.size > 0) {
e.preventDefault();
e.returnValue = '';
}
});
/* ─── 9. invalid input 视觉反馈样式 ─── */
const _invalidStyle = document.createElement('style');
_invalidStyle.textContent = `
.input.invalid { border-color: var(--accent-crimson); box-shadow: 0 0 0 3px rgba(235,52,36,.12); }
.input.invalid:focus { border-color: var(--accent-crimson); box-shadow: 0 0 0 3px rgba(235,52,36,.18); }
`;
document.head.appendChild(_invalidStyle);
/* ─── 10. nav badge 计数自动同步(从 section 内 switch 开启数 / device 数等) ─── */
function syncBadges() {
const secNotify = document.getElementById('sec-notify');
if (secNotify) {
const onCount = secNotify.querySelectorAll('input[type="checkbox"]:checked').length;
const totalCount = secNotify.querySelectorAll('input[type="checkbox"]').length;
const badge = document.querySelector('.settings-nav a[data-jump="sec-notify"] .nav-badge');
if (badge) badge.textContent = `${onCount}/${totalCount}`;
}
const secSecurity = document.getElementById('sec-security');
if (secSecurity) {
const devCount = secSecurity.querySelectorAll('.device-row').length;
const badge = document.querySelector('.settings-nav a[data-jump="sec-security"] .nav-badge');
if (badge) badge.textContent = `${devCount} 设备`;
}
}
syncBadges();
document.querySelectorAll('#sec-notify input[type="checkbox"]').forEach(cb => cb.addEventListener('change', syncBadges));
/* ─── 11. 头像上传 modal ─── */
const DEFAULT_AVATAR = { kind: 'default', label: '李', name: '默认', info: '// 系统生成 · 取姓氏首字' };
let _draftAvatar = { ...DEFAULT_AVATAR };
let _currentAvatar = { ...DEFAULT_AVATAR };
const avPreview = document.getElementById('av-up-preview');
const avPreviewName = document.getElementById('av-up-preview-name');
const avPreviewInfo = document.getElementById('av-up-preview-info');
const avProfPreview = document.getElementById('prof-avatar-preview');
const avZone = document.getElementById('av-up-zone');
const avFile = document.getElementById('av-up-file');
const avConfirm = document.getElementById('av-up-confirm');
const avResetBtn = document.getElementById('prof-avatar-reset');
function paintPreview() {
if (_draftAvatar.kind === 'image') {
avPreview.innerHTML = `<img src="${_draftAvatar.src}" alt="头像预览">`;
} else {
avPreview.innerHTML = '';
avPreview.textContent = _draftAvatar.label || '李';
}
avPreviewName.textContent = `预览 · ${_draftAvatar.name}`;
avPreviewInfo.textContent = _draftAvatar.info;
}
function applyAvatarTo(el, avatar) {
if (avatar.kind === 'image') {
el.innerHTML = `<img src="${avatar.src}" alt="头像" style="width:100%;height:100%;object-fit:cover;border-radius:50%;display:block;">`;
} else {
el.innerHTML = '';
el.textContent = avatar.label || '李';
}
}
avZone.addEventListener('click', () => avFile.click());
avZone.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); avFile.click(); } });
avZone.addEventListener('dragover', e => { e.preventDefault(); avZone.classList.add('dragover'); });
avZone.addEventListener('dragleave', () => avZone.classList.remove('dragover'));
avZone.addEventListener('drop', e => {
e.preventDefault();
avZone.classList.remove('dragover');
const f = e.dataTransfer.files && e.dataTransfer.files[0];
if (f) handleAvatarFile(f);
});
avFile.addEventListener('change', () => { if (avFile.files[0]) handleAvatarFile(avFile.files[0]); });
function handleAvatarFile(f) {
if (!/^image\/(jpeg|png|webp)$/.test(f.type)) {
Shell.toast('格式不支持', '仅支持 JPG / PNG / WebP');
return;
}
if (f.size > 2 * 1024 * 1024) {
Shell.toast('文件过大', `${(f.size / 1024 / 1024).toFixed(2)} MB · 限 2 MB`);
return;
}
const url = URL.createObjectURL(f);
_draftAvatar = { kind: 'image', src: url, name: f.name, info: `// ${(f.size / 1024).toFixed(0)} KB · ${f.type.split('/')[1].toUpperCase()}` };
paintPreview();
}
avConfirm.addEventListener('click', () => {
_currentAvatar = { ..._draftAvatar };
applyAvatarTo(avProfPreview, _currentAvatar);
Shell.closeModal('avatar-up-bg');
Shell.toast('头像已更新', _currentAvatar.name);
markDirty(avProfPreview);
});
avResetBtn.addEventListener('click', () => {
_currentAvatar = { ...DEFAULT_AVATAR };
applyAvatarTo(avProfPreview, _currentAvatar);
_draftAvatar = { ...DEFAULT_AVATAR };
paintPreview();
Shell.toast('已恢复默认头像', _currentAvatar.name);
markDirty(avProfPreview);
});
// 打开 modal 时同步当前到 draft + preview
document.querySelector('.av-actions button.btn-sm').addEventListener('click', () => {
_draftAvatar = { ..._currentAvatar };
paintPreview();
});
paintPreview();
/* ─── 12. 实时邮箱 / 名称 input 校验反馈(blur 时显示错) ─── */
['prof-name', 'prof-email', 'prof-phone'].forEach(id => {
const el = document.getElementById(id);
if (!el || !VALIDATORS[id]) return;
el.addEventListener('blur', () => {
const err = VALIDATORS[id](el.value);
el.classList.toggle('invalid', !!err);
});
el.addEventListener('input', () => {
if (el.classList.contains('invalid')) {
const err = VALIDATORS[id](el.value);
if (!err) el.classList.remove('invalid');
}
});
});
</script>
</body>