feat: redesign popup update panel

This commit is contained in:
admin123 2026-05-25 15:50:51 +08:00
parent 18b9d8eee5
commit e1cf2970da
4 changed files with 319 additions and 43 deletions

View File

@ -4,6 +4,183 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Star Chart Search Enhancer</title> <title>Star Chart Search Enhancer</title>
<style>
:root {
color-scheme: light;
--popup-bg: #f3f4f6;
--popup-card: #ffffff;
--popup-text: #111827;
--popup-subtle: #6b7280;
--popup-border: #d1d5db;
--popup-accent: #8f1f4b;
--popup-accent-strong: #74183b;
--popup-success: #065f46;
--popup-warning: #92400e;
}
html,
body {
width: 380px;
min-height: 560px;
margin: 0;
background: var(--popup-bg);
color: var(--popup-text);
font-family: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
}
body {
box-sizing: border-box;
}
#app {
box-sizing: border-box;
padding: 14px;
}
.popup-shell {
display: grid;
gap: 12px;
}
.popup-header {
padding: 6px 2px 2px;
}
.popup-eyebrow {
margin: 0 0 6px;
color: var(--popup-subtle);
font-size: 12px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.popup-header h1 {
margin: 0;
font-size: 24px;
line-height: 1.08;
font-weight: 800;
letter-spacing: -0.03em;
}
.popup-card {
background: var(--popup-card);
border: 1px solid rgba(17, 24, 39, 0.08);
border-radius: 16px;
padding: 14px;
box-shadow: 0 10px 24px rgba(17, 24, 39, 0.06);
}
.popup-card-title {
margin: 0 0 10px;
font-size: 14px;
font-weight: 700;
color: var(--popup-subtle);
}
.popup-status {
margin: 0 0 6px;
font-size: 14px;
font-weight: 600;
}
.popup-status--accent {
color: var(--popup-accent);
}
.popup-user {
margin: 0 0 2px;
font-size: 16px;
font-weight: 700;
}
.popup-copy,
.popup-error,
.popup-warning,
.popup-success {
margin: 0 0 10px;
font-size: 13px;
line-height: 1.5;
color: var(--popup-subtle);
}
.popup-error {
color: #b42318;
}
.popup-warning {
color: var(--popup-warning);
font-weight: 600;
}
.popup-success {
color: var(--popup-success);
font-weight: 600;
}
.popup-notes {
margin: 0 0 12px;
padding-left: 18px;
color: var(--popup-text);
font-size: 13px;
line-height: 1.45;
}
.popup-notes li + li {
margin-top: 6px;
}
.popup-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 10px;
}
.popup-footer {
display: flex;
justify-content: flex-start;
}
.popup-button {
appearance: none;
border: 1px solid var(--popup-border);
border-radius: 10px;
padding: 9px 12px;
font-size: 13px;
font-weight: 700;
line-height: 1;
cursor: pointer;
transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease;
}
.popup-button--primary {
border-color: var(--popup-accent);
background: var(--popup-accent);
color: #fff;
}
.popup-button--primary:hover {
background: var(--popup-accent-strong);
border-color: var(--popup-accent-strong);
}
.popup-button--secondary {
background: #fff;
color: var(--popup-text);
}
.popup-button--secondary:hover,
.popup-button--ghost:hover {
background: rgba(17, 24, 39, 0.04);
}
.popup-button--ghost {
background: transparent;
color: var(--popup-subtle);
border-color: transparent;
padding-inline: 0;
}
</style>
</head> </head>
<body> <body>
<main id="app"></main> <main id="app"></main>

View File

@ -104,7 +104,7 @@ async function renderCurrentAuthState(
} }
renderLoggedIn(root, response.value); renderLoggedIn(root, response.value);
void runUpdateCheck(root, sendMessage, updateOptions); await runUpdateCheck(root, sendMessage, updateOptions);
root root
.querySelector('[data-popup-sign-out="button"]') .querySelector('[data-popup-sign-out="button"]')
?.addEventListener("click", () => { ?.addEventListener("click", () => {
@ -195,9 +195,10 @@ async function runUpdateCheck(
status: "available" status: "available"
}); });
bindUpdateDownloadButtons(root, sendMessage, manifest); bindUpdateDownloadButtons(root, sendMessage, manifest);
} catch { } catch (error) {
renderUpdateStatus(root, { renderUpdateStatus(root, {
currentVersion: options.currentVersion, currentVersion: options.currentVersion,
message: error instanceof Error ? error.message : String(error),
status: "error" status: "error"
}); });
} }

View File

@ -3,11 +3,18 @@ import type { UpdateManifest } from "../shared/update-check";
export function renderLoggedOut(root: HTMLElement, error?: string | null): void { export function renderLoggedOut(root: HTMLElement, error?: string | null): void {
root.innerHTML = ` root.innerHTML = `
<section data-popup-state="logged-out"> <section class="popup-shell" data-popup-shell="root" data-popup-state="logged-out">
<h1>Star Chart Search Enhancer</h1> <header class="popup-header" data-popup-header="root">
<p>使</p> <p class="popup-eyebrow"></p>
${error ? `<p data-popup-error="true">${error}</p>` : ""} <h1>Star Chart Search Enhancer</h1>
<button type="button" data-popup-sign-in="button"> Logto</button> </header>
<section class="popup-card popup-card--account" data-popup-account="card">
<div class="popup-card-title"></div>
<p class="popup-status"></p>
<p class="popup-copy">使</p>
${error ? `<p class="popup-error" data-popup-error="true">${escapeHtml(error)}</p>` : ""}
<button type="button" class="popup-button popup-button--primary" data-popup-sign-in="button"> Logto</button>
</section>
</section> </section>
`; `;
} }
@ -19,16 +26,24 @@ export function renderLoggedIn(
const userInfo = authState.userInfo; const userInfo = authState.userInfo;
root.innerHTML = ` root.innerHTML = `
<section data-popup-state="logged-in"> <section class="popup-shell" data-popup-shell="root" data-popup-state="logged-in">
<h1>Star Chart Search Enhancer</h1> <header class="popup-header" data-popup-header="root">
<p></p> <p class="popup-eyebrow"></p>
<p>${userInfo?.name ?? userInfo?.username ?? "未知用户"}</p> <h1>Star Chart Search Enhancer</h1>
<p>${userInfo?.email ?? ""}</p> </header>
<section data-popup-update="root"> <section class="popup-card popup-card--account" data-popup-account="card">
<h2></h2> <div class="popup-card-title"></div>
<p data-popup-update-status="text">...</p> <p class="popup-status"></p>
<p class="popup-user">${escapeHtml(userInfo?.name ?? userInfo?.username ?? "未知用户")}</p>
<p class="popup-copy">${escapeHtml(userInfo?.email ?? "")}</p>
</section> </section>
<button type="button" data-popup-sign-out="button">退</button> <section class="popup-card popup-card--update" data-popup-update="card" data-popup-update-root="root">
<div class="popup-card-title"></div>
<p data-popup-update-status="text" class="popup-status">...</p>
</section>
<footer class="popup-footer">
<button type="button" class="popup-button popup-button--ghost" data-popup-sign-out="button">退</button>
</footer>
</section> </section>
`; `;
} }
@ -38,50 +53,54 @@ export function renderUpdateStatus(
options: { options: {
currentVersion: string; currentVersion: string;
manifest?: UpdateManifest; manifest?: UpdateManifest;
message?: string | null;
status: "checking" | "error" | "latest" | "available"; status: "checking" | "error" | "latest" | "available";
} }
): void { ): void {
const container = root.querySelector('[data-popup-update="root"]'); const container = root.querySelector('[data-popup-update-root="root"]');
if (!container) { if (!container) {
return; return;
} }
if (options.status === "checking") { if (options.status === "checking") {
container.innerHTML = ` container.innerHTML = `
<h2></h2> <div class="popup-card-title"></div>
<p data-popup-update-status="text">${options.currentVersion}</p> <p data-popup-update-status="text" class="popup-status">${options.currentVersion}</p>
<p>...</p> <p class="popup-copy">...</p>
`; `;
return; return;
} }
if (options.status === "error") { if (options.status === "error") {
container.innerHTML = ` container.innerHTML = `
<h2></h2> <div class="popup-card-title"></div>
<p data-popup-update-status="text">${options.currentVersion}</p> <p data-popup-update-status="text" class="popup-status">${options.currentVersion}</p>
<p></p> <p class="popup-warning"></p>
<p></p> ${options.message ? `<p class="popup-error">${escapeHtml(options.message)}</p>` : ""}
<p class="popup-copy"></p>
`; `;
return; return;
} }
if (options.status === "latest" || !options.manifest) { if (options.status === "latest" || !options.manifest) {
container.innerHTML = ` container.innerHTML = `
<h2></h2> <div class="popup-card-title"></div>
<p data-popup-update-status="text">${options.currentVersion}</p> <p data-popup-update-status="text" class="popup-status">${options.currentVersion}</p>
<p></p> <p class="popup-success"></p>
`; `;
return; return;
} }
container.innerHTML = ` container.innerHTML = `
<h2></h2> <div class="popup-card-title"></div>
<p data-popup-update-status="text">${options.currentVersion}</p> <p data-popup-update-status="text" class="popup-status">${options.currentVersion}</p>
<p>${options.manifest.latestVersion}</p> <p class="popup-status popup-status--accent">${options.manifest.latestVersion}</p>
${renderReleaseNotes(options.manifest.releaseNotes)} ${renderReleaseNotes(options.manifest.releaseNotes)}
<button type="button" data-popup-download-update="button"></button> <div class="popup-actions">
<button type="button" data-popup-download-guide="button">使</button> <button type="button" class="popup-button popup-button--primary" data-popup-download-update="button"></button>
<p data-popup-update-download-status="text"> zip chrome://extensions 里重新加载插件。</p> <button type="button" class="popup-button popup-button--secondary" data-popup-download-guide="button">使</button>
</div>
<p data-popup-update-download-status="text" class="popup-copy"> zip chrome://extensions 里重新加载插件。</p>
`; `;
} }
@ -103,7 +122,7 @@ function renderReleaseNotes(releaseNotes: string[]): string {
} }
return ` return `
<ul> <ul class="popup-notes">
${releaseNotes.map((note) => `<li>${escapeHtml(note)}</li>`).join("")} ${releaseNotes.map((note) => `<li>${escapeHtml(note)}</li>`).join("")}
</ul> </ul>
`; `;
@ -122,15 +141,16 @@ export function renderDevPanel(
authState: AuthStateValue authState: AuthStateValue
): void { ): void {
const panel = root.ownerDocument.createElement("section"); const panel = root.ownerDocument.createElement("section");
panel.className = "popup-card popup-card--dev";
panel.dataset.popupDevPanel = "root"; panel.dataset.popupDevPanel = "root";
panel.innerHTML = ` panel.innerHTML = `
<h2>dev auth panel</h2> <div class="popup-card-title">dev auth panel</div>
<p>resource: ${authState.resource ?? ""}</p> <p class="popup-copy">resource: ${escapeHtml(authState.resource ?? "")}</p>
<p>scopes: ${(authState.scopes ?? []).join(", ")}</p> <p class="popup-copy">scopes: ${escapeHtml((authState.scopes ?? []).join(", "))}</p>
<p>token: ${authState.tokenAvailable ? "available" : "missing"}</p> <p class="popup-copy">token: ${authState.tokenAvailable ? "available" : "missing"}</p>
<p>expires: ${authState.accessTokenExpiresAt ?? "unknown"}</p> <p class="popup-copy">expires: ${escapeHtml(String(authState.accessTokenExpiresAt ?? "unknown"))}</p>
<p>error: ${authState.lastError ?? ""}</p> <p class="popup-copy">error: ${escapeHtml(authState.lastError ?? "")}</p>
<button type="button" data-popup-test-protected-api="button"></button> <button type="button" class="popup-button popup-button--secondary" data-popup-test-protected-api="button"></button>
<pre data-popup-protected-api-result="output"></pre> <pre data-popup-protected-api-result="output"></pre>
`; `;
root.appendChild(panel); root.appendChild(panel);

View File

@ -3,6 +3,10 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
import { bootPopup } from "../src/popup/index"; import { bootPopup } from "../src/popup/index";
function flushTasks(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
describe("popup-entry", () => { describe("popup-entry", () => {
let dom: JSDOM; let dom: JSDOM;
@ -22,6 +26,12 @@ describe("popup-entry", () => {
})) }))
}); });
expect(
dom.window.document.querySelector('[data-popup-shell="root"]')
).not.toBeNull();
expect(
dom.window.document.querySelector('[data-popup-account="card"]')
).not.toBeNull();
expect(dom.window.document.querySelector("button")?.textContent).toContain( expect(dom.window.document.querySelector("button")?.textContent).toContain(
"登录" "登录"
); );
@ -47,6 +57,12 @@ describe("popup-entry", () => {
})) }))
}); });
expect(
dom.window.document.querySelector('[data-popup-header="root"]')
).not.toBeNull();
expect(
dom.window.document.querySelector('[data-popup-account="card"]')
).not.toBeNull();
expect(dom.window.document.body.textContent).toContain("resource"); expect(dom.window.document.body.textContent).toContain("resource");
expect(dom.window.document.body.textContent).toContain("token"); expect(dom.window.document.body.textContent).toContain("token");
}); });
@ -76,12 +92,16 @@ describe("popup-entry", () => {
} }
})) }))
}); });
await Promise.resolve(); await flushTasks();
await flushTasks();
expect(fetchUpdateManifest).toHaveBeenCalledTimes(1); expect(fetchUpdateManifest).toHaveBeenCalledTimes(1);
expect(dom.window.document.body.textContent).toContain("当前版本0.2.0421.2"); expect(dom.window.document.body.textContent).toContain("当前版本0.2.0421.2");
expect(dom.window.document.body.textContent).toContain("发现新版本0.2.0421.3"); expect(dom.window.document.body.textContent).toContain("发现新版本0.2.0421.3");
expect(dom.window.document.body.textContent).toContain("支持检查更新"); expect(dom.window.document.body.textContent).toContain("支持检查更新");
expect(
dom.window.document.querySelector('[data-popup-update="card"]')
).not.toBeNull();
expect( expect(
dom.window.document.querySelector('[data-popup-download-update="button"]') dom.window.document.querySelector('[data-popup-download-update="button"]')
).not.toBeNull(); ).not.toBeNull();
@ -118,7 +138,8 @@ describe("popup-entry", () => {
})), })),
sendMessage sendMessage
}); });
await Promise.resolve(); await flushTasks();
await flushTasks();
( (
dom.window.document.querySelector( dom.window.document.querySelector(
@ -245,6 +266,63 @@ describe("popup-entry", () => {
expect(sendMessage).toHaveBeenCalledWith({ type: "auth:sign-out" }); expect(sendMessage).toHaveBeenCalledWith({ type: "auth:sign-out" });
}); });
test("shows the latest state without update actions", async () => {
dom.window.document.body.innerHTML = "<main id='app'></main>";
await bootPopup({
currentVersion: "0.0525.5",
document: dom.window.document,
fetchUpdateManifest: vi.fn(async () => ({
guideUrl: "https://cos.example.com/guide.pdf",
latestVersion: "0.0525.5",
minSupportedVersion: "0.0525.5",
publishedAt: "2026-05-19",
releaseNotes: ["支持检查更新"],
zipUrl: "https://cos.example.com/plugin.zip"
})),
sendMessage: vi.fn(async () => ({
ok: true,
type: "auth:state",
value: {
isAuthenticated: true,
userInfo: { name: "Dev" }
}
}))
});
await flushTasks();
await flushTasks();
expect(dom.window.document.body.textContent).toContain("当前已是最新版本");
expect(
dom.window.document.querySelector('[data-popup-download-update="button"]')
).toBeNull();
});
test("shows a readable error state when the manifest fetch fails", async () => {
dom.window.document.body.innerHTML = "<main id='app'></main>";
await bootPopup({
currentVersion: "0.0525.5",
document: dom.window.document,
fetchUpdateManifest: vi.fn(async () => {
throw new Error("network down");
}),
sendMessage: vi.fn(async () => ({
ok: true,
type: "auth:state",
value: {
isAuthenticated: true,
userInfo: { name: "Dev" }
}
}))
});
await flushTasks();
await flushTasks();
expect(dom.window.document.body.textContent).toContain("暂时无法检查更新");
expect(dom.window.document.body.textContent).toContain("network down");
});
test("shows the auth error when sign-in fails", async () => { test("shows the auth error when sign-in fails", async () => {
const sendMessage = vi const sendMessage = vi
.fn() .fn()