859 lines
28 KiB
Go
859 lines
28 KiB
Go
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"
|
|
}
|
|
}
|