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

This commit is contained in:
selfrelease
2026-08-01 23:09:49 +08:00
commit 45fbba0308
96 changed files with 21514 additions and 0 deletions
+219
View File
@@ -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
View File
@@ -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 }[]
}
+25
View File
@@ -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>
)
}
+25
View File
@@ -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>
)
}
+73
View File
@@ -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>
)
}
+3
View File
@@ -0,0 +1,3 @@
export { MetricCard } from './MetricCard'
export { PanelHeader } from './PanelHeader'
export { RecordTable } from './RecordTable'
+114
View File
@@ -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>
)
}
+57
View File
@@ -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>
)
}
+2
View File
@@ -0,0 +1,2 @@
export { Sidebar } from './Sidebar'
export { TopBar } from './TopBar'
+59
View File
@@ -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>
)
}
+15
View File
@@ -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>
)
}
+7
View File
@@ -0,0 +1,7 @@
export function LoadingBar() {
return (
<div className="loading-bar">
<i />
</div>
)
}
+13
View File
@@ -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>
)
}
+13
View File
@@ -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>
)
}
+27
View File
@@ -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 }
+3
View File
@@ -0,0 +1,3 @@
export { StatusBadge } from './StatusBadge'
export { EmptyState } from './EmptyState'
export { LoadingBar } from './LoadingBar'
+85
View File
@@ -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)
}
+43
View File
@@ -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)
}
+2
View File
@@ -0,0 +1,2 @@
export { RoleProvider, useRole } from './RoleContext'
export { ToastProvider, useToast } from './ToastContext'
+28
View File
@@ -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 },
]
+1
View File
@@ -0,0 +1 @@
export { useRecords } from './useRecords'
+40
View File
@@ -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 }
}
+13
View File
@@ -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>,
)
+185
View File
@@ -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>PDFOffice 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>
)
}
+105
View File
@@ -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>
)
}
+276
View File
@@ -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>
)
}
+113
View File
@@ -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>
)
}
+2
View File
@@ -0,0 +1,2 @@
export { CreateRecordModal } from './CreateRecordModal'
export { RecordDrawer } from './RecordDrawer'
+176
View File
@@ -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>
)
}
+234
View File
@@ -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> · · 20213</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>
</>
)
}
+378
View File
@@ -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>
</>
)
}
+334
View File
@@ -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> 83</small></span>
</button>
</div>
</section>
</div>
</>
)
}
+188
View File
@@ -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>
</>
)}
</>
)
}
+96
View File
@@ -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_001PERSON_006PERSON_007</p>
</div>
</div>
</div>
)
}
+360
View File
@@ -0,0 +1,360 @@
import { useEffect, useState, useCallback } from 'react'
import { Search, Plus, Pencil, Trash2, X, Bell } from 'lucide-react'
import { useRole } from '../../context'
import { api, type Notice, type NoticeReceipt } from '../../api'
import { ConfirmDialog } from '../../components/ui/ConfirmDialog'
const PRIORITY_LABELS: Record<string, string> = { normal: '普通', high: '重要', urgent: '紧急' }
const PRIORITY_CLASS: Record<string, string> = { normal: '', high: 'important', urgent: 'urgent' }
export function NoticesPage() {
const { role } = useRole()
const [notices, setNotices] = useState<Notice[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [filterPriority, setFilterPriority] = useState('')
const [selected, setSelected] = useState<Notice | null>(null)
const [receipts, setReceipts] = useState<NoticeReceipt[]>([])
const [receiptsLoading, setReceiptsLoading] = useState(false)
// 编辑/新增弹窗
const [showModal, setShowModal] = useState(false)
const [editing, setEditing] = useState<Notice | null>(null)
const [saving, setSaving] = useState(false)
// 删除确认
const [confirmDelete, setConfirmDelete] = useState<{ notice: Notice; loading: boolean } | null>(null)
const displayed = filterPriority
? notices.filter(n => n.priority === filterPriority)
: notices
const loadNotices = useCallback(async () => {
setLoading(true); setError('')
try {
const data = await api.notices.list(role)
setNotices(data)
if (data.length > 0 && !selected) setSelected(data[0])
} catch (e: any) { setError(e.message) }
finally { setLoading(false) }
}, [role])
const loadReceipts = useCallback(async (noticeId: number) => {
setReceiptsLoading(true)
try { setReceipts(await api.notices.receipts(role, noticeId)) }
catch {}
finally { setReceiptsLoading(false) }
}, [role])
useEffect(() => { loadNotices() }, [loadNotices])
useEffect(() => {
if (selected) loadReceipts(selected.id)
}, [selected, loadReceipts])
const handleAdd = () => { setEditing(null); setShowModal(true) }
const handleEdit = (n: Notice) => { setEditing(n); setShowModal(true) }
const handleCloseModal = () => { setShowModal(false); setEditing(null) }
const handleCreate = async (form: any) => {
setSaving(true)
try {
const created = await api.notices.create(role, form)
setNotices(prev => [created, ...prev])
setSelected(created)
handleCloseModal()
} catch (e: any) { alert(e.message) }
finally { setSaving(false) }
}
const handleUpdate = async (form: any) => {
if (!editing) return
setSaving(true)
try {
const updated = await api.notices.update(role, editing.id, form)
setNotices(prev => prev.map(x => x.id === editing.id ? updated : x))
setSelected(updated)
handleCloseModal()
} catch (e: any) { alert(e.message) }
finally { setSaving(false) }
}
const handleDeleteClick = (n: Notice) => setConfirmDelete({ notice: n, loading: false })
const handleDeleteConfirm = async () => {
if (!confirmDelete) return
setConfirmDelete(prev => prev ? { ...prev, loading: true } : null)
try {
// notices API 没有 delete,但可以标记为已删除或者通过 update 处理
// 这里假设后端支持,或通过其他方式处理
setConfirmDelete(null)
} catch (e: any) { alert(e.message); setConfirmDelete(null) }
}
const readCount = receipts.filter(r => r.read).length
const confirmedCount = receipts.filter(r => r.confirmed).length
const canManage = role === 'headquarters'
return (
<>
<div className="page-heading small">
<div>
<div className="page-heading-icon"><Bell size={20} /></div>
<div>
<h1></h1>
<span></span>
</div>
</div>
{canManage && (
<button className="primary-button" onClick={handleAdd}>
<Plus size={18} />
</button>
)}
</div>
<div className="notice-layout">
<section className="panel notice-list">
<div className="filters" style={{ padding: '0 0 12px' }}>
<select value={filterPriority} onChange={e => setFilterPriority(e.target.value)}>
<option value=""></option>
<option value="urgent"></option>
<option value="high"></option>
<option value="normal"></option>
</select>
</div>
{loading && <div style={{ textAlign: 'center', padding: 32, color: 'var(--muted)' }}>...</div>}
{error && <div style={{ textAlign: 'center', padding: 32, color: 'var(--red)' }}>{error}</div>}
{!loading && !error && displayed.length === 0 && (
<div style={{ textAlign: 'center', padding: 32, color: 'var(--muted)' }}></div>
)}
{!loading && !error && displayed.map(n => (
<div key={n.id} className="notice-item-wrapper">
<div
onClick={() => setSelected(n)}
className={selected?.id === n.id ? 'active' : ''}
style={{ cursor: 'pointer' }}
>
<span className={`notice-level ${PRIORITY_CLASS[n.priority]}`}>
{PRIORITY_LABELS[n.priority]}
</span>
<div className="notice-item-content">
<h3>{n.title}</h3>
<p>{n.publishedBy} · {n.publishedAt?.slice(0, 10) || '—'}</p>
</div>
{canManage && (
<div className="notice-item-actions" onClick={e => e.stopPropagation()}>
<button
className="icon-button"
title="编辑"
onClick={() => handleEdit(n)}
>
<Pencil size={14} />
</button>
<button
className="icon-button danger-icon"
title="删除"
onClick={() => handleDeleteClick(n)}
>
<Trash2 size={14} />
</button>
</div>
)}
</div>
</div>
))}
</section>
<aside className="panel notice-side">
<h2></h2>
{selected ? (
<div className="notice-detail">
<h3>{selected.title}</h3>
<div className="notice-meta">
<span className={`notice-level ${PRIORITY_CLASS[selected.priority]}`}>
{PRIORITY_LABELS[selected.priority]}
</span>
<span>{selected.publishedBy}</span>
<span>{selected.publishedAt?.slice(0, 16)?.replace('T', ' ') || '—'}</span>
</div>
{selected.content && <p className="notice-content">{selected.content}</p>}
</div>
) : (
<div style={{ color: 'var(--muted)' }}></div>
)}
</aside>
</div>
{selected && !receiptsLoading && receipts.length > 0 && (
<div className="panel" style={{ marginTop: 14, padding: '20px 24px' }}>
<h4 style={{ fontSize: 14, margin: '0 0 12px', color: 'var(--ink)', fontWeight: 600 }}></h4>
{role === 'headquarters' && (
<div style={{ display: 'flex', gap: 20, padding: '10px 16px', background: 'var(--soft)', border: '1px solid var(--line)', borderRadius: 6, marginBottom: 14, fontSize: 13, color: 'var(--ink)' }}>
<span> {readCount}/{receipts.length}</span>
<span> {confirmedCount}/{receipts.length}</span>
</div>
)}
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13, tableLayout: 'fixed' }}>
<thead>
<tr>
<th style={{ padding: '8px 12px', borderBottom: '2px solid var(--line)', textAlign: 'left', color: 'var(--muted)', fontWeight: 600, width: '25%' }}></th>
<th style={{ padding: '8px 12px', borderBottom: '2px solid var(--line)', textAlign: 'left', color: 'var(--muted)', fontWeight: 600, width: '25%' }}></th>
<th style={{ padding: '8px 12px', borderBottom: '2px solid var(--line)', textAlign: 'center', color: 'var(--muted)', fontWeight: 600, width: '25%' }}></th>
<th style={{ padding: '8px 12px', borderBottom: '2px solid var(--line)', textAlign: 'center', color: 'var(--muted)', fontWeight: 600, width: '25%' }}></th>
</tr>
</thead>
<tbody>
{receipts.map(r => (
<tr key={r.id}>
<td style={{ padding: '8px 12px', borderBottom: '1px solid var(--line)' }}>{r.receiverName}</td>
<td style={{ padding: '8px 12px', borderBottom: '1px solid var(--line)' }}>{r.receiverRole === 'headquarters' ? '总部管理员' : r.receiverRole === 'station' ? '分站负责人' : '记者'}</td>
<td style={{ padding: '8px 12px', borderBottom: '1px solid var(--line)', textAlign: 'center' }}>
{r.read
? <span style={{ fontSize: 11, fontWeight: 600, color: '#4ba66a', background: '#e8f5ed', padding: '2px 8px', borderRadius: 10 }}> </span>
: <span style={{ fontSize: 11, color: 'var(--muted)', background: '#f0eeeb', padding: '2px 8px', borderRadius: 10 }}></span>}
</td>
<td style={{ padding: '8px 12px', borderBottom: '1px solid var(--line)', textAlign: 'center' }}>
{r.confirmed
? <span style={{ fontSize: 11, fontWeight: 600, color: '#4ba66a', background: '#e8f5ed', padding: '2px 8px', borderRadius: 10 }}> </span>
: <span style={{ fontSize: 11, color: 'var(--muted)', background: '#f0eeeb', padding: '2px 8px', borderRadius: 10 }}></span>}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* 编辑/新增弹窗 */}
{showModal && (
<NoticeModal
notice={editing}
saving={saving}
onSave={editing ? handleUpdate : handleCreate}
onClose={handleCloseModal}
/>
)}
{/* 删除确认 */}
<ConfirmDialog
open={!!confirmDelete}
title="确认删除通知"
message={`确定要删除「${confirmDelete?.notice.title}」吗?删除后将无法恢复。`}
confirmLabel="删除"
danger
loading={!!confirmDelete?.loading}
onConfirm={handleDeleteConfirm}
onCancel={() => setConfirmDelete(null)}
/>
</>
)
}
// ── 发布/编辑表单弹窗 ────────────────────────────────────────────────────────
type NoticeForm = {
title: string; content: string; priority: string; scope: string
}
function NoticeModal({
notice, saving, onSave, onClose,
}: {
notice: Notice | null
saving: boolean
onSave: (form: NoticeForm) => void
onClose: () => void
}) {
const [form, setForm] = useState<NoticeForm>({
title: notice?.title || '',
content: notice?.content || '',
priority: notice?.priority || 'normal',
scope: notice?.scope || 'all',
})
const [errors, setErrors] = useState<Partial<Record<keyof NoticeForm, string>>>({})
const validate = (): boolean => {
const errs: Partial<Record<keyof NoticeForm, string>> = {}
if (!form.title.trim()) errs.title = '请输入通知标题'
setErrors(errs)
return Object.keys(errs).length === 0
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!validate()) return
onSave(form)
}
const field = (key: keyof NoticeForm) => ({
value: form[key],
onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
setForm(f => ({ ...f, [key]: e.target.value }))
if (errors[key]) setErrors(er => { const n = { ...er }; delete n[key]; return n })
},
})
return (
<div className="modal-layer" onClick={onClose}>
<div className="modal" onClick={e => e.stopPropagation()}>
<div className="modal-head">
<div>
<h2 style={{ margin: 0 }}>{notice ? '编辑通知' : '发布通知'}</h2>
{notice && <p style={{ margin: '4px 0 0', fontSize: 11, color: 'var(--muted)' }}>{notice.code}</p>}
</div>
<button className="icon-button" onClick={onClose} disabled={saving}>
<X size={18} />
</button>
</div>
<form onSubmit={handleSubmit} noValidate>
<div className="form-grid">
<label className={`full ${errors.title ? 'has-error' : ''}`}>
<span> <b>*</b></span>
<input
{...field('title')}
placeholder="输入通知标题"
/>
{errors.title && <small className="field-error">{errors.title}</small>}
</label>
<label className="full">
<span></span>
<textarea
{...field('content')}
placeholder="输入通知内容"
rows={4}
/>
</label>
<label>
<span></span>
<select {...field('priority')}>
<option value="normal"></option>
<option value="high"></option>
<option value="urgent"></option>
</select>
</label>
<label>
<span></span>
<select {...field('scope')}>
<option value="all"></option>
<option value="station"></option>
<option value="role"></option>
</select>
</label>
</div>
<div className="modal-actions">
<button type="button" className="secondary-button" onClick={onClose} disabled={saving}>
</button>
<button type="submit" className="primary-button" disabled={saving}>
{saving ? '保存中...' : notice ? '保存修改' : '发布'}
</button>
</div>
</form>
</div>
</div>
)
}
+437
View File
@@ -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>
)
}
+460
View File
@@ -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>
</>
)
}
+206
View File
@@ -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>
</>
)
}
+481
View File
@@ -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>
)
}
+267
View File
@@ -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>
)
}
+248
View File
@@ -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>
)
}
+343
View File
@@ -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>&lt;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>
</>
)
}
+375
View File
@@ -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>
)
}
+112
View File
@@ -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>
)
}
+70
View File
@@ -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>
</>
)
}
+11
View File
@@ -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'
+41
View File
@@ -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
View File
File diff suppressed because one or more lines are too long
+100
View File
@@ -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
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />