feat: add local TOTP user tracking
This commit is contained in:
+219
@@ -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);
|
||||
@@ -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
@@ -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) => ({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''' })[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();
|
||||
})();
|
||||
|
||||
@@ -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);
|
||||
@@ -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) => `<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
@@ -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
Reference in New Issue
Block a user