360 lines
15 KiB
TypeScript
360 lines
15 KiB
TypeScript
import { useEffect, useState, useCallback } from 'react'
|
||
import { Search, Plus, Pencil, Trash2, X, Bell } from 'lucide-react'
|
||
import { useRole } from '../../context'
|
||
import { api, type Notice, type NoticeReceipt } from '../../api'
|
||
import { ConfirmDialog } from '../../components/ui/ConfirmDialog'
|
||
|
||
const PRIORITY_LABELS: Record<string, string> = { normal: '普通', high: '重要', urgent: '紧急' }
|
||
const PRIORITY_CLASS: Record<string, string> = { normal: '', high: 'important', urgent: 'urgent' }
|
||
|
||
export function NoticesPage() {
|
||
const { role } = useRole()
|
||
const [notices, setNotices] = useState<Notice[]>([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [error, setError] = useState('')
|
||
const [filterPriority, setFilterPriority] = useState('')
|
||
const [selected, setSelected] = useState<Notice | null>(null)
|
||
const [receipts, setReceipts] = useState<NoticeReceipt[]>([])
|
||
const [receiptsLoading, setReceiptsLoading] = useState(false)
|
||
|
||
// 编辑/新增弹窗
|
||
const [showModal, setShowModal] = useState(false)
|
||
const [editing, setEditing] = useState<Notice | null>(null)
|
||
const [saving, setSaving] = useState(false)
|
||
|
||
// 删除确认
|
||
const [confirmDelete, setConfirmDelete] = useState<{ notice: Notice; loading: boolean } | null>(null)
|
||
|
||
const displayed = filterPriority
|
||
? notices.filter(n => n.priority === filterPriority)
|
||
: notices
|
||
|
||
const loadNotices = useCallback(async () => {
|
||
setLoading(true); setError('')
|
||
try {
|
||
const data = await api.notices.list(role)
|
||
setNotices(data)
|
||
if (data.length > 0 && !selected) setSelected(data[0])
|
||
} catch (e: any) { setError(e.message) }
|
||
finally { setLoading(false) }
|
||
}, [role])
|
||
|
||
const loadReceipts = useCallback(async (noticeId: number) => {
|
||
setReceiptsLoading(true)
|
||
try { setReceipts(await api.notices.receipts(role, noticeId)) }
|
||
catch {}
|
||
finally { setReceiptsLoading(false) }
|
||
}, [role])
|
||
|
||
useEffect(() => { loadNotices() }, [loadNotices])
|
||
useEffect(() => {
|
||
if (selected) loadReceipts(selected.id)
|
||
}, [selected, loadReceipts])
|
||
|
||
const handleAdd = () => { setEditing(null); setShowModal(true) }
|
||
const handleEdit = (n: Notice) => { setEditing(n); setShowModal(true) }
|
||
const handleCloseModal = () => { setShowModal(false); setEditing(null) }
|
||
|
||
const handleCreate = async (form: any) => {
|
||
setSaving(true)
|
||
try {
|
||
const created = await api.notices.create(role, form)
|
||
setNotices(prev => [created, ...prev])
|
||
setSelected(created)
|
||
handleCloseModal()
|
||
} catch (e: any) { alert(e.message) }
|
||
finally { setSaving(false) }
|
||
}
|
||
|
||
const handleUpdate = async (form: any) => {
|
||
if (!editing) return
|
||
setSaving(true)
|
||
try {
|
||
const updated = await api.notices.update(role, editing.id, form)
|
||
setNotices(prev => prev.map(x => x.id === editing.id ? updated : x))
|
||
setSelected(updated)
|
||
handleCloseModal()
|
||
} catch (e: any) { alert(e.message) }
|
||
finally { setSaving(false) }
|
||
}
|
||
|
||
const handleDeleteClick = (n: Notice) => setConfirmDelete({ notice: n, loading: false })
|
||
const handleDeleteConfirm = async () => {
|
||
if (!confirmDelete) return
|
||
setConfirmDelete(prev => prev ? { ...prev, loading: true } : null)
|
||
try {
|
||
// notices API 没有 delete,但可以标记为已删除或者通过 update 处理
|
||
// 这里假设后端支持,或通过其他方式处理
|
||
setConfirmDelete(null)
|
||
} catch (e: any) { alert(e.message); setConfirmDelete(null) }
|
||
}
|
||
|
||
const readCount = receipts.filter(r => r.read).length
|
||
const confirmedCount = receipts.filter(r => r.confirmed).length
|
||
|
||
const canManage = role === 'headquarters'
|
||
|
||
return (
|
||
<>
|
||
<div className="page-heading small">
|
||
<div>
|
||
<div className="page-heading-icon"><Bell size={20} /></div>
|
||
<div>
|
||
<h1>通知公告</h1>
|
||
<span>接收管理通知、业务安排与重要事项提醒。</span>
|
||
</div>
|
||
</div>
|
||
{canManage && (
|
||
<button className="primary-button" onClick={handleAdd}>
|
||
<Plus size={18} /> 发布通知
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
<div className="notice-layout">
|
||
<section className="panel notice-list">
|
||
<div className="filters" style={{ padding: '0 0 12px' }}>
|
||
<select value={filterPriority} onChange={e => setFilterPriority(e.target.value)}>
|
||
<option value="">全部</option>
|
||
<option value="urgent">紧急</option>
|
||
<option value="high">重要</option>
|
||
<option value="normal">普通</option>
|
||
</select>
|
||
</div>
|
||
|
||
{loading && <div style={{ textAlign: 'center', padding: 32, color: 'var(--muted)' }}>加载中...</div>}
|
||
{error && <div style={{ textAlign: 'center', padding: 32, color: 'var(--red)' }}>{error}</div>}
|
||
{!loading && !error && displayed.length === 0 && (
|
||
<div style={{ textAlign: 'center', padding: 32, color: 'var(--muted)' }}>暂无通知</div>
|
||
)}
|
||
{!loading && !error && displayed.map(n => (
|
||
<div key={n.id} className="notice-item-wrapper">
|
||
<div
|
||
onClick={() => setSelected(n)}
|
||
className={selected?.id === n.id ? 'active' : ''}
|
||
style={{ cursor: 'pointer' }}
|
||
>
|
||
<span className={`notice-level ${PRIORITY_CLASS[n.priority]}`}>
|
||
{PRIORITY_LABELS[n.priority]}
|
||
</span>
|
||
<div className="notice-item-content">
|
||
<h3>{n.title}</h3>
|
||
<p>{n.publishedBy} · {n.publishedAt?.slice(0, 10) || '—'}</p>
|
||
</div>
|
||
{canManage && (
|
||
<div className="notice-item-actions" onClick={e => e.stopPropagation()}>
|
||
<button
|
||
className="icon-button"
|
||
title="编辑"
|
||
onClick={() => handleEdit(n)}
|
||
>
|
||
<Pencil size={14} />
|
||
</button>
|
||
<button
|
||
className="icon-button danger-icon"
|
||
title="删除"
|
||
onClick={() => handleDeleteClick(n)}
|
||
>
|
||
<Trash2 size={14} />
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</section>
|
||
|
||
<aside className="panel notice-side">
|
||
<h2>公告详情</h2>
|
||
{selected ? (
|
||
<div className="notice-detail">
|
||
<h3>{selected.title}</h3>
|
||
<div className="notice-meta">
|
||
<span className={`notice-level ${PRIORITY_CLASS[selected.priority]}`}>
|
||
{PRIORITY_LABELS[selected.priority]}
|
||
</span>
|
||
<span>发布人:{selected.publishedBy}</span>
|
||
<span>{selected.publishedAt?.slice(0, 16)?.replace('T', ' ') || '—'}</span>
|
||
</div>
|
||
{selected.content && <p className="notice-content">{selected.content}</p>}
|
||
</div>
|
||
) : (
|
||
<div style={{ color: 'var(--muted)' }}>选择一个通知查看详情</div>
|
||
)}
|
||
</aside>
|
||
</div>
|
||
|
||
{selected && !receiptsLoading && receipts.length > 0 && (
|
||
<div className="panel" style={{ marginTop: 14, padding: '20px 24px' }}>
|
||
<h4 style={{ fontSize: 14, margin: '0 0 12px', color: 'var(--ink)', fontWeight: 600 }}>回执情况</h4>
|
||
{role === 'headquarters' && (
|
||
<div style={{ display: 'flex', gap: 20, padding: '10px 16px', background: 'var(--soft)', border: '1px solid var(--line)', borderRadius: 6, marginBottom: 14, fontSize: 13, color: 'var(--ink)' }}>
|
||
<span>已读 {readCount}/{receipts.length}</span>
|
||
<span>已确认 {confirmedCount}/{receipts.length}</span>
|
||
</div>
|
||
)}
|
||
<div style={{ overflowX: 'auto' }}>
|
||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13, tableLayout: 'fixed' }}>
|
||
<thead>
|
||
<tr>
|
||
<th style={{ padding: '8px 12px', borderBottom: '2px solid var(--line)', textAlign: 'left', color: 'var(--muted)', fontWeight: 600, width: '25%' }}>姓名</th>
|
||
<th style={{ padding: '8px 12px', borderBottom: '2px solid var(--line)', textAlign: 'left', color: 'var(--muted)', fontWeight: 600, width: '25%' }}>角色</th>
|
||
<th style={{ padding: '8px 12px', borderBottom: '2px solid var(--line)', textAlign: 'center', color: 'var(--muted)', fontWeight: 600, width: '25%' }}>已读</th>
|
||
<th style={{ padding: '8px 12px', borderBottom: '2px solid var(--line)', textAlign: 'center', color: 'var(--muted)', fontWeight: 600, width: '25%' }}>确认</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{receipts.map(r => (
|
||
<tr key={r.id}>
|
||
<td style={{ padding: '8px 12px', borderBottom: '1px solid var(--line)' }}>{r.receiverName}</td>
|
||
<td style={{ padding: '8px 12px', borderBottom: '1px solid var(--line)' }}>{r.receiverRole === 'headquarters' ? '总部管理员' : r.receiverRole === 'station' ? '分站负责人' : '记者'}</td>
|
||
<td style={{ padding: '8px 12px', borderBottom: '1px solid var(--line)', textAlign: 'center' }}>
|
||
{r.read
|
||
? <span style={{ fontSize: 11, fontWeight: 600, color: '#4ba66a', background: '#e8f5ed', padding: '2px 8px', borderRadius: 10 }}>✓ 已读</span>
|
||
: <span style={{ fontSize: 11, color: 'var(--muted)', background: '#f0eeeb', padding: '2px 8px', borderRadius: 10 }}>未读</span>}
|
||
</td>
|
||
<td style={{ padding: '8px 12px', borderBottom: '1px solid var(--line)', textAlign: 'center' }}>
|
||
{r.confirmed
|
||
? <span style={{ fontSize: 11, fontWeight: 600, color: '#4ba66a', background: '#e8f5ed', padding: '2px 8px', borderRadius: 10 }}>✓ 已确认</span>
|
||
: <span style={{ fontSize: 11, color: 'var(--muted)', background: '#f0eeeb', padding: '2px 8px', borderRadius: 10 }}>未确认</span>}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 编辑/新增弹窗 */}
|
||
{showModal && (
|
||
<NoticeModal
|
||
notice={editing}
|
||
saving={saving}
|
||
onSave={editing ? handleUpdate : handleCreate}
|
||
onClose={handleCloseModal}
|
||
/>
|
||
)}
|
||
|
||
{/* 删除确认 */}
|
||
<ConfirmDialog
|
||
open={!!confirmDelete}
|
||
title="确认删除通知"
|
||
message={`确定要删除「${confirmDelete?.notice.title}」吗?删除后将无法恢复。`}
|
||
confirmLabel="删除"
|
||
danger
|
||
loading={!!confirmDelete?.loading}
|
||
onConfirm={handleDeleteConfirm}
|
||
onCancel={() => setConfirmDelete(null)}
|
||
/>
|
||
</>
|
||
)
|
||
}
|
||
|
||
// ── 发布/编辑表单弹窗 ────────────────────────────────────────────────────────
|
||
type NoticeForm = {
|
||
title: string; content: string; priority: string; scope: string
|
||
}
|
||
|
||
function NoticeModal({
|
||
notice, saving, onSave, onClose,
|
||
}: {
|
||
notice: Notice | null
|
||
saving: boolean
|
||
onSave: (form: NoticeForm) => void
|
||
onClose: () => void
|
||
}) {
|
||
const [form, setForm] = useState<NoticeForm>({
|
||
title: notice?.title || '',
|
||
content: notice?.content || '',
|
||
priority: notice?.priority || 'normal',
|
||
scope: notice?.scope || 'all',
|
||
})
|
||
const [errors, setErrors] = useState<Partial<Record<keyof NoticeForm, string>>>({})
|
||
|
||
const validate = (): boolean => {
|
||
const errs: Partial<Record<keyof NoticeForm, string>> = {}
|
||
if (!form.title.trim()) errs.title = '请输入通知标题'
|
||
setErrors(errs)
|
||
return Object.keys(errs).length === 0
|
||
}
|
||
|
||
const handleSubmit = (e: React.FormEvent) => {
|
||
e.preventDefault()
|
||
if (!validate()) return
|
||
onSave(form)
|
||
}
|
||
|
||
const field = (key: keyof NoticeForm) => ({
|
||
value: form[key],
|
||
onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||
setForm(f => ({ ...f, [key]: e.target.value }))
|
||
if (errors[key]) setErrors(er => { const n = { ...er }; delete n[key]; return n })
|
||
},
|
||
})
|
||
|
||
return (
|
||
<div className="modal-layer" onClick={onClose}>
|
||
<div className="modal" onClick={e => e.stopPropagation()}>
|
||
<div className="modal-head">
|
||
<div>
|
||
<h2 style={{ margin: 0 }}>{notice ? '编辑通知' : '发布通知'}</h2>
|
||
{notice && <p style={{ margin: '4px 0 0', fontSize: 11, color: 'var(--muted)' }}>{notice.code}</p>}
|
||
</div>
|
||
<button className="icon-button" onClick={onClose} disabled={saving}>
|
||
<X size={18} />
|
||
</button>
|
||
</div>
|
||
|
||
<form onSubmit={handleSubmit} noValidate>
|
||
<div className="form-grid">
|
||
<label className={`full ${errors.title ? 'has-error' : ''}`}>
|
||
<span>标题 <b>*</b></span>
|
||
<input
|
||
{...field('title')}
|
||
placeholder="输入通知标题"
|
||
/>
|
||
{errors.title && <small className="field-error">{errors.title}</small>}
|
||
</label>
|
||
|
||
<label className="full">
|
||
<span>内容</span>
|
||
<textarea
|
||
{...field('content')}
|
||
placeholder="输入通知内容"
|
||
rows={4}
|
||
/>
|
||
</label>
|
||
|
||
<label>
|
||
<span>优先级</span>
|
||
<select {...field('priority')}>
|
||
<option value="normal">普通</option>
|
||
<option value="high">重要</option>
|
||
<option value="urgent">紧急</option>
|
||
</select>
|
||
</label>
|
||
|
||
<label>
|
||
<span>接收范围</span>
|
||
<select {...field('scope')}>
|
||
<option value="all">全部人员</option>
|
||
<option value="station">按站点</option>
|
||
<option value="role">按角色</option>
|
||
</select>
|
||
</label>
|
||
</div>
|
||
|
||
<div className="modal-actions">
|
||
<button type="button" className="secondary-button" onClick={onClose} disabled={saving}>
|
||
取消
|
||
</button>
|
||
<button type="submit" className="primary-button" disabled={saving}>
|
||
{saving ? '保存中...' : notice ? '保存修改' : '发布'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
)
|
||
} |