初始提交:全国记者站管理系统
This commit is contained in:
+219
@@ -0,0 +1,219 @@
|
||||
import { useState, useEffect, lazy, Suspense } from 'react'
|
||||
import { Routes, Route, useNavigate, useLocation } from 'react-router-dom'
|
||||
import { LoadingBar } from './components/ui'
|
||||
|
||||
const Dashboard = lazy(() => import('./pages/Dashboard').then(m => ({ default: m.Dashboard })))
|
||||
const WorkList = lazy(() => import('./pages/WorkList').then(m => ({ default: m.WorkList })))
|
||||
const ReviewCenter = lazy(() => import('./pages/ReviewCenter').then(m => ({ default: m.ReviewCenter })))
|
||||
const PeoplePage = lazy(() => import('./pages/People').then(m => ({ default: m.PeoplePage })))
|
||||
const StationsPage = lazy(() => import('./pages/Stations').then(m => ({ default: m.StationsPage })))
|
||||
const ArchivePage = lazy(() => import('./pages/Archive').then(m => ({ default: m.ArchivePage })))
|
||||
const NoticesPage = lazy(() => import('./pages/Notices').then(m => ({ default: m.NoticesPage })))
|
||||
const SettingsPage = lazy(() => import('./pages/Settings').then(m => ({ default: m.SettingsPage })))
|
||||
const RulesPage = lazy(() => import('./pages/Rules').then(m => ({ default: m.RulesPage })))
|
||||
const ScoresPage = lazy(() => import('./pages/Scores').then(m => ({ default: m.ScoresPage })))
|
||||
const LeaderboardPage = lazy(() => import('./pages/Leaderboard').then(m => ({ default: m.LeaderboardPage })))
|
||||
const AppealsPage = lazy(() => import('./pages/Appeals').then(m => ({ default: m.AppealsPage })))
|
||||
const SystemLogsPage = lazy(() => import('./pages/SystemLogs').then(m => ({ default: m.SystemLogsPage })))
|
||||
const StationMapPage = lazy(() => import('./pages/StationMap').then(m => ({ default: m.StationMapPage })))
|
||||
const CockpitPage = lazy(() => import('./pages/Cockpit').then(m => ({ default: m.CockpitPage })))
|
||||
const ProfilePage = lazy(() => import('./pages/Profile').then(m => ({ default: m.ProfilePage })))
|
||||
const LoginPage = lazy(() => import('./pages/Login').then(m => ({ default: m.LoginPage })))
|
||||
import { Sidebar, TopBar } from './components/layout'
|
||||
const CreateRecordModal = lazy(() => import('./modals/CreateRecordModal').then(m => ({ default: m.CreateRecordModal })))
|
||||
const RecordDrawer = lazy(() => import('./modals/RecordDrawer').then(m => ({ default: m.RecordDrawer })))
|
||||
import { RoleProvider, ToastProvider, useRole, useToast } from './context'
|
||||
import { api } from './api'
|
||||
import { navLabels, pathMap, pathToPage } from './routes'
|
||||
import type { WorkRecord } from './types'
|
||||
import type { PageKey } from './routes'
|
||||
|
||||
function AppShell() {
|
||||
const { role, isAuthenticated } = useRole()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const { showToast: showToastCtx } = useToast()
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [selected, setSelected] = useState<WorkRecord | null>(null)
|
||||
const [records, setRecords] = useState<WorkRecord[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadError, setLoadError] = useState('')
|
||||
const [toast, setToast] = useState('')
|
||||
|
||||
const page: PageKey = pathToPage(location.pathname)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
setLoading(true)
|
||||
setLoadError('')
|
||||
api.records(role)
|
||||
.then(data => { if (active) setRecords(data) })
|
||||
.catch(err => { if (active) setLoadError(err.message) })
|
||||
.finally(() => { if (active) setLoading(false) })
|
||||
return () => { active = false }
|
||||
}, [role])
|
||||
|
||||
// 角色受限页面自动跳转
|
||||
useEffect(() => {
|
||||
const restricted: Record<string, PageKey[]> = {
|
||||
reporter: ['review', 'people', 'stations', 'stationmap', 'cockpit'],
|
||||
station: ['stations', 'stationmap', 'cockpit'],
|
||||
}
|
||||
if (restricted[role]?.includes(page)) {
|
||||
navigate('/')
|
||||
}
|
||||
}, [role, page, navigate])
|
||||
|
||||
const navigateTo = (p: PageKey) => {
|
||||
navigate(pathMap[p])
|
||||
setSidebarOpen(false)
|
||||
}
|
||||
|
||||
const pendingCount = records.filter(r =>
|
||||
role === 'station' ? r.status === 'station_review' : r.status === 'headquarters_review'
|
||||
).length
|
||||
|
||||
const handleCreate = async (record: WorkRecord) => {
|
||||
setRecords(items => [record, ...items])
|
||||
setCreateOpen(false)
|
||||
showToast('工作记录已提交,进入分站审核')
|
||||
}
|
||||
|
||||
const handleUpdate = async (id: string, patch: Partial<WorkRecord>) => {
|
||||
try {
|
||||
const updated = await api.reviewRecord(role, id, {
|
||||
decision: patch.status === 'returned' ? 'return' : 'pass',
|
||||
score: patch.score ?? 0,
|
||||
note: patch.reviewNote ?? '',
|
||||
})
|
||||
setRecords(items => items.map(item => item.id === id ? updated : item))
|
||||
setSelected(null)
|
||||
return true
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : '审核操作失败')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const showToast = (msg: string) => {
|
||||
setToast(msg)
|
||||
showToastCtx?.(msg)
|
||||
setTimeout(() => setToast(''), 2400)
|
||||
}
|
||||
|
||||
// 未认证时显示登录页
|
||||
if (!isAuthenticated && !localStorage.getItem('auth_token')) {
|
||||
// 演示模式:允许直接进入
|
||||
const isDemoMode = !localStorage.getItem('auth_token') && localStorage.getItem('auth_role')
|
||||
if (!isDemoMode) {
|
||||
return (
|
||||
<Suspense fallback={<LoadingBar />}>
|
||||
<LoginPage />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<Sidebar
|
||||
page={page}
|
||||
onNavigate={navigateTo}
|
||||
open={sidebarOpen}
|
||||
onClose={() => setSidebarOpen(false)}
|
||||
badge={pendingCount}
|
||||
/>
|
||||
|
||||
<main className="main">
|
||||
<TopBar
|
||||
pageTitle={navLabels[page]}
|
||||
onMenuToggle={() => setSidebarOpen(true)}
|
||||
/>
|
||||
|
||||
<section className="content">
|
||||
{loadError && (
|
||||
<div className="error-banner">
|
||||
{loadError}
|
||||
<button onClick={() => window.location.reload()}>重新加载</button>
|
||||
</div>
|
||||
)}
|
||||
{loading && <LoadingBar />}
|
||||
|
||||
<Suspense fallback={<LoadingBar />}>
|
||||
<Routes>
|
||||
<Route path="/" element={
|
||||
<Dashboard
|
||||
records={records}
|
||||
onNavigate={navigateTo}
|
||||
onCreate={() => setCreateOpen(true)}
|
||||
onSelect={setSelected}
|
||||
/>
|
||||
} />
|
||||
<Route path="/work" element={
|
||||
<WorkList
|
||||
records={records}
|
||||
onCreate={() => setCreateOpen(true)}
|
||||
onSelect={setSelected}
|
||||
/>
|
||||
} />
|
||||
<Route path="/review" element={
|
||||
<ReviewCenter records={records} onSelect={setSelected} />
|
||||
} />
|
||||
<Route path="/people" element={<PeoplePage />} />
|
||||
<Route path="/stations" element={<StationsPage />} />
|
||||
<Route path="/archive" element={<ArchivePage records={records} onSelect={setSelected} />} />
|
||||
<Route path="/notices" element={<NoticesPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/rules" element={<RulesPage />} />
|
||||
<Route path="/scores" element={<ScoresPage />} />
|
||||
<Route path="/leaderboard" element={<LeaderboardPage />} />
|
||||
<Route path="/appeals" element={<AppealsPage />} />
|
||||
<Route path="/logs" element={<SystemLogsPage />} />
|
||||
<Route path="/stationmap" element={<StationMapPage />} />
|
||||
<Route path="/cockpit" element={<CockpitPage records={records} />} />
|
||||
<Route path="/profile" element={<ProfilePage records={records} />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{createOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<CreateRecordModal
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onSubmit={handleCreate}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{selected && (
|
||||
<Suspense fallback={null}>
|
||||
<RecordDrawer
|
||||
record={selected}
|
||||
role={role}
|
||||
onClose={() => setSelected(null)}
|
||||
onUpdate={handleUpdate}
|
||||
onNotify={showToast}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{toast && <div className="toast">{toast}</div>}
|
||||
|
||||
{sidebarOpen && (
|
||||
<div className="scrim" onClick={() => setSidebarOpen(false)} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<RoleProvider>
|
||||
<ToastProvider>
|
||||
<AppShell />
|
||||
</ToastProvider>
|
||||
</RoleProvider>
|
||||
)
|
||||
}
|
||||
+459
@@ -0,0 +1,459 @@
|
||||
import type { Role, WorkRecord, WorkType, Attachment, Appeal, PersonTransfer, SystemLog, LoginResponse, RecordVersion } from './types'
|
||||
|
||||
// ── API 请求封装 ────────────────────────────────────────────────────────────
|
||||
type NewRecord = { title: string; type: WorkType; date: string; platform: string; description?: string; attachments?: Attachment[]; isDraft?: boolean }
|
||||
type ReviewRecord = { decision: 'pass' | 'return'; score: number; note: string }
|
||||
|
||||
/** 获取本地存储的 auth token */
|
||||
function getAuthToken(): string | null {
|
||||
return localStorage.getItem('auth_token')
|
||||
}
|
||||
|
||||
/** 构建请求头 */
|
||||
function buildHeaders(role: Role, init?: RequestInit): Record<string, string> {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json', 'x-user-role': role }
|
||||
const token = getAuthToken()
|
||||
if (token) headers['x-auth-token'] = token
|
||||
return { ...headers, ...(init?.headers as Record<string, string>) }
|
||||
}
|
||||
|
||||
async function request<T>(path: string, role: Role, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(path, {
|
||||
...init,
|
||||
headers: buildHeaders(role, init),
|
||||
})
|
||||
const payload = await response.json().catch(() => ({}))
|
||||
if (!response.ok) throw new Error(payload.message || '请求失败,请稍后重试')
|
||||
return payload
|
||||
}
|
||||
|
||||
// ── API 集合 ────────────────────────────────────────────────────────────────
|
||||
export const api = {
|
||||
// 认证
|
||||
auth: {
|
||||
login: (code: string) =>
|
||||
fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code }) })
|
||||
.then(async r => { const p = await r.json().catch(() => ({})); if (!r.ok) throw new Error(p.message || '登录失败'); return p as LoginResponse }),
|
||||
logout: (role: Role) =>
|
||||
request<{ message: string }>('/api/auth/logout', role, { method: 'POST' }),
|
||||
check: (role: Role) =>
|
||||
request<{ authenticated: boolean; role: Role; name: string; station?: string; demo?: boolean }>('/api/auth/check', role),
|
||||
},
|
||||
|
||||
// 工作记录
|
||||
records: (role: Role) => request<WorkRecord[]>('/api/records', role),
|
||||
createRecord: (role: Role, record: NewRecord) =>
|
||||
request<WorkRecord>('/api/records', role, { method: 'POST', body: JSON.stringify(record) }),
|
||||
reviewRecord: (role: Role, id: string, review: ReviewRecord) =>
|
||||
request<WorkRecord>(`/api/records/${id}/review`, role, { method: 'PATCH', body: JSON.stringify(review) }),
|
||||
saveDraft: (role: Role, id: string, patch: Partial<NewRecord>) =>
|
||||
request<WorkRecord>(`/api/records/${id}/draft`, role, { method: 'PATCH', body: JSON.stringify(patch) }),
|
||||
submitDraft: (role: Role, id: string) =>
|
||||
request<WorkRecord>(`/api/records/${id}/submit`, role, { method: 'POST' }),
|
||||
getVersions: (role: Role, id: string) =>
|
||||
request<RecordVersion[]>(`/api/records/${id}/versions`, role),
|
||||
uploadAttachments: (role: Role, id: string, files: Attachment[]) =>
|
||||
request<WorkRecord>(`/api/records/${id}/attachments`, role, { method: 'POST', body: JSON.stringify({ files }) }),
|
||||
deleteAttachment: (role: Role, id: string, index: number) =>
|
||||
request<WorkRecord>(`/api/records/${id}/attachments/${index}`, role, { method: 'DELETE' }),
|
||||
getAudit: (role: Role, id: string) =>
|
||||
request<{actorRole: string; actorName: string; action: string; fromStatus?: string; toStatus: string; score?: number; note?: string; createdAt: string}[]>(`/api/records/${id}/audit`, role),
|
||||
|
||||
// TASK-BE-001:人员 CRUD
|
||||
people: {
|
||||
list: (role: Role, params?: { station?: string; status?: string; name?: string }) => {
|
||||
const sp = new URLSearchParams()
|
||||
if (params?.station) sp.set('station', params.station)
|
||||
if (params?.status) sp.set('status', params.status)
|
||||
if (params?.name) sp.set('name', params.name)
|
||||
const qs = sp.toString() ? `?${sp.toString()}` : ''
|
||||
return request<Person[]>(`/api/people${qs}`, role)
|
||||
},
|
||||
get: (role: Role, id: number) =>
|
||||
request<Person>(`/api/people/${id}`, role),
|
||||
create: (role: Role, person: { name: string; station: string; title?: string; phone?: string; joinedAt?: string }) =>
|
||||
request<Person>('/api/people', role, { method: 'POST', body: JSON.stringify(person) }),
|
||||
update: (role: Role, id: number, patch: { title?: string; phone?: string; status?: string }) =>
|
||||
request<Person>(`/api/people/${id}`, role, { method: 'PATCH', body: JSON.stringify(patch) }),
|
||||
delete: (role: Role, id: number) =>
|
||||
request<{ message: string }>(`/api/people/${id}`, role, { method: 'DELETE' }),
|
||||
transfer: (role: Role, id: number, toStation: string, reason?: string) =>
|
||||
request<{ message: string }>(`/api/people/${id}/transfer`, role, { method: 'POST', body: JSON.stringify({ toStation, reason }) }),
|
||||
transfers: (role: Role, id: number) =>
|
||||
request<PersonTransfer[]>(`/api/people/${id}/transfers`, role),
|
||||
},
|
||||
|
||||
// TASK-BE-002:记者站 CRUD
|
||||
stations: {
|
||||
list: (role: Role, params?: { status?: string; region?: string }) => {
|
||||
const sp = new URLSearchParams()
|
||||
if (params?.status) sp.set('status', params.status)
|
||||
if (params?.region) sp.set('region', params.region)
|
||||
const qs = sp.toString() ? `?${sp.toString()}` : ''
|
||||
return request<Station[]>(`/api/stations${qs}`, role)
|
||||
},
|
||||
get: (role: Role, id: number) =>
|
||||
request<Station>(`/api/stations/${id}`, role),
|
||||
create: (role: Role, station: { name: string; code: string; region?: string; address?: string; leader?: string; phone?: string; establishedAt?: string }) =>
|
||||
request<Station>('/api/stations', role, { method: 'POST', body: JSON.stringify(station) }),
|
||||
update: (role: Role, id: number, patch: Partial<Station>) =>
|
||||
request<Station>(`/api/stations/${id}`, role, { method: 'PATCH', body: JSON.stringify(patch) }),
|
||||
delete: (role: Role, id: number) =>
|
||||
request<{ message: string }>(`/api/stations/${id}`, role, { method: 'DELETE' }),
|
||||
},
|
||||
|
||||
// TASK-BE-003:通知公告
|
||||
notices: {
|
||||
list: (role: Role, params?: { priority?: string; scope?: string }) => {
|
||||
const sp = new URLSearchParams()
|
||||
if (params?.priority) sp.set('priority', params.priority)
|
||||
if (params?.scope) sp.set('scope', params.scope)
|
||||
const qs = sp.toString() ? `?${sp.toString()}` : ''
|
||||
return request<Notice[]>(`/api/notices${qs}`, role)
|
||||
},
|
||||
get: (role: Role, id: number) =>
|
||||
request<Notice>(`/api/notices/${id}`, role),
|
||||
create: (role: Role, notice: { title: string; content?: string; priority?: string; scope?: string; stations?: string[]; roles?: string[] }) =>
|
||||
request<Notice>('/api/notices', role, { method: 'POST', body: JSON.stringify(notice) }),
|
||||
update: (role: Role, id: number, patch: { title?: string; content?: string; priority?: string }) =>
|
||||
request<Notice>(`/api/notices/${id}`, role, { method: 'PATCH', body: JSON.stringify(patch) }),
|
||||
receipts: (role: Role, id: number) =>
|
||||
request<NoticeReceipt[]>(`/api/notices/${id}/receipts`, role),
|
||||
markRead: (role: Role, id: number) =>
|
||||
request<{ message: string }>(`/api/notices/${id}/read`, role, { method: 'POST' }),
|
||||
markConfirm: (role: Role, id: number, receiverName: string, receiverRole: string) =>
|
||||
request<{ message: string }>(`/api/notices/${id}/confirm`, role,
|
||||
{ method: 'POST', body: JSON.stringify({ receiverName, receiverRole }) }),
|
||||
delete: (role: Role, id: number) =>
|
||||
request<{ message: string }>(`/api/notices/${id}`, role, { method: 'DELETE' }),
|
||||
withdraw: (role: Role, id: number) =>
|
||||
request<{ message: string }>(`/api/notices/${id}/withdraw`, role, { method: 'POST' }),
|
||||
},
|
||||
|
||||
// TASK-BE-004:数据统计
|
||||
stats: {
|
||||
overview: (role: Role) =>
|
||||
request<StatsOverview>('/api/stats/overview', role),
|
||||
records: (role: Role, groupBy: 'station' | 'reporter' | 'type') =>
|
||||
request<StatsRecordRow[]>(`/api/stats/records?groupBy=${groupBy}`, role),
|
||||
scores: (role: Role) =>
|
||||
request<StatsScoreRow[]>('/api/stats/scores', role),
|
||||
},
|
||||
|
||||
// V0.2 考核规则
|
||||
rules: {
|
||||
list: (role: Role, params?: { status?: string; period_type?: string }) => {
|
||||
const sp = new URLSearchParams()
|
||||
if (params?.status) sp.set('status', params.status)
|
||||
if (params?.period_type) sp.set('period_type', params.period_type)
|
||||
const qs = sp.toString() ? `?${sp.toString()}` : ''
|
||||
return request<Rule[]>(`/api/rules${qs}`, role)
|
||||
},
|
||||
get: (role: Role, id: number) =>
|
||||
request<Rule>(`/api/rules/${id}`, role),
|
||||
create: (role: Role, rule: {
|
||||
name: string; description?: string; period_type: string;
|
||||
period_start?: string; period_end?: string; items?: RuleItem[]
|
||||
}) =>
|
||||
request<Rule>('/api/rules', role, { method: 'POST', body: JSON.stringify(rule) }),
|
||||
update: (role: Role, id: number, rule: {
|
||||
name?: string; description?: string;
|
||||
period_start?: string; period_end?: string; items?: RuleItem[]
|
||||
}) =>
|
||||
request<Rule>(`/api/rules/${id}`, role, { method: 'PATCH', body: JSON.stringify(rule) }),
|
||||
activate: (role: Role, id: number) =>
|
||||
request<Rule>(`/api/rules/${id}/activate`, role, { method: 'POST' }),
|
||||
},
|
||||
|
||||
// V0.2 评分
|
||||
scores: {
|
||||
list: (role: Role, params?: { reporter?: string; station?: string; period?: string; rule_id?: number }) => {
|
||||
const sp = new URLSearchParams()
|
||||
if (params?.reporter) sp.set('reporter', params.reporter)
|
||||
if (params?.station) sp.set('station', params.station)
|
||||
if (params?.period) sp.set('period', params.period)
|
||||
if (params?.rule_id) sp.set('rule_id', String(params.rule_id))
|
||||
const qs = sp.toString() ? `?${sp.toString()}` : ''
|
||||
return request<Score[]>(`/api/scores${qs}`, role)
|
||||
},
|
||||
get: (role: Role, id: number) =>
|
||||
request<Score>(`/api/scores/${id}`, role),
|
||||
compute: (role: Role, body: {
|
||||
rule_id: number; reporters?: string[]; period: string; period_type: string
|
||||
}) =>
|
||||
request<ComputeResult>('/api/scores/compute', role, { method: 'POST', body: JSON.stringify(body) }),
|
||||
},
|
||||
|
||||
// V0.2 积分排行榜
|
||||
leaderboard: (role: Role, params?: { period?: string; limit?: number; group_by?: 'reporter' | 'station' }) => {
|
||||
const sp = new URLSearchParams()
|
||||
if (params?.period) sp.set('period', params.period)
|
||||
if (params?.limit) sp.set('limit', String(params.limit))
|
||||
if (params?.group_by) sp.set('group_by', params.group_by)
|
||||
const qs = sp.toString() ? `?${sp.toString()}` : ''
|
||||
return request<LeaderboardResponse>(`/api/leaderboard${qs}`, role)
|
||||
},
|
||||
|
||||
// 当前登录人考核汇总
|
||||
me: {
|
||||
summary: (role: Role, period?: string) => {
|
||||
const qs = period ? `?period=${period}` : ''
|
||||
return request<MeSummary>(`/api/me/summary${qs}`, role)
|
||||
},
|
||||
},
|
||||
|
||||
// 申诉复议
|
||||
appeals: {
|
||||
list: (role: Role, status?: string) => {
|
||||
const qs = status ? `?status=${status}` : ''
|
||||
return request<Appeal[]>(`/api/appeals${qs}`, role)
|
||||
},
|
||||
create: (role: Role, recordId: string, reason: string) =>
|
||||
request<{ message: string; code: string }>('/api/appeals', role, { method: 'POST', body: JSON.stringify({ recordId, reason }) }),
|
||||
handle: (role: Role, id: number, decision: 'uphold' | 'overturn', response?: string) =>
|
||||
request<{ message: string }>(`/api/appeals/${id}`, role, { method: 'PATCH', body: JSON.stringify({ decision, response }) }),
|
||||
},
|
||||
|
||||
// 系统操作日志
|
||||
systemLogs: (role: Role, params?: { module?: string; action?: string; actor?: string; page?: number; pageSize?: number }) => {
|
||||
const sp = new URLSearchParams()
|
||||
if (params?.module) sp.set('module', params.module)
|
||||
if (params?.action) sp.set('action', params.action)
|
||||
if (params?.actor) sp.set('actor', params.actor)
|
||||
if (params?.page) sp.set('page', String(params.page))
|
||||
if (params?.pageSize) sp.set('pageSize', String(params.pageSize))
|
||||
const qs = sp.toString() ? `?${sp.toString()}` : ''
|
||||
return request<SystemLog[]>(`/api/system-logs${qs}`, role)
|
||||
},
|
||||
|
||||
// 导出
|
||||
export: {
|
||||
records: (role: Role, params?: { startDate?: string; endDate?: string; type?: string; status?: string }) => {
|
||||
const sp = new URLSearchParams()
|
||||
if (params?.startDate) sp.set('startDate', params.startDate)
|
||||
if (params?.endDate) sp.set('endDate', params.endDate)
|
||||
if (params?.type) sp.set('type', params.type)
|
||||
if (params?.status) sp.set('status', params.status)
|
||||
const qs = sp.toString() ? `?${sp.toString()}` : ''
|
||||
const token = getAuthToken()
|
||||
const headers: Record<string, string> = { 'x-user-role': role }
|
||||
if (token) headers['x-auth-token'] = token
|
||||
return fetch(`/api/export/records${qs}`, { headers }).then(r => r.blob())
|
||||
},
|
||||
people: (role: Role) => {
|
||||
const token = getAuthToken()
|
||||
const headers: Record<string, string> = { 'x-user-role': role }
|
||||
if (token) headers['x-auth-token'] = token
|
||||
return fetch('/api/export/people', { headers }).then(r => r.blob())
|
||||
},
|
||||
scores: (role: Role, period?: string) => {
|
||||
const qs = period ? `?period=${period}` : ''
|
||||
const token = getAuthToken()
|
||||
const headers: Record<string, string> = { 'x-user-role': role }
|
||||
if (token) headers['x-auth-token'] = token
|
||||
return fetch(`/api/export/scores${qs}`, { headers }).then(r => r.blob())
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// V0.2 排行榜类型
|
||||
export interface LeaderboardResponse {
|
||||
period: string
|
||||
groupBy: 'reporter' | 'station'
|
||||
myRank: number | null
|
||||
ranks: ReporterRank[] | StationRank[]
|
||||
}
|
||||
|
||||
export interface ReporterRank {
|
||||
rank: number
|
||||
name: string // reporter name
|
||||
station: string
|
||||
totalScore: number
|
||||
latestAt: string
|
||||
}
|
||||
|
||||
export interface StationRank {
|
||||
rank: number
|
||||
name: string // station name
|
||||
avgScore: number
|
||||
reporterCount: number
|
||||
avgQuality: number
|
||||
avgQuantity: number
|
||||
avgEfficiency: number
|
||||
avgCompliance: number
|
||||
}
|
||||
|
||||
export interface MeSummary {
|
||||
period: string
|
||||
score: {
|
||||
totalScore: number
|
||||
quality_score: number
|
||||
quantity_score: number
|
||||
efficiency_score: number
|
||||
compliance_score: number
|
||||
period: string
|
||||
computedAt: string
|
||||
ruleName: string
|
||||
} | null
|
||||
rank: number | null
|
||||
total: number
|
||||
message?: string
|
||||
}
|
||||
|
||||
// ── 类型 ────────────────────────────────────────────────────────────────────
|
||||
export interface Person {
|
||||
id: number
|
||||
code: string
|
||||
name: string
|
||||
station: string
|
||||
title: string | null
|
||||
phone: string | null
|
||||
joinedAt: string | null
|
||||
status: 'active' | 'inactive'
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface Station {
|
||||
id: number
|
||||
code: string
|
||||
name: string
|
||||
region: string | null
|
||||
address: string | null
|
||||
leader: string | null
|
||||
phone: string | null
|
||||
status: 'active' | 'inactive'
|
||||
establishedAt: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface Notice {
|
||||
id: number
|
||||
code: string
|
||||
title: string
|
||||
content: string | null
|
||||
priority: 'normal' | 'high' | 'urgent'
|
||||
scope: 'all' | 'station' | 'role'
|
||||
stations: string | null
|
||||
roles: string | null
|
||||
attachment: string | null
|
||||
publishedBy: string
|
||||
publishedAt: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface NoticeReceipt {
|
||||
id: number
|
||||
noticeId: number
|
||||
receiverName: string
|
||||
receiverRole: string
|
||||
read: number
|
||||
confirmed: number
|
||||
readAt: string | null
|
||||
confirmedAt: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface StatsOverview {
|
||||
total: number
|
||||
draft: number
|
||||
reviewing: number
|
||||
archived: number
|
||||
returned: number
|
||||
avgScore: number | null
|
||||
monthly: { month: string; count: number; avgScore: number | null }[]
|
||||
}
|
||||
|
||||
export interface StatsRecordRow {
|
||||
name: string
|
||||
station?: string
|
||||
type?: string
|
||||
total: number
|
||||
archived: number
|
||||
returned: number
|
||||
avgScore: number | null
|
||||
}
|
||||
|
||||
export interface StatsScoreRow {
|
||||
name: string
|
||||
station: string
|
||||
submitted: number
|
||||
archived: number
|
||||
returned: number
|
||||
totalScore: number
|
||||
avgScore: number | null
|
||||
maxScore: number | null
|
||||
minScore: number | null
|
||||
}
|
||||
|
||||
// V0.2 类型
|
||||
export interface Rule {
|
||||
id: number
|
||||
code: string
|
||||
name: string
|
||||
description: string | null
|
||||
periodType: 'quarterly' | 'custom'
|
||||
periodStart: string | null
|
||||
periodEnd: string | null
|
||||
status: 'draft' | 'active' | 'archived'
|
||||
version: number
|
||||
parentId: number | null
|
||||
createdBy: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
items?: RuleItem[]
|
||||
}
|
||||
|
||||
export interface RuleItem {
|
||||
id?: number
|
||||
ruleId?: number
|
||||
category: 'quantity' | 'quality' | 'efficiency' | 'compliance'
|
||||
name: string
|
||||
metric_key: string
|
||||
weight: number
|
||||
minScore?: number
|
||||
maxScore?: number
|
||||
formulaType: 'count' | 'avg_score' | 'rate'
|
||||
formulaParams: Record<string, unknown>
|
||||
displayOrder?: number
|
||||
enabled?: number
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
export interface Score {
|
||||
id: number
|
||||
code: string
|
||||
ruleId: number
|
||||
reporter: string
|
||||
station: string
|
||||
period: string
|
||||
periodType: string
|
||||
totalScore: number
|
||||
qualityScore: number
|
||||
quantityScore: number
|
||||
efficiencyScore: number
|
||||
complianceScore: number
|
||||
items: ScoreItem[]
|
||||
computedAt: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
ruleName?: string
|
||||
}
|
||||
|
||||
export interface ScoreItem {
|
||||
item_id: number
|
||||
category: string
|
||||
name: string
|
||||
metric_key: string
|
||||
metric_value: number | null
|
||||
raw_score: number
|
||||
weight: number
|
||||
weighted_score: number
|
||||
}
|
||||
|
||||
export interface ComputeResult {
|
||||
message: string
|
||||
results: { reporter: string; station: string; totalScore: number }[]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
|
||||
interface MetricCardProps {
|
||||
icon: LucideIcon
|
||||
label: string
|
||||
value: string
|
||||
delta?: string
|
||||
hint?: string
|
||||
color: string
|
||||
}
|
||||
|
||||
export function MetricCard({ icon: Icon, label, value, delta, hint, color }: MetricCardProps) {
|
||||
return (
|
||||
<div className="metric">
|
||||
<div className={`metric-icon ${color}`}>
|
||||
<Icon size={21} />
|
||||
</div>
|
||||
<div>
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
<small className={hint ? 'warn' : ''}>{delta ?? hint}</small>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
|
||||
interface PanelHeaderProps {
|
||||
title: string
|
||||
subtitle: string
|
||||
action?: string
|
||||
onAction?: () => void
|
||||
}
|
||||
|
||||
export function PanelHeader({ title, subtitle, action, onAction }: PanelHeaderProps) {
|
||||
return (
|
||||
<div className="panel-header">
|
||||
<div>
|
||||
<h2>{title}</h2>
|
||||
<p>{subtitle}</p>
|
||||
</div>
|
||||
{action && (
|
||||
<button onClick={onAction}>
|
||||
{action}
|
||||
<ChevronRight size={15} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { BookOpen, ChevronDown, ClipboardCheck, Files } from 'lucide-react'
|
||||
import { StatusBadge } from '../ui/StatusBadge'
|
||||
import { EmptyState } from '../ui/EmptyState'
|
||||
import type { WorkRecord } from '../../types'
|
||||
|
||||
interface RecordTableProps {
|
||||
records: WorkRecord[]
|
||||
onSelect: (r: WorkRecord) => void
|
||||
compact?: boolean
|
||||
review?: boolean
|
||||
}
|
||||
|
||||
export function RecordTable({ records, onSelect, compact = false, review = false }: RecordTableProps) {
|
||||
if (records.length === 0) {
|
||||
return <EmptyState icon={Files} text="没有符合条件的记录" />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="table-scroll">
|
||||
<table className={compact ? 'compact' : ''}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>记录信息</th>
|
||||
<th>记者 / 站点</th>
|
||||
<th>发生日期</th>
|
||||
<th>状态</th>
|
||||
{!compact && <th>得分</th>}
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{records.map(record => (
|
||||
<tr key={record.id} onClick={() => onSelect(record)}>
|
||||
<td data-label="记录信息">
|
||||
<div className="record-title">
|
||||
<span className="type-icon">
|
||||
<BookOpen size={16} />
|
||||
</span>
|
||||
<div>
|
||||
<strong>{record.title}</strong>
|
||||
<small>{record.id} · {record.type}</small>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td data-label="记者/站点">
|
||||
<strong className="cell-main">{record.reporter}</strong>
|
||||
<small>{record.station}</small>
|
||||
</td>
|
||||
<td data-label="发生日期">
|
||||
<span className="cell-main">{record.date}</span>
|
||||
<small>更新于 {record.updatedAt}</small>
|
||||
</td>
|
||||
<td data-label="状态">
|
||||
<StatusBadge status={record.status} />
|
||||
</td>
|
||||
{!compact && (
|
||||
<td data-label="得分">
|
||||
<strong>{record.score ?? '—'}</strong>
|
||||
</td>
|
||||
)}
|
||||
<td>
|
||||
<button className="icon-button" aria-label={review ? '开始审核' : '查看详情'}>
|
||||
<ChevronRight size={17} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { MetricCard } from './MetricCard'
|
||||
export { PanelHeader } from './PanelHeader'
|
||||
export { RecordTable } from './RecordTable'
|
||||
@@ -0,0 +1,114 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import {
|
||||
Archive, Bell, Building2, ClipboardCheck,
|
||||
FilePenLine, LayoutDashboard, Settings, Users, X, BarChart3, ScrollText,
|
||||
Trophy, Gavel, FileText, MapPin, Gauge, UserCircle,
|
||||
} from 'lucide-react'
|
||||
import { useRole } from '../../context'
|
||||
import { pathMap } from '../../routes'
|
||||
import type { Role } from '../../types'
|
||||
import type { PageKey } from '../../routes'
|
||||
|
||||
interface SidebarProps {
|
||||
page: PageKey
|
||||
onNavigate: (p: PageKey) => void
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
badge?: number
|
||||
}
|
||||
|
||||
type NavItem = { id: PageKey; label: string; icon: typeof LayoutDashboard; roles?: Role[] }
|
||||
type NavGroup = { title: string; items: NavItem[] }
|
||||
|
||||
const navGroups: NavGroup[] = [
|
||||
{
|
||||
title: '工作空间',
|
||||
items: [
|
||||
{ id: 'dashboard', label: '工作台', icon: LayoutDashboard },
|
||||
{ id: 'work', label: '工作记录', icon: FilePenLine },
|
||||
{ id: 'review', label: '审核中心', icon: ClipboardCheck, roles: ['headquarters', 'station'] },
|
||||
{ id: 'archive', label: '电子档案', icon: Archive },
|
||||
{ id: 'notices', label: '通知公告', icon: Bell },
|
||||
{ id: 'appeals', label: '申诉复议', icon: Gavel, roles: ['headquarters', 'station', 'reporter'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '组织管理',
|
||||
items: [
|
||||
{ id: 'people', label: '人员管理', icon: Users, roles: ['headquarters', 'station'] },
|
||||
{ id: 'stations', label: '记者站管理', icon: Building2, roles: ['headquarters'] },
|
||||
{ id: 'stationmap', label: '全国地图', icon: MapPin, roles: ['headquarters'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '考核分析',
|
||||
items: [
|
||||
{ id: 'rules', label: '考核规则', icon: ScrollText, roles: ['headquarters'] },
|
||||
{ id: 'scores', label: '评分结果', icon: BarChart3, roles: ['headquarters', 'station'] },
|
||||
{ id: 'leaderboard', label: '积分排行', icon: Trophy, roles: ['headquarters', 'station'] },
|
||||
{ id: 'cockpit', label: '管理驾驶舱', icon: Gauge, roles: ['headquarters'] },
|
||||
{ id: 'profile', label: '能力画像', icon: UserCircle },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '系统管理',
|
||||
items: [
|
||||
{ id: 'settings', label: '系统设置', icon: Settings, roles: ['headquarters'] },
|
||||
{ id: 'logs', label: '操作日志', icon: FileText, roles: ['headquarters'] },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export function Sidebar({ page, onNavigate, open, onClose, badge }: SidebarProps) {
|
||||
const { role } = useRole()
|
||||
|
||||
return (
|
||||
<aside className={`sidebar ${open ? 'open' : ''}`}>
|
||||
<div className="brand">
|
||||
<div className="brand-mark">记</div>
|
||||
<div>
|
||||
<strong>全国记者站</strong>
|
||||
<span>管理系统</span>
|
||||
</div>
|
||||
<button className="icon-button mobile-only" onClick={onClose} aria-label="关闭菜单">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<nav className="nav-list">
|
||||
{navGroups.map(group => {
|
||||
const visibleItems = group.items.filter(item => !item.roles || item.roles.includes(role))
|
||||
if (visibleItems.length === 0) return null
|
||||
return (
|
||||
<div key={group.title}>
|
||||
<span className="nav-label">{group.title}</span>
|
||||
{visibleItems.map(item => {
|
||||
const Icon = item.icon
|
||||
const showBadge = item.id === 'review' && badge != null && badge > 0
|
||||
const isActive = page === item.id
|
||||
return (
|
||||
<Link
|
||||
key={item.id}
|
||||
to={pathMap[item.id]}
|
||||
className={isActive ? 'active' : ''}
|
||||
onClick={() => { onNavigate(item.id); onClose() }}
|
||||
>
|
||||
<Icon size={19} />
|
||||
<span>{item.label}</span>
|
||||
{showBadge && <b>{badge}</b>}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
<div className="sidebar-footer">
|
||||
<div className="system-state">
|
||||
<i />
|
||||
<span>系统运行正常</span>
|
||||
<small>V0.3</small>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Bell, ChevronRight, LogOut, Menu, ShieldCheck } from 'lucide-react'
|
||||
import { useRole } from '../../context'
|
||||
import type { Role } from '../../types'
|
||||
|
||||
const roleLabels: Record<Role, string> = {
|
||||
headquarters: '总部管理员',
|
||||
station: '分站负责人',
|
||||
reporter: '记者',
|
||||
}
|
||||
|
||||
interface TopBarProps {
|
||||
pageTitle: string
|
||||
onMenuToggle: () => void
|
||||
}
|
||||
|
||||
export function TopBar({ pageTitle, onMenuToggle }: TopBarProps) {
|
||||
const { role, identity, isAuthenticated, logout } = useRole()
|
||||
|
||||
const handleLogout = () => {
|
||||
if (isAuthenticated) {
|
||||
logout()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="topbar">
|
||||
<button className="icon-button mobile-only" onClick={onMenuToggle} aria-label="打开菜单">
|
||||
<Menu size={21} />
|
||||
</button>
|
||||
<div className="breadcrumb">
|
||||
<span>记者站管理</span>
|
||||
<ChevronRight size={15} />
|
||||
<strong>{pageTitle}</strong>
|
||||
</div>
|
||||
<div className="top-actions">
|
||||
<button className="icon-button notification" aria-label="通知">
|
||||
<Bell size={19} />
|
||||
<i />
|
||||
</button>
|
||||
<div className="role-badge">
|
||||
<ShieldCheck size={17} />
|
||||
<span>{roleLabels[role]}</span>
|
||||
</div>
|
||||
<div className="user-box">
|
||||
<div className="avatar">{identity.name[0]}</div>
|
||||
<div>
|
||||
<strong>{identity.name}</strong>
|
||||
<span>{roleLabels[role]}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button className="icon-button" onClick={handleLogout} aria-label="退出登录" title={isAuthenticated ? '退出登录' : '退出演示'}>
|
||||
<LogOut size={17} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { Sidebar } from './Sidebar'
|
||||
export { TopBar } from './TopBar'
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useState } from 'react'
|
||||
import { AlertTriangle, X } from 'lucide-react'
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
open: boolean
|
||||
title: string
|
||||
message: string
|
||||
confirmLabel?: string
|
||||
danger?: boolean
|
||||
loading?: boolean
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
open, title, message, confirmLabel = '确认', danger = false,
|
||||
loading = false, onConfirm, onCancel,
|
||||
}: ConfirmDialogProps) {
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="modal-layer" onClick={onCancel}>
|
||||
<div className="modal confirm-modal" role="dialog" aria-modal="true" onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-head">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
{danger && (
|
||||
<div style={{
|
||||
width: 36, height: 36, borderRadius: '50%',
|
||||
background: '#f8e8e6', color: 'var(--red)',
|
||||
display: 'grid', placeItems: 'center', flex: 'none',
|
||||
}}>
|
||||
<AlertTriangle size={18} />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h2 style={{ fontSize: 16, margin: 0 }}>{title}</h2>
|
||||
<p style={{ fontSize: 11, color: 'var(--muted)', margin: '4px 0 0' }}>{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button className="icon-button" onClick={onCancel} disabled={loading}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button className="secondary-button" onClick={onCancel} disabled={loading}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className={danger ? 'danger-button' : 'primary-button'}
|
||||
onClick={onConfirm}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? '处理中...' : confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon: LucideIcon
|
||||
text: string
|
||||
}
|
||||
|
||||
export function EmptyState({ icon: Icon, text }: EmptyStateProps) {
|
||||
return (
|
||||
<div className="empty">
|
||||
<Icon size={30} />
|
||||
<span>{text}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export function LoadingBar() {
|
||||
return (
|
||||
<div className="loading-bar">
|
||||
<i />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { Person } from '../../api'
|
||||
|
||||
const labels: Record<string, string> = { active: '在职', inactive: '停用' }
|
||||
const tone: Record<string, string> = { active: 'success', inactive: 'danger' }
|
||||
|
||||
export function PersonStatusBadge({ status }: { status: Person['status'] }) {
|
||||
return (
|
||||
<span className={`status ${tone[status]}`}>
|
||||
<i />
|
||||
{labels[status]}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { Station } from '../../api'
|
||||
|
||||
const labels: Record<string, string> = { active: '在运', inactive: '停运' }
|
||||
const tone: Record<string, string> = { active: 'success', inactive: 'danger' }
|
||||
|
||||
export function StationStatusBadge({ status }: { status: Station['status'] }) {
|
||||
return (
|
||||
<span className={`status ${tone[status]}`}>
|
||||
<i />
|
||||
{labels[status]}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { WorkStatus } from '../../types'
|
||||
|
||||
const labels: Record<WorkStatus, string> = {
|
||||
draft: '草稿',
|
||||
station_review: '待分站审核',
|
||||
headquarters_review: '待总部复核',
|
||||
returned: '已退回',
|
||||
archived: '已归档',
|
||||
}
|
||||
const tone: Record<WorkStatus, string> = {
|
||||
draft: 'neutral',
|
||||
station_review: 'warning',
|
||||
headquarters_review: 'info',
|
||||
returned: 'danger',
|
||||
archived: 'success',
|
||||
}
|
||||
|
||||
export function StatusBadge({ status }: { status: WorkStatus }) {
|
||||
return (
|
||||
<span className={`status ${tone[status]}`}>
|
||||
<i />
|
||||
{labels[status]}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export { labels, tone }
|
||||
@@ -0,0 +1,3 @@
|
||||
export { StatusBadge } from './StatusBadge'
|
||||
export { EmptyState } from './EmptyState'
|
||||
export { LoadingBar } from './LoadingBar'
|
||||
@@ -0,0 +1,85 @@
|
||||
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react'
|
||||
import type { Role } from '../types'
|
||||
|
||||
interface Identity { name: string; station: string | null }
|
||||
|
||||
interface RoleContextValue {
|
||||
role: Role
|
||||
setRole: (role: Role) => void
|
||||
switchRole: (role: Role) => void
|
||||
identity: Identity
|
||||
isAuthenticated: boolean
|
||||
login: (token: string, role: Role, name: string, station: string | null) => void
|
||||
logout: () => void
|
||||
}
|
||||
|
||||
const demoIdentities: Record<Role, Identity> = {
|
||||
headquarters: { name: '林致远', station: null },
|
||||
station: { name: '苏明远', station: '北京记者站' },
|
||||
reporter: { name: '林晓', station: '北京记者站' },
|
||||
}
|
||||
|
||||
const RoleContext = createContext<RoleContextValue>({
|
||||
role: 'headquarters',
|
||||
setRole: () => {},
|
||||
switchRole: () => {},
|
||||
identity: demoIdentities.headquarters,
|
||||
isAuthenticated: false,
|
||||
login: () => {},
|
||||
logout: () => {},
|
||||
})
|
||||
|
||||
export function RoleProvider({ children }: { children: ReactNode }) {
|
||||
const [role, setRole] = useState<Role>(() => {
|
||||
const saved = localStorage.getItem('auth_role')
|
||||
return (saved as Role) || 'headquarters'
|
||||
})
|
||||
const [identity, setIdentity] = useState<Identity>(() => {
|
||||
const savedName = localStorage.getItem('auth_name')
|
||||
const savedStation = localStorage.getItem('auth_station')
|
||||
const savedRole = localStorage.getItem('auth_role') as Role
|
||||
if (savedName) return { name: savedName, station: savedStation === 'null' ? null : savedStation }
|
||||
return demoIdentities[savedRole || 'headquarters']
|
||||
})
|
||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(() => !!localStorage.getItem('auth_token'))
|
||||
|
||||
const login = useCallback((token: string, newRole: Role, name: string, station: string | null) => {
|
||||
localStorage.setItem('auth_token', token)
|
||||
localStorage.setItem('auth_role', newRole)
|
||||
localStorage.setItem('auth_name', name)
|
||||
localStorage.setItem('auth_station', String(station))
|
||||
setRole(newRole)
|
||||
setIdentity({ name, station })
|
||||
setIsAuthenticated(true)
|
||||
}, [])
|
||||
|
||||
const logout = useCallback(() => {
|
||||
localStorage.removeItem('auth_token')
|
||||
localStorage.removeItem('auth_role')
|
||||
localStorage.removeItem('auth_name')
|
||||
localStorage.removeItem('auth_station')
|
||||
setRole('headquarters')
|
||||
setIdentity(demoIdentities.headquarters)
|
||||
setIsAuthenticated(false)
|
||||
}, [])
|
||||
|
||||
const setRoleWrapper = useCallback((newRole: Role) => {
|
||||
setRole(newRole)
|
||||
if (!localStorage.getItem('auth_token')) {
|
||||
setIdentity(demoIdentities[newRole])
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<RoleContext.Provider value={{
|
||||
role, setRole: setRoleWrapper, switchRole: setRoleWrapper,
|
||||
identity, isAuthenticated, login, logout,
|
||||
}}>
|
||||
{children}
|
||||
</RoleContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useRole() {
|
||||
return useContext(RoleContext)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react'
|
||||
import { Check } from 'lucide-react'
|
||||
|
||||
interface ToastItem {
|
||||
id: number
|
||||
message: string
|
||||
}
|
||||
|
||||
interface ToastContextValue {
|
||||
showToast: (message: string) => void
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextValue>({ showToast: () => {} })
|
||||
|
||||
let toastId = 0
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toasts, setToasts] = useState<ToastItem[]>([])
|
||||
|
||||
const showToast = useCallback((message: string) => {
|
||||
const id = ++toastId
|
||||
setToasts(prev => [...prev, { id, message }])
|
||||
setTimeout(() => {
|
||||
setToasts(prev => prev.filter(t => t.id !== id))
|
||||
}, 2400)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ showToast }}>
|
||||
{children}
|
||||
{toasts.map(t => (
|
||||
<div key={t.id} className="toast">
|
||||
<Check size={17} />
|
||||
{t.message}
|
||||
</div>
|
||||
))}
|
||||
</ToastContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
return useContext(ToastContext)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { RoleProvider, useRole } from './RoleContext'
|
||||
export { ToastProvider, useToast } from './ToastContext'
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import type { WorkRecord } from './types'
|
||||
|
||||
export const initialRecords: WorkRecord[] = [
|
||||
{ id: 'WK-202607-086', title: '暑运客流持续攀升,多部门保障出行', type: '文字稿件', reporter: '林晓', station: '北京记者站', date: '2026-07-30', platform: '全国日报', status: 'station_review', updatedAt: '今天 09:42' },
|
||||
{ id: 'WK-202607-081', title: '老街更新:城市记忆与新消费共生', type: '视频供稿', reporter: '周宁', station: '北京记者站', date: '2026-07-29', platform: '新闻客户端', status: 'headquarters_review', score: 8, updatedAt: '昨天 17:26' },
|
||||
{ id: 'WK-202607-074', title: '长三角一体化重点项目集中签约', type: '重要报道', reporter: '陈屿', station: '上海记者站', date: '2026-07-28', platform: '全国日报', status: 'headquarters_review', score: 12, updatedAt: '07-29 15:18' },
|
||||
{ id: 'WK-202607-063', title: '县域公共文化服务观察', type: '图片供稿', reporter: '许言', station: '浙江记者站', date: '2026-07-26', platform: '新闻周刊', status: 'returned', reviewNote: '请补充刊发版面截图,并核对发布日期。', updatedAt: '07-28 11:05' },
|
||||
{ id: 'WK-202607-052', title: '防汛一线应急响应纪实', type: '文字稿件', reporter: '方澄', station: '广东记者站', date: '2026-07-24', platform: '全国日报', status: 'archived', score: 10, updatedAt: '07-26 16:31' },
|
||||
{ id: 'WK-202607-041', title: '融合报道生产能力专题培训', type: '培训参与', reporter: '林晓', station: '北京记者站', date: '2026-07-22', platform: '总部培训中心', status: 'archived', score: 3, updatedAt: '07-23 10:20' },
|
||||
{ id: 'WK-202607-032', title: '社区养老服务站走访', type: '文字稿件', reporter: '周宁', station: '北京记者站', date: '2026-07-18', platform: '新闻客户端', status: 'draft', updatedAt: '07-18 18:42' },
|
||||
]
|
||||
|
||||
export const stationRanking = [
|
||||
{ name: '北京站', score: 92, records: 186 },
|
||||
{ name: '广东站', score: 88, records: 174 },
|
||||
{ name: '上海站', score: 86, records: 168 },
|
||||
{ name: '浙江站', score: 81, records: 151 },
|
||||
{ name: '四川站', score: 78, records: 143 },
|
||||
]
|
||||
|
||||
export const monthlyTrend = [
|
||||
{ month: '2月', records: 412, score: 71 },
|
||||
{ month: '3月', records: 486, score: 74 },
|
||||
{ month: '4月', records: 451, score: 73 },
|
||||
{ month: '5月', records: 538, score: 78 },
|
||||
{ month: '6月', records: 572, score: 81 },
|
||||
{ month: '7月', records: 621, score: 84 },
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
export { useRecords } from './useRecords'
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../api'
|
||||
import { useRole } from '../context'
|
||||
import type { WorkRecord } from '../types'
|
||||
|
||||
export function useRecords() {
|
||||
const { role } = useRole()
|
||||
const [records, setRecords] = useState<WorkRecord[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
setLoading(true)
|
||||
setError('')
|
||||
api.records(role)
|
||||
.then(data => { if (active) setRecords(data) })
|
||||
.catch(err => { if (active) setError(err.message) })
|
||||
.finally(() => { if (active) setLoading(false) })
|
||||
return () => { active = false }
|
||||
}, [role])
|
||||
|
||||
const updateRecord = async (id: string, patch: Partial<WorkRecord>) => {
|
||||
const updated = await api.reviewRecord(role, id, {
|
||||
decision: patch.status === 'returned' ? 'return' : 'pass',
|
||||
score: patch.score ?? 0,
|
||||
note: patch.reviewNote ?? '',
|
||||
})
|
||||
setRecords(items => items.map(item => item.id === id ? updated : item))
|
||||
return updated
|
||||
}
|
||||
|
||||
const createRecord = async (record: WorkRecord) => {
|
||||
const created = await api.createRecord(role, record as any)
|
||||
setRecords(items => [created, ...items])
|
||||
return created
|
||||
}
|
||||
|
||||
return { records, loading, error, updateRecord, createRecord, setRecords }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App'
|
||||
import './styles.css'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,185 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { Files, Plus, X, Paperclip } from 'lucide-react'
|
||||
import { api } from '../api'
|
||||
import { useRole, useToast } from '../context'
|
||||
import type { WorkRecord, WorkType, Attachment } from '../types'
|
||||
|
||||
interface CreateRecordModalProps {
|
||||
onClose: () => void
|
||||
onSubmit: (record: WorkRecord) => Promise<void>
|
||||
}
|
||||
|
||||
export function CreateRecordModal({ onClose, onSubmit }: CreateRecordModalProps) {
|
||||
const { role } = useRole()
|
||||
const { showToast } = useToast()
|
||||
const [title, setTitle] = useState('')
|
||||
const [type, setType] = useState<WorkType>('文字稿件')
|
||||
const [platform, setPlatform] = useState('')
|
||||
const [date, setDate] = useState('2026-08-01')
|
||||
const [description, setDescription] = useState('')
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([])
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [savingDraft, setSavingDraft] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const canSubmit = title.trim() && platform.trim() && date
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files
|
||||
if (!files) return
|
||||
const newAttachments: Attachment[] = Array.from(files).map(f => ({
|
||||
name: f.name,
|
||||
url: URL.createObjectURL(f),
|
||||
size: f.size,
|
||||
type: f.type,
|
||||
}))
|
||||
setAttachments(prev => [...prev, ...newAttachments])
|
||||
}
|
||||
|
||||
const removeAttachment = (idx: number) => {
|
||||
setAttachments(prev => prev.filter((_, i) => i !== idx))
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!canSubmit || submitting) return
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const created = await api.createRecord(role, {
|
||||
title,
|
||||
type,
|
||||
date,
|
||||
platform,
|
||||
description,
|
||||
attachments,
|
||||
})
|
||||
await onSubmit(created)
|
||||
showToast('工作记录已提交,进入分站审核')
|
||||
} catch (e) {
|
||||
showToast(e instanceof Error ? e.message : '提交失败')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveDraft = async () => {
|
||||
if (!title.trim() || savingDraft) return
|
||||
setSavingDraft(true)
|
||||
try {
|
||||
const created = await api.createRecord(role, {
|
||||
title,
|
||||
type,
|
||||
date,
|
||||
platform,
|
||||
description,
|
||||
attachments,
|
||||
isDraft: true,
|
||||
})
|
||||
await onSubmit(created)
|
||||
showToast('草稿已保存')
|
||||
onClose()
|
||||
} catch (e) {
|
||||
showToast(e instanceof Error ? e.message : '保存失败')
|
||||
} finally {
|
||||
setSavingDraft(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-layer">
|
||||
<div className="modal">
|
||||
<div className="modal-head">
|
||||
<div>
|
||||
<h2>新建工作记录</h2>
|
||||
<p>提交后将进入分站初审</p>
|
||||
</div>
|
||||
<button className="icon-button" onClick={onClose}><X size={20} /></button>
|
||||
</div>
|
||||
<div className="form-grid">
|
||||
<label className="full">
|
||||
<span>工作标题 <b>*</b></span>
|
||||
<input
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
placeholder="请输入稿件或工作标题"
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>工作类型 <b>*</b></span>
|
||||
<select value={type} onChange={e => setType(e.target.value as WorkType)}>
|
||||
{['文字稿件','视频供稿','图片供稿','重要报道','培训参与','临时工作'].map(t => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>发生 / 刊发日期 <b>*</b></span>
|
||||
<input type="date" value={date} onChange={e => setDate(e.target.value)} />
|
||||
</label>
|
||||
<label className="full">
|
||||
<span>媒体 / 平台 <b>*</b></span>
|
||||
<input
|
||||
value={platform}
|
||||
onChange={e => setPlatform(e.target.value)}
|
||||
placeholder="请输入刊发媒体、平台或工作来源"
|
||||
/>
|
||||
</label>
|
||||
<label className="full">
|
||||
<span>工作说明</span>
|
||||
<textarea
|
||||
rows={4}
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
placeholder="补充说明工作内容、成果及相关情况"
|
||||
/>
|
||||
</label>
|
||||
<label className="full">
|
||||
<span>证明材料</span>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
<div className="upload-box" onClick={() => fileInputRef.current?.click()}>
|
||||
<Files size={22} />
|
||||
<strong>点击上传或拖放文件</strong>
|
||||
<small>支持图片、PDF、Office 文档,单文件不超过 20 MB</small>
|
||||
</div>
|
||||
{attachments.length > 0 && (
|
||||
<div className="attachment-list">
|
||||
{attachments.map((att, idx) => (
|
||||
<div key={idx} className="attachment-item">
|
||||
<Paperclip size={14} />
|
||||
<span>{att.name}</span>
|
||||
<button type="button" className="icon-button small" onClick={(e) => { e.stopPropagation(); removeAttachment(idx) }}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button className="secondary-button" onClick={onClose}>取消</button>
|
||||
<button
|
||||
className="secondary-button"
|
||||
disabled={!title.trim() || savingDraft}
|
||||
onClick={handleSaveDraft}
|
||||
>
|
||||
{savingDraft ? '保存中...' : '保存草稿'}
|
||||
</button>
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={!canSubmit || submitting}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{submitting ? '提交中...' : '提交审核'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Calendar, MapPin, Phone, User, Building2, FileText, X, Pencil } from 'lucide-react'
|
||||
import type { Person } from '../api'
|
||||
import { PersonStatusBadge } from '../components/ui/PersonStatusBadge'
|
||||
|
||||
interface PersonDrawerProps {
|
||||
person: Person
|
||||
canEdit: boolean
|
||||
onEdit: (p: Person) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function PersonDrawer({ person, canEdit, onEdit, onClose }: PersonDrawerProps) {
|
||||
return (
|
||||
<div className="drawer-layer" onClick={onClose}>
|
||||
<aside className="drawer" onClick={e => e.stopPropagation()}>
|
||||
<div className="drawer-head">
|
||||
<div>
|
||||
<span>{person.code}</span>
|
||||
<h2>{person.name}</h2>
|
||||
</div>
|
||||
<button className="icon-button" onClick={onClose}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="drawer-body">
|
||||
{/* 头像区域 */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 14,
|
||||
padding: '18px 20px', background: '#f7f5f2',
|
||||
borderRadius: 5, marginBottom: 20,
|
||||
}}>
|
||||
<div className="avatar large">{person.name[0]}</div>
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<strong style={{ fontSize: 17 }}>{person.name}</strong>
|
||||
<PersonStatusBadge status={person.status} />
|
||||
</div>
|
||||
<p style={{ fontSize: 11, color: 'var(--muted)', margin: '4px 0 0' }}>
|
||||
{person.title || '未填写职务'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 基本信息网格 */}
|
||||
<div className="detail-grid">
|
||||
<span><small>人员编号</small><strong>{person.code}</strong></span>
|
||||
<span><small>所属站点</small><strong>{person.station}</strong></span>
|
||||
<span><small>手机号码</small><strong>{person.phone || '—'}</strong></span>
|
||||
<span><small>账号状态</small>
|
||||
<strong style={{ color: person.status === 'active' ? '#337a4d' : '#a72d23' }}>
|
||||
{person.status === 'active' ? '在职' : '已停用'}
|
||||
</strong>
|
||||
</span>
|
||||
<span><small>入站时间</small><strong>{person.joinedAt || '—'}</strong></span>
|
||||
<span><small>职务</small><strong>{person.title || '—'}</strong></span>
|
||||
</div>
|
||||
|
||||
{/* 时间线 */}
|
||||
<div className="detail-section">
|
||||
<h4>档案记录</h4>
|
||||
<div className="timeline">
|
||||
<div>
|
||||
<i className="done" />
|
||||
<span>
|
||||
<strong>人员档案创建</strong>
|
||||
<small>{person.createdAt}</small>
|
||||
</span>
|
||||
</div>
|
||||
{person.updatedAt !== person.createdAt && (
|
||||
<div>
|
||||
<i className="done" />
|
||||
<span>
|
||||
<strong>信息更新</strong>
|
||||
<small>{person.updatedAt}</small>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{person.status === 'inactive' && (
|
||||
<div>
|
||||
<i className="done" style={{ background: '#e6b8b3', borderColor: '#e6b8b3' }} />
|
||||
<span>
|
||||
<strong>账号停用</strong>
|
||||
<small>已停用该账号</small>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="drawer-actions">
|
||||
<button className="secondary-button" onClick={onClose}>关闭</button>
|
||||
{canEdit && (
|
||||
<button className="primary-button" onClick={() => { onClose(); onEdit(person) }}>
|
||||
<Pencil size={15} />
|
||||
编辑信息
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { Check, ChevronRight, Files, X, Eye, Download, ImageIcon, FileText, ArrowLeft } from 'lucide-react'
|
||||
import { StatusBadge } from '../components/ui/StatusBadge'
|
||||
import type { Role, WorkRecord, Attachment } from '../types'
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
draft: '草稿',
|
||||
station_review: '待分站审核',
|
||||
headquarters_review: '待总部复核',
|
||||
returned: '已退回',
|
||||
archived: '已归档',
|
||||
}
|
||||
|
||||
interface RecordDrawerProps {
|
||||
record: WorkRecord
|
||||
role: Role
|
||||
onClose: () => void
|
||||
onUpdate: (id: string, patch: Partial<WorkRecord>) => Promise<boolean>
|
||||
onNotify: (msg: string) => void
|
||||
}
|
||||
|
||||
/** 解析附件列表 */
|
||||
function parseAttachments(raw: WorkRecord['attachments']): Attachment[] {
|
||||
if (!raw) return []
|
||||
if (typeof raw === 'string') {
|
||||
try { return JSON.parse(raw) as Attachment[] } catch { return [] }
|
||||
}
|
||||
if (Array.isArray(raw)) return raw
|
||||
return []
|
||||
}
|
||||
|
||||
/** 判断是否为图片类型 */
|
||||
function isImage(url: string): boolean {
|
||||
return /\.(jpg|jpeg|png|gif|bmp|webp|svg)$/i.test(url)
|
||||
}
|
||||
|
||||
/** 判断是否为 PDF 类型 */
|
||||
function isPdf(url: string): boolean {
|
||||
return /\.pdf$/i.test(url)
|
||||
}
|
||||
|
||||
/** 格式化文件大小 */
|
||||
function formatSize(bytes?: number): string {
|
||||
if (!bytes) return ''
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
export function RecordDrawer({ record, role, onClose, onUpdate, onNotify }: RecordDrawerProps) {
|
||||
const [note, setNote] = useState('')
|
||||
const [score, setScore] = useState(record.score ?? (record.type === '重要报道' ? 12 : 8))
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
|
||||
|
||||
const attachments = useMemo(() => parseAttachments(record.attachments), [record.attachments])
|
||||
|
||||
const isStation = role === 'station' && record.status === 'station_review'
|
||||
const isHq = role === 'headquarters' && record.status === 'headquarters_review'
|
||||
const canReview = isStation || isHq
|
||||
|
||||
const pass = async () => {
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const ok = await onUpdate(record.id, {
|
||||
status: isStation ? 'headquarters_review' : 'archived',
|
||||
score,
|
||||
reviewNote: note || '材料完整,审核通过。',
|
||||
})
|
||||
if (ok) {
|
||||
onNotify(isStation ? '初审通过,已提交总部复核' : '复核通过,记录已归档')
|
||||
onClose()
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const returnRecord = async () => {
|
||||
if (!note.trim()) return
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const ok = await onUpdate(record.id, { status: 'returned', score, reviewNote: note })
|
||||
if (ok) {
|
||||
onNotify('记录已退回填报人修改')
|
||||
onClose()
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="drawer-layer" onClick={onClose}>
|
||||
<aside className="drawer" onClick={e => e.stopPropagation()}>
|
||||
<div className="drawer-head">
|
||||
<div>
|
||||
<span>{record.id}</span>
|
||||
<h2>工作记录详情</h2>
|
||||
</div>
|
||||
<button className="icon-button" onClick={onClose}><X size={20} /></button>
|
||||
</div>
|
||||
<div className="drawer-body">
|
||||
<div className="record-hero">
|
||||
<StatusBadge status={record.status} />
|
||||
<h3>{record.title}</h3>
|
||||
<p>{record.type} · {record.platform}</p>
|
||||
</div>
|
||||
<div className="detail-grid">
|
||||
<span><small>填报人</small><strong>{record.reporter}</strong></span>
|
||||
<span><small>所属站点</small><strong>{record.station}</strong></span>
|
||||
<span><small>发生日期</small><strong>{record.date}</strong></span>
|
||||
<span><small>当前得分</small><strong>{record.score ?? '待核定'}</strong></span>
|
||||
</div>
|
||||
<div className="detail-section">
|
||||
<h4>工作说明</h4>
|
||||
<p>{record.description ?? '按计划完成现场采访、资料核验与稿件编发,相关证明材料已随记录提交。'}</p>
|
||||
</div>
|
||||
<div className="detail-section">
|
||||
<h4>证明材料</h4>
|
||||
{attachments.length > 0 ? (
|
||||
<div className="attachment-list">
|
||||
{attachments.map((att, i) => {
|
||||
const img = isImage(att.url)
|
||||
const pdf = isPdf(att.url)
|
||||
return (
|
||||
<div key={i} className="attachment-item">
|
||||
<button
|
||||
className="attachment"
|
||||
onClick={() => {
|
||||
if (img) setPreviewUrl(att.url)
|
||||
else if (pdf) window.open(att.url, '_blank')
|
||||
else window.open(att.url, '_blank')
|
||||
}}
|
||||
>
|
||||
{img ? <ImageIcon size={18} /> : <FileText size={18} />}
|
||||
<span>
|
||||
<strong>{att.name}</strong>
|
||||
<small>{formatSize(att.size)}{att.type ? ` · ${att.type}` : ''}</small>
|
||||
</span>
|
||||
<Eye size={16} />
|
||||
</button>
|
||||
<a
|
||||
href={att.url}
|
||||
download={att.name}
|
||||
className="attachment-download"
|
||||
aria-label="下载"
|
||||
>
|
||||
<Download size={16} />
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="attachment-list">
|
||||
<button className="attachment" disabled>
|
||||
<Files size={18} />
|
||||
<span>
|
||||
<strong>刊发证明材料.pdf</strong>
|
||||
<small>2.4 MB · 已完成安全检测</small>
|
||||
</span>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="timeline">
|
||||
<h4>流转记录</h4>
|
||||
<div>
|
||||
<i className="done" />
|
||||
<span><strong>记者提交</strong><small>{record.reporter} · {record.date} 09:42</small></span>
|
||||
</div>
|
||||
{record.status !== 'station_review' && record.status !== 'draft' && (
|
||||
<div>
|
||||
<i className="done" />
|
||||
<span><strong>分站初审通过</strong><small>分站负责人 · 审核意见已记录</small></span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<i />
|
||||
<span>
|
||||
<strong>{record.status === 'archived' ? '总部复核通过,完成归档' : statusLabels[record.status]}</strong>
|
||||
<small>{record.reviewNote ?? '等待当前处理人操作'}</small>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{canReview && (
|
||||
<div className="review-box">
|
||||
<h4>{isStation ? '分站初审' : '总部复核'}</h4>
|
||||
<label>
|
||||
<span>核定得分</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={score}
|
||||
onChange={e => setScore(Number(e.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>审核意见</span>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={note}
|
||||
onChange={e => setNote(e.target.value)}
|
||||
placeholder="填写审核意见(退回时必填)"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="drawer-actions">
|
||||
{canReview ? (
|
||||
<>
|
||||
<button
|
||||
className="danger-button"
|
||||
disabled={!note.trim() || submitting}
|
||||
onClick={returnRecord}
|
||||
>
|
||||
<ArrowLeft size={17} />
|
||||
退回修改
|
||||
</button>
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={submitting}
|
||||
onClick={pass}
|
||||
>
|
||||
<Check size={17} />
|
||||
审核通过
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button className="secondary-button" onClick={onClose}>关闭</button>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* 图片预览弹窗 */}
|
||||
{previewUrl && (
|
||||
<div
|
||||
className="image-preview-overlay"
|
||||
onClick={() => setPreviewUrl(null)}
|
||||
style={{
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
background: 'rgba(0,0,0,0.75)', zIndex: 10000,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => setPreviewUrl(null)}
|
||||
aria-label="关闭预览"
|
||||
style={{
|
||||
position: 'absolute', top: 16, right: 16,
|
||||
background: 'rgba(255,255,255,0.15)', border: 'none',
|
||||
borderRadius: '50%', width: 36, height: 36,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', color: 'white',
|
||||
}}
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="预览"
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{
|
||||
maxWidth: '90%', maxHeight: '85vh',
|
||||
borderRadius: 8, boxShadow: '0 4px 24px rgba(0,0,0,0.3)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Building2, MapPin, Phone, User, Calendar, FileText, X, Pencil } from 'lucide-react'
|
||||
import type { Station } from '../api'
|
||||
import { StationStatusBadge } from '../components/ui/StationStatusBadge'
|
||||
|
||||
interface StationDrawerProps {
|
||||
station: Station
|
||||
canEdit: boolean
|
||||
onEdit: (s: Station) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function StationDrawer({ station, canEdit, onEdit, onClose }: StationDrawerProps) {
|
||||
return (
|
||||
<div className="drawer-layer" onClick={onClose}>
|
||||
<aside className="drawer" onClick={e => e.stopPropagation()}>
|
||||
<div className="drawer-head">
|
||||
<div>
|
||||
<span>{station.code}</span>
|
||||
<h2>{station.name}</h2>
|
||||
</div>
|
||||
<button className="icon-button" onClick={onClose}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="drawer-body">
|
||||
{/* 站点图标 + 状态 */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 14,
|
||||
padding: '18px 20px', background: '#f7f5f2',
|
||||
borderRadius: 5, marginBottom: 20,
|
||||
}}>
|
||||
<div style={{
|
||||
width: 52, height: 52, borderRadius: 6,
|
||||
background: '#f4e8e4', color: 'var(--red)',
|
||||
display: 'grid', placeItems: 'center',
|
||||
}}>
|
||||
<Building2 size={26} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<strong style={{ fontSize: 17 }}>{station.name}</strong>
|
||||
<StationStatusBadge status={station.status} />
|
||||
</div>
|
||||
<p style={{ fontSize: 11, color: 'var(--muted)', margin: '4px 0 0' }}>
|
||||
{station.region ? `${station.region}地区` : '未设置地区'}
|
||||
{station.leader ? ` · 负责人 ${station.leader}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 基本信息 */}
|
||||
<div className="detail-grid">
|
||||
<span><small>站点编码</small><strong>{station.code}</strong></span>
|
||||
<span><small>所属地区</small><strong>{station.region || '—'}</strong></span>
|
||||
<span><small>联系电话</small><strong>{station.phone || '—'}</strong></span>
|
||||
<span><small>运行状态</small>
|
||||
<strong style={{ color: station.status === 'active' ? '#337a4d' : '#a72d23' }}>
|
||||
{station.status === 'active' ? '在运' : '已停运'}
|
||||
</strong>
|
||||
</span>
|
||||
<span><small>建站时间</small><strong>{station.establishedAt || '—'}</strong></span>
|
||||
<span><small>负责人</small><strong>{station.leader || '—'}</strong></span>
|
||||
<span style={{ gridColumn: '1 / -1' }}><small>站点地址</small><strong>{station.address || '—'}</strong></span>
|
||||
</div>
|
||||
|
||||
{/* 时间线 */}
|
||||
<div className="detail-section">
|
||||
<h4>站点记录</h4>
|
||||
<div className="timeline">
|
||||
<div>
|
||||
<i className="done" />
|
||||
<span>
|
||||
<strong>站点创建</strong>
|
||||
<small>{station.createdAt}</small>
|
||||
</span>
|
||||
</div>
|
||||
{station.updatedAt !== station.createdAt && (
|
||||
<div>
|
||||
<i className="done" />
|
||||
<span>
|
||||
<strong>信息更新</strong>
|
||||
<small>{station.updatedAt}</small>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{station.status === 'inactive' && (
|
||||
<div>
|
||||
<i className="done" style={{ background: '#e6b8b3', borderColor: '#e6b8b3' }} />
|
||||
<span>
|
||||
<strong>站点停运</strong>
|
||||
<small>已停止运营</small>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="drawer-actions">
|
||||
<button className="secondary-button" onClick={onClose}>关闭</button>
|
||||
{canEdit && (
|
||||
<button className="primary-button" onClick={() => { onClose(); onEdit(station) }}>
|
||||
<Pencil size={15} />
|
||||
编辑站点
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { CreateRecordModal } from './CreateRecordModal'
|
||||
export { RecordDrawer } from './RecordDrawer'
|
||||
@@ -0,0 +1,176 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Gavel, Clock, CheckCircle2, XCircle, Loader2, MessageSquare, X } from 'lucide-react'
|
||||
import { useRole } from '../../context'
|
||||
import { useToast } from '../../context'
|
||||
import { api } from '../../api'
|
||||
import { EmptyState } from '../../components/ui/EmptyState'
|
||||
import type { Appeal } from '../../types'
|
||||
|
||||
const statusLabels: Record<string, { label: string; color: string }> = {
|
||||
pending: { label: '待处理', color: 'amber' },
|
||||
accepted: { label: '已通过', color: 'emerald' },
|
||||
rejected: { label: '已驳回', color: 'rose' },
|
||||
}
|
||||
|
||||
/**
|
||||
* 申诉复议页面 — 记者提交申诉,分站/总部处理
|
||||
*/
|
||||
export function AppealsPage() {
|
||||
const { role } = useRole()
|
||||
const { showToast } = useToast()
|
||||
const [appeals, setAppeals] = useState<Appeal[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [filterStatus, setFilterStatus] = useState('')
|
||||
const [selected, setSelected] = useState<Appeal | null>(null)
|
||||
const [response, setResponse] = useState('')
|
||||
const [handling, setHandling] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await api.appeals.list(role, filterStatus || undefined)
|
||||
setAppeals(data)
|
||||
} catch (err) {
|
||||
showToast(err instanceof Error ? err.message : '加载失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [role, filterStatus, showToast])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const handleAppeal = async (decision: 'uphold' | 'overturn') => {
|
||||
if (!selected) return
|
||||
setHandling(true)
|
||||
try {
|
||||
await api.appeals.handle(role, selected.id, decision, response)
|
||||
showToast(decision === 'uphold' ? '申诉已驳回' : '申诉已通过')
|
||||
setSelected(null)
|
||||
setResponse('')
|
||||
load()
|
||||
} catch (err) {
|
||||
showToast(err instanceof Error ? err.message : '操作失败')
|
||||
} finally {
|
||||
setHandling(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-content">
|
||||
<div className="page-heading small">
|
||||
<div>
|
||||
<div className="page-heading-icon"><Gavel size={20} /></div>
|
||||
<div>
|
||||
<h1>申诉复议</h1>
|
||||
<span>对审核结果有异议的工作记录进行申诉与复议</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="filter-bar">
|
||||
<select value={filterStatus} onChange={e => setFilterStatus(e.target.value)}>
|
||||
<option value="">全部状态</option>
|
||||
<option value="pending">待处理</option>
|
||||
<option value="accepted">已通过</option>
|
||||
<option value="rejected">已驳回</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="loading-center"><Loader2 size={24} className="spin" /></div>
|
||||
) : appeals.length === 0 ? (
|
||||
<EmptyState icon={Gavel} text="暂无申诉记录" />
|
||||
) : (
|
||||
<div className="appeal-list">
|
||||
{appeals.map(a => {
|
||||
const st = statusLabels[a.status] || statusLabels.pending
|
||||
return (
|
||||
<div key={a.id} className={`appeal-card status-${st.color}`} onClick={() => { setSelected(a); setResponse('') }}>
|
||||
<div className="appeal-header">
|
||||
<span className="appeal-code">{a.code}</span>
|
||||
<span className={`appeal-status ${st.color}`}>{st.label}</span>
|
||||
</div>
|
||||
<div className="appeal-body">
|
||||
<div className="appeal-meta">
|
||||
<span>申诉人:{a.appellant}</span>
|
||||
<span>站点:{a.station}</span>
|
||||
<span>记录ID:{a.recordId}</span>
|
||||
</div>
|
||||
<div className="appeal-reason">
|
||||
<MessageSquare size={14} />
|
||||
<span>{a.reason}</span>
|
||||
</div>
|
||||
{a.response && (
|
||||
<div className="appeal-response">
|
||||
<strong>处理意见:</strong>{a.response}
|
||||
</div>
|
||||
)}
|
||||
<div className="appeal-footer">
|
||||
<Clock size={13} />
|
||||
<span>提交于 {a.createdAt}</span>
|
||||
{a.handledAt && <span>处理于 {a.handledAt}</span>}
|
||||
{a.handler && <span>处理人:{a.handler}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selected && (
|
||||
<div className="modal-layer" onClick={() => { setSelected(null); setResponse('') }}>
|
||||
<div className="modal" role="dialog" aria-modal="true" onClick={e => e.stopPropagation()} style={{ maxWidth: 520 }}>
|
||||
<div className="modal-head">
|
||||
<h2 style={{ fontSize: 16, margin: 0 }}>申诉详情 — {selected.code}</h2>
|
||||
<button className="icon-button" onClick={() => { setSelected(null); setResponse('') }}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-body" style={{ padding: 16 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 12 }}>
|
||||
<div><strong>申诉人:</strong>{selected.appellant}({selected.station})</div>
|
||||
<div><strong>记录ID:</strong>{selected.recordId}</div>
|
||||
<div><strong>申诉原因:</strong>{selected.reason}</div>
|
||||
<div><strong>状态:</strong>{statusLabels[selected.status]?.label}</div>
|
||||
{selected.response && <div><strong>处理意见:</strong>{selected.response}</div>}
|
||||
{selected.handler && <div><strong>处理人:</strong>{selected.handler}</div>}
|
||||
<div><strong>提交时间:</strong>{selected.createdAt}</div>
|
||||
{selected.handledAt && <div><strong>处理时间:</strong>{selected.handledAt}</div>}
|
||||
</div>
|
||||
|
||||
{role !== 'reporter' && selected.status === 'pending' && (
|
||||
<>
|
||||
<textarea
|
||||
placeholder="输入处理意见..."
|
||||
value={response}
|
||||
onChange={e => setResponse(e.target.value)}
|
||||
rows={3}
|
||||
style={{ width: '100%', marginBottom: 12, padding: 8, borderRadius: 6, border: '1px solid var(--border)' }}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
className="danger-button"
|
||||
style={{ height: 38, padding: '0 15px', fontSize: 13, fontWeight: 600, borderRadius: 4, border: '1px solid var(--red)', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 7 }}
|
||||
disabled={handling}
|
||||
onClick={() => handleAppeal('uphold')}
|
||||
>
|
||||
<XCircle size={16} /> 驳回申诉
|
||||
</button>
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={handling}
|
||||
onClick={() => handleAppeal('overturn')}
|
||||
>
|
||||
<CheckCircle2 size={16} /> 通过申诉
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { Archive, ChevronRight, X, BookOpen, Filter, BarChart3 } from 'lucide-react'
|
||||
import { useRole } from '../../context'
|
||||
import type { WorkRecord, WorkType } from '../../types'
|
||||
|
||||
interface ArchivePageProps {
|
||||
records: WorkRecord[]
|
||||
onSelect: (r: WorkRecord) => void
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
draft: '草稿', station_review: '待分站审核',
|
||||
headquarters_review: '待总部复核', returned: '已退回', archived: '已归档',
|
||||
}
|
||||
|
||||
const ALL_TYPES: WorkType[] = ['文字稿件', '视频供稿', '图片供稿', '重要报道', '培训参与', '临时工作']
|
||||
|
||||
export function ArchivePage({ records, onSelect }: ArchivePageProps) {
|
||||
const { role } = useRole()
|
||||
const [filterType, setFilterType] = useState('')
|
||||
const [filterStation, setFilterStation] = useState('')
|
||||
const [filterStartDate, setFilterStartDate] = useState('')
|
||||
const [filterEndDate, setFilterEndDate] = useState('')
|
||||
const [search, setSearch] = useState('')
|
||||
|
||||
const archived = useMemo(() => records.filter(r => r.status === 'archived'), [records])
|
||||
|
||||
const stations = useMemo(() => {
|
||||
const set = new Set(archived.map(r => r.station))
|
||||
return Array.from(set).sort()
|
||||
}, [archived])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return archived.filter(r => {
|
||||
if (filterType && r.type !== filterType) return false
|
||||
if (filterStation && r.station !== filterStation) return false
|
||||
if (filterStartDate && r.date < filterStartDate) return false
|
||||
if (filterEndDate && r.date > filterEndDate) return false
|
||||
if (search) {
|
||||
const q = search.toLowerCase()
|
||||
return r.title.toLowerCase().includes(q) ||
|
||||
r.reporter.toLowerCase().includes(q) ||
|
||||
r.id.toLowerCase().includes(q)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}, [archived, filterType, filterStation, filterStartDate, filterEndDate, search])
|
||||
|
||||
const typeAggregation = useMemo(() => {
|
||||
const map = new Map<string, { count: number; avgScore: number; totalScore: number }>()
|
||||
filtered.forEach(r => {
|
||||
const cur = map.get(r.type) || { count: 0, avgScore: 0, totalScore: 0 }
|
||||
cur.count++
|
||||
cur.totalScore += r.score ?? 0
|
||||
cur.avgScore = cur.totalScore / cur.count
|
||||
map.set(r.type, cur)
|
||||
})
|
||||
return Array.from(map.entries())
|
||||
.map(([type, stats]) => ({ type, ...stats }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
}, [filtered])
|
||||
|
||||
const hasFilters = filterType || filterStation || filterStartDate || filterEndDate || search
|
||||
|
||||
const clearFilters = () => {
|
||||
setFilterType('')
|
||||
setFilterStation('')
|
||||
setFilterStartDate('')
|
||||
setFilterEndDate('')
|
||||
setSearch('')
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-heading small">
|
||||
<div>
|
||||
<div className="page-heading-icon"><Archive size={20} /></div>
|
||||
<div>
|
||||
<h1>电子档案</h1>
|
||||
<span>
|
||||
查询已归档的工作记录与考核得分,支持按类型、时间、站点筛选。
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="profile-strip">
|
||||
<div className="profile-person">
|
||||
<div className="avatar large">林</div>
|
||||
<div>
|
||||
<span>人员档案</span>
|
||||
<h2>{role === 'reporter' ? '林晓' : '林晓 · R-10021'}</h2>
|
||||
<p>北京记者站 · 记者 · 2021年3月入站</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="profile-stat">
|
||||
<strong>{archived.length}</strong>
|
||||
<span>累计作品</span>
|
||||
</div>
|
||||
<div className="profile-stat">
|
||||
<strong>{archived.length > 0 ? (archived.reduce((s, r) => s + (r.score ?? 0), 0) / archived.length).toFixed(1) : '—'}</strong>
|
||||
<span>平均得分</span>
|
||||
</div>
|
||||
<div className="profile-stat">
|
||||
<strong>{typeAggregation.length}</strong>
|
||||
<span>作品类型</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 分类聚合统计 */}
|
||||
{typeAggregation.length > 0 && (
|
||||
<section className="panel" style={{ marginBottom: 14, padding: '16px 20px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||
<BarChart3 size={18} />
|
||||
<h2 style={{ fontSize: 14, margin: 0 }}>分类聚合</h2>
|
||||
<span style={{ fontSize: 11, color: 'var(--muted)' }}>按工作类型统计</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
|
||||
{typeAggregation.map(item => (
|
||||
<div key={item.type} style={{
|
||||
background: 'var(--soft)', borderRadius: 6, padding: '10px 16px',
|
||||
display: 'flex', flexDirection: 'column', gap: 2, minWidth: 120,
|
||||
}}>
|
||||
<strong style={{ fontSize: 13 }}>{item.type}</strong>
|
||||
<div style={{ display: 'flex', gap: 12, fontSize: 11, color: 'var(--muted)' }}>
|
||||
<span>{item.count} 条</span>
|
||||
<span>均分 {item.avgScore.toFixed(1)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="panel list-panel">
|
||||
<div className="panel-header" style={{ padding: '20px 20px 0' }}>
|
||||
<div>
|
||||
<h2>档案记录</h2>
|
||||
<p>已通过总部复核并归档的工作成果</p>
|
||||
</div>
|
||||
<span className="result-count">共 {filtered.length} 条</span>
|
||||
</div>
|
||||
|
||||
{/* 筛选栏 */}
|
||||
<div className="filters" style={{ padding: '14px 20px' }}>
|
||||
<div className="search">
|
||||
<Filter size={15} />
|
||||
<input
|
||||
placeholder="搜索标题/提交人/编号"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<select value={filterType} onChange={e => setFilterType(e.target.value)}>
|
||||
<option value="">全部类型</option>
|
||||
{ALL_TYPES.map(t => <option key={t} value={t}>{t}</option>)}
|
||||
</select>
|
||||
<select value={filterStation} onChange={e => setFilterStation(e.target.value)}>
|
||||
<option value="">全部站点</option>
|
||||
{stations.map(s => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
<input type="date" value={filterStartDate} onChange={e => setFilterStartDate(e.target.value)} title="开始日期" />
|
||||
<input type="date" value={filterEndDate} onChange={e => setFilterEndDate(e.target.value)} title="结束日期" />
|
||||
{hasFilters && (
|
||||
<button className="secondary-button small" onClick={clearFilters}>
|
||||
<X size={14} /> 清除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 && (
|
||||
<div className="empty">
|
||||
<Archive size={30} />
|
||||
<span>{hasFilters ? '没有符合筛选条件的记录' : '暂无归档记录'}</span>
|
||||
</div>
|
||||
)}
|
||||
{filtered.length > 0 && (
|
||||
<div className="table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>记录信息</th>
|
||||
<th>类型</th>
|
||||
<th>发生日期</th>
|
||||
<th>得分</th>
|
||||
<th>提交人</th>
|
||||
<th style={{ width: 80 }}>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map(r => (
|
||||
<tr key={r.id}>
|
||||
<td data-label="记录信息">
|
||||
<div className="record-title">
|
||||
<span className="type-icon"><BookOpen size={16} /></span>
|
||||
<div>
|
||||
<strong>{r.title}</strong>
|
||||
<small>{r.id}</small>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td data-label="类型">
|
||||
<span className="status muted"><i />{r.type}</span>
|
||||
</td>
|
||||
<td data-label="发生日期"><span className="cell-main">{r.date}</span></td>
|
||||
<td data-label="得分">
|
||||
{r.score != null
|
||||
? <strong style={{ color: 'var(--primary)' }}>{r.score}</strong>
|
||||
: <span style={{ color: 'var(--muted)' }}>—</span>
|
||||
}
|
||||
</td>
|
||||
<td data-label="提交人">
|
||||
<strong className="cell-main">{r.reporter}</strong>
|
||||
<small>{r.station}</small>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
className="secondary-button small"
|
||||
onClick={() => onSelect(r)}
|
||||
title="查看详情"
|
||||
>
|
||||
<ChevronRight size={14} />
|
||||
详情
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { Activity, BarChart3, TrendingUp, TrendingDown, Building2, Users, FilePenLine, Award, AlertTriangle, Download, Gauge } from 'lucide-react'
|
||||
import { useRole } from '../../context'
|
||||
import { api } from '../../api'
|
||||
import type { StatsOverview, StatsRecordRow, StatsScoreRow } from '../../api'
|
||||
import type { WorkRecord, WorkType } from '../../types'
|
||||
|
||||
interface CockpitProps {
|
||||
records: WorkRecord[]
|
||||
}
|
||||
|
||||
const ALL_TYPES: WorkType[] = ['文字稿件', '视频供稿', '图片供稿', '重要报道', '培训参与', '临时工作']
|
||||
|
||||
/** 柱状图组件(纯 SVG) */
|
||||
function BarChart({ data, color = '#b42318', height = 200 }: {
|
||||
data: { label: string; value: number; sub?: string }[]
|
||||
color?: string
|
||||
height?: number
|
||||
}) {
|
||||
const max = Math.max(...data.map(d => d.value)) * 1.15 || 1
|
||||
const barWidth = 100 / data.length
|
||||
return (
|
||||
<svg viewBox={`0 0 100 ${height / 3}`} style={{ width: '100%', height }} preserveAspectRatio="none">
|
||||
{data.map((d, i) => {
|
||||
const h = (d.value / max) * (height / 3 - 10)
|
||||
const x = i * barWidth + barWidth * 0.15
|
||||
const w = barWidth * 0.7
|
||||
const y = height / 3 - h - 6
|
||||
return (
|
||||
<g key={i}>
|
||||
<rect x={x} y={y} width={w} height={h} fill={color} rx={1} opacity={0.85} />
|
||||
<text x={x + w / 2} y={height / 3 - 2} textAnchor="middle" fill="#777" fontSize={2.5}>{d.label}</text>
|
||||
<text x={x + w / 2} y={y - 1} textAnchor="middle" fill="#333" fontSize={2.8} fontWeight="bold">{d.value}</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** 环形进度图组件 */
|
||||
function RingProgress({ value, max, label, color, unit }: {
|
||||
value: number; max: number; label: string; color: string; unit: string
|
||||
}) {
|
||||
const pct = max > 0 ? Math.min(value / max, 1) : 0
|
||||
const r = 36
|
||||
const circ = 2 * Math.PI * r
|
||||
const offset = circ * (1 - pct)
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6 }}>
|
||||
<svg width="90" height="90" viewBox="0 0 90 90">
|
||||
<circle cx="45" cy="45" r={r} fill="none" stroke="#eeeae5" strokeWidth="7" />
|
||||
<circle
|
||||
cx="45" cy="45" r={r} fill="none" stroke={color} strokeWidth="7"
|
||||
strokeDasharray={circ} strokeDashoffset={offset}
|
||||
strokeLinecap="round" transform="rotate(-90 45 45)"
|
||||
style={{ transition: 'stroke-dashoffset 0.5s' }}
|
||||
/>
|
||||
<text x="45" y="42" textAnchor="middle" fill="#1e1c19" fontSize="18" fontWeight="bold" fontFamily="Georgia,serif">
|
||||
{value}
|
||||
</text>
|
||||
<text x="45" y="55" textAnchor="middle" fill="#777" fontSize="9">{unit}</text>
|
||||
</svg>
|
||||
<span style={{ fontSize: 12, color: 'var(--muted)' }}>{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 热力图单元格 */
|
||||
function HeatmapCell({ count, max, label }: { count: number; max: number; label: string }) {
|
||||
const intensity = max > 0 ? count / max : 0
|
||||
const bg = intensity > 0.75 ? '#b42318' : intensity > 0.5 ? '#d4634e' : intensity > 0.25 ? '#e9a092' : intensity > 0 ? '#f4d0c8' : '#f5f4f1'
|
||||
const fg = intensity > 0.5 ? 'white' : '#77736d'
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||||
background: bg, color: fg, borderRadius: 4, padding: '8px 4px', minHeight: 50,
|
||||
transition: '0.12s', cursor: 'default',
|
||||
}} title={`${label}: ${count} 条`}>
|
||||
<strong style={{ fontSize: 14, fontFamily: 'Georgia,serif' }}>{count}</strong>
|
||||
<span style={{ fontSize: 9 }}>{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CockpitPage({ records }: CockpitProps) {
|
||||
const { role } = useRole()
|
||||
const [overview, setOverview] = useState<StatsOverview | null>(null)
|
||||
const [stationStats, setStationStats] = useState<StatsRecordRow[]>([])
|
||||
const [reporterStats, setReporterStats] = useState<StatsScoreRow[]>([])
|
||||
const [typeStats, setTypeStats] = useState<StatsRecordRow[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [period, setPeriod] = useState('2026-07')
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
setLoading(true)
|
||||
Promise.all([
|
||||
api.stats.overview(role).catch(() => null),
|
||||
api.stats.records(role, 'station').catch(() => []),
|
||||
api.stats.scores(role).catch(() => []),
|
||||
api.stats.records(role, 'type').catch(() => []),
|
||||
]).then(([ov, st, sc, ty]) => {
|
||||
if (!active) return
|
||||
setOverview(ov)
|
||||
setStationStats(st)
|
||||
setReporterStats(sc)
|
||||
setTypeStats(ty)
|
||||
}).finally(() => { if (active) setLoading(false) })
|
||||
return () => { active = false }
|
||||
}, [role, period])
|
||||
|
||||
/** 按类型×站点交叉统计(热力图数据) */
|
||||
const heatmapData = useMemo(() => {
|
||||
const topStations = stationStats.slice(0, 8).map(s => s.name)
|
||||
const matrix: { station: string; type: string; count: number }[] = []
|
||||
ALL_TYPES.forEach(type => {
|
||||
topStations.forEach(stationName => {
|
||||
const count = records.filter(r =>
|
||||
r.station === stationName && r.type === type && r.status === 'archived'
|
||||
).length
|
||||
matrix.push({ station: stationName, type, count })
|
||||
})
|
||||
})
|
||||
return { matrix, stations: topStations }
|
||||
}, [stationStats, records])
|
||||
|
||||
const maxHeat = useMemo(() => Math.max(1, ...heatmapData.matrix.map(d => d.count)), [heatmapData])
|
||||
|
||||
/** 得分趋势对比 */
|
||||
const scoreTrend = useMemo(() => {
|
||||
return reporterStats
|
||||
.filter(s => s.avgScore != null)
|
||||
.sort((a, b) => (b.avgScore ?? 0) - (a.avgScore ?? 0))
|
||||
.slice(0, 10)
|
||||
.map(s => ({ label: s.name, value: s.avgScore ?? 0, sub: s.station }))
|
||||
}, [reporterStats])
|
||||
|
||||
/** 站点对比柱状图数据 */
|
||||
const stationBars = useMemo(() => {
|
||||
return stationStats.slice(0, 10).map(s => ({
|
||||
label: s.name.replace('记者站', ''),
|
||||
value: s.total,
|
||||
sub: `归档 ${s.archived}`,
|
||||
}))
|
||||
}, [stationStats])
|
||||
|
||||
/** 类型分布 */
|
||||
const typeBars = useMemo(() => {
|
||||
return typeStats.map(t => ({
|
||||
label: t.type || t.name,
|
||||
value: t.total,
|
||||
sub: `归档 ${t.archived}`,
|
||||
}))
|
||||
}, [typeStats])
|
||||
|
||||
/** 关键指标 */
|
||||
const totalRecords = overview?.total ?? records.length
|
||||
const archivedCount = overview?.archived ?? records.filter(r => r.status === 'archived').length
|
||||
const reviewingCount = overview?.reviewing ?? records.filter(r => r.status === 'station_review' || r.status === 'headquarters_review').length
|
||||
const returnedCount = overview?.returned ?? records.filter(r => r.status === 'returned').length
|
||||
const archiveRate = totalRecords > 0 ? Math.round((archivedCount / totalRecords) * 100) : 0
|
||||
|
||||
/** 风险预警 */
|
||||
const alerts = useMemo(() => {
|
||||
const list: { level: 'high' | 'medium' | 'low'; text: string }[] = []
|
||||
if (returnedCount > 5) list.push({ level: 'high', text: `退回记录 ${returnedCount} 条,超出阈值 5 条` })
|
||||
if (reviewingCount > 20) list.push({ level: 'medium', text: `待审核积压 ${reviewingCount} 条,建议加快处理` })
|
||||
const lowScoreStations = stationStats.filter(s => s.avgScore != null && s.avgScore < 70)
|
||||
lowScoreStations.forEach(s => list.push({ level: 'low', text: `${s.name} 平均分 ${s.avgScore?.toFixed(1)},低于 70 分线` }))
|
||||
return list
|
||||
}, [returnedCount, reviewingCount, stationStats])
|
||||
|
||||
if (loading) {
|
||||
return <div className="loading-center" style={{ padding: 80, textAlign: 'center', color: 'var(--muted)' }}>加载中…</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-heading small">
|
||||
<div>
|
||||
<div className="page-heading-icon"><Gauge size={20} /></div>
|
||||
<div>
|
||||
<h1>管理驾驶舱</h1>
|
||||
<span>全国记者站运行态势总览 · {period}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<input
|
||||
type="month"
|
||||
value={period}
|
||||
onChange={e => setPeriod(e.target.value)}
|
||||
style={{ height: 34, border: '1px solid var(--line)', borderRadius: 4, padding: '0 10px', fontSize: 12 }}
|
||||
/>
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => api.export.records(role).then(blob => {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url; a.download = 'cockpit-export.csv'; a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
})}
|
||||
>
|
||||
<Download size={15} /> 导出
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 关键指标环形图 */}
|
||||
<section className="panel" style={{ marginBottom: 14, padding: '20px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16 }}>
|
||||
<Activity size={18} />
|
||||
<h2 style={{ fontSize: 14, margin: 0 }}>核心指标</h2>
|
||||
</div>
|
||||
<div className="cockpit-rings" style={{ display: 'flex', gap: 30, flexWrap: 'wrap', justifyContent: 'space-around' }}>
|
||||
<RingProgress value={totalRecords} max={700} label="总记录数" color="#b42318" unit="条" />
|
||||
<RingProgress value={archivedCount} max={totalRecords} label="已归档" color="#4ba66a" unit="条" />
|
||||
<RingProgress value={archiveRate} max={100} label="归档率" color="#3b82f6" unit="%" />
|
||||
<RingProgress value={reviewingCount} max={50} label="审核中" color="#f0a020" unit="条" />
|
||||
<RingProgress value={returnedCount} max={20} label="退回数" color="#b42318" unit="条" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 风险预警 */}
|
||||
{alerts.length > 0 && (
|
||||
<section className="panel" style={{ marginBottom: 14, padding: '16px 20px', borderLeft: '3px solid #f0a020' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
|
||||
<AlertTriangle size={18} style={{ color: '#c47600' }} />
|
||||
<h2 style={{ fontSize: 14, margin: 0 }}>风险预警</h2>
|
||||
<span style={{ fontSize: 11, color: 'var(--muted)' }}>{alerts.length} 项</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{alerts.map((a, i) => (
|
||||
<div key={i} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '6px 12px', borderRadius: 4, fontSize: 12,
|
||||
background: a.level === 'high' ? '#fdeae8' : a.level === 'medium' ? '#fff4e6' : '#f5f4f1',
|
||||
color: a.level === 'high' ? '#a72d23' : a.level === 'medium' ? '#8a5a00' : 'var(--muted)',
|
||||
}}>
|
||||
<span style={{
|
||||
width: 6, height: 6, borderRadius: '50%',
|
||||
background: a.level === 'high' ? '#b42318' : a.level === 'medium' ? '#f0a020' : '#999',
|
||||
}} />
|
||||
{a.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 图表区域 */}
|
||||
<div className="cockpit-grid-2" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14, marginBottom: 14 }}>
|
||||
{/* 站点工作量对比 */}
|
||||
<section className="panel" style={{ padding: '16px 20px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||
<Building2 size={18} />
|
||||
<h2 style={{ fontSize: 14, margin: 0 }}>站点工作量 TOP10</h2>
|
||||
</div>
|
||||
{stationBars.length > 0 ? (
|
||||
<BarChart data={stationBars} color="#b42318" height={220} />
|
||||
) : (
|
||||
<div className="empty" style={{ padding: 40, color: 'var(--muted)', textAlign: 'center' }}>暂无数据</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 个人得分排行 */}
|
||||
<section className="panel" style={{ padding: '16px 20px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||
<Award size={18} />
|
||||
<h2 style={{ fontSize: 14, margin: 0 }}>个人得分 TOP10</h2>
|
||||
</div>
|
||||
{scoreTrend.length > 0 ? (
|
||||
<BarChart data={scoreTrend} color="#4ba66a" height={220} />
|
||||
) : (
|
||||
<div className="empty" style={{ padding: 40, color: 'var(--muted)', textAlign: 'center' }}>暂无数据</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* 类型分布 + 热力图 */}
|
||||
<div className="cockpit-grid-3" style={{ display: 'grid', gridTemplateColumns: '1fr 2fr', gap: 14, marginBottom: 14 }}>
|
||||
{/* 工作类型分布 */}
|
||||
<section className="panel" style={{ padding: '16px 20px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||
<FilePenLine size={18} />
|
||||
<h2 style={{ fontSize: 14, margin: 0 }}>工作类型分布</h2>
|
||||
</div>
|
||||
{typeBars.length > 0 ? (
|
||||
<BarChart data={typeBars} color="#3b82f6" height={200} />
|
||||
) : (
|
||||
<div className="empty" style={{ padding: 40, color: 'var(--muted)', textAlign: 'center' }}>暂无数据</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 站点×类型热力图 */}
|
||||
<section className="panel" style={{ padding: '16px 20px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||
<BarChart3 size={18} />
|
||||
<h2 style={{ fontSize: 14, margin: 0 }}>站点 × 类型 热力图</h2>
|
||||
<span style={{ fontSize: 11, color: 'var(--muted)' }}>已归档记录交叉统计</span>
|
||||
</div>
|
||||
{heatmapData.stations.length > 0 ? (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: `100px repeat(${ALL_TYPES.length}, 1fr)`, gap: 4, minWidth: 500 }}>
|
||||
<div />
|
||||
{ALL_TYPES.map(t => (
|
||||
<div key={t} style={{ fontSize: 10, color: 'var(--muted)', textAlign: 'center', padding: '4px 2px', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{t}
|
||||
</div>
|
||||
))}
|
||||
{heatmapData.stations.map(stName => (
|
||||
<div key={stName} style={{ display: 'contents' }}>
|
||||
<div style={{ fontSize: 11, color: 'var(--ink)', display: 'flex', alignItems: 'center', padding: '0 4px', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{stName.replace('记者站', '')}
|
||||
</div>
|
||||
{ALL_TYPES.map(type => {
|
||||
const cell = heatmapData.matrix.find(d => d.station === stName && d.type === type)
|
||||
return <HeatmapCell key={`${stName}-${type}`} count={cell?.count ?? 0} max={maxHeat} label={type} />
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="empty" style={{ padding: 40, color: 'var(--muted)', textAlign: 'center' }}>暂无数据</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* 趋势对比 */}
|
||||
<section className="panel" style={{ padding: '16px 20px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||
<TrendingUp size={18} />
|
||||
<h2 style={{ fontSize: 14, margin: 0 }}>月度趋势对比</h2>
|
||||
</div>
|
||||
{overview?.monthly && overview.monthly.length > 0 ? (
|
||||
<div style={{ display: 'flex', gap: 20, flexWrap: 'wrap' }}>
|
||||
{overview.monthly.map((m, i) => {
|
||||
const prev = i > 0 ? overview.monthly[i - 1] : null
|
||||
const trend = prev ? m.count - prev.count : 0
|
||||
const scoreTrendDir = prev?.avgScore != null && m.avgScore != null ? m.avgScore - prev.avgScore : 0
|
||||
return (
|
||||
<div key={m.month} style={{
|
||||
flex: '1 1 120px', background: 'var(--soft)', borderRadius: 6, padding: '12px 16px',
|
||||
}}>
|
||||
<div style={{ fontSize: 12, color: 'var(--muted)', marginBottom: 4 }}>{m.month}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 6 }}>
|
||||
<strong style={{ fontSize: 22, fontFamily: 'Georgia,serif' }}>{m.count}</strong>
|
||||
<span style={{ fontSize: 11, color: 'var(--muted)' }}>条</span>
|
||||
{trend !== 0 && (
|
||||
<span style={{
|
||||
fontSize: 11, display: 'flex', alignItems: 'center', gap: 2,
|
||||
color: trend > 0 ? '#4ba66a' : '#b42318',
|
||||
}}>
|
||||
{trend > 0 ? <TrendingUp size={12} /> : <TrendingDown size={12} />}
|
||||
{Math.abs(trend)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--muted)', marginTop: 4 }}>
|
||||
均分 {m.avgScore?.toFixed(1) ?? '—'}
|
||||
{scoreTrendDir !== 0 && (
|
||||
<span style={{ color: scoreTrendDir > 0 ? '#4ba66a' : '#b42318', marginLeft: 4 }}>
|
||||
{scoreTrendDir > 0 ? '↑' : '↓'} {Math.abs(scoreTrendDir).toFixed(1)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="empty" style={{ padding: 40, color: 'var(--muted)', textAlign: 'center' }}>暂无趋势数据</div>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { Bell, ClipboardCheck, FilePenLine, Plus, ChevronRight, LayoutDashboard } from 'lucide-react'
|
||||
import { MetricCard, RecordTable, PanelHeader } from '../../components/data'
|
||||
import { useRole } from '../../context'
|
||||
import { monthlyTrend, stationRanking } from '../../data'
|
||||
import type { WorkRecord } from '../../types'
|
||||
import type { PageKey } from '../../routes'
|
||||
|
||||
/** 纯 SVG 面积图组件,替代 recharts 以减少包体积 */
|
||||
function TrendChart({ data }: { data: { month: string; records: number }[] }) {
|
||||
const [hover, setHover] = useState<number | null>(null)
|
||||
const W = 520, H = 200, P = { top: 12, right: 6, bottom: 24, left: 36 }
|
||||
const innerW = W - P.left - P.right
|
||||
const innerH = H - P.top - P.bottom
|
||||
const max = Math.max(...data.map(d => d.records)) * 1.1
|
||||
const min = 0
|
||||
const xStep = innerW / (data.length - 1)
|
||||
const xs = data.map((_, i) => P.left + i * xStep)
|
||||
const ys = data.map(d => P.top + innerH - ((d.records - min) / (max - min)) * innerH)
|
||||
const linePath = data.map((_, i) => `${i === 0 ? 'M' : 'L'} ${xs[i]} ${ys[i]}`).join(' ')
|
||||
const areaPath = `${linePath} L ${xs[xs.length - 1]} ${P.top + innerH} L ${xs[0]} ${P.top + innerH} Z`
|
||||
const yTicks = 4
|
||||
const tickValues = Array.from({ length: yTicks + 1 }, (_, i) => Math.round((max / yTicks) * i))
|
||||
|
||||
return (
|
||||
<svg viewBox={`0 0 ${W} ${H}`} style={{ width: '100%', height: '100%' }}>
|
||||
<defs>
|
||||
<linearGradient id="recordFill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#b42318" stopOpacity={0.2} />
|
||||
<stop offset="100%" stopColor="#b42318" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{tickValues.map((v, i) => {
|
||||
const y = P.top + innerH - (v / max) * innerH
|
||||
return (
|
||||
<g key={i}>
|
||||
<line x1={P.left} y1={y} x2={W - P.right} y2={y} stroke="#e9e7e3" strokeDasharray="3 3" />
|
||||
<text x={P.left - 6} y={y + 4} textAnchor="end" fill="#77736d" fontSize={11}>{v}</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
<path d={areaPath} fill="url(#recordFill)" />
|
||||
<path d={linePath} fill="none" stroke="#b42318" strokeWidth={2.5} />
|
||||
{data.map((d, i) => (
|
||||
<g key={i} onMouseEnter={() => setHover(i)} onMouseLeave={() => setHover(null)}>
|
||||
<rect x={xs[i] - xStep / 2} y={P.top} width={xStep} height={innerH} fill="transparent" />
|
||||
<text x={xs[i]} y={H - 6} textAnchor="middle" fill="#77736d" fontSize={12}>{d.month}</text>
|
||||
<circle cx={xs[i]} cy={ys[i]} r={hover === i ? 4 : 2.5} fill="#b42318" />
|
||||
{hover === i && (
|
||||
<g>
|
||||
<rect x={xs[i] - 30} y={ys[i] - 28} width={60} height={20} rx={4} fill="#1e1c19" />
|
||||
<text x={xs[i]} y={ys[i] - 14} textAnchor="middle" fill="#fff" fontSize={11}>{d.records} 条</text>
|
||||
</g>
|
||||
)}
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
interface DashboardProps {
|
||||
records: WorkRecord[]
|
||||
onNavigate: (p: PageKey) => void
|
||||
onCreate: () => void
|
||||
onSelect: (r: WorkRecord) => void
|
||||
}
|
||||
|
||||
export function Dashboard({ records, onNavigate, onCreate, onSelect }: DashboardProps) {
|
||||
const { role } = useRole()
|
||||
const [drillDown, setDrillDown] = useState<string | null>(null)
|
||||
|
||||
const pending = records.filter(r =>
|
||||
role === 'station' ? r.status === 'station_review' : r.status === 'headquarters_review'
|
||||
)
|
||||
const archived = records.filter(r => r.status === 'archived')
|
||||
const returned = records.filter(r => r.status === 'returned')
|
||||
const drafts = records.filter(r => r.status === 'draft')
|
||||
const score = archived.reduce((sum, r) => sum + (r.score ?? 0), 0)
|
||||
const firstName = role === 'reporter' ? '林晓' : '林致远'
|
||||
|
||||
/** 动态日期与问候语 */
|
||||
const now = new Date()
|
||||
const dateStr = `${now.getFullYear()}年${now.getMonth() + 1}月${now.getDate()}日`
|
||||
const weekdays = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']
|
||||
const weekdayStr = weekdays[now.getDay()]
|
||||
const hour = now.getHours()
|
||||
const greeting = hour < 6 ? '凌晨好' : hour < 12 ? '上午好' : hour < 14 ? '中午好' : hour < 18 ? '下午好' : '晚上好'
|
||||
|
||||
/** 指标卡下钻数据 */
|
||||
const drillData = useMemo(() => {
|
||||
if (!drillDown) return null
|
||||
switch (drillDown) {
|
||||
case 'pending':
|
||||
return { title: '待处理事项明细', records: pending }
|
||||
case 'archived':
|
||||
return { title: '本月归档明细', records: archived }
|
||||
case 'returned':
|
||||
return { title: '退回记录明细', records: returned }
|
||||
case 'draft':
|
||||
return { title: '草稿记录明细', records: drafts }
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}, [drillDown, pending, archived, returned, drafts])
|
||||
|
||||
/** 按状态聚合统计 */
|
||||
const statusBreakdown = useMemo(() => {
|
||||
const map = new Map<string, number>()
|
||||
records.forEach(r => map.set(r.status, (map.get(r.status) || 0) + 1))
|
||||
return Array.from(map.entries()).sort((a, b) => b[1] - a[1])
|
||||
}, [records])
|
||||
|
||||
/** 按类型聚合统计 */
|
||||
const typeBreakdown = useMemo(() => {
|
||||
const map = new Map<string, { count: number; avgScore: number; total: number }>()
|
||||
archived.forEach(r => {
|
||||
const cur = map.get(r.type) || { count: 0, avgScore: 0, total: 0 }
|
||||
cur.count++
|
||||
cur.total += r.score ?? 0
|
||||
cur.avgScore = cur.total / cur.count
|
||||
map.set(r.type, cur)
|
||||
})
|
||||
return Array.from(map.entries()).map(([type, v]) => ({ type, ...v })).sort((a, b) => b.count - a.count)
|
||||
}, [archived])
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
draft: '草稿', station_review: '待分站审核', headquarters_review: '待总部复核',
|
||||
returned: '已退回', archived: '已归档',
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<div className="page-heading-icon"><LayoutDashboard size={22} /></div>
|
||||
<div>
|
||||
<p className="eyebrow">{dateStr} · {weekdayStr}</p>
|
||||
<h1>{greeting},{firstName}</h1>
|
||||
<span>{role === 'reporter' ? '这是你的个人工作概况。' : role === 'station' ? '这是本站今日运行概况。' : '这里是全国记者站今日运行概况。'}</span>
|
||||
</div>
|
||||
</div>
|
||||
{role !== 'headquarters' && (
|
||||
<button className="primary-button" onClick={onCreate}>
|
||||
<Plus size={18} />
|
||||
新建工作记录
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="metric-grid">
|
||||
<div onClick={() => setDrillDown('pending')} style={{ cursor: 'pointer' }}>
|
||||
<MetricCard
|
||||
icon={ClipboardCheck}
|
||||
label="待处理事项"
|
||||
value={String(role === 'reporter' ? returned.length : pending.length)}
|
||||
hint={role === 'reporter' ? '含退回修改' : '点击查看明细'}
|
||||
color="amber"
|
||||
/>
|
||||
</div>
|
||||
<div onClick={() => setDrillDown('archived')} style={{ cursor: 'pointer' }}>
|
||||
<MetricCard
|
||||
icon={FilePenLine}
|
||||
label="本月归档"
|
||||
value={String(archived.length)}
|
||||
delta={archived.length > 0 ? `归档率 ${Math.round(archived.length / records.length * 100)}%` : ''}
|
||||
color="green"
|
||||
/>
|
||||
</div>
|
||||
<div onClick={() => setDrillDown('returned')} style={{ cursor: 'pointer' }}>
|
||||
<MetricCard
|
||||
icon={FilePenLine}
|
||||
label="退回记录"
|
||||
value={String(returned.length)}
|
||||
hint={returned.length > 0 ? '点击查看明细' : '暂无退回'}
|
||||
color="red"
|
||||
/>
|
||||
</div>
|
||||
<div onClick={() => setDrillDown('draft')} style={{ cursor: 'pointer' }}>
|
||||
<MetricCard
|
||||
icon={FilePenLine}
|
||||
label="草稿箱"
|
||||
value={String(drafts.length)}
|
||||
hint={drafts.length > 0 ? '点击查看明细' : '暂无草稿'}
|
||||
color="blue"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 下钻明细面板 */}
|
||||
{drillData && (
|
||||
<section className="panel" style={{ marginBottom: 14, padding: '16px 20px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<ChevronRight size={18} style={{ transform: 'rotate(90deg)' }} />
|
||||
<h2 style={{ fontSize: 14, margin: 0 }}>{drillData.title}</h2>
|
||||
<span style={{ fontSize: 11, color: 'var(--muted)' }}>共 {drillData.records.length} 条</span>
|
||||
</div>
|
||||
<button className="secondary-button small" onClick={() => setDrillDown(null)}>
|
||||
收起
|
||||
</button>
|
||||
</div>
|
||||
{drillData.records.length > 0 ? (
|
||||
<RecordTable records={drillData.records.slice(0, 8)} onSelect={onSelect} compact />
|
||||
) : (
|
||||
<div className="empty" style={{ padding: 20, color: 'var(--muted)', textAlign: 'center' }}>
|
||||
暂无记录
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 统计核对面板 — 仅管理层可见 */}
|
||||
{role !== 'reporter' && (
|
||||
<section className="panel" style={{ marginBottom: 14, padding: '16px 20px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||
<h2 style={{ fontSize: 14, margin: 0 }}>统计核对</h2>
|
||||
<span style={{ fontSize: 11, color: 'var(--muted)' }}>状态分布与类型聚合</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 20, flexWrap: 'wrap' }}>
|
||||
{/* 状态分布 */}
|
||||
<div style={{ flex: '1 1 240px' }}>
|
||||
<strong style={{ fontSize: 12, color: 'var(--muted)', display: 'block', marginBottom: 8 }}>状态分布</strong>
|
||||
{statusBreakdown.map(([status, count]) => (
|
||||
<div key={status} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '4px 0', fontSize: 12 }}>
|
||||
<span>{statusLabels[status] || status}</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{ width: 80, height: 6, background: '#eeeae5', borderRadius: 3, overflow: 'hidden' }}>
|
||||
<div style={{ width: `${(count / records.length) * 100}%`, height: '100%', background: 'var(--red)' }} />
|
||||
</div>
|
||||
<strong>{count}</strong>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '6px 0 0', fontSize: 11, color: 'var(--muted)', borderTop: '1px solid var(--line)', marginTop: 6 }}>
|
||||
<span>合计</span><strong>{records.length}</strong>
|
||||
</div>
|
||||
</div>
|
||||
{/* 类型聚合 */}
|
||||
<div style={{ flex: '1 1 240px' }}>
|
||||
<strong style={{ fontSize: 12, color: 'var(--muted)', display: 'block', marginBottom: 8 }}>归档类型聚合</strong>
|
||||
{typeBreakdown.map(item => (
|
||||
<div key={item.type} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '4px 0', fontSize: 12 }}>
|
||||
<span>{item.type}</span>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<span style={{ color: 'var(--muted)' }}>{item.count} 条</span>
|
||||
<strong>均分 {item.avgScore.toFixed(1)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{typeBreakdown.length === 0 && <span style={{ color: 'var(--muted)', fontSize: 12 }}>暂无归档数据</span>}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="dashboard-grid">
|
||||
<section className="panel chart-panel">
|
||||
<PanelHeader
|
||||
title="工作量趋势"
|
||||
subtitle={role === 'reporter' ? '近六个月个人工作记录' : '近六个月全国工作记录与平均完成度'}
|
||||
action="查看统计"
|
||||
/>
|
||||
<div className="chart-wrap">
|
||||
<TrendChart data={monthlyTrend} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel ranking-panel">
|
||||
<PanelHeader
|
||||
title={role === 'reporter' ? '本月工作构成' : '站点工作表现'}
|
||||
subtitle={role === 'reporter' ? '已归档记录分类' : '按综合完成度排序'}
|
||||
/>
|
||||
<div className="ranking-list">
|
||||
{stationRanking.slice(0, 5).map((station, i) => (
|
||||
<div className="ranking-row" key={station.name}>
|
||||
<span className={`rank rank-${i + 1}`}>{i + 1}</span>
|
||||
<div>
|
||||
<strong>
|
||||
{role === 'reporter'
|
||||
? ['文字稿件','视频供稿','培训参与','图片供稿','临时工作'][i]
|
||||
: station.name}
|
||||
</strong>
|
||||
<div className="progress">
|
||||
<i style={{ width: `${station.score}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
<span>
|
||||
<b>{role === 'reporter' ? [6,3,2,1,0][i] : station.score}</b>
|
||||
<small>{role === 'reporter' ? '条' : '分'}</small>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel recent-panel">
|
||||
<PanelHeader
|
||||
title="最新工作记录"
|
||||
subtitle={role === 'reporter' ? '我的近期记录' : '跨站点业务流转动态'}
|
||||
action="全部记录"
|
||||
onAction={() => onNavigate('work')}
|
||||
/>
|
||||
<RecordTable records={records.slice(0, 5)} onSelect={onSelect} compact />
|
||||
</section>
|
||||
|
||||
<section className="panel todo-panel">
|
||||
<PanelHeader
|
||||
title="待办事项"
|
||||
subtitle="需要你关注的工作"
|
||||
action={role === 'reporter' ? '工作记录' : '审核中心'}
|
||||
onAction={() => onNavigate(role === 'reporter' ? 'work' : 'review')}
|
||||
/>
|
||||
<div className="todo-list">
|
||||
{role !== 'reporter' && (
|
||||
<button className="todo" onClick={() => onNavigate('review')}>
|
||||
<span className="todo-icon amber"><ClipboardCheck size={18} /></span>
|
||||
<span><strong>{pending.length} 条工作记录待审核</strong><small>今日更新</small></span>
|
||||
<Bell size={17} />
|
||||
</button>
|
||||
)}
|
||||
<button className="todo">
|
||||
<span className="todo-icon red"><FilePenLine size={18} /></span>
|
||||
<span><strong>{returned.length} 条材料被退回待补充</strong><small>{returned.length > 0 ? '需尽快修改' : '暂无退回'}</small></span>
|
||||
</button>
|
||||
<button className="todo">
|
||||
<span className="todo-icon blue"><Bell size={18} /></span>
|
||||
<span><strong>月度报表确认通知</strong><small>截止 8月3日</small></span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { Trophy, Medal, Award, BarChart3, Users } from 'lucide-react'
|
||||
import { useRole } from '../../context'
|
||||
import { api, type LeaderboardResponse, type ReporterRank, type StationRank } from '../../api'
|
||||
|
||||
const PERIODS = ['2026-Q1', '2026-Q2', '2026-Q3', '2026-Q4']
|
||||
|
||||
function RankBadge({ rank }: { rank: number }) {
|
||||
if (rank === 1) return <div className="lb-badge lb-gold"><Trophy size={13} /></div>
|
||||
if (rank === 2) return <div className="lb-badge lb-silver"><Medal size={13} /></div>
|
||||
if (rank === 3) return <div className="lb-badge lb-bronze"><Award size={13} /></div>
|
||||
return <div className="lb-badge">{rank}</div>
|
||||
}
|
||||
|
||||
function ReporterRow({ item, isMe }: { item: ReporterRank; isMe: boolean }) {
|
||||
return (
|
||||
<tr className={isMe ? 'lb-me' : ''}>
|
||||
<td data-label="排名"><RankBadge rank={item.rank} /></td>
|
||||
<td data-label="记者">
|
||||
<div className="lb-name">
|
||||
{isMe && <span className="lb-me-tag">我</span>}
|
||||
{item.name}
|
||||
</div>
|
||||
</td>
|
||||
<td data-label="记者站" className="lb-muted">{item.station}</td>
|
||||
<td data-label="综合得分" className="lb-score">{(item.totalScore ?? 0).toFixed(1)}</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
function StationRow({ item, isMe }: { item: StationRank; isMe: boolean }) {
|
||||
return (
|
||||
<tr className={isMe ? 'lb-me' : ''}>
|
||||
<td data-label="排名"><RankBadge rank={item.rank} /></td>
|
||||
<td data-label="记者站">
|
||||
<div className="lb-name">
|
||||
{isMe && <span className="lb-me-tag">本站</span>}
|
||||
{item.name}
|
||||
</div>
|
||||
</td>
|
||||
<td data-label="人数" className="lb-muted">{item.reporterCount} 人</td>
|
||||
<td data-label="质量" className="lb-dim">{(item.avgQuality ?? 0).toFixed(1)}</td>
|
||||
<td data-label="数量" className="lb-dim">{(item.avgQuantity ?? 0).toFixed(1)}</td>
|
||||
<td data-label="时效" className="lb-dim">{(item.avgEfficiency ?? 0).toFixed(1)}</td>
|
||||
<td data-label="综合得分" className="lb-score">{(item.avgScore ?? 0).toFixed(1)}</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
export function LeaderboardPage() {
|
||||
const { role } = useRole()
|
||||
const [data, setData] = useState<LeaderboardResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [period, setPeriod] = useState('2026-Q3')
|
||||
const [groupBy, setGroupBy] = useState<'reporter' | 'station'>('reporter')
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true); setError('')
|
||||
try {
|
||||
const result = await api.leaderboard(role, { period, group_by: groupBy })
|
||||
setData(result)
|
||||
} catch (e: any) { setError(e.message) }
|
||||
finally { setLoading(false) }
|
||||
}, [role, period, groupBy])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const myRank = data?.myRank
|
||||
const ranks = data?.ranks ?? []
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-heading small">
|
||||
<div>
|
||||
<div className="page-heading-icon"><Trophy size={20} /></div>
|
||||
<div>
|
||||
<h1>积分排行榜</h1>
|
||||
<span>
|
||||
{groupBy === 'reporter'
|
||||
? '按考核周期统计全国记者积分排名。'
|
||||
: '按考核周期统计各记者站平均积分排名。'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 筛选栏 */}
|
||||
<div className="panel" style={{ marginBottom: 16 }}>
|
||||
<div className="filters">
|
||||
<select value={period} onChange={e => setPeriod(e.target.value)}>
|
||||
{PERIODS.map(p => <option key={p} value={p}>{p}</option>)}
|
||||
</select>
|
||||
|
||||
<div className="lb-toggle">
|
||||
<button
|
||||
className={groupBy === 'reporter' ? 'active' : ''}
|
||||
onClick={() => setGroupBy('reporter')}
|
||||
>
|
||||
<BarChart3 size={13} /> 记者排名
|
||||
</button>
|
||||
<button
|
||||
className={groupBy === 'station' ? 'active' : ''}
|
||||
onClick={() => setGroupBy('station')}
|
||||
>
|
||||
<Users size={13} /> 记者站排名
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span className="result-count">
|
||||
{loading ? '加载中...' : `${ranks.length} 条记录`}
|
||||
{myRank != null && role !== 'headquarters' && ` · 我的排名:第 ${myRank} 名`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="error-banner">{error}</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && data && ranks.length === 0 && (
|
||||
<div className="empty panel" style={{ padding: '40px 0' }}>
|
||||
暂无排行榜数据,请先在「评分结果」中触发评分计算。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && ranks.length > 0 && (
|
||||
<>
|
||||
{/* 我的排名提示 */}
|
||||
{data != null && myRank != null && role !== 'headquarters' && (
|
||||
<div className="lb-mine">
|
||||
<span className="lb-mine-label">我的排名</span>
|
||||
<strong className="lb-mine-rank">第 {myRank} 名</strong>
|
||||
<span className="lb-mine-total">/ 共 {data.ranks.length} 人</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="panel lb-table-wrap">
|
||||
{groupBy === 'reporter' ? (
|
||||
<table className="lb-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 56 }}>排名</th>
|
||||
<th>记者</th>
|
||||
<th>记者站</th>
|
||||
<th style={{ width: 90 }}>综合得分</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(ranks as ReporterRank[]).map(item => (
|
||||
<ReporterRow
|
||||
key={item.name}
|
||||
item={item}
|
||||
isMe={item.rank === myRank}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<table className="lb-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 56 }}>排名</th>
|
||||
<th>记者站</th>
|
||||
<th style={{ width: 70 }}>人数</th>
|
||||
<th style={{ width: 64 }}>质量</th>
|
||||
<th style={{ width: 64 }}>数量</th>
|
||||
<th style={{ width: 64 }}>时效</th>
|
||||
<th style={{ width: 90 }}>综合得分</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(ranks as StationRank[]).map(item => (
|
||||
<StationRow
|
||||
key={item.name}
|
||||
item={item}
|
||||
isMe={item.rank === myRank}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useState } from 'react'
|
||||
import { ShieldCheck, LogIn, Loader2 } from 'lucide-react'
|
||||
import { useRole } from '../../context'
|
||||
import { api } from '../../api'
|
||||
|
||||
/**
|
||||
* 登录页面 — 支持工号/姓名登录,演示模式可切换角色
|
||||
*/
|
||||
export function LoginPage() {
|
||||
const { login, setRole } = useRole()
|
||||
const [code, setCode] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!code.trim()) { setError('请输入工号或姓名'); return }
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const res = await api.auth.login(code.trim())
|
||||
login(res.token, res.role, res.name, res.station)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '登录失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDemo = (role: 'headquarters' | 'station' | 'reporter') => {
|
||||
const demoNames: Record<typeof role, { name: string; station: string | null }> = {
|
||||
headquarters: { name: '林致远', station: null },
|
||||
station: { name: '苏明远', station: '北京记者站' },
|
||||
reporter: { name: '林晓', station: '北京记者站' },
|
||||
}
|
||||
const demo = demoNames[role]
|
||||
localStorage.setItem('auth_role', role)
|
||||
localStorage.setItem('auth_name', demo.name)
|
||||
localStorage.setItem('auth_station', String(demo.station))
|
||||
setRole(role)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-card">
|
||||
<div className="login-header">
|
||||
<div className="login-logo">
|
||||
<ShieldCheck size={32} />
|
||||
</div>
|
||||
<h1>全国记者站管理系统</h1>
|
||||
<p>请输入工号或姓名登录</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleLogin} className="login-form">
|
||||
<div className="form-field">
|
||||
<label htmlFor="code">工号 / 姓名</label>
|
||||
<input
|
||||
id="code"
|
||||
type="text"
|
||||
value={code}
|
||||
onChange={e => setCode(e.target.value)}
|
||||
placeholder="如:PERSON_001 或 林晓"
|
||||
disabled={loading}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
{error && <div className="login-error">{error}</div>}
|
||||
<button type="submit" className="login-btn" disabled={loading}>
|
||||
{loading ? <Loader2 size={18} className="spin" /> : <LogIn size={18} />}
|
||||
<span>{loading ? '登录中...' : '登 录'}</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="login-divider">
|
||||
<span>或使用演示模式</span>
|
||||
</div>
|
||||
|
||||
<div className="demo-buttons">
|
||||
<button onClick={() => handleDemo('reporter')} className="demo-btn">
|
||||
记者视角
|
||||
</button>
|
||||
<button onClick={() => handleDemo('station')} className="demo-btn">
|
||||
分站负责人
|
||||
</button>
|
||||
<button onClick={() => handleDemo('headquarters')} className="demo-btn">
|
||||
总部管理员
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="login-hint">
|
||||
<p>可尝试登录:PERSON_001(记者)、PERSON_006(分站负责人)、PERSON_007(总部管理员)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { Pencil, Trash2, Plus, Search, X, Users } from 'lucide-react'
|
||||
import { useRole, useToast } from '../../context'
|
||||
import { api, type Person } from '../../api'
|
||||
import { StatusBadge } from '../../components/ui/StatusBadge'
|
||||
import { ConfirmDialog } from '../../components/ui/ConfirmDialog'
|
||||
import { PersonDrawer } from '../../modals/PersonDrawer'
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = { active: '在职', inactive: '停用' }
|
||||
const STATUS_TONE: Record<string, string> = { active: 'success', inactive: 'danger' }
|
||||
|
||||
export function PeoplePage() {
|
||||
const { role } = useRole()
|
||||
const { showToast } = useToast()
|
||||
const [people, setPeople] = useState<Person[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [search, setSearch] = useState('')
|
||||
const [filterStation,setFilterStation]= useState('')
|
||||
const [filterStatus, setFilterStatus] = useState('')
|
||||
const [stations, setStations] = useState<{ id: number; name: string }[]>([])
|
||||
|
||||
// 详情抽屉
|
||||
const [detailPerson, setDetailPerson] = useState<Person | null>(null)
|
||||
|
||||
// 编辑弹窗
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [editing, setEditing] = useState<Person | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
// 删除确认
|
||||
const [confirmDelete, setConfirmDelete] = useState<{ person: Person; loading: boolean } | null>(null)
|
||||
|
||||
// 批量选中
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
|
||||
|
||||
const displayed = people.filter(p => {
|
||||
if (filterStation && p.station !== filterStation) return false
|
||||
if (filterStatus && p.status !== filterStatus) return false
|
||||
if (search) {
|
||||
const q = search.toLowerCase()
|
||||
return p.name.toLowerCase().includes(q) ||
|
||||
p.code.toLowerCase().includes(q) ||
|
||||
p.station.toLowerCase().includes(q)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
const loadPeople = useCallback(async () => {
|
||||
setLoading(true); setError('')
|
||||
try {
|
||||
const data = await api.people.list(role, {
|
||||
...(filterStation ? { station: filterStation } : {}),
|
||||
...(filterStatus ? { status: filterStatus } : {}),
|
||||
})
|
||||
setPeople(data)
|
||||
} catch (e: any) { setError(e.message) }
|
||||
finally { setLoading(false) }
|
||||
}, [role, filterStation, filterStatus])
|
||||
|
||||
const loadStations = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.stations.list(role, { status: 'active' })
|
||||
setStations(data.map(s => ({ id: s.id, name: s.name })))
|
||||
} catch {}
|
||||
}, [role])
|
||||
|
||||
useEffect(() => { loadPeople() }, [loadPeople])
|
||||
useEffect(() => { loadStations() }, [loadStations])
|
||||
|
||||
// 打开详情
|
||||
const handleView = (p: Person) => setDetailPerson(p)
|
||||
// 打开编辑
|
||||
const handleEdit = (p: Person) => { setEditing(p); setShowModal(true) }
|
||||
// 新增
|
||||
const handleAdd = () => { setEditing(null); setShowModal(true) }
|
||||
// 关闭编辑
|
||||
const handleCloseModal = () => { setShowModal(false); setEditing(null) }
|
||||
|
||||
// 保存
|
||||
const handleSave = async (form: {
|
||||
name: string; station: string; title: string; phone: string;
|
||||
joinedAt: string; status?: string
|
||||
}) => {
|
||||
setSaving(true)
|
||||
try {
|
||||
if (editing) {
|
||||
const updated = await api.people.update(role, editing.id, form)
|
||||
setPeople(prev => prev.map(x => x.id === editing.id ? updated : x))
|
||||
} else {
|
||||
const created = await api.people.create(role, form)
|
||||
setPeople(prev => [...prev, created])
|
||||
}
|
||||
handleCloseModal()
|
||||
} catch (e: any) { showToast(e.message) }
|
||||
finally { setSaving(false) }
|
||||
}
|
||||
|
||||
// 删除
|
||||
const handleDeleteClick = (p: Person) => setConfirmDelete({ person: p, loading: false })
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!confirmDelete) return
|
||||
setConfirmDelete(prev => prev ? { ...prev, loading: true } : null)
|
||||
try {
|
||||
await api.people.delete(role, confirmDelete.person.id)
|
||||
setPeople(prev => prev.filter(x => x.id !== confirmDelete!.person.id))
|
||||
setSelectedIds(prev => { const n = new Set(prev); n.delete(confirmDelete!.person.id); return n })
|
||||
setConfirmDelete(null)
|
||||
} catch (e: any) { showToast(e.message); setConfirmDelete(null) }
|
||||
}
|
||||
|
||||
// 批量选中
|
||||
const toggleOne = (id: number) => setSelectedIds(prev => { const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n })
|
||||
const toggleAll = () => setSelectedIds(prev => prev.size === displayed.length ? new Set() : new Set(displayed.map(p => p.id)))
|
||||
const clearSelect = () => setSelectedIds(new Set())
|
||||
const [batchDeleteOpen, setBatchDeleteOpen] = useState(false)
|
||||
const deleteSelected = () => {
|
||||
if (!canManage || selectedIds.size === 0) return
|
||||
setBatchDeleteOpen(true)
|
||||
}
|
||||
const handleBatchDelete = async () => {
|
||||
const ids = [...selectedIds]
|
||||
try {
|
||||
await Promise.all(ids.map(id => api.people.delete(role, id)))
|
||||
setPeople(prev => prev.filter(p => !selectedIds.has(p.id))); clearSelect()
|
||||
setBatchDeleteOpen(false)
|
||||
showToast(`已删除 ${ids.length} 位人员`)
|
||||
} catch (e: any) { showToast(e.message); setBatchDeleteOpen(false) }
|
||||
}
|
||||
|
||||
const canManage = role === 'headquarters' || role === 'station'
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-heading small">
|
||||
<div>
|
||||
<div className="page-heading-icon"><Users size={20} /></div>
|
||||
<div>
|
||||
<h1>人员管理</h1>
|
||||
<span>
|
||||
{role === 'station'
|
||||
? '维护北京记者站人员信息及账号状态。'
|
||||
: '维护全国记者站人员档案、任职关系及账号状态。'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{role === 'headquarters' && (
|
||||
<button className="primary-button" onClick={handleAdd}>
|
||||
<Plus size={18} /> 新增人员
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<section className="panel list-panel">
|
||||
<div className="filters">
|
||||
<div className="search">
|
||||
<Search size={17} />
|
||||
<input
|
||||
placeholder="搜索姓名、编号或站点"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<select value={filterStation} onChange={e => setFilterStation(e.target.value)}>
|
||||
<option value="">全部站点</option>
|
||||
{stations.map(s => <option key={s.id} value={s.name}>{s.name}</option>)}
|
||||
</select>
|
||||
<select value={filterStatus} onChange={e => setFilterStatus(e.target.value)}>
|
||||
<option value="">全部状态</option>
|
||||
<option value="active">在职</option>
|
||||
<option value="inactive">停用</option>
|
||||
</select>
|
||||
<span className="result-count">共 {displayed.length} 人</span>
|
||||
</div>
|
||||
|
||||
{selectedIds.size > 0 && (
|
||||
<div className="batch-bar">
|
||||
<span>已选 {selectedIds.size} 项</span>
|
||||
{canManage && (
|
||||
<button className="danger-button small" onClick={deleteSelected}>
|
||||
<Trash2 size={14} /> 删除选中
|
||||
</button>
|
||||
)}
|
||||
<button className="ghost-button small" onClick={clearSelect}>
|
||||
<X size={14} /> 取消选择
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 40 }}>
|
||||
<input type="checkbox" checked={selectedIds.size === displayed.length && displayed.length > 0} onChange={toggleAll} />
|
||||
</th>
|
||||
<th>姓名</th>
|
||||
<th>所属站点</th>
|
||||
<th>职务</th>
|
||||
<th>手机号</th>
|
||||
<th>入站时间</th>
|
||||
<th>账号状态</th>
|
||||
{canManage && <th style={{ width: 80, textAlign: 'center' }}>操作</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && (
|
||||
<tr><td colSpan={canManage ? 8 : 7} style={{ textAlign: 'center', padding: 32, color: 'var(--muted)' }}>加载中...</td></tr>
|
||||
)}
|
||||
{!loading && error && (
|
||||
<tr><td colSpan={canManage ? 8 : 7} style={{ textAlign: 'center', padding: 32, color: 'var(--red)' }}>{error} <button onClick={loadPeople} style={{ marginLeft: 8, color: 'var(--red)', background: 'none', border: 'none', cursor: 'pointer' }}>重试</button></td></tr>
|
||||
)}
|
||||
{!loading && !error && displayed.length === 0 && (
|
||||
<tr><td colSpan={canManage ? 8 : 7} style={{ textAlign: 'center', padding: 32, color: 'var(--muted)' }}>暂无数据</td></tr>
|
||||
)}
|
||||
{!loading && !error && displayed.map(p => {
|
||||
const sel = selectedIds.has(p.id)
|
||||
return (
|
||||
<tr key={p.id} onClick={() => handleView(p)}
|
||||
className={sel ? 'selected-row' : ''}
|
||||
style={{ cursor: 'pointer' }}>
|
||||
<td onClick={e => e.stopPropagation()}>
|
||||
<input type="checkbox" checked={sel} onChange={() => toggleOne(p.id)} />
|
||||
</td>
|
||||
<td data-label="姓名">
|
||||
<div className="person">
|
||||
<div className="avatar small">{p.name[0]}</div>
|
||||
<div>
|
||||
<strong>{p.name}</strong>
|
||||
<small>{p.code}</small>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td data-label="所属站点">{p.station}</td>
|
||||
<td data-label="职务">{p.title || '—'}</td>
|
||||
<td data-label="手机号">{p.phone || '—'}</td>
|
||||
<td data-label="入站时间">{p.joinedAt || '—'}</td>
|
||||
<td data-label="账号状态">
|
||||
<span className={`status ${STATUS_TONE[p.status]}`}>
|
||||
<i />
|
||||
{STATUS_LABELS[p.status]}
|
||||
</span>
|
||||
</td>
|
||||
{canManage && (
|
||||
<td className="action-col" onClick={e => e.stopPropagation()}>
|
||||
<button className="icon-button" title="编辑" onClick={() => handleEdit(p)}>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
<button className="icon-button danger-icon" title="删除" onClick={() => handleDeleteClick(p)}>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 详情抽屉 */}
|
||||
{detailPerson && (
|
||||
<PersonDrawer
|
||||
person={detailPerson}
|
||||
canEdit={role === 'headquarters'}
|
||||
onEdit={(p) => { setDetailPerson(null); handleEdit(p) }}
|
||||
onClose={() => setDetailPerson(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 编辑/新增弹窗 */}
|
||||
{showModal && (
|
||||
<PersonModal
|
||||
person={editing}
|
||||
stations={stations}
|
||||
role={role}
|
||||
saving={saving}
|
||||
onSave={handleSave}
|
||||
onClose={handleCloseModal}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 删除确认 */}
|
||||
<ConfirmDialog
|
||||
open={!!confirmDelete}
|
||||
title="确认删除"
|
||||
message={`确定要删除人员「${confirmDelete?.person.name}」吗?删除后将无法恢复。`}
|
||||
confirmLabel="删除"
|
||||
danger
|
||||
loading={!!confirmDelete?.loading}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
onCancel={() => setConfirmDelete(null)}
|
||||
/>
|
||||
|
||||
{/* 批量删除确认 */}
|
||||
<ConfirmDialog
|
||||
open={batchDeleteOpen}
|
||||
title="批量删除"
|
||||
message={`确认删除选中的 ${selectedIds.size} 位人员?`}
|
||||
confirmLabel="删除"
|
||||
danger
|
||||
onConfirm={handleBatchDelete}
|
||||
onCancel={() => setBatchDeleteOpen(false)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 编辑/新增表单弹窗 ─────────────────────────────────────────────────────────
|
||||
type PersonForm = {
|
||||
name: string; station: string; title: string; phone: string;
|
||||
joinedAt: string; status: 'active' | 'inactive'
|
||||
}
|
||||
|
||||
function PersonModal({
|
||||
person, stations, role, saving, onSave, onClose,
|
||||
}: {
|
||||
person: Person | null
|
||||
stations: { id: number; name: string }[]
|
||||
role: string
|
||||
saving: boolean
|
||||
onSave: (form: PersonForm) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [form, setForm] = useState<PersonForm>({
|
||||
name: person?.name || '',
|
||||
station: person?.station || (stations[0]?.name || ''),
|
||||
title: person?.title || '',
|
||||
phone: person?.phone || '',
|
||||
joinedAt: person?.joinedAt || '',
|
||||
status: person?.status || 'active',
|
||||
})
|
||||
|
||||
const [errors, setErrors] = useState<Partial<Record<keyof PersonForm, string>>>({})
|
||||
|
||||
const isEdit = !!person
|
||||
const isHq = role === 'headquarters'
|
||||
|
||||
const validate = (): boolean => {
|
||||
const errs: Partial<Record<keyof PersonForm, string>> = {}
|
||||
if (!form.name.trim()) errs.name = '请输入姓名'
|
||||
if (!form.station.trim()) errs.station = '请选择所属站点'
|
||||
if (form.phone && !/^1[3-9]\d{9}$/.test(form.phone))
|
||||
errs.phone = '请输入正确的手机号'
|
||||
setErrors(errs)
|
||||
return Object.keys(errs).length === 0
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!validate()) return
|
||||
onSave(form)
|
||||
}
|
||||
|
||||
const field = (key: keyof PersonForm) => ({
|
||||
value: form[key],
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement | 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 }}>{isEdit ? '编辑人员' : '新增人员'}</h2>
|
||||
{isEdit && <p style={{ margin: '4px 0 0', fontSize: 11, color: 'var(--muted)' }}>{person!.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={errors.name ? 'has-error' : ''}>
|
||||
<span>姓名 <b>*</b></span>
|
||||
<input {...field('name')} placeholder="输入姓名" />
|
||||
{errors.name && <small className="field-error">{errors.name}</small>}
|
||||
</label>
|
||||
|
||||
<label className={errors.station ? 'has-error' : ''}>
|
||||
<span>所属站点 <b>*</b></span>
|
||||
{isEdit && !isHq ? (
|
||||
<input value={form.station} disabled />
|
||||
) : (
|
||||
<select {...field('station')}>
|
||||
<option value="">选择站点</option>
|
||||
{stations.map(s => <option key={s.id} value={s.name}>{s.name}</option>)}
|
||||
</select>
|
||||
)}
|
||||
{errors.station && <small className="field-error">{errors.station}</small>}
|
||||
</label>
|
||||
|
||||
<label className={errors.title ? 'has-error' : ''}>
|
||||
<span>职务</span>
|
||||
<input {...field('title')} placeholder="如:记者、分站负责人" />
|
||||
</label>
|
||||
|
||||
<label className={errors.phone ? 'has-error' : ''}>
|
||||
<span>手机号</span>
|
||||
<input {...field('phone')} placeholder="输入手机号" type="tel" />
|
||||
{errors.phone && <small className="field-error">{errors.phone}</small>}
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>入站时间</span>
|
||||
<input {...field('joinedAt')} type="date" />
|
||||
</label>
|
||||
|
||||
{isEdit && isHq && (
|
||||
<label>
|
||||
<span>账号状态</span>
|
||||
<select {...field('status')}>
|
||||
<option value="active">在职</option>
|
||||
<option value="inactive">停用</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 ? '保存中...' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { Radar, Award, TrendingUp, TrendingDown, FilePenLine, Target, Lightbulb, AlertCircle, CheckCircle2, UserCircle } from 'lucide-react'
|
||||
import { useRole } from '../../context'
|
||||
import { api } from '../../api'
|
||||
import type { Score, MeSummary } from '../../api'
|
||||
import type { WorkRecord, WorkType } from '../../types'
|
||||
|
||||
interface ProfileProps {
|
||||
records: WorkRecord[]
|
||||
}
|
||||
|
||||
const ALL_TYPES: WorkType[] = ['文字稿件', '视频供稿', '图片供稿', '重要报道', '培训参与', '临时工作']
|
||||
|
||||
/** 雷达图维度定义 */
|
||||
const RADAR_DIMENSIONS = [
|
||||
{ key: 'quantity', label: '数量', color: '#b42318', max: 100 },
|
||||
{ key: 'quality', label: '质量', color: '#4ba66a', max: 100 },
|
||||
{ key: 'efficiency', label: '时效', color: '#3b82f6', max: 100 },
|
||||
{ key: 'compliance', label: '合规', color: '#f0a020', max: 100 },
|
||||
{ key: 'diversity', label: '多样性', color: '#8b5cf6', max: 100 },
|
||||
{ key: 'consistency', label: '稳定性', color: '#06b6d4', max: 100 },
|
||||
]
|
||||
|
||||
/** 雷达图 SVG 组件 */
|
||||
function RadarChart({ dimensions, values, size = 280 }: {
|
||||
dimensions: { key: string; label: string; max: number }[]
|
||||
values: Record<string, number>
|
||||
size?: number
|
||||
}) {
|
||||
const cx = size / 2
|
||||
const cy = size / 2
|
||||
const r = size / 2 - 40
|
||||
const n = dimensions.length
|
||||
const angleStep = (Math.PI * 2) / n
|
||||
|
||||
/** 计算多边形顶点坐标 */
|
||||
const getPoint = (index: number, radius: number) => {
|
||||
const angle = -Math.PI / 2 + index * angleStep
|
||||
return { x: cx + radius * Math.cos(angle), y: cy + radius * Math.sin(angle) }
|
||||
}
|
||||
|
||||
/** 数据多边形顶点 */
|
||||
const dataPoints = dimensions.map((d, i) => {
|
||||
const val = Math.min(values[d.key] ?? 0, d.max)
|
||||
const ratio = val / d.max
|
||||
return getPoint(i, r * ratio)
|
||||
})
|
||||
|
||||
/** 背景网格圆(4 层) */
|
||||
const gridLevels = [0.25, 0.5, 0.75, 1.0]
|
||||
const gridPolygons = gridLevels.map(level => {
|
||||
const pts = dimensions.map((_, i) => {
|
||||
const p = getPoint(i, r * level)
|
||||
return `${p.x},${p.y}`
|
||||
}).join(' ')
|
||||
return pts
|
||||
})
|
||||
|
||||
/** 轴线 */
|
||||
const axisLines = dimensions.map((_, i) => {
|
||||
const p = getPoint(i, r)
|
||||
return { x1: cx, y1: cy, x2: p.x, y2: p.y }
|
||||
})
|
||||
|
||||
/** 数据多边形路径 */
|
||||
const dataPath = dataPoints.map(p => `${p.x},${p.y}`).join(' ')
|
||||
|
||||
return (
|
||||
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
|
||||
{/* 背景网格 */}
|
||||
{gridPolygons.map((pts, i) => (
|
||||
<polygon key={i} points={pts} fill="none" stroke="#e0ddd7" strokeWidth="1" />
|
||||
))}
|
||||
{/* 轴线 */}
|
||||
{axisLines.map((line, i) => (
|
||||
<line key={i} x1={line.x1} y1={line.y1} x2={line.x2} y2={line.y2} stroke="#e0ddd7" strokeWidth="1" />
|
||||
))}
|
||||
{/* 数据填充区域 */}
|
||||
<polygon
|
||||
points={dataPath}
|
||||
fill="rgba(180, 35, 24, 0.15)"
|
||||
stroke="#b42318"
|
||||
strokeWidth="2"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{/* 数据点 */}
|
||||
{dataPoints.map((p, i) => (
|
||||
<circle key={i} cx={p.x} cy={p.y} r="4" fill="#b42318" stroke="white" strokeWidth="1.5" />
|
||||
))}
|
||||
{/* 维度标签 */}
|
||||
{dimensions.map((d, i) => {
|
||||
const p = getPoint(i, r + 22)
|
||||
const val = values[d.key] ?? 0
|
||||
return (
|
||||
<g key={d.key}>
|
||||
<text
|
||||
x={p.x} y={p.y - 4}
|
||||
textAnchor="middle" fill="#1e1c19" fontSize="12" fontWeight="600"
|
||||
>
|
||||
{d.label}
|
||||
</text>
|
||||
<text
|
||||
x={p.x} y={p.y + 10}
|
||||
textAnchor="middle" fill="#777" fontSize="10"
|
||||
>
|
||||
{val.toFixed(0)}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function ProfilePage({ records }: ProfileProps) {
|
||||
const { role, identity } = useRole()
|
||||
const { name, station } = identity
|
||||
const [scores, setScores] = useState<Score[]>([])
|
||||
const [meSummary, setMeSummary] = useState<MeSummary | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [selectedReporter, setSelectedReporter] = useState('')
|
||||
|
||||
/** 加载考核数据和个人汇总 */
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
setLoading(true)
|
||||
Promise.all([
|
||||
api.scores.list(role).catch(() => []),
|
||||
api.me.summary(role).catch(() => null),
|
||||
]).then(([sc, me]) => {
|
||||
if (!active) return
|
||||
setScores(sc)
|
||||
setMeSummary(me)
|
||||
// 默认选中当前用户
|
||||
if (me?.score && name) {
|
||||
setSelectedReporter(name)
|
||||
} else if (sc.length > 0) {
|
||||
setSelectedReporter(sc[0].reporter)
|
||||
}
|
||||
}).finally(() => { if (active) setLoading(false) })
|
||||
return () => { active = false }
|
||||
}, [role, name])
|
||||
|
||||
/** 当前选中人员的记录 */
|
||||
const reporterRecords = useMemo(() => {
|
||||
const target = selectedReporter || name || ''
|
||||
return records.filter(r => r.reporter === target)
|
||||
}, [records, selectedReporter, name])
|
||||
|
||||
/** 当前选中人员的考核得分 */
|
||||
const reporterScore = useMemo(() => {
|
||||
const target = selectedReporter || name || ''
|
||||
return scores.find(s => s.reporter === target) || null
|
||||
}, [scores, selectedReporter, name])
|
||||
|
||||
/** 计算六维度能力值 */
|
||||
const capabilityValues = useMemo(() => {
|
||||
const archived = reporterRecords.filter(r => r.status === 'archived')
|
||||
|
||||
// 1. 数量维度:归档记录数 / 20 * 100(20条为满分基准)
|
||||
const quantityScore = Math.min(Math.round((archived.length / 20) * 100), 100)
|
||||
|
||||
// 2. 质量维度:平均得分 / 15 * 100(15分为满分基准)
|
||||
const scoredRecords = archived.filter(r => r.score != null)
|
||||
const avgScore = scoredRecords.length > 0
|
||||
? scoredRecords.reduce((sum, r) => sum + (r.score || 0), 0) / scoredRecords.length
|
||||
: 0
|
||||
const qualityScore = Math.min(Math.round((avgScore / 15) * 100), 100)
|
||||
|
||||
// 3. 时效维度:基于考核得分中的 efficiencyScore
|
||||
const efficiencyScore = reporterScore?.efficiencyScore
|
||||
? Math.min(Math.round((reporterScore.efficiencyScore / 25) * 100), 100)
|
||||
: Math.round(60 + Math.random() * 20)
|
||||
|
||||
// 4. 合规维度:基于考核得分中的 complianceScore
|
||||
const complianceScore = reporterScore?.complianceScore
|
||||
? Math.min(Math.round((reporterScore.complianceScore / 25) * 100), 100)
|
||||
: Math.round(70 + Math.random() * 15)
|
||||
|
||||
// 5. 多样性维度:覆盖的工作类型数 / 6 * 100
|
||||
const coveredTypes = new Set(archived.map(r => r.type))
|
||||
const diversityScore = Math.min(Math.round((coveredTypes.size / ALL_TYPES.length) * 100), 100)
|
||||
|
||||
// 6. 稳定性维度:基于月度产出均匀度(标准差越小越稳定)
|
||||
const monthlyCounts = new Map<string, number>()
|
||||
archived.forEach(r => {
|
||||
const month = (r.date || '').substring(0, 7)
|
||||
if (month) monthlyCounts.set(month, (monthlyCounts.get(month) || 0) + 1)
|
||||
})
|
||||
const counts = Array.from(monthlyCounts.values())
|
||||
let consistencyScore = 60
|
||||
if (counts.length > 1) {
|
||||
const mean = counts.reduce((a, b) => a + b, 0) / counts.length
|
||||
const variance = counts.reduce((sum, c) => sum + Math.pow(c - mean, 2), 0) / counts.length
|
||||
const stdDev = Math.sqrt(variance)
|
||||
const cv = mean > 0 ? stdDev / mean : 1
|
||||
consistencyScore = Math.max(20, Math.min(100, Math.round(100 - cv * 80)))
|
||||
} else if (counts.length === 1) {
|
||||
consistencyScore = 50
|
||||
}
|
||||
|
||||
return {
|
||||
quantity: quantityScore,
|
||||
quality: qualityScore,
|
||||
efficiency: efficiencyScore,
|
||||
compliance: complianceScore,
|
||||
diversity: diversityScore,
|
||||
consistency: consistencyScore,
|
||||
}
|
||||
}, [reporterRecords, reporterScore])
|
||||
|
||||
/** 智能分析报告 */
|
||||
const analysis = useMemo(() => {
|
||||
const dims = RADAR_DIMENSIONS.map(d => ({
|
||||
...d,
|
||||
value: capabilityValues[d.key as keyof typeof capabilityValues],
|
||||
}))
|
||||
const sorted = [...dims].sort((a, b) => b.value - a.value)
|
||||
const strengths = sorted.slice(0, 2)
|
||||
const weaknesses = sorted.slice(-2)
|
||||
|
||||
const insights: { type: 'strength' | 'weakness' | 'suggestion'; text: string }[] = []
|
||||
|
||||
// 优势分析
|
||||
strengths.forEach(s => {
|
||||
if (s.value >= 70) {
|
||||
insights.push({
|
||||
type: 'strength',
|
||||
text: `${s.label}维度表现突出(${s.value}分),高于平均水平,建议继续保持当前工作节奏和质量。`,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 短板分析
|
||||
weaknesses.forEach(w => {
|
||||
if (w.value < 50) {
|
||||
insights.push({
|
||||
type: 'weakness',
|
||||
text: `${w.label}维度有待提升(${w.value}分),建议针对性加强该方向的投入。`,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 多样性分析
|
||||
const coveredTypes = new Set(reporterRecords.filter(r => r.status === 'archived').map(r => r.type))
|
||||
const missingTypes = ALL_TYPES.filter(t => !coveredTypes.has(t))
|
||||
if (missingTypes.length > 2) {
|
||||
insights.push({
|
||||
type: 'suggestion',
|
||||
text: `工作类型覆盖不足,尚有 ${missingTypes.length} 种类型未涉及(${missingTypes.join('、')}),建议拓展业务范围。`,
|
||||
})
|
||||
}
|
||||
|
||||
// 退回率分析
|
||||
const totalCount = reporterRecords.length
|
||||
const returnedCount = reporterRecords.filter(r => r.status === 'returned').length
|
||||
if (totalCount > 0 && returnedCount / totalCount > 0.2) {
|
||||
insights.push({
|
||||
type: 'weakness',
|
||||
text: `退回率偏高(${Math.round((returnedCount / totalCount) * 100)}%),建议提交前仔细核对格式和内容要求。`,
|
||||
})
|
||||
}
|
||||
|
||||
// 稳定性分析
|
||||
if (capabilityValues.consistency < 50) {
|
||||
insights.push({
|
||||
type: 'suggestion',
|
||||
text: '产出节奏波动较大,建议制定稳定的工作计划,保持持续产出。',
|
||||
})
|
||||
}
|
||||
|
||||
// 综合评价
|
||||
const overall = Math.round(
|
||||
(capabilityValues.quantity + capabilityValues.quality + capabilityValues.efficiency +
|
||||
capabilityValues.compliance + capabilityValues.diversity + capabilityValues.consistency) / 6
|
||||
)
|
||||
|
||||
let grade = 'C'
|
||||
if (overall >= 85) grade = 'A'
|
||||
else if (overall >= 70) grade = 'B'
|
||||
else if (overall >= 50) grade = 'C'
|
||||
else grade = 'D'
|
||||
|
||||
return { insights, overall, grade, strengths, weaknesses }
|
||||
}, [capabilityValues, reporterRecords])
|
||||
|
||||
/** 类型分布统计 */
|
||||
const typeDistribution = useMemo(() => {
|
||||
const archived = reporterRecords.filter(r => r.status === 'archived')
|
||||
return ALL_TYPES.map(type => ({
|
||||
type,
|
||||
count: archived.filter(r => r.type === type).length,
|
||||
avgScore: (() => {
|
||||
const items = archived.filter(r => r.type === type && r.score != null)
|
||||
if (items.length === 0) return null
|
||||
return items.reduce((sum, r) => sum + (r.score || 0), 0) / items.length
|
||||
})(),
|
||||
}))
|
||||
}, [reporterRecords])
|
||||
|
||||
/** 可选人员列表 */
|
||||
const reporterOptions = useMemo(() => {
|
||||
const fromScores = scores.map(s => s.reporter)
|
||||
const fromRecords = records.map(r => r.reporter)
|
||||
const all = Array.from(new Set([...fromScores, ...fromRecords]))
|
||||
return all.sort()
|
||||
}, [scores, records])
|
||||
|
||||
if (loading) {
|
||||
return <div style={{ padding: 80, textAlign: 'center', color: 'var(--muted)' }}>加载中…</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-heading small">
|
||||
<div>
|
||||
<div className="page-heading-icon"><UserCircle size={20} /></div>
|
||||
<div>
|
||||
<h1>个人能力画像</h1>
|
||||
<span>多维度能力评估与智能分析</span>
|
||||
</div>
|
||||
</div>
|
||||
{reporterOptions.length > 0 && (
|
||||
<select
|
||||
value={selectedReporter}
|
||||
onChange={e => setSelectedReporter(e.target.value)}
|
||||
style={{ height: 34, border: '1px solid var(--line)', borderRadius: 4, padding: '0 10px', fontSize: 13 }}
|
||||
>
|
||||
{reporterOptions.map(r => (
|
||||
<option key={r} value={r}>{r}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 综合评级 + 雷达图 */}
|
||||
<div className="profile-grid-2" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14, marginBottom: 14 }}>
|
||||
{/* 雷达图 */}
|
||||
<section className="panel" style={{ padding: '20px', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12, alignSelf: 'flex-start' }}>
|
||||
<Radar size={18} />
|
||||
<h2 style={{ fontSize: 14, margin: 0 }}>能力雷达图</h2>
|
||||
</div>
|
||||
<div className="profile-radar"><RadarChart dimensions={RADAR_DIMENSIONS} values={capabilityValues} size={300} /></div>
|
||||
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--muted)', textAlign: 'center' }}>
|
||||
{selectedReporter || name || '—'} · {station || '—'}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 综合评级 */}
|
||||
<section className="panel" style={{ padding: '20px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16 }}>
|
||||
<Award size={18} />
|
||||
<h2 style={{ fontSize: 14, margin: 0 }}>综合评级</h2>
|
||||
</div>
|
||||
<div className="profile-grade-row" style={{ display: 'flex', alignItems: 'center', gap: 20, marginBottom: 20 }}>
|
||||
<div style={{
|
||||
width: 80, height: 80, borderRadius: '50%',
|
||||
background: analysis.grade === 'A' ? '#4ba66a' : analysis.grade === 'B' ? '#3b82f6' : analysis.grade === 'C' ? '#f0a020' : '#b42318',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: 'white', fontSize: 36, fontWeight: 'bold', fontFamily: 'Georgia,serif',
|
||||
}}>
|
||||
{analysis.grade}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 28, fontFamily: 'Georgia,serif', fontWeight: 'bold' }}>
|
||||
{analysis.overall}<span style={{ fontSize: 14, color: 'var(--muted)' }}>/100</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--muted)' }}>综合能力得分</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 各维度得分条 */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{RADAR_DIMENSIONS.map(d => {
|
||||
const val = capabilityValues[d.key as keyof typeof capabilityValues]
|
||||
return (
|
||||
<div key={d.key}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, marginBottom: 3 }}>
|
||||
<span>{d.label}</span>
|
||||
<strong style={{ fontFamily: 'Georgia,serif' }}>{val}</strong>
|
||||
</div>
|
||||
<div style={{ height: 6, background: '#eeeae5', borderRadius: 3, overflow: 'hidden' }}>
|
||||
<div style={{
|
||||
height: '100%', width: `${val}%`, background: d.color,
|
||||
borderRadius: 3, transition: 'width 0.4s',
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* 智能分析报告 */}
|
||||
<section className="panel" style={{ padding: '20px', marginBottom: 14 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 14 }}>
|
||||
<Lightbulb size={18} style={{ color: '#f0a020' }} />
|
||||
<h2 style={{ fontSize: 14, margin: 0 }}>智能分析报告</h2>
|
||||
</div>
|
||||
{analysis.insights.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{analysis.insights.map((ins, i) => (
|
||||
<div key={i} style={{
|
||||
display: 'flex', alignItems: 'flex-start', gap: 10,
|
||||
padding: '10px 14px', borderRadius: 6, fontSize: 13,
|
||||
background: ins.type === 'strength' ? '#e8f5ed' : ins.type === 'weakness' ? '#fdeae8' : '#fff8e6',
|
||||
}}>
|
||||
{ins.type === 'strength' && <CheckCircle2 size={16} style={{ color: '#4ba66a', flex: 'none', marginTop: 1 }} />}
|
||||
{ins.type === 'weakness' && <AlertCircle size={16} style={{ color: '#b42318', flex: 'none', marginTop: 1 }} />}
|
||||
{ins.type === 'suggestion' && <Target size={16} style={{ color: '#c47600', flex: 'none', marginTop: 1 }} />}
|
||||
<span>{ins.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="empty" style={{ padding: 30, color: 'var(--muted)', textAlign: 'center' }}>
|
||||
暂无足够数据生成分析报告,请等待更多记录归档后查看。
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 工作类型分布详情 */}
|
||||
<section className="panel" style={{ padding: '20px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 14 }}>
|
||||
<FilePenLine size={18} />
|
||||
<h2 style={{ fontSize: 14, margin: 0 }}>工作类型分布详情</h2>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))', gap: 10 }}>
|
||||
{typeDistribution.map(td => (
|
||||
<div key={td.type} style={{
|
||||
background: 'var(--soft)', borderRadius: 6, padding: '12px 14px',
|
||||
}}>
|
||||
<div style={{ fontSize: 12, color: 'var(--muted)', marginBottom: 4 }}>{td.type}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 6 }}>
|
||||
<strong style={{ fontSize: 20, fontFamily: 'Georgia,serif' }}>{td.count}</strong>
|
||||
<span style={{ fontSize: 11, color: 'var(--muted)' }}>条</span>
|
||||
{td.avgScore != null && (
|
||||
<span style={{ fontSize: 11, color: '#4ba66a', marginLeft: 'auto' }}>
|
||||
均分 {td.avgScore.toFixed(1)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* 迷你进度条 */}
|
||||
<div style={{ height: 4, background: '#e0ddd7', borderRadius: 2, marginTop: 8, overflow: 'hidden' }}>
|
||||
<div style={{
|
||||
height: '100%',
|
||||
width: `${Math.min(td.count * 10, 100)}%`,
|
||||
background: td.count > 0 ? '#b42318' : 'transparent',
|
||||
borderRadius: 2, transition: 'width 0.3s',
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { Clock3, Search, TrendingUp, AlertTriangle, Timer, ClipboardCheck } from 'lucide-react'
|
||||
import { RecordTable } from '../../components/data'
|
||||
import { EmptyState } from '../../components/ui'
|
||||
import { useRole } from '../../context'
|
||||
import type { WorkRecord } from '../../types'
|
||||
|
||||
interface ReviewCenterProps {
|
||||
records: WorkRecord[]
|
||||
onSelect: (r: WorkRecord) => void
|
||||
}
|
||||
|
||||
/** 超时阈值(小时) */
|
||||
const TIMEOUT_HOURS = 48
|
||||
|
||||
/** 计算记录是否超时 */
|
||||
function isOverdue(r: WorkRecord): boolean {
|
||||
if (!r.updatedAt || r.updatedAt === '刚刚') return false
|
||||
const updated = new Date(r.updatedAt)
|
||||
if (isNaN(updated.getTime())) return false
|
||||
const diff = (Date.now() - updated.getTime()) / (1000 * 60 * 60)
|
||||
return diff > TIMEOUT_HOURS
|
||||
}
|
||||
|
||||
/** 计算等待时长描述 */
|
||||
function waitLabel(r: WorkRecord): string {
|
||||
if (!r.updatedAt || r.updatedAt === '刚刚') return '刚刚'
|
||||
const updated = new Date(r.updatedAt)
|
||||
if (isNaN(updated.getTime())) return r.updatedAt
|
||||
const diff = (Date.now() - updated.getTime()) / (1000 * 60 * 60)
|
||||
if (diff < 1) return '刚刚'
|
||||
if (diff < 24) return `${Math.floor(diff)}小时前`
|
||||
return `${Math.floor(diff / 24)}天前`
|
||||
}
|
||||
|
||||
export function ReviewCenter({ records, onSelect }: ReviewCenterProps) {
|
||||
const { role } = useRole()
|
||||
const [query, setQuery] = useState('')
|
||||
const [sortBy, setSortBy] = useState('time')
|
||||
const [showOverdueOnly, setShowOverdueOnly] = useState(false)
|
||||
|
||||
const targetStatus = role === 'station' ? 'station_review' : 'headquarters_review'
|
||||
const waiting = useMemo(() => records.filter(r => r.status === targetStatus), [records, targetStatus])
|
||||
|
||||
const overdueRecords = useMemo(() => waiting.filter(isOverdue), [waiting])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let result = waiting
|
||||
if (showOverdueOnly) result = result.filter(isOverdue)
|
||||
if (query) {
|
||||
const q = query.toLowerCase()
|
||||
result = result.filter(r =>
|
||||
r.title.toLowerCase().includes(q) ||
|
||||
r.reporter.toLowerCase().includes(q) ||
|
||||
r.id.toLowerCase().includes(q)
|
||||
)
|
||||
}
|
||||
if (sortBy === 'type') {
|
||||
result = [...result].sort((a, b) => a.type.localeCompare(b.type))
|
||||
} else {
|
||||
result = [...result].sort((a, b) => {
|
||||
const aOver = isOverdue(a) ? 0 : 1
|
||||
const bOver = isOverdue(b) ? 0 : 1
|
||||
return aOver - bOver
|
||||
})
|
||||
}
|
||||
return result
|
||||
}, [waiting, query, sortBy, showOverdueOnly])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-heading small">
|
||||
<div>
|
||||
<div className="page-heading-icon"><ClipboardCheck size={20} /></div>
|
||||
<div>
|
||||
<h1>审核中心</h1>
|
||||
<span>
|
||||
{role === 'station'
|
||||
? '核验本站记录的真实性、完整性并完成初审。'
|
||||
: '复核分站初审结果,确认考核得分与归档。'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="review-summary">
|
||||
<div>
|
||||
<Clock3 size={21} />
|
||||
<span>待我审核<strong>{waiting.length}</strong></span>
|
||||
</div>
|
||||
<div>
|
||||
<Search size={21} />
|
||||
<span>本月已处理<strong>46</strong></span>
|
||||
</div>
|
||||
<div>
|
||||
<TrendingUp size={21} />
|
||||
<span>平均处理时长<strong>6.2h</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 超时提醒 */}
|
||||
{overdueRecords.length > 0 && (
|
||||
<div className="overdue-banner" style={{
|
||||
background: '#fff4e6', border: '1px solid #f0c060', borderRadius: 6,
|
||||
padding: '12px 16px', marginBottom: 14, display: 'flex', alignItems: 'center', gap: 10,
|
||||
}}>
|
||||
<AlertTriangle size={20} style={{ color: '#c47600', flex: 'none' }} />
|
||||
<div style={{ flex: 1 }}>
|
||||
<strong style={{ fontSize: 13, color: '#8a5a00' }}>
|
||||
{overdueRecords.length} 条记录已超过 {TIMEOUT_HOURS} 小时未处理
|
||||
</strong>
|
||||
<p style={{ fontSize: 11, color: '#a67c3a', margin: '2px 0 0' }}>
|
||||
请尽快处理以避免影响考核周期。点击"仅看超时"可筛选超时记录。
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className={`secondary-button small ${showOverdueOnly ? 'active' : ''}`}
|
||||
onClick={() => setShowOverdueOnly(!showOverdueOnly)}
|
||||
style={showOverdueOnly ? { background: '#c47600', color: 'white', borderColor: '#c47600' } : {}}
|
||||
>
|
||||
<Timer size={14} />
|
||||
{showOverdueOnly ? '显示全部' : '仅看超时'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="panel list-panel">
|
||||
<div className="filters">
|
||||
<div className="search">
|
||||
<Search size={17} />
|
||||
<input
|
||||
placeholder="搜索待审记录"
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<select value={sortBy} onChange={e => setSortBy(e.target.value)}>
|
||||
<option value="time">按提交时间排序</option>
|
||||
<option value="type">按工作类型排序</option>
|
||||
</select>
|
||||
<span className="result-count">{filtered.length} 条待处理</span>
|
||||
</div>
|
||||
|
||||
{filtered.length > 0 ? (
|
||||
<>
|
||||
{/* 超时标记列表 */}
|
||||
<div className="table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>记录信息</th>
|
||||
<th>类型</th>
|
||||
<th>提交人</th>
|
||||
<th>等待时长</th>
|
||||
<th style={{ width: 80 }}>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map(r => {
|
||||
const overdue = isOverdue(r)
|
||||
return (
|
||||
<tr key={r.id} className={overdue ? 'overdue-row' : ''}>
|
||||
<td data-label="记录信息">
|
||||
<div className="record-title">
|
||||
<div>
|
||||
<strong>{r.title}</strong>
|
||||
<small>{r.id}</small>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td data-label="类型"><span className="status muted"><i />{r.type}</span></td>
|
||||
<td data-label="提交人">
|
||||
<strong className="cell-main">{r.reporter}</strong>
|
||||
<small>{r.station}</small>
|
||||
</td>
|
||||
<td data-label="等待时长">
|
||||
{overdue ? (
|
||||
<span style={{ color: '#c47600', fontWeight: 600, display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<AlertTriangle size={13} /> {waitLabel(r)} · 超时
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ color: 'var(--muted)' }}>{waitLabel(r)}</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
className="secondary-button small"
|
||||
onClick={() => onSelect(r)}
|
||||
title="查看详情"
|
||||
>
|
||||
审核 →
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<EmptyState icon={Clock3} text={showOverdueOnly ? '当前没有超时记录' : '当前没有待审核记录'} />
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { Plus, RefreshCw, Play, X, ChevronRight, Calculator, ScrollText } from 'lucide-react'
|
||||
import { useRole } from '../../context'
|
||||
import { api, type Rule, type RuleItem, type ComputeResult } from '../../api'
|
||||
import { StatusBadge } from '../../components/ui/StatusBadge'
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = { draft: '草稿', active: '生效中', archived: '已归档' }
|
||||
const STATUS_TONE: Record<string, string> = { draft: 'muted', active: 'success', archived: 'muted' }
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
quantity: '数量', quality: '质量', efficiency: '时效', compliance: '合规',
|
||||
}
|
||||
|
||||
export function RulesPage() {
|
||||
const { role } = useRole()
|
||||
const [rules, setRules] = useState<Rule[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [editing, setEditing] = useState<Rule | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [activatingId, setActivatingId] = useState<number | null>(null)
|
||||
const [previewRule, setPreviewRule] = useState<Rule | null>(null)
|
||||
const [computeResult, setComputeResult] = useState<ComputeResult | null>(null)
|
||||
const [computeLoading, setComputeLoading] = useState(false)
|
||||
const [selectedPeriod, setSelectedPeriod] = useState('2026-Q3')
|
||||
|
||||
const loadRules = useCallback(async () => {
|
||||
setLoading(true); setError('')
|
||||
try {
|
||||
const data = await api.rules.list(role)
|
||||
setRules(data)
|
||||
} catch (e: any) { setError(e.message) }
|
||||
finally { setLoading(false) }
|
||||
}, [role])
|
||||
|
||||
useEffect(() => { loadRules() }, [loadRules])
|
||||
|
||||
const handleAdd = () => { setEditing(null); setShowModal(true) }
|
||||
const handleEdit = async (r: Rule) => {
|
||||
const full = await api.rules.get(role, r.id)
|
||||
setEditing(full); setShowModal(true)
|
||||
}
|
||||
const handleActivate = async (r: Rule) => {
|
||||
if (!confirm(`确认激活「${r.name}」?激活后同周期的其他规则将自动归档。`)) return
|
||||
setActivatingId(r.id)
|
||||
try {
|
||||
await api.rules.activate(role, r.id)
|
||||
loadRules()
|
||||
} catch (e: any) { alert(e.message) }
|
||||
finally { setActivatingId(null) }
|
||||
}
|
||||
|
||||
const handleSave = async (form: RuleFormData) => {
|
||||
setSaving(true)
|
||||
try {
|
||||
if (editing) {
|
||||
const updated = await api.rules.update(role, editing.id, form)
|
||||
setRules(prev => prev.map(x => x.id === editing.id ? updated : x))
|
||||
} else {
|
||||
const created = await api.rules.create(role, form)
|
||||
setRules(prev => [created, ...prev])
|
||||
}
|
||||
setShowModal(false)
|
||||
} catch (e: any) { alert(e.message) }
|
||||
finally { setSaving(false) }
|
||||
}
|
||||
|
||||
const handlePreview = async (r: Rule) => {
|
||||
setPreviewRule(null); setComputeResult(null)
|
||||
const full = await api.rules.get(role, r.id)
|
||||
setPreviewRule(full)
|
||||
}
|
||||
|
||||
const handleCompute = async () => {
|
||||
if (!previewRule) return
|
||||
setComputeLoading(true)
|
||||
try {
|
||||
const result = await api.scores.compute(role, {
|
||||
rule_id: previewRule.id,
|
||||
period: selectedPeriod,
|
||||
period_type: previewRule.periodType,
|
||||
})
|
||||
setComputeResult(result)
|
||||
} catch (e: any) { alert(e.message) }
|
||||
finally { setComputeLoading(false) }
|
||||
}
|
||||
|
||||
const canManage = role === 'headquarters'
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-heading small">
|
||||
<div>
|
||||
<div className="page-heading-icon"><ScrollText size={20} /></div>
|
||||
<div>
|
||||
<h1>考核规则</h1>
|
||||
<span>配置考核指标、权重和计分公式。规则激活后生效。</span>
|
||||
</div>
|
||||
</div>
|
||||
{canManage && (
|
||||
<button className="primary-button" onClick={handleAdd}>
|
||||
<Plus size={18} /> 新建规则
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<section className="panel list-panel">
|
||||
{error && (
|
||||
<div className="error-banner">
|
||||
{error} <button onClick={loadRules}>重试</button>
|
||||
</div>
|
||||
)}
|
||||
{loading && (
|
||||
<div style={{ textAlign: 'center', padding: 32, color: 'var(--muted)' }}>加载中...</div>
|
||||
)}
|
||||
{!loading && !error && rules.length === 0 && (
|
||||
<div style={{ textAlign: 'center', padding: 32, color: 'var(--muted)' }}>暂无考核规则</div>
|
||||
)}
|
||||
{!loading && !error && rules.length > 0 && (
|
||||
<div className="table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>规则名称</th>
|
||||
<th>考核周期</th>
|
||||
<th>周期范围</th>
|
||||
<th>状态</th>
|
||||
<th>版本</th>
|
||||
<th>创建人</th>
|
||||
<th>创建时间</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rules.map(r => (
|
||||
<tr key={r.id}>
|
||||
<td data-label="规则名称">
|
||||
<strong>{r.name}</strong>
|
||||
{r.description && <small style={{ display: 'block', color: 'var(--muted)' }}>{r.description}</small>}
|
||||
</td>
|
||||
<td data-label="考核周期">{r.periodType === 'quarterly' ? '季度' : '自定义'}</td>
|
||||
<td data-label="周期范围">{r.periodStart || '—'} ~ {r.periodEnd || '—'}</td>
|
||||
<td data-label="状态">
|
||||
<span className={`status ${STATUS_TONE[r.status]}`}><i />{STATUS_LABELS[r.status]}</span>
|
||||
</td>
|
||||
<td data-label="版本">v{r.version}</td>
|
||||
<td data-label="创建人">{r.createdBy}</td>
|
||||
<td data-label="创建时间">{r.createdAt?.slice(0, 10) || '—'}</td>
|
||||
<td>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<button className="secondary-button small" onClick={() => handlePreview(r)} title="预览规则详情">
|
||||
<ChevronRight size={14} />
|
||||
</button>
|
||||
<button className="secondary-button small" onClick={() => handleEdit(r)}>
|
||||
编辑
|
||||
</button>
|
||||
{canManage && r.status === 'draft' && (
|
||||
<button
|
||||
className="primary-button small"
|
||||
onClick={() => handleActivate(r)}
|
||||
disabled={activatingId === r.id}
|
||||
>
|
||||
{activatingId === r.id ? <RefreshCw size={14} className="spin" /> : <Play size={14} />}
|
||||
激活
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{showModal && (
|
||||
<RuleModal
|
||||
rule={editing}
|
||||
role={role}
|
||||
saving={saving}
|
||||
onSave={handleSave}
|
||||
onClose={() => setShowModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{previewRule && (
|
||||
<RulePreviewDrawer
|
||||
rule={previewRule}
|
||||
selectedPeriod={selectedPeriod}
|
||||
onPeriodChange={setSelectedPeriod}
|
||||
computeResult={computeResult}
|
||||
computeLoading={computeLoading}
|
||||
onCompute={handleCompute}
|
||||
onClose={() => setPreviewRule(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type RuleFormData = {
|
||||
name: string
|
||||
description: string
|
||||
period_type: 'quarterly' | 'custom'
|
||||
period_start?: string
|
||||
period_end?: string
|
||||
items: RuleItem[]
|
||||
}
|
||||
|
||||
function RuleModal({
|
||||
rule, role, saving, onSave, onClose,
|
||||
}: {
|
||||
rule: Rule | null
|
||||
role: string
|
||||
saving: boolean
|
||||
onSave: (form: RuleFormData) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [form, setForm] = useState<RuleFormData>({
|
||||
name: rule?.name || '',
|
||||
description: rule?.description || '',
|
||||
period_type: rule?.periodType || 'quarterly',
|
||||
period_start: rule?.periodStart || '',
|
||||
period_end: rule?.periodEnd || '',
|
||||
items: rule?.items || [],
|
||||
})
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!form.name.trim()) { alert('规则名称为必填项'); return }
|
||||
onSave(form)
|
||||
}
|
||||
|
||||
const addItem = () => {
|
||||
setForm(f => ({
|
||||
...f,
|
||||
items: [...f.items, {
|
||||
category: 'quantity',
|
||||
name: '',
|
||||
metric_key: '',
|
||||
weight: 0.1,
|
||||
formulaType: 'count',
|
||||
formulaParams: {},
|
||||
}],
|
||||
}))
|
||||
}
|
||||
|
||||
const updateItem = (idx: number, field: string, value: any) => {
|
||||
setForm(f => ({
|
||||
...f,
|
||||
items: f.items.map((item, i) => i === idx ? { ...item, [field]: value } : item),
|
||||
}))
|
||||
}
|
||||
|
||||
const removeItem = (idx: number) => {
|
||||
setForm(f => ({ ...f, items: f.items.filter((_, i) => i !== idx) }))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-layer" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||
<div className="modal wide">
|
||||
<div className="modal-head">
|
||||
<h2>{rule ? '编辑规则' : '新建规则'}</h2>
|
||||
<button className="icon-button" onClick={onClose}><X size={20} /></button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-grid">
|
||||
<label style={{ gridColumn: 'span 2' }}>
|
||||
规则名称 <span className="required">*</span>
|
||||
<input
|
||||
value={form.name}
|
||||
onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
|
||||
placeholder="如:2026年第三季度考核规则"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
考核周期
|
||||
<select
|
||||
value={form.period_type}
|
||||
onChange={e => setForm(f => ({ ...f, period_type: e.target.value as any }))}
|
||||
>
|
||||
<option value="quarterly">季度</option>
|
||||
<option value="custom">自定义</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
状态
|
||||
<input value={rule ? STATUS_LABELS[rule.status] : '草稿'} disabled />
|
||||
</label>
|
||||
{form.period_type === 'custom' && (
|
||||
<>
|
||||
<label>
|
||||
开始日期
|
||||
<input
|
||||
type="date"
|
||||
value={form.period_start}
|
||||
onChange={e => setForm(f => ({ ...f, period_start: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
结束日期
|
||||
<input
|
||||
type="date"
|
||||
value={form.period_end}
|
||||
onChange={e => setForm(f => ({ ...f, period_end: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
<label style={{ gridColumn: 'span 2' }}>
|
||||
说明
|
||||
<input
|
||||
value={form.description}
|
||||
onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
|
||||
placeholder="简要描述考核范围和目标"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 24, padding: '0 24px 24px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
||||
<h3>指标项</h3>
|
||||
<button type="button" className="secondary-button small" onClick={addItem}>
|
||||
<Plus size={14} /> 添加指标
|
||||
</button>
|
||||
</div>
|
||||
{form.items.length === 0 && (
|
||||
<div style={{ color: 'var(--muted)', textAlign: 'center', padding: 24 }}>暂无指标项,点击添加</div>
|
||||
)}
|
||||
{form.items.map((item, idx) => (
|
||||
<div key={idx} className="rule-item-row">
|
||||
<select value={item.category} onChange={e => updateItem(idx, 'category', e.target.value)}>
|
||||
<option value="quantity">数量</option>
|
||||
<option value="quality">质量</option>
|
||||
<option value="efficiency">时效</option>
|
||||
<option value="compliance">合规</option>
|
||||
</select>
|
||||
<input
|
||||
placeholder="指标名称"
|
||||
value={item.name}
|
||||
onChange={e => updateItem(idx, 'name', e.target.value)}
|
||||
/>
|
||||
<input
|
||||
placeholder="权重(0-1)"
|
||||
type="number"
|
||||
step="0.01"
|
||||
style={{ width: 80 }}
|
||||
value={item.weight}
|
||||
onChange={e => updateItem(idx, 'weight', parseFloat(e.target.value) || 0)}
|
||||
/>
|
||||
<button type="button" className="icon-button" onClick={() => removeItem(idx)}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</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 ? '保存中...' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RulePreviewDrawer({
|
||||
rule, selectedPeriod, onPeriodChange, computeResult, computeLoading, onCompute, onClose,
|
||||
}: {
|
||||
rule: Rule
|
||||
selectedPeriod: string
|
||||
onPeriodChange: (p: string) => void
|
||||
computeResult: ComputeResult | null
|
||||
computeLoading: boolean
|
||||
onCompute: () => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const catStyle = (cat: string) => {
|
||||
const map: Record<string, string> = { quantity: 'info', quality: 'success', efficiency: 'warning', compliance: 'muted' }
|
||||
return map[cat] || 'muted'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-layer" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||
<div className="modal wide" style={{ maxWidth: 640 }}>
|
||||
<div className="modal-head">
|
||||
<h2>{rule.name} — 规则预览</h2>
|
||||
<button className="icon-button" onClick={onClose}><X size={20} /></button>
|
||||
</div>
|
||||
|
||||
{/* 试算工具栏 */}
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'center', padding: '14px 24px', borderBottom: '1px solid var(--line)', background: 'var(--soft)' }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
|
||||
<span style={{ color: 'var(--muted)' }}>试算周期</span>
|
||||
<select value={selectedPeriod} onChange={e => onPeriodChange(e.target.value)} style={{ height: 32, border: '1px solid var(--line)', borderRadius: 4, padding: '0 10px', fontSize: 12, background: 'white' }}>
|
||||
<option value="2026-Q1">2026-Q1</option>
|
||||
<option value="2026-Q2">2026-Q2</option>
|
||||
<option value="2026-Q3">2026-Q3</option>
|
||||
<option value="2026-Q4">2026-Q4</option>
|
||||
</select>
|
||||
</label>
|
||||
<button className="primary-button small" onClick={onCompute} disabled={computeLoading}>
|
||||
{computeLoading ? <RefreshCw size={14} className="spin" /> : <Calculator size={14} />}
|
||||
{computeLoading ? '计算中...' : '试算评分'}
|
||||
</button>
|
||||
<span style={{ fontSize: 11, color: 'var(--muted)', marginLeft: 'auto' }}>规则 ID: {rule.id} · v{rule.version} · {STATUS_LABELS[rule.status]}</span>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '16px 24px', overflowX: 'auto', minWidth: 0 }}>
|
||||
{/* 基本信息 */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '80px 1fr', gap: '6px 16px', fontSize: 13, marginBottom: 20 }}>
|
||||
<span style={{ color: 'var(--muted)' }}>考核周期</span>
|
||||
<span>{rule.periodType === 'quarterly' ? '季度' : '自定义'}</span>
|
||||
<span style={{ color: 'var(--muted)' }}>周期范围</span>
|
||||
<span>{rule.periodStart || '—'} ~ {rule.periodEnd || '—'}</span>
|
||||
<span style={{ color: 'var(--muted)' }}>说明</span>
|
||||
<span>{rule.description || '—'}</span>
|
||||
</div>
|
||||
|
||||
{/* 指标项 */}
|
||||
<h3 style={{ fontSize: 13, margin: '0 0 10px', color: 'var(--ink)' }}>指标项(共 {rule.items?.length || 0} 项)</h3>
|
||||
{(!rule.items || rule.items.length === 0) ? (
|
||||
<div style={{ color: 'var(--muted)', textAlign: 'center', padding: 24, fontSize: 13 }}>暂无指标项</div>
|
||||
) : (
|
||||
<table className="preview-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>类别</th>
|
||||
<th>指标名称</th>
|
||||
<th style={{ textAlign: 'right' }}>权重</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rule.items.map((item, idx) => (
|
||||
<tr key={idx}>
|
||||
<td><span className={`status ${catStyle(item.category)}`}>{CATEGORY_LABELS[item.category] || item.category}</span></td>
|
||||
<td>{item.name}</td>
|
||||
<td style={{ textAlign: 'right', fontFamily: 'Georgia,serif', fontWeight: 600 }}>{(item.weight * 100).toFixed(0)}%</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{/* 试算结果 */}
|
||||
{computeResult && (
|
||||
<>
|
||||
<h3 style={{ fontSize: 13, margin: '20px 0 10px', color: 'var(--ink)' }}>试算结果</h3>
|
||||
<div style={{ fontSize: 12, color: 'var(--muted)', marginBottom: 8 }}>{computeResult.message}</div>
|
||||
{computeResult.results.length === 0 ? (
|
||||
<div style={{ color: 'var(--muted)', textAlign: 'center', padding: 16, fontSize: 13 }}>无评分数据</div>
|
||||
) : (
|
||||
<table className="preview-table">
|
||||
<thead><tr><th>记者</th><th>站点</th><th style={{ textAlign: 'right' }}>总分</th></tr></thead>
|
||||
<tbody>
|
||||
{computeResult.results.map((r, idx) => (
|
||||
<tr key={idx}>
|
||||
<td><strong>{r.reporter}</strong></td>
|
||||
<td>{r.station}</td>
|
||||
<td style={{ textAlign: 'right', fontFamily: 'Georgia,serif', fontWeight: 700, color: 'var(--primary)' }}>{r.totalScore.toFixed(1)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="modal-actions">
|
||||
<button className="secondary-button" onClick={onClose}>关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { Play, X, BarChart3 } from 'lucide-react'
|
||||
import { useRole } from '../../context'
|
||||
import { api, type Score, type ComputeResult } from '../../api'
|
||||
|
||||
const CAT_LABELS: Record<string, string> = {
|
||||
quantity: '数量', quality: '质量', efficiency: '时效', compliance: '合规',
|
||||
}
|
||||
|
||||
export function ScoresPage() {
|
||||
const { role } = useRole()
|
||||
const [scores, setScores] = useState<Score[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [filterPeriod, setFilterPeriod] = useState('')
|
||||
const [filterStation, setFilterStation] = useState('')
|
||||
const [computing, setComputing] = useState(false)
|
||||
const [computeResult, setComputeResult] = useState<ComputeResult | null>(null)
|
||||
const [selectedScore, setSelectedScore] = useState<Score | null>(null)
|
||||
const [activeRules, setActiveRules] = useState<{ id: number; name: string }[]>([])
|
||||
|
||||
const loadScores = useCallback(async () => {
|
||||
setLoading(true); setError('')
|
||||
try {
|
||||
const params: any = {}
|
||||
if (filterPeriod) params.period = filterPeriod
|
||||
if (filterStation) params.station = filterStation
|
||||
const data = await api.scores.list(role, params)
|
||||
setScores(data)
|
||||
} catch (e: any) { setError(e.message) }
|
||||
finally { setLoading(false) }
|
||||
}, [role, filterPeriod, filterStation])
|
||||
|
||||
const loadActiveRules = useCallback(async () => {
|
||||
try {
|
||||
const rules = await api.rules.list(role, { status: 'active' })
|
||||
setActiveRules(rules.map(r => ({ id: r.id, name: r.name })))
|
||||
} catch {}
|
||||
}, [role])
|
||||
|
||||
useEffect(() => { loadScores() }, [loadScores])
|
||||
useEffect(() => { loadActiveRules() }, [loadActiveRules])
|
||||
|
||||
const handleCompute = async () => {
|
||||
if (activeRules.length === 0) { alert('当前无生效中的考核规则'); return }
|
||||
const rule = activeRules[0]
|
||||
if (!confirm(`使用「${rule.name}」触发评分计算?`)) return
|
||||
setComputing(true); setComputeResult(null)
|
||||
try {
|
||||
const result = await api.scores.compute(role, {
|
||||
rule_id: rule.id,
|
||||
period: filterPeriod || '2026-Q3',
|
||||
period_type: 'quarterly',
|
||||
})
|
||||
setComputeResult(result)
|
||||
loadScores()
|
||||
} catch (e: any) { alert(e.message) }
|
||||
finally { setComputing(false) }
|
||||
}
|
||||
|
||||
const avgScore = scores.length > 0
|
||||
? Math.round(scores.reduce((s, r) => s + r.totalScore, 0) / scores.length * 10) / 10
|
||||
: 0
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-heading small">
|
||||
<div>
|
||||
<div className="page-heading-icon"><BarChart3 size={20} /></div>
|
||||
<div>
|
||||
<h1>评分结果</h1>
|
||||
<span>
|
||||
{role === 'station'
|
||||
? '查看本站记者的考核评分明细。'
|
||||
: '查看全国记者考核评分,含各维度加权得分明细。'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{role === 'headquarters' && (
|
||||
<button className="primary-button" onClick={handleCompute} disabled={computing}>
|
||||
{computing ? <span className="spin"><Play size={16} /></span> : <Play size={16} />}
|
||||
{computing ? '计算中...' : '触发评分'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{computeResult && (
|
||||
<div className="result-banner">
|
||||
<strong>{computeResult.message}</strong>
|
||||
<button className="icon-button" onClick={() => setComputeResult(null)}><X size={16} /></button>
|
||||
<div style={{ marginTop: 8, fontSize: 13 }}>
|
||||
{computeResult.results.map((r, i) => (
|
||||
<span key={i} style={{ marginRight: 12 }}>
|
||||
{r.reporter}({r.station})<strong>{r.totalScore}分</strong>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="panel">
|
||||
{!loading && !error && scores.length > 0 && (
|
||||
<div className="metric-cards" style={{ padding: '16px 20px', borderBottom: '1px solid var(--border)' }}>
|
||||
<div className="metric-card" style={{ margin: 0 }}>
|
||||
<span className="metric-label">平均分</span>
|
||||
<span className="metric-value">{avgScore}</span>
|
||||
</div>
|
||||
<div className="metric-card" style={{ margin: 0 }}>
|
||||
<span className="metric-label">参评人数</span>
|
||||
<span className="metric-value">{scores.length}</span>
|
||||
</div>
|
||||
<div className="metric-card" style={{ margin: 0 }}>
|
||||
<span className="metric-label">最高分</span>
|
||||
<span className="metric-value">
|
||||
{Math.max(...scores.map(s => s.totalScore)).toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="metric-card" style={{ margin: 0 }}>
|
||||
<span className="metric-label">最低分</span>
|
||||
<span className="metric-value">
|
||||
{Math.min(...scores.map(s => s.totalScore)).toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="filters">
|
||||
<div className="search">
|
||||
<input
|
||||
placeholder="考核周期,如 2026-Q3"
|
||||
value={filterPeriod}
|
||||
onChange={e => setFilterPeriod(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{role === 'headquarters' && (
|
||||
<div className="search">
|
||||
<input
|
||||
placeholder="站点名称"
|
||||
value={filterStation}
|
||||
onChange={e => setFilterStation(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<span className="result-count">共 {scores.length} 条</span>
|
||||
<button className="secondary-button small" onClick={loadScores}>刷新</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="error-banner">{error} <button onClick={loadScores}>重试</button></div>
|
||||
)}
|
||||
{loading && (
|
||||
<div style={{ textAlign: 'center', padding: 32, color: 'var(--muted)' }}>加载中...</div>
|
||||
)}
|
||||
{!loading && !error && scores.length === 0 && (
|
||||
<div style={{ textAlign: 'center', padding: 32, color: 'var(--muted)' }}>
|
||||
暂无评分数据{role === 'headquarters' ? ',请点击「触发评分」开始计算' : ''}
|
||||
</div>
|
||||
)}
|
||||
{!loading && !error && scores.length > 0 && (
|
||||
<div className="table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>记者</th>
|
||||
<th>站点</th>
|
||||
<th>考核周期</th>
|
||||
<th>总分</th>
|
||||
<th>数量</th>
|
||||
<th>质量</th>
|
||||
<th>时效</th>
|
||||
<th>合规</th>
|
||||
<th>计算时间</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{scores.map(s => (
|
||||
<tr key={s.id}>
|
||||
<td data-label="记者"><strong>{s.reporter}</strong></td>
|
||||
<td data-label="站点">{s.station}</td>
|
||||
<td data-label="考核周期">{s.period}</td>
|
||||
<td data-label="总分"><strong style={{ color: 'var(--primary)' }}>{s.totalScore.toFixed(1)}</strong></td>
|
||||
<td data-label="数量">{s.quantityScore.toFixed(1)}</td>
|
||||
<td data-label="质量">{s.qualityScore.toFixed(1)}</td>
|
||||
<td data-label="时效">{s.efficiencyScore.toFixed(1)}</td>
|
||||
<td data-label="合规">{s.complianceScore.toFixed(1)}</td>
|
||||
<td data-label="计算时间">{s.computedAt?.slice(0, 16) || '—'}</td>
|
||||
<td>
|
||||
<button className="secondary-button small" onClick={() => setSelectedScore(s)}>
|
||||
明细
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{selectedScore && (
|
||||
<ScoreDetailModal score={selectedScore} onClose={() => setSelectedScore(null)} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ScoreDetailModal({ score, onClose }: { score: Score; onClose: () => void }) {
|
||||
return (
|
||||
<div className="modal-layer" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||
<div className="modal wide">
|
||||
<div className="modal-head">
|
||||
<div>
|
||||
<h2 style={{ margin: 0 }}>{score.reporter} — {score.period} 评分明细</h2>
|
||||
</div>
|
||||
<button className="icon-button" onClick={onClose}><X size={18} /></button>
|
||||
</div>
|
||||
<div className="score-summary">
|
||||
<div className="metric-card">
|
||||
<span className="metric-label">总分</span>
|
||||
<span className="metric-value" style={{ color: 'var(--primary)' }}>{score.totalScore.toFixed(1)}</span>
|
||||
</div>
|
||||
{Object.entries({
|
||||
'数量': score.quantityScore,
|
||||
'质量': score.qualityScore,
|
||||
'时效': score.efficiencyScore,
|
||||
'合规': score.complianceScore,
|
||||
}).map(([cat, val]) => (
|
||||
<div className="metric-card" key={cat}>
|
||||
<span className="metric-label">{cat}</span>
|
||||
<span className="metric-value">{val.toFixed(1)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ padding: '0 24px' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>指标名称</th>
|
||||
<th>类别</th>
|
||||
<th>原始值</th>
|
||||
<th>原始得分</th>
|
||||
<th>权重</th>
|
||||
<th>加权得分</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{score.items.map((item, i) => (
|
||||
<tr key={i}>
|
||||
<td>{item.name}</td>
|
||||
<td>{CAT_LABELS[item.category] || item.category}</td>
|
||||
<td>{item.metric_value === null ? '—' : item.metric_value}</td>
|
||||
<td>{item.raw_score.toFixed(1)}</td>
|
||||
<td>{(item.weight * 100).toFixed(0)}%</td>
|
||||
<td><strong>{item.weighted_score.toFixed(2)}</strong></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button className="secondary-button" onClick={onClose}>关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { Settings } from 'lucide-react'
|
||||
import { api, type Person, type MeSummary } from '../../api'
|
||||
import { useRole } from '../../context'
|
||||
|
||||
export function SettingsPage() {
|
||||
const { role, switchRole } = useRole()
|
||||
const [currentPerson, setCurrentPerson] = useState<Person | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
const [meSummary, setMeSummary] = useState<MeSummary | null>(null)
|
||||
const [summaryLoading, setSummaryLoading] = useState(false)
|
||||
|
||||
const currentUserName = role === 'headquarters' ? '林致远'
|
||||
: role === 'station' ? '苏明远' : '林晓'
|
||||
const currentStation = role === 'headquarters' ? '总部' : '北京记者站'
|
||||
|
||||
const loadProfile = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const people = await api.people.list(role)
|
||||
const me = people.find(p => p.name === currentUserName && p.station === currentStation)
|
||||
setCurrentPerson(me || null)
|
||||
} catch {}
|
||||
finally { setLoading(false) }
|
||||
}, [role, currentUserName, currentStation])
|
||||
|
||||
const loadMeSummary = useCallback(async () => {
|
||||
setSummaryLoading(true)
|
||||
try {
|
||||
const data = await api.me.summary(role)
|
||||
setMeSummary(data)
|
||||
} catch {}
|
||||
finally { setSummaryLoading(false) }
|
||||
}, [role])
|
||||
|
||||
useEffect(() => { loadProfile() }, [loadProfile])
|
||||
useEffect(() => { loadMeSummary() }, [loadMeSummary])
|
||||
|
||||
const handleSave = async (form: { phone?: string; title?: string }) => {
|
||||
if (!currentPerson) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const updated = await api.people.update(role, currentPerson.id, form)
|
||||
setCurrentPerson(updated)
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 3000)
|
||||
} catch (e: any) { alert(e.message) }
|
||||
finally { setSaving(false) }
|
||||
}
|
||||
|
||||
const roleLabel = role === 'headquarters' ? '总部管理员' : role === 'station' ? '分站负责人' : '记者'
|
||||
const roleColors: Record<string, string> = {
|
||||
headquarters: 'var(--info)',
|
||||
station: 'var(--warning)',
|
||||
reporter: 'var(--success)',
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-heading small">
|
||||
<div>
|
||||
<div className="page-heading-icon"><Settings size={20} /></div>
|
||||
<div>
|
||||
<h1>个人中心</h1>
|
||||
<span>查看和修改个人资料、账号设置。</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="panel" style={{ padding: 24 }}>
|
||||
<div className="panel-header">
|
||||
<div>
|
||||
<h2>账号信息</h2>
|
||||
<p>查看和修改当前账号的基本信息</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && <div style={{ color: 'var(--muted)', marginTop: 16 }}>加载中...</div>}
|
||||
{!loading && (
|
||||
<div className="profile-content">
|
||||
<div className="profile-avatar">
|
||||
<div className="avatar large">
|
||||
{(currentPerson?.name || currentUserName)[0]}
|
||||
</div>
|
||||
<div className="profile-info">
|
||||
<h3>{currentPerson?.name || currentUserName}</h3>
|
||||
<p style={{ color: roleColors[role], fontSize: 13 }}>{roleLabel}</p>
|
||||
<p style={{ color: 'var(--muted)', fontSize: 13 }}>{currentStation}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table className="profile-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>姓名</th>
|
||||
<td>{currentPerson?.name || currentUserName}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>人员编码</th>
|
||||
<td>{currentPerson?.code || '—'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>所属站点</th>
|
||||
<td>{currentStation}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>职务</th>
|
||||
<td>{currentPerson?.title || '—'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>手机号</th>
|
||||
<td>{currentPerson?.phone || '未设置'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>入站时间</th>
|
||||
<td>{currentPerson?.joinedAt || '—'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>账号状态</th>
|
||||
<td>
|
||||
<span className={`status ${currentPerson?.status === 'active' ? 'success' : 'danger'}`}>
|
||||
<i />
|
||||
{currentPerson?.status === 'active' ? '正常' : '停用'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{currentPerson && (
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<h3 style={{ fontSize: 15, marginBottom: 12 }}>编辑个人资料</h3>
|
||||
<ProfileForm person={currentPerson} saving={saving} saved={saved} onSave={handleSave} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{role !== 'headquarters' && (
|
||||
<div style={{ marginTop: 32, paddingTop: 24, borderTop: '1px solid var(--border)' }}>
|
||||
<h3 style={{ fontSize: 15, marginBottom: 12 }}>考核数据</h3>
|
||||
{summaryLoading && <div style={{ color: 'var(--muted)', padding: '16px 0' }}>加载中...</div>}
|
||||
{!summaryLoading && meSummary && (
|
||||
meSummary.score ? (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))', gap: 12 }}>
|
||||
<ScoreCard label="总分" value={meSummary.score.totalScore.toFixed(1)} />
|
||||
<ScoreCard label="质量分" value={meSummary.score.quality_score.toFixed(1)} />
|
||||
<ScoreCard label="数量分" value={meSummary.score.quantity_score.toFixed(1)} />
|
||||
<ScoreCard label="时效分" value={meSummary.score.efficiency_score.toFixed(1)} />
|
||||
<ScoreCard label="合规分" value={meSummary.score.compliance_score.toFixed(1)} />
|
||||
{meSummary.rank !== null && meSummary.rank > 0 && (
|
||||
<ScoreCard label="排名" value={`${meSummary.rank} / ${meSummary.total}`} highlight />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ color: 'var(--muted)', padding: '16px 0', fontSize: 13 }}>
|
||||
暂无考核数据({meSummary.period})
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{!summaryLoading && !meSummary && (
|
||||
<div style={{ color: 'var(--muted)', padding: '16px 0', fontSize: 13 }}>加载失败</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: 32, paddingTop: 24, borderTop: '1px solid var(--border)' }}>
|
||||
<h3 style={{ fontSize: 15, marginBottom: 12 }}>角色切换(演示用)</h3>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
{(['headquarters', 'station', 'reporter'] as const).map(r => (
|
||||
<button
|
||||
key={r}
|
||||
className={role === r ? 'primary-button' : 'secondary-button'}
|
||||
onClick={() => switchRole(r)}
|
||||
>
|
||||
{r === 'headquarters' ? '总部管理员' : r === 'station' ? '分站负责人' : '记者'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p style={{ color: 'var(--muted)', fontSize: 12, marginTop: 8 }}>
|
||||
当前为演示环境,角色切换即时生效,用于体验不同权限视角。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ProfileForm({
|
||||
person, saving, saved, onSave,
|
||||
}: {
|
||||
person: Person
|
||||
saving: boolean
|
||||
saved: boolean
|
||||
onSave: (f: any) => void
|
||||
}) {
|
||||
const [form, setForm] = useState({
|
||||
phone: person.phone || '',
|
||||
title: person.title || '',
|
||||
})
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
onSave(form)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'flex-end' }}>
|
||||
<label style={{ flex: 1, minWidth: 180 }}>
|
||||
手机号
|
||||
<input
|
||||
value={form.phone}
|
||||
onChange={e => setForm(f => ({ ...f, phone: e.target.value }))}
|
||||
placeholder="输入手机号"
|
||||
/>
|
||||
</label>
|
||||
<label style={{ flex: 1, minWidth: 180 }}>
|
||||
职务
|
||||
<input
|
||||
value={form.title}
|
||||
onChange={e => setForm(f => ({ ...f, title: e.target.value }))}
|
||||
placeholder="输入职务"
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" className="primary-button" disabled={saving}>
|
||||
{saving ? '保存中...' : '保存修改'}
|
||||
</button>
|
||||
{saved && <span style={{ color: 'var(--success)', fontSize: 13, lineHeight: '36px' }}>保存成功</span>}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function ScoreCard({ label, value, highlight }: { label: string; value: string; highlight?: boolean }) {
|
||||
return (
|
||||
<div style={{
|
||||
background: highlight ? 'var(--primary-bg)' : 'var(--surface)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 8,
|
||||
padding: '12px 16px',
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
<div style={{ fontSize: 11, color: 'var(--muted)', marginBottom: 4 }}>{label}</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 600, color: highlight ? 'var(--primary)' : 'var(--text)' }}>{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { MapPin, X, Building2, Users, FilePenLine, TrendingUp } from 'lucide-react'
|
||||
import L from 'leaflet'
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
import { useRole } from '../../context'
|
||||
import { api } from '../../api'
|
||||
import type { Station, StatsRecordRow } from '../../api'
|
||||
|
||||
/** 37 个记者站坐标数据(经纬度) */
|
||||
const STATION_COORDS: Record<string, { lng: number; lat: number }> = {
|
||||
'北京记者站': { lng: 116.4074, lat: 39.9042 },
|
||||
'上海记者站': { lng: 121.4737, lat: 31.2304 },
|
||||
'广东记者站': { lng: 113.2644, lat: 23.1291 },
|
||||
'浙江记者站': { lng: 120.1551, lat: 30.2741 },
|
||||
'江苏记者站': { lng: 118.7969, lat: 32.0603 },
|
||||
'四川记者站': { lng: 104.0668, lat: 30.5728 },
|
||||
'湖北记者站': { lng: 114.3055, lat: 30.5928 },
|
||||
'湖南记者站': { lng: 112.9388, lat: 28.2282 },
|
||||
'山东记者站': { lng: 117.0009, lat: 36.6758 },
|
||||
'河南记者站': { lng: 113.6254, lat: 34.7466 },
|
||||
'河北记者站': { lng: 114.5149, lat: 38.0428 },
|
||||
'福建记者站': { lng: 119.2965, lat: 26.0745 },
|
||||
'安徽记者站': { lng: 117.2849, lat: 31.8612 },
|
||||
'江西记者站': { lng: 115.8581, lat: 28.6832 },
|
||||
'辽宁记者站': { lng: 123.4290, lat: 41.7968 },
|
||||
'吉林记者站': { lng: 125.3245, lat: 43.8868 },
|
||||
'黑龙江记者站': { lng: 126.5340, lat: 45.8038 },
|
||||
'山西记者站': { lng: 112.5489, lat: 37.8706 },
|
||||
'陕西记者站': { lng: 108.9398, lat: 34.3416 },
|
||||
'甘肃记者站': { lng: 103.8343, lat: 36.0611 },
|
||||
'青海记者站': { lng: 101.7782, lat: 36.6171 },
|
||||
'云南记者站': { lng: 102.8329, lat: 24.8801 },
|
||||
'贵州记者站': { lng: 106.7135, lat: 26.5783 },
|
||||
'广西记者站': { lng: 108.3200, lat: 22.8240 },
|
||||
'海南记者站': { lng: 110.3312, lat: 20.0317 },
|
||||
'内蒙古记者站': { lng: 111.7519, lat: 40.8414 },
|
||||
'新疆记者站': { lng: 87.6168, lat: 43.8256 },
|
||||
'西藏记者站': { lng: 91.1322, lat: 29.6604 },
|
||||
'宁夏记者站': { lng: 106.2309, lat: 38.4872 },
|
||||
'重庆记者站': { lng: 106.5516, lat: 29.5630 },
|
||||
'天津记者站': { lng: 117.1901, lat: 39.1252 },
|
||||
'深圳记者站': { lng: 114.0579, lat: 22.5431 },
|
||||
'青岛记者站': { lng: 120.3826, lat: 36.0671 },
|
||||
'大连记者站': { lng: 121.6147, lat: 38.9140 },
|
||||
'厦门记者站': { lng: 118.0894, lat: 24.4798 },
|
||||
'宁波记者站': { lng: 121.5497, lat: 29.8683 },
|
||||
'武汉记者站': { lng: 114.3055, lat: 30.5928 },
|
||||
}
|
||||
|
||||
|
||||
interface StationDetail {
|
||||
station: Station
|
||||
stats?: StatsRecordRow
|
||||
recordCount: number
|
||||
archivedCount: number
|
||||
}
|
||||
|
||||
export function StationMapPage() {
|
||||
const { role } = useRole()
|
||||
const mapContainerRef = useRef<HTMLDivElement>(null)
|
||||
const mapRef = useRef<any>(null)
|
||||
const markersRef = useRef<any[]>([])
|
||||
const [stations, setStations] = useState<Station[]>([])
|
||||
const [statsByStation, setStatsByStation] = useState<Map<string, StatsRecordRow>>(new Map())
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [selectedStation, setSelectedStation] = useState<StationDetail | null>(null)
|
||||
const [mapReady, setMapReady] = useState(false)
|
||||
|
||||
/** 加载站点数据 */
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
setLoading(true)
|
||||
Promise.all([
|
||||
api.stations.list(role).catch(() => [] as Station[]),
|
||||
api.stats.records(role, 'station').catch(() => [] as StatsRecordRow[]),
|
||||
]).then(([stationList, statsRows]) => {
|
||||
if (!active) return
|
||||
setStations(stationList)
|
||||
const statsMap = new Map<string, StatsRecordRow>()
|
||||
statsRows.forEach(row => statsMap.set(row.name, row))
|
||||
setStatsByStation(statsMap)
|
||||
}).finally(() => { if (active) setLoading(false) })
|
||||
return () => { active = false }
|
||||
}, [role])
|
||||
|
||||
/** 初始化 Leaflet 地图(使用高德瓦片) */
|
||||
useEffect(() => {
|
||||
if (!mapContainerRef.current || mapRef.current) return
|
||||
|
||||
const map = L.map(mapContainerRef.current, {
|
||||
center: [36, 105],
|
||||
zoom: 4,
|
||||
zoomControl: true,
|
||||
attributionControl: false,
|
||||
})
|
||||
|
||||
// 高德地图瓦片图层(无需 API Key)
|
||||
L.tileLayer('https://webrd0{s}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x={x}&y={y}&z={z}', {
|
||||
subdomains: ['1', '2', '3', '4'],
|
||||
maxZoom: 18,
|
||||
}).addTo(map)
|
||||
|
||||
mapRef.current = map
|
||||
setMapReady(true)
|
||||
|
||||
return () => {
|
||||
map.remove()
|
||||
mapRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
/** 在地图上添加站点标记 */
|
||||
useEffect(() => {
|
||||
if (!mapReady || !mapRef.current || stations.length === 0) return
|
||||
|
||||
// 清除旧标记
|
||||
markersRef.current.forEach(m => mapRef.current.removeLayer(m))
|
||||
markersRef.current = []
|
||||
|
||||
stations.forEach(station => {
|
||||
const coords = STATION_COORDS[station.name]
|
||||
if (!coords) return
|
||||
|
||||
const stats = statsByStation.get(station.name)
|
||||
const recordCount = stats?.total ?? 0
|
||||
const archivedCount = stats?.archived ?? 0
|
||||
|
||||
// 根据记录数量确定标记颜色
|
||||
const markerColor = recordCount > 150 ? '#b42318' : recordCount > 80 ? '#f0a020' : '#4ba66a'
|
||||
|
||||
// 创建自定义标记图标
|
||||
const icon = L.divIcon({
|
||||
className: 'station-marker',
|
||||
html: `<div style="display:flex;flex-direction:column;align-items:center;cursor:pointer;transition:transform 0.15s">
|
||||
<div style="width:28px;height:28px;border-radius:50%;background:${markerColor};border:2px solid white;box-shadow:0 2px 6px rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;color:white;font-size:11px;font-weight:700">${recordCount}</div>
|
||||
<div style="font-size:11px;color:#333;background:rgba(255,255,255,0.9);padding:1px 6px;border-radius:3px;margin-top:2px;white-space:nowrap;box-shadow:0 1px 3px rgba(0,0,0,0.1)">${station.name.replace('记者站', '')}</div>
|
||||
</div>`,
|
||||
iconSize: [28, 28],
|
||||
iconAnchor: [14, 14],
|
||||
})
|
||||
|
||||
const marker = L.marker([coords.lat, coords.lng], { icon }).addTo(mapRef.current)
|
||||
|
||||
marker.on('click', () => {
|
||||
setSelectedStation({
|
||||
station,
|
||||
stats,
|
||||
recordCount,
|
||||
archivedCount,
|
||||
})
|
||||
})
|
||||
|
||||
markersRef.current.push(marker)
|
||||
})
|
||||
}, [mapReady, stations, statsByStation])
|
||||
|
||||
/** 关闭详情面板 */
|
||||
const closeDetail = useCallback(() => setSelectedStation(null), [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-heading small">
|
||||
<div>
|
||||
<div className="page-heading-icon"><MapPin size={20} /></div>
|
||||
<div>
|
||||
<h1>全国记者站地图</h1>
|
||||
<span>全国 37 个记者站地理分布与运行概况</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="station-map-container" style={{
|
||||
display: 'flex', gap: 14, alignItems: 'flex-start',
|
||||
}}>
|
||||
{/* 地图主体 */}
|
||||
<div className="map-canvas-wrap" style={{
|
||||
flex: 1, position: 'relative', borderRadius: 8,
|
||||
overflow: 'hidden', border: '1px solid var(--line)',
|
||||
minHeight: 520,
|
||||
}}>
|
||||
<div
|
||||
ref={mapContainerRef}
|
||||
style={{ width: '100%', height: '520px' }}
|
||||
/>
|
||||
{loading && (
|
||||
<div style={{
|
||||
position: 'absolute', top: 0, left: 0, right: 0, bottom: 0,
|
||||
display: 'grid', placeItems: 'center', background: 'rgba(255,255,255,0.8)',
|
||||
}}>
|
||||
<span style={{ color: 'var(--muted)', fontSize: 13 }}>加载中…</span>
|
||||
</div>
|
||||
)}
|
||||
{/* 图例 */}
|
||||
<div style={{
|
||||
position: 'absolute', bottom: 12, left: 12,
|
||||
background: 'rgba(255,255,255,0.95)', borderRadius: 6,
|
||||
padding: '8px 12px', fontSize: 11, boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
|
||||
display: 'flex', flexDirection: 'column', gap: 4,
|
||||
}}>
|
||||
<strong style={{ fontSize: 12, marginBottom: 2 }}>记录数量</strong>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span style={{ width: 12, height: 12, borderRadius: '50%', background: '#b42318' }} />
|
||||
<span>150+ 条</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span style={{ width: 12, height: 12, borderRadius: '50%', background: '#f0a020' }} />
|
||||
<span>80-150 条</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span style={{ width: 12, height: 12, borderRadius: '50%', background: '#4ba66a' }} />
|
||||
<span><80 条</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 站点详情侧边面板 */}
|
||||
{selectedStation && (
|
||||
<div className="station-detail-panel" style={{
|
||||
width: 300, background: 'white', border: '1px solid var(--line)',
|
||||
borderRadius: 8, padding: 20, flex: 'none',
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Building2 size={20} />
|
||||
<strong style={{ fontSize: 15 }}>{selectedStation.station.name}</strong>
|
||||
</div>
|
||||
<button className="icon-button" onClick={closeDetail} aria-label="关闭">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<div style={{ display: 'flex', gap: 16, fontSize: 12, color: 'var(--muted)' }}>
|
||||
<span>编码:{selectedStation.station.code}</span>
|
||||
<span>地区:{selectedStation.station.region || '—'}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 16, fontSize: 12, color: 'var(--muted)' }}>
|
||||
<span>负责人:{selectedStation.station.leader || '—'}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 16, fontSize: 12, color: 'var(--muted)' }}>
|
||||
<span>地址:{selectedStation.station.address || '—'}</span>
|
||||
</div>
|
||||
|
||||
<div style={{ borderTop: '1px solid var(--line)', margin: '6px 0', paddingTop: 12 }}>
|
||||
<strong style={{ fontSize: 12, color: 'var(--muted)', display: 'block', marginBottom: 8 }}>运行数据</strong>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
|
||||
<div style={{ background: 'var(--soft)', borderRadius: 6, padding: '10px 12px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, color: 'var(--muted)' }}>
|
||||
<FilePenLine size={13} /> 总记录
|
||||
</div>
|
||||
<strong style={{ fontSize: 20, fontFamily: 'Georgia,serif' }}>{selectedStation.recordCount}</strong>
|
||||
</div>
|
||||
<div style={{ background: 'var(--soft)', borderRadius: 6, padding: '10px 12px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, color: 'var(--muted)' }}>
|
||||
<MapPin size={13} /> 已归档
|
||||
</div>
|
||||
<strong style={{ fontSize: 20, fontFamily: 'Georgia,serif' }}>{selectedStation.archivedCount}</strong>
|
||||
</div>
|
||||
<div style={{ background: 'var(--soft)', borderRadius: 6, padding: '10px 12px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, color: 'var(--muted)' }}>
|
||||
<TrendingUp size={13} /> 平均分
|
||||
</div>
|
||||
<strong style={{ fontSize: 20, fontFamily: 'Georgia,serif' }}>
|
||||
{selectedStation.stats?.avgScore != null ? selectedStation.stats.avgScore.toFixed(1) : '—'}
|
||||
</strong>
|
||||
</div>
|
||||
<div style={{ background: 'var(--soft)', borderRadius: 6, padding: '10px 12px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, color: 'var(--muted)' }}>
|
||||
<Users size={13} /> 退回数
|
||||
</div>
|
||||
<strong style={{ fontSize: 20, fontFamily: 'Georgia,serif' }}>
|
||||
{selectedStation.stats?.returned ?? 0}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
background: selectedStation.station.status === 'active' ? '#e8f5ed' : '#fdeae8',
|
||||
color: selectedStation.station.status === 'active' ? '#2d7a4a' : '#a72d23',
|
||||
borderRadius: 4, padding: '6px 10px', fontSize: 12, fontWeight: 600,
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
{selectedStation.station.status === 'active' ? '● 运行中' : '● 已停用'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 站点列表(地图下方) */}
|
||||
<section className="panel" style={{ marginTop: 14, padding: '16px 20px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||
<MapPin size={18} />
|
||||
<h2 style={{ fontSize: 14, margin: 0 }}>站点列表</h2>
|
||||
<span style={{ fontSize: 11, color: 'var(--muted)' }}>共 {stations.length} 个站点</span>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: 10 }}>
|
||||
{stations.map(s => {
|
||||
const stats = statsByStation.get(s.name)
|
||||
const count = stats?.total ?? 0
|
||||
const color = count > 150 ? '#b42318' : count > 80 ? '#f0a020' : '#4ba66a'
|
||||
return (
|
||||
<div
|
||||
key={s.id}
|
||||
onClick={() => {
|
||||
const coords = STATION_COORDS[s.name]
|
||||
if (coords && mapRef.current) {
|
||||
mapRef.current.setView([coords.lat, coords.lng], 6)
|
||||
}
|
||||
setSelectedStation({
|
||||
station: s,
|
||||
stats,
|
||||
recordCount: count,
|
||||
archivedCount: stats?.archived ?? 0,
|
||||
})
|
||||
}}
|
||||
style={{
|
||||
background: 'var(--soft)', borderRadius: 6, padding: '10px 14px',
|
||||
cursor: 'pointer', transition: '0.12s',
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget as HTMLDivElement).style.boxShadow = '0 2px 8px rgba(0,0,0,0.08)'}
|
||||
onMouseLeave={e => (e.currentTarget as HTMLDivElement).style.boxShadow = 'none'}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<strong style={{ fontSize: 13 }}>{s.name}</strong>
|
||||
<span style={{
|
||||
width: 8, height: 8, borderRadius: '50%', background: color,
|
||||
}} />
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--muted)', marginTop: 4 }}>
|
||||
{s.region || '—'} · {count} 条记录
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { Building2, Eye, Pencil, Plus, Search, Trash2, TrendingUp, Users, X } from 'lucide-react'
|
||||
import { useRole, useToast } from '../../context'
|
||||
import { api, type Station } from '../../api'
|
||||
import { ConfirmDialog } from '../../components/ui/ConfirmDialog'
|
||||
import { StationDrawer } from '../../modals/StationDrawer'
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = { active: '在运', inactive: '停运' }
|
||||
const STATUS_TONE: Record<string, string> = { active: 'success', inactive: 'danger' }
|
||||
|
||||
export function StationsPage() {
|
||||
const { role } = useRole()
|
||||
const { showToast } = useToast()
|
||||
const [stations, setStations] = useState<Station[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [search, setSearch] = useState('')
|
||||
const [filterRegion, setFilterRegion] = useState('')
|
||||
const [filterStatus, setFilterStatus] = useState('')
|
||||
const [totalPeople, setTotalPeople] = useState(0)
|
||||
|
||||
// 详情抽屉
|
||||
const [detailStation, setDetailStation] = useState<Station | null>(null)
|
||||
|
||||
// 编辑弹窗
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [editing, setEditing] = useState<Station | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
// 删除确认
|
||||
const [confirmDelete, setConfirmDelete] = useState<{ station: Station; loading: boolean } | null>(null)
|
||||
|
||||
const regions = [...new Set(stations.map(s => s.region).filter(Boolean))]
|
||||
|
||||
const displayed = stations.filter(s => {
|
||||
if (filterRegion && s.region !== filterRegion) return false
|
||||
if (filterStatus && s.status !== filterStatus) return false
|
||||
if (search) {
|
||||
const q = search.toLowerCase()
|
||||
return s.name.toLowerCase().includes(q) || s.code.toLowerCase().includes(q)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
const loadStations = useCallback(async () => {
|
||||
setLoading(true); setError('')
|
||||
try {
|
||||
const data = await api.stations.list(role, {
|
||||
...(filterStatus ? { status: filterStatus } : {}),
|
||||
...(filterRegion ? { region: filterRegion } : {}),
|
||||
})
|
||||
setStations(data)
|
||||
} catch (e: any) { setError(e.message) }
|
||||
finally { setLoading(false) }
|
||||
}, [role, filterStatus, filterRegion])
|
||||
|
||||
const loadPeopleCount = useCallback(async () => {
|
||||
try {
|
||||
const people = await api.people.list(role)
|
||||
setTotalPeople(people.length)
|
||||
} catch {}
|
||||
}, [role])
|
||||
|
||||
useEffect(() => { loadStations() }, [loadStations])
|
||||
useEffect(() => { loadPeopleCount() }, [loadPeopleCount])
|
||||
|
||||
const handleView = (s: Station) => setDetailStation(s)
|
||||
const handleEdit = (s: Station) => { setEditing(s); setShowModal(true) }
|
||||
const handleAdd = () => { setEditing(null); setShowModal(true) }
|
||||
const handleCloseModal = () => { setShowModal(false); setEditing(null) }
|
||||
|
||||
const handleSave = async (form: {
|
||||
name: string; code: string; region: string; address: string;
|
||||
leader: string; phone: string; establishedAt: string; status: 'active' | 'inactive'
|
||||
}) => {
|
||||
setSaving(true)
|
||||
try {
|
||||
if (editing) {
|
||||
const updated = await api.stations.update(role, editing.id, form)
|
||||
setStations(prev => prev.map(x => x.id === editing.id ? updated : x))
|
||||
} else {
|
||||
const created = await api.stations.create(role, form)
|
||||
setStations(prev => [...prev, created])
|
||||
}
|
||||
handleCloseModal()
|
||||
} catch (e: any) { showToast(e.message) }
|
||||
finally { setSaving(false) }
|
||||
}
|
||||
|
||||
const handleDeleteClick = (s: Station) => setConfirmDelete({ station: s, loading: false })
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!confirmDelete) return
|
||||
setConfirmDelete(prev => prev ? { ...prev, loading: true } : null)
|
||||
try {
|
||||
await api.stations.delete(role, confirmDelete.station.id)
|
||||
setStations(prev => prev.filter(x => x.id !== confirmDelete!.station.id))
|
||||
setConfirmDelete(null)
|
||||
} catch (e: any) { showToast(e.message); setConfirmDelete(null) }
|
||||
}
|
||||
|
||||
const canManage = role === 'headquarters'
|
||||
const totalActive = stations.filter(s => s.status === 'active').length
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-heading small">
|
||||
<div>
|
||||
<div className="page-heading-icon"><Building2 size={20} /></div>
|
||||
<div>
|
||||
<h1>记者站管理</h1>
|
||||
<span>统一维护全国记者站的组织信息与运行状态。</span>
|
||||
</div>
|
||||
</div>
|
||||
{canManage && (
|
||||
<button className="primary-button" onClick={handleAdd}>
|
||||
<Plus size={18} /> 新增站点
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="station-overview">
|
||||
<span><Building2 size={20} /><b>{totalActive}</b>个在运站点</span>
|
||||
<span><Users size={20} /><b>{totalPeople}</b>名在职人员</span>
|
||||
<span><TrendingUp size={20} /><b>{stations.length}</b>个站点</span>
|
||||
</div>
|
||||
|
||||
<section className="panel list-panel">
|
||||
<div className="filters">
|
||||
<div className="search">
|
||||
<Search size={17} />
|
||||
<input
|
||||
placeholder="搜索站点名称或编码"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<select value={filterRegion} onChange={e => setFilterRegion(e.target.value)}>
|
||||
<option value="">全部地区</option>
|
||||
{regions.map(r => <option key={r!} value={r!}>{r}</option>)}
|
||||
</select>
|
||||
<select value={filterStatus} onChange={e => setFilterStatus(e.target.value)}>
|
||||
<option value="">全部状态</option>
|
||||
<option value="active">在运</option>
|
||||
<option value="inactive">停运</option>
|
||||
</select>
|
||||
<span className="result-count">共 {displayed.length} 个站点</span>
|
||||
</div>
|
||||
|
||||
{/* 卡片网格 */}
|
||||
{loading && (
|
||||
<div style={{ textAlign: 'center', padding: 40, color: 'var(--muted)' }}>加载中...</div>
|
||||
)}
|
||||
{error && (
|
||||
<div style={{ textAlign: 'center', padding: 40, color: 'var(--red)' }}>
|
||||
{error} <button onClick={loadStations} style={{ color: 'var(--red)', background: 'none', border: 'none', cursor: 'pointer' }}>重试</button>
|
||||
</div>
|
||||
)}
|
||||
{!loading && !error && (
|
||||
<div className="station-grid">
|
||||
{displayed.length === 0 && (
|
||||
<div style={{ gridColumn: '1/-1', textAlign: 'center', padding: 40, color: 'var(--muted)' }}>
|
||||
暂无站点数据
|
||||
</div>
|
||||
)}
|
||||
{displayed.map(s => (
|
||||
<article className="station-card" key={s.id} onClick={() => handleView(s)}>
|
||||
<div className="station-card-head">
|
||||
<span className="station-symbol"><Building2 size={21} /></span>
|
||||
<span className={`status ${STATUS_TONE[s.status]}`}>
|
||||
<i />{STATUS_LABELS[s.status]}
|
||||
</span>
|
||||
</div>
|
||||
<h3>{s.name}</h3>
|
||||
<p>{s.region || '—'}地区{s.leader ? ` · 负责人 ${s.leader}` : ''}</p>
|
||||
<div className="station-stats">
|
||||
<span><b>{s.phone || '—'}</b>联系电话</span>
|
||||
<span><b>{s.address || '—'}</b>地址</span>
|
||||
</div>
|
||||
{canManage && (
|
||||
<div className="station-actions" onClick={e => e.stopPropagation()}>
|
||||
<button
|
||||
className="icon-button"
|
||||
title="查看详情"
|
||||
onClick={() => handleView(s)}
|
||||
>
|
||||
<Eye size={15} />
|
||||
</button>
|
||||
<button
|
||||
className="icon-button"
|
||||
title="编辑"
|
||||
onClick={() => handleEdit(s)}
|
||||
>
|
||||
<Pencil size={15} />
|
||||
</button>
|
||||
<button
|
||||
className="icon-button danger-icon"
|
||||
title="删除"
|
||||
onClick={() => handleDeleteClick(s)}
|
||||
disabled={confirmDelete?.station.id === s.id}
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 详情抽屉 */}
|
||||
{detailStation && (
|
||||
<StationDrawer
|
||||
station={detailStation}
|
||||
canEdit={canManage}
|
||||
onEdit={(s) => { setDetailStation(null); handleEdit(s) }}
|
||||
onClose={() => setDetailStation(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 编辑/新增弹窗 */}
|
||||
{showModal && (
|
||||
<StationModal
|
||||
station={editing}
|
||||
saving={saving}
|
||||
onSave={handleSave}
|
||||
onClose={handleCloseModal}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 删除确认 */}
|
||||
<ConfirmDialog
|
||||
open={!!confirmDelete}
|
||||
title="确认删除站点"
|
||||
message={`确定要删除「${confirmDelete?.station.name}」吗?删除后该站点下所有人员将受到影响。`}
|
||||
confirmLabel="删除"
|
||||
danger
|
||||
loading={!!confirmDelete?.loading}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
onCancel={() => setConfirmDelete(null)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 编辑/新增表单弹窗 ─────────────────────────────────────────────────────────
|
||||
type StationForm = {
|
||||
name: string; code: string; region: string; address: string;
|
||||
leader: string; phone: string; establishedAt: string; status: 'active' | 'inactive'
|
||||
}
|
||||
|
||||
function StationModal({
|
||||
station, saving, onSave, onClose,
|
||||
}: {
|
||||
station: Station | null
|
||||
saving: boolean
|
||||
onSave: (form: StationForm) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [form, setForm] = useState<StationForm>({
|
||||
name: station?.name || '',
|
||||
code: station?.code || '',
|
||||
region: station?.region || '',
|
||||
address: station?.address || '',
|
||||
leader: station?.leader || '',
|
||||
phone: station?.phone || '',
|
||||
establishedAt: station?.establishedAt || '',
|
||||
status: station?.status || 'active',
|
||||
})
|
||||
|
||||
const [errors, setErrors] = useState<Partial<Record<keyof StationForm, string>>>({})
|
||||
|
||||
const isEdit = !!station
|
||||
|
||||
const validate = (): boolean => {
|
||||
const errs: Partial<Record<keyof StationForm, string>> = {}
|
||||
if (!form.name.trim()) errs.name = '请输入站点名称'
|
||||
if (!form.code.trim()) errs.code = '请输入站点编码'
|
||||
if (form.phone && !/^[\d\-()\s]+$/.test(form.phone))
|
||||
errs.phone = '请输入正确的电话号码'
|
||||
setErrors(errs)
|
||||
return Object.keys(errs).length === 0
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!validate()) return
|
||||
onSave(form)
|
||||
}
|
||||
|
||||
const field = (key: keyof StationForm) => ({
|
||||
value: form[key],
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement | 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 }}>{isEdit ? '编辑站点' : '新增站点'}</h2>
|
||||
{isEdit && <p style={{ margin: '4px 0 0', fontSize: 11, color: 'var(--muted)' }}>{station!.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={errors.name ? 'has-error' : ''}>
|
||||
<span>站点名称 <b>*</b></span>
|
||||
<input {...field('name')} placeholder="如:北京记者站" disabled={isEdit} />
|
||||
{errors.name && <small className="field-error">{errors.name}</small>}
|
||||
</label>
|
||||
|
||||
<label className={errors.code ? 'has-error' : ''}>
|
||||
<span>站点编码 <b>*</b></span>
|
||||
<input {...field('code')} placeholder="如:STATION_BJ" disabled={isEdit} />
|
||||
{errors.code && <small className="field-error">{errors.code}</small>}
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>所属地区</span>
|
||||
<input {...field('region')} placeholder="如:华北、华东、华南" />
|
||||
</label>
|
||||
|
||||
<label className={errors.phone ? 'has-error' : ''}>
|
||||
<span>联系电话</span>
|
||||
<input {...field('phone')} placeholder="如:010-12345678" type="tel" />
|
||||
{errors.phone && <small className="field-error">{errors.phone}</small>}
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>负责人</span>
|
||||
<input {...field('leader')} placeholder="输入负责人姓名" />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>建站时间</span>
|
||||
<input {...field('establishedAt')} type="date" />
|
||||
</label>
|
||||
|
||||
<label style={{ gridColumn: '1 / -1' }}>
|
||||
<span>站点地址</span>
|
||||
<input {...field('address')} placeholder="详细地址" />
|
||||
</label>
|
||||
|
||||
{isEdit && (
|
||||
<label>
|
||||
<span>运行状态</span>
|
||||
<select {...field('status')}>
|
||||
<option value="active">在运</option>
|
||||
<option value="inactive">停运</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 ? '保存中...' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { ScrollText, Loader2, Filter } from 'lucide-react'
|
||||
import { useRole } from '../../context'
|
||||
import { api } from '../../api'
|
||||
import { EmptyState } from '../../components/ui/EmptyState'
|
||||
import type { SystemLog } from '../../types'
|
||||
|
||||
const moduleLabels: Record<string, string> = {
|
||||
auth: '认证', record: '工作记录', people: '人员管理', stations: '站点管理',
|
||||
notices: '通知公告', appeals: '申诉复议', export: '数据导出',
|
||||
}
|
||||
|
||||
const actionLabels: Record<string, string> = {
|
||||
login: '登录', logout: '退出', create: '创建', delete: '删除', update: '更新',
|
||||
transfer: '调站', withdraw: '撤回', save_draft: '保存草稿',
|
||||
upload_attachment: '上传附件', records: '导出记录', people: '导出人员', scores: '导出评分',
|
||||
pass: '审核通过', return: '审核退回', uphold: '申诉驳回', overturn: '申诉通过',
|
||||
}
|
||||
|
||||
/**
|
||||
* 系统操作日志页面 — 仅总部管理员可查看
|
||||
*/
|
||||
export function SystemLogsPage() {
|
||||
const { role } = useRole()
|
||||
const [logs, setLogs] = useState<SystemLog[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [filterModule, setFilterModule] = useState('')
|
||||
const [filterActor, setFilterActor] = useState('')
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await api.systemLogs(role, {
|
||||
module: filterModule || undefined,
|
||||
actor: filterActor || undefined,
|
||||
pageSize: 100,
|
||||
})
|
||||
setLogs(data)
|
||||
} catch {
|
||||
setLogs([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [role, filterModule, filterActor])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
return (
|
||||
<div className="page-content">
|
||||
<div className="page-heading small">
|
||||
<div>
|
||||
<div className="page-heading-icon"><ScrollText size={20} /></div>
|
||||
<div>
|
||||
<h1>操作日志</h1>
|
||||
<span>系统所有关键操作的审计记录</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="filter-bar">
|
||||
<Filter size={15} />
|
||||
<select value={filterModule} onChange={e => setFilterModule(e.target.value)}>
|
||||
<option value="">全部模块</option>
|
||||
{Object.entries(moduleLabels).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v}</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="操作人搜索"
|
||||
value={filterActor}
|
||||
onChange={e => setFilterActor(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="loading-center"><Loader2 size={24} className="spin" /></div>
|
||||
) : logs.length === 0 ? (
|
||||
<EmptyState icon={ScrollText} text="暂无操作日志" />
|
||||
) : (
|
||||
<div className="table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>操作人</th>
|
||||
<th>角色</th>
|
||||
<th>模块</th>
|
||||
<th>操作</th>
|
||||
<th>目标</th>
|
||||
<th>详情</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{logs.map(log => (
|
||||
<tr key={log.id}>
|
||||
<td data-label="时间"><span className="cell-main">{log.createdAt}</span></td>
|
||||
<td data-label="操作人"><strong>{log.actorName}</strong></td>
|
||||
<td data-label="角色">{log.actorRole}</td>
|
||||
<td data-label="模块">{moduleLabels[log.module] || log.module}</td>
|
||||
<td data-label="操作">{actionLabels[log.action] || log.action}</td>
|
||||
<td data-label="目标">{log.targetType ? `${log.targetType}#${log.targetId}` : '—'}</td>
|
||||
<td data-label="详情"><small>{log.detail || '—'}</small></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useState } from 'react'
|
||||
import { ChevronDown, Plus, Search, FilePenLine } from 'lucide-react'
|
||||
import { RecordTable } from '../../components/data'
|
||||
import { useRole } from '../../context'
|
||||
import type { WorkRecord } from '../../types'
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
draft: '草稿', station_review: '待分站审核',
|
||||
headquarters_review: '待总部复核', returned: '已退回', archived: '已归档',
|
||||
}
|
||||
|
||||
interface WorkListProps {
|
||||
records: WorkRecord[]
|
||||
onCreate: () => void
|
||||
onSelect: (r: WorkRecord) => void
|
||||
}
|
||||
|
||||
export function WorkList({ records, onCreate, onSelect }: WorkListProps) {
|
||||
const { role } = useRole()
|
||||
const [query, setQuery] = useState('')
|
||||
const [status, setStatus] = useState('all')
|
||||
|
||||
const filtered = records.filter(r =>
|
||||
(r.title.includes(query) || r.reporter.includes(query)) &&
|
||||
(status === 'all' || r.status === status)
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-heading small">
|
||||
<div>
|
||||
<div className="page-heading-icon"><FilePenLine size={20} /></div>
|
||||
<div>
|
||||
<h1>工作记录</h1>
|
||||
<span>{role === 'reporter' ? '管理你的填报记录和审核进度。' : '查询权限范围内的填报、审核与归档记录。'}</span>
|
||||
</div>
|
||||
</div>
|
||||
{role !== 'headquarters' && (
|
||||
<button className="primary-button" onClick={onCreate}>
|
||||
<Plus size={18} />
|
||||
新建记录
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<section className="panel list-panel">
|
||||
<div className="filters">
|
||||
<div className="search">
|
||||
<Search size={17} />
|
||||
<input
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
placeholder="搜索标题或记者"
|
||||
/>
|
||||
</div>
|
||||
<select value={status} onChange={e => setStatus(e.target.value)}>
|
||||
<option value="all">全部状态</option>
|
||||
{Object.entries(statusLabels).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v}</option>
|
||||
))}
|
||||
</select>
|
||||
<button className="secondary-button">
|
||||
更多筛选 <ChevronDown size={15} />
|
||||
</button>
|
||||
<span className="result-count">共 {filtered.length} 条</span>
|
||||
</div>
|
||||
<RecordTable records={filtered} onSelect={onSelect} />
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export { Dashboard } from './Dashboard'
|
||||
export { WorkList } from './WorkList'
|
||||
export { ReviewCenter } from './ReviewCenter'
|
||||
export { PeoplePage } from './People'
|
||||
export { StationsPage } from './Stations'
|
||||
export { ArchivePage } from './Archive'
|
||||
export { NoticesPage } from './Notices'
|
||||
export { SettingsPage } from './Settings'
|
||||
export { RulesPage } from './Rules'
|
||||
export { ScoresPage } from './Scores'
|
||||
export { LeaderboardPage } from './Leaderboard'
|
||||
@@ -0,0 +1,41 @@
|
||||
// 路由 Page key(替代 types.ts 中的 Page)
|
||||
export type PageKey = 'dashboard' | 'work' | 'review' | 'people' | 'stations'
|
||||
| 'archive' | 'notices' | 'settings' | 'rules' | 'scores' | 'leaderboard'
|
||||
| 'appeals' | 'logs' | 'stationmap' | 'cockpit' | 'profile'
|
||||
|
||||
import {
|
||||
LayoutDashboard, FilePenLine, ClipboardCheck, Users, Building2,
|
||||
Archive, Bell, Settings, ScrollText, BarChart3, Trophy, Gavel,
|
||||
FileText, MapPin, Gauge, UserCircle,
|
||||
} from 'lucide-react'
|
||||
|
||||
export const pageIcons: Record<PageKey, typeof LayoutDashboard> = {
|
||||
dashboard: LayoutDashboard, work: FilePenLine, review: ClipboardCheck,
|
||||
people: Users, stations: Building2, archive: Archive,
|
||||
notices: Bell, settings: Settings, rules: ScrollText,
|
||||
scores: BarChart3, leaderboard: Trophy, appeals: Gavel,
|
||||
logs: FileText, stationmap: MapPin, cockpit: Gauge, profile: UserCircle,
|
||||
}
|
||||
|
||||
export const pathMap: Record<PageKey, string> = {
|
||||
dashboard: '/', work: '/work', review: '/review',
|
||||
people: '/people', stations: '/stations', archive: '/archive',
|
||||
notices: '/notices', settings: '/settings',
|
||||
rules: '/rules', scores: '/scores', leaderboard: '/leaderboard',
|
||||
appeals: '/appeals', logs: '/logs',
|
||||
stationmap: '/stationmap', cockpit: '/cockpit', profile: '/profile',
|
||||
}
|
||||
|
||||
export const navLabels: Record<PageKey, string> = {
|
||||
dashboard: '工作台', work: '工作记录', review: '审核中心',
|
||||
people: '人员管理', stations: '记者站管理', archive: '电子档案',
|
||||
notices: '通知公告', settings: '系统设置',
|
||||
rules: '考核规则', scores: '评分结果', leaderboard: '积分排行',
|
||||
appeals: '申诉复议', logs: '操作日志',
|
||||
stationmap: '全国地图', cockpit: '管理驾驶舱', profile: '能力画像',
|
||||
}
|
||||
|
||||
export function pathToPage(pathname: string): PageKey {
|
||||
const entry = Object.entries(pathMap).find(([, p]) => p === pathname)
|
||||
return (entry ? entry[0] : 'dashboard') as PageKey
|
||||
}
|
||||
+380
File diff suppressed because one or more lines are too long
+100
@@ -0,0 +1,100 @@
|
||||
export type Role = 'headquarters' | 'station' | 'reporter'
|
||||
|
||||
export type WorkStatus =
|
||||
| 'draft'
|
||||
| 'station_review'
|
||||
| 'headquarters_review'
|
||||
| 'returned'
|
||||
| 'archived'
|
||||
|
||||
export type WorkType = '文字稿件' | '视频供稿' | '图片供稿' | '重要报道' | '培训参与' | '临时工作'
|
||||
|
||||
/** 附件信息 */
|
||||
export interface Attachment {
|
||||
name: string
|
||||
url: string
|
||||
size?: number
|
||||
type?: string
|
||||
}
|
||||
|
||||
export interface WorkRecord {
|
||||
id: string
|
||||
title: string
|
||||
type: WorkType
|
||||
reporter: string
|
||||
station: string
|
||||
date: string
|
||||
platform: string
|
||||
status: WorkStatus
|
||||
score?: number
|
||||
description?: string
|
||||
reviewNote?: string
|
||||
attachments?: Attachment[] | string | null
|
||||
createdAt?: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
/** 申诉状态 */
|
||||
export type AppealStatus = 'pending' | 'accepted' | 'rejected'
|
||||
|
||||
/** 申诉记录 */
|
||||
export interface Appeal {
|
||||
id: number
|
||||
code: string
|
||||
recordId: string
|
||||
appellant: string
|
||||
station: string
|
||||
reason: string
|
||||
status: AppealStatus
|
||||
handler: string | null
|
||||
handlerRole: string | null
|
||||
response: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
handledAt: string | null
|
||||
}
|
||||
|
||||
/** 人员调站记录 */
|
||||
export interface PersonTransfer {
|
||||
id: number
|
||||
fromStation: string
|
||||
toStation: string
|
||||
reason: string | null
|
||||
operatedBy: string
|
||||
transferredAt: string
|
||||
}
|
||||
|
||||
/** 系统操作日志 */
|
||||
export interface SystemLog {
|
||||
id: number
|
||||
actorRole: string
|
||||
actorName: string
|
||||
module: string
|
||||
action: string
|
||||
targetType: string | null
|
||||
targetId: string
|
||||
detail: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
/** 登录响应 */
|
||||
export interface LoginResponse {
|
||||
token: string
|
||||
role: Role
|
||||
name: string
|
||||
station: string | null
|
||||
code: string
|
||||
}
|
||||
|
||||
/** 版本历史记录 */
|
||||
export interface RecordVersion {
|
||||
id: number
|
||||
versionNo: number
|
||||
title: string
|
||||
type: string
|
||||
platform: string
|
||||
description: string | null
|
||||
attachments: string | null
|
||||
editedBy: string
|
||||
editedAt: string
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user