feat: 微信扫码轮询优化、XHS 联调与本地代理指向线上 API

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-07-02 15:52:09 +08:00
parent 5a26ae0048
commit 64becd8b1a
30 changed files with 943 additions and 150 deletions

9
Dockerfile Normal file
View File

@ -0,0 +1,9 @@
FROM nginx:1.27-alpine
# 静态资源
COPY dist/ /usr/share/nginx/html/
# Docker 内只用 HTTP:80,SSL 在宿主机 nginx 或网关层处理
COPY nginx-docker.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

View File

@ -96,12 +96,19 @@ npm run lint
## 部署说明
1.安装依赖 npm i
2.构建代码 npm build
3.构建后会生成dist目录,直接给他拖拽到43服务器的/var/www/目录下
4.构建镜像 docker build -t web1 .
5.删掉旧的前端服务 docker rm -f web
6.跑新的 docker run -d -p 7962:80 --name web web1
1. 安装依赖 `pnpm i`
2. 构建 `pnpm build`(生成 `dist/`)
3. **宿主机 nginx(推荐)**:将 `dist/` 放到 `/usr/share/nginx/html`,使用 `nginx-default.conf`(含 SSL,证书在 `/etc/nginx/ssl/`)
4. **Docker 方式(7962 端口)**:使用 `nginx-docker.conf`(仅 HTTP,不含 SSL)
```bash
docker build -t web1 .
docker rm -f web
docker run -d -p 7962:80 --name web web1
docker ps | grep web # 确认 Up 状态
```
> 勿把 `nginx-default.conf`(含 SSL)直接打进 Docker,容器内没有 `/etc/nginx/ssl/` 会导致启动失败。
## 贡献指南

View File

@ -2,7 +2,7 @@
# 1. 本文件必须真正生效:确认 oms.nanafa.com 的请求由「本 server 块」处理
# - 若用 include:确保 include 的是含本 server 的 conf,且无其它 server 抢先匹配
# - 更新后必须执行:nginx -t && nginx -s reload
# 2. /api、/Track 由 nginx 代理到 api.nanafa.com,避免浏览器跨域 405;rbac 由前端直连 admin
# 2. /api、/Track、/rbac、/order 等 Admin 接口均由 nginx 代理到 api.nanafa.com,浏览器只访问 oms 同源
# 3. HTTPS:证书部署到服务器(勿提交 .key 到 git)
# /etc/nginx/ssl/oms.nanafa.com.pem
# /etc/nginx/ssl/oms.nanafa.com.key
@ -60,8 +60,46 @@ server {
try_files /index.html =200;
}
# ============ 小红书前端路由(必须先于后端 API 反代)============
location ~ ^/xhs/(startService|analysis|creation|publishHistory|skills|humanSettings)(/|$) {
# ============ 小红书前端路由(^~ 须比下方 /xhs/ API 更具体)============
location ^~ /xhs/startService {
try_files /index.html =200;
}
location ^~ /xhs/creation {
try_files /index.html =200;
}
location ^~ /xhs/publishHistory {
try_files /index.html =200;
}
location ^~ /xhs/skills {
try_files /index.html =200;
}
location ^~ /xhs/humanSettings {
try_files /index.html =200;
}
location ^~ /xhs/agentPair {
try_files /index.html =200;
}
location = /xhs/analysis {
try_files /index.html =200;
}
location = /xhs/analysis/ {
try_files /index.html =200;
}
# ============ 微信 H5 / 操作页(^~ 须比下方 API 前缀更长或更具体)============
location ^~ /wechat/register {
try_files /index.html =200;
}
location ^~ /wechat/bind {
try_files /index.html =200;
}
location ^~ /operation/inboundScan {
try_files /index.html =200;
}
location ^~ /operation/outboundScan {
try_files /index.html =200;
}
location ^~ /operation/printLabel {
try_files /index.html =200;
}
@ -106,6 +144,59 @@ server {
proxy_read_timeout 30s;
}
# ============ Admin API 同源反代(/rbac /order /xhs … → api.nanafa.com/admin)============
location ~ ^/(rbac|order|customer|recharge|channel|system)/ {
rewrite ^/(.*)$ /admin/$1 break;
proxy_pass http://api.nanafa.com;
proxy_http_version 1.1;
proxy_set_header Host api.nanafa.com;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 30s;
proxy_send_timeout 30s;
proxy_read_timeout 120s;
}
location ^~ /wechat/ {
rewrite ^/(.*)$ /admin/$1 break;
proxy_pass http://api.nanafa.com;
proxy_http_version 1.1;
proxy_set_header Host api.nanafa.com;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 30s;
proxy_send_timeout 30s;
proxy_read_timeout 120s;
}
location ^~ /operation/ {
rewrite ^/(.*)$ /admin/$1 break;
proxy_pass http://api.nanafa.com;
proxy_http_version 1.1;
proxy_set_header Host api.nanafa.com;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 30s;
proxy_send_timeout 30s;
proxy_read_timeout 120s;
}
location ^~ /xhs/ {
rewrite ^/(.*)$ /admin/$1 break;
proxy_pass http://api.nanafa.com;
proxy_http_version 1.1;
proxy_set_header Host api.nanafa.com;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 30s;
proxy_send_timeout 30s;
proxy_read_timeout 300s;
}
# HTML 不缓存
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";

131
nginx-docker.conf Normal file
View File

@ -0,0 +1,131 @@
# Docker 容器专用:只监听 80,不配置 SSL(7962:80,由外层网关/宿主机 nginx 终结 HTTPS)
server {
listen 80;
server_name _;
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/json application/xml image/svg+xml;
root /usr/share/nginx/html;
rewrite ^/login/?$ / permanent;
rewrite ^/admin/?$ /index.html last;
location = /admin {
try_files /index.html =200;
}
location = /admin/ {
try_files /index.html =200;
}
location ~ ^/api/(upstreamInterface|upstreamInterfaceConfig|upstreamInterfaceConfigEdit|downstreamInterface(|/newPort)|appList(|/createApplication)|requestLog|callStatistics|apiDebugger|apiTest)(/|$) {
try_files /index.html =200;
}
location ^~ /xhs/startService { try_files /index.html =200; }
location ^~ /xhs/creation { try_files /index.html =200; }
location ^~ /xhs/publishHistory { try_files /index.html =200; }
location ^~ /xhs/skills { try_files /index.html =200; }
location ^~ /xhs/humanSettings { try_files /index.html =200; }
location ^~ /xhs/agentPair { try_files /index.html =200; }
location = /xhs/analysis { try_files /index.html =200; }
location = /xhs/analysis/ { try_files /index.html =200; }
location ^~ /wechat/register { try_files /index.html =200; }
location ^~ /wechat/bind { try_files /index.html =200; }
location ^~ /operation/inboundScan { try_files /index.html =200; }
location ^~ /operation/outboundScan { try_files /index.html =200; }
location ^~ /operation/printLabel { try_files /index.html =200; }
location ^~ /api/apiwechat/ {
rewrite ^/api/apiwechat/(.*)$ /admin/apiwechat/$1 break;
proxy_pass http://api.nanafa.com;
proxy_http_version 1.1;
proxy_set_header Host api.nanafa.com;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~ ^/api/ {
rewrite ^/api/(.*)$ /itfx/$1 break;
proxy_pass http://api.nanafa.com;
proxy_http_version 1.1;
proxy_set_header Host api.nanafa.com;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ^~ /Track {
rewrite ^/Track(.*)$ /track/Track$1 break;
proxy_pass http://api.nanafa.com;
proxy_http_version 1.1;
proxy_set_header Host api.nanafa.com;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~ ^/(rbac|order|customer|recharge|channel|system)/ {
rewrite ^/(.*)$ /admin/$1 break;
proxy_pass http://api.nanafa.com;
proxy_http_version 1.1;
proxy_set_header Host api.nanafa.com;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s;
}
location ^~ /wechat/ {
rewrite ^/(.*)$ /admin/$1 break;
proxy_pass http://api.nanafa.com;
proxy_http_version 1.1;
proxy_set_header Host api.nanafa.com;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ^~ /operation/ {
rewrite ^/(.*)$ /admin/$1 break;
proxy_pass http://api.nanafa.com;
proxy_http_version 1.1;
proxy_set_header Host api.nanafa.com;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ^~ /xhs/ {
rewrite ^/(.*)$ /admin/$1 break;
proxy_pass http://api.nanafa.com;
proxy_http_version 1.1;
proxy_set_header Host api.nanafa.com;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s;
}
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
}
location ~* \.(?:js|css)$ {
try_files $uri =404;
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
location / {
index index.html;
try_files $uri /index.html;
}
}

View File

@ -2,14 +2,10 @@
* OMS admin 接口通用:与 /order 同源(开发环境走 Vite 代理路径前缀)
*/
import { axiosInstance } from '@/api/rbac'
import { resolveAdminApiPath, resolveAdminAssetOrigin } from '@/utils/adminApi'
export function omsUrl(path: string): string {
const p = path.startsWith('/') ? path : `/${path}`
if (import.meta.env.DEV) {
return p
}
const base = (import.meta.env.VITE_API_ADMIN_URL || 'http://api.nanafa.com/admin').replace(/\/$/, '')
return `${base}${p}`
return resolveAdminApiPath(path)
}
export function assertBusinessOk(body: any) {
@ -101,11 +97,6 @@ export function resolveOmsAssetUrl(path?: string | null): string {
if (/^https?:\/\//i.test(value) || value.startsWith('data:') || value.startsWith('blob:')) {
return value
}
const origin = (
import.meta.env.VITE_API_ASSET_ORIGIN ||
(import.meta.env.DEV ? 'http://api.nanafa.com' : 'http://api.nanafa.com')
)
.toString()
.replace(/\/$/, '')
const origin = resolveAdminAssetOrigin()
return `${origin}${value.startsWith('/') ? value : `/${value}`}`
}

View File

@ -2,14 +2,10 @@
* OMS 订单接口(Swagger:OMS-Order,与 admin 同源)
*/
import { axiosInstance } from '@/api/rbac'
import { resolveAdminApiPath } from '@/utils/adminApi'
function orderUrl(path: string): string {
const p = path.startsWith('/') ? path : `/${path}`
if (import.meta.env.DEV) {
return p
}
const base = (import.meta.env.VITE_API_ADMIN_URL || 'http://api.nanafa.com/admin').replace(/\/$/, '')
return `${base}${p}`
return resolveAdminApiPath(path)
}
export interface OrderListRequest {

View File

@ -5,7 +5,7 @@ import { OpenAPI, clearAuthToken } from './index'
import { useRbacUserStore } from '@/store/modules/rbac-user'
// 创建自定义 axios 实例
// baseURL 为空,URL 由 OpenAPI.BASE + path 构建;生产环境 OpenAPI.BASE = http://api.nanafa.com/admin
// baseURL 为空,URL 由 OpenAPI.BASE + path 构建;生产默认同源反代,OpenAPI.BASE 为空
export const axiosInstance = axios.create({
baseURL: '', // 始终为空,URL 由 OpenAPI.BASE + path 构建
timeout: 30000,

View File

@ -9,12 +9,10 @@ export * from './generated'
// 配置 API
import { OpenAPI } from './generated/core/OpenAPI'
import { axiosInstance } from './axios-config'
import { resolveOpenApiBase } from '@/utils/adminApi'
// 设置基础 URL:生产直连 http://api.nanafa.com/admin,开发走 Vite 代理
const isProduction = (import.meta as any).env?.MODE === 'production' || (import.meta as any).env?.PROD === true
OpenAPI.BASE = isProduction
? (import.meta.env.VITE_API_ADMIN_URL || 'http://api.nanafa.com/admin')
: ''
// 生产环境走 oms 同源 nginx 反代(相对路径);VITE_API_ADMIN_URL 可覆盖为绝对地址
OpenAPI.BASE = resolveOpenApiBase()
// 认证 token 管理
export function setAuthToken(token: string) {

View File

@ -22,14 +22,22 @@ function unwrapOrSelf<T>(body: unknown): T {
return unwrapData<T>(body);
}
/** 获取微信公众号登录二维码 */
export async function getLoginQrcode(loginUuid: string): Promise<WxLoginQrcodeResult> {
/** 获取微信公众号登录二维码(loginUuid 可选,不传则由后端生成 key) */
export async function getLoginQrcode(loginUuid?: string): Promise<WxLoginQrcodeResult> {
const res = await axiosInstance.get(omsUrl('/wechat/mp/login-qrcode'), {
params: { loginUuid },
params: loginUuid ? { loginUuid } : undefined,
});
return unwrapOrSelf<WxLoginQrcodeResult>(res.data);
}
/** 查询扫码 key 是否已关联账号(轮询用,无值时不抛错) */
export async function getWxLoginStatus(loginUuid: string): Promise<WxLoginStatusResult> {
const res = await axiosInstance.get('/rbac/auth/wx-login/status', {
params: { loginUuid },
});
return unwrapOrSelf<WxLoginStatusResult>(res.data);
}
/** 是否属于「等待扫码」类错误(轮询时应静默继续) */
export function isWxLoginPendingError(error: unknown): boolean {
const axiosErr = error as AxiosError<{ message?: string; Message?: string }>;

View File

@ -2,10 +2,16 @@ import type { LoginResultDto } from '@/api/rbac';
/** 登录二维码 */
export interface WxLoginQrcodeResult {
loginKey?: string;
qrCodeUrl: string;
expiresIn?: number;
}
export interface WxLoginStatusResult {
ready: boolean;
account?: string;
}
/** 微信 OAuth 用户信息 */
export interface WeChatOAuthUserInfo {
openid: string;

View File

@ -2,14 +2,10 @@
* 小红书模块 API(Swagger:OMS-Xhs)
*/
import { axiosInstance } from '@/api/rbac';
import { resolveAdminApiPath } from '@/utils/adminApi';
function xhsUrl(path: string): string {
const p = path.startsWith('/') ? path : `/${path}`;
if (import.meta.env.DEV) {
return p;
}
const base = (import.meta.env.VITE_API_ADMIN_URL || 'http://api.nanafa.com/admin').replace(/\/$/, '');
return `${base}${p}`;
return resolveAdminApiPath(path);
}
function assertBusinessOk(body: any) {

View File

@ -72,6 +72,16 @@ const XHS: AppRouteRecordRaw = {
permission: 'Xhs.HumanSettings.Save',
},
},
{
path: 'agentPair',
name: 'XhsAgentPair',
component: () => import('@/views/xhs/agentPair/index.vue'),
meta: {
locale: 'menu.xhs.agentPair',
requiresAuth: true,
hideInMenu: true,
},
},
],
};

50
src/utils/adminApi.ts Normal file
View File

@ -0,0 +1,50 @@
/**
* Admin API 地址:生产环境走 oms 同源 nginx 反代(相对路径),开发走 Vite 代理。
* 桌面 Agent 等需直连网关时使用 resolveDirectAdminApiUrl。
*/
function normalizeBase(base: string): string {
return base.trim().replace(/\/$/, '')
}
/** 浏览器请求 Admin API(OpenAPI.BASE 或 omsUrl/orderUrl 等) */
export function resolveAdminApiPath(path: string): string {
const p = path.startsWith('/') ? path : `/${path}`
const override = import.meta.env.VITE_API_ADMIN_URL as string | undefined
if (!import.meta.env.DEV && override?.trim()) {
return `${normalizeBase(override)}${p}`
}
return p
}
export function resolveOpenApiBase(): string {
const override = import.meta.env.VITE_API_ADMIN_URL as string | undefined
if (!import.meta.env.DEV && override?.trim()) {
return normalizeBase(override)
}
return ''
}
/** 桌面 Agent 等直连 Admin 网关(不经 oms 页面反代) */
export function resolveDirectAdminApiUrl(): string {
const override = import.meta.env.VITE_API_ADMIN_URL as string | undefined
if (override?.trim()) {
return normalizeBase(override)
}
if (import.meta.env.DEV) {
return 'http://api.nanafa.com/admin'
}
return 'https://api.nanafa.com/admin'
}
/** 静态资源(uploads 等)仍托管在 api 网关 */
export function resolveAdminAssetOrigin(): string {
const override = import.meta.env.VITE_API_ASSET_ORIGIN as string | undefined
if (override?.trim()) {
return normalizeBase(override)
}
if (import.meta.env.DEV) {
return 'http://api.nanafa.com'
}
return 'https://api.nanafa.com'
}

View File

@ -23,7 +23,7 @@ const service: AxiosInstance = axios.create({
service.interceptors.request.use(
(config: AxiosRequestConfig) => {
config.headers = config.headers ?? {};
// 生产:/api、/Track 走同源,由 nginx 代理到 api.nanafa.com,避免跨域 405;rbac 直连 admin
// 生产:/api、/Track、/rbac 等走同源,由 nginx 代理到 api.nanafa.com
// 开发:Vite 代理 /api、/Track、/rbac
// 从 localStorage 获取 token
const token = localStorage.getItem('token');

21
src/utils/wechatOAuth.ts Normal file
View File

@ -0,0 +1,21 @@
/** 微信公众号 H5 网页授权(注册 / 绑定页无 code 时跳转) */
export type WeChatH5Page = 'register' | 'bind';
const DEFAULT_MP_APP_ID = 'wxd5141ba46c856e1f';
export function resolveWeChatMpAppId(): string {
const fromEnv = import.meta.env.VITE_WECHAT_MP_APPID as string | undefined;
return fromEnv?.trim() || DEFAULT_MP_APP_ID;
}
/** 跳转微信 OAuth,回调后 URL 带 ?code= */
export function redirectToWeChatOAuth(page: WeChatH5Page): void {
const appId = resolveWeChatMpAppId();
const redirectUri = encodeURIComponent(`${window.location.origin}/wechat/${page}`);
window.location.replace(
`https://open.weixin.qq.com/connect/oauth2/authorize?appid=${appId}` +
`&redirect_uri=${redirectUri}` +
`&response_type=code&scope=snsapi_userinfo&state=${page}#wechat_redirect`,
);
}

57
src/utils/xhsAgentPair.ts Normal file
View File

@ -0,0 +1,57 @@
/** OMS 小红书 Agent 桌面端本地配对(127.0.0.1) */
export const XHS_AGENT_PAIR_PORT = 19333;
import { resolveDirectAdminApiUrl } from '@/utils/adminApi';
/** 桌面端应使用的 OMS API 根地址(含 /admin;直连 Admin 网关,不经 oms 页面反代) */
export function resolveDesktopOmsApiUrl(): string {
return resolveDirectAdminApiUrl();
}
export async function isAgentWaitingPair(
port = XHS_AGENT_PAIR_PORT,
): Promise<boolean> {
try {
const controller = new AbortController();
const timer = window.setTimeout(() => controller.abort(), 800);
const res = await fetch(`http://127.0.0.1:${port}/status`, {
signal: controller.signal,
});
window.clearTimeout(timer);
if (!res.ok) return false;
const data = await res.json();
return Boolean(data?.waiting);
} catch {
return false;
}
}
/** 将当前登录 Token 推送给本机 Agent */
export async function pushCredentialsToAgent(
token: string,
port = XHS_AGENT_PAIR_PORT,
): Promise<boolean> {
if (!token?.trim()) return false;
const omsUrl = resolveDesktopOmsApiUrl();
try {
const controller = new AbortController();
const timer = window.setTimeout(() => controller.abort(), 5000);
const res = await fetch(`http://127.0.0.1:${port}/pair`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: token.trim(),
omsUrl,
webOrigin: window.location.origin,
}),
signal: controller.signal,
});
window.clearTimeout(timer);
if (!res.ok) return false;
const data = await res.json();
return data?.ok !== false;
} catch {
return false;
}
}

View File

@ -45,7 +45,7 @@ import { useI18n } from 'vue-i18n';
import { Message } from '@arco-design/web-vue';
import { IconUser } from '@arco-design/web-vue/es/icon';
import { OmsLoginType } from '@/api/rbac';
import { getLoginQrcode, isWxLoginPendingError, wxLogin } from '@/api/wechat';
import { getLoginQrcode, getWxLoginStatus, isWxLoginPendingError, wxLogin } from '@/api/wechat';
import { useRbacUserStore } from '@/store/modules/rbac-user';
import { useUserStore } from '@/store';
import { setToken } from '@/utils/auth';
@ -76,9 +76,9 @@ let expireTimer: ReturnType<typeof setTimeout> | undefined;
function createLoginUuid(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
return crypto.randomUUID().replace(/-/g, '');
}
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
return `${Date.now()}${Math.random().toString(16).slice(2)}`;
}
function clearTimers() {
@ -144,6 +144,9 @@ async function pollWxLogin() {
if (!loginUuid.value) return;
try {
const status = await getWxLoginStatus(loginUuid.value);
if (!status?.ready) return;
const result = await wxLogin(loginUuid.value);
if (!result?.accessToken) return;
@ -178,6 +181,7 @@ async function loadQrCode() {
Message.error(t('login.wechat.loadFailed'));
return;
}
loginUuid.value = result.loginKey || loginUuid.value;
qrCodeUrl.value = result.qrCodeUrl;
startPolling();
} catch {

View File

@ -69,6 +69,7 @@ import {
type WeChatOAuthUserInfo,
} from '@/api/wechat';
import { handleApiError } from '@/utils/rbac-error-handler';
import { redirectToWeChatOAuth } from '@/utils/wechatOAuth';
defineOptions({ name: 'WechatBind' });
@ -142,7 +143,7 @@ async function handleSubmit() {
onMounted(() => {
const code = route.query.code;
if (typeof code !== 'string' || !code) {
statusCode.value = 'oauth-error';
redirectToWeChatOAuth('bind');
return;
}
void loadOAuthUserInfo(code);

View File

@ -95,6 +95,7 @@ import {
DEFAULT_CUSTOMER_PASSWORD,
isLetterAndDigitPassword,
} from '@/utils/customerPassword';
import { redirectToWeChatOAuth } from '@/utils/wechatOAuth';
defineOptions({ name: 'WechatRegister' });
@ -187,7 +188,7 @@ async function handleSubmit() {
onMounted(() => {
const code = route.query.code;
if (typeof code !== 'string' || !code) {
statusCode.value = 'oauth-error';
redirectToWeChatOAuth('register');
return;
}
void loadOAuthUserInfo(code);

View File

@ -0,0 +1,87 @@
<template>
<div class="agent-pair-page">
<div class="agent-pair-card">
<h1>连接桌面 Agent</h1>
<p v-if="state === 'pending'" class="hint">正在将登录凭证发送给本机 Agent…</p>
<p v-else-if="state === 'ok'" class="hint ok">已成功连接,可关闭此页并返回「启动服务」。</p>
<p v-else-if="state === 'no-token'" class="hint err">未检测到登录状态,请先登录 OMS 后再试。</p>
<p v-else class="hint err">{{ errorMsg || '连接失败,请确认桌面端已点击「连接 OMS」。' }}</p>
<a-button v-if="state !== 'pending'" type="primary" @click="retry">重试</a-button>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { pushCredentialsToAgent } from '@/utils/xhsAgentPair';
import { useRbacUserStore } from '@/store/modules/rbac-user';
const rbacStore = useRbacUserStore();
const state = ref<'pending' | 'ok' | 'fail' | 'no-token'>('pending');
const errorMsg = ref('');
async function doPair() {
state.value = 'pending';
errorMsg.value = '';
const token =
rbacStore.accessToken || localStorage.getItem('rbac_access_token') || '';
if (!token) {
state.value = 'no-token';
return;
}
const ok = await pushCredentialsToAgent(token);
state.value = ok ? 'ok' : 'fail';
if (!ok) {
errorMsg.value = '无法联系本机 Agent,请确认桌面端已打开并点击「连接 OMS」。';
}
}
function retry() {
doPair();
}
onMounted(() => {
doPair();
});
</script>
<style scoped lang="less">
.agent-pair-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: #f0f3f4;
padding: 24px;
}
.agent-pair-card {
width: 100%;
max-width: 420px;
padding: 32px 28px;
background: #fff;
border-radius: 12px;
border: 1px solid #d5dce6;
text-align: center;
h1 {
margin: 0 0 16px;
font-size: 18px;
color: #0f172a;
}
.hint {
margin: 0 0 20px;
font-size: 14px;
color: #64748b;
line-height: 1.6;
&.ok {
color: #059669;
}
&.err {
color: #dc2626;
}
}
}
</style>

View File

@ -25,7 +25,7 @@
<AnalysisToolPanel
ref="panelRef"
:tool="activeTool"
:running="running"
:running="running || browseLoading"
:browse-loading="browseLoading"
:feed-rows="currentBrowseState.feeds"
:selected-feed="currentBrowseState.selectedFeed"
@ -114,6 +114,11 @@ const browseStates = reactive<Record<string, BrowseToolState>>({
});
let pollTimer: ReturnType<typeof setInterval> | null = null;
let pollStartedAt = 0;
const POLL_INTERVAL_MS = 5000;
const POLL_TIMEOUT_MS = 20 * 60 * 1000;
const onlineAgents = computed(() => agents.value.filter((a) => a.status === 'online'));
const toolTreeData = computed<CustomLeftMenuTreeNode[]>(() =>
buildAnalysisToolTree(menuSearchKeyword.value),
@ -141,6 +146,17 @@ function parseFeedRows(feeds: unknown): XhsFeedRow[] {
if (!feeds || typeof feeds !== 'object') return [];
const root = feeds as Record<string, unknown>;
if (root.error) return [];
if (typeof root.raw === 'string' && root.raw.trim()) {
try {
const parsed = JSON.parse(root.raw);
const fromRaw = parseFeedRows(parsed);
if (fromRaw.length) return fromRaw;
} catch {
// ignore invalid raw json
}
}
const list = Array.isArray(feeds)
? feeds
: (root.feeds ?? root.items ?? root.notes);
@ -159,6 +175,33 @@ function parseFeedRows(feeds: unknown): XhsFeedRow[] {
});
}
function resolveAgentIdForTask(): number | undefined {
const id = interactAgentId.value;
if (!id) return undefined;
const agent = agents.value.find((a) => a.id === id);
if (!agent || agent.status !== 'online') {
return undefined;
}
return id;
}
function formatTaskStatus(status?: string) {
switch (status) {
case 'pending':
return '等待 Agent 领取任务…';
case 'claimed':
return 'Agent 已领取,准备执行…';
case 'running':
return 'Agent 采集中(首页推荐约需 2–5 分钟,请保持引擎运行)…';
case 'succeeded':
return '采集完成';
case 'failed':
return '任务失败';
default:
return status ? `任务状态:${status}` : '等待 Agent…';
}
}
async function onToolRun(payload: Record<string, unknown>) {
const tool = activeTool.value;
if (!tool) return;
@ -186,24 +229,37 @@ async function runBrowseTool(tool: XhsAnalysisToolDef, payload: Record<string, u
browseLoading.value = true;
respStatus.value = '任务提交中…';
const agentId = resolveAgentIdForTask();
if (interactAgentId.value && !agentId) {
Message.warning('所选 Agent 离线,将交由任意在线 Agent 执行');
}
if (!agentId && onlineAgents.value.length === 0) {
Message.warning('没有在线 Agent,请先在「启动服务」连接桌面端并启动引擎');
running.value = false;
browseLoading.value = false;
respStatus.value = '无在线 Agent';
return;
}
try {
const result = await startXhsAnalysis({
analysisType: tool.analysisType!,
keyword: String(payload.keyword ?? '').trim() || undefined,
filters: payload.filters,
agentId: interactAgentId.value,
agentId,
});
const state = browseStates[tool.name];
state.sessionId = result.sessionId;
state.feeds = [];
state.selectedFeed = null;
state.taskStatus = 'pending';
respStatus.value = `已提交任务 #${result.taskId},等待 Agent 采集…`;
respStatus.value = `已提交任务 #${result.taskId},${formatTaskStatus('pending')}`;
responseText.value = '';
setupPoll(tool.name);
} catch (e: any) {
Message.error(e?.message || '提交失败');
respStatus.value = '提交失败';
browseLoading.value = false;
} finally {
running.value = false;
}
@ -223,7 +279,7 @@ async function runAnalyzeTool(payload: Record<string, unknown>) {
const result = await startXhsAnalysis({
analysisType: 'feed_detail',
keyword: feedId,
agentId: interactAgentId.value,
agentId: resolveAgentIdForTask(),
filters: {
xsec_token: payload.xsec_token,
load_all_comments: payload.load_all_comments,
@ -299,11 +355,22 @@ async function sendInteract(
}
}
async function loadSessionDetail(toolName: string) {
async function loadSessionDetail(toolName: string, options?: { silent?: boolean }) {
const state = browseStates[toolName];
if (!state?.sessionId) return;
browseLoading.value = true;
if (pollStartedAt && Date.now() - pollStartedAt > POLL_TIMEOUT_MS) {
respStatus.value = '采集超时,请确认 Agent 在线且引擎已启动后重试';
browseLoading.value = false;
running.value = false;
stopPoll();
Message.warning('采集超时');
return;
}
if (!options?.silent) {
browseLoading.value = true;
}
try {
const detail = await getXhsAnalysisDetail(state.sessionId);
state.taskStatus = detail.taskStatus;
@ -327,21 +394,28 @@ async function loadSessionDetail(toolName: string) {
stopPoll();
} else if (detail.taskStatus === 'failed') {
respStatus.value = '任务失败';
if (detail.feeds && typeof detail.feeds === 'object' && (detail.feeds as any).error) {
responseText.value = String((detail.feeds as any).error);
}
stopPoll();
} else {
respStatus.value = `任务状态:${detail.taskStatus ?? '执行中'}`;
respStatus.value = formatTaskStatus(detail.taskStatus);
}
} catch (e: any) {
Message.error(e?.message || '加载详情失败');
} finally {
browseLoading.value = false;
if (!pollTimer) {
browseLoading.value = false;
}
running.value = false;
}
}
function setupPoll(toolName: string) {
stopPoll();
pollTimer = setInterval(() => loadSessionDetail(toolName), 5000);
pollStartedAt = Date.now();
browseLoading.value = true;
pollTimer = setInterval(() => loadSessionDetail(toolName, { silent: true }), POLL_INTERVAL_MS);
loadSessionDetail(toolName);
}
@ -350,6 +424,8 @@ function stopPoll() {
clearInterval(pollTimer);
pollTimer = null;
}
pollStartedAt = 0;
browseLoading.value = false;
}
function onSelectFeed(feed: XhsFeedRow) {
@ -426,7 +502,7 @@ onMounted(async () => {
await Promise.all([
listXhsAgents(1, 100).then((r) => {
agents.value = r.items ?? [];
const online = agents.value.find((a) => a.status === 'online');
const online = onlineAgents.value[0];
if (online) interactAgentId.value = online.id;
}),
listXhsAiProviders().then((r) => {

View File

@ -204,11 +204,11 @@
:footer="false"
>
<a-steps :current="0" direction="vertical" size="small" style="margin-bottom: 20px">
<a-step description="安装并运行 OMS Agent 桌面端(xhs-agent-desktop/run.ps1)">
安装 Agent 桌面端
<a-step description="运行 xhs-agent-desktop/run.ps1 打开桌面端">
打开 Agent 桌面端
</a-step>
<a-step description="桌面端填入 API 地址与 Token,点击「连接 OMS」">
连接 OMS
<a-step description="桌面端点击「连接 OMS」;保持本页打开即可自动授权">
一键连接
</a-step>
<a-step description="启动引擎,Chrome 加载内置扩展并登录小红书">
本机引擎与扩展
@ -218,22 +218,16 @@
</a-step>
</a-steps>
<a-form layout="vertical" size="small">
<a-form-item label="OMS API 地址">
<a-input :model-value="omsApiUrl" readonly>
<template #append>
<a-button @click="copyText(omsApiUrl)">复制</a-button>
</template>
</a-input>
</a-form-item>
<a-form-item label="JWT Token(当前登录)">
<a-textarea :model-value="maskedToken" readonly :auto-size="{ minRows: 2, maxRows: 4 }" />
<a-button style="margin-top: 8px" @click="copyText(accessToken)">复制 Token</a-button>
</a-form-item>
</a-form>
<a-alert
type="info"
style="margin-bottom: 12px"
title="无需填写 API 或 Token"
>
请先在浏览器登录 OMS,再打开桌面端点击「连接 OMS」。若本页已打开,授权会自动完成。
</a-alert>
<div class="cmd-block">
<div class="cmd-label">桌面端(推荐,已替代 LIKE AI)</div>
<div class="cmd-label">桌面端</div>
<pre>{{ desktopAgentHint }}</pre>
</div>
</a-drawer>
@ -248,6 +242,10 @@ import {
listXhsAgents,
type XhsAgentItem,
} from '@/api/xhs';
import {
isAgentWaitingPair,
pushCredentialsToAgent,
} from '@/utils/xhsAgentPair';
import { useRbacUserStore } from '@/store/modules/rbac-user';
import XhsStatusBadge, { type XhsBadgeKind } from '../_shared/XhsStatusBadge.vue';
@ -264,6 +262,8 @@ const pagination = reactive({
});
let refreshTimer: ReturnType<typeof setInterval> | null = null;
let pairTimer: ReturnType<typeof setInterval> | null = null;
let pairPushed = false;
const selectedAgent = computed(() =>
agents.value.find((a) => a.id === selectedAgentId.value),
@ -315,34 +315,14 @@ const loginGuideText = computed(() => {
return '只需:启动引擎 → 在 Chrome 加载扩展 → 打开小红书并登录。扩展图标可查看 Bridge 与登录状态;下方按钮用于快捷操作。';
});
const omsApiUrl = computed(() => {
if (import.meta.env.DEV) {
return `${window.location.origin}/admin`;
}
return (import.meta.env.VITE_API_ADMIN_URL || 'http://api.nanafa.com/admin').replace(/\/$/, '');
});
const accessToken = computed(
() => rbacStore.accessToken || localStorage.getItem('rbac_access_token') || '',
);
const maskedToken = computed(() => {
const t = accessToken.value;
if (!t) return '未登录';
if (t.length <= 16) return '***';
return `${t.slice(0, 8)}...${t.slice(-8)}`;
});
const desktopAgentHint = computed(() => {
const url = omsApiUrl.value;
return [
const desktopAgentHint = computed(() =>
[
'1. 运行 oms0414/xhs-agent-desktop/run.ps1',
'2. 填入 API 地址与 JWT Token(下方可复制)',
` API: ${url}`,
'3. 连接 OMS → 启动引擎 → Chrome 加载扩展目录',
'4. 无需单独安装 LIKE AI',
].join('\n');
});
'2. 桌面端点击「连接 OMS」(无需填 API / Token)',
'3. 保持本页打开 → 自动授权;或按提示打开授权页',
'4. 启动引擎 → Chrome 加载扩展目录 → 登录小红书',
].join('\n'),
);
function formatTime(value?: string) {
if (!value) return '-';
@ -370,16 +350,20 @@ function pickDefaultAgent() {
watch(agents, pickDefaultAgent);
async function copyText(text: string) {
if (!text) {
Message.warning('无内容可复制');
async function tryAutoPair() {
const waiting = await isAgentWaitingPair();
if (!waiting) {
pairPushed = false;
return;
}
try {
await navigator.clipboard.writeText(text);
Message.success('已复制');
} catch {
Message.error('复制失败');
if (pairPushed) return;
const token = rbacStore.accessToken || localStorage.getItem('rbac_access_token') || '';
if (!token) return;
const ok = await pushCredentialsToAgent(token);
if (ok) {
pairPushed = true;
Message.success('已向桌面 Agent 发送授权');
setTimeout(loadAgents, 1500);
}
}
@ -436,10 +420,13 @@ async function sendControl(agentId: number, action: string) {
onMounted(() => {
loadAgents();
refreshTimer = setInterval(loadAgents, 15000);
tryAutoPair();
pairTimer = setInterval(tryAutoPair, 2000);
});
onUnmounted(() => {
if (refreshTimer) clearInterval(refreshTimer);
if (pairTimer) clearInterval(pairTimer);
});
</script>

View File

@ -1,6 +1,6 @@
import { resolve } from 'path';
import type { Plugin, UserConfig } from 'vite';
import { defineConfig } from 'vite';
import { defineConfig, loadEnv } from 'vite';
import visualizer from 'rollup-plugin-visualizer';
import vue from '@vitejs/plugin-vue';
import vueJsx from '@vitejs/plugin-vue-jsx';
@ -11,7 +11,11 @@ import UnoCSS from 'unocss/vite';
import configArcoStyleImportPlugin from './config/plugin/arcoStyleImport';
import configCompressPlugin from './config/plugin/compress';
export default defineConfig({
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '');
const omsAdminTarget = (env.VITE_OMS_ADMIN_URL || 'http://api.nanafa.com/admin').replace(/\/$/, '');
return {
server: {
open: true,
fs: {
@ -97,9 +101,9 @@ export default defineConfig({
changeOrigin: true,
secure: false,
},
// 小红书 OMS-Xhs 接口
// 小红书 OMS-Xhs 接口(开发默认走本机 Admin API,与桌面 Agent 一致)
'/xhs': {
target: 'http://api.nanafa.com/admin',
target: omsAdminTarget,
changeOrigin: true,
secure: false,
},
@ -239,6 +243,7 @@ export default defineConfig({
'/xhs/publishHistory',
'/xhs/skills',
'/xhs/humanSettings',
'/xhs/agentPair',
'/track/guide',
'/track/order',
'/track/warningOrder',
@ -475,4 +480,5 @@ export default defineConfig({
},
},
},
} as UserConfig);
} as UserConfig;
});

View File

@ -23,14 +23,16 @@ cd E:\oms0414\xhs-agent-desktop
首次运行会自动创建 `.venv` 并安装依赖。
## 使用
## 使用(一键连接)
1. 网页登录 OMS → **小红书 → 启动服务 → Agent 连接指南**
2. 复制 **API 地址**、**JWT Token** 到桌面端
3. 点击 **连接 OMS**
1. 浏览器登录 OMS,打开 **小红书 → 启动服务**(保持页面打开)
2. 运行桌面端 `run.ps1`,点击 **连接 OMS**
3. 网页会自动把登录凭证发给本机 Agent,**无需填写 API 地址或 Token**
4. **启动引擎**,在 Chrome `chrome://extensions` 加载扩展目录(界面已显示路径)
5. 打开小红书登录;网页即可远程控制
若未自动授权,桌面端会打开授权页;也可手动访问 `/xhs/agentPair`。
## 开发说明
`engine/` 目录为从 LIKE AI 提取的运行时(无 GUI)。后续可在 OMS 仓库内独立演进,不再依赖外部 LIKE AI 安装。
@ -43,5 +45,5 @@ cd E:\oms0414\xhs-agent-desktop
## 联调
- 前端 dev 时 API 地址填 `http://localhost:5173/admin`(Vite 代理)
- Agent 必须能访问真实 Admin API,不能用仅静态资源的地址
- 开发环境:网页 dev server(5173)会通过 Vite 代理 `/xhs`;桌面端配对后自动使用相同根地址
- 生产环境:配对时自动写入 `VITE_API_ADMIN_URL` 对应的后端地址

View File

@ -9,10 +9,11 @@ from pathlib import Path
@dataclass
class AgentSettings:
oms_url: str = "http://localhost:5281/admin"
oms_url: str = "http://api.nanafa.com/admin"
token: str = ""
device_name: str = ""
bridge_port: int = 9333
web_origin: str = "http://localhost:5173"
@staticmethod
def config_path() -> Path:
@ -34,6 +35,7 @@ class AgentSettings:
token=str(raw.get("token") or ""),
device_name=str(raw.get("device_name") or ""),
bridge_port=int(raw.get("bridge_port") or 9333),
web_origin=str(raw.get("web_origin") or cls().web_origin),
)
except (json.JSONDecodeError, OSError, ValueError):
return cls()

View File

@ -4,9 +4,10 @@ from __future__ import annotations
import logging
import sys
import webbrowser
from datetime import datetime
from PyQt6.QtCore import Qt
from PyQt6.QtCore import QTimer, pyqtSignal
from PyQt6.QtGui import QCloseEvent, QFont
from PyQt6.QtWidgets import (
QApplication,
@ -27,11 +28,16 @@ from PyQt6.QtWidgets import (
from app.agent_worker import AgentWorker
from app.config_store import AgentSettings
from app.engine_path import ensure_runtime_importable, extension_dir
from app.pair_server import DEFAULT_PAIR_PORT, PairPayload, PairServer
logger = logging.getLogger(__name__)
PAIR_TIMEOUT_SEC = 120
class MainWindow(QMainWindow):
pair_received = pyqtSignal(object)
def __init__(self) -> None:
super().__init__()
self.setWindowTitle("OMS 小红书 Agent")
@ -41,6 +47,8 @@ class MainWindow(QMainWindow):
self._settings = AgentSettings.load()
self._worker: AgentWorker | None = None
self._bridge = None
self._pair_server: PairServer | None = None
self._pair_timer: QTimer | None = None
root = QWidget()
self.setCentralWidget(root)
@ -54,16 +62,16 @@ class MainWindow(QMainWindow):
layout.addWidget(self._build_status_group())
layout.addWidget(self._build_log_group(), 1)
self.pair_received.connect(self._on_pair_received)
self._apply_settings_to_form()
self._try_init_bridge()
def _build_intro(self) -> QLabel:
lbl = QLabel(
"独立桌面端:内置 Chrome 扩展 + Bridge 引擎,连接 OMS 后由网页远程调度。"
"在网页「小红书 → 启动服务 → Agent 连接指南」复制 API 地址与 Token。"
"打开本程序并点击「连接 OMS」即可。"
"请先在浏览器登录 OMS;若已打开「启动服务」页面,将自动完成授权,无需填写 API 或 Token。"
)
lbl.setWordWrap(True)
lbl.setObjectName("intro")
lbl.setStyleSheet("color:#64748b;font-size:12px;")
return lbl
@ -72,15 +80,6 @@ class MainWindow(QMainWindow):
form = QFormLayout(box)
form.setSpacing(8)
self.input_oms_url = QLineEdit()
self.input_oms_url.setPlaceholderText("http://localhost:5281/admin")
form.addRow("API 地址", self.input_oms_url)
self.input_token = QLineEdit()
self.input_token.setEchoMode(QLineEdit.EchoMode.Password)
self.input_token.setPlaceholderText("从网页「Agent 连接指南」复制 JWT")
form.addRow("JWT Token", self.input_token)
self.input_device_name = QLineEdit()
self.input_device_name.setPlaceholderText("如:办公室电脑")
form.addRow("设备名称", self.input_device_name)
@ -90,10 +89,10 @@ class MainWindow(QMainWindow):
self.input_bridge_port.setValue(9333)
form.addRow("Bridge 端口", self.input_bridge_port)
self.lbl_extension = QLabel(extension_dir().as_posix())
self.lbl_extension.setWordWrap(True)
self.lbl_extension.setStyleSheet("color:#475569;font-size:11px;")
form.addRow("Chrome 扩展", self.lbl_extension)
self.lbl_extension_path = QLabel(extension_dir().as_posix())
self.lbl_extension_path.setWordWrap(True)
self.lbl_extension_path.setStyleSheet("color:#475569;font-size:11px;")
form.addRow("Chrome 扩展", self.lbl_extension_path)
row = QHBoxLayout()
self.btn_save = QPushButton("保存配置")
@ -103,7 +102,10 @@ class MainWindow(QMainWindow):
"background:#4F46E5;color:#fff;padding:6px 16px;border-radius:4px;"
)
self.btn_connect.clicked.connect(self._toggle_agent)
self.btn_reauth = QPushButton("重新授权")
self.btn_reauth.clicked.connect(self._reauthorize)
row.addWidget(self.btn_save)
row.addWidget(self.btn_reauth)
row.addStretch(1)
row.addWidget(self.btn_connect)
form.addRow("", row)
@ -151,17 +153,16 @@ class MainWindow(QMainWindow):
return box
def _apply_settings_to_form(self) -> None:
self.input_oms_url.setText(self._settings.oms_url)
self.input_token.setText(self._settings.token)
self.input_device_name.setText(self._settings.device_name)
self.input_bridge_port.setValue(self._settings.bridge_port)
def _collect_settings(self) -> AgentSettings:
return AgentSettings(
oms_url=self.input_oms_url.text().strip() or AgentSettings().oms_url,
token=self.input_token.text().strip(),
oms_url=self._settings.oms_url,
token=self._settings.token,
device_name=self.input_device_name.text().strip(),
bridge_port=self.input_bridge_port.value(),
web_origin=self._settings.web_origin,
)
def _save_settings(self) -> None:
@ -191,9 +192,15 @@ class MainWindow(QMainWindow):
self._settings = self._collect_settings()
if not self._settings.token:
QMessageBox.warning(self, "提示", "请填写 JWT Token")
self._start_pairing()
return
self._connect_with_settings()
def _connect_with_settings(self) -> None:
self._settings = self._collect_settings()
self._settings.save()
self._stop_pairing()
self._worker = AgentWorker(self._settings)
self._worker.log_line.connect(self._append_log)
@ -205,6 +212,98 @@ class MainWindow(QMainWindow):
self.btn_connect.setText("断开连接")
self.lbl_agent_state.setText("连接中…")
def _reauthorize(self) -> None:
if self._worker and self._worker.isRunning():
QMessageBox.warning(self, "提示", "请先断开连接再重新授权")
return
self._settings.token = ""
self._settings.save()
self._append_log("已清除授权,等待重新配对…")
self._start_pairing()
def _start_pairing(self) -> None:
if self._pair_server and self._pair_server.is_waiting:
self._append_log("已在等待浏览器授权…")
return
self._stop_pairing()
self._pair_server = PairServer(DEFAULT_PAIR_PORT)
def on_pair(payload: PairPayload) -> None:
self.pair_received.emit(payload)
self._pair_server.start(on_pair)
self.lbl_agent_state.setText("等待浏览器授权…")
self._append_log("等待 OMS 网页授权(请在浏览器登录 OMS)…")
self._open_pair_pages()
self._pair_timer = QTimer(self)
self._pair_timer.setSingleShot(True)
self._pair_timer.timeout.connect(self._on_pair_timeout)
self._pair_timer.start(PAIR_TIMEOUT_SEC * 1000)
def _open_pair_pages(self) -> None:
port = DEFAULT_PAIR_PORT
origins: list[str] = []
if self._settings.web_origin:
origins.append(self._settings.web_origin.rstrip("/"))
origins.extend(
[
"http://localhost:5173",
"http://127.0.0.1:5173",
]
)
seen: set[str] = set()
opened = False
for origin in origins:
if origin in seen:
continue
seen.add(origin)
url = f"{origin}/xhs/agentPair?port={port}"
try:
webbrowser.open(url)
opened = True
self._append_log(f"已打开授权页:{url}")
break
except Exception as exc:
logger.warning("打开浏览器失败 %s: %s", url, exc)
if not opened:
self._append_log(
"未能自动打开浏览器;请手动访问 OMS「小红书 → Agent 授权」或保持「启动服务」页打开"
)
def _on_pair_received(self, payload: object) -> None:
if not isinstance(payload, PairPayload):
return
self._settings.token = payload.token
self._settings.oms_url = payload.oms_url.rstrip("/")
if payload.web_origin:
self._settings.web_origin = payload.web_origin.rstrip("/")
self._settings.save()
self._append_log("已从浏览器收到授权,正在连接 OMS…")
self._connect_with_settings()
def _on_pair_timeout(self) -> None:
if self._pair_server and self._pair_server.is_waiting:
self._stop_pairing()
self.lbl_agent_state.setText("授权超时")
self._append_log("等待授权超时,请重试或保持「启动服务」页打开后再点连接")
QMessageBox.information(
self,
"授权超时",
"未收到浏览器授权。\n\n请确认:\n1. 浏览器已登录 OMS\n"
"2. 打开「小红书 → 启动服务」页面,或允许自动打开的授权页\n"
"3. 再次点击「连接 OMS」",
)
def _stop_pairing(self) -> None:
if self._pair_timer:
self._pair_timer.stop()
self._pair_timer = None
if self._pair_server:
self._pair_server.stop()
self._pair_server = None
def _on_agent_started(self, info: dict) -> None:
self.lbl_device_id.setText(str(info.get("device_id", "-")))
self.lbl_agent_state.setText("已连接 · 等待网页下发任务")
@ -273,6 +372,7 @@ class MainWindow(QMainWindow):
self.log_view.appendPlainText(f"[{ts}] {text}")
def closeEvent(self, event: QCloseEvent) -> None:
self._stop_pairing()
if self._worker and self._worker.isRunning():
box = QMessageBox.question(
self,

View File

@ -7,13 +7,18 @@ import shutil
import tempfile
import time
import uuid
import json
from pathlib import Path
from typing import Any, Callable
from urllib.parse import urlparse
import requests
from app.engine_path import ensure_runtime_importable
from app.oms_client import OmsAgentClient, OmsClientError
ensure_runtime_importable()
from xhs_bridge.config import parse_human_behavior_config, parse_human_guard_config
from xhs_bridge.exceptions import HumanGuardError, XhsBridgeError
from xhs_bridge.service import XhsBridgeService
@ -104,10 +109,11 @@ def _execute_task(
if not isinstance(arguments, dict):
arguments = {}
client.report_task(task_id, status="running", progress_text="Agent 开始执行")
_safe_report(client, task_id, status="running", progress_text="Agent 开始执行")
try:
result = _dispatch_task(service, task_type, tool_name, arguments, oms_base_url, temp_dir)
client.report_task(
_safe_report(
client,
task_id,
status="succeeded",
progress_text="执行完成",
@ -115,7 +121,8 @@ def _execute_task(
)
except Exception as exc:
logger.exception("任务 %s 失败", task_id)
client.report_task(
_safe_report(
client,
task_id,
status="failed",
progress_text=str(exc)[:500],
@ -123,6 +130,13 @@ def _execute_task(
)
def _safe_report(client: OmsAgentClient, task_id: int, **kwargs: Any) -> None:
try:
client.report_task(task_id, **kwargs)
except OmsClientError as exc:
logger.warning("任务 %s 上报失败: %s", task_id, exc)
def _dispatch_task(
service: XhsBridgeService,
task_type: str,
@ -185,7 +199,20 @@ def _dispatch_task(
if not success:
raise XhsBridgeError(text)
return {"summary": text[:500], "raw": text}
return _normalize_tool_result(text)
def _normalize_tool_result(text: str) -> dict[str, Any]:
stripped = (text or "").strip()
if not stripped:
return {"summary": ""}
try:
data = json.loads(stripped)
if isinstance(data, dict):
return data
except json.JSONDecodeError:
pass
return {"summary": stripped[:500], "raw": stripped}
def _resolve_media_paths(urls: list[Any], oms_base_url: str, temp_dir: Path) -> list[str]:

View File

@ -131,10 +131,14 @@ class OmsAgentClient:
if resp.status_code >= 400:
raise OmsClientError(f"HTTP {resp.status_code} {path}: {resp.text[:500]}")
text = (resp.text or "").strip()
if not text:
return {}
try:
payload = resp.json()
except ValueError as exc:
raise OmsClientError(f"非 JSON 响应 {path}") from exc
raise OmsClientError(f"非 JSON 响应 {path}: {text[:200]}") from exc
code = payload.get("code", payload.get("Code"))
if code not in (None, 0, "0", 200, "200"):

View File

@ -0,0 +1,121 @@
"""本地配对服务:接收浏览器发来的 OMS Token。"""
from __future__ import annotations
import json
import logging
import threading
from dataclasses import dataclass
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Callable
logger = logging.getLogger(__name__)
DEFAULT_PAIR_PORT = 19333
@dataclass(frozen=True)
class PairPayload:
token: str
oms_url: str
web_origin: str = ""
class PairServer:
"""127.0.0.1 本地 HTTP 服务,供 OMS 网页推送 JWT。"""
def __init__(self, port: int = DEFAULT_PAIR_PORT) -> None:
self.port = port
self._server: ThreadingHTTPServer | None = None
self._thread: threading.Thread | None = None
self._waiting = False
self._on_pair: Callable[[PairPayload], None] | None = None
@property
def is_waiting(self) -> bool:
return self._waiting
def start(self, on_pair: Callable[[PairPayload], None]) -> None:
self.stop()
self._on_pair = on_pair
self._waiting = True
server_ref = self
class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt: str, *args) -> None:
logger.debug(fmt, *args)
def _write_cors(self) -> None:
origin = self.headers.get("Origin", "")
if origin.startswith(("http://localhost", "http://127.0.0.1", "https://")):
self.send_header("Access-Control-Allow-Origin", origin)
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
def do_OPTIONS(self) -> None:
self.send_response(204)
self._write_cors()
self.end_headers()
def do_GET(self) -> None:
if self.path.split("?", 1)[0].rstrip("/") != "/status":
self.send_error(404)
return
body = json.dumps({"waiting": server_ref._waiting})
self.send_response(200)
self._write_cors()
self.send_header("Content-Type", "application/json; charset=utf-8")
self.end_headers()
self.wfile.write(body.encode("utf-8"))
def do_POST(self) -> None:
if self.path.split("?", 1)[0].rstrip("/") != "/pair":
self.send_error(404)
return
length = int(self.headers.get("Content-Length", 0))
raw = self.rfile.read(length) if length else b"{}"
try:
data = json.loads(raw.decode("utf-8"))
except json.JSONDecodeError:
self.send_error(400)
return
if not isinstance(data, dict):
self.send_error(400)
return
token = str(data.get("token") or "").strip()
oms_url = str(data.get("omsUrl") or data.get("oms_url") or "").strip().rstrip("/")
web_origin = str(data.get("webOrigin") or data.get("web_origin") or "").strip().rstrip("/")
if not token or not oms_url:
payload = json.dumps({"ok": False, "message": "token 与 omsUrl 必填"})
self.send_response(400)
self._write_cors()
self.send_header("Content-Type", "application/json; charset=utf-8")
self.end_headers()
self.wfile.write(payload.encode("utf-8"))
return
server_ref._waiting = False
if server_ref._on_pair:
server_ref._on_pair(
PairPayload(token=token, oms_url=oms_url, web_origin=web_origin)
)
payload = json.dumps({"ok": True})
self.send_response(200)
self._write_cors()
self.send_header("Content-Type", "application/json; charset=utf-8")
self.end_headers()
self.wfile.write(payload.encode("utf-8"))
self._server = ThreadingHTTPServer(("127.0.0.1", self.port), Handler)
self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
self._thread.start()
logger.info("配对服务已启动 127.0.0.1:%s", self.port)
def stop(self) -> None:
self._waiting = False
if self._server:
self._server.shutdown()
self._server.server_close()
self._server = None
self._thread = None

View File

@ -6,6 +6,10 @@ from __future__ import annotations
import sys
from app.engine_path import ensure_runtime_importable
ensure_runtime_importable()
from app.main_window import run_app