feat(notification): add scheduled email pipeline with prefs/groups and DLQ UI
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
74
web/src/views/NotificationDlqView.vue
Normal file
74
web/src/views/NotificationDlqView.vue
Normal 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>
|
||||
134
web/src/views/NotificationGroupsView.vue
Normal file
134
web/src/views/NotificationGroupsView.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user