feat: implement SMS management features including frontend UI and backend API handlers

This commit is contained in:
MengMengCode
2026-08-20 15:35:10 +08:00
parent 53345d2915
commit 4f3f37ba7c
4 changed files with 179 additions and 152 deletions
+10 -4
View File
@@ -101,10 +101,16 @@ func (s *Server) handleSMSThread(w http.ResponseWriter, r *http.Request) {
s.writeStoreError(w, err)
return
}
for _, message := range messages {
if !message.Read && (message.Direction == "inbound" || message.Direction == "received") {
message.Read = true
_, _ = s.store.SaveSMSMessage(r.Context(), message)
unreadIDs := make([]int64, 0, len(messages))
for i := range messages {
if !messages[i].Read && (messages[i].Direction == "inbound" || messages[i].Direction == "received") {
messages[i].Read = true
unreadIDs = append(unreadIDs, messages[i].ID)
}
}
if len(unreadIDs) > 0 {
if markErr := s.store.MarkSMSMessagesRead(r.Context(), unreadIDs); markErr != nil {
s.logger.Warn("mark SMS messages read failed", "error", markErr)
}
}
reverseSMS(messages)
+14
View File
@@ -579,6 +579,20 @@ func TestSMSPersistenceAndDerivedThreads(t *testing.T) {
if len(contacts) != 1 || contacts[0].UnreadCount != 0 {
t.Fatalf("thread should be read: %+v", contacts)
}
// A subsequent periodic modem AT sync with raw unread state must not revert is_read back to 0.
if _, err := database.SaveSMSMessage(ctx, SMSMessage{
MessageID: "network-1", DeviceID: "ec20-1", IMSI: "46000",
Peer: "10086", Direction: "inbound", Body: "第一条(完整)",
Timestamp: base, Status: "received", Read: false,
}); err != nil {
t.Fatal(err)
}
contacts, err = database.ListSMSContacts(ctx, SMSFilter{Peer: "10086"})
if err != nil || len(contacts) != 1 || contacts[0].UnreadCount != 0 {
t.Fatalf("thread read state must survive modem rescan: %+v", contacts)
}
deleted, err := database.DeleteSMSThread(ctx, "ec20-1", "46000", "10086")
if err != nil || deleted != 2 {
t.Fatalf("DeleteSMSThread() = %d, %v", deleted, err)
+34 -9
View File
@@ -92,15 +92,16 @@ func saveSMSMessage(
if mergeErr != nil {
return SMSMessage{}, fmt.Errorf("merge concatenated SMS segment: %w", mergeErr)
}
if existingErr == nil && !changed {
// This segment is already folded into the stored row (a periodic modem
// rescan redelivers every segment). Leave the row untouched so the
// durable id stays put and Telegram does not re-notify.
return existing, nil
}
value.Body = mergedBody
extra = mergedExtra
if existingErr == nil {
if !changed {
if value.Read != existing.Read {
if _, err := executor.ExecContext(ctx, `UPDATE sms_messages SET is_read = ?, updated_at = ? WHERE id = ?`, boolInt(value.Read), now.Unix(), existing.ID); err != nil {
return SMSMessage{}, fmt.Errorf("update concatenated SMS read state: %w", err)
}
existing.Read = value.Read
}
return existing, nil
}
// A new segment advanced the message. Replace the stale partial row so
// the merged row receives a fresh durable id; the Telegram id-cursor
// then surfaces the now-more-complete message exactly once. Carry
@@ -116,6 +117,8 @@ func saveSMSMessage(
value.Timestamp = existing.Timestamp
}
}
value.Body = mergedBody
extra = mergedExtra
}
if value.Timestamp.IsZero() {
value.Timestamp = now
@@ -171,7 +174,10 @@ func saveSMSMessage(
source = excluded.source,
parts_total = excluded.parts_total,
delivery_state = excluded.delivery_state,
is_read = excluded.is_read,
is_read = CASE
WHEN sms_messages.is_read = 1 THEN 1
ELSE excluded.is_read
END,
extra_json = excluded.extra_json,
updated_at = excluded.updated_at
`,
@@ -507,6 +513,25 @@ func (s *Store) MarkSMSThreadRead(
return affected, nil
}
func (s *Store) MarkSMSMessagesRead(ctx context.Context, ids []int64) error {
if len(ids) == 0 {
return nil
}
placeholders := make([]string, len(ids))
args := make([]any, 0, len(ids)+1)
args = append(args, time.Now().UTC().Unix())
for i, id := range ids {
placeholders[i] = "?"
args = append(args, id)
}
query := fmt.Sprintf("UPDATE sms_messages SET is_read = 1, updated_at = ? WHERE id IN (%s) AND is_read = 0", strings.Join(placeholders, ","))
_, err := s.db.ExecContext(ctx, query, args...)
if err != nil {
return fmt.Errorf("mark SMS messages read: %w", err)
}
return nil
}
// ListSMSContacts derives contacts and thread counters from messages. No
// duplicated contact/thread table can drift out of sync with message history.
func (s *Store) ListSMSContacts(ctx context.Context, filter SMSFilter) ([]SMSContact, error) {
+121 -139
View File
@@ -314,7 +314,7 @@ export default function SmsPage() {
const selectContact = useCallback(
async (key: string, opts: { syncRoute?: boolean; silent?: boolean; scrollToBottom?: boolean } = {}) => {
const { syncRoute = true, silent = false, scrollToBottom = true } = opts;
if (!key || (keyRef.current === key && messagesRef.current.length > 0)) return;
if (!key) return;
setKey(key);
if (syncRoute) syncQuery(deviceRef.current, key);
const thread = contactsRef.current.find((t) => t.key === key) || null;
@@ -338,8 +338,8 @@ export default function SmsPage() {
contactsList: SmsThread[],
opts: { syncRoute?: boolean; silent?: boolean; scrollToBottom?: boolean } = {},
) => {
const { syncRoute = false, silent = false, scrollToBottom = false } = opts;
const active = contactsList.find((t) => t.key === keyRef.current) || null;
const { silent = false, scrollToBottom = false } = opts;
const active = (keyRef.current && contactsList.find((t) => t.key === keyRef.current)) || null;
if (active) {
const ok = await loadThreadFor(active, device, silent);
if (ok) {
@@ -350,16 +350,8 @@ export default function SmsPage() {
}
setMessagesState([]);
setHasMoreState(false);
if (keyRef.current) {
setKey("");
if (syncRoute) syncQuery(device, "");
}
const filtered = filterThreads(contactsList, searchRef.current);
if (!isMobileRef.current && filtered.length > 0) {
await selectContact(filtered[0].key, { syncRoute, silent, scrollToBottom });
}
},
[loadThreadFor, selectContact, syncQuery, scrollToBottomNow],
[loadThreadFor, scrollToBottomNow],
);
const clearSelection = useCallback(
@@ -479,7 +471,7 @@ export default function SmsPage() {
} finally {
setSending(false);
}
}, [composer, devices, refreshCurrent, scrollToBottomNow]);
}, [composer, devices, refreshCurrent, scrollToBottomNow, t]);
const openNewSms = useCallback(() => {
setNewSmsDevice(deviceRef.current !== "all" ? deviceRef.current : devices[0]?.id || "");
@@ -506,7 +498,7 @@ export default function SmsPage() {
setSending(false);
}
},
[refreshCurrent],
[refreshCurrent, t],
);
const deleteMessageAction = useCallback(
@@ -530,7 +522,7 @@ export default function SmsPage() {
setDeletingMessageId(null);
}
},
[deletingMessageId, refreshCurrent, clearSelection],
[deletingMessageId, refreshCurrent, clearSelection, t],
);
const deleteThreadAction = useCallback(
@@ -560,7 +552,7 @@ export default function SmsPage() {
setDeletingThreadKey(null);
}
},
[deletingThreadKey, clearSelection, loadContacts],
[deletingThreadKey, clearSelection, loadContacts, lang],
);
const closeActionSheet = useCallback(() => {
@@ -622,16 +614,6 @@ export default function SmsPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const prevIsMobile = useRef(isMobile);
useEffect(() => {
const was = prevIsMobile.current;
prevIsMobile.current = isMobile;
if (was && !isMobile && !keyRef.current) {
const filtered = filterThreads(contactsRef.current, searchRef.current);
if (filtered.length > 0) void selectContact(filtered[0].key, { syncRoute: true, scrollToBottom: false });
}
}, [isMobile, selectContact]);
useEffect(() => () => clearLongPress(), [clearLongPress]);
return (
@@ -670,121 +652,121 @@ export default function SmsPage() {
onRetry={refreshAll}
/>
) : null}
<div className="relative flex-1 overflow-hidden ui-card">
{contactsLoading && contacts.length === 0 ? (
<div className="absolute inset-0 z-20 flex items-center justify-center bg-white/50 backdrop-blur-sm dark:bg-black/20">
<Spinner className="h-7 w-7 text-[#0ea5e9]" />
</div>
) : null}
<div className="sms-main-layout">
{isDesktop ? (
<div className="flex flex-col border-r border-gray-100 dark:border-white/10">
<div className="border-b border-gray-100 p-4 dark:border-white/10">
<div className="text-xs font-bold uppercase tracking-wider text-gray-500">{t("设备")}</div>
</div>
<div className="space-y-1 overflow-auto p-3">
{deviceFilters.map((d) => (
<button
key={d.id}
type="button"
onClick={() => void selectDevice(d.id)}
className={cx(
"flex w-full items-center justify-between gap-3 rounded-xl border px-3 py-2 text-left transition-all",
selectedDevice === d.id
? "border-indigo-200 bg-indigo-50/70 dark:border-indigo-500/30 dark:bg-indigo-500/10"
: "border-transparent hover:bg-gray-50/60 dark:hover:bg-white/5",
)}
>
<div className="min-w-0">
<div className="truncate text-sm font-bold text-gray-800 dark:text-gray-100">{d.label}</div>
<div className="truncate text-xs text-gray-400">{d.id === "all" ? t("汇总全部设备检测记录") : d.id}</div>
<div className="relative flex-1 overflow-hidden ui-card">
{contactsLoading && contacts.length === 0 ? (
<div className="absolute inset-0 z-20 flex items-center justify-center bg-white/50 backdrop-blur-sm dark:bg-black/20">
<Spinner className="h-7 w-7 text-[#0ea5e9]" />
</div>
{d.id !== "all" ? (
<span className={cx("h-2 w-2 rounded-full", d.healthy ? "bg-green-500" : "bg-red-500")} />
) : null}
<div className="sms-main-layout">
{isDesktop ? (
<div className="flex flex-col border-r border-gray-100 dark:border-white/10">
<div className="border-b border-gray-100 p-4 dark:border-white/10">
<div className="text-xs font-bold uppercase tracking-wider text-gray-500">{t("设备")}</div>
</div>
<div className="space-y-1 overflow-auto p-3">
{deviceFilters.map((d) => (
<button
key={d.id}
type="button"
onClick={() => void selectDevice(d.id)}
className={cx(
"flex w-full items-center justify-between gap-3 rounded-xl border px-3 py-2 text-left transition-all",
selectedDevice === d.id
? "border-indigo-200 bg-indigo-50/70 dark:border-indigo-500/30 dark:bg-indigo-500/10"
: "border-transparent hover:bg-gray-50/60 dark:hover:bg-white/5",
)}
>
<div className="min-w-0">
<div className="truncate text-sm font-bold text-gray-800 dark:text-gray-100">{d.label}</div>
<div className="truncate text-xs text-gray-400">{d.id === "all" ? t("汇总全部设备检测记录") : d.id}</div>
</div>
{d.id !== "all" ? (
<span className={cx("h-2 w-2 rounded-full", d.healthy ? "bg-green-500" : "bg-red-500")} />
) : null}
</button>
))}
</div>
</div>
) : null}
</button>
))}
</div>
</div>
) : null}
{showContactColumn ? (
<ContactList
isMobile={isMobile}
isDesktop={isDesktop}
selectedDevice={selectedDevice}
deviceOptions={deviceSelectOptions}
onSelectDevice={(id) => void selectDevice(id)}
searchQuery={searchQuery}
onSearchChange={setSearch}
loading={contactsLoading}
contacts={filteredContacts}
activeKey={selectedKey}
isUnread={isUnread}
deletingKey={deletingThreadKey}
canHover={canHover}
onSelect={(key) => void selectContact(key)}
onDelete={(t) => void deleteThreadAction(t)}
onRowPointerDown={onThreadPointerDown}
onRowPointerMove={moveLongPress}
onRowPointerEnd={clearLongPress}
/>
) : null}
{showDetailColumn ? (
<ThreadPanel
isMobile={isMobile}
isDesktop={isDesktop}
selectedDevice={selectedDevice}
activeThread={activeThread}
canLoadMore={!!activeThread && hasMore}
loadingMore={loadingMore}
groups={groups}
deletingMessageId={deletingMessageId}
canHover={canHover}
composer={composer}
composerInfo={composerInfo}
composerLength={composerLength}
sending={sending}
detailRef={detailRef}
composerRef={composerRef}
onBack={onBack}
onScrollToBottom={scrollToBottomNow}
onLoadMore={() => void loadMore()}
onDeleteMessage={(m) => void deleteMessageAction(m)}
onComposerChange={setComposer}
onSend={() => void sendReply()}
onDetailScroll={onDetailScroll}
onMsgPointerDown={onMsgPointerDown}
onMsgPointerMove={moveLongPress}
onMsgPointerEnd={clearLongPress}
/>
) : null}
</div>
</div>
{actionSheetOpen && isMobile && actionTarget ? (
<div className="sms-action-sheet-mask animate-[fade-slide-in_0.18s_ease]" onClick={closeActionSheet}>
<div className="sms-action-sheet" onClick={(e) => e.stopPropagation()}>
<div className="sms-action-sheet-title">{t("操作")}</div>
<Button
className="sms-danger-ghost-btn !w-full !justify-center"
icon={<DeleteRegular />}
onClick={() => void confirmSheetAction()}
>
{actionTarget.type === "thread" ? t("删除对话") : t("删除短信")}
</Button>
<Button className="!w-full !justify-center" onClick={closeActionSheet}>
{t("取消")}
</Button>
</div>
</div>
) : null}
<NewSmsModal
open={newSmsOpen}
devices={devices}
defaultDeviceId={newSmsDevice}
sending={sending}
onClose={() => setNewSmsOpen(false)}
onSend={sendNewSms}
/>
{showContactColumn ? (
<ContactList
isMobile={isMobile}
isDesktop={isDesktop}
selectedDevice={selectedDevice}
deviceOptions={deviceSelectOptions}
onSelectDevice={(id) => void selectDevice(id)}
searchQuery={searchQuery}
onSearchChange={setSearch}
loading={contactsLoading}
contacts={filteredContacts}
activeKey={selectedKey}
isUnread={isUnread}
deletingKey={deletingThreadKey}
canHover={canHover}
onSelect={(key) => void selectContact(key)}
onDelete={(t) => void deleteThreadAction(t)}
onRowPointerDown={onThreadPointerDown}
onRowPointerMove={moveLongPress}
onRowPointerEnd={clearLongPress}
/>
) : null}
{showDetailColumn ? (
<ThreadPanel
isMobile={isMobile}
isDesktop={isDesktop}
selectedDevice={selectedDevice}
activeThread={activeThread}
canLoadMore={!!activeThread && hasMore}
loadingMore={loadingMore}
groups={groups}
deletingMessageId={deletingMessageId}
canHover={canHover}
composer={composer}
composerInfo={composerInfo}
composerLength={composerLength}
sending={sending}
detailRef={detailRef}
composerRef={composerRef}
onBack={onBack}
onScrollToBottom={scrollToBottomNow}
onLoadMore={() => void loadMore()}
onDeleteMessage={(m) => void deleteMessageAction(m)}
onComposerChange={setComposer}
onSend={() => void sendReply()}
onDetailScroll={onDetailScroll}
onMsgPointerDown={onMsgPointerDown}
onMsgPointerMove={moveLongPress}
onMsgPointerEnd={clearLongPress}
/>
) : null}
</div>
</div>
{actionSheetOpen && isMobile && actionTarget ? (
<div className="sms-action-sheet-mask animate-[fade-slide-in_0.18s_ease]" onClick={closeActionSheet}>
<div className="sms-action-sheet" onClick={(e) => e.stopPropagation()}>
<div className="sms-action-sheet-title">{t("操作")}</div>
<Button
className="sms-danger-ghost-btn !w-full !justify-center"
icon={<DeleteRegular />}
onClick={() => void confirmSheetAction()}
>
{actionTarget.type === "thread" ? t("删除对话") : t("删除短信")}
</Button>
<Button className="!w-full !justify-center" onClick={closeActionSheet}>
{t("取消")}
</Button>
</div>
</div>
) : null}
<NewSmsModal
open={newSmsOpen}
devices={devices}
defaultDeviceId={newSmsDevice}
sending={sending}
onClose={() => setNewSmsOpen(false)}
onSend={sendNewSms}
/>
</div>
);
}