From b735afbbfae854d587169bca55dddc6e1090bbcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=BA=90?= Date: Tue, 20 Oct 2026 16:39:15 +0800 Subject: [PATCH] feat: add local TOTP user tracking --- README.md | 7 +- assets/auth.js | 219 +++++++++++++++++++++++++++++++++++ assets/data/users.js | 18 +++ assets/link-generator.js | 162 ++++++++------------------ assets/link-history-store.js | 47 ++++++++ assets/link-history.js | 93 +++++++++++++++ assets/login.js | 105 +++++++++++++++++ assets/styles.css | 7 ++ link-generator.html | 39 ++----- link-history.html | 39 +++++++ login.html | 66 +++++++++++ 11 files changed, 657 insertions(+), 145 deletions(-) create mode 100644 assets/auth.js create mode 100644 assets/data/users.js create mode 100644 assets/link-history-store.js create mode 100644 assets/link-history.js create mode 100644 assets/login.js create mode 100644 link-history.html create mode 100644 login.html diff --git a/README.md b/README.md index cb6fd83..aea4cdb 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,15 @@ - `index.html`:工具入口 - `channel-recommendation.html`:包名、品类、国家 → 渠道优先级建议 -- `link-generator.html`:粘贴原始 Mintegral Click Link 并转换为已确认的平台宏 +- `link-generator.html`:登录后粘贴原始 Mintegral Click Link 并转换为已确认的平台宏 +- `login.html`:本机密码与 Google Authenticator 动态码验证 +- `link-history.html`:按登录用户查看独立的链接记录 + +登录、Authenticator 绑定和链接记录均保存在当前浏览器,不会跨设备同步;这是内部辅助识别方案,不可替代服务端身份认证或安全审计。 ## 可维护数据 +- `assets/data/users.js`:允许使用工具的用户名和角色 - `assets/data/channel-rules.js`:品类、国家、区域和渠道评分规则 - `assets/data/link-templates.js`:媒体宏与链接参数模板 diff --git a/assets/auth.js b/assets/auth.js new file mode 100644 index 0000000..218636c --- /dev/null +++ b/assets/auth.js @@ -0,0 +1,219 @@ +((root) => { + const users = root.APP_USERS || []; + const PROFILE_KEY = 'traffic_auth_profiles_v1'; + const SESSION_KEY = 'traffic_auth_session_v1'; + const SESSION_DURATION = 8 * 60 * 60 * 1000; + const encoder = new TextEncoder(); + const decoder = new TextDecoder(); + const BASE32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + + function findUser(username) { + return users.find((user) => user.username === username) || null; + } + + function readProfiles() { + try { + const value = JSON.parse(localStorage.getItem(PROFILE_KEY) || '{}'); + return value && typeof value === 'object' ? value : {}; + } catch (_) { return {}; } + } + + function writeProfiles(profiles) { + try { localStorage.setItem(PROFILE_KEY, JSON.stringify(profiles)); } + catch (_) { throw new Error('浏览器无法保存本机绑定,请检查存储权限'); } + } + + function bytesToBase64(bytes) { + let binary = ''; + bytes.forEach((byte) => { binary += String.fromCharCode(byte); }); + return btoa(binary); + } + + function base64ToBytes(value) { + const binary = atob(value); + return Uint8Array.from(binary, (char) => char.charCodeAt(0)); + } + + function base32Encode(bytes) { + let bits = 0, value = 0, output = ''; + bytes.forEach((byte) => { + value = (value << 8) | byte; + bits += 8; + while (bits >= 5) { + output += BASE32[(value >>> (bits - 5)) & 31]; + bits -= 5; + } + }); + if (bits > 0) output += BASE32[(value << (5 - bits)) & 31]; + return output; + } + + function base32Decode(input) { + const clean = input.toUpperCase().replace(/[^A-Z2-7]/g, ''); + let bits = 0, value = 0; + const output = []; + for (const char of clean) { + const index = BASE32.indexOf(char); + if (index < 0) continue; + value = (value << 5) | index; + bits += 5; + if (bits >= 8) { + output.push((value >>> (bits - 8)) & 255); + bits -= 8; + } + } + return new Uint8Array(output); + } + + async function deriveKey(password, salt) { + const material = await crypto.subtle.importKey('raw', encoder.encode(password), 'PBKDF2', false, ['deriveKey']); + return crypto.subtle.deriveKey( + { name:'PBKDF2', salt, iterations:210000, hash:'SHA-256' }, + material, + { name:'AES-GCM', length:256 }, + false, + ['encrypt', 'decrypt'] + ); + } + + async function encryptSecret(secret, password) { + const salt = crypto.getRandomValues(new Uint8Array(16)); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const key = await deriveKey(password, salt); + const encrypted = await crypto.subtle.encrypt({ name:'AES-GCM', iv }, key, encoder.encode(secret)); + return { salt:bytesToBase64(salt), iv:bytesToBase64(iv), cipher:bytesToBase64(new Uint8Array(encrypted)) }; + } + + async function decryptSecret(profile, password) { + try { + const salt = base64ToBytes(profile.salt); + const iv = base64ToBytes(profile.iv); + const key = await deriveKey(password, salt); + const decrypted = await crypto.subtle.decrypt({ name:'AES-GCM', iv }, key, base64ToBytes(profile.cipher)); + return decoder.decode(decrypted); + } catch (_) { + throw new Error('密码不正确'); + } + } + + function counterBytes(counter) { + const buffer = new ArrayBuffer(8); + const view = new DataView(buffer); + view.setUint32(0, Math.floor(counter / 0x100000000)); + view.setUint32(4, counter >>> 0); + return buffer; + } + + async function generateTotp(secret, timestamp = Date.now()) { + const key = await crypto.subtle.importKey('raw', base32Decode(secret), { name:'HMAC', hash:'SHA-1' }, false, ['sign']); + const counter = Math.floor(timestamp / 30000); + const signature = new Uint8Array(await crypto.subtle.sign('HMAC', key, counterBytes(counter))); + const offset = signature[signature.length - 1] & 15; + const binary = ((signature[offset] & 127) << 24) | (signature[offset + 1] << 16) | (signature[offset + 2] << 8) | signature[offset + 3]; + return String(binary % 1000000).padStart(6, '0'); + } + + async function verifyTotp(secret, code, timestamp = Date.now()) { + const normalized = String(code).replace(/\D/g, ''); + if (normalized.length !== 6) return false; + for (const step of [-1, 0, 1]) { + if (await generateTotp(secret, timestamp + step * 30000) === normalized) return true; + } + return false; + } + + function createEnrollment(username) { + const user = findUser(username); + if (!user) throw new Error('用户不存在'); + const secret = base32Encode(crypto.getRandomValues(new Uint8Array(20))); + const label = `Traffic Intelligence:${username}`; + const otpauthUrl = `otpauth://totp/${encodeURIComponent(label)}?secret=${secret}&issuer=${encodeURIComponent('Traffic Intelligence')}&algorithm=SHA1&digits=6&period=30`; + return { secret, otpauthUrl }; + } + + function setSession(user, revision) { + const session = { username:user.username, role:user.role, revision, expiresAt:Date.now() + SESSION_DURATION }; + sessionStorage.setItem(SESSION_KEY, JSON.stringify(session)); + return session; + } + + function currentUser() { + try { + const session = JSON.parse(sessionStorage.getItem(SESSION_KEY) || 'null'); + const user = session && findUser(session.username); + const profile = user && readProfiles()[user.username]; + if (!user || !profile?.revision || profile.revision !== session.revision || session.expiresAt <= Date.now()) { + sessionStorage.removeItem(SESSION_KEY); + return null; + } + return { ...user, expiresAt:session.expiresAt }; + } catch (_) { return null; } + } + + function isEnrolled(username) { + return Boolean(readProfiles()[username]); + } + + async function enroll(username, password, secret, code) { + const user = findUser(username); + if (!user) throw new Error('用户不存在'); + if (String(password).length < 8) throw new Error('密码至少需要 8 位'); + if (!await verifyTotp(secret, code)) throw new Error('动态验证码不正确,请确认手机时间准确'); + const encrypted = await encryptSecret(secret, password); + const profiles = readProfiles(); + const revision = bytesToBase64(crypto.getRandomValues(new Uint8Array(12))); + profiles[username] = { ...encrypted, revision, createdAt:new Date().toISOString() }; + writeProfiles(profiles); + return setSession(user, revision); + } + + async function login(username, password, code) { + const user = findUser(username); + const profile = readProfiles()[username]; + if (!user || !profile) throw new Error('该账号尚未在本机完成首次设置'); + const secret = await decryptSecret(profile, password); + if (!await verifyTotp(secret, code)) throw new Error('动态验证码不正确'); + return setSession(user, profile.revision); + } + + function logout() { + sessionStorage.removeItem(SESSION_KEY); + } + + function clearEnrollment(username) { + const profiles = readProfiles(); + delete profiles[username]; + writeProfiles(profiles); + if (currentUser()?.username === username) logout(); + } + + function requireUser() { + const user = currentUser(); + if (user) return user; + const page = location.pathname.split('/').pop() || 'link-generator.html'; + const next = ['link-generator.html', 'link-history.html'].includes(page) ? page : 'link-generator.html'; + location.replace(`login.html?next=${encodeURIComponent(next)}`); + return null; + } + + function watchSession(onInvalid) { + let redirected = false; + const check = () => { + if (redirected || currentUser()) return; + redirected = true; + onInvalid(); + }; + const timer = setInterval(check, 30000); + const onStorage = (event) => { if (event.key === PROFILE_KEY) check(); }; + const onVisibility = () => { if (document.visibilityState === 'visible') check(); }; + addEventListener('storage', onStorage); + document.addEventListener('visibilitychange', onVisibility); + return () => { + clearInterval(timer); + removeEventListener('storage', onStorage); + document.removeEventListener('visibilitychange', onVisibility); + }; + } + + root.TrafficAuth = { users, PROFILE_KEY, findUser, createEnrollment, generateTotp, verifyTotp, isEnrolled, enroll, login, logout, clearEnrollment, currentUser, requireUser, watchSession }; +})(typeof window === 'undefined' ? globalThis : window); diff --git a/assets/data/users.js b/assets/data/users.js new file mode 100644 index 0000000..e452fec --- /dev/null +++ b/assets/data/users.js @@ -0,0 +1,18 @@ +window.APP_USERS = [ + { username: 'wangyuan', role: 'admin' }, + { username: 'fangjie', role: 'admin' }, + { username: 'liuji', role: 'user' }, + { username: 'jiameng', role: 'user' }, + { username: 'zouyun', role: 'user' }, + { username: 'lilin', role: 'user' }, + { username: 'caoxiaoqi', role: 'user' }, + { username: 'zhangyujia', role: 'user' }, + { username: 'yangyanming', role: 'user' }, + { username: 'zhanhanyue', role: 'user' }, + { username: 'wanke', role: 'user' }, + { username: 'huangzheng', role: 'user' }, + { username: 'zhouzijun', role: 'user' }, + { username: 'tanchao', role: 'user' }, + { username: 'zhaoguoqing', role: 'user' }, + { username: 'wangwenjiao', role: 'user' } +]; diff --git a/assets/link-generator.js b/assets/link-generator.js index c8f61e0..286ffd8 100644 --- a/assets/link-generator.js +++ b/assets/link-generator.js @@ -1,23 +1,43 @@ (() => { + const auth = window.TrafficAuth; + const current = auth.requireUser(); + if (!current) return; const templates = window.LINK_TEMPLATES; + const historyStore = window.LinkHistoryStore; if (!templates?.length) return; const { convertTrackingLink } = window.TrackingLinkConverter; - const HISTORY_KEY = 'traffic_link_history_v1'; - const MAX_HISTORY = 50; const $ = (id) => document.getElementById(id); const platformSelect = $('platformSelect'); const macroBody = $('macroTableBody'); const output = $('urlOutput'); let activeResult = null; - let history = loadHistory(); let inputTimer; + $('currentUser').innerHTML = `${escapeHtml(current.username)}${current.role === 'admin' ? '管理员' : '普通用户'}`; $('templateCount').textContent = `${templates.length} 个平台模板`; templates.forEach((template, index) => platformSelect.add(new Option(template.name, index))); const activeTemplate = () => templates[Number(platformSelect.value || 0)]; + function ensureSession() { + const active = auth.currentUser(); + if (active?.username === current.username) return true; + location.replace('login.html?next=link-generator.html'); + return false; + } + + auth.watchSession(() => location.replace('login.html?next=link-generator.html')); + + function scopedHistoryCount() { + const records = historyStore.list(); + return current.role === 'admin' ? records.length : records.filter((item) => item.username === current.username).length; + } + + function updateHistoryCount() { + $('historyCountBadge').textContent = scopedHistoryCount(); + } + function renderPlatform() { const template = activeTemplate(); const review = template.status.includes('待'); @@ -31,18 +51,12 @@ const allMacros = activeTemplate().macros; const rows = allMacros.filter(([macro, desc]) => `${macro} ${desc}`.toLowerCase().includes(query)); $('macroCount').textContent = `${allMacros.length} 个可用宏 · 点击展开`; - macroBody.innerHTML = rows.length - ? rows.map(([macro, desc]) => `${escapeHtml(macro)}${escapeHtml(desc)}`).join('') - : '没有匹配的媒体宏'; + macroBody.innerHTML = rows.length ? rows.map(([macro, desc]) => `${escapeHtml(macro)}${escapeHtml(desc)}`).join('') : '没有匹配的媒体宏'; } function renderChanges(changes) { const rows = changes.length ? changes : [{ key:'—', from:'原链接参数无需变化', to:'—' }]; - $('mappingTableBody').innerHTML = rows.map((change) => ` - ${escapeHtml(change.key)} - ${escapeHtml(change.from || '(空值)')} - ${change.to ? `${escapeHtml(change.to)}` : '留空'} - `).join(''); + $('mappingTableBody').innerHTML = rows.map((change) => `${escapeHtml(change.key)}${escapeHtml(change.from || '(空值)')}${change.to ? `${escapeHtml(change.to)}` : '留空'}`).join(''); } function showEmpty() { @@ -72,7 +86,6 @@ if (showRequiredMessage) $('generatorMessage').textContent = '请先粘贴一条完整的 Tracking Link'; return null; } - try { const result = convertTrackingLink(original, activeTemplate()); activeResult = result; @@ -95,11 +108,19 @@ } } + function extractPkg(url) { + try { return new URL(url).searchParams.get('pkg') || '—'; } + catch (_) { return '—'; } + } + function convertAndSave() { + if (!ensureSession()) return; const result = convertFromInput(true); if (!result) return; - saveHistory(result); - $('generatorMessage').textContent = '转换完成,已加入平台链接总结'; + const saved = historyStore.add({ username:current.username, userRole:current.role, platform:activeTemplate().name, pkg:extractPkg(result.url), type:result.type, url:result.url }); + if (!saved.ok) return showToast('转换成功,但浏览器无法保存记录'); + updateHistoryCount(); + $('generatorMessage').textContent = '转换完成,已记录操作用户'; showToast('转换完成并已保存记录'); } @@ -108,130 +129,39 @@ convertFromInput(false); } - function loadHistory() { - try { - const stored = JSON.parse(localStorage.getItem(HISTORY_KEY) || '[]'); - return Array.isArray(stored) ? stored.filter((item) => item?.id && item?.url).slice(0, MAX_HISTORY) : []; - } catch (_) { return []; } - } - - function persistHistory() { - try { - localStorage.setItem(HISTORY_KEY, JSON.stringify(history)); - return true; - } catch (_) { - showToast('浏览器无法保存记录,请检查存储权限'); - return false; - } - } - - function extractPkg(url) { - try { return new URL(url).searchParams.get('pkg') || '—'; } - catch (_) { return '—'; } - } - - function saveHistory(result) { - const platform = activeTemplate().name; - const pkg = extractPkg(result.url); - history = history.filter((item) => item.url !== result.url); - history.unshift({ - id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, - platform, - pkg, - type: result.type, - createdAt: new Date().toISOString(), - url: result.url - }); - history = history.slice(0, MAX_HISTORY); - persistHistory(); - renderHistory(); - } - - function formatTime(value) { - const date = new Date(value); - if (Number.isNaN(date.getTime())) return '—'; - return new Intl.DateTimeFormat('zh-CN', { year:'numeric', month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit', second:'2-digit', hour12:false }).format(date); - } - - function renderHistory() { - $('historyCount').textContent = history.length; - $('clearHistory').disabled = history.length === 0; - $('historyEmpty').classList.toggle('hidden', history.length > 0); - $('historyTableWrap').classList.toggle('hidden', history.length === 0); - $('historyTableBody').innerHTML = history.map((item) => ` - - ${escapeHtml(item.platform)} - ${escapeHtml(item.pkg)} - - - `).join(''); - } - async function copyText(text) { try { await navigator.clipboard.writeText(text); } catch (_) { - const area = document.createElement('textarea'); - area.value = text; - area.style.position = 'fixed'; - area.style.opacity = '0'; - document.body.appendChild(area); - area.select(); - document.execCommand('copy'); - area.remove(); + const area = document.createElement('textarea'); area.value = text; + area.style.position = 'fixed'; area.style.opacity = '0'; + document.body.appendChild(area); area.select(); document.execCommand('copy'); area.remove(); } } async function copyUrl() { - if (!activeResult) return; + if (!activeResult || !ensureSession()) return; await copyText(activeResult.url); - $('copyStatus').textContent = '已复制'; - showToast('转换后的平台链接已复制'); + $('copyStatus').textContent = '已复制'; showToast('转换后的平台链接已复制'); } function showToast(message) { - const toast = $('toast'); - toast.textContent = message; - toast.classList.add('show'); - clearTimeout(showToast.timer); - showToast.timer = setTimeout(() => toast.classList.remove('show'), 1800); + const toast = $('toast'); toast.textContent = message; toast.classList.add('show'); + clearTimeout(showToast.timer); showToast.timer = setTimeout(() => toast.classList.remove('show'), 1800); } function escapeHtml(value) { return String(value).replace(/[&<>"']/g, (char) => ({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''' })[char]); } + $('logoutBtn').addEventListener('click', () => { auth.logout(); location.href = 'login.html'; }); platformSelect.addEventListener('change', renderPlatform); $('macroSearch').addEventListener('input', renderMacros); $('convertLink').addEventListener('click', convertAndSave); $('sampleLink').addEventListener('click', loadSample); $('copyLink').addEventListener('click', copyUrl); $('clearConverter').addEventListener('click', () => { $('originalLink').value = ''; showEmpty(); $('originalLink').focus(); }); - $('clearHistory').addEventListener('click', () => { - if (!history.length || !window.confirm('确定清空全部平台链接记录吗?')) return; - history = []; - persistHistory(); - renderHistory(); - }); - $('historyTableBody').addEventListener('click', async (event) => { - const button = event.target.closest('button[data-action]'); - if (!button) return; - const item = history.find((record) => record.id === button.dataset.id); - if (!item) return; - if (button.dataset.action === 'copy') { - await copyText(item.url); - showToast('历史链接已复制'); - } else if (button.dataset.action === 'delete') { - history = history.filter((record) => record.id !== item.id); - persistHistory(); - renderHistory(); - } - }); - $('originalLink').addEventListener('input', () => { - clearTimeout(inputTimer); - inputTimer = setTimeout(() => convertFromInput(false), 220); - }); + $('originalLink').addEventListener('input', () => { clearTimeout(inputTimer); inputTimer = setTimeout(() => convertFromInput(false), 220); }); + window.addEventListener('storage', (event) => { if (event.key === historyStore.STORAGE_KEY) updateHistoryCount(); }); - renderPlatform(); - showEmpty(); - renderHistory(); + renderPlatform(); showEmpty(); updateHistoryCount(); })(); diff --git a/assets/link-history-store.js b/assets/link-history-store.js new file mode 100644 index 0000000..0cca781 --- /dev/null +++ b/assets/link-history-store.js @@ -0,0 +1,47 @@ +((root) => { + const STORAGE_KEY = 'traffic_link_history_v1'; + const MAX_RECORDS = 100; + + function normalize(records) { + if (!Array.isArray(records)) return []; + return records.filter((item) => item && item.id && item.url).map((item) => ({ + ...item, + username: item.username || '未标记', + userRole: item.userRole || 'unknown' + })).slice(0, MAX_RECORDS); + } + + function list() { + try { return normalize(JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]')); } + catch (_) { return []; } + } + + function write(records) { + const normalized = normalize(records); + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(normalized)); + return { ok:true, records:normalized }; + } catch (_) { return { ok:false, records:normalized }; } + } + + function add(record) { + const next = list(); + next.unshift({ + id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + username: record.username, + userRole: record.userRole, + platform: record.platform || '—', + pkg: record.pkg || '—', + type: record.type || 'click', + createdAt: new Date().toISOString(), + url: record.url + }); + return write(next); + } + + function remove(id) { return write(list().filter((item) => item.id !== id)); } + function replace(records) { return write(records); } + function clear() { return write([]); } + + root.LinkHistoryStore = { STORAGE_KEY, MAX_RECORDS, list, add, remove, replace, clear }; +})(typeof window === 'undefined' ? globalThis : window); diff --git a/assets/link-history.js b/assets/link-history.js new file mode 100644 index 0000000..30be85e --- /dev/null +++ b/assets/link-history.js @@ -0,0 +1,93 @@ +(() => { + const auth = window.TrafficAuth; + const store = window.LinkHistoryStore; + const current = auth.requireUser(); + if (!current) return; + const $ = (id) => document.getElementById(id); + let records = []; + + function ensureSession() { + const active = auth.currentUser(); + if (active?.username === current.username) return true; + location.replace('login.html?next=link-history.html'); + return false; + } + + auth.watchSession(() => location.replace('login.html?next=link-history.html')); + + function escapeHtml(value) { + return String(value).replace(/[&<>"']/g, (char) => ({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''' })[char]); + } + + function formatTime(value) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return '—'; + return new Intl.DateTimeFormat('zh-CN', { year:'numeric', month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit', second:'2-digit', hour12:false }).format(date); + } + + function scopedRecords() { + const all = store.list(); + return current.role === 'admin' ? all : all.filter((item) => item.username === current.username); + } + + function filteredRecords() { + const query = $('historySearch').value.trim().toLowerCase(); + return query ? records.filter((item) => `${item.username} ${item.platform} ${item.pkg} ${item.url}`.toLowerCase().includes(query)) : records; + } + + function render() { + records = scopedRecords(); + const filtered = filteredRecords(); + $('historyTotal').textContent = records.length; + $('filterCount').textContent = `显示 ${filtered.length} 条`; + $('clearHistory').disabled = records.length === 0; + $('clearHistory').textContent = current.role === 'admin' ? '清空本机全部记录' : '清空我的记录'; + $('historyEmpty').classList.toggle('hidden', records.length > 0); + $('historyTableWrap').classList.toggle('hidden', records.length === 0); + $('historyTableBody').innerHTML = filtered.length ? filtered.map((item) => ` + + ${escapeHtml(item.username)} + ${escapeHtml(item.platform)} + ${escapeHtml(item.pkg)} + + + `).join('') : '没有匹配的链接记录'; + } + + async function copyText(text) { + try { await navigator.clipboard.writeText(text); } + catch (_) { + const area = document.createElement('textarea'); area.value = text; + area.style.position = 'fixed'; area.style.opacity = '0'; + document.body.appendChild(area); area.select(); document.execCommand('copy'); area.remove(); + } + } + + function showToast(message) { + const toast = $('toast'); toast.textContent = message; toast.classList.add('show'); + clearTimeout(showToast.timer); showToast.timer = setTimeout(() => toast.classList.remove('show'), 1800); + } + + $('currentUser').innerHTML = `${escapeHtml(current.username)}${current.role === 'admin' ? '管理员' : '普通用户'}`; + $('historyScope').textContent = current.role === 'admin' ? '管理员可查看当前浏览器内全部用户的记录。' : '普通用户仅可查看当前浏览器内自己的记录。'; + $('logoutBtn').addEventListener('click', () => { auth.logout(); location.href = 'login.html'; }); + $('historySearch').addEventListener('input', render); + $('clearHistory').addEventListener('click', () => { + if (!ensureSession()) return; + if (!records.length || !window.confirm(current.role === 'admin' ? '确定清空当前浏览器的全部链接记录吗?' : '确定清空你的全部链接记录吗?')) return; + const result = current.role === 'admin' ? store.clear() : store.replace(store.list().filter((item) => item.username !== current.username)); + if (!result.ok) return showToast('清空失败,请检查浏览器存储权限'); + $('historySearch').value = ''; render(); showToast('链接记录已清空'); + }); + $('historyTableBody').addEventListener('click', async (event) => { + if (!ensureSession()) return; + const button = event.target.closest('button[data-action]'); + if (!button) return; + const item = records.find((record) => record.id === button.dataset.id); + if (!item) return; + if (button.dataset.action === 'copy') { await copyText(item.url); showToast('链接已复制'); } + else if (button.dataset.action === 'delete') { if (store.remove(item.id).ok) render(); } + }); + window.addEventListener('storage', (event) => { if (event.key === store.STORAGE_KEY) render(); }); + render(); +})(); diff --git a/assets/login.js b/assets/login.js new file mode 100644 index 0000000..544838d --- /dev/null +++ b/assets/login.js @@ -0,0 +1,105 @@ +(() => { + const auth = window.TrafficAuth; + const $ = (id) => document.getElementById(id); + const enrollmentByUser = new Map(); + const userSelect = $('loginUser'); + + auth.users.forEach((user) => userSelect.add(new Option(`${user.username} · ${user.role === 'admin' ? '管理员' : '普通用户'}`, user.username))); + + function nextPage() { + const next = new URLSearchParams(location.search).get('next'); + return ['link-generator.html', 'link-history.html'].includes(next) ? next : 'link-generator.html'; + } + + function showToast(message) { + const toast = $('toast'); + toast.textContent = message; toast.classList.add('show'); + setTimeout(() => toast.classList.remove('show'), 1600); + } + + function currentEnrollment() { + const username = userSelect.value; + if (!enrollmentByUser.has(username)) enrollmentByUser.set(username, auth.createEnrollment(username)); + return enrollmentByUser.get(username); + } + + function renderMode() { + const username = userSelect.value; + const user = auth.findUser(username); + const enrolled = auth.isEnrolled(username); + $('selectedRole').innerHTML = `${user.role === 'admin' ? '管理员' : '普通用户'}${enrolled ? '已在当前浏览器绑定' : '首次使用,需要完成本机设置'}`; + $('signinForm').classList.toggle('hidden', !enrolled); + $('enrollForm').classList.toggle('hidden', enrolled); + $('loginTitle').textContent = enrolled ? '用户登录' : '首次绑定'; + $('loginSubtitle').textContent = enrolled ? '密码和动态验证码双重验证' : '设置密码并绑定 Google Authenticator'; + $('signinMessage').textContent = ''; + $('enrollMessage').textContent = ''; + if (!enrolled) { + const enrollment = currentEnrollment(); + $('setupKey').textContent = enrollment.secret.match(/.{1,4}/g).join(' '); + $('authAccountName').textContent = `Traffic Intelligence (${username})`; + } + } + + $('signinForm').addEventListener('submit', async (event) => { + event.preventDefault(); + const button = event.submitter; + button.disabled = true; + $('signinMessage').textContent = '正在验证…'; + try { + await auth.login(userSelect.value, $('signinPassword').value, $('signinCode').value); + location.href = nextPage(); + } catch (error) { + $('signinMessage').textContent = error.message; + button.disabled = false; + } + }); + + $('enrollForm').addEventListener('submit', async (event) => { + event.preventDefault(); + const password = $('newPassword').value; + if (password !== $('confirmPassword').value) { + $('enrollMessage').textContent = '两次输入的密码不一致'; + return; + } + const button = event.submitter; + button.disabled = true; + $('enrollMessage').textContent = '正在绑定…'; + try { + await auth.enroll(userSelect.value, password, currentEnrollment().secret, $('setupCode').value); + location.href = nextPage(); + } catch (error) { + $('enrollMessage').textContent = error.message; + button.disabled = false; + } + }); + + $('copySetupKey').addEventListener('click', async () => { + const secret = currentEnrollment().secret; + try { await navigator.clipboard.writeText(secret); } + catch (_) { + const area = document.createElement('textarea'); area.value = secret; + document.body.appendChild(area); area.select(); document.execCommand('copy'); area.remove(); + } + showToast('设置密钥已复制'); + }); + + $('resetEnrollment').addEventListener('click', () => { + const username = userSelect.value; + if (!window.confirm(`确定清除 ${username} 在当前浏览器的密码和 Authenticator 绑定吗?`)) return; + try { + auth.clearEnrollment(username); + enrollmentByUser.delete(username); + $('signinPassword').value = ''; + $('signinCode').value = ''; + renderMode(); + showToast('本机绑定已清除,请重新设置'); + } catch (error) { + $('signinMessage').textContent = error.message; + } + }); + + userSelect.addEventListener('change', renderMode); + if (auth.currentUser()) location.replace(nextPage()); + else renderMode(); +})(); diff --git a/assets/styles.css b/assets/styles.css index 893a558..30e0cd0 100644 --- a/assets/styles.css +++ b/assets/styles.css @@ -9,3 +9,10 @@ .generator-stack{display:flex;flex-direction:column;gap:18px}.converter-input-panel{padding:26px}.config-topline{gap:13px}.config-topline .outline-btn{margin-left:auto}.converter-toolbar{display:grid;grid-template-columns:1.3fr .7fr .7fr;gap:14px;margin-top:21px}.converter-toolbar label>span,.detect-card>span{display:block;font-size:9px;font-weight:700;margin-bottom:5px}.detect-card{height:65px;border:1px solid var(--line);border-radius:10px;background:#fafaf7;padding:9px 12px}.detect-card strong{display:flex;align-items:center;height:29px;font-size:11px}.textarea-label{display:flex;align-items:center;justify-content:space-between;margin:20px 0 7px}.textarea-label>span{font-size:10px;font-weight:700}.textarea-label small{font-size:9px;color:var(--muted)}.link-textarea{width:100%;min-height:132px;resize:vertical;border:1px solid var(--line);border-radius:12px;background:#fafaf7;padding:13px 14px;outline:none;font:10px/1.65 var(--mono);color:var(--ink)}.link-textarea:focus{border-color:var(--blue);box-shadow:0 0 0 3px rgba(111,140,255,.1)}.converter-actions{display:flex;align-items:center;gap:14px;margin-top:12px}.converter-actions .validation-line{margin:0 0 0 auto}.text-inline-btn{border:0;background:transparent;color:var(--blue-dark);font-size:10px}.type-chip{display:inline-block;background:#dfe5ff;color:#273fae;border-radius:99px;padding:3px 8px;font-size:9px}.macro-disclosure{overflow:hidden}.macro-disclosure summary{height:66px;display:flex;align-items:center;gap:13px;padding:0 23px;cursor:pointer;list-style:none}.macro-disclosure summary::-webkit-details-marker{display:none}.macro-disclosure summary:hover{background:#fafaf7}.disclosure-icon{width:28px;height:28px;border:1px solid var(--line);border-radius:50%;display:grid;place-items:center;font-size:16px;transition:transform .2s}.macro-disclosure[open] .disclosure-icon{transform:rotate(45deg)}.macro-disclosure summary>div{display:flex;align-items:center;gap:12px}.macro-disclosure summary strong{font-size:13px}.macro-disclosure summary>small{margin-left:auto;color:var(--muted);font-size:9px}.macro-content{border-top:1px solid var(--line);padding-top:15px}.macro-search{padding:0 23px 14px}.macro-search input{height:38px}.macro-table-wrap{max-height:330px;overflow:auto;border-top:1px solid var(--line)}table{width:100%;border-collapse:collapse}.macro-table th,.macro-table td,.mapping-table th,.mapping-table td{text-align:left;border-bottom:1px solid #eceeea;padding:10px 13px;font-size:9px}.macro-table th,.mapping-table th{position:sticky;top:0;background:#f8f8f5;color:var(--muted);font:8px var(--mono);z-index:1}.macro-table th:first-child{width:32%}.macro-table code,.mapping-table code{font-family:var(--mono);font-size:9px;color:#2e48bc;word-break:break-all}.table-empty{text-align:center!important;color:var(--muted);padding:30px!important}.template-status{display:inline-block;background:#eaf7c9;color:#5b7d0a;border-radius:99px;padding:3px 7px;font-size:8px}.template-status.review{background:#ffecdf;color:#b25329}.output-panel{padding:30px}.output-panel-wide{min-height:500px}.output-title{padding-bottom:16px;border-bottom:1px solid var(--line)}.live-chip{font:8px var(--mono);margin-left:auto;color:#4f7320;display:flex;align-items:center;gap:5px}.live-chip i{width:6px;height:6px;border-radius:50%;background:#7ab836}.conversion-empty{min-height:390px;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center}.conversion-empty>div{width:58px;height:58px;border:1px solid var(--line);border-radius:50%;display:grid;place-items:center;background:#f7f8ff;color:var(--blue-dark);font-size:24px}.conversion-empty h3{font-size:14px;margin:15px 0 4px}.conversion-empty p{font-size:10px;color:var(--muted);margin:0}.conversion-summary{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-top:18px}.conversion-summary div{background:#f5f5f1;border-radius:9px;padding:11px 13px;display:flex;flex-direction:column}.conversion-summary span{font:8px var(--mono);color:var(--muted)}.conversion-summary strong{font-size:15px;margin-top:2px}.mapping-table-wrap{max-height:330px;overflow:auto;margin-top:14px;border:1px solid var(--line);border-radius:10px}.conversion-table th:first-child{width:20%}.conversion-table th:nth-child(2){width:38%}.mapping-table td{padding:9px 12px;word-break:break-all}.old-param{font:9px var(--mono);color:#7b8581}.empty-param{font-size:8px;color:#9a4e2e;background:#ffecdf;border-radius:4px;padding:2px 5px}.output-label{display:flex;justify-content:space-between;align-items:center;margin:20px 0 8px;font-size:11px;font-weight:700}.output-label small{font:8px var(--mono);color:var(--muted);font-weight:400}.url-output{min-height:176px;max-height:280px;overflow:auto;white-space:pre-wrap;overflow-wrap:anywhere;margin:0;background:var(--ink);color:#dff28d;border-radius:12px;padding:18px;font:11px/1.75 var(--mono);border:1px solid #263b35}.output-actions{display:flex;align-items:center;gap:12px;margin-top:14px}.primary-btn:disabled{opacity:.45;cursor:not-allowed}.copy-status{font-size:9px;color:#4d7622}.history-panel{padding:27px}.history-heading{display:flex;align-items:flex-start;justify-content:space-between;padding-bottom:18px;border-bottom:1px solid var(--line)}.history-heading h2{font-size:20px;margin:3px 0 2px}.history-heading h2 em{display:inline-grid;place-items:center;min-width:24px;height:20px;padding:0 6px;border-radius:99px;background:#e5eaff;color:#2e48bc;font:9px var(--mono);font-style:normal;vertical-align:2px}.history-heading p{margin:0;color:var(--muted);font-size:9px}.history-empty{height:150px;display:flex;flex-direction:column;align-items:center;justify-content:center;color:var(--muted)}.history-empty span{font-size:27px}.history-empty p{font-size:10px;margin:5px 0}.history-table-wrap{overflow:auto}.history-table{min-width:900px}.history-table th,.history-table td{text-align:left;border-bottom:1px solid #eceeea;padding:12px 10px;font-size:9px}.history-table th{color:var(--muted);font:8px var(--mono);background:#fafaf7}.history-table th:first-child{width:145px}.history-table th:nth-child(2){width:105px}.history-table th:nth-child(3){width:190px}.history-table th:last-child{width:55px}.history-table time{color:var(--muted);white-space:nowrap}.history-platform{background:#eaf7c9;color:#58790a;border-radius:99px;padding:4px 7px;font-size:8px}.history-pkg{font:9px var(--mono);color:var(--ink)}.history-link{display:flex;align-items:center;gap:8px;min-width:300px}.history-link span{font:8px var(--mono);color:#5e6b67;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:470px}.history-link button,.delete-record{border:0;background:transparent;font-size:8px;white-space:nowrap}.history-link button{color:var(--blue-dark)}.delete-record{color:#b05b45}.toast{position:fixed;left:50%;bottom:30px;transform:translate(-50%,20px);opacity:0;pointer-events:none;background:var(--ink);color:#fff;padding:10px 16px;border-radius:99px;font-size:10px;transition:.25s;z-index:100}.toast.show{opacity:1;transform:translate(-50%,0)} @media(max-width:900px){.hero{grid-template-columns:1fr}.hero-visual{display:none}.recommendation-layout{grid-template-columns:1fr}.form-panel{position:static}.converter-toolbar{grid-template-columns:1fr 1fr}.converter-toolbar label{grid-column:1/-1}.main-nav{gap:18px}.method-note{flex-wrap:wrap}.method-note strong{width:100%}.history-link span{max-width:300px}} @media(max-width:620px){.shell{width:min(100% - 28px,1180px)}.site-header{height:68px;padding-inline:14px}.brand small,.home-link,.version-chip{display:none}.main-nav{gap:13px}.main-nav a{font-size:11px}.hero{min-height:auto;padding-block:55px 45px}.hero h1{font-size:39px}.hero-meta{display:none}.tool-grid{grid-template-columns:1fr}.tool-card{min-height:365px;padding:28px}.tool-cta{left:28px}.status-strip,.page-heading{align-items:flex-start;gap:17px;flex-direction:column}.status-note{display:none}.page-main{padding-block:32px 50px}.page-heading h1{font-size:34px}.data-badge{width:100%}.recommendation-layout{gap:14px}.form-panel,.result-panel,.converter-input-panel,.output-panel,.history-panel{padding:20px}.result-card{grid-template-columns:30px 1fr}.score-block{grid-column:2;justify-content:flex-start}.query-summary{grid-template-columns:1fr}.query-summary div+div{border-left:0;padding-left:0;border-top:1px solid var(--line);padding-top:8px}.method-note{display:none}.converter-toolbar{grid-template-columns:1fr}.converter-toolbar label{grid-column:auto}.textarea-label{align-items:flex-start;gap:4px;flex-direction:column}.converter-actions{align-items:flex-start;flex-wrap:wrap}.converter-actions .validation-line{width:100%;margin:0}.macro-disclosure summary{height:auto;min-height:66px;padding:14px 18px}.macro-disclosure summary>small{display:none}.conversion-summary{grid-template-columns:1fr}.history-heading{gap:15px}.history-heading p{max-width:220px}.history-table{min-width:820px}.site-footer{height:70px}.site-footer span:first-child{display:none}} + +/* Local user login, TOTP setup, and separate link history */ +.header-user{display:flex;align-items:center;gap:11px}.header-user>span{display:flex;flex-direction:column;text-align:right;line-height:1.15}.header-user b{font-size:10px}.header-user small{font-size:8px;color:var(--muted);margin-top:2px}.header-user button{height:30px;border:1px solid var(--line);border-radius:8px;background:#fff;color:var(--muted);font-size:9px;padding:0 10px}.header-user button:hover{border-color:var(--ink);color:var(--ink)} +.login-main{min-height:calc(100vh - 170px);display:grid;grid-template-columns:1fr 460px;gap:90px;align-items:center;padding-block:64px}.login-intro h1{font-size:clamp(46px,5vw,68px);line-height:1.06;letter-spacing:-.055em;margin:0}.login-intro h1 em{font-style:normal;color:var(--blue-dark)}.login-intro>p:not(.eyebrow){max-width:490px;color:var(--muted);font-size:14px}.login-note{display:flex;gap:11px;max-width:520px;margin-top:30px;padding:14px 16px;border:1px solid #e7c8b7;background:#fff6ef;border-radius:12px}.login-note>b{width:20px;height:20px;border-radius:50%;background:var(--orange);color:#fff;display:grid;place-items:center;font:10px var(--mono);flex:none}.login-note span{display:flex;flex-direction:column;font-size:9px;color:#8d5b45}.login-note strong{color:#713f2b;font-size:10px;margin-bottom:2px}.login-card{padding:31px}.auth-form{margin-top:19px;padding-top:2px;border-top:1px solid var(--line)}.selected-role{height:31px;display:flex;align-items:center;gap:8px}.selected-role small{font-size:8px;color:var(--muted)}.user-role{border-radius:99px;padding:3px 7px;font-size:8px}.user-role.admin{background:#dfe5ff;color:#2c46b5}.user-role.user{background:#eaf7c9;color:#58790a}.otp-input{font:18px var(--mono);letter-spacing:.32em;text-align:center}.auth-message{min-height:17px;margin-top:9px;color:#bf4936;font-size:9px}.danger-text{color:#a95a46}.setup-steps{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin:16px 0}.setup-steps>div{display:flex;gap:8px;background:#f6f6f2;border-radius:9px;padding:10px}.setup-steps b{width:20px;height:20px;border-radius:50%;background:var(--ink);color:#fff;display:grid;place-items:center;font:9px var(--mono);flex:none}.setup-steps p{display:flex;flex-direction:column;margin:0}.setup-steps strong{font-size:9px}.setup-steps span{font-size:8px;color:var(--muted)}.setup-key{display:flex;border:1px solid var(--line);border-radius:10px;background:#f5f5f1;overflow:hidden}.setup-key code{flex:1;padding:12px;font:11px var(--mono);color:#263fa8;word-break:break-all}.setup-key button{border:0;border-left:1px solid var(--line);background:#fff;color:var(--blue-dark);font-size:9px;padding:0 12px} +.history-shortcut{min-height:72px;padding:15px 21px;display:flex;align-items:center;gap:13px;transition:border-color .2s,transform .2s}.history-shortcut:hover{border-color:#9eaff4;transform:translateY(-2px)}.history-shortcut-icon{width:37px;height:37px;border-radius:50%;background:#e5eaff;color:var(--blue-dark);display:grid;place-items:center;font-size:19px}.history-shortcut p{display:flex;flex-direction:column;margin:0}.history-shortcut strong{font-size:12px}.history-shortcut small{font-size:9px;color:var(--muted)}.history-shortcut>b{margin-left:auto;min-width:26px;height:23px;padding:0 7px;border-radius:99px;background:#eaf7c9;color:#58790a;display:grid;place-items:center;font:9px var(--mono)}.history-shortcut>i{font-style:normal;font-size:17px}.history-page-panel{min-height:440px}.history-heading-actions{display:flex;align-items:center;gap:8px}.link-button{display:inline-flex;align-items:center;justify-content:center}.history-toolbar{display:flex;align-items:flex-end;gap:16px;padding:17px 0}.history-toolbar label{width:min(420px,100%)}.history-toolbar label>span{display:block;font-size:9px;font-weight:700;margin-bottom:5px}.history-toolbar>span{margin-left:auto;color:var(--muted);font:9px var(--mono);padding-bottom:12px}.history-empty{height:245px}.history-empty h3{font-size:13px;color:var(--ink);margin:9px 0 1px}.history-empty a{color:var(--blue-dark);font-size:10px}.auth-history-table{min-width:1030px}.auth-history-table th:first-child{width:145px}.auth-history-table th:nth-child(2){width:115px}.auth-history-table th:nth-child(3){width:95px}.auth-history-table th:nth-child(4){width:190px}.record-user{display:inline-block;border-radius:99px;background:#eef0ed;color:#42504b;padding:4px 7px;font:8px var(--mono)} +@media(max-width:900px){.login-main{grid-template-columns:1fr;gap:34px;padding-block:45px}.login-intro h1{font-size:48px}.login-intro>p:not(.eyebrow){max-width:620px}.login-card{width:min(100%,520px)}.header-user span small{display:none}} +@media(max-width:620px){.header-user>span{display:none}.header-user{gap:4px}.main-nav{gap:9px}.main-nav a{font-size:9px}.login-main{padding-block:38px}.login-intro h1{font-size:39px}.login-card{padding:22px}.setup-steps{grid-template-columns:1fr}.history-shortcut{padding:13px 15px}.history-shortcut small{display:none}.history-heading-actions{align-items:stretch;flex-direction:column}.history-toolbar{align-items:stretch;flex-direction:column}.history-toolbar>span{margin:0;padding:0}.auth-history-table{min-width:940px}} diff --git a/link-generator.html b/link-generator.html index c2852a4..d3b1620 100644 --- a/link-generator.html +++ b/link-generator.html @@ -3,20 +3,20 @@ - + 自投链接转换器 · Traffic Intelligence
-

TRACKING CONVERTER / 02

自投平台链接转换器

粘贴一条原始 Tracking Link,自动替换为 Mintegral 要求的宏参数。

+

TRACKING CONVERTER / 02

自投平台链接转换器

粘贴原始 Tracking Link,自动替换为 Mintegral 要求的宏参数。

— 个平台模板Mintegral Click URL 转换

@@ -30,30 +30,19 @@ -
- - - -
+
+
REFERENCEMintegral 平台宏参考
点击展开完整宏清单
-
- -
媒体宏说明
-
+
媒体宏说明
STEP 02

转换结果

AUTO
↳

等待原始链接

转换后将在这里展示参数替换明细与最终链接。

-
-
-
LINK SUMMARY

平台链接总结 0

保存已转换链接的平台、PKG、时间和最终结果,仅存储在当前浏览器。

- -
-
⌁

还没有转换记录

- -
+ ⌁

查看平台链接记录每条记录包含操作用户、平台、PKG、时间和最终链接

0→
+ + + diff --git a/link-history.html b/link-history.html new file mode 100644 index 0000000..dc680a9 --- /dev/null +++ b/link-history.html @@ -0,0 +1,39 @@ + + + + + + + 平台链接记录 · Traffic Intelligence + + + + + +
+
+

LINK SUMMARY / 03

平台链接记录

查看当前浏览器保存的链接记录。

+

0 条记录当前浏览器本地记录

+
+ +
+
BROWSER STORAGE

平台链接总结

记录操作用户、平台、PKG、转换时间和最终链接。

+
显示 0 条
+
⌁

还没有转换记录

返回链接转换器完成一次转换后,记录会自动出现在这里。

去转换链接 →
+ +
+
+ +
+ + + + + + + + diff --git a/login.html b/login.html new file mode 100644 index 0000000..f884c6a --- /dev/null +++ b/login.html @@ -0,0 +1,66 @@ + + + + + + + 用户登录 · Traffic Intelligence + + + + + +
+ + + +
+ +
+ + + + + +