Initial qiwei secondary development handoff

This commit is contained in:
2026-06-23 21:11:20 +08:00
commit 858cb68f4f
207 changed files with 52782 additions and 0 deletions

858
after_sales.go Normal file
View File

@@ -0,0 +1,858 @@
package main
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"mime"
"net/http"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
"github.com/wailsapp/wails/v2/pkg/runtime"
"github.com/xuri/excelize/v2"
)
type AfterSalesIssue struct {
ID string `json:"id"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
ConversationID string `json:"conversationId"`
RoomName string `json:"roomName"`
SourceClientID int32 `json:"sourceClientId"`
SourceAccountUserID string `json:"sourceAccountUserId"`
SourceAccountName string `json:"sourceAccountName"`
CustomerUserID string `json:"customerUserId"`
CustomerName string `json:"customerName"`
IssueContent string `json:"issueContent"`
ImagePaths []string `json:"imagePaths"`
ImageRefs []string `json:"imageRefs"`
FileAttachments []AfterSalesFileAttachment `json:"fileAttachments"`
AISuggestion string `json:"aiSuggestion"`
Status string `json:"status"`
SourceMessageIDs []string `json:"sourceMessageIds"`
Fingerprint string `json:"fingerprint"`
CollectBatchID string `json:"collectBatchId"`
AIConfidence float64 `json:"aiConfidence"`
AISuggestionEdited bool `json:"aiSuggestionEdited"`
AssignedEngineerID string `json:"assignedEngineerId"`
AssignedEngineerName string `json:"assignedEngineerName"`
DispatchStatus string `json:"dispatchStatus"`
DispatchReason string `json:"dispatchReason"`
DispatchRuleID string `json:"dispatchRuleId"`
DispatchConfidence float64 `json:"dispatchConfidence"`
DispatchSource string `json:"dispatchSource"`
NotifyStatus string `json:"notifyStatus"`
LastNotifiedAt int64 `json:"lastNotifiedAt"`
NotifyError string `json:"notifyError"`
NotifyCount int `json:"notifyCount"`
ResolutionContent string `json:"resolutionContent"`
ResolvedAt string `json:"resolvedAt"`
KnowledgeArchivedAt string `json:"knowledgeArchivedAt"`
KnowledgeSourcePath string `json:"knowledgeSourcePath"`
}
type AfterSalesFileAttachment struct {
Name string `json:"name"`
Path string `json:"path"`
Ref string `json:"ref"`
Content string `json:"content"`
ExtractStatus string `json:"extractStatus"`
SourceMessageID string `json:"sourceMessageId"`
}
type AfterSalesHistoryImportRequest struct {
ConversationID string `json:"conversationId"`
RoomName string `json:"roomName"`
RawText string `json:"rawText"`
}
type AfterSalesKnowledgeArchive struct {
ID string `json:"id"`
FileName string `json:"fileName"`
Path string `json:"path"`
CreatedAt string `json:"createdAt"`
IssueCount int `json:"issueCount"`
IssueIDs []string `json:"issueIds,omitempty"`
MissingFile bool `json:"missingFile,omitempty"`
DisplayTime string `json:"displayTime,omitempty"`
}
type AfterSalesKnowledgeCase struct {
IssueID string `json:"issueId"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
ResolvedAt string `json:"resolvedAt"`
KnowledgeArchivedAt string `json:"knowledgeArchivedAt"`
ConversationID string `json:"conversationId"`
RoomName string `json:"roomName"`
CustomerUserID string `json:"customerUserId"`
CustomerName string `json:"customerName"`
IssueContent string `json:"issueContent"`
AISuggestion string `json:"aiSuggestion"`
ResolutionContent string `json:"resolutionContent"`
AssignedEngineerID string `json:"assignedEngineerId"`
AssignedEngineerName string `json:"assignedEngineerName"`
ImageCount int `json:"imageCount"`
MarkdownPath string `json:"markdownPath"`
MissingMarkdown bool `json:"missingMarkdown,omitempty"`
}
type AfterSalesKnowledgeSummary struct {
PendingCount int `json:"pendingCount"`
TotalCount int `json:"totalCount"`
ArchiveCount int `json:"archiveCount"`
}
type afterSalesKnowledgeManifest struct {
Archives []AfterSalesKnowledgeArchive `json:"archives"`
}
// GetIssues returns all locally collected after-sales issues.
func (a *App) GetIssues() []AfterSalesIssue {
issues, err := a.fetchAfterSalesIssues()
if err != nil {
globalLogger.Warn("闂備礁鍚嬮崕鎶藉床閼艰翰浜归柛銉墮閼歌銇勯弬鍨倯闁稿﹦鏁诲濠氬磼閵堝懏鐝濆銈忕秬婵倝骞嗛弮鍫濈闁绘劖鍨濋弶顓㈡煟? %v", err)
return []AfterSalesIssue{}
}
return issues
}
// SaveIssue creates or updates one after-sales issue.
func (a *App) SaveIssue(issue AfterSalesIssue) (bool, string) {
result, err := a.postHelperJSON("/api/after-sales/issues/save", issue)
if err != nil {
return false, err.Error()
}
return helperResultOK(result)
}
// DeleteIssue removes one after-sales issue.
func (a *App) DeleteIssue(id string) (bool, string) {
result, err := a.postHelperJSON("/api/after-sales/issues/delete", map[string]interface{}{"id": id})
if err != nil {
return false, err.Error()
}
return helperResultOK(result)
}
// ResolveAfterSalesIssue marks one issue resolved and saves it as a knowledge case.
func (a *App) ResolveAfterSalesIssue(issueId string, resolutionContent string) interface{} {
result, err := a.postHelperJSON("/api/after-sales/issues/resolve", map[string]interface{}{
"issueId": issueId,
"resolutionContent": resolutionContent,
})
if err != nil {
if result != nil {
return result
}
return map[string]interface{}{"success": false, "message": err.Error()}
}
return result
}
// ListAfterSalesKnowledgeCases returns resolved after-sales knowledge cases.
func (a *App) ListAfterSalesKnowledgeCases() interface{} {
result, err := a.getHelperJSON("/api/after-sales/knowledge/cases")
if err != nil {
return map[string]interface{}{"success": false, "message": err.Error(), "data": []AfterSalesKnowledgeCase{}}
}
return result
}
// UpdateAfterSalesKnowledgeCase updates the final resolution of a knowledge case.
func (a *App) UpdateAfterSalesKnowledgeCase(issueId string, resolutionContent string) interface{} {
result, err := a.postHelperJSON("/api/after-sales/knowledge/cases/update", map[string]interface{}{
"issueId": issueId,
"resolutionContent": resolutionContent,
})
if err != nil {
if result != nil {
return result
}
return map[string]interface{}{"success": false, "message": err.Error()}
}
return result
}
// RevealAfterSalesKnowledgeCase opens the generated Markdown file for one case.
func (a *App) RevealAfterSalesKnowledgeCase(issueId string) (bool, string) {
result, err := a.postHelperJSON("/api/after-sales/knowledge/cases/reveal", map[string]interface{}{"issueId": issueId})
if err != nil {
if result != nil {
return helperResultOK(result)
}
return false, err.Error()
}
return helperResultOK(result)
}
// ExportIssuesToExcel asks the user for an xlsx path and writes the current issues.
func (a *App) ExportIssuesToExcel() (bool, string) {
issues, err := a.fetchAfterSalesIssues()
if err != nil {
return false, err.Error()
}
defaultName := fmt.Sprintf("after_sales_issues_%s.xlsx", time.Now().Format("2006-01-02_1504"))
path, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
Title: "Export after-sales issues",
DefaultFilename: defaultName,
Filters: []runtime.FileFilter{{
DisplayName: "Excel workbook (*.xlsx)",
Pattern: "*.xlsx",
}},
CanCreateDirectories: true,
})
if err != nil {
return false, err.Error()
}
if strings.TrimSpace(path) == "" {
return false, "export canceled"
}
if strings.ToLower(filepath.Ext(path)) != ".xlsx" {
path += ".xlsx"
}
if err := writeAfterSalesIssuesExcel(path, issues); err != nil {
return false, err.Error()
}
return true, path
}
// ListAfterSalesKnowledgeArchives returns all saved after-sales Excel archives.
func (a *App) ListAfterSalesKnowledgeArchives() interface{} {
manifest, err := readAfterSalesKnowledgeManifest()
if err != nil {
return map[string]interface{}{"success": false, "message": err.Error(), "data": []AfterSalesKnowledgeArchive{}}
}
archives := append([]AfterSalesKnowledgeArchive(nil), manifest.Archives...)
for i := range archives {
archives[i].MissingFile = !fileExists(archives[i].Path)
archives[i].DisplayTime = formatAfterSalesExcelTime(archives[i].CreatedAt)
}
sortAfterSalesKnowledgeArchives(archives)
return map[string]interface{}{"success": true, "message": "ok", "data": archives}
}
// GetPendingAfterSalesArchiveSummary reports how many issues have not been saved
// into the local Excel knowledge archive yet.
func (a *App) GetPendingAfterSalesArchiveSummary() interface{} {
issues, err := a.fetchAfterSalesIssues()
if err != nil {
return map[string]interface{}{"success": false, "message": err.Error(), "data": AfterSalesKnowledgeSummary{}}
}
manifest, err := readAfterSalesKnowledgeManifest()
if err != nil {
return map[string]interface{}{"success": false, "message": err.Error(), "data": AfterSalesKnowledgeSummary{}}
}
summary := AfterSalesKnowledgeSummary{
PendingCount: len(filterPendingAfterSalesArchiveIssues(issues, manifest)),
TotalCount: len(issues),
ArchiveCount: len(manifest.Archives),
}
return map[string]interface{}{"success": true, "message": "ok", "data": summary}
}
// ArchivePendingAfterSalesIssues writes one new timestamped Excel archive for
// issues that have not been saved to the knowledge archive yet.
func (a *App) ArchivePendingAfterSalesIssues() interface{} {
archive, archived, err := a.archivePendingAfterSalesIssues()
if err != nil {
return map[string]interface{}{"success": false, "message": err.Error(), "data": archive}
}
if !archived {
return map[string]interface{}{"success": true, "message": "no pending issues", "data": archive}
}
return map[string]interface{}{"success": true, "message": "saved to knowledge archive", "data": archive}
}
func (a *App) confirmArchivePendingAfterSalesBeforeClose(ctx context.Context) bool {
summaryResult := a.GetPendingAfterSalesArchiveSummary()
summaryMap, _ := summaryResult.(map[string]interface{})
if ok, _ := summaryMap["success"].(bool); !ok {
return false
}
summary, ok := summaryMap["data"].(AfterSalesKnowledgeSummary)
if !ok || summary.PendingCount <= 0 {
return false
}
message := fmt.Sprintf("There are %d after-sales issues not saved to the knowledge archive. Save them before exit?", summary.PendingCount)
choice, err := runtime.MessageDialog(ctx, runtime.MessageDialogOptions{
Type: runtime.QuestionDialog,
Title: "Save after-sales issues",
Message: message,
DefaultButton: "Yes",
})
if err != nil || choice != "Yes" {
confirmExit, confirmErr := runtime.MessageDialog(ctx, runtime.MessageDialogOptions{
Type: runtime.QuestionDialog,
Title: "Confirm exit",
Message: "Exit without saving after-sales issues to the knowledge archive? Choose No to return to the app.",
DefaultButton: "No",
})
return confirmErr != nil || confirmExit != "Yes"
}
archive, archived, err := a.archivePendingAfterSalesIssues()
if err != nil {
_, _ = runtime.MessageDialog(ctx, runtime.MessageDialogOptions{
Type: runtime.ErrorDialog,
Title: "Save failed",
Message: "Saving to the knowledge archive failed; exit canceled: " + err.Error(),
})
return true
}
if archived {
globalLogger.Info("Saved after-sales knowledge archive before exit: %s (%d issues)", archive.Path, archive.IssueCount)
}
return false
}
// RevealAfterSalesKnowledgeArchive opens an archive file or the archive folder.
func (a *App) RevealAfterSalesKnowledgeArchive(path string) (bool, string) {
path = strings.Trim(strings.TrimSpace(path), "\"'")
if path == "" {
path = afterSalesKnowledgeDir()
}
if err := os.MkdirAll(afterSalesKnowledgeDir(), 0755); err != nil {
return false, err.Error()
}
if !filepath.IsAbs(path) {
path = filepath.Join(afterSalesKnowledgeDir(), filepath.Clean(path))
}
if !isPathInside(path, afterSalesKnowledgeDir()) {
return false, "can only open files inside the after-sales knowledge directory"
}
target := path
if !fileExists(target) {
target = afterSalesKnowledgeDir()
}
cmd := exec.Command("explorer.exe", target)
if err := cmd.Start(); err != nil {
return false, err.Error()
}
return true, "opened"
}
// RevealAfterSalesAttachment opens a locally saved after-sales attachment.
func (a *App) RevealAfterSalesAttachment(path string) (bool, string) {
path = strings.Trim(strings.TrimSpace(path), "\"'")
if path == "" {
return false, "empty attachment path"
}
if !filepath.IsAbs(path) {
return false, "attachment path must be absolute"
}
if !fileExists(path) {
return false, "attachment file is missing"
}
if !isAllowedAfterSalesAttachmentPath(path) {
return false, "can only open files saved by this app"
}
cmd := exec.Command("explorer.exe", path)
if err := cmd.Start(); err != nil {
return false, err.Error()
}
return true, "opened"
}
// TriggerManualCollect starts one asynchronous after-sales issue collection.
func (a *App) TriggerManualCollect(conversationID string) (bool, string) {
result, err := a.postHelperJSON("/api/after-sales/collect", map[string]interface{}{"conversationId": conversationID})
if err != nil {
if result != nil {
return helperResultOK(result)
}
return false, err.Error()
}
return helperResultOK(result)
}
// ImportAfterSalesHistory imports copied WeCom chat history and runs collection.
func (a *App) ImportAfterSalesHistory(req AfterSalesHistoryImportRequest) (bool, string) {
result, err := a.postHelperJSON("/api/after-sales/import-history", req)
if err != nil {
if result != nil {
return helperResultOK(result)
}
return false, err.Error()
}
return helperResultOK(result)
}
// PrepareWeComHistoryCopy activates WeCom and sends the copy shortcut.
// The frontend owns clipboard reads so the UI can show progress and timeouts.
func (a *App) PrepareWeComHistoryCopy() (bool, string) {
return a.prepareWeComHistoryCopy()
}
// SyncCurrentWeComChatHistory is kept for older frontends. New UI should call
// PrepareWeComHistoryCopy, read clipboard text, then ImportAfterSalesHistory.
func (a *App) SyncCurrentWeComChatHistory(req AfterSalesHistoryImportRequest) (bool, string) {
req.ConversationID = strings.TrimSpace(req.ConversationID)
req.RoomName = strings.TrimSpace(req.RoomName)
if req.ConversationID == "" || strings.EqualFold(req.ConversationID, "all") {
return false, "please select a specific group before syncing history"
}
if req.RawText == "" {
return false, "raw history text is empty"
}
result, err := a.postHelperJSON("/api/after-sales/import-history", req)
if err != nil {
if result != nil {
return helperResultOK(result)
}
return false, err.Error()
}
return helperResultOK(result)
}
// SetAutoCollectTask persists the hourly collection switch.
func (a *App) SetAutoCollectTask(enabled bool) (bool, string) {
result, err := a.postHelperJSON("/api/after-sales/auto-collect", map[string]interface{}{"enabled": enabled})
if err != nil {
return false, err.Error()
}
return helperResultOK(result)
}
// GetAfterSalesIssueStatus returns collection status.
func (a *App) GetAfterSalesIssueStatus() interface{} {
result, err := a.getHelperJSON("/api/after-sales/status")
if err != nil {
return map[string]interface{}{"success": false, "message": err.Error()}
}
return result
}
// GetAfterSalesImageData returns a local after-sales image as a browser-safe data URL.
func (a *App) GetAfterSalesImageData(path string) (string, error) {
path = strings.Trim(strings.TrimSpace(path), "\"'")
if path == "" {
return "", fmt.Errorf("image path is empty")
}
if !filepath.IsAbs(path) {
path = filepath.Clean(path)
}
ext := strings.ToLower(filepath.Ext(path))
switch ext {
case ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp":
default:
return "", fmt.Errorf("unsupported image type: %s", ext)
}
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
if len(data) == 0 {
return "", fmt.Errorf("image file is empty")
}
mimeType := mime.TypeByExtension(ext)
if mimeType == "" {
mimeType = http.DetectContentType(data)
}
if !strings.HasPrefix(mimeType, "image/") {
return "", fmt.Errorf("not an image file")
}
return "data:" + mimeType + ";base64," + base64.StdEncoding.EncodeToString(data), nil
}
func (a *App) fetchAfterSalesIssues() ([]AfterSalesIssue, error) {
result, err := a.getHelperJSON("/api/after-sales/issues")
if err != nil {
return nil, err
}
if ok, _ := result["success"].(bool); !ok {
return nil, fmt.Errorf("%v", result["message"])
}
data, err := json.Marshal(result["data"])
if err != nil {
return nil, err
}
var issues []AfterSalesIssue
if err := json.Unmarshal(data, &issues); err != nil {
return nil, err
}
if issues == nil {
issues = []AfterSalesIssue{}
}
return issues, nil
}
func (a *App) archivePendingAfterSalesIssues() (AfterSalesKnowledgeArchive, bool, error) {
issues, err := a.fetchAfterSalesIssues()
if err != nil {
return AfterSalesKnowledgeArchive{}, false, err
}
manifest, err := readAfterSalesKnowledgeManifest()
if err != nil {
return AfterSalesKnowledgeArchive{}, false, err
}
pending := filterPendingAfterSalesArchiveIssues(issues, manifest)
if len(pending) == 0 {
return AfterSalesKnowledgeArchive{}, false, nil
}
sort.Slice(pending, func(i, j int) bool {
return pending[i].CreatedAt > pending[j].CreatedAt
})
if err := os.MkdirAll(afterSalesKnowledgeDir(), 0755); err != nil {
return AfterSalesKnowledgeArchive{}, false, err
}
now := time.Now().Local()
path := nextAfterSalesKnowledgeExcelPath(now)
if err := writeAfterSalesIssuesExcel(path, pending); err != nil {
return AfterSalesKnowledgeArchive{}, false, err
}
archive := AfterSalesKnowledgeArchive{
ID: strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)),
FileName: filepath.Base(path),
Path: path,
CreatedAt: now.Format(time.RFC3339),
IssueCount: len(pending),
IssueIDs: afterSalesIssueIDs(pending),
DisplayTime: now.Format("2006-01-02 15:04"),
}
manifest.Archives = append(manifest.Archives, archive)
sortAfterSalesKnowledgeArchives(manifest.Archives)
if err := writeAfterSalesKnowledgeManifest(manifest); err != nil {
return AfterSalesKnowledgeArchive{}, false, err
}
return archive, true, nil
}
func filterPendingAfterSalesArchiveIssues(issues []AfterSalesIssue, manifest afterSalesKnowledgeManifest) []AfterSalesIssue {
archived := make(map[string]bool)
for _, archive := range manifest.Archives {
for _, id := range archive.IssueIDs {
id = strings.TrimSpace(id)
if id != "" {
archived[id] = true
}
}
}
pending := make([]AfterSalesIssue, 0)
for _, issue := range issues {
if strings.TrimSpace(issue.ID) == "" || archived[issue.ID] {
continue
}
pending = append(pending, issue)
}
return pending
}
func readAfterSalesKnowledgeManifest() (afterSalesKnowledgeManifest, error) {
var manifest afterSalesKnowledgeManifest
data, err := os.ReadFile(afterSalesKnowledgeManifestPath())
if err != nil {
if os.IsNotExist(err) {
return manifest, nil
}
return manifest, err
}
if len(strings.TrimSpace(string(data))) == 0 {
return manifest, nil
}
if err := json.Unmarshal(data, &manifest); err != nil {
return manifest, err
}
for i := range manifest.Archives {
if manifest.Archives[i].ID == "" {
manifest.Archives[i].ID = strings.TrimSuffix(manifest.Archives[i].FileName, filepath.Ext(manifest.Archives[i].FileName))
}
if manifest.Archives[i].Path == "" && manifest.Archives[i].FileName != "" {
manifest.Archives[i].Path = filepath.Join(afterSalesKnowledgeDir(), manifest.Archives[i].FileName)
}
}
return manifest, nil
}
func writeAfterSalesKnowledgeManifest(manifest afterSalesKnowledgeManifest) error {
if err := os.MkdirAll(afterSalesKnowledgeDir(), 0755); err != nil {
return err
}
data, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return err
}
tmp := afterSalesKnowledgeManifestPath() + ".tmp"
if err := os.WriteFile(tmp, data, 0644); err != nil {
return err
}
return os.Rename(tmp, afterSalesKnowledgeManifestPath())
}
func afterSalesKnowledgeDir() string {
base, err := os.Executable()
if err != nil {
if wd, wdErr := os.Getwd(); wdErr == nil {
base = wd
}
}
if base != "" && filepath.Ext(base) != "" {
base = filepath.Dir(base)
}
return filepath.Join(base, "config", "after_sales_knowledge")
}
func afterSalesKnowledgeManifestPath() string {
return filepath.Join(afterSalesKnowledgeDir(), "manifest.json")
}
func nextAfterSalesKnowledgeExcelPath(now time.Time) string {
baseName := fmt.Sprintf("after_sales_knowledge_%s", now.Format("2006-01-02_1504"))
path := filepath.Join(afterSalesKnowledgeDir(), baseName+".xlsx")
if !fileExists(path) {
return path
}
secondName := fmt.Sprintf("after_sales_knowledge_%s", now.Format("2006-01-02_150405"))
path = filepath.Join(afterSalesKnowledgeDir(), secondName+".xlsx")
if !fileExists(path) {
return path
}
for i := 2; ; i++ {
path = filepath.Join(afterSalesKnowledgeDir(), fmt.Sprintf("%s_%02d.xlsx", secondName, i))
if !fileExists(path) {
return path
}
}
}
func afterSalesIssueIDs(issues []AfterSalesIssue) []string {
ids := make([]string, 0, len(issues))
for _, issue := range issues {
if id := strings.TrimSpace(issue.ID); id != "" {
ids = append(ids, id)
}
}
return ids
}
func sortAfterSalesKnowledgeArchives(archives []AfterSalesKnowledgeArchive) {
sort.Slice(archives, func(i, j int) bool {
if archives[i].CreatedAt != archives[j].CreatedAt {
return archives[i].CreatedAt > archives[j].CreatedAt
}
return archives[i].FileName > archives[j].FileName
})
}
func fileExists(path string) bool {
if strings.TrimSpace(path) == "" {
return false
}
_, err := os.Stat(path)
return err == nil
}
func isPathInside(path string, root string) bool {
absPath, err := filepath.Abs(path)
if err != nil {
return false
}
absRoot, err := filepath.Abs(root)
if err != nil {
return false
}
rel, err := filepath.Rel(absRoot, absPath)
if err != nil {
return false
}
return rel == "." || (!strings.HasPrefix(rel, "..") && !filepath.IsAbs(rel))
}
func isAllowedAfterSalesAttachmentPath(path string) bool {
roots := make([]string, 0, 6)
if wd, err := os.Getwd(); err == nil && strings.TrimSpace(wd) != "" {
roots = append(roots, wd, filepath.Join(wd, "temp"))
}
if exe, err := os.Executable(); err == nil && strings.TrimSpace(exe) != "" {
exeDir := filepath.Dir(exe)
roots = append(roots, exeDir, filepath.Join(exeDir, "temp"), filepath.Join(filepath.Dir(exeDir), "temp"))
}
if tmp := os.TempDir(); strings.TrimSpace(tmp) != "" {
roots = append(roots, tmp)
}
cleaned := filepath.Clean(path)
for _, root := range roots {
if strings.TrimSpace(root) != "" && isPathInside(cleaned, root) {
return true
}
}
return false
}
func helperResultOK(result map[string]interface{}) (bool, string) {
if ok, _ := result["success"].(bool); ok {
if msg := strings.TrimSpace(fmt.Sprint(result["message"])); msg != "" && msg != "<nil>" {
return true, msg
}
return true, "success"
}
msg := strings.TrimSpace(fmt.Sprint(result["message"]))
if msg == "" || msg == "<nil>" {
msg = "operation failed"
}
return false, msg
}
func writeAfterSalesIssuesExcel(path string, issues []AfterSalesIssue) error {
file := excelize.NewFile()
sheet := "AfterSalesIssues"
defaultSheet := file.GetSheetName(0)
if defaultSheet != sheet {
if err := file.SetSheetName(defaultSheet, sheet); err != nil {
return err
}
}
headers := []string{"Created At", "问题来源账号", "Group", "Customer", "Issue Content", "Images", "Files", "File Content", "AI Suggestion", "Status", "Engineer", "Dispatch Status", "Notify Status"}
for i, header := range headers {
cell, _ := excelize.CoordinatesToCellName(i+1, 1)
_ = file.SetCellValue(sheet, cell, header)
}
for row, issue := range issues {
values := []interface{}{
formatAfterSalesExcelTime(issue.CreatedAt),
displayAfterSalesSourceAccount(issue),
issue.RoomName,
displayAfterSalesCustomerName(issue.CustomerName),
issue.IssueContent,
strings.Join(append(append([]string{}, issue.ImagePaths...), issue.ImageRefs...), "\n"),
formatAfterSalesIssueFiles(issue.FileAttachments, false),
formatAfterSalesIssueFiles(issue.FileAttachments, true),
issue.AISuggestion,
afterSalesStatusLabel(issue.Status),
displayAfterSalesEngineerName(issue),
afterSalesDispatchStatusLabel(issue.DispatchStatus),
afterSalesNotifyStatusLabel(issue.NotifyStatus),
}
for col, value := range values {
cell, _ := excelize.CoordinatesToCellName(col+1, row+2)
_ = file.SetCellValue(sheet, cell, value)
}
}
_ = file.SetColWidth(sheet, "A", "A", 18)
_ = file.SetColWidth(sheet, "B", "D", 24)
_ = file.SetColWidth(sheet, "E", "I", 42)
_ = file.SetColWidth(sheet, "J", "M", 14)
return file.SaveAs(path)
}
func formatAfterSalesIssueFiles(files []AfterSalesFileAttachment, includeContent bool) string {
if len(files) == 0 {
return ""
}
lines := make([]string, 0, len(files))
for _, file := range files {
name := strings.TrimSpace(file.Name)
if name == "" {
name = strings.TrimSpace(filepath.Base(file.Path))
}
if name == "" {
name = strings.TrimSpace(file.Ref)
}
if name == "" {
name = "attachment"
}
line := name
if strings.TrimSpace(file.Path) != "" {
line += " | " + strings.TrimSpace(file.Path)
} else if strings.TrimSpace(file.Ref) != "" {
line += " | " + strings.TrimSpace(file.Ref)
}
if strings.TrimSpace(file.ExtractStatus) != "" {
line += " | " + strings.TrimSpace(file.ExtractStatus)
}
if includeContent && strings.TrimSpace(file.Content) != "" {
line += "\n" + truncateAfterSalesExcelText(file.Content, 1200)
}
lines = append(lines, line)
}
return strings.Join(lines, "\n\n")
}
func truncateAfterSalesExcelText(text string, limit int) string {
text = strings.TrimSpace(text)
if limit <= 0 || len([]rune(text)) <= limit {
return text
}
runes := []rune(text)
return string(runes[:limit]) + "..."
}
func displayAfterSalesCustomerName(name string) string {
name = strings.TrimSpace(name)
if name == "" {
return "unknown customer"
}
return name
}
func displayAfterSalesSourceAccount(issue AfterSalesIssue) string {
parts := make([]string, 0, 3)
if name := strings.TrimSpace(issue.SourceAccountName); name != "" {
parts = append(parts, name)
}
if userID := strings.TrimSpace(issue.SourceAccountUserID); userID != "" && userID != strings.TrimSpace(issue.SourceAccountName) {
parts = append(parts, userID)
}
if issue.SourceClientID != 0 {
parts = append(parts, fmt.Sprintf("client %d", issue.SourceClientID))
}
if len(parts) == 0 {
return "-"
}
return strings.Join(parts, " / ")
}
func formatAfterSalesExcelTime(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
if t, err := time.Parse(time.RFC3339, value); err == nil {
return t.Local().Format("2006-01-02 15:04")
}
return value
}
func afterSalesStatusLabel(status string) string {
switch strings.ToLower(strings.TrimSpace(status)) {
case "resolved":
return "resolved"
case "ignored":
return "ignored"
default:
return "pending"
}
}
func displayAfterSalesEngineerName(issue AfterSalesIssue) string {
if strings.TrimSpace(issue.AssignedEngineerName) != "" {
return strings.TrimSpace(issue.AssignedEngineerName)
}
return strings.TrimSpace(issue.AssignedEngineerID)
}
func afterSalesDispatchStatusLabel(status string) string {
switch strings.ToLower(strings.TrimSpace(status)) {
case "assigned":
return "assigned"
case "suggested":
return "suggested"
default:
return "unassigned"
}
}
func afterSalesNotifyStatusLabel(status string) string {
switch strings.ToLower(strings.TrimSpace(status)) {
case "sent":
return "sent"
case "failed":
return "failed"
default:
return "not_sent"
}
}