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)} |
- ${escapeHtml(item.url)} |
- |
-
`).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)} |
+ ${escapeHtml(item.url)} |
+ |
+
`).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
↳
等待原始链接
转换后将在这里展示参数替换明细与最终链接。
-
-
链接类型—
-
已替换参数0
-
保留参数0
-
+
最终平台链接0 chars
@@ -61,23 +50,17 @@
-
-
-
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
平台链接记录
查看当前浏览器保存的链接记录。
+
+
+
+
+ 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
+
+
+
+
+
+
+
+ IDENTITY CHECK
+ 登录后开始
转换链接。
+ 请选择自己的账号,并使用密码和 Google Authenticator 动态码完成验证。
+ !简易本机验证账号绑定、登录状态和链接记录仅保存在当前浏览器,不属于服务端安全认证。
+
+
+
+
+
+
+
+
+
+
+
+