1044 lines
33 KiB
Go
1044 lines
33 KiB
Go
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, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """)
|
||
.replace(/'/g, "'");
|
||
}
|
||
|
||
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>`
|