diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f7f517b --- /dev/null +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index 9a4a8b6..2b7a296 100644 --- a/README.md +++ b/README.md @@ -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/` 会导致启动失败。 ## 贡献指南 diff --git a/nginx-default.conf b/nginx-default.conf index bc823bb..bd8bf65 100644 --- a/nginx-default.conf +++ b/nginx-default.conf @@ -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"; diff --git a/nginx-docker.conf b/nginx-docker.conf new file mode 100644 index 0000000..64e942e --- /dev/null +++ b/nginx-docker.conf @@ -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; + } +} diff --git a/src/api/oms/common.ts b/src/api/oms/common.ts index 9ad619e..113758e 100644 --- a/src/api/oms/common.ts +++ b/src/api/oms/common.ts @@ -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}`}` } diff --git a/src/api/order/index.ts b/src/api/order/index.ts index 4f2cd24..3fb59f9 100644 --- a/src/api/order/index.ts +++ b/src/api/order/index.ts @@ -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 { diff --git a/src/api/rbac/axios-config.ts b/src/api/rbac/axios-config.ts index 714f62a..8606b17 100644 --- a/src/api/rbac/axios-config.ts +++ b/src/api/rbac/axios-config.ts @@ -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, diff --git a/src/api/rbac/index.ts b/src/api/rbac/index.ts index bc0122d..40e49cd 100644 --- a/src/api/rbac/index.ts +++ b/src/api/rbac/index.ts @@ -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) { diff --git a/src/api/wechat/index.ts b/src/api/wechat/index.ts index 6f11d9d..81944a8 100644 --- a/src/api/wechat/index.ts +++ b/src/api/wechat/index.ts @@ -22,14 +22,22 @@ function unwrapOrSelf(body: unknown): T { return unwrapData(body); } -/** 获取微信公众号登录二维码 */ -export async function getLoginQrcode(loginUuid: string): Promise { +/** 获取微信公众号登录二维码(loginUuid 可选,不传则由后端生成 key) */ +export async function getLoginQrcode(loginUuid?: string): Promise { const res = await axiosInstance.get(omsUrl('/wechat/mp/login-qrcode'), { - params: { loginUuid }, + params: loginUuid ? { loginUuid } : undefined, }); return unwrapOrSelf(res.data); } +/** 查询扫码 key 是否已关联账号(轮询用,无值时不抛错) */ +export async function getWxLoginStatus(loginUuid: string): Promise { + const res = await axiosInstance.get('/rbac/auth/wx-login/status', { + params: { loginUuid }, + }); + return unwrapOrSelf(res.data); +} + /** 是否属于「等待扫码」类错误(轮询时应静默继续) */ export function isWxLoginPendingError(error: unknown): boolean { const axiosErr = error as AxiosError<{ message?: string; Message?: string }>; diff --git a/src/api/wechat/types.ts b/src/api/wechat/types.ts index 31c4c4e..45ff9a9 100644 --- a/src/api/wechat/types.ts +++ b/src/api/wechat/types.ts @@ -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; diff --git a/src/api/xhs/index.ts b/src/api/xhs/index.ts index fa33475..cdceb60 100644 --- a/src/api/xhs/index.ts +++ b/src/api/xhs/index.ts @@ -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) { diff --git a/src/router/routes/newModules/xhs.ts b/src/router/routes/newModules/xhs.ts index 6c68725..300e82d 100644 --- a/src/router/routes/newModules/xhs.ts +++ b/src/router/routes/newModules/xhs.ts @@ -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, + }, + }, ], }; diff --git a/src/utils/adminApi.ts b/src/utils/adminApi.ts new file mode 100644 index 0000000..0574af8 --- /dev/null +++ b/src/utils/adminApi.ts @@ -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' +} diff --git a/src/utils/request.ts b/src/utils/request.ts index 46e2d6e..5d16564 100644 --- a/src/utils/request.ts +++ b/src/utils/request.ts @@ -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'); diff --git a/src/utils/wechatOAuth.ts b/src/utils/wechatOAuth.ts new file mode 100644 index 0000000..c5df149 --- /dev/null +++ b/src/utils/wechatOAuth.ts @@ -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`, + ); +} diff --git a/src/utils/xhsAgentPair.ts b/src/utils/xhsAgentPair.ts new file mode 100644 index 0000000..1f7c2a4 --- /dev/null +++ b/src/utils/xhsAgentPair.ts @@ -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 { + 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 { + 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; + } +} diff --git a/src/views/login/components/wechat-login.vue b/src/views/login/components/wechat-login.vue index 9845208..d9cd433 100644 --- a/src/views/login/components/wechat-login.vue +++ b/src/views/login/components/wechat-login.vue @@ -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 | 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 { diff --git a/src/views/wechat/bind/index.vue b/src/views/wechat/bind/index.vue index 41125db..a183866 100644 --- a/src/views/wechat/bind/index.vue +++ b/src/views/wechat/bind/index.vue @@ -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); diff --git a/src/views/wechat/register/index.vue b/src/views/wechat/register/index.vue index a0ad5ec..03619db 100644 --- a/src/views/wechat/register/index.vue +++ b/src/views/wechat/register/index.vue @@ -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); diff --git a/src/views/xhs/agentPair/index.vue b/src/views/xhs/agentPair/index.vue new file mode 100644 index 0000000..d076147 --- /dev/null +++ b/src/views/xhs/agentPair/index.vue @@ -0,0 +1,87 @@ + + + + + diff --git a/src/views/xhs/analysis/index.vue b/src/views/xhs/analysis/index.vue index 8dbc62f..d81af6d 100644 --- a/src/views/xhs/analysis/index.vue +++ b/src/views/xhs/analysis/index.vue @@ -25,7 +25,7 @@ >({ }); let pollTimer: ReturnType | 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(() => buildAnalysisToolTree(menuSearchKeyword.value), @@ -141,6 +146,17 @@ function parseFeedRows(feeds: unknown): XhsFeedRow[] { if (!feeds || typeof feeds !== 'object') return []; const root = feeds as Record; 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) { const tool = activeTool.value; if (!tool) return; @@ -186,24 +229,37 @@ async function runBrowseTool(tool: XhsAnalysisToolDef, payload: Record) { 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) => { diff --git a/src/views/xhs/startService/index.vue b/src/views/xhs/startService/index.vue index 38dc547..5976835 100644 --- a/src/views/xhs/startService/index.vue +++ b/src/views/xhs/startService/index.vue @@ -204,11 +204,11 @@ :footer="false" > - - 安装 Agent 桌面端 + + 打开 Agent 桌面端 - - 连接 OMS + + 一键连接 本机引擎与扩展 @@ -218,22 +218,16 @@ - - - - - - - - - 复制 Token - - + + 请先在浏览器登录 OMS,再打开桌面端点击「连接 OMS」。若本页已打开,授权会自动完成。 +
-
桌面端(推荐,已替代 LIKE AI)
+
桌面端
{{ desktopAgentHint }}
@@ -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 | null = null; +let pairTimer: ReturnType | 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); }); diff --git a/vite.config.ts b/vite.config.ts index 962a81c..1ea505b 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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; +}); diff --git a/xhs-agent-desktop/README.md b/xhs-agent-desktop/README.md index 8ffee76..86933cd 100644 --- a/xhs-agent-desktop/README.md +++ b/xhs-agent-desktop/README.md @@ -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` 对应的后端地址 diff --git a/xhs-agent-desktop/app/config_store.py b/xhs-agent-desktop/app/config_store.py index 3e1d22c..0f4f097 100644 --- a/xhs-agent-desktop/app/config_store.py +++ b/xhs-agent-desktop/app/config_store.py @@ -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() diff --git a/xhs-agent-desktop/app/main_window.py b/xhs-agent-desktop/app/main_window.py index cecc064..df1ef4a 100644 --- a/xhs-agent-desktop/app/main_window.py +++ b/xhs-agent-desktop/app/main_window.py @@ -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, diff --git a/xhs-agent-desktop/app/oms_agent_runner.py b/xhs-agent-desktop/app/oms_agent_runner.py index 008c0c0..d0f4643 100644 --- a/xhs-agent-desktop/app/oms_agent_runner.py +++ b/xhs-agent-desktop/app/oms_agent_runner.py @@ -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]: diff --git a/xhs-agent-desktop/app/oms_client.py b/xhs-agent-desktop/app/oms_client.py index 00284ee..09f93fd 100644 --- a/xhs-agent-desktop/app/oms_client.py +++ b/xhs-agent-desktop/app/oms_client.py @@ -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"): diff --git a/xhs-agent-desktop/app/pair_server.py b/xhs-agent-desktop/app/pair_server.py new file mode 100644 index 0000000..9164933 --- /dev/null +++ b/xhs-agent-desktop/app/pair_server.py @@ -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 diff --git a/xhs-agent-desktop/main.py b/xhs-agent-desktop/main.py index dd57d9d..70ea9a3 100644 --- a/xhs-agent-desktop/main.py +++ b/xhs-agent-desktop/main.py @@ -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