2026-08-14 07:52:01 +08:00
|
|
|
|
package main
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"fmt"
|
|
|
|
|
|
"strconv"
|
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
|
|
"github.com/wailsapp/wails/v3/pkg/services/notifications"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// ---------- Todo 绑定 ----------
|
|
|
|
|
|
|
|
|
|
|
|
func (a *App) ListTodos(status string, projectID int64) ([]Todo, error) {
|
|
|
|
|
|
if e := a.ready(); e != nil {
|
|
|
|
|
|
return nil, e
|
|
|
|
|
|
}
|
|
|
|
|
|
return a.store.ListTodos(status, projectID)
|
|
|
|
|
|
}
|
|
|
|
|
|
func (a *App) SaveTodo(t Todo) (Todo, error) {
|
|
|
|
|
|
if e := a.ready(); e != nil {
|
|
|
|
|
|
return t, e
|
|
|
|
|
|
}
|
|
|
|
|
|
return a.store.SaveTodo(t)
|
|
|
|
|
|
}
|
|
|
|
|
|
func (a *App) SetTodoStatus(id int64, status string) error {
|
|
|
|
|
|
if e := a.ready(); e != nil {
|
|
|
|
|
|
return e
|
|
|
|
|
|
}
|
|
|
|
|
|
return a.store.SetTodoStatus(id, status)
|
|
|
|
|
|
}
|
|
|
|
|
|
func (a *App) DeleteTodo(id int64) error {
|
|
|
|
|
|
if e := a.ready(); e != nil {
|
|
|
|
|
|
return e
|
|
|
|
|
|
}
|
|
|
|
|
|
return a.store.DeleteTodo(id)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------- 工单绑定 ----------
|
|
|
|
|
|
|
|
|
|
|
|
func (a *App) ListTickets(status string, projectID int64) ([]Ticket, error) {
|
|
|
|
|
|
if e := a.ready(); e != nil {
|
|
|
|
|
|
return nil, e
|
|
|
|
|
|
}
|
|
|
|
|
|
return a.store.ListTickets(status, projectID)
|
|
|
|
|
|
}
|
|
|
|
|
|
func (a *App) SaveTicket(t Ticket) (Ticket, error) {
|
|
|
|
|
|
if e := a.ready(); e != nil {
|
|
|
|
|
|
return t, e
|
|
|
|
|
|
}
|
|
|
|
|
|
return a.store.SaveTicket(t)
|
|
|
|
|
|
}
|
|
|
|
|
|
func (a *App) SetTicketStatus(id int64, status string) error {
|
|
|
|
|
|
if e := a.ready(); e != nil {
|
|
|
|
|
|
return e
|
|
|
|
|
|
}
|
|
|
|
|
|
return a.store.SetTicketStatus(id, status)
|
|
|
|
|
|
}
|
|
|
|
|
|
func (a *App) DeleteTicket(id int64) error {
|
|
|
|
|
|
if e := a.ready(); e != nil {
|
|
|
|
|
|
return e
|
|
|
|
|
|
}
|
|
|
|
|
|
return a.store.DeleteTicket(id)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------- 记事本与收藏 ----------
|
|
|
|
|
|
|
|
|
|
|
|
func (a *App) GetNote() (Note, error) {
|
|
|
|
|
|
if e := a.ready(); e != nil {
|
|
|
|
|
|
return Note{}, e
|
|
|
|
|
|
}
|
|
|
|
|
|
return a.store.GetNote()
|
|
|
|
|
|
}
|
|
|
|
|
|
func (a *App) SaveNote(content string) (Note, error) {
|
|
|
|
|
|
if e := a.ready(); e != nil {
|
|
|
|
|
|
return Note{}, e
|
|
|
|
|
|
}
|
|
|
|
|
|
return a.store.SaveNote(content)
|
|
|
|
|
|
}
|
|
|
|
|
|
func (a *App) ListNotes(limit int) ([]Note, error) {
|
|
|
|
|
|
if e := a.ready(); e != nil {
|
|
|
|
|
|
return nil, e
|
|
|
|
|
|
}
|
|
|
|
|
|
return a.store.ListNotes(limit)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// SaveNoteByID 保存指定笔记;id=0 新建。
|
|
|
|
|
|
func (a *App) SaveNoteByID(id int64, content string) (Note, error) {
|
|
|
|
|
|
if e := a.ready(); e != nil {
|
|
|
|
|
|
return Note{}, e
|
|
|
|
|
|
}
|
|
|
|
|
|
return a.store.SaveNoteByID(id, content)
|
|
|
|
|
|
}
|
|
|
|
|
|
func (a *App) DeleteNote(id int64) error {
|
|
|
|
|
|
if e := a.ready(); e != nil {
|
|
|
|
|
|
return e
|
|
|
|
|
|
}
|
|
|
|
|
|
return a.store.DeleteNote(id)
|
|
|
|
|
|
}
|
|
|
|
|
|
func (a *App) ListFavorites() ([]int64, error) {
|
|
|
|
|
|
if e := a.ready(); e != nil {
|
|
|
|
|
|
return nil, e
|
|
|
|
|
|
}
|
|
|
|
|
|
return a.store.ListFavorites()
|
|
|
|
|
|
}
|
|
|
|
|
|
func (a *App) ToggleFavorite(projectID int64) (bool, error) {
|
|
|
|
|
|
if e := a.ready(); e != nil {
|
|
|
|
|
|
return false, e
|
|
|
|
|
|
}
|
|
|
|
|
|
return a.store.ToggleFavorite(projectID)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------- 消息中心 ----------
|
|
|
|
|
|
|
|
|
|
|
|
func (a *App) ListMessages(limit int64) ([]Message, error) {
|
|
|
|
|
|
if e := a.ready(); e != nil {
|
|
|
|
|
|
return nil, e
|
|
|
|
|
|
}
|
|
|
|
|
|
return a.store.ListMessages(limit)
|
|
|
|
|
|
}
|
|
|
|
|
|
func (a *App) UnreadMessageCount() (int64, error) {
|
|
|
|
|
|
if e := a.ready(); e != nil {
|
|
|
|
|
|
return 0, e
|
|
|
|
|
|
}
|
|
|
|
|
|
return a.store.UnreadMessageCount(), nil
|
|
|
|
|
|
}
|
|
|
|
|
|
func (a *App) MarkMessageRead(id int64) error {
|
|
|
|
|
|
if e := a.ready(); e != nil {
|
|
|
|
|
|
return e
|
|
|
|
|
|
}
|
|
|
|
|
|
return a.store.MarkMessageRead(id)
|
|
|
|
|
|
}
|
|
|
|
|
|
func (a *App) MarkAllMessagesRead() error {
|
|
|
|
|
|
if e := a.ready(); e != nil {
|
|
|
|
|
|
return e
|
|
|
|
|
|
}
|
|
|
|
|
|
return a.store.MarkAllMessagesRead()
|
|
|
|
|
|
}
|
|
|
|
|
|
func (a *App) ClearMessages() error {
|
|
|
|
|
|
if e := a.ready(); e != nil {
|
|
|
|
|
|
return e
|
|
|
|
|
|
}
|
|
|
|
|
|
return a.store.ClearMessages()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// pushMessage 写入消息中心、广播给前端,并可选发送系统通知。
|
|
|
|
|
|
func (a *App) pushMessage(kind, title, body, sourceType string, sourceID int64, notify bool) {
|
|
|
|
|
|
if a.store == nil {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
m, e := a.store.AddMessage(kind, title, body, sourceType, sourceID)
|
|
|
|
|
|
if e != nil {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
a.emit("message:new", m)
|
2026-08-14 17:54:06 +08:00
|
|
|
|
a.RefreshTrayStatus()
|
2026-08-14 07:52:01 +08:00
|
|
|
|
if notify && a.notifier != nil {
|
2026-08-14 17:54:06 +08:00
|
|
|
|
path := "/messages"
|
|
|
|
|
|
switch sourceType {
|
|
|
|
|
|
case "todo":
|
|
|
|
|
|
path = "/todos"
|
|
|
|
|
|
case "ticket":
|
|
|
|
|
|
path = "/tickets"
|
|
|
|
|
|
case "team":
|
|
|
|
|
|
path = "/team/tasks"
|
|
|
|
|
|
}
|
2026-08-14 07:52:01 +08:00
|
|
|
|
_ = a.notifier.SendNotification(notifications.NotificationOptions{
|
|
|
|
|
|
ID: fmt.Sprintf("cc-%s-%d", kind, m.ID),
|
|
|
|
|
|
Title: title,
|
|
|
|
|
|
Body: body,
|
2026-08-14 17:54:06 +08:00
|
|
|
|
Data: map[string]interface{}{"path": path, "kind": kind, "sourceType": sourceType, "sourceId": sourceID},
|
2026-08-14 07:52:01 +08:00
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------- 到期巡检 ----------
|
|
|
|
|
|
|
|
|
|
|
|
// parseDue 解析待办/工单的排期时间(本地时区),支持日期或日期+时间。
|
|
|
|
|
|
func parseDue(v string) (time.Time, bool) {
|
|
|
|
|
|
for _, layout := range []string{"2006-01-02T15:04", "2006-01-02 15:04", "2006-01-02"} {
|
|
|
|
|
|
if t, e := time.ParseInLocation(layout, v, time.Local); e == nil {
|
|
|
|
|
|
// 纯日期视为当天 23:59 截止
|
|
|
|
|
|
if layout == "2006-01-02" {
|
|
|
|
|
|
t = t.Add(23*time.Hour + 59*time.Minute)
|
|
|
|
|
|
}
|
|
|
|
|
|
return t, true
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return time.Time{}, false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// runReminderLoop 每分钟巡检到期 todo/工单:24 小时内到期与已逾期各提醒一次。
|
|
|
|
|
|
func (a *App) runReminderLoop() {
|
|
|
|
|
|
t := time.NewTicker(time.Minute)
|
|
|
|
|
|
defer t.Stop()
|
|
|
|
|
|
for {
|
|
|
|
|
|
select {
|
|
|
|
|
|
case <-a.ctx.Done():
|
|
|
|
|
|
return
|
|
|
|
|
|
case <-t.C:
|
|
|
|
|
|
a.checkDueReminders()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
|
remindedSoon = 1
|
|
|
|
|
|
remindedOverdue = 2
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// reminderTexts 返回按当前语言本地化的提醒文案:[todo 逾期, todo 将到期, 工单逾期, 工单将到期, "截止"前缀]。
|
|
|
|
|
|
func (a *App) reminderTexts() [5]string {
|
|
|
|
|
|
locale := "zh-CN"
|
|
|
|
|
|
if st, e := a.store.Settings(); e == nil {
|
|
|
|
|
|
locale = st.Locale
|
|
|
|
|
|
}
|
|
|
|
|
|
if locale == "en" {
|
|
|
|
|
|
return [5]string{"Todo overdue", "Todo due soon", "Ticket overdue", "Ticket due soon", "due"}
|
|
|
|
|
|
}
|
|
|
|
|
|
return [5]string{"待办已逾期", "待办即将到期", "工单已逾期", "工单即将到期", "截止"}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (a *App) checkDueReminders() {
|
|
|
|
|
|
if a.store == nil || a.bootstrap.State != BootstrapReady {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
txt := a.reminderTexts()
|
|
|
|
|
|
now := time.Now()
|
|
|
|
|
|
type row struct {
|
|
|
|
|
|
id int64
|
|
|
|
|
|
title string
|
|
|
|
|
|
due string
|
|
|
|
|
|
reminded int64
|
|
|
|
|
|
}
|
|
|
|
|
|
scan := func(query string) []row {
|
|
|
|
|
|
rows, e := a.store.db.Query(query)
|
|
|
|
|
|
if e != nil {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
defer rows.Close()
|
|
|
|
|
|
out := []row{}
|
|
|
|
|
|
for rows.Next() {
|
|
|
|
|
|
var x row
|
|
|
|
|
|
if rows.Scan(&x.id, &x.title, &x.due, &x.reminded) == nil {
|
|
|
|
|
|
out = append(out, x)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return out
|
|
|
|
|
|
}
|
|
|
|
|
|
for _, x := range scan(`SELECT id,title,due_at,reminded FROM todos WHERE deleted=0 AND status!='done' AND due_at!=''`) {
|
|
|
|
|
|
due, ok := parseDue(x.due)
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if now.After(due) && x.reminded&remindedOverdue == 0 {
|
|
|
|
|
|
a.pushMessage("todo_due", txt[0], x.title+" ("+txt[4]+" "+x.due+")", "todo", x.id, true)
|
|
|
|
|
|
_, _ = a.store.db.Exec(`UPDATE todos SET reminded=reminded|? WHERE id=?`, remindedOverdue, x.id)
|
|
|
|
|
|
} else if !now.After(due) && due.Sub(now) <= 24*time.Hour && x.reminded&remindedSoon == 0 {
|
|
|
|
|
|
a.pushMessage("todo_due", txt[1], x.title+" ("+txt[4]+" "+x.due+")", "todo", x.id, true)
|
|
|
|
|
|
_, _ = a.store.db.Exec(`UPDATE todos SET reminded=reminded|? WHERE id=?`, remindedSoon, x.id)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
for _, x := range scan(`SELECT id,title,due_at,reminded FROM tickets WHERE deleted=0 AND status IN ('open','in_progress') AND due_at!=''`) {
|
|
|
|
|
|
due, ok := parseDue(x.due)
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if now.After(due) && x.reminded&remindedOverdue == 0 {
|
|
|
|
|
|
a.pushMessage("ticket_due", txt[2], x.title+" ("+txt[4]+" "+x.due+")", "ticket", x.id, true)
|
|
|
|
|
|
_, _ = a.store.db.Exec(`UPDATE tickets SET reminded=reminded|? WHERE id=?`, remindedOverdue, x.id)
|
|
|
|
|
|
} else if !now.After(due) && due.Sub(now) <= 24*time.Hour && x.reminded&remindedSoon == 0 {
|
|
|
|
|
|
a.pushMessage("ticket_due", txt[3], x.title+" ("+txt[4]+" "+x.due+")", "ticket", x.id, true)
|
|
|
|
|
|
_, _ = a.store.db.Exec(`UPDATE tickets SET reminded=reminded|? WHERE id=?`, remindedSoon, x.id)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------- 导航徽标 ----------
|
|
|
|
|
|
|
|
|
|
|
|
// NavBadges 左侧导航徽标数据(一次查询打包返回)。
|
|
|
|
|
|
type NavBadges struct {
|
|
|
|
|
|
Unread int `json:"unread"` // 消息中心:未读
|
|
|
|
|
|
TodosOpen int `json:"todosOpen"` // 待办事项:未完成
|
|
|
|
|
|
TicketsActive int `json:"ticketsActive"` // 需求工单:开放 + 进行中
|
|
|
|
|
|
TodayDue int `json:"todayDue"` // 今日任务:逾期 + 今日截止(未完成)
|
|
|
|
|
|
CalendarDot bool `json:"calendarDot"` // 排期日历:今天有到期事项
|
|
|
|
|
|
TeamAssigned int `json:"teamAssigned"` // 团队任务:指派给我未完成(同步时缓存)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (a *App) GetNavBadges() (NavBadges, error) {
|
|
|
|
|
|
if e := a.ready(); e != nil {
|
|
|
|
|
|
return NavBadges{}, e
|
|
|
|
|
|
}
|
|
|
|
|
|
var b NavBadges
|
|
|
|
|
|
_ = a.store.db.QueryRow(`SELECT COUNT(*) FROM messages WHERE is_read=0`).Scan(&b.Unread)
|
|
|
|
|
|
_ = a.store.db.QueryRow(`SELECT COUNT(*) FROM todos WHERE deleted=0 AND status IN ('open','doing')`).Scan(&b.TodosOpen)
|
|
|
|
|
|
_ = a.store.db.QueryRow(`SELECT COUNT(*) FROM tickets WHERE deleted=0 AND status IN ('open','in_progress')`).Scan(&b.TicketsActive)
|
|
|
|
|
|
b.TodayDue = a.countDueToday()
|
|
|
|
|
|
b.CalendarDot = b.TodayDue > 0
|
|
|
|
|
|
if a.syncUserID() > 0 {
|
|
|
|
|
|
b.TeamAssigned, _ = strconv.Atoi(a.store.Meta("team_assigned_cache"))
|
|
|
|
|
|
}
|
|
|
|
|
|
return b, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// countDueToday 统计逾期与今日截止的未完成待办/工单。
|
|
|
|
|
|
func (a *App) countDueToday() int {
|
|
|
|
|
|
now := time.Now()
|
|
|
|
|
|
end := time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, now.Location())
|
|
|
|
|
|
n := 0
|
|
|
|
|
|
for _, q := range []string{
|
|
|
|
|
|
`SELECT due_at FROM todos WHERE deleted=0 AND status!='done' AND due_at!=''`,
|
|
|
|
|
|
`SELECT due_at FROM tickets WHERE deleted=0 AND status IN ('open','in_progress') AND due_at!=''`,
|
|
|
|
|
|
} {
|
|
|
|
|
|
rows, e := a.store.db.Query(q)
|
|
|
|
|
|
if e != nil {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
for rows.Next() {
|
|
|
|
|
|
var v string
|
|
|
|
|
|
if rows.Scan(&v) != nil {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if due, ok := parseDue(v); ok && !due.After(end) {
|
|
|
|
|
|
n++
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
rows.Close()
|
|
|
|
|
|
}
|
|
|
|
|
|
return n
|
|
|
|
|
|
}
|