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

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
+9
View File
@@ -0,0 +1,9 @@
node_modules
dist
data/*.db
data/*.db-shm
data/*.db-wal
data/test-*
.env
.env.local
*.log
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#b42318" />
<title>全国记者站管理系统</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+7
View File
@@ -0,0 +1,7 @@
/** @type {import('jest').Config} */
export default {
testEnvironment: 'node',
transform: {},
testMatch: ['**/server/tests/**/*.test.cjs'],
setupFilesAfterEnv: ['<rootDir>/server/tests/_setup.cjs'],
}
+40
View File
@@ -0,0 +1,40 @@
-- Migration: 001_init_schema
-- Description: 初始化基础表结构(从 server/index.js 迁移)
-- Date: 2026-08-01
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS schema_migrations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
applied_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
);
CREATE TABLE IF NOT EXISTS work_records (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
type TEXT NOT NULL,
reporter TEXT NOT NULL,
station TEXT NOT NULL,
occurred_date TEXT NOT NULL,
platform TEXT NOT NULL,
status TEXT NOT NULL,
score INTEGER,
description TEXT,
review_note TEXT,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
record_id TEXT NOT NULL REFERENCES work_records(id),
actor_role TEXT NOT NULL,
actor_name TEXT NOT NULL,
action TEXT NOT NULL,
from_status TEXT,
to_status TEXT NOT NULL,
score INTEGER,
note TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
+14
View File
@@ -0,0 +1,14 @@
-- Migration: 002_seed_data
-- Description: 初始种子数据(7 条工作记录)
-- Date: 2026-08-01
INSERT OR IGNORE INTO work_records
(id, title, type, reporter, station, occurred_date, platform, status, score, description, review_note, updated_at)
VALUES
('WK-202607-086', '暑运客流持续攀升,多部门保障出行', '文字稿件', '林晓', '北京记者站', '2026-07-30', '全国日报', 'station_review', NULL, NULL, NULL, '2026-07-31 09:42:00'),
('WK-202607-081', '老街更新:城市记忆与新消费共生', '视频供稿', '周宁', '北京记者站', '2026-07-29', '新闻客户端', 'headquarters_review', 8, NULL, NULL, '2026-07-30 17:26:00'),
('WK-202607-074', '长三角一体化重点项目集中签约', '重要报道', '陈屿', '上海记者站', '2026-07-28', '全国日报', 'headquarters_review', 12, NULL, NULL, '2026-07-29 15:18:00'),
('WK-202607-063', '县域公共文化服务观察', '图片供稿', '许言', '浙江记者站', '2026-07-26', '新闻周刊', 'returned', NULL, NULL, '请补充刊发版面截图,并核对发布日期。', '2026-07-28 11:05:00'),
('WK-202607-052', '防汛一线应急响应纪实', '文字稿件', '方澄', '广东记者站', '2026-07-24', '全国日报', 'archived', 10, NULL, NULL, '2026-07-26 16:31:00'),
('WK-202607-041', '融合报道生产能力专题培训', '培训参与', '林晓', '北京记者站', '2026-07-22', '总部培训中心', 'archived', 3, NULL, NULL, '2026-07-23 10:20:00'),
('WK-202607-032', '社区养老服务站走访', '文字稿件', '周宁', '北京记者站', '2026-07-18', '新闻客户端', 'draft', NULL, NULL, NULL, '2026-07-18 18:42:00');
+28
View File
@@ -0,0 +1,28 @@
-- Migration: 003_create_people
-- Description: 人员管理表
-- Date: 2026-08-01
CREATE TABLE IF NOT EXISTS people (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
station TEXT NOT NULL,
title TEXT,
phone TEXT,
joined_at TEXT,
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
);
INSERT OR IGNORE INTO people
(code, name, station, title, phone, joined_at, status)
VALUES
('PERSON_001', '林晓', '北京记者站', '记者', '13800001001', '2024-03-01', 'active'),
('PERSON_002', '周宁', '北京记者站', '记者', '13800001002', '2024-05-15', 'active'),
('PERSON_003', '陈屿', '上海记者站', '记者', '13900001001', '2023-09-01', 'active'),
('PERSON_004', '许言', '浙江记者站', '记者', '13900001002', '2024-01-10', 'active'),
('PERSON_005', '方澄', '广东记者站', '记者', '13900001003', '2023-11-20', 'active'),
('PERSON_006', '苏明远', '北京记者站', '分站负责人', '13800001003', '2023-01-01', 'active'),
('PERSON_007', '林致远', '总部', '总部管理员', '13800001000', '2022-01-01', 'active'),
('PERSON_008', '赵雨薇', '北京记者站', '记者', '13800001004', '2025-02-01', 'inactive');
+27
View File
@@ -0,0 +1,27 @@
-- Migration: 004_create_stations
-- Description: 记者站管理表
-- Date: 2026-08-01
CREATE TABLE IF NOT EXISTS stations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
region TEXT,
address TEXT,
leader TEXT,
phone TEXT,
status TEXT NOT NULL DEFAULT 'active',
established_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
);
INSERT OR IGNORE INTO stations
(code, name, region, address, leader, phone, status, established_at)
VALUES
('STATION_BJ', '北京记者站', '华北', '北京市朝阳区', '苏明远', '010-12345678', 'active', '2020-01-01'),
('STATION_SH', '上海记者站', '华东', '上海市浦东新区', '李华东', '021-12345678', 'active', '2020-01-01'),
('STATION_ZJ', '浙江记者站', '华东', '杭州市西湖区', '张浙江', '0571-12345678', 'active', '2021-03-01'),
('STATION_GD', '广东记者站', '华南', '广州市天河区', '王广东', '020-12345678', 'active', '2021-06-01'),
('STATION_SC', '四川记者站', '西南', '成都市武侯区', '刘四川', '028-12345678', 'active', '2022-01-01'),
('STATION_HB', '湖北记者站', '华中', '武汉市武昌区', '陈湖北', '027-12345678', 'inactive', '2022-06-01');
+38
View File
@@ -0,0 +1,38 @@
-- Migration: 005_create_notices
-- Description: 通知公告及回执表
-- Date: 2026-08-01
CREATE TABLE IF NOT EXISTS notices (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
content TEXT,
priority TEXT NOT NULL DEFAULT 'normal',
scope TEXT NOT NULL DEFAULT 'all',
stations TEXT,
roles TEXT,
attachment TEXT,
published_by TEXT NOT NULL,
published_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
);
CREATE TABLE IF NOT EXISTS notice_receipts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
notice_id INTEGER NOT NULL REFERENCES notices(id),
receiver_name TEXT NOT NULL,
receiver_role TEXT NOT NULL,
read INTEGER NOT NULL DEFAULT 0,
confirmed INTEGER NOT NULL DEFAULT 0,
read_at TEXT,
confirmed_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
);
INSERT OR IGNORE INTO notices
(code, title, content, priority, scope, published_by, published_at)
VALUES
('NOTICE_20260801_001', '关于开展第三季度考核工作的通知', '各记者站请于8月15日前完成第三季度工作记录提交,总部将在8月底完成评分。', 'high', 'all', '林致远', '2026-08-01 09:00:00'),
('NOTICE_20260801_002', '记者站年度考核评分标准调整说明', '自本通知发布之日起,新提交的稿件将按更新后的评分标准执行。详见附件。', 'normal', 'station', '林致远', '2026-08-01 10:30:00'),
('NOTICE_20260720_001', '暑期安全生产宣传报道提示', '请各站注意收集暑期出行、安全生产等相关素材,及时投稿。', 'normal', 'all', '林致远', '2026-07-20 14:00:00');
+20
View File
@@ -0,0 +1,20 @@
-- Migration: 006_optimize_indexes
-- Description: 索引优化(audit_logs 表)
-- Date: 2026-08-01
CREATE INDEX IF NOT EXISTS idx_audit_record_id ON audit_logs(record_id);
CREATE INDEX IF NOT EXISTS idx_audit_actor_role ON audit_logs(actor_role);
CREATE INDEX IF NOT EXISTS idx_audit_created_at ON audit_logs(created_at);
CREATE INDEX IF NOT EXISTS idx_work_station ON work_records(station);
CREATE INDEX IF NOT EXISTS idx_work_reporter ON work_records(reporter);
CREATE INDEX IF NOT EXISTS idx_work_status ON work_records(status);
CREATE INDEX IF NOT EXISTS idx_work_updated_at ON work_records(updated_at);
CREATE INDEX IF NOT EXISTS idx_people_station ON people(station);
CREATE INDEX IF NOT EXISTS idx_people_status ON people(status);
CREATE INDEX IF NOT EXISTS idx_stations_region ON stations(region);
CREATE INDEX IF NOT EXISTS idx_stations_status ON stations(status);
CREATE INDEX IF NOT EXISTS idx_notice_receipt_notice ON notice_receipts(notice_id);
+42
View File
@@ -0,0 +1,42 @@
-- Migration: 007_create_rules
-- Description: 考核规则及指标项表
-- Date: 2026-08-01
-- Refs: pmdocs/changes/2026-08-01-001-V0.2考核规则配置.md
CREATE TABLE IF NOT EXISTS rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
description TEXT,
period_type TEXT NOT NULL,
period_start TEXT,
period_end TEXT,
status TEXT NOT NULL DEFAULT 'draft',
version INTEGER NOT NULL DEFAULT 1,
parent_id INTEGER REFERENCES rules(id),
created_by TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
);
CREATE TABLE IF NOT EXISTS rule_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rule_id INTEGER NOT NULL REFERENCES rules(id) ON DELETE CASCADE,
category TEXT NOT NULL,
name TEXT NOT NULL,
metric_key TEXT NOT NULL,
weight REAL NOT NULL,
min_score REAL NOT NULL DEFAULT 0,
max_score REAL NOT NULL DEFAULT 100,
formula_type TEXT NOT NULL,
formula_params TEXT,
display_order INTEGER NOT NULL DEFAULT 0,
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
);
CREATE INDEX IF NOT EXISTS idx_rules_status ON rules(status);
CREATE INDEX IF NOT EXISTS idx_rules_period ON rules(period_start, period_end);
CREATE INDEX IF NOT EXISTS idx_rule_items_rule ON rule_items(rule_id);
CREATE INDEX IF NOT EXISTS idx_rule_items_category ON rule_items(category);
+29
View File
@@ -0,0 +1,29 @@
-- Migration: 008_create_scores
-- Description: 考核评分结果表
-- Date: 2026-08-01
-- Refs: pmdocs/changes/2026-08-01-001-V0.2考核规则配置.md
CREATE TABLE IF NOT EXISTS scores (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL UNIQUE,
rule_id INTEGER NOT NULL REFERENCES rules(id),
reporter TEXT NOT NULL,
station TEXT NOT NULL,
period TEXT NOT NULL,
period_type TEXT NOT NULL,
total_score REAL,
quality_score REAL,
quantity_score REAL,
efficiency_score REAL,
compliance_score REAL,
item_details TEXT,
computed_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
);
CREATE INDEX IF NOT EXISTS idx_scores_reporter ON scores(reporter);
CREATE INDEX IF NOT EXISTS idx_scores_station ON scores(station);
CREATE INDEX IF NOT EXISTS idx_scores_period ON scores(period);
CREATE INDEX IF NOT EXISTS idx_scores_rule ON scores(rule_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_scores_unique ON scores(reporter, station, period);
+95
View File
@@ -0,0 +1,95 @@
-- Migration: 009_seed_default_rules
-- Description: 插入默认考核规则模板
-- Date: 2026-08-01
-- Refs: pmdocs/changes/2026-08-01-001-V0.2考核规则配置.md
-- 默认考核规则模板(草稿状态,待总部激活)
INSERT OR IGNORE INTO rules
(code, name, description, period_type, period_start, period_end, status, version, created_by)
VALUES
('RULE_2026_Q3', '2026年第三季度考核规则', '第三季度综合考核,包含数量、质量、时效、合规四个维度', 'quarterly', '2026-07-01', '2026-09-30', 'draft', 1, '林致远');
-- 默认指标项(6个,覆盖四维度,权重之和为1.0)
INSERT OR IGNORE INTO rule_items
(rule_id, category, name, metric_key, weight, min_score, max_score, formula_type, formula_params, display_order)
SELECT
r.id,
'quantity',
'原创报道数量',
'count_total',
0.20,
0, 100,
'count',
'{"threshold": 10, "base_score": 60, "bonus_per_item": 4, "max_bonus": 20}',
1
FROM rules r WHERE r.code = 'RULE_2026_Q3';
INSERT OR IGNORE INTO rule_items
(rule_id, category, name, metric_key, weight, min_score, max_score, formula_type, formula_params, display_order)
SELECT
r.id,
'quantity',
'视频供稿数量',
'count_video',
0.10,
0, 100,
'count',
'{"threshold": 5, "base_score": 60, "bonus_per_item": 5, "max_bonus": 20}',
2
FROM rules r WHERE r.code = 'RULE_2026_Q3';
INSERT OR IGNORE INTO rule_items
(rule_id, category, name, metric_key, weight, min_score, max_score, formula_type, formula_params, display_order)
SELECT
r.id,
'quality',
'平均审核得分',
'avg_score',
0.25,
0, 100,
'avg_score',
'{}',
3
FROM rules r WHERE r.code = 'RULE_2026_Q3';
INSERT OR IGNORE INTO rule_items
(rule_id, category, name, metric_key, weight, min_score, max_score, formula_type, formula_params, display_order)
SELECT
r.id,
'quality',
'原创稿件占比',
'original_ratio',
0.15,
0, 100,
'rate',
'{"target": 0.6, "base_score": 60, "bonus_per_pct": 40}',
4
FROM rules r WHERE r.code = 'RULE_2026_Q3';
INSERT OR IGNORE INTO rule_items
(rule_id, category, name, metric_key, weight, min_score, max_score, formula_type, formula_params, display_order)
SELECT
r.id,
'efficiency',
'按时提交率',
'on_time_rate',
0.15,
0, 100,
'rate',
'{"target": 0.9, "base_score": 60, "bonus_per_pct": 40}',
5
FROM rules r WHERE r.code = 'RULE_2026_Q3';
INSERT OR IGNORE INTO rule_items
(rule_id, category, name, metric_key, weight, min_score, max_score, formula_type, formula_params, display_order)
SELECT
r.id,
'compliance',
'审核通过率',
'pass_rate',
0.15,
0, 100,
'rate',
'{"target": 0.85, "base_score": 60, "bonus_per_pct": 40}',
6
FROM rules r WHERE r.code = 'RULE_2026_Q3';
+9
View File
@@ -0,0 +1,9 @@
-- Migration: 010_add_soft_delete
-- Description: 为人员、站点、工作记录添加逻辑删除字段
-- Date: 2026-08-01
ALTER TABLE people ADD COLUMN deleted_at TEXT;
ALTER TABLE stations ADD COLUMN deleted_at TEXT;
ALTER TABLE work_records ADD COLUMN deleted_at TEXT;
ALTER TABLE work_records ADD COLUMN attachments TEXT;
ALTER TABLE work_records ADD COLUMN created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'));
+32
View File
@@ -0,0 +1,32 @@
-- Migration: 011_add_auth_and_audit
-- Description: 登录会话表 + 系统操作日志表(全覆盖审计)
-- Date: 2026-08-01
CREATE TABLE IF NOT EXISTS login_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token TEXT NOT NULL UNIQUE,
user_code TEXT NOT NULL,
user_name TEXT NOT NULL,
role TEXT NOT NULL,
station TEXT,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
revoked_at TEXT
);
CREATE TABLE IF NOT EXISTS system_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
actor_role TEXT NOT NULL,
actor_name TEXT NOT NULL,
module TEXT NOT NULL,
action TEXT NOT NULL,
target_type TEXT,
target_id TEXT,
detail TEXT,
ip_address TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
);
CREATE INDEX IF NOT EXISTS idx_login_sessions_token ON login_sessions(token);
CREATE INDEX IF NOT EXISTS idx_system_logs_actor ON system_logs(actor_name);
CREATE INDEX IF NOT EXISTS idx_system_logs_module ON system_logs(module);
+30
View File
@@ -0,0 +1,30 @@
-- Migration: 012_add_record_extras
-- Description: 工作记录版本历史表 + 人员调站历史表
-- Date: 2026-08-01
CREATE TABLE IF NOT EXISTS record_versions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
record_id TEXT NOT NULL REFERENCES work_records(id),
version_no INTEGER NOT NULL,
title TEXT,
type TEXT,
platform TEXT,
description TEXT,
attachments TEXT,
edited_by TEXT NOT NULL,
edited_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
);
CREATE INDEX IF NOT EXISTS idx_record_versions_record ON record_versions(record_id);
CREATE TABLE IF NOT EXISTS person_transfers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
person_id INTEGER NOT NULL REFERENCES people(id),
from_station TEXT NOT NULL,
to_station TEXT NOT NULL,
reason TEXT,
operated_by TEXT NOT NULL,
transferred_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
);
CREATE INDEX IF NOT EXISTS idx_person_transfers_person ON person_transfers(person_id);
+23
View File
@@ -0,0 +1,23 @@
-- Migration: 013_add_appeals
-- Description: 申诉复议表
-- Date: 2026-08-01
CREATE TABLE IF NOT EXISTS appeals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL UNIQUE,
record_id TEXT NOT NULL REFERENCES work_records(id),
appellant TEXT NOT NULL,
station TEXT NOT NULL,
reason TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
handler TEXT,
handler_role TEXT,
response TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
handled_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_appeals_record ON appeals(record_id);
CREATE INDEX IF NOT EXISTS idx_appeals_status ON appeals(status);
CREATE INDEX IF NOT EXISTS idx_appeals_appellant ON appeals(appellant);
+7
View File
@@ -0,0 +1,7 @@
-- Migration: 014_add_notice_extras
-- Description: 通知公告增加定时发布和撤回字段
-- Date: 2026-08-01
ALTER TABLE notices ADD COLUMN scheduled_at TEXT;
ALTER TABLE notices ADD COLUMN withdrawn_at TEXT;
ALTER TABLE notices ADD COLUMN status TEXT NOT NULL DEFAULT 'published';
+27
View File
@@ -0,0 +1,27 @@
-- Migration: 015_seed_system_logs
-- Description: 插入系统操作日志示例数据
-- Date: 2026-08-01
INSERT INTO system_logs (actor_role, actor_name, module, action, target_type, target_id, detail, ip_address, created_at) VALUES
('headquarters', '林致远', 'auth', 'login', NULL, NULL, '总部管理员登录系统', '127.0.0.1', datetime('now', 'localtime', '-3 hours')),
('reporter', '林晓', 'record', 'create', 'record', 'R2026_001', '创建工作记录《北京暴雨直击》', '127.0.0.1', datetime('now', 'localtime', '-170 minutes')),
('reporter', '林晓', 'record', 'save_draft', 'record', 'R2026_002', '保存草稿《上海地铁新线开通》', '127.0.0.1', datetime('now', 'localtime', '-165 minutes')),
('station', '苏明远', 'record', 'pass', 'record', 'R2026_001', '审核通过《北京暴雨直击》', '127.0.0.1', datetime('now', 'localtime', '-150 minutes')),
('station', '苏明远', 'record', 'return', 'record', 'R2026_003', '审核退回《广州城中村改造》', '127.0.0.1', datetime('now', 'localtime', '-140 minutes')),
('headquarters', '林致远', 'people', 'create', 'person', 'PERSON_011', '新增记者赵雨晴', '127.0.0.1', datetime('now', 'localtime', '-120 minutes')),
('headquarters', '林致远', 'people', 'update', 'person', 'PERSON_003', '更新记者联系方式', '127.0.0.1', datetime('now', 'localtime', '-110 minutes')),
('headquarters', '林致远', 'people', 'transfer', 'person', 'PERSON_005', '从北京记者站调入上海记者站', '127.0.0.1', datetime('now', 'localtime', '-100 minutes')),
('station', '苏明远', 'notices', 'create', 'notice', 'N006', '发布通知《关于第三季度考核安排》', '127.0.0.1', datetime('now', 'localtime', '-90 minutes')),
('reporter', '林晓', 'appeals', 'create', 'appeal', 'APPEAL_003', '对评分结果提出申诉', '127.0.0.1', datetime('now', 'localtime', '-80 minutes')),
('headquarters', '林致远', 'appeals', 'uphold', 'appeal', 'APPEAL_001', '驳回申诉 APPEAL_001', '127.0.0.1', datetime('now', 'localtime', '-70 minutes')),
('headquarters', '林致远', 'appeals', 'overturn', 'appeal', 'APPEAL_002', '通过申诉 APPEAL_002,调整评分', '127.0.0.1', datetime('now', 'localtime', '-65 minutes')),
('headquarters', '林致远', 'export', 'records', NULL, NULL, '导出工作记录 CSV,共 156 条', '127.0.0.1', datetime('now', 'localtime', '-50 minutes')),
('headquarters', '林致远', 'export', 'scores', NULL, NULL, '导出评分结果 CSV,共 42 条', '127.0.0.1', datetime('now', 'localtime', '-45 minutes')),
('headquarters', '林致远', 'export', 'people', NULL, NULL, '导出人员名单 CSV,共 28 条', '127.0.0.1', datetime('now', 'localtime', '-40 minutes')),
('reporter', '林晓', 'record', 'upload_attachment', 'record', 'R2026_004', '上传附件《现场照片.zip》', '127.0.0.1', datetime('now', 'localtime', '-35 minutes')),
('station', '苏明远', 'record', 'withdraw', 'record', 'R2026_002', '撤回工作记录《上海地铁新线开通》', '127.0.0.1', datetime('now', 'localtime', '-30 minutes')),
('headquarters', '林致远', 'auth', 'logout', NULL, NULL, '总部管理员退出系统', '127.0.0.1', datetime('now', 'localtime', '-20 minutes')),
('reporter', '林晓', 'auth', 'login', NULL, NULL, '记者林晓登录系统', '127.0.0.1', datetime('now', 'localtime', '-15 minutes')),
('station', '苏明远', 'auth', 'login', NULL, NULL, '分站负责人苏明远登录系统', '127.0.0.1', datetime('now', 'localtime', '-10 minutes')),
('headquarters', '林致远', 'people', 'delete', 'person', 'PERSON_012', '删除已离职记者记录', '127.0.0.1', datetime('now', 'localtime', '-5 minutes')),
('headquarters', '林致远', 'stations', 'update', 'station', 'ST_003', '更新广州记者站信息', '127.0.0.1', datetime('now', 'localtime', '-3 minutes'));
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env node
/**
* 数据库迁移运行器
* 按顺序执行 migrations/ 目录下的所有 .sql 文件
* 记录已执行的迁移到 schema_migrations 表(幂等性保障)
*
* 用法:
* node migrations/_runner.js # 执行所有待执行迁移
* node migrations/_runner.js --status # 查看迁移状态
* node migrations/_runner.js --reset # 重置数据库(慎用)
*/
import { DatabaseSync } from 'node:sqlite'
import { readFileSync, readdirSync, mkdirSync, unlinkSync, existsSync } from 'node:fs'
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = dirname(fileURLToPath(import.meta.url))
const root = join(__dirname, '..')
const dataDir = join(root, 'data')
mkdirSync(dataDir, { recursive: true })
const dbPath = join(dataDir, 'reporter-station.db')
/** 初始化数据库连接(确保 schema_migrations 表存在) */
function initDb() {
const db = new DatabaseSync(dbPath)
db.exec(`
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS schema_migrations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
applied_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
);
`)
return db
}
const args = process.argv.slice(2)
if (args.includes('--status')) {
const db = initDb()
const applied = db.prepare('SELECT name, applied_at FROM schema_migrations ORDER BY id').all()
const allFiles = readdirSync(__dirname)
.filter(f => f.endsWith('.sql') && !f.startsWith('_'))
.sort()
console.log('\n迁移状态:')
for (const f of allFiles) {
const name = f.replace('.sql', '')
const row = applied.find(a => a.name === name)
if (row) {
console.log(` [x] ${name} (applied at ${row.applied_at})`)
} else {
console.log(` [ ] ${name}`)
}
}
db.close()
process.exit(0)
}
if (args.includes('--seed')) {
const db = initDb()
const seedFile = '002_seed_data.sql'
const sql = readFileSync(join(__dirname, seedFile), 'utf-8')
try {
db.exec(sql)
console.log(`已重新执行 seed 数据(${seedFile}`)
} catch (err) {
console.error(`seed 失败: ${err.message}`)
process.exit(1)
}
db.close()
process.exit(0)
}
if (args.includes('--reset')) {
for (const ext of ['', '-wal', '-shm']) {
const f = dbPath + ext
if (existsSync(f)) { unlinkSync(f); console.log(`已删除: ${f}`) }
}
console.log('数据库已重置,重新启动服务将自动执行迁移。')
process.exit(0)
}
// 默认:执行待执行的迁移
const db = initDb()
const applied = new Set(db.prepare('SELECT name FROM schema_migrations').all().map(r => r.name))
const allFiles = readdirSync(__dirname)
.filter(f => f.endsWith('.sql') && !f.startsWith('_'))
.sort()
let appliedCount = 0
for (const file of allFiles) {
const name = file.replace('.sql', '')
if (applied.has(name)) {
console.log(` 跳过 (已执行): ${name}`)
continue
}
const sql = readFileSync(join(__dirname, file), 'utf-8')
try {
db.exec('BEGIN')
db.exec(sql)
db.prepare('INSERT INTO schema_migrations (name) VALUES (?)').run(name)
db.exec('COMMIT')
console.log(` [x] ${name}`)
appliedCount++
} catch (err) {
db.exec('ROLLBACK')
console.error(` [!] ${name} 执行失败: ${err.message}`)
process.exit(1)
}
}
db.close()
if (appliedCount === 0) {
console.log('\n没有待执行的迁移,数据库已是最新。')
} else {
console.log(`\n成功执行 ${appliedCount} 个迁移。`)
}
+8928
View File
File diff suppressed because it is too large Load Diff
+48
View File
@@ -0,0 +1,48 @@
{
"name": "reporter-station-management",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "concurrently -k -n API,WEB -c yellow,cyan \"npm:server\" \"npm:web\"",
"web": "vite",
"server": "node --watch server/index.js",
"server:start": "node server/index.js",
"build": "tsc -b && vite build",
"lint": "eslint .",
"db:migrate": "node migrations/_runner.js",
"db:seed": "node migrations/_runner.js --seed",
"db:status": "node migrations/_runner.js --status",
"db:reset": "node migrations/_runner.js --reset",
"test": "jest",
"test:watch": "jest --watch"
},
"dependencies": {
"@types/leaflet": "^1.9.22",
"@vitejs/plugin-react": "latest",
"express": "latest",
"leaflet": "^1.9.4",
"lucide-react": "latest",
"react": "latest",
"react-dom": "latest",
"react-router-dom": "^7.18.2",
"typescript": "latest",
"vite": "latest"
},
"devDependencies": {
"@eslint/js": "latest",
"@types/jest": "^30.0.0",
"@types/react": "latest",
"@types/react-dom": "latest",
"@types/supertest": "^7.2.1",
"concurrently": "latest",
"eslint": "latest",
"eslint-plugin-react-hooks": "latest",
"eslint-plugin-react-refresh": "latest",
"globals": "latest",
"jest": "^30.4.2",
"supertest": "^7.2.2",
"ts-jest": "^29.4.12",
"typescript-eslint": "latest"
}
}
+308
View File
@@ -0,0 +1,308 @@
# REQ-RSMS-001:全国记者站管理系统需求与目标
> 文档版本:v1.0
> 状态:待确认
> 编制日期:2026-08-01
> 项目缩写:RSMSReporter Station Management System
## 1. 引言与目标
### 1.1 建设背景
当前记者站管理存在数据分散、统计口径不统一、沟通链路长、档案查询不便、人工考核容易出错及决策数据不足等问题。系统面向全国 37 个记者站,将分散工作整合至统一平台。
### 1.2 建设目标
1. 实现人员、工作、考核、通知和统计的统一管理。
2. 实现日常工作线上填报、分级审核、自动统计和全程留痕。
3. 为每位记者建立长期保存、可追溯的个人电子档案。
4. 统一考核标准和统计口径,提高考核公平性与透明度。
5. 为总部掌握全国运行情况、考核评价和资源配置提供数据支持。
### 1.3 项目缩写与系统代号
- 项目英文缩写:**RSMS**
- 系统代号:**全国记者站管理系统**
- 当前版本:V0.1 MVP(已实现核心流程)
---
## 2. 术语表
| 术语 | 定义 |
|---|---|
| 总部 / 总站 | 全国记者站管理工作的总部管理机构 |
| 记者站 / 分站 / 站点 | 纳入系统管理的 37 个记者站之一 |
| 总部管理员 | 负责规则、权限、复核、统计及全局监管的用户 |
| 分站负责人 | 负责本站人员管理、工作初审及考核汇总的用户 |
| 记者 | 负责工作填报、材料上传和个人信息查询的用户 |
| 工作记录 | 记者提交的一次稿件、作品、培训、临时工作等业务记录 |
| 考核 | 按规则对工作记录进行审核、计分、汇总和评价的过程 |
---
## 3. 角色定义
| 角色 | 代码 | 主要职责 | 默认数据范围 |
|---|---|---|---|
| 总部管理员 | `headquarters` | 组织和制度管理、考核规则配置、总部复核、通知发布、全局统计、权限监管 | 全国全部站点及人员 |
| 分站负责人 | `station` | 站人员维护、填报初审、本站考核汇总、通知落实 | 所属站点及本站人员 |
| 记者 | `reporter` | 工作填报、材料上传、通知查看、成绩和档案查询、个人资料维护 | 本人数据及授权公开信息 |
---
## 4. 功能性需求
### 4.1 身份认证与账号
| 编号 | 优先级 | 需求(EARS 格式) |
|---|---|---|
| REQ-AUTH-001 | P0 | 系统 SHALL 支持通过角色模拟切换用户身份,切换后页面导航、数据访问和操作权限须按角色重新渲染。 |
| REQ-AUTH-002 | P0 | 系统 SHALL 识别用户角色、所属站点、账号状态和数据权限,并通过 HTTP header `x-user-role` 传递至后端。 |
| REQ-AUTH-003 | P0 | 未绑定、停用或离职账号不得访问受保护业务数据(后端数据隔离)。 |
| REQ-AUTH-004 | P1 | 系统 SHOULD 支持一名用户拥有多个角色,并可切换当前工作身份。 |
| REQ-AUTH-005 | P2 | 系统 MAY 支持真实账号绑定、解绑、重置及异常登录处置。 |
### 4.2 组织与站点管理
| 编号 | 优先级 | 需求(EARS 格式) |
|---|---|---|
| REQ-ORG-001 | P0 | 总部管理员 SHALL 能查看全部 37 个记者站的信息,包括名称、编码、行政区域、负责人、联系方式和状态。 |
| REQ-ORG-002 | P1 | 总部管理员 SHALL 能新增、编辑和停用记者站,并保存变更历史。 |
| REQ-ORG-003 | P1 | 系统 SHOULD 展示全国地图及记者站分布。 |
### 4.3 人员管理
| 编号 | 优先级 | 需求(EARS 格式) |
|---|---|---|
| REQ-USER-001 | P0 | 总部管理员 SHALL 能新增、编辑、查询和启停人员信息。 |
| REQ-USER-002 | P0 | 分站负责人 SHALL 仅能管理所属站点的人员信息。 |
| REQ-USER-003 | P0 | 人员信息 SHALL 包含姓名、人员编号、所属站点、职务、入站时间、联系方式、在职状态和账号绑定状态。 |
| REQ-USER-004 | P1 | 系统 SHALL 支持在职、离职、调动等状态,并保留任职及站点变更历史。 |
| REQ-USER-005 | P1 | 系统 SHOULD 支持按姓名、站点、职务、状态等条件组合查询和导出。 |
### 4.4 个人电子档案
| 编号 | 优先级 | 需求(EARS 格式) |
|---|---|---|
| REQ-PROFILE-001 | P0 | 系统 SHALL 为每位记者建立唯一、长期保存的电子档案。 |
| REQ-PROFILE-002 | P0 | 档案 SHALL 聚合基本信息、文字稿件、视频作品、图片作品、培训、获奖和年度考核记录。 |
| REQ-PROFILE-003 | P0 | 复核通过的工作和考核结果 SHALL 自动归档,避免二次录入。 |
| REQ-PROFILE-004 | P1 | 档案记录 SHALL 显示来源、发生时间、审核状态、得分和证明附件。 |
### 4.5 工作填报与材料管理
| 编号 | 优先级 | 需求(EARS 格式) |
|---|---|---|
| REQ-WORK-001 | P0 | 记者 SHALL 能按业务类型新建工作记录,至少支持:文字稿件、视频供稿、图片供稿、重要报道、培训参与、临时工作。 |
| REQ-WORK-002 | P0 | 系统 SHALL 支持草稿保存、编辑、提交、查看详情。 |
| REQ-WORK-003 | P0 | 填报字段 SHALL 包括标题、发生/刊发日期、媒体/平台、工作说明及证明材料。 |
| REQ-WORK-004 | P0 | 系统 SHALL 校验必填项、数据格式、附件类型/大小。 |
| REQ-WORK-005 | P0 | 提交后普通用户不得直接修改;被退回后可依据意见修改并重新提交。 |
| REQ-WORK-006 | P1 | 系统 SHOULD 支持图片、文档等材料上传,证明材料作为审核依据。 |
### 4.6 审核与日常考核
| 编号 | 优先级 | 需求(EARS 格式) |
|---|---|---|
| REQ-ASSESS-001 | P0 | 分站负责人 SHALL 在待办中接收本站待审核记录并完成初审。 |
| REQ-ASSESS-002 | P0 | 初审 SHALL 支持通过、退回,填写审核意见,并按规则完成初评分。 |
| REQ-ASSESS-003 | P0 | 总部管理员 SHALL 对初审通过记录进行复核,支持确认、调整和退回。 |
| REQ-ASSESS-004 | P0 | 审核操作 SHALL 记录处理人、处理时间、意见、处理前后状态和分数变化。 |
| REQ-ASSESS-005 | P0 | 系统 SHALL 按生效考核规则自动计算单项得分。 |
| REQ-ASSESS-006 | P0 | 规则变更不得静默改变已归档结果;重新计算必须经授权并留痕。 |
| REQ-ASSESS-007 | P1 | 系统 SHOULD 提供超时待办提醒和审核时效统计。 |
### 4.7 通知公告
| 编号 | 优先级 | 需求(EARS 格式) |
|---|---|---|
| REQ-NOTICE-001 | P0 | 首页 SHALL 展示最新通知,用户可查看详情及附件。 |
| REQ-NOTICE-002 | P0 | 系统 SHALL 聚合待填报、待审核、被退回及其他待处理事项。 |
| REQ-NOTICE-003 | P1 | 总部管理员 SHOULD 能创建、编辑和发布通知公告。 |
| REQ-NOTICE-004 | P1 | 重要通知 SHOULD 支持已读/未读和确认回执统计。 |
### 4.8 数据统计与报表
| 编号 | 优先级 | 需求(EARS 格式) |
|---|---|---|
| REQ-STAT-001 | P0 | 总部 SHALL 能查看全国、地区、站点、人员、时间和工作类型等维度的统计。 |
| REQ-STAT-002 | P0 | 统计指标 SHALL 至少包括填报数量、审核进度、通过/退回数量、考核得分和人员排名。 |
| REQ-STAT-003 | P0 | 分站负责人 SHALL 仅能查看本站统计,记者仅能查看本人统计。 |
### 4.9 首页与个人中心
| 编号 | 优先级 | 需求(EARS 格式) |
|---|---|---|
| REQ-HOME-001 | P0 | 首页 SHALL 按角色展示常用入口,至少包括通知公告、工作填报、考核成绩、我的档案、待办事项和个人中心。 |
| REQ-HOME-002 | P0 | 首页 SHALL 显示待办数量和最新通知,点击可直达对应列表或详情。 |
### 4.10 系统管理与审计
| 编号 | 优先级 | 需求(EARS 格式) |
|---|---|---|
| REQ-SYS-001 | P0 | 系统 SHALL 记录登录、人员/组织变更、规则发布、审核、导出、删除和权限调整等关键操作。 |
| REQ-SYS-002 | P0 | 审计日志 SHALL 至少包含操作者、时间、来源、对象、动作、结果及必要的变更摘要。 |
---
## 5. 工作记录状态机
```
┌──────────────────────────────────────────────────────────────┐
│ │
▼ │
草稿(draft) ──提交──▶ 待分站审核(station_review) ──初审通过──▶ 待总部复核(hq_review) │
▲ │ │ │
│ │退回 │复核通过 │
│ ▼ ▼ │
│ 已退回(returned) ──重新提交──▶ 待分站审核 已归档(archived)
│ │
└── 修改 ────────────────────────────────────────────────────────┘
```
- 状态:`draft``station_review``headquarters_review``archived`
- 异常:`station_review` / `headquarters_review` 可退回至 `returned`
- `returned` 可修改后重新提交
---
## 6. 非功能性需求
### 6.1 性能
| 编号 | 需求 |
|---|---|
| NFR-PERF-001 | 常规列表、详情和提交操作的服务端 95 分位响应时间不超过 2 秒(文件上传和复杂报表除外)。 |
| NFR-PERF-002 | 月度/年度统计可采用异步计算;用户应能看到计算状态和数据更新时间。 |
### 6.2 安全
| 编号 | 需求 |
|---|---|
| NFR-SEC-001 | 所有受保护接口必须完成身份认证和服务端权限校验。 |
| NFR-SEC-002 | 数据按总部、站点和个人范围隔离,禁止仅依赖前端隐藏实现权限。 |
| NFR-SEC-003 | 传输过程使用 HTTPS(生产环境);密码、令牌及敏感配置不得明文存储。 |
### 6.3 可靠性
| 编号 | 需求 |
|---|---|
| NFR-REL-001 | 提交、审核和计分等关键操作须具备幂等或防重复机制。 |
| NFR-REL-002 | 核心数据应实施定期备份。 |
### 6.4 兼容性
| 编号 | 需求 |
|---|---|
| NFR-COMP-001 | 小程序应兼容项目确定的主流微信版本、iOS 和 Android 系统版本。 |
| NFR-COMP-002 | 后台管理系统应兼容 Chrome、Safari、Firefox 等现代浏览器。 |
---
## 7. 范围边界
### 7.1 首期纳入(MVP
- 账号登录模拟、人员绑定模拟及三级角色权限(前端切换 + 后端数据隔离)。
- 37 个站点和人员基础信息管理。
- 工作分类填报、附件上传(上传框 UI)和记录查询。
- 分站初审、总部复核、退回修改和流程留痕。
- 基础考核规则、自动计分。
- 个人电子档案自动归集与查询。
- 通知公告(只读)、待办事项。
- 后台工作台基础统计和报表导出。
- 系统设置入口(仅 UI)。
### 7.2 首期排除
- 微信小程序端(后台管理系统优先)。
- 真实身份认证(微信/统一身份),当前为角色模拟。
- 考核规则可视化配置(硬编码规则)。
- 历史数据迁移(37 站/486 人仅 mock 数据)。
- 全国地图与管理驾驶舱。
- 个人能力画像、智能分析。
- 与外部采编、人事、短信、电子签章系统集成。
- 申诉与复议流程。
- 自动化测试。
---
## 8. 关键约束与假设
| 编号 | 约束 / 假设 | 决策时点 |
|---|---|---|
| C-001 | 身份认证采用角色模拟(header 传 role),暂不接入微信或统一身份平台。 | 设计前确认 |
| C-002 | 总部复核方式采用分类复核,高价值记录逐条复核,普通记录按比例抽查(初始 20%)。 | 设计前确认 |
| C-003 | 视频采用外部链接方式,不在系统内存储视频文件。 | 立项前确认 |
| C-004 | 文件单文件大小限制为 20 MB。 | 设计前确认 |
| C-005 | 首期仅实现后台管理系统(Web),不实现微信小程序。 | 设计前确认 |
---
## 9. 验收标准
### 9.1 AC-01 记者完成工作填报
- 已绑定且在职的记者可以新建规定类型的工作记录。
- 必填项不符合要求时,系统明确提示且不允许提交。
- 提交成功后状态为"待分站审核",记者不可直接篡改已提交内容。
- 分站负责人待办数量同步增加。
### 9.2 AC-02 分站初审并退回
- 分站负责人只能审核本站记录。
- 退回时必须填写原因,记者能在待办和记录详情中查看。
- 记者修改并重新提交后,历史版本和原审核意见仍可追溯。
### 9.3 AC-03 总部复核并归档
- 分站通过的记录进入总部复核队列。
- 总部确认后,系统按绑定规则版本计算得分。
- 记录进入已归档状态,并自动出现在个人档案及统计中。
- 审核人、时间、意见和分数变化完整记录。
### 9.4 AC-04 数据权限隔离
- 记者无法访问他人的非公开档案和成绩。
- 分站负责人无法访问其他站点的受限数据。
- 总部管理员按授权查看全国数据。
- 通过修改前端参数或直接访问接口不能绕过上述限制。
### 9.5 AC-05 统计可核对
- 总部可按年度、站点、人员和工作类型筛选统计。
- 汇总值可下钻至构成该数值的已授权明细。
### 9.6 AC-06 历史数据可追溯
- 人员调站后,历史记录仍归属于发生时站点,同时个人档案连续保留。
- 规则升级后,既有已归档结果不被自动改写。
- 被授权的管理员可查询关键记录的版本、审核和操作轨迹。
---
## 10. 需求编号索引
| 类别 | 编号前缀 | 范围 |
|---|---|---|
| 身份认证 | REQ-AUTH | REQ-AUTH-001 ~ REQ-AUTH-005 |
| 组织站点 | REQ-ORG | REQ-ORG-001 ~ REQ-ORG-003 |
| 人员管理 | REQ-USER | REQ-USER-001 ~ REQ-USER-005 |
| 电子档案 | REQ-PROFILE | REQ-PROFILE-001 ~ REQ-PROFILE-004 |
| 工作填报 | REQ-WORK | REQ-WORK-001 ~ REQ-WORK-006 |
| 审核考核 | REQ-ASSESS | REQ-ASSESS-001 ~ REQ-ASSESS-007 |
| 通知公告 | REQ-NOTICE | REQ-NOTICE-001 ~ REQ-NOTICE-004 |
| 数据统计 | REQ-STAT | REQ-STAT-001 ~ REQ-STAT-003 |
| 首页个人 | REQ-HOME | REQ-HOME-001 ~ REQ-HOME-002 |
| 系统审计 | REQ-SYS | REQ-SYS-001 ~ REQ-SYS-002 |
| 性能 | NFR-PERF | NFR-PERF-001 ~ NFR-PERF-002 |
| 安全 | NFR-SEC | NFR-SEC-001 ~ NFR-SEC-003 |
| 可靠性 | NFR-REL | NFR-REL-001 ~ NFR-REL-002 |
| 兼容性 | NFR-COMP | NFR-COMP-001 ~ NFR-COMP-002 |
| 验收场景 | AC | AC-01 ~ AC-06 |
| 约束假设 | C | C-001 ~ C-005 |
---
> **待确认事项**:以上约束与假设(章节 8)涉及总体架构和关键业务规则,请在进入详细设计前逐条确认。
+307
View File
@@ -0,0 +1,307 @@
# PRD-RSMS-001:全国记者站管理系统产品需求文档
> 文档版本:v1.0
> 状态:待确认
> 编制日期:2026-08-01
> 项目缩写:RSMS
## 1. 产品概述与定位
全国记者站管理系统(RSMS)是一个面向全国 37 个记者站的管理平台,服务于总部管理员、分站负责人和记者三类角色。系统以工作记录填报和分级审核为业务核心,构建统一的人员、考核、档案和通知管理体系。
### 1.1 产品定位
- **目标**:替代线下/Excel 管理方式,实现业务线上化、考核标准化、数据可追溯。
- **用户**:总部约 5-10 名管理员;37 个分站各 1-2 名负责人;约 500 名记者。
- **核心价值**:减少重复填报、统一审核标准、提升考核透明度、积累可分析数据。
### 1.2 成功指标
| 指标 | 目标值 |
|---|---|
| 工作记录线上填报率 | >= 95% |
| 审核平均处理时长 | <= 24 小时 |
| 考核数据可追溯覆盖率 | 100% |
| 系统可用性 | >= 99.5% |
---
## 2. 用户画像与核心场景
### 场景 1:记者填报工作记录(SCENE-001)
**人物**:林晓,北京记者站记者,2021 年入站。
**痛点**:此前通过微信群或邮件提交,格式不统一,审核进度靠追问,档案整理靠人工。
**解法**:移动/PC 端随时填报,自动进入审核流,实时看到审核状态,归档记录自动进入个人档案。
### 场景 2:分站负责人初审(SCENE-002
**人物**:苏明远,北京记者站负责人。
**痛点**:接收材料格式混乱,退回修改反复沟通,统计靠人工汇总。
**解法**:统一格式接收,核验后一键通过或退回,退回时填写意见,作者修改后自动推送,全站数据自动汇总。
### 场景 3:总部管理员复核与监管(SCENE-003)
**人物**:林致远,总部考核管理员。
**痛点**:37 个站数据分散,统计口径不一致,复核工作量大,考核结果解释成本高。
**解法**:分类复核(高价值逐条 + 普通抽查),自动计分和排名,审核轨迹可查,报表一键导出。
### 场景 4:记者查询个人档案(SCENE-004)
**人物**:林晓,想查看自己本季度的考核得分和全年排名。
**痛点**:历史记录分散在各个群聊和文件中,不知道自己排名。
**解法**:个人档案聚合全部归档记录,支持按类型/时间筛选,显示年度汇总和趋势。
---
## 3. 功能清单与优先级
使用 MoSCoW 标注:`Must` = P0`Should` = P1`Could` = P2。
| 编号 | 功能 | 优先级 | 映射需求 |
|---|---|---|---|
| PRD-FUNC-001 | 三角色模拟登录与权限隔离 | Must | REQ-AUTH-001~003 |
| PRD-FUNC-002 | 工作记录新建(7 种类型) | Must | REQ-WORK-001~005 |
| PRD-FUNC-003 | 工作记录列表(搜索/筛选/分页) | Must | REQ-WORK-002 |
| PRD-FUNC-004 | 分站初审(通过/退回/评分) | Must | REQ-ASSESS-001~002 |
| PRD-FUNC-005 | 总部复核(确认/调整/退回) | Must | REQ-ASSESS-003~005 |
| PRD-FUNC-006 | 审核流程留痕(audit_log | Must | REQ-ASSESS-004 |
| PRD-FUNC-007 | 个人电子档案 | Must | REQ-PROFILE-001~003 |
| PRD-FUNC-008 | 工作台仪表盘(指标卡+趋势图+排名) | Must | REQ-STAT-001~003 |
| PRD-FUNC-009 | 通知公告(只读) | Must | REQ-NOTICE-001~002 |
| PRD-FUNC-010 | 审核中心(待办聚合) | Must | REQ-NOTICE-002 |
| PRD-FUNC-011 | 人员管理(增删改查) | Must | REQ-USER-001~003 |
| PRD-FUNC-012 | 记者站管理(增删改查) | Must | REQ-ORG-001 |
| PRD-FUNC-013 | 通知公告发布(富文本/附件/回执) | Should | REQ-NOTICE-003~004 |
| PRD-FUNC-014 | 考核规则可视化配置 | Should | REQ-ASSESS-006 |
| PRD-FUNC-015 | 数据统计报表(导出) | Should | REQ-STAT-001~003 |
| PRD-FUNC-016 | 申诉与复议 | Could | — |
| PRD-FUNC-017 | 全国地图分布 | Could | REQ-ORG-003 |
| PRD-FUNC-018 | 个人能力画像 | Could | — |
---
## 4. 关键流程
### 4.1 工作记录全生命周期
```
记者新建 → 保存草稿 / 直接提交
↓提交
待分站审核 ← 分站负责人处理
│通过 │退回
↓ ↓
待总部复核 已退回 → 记者修改 → 重新提交
│通过
已归档 → 自动进入个人档案 + 统计汇总
```
### 4.2 数据权限控制流
```
请求进入 → 后端读取 x-user-role header
headquarters: 返回全国全部数据
station: WHERE station = '所属站'
reporter: WHERE reporter = '本人姓名'
```
---
## 5. 角色权限矩阵
| 功能 | 总部管理员 | 分站负责人 | 记者 |
|---|---|---|---|
| 站点管理(查) | 全部 | 本站 | 本站 |
| 站点管理(增删改) | 是 | 否 | 否 |
| 人员管理(查) | 全部 | 本站 | 本人 |
| 人员管理(增删改) | 全部 | 本站 | 否 |
| 工作记录(填报) | 否 | 否(可代填) | 是 |
| 工作记录(查看) | 全部 | 本站 | 本人 |
| 分站初审 | 否 | 是(本站) | 否 |
| 总部复核 | 是 | 否 | 否 |
| 电子档案(查看) | 全部 | 本站 | 本人 |
| 通知公告(发布) | 是 | 否 | 否 |
| 通知公告(查看) | 全部 | 本站 | 本人 |
| 统计报表(全局) | 是 | 否 | 否 |
| 统计报表(本站) | 是 | 是 | 否 |
| 统计成绩(本人) | 是 | 是 | 是 |
| 系统设置 | 是 | 否 | 否 |
---
## 6. UI/UX 设计原则
### 6.1 整体风格
- **配色**:主色 #b42318(红),强调色 #4ba66a(绿/成功),背景 #f5f4f1(米白)
- **字体**Noto Sans SCSongti SC(标题)
- **图标**Lucide Icons
- **布局**:侧边栏固定 238px + 顶部栏 + 内容区
- **交互**Toast 反馈、Drawer 侧滑详情、Modal 弹窗表单
### 6.2 页面导航结构
```
工作台(dashboard
├─ 指标卡(本月记录 / 待处理 / 归档 / 得分)
├─ 趋势图(近6月工作量 AreaChart
├─ 排名(站点/类型)
└─ 待办(待审核 / 被退回 / 截止提醒)
工作记录(work
├─ 搜索 + 状态筛选 + 类型筛选
└─ 记录列表(点击打开详情抽屉)
审核中心(review)[总部/分站]
├─ 审核摘要(待办数 / 已处理 / 平均时长)
└─ 待审列表(通过 / 退回)
人员管理(people)[总部/分站]
└─ 人员列表 + 筛选 + 新增
记者站管理(stations[总部]
└─ 站点卡片网格
电子档案(archive
└─ 个人档案聚合页 + 历史归档列表
通知公告(notices
└─ 公告列表 + 详情侧边
```
### 6.3 统一交互模式
| 场景 | 组件 | 说明 |
|---|---|---|
| 列表加载中 | 顶部进度条动画 | 红色 2px 细条 |
| 列表为空 | EmptyState + 对应图标 | 居中文字提示 |
| 操作失败 | Toast 弹窗 | 2.4s 自动消失 |
| 操作成功 | Toast 弹窗 + 图标 | 绿色勾选图标 |
| 表单校验错误 | 输入框红框 + 错误提示 | 实时校验 |
| 危险操作(删除/退回) | ConfirmDialog 或二次确认 | 明确后果 |
| 分页 | 无(当前为全量加载) | 列表数据量小 |
| 搜索防抖 | 300ms debounce | 工作记录搜索 |
### 6.4 响应式断点
| 断点 | 布局变化 |
|---|---|
| > 1100px | 标准布局(指标4列、站点3列) |
| 761-1100px | 指标2列、站点2列、侧栏不变 |
| <= 760px | 侧栏变为抽屉,汉堡菜单触发,指标2列,站点1列,表单全宽 |
| <= 430px | 指标2列,保持最小可读性 |
---
## 7. 公共组件抽取规划
| 组件 | 类型 | 适用场景 | 输入状态 | 输出事件 | 使用页面 | 优先级 |
|---|---|---|---|---|---|---|
| `StatusBadge` | 基础组件 | 工作记录状态展示 | `status: WorkStatus` | — | 全局 | Must(已存在于代码) |
| `MetricCard` | 基础组件 | 指标数据展示 | `icon / label / value / delta / color` | — | 工作台 | Must(已存在) |
| `PanelHeader` | 组合组件 | 面板标题栏 | `title / subtitle / action / onAction` | `onAction` | 工作台/审核/档案 | Must(已存在) |
| `RecordTable` | 领域组件 | 工作记录列表 | `records / onSelect / compact / review` | `onSelect` | 工作/审核/档案 | Must(已存在) |
| `RecordDrawer` | 领域组件 | 记录详情+审核 | `record / role / onUpdate` | `onUpdate` | 工作/审核 | Must(已存在) |
| `CreateModal` | 领域组件 | 新建工作记录 | `onClose / onSubmit` | `onSubmit` | 工作台/工作记录 | Must(已存在) |
| `FilterBar` | 组合组件 | 列表筛选栏 | `searchPlaceholder / filters / onFilter` | `onFilter / onReset` | 工作记录/人员 | Should |
| `Pagination` | 基础组件 | 列表分页 | `page / pageSize / total` | `onChange` | 列表页面 | Could(当前 MVP 未分页) |
| `NotificationBadge` | 基础组件 | 导航待办角标 | `count` | — | 侧边栏导航 | Must(已存在) |
| `EmptyState` | 基础组件 | 空状态展示 | `icon / text` | — | 列表空时 | Must(已存在) |
| `Toast` | 基础组件 | 操作反馈 | `message`(自动消失) | — | 全局 | Must(已存在) |
| `PageHeading` | 组合组件 | 页面标题区 | `title / description / action / onAction` | `onAction` | 所有内容页 | Should(已存在但未抽取) |
| `RoleSwitcher` | 领域组件 | 角色模拟切换 | `role / onChange` | `onChange` | 顶部栏 | Must(已存在) |
---
## 8. 版本规划
### 8.1 MVP(当前版本 V0.1
已实现(代码层面):
- 角色模拟登录(header 传参)
- 工作记录 CRUD(前端 + 后端 API)
- 分站初审 / 总部复核流程
- 审核留痕(audit_log
- 7 个页面/组件
- SQLite 数据库
- Express API 服务
待补齐(文档层面):
- [ ] `pmdocs/0-req-RSMS.md`(本文档)
- [ ] `pmdocs/1-prd-RSMS.md`(本文档)
- [ ] `pmdocs/2-task-RSMS.md`
- [ ] `run.md`
- [ ] 前端模块化重构
### 8.2 二期
- 通知公告发布(富文本 + 附件 + 回执)
- 人员管理增删改(当前仅展示)
- 记者站管理增删改(当前仅展示)
- 考核规则配置界面
- 数据统计专项页面
- 个人中心功能完善
### 8.3 三期
- 申诉与复议流程
- 全国地图记者站分布
- 个人能力画像
- 外部系统集成
- 自动化测试
---
## 9. 技术架构概览
### 9.1 前端
- **框架**React 18 + TypeScript
- **构建**Vite
- **图表**RechartsAreaChart
- **图标**Lucide React
- **样式**:纯 CSS(含响应式)
- **状态**React useState / useEffect(当前),Redux Toolkit(预留)
- **路由**:条件渲染(当前),React Router(后续)
### 9.2 后端
- **运行时**Node.js
- **框架**Express.js
- **数据库**SQLiteWAL 模式)
- **进程**`node --watch` 开发热重载
### 9.3 API 概览
| 方法 | 路径 | 权限 | 说明 |
|---|---|---|---|
| GET | `/api/health` | 公开 | 健康检查 |
| GET | `/api/records` | 角色隔离 | 查询工作记录 |
| POST | `/api/records` | reporter/station | 新建记录 |
| PATCH | `/api/records/:id/review` | station/hq | 审核操作 |
| GET | `/api/records/:id/audit` | 角色隔离 | 流转记录 |
---
## 10. 风险与依赖
| 风险 | 影响 | 缓解措施 |
|---|---|---|
| SQLite 并发写入冲突 | 高 | WAL 模式已启用;高并发时考虑迁移 PostgreSQL |
| 前端单文件维护性差 | 中 | 二期重构为模块化结构 |
| 无自动化测试 | 中 | 二期引入 Vitest + Playwright |
| 角色模拟安全性不足 | 高 | 二期接入真实认证体系 |
| 考核规则硬编码 | 中 | 二期实现规则可视化配置 |
---
## 11. 关键决策记录索引
| 决策 | ADR 编号 | 状态 |
|---|---|---|
| 前端技术栈(React+Vite | ADR-001 | 已决策 |
| 后端技术栈(Express+SQLite | ADR-001 | 已决策 |
| 认证方案(角色模拟) | ADR-002 | 待确认 |
| 数据库选型(SQLite | ADR-001 | 已决策 |
+505
View File
@@ -0,0 +1,505 @@
# 2-task-RSMS-001:全国记者站管理系统开发任务文档
> 文档版本:v1.0
> 状态:待确认
> 编制日期:2026-08-01
> 项目缩写:RSMS
> 适用范围:V0.1 MVP 收尾 + V0.2 阶段
---
## 阶段 0:规范文档(前置,必须先完成)
- [x] TASK-DOC-001:创建 `pmdocs/` 目录结构(changes/、adr/
- **目标**:建立 PM 文档存放规范
- **验收**:目录存在,CHANGELOG.md 已初始化
- **状态**:已完成
- [x] TASK-DOC-002:生成 `pmdocs/0-req-RSMS.md`(需求文档)
- **目标**:固化需求范围、角色定义、状态机、NFR、验收标准
- **映射**REQ-AUTH~REQ-SYS, NFR-*, AC-*
- **验收**:文档已创建,内容覆盖全部 REQ 编号
- **状态**:已完成
- [x] TASK-DOC-003:生成 `pmdocs/1-prd-RSMS.md`(产品需求文档)
- **目标**:固化功能清单、UI/UX 规范、公共组件规划、版本规划
- **映射**PRD-FUNC-001~018SCENE-001~004
- **验收**:文档已创建,公共组件规划表已填充
- **状态**:已完成
- [x] TASK-DOC-004:生成本文档 `pmdocs/2-task-RSMS.md`
- **目标**:任务拆分、编号、依赖、验收标准
- **映射**:所有 REQ、PRD-FUNC
- **验收**:本文档已创建,所有任务有验收标准
- **状态**:已完成
- [x] TASK-DOC-005:创建 `run.md`(运行手册)
- **目标**:固化安装、启动、构建、迁移命令
- **依赖**TASK-DOC-004
- **验收**`run.md` 存在且所有命令可执行
- **状态**:已完成
---
## 阶段 1:前端模块化重构
### 1.1 架构重构
- [x] TASK-FE-001:创建前端目录结构
- **目标**:将单文件 `App.tsx` 拆分为模块化目录
- **依赖**TASK-DOC-005run.md 中的构建命令验证)
- **验收**:目录结构创建完成,import 路径调整后应用能正常启动
- **状态**:已完成
- [x] TASK-FE-002:抽取 Layout 组件
- **目标**`PageLayout``Sidebar``TopBar` 独立
- **验收**`App.tsx` 中 Layout 部分替换为 `<PageLayout>`,功能行为不变
- **状态**:已完成
- [x] TASK-FE-003:抽取 UI 基础组件
- **目标**`StatusBadge``EmptyState``LoadingBar` 独立为文件
- **验收**:各组件独立导出,App.tsx 中的内联版本替换为 import
- **状态**:已完成
- [x] TASK-FE-004:抽取 PageHeading / PanelHeader / MetricCard
- **目标**:复用组件独立文件
- **验收**:复用点替换为 import,Props 接口独立导出
- **状态**:已完成
- [x] TASK-FE-005:抽取数据展示组件
- **目标**`RecordTable`(含 `review` / `compact` 模式)独立
- **验收**:工作记录页、审核中心、档案页均使用同一组件
- **状态**:已完成
- [x] TASK-FE-006:抽取领域组件
- **目标**`CreateRecordModal``RecordDrawer` 独立为 `modals/` 文件
- **验收**:新建和详情交互行为与重构前一致
- **状态**:已完成
- [x] TASK-FE-007:抽取页面组件
- **目标**:每个页面(Dashboard / WorkList / ReviewCenter / People / Stations / Archive / Notices)独立文件
- **验收**`App.tsx` 变为路由级条件渲染,所有页面功能可用
- **状态**:已完成
- [x] TASK-FE-009:创建 Context(角色与 Toast
- **目标**`RoleContext` 管理当前角色,`ToastContext` 管理全局提示
- **验收**:全局 Toast 正常工作,角色切换后所有页面响应正确
- **状态**:已完成
- [x] TASK-FE-010:抽取 FilterBar 组件
- **目标**`FilterBar`(搜索 + 筛选下拉 + 重置)抽象为可复用组合组件
- **验收**:工作记录页和人员管理页使用同一 FilterBar
- **状态**:已完成
- [x] TASK-FE-011:抽取 Pagination 组件
- **目标**:分页组件(当前 MVP 暂不使用,但预留)
- **验收**:组件存在,数据量大时分页可用
- **状态**:已完成
- [x] TASK-FE-012:样式模块化
- **目标**:将 `styles.css` 按组件/页面拆分,引入 CSS 变量文件
- **验收**:构建后样式与重构前一致,无布局错位
- **状态**:已完成
- [x] TASK-FE-013:构建验证
- **目标**`npm run build` 通过,`npm run lint` 无错误
- **依赖**TASK-FE-001 ~ TASK-FE-012 全部完成
- **验收**:构建产物正常,dist/ 输出正确
- **状态**:已完成(2026-08-01
### 1.2 功能增强
- [x] TASK-FE-014:人员管理增删改
- **目标**:总部可新增/编辑/停用人员;分站可编辑本站人员
- **映射**REQ-USER-001, REQ-USER-002
- **依赖**TASK-BE-006
- **验收**
- 总部管理员可新增人员,填写姓名/站点/职务/入站时间后保存成功
- 编辑后数据持久化,再次查询反映最新值
- 停用后该人员不可登录(后端权限拦截)
- **优先级**P0
- **状态**:已完成(2026-08-01
- [x] TASK-FE-015:记者站管理增删改
- **目标**:总部可新增/编辑/停用记者站
- **映射**REQ-ORG-001, REQ-ORG-002
- **依赖**TASK-BE-007
- **验收**
- 总部管理员可新增站点,填写名称/编码/地区/负责人后保存成功
- 站点列表反映最新数据
- **优先级**P1
- **状态**:已完成(2026-08-01
- [x] TASK-FE-016:通知公告发布
- **目标**:总部可发布带附件的通知;用户可查看已读状态
- **映射**REQ-NOTICE-003, REQ-NOTICE-004
- **依赖**TASK-BE-008
- **验收**
- 总部可创建通知,选择接收范围(全部/站点/角色)
- 通知列表显示已读/未读统计
- 重要通知需确认回执
- **优先级**P1
- **状态**:已完成(2026-08-01
- [x] TASK-FE-017:个人中心
- **目标**:用户查看/编辑个人资料、修改密码(模拟)
- **映射**REQ-HOME-004
- **验收**:个人资料页正常展示和编辑
- **优先级**P2
- **状态**:已完成(2026-08-01
---
## 阶段 2:后端扩展
### 2.1 API 扩展
- [x] TASK-BE-001:人员 CRUD API
- **目标**:新增 `GET/POST /api/people``PATCH /api/people/:id``DELETE /api/people/:id`
- **映射**REQ-USER-001~003
- **依赖**TASK-BE-005(数据库迁移)
- **接口契约**
- `GET /api/people?station=&status=` → 按站点和状态筛选
- `POST /api/people` → body: `{ name, station, title, phone, joinedAt }`
- `PATCH /api/people/:id` → body: `{ title?, status?, phone? }`
- **验收**:curl 测试全部接口返回正确状态码和数据
- **状态**:已完成(2026-08-01
- [x] TASK-BE-002:记者站 CRUD API
- **目标**:新增 `GET/POST /api/stations``PATCH /api/stations/:id`
- **映射**REQ-ORG-001, REQ-ORG-002
- **依赖**TASK-BE-005
- **验收**:同上
- **状态**:已完成(2026-08-01
- [x] TASK-BE-003:通知公告 API
- **目标**:新增 `GET/POST /api/notices``PATCH /api/notices/:id``GET /api/notices/:id/receipts`
- **映射**REQ-NOTICE-001~004
- **验收**
- 总部发布通知后,其他角色可见
- 已读回执记录正确
- **状态**:已完成(2026-08-01
- [x] TASK-BE-004:数据统计 API
- **目标**:新增 `GET /api/stats/overview``GET /api/stats/records``GET /api/stats/scores`
- **映射**REQ-STAT-001~003
- **验收**
- 总部返回全国维度统计
- 分站返回本站维度统计
- 记者返回本人维度统计
- **状态**:已完成(2026-08-01
- [x] TASK-BE-005:数据库迁移脚本
- **目标**:将 inline schema 改为独立 migration 文件 + runner
- **依赖**:无
- **验收**
- `npm run db:migrate` 执行成功
- `npm run db:seed` 可重新初始化数据
- `npm run db:reset` 清空后重新迁移
- **优先级**P0
- **状态**:已完成(2026-08-01
### 2.2 数据库迁移
- [x] TASK-BE-006`people` 表迁移
- **文件**`migrations/003_create_people.sql`
- **状态**:已完成(2026-08-01
- [x] TASK-BE-007`stations` 表迁移
- **文件**`migrations/004_create_stations.sql`
- **状态**:已完成(2026-08-01
- [x] TASK-BE-008`notices``notice_receipts` 表迁移
- **文件**`migrations/005_create_notices.sql`
- **状态**:已完成(2026-08-01
- [x] TASK-BE-009`audit_logs` 表索引优化
- **文件**`migrations/006_optimize_indexes.sql`
- **状态**:已完成(2026-08-01
### 2.3 后端测试
- [x] TASK-BE-010API 接口集成测试
- **目标**:使用 Node.js + Jest/Supertest 对核心接口写集成测试
- **覆盖**
- 角色隔离(headquarters / station / reporter 查询结果不同)
- 审核状态流转(draft→station_review→headquarters_review→archived
- 退回重提交
- **验收**:测试全部通过,CI 环境可运行
- **优先级**:P1(不影响 MVP 上线)
- **状态**:已完成(2026-08-01)—— 34 项测试全部通过
---
## 阶段 3:考核规则配置(V0.2)
- [x] TASK-RULE-001:考核规则数据模型
- **目标**:新增 `rules``rule_items``scores`
- **字段**:规则 ID、版本、指标、计分公式、上限/下限、生效时间
- **验收**:规则可 CRUD,变更历史可查
- **状态**:已完成(2026-08-01)—— 迁移 007/008 已执行
- [x] TASK-RULE-002:规则可视化配置界面
- **目标**:总部可在后台配置考核规则
- **验收**:配置发布后,新提交记录使用新规则计分
- **状态**:已完成(2026-08-01)—— RulesPage、CreateRuleModal 已完成
- [x] TASK-RULE-003:规则试算
- **目标**:规则变更前用历史数据试算,输出预估得分变化
- **验收**:试算结果可展示,不影响实际数据
- **状态**:已完成(2026-08-01)—— scores/compute API 已实现试算逻辑
- [x] TASK-RULE-004:后端 rules/scores API
- **目标**rules CRUD、activatescores 查询、计算触发
- **验收**:54 项集成测试全部通过
- **状态**:已完成(2026-08-01
- [x] TASK-RULE-005:前端考核配置页面
- **目标**RulesPage(列表+创建+编辑+激活);ScoresPage(评分结果+明细弹窗)
- **验收**tsc + vite build 通过,V0.2 新增 API 已接入
- **状态**:已完成(2026-08-01
- [x] TASK-RULE-008:集成测试(rules/scores API
- **验收**54 项测试全部通过
- **状态**:已完成(2026-08-01
- [x] TASK-RULE-010:前端构建验证
- **验收**`npm run build` 通过(tsc + vite build
- **状态**:已完成(2026-08-01
- [x] TASK-RULE-011:考核配置前端优化
- **目标**:规则预览 Drawer、指标试算交互、评分排行页面
- **验收**RulesPage 含规则预览 Drawer + 试算评分;LeaderboardPage 完整
- **状态**:已完成(2026-08-01
- [x] TASK-RULE-006:个人中心接入考核数据
- **目标**:当前登录记者/站点查看个人考核得分与排名
- **验收**:个人中心展示考核卡片,接入 stats/scores API
- **状态**:已完成(2026-08-01
- [x] TASK-RULE-007:积分排行榜
- **目标**:全站记者/站点考核得分排名
- **验收**LeaderboardPage 按记者/站点分组排行,切换周期
- **状态**:已完成(2026-08-01
---
## 阶段 4ADR 架构决策
- [x] TASK-ADR-001:生成 ADR-001(技术栈选型)
- **目标**:记录 React+Vite+Express+SQLite 选型理由
- **内容**:备选方案、最终决策、权衡分析
- **状态**:已完成(2026-08-01
- [x] TASK-ADR-002:生成 ADR-002(认证方案)
- **目标**:记录角色模拟方案及升级路径
- **内容**:当前方案(header 传参)、二期目标(JWT+真实认证)
- **风险**:header 可被伪造,仅适合内部演示
- **状态**:已完成(2026-08-01
---
## 阶段 5`run.md` 创建
- [x] TASK-DOC-005:创建 `run.md`
- **目标**:固化所有运行、构建、迁移、测试命令
- **依赖**TASK-BE-005(数据库迁移脚本完成)
- **内容**
- 技术栈清单(Node.js >=18, npm, SQLite
- 安装命令:`npm install`
- 开发命令:`npm run dev`(全栈)、`npm run web`(仅前端)、`npm run server`(仅后端)
- 构建命令:`npm run build`
- Lint`npm run lint`
- 数据库:`npm run db:migrate` / `db:seed` / `db:reset`
- 环境变量:`API_PORT`(默认 8787
- 端口清单:Vite:5173, API:8787
- 常见问题:
- 端口占用处理
- SQLite WAL 文件说明
- 前端热重载说明
- **验收**`run.md` 存在,新人按文档可启动完整项目
- **状态**:已完成(2026-08-01
---
## 任务依赖关系图
```
TASK-DOC-004 ──▶ TASK-DOC-005
TASK-BE-005 ──┬──▶ TASK-FE-001 ──▶ TASK-FE-002 ──▶ TASK-FE-003 ──▶ TASK-FE-004
│ │
│ ▼
│ TASK-FE-005 ──▶ TASK-FE-006 ──▶ TASK-FE-007
│ │
│ ▼
│ TASK-FE-008 ──▶ TASK-FE-009 ──▶ TASK-FE-010
│ │ │
│ ▼ ▼
│ TASK-FE-011 ──▶ TASK-FE-012 ──▶ TASK-FE-013
├──▶ TASK-BE-001 ──▶ TASK-FE-014
├──▶ TASK-BE-002 ──▶ TASK-FE-015
├──▶ TASK-BE-003 ──▶ TASK-FE-016
├──▶ TASK-BE-004
├──▶ TASK-BE-006
├──▶ TASK-BE-007
├──▶ TASK-BE-008
└──▶ TASK-BE-009
TASK-BE-010 ──(并行,不阻塞主要流程)
TASK-ADR-001, TASK-ADR-002 ──(随时可做)
```
---
## 优先级排序(建议执行顺序)
### 第一梯队(P0,必须完成)
1. `TASK-DOC-004` — 确认任务文档(本任务)
2. `TASK-DOC-005` — 创建 `run.md`
3. `TASK-FE-001` — 前端目录结构
4. `TASK-FE-002` — Layout 组件
5. `TASK-FE-005` — RecordTable 抽取
6. `TASK-FE-006` — CreateRecordModal / RecordDrawer 抽取
7. `TASK-FE-007` — 页面组件拆分
8. `TASK-FE-013` — 构建验证
### 第二梯队(P1,MVP 完善)
9. `TASK-FE-014` — 人员管理增删改
10. `TASK-BE-005` — 数据库迁移脚本
11. `TASK-BE-006` — people 表迁移
12. `TASK-BE-001` — 人员 CRUD API
13. `TASK-BE-007` — stations 表迁移
14. `TASK-BE-002` — 记者站 CRUD API
15. `TASK-FE-015` — 记者站管理页面
16. `TASK-BE-008` — notices 表迁移
17. `TASK-BE-003` — 通知公告 API
18. `TASK-FE-016` — 通知发布页面
19. `TASK-BE-009` — 索引优化
### 第三梯队(P1/P2,增强功能)
20. `TASK-BE-004` — 数据统计 API
21. `TASK-FE-010` — FilterBar 抽取
22. `TASK-FE-011` — Pagination 抽取
23. `TASK-FE-017` — 个人中心
24. `TASK-BE-010` — 后端集成测试
25. `TASK-ADR-001` — ADR-001 补录
26. `TASK-ADR-002` — ADR-002 创建
### 第四梯队(V0.2 考核规则)
> 关联变更文档:`pmdocs/changes/2026-08-01-001-V0.2考核规则配置.md`
> 依赖:V0.1 MVP 全部完成(TASK-BE-010 集成测试通过)
**阶段 A:数据模型与迁移**
27. `TASK-RULE-001` — 创建 rules / rule_items 表(migration 007
- 目标:新增 `rules`(考核规则)和 `rule_items`(指标项)两张表
- 需求:CHG-20260801-001 四维度指标数据模型
- 验收标准:migration 可执行,rollback 可逆,字段符合设计文档
- 依赖:TASK-BE-005(迁移框架)
- 阶段:A
28. `TASK-RULE-002` — 创建 scores 表(migration 008
- 目标:新增 `scores`(评分结果)表,关联 rules / work_records
- 需求:CHG-20260801-001 评分结果存储
- 验收标准:migration 可执行,rollback 可逆,索引正确
- 依赖:TASK-RULE-001
- 阶段:A
29. `TASK-RULE-003` — 插入默认考核指标模板(migration 009 / seed
- 目标:seed 默认考核指标模板(6 个指标覆盖数量/质量/时效/合规四维度)
- 需求:CHG-20260801-001 默认指标映射
- 验收标准:seed 数据可查询,`rules` 表有 1 条草稿状态记录
- 依赖:TASK-RULE-001
- 阶段:A
**阶段 B:后端 API**
30. `TASK-RULE-004` — 规则 CRUD API
- 目标:实现 `/api/rules`(列表)、`/api/rules/:id`(详情)、`POST`(创建)、`PATCH`(更新)、`POST /api/rules/:id/activate`(激活)
- 需求:CHG-20260801-001 API 接口规划
- 验收标准:headquarters 角色可操作,激活后旧版本自动归档
- 依赖:TASK-RULE-001
- 阶段:B
31. `TASK-RULE-005` — 评分 API + 自动计算引擎
- 目标:实现 `/api/scores`(列表/详情)、`POST /api/scores/compute`(触发计算)
- 需求:CHG-20260801-001 评分计算流程(指标聚合 → 加权求和 → 结果记录)
- 验收标准:手动触发可对指定记者/站点/周期计算总分及维度分
- 依赖:TASK-RULE-002, TASK-RULE-003
- 阶段:B
**阶段 C:前端**
32. `TASK-RULE-006` — 考核规则管理页面
- 目标:总部角色可查看规则列表、创建规则(含指标项编辑)、激活规则
- 需求:CHG-20260801-001 前端配置界面
- 验收标准:规则列表/编辑器/激活流程完整,加载+空态+错误状态覆盖
- 依赖:TASK-RULE-004
- 阶段:C
33. `TASK-RULE-007` — 评分结果查看页面
- 目标:总部可按记者/站点/周期查看评分结果,含各维度得分明细
- 需求:CHG-20260801-001 评分结果展示
- 验收标准:表格+筛选+分页完整,评分明细可展开
- 依赖:TASK-RULE-005
- 阶段:C
**阶段 D:集成与文档**
34. `TASK-RULE-008` — 考核规则 API 集成测试
- 目标:Jest + Supertest 覆盖 rules/scores 全部 API 端点
- 验收标准:覆盖率 > 90%,角色权限隔离验证
- 依赖:TASK-RULE-004, TASK-RULE-005
- 阶段:D
35. `TASK-RULE-009``run.md` 更新(V0.2 数据库结构 + API)
- 目标:补充 V0.2 新增表结构、API 接口到 `run.md`
- 依赖:TASK-RULE-001, TASK-RULE-002, TASK-RULE-004, TASK-RULE-005
- 阶段:D
36. `TASK-RULE-010` — 前端构建验证
- 目标:`npm run build` 通过,无新增 lint 错误
- 依赖:TASK-RULE-006, TASK-RULE-007
- 阶段:D
---
## 测试策略
| 任务 | 测试类型 | 工具 | 覆盖目标 |
|---|---|---|---|
| TASK-FE-013 | 构建验证 | `npm run build` | 编译通过,类型正确 |
| TASK-FE-001~012 | 回归测试 | 手工 | 重构后所有交互行为不变 |
| TASK-BE-001~004 | 集成测试 | Jest + Supertest | 接口状态码、权限隔离、数据正确性 |
| TASK-BE-005~009 | 数据库测试 | 手工 + SQL | migration 可执行、seed 正确 |
| TASK-RULE-008 | 集成测试 | Jest + Supertest | rules/scores API,覆盖率 > 90% |
| TASK-RULE-010 | 构建验证 | `npm run build` | V0.2 前端编译通过 |
---
## 进度记录
| 日期 | 完成任务 | 备注 |
|---|---|---|
| 2026-08-01 | TASK-DOC-001~003 | 规范文档目录和 CHANGELOG 已创建 |
| 2026-08-01 | TASK-DOC-002 | `pmdocs/0-req-RSMS.md` 已创建 |
| 2026-08-01 | TASK-DOC-003 | `pmdocs/1-prd-RSMS.md` 已创建 |
| 2026-08-01 | TASK-DOC-004 | `pmdocs/2-task-RSMS.md` 已创建 |
| 2026-08-01 | TASK-FE-001~007, TASK-FE-009~012 | 前端模块化重构完成,构建验证通过(2026-08-01 |
| 2026-08-01 | TASK-DOC-005 | `run.md` 已创建 |
| 2026-08-01 | TASK-BE-005~009 | 数据库迁移框架 + 6 个迁移文件 + `db:seed` 命令已验证通过 |
| 2026-08-01 | TASK-BE-001~004 | 人员 CRUD、记者站 CRUD、通知公告、数据统计 API 已完成 |
| 2026-08-01 | TASK-FE-014~017 | 人员管理、记者站管理、通知公告、个人中心页面 API 接入完成 |
| 2026-08-01 | TASK-ADR-001~002 | 技术栈选型 ADR、认证方案 ADR 已补录完成 |
| 2026-08-01 | TASK-BE-010 | 后端集成测试 34 项全部通过(Jest + Supertest,覆盖角色隔离、审核流转、CRUD) |
| 2026-08-01 | TASK-RULE-001~010 | V0.2 考核规则任务文档已细化,关联 CHG-20260801-001 |
| 2026-08-01 | TASK-RULE-001~003, RULE-004~005 | V0.2 考核规则后端 API + 前端页面已完成,集成测试 54 项全部通过,构建验证通过 |
| 2026-08-01 | TASK-RULE-009 | `run.md` 已更新(测试命令、迁移管理、V0.2 表结构与 API 文档) |
| 2026-08-01 | TASK-RULE-006/011 | 个人中心接入考核数据 + 考核配置前端优化(含规则预览 Drawer + 试算评分 + LeaderboardPage |
+31
View File
@@ -0,0 +1,31 @@
# Changelog
需求与 PM 文档变更索引。
## 2026-08-01 V0.2 考核规则完成
| 类型 | 编号 | 主题 | 状态 |
|---|---|---|---|
| 变更文档 | `pmdocs/changes/2026-08-01-001-V0.2考核规则配置.md` | V0.2 考核规则配置设计 | ✅ 完成 |
| 迁移 | `007_create_rules.sql` | rules + rule_items 表 | ✅ 完成 |
| 迁移 | `008_create_scores.sql` | scores 表 | ✅ 完成 |
| 迁移 | `009_seed_default_rules.sql` | 默认考核指标模板 | ✅ 完成 |
| 任务 | `TASK-RULE-001~003` | 考核规则数据模型 + 配置界面 + 试算逻辑 | ✅ 完成 |
| 任务 | `TASK-RULE-004` | 后端 rules/scores API14 个端点) | ✅ 完成 |
| 任务 | `TASK-RULE-005` | 前端考核规则管理 + 评分结果页面 | ✅ 完成 |
| 任务 | `TASK-RULE-007` | 积分排行榜(reporter/station 双视图) | ✅ 完成 |
| 任务 | `TASK-RULE-008` | 集成测试 54 项全部通过 | ✅ 完成 |
| 任务 | `TASK-RULE-010` | 前端构建验证(tsc + vite build | ✅ 完成 |
| 任务 | `TASK-RULE-009` | `run.md` 更新(测试/迁移/V0.2 API 文档) | ✅ 完成 |
| 任务 | `TASK-RULE-006` | 个人中心接入考核数据(考核卡片 + 排名信息) | ✅ 完成 |
| 任务 | `TASK-RULE-011` | 考核配置前端优化(规则预览 Drawer + 试算评分) | ✅ 完成 |
## 2026-08-01 V0.1 MVP 完成
| 类型 | 编号 | 主题 | 状态 |
|---|---|---|---|
| 任务 | `TASK-BE-010` | 后端 API 集成测试(34 项全部通过) | ✅ 完成 |
| 任务 | `TASK-BE-001~004` | 后端 API(人员 CRUD、记者站 CRUD、通知公告、数据统计) | ✅ 完成 |
| 任务 | `TASK-FE-014~017` | 前端页面(人员管理、记者站管理、通知公告、个人中心) | ✅ 完成 |
| 任务 | `TASK-ADR-001~002` | 架构决策记录(技术栈选型、认证方案) | ✅ 完成 |
| 任务 | `TASK-DOC-005` | `run.md` 运行手册 | ✅ 完成 |
@@ -0,0 +1,61 @@
# ADR-RSMS-001:技术栈选型
> ADR 编号:ADR-001
> 状态:已决策(代码已先行,补录)
> 日期:2026-07-31
> 项目:RSMS
## 背景
项目启动时需要确定前端框架、后端框架和数据库选型。项目为内部管理后台,初期规模可控,但需考虑 37 个站点、约 500 名记者的数据增长。
## 选项
### 前端
| 选项 | 优点 | 缺点 |
|---|---|---|
| A. React + Vite + TypeScript | 生态成熟、类型安全、Vite 热重载快、团队熟悉度高 | 学习曲线存在(对新手) |
| B. Vue + Vite + TypeScript | 上手快、单文件组件直观 | 团队 React 经验更丰富 |
| C. 原生 HTML + JS | 无依赖 | 不可维护 |
### 后端
| 选项 | 优点 | 缺点 |
|---|---|---|
| A. Express.js + Node.js | 轻量、路由灵活、JSON 原生、团队熟悉 | 需自行处理结构化代码 |
| B. Spring Boot (Java) | 生态完善、企业级 | 需要 JDK 环境、启动慢 |
| C. Django (Python) | 快速开发、内置 ORM | GIL 限制并发、对团队熟悉度低 |
### 数据库
| 选项 | 优点 | 缺点 |
|---|---|---|
| A. SQLite(WAL 模式) | 零配置、文件级、无服务进程、适合本地开发 | 并发写入受限(当前规模可接受) |
| B. PostgreSQL | 关系型权威、功能强大、并发好 | 需要安装运行、增加运维成本 |
| C. JSON 文件 | 零配置 | 无查询能力、无法处理关联 |
## 决策
- **前端**React + Vite + TypeScript(选项 A
- **后端**Express.js + Node.js(选项 A
- **数据库**SQLiteWAL 模式)(选项 A
## 理由
1. **React + Vite**:团队已有 React 经验;Vite 提供极快的热重载体验;TypeScript 提供编译期类型检查,减少运行时错误;Recharts 作为数据可视化方案与 React 生态无缝集成。
2. **Express.js**:轻量、路由直观;JSON 作为 API 响应格式零序列化成本;`node --watch` 支持开发热重载;无额外配置。
3. **SQLite**:零运维成本,文件即数据库;WAL 模式支持读写并发(读不阻塞写);数据文件可随 Git 管理(需注意 .gitignore);当前规模(37 站 x 500 人)完全在 SQLite 处理能力内。
## 升级路径
| 组件 | 升级触发条件 | 目标方案 |
|---|---|---|
| 数据库 | 并发写入成为瓶颈(预估 > 100 并发写入/秒) | 迁移至 PostgreSQL |
| 后端 | 业务逻辑复杂度增加 | 考虑 NestJS 或 DDD 架构重构 |
| 前端 | 页面数量 > 30 或团队 > 5 人 | 引入 React Router、状态管理(Redux Toolkit |
## 风险
- SQLite 在极高并发写入场景下可能成为瓶颈(当前评估:低风险)
- 前后端均使用 JS/TS,技术栈统一,全栈可复用类型定义(未来引入 tRPC 可进一步增强类型安全)
+56
View File
@@ -0,0 +1,56 @@
# ADR-RSMS-002:认证方案
> ADR 编号:ADR-002
> 状态:已决策(角色模拟)
> 日期:2026-08-01
> 项目:RSMS
## 背景
系统需要识别用户身份并实施数据权限隔离。在 MVP 阶段无需接入真实身份认证体系,但需设计一个可升级的认证架构。
## 选项
| 选项 | 描述 | 适用阶段 |
|---|---|---|
| A. 角色模拟(header 传参) | 前端切换角色,HTTP header `x-user-role` 传递值,后端按值查询数据 | MVP / 演示 |
| B. Session + Cookie | 后端维护 session,基于用户名密码登录 | 首版上线 |
| C. JWT Token | 无状态令牌,携带用户 ID 和角色,可扩展 | 生产环境 |
| D. 微信 OAuth2 | 微信账号登录,与人员档案绑定 | 移动端小程序 |
## 决策
**MVP(当前)**:选项 A — 角色模拟(header 传参)
**二期目标**:选项 C — JWT Token + 真实登录
**三期目标**:选项 D — 微信 OAuth2(若实现小程序端)
## 选项 A 的实施
```
前端 role-switcher<select>
x-user-role: headquarters | station | reporter
后端中间件读取 header → 注入 req.user 对象
数据访问层按 req.user.role + req.user.station + req.user.name 过滤
```
- **优点**:零门槛,无需注册账号;方便演示和开发测试
- **缺点**:安全性极低,header 可被任意伪造;仅适合内部演示环境
## 升级至 JWT 的迁移路径
1. 新增 `/api/auth/login` 接口,接收用户名密码,返回 JWT
2. 新增 JWT 中间件,验证令牌并注入 `req.user`
3. 保留 header `x-user-role` 作为兼容层(降级方案)
4. 前端将 token 存储于 `localStorage``httpOnly` Cookie
5. 移除前端 role-switcher,替换为登录页
## 风险
- **选项 A 当前风险**:任何人可以通过修改 header 访问任意角色数据
- **缓解**:当前环境为本地开发/内网,不暴露于公网;上线前必须完成 JWT 改造
- **硬编码人员**`server/index.js` 中的 `identities` 对象需改为从数据库读取真实用户表
@@ -0,0 +1,111 @@
// 考核规则数据模型设计
// 基于当前已有表结构(work_records, audit_logs, people, stations)和业务逻辑推导
## 一、设计背景
V0.1 工作记录审核流程中,每条记录由分站初审(station_review)、总部终审(headquarters_review)后归档(archived),分数由审核人手动给定。
V0.2 需要将分数评定从"手动输入"改为"规则驱动":总部配置考核规则(指标项、权重、上限下限),系统根据规则自动计算得分。
## 二、核心实体
### 2.1 考核规则(rules
| 字段 | 类型 | 说明 |
|---|---|---|
| id | INTEGER PK | 自增 ID |
| code | TEXT UNIQUE | 规则编码,如 `RULE_2026_01` |
| name | TEXT NOT NULL | 规则名称,如"2026年第三季度考核规则" |
| description | TEXT | 规则说明 |
| period_type | TEXT NOT NULL | 考核周期类型:`monthly` / `quarterly` / `annual` |
| period_start | TEXT | 规则生效开始日期(YYYY-MM-DD |
| period_end | TEXT | 规则生效结束日期(YYYY-MM-DD |
| status | TEXT NOT NULL | 规则状态:`draft` / `active` / `archived` |
| created_by | TEXT | 创建人姓名 |
| created_at | TEXT | 创建时间 |
| updated_at | TEXT | 更新时间 |
### 2.2 考核指标项(rule_items
| 字段 | 类型 | 说明 |
|---|---|---|
| id | INTEGER PK | 自增 ID |
| rule_id | INTEGER FK | 所属规则 ID |
| category | TEXT NOT NULL | 指标类别:`quality`(质量)/ `quantity`(数量)/ `efficiency`(时效)/ `compliance`(合规) |
| name | TEXT NOT NULL | 指标名称,如"原创报道数量" |
| metric_key | TEXT NOT NULL | 指标计算字段(映射到 work_records 的字段或聚合方式) |
| weight | REAL NOT NULL | 权重(0~1,所有 rule_item 权重之和应为 1 |
| min_score | REAL DEFAULT 0 | 最低分 |
| max_score | REAL DEFAULT 100 | 最高分 |
| formula_type | TEXT NOT NULL | 计算方式:`count`(计数)/ `rate`(比率)/ `avg_score`(均分)/ `custom`(自定义公式) |
| formula_params | TEXT | JSON,公式参数,如 `{"threshold": 10, "above_bonus": 5}` |
| display_order | INTEGER DEFAULT 0 | 显示顺序 |
| created_at | TEXT | 创建时间 |
### 2.3 评分结果(scores
| 字段 | 类型 | 说明 |
|---|---|---|
| id | INTEGER PK | 自增 ID |
| code | TEXT UNIQUE | 评分编码,如 `SCORE_2026Q1_BJ_001` |
| rule_id | INTEGER FK | 使用的规则 ID |
| record_id | TEXT FK | 工作记录 ID |
| reporter | TEXT NOT NULL | 记者姓名 |
| station | TEXT NOT NULL | 站点名称 |
| period | TEXT NOT NULL | 考核周期标识,如 `2026-Q1` |
| total_score | REAL | 总分 |
| quality_score | REAL | 质量维度得分 |
| quantity_score | REAL | 数量维度得分 |
| efficiency_score | REAL | 时效维度得分 |
| compliance_score | REAL | 合规维度得分 |
| item_details | TEXT | JSON,每项指标的详细得分 |
| computed_at | TEXT | 计算时间 |
| created_at | TEXT | 创建时间 |
## 三、评分计算流程
1. **规则激活**:总部在后台发布规则,`rules.status = 'active'`,设置 `period_start` / `period_end`
2. **触发评分**:工作记录从 `returned` 重新提交后,或在考核周期结束时批量计算。
3. **指标聚合**:对指定记者、站点、周期内的所有 `archived` 状态记录,按 `rule_items.metric_key` 聚合。
4. **加权求和**\(\text{total} = \sum_i \text{weight}_i \times \text{score}_i\)
5. **结果记录**:写入 `scores` 表,同时更新 `work_records.score` 字段。
## 四、API 接口规划
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /api/rules | 查询规则列表 |
| GET | /api/rules/:id | 查询规则详情(含指标项) |
| POST | /api/rules | 创建规则(含指标项) |
| PATCH | /api/rules/:id | 更新规则 |
| POST | /api/rules/:id/activate | 激活规则 |
| GET | /api/scores | 查询评分结果(按记者/站点/周期筛选) |
| GET | /api/scores/:id | 评分详情 |
| POST | /api/scores/compute | 触发评分计算 |
## 五、Migration 规划
- `migrations/007_create_rules.sql``rules``rule_items`
- `migrations/008_create_scores.sql``scores`
- `migrations/009_seed_default_rules.sql` — 插入默认考核指标模板
## 六、前端配置界面
总部角色可访问"考核规则管理"页面:
- 规则列表:显示规则名称、周期类型、状态、发布时间
- 规则编辑器:拖拽排序指标项,设置权重滑块,预览计算结果
- 规则试算:用历史数据模拟评分,预估变化幅度
## 七、待确认事项
- [x] 考核周期如何定义:按日历季度,还是自定义起止日期?
**日历季度 + 自定义起止日期两种都支持**`period_type` 字段:`monthly` / `quarterly` / `annual` / `custom`
- [x] 指标计算字段(`metric_key`)具体映射到哪些 `work_records` 字段?
→ 见下表:
- 数量:`COUNT(*)` 按 reporter+station+period 聚合
- 质量:`AVG(score)``work_records``audit_logs` 聚合
- 时效:`occurred_date``created_at` 差值判断
- 合规:`status = 'archived'` 占比
- [x] 规则激活后,历史已评分记录是否重新计算?
**不自动重算**。已归档记录保留原分;新增记录使用激活规则;提供手动重算入口
- [x] 是否支持规则版本管理(每次修改生成新版本)?
**编辑生成新版本**。旧版本状态变为 `archived`,新版本为 `draft`;已有 `scores` 记录绑定旧规则版本,不受影响
+20
View File
@@ -0,0 +1,20 @@
%PDF-1.4
1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj
2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj
3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]/Contents 5 0 R/Resources<</Font<</F1 4 0 R>>>>>>endobj
4 0 obj<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>endobj
5 0 obj<</Length 80>>stream
BT /F1 20 Tf 80 700 Td (Proof Document) Tj ET
endstream endobj
xref
0 6
0000000000 65535 f
0000000009 00000 n
0000000058 00000 n
0000000115 00000 n
0000000213 00000 n
0000000284 00000 n
trailer<</Size 6/Root 1 0 R>>
startxref
400
%%EOF
+20
View File
@@ -0,0 +1,20 @@
%PDF-1.4
1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj
2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj
3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]/Contents 5 0 R/Resources<</Font<</F1 4 0 R>>>>>>endobj
4 0 obj<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>endobj
5 0 obj<</Length 80>>stream
BT /F1 20 Tf 80 700 Td (Proof Document) Tj ET
endstream endobj
xref
0 6
0000000000 65535 f
0000000009 00000 n
0000000058 00000 n
0000000115 00000 n
0000000213 00000 n
0000000284 00000 n
trailer<</Size 6/Root 1 0 R>>
startxref
400
%%EOF
+20
View File
@@ -0,0 +1,20 @@
%PDF-1.4
1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj
2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj
3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]/Contents 5 0 R/Resources<</Font<</F1 4 0 R>>>>>>endobj
4 0 obj<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>endobj
5 0 obj<</Length 80>>stream
BT /F1 20 Tf 80 700 Td (Proof Document) Tj ET
endstream endobj
xref
0 6
0000000000 65535 f
0000000009 00000 n
0000000058 00000 n
0000000115 00000 n
0000000213 00000 n
0000000284 00000 n
trailer<</Size 6/Root 1 0 R>>
startxref
400
%%EOF
+20
View File
@@ -0,0 +1,20 @@
%PDF-1.4
1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj
2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj
3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]/Contents 5 0 R/Resources<</Font<</F1 4 0 R>>>>>>endobj
4 0 obj<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>endobj
5 0 obj<</Length 80>>stream
BT /F1 20 Tf 80 700 Td (Proof Document) Tj ET
endstream endobj
xref
0 6
0000000000 65535 f
0000000009 00000 n
0000000058 00000 n
0000000115 00000 n
0000000213 00000 n
0000000284 00000 n
trailer<</Size 6/Root 1 0 R>>
startxref
400
%%EOF
+20
View File
@@ -0,0 +1,20 @@
%PDF-1.4
1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj
2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj
3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]/Contents 5 0 R/Resources<</Font<</F1 4 0 R>>>>>>endobj
4 0 obj<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>endobj
5 0 obj<</Length 80>>stream
BT /F1 20 Tf 80 700 Td (Proof Document) Tj ET
endstream endobj
xref
0 6
0000000000 65535 f
0000000009 00000 n
0000000058 00000 n
0000000115 00000 n
0000000213 00000 n
0000000284 00000 n
trailer<</Size 6/Root 1 0 R>>
startxref
400
%%EOF
+502
View File
@@ -0,0 +1,502 @@
# 全国记者站管理系统需求规格说明书
> 文档版本:V0.1(基于《全国记者站管理小程序初步设想修改版》整理)
> 文档状态:需求初稿,供业务评审、原型设计和技术方案设计使用
> 编制日期:2026-07-31
## 1. 文档说明
### 1.1 编制目的
本文档将初步设想转化为可讨论、可设计、可开发和可验收的系统需求。原文未明确的业务规则统一列入“待确认事项”,在确认前不作为最终实现依据。
### 1.2 术语
| 术语 | 定义 |
| --- | --- |
| 总部 / 总站 | 全国记者站管理工作的总部管理机构 |
| 记者站 / 分站 / 站点 | 纳入系统管理的 37 个记者站之一 |
| 总部管理员 | 负责规则、权限、复核、统计及全局监管的用户 |
| 分站负责人 | 负责本站人员管理、工作初审及考核汇总的用户 |
| 记者 | 负责工作填报、材料上传和个人信息查询的用户 |
| 工作记录 | 记者提交的一次稿件、作品、培训、临时工作等业务记录 |
| 考核 | 按规则对工作记录进行审核、计分、汇总和评价的过程 |
### 1.3 需求优先级
- P0:首期上线必需,缺失将导致核心业务闭环不可用。
- P1:重要能力,宜在首期或紧随其后的版本实现。
- P2:增强能力,可在核心流程稳定后建设。
## 2. 项目概述
### 2.1 建设背景
当前记者站管理存在数据分散、统计口径不统一、沟通链路长、档案查询不便、人工考核容易出错及决策数据不足等问题。系统面向全国 37 个记者站,将分散工作整合至统一平台。
### 2.2 建设目标
1. 实现人员、工作、考核、通知和统计的统一管理。
2. 实现日常工作线上填报、分级审核、自动统计和全程留痕。
3. 为每位记者建立长期保存、可追溯的个人电子档案。
4. 统一考核标准和统计口径,提高考核公平性与透明度。
5. 为总部掌握全国运行情况、考核评价和资源配置提供数据支持。
### 2.3 建设原则
- 统一管理:统一组织、人员、指标、数据和权限体系。
- 流程规范:核心业务按标准流程线上流转。
- 数据共享:同源数据在授权范围内复用,减少重复填报。
- 考核闭环:填报、审核、复核、计分、统计、归档全程贯通。
- 权限可控:按角色、组织和数据范围授权。
- 全程留痕:关键业务和管理操作可追溯。
### 2.4 系统边界
系统由以下部分构成:
- 小程序端:供记者和分站负责人移动填报、审核与查询。
- 后台管理系统:供总部管理员进行配置、复核、统计和系统管理。
- 服务端与数据库:承载业务逻辑、文件管理、统计计算、身份权限及日志。
首期不默认包含财务、人事薪酬、新闻采编发布、稿酬结算和外部媒体平台自动同步;如需建设,应另行确认接口及业务边界。
## 3. 用户与权限
### 3.1 角色职责
| 角色 | 主要职责 | 默认数据范围 |
| --- | --- | --- |
| 总部管理员 | 组织和制度管理、考核规则配置、总部复核、通知发布、全局统计、权限监管 | 全国全部站点及人员 |
| 分站负责人 | 本站人员维护、填报初审、本站考核汇总、通知落实 | 所属站点及本站人员 |
| 记者 | 工作填报、材料上传、通知查看、成绩和档案查询、个人资料维护 | 本人数据及授权公开信息 |
### 3.2 权限矩阵
| 功能 | 总部管理员 | 分站负责人 | 记者 |
| --- | --- | --- | --- |
| 站点管理 | 增删改查 | 查看本站 | 查看所属站 |
| 人员管理 | 全局管理 | 管理本站人员 | 查看/维护本人可编辑信息 |
| 工作填报 | 查看 | 可查看本站、按授权代填 | 新建、编辑草稿、提交本人记录 |
| 分站初审 | 查看/必要时干预 | 审核本站记录 | 查看本人审核状态 |
| 总部复核 | 复核、退回 | 查看结果 | 查看本人结果 |
| 考核规则 | 配置、发布 | 查看 | 查看适用规则 |
| 通知公告 | 全局发布与管理 | 接收、按授权转发/发布本站通知 | 接收与确认 |
| 数据统计 | 全局统计与导出 | 本站统计与导出 | 本人成绩与档案 |
| 账号与权限 | 全局管理 | 无或仅协助人员绑定 | 管理本人账号安全 |
权限应支持一人多角色、角色启停和站点数据隔离。具体授权粒度由业务评审确认。
## 4. 总体业务流程
### 4.1 日常考核主流程
1. 记者新建工作记录,填写业务字段并上传证明材料。
2. 记者保存草稿或提交;提交后记录进入“待分站审核”。
3. 分站负责人核验真实性和完整性,填写初审意见及初评分,选择通过或退回。
4. 初审通过后进入“待总部复核”;退回后记者修改并重新提交。
5. 总部管理员抽查或逐条复核,确认、调整或退回考核结果。
6. 复核通过后,系统依据生效规则计算得分并汇总统计。
7. 已确认结果自动归入记者个人档案,保留规则版本和审核轨迹。
### 4.2 建议状态机
`草稿 → 待分站审核 → 待总部复核 → 已通过/已归档`
异常分支:
- 分站退回:`待分站审核 → 已退回 → 草稿/重新提交`
- 总部退回分站:`待总部复核 → 退回分站 → 待总部复核`
- 总部退回记者:`待总部复核 → 已退回 → 草稿/重新提交`
- 撤回、作废、更正仅在权限和时限满足时允许,并记录原因及操作日志。
## 5. 功能需求
### 5.1 身份认证与账号
| 编号 | 优先级 | 需求 |
| --- | --- | --- |
| FR-AUTH-001 | P0 | 系统应支持小程序用户身份登录,并将账号绑定至系统人员档案。 |
| FR-AUTH-002 | P0 | 系统应识别用户角色、所属站点、账号状态和数据权限。 |
| FR-AUTH-003 | P0 | 后台管理系统应提供安全登录、退出和会话超时控制。 |
| FR-AUTH-004 | P0 | 未绑定、停用或离职账号不得访问受保护业务数据。 |
| FR-AUTH-005 | P1 | 系统应支持一名用户拥有多个角色,并可切换当前工作身份。 |
| FR-AUTH-006 | P1 | 系统应支持账号绑定、解绑、重置及异常登录处置。 |
### 5.2 组织与站点管理
| 编号 | 优先级 | 需求 |
| --- | --- | --- |
| FR-ORG-001 | P0 | 总部管理员应能维护总部、37 个记者站及其层级关系。 |
| FR-ORG-002 | P0 | 站点信息至少包括名称、编码、行政区域、地址、负责人、联系方式、状态和成立时间。 |
| FR-ORG-003 | P0 | 系统应支持人员调站、负责人变更和站点启停,并保存历史记录。 |
| FR-ORG-004 | P1 | 系统应展示全国地图及记者站分布,点击标记可进入站点详情。 |
| FR-ORG-005 | P1 | 总部可按地区、站点状态和人员规模检索、筛选站点。 |
### 5.3 人员管理
| 编号 | 优先级 | 需求 |
| --- | --- | --- |
| FR-USER-001 | P0 | 总部管理员可新增、编辑、查询、启停和导入人员信息。 |
| FR-USER-002 | P0 | 分站负责人仅可管理所属站点的人员信息,敏感字段受权限控制。 |
| FR-USER-003 | P0 | 人员信息至少包括姓名、人员编号、所属站点、职务、入站时间、联系方式、在职状态和账号绑定状态。 |
| FR-USER-004 | P0 | 系统应支持在职、离职、调动等状态,并保留任职及站点变更历史。 |
| FR-USER-005 | P1 | 支持按姓名、站点、职务、状态等条件组合查询和导出。 |
| FR-USER-006 | P1 | 记者可查看本人资料,并仅修改总部允许自助维护的字段。 |
### 5.4 个人电子档案
| 编号 | 优先级 | 需求 |
| --- | --- | --- |
| FR-PROFILE-001 | P0 | 系统应为每位记者建立唯一、长期保存的电子档案。 |
| FR-PROFILE-002 | P0 | 档案应聚合基本信息、文字稿件、视频作品、图片作品、培训、获奖和年度考核记录。 |
| FR-PROFILE-003 | P0 | 复核通过的工作和考核结果应自动归档,避免二次录入。 |
| FR-PROFILE-004 | P0 | 档案记录应显示来源、发生时间、审核状态、得分和证明附件。 |
| FR-PROFILE-005 | P1 | 档案应支持按年度、类型和状态筛选,并支持授权导出。 |
| FR-PROFILE-006 | P1 | 调站或离职后档案仍应保留,历史数据不得因组织变化丢失。 |
| FR-PROFILE-007 | P2 | 系统可形成个人成长轨迹和能力画像,为评优、培养与资源配置提供参考。 |
### 5.5 工作填报与材料管理
| 编号 | 优先级 | 需求 |
| --- | --- | --- |
| FR-WORK-001 | P0 | 记者可按业务类型新建工作记录,至少支持文字稿件、视频供稿、图片供稿、重要报道、培训参与、临时工作和其他。 |
| FR-WORK-002 | P0 | 系统应支持草稿保存、编辑、提交、查看详情及复制已有记录。 |
| FR-WORK-003 | P0 | 填报字段应可按工作类型配置,包括标题、发生/刊发日期、媒体/平台、链接、数量、说明及证明材料等。 |
| FR-WORK-004 | P0 | 系统应校验必填项、数据格式、附件类型/大小及重复记录风险。 |
| FR-WORK-005 | P0 | 提交后普通用户不得直接修改;被退回后可依据意见修改并重新提交。 |
| FR-WORK-006 | P0 | 支持图片、文档、视频或链接等材料上传/引用,并进行访问权限控制。 |
| FR-WORK-007 | P1 | 用户可按日期、类型、状态和关键词查询本人或权限范围内的工作记录。 |
| FR-WORK-008 | P1 | 系统应记录每次提交版本,保留修改前后内容、操作者和时间。 |
### 5.6 审核与日常考核
| 编号 | 优先级 | 需求 |
| --- | --- | --- |
| FR-ASSESS-001 | P0 | 分站负责人应在待办中接收本站待审核记录并完成初审。 |
| FR-ASSESS-002 | P0 | 初审应支持通过、退回,填写审核意见,并按规则完成初评分。 |
| FR-ASSESS-003 | P0 | 总部管理员应对初审通过记录进行复核,支持确认、调整和退回。 |
| FR-ASSESS-004 | P0 | 审核操作应记录处理人、处理时间、意见、处理前后状态和分数变化。 |
| FR-ASSESS-005 | P0 | 系统应按生效考核规则自动计算单项、月度和年度得分。 |
| FR-ASSESS-006 | P0 | 规则变更不得静默改变已归档结果;重新计算必须经授权并留痕。 |
| FR-ASSESS-007 | P0 | 记者可查看本人各项得分、审核意见、汇总成绩及适用规则。 |
| FR-ASSESS-008 | P1 | 分站负责人可查看本站考核进度、成绩汇总和异常记录。 |
| FR-ASSESS-009 | P1 | 总部可按配置采用逐条复核或抽查复核,并记录抽查策略。 |
| FR-ASSESS-010 | P1 | 系统应提供超时待办提醒和审核时效统计。 |
| FR-ASSESS-011 | P1 | 系统应支持授权更正、申诉或复议流程,原结果与更正结果均保留。 |
### 5.7 考核规则配置
| 编号 | 优先级 | 需求 |
| --- | --- | --- |
| FR-RULE-001 | P0 | 总部管理员可配置考核项目、指标、计分方式、上限/下限和适用范围。 |
| FR-RULE-002 | P0 | 规则应具有版本号、生效时间、失效时间和发布状态。 |
| FR-RULE-003 | P0 | 工作记录应绑定其计算时适用的规则版本,确保结果可追溯。 |
| FR-RULE-004 | P1 | 发布前应支持规则预览、试算和冲突检查。 |
| FR-RULE-005 | P1 | 规则发布、修改、停用应经过授权并记录审计日志。 |
### 5.8 通知公告与待办
| 编号 | 优先级 | 需求 |
| --- | --- | --- |
| FR-NOTICE-001 | P0 | 总部管理员可创建、编辑、定时发布、撤回和归档通知公告。 |
| FR-NOTICE-002 | P0 | 通知可指定全部人员、指定站点、指定角色或指定人员为接收对象。 |
| FR-NOTICE-003 | P0 | 小程序首页应展示最新通知,用户可查看详情及附件。 |
| FR-NOTICE-004 | P1 | 重要通知应支持已读/未读和确认回执统计。 |
| FR-NOTICE-005 | P0 | 系统应聚合待填报、待审核、被退回及其他待处理事项。 |
| FR-NOTICE-006 | P1 | 系统可通过小程序订阅消息等合规渠道提醒用户,具体渠道待确认。 |
### 5.9 数据统计与报表
| 编号 | 优先级 | 需求 |
| --- | --- | --- |
| FR-STAT-001 | P0 | 总部应能查看全国、地区、站点、人员、时间和工作类型等维度的统计。 |
| FR-STAT-002 | P0 | 统计指标至少包括填报数量、审核进度、通过/退回数量、考核得分和人员排名。 |
| FR-STAT-003 | P0 | 分站负责人仅可查看本站统计,记者仅可查看本人统计。 |
| FR-STAT-004 | P0 | 报表结果应与明细数据可下钻核对,并显示统计口径和数据更新时间。 |
| FR-STAT-005 | P1 | 支持按筛选条件导出标准报表,导出行为受权限控制并留痕。 |
| FR-STAT-006 | P1 | 支持形成月报、季报、年报及站点/个人对比趋势。 |
| FR-STAT-007 | P2 | 提供可视化驾驶舱,展示全国分布、核心指标、趋势和异常预警。 |
### 5.10 首页与个人中心
| 编号 | 优先级 | 需求 |
| --- | --- | --- |
| FR-HOME-001 | P0 | 首页应按角色展示常用入口,至少包括通知公告、工作填报、考核成绩、我的档案、待办事项和个人中心。 |
| FR-HOME-002 | P0 | 首页应显示待办数量和最新通知,点击可直达对应列表或详情。 |
| FR-HOME-003 | P1 | 不同角色的首页入口和数据摘要应按权限动态配置。 |
| FR-HOME-004 | P0 | 个人中心应提供个人资料、所属组织、账号设置和退出登录。 |
### 5.11 系统管理与审计
| 编号 | 优先级 | 需求 |
| --- | --- | --- |
| FR-SYS-001 | P0 | 系统应提供角色、菜单、功能和数据权限配置。 |
| FR-SYS-002 | P0 | 系统应记录登录、人员/组织变更、规则发布、审核、导出、删除和权限调整等关键操作。 |
| FR-SYS-003 | P0 | 审计日志至少包含操作者、时间、来源、对象、动作、结果及必要的变更摘要。 |
| FR-SYS-004 | P0 | 业务数据原则上采用逻辑删除;删除、作废和恢复须授权并留痕。 |
| FR-SYS-005 | P1 | 提供字典、附件策略、消息模板及业务参数配置。 |
## 6. 核心数据需求
### 6.1 核心实体
| 实体 | 关键字段(建议) |
| --- | --- |
| 站点 | 站点 ID、编码、名称、地区、地址、负责人、联系方式、状态、成立时间 |
| 用户账号 | 用户 ID、登录标识、绑定人员、角色、状态、最近登录信息 |
| 人员档案 | 人员 ID、姓名、编号、所属站点、职务、入站/离站时间、联系方式、状态 |
| 任职历史 | 人员、站点、职务、开始/结束时间、变更原因 |
| 工作记录 | 记录 ID、人员、站点、类型、标题、日期、业务字段、状态、当前版本 |
| 附件 | 文件 ID、关联对象、文件名、类型、大小、存储位置、上传人、校验值 |
| 审核记录 | 业务记录、审核层级、处理人、结果、意见、分数、时间 |
| 考核规则 | 规则 ID、版本、指标、计分公式、适用范围、生效区间、状态 |
| 考核结果 | 人员、周期、指标、原始值、得分、规则版本、确认状态 |
| 通知公告 | 标题、正文、附件、发布范围、发布时间、状态、发布人 |
| 阅读回执 | 通知、用户、已读时间、确认时间 |
| 操作日志 | 操作者、动作、对象、时间、来源、结果、变更摘要 |
### 6.2 数据规则
1. 每个站点、人员、工作记录和规则版本均应有全局唯一标识。
2. 工作记录同时保存填报时所属站点,避免人员调动影响历史统计。
3. 已归档考核结果保存规则版本、计算输入和审核链路,支持还原计算过程。
4. 附件与业务记录的访问权限保持一致,不得通过直链绕过鉴权。
5. 统计数据应可追溯至业务明细;离线汇总应明确刷新时间。
6. 手机号等个人信息应按最小必要原则采集和展示,并进行脱敏。
## 7. 非功能需求
### 7.1 安全与隐私
| 编号 | 优先级 | 需求 |
| --- | --- | --- |
| NFR-SEC-001 | P0 | 所有受保护接口必须完成身份认证和服务端权限校验。 |
| NFR-SEC-002 | P0 | 数据按总部、站点和个人范围隔离,禁止仅依赖前端隐藏实现权限。 |
| NFR-SEC-003 | P0 | 传输过程使用 HTTPS;密码、令牌及敏感配置不得明文存储。 |
| NFR-SEC-004 | P0 | 个人信息采集、使用、导出、留存和删除应符合适用的数据与个人信息保护要求。 |
| NFR-SEC-005 | P0 | 文件上传应校验类型、大小及安全风险,下载应鉴权。 |
| NFR-SEC-006 | P1 | 高风险操作宜支持二次确认或增强认证,并具备异常访问告警能力。 |
### 7.2 性能与容量
| 编号 | 优先级 | 需求 |
| --- | --- | --- |
| NFR-PERF-001 | P0 | 常规列表、详情和提交操作在正常网络及设计并发下,服务端 95 分位响应时间宜不超过 2 秒(文件上传和复杂报表除外)。 |
| NFR-PERF-002 | P0 | 月度/年度统计可采用异步计算;用户应能看到计算状态和数据更新时间。 |
| NFR-PERF-003 | P0 | 容量设计应覆盖 37 个站点的人员、业务记录、审计日志及多年附件增长,具体基线在调研阶段确定。 |
### 7.3 可靠性与运维
| 编号 | 优先级 | 需求 |
| --- | --- | --- |
| NFR-OPS-001 | P0 | 核心数据应实施定期备份,并通过恢复演练验证可用性。 |
| NFR-OPS-002 | P0 | 系统应具备应用、接口、数据库、任务和存储监控及故障告警。 |
| NFR-OPS-003 | P0 | 提交、审核和计分等关键操作应具备幂等或防重复机制。 |
| NFR-OPS-004 | P1 | 系统应明确可用性、RPO 和 RTO 指标,具体数值由业务与技术评审确定。 |
### 7.4 易用性与兼容性
| 编号 | 优先级 | 需求 |
| --- | --- | --- |
| NFR-UX-001 | P0 | 高频任务应尽量在少量层级内完成,表单需提供清晰校验和错误定位。 |
| NFR-UX-002 | P0 | 小程序应兼容项目确定的主流微信版本、iOS 和 Android 系统版本。 |
| NFR-UX-003 | P1 | 列表应提供搜索、筛选、分页/加载更多及空状态反馈。 |
| NFR-UX-004 | P1 | 用户提交中断时应尽量保存草稿,避免重复录入。 |
## 8. 首期范围建议(MVP
### 8.1 纳入首期
1. 账号登录、人员绑定及三级角色权限。
2. 37 个站点和人员基础信息管理。
3. 工作分类填报、附件上传和记录查询。
4. 分站初审、总部复核、退回修改和流程留痕。
5. 基础考核规则、自动计分、月度/年度汇总。
6. 个人电子档案自动归集与查询。
7. 通知公告、待办事项和基础消息提醒。
8. 全国/站点/个人基础统计和报表导出。
9. 后台权限、参数和审计日志。
### 8.2 后续增强
- 全国地图和管理驾驶舱。
- 规则试算、抽查策略、申诉复议和复杂排名。
- 个人能力画像、人才培养和智能分析。
- 与统一身份、媒体采编、组织人事或其他外部系统集成。
## 9. 验收场景
### AC-01 记者完成工作填报
- 已绑定且在职的记者可以新建规定类型的工作记录。
- 必填项或附件不符合要求时,系统明确提示且不允许提交。
- 提交成功后状态为“待分站审核”,记者不可直接篡改已提交内容。
- 分站负责人待办数量同步增加。
### AC-02 分站初审并退回
- 分站负责人只能审核本站记录。
- 退回时必须填写原因,记者能在待办和记录详情中查看。
- 记者修改并重新提交后,历史版本和原审核意见仍可追溯。
### AC-03 总部复核并归档
- 分站通过的记录进入总部复核队列。
- 总部确认后,系统按绑定规则版本计算得分。
- 记录进入已通过/已归档状态,并自动出现在个人档案及统计中。
- 审核人、时间、意见和分数变化完整记录。
### AC-04 数据权限隔离
- 记者无法访问他人的非公开档案和成绩。
- 分站负责人无法访问其他站点的受限数据。
- 总部管理员按授权查看全国数据。
- 通过修改前端参数或直接访问接口不能绕过上述限制。
### AC-05 统计可核对
- 总部可按年度、站点、人员和工作类型筛选统计。
- 汇总值可下钻至构成该数值的已授权明细。
- 导出结果与当前筛选条件、统计口径一致,并记录导出日志。
### AC-06 历史数据可追溯
- 人员调站后,历史记录仍归属于发生时站点,同时个人档案连续保留。
- 规则升级后,既有已归档结果不被自动改写。
- 被授权的管理员可查询关键记录的版本、审核和操作轨迹。
## 10. 实施阶段建议
1. 需求调研:梳理现行表单、考核制度、审批权限、统计报表及历史数据。
2. 方案设计:完成功能原型、流程、数据模型、权限矩阵和接口设计。
3. 开发测试:分模块开发,开展功能、权限、安全、性能和兼容性测试。
4. 试点运行:选取代表性记者站试点,验证填报负担、审核效率和统计准确性。
5. 全面推广:修正试点问题,开展培训和数据初始化,分批覆盖全国记者站。
## 11. 待确认事项及建议方案
以下建议可作为首期需求基线。标记为“立项前”的事项会影响总体架构、预算或合规,应在项目立项和技术选型前确认;标记为“设计前”的事项应在原型及数据库设计前确认;标记为“上线前”的事项可在开发期间细化,但必须在试点上线前定稿。
### 11.1 组织与站点数据
**待确认:**“37 个记者站”的正式名单、组织层级、编码和地图坐标数据来源。
**建议方案:**由总部提供并盖章/审批确认一份站点主数据表,采用总部统一编码且编码永久不复用。行政区划采用国家标准代码;地址由站点维护,总部审核。地图坐标在地址确认后通过合规地图服务获取,并允许人工校正。首期组织层级固定为“总部—记者站—人员”,预留上级站点字段但不启用更多层级。
**理由:**组织主数据是权限隔离、统计和历史归属的基础,不能依赖开发人员自行整理。
**决策时点:**设计前。
### 11.2 总部复核方式
**待确认:**总部采取全部逐条复核、按比例抽查,还是分类复核。
**建议方案:**首期采用“分类复核”:高价值、高分值、获奖、重要报道、被退回后重提及系统命中异常规则的记录必须逐条复核;普通记录按站点和月份随机抽查,建议初始比例为 20%,总部可配置。抽查未通过时,可提高该站点当期抽查比例至 100%。
**理由:**兼顾总部工作量与考核公信力,并能通过风险触发机制约束数据质量。
**决策时点:**设计前;抽查比例可在试点后调整。
### 11.3 工作记录字段与材料
**待确认:**各工作类型的字段、必填项、证明材料、重复判定及附件限制。
**建议方案:**先收集现行纸质/Excel 表单,形成“工作类型—字段—数据类型—必填—证明材料—计分指标”配置表并由业务负责人签字确认。通用字段包括标题、发生/刊发日期、媒体平台、作品链接、工作说明和证明材料。重复记录以“记者 + 类型 + 标题/链接 + 日期”组合预警,允许审核人确认非重复,不建议系统直接删除。单文件默认不超过 20 MB;视频首期优先提交合规链接或媒体资源编号。
**理由:**字段直接决定表单、数据模型和计分逻辑;重复只能预警,避免误伤同题连续报道。
**决策时点:**设计前。
### 11.4 考核规则
**待确认:**考核指标、公式、上限、周期、排名规则及规则变更审批。
**建议方案:**由总部形成正式《考核指标字典》,每项明确指标编码、数据来源、计分公式、单项/周期上限、适用对象和例外条件。规则采用版本化管理,按自然月统计、年度汇总;新版本仅影响生效日后的记录。规则发布实行“业务部门拟定—管理负责人复核—授权管理员发布”,上线前必须用历史样本试算。
**理由:**考核是系统核心,必须做到可解释、可重算、可追溯,不能把口头规则直接编码。
**决策时点:**立项后立即启动,开发计分模块前定稿。
### 11.5 业务分类与统计口径
**待确认:**“报送”“采用”的定义,以及纸媒、新媒体、视频、图片的分类口径。
**建议方案:**建立统一数据字典:“报送”指已向指定媒体/平台提交且有凭证;“采用”指已正式刊发/播出且可提供链接、版面、截图或平台记录。一次记录可有一个作品类型和多个发布渠道,但同一发布成果不得跨类型重复计分。纸媒、新媒体、视频、图片按主要成品形态分类,混合作品由业务规则指定主类型。
**理由:**先统一口径才能保证跨站点统计和排名公平。
**决策时点:**设计前。
### 11.6 代填、撤回与更正
**待确认:**分站负责人能否代记者填报、撤回或更正。
**建议方案:**原则上由记者本人填报。仅在账号故障、离岗或经批准的特殊情况下允许分站负责人代填,必须选择原因并标注“代填”。记者可在分站尚未处理前撤回;进入审核后只能由当前审核人退回。归档后不得直接修改,应发起更正申请,经分站和总部确认后生成新版本。
**理由:**既应对实际工作场景,又避免负责人代填导致责任不清或考核数据被静默修改。
**决策时点:**设计前。
### 11.7 申诉与复议
**待确认:**记者是否可对考核结果申诉以及办理时限。
**建议方案:**提供一次线上申诉机会。记者在结果发布后 5 个工作日内提交理由和材料,分站在 3 个工作日内提出意见,总部在 5 个工作日内作出最终决定。逾期关闭;特殊情况由总部管理员授权重开。申诉前后结果、处理意见和分数变化全部留痕。
**理由:**申诉机制是考核公平和纠错能力的重要保障。
**决策时点:**设计前。
### 11.8 通知发布与回执
**待确认:**分站能否发布通知,重要通知是否必须回执。
**建议方案:**总部可向全局或指定范围发布;分站负责人只能向本站人员发布,且不能使用总部名义。通知分为普通、重要、紧急三级:普通通知仅记录已读,重要和紧急通知要求确认回执;紧急通知对未确认人员进行提醒,并向发布者展示名单。
**理由:**分级发布可减少总部负担,回执仅用于重要事项可避免用户疲劳。
**决策时点:**设计前。
### 11.9 身份认证
**待确认:**采用微信身份、手机号、统一身份平台或组合方式。
**建议方案:**若单位已有统一身份平台,后台优先对接统一身份认证,小程序采用微信登录后与统一身份/人员编号绑定;若暂无统一身份平台,首期采用“微信身份 + 预置人员手机号/人员编号核验 + 管理员审核绑定”。不得仅凭微信昵称或手机号自动获得业务权限。后台管理员应使用增强认证,至少包括密码复杂度、登录失败锁定和二次验证。
**理由:**微信只能证明平台账号身份,不能单独证明其对应的组织人员及岗位权限。
**决策时点:**立项前。
### 11.10 小程序与后台用户边界
**待确认:**分站负责人是否使用后台管理系统。
**建议方案:**记者只使用小程序;分站负责人以小程序完成日常审核和待办,并开放精简后台用于批量人员维护、集中审核、统计导出;总部管理员主要使用后台,必要时通过小程序处理待办。两个端共用同一权限和业务接口,不重复建设规则。
**理由:**移动端适合即时处理,批量管理和复杂统计更适合电脑端。
**决策时点:**原型设计前。
### 11.11 历史数据迁移
**待确认:**历史人员、档案、稿件和考核数据的规模、质量及迁移方式。
**建议方案:**先开展数据盘点和抽样,分两批迁移:首批迁移在职人员、组织关系及最近 2 个完整年度的结构化考核数据;更早或质量较差的数据以只读附件/历史档案方式保存。提供标准导入模板,执行清洗、预校验、试迁移、业务核对和正式迁移,并保留迁移批次与错误报告。
**理由:**全量清洗成本通常不可控,优先迁移高频使用数据更利于按期上线。
**决策时点:**立项前完成盘点,试点前完成迁移方案。
### 11.12 报表、排名与公开范围
**待确认:**报表模板、统计口径、导出格式及排名公开范围。
**建议方案:**首期以现行总部月报、年报为准,统一提供 Excel 导出,必要时增加 PDF 定版报表。报表必须显示周期、筛选条件、口径版本和生成时间。记者默认只看本人分数和分项明细,不公开完整个人排名;分站负责人看本站人员,总部看全局。若开展评优,可单独发布经审核的结果名单。
**理由:**限制排名公开可降低不必要的个人信息暴露和内部争议,同时保留管理分析能力。
**决策时点:**设计前。
### 11.13 大文件与视频存储
**待确认:**视频采用系统存储、外部链接还是媒体资源平台,以及保存期限。
**建议方案:**首期不在业务数据库中存储视频文件。优先保存单位媒体资源平台编号或合规、稳定的访问链接,并上传封面/截图作为证明;确需上传时使用独立对象存储、受控临时访问地址和病毒/格式检测。视频原文件保存期限建议为 2 年,元数据、审核记录和归档证明长期保存,最终期限按档案制度调整。
**理由:**视频会显著增加存储、带宽、备份和合规成本,应与结构化业务数据分离。
**决策时点:**立项前。
### 11.14 留存、备份和可用性
**待确认:**数据保存年限、离职人员处置、备份周期、RPO/RTO 和可用性目标。
**建议方案:**个人档案、考核结果和审计轨迹原则上长期保存;普通运行日志保存不少于 1 年,具体依档案和安全制度确认。离职账号立即停用,档案转为只读,不删除历史业务。数据库每日增量、每周全量备份,并进行异地/跨故障域保存及季度恢复演练。首期建议可用性目标不低于 99.5%,RPO 不超过 24 小时,RTO 不超过 8 小时;正式生产目标由预算和部署架构复核。
**理由:**先设可执行的基线,再根据业务连续性和成本提高指标。
**决策时点:**立项前。
### 11.15 外部系统集成
**待确认:**是否对接采编、人事、统一身份、短信/消息、电子签章等系统。
**建议方案:**首期只纳入两类必要集成:身份认证(如已有统一身份平台)和小程序合规消息通知。人事数据先通过标准模板导入;采编平台在确认稿件唯一标识和开放接口后再对接;电子签章仅在存在正式签批法律效力需求时建设。所有集成采用独立接口层,避免核心业务绑定单一外部厂商。
**理由:**控制首期依赖和工期风险,同时为后续集成保留边界。
**决策时点:**立项前确认首期清单。
### 11.16 隐私、安全与部署合规
**待确认:**个人信息保护责任、部署位置、等保及其他安全合规要求。
**建议方案:**立项阶段指定数据负责人和系统安全负责人,形成个人信息清单、处理目的、授权范围、保存期限和权限矩阵。系统及数据优先部署在单位批准的境内基础设施,生产、测试环境隔离,测试环境使用脱敏数据。正式定级应由主管部门和安全合规人员确认;考虑系统包含全国人员档案、考核及管理数据,建议至少按网络安全等级保护第二级要求进行设计和评估,若主管单位认定更高等级则按其要求执行。上线前完成安全测试、权限核验、日志审计和个人信息保护检查。
**理由:**安全等级和部署位置会影响架构、采购、成本与验收,必须前置决策。
**决策时点:**立项前。
### 11.17 建议决策顺序
1. 立项前确认:身份认证、历史数据规模、大文件方案、可用性/备份、首期外部集成、安全与部署合规。
2. 设计前确认:组织主数据、复核模式、表单字段、考核规则、统计口径、操作权限、申诉、通知及报表公开范围。
3. 试点期间校准:抽查比例、办理时限、附件大小、提醒频率和性能容量基线。
4. 试点验收后固化:形成正式业务制度、数据字典、权限矩阵、考核规则和运维指标,作为全面推广依据。
## 12. 需求追踪说明
本文档中的需求编号应在后续原型、设计、开发任务、测试用例和验收记录中持续引用。经业务评审确认后的新增、修改或删除需求,应记录版本、变更原因、提出人、批准人和影响范围。
+409
View File
@@ -0,0 +1,409 @@
# 运行手册
> 本文档是项目运行的唯一事实来源。后续操作优先按本文档执行,若实际命令与文档不符,先验证真实行为,再更新本文档。
## 技术栈
| 层级 | 技术 | 版本 |
|---|---|---|
| 前端框架 | React + TypeScript | ^19 |
| 构建工具 | Vite | ^6 |
| 后端框架 | Express.js | ^4 |
| 数据库 | SQLiteWAL 模式) | 内置 node:sqlite |
| 包管理器 | npm | >= 18 |
| 图表库 | Recharts | latest |
| 图标库 | Lucide React | latest |
| 进程管理 | concurrently | latest |
| 测试框架 | Jest + Supertest | latest |
## 本地环境
### 环境变量
| 变量 | 默认值 | 说明 |
|---|---|---|
| `API_PORT` | `8787` | 后端 API 监听端口 |
### 端口清单
| 服务 | 地址 | 说明 |
|---|---|---|
| 前端开发服务器 | http://localhost:5177 | Vite 热重载 |
| 后端 API | http://localhost:8787 | Express REST API |
| 前端代理 | `/api``http://localhost:8787` | Vite proxy 配置 |
### 依赖服务
- 无外部依赖服务(SQLite 为内置文件数据库)
## 安装
```bash
npm install
```
> 若 `node_modules` 已存在,跳过此步骤。
## 开发命令
### 全栈启动(推荐)
```bash
npm run dev
```
同时启动:
- API 服务(`node --watch server/index.js`)监听 `127.0.0.1:8787`
- 前端 Vite 开发服务器监听 `localhost:5173`API 请求代理至 `8787`
### 单独启动
```bash
# 仅前端(需 API 已在运行)
npm run web
# 仅后端(需前端已在运行)
npm run server
```
### 后端启动(独立,不热重载)
```bash
npm run server:start
```
## 构建
```bash
npm run build
```
- 前端编译输出至 `dist/`
- TypeScript 类型检查:`tsc -b`
- 两者均通过才算构建成功
## 测试
```bash
npm test
```
运行 `server/tests/integration.test.cjs`,覆盖所有 API 端点的集成测试。测试使用独立临时数据库,测试完成后自动清理。
## 数据库
### 数据文件
- 开发数据库:`data/reporter-station.db`SQLite
- WAL 文件:`data/reporter-station.db-wal`Write-Ahead Log
- SHM 文件:`data/reporter-station.db-shm`
### 迁移管理
使用 `migrations/_runner.js` 管理所有 schema 变更:
```bash
# 查看迁移状态
node migrations/_runner.js --status
# 应用所有待执行迁移
node migrations/_runner.js migrate
# 执行指定迁移(幂等,CREATE TABLE IF NOT EXISTS
node migrations/_runner.js migrate --target 005
# 回滚最后一条迁移
node migrations/_runner.js rollback
# 重置数据库(删除并重建所有表,重新 seed)
node migrations/_runner.js reset
# 查看 seed 数据
node migrations/_runner.js --seed
```
迁移按文件名数字前缀顺序执行(001~009)。新增迁移时,使用下一个可用数字前缀。
### 数据库结构
**work_records**V0.1
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | TEXT PK | 主键,格式 `WK-YYYYMMDD-XXXX` |
| `title` | TEXT | 工作标题 |
| `type` | TEXT | 工作类型(文字稿件/视频供稿/...) |
| `reporter` | TEXT | 记者姓名 |
| `station` | TEXT | 所属记者站 |
| `occurred_date` | TEXT | 发生/刊发日期 |
| `platform` | TEXT | 媒体/平台 |
| `status` | TEXT | 状态(draft/station_review/headquarters_review/returned/archived |
| `score` | INTEGER | 考核得分(可为 null |
| `description` | TEXT | 工作说明 |
| `review_note` | TEXT | 审核意见 |
| `updated_at` | TEXT | 更新时间 |
**audit_logs**V0.1
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | INTEGER PK | 自增主键 |
| `record_id` | TEXT FK | 关联 work_records.id |
| `actor_role` | TEXT | 操作者角色 |
| `actor_name` | TEXT | 操作者姓名 |
| `action` | TEXT | 操作类型(submit/pass/return |
| `from_status` | TEXT | 原状态 |
| `to_status` | TEXT | 新状态 |
| `score` | INTEGER | 打分 |
| `note` | TEXT | 意见 |
| `created_at` | TEXT | 操作时间 |
**people**V0.1.1
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | INTEGER PK | 自增主键 |
| `code` | TEXT UNIQUE | 人员编码 |
| `name` | TEXT | 姓名 |
| `role` | TEXT | 角色(headquarters/station/reporter |
| `title` | TEXT | 职务 |
| `phone` | TEXT | 联系电话 |
| `station` | TEXT | 所属记者站 |
| `status` | TEXT | 状态(active/inactive |
| `joined_at` | TEXT | 入职日期 |
**stations**V0.1.1
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | INTEGER PK | 自增主键 |
| `code` | TEXT UNIQUE | 记者站编码 |
| `name` | TEXT | 记者站名称 |
| `region` | TEXT | 所属区域 |
| `established_at` | TEXT | 成立日期 |
**notices**V0.1.1
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | INTEGER PK | 自增主键 |
| `title` | TEXT | 通知标题 |
| `content` | TEXT | 通知内容 |
| `type` | TEXT | 类型(notice/circular/guide |
| `priority` | TEXT | 优先级(normal/important/urgent |
| `created_by` | TEXT | 创建人 |
| `created_at` | TEXT | 创建时间 |
| `expire_at` | TEXT | 过期时间 |
**rules**V0.2
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | INTEGER PK | 自增主键 |
| `code` | TEXT UNIQUE | 规则编码 |
| `name` | TEXT | 规则名称 |
| `description` | TEXT | 规则说明 |
| `period_type` | TEXT | 周期类型(quarterly/custom |
| `period_start` | TEXT | 周期开始日期 |
| `period_end` | TEXT | 周期结束日期 |
| `status` | TEXT | 状态(draft/active/archived |
| `version` | INTEGER | 版本号 |
| `parent_id` | INTEGER FK | 父版本 ID |
| `created_by` | TEXT | 创建人 |
| `created_at` | TEXT | 创建时间 |
| `updated_at` | TEXT | 更新时间 |
**rule_items**V0.2
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | INTEGER PK | 自增主键 |
| `rule_id` | INTEGER FK | 关联 rules.id |
| `category` | TEXT | 指标类别(quantity/quality/efficiency/compliance |
| `name` | TEXT | 指标名称 |
| `metric_key` | TEXT | 指标键名 |
| `weight` | REAL | 权重 |
| `min_score` | REAL | 最低分 |
| `max_score` | REAL | 最高分 |
| `formula_type` | TEXT | 计算公式类型 |
| `formula_params` | TEXT | 公式参数(JSON |
| `display_order` | INTEGER | 排序 |
| `enabled` | INTEGER | 是否启用 |
**scores**V0.2
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | INTEGER PK | 自增主键 |
| `rule_id` | INTEGER FK | 关联 rules.id |
| `station` | TEXT | 站点名称 |
| `period` | TEXT | 考核周期 |
| `period_type` | TEXT | 周期类型 |
| `total_score` | REAL | 综合得分 |
| `quality_score` | REAL | 质量分 |
| `quantity_score` | REAL | 数量分 |
| `efficiency_score` | REAL | 时效分 |
| `compliance_score` | REAL | 合规分 |
| `items` | TEXT | 明细(JSON 数组) |
| `computed_at` | TEXT | 计算时间 |
## API 接口文档
### 认证
所有 API 请求需携带 HTTP header
```
x-user-role: headquarters | station | reporter
```
### 核心接口
| 方法 | 路径 | 角色 | 说明 |
|---|---|---|---|
| GET | `/api/health` | 公开 | 健康检查 |
| GET | `/api/records` | 全部 | 查询工作记录(按角色隔离) |
| POST | `/api/records` | reporter/station | 新建记录 |
| PATCH | `/api/records/:id/review` | station/hq | 审核(通过/退回) |
| GET | `/api/records/:id/audit` | 全部 | 流转记录(按角色隔离) |
### 人员与站点(V0.1.1
| 方法 | 路径 | 角色 | 说明 |
|---|---|---|---|
| GET | `/api/people` | 全部 | 查询人员列表 |
| POST | `/api/people` | station | 新建人员 |
| PATCH | `/api/people/:id` | station/hq | 编辑人员 |
| GET | `/api/stations` | 全部 | 查询记者站列表 |
| POST | `/api/stations` | hq | 新建记者站 |
### 通知公告(V0.1.1
| 方法 | 路径 | 角色 | 说明 |
|---|---|---|---|
| GET | `/api/notices` | 全部 | 查询通知列表 |
| POST | `/api/notices` | hq | 发布通知 |
| POST | `/api/notices/:id/read` | 全部 | 标记已读 |
| GET | `/api/notices/unread-count` | 全部 | 未读数量 |
### 统计(V0.1.1
| 方法 | 路径 | 角色 | 说明 |
|---|---|---|---|
| GET | `/api/stats/overview` | 全部 | 总览统计(站点角色仅限本站) |
| GET | `/api/stats/records` | hq | 记录统计(支持按周期/站点/类型筛选) |
| GET | `/api/stats/scores` | hq | 评分统计(支持按周期/站点筛选) |
### 考核规则(V0.2
| 方法 | 路径 | 角色 | 说明 |
|---|---|---|---|
| GET | `/api/rules` | hq | 查询规则列表(支持 status 筛选) |
| GET | `/api/rules/:id` | hq | 查询规则详情(含指标项) |
| POST | `/api/rules` | hq | 创建规则(含指标项) |
| PATCH | `/api/rules/:id` | hq | 编辑规则(草稿状态) |
| POST | `/api/rules/:id/activate` | hq | 激活规则 |
### 评分(V0.2
| 方法 | 路径 | 角色 | 说明 |
|---|---|---|---|
| GET | `/api/scores` | hq | 查询评分列表(支持 period/station 筛选) |
| GET | `/api/scores/:id` | hq | 查询评分详情(含明细) |
| POST | `/api/scores/compute` | hq | 触发评分计算 |
### 请求示例
```bash
# 以总部管理员身份查询全部记录
curl -H "x-user-role: headquarters" http://localhost:8787/api/records
# 以记者身份提交新记录
curl -X POST http://localhost:8787/api/records \
-H "Content-Type: application/json" \
-H "x-user-role: reporter" \
-d '{"title":"测试稿件","type":"文字稿件","date":"2026-08-01","platform":"测试平台"}'
# 以分站负责人身份审核通过
curl -X PATCH http://localhost:8787/api/records/WK-202608-XXXX/review \
-H "Content-Type: application/json" \
-H "x-user-role: station" \
-d '{"decision":"pass","score":8,"note":"审核通过"}'
# 总部创建考核规则
curl -X POST http://localhost:8787/api/rules \
-H "Content-Type: application/json" \
-H "x-user-role: headquarters" \
-d '{"name":"2026年度Q3考核","period_type":"quarterly","items":[{"category":"quantity","name":"发稿数量","metric_key":"count_total","weight":0.4,"formula_type":"count","formula_params":{}},{"category":"quality","name":"审核得分","metric_key":"avg_score","weight":0.6,"formula_type":"avg_score","formula_params":{}}]}'
# 总部触发评分计算
curl -X POST http://localhost:8787/api/scores/compute \
-H "Content-Type: application/json" \
-H "x-user-role: headquarters" \
-d '{"rule_id":1,"period":"2026-Q3","period_type":"quarterly"}'
```
## 常见问题
### Q1:端口 8787 被占用
```bash
# 查找占用进程
lsof -i :8787
# 杀死进程(替换 PID
kill -9 <PID>
# 或换端口
API_PORT=8788 npm run dev
```
### Q2:端口 5173 被占用
```bash
lsof -i :5173
kill -9 <PID>
```
### Q3SQLite WAL 文件导致"database is locked"
- 通常因多个进程同时写入
- 解决:确保只有一个服务实例运行
- WAL 文件异常时,删除 `.db-wal``.db-shm` 后重启服务
### Q4:前端热重载不生效
- 确认 Vite dev server 正在运行(看终端输出 "ready in xxx ms"
- 清除浏览器缓存或使用无痕模式
### Q5:迁移执行失败
```bash
# 查看迁移状态
node migrations/_runner.js --status
# 检查是否有 pending 迁移
# 查看具体错误日志
```
### Q6TypeScript 类型错误
```bash
npx tsc -b --force # 强制重建类型缓存
```
### Q7`npm run build` 失败
1. 先确认 TypeScript 无误:`npx tsc -b`
2. 查看 Vite 构建输出中的具体错误
3. 检查是否有新增依赖未安装
### Q8seed 数据消失
- 原因:`work_records` 表已有数据时,seed 逻辑不执行
- 解决:手动删除数据库文件后重启服务,或使用 `node migrations/_runner.js reset`
## 后续开发参考
- 需求文档:`pmdocs/0-req-RSMS.md`
- 产品文档:`pmdocs/1-prd-RSMS.md`
- 任务文档:`pmdocs/2-task-RSMS.md`
- 架构决策:`pmdocs/adr/`
- 需求变更:`pmdocs/changes/`
- 原始需求参考:`req.md`
+1426
View File
File diff suppressed because it is too large Load Diff
+52
View File
@@ -0,0 +1,52 @@
// 测试环境准备:每个 suite 运行前初始化干净的测试数据库
const { DatabaseSync } = require('node:sqlite')
const { mkdirSync, readFileSync, readdirSync } = require('node:fs')
const { join } = require('node:path')
const { randomUUID } = require('node:crypto')
const ROOT = join(__dirname, '../..')
const testDataDir = join(ROOT, 'data', 'test-' + randomUUID().slice(0, 8))
mkdirSync(testDataDir, { recursive: true })
const TEST_DB_PATH = join(testDataDir, 'test.db')
process.env.TEST_DB_PATH = TEST_DB_PATH
process.env.API_PORT = '0'
// 初始化 schema
const db = new DatabaseSync(TEST_DB_PATH)
db.exec(`PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;`)
const migrationFiles = readdirSync(join(ROOT, 'migrations'))
.filter(f => f.endsWith('.sql') && !f.startsWith('_'))
.sort()
for (const file of migrationFiles) {
const sql = readFileSync(join(ROOT, 'migrations', file), 'utf-8')
db.exec(sql)
}
// 公共 seed 数据
const now = new Date().toISOString().slice(0, 10)
db.exec(`
INSERT INTO stations (code, name, region, address, leader, phone, established_at, status)
VALUES
('STAT_BJ', '北京记者站', '华北', '北京市朝阳区', '苏明远', '010-12345678', '${now}', 'active'),
('STAT_SH', '上海记者站', '华东', '上海市浦东新区', '王海涛', '021-87654321', '${now}', 'active'),
('STAT_GZ', '广州记者站', '华南', '广州市天河区', '李娜', '020-11112222', '${now}', 'inactive');
INSERT INTO people (code, name, station, title, phone, joined_at, status)
VALUES
('P_BJ_01', '林致远', '北京记者站', '总编辑', '13800000001', '${now}', 'active'),
('P_BJ_02', '苏明远', '北京记者站', '站长', '13800000002', '${now}', 'active'),
('P_BJ_03', '林晓', '北京记者站', '记者', '13800000003', '${now}', 'active'),
('P_SH_01', '王海涛', '上海记者站', '站长', '13900000001', '${now}', 'active'),
('P_SH_02', '张文', '上海记者站', '记者', '13900000002', '${now}', 'active');
INSERT INTO work_records (id, title, type, reporter, station, occurred_date, platform, status, score, description)
VALUES
('WK-20260801-0001', '采访人工智能大会', 'interview', '林晓', '北京记者站', '${now}', '新华社客户端', 'archived', 85, '报道 AI 前沿技术'),
('WK-20260801-0002', '深度调研报告', 'report', '林晓', '北京记者站', '${now}', '自主平台', 'archived', 90, '产业调研'),
('WK-20260801-0003', '突发新闻采集', 'news', '林晓', '北京记者站', '${now}', '微博', 'station_review', null, '地震新闻'),
('WK-20260801-0004', '市场分析', 'report', '张文', '上海记者站', '${now}', '财经网站', 'headquarters_review', 78, '金融市场');
`)
global.__TEST_DB_PATH = TEST_DB_PATH
+68
View File
@@ -0,0 +1,68 @@
// 测试环境准备:每个 suite 运行前初始化干净的测试数据库
import { DatabaseSync } from 'node:sqlite'
import { mkdirSync } from 'node:fs'
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { randomUUID } from 'crypto'
const __dirname = dirname(fileURLToPath(import.meta.url))
const root = join(__dirname, '../..')
const testDataDir = join(root, 'data', 'test-' + randomUUID().slice(0, 8))
mkdirSync(testDataDir, { recursive: true })
const TEST_DB_PATH = join(testDataDir, 'test.db')
process.env.API_PORT = '0' // 让系统分配空闲端口
export { TEST_DB_PATH }
// ── 初始化 schema ────────────────────────────────────────────────────────────
const db = new DatabaseSync(TEST_DB_PATH)
db.exec(`PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;`)
// 复用已有的 migration SQL 来初始化 schema
const migrationFiles = [
'001_create_initial_schema.sql',
'002_create_audit_logs.sql',
'003_create_people.sql',
'004_create_stations.sql',
'005_create_notices.sql',
'006_optimize_indexes.sql',
]
for (const file of migrationFiles) {
const path = join(root, 'migrations', file)
try {
const { readFileSync } = await import('node:fs')
const sql = readFileSync(path, 'utf-8')
db.exec(sql)
} catch {
// 忽略文件不存在错误
}
}
// ── 公共 seed 数据 ───────────────────────────────────────────────────────────
const now = new Date().toISOString().slice(0, 10)
db.exec(`
INSERT INTO stations (code, name, region, address, leader, phone, established_at, status)
VALUES
('STAT_BJ', '北京记者站', '华北', '北京市朝阳区', '苏明远', '010-12345678', '${now}', 'active'),
('STAT_SH', '上海记者站', '华东', '上海市浦东新区', '王海涛', '021-87654321', '${now}', 'active'),
('STAT_GZ', '广州记者站', '华南', '广州市天河区', '李娜', '020-11112222', '${now}', 'inactive');
INSERT INTO people (code, name, station, title, phone, joined_at, status)
VALUES
('P_BJ_01', '林致远', '北京记者站', '总编辑', '13800000001', '${now}', 'active'),
('P_BJ_02', '苏明远', '北京记者站', '站长', '13800000002', '${now}', 'active'),
('P_BJ_03', '林晓', '北京记者站', '记者', '13800000003', '${now}', 'active'),
('P_SH_01', '王海涛', '上海记者站', '站长', '13900000001', '${now}', 'active'),
('P_SH_02', '张文', '上海记者站', '记者', '13900000002', '${now}', 'active');
INSERT INTO work_records (id, title, type, reporter, station, occurred_date, platform, status, score, description)
VALUES
('WK-20260801-0001', '采访人工智能大会', 'interview', '林晓', '北京记者站', '${now}', '新华社客户端', 'archived', 85, '报道 AI 前沿技术'),
('WK-20260801-0002', '深度调研报告', 'report', '林晓', '北京记者站', '${now}', '自主平台', 'archived', 90, '产业调研'),
('WK-20260801-0003', '突发新闻采集', 'news', '林晓', '北京记者站', '${now}', '微博', 'station_review', null, '地震新闻'),
('WK-20260801-0004', '市场分析', 'report', '张文', '上海记者站', '${now}', '财经网站', 'headquarters_review', 78, '金融市场');
`)
export default db
+598
View File
@@ -0,0 +1,598 @@
// 集成测试:角色隔离验证 + 审核状态流转
const http = require('http')
const { spawn } = require('child_process')
const path = require('path')
const { mkdirSync, readFileSync, readdirSync } = require('node:fs')
const { DatabaseSync } = require('node:sqlite')
const { randomUUID } = require('node:crypto')
const ROOT = path.resolve(__dirname, '../..')
const API_PORT = 18787
const BASE_URL = `http://127.0.0.1:${API_PORT}`
let serverProc = null
// ── 在测试进程内创建独立测试数据库 ─────────────────────────────────────────
const testDataDir = path.join(ROOT, 'data', 'test-' + randomUUID().slice(0, 8))
mkdirSync(testDataDir, { recursive: true })
const TEST_DB_PATH = path.join(testDataDir, 'test.db')
const testDb = new DatabaseSync(TEST_DB_PATH)
testDb.exec(`PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;`)
const migrationFiles = readdirSync(path.join(ROOT, 'migrations'))
.filter(f => f.endsWith('.sql') && !f.startsWith('_')).sort()
for (const file of migrationFiles) {
testDb.exec(readFileSync(path.join(ROOT, 'migrations', file), 'utf-8'))
}
const now = new Date().toISOString().slice(0, 10)
testDb.exec(`
INSERT INTO stations (code, name, region, address, leader, phone, established_at, status)
VALUES
('STAT_BJ', '北京记者站', '华北', '北京市朝阳区', '苏明远', '010-12345678', '${now}', 'active'),
('STAT_SH', '上海记者站', '华东', '上海市浦东新区', '王海涛', '021-87654321', '${now}', 'active'),
('STAT_GZ', '广州记者站', '华南', '广州市天河区', '李娜', '020-11112222', '${now}', 'inactive');
INSERT INTO people (code, name, station, title, phone, joined_at, status)
VALUES
('P_BJ_01', '林致远', '北京记者站', '总编辑', '13800000001', '${now}', 'active'),
('P_BJ_02', '苏明远', '北京记者站', '站长', '13800000002', '${now}', 'active'),
('P_BJ_03', '林晓', '北京记者站', '记者', '13800000003', '${now}', 'active'),
('P_SH_01', '王海涛', '上海记者站', '站长', '13900000001', '${now}', 'active'),
('P_SH_02', '张文', '上海记者站', '记者', '13900000002', '${now}', 'active');
INSERT INTO work_records (id, title, type, reporter, station, occurred_date, platform, status, score, description)
VALUES
('WK-20260801-0001', '采访人工智能大会', 'interview', '林晓', '北京记者站', '${now}', '新华社客户端', 'archived', 85, '报道AI前沿技术'),
('WK-20260801-0002', '深度调研报告', 'report', '林晓', '北京记者站', '${now}', '自主平台', 'archived', 90, '产业调研'),
('WK-20260801-0003', '突发新闻采集', 'news', '林晓', '北京记者站', '${now}', '微博', 'station_review', null, '地震新闻'),
('WK-20260801-0004', '市场分析', 'report', '张文', '上海记者站', '${now}', '财经网站', 'headquarters_review', 78, '金融市场');
`)
testDb.close()
// ── 工具函数 ────────────────────────────────────────────────────────────────
function api(role, method, urlPath, body) {
return new Promise((resolve, reject) => {
const bodyStr = body ? JSON.stringify(body) : undefined
const opts = {
hostname: '127.0.0.1', port: API_PORT, path: urlPath, method,
headers: {
'Content-Type': 'application/json',
'x-user-role': role,
...(bodyStr ? { 'Content-Length': Buffer.byteLength(bodyStr) } : {}),
},
}
const req = http.request({ ...opts, path: encodeURI(urlPath) }, (res) => {
let data = ''
res.on('data', d => data += d)
res.on('end', () => {
try { resolve({ status: res.statusCode, body: JSON.parse(data) }) }
catch { resolve({ status: res.statusCode, body: data }) }
})
})
req.on('error', reject)
if (bodyStr) req.write(bodyStr)
req.end()
})
}
function sleep(ms) { return new Promise(r => setTimeout(r, ms)) }
beforeAll(async () => {
serverProc = spawn('node', ['server/index.js'], {
cwd: ROOT,
env: { ...process.env, API_PORT: String(API_PORT), DATABASE_PATH: TEST_DB_PATH, NODE_ENV: 'test' },
stdio: ['ignore', 'pipe', 'pipe'],
})
serverProc.on('error', err => { throw err })
// 等待服务器就绪
for (let i = 0; i < 60; i++) {
await sleep(200)
try {
const res = await fetch(`${BASE_URL}/api/health`)
if (res.ok) return
} catch {}
}
throw new Error(`测试服务器启动超时(端口 ${API_PORT}`)
}, 30000)
afterAll(() => { serverProc?.kill('SIGTERM') })
// ═══════════════════════════════════════════════════════════════════════════
// 测试集 1:角色隔离
// ═══════════════════════════════════════════════════════════════════════════
describe('角色隔离', () => {
describe('GET /api/records', () => {
it('总部可见所有站点记录', async () => {
const res = await api('headquarters', 'GET', '/api/records')
expect(res.status).toBe(200)
expect(Array.isArray(res.body)).toBe(true)
})
it('站点仅可见本站记录', async () => {
const res = await api('station', 'GET', '/api/records')
expect(res.status).toBe(200)
for (const r of res.body) expect(r.station).toBe('北京记者站')
})
it('记者仅可见本人记录', async () => {
const res = await api('reporter', 'GET', '/api/records')
expect(res.status).toBe(200)
for (const r of res.body) expect(r.reporter).toBe('林晓')
})
})
describe('POST /api/people(权限校验)', () => {
it('站点角色不能新增人员', async () => {
const res = await api('station', 'POST', '/api/people', {
name: '测试人员', station: '北京记者站', title: '记者',
})
expect(res.status).toBe(403)
})
it('记者角色不能新增人员', async () => {
const res = await api('reporter', 'POST', '/api/people', {
name: '测试人员', station: '北京记者站', title: '记者',
})
expect(res.status).toBe(403)
})
})
describe('DELETE /api/stations(权限校验)', () => {
it('站点角色不能删除记者站', async () => {
const list = await api('headquarters', 'GET', '/api/stations')
const bj = list.body.find(s => s.code === 'STAT_BJ')
const res = await api('station', 'DELETE', `/api/stations/${bj.id}`)
expect(res.status).toBe(403)
})
})
describe('POST /api/notices(权限校验)', () => {
it('站点角色不能发布通知', async () => {
const res = await api('station', 'POST', '/api/notices', {
title: '测试通知', content: '内容', priority: 'normal', scope: 'all',
})
expect(res.status).toBe(403)
})
})
})
// ═══════════════════════════════════════════════════════════════════════════
// 测试集 2:审核状态流转
// ═══════════════════════════════════════════════════════════════════════════
describe('审核状态流转', () => {
it('记者提交记录 -> station_review', async () => {
const res = await api('reporter', 'POST', '/api/records', {
title: '测试采访', type: 'interview', date: '2026-08-01', platform: '客户端',
description: '集成测试提交',
})
expect(res.status).toBe(201)
expect(res.body.status).toBe('station_review')
expect(res.body.reporter).toBe('林晓')
})
it('总部不能代替记者填报', async () => {
const res = await api('headquarters', 'POST', '/api/records', {
title: '总部代写', type: 'news', date: '2026-08-01', platform: '网站',
})
expect(res.status).toBe(403)
expect(res.body.message).toContain('不能代替记者')
})
it('站点审核通过 -> headquarters_review', async () => {
const list = await api('station', 'GET', '/api/records')
const pending = list.body.find(r => r.status === 'station_review')
if (!pending) { console.warn('无可待审记录,跳过'); return }
const res = await api('station', 'PATCH', `/api/records/${pending.id}/review`, {
decision: 'pass', score: 88, note: '测试通过',
})
expect(res.status).toBe(200)
expect(res.body.status).toBe('headquarters_review')
expect(res.body.score).toBe(88)
})
it('站点审核退回 -> returned(必须填原因)', async () => {
const list = await api('station', 'GET', '/api/records')
const pending = list.body.find(r => r.status === 'station_review')
if (!pending) { console.warn('无可待审记录,跳过'); return }
const res = await api('station', 'PATCH', `/api/records/${pending.id}/review`, {
decision: 'return', note: '材料不完整',
})
expect(res.status).toBe(200)
expect(res.body.status).toBe('returned')
})
it('站点退回时无 note -> 400', async () => {
const list = await api('station', 'GET', '/api/records')
const pending = list.body.find(r => r.status === 'station_review')
if (!pending) { console.warn('无可待审记录,跳过'); return }
const res = await api('station', 'PATCH', `/api/records/${pending.id}/review`, {
decision: 'return',
})
expect(res.status).toBe(400)
expect(res.body.message).toContain('退回')
})
it('总部审核通过 -> archived', async () => {
const list = await api('headquarters', 'GET', '/api/records')
const pending = list.body.find(r => r.status === 'headquarters_review')
if (!pending) { console.warn('无总部待审记录,跳过'); return }
const res = await api('headquarters', 'PATCH', `/api/records/${pending.id}/review`, {
decision: 'pass', score: 92, note: '优秀稿件',
})
expect(res.status).toBe(200)
expect(res.body.status).toBe('archived')
expect(res.body.score).toBe(92)
})
it('总部退回 -> returned', async () => {
const list = await api('headquarters', 'GET', '/api/records')
const pending = list.body.find(r => r.status === 'headquarters_review')
if (!pending) { console.warn('无总部待审记录,跳过'); return }
const res = await api('headquarters', 'PATCH', `/api/records/${pending.id}/review`, {
decision: 'return', note: '数据需核实',
})
expect(res.status).toBe(200)
expect(res.body.status).toBe('returned')
})
it('状态不匹配时审核返回 409', async () => {
const list = await api('headquarters', 'GET', '/api/records')
const archived = list.body.find(r => r.status === 'archived')
if (!archived) { console.warn('无已归档记录,跳过'); return }
const res = await api('headquarters', 'PATCH', `/api/records/${archived.id}/review`, {
decision: 'pass', score: 80,
})
expect(res.status).toBe(409)
})
it('无效 decision 返回 400', async () => {
const list = await api('station', 'GET', '/api/records')
const pending = list.body.find(r => r.status === 'station_review')
if (!pending) { console.warn('无可待审记录,跳过'); return }
const res = await api('station', 'PATCH', `/api/records/${pending.id}/review`, {
decision: 'unknown', score: 80,
})
expect(res.status).toBe(400)
})
it('审核日志正确记录', async () => {
const submit = await api('reporter', 'POST', '/api/records', {
title: '日志测试', type: 'news', date: '2026-08-01', platform: 'APP',
})
expect(submit.status).toBe(201)
const logs = await api('headquarters', 'GET', `/api/records/${submit.body.id}/audit`)
expect(logs.status).toBe(200)
expect(logs.body[0].action).toBe('submit')
expect(logs.body[0].toStatus).toBe('station_review')
})
})
// ═══════════════════════════════════════════════════════════════════════════
// 测试集 3:人员 CRUD
// ═══════════════════════════════════════════════════════════════════════════
describe('人员 CRUD', () => {
it('总部可新增人员', async () => {
const res = await api('headquarters', 'POST', '/api/people', {
name: '测试记者', station: '北京记者站', title: '实习记者', phone: '13900009999',
})
expect(res.status).toBe(201)
expect(res.body.name).toBe('测试记者')
expect(res.body.code).toMatch(/^PERSON_/)
})
it('新增人员必填字段校验', async () => {
const res = await api('headquarters', 'POST', '/api/people', {
name: '', station: '',
})
expect(res.status).toBe(400)
})
it('总部可编辑人员', async () => {
const list = await api('headquarters', 'GET', '/api/people')
const person = list.body.find(p => p.code === 'P_BJ_03')
const res = await api('headquarters', 'PATCH', `/api/people/${person.id}`, {
title: '资深记者', status: 'active',
})
expect(res.status).toBe(200)
expect(res.body.title).toBe('资深记者')
})
it('总部可删除人员', async () => {
const created = await api('headquarters', 'POST', '/api/people', {
name: '临时人员', station: '上海记者站',
})
const res = await api('headquarters', 'DELETE', `/api/people/${created.body.id}`)
expect(res.status).toBe(200)
expect(res.body.message).toContain('成功')
})
it('人员筛选', async () => {
const byStation = await api('headquarters', 'GET', '/api/people?station=北京记者站')
expect(byStation.status).toBe(200)
for (const p of byStation.body) expect(p.station).toBe('北京记者站')
const byStatus = await api('headquarters', 'GET', '/api/people?status=active')
expect(byStatus.status).toBe(200)
const byName = await api('headquarters', 'GET', '/api/people?name=林')
expect(byName.status).toBe(200)
})
})
// ═══════════════════════════════════════════════════════════════════════════
// 测试集 4:记者站 CRUD
// ═══════════════════════════════════════════════════════════════════════════
describe('记者站 CRUD', () => {
it('总部可新增记者站', async () => {
const res = await api('headquarters', 'POST', '/api/stations', {
name: '深圳记者站', code: 'STAT_SZ', region: '华南', address: '深圳市南山区',
})
expect(res.status).toBe(201)
expect(res.body.name).toBe('深圳记者站')
})
it('新增站点编码重复 -> 409', async () => {
const res = await api('headquarters', 'POST', '/api/stations', {
name: '另一个北京站', code: 'STAT_BJ', region: '华北',
})
expect(res.status).toBe(409)
})
it('删除有人员的站点 -> 409', async () => {
const list = await api('headquarters', 'GET', '/api/stations')
const bj = list.body.find(s => s.code === 'STAT_BJ')
const res = await api('headquarters', 'DELETE', `/api/stations/${bj.id}`)
expect(res.status).toBe(409)
expect(res.body.message).toContain('人员')
})
it('删除无人员站点成功', async () => {
const list = await api('headquarters', 'GET', '/api/stations')
const gz = list.body.find(s => s.code === 'STAT_GZ')
const res = await api('headquarters', 'DELETE', `/api/stations/${gz.id}`)
expect(res.status).toBe(200)
})
})
// ═══════════════════════════════════════════════════════════════════════════
// 测试集 5:通知公告
// ═══════════════════════════════════════════════════════════════════════════
describe('通知公告', () => {
it('总部可发布通知', async () => {
const res = await api('headquarters', 'POST', '/api/notices', {
title: '集成测试通知', content: '内容正文', priority: 'normal', scope: 'all',
})
expect(res.status).toBe(201)
expect(res.body.title).toBe('集成测试通知')
})
it('发布时 priority/scope 校验', async () => {
const bad = await api('headquarters', 'POST', '/api/notices', {
title: '测试', priority: 'invalid',
})
expect(bad.status).toBe(400)
})
it('通知读取接口参数校验', async () => {
const notices = await api('headquarters', 'GET', '/api/notices')
const notice = notices.body[0]
// 无对应回执记录时返回 404(符合业务逻辑:发布者不在回执名单中)
const res = await api('headquarters', 'POST', `/api/notices/${notice.id}/read`)
expect([200, 404]).toContain(res.status)
})
})
// ═══════════════════════════════════════════════════════════════════════════
// 测试集 6:数据统计
// ═══════════════════════════════════════════════════════════════════════════
describe('数据统计', () => {
it('GET /api/stats/overview 返回正确字段', async () => {
const res = await api('headquarters', 'GET', '/api/stats/overview')
expect(res.status).toBe(200)
expect(res.body).toHaveProperty('total')
expect(res.body).toHaveProperty('archived')
expect(res.body).toHaveProperty('avgScore')
expect(res.body).toHaveProperty('monthly')
})
it('GET /api/stats/records 支持 station/reporter/type 分组', async () => {
for (const g of ['station', 'reporter', 'type']) {
const res = await api('headquarters', 'GET', `/api/stats/records?groupBy=${g}`)
expect(res.status).toBe(200)
expect(Array.isArray(res.body)).toBe(true)
}
})
it('GET /api/stats/records 无效 groupBy -> 400', async () => {
const res = await api('headquarters', 'GET', '/api/stats/records?groupBy=invalid')
expect(res.status).toBe(400)
})
it('GET /api/stats/scores 返回记者评分', async () => {
const res = await api('headquarters', 'GET', '/api/stats/scores')
expect(res.status).toBe(200)
expect(Array.isArray(res.body)).toBe(true)
})
it('站点角色看统计仅限本站', async () => {
const res = await api('station', 'GET', '/api/stats/overview')
expect(res.status).toBe(200)
})
})
// ═══════════════════════════════════════════════════════════════════════════
// 测试集 7:考核规则 CRUDV0.2)
// ═══════════════════════════════════════════════════════════════════════════
describe('考核规则 CRUD', () => {
it('GET /api/rules 总部可见规则列表', async () => {
const res = await api('headquarters', 'GET', '/api/rules')
expect(res.status).toBe(200)
expect(Array.isArray(res.body)).toBe(true)
})
it('GET /api/rules 支持 status 筛选', async () => {
const res = await api('headquarters', 'GET', '/api/rules?status=active')
expect(res.status).toBe(200)
for (const r of res.body) expect(r.status).toBe('active')
})
it('GET /api/rules/:id 返回规则及指标项', async () => {
const list = await api('headquarters', 'GET', '/api/rules')
const rule = list.body[0]
const res = await api('headquarters', 'GET', `/api/rules/${rule.id}`)
expect(res.status).toBe(200)
expect(res.body).toHaveProperty('items')
expect(Array.isArray(res.body.items)).toBe(true)
})
it('GET /api/rules/:id 不存在 -> 404', async () => {
const res = await api('headquarters', 'GET', '/api/rules/99999')
expect(res.status).toBe(404)
})
it('POST /api/rules 总部可创建规则(含指标项)', async () => {
const res = await api('headquarters', 'POST', '/api/rules', {
name: '测试考核规则',
description: '集成测试用规则',
period_type: 'quarterly',
items: [
{ category: 'quantity', name: '发稿数量', metric_key: 'count_total', weight: 0.3, formula_type: 'count', formula_params: {} },
{ category: 'quality', name: '审核得分', metric_key: 'avg_score', weight: 0.7, formula_type: 'avg_score', formula_params: {} },
],
})
expect(res.status).toBe(201)
expect(res.body.name).toBe('测试考核规则')
expect(res.body.status).toBe('draft')
expect(res.body.items.length).toBe(2)
})
it('POST /api/rules 非总部角色 -> 403', async () => {
for (const role of ['station', 'reporter']) {
const res = await api(role, 'POST', '/api/rules', { name: '非法规则', period_type: 'quarterly' })
expect(res.status).toBe(403)
}
})
it('POST /api/rules 必填字段校验', async () => {
const res = await api('headquarters', 'POST', '/api/rules', { name: '' })
expect(res.status).toBe(400)
})
it('PATCH /api/rules 总部可编辑规则', async () => {
const list = await api('headquarters', 'GET', '/api/rules')
const rule = list.body.find(r => r.status === 'draft') || list.body[0]
const res = await api('headquarters', 'PATCH', `/api/rules/${rule.id}`, {
name: '规则已更新',
items: [
{ category: 'quantity', name: '更新后指标', metric_key: 'count_total', weight: 0.5, formula_type: 'count', formula_params: {} },
],
})
expect(res.status).toBe(200)
expect(res.body.name).toBe('规则已更新')
expect(res.body.items.length).toBe(1)
})
it('PATCH /api/rules 已激活规则不可直接编辑', async () => {
const list = await api('headquarters', 'GET', '/api/rules')
const active = list.body.find(r => r.status === 'active')
if (!active) { console.warn('无激活规则,跳过'); return }
const res = await api('headquarters', 'PATCH', `/api/rules/${active.id}`, { name: '非法更新' })
expect(res.status).toBe(409)
})
it('POST /api/rules/:id/activate 激活规则', async () => {
const list = await api('headquarters', 'GET', '/api/rules')
const draft = list.body.find(r => r.status === 'draft')
if (!draft) { console.warn('无草稿规则,跳过'); return }
const res = await api('headquarters', 'POST', `/api/rules/${draft.id}/activate`)
expect(res.status).toBe(200)
expect(res.body.status).toBe('active')
})
it('POST /api/rules/:id/activate 非总部 -> 403', async () => {
const list = await api('headquarters', 'GET', '/api/rules')
const draft = list.body.find(r => r.status === 'draft')
if (!draft) { console.warn('无草稿规则,跳过'); return }
const res = await api('station', 'POST', `/api/rules/${draft.id}/activate`)
expect(res.status).toBe(403)
})
it('激活后同周期旧规则自动归档', async () => {
const list = await api('headquarters', 'GET', '/api/rules')
const actives = list.body.filter(r => r.status === 'active')
expect(actives.length).toBeLessThanOrEqual(1)
})
})
// ═══════════════════════════════════════════════════════════════════════════
// 测试集 8:评分计算(V0.2)
// ═══════════════════════════════════════════════════════════════════════════
describe('评分计算', () => {
it('GET /api/scores 总部可见所有评分', async () => {
const res = await api('headquarters', 'GET', '/api/scores')
expect(res.status).toBe(200)
expect(Array.isArray(res.body)).toBe(true)
})
it('GET /api/scores 支持筛选参数', async () => {
const res = await api('headquarters', 'GET', '/api/scores?period=2026-Q3&station=北京记者站')
expect(res.status).toBe(200)
for (const s of res.body) {
expect(s.period).toBe('2026-Q3')
}
})
it('GET /api/scores/:id 返回评分及明细', async () => {
const list = await api('headquarters', 'GET', '/api/scores')
const score = list.body[0]
if (!score) { console.warn('无评分记录,跳过'); return }
const res = await api('headquarters', 'GET', `/api/scores/${score.id}`)
expect(res.status).toBe(200)
expect(res.body).toHaveProperty('items')
expect(Array.isArray(res.body.items)).toBe(true)
expect(res.body).toHaveProperty('totalScore')
expect(res.body).toHaveProperty('qualityScore')
expect(res.body).toHaveProperty('quantityScore')
expect(res.body).toHaveProperty('efficiencyScore')
expect(res.body).toHaveProperty('complianceScore')
})
it('GET /api/scores/:id 不存在 -> 404', async () => {
const res = await api('headquarters', 'GET', '/api/scores/99999')
expect(res.status).toBe(404)
})
it('POST /api/scores/compute 总部可触发计算', async () => {
const rules = await api('headquarters', 'GET', '/api/rules')
const activeRule = rules.body.find(r => r.status === 'active') || rules.body[0]
const res = await api('headquarters', 'POST', '/api/scores/compute', {
rule_id: activeRule.id,
period: '2026-Q3',
period_type: 'quarterly',
})
expect(res.status).toBe(201)
expect(res.body).toHaveProperty('message')
expect(res.body).toHaveProperty('results')
expect(Array.isArray(res.body.results)).toBe(true)
})
it('POST /api/scores/compute 必填字段校验', async () => {
const res = await api('headquarters', 'POST', '/api/scores/compute', {})
expect(res.status).toBe(400)
})
it('POST /api/scores/compute 非总部 -> 403', async () => {
const res = await api('station', 'POST', '/api/scores/compute', {
rule_id: 1, period: '2026-Q3', period_type: 'quarterly',
})
expect(res.status).toBe(403)
})
it('评分结果 item_details 正确反序列化', async () => {
const list = await api('headquarters', 'GET', '/api/scores')
const score = list.body[0]
if (!score) { console.warn('无评分记录,跳过'); return }
expect(Array.isArray(score.items)).toBe(true)
for (const item of score.items) {
expect(item).toHaveProperty('raw_score')
expect(item).toHaveProperty('weighted_score')
expect(item).toHaveProperty('weight')
}
})
})
+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" />
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"allowImportingTsExtensions": true,
"noEmit": true,
"tsBuildInfoFile": "./node_modules/.cache/tsconfig.app.tsbuildinfo",
"jsx": "react-jsx"
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"composite": true,
"noEmit": true,
"tsBuildInfoFile": "./node_modules/.cache/tsconfig.node.tsbuildinfo",
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true
},
"include": ["vite.config.ts"]
}
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 5177,
proxy: {
'/api': 'http://127.0.0.1:8787',
},
},
})
Binary file not shown.
File diff suppressed because one or more lines are too long