初始提交:全国记者站管理系统

This commit is contained in:
selfrelease
2026-08-01 23:09:49 +08:00
commit 45fbba0308
96 changed files with 21514 additions and 0 deletions
+360
View File
@@ -0,0 +1,360 @@
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>
)
}