feat(notification): add scheduled email pipeline with prefs/groups and DLQ UI

This commit is contained in:
2026-03-01 22:28:51 +08:00
parent 809a3dc522
commit 7e97aaa7fc
13 changed files with 2416 additions and 88 deletions

View File

@@ -33,6 +33,8 @@ async function onLogout() {
<RouterLink to="/team">{{ t('nav_team') }}</RouterLink>
<RouterLink to="/settings/user">{{ t('nav_user_settings') }}</RouterLink>
<RouterLink to="/settings/team">{{ t('nav_team_settings') }}</RouterLink>
<RouterLink to="/settings/notification-groups">{{ t('nav_notification_groups') }}</RouterLink>
<RouterLink to="/settings/notification-dlq">{{ t('nav_notification_dlq') }}</RouterLink>
</template>
<template v-else>
<RouterLink to="/auth/login">{{ t('nav_login') }}</RouterLink>

View File

@@ -1,11 +1,12 @@
<script setup lang="ts">
import { reactive, watch } from 'vue'
import type { Task } from '../types'
import type { NotificationGroup, Task } from '../types'
import { t } from '../i18n'
const props = defineProps<{
modelValue: boolean
editingTask?: Task | null
availableGroups: NotificationGroup[]
}>()
const emit = defineEmits<{
@@ -19,6 +20,7 @@ const form = reactive({
due_at: '',
priority: 2,
tags: '',
notify_group_ids: [] as number[],
})
watch(
@@ -29,6 +31,7 @@ watch(
form.due_at = toLocal(task?.due_at ?? '')
form.priority = task?.priority ?? 2
form.tags = task?.tags?.join(', ') ?? ''
form.notify_group_ids = task?.notify_group_ids ? [...task.notify_group_ids] : []
},
{ immediate: true },
)
@@ -42,6 +45,7 @@ watch(
form.due_at = ''
form.priority = 2
form.tags = ''
form.notify_group_ids = []
}
},
)
@@ -60,6 +64,7 @@ function save() {
.split(',')
.map((v) => v.trim())
.filter(Boolean),
notify_group_ids: [...form.notify_group_ids],
})
}
@@ -70,6 +75,12 @@ function toLocal(v: string) {
const offset = d.getTimezoneOffset() * 60000
return new Date(d.getTime() - offset).toISOString().slice(0, 16)
}
function onSelectGroups(event: Event) {
const target = event.target as HTMLSelectElement
const values = Array.from(target.selectedOptions).map((opt) => Number(opt.value)).filter(Boolean)
form.notify_group_ids = values
}
</script>
<template>
@@ -98,6 +109,14 @@ function toLocal(v: string) {
{{ t('tags') }}
<input v-model="form.tags" type="text" :placeholder="t('tags_placeholder')" />
</label>
<label>
{{ t('notify_groups') }}
<select multiple size="5" :value="form.notify_group_ids.map(String)" @change="onSelectGroups">
<option v-for="group in availableGroups" :key="group.id" :value="group.id">
{{ group.name }} ({{ group.emails.length }})
</option>
</select>
</label>
<label>
{{ t('description') }}
<textarea v-model="form.description" rows="4"></textarea>

View File

@@ -11,6 +11,8 @@ const messages = {
nav_team: 'Team',
nav_user_settings: 'User Settings',
nav_team_settings: 'Team Settings',
nav_notification_groups: 'Notify Groups',
nav_notification_dlq: 'Notification DLQ',
nav_login: 'Login',
logout: 'Logout',
guest_mode: 'Guest mode',
@@ -35,6 +37,7 @@ const messages = {
due_empty: '-',
edit: 'Edit',
delete: 'Delete',
create: 'Create',
loading_tasks: 'Loading tasks...',
load_tasks_failed: 'Failed to load tasks. Please login again.',
save_task_failed: 'Failed to save task.',
@@ -53,6 +56,7 @@ const messages = {
low: 'Low',
tags: 'Tags',
tags_placeholder: 'backend, planning',
notify_groups: 'Notify Groups',
description: 'Description',
cancel: 'Cancel',
save: 'Save',
@@ -60,12 +64,20 @@ const messages = {
team_subtitle: 'Switch between teams and check active workload.',
team_members: '{count} members',
team_open_todos: '{count} open todos',
user_settings_title: 'User Settings',
user_settings_subtitle: 'Personal profile and preference controls.',
user_settings_title: 'Notification Preferences',
user_settings_subtitle: 'Manage your notification subscription and schedule.',
display_name: 'Display Name',
timezone: 'Timezone',
reminders: 'Receive task reminders',
locale: 'Language',
dnd_start: 'DND Start',
dnd_end: 'DND End',
reminders: 'Receive notifications',
daily_summary_enabled: 'Enable Daily Summary',
daily_summary_time: 'Daily Summary Time',
save_settings: 'Save Settings',
save_settings_success: 'Settings updated successfully',
save_settings_failed: 'Failed to save settings',
load_settings_failed: 'Failed to load settings',
team_settings_title: 'Team Settings',
team_settings_subtitle: 'Global conventions for team collaboration.',
team_name: 'Team Name',
@@ -77,14 +89,32 @@ const messages = {
task_created_success: 'Task created successfully',
task_deleted_success: 'Task deleted successfully',
confirm_delete: 'Are you sure you want to delete this task?',
notification_groups_title: 'Notification Groups',
notification_groups_subtitle: 'Manage reusable recipient groups for tasks.',
group_name: 'Group Name',
group_emails: 'Emails',
group_emails_placeholder: 'a@example.com, b@example.com',
group_validation_error: 'Please input group name and emails',
group_created: 'Group created',
group_updated: 'Group updated',
group_deleted: 'Group deleted',
group_save_failed: 'Failed to save group',
group_delete_failed: 'Failed to delete group',
load_groups_failed: 'Failed to load groups',
notification_dlq_title: 'Notification DLQ',
notification_dlq_subtitle: 'Failed notification jobs waiting for compensation.',
load_dlq_failed: 'Failed to load DLQ records',
dlq_empty: 'No dead-letter records',
},
zh: {
brand_name: 'SuperTodo',
brand_caption: '简约的团队任务执行面板',
nav_todo: '待办',
nav_team: '团队入口',
nav_user_settings: '用户设置',
nav_user_settings: '通知偏好',
nav_team_settings: '团队设置',
nav_notification_groups: '通知组',
nav_notification_dlq: '死信队列',
nav_login: '登录',
logout: '退出登录',
guest_mode: '访客模式',
@@ -109,6 +139,7 @@ const messages = {
due_empty: '-',
edit: '编辑',
delete: '删除',
create: '创建',
loading_tasks: '任务加载中...',
load_tasks_failed: '加载任务失败,请重新登录。',
save_task_failed: '保存任务失败。',
@@ -127,6 +158,7 @@ const messages = {
low: '低',
tags: '标签',
tags_placeholder: '后端, 规划',
notify_groups: '通知组',
description: '描述',
cancel: '取消',
save: '保存',
@@ -134,12 +166,20 @@ const messages = {
team_subtitle: '在团队之间切换并查看当前工作量。',
team_members: '{count} 位成员',
team_open_todos: '{count} 个未完成任务',
user_settings_title: '用户设置',
user_settings_subtitle: '管理个人资料和偏好设置。',
user_settings_title: '通知偏好设置',
user_settings_subtitle: '管理订阅、免打扰与每日摘要。',
display_name: '显示名称',
timezone: '时区',
reminders: '接收任务提醒',
locale: '语言',
dnd_start: '免打扰开始',
dnd_end: '免打扰结束',
reminders: '接收通知',
daily_summary_enabled: '启用每日摘要',
daily_summary_time: '每日摘要时间',
save_settings: '保存设置',
save_settings_success: '设置保存成功',
save_settings_failed: '保存设置失败',
load_settings_failed: '加载设置失败',
team_settings_title: '团队设置',
team_settings_subtitle: '管理团队协作的默认规则。',
team_name: '团队名称',
@@ -151,6 +191,22 @@ const messages = {
task_created_success: '任务创建成功',
task_deleted_success: '任务删除成功',
confirm_delete: '确定要删除这个任务吗?',
notification_groups_title: '通知组管理',
notification_groups_subtitle: '为任务维护可复用收件人组。',
group_name: '组名称',
group_emails: '邮箱列表',
group_emails_placeholder: 'a@example.com, b@example.com',
group_validation_error: '请填写组名和至少一个邮箱',
group_created: '通知组已创建',
group_updated: '通知组已更新',
group_deleted: '通知组已删除',
group_save_failed: '保存通知组失败',
group_delete_failed: '删除通知组失败',
load_groups_failed: '加载通知组失败',
notification_dlq_title: '通知死信队列',
notification_dlq_subtitle: '查看投递失败并进入死信的通知任务。',
load_dlq_failed: '加载死信记录失败',
dlq_empty: '暂无死信记录',
},
} as const

View File

@@ -30,6 +30,18 @@ const router = createRouter({
component: () => import('./views/TeamSettingsView.vue'),
meta: { requiresAuth: true },
},
{
path: '/settings/notification-groups',
name: 'notification-groups',
component: () => import('./views/NotificationGroupsView.vue'),
meta: { requiresAuth: true },
},
{
path: '/settings/notification-dlq',
name: 'notification-dlq',
component: () => import('./views/NotificationDlqView.vue'),
meta: { requiresAuth: true },
},
],
})

View File

@@ -1,4 +1,4 @@
import type { Credentials, Task } from '../types'
import type { Credentials, DlqItem, NotificationGroup, NotificationPrefs, Task } from '../types'
import { clearSession, session, setSessionToken } from '../stores/session'
const API_BASE = 'http://localhost:8080/api/v1'
@@ -115,3 +115,42 @@ export async function deleteTask(id: number) {
method: 'DELETE',
})
}
export async function getNotificationPrefs() {
return request<NotificationPrefs>('/notifications/prefs')
}
export async function updateNotificationPrefs(payload: NotificationPrefs) {
return request<NotificationPrefs>('/notifications/prefs', {
method: 'PUT',
body: JSON.stringify(payload),
})
}
export async function listNotificationGroups() {
return request<NotificationGroup[]>('/notifications/groups')
}
export async function createNotificationGroup(payload: Omit<NotificationGroup, 'id'>) {
return request<NotificationGroup>('/notifications/groups', {
method: 'POST',
body: JSON.stringify(payload),
})
}
export async function updateNotificationGroup(id: number, payload: Omit<NotificationGroup, 'id'>) {
return request<NotificationGroup>(`/notifications/groups/${id}`, {
method: 'PUT',
body: JSON.stringify(payload),
})
}
export async function deleteNotificationGroup(id: number) {
return request<void>(`/notifications/groups/${id}`, {
method: 'DELETE',
})
}
export async function listNotificationDlq(page = 1, pageSize = 20) {
return request<DlqItem[]>(`/notifications/dlq?page=${page}&page_size=${pageSize}`)
}

View File

@@ -6,6 +6,7 @@ export type Task = {
due_at: string
priority: number
tags: string[]
notify_group_ids: number[]
created_at: string
updated_at: string
}
@@ -14,3 +15,30 @@ export type Credentials = {
email: string
password: string
}
export type NotificationPrefs = {
subscribed: boolean
dnd_start: string
dnd_end: string
locale: 'zh' | 'en'
timezone: string
daily_summary_enabled: boolean
daily_summary_time: string
}
export type NotificationGroup = {
id: number
name: string
emails: string[]
}
export type DlqItem = {
job_id: string
template_id: string
to_emails: string[]
reason: string
failed_at: string
retry_count: number
trace_id: string
error_code: string
}

View File

@@ -0,0 +1,74 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import type { DlqItem } from '../types'
import * as api from '../services/api'
import PaginationControl from '../components/PaginationControl.vue'
import { t } from '../i18n'
import { useToast } from '../composables/useToast'
const { error } = useToast()
const loading = ref(false)
const items = ref<DlqItem[]>([])
const page = ref(1)
const pageSize = ref(20)
async function load() {
loading.value = true
try {
items.value = await api.listNotificationDlq(page.value, pageSize.value)
} catch {
error(t('load_dlq_failed'))
} finally {
loading.value = false
}
}
function nextPage() {
page.value += 1
load()
}
function prevPage() {
if (page.value <= 1) return
page.value -= 1
load()
}
onMounted(load)
</script>
<template>
<section class="page">
<header class="page-head">
<div>
<h2>{{ t('notification_dlq_title') }}</h2>
<p>{{ t('notification_dlq_subtitle') }}</p>
</div>
<button class="btn" @click="load">{{ t('refresh') }}</button>
</header>
<div class="cards" v-if="!loading">
<article v-for="item in items" :key="item.job_id" class="card dlq-card">
<p><strong>Job:</strong> {{ item.job_id }}</p>
<p><strong>Template:</strong> {{ item.template_id }}</p>
<p><strong>To:</strong> {{ item.to_emails.join(', ') }}</p>
<p><strong>Reason:</strong> {{ item.reason }}</p>
<p><strong>Error:</strong> {{ item.error_code || '-' }}</p>
<p><strong>Retry:</strong> {{ item.retry_count }}</p>
<p><strong>At:</strong> {{ new Date(item.failed_at).toLocaleString() }}</p>
</article>
<div v-if="items.length === 0" class="card">{{ t('dlq_empty') }}</div>
</div>
<p v-else>{{ t('loading_wait') }}</p>
<PaginationControl :page="page" :page-size="pageSize" :total="items.length === pageSize ? page * pageSize + 1 : (page - 1) * pageSize + items.length" @next="nextPage" @prev="prevPage" />
</section>
</template>
<style scoped>
.dlq-card p {
margin: 0 0 8px;
line-height: 1.45;
}
</style>

View File

@@ -0,0 +1,134 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import type { NotificationGroup } from '../types'
import * as api from '../services/api'
import { t } from '../i18n'
import { useToast } from '../composables/useToast'
const { success, error } = useToast()
const loading = ref(false)
const editingId = ref<number | null>(null)
const groups = ref<NotificationGroup[]>([])
const form = reactive({
name: '',
emails: '',
})
function resetForm() {
form.name = ''
form.emails = ''
editingId.value = null
}
async function loadGroups() {
loading.value = true
try {
groups.value = await api.listNotificationGroups()
} catch {
error(t('load_groups_failed'))
} finally {
loading.value = false
}
}
function startEdit(group: NotificationGroup) {
editingId.value = group.id
form.name = group.name
form.emails = group.emails.join(', ')
}
async function saveGroup() {
const payload = {
name: form.name.trim(),
emails: form.emails
.split(',')
.map((item) => item.trim())
.filter(Boolean),
}
if (!payload.name || payload.emails.length === 0) {
error(t('group_validation_error'))
return
}
loading.value = true
try {
if (editingId.value) {
await api.updateNotificationGroup(editingId.value, payload)
success(t('group_updated'))
} else {
await api.createNotificationGroup(payload)
success(t('group_created'))
}
resetForm()
await loadGroups()
} catch {
error(t('group_save_failed'))
} finally {
loading.value = false
}
}
async function removeGroup(id: number) {
if (!confirm(t('confirm_delete'))) return
loading.value = true
try {
await api.deleteNotificationGroup(id)
success(t('group_deleted'))
await loadGroups()
} catch {
error(t('group_delete_failed'))
} finally {
loading.value = false
}
}
onMounted(loadGroups)
</script>
<template>
<section class="page">
<header class="page-head">
<div>
<h2>{{ t('notification_groups_title') }}</h2>
<p>{{ t('notification_groups_subtitle') }}</p>
</div>
</header>
<form class="card settings-form" @submit.prevent="saveGroup">
<label>
{{ t('group_name') }}
<input v-model="form.name" type="text" />
</label>
<label>
{{ t('group_emails') }}
<input v-model="form.emails" type="text" :placeholder="t('group_emails_placeholder')" />
</label>
<div class="page-actions">
<button class="btn primary" type="submit" :disabled="loading">{{ editingId ? t('save') : t('create') }}</button>
<button class="btn" type="button" @click="resetForm">{{ t('cancel') }}</button>
</div>
</form>
<div class="cards">
<article v-for="group in groups" :key="group.id" class="card group-card">
<h3>{{ group.name }}</h3>
<p>{{ group.emails.join(', ') }}</p>
<div class="page-actions">
<button class="btn" @click="startEdit(group)">{{ t('edit') }}</button>
<button class="btn danger" @click="removeGroup(group.id)">{{ t('delete') }}</button>
</div>
</article>
</div>
</section>
</template>
<style scoped>
.group-card h3 {
margin: 0 0 8px;
}
.group-card p {
margin: 0 0 12px;
line-height: 1.5;
}
</style>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import type { Task } from '../types'
import type { NotificationGroup, Task } from '../types'
import * as api from '../services/api'
import PaginationControl from '../components/PaginationControl.vue'
import TodoEditorModal from '../components/TodoEditorModal.vue'
@@ -10,6 +10,7 @@ import { useToast } from '../composables/useToast'
const { success, error: showError } = useToast()
const tasks = ref<Task[]>([])
const groups = ref<NotificationGroup[]>([])
const loading = ref(false)
const modalOpen = ref(false)
const editingTask = ref<Task | null>(null)
@@ -19,7 +20,7 @@ const filter = ref<'all' | 'todo' | 'done'>('all')
const filteredTasks = computed(() => {
if (filter.value === 'all') return tasks.value
return tasks.value.filter((t) => t.status === filter.value)
return tasks.value.filter((task) => task.status === filter.value)
})
const total = computed(() => filteredTasks.value.length)
@@ -29,6 +30,14 @@ const pagedTasks = computed(() => {
return filteredTasks.value.slice(start, start + pageSize.value)
})
async function loadGroups() {
try {
groups.value = await api.listNotificationGroups()
} catch {
groups.value = []
}
}
async function loadTasks() {
loading.value = true
try {
@@ -56,10 +65,10 @@ async function saveTask(payload: Partial<Task>) {
try {
if (editingTask.value?.id) {
await api.updateTask(editingTask.value.id, payload)
success(t('task_updated_success') || 'Task updated')
success(t('task_updated_success'))
} else {
await api.createTask({ ...payload, status: 'todo' })
success(t('task_created_success') || 'Task created')
await api.createTask({ ...payload, status: 'todo', notify_group_ids: payload.notify_group_ids ?? [] })
success(t('task_created_success'))
}
modalOpen.value = false
editingTask.value = null
@@ -72,16 +81,12 @@ async function saveTask(payload: Partial<Task>) {
async function toggle(task: Task) {
const originalStatus = task.status
const newStatus = task.status === 'done' ? 'todo' : 'done'
// Optimistic update
task.status = newStatus
try {
await api.updateTask(task.id, { status: newStatus })
// Background refresh to ensure consistency
// await loadTasks() // Optional: might cause jumping if list sort changes
} catch {
// Revert on failure
task.status = originalStatus
showError(t('update_task_failed'))
}
@@ -91,11 +96,11 @@ async function remove(id: number) {
if (!confirm(t('confirm_delete') || 'Are you sure?')) return
const originalTasks = [...tasks.value]
tasks.value = tasks.value.filter(t => t.id !== id)
tasks.value = tasks.value.filter((task) => task.id !== id)
try {
await api.deleteTask(id)
success(t('task_deleted_success') || 'Task deleted')
success(t('task_deleted_success'))
} catch {
tasks.value = originalTasks
showError(t('delete_task_failed'))
@@ -114,12 +119,20 @@ function formatDue(value: string) {
return value ? new Date(value).toLocaleString() : t('due_empty')
}
function setFilter(f: 'all' | 'todo' | 'done') {
filter.value = f
function setFilter(value: 'all' | 'todo' | 'done') {
filter.value = value
page.value = 1
}
onMounted(loadTasks)
function groupNames(ids: number[]) {
if (!ids || ids.length === 0) return '-'
const names = groups.value.filter((group) => ids.includes(group.id)).map((group) => group.name)
return names.length ? names.join(', ') : '-'
}
onMounted(async () => {
await Promise.all([loadTasks(), loadGroups()])
})
</script>
<template>
@@ -131,21 +144,9 @@ onMounted(loadTasks)
</div>
<div class="page-actions">
<div class="filter-group">
<button
class="btn sm"
:class="{ primary: filter === 'all' }"
@click="setFilter('all')"
>All</button>
<button
class="btn sm"
:class="{ primary: filter === 'todo' }"
@click="setFilter('todo')"
>Active</button>
<button
class="btn sm"
:class="{ primary: filter === 'done' }"
@click="setFilter('done')"
>Done</button>
<button class="btn sm" :class="{ primary: filter === 'all' }" @click="setFilter('all')">All</button>
<button class="btn sm" :class="{ primary: filter === 'todo' }" @click="setFilter('todo')">Active</button>
<button class="btn sm" :class="{ primary: filter === 'done' }" @click="setFilter('done')">Done</button>
</div>
<button class="btn" @click="loadTasks">{{ t('refresh') }}</button>
<button class="btn primary" @click="openCreate">{{ t('new_todo') }}</button>
@@ -174,6 +175,7 @@ onMounted(loadTasks)
<div class="task-meta">
<small>{{ t('priority') }} P{{ task.priority || 2 }}</small>
<small>{{ t('due_at') }} {{ formatDue(task.due_at) }}</small>
<small>{{ t('notify_groups') }} {{ groupNames(task.notify_group_ids || []) }}</small>
</div>
</div>
<div class="task-row-actions">
@@ -187,7 +189,7 @@ onMounted(loadTasks)
<PaginationControl :page="page" :page-size="pageSize" :total="total" @next="nextPage" @prev="prevPage" />
<TodoEditorModal v-model="modalOpen" :editing-task="editingTask" @submit="saveTask" />
<TodoEditorModal v-model="modalOpen" :editing-task="editingTask" :available-groups="groups" @submit="saveTask" />
</section>
</template>

View File

@@ -1,14 +1,49 @@
<script setup lang="ts">
import { reactive } from 'vue'
import { session } from '../stores/session'
import { onMounted, reactive, ref } from 'vue'
import type { NotificationPrefs } from '../types'
import * as api from '../services/api'
import { t } from '../i18n'
import { useToast } from '../composables/useToast'
const form = reactive({
displayName: 'Product Operator',
email: session.email,
const { success, error } = useToast()
const loading = ref(false)
const form = reactive<NotificationPrefs>({
subscribed: true,
dnd_start: '',
dnd_end: '',
locale: 'zh',
timezone: 'Asia/Shanghai',
notifications: true,
daily_summary_enabled: true,
daily_summary_time: '09:30',
})
async function loadPrefs() {
loading.value = true
try {
const prefs = await api.getNotificationPrefs()
Object.assign(form, prefs)
} catch {
error(t('load_settings_failed'))
} finally {
loading.value = false
}
}
async function savePrefs() {
loading.value = true
try {
const updated = await api.updateNotificationPrefs(form)
Object.assign(form, updated)
success(t('save_settings_success'))
} catch {
error(t('save_settings_failed'))
} finally {
loading.value = false
}
}
onMounted(loadPrefs)
</script>
<template>
@@ -18,24 +53,39 @@ const form = reactive({
<p>{{ t('user_settings_subtitle') }}</p>
</header>
<form class="card settings-form" @submit.prevent>
<label>
{{ t('display_name') }}
<input v-model="form.displayName" type="text" />
</label>
<label>
{{ t('email') }}
<input v-model="form.email" type="email" />
<form class="card settings-form" @submit.prevent="savePrefs">
<label class="inline">
<input v-model="form.subscribed" type="checkbox" />
{{ t('reminders') }}
</label>
<label>
{{ t('timezone') }}
<input v-model="form.timezone" type="text" />
<input v-model="form.timezone" type="text" placeholder="Asia/Shanghai" />
</label>
<label>
{{ t('locale') }}
<select v-model="form.locale">
<option value="zh">中文</option>
<option value="en">English</option>
</select>
</label>
<label>
{{ t('dnd_start') }}
<input v-model="form.dnd_start" type="time" />
</label>
<label>
{{ t('dnd_end') }}
<input v-model="form.dnd_end" type="time" />
</label>
<label class="inline">
<input v-model="form.notifications" type="checkbox" />
{{ t('reminders') }}
<input v-model="form.daily_summary_enabled" type="checkbox" />
{{ t('daily_summary_enabled') }}
</label>
<button class="btn primary" type="submit">{{ t('save_settings') }}</button>
<label>
{{ t('daily_summary_time') }}
<input v-model="form.daily_summary_time" type="time" />
</label>
<button class="btn primary" type="submit" :disabled="loading">{{ loading ? t('loading_wait') : t('save_settings') }}</button>
</form>
</section>
</template>