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:
+345
-74
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user