feat: add local TOTP user tracking
This commit is contained in:
@@ -6,10 +6,15 @@
|
|||||||
|
|
||||||
- `index.html`:工具入口
|
- `index.html`:工具入口
|
||||||
- `channel-recommendation.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/channel-rules.js`:品类、国家、区域和渠道评分规则
|
||||||
- `assets/data/link-templates.js`:媒体宏与链接参数模板
|
- `assets/data/link-templates.js`:媒体宏与链接参数模板
|
||||||
|
|
||||||
|
|||||||
+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 templates = window.LINK_TEMPLATES;
|
||||||
|
const historyStore = window.LinkHistoryStore;
|
||||||
if (!templates?.length) return;
|
if (!templates?.length) return;
|
||||||
|
|
||||||
const { convertTrackingLink } = window.TrackingLinkConverter;
|
const { convertTrackingLink } = window.TrackingLinkConverter;
|
||||||
const HISTORY_KEY = 'traffic_link_history_v1';
|
|
||||||
const MAX_HISTORY = 50;
|
|
||||||
const $ = (id) => document.getElementById(id);
|
const $ = (id) => document.getElementById(id);
|
||||||
const platformSelect = $('platformSelect');
|
const platformSelect = $('platformSelect');
|
||||||
const macroBody = $('macroTableBody');
|
const macroBody = $('macroTableBody');
|
||||||
const output = $('urlOutput');
|
const output = $('urlOutput');
|
||||||
let activeResult = null;
|
let activeResult = null;
|
||||||
let history = loadHistory();
|
|
||||||
let inputTimer;
|
let inputTimer;
|
||||||
|
|
||||||
|
$('currentUser').innerHTML = `<b>${escapeHtml(current.username)}</b><small>${current.role === 'admin' ? '管理员' : '普通用户'}</small>`;
|
||||||
$('templateCount').textContent = `${templates.length} 个平台模板`;
|
$('templateCount').textContent = `${templates.length} 个平台模板`;
|
||||||
templates.forEach((template, index) => platformSelect.add(new Option(template.name, index)));
|
templates.forEach((template, index) => platformSelect.add(new Option(template.name, index)));
|
||||||
|
|
||||||
const activeTemplate = () => templates[Number(platformSelect.value || 0)];
|
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() {
|
function renderPlatform() {
|
||||||
const template = activeTemplate();
|
const template = activeTemplate();
|
||||||
const review = template.status.includes('待');
|
const review = template.status.includes('待');
|
||||||
@@ -31,18 +51,12 @@
|
|||||||
const allMacros = activeTemplate().macros;
|
const allMacros = activeTemplate().macros;
|
||||||
const rows = allMacros.filter(([macro, desc]) => `${macro} ${desc}`.toLowerCase().includes(query));
|
const rows = allMacros.filter(([macro, desc]) => `${macro} ${desc}`.toLowerCase().includes(query));
|
||||||
$('macroCount').textContent = `${allMacros.length} 个可用宏 · 点击展开`;
|
$('macroCount').textContent = `${allMacros.length} 个可用宏 · 点击展开`;
|
||||||
macroBody.innerHTML = rows.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>';
|
||||||
? 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) {
|
function renderChanges(changes) {
|
||||||
const rows = changes.length ? changes : [{ key:'—', from:'原链接参数无需变化', to:'—' }];
|
const rows = changes.length ? changes : [{ key:'—', from:'原链接参数无需变化', to:'—' }];
|
||||||
$('mappingTableBody').innerHTML = rows.map((change) => `<tr>
|
$('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('');
|
||||||
<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() {
|
function showEmpty() {
|
||||||
@@ -72,7 +86,6 @@
|
|||||||
if (showRequiredMessage) $('generatorMessage').textContent = '请先粘贴一条完整的 Tracking Link';
|
if (showRequiredMessage) $('generatorMessage').textContent = '请先粘贴一条完整的 Tracking Link';
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = convertTrackingLink(original, activeTemplate());
|
const result = convertTrackingLink(original, activeTemplate());
|
||||||
activeResult = result;
|
activeResult = result;
|
||||||
@@ -95,11 +108,19 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractPkg(url) {
|
||||||
|
try { return new URL(url).searchParams.get('pkg') || '—'; }
|
||||||
|
catch (_) { return '—'; }
|
||||||
|
}
|
||||||
|
|
||||||
function convertAndSave() {
|
function convertAndSave() {
|
||||||
|
if (!ensureSession()) return;
|
||||||
const result = convertFromInput(true);
|
const result = convertFromInput(true);
|
||||||
if (!result) return;
|
if (!result) return;
|
||||||
saveHistory(result);
|
const saved = historyStore.add({ username:current.username, userRole:current.role, platform:activeTemplate().name, pkg:extractPkg(result.url), type:result.type, url:result.url });
|
||||||
$('generatorMessage').textContent = '转换完成,已加入平台链接总结';
|
if (!saved.ok) return showToast('转换成功,但浏览器无法保存记录');
|
||||||
|
updateHistoryCount();
|
||||||
|
$('generatorMessage').textContent = '转换完成,已记录操作用户';
|
||||||
showToast('转换完成并已保存记录');
|
showToast('转换完成并已保存记录');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,130 +129,39 @@
|
|||||||
convertFromInput(false);
|
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) {
|
async function copyText(text) {
|
||||||
try { await navigator.clipboard.writeText(text); }
|
try { await navigator.clipboard.writeText(text); }
|
||||||
catch (_) {
|
catch (_) {
|
||||||
const area = document.createElement('textarea');
|
const area = document.createElement('textarea'); area.value = text;
|
||||||
area.value = text;
|
area.style.position = 'fixed'; area.style.opacity = '0';
|
||||||
area.style.position = 'fixed';
|
document.body.appendChild(area); area.select(); document.execCommand('copy'); area.remove();
|
||||||
area.style.opacity = '0';
|
|
||||||
document.body.appendChild(area);
|
|
||||||
area.select();
|
|
||||||
document.execCommand('copy');
|
|
||||||
area.remove();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyUrl() {
|
async function copyUrl() {
|
||||||
if (!activeResult) return;
|
if (!activeResult || !ensureSession()) return;
|
||||||
await copyText(activeResult.url);
|
await copyText(activeResult.url);
|
||||||
$('copyStatus').textContent = '已复制';
|
$('copyStatus').textContent = '已复制'; showToast('转换后的平台链接已复制');
|
||||||
showToast('转换后的平台链接已复制');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function showToast(message) {
|
function showToast(message) {
|
||||||
const toast = $('toast');
|
const toast = $('toast'); toast.textContent = message; toast.classList.add('show');
|
||||||
toast.textContent = message;
|
clearTimeout(showToast.timer); showToast.timer = setTimeout(() => toast.classList.remove('show'), 1800);
|
||||||
toast.classList.add('show');
|
|
||||||
clearTimeout(showToast.timer);
|
|
||||||
showToast.timer = setTimeout(() => toast.classList.remove('show'), 1800);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(value) {
|
function escapeHtml(value) {
|
||||||
return String(value).replace(/[&<>"']/g, (char) => ({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''' })[char]);
|
return String(value).replace(/[&<>"']/g, (char) => ({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''' })[char]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$('logoutBtn').addEventListener('click', () => { auth.logout(); location.href = 'login.html'; });
|
||||||
platformSelect.addEventListener('change', renderPlatform);
|
platformSelect.addEventListener('change', renderPlatform);
|
||||||
$('macroSearch').addEventListener('input', renderMacros);
|
$('macroSearch').addEventListener('input', renderMacros);
|
||||||
$('convertLink').addEventListener('click', convertAndSave);
|
$('convertLink').addEventListener('click', convertAndSave);
|
||||||
$('sampleLink').addEventListener('click', loadSample);
|
$('sampleLink').addEventListener('click', loadSample);
|
||||||
$('copyLink').addEventListener('click', copyUrl);
|
$('copyLink').addEventListener('click', copyUrl);
|
||||||
$('clearConverter').addEventListener('click', () => { $('originalLink').value = ''; showEmpty(); $('originalLink').focus(); });
|
$('clearConverter').addEventListener('click', () => { $('originalLink').value = ''; showEmpty(); $('originalLink').focus(); });
|
||||||
$('clearHistory').addEventListener('click', () => {
|
$('originalLink').addEventListener('input', () => { clearTimeout(inputTimer); inputTimer = setTimeout(() => convertFromInput(false), 220); });
|
||||||
if (!history.length || !window.confirm('确定清空全部平台链接记录吗?')) return;
|
window.addEventListener('storage', (event) => { if (event.key === historyStore.STORAGE_KEY) updateHistoryCount(); });
|
||||||
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);
|
|
||||||
});
|
|
||||||
|
|
||||||
renderPlatform();
|
renderPlatform(); showEmpty(); updateHistoryCount();
|
||||||
showEmpty();
|
|
||||||
renderHistory();
|
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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
+11
-28
@@ -3,20 +3,20 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<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>
|
<title>自投链接转换器 · Traffic Intelligence</title>
|
||||||
<link rel="stylesheet" href="assets/styles.css">
|
<link rel="stylesheet" href="assets/styles.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="site-header inner-header">
|
<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>
|
<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>
|
<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>
|
||||||
<a class="home-link" href="index.html">返回首页</a>
|
<div class="header-user"><span id="currentUser"></span><button id="logoutBtn" type="button">退出</button></div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="shell page-main">
|
<main class="shell page-main">
|
||||||
<section class="page-heading">
|
<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>
|
<div class="data-badge"><span class="pulse-dot"></span><p><strong id="templateCount">— 个平台模板</strong><small>Mintegral Click URL 转换</small></p></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -30,30 +30,19 @@
|
|||||||
</div>
|
</div>
|
||||||
<label class="textarea-label" for="originalLink"><span>原始 Tracking Link *</span><small>原有业务参数与固定值会保留</small></label>
|
<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>
|
<textarea id="originalLink" class="link-textarea" rows="6" spellcheck="false" placeholder="在这里粘贴完整的 Mintegral Click Link…"></textarea>
|
||||||
<div class="converter-actions">
|
<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>
|
||||||
<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>
|
</section>
|
||||||
|
|
||||||
<details class="panel macro-disclosure">
|
<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>
|
<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-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-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>
|
</details>
|
||||||
|
|
||||||
<section class="panel output-panel output-panel-wide">
|
<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="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 class="conversion-empty" id="conversionEmpty"><div>↳</div><h3>等待原始链接</h3><p>转换后将在这里展示参数替换明细与最终链接。</p></div>
|
||||||
<div id="conversionResult" class="hidden">
|
<div id="conversionResult" class="hidden">
|
||||||
<div class="conversion-summary">
|
<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><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="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>
|
<div class="output-label"><span>最终平台链接</span><small id="urlLength">0 chars</small></div>
|
||||||
<pre class="url-output" id="urlOutput"></pre>
|
<pre class="url-output" id="urlOutput"></pre>
|
||||||
@@ -61,23 +50,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel history-panel">
|
<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>
|
||||||
<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>
|
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<div class="toast" id="toast" role="status"></div>
|
<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>
|
<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/data/link-templates.js"></script>
|
||||||
<script src="assets/link-converter-core.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/common.js"></script>
|
||||||
<script src="assets/link-generator.js"></script>
|
<script src="assets/link-generator.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
Reference in New Issue
Block a user