diff --git a/deploy/docker/docker-compose.yml b/deploy/docker/docker-compose.yml index 8fd7303..55e4ac6 100644 --- a/deploy/docker/docker-compose.yml +++ b/deploy/docker/docker-compose.yml @@ -71,6 +71,8 @@ services: condition: service_healthy referral-service: condition: service_healthy + notification-service: + condition: service_healthy healthcheck: test: ["CMD", "kong", "health"] interval: 10s @@ -469,6 +471,37 @@ services: networks: - it0-network + notification-service: + build: + context: ../.. + dockerfile: Dockerfile.service + args: + SERVICE_NAME: notification-service + SERVICE_PORT: 3013 + container_name: it0-notification-service + restart: unless-stopped + ports: + - "13013:3013" + environment: + - DB_HOST=postgres + - DB_PORT=5432 + - DB_USERNAME=${POSTGRES_USER:-it0} + - DB_PASSWORD=${POSTGRES_PASSWORD:-it0_dev} + - DB_DATABASE=${POSTGRES_DB:-it0} + - NOTIFICATION_SERVICE_PORT=3013 + - JWT_SECRET=${JWT_SECRET:-dev-jwt-secret} + healthcheck: + test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:3013/',r=>{process.exit(r.statusCode<500?0:1)}).on('error',()=>process.exit(1))\""] + interval: 30s + timeout: 5s + retries: 3 + start_period: 15s + depends_on: + postgres: + condition: service_healthy + networks: + - it0-network + # ===== LiveKit Infrastructure ===== # NOTE: livekit-server, voice-agent, voice-service use host networking # to eliminate docker-proxy overhead for real-time audio (WebRTC UDP). diff --git a/it0-web-admin/src/app/(admin)/campaigns/page.tsx b/it0-web-admin/src/app/(admin)/campaigns/page.tsx new file mode 100644 index 0000000..b2fd1e0 --- /dev/null +++ b/it0-web-admin/src/app/(admin)/campaigns/page.tsx @@ -0,0 +1,488 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { Megaphone, Plus, RefreshCw, Pencil, Trash2, X, BarChart2, Play, Ban } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +type CampaignStatus = 'DRAFT' | 'SCHEDULED' | 'RUNNING' | 'COMPLETED' | 'CANCELLED' | 'FAILED'; +type ScheduleType = 'ONCE' | 'RECURRING'; + +interface Campaign { + id: string; + name: string; + description: string | null; + status: CampaignStatus; + notificationId: string | null; + scheduleType: ScheduleType; + scheduledAt: string | null; + cronExpr: string | null; + timezone: string; + nextRunAt: string | null; + lastRunAt: string | null; + targetCount: number; + sentCount: number; + readCount: number; + createdBy: string | null; + createdAt: string; + updatedAt: string; +} + +interface CampaignStats { + targetCount: number; + sentCount: number; + readCount: number; + readRate: number; + executions: any[]; +} + +function getAuthHeaders(): HeadersInit { + const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null; + return { Authorization: `Bearer ${token ?? ''}`, 'Content-Type': 'application/json' }; +} + +const BASE = '/api/proxy/api/v1/notifications/campaigns'; + +async function listCampaigns(params: { status?: string; limit?: number; offset?: number }) { + const q = new URLSearchParams(); + if (params.status) q.set('status', params.status); + if (params.limit != null) q.set('limit', String(params.limit)); + if (params.offset != null) q.set('offset', String(params.offset)); + const res = await fetch(`${BASE}?${q}`, { headers: getAuthHeaders() }); + if (!res.ok) throw new Error(`${res.status}`); + return res.json() as Promise<{ items: Campaign[]; total: number }>; +} + +async function createCampaign(dto: any) { + const res = await fetch(BASE, { method: 'POST', headers: getAuthHeaders(), body: JSON.stringify(dto) }); + if (!res.ok) throw new Error(`${res.status}`); + return res.json(); +} + +async function updateCampaign(id: string, dto: any) { + const res = await fetch(`${BASE}/${id}`, { method: 'PUT', headers: getAuthHeaders(), body: JSON.stringify(dto) }); + if (!res.ok) throw new Error(`${res.status}`); + return res.json(); +} + +async function scheduleCampaign(id: string, dto: { scheduledAt?: string; cronExpr?: string; timezone?: string }) { + const res = await fetch(`${BASE}/${id}/schedule`, { method: 'POST', headers: getAuthHeaders(), body: JSON.stringify(dto) }); + if (!res.ok) throw new Error(`${res.status}`); + return res.json(); +} + +async function cancelCampaign(id: string) { + const res = await fetch(`${BASE}/${id}/cancel`, { method: 'POST', headers: getAuthHeaders() }); + if (!res.ok) throw new Error(`${res.status}`); + return res.json(); +} + +async function deleteCampaign(id: string) { + const res = await fetch(`${BASE}/${id}`, { method: 'DELETE', headers: getAuthHeaders() }); + if (!res.ok) throw new Error(`${res.status}`); +} + +async function getCampaignStats(id: string): Promise { + const res = await fetch(`${BASE}/${id}/stats`, { headers: getAuthHeaders() }); + if (!res.ok) throw new Error(`${res.status}`); + return res.json(); +} + +/* ---------- Status badge ---------- */ + +const STATUS_STYLE: Record = { + DRAFT: 'bg-gray-100 text-gray-600', + SCHEDULED: 'bg-blue-100 text-blue-700', + RUNNING: 'bg-yellow-100 text-yellow-700', + COMPLETED: 'bg-green-100 text-green-700', + CANCELLED: 'bg-gray-100 text-gray-500', + FAILED: 'bg-red-100 text-red-700', +}; +const STATUS_LABEL: Record = { + DRAFT: '草稿', SCHEDULED: '已排期', RUNNING: '执行中', + COMPLETED: '已完成', CANCELLED: '已取消', FAILED: '失败', +}; + +/* ---------- Stats Modal ---------- */ + +function StatsModal({ campaignId, name, onClose }: { campaignId: string; name: string; onClose: () => void }) { + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + getCampaignStats(campaignId) + .then(setStats) + .catch(console.error) + .finally(() => setLoading(false)); + }, [campaignId]); + + return ( +
+
+
+

{name} — 数据统计

+ +
+
+ {loading ? ( +

加载中…

+ ) : stats ? ( +
+
+ {[ + { label: '目标人数', value: stats.targetCount }, + { label: '已发送', value: stats.sentCount }, + { label: '已阅读', value: stats.readCount }, + ].map((item) => ( +
+

{item.value.toLocaleString()}

+

{item.label}

+
+ ))} +
+
+

{stats.readRate}%

+

阅读率

+
+ {stats.executions.length > 0 && ( +
+

最近执行记录

+
+ {stats.executions.map((ex: any, i: number) => ( +
+ {new Date(ex.started_at).toLocaleString('zh-CN', { dateStyle: 'short', timeStyle: 'short' })} + + {ex.status} + + {ex.sent_count} 发送 +
+ ))} +
+
+ )} +
+ ) : ( +

暂无数据

+ )} +
+
+
+ ); +} + +/* ---------- Campaign Modal ---------- */ + +function CampaignModal({ + initial, + onSave, + onClose, +}: { + initial?: Campaign; + onSave: (dto: any, id?: string) => Promise; + onClose: () => void; +}) { + const [name, setName] = useState(initial?.name ?? ''); + const [description, setDescription] = useState(initial?.description ?? ''); + const [notificationId, setNotificationId] = useState(initial?.notificationId ?? ''); + const [scheduleType, setScheduleType] = useState(initial?.scheduleType ?? 'ONCE'); + const [scheduledAt, setScheduledAt] = useState( + initial?.scheduledAt ? initial.scheduledAt.slice(0, 16) : '', + ); + const [cronExpr, setCronExpr] = useState(initial?.cronExpr ?? ''); + const [timezone, setTimezone] = useState(initial?.timezone ?? 'Asia/Shanghai'); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + + const handleSubmit = async () => { + if (!name.trim()) { setError('活动名称不能为空'); return; } + setSaving(true); setError(''); + try { + await onSave({ + name: name.trim(), + description: description.trim() || undefined, + notificationId: notificationId.trim() || undefined, + scheduleType, + scheduledAt: scheduleType === 'ONCE' && scheduledAt ? new Date(scheduledAt).toISOString() : null, + cronExpr: scheduleType === 'RECURRING' ? cronExpr.trim() : null, + timezone, + }, initial?.id); + onClose(); + } catch (e: any) { + setError(e.message ?? '保存失败'); + } finally { + setSaving(false); + } + }; + + return ( +
+
+
+

{initial ? '编辑活动' : '新建推送活动'}

+ +
+
+ {error &&

{error}

} + +
+ + setName(e.target.value)} maxLength={200} /> +
+ +
+ +