[HTML] 纯文本查看 复制代码
<!doctype html><html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>采集管理台</title>
<link rel="stylesheet" href="https://unpkg.com/element-plus/dist/index.css" />
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<div id="app" v-cloak>
<el-container class="app-shell">
<el-header class="topbar">
<div>
<h1>采集管理台</h1>
<p>配置群聊和匹配词,启动机器人后扫码登录。</p>
</div>
<el-tag :type="statusTagType" size="large">{{ statusText }}</el-tag>
</el-header>
<el-main class="main-grid">
<section class="panel hero-panel">
<div class="hero-copy">
<span class="eyebrow">机器人控制</span>
<h2>{{ status.loginUserName || '等待启动' }}</h2>
<p>已处理 {{ status.totalProcessedCount || 0 }} 条,待写入 {{ status.pendingCount || 0 }} 条。</p>
</div>
<div class="actions">
<el-button type="primary" size="large" :loading="starting" @click="startBot">
启动并获取二维码
</el-button>
<el-button size="large" :loading="stopping" @click="stopBot">停止</el-button>
</div>
</section>
<section class="panel config-panel">
<div class="section-title">
<div>
<h2>匹配配置</h2>
<p>群聊名称需与微信里的群名一致,匹配词命中后会提取消息里的单号。</p>
</div>
<el-button type="primary" :loading="saving" @click="saveConfig">保存配置</el-button>
</div>
<div class="form-grid">
<div class="field-block">
<label>群聊名称</label>
<div class="inline-editor">
<el-input v-model="newRoom" placeholder="输入群聊名称" @keyup.enter="addRoom" />
<el-button @click="addRoom">添加</el-button>
</div>
<div class="tag-list">
<el-tag
v-for="room in config.rooms"
:key="room"
closable
effect="plain"
@close="removeRoom(room)"
>
{{ room }}
</el-tag>
</div>
</div>
<div class="field-block">
<label>匹配词</label>
<div class="inline-editor">
<el-input v-model="newWord" placeholder="例如:退货、拦截" @keyup.enter="addWord" />
<el-button @click="addWord">添加</el-button>
</div>
<div class="tag-list">
<el-tag
v-for="word in config.matchWords"
:key="word"
closable
type="success"
effect="plain"
@close="removeWord(word)"
>
{{ word }}
</el-tag>
</div>
</div>
</div>
<div class="field-block excel-mode">
<label>Excel 生成方式</label>
<el-radio-group v-model="config.excelMode">
<el-radio-button label="single">单个 Excel,保留群聊名称列</el-radio-button>
<el-radio-button label="perRoom">每个群聊一个 Excel</el-radio-button>
</el-radio-group>
</div>
<div class="matching-grid">
<div class="field-block">
<label>单号正则</label>
<el-input v-model="config.matching.numberPattern" />
</div>
<div class="field-block">
<label>上下文消息条数</label>
<el-input-number v-model="config.matching.contextMessageWindow" :min="0" :max="20" />
</div>
<div class="field-block">
<label>上下文有效分钟数</label>
<el-input-number v-model="config.matching.contextExpireMinutes" :min="1" :max="120" />
</div>
<div class="field-block switch-row">
<el-switch
v-model="config.matching.contextSameSenderOnly"
active-text="仅匹配同一发送人"
/>
<el-switch
v-model="config.matching.allowDefaultAction"
active-text="无动作默认退货"
/>
</div>
</div>
</section>
</el-main>
</el-container>
<el-dialog v-model="qrcodeVisible" title="微信扫码登录" width="360px" align-center>
<div class="qrcode-box" v-loading="!qrcode.qrcode">
<img v-if="qrcode.qrcode" :src="qrcodeImageSrc" alt="微信登录二维码" />
<p v-else>正在等待 Wechaty 返回二维码...</p>
</div>
</el-dialog>
</div>
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<script src="https://unpkg.com/element-plus/dist/index.full.min.js"></script>
<script>
const { createApp } = Vue;
createApp({
data() {
return {
// 后端 /api/status 返回的机器人运行状态,用于顶部状态和统计数字。
status: { state: 'stopped', totalProcessedCount: 0, pendingCount: 0 },
// 后端 /api/config 返回的业务配置,对应页面上的群聊、匹配词、Excel 和匹配策略控件。
config: {
// 需要监听的微信群名称列表。
rooms: [],
// 动作词列表,例如退货、拦截、拒收、召回。
matchWords: [],
// Excel 输出方式:single 单文件,perRoom 按群拆分。
excelMode: 'single',
// 匹配策略配置,和 src/config-store.js 的 DEFAULT_MATCHING_OPTIONS 保持一致。
matching: {
// 从聊天文本中提取单号的正则字符串。
numberPattern: '[A-Z]{1,8}[0-9][A-Z0-9-]{5,}|[0-9A-Z-]{8,}',
// 动作词和单号分开发送时,向前参考的消息条数。
contextMessageWindow: 1,
// 上下文消息在多少分钟内有效。
contextExpireMinutes: 5,
// 是否要求上下文消息必须来自同一个发送人。
contextSameSenderOnly: false,
// 是否允许没有动作词时默认按退货处理。
allowDefaultAction: false
}
},
// 最近一次二维码接口返回值,包含原始 qrcode 和状态。
qrcode: {},
// 二维码图片缓存刷新 token,变化后 img 会重新请求 /api/qrcode.png。
qrcodeImageToken: Date.now(),
// 控制二维码弹窗显隐。
qrcodeVisible: false,
// 启动按钮 loading 状态。
starting: false,
// 停止按钮 loading 状态。
stopping: false,
// 保存配置按钮 loading 状态。
saving: false,
// 群聊输入框的临时值。
newRoom: '',
// 匹配词输入框的临时值。
newWord: '',
// 页面轮询状态和二维码的定时器句柄,组件卸载时需要清理。
pollTimer: null
};
},
computed: {
// 将后端状态码转换成页面上可读的中文状态。
statusText() {
const map = {
running: '运行中',
starting: '启动中',
stopping: '停止中',
stopped: '已停止'
};
return map[this.status.state] || this.status.state || '未知';
},
// 根据运行状态切换标签颜色,让当前状态更容易扫一眼看懂。
statusTagType() {
if (this.status.state === 'running') return 'success';
if (this.status.state === 'starting') return 'warning';
if (this.status.state === 'stopping') return 'warning';
return 'info';
},
// 二维码图片接口加时间戳,避免浏览器缓存旧二维码。
qrcodeImageSrc() {
return `/api/qrcode.png?t=${this.qrcodeImageToken}`;
}
},
mounted() {
this.loadAll();
this.pollTimer = setInterval(this.refreshStatusAndQrcode, 2500);
},
beforeUnmount() {
clearInterval(this.pollTimer);
},
methods: {
// 前端请求统一入口:负责发送请求、解析 JSON,并把错误转成异常。
async request(path, options = {}) {
const response = await fetch(path, {
headers: { 'Content-Type': 'application/json' },
...options
});
const payload = await response.json();
if (!response.ok) throw new Error(payload.error || '请求失败');
return payload;
},
// 页面加载时同时读取机器人状态和匹配配置,减少首次等待时间。
async loadAll() {
const [status, config] = await Promise.all([
this.request('/api/status'),
this.request('/api/config')
]);
this.status = status;
this.config = this.normalizeConfig(config);
await this.refreshQrcode();
},
// 兼容旧配置文件没有 matching 字段的情况,保证页面控件都有默认值。
normalizeConfig(config) {
return {
rooms: config.rooms || [],
matchWords: config.matchWords || [],
excelMode: config.excelMode || 'single',
matching: {
numberPattern:
config.matching?.numberPattern || '[A-Z]{1,8}[0-9][A-Z0-9-]{5,}|[0-9A-Z-]{8,}',
contextMessageWindow: config.matching?.contextMessageWindow ?? 1,
contextExpireMinutes: config.matching?.contextExpireMinutes ?? 5,
contextSameSenderOnly: config.matching?.contextSameSenderOnly === true,
allowDefaultAction: config.matching?.allowDefaultAction === true
}
};
},
// 定时轮询状态和二维码,确保扫码状态不用手动刷新页面。
async refreshStatusAndQrcode() {
this.status = await this.request('/api/status');
await this.refreshQrcode();
},
// 读取最近一次二维码;如果二维码已经生成,就自动打开登录弹窗。
async refreshQrcode() {
this.qrcode = await this.request('/api/qrcode');
if (this.qrcode.qrcode) {
this.qrcodeImageToken = Date.now();
if (this.status.state === 'starting' || this.status.state === 'running') {
this.qrcodeVisible = true;
}
}
},
// 启动机器人并立刻打开弹窗,后续通过轮询等待二维码返回。
async startBot() {
this.starting = true;
this.qrcodeVisible = true;
try {
this.status = await this.request('/api/start', { method: 'POST' });
await this.refreshQrcode();
ElementPlus.ElMessage.success('机器人启动请求已发送');
} catch (error) {
ElementPlus.ElMessage.error(error.message);
} finally {
this.starting = false;
}
},
// 停止当前 Wechaty 实例,并关闭二维码弹窗。
async stopBot() {
this.stopping = true;
try {
this.status = await this.request('/api/stop', { method: 'POST' });
this.qrcodeVisible = false;
ElementPlus.ElMessage.success('机器人已停止');
} catch (error) {
ElementPlus.ElMessage.error(error.message);
} finally {
this.stopping = false;
}
},
// 保存群聊、匹配词和 Excel 模式到后端配置文件。
async saveConfig() {
this.saving = true;
try {
this.config = this.normalizeConfig(await this.request('/api/config', {
method: 'PUT',
body: JSON.stringify(this.config)
}));
ElementPlus.ElMessage.success('配置已保存');
} catch (error) {
ElementPlus.ElMessage.error(error.message);
} finally {
this.saving = false;
}
},
// 添加一个群聊名称,重复项会被忽略。
addRoom() {
const room = this.newRoom.trim();
if (room && !this.config.rooms.includes(room)) this.config.rooms.push(room);
this.newRoom = '';
},
// 从当前配置中删除指定群聊。
removeRoom(room) {
this.config.rooms = this.config.rooms.filter((item) => item !== room);
},
// 添加一个动态匹配词,消息中包含该词时会尝试提取单号。
addWord() {
const word = this.newWord.trim();
if (word && !this.config.matchWords.includes(word)) this.config.matchWords.push(word);
this.newWord = '';
},
// 从当前配置中删除指定匹配词。
removeWord(word) {
this.config.matchWords = this.config.matchWords.filter((item) => item !== word);
}
}
}).use(ElementPlus).mount('#app');
</script>
</body>
</html>