Files
wechatAiclaw/public/manage.html
2026-03-11 00:22:41 +08:00

490 lines
26 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;
gap: 16px;
padding: 12px 24px;
border-bottom: 1px solid var(--border);
background: rgba(2, 6, 23, 0.95);
}
.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; }
.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; }
.key-row { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; }
.key-row input { max-width: 280px; padding: 8px 12px; border: 1px solid var(--border); border-radius: 8px; background: rgba(15,23,42,0.9); color: var(--text); }
.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">
<a href="index.html">登录</a>
<a href="manage.html" class="current">客户与消息管理</a>
<a href="chat.html">实时消息</a>
<a href="models.html">模型管理</a>
</nav>
<div class="container">
<div class="card">
<div class="card-title">客户与消息管理R1-2 / R1-3 / R1-4</div>
<div class="key-row">
<label for="key">账号 key</label>
<input type="text" id="key" placeholder="与登录页一致,如 HBpEnbtj9BJZ" autocomplete="off" />
</div>
<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>
</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>
<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="">请先选账号 key 并加载</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">商品标签</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>
</div>
<script>
const $ = (id) => document.getElementById(id);
const API_BASE = 'http://localhost:8000';
const KEY_STORAGE = 'wechat_key';
function getKey() {
const k = $('key').value.trim();
if (!k) { alert('请先填写账号 key'); 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();
}
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="">请先填写账号 key</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 loadCustomers() {
const key = $('key').value.trim();
if (!key) { $('customer-list').innerHTML = '<p class="small-label">请先填写账号 key。</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 wxid = $('c-wxid').value.trim();
if (!wxid) { alert('请填写微信ID'); return; }
const tagsStr = $('c-tags').value.trim();
const tags = tagsStr ? tagsStr.split(',').map(s => s.trim()).filter(Boolean) : [];
try {
await callApi('/api/customers', { method: 'POST', body: JSON.stringify({ key, wxid, 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 }) });
loadCustomers();
} 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">请先填写账号 key。</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">请先填写账号 key。</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">请先填写账号 key。</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); }
}
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 (!tagId || !groupId || !content) { alert('请选择标签、群组并填写内容'); return; }
try {
await callApi('/api/push-tasks', { method: 'POST', body: JSON.stringify({ key, product_tag_id: tagId, group_id: groupId, content }) });
$('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-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();
});
$('key').addEventListener('input', () => { if ($('panel-greeting') && $('panel-greeting').classList.contains('show')) loadCustomerTagsForGreeting(); });
(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-pt-add').addEventListener('click', addProductTag);
$('btn-push-group-add').addEventListener('click', createPushGroup);
$('btn-push-send').addEventListener('click', doPushSend);
$('key').addEventListener('change', function() { try { localStorage.setItem(KEY_STORAGE, this.value.trim()); } catch (_) {} });
if (typeof localStorage !== 'undefined' && localStorage.getItem(KEY_STORAGE)) $('key').value = localStorage.getItem(KEY_STORAGE);
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>