diff --git a/web/_data/build.js b/web/_data/build.js new file mode 100644 index 0000000..a98747f --- /dev/null +++ b/web/_data/build.js @@ -0,0 +1,3 @@ +/* 构建期资产版本号:每次 build 变化,模板里作 ?v= 后缀破浏览器缓存, + 避免旧版 JS/CSS(同 URL)被缓存后在新版页面上执行。 */ +module.exports = { rev: Date.now().toString(36) }; diff --git a/web/_includes/base.njk b/web/_includes/base.njk index 22fba22..e72d1f8 100644 --- a/web/_includes/base.njk +++ b/web/_includes/base.njk @@ -8,10 +8,12 @@ {% if description %}{% endif %} {# 真相源:tokens.css = App 原型 tokens.css 主题A 拷贝;site.css 镜像 atoms.css。 全站零外链(无 Google Fonts / unpkg):字体走系统栈(与 App 真机一致),图标走内联雪碧图。 #} - - + + {% if pageExtraCss %}{% endif %} +{# auth.js 放 head 同步加载:页面内联脚本(checkout/登录态判断)在 body 中就要用 jiuAuth #} + {% if pageStyle %}{% endif %} @@ -22,6 +24,6 @@ {% include "footer.njk" %} - + diff --git a/web/assets/auth.js b/web/assets/auth.js new file mode 100644 index 0000000..cf26346 --- /dev/null +++ b/web/assets/auth.js @@ -0,0 +1,48 @@ +/* 官网共享登录态助手:与 Web 版 App(shared_preferences_web)共享 + localStorage['flutter.*'](值为 JSON 编码)。access token 60 分钟过期, + 官网页面不常驻刷新,所以请求 401 时用 refresh_token 自动续签并重试一次, + 仍失败才交回调用方跳登录。零依赖。 */ +(function () { + function get(key) { + var raw = localStorage.getItem('flutter.' + key); + if (raw === null) return null; + try { return JSON.parse(raw); } catch (e) { return raw; } + } + function set(key, val) { localStorage.setItem('flutter.' + key, JSON.stringify(val)); } + + function refresh(apiBase) { + var rt = get('refresh_token'); + if (!rt) return Promise.resolve(false); + return fetch(apiBase + '/api/v1/auth/refresh', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refresh_token: rt }), + }) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (j) { + var d = j && j.data; + if (!d || !d.access_token) return false; + set('access_token', d.access_token); + if (d.refresh_token) set('refresh_token', d.refresh_token); // refresh token 会轮换 + return true; + }) + .catch(function () { return false; }); + } + + function authFetch(apiBase, path, opts) { + opts = opts || {}; + function doFetch() { + var headers = Object.assign({}, opts.headers, { Authorization: 'Bearer ' + get('access_token') }); + return fetch(apiBase + path, Object.assign({}, opts, { headers: headers })); + } + return doFetch().then(function (r) { + if (r.status !== 401) return r; + return refresh(apiBase).then(function (ok) { return ok ? doFetch() : r; }); + }); + } + + /* 是否可视为登录: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 }; +})(); diff --git a/web/assets/nav.js b/web/assets/nav.js index ee8372e..f105973 100644 --- a/web/assets/nav.js +++ b/web/assets/nav.js @@ -2,13 +2,13 @@ localStorage['flutter.*'] 有登录信息 → CTA 变「进入系统」)。零依赖。 */ (function () { function getAuthUser() { - var token = localStorage.getItem('flutter.access_token'); - var username = localStorage.getItem('flutter.username'); - if (!token || !username) return null; + if (!window.jiuAuth || !jiuAuth.loggedIn()) return null; + var username = jiuAuth.get('username'); + if (!username) return null; return { username: username, - realName: localStorage.getItem('flutter.real_name') || '', - shopNo: localStorage.getItem('flutter.shop_no') || '', + realName: jiuAuth.get('real_name') || '', + shopNo: jiuAuth.get('shop_no') || '', }; } diff --git a/web/checkout.njk b/web/checkout.njk index db5f4aa..5416391 100644 --- a/web/checkout.njk +++ b/web/checkout.njk @@ -68,13 +68,9 @@ permalink: /checkout/ (function () { var API = '{{ site.appBaseUrl }}'; function $(id) { return document.getElementById(id); } - function token() { - var raw = localStorage.getItem('flutter.access_token'); - if (!raw) return null; - try { return JSON.parse(raw); } catch (e) { return raw; } - } function gotoLogin() { location.href = '/login/?next=' + encodeURIComponent('/checkout/' + location.search); } - if (!token()) { gotoLogin(); return; } + // access token 只有 60 分钟,只要 refresh token(7 天)还在就算登录,请求时自动续签 + if (!jiuAuth.loggedIn()) { gotoLogin(); return; } /* 套餐目录:价格仅前端展示,实付金额以 pay 套餐表为准(下单响应回传) */ var PLANS = { @@ -123,12 +119,13 @@ permalink: /checkout/ $('coBtn').addEventListener('click', function () { $('coErr').classList.remove('show'); var btn = $('coBtn'); btn.disabled = true; btn.textContent = '正在创建订单…'; - fetch(API + '/api/v1/license/purchase', { + jiuAuth.authFetch(API, '/api/v1/license/purchase', { method: 'POST', - headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token() }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ biz_code: PLANS[plan][cycle].biz }), }) .then(function (r) { + // authFetch 已自动用 refresh_token 续签重试过,仍 401 才是真过期 if (r.status === 401) { gotoLogin(); throw new Error('登录已过期'); } return r.json().then(function (j) { return { ok: r.ok, j: j }; }); }) diff --git a/web/index.njk b/web/index.njk index 1452068..7e8cb0c 100644 --- a/web/index.njk +++ b/web/index.njk @@ -276,7 +276,7 @@ description: 岩美酒库管理系统——每一瓶酒一个编号,审核驱 /* 价格区登录态:已登录(与 Web 版 App 共享 localStorage flutter.*)时, 标准/高级按钮 → 「购买 / 续费」进 /checkout/;免费版按钮 → 「进入系统」。 */ (function () { - if (!localStorage.getItem('flutter.access_token')) return; + if (!window.jiuAuth || !jiuAuth.loggedIn()) return; document.querySelectorAll('[data-plan]').forEach(function (a) { var plan = a.dataset.plan; if (plan === 'free') { diff --git a/web/license-result.njk b/web/license-result.njk index cea1c77..f47e986 100644 --- a/web/license-result.njk +++ b/web/license-result.njk @@ -38,20 +38,13 @@ permalink: /license/result/ (function () { var API = '{{ site.appBaseUrl }}'; function $(id) { return document.getElementById(id); } - function token() { - var raw = localStorage.getItem('flutter.access_token'); - if (!raw) return null; - try { return JSON.parse(raw); } catch (e) { return raw; } - } var otn = new URLSearchParams(location.search).get('out_trade_no'); - if (!otn || !token()) { $('waiting').style.display = 'none'; $('slow').style.display = 'block'; return; } + if (!otn || !jiuAuth.loggedIn()) { $('waiting').style.display = 'none'; $('slow').style.display = 'block'; return; } var tries = 0, maxTries = 30; // 2s × 30 = 60s function poll() { tries++; - fetch(API + '/api/v1/license/purchase/' + encodeURIComponent(otn), { - headers: { 'Authorization': 'Bearer ' + token() }, - }) + jiuAuth.authFetch(API, '/api/v1/license/purchase/' + encodeURIComponent(otn), {}) .then(function (r) { return r.ok ? r.json() : null; }) .then(function (j) { var d = j && j.data; diff --git a/web/login.njk b/web/login.njk index 7b79be1..80660d7 100644 --- a/web/login.njk +++ b/web/login.njk @@ -76,6 +76,13 @@ permalink: /login/ btn.disabled = false; btn.textContent = '登 录'; }); } + // 登录过的浏览器回填门店编号/账号(token 过期重登时少敲两栏) + if (window.jiuAuth) { + var savedShop = jiuAuth.get('shop_no'), savedUser = jiuAuth.get('username'); + if (savedShop && !$('shop').value) $('shop').value = savedShop; + if (savedUser && !$('user').value) $('user').value = savedUser; + } + $('loginBtn').addEventListener('click', doLogin); ['shop', 'user', 'pass'].forEach(function (id) { $(id).addEventListener('keydown', function (e) { if (e.key === 'Enter') doLogin(); });