mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-05 21:06:38 +08:00
Fix notification event toggles and license heartbeat
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
# MediaStationGo
|
# MediaStationGo
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="web/public/brand/mgo-emby-icon.png" width="96" height="96" alt="MediaStationGo Logo" />
|
<img src="web/public/brand/mgo-emby-icon.svg" width="96" height="96" alt="MediaStationGo Logo" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h3 align="center">适合 NAS、家庭共享和多端播放的私人媒体中心</h3>
|
<h3 align="center">适合 NAS、家庭共享和多端播放的私人媒体中心</h3>
|
||||||
@@ -96,11 +96,11 @@ MediaStationGo 推荐按机器资源和用户规模选择部署档位。三挡
|
|||||||
第一档是默认推荐部署。它只启动主服务和 PostgreSQL,资源占用最低,适合绝大多数 NAS。
|
第一档是默认推荐部署。它只启动主服务和 PostgreSQL,资源占用最低,适合绝大多数 NAS。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 拉取最新镜像
|
# 只拉取 MediaStationGo 主服务镜像,避免升级时动到 PostgreSQL / Redis
|
||||||
docker compose pull
|
docker compose pull mediastation-go
|
||||||
|
|
||||||
# 启动第一档:MediaStationGo + PostgreSQL
|
# 启动第一档:MediaStationGo + PostgreSQL
|
||||||
docker compose up -d
|
docker compose up -d --no-deps mediastation-go
|
||||||
```
|
```
|
||||||
|
|
||||||
关键数据目录:
|
关键数据目录:
|
||||||
@@ -238,20 +238,20 @@ environment:
|
|||||||
更新镜像:
|
更新镜像:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose pull
|
docker compose pull mediastation-go
|
||||||
docker compose up -d
|
docker compose up -d --no-deps mediastation-go
|
||||||
```
|
```
|
||||||
|
|
||||||
第二档和第三档更新时继续带上叠加文件:
|
第二档和第三档更新时继续带上叠加文件:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 第二档
|
# 第二档
|
||||||
docker compose -f docker-compose.yml -f docker-compose.standard.yml pull
|
docker compose -f docker-compose.yml -f docker-compose.standard.yml pull mediastation-go
|
||||||
docker compose -f docker-compose.yml -f docker-compose.standard.yml up -d
|
docker compose -f docker-compose.yml -f docker-compose.standard.yml up -d --no-deps mediastation-go
|
||||||
|
|
||||||
# 第三档
|
# 第三档
|
||||||
docker compose -f docker-compose.yml -f docker-compose.standard.yml -f docker-compose.search.yml pull
|
docker compose -f docker-compose.yml -f docker-compose.standard.yml -f docker-compose.search.yml pull mediastation-go
|
||||||
docker compose -f docker-compose.yml -f docker-compose.standard.yml -f docker-compose.search.yml up -d
|
docker compose -f docker-compose.yml -f docker-compose.standard.yml -f docker-compose.search.yml up -d --no-deps mediastation-go
|
||||||
```
|
```
|
||||||
|
|
||||||
必须备份:
|
必须备份:
|
||||||
|
|||||||
+5
-5
@@ -1,7 +1,7 @@
|
|||||||
# MediaStationGo
|
# MediaStationGo
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="web/public/brand/mgo-emby-icon.png" width="96" height="96" alt="MediaStationGo Logo" />
|
<img src="web/public/brand/mgo-emby-icon.svg" width="96" height="96" alt="MediaStationGo Logo" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h3 align="center">A lightweight, polished, NAS-friendly private media center</h3>
|
<h3 align="center">A lightweight, polished, NAS-friendly private media center</h3>
|
||||||
@@ -151,8 +151,8 @@ environment:
|
|||||||
Recommended SQLite to PostgreSQL upgrade flow:
|
Recommended SQLite to PostgreSQL upgrade flow:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose pull
|
docker compose pull mediastation-go
|
||||||
docker compose up -d
|
docker compose up -d --no-deps mediastation-go
|
||||||
docker compose logs -f mediastation-go
|
docker compose logs -f mediastation-go
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -384,8 +384,8 @@ services:
|
|||||||
### Update
|
### Update
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose pull
|
docker compose pull mediastation-go
|
||||||
docker compose up -d
|
docker compose up -d --no-deps mediastation-go
|
||||||
```
|
```
|
||||||
|
|
||||||
### Logs
|
### Logs
|
||||||
|
|||||||
@@ -113,13 +113,28 @@ func licenseStatusHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
deviceID, idErr := ensureLicenseDeviceID(c.Request.Context(), svc, "")
|
deviceID, idErr := ensureLicenseDeviceID(c.Request.Context(), svc, "")
|
||||||
if idErr == nil {
|
if idErr == nil {
|
||||||
var upstream licenseServerStatusResp
|
deviceName, _ := svc.Repo.Setting.Get(c.Request.Context(), licenseDeviceNameSetting)
|
||||||
if getErr := client.get(c.Request.Context(), "/api/v1/status/"+url.PathEscape(deviceID), &upstream); getErr == nil && upstream.Valid {
|
if strings.TrimSpace(deviceName) == "" {
|
||||||
applyLicenseStatus(&state, upstream, deviceID)
|
deviceName = defaultLicenseDeviceName()
|
||||||
_ = persistLicenseState(c.Request.Context(), svc, state)
|
_ = svc.Repo.Setting.Set(c.Request.Context(), licenseDeviceNameSetting, deviceName)
|
||||||
} else if getErr == nil && !upstream.Valid {
|
}
|
||||||
state.Valid = false
|
var signed licenseServerSignedResp
|
||||||
|
if heartbeatErr := client.post(c.Request.Context(), "/api/v1/heartbeat", map[string]any{
|
||||||
|
"fingerprint": deviceID,
|
||||||
|
"instance_id": deviceID,
|
||||||
|
"device_name": deviceName,
|
||||||
|
}, &signed); heartbeatErr == nil && client.verifySigned(&signed) == nil {
|
||||||
|
state = licenseStateFromSigned(signed, deviceID, deviceName)
|
||||||
_ = persistLicenseState(c.Request.Context(), svc, state)
|
_ = persistLicenseState(c.Request.Context(), svc, state)
|
||||||
|
} else {
|
||||||
|
var upstream licenseServerStatusResp
|
||||||
|
if getErr := client.get(c.Request.Context(), "/api/v1/status/"+url.PathEscape(deviceID), &upstream); getErr == nil && upstream.Valid {
|
||||||
|
applyLicenseStatus(&state, upstream, deviceID)
|
||||||
|
_ = persistLicenseState(c.Request.Context(), svc, state)
|
||||||
|
} else if getErr == nil && !upstream.Valid {
|
||||||
|
state.Valid = false
|
||||||
|
_ = persistLicenseState(c.Request.Context(), svc, state)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -146,10 +161,16 @@ func licenseHeartbeatHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
deviceName, _ := svc.Repo.Setting.Get(c.Request.Context(), licenseDeviceNameSetting)
|
||||||
|
if strings.TrimSpace(deviceName) == "" {
|
||||||
|
deviceName = defaultLicenseDeviceName()
|
||||||
|
_ = svc.Repo.Setting.Set(c.Request.Context(), licenseDeviceNameSetting, deviceName)
|
||||||
|
}
|
||||||
var upstream licenseServerSignedResp
|
var upstream licenseServerSignedResp
|
||||||
if err := client.post(c.Request.Context(), "/api/v1/heartbeat", map[string]any{
|
if err := client.post(c.Request.Context(), "/api/v1/heartbeat", map[string]any{
|
||||||
"fingerprint": deviceID,
|
"fingerprint": deviceID,
|
||||||
"instance_id": deviceID,
|
"instance_id": deviceID,
|
||||||
|
"device_name": deviceName,
|
||||||
}, &upstream); err != nil {
|
}, &upstream); err != nil {
|
||||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -158,7 +179,6 @@ func licenseHeartbeatHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
deviceName, _ := svc.Repo.Setting.Get(c.Request.Context(), licenseDeviceNameSetting)
|
|
||||||
state := licenseStateFromSigned(upstream, deviceID, deviceName)
|
state := licenseStateFromSigned(upstream, deviceID, deviceName)
|
||||||
if err := persistLicenseState(c.Request.Context(), svc, state); err != nil {
|
if err := persistLicenseState(c.Request.Context(), svc, state); err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
|||||||
@@ -182,9 +182,15 @@ func (s *NotifyChannelService) Test(ctx context.Context, id string) error {
|
|||||||
return s.dispatchOne(ctx, *row, "MediaStationGo 测试通知", "如果你看到这条消息,说明该通道工作正常。")
|
return s.dispatchOne(ctx, *row, "MediaStationGo 测试通知", "如果你看到这条消息,说明该通道工作正常。")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
NotifyEventAll = "__all__"
|
||||||
|
NotifyEventNone = "__none__"
|
||||||
|
)
|
||||||
|
|
||||||
// Broadcast sends a message to every enabled channel that subscribes to
|
// Broadcast sends a message to every enabled channel that subscribes to
|
||||||
// `event` (an empty Events slice means "all events"). Failures are
|
// `event`. Legacy empty Events values mean "all events"; the explicit
|
||||||
// logged and never abort the loop.
|
// NotifyEventNone sentinel means the channel stays enabled but receives no
|
||||||
|
// event push.
|
||||||
func (s *NotifyChannelService) Broadcast(ctx context.Context, title, body, event string) {
|
func (s *NotifyChannelService) Broadcast(ctx context.Context, title, body, event string) {
|
||||||
rows, err := s.repo.NotifyChannel.ListEnabled(ctx)
|
rows, err := s.repo.NotifyChannel.ListEnabled(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -201,10 +207,10 @@ func (s *NotifyChannelService) Broadcast(ctx context.Context, title, body, event
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// channelSubscribes returns true when the channel's Events list is
|
// channelSubscribes returns true when the channel's Events list contains the
|
||||||
// empty (= all events) or contains `event`.
|
// event, or when the list is the legacy empty/"all events" value.
|
||||||
func channelSubscribes(n model.NotifyChannel, event string) bool {
|
func channelSubscribes(n model.NotifyChannel, event string) bool {
|
||||||
if event == "" || n.Events == "" || n.Events == "[]" {
|
if event == "" || n.Events == "" {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
var ev []string
|
var ev []string
|
||||||
@@ -215,6 +221,12 @@ func channelSubscribes(n model.NotifyChannel, event string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
for _, e := range ev {
|
for _, e := range ev {
|
||||||
|
switch e {
|
||||||
|
case NotifyEventNone:
|
||||||
|
return false
|
||||||
|
case NotifyEventAll:
|
||||||
|
return true
|
||||||
|
}
|
||||||
if e == event {
|
if e == event {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestChannelSubscribesCanDisableAllEvents(t *testing.T) {
|
||||||
|
channel := model.NotifyChannel{Events: `["` + NotifyEventNone + `"]`}
|
||||||
|
|
||||||
|
if channelSubscribes(channel, EventDownloadComplete) {
|
||||||
|
t.Fatal("explicit none sentinel should disable event pushes")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChannelSubscribesKeepsLegacyEmptyAsAllEvents(t *testing.T) {
|
||||||
|
for _, raw := range []string{"", "[]"} {
|
||||||
|
channel := model.NotifyChannel{Events: raw}
|
||||||
|
if !channelSubscribes(channel, EventDownloadComplete) {
|
||||||
|
t.Fatalf("legacy events %q should still subscribe to all events", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChannelSubscribesSupportsExplicitAllAndSpecificEvents(t *testing.T) {
|
||||||
|
all := model.NotifyChannel{Events: `["` + NotifyEventAll + `"]`}
|
||||||
|
if !channelSubscribes(all, EventScrapeFailed) {
|
||||||
|
t.Fatal("explicit all sentinel should subscribe to every event")
|
||||||
|
}
|
||||||
|
|
||||||
|
specific := model.NotifyChannel{Events: `["` + EventDownloadComplete + `"]`}
|
||||||
|
if !channelSubscribes(specific, EventDownloadComplete) {
|
||||||
|
t.Fatal("specific event should be subscribed")
|
||||||
|
}
|
||||||
|
if channelSubscribes(specific, EventScrapeFailed) {
|
||||||
|
t.Fatal("unlisted event should not be subscribed")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,11 +16,11 @@ else
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "==> Pulling latest configured image"
|
echo "==> Pulling latest $SERVICE image only (PostgreSQL/Redis/OpenSearch are left untouched)"
|
||||||
"${COMPOSE[@]}" pull "$SERVICE"
|
"${COMPOSE[@]}" pull "$SERVICE"
|
||||||
|
|
||||||
echo "==> Recreating service"
|
echo "==> Recreating $SERVICE without dependencies"
|
||||||
"${COMPOSE[@]}" up -d "$SERVICE"
|
"${COMPOSE[@]}" up -d --no-deps "$SERVICE"
|
||||||
|
|
||||||
running_image_id="$(docker inspect -f '{{.Image}}' "$CONTAINER" 2>/dev/null || true)"
|
running_image_id="$(docker inspect -f '{{.Image}}' "$CONTAINER" 2>/dev/null || true)"
|
||||||
if [[ -z "$running_image_id" ]]; then
|
if [[ -z "$running_image_id" ]]; then
|
||||||
|
|||||||
+2
-2
@@ -5,8 +5,8 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="theme-color" content="#faf8f5" />
|
<meta name="theme-color" content="#faf8f5" />
|
||||||
<meta name="description" content="MediaStationGo — 个人家庭媒体中心" />
|
<meta name="description" content="MediaStationGo — 个人家庭媒体中心" />
|
||||||
<link rel="icon" type="image/png" href="/brand/mgo-emby-icon.png" />
|
<link rel="icon" type="image/svg+xml" href="/brand/mgo-emby-icon.svg" />
|
||||||
<link rel="apple-touch-icon" href="/brand/mgo-emby-icon.png" />
|
<link rel="apple-touch-icon" href="/brand/mgo-emby-icon.svg" />
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link
|
<link
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<svg width="64" height="64" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<rect width="32" height="32" rx="8" fill="url(#mgoPurpleGrad)" />
|
<rect width="32" height="32" rx="8" fill="url(#mgoPurpleGrad)" />
|
||||||
<path d="M11 10 L21 16 L11 22" stroke="white" stroke-width="2.5" stroke-linecap="square" stroke-linejoin="miter" fill="none" />
|
<path d="M11 10 L21 16 L11 22" stroke="white" stroke-width="2.5" stroke-linecap="square" stroke-linejoin="miter" fill="none" />
|
||||||
<path d="M11 18 L11 24 L16 21" stroke="white" stroke-width="2.5" stroke-linecap="square" stroke-linejoin="miter" fill="none" />
|
<path d="M11 18 L11 24 L16 21" stroke="white" stroke-width="2.5" stroke-linecap="square" stroke-linejoin="miter" fill="none" />
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 705 B After Width: | Height: | Size: 705 B |
+11
-2
@@ -1,3 +1,12 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="MediaStationGo">
|
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="MediaStationGo">
|
||||||
<image href="/brand/mgo-emby-icon.png" width="64" height="64" preserveAspectRatio="xMidYMid meet"/>
|
<rect width="32" height="32" rx="8" fill="url(#mgoPurpleGrad)" />
|
||||||
|
<path d="M11 10 L21 16 L11 22" stroke="white" stroke-width="2.5" stroke-linecap="square" stroke-linejoin="miter" fill="none" />
|
||||||
|
<path d="M11 18 L11 24 L16 21" stroke="white" stroke-width="2.5" stroke-linecap="square" stroke-linejoin="miter" fill="none" />
|
||||||
|
<circle cx="23" cy="9" r="2" fill="#06B6D4" />
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="mgoPurpleGrad" x1="0" y1="0" x2="32" y2="32" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#7C3AED" />
|
||||||
|
<stop offset="1" stop-color="#5B21B6" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 209 B After Width: | Height: | Size: 744 B |
@@ -192,7 +192,7 @@ export function Layout() {
|
|||||||
<div className="flex h-20 items-center justify-between px-6 border-b border-gray-100">
|
<div className="flex h-20 items-center justify-between px-6 border-b border-gray-100">
|
||||||
<Link to="/" className="flex items-center gap-3">
|
<Link to="/" className="flex items-center gap-3">
|
||||||
<img
|
<img
|
||||||
src="/brand/mgo-emby-icon.png"
|
src="/brand/mgo-emby-icon.svg"
|
||||||
alt="MediaStationGo"
|
alt="MediaStationGo"
|
||||||
className="h-10 w-10 shrink-0 rounded-xl object-contain shadow-sm"
|
className="h-10 w-10 shrink-0 rounded-xl object-contain shadow-sm"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export function LoginPage() {
|
|||||||
initial={{ scale: 0.8, opacity: 0 }}
|
initial={{ scale: 0.8, opacity: 0 }}
|
||||||
animate={{ scale: 1, opacity: 1 }}
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
transition={{ delay: 0.15, type: 'spring', stiffness: 200 }}
|
transition={{ delay: 0.15, type: 'spring', stiffness: 200 }}
|
||||||
src="/brand/mgo-emby-icon.png"
|
src="/brand/mgo-emby-icon.svg"
|
||||||
alt="MediaStationGo"
|
alt="MediaStationGo"
|
||||||
className="mb-4 h-14 w-14 rounded-2xl object-contain shadow-sm"
|
className="mb-4 h-14 w-14 rounded-2xl object-contain shadow-sm"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -197,6 +197,16 @@ const EVENT_OPTIONS = [
|
|||||||
{ value: 'system_alert', label: '系统异常通知' },
|
{ value: 'system_alert', label: '系统异常通知' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const EVENT_ALL = '__all__'
|
||||||
|
const EVENT_NONE = '__none__'
|
||||||
|
type EventMode = 'all' | 'custom' | 'none'
|
||||||
|
|
||||||
|
function initialEventMode(events: string[] | undefined): EventMode {
|
||||||
|
if (events?.includes(EVENT_NONE)) return 'none'
|
||||||
|
if (events?.includes(EVENT_ALL) || !events || events.length === 0) return 'all'
|
||||||
|
return 'custom'
|
||||||
|
}
|
||||||
|
|
||||||
function ChannelCard({
|
function ChannelCard({
|
||||||
channel,
|
channel,
|
||||||
onTest,
|
onTest,
|
||||||
@@ -252,6 +262,8 @@ function ChannelCard({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function eventSummary(events: string[] | undefined): string {
|
function eventSummary(events: string[] | undefined): string {
|
||||||
|
if (events?.includes(EVENT_NONE)) return '事件:不推送'
|
||||||
|
if (events?.includes(EVENT_ALL)) return '事件:全部'
|
||||||
if (!events || events.length === 0) return '事件:全部'
|
if (!events || events.length === 0) return '事件:全部'
|
||||||
const labels = events.map((event) => EVENT_OPTIONS.find((item) => item.value === event)?.label ?? event)
|
const labels = events.map((event) => EVENT_OPTIONS.find((item) => item.value === event)?.label ?? event)
|
||||||
return `事件:${labels.join('、')}`
|
return `事件:${labels.join('、')}`
|
||||||
@@ -324,6 +336,7 @@ function ChannelFormModal({
|
|||||||
normalizeInitialConfig(editing?.type ?? 'telegram', editing?.config ?? {}),
|
normalizeInitialConfig(editing?.type ?? 'telegram', editing?.config ?? {}),
|
||||||
)
|
)
|
||||||
const [events, setEvents] = useState<string[]>(editing?.events ?? [])
|
const [events, setEvents] = useState<string[]>(editing?.events ?? [])
|
||||||
|
const [eventMode, setEventMode] = useState<EventMode>(initialEventMode(editing?.events))
|
||||||
const [enabled, setEnabled] = useState(editing?.enabled ?? true)
|
const [enabled, setEnabled] = useState(editing?.enabled ?? true)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
@@ -344,6 +357,11 @@ function ChannelFormModal({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const selectedEvents = events.filter((event) => EVENT_OPTIONS.some((item) => item.value === event))
|
||||||
|
if (eventMode === 'custom' && selectedEvents.length === 0) {
|
||||||
|
toast.error('请至少选择一个推送事件,或选择关闭全部推送事件')
|
||||||
|
return
|
||||||
|
}
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
const cleanedConfig = Object.fromEntries(
|
const cleanedConfig = Object.fromEntries(
|
||||||
@@ -354,7 +372,7 @@ function ChannelFormModal({
|
|||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
type: type,
|
type: type,
|
||||||
config: cleanedConfig,
|
config: cleanedConfig,
|
||||||
events,
|
events: eventMode === 'all' ? [EVENT_ALL] : eventMode === 'none' ? [EVENT_NONE] : selectedEvents,
|
||||||
enabled,
|
enabled,
|
||||||
}
|
}
|
||||||
if (editing) {
|
if (editing) {
|
||||||
@@ -378,7 +396,9 @@ function ChannelFormModal({
|
|||||||
|
|
||||||
const toggleEvent = (event: string) => {
|
const toggleEvent = (event: string) => {
|
||||||
setEvents((current) =>
|
setEvents((current) =>
|
||||||
current.includes(event) ? current.filter((item) => item !== event) : [...current, event],
|
current.includes(event)
|
||||||
|
? current.filter((item) => item !== event)
|
||||||
|
: [...current.filter((item) => item !== EVENT_ALL && item !== EVENT_NONE), event],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -622,19 +642,41 @@ function ChannelFormModal({
|
|||||||
<div className="space-y-2 rounded-lg border border-gray-200 bg-gray-50/60 p-3">
|
<div className="space-y-2 rounded-lg border border-gray-200 bg-gray-50/60 p-3">
|
||||||
<label className="flex cursor-pointer items-center gap-2 text-sm text-ink-100">
|
<label className="flex cursor-pointer items-center gap-2 text-sm text-ink-100">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="radio"
|
||||||
|
name="notify-event-mode"
|
||||||
className="h-4 w-4 accent-primary-400"
|
className="h-4 w-4 accent-primary-400"
|
||||||
checked={events.length === 0}
|
checked={eventMode === 'all'}
|
||||||
onChange={() => setEvents([])}
|
onChange={() => setEventMode('all')}
|
||||||
/>
|
/>
|
||||||
全部事件
|
全部事件
|
||||||
</label>
|
</label>
|
||||||
|
<label className="flex cursor-pointer items-center gap-2 text-sm text-ink-100">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="notify-event-mode"
|
||||||
|
className="h-4 w-4 accent-primary-400"
|
||||||
|
checked={eventMode === 'none'}
|
||||||
|
onChange={() => setEventMode('none')}
|
||||||
|
/>
|
||||||
|
关闭全部推送事件
|
||||||
|
</label>
|
||||||
|
<label className="flex cursor-pointer items-center gap-2 text-sm text-ink-100">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="notify-event-mode"
|
||||||
|
className="h-4 w-4 accent-primary-400"
|
||||||
|
checked={eventMode === 'custom'}
|
||||||
|
onChange={() => setEventMode('custom')}
|
||||||
|
/>
|
||||||
|
仅推送勾选事件
|
||||||
|
</label>
|
||||||
<div className="grid gap-2 sm:grid-cols-2">
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
{EVENT_OPTIONS.map((event) => (
|
{EVENT_OPTIONS.map((event) => (
|
||||||
<label key={event.value} className="flex cursor-pointer items-center gap-2 text-sm text-ink-100">
|
<label key={event.value} className="flex cursor-pointer items-center gap-2 text-sm text-ink-100">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="h-4 w-4 accent-primary-400"
|
className="h-4 w-4 accent-primary-400"
|
||||||
|
disabled={eventMode !== 'custom'}
|
||||||
checked={events.includes(event.value)}
|
checked={events.includes(event.value)}
|
||||||
onChange={() => toggleEvent(event.value)}
|
onChange={() => toggleEvent(event.value)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user