feat/diary
This commit is contained in:
+269
-209
@@ -11,6 +11,7 @@ import (
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
@@ -21,11 +22,15 @@ import (
|
||||
"github.com/sjzar/chatlog/pkg/util/silk"
|
||||
)
|
||||
|
||||
// EFS holds embedded file system data for static assets.
|
||||
//
|
||||
//go:embed static
|
||||
var EFS embed.FS
|
||||
|
||||
// 统一的 HTML 预览组件片段
|
||||
var previewHTMLSnippet = `
|
||||
<style>#preview{position:fixed;top:60px;left:40px;z-index:9999;display:none;background:#1f2329;border:1px solid #444;padding:4px 4px 8px;border-radius:8px;max-width:720px;max-height:520px;box-shadow:0 4px 16px rgba(0,0,0,0.45);color:#eee;font-size:12px;resize:both;overflow:hidden;}#preview.dragging{opacity:.85;cursor:grabbing;}#preview .pv-header{display:flex;align-items:center;justify-content:space-between;gap:6px;margin:0 2px 4px 2px;font-size:12px;user-select:none;cursor:grab;}#preview .pv-header .title{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:#9ecbff;font-weight:600;}#preview button{background:#2d333b;border:1px solid #555;color:#ddd;font-size:11px;padding:2px 6px;border-radius:4px;cursor:pointer;}#preview button:hover{background:#3a424b}#preview-content{max-width:100%;max-height:470px;overflow:auto;}#preview-content img,#preview-content video{max-width:100%;max-height:470px;display:block;border-radius:4px;}#preview-content audio{width:100%;margin-top:4px;}</style>
|
||||
<div id="preview"><div class="pv-header"><span class="title" id="pv-title">预览</span><button id="pv-pin" title="固定/取消固定">📌</button><button id="pv-close" title="关闭">✕</button></div><div id="preview-content"></div></div>
|
||||
<script>(function(){const pv=document.getElementById('preview');const pvc=document.getElementById('preview-content');const titleEl=document.getElementById('pv-title');const pinBtn=document.getElementById('pv-pin');const closeBtn=document.getElementById('pv-close');let activeLink=null;let hideTimer=null;let pinned=false;let dragState=null;let currentType='';function esc(s){return s.replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"','\'':'''}[c]));}function build(href,text){let label=text||'';label=label.replace(/^[\[]|[\]]$/g,'');currentType='text';if(/\/image\//.test(href)){currentType='image';return '<img src="'+href+'" loading="lazy" />';}if(/\/video\//.test(href)){currentType='video';return '<video src="'+href+'" controls preload="metadata"></video>'; }if(/\/voice\//.test(href)){currentType='audio';return '<audio src="'+href+'" controls preload="metadata"></audio>'; }if(/表情/.test(label)||/\.(gif|apng|webp)(\?|$)/i.test(href)){currentType='emoji';return '<img src="'+href+'" style="max-width:100%;max-height:470px;display:block;" />';}if(/\/file\//.test(href)){currentType='file';return '<div style="word-break:break-all;line-height:1.5;">文件: '+esc(label)+'<br/><a href="'+href+'" target="_blank" style="color:#61afef;">下载</a></div>'; }return '<div style="word-break:break-all;line-height:1.5;">'+esc(label)+'<br/><a href="'+href+'" target="_blank" style="color:#61afef;">打开</a></div>'; }function adjustWidth(){if(dragState)return;const vw=window.innerWidth;const clamp=w=>Math.min(w,vw-40);switch(currentType){case'audio':pv.style.width=clamp(680)+'px';break;case'video':pv.style.width=clamp(720)+'px';break;case'file':pv.style.width=clamp(560)+'px';break;case'image':case'emoji':pv.style.width='auto';break;default:pv.style.width='420px';}}function showFor(a){clearTimeout(hideTimer);activeLink=a;const href=a.getAttribute('href');pvc.innerHTML=build(href,a.textContent||'');titleEl.textContent=a.textContent||'预览';pv.style.display='block';adjustWidth();positionNear(a);}function positionNear(a){if(pinned||dragState)return;const rect=a.getBoundingClientRect();const pw=pv.offsetWidth;const ph=pv.offsetHeight;let x=rect.right+12;let y=rect.top;const vw=window.innerWidth;const vh=window.innerHeight;if(x+pw>vw-8)x=rect.left-pw-12;if(x<8)x=8;if(y+ph>vh-8)y=vh-ph-8;if(y<8)y=8;pv.style.left=x+'px';pv.style.top=y+'px';}function scheduleHide(){if(pinned)return;hideTimer=setTimeout(()=>{if(pinned)return;activeLink=null;pv.style.display='none';pvc.innerHTML='';},280);}document.addEventListener('mouseover',e=>{const a=e.target.closest('a.media');if(!a)return;if(a===activeLink){clearTimeout(hideTimer);return;}showFor(a);});document.addEventListener('mousemove',e=>{if(!activeLink||pinned||dragState)return;positionNear(activeLink);});pv.addEventListener('mouseenter',()=>{clearTimeout(hideTimer);});pv.addEventListener('mouseleave',()=>{scheduleHide();});document.addEventListener('mouseout',e=>{const a=e.target.closest&&e.target.closest('a.media');if(!a)return;if(pv.contains(e.relatedTarget))return;scheduleHide();});pinBtn.addEventListener('click',()=>{pinned=!pinned;pinBtn.style.opacity=pinned?1:0.6;if(!pinned){scheduleHide();}else{clearTimeout(hideTimer);}});closeBtn.addEventListener('click',()=>{pinned=false;activeLink=null;pv.style.display='none';pvc.innerHTML='';});pv.querySelector('.pv-header').addEventListener('mousedown',e=>{if(e.target===pinBtn||e.target===closeBtn)return;pinned=true;pinBtn.style.opacity=1;dragState={ox:e.clientX,oy:e.clientY,left:pv.offsetLeft,top:pv.offsetTop};pv.classList.add('dragging');e.preventDefault();});window.addEventListener('mousemove',e=>{if(!dragState)return;const dx=e.clientX-dragState.ox;const dy=e.clientY-dragState.oy;let nl=dragState.left+dx;let nt=dragState.top+dy;const vw=window.innerWidth;const vh=window.innerHeight;nl=Math.max(0,Math.min(vw-pv.offsetWidth,nl));nt=Math.max(0,Math.min(vh-pv.offsetHeight,nt));pv.style.left=nl+'px';pv.style.top=nt+'px';});window.addEventListener('mouseup',()=>{if(dragState){dragState=null;pv.classList.remove('dragging');}});window.addEventListener('keydown',e=>{if(e.key==='Escape'){pinned=false;pv.style.display='none';pvc.innerHTML='';activeLink=null;}});})();</script>`
|
||||
|
||||
func (s *Service) initRouter() {
|
||||
s.initBaseRouter()
|
||||
s.initMediaRouter()
|
||||
@@ -35,16 +40,19 @@ func (s *Service) initRouter() {
|
||||
|
||||
func (s *Service) initBaseRouter() {
|
||||
staticDir, _ := fs.Sub(EFS, "static")
|
||||
|
||||
s.router.StaticFS("/static", http.FS(staticDir))
|
||||
s.router.StaticFileFS("/favicon.ico", "./favicon.ico", http.FS(staticDir))
|
||||
s.router.StaticFileFS("/", "./index.htm", http.FS(staticDir))
|
||||
|
||||
s.router.GET("/health", func(ctx *gin.Context) {
|
||||
ctx.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
s.router.GET("/health", func(ctx *gin.Context) { ctx.JSON(http.StatusOK, gin.H{"status":"ok"}) })
|
||||
s.router.NoRoute(func(c *gin.Context){
|
||||
path := c.Request.URL.Path
|
||||
if strings.HasPrefix(path,"/api") || strings.HasPrefix(path,"/static") {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error":"Not found"})
|
||||
return
|
||||
}
|
||||
c.Header("Cache-Control","no-cache, no-store, max-age=0, must-revalidate, value")
|
||||
c.Redirect(http.StatusFound, "/")
|
||||
})
|
||||
|
||||
s.router.NoRoute(s.NoRoute)
|
||||
}
|
||||
|
||||
func (s *Service) initMediaRouter() {
|
||||
@@ -62,216 +70,142 @@ func (s *Service) initAPIRouter() {
|
||||
api.GET("/contact", s.handleContacts)
|
||||
api.GET("/chatroom", s.handleChatRooms)
|
||||
api.GET("/session", s.handleSessions)
|
||||
api.GET("/diary", s.handleDiary)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) initMCPRouter() {
|
||||
s.router.Any("/mcp", func(c *gin.Context) {
|
||||
s.mcpStreamableServer.ServeHTTP(c.Writer, c.Request)
|
||||
})
|
||||
s.router.Any("/sse", func(c *gin.Context) {
|
||||
s.mcpSSEServer.ServeHTTP(c.Writer, c.Request)
|
||||
})
|
||||
s.router.Any("/message", func(c *gin.Context) {
|
||||
s.mcpSSEServer.ServeHTTP(c.Writer, c.Request)
|
||||
})
|
||||
}
|
||||
|
||||
// NoRoute handles 404 Not Found errors. If the request URL starts with "/api"
|
||||
// or "/static", it responds with a JSON error. Otherwise, it redirects to the root path.
|
||||
func (s *Service) NoRoute(c *gin.Context) {
|
||||
path := c.Request.URL.Path
|
||||
switch {
|
||||
case strings.HasPrefix(path, "/api"), strings.HasPrefix(path, "/static"):
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Not found"})
|
||||
default:
|
||||
c.Header("Cache-Control", "no-cache, no-store, max-age=0, must-revalidate, value")
|
||||
c.Redirect(http.StatusFound, "/")
|
||||
}
|
||||
s.router.Any("/mcp", func(c *gin.Context) { s.mcpStreamableServer.ServeHTTP(c.Writer, c.Request) })
|
||||
s.router.Any("/sse", func(c *gin.Context) { s.mcpSSEServer.ServeHTTP(c.Writer, c.Request) })
|
||||
s.router.Any("/message", func(c *gin.Context) { s.mcpSSEServer.ServeHTTP(c.Writer, c.Request) })
|
||||
}
|
||||
|
||||
func (s *Service) handleChatlog(c *gin.Context) {
|
||||
q := struct {
|
||||
Time string `form:"time"`
|
||||
Talker string `form:"talker"`
|
||||
Sender string `form:"sender"`
|
||||
Keyword string `form:"keyword"`
|
||||
Limit int `form:"limit"`
|
||||
Offset int `form:"offset"`
|
||||
Format string `form:"format"`
|
||||
}{}
|
||||
|
||||
q := struct {
|
||||
Time string `form:"time"`
|
||||
Talker string `form:"talker"`
|
||||
Sender string `form:"sender"`
|
||||
Keyword string `form:"keyword"`
|
||||
Limit int `form:"limit"`
|
||||
Offset int `form:"offset"`
|
||||
Format string `form:"format"`
|
||||
}{}
|
||||
if err := c.BindQuery(&q); err != nil { errors.Err(c, err); return }
|
||||
|
||||
if err := c.BindQuery(&q); err != nil {
|
||||
errors.Err(c, err)
|
||||
return
|
||||
}
|
||||
start, end, ok := util.TimeRangeOf(q.Time)
|
||||
if !ok { errors.Err(c, errors.InvalidArg("time")) }
|
||||
if q.Limit < 0 { q.Limit = 0 }
|
||||
if q.Offset < 0 { q.Offset = 0 }
|
||||
|
||||
var err error
|
||||
start, end, ok := util.TimeRangeOf(q.Time)
|
||||
if !ok {
|
||||
errors.Err(c, errors.InvalidArg("time"))
|
||||
}
|
||||
if q.Limit < 0 {
|
||||
q.Limit = 0
|
||||
}
|
||||
|
||||
if q.Offset < 0 {
|
||||
q.Offset = 0
|
||||
}
|
||||
|
||||
// 当未提供 talker 时,返回所有会话内消息并按 chatroom(talker) 分组
|
||||
if q.Talker == "" {
|
||||
// 获取所有会话(不分页)
|
||||
sessionsResp, err := s.db.GetSessions("", 0, 0)
|
||||
if err != nil {
|
||||
errors.Err(c, err)
|
||||
// 1. 未指定 talker: 分组输出
|
||||
if q.Talker == "" {
|
||||
sessionsResp, err := s.db.GetSessions("", 0, 0)
|
||||
if err != nil { errors.Err(c, err); return }
|
||||
type grouped struct {
|
||||
Talker string `json:"talker"`
|
||||
TalkerName string `json:"talkerName,omitempty"`
|
||||
Messages []*model.Message `json:"messages"`
|
||||
}
|
||||
groups := make([]*grouped,0)
|
||||
for _, sess := range sessionsResp.Items {
|
||||
msgs, err := s.db.GetMessages(start, end, sess.UserName, q.Sender, q.Keyword, 0, 0)
|
||||
if err != nil || len(msgs)==0 { continue }
|
||||
groups = append(groups, &grouped{Talker:sess.UserName, TalkerName:sess.NickName, Messages:msgs})
|
||||
}
|
||||
switch strings.ToLower(q.Format) {
|
||||
case "html":
|
||||
c.Writer.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
c.Writer.WriteString("<html><head><meta charset=\"utf-8\"><title>Chatlog</title><style>body{font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:1.4;}details{margin:8px 0;padding:4px 8px;border:1px solid #ddd;border-radius:4px; background:#fafafa;}summary{cursor:pointer;font-weight:600;} .msg{margin:4px 0;padding:4px 6px;border-left:3px solid #3498db;background:#fff;} .meta{color:#666;font-size:12px;} pre{white-space:pre-wrap;word-break:break-word;margin:2px 0;} .talker{color:#2c3e50;} .sender{color:#8e44ad;} .time{color:#16a085;} .content{margin-left:4px;} a.media{color:#2c3e50;text-decoration:none;} a.media:hover{text-decoration:underline;}</style></head><body>")
|
||||
c.Writer.WriteString(fmt.Sprintf("<h2>All Messages %s ~ %s</h2>", start.Format("2006-01-02 15:04:05"), end.Format("2006-01-02 15:04:05")))
|
||||
for _, g := range groups {
|
||||
title := g.Talker
|
||||
if g.TalkerName != "" { title = fmt.Sprintf("%s (%s)", g.TalkerName, g.Talker) }
|
||||
c.Writer.WriteString("<details open><summary>" + template.HTMLEscapeString(title) + fmt.Sprintf(" - %d 条消息</summary>", len(g.Messages)))
|
||||
for _, m := range g.Messages {
|
||||
m.SetContent("host", c.Request.Host)
|
||||
senderDisplay := m.Sender
|
||||
if m.IsSelf { senderDisplay = "我" }
|
||||
if m.SenderName != "" { senderDisplay = template.HTMLEscapeString(m.SenderName) + "(" + template.HTMLEscapeString(senderDisplay) + ")" } else { senderDisplay = template.HTMLEscapeString(senderDisplay) }
|
||||
c.Writer.WriteString("<div class=\"msg\"><div class=\"meta\"><span class=\"sender\">"+ senderDisplay +"</span><span class=\"time\">"+ m.Time.Format("2006-01-02 15:04:05") +"</span></div><pre>"+ messageHTMLPlaceholder(m) +"</pre></div>")
|
||||
}
|
||||
c.Writer.WriteString("</details>")
|
||||
}
|
||||
c.Writer.WriteString(previewHTMLSnippet)
|
||||
c.Writer.WriteString("</body></html>")
|
||||
case "json":
|
||||
c.JSON(http.StatusOK, groups)
|
||||
case "csv":
|
||||
c.Writer.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
||||
c.Writer.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=all_%s_%s.csv", start.Format("2006-01-02"), end.Format("2006-01-02")))
|
||||
c.Writer.Header().Set("Cache-Control", "no-cache")
|
||||
c.Writer.Header().Set("Connection", "keep-alive")
|
||||
c.Writer.Flush()
|
||||
csvWriter := csv.NewWriter(c.Writer)
|
||||
csvWriter.Write([]string{"Talker","TalkerName","Time","SenderName","Sender","Content"})
|
||||
for _, g := range groups { for _, m := range g.Messages { csvWriter.Write([]string{g.Talker, g.TalkerName, m.Time.Format("2006-01-02 15:04:05"), m.SenderName, m.Sender, m.PlainTextContent()}) } }
|
||||
csvWriter.Flush()
|
||||
default:
|
||||
c.Writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
c.Writer.Header().Set("Cache-Control", "no-cache")
|
||||
c.Writer.Header().Set("Connection", "keep-alive")
|
||||
c.Writer.Flush()
|
||||
for _, g := range groups {
|
||||
header := g.Talker
|
||||
if g.TalkerName!="" { header = fmt.Sprintf("%s (%s)", g.TalkerName, g.Talker) }
|
||||
c.Writer.WriteString(header+"\n")
|
||||
for _, m := range g.Messages {
|
||||
sender := m.Sender
|
||||
if m.IsSelf { sender = "我" }
|
||||
if m.SenderName!="" { sender = m.SenderName + "("+sender+")" }
|
||||
c.Writer.WriteString(m.Time.Format("2006-01-02 15:04:05")+" "+sender+" "+m.PlainTextContent()+"\n")
|
||||
}
|
||||
c.Writer.WriteString("-----------------------------\n")
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 结果分组结构
|
||||
type grouped struct {
|
||||
Talker string `json:"talker"`
|
||||
TalkerName string `json:"talkerName,omitempty"`
|
||||
Messages []*model.Message `json:"messages"`
|
||||
}
|
||||
|
||||
groups := make([]*grouped, 0)
|
||||
|
||||
// 遍历每个会话获取消息
|
||||
for _, sess := range sessionsResp.Items {
|
||||
msgs, err := s.db.GetMessages(start, end, sess.UserName, q.Sender, q.Keyword, 0, 0)
|
||||
if err != nil || len(msgs) == 0 {
|
||||
continue
|
||||
}
|
||||
g := &grouped{Talker: sess.UserName, TalkerName: sess.NickName, Messages: msgs}
|
||||
groups = append(groups, g)
|
||||
}
|
||||
|
||||
// 2. 指定 talker: 单会话消息
|
||||
messages, err := s.db.GetMessages(start, end, q.Talker, q.Sender, q.Keyword, q.Limit, q.Offset)
|
||||
if err != nil { errors.Err(c, err); return }
|
||||
switch strings.ToLower(q.Format) {
|
||||
case "html":
|
||||
// HTML 输出:分组折叠
|
||||
c.Writer.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
c.Writer.WriteString("<html><head><meta charset=\"utf-8\"><title>Chatlog</title><style>body{font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:1.4;}details{margin:8px 0;padding:4px 8px;border:1px solid #ddd;border-radius:4px; background:#fafafa;}summary{cursor:pointer;font-weight:600;} .msg{margin:4px 0;padding:4px 6px;border-left:3px solid #3498db;background:#fff;} .meta{color:#666;font-size:12px;} pre{white-space:pre-wrap;word-break:break-word;margin:2px 0;} .talker{color:#2c3e50;} .sender{color:#8e44ad;} .time{color:#16a085;} .content{margin-left:4px;} </style></head><body>")
|
||||
c.Writer.WriteString(fmt.Sprintf("<h2>All Messages %s ~ %s</h2>", start.Format("2006-01-02 15:04:05"), end.Format("2006-01-02 15:04:05")))
|
||||
for _, g := range groups {
|
||||
title := g.Talker
|
||||
if g.TalkerName != "" {
|
||||
title = fmt.Sprintf("%s (%s)", g.TalkerName, g.Talker)
|
||||
}
|
||||
c.Writer.WriteString("<details open><summary>" + template.HTMLEscapeString(title) + fmt.Sprintf(" - %d 条消息</summary>", len(g.Messages)))
|
||||
for _, m := range g.Messages {
|
||||
m.SetContent("host", c.Request.Host)
|
||||
c.Writer.WriteString("<div class=\"msg\">")
|
||||
c.Writer.WriteString("<div class=\"meta\"><span class=\"sender\">")
|
||||
c.Writer.WriteString(template.HTMLEscapeString(m.SenderName))
|
||||
if m.SenderName != "" { c.Writer.WriteString("(") }
|
||||
c.Writer.WriteString(template.HTMLEscapeString(m.Sender))
|
||||
if m.SenderName != "" { c.Writer.WriteString(")") }
|
||||
c.Writer.WriteString("</span> <span class=\"time\">")
|
||||
c.Writer.WriteString(m.Time.Format("2006-01-02 15:04:05"))
|
||||
c.Writer.WriteString("</span></div>")
|
||||
c.Writer.WriteString("<pre class=\"content\">")
|
||||
c.Writer.WriteString(renderMarkdownImages(m.PlainTextContent()))
|
||||
c.Writer.WriteString("</pre></div>")
|
||||
}
|
||||
c.Writer.WriteString("</details>")
|
||||
c.Writer.WriteString("<html><head><meta charset=\"utf-8\"><title>Chatlog</title><style>body{font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:1.4;} .msg{margin:8px 0;padding:6px 8px;border-left:3px solid #3498db;background:#fafafa;} .meta{color:#666;font-size:12px;margin-bottom:2px;} pre{white-space:pre-wrap;word-break:break-word;margin:0;} .sender{color:#8e44ad;} .time{color:#16a085;margin-left:6px;} a.media{color:#2c3e50;text-decoration:none;} a.media:hover{text-decoration:underline;}</style></head><body>")
|
||||
c.Writer.WriteString(fmt.Sprintf("<h2>Messages %s ~ %s (%s)</h2>", start.Format("2006-01-02 15:04:05"), end.Format("2006-01-02 15:04:05"), template.HTMLEscapeString(q.Talker)))
|
||||
for _, m := range messages {
|
||||
m.SetContent("host", c.Request.Host)
|
||||
c.Writer.WriteString("<div class=\"msg\"><div class=\"meta\"><span class=\"sender\">")
|
||||
if m.SenderName != "" { c.Writer.WriteString(template.HTMLEscapeString(m.SenderName)+"(") }
|
||||
c.Writer.WriteString(template.HTMLEscapeString(m.Sender))
|
||||
if m.SenderName != "" { c.Writer.WriteString(")") }
|
||||
c.Writer.WriteString("</span><span class=\"time\">"+m.Time.Format("2006-01-02 15:04:05")+"</span></div><pre>")
|
||||
c.Writer.WriteString(messageHTMLPlaceholder(m))
|
||||
c.Writer.WriteString("</pre></div>")
|
||||
}
|
||||
c.Writer.WriteString(previewHTMLSnippet)
|
||||
c.Writer.WriteString("</body></html>")
|
||||
case "json":
|
||||
c.JSON(http.StatusOK, groups)
|
||||
case "csv":
|
||||
c.Writer.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
||||
c.Writer.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=all_%s_%s.csv", start.Format("2006-01-02"), end.Format("2006-01-02")))
|
||||
c.Writer.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s_%s_%s.csv", q.Talker, start.Format("2006-01-02"), end.Format("2006-01-02")))
|
||||
c.Writer.Header().Set("Cache-Control", "no-cache")
|
||||
c.Writer.Header().Set("Connection", "keep-alive")
|
||||
c.Writer.Flush()
|
||||
csvWriter := csv.NewWriter(c.Writer)
|
||||
csvWriter.Write([]string{"Talker", "TalkerName", "Time", "SenderName", "Sender", "Content"})
|
||||
for _, g := range groups {
|
||||
for _, m := range g.Messages {
|
||||
row := m.CSV(c.Request.Host)
|
||||
// 原 CSV: Time, SenderName, Sender, TalkerName, Talker, Content
|
||||
csvWriter.Write([]string{m.Talker, m.TalkerName, row[0], row[1], row[2], row[5]})
|
||||
}
|
||||
}
|
||||
csvWriter.Write([]string{"Time","SenderName","Sender","TalkerName","Talker","Content"})
|
||||
for _, m := range messages { csvWriter.Write(m.CSV(c.Request.Host)) }
|
||||
csvWriter.Flush()
|
||||
case "json":
|
||||
c.JSON(http.StatusOK, messages)
|
||||
default:
|
||||
c.Writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
c.Writer.Header().Set("Cache-Control", "no-cache")
|
||||
c.Writer.Header().Set("Connection", "keep-alive")
|
||||
c.Writer.Flush()
|
||||
for _, g := range groups {
|
||||
c.Writer.WriteString("====================\n")
|
||||
if g.TalkerName != "" {
|
||||
c.Writer.WriteString(fmt.Sprintf("%s (%s)\n", g.TalkerName, g.Talker))
|
||||
} else {
|
||||
c.Writer.WriteString(fmt.Sprintf("%s\n", g.Talker))
|
||||
}
|
||||
c.Writer.WriteString("--------------------\n")
|
||||
for _, m := range g.Messages {
|
||||
c.Writer.WriteString(m.PlainText(true, util.PerfectTimeFormat(start, end), c.Request.Host))
|
||||
c.Writer.WriteString("\n")
|
||||
}
|
||||
c.Writer.Flush()
|
||||
for _, m := range messages {
|
||||
c.Writer.WriteString(m.PlainText(strings.Contains(q.Talker, ","), util.PerfectTimeFormat(start, end), c.Request.Host)+"\n")
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 正常:指定 talker
|
||||
messages, err := s.db.GetMessages(start, end, q.Talker, q.Sender, q.Keyword, q.Limit, q.Offset)
|
||||
if err != nil {
|
||||
errors.Err(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
switch strings.ToLower(q.Format) {
|
||||
case "html":
|
||||
c.Writer.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
c.Writer.WriteString("<html><head><meta charset=\"utf-8\"><title>Chatlog</title><style>body{font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:1.4;} .msg{margin:8px 0;padding:6px 8px;border-left:3px solid #3498db;background:#fafafa;} .meta{color:#666;font-size:12px;margin-bottom:2px;} pre{white-space:pre-wrap;word-break:break-word;margin:0;} .sender{color:#8e44ad;} .time{color:#16a085;margin-left:6px;}</style></head><body>")
|
||||
c.Writer.WriteString(fmt.Sprintf("<h2>Messages %s ~ %s (%s)</h2>", start.Format("2006-01-02 15:04:05"), end.Format("2006-01-02 15:04:05"), template.HTMLEscapeString(q.Talker)))
|
||||
for _, m := range messages {
|
||||
m.SetContent("host", c.Request.Host)
|
||||
c.Writer.WriteString("<div class=\"msg\">")
|
||||
c.Writer.WriteString("<div class=\"meta\"><span class=\"sender\">")
|
||||
c.Writer.WriteString(template.HTMLEscapeString(m.SenderName))
|
||||
if m.SenderName != "" { c.Writer.WriteString("(") }
|
||||
c.Writer.WriteString(template.HTMLEscapeString(m.Sender))
|
||||
if m.SenderName != "" { c.Writer.WriteString(")") }
|
||||
c.Writer.WriteString("</span><span class=\"time\">")
|
||||
c.Writer.WriteString(m.Time.Format("2006-01-02 15:04:05"))
|
||||
c.Writer.WriteString("</span></div>")
|
||||
c.Writer.WriteString("<pre>")
|
||||
c.Writer.WriteString(renderMarkdownImages(m.PlainTextContent()))
|
||||
c.Writer.WriteString("</pre></div>")
|
||||
}
|
||||
c.Writer.WriteString("</body></html>")
|
||||
case "csv":
|
||||
c.Writer.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
||||
c.Writer.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s_%s_%s.csv", q.Talker, start.Format("2006-01-02"), end.Format("2006-01-02")))
|
||||
c.Writer.Header().Set("Cache-Control", "no-cache")
|
||||
c.Writer.Header().Set("Connection", "keep-alive")
|
||||
c.Writer.Flush()
|
||||
|
||||
csvWriter := csv.NewWriter(c.Writer)
|
||||
csvWriter.Write([]string{"Time", "SenderName", "Sender", "TalkerName", "Talker", "Content"})
|
||||
for _, m := range messages {
|
||||
csvWriter.Write(m.CSV(c.Request.Host))
|
||||
}
|
||||
csvWriter.Flush()
|
||||
case "json":
|
||||
c.JSON(http.StatusOK, messages)
|
||||
default:
|
||||
c.Writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
c.Writer.Header().Set("Cache-Control", "no-cache")
|
||||
c.Writer.Header().Set("Connection", "keep-alive")
|
||||
c.Writer.Flush()
|
||||
for _, m := range messages {
|
||||
c.Writer.WriteString(m.PlainText(strings.Contains(q.Talker, ","), util.PerfectTimeFormat(start, end), c.Request.Host))
|
||||
c.Writer.WriteString("\n")
|
||||
c.Writer.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -385,6 +319,33 @@ func (s *Service) handleSessions(c *gin.Context) {
|
||||
}
|
||||
format := strings.ToLower(q.Format)
|
||||
switch format {
|
||||
case "html":
|
||||
c.Writer.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
c.Writer.WriteHeader(http.StatusOK)
|
||||
c.Writer.Write([]byte(`<style>
|
||||
.sessions-wrap{font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:1.5;}
|
||||
.session-item{border:1px solid #ddd;border-radius:6px;padding:8px 10px;margin:8px 0;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.04);}
|
||||
.session-head{font-weight:600;color:#2c3e50;margin-bottom:4px;}
|
||||
.session-head .uname{color:#888;font-weight:400;margin-left:6px;}
|
||||
.session-time{color:#16a085;font-size:12px;margin-left:4px;}
|
||||
.session-content{margin-top:4px;white-space:pre-wrap;word-break:break-word;color:#333;}
|
||||
</style><div class="sessions-wrap">`))
|
||||
for _, session := range sessions.Items {
|
||||
// 转义
|
||||
name := template.HTMLEscapeString(session.NickName)
|
||||
uname := template.HTMLEscapeString(session.UserName)
|
||||
content := template.HTMLEscapeString(session.Content)
|
||||
if len(content) > 400 { // 简单截断,避免过长
|
||||
content = content[:400] + "..."
|
||||
}
|
||||
content = strings.ReplaceAll(content, "\r", "")
|
||||
content = strings.ReplaceAll(content, "\n", "\n") // 让 pre-wrap 生效
|
||||
c.Writer.Write([]byte(`<div class="session-item"><div class="session-head">` + name + `<span class="uname">(` + uname + `)</span><span class="session-time">` + session.NTime.Format("2006-01-02 15:04:05") + `</span></div><div class="session-content">` + content + `</div></div>`))
|
||||
}
|
||||
c.Writer.Write([]byte(`</div>`))
|
||||
c.Writer.Write([]byte(previewHTMLSnippet))
|
||||
c.Writer.Flush()
|
||||
return
|
||||
case "csv":
|
||||
c.Writer.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
||||
c.Writer.Header().Set("Cache-Control", "no-cache")
|
||||
@@ -412,6 +373,95 @@ func (s *Service) handleSessions(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// handleDiary 返回最近24小时内“我”发送的消息,按 talker 分组。
|
||||
// GET /api/v1/diary?format=(html|json|csv|text)
|
||||
func (s *Service) handleDiary(c *gin.Context) {
|
||||
q := struct { Format string `form:"format"` }{}
|
||||
if err := c.BindQuery(&q); err != nil { errors.Err(c, err); return }
|
||||
end := time.Now()
|
||||
start := end.Add(-24 * time.Hour)
|
||||
|
||||
// 获取所有会话
|
||||
sessionsResp, err := s.db.GetSessions("", 0, 0)
|
||||
if err != nil { errors.Err(c, err); return }
|
||||
|
||||
type grouped struct {
|
||||
Talker string `json:"talker"`
|
||||
TalkerName string `json:"talkerName,omitempty"`
|
||||
Messages []*model.Message `json:"messages"`
|
||||
}
|
||||
groups := make([]*grouped,0)
|
||||
|
||||
for _, sess := range sessionsResp.Items {
|
||||
msgs, err := s.db.GetMessages(start, end, sess.UserName, "", "", 0, 0)
|
||||
if err != nil || len(msgs)==0 { continue }
|
||||
hasSelf := false
|
||||
for _, m := range msgs { if m.IsSelf { hasSelf = true; break } }
|
||||
if !hasSelf { continue }
|
||||
groups = append(groups, &grouped{Talker:sess.UserName, TalkerName:sess.NickName, Messages:msgs})
|
||||
}
|
||||
|
||||
format := strings.ToLower(q.Format)
|
||||
switch format {
|
||||
case "html":
|
||||
c.Writer.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
c.Writer.WriteString(`<html><head><meta charset="utf-8"><title>Diary - 24h</title><style>body{font-family:Arial,Helvetica,sans-serif;font-size:14px;}details{margin:8px 0;padding:6px 8px;border:1px solid #ddd;border-radius:6px;background:#fafafa;}summary{cursor:pointer;font-weight:600;} .msg{margin:4px 0;padding:4px 6px;border-left:3px solid #2ecc71;background:#fff;} .meta{color:#666;font-size:12px;margin-bottom:2px;} pre{white-space:pre-wrap;word-break:break-word;margin:0;} .sender{color:#27ae60;} .time{color:#16a085;margin-left:6px;} a.media{color:#2c3e50;text-decoration:none;} a.media:hover{text-decoration:underline;}</style></head><body>`)
|
||||
c.Writer.WriteString(fmt.Sprintf("<h2>最近24小时我参与过的会话全部消息(%s ~ %s)</h2>", start.Format("2006-01-02 15:04:05"), end.Format("2006-01-02 15:04:05")))
|
||||
for _, g := range groups {
|
||||
title := g.Talker
|
||||
if g.TalkerName != "" { title = fmt.Sprintf("%s (%s)", g.TalkerName, g.Talker) }
|
||||
c.Writer.WriteString("<details open><summary>" + template.HTMLEscapeString(title) + fmt.Sprintf(" - %d 条消息</summary>", len(g.Messages)))
|
||||
for _, m := range g.Messages {
|
||||
m.SetContent("host", c.Request.Host)
|
||||
senderDisplay := m.Sender
|
||||
if m.IsSelf { senderDisplay = "我" }
|
||||
if m.SenderName != "" {
|
||||
senderDisplay = template.HTMLEscapeString(m.SenderName) + "(" + template.HTMLEscapeString(senderDisplay) + ")"
|
||||
} else {
|
||||
senderDisplay = template.HTMLEscapeString(senderDisplay)
|
||||
}
|
||||
c.Writer.WriteString("<div class=\"msg\"><div class=\"meta\"><span class=\"sender\">" + senderDisplay + "</span><span class=\"time\">" + m.Time.Format("2006-01-02 15:04:05") + "</span></div><pre>" + messageHTMLPlaceholder(m) + "</pre></div>")
|
||||
}
|
||||
c.Writer.WriteString("</details>")
|
||||
}
|
||||
c.Writer.WriteString(previewHTMLSnippet)
|
||||
c.Writer.WriteString("</body></html>")
|
||||
case "json":
|
||||
c.JSON(http.StatusOK, groups)
|
||||
case "csv":
|
||||
c.Writer.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
||||
c.Writer.Header().Set("Cache-Control", "no-cache")
|
||||
c.Writer.Header().Set("Connection", "keep-alive")
|
||||
c.Writer.Flush()
|
||||
writer := csv.NewWriter(c.Writer)
|
||||
writer.Write([]string{"Talker","TalkerName","Time","SenderName","Sender","Content"})
|
||||
for _, g := range groups { for _, m := range g.Messages { writer.Write([]string{m.Talker, m.TalkerName, m.Time.Format("2006-01-02 15:04:05"), m.SenderName, m.Sender, m.PlainTextContent()}) } }
|
||||
writer.Flush()
|
||||
default:
|
||||
c.Writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
c.Writer.Header().Set("Cache-Control", "no-cache")
|
||||
c.Writer.Header().Set("Connection", "keep-alive")
|
||||
c.Writer.Flush()
|
||||
for _, g := range groups {
|
||||
if g.TalkerName!="" { c.Writer.WriteString(fmt.Sprintf("%s (%s)\n", g.TalkerName, g.Talker)) } else { c.Writer.WriteString(g.Talker+"\n") }
|
||||
for _, m := range g.Messages {
|
||||
senderDisplay := m.Sender
|
||||
if m.IsSelf { senderDisplay = "我" }
|
||||
if m.SenderName != "" {
|
||||
senderDisplay = m.SenderName + "(" + senderDisplay + ")"
|
||||
}
|
||||
c.Writer.WriteString(m.Time.Format("2006-01-02 15:04:05"))
|
||||
c.Writer.WriteString(" ")
|
||||
c.Writer.WriteString(senderDisplay)
|
||||
c.Writer.WriteString(" ")
|
||||
c.Writer.WriteString(m.PlainTextContent())
|
||||
c.Writer.WriteString("\n")
|
||||
}
|
||||
c.Writer.WriteString("-----------------------------\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) handleMedia(c *gin.Context, _type string) {
|
||||
key := strings.TrimPrefix(c.Param("key"), "/")
|
||||
if key == "" {
|
||||
@@ -542,27 +592,37 @@ func (s *Service) HandleVoice(c *gin.Context, data []byte) {
|
||||
c.Data(http.StatusOK, "audio/mp3", out)
|
||||
}
|
||||
|
||||
// Markdown 图片语法转换为 <img>,其余文本保持转义
|
||||
var mdImgPattern = regexp.MustCompile(`!\[([^\]]*)\]\(([^)]+)\)`)
|
||||
// 统一占位符:将 PlainTextContent 里形如  或 [标签](url) 的模式转成超链接形式,仅显示 [标签]。
|
||||
var placeholderPattern = regexp.MustCompile(`!?\[([^\]]+)\]\((https?://[^)]+)\)`)
|
||||
|
||||
func renderMarkdownImages(src string) string {
|
||||
if src == "" {
|
||||
return ""
|
||||
}
|
||||
matches := mdImgPattern.FindAllStringSubmatchIndex(src, -1)
|
||||
if len(matches) == 0 {
|
||||
return template.HTMLEscapeString(src)
|
||||
}
|
||||
var b strings.Builder
|
||||
last := 0
|
||||
for _, m := range matches {
|
||||
// m: full, alt, url indices
|
||||
b.WriteString(template.HTMLEscapeString(src[last:m[0]]))
|
||||
alt := template.HTMLEscapeString(src[m[2]:m[3]])
|
||||
url := template.HTMLEscapeString(src[m[4]:m[5]])
|
||||
b.WriteString(`<img src="` + url + `" alt="` + alt + `" loading="lazy"/>`)
|
||||
last = m[1]
|
||||
}
|
||||
b.WriteString(template.HTMLEscapeString(src[last:]))
|
||||
return b.String()
|
||||
func messageHTMLPlaceholder(m *model.Message) string {
|
||||
content := m.PlainTextContent()
|
||||
return placeholderPattern.ReplaceAllStringFunc(content, func(s string) string {
|
||||
matches := placeholderPattern.FindStringSubmatch(s)
|
||||
if len(matches) != 3 { return template.HTMLEscapeString(s) }
|
||||
fullLabel := matches[1]
|
||||
url := matches[2]
|
||||
left := fullLabel
|
||||
rest := ""
|
||||
if p := strings.Index(fullLabel, "|") ; p >= 0 {
|
||||
left = fullLabel[:p]
|
||||
rest = fullLabel[p+1:]
|
||||
}
|
||||
className := "media"
|
||||
if left == "动画表情" || left == "GIF表情" || strings.Contains(left, "表情") {
|
||||
className = "media anim"
|
||||
}
|
||||
var anchorText string
|
||||
if left == "链接" { // 保留完整形式 [链接|标题\n更多说明]
|
||||
escapedFull := template.HTMLEscapeString(fullLabel)
|
||||
escapedFull = strings.ReplaceAll(escapedFull, "\r", "")
|
||||
escapedFull = strings.ReplaceAll(escapedFull, "\n", "<br/>")
|
||||
anchorText = "[" + escapedFull + "]"
|
||||
} else if left == "文件" && rest != "" { // 文件保留文件名
|
||||
anchorText = "[文件]" + template.HTMLEscapeString(rest)
|
||||
} else {
|
||||
anchorText = "[" + template.HTMLEscapeString(left) + "]"
|
||||
}
|
||||
return `<a class="` + className + `" href="` + template.HTMLEscapeString(url) + `" target="_blank">` + anchorText + `</a>`
|
||||
})
|
||||
}
|
||||
|
||||
@@ -350,6 +350,7 @@
|
||||
<div class="tab" data-tab="chatroom">群聊</div>
|
||||
<div class="tab" data-tab="contact">联系人</div>
|
||||
<div class="tab" data-tab="chatlog">聊天记录</div>
|
||||
<div class="tab" data-tab="diary">日记</div>
|
||||
</div>
|
||||
|
||||
<!-- 会话查询表单 -->
|
||||
@@ -371,6 +372,33 @@
|
||||
<option value="">默认</option>
|
||||
<option value="json">JSON</option>
|
||||
<option value="text">纯文本</option>
|
||||
<option value="html">HTML</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 日记查询表单 -->
|
||||
<div class="tab-content" id="diary-tab">
|
||||
<div class="api-description">
|
||||
<p>
|
||||
查询最近24小时内“我”发送的消息按会话分组。<span
|
||||
class="badge"
|
||||
>GET /api/v1/diary</span
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="diary-format"
|
||||
>输出格式:<span class="optional-param"
|
||||
>可选</span
|
||||
></label
|
||||
>
|
||||
<select id="diary-format">
|
||||
<option value="">默认</option>
|
||||
<option value="json">JSON</option>
|
||||
<option value="text">纯文本</option>
|
||||
<option value="html">HTML</option>
|
||||
<option value="csv">CSV</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -755,6 +783,15 @@
|
||||
if (sessionFormat)
|
||||
params.append("format", sessionFormat);
|
||||
break;
|
||||
case "diary":
|
||||
url += "diary";
|
||||
const diaryFormat =
|
||||
document.getElementById(
|
||||
"diary-format"
|
||||
).value;
|
||||
if (diaryFormat)
|
||||
params.append("format", diaryFormat);
|
||||
break;
|
||||
}
|
||||
|
||||
// 添加参数到URL
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"strconv"
|
||||
|
||||
"github.com/sjzar/chatlog/pkg/util"
|
||||
)
|
||||
@@ -366,6 +367,16 @@ func (m *Message) PlainTextContent() string {
|
||||
return fmt.Sprintf("", m.Contents["host"], strings.Join(keylist, ","))
|
||||
case MessageTypeVoice:
|
||||
if voice, ok := m.Contents["voice"]; ok {
|
||||
// 可选时长字段(可能来源于不同表:voicelength/voiceduration/length 秒)
|
||||
var durStr string
|
||||
if d, ok2 := m.Contents["voicelength"]; ok2 { durStr = fmt.Sprint(d) }
|
||||
if d, ok2 := m.Contents["voiceduration"]; ok2 { durStr = fmt.Sprint(d) }
|
||||
if d, ok2 := m.Contents["length"]; ok2 && durStr=="" { durStr = fmt.Sprint(d) }
|
||||
if durStr != "" {
|
||||
// 规范成整数秒显示
|
||||
if strings.Contains(durStr, ".") { if f,err:=strconv.ParseFloat(durStr,64); err==nil { durStr = fmt.Sprint(int(f+0.5)) } }
|
||||
return fmt.Sprintf("[语音(%ss)](http://%s/voice/%s)", durStr, m.Contents["host"], voice)
|
||||
}
|
||||
return fmt.Sprintf("[语音](http://%s/voice/%s)", m.Contents["host"], voice)
|
||||
}
|
||||
return "[语音]"
|
||||
|
||||
Reference in New Issue
Block a user