Files
wechatAiclaw/public/manage.html
丹尼尔 152877cef2 fix:bug
2026-03-11 13:59:22 +08:00

739 lines
38 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>客户与消息管理 - 后端管理</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
:root {
color-scheme: light dark;
--bg: #0f172a;
--card: #020617;
--border: #1e293b;
--accent: #22c55e;
--accent-soft: rgba(34, 197, 94, 0.12);
--text: #e5e7eb;
--muted: #9ca3af;
--radius: 16px;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background: radial-gradient(circle at top, #1e293b 0, #020617 55%, #000 100%);
font-family: system-ui, -apple-system, sans-serif;
color: var(--text);
}
.nav {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
padding: 12px 24px;
border-bottom: 1px solid var(--border);
background: rgba(2, 6, 23, 0.95);
}
.nav-links { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
.nav a {
color: var(--muted);
text-decoration: none;
font-size: 14px;
}
.nav a:hover { color: var(--accent); }
.nav a.current { color: var(--accent); font-weight: 500; }
.nav-banner {
font-size: 18px;
font-weight: 700;
letter-spacing: 0.04em;
color: var(--text);
text-shadow: 0 0 20px rgba(34, 197, 94, 0.2);
}
.nav-banner span { color: var(--accent); }
.container {
max-width: 960px;
margin: 0 auto;
padding: 24px;
}
.card {
border-radius: var(--radius);
border: 1px solid var(--border);
background: radial-gradient(circle at top, rgba(15, 23, 42, 0.9), #020617);
padding: 20px;
margin-bottom: 20px;
}
.card-title { font-size: 14px; font-weight: 600; margin-bottom: 16px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; }
.small-label { font-size: 12px; color: var(--muted); margin-bottom: 8px; }
.field label { font-size: 12px; color: var(--muted); display: block; margin-bottom: 4px; }
.field input, .field select, .field textarea {
width: 100%; padding: 8px 10px; border: 1px solid var(--border); border-radius: 8px;
background: rgba(15, 23, 42, 0.9); color: var(--text); font-size: 13px;
}
.mgmt-tabs { display: flex; gap: 6px; margin-bottom: 16px; flex-wrap: wrap; }
.mgmt-tabs button {
padding: 8px 14px; font-size: 13px; border-radius: 8px;
background: rgba(30, 41, 59, 0.8); border: 1px solid var(--border); color: var(--text); cursor: pointer;
}
.mgmt-tabs button.active { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); }
.mgmt-panel { display: none; }
.mgmt-panel.show { display: block; }
.mgmt-table { width: 100%; font-size: 12px; border-collapse: collapse; }
.mgmt-table th, .mgmt-table td { padding: 8px 10px; text-align: left; border-bottom: 1px solid var(--border); }
.mgmt-form-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 12px; margin-bottom: 14px; }
.mgmt-form-grid .full { grid-column: 1 / -1; }
.mgmt-form-grid label { font-size: 11px; color: var(--muted); }
.mgmt-form-grid input, .mgmt-form-grid select, .mgmt-form-grid textarea {
padding: 6px 8px; border: 1px solid var(--border); border-radius: 6px; background: rgba(15,23,42,0.9); color: var(--text); font-size: 12px;
}
.tag { display: inline-block; padding: 4px 10px; border-radius: 999px; background: rgba(30,41,59,0.9); border: 1px solid var(--border); font-size: 12px; margin-right: 6px; margin-bottom: 6px; }
.primary { padding: 10px 20px; border-radius: 8px; background: var(--accent); color: #000; border: none; cursor: pointer; font-weight: 500; }
.secondary { padding: 8px 16px; border-radius: 8px; background: rgba(30,41,59,0.9); border: 1px solid var(--border); color: var(--text); cursor: pointer; font-size: 13px; }
.error-msg { color: #f87171; font-size: 12px; }
.tags-chips { display: inline-flex; flex-wrap: wrap; gap: 6px; }
.tags-chips .chip { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 999px; background: var(--accent-soft); border: 1px solid var(--accent); font-size: 12px; color: var(--accent); }
.tags-chips .chip button { background: none; border: none; color: var(--muted); cursor: pointer; padding: 0; font-size: 14px; line-height: 1; }
.tags-chips .chip button:hover { color: var(--text); }
</style>
</head>
<body>
<nav class="nav">
<div class="nav-banner">Wechat <span>智能托管服务</span></div>
<div class="nav-links">
<a href="manage.html" class="current">客户与消息管理</a>
<a href="chat.html">实时消息</a>
<a href="models.html">模型管理</a>
<a href="swagger.html" target="_blank">API 文档 (Swagger)</a>
</div>
</nav>
<div class="container">
<div class="card">
<div class="card-title">客户与消息管理</div>
<input type="hidden" id="key" autocomplete="off" />
<div class="mgmt-tabs">
<button type="button" class="mgmt-tab active" data-panel="panel-customers">客户档案</button>
<button type="button" class="mgmt-tab" data-panel="panel-greeting">定时问候</button>
<button type="button" class="mgmt-tab" data-panel="panel-push">分群推送</button>
<button type="button" class="mgmt-tab" data-panel="panel-ai-reply">AI 回复设置</button>
</div>
<div id="panel-customers" class="mgmt-panel show">
<div class="mgmt-form-grid">
<div class="field full">
<label>微信ID (wxid)</label>
<input id="c-wxid" placeholder="wxid_xxx 或从下方多选填入" />
<div class="small-label" style="margin-top:6px">从联系人选择(多选可批量填入)</div>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:4px">
<button type="button" class="secondary" id="btn-load-contact-list" style="padding:4px 10px;font-size:12px">加载联系人</button>
<select id="c-wxid-select" multiple style="min-width:200px;min-height:80px;max-height:120px"></select>
</div>
</div>
<div class="field"><label>备注名</label><input id="c-remark" placeholder="客户备注" /></div>
<div class="field"><label>地区</label><input id="c-region" placeholder="如:广东" /></div>
<div class="field"><label>年龄</label><input id="c-age" placeholder="25" /></div>
<div class="field"><label>性别</label><select id="c-gender"><option value="">-</option><option value="男"></option><option value="女"></option></select></div>
<div class="field"><label>拿货等级</label><input id="c-level" placeholder="如A/B/C" /></div>
<div class="field full"><label>标签(逗号分隔)</label><input id="c-tags" placeholder="VIP, 复购" /></div>
</div>
<button type="button" class="secondary" id="btn-customer-save" style="margin-bottom:12px">保存客户</button>
<div id="customer-list"></div>
</div>
<div id="panel-greeting" class="mgmt-panel">
<div class="mgmt-form-grid">
<div class="field"><label>任务名称</label><input id="g-name" placeholder="每日早安" /></div>
<div class="field"><label>触发日期</label><input type="date" id="g-date" /></div>
<div class="field"><label>触发时间(时:分:秒)</label><input type="time" id="g-time" step="1" placeholder="09:00:00" /></div>
<div class="field full" id="g-time-error-wrap" style="display:none"><label>&nbsp;</label><span id="g-time-error" class="error-msg"></span></div>
<div class="field full">
<label>客户标签(从客户档案中选)</label>
<div style="display:flex;flex-wrap:wrap;gap:8px;align-items:center;">
<select id="g-tag-select" style="max-width:160px"><option value="">请先登录后加载</option></select>
<button type="button" class="secondary" id="g-tag-add" style="padding:4px 10px;font-size:12px">添加标签</button>
<span id="g-tags-chips" class="tags-chips"></span>
</div>
<p class="small-label" style="margin-top:4px">在下方「客户档案」中维护的标签会出现在下拉列表;可多选,发送时仅推送给带这些标签的客户。</p>
</div>
<div class="field full"><label>问候语模板(可用 {{name}}</label><textarea id="g-template" rows="2" placeholder="早安,{{name}}!今日上新…"></textarea></div>
<div class="field"><label>&nbsp;</label><label><input type="checkbox" id="g-use-qwen" /> 使用千问生成个性化问候</label></div>
</div>
<button type="button" class="secondary" id="btn-greeting-add" style="margin-bottom:12px">添加定时任务</button>
<div id="greeting-list"></div>
</div>
<div id="panel-push" class="mgmt-panel">
<div class="small-label" style="margin-bottom:8px">快速群发(选好友/客户后一键分发)</div>
<div class="mgmt-form-grid">
<div class="field full">
<label>选择接收人(从好友/客户列表)</label>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
<button type="button" class="secondary" id="btn-load-friends" style="padding:4px 10px;font-size:12px">加载联系人</button>
<span id="mass-selected-count" class="small-label">已选 0 人</span>
</div>
<div id="mass-friend-list" style="max-height:140px;overflow-y:auto;border:1px solid var(--border);border-radius:8px;padding:8px;margin-top:6px;background:rgba(15,23,42,0.6)"></div>
</div>
<div class="field full"><label>群发文案</label><textarea id="mass-content" rows="2" placeholder="输入要群发的文字…"></textarea></div>
<button type="button" class="primary" id="btn-mass-send">一键群发</button>
</div>
<div class="small-label" style="margin-top:16px;margin-bottom:8px">发送图片消息(快捷方式)</div>
<div class="mgmt-form-grid">
<div class="field"><label>接收人 wxid</label><input id="img-to-user" placeholder="zhang499142409" /></div>
<div class="field full"><label>图片内容base64 或 URL</label><input id="img-content" placeholder="图片 base64 或图片 URL" /></div>
<div class="field full"><label>附带文字(可选)</label><input id="img-text" placeholder="可选" /></div>
<button type="button" class="primary" id="btn-send-image">发送图片</button>
</div>
<hr style="border:0;border-top:1px solid var(--border);margin:20px 0" />
<div class="small-label">商品标签</div>
<div class="mgmt-form-grid">
<div class="field"><input id="pt-name" placeholder="如:爆款、清仓、新中式" /></div>
<button type="button" class="secondary" id="btn-pt-add">添加标签</button>
</div>
<div id="product-tag-list" style="margin-bottom:12px"></div>
<div class="small-label">推送群组 <button type="button" class="secondary" id="btn-push-group-add" style="margin-left:8px;padding:4px 10px;font-size:12px">新建群组</button></div>
<div id="push-group-list" style="margin-bottom:12px"></div>
<div class="small-label">创建推送任务</div>
<div class="mgmt-form-grid">
<div class="field"><label>商品标签</label><select id="push-tag"></select></div>
<div class="field"><label>目标群组</label><select id="push-group"></select></div>
<div class="field full"><label>推送内容</label><textarea id="push-content" rows="2" placeholder="文案…"></textarea></div>
<button type="button" class="primary" id="btn-push-send">一键发送</button>
</div>
<div id="push-task-list"></div>
</div>
<div id="panel-ai-reply" class="mgmt-panel">
<p class="small-label" style="margin-bottom:12px">分级处理:仅<strong>超级管理员</strong><strong>白名单</strong>中的联系人会收到 AI 自动回复,其他消息一律不回复。</p>
<div class="mgmt-form-grid">
<div class="field full">
<label>超级管理员 wxid每行一个或逗号分隔</label>
<textarea id="ai-super-admin" rows="3" placeholder="zhang499142409&#10;wxid_xxx"></textarea>
</div>
<div class="field full">
<label>白名单 wxid可收到 AI 回复的联系人,每行一个或逗号分隔)</label>
<textarea id="ai-whitelist" rows="4" placeholder="wxid_abc&#10;wxid_def"></textarea>
</div>
<button type="button" class="primary" id="btn-ai-reply-save">保存配置</button>
</div>
<p class="small-label" style="margin-top:12px">保存后,仅上述列表中的发信人会被 AI 接管回复;未在列表中的联系人发来的消息将不会触发自动回复。</p>
</div>
</div>
</div>
<script>
const $ = (id) => document.getElementById(id);
// 相对路径,由 Node 代理到后端,适配 -p/-b 任意端口
const API_BASE = '';
const KEY_STORAGE = 'wechat_key';
(function applyKeyFromUrl() {
try {
var params = new URLSearchParams(window.location.search);
var k = params.get('key');
if (k && k.trim()) {
k = k.trim();
if ($('key')) $('key').value = k;
try { localStorage.setItem(KEY_STORAGE, k); } catch (_) {}
}
} catch (_) {}
})();
function getKey() {
const k = ($('key') && $('key').value ? $('key').value.trim() : '') || (typeof localStorage !== 'undefined' ? (localStorage.getItem(KEY_STORAGE) || '').trim() : '');
if (!k) { alert('请先登录'); return null; }
return k;
}
async function callApi(path, options = {}) {
const url = API_BASE + path;
const res = await fetch(url, { ...options, headers: { 'Content-Type': 'application/json', ...(options.headers || {}) } });
let body = null;
try { body = await res.json(); } catch (_) {}
if (!res.ok) throw new Error((body && (body.detail || body.message)) || res.statusText || '请求失败');
return body;
}
let greetingSelectedTags = [];
function switchPanel(panelId) {
document.querySelectorAll('.mgmt-panel').forEach(p => p.classList.remove('show'));
document.querySelectorAll('.mgmt-tab').forEach(t => t.classList.remove('active'));
const panel = $(panelId);
if (panel) panel.classList.add('show');
const tab = document.querySelector('.mgmt-tab[data-panel="' + panelId + '"]');
if (tab) tab.classList.add('active');
if (panelId === 'panel-greeting') loadCustomerTagsForGreeting();
if (panelId === 'panel-ai-reply') loadAiReplyConfig();
}
function _parseWxidLines(ta) {
const raw = (ta && ta.value) ? ta.value.trim() : '';
return raw.split(/[\n,]/).map(s => s.trim()).filter(Boolean);
}
async function loadAiReplyConfig() {
const key = $('key').value.trim();
const superEl = $('ai-super-admin');
const whiteEl = $('ai-whitelist');
if (!key) {
if (superEl) superEl.value = '';
if (whiteEl) whiteEl.value = '';
return;
}
try {
const data = await callApi('/api/ai-reply-config?key=' + encodeURIComponent(key));
if (superEl) superEl.value = (data.super_admin_wxids || []).join('\n');
if (whiteEl) whiteEl.value = (data.whitelist_wxids || []).join('\n');
} catch (e) {
if (superEl) superEl.value = '';
if (whiteEl) whiteEl.value = '';
}
}
async function saveAiReplyConfig() {
const key = getKey();
if (!key) return;
const superList = _parseWxidLines($('ai-super-admin'));
const whiteList = _parseWxidLines($('ai-whitelist'));
try {
await callApi('/api/ai-reply-config', {
method: 'PATCH',
body: JSON.stringify({ key, super_admin_wxids: superList, whitelist_wxids: whiteList })
});
alert('已保存:仅超级管理员与白名单中的联系人将收到 AI 回复。');
} catch (e) { alert('保存失败: ' + e.message); }
}
function renderGreetingTagChips() {
const el = $('g-tags-chips');
if (!el) return;
el.innerHTML = greetingSelectedTags.map(tag => '<span class="chip">' + escapeHtml(tag) + ' <button type="button" data-tag="' + escapeHtml(tag) + '" data-action="remove-g-tag">×</button></span>').join('');
el.querySelectorAll('[data-action="remove-g-tag"]').forEach(btn => {
btn.addEventListener('click', () => {
greetingSelectedTags = greetingSelectedTags.filter(t => t !== btn.dataset.tag);
renderGreetingTagChips();
});
});
}
function escapeHtml(s) {
const div = document.createElement('div');
div.textContent = s;
return div.innerHTML;
}
async function loadCustomerTagsForGreeting() {
const key = $('key').value.trim();
const sel = $('g-tag-select');
if (!sel) return;
if (!key) {
sel.innerHTML = '<option value="">请先登录后加载</option>';
return;
}
try {
const data = await callApi('/api/customer-tags?key=' + encodeURIComponent(key));
const tags = data.tags || [];
sel.innerHTML = '<option value="">选择标签…</option>' + tags.map(t => '<option value="' + escapeHtml(t) + '">' + escapeHtml(t) + '</option>').join('');
} catch (e) {
sel.innerHTML = '<option value="">加载失败</option>';
}
}
async function loadContactListForWxidSelect() {
const key = $('key').value.trim();
const sel = $('c-wxid-select');
if (!sel) return;
if (!key) { alert('请先登录'); return; }
sel.innerHTML = '<option value="">加载中…</option>';
try {
const data = await callApi('/api/contact-list?key=' + encodeURIComponent(key));
const list = data.items || [];
if (data.error) {
sel.innerHTML = '<option value="">获取失败</option>';
alert('获取联系人失败:' + (data.error || '请检查网络或 key'));
return;
}
sel.innerHTML = list.length
? list.map(c => {
const w = (c.wxid || c.Wxid || '').toString();
const r = (c.remark_name || c.RemarkName || c.NickName || w).toString();
return '<option value="' + escapeHtml(w) + '">' + escapeHtml(r) + ' (' + escapeHtml(w.slice(0, 20)) + (w.length > 20 ? '…' : '') + ')</option>';
}).join('')
: '<option value="">无联系人数据</option>';
sel.multiple = true;
} catch (e) {
sel.innerHTML = '<option value="">加载失败</option>';
alert('加载联系人失败: ' + (e.message || e));
}
}
async function loadCustomers() {
const key = $('key').value.trim();
if (!key) { $('customer-list').innerHTML = '<p class="small-label">请先登录。</p>'; return; }
try {
const data = await callApi('/api/customers?key=' + encodeURIComponent(key));
const list = data.items || [];
const html = list.length ? '<table class="mgmt-table"><thead><tr><th>备注</th><th>wxid</th><th>地区</th><th>等级</th><th>标签</th><th></th></tr></thead><tbody>' +
list.map(c => '<tr><td>' + (c.remark_name || '-') + '</td><td>' + (c.wxid || '-') + '</td><td>' + (c.region || '-') + '</td><td>' + (c.level || '-') + '</td><td>' + (c.tags || []).join(',') + '</td><td><button type="button" class="secondary" data-id="' + c.id + '" data-action="del-customer">删</button></td></tr>').join('') + '</tbody></table>' : '<p class="small-label">暂无客户,请先保存。</p>';
$('customer-list').innerHTML = html;
$('customer-list').querySelectorAll('[data-action="del-customer"]').forEach(btn => {
btn.addEventListener('click', async () => {
if (!confirm('确定删除?')) return;
try {
await callApi('/api/customers/' + btn.dataset.id, { method: 'DELETE' });
loadCustomers();
} catch (e) { alert('删除失败: ' + e.message); }
});
});
} catch (e) { $('customer-list').innerHTML = '<p class="small-label">加载失败: ' + e.message + '</p>'; }
}
async function saveCustomer() {
const key = getKey();
if (!key) return;
const wxidRaw = $('c-wxid').value.trim();
if (!wxidRaw) { alert('请填写微信ID或从联系人多选'); return; }
const wxids = wxidRaw.split(',').map(s => s.trim()).filter(Boolean);
const tagsStr = $('c-tags').value.trim();
const tags = tagsStr ? tagsStr.split(',').map(s => s.trim()).filter(Boolean) : [];
const payload = { key, remark_name: $('c-remark').value.trim(), region: $('c-region').value.trim(), age: $('c-age').value.trim(), gender: $('c-gender').value, level: $('c-level').value.trim(), tags };
try {
for (const wxid of wxids) {
await callApi('/api/customers', { method: 'POST', body: JSON.stringify({ ...payload, wxid }) });
}
loadCustomers();
if (wxids.length > 1) alert('已保存 ' + wxids.length + ' 个客户'); else alert('已保存');
} catch (e) { alert('保存失败: ' + e.message); }
}
function getGreetingSendTimeISO() {
const dateVal = $('g-date').value.trim();
const timeVal = $('g-time').value.trim();
if (!dateVal || !timeVal) return null;
const timePart = timeVal.length === 5 ? timeVal + ':00' : timeVal;
return dateVal + 'T' + timePart;
}
function validateGreetingTime() {
const iso = getGreetingSendTimeISO();
const wrap = $('g-time-error-wrap');
const msgEl = $('g-time-error');
if (!iso) {
wrap.style.display = 'none';
return { valid: false, message: '' };
}
const chosen = new Date(iso);
if (isNaN(chosen.getTime())) {
wrap.style.display = 'block';
msgEl.textContent = '请选择有效的日期和时间';
return { valid: false, message: msgEl.textContent };
}
if (chosen <= new Date()) {
wrap.style.display = 'block';
msgEl.textContent = '触发时间必须是未来时间,请重新选择';
return { valid: false, message: msgEl.textContent };
}
wrap.style.display = 'none';
msgEl.textContent = '';
return { valid: true, message: '' };
}
async function loadGreetingTasks() {
const key = $('key').value.trim();
if (!key) { $('greeting-list').innerHTML = '<p class="small-label">请先登录。</p>'; return; }
try {
const data = await callApi('/api/greeting-tasks?key=' + encodeURIComponent(key));
const list = data.items || [];
const fmt = (t) => (t.send_time || t.cron || '-').replace('T', ' ');
const html = list.length ? '<table class="mgmt-table"><thead><tr><th>名称</th><th>触发时间</th><th>标签</th><th>千问</th><th></th></tr></thead><tbody>' +
list.map(t => '<tr><td>' + t.name + '</td><td>' + fmt(t) + '</td><td>' + (t.customer_tags || []).join(',') + '</td><td>' + (t.use_qwen ? '是' : '否') + '</td><td><button type="button" class="secondary" data-id="' + t.id + '" data-action="del-greeting">删</button></td></tr>').join('') + '</tbody></table>' : '<p class="small-label">暂无定时任务。</p>';
$('greeting-list').innerHTML = html;
$('greeting-list').querySelectorAll('[data-action="del-greeting"]').forEach(btn => {
btn.addEventListener('click', async () => {
if (!confirm('确定删除?')) return;
try {
await callApi('/api/greeting-tasks/' + btn.dataset.id, { method: 'DELETE' });
loadGreetingTasks();
} catch (e) { alert(e.message); }
});
});
} catch (e) { $('greeting-list').innerHTML = '<p class="small-label">加载失败: ' + e.message + '</p>'; }
}
async function addGreetingTask() {
const key = getKey();
if (!key) return;
const name = $('g-name').value.trim();
if (!name) { alert('请填写任务名称'); return; }
const send_time = getGreetingSendTimeISO();
if (!send_time) { alert('请选择触发日期和时分秒'); return; }
const check = validateGreetingTime();
if (!check.valid) {
alert(check.message || '触发时间必须是未来时间,请重新选择');
return;
}
const customer_tags = greetingSelectedTags.slice();
const template = $('g-template').value.trim() || '早安,{{name}}';
try {
await callApi('/api/greeting-tasks', { method: 'POST', body: JSON.stringify({ key, name, send_time, customer_tags, template, use_qwen: $('g-use-qwen').checked }) });
greetingSelectedTags = [];
renderGreetingTagChips();
loadGreetingTasks();
} catch (e) { alert(e.message); }
}
async function loadProductTags() {
const key = $('key').value.trim();
if (!key) { $('product-tag-list').innerHTML = '<span class="small-label">请先登录。</span>'; return; }
try {
const data = await callApi('/api/product-tags?key=' + encodeURIComponent(key));
const list = data.items || [];
$('product-tag-list').innerHTML = list.length ? list.map(t => '<span class="tag">' + t.name + ' <button type="button" data-id="' + t.id + '" data-action="del-pt">×</button></span>').join('') : '<span class="small-label">暂无标签</span>';
$('product-tag-list').querySelectorAll('[data-action="del-pt"]').forEach(btn => {
btn.addEventListener('click', async () => {
try {
await callApi('/api/product-tags/' + btn.dataset.id, { method: 'DELETE' });
loadProductTags();
fillPushSelects();
} catch (e) { alert(e.message); }
});
});
fillPushSelects();
} catch (e) { $('product-tag-list').innerHTML = '<span class="small-label">加载失败</span>'; }
}
async function loadPushGroups() {
const key = $('key').value.trim();
if (!key) { $('push-group-list').innerHTML = '<p class="small-label">请先登录。</p>'; return; }
try {
const data = await callApi('/api/push-groups?key=' + encodeURIComponent(key));
const list = data.items || [];
$('push-group-list').innerHTML = list.length ? '<table class="mgmt-table"><tr><th>群组名</th><th>客户数</th><th></th></tr>' + list.map(g => '<tr><td>' + g.name + '</td><td>' + (g.customer_ids || []).length + '</td><td><button type="button" class="secondary" data-id="' + g.id + '" data-action="del-pg">删</button></td></tr>').join('') + '</table>' : '<p class="small-label">暂无群组。</p>';
$('push-group-list').querySelectorAll('[data-action="del-pg"]').forEach(btn => {
btn.addEventListener('click', async () => {
if (!confirm('确定删除?')) return;
try {
await callApi('/api/push-groups/' + btn.dataset.id, { method: 'DELETE' });
loadPushGroups();
fillPushSelects();
} catch (e) { alert(e.message); }
});
});
fillPushSelects();
} catch (e) { $('push-group-list').innerHTML = '<p class="small-label">加载失败</p>'; }
}
function fillPushSelects() {
const key = $('key').value.trim();
if (!key) return;
Promise.all([callApi('/api/product-tags?key=' + encodeURIComponent(key)), callApi('/api/push-groups?key=' + encodeURIComponent(key))]).then(([tagsRes, groupsRes]) => {
const tags = tagsRes.items || [], groups = groupsRes.items || [];
const tagSel = $('push-tag');
const groupSel = $('push-group');
if (tagSel) tagSel.innerHTML = '<option value="">选择标签</option>' + tags.map(t => '<option value="' + t.id + '">' + t.name + '</option>').join('');
if (groupSel) groupSel.innerHTML = '<option value="">选择群组</option>' + groups.map(g => '<option value="' + g.id + '">' + g.name + '</option>').join('');
}).catch(() => {});
}
async function addProductTag() {
const key = getKey();
if (!key) return;
const name = $('pt-name').value.trim();
if (!name) { alert('请输入标签名'); return; }
try {
await callApi('/api/product-tags', { method: 'POST', body: JSON.stringify({ key, name }) });
$('pt-name').value = '';
loadProductTags();
} catch (e) { alert(e.message); }
}
async function createPushGroup() {
const key = getKey();
if (!key) return;
const name = prompt('群组名称');
if (!name) return;
try {
await callApi('/api/push-groups', { method: 'POST', body: JSON.stringify({ key, name, customer_ids: [], tag_ids: [] }) });
loadPushGroups();
} catch (e) { alert(e.message); }
}
let massSelectedWxids = [];
async function loadFriendsForMass() {
const key = $('key').value.trim();
if (!key) { alert('请先登录'); return; }
const el = $('mass-friend-list');
el.innerHTML = '<span class="small-label">加载中…</span>';
try {
const data = await callApi('/api/friends?key=' + encodeURIComponent(key));
const list = data.items || [];
if (!list.length) {
el.innerHTML = '<span class="small-label">暂无联系人,请先在「客户档案」添加客户。</span>';
return;
}
el.innerHTML = list.map(f => {
const wxid = (f.wxid || f.Wxid || f.UserName || '').toString();
const name = (f.remark_name || f.RemarkName || f.NickName || wxid).toString();
return '<label style="display:block;margin:4px 0"><input type="checkbox" class="mass-friend-cb" data-wxid="' + escapeHtml(wxid) + '" /> ' + escapeHtml(name) + ' <span class="small-label">(' + escapeHtml(wxid.slice(0, 16)) + '…)</span></label>';
}).join('');
el.querySelectorAll('.mass-friend-cb').forEach(cb => {
cb.addEventListener('change', updateMassSelected);
});
massSelectedWxids = [];
updateMassSelected();
} catch (e) {
el.innerHTML = '<span class="small-label">加载失败: ' + escapeHtml(e.message) + '</span>';
}
}
function updateMassSelected() {
const list = $('mass-friend-list');
if (!list) return;
const checked = list.querySelectorAll('.mass-friend-cb:checked');
massSelectedWxids = Array.from(checked).map(c => c.dataset.wxid).filter(Boolean);
const countEl = $('mass-selected-count');
if (countEl) countEl.textContent = '已选 ' + massSelectedWxids.length + ' 人';
}
async function doMassSend() {
const key = getKey();
if (!key) return;
const content = $('mass-content').value.trim();
if (!content) { alert('请填写群发文案'); return; }
if (!massSelectedWxids.length) { alert('请先点击「加载联系人」并勾选要群发的对象'); return; }
try {
const items = massSelectedWxids.map(wxid => ({ to_user_name: wxid, content }));
await callApi('/api/send-batch', { method: 'POST', body: JSON.stringify({ key, items }) });
alert('已提交群发,共 ' + items.length + ' 人');
$('mass-content').value = '';
} catch (e) { alert('群发失败: ' + e.message); }
}
async function doSendImage() {
const key = getKey();
if (!key) return;
const toUser = $('img-to-user').value.trim();
const imageContent = $('img-content').value.trim();
if (!toUser || !imageContent) { alert('请填写接收人 wxid 和图片内容'); return; }
try {
await callApi('/api/send-image', {
method: 'POST',
body: JSON.stringify({
key,
to_user_name: toUser,
image_content: imageContent,
text_content: ($('img-text').value || '').trim()
})
});
alert('图片已发送');
$('img-content').value = '';
$('img-text').value = '';
} catch (e) { alert('发送图片失败: ' + e.message); }
}
async function doPushSend() {
const key = getKey();
if (!key) return;
const tagId = $('push-tag').value;
const groupId = $('push-group').value;
const content = $('push-content').value.trim();
if (!groupId || !content) { alert('请选择目标群组并填写推送内容'); return; }
try {
const groupsRes = await callApi('/api/push-groups?key=' + encodeURIComponent(key));
const group = (groupsRes.items || []).find(g => g.id === groupId);
if (!group) { alert('未找到该群组'); return; }
const customerIds = group.customer_ids || [];
const tagIds = group.tag_ids || [];
const customersRes = await callApi('/api/customers?key=' + encodeURIComponent(key));
const allCustomers = customersRes.items || [];
let wxids = [];
customerIds.forEach(cid => {
const c = allCustomers.find(x => x.id === cid);
if (c && c.wxid) wxids.push(c.wxid);
});
if (tagIds.length) {
const byTag = new Set(tagIds);
allCustomers.forEach(c => {
if (c.wxid && (c.tags || []).some(t => byTag.has(t)) && !wxids.includes(c.wxid)) wxids.push(c.wxid);
});
}
wxids = [...new Set(wxids)];
if (!wxids.length) { alert('该群组下没有可推送的客户,请先编辑群组添加客户或标签'); return; }
const items = wxids.map(wxid => ({ to_user_name: wxid, content }));
await callApi('/api/send-batch', { method: 'POST', body: JSON.stringify({ key, items }) });
await callApi('/api/push-tasks', { method: 'POST', body: JSON.stringify({ key, product_tag_id: tagId || '', group_id: groupId, content }) });
alert('已推送至 ' + items.length + ' 人');
$('push-content').value = '';
} catch (e) { alert(e.message); }
}
document.querySelectorAll('.mgmt-tab').forEach(tab => tab.addEventListener('click', () => switchPanel(tab.dataset.panel)));
$('btn-customer-save').addEventListener('click', saveCustomer);
$('btn-load-contact-list').addEventListener('click', loadContactListForWxidSelect);
$('c-wxid-select').addEventListener('change', function() {
const sel = $('c-wxid-select');
const opts = sel ? sel.querySelectorAll('option:checked') : [];
const vals = Array.from(opts).map(o => o.value).filter(Boolean);
if ($('c-wxid')) $('c-wxid').value = vals.join(',');
});
$('btn-greeting-add').addEventListener('click', addGreetingTask);
$('g-tag-add').addEventListener('click', () => {
const sel = $('g-tag-select');
const v = sel && sel.value ? sel.value.trim() : '';
if (!v) return;
if (greetingSelectedTags.indexOf(v) >= 0) return;
greetingSelectedTags.push(v);
renderGreetingTagChips();
});
(function initKeyFromStorage() {
try {
if (typeof localStorage !== 'undefined' && $('key')) $('key').value = localStorage.getItem(KEY_STORAGE) || '';
} catch (_) {}
})();
(function initGreetingTimeMin() {
const today = new Date().toISOString().slice(0, 10);
const dateEl = $('g-date');
if (dateEl) dateEl.setAttribute('min', today);
function updateTimeMin() {
const d = $('g-date').value;
const timeEl = $('g-time');
const todayStr = new Date().toISOString().slice(0, 10);
if (!d || d !== todayStr) { timeEl.removeAttribute('min'); return; }
const now = new Date();
const h = String(now.getHours()).padStart(2, '0');
const m = String(now.getMinutes()).padStart(2, '0');
const s = String(now.getSeconds()).padStart(2, '0');
timeEl.setAttribute('min', h + ':' + m + ':' + s);
}
function onGreetingTimeChange() {
updateTimeMin();
validateGreetingTime();
}
if ($('g-date')) { $('g-date').addEventListener('change', onGreetingTimeChange); $('g-date').addEventListener('input', onGreetingTimeChange); }
if ($('g-time')) { $('g-time').addEventListener('change', onGreetingTimeChange); $('g-time').addEventListener('input', onGreetingTimeChange); }
})();
$('btn-load-friends').addEventListener('click', loadFriendsForMass);
$('btn-mass-send').addEventListener('click', doMassSend);
$('btn-send-image').addEventListener('click', doSendImage);
$('btn-ai-reply-save').addEventListener('click', saveAiReplyConfig);
$('btn-pt-add').addEventListener('click', addProductTag);
$('btn-push-group-add').addEventListener('click', createPushGroup);
$('btn-push-send').addEventListener('click', doPushSend);
loadCustomers();
loadGreetingTasks();
loadProductTags();
loadPushGroups();
(function wsStatusCheck() {
let wasConnected = false;
const CHECK_MS = 8000;
const t = setInterval(async () => {
try {
const r = await fetch(API_BASE + '/api/ws-status', { cache: 'no-store' });
const d = await r.json().catch(() => ({}));
if (d && d.connected) wasConnected = true;
if (wasConnected && d && d.connected === false) {
clearInterval(t);
alert('连接已断开,请重新登录');
window.location.href = 'index.html';
}
} catch (_) {}
}, CHECK_MS);
})();
</script>
</body>
</html>