547 lines
16 KiB
Vue
547 lines
16 KiB
Vue
<template>
|
||
<div class="xhs-start-service-container">
|
||
<div class="xhs-page-card start-service-card">
|
||
<div class="table-toolbar">
|
||
<div class="toolbar-left">
|
||
<span class="xhs-panel-title">启动服务</span>
|
||
<span class="xhs-panel-desc">
|
||
本机 Agent + 浏览器引擎:找对标、写稿、发笔记、互动留痕
|
||
</span>
|
||
</div>
|
||
<div class="toolbar-right">
|
||
<a-select
|
||
v-if="agents.length > 0"
|
||
v-model="selectedAgentId"
|
||
placeholder="选择 Agent"
|
||
:style="{ width: '200px' }"
|
||
>
|
||
<a-option
|
||
v-for="a in agents"
|
||
:key="a.id"
|
||
:value="a.id"
|
||
:label="a.deviceName || a.deviceId"
|
||
/>
|
||
</a-select>
|
||
<a-button @click="showSetupDrawer = true">Agent 连接指南</a-button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="start-service-body">
|
||
<a-alert
|
||
v-if="offlineCount > 0 && agents.length > 0"
|
||
type="warning"
|
||
style="margin-bottom: 12px"
|
||
:title="`${offlineCount} 个 Agent 离线,请检查本机 Agent 是否在运行`"
|
||
closable
|
||
/>
|
||
|
||
<a-alert
|
||
v-if="!loading && agents.length === 0"
|
||
type="info"
|
||
style="margin-bottom: 12px"
|
||
title="尚未连接 Agent,请运行 xhs-agent-desktop 并连接 OMS"
|
||
closable
|
||
/>
|
||
|
||
<div class="service-cards-row">
|
||
<div class="service-card xhs-inner-card">
|
||
<div class="card-header">
|
||
<span class="card-title">小红书 · 浏览器引擎</span>
|
||
<XhsStatusBadge :kind="engineBadge.kind" :text="engineBadge.text" />
|
||
</div>
|
||
<p class="card-hint">{{ bridgeUrlText }}</p>
|
||
<p class="card-hint card-note">
|
||
内嵌 xiaohongshu-skills:Bridge Server + Chrome 扩展,在本机真实浏览器中操作小红书。
|
||
</p>
|
||
<div class="card-btn-row">
|
||
<a-button type="primary" :disabled="!canControl" @click="control('engine_start')">
|
||
启动引擎
|
||
</a-button>
|
||
<a-button :disabled="!canControl" @click="control('engine_stop')">停止引擎</a-button>
|
||
<a-button @click="loadAgents">刷新状态</a-button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="service-card xhs-inner-card">
|
||
<div class="card-header">
|
||
<span class="card-title">小红书账号(可选)</span>
|
||
<XhsStatusBadge :kind="loginBadge.kind" :text="loginBadge.text" />
|
||
</div>
|
||
<p class="card-hint card-note">{{ loginGuideText }}</p>
|
||
<div class="card-btn-row">
|
||
<a-button :disabled="!canControl" @click="control('clear_cookies')">退出登录</a-button>
|
||
<a-button type="primary" :disabled="!canControl" @click="control('browser_login')">
|
||
浏览器登录
|
||
</a-button>
|
||
<a-button :disabled="!canControl" @click="control('check_login')">登录校验</a-button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="service-card xhs-inner-card coming-soon">
|
||
<div class="card-header">
|
||
<span class="card-title">抖音</span>
|
||
<XhsStatusBadge kind="idle" text="即将支持" />
|
||
</div>
|
||
<p class="card-hint card-note">
|
||
抖音 / Chrome 扩展与自动化引擎正在接入中。上线后可与本站一样使用分析、评论与创作能力。
|
||
</p>
|
||
<div class="card-btn-row">
|
||
<a-button disabled>敬请期待</a-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="agents.length > 0" class="agent-table-section">
|
||
<div class="table-toolbar">
|
||
<div class="toolbar-left">
|
||
<span class="section-title">已连接设备</span>
|
||
<a-button @click="loadAgents">
|
||
<template #icon><icon-refresh /></template>
|
||
刷新
|
||
</a-button>
|
||
</div>
|
||
<div class="toolbar-right">
|
||
<a-pagination
|
||
v-model:current="pagination.current"
|
||
v-model:page-size="pagination.pageSize"
|
||
:total="pagination.total"
|
||
:page-size-options="['10', '30', '50', '100']"
|
||
show-total
|
||
show-page-size
|
||
size="small"
|
||
@change="onPageChange"
|
||
@page-size-change="onPageSizeChange"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<a-table
|
||
:loading="loading"
|
||
:data="agents"
|
||
:pagination="false"
|
||
row-key="id"
|
||
size="small"
|
||
:row-class="agentRowClass"
|
||
@row-click="onAgentRowClick"
|
||
>
|
||
<template #columns>
|
||
<a-table-column title="设备名称" data-index="deviceName">
|
||
<template #cell="{ record }">
|
||
{{ record.deviceName || record.deviceId }}
|
||
</template>
|
||
</a-table-column>
|
||
<a-table-column title="状态" :width="90">
|
||
<template #cell="{ record }">
|
||
<XhsStatusBadge
|
||
:kind="record.status === 'online' ? 'ok' : 'idle'"
|
||
:text="record.status === 'online' ? '在线' : '离线'"
|
||
/>
|
||
</template>
|
||
</a-table-column>
|
||
<a-table-column title="引擎" :width="90">
|
||
<template #cell="{ record }">
|
||
<XhsStatusBadge
|
||
:kind="record.engineRunning ? 'ok' : 'err'"
|
||
:text="record.engineRunning ? '运行中' : '未启动'"
|
||
/>
|
||
</template>
|
||
</a-table-column>
|
||
<a-table-column title="扩展" :width="90">
|
||
<template #cell="{ record }">
|
||
<XhsStatusBadge
|
||
:kind="record.extensionConnected ? 'ok' : 'warn'"
|
||
:text="record.extensionConnected ? '已连接' : '未连接'"
|
||
/>
|
||
</template>
|
||
</a-table-column>
|
||
<a-table-column title="小红书" :width="90">
|
||
<template #cell="{ record }">
|
||
<XhsStatusBadge
|
||
:kind="record.xhsLoggedIn ? 'ok' : 'warn'"
|
||
:text="record.xhsLoggedIn ? '已登录' : '未登录'"
|
||
/>
|
||
</template>
|
||
</a-table-column>
|
||
<a-table-column title="版本" data-index="agentVersion" :width="100" />
|
||
<a-table-column title="最后心跳" :width="170">
|
||
<template #cell="{ record }">
|
||
{{ formatTime(record.lastHeartbeatTime) }}
|
||
</template>
|
||
</a-table-column>
|
||
</template>
|
||
</a-table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<a-drawer
|
||
v-model:visible="showSetupDrawer"
|
||
title="Agent 连接指南"
|
||
:width="480"
|
||
:footer="false"
|
||
>
|
||
<a-steps :current="0" direction="vertical" size="small" style="margin-bottom: 20px">
|
||
<a-step description="运行 xhs-agent-desktop/run.ps1 打开桌面端">
|
||
打开 Agent 桌面端
|
||
</a-step>
|
||
<a-step description="桌面端点击「连接 OMS」;保持本页打开即可自动授权">
|
||
一键连接
|
||
</a-step>
|
||
<a-step description="启动引擎,Chrome 加载内置扩展并登录小红书">
|
||
本机引擎与扩展
|
||
</a-step>
|
||
<a-step description="在此页远程启停引擎、扫码登录小红书">
|
||
Web 远程控制
|
||
</a-step>
|
||
</a-steps>
|
||
|
||
<a-alert type="info" style="margin-bottom: 12px" title="无需填写 API / Token">
|
||
请先在浏览器登录 OMS,再打开桌面端点击「连接 OMS」。若本页已打开,授权会自动完成。
|
||
</a-alert>
|
||
|
||
<div class="cmd-block">
|
||
<div class="cmd-label">桌面端</div>
|
||
<pre>{{ desktopAgentHint }}</pre>
|
||
</div>
|
||
</a-drawer>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { Message, Modal } from '@arco-design/web-vue';
|
||
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue';
|
||
import {
|
||
controlXhsAgent,
|
||
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';
|
||
|
||
const rbacStore = useRbacUserStore();
|
||
const loading = ref(false);
|
||
const agents = ref<XhsAgentItem[]>([]);
|
||
const selectedAgentId = ref<number | undefined>();
|
||
const showSetupDrawer = ref(false);
|
||
const pagination = reactive({
|
||
current: 1,
|
||
pageSize: 50,
|
||
total: 0,
|
||
});
|
||
|
||
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),
|
||
);
|
||
|
||
const canControl = computed(
|
||
() => !!selectedAgent.value && selectedAgent.value.status === 'online',
|
||
);
|
||
|
||
const offlineCount = computed(
|
||
() => agents.value.filter((a) => a.status !== 'online').length,
|
||
);
|
||
|
||
const bridgeUrlText = computed(() => {
|
||
const agent = selectedAgent.value;
|
||
if (!agent) return 'Bridge: 请先连接 Agent';
|
||
const port = agent.bridgePort || 9333;
|
||
return `Bridge: ws://127.0.0.1:${port}`;
|
||
});
|
||
|
||
const engineBadge = computed((): { kind: XhsBadgeKind; text: string } => {
|
||
const agent = selectedAgent.value;
|
||
if (!agent || agent.status !== 'online') return { kind: 'idle', text: '待刷新' };
|
||
if (agent.engineRunning) return { kind: 'ok', text: '运行中' };
|
||
if (agent.extensionConnected) return { kind: 'warn', text: '待扩展' };
|
||
return { kind: 'err', text: '未启动' };
|
||
});
|
||
|
||
const loginBadge = computed((): { kind: XhsBadgeKind; text: string } => {
|
||
const agent = selectedAgent.value;
|
||
if (!agent || agent.status !== 'online') return { kind: 'idle', text: '待刷新' };
|
||
if (agent.xhsLoggedIn) return { kind: 'ok', text: '已登录' };
|
||
if (agent.engineRunning && agent.extensionConnected) return { kind: 'ok', text: '扩展就绪' };
|
||
if (agent.engineRunning) return { kind: 'warn', text: '未登录' };
|
||
return { kind: 'warn', text: '待启动' };
|
||
});
|
||
|
||
const loginGuideText = computed(() => {
|
||
const agent = selectedAgent.value;
|
||
if (!agent) {
|
||
return '只需:连接 Agent → 启动引擎 → 在 Chrome 加载扩展 → 打开小红书并登录。';
|
||
}
|
||
if (agent.xhsLoggedIn) {
|
||
return '校验通过:可在「内容分析」或「爆款创作」页使用工具。';
|
||
}
|
||
if (agent.engineRunning && agent.extensionConnected) {
|
||
return '引擎与扩展已就绪。请在 Chrome 中打开小红书并登录;可点击扩展图标查看实时状态。';
|
||
}
|
||
return '只需:启动引擎 → 在 Chrome 加载扩展 → 打开小红书并登录。扩展图标可查看 Bridge 与登录状态;下方按钮用于快捷操作。';
|
||
});
|
||
|
||
const desktopAgentHint = computed(() =>
|
||
[
|
||
'1. 运行 oms0414/xhs-agent-desktop/run.ps1',
|
||
'2. 桌面端点击「连接 OMS」(无需填 API / Token)',
|
||
'3. 保持本页打开 → 自动授权;或按提示打开授权页',
|
||
'4. 启动引擎 → Chrome 加载扩展目录 → 登录小红书',
|
||
].join('\n'),
|
||
);
|
||
|
||
function formatTime(value?: string) {
|
||
if (!value) return '-';
|
||
return value.replace('T', ' ').slice(0, 19);
|
||
}
|
||
|
||
function agentRowClass(record: XhsAgentItem) {
|
||
return record.id === selectedAgentId.value ? 'agent-row-selected' : '';
|
||
}
|
||
|
||
function onAgentRowClick(record: XhsAgentItem) {
|
||
selectedAgentId.value = record.id;
|
||
}
|
||
|
||
function pickDefaultAgent() {
|
||
if (agents.value.length === 0) {
|
||
selectedAgentId.value = undefined;
|
||
return;
|
||
}
|
||
const current = agents.value.find((a) => a.id === selectedAgentId.value);
|
||
if (current) return;
|
||
const online = agents.value.find((a) => a.status === 'online');
|
||
selectedAgentId.value = (online ?? agents.value[0]).id;
|
||
}
|
||
|
||
watch(agents, pickDefaultAgent);
|
||
|
||
async function tryAutoPair() {
|
||
const waiting = await isAgentWaitingPair();
|
||
if (!waiting) {
|
||
pairPushed = false;
|
||
return;
|
||
}
|
||
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);
|
||
}
|
||
}
|
||
|
||
async function loadAgents() {
|
||
loading.value = true;
|
||
try {
|
||
const res = await listXhsAgents(pagination.current, pagination.pageSize);
|
||
agents.value = res.items ?? [];
|
||
pagination.total = res.total ?? 0;
|
||
pickDefaultAgent();
|
||
} catch (e: any) {
|
||
Message.error(e?.message || '加载失败');
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
function onPageChange(page: number) {
|
||
pagination.current = page;
|
||
loadAgents();
|
||
}
|
||
|
||
function onPageSizeChange(size: number) {
|
||
pagination.pageSize = size;
|
||
pagination.current = 1;
|
||
loadAgents();
|
||
}
|
||
|
||
async function control(action: string) {
|
||
const agent = selectedAgent.value;
|
||
if (!agent) {
|
||
Message.warning('请先连接 Agent');
|
||
return;
|
||
}
|
||
if (agent.status !== 'online') {
|
||
Message.warning('所有 Agent 离线,请检查本机是否在运行');
|
||
return;
|
||
}
|
||
if (action === 'clear_cookies') {
|
||
Modal.confirm({
|
||
title: '退出登录',
|
||
content: '将在浏览器中退出小红书登录状态,确定继续?',
|
||
onOk: () => sendControl(agent.id, action),
|
||
});
|
||
return;
|
||
}
|
||
await sendControl(agent.id, action);
|
||
}
|
||
|
||
async function sendControl(agentId: number, action: string) {
|
||
try {
|
||
const res = await controlXhsAgent(agentId, action);
|
||
Message.success(`已创建任务 #${res.taskId}`);
|
||
setTimeout(loadAgents, 2000);
|
||
} catch (e: any) {
|
||
Message.error(e?.message || '操作失败');
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
loadAgents();
|
||
refreshTimer = setInterval(loadAgents, 15000);
|
||
tryAutoPair();
|
||
pairTimer = setInterval(tryAutoPair, 2000);
|
||
});
|
||
|
||
onUnmounted(() => {
|
||
if (refreshTimer) clearInterval(refreshTimer);
|
||
if (pairTimer) clearInterval(pairTimer);
|
||
});
|
||
</script>
|
||
|
||
<style scoped lang="less">
|
||
@import '../_shared/xhs-page.less';
|
||
|
||
.xhs-start-service-container {
|
||
box-sizing: border-box;
|
||
display: flex;
|
||
flex-direction: column;
|
||
flex: 1;
|
||
min-height: 0;
|
||
height: 100%;
|
||
padding: 16px;
|
||
background: var(--app-page-bg);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.start-service-card {
|
||
padding: 16px;
|
||
}
|
||
|
||
.start-service-body {
|
||
flex: 1;
|
||
min-height: 0;
|
||
overflow: auto;
|
||
}
|
||
|
||
.toolbar-left {
|
||
flex-direction: column;
|
||
align-items: flex-start !important;
|
||
gap: 2px !important;
|
||
}
|
||
|
||
.service-cards-row {
|
||
display: flex;
|
||
gap: 12px;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.service-card {
|
||
flex: 1;
|
||
min-width: 0;
|
||
min-height: 180px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
padding: 12px 14px;
|
||
|
||
&.coming-soon {
|
||
background: var(--color-fill-1);
|
||
border-style: dashed;
|
||
|
||
.card-title,
|
||
.card-hint {
|
||
color: var(--color-text-3);
|
||
}
|
||
}
|
||
}
|
||
|
||
.card-header {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
gap: 8px;
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.card-title {
|
||
color: var(--color-text-1);
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.card-hint {
|
||
margin: 0;
|
||
color: var(--color-text-3);
|
||
font-size: 12px;
|
||
line-height: 1.55;
|
||
}
|
||
|
||
.card-note {
|
||
flex: 1;
|
||
overflow: hidden;
|
||
display: -webkit-box;
|
||
-webkit-line-clamp: 3;
|
||
-webkit-box-orient: vertical;
|
||
}
|
||
|
||
.card-btn-row {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
margin-top: auto;
|
||
padding-top: 8px;
|
||
}
|
||
|
||
.agent-table-section {
|
||
border: 1px solid var(--color-border-2);
|
||
border-radius: 8px;
|
||
padding: 12px 16px;
|
||
}
|
||
|
||
.section-title {
|
||
color: var(--color-text-1);
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
:deep(.agent-row-selected) {
|
||
.arco-table-td {
|
||
background: color-mix(in srgb, var(--app-primary-color) 8%, var(--app-card-bg)) !important;
|
||
}
|
||
}
|
||
|
||
.cmd-block {
|
||
margin-top: 12px;
|
||
padding: 12px;
|
||
background: var(--color-fill-1);
|
||
border-radius: 4px;
|
||
|
||
.cmd-label {
|
||
font-size: 12px;
|
||
color: var(--color-text-3);
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
pre {
|
||
margin: 0;
|
||
white-space: pre-wrap;
|
||
word-break: break-all;
|
||
font-size: 12px;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 1200px) {
|
||
.service-cards-row {
|
||
flex-direction: column;
|
||
}
|
||
}
|
||
</style>
|