483 lines
18 KiB
JavaScript
483 lines
18 KiB
JavaScript
(function attachSidepanelHostedSmsPoolManager(globalScope) {
|
|
const SEPARATOR = '----';
|
|
|
|
function createHostedSmsPoolManager(context = {}) {
|
|
const {
|
|
dom = {},
|
|
helpers = {},
|
|
state = {},
|
|
actions = {},
|
|
constants = {},
|
|
} = context;
|
|
|
|
const copyIcon = constants.copyIcon || '';
|
|
let renderedEntries = [];
|
|
let searchTerm = '';
|
|
let filterMode = 'all';
|
|
let loading = false;
|
|
let refreshQueued = false;
|
|
|
|
function normalizeText(value = '') {
|
|
return String(value || '').trim();
|
|
}
|
|
|
|
function normalizePoolText(value = '') {
|
|
return String(value || '')
|
|
.replace(/\r/g, '')
|
|
.split('\n')
|
|
.map((line) => line.trim())
|
|
.filter(Boolean)
|
|
.join('\n');
|
|
}
|
|
|
|
function normalizePoolPhone(value = '') {
|
|
return normalizeText(value);
|
|
}
|
|
|
|
function normalizePoolUrl(value = '') {
|
|
const rawValue = normalizeText(value);
|
|
if (!rawValue) {
|
|
return '';
|
|
}
|
|
try {
|
|
const parsed = new URL(rawValue);
|
|
parsed.searchParams.delete('t');
|
|
return parsed.toString();
|
|
} catch {
|
|
return rawValue
|
|
.replace(/([?&])t=\d+(?=(&|$))/i, '$1')
|
|
.replace(/[?&]$/g, '');
|
|
}
|
|
}
|
|
|
|
function formatPayPalLocalPhone(value = '') {
|
|
const rawValue = normalizeText(value);
|
|
const digits = rawValue.replace(/\D+/g, '');
|
|
if (rawValue.startsWith('+1') && digits.length === 11 && digits.startsWith('1')) {
|
|
return digits.slice(1);
|
|
}
|
|
return digits;
|
|
}
|
|
|
|
function buildKey(phone = '', verificationUrl = '') {
|
|
const normalizedPhone = normalizePoolPhone(phone);
|
|
const normalizedUrl = normalizePoolUrl(verificationUrl);
|
|
return normalizedPhone && normalizedUrl ? `${normalizedPhone}${SEPARATOR}${normalizedUrl}` : '';
|
|
}
|
|
|
|
function parseEntries(text = '') {
|
|
const lines = normalizePoolText(text).split('\n').filter(Boolean);
|
|
const seen = new Set();
|
|
const entries = [];
|
|
for (let index = 0; index < lines.length; index += 1) {
|
|
const line = lines[index];
|
|
const separatorIndex = line.indexOf(SEPARATOR);
|
|
const hasSeparator = separatorIndex > 0;
|
|
const phone = hasSeparator
|
|
? normalizePoolPhone(line.slice(0, separatorIndex))
|
|
: normalizePoolPhone(line);
|
|
const verificationUrl = hasSeparator
|
|
? normalizePoolUrl(line.slice(separatorIndex + SEPARATOR.length))
|
|
: normalizePoolUrl(lines[index + 1] || '');
|
|
if (!hasSeparator && verificationUrl) {
|
|
index += 1;
|
|
}
|
|
const key = buildKey(phone, verificationUrl);
|
|
if (!phone || !verificationUrl || !key || seen.has(key)) {
|
|
continue;
|
|
}
|
|
seen.add(key);
|
|
entries.push({
|
|
index,
|
|
key,
|
|
phone,
|
|
verificationUrl,
|
|
});
|
|
}
|
|
return entries;
|
|
}
|
|
|
|
function entriesToText(entries = []) {
|
|
return parseEntries(entries.map((entry) => `${entry.phone}${SEPARATOR}${entry.verificationUrl}`).join('\n'))
|
|
.map((entry) => `${entry.phone}${SEPARATOR}${entry.verificationUrl}`)
|
|
.join('\n');
|
|
}
|
|
|
|
function normalizeUsage(value = {}) {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
return {};
|
|
}
|
|
return Object.fromEntries(Object.entries(value).map(([key, item]) => {
|
|
const usage = item && typeof item === 'object' && !Array.isArray(item) ? item : {};
|
|
const legacyUsedCount = Number(usage.usedAt) > 0 ? 1 : 0;
|
|
const useCount = Math.max(0, Math.floor(Number(usage.useCount ?? usage.usageCount ?? legacyUsedCount) || 0));
|
|
return [normalizeText(key), {
|
|
useCount,
|
|
usedAt: Math.max(0, Number(usage.usedAt) || 0),
|
|
lastAttemptAt: Math.max(0, Number(usage.lastAttemptAt) || 0),
|
|
lastError: normalizeText(usage.lastError),
|
|
}];
|
|
}).filter(([key]) => Boolean(key)));
|
|
}
|
|
|
|
function getCurrentKey() {
|
|
const current = state.getCurrentEntry?.() || null;
|
|
return normalizeText(current?.key || buildKey(current?.phone, current?.verificationUrl));
|
|
}
|
|
|
|
function getEntriesWithState(entries = renderedEntries) {
|
|
const usage = normalizeUsage(state.getUsage?.());
|
|
const currentKey = getCurrentKey();
|
|
return parseEntries(entriesToText(entries)).map((entry) => {
|
|
const itemUsage = usage[entry.key] || {};
|
|
return {
|
|
...entry,
|
|
current: Boolean(currentKey && entry.key === currentKey),
|
|
useCount: Math.max(0, Math.floor(Number(itemUsage.useCount) || 0)),
|
|
used: Math.max(0, Math.floor(Number(itemUsage.useCount) || 0)) > 0,
|
|
lastAttemptAt: Math.max(0, Number(itemUsage.lastAttemptAt) || 0),
|
|
lastError: normalizeText(itemUsage.lastError),
|
|
};
|
|
});
|
|
}
|
|
|
|
function getFilteredEntries(entries = renderedEntries) {
|
|
const normalizedSearch = normalizeText(searchTerm).toLowerCase();
|
|
return getEntriesWithState(entries).filter((entry) => {
|
|
const matchesFilter = (() => {
|
|
switch (filterMode) {
|
|
case 'current': return Boolean(entry.current);
|
|
case 'used': return Boolean(entry.used);
|
|
case 'unused': return !entry.used;
|
|
case 'error': return Boolean(entry.lastError);
|
|
default: return true;
|
|
}
|
|
})();
|
|
if (!matchesFilter) {
|
|
return false;
|
|
}
|
|
if (!normalizedSearch) {
|
|
return true;
|
|
}
|
|
return [
|
|
entry.phone,
|
|
entry.verificationUrl,
|
|
entry.current ? 'current 当前' : '',
|
|
entry.used ? 'used 已用' : 'unused 未用',
|
|
entry.lastError ? `error 异常 ${entry.lastError}` : '',
|
|
].join(' ').toLowerCase().includes(normalizedSearch);
|
|
});
|
|
}
|
|
|
|
function setLoading(nextLoading, summary = '') {
|
|
loading = Boolean(nextLoading);
|
|
[
|
|
dom.btnHostedSmsPoolRefresh,
|
|
dom.btnHostedSmsPoolClearUsed,
|
|
dom.btnHostedSmsPoolDeleteAll,
|
|
dom.btnHostedSmsPoolImport,
|
|
].forEach((button) => {
|
|
if (button) button.disabled = loading;
|
|
});
|
|
if (dom.inputHostedSmsPoolImport) {
|
|
dom.inputHostedSmsPoolImport.disabled = loading;
|
|
}
|
|
if (summary && dom.hostedSmsPoolSummary) {
|
|
dom.hostedSmsPoolSummary.textContent = summary;
|
|
}
|
|
}
|
|
|
|
function updateControls(entries = renderedEntries) {
|
|
const entriesWithState = getEntriesWithState(entries);
|
|
const usedCount = entriesWithState.filter((entry) => entry.useCount > 0).length;
|
|
if (dom.btnHostedSmsPoolClearUsed) {
|
|
dom.btnHostedSmsPoolClearUsed.disabled = loading || usedCount === 0;
|
|
}
|
|
if (dom.btnHostedSmsPoolDeleteAll) {
|
|
dom.btnHostedSmsPoolDeleteAll.disabled = loading || entriesWithState.length === 0;
|
|
}
|
|
}
|
|
|
|
function render(entries = parseEntries(state.getText?.())) {
|
|
if (!dom.hostedSmsPoolList || !dom.hostedSmsPoolSummary) {
|
|
return;
|
|
}
|
|
renderedEntries = parseEntries(entriesToText(entries));
|
|
dom.hostedSmsPoolList.innerHTML = '';
|
|
|
|
const entriesWithState = getEntriesWithState(renderedEntries);
|
|
if (!entriesWithState.length) {
|
|
dom.hostedSmsPoolList.innerHTML = '<div class="luckmail-empty">还没有 Hosted 号码,先导入一批号码再开始。</div>';
|
|
dom.hostedSmsPoolSummary.textContent = '导入 PayPal Hosted 接码号码,每行一个号码和验证码接口。';
|
|
updateControls([]);
|
|
return;
|
|
}
|
|
|
|
const usedCount = entriesWithState.filter((entry) => entry.useCount > 0).length;
|
|
const totalUseCount = entriesWithState.reduce((sum, entry) => sum + Math.max(0, Number(entry.useCount) || 0), 0);
|
|
dom.hostedSmsPoolSummary.textContent = `已加载 ${entriesWithState.length} 个号码,${usedCount} 个有使用记录,累计使用 ${totalUseCount} 次。`;
|
|
|
|
const visibleEntries = getFilteredEntries(renderedEntries);
|
|
if (!visibleEntries.length) {
|
|
dom.hostedSmsPoolList.innerHTML = '<div class="luckmail-empty">没有匹配当前筛选条件的号码。</div>';
|
|
updateControls(renderedEntries);
|
|
return;
|
|
}
|
|
|
|
for (const entry of visibleEntries) {
|
|
const item = document.createElement('div');
|
|
item.className = `luckmail-item${entry.current ? ' is-current' : ''}`;
|
|
const localPhone = formatPayPalLocalPhone(entry.phone);
|
|
item.innerHTML = `
|
|
<div class="luckmail-item-main">
|
|
<div class="luckmail-item-email-row">
|
|
<div class="luckmail-item-email hosted-sms-pool-phone">
|
|
<span>${helpers.escapeHtml?.(entry.phone) || entry.phone}</span>
|
|
${localPhone && localPhone !== entry.phone ? `<span class="hosted-sms-pool-phone-local">PayPal 填 ${helpers.escapeHtml?.(localPhone) || localPhone}</span>` : ''}
|
|
</div>
|
|
<button
|
|
class="hotmail-copy-btn"
|
|
type="button"
|
|
data-action="copy-phone"
|
|
title="复制号码"
|
|
aria-label="复制号码 ${helpers.escapeHtml?.(entry.phone) || entry.phone}"
|
|
>${copyIcon}</button>
|
|
</div>
|
|
<div class="luckmail-item-details mono">${helpers.escapeHtml?.(entry.verificationUrl) || entry.verificationUrl}</div>
|
|
<div class="luckmail-item-meta">
|
|
${entry.current ? '<span class="luckmail-tag current">当前</span>' : ''}
|
|
<span class="luckmail-tag active">使用 ${Math.max(0, Number(entry.useCount) || 0)} 次</span>
|
|
</div>
|
|
</div>
|
|
<div class="luckmail-item-actions">
|
|
<button class="btn btn-outline btn-xs" type="button" data-action="increment-usage">次数 +1</button>
|
|
<button class="btn btn-outline btn-xs" type="button" data-action="reset-usage">清零</button>
|
|
<button class="btn btn-outline btn-xs" type="button" data-action="delete">删除</button>
|
|
</div>
|
|
`;
|
|
|
|
item.querySelector('[data-action="copy-phone"]')?.addEventListener('click', async () => {
|
|
await helpers.copyTextToClipboard?.(entry.phone || '');
|
|
helpers.showToast?.('号码已复制', 'success', 1600);
|
|
});
|
|
|
|
item.querySelector('[data-action="increment-usage"]')?.addEventListener('click', async () => {
|
|
await patchPool(({ entries: entriesList, usage }) => {
|
|
const nextUsage = { ...usage };
|
|
nextUsage[entry.key] = {
|
|
...(nextUsage[entry.key] || {}),
|
|
useCount: Math.max(0, Number(nextUsage[entry.key]?.useCount) || 0) + 1,
|
|
usedAt: Date.now(),
|
|
lastAttemptAt: Math.max(0, Number(nextUsage[entry.key]?.lastAttemptAt) || 0),
|
|
lastError: normalizeText(nextUsage[entry.key]?.lastError),
|
|
};
|
|
return { entries: entriesList, usage: nextUsage };
|
|
});
|
|
});
|
|
|
|
item.querySelector('[data-action="reset-usage"]')?.addEventListener('click', async () => {
|
|
await patchPool(({ entries: entriesList, usage }) => {
|
|
const nextUsage = { ...usage };
|
|
nextUsage[entry.key] = {
|
|
...(nextUsage[entry.key] || {}),
|
|
useCount: 0,
|
|
usedAt: 0,
|
|
lastError: '',
|
|
};
|
|
return { entries: entriesList, usage: nextUsage };
|
|
});
|
|
});
|
|
|
|
item.querySelector('[data-action="delete"]')?.addEventListener('click', async () => {
|
|
const confirmed = await helpers.openConfirmModal?.({
|
|
title: '删除 Hosted 号码',
|
|
message: `确认删除 ${entry.phone} 吗?此操作不可撤销。`,
|
|
confirmLabel: '确认删除',
|
|
confirmVariant: 'btn-danger',
|
|
});
|
|
if (!confirmed) return;
|
|
await patchPool(({ entries: entriesList, usage }) => {
|
|
const nextUsage = { ...usage };
|
|
delete nextUsage[entry.key];
|
|
return {
|
|
entries: entriesList.filter((candidate) => candidate.key !== entry.key),
|
|
usage: nextUsage,
|
|
};
|
|
});
|
|
});
|
|
|
|
dom.hostedSmsPoolList.appendChild(item);
|
|
}
|
|
|
|
updateControls(renderedEntries);
|
|
}
|
|
|
|
async function patchPool(mutator) {
|
|
const previousText = normalizePoolText(state.getText?.());
|
|
const previousUsage = normalizeUsage(state.getUsage?.());
|
|
const previousEntries = parseEntries(previousText);
|
|
const result = mutator({
|
|
entries: previousEntries.map((entry) => ({ ...entry })),
|
|
usage: { ...previousUsage },
|
|
}) || {};
|
|
const nextEntries = parseEntries(entriesToText(result.entries || previousEntries));
|
|
const nextUsage = normalizeUsage(result.usage || previousUsage);
|
|
const nextText = entriesToText(nextEntries);
|
|
|
|
setLoading(true, '正在更新 Hosted 号池...');
|
|
state.setText?.(nextText);
|
|
state.setUsage?.(nextUsage);
|
|
if (!nextText) {
|
|
actions.clearFallback?.();
|
|
}
|
|
render(nextEntries);
|
|
try {
|
|
await actions.persistPool?.();
|
|
return true;
|
|
} catch (error) {
|
|
state.setText?.(previousText);
|
|
state.setUsage?.(previousUsage);
|
|
render(previousEntries);
|
|
helpers.showToast?.(`更新 Hosted 号池失败:${error.message}`, 'error');
|
|
return false;
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
async function importEntries() {
|
|
const text = normalizePoolText(dom.inputHostedSmsPoolImport?.value || '');
|
|
if (!text) {
|
|
helpers.showToast?.('请先粘贴 Hosted 号码,每行一个号码和验证码接口。', 'warn');
|
|
return;
|
|
}
|
|
|
|
const previousEntries = parseEntries(state.getText?.());
|
|
const knownKeys = new Set(previousEntries.map((entry) => entry.key));
|
|
const imported = [];
|
|
let skippedCount = 0;
|
|
for (const entry of parseEntries(text)) {
|
|
if (knownKeys.has(entry.key)) {
|
|
skippedCount += 1;
|
|
continue;
|
|
}
|
|
knownKeys.add(entry.key);
|
|
imported.push(entry);
|
|
}
|
|
if (!imported.length) {
|
|
helpers.showToast?.(skippedCount > 0 ? '没有可导入的新号码(可能都重复或格式无效)。' : '没有识别到有效号码。', 'warn');
|
|
return;
|
|
}
|
|
|
|
const persisted = await patchPool(({ entries, usage }) => ({
|
|
entries: [...entries, ...imported],
|
|
usage,
|
|
}));
|
|
if (!persisted) {
|
|
return;
|
|
}
|
|
if (dom.inputHostedSmsPoolImport) {
|
|
dom.inputHostedSmsPoolImport.value = '';
|
|
}
|
|
helpers.showToast?.(
|
|
skippedCount > 0
|
|
? `已导入 ${imported.length} 个号码,跳过 ${skippedCount} 条重复数据。`
|
|
: `已导入 ${imported.length} 个号码。`,
|
|
'success',
|
|
2200
|
|
);
|
|
}
|
|
|
|
async function clearUsedState() {
|
|
const confirmed = await helpers.openConfirmModal?.({
|
|
title: '清空使用次数',
|
|
message: '确认清空 Hosted 号池的使用次数吗?号码本身会保留。',
|
|
confirmLabel: '清空次数',
|
|
});
|
|
if (!confirmed) return;
|
|
await patchPool(({ entries }) => ({ entries, usage: {} }));
|
|
}
|
|
|
|
async function deleteAll() {
|
|
const confirmed = await helpers.openConfirmModal?.({
|
|
title: '删除 Hosted 号池',
|
|
message: '确认删除当前全部 Hosted 号码吗?此操作不可撤销。',
|
|
confirmLabel: '确认删除',
|
|
confirmVariant: 'btn-danger',
|
|
});
|
|
if (!confirmed) return;
|
|
await patchPool(() => ({ entries: [], usage: {} }));
|
|
}
|
|
|
|
function refresh(options = {}) {
|
|
const { silent = false } = options;
|
|
if (state.isVisible && !state.isVisible()) {
|
|
return;
|
|
}
|
|
if (!silent) setLoading(true, '正在刷新 Hosted 号池...');
|
|
render(parseEntries(state.getText?.()));
|
|
if (!silent) setLoading(false);
|
|
}
|
|
|
|
function queueRefresh() {
|
|
if (refreshQueued) return;
|
|
refreshQueued = true;
|
|
setTimeout(() => {
|
|
refreshQueued = false;
|
|
refresh({ silent: true });
|
|
}, 120);
|
|
}
|
|
|
|
function bindEvents() {
|
|
dom.btnHostedSmsPoolRefresh?.addEventListener('click', () => refresh());
|
|
dom.btnHostedSmsPoolImport?.addEventListener('click', () => {
|
|
void importEntries();
|
|
});
|
|
dom.inputHostedSmsPoolImport?.addEventListener('keydown', (event) => {
|
|
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
|
|
event.preventDefault();
|
|
void importEntries();
|
|
}
|
|
});
|
|
dom.inputHostedSmsPoolSearch?.addEventListener('input', (event) => {
|
|
searchTerm = normalizeText(event.target.value);
|
|
render(renderedEntries);
|
|
});
|
|
dom.selectHostedSmsPoolFilter?.addEventListener('change', (event) => {
|
|
filterMode = normalizeText(event.target.value) || 'all';
|
|
render(renderedEntries);
|
|
});
|
|
dom.btnHostedSmsPoolClearUsed?.addEventListener('click', () => {
|
|
void clearUsedState();
|
|
});
|
|
dom.btnHostedSmsPoolDeleteAll?.addEventListener('click', () => {
|
|
void deleteAll();
|
|
});
|
|
}
|
|
|
|
function reset() {
|
|
searchTerm = '';
|
|
filterMode = 'all';
|
|
if (dom.inputHostedSmsPoolSearch) dom.inputHostedSmsPoolSearch.value = '';
|
|
if (dom.selectHostedSmsPoolFilter) dom.selectHostedSmsPoolFilter.value = 'all';
|
|
if (dom.hostedSmsPoolList) dom.hostedSmsPoolList.innerHTML = '';
|
|
if (dom.hostedSmsPoolSummary) {
|
|
dom.hostedSmsPoolSummary.textContent = '导入 PayPal Hosted 接码号码,每行一个号码和验证码接口。';
|
|
}
|
|
updateControls([]);
|
|
}
|
|
|
|
return {
|
|
bindEvents,
|
|
queueRefresh,
|
|
refresh,
|
|
render,
|
|
reset,
|
|
};
|
|
}
|
|
|
|
globalScope.SidepanelHostedSmsPoolManager = {
|
|
createHostedSmsPoolManager,
|
|
};
|
|
})(typeof window !== 'undefined' ? window : globalThis);
|