feat(site): 导航登录态下拉菜单 + 个人信息/退出登录页,登录不再直跳 App
- 导航「进入系统 · 名字」改为下拉:个人信息 / 进入系统 / 退出登录,导航可重入刷新(window.jiuNavRender) - 新增 /profile/(账号 / 门店 / 授权三卡,authFetch 实时拉取)与 /logout/(撤销会话 + 清本地凭证) - 登录成功默认落地 /profile/(?next= 站内白名单不变),不再直跳 /app/ - auth.js 增加 jiuAuth.clear();site.css 含本轮下拉 / profile / 特惠卡样式 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
This commit is contained in:
@@ -25,7 +25,7 @@
|
||||
<a href="/#pricing">价格</a>
|
||||
<a href="/download/">下载</a>
|
||||
<a href="/docs/">帮助</a>
|
||||
<a href="/register/">注册门店</a>
|
||||
<a href="/login/">登录</a>
|
||||
<a href="/register/" data-auth>注册门店</a>
|
||||
<a href="/login/" data-auth>登录</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
+8
-1
@@ -44,5 +44,12 @@
|
||||
/* 是否可视为登录:access 过期但 refresh 还在(7 天),authFetch 会自动续 */
|
||||
function loggedIn() { return !!(get('access_token') || get('refresh_token')); }
|
||||
|
||||
window.jiuAuth = { get: get, set: set, refresh: refresh, authFetch: authFetch, loggedIn: loggedIn };
|
||||
/* 退出登录:清凭证与身份,保留 shop_no/username 供登录页回填 */
|
||||
function clear() {
|
||||
['access_token', 'refresh_token', 'user_id', 'real_name', 'role', 'shop_id'].forEach(function (k) {
|
||||
localStorage.removeItem('flutter.' + k);
|
||||
});
|
||||
}
|
||||
|
||||
window.jiuAuth = { get: get, set: set, refresh: refresh, authFetch: authFetch, loggedIn: loggedIn, clear: clear };
|
||||
})();
|
||||
|
||||
+56
-8
@@ -1,5 +1,6 @@
|
||||
/* 官网导航交互:汉堡菜单 + 登录态感知(Web 版 App 与官网同域时,
|
||||
localStorage['flutter.*'] 有登录信息 → CTA 变「进入系统」)。零依赖。 */
|
||||
localStorage['flutter.*'] 有登录信息 → CTA 变「进入系统 · 姓名」下拉菜单)。
|
||||
renderAuth 可重复调用(暴露为 window.jiuNavRender,退出登录页清凭证后刷新导航)。零依赖。 */
|
||||
(function () {
|
||||
function getAuthUser() {
|
||||
if (!window.jiuAuth || !jiuAuth.loggedIn()) return null;
|
||||
@@ -28,15 +29,62 @@
|
||||
});
|
||||
}
|
||||
|
||||
// 登录态:CTA 换成「进入系统」
|
||||
var user = getAuthUser();
|
||||
if (user) {
|
||||
// 登录态渲染:桌面 CTA + 移动菜单(data-auth 标记的项按状态重建)
|
||||
var MENU_ITEMS =
|
||||
'<a href="/profile/">个人信息</a>' +
|
||||
'<a href="/app/">进入系统</a>' +
|
||||
'<div class="sep"></div>' +
|
||||
'<a class="danger" href="/logout/">退出登录</a>';
|
||||
|
||||
function renderAuth() {
|
||||
var user = getAuthUser();
|
||||
var cta = document.getElementById('nav-cta');
|
||||
if (cta) {
|
||||
cta.innerHTML =
|
||||
'<a class="btn primary" href="/app/">进入系统 · ' +
|
||||
(user.realName || user.username).replace(/[<>&"]/g, '') +
|
||||
'</a>';
|
||||
if (user) {
|
||||
cta.innerHTML =
|
||||
'<div class="nav-user" id="nav-user">' +
|
||||
'<button class="btn primary nav-user-btn" id="nav-user-btn" aria-haspopup="true" aria-expanded="false">' +
|
||||
'进入系统 · ' + (user.realName || user.username).replace(/[<>&"]/g, '') +
|
||||
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>' +
|
||||
'</button>' +
|
||||
'<div class="nav-menu">' + MENU_ITEMS + '</div>' +
|
||||
'</div>';
|
||||
} else {
|
||||
cta.innerHTML =
|
||||
'<a class="btn ghost" href="/login/">登录</a>' +
|
||||
'<a class="btn primary" href="/register/">注册门店</a>';
|
||||
}
|
||||
}
|
||||
if (mobile) {
|
||||
Array.prototype.slice.call(mobile.querySelectorAll('[data-auth]')).forEach(function (el) { el.remove(); });
|
||||
var frag = document.createElement('div');
|
||||
frag.innerHTML = user
|
||||
? MENU_ITEMS
|
||||
: '<a href="/register/">注册门店</a><a href="/login/">登录</a>';
|
||||
Array.prototype.slice.call(frag.children).forEach(function (el) {
|
||||
if (el.tagName === 'A') { el.setAttribute('data-auth', ''); mobile.appendChild(el); }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 下拉开合(事件委托,renderAuth 重建 DOM 后依然有效)
|
||||
document.addEventListener('click', function (e) {
|
||||
var wrap = document.getElementById('nav-user');
|
||||
if (!wrap) return;
|
||||
var btn = document.getElementById('nav-user-btn');
|
||||
if (btn && btn.contains(e.target)) {
|
||||
var open = wrap.classList.toggle('open');
|
||||
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
} else if (!wrap.contains(e.target)) {
|
||||
wrap.classList.remove('open');
|
||||
}
|
||||
});
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key !== 'Escape') return;
|
||||
var wrap = document.getElementById('nav-user');
|
||||
if (wrap) wrap.classList.remove('open');
|
||||
});
|
||||
|
||||
renderAuth();
|
||||
window.jiuNavRender = renderAuth;
|
||||
})();
|
||||
|
||||
+37
-2
@@ -64,6 +64,16 @@ h1,h2,h3{color:var(--heading); line-height:1.3; margin:0;}
|
||||
.nav-mobile{display:none; border-top:1px solid var(--border); background:var(--surface); padding:10px 24px 16px;}
|
||||
.nav-mobile a{display:block; padding:10px 0; color:var(--text); font-size:15px; border-bottom:1px solid var(--border-subtle); text-decoration:none !important;}
|
||||
.nav-mobile.open{display:block;}
|
||||
.nav-user{position:relative;}
|
||||
.nav-user-btn{display:inline-flex; align-items:center; gap:7px; cursor:pointer;}
|
||||
.nav-user-btn svg{width:13px; height:13px; transition:transform .15s;}
|
||||
.nav-user.open .nav-user-btn svg{transform:rotate(180deg);}
|
||||
.nav-menu{display:none; position:absolute; right:0; top:calc(100% + 8px); min-width:158px; background:var(--surface); border:1px solid var(--border); border-radius:var(--r-md); box-shadow:var(--sh-2); padding:6px; z-index:50;}
|
||||
.nav-user.open .nav-menu{display:block;}
|
||||
.nav-menu a{display:block; padding:9px 12px; border-radius:var(--r-sm); font-size:14px; color:var(--text); text-decoration:none !important;}
|
||||
.nav-menu a:hover{background:var(--bg);}
|
||||
.nav-menu a.danger{color:var(--danger);}
|
||||
.nav-menu .sep{height:1px; background:var(--border-subtle); margin:5px 4px;}
|
||||
|
||||
/* ---------- Hero(品牌渐变 = 登录页品牌面板同源) ---------- */
|
||||
.hero{background:var(--mk-hero-grad); color:var(--on-accent); padding:84px 0 0; overflow:hidden;}
|
||||
@@ -129,6 +139,14 @@ table.roles .n{color:var(--faint);}
|
||||
.plans.p4{grid-template-columns:repeat(4,1fr); gap:16px;}
|
||||
.plans.p4 .plan{padding:26px 22px;}
|
||||
.plans.p4 .plan .price{font-size:30px;}
|
||||
.plans.p5{grid-template-columns:repeat(5,1fr); gap:14px;}
|
||||
.plans.p5 .plan{padding:24px 18px;}
|
||||
.plans.p5 .plan .price{font-size:28px;}
|
||||
.plan.promo{border-color:var(--warn); box-shadow:var(--sh-2); position:relative; background:linear-gradient(180deg,var(--warn-bg),var(--surface) 55%);}
|
||||
.plan.promo::before{content:'🔥 新店专享'; position:absolute; top:-11px; left:50%; transform:translateX(-50%); background:var(--warn); color:#fff; font-size:var(--fs-xs); font-weight:700; padding:3px 12px; border-radius:var(--r-pill); white-space:nowrap;}
|
||||
.plan.promo .price{color:var(--warn);}
|
||||
.plan .price .was{font-size:14px; color:var(--faint); font-weight:400; text-decoration:line-through; font-family:var(--font); margin-left:6px;}
|
||||
.plan .btn.disabled{pointer-events:none; opacity:.55;}
|
||||
.plan{background:var(--surface); border:1px solid var(--border); border-radius:var(--r-xl); padding:28px 26px; display:flex; flex-direction:column;}
|
||||
.plan.hot{border-color:var(--primary); box-shadow:var(--sh-2); position:relative;}
|
||||
.plan.hot::before{content:'推荐'; position:absolute; top:-11px; left:50%; transform:translateX(-50%); background:var(--primary); color:var(--on-primary); font-size:var(--fs-xs); font-weight:700; padding:3px 12px; border-radius:var(--r-pill);}
|
||||
@@ -169,12 +187,12 @@ table.roles .n{color:var(--faint);}
|
||||
/* ---------- 响应式 ---------- */
|
||||
@media (max-width:1024px){
|
||||
.grid.c4{grid-template-columns:repeat(2,1fr);}
|
||||
.plans,.plans.p4{grid-template-columns:repeat(2,1fr);}
|
||||
.plans,.plans.p4,.plans.p5{grid-template-columns:repeat(2,1fr);}
|
||||
.split{grid-template-columns:1fr;}
|
||||
.footer-grid{grid-template-columns:1fr 1fr;}
|
||||
}
|
||||
@media (max-width:640px){
|
||||
.plans,.plans.p4{grid-template-columns:1fr; max-width:440px; margin:0 auto;}
|
||||
.plans,.plans.p4,.plans.p5{grid-template-columns:1fr; max-width:440px; margin:0 auto;}
|
||||
}
|
||||
@media (max-width:720px){
|
||||
.nav-links,.nav-cta .btn.ghost{display:none;}
|
||||
@@ -248,6 +266,11 @@ textarea.input{height:auto; min-height:64px; padding:9px 11px; resize:vertical;}
|
||||
.seg-btn{border:none; background:transparent; padding:8px 18px; border-radius:calc(var(--r-md) - 3px); font-size:var(--fs-body); color:var(--muted); cursor:pointer; font-family:var(--font); display:inline-flex; align-items:center; gap:7px;}
|
||||
.seg-btn.on{background:var(--surface); color:var(--heading); font-weight:600; box-shadow:var(--sh-1);}
|
||||
.seg-tip{font-size:var(--fs-xs); color:var(--success); background:var(--ok-soft); padding:1px 8px; border-radius:var(--r-pill); font-weight:600;}
|
||||
.seg-btn.promo{color:var(--warn); font-weight:700;}
|
||||
.seg-btn.promo.on{color:var(--warn);}
|
||||
.seg-btn.used,.seg-btn:disabled{color:var(--faint); font-weight:400; cursor:not-allowed; opacity:.7;}
|
||||
.promo-banner{background:var(--warn-bg); border:1px solid var(--warn); border-radius:var(--r-md); padding:12px 14px; font-size:var(--fs-sm); color:var(--text); margin:10px 0 4px; line-height:1.7;}
|
||||
.promo-banner b{display:block; color:var(--warn); font-size:var(--fs-body);}
|
||||
.seg-label{font-size:var(--fs-sm); color:var(--muted); margin:14px 0 8px;}
|
||||
.co-feats{list-style:none; padding:0; margin:12px 0 4px; display:flex; flex-direction:column; gap:8px; font-size:14px; color:var(--text);}
|
||||
.co-feats li{display:flex; gap:8px; align-items:flex-start;}
|
||||
@@ -281,6 +304,18 @@ textarea.input{height:auto; min-height:64px; padding:9px 11px; resize:vertical;}
|
||||
.sum-total .val{font-size:var(--fs-display); font-weight:800; color:var(--primary); font-family:var(--font-mono); letter-spacing:-.5px;}
|
||||
.co-summary .fine{font-size:var(--fs-xs); color:var(--faint); text-align:center; margin-top:12px; line-height:1.6;}
|
||||
|
||||
/* ---------- 个人信息(/profile/) ---------- */
|
||||
.pf-row{display:flex; justify-content:space-between; gap:16px; padding:9px 0; font-size:var(--fs-body); border-bottom:1px solid var(--border-subtle);}
|
||||
.pf-row:last-child{border-bottom:none; padding-bottom:2px;}
|
||||
.pf-row .k{color:var(--muted); flex:none;}
|
||||
.pf-row .v{color:var(--heading); font-weight:500; text-align:right; word-break:break-all;}
|
||||
.pf-row .v.mono{font-family:var(--font-mono);}
|
||||
.pf-badge{display:inline-flex; padding:2px 10px; border-radius:var(--r-pill); font-size:var(--fs-sm); font-weight:600;}
|
||||
.pf-badge.ok{background:var(--ok-soft); color:var(--success);}
|
||||
.pf-badge.warn{background:var(--warn-bg); color:var(--warn);}
|
||||
.pf-badge.bad{background:var(--danger-bg); color:var(--danger);}
|
||||
.pf-acts{display:flex; gap:10px; flex-wrap:wrap; margin-top:4px;}
|
||||
|
||||
/* ---------- 支付结果页 ---------- */
|
||||
.result{text-align:center; max-width:520px; margin:0 auto; padding:24px 16px;}
|
||||
.result .ring{width:72px; height:72px; border-radius:50%; display:flex; align-items:center; justify-content:center; margin:0 auto 18px;}
|
||||
|
||||
+3
-3
@@ -6,7 +6,7 @@ permalink: /login/
|
||||
---
|
||||
{# 官网内登录(不跳 App):POST /api/v1/auth/login,成功后把凭证写入与
|
||||
Web 版 App(shared_preferences_web)共享的 localStorage['flutter.*']
|
||||
(值为 JSON 编码,与插件格式一致),再进入 /app/ 即已登录。 #}
|
||||
(值为 JSON 编码,与插件格式一致),默认落地 /profile/;后续打开 /app/ 即已登录。 #}
|
||||
<div class="auth-wrap">
|
||||
<div class="auth">
|
||||
<div class="auth-brand">
|
||||
@@ -67,9 +67,9 @@ permalink: /login/
|
||||
.then(function (res) {
|
||||
if (!res.ok) throw new Error((res.j && res.j.error) || '登录失败,请检查账号信息');
|
||||
persist(res.j.data || {}, shop);
|
||||
// ?next= 仅允许站内路径(防开放跳转)
|
||||
// ?next= 仅允许站内路径(防开放跳转);默认留在官网(个人信息页),不跳 App
|
||||
var next = new URLSearchParams(location.search).get('next') || '';
|
||||
location.href = (next.charAt(0) === '/' && next.indexOf('//') !== 0) ? next : '/app/';
|
||||
location.href = (next.charAt(0) === '/' && next.indexOf('//') !== 0) ? next : '/profile/';
|
||||
})
|
||||
.catch(function (e) {
|
||||
showErr(e && e.message ? e.message : '无法连接服务器,请稍后重试');
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
layout: base.njk
|
||||
title: 退出登录
|
||||
description: 退出岩美酒库管理系统的官网登录状态。
|
||||
permalink: /logout/
|
||||
---
|
||||
{# 退出登录:POST /api/v1/auth/logout 撤销服务端会话(尽力而为),
|
||||
再清 localStorage 凭证(保留门店编号/账号供下次登录回填),刷新导航登录态。 #}
|
||||
<div class="container" style="padding:64px 24px 88px">
|
||||
<div class="result">
|
||||
<div class="ring wait" id="loRing"><svg class="ic"><use href="#i-ic23"/></svg></div>
|
||||
<h2 id="loTitle">正在退出…</h2>
|
||||
<p id="loDesc">正在撤销当前登录会话,请稍候。</p>
|
||||
<div class="acts" id="loActs" style="display:none">
|
||||
<a class="btn primary" href="/login/">重新登录</a>
|
||||
<a class="btn ghost" href="/">返回首页</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var API = '{{ site.appBaseUrl }}';
|
||||
function done() {
|
||||
if (window.jiuAuth) jiuAuth.clear();
|
||||
if (window.jiuNavRender) jiuNavRender();
|
||||
document.getElementById('loRing').className = 'ring good';
|
||||
document.getElementById('loTitle').textContent = '已退出登录';
|
||||
document.getElementById('loDesc').textContent = '本设备的登录状态已清除,感谢使用岩美酒库。';
|
||||
document.getElementById('loActs').style.display = 'flex';
|
||||
}
|
||||
if (window.jiuAuth && jiuAuth.loggedIn()) {
|
||||
// 撤销服务端会话失败也照常清本地(token 可能已过期)
|
||||
jiuAuth.authFetch(API, '/api/v1/auth/logout', { method: 'POST' }).then(done, done);
|
||||
} else {
|
||||
done();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,92 @@
|
||||
---
|
||||
layout: base.njk
|
||||
title: 个人信息
|
||||
description: 查看当前登录账号、门店与授权状态。
|
||||
permalink: /profile/
|
||||
---
|
||||
{# 个人信息(需登录):账号信息来自 localStorage;门店与授权实时拉
|
||||
GET /api/v1/shop/info(原样 JSON)与 GET /api/v1/license/info({data}包裹)。 #}
|
||||
<div class="container" style="padding:40px 24px 72px; max-width:720px">
|
||||
<div class="crumb"><a href="/">首页</a><span>/</span><span>个人信息</span></div>
|
||||
<h1 class="co-title">个人信息</h1>
|
||||
|
||||
<div class="co-card">
|
||||
<h2>账号</h2>
|
||||
<div class="pf-row"><span class="k">姓名</span><span class="v" id="pfName">—</span></div>
|
||||
<div class="pf-row"><span class="k">登录账号</span><span class="v mono" id="pfUser">—</span></div>
|
||||
<div class="pf-row"><span class="k">角色</span><span class="v" id="pfRole">—</span></div>
|
||||
<div class="pf-row"><span class="k">门店编号</span><span class="v mono" id="pfShopNo">—</span></div>
|
||||
</div>
|
||||
|
||||
<div class="co-card">
|
||||
<h2>门店</h2>
|
||||
<div class="pf-row"><span class="k">门店名称</span><span class="v" id="pfShopName">加载中…</span></div>
|
||||
<div class="pf-row"><span class="k">负责人</span><span class="v" id="pfManager">—</span></div>
|
||||
<div class="pf-row"><span class="k">联系电话</span><span class="v mono" id="pfPhone">—</span></div>
|
||||
<div class="pf-row"><span class="k">门店地址</span><span class="v" id="pfAddr">—</span></div>
|
||||
</div>
|
||||
|
||||
<div class="co-card">
|
||||
<h2>授权</h2>
|
||||
<div class="pf-row"><span class="k">状态</span><span class="v" id="pfPhase">加载中…</span></div>
|
||||
<div class="pf-row"><span class="k">授权类型</span><span class="v" id="pfType">—</span></div>
|
||||
<div class="pf-row"><span class="k">到期时间</span><span class="v mono" id="pfExpire">—</span></div>
|
||||
<div class="pf-row"><span class="k">已绑定设备</span><span class="v mono" id="pfDev">—</span></div>
|
||||
<div class="pf-acts" style="margin-top:14px">
|
||||
<a class="btn primary" href="/checkout/">购买 / 续费</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pf-acts">
|
||||
<a class="btn primary" href="/app/">进入系统</a>
|
||||
<a class="btn ghost" href="/logout/">退出登录</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var API = '{{ site.appBaseUrl }}';
|
||||
function $(id) { return document.getElementById(id); }
|
||||
function gotoLogin() { location.href = '/login/?next=/profile/'; }
|
||||
if (!window.jiuAuth || !jiuAuth.loggedIn()) { gotoLogin(); return; }
|
||||
|
||||
var ROLE = { superadmin: '超级管理员', super_admin: '超级管理员', admin: '管理员', operator: '操作员', readonly: '只读' };
|
||||
var TYPE = { trial: '试用', monthly: '包月', annual: '包年', lifetime: '永久' };
|
||||
|
||||
$('pfName').textContent = jiuAuth.get('real_name') || '—';
|
||||
$('pfUser').textContent = jiuAuth.get('username') || '—';
|
||||
var role = jiuAuth.get('role') || '';
|
||||
$('pfRole').textContent = ROLE[role] || role || '—';
|
||||
$('pfShopNo').textContent = jiuAuth.get('shop_no') || '—';
|
||||
|
||||
function guard(r) {
|
||||
if (r.status === 401) { gotoLogin(); return null; }
|
||||
return r.ok ? r.json() : null;
|
||||
}
|
||||
|
||||
// 门店信息(GetInfo 直接返回 shop JSON,无 {data} 包裹)
|
||||
jiuAuth.authFetch(API, '/api/v1/shop/info').then(guard).then(function (s) {
|
||||
if (!s) return;
|
||||
$('pfShopName').textContent = s.name || '—';
|
||||
$('pfManager').textContent = s.manager_name || '—';
|
||||
$('pfPhone').textContent = s.phone || '—';
|
||||
$('pfAddr').textContent = s.address || '—';
|
||||
}).catch(function () { $('pfShopName').textContent = '加载失败'; });
|
||||
|
||||
// 授权信息({data} 包裹;data:null = 无有效授权)
|
||||
jiuAuth.authFetch(API, '/api/v1/license/info').then(guard).then(function (j) {
|
||||
if (!j) return;
|
||||
var d = j.data;
|
||||
if (!d) { $('pfPhase').innerHTML = '<span class="pf-badge bad">无有效授权</span>'; return; }
|
||||
var badge =
|
||||
d.phase === 'normal' ? '<span class="pf-badge ok">正常</span>' :
|
||||
d.phase === 'grace' ? '<span class="pf-badge warn">已到期 · 宽限期内</span>' :
|
||||
d.phase === 'readonly' ? '<span class="pf-badge warn">已到期 · 只读</span>' :
|
||||
'<span class="pf-badge bad">已锁定</span>';
|
||||
$('pfPhase').innerHTML = badge;
|
||||
$('pfType').textContent = TYPE[d.type] || d.type || '—';
|
||||
$('pfExpire').textContent = d.expires_at ? String(d.expires_at).slice(0, 10) : '—';
|
||||
$('pfDev').textContent = (d.device_count != null ? d.device_count : '—') + ' / ' + (d.max_devices || '—');
|
||||
}).catch(function () { $('pfPhase').textContent = '加载失败'; });
|
||||
})();
|
||||
</script>
|
||||
Reference in New Issue
Block a user