feat: 微信扫码轮询优化、XHS 联调与本地代理指向线上 API
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
5a26ae0048
commit
64becd8b1a
9
Dockerfile
Normal file
9
Dockerfile
Normal 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
|
||||||
19
README.md
19
README.md
@ -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/` 会导致启动失败。
|
||||||
|
|
||||||
## 贡献指南
|
## 贡献指南
|
||||||
|
|
||||||
|
|||||||
@ -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
131
nginx-docker.conf
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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}`}`
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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 }>;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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
50
src/utils/adminApi.ts
Normal 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'
|
||||||
|
}
|
||||||
@ -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
21
src/utils/wechatOAuth.ts
Normal 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
57
src/utils/xhsAgentPair.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 {
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
87
src/views/xhs/agentPair/index.vue
Normal file
87
src/views/xhs/agentPair/index.vue
Normal 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>
|
||||||
@ -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;
|
||||||
|
|
||||||
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 {
|
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 {
|
||||||
browseLoading.value = false;
|
if (!pollTimer) {
|
||||||
|
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) => {
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
});
|
||||||
|
|||||||
@ -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` 对应的后端地址
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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]:
|
||||||
|
|||||||
@ -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"):
|
||||||
|
|||||||
121
xhs-agent-desktop/app/pair_server.py
Normal file
121
xhs-agent-desktop/app/pair_server.py
Normal 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
|
||||||
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user