Files
qiweimanager-master/helper/dashboard.go

1044 lines
33 KiB
Go
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.
package main
import (
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
"qiweimanager/config"
)
const dashboardMessageLimit = 300
type DashboardMessage struct {
ID int64 `json:"id"`
Time string `json:"time"`
ClientID int32 `json:"clientId"`
Direction string `json:"direction"`
Status string `json:"status"`
Type string `json:"type"`
Summary string `json:"summary"`
Raw interface{} `json:"raw,omitempty"`
RawText string `json:"rawText,omitempty"`
}
var (
dashboardMessageMu sync.Mutex
dashboardMessageSeq int64
dashboardMessageList []DashboardMessage
)
func recordDashboardMessage(clientID int32, direction string, raw string, transformed []byte, status string) {
value, rawText := decodeDashboardJSON(raw)
if len(transformed) > 0 {
if transformedValue, transformedText := decodeDashboardJSON(string(transformed)); transformedText == "" {
value = transformedValue
rawText = ""
}
}
dashboardMessageMu.Lock()
dashboardMessageSeq++
msg := DashboardMessage{
ID: dashboardMessageSeq,
Time: time.Now().Format("2006-01-02 15:04:05"),
ClientID: clientID,
Direction: direction,
Status: status,
Type: dashboardMessageType(value),
Summary: dashboardMessageSummary(value, rawText),
Raw: value,
RawText: rawText,
}
dashboardMessageList = append(dashboardMessageList, msg)
if len(dashboardMessageList) > dashboardMessageLimit {
dashboardMessageList = dashboardMessageList[len(dashboardMessageList)-dashboardMessageLimit:]
}
dashboardMessageMu.Unlock()
}
func decodeDashboardJSON(raw string) (interface{}, string) {
raw = strings.TrimSpace(strings.TrimRight(raw, "\x00"))
if raw == "" {
return nil, ""
}
var value interface{}
if err := json.Unmarshal([]byte(raw), &value); err != nil {
return nil, raw
}
return value, ""
}
func dashboardMessageType(value interface{}) string {
if m, ok := value.(map[string]interface{}); ok {
if typeValue, exists := m["type"]; exists {
return fmt.Sprint(typeValue)
}
if eventValue, exists := m["event"]; exists {
return fmt.Sprint(eventValue)
}
}
return ""
}
func dashboardMessageSummary(value interface{}, rawText string) string {
if rawText != "" {
if len(rawText) > 160 {
return rawText[:160] + "..."
}
return rawText
}
if m, ok := value.(map[string]interface{}); ok {
if fmt.Sprint(m["type"]) == "11024" {
pid := ""
if data, ok := m["data"].(map[string]interface{}); ok {
pid = strings.TrimSpace(fmt.Sprint(data["pid"]))
}
if pid != "" && pid != "<nil>" {
return "连接事件(非账号登录) | pid=" + pid
}
return "连接事件(非账号登录)"
}
}
fields := make([]string, 0, 8)
collectDashboardFields(value, &fields)
if len(fields) == 0 {
if value == nil {
return ""
}
encoded, err := json.Marshal(value)
if err != nil {
return fmt.Sprint(value)
}
text := string(encoded)
if len(text) > 160 {
return text[:160] + "..."
}
return text
}
summary := strings.Join(fields, " | ")
if len(summary) > 220 {
return summary[:220] + "..."
}
return summary
}
func collectDashboardFields(value interface{}, fields *[]string) {
if len(*fields) >= 8 || value == nil {
return
}
switch typed := value.(type) {
case map[string]interface{}:
keys := []string{
"type", "event", "client_id", "user_id", "conversation_id",
"sender", "sender_name", "content", "message", "text",
"file_name", "path", "url",
}
for _, key := range keys {
if len(*fields) >= 8 {
return
}
if val, exists := typed[key]; exists {
text := strings.TrimSpace(fmt.Sprint(val))
if text != "" && text != "<nil>" {
*fields = append(*fields, key+"="+text)
}
}
}
for _, key := range []string{"data", "msg", "messageData"} {
if nested, exists := typed[key]; exists {
collectDashboardFields(nested, fields)
}
}
case []interface{}:
for _, item := range typed {
collectDashboardFields(item, fields)
if len(*fields) >= 8 {
return
}
}
}
}
func getDashboardMessages(limit int) []DashboardMessage {
if limit <= 0 || limit > dashboardMessageLimit {
limit = 100
}
dashboardMessageMu.Lock()
defer dashboardMessageMu.Unlock()
start := len(dashboardMessageList) - limit
if start < 0 {
start = 0
}
result := make([]DashboardMessage, len(dashboardMessageList[start:]))
copy(result, dashboardMessageList[start:])
for i, j := 0, len(result)-1; i < j; i, j = i+1, j-1 {
result[i], result[j] = result[j], result[i]
}
return result
}
func getDashboardMessageCount() int {
dashboardMessageMu.Lock()
defer dashboardMessageMu.Unlock()
return len(dashboardMessageList)
}
func handleDashboardPage(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" && r.URL.Path != "/dashboard" {
http.NotFound(w, r)
return
}
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(dashboardHTML))
}
func handleDashboardState(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
clients := getUsableClientsMap()
activeClientCount := usableClientCount()
clientDiagnostics := getClientDiagnostics()
cfg := config.GetGlobalConfig()
writeDashboardJSON(w, http.StatusOK, map[string]interface{}{
"status": "ok",
"serverTime": time.Now().Format("2006-01-02 15:04:05"),
"port": httpPort,
"activeClientCount": activeClientCount,
"connectionCount": connectedClientCount(),
"unidentifiedCount": unidentifiedClientCount(),
"clients": clients,
"clientDiagnostics": clientDiagnostics,
"bindableAccounts": getBindableAccountRows(),
"accounts": getActiveUsersFromClientStatus(),
"config": cfg,
"templates": listDashboardTemplates(),
"messageCount": getDashboardMessageCount(),
})
}
func handleDashboardMessages(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
limit := 100
if value := r.URL.Query().Get("limit"); value != "" {
if parsed, err := strconv.Atoi(value); err == nil {
limit = parsed
}
}
writeDashboardJSON(w, http.StatusOK, map[string]interface{}{
"messages": getDashboardMessages(limit),
})
}
func handleDebugClients(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
writeDashboardJSON(w, http.StatusOK, map[string]interface{}{
"success": true,
"data": getClientDiagnostics(),
})
}
func handleDebugClientIdentify(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
prefix := "/api/debug/clients/"
suffix := strings.TrimPrefix(r.URL.Path, prefix)
action := ""
if strings.HasSuffix(suffix, "/identify") {
action = "identify"
} else if strings.HasSuffix(suffix, "/probe-account") {
action = "probe-account"
} else if strings.HasSuffix(suffix, "/bind") {
action = "bind"
} else {
http.NotFound(w, r)
return
}
clientIDText := strings.TrimSuffix(suffix, "/"+action)
clientIDText = strings.Trim(clientIDText, "/")
parsed, err := strconv.ParseUint(clientIDText, 10, 32)
if err != nil || parsed == 0 {
writeDashboardJSON(w, http.StatusBadRequest, map[string]interface{}{
"success": false,
"error": "invalid clientId",
})
return
}
if action == "probe-account" {
result := runClientAccountProbe(uint32(parsed))
writeDashboardJSON(w, http.StatusOK, map[string]interface{}{
"success": true,
"message": result.Message,
"data": result,
})
return
}
if action == "bind" {
userID := strings.TrimSpace(r.URL.Query().Get("userId"))
if userID == "" {
var payload map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&payload); err == nil {
userID = strings.TrimSpace(fmt.Sprint(firstExisting(payload, "userId", "user_id")))
}
}
if userID == "" {
writeDashboardJSON(w, http.StatusBadRequest, map[string]interface{}{
"success": false,
"error": "missing userId",
})
return
}
if err := bindClientToHistoricalAccount(uint32(parsed), userID); err != nil {
writeDashboardJSON(w, http.StatusBadRequest, map[string]interface{}{
"success": false,
"error": err.Error(),
})
return
}
writeDashboardJSON(w, http.StatusOK, map[string]interface{}{
"success": true,
"message": "client bound",
"data": getClientDiagnostics(),
})
return
}
retryClientIdentification(uint32(parsed))
writeDashboardJSON(w, http.StatusOK, map[string]interface{}{
"success": true,
"message": "identify retry started",
"data": getClientDiagnostics(),
})
}
func bindClientToHistoricalAccount(clientID uint32, userID string) error {
userID = strings.TrimSpace(userID)
if clientID == 0 {
return fmt.Errorf("invalid clientId")
}
if userID == "" {
return fmt.Errorf("missing userId")
}
for _, account := range getBindableAccountRows() {
if strings.TrimSpace(fmt.Sprint(account["user_id"])) == userID {
markClientManuallyBound(clientID, userID, account)
globalLogger.Info("[dashboard] manually bound client %d to user_id=%s", clientID, userID)
return nil
}
}
return fmt.Errorf("userId %s not found in client_status.json", userID)
}
func getBindableAccountRows() []map[string]interface{} {
exePath, err := os.Executable()
candidates := []string{filepath.Join("config", "client_status.json")}
if err == nil {
candidates = append([]string{filepath.Join(filepath.Dir(exePath), "config", "client_status.json")}, candidates...)
}
var clientStatus map[string]interface{}
for _, path := range candidates {
data, readErr := os.ReadFile(path)
if readErr != nil {
continue
}
if json.Unmarshal(data, &clientStatus) == nil {
break
}
}
if len(clientStatus) == 0 {
return nil
}
fields := []string{
"account", "acctid", "avatar", "corp_id", "corp_name",
"corp_short_name", "email", "job_name", "mobile", "nickname",
"position", "sex", "status", "user_id", "username",
}
rows := make([]map[string]interface{}, 0, len(clientStatus))
for key, value := range clientStatus {
userMap, ok := value.(map[string]interface{})
if !ok {
continue
}
userID := strings.TrimSpace(fmt.Sprint(firstExisting(userMap, "user_id", "userId")))
if userID == "" || userID == "<nil>" {
userID = strings.TrimSpace(key)
}
if userID == "" {
continue
}
row := make(map[string]interface{}, len(fields))
for _, field := range fields {
if field == "user_id" {
row[field] = userID
continue
}
if v, exists := userMap[field]; exists {
row[field] = v
}
}
if _, exists := row["status"]; !exists {
row["status"] = 1
}
rows = append(rows, row)
}
sort.Slice(rows, func(i, j int) bool {
left := fmt.Sprint(firstExisting(rows[i], "username", "nickname", "user_id"))
right := fmt.Sprint(firstExisting(rows[j], "username", "nickname", "user_id"))
return left < right
})
return rows
}
func listDashboardTemplates() []string {
exePath, err := os.Executable()
var candidates []string
if err == nil {
candidates = append(candidates, filepath.Join(filepath.Dir(exePath), "requestdata"))
}
candidates = append(candidates, "requestdata")
seen := make(map[string]bool)
templates := make([]string, 0)
for _, dir := range candidates {
entries, err := os.ReadDir(dir)
if err != nil {
continue
}
for _, entry := range entries {
if entry.IsDir() || strings.ToLower(filepath.Ext(entry.Name())) != ".json" {
continue
}
name := strings.TrimSuffix(entry.Name(), filepath.Ext(entry.Name()))
if !seen[name] {
seen[name] = true
templates = append(templates, name)
}
}
}
sort.Strings(templates)
return templates
}
func writeDashboardJSON(w http.ResponseWriter, statusCode int, payload interface{}) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(statusCode)
_ = json.NewEncoder(w).Encode(payload)
}
const dashboardHTML = `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>企业微信消息管理</title>
<style>
:root {
color-scheme: light;
--bg: #f6f7f9;
--panel: #ffffff;
--line: #dfe3e8;
--text: #17212b;
--muted: #64748b;
--brand: #1f7a8c;
--brand-2: #2563eb;
--ok: #16803c;
--warn: #a15c00;
--bad: #b42318;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: "Microsoft YaHei", "Segoe UI", Arial, sans-serif;
font-size: 14px;
}
header {
height: 58px;
padding: 0 22px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--line);
background: #fff;
position: sticky;
top: 0;
z-index: 2;
}
h1 { font-size: 18px; margin: 0; letter-spacing: 0; }
main {
padding: 18px;
display: grid;
grid-template-columns: minmax(380px, 1fr) minmax(360px, 460px);
gap: 16px;
}
section {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
padding: 14px;
min-width: 0;
}
.stack { display: grid; gap: 16px; }
.cards {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.card {
border: 1px solid var(--line);
border-radius: 8px;
padding: 12px;
background: #fbfcfd;
min-height: 78px;
}
.label { color: var(--muted); font-size: 12px; margin-bottom: 8px; }
.value { font-size: 22px; font-weight: 700; word-break: break-all; }
.ok { color: var(--ok); }
.warn { color: var(--warn); }
h2 {
margin: 0 0 12px;
font-size: 15px;
display: flex;
justify-content: space-between;
align-items: center;
}
table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
th, td {
border-bottom: 1px solid var(--line);
padding: 9px 8px;
text-align: left;
vertical-align: top;
overflow-wrap: anywhere;
}
th { color: var(--muted); font-size: 12px; font-weight: 600; }
button {
border: 1px solid #176a79;
background: var(--brand);
color: #fff;
border-radius: 6px;
padding: 8px 12px;
cursor: pointer;
font-weight: 600;
}
button.secondary {
background: #fff;
color: var(--brand);
}
button:disabled {
opacity: .55;
cursor: not-allowed;
}
input, select, textarea {
width: 100%;
border: 1px solid var(--line);
border-radius: 6px;
padding: 9px 10px;
font: inherit;
background: #fff;
color: var(--text);
}
textarea {
min-height: 140px;
resize: vertical;
font-family: Consolas, "Microsoft YaHei", monospace;
line-height: 1.45;
}
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.form-grid { display: grid; gap: 10px; }
.toolbar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.pill {
display: inline-flex;
align-items: center;
border: 1px solid var(--line);
border-radius: 999px;
padding: 4px 8px;
color: var(--muted);
background: #fff;
font-size: 12px;
}
.messages {
display: grid;
gap: 10px;
max-height: 560px;
overflow: auto;
padding-right: 2px;
}
.message {
border: 1px solid var(--line);
border-radius: 8px;
padding: 10px;
background: #fbfcfd;
}
.message-head {
display: flex;
justify-content: space-between;
gap: 8px;
color: var(--muted);
font-size: 12px;
margin-bottom: 7px;
}
.message-title {
font-weight: 700;
margin-bottom: 6px;
color: var(--text);
overflow-wrap: anywhere;
}
pre {
margin: 8px 0 0;
padding: 10px;
background: #111827;
color: #e5e7eb;
border-radius: 6px;
max-height: 240px;
overflow: auto;
font-size: 12px;
line-height: 1.45;
white-space: pre-wrap;
word-break: break-word;
}
.result {
min-height: 38px;
border: 1px dashed var(--line);
border-radius: 6px;
padding: 9px 10px;
color: var(--muted);
background: #fbfcfd;
overflow-wrap: anywhere;
}
.empty {
color: var(--muted);
padding: 18px;
text-align: center;
border: 1px dashed var(--line);
border-radius: 8px;
}
@media (max-width: 980px) {
main { grid-template-columns: 1fr; }
.cards { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.row { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<header>
<h1>企业微信消息管理</h1>
<div class="toolbar">
<span id="refreshState" class="pill">等待连接</span>
<button class="secondary" onclick="refreshAll()">刷新</button>
</div>
</header>
<main>
<div class="stack">
<section>
<h2>运行状态</h2>
<div class="cards">
<div class="card"><div class="label">HTTP服务</div><div id="status" class="value warn">检查中</div></div>
<div class="card"><div class="label">本地端口</div><div id="port" class="value">-</div></div>
<div class="card"><div class="label">可用账号</div><div id="activeCount" class="value">0</div></div>
<div class="card"><div class="label">待识别连接</div><div id="unidentifiedCount" class="value">0</div></div>
<div class="card"><div class="label">消息记录</div><div id="messageCount" class="value">0</div></div>
</div>
</section>
<section>
<h2>在线企微账号 <span id="accountHint" class="pill">自动刷新</span></h2>
<div id="accounts"></div>
</section>
<section>
<h2>账号识别状态</h2>
<div id="clientDiagnostics" class="result">加载中</div>
</section>
<section>
<h2>最近消息</h2>
<div id="messages" class="messages"><div class="empty">还没有收到消息</div></div>
</section>
</div>
<div class="stack">
<section>
<h2>快捷操作</h2>
<div class="toolbar">
<button onclick="startWxwork()">启动企微</button>
<button class="secondary" onclick="loadExample('sendVWorkTextMessage')">文本消息模板</button>
<button class="secondary" onclick="loadExample('getVWorkGroupList')">群列表模板</button>
</div>
<div id="quickResult" class="result" style="margin-top:10px;">操作结果会显示在这里</div>
</section>
<section>
<h2>调用接口模板</h2>
<div class="form-grid">
<label>
<div class="label">模板类型</div>
<select id="templateSelect"></select>
</label>
<label>
<div class="label">参数 JSON</div>
<textarea id="paramsInput">{
"robotId": "",
"instanceId": "",
"conversationId": "",
"message": "测试消息"
}</textarea>
</label>
<div class="toolbar">
<button onclick="sendTemplate()">发送 / 查询</button>
<button class="secondary" onclick="formatParams()">格式化 JSON</button>
</div>
<div id="templateResult" class="result">请选择模板并填写参数</div>
</div>
</section>
<section>
<h2>回调配置</h2>
<div id="configView" class="result">加载中</div>
</section>
</div>
</main>
<script>
let templatesLoaded = false;
function escapeHTML(value) {
return String(value == null ? "" : value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function pretty(value) {
return JSON.stringify(value, null, 2);
}
async function getJSON(url, options) {
const response = await fetch(url, options || {});
const text = await response.text();
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch (error) {
data = { raw: text };
}
if (!response.ok) {
throw new Error(data && data.error ? data.error : response.status + " " + response.statusText);
}
return data;
}
async function refreshState() {
const state = await getJSON("/api/dashboard/state");
document.getElementById("status").textContent = state.status || "ok";
document.getElementById("status").className = "value ok";
document.getElementById("port").textContent = state.port || "-";
document.getElementById("activeCount").textContent = state.activeClientCount || 0;
document.getElementById("unidentifiedCount").textContent = state.unidentifiedCount || 0;
document.getElementById("messageCount").textContent = state.messageCount || 0;
window.dashboardBindableAccounts = state.bindableAccounts || [];
document.getElementById("refreshState").textContent = "最后刷新 " + (state.serverTime || "");
renderAccounts(state.accounts || [], state.clients || {});
renderClientDiagnostics(state.clientDiagnostics || {});
renderConfig(state.config || {});
renderTemplates(state.templates || []);
}
function renderAccounts(accounts, clients) {
const target = document.getElementById("accounts");
if (!accounts.length) {
target.innerHTML = "<div class=\"empty\">暂无可用企微账号。若只有待识别连接,请让同事私聊当前账号,收到 11041 后会进入 message_ready 模式。</div>";
return;
}
let rows = accounts.map(function(account) {
return "<tr>" +
"<td>" + escapeHTML(account.user_id || account.account || "-") + "</td>" +
"<td>" + escapeHTML(account.username || account.nickname || "-") + "</td>" +
"<td>" + escapeHTML(account.corp_name || account.corp_short_name || "-") + "</td>" +
"<td>" + escapeHTML(account.status == null ? "1" : account.status) + "</td>" +
"</tr>";
}).join("");
target.innerHTML = "<table><thead><tr><th>账号ID</th><th>名称</th><th>企业</th><th>状态</th></tr></thead><tbody>" + rows + "</tbody></table>";
}
function renderClientDiagnostics(diagnostics) {
const target = document.getElementById("clientDiagnostics");
const injection = diagnostics.injection || {};
const version = diagnostics.version || {};
const clients = diagnostics.clients || [];
let html =
"<div><b>注入状态:</b>" + escapeHTML(injection.status || "-") + "</div>" +
"<div><b>企业微信版本:</b>" + escapeHTML(version.wxWorkVersion || "-") + "</div>" +
"<div><b>Helper版本</b>" + escapeHTML(version.helperVersion || "-") + "</div>" +
"<div><b>可用账号:</b>" + escapeHTML(diagnostics.usableClientCount || 0) + "</div>" +
"<div><b>已识别账号:</b>" + escapeHTML(diagnostics.recognizedClientCount || 0) + "</div>" +
"<div><b>待识别连接:</b>" + escapeHTML(diagnostics.unidentifiedClientCount || 0) + "</div>" +
"<div><b>连接总数:</b>" + escapeHTML(diagnostics.connectionCount || 0) + "</div>";
if (version.message) {
html += "<div style=\"margin-top:8px;color:var(--bad);\"><b>版本提示:</b>" + escapeHTML(version.message) + "</div>";
}
if (injection.lastError) {
html += "<div><b>最后错误:</b>" + escapeHTML(injection.lastError) + "</div>";
}
if (clients.length) {
html += "<table style=\"margin-top:10px;\"><thead><tr><th>client</th><th>PID</th><th>user_id</th><th>状态</th><th>最近事件</th><th>错误</th></tr></thead><tbody>";
html += clients.map(function(client) {
return "<tr>" +
"<td>" + escapeHTML(client.clientId) + "</td>" +
"<td>" + escapeHTML(client.pid || "-") + "</td>" +
"<td>" + escapeHTML(client.userId || "-") + "</td>" +
"<td>" + escapeHTML(client.status || "-") + "</td>" +
"<td>" + escapeHTML(client.lastEvent || "-") + "</td>" +
"<td>" + escapeHTML(client.lastError || "-") + "</td>" +
"</tr>";
}).join("");
html += "</tbody></table>";
}
if (clients.length) {
html += "<div class=\"toolbar\" style=\"margin-top:10px;\">";
html += clients.map(function(client) {
if (client.status === "identified" || client.status === "identifying") return "";
return "<button class=\"secondary\" onclick=\"retryIdentify(" + Number(client.clientId || 0) + ")\">重新识别 client " + escapeHTML(client.clientId) + "</button>" +
"<button class=\"secondary\" onclick=\"probeAccount(" + Number(client.clientId || 0) + ")\">账号探测 client " + escapeHTML(client.clientId) + "</button>";
}).join("");
html += clients.map(function(client) {
if (client.status === "identified" || client.status === "identifying") return "";
const accounts = window.dashboardBindableAccounts || [];
const options = accounts.map(function(account) {
const userId = account.user_id || "";
const label = (account.username || account.nickname || userId || "-") + " / " + userId;
return "<option value=\"" + escapeHTML(userId) + "\">" + escapeHTML(label) + "</option>";
}).join("");
if (!options) return "";
const selectId = "bindUser_" + Number(client.clientId || 0);
return "<select id=\"" + selectId + "\" style=\"max-width:320px;\">" + options + "</select>" +
"<button class=\"secondary\" onclick=\"bindClient(" + Number(client.clientId || 0) + ")\">Bind client " + escapeHTML(client.clientId) + "</button>";
}).join("");
html += "</div>";
html += clients.map(function(client) {
if (!client.lastIdentifyRequestAt && !client.lastIdentifyResponseType && !client.ignoredIdentifyEvents && !client.lastIgnoredIdentifyEvent) return "";
return "<pre>client " + escapeHTML(client.clientId) +
"\nlastIdentifyRequestAt=" + escapeHTML(client.lastIdentifyRequestAt || "-") +
"\nlastIdentifyResponseType=" + escapeHTML(client.lastIdentifyResponseType || "-") +
"\nignoredIdentifyEvents=" + escapeHTML(client.ignoredIdentifyEvents || 0) +
"\nlastIgnoredReason=" + escapeHTML(client.lastIgnoredIdentifyReason || "-") +
"\nlastIgnoredEvent=" + escapeHTML(client.lastIgnoredIdentifyEvent || "-") +
"</pre>";
}).join("");
}
target.innerHTML = html;
}
async function retryIdentify(clientId) {
const buttonResult = document.getElementById("quickResult");
buttonResult.textContent = "正在重新识别 client " + clientId + "...";
try {
const result = await getJSON("/api/debug/clients/" + encodeURIComponent(clientId) + "/identify", {
method: "POST"
});
buttonResult.textContent = pretty(result);
refreshAll();
} catch (error) {
buttonResult.textContent = "重新识别失败: " + error.message;
}
}
async function probeAccount(clientId) {
const buttonResult = document.getElementById("quickResult");
buttonResult.textContent = "正在探测 client " + clientId + ",最多等待 15 秒...";
try {
const result = await getJSON("/api/debug/clients/" + encodeURIComponent(clientId) + "/probe-account", {
method: "POST"
});
buttonResult.textContent = pretty(result);
refreshAll();
} catch (error) {
buttonResult.textContent = "账号探测失败: " + error.message;
}
}
async function bindClient(clientId) {
const buttonResult = document.getElementById("quickResult");
const select = document.getElementById("bindUser_" + Number(clientId || 0));
const userId = select ? select.value : "";
if (!userId) {
buttonResult.textContent = "请选择要绑定的账号";
return;
}
buttonResult.textContent = "Binding client " + clientId + " to " + userId + "...";
try {
const result = await getJSON("/api/debug/clients/" + encodeURIComponent(clientId) + "/bind", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({userId: userId})
});
buttonResult.textContent = pretty(result);
refreshAll();
} catch (error) {
buttonResult.textContent = "Bind failed: " + error.message;
}
}
function renderConfig(config) {
const cfg = config.callbackConfig || config.CallbackConfig || {};
document.getElementById("configView").innerHTML =
"<div><b>回调地址:</b>" + escapeHTML(cfg.callbackUrl || cfg.CallbackURL || "-") + "</div>" +
"<div><b>回调开关:</b>" + escapeHTML(String(cfg.enableCallback !== undefined ? cfg.enableCallback : cfg.EnableCallback)) + "</div>" +
"<div><b>HTTP端口</b>" + escapeHTML(cfg.httpPort || cfg.HTTPPort || "-") + "</div>" +
"<div><b>文件上传:</b>" + escapeHTML(cfg.fileUploadUrl || cfg.FileUploadUrl || "-") + "</div>";
}
function renderTemplates(templates) {
if (templatesLoaded && document.getElementById("templateSelect").options.length) {
return;
}
const select = document.getElementById("templateSelect");
select.innerHTML = templates.map(function(name) {
return "<option value=\"" + escapeHTML(name) + "\">" + escapeHTML(name) + "</option>";
}).join("");
if (templates.indexOf("sendVWorkTextMessage") >= 0) {
select.value = "sendVWorkTextMessage";
}
templatesLoaded = true;
}
async function refreshMessages() {
const data = await getJSON("/api/dashboard/messages?limit=100");
const messages = data.messages || [];
const target = document.getElementById("messages");
if (!messages.length) {
target.innerHTML = "<div class=\"empty\">还没有收到消息</div>";
return;
}
target.innerHTML = messages.map(function(msg) {
const payload = msg.raw !== undefined ? msg.raw : msg.rawText;
return "<div class=\"message\">" +
"<div class=\"message-head\"><span>#" + escapeHTML(msg.id) + " " + escapeHTML(msg.time) + "</span><span>" + escapeHTML(msg.direction) + " / client " + escapeHTML(msg.clientId) + "</span></div>" +
"<div class=\"message-title\">type " + escapeHTML(msg.type || "-") + " · " + escapeHTML(msg.status || "-") + "</div>" +
"<div>" + escapeHTML(msg.summary || "") + "</div>" +
"<pre>" + escapeHTML(pretty(payload)) + "</pre>" +
"</div>";
}).join("");
}
async function refreshAll() {
try {
await refreshState();
await refreshMessages();
} catch (error) {
document.getElementById("refreshState").textContent = "刷新失败:" + error.message;
}
}
async function startWxwork() {
const buttonResult = document.getElementById("quickResult");
buttonResult.textContent = "正在发送启动指令...";
try {
const result = await getJSON("/api/send-wxwork-data", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ clientId: "0", data: JSON.stringify({ type: 10000, data: {} }) })
});
buttonResult.textContent = pretty(result);
refreshAll();
} catch (error) {
buttonResult.textContent = "启动失败:" + error.message;
}
}
function loadExample(type) {
document.getElementById("templateSelect").value = type;
if (type === "sendVWorkTextMessage") {
document.getElementById("paramsInput").value = pretty({
robotId: "",
instanceId: "",
conversationId: "",
message: "测试消息"
});
} else if (type === "getVWorkGroupList") {
document.getElementById("paramsInput").value = pretty({
robotId: "",
instanceId: "",
pageNum: 1,
pageSize: 10
});
}
}
function formatParams() {
const input = document.getElementById("paramsInput");
input.value = pretty(JSON.parse(input.value || "{}"));
}
async function sendTemplate() {
const resultView = document.getElementById("templateResult");
resultView.textContent = "正在调用...";
try {
const type = document.getElementById("templateSelect").value;
const params = JSON.parse(document.getElementById("paramsInput").value || "{}");
const result = await getJSON("/api/third-party-request", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type: type, params: params })
});
resultView.textContent = pretty(result);
refreshAll();
} catch (error) {
resultView.textContent = "调用失败:" + error.message;
}
}
refreshAll();
setInterval(refreshAll, 2500);
</script>
</body>
</html>`