每天第一次打开浏览器时自动打开指定网站,并提供手动打开和网站管理界面
// ==UserScript==
// @name dailyOpenSites
// @namespace http://tampermonkey.net/
// @version 1.1
// @description 每天第一次打开浏览器时自动打开指定网站,并提供手动打开和网站管理界面
// @author gcnanmu
// @license MIT
// @match *://*/*
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// @grant GM_openInTab
// @run-at document-start
// @noframes
// ==/UserScript==
(function () {
'use strict';
/**
* Tampermonkey 存储 key 和弹窗 DOM id。
*
* 这些字符串会被多个模块复用:
* - 网站配置模块读写 managedSites。
* - 每日打开模块读写 lastOpenDate。
* - 防重复触发模块读写 lastManualOpen。
* - 弹窗模块用 DOM id 查找并移除旧面板。
*
* 集中定义可以避免同一个 key 在不同函数里拼写不一致,尤其是存储 key
* 一旦写错,就会表现为“配置丢失”或“重置不生效”。
*/
const STORAGE_KEY_SITES = 'managedSites';
const STORAGE_KEY_LAST_OPEN_DATE = 'lastOpenDate';
const STORAGE_KEY_LAST_MANUAL_OPEN = 'lastManualOpen';
const PANEL_ID = 'auto-open-panel';
const OVERLAY_ID = 'auto-open-overlay';
const MANAGER_ID = 'auto-open-manager';
/**
* 默认网站列表。
*
* 当前数据结构:
* {
* name: string, // 控制面板和管理面板展示的名称
* url: string, // GM_openInTab 打开的目标地址
* enabled: boolean // 是否参与“打开所有网站”和每日自动打开
* }
*
* 默认列表只在两种情况下使用:
* 1. 用户还没有保存过 managedSites。
* 2. managedSites 不是数组。
*
* 如果用户明确保存了空数组,表示用户希望网站列表保持为空。
* 这种情况下不能回退默认列表,否则“删除全部网站并保存”会看起来不生效。
*/
const DEFAULT_SITES = [
{ name: 'bilibili', url: 'https://www.bilibili.com/', enabled: true }
];
/**
* 读取网站配置,并对 Tampermonkey 存储中的旧数据或脏数据做基础清洗。
*
* 这里做运行时防御,而不是假设存储一定可靠,原因是:
* - 用户可能从旧版本脚本升级,旧配置没有 enabled 字段。
* - 用户可能手动修改 Tampermonkey 存储。
* - 未来修改数据结构时,旧数据仍可能被读取。
*
* 清洗策略:
* - 只保留 name/url 都是字符串的项。
* - 对 name/url 做 trim。
* - 空 name 或空 url 的项直接丢弃。
* - enabled 只有明确为 false 才视为禁用,缺失字段默认启用。
*
* @returns {Array<{name: string, url: string, enabled: boolean}>}
*/
function getSites() {
const storedSites = GM_getValue(STORAGE_KEY_SITES, null);
if (storedSites === null || !Array.isArray(storedSites)) {
return DEFAULT_SITES.slice();
}
return storedSites
.filter((site) => site && typeof site.name === 'string' && typeof site.url === 'string')
.map((site) => ({
name: site.name.trim(),
url: site.url.trim(),
enabled: site.enabled !== false
}))
.filter((site) => site.name && site.url);
}
function saveSites(sites) {
GM_setValue(STORAGE_KEY_SITES, sites);
}
/**
* 获取当天日期字符串。
*
* 当前实现使用 toISOString(),生成的是 UTC 日期,不是本地时区日期。
* 这意味着在东八区凌晨 0 点到 8 点之间,UTC 日期可能仍是前一天。
* 如果要严格按本地日期判断,需要改成本地年月日拼接。
*
* @returns {string} YYYY-MM-DD
*/
function getTodayString() {
return new Date().toISOString().split('T')[0];
}
/**
* 打开当前配置中的全部启用网站。
*
* 重要行为:
* - 只打开 enabled !== false 的网站。
* - 打开前写入 lastManualOpen,用来告诉新标签页“这是刚由脚本打开的”。
* - 每个标签页间隔 500ms,降低浏览器把它当作批量弹窗拦截的概率。
* - 第一个标签页 active,后续标签页后台插入。
*
* @param {{silentEmpty?: boolean}} options
* @returns {boolean} true 表示确实安排了打开标签页;false 表示没有触发打开
*/
function openAllSites(options = {}) {
const allSites = getSites();
const sites = allSites.filter((site) => site.enabled);
const silentEmpty = options.silentEmpty === true;
if (allSites.length === 0) {
console.log('[每日自动打开网站]: 当前网站列表为空,不触发自动打开逻辑');
if (!silentEmpty) {
alert('当前网站列表为空,请先添加网站。');
}
return false;
}
if (sites.length === 0) {
console.log('[每日自动打开网站]: 当前没有启用的网站,不触发自动打开逻辑');
if (!silentEmpty) {
alert('当前没有启用的网站,请先添加或启用网站。');
}
return false;
}
// 标记刚刚由脚本主动打开过网站,避免新标签页短时间内再次触发自动打开。
GM_setValue(STORAGE_KEY_LAST_MANUAL_OPEN, Date.now());
sites.forEach((site, index) => {
setTimeout(() => {
GM_openInTab(site.url, { active: index === 0, insert: true });
}, index * 500);
});
return true;
}
/**
* 每日自动打开入口。
*
* 判断逻辑:
* 1. 从 Tampermonkey 存储读取 lastOpenDate。
* 2. 和今天日期比较。
* 3. 如果今天还没有打开过,调用 openAllSites()。
*
* 只有真正安排打开标签页时,才写入 lastOpenDate。
* 如果网站列表为空,或者没有任何启用网站,则不算作今日已触发。
* 这样用户添加网站后仍能在当天重新触发自动打开逻辑。
*/
function checkAndAutoOpen() {
const lastOpenDate = GM_getValue(STORAGE_KEY_LAST_OPEN_DATE, '');
const today = getTodayString();
if (lastOpenDate !== today) {
console.log('[每日自动打开] 检测到今天首次打开浏览器,正在打开网站...');
if (openAllSites({ silentEmpty: true })) {
GM_setValue(STORAGE_KEY_LAST_OPEN_DATE, today);
}
return;
}
console.log('[每日自动打开] 今天已经打开过,跳过自动打开');
}
/**
* 确保 document.body 可用后再执行 DOM 操作。
*
* 脚本配置了 @run-at document-start,此时页面 body 可能还没创建。
* 自动打开逻辑不依赖 body,可以尽早执行;但控制面板和管理面板需要 appendChild,
* 所以菜单入口统一通过这个函数延迟到 DOMContentLoaded。
*
* @param {Function} callback body 可用后执行的回调
*/
function ensureBodyReady(callback) {
if (document.body) {
callback();
return;
}
window.addEventListener('DOMContentLoaded', callback, { once: true });
}
/**
* 关闭指定弹窗,并同步移除共享遮罩。
*
* 这里不只移除 panel,也要移除 overlay。否则面板消失后遮罩仍覆盖页面,
* 用户会感觉原网页无法点击。
*
* @param {string} panelId 要关闭的面板 DOM id
*/
function closeModal(panelId) {
const panel = document.getElementById(panelId);
const overlay = document.getElementById(OVERLAY_ID);
if (panel) {
panel.remove();
}
if (overlay) {
overlay.remove();
}
}
/**
* 创建弹窗遮罩。
*
* 创建前会先移除旧遮罩,保证页面上最多只有一个 overlay。
* 这个脚本运行在任意网站上,不知道目标页面自己的 z-index 体系,
* 所以使用较高 z-index 来尽量保证面板可见。
*
* @returns {HTMLDivElement}
*/
function createOverlay() {
const existingOverlay = document.getElementById(OVERLAY_ID);
if (existingOverlay) {
existingOverlay.remove();
}
const overlay = document.createElement('div');
overlay.id = OVERLAY_ID;
overlay.style.cssText = `
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 999998;
`;
document.body.appendChild(overlay);
return overlay;
}
/**
* 创建控制面板和管理面板共用的弹窗骨架。
*
* 统一处理:
* - 关闭旧控制面板和旧管理面板,避免两个弹窗重叠。
* - 创建遮罩。
* - 创建居中弹窗容器。
* - 点击遮罩关闭当前面板。
*
* @param {string} panelId 面板 DOM id
* @param {string} titleText 标题文案
* @param {string} width CSS 宽度表达式,例如 360px / 520px
* @returns {HTMLDivElement}
*/
function createBasePanel(panelId, titleText, width = '360px') {
closeModal(PANEL_ID);
closeModal(MANAGER_ID);
const overlay = createOverlay();
const panel = document.createElement('div');
panel.id = panelId;
panel.style.cssText = `
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: min(${width}, calc(100vw - 32px));
max-height: calc(100vh - 32px);
overflow-y: auto;
background: #ffffff;
border: 1px solid #cfcfcf;
border-radius: 8px;
padding: 16px;
z-index: 999999;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.25);
font-family: Arial, sans-serif;
color: #222;
`;
const title = document.createElement('h3');
title.textContent = titleText;
title.style.cssText = 'margin: 0 0 12px; font-size: 18px; text-align: center;';
panel.appendChild(title);
overlay.onclick = () => closeModal(panelId);
document.body.appendChild(panel);
return panel;
}
/**
* 创建主操作按钮。
*
* 用于控制面板和管理面板底部的大按钮,例如“打开所有网站”“保存配置”。
* 行内小按钮使用 createSmallButton(),避免尺寸混用。
*
* @param {string} text 按钮文案
* @param {string} background 默认背景色
* @param {string} hoverBackground hover 背景色
* @param {Function} onClick 点击回调
* @returns {HTMLButtonElement}
*/
function createActionButton(text, background, hoverBackground, onClick) {
const button = document.createElement('button');
button.type = 'button';
button.textContent = text;
button.style.cssText = `
display: block;
width: 100%;
padding: 10px 12px;
margin: 8px 0;
background: ${background};
color: #fff;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
`;
button.onmouseover = () => {
button.style.background = hoverBackground;
};
button.onmouseout = () => {
button.style.background = background;
};
button.onclick = onClick;
return button;
}
/**
* 在控制面板中切换单个网站启用状态。
*
* 控制面板属于快捷操作入口,用户点击开关后预期立即生效,
* 所以这里直接读取完整配置、修改对应下标、马上写回 Tampermonkey 存储。
*
* 注意:targetIndex 必须来自 getSites() 当前返回数组的下标。
* 当前控制面板渲染和点击回调在同一次渲染周期内使用同一个 index,
* 因此不会出现排序变化后 index 错位的问题。
*
* @param {number} targetIndex getSites() 返回列表中的网站下标
* @param {boolean} enabled 新的启用状态
*/
function updateSiteEnabled(targetIndex, enabled) {
const sites = getSites();
sites[targetIndex].enabled = enabled;
saveSites(sites);
}
/**
* 创建滑动开关组件。
*
* 设计说明:
* - 使用 button 而不是 checkbox,是为了完全控制视觉样式并减少跨网站 CSS 干扰。
* - 使用 role="switch" 和 aria-checked 暴露开关语义。
* - 内部 span(knob) 作为滑块圆点,通过 transform 移动位置。
* - 组件内部只维护视觉状态;状态如何保存由 onChange 的调用方决定。
*
* 使用场景:
* - 控制面板:onChange 里立即 saveSites()。
* - 管理面板:onChange 只更新当前行状态,点击“保存配置”后统一持久化。
*
* @param {boolean} checked 初始是否启用
* @param {(checked: boolean, event: MouseEvent) => void} onChange 状态变化回调
* @returns {HTMLButtonElement}
*/
function createToggleSwitch(checked, onChange) {
const switchBtn = document.createElement('button');
switchBtn.type = 'button';
switchBtn.setAttribute('role', 'switch');
switchBtn.title = checked ? '已启用,点击禁用' : '已禁用,点击启用';
switchBtn.style.cssText = `
position: relative;
flex: 0 0 auto;
width: 46px;
height: 26px;
padding: 0;
border: none;
border-radius: 999px;
cursor: pointer;
transition: background 0.18s ease;
vertical-align: middle;
`;
const knob = document.createElement('span');
knob.style.cssText = `
position: absolute;
top: 3px;
left: 3px;
width: 20px;
height: 20px;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.28);
transition: transform 0.18s ease;
`;
// 将状态同步到视觉样式和无障碍属性,避免文字按钮式的状态不清晰。
function sync(nextChecked) {
checked = nextChecked;
switchBtn.setAttribute('aria-checked', String(checked));
switchBtn.title = checked ? '已启用,点击禁用' : '已禁用,点击启用';
switchBtn.style.background = checked ? '#22c55e' : '#cbd5e1';
knob.style.transform = checked ? 'translateX(20px)' : 'translateX(0)';
}
switchBtn.onclick = (event) => {
// 开关经常放在可点击行内,阻止冒泡避免同时触发行展开/收起。
event.stopPropagation();
sync(!checked);
onChange(checked, event);
};
switchBtn.appendChild(knob);
sync(checked);
return switchBtn;
}
/**
* 创建快速打开控制面板。
*
* 面板职责:
* - 展示所有网站。
* - 启用网站可以直接点击打开。
* - 禁用网站仍展示,但点击打开时提示先启用。
* - 每个网站右侧提供滑动开关,切换后立即保存。
* - 提供“打开所有网站”“管理网站”“关闭”入口。
*
* 这里没有过滤禁用网站,是因为用户需要从控制面板重新启用它们。
*/
function createControlPanel() {
const panel = createBasePanel(PANEL_ID, '网站快速打开');
const sites = getSites();
if (sites.length === 0) {
const empty = document.createElement('div');
empty.textContent = '当前没有网站,请先进入“管理网站”添加。';
empty.style.cssText = 'margin: 12px 0; color: #666; text-align: center;';
panel.appendChild(empty);
}
sites.forEach((site, index) => {
const row = document.createElement('div');
row.style.cssText = `
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
align-items: center;
margin: 8px 0;
`;
const btn = createActionButton(site.name, site.enabled ? '#4caf50' : '#9e9e9e', site.enabled ? '#449d48' : '#8a8a8a', () => {
if (!site.enabled) {
alert('该网站已禁用,请先启用后再打开。');
return;
}
GM_openInTab(site.url, { active: true });
});
btn.style.margin = '0';
const toggleSwitch = createToggleSwitch(site.enabled, (nextEnabled) => {
// 控制面板是日常快捷入口,切换状态后立即保存并重绘当前面板。
updateSiteEnabled(index, nextEnabled);
createControlPanel();
});
row.appendChild(btn);
row.appendChild(toggleSwitch);
panel.appendChild(row);
});
panel.appendChild(createActionButton('打开所有网站', '#2196f3', '#0b7dda', () => {
openAllSites();
}));
panel.appendChild(createActionButton('管理网站', '#ff9800', '#e68900', () => {
createSiteManager();
}));
panel.appendChild(createActionButton('关闭', '#f44336', '#da190b', () => {
closeModal(PANEL_ID);
}));
}
/**
* 创建一组 label + input。
*
* 管理面板里每个网站都需要名称和网址两个字段。
* 封装这个函数可以保证输入框样式一致,也能让 createSiteRow()
* 只关心字段含义,不重复写 DOM 结构。
*
* @param {string} labelText 标签文案
* @param {string} value 初始值
* @param {string} placeholder 输入提示
* @returns {{wrapper: HTMLDivElement, input: HTMLInputElement}}
*/
function createField(labelText, value, placeholder) {
const wrapper = document.createElement('div');
wrapper.style.cssText = 'display: grid; gap: 5px; margin-bottom: 8px;';
const label = document.createElement('label');
label.textContent = labelText;
label.style.cssText = 'font-size: 12px; color: #555;';
const input = document.createElement('input');
input.type = 'text';
input.value = value;
input.placeholder = placeholder;
input.style.cssText = `
width: 100%;
box-sizing: border-box;
padding: 8px 10px;
border: 1px solid #ccc;
border-radius: 6px;
font-size: 14px;
`;
wrapper.appendChild(label);
wrapper.appendChild(input);
return { wrapper, input };
}
/**
* 获取 URL 的展示摘要。
*
* 折叠行空间有限,只展示 hostname 比展示完整 URL 更容易扫视。
* 新增网站时 URL 可能为空或不完整,new URL() 会抛异常,
* 所以这里使用 try/catch fallback,避免输入过程导致整个管理面板崩溃。
*
* @param {string} url 用户输入的网址
* @returns {string} hostname、原始文本或“未设置网址”
*/
function getUrlDisplay(url) {
try {
return new URL(url).hostname;
} catch (error) {
return url || '未设置网址';
}
}
/**
* 创建行内小按钮。
*
* 当前主要用于“编辑/收起”和“删除”。
* 早期启用/禁用也使用小按钮,后来改成 createToggleSwitch(),
* 因此这里保留 hover 色 dataset 逻辑,方便未来有动态按钮时复用。
*
* @param {string} text 按钮文案
* @param {string} background 默认背景色
* @param {string} hoverBackground hover 背景色
* @param {Function} onClick 点击回调
* @returns {HTMLButtonElement}
*/
function createSmallButton(text, background, hoverBackground, onClick) {
const button = document.createElement('button');
button.type = 'button';
button.textContent = text;
button.dataset.background = background;
button.dataset.hoverBackground = hoverBackground;
button.style.cssText = `
flex: 0 0 auto;
min-width: 54px;
padding: 6px 9px;
background: ${background};
color: #fff;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 12px;
line-height: 1.2;
`;
button.onmouseover = () => {
button.style.background = button.dataset.hoverBackground;
};
button.onmouseout = () => {
button.style.background = button.dataset.background;
};
button.onclick = onClick;
return button;
}
/**
* 创建拖拽把手组件。
*
* 只让这个小控件 draggable,而不是让整行 draggable,原因是:
* - 整行可拖会影响点击行展开/收起。
* - 展开后输入框中选择文本时也可能误触拖拽。
* - 明确的拖拽把手更符合用户对排序控件的预期。
*
* 视觉上使用胶囊形轨道和三条竖线,模拟常见 grip/slider 控件。
*
* @returns {HTMLButtonElement}
*/
function createDragHandle() {
const handle = document.createElement('button');
handle.type = 'button';
handle.draggable = true;
handle.title = '拖动调整顺序';
handle.style.cssText = `
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: 42px;
height: 26px;
padding: 0;
background: #eef2f7;
border: 1px solid #cbd5e1;
border-radius: 999px;
cursor: grab;
box-shadow: inset 0 1px 2px rgba(15, 23, 42, 0.08);
`;
const grip = document.createElement('span');
grip.style.cssText = `
display: inline-flex;
gap: 3px;
align-items: center;
justify-content: center;
`;
// 三条竖线模拟常见的 grip/slider 控件,比“拖动”文字按钮更节省空间。
for (let i = 0; i < 3; i++) {
const bar = document.createElement('span');
bar.style.cssText = `
width: 2px;
height: 12px;
border-radius: 999px;
background: #64748b;
`;
grip.appendChild(bar);
}
handle.onmousedown = () => {
handle.style.cursor = 'grabbing';
};
handle.onmouseup = () => {
handle.style.cursor = 'grab';
};
handle.onclick = (event) => {
event.stopPropagation();
};
handle.appendChild(grip);
return handle;
}
/**
* 给管理列表绑定拖拽排序。
*
* 实现方式:
* - dragstart 时,createSiteRow() 会把当前行暂存到 listContainer.draggedRow。
* - dragover 时,根据鼠标在目标行的上半区/下半区决定插到目标行前面或后面。
* - 这里只移动 DOM 节点,不立即写入存储。
* - 用户点击“保存配置”时,collectSites() 按当前 DOM 顺序收集数据并持久化。
*
* 这样做的好处是拖拽过程可取消:用户如果拖错了,直接点“取消”即可不保存。
*
* @param {HTMLDivElement} listContainer 网站行容器
*/
function setupDragSorting(listContainer) {
listContainer.addEventListener('dragover', (event) => {
const draggedRow = listContainer.draggedRow;
if (!draggedRow) {
return;
}
const targetElement = event.target instanceof Element ? event.target : event.target.parentElement;
const targetRow = targetElement ? targetElement.closest('[data-site-row="true"]') : null;
if (!targetRow || targetRow === draggedRow || targetRow.parentElement !== listContainer) {
return;
}
event.preventDefault();
const targetRect = targetRow.getBoundingClientRect();
// 鼠标在目标行下半区时插到目标行后面,否则插到目标行前面。
const shouldInsertAfter = event.clientY > targetRect.top + targetRect.height / 2;
listContainer.insertBefore(draggedRow, shouldInsertAfter ? targetRow.nextSibling : targetRow);
});
listContainer.addEventListener('drop', (event) => {
if (listContainer.draggedRow) {
event.preventDefault();
}
});
}
/**
* 校验单个网站数据。
*
* 用于每行独立保存:只检查这一个网站本身是否合法(名称、网址非空,
* 网址必须以 http:// 或 https:// 开头)。跨网站的重名/重 URL 冲突
* 由 saveSingleSite() 结合当前存储另行处理。
*
* @param {{name: string, url: string}} site
* @returns {boolean} true 表示通过校验
*/
function validateSingleSite(site) {
if (!site.name || !site.url) {
alert('名称和网址都不能为空。');
return false;
}
if (!/^https?:\/\//i.test(site.url)) {
alert(`网址格式不正确:${site.url}\n请使用 http:// 或 https:// 开头。`);
return false;
}
return true;
}
/**
* 保存单个网站到存储(upsert)。
*
* 设计目的:让每行的“保存”按钮只影响这一个网站,避免每次小改动都走
* collectSites() 的全量收集与去重逻辑。
*
* 定位策略:
* - prevKey 是这一行上次保存时使用的 URL(小写),用来在存储中找到旧记录。
* - 新行没有 prevKey,按新增处理。
*
* 冲突校验(与存储中的其他网站比较,排除自身旧记录):
* - 名称重复直接拒绝。
* - URL 重复需用户确认覆盖;确认后用当前数据覆盖那条旧记录。
*
* @param {{name: string, url: string, enabled: boolean}} site 当前行数据
* @param {string|null} prevKey 该行上次保存使用的 URL key(小写),新行为 null
* @returns {string|null} 保存成功返回新的 URL key(小写);失败返回 null
*/
function saveSingleSite(site, prevKey) {
if (!validateSingleSite(site)) {
return null;
}
const sites = getSites();
const nameKey = site.name.toLowerCase();
const urlKey = site.url.toLowerCase();
// 自身旧记录的下标:优先按 prevKey 定位,避免误判为与他人冲突。
const selfIndex = prevKey === null
? -1
: sites.findIndex((item) => item.url.toLowerCase() === prevKey);
const nameConflict = sites.some((item, index) =>
index !== selfIndex && item.name.toLowerCase() === nameKey);
if (nameConflict) {
alert(`名称重复:${site.name}\n请更换网站名称。`);
return null;
}
const urlConflictIndex = sites.findIndex((item, index) =>
index !== selfIndex && item.url.toLowerCase() === urlKey);
if (urlConflictIndex !== -1) {
const existingSite = sites[urlConflictIndex];
const shouldOverwrite = confirm(
`URL 已存在:${site.url}\n\n` +
`已有网站:${existingSite.name}\n` +
`当前网站:${site.name}\n\n` +
'是否用当前网站配置覆盖已有配置?'
);
if (!shouldOverwrite) {
return null;
}
// 覆盖语义:移除那条相同 URL 的旧记录,下面再写入当前数据。
sites.splice(urlConflictIndex, 1);
}
// splice 可能改变 selfIndex 之后元素的位置,保存前重新按 prevKey 定位自身。
const writeIndex = prevKey === null
? -1
: sites.findIndex((item) => item.url.toLowerCase() === prevKey);
if (writeIndex === -1) {
sites.push(site);
} else {
sites[writeIndex] = site;
}
saveSites(sites);
return urlKey;
}
/**
* 从存储中删除指定 key(URL 小写)对应的网站。
*
* 用于每行“删除”按钮的即时持久化:已保存过的行删除后立即写回存储,
* 不依赖底部“保存配置”。未保存过的新行没有 key,调用方直接移除 DOM 即可。
*
* @param {string} key 该行保存时使用的 URL key(小写)
*/
function deleteSiteByKey(key) {
const sites = getSites().filter((item) => item.url.toLowerCase() !== key);
saveSites(sites);
}
/**
* 按当前 DOM 顺序持久化已保存网站的排序。
*
* 拖拽结束后立即调用:只对“已保存过”的行(带 siteKey)按 DOM 顺序重排,
* 未保存的新行不参与,避免把未确认的数据写入存储。
*
* @param {HTMLDivElement} listContainer 网站行容器
*/
function persistOrder(listContainer) {
const orderedKeys = Array.from(listContainer.children)
.filter((child) => child.dataset && child.dataset.siteKey)
.map((child) => child.dataset.siteKey);
if (orderedKeys.length === 0) {
return;
}
const sites = getSites();
const byKey = new Map(sites.map((item) => [item.url.toLowerCase(), item]));
const ordered = [];
orderedKeys.forEach((key) => {
if (byKey.has(key)) {
ordered.push(byKey.get(key));
byKey.delete(key);
}
});
// 存储中存在但当前列表未覆盖的网站(理论上不应出现)追加到末尾,避免丢数据。
byKey.forEach((item) => ordered.push(item));
saveSites(ordered);
}
/**
* 创建单个网站的管理行。
*
* 行结构:
* - 左侧:拖拽把手,用于调整排序。
* - 中间:摘要信息,包括名称、域名摘要、启用状态。
* - 右侧:滑动开关、编辑/收起按钮、删除按钮。
* - 下方详情区:展开后显示名称和网址输入框。
*
* 保存策略:
* - 本函数内部维护 enabled 和 expanded 两个临时状态。
* - 名称和 URL 直接存在 input.value 中。
* - row.getSiteData() 统一导出当前行数据。
* - collectSites() 保存时按 DOM 顺序调用 getSiteData(),因此拖拽排序天然生效。
*
* @param {{name: string, url: string, enabled?: boolean}} site 网站配置
* @param {HTMLDivElement} listContainer 当前行所在容器
* @param {boolean} expandedByDefault 是否默认展开
* @returns {HTMLDivElement}
*/
function createSiteRow(site, listContainer, expandedByDefault = false) {
const row = document.createElement('div');
row.dataset.siteRow = 'true';
// 已保存过的行用 siteKey(URL 小写)标识,供单行保存/删除和排序持久化定位。
// 新增但未保存的行没有 siteKey。
if (site.url) {
row.dataset.siteKey = site.url.trim().toLowerCase();
}
row.style.cssText = `
border: 1px solid #e2e2e2;
border-radius: 6px;
margin-bottom: 8px;
background: #fafafa;
overflow: hidden;
`;
const nameField = createField('名称', site.name, '例如:控制台');
const urlField = createField('网址', site.url, 'https://example.com');
let enabled = site.enabled !== false;
let expanded = expandedByDefault;
const header = document.createElement('div');
header.style.cssText = `
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
padding: 9px 10px;
cursor: pointer;
`;
const summary = document.createElement('div');
summary.style.cssText = 'min-width: 0;';
const nameText = document.createElement('div');
nameText.style.cssText = `
overflow: hidden;
color: #222;
font-size: 14px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
`;
const urlText = document.createElement('div');
urlText.style.cssText = `
overflow: hidden;
margin-top: 2px;
color: #666;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
`;
const statusText = document.createElement('div');
statusText.style.cssText = `
margin-top: 2px;
color: #888;
font-size: 12px;
`;
const actions = document.createElement('div');
actions.style.cssText = 'display: flex; gap: 8px; align-items: center;';
const detail = document.createElement('div');
detail.style.cssText = `
display: ${expanded ? 'block' : 'none'};
padding: 0 10px 10px;
border-top: 1px solid #e8e8e8;
`;
// 摘要和详情共用同一组 input,输入变化时同步刷新折叠态文案。
function refreshSummary() {
nameText.textContent = nameField.input.value.trim() || '未命名网站';
urlText.textContent = getUrlDisplay(urlField.input.value.trim());
statusText.textContent = enabled ? '已启用' : '已禁用';
row.style.opacity = enabled ? '1' : '0.72';
editBtn.textContent = expanded ? '收起' : '编辑';
detail.style.display = expanded ? 'block' : 'none';
}
const dragHandle = createDragHandle();
dragHandle.ondragstart = (event) => {
event.stopPropagation();
// 把当前拖拽行临时挂到容器上,dragover 时直接移动这个 DOM 节点。
listContainer.draggedRow = row;
row.style.opacity = '0.45';
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', nameField.input.value.trim() || 'site');
};
dragHandle.ondragend = () => {
// 拖拽结束后清理临时状态,并按启用状态恢复透明度。
listContainer.draggedRow = null;
refreshSummary();
// 拖拽排序立即持久化:只重排已保存过的行,未保存的新行不参与。
persistOrder(listContainer);
};
const toggleSwitch = createToggleSwitch(enabled, (nextEnabled) => {
// 管理面板中的修改先停留在当前 DOM 行,点击“保存配置”后统一持久化。
enabled = nextEnabled;
refreshSummary();
});
const editBtn = createSmallButton(expanded ? '收起' : '编辑', '#2196f3', '#0b7dda', (event) => {
// 阻止冒泡,避免点击编辑按钮时又触发行 header 的展开切换。
event.stopPropagation();
expanded = !expanded;
refreshSummary();
});
// 行内“保存”按钮:只校验并写入当前这一个网站,不触发全量收集逻辑。
const saveRowBtn = createActionButton('保存', '#2196f3', '#0b7dda', () => {
const data = {
name: nameField.input.value.trim(),
url: urlField.input.value.trim(),
enabled
};
// prevKey 为该行上次保存使用的 key;新行为 null,按新增处理。
const prevKey = row.dataset.siteKey || null;
const newKey = saveSingleSite(data, prevKey);
if (newKey === null) {
return;
}
// 保存成功后更新 siteKey,后续编辑会基于新 key 在存储中原地更新。
row.dataset.siteKey = newKey;
expanded = false;
refreshSummary();
alert('该网站已保存。');
});
saveRowBtn.style.marginTop = '4px';
const removeBtn = createSmallButton('删除', '#f44336', '#da190b', (event) => {
// 删除按钮也要阻止冒泡,否则删除前会先触发展开/收起。
event.stopPropagation();
if (!confirm(`确定要删除网站“${nameField.input.value.trim() || '未命名网站'}”吗?`)) {
return;
}
// 已保存过的行立即从存储移除;未保存的新行只移除 DOM。
if (row.dataset.siteKey) {
deleteSiteByKey(row.dataset.siteKey);
}
row.remove();
if (!listContainer.children.length) {
listContainer.appendChild(createEmptyRowHint());
}
});
header.onclick = () => {
expanded = !expanded;
refreshSummary();
};
nameField.input.addEventListener('input', refreshSummary);
urlField.input.addEventListener('input', refreshSummary);
summary.appendChild(nameText);
summary.appendChild(urlText);
summary.appendChild(statusText);
actions.appendChild(toggleSwitch);
actions.appendChild(editBtn);
actions.appendChild(removeBtn);
header.appendChild(dragHandle);
header.appendChild(summary);
header.appendChild(actions);
detail.appendChild(nameField.wrapper);
detail.appendChild(urlField.wrapper);
detail.appendChild(saveRowBtn);
row.appendChild(header);
row.appendChild(detail);
// 保存时由 collectSites() 统一调用,避免外部依赖这一行内部 DOM 结构。
row.getSiteData = () => ({
name: nameField.input.value.trim(),
url: urlField.input.value.trim(),
enabled
});
refreshSummary();
return row;
}
/**
* 创建空列表提示。
*
* 管理面板中删除最后一个网站后,需要给用户一个明确反馈,
* 否则面板中间会变成一片空白,看起来像渲染失败。
*
* @returns {HTMLDivElement}
*/
function createEmptyRowHint() {
const hint = document.createElement('div');
hint.dataset.emptyHint = 'true';
hint.textContent = '还没有网站,点“新增网站”添加。';
hint.style.cssText = 'padding: 10px 0; color: #666; text-align: center;';
return hint;
}
/**
* 清理空列表提示。
*
* 当用户点击“新增网站”时,如果当前只有 empty hint,
* 需要先移除提示,再插入真正的网站配置行,避免提示和表单同时出现。
*
* @param {HTMLDivElement} container 网站列表容器
*/
function clearEmptyHint(container) {
const hint = container.querySelector('[data-empty-hint="true"]');
if (hint) {
hint.remove();
}
}
/**
* 收集并校验管理面板中的网站配置。
*
* 数据来源:
* - 每个 createSiteRow() 返回的 row 都挂载了 getSiteData()。
* - 当前函数只读取有 getSiteData() 的子节点,因此 empty hint 不会被当作网站。
* - 读取顺序就是 DOM 顺序,所以拖拽排序后的结果会自然进入保存数据。
*
* 校验策略:
* - 名称和网址不能为空。
* - URL 必须以 http:// 或 https:// 开头。
* - 名称不能重复,重复时提示用户更换。
* - URL 可以重复,但必须经过用户确认;确认后用当前行覆盖旧行。
*
* 返回值约定:
* - 返回数组:可以保存。
* - 返回 null:校验失败,保存按钮应停止后续流程。
*
* @param {HTMLDivElement} listContainer 网站列表容器
* @returns {Array<{name: string, url: string, enabled: boolean}> | null}
*/
function collectSites(listContainer) {
const rows = Array.from(listContainer.children).filter((child) => typeof child.getSiteData === 'function');
const sites = rows.map((row) => row.getSiteData());
const nextSites = [];
const nameSet = new Set();
let urlIndexByKey = new Map();
function rebuildUrlIndex() {
urlIndexByKey = new Map();
nextSites.forEach((site, index) => {
urlIndexByKey.set(site.url.toLowerCase(), index);
});
}
for (const site of sites) {
if (!site.name || !site.url) {
alert('名称和网址都不能为空。');
return null;
}
if (!/^https?:\/\//i.test(site.url)) {
alert(`网址格式不正确:${site.url}\n请使用 http:// 或 https:// 开头。`);
return null;
}
const nameKey = site.name.toLowerCase();
if (nameSet.has(nameKey)) {
alert(`名称重复:${site.name}\n请更换网站名称。`);
return null;
}
const urlKey = site.url.toLowerCase();
if (urlIndexByKey.has(urlKey)) {
const existingIndex = urlIndexByKey.get(urlKey);
const existingSite = nextSites[existingIndex];
const shouldOverwrite = confirm(
`URL 已存在:${site.url}\n\n` +
`已有网站:${existingSite.name}\n` +
`当前网站:${site.name}\n\n` +
'是否用当前网站配置覆盖已有配置?'
);
if (!shouldOverwrite) {
return null;
}
// 覆盖语义:移除之前相同 URL 的配置,再把当前行作为新配置写入。
// 这样保存后的顺序以用户当前看到的 DOM 顺序为准。
nameSet.delete(existingSite.name.toLowerCase());
nextSites.splice(existingIndex, 1);
rebuildUrlIndex();
}
nameSet.add(nameKey);
nextSites.push(site);
urlIndexByKey.set(urlKey, nextSites.length - 1);
}
return nextSites;
}
/**
* 创建网站管理面板。
*
* 面板职责:
* - 渲染当前网站配置列表。
* - 允许新增、编辑、删除、启用/禁用和拖拽排序。
* - 点击“保存配置”时统一收集 DOM 当前状态并写入 Tampermonkey 存储。
* - 点击“取消”时直接关闭,不保存本次面板中的临时修改。
*
* 保存时机设计:
* 管理面板里有多种临时操作,尤其是拖拽排序。如果每拖一次就保存,
* 用户很难撤销误操作。因此这里采用“临时编辑 + 保存按钮统一提交”的模式。
*/
function createSiteManager() {
const panel = createBasePanel(MANAGER_ID, '管理网站', '520px');
const sites = getSites();
const desc = document.createElement('div');
desc.textContent = '你可以在这里新增、修改、启用、禁用或拖拽排序自动打开的网站。';
desc.style.cssText = 'margin-bottom: 12px; color: #555; font-size: 13px;';
panel.appendChild(desc);
const listContainer = document.createElement('div');
setupDragSorting(listContainer);
panel.appendChild(listContainer);
if (sites.length === 0) {
listContainer.appendChild(createEmptyRowHint());
} else {
sites.forEach((site) => {
listContainer.appendChild(createSiteRow(site, listContainer));
});
}
const addBtn = createActionButton('新增网站', '#4caf50', '#449d48', () => {
clearEmptyHint(listContainer);
listContainer.appendChild(createSiteRow({ name: '', url: '', enabled: true }, listContainer, true));
});
addBtn.style.marginTop = '12px';
panel.appendChild(addBtn);
const saveBtn = createActionButton('保存配置', '#2196f3', '#0b7dda', () => {
const nextSites = collectSites(listContainer);
if (!nextSites) {
return;
}
saveSites(nextSites);
alert('网站配置已保存。');
closeModal(MANAGER_ID);
});
panel.appendChild(saveBtn);
const cancelBtn = createActionButton('取消', '#9e9e9e', '#7f7f7f', () => {
closeModal(MANAGER_ID);
});
panel.appendChild(cancelBtn);
}
/**
* Tampermonkey 菜单入口。
*
* 需要创建 DOM 的命令必须通过 ensureBodyReady():
* - 打开控制面板
* - 管理网站
*
* 不依赖 DOM 的命令可以直接执行:
* - 立即打开所有网站
* - 重置今日记录
*/
GM_registerMenuCommand('打开控制面板', () => {
ensureBodyReady(createControlPanel);
});
GM_registerMenuCommand('管理网站', () => {
ensureBodyReady(createSiteManager);
});
GM_registerMenuCommand('立即打开所有网站', () => {
openAllSites();
});
GM_registerMenuCommand('重置今日记录', () => {
// lastOpenDate 控制“今天是否已经自动打开过”。
GM_setValue(STORAGE_KEY_LAST_OPEN_DATE, '');
// lastManualOpen 控制“最近是否刚由脚本打开过网站”,重置时也清掉,避免 10 秒窗口继续跳过检查。
GM_setValue(STORAGE_KEY_LAST_MANUAL_OPEN, 0);
// sessionStorage 标记当前标签页已经检查过;不清它,同一标签页访问新页面仍不会再次触发。
sessionStorage.removeItem('autoOpenChecked');
alert('今日记录已重置,下次访问或刷新页面会再次触发自动打开。');
});
/**
* 启动阶段的防重复逻辑。
*
* 需要同时考虑两种重复来源:
* 1. 同一个标签页里脚本重复运行:用 sessionStorage.autoOpenChecked 阻止。
* 2. openAllSites() 打开的新标签页也会运行脚本:用 lastManualOpen 的 10 秒窗口阻止。
*
* 如果两个条件都允许,才执行 checkAndAutoOpen()。
*/
const lastManualOpen = GM_getValue(STORAGE_KEY_LAST_MANUAL_OPEN, 0);
const now = Date.now();
const isRecentlyManuallyOpened = lastManualOpen && (now - lastManualOpen) < 10000;
if (!sessionStorage.getItem('autoOpenChecked') && !isRecentlyManuallyOpened) {
sessionStorage.setItem('autoOpenChecked', 'true');
checkAndAutoOpen();
}
})();