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
|
||||
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/` 会导致启动失败。
|
||||
|
||||
## 贡献指南
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
# 1. 本文件必须真正生效:确认 oms.nanafa.com 的请求由「本 server 块」处理
|
||||
# - 若用 include:确保 include 的是含本 server 的 conf,且无其它 server 抢先匹配
|
||||
# - 更新后必须执行:nginx -t && nginx -s reload
|
||||
# 2. /api、/Track 由 nginx 代理到 api.nanafa.com,避免浏览器跨域 405;rbac 由前端直连 admin
|
||||
# 2. /api、/Track、/rbac、/order 等 Admin 接口均由 nginx 代理到 api.nanafa.com,浏览器只访问 oms 同源
|
||||
# 3. HTTPS:证书部署到服务器(勿提交 .key 到 git)
|
||||
# /etc/nginx/ssl/oms.nanafa.com.pem
|
||||
# /etc/nginx/ssl/oms.nanafa.com.key
|
||||
@ -60,8 +60,46 @@ server {
|
||||
try_files /index.html =200;
|
||||
}
|
||||
|
||||
# ============ 小红书前端路由(必须先于后端 API 反代)============
|
||||
location ~ ^/xhs/(startService|analysis|creation|publishHistory|skills|humanSettings)(/|$) {
|
||||
# ============ 小红书前端路由(^~ 须比下方 /xhs/ API 更具体)============
|
||||
location ^~ /xhs/startService {
|
||||
try_files /index.html =200;
|
||||
}
|
||||
location ^~ /xhs/creation {
|
||||
try_files /index.html =200;
|
||||
}
|
||||
location ^~ /xhs/publishHistory {
|
||||
try_files /index.html =200;
|
||||
}
|
||||
location ^~ /xhs/skills {
|
||||
try_files /index.html =200;
|
||||
}
|
||||
location ^~ /xhs/humanSettings {
|
||||
try_files /index.html =200;
|
||||
}
|
||||
location ^~ /xhs/agentPair {
|
||||
try_files /index.html =200;
|
||||
}
|
||||
location = /xhs/analysis {
|
||||
try_files /index.html =200;
|
||||
}
|
||||
location = /xhs/analysis/ {
|
||||
try_files /index.html =200;
|
||||
}
|
||||
|
||||
# ============ 微信 H5 / 操作页(^~ 须比下方 API 前缀更长或更具体)============
|
||||
location ^~ /wechat/register {
|
||||
try_files /index.html =200;
|
||||
}
|
||||
location ^~ /wechat/bind {
|
||||
try_files /index.html =200;
|
||||
}
|
||||
location ^~ /operation/inboundScan {
|
||||
try_files /index.html =200;
|
||||
}
|
||||
location ^~ /operation/outboundScan {
|
||||
try_files /index.html =200;
|
||||
}
|
||||
location ^~ /operation/printLabel {
|
||||
try_files /index.html =200;
|
||||
}
|
||||
|
||||
@ -106,6 +144,59 @@ server {
|
||||
proxy_read_timeout 30s;
|
||||
}
|
||||
|
||||
# ============ Admin API 同源反代(/rbac /order /xhs … → api.nanafa.com/admin)============
|
||||
location ~ ^/(rbac|order|customer|recharge|channel|system)/ {
|
||||
rewrite ^/(.*)$ /admin/$1 break;
|
||||
proxy_pass http://api.nanafa.com;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host api.nanafa.com;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_connect_timeout 30s;
|
||||
proxy_send_timeout 30s;
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
|
||||
location ^~ /wechat/ {
|
||||
rewrite ^/(.*)$ /admin/$1 break;
|
||||
proxy_pass http://api.nanafa.com;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host api.nanafa.com;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_connect_timeout 30s;
|
||||
proxy_send_timeout 30s;
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
|
||||
location ^~ /operation/ {
|
||||
rewrite ^/(.*)$ /admin/$1 break;
|
||||
proxy_pass http://api.nanafa.com;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host api.nanafa.com;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_connect_timeout 30s;
|
||||
proxy_send_timeout 30s;
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
|
||||
location ^~ /xhs/ {
|
||||
rewrite ^/(.*)$ /admin/$1 break;
|
||||
proxy_pass http://api.nanafa.com;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host api.nanafa.com;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_connect_timeout 30s;
|
||||
proxy_send_timeout 30s;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
|
||||
# HTML 不缓存
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
|
||||
131
nginx-docker.conf
Normal file
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 代理路径前缀)
|
||||
*/
|
||||
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}`}`
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -22,14 +22,22 @@ function unwrapOrSelf<T>(body: unknown): T {
|
||||
return unwrapData<T>(body);
|
||||
}
|
||||
|
||||
/** 获取微信公众号登录二维码 */
|
||||
export async function getLoginQrcode(loginUuid: string): Promise<WxLoginQrcodeResult> {
|
||||
/** 获取微信公众号登录二维码(loginUuid 可选,不传则由后端生成 key) */
|
||||
export async function getLoginQrcode(loginUuid?: string): Promise<WxLoginQrcodeResult> {
|
||||
const res = await axiosInstance.get(omsUrl('/wechat/mp/login-qrcode'), {
|
||||
params: { loginUuid },
|
||||
params: loginUuid ? { loginUuid } : undefined,
|
||||
});
|
||||
return unwrapOrSelf<WxLoginQrcodeResult>(res.data);
|
||||
}
|
||||
|
||||
/** 查询扫码 key 是否已关联账号(轮询用,无值时不抛错) */
|
||||
export async function getWxLoginStatus(loginUuid: string): Promise<WxLoginStatusResult> {
|
||||
const res = await axiosInstance.get('/rbac/auth/wx-login/status', {
|
||||
params: { loginUuid },
|
||||
});
|
||||
return unwrapOrSelf<WxLoginStatusResult>(res.data);
|
||||
}
|
||||
|
||||
/** 是否属于「等待扫码」类错误(轮询时应静默继续) */
|
||||
export function isWxLoginPendingError(error: unknown): boolean {
|
||||
const axiosErr = error as AxiosError<{ message?: string; Message?: string }>;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -72,6 +72,16 @@ const XHS: AppRouteRecordRaw = {
|
||||
permission: 'Xhs.HumanSettings.Save',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'agentPair',
|
||||
name: 'XhsAgentPair',
|
||||
component: () => import('@/views/xhs/agentPair/index.vue'),
|
||||
meta: {
|
||||
locale: 'menu.xhs.agentPair',
|
||||
requiresAuth: true,
|
||||
hideInMenu: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
50
src/utils/adminApi.ts
Normal file
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(
|
||||
(config: AxiosRequestConfig) => {
|
||||
config.headers = config.headers ?? {};
|
||||
// 生产:/api、/Track 走同源,由 nginx 代理到 api.nanafa.com,避免跨域 405;rbac 直连 admin
|
||||
// 生产:/api、/Track、/rbac 等走同源,由 nginx 代理到 api.nanafa.com
|
||||
// 开发:Vite 代理 /api、/Track、/rbac
|
||||
// 从 localStorage 获取 token
|
||||
const token = localStorage.getItem('token');
|
||||
|
||||
21
src/utils/wechatOAuth.ts
Normal file
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 { IconUser } from '@arco-design/web-vue/es/icon';
|
||||
import { OmsLoginType } from '@/api/rbac';
|
||||
import { getLoginQrcode, isWxLoginPendingError, wxLogin } from '@/api/wechat';
|
||||
import { getLoginQrcode, getWxLoginStatus, isWxLoginPendingError, wxLogin } from '@/api/wechat';
|
||||
import { useRbacUserStore } from '@/store/modules/rbac-user';
|
||||
import { useUserStore } from '@/store';
|
||||
import { setToken } from '@/utils/auth';
|
||||
@ -76,9 +76,9 @@ let expireTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
function createLoginUuid(): string {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
return crypto.randomUUID().replace(/-/g, '');
|
||||
}
|
||||
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
return `${Date.now()}${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
function clearTimers() {
|
||||
@ -144,6 +144,9 @@ async function pollWxLogin() {
|
||||
if (!loginUuid.value) return;
|
||||
|
||||
try {
|
||||
const status = await getWxLoginStatus(loginUuid.value);
|
||||
if (!status?.ready) return;
|
||||
|
||||
const result = await wxLogin(loginUuid.value);
|
||||
if (!result?.accessToken) return;
|
||||
|
||||
@ -178,6 +181,7 @@ async function loadQrCode() {
|
||||
Message.error(t('login.wechat.loadFailed'));
|
||||
return;
|
||||
}
|
||||
loginUuid.value = result.loginKey || loginUuid.value;
|
||||
qrCodeUrl.value = result.qrCodeUrl;
|
||||
startPolling();
|
||||
} catch {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
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
|
||||
ref="panelRef"
|
||||
:tool="activeTool"
|
||||
:running="running"
|
||||
:running="running || browseLoading"
|
||||
:browse-loading="browseLoading"
|
||||
:feed-rows="currentBrowseState.feeds"
|
||||
:selected-feed="currentBrowseState.selectedFeed"
|
||||
@ -114,6 +114,11 @@ const browseStates = reactive<Record<string, BrowseToolState>>({
|
||||
});
|
||||
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let pollStartedAt = 0;
|
||||
const POLL_INTERVAL_MS = 5000;
|
||||
const POLL_TIMEOUT_MS = 20 * 60 * 1000;
|
||||
|
||||
const onlineAgents = computed(() => agents.value.filter((a) => a.status === 'online'));
|
||||
|
||||
const toolTreeData = computed<CustomLeftMenuTreeNode[]>(() =>
|
||||
buildAnalysisToolTree(menuSearchKeyword.value),
|
||||
@ -141,6 +146,17 @@ function parseFeedRows(feeds: unknown): XhsFeedRow[] {
|
||||
if (!feeds || typeof feeds !== 'object') return [];
|
||||
const root = feeds as Record<string, unknown>;
|
||||
if (root.error) return [];
|
||||
|
||||
if (typeof root.raw === 'string' && root.raw.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(root.raw);
|
||||
const fromRaw = parseFeedRows(parsed);
|
||||
if (fromRaw.length) return fromRaw;
|
||||
} catch {
|
||||
// ignore invalid raw json
|
||||
}
|
||||
}
|
||||
|
||||
const list = Array.isArray(feeds)
|
||||
? feeds
|
||||
: (root.feeds ?? root.items ?? root.notes);
|
||||
@ -159,6 +175,33 @@ function parseFeedRows(feeds: unknown): XhsFeedRow[] {
|
||||
});
|
||||
}
|
||||
|
||||
function resolveAgentIdForTask(): number | undefined {
|
||||
const id = interactAgentId.value;
|
||||
if (!id) return undefined;
|
||||
const agent = agents.value.find((a) => a.id === id);
|
||||
if (!agent || agent.status !== 'online') {
|
||||
return undefined;
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
function formatTaskStatus(status?: string) {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return '等待 Agent 领取任务…';
|
||||
case 'claimed':
|
||||
return 'Agent 已领取,准备执行…';
|
||||
case 'running':
|
||||
return 'Agent 采集中(首页推荐约需 2–5 分钟,请保持引擎运行)…';
|
||||
case 'succeeded':
|
||||
return '采集完成';
|
||||
case 'failed':
|
||||
return '任务失败';
|
||||
default:
|
||||
return status ? `任务状态:${status}` : '等待 Agent…';
|
||||
}
|
||||
}
|
||||
|
||||
async function onToolRun(payload: Record<string, unknown>) {
|
||||
const tool = activeTool.value;
|
||||
if (!tool) return;
|
||||
@ -186,24 +229,37 @@ async function runBrowseTool(tool: XhsAnalysisToolDef, payload: Record<string, u
|
||||
browseLoading.value = true;
|
||||
respStatus.value = '任务提交中…';
|
||||
|
||||
const agentId = resolveAgentIdForTask();
|
||||
if (interactAgentId.value && !agentId) {
|
||||
Message.warning('所选 Agent 离线,将交由任意在线 Agent 执行');
|
||||
}
|
||||
if (!agentId && onlineAgents.value.length === 0) {
|
||||
Message.warning('没有在线 Agent,请先在「启动服务」连接桌面端并启动引擎');
|
||||
running.value = false;
|
||||
browseLoading.value = false;
|
||||
respStatus.value = '无在线 Agent';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await startXhsAnalysis({
|
||||
analysisType: tool.analysisType!,
|
||||
keyword: String(payload.keyword ?? '').trim() || undefined,
|
||||
filters: payload.filters,
|
||||
agentId: interactAgentId.value,
|
||||
agentId,
|
||||
});
|
||||
const state = browseStates[tool.name];
|
||||
state.sessionId = result.sessionId;
|
||||
state.feeds = [];
|
||||
state.selectedFeed = null;
|
||||
state.taskStatus = 'pending';
|
||||
respStatus.value = `已提交任务 #${result.taskId},等待 Agent 采集…`;
|
||||
respStatus.value = `已提交任务 #${result.taskId},${formatTaskStatus('pending')}`;
|
||||
responseText.value = '';
|
||||
setupPoll(tool.name);
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || '提交失败');
|
||||
respStatus.value = '提交失败';
|
||||
browseLoading.value = false;
|
||||
} finally {
|
||||
running.value = false;
|
||||
}
|
||||
@ -223,7 +279,7 @@ async function runAnalyzeTool(payload: Record<string, unknown>) {
|
||||
const result = await startXhsAnalysis({
|
||||
analysisType: 'feed_detail',
|
||||
keyword: feedId,
|
||||
agentId: interactAgentId.value,
|
||||
agentId: resolveAgentIdForTask(),
|
||||
filters: {
|
||||
xsec_token: payload.xsec_token,
|
||||
load_all_comments: payload.load_all_comments,
|
||||
@ -299,11 +355,22 @@ async function sendInteract(
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSessionDetail(toolName: string) {
|
||||
async function loadSessionDetail(toolName: string, options?: { silent?: boolean }) {
|
||||
const state = browseStates[toolName];
|
||||
if (!state?.sessionId) return;
|
||||
|
||||
browseLoading.value = true;
|
||||
if (pollStartedAt && Date.now() - pollStartedAt > POLL_TIMEOUT_MS) {
|
||||
respStatus.value = '采集超时,请确认 Agent 在线且引擎已启动后重试';
|
||||
browseLoading.value = false;
|
||||
running.value = false;
|
||||
stopPoll();
|
||||
Message.warning('采集超时');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!options?.silent) {
|
||||
browseLoading.value = true;
|
||||
}
|
||||
try {
|
||||
const detail = await getXhsAnalysisDetail(state.sessionId);
|
||||
state.taskStatus = detail.taskStatus;
|
||||
@ -327,21 +394,28 @@ async function loadSessionDetail(toolName: string) {
|
||||
stopPoll();
|
||||
} else if (detail.taskStatus === 'failed') {
|
||||
respStatus.value = '任务失败';
|
||||
if (detail.feeds && typeof detail.feeds === 'object' && (detail.feeds as any).error) {
|
||||
responseText.value = String((detail.feeds as any).error);
|
||||
}
|
||||
stopPoll();
|
||||
} else {
|
||||
respStatus.value = `任务状态:${detail.taskStatus ?? '执行中'}`;
|
||||
respStatus.value = formatTaskStatus(detail.taskStatus);
|
||||
}
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || '加载详情失败');
|
||||
} finally {
|
||||
browseLoading.value = false;
|
||||
if (!pollTimer) {
|
||||
browseLoading.value = false;
|
||||
}
|
||||
running.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function setupPoll(toolName: string) {
|
||||
stopPoll();
|
||||
pollTimer = setInterval(() => loadSessionDetail(toolName), 5000);
|
||||
pollStartedAt = Date.now();
|
||||
browseLoading.value = true;
|
||||
pollTimer = setInterval(() => loadSessionDetail(toolName, { silent: true }), POLL_INTERVAL_MS);
|
||||
loadSessionDetail(toolName);
|
||||
}
|
||||
|
||||
@ -350,6 +424,8 @@ function stopPoll() {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
pollStartedAt = 0;
|
||||
browseLoading.value = false;
|
||||
}
|
||||
|
||||
function onSelectFeed(feed: XhsFeedRow) {
|
||||
@ -426,7 +502,7 @@ onMounted(async () => {
|
||||
await Promise.all([
|
||||
listXhsAgents(1, 100).then((r) => {
|
||||
agents.value = r.items ?? [];
|
||||
const online = agents.value.find((a) => a.status === 'online');
|
||||
const online = onlineAgents.value[0];
|
||||
if (online) interactAgentId.value = online.id;
|
||||
}),
|
||||
listXhsAiProviders().then((r) => {
|
||||
|
||||
@ -204,11 +204,11 @@
|
||||
:footer="false"
|
||||
>
|
||||
<a-steps :current="0" direction="vertical" size="small" style="margin-bottom: 20px">
|
||||
<a-step description="安装并运行 OMS Agent 桌面端(xhs-agent-desktop/run.ps1)">
|
||||
安装 Agent 桌面端
|
||||
<a-step description="运行 xhs-agent-desktop/run.ps1 打开桌面端">
|
||||
打开 Agent 桌面端
|
||||
</a-step>
|
||||
<a-step description="桌面端填入 API 地址与 Token,点击「连接 OMS」">
|
||||
连接 OMS
|
||||
<a-step description="桌面端点击「连接 OMS」;保持本页打开即可自动授权">
|
||||
一键连接
|
||||
</a-step>
|
||||
<a-step description="启动引擎,Chrome 加载内置扩展并登录小红书">
|
||||
本机引擎与扩展
|
||||
@ -218,22 +218,16 @@
|
||||
</a-step>
|
||||
</a-steps>
|
||||
|
||||
<a-form layout="vertical" size="small">
|
||||
<a-form-item label="OMS API 地址">
|
||||
<a-input :model-value="omsApiUrl" readonly>
|
||||
<template #append>
|
||||
<a-button @click="copyText(omsApiUrl)">复制</a-button>
|
||||
</template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label="JWT Token(当前登录)">
|
||||
<a-textarea :model-value="maskedToken" readonly :auto-size="{ minRows: 2, maxRows: 4 }" />
|
||||
<a-button style="margin-top: 8px" @click="copyText(accessToken)">复制 Token</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
<a-alert
|
||||
type="info"
|
||||
style="margin-bottom: 12px"
|
||||
title="无需填写 API 或 Token"
|
||||
>
|
||||
请先在浏览器登录 OMS,再打开桌面端点击「连接 OMS」。若本页已打开,授权会自动完成。
|
||||
</a-alert>
|
||||
|
||||
<div class="cmd-block">
|
||||
<div class="cmd-label">桌面端(推荐,已替代 LIKE AI)</div>
|
||||
<div class="cmd-label">桌面端</div>
|
||||
<pre>{{ desktopAgentHint }}</pre>
|
||||
</div>
|
||||
</a-drawer>
|
||||
@ -248,6 +242,10 @@ import {
|
||||
listXhsAgents,
|
||||
type XhsAgentItem,
|
||||
} from '@/api/xhs';
|
||||
import {
|
||||
isAgentWaitingPair,
|
||||
pushCredentialsToAgent,
|
||||
} from '@/utils/xhsAgentPair';
|
||||
import { useRbacUserStore } from '@/store/modules/rbac-user';
|
||||
import XhsStatusBadge, { type XhsBadgeKind } from '../_shared/XhsStatusBadge.vue';
|
||||
|
||||
@ -264,6 +262,8 @@ const pagination = reactive({
|
||||
});
|
||||
|
||||
let refreshTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let pairTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let pairPushed = false;
|
||||
|
||||
const selectedAgent = computed(() =>
|
||||
agents.value.find((a) => a.id === selectedAgentId.value),
|
||||
@ -315,34 +315,14 @@ const loginGuideText = computed(() => {
|
||||
return '只需:启动引擎 → 在 Chrome 加载扩展 → 打开小红书并登录。扩展图标可查看 Bridge 与登录状态;下方按钮用于快捷操作。';
|
||||
});
|
||||
|
||||
const omsApiUrl = computed(() => {
|
||||
if (import.meta.env.DEV) {
|
||||
return `${window.location.origin}/admin`;
|
||||
}
|
||||
return (import.meta.env.VITE_API_ADMIN_URL || 'http://api.nanafa.com/admin').replace(/\/$/, '');
|
||||
});
|
||||
|
||||
const accessToken = computed(
|
||||
() => rbacStore.accessToken || localStorage.getItem('rbac_access_token') || '',
|
||||
);
|
||||
|
||||
const maskedToken = computed(() => {
|
||||
const t = accessToken.value;
|
||||
if (!t) return '未登录';
|
||||
if (t.length <= 16) return '***';
|
||||
return `${t.slice(0, 8)}...${t.slice(-8)}`;
|
||||
});
|
||||
|
||||
const desktopAgentHint = computed(() => {
|
||||
const url = omsApiUrl.value;
|
||||
return [
|
||||
const desktopAgentHint = computed(() =>
|
||||
[
|
||||
'1. 运行 oms0414/xhs-agent-desktop/run.ps1',
|
||||
'2. 填入 API 地址与 JWT Token(下方可复制)',
|
||||
` API: ${url}`,
|
||||
'3. 连接 OMS → 启动引擎 → Chrome 加载扩展目录',
|
||||
'4. 无需单独安装 LIKE AI',
|
||||
].join('\n');
|
||||
});
|
||||
'2. 桌面端点击「连接 OMS」(无需填 API / Token)',
|
||||
'3. 保持本页打开 → 自动授权;或按提示打开授权页',
|
||||
'4. 启动引擎 → Chrome 加载扩展目录 → 登录小红书',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
function formatTime(value?: string) {
|
||||
if (!value) return '-';
|
||||
@ -370,16 +350,20 @@ function pickDefaultAgent() {
|
||||
|
||||
watch(agents, pickDefaultAgent);
|
||||
|
||||
async function copyText(text: string) {
|
||||
if (!text) {
|
||||
Message.warning('无内容可复制');
|
||||
async function tryAutoPair() {
|
||||
const waiting = await isAgentWaitingPair();
|
||||
if (!waiting) {
|
||||
pairPushed = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
Message.success('已复制');
|
||||
} catch {
|
||||
Message.error('复制失败');
|
||||
if (pairPushed) return;
|
||||
const token = rbacStore.accessToken || localStorage.getItem('rbac_access_token') || '';
|
||||
if (!token) return;
|
||||
const ok = await pushCredentialsToAgent(token);
|
||||
if (ok) {
|
||||
pairPushed = true;
|
||||
Message.success('已向桌面 Agent 发送授权');
|
||||
setTimeout(loadAgents, 1500);
|
||||
}
|
||||
}
|
||||
|
||||
@ -436,10 +420,13 @@ async function sendControl(agentId: number, action: string) {
|
||||
onMounted(() => {
|
||||
loadAgents();
|
||||
refreshTimer = setInterval(loadAgents, 15000);
|
||||
tryAutoPair();
|
||||
pairTimer = setInterval(tryAutoPair, 2000);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (refreshTimer) clearInterval(refreshTimer);
|
||||
if (pairTimer) clearInterval(pairTimer);
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@ -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;
|
||||
});
|
||||
|
||||
@ -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` 对应的后端地址
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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]:
|
||||
|
||||
@ -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"):
|
||||
|
||||
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
|
||||
|
||||
from app.engine_path import ensure_runtime_importable
|
||||
|
||||
ensure_runtime_importable()
|
||||
|
||||
from app.main_window import run_app
|
||||
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user