first commit

This commit is contained in:
theshy
2025-08-12 22:49:47 +08:00
commit c61274e3a8
12 changed files with 788 additions and 0 deletions

23
README.md Normal file
View File

@ -0,0 +1,23 @@
# Proxy SwitcherChrome 扩展)
一键切换 Chrome 代理:直连 / System / HTTP / SOCKS5 / PAC支持弹窗选择与 PAC URL 保存。
## 使用方法
1. 打开 Chrome `chrome://extensions/`
2. 右上角打开“开发者模式”
3. 点击“加载已解压的扩展程序”,选择本目录 `2025-08-12_代理切换-浏览器扩展`
4. 点击工具栏图标,在弹窗中选择代理配置并点击“应用”
5. 选择 PAC 模式时,可在输入框中填写 PAC URL 后点击“保存”
6. 选择 自定义代理,可输入协议/地址/端口与绕过列表(逗号分隔),可保存或直接“应用”
## 权限说明
- `proxy`: 设置浏览器代理
- `storage`: 保存配置与当前选择
## 注意
- 如果系统或安全策略禁用了扩展修改代理,可能无法生效
- PAC URL 需可访问且返回有效 PAC 脚本
- 若切换到“系统代理/自动检测”仍无效,可尝试先切到“直连”,再切回“系统/自动”,或重载扩展。代码已在切换时先清理旧规则再设置以减少残留影响。

276
background.js Normal file
View File

@ -0,0 +1,276 @@
const STORAGE_KEYS = {
ACTIVE_PROFILE_ID: "activeProfileId",
PROFILES: "profiles",
};
const DEFAULT_PROFILES = [
{ id: "direct", name: "直连(关闭代理)", type: "direct" },
{ id: "system", name: "跟随系统代理", type: "system" },
{ id: "auto", name: "自动检测WPAD", type: "auto_detect" },
{
id: "local-http",
name: "HTTP 127.0.0.1:7897",
type: "fixed",
scheme: "http",
host: "127.0.0.1",
port: 7897,
bypassList: ["localhost", "127.0.0.1"],
},
{
id: "local-socks5",
name: "SOCKS5 127.0.0.1:7897",
type: "fixed",
scheme: "socks5",
host: "127.0.0.1",
port: 7897,
bypassList: ["localhost", "127.0.0.1"],
},
{
id: "pac",
name: "PAC自定义 URL",
type: "pac",
pacUrl: "",
},
{
id: "custom",
name: "自定义代理",
type: "fixed",
scheme: "http",
host: "",
port: 0,
bypassList: ["localhost", "127.0.0.1"],
},
];
// 动态图标(仅使用 PNG
// 只支持 PNG去掉 SVG 支持与栅格化逻辑
const ICON_FALLBACK_PNG = {
open: { 16: "icons/open-16.png", 32: "icons/open-32.png" },
close: { 16: "icons/close-16.png", 32: "icons/close-32.png" },
};
chrome.runtime.onInstalled.addListener(async () => {
const { profiles, activeProfileId } = await chrome.storage.local.get([
STORAGE_KEYS.PROFILES,
STORAGE_KEYS.ACTIVE_PROFILE_ID,
]);
const ensured = ensureProfiles(profiles);
await chrome.storage.local.set({ [STORAGE_KEYS.PROFILES]: ensured });
if (!activeProfileId) {
await chrome.storage.local.set({
[STORAGE_KEYS.ACTIVE_PROFILE_ID]: "direct",
});
}
await applyActiveProfile();
});
chrome.runtime.onStartup.addListener(async () => {
const { profiles } = await chrome.storage.local.get(STORAGE_KEYS.PROFILES);
const ensured = ensureProfiles(profiles);
await chrome.storage.local.set({ [STORAGE_KEYS.PROFILES]: ensured });
await applyActiveProfile();
});
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
(async () => {
if (message?.type === "applyProfileById") {
await chrome.storage.local.set({
[STORAGE_KEYS.ACTIVE_PROFILE_ID]: message.profileId,
});
await applyActiveProfile();
sendResponse({ ok: true });
} else if (message?.type === "updateCustomProxy") {
const { scheme, host, port, bypassList } = message;
const { profiles } = await chrome.storage.local.get(
STORAGE_KEYS.PROFILES
);
const updated = (profiles || []).map((p) =>
p.id === "custom"
? {
...p,
type: "fixed",
scheme: scheme || "http",
host: host || "",
port: Number(port) || 0,
bypassList: Array.isArray(bypassList)
? bypassList
: typeof bypassList === "string" && bypassList.trim()
? bypassList
.split(",")
.map((s) => s.trim())
.filter(Boolean)
: p.bypassList || [],
}
: p
);
await chrome.storage.local.set({ [STORAGE_KEYS.PROFILES]: updated });
const { activeProfileId } = await chrome.storage.local.get(
STORAGE_KEYS.ACTIVE_PROFILE_ID
);
if (activeProfileId === "custom") {
await applyActiveProfile();
}
sendResponse({ ok: true });
} else if (message?.type === "updatePacUrl") {
const { profiles } = await chrome.storage.local.get(
STORAGE_KEYS.PROFILES
);
const updated = (profiles || []).map((p) =>
p.id === "pac" ? { ...p, pacUrl: message.pacUrl || "" } : p
);
await chrome.storage.local.set({ [STORAGE_KEYS.PROFILES]: updated });
const { activeProfileId } = await chrome.storage.local.get(
STORAGE_KEYS.ACTIVE_PROFILE_ID
);
if (activeProfileId === "pac") {
await applyActiveProfile();
}
sendResponse({ ok: true });
} else if (message?.type === "getState") {
const { profiles, activeProfileId } = await chrome.storage.local.get([
STORAGE_KEYS.PROFILES,
STORAGE_KEYS.ACTIVE_PROFILE_ID,
]);
const ensured = ensureProfiles(profiles);
if (ensured !== profiles) {
await chrome.storage.local.set({ [STORAGE_KEYS.PROFILES]: ensured });
}
sendResponse({ ok: true, profiles: ensured, activeProfileId });
} else if (message?.type === "resetProfiles") {
await chrome.storage.local.set({
[STORAGE_KEYS.PROFILES]: DEFAULT_PROFILES,
[STORAGE_KEYS.ACTIVE_PROFILE_ID]: "direct",
});
await applyActiveProfile();
sendResponse({ ok: true });
}
})();
return true;
});
async function applyActiveProfile() {
const { profiles, activeProfileId } = await chrome.storage.local.get([
STORAGE_KEYS.PROFILES,
STORAGE_KEYS.ACTIVE_PROFILE_ID,
]);
const profile =
(profiles || DEFAULT_PROFILES).find((p) => p.id === activeProfileId) ||
DEFAULT_PROFILES[0];
const config = buildProxyConfig(profile);
// 为避免残留规则影响,先清理再设置
await new Promise((resolve) =>
chrome.proxy.settings.clear({ scope: "regular" }, resolve)
);
await new Promise((resolve) => {
chrome.proxy.settings.set({ value: config, scope: "regular" }, () => {
if (chrome.runtime.lastError) {
console.warn("proxy set error:", chrome.runtime.lastError);
}
resolve();
});
});
await updateBadge(profile);
await updateIcon(profile);
}
function buildProxyConfig(profile) {
if (profile.type === "direct") {
return { mode: "direct" };
}
if (profile.type === "system") {
return { mode: "system" };
}
if (profile.type === "auto_detect") {
return { mode: "auto_detect" };
}
if (profile.type === "pac") {
const url = (profile.pacUrl || "").trim();
if (url) {
return { mode: "pac_script", pacScript: { url } };
}
return { mode: "direct" };
}
if (profile.type === "fixed") {
const singleProxy = {
scheme: profile.scheme || "http",
host: profile.host,
port: Number(profile.port) || 0,
};
if (!singleProxy.host || !singleProxy.port) {
// 避免设置非法 fixed 导致联网异常
return { mode: "direct" };
}
const bypassList = Array.isArray(profile.bypassList)
? profile.bypassList
: [];
return {
mode: "fixed_servers",
rules: { singleProxy, bypassList },
};
}
return { mode: "direct" };
}
async function updateBadge(profile) {
// 仅显示图标,不显示任何徽章文字
if (chrome.action?.setBadgeText) {
await chrome.action.setBadgeText({ text: "" });
}
if (chrome.action?.setBadgeBackgroundColor) {
await chrome.action.setBadgeBackgroundColor({ color: "#00000000" });
}
}
async function updateIcon(profile) {
if (!chrome.action?.setIcon) return;
const isProxyOn = profile.type !== "direct";
const path = isProxyOn ? ICON_FALLBACK_PNG.open : ICON_FALLBACK_PNG.close;
try {
await chrome.action.setIcon({ path });
} catch (e) {
console.warn("setIcon failed", e);
}
}
// 已移除 SVG 栅格化与程序化绘制,专用 PNG 路径作为图标资源
function ensureProfiles(existing) {
const current = Array.isArray(existing) ? [...existing] : [];
const byId = new Map(current.map((p) => [p.id, p]));
const need = [
"direct",
"system",
"auto",
"local-http",
"local-socks5",
"pac",
"custom",
];
let changed = false;
for (const id of need) {
if (!byId.has(id)) {
const def = DEFAULT_PROFILES.find((p) => p.id === id);
if (def) {
current.push(def);
byId.set(id, def);
changed = true;
}
}
}
// 修补关键字段
const custom = byId.get("custom");
if (custom) {
if (!custom.scheme) (custom.scheme = "http"), (changed = true);
if (typeof custom.port !== "number")
(custom.port = Number(custom.port) || 0), (changed = true);
if (!Array.isArray(custom.bypassList))
(custom.bypassList = ["localhost", "127.0.0.1"]), (changed = true);
custom.type = "fixed";
}
const auto = byId.get("auto");
if (auto && auto.type !== "auto_detect") {
auto.type = "auto_detect";
changed = true;
}
return changed ? current : current;
}

BIN
icons/close-16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 731 B

BIN
icons/close-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

51
icons/close.svg Normal file
View File

@ -0,0 +1,51 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg height="800px" width="800px" version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 300.725 300.725" xml:space="preserve">
<g>
<g id="close-hang-sign-light-shop_1_">
<path style="fill:#F78B28;" d="M150.363,176.206c-5.183,0-9.398,4.215-9.398,9.398v28.193c0,5.183,4.215,9.398,9.398,9.398
s9.398-4.215,9.398-9.398v-28.193C159.76,180.421,155.546,176.206,150.363,176.206z"/>
<path style="fill:#F78B28;" d="M281.93,143.314H28.193c-7.772,0-14.097,6.325-14.097,14.096v84.579
c0,7.772,6.325,14.097,14.097,14.097H281.93c7.772,0,14.096-6.325,14.096-14.097V157.41
C296.027,149.639,289.702,143.314,281.93,143.314z M84.579,185.604h-9.398v-2.349c0-3.886-3.162-7.048-7.048-7.048
c-3.886,0-7.048,3.162-7.048,7.048v32.892c0,3.886,3.162,7.048,7.048,7.048s7.048-3.162,7.048-7.048v-2.349h9.398v2.349
c0,9.069-7.377,16.446-16.446,16.446s-16.446-7.377-16.446-16.446v-32.892c0-9.069,7.377-16.446,16.446-16.446
s16.446,7.377,16.446,16.446V185.604z M126.869,232.592H98.676c-2.594,0-4.699-2.105-4.699-4.699v-61.085h9.398v56.386h23.494
V232.592z M169.158,213.797c0,10.366-8.43,18.795-18.795,18.795s-18.795-8.43-18.795-18.795v-28.193
c0-10.366,8.43-18.795,18.795-18.795c10.366,0,18.795,8.43,18.795,18.795V213.797z M211.448,185.604h-9.398v-2.349
c0-3.886-3.162-7.048-7.048-7.048s-7.048,3.162-7.048,7.048v4.699c0,3.886,3.162,7.048,7.048,7.048
c9.069,0,16.446,7.377,16.446,16.446v4.699c0,9.069-7.377,16.446-16.446,16.446s-16.446-7.377-16.446-16.446v-2.349h9.398v2.349
c0,3.886,3.162,7.048,7.048,7.048s7.048-3.162,7.048-7.048v-4.699c0-3.886-3.162-7.048-7.048-7.048
c-9.069,0-16.446-7.377-16.446-16.446v-4.699c0-9.069,7.377-16.446,16.446-16.446s16.446,7.377,16.446,16.446V185.604z
M253.737,176.206h-23.494v18.795h18.795v9.398h-18.795v18.795h23.494v9.398h-28.193c-2.594,0-4.699-2.105-4.699-4.699v-23.494
v-9.398v-23.494c0-2.594,2.105-4.699,4.699-4.699h28.193V176.206z"/>
<circle style="fill:#FFFFFF;" cx="155.062" cy="58.735" r="4.699"/>
<path d="M190.303,162.11c-9.069,0-16.446,7.377-16.446,16.446v4.699c0,9.069,7.377,16.446,16.446,16.446
c3.886,0,7.048,3.162,7.048,7.048v4.699c0,3.886-3.162,7.048-7.048,7.048s-7.048-3.162-7.048-7.048V209.1h-9.398v2.349
c0,9.069,7.377,16.446,16.446,16.446s16.446-7.377,16.446-16.446v-4.699c0-9.069-7.377-16.446-16.446-16.446
c-3.886,0-7.048-3.162-7.048-7.048v-4.699c0-3.886,3.162-7.048,7.048-7.048s7.048,3.162,7.048,7.048v2.349h9.398v-2.349
C206.749,169.487,199.372,162.11,190.303,162.11z"/>
<path d="M216.146,166.809v23.494v9.398v23.494c0,2.594,2.105,4.699,4.699,4.699h28.193v-9.398h-23.494V199.7h18.795v-9.398
h-18.795v-18.795h23.494v-9.398h-28.193C218.251,162.11,216.146,164.215,216.146,166.809z"/>
<path d="M277.231,129.218h-31.327l-86.233-64.675c2.913-2.584,4.788-6.315,4.788-10.507c0-7.772-6.325-14.097-14.097-14.097
s-14.096,6.325-14.096,14.097c0,4.191,1.875,7.922,4.788,10.507l-86.233,64.675H23.494C10.539,129.218,0,139.757,0,152.712v84.579
c0,12.955,10.539,23.494,23.494,23.494h253.737c12.955,0,23.494-10.539,23.494-23.494v-84.579
C300.725,139.757,290.186,129.218,277.231,129.218z M150.363,49.338c2.589,0,4.699,2.11,4.699,4.699
c0,2.589-2.11,4.699-4.699,4.699s-4.699-2.11-4.699-4.699C145.664,51.448,147.774,49.338,150.363,49.338z M150.363,69.308
l79.88,59.91H70.483L150.363,69.308z M291.328,237.291c0,7.772-6.325,14.097-14.096,14.097H23.494
c-7.772,0-14.097-6.325-14.097-14.097v-84.579c0-7.772,6.325-14.097,14.097-14.097h253.737c7.772,0,14.096,6.325,14.096,14.097
v84.579H291.328z"/>
<path d="M63.434,162.11c-9.069,0-16.446,7.377-16.446,16.446v32.892c0,9.069,7.377,16.446,16.446,16.446
s16.446-7.377,16.446-16.446v-2.349h-9.398v2.349c0,3.886-3.162,7.048-7.048,7.048s-7.048-3.162-7.048-7.048v-32.892
c0-3.886,3.162-7.048,7.048-7.048s7.048,3.162,7.048,7.048v2.349h9.398v-2.349C79.88,169.487,72.503,162.11,63.434,162.11z"/>
<path d="M98.676,162.11h-9.398v61.085c0,2.594,2.105,4.699,4.699,4.699h28.193v-9.398H98.676V162.11z"/>
<path d="M145.664,162.11c-10.366,0-18.795,8.43-18.795,18.795v28.193c0,10.366,8.43,18.795,18.795,18.795
c10.366,0,18.795-8.43,18.795-18.795v-28.193C164.459,170.54,156.03,162.11,145.664,162.11z M155.062,209.098
c0,5.183-4.215,9.398-9.398,9.398s-9.398-4.215-9.398-9.398v-28.193c0-5.183,4.215-9.398,9.398-9.398s9.398,4.215,9.398,9.398
V209.098z"/>
</g>
<g id="Layer_1_5_">
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

BIN
icons/open-16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 842 B

BIN
icons/open-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

18
icons/open.svg Normal file
View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 -6.33 90.755 90.755" xmlns="http://www.w3.org/2000/svg">
<g id="open_label_door_hanger" data-name="open label door hanger" transform="translate(-458.165 -809.902)">
<path id="Path_195" data-name="Path 195" d="M519.038,820.274a7.714,7.714,0,1,1-4.582-9.9A7.718,7.718,0,0,1,519.038,820.274Z" fill="#27b7ff"/>
<g id="Group_78" data-name="Group 78">
<path id="Path_196" data-name="Path 196" d="M546.082,880.081a9.438,9.438,0,0,1-10.8,7.79l-69.21-11.225a9.446,9.446,0,0,1-7.787-10.8L461,849.107a9.438,9.438,0,0,1,10.8-7.79l69.213,11.229a9.443,9.443,0,0,1,7.787,10.8Z" fill="#a4d322"/>
</g>
<g id="Group_79" data-name="Group 79">
<path id="Path_197" data-name="Path 197" d="M488.881,862.22a7.025,7.025,0,0,1-2.645,4.77,7.823,7.823,0,0,1-10.088-1.638,8.06,8.06,0,0,1,1.644-10.147,7.857,7.857,0,0,1,10.083,1.636A7.015,7.015,0,0,1,488.881,862.22Zm-5.1,2.635a4,4,0,0,0,.971-1.313,6.922,6.922,0,0,0,.557-1.906,6.7,6.7,0,0,0,.055-2.089,3.965,3.965,0,0,0-.5-1.474,2.736,2.736,0,0,0-.925-.955,3.537,3.537,0,0,0-1.2-.444,3.423,3.423,0,0,0-1.267.033,3.009,3.009,0,0,0-1.19.6,3.9,3.9,0,0,0-.95,1.272,6.757,6.757,0,0,0-.615,1.995,6.655,6.655,0,0,0-.058,2.071,4.145,4.145,0,0,0,.5,1.488,2.813,2.813,0,0,0,.923.942,3.142,3.142,0,0,0,1.229.462,3.2,3.2,0,0,0,1.3-.054A2.814,2.814,0,0,0,483.785,864.855Z" fill="#ffffff"/>
<path id="Path_198" data-name="Path 198" d="M502.913,861.969a4.98,4.98,0,0,1-.6,1.7,3.95,3.95,0,0,1-1.123,1.292,5.489,5.489,0,0,1-1.975.9,6.505,6.505,0,0,1-2.5.016l-2.138-.353-.708,4.348-3.438-.556,2.165-13.337,5.656.919a8.923,8.923,0,0,1,2.111.566,5.212,5.212,0,0,1,1.438.909,3.585,3.585,0,0,1,1.009,1.548A4.335,4.335,0,0,1,502.913,861.969Zm-3.564-.5a1.6,1.6,0,0,0-.154-1.017,1.71,1.71,0,0,0-.613-.686,3.2,3.2,0,0,0-1.01-.4c-.34-.069-.789-.151-1.354-.244l-.6-.1-.646,3.985.984.16a8.058,8.058,0,0,0,1.472.123,1.985,1.985,0,0,0,1.017-.27,1.584,1.584,0,0,0,.588-.606A2.8,2.8,0,0,0,499.349,861.471Z" fill="#ffffff"/>
<path id="Path_199" data-name="Path 199" d="M513.514,873.054l-9.646-1.566,2.163-13.329,9.643,1.563-.417,2.574-6.22-1-.375,2.3,5.775.937-.421,2.579-5.773-.935-.532,3.287,6.222,1.013Z" fill="#ffffff"/>
<path id="Path_200" data-name="Path 200" d="M528.519,875.486l-3.313-.536-4.173-10.067-1.489,9.146-3.146-.508,2.16-13.337,4.107.673,3.628,8.428,1.239-7.644,3.152.516Z" fill="#ffffff"/>
</g>
<path id="Path_201" data-name="Path 201" d="M517.746,822.528a7.749,7.749,0,0,1-4.96,2.744L526.558,850.2l7.121,1.16Zm-13.272-2.47-26.56,22.254,7.4,1.2L508.12,824.4A7.762,7.762,0,0,1,504.474,820.058Z" fill="#f4f4f4"/>
<path id="Path_202" data-name="Path 202" d="M477.914,842.312l7.4,1.2,3.768-3.16-7.4-1.2Zm53.478,4.9-7.121-1.152,2.287,4.137,7.121,1.16Z" fill="#c9d1d1"/>
</g>

After

Width:  |  Height:  |  Size: 2.9 KiB

14
manifest.json Normal file
View File

@ -0,0 +1,14 @@
{
"manifest_version": 3,
"name": "Proxy Switcher",
"version": "1.0.1",
"description": "一键切换 Chrome 代理",
"permissions": ["proxy", "storage"],
"background": {
"service_worker": "background.js"
},
"action": {
"default_popup": "popup.html",
"default_title": "Proxy Switcher"
}
}

158
popup.css Normal file
View File

@ -0,0 +1,158 @@
:root {
--bg: #ffffff;
--card: #f8fafc;
--text: #0f172a;
--muted: #64748b;
--line: #e2e8f0;
--accent: #6366f1;
--accent-2: #3b82f6;
--focus: rgba(99, 102, 241, 0.35);
--shadow: 0 6px 20px rgba(2, 6, 23, 0.06), 0 1px 3px rgba(2, 6, 23, 0.06);
--radius: 12px;
--radius-sm: 10px;
--ring: 0 0 0 3px var(--focus);
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
background: var(--bg);
color: var(--text);
}
body {
font: 13px/1.5 system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial,
"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
}
.popup {
width: 340px;
max-width: 380px;
min-width: 320px;
padding: 14px 14px 12px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.title {
display: flex;
align-items: center;
gap: 8px;
}
.title h1 {
font-size: 14px;
margin: 0;
font-weight: 600;
}
.logo {
width: 18px;
height: 18px;
border-radius: 6px;
background: linear-gradient(135deg, var(--accent), var(--accent-2));
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.35);
}
/* 移除未使用的标签页样式 */
.main {
display: grid;
gap: 12px;
}
.row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.field {
display: grid;
gap: 6px;
}
.field-label {
font-size: 12px;
color: var(--muted);
}
.hint {
color: var(--muted);
font-size: 12px;
}
select,
input[type="text"],
input[type="number"] {
padding: 6px 8px;
font-size: 12px;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
}
.text-btn {
border: 0;
background: transparent;
color: var(--accent);
font-weight: 600;
cursor: pointer;
padding: 6px 8px;
border-radius: 8px;
}
.text-btn:hover {
background: rgba(99, 102, 241, 0.1);
}
.text-btn:focus-visible {
outline: none;
box-shadow: var(--ring);
}
/* 自定义下拉包装 */
.select {
position: relative;
display: inline-flex;
align-items: center;
background: #fff;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
padding: 0;
transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
}
.select:focus-within {
border-color: var(--accent);
box-shadow: var(--ring);
}
.select select {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
border: 0;
outline: none;
padding: 6px 26px 6px 8px;
background: transparent;
font-size: 12px;
}
.select .chevron {
position: absolute;
right: 8px;
color: var(--muted);
pointer-events: none;
transition: transform 0.2s ease, color 0.2s ease;
}
.select.open .chevron {
color: var(--accent);
transform: translateY(1px) rotate(180deg);
}
/* 移除未使用的 Quick Save 样式 */
/* 移除未使用的按钮/状态样式 */
/* 移除未使用的 Toast 样式 */
/* 移除未使用的动画 */

125
popup.html Normal file
View File

@ -0,0 +1,125 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Proxy Switcher</title>
<link rel="stylesheet" href="popup.css" />
</head>
<body>
<div class="popup">
<header class="header">
<div class="title">
<span class="logo" aria-hidden="true"></span>
<h1>Proxy Switcher</h1>
</div>
</header>
<main class="main">
<section class="panel" role="region" aria-label="代理设置">
<label class="field">
<span class="field-label">代理配置</span>
<div class="row">
<div class="select">
<select id="profileSelect"></select>
<svg
class="chevron"
viewBox="0 0 24 24"
width="14"
height="14"
aria-hidden="true"
>
<path
d="M6 9l6 6 6-6"
stroke="currentColor"
stroke-width="2"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</div>
<button id="applyBtn" class="text-btn">应用</button>
</div>
</label>
<!-- <div class="row">
<button id="resetBtn" class="text-btn" title="恢复默认配置">
重置配置
</button>
</div> -->
<div id="pacBlock" style="display: none">
<div class="row">
<input
id="pacUrl"
type="text"
placeholder="PAC URL如 https://example.com/proxy.pac"
/>
<button id="savePac" class="text-btn">保存</button>
</div>
<div class="hint">当选择 PAC 模式时,可填写 PAC 文件地址。</div>
</div>
<div id="customBlock" style="display: none">
<div class="row">
<label class="field-label" style="min-width: 40px">协议</label>
<div class="select">
<select id="customScheme">
<option value="http">HTTP</option>
<option value="https">HTTPS</option>
<option value="socks5">SOCKS5</option>
<option value="socks4">SOCKS4</option>
</select>
<svg
class="chevron"
viewBox="0 0 24 24"
width="14"
height="14"
aria-hidden="true"
>
<path
d="M6 9l6 6 6-6"
stroke="currentColor"
stroke-width="2"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</div>
</div>
<div class="row">
<input
id="customHost"
type="text"
placeholder="代理地址,如 127.0.0.1"
/>
<input
id="customPort"
type="number"
min="0"
max="65535"
placeholder="端口,如 7890"
/>
</div>
<div class="row">
<input
id="customBypass"
type="text"
placeholder="不走代理(逗号分隔),如 localhost, 127.0.0.1"
/>
<button id="saveCustom" class="text-btn">保存</button>
</div>
<!-- <div class="hint">
“自定义代理”会保存到本地,点击“应用”即可立即启用。
</div> -->
</div>
</section>
</main>
</div>
<script src="popup.js"></script>
</body>
</html>

123
popup.js Normal file
View File

@ -0,0 +1,123 @@
// ========== 代理切换逻辑 ==========
const els = {
profileSelect: document.getElementById("profileSelect"),
applyBtn: document.getElementById("applyBtn"),
pacBlock: document.getElementById("pacBlock"),
pacUrl: document.getElementById("pacUrl"),
savePac: document.getElementById("savePac"),
customBlock: document.getElementById("customBlock"),
customScheme: document.getElementById("customScheme"),
customHost: document.getElementById("customHost"),
customPort: document.getElementById("customPort"),
customBypass: document.getElementById("customBypass"),
saveCustom: document.getElementById("saveCustom"),
// resetBtn: document.getElementById("resetBtn"),
};
// 下拉箭头展开/收起状态同步
function bindSelectOpenState(selectEl) {
if (!selectEl) return;
const wrapper = selectEl.closest(".select");
if (!wrapper) return;
// 点击时认为打开
selectEl.addEventListener("mousedown", () => wrapper.classList.add("open"));
// 获得焦点也可视为打开
selectEl.addEventListener("focus", () => wrapper.classList.add("open"));
// 失焦或变更后关闭
selectEl.addEventListener("blur", () => wrapper.classList.remove("open"));
selectEl.addEventListener("change", () => wrapper.classList.remove("open"));
}
bindSelectOpenState(els.profileSelect);
bindSelectOpenState(els.customScheme);
init();
async function init() {
const state = await sendMessage({ type: "getState" });
const profiles = state.profiles || [];
const activeProfileId = state.activeProfileId || "direct";
// 填充下拉
els.profileSelect.innerHTML = "";
for (const p of profiles) {
const opt = document.createElement("option");
opt.value = p.id;
opt.textContent = p.name;
if (p.id === activeProfileId) opt.selected = true;
els.profileSelect.appendChild(opt);
}
toggleBlocks();
// 回填 PAC、自定义
const pacProfile = profiles.find((p) => p.id === "pac");
if (els.pacUrl) els.pacUrl.value = pacProfile?.pacUrl || "";
const customProfile = profiles.find((p) => p.id === "custom");
if (customProfile) {
if (els.customScheme)
els.customScheme.value = customProfile.scheme || "http";
if (els.customHost) els.customHost.value = customProfile.host || "";
if (els.customPort) els.customPort.value = customProfile.port || "";
if (els.customBypass)
els.customBypass.value = (customProfile.bypassList || []).join(", ");
}
}
els.profileSelect?.addEventListener("change", toggleBlocks);
function toggleBlocks() {
const mode = els.profileSelect?.value;
const isPac = mode === "pac";
const isCustom = mode === "custom";
if (els.pacBlock) els.pacBlock.style.display = isPac ? "block" : "none";
if (els.customBlock)
els.customBlock.style.display = isCustom ? "block" : "none";
}
els.applyBtn?.addEventListener("click", async () => {
const profileId = els.profileSelect.value;
if (profileId === "custom") {
await sendMessage({
type: "updateCustomProxy",
scheme: els.customScheme?.value,
host: els.customHost?.value.trim(),
port: Number(els.customPort?.value) || 0,
bypassList: (els.customBypass?.value || "")
.split(",")
.map((s) => s.trim())
.filter(Boolean),
});
}
await sendMessage({ type: "applyProfileById", profileId });
window.close();
});
els.savePac?.addEventListener("click", async () => {
const pacUrl = (els.pacUrl?.value || "").trim();
await sendMessage({ type: "updatePacUrl", pacUrl });
});
els.saveCustom?.addEventListener("click", async () => {
await sendMessage({
type: "updateCustomProxy",
scheme: els.customScheme?.value,
host: els.customHost?.value.trim(),
port: Number(els.customPort?.value) || 0,
bypassList: (els.customBypass?.value || "")
.split(",")
.map((s) => s.trim())
.filter(Boolean),
});
});
// 重置按钮已移除,如需恢复可取消上方注释并启用以下代码
// els.resetBtn?.addEventListener("click", async () => {
// await sendMessage({ type: "resetProfiles" });
// await init();
// });
function sendMessage(msg) {
return new Promise((resolve) => chrome.runtime.sendMessage(msg, resolve));
}