Files
ReporterStationManagementSy…/src/pages/Notices/index.tsx
T
2026-08-01 23:09:49 +08:00

360 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}