98 lines
4.9 KiB
TypeScript
98 lines
4.9 KiB
TypeScript
import type { CollectedProductPayload } from '@atlas/contracts';
|
|
import { requestError } from './request-errors.js';
|
|
import { validateCollectedPayload } from './collection-payload-validation.js';
|
|
|
|
const apiOrigin = (import.meta.env.ATLAS_API_ORIGIN as string | undefined) ?? 'http://localhost:8200';
|
|
const webOrigin = (import.meta.env.ATLAS_WEB_ORIGIN as string | undefined) ?? 'http://localhost:8100';
|
|
type Session = { authenticated: true; workspace: { id: string; available: boolean }; can_collect: boolean };
|
|
const storageKey = 'atlas.extension.web-token.v1';
|
|
|
|
async function loadToken() { return (await browser.storage.local.get(storageKey))[storageKey] as string | undefined; }
|
|
async function saveToken(token: string) { await browser.storage.local.set({ [storageKey]: token }); }
|
|
async function clearTokens() { await browser.storage.local.remove(storageKey); }
|
|
async function request(path: string, options: RequestInit = {}, token?: string) {
|
|
const response = await fetch(`${apiOrigin}/api${path}`, { ...options, headers: { Accept: 'application/json', ...(options.headers ?? {}), ...(token ? { Authorization: `Bearer ${token}` } : {}) } });
|
|
if (!response.ok) {
|
|
const body: unknown = await response.json().catch(() => null);
|
|
throw requestError(body, response.status);
|
|
}
|
|
return response.status === 204 ? undefined : response.json() as Promise<{ data: unknown }>;
|
|
}
|
|
async function activeAccessToken() {
|
|
const cached = await loadToken();
|
|
const usableCached = await validateAccessToken(cached);
|
|
if (usableCached) return usableCached;
|
|
|
|
// 用户可能先在 Atlas Web 登录、再打开来源商品页。此时插件还没有自己的
|
|
// 缓存,仍应直接从已打开的 Atlas 标签页同步,而不是错误地展示“登录”。
|
|
const synchronized = await syncAccessTokenFromWebTabs();
|
|
return validateAccessToken(synchronized);
|
|
}
|
|
export async function getSession(): Promise<Session | null> { const token = await activeAccessToken(); if (!token) return null; return (await request('/extension/session', {}, token) as { data: Session }).data; }
|
|
export async function login() {
|
|
const currentSession = await getSession();
|
|
if (currentSession) return currentSession;
|
|
|
|
const tab = await browser.tabs.create({ url: webOrigin, active: true });
|
|
if (tab.id === undefined) throw new Error('无法打开 Atlas ERP 登录页');
|
|
const deadline = Date.now() + 3 * 60 * 1_000;
|
|
while (Date.now() < deadline) {
|
|
const token = await readWebAccessToken(tab.id);
|
|
if (token) {
|
|
await saveToken(token);
|
|
const session = await getSession();
|
|
if (session) return session;
|
|
await clearTokens();
|
|
}
|
|
await delay(2_000);
|
|
}
|
|
throw new Error('登录超时,请在 Atlas ERP 网页完成登录后重试');
|
|
}
|
|
export async function submit(payload: CollectedProductPayload) {
|
|
const validated = validateCollectedPayload(payload);
|
|
const token = await activeAccessToken();
|
|
if (!token) throw new Error('请先登录 Atlas ERP');
|
|
return request('/extension/materials', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'Idempotency-Key': crypto.randomUUID() },
|
|
body: JSON.stringify(validated),
|
|
}, token) as Promise<{ data: { editor_entry_path: string; product_id: string; collection_item: { id: string }; reused: boolean } }>;
|
|
}
|
|
export async function openErp(path = '/') { await browser.tabs.create({ url: new URL(path, webOrigin).toString() }); }
|
|
async function readWebAccessToken(tabId: number) {
|
|
try {
|
|
const result = await browser.scripting.executeScript({ target: { tabId }, func: () => localStorage.getItem('atlas.extension.web-access') });
|
|
const raw = result[0]?.result;
|
|
if (typeof raw !== 'string') return null;
|
|
const parsed = JSON.parse(raw) as { accessToken?: unknown };
|
|
return typeof parsed.accessToken === 'string' && parsed.accessToken.length > 0 ? parsed.accessToken : null;
|
|
} catch { return null; }
|
|
}
|
|
async function syncAccessTokenFromWebTabs() {
|
|
const tabs = await browser.tabs.query({ url: `${webOrigin}/*` });
|
|
// tabs.query 返回的顺序未承诺。倒序优先尝试最近的标签页(Chrome 通常最后返回它)。
|
|
for (const tab of tabs.reverse()) {
|
|
if (tab.id === undefined) continue;
|
|
const token = await readWebAccessToken(tab.id);
|
|
if (token) {
|
|
await saveToken(token);
|
|
return token;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
async function validateAccessToken(token: string | undefined | null) {
|
|
if (!token) return null;
|
|
try {
|
|
await request('/extension/session', {}, token);
|
|
return token;
|
|
} catch (error) {
|
|
if ((error as { status?: number }).status === 401) {
|
|
await clearTokens();
|
|
return null;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
function delay(milliseconds: number) { return new Promise<void>((resolve) => setTimeout(resolve, milliseconds)); }
|