diff --git a/.github/csharpe.prompt.md b/.github/csharpe.prompt.md
new file mode 100644
index 0000000..02c1157
--- /dev/null
+++ b/.github/csharpe.prompt.md
@@ -0,0 +1,6 @@
+# C# Developer Prompt
+- 优先使用新C#语法
+- 优先使用中文注释
+- 优先复用已有方法
+- 不要深嵌套代码
+- 保持代码简洁易读
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 5a1cee4..dad241b 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -21,6 +21,7 @@
+
diff --git a/Entity/PushSubscriptionEntity.cs b/Entity/PushSubscriptionEntity.cs
new file mode 100644
index 0000000..397c99d
--- /dev/null
+++ b/Entity/PushSubscriptionEntity.cs
@@ -0,0 +1,17 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace Entity;
+
+public class PushSubscriptionEntity : BaseEntity
+{
+ [Required]
+ public string Endpoint { get; set; } = string.Empty;
+
+ public string? P256DH { get; set; }
+
+ public string? Auth { get; set; }
+
+ public string? UserId { get; set; } // Optional: if you have user authentication
+
+ public string? UserAgent { get; set; }
+}
diff --git a/Repository/PushSubscriptionRepository.cs b/Repository/PushSubscriptionRepository.cs
new file mode 100644
index 0000000..8d4f32e
--- /dev/null
+++ b/Repository/PushSubscriptionRepository.cs
@@ -0,0 +1,16 @@
+namespace Repository;
+
+public interface IPushSubscriptionRepository : IBaseRepository
+{
+ Task GetByEndpointAsync(string endpoint);
+}
+
+public class PushSubscriptionRepository(IFreeSql freeSql) : BaseRepository(freeSql), IPushSubscriptionRepository
+{
+ public async Task GetByEndpointAsync(string endpoint)
+ {
+ return await FreeSql.Select()
+ .Where(x => x.Endpoint == endpoint)
+ .FirstAsync();
+ }
+}
diff --git a/Service/AppSettingModel/NotificationSettings.cs b/Service/AppSettingModel/NotificationSettings.cs
new file mode 100644
index 0000000..50dfbbb
--- /dev/null
+++ b/Service/AppSettingModel/NotificationSettings.cs
@@ -0,0 +1,8 @@
+namespace Service.AppSettingModel;
+
+public class NotificationSettings
+{
+ public string Subject { get; set; } = string.Empty;
+ public string PublicKey { get; set; } = string.Empty;
+ public string PrivateKey { get; set; } = string.Empty;
+}
diff --git a/Service/GlobalUsings.cs b/Service/GlobalUsings.cs
index 38b7571..05d55e6 100644
--- a/Service/GlobalUsings.cs
+++ b/Service/GlobalUsings.cs
@@ -11,4 +11,5 @@ global using FreeSql;
global using System.Linq;
global using Service.AppSettingModel;
global using System.Text.Json.Serialization;
-global using System.Text.Json.Nodes;
\ No newline at end of file
+global using System.Text.Json.Nodes;
+global using Microsoft.Extensions.Configuration;
\ No newline at end of file
diff --git a/Service/MessageRecordService.cs b/Service/MessageRecordService.cs
index e44b637..69dfd29 100644
--- a/Service/MessageRecordService.cs
+++ b/Service/MessageRecordService.cs
@@ -12,7 +12,7 @@ public interface IMessageRecordService
Task GetUnreadCountAsync();
}
-public class MessageRecordService(IMessageRecordRepository messageRepo) : IMessageRecordService
+public class MessageRecordService(IMessageRecordRepository messageRepo, INotificationService notificationService) : IMessageRecordService
{
public async Task<(IEnumerable List, long Total)> GetPagedListAsync(int pageIndex, int pageSize)
{
@@ -37,7 +37,12 @@ public class MessageRecordService(IMessageRecordRepository messageRepo) : IMessa
Content = content,
IsRead = false
};
- return await messageRepo.AddAsync(message);
+ var result = await messageRepo.AddAsync(message);
+ if (result)
+ {
+ await notificationService.SendNotificationAsync($"新增的账单通知: {title}");
+ }
+ return result;
}
public async Task MarkAsReadAsync(long id)
diff --git a/Service/NotificationService.cs b/Service/NotificationService.cs
new file mode 100644
index 0000000..6e54c22
--- /dev/null
+++ b/Service/NotificationService.cs
@@ -0,0 +1,93 @@
+using WebPush;
+
+namespace Service;
+
+public interface INotificationService
+{
+ Task GetVapidPublicKeyAsync();
+ Task SubscribeAsync(PushSubscriptionEntity subscription);
+ Task SendNotificationAsync(string message, string? url = null);
+}
+
+public class NotificationService(
+ IPushSubscriptionRepository subscriptionRepo,
+ IConfiguration configuration,
+ ILogger logger) : INotificationService
+{
+ private NotificationSettings GetSettings()
+ {
+ var settings = configuration.GetSection("NotificationSettings").Get();
+ if (settings == null)
+ {
+ // Fallback or throw. For now, let's return empty to avoid crashing if not configured,
+ // but logging error is better.
+ logger.LogWarning("NotificationSettings not configured");
+ return new NotificationSettings();
+ }
+ return settings;
+ }
+
+ public Task GetVapidPublicKeyAsync()
+ {
+ return Task.FromResult(GetSettings().PublicKey);
+ }
+
+ public async Task SubscribeAsync(PushSubscriptionEntity subscription)
+ {
+ var existing = await subscriptionRepo.GetByEndpointAsync(subscription.Endpoint);
+ if (existing != null)
+ {
+ existing.P256DH = subscription.P256DH;
+ existing.Auth = subscription.Auth;
+ existing.UpdateTime = DateTime.Now;
+ await subscriptionRepo.UpdateAsync(existing);
+ }
+ else
+ {
+ await subscriptionRepo.AddAsync(subscription);
+ }
+ }
+
+ public async Task SendNotificationAsync(string message, string? url = null)
+ {
+ var settings = GetSettings();
+ if (string.IsNullOrEmpty(settings.PublicKey) || string.IsNullOrEmpty(settings.PrivateKey))
+ {
+ logger.LogWarning("VAPID keys not configured, skipping notification");
+ return;
+ }
+
+ var vapidDetails = new VapidDetails(settings.Subject, settings.PublicKey, settings.PrivateKey);
+ var webPushClient = new WebPushClient();
+
+ var subscriptions = await subscriptionRepo.GetAllAsync();
+ var payload = System.Text.Json.JsonSerializer.Serialize(new
+ {
+ title = "System Notification",
+ body = message,
+ url = url ?? "/",
+ icon = "/pwa-192x192.png"
+ });
+
+ foreach (var sub in subscriptions)
+ {
+ try
+ {
+ var pushSubscription = new PushSubscription(sub.Endpoint, sub.P256DH, sub.Auth);
+ await webPushClient.SendNotificationAsync(pushSubscription, payload, vapidDetails);
+ }
+ catch (WebPushException ex)
+ {
+ if (ex.StatusCode == System.Net.HttpStatusCode.Gone || ex.StatusCode == System.Net.HttpStatusCode.NotFound)
+ {
+ await subscriptionRepo.DeleteAsync(sub.Id);
+ }
+ logger.LogError(ex, "Error sending push notification to {Endpoint}", sub.Endpoint);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error sending push notification to {Endpoint}", sub.Endpoint);
+ }
+ }
+ }
+}
diff --git a/Service/Service.csproj b/Service/Service.csproj
index a2bf00f..2ddb43e 100644
--- a/Service/Service.csproj
+++ b/Service/Service.csproj
@@ -21,6 +21,7 @@
+
diff --git a/Web/public/service-worker.js b/Web/public/service-worker.js
index 9ec0dc7..b9bd96e 100644
--- a/Web/public/service-worker.js
+++ b/Web/public/service-worker.js
@@ -107,17 +107,29 @@ self.addEventListener('sync', (event) => {
// 推送通知
self.addEventListener('push', (event) => {
console.log('[Service Worker] 收到推送消息');
+ let data = { title: '账单管理', body: '您有新的消息', url: '/', icon: '/icons/icon-192x192.png' };
+
+ if (event.data) {
+ try {
+ const json = event.data.json();
+ data = { ...data, ...json };
+ } catch (e) {
+ data.body = event.data.text();
+ }
+ }
+
const options = {
- body: event.data ? event.data.text() : '您有新的账单消息',
- icon: '/icons/icon-192x192.png',
+ body: data.body,
+ icon: data.icon,
badge: '/icons/icon-72x72.png',
vibrate: [200, 100, 200],
tag: 'emailbill-notification',
- requireInteraction: false
+ requireInteraction: false,
+ data: { url: data.url }
};
event.waitUntil(
- self.registration.showNotification('账单管理', options)
+ self.registration.showNotification(data.title, options)
);
});
@@ -125,8 +137,21 @@ self.addEventListener('push', (event) => {
self.addEventListener('notificationclick', (event) => {
console.log('[Service Worker] 通知被点击');
event.notification.close();
+ const urlToOpen = event.notification.data?.url || '/';
event.waitUntil(
- clients.openWindow('/')
+ clients.matchAll({ type: 'window', includeUncontrolled: true }).then((windowClients) => {
+ // 如果已经打开了该 URL,则聚焦
+ for (let i = 0; i < windowClients.length; i++) {
+ const client = windowClients[i];
+ if (client.url === urlToOpen && 'focus' in client) {
+ return client.focus();
+ }
+ }
+ // 否则打开新窗口
+ if (clients.openWindow) {
+ return clients.openWindow(urlToOpen);
+ }
+ })
);
});
diff --git a/Web/src/App.vue b/Web/src/App.vue
index c0334e2..4a57136 100644
--- a/Web/src/App.vue
+++ b/Web/src/App.vue
@@ -79,12 +79,15 @@ const updateTheme = () => {
let mediaQuery
onMounted(() => {
updateTheme()
- messageStore.updateUnreadCount()
mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
mediaQuery.addEventListener('change', updateTheme)
setActive(route.path)
})
+setInterval(() => {
+ messageStore.updateUnreadCount()
+}, 30 * 1000) // 每30秒更新一次未读消息数
+
// 监听路由变化调整
watch(() => route.path, (newPath) => {
setActive(newPath)
@@ -127,6 +130,48 @@ const handleAddTransactionSuccess = () => {
window.dispatchEvent(event)
}
+// 辅助函数:将 Base64 字符串转换为 Uint8Array
+const urlBase64ToUint8Array = (base64String) => {
+ const padding = '='.repeat((4 - base64String.length % 4) % 4);
+ const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
+ const rawData = window.atob(base64);
+ const outputArray = new Uint8Array(rawData.length);
+ for (let i = 0; i < rawData.length; ++i) {
+ outputArray[i] = rawData.charCodeAt(i);
+ }
+ return outputArray;
+}
+
+const subscribeToPush = async () => {
+ if (!('serviceWorker' in navigator)) return;
+
+ // 1. 获取 VAPID 公钥
+ const response = await fetch('/api/notification/vapid-public-key');
+ const { publicKey } = await response.json();
+
+ // 2. 等待 Service Worker 准备就绪
+ const registration = await navigator.serviceWorker.ready;
+
+ // 3. 请求订阅
+ const subscription = await registration.pushManager.subscribe({
+ userVisibleOnly: true,
+ applicationServerKey: urlBase64ToUint8Array(publicKey)
+ });
+
+ // 4. 将订阅信息发送给后端
+ // 注意:后端 PushSubscriptionEntity 字段首字母大写,这里需要转换或让后端兼容
+ const subJson = subscription.toJSON();
+ await fetch('/api/notification/subscribe', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ endpoint: subJson.endpoint,
+ p256dh: subJson.keys.p256dh,
+ auth: subJson.keys.auth
+ })
+ });
+}
+