宝塔 + Nginx 双端口实现 Java 无感更新，告别发布时的 502 报错
最近在迭代我们公司的材料采购系统时，又遇到了那个让人头疼的老问题：每次后端打包发布，接口总要断开那么十多秒，前端页面轻则转圈圈，重则直接报 502。对于一个正在频繁做数据对账、表单提交的系统来说，这十几秒的中断极易导致用户数据卡死或重复提交。虽然用的是单台服务器，但“服务重启中，请稍候”这种体验，在一线开发眼里确实是不及格的。既然用了宝塔面板加 Nginx 反向代理，其实只需要稍微改变一下思路，用“单机双端口交替部署（蓝绿发布）”的方式，配合 Nginx 的容错机制，就能做到完全不断开、无感的平滑切换。这篇文章我就把亲测通过的 Nginx 配置和全自动 Shell 脚本贴出来，希望能帮同样在做单机运维的兄弟少踩点坑。1. 为什么单实例重启做不到“无感”？之前我们的运维脚本很简单：kill 掉旧进程 -> 替换 Jar 包 -> nohup java -jar ...。这种“先杀后启”的方式，存在两个无法调和的致命伤：服务真空期：旧进程死了，新进程还在 JVM 初始化、加载 Spring 容器。在这漫长的十几秒到一分钟内，Nginx 找不到后端，进来的请求全部铁定遭遇 502。硬切中断：旧进程被干掉时，它手里可能正处理到一半的数据库事务、正在上传的图片会直接被强行掐断，给用户返回网络连接拒绝。所以，要做到接口不断开，核心只有一条：在新版本完全启动并成功监听端口之前，老版本必须死死钉在岗位上继续干活；新版本准备好了，Nginx 无感把流量切过去，老版本再优雅退场。2. 核心思路：单机双端口的“伪滚动更新”我们给项目分配两个内部端口，比如 9821（端口 A）和 9822（端口 B）。常驻状态：平时线上只有其中一个端口（比如 A）在跑，Nginx 把流量正常转给 A。发布时刻：我们上传新 Jar 包，脚本检测到当前是在跑 A，它绝对不去杀 A，而是默默在后台把新版代码在 端口 B 上启动起来。健康检查：脚本在后台不断去探测端口 B，直到 B 的 Spring Boot 完全初始化完毕、Tomcat 端口握手成功。无感切换：Nginx 的 proxy_next_upstream 机制会自动判断。当新请求进来，如果新端口活了，流量自然平滑过渡；同时 Nginx 在检测到老端口关闭时，会自动把未处理完的残余请求悄悄转给新端口。优雅退场：新版完全接管流量后，脚本向老端口发送普通的 kill 信号，触发 Spring Boot 的优雅停机，老版本站完最后一班岗，安全退出。⚠️ 唯一的硬性要求：在发布的这几十秒内，服务器内存要能短暂同时撑起两个 Java 实例（比如平时占 1G，发布时短暂需要 2G，等老的杀了就回落）。如果服务器内存只有 1G 且已经快爆了，请先去加内存，否则系统会因为 OOM 随机乱杀进程。3. 第一步：改造宝塔网站的主配置文件打开宝塔面板 -> 找到你的网站 -> 点击“设置” -> 找到 “配置文件”。在配置文件的 最顶部（也就是 server { 块的外层），加入以下 upstream 负载均衡组的定义。这样 Nginx 才能动态认得这两个端口。# 定义 Java 后端服务组，名字叫 procurement_servers upstream procurement_servers { server 127.0.0.1:9821 max_fails=3 fail_timeout=10s; server 127.0.0.1:9822 max_fails=3 fail_timeout=10s; }加好之后，点击保存即可。4. 第二步：重写宝塔的反向代理规则在宝塔面板的网站设置里，点击左侧的 “反向代理” -> 找到你原本创建好的那个反代条目，点击“配置文件”。把里面原本写死 proxy_pass http://127.0.0.1:9821; 的内容完全清空，用下面这段更健全的反代规则整体覆盖：#PROXY-START/ location ^~ / { # 1. 转发到我们在全局配置中定义的 upstream 服务组 proxy_pass http://procurement_servers; # 2. 核心无感切换机制 # 当其中一个端口被脚本关掉、或正在重启报 502/504 时，Nginx 自动、无感地在 0.1 秒内把请求发给另一个健康的端口 proxy_next_upstream error timeout invalid_header http_502 http_503 http_504; proxy_next_upstream_tries 2; proxy_next_upstream_timeout 10s; # 3. 基础反代透传配置（改为标准的 $host，比宝塔默认写死 127.0.0.1 兼容性更好） proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header REMOTE-HOST $remote_addr; # 4. 支持 WebSocket 长连接（如果系统用到了通知或大文件分片，这几行重要） proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_http_version 1.1; add_header X-Cache $upstream_cache_status; # 5. 宝塔自带的动静分离与浏览器静态资源缓存策略 set $static_filekKGGvf8l 0; if ( $uri ~* "\.(gif|png|jpg|css|js|woff|woff2)$" ) { set $static_filekKGGvf8l 1; expires 1m; } if ( $static_filekKGGvf8l = 0 ) { add_header Cache-Control no-cache; } } #PROXY-END/配置完成后，去宝塔的 Nginx 管理里点击 重载配置。5. 第三步：编写双端口交替部署的 Shell 脚本万事俱备，现在我们需要一个聪明的脚本来干苦力。它会自动判断当前哪个端口活着、哪个死了，并进行交替发布。在你的应用目录（例如 /www/wwwroot/procurement-java/prod）下，创建一个脚本文件 deploy.sh，内容如下：#!/bin/bash # 遇到任何错误立即停止执行，确保发布安全 set -e # ==================== 配置区域 ==================== APP_NAME="your-app-prod" APP_DIR="/www/wwwroot/procurement-java/prod" UPLOAD_JAR="${APP_DIR}/procurement-server.jar" # 环境与 Java 路径 JAVA_PATH="/www/server/java/amazon-corretto-21.0.11.10.1-linux-x64/bin/java" PROFILE="prod" # 双端口定义（必须与 Nginx 里的 upstream 端口完全一致） PORT_A="9821" PORT_B="9822" LOG_DIR="${APP_DIR}/logs" BACKUP_DIR="${APP_DIR}/backup" JAVA_OPTS="-Xms512m -Xmx1024m" KEEP_BACKUP_COUNT=3 # ================================================== # 环境基础检查与目录创建 check_env() { [ -x "${JAVA_PATH}" ] || { echo "❌ 错误: Java 不存在或不可执行: ${JAVA_PATH}"; exit 1; } [ -d "${APP_DIR}" ] || { echo "❌ 错误: 应用目录不存在: ${APP_DIR}"; exit 1; } mkdir -p "${LOG_DIR}" "${BACKUP_DIR}" } # 精准检查某个端口是否处于监听状态 is_port_listening() { local port="$1" if command -v ss >/dev/null 2>&1; then ss -lnt | awk '{print $4}' | grep -q ":${port}$" elif command -v netstat >/dev/null 2>&1; then netstat -lnt | awk '{print $4}' | grep -q ":${port}$" else # 备用方案：用 lsof 兜底 lsof -i:"${port}" -sTCP:LISTEN >/dev/null 2>&1 fi } # 根据端口精准获取其 Java 进程 PID get_pid_by_port() { local port="$1" lsof -t -i:"${port}" -sTCP:LISTEN || true } # 备份新上传的 jar 包到历史库，方便异常时回滚 backup_jar() { if [ -f "${UPLOAD_JAR}" ]; then local backup_file="${BACKUP_DIR}/procurement-server-$(date +%Y%m%d%H%M%S).jar" echo "📦 备份新包到历史库: ${backup_file}" cp "${UPLOAD_JAR}" "${backup_file}" # 保留最近指定数量的备份，其余删除 ls -1t "${BACKUP_DIR}/procurement-server-"*.jar 2>/dev/null | tail -n +"$((KEEP_BACKUP_COUNT + 1))" | xargs -r rm -f fi } # 动态启动指定端口的实例 start_instance() { local port="$1" local run_jar="${APP_DIR}/procurement-server-${port}.jar" local log_file="${LOG_DIR}/${APP_NAME}-${port}.log" local pid_file="${APP_DIR}/${APP_NAME}-${port}.pid" echo "▶️ 正在端口 ${port} 上部署新版本实例..." # 独立复制一份当前要运行的独立 jar，防止端口 A 和 B 发生读写冲突 cp "${UPLOAD_JAR}" "${run_jar}" chmod 755 "${run_jar}" # 后台启动进程 nohup "${JAVA_PATH}" ${JAVA_OPTS} -jar "${run_jar}" --spring.profiles.active=${PROFILE} --server.port=${port} >> "${log_file}" 2>&1 & local pid=$! echo "${pid}" > "${pid_file}" echo "⏳ 等待 Spring Boot 初始化并监听端口 ${port} (最多等待60秒)..." for i in {1..30}; do # 检查进程是否半路夭折 if ! ps -p "${pid}" > /dev/null 2>&1; then echo "❌ 端口 ${port} 启动失败，Java 进程已意外退出！请查看日志:" echo "tail -n 100 ${log_file}" rm -f "${pid_file}" exit 1 fi # 检查端口是否成功就绪 if is_port_listening "${port}"; then echo "✨ [成功] 端口 ${port} 已成功启动并进入就绪状态！" return 0 fi sleep 2 done echo "❌ 错误：进程虽在运行，但端口 ${port} 未能在 60 秒内建立监听，更新中止。" exit 1 } # 优雅停止指定端口的旧实例 stop_instance() { local port="$1" local pid pid=$(get_pid_by_port "${port}") if [ -z "${pid}" ]; then echo "ℹ️ 端口 ${port} 本就未运行，无需清理。" return 0 fi echo "⏹️ 正在向端口 ${port} 发送停机信号 (PID: ${pid})..." kill "${pid}" # 循环检查，给旧实例 30 秒时间执行 Spring Boot 的优雅停机 for i in {1..30}; do if ps -p "${pid}" > /dev/null 2>&1; then sleep 1 else echo "✅ 端口 ${port} 的旧实例已安全平滑退出。" rm -f "${APP_DIR}/${APP_NAME}-${port}.pid" rm -f "${APP_DIR}/procurement-server-${port}.jar" return 0 fi done # 顽固进程强制清理 echo "⚠️ 警告：端口 ${port} 未能优雅退出，执行强制 kill -9 释放空间..." kill -9 "${pid}" || true rm -f "${APP_DIR}/${APP_NAME}-${port}.pid" rm -f "${APP_DIR}/procurement-server-${port}.jar" } # 核心滚动发布逻辑 deploy_smart() { check_env if [ ! -f "${UPLOAD_JAR}" ]; then echo "❌ 错误：未在指定目录找到新上传的 jar 包!" echo "请先上传新包到: ${UPLOAD_JAR}" exit 1 fi backup_jar # 探测当前哪个端口在提供服务 local listen_a=0 local listen_b=0 is_port_listening "${PORT_A}" && listen_a=1 || listen_a=0 is_port_listening "${PORT_B}" && listen_b=1 || listen_b=0 echo "======================================" echo " 当前线上常驻状态:" echo " 端口 ${PORT_A} (A 组): $([ $listen_a -eq 1 ] && echo "🟢 正在运行" || echo "⚪ 未运行")" echo " 端口 ${PORT_B} (B 组): $([ $listen_b -eq 1 ] && echo "🟢 正在运行" || echo "⚪ 未运行")" echo "======================================" if [ $listen_a -eq 1 ] && [ $listen_b -eq 1 ]; then # 异常双活处理：通常是上次发布异常或者人工误操作 echo "⚠️ 异常提示: 检测到双端口同时在运行。" echo "系统将默认信任 A 组，现在重新部署并刷新 B 组..." stop_instance "${PORT_B}" start_instance "${PORT_B}" echo "💡 预留 5 秒让 Nginx 负载均衡器稳定识别新节点..." sleep 5 stop_instance "${PORT_A}" echo "🚀 双活异常已修复，新版已在端口 ${PORT_B} 稳定上线！" elif [ $listen_b -eq 1 ]; then # 当前是 B 在运行 -> 往 A 升级 echo "🎯 当前主力服务在 端口 B(${PORT_B})，将在 端口 A(${PORT_A}) 部署新版本..." start_instance "${PORT_A}" echo "💡 预留 5 秒让 Nginx 负载均衡器稳定识别新节点..." sleep 5 stop_instance "${PORT_B}" echo "🚀 【无感切换成功】新版本已在 A(${PORT_A}) 上线，旧版本 B 已功成身退。" else # 当前是 A 在运行，或者两个都没运行（首次初始化冷启动） if [ $listen_a -eq 1 ]; then # 当前是 A 在运行 -> 往 B 升级 echo "🎯 当前主力服务在 端口 A(${PORT_A})，将在 端口 B(${PORT_B}) 部署新版本..." start_instance "${PORT_B}" echo "💡 预留 5 秒让 Nginx 负载均衡器稳定识别新节点..." sleep 5 stop_instance "${PORT_A}" echo "🚀 【无感切换成功】新版本已在 B(${PORT_B}) 上线，旧版本 A 已功成身退。" else # 两个都没跑，纯冷启动 echo "❄️ 未检测到任何正在运行的实例，执行首次冷启动初始化..." start_instance "${PORT_A}" echo "🎉 项目首次初始化启动成功，运行在端口: ${PORT_A}" fi fi } # 查看双端口运行状态 status_app() { echo "=== 采购服务运行状态汇总 ===" if is_port_listening "${PORT_A}"; then echo "● 端口 ${PORT_A} -> 🟢 正在运行 (PID: $(get_pid_by_port ${PORT_A}))" else echo "○ 端口 ${PORT_A} -> ⚪ 未运行" fi if is_port_listening "${PORT_B}"; then echo "● 端口 ${PORT_B} -> 🟢 正在运行 (PID: $(get_pid_by_port ${PORT_B}))" else echo "○ 端口 ${PORT_B} -> ⚪ 未运行" fi echo "============================" } # 响应外部指令 case "$1" in restart) deploy_smart ;; status) status_app ;; stop) check_env echo "⚠️ 正在安全停止全站(A/B组)所有后端 Java 实例..." stop_instance "${PORT_A}" stop_instance "${PORT_B}" echo "⚙️ 所有实例已全部关停。" ;; *) echo "用法: $0 {restart|status|stop}" echo " $0 restart -> 执行无感、零停机平滑升级" echo " $0 status -> 检查当前双端口健康状态" echo " $0 stop -> 一键关停所有端口的实例" exit 1 ;; esac保存文件后，记得给脚本赋予执行权限chmod +x deploy.sh6. 复盘：不仅仅是省下了几秒钟这套方案上线后，我的日常更新动作变成了极其标准的固定流：每次本地打包完，直接把新的包重命名为 procurement-server.jar 上传到服务器。运行终端执行 ./deploy.sh restart。脚本在跑的时候，我特意挂着前端页面不停地连续刷新去请求数据，结果是没有任何一次报错，只有在新老交替、Nginx 刚刷新识别的那一下，某一个请求稍微迟钝了 0.5 秒，紧接着就恢复了流畅。对于单机单实例的生产环境来说，这几行脚本和几行 Nginx 配置，算是用最低的成本，换来了最扎实的可用性体验。
官方筛选不够用，我用油猴给链动小铺货源列表加了增强筛选
最近在链动小铺后台找货源时，有个感受比较明显：官方的搜索和筛选功能太少了。原页面能搜商品，也能看到成本价、库存、商家、对接状态这些信息，但真正找货源时，我更关心的是这些问题：哪些商品还有库存我的成本价是不是足够低哪些商品还没有对接某个分类下面有哪些商品能不能一次多看一些结果，而不是一直翻页这些信息页面上其实都有，只是缺少更顺手的筛选方式。我没有链动小铺前端和后端的修改权限，所以最后没有走“改页面源码”的路子，而是写了一个油猴脚本，在现有页面上加一个悬浮的增强筛选面板。这个方案不算正式产品功能，但对自己找货源、比价格、看库存来说，确实能省不少时间。为什么选油猴脚本这种需求有几种做法。如果能改官方系统，最稳的方式肯定是前端加筛选项，后端接口支持条件查询，然后按条件分页返回。但这次没这个条件。我能做的是在浏览器里增强当前页面。Chrome 插件也可以做，但对这个需求来说有点重。油猴脚本更合适：安装简单修改后刷新页面就能验证只针对一个页面生效可以直接复用当前页面的登录态不需要重新打包浏览器插件所以最后我选择 Tampermonkey。它的定位很清楚：不是替代官方功能，而是在自己浏览器里加一层辅助工具。先确认页面到底怎么搜在写脚本前，我先看了一下页面行为。链动小铺这个页面路径是：https://www.ldxp.cn/merchant/my_parent/source_square页面里有两个主要区域：货源广场商品搜索商品搜索不是一打开就直接显示表格，而是先输入关键词，点击搜索后才会展示商品结果。搜索时调用的是这个接口：/merchantApi/MyParent/searchGoodsList请求参数大概是这样：{ "current": 1, "pageSize": 50, "name": "", "goods_type": "card", "keywords": "GPT" }这个接口返回的数据里，已经有脚本需要的大部分字段，比如商品名称、分类、代理价、我的成本价、库存、商家、是否已对接。所以思路就很明确了：先用原接口把搜索结果拉到浏览器本地，再在本地做二次筛选。这里有一个限制要提前说清楚。这个脚本不是后端筛选。它只能筛已经拉到本地的数据。如果只拉了 5 页，那筛选范围就是这 5 页。想看更多，就需要把脚本里的“拉取页数”调大。脚本做了哪些功能最后做出来的辅助面板是一个悬浮窗。它不插入原页面内容区，避免把官方页面布局撑乱。目前主要功能有这些：关键词搜索商品类型筛选：卡密、知识、资源、权益拉取多页结果按我的成本价区间筛选按库存筛选：全部、有库存、缺货按状态筛选：全部、正常、未上架按对接状态筛选：全部、已对接、未对接按分类关键词筛选按商家名称筛选按我的成本价或库存排序每页显示 50、100、200、1000、10000 条首页、上一页、页码跳转、下一页、末页面板支持拖动、调整大小、收起展开最终效果大概是这样：关键代码油猴脚本头部先限制只在链动小铺对应页面生效。// ==UserScript== // @name 链动小铺货源商品增强筛选 // @namespace https://www.ldxp.cn/ // @version 0.4.1 // @description 在货源商品搜索页增加价格区间、库存、分类、对接状态等本地筛选，并支持一次拉取多页结果。 // @match https://www.ldxp.cn/merchant/my_parent/source_square* // @grant none // @run-at document-idle // ==/UserScript==这里我用了 @grant none。原因是这个脚本只请求当前站点自己的接口，不需要跨域，也不需要额外的 GM API。读取当前登录态原页面请求接口时会带 Merchant-Token。脚本里没有把 token 写死，而是从当前页面的 localStorage 里读。function getToken() { try { const raw = localStorage.getItem("auth-token"); if (!raw) return ""; const parsed = JSON.parse(raw); return parsed && parsed.value ? parsed.value : ""; } catch (_) { return ""; } }然后用 fetch 调接口。async function postJson(url, body) { const token = getToken(); const response = await fetch(url, { method: "POST", credentials: "include", headers: { "Content-Type": "application/json", Accept: "application/json, text/plain, */*", ...(token ? { "Merchant-Token": token } : {}), }, body: JSON.stringify(body), }); if (!response.ok) { throw new Error(`请求失败：HTTP ${response.status}`); } const data = await response.json(); if (data.code !== 1) { throw new Error(data.msg || "接口返回失败"); } return data.data || {}; }这个地方一定不要为了省事，把自己的 cookie、token、session 直接写进脚本。这些都是登录凭证，发文章或者发截图前也要检查一遍，避免泄露。拉取多页结果官方页面是分页搜索。脚本为了能筛更多数据，会按设置的页数循环请求。async function search() { readControls(); if (!filters.keywords) { setStatus("请输入关键词后再搜索。", true); return; } state.loading = true; state.raw = []; state.filtered = []; renderResults(); try { let total = 0; const pageSize = 50; const maxPages = filters.maxPages; for (let current = 1; current <= maxPages; current += 1) { setStatus(`正在拉取第 ${current} / ${maxPages} 页...`, false); const data = await postJson("/merchantApi/MyParent/searchGoodsList", { current, pageSize, name: "", goods_type: filters.goodsType, keywords: filters.keywords, }); const list = Array.isArray(data.list) ? data.list : []; total = Number(data.total) || total; state.raw.push(...list); if (list.length < pageSize || state.raw.length >= total) break; } state.loading = false; applyFilters(); setStatus(`已拉取 ${state.raw.length} 条，筛选后 ${state.filtered.length} 条。`, false); } catch (error) { state.loading = false; setStatus(error.message || String(error), true); renderResults(); } }这里有两个参数容易混。一个是拉取页数，决定从接口拿多少数据。一个是每页显示，决定增强表格里一次展示多少条。比如接口每页拉 50 条，拉取页数设置成 20，理论上最多会拉 1000 条。每页显示设置成 200，就会在脚本自己的表格里分 5 页展示。成本价和我的成本价这个地方我一开始处理得不够好。为了做价格筛选，我最开始只显示了一个最低价。但原页面的成本价其实是分代理等级展示的：普通代理 125元 金牌代理 115元 核心代理 105元这个信息不能丢。所以后面改成两列：成本价：按普通代理、金牌代理、核心代理分行显示我的成本价：显示当前账号实际成本价function getAgentPrices(item) { return [ ["普通代理", item.agent_price1], ["金牌代理", item.agent_price2], ["核心代理", item.agent_price3], ] .map(([label, value]) => [label, toNumber(value)]) .filter(([, value]) => value !== null && value >= 0); } function getMyCostPrice(item) { const cost = toNumber(item.cost_price); if (cost !== null) return cost; const limited = toNumber(item.agent_price_limit); if (limited !== null) return limited; const prices = getAgentPrices(item).map(([, value]) => value); return prices.length ? Math.min(...prices) : null; }价格区间筛选按「我的成本价」来做。因为找货源时，我真正关心的是自己当前账号拿到的成本，而不是其他代理等级的价格。本地筛选逻辑筛选逻辑放在浏览器本地执行。function itemMatches(item) { const minPrice = toNumber(filters.minPrice); const maxPrice = toNumber(filters.maxPrice); const price = getMyCostPrice(item); const stock = getStock(item); const category = getCategory(item).toLowerCase(); const merchant = getMerchant(item).toLowerCase(); if (minPrice !== null && (price === null || price < minPrice)) return false; if (maxPrice !== null && (price === null || price > maxPrice)) return false; if (filters.stock === "in" && !(stock === null || stock > 0)) return false; if (filters.stock === "out" && stock !== 0) return false; if (filters.status === "normal" && item.status !== 1) return false; if (filters.status === "off" && item.status === 1) return false; if (filters.connect === "yes" && !item.child) return false; if (filters.connect === "no" && item.child) return false; if (filters.category && !category.includes(filters.category.toLowerCase())) return false; if (filters.merchant && !merchant.includes(filters.merchant.toLowerCase())) return false; return true; }这段代码没有什么高级技巧，但比较实用。它解决的是一个很具体的问题：官方页面信息有了，但筛选入口不够。收起功能踩的一个小坑悬浮面板支持拖动和调整大小。这里有个小坑：如果用户手动调整过面板大小，浏览器可能会给面板留下内联宽高。这时如果只用 CSS class 做收起，可能会出现内容隐藏了，但白色外框还是原来那么大。所以收起时我改成用 JS 显式保存和恢复尺寸。function toggleCollapsed(panel, button) { const collapsed = panel.classList.toggle("is-collapsed"); if (collapsed) { const rect = panel.getBoundingClientRect(); panel.dataset.expandedWidth = panel.style.width || `${rect.width}px`; panel.dataset.expandedHeight = panel.style.height || `${rect.height}px`; panel.style.width = "300px"; panel.style.height = ""; panel.style.maxHeight = "56px"; panel.style.resize = "none"; panel.style.overflow = "hidden"; button.textContent = "展开"; return; } panel.style.width = panel.dataset.expandedWidth || ""; panel.style.height = panel.dataset.expandedHeight || ""; panel.style.maxHeight = ""; panel.style.resize = ""; panel.style.overflow = ""; button.textContent = "收起"; }这种外挂式页面增强，很多问题不是业务逻辑，而是和原页面布局、浏览器默认行为打交道。使用方式先安装 Tampermonkey。然后新建脚本，把完整脚本内容粘进去保存。刷新链动小铺货源页面：https://www.ldxp.cn/merchant/my_parent/source_square页面右上角会出现「货源商品增强筛选」悬浮面板。我的使用习惯是：输入关键词，比如 GPT选择商品类型，比如卡密拉取页数先设置 10 或 20点击增强搜索按我的成本价、库存、对接状态继续筛如果结果不够，再把拉取页数调大完整脚本下载：https://wwamg.lanzoue.com/ijYiT3ph149a
用 API Key 登录 Codex 后插件灰色不可用，Codex++ 帮我绕过了这个限制
用 Codex 的时候，我一直有个特别烦的问题。我是用 API Key 登录的，走的是第三方中转站，没有 ChatGPT 账号。Codex 主体功能都能正常用，但插件入口是灰色的，锁死的，点进去就一行提示：需要登录 ChatGPT 账号。这意味着插件功能跟我完全没关系。说白了，我花了钱买 API Key，主体功能能跑，但插件生态整个被挡在外面了。这个体验挺割裂的——你明明知道 Codex 有插件能力，但就是用不了。后来在 GitHub 上找到了 Codex++，装上之后插件功能终于能用了。下面把整个过程记一下。Codex++ 是什么Codex++ 是一个 Codex 的外部增强工具，GitHub 地址：BigPizzaV3/CodexPlusPlus它不修改 Codex 的原始安装文件，而是通过 Chromium DevTools Protocol（CDP）向 Codex 的渲染进程注入脚本来实现功能增强。简单理解就是：它是一个独立的启动器，从外面"接管" Codex 的启动过程，往里注入解锁逻辑。核心功能有两个：解锁插件入口 — API Key 登录模式下也能正常使用插件会话删除 — Codex 原生只有归档，没有真正的删除按钮，Codex++ 补上了依赖要求：Python 3.11+，已经装好 Codex App。安装步骤第一步：获取代码从 GitHub 克隆仓库到本地：git clone https://github.com/BigPizzaV3/CodexPlusPlus.git cd CodexPlusPlus第二步：安装依赖python -m pip install -e .这一步会把 Codex++ 及其依赖装好。第三步：运行安装Windows 用户可以直接双击项目根目录的 setup.bat，会弹出一个菜单：[1] Install Codex++ [2] Uninstall Codex++ [3] Update Codex++ [4] Exit选 1 就行。安装完成后桌面会生成一个 Codex++.lnk 快捷方式。也可以用命令行安装：python -m codex_session_delete setupmacOS 用户同样用命令行：python -m codex_session_delete setup安装后会在 /Applications/ 下生成 Codex++.app。第四步：通过 Codex++ 启动以后启动 Codex 不要直接点 Codex 的图标，要点 Codex++ 的快捷方式。它的工作流程是：以 --remote-debugging-port=9229 参数启动 Codex App启动本地 helper 服务通过 CDP 注入 renderer-inject.js 到渲染进程插件入口解锁，会话删除按钮出现第五步：验证效果启动后看两个地方：顶部菜单栏多了 Codex++ 菜单插件入口从灰色变成了可点击状态点进插件列表，能正常浏览和安装插件就说明生效了。还有个实用功能：会话删除除了解锁插件，Codex++ 还补了一个 Codex 一直没做的功能——会话删除。原生 Codex 的会话列表只有归档，没有删除。Codex++ 装上之后，鼠标悬停在会话上会显示"删除"按钮，点删除前有确认提示，还支持撤销。删除逻辑会优先走服务端，如果服务端不可用，就直接操作本地的 SQLite 数据库（~/.codex/state_5.sqlite）。删除前会自动备份到 ~/.codex-session-delete/backups，这点比较稳。遇到的问题安装过程中有几个地方要注意：端口冲突：Codex++ 启动时会用到 9229 端口做 CDP 通信。如果这个端口被占了，启动会失败。看一下 ~/.codex-session-delete/launcher.log 日志就能定位。代理问题：如果你的网络环境需要代理，Codex++ 启动时会自动探测常见本地代理端口（比如 127.0.0.1:7897）。如果探测不到，需要手动设置代理环境变量。Codex 更新后不兼容：因为 Codex++ 是通过注入脚本工作的，如果 Codex App 更新后页面结构变了，注入脚本可能失效。这时候需要更新 Codex++ 版本。检查更新：python -m codex_session_delete check-update执行更新：python -m codex_session_delete update卸载如果不想用了，卸载也很干净：Windows：系统设置 → 应用 → 已安装的应用，找到 Codex++ 卸载。或者命令行：python -m codex_session_delete remove如果连日志和备份都想删掉：python -m codex_session_delete remove --remove-datamacOS：python -m codex_session_delete remove因为 Codex++ 不修改 Codex 的原始文件，卸载后 Codex 本身不受影响。一个可选的高级功能：自动接管Windows 上还支持一个 watcher 功能。开启后，它会每 3 秒探测一次 CDP 端口，如果你直接双击了原生 Codex 而不是 Codex++，它会自动杀掉进程，用 Codex++ 重新拉起。python -m codex_session_delete watch-install不想用了：python -m codex_session_delete watch-remove这个功能适合经常忘记用 Codex++ 启动的人，但说实话我不太建议开，3 秒一次的探测多少有点资源消耗，养成用 Codex++ 快捷方式启动的习惯就行。我的建议如果你也是用 API Key 登录 Codex，之前被插件入口锁死困扰过，Codex++ 是目前最直接的解决方案。安装过程不复杂，Python 环境 + 一条 pip install + 一条 setup，三步搞定。装完之后从 Codex++ 快捷方式启动就行，体验和原生 Codex 一样，就是多了插件和删除会话的能力。唯一要注意的是 Codex 更新后可能需要同步更新 Codex++，保持两边版本匹配就好。
芋道yudao-ui-admin-vue3 打包从 2 分钟到 1 分钟，我做了这几件事
最近在基于 yudao-ui-admin-vue3（芋道 Vue3 后台管理框架）的项目里做了一次打包优化。芋道这个框架本身功能很全，但依赖也重。src 下 2000 多个文件，除了框架自带的 element-plus、vue-i18n，业务侧还加了 echarts、bpmn-js、form-create 表单设计器、富文本编辑器、甘特图、思维导图这些重型库。打包时间一直在 2 分钟以上，这次优化后降到了 1 分 8 秒。核心就改了四个地方，记录一下。用芋道框架的同学可以直接抄。先说结论优化项改动效果Terser → esbuild去掉 minify: 'terser'压缩阶段快 20-100 倍构建期 ESLintbuild 时不注册 eslint 插件省了约 50 秒压缩大小报告reportCompressedSize: false省掉 gzip 计算chunk 拆分大库单独拆 chunk并行压缩一、最大的一刀：去掉 Terser这是最核心的优化。芋道模板的 vite.config.ts 里默认写了 minify: 'terser'，带着 drop_console 和 drop_debugger 的 terserOptions。这个配置在项目小的时候没什么感觉，但文件一多就成了瓶颈。Terser 是纯 JavaScript 实现的压缩工具，Vite 5 默认用的 esbuild 是 Go 写的，速度差了一个量级。我一开始没注意到这个配置，先去折腾别的了。后来发现压缩阶段占了打包大头，才回头看到这一行。改法很简单，把 minify: 'terser' 和 terserOptions 整段删掉就行：// 芋道模板默认配置 build: { minify: 'terser', terserOptions: { compress: { drop_debugger: env.VITE_DROP_DEBUGGER === 'true', drop_console: env.VITE_DROP_CONSOLE === 'true' } }, } // 改成这样，不写 minify，走 esbuild 默认 build: { // 删掉 minify 和 terserOptions }如果你确实需要 drop_console，可以用 vite-plugin-drop-console 之类的插件在编译阶段处理，不用靠 Terser。另外最新 Vite 8 已经默认用 Oxc Minifier（Rust 实现），官方说比 Terser 快 30-90 倍，压缩率只差 0.5-2%。后续升级 Vite 版本这个问题会自动解决。二、构建时禁用 ESLint 插件芋道模板用了 vite-plugin-eslint，配置了 lintOnStart: false。我一开始以为这个配置能让 build 时跳过 lint。结果不是。vite-plugin-eslint 的 linting 发生在 Vite 的 transform 钩子里，也就是每个文件编译时都会跑一次 ESLint。lintOnStart 只控制 dev server 启动时是否 lint，跟 build 时的 transform 完全没关系。2000 多个文件，每个文件过一遍 ESLint，这个开销非常可观。正确的做法是 build 时不注册这个插件。我改成了条件注册：// build/vite/index.ts export function createVitePlugins(isBuild = false) { return [ Vue(), VueJsx(), // ... 其他插件 // 只在 dev 时注册 ESLint ...(!isBuild ? [EslintPlugin({ cache: false, include: ['src/**/*.vue', 'src/**/*.ts', 'src/**/*.tsx'], })] : []), ] }// vite.config.ts const isBuild = command === 'build' plugins: createVitePlugins(isBuild),这样 dev 时正常 lint，build 时完全跳过。lint 这种事应该在 CI 或 commit 阶段做，不应该拖慢打包。这一个改动单独省了将近 50 秒。三、关闭压缩大小报告Vite 默认会在打包完成后计算每个 chunk 的 gzip 大小，输出到控制台。小项目无所谓，但芋道项目 chunk 多、体积大，这个计算本身也要花好几秒。加一行配置关掉：build: { reportCompressedSize: false, }四、更精细的 chunk 拆分芋道模板默认只拆了三个 chunk：manualChunks: { echarts: ['echarts'], 'form-create': ['@form-create/element-ui'], 'form-designer': ['@form-create/designer'], }但芋道本身还带了一堆大库：element-plus、bpmn-js、wangeditor 等，业务侧再加 dhtmlx-gantt、markmap、video.js，全被打进 vendor chunk，压缩时单线程卡住。拆细之后：manualChunks: { echarts: ['echarts'], 'form-create': ['@form-create/element-ui'], 'form-designer': ['@form-create/designer'], 'element-plus': ['element-plus', '@element-plus/icons-vue'], bpmn: ['bpmn-js', 'bpmn-js-properties-panel', 'bpmn-js-token-simulation'], wangeditor: ['@wangeditor-next/editor', '@wangeditor-next/editor-for-vue'], gantt: ['dhtmlx-gantt'], markmap: ['markmap-lib', 'markmap-view', 'markmap-common', 'markmap-toolbar'], video: ['video.js'], }多个小 chunk 可以并行压缩，比一个巨型 vendor 效率高很多。同时用户访问时按需加载，不会一次性下载一个巨大的 JS 文件。具体拆哪些看你项目实际引入了什么，没用到的不用管。核心思路就是：体积大、独立性强的库单独拆出来。最终效果改完之后跑一次 pnpm build:local，从 2 分 4 秒降到了 1 分 8 秒。对于芋道这种 2000 多文件的项目来说，这个时间已经可以接受了。如果后续想继续优化，可以考虑升级到 Vite 6+，用内置的 Oxc 压缩器。用芋道框架的同学，第一刀先砍 Terser，效果最明显。
Navicat Premium 是强大的一体化数据库开发解决方案，可从单一应用程序无缝连接多个数据库，包括 MySQL、PostgreSQL、MongoDB、MariaDB、SQL Server、Oracle、SQLite、Redis 和 Snowflake。同时，它与达梦、金仓、GaussDB、OceanBase、TiDB、IvorySQL、PolarDB 数据库及阿里云、腾讯云和华为云等主流云数据库兼容。你可以快速轻松地创建、管理和维护数据库。Navicat 原生的用户界面为用户带来了简化数据库操作的舒适体验，使数据库的设计、管理和维护比以往任何时候都更加轻松。无论你是执行复杂的查询、执行数据迁移、调整数据库的数据模型，还是利用强大的商业智能工具进行深入分析，Navicat Premium 以丰富的功能和 AI 助手的智能辅助，全方位优化数据库性能并提高生产力。软件截图：下载链接:https://pan.baidu.com/s/1q43jSuHqU9p8YLNQZ9fA_A?pwd=5200 提取码: 5200如果觉得软件好用，建议购买官方正版授权，支持开发者持续更新与维护。
误删 .git 目录后怎么恢复远程仓库连接，完整操作记录
今天操作项目的时候，不小心把 .git 目录删了。代码文件都在，但 git status 已经跑不了了，git 信息全没了。这个项目是一个 Vue3 的后台管理系统，远程仓库在 Gitee 上。我不想重新 git init 然后强推，那样会和远程仓库的历史完全断裂，后面协作会出大问题。所以目标很明确：在不丢失远程提交记录的前提下，重新把本地代码和远程仓库连起来。先说结论四步搞定：git init git remote add origin https://gitee.com/your-org/your-project.git git fetch origin git reset --hard origin/master注意最后一个参数，是 origin/master 不是 origin/main。这个坑下面单独讲。具体操作过程第一步：重新初始化本地仓库git init这一步只是在当前目录重新创建一个空的 .git 目录，不会影响你的代码文件。第二步：重新添加远程仓库地址git remote add origin https://gitee.com/your-org/your-project.git把你的远程仓库地址换成你自己的就行。如果是 GitHub，地址格式一样，只是域名不同。第三步：从远程拉取分支信息git fetch origin这一步会把远程的所有分支和提交记录拉到本地。拉完之后我看到的输出是：* [new branch] master -> origin/master注意这里已经暴露了一个关键信息：远程的主分支叫 master，不叫 main。第四步：重置本地代码到远程分支git reset --hard origin/master执行完之后显示：HEAD is now at af7bc9f first commit到这里代码就已经和远程完全一致了。踩坑：分支名不匹配一开始我本能地写了：git reset --hard origin/main然后报了这个错：fatal: ambiguous argument 'origin/main': unknown revision or path not in the working tree.这个报错不算直观，ambiguous argument 看起来像参数格式问题，但实际原因很简单：远程根本没有 main 分支，只有 master 分支。现在 Git 的默认主分支名确实是 main，但很多老项目、Gitee 上不少项目用的还是 master。所以 git fetch 完之后，先看看拉下来的是什么分支名，再执行 reset。一个简单的判断方法：git fetch 之后，跑一下 git branch -r 就能看到远程有哪些分支。最后设置上游跟踪reset 之后我检查了一下状态，发现 upstream 还没配。执行：git branch --set-upstream-to=origin/master master这样后续 git pull 和 git push 就能正常追踪远程分支了，不用每次手动指定。这次操作的前提有一点要注意：这套操作能成功，是因为远程仓库的代码和我本地删 .git 之前的代码基本一致。如果本地有很多未提交的新改动，git reset --hard 之后这些改动会全部丢掉。所以如果你本地有未提交的代码，建议先备份一份，reset 之后再手动合进去。我这次的情况是本地有两个未跟踪的新目录，是新加的接口和页面文件。因为还没 add 过，所以 git reset --hard 不会影响它们——hard reset 只作用于已经被 git 跟踪的文件。总结步骤命令作用初始化git init创建空 .git 目录添加远程git remote add origin <url>关联远程仓库拉取分支git fetch origin获取远程分支信息重置代码git reset --hard origin/<branch>对齐到远程分支设置跟踪git branch --set-upstream-to=origin/<branch> <branch>配置上下游关系整个过程不到两分钟，代码没丢，历史没断，和直接 git clone 一个新仓库效果一样。以后万一再遇到 .git 被误删的情况，别慌，照着来就行。
清除此电脑中WPS网盘/百度网盘等图标方法
主要是在使用的时候，发现每个订阅的分组方式都不一样，规则也比较不同。找了一个可以统一的，覆写脚本覆写新建JavaScript// Mihomo Party 覆写 / Clash Verge Rev 扩展脚本 function main(params) { if (!params.proxies) return params; overwriteBasicOptions(params); overwriteDns(params); overwriteFakeIpFilter(params); overwriteNameserverPolicy(params); overwriteHosts(params); overwriteTunnel(params); overwriteProxyGroups(params); overwriteRules(params); return params; } // 覆写Basic Options function overwriteBasicOptions(params) { const otherOptions = { "mixed-port": 7897, "allow-lan": true, mode: "rule", "log-level": "warning", ipv6: false, "find-process-mode": "strict", profile: { "store-selected": true, "store-fake-ip": true, }, "unified-delay": true, "tcp-concurrent": true, "global-client-fingerprint": "chrome", sniffer: { enable: true, sniff: { HTTP: { ports: [80, "8080-8880"], "override-destination": true, }, TLS: { ports: [443, 8443], }, QUIC: { ports: [443, 8443], }, }, "skip-domain": ["Mijia Cloud", "+.push.apple.com"] }, }; Object.keys(otherOptions).forEach((key) => { params[key] = otherOptions[key]; }); } // 覆写DNS function overwriteDns(params) { const dnsList = [ "https://223.5.5.5/dns-query", "https://doh.pub/dns-query", ]; const proxyDnsList = [ "https://223.5.5.5/dns-query", "https://doh.pub/dns-query", ]; const dnsOptions = { enable: true, "prefer-h3": true, ipv6: false, "enhanced-mode": "fake-ip", "fake-ip-range": "198.18.0.1/16", nameserver: dnsList, "proxy-server-nameserver": proxyDnsList, }; params.dns = { ...dnsOptions }; } // 覆写DNS.Fake IP Filter function overwriteFakeIpFilter (params) { const fakeIpFilter = [ "+.m2m", "injections.adguard.org", "local.adguard.org", "+.bogon", "+.local", "+.lan", "+.internal", "+.localdomain", "home.arpa", "127.0.0.1.sslip.io", "127.atlas.skk.moe", "dns.msftncsi.com", "*.srv.nintendo.net", "*.stun.playstation.net", "xbox.*.microsoft.com", "*.xboxlive.com", "*.turn.twilio.com", "*.stun.twilio.com", "stun.syncthing.net", "stun.*", "127.*.*.*.sslip.io", "127-*-*-*.sslip.io", "*.127.*.*.*.sslip.io", "*-127-*-*-*.sslip.io", "127.*.*.*.nip.io", "127-*-*-*.nip.io", "*.127.*.*.*.nip.io", "*-127-*-*-*.nip.io" ]; params.dns["fake-ip-filter"] = fakeIpFilter; } // 覆写DNS.Nameserver Policy function overwriteNameserverPolicy (params) { const nameserverPolicy = { "dns.alidns.com": "quic://223.5.5.5:853", "dot.pub": "119.29.29.29", "doh.pub": "119.29.29.29", "dns.pub": "119.29.29.29", "doh.360.cn": "101.198.198.198", "rule-set:mihomo_nameserver_policy_alibaba": "quic://dns.alidns.com:853", "rule-set:mihomo_nameserver_policy_tencent": "https://doh.pub/dns-query", "rule-set:mihomo_nameserver_policy_bilibili": "https://doh.pub/dns-query", "rule-set:mihomo_nameserver_policy_xiaomi": "https://doh.pub/dns-query", "rule-set:mihomo_nameserver_policy_bytedance": "180.184.2.2", "rule-set:mihomo_nameserver_policy_baidu": "180.76.76.76", "rule-set:mihomo_nameserver_policy_qihoo360": "https://doh.360.cn/dns-query", "rule-set:mihomo_nameserver_policy_hotspot_captive_portal": "system", "*.m2m": "system", "injections.adguard.org": "system", "local.adguard.org": "system", "*.bogon": "system", "rule-set:mihomo_nameserver_policy_lan_without_real_ip": "system", "rule-set:mihomo_nameserver_policy_lan_with_realip": "system" }; params.dns["nameserver-policy"] = nameserverPolicy; } // 覆写hosts function overwriteHosts (params) { const hosts = { "dns.alidns.com": ['223.5.5.5', '223.6.6.6', '2400:3200:baba::1', '2400:3200::1'], "127.0.0.1.sslip.io": "127.0.0.1", "127.atlas.skk.moe": "127.0.0.1", "cdn.jsdelivr.net": "cdn.jsdelivr.net.cdn.cloudflare.net" }; params.hosts = hosts; } // 覆写Tunnel function overwriteTunnel(params) { const tunnelOptions = { enable: true, stack: "system", device: "tun0", "dns-hijack": ["any:53", "tcp://any:53"], "auto-route": true, "auto-detect-interface": true, "strict-route": true, // 根据自己环境来看要排除哪些网段 "route-exclude-address": [], }; params.tun = { ...tunnelOptions }; } // 覆写代理组 function overwriteProxyGroups(params) { // 所有代理 const allProxies = params["proxies"].map((e) => e.name); // 公共的正则片段 const excludeTerms = "剩余|到期|主页|官网|游戏|关注|网站|地址|有效|网址|禁止|邮箱|发布|客服|订阅|节点|问题|联系"; // 包含条件：各个国家或地区的关键词 const includeTerms = { HK: "(香港|HK|Hong|🇭🇰)", TW: "(台湾|TW|Taiwan|Wan|🇹🇼|🇨🇳)", SG: "(新加坡|狮城|SG|Singapore|🇸🇬)", JP: "(日本|JP|Japan|🇯🇵)", KR: "(韩国|韓|KR|Korea|🇰🇷)", US: "(美国|US|United States|America|🇺🇸)", UK: "(英国|UK|United Kingdom|🇬🇧)", FR: "(法国|FR|France|🇫🇷)", DE: "(德国|DE|Germany|🇩🇪)" }; // 合并所有国家关键词，供"其它"条件使用 const allCountryTerms = Object.values(includeTerms).join("|"); // 自动代理组正则表达式配置 const autoProxyGroupRegexs = [ { name: "HK - 自动选择", regex: new RegExp(`^(?=.*${includeTerms.HK})(?!.*${excludeTerms}).*$`, "i") }, { name: "TW - 自动选择", regex: new RegExp(`^(?=.*${includeTerms.TW})(?!.*${excludeTerms}).*$`, "i") }, { name: "SG - 自动选择", regex: new RegExp(`^(?=.*${includeTerms.SG})(?!.*${excludeTerms}).*$`, "i") }, { name: "JP - 自动选择", regex: new RegExp(`^(?=.*${includeTerms.JP})(?!.*${excludeTerms}).*$`, "i") }, { name: "KR - 自动选择", regex: new RegExp(`^(?=.*${includeTerms.KR})(?!.*${excludeTerms}).*$`, "i") }, { name: "US - 自动选择", regex: new RegExp(`^(?=.*${includeTerms.US})(?!.*${excludeTerms}).*$`, "i") }, { name: "UK - 自动选择", regex: new RegExp(`^(?=.*${includeTerms.UK})(?!.*${excludeTerms}).*$`, "i") }, { name: "FR - 自动选择", regex: new RegExp(`^(?=.*${includeTerms.FR})(?!.*${excludeTerms}).*$`, "i") }, { name: "DE - 自动选择", regex: new RegExp(`^(?=.*${includeTerms.DE})(?!.*${excludeTerms}).*$`, "i") }, { name: "其它 - 自动选择", regex: new RegExp(`^(?!.*(?:${allCountryTerms}|${excludeTerms})).*$`, "i") } ]; const autoProxyGroups = autoProxyGroupRegexs .map((item) => ({ name: item.name, type: "url-test", url: "https://cp.cloudflare.com", interval: 300, tolerance: 50, proxies: getProxiesByRegex(params, item.regex), hidden: true, })) .filter((item) => item.proxies.length > 0); // 手动选择代理组 const manualProxyGroups = [ { name: "HK - 手动选择", regex: new RegExp(`^(?=.*${includeTerms.HK})(?!.*${excludeTerms}).*$`, "i"), icon: "https://raw.githubusercontent.com/Orz-3/mini/master/Color/HK.png" }, { name: "JP - 手动选择", regex: new RegExp(`^(?=.*${includeTerms.JP})(?!.*${excludeTerms}).*$`, "i"), icon: "https://raw.githubusercontent.com/Orz-3/mini/master/Color/JP.png" }, { name: "KR - 手动选择", regex: new RegExp(`^(?=.*${includeTerms.KR})(?!.*${excludeTerms}).*$`, "i"), icon: "https://raw.githubusercontent.com/Orz-3/mini/master/Color/KR.png" }, { name: "SG - 手动选择", regex: new RegExp(`^(?=.*${includeTerms.SG})(?!.*${excludeTerms}).*$`, "i"), icon: "https://raw.githubusercontent.com/Orz-3/mini/master/Color/SG.png" }, { name: "US - 手动选择", regex: new RegExp(`^(?=.*${includeTerms.US})(?!.*${excludeTerms}).*$`, "i"), icon: "https://raw.githubusercontent.com/Orz-3/mini/master/Color/US.png" }, { name: "UK - 手动选择", regex: new RegExp(`^(?=.*${includeTerms.UK})(?!.*${excludeTerms}).*$`, "i"), icon: "https://raw.githubusercontent.com/Orz-3/mini/master/Color/UK.png" }, { name: "FR - 手动选择", regex: new RegExp(`^(?=.*${includeTerms.FR})(?!.*${excludeTerms}).*$`, "i"), icon: "https://raw.githubusercontent.com/Orz-3/mini/master/Color/FR.png" }, { name: "DE - 手动选择", regex: new RegExp(`^(?=.*${includeTerms.DE})(?!.*${excludeTerms}).*$`, "i"), icon: "https://raw.githubusercontent.com/Orz-3/mini/master/Color/DE.png" }, { name: "TW - 手动选择", regex: new RegExp(`^(?=.*${includeTerms.TW})(?!.*${excludeTerms}).*$`, "i"), icon: "https://raw.githubusercontent.com/Orz-3/mini/master/Color/TW.png" } ]; const manualProxyGroupsConfig = manualProxyGroups .map((item) => ({ name: item.name, type: "select", proxies: getManualProxiesByRegex(params, item.regex), icon: item.icon, hidden: false, })) .filter((item) => item.proxies.length > 0); // 负载均衡策略 // 可选值：round-robin / consistent-hashing / sticky-sessions // round-robin：轮询 按顺序循环使用代理列表中的节点 // consistent-hashing：散列 根据请求的哈希值将请求分配到固定的节点 // sticky-sessions：缓存 对「你的设备IP + 目标地址」组合计算哈希值，根据哈希值将请求分配到固定的节点 缓存 10 分钟过期 // 默认值：consistent-hashing const loadBalanceStrategy = "consistent-hashing"; const groups = [ { name: "🎯 节点选择", type: "select", url: "https://cp.cloudflare.com", icon: "https://raw.githubusercontent.com/Orz-3/mini/master/Color/Static.png", proxies: [ "自动选择", "手动选择", "⚖️ 负载均衡", "DIRECT", ], }, { name: "手动选择", type: "select", icon: "https://raw.githubusercontent.com/Orz-3/mini/master/Color/Cylink.png", proxies: ["HK - 手动选择", "JP - 手动选择", "KR - 手动选择", "SG - 手动选择", "US - 手动选择", "UK - 手动选择", "FR - 手动选择", "DE - 手动选择", "TW - 手动选择"], }, { name: "自动选择", type: "select", icon: "https://raw.githubusercontent.com/Orz-3/mini/master/Color/Urltest.png", proxies: ["ALL - 自动选择"], }, { name: "⚖️ 负载均衡", type: "load-balance", url: "https://cp.cloudflare.com", interval: 300, strategy: loadBalanceStrategy, proxies: allProxies, icon: "https://raw.githubusercontent.com/Orz-3/mini/master/Color/Available.png" }, { name: "ALL - 自动选择", type: "url-test", url: "https://cp.cloudflare.com", interval: 300, tolerance: 50, proxies: allProxies, hidden: true, }, { name: "✈️ 电报信息", type: "select", proxies: ["🎯 节点选择", "HK - 自动选择", "JP - 自动选择", "KR - 自动选择", "SG - 自动选择", "US - 自动选择", "UK - 自动选择", "FR - 自动选择", "DE - 自动选择", "TW - 自动选择", "其它 - 自动选择"], // "include-all": true, icon: "https://raw.githubusercontent.com/Orz-3/mini/master/Color/Telegram.png" }, { name: "🤖 AIGC", type: "select", proxies: ["US - 自动选择", "🎯 节点选择", "HK - 自动选择", "JP - 自动选择", "KR - 自动选择", "SG - 自动选择", "UK - 自动选择", "FR - 自动选择", "DE - 自动选择", "TW - 自动选择", "其它 - 自动选择"], // "include-all": true, icon: "https://raw.githubusercontent.com/Orz-3/mini/master/Color/OpenAI.png" }, { name: "🍎 苹果服务", type: "select", proxies: ["DIRECT", "🎯 节点选择", "HK - 自动选择", "JP - 自动选择", "KR - 自动选择", "SG - 自动选择", "US - 自动选择", "UK - 自动选择", "FR - 自动选择", "DE - 自动选择", "TW - 自动选择", "其它 - 自动选择"], // "include-all": true, icon: "https://raw.githubusercontent.com/Orz-3/mini/master/Color/Apple.png" }, { name: "Ⓜ️ 微软服务", type: "select", proxies: ["DIRECT", "🎯 节点选择", "HK - 自动选择", "JP - 自动选择", "KR - 自动选择", "SG - 自动选择", "US - 自动选择", "UK - 自动选择", "FR - 自动选择", "DE - 自动选择", "TW - 自动选择", "其它 - 自动选择"], // "include-all": true, icon: "https://raw.githubusercontent.com/Orz-3/mini/master/Color/Microsoft.png" }, ]; autoProxyGroups.length && groups[2].proxies.unshift(...autoProxyGroups.map((item) => item.name)); groups.push(...autoProxyGroups); groups.push(...manualProxyGroupsConfig); params["proxy-groups"] = groups; } // 覆写规则 function overwriteRules(params) { const adNonipRules = [ "RULE-SET,reject_non_ip,REJECT", "RULE-SET,reject_domainset,REJECT", "RULE-SET,reject_non_ip_drop,REJECT-DROP", "RULE-SET,reject_non_ip_no_drop,REJECT" ]; const customRules = [ // 在此添加自定义规则，优先级次于ad。例子： // "DOMAIN,baidu.com,DIRECT", ]; const nonipRules = [ "RULE-SET,cdn_domainset,🎯 节点选择", "RULE-SET,cdn_non_ip,🎯 节点选择", "RULE-SET,stream_non_ip,US - 自动选择", "RULE-SET,telegram_non_ip,✈️ 电报信息", "RULE-SET,apple_cdn,DIRECT", "RULE-SET,download_domainset,🎯 节点选择", "RULE-SET,download_non_ip,🎯 节点选择", "RULE-SET,microsoft_cdn_non_ip,DIRECT", "RULE-SET,apple_cn_non_ip,DIRECT", "RULE-SET,apple_services,🍎 苹果服务", "RULE-SET,microsoft_non_ip,Ⓜ️ 微软服务", "RULE-SET,ai_non_ip,🤖 AIGC", "RULE-SET,global_non_ip,🎯 节点选择", "RULE-SET,domestic_non_ip,DIRECT", "RULE-SET,direct_non_ip,DIRECT", "RULE-SET,lan_non_ip,DIRECT" ]; const allNonipRules = [ ...adNonipRules, ...customRules, ...nonipRules ]; const ipRules = [ "RULE-SET,reject_ip,REJECT", "RULE-SET,telegram_ip,✈️ 电报信息", "RULE-SET,stream_ip,US - 自动选择", "RULE-SET,lan_ip,DIRECT", "RULE-SET,domestic_ip,DIRECT", "RULE-SET,china_ip,DIRECT", "MATCH,🎯 节点选择" ]; const rules = [ // 非ip类规则 ...allNonipRules, // ip类规则 ...ipRules ]; const baseRuleProviders = { // 去广告 reject_non_ip_no_drop: { type: "http", behavior: "classical", url: "https://ruleset.skk.moe/Clash/non_ip/reject-no-drop.txt", path: "./rule_set/sukkaw_ruleset/reject_non_ip_no_drop.txt", interval: 43200, format: "text", proxy: "🎯 节点选择" }, reject_non_ip_drop: { type: "http", behavior: "classical", url: "https://ruleset.skk.moe/Clash/non_ip/reject-drop.txt", path: "./rule_set/sukkaw_ruleset/reject_non_ip_drop.txt", interval: 43200, format: "text", proxy: "🎯 节点选择" }, reject_non_ip: { type: "http", behavior: "classical", url: "https://ruleset.skk.moe/Clash/non_ip/reject.txt", path: "./rule_set/sukkaw_ruleset/reject_non_ip.txt", interval: 43200, format: "text", proxy: "🎯 节点选择" }, reject_domainset: { type: "http", behavior: "domain", url: "https://ruleset.skk.moe/Clash/domainset/reject.txt", path: "./rule_set/sukkaw_ruleset/reject_domainset.txt", interval: 43200, format: "text", proxy: "🎯 节点选择" }, reject_ip: { type: "http", behavior: "classical", url: "https://ruleset.skk.moe/Clash/ip/reject.txt", path: "./rule_set/sukkaw_ruleset/reject_ip.txt", interval: 43200, format: "text", proxy: "🎯 节点选择" }, // 静态cdn cdn_domainset: { type: "http", behavior: "domain", url: "https://ruleset.skk.moe/Clash/domainset/cdn.txt", path: "./rule_set/sukkaw_ruleset/cdn_domainset.txt", interval: 43200, format: "text", proxy: "🎯 节点选择" }, cdn_non_ip: { type: "http", behavior: "domain", url: "https://ruleset.skk.moe/Clash/non_ip/cdn.txt", path: "./rule_set/sukkaw_ruleset/cdn_non_ip.txt", interval: 43200, format: "text", proxy: "🎯 节点选择" }, // 流媒体 stream_non_ip: { type: "http", behavior: "classical", url: "https://ruleset.skk.moe/Clash/non_ip/stream.txt", path: "./rule_set/sukkaw_ruleset/stream_non_ip.txt", interval: 43200, format: "text", proxy: "🎯 节点选择" }, stream_ip: { type: "http", behavior: "classical", url: "https://ruleset.skk.moe/Clash/ip/stream.txt", path: "./rule_set/sukkaw_ruleset/stream_ip.txt", interval: 43200, format: "text", proxy: "🎯 节点选择" }, // AIGC ai_non_ip: { type: "http", behavior: "classical", url: "https://ruleset.skk.moe/Clash/non_ip/ai.txt", path: "./rule_set/sukkaw_ruleset/ai_non_ip.txt", interval: 43200, format: "text", proxy: "🎯 节点选择" }, // telegram telegram_non_ip: { type: "http", behavior: "classical", url: "https://ruleset.skk.moe/Clash/non_ip/telegram.txt", path: "./rule_set/sukkaw_ruleset/telegram_non_ip.txt", interval: 43200, format: "text", proxy: "🎯 节点选择" }, telegram_ip: { type: "http", behavior: "classical", url: "https://ruleset.skk.moe/Clash/ip/telegram.txt", path: "./rule_set/sukkaw_ruleset/telegram_ip.txt", interval: 43200, format: "text", proxy: "🎯 节点选择" }, // apple apple_cdn: { type: "http", behavior: "domain", url: "https://ruleset.skk.moe/Clash/domainset/apple_cdn.txt", path: "./rule_set/sukkaw_ruleset/apple_cdn.txt", interval: 43200, format: "text", proxy: "🎯 节点选择" }, apple_services: { type: "http", behavior: "classical", url: "https://ruleset.skk.moe/Clash/non_ip/apple_services.txt", path: "./rule_set/sukkaw_ruleset/apple_services.txt", interval: 43200, format: "text", proxy: "🎯 节点选择" }, apple_cn_non_ip: { type: "http", behavior: "classical", url: "https://ruleset.skk.moe/Clash/non_ip/apple_cn.txt", path: "./rule_set/sukkaw_ruleset/apple_cn_non_ip.txt", interval: 43200, format: "text", proxy: "🎯 节点选择" }, // microsoft microsoft_cdn_non_ip: { type: "http", behavior: "classical", url: "https://ruleset.skk.moe/Clash/non_ip/microsoft_cdn.txt", path: "./rule_set/sukkaw_ruleset/microsoft_cdn_non_ip.txt", interval: 43200, format: "text", proxy: "🎯 节点选择" }, microsoft_non_ip: { type: "http", behavior: "classical", url: "https://ruleset.skk.moe/Clash/non_ip/microsoft.txt", path: "./rule_set/sukkaw_ruleset/microsoft_non_ip.txt", interval: 43200, format: "text", proxy: "🎯 节点选择" }, // 软件更新、操作系统等大文件下载 download_domainset: { type: "http", behavior: "domain", url: "https://ruleset.skk.moe/Clash/domainset/download.txt", path: "./rule_set/sukkaw_ruleset/download_domainset.txt", interval: 43200, format: "text", proxy: "🎯 节点选择" }, download_non_ip: { type: "http", behavior: "domain", url: "https://ruleset.skk.moe/Clash/non_ip/download.txt", path: "./rule_set/sukkaw_ruleset/download_non_ip.txt", interval: 43200, format: "text", proxy: "🎯 节点选择" }, // 内网域名和局域网 IP lan_non_ip: { type: "http", behavior: "classical", url: "https://ruleset.skk.moe/Clash/non_ip/lan.txt", path: "./rule_set/sukkaw_ruleset/lan_non_ip.txt", interval: 43200, format: "text", proxy: "🎯 节点选择" }, lan_ip: { type: "http", behavior: "classical", url: "https://ruleset.skk.moe/Clash/ip/lan.txt", path: "./rule_set/sukkaw_ruleset/lan_ip.txt", interval: 43200, format: "text", proxy: "🎯 节点选择" }, domestic_non_ip: { type: "http", behavior: "classical", url: "https://ruleset.skk.moe/Clash/non_ip/domestic.txt", path: "./rule_set/sukkaw_ruleset/domestic_non_ip.txt", interval: 43200, format: "text", proxy: "🎯 节点选择" }, direct_non_ip: { type: "http", behavior: "classical", url: "https://ruleset.skk.moe/Clash/non_ip/direct.txt", path: "./rule_set/sukkaw_ruleset/direct_non_ip.txt", interval: 43200, format: "text", proxy: "🎯 节点选择" }, global_non_ip: { type: "http", behavior: "classical", url: "https://ruleset.skk.moe/Clash/non_ip/global.txt", path: "./rule_set/sukkaw_ruleset/global_non_ip.txt", interval: 43200, format: "text", proxy: "🎯 节点选择" }, domestic_ip: { type: "http", behavior: "classical", url: "https://ruleset.skk.moe/Clash/ip/domestic.txt", path: "./rule_set/sukkaw_ruleset/domestic_ip.txt", interval: 43200, format: "text", proxy: "🎯 节点选择" }, china_ip: { type: "http", behavior: "ipcidr", url: "https://ruleset.skk.moe/Clash/ip/china_ip.txt", path: "./rule_set/sukkaw_ruleset/china_ip.txt", interval: 43200, format: "text", proxy: "🎯 节点选择" } }; // 来自上游的 rule-providers const overwriteRuleProviders = { mihomo_nameserver_policy_alibaba: { type: "http", path: "./sukkaw_ruleset/mihomo_nameserver_policy_alibaba.txt", url: "https://ruleset.skk.moe/Internal/mihomo_nameserver_policy/alibaba.txt", behavior: "classical", format: "text", interval: 43200 }, mihomo_nameserver_policy_tencent: { type: "http", path: "./sukkaw_ruleset/mihomo_nameserver_policy_tencent.txt", url: "https://ruleset.skk.moe/Internal/mihomo_nameserver_policy/tencent.txt", behavior: "classical", format: "text", interval: 43200 }, mihomo_nameserver_policy_bilibili: { type: "http", path: "./sukkaw_ruleset/mihomo_nameserver_policy_bilibili.txt", url: "https://ruleset.skk.moe/Internal/mihomo_nameserver_policy/bilibili.txt", behavior: "classical", format: "text", interval: 43200 }, mihomo_nameserver_policy_xiaomi: { type: "http", path: "./sukkaw_ruleset/mihomo_nameserver_policy_xiaomi.txt", url: "https://ruleset.skk.moe/Internal/mihomo_nameserver_policy/xiaomi.txt", behavior: "classical", format: "text", interval: 43200 }, mihomo_nameserver_policy_bytedance: { type: "http", path: "./sukkaw_ruleset/mihomo_nameserver_policy_bytedance.txt", url: "https://ruleset.skk.moe/Internal/mihomo_nameserver_policy/bytedance.txt", behavior: "classical", format: "text", interval: 43200 }, mihomo_nameserver_policy_baidu: { type: "http", path: "./sukkaw_ruleset/mihomo_nameserver_policy_baidu.txt", url: "https://ruleset.skk.moe/Internal/mihomo_nameserver_policy/baidu.txt", behavior: "classical", format: "text", interval: 43200 }, mihomo_nameserver_policy_qihoo360: { type: "http", path: "./sukkaw_ruleset/mihomo_nameserver_policy_qihoo360.txt", url: "https://ruleset.skk.moe/Internal/mihomo_nameserver_policy/qihoo360.txt", behavior: "classical", format: "text", interval: 43200 }, mihomo_nameserver_policy_hotspot_captive_portal: { type: "http", path: "./sukkaw_ruleset/mihomo_nameserver_policy_hotspot_captive_portal.txt", url: "https://ruleset.skk.moe/Internal/mihomo_nameserver_policy/hotspot_captive_portal.txt", behavior: "classical", format: "text", interval: 43200 }, mihomo_nameserver_policy_lan_without_real_ip: { type: "http", path: "./sukkaw_ruleset/mihomo_nameserver_policy_lan_without_real_ip.txt", url: "https://ruleset.skk.moe/Internal/mihomo_nameserver_policy/lan_without_real_ip.txt", behavior: "classical", format: "text", interval: 43200 }, mihomo_nameserver_policy_lan_with_realip: { type: "http", path: "./sukkaw_ruleset/mihomo_nameserver_policy_lan_with_realip.txt", url: "https://ruleset.skk.moe/Internal/mihomo_nameserver_policy/lan_with_realip.txt", behavior: "classical", format: "text", interval: 43200 } }; const ruleProviders = { ...baseRuleProviders, ...overwriteRuleProviders }; params["rule-providers"] = ruleProviders; params["rules"] = rules; } function getProxiesByRegex(params, regex) { const matchedProxies = params.proxies.filter((e) => regex.test(e.name)).map((e) => e.name); return matchedProxies.length > 0 ? matchedProxies : ["COMPATIBLE"]; } function getManualProxiesByRegex(params, regex) { const matchedProxies = params.proxies.filter((e) => regex.test(e.name)).map((e) => e.name); return matchedProxies.length > 0 ? matchedProxies : ["COMPATIBLE"]; }
下载mvndhttps://github.com/apache/maven-mvnd/releases配置mvnd使用maven设置修改D:\soft\maven-mvnd-1.0-m6-m39-windows-amd64\conf中的mvnd.properties配置文件最后一行配置maven setting地址maven.settings=D://soft//apache-maven-3.5.4//conf//settings.xml在环境变量Path中添加mvnd的主目录如D:\soft\maven-mvnd-1.0-m6-m39-windows-amd64\bincmd运行mvnd -v 检验是否成功安装idea安装maven helper插件配置maven helper插件使用mvnd打包，写入mvnd.exe的路径不再使用maven原生命令打包idea配置utf-8编码-encoding utf-8
介绍不多说官网：https://lxmusic.toside.cn/GitHub：https://github.com/lyswhut/lx-music-desktop默认安装后会因为版权问题不能播放，需要自定义音乐源野花：https://liuyang-public.oss-cn-beijing.aliyuncs.com/flower-v1.0.0.js https://raw.githubusercontent.com/OneCodeMonkey/music-sources/master/js/flower-v1.0.0.js