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 1. 安装依赖 `pnpm i`
2.构建代码 npm build 2. 构建 `pnpm build`(生成 `dist/`)
3.构建后会生成dist目录,直接给他拖拽到43服务器的/var/www/目录下 3. **宿主机 nginx(推荐)**:将 `dist/` 放到 `/usr/share/nginx/html`,使用 `nginx-default.conf`(含 SSL,证书在 `/etc/nginx/ssl/`)
4.构建镜像 docker build -t web1 . 4. **Docker 方式(7962 端口)**:使用 `nginx-docker.conf`(仅 HTTP,不含 SSL)
5.删掉旧的前端服务 docker rm -f web
6.跑新的 docker run -d -p 7962:80 --name web web1 ```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 块」处理 # 1. 本文件必须真正生效:确认 oms.nanafa.com 的请求由「本 server 块」处理
# - 若用 include:确保 include 的是含本 server 的 conf,且无其它 server 抢先匹配 # - 若用 include:确保 include 的是含本 server 的 conf,且无其它 server 抢先匹配
# - 更新后必须执行:nginx -t && nginx -s reload # - 更新后必须执行: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) # 3. HTTPS:证书部署到服务器(勿提交 .key 到 git)
# /etc/nginx/ssl/oms.nanafa.com.pem # /etc/nginx/ssl/oms.nanafa.com.pem
# /etc/nginx/ssl/oms.nanafa.com.key # /etc/nginx/ssl/oms.nanafa.com.key
@ -60,8 +60,46 @@ server {
try_files /index.html =200; try_files /index.html =200;
} }
# ============ 小红书前端路由(必须先于后端 API 反代)============ # ============ 小红书前端路由(^~ 须比下方 /xhs/ API 更具体)============
location ~ ^/xhs/(startService|analysis|creation|publishHistory|skills|humanSettings)(/|$) { 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; try_files /index.html =200;
} }
@ -106,6 +144,59 @@ server {
proxy_read_timeout 30s; 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 不缓存 # HTML 不缓存
location = /index.html { location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate"; 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 代理路径前缀) * OMS admin 接口通用:与 /order 同源(开发环境走 Vite 代理路径前缀)
*/ */
import { axiosInstance } from '@/api/rbac' import { axiosInstance } from '@/api/rbac'
import { resolveAdminApiPath, resolveAdminAssetOrigin } from '@/utils/adminApi'
export function omsUrl(path: string): string { export function omsUrl(path: string): string {
const p = path.startsWith('/') ? path : `/${path}` return resolveAdminApiPath(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}`
} }
export function assertBusinessOk(body: any) { 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:')) { if (/^https?:\/\//i.test(value) || value.startsWith('data:') || value.startsWith('blob:')) {
return value return value
} }
const origin = ( const origin = resolveAdminAssetOrigin()
import.meta.env.VITE_API_ASSET_ORIGIN ||
(import.meta.env.DEV ? 'http://api.nanafa.com' : 'http://api.nanafa.com')
)
.toString()
.replace(/\/$/, '')
return `${origin}${value.startsWith('/') ? value : `/${value}`}` return `${origin}${value.startsWith('/') ? value : `/${value}`}`
} }

View File

@ -2,14 +2,10 @@
* OMS 订单接口(Swagger:OMS-Order,与 admin 同源) * OMS 订单接口(Swagger:OMS-Order,与 admin 同源)
*/ */
import { axiosInstance } from '@/api/rbac' import { axiosInstance } from '@/api/rbac'
import { resolveAdminApiPath } from '@/utils/adminApi'
function orderUrl(path: string): string { function orderUrl(path: string): string {
const p = path.startsWith('/') ? path : `/${path}` return resolveAdminApiPath(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}`
} }
export interface OrderListRequest { export interface OrderListRequest {

View File

@ -5,7 +5,7 @@ import { OpenAPI, clearAuthToken } from './index'
import { useRbacUserStore } from '@/store/modules/rbac-user' import { useRbacUserStore } from '@/store/modules/rbac-user'
// 创建自定义 axios 实例 // 创建自定义 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({ export const axiosInstance = axios.create({
baseURL: '', // 始终为空,URL 由 OpenAPI.BASE + path 构建 baseURL: '', // 始终为空,URL 由 OpenAPI.BASE + path 构建
timeout: 30000, timeout: 30000,

View File

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

View File

@ -22,14 +22,22 @@ function unwrapOrSelf<T>(body: unknown): T {
return unwrapData<T>(body); return unwrapData<T>(body);
} }
/** 获取微信公众号登录二维码 */ /** 获取微信公众号登录二维码(loginUuid 可选,不传则由后端生成 key) */
export async function getLoginQrcode(loginUuid: string): Promise<WxLoginQrcodeResult> { export async function getLoginQrcode(loginUuid?: string): Promise<WxLoginQrcodeResult> {
const res = await axiosInstance.get(omsUrl('/wechat/mp/login-qrcode'), { const res = await axiosInstance.get(omsUrl('/wechat/mp/login-qrcode'), {
params: { loginUuid }, params: loginUuid ? { loginUuid } : undefined,
}); });
return unwrapOrSelf<WxLoginQrcodeResult>(res.data); 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 { export function isWxLoginPendingError(error: unknown): boolean {
const axiosErr = error as AxiosError<{ message?: string; Message?: string }>; const axiosErr = error as AxiosError<{ message?: string; Message?: string }>;

View File

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

View File

@ -2,14 +2,10 @@
* 小红书模块 API(Swagger:OMS-Xhs) * 小红书模块 API(Swagger:OMS-Xhs)
*/ */
import { axiosInstance } from '@/api/rbac'; import { axiosInstance } from '@/api/rbac';
import { resolveAdminApiPath } from '@/utils/adminApi';
function xhsUrl(path: string): string { function xhsUrl(path: string): string {
const p = path.startsWith('/') ? path : `/${path}`; return resolveAdminApiPath(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}`;
} }
function assertBusinessOk(body: any) { function assertBusinessOk(body: any) {

View File

@ -72,6 +72,16 @@ const XHS: AppRouteRecordRaw = {
permission: 'Xhs.HumanSettings.Save', 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( service.interceptors.request.use(
(config: AxiosRequestConfig) => { (config: AxiosRequestConfig) => {
config.headers = config.headers ?? {}; config.headers = config.headers ?? {};
// 生产:/api、/Track 走同源,由 nginx 代理到 api.nanafa.com,避免跨域 405;rbac 直连 admin // 生产:/api、/Track、/rbac 等走同源,由 nginx 代理到 api.nanafa.com
// 开发:Vite 代理 /api、/Track、/rbac // 开发:Vite 代理 /api、/Track、/rbac
// 从 localStorage 获取 token // 从 localStorage 获取 token
const token = localStorage.getItem('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 { Message } from '@arco-design/web-vue';
import { IconUser } from '@arco-design/web-vue/es/icon'; import { IconUser } from '@arco-design/web-vue/es/icon';
import { OmsLoginType } from '@/api/rbac'; 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 { useRbacUserStore } from '@/store/modules/rbac-user';
import { useUserStore } from '@/store'; import { useUserStore } from '@/store';
import { setToken } from '@/utils/auth'; import { setToken } from '@/utils/auth';
@ -76,9 +76,9 @@ let expireTimer: ReturnType<typeof setTimeout> | undefined;
function createLoginUuid(): string { function createLoginUuid(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { 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() { function clearTimers() {
@ -144,6 +144,9 @@ async function pollWxLogin() {
if (!loginUuid.value) return; if (!loginUuid.value) return;
try { try {
const status = await getWxLoginStatus(loginUuid.value);
if (!status?.ready) return;
const result = await wxLogin(loginUuid.value); const result = await wxLogin(loginUuid.value);
if (!result?.accessToken) return; if (!result?.accessToken) return;
@ -178,6 +181,7 @@ async function loadQrCode() {
Message.error(t('login.wechat.loadFailed')); Message.error(t('login.wechat.loadFailed'));
return; return;
} }
loginUuid.value = result.loginKey || loginUuid.value;
qrCodeUrl.value = result.qrCodeUrl; qrCodeUrl.value = result.qrCodeUrl;
startPolling(); startPolling();
} catch { } catch {

View File

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

View File

@ -95,6 +95,7 @@ import {
DEFAULT_CUSTOMER_PASSWORD, DEFAULT_CUSTOMER_PASSWORD,
isLetterAndDigitPassword, isLetterAndDigitPassword,
} from '@/utils/customerPassword'; } from '@/utils/customerPassword';
import { redirectToWeChatOAuth } from '@/utils/wechatOAuth';
defineOptions({ name: 'WechatRegister' }); defineOptions({ name: 'WechatRegister' });
@ -187,7 +188,7 @@ async function handleSubmit() {
onMounted(() => { onMounted(() => {
const code = route.query.code; const code = route.query.code;
if (typeof code !== 'string' || !code) { if (typeof code !== 'string' || !code) {
statusCode.value = 'oauth-error'; redirectToWeChatOAuth('register');
return; return;
} }
void loadOAuthUserInfo(code); 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 <AnalysisToolPanel
ref="panelRef" ref="panelRef"
:tool="activeTool" :tool="activeTool"
:running="running" :running="running || browseLoading"
:browse-loading="browseLoading" :browse-loading="browseLoading"
:feed-rows="currentBrowseState.feeds" :feed-rows="currentBrowseState.feeds"
:selected-feed="currentBrowseState.selectedFeed" :selected-feed="currentBrowseState.selectedFeed"
@ -114,6 +114,11 @@ const browseStates = reactive<Record<string, BrowseToolState>>({
}); });
let pollTimer: ReturnType<typeof setInterval> | null = null; 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[]>(() => const toolTreeData = computed<CustomLeftMenuTreeNode[]>(() =>
buildAnalysisToolTree(menuSearchKeyword.value), buildAnalysisToolTree(menuSearchKeyword.value),
@ -141,6 +146,17 @@ function parseFeedRows(feeds: unknown): XhsFeedRow[] {
if (!feeds || typeof feeds !== 'object') return []; if (!feeds || typeof feeds !== 'object') return [];
const root = feeds as Record<string, unknown>; const root = feeds as Record<string, unknown>;
if (root.error) return []; 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) const list = Array.isArray(feeds)
? feeds ? feeds
: (root.feeds ?? root.items ?? root.notes); : (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>) { async function onToolRun(payload: Record<string, unknown>) {
const tool = activeTool.value; const tool = activeTool.value;
if (!tool) return; if (!tool) return;
@ -186,24 +229,37 @@ async function runBrowseTool(tool: XhsAnalysisToolDef, payload: Record<string, u
browseLoading.value = true; browseLoading.value = true;
respStatus.value = '任务提交中…'; 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 { try {
const result = await startXhsAnalysis({ const result = await startXhsAnalysis({
analysisType: tool.analysisType!, analysisType: tool.analysisType!,
keyword: String(payload.keyword ?? '').trim() || undefined, keyword: String(payload.keyword ?? '').trim() || undefined,
filters: payload.filters, filters: payload.filters,
agentId: interactAgentId.value, agentId,
}); });
const state = browseStates[tool.name]; const state = browseStates[tool.name];
state.sessionId = result.sessionId; state.sessionId = result.sessionId;
state.feeds = []; state.feeds = [];
state.selectedFeed = null; state.selectedFeed = null;
state.taskStatus = 'pending'; state.taskStatus = 'pending';
respStatus.value = `已提交任务 #${result.taskId},等待 Agent 采集…`; respStatus.value = `已提交任务 #${result.taskId},${formatTaskStatus('pending')}`;
responseText.value = ''; responseText.value = '';
setupPoll(tool.name); setupPoll(tool.name);
} catch (e: any) { } catch (e: any) {
Message.error(e?.message || '提交失败'); Message.error(e?.message || '提交失败');
respStatus.value = '提交失败'; respStatus.value = '提交失败';
browseLoading.value = false;
} finally { } finally {
running.value = false; running.value = false;
} }
@ -223,7 +279,7 @@ async function runAnalyzeTool(payload: Record<string, unknown>) {
const result = await startXhsAnalysis({ const result = await startXhsAnalysis({
analysisType: 'feed_detail', analysisType: 'feed_detail',
keyword: feedId, keyword: feedId,
agentId: interactAgentId.value, agentId: resolveAgentIdForTask(),
filters: { filters: {
xsec_token: payload.xsec_token, xsec_token: payload.xsec_token,
load_all_comments: payload.load_all_comments, 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]; const state = browseStates[toolName];
if (!state?.sessionId) return; if (!state?.sessionId) return;
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; browseLoading.value = true;
}
try { try {
const detail = await getXhsAnalysisDetail(state.sessionId); const detail = await getXhsAnalysisDetail(state.sessionId);
state.taskStatus = detail.taskStatus; state.taskStatus = detail.taskStatus;
@ -327,21 +394,28 @@ async function loadSessionDetail(toolName: string) {
stopPoll(); stopPoll();
} else if (detail.taskStatus === 'failed') { } else if (detail.taskStatus === 'failed') {
respStatus.value = '任务失败'; respStatus.value = '任务失败';
if (detail.feeds && typeof detail.feeds === 'object' && (detail.feeds as any).error) {
responseText.value = String((detail.feeds as any).error);
}
stopPoll(); stopPoll();
} else { } else {
respStatus.value = `任务状态:${detail.taskStatus ?? '执行中'}`; respStatus.value = formatTaskStatus(detail.taskStatus);
} }
} catch (e: any) { } catch (e: any) {
Message.error(e?.message || '加载详情失败'); Message.error(e?.message || '加载详情失败');
} finally { } finally {
if (!pollTimer) {
browseLoading.value = false; browseLoading.value = false;
}
running.value = false; running.value = false;
} }
} }
function setupPoll(toolName: string) { function setupPoll(toolName: string) {
stopPoll(); stopPoll();
pollTimer = setInterval(() => loadSessionDetail(toolName), 5000); pollStartedAt = Date.now();
browseLoading.value = true;
pollTimer = setInterval(() => loadSessionDetail(toolName, { silent: true }), POLL_INTERVAL_MS);
loadSessionDetail(toolName); loadSessionDetail(toolName);
} }
@ -350,6 +424,8 @@ function stopPoll() {
clearInterval(pollTimer); clearInterval(pollTimer);
pollTimer = null; pollTimer = null;
} }
pollStartedAt = 0;
browseLoading.value = false;
} }
function onSelectFeed(feed: XhsFeedRow) { function onSelectFeed(feed: XhsFeedRow) {
@ -426,7 +502,7 @@ onMounted(async () => {
await Promise.all([ await Promise.all([
listXhsAgents(1, 100).then((r) => { listXhsAgents(1, 100).then((r) => {
agents.value = r.items ?? []; agents.value = r.items ?? [];
const online = agents.value.find((a) => a.status === 'online'); const online = onlineAgents.value[0];
if (online) interactAgentId.value = online.id; if (online) interactAgentId.value = online.id;
}), }),
listXhsAiProviders().then((r) => { listXhsAiProviders().then((r) => {

View File

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

View File

@ -1,6 +1,6 @@
import { resolve } from 'path'; import { resolve } from 'path';
import type { Plugin, UserConfig } from 'vite'; import type { Plugin, UserConfig } from 'vite';
import { defineConfig } from 'vite'; import { defineConfig, loadEnv } from 'vite';
import visualizer from 'rollup-plugin-visualizer'; import visualizer from 'rollup-plugin-visualizer';
import vue from '@vitejs/plugin-vue'; import vue from '@vitejs/plugin-vue';
import vueJsx from '@vitejs/plugin-vue-jsx'; import vueJsx from '@vitejs/plugin-vue-jsx';
@ -11,7 +11,11 @@ import UnoCSS from 'unocss/vite';
import configArcoStyleImportPlugin from './config/plugin/arcoStyleImport'; import configArcoStyleImportPlugin from './config/plugin/arcoStyleImport';
import configCompressPlugin from './config/plugin/compress'; 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: { server: {
open: true, open: true,
fs: { fs: {
@ -97,9 +101,9 @@ export default defineConfig({
changeOrigin: true, changeOrigin: true,
secure: false, secure: false,
}, },
// 小红书 OMS-Xhs 接口 // 小红书 OMS-Xhs 接口(开发默认走本机 Admin API,与桌面 Agent 一致)
'/xhs': { '/xhs': {
target: 'http://api.nanafa.com/admin', target: omsAdminTarget,
changeOrigin: true, changeOrigin: true,
secure: false, secure: false,
}, },
@ -239,6 +243,7 @@ export default defineConfig({
'/xhs/publishHistory', '/xhs/publishHistory',
'/xhs/skills', '/xhs/skills',
'/xhs/humanSettings', '/xhs/humanSettings',
'/xhs/agentPair',
'/track/guide', '/track/guide',
'/track/order', '/track/order',
'/track/warningOrder', '/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` 并安装依赖。 首次运行会自动创建 `.venv` 并安装依赖。
## 使用 ## 使用(一键连接)
1. 网页登录 OMS → **小红书 → 启动服务 → Agent 连接指南** 1. 浏览器登录 OMS,打开 **小红书 → 启动服务**(保持页面打开)
2. 复制 **API 地址**、**JWT Token** 到桌面端 2. 运行桌面端 `run.ps1`,点击 **连接 OMS**
3. 点击 **连接 OMS** 3. 网页会自动把登录凭证发给本机 Agent,**无需填写 API 地址或 Token**
4. **启动引擎**,在 Chrome `chrome://extensions` 加载扩展目录(界面已显示路径) 4. **启动引擎**,在 Chrome `chrome://extensions` 加载扩展目录(界面已显示路径)
5. 打开小红书登录;网页即可远程控制 5. 打开小红书登录;网页即可远程控制
若未自动授权,桌面端会打开授权页;也可手动访问 `/xhs/agentPair`。
## 开发说明 ## 开发说明
`engine/` 目录为从 LIKE AI 提取的运行时(无 GUI)。后续可在 OMS 仓库内独立演进,不再依赖外部 LIKE AI 安装。 `engine/` 目录为从 LIKE AI 提取的运行时(无 GUI)。后续可在 OMS 仓库内独立演进,不再依赖外部 LIKE AI 安装。
@ -43,5 +45,5 @@ cd E:\oms0414\xhs-agent-desktop
## 联调 ## 联调
- 前端 dev 时 API 地址填 `http://localhost:5173/admin`(Vite 代理) - 开发环境:网页 dev server(5173)会通过 Vite 代理 `/xhs`;桌面端配对后自动使用相同根地址
- Agent 必须能访问真实 Admin API,不能用仅静态资源的地址 - 生产环境:配对时自动写入 `VITE_API_ADMIN_URL` 对应的后端地址

View File

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

View File

@ -4,9 +4,10 @@ from __future__ import annotations
import logging import logging
import sys import sys
import webbrowser
from datetime import datetime from datetime import datetime
from PyQt6.QtCore import Qt from PyQt6.QtCore import QTimer, pyqtSignal
from PyQt6.QtGui import QCloseEvent, QFont from PyQt6.QtGui import QCloseEvent, QFont
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QApplication, QApplication,
@ -27,11 +28,16 @@ from PyQt6.QtWidgets import (
from app.agent_worker import AgentWorker from app.agent_worker import AgentWorker
from app.config_store import AgentSettings from app.config_store import AgentSettings
from app.engine_path import ensure_runtime_importable, extension_dir from app.engine_path import ensure_runtime_importable, extension_dir
from app.pair_server import DEFAULT_PAIR_PORT, PairPayload, PairServer
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
PAIR_TIMEOUT_SEC = 120
class MainWindow(QMainWindow): class MainWindow(QMainWindow):
pair_received = pyqtSignal(object)
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
self.setWindowTitle("OMS 小红书 Agent") self.setWindowTitle("OMS 小红书 Agent")
@ -41,6 +47,8 @@ class MainWindow(QMainWindow):
self._settings = AgentSettings.load() self._settings = AgentSettings.load()
self._worker: AgentWorker | None = None self._worker: AgentWorker | None = None
self._bridge = None self._bridge = None
self._pair_server: PairServer | None = None
self._pair_timer: QTimer | None = None
root = QWidget() root = QWidget()
self.setCentralWidget(root) self.setCentralWidget(root)
@ -54,16 +62,16 @@ class MainWindow(QMainWindow):
layout.addWidget(self._build_status_group()) layout.addWidget(self._build_status_group())
layout.addWidget(self._build_log_group(), 1) layout.addWidget(self._build_log_group(), 1)
self.pair_received.connect(self._on_pair_received)
self._apply_settings_to_form() self._apply_settings_to_form()
self._try_init_bridge() self._try_init_bridge()
def _build_intro(self) -> QLabel: def _build_intro(self) -> QLabel:
lbl = QLabel( lbl = QLabel(
"独立桌面端:内置 Chrome 扩展 + Bridge 引擎,连接 OMS 后由网页远程调度。" "打开本程序并点击「连接 OMS」即可。"
"在网页「小红书 → 启动服务 → Agent 连接指南」复制 API 地址与 Token。" "请先在浏览器登录 OMS;若已打开「启动服务」页面,将自动完成授权,无需填写 API 或 Token。"
) )
lbl.setWordWrap(True) lbl.setWordWrap(True)
lbl.setObjectName("intro")
lbl.setStyleSheet("color:#64748b;font-size:12px;") lbl.setStyleSheet("color:#64748b;font-size:12px;")
return lbl return lbl
@ -72,15 +80,6 @@ class MainWindow(QMainWindow):
form = QFormLayout(box) form = QFormLayout(box)
form.setSpacing(8) 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 = QLineEdit()
self.input_device_name.setPlaceholderText("如:办公室电脑") self.input_device_name.setPlaceholderText("如:办公室电脑")
form.addRow("设备名称", self.input_device_name) form.addRow("设备名称", self.input_device_name)
@ -90,10 +89,10 @@ class MainWindow(QMainWindow):
self.input_bridge_port.setValue(9333) self.input_bridge_port.setValue(9333)
form.addRow("Bridge 端口", self.input_bridge_port) form.addRow("Bridge 端口", self.input_bridge_port)
self.lbl_extension = QLabel(extension_dir().as_posix()) self.lbl_extension_path = QLabel(extension_dir().as_posix())
self.lbl_extension.setWordWrap(True) self.lbl_extension_path.setWordWrap(True)
self.lbl_extension.setStyleSheet("color:#475569;font-size:11px;") self.lbl_extension_path.setStyleSheet("color:#475569;font-size:11px;")
form.addRow("Chrome 扩展", self.lbl_extension) form.addRow("Chrome 扩展", self.lbl_extension_path)
row = QHBoxLayout() row = QHBoxLayout()
self.btn_save = QPushButton("保存配置") self.btn_save = QPushButton("保存配置")
@ -103,7 +102,10 @@ class MainWindow(QMainWindow):
"background:#4F46E5;color:#fff;padding:6px 16px;border-radius:4px;" "background:#4F46E5;color:#fff;padding:6px 16px;border-radius:4px;"
) )
self.btn_connect.clicked.connect(self._toggle_agent) 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_save)
row.addWidget(self.btn_reauth)
row.addStretch(1) row.addStretch(1)
row.addWidget(self.btn_connect) row.addWidget(self.btn_connect)
form.addRow("", row) form.addRow("", row)
@ -151,17 +153,16 @@ class MainWindow(QMainWindow):
return box return box
def _apply_settings_to_form(self) -> None: 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_device_name.setText(self._settings.device_name)
self.input_bridge_port.setValue(self._settings.bridge_port) self.input_bridge_port.setValue(self._settings.bridge_port)
def _collect_settings(self) -> AgentSettings: def _collect_settings(self) -> AgentSettings:
return AgentSettings( return AgentSettings(
oms_url=self.input_oms_url.text().strip() or AgentSettings().oms_url, oms_url=self._settings.oms_url,
token=self.input_token.text().strip(), token=self._settings.token,
device_name=self.input_device_name.text().strip(), device_name=self.input_device_name.text().strip(),
bridge_port=self.input_bridge_port.value(), bridge_port=self.input_bridge_port.value(),
web_origin=self._settings.web_origin,
) )
def _save_settings(self) -> None: def _save_settings(self) -> None:
@ -191,9 +192,15 @@ class MainWindow(QMainWindow):
self._settings = self._collect_settings() self._settings = self._collect_settings()
if not self._settings.token: if not self._settings.token:
QMessageBox.warning(self, "提示", "请填写 JWT Token") self._start_pairing()
return return
self._connect_with_settings()
def _connect_with_settings(self) -> None:
self._settings = self._collect_settings()
self._settings.save() self._settings.save()
self._stop_pairing()
self._worker = AgentWorker(self._settings) self._worker = AgentWorker(self._settings)
self._worker.log_line.connect(self._append_log) self._worker.log_line.connect(self._append_log)
@ -205,6 +212,98 @@ class MainWindow(QMainWindow):
self.btn_connect.setText("断开连接") self.btn_connect.setText("断开连接")
self.lbl_agent_state.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: def _on_agent_started(self, info: dict) -> None:
self.lbl_device_id.setText(str(info.get("device_id", "-"))) self.lbl_device_id.setText(str(info.get("device_id", "-")))
self.lbl_agent_state.setText("已连接 · 等待网页下发任务") self.lbl_agent_state.setText("已连接 · 等待网页下发任务")
@ -273,6 +372,7 @@ class MainWindow(QMainWindow):
self.log_view.appendPlainText(f"[{ts}] {text}") self.log_view.appendPlainText(f"[{ts}] {text}")
def closeEvent(self, event: QCloseEvent) -> None: def closeEvent(self, event: QCloseEvent) -> None:
self._stop_pairing()
if self._worker and self._worker.isRunning(): if self._worker and self._worker.isRunning():
box = QMessageBox.question( box = QMessageBox.question(
self, self,

View File

@ -7,13 +7,18 @@ import shutil
import tempfile import tempfile
import time import time
import uuid import uuid
import json
from pathlib import Path from pathlib import Path
from typing import Any, Callable from typing import Any, Callable
from urllib.parse import urlparse from urllib.parse import urlparse
import requests import requests
from app.engine_path import ensure_runtime_importable
from app.oms_client import OmsAgentClient, OmsClientError 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.config import parse_human_behavior_config, parse_human_guard_config
from xhs_bridge.exceptions import HumanGuardError, XhsBridgeError from xhs_bridge.exceptions import HumanGuardError, XhsBridgeError
from xhs_bridge.service import XhsBridgeService from xhs_bridge.service import XhsBridgeService
@ -104,10 +109,11 @@ def _execute_task(
if not isinstance(arguments, dict): if not isinstance(arguments, dict):
arguments = {} arguments = {}
client.report_task(task_id, status="running", progress_text="Agent 开始执行") _safe_report(client, task_id, status="running", progress_text="Agent 开始执行")
try: try:
result = _dispatch_task(service, task_type, tool_name, arguments, oms_base_url, temp_dir) result = _dispatch_task(service, task_type, tool_name, arguments, oms_base_url, temp_dir)
client.report_task( _safe_report(
client,
task_id, task_id,
status="succeeded", status="succeeded",
progress_text="执行完成", progress_text="执行完成",
@ -115,7 +121,8 @@ def _execute_task(
) )
except Exception as exc: except Exception as exc:
logger.exception("任务 %s 失败", task_id) logger.exception("任务 %s 失败", task_id)
client.report_task( _safe_report(
client,
task_id, task_id,
status="failed", status="failed",
progress_text=str(exc)[:500], 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( def _dispatch_task(
service: XhsBridgeService, service: XhsBridgeService,
task_type: str, task_type: str,
@ -185,7 +199,20 @@ def _dispatch_task(
if not success: if not success:
raise XhsBridgeError(text) 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]: 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: if resp.status_code >= 400:
raise OmsClientError(f"HTTP {resp.status_code} {path}: {resp.text[:500]}") raise OmsClientError(f"HTTP {resp.status_code} {path}: {resp.text[:500]}")
text = (resp.text or "").strip()
if not text:
return {}
try: try:
payload = resp.json() payload = resp.json()
except ValueError as exc: 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")) code = payload.get("code", payload.get("Code"))
if code not in (None, 0, "0", 200, "200"): 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 import sys
from app.engine_path import ensure_runtime_importable
ensure_runtime_importable()
from app.main_window import run_app from app.main_window import run_app