feat: add local TOTP user tracking

This commit is contained in:
王源
2026-10-20 16:39:15 +08:00
parent 903b058340
commit b735afbbfa
11 changed files with 657 additions and 145 deletions
+6 -1
View File
@@ -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`:媒体宏与链接参数模板
+219
View File
@@ -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);
+18
View File
@@ -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' }
];
+46 -116
View File
@@ -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 = `<b>${escapeHtml(current.username)}</b><small>${current.role === 'admin' ? '管理员' : '普通用户'}</small>`;
$('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]) => `<tr><td><code>${escapeHtml(macro)}</code></td><td>${escapeHtml(desc)}</td></tr>`).join('')
: '<tr><td colspan="2" class="table-empty">没有匹配的媒体宏</td></tr>';
macroBody.innerHTML = rows.length ? rows.map(([macro, desc]) => `<tr><td><code>${escapeHtml(macro)}</code></td><td>${escapeHtml(desc)}</td></tr>`).join('') : '<tr><td colspan="2" class="table-empty">没有匹配的媒体宏</td></tr>';
}
function renderChanges(changes) {
const rows = changes.length ? changes : [{ key:'—', from:'原链接参数无需变化', to:'—' }];
$('mappingTableBody').innerHTML = rows.map((change) => `<tr>
<td><code>${escapeHtml(change.key)}</code></td>
<td><span class="old-param">${escapeHtml(change.from || '(空值)')}</span></td>
<td>${change.to ? `<code>${escapeHtml(change.to)}</code>` : '<span class="empty-param">留空</span>'}</td>
</tr>`).join('');
$('mappingTableBody').innerHTML = rows.map((change) => `<tr><td><code>${escapeHtml(change.key)}</code></td><td><span class="old-param">${escapeHtml(change.from || '(空值)')}</span></td><td>${change.to ? `<code>${escapeHtml(change.to)}</code>` : '<span class="empty-param">留空</span>'}</td></tr>`).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) => `<tr>
<td><time datetime="${escapeHtml(item.createdAt)}">${escapeHtml(formatTime(item.createdAt))}</time></td>
<td><span class="history-platform">${escapeHtml(item.platform)}</span></td>
<td><code class="history-pkg">${escapeHtml(item.pkg)}</code></td>
<td><div class="history-link"><span title="${escapeHtml(item.url)}">${escapeHtml(item.url)}</span><button type="button" data-action="copy" data-id="${escapeHtml(item.id)}">复制</button></div></td>
<td><button class="delete-record" type="button" data-action="delete" data-id="${escapeHtml(item.id)}" aria-label="删除这条记录">删除</button></td>
</tr>`).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) => ({ '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;' })[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();
})();
+47
View File
@@ -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);
+93
View File
@@ -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) => ({ '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;' })[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) => `<tr>
<td><time datetime="${escapeHtml(item.createdAt)}">${escapeHtml(formatTime(item.createdAt))}</time></td>
<td><span class="record-user">${escapeHtml(item.username)}</span></td>
<td><span class="history-platform">${escapeHtml(item.platform)}</span></td>
<td><code class="history-pkg">${escapeHtml(item.pkg)}</code></td>
<td><div class="history-link"><span title="${escapeHtml(item.url)}">${escapeHtml(item.url)}</span><button type="button" data-action="copy" data-id="${escapeHtml(item.id)}">复制</button></div></td>
<td><button class="delete-record" type="button" data-action="delete" data-id="${escapeHtml(item.id)}">删除</button></td>
</tr>`).join('') : '<tr><td colspan="6" class="table-empty">没有匹配的链接记录</td></tr>';
}
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 = `<b>${escapeHtml(current.username)}</b><small>${current.role === 'admin' ? '管理员' : '普通用户'}</small>`;
$('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();
})();
+105
View File
@@ -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 = `<span class="user-role ${user.role}">${user.role === 'admin' ? '管理员' : '普通用户'}</span><small>${enrolled ? '已在当前浏览器绑定' : '首次使用,需要完成本机设置'}</small>`;
$('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();
})();
File diff suppressed because one or more lines are too long
+11 -28
View File
@@ -3,20 +3,20 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="粘贴原始 Tracking Link,自动替换为 Mintegral 平台宏">
<meta name="description" content="登录后将原始 Tracking Link 转换为 Mintegral 平台宏">
<title>自投链接转换器 · Traffic Intelligence</title>
<link rel="stylesheet" href="assets/styles.css">
</head>
<body>
<header class="site-header inner-header">
<a class="brand" href="index.html"><span class="brand-mark" aria-hidden="true"><i></i><i></i><i></i></span><span><strong>Traffic Intelligence</strong><small>投放决策工作台</small></span></a>
<nav class="main-nav" aria-label="主导航"><a href="channel-recommendation.html">渠道建议</a><a class="active" href="link-generator.html">链接生成器</a></nav>
<a class="home-link" href="index.html">返回首页</a>
<nav class="main-nav" aria-label="主导航"><a href="channel-recommendation.html">渠道建议</a><a class="active" href="link-generator.html">链接转换</a><a href="link-history.html">链接记录</a></nav>
<div class="header-user"><span id="currentUser"></span><button id="logoutBtn" type="button">退出</button></div>
</header>
<main class="shell page-main">
<section class="page-heading">
<div><p class="eyebrow"><span></span> TRACKING CONVERTER / 02</p><h1>自投平台链接转换器</h1><p>粘贴一条原始 Tracking Link,自动替换为 Mintegral 要求的宏参数。</p></div>
<div><p class="eyebrow"><span></span> TRACKING CONVERTER / 02</p><h1>自投平台链接转换器</h1><p>粘贴原始 Tracking Link,自动替换为 Mintegral 要求的宏参数。</p></div>
<div class="data-badge"><span class="pulse-dot"></span><p><strong id="templateCount">— 个平台模板</strong><small>Mintegral Click URL 转换</small></p></div>
</section>
@@ -30,30 +30,19 @@
</div>
<label class="textarea-label" for="originalLink"><span>原始 Tracking Link *</span><small>原有业务参数与固定值会保留</small></label>
<textarea id="originalLink" class="link-textarea" rows="6" spellcheck="false" placeholder="在这里粘贴完整的 Mintegral Click Link…"></textarea>
<div class="converter-actions">
<button class="primary-btn" id="convertLink" type="button"><span>转换为平台链接</span><b>→</b></button>
<button class="text-inline-btn" id="sampleLink" type="button">载入示例链接</button>
<span class="validation-line" id="generatorMessage" role="alert"></span>
</div>
<div class="converter-actions"><button class="primary-btn" id="convertLink" type="button"><span>转换为平台链接</span><b>→</b></button><button class="text-inline-btn" id="sampleLink" type="button">载入示例链接</button><span class="validation-line" id="generatorMessage" role="alert"></span></div>
</section>
<details class="panel macro-disclosure">
<summary><span class="disclosure-icon">+</span><div><span class="section-index">REFERENCE</span><strong>Mintegral 平台宏参考</strong></div><small id="macroCount">点击展开完整宏清单</small></summary>
<div class="macro-content">
<div class="macro-search"><input id="macroSearch" placeholder="搜索宏或说明…" aria-label="搜索媒体宏"></div>
<div class="macro-table-wrap"><table class="macro-table"><thead><tr><th>媒体宏</th><th>说明</th></tr></thead><tbody id="macroTableBody"></tbody></table></div>
</div>
<div class="macro-content"><div class="macro-search"><input id="macroSearch" placeholder="搜索宏或说明…" aria-label="搜索媒体宏"></div><div class="macro-table-wrap"><table class="macro-table"><thead><tr><th>媒体宏</th><th>说明</th></tr></thead><tbody id="macroTableBody"></tbody></table></div></div>
</details>
<section class="panel output-panel output-panel-wide">
<div class="config-topline output-title"><span class="section-index">STEP 02</span><h2>转换结果</h2><span class="live-chip"><i></i> AUTO</span></div>
<div class="conversion-empty" id="conversionEmpty"><div>↳</div><h3>等待原始链接</h3><p>转换后将在这里展示参数替换明细与最终链接。</p></div>
<div id="conversionResult" class="hidden">
<div class="conversion-summary">
<div><span>链接类型</span><strong id="summaryType">—</strong></div>
<div><span>已替换参数</span><strong id="replacementCount">0</strong></div>
<div><span>保留参数</span><strong id="preservedCount">0</strong></div>
</div>
<div class="conversion-summary"><div><span>链接类型</span><strong id="summaryType">—</strong></div><div><span>已替换参数</span><strong id="replacementCount">0</strong></div><div><span>保留参数</span><strong id="preservedCount">0</strong></div></div>
<div class="mapping-table-wrap"><table class="mapping-table conversion-table"><thead><tr><th>参数 Key</th><th>原始值</th><th>替换后的平台宏</th></tr></thead><tbody id="mappingTableBody"></tbody></table></div>
<div class="output-label"><span>最终平台链接</span><small id="urlLength">0 chars</small></div>
<pre class="url-output" id="urlOutput"></pre>
@@ -61,23 +50,17 @@
</div>
</section>
<section class="panel history-panel">
<div class="history-heading">
<div><span class="section-index">LINK SUMMARY</span><h2>平台链接总结 <em id="historyCount">0</em></h2><p>保存已转换链接的平台、PKG、时间和最终结果,仅存储在当前浏览器。</p></div>
<button class="outline-btn" id="clearHistory" type="button" disabled>清空记录</button>
</div>
<div class="history-empty" id="historyEmpty"><span>⌁</span><p>还没有转换记录</p></div>
<div class="history-table-wrap hidden" id="historyTableWrap">
<table class="history-table"><thead><tr><th>转换时间</th><th>平台</th><th>PKG</th><th>最终链接</th><th>操作</th></tr></thead><tbody id="historyTableBody"></tbody></table>
</div>
</section>
<a class="panel history-shortcut" href="link-history.html"><span class="history-shortcut-icon">⌁</span><p><strong>查看平台链接记录</strong><small>每条记录包含操作用户、平台、PKG、时间和最终链接</small></p><b id="historyCountBadge">0</b><i>→</i></a>
</section>
</main>
<div class="toast" id="toast" role="status"></div>
<footer class="site-footer shell"><span>Traffic Intelligence Hub</span><span>© <b data-year></b> Media Operations</span></footer>
<script src="assets/data/users.js"></script>
<script src="assets/auth.js"></script>
<script src="assets/data/link-templates.js"></script>
<script src="assets/link-converter-core.js"></script>
<script src="assets/link-history-store.js"></script>
<script src="assets/common.js"></script>
<script src="assets/link-generator.js"></script>
</body>
+39
View File
@@ -0,0 +1,39 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="按用户查看 Mintegral 平台链接转换记录">
<title>平台链接记录 · Traffic Intelligence</title>
<link rel="stylesheet" href="assets/styles.css">
</head>
<body>
<header class="site-header inner-header">
<a class="brand" href="index.html"><span class="brand-mark" aria-hidden="true"><i></i><i></i><i></i></span><span><strong>Traffic Intelligence</strong><small>投放决策工作台</small></span></a>
<nav class="main-nav" aria-label="主导航"><a href="channel-recommendation.html">渠道建议</a><a href="link-generator.html">链接转换</a><a class="active" href="link-history.html">链接记录</a></nav>
<div class="header-user"><span id="currentUser"></span><button id="logoutBtn" type="button">退出</button></div>
</header>
<main class="shell page-main">
<section class="page-heading">
<div><p class="eyebrow"><span></span> LINK SUMMARY / 03</p><h1>平台链接记录</h1><p id="historyScope">查看当前浏览器保存的链接记录。</p></div>
<div class="data-badge"><span class="pulse-dot"></span><p><strong><span id="historyTotal">0</span> 条记录</strong><small>当前浏览器本地记录</small></p></div>
</section>
<section class="panel history-panel history-page-panel">
<div class="history-heading"><div><span class="section-index">BROWSER STORAGE</span><h2>平台链接总结</h2><p>记录操作用户、平台、PKG、转换时间和最终链接。</p></div><div class="history-heading-actions"><a class="outline-btn link-button" href="link-generator.html">← 返回转换器</a><button class="outline-btn" id="clearHistory" type="button" disabled>清空记录</button></div></div>
<div class="history-toolbar"><label><span>搜索记录</span><input id="historySearch" type="search" placeholder="搜索用户、平台、PKG 或链接…"></label><span id="filterCount">显示 0 条</span></div>
<div class="history-empty" id="historyEmpty"><span>⌁</span><h3>还没有转换记录</h3><p>返回链接转换器完成一次转换后,记录会自动出现在这里。</p><a href="link-generator.html">去转换链接 →</a></div>
<div class="history-table-wrap hidden" id="historyTableWrap"><table class="history-table auth-history-table"><thead><tr><th>转换时间</th><th>操作用户</th><th>平台</th><th>PKG</th><th>最终链接</th><th>操作</th></tr></thead><tbody id="historyTableBody"></tbody></table></div>
</section>
</main>
<div class="toast" id="toast" role="status"></div>
<footer class="site-footer shell"><span>Traffic Intelligence Hub</span><span>© <b data-year></b> Media Operations</span></footer>
<script src="assets/data/users.js"></script>
<script src="assets/auth.js"></script>
<script src="assets/link-history-store.js"></script>
<script src="assets/common.js"></script>
<script src="assets/link-history.js"></script>
</body>
</html>
+66
View File
@@ -0,0 +1,66 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="Traffic Intelligence 用户登录与 Google Authenticator 验证">
<title>用户登录 · Traffic Intelligence</title>
<link rel="stylesheet" href="assets/styles.css">
</head>
<body class="login-page">
<header class="site-header">
<a class="brand" href="index.html"><span class="brand-mark" aria-hidden="true"><i></i><i></i><i></i></span><span><strong>Traffic Intelligence</strong><small>投放决策工作台</small></span></a>
<a class="home-link" href="index.html">返回首页</a>
</header>
<main class="login-main shell">
<section class="login-intro">
<p class="eyebrow"><span></span> IDENTITY CHECK</p>
<h1>登录后开始<br><em>转换链接。</em></h1>
<p>请选择自己的账号,并使用密码和 Google Authenticator 动态码完成验证。</p>
<div class="login-note"><b>!</b><span><strong>简易本机验证</strong>账号绑定、登录状态和链接记录仅保存在当前浏览器,不属于服务端安全认证。</span></div>
</section>
<section class="panel login-card">
<div class="panel-title"><span>ACCOUNT</span><h2 id="loginTitle">用户登录</h2><p id="loginSubtitle">选择你的账号</p></div>
<label class="field-label" for="loginUser">用户账号</label>
<select id="loginUser"></select>
<div class="selected-role" id="selectedRole"></div>
<form id="signinForm" class="auth-form hidden" novalidate>
<label class="field-label" for="signinPassword">登录密码</label>
<input id="signinPassword" type="password" autocomplete="current-password" placeholder="输入本机登录密码">
<label class="field-label" for="signinCode">Google Authenticator 验证码</label>
<input id="signinCode" class="otp-input" inputmode="numeric" autocomplete="one-time-code" maxlength="6" placeholder="000000">
<div class="auth-message" id="signinMessage" role="alert"></div>
<button class="primary-btn full-btn" type="submit"><span>登录</span><b>→</b></button>
<button class="text-btn danger-text" id="resetEnrollment" type="button">忘记密码?重置这个账号的本机绑定</button>
</form>
<form id="enrollForm" class="auth-form hidden" novalidate>
<div class="setup-steps">
<div><b>1</b><p><strong>打开 Google Authenticator</strong><span>点击“+”并选择“输入设置密钥”</span></p></div>
<div><b>2</b><p><strong>填写账号和设置密钥</strong><span>类型选择“基于时间”</span></p></div>
</div>
<label class="field-label">Authenticator 设置密钥</label>
<div class="setup-key"><code id="setupKey">生成中…</code><button id="copySetupKey" type="button">复制</button></div>
<p class="field-help">账号名称:<strong id="authAccountName"></strong></p>
<label class="field-label" for="newPassword">设置本机登录密码</label>
<input id="newPassword" type="password" autocomplete="new-password" placeholder="至少 8 位">
<label class="field-label" for="confirmPassword">再次输入密码</label>
<input id="confirmPassword" type="password" autocomplete="new-password" placeholder="再次输入相同密码">
<label class="field-label" for="setupCode">输入 App 中显示的 6 位验证码</label>
<input id="setupCode" class="otp-input" inputmode="numeric" autocomplete="one-time-code" maxlength="6" placeholder="000000">
<div class="auth-message" id="enrollMessage" role="alert"></div>
<button class="primary-btn full-btn" type="submit"><span>完成绑定并登录</span><b>→</b></button>
</form>
</section>
</main>
<div class="toast" id="toast" role="status"></div>
<script src="assets/data/users.js"></script>
<script src="assets/auth.js"></script>
<script src="assets/common.js"></script>
<script src="assets/login.js"></script>
</body>
</html>