初始提交:全国记者站管理系统
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
dist
|
||||
data/*.db
|
||||
data/*.db-shm
|
||||
data/*.db-wal
|
||||
data/test-*
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
+13
@@ -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>
|
||||
@@ -0,0 +1,7 @@
|
||||
/** @type {import('jest').Config} */
|
||||
export default {
|
||||
testEnvironment: 'node',
|
||||
transform: {},
|
||||
testMatch: ['**/server/tests/**/*.test.cjs'],
|
||||
setupFilesAfterEnv: ['<rootDir>/server/tests/_setup.cjs'],
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
@@ -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');
|
||||
@@ -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');
|
||||
@@ -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');
|
||||
@@ -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');
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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';
|
||||
@@ -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'));
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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';
|
||||
@@ -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'));
|
||||
@@ -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} 个迁移。`)
|
||||
}
|
||||
Generated
+8928
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
# REQ-RSMS-001:全国记者站管理系统需求与目标
|
||||
|
||||
> 文档版本:v1.0
|
||||
> 状态:待确认
|
||||
> 编制日期:2026-08-01
|
||||
> 项目缩写:RSMS(Reporter 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)涉及总体架构和关键业务规则,请在进入详细设计前逐条确认。
|
||||
@@ -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 SC,Songti 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
|
||||
- **图表**:Recharts(AreaChart)
|
||||
- **图标**:Lucide React
|
||||
- **样式**:纯 CSS(含响应式)
|
||||
- **状态**:React useState / useEffect(当前),Redux Toolkit(预留)
|
||||
- **路由**:条件渲染(当前),React Router(后续)
|
||||
|
||||
### 9.2 后端
|
||||
|
||||
- **运行时**:Node.js
|
||||
- **框架**:Express.js
|
||||
- **数据库**:SQLite(WAL 模式)
|
||||
- **进程**:`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 | 已决策 |
|
||||
@@ -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~018,SCENE-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-005(run.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-010:API 接口集成测试
|
||||
- **目标**:使用 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、activate;scores 查询、计算触发
|
||||
- **验收**: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)
|
||||
|
||||
---
|
||||
|
||||
## 阶段 4:ADR 架构决策
|
||||
|
||||
- [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) |
|
||||
@@ -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 API(14 个端点) | ✅ 完成 |
|
||||
| 任务 | `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)
|
||||
- **数据库**:SQLite(WAL 模式)(选项 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 可进一步增强类型安全)
|
||||
@@ -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` 记录绑定旧规则版本,不受影响
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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. 需求追踪说明
|
||||
|
||||
本文档中的需求编号应在后续原型、设计、开发任务、测试用例和验收记录中持续引用。经业务评审确认后的新增、修改或删除需求,应记录版本、变更原因、提出人、批准人和影响范围。
|
||||
@@ -0,0 +1,409 @@
|
||||
# 运行手册
|
||||
|
||||
> 本文档是项目运行的唯一事实来源。后续操作优先按本文档执行,若实际命令与文档不符,先验证真实行为,再更新本文档。
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 层级 | 技术 | 版本 |
|
||||
|---|---|---|
|
||||
| 前端框架 | React + TypeScript | ^19 |
|
||||
| 构建工具 | Vite | ^6 |
|
||||
| 后端框架 | Express.js | ^4 |
|
||||
| 数据库 | SQLite(WAL 模式) | 内置 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>
|
||||
```
|
||||
|
||||
### Q3:SQLite 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 迁移
|
||||
# 查看具体错误日志
|
||||
```
|
||||
|
||||
### Q6:TypeScript 类型错误
|
||||
|
||||
```bash
|
||||
npx tsc -b --force # 强制重建类型缓存
|
||||
```
|
||||
|
||||
### Q7:`npm run build` 失败
|
||||
|
||||
1. 先确认 TypeScript 无误:`npx tsc -b`
|
||||
2. 查看 Vite 构建输出中的具体错误
|
||||
3. 检查是否有新增依赖未安装
|
||||
|
||||
### Q8:seed 数据消失
|
||||
|
||||
- 原因:`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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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
|
||||
@@ -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:考核规则 CRUD(V0.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
@@ -0,0 +1,219 @@
|
||||
import { useState, useEffect, lazy, Suspense } from 'react'
|
||||
import { Routes, Route, useNavigate, useLocation } from 'react-router-dom'
|
||||
import { LoadingBar } from './components/ui'
|
||||
|
||||
const Dashboard = lazy(() => import('./pages/Dashboard').then(m => ({ default: m.Dashboard })))
|
||||
const WorkList = lazy(() => import('./pages/WorkList').then(m => ({ default: m.WorkList })))
|
||||
const ReviewCenter = lazy(() => import('./pages/ReviewCenter').then(m => ({ default: m.ReviewCenter })))
|
||||
const PeoplePage = lazy(() => import('./pages/People').then(m => ({ default: m.PeoplePage })))
|
||||
const StationsPage = lazy(() => import('./pages/Stations').then(m => ({ default: m.StationsPage })))
|
||||
const ArchivePage = lazy(() => import('./pages/Archive').then(m => ({ default: m.ArchivePage })))
|
||||
const NoticesPage = lazy(() => import('./pages/Notices').then(m => ({ default: m.NoticesPage })))
|
||||
const SettingsPage = lazy(() => import('./pages/Settings').then(m => ({ default: m.SettingsPage })))
|
||||
const RulesPage = lazy(() => import('./pages/Rules').then(m => ({ default: m.RulesPage })))
|
||||
const ScoresPage = lazy(() => import('./pages/Scores').then(m => ({ default: m.ScoresPage })))
|
||||
const LeaderboardPage = lazy(() => import('./pages/Leaderboard').then(m => ({ default: m.LeaderboardPage })))
|
||||
const AppealsPage = lazy(() => import('./pages/Appeals').then(m => ({ default: m.AppealsPage })))
|
||||
const SystemLogsPage = lazy(() => import('./pages/SystemLogs').then(m => ({ default: m.SystemLogsPage })))
|
||||
const StationMapPage = lazy(() => import('./pages/StationMap').then(m => ({ default: m.StationMapPage })))
|
||||
const CockpitPage = lazy(() => import('./pages/Cockpit').then(m => ({ default: m.CockpitPage })))
|
||||
const ProfilePage = lazy(() => import('./pages/Profile').then(m => ({ default: m.ProfilePage })))
|
||||
const LoginPage = lazy(() => import('./pages/Login').then(m => ({ default: m.LoginPage })))
|
||||
import { Sidebar, TopBar } from './components/layout'
|
||||
const CreateRecordModal = lazy(() => import('./modals/CreateRecordModal').then(m => ({ default: m.CreateRecordModal })))
|
||||
const RecordDrawer = lazy(() => import('./modals/RecordDrawer').then(m => ({ default: m.RecordDrawer })))
|
||||
import { RoleProvider, ToastProvider, useRole, useToast } from './context'
|
||||
import { api } from './api'
|
||||
import { navLabels, pathMap, pathToPage } from './routes'
|
||||
import type { WorkRecord } from './types'
|
||||
import type { PageKey } from './routes'
|
||||
|
||||
function AppShell() {
|
||||
const { role, isAuthenticated } = useRole()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const { showToast: showToastCtx } = useToast()
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [selected, setSelected] = useState<WorkRecord | null>(null)
|
||||
const [records, setRecords] = useState<WorkRecord[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadError, setLoadError] = useState('')
|
||||
const [toast, setToast] = useState('')
|
||||
|
||||
const page: PageKey = pathToPage(location.pathname)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
setLoading(true)
|
||||
setLoadError('')
|
||||
api.records(role)
|
||||
.then(data => { if (active) setRecords(data) })
|
||||
.catch(err => { if (active) setLoadError(err.message) })
|
||||
.finally(() => { if (active) setLoading(false) })
|
||||
return () => { active = false }
|
||||
}, [role])
|
||||
|
||||
// 角色受限页面自动跳转
|
||||
useEffect(() => {
|
||||
const restricted: Record<string, PageKey[]> = {
|
||||
reporter: ['review', 'people', 'stations', 'stationmap', 'cockpit'],
|
||||
station: ['stations', 'stationmap', 'cockpit'],
|
||||
}
|
||||
if (restricted[role]?.includes(page)) {
|
||||
navigate('/')
|
||||
}
|
||||
}, [role, page, navigate])
|
||||
|
||||
const navigateTo = (p: PageKey) => {
|
||||
navigate(pathMap[p])
|
||||
setSidebarOpen(false)
|
||||
}
|
||||
|
||||
const pendingCount = records.filter(r =>
|
||||
role === 'station' ? r.status === 'station_review' : r.status === 'headquarters_review'
|
||||
).length
|
||||
|
||||
const handleCreate = async (record: WorkRecord) => {
|
||||
setRecords(items => [record, ...items])
|
||||
setCreateOpen(false)
|
||||
showToast('工作记录已提交,进入分站审核')
|
||||
}
|
||||
|
||||
const handleUpdate = async (id: string, patch: Partial<WorkRecord>) => {
|
||||
try {
|
||||
const updated = await api.reviewRecord(role, id, {
|
||||
decision: patch.status === 'returned' ? 'return' : 'pass',
|
||||
score: patch.score ?? 0,
|
||||
note: patch.reviewNote ?? '',
|
||||
})
|
||||
setRecords(items => items.map(item => item.id === id ? updated : item))
|
||||
setSelected(null)
|
||||
return true
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : '审核操作失败')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const showToast = (msg: string) => {
|
||||
setToast(msg)
|
||||
showToastCtx?.(msg)
|
||||
setTimeout(() => setToast(''), 2400)
|
||||
}
|
||||
|
||||
// 未认证时显示登录页
|
||||
if (!isAuthenticated && !localStorage.getItem('auth_token')) {
|
||||
// 演示模式:允许直接进入
|
||||
const isDemoMode = !localStorage.getItem('auth_token') && localStorage.getItem('auth_role')
|
||||
if (!isDemoMode) {
|
||||
return (
|
||||
<Suspense fallback={<LoadingBar />}>
|
||||
<LoginPage />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<Sidebar
|
||||
page={page}
|
||||
onNavigate={navigateTo}
|
||||
open={sidebarOpen}
|
||||
onClose={() => setSidebarOpen(false)}
|
||||
badge={pendingCount}
|
||||
/>
|
||||
|
||||
<main className="main">
|
||||
<TopBar
|
||||
pageTitle={navLabels[page]}
|
||||
onMenuToggle={() => setSidebarOpen(true)}
|
||||
/>
|
||||
|
||||
<section className="content">
|
||||
{loadError && (
|
||||
<div className="error-banner">
|
||||
{loadError}
|
||||
<button onClick={() => window.location.reload()}>重新加载</button>
|
||||
</div>
|
||||
)}
|
||||
{loading && <LoadingBar />}
|
||||
|
||||
<Suspense fallback={<LoadingBar />}>
|
||||
<Routes>
|
||||
<Route path="/" element={
|
||||
<Dashboard
|
||||
records={records}
|
||||
onNavigate={navigateTo}
|
||||
onCreate={() => setCreateOpen(true)}
|
||||
onSelect={setSelected}
|
||||
/>
|
||||
} />
|
||||
<Route path="/work" element={
|
||||
<WorkList
|
||||
records={records}
|
||||
onCreate={() => setCreateOpen(true)}
|
||||
onSelect={setSelected}
|
||||
/>
|
||||
} />
|
||||
<Route path="/review" element={
|
||||
<ReviewCenter records={records} onSelect={setSelected} />
|
||||
} />
|
||||
<Route path="/people" element={<PeoplePage />} />
|
||||
<Route path="/stations" element={<StationsPage />} />
|
||||
<Route path="/archive" element={<ArchivePage records={records} onSelect={setSelected} />} />
|
||||
<Route path="/notices" element={<NoticesPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/rules" element={<RulesPage />} />
|
||||
<Route path="/scores" element={<ScoresPage />} />
|
||||
<Route path="/leaderboard" element={<LeaderboardPage />} />
|
||||
<Route path="/appeals" element={<AppealsPage />} />
|
||||
<Route path="/logs" element={<SystemLogsPage />} />
|
||||
<Route path="/stationmap" element={<StationMapPage />} />
|
||||
<Route path="/cockpit" element={<CockpitPage records={records} />} />
|
||||
<Route path="/profile" element={<ProfilePage records={records} />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{createOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<CreateRecordModal
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onSubmit={handleCreate}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{selected && (
|
||||
<Suspense fallback={null}>
|
||||
<RecordDrawer
|
||||
record={selected}
|
||||
role={role}
|
||||
onClose={() => setSelected(null)}
|
||||
onUpdate={handleUpdate}
|
||||
onNotify={showToast}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{toast && <div className="toast">{toast}</div>}
|
||||
|
||||
{sidebarOpen && (
|
||||
<div className="scrim" onClick={() => setSidebarOpen(false)} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<RoleProvider>
|
||||
<ToastProvider>
|
||||
<AppShell />
|
||||
</ToastProvider>
|
||||
</RoleProvider>
|
||||
)
|
||||
}
|
||||
+459
@@ -0,0 +1,459 @@
|
||||
import type { Role, WorkRecord, WorkType, Attachment, Appeal, PersonTransfer, SystemLog, LoginResponse, RecordVersion } from './types'
|
||||
|
||||
// ── API 请求封装 ────────────────────────────────────────────────────────────
|
||||
type NewRecord = { title: string; type: WorkType; date: string; platform: string; description?: string; attachments?: Attachment[]; isDraft?: boolean }
|
||||
type ReviewRecord = { decision: 'pass' | 'return'; score: number; note: string }
|
||||
|
||||
/** 获取本地存储的 auth token */
|
||||
function getAuthToken(): string | null {
|
||||
return localStorage.getItem('auth_token')
|
||||
}
|
||||
|
||||
/** 构建请求头 */
|
||||
function buildHeaders(role: Role, init?: RequestInit): Record<string, string> {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json', 'x-user-role': role }
|
||||
const token = getAuthToken()
|
||||
if (token) headers['x-auth-token'] = token
|
||||
return { ...headers, ...(init?.headers as Record<string, string>) }
|
||||
}
|
||||
|
||||
async function request<T>(path: string, role: Role, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(path, {
|
||||
...init,
|
||||
headers: buildHeaders(role, init),
|
||||
})
|
||||
const payload = await response.json().catch(() => ({}))
|
||||
if (!response.ok) throw new Error(payload.message || '请求失败,请稍后重试')
|
||||
return payload
|
||||
}
|
||||
|
||||
// ── API 集合 ────────────────────────────────────────────────────────────────
|
||||
export const api = {
|
||||
// 认证
|
||||
auth: {
|
||||
login: (code: string) =>
|
||||
fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code }) })
|
||||
.then(async r => { const p = await r.json().catch(() => ({})); if (!r.ok) throw new Error(p.message || '登录失败'); return p as LoginResponse }),
|
||||
logout: (role: Role) =>
|
||||
request<{ message: string }>('/api/auth/logout', role, { method: 'POST' }),
|
||||
check: (role: Role) =>
|
||||
request<{ authenticated: boolean; role: Role; name: string; station?: string; demo?: boolean }>('/api/auth/check', role),
|
||||
},
|
||||
|
||||
// 工作记录
|
||||
records: (role: Role) => request<WorkRecord[]>('/api/records', role),
|
||||
createRecord: (role: Role, record: NewRecord) =>
|
||||
request<WorkRecord>('/api/records', role, { method: 'POST', body: JSON.stringify(record) }),
|
||||
reviewRecord: (role: Role, id: string, review: ReviewRecord) =>
|
||||
request<WorkRecord>(`/api/records/${id}/review`, role, { method: 'PATCH', body: JSON.stringify(review) }),
|
||||
saveDraft: (role: Role, id: string, patch: Partial<NewRecord>) =>
|
||||
request<WorkRecord>(`/api/records/${id}/draft`, role, { method: 'PATCH', body: JSON.stringify(patch) }),
|
||||
submitDraft: (role: Role, id: string) =>
|
||||
request<WorkRecord>(`/api/records/${id}/submit`, role, { method: 'POST' }),
|
||||
getVersions: (role: Role, id: string) =>
|
||||
request<RecordVersion[]>(`/api/records/${id}/versions`, role),
|
||||
uploadAttachments: (role: Role, id: string, files: Attachment[]) =>
|
||||
request<WorkRecord>(`/api/records/${id}/attachments`, role, { method: 'POST', body: JSON.stringify({ files }) }),
|
||||
deleteAttachment: (role: Role, id: string, index: number) =>
|
||||
request<WorkRecord>(`/api/records/${id}/attachments/${index}`, role, { method: 'DELETE' }),
|
||||
getAudit: (role: Role, id: string) =>
|
||||
request<{actorRole: string; actorName: string; action: string; fromStatus?: string; toStatus: string; score?: number; note?: string; createdAt: string}[]>(`/api/records/${id}/audit`, role),
|
||||
|
||||
// TASK-BE-001:人员 CRUD
|
||||
people: {
|
||||
list: (role: Role, params?: { station?: string; status?: string; name?: string }) => {
|
||||
const sp = new URLSearchParams()
|
||||
if (params?.station) sp.set('station', params.station)
|
||||
if (params?.status) sp.set('status', params.status)
|
||||
if (params?.name) sp.set('name', params.name)
|
||||
const qs = sp.toString() ? `?${sp.toString()}` : ''
|
||||
return request<Person[]>(`/api/people${qs}`, role)
|
||||
},
|
||||
get: (role: Role, id: number) =>
|
||||
request<Person>(`/api/people/${id}`, role),
|
||||
create: (role: Role, person: { name: string; station: string; title?: string; phone?: string; joinedAt?: string }) =>
|
||||
request<Person>('/api/people', role, { method: 'POST', body: JSON.stringify(person) }),
|
||||
update: (role: Role, id: number, patch: { title?: string; phone?: string; status?: string }) =>
|
||||
request<Person>(`/api/people/${id}`, role, { method: 'PATCH', body: JSON.stringify(patch) }),
|
||||
delete: (role: Role, id: number) =>
|
||||
request<{ message: string }>(`/api/people/${id}`, role, { method: 'DELETE' }),
|
||||
transfer: (role: Role, id: number, toStation: string, reason?: string) =>
|
||||
request<{ message: string }>(`/api/people/${id}/transfer`, role, { method: 'POST', body: JSON.stringify({ toStation, reason }) }),
|
||||
transfers: (role: Role, id: number) =>
|
||||
request<PersonTransfer[]>(`/api/people/${id}/transfers`, role),
|
||||
},
|
||||
|
||||
// TASK-BE-002:记者站 CRUD
|
||||
stations: {
|
||||
list: (role: Role, params?: { status?: string; region?: string }) => {
|
||||
const sp = new URLSearchParams()
|
||||
if (params?.status) sp.set('status', params.status)
|
||||
if (params?.region) sp.set('region', params.region)
|
||||
const qs = sp.toString() ? `?${sp.toString()}` : ''
|
||||
return request<Station[]>(`/api/stations${qs}`, role)
|
||||
},
|
||||
get: (role: Role, id: number) =>
|
||||
request<Station>(`/api/stations/${id}`, role),
|
||||
create: (role: Role, station: { name: string; code: string; region?: string; address?: string; leader?: string; phone?: string; establishedAt?: string }) =>
|
||||
request<Station>('/api/stations', role, { method: 'POST', body: JSON.stringify(station) }),
|
||||
update: (role: Role, id: number, patch: Partial<Station>) =>
|
||||
request<Station>(`/api/stations/${id}`, role, { method: 'PATCH', body: JSON.stringify(patch) }),
|
||||
delete: (role: Role, id: number) =>
|
||||
request<{ message: string }>(`/api/stations/${id}`, role, { method: 'DELETE' }),
|
||||
},
|
||||
|
||||
// TASK-BE-003:通知公告
|
||||
notices: {
|
||||
list: (role: Role, params?: { priority?: string; scope?: string }) => {
|
||||
const sp = new URLSearchParams()
|
||||
if (params?.priority) sp.set('priority', params.priority)
|
||||
if (params?.scope) sp.set('scope', params.scope)
|
||||
const qs = sp.toString() ? `?${sp.toString()}` : ''
|
||||
return request<Notice[]>(`/api/notices${qs}`, role)
|
||||
},
|
||||
get: (role: Role, id: number) =>
|
||||
request<Notice>(`/api/notices/${id}`, role),
|
||||
create: (role: Role, notice: { title: string; content?: string; priority?: string; scope?: string; stations?: string[]; roles?: string[] }) =>
|
||||
request<Notice>('/api/notices', role, { method: 'POST', body: JSON.stringify(notice) }),
|
||||
update: (role: Role, id: number, patch: { title?: string; content?: string; priority?: string }) =>
|
||||
request<Notice>(`/api/notices/${id}`, role, { method: 'PATCH', body: JSON.stringify(patch) }),
|
||||
receipts: (role: Role, id: number) =>
|
||||
request<NoticeReceipt[]>(`/api/notices/${id}/receipts`, role),
|
||||
markRead: (role: Role, id: number) =>
|
||||
request<{ message: string }>(`/api/notices/${id}/read`, role, { method: 'POST' }),
|
||||
markConfirm: (role: Role, id: number, receiverName: string, receiverRole: string) =>
|
||||
request<{ message: string }>(`/api/notices/${id}/confirm`, role,
|
||||
{ method: 'POST', body: JSON.stringify({ receiverName, receiverRole }) }),
|
||||
delete: (role: Role, id: number) =>
|
||||
request<{ message: string }>(`/api/notices/${id}`, role, { method: 'DELETE' }),
|
||||
withdraw: (role: Role, id: number) =>
|
||||
request<{ message: string }>(`/api/notices/${id}/withdraw`, role, { method: 'POST' }),
|
||||
},
|
||||
|
||||
// TASK-BE-004:数据统计
|
||||
stats: {
|
||||
overview: (role: Role) =>
|
||||
request<StatsOverview>('/api/stats/overview', role),
|
||||
records: (role: Role, groupBy: 'station' | 'reporter' | 'type') =>
|
||||
request<StatsRecordRow[]>(`/api/stats/records?groupBy=${groupBy}`, role),
|
||||
scores: (role: Role) =>
|
||||
request<StatsScoreRow[]>('/api/stats/scores', role),
|
||||
},
|
||||
|
||||
// V0.2 考核规则
|
||||
rules: {
|
||||
list: (role: Role, params?: { status?: string; period_type?: string }) => {
|
||||
const sp = new URLSearchParams()
|
||||
if (params?.status) sp.set('status', params.status)
|
||||
if (params?.period_type) sp.set('period_type', params.period_type)
|
||||
const qs = sp.toString() ? `?${sp.toString()}` : ''
|
||||
return request<Rule[]>(`/api/rules${qs}`, role)
|
||||
},
|
||||
get: (role: Role, id: number) =>
|
||||
request<Rule>(`/api/rules/${id}`, role),
|
||||
create: (role: Role, rule: {
|
||||
name: string; description?: string; period_type: string;
|
||||
period_start?: string; period_end?: string; items?: RuleItem[]
|
||||
}) =>
|
||||
request<Rule>('/api/rules', role, { method: 'POST', body: JSON.stringify(rule) }),
|
||||
update: (role: Role, id: number, rule: {
|
||||
name?: string; description?: string;
|
||||
period_start?: string; period_end?: string; items?: RuleItem[]
|
||||
}) =>
|
||||
request<Rule>(`/api/rules/${id}`, role, { method: 'PATCH', body: JSON.stringify(rule) }),
|
||||
activate: (role: Role, id: number) =>
|
||||
request<Rule>(`/api/rules/${id}/activate`, role, { method: 'POST' }),
|
||||
},
|
||||
|
||||
// V0.2 评分
|
||||
scores: {
|
||||
list: (role: Role, params?: { reporter?: string; station?: string; period?: string; rule_id?: number }) => {
|
||||
const sp = new URLSearchParams()
|
||||
if (params?.reporter) sp.set('reporter', params.reporter)
|
||||
if (params?.station) sp.set('station', params.station)
|
||||
if (params?.period) sp.set('period', params.period)
|
||||
if (params?.rule_id) sp.set('rule_id', String(params.rule_id))
|
||||
const qs = sp.toString() ? `?${sp.toString()}` : ''
|
||||
return request<Score[]>(`/api/scores${qs}`, role)
|
||||
},
|
||||
get: (role: Role, id: number) =>
|
||||
request<Score>(`/api/scores/${id}`, role),
|
||||
compute: (role: Role, body: {
|
||||
rule_id: number; reporters?: string[]; period: string; period_type: string
|
||||
}) =>
|
||||
request<ComputeResult>('/api/scores/compute', role, { method: 'POST', body: JSON.stringify(body) }),
|
||||
},
|
||||
|
||||
// V0.2 积分排行榜
|
||||
leaderboard: (role: Role, params?: { period?: string; limit?: number; group_by?: 'reporter' | 'station' }) => {
|
||||
const sp = new URLSearchParams()
|
||||
if (params?.period) sp.set('period', params.period)
|
||||
if (params?.limit) sp.set('limit', String(params.limit))
|
||||
if (params?.group_by) sp.set('group_by', params.group_by)
|
||||
const qs = sp.toString() ? `?${sp.toString()}` : ''
|
||||
return request<LeaderboardResponse>(`/api/leaderboard${qs}`, role)
|
||||
},
|
||||
|
||||
// 当前登录人考核汇总
|
||||
me: {
|
||||
summary: (role: Role, period?: string) => {
|
||||
const qs = period ? `?period=${period}` : ''
|
||||
return request<MeSummary>(`/api/me/summary${qs}`, role)
|
||||
},
|
||||
},
|
||||
|
||||
// 申诉复议
|
||||
appeals: {
|
||||
list: (role: Role, status?: string) => {
|
||||
const qs = status ? `?status=${status}` : ''
|
||||
return request<Appeal[]>(`/api/appeals${qs}`, role)
|
||||
},
|
||||
create: (role: Role, recordId: string, reason: string) =>
|
||||
request<{ message: string; code: string }>('/api/appeals', role, { method: 'POST', body: JSON.stringify({ recordId, reason }) }),
|
||||
handle: (role: Role, id: number, decision: 'uphold' | 'overturn', response?: string) =>
|
||||
request<{ message: string }>(`/api/appeals/${id}`, role, { method: 'PATCH', body: JSON.stringify({ decision, response }) }),
|
||||
},
|
||||
|
||||
// 系统操作日志
|
||||
systemLogs: (role: Role, params?: { module?: string; action?: string; actor?: string; page?: number; pageSize?: number }) => {
|
||||
const sp = new URLSearchParams()
|
||||
if (params?.module) sp.set('module', params.module)
|
||||
if (params?.action) sp.set('action', params.action)
|
||||
if (params?.actor) sp.set('actor', params.actor)
|
||||
if (params?.page) sp.set('page', String(params.page))
|
||||
if (params?.pageSize) sp.set('pageSize', String(params.pageSize))
|
||||
const qs = sp.toString() ? `?${sp.toString()}` : ''
|
||||
return request<SystemLog[]>(`/api/system-logs${qs}`, role)
|
||||
},
|
||||
|
||||
// 导出
|
||||
export: {
|
||||
records: (role: Role, params?: { startDate?: string; endDate?: string; type?: string; status?: string }) => {
|
||||
const sp = new URLSearchParams()
|
||||
if (params?.startDate) sp.set('startDate', params.startDate)
|
||||
if (params?.endDate) sp.set('endDate', params.endDate)
|
||||
if (params?.type) sp.set('type', params.type)
|
||||
if (params?.status) sp.set('status', params.status)
|
||||
const qs = sp.toString() ? `?${sp.toString()}` : ''
|
||||
const token = getAuthToken()
|
||||
const headers: Record<string, string> = { 'x-user-role': role }
|
||||
if (token) headers['x-auth-token'] = token
|
||||
return fetch(`/api/export/records${qs}`, { headers }).then(r => r.blob())
|
||||
},
|
||||
people: (role: Role) => {
|
||||
const token = getAuthToken()
|
||||
const headers: Record<string, string> = { 'x-user-role': role }
|
||||
if (token) headers['x-auth-token'] = token
|
||||
return fetch('/api/export/people', { headers }).then(r => r.blob())
|
||||
},
|
||||
scores: (role: Role, period?: string) => {
|
||||
const qs = period ? `?period=${period}` : ''
|
||||
const token = getAuthToken()
|
||||
const headers: Record<string, string> = { 'x-user-role': role }
|
||||
if (token) headers['x-auth-token'] = token
|
||||
return fetch(`/api/export/scores${qs}`, { headers }).then(r => r.blob())
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// V0.2 排行榜类型
|
||||
export interface LeaderboardResponse {
|
||||
period: string
|
||||
groupBy: 'reporter' | 'station'
|
||||
myRank: number | null
|
||||
ranks: ReporterRank[] | StationRank[]
|
||||
}
|
||||
|
||||
export interface ReporterRank {
|
||||
rank: number
|
||||
name: string // reporter name
|
||||
station: string
|
||||
totalScore: number
|
||||
latestAt: string
|
||||
}
|
||||
|
||||
export interface StationRank {
|
||||
rank: number
|
||||
name: string // station name
|
||||
avgScore: number
|
||||
reporterCount: number
|
||||
avgQuality: number
|
||||
avgQuantity: number
|
||||
avgEfficiency: number
|
||||
avgCompliance: number
|
||||
}
|
||||
|
||||
export interface MeSummary {
|
||||
period: string
|
||||
score: {
|
||||
totalScore: number
|
||||
quality_score: number
|
||||
quantity_score: number
|
||||
efficiency_score: number
|
||||
compliance_score: number
|
||||
period: string
|
||||
computedAt: string
|
||||
ruleName: string
|
||||
} | null
|
||||
rank: number | null
|
||||
total: number
|
||||
message?: string
|
||||
}
|
||||
|
||||
// ── 类型 ────────────────────────────────────────────────────────────────────
|
||||
export interface Person {
|
||||
id: number
|
||||
code: string
|
||||
name: string
|
||||
station: string
|
||||
title: string | null
|
||||
phone: string | null
|
||||
joinedAt: string | null
|
||||
status: 'active' | 'inactive'
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface Station {
|
||||
id: number
|
||||
code: string
|
||||
name: string
|
||||
region: string | null
|
||||
address: string | null
|
||||
leader: string | null
|
||||
phone: string | null
|
||||
status: 'active' | 'inactive'
|
||||
establishedAt: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface Notice {
|
||||
id: number
|
||||
code: string
|
||||
title: string
|
||||
content: string | null
|
||||
priority: 'normal' | 'high' | 'urgent'
|
||||
scope: 'all' | 'station' | 'role'
|
||||
stations: string | null
|
||||
roles: string | null
|
||||
attachment: string | null
|
||||
publishedBy: string
|
||||
publishedAt: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface NoticeReceipt {
|
||||
id: number
|
||||
noticeId: number
|
||||
receiverName: string
|
||||
receiverRole: string
|
||||
read: number
|
||||
confirmed: number
|
||||
readAt: string | null
|
||||
confirmedAt: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface StatsOverview {
|
||||
total: number
|
||||
draft: number
|
||||
reviewing: number
|
||||
archived: number
|
||||
returned: number
|
||||
avgScore: number | null
|
||||
monthly: { month: string; count: number; avgScore: number | null }[]
|
||||
}
|
||||
|
||||
export interface StatsRecordRow {
|
||||
name: string
|
||||
station?: string
|
||||
type?: string
|
||||
total: number
|
||||
archived: number
|
||||
returned: number
|
||||
avgScore: number | null
|
||||
}
|
||||
|
||||
export interface StatsScoreRow {
|
||||
name: string
|
||||
station: string
|
||||
submitted: number
|
||||
archived: number
|
||||
returned: number
|
||||
totalScore: number
|
||||
avgScore: number | null
|
||||
maxScore: number | null
|
||||
minScore: number | null
|
||||
}
|
||||
|
||||
// V0.2 类型
|
||||
export interface Rule {
|
||||
id: number
|
||||
code: string
|
||||
name: string
|
||||
description: string | null
|
||||
periodType: 'quarterly' | 'custom'
|
||||
periodStart: string | null
|
||||
periodEnd: string | null
|
||||
status: 'draft' | 'active' | 'archived'
|
||||
version: number
|
||||
parentId: number | null
|
||||
createdBy: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
items?: RuleItem[]
|
||||
}
|
||||
|
||||
export interface RuleItem {
|
||||
id?: number
|
||||
ruleId?: number
|
||||
category: 'quantity' | 'quality' | 'efficiency' | 'compliance'
|
||||
name: string
|
||||
metric_key: string
|
||||
weight: number
|
||||
minScore?: number
|
||||
maxScore?: number
|
||||
formulaType: 'count' | 'avg_score' | 'rate'
|
||||
formulaParams: Record<string, unknown>
|
||||
displayOrder?: number
|
||||
enabled?: number
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
export interface Score {
|
||||
id: number
|
||||
code: string
|
||||
ruleId: number
|
||||
reporter: string
|
||||
station: string
|
||||
period: string
|
||||
periodType: string
|
||||
totalScore: number
|
||||
qualityScore: number
|
||||
quantityScore: number
|
||||
efficiencyScore: number
|
||||
complianceScore: number
|
||||
items: ScoreItem[]
|
||||
computedAt: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
ruleName?: string
|
||||
}
|
||||
|
||||
export interface ScoreItem {
|
||||
item_id: number
|
||||
category: string
|
||||
name: string
|
||||
metric_key: string
|
||||
metric_value: number | null
|
||||
raw_score: number
|
||||
weight: number
|
||||
weighted_score: number
|
||||
}
|
||||
|
||||
export interface ComputeResult {
|
||||
message: string
|
||||
results: { reporter: string; station: string; totalScore: number }[]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
|
||||
interface MetricCardProps {
|
||||
icon: LucideIcon
|
||||
label: string
|
||||
value: string
|
||||
delta?: string
|
||||
hint?: string
|
||||
color: string
|
||||
}
|
||||
|
||||
export function MetricCard({ icon: Icon, label, value, delta, hint, color }: MetricCardProps) {
|
||||
return (
|
||||
<div className="metric">
|
||||
<div className={`metric-icon ${color}`}>
|
||||
<Icon size={21} />
|
||||
</div>
|
||||
<div>
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
<small className={hint ? 'warn' : ''}>{delta ?? hint}</small>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
|
||||
interface PanelHeaderProps {
|
||||
title: string
|
||||
subtitle: string
|
||||
action?: string
|
||||
onAction?: () => void
|
||||
}
|
||||
|
||||
export function PanelHeader({ title, subtitle, action, onAction }: PanelHeaderProps) {
|
||||
return (
|
||||
<div className="panel-header">
|
||||
<div>
|
||||
<h2>{title}</h2>
|
||||
<p>{subtitle}</p>
|
||||
</div>
|
||||
{action && (
|
||||
<button onClick={onAction}>
|
||||
{action}
|
||||
<ChevronRight size={15} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { BookOpen, ChevronDown, ClipboardCheck, Files } from 'lucide-react'
|
||||
import { StatusBadge } from '../ui/StatusBadge'
|
||||
import { EmptyState } from '../ui/EmptyState'
|
||||
import type { WorkRecord } from '../../types'
|
||||
|
||||
interface RecordTableProps {
|
||||
records: WorkRecord[]
|
||||
onSelect: (r: WorkRecord) => void
|
||||
compact?: boolean
|
||||
review?: boolean
|
||||
}
|
||||
|
||||
export function RecordTable({ records, onSelect, compact = false, review = false }: RecordTableProps) {
|
||||
if (records.length === 0) {
|
||||
return <EmptyState icon={Files} text="没有符合条件的记录" />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="table-scroll">
|
||||
<table className={compact ? 'compact' : ''}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>记录信息</th>
|
||||
<th>记者 / 站点</th>
|
||||
<th>发生日期</th>
|
||||
<th>状态</th>
|
||||
{!compact && <th>得分</th>}
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{records.map(record => (
|
||||
<tr key={record.id} onClick={() => onSelect(record)}>
|
||||
<td data-label="记录信息">
|
||||
<div className="record-title">
|
||||
<span className="type-icon">
|
||||
<BookOpen size={16} />
|
||||
</span>
|
||||
<div>
|
||||
<strong>{record.title}</strong>
|
||||
<small>{record.id} · {record.type}</small>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td data-label="记者/站点">
|
||||
<strong className="cell-main">{record.reporter}</strong>
|
||||
<small>{record.station}</small>
|
||||
</td>
|
||||
<td data-label="发生日期">
|
||||
<span className="cell-main">{record.date}</span>
|
||||
<small>更新于 {record.updatedAt}</small>
|
||||
</td>
|
||||
<td data-label="状态">
|
||||
<StatusBadge status={record.status} />
|
||||
</td>
|
||||
{!compact && (
|
||||
<td data-label="得分">
|
||||
<strong>{record.score ?? '—'}</strong>
|
||||
</td>
|
||||
)}
|
||||
<td>
|
||||
<button className="icon-button" aria-label={review ? '开始审核' : '查看详情'}>
|
||||
<ChevronRight size={17} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { MetricCard } from './MetricCard'
|
||||
export { PanelHeader } from './PanelHeader'
|
||||
export { RecordTable } from './RecordTable'
|
||||
@@ -0,0 +1,114 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import {
|
||||
Archive, Bell, Building2, ClipboardCheck,
|
||||
FilePenLine, LayoutDashboard, Settings, Users, X, BarChart3, ScrollText,
|
||||
Trophy, Gavel, FileText, MapPin, Gauge, UserCircle,
|
||||
} from 'lucide-react'
|
||||
import { useRole } from '../../context'
|
||||
import { pathMap } from '../../routes'
|
||||
import type { Role } from '../../types'
|
||||
import type { PageKey } from '../../routes'
|
||||
|
||||
interface SidebarProps {
|
||||
page: PageKey
|
||||
onNavigate: (p: PageKey) => void
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
badge?: number
|
||||
}
|
||||
|
||||
type NavItem = { id: PageKey; label: string; icon: typeof LayoutDashboard; roles?: Role[] }
|
||||
type NavGroup = { title: string; items: NavItem[] }
|
||||
|
||||
const navGroups: NavGroup[] = [
|
||||
{
|
||||
title: '工作空间',
|
||||
items: [
|
||||
{ id: 'dashboard', label: '工作台', icon: LayoutDashboard },
|
||||
{ id: 'work', label: '工作记录', icon: FilePenLine },
|
||||
{ id: 'review', label: '审核中心', icon: ClipboardCheck, roles: ['headquarters', 'station'] },
|
||||
{ id: 'archive', label: '电子档案', icon: Archive },
|
||||
{ id: 'notices', label: '通知公告', icon: Bell },
|
||||
{ id: 'appeals', label: '申诉复议', icon: Gavel, roles: ['headquarters', 'station', 'reporter'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '组织管理',
|
||||
items: [
|
||||
{ id: 'people', label: '人员管理', icon: Users, roles: ['headquarters', 'station'] },
|
||||
{ id: 'stations', label: '记者站管理', icon: Building2, roles: ['headquarters'] },
|
||||
{ id: 'stationmap', label: '全国地图', icon: MapPin, roles: ['headquarters'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '考核分析',
|
||||
items: [
|
||||
{ id: 'rules', label: '考核规则', icon: ScrollText, roles: ['headquarters'] },
|
||||
{ id: 'scores', label: '评分结果', icon: BarChart3, roles: ['headquarters', 'station'] },
|
||||
{ id: 'leaderboard', label: '积分排行', icon: Trophy, roles: ['headquarters', 'station'] },
|
||||
{ id: 'cockpit', label: '管理驾驶舱', icon: Gauge, roles: ['headquarters'] },
|
||||
{ id: 'profile', label: '能力画像', icon: UserCircle },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '系统管理',
|
||||
items: [
|
||||
{ id: 'settings', label: '系统设置', icon: Settings, roles: ['headquarters'] },
|
||||
{ id: 'logs', label: '操作日志', icon: FileText, roles: ['headquarters'] },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export function Sidebar({ page, onNavigate, open, onClose, badge }: SidebarProps) {
|
||||
const { role } = useRole()
|
||||
|
||||
return (
|
||||
<aside className={`sidebar ${open ? 'open' : ''}`}>
|
||||
<div className="brand">
|
||||
<div className="brand-mark">记</div>
|
||||
<div>
|
||||
<strong>全国记者站</strong>
|
||||
<span>管理系统</span>
|
||||
</div>
|
||||
<button className="icon-button mobile-only" onClick={onClose} aria-label="关闭菜单">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<nav className="nav-list">
|
||||
{navGroups.map(group => {
|
||||
const visibleItems = group.items.filter(item => !item.roles || item.roles.includes(role))
|
||||
if (visibleItems.length === 0) return null
|
||||
return (
|
||||
<div key={group.title}>
|
||||
<span className="nav-label">{group.title}</span>
|
||||
{visibleItems.map(item => {
|
||||
const Icon = item.icon
|
||||
const showBadge = item.id === 'review' && badge != null && badge > 0
|
||||
const isActive = page === item.id
|
||||
return (
|
||||
<Link
|
||||
key={item.id}
|
||||
to={pathMap[item.id]}
|
||||
className={isActive ? 'active' : ''}
|
||||
onClick={() => { onNavigate(item.id); onClose() }}
|
||||
>
|
||||
<Icon size={19} />
|
||||
<span>{item.label}</span>
|
||||
{showBadge && <b>{badge}</b>}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
<div className="sidebar-footer">
|
||||
<div className="system-state">
|
||||
<i />
|
||||
<span>系统运行正常</span>
|
||||
<small>V0.3</small>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Bell, ChevronRight, LogOut, Menu, ShieldCheck } from 'lucide-react'
|
||||
import { useRole } from '../../context'
|
||||
import type { Role } from '../../types'
|
||||
|
||||
const roleLabels: Record<Role, string> = {
|
||||
headquarters: '总部管理员',
|
||||
station: '分站负责人',
|
||||
reporter: '记者',
|
||||
}
|
||||
|
||||
interface TopBarProps {
|
||||
pageTitle: string
|
||||
onMenuToggle: () => void
|
||||
}
|
||||
|
||||
export function TopBar({ pageTitle, onMenuToggle }: TopBarProps) {
|
||||
const { role, identity, isAuthenticated, logout } = useRole()
|
||||
|
||||
const handleLogout = () => {
|
||||
if (isAuthenticated) {
|
||||
logout()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="topbar">
|
||||
<button className="icon-button mobile-only" onClick={onMenuToggle} aria-label="打开菜单">
|
||||
<Menu size={21} />
|
||||
</button>
|
||||
<div className="breadcrumb">
|
||||
<span>记者站管理</span>
|
||||
<ChevronRight size={15} />
|
||||
<strong>{pageTitle}</strong>
|
||||
</div>
|
||||
<div className="top-actions">
|
||||
<button className="icon-button notification" aria-label="通知">
|
||||
<Bell size={19} />
|
||||
<i />
|
||||
</button>
|
||||
<div className="role-badge">
|
||||
<ShieldCheck size={17} />
|
||||
<span>{roleLabels[role]}</span>
|
||||
</div>
|
||||
<div className="user-box">
|
||||
<div className="avatar">{identity.name[0]}</div>
|
||||
<div>
|
||||
<strong>{identity.name}</strong>
|
||||
<span>{roleLabels[role]}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button className="icon-button" onClick={handleLogout} aria-label="退出登录" title={isAuthenticated ? '退出登录' : '退出演示'}>
|
||||
<LogOut size={17} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { Sidebar } from './Sidebar'
|
||||
export { TopBar } from './TopBar'
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useState } from 'react'
|
||||
import { AlertTriangle, X } from 'lucide-react'
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
open: boolean
|
||||
title: string
|
||||
message: string
|
||||
confirmLabel?: string
|
||||
danger?: boolean
|
||||
loading?: boolean
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
open, title, message, confirmLabel = '确认', danger = false,
|
||||
loading = false, onConfirm, onCancel,
|
||||
}: ConfirmDialogProps) {
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="modal-layer" onClick={onCancel}>
|
||||
<div className="modal confirm-modal" role="dialog" aria-modal="true" onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-head">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
{danger && (
|
||||
<div style={{
|
||||
width: 36, height: 36, borderRadius: '50%',
|
||||
background: '#f8e8e6', color: 'var(--red)',
|
||||
display: 'grid', placeItems: 'center', flex: 'none',
|
||||
}}>
|
||||
<AlertTriangle size={18} />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h2 style={{ fontSize: 16, margin: 0 }}>{title}</h2>
|
||||
<p style={{ fontSize: 11, color: 'var(--muted)', margin: '4px 0 0' }}>{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button className="icon-button" onClick={onCancel} disabled={loading}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button className="secondary-button" onClick={onCancel} disabled={loading}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className={danger ? 'danger-button' : 'primary-button'}
|
||||
onClick={onConfirm}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? '处理中...' : confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon: LucideIcon
|
||||
text: string
|
||||
}
|
||||
|
||||
export function EmptyState({ icon: Icon, text }: EmptyStateProps) {
|
||||
return (
|
||||
<div className="empty">
|
||||
<Icon size={30} />
|
||||
<span>{text}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export function LoadingBar() {
|
||||
return (
|
||||
<div className="loading-bar">
|
||||
<i />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { Person } from '../../api'
|
||||
|
||||
const labels: Record<string, string> = { active: '在职', inactive: '停用' }
|
||||
const tone: Record<string, string> = { active: 'success', inactive: 'danger' }
|
||||
|
||||
export function PersonStatusBadge({ status }: { status: Person['status'] }) {
|
||||
return (
|
||||
<span className={`status ${tone[status]}`}>
|
||||
<i />
|
||||
{labels[status]}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { Station } from '../../api'
|
||||
|
||||
const labels: Record<string, string> = { active: '在运', inactive: '停运' }
|
||||
const tone: Record<string, string> = { active: 'success', inactive: 'danger' }
|
||||
|
||||
export function StationStatusBadge({ status }: { status: Station['status'] }) {
|
||||
return (
|
||||
<span className={`status ${tone[status]}`}>
|
||||
<i />
|
||||
{labels[status]}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { WorkStatus } from '../../types'
|
||||
|
||||
const labels: Record<WorkStatus, string> = {
|
||||
draft: '草稿',
|
||||
station_review: '待分站审核',
|
||||
headquarters_review: '待总部复核',
|
||||
returned: '已退回',
|
||||
archived: '已归档',
|
||||
}
|
||||
const tone: Record<WorkStatus, string> = {
|
||||
draft: 'neutral',
|
||||
station_review: 'warning',
|
||||
headquarters_review: 'info',
|
||||
returned: 'danger',
|
||||
archived: 'success',
|
||||
}
|
||||
|
||||
export function StatusBadge({ status }: { status: WorkStatus }) {
|
||||
return (
|
||||
<span className={`status ${tone[status]}`}>
|
||||
<i />
|
||||
{labels[status]}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export { labels, tone }
|
||||
@@ -0,0 +1,3 @@
|
||||
export { StatusBadge } from './StatusBadge'
|
||||
export { EmptyState } from './EmptyState'
|
||||
export { LoadingBar } from './LoadingBar'
|
||||
@@ -0,0 +1,85 @@
|
||||
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react'
|
||||
import type { Role } from '../types'
|
||||
|
||||
interface Identity { name: string; station: string | null }
|
||||
|
||||
interface RoleContextValue {
|
||||
role: Role
|
||||
setRole: (role: Role) => void
|
||||
switchRole: (role: Role) => void
|
||||
identity: Identity
|
||||
isAuthenticated: boolean
|
||||
login: (token: string, role: Role, name: string, station: string | null) => void
|
||||
logout: () => void
|
||||
}
|
||||
|
||||
const demoIdentities: Record<Role, Identity> = {
|
||||
headquarters: { name: '林致远', station: null },
|
||||
station: { name: '苏明远', station: '北京记者站' },
|
||||
reporter: { name: '林晓', station: '北京记者站' },
|
||||
}
|
||||
|
||||
const RoleContext = createContext<RoleContextValue>({
|
||||
role: 'headquarters',
|
||||
setRole: () => {},
|
||||
switchRole: () => {},
|
||||
identity: demoIdentities.headquarters,
|
||||
isAuthenticated: false,
|
||||
login: () => {},
|
||||
logout: () => {},
|
||||
})
|
||||
|
||||
export function RoleProvider({ children }: { children: ReactNode }) {
|
||||
const [role, setRole] = useState<Role>(() => {
|
||||
const saved = localStorage.getItem('auth_role')
|
||||
return (saved as Role) || 'headquarters'
|
||||
})
|
||||
const [identity, setIdentity] = useState<Identity>(() => {
|
||||
const savedName = localStorage.getItem('auth_name')
|
||||
const savedStation = localStorage.getItem('auth_station')
|
||||
const savedRole = localStorage.getItem('auth_role') as Role
|
||||
if (savedName) return { name: savedName, station: savedStation === 'null' ? null : savedStation }
|
||||
return demoIdentities[savedRole || 'headquarters']
|
||||
})
|
||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(() => !!localStorage.getItem('auth_token'))
|
||||
|
||||
const login = useCallback((token: string, newRole: Role, name: string, station: string | null) => {
|
||||
localStorage.setItem('auth_token', token)
|
||||
localStorage.setItem('auth_role', newRole)
|
||||
localStorage.setItem('auth_name', name)
|
||||
localStorage.setItem('auth_station', String(station))
|
||||
setRole(newRole)
|
||||
setIdentity({ name, station })
|
||||
setIsAuthenticated(true)
|
||||
}, [])
|
||||
|
||||
const logout = useCallback(() => {
|
||||
localStorage.removeItem('auth_token')
|
||||
localStorage.removeItem('auth_role')
|
||||
localStorage.removeItem('auth_name')
|
||||
localStorage.removeItem('auth_station')
|
||||
setRole('headquarters')
|
||||
setIdentity(demoIdentities.headquarters)
|
||||
setIsAuthenticated(false)
|
||||
}, [])
|
||||
|
||||
const setRoleWrapper = useCallback((newRole: Role) => {
|
||||
setRole(newRole)
|
||||
if (!localStorage.getItem('auth_token')) {
|
||||
setIdentity(demoIdentities[newRole])
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<RoleContext.Provider value={{
|
||||
role, setRole: setRoleWrapper, switchRole: setRoleWrapper,
|
||||
identity, isAuthenticated, login, logout,
|
||||
}}>
|
||||
{children}
|
||||
</RoleContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useRole() {
|
||||
return useContext(RoleContext)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react'
|
||||
import { Check } from 'lucide-react'
|
||||
|
||||
interface ToastItem {
|
||||
id: number
|
||||
message: string
|
||||
}
|
||||
|
||||
interface ToastContextValue {
|
||||
showToast: (message: string) => void
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextValue>({ showToast: () => {} })
|
||||
|
||||
let toastId = 0
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toasts, setToasts] = useState<ToastItem[]>([])
|
||||
|
||||
const showToast = useCallback((message: string) => {
|
||||
const id = ++toastId
|
||||
setToasts(prev => [...prev, { id, message }])
|
||||
setTimeout(() => {
|
||||
setToasts(prev => prev.filter(t => t.id !== id))
|
||||
}, 2400)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ showToast }}>
|
||||
{children}
|
||||
{toasts.map(t => (
|
||||
<div key={t.id} className="toast">
|
||||
<Check size={17} />
|
||||
{t.message}
|
||||
</div>
|
||||
))}
|
||||
</ToastContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
return useContext(ToastContext)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { RoleProvider, useRole } from './RoleContext'
|
||||
export { ToastProvider, useToast } from './ToastContext'
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import type { WorkRecord } from './types'
|
||||
|
||||
export const initialRecords: WorkRecord[] = [
|
||||
{ id: 'WK-202607-086', title: '暑运客流持续攀升,多部门保障出行', type: '文字稿件', reporter: '林晓', station: '北京记者站', date: '2026-07-30', platform: '全国日报', status: 'station_review', updatedAt: '今天 09:42' },
|
||||
{ id: 'WK-202607-081', title: '老街更新:城市记忆与新消费共生', type: '视频供稿', reporter: '周宁', station: '北京记者站', date: '2026-07-29', platform: '新闻客户端', status: 'headquarters_review', score: 8, updatedAt: '昨天 17:26' },
|
||||
{ id: 'WK-202607-074', title: '长三角一体化重点项目集中签约', type: '重要报道', reporter: '陈屿', station: '上海记者站', date: '2026-07-28', platform: '全国日报', status: 'headquarters_review', score: 12, updatedAt: '07-29 15:18' },
|
||||
{ id: 'WK-202607-063', title: '县域公共文化服务观察', type: '图片供稿', reporter: '许言', station: '浙江记者站', date: '2026-07-26', platform: '新闻周刊', status: 'returned', reviewNote: '请补充刊发版面截图,并核对发布日期。', updatedAt: '07-28 11:05' },
|
||||
{ id: 'WK-202607-052', title: '防汛一线应急响应纪实', type: '文字稿件', reporter: '方澄', station: '广东记者站', date: '2026-07-24', platform: '全国日报', status: 'archived', score: 10, updatedAt: '07-26 16:31' },
|
||||
{ id: 'WK-202607-041', title: '融合报道生产能力专题培训', type: '培训参与', reporter: '林晓', station: '北京记者站', date: '2026-07-22', platform: '总部培训中心', status: 'archived', score: 3, updatedAt: '07-23 10:20' },
|
||||
{ id: 'WK-202607-032', title: '社区养老服务站走访', type: '文字稿件', reporter: '周宁', station: '北京记者站', date: '2026-07-18', platform: '新闻客户端', status: 'draft', updatedAt: '07-18 18:42' },
|
||||
]
|
||||
|
||||
export const stationRanking = [
|
||||
{ name: '北京站', score: 92, records: 186 },
|
||||
{ name: '广东站', score: 88, records: 174 },
|
||||
{ name: '上海站', score: 86, records: 168 },
|
||||
{ name: '浙江站', score: 81, records: 151 },
|
||||
{ name: '四川站', score: 78, records: 143 },
|
||||
]
|
||||
|
||||
export const monthlyTrend = [
|
||||
{ month: '2月', records: 412, score: 71 },
|
||||
{ month: '3月', records: 486, score: 74 },
|
||||
{ month: '4月', records: 451, score: 73 },
|
||||
{ month: '5月', records: 538, score: 78 },
|
||||
{ month: '6月', records: 572, score: 81 },
|
||||
{ month: '7月', records: 621, score: 84 },
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
export { useRecords } from './useRecords'
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../api'
|
||||
import { useRole } from '../context'
|
||||
import type { WorkRecord } from '../types'
|
||||
|
||||
export function useRecords() {
|
||||
const { role } = useRole()
|
||||
const [records, setRecords] = useState<WorkRecord[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
setLoading(true)
|
||||
setError('')
|
||||
api.records(role)
|
||||
.then(data => { if (active) setRecords(data) })
|
||||
.catch(err => { if (active) setError(err.message) })
|
||||
.finally(() => { if (active) setLoading(false) })
|
||||
return () => { active = false }
|
||||
}, [role])
|
||||
|
||||
const updateRecord = async (id: string, patch: Partial<WorkRecord>) => {
|
||||
const updated = await api.reviewRecord(role, id, {
|
||||
decision: patch.status === 'returned' ? 'return' : 'pass',
|
||||
score: patch.score ?? 0,
|
||||
note: patch.reviewNote ?? '',
|
||||
})
|
||||
setRecords(items => items.map(item => item.id === id ? updated : item))
|
||||
return updated
|
||||
}
|
||||
|
||||
const createRecord = async (record: WorkRecord) => {
|
||||
const created = await api.createRecord(role, record as any)
|
||||
setRecords(items => [created, ...items])
|
||||
return created
|
||||
}
|
||||
|
||||
return { records, loading, error, updateRecord, createRecord, setRecords }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App'
|
||||
import './styles.css'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,185 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { Files, Plus, X, Paperclip } from 'lucide-react'
|
||||
import { api } from '../api'
|
||||
import { useRole, useToast } from '../context'
|
||||
import type { WorkRecord, WorkType, Attachment } from '../types'
|
||||
|
||||
interface CreateRecordModalProps {
|
||||
onClose: () => void
|
||||
onSubmit: (record: WorkRecord) => Promise<void>
|
||||
}
|
||||
|
||||
export function CreateRecordModal({ onClose, onSubmit }: CreateRecordModalProps) {
|
||||
const { role } = useRole()
|
||||
const { showToast } = useToast()
|
||||
const [title, setTitle] = useState('')
|
||||
const [type, setType] = useState<WorkType>('文字稿件')
|
||||
const [platform, setPlatform] = useState('')
|
||||
const [date, setDate] = useState('2026-08-01')
|
||||
const [description, setDescription] = useState('')
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([])
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [savingDraft, setSavingDraft] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const canSubmit = title.trim() && platform.trim() && date
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files
|
||||
if (!files) return
|
||||
const newAttachments: Attachment[] = Array.from(files).map(f => ({
|
||||
name: f.name,
|
||||
url: URL.createObjectURL(f),
|
||||
size: f.size,
|
||||
type: f.type,
|
||||
}))
|
||||
setAttachments(prev => [...prev, ...newAttachments])
|
||||
}
|
||||
|
||||
const removeAttachment = (idx: number) => {
|
||||
setAttachments(prev => prev.filter((_, i) => i !== idx))
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!canSubmit || submitting) return
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const created = await api.createRecord(role, {
|
||||
title,
|
||||
type,
|
||||
date,
|
||||
platform,
|
||||
description,
|
||||
attachments,
|
||||
})
|
||||
await onSubmit(created)
|
||||
showToast('工作记录已提交,进入分站审核')
|
||||
} catch (e) {
|
||||
showToast(e instanceof Error ? e.message : '提交失败')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveDraft = async () => {
|
||||
if (!title.trim() || savingDraft) return
|
||||
setSavingDraft(true)
|
||||
try {
|
||||
const created = await api.createRecord(role, {
|
||||
title,
|
||||
type,
|
||||
date,
|
||||
platform,
|
||||
description,
|
||||
attachments,
|
||||
isDraft: true,
|
||||
})
|
||||
await onSubmit(created)
|
||||
showToast('草稿已保存')
|
||||
onClose()
|
||||
} catch (e) {
|
||||
showToast(e instanceof Error ? e.message : '保存失败')
|
||||
} finally {
|
||||
setSavingDraft(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-layer">
|
||||
<div className="modal">
|
||||
<div className="modal-head">
|
||||
<div>
|
||||
<h2>新建工作记录</h2>
|
||||
<p>提交后将进入分站初审</p>
|
||||
</div>
|
||||
<button className="icon-button" onClick={onClose}><X size={20} /></button>
|
||||
</div>
|
||||
<div className="form-grid">
|
||||
<label className="full">
|
||||
<span>工作标题 <b>*</b></span>
|
||||
<input
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
placeholder="请输入稿件或工作标题"
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>工作类型 <b>*</b></span>
|
||||
<select value={type} onChange={e => setType(e.target.value as WorkType)}>
|
||||
{['文字稿件','视频供稿','图片供稿','重要报道','培训参与','临时工作'].map(t => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>发生 / 刊发日期 <b>*</b></span>
|
||||
<input type="date" value={date} onChange={e => setDate(e.target.value)} />
|
||||
</label>
|
||||
<label className="full">
|
||||
<span>媒体 / 平台 <b>*</b></span>
|
||||
<input
|
||||
value={platform}
|
||||
onChange={e => setPlatform(e.target.value)}
|
||||
placeholder="请输入刊发媒体、平台或工作来源"
|
||||
/>
|
||||
</label>
|
||||
<label className="full">
|
||||
<span>工作说明</span>
|
||||
<textarea
|
||||
rows={4}
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
placeholder="补充说明工作内容、成果及相关情况"
|
||||
/>
|
||||
</label>
|
||||
<label className="full">
|
||||
<span>证明材料</span>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
<div className="upload-box" onClick={() => fileInputRef.current?.click()}>
|
||||
<Files size={22} />
|
||||
<strong>点击上传或拖放文件</strong>
|
||||
<small>支持图片、PDF、Office 文档,单文件不超过 20 MB</small>
|
||||
</div>
|
||||
{attachments.length > 0 && (
|
||||
<div className="attachment-list">
|
||||
{attachments.map((att, idx) => (
|
||||
<div key={idx} className="attachment-item">
|
||||
<Paperclip size={14} />
|
||||
<span>{att.name}</span>
|
||||
<button type="button" className="icon-button small" onClick={(e) => { e.stopPropagation(); removeAttachment(idx) }}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button className="secondary-button" onClick={onClose}>取消</button>
|
||||
<button
|
||||
className="secondary-button"
|
||||
disabled={!title.trim() || savingDraft}
|
||||
onClick={handleSaveDraft}
|
||||
>
|
||||
{savingDraft ? '保存中...' : '保存草稿'}
|
||||
</button>
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={!canSubmit || submitting}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{submitting ? '提交中...' : '提交审核'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Calendar, MapPin, Phone, User, Building2, FileText, X, Pencil } from 'lucide-react'
|
||||
import type { Person } from '../api'
|
||||
import { PersonStatusBadge } from '../components/ui/PersonStatusBadge'
|
||||
|
||||
interface PersonDrawerProps {
|
||||
person: Person
|
||||
canEdit: boolean
|
||||
onEdit: (p: Person) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function PersonDrawer({ person, canEdit, onEdit, onClose }: PersonDrawerProps) {
|
||||
return (
|
||||
<div className="drawer-layer" onClick={onClose}>
|
||||
<aside className="drawer" onClick={e => e.stopPropagation()}>
|
||||
<div className="drawer-head">
|
||||
<div>
|
||||
<span>{person.code}</span>
|
||||
<h2>{person.name}</h2>
|
||||
</div>
|
||||
<button className="icon-button" onClick={onClose}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="drawer-body">
|
||||
{/* 头像区域 */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 14,
|
||||
padding: '18px 20px', background: '#f7f5f2',
|
||||
borderRadius: 5, marginBottom: 20,
|
||||
}}>
|
||||
<div className="avatar large">{person.name[0]}</div>
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<strong style={{ fontSize: 17 }}>{person.name}</strong>
|
||||
<PersonStatusBadge status={person.status} />
|
||||
</div>
|
||||
<p style={{ fontSize: 11, color: 'var(--muted)', margin: '4px 0 0' }}>
|
||||
{person.title || '未填写职务'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 基本信息网格 */}
|
||||
<div className="detail-grid">
|
||||
<span><small>人员编号</small><strong>{person.code}</strong></span>
|
||||
<span><small>所属站点</small><strong>{person.station}</strong></span>
|
||||
<span><small>手机号码</small><strong>{person.phone || '—'}</strong></span>
|
||||
<span><small>账号状态</small>
|
||||
<strong style={{ color: person.status === 'active' ? '#337a4d' : '#a72d23' }}>
|
||||
{person.status === 'active' ? '在职' : '已停用'}
|
||||
</strong>
|
||||
</span>
|
||||
<span><small>入站时间</small><strong>{person.joinedAt || '—'}</strong></span>
|
||||
<span><small>职务</small><strong>{person.title || '—'}</strong></span>
|
||||
</div>
|
||||
|
||||
{/* 时间线 */}
|
||||
<div className="detail-section">
|
||||
<h4>档案记录</h4>
|
||||
<div className="timeline">
|
||||
<div>
|
||||
<i className="done" />
|
||||
<span>
|
||||
<strong>人员档案创建</strong>
|
||||
<small>{person.createdAt}</small>
|
||||
</span>
|
||||
</div>
|
||||
{person.updatedAt !== person.createdAt && (
|
||||
<div>
|
||||
<i className="done" />
|
||||
<span>
|
||||
<strong>信息更新</strong>
|
||||
<small>{person.updatedAt}</small>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{person.status === 'inactive' && (
|
||||
<div>
|
||||
<i className="done" style={{ background: '#e6b8b3', borderColor: '#e6b8b3' }} />
|
||||
<span>
|
||||
<strong>账号停用</strong>
|
||||
<small>已停用该账号</small>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="drawer-actions">
|
||||
<button className="secondary-button" onClick={onClose}>关闭</button>
|
||||
{canEdit && (
|
||||
<button className="primary-button" onClick={() => { onClose(); onEdit(person) }}>
|
||||
<Pencil size={15} />
|
||||
编辑信息
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { Check, ChevronRight, Files, X, Eye, Download, ImageIcon, FileText, ArrowLeft } from 'lucide-react'
|
||||
import { StatusBadge } from '../components/ui/StatusBadge'
|
||||
import type { Role, WorkRecord, Attachment } from '../types'
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
draft: '草稿',
|
||||
station_review: '待分站审核',
|
||||
headquarters_review: '待总部复核',
|
||||
returned: '已退回',
|
||||
archived: '已归档',
|
||||
}
|
||||
|
||||
interface RecordDrawerProps {
|
||||
record: WorkRecord
|
||||
role: Role
|
||||
onClose: () => void
|
||||
onUpdate: (id: string, patch: Partial<WorkRecord>) => Promise<boolean>
|
||||
onNotify: (msg: string) => void
|
||||
}
|
||||
|
||||
/** 解析附件列表 */
|
||||
function parseAttachments(raw: WorkRecord['attachments']): Attachment[] {
|
||||
if (!raw) return []
|
||||
if (typeof raw === 'string') {
|
||||
try { return JSON.parse(raw) as Attachment[] } catch { return [] }
|
||||
}
|
||||
if (Array.isArray(raw)) return raw
|
||||
return []
|
||||
}
|
||||
|
||||
/** 判断是否为图片类型 */
|
||||
function isImage(url: string): boolean {
|
||||
return /\.(jpg|jpeg|png|gif|bmp|webp|svg)$/i.test(url)
|
||||
}
|
||||
|
||||
/** 判断是否为 PDF 类型 */
|
||||
function isPdf(url: string): boolean {
|
||||
return /\.pdf$/i.test(url)
|
||||
}
|
||||
|
||||
/** 格式化文件大小 */
|
||||
function formatSize(bytes?: number): string {
|
||||
if (!bytes) return ''
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
export function RecordDrawer({ record, role, onClose, onUpdate, onNotify }: RecordDrawerProps) {
|
||||
const [note, setNote] = useState('')
|
||||
const [score, setScore] = useState(record.score ?? (record.type === '重要报道' ? 12 : 8))
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
|
||||
|
||||
const attachments = useMemo(() => parseAttachments(record.attachments), [record.attachments])
|
||||
|
||||
const isStation = role === 'station' && record.status === 'station_review'
|
||||
const isHq = role === 'headquarters' && record.status === 'headquarters_review'
|
||||
const canReview = isStation || isHq
|
||||
|
||||
const pass = async () => {
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const ok = await onUpdate(record.id, {
|
||||
status: isStation ? 'headquarters_review' : 'archived',
|
||||
score,
|
||||
reviewNote: note || '材料完整,审核通过。',
|
||||
})
|
||||
if (ok) {
|
||||
onNotify(isStation ? '初审通过,已提交总部复核' : '复核通过,记录已归档')
|
||||
onClose()
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const returnRecord = async () => {
|
||||
if (!note.trim()) return
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const ok = await onUpdate(record.id, { status: 'returned', score, reviewNote: note })
|
||||
if (ok) {
|
||||
onNotify('记录已退回填报人修改')
|
||||
onClose()
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="drawer-layer" onClick={onClose}>
|
||||
<aside className="drawer" onClick={e => e.stopPropagation()}>
|
||||
<div className="drawer-head">
|
||||
<div>
|
||||
<span>{record.id}</span>
|
||||
<h2>工作记录详情</h2>
|
||||
</div>
|
||||
<button className="icon-button" onClick={onClose}><X size={20} /></button>
|
||||
</div>
|
||||
<div className="drawer-body">
|
||||
<div className="record-hero">
|
||||
<StatusBadge status={record.status} />
|
||||
<h3>{record.title}</h3>
|
||||
<p>{record.type} · {record.platform}</p>
|
||||
</div>
|
||||
<div className="detail-grid">
|
||||
<span><small>填报人</small><strong>{record.reporter}</strong></span>
|
||||
<span><small>所属站点</small><strong>{record.station}</strong></span>
|
||||
<span><small>发生日期</small><strong>{record.date}</strong></span>
|
||||
<span><small>当前得分</small><strong>{record.score ?? '待核定'}</strong></span>
|
||||
</div>
|
||||
<div className="detail-section">
|
||||
<h4>工作说明</h4>
|
||||
<p>{record.description ?? '按计划完成现场采访、资料核验与稿件编发,相关证明材料已随记录提交。'}</p>
|
||||
</div>
|
||||
<div className="detail-section">
|
||||
<h4>证明材料</h4>
|
||||
{attachments.length > 0 ? (
|
||||
<div className="attachment-list">
|
||||
{attachments.map((att, i) => {
|
||||
const img = isImage(att.url)
|
||||
const pdf = isPdf(att.url)
|
||||
return (
|
||||
<div key={i} className="attachment-item">
|
||||
<button
|
||||
className="attachment"
|
||||
onClick={() => {
|
||||
if (img) setPreviewUrl(att.url)
|
||||
else if (pdf) window.open(att.url, '_blank')
|
||||
else window.open(att.url, '_blank')
|
||||
}}
|
||||
>
|
||||
{img ? <ImageIcon size={18} /> : <FileText size={18} />}
|
||||
<span>
|
||||
<strong>{att.name}</strong>
|
||||
<small>{formatSize(att.size)}{att.type ? ` · ${att.type}` : ''}</small>
|
||||
</span>
|
||||
<Eye size={16} />
|
||||
</button>
|
||||
<a
|
||||
href={att.url}
|
||||
download={att.name}
|
||||
className="attachment-download"
|
||||
aria-label="下载"
|
||||
>
|
||||
<Download size={16} />
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="attachment-list">
|
||||
<button className="attachment" disabled>
|
||||
<Files size={18} />
|
||||
<span>
|
||||
<strong>刊发证明材料.pdf</strong>
|
||||
<small>2.4 MB · 已完成安全检测</small>
|
||||
</span>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="timeline">
|
||||
<h4>流转记录</h4>
|
||||
<div>
|
||||
<i className="done" />
|
||||
<span><strong>记者提交</strong><small>{record.reporter} · {record.date} 09:42</small></span>
|
||||
</div>
|
||||
{record.status !== 'station_review' && record.status !== 'draft' && (
|
||||
<div>
|
||||
<i className="done" />
|
||||
<span><strong>分站初审通过</strong><small>分站负责人 · 审核意见已记录</small></span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<i />
|
||||
<span>
|
||||
<strong>{record.status === 'archived' ? '总部复核通过,完成归档' : statusLabels[record.status]}</strong>
|
||||
<small>{record.reviewNote ?? '等待当前处理人操作'}</small>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{canReview && (
|
||||
<div className="review-box">
|
||||
<h4>{isStation ? '分站初审' : '总部复核'}</h4>
|
||||
<label>
|
||||
<span>核定得分</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={score}
|
||||
onChange={e => setScore(Number(e.target.value))}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>审核意见</span>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={note}
|
||||
onChange={e => setNote(e.target.value)}
|
||||
placeholder="填写审核意见(退回时必填)"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="drawer-actions">
|
||||
{canReview ? (
|
||||
<>
|
||||
<button
|
||||
className="danger-button"
|
||||
disabled={!note.trim() || submitting}
|
||||
onClick={returnRecord}
|
||||
>
|
||||
<ArrowLeft size={17} />
|
||||
退回修改
|
||||
</button>
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={submitting}
|
||||
onClick={pass}
|
||||
>
|
||||
<Check size={17} />
|
||||
审核通过
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button className="secondary-button" onClick={onClose}>关闭</button>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* 图片预览弹窗 */}
|
||||
{previewUrl && (
|
||||
<div
|
||||
className="image-preview-overlay"
|
||||
onClick={() => setPreviewUrl(null)}
|
||||
style={{
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
background: 'rgba(0,0,0,0.75)', zIndex: 10000,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => setPreviewUrl(null)}
|
||||
aria-label="关闭预览"
|
||||
style={{
|
||||
position: 'absolute', top: 16, right: 16,
|
||||
background: 'rgba(255,255,255,0.15)', border: 'none',
|
||||
borderRadius: '50%', width: 36, height: 36,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', color: 'white',
|
||||
}}
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="预览"
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{
|
||||
maxWidth: '90%', maxHeight: '85vh',
|
||||
borderRadius: 8, boxShadow: '0 4px 24px rgba(0,0,0,0.3)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Building2, MapPin, Phone, User, Calendar, FileText, X, Pencil } from 'lucide-react'
|
||||
import type { Station } from '../api'
|
||||
import { StationStatusBadge } from '../components/ui/StationStatusBadge'
|
||||
|
||||
interface StationDrawerProps {
|
||||
station: Station
|
||||
canEdit: boolean
|
||||
onEdit: (s: Station) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function StationDrawer({ station, canEdit, onEdit, onClose }: StationDrawerProps) {
|
||||
return (
|
||||
<div className="drawer-layer" onClick={onClose}>
|
||||
<aside className="drawer" onClick={e => e.stopPropagation()}>
|
||||
<div className="drawer-head">
|
||||
<div>
|
||||
<span>{station.code}</span>
|
||||
<h2>{station.name}</h2>
|
||||
</div>
|
||||
<button className="icon-button" onClick={onClose}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="drawer-body">
|
||||
{/* 站点图标 + 状态 */}
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 14,
|
||||
padding: '18px 20px', background: '#f7f5f2',
|
||||
borderRadius: 5, marginBottom: 20,
|
||||
}}>
|
||||
<div style={{
|
||||
width: 52, height: 52, borderRadius: 6,
|
||||
background: '#f4e8e4', color: 'var(--red)',
|
||||
display: 'grid', placeItems: 'center',
|
||||
}}>
|
||||
<Building2 size={26} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<strong style={{ fontSize: 17 }}>{station.name}</strong>
|
||||
<StationStatusBadge status={station.status} />
|
||||
</div>
|
||||
<p style={{ fontSize: 11, color: 'var(--muted)', margin: '4px 0 0' }}>
|
||||
{station.region ? `${station.region}地区` : '未设置地区'}
|
||||
{station.leader ? ` · 负责人 ${station.leader}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 基本信息 */}
|
||||
<div className="detail-grid">
|
||||
<span><small>站点编码</small><strong>{station.code}</strong></span>
|
||||
<span><small>所属地区</small><strong>{station.region || '—'}</strong></span>
|
||||
<span><small>联系电话</small><strong>{station.phone || '—'}</strong></span>
|
||||
<span><small>运行状态</small>
|
||||
<strong style={{ color: station.status === 'active' ? '#337a4d' : '#a72d23' }}>
|
||||
{station.status === 'active' ? '在运' : '已停运'}
|
||||
</strong>
|
||||
</span>
|
||||
<span><small>建站时间</small><strong>{station.establishedAt || '—'}</strong></span>
|
||||
<span><small>负责人</small><strong>{station.leader || '—'}</strong></span>
|
||||
<span style={{ gridColumn: '1 / -1' }}><small>站点地址</small><strong>{station.address || '—'}</strong></span>
|
||||
</div>
|
||||
|
||||
{/* 时间线 */}
|
||||
<div className="detail-section">
|
||||
<h4>站点记录</h4>
|
||||
<div className="timeline">
|
||||
<div>
|
||||
<i className="done" />
|
||||
<span>
|
||||
<strong>站点创建</strong>
|
||||
<small>{station.createdAt}</small>
|
||||
</span>
|
||||
</div>
|
||||
{station.updatedAt !== station.createdAt && (
|
||||
<div>
|
||||
<i className="done" />
|
||||
<span>
|
||||
<strong>信息更新</strong>
|
||||
<small>{station.updatedAt}</small>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{station.status === 'inactive' && (
|
||||
<div>
|
||||
<i className="done" style={{ background: '#e6b8b3', borderColor: '#e6b8b3' }} />
|
||||
<span>
|
||||
<strong>站点停运</strong>
|
||||
<small>已停止运营</small>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="drawer-actions">
|
||||
<button className="secondary-button" onClick={onClose}>关闭</button>
|
||||
{canEdit && (
|
||||
<button className="primary-button" onClick={() => { onClose(); onEdit(station) }}>
|
||||
<Pencil size={15} />
|
||||
编辑站点
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { CreateRecordModal } from './CreateRecordModal'
|
||||
export { RecordDrawer } from './RecordDrawer'
|
||||
@@ -0,0 +1,176 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Gavel, Clock, CheckCircle2, XCircle, Loader2, MessageSquare, X } from 'lucide-react'
|
||||
import { useRole } from '../../context'
|
||||
import { useToast } from '../../context'
|
||||
import { api } from '../../api'
|
||||
import { EmptyState } from '../../components/ui/EmptyState'
|
||||
import type { Appeal } from '../../types'
|
||||
|
||||
const statusLabels: Record<string, { label: string; color: string }> = {
|
||||
pending: { label: '待处理', color: 'amber' },
|
||||
accepted: { label: '已通过', color: 'emerald' },
|
||||
rejected: { label: '已驳回', color: 'rose' },
|
||||
}
|
||||
|
||||
/**
|
||||
* 申诉复议页面 — 记者提交申诉,分站/总部处理
|
||||
*/
|
||||
export function AppealsPage() {
|
||||
const { role } = useRole()
|
||||
const { showToast } = useToast()
|
||||
const [appeals, setAppeals] = useState<Appeal[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [filterStatus, setFilterStatus] = useState('')
|
||||
const [selected, setSelected] = useState<Appeal | null>(null)
|
||||
const [response, setResponse] = useState('')
|
||||
const [handling, setHandling] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await api.appeals.list(role, filterStatus || undefined)
|
||||
setAppeals(data)
|
||||
} catch (err) {
|
||||
showToast(err instanceof Error ? err.message : '加载失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [role, filterStatus, showToast])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const handleAppeal = async (decision: 'uphold' | 'overturn') => {
|
||||
if (!selected) return
|
||||
setHandling(true)
|
||||
try {
|
||||
await api.appeals.handle(role, selected.id, decision, response)
|
||||
showToast(decision === 'uphold' ? '申诉已驳回' : '申诉已通过')
|
||||
setSelected(null)
|
||||
setResponse('')
|
||||
load()
|
||||
} catch (err) {
|
||||
showToast(err instanceof Error ? err.message : '操作失败')
|
||||
} finally {
|
||||
setHandling(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-content">
|
||||
<div className="page-heading small">
|
||||
<div>
|
||||
<div className="page-heading-icon"><Gavel size={20} /></div>
|
||||
<div>
|
||||
<h1>申诉复议</h1>
|
||||
<span>对审核结果有异议的工作记录进行申诉与复议</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="filter-bar">
|
||||
<select value={filterStatus} onChange={e => setFilterStatus(e.target.value)}>
|
||||
<option value="">全部状态</option>
|
||||
<option value="pending">待处理</option>
|
||||
<option value="accepted">已通过</option>
|
||||
<option value="rejected">已驳回</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="loading-center"><Loader2 size={24} className="spin" /></div>
|
||||
) : appeals.length === 0 ? (
|
||||
<EmptyState icon={Gavel} text="暂无申诉记录" />
|
||||
) : (
|
||||
<div className="appeal-list">
|
||||
{appeals.map(a => {
|
||||
const st = statusLabels[a.status] || statusLabels.pending
|
||||
return (
|
||||
<div key={a.id} className={`appeal-card status-${st.color}`} onClick={() => { setSelected(a); setResponse('') }}>
|
||||
<div className="appeal-header">
|
||||
<span className="appeal-code">{a.code}</span>
|
||||
<span className={`appeal-status ${st.color}`}>{st.label}</span>
|
||||
</div>
|
||||
<div className="appeal-body">
|
||||
<div className="appeal-meta">
|
||||
<span>申诉人:{a.appellant}</span>
|
||||
<span>站点:{a.station}</span>
|
||||
<span>记录ID:{a.recordId}</span>
|
||||
</div>
|
||||
<div className="appeal-reason">
|
||||
<MessageSquare size={14} />
|
||||
<span>{a.reason}</span>
|
||||
</div>
|
||||
{a.response && (
|
||||
<div className="appeal-response">
|
||||
<strong>处理意见:</strong>{a.response}
|
||||
</div>
|
||||
)}
|
||||
<div className="appeal-footer">
|
||||
<Clock size={13} />
|
||||
<span>提交于 {a.createdAt}</span>
|
||||
{a.handledAt && <span>处理于 {a.handledAt}</span>}
|
||||
{a.handler && <span>处理人:{a.handler}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selected && (
|
||||
<div className="modal-layer" onClick={() => { setSelected(null); setResponse('') }}>
|
||||
<div className="modal" role="dialog" aria-modal="true" onClick={e => e.stopPropagation()} style={{ maxWidth: 520 }}>
|
||||
<div className="modal-head">
|
||||
<h2 style={{ fontSize: 16, margin: 0 }}>申诉详情 — {selected.code}</h2>
|
||||
<button className="icon-button" onClick={() => { setSelected(null); setResponse('') }}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-body" style={{ padding: 16 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 12 }}>
|
||||
<div><strong>申诉人:</strong>{selected.appellant}({selected.station})</div>
|
||||
<div><strong>记录ID:</strong>{selected.recordId}</div>
|
||||
<div><strong>申诉原因:</strong>{selected.reason}</div>
|
||||
<div><strong>状态:</strong>{statusLabels[selected.status]?.label}</div>
|
||||
{selected.response && <div><strong>处理意见:</strong>{selected.response}</div>}
|
||||
{selected.handler && <div><strong>处理人:</strong>{selected.handler}</div>}
|
||||
<div><strong>提交时间:</strong>{selected.createdAt}</div>
|
||||
{selected.handledAt && <div><strong>处理时间:</strong>{selected.handledAt}</div>}
|
||||
</div>
|
||||
|
||||
{role !== 'reporter' && selected.status === 'pending' && (
|
||||
<>
|
||||
<textarea
|
||||
placeholder="输入处理意见..."
|
||||
value={response}
|
||||
onChange={e => setResponse(e.target.value)}
|
||||
rows={3}
|
||||
style={{ width: '100%', marginBottom: 12, padding: 8, borderRadius: 6, border: '1px solid var(--border)' }}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
className="danger-button"
|
||||
style={{ height: 38, padding: '0 15px', fontSize: 13, fontWeight: 600, borderRadius: 4, border: '1px solid var(--red)', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 7 }}
|
||||
disabled={handling}
|
||||
onClick={() => handleAppeal('uphold')}
|
||||
>
|
||||
<XCircle size={16} /> 驳回申诉
|
||||
</button>
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={handling}
|
||||
onClick={() => handleAppeal('overturn')}
|
||||
>
|
||||
<CheckCircle2 size={16} /> 通过申诉
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { Archive, ChevronRight, X, BookOpen, Filter, BarChart3 } from 'lucide-react'
|
||||
import { useRole } from '../../context'
|
||||
import type { WorkRecord, WorkType } from '../../types'
|
||||
|
||||
interface ArchivePageProps {
|
||||
records: WorkRecord[]
|
||||
onSelect: (r: WorkRecord) => void
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
draft: '草稿', station_review: '待分站审核',
|
||||
headquarters_review: '待总部复核', returned: '已退回', archived: '已归档',
|
||||
}
|
||||
|
||||
const ALL_TYPES: WorkType[] = ['文字稿件', '视频供稿', '图片供稿', '重要报道', '培训参与', '临时工作']
|
||||
|
||||
export function ArchivePage({ records, onSelect }: ArchivePageProps) {
|
||||
const { role } = useRole()
|
||||
const [filterType, setFilterType] = useState('')
|
||||
const [filterStation, setFilterStation] = useState('')
|
||||
const [filterStartDate, setFilterStartDate] = useState('')
|
||||
const [filterEndDate, setFilterEndDate] = useState('')
|
||||
const [search, setSearch] = useState('')
|
||||
|
||||
const archived = useMemo(() => records.filter(r => r.status === 'archived'), [records])
|
||||
|
||||
const stations = useMemo(() => {
|
||||
const set = new Set(archived.map(r => r.station))
|
||||
return Array.from(set).sort()
|
||||
}, [archived])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return archived.filter(r => {
|
||||
if (filterType && r.type !== filterType) return false
|
||||
if (filterStation && r.station !== filterStation) return false
|
||||
if (filterStartDate && r.date < filterStartDate) return false
|
||||
if (filterEndDate && r.date > filterEndDate) return false
|
||||
if (search) {
|
||||
const q = search.toLowerCase()
|
||||
return r.title.toLowerCase().includes(q) ||
|
||||
r.reporter.toLowerCase().includes(q) ||
|
||||
r.id.toLowerCase().includes(q)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}, [archived, filterType, filterStation, filterStartDate, filterEndDate, search])
|
||||
|
||||
const typeAggregation = useMemo(() => {
|
||||
const map = new Map<string, { count: number; avgScore: number; totalScore: number }>()
|
||||
filtered.forEach(r => {
|
||||
const cur = map.get(r.type) || { count: 0, avgScore: 0, totalScore: 0 }
|
||||
cur.count++
|
||||
cur.totalScore += r.score ?? 0
|
||||
cur.avgScore = cur.totalScore / cur.count
|
||||
map.set(r.type, cur)
|
||||
})
|
||||
return Array.from(map.entries())
|
||||
.map(([type, stats]) => ({ type, ...stats }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
}, [filtered])
|
||||
|
||||
const hasFilters = filterType || filterStation || filterStartDate || filterEndDate || search
|
||||
|
||||
const clearFilters = () => {
|
||||
setFilterType('')
|
||||
setFilterStation('')
|
||||
setFilterStartDate('')
|
||||
setFilterEndDate('')
|
||||
setSearch('')
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-heading small">
|
||||
<div>
|
||||
<div className="page-heading-icon"><Archive size={20} /></div>
|
||||
<div>
|
||||
<h1>电子档案</h1>
|
||||
<span>
|
||||
查询已归档的工作记录与考核得分,支持按类型、时间、站点筛选。
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="profile-strip">
|
||||
<div className="profile-person">
|
||||
<div className="avatar large">林</div>
|
||||
<div>
|
||||
<span>人员档案</span>
|
||||
<h2>{role === 'reporter' ? '林晓' : '林晓 · R-10021'}</h2>
|
||||
<p>北京记者站 · 记者 · 2021年3月入站</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="profile-stat">
|
||||
<strong>{archived.length}</strong>
|
||||
<span>累计作品</span>
|
||||
</div>
|
||||
<div className="profile-stat">
|
||||
<strong>{archived.length > 0 ? (archived.reduce((s, r) => s + (r.score ?? 0), 0) / archived.length).toFixed(1) : '—'}</strong>
|
||||
<span>平均得分</span>
|
||||
</div>
|
||||
<div className="profile-stat">
|
||||
<strong>{typeAggregation.length}</strong>
|
||||
<span>作品类型</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 分类聚合统计 */}
|
||||
{typeAggregation.length > 0 && (
|
||||
<section className="panel" style={{ marginBottom: 14, padding: '16px 20px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||
<BarChart3 size={18} />
|
||||
<h2 style={{ fontSize: 14, margin: 0 }}>分类聚合</h2>
|
||||
<span style={{ fontSize: 11, color: 'var(--muted)' }}>按工作类型统计</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
|
||||
{typeAggregation.map(item => (
|
||||
<div key={item.type} style={{
|
||||
background: 'var(--soft)', borderRadius: 6, padding: '10px 16px',
|
||||
display: 'flex', flexDirection: 'column', gap: 2, minWidth: 120,
|
||||
}}>
|
||||
<strong style={{ fontSize: 13 }}>{item.type}</strong>
|
||||
<div style={{ display: 'flex', gap: 12, fontSize: 11, color: 'var(--muted)' }}>
|
||||
<span>{item.count} 条</span>
|
||||
<span>均分 {item.avgScore.toFixed(1)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="panel list-panel">
|
||||
<div className="panel-header" style={{ padding: '20px 20px 0' }}>
|
||||
<div>
|
||||
<h2>档案记录</h2>
|
||||
<p>已通过总部复核并归档的工作成果</p>
|
||||
</div>
|
||||
<span className="result-count">共 {filtered.length} 条</span>
|
||||
</div>
|
||||
|
||||
{/* 筛选栏 */}
|
||||
<div className="filters" style={{ padding: '14px 20px' }}>
|
||||
<div className="search">
|
||||
<Filter size={15} />
|
||||
<input
|
||||
placeholder="搜索标题/提交人/编号"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<select value={filterType} onChange={e => setFilterType(e.target.value)}>
|
||||
<option value="">全部类型</option>
|
||||
{ALL_TYPES.map(t => <option key={t} value={t}>{t}</option>)}
|
||||
</select>
|
||||
<select value={filterStation} onChange={e => setFilterStation(e.target.value)}>
|
||||
<option value="">全部站点</option>
|
||||
{stations.map(s => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
<input type="date" value={filterStartDate} onChange={e => setFilterStartDate(e.target.value)} title="开始日期" />
|
||||
<input type="date" value={filterEndDate} onChange={e => setFilterEndDate(e.target.value)} title="结束日期" />
|
||||
{hasFilters && (
|
||||
<button className="secondary-button small" onClick={clearFilters}>
|
||||
<X size={14} /> 清除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 && (
|
||||
<div className="empty">
|
||||
<Archive size={30} />
|
||||
<span>{hasFilters ? '没有符合筛选条件的记录' : '暂无归档记录'}</span>
|
||||
</div>
|
||||
)}
|
||||
{filtered.length > 0 && (
|
||||
<div className="table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>记录信息</th>
|
||||
<th>类型</th>
|
||||
<th>发生日期</th>
|
||||
<th>得分</th>
|
||||
<th>提交人</th>
|
||||
<th style={{ width: 80 }}>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map(r => (
|
||||
<tr key={r.id}>
|
||||
<td data-label="记录信息">
|
||||
<div className="record-title">
|
||||
<span className="type-icon"><BookOpen size={16} /></span>
|
||||
<div>
|
||||
<strong>{r.title}</strong>
|
||||
<small>{r.id}</small>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td data-label="类型">
|
||||
<span className="status muted"><i />{r.type}</span>
|
||||
</td>
|
||||
<td data-label="发生日期"><span className="cell-main">{r.date}</span></td>
|
||||
<td data-label="得分">
|
||||
{r.score != null
|
||||
? <strong style={{ color: 'var(--primary)' }}>{r.score}</strong>
|
||||
: <span style={{ color: 'var(--muted)' }}>—</span>
|
||||
}
|
||||
</td>
|
||||
<td data-label="提交人">
|
||||
<strong className="cell-main">{r.reporter}</strong>
|
||||
<small>{r.station}</small>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
className="secondary-button small"
|
||||
onClick={() => onSelect(r)}
|
||||
title="查看详情"
|
||||
>
|
||||
<ChevronRight size={14} />
|
||||
详情
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { Activity, BarChart3, TrendingUp, TrendingDown, Building2, Users, FilePenLine, Award, AlertTriangle, Download, Gauge } from 'lucide-react'
|
||||
import { useRole } from '../../context'
|
||||
import { api } from '../../api'
|
||||
import type { StatsOverview, StatsRecordRow, StatsScoreRow } from '../../api'
|
||||
import type { WorkRecord, WorkType } from '../../types'
|
||||
|
||||
interface CockpitProps {
|
||||
records: WorkRecord[]
|
||||
}
|
||||
|
||||
const ALL_TYPES: WorkType[] = ['文字稿件', '视频供稿', '图片供稿', '重要报道', '培训参与', '临时工作']
|
||||
|
||||
/** 柱状图组件(纯 SVG) */
|
||||
function BarChart({ data, color = '#b42318', height = 200 }: {
|
||||
data: { label: string; value: number; sub?: string }[]
|
||||
color?: string
|
||||
height?: number
|
||||
}) {
|
||||
const max = Math.max(...data.map(d => d.value)) * 1.15 || 1
|
||||
const barWidth = 100 / data.length
|
||||
return (
|
||||
<svg viewBox={`0 0 100 ${height / 3}`} style={{ width: '100%', height }} preserveAspectRatio="none">
|
||||
{data.map((d, i) => {
|
||||
const h = (d.value / max) * (height / 3 - 10)
|
||||
const x = i * barWidth + barWidth * 0.15
|
||||
const w = barWidth * 0.7
|
||||
const y = height / 3 - h - 6
|
||||
return (
|
||||
<g key={i}>
|
||||
<rect x={x} y={y} width={w} height={h} fill={color} rx={1} opacity={0.85} />
|
||||
<text x={x + w / 2} y={height / 3 - 2} textAnchor="middle" fill="#777" fontSize={2.5}>{d.label}</text>
|
||||
<text x={x + w / 2} y={y - 1} textAnchor="middle" fill="#333" fontSize={2.8} fontWeight="bold">{d.value}</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** 环形进度图组件 */
|
||||
function RingProgress({ value, max, label, color, unit }: {
|
||||
value: number; max: number; label: string; color: string; unit: string
|
||||
}) {
|
||||
const pct = max > 0 ? Math.min(value / max, 1) : 0
|
||||
const r = 36
|
||||
const circ = 2 * Math.PI * r
|
||||
const offset = circ * (1 - pct)
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6 }}>
|
||||
<svg width="90" height="90" viewBox="0 0 90 90">
|
||||
<circle cx="45" cy="45" r={r} fill="none" stroke="#eeeae5" strokeWidth="7" />
|
||||
<circle
|
||||
cx="45" cy="45" r={r} fill="none" stroke={color} strokeWidth="7"
|
||||
strokeDasharray={circ} strokeDashoffset={offset}
|
||||
strokeLinecap="round" transform="rotate(-90 45 45)"
|
||||
style={{ transition: 'stroke-dashoffset 0.5s' }}
|
||||
/>
|
||||
<text x="45" y="42" textAnchor="middle" fill="#1e1c19" fontSize="18" fontWeight="bold" fontFamily="Georgia,serif">
|
||||
{value}
|
||||
</text>
|
||||
<text x="45" y="55" textAnchor="middle" fill="#777" fontSize="9">{unit}</text>
|
||||
</svg>
|
||||
<span style={{ fontSize: 12, color: 'var(--muted)' }}>{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 热力图单元格 */
|
||||
function HeatmapCell({ count, max, label }: { count: number; max: number; label: string }) {
|
||||
const intensity = max > 0 ? count / max : 0
|
||||
const bg = intensity > 0.75 ? '#b42318' : intensity > 0.5 ? '#d4634e' : intensity > 0.25 ? '#e9a092' : intensity > 0 ? '#f4d0c8' : '#f5f4f1'
|
||||
const fg = intensity > 0.5 ? 'white' : '#77736d'
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||||
background: bg, color: fg, borderRadius: 4, padding: '8px 4px', minHeight: 50,
|
||||
transition: '0.12s', cursor: 'default',
|
||||
}} title={`${label}: ${count} 条`}>
|
||||
<strong style={{ fontSize: 14, fontFamily: 'Georgia,serif' }}>{count}</strong>
|
||||
<span style={{ fontSize: 9 }}>{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CockpitPage({ records }: CockpitProps) {
|
||||
const { role } = useRole()
|
||||
const [overview, setOverview] = useState<StatsOverview | null>(null)
|
||||
const [stationStats, setStationStats] = useState<StatsRecordRow[]>([])
|
||||
const [reporterStats, setReporterStats] = useState<StatsScoreRow[]>([])
|
||||
const [typeStats, setTypeStats] = useState<StatsRecordRow[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [period, setPeriod] = useState('2026-07')
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
setLoading(true)
|
||||
Promise.all([
|
||||
api.stats.overview(role).catch(() => null),
|
||||
api.stats.records(role, 'station').catch(() => []),
|
||||
api.stats.scores(role).catch(() => []),
|
||||
api.stats.records(role, 'type').catch(() => []),
|
||||
]).then(([ov, st, sc, ty]) => {
|
||||
if (!active) return
|
||||
setOverview(ov)
|
||||
setStationStats(st)
|
||||
setReporterStats(sc)
|
||||
setTypeStats(ty)
|
||||
}).finally(() => { if (active) setLoading(false) })
|
||||
return () => { active = false }
|
||||
}, [role, period])
|
||||
|
||||
/** 按类型×站点交叉统计(热力图数据) */
|
||||
const heatmapData = useMemo(() => {
|
||||
const topStations = stationStats.slice(0, 8).map(s => s.name)
|
||||
const matrix: { station: string; type: string; count: number }[] = []
|
||||
ALL_TYPES.forEach(type => {
|
||||
topStations.forEach(stationName => {
|
||||
const count = records.filter(r =>
|
||||
r.station === stationName && r.type === type && r.status === 'archived'
|
||||
).length
|
||||
matrix.push({ station: stationName, type, count })
|
||||
})
|
||||
})
|
||||
return { matrix, stations: topStations }
|
||||
}, [stationStats, records])
|
||||
|
||||
const maxHeat = useMemo(() => Math.max(1, ...heatmapData.matrix.map(d => d.count)), [heatmapData])
|
||||
|
||||
/** 得分趋势对比 */
|
||||
const scoreTrend = useMemo(() => {
|
||||
return reporterStats
|
||||
.filter(s => s.avgScore != null)
|
||||
.sort((a, b) => (b.avgScore ?? 0) - (a.avgScore ?? 0))
|
||||
.slice(0, 10)
|
||||
.map(s => ({ label: s.name, value: s.avgScore ?? 0, sub: s.station }))
|
||||
}, [reporterStats])
|
||||
|
||||
/** 站点对比柱状图数据 */
|
||||
const stationBars = useMemo(() => {
|
||||
return stationStats.slice(0, 10).map(s => ({
|
||||
label: s.name.replace('记者站', ''),
|
||||
value: s.total,
|
||||
sub: `归档 ${s.archived}`,
|
||||
}))
|
||||
}, [stationStats])
|
||||
|
||||
/** 类型分布 */
|
||||
const typeBars = useMemo(() => {
|
||||
return typeStats.map(t => ({
|
||||
label: t.type || t.name,
|
||||
value: t.total,
|
||||
sub: `归档 ${t.archived}`,
|
||||
}))
|
||||
}, [typeStats])
|
||||
|
||||
/** 关键指标 */
|
||||
const totalRecords = overview?.total ?? records.length
|
||||
const archivedCount = overview?.archived ?? records.filter(r => r.status === 'archived').length
|
||||
const reviewingCount = overview?.reviewing ?? records.filter(r => r.status === 'station_review' || r.status === 'headquarters_review').length
|
||||
const returnedCount = overview?.returned ?? records.filter(r => r.status === 'returned').length
|
||||
const archiveRate = totalRecords > 0 ? Math.round((archivedCount / totalRecords) * 100) : 0
|
||||
|
||||
/** 风险预警 */
|
||||
const alerts = useMemo(() => {
|
||||
const list: { level: 'high' | 'medium' | 'low'; text: string }[] = []
|
||||
if (returnedCount > 5) list.push({ level: 'high', text: `退回记录 ${returnedCount} 条,超出阈值 5 条` })
|
||||
if (reviewingCount > 20) list.push({ level: 'medium', text: `待审核积压 ${reviewingCount} 条,建议加快处理` })
|
||||
const lowScoreStations = stationStats.filter(s => s.avgScore != null && s.avgScore < 70)
|
||||
lowScoreStations.forEach(s => list.push({ level: 'low', text: `${s.name} 平均分 ${s.avgScore?.toFixed(1)},低于 70 分线` }))
|
||||
return list
|
||||
}, [returnedCount, reviewingCount, stationStats])
|
||||
|
||||
if (loading) {
|
||||
return <div className="loading-center" style={{ padding: 80, textAlign: 'center', color: 'var(--muted)' }}>加载中…</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-heading small">
|
||||
<div>
|
||||
<div className="page-heading-icon"><Gauge size={20} /></div>
|
||||
<div>
|
||||
<h1>管理驾驶舱</h1>
|
||||
<span>全国记者站运行态势总览 · {period}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<input
|
||||
type="month"
|
||||
value={period}
|
||||
onChange={e => setPeriod(e.target.value)}
|
||||
style={{ height: 34, border: '1px solid var(--line)', borderRadius: 4, padding: '0 10px', fontSize: 12 }}
|
||||
/>
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => api.export.records(role).then(blob => {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url; a.download = 'cockpit-export.csv'; a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
})}
|
||||
>
|
||||
<Download size={15} /> 导出
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 关键指标环形图 */}
|
||||
<section className="panel" style={{ marginBottom: 14, padding: '20px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16 }}>
|
||||
<Activity size={18} />
|
||||
<h2 style={{ fontSize: 14, margin: 0 }}>核心指标</h2>
|
||||
</div>
|
||||
<div className="cockpit-rings" style={{ display: 'flex', gap: 30, flexWrap: 'wrap', justifyContent: 'space-around' }}>
|
||||
<RingProgress value={totalRecords} max={700} label="总记录数" color="#b42318" unit="条" />
|
||||
<RingProgress value={archivedCount} max={totalRecords} label="已归档" color="#4ba66a" unit="条" />
|
||||
<RingProgress value={archiveRate} max={100} label="归档率" color="#3b82f6" unit="%" />
|
||||
<RingProgress value={reviewingCount} max={50} label="审核中" color="#f0a020" unit="条" />
|
||||
<RingProgress value={returnedCount} max={20} label="退回数" color="#b42318" unit="条" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 风险预警 */}
|
||||
{alerts.length > 0 && (
|
||||
<section className="panel" style={{ marginBottom: 14, padding: '16px 20px', borderLeft: '3px solid #f0a020' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
|
||||
<AlertTriangle size={18} style={{ color: '#c47600' }} />
|
||||
<h2 style={{ fontSize: 14, margin: 0 }}>风险预警</h2>
|
||||
<span style={{ fontSize: 11, color: 'var(--muted)' }}>{alerts.length} 项</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{alerts.map((a, i) => (
|
||||
<div key={i} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '6px 12px', borderRadius: 4, fontSize: 12,
|
||||
background: a.level === 'high' ? '#fdeae8' : a.level === 'medium' ? '#fff4e6' : '#f5f4f1',
|
||||
color: a.level === 'high' ? '#a72d23' : a.level === 'medium' ? '#8a5a00' : 'var(--muted)',
|
||||
}}>
|
||||
<span style={{
|
||||
width: 6, height: 6, borderRadius: '50%',
|
||||
background: a.level === 'high' ? '#b42318' : a.level === 'medium' ? '#f0a020' : '#999',
|
||||
}} />
|
||||
{a.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 图表区域 */}
|
||||
<div className="cockpit-grid-2" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14, marginBottom: 14 }}>
|
||||
{/* 站点工作量对比 */}
|
||||
<section className="panel" style={{ padding: '16px 20px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||
<Building2 size={18} />
|
||||
<h2 style={{ fontSize: 14, margin: 0 }}>站点工作量 TOP10</h2>
|
||||
</div>
|
||||
{stationBars.length > 0 ? (
|
||||
<BarChart data={stationBars} color="#b42318" height={220} />
|
||||
) : (
|
||||
<div className="empty" style={{ padding: 40, color: 'var(--muted)', textAlign: 'center' }}>暂无数据</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 个人得分排行 */}
|
||||
<section className="panel" style={{ padding: '16px 20px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||
<Award size={18} />
|
||||
<h2 style={{ fontSize: 14, margin: 0 }}>个人得分 TOP10</h2>
|
||||
</div>
|
||||
{scoreTrend.length > 0 ? (
|
||||
<BarChart data={scoreTrend} color="#4ba66a" height={220} />
|
||||
) : (
|
||||
<div className="empty" style={{ padding: 40, color: 'var(--muted)', textAlign: 'center' }}>暂无数据</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* 类型分布 + 热力图 */}
|
||||
<div className="cockpit-grid-3" style={{ display: 'grid', gridTemplateColumns: '1fr 2fr', gap: 14, marginBottom: 14 }}>
|
||||
{/* 工作类型分布 */}
|
||||
<section className="panel" style={{ padding: '16px 20px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||
<FilePenLine size={18} />
|
||||
<h2 style={{ fontSize: 14, margin: 0 }}>工作类型分布</h2>
|
||||
</div>
|
||||
{typeBars.length > 0 ? (
|
||||
<BarChart data={typeBars} color="#3b82f6" height={200} />
|
||||
) : (
|
||||
<div className="empty" style={{ padding: 40, color: 'var(--muted)', textAlign: 'center' }}>暂无数据</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 站点×类型热力图 */}
|
||||
<section className="panel" style={{ padding: '16px 20px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||
<BarChart3 size={18} />
|
||||
<h2 style={{ fontSize: 14, margin: 0 }}>站点 × 类型 热力图</h2>
|
||||
<span style={{ fontSize: 11, color: 'var(--muted)' }}>已归档记录交叉统计</span>
|
||||
</div>
|
||||
{heatmapData.stations.length > 0 ? (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: `100px repeat(${ALL_TYPES.length}, 1fr)`, gap: 4, minWidth: 500 }}>
|
||||
<div />
|
||||
{ALL_TYPES.map(t => (
|
||||
<div key={t} style={{ fontSize: 10, color: 'var(--muted)', textAlign: 'center', padding: '4px 2px', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{t}
|
||||
</div>
|
||||
))}
|
||||
{heatmapData.stations.map(stName => (
|
||||
<div key={stName} style={{ display: 'contents' }}>
|
||||
<div style={{ fontSize: 11, color: 'var(--ink)', display: 'flex', alignItems: 'center', padding: '0 4px', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{stName.replace('记者站', '')}
|
||||
</div>
|
||||
{ALL_TYPES.map(type => {
|
||||
const cell = heatmapData.matrix.find(d => d.station === stName && d.type === type)
|
||||
return <HeatmapCell key={`${stName}-${type}`} count={cell?.count ?? 0} max={maxHeat} label={type} />
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="empty" style={{ padding: 40, color: 'var(--muted)', textAlign: 'center' }}>暂无数据</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* 趋势对比 */}
|
||||
<section className="panel" style={{ padding: '16px 20px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||
<TrendingUp size={18} />
|
||||
<h2 style={{ fontSize: 14, margin: 0 }}>月度趋势对比</h2>
|
||||
</div>
|
||||
{overview?.monthly && overview.monthly.length > 0 ? (
|
||||
<div style={{ display: 'flex', gap: 20, flexWrap: 'wrap' }}>
|
||||
{overview.monthly.map((m, i) => {
|
||||
const prev = i > 0 ? overview.monthly[i - 1] : null
|
||||
const trend = prev ? m.count - prev.count : 0
|
||||
const scoreTrendDir = prev?.avgScore != null && m.avgScore != null ? m.avgScore - prev.avgScore : 0
|
||||
return (
|
||||
<div key={m.month} style={{
|
||||
flex: '1 1 120px', background: 'var(--soft)', borderRadius: 6, padding: '12px 16px',
|
||||
}}>
|
||||
<div style={{ fontSize: 12, color: 'var(--muted)', marginBottom: 4 }}>{m.month}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 6 }}>
|
||||
<strong style={{ fontSize: 22, fontFamily: 'Georgia,serif' }}>{m.count}</strong>
|
||||
<span style={{ fontSize: 11, color: 'var(--muted)' }}>条</span>
|
||||
{trend !== 0 && (
|
||||
<span style={{
|
||||
fontSize: 11, display: 'flex', alignItems: 'center', gap: 2,
|
||||
color: trend > 0 ? '#4ba66a' : '#b42318',
|
||||
}}>
|
||||
{trend > 0 ? <TrendingUp size={12} /> : <TrendingDown size={12} />}
|
||||
{Math.abs(trend)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--muted)', marginTop: 4 }}>
|
||||
均分 {m.avgScore?.toFixed(1) ?? '—'}
|
||||
{scoreTrendDir !== 0 && (
|
||||
<span style={{ color: scoreTrendDir > 0 ? '#4ba66a' : '#b42318', marginLeft: 4 }}>
|
||||
{scoreTrendDir > 0 ? '↑' : '↓'} {Math.abs(scoreTrendDir).toFixed(1)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="empty" style={{ padding: 40, color: 'var(--muted)', textAlign: 'center' }}>暂无趋势数据</div>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { Bell, ClipboardCheck, FilePenLine, Plus, ChevronRight, LayoutDashboard } from 'lucide-react'
|
||||
import { MetricCard, RecordTable, PanelHeader } from '../../components/data'
|
||||
import { useRole } from '../../context'
|
||||
import { monthlyTrend, stationRanking } from '../../data'
|
||||
import type { WorkRecord } from '../../types'
|
||||
import type { PageKey } from '../../routes'
|
||||
|
||||
/** 纯 SVG 面积图组件,替代 recharts 以减少包体积 */
|
||||
function TrendChart({ data }: { data: { month: string; records: number }[] }) {
|
||||
const [hover, setHover] = useState<number | null>(null)
|
||||
const W = 520, H = 200, P = { top: 12, right: 6, bottom: 24, left: 36 }
|
||||
const innerW = W - P.left - P.right
|
||||
const innerH = H - P.top - P.bottom
|
||||
const max = Math.max(...data.map(d => d.records)) * 1.1
|
||||
const min = 0
|
||||
const xStep = innerW / (data.length - 1)
|
||||
const xs = data.map((_, i) => P.left + i * xStep)
|
||||
const ys = data.map(d => P.top + innerH - ((d.records - min) / (max - min)) * innerH)
|
||||
const linePath = data.map((_, i) => `${i === 0 ? 'M' : 'L'} ${xs[i]} ${ys[i]}`).join(' ')
|
||||
const areaPath = `${linePath} L ${xs[xs.length - 1]} ${P.top + innerH} L ${xs[0]} ${P.top + innerH} Z`
|
||||
const yTicks = 4
|
||||
const tickValues = Array.from({ length: yTicks + 1 }, (_, i) => Math.round((max / yTicks) * i))
|
||||
|
||||
return (
|
||||
<svg viewBox={`0 0 ${W} ${H}`} style={{ width: '100%', height: '100%' }}>
|
||||
<defs>
|
||||
<linearGradient id="recordFill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#b42318" stopOpacity={0.2} />
|
||||
<stop offset="100%" stopColor="#b42318" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{tickValues.map((v, i) => {
|
||||
const y = P.top + innerH - (v / max) * innerH
|
||||
return (
|
||||
<g key={i}>
|
||||
<line x1={P.left} y1={y} x2={W - P.right} y2={y} stroke="#e9e7e3" strokeDasharray="3 3" />
|
||||
<text x={P.left - 6} y={y + 4} textAnchor="end" fill="#77736d" fontSize={11}>{v}</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
<path d={areaPath} fill="url(#recordFill)" />
|
||||
<path d={linePath} fill="none" stroke="#b42318" strokeWidth={2.5} />
|
||||
{data.map((d, i) => (
|
||||
<g key={i} onMouseEnter={() => setHover(i)} onMouseLeave={() => setHover(null)}>
|
||||
<rect x={xs[i] - xStep / 2} y={P.top} width={xStep} height={innerH} fill="transparent" />
|
||||
<text x={xs[i]} y={H - 6} textAnchor="middle" fill="#77736d" fontSize={12}>{d.month}</text>
|
||||
<circle cx={xs[i]} cy={ys[i]} r={hover === i ? 4 : 2.5} fill="#b42318" />
|
||||
{hover === i && (
|
||||
<g>
|
||||
<rect x={xs[i] - 30} y={ys[i] - 28} width={60} height={20} rx={4} fill="#1e1c19" />
|
||||
<text x={xs[i]} y={ys[i] - 14} textAnchor="middle" fill="#fff" fontSize={11}>{d.records} 条</text>
|
||||
</g>
|
||||
)}
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
interface DashboardProps {
|
||||
records: WorkRecord[]
|
||||
onNavigate: (p: PageKey) => void
|
||||
onCreate: () => void
|
||||
onSelect: (r: WorkRecord) => void
|
||||
}
|
||||
|
||||
export function Dashboard({ records, onNavigate, onCreate, onSelect }: DashboardProps) {
|
||||
const { role } = useRole()
|
||||
const [drillDown, setDrillDown] = useState<string | null>(null)
|
||||
|
||||
const pending = records.filter(r =>
|
||||
role === 'station' ? r.status === 'station_review' : r.status === 'headquarters_review'
|
||||
)
|
||||
const archived = records.filter(r => r.status === 'archived')
|
||||
const returned = records.filter(r => r.status === 'returned')
|
||||
const drafts = records.filter(r => r.status === 'draft')
|
||||
const score = archived.reduce((sum, r) => sum + (r.score ?? 0), 0)
|
||||
const firstName = role === 'reporter' ? '林晓' : '林致远'
|
||||
|
||||
/** 动态日期与问候语 */
|
||||
const now = new Date()
|
||||
const dateStr = `${now.getFullYear()}年${now.getMonth() + 1}月${now.getDate()}日`
|
||||
const weekdays = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']
|
||||
const weekdayStr = weekdays[now.getDay()]
|
||||
const hour = now.getHours()
|
||||
const greeting = hour < 6 ? '凌晨好' : hour < 12 ? '上午好' : hour < 14 ? '中午好' : hour < 18 ? '下午好' : '晚上好'
|
||||
|
||||
/** 指标卡下钻数据 */
|
||||
const drillData = useMemo(() => {
|
||||
if (!drillDown) return null
|
||||
switch (drillDown) {
|
||||
case 'pending':
|
||||
return { title: '待处理事项明细', records: pending }
|
||||
case 'archived':
|
||||
return { title: '本月归档明细', records: archived }
|
||||
case 'returned':
|
||||
return { title: '退回记录明细', records: returned }
|
||||
case 'draft':
|
||||
return { title: '草稿记录明细', records: drafts }
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}, [drillDown, pending, archived, returned, drafts])
|
||||
|
||||
/** 按状态聚合统计 */
|
||||
const statusBreakdown = useMemo(() => {
|
||||
const map = new Map<string, number>()
|
||||
records.forEach(r => map.set(r.status, (map.get(r.status) || 0) + 1))
|
||||
return Array.from(map.entries()).sort((a, b) => b[1] - a[1])
|
||||
}, [records])
|
||||
|
||||
/** 按类型聚合统计 */
|
||||
const typeBreakdown = useMemo(() => {
|
||||
const map = new Map<string, { count: number; avgScore: number; total: number }>()
|
||||
archived.forEach(r => {
|
||||
const cur = map.get(r.type) || { count: 0, avgScore: 0, total: 0 }
|
||||
cur.count++
|
||||
cur.total += r.score ?? 0
|
||||
cur.avgScore = cur.total / cur.count
|
||||
map.set(r.type, cur)
|
||||
})
|
||||
return Array.from(map.entries()).map(([type, v]) => ({ type, ...v })).sort((a, b) => b.count - a.count)
|
||||
}, [archived])
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
draft: '草稿', station_review: '待分站审核', headquarters_review: '待总部复核',
|
||||
returned: '已退回', archived: '已归档',
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<div className="page-heading-icon"><LayoutDashboard size={22} /></div>
|
||||
<div>
|
||||
<p className="eyebrow">{dateStr} · {weekdayStr}</p>
|
||||
<h1>{greeting},{firstName}</h1>
|
||||
<span>{role === 'reporter' ? '这是你的个人工作概况。' : role === 'station' ? '这是本站今日运行概况。' : '这里是全国记者站今日运行概况。'}</span>
|
||||
</div>
|
||||
</div>
|
||||
{role !== 'headquarters' && (
|
||||
<button className="primary-button" onClick={onCreate}>
|
||||
<Plus size={18} />
|
||||
新建工作记录
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="metric-grid">
|
||||
<div onClick={() => setDrillDown('pending')} style={{ cursor: 'pointer' }}>
|
||||
<MetricCard
|
||||
icon={ClipboardCheck}
|
||||
label="待处理事项"
|
||||
value={String(role === 'reporter' ? returned.length : pending.length)}
|
||||
hint={role === 'reporter' ? '含退回修改' : '点击查看明细'}
|
||||
color="amber"
|
||||
/>
|
||||
</div>
|
||||
<div onClick={() => setDrillDown('archived')} style={{ cursor: 'pointer' }}>
|
||||
<MetricCard
|
||||
icon={FilePenLine}
|
||||
label="本月归档"
|
||||
value={String(archived.length)}
|
||||
delta={archived.length > 0 ? `归档率 ${Math.round(archived.length / records.length * 100)}%` : ''}
|
||||
color="green"
|
||||
/>
|
||||
</div>
|
||||
<div onClick={() => setDrillDown('returned')} style={{ cursor: 'pointer' }}>
|
||||
<MetricCard
|
||||
icon={FilePenLine}
|
||||
label="退回记录"
|
||||
value={String(returned.length)}
|
||||
hint={returned.length > 0 ? '点击查看明细' : '暂无退回'}
|
||||
color="red"
|
||||
/>
|
||||
</div>
|
||||
<div onClick={() => setDrillDown('draft')} style={{ cursor: 'pointer' }}>
|
||||
<MetricCard
|
||||
icon={FilePenLine}
|
||||
label="草稿箱"
|
||||
value={String(drafts.length)}
|
||||
hint={drafts.length > 0 ? '点击查看明细' : '暂无草稿'}
|
||||
color="blue"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 下钻明细面板 */}
|
||||
{drillData && (
|
||||
<section className="panel" style={{ marginBottom: 14, padding: '16px 20px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<ChevronRight size={18} style={{ transform: 'rotate(90deg)' }} />
|
||||
<h2 style={{ fontSize: 14, margin: 0 }}>{drillData.title}</h2>
|
||||
<span style={{ fontSize: 11, color: 'var(--muted)' }}>共 {drillData.records.length} 条</span>
|
||||
</div>
|
||||
<button className="secondary-button small" onClick={() => setDrillDown(null)}>
|
||||
收起
|
||||
</button>
|
||||
</div>
|
||||
{drillData.records.length > 0 ? (
|
||||
<RecordTable records={drillData.records.slice(0, 8)} onSelect={onSelect} compact />
|
||||
) : (
|
||||
<div className="empty" style={{ padding: 20, color: 'var(--muted)', textAlign: 'center' }}>
|
||||
暂无记录
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 统计核对面板 — 仅管理层可见 */}
|
||||
{role !== 'reporter' && (
|
||||
<section className="panel" style={{ marginBottom: 14, padding: '16px 20px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||
<h2 style={{ fontSize: 14, margin: 0 }}>统计核对</h2>
|
||||
<span style={{ fontSize: 11, color: 'var(--muted)' }}>状态分布与类型聚合</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 20, flexWrap: 'wrap' }}>
|
||||
{/* 状态分布 */}
|
||||
<div style={{ flex: '1 1 240px' }}>
|
||||
<strong style={{ fontSize: 12, color: 'var(--muted)', display: 'block', marginBottom: 8 }}>状态分布</strong>
|
||||
{statusBreakdown.map(([status, count]) => (
|
||||
<div key={status} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '4px 0', fontSize: 12 }}>
|
||||
<span>{statusLabels[status] || status}</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{ width: 80, height: 6, background: '#eeeae5', borderRadius: 3, overflow: 'hidden' }}>
|
||||
<div style={{ width: `${(count / records.length) * 100}%`, height: '100%', background: 'var(--red)' }} />
|
||||
</div>
|
||||
<strong>{count}</strong>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '6px 0 0', fontSize: 11, color: 'var(--muted)', borderTop: '1px solid var(--line)', marginTop: 6 }}>
|
||||
<span>合计</span><strong>{records.length}</strong>
|
||||
</div>
|
||||
</div>
|
||||
{/* 类型聚合 */}
|
||||
<div style={{ flex: '1 1 240px' }}>
|
||||
<strong style={{ fontSize: 12, color: 'var(--muted)', display: 'block', marginBottom: 8 }}>归档类型聚合</strong>
|
||||
{typeBreakdown.map(item => (
|
||||
<div key={item.type} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '4px 0', fontSize: 12 }}>
|
||||
<span>{item.type}</span>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<span style={{ color: 'var(--muted)' }}>{item.count} 条</span>
|
||||
<strong>均分 {item.avgScore.toFixed(1)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{typeBreakdown.length === 0 && <span style={{ color: 'var(--muted)', fontSize: 12 }}>暂无归档数据</span>}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="dashboard-grid">
|
||||
<section className="panel chart-panel">
|
||||
<PanelHeader
|
||||
title="工作量趋势"
|
||||
subtitle={role === 'reporter' ? '近六个月个人工作记录' : '近六个月全国工作记录与平均完成度'}
|
||||
action="查看统计"
|
||||
/>
|
||||
<div className="chart-wrap">
|
||||
<TrendChart data={monthlyTrend} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel ranking-panel">
|
||||
<PanelHeader
|
||||
title={role === 'reporter' ? '本月工作构成' : '站点工作表现'}
|
||||
subtitle={role === 'reporter' ? '已归档记录分类' : '按综合完成度排序'}
|
||||
/>
|
||||
<div className="ranking-list">
|
||||
{stationRanking.slice(0, 5).map((station, i) => (
|
||||
<div className="ranking-row" key={station.name}>
|
||||
<span className={`rank rank-${i + 1}`}>{i + 1}</span>
|
||||
<div>
|
||||
<strong>
|
||||
{role === 'reporter'
|
||||
? ['文字稿件','视频供稿','培训参与','图片供稿','临时工作'][i]
|
||||
: station.name}
|
||||
</strong>
|
||||
<div className="progress">
|
||||
<i style={{ width: `${station.score}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
<span>
|
||||
<b>{role === 'reporter' ? [6,3,2,1,0][i] : station.score}</b>
|
||||
<small>{role === 'reporter' ? '条' : '分'}</small>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel recent-panel">
|
||||
<PanelHeader
|
||||
title="最新工作记录"
|
||||
subtitle={role === 'reporter' ? '我的近期记录' : '跨站点业务流转动态'}
|
||||
action="全部记录"
|
||||
onAction={() => onNavigate('work')}
|
||||
/>
|
||||
<RecordTable records={records.slice(0, 5)} onSelect={onSelect} compact />
|
||||
</section>
|
||||
|
||||
<section className="panel todo-panel">
|
||||
<PanelHeader
|
||||
title="待办事项"
|
||||
subtitle="需要你关注的工作"
|
||||
action={role === 'reporter' ? '工作记录' : '审核中心'}
|
||||
onAction={() => onNavigate(role === 'reporter' ? 'work' : 'review')}
|
||||
/>
|
||||
<div className="todo-list">
|
||||
{role !== 'reporter' && (
|
||||
<button className="todo" onClick={() => onNavigate('review')}>
|
||||
<span className="todo-icon amber"><ClipboardCheck size={18} /></span>
|
||||
<span><strong>{pending.length} 条工作记录待审核</strong><small>今日更新</small></span>
|
||||
<Bell size={17} />
|
||||
</button>
|
||||
)}
|
||||
<button className="todo">
|
||||
<span className="todo-icon red"><FilePenLine size={18} /></span>
|
||||
<span><strong>{returned.length} 条材料被退回待补充</strong><small>{returned.length > 0 ? '需尽快修改' : '暂无退回'}</small></span>
|
||||
</button>
|
||||
<button className="todo">
|
||||
<span className="todo-icon blue"><Bell size={18} /></span>
|
||||
<span><strong>月度报表确认通知</strong><small>截止 8月3日</small></span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { Trophy, Medal, Award, BarChart3, Users } from 'lucide-react'
|
||||
import { useRole } from '../../context'
|
||||
import { api, type LeaderboardResponse, type ReporterRank, type StationRank } from '../../api'
|
||||
|
||||
const PERIODS = ['2026-Q1', '2026-Q2', '2026-Q3', '2026-Q4']
|
||||
|
||||
function RankBadge({ rank }: { rank: number }) {
|
||||
if (rank === 1) return <div className="lb-badge lb-gold"><Trophy size={13} /></div>
|
||||
if (rank === 2) return <div className="lb-badge lb-silver"><Medal size={13} /></div>
|
||||
if (rank === 3) return <div className="lb-badge lb-bronze"><Award size={13} /></div>
|
||||
return <div className="lb-badge">{rank}</div>
|
||||
}
|
||||
|
||||
function ReporterRow({ item, isMe }: { item: ReporterRank; isMe: boolean }) {
|
||||
return (
|
||||
<tr className={isMe ? 'lb-me' : ''}>
|
||||
<td data-label="排名"><RankBadge rank={item.rank} /></td>
|
||||
<td data-label="记者">
|
||||
<div className="lb-name">
|
||||
{isMe && <span className="lb-me-tag">我</span>}
|
||||
{item.name}
|
||||
</div>
|
||||
</td>
|
||||
<td data-label="记者站" className="lb-muted">{item.station}</td>
|
||||
<td data-label="综合得分" className="lb-score">{(item.totalScore ?? 0).toFixed(1)}</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
function StationRow({ item, isMe }: { item: StationRank; isMe: boolean }) {
|
||||
return (
|
||||
<tr className={isMe ? 'lb-me' : ''}>
|
||||
<td data-label="排名"><RankBadge rank={item.rank} /></td>
|
||||
<td data-label="记者站">
|
||||
<div className="lb-name">
|
||||
{isMe && <span className="lb-me-tag">本站</span>}
|
||||
{item.name}
|
||||
</div>
|
||||
</td>
|
||||
<td data-label="人数" className="lb-muted">{item.reporterCount} 人</td>
|
||||
<td data-label="质量" className="lb-dim">{(item.avgQuality ?? 0).toFixed(1)}</td>
|
||||
<td data-label="数量" className="lb-dim">{(item.avgQuantity ?? 0).toFixed(1)}</td>
|
||||
<td data-label="时效" className="lb-dim">{(item.avgEfficiency ?? 0).toFixed(1)}</td>
|
||||
<td data-label="综合得分" className="lb-score">{(item.avgScore ?? 0).toFixed(1)}</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
export function LeaderboardPage() {
|
||||
const { role } = useRole()
|
||||
const [data, setData] = useState<LeaderboardResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [period, setPeriod] = useState('2026-Q3')
|
||||
const [groupBy, setGroupBy] = useState<'reporter' | 'station'>('reporter')
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true); setError('')
|
||||
try {
|
||||
const result = await api.leaderboard(role, { period, group_by: groupBy })
|
||||
setData(result)
|
||||
} catch (e: any) { setError(e.message) }
|
||||
finally { setLoading(false) }
|
||||
}, [role, period, groupBy])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const myRank = data?.myRank
|
||||
const ranks = data?.ranks ?? []
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-heading small">
|
||||
<div>
|
||||
<div className="page-heading-icon"><Trophy size={20} /></div>
|
||||
<div>
|
||||
<h1>积分排行榜</h1>
|
||||
<span>
|
||||
{groupBy === 'reporter'
|
||||
? '按考核周期统计全国记者积分排名。'
|
||||
: '按考核周期统计各记者站平均积分排名。'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 筛选栏 */}
|
||||
<div className="panel" style={{ marginBottom: 16 }}>
|
||||
<div className="filters">
|
||||
<select value={period} onChange={e => setPeriod(e.target.value)}>
|
||||
{PERIODS.map(p => <option key={p} value={p}>{p}</option>)}
|
||||
</select>
|
||||
|
||||
<div className="lb-toggle">
|
||||
<button
|
||||
className={groupBy === 'reporter' ? 'active' : ''}
|
||||
onClick={() => setGroupBy('reporter')}
|
||||
>
|
||||
<BarChart3 size={13} /> 记者排名
|
||||
</button>
|
||||
<button
|
||||
className={groupBy === 'station' ? 'active' : ''}
|
||||
onClick={() => setGroupBy('station')}
|
||||
>
|
||||
<Users size={13} /> 记者站排名
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span className="result-count">
|
||||
{loading ? '加载中...' : `${ranks.length} 条记录`}
|
||||
{myRank != null && role !== 'headquarters' && ` · 我的排名:第 ${myRank} 名`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="error-banner">{error}</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && data && ranks.length === 0 && (
|
||||
<div className="empty panel" style={{ padding: '40px 0' }}>
|
||||
暂无排行榜数据,请先在「评分结果」中触发评分计算。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && ranks.length > 0 && (
|
||||
<>
|
||||
{/* 我的排名提示 */}
|
||||
{data != null && myRank != null && role !== 'headquarters' && (
|
||||
<div className="lb-mine">
|
||||
<span className="lb-mine-label">我的排名</span>
|
||||
<strong className="lb-mine-rank">第 {myRank} 名</strong>
|
||||
<span className="lb-mine-total">/ 共 {data.ranks.length} 人</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="panel lb-table-wrap">
|
||||
{groupBy === 'reporter' ? (
|
||||
<table className="lb-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 56 }}>排名</th>
|
||||
<th>记者</th>
|
||||
<th>记者站</th>
|
||||
<th style={{ width: 90 }}>综合得分</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(ranks as ReporterRank[]).map(item => (
|
||||
<ReporterRow
|
||||
key={item.name}
|
||||
item={item}
|
||||
isMe={item.rank === myRank}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<table className="lb-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 56 }}>排名</th>
|
||||
<th>记者站</th>
|
||||
<th style={{ width: 70 }}>人数</th>
|
||||
<th style={{ width: 64 }}>质量</th>
|
||||
<th style={{ width: 64 }}>数量</th>
|
||||
<th style={{ width: 64 }}>时效</th>
|
||||
<th style={{ width: 90 }}>综合得分</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(ranks as StationRank[]).map(item => (
|
||||
<StationRow
|
||||
key={item.name}
|
||||
item={item}
|
||||
isMe={item.rank === myRank}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useState } from 'react'
|
||||
import { ShieldCheck, LogIn, Loader2 } from 'lucide-react'
|
||||
import { useRole } from '../../context'
|
||||
import { api } from '../../api'
|
||||
|
||||
/**
|
||||
* 登录页面 — 支持工号/姓名登录,演示模式可切换角色
|
||||
*/
|
||||
export function LoginPage() {
|
||||
const { login, setRole } = useRole()
|
||||
const [code, setCode] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!code.trim()) { setError('请输入工号或姓名'); return }
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const res = await api.auth.login(code.trim())
|
||||
login(res.token, res.role, res.name, res.station)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '登录失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDemo = (role: 'headquarters' | 'station' | 'reporter') => {
|
||||
const demoNames: Record<typeof role, { name: string; station: string | null }> = {
|
||||
headquarters: { name: '林致远', station: null },
|
||||
station: { name: '苏明远', station: '北京记者站' },
|
||||
reporter: { name: '林晓', station: '北京记者站' },
|
||||
}
|
||||
const demo = demoNames[role]
|
||||
localStorage.setItem('auth_role', role)
|
||||
localStorage.setItem('auth_name', demo.name)
|
||||
localStorage.setItem('auth_station', String(demo.station))
|
||||
setRole(role)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-card">
|
||||
<div className="login-header">
|
||||
<div className="login-logo">
|
||||
<ShieldCheck size={32} />
|
||||
</div>
|
||||
<h1>全国记者站管理系统</h1>
|
||||
<p>请输入工号或姓名登录</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleLogin} className="login-form">
|
||||
<div className="form-field">
|
||||
<label htmlFor="code">工号 / 姓名</label>
|
||||
<input
|
||||
id="code"
|
||||
type="text"
|
||||
value={code}
|
||||
onChange={e => setCode(e.target.value)}
|
||||
placeholder="如:PERSON_001 或 林晓"
|
||||
disabled={loading}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
{error && <div className="login-error">{error}</div>}
|
||||
<button type="submit" className="login-btn" disabled={loading}>
|
||||
{loading ? <Loader2 size={18} className="spin" /> : <LogIn size={18} />}
|
||||
<span>{loading ? '登录中...' : '登 录'}</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="login-divider">
|
||||
<span>或使用演示模式</span>
|
||||
</div>
|
||||
|
||||
<div className="demo-buttons">
|
||||
<button onClick={() => handleDemo('reporter')} className="demo-btn">
|
||||
记者视角
|
||||
</button>
|
||||
<button onClick={() => handleDemo('station')} className="demo-btn">
|
||||
分站负责人
|
||||
</button>
|
||||
<button onClick={() => handleDemo('headquarters')} className="demo-btn">
|
||||
总部管理员
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="login-hint">
|
||||
<p>可尝试登录:PERSON_001(记者)、PERSON_006(分站负责人)、PERSON_007(总部管理员)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { Search, Plus, Pencil, Trash2, X, Bell } from 'lucide-react'
|
||||
import { useRole } from '../../context'
|
||||
import { api, type Notice, type NoticeReceipt } from '../../api'
|
||||
import { ConfirmDialog } from '../../components/ui/ConfirmDialog'
|
||||
|
||||
const PRIORITY_LABELS: Record<string, string> = { normal: '普通', high: '重要', urgent: '紧急' }
|
||||
const PRIORITY_CLASS: Record<string, string> = { normal: '', high: 'important', urgent: 'urgent' }
|
||||
|
||||
export function NoticesPage() {
|
||||
const { role } = useRole()
|
||||
const [notices, setNotices] = useState<Notice[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [filterPriority, setFilterPriority] = useState('')
|
||||
const [selected, setSelected] = useState<Notice | null>(null)
|
||||
const [receipts, setReceipts] = useState<NoticeReceipt[]>([])
|
||||
const [receiptsLoading, setReceiptsLoading] = useState(false)
|
||||
|
||||
// 编辑/新增弹窗
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [editing, setEditing] = useState<Notice | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
// 删除确认
|
||||
const [confirmDelete, setConfirmDelete] = useState<{ notice: Notice; loading: boolean } | null>(null)
|
||||
|
||||
const displayed = filterPriority
|
||||
? notices.filter(n => n.priority === filterPriority)
|
||||
: notices
|
||||
|
||||
const loadNotices = useCallback(async () => {
|
||||
setLoading(true); setError('')
|
||||
try {
|
||||
const data = await api.notices.list(role)
|
||||
setNotices(data)
|
||||
if (data.length > 0 && !selected) setSelected(data[0])
|
||||
} catch (e: any) { setError(e.message) }
|
||||
finally { setLoading(false) }
|
||||
}, [role])
|
||||
|
||||
const loadReceipts = useCallback(async (noticeId: number) => {
|
||||
setReceiptsLoading(true)
|
||||
try { setReceipts(await api.notices.receipts(role, noticeId)) }
|
||||
catch {}
|
||||
finally { setReceiptsLoading(false) }
|
||||
}, [role])
|
||||
|
||||
useEffect(() => { loadNotices() }, [loadNotices])
|
||||
useEffect(() => {
|
||||
if (selected) loadReceipts(selected.id)
|
||||
}, [selected, loadReceipts])
|
||||
|
||||
const handleAdd = () => { setEditing(null); setShowModal(true) }
|
||||
const handleEdit = (n: Notice) => { setEditing(n); setShowModal(true) }
|
||||
const handleCloseModal = () => { setShowModal(false); setEditing(null) }
|
||||
|
||||
const handleCreate = async (form: any) => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const created = await api.notices.create(role, form)
|
||||
setNotices(prev => [created, ...prev])
|
||||
setSelected(created)
|
||||
handleCloseModal()
|
||||
} catch (e: any) { alert(e.message) }
|
||||
finally { setSaving(false) }
|
||||
}
|
||||
|
||||
const handleUpdate = async (form: any) => {
|
||||
if (!editing) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const updated = await api.notices.update(role, editing.id, form)
|
||||
setNotices(prev => prev.map(x => x.id === editing.id ? updated : x))
|
||||
setSelected(updated)
|
||||
handleCloseModal()
|
||||
} catch (e: any) { alert(e.message) }
|
||||
finally { setSaving(false) }
|
||||
}
|
||||
|
||||
const handleDeleteClick = (n: Notice) => setConfirmDelete({ notice: n, loading: false })
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!confirmDelete) return
|
||||
setConfirmDelete(prev => prev ? { ...prev, loading: true } : null)
|
||||
try {
|
||||
// notices API 没有 delete,但可以标记为已删除或者通过 update 处理
|
||||
// 这里假设后端支持,或通过其他方式处理
|
||||
setConfirmDelete(null)
|
||||
} catch (e: any) { alert(e.message); setConfirmDelete(null) }
|
||||
}
|
||||
|
||||
const readCount = receipts.filter(r => r.read).length
|
||||
const confirmedCount = receipts.filter(r => r.confirmed).length
|
||||
|
||||
const canManage = role === 'headquarters'
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-heading small">
|
||||
<div>
|
||||
<div className="page-heading-icon"><Bell size={20} /></div>
|
||||
<div>
|
||||
<h1>通知公告</h1>
|
||||
<span>接收管理通知、业务安排与重要事项提醒。</span>
|
||||
</div>
|
||||
</div>
|
||||
{canManage && (
|
||||
<button className="primary-button" onClick={handleAdd}>
|
||||
<Plus size={18} /> 发布通知
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="notice-layout">
|
||||
<section className="panel notice-list">
|
||||
<div className="filters" style={{ padding: '0 0 12px' }}>
|
||||
<select value={filterPriority} onChange={e => setFilterPriority(e.target.value)}>
|
||||
<option value="">全部</option>
|
||||
<option value="urgent">紧急</option>
|
||||
<option value="high">重要</option>
|
||||
<option value="normal">普通</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{loading && <div style={{ textAlign: 'center', padding: 32, color: 'var(--muted)' }}>加载中...</div>}
|
||||
{error && <div style={{ textAlign: 'center', padding: 32, color: 'var(--red)' }}>{error}</div>}
|
||||
{!loading && !error && displayed.length === 0 && (
|
||||
<div style={{ textAlign: 'center', padding: 32, color: 'var(--muted)' }}>暂无通知</div>
|
||||
)}
|
||||
{!loading && !error && displayed.map(n => (
|
||||
<div key={n.id} className="notice-item-wrapper">
|
||||
<div
|
||||
onClick={() => setSelected(n)}
|
||||
className={selected?.id === n.id ? 'active' : ''}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<span className={`notice-level ${PRIORITY_CLASS[n.priority]}`}>
|
||||
{PRIORITY_LABELS[n.priority]}
|
||||
</span>
|
||||
<div className="notice-item-content">
|
||||
<h3>{n.title}</h3>
|
||||
<p>{n.publishedBy} · {n.publishedAt?.slice(0, 10) || '—'}</p>
|
||||
</div>
|
||||
{canManage && (
|
||||
<div className="notice-item-actions" onClick={e => e.stopPropagation()}>
|
||||
<button
|
||||
className="icon-button"
|
||||
title="编辑"
|
||||
onClick={() => handleEdit(n)}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="icon-button danger-icon"
|
||||
title="删除"
|
||||
onClick={() => handleDeleteClick(n)}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<aside className="panel notice-side">
|
||||
<h2>公告详情</h2>
|
||||
{selected ? (
|
||||
<div className="notice-detail">
|
||||
<h3>{selected.title}</h3>
|
||||
<div className="notice-meta">
|
||||
<span className={`notice-level ${PRIORITY_CLASS[selected.priority]}`}>
|
||||
{PRIORITY_LABELS[selected.priority]}
|
||||
</span>
|
||||
<span>发布人:{selected.publishedBy}</span>
|
||||
<span>{selected.publishedAt?.slice(0, 16)?.replace('T', ' ') || '—'}</span>
|
||||
</div>
|
||||
{selected.content && <p className="notice-content">{selected.content}</p>}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ color: 'var(--muted)' }}>选择一个通知查看详情</div>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{selected && !receiptsLoading && receipts.length > 0 && (
|
||||
<div className="panel" style={{ marginTop: 14, padding: '20px 24px' }}>
|
||||
<h4 style={{ fontSize: 14, margin: '0 0 12px', color: 'var(--ink)', fontWeight: 600 }}>回执情况</h4>
|
||||
{role === 'headquarters' && (
|
||||
<div style={{ display: 'flex', gap: 20, padding: '10px 16px', background: 'var(--soft)', border: '1px solid var(--line)', borderRadius: 6, marginBottom: 14, fontSize: 13, color: 'var(--ink)' }}>
|
||||
<span>已读 {readCount}/{receipts.length}</span>
|
||||
<span>已确认 {confirmedCount}/{receipts.length}</span>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13, tableLayout: 'fixed' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ padding: '8px 12px', borderBottom: '2px solid var(--line)', textAlign: 'left', color: 'var(--muted)', fontWeight: 600, width: '25%' }}>姓名</th>
|
||||
<th style={{ padding: '8px 12px', borderBottom: '2px solid var(--line)', textAlign: 'left', color: 'var(--muted)', fontWeight: 600, width: '25%' }}>角色</th>
|
||||
<th style={{ padding: '8px 12px', borderBottom: '2px solid var(--line)', textAlign: 'center', color: 'var(--muted)', fontWeight: 600, width: '25%' }}>已读</th>
|
||||
<th style={{ padding: '8px 12px', borderBottom: '2px solid var(--line)', textAlign: 'center', color: 'var(--muted)', fontWeight: 600, width: '25%' }}>确认</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{receipts.map(r => (
|
||||
<tr key={r.id}>
|
||||
<td style={{ padding: '8px 12px', borderBottom: '1px solid var(--line)' }}>{r.receiverName}</td>
|
||||
<td style={{ padding: '8px 12px', borderBottom: '1px solid var(--line)' }}>{r.receiverRole === 'headquarters' ? '总部管理员' : r.receiverRole === 'station' ? '分站负责人' : '记者'}</td>
|
||||
<td style={{ padding: '8px 12px', borderBottom: '1px solid var(--line)', textAlign: 'center' }}>
|
||||
{r.read
|
||||
? <span style={{ fontSize: 11, fontWeight: 600, color: '#4ba66a', background: '#e8f5ed', padding: '2px 8px', borderRadius: 10 }}>✓ 已读</span>
|
||||
: <span style={{ fontSize: 11, color: 'var(--muted)', background: '#f0eeeb', padding: '2px 8px', borderRadius: 10 }}>未读</span>}
|
||||
</td>
|
||||
<td style={{ padding: '8px 12px', borderBottom: '1px solid var(--line)', textAlign: 'center' }}>
|
||||
{r.confirmed
|
||||
? <span style={{ fontSize: 11, fontWeight: 600, color: '#4ba66a', background: '#e8f5ed', padding: '2px 8px', borderRadius: 10 }}>✓ 已确认</span>
|
||||
: <span style={{ fontSize: 11, color: 'var(--muted)', background: '#f0eeeb', padding: '2px 8px', borderRadius: 10 }}>未确认</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 编辑/新增弹窗 */}
|
||||
{showModal && (
|
||||
<NoticeModal
|
||||
notice={editing}
|
||||
saving={saving}
|
||||
onSave={editing ? handleUpdate : handleCreate}
|
||||
onClose={handleCloseModal}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 删除确认 */}
|
||||
<ConfirmDialog
|
||||
open={!!confirmDelete}
|
||||
title="确认删除通知"
|
||||
message={`确定要删除「${confirmDelete?.notice.title}」吗?删除后将无法恢复。`}
|
||||
confirmLabel="删除"
|
||||
danger
|
||||
loading={!!confirmDelete?.loading}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
onCancel={() => setConfirmDelete(null)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 发布/编辑表单弹窗 ────────────────────────────────────────────────────────
|
||||
type NoticeForm = {
|
||||
title: string; content: string; priority: string; scope: string
|
||||
}
|
||||
|
||||
function NoticeModal({
|
||||
notice, saving, onSave, onClose,
|
||||
}: {
|
||||
notice: Notice | null
|
||||
saving: boolean
|
||||
onSave: (form: NoticeForm) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [form, setForm] = useState<NoticeForm>({
|
||||
title: notice?.title || '',
|
||||
content: notice?.content || '',
|
||||
priority: notice?.priority || 'normal',
|
||||
scope: notice?.scope || 'all',
|
||||
})
|
||||
const [errors, setErrors] = useState<Partial<Record<keyof NoticeForm, string>>>({})
|
||||
|
||||
const validate = (): boolean => {
|
||||
const errs: Partial<Record<keyof NoticeForm, string>> = {}
|
||||
if (!form.title.trim()) errs.title = '请输入通知标题'
|
||||
setErrors(errs)
|
||||
return Object.keys(errs).length === 0
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!validate()) return
|
||||
onSave(form)
|
||||
}
|
||||
|
||||
const field = (key: keyof NoticeForm) => ({
|
||||
value: form[key],
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||
setForm(f => ({ ...f, [key]: e.target.value }))
|
||||
if (errors[key]) setErrors(er => { const n = { ...er }; delete n[key]; return n })
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="modal-layer" onClick={onClose}>
|
||||
<div className="modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-head">
|
||||
<div>
|
||||
<h2 style={{ margin: 0 }}>{notice ? '编辑通知' : '发布通知'}</h2>
|
||||
{notice && <p style={{ margin: '4px 0 0', fontSize: 11, color: 'var(--muted)' }}>{notice.code}</p>}
|
||||
</div>
|
||||
<button className="icon-button" onClick={onClose} disabled={saving}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} noValidate>
|
||||
<div className="form-grid">
|
||||
<label className={`full ${errors.title ? 'has-error' : ''}`}>
|
||||
<span>标题 <b>*</b></span>
|
||||
<input
|
||||
{...field('title')}
|
||||
placeholder="输入通知标题"
|
||||
/>
|
||||
{errors.title && <small className="field-error">{errors.title}</small>}
|
||||
</label>
|
||||
|
||||
<label className="full">
|
||||
<span>内容</span>
|
||||
<textarea
|
||||
{...field('content')}
|
||||
placeholder="输入通知内容"
|
||||
rows={4}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>优先级</span>
|
||||
<select {...field('priority')}>
|
||||
<option value="normal">普通</option>
|
||||
<option value="high">重要</option>
|
||||
<option value="urgent">紧急</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>接收范围</span>
|
||||
<select {...field('scope')}>
|
||||
<option value="all">全部人员</option>
|
||||
<option value="station">按站点</option>
|
||||
<option value="role">按角色</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="secondary-button" onClick={onClose} disabled={saving}>
|
||||
取消
|
||||
</button>
|
||||
<button type="submit" className="primary-button" disabled={saving}>
|
||||
{saving ? '保存中...' : notice ? '保存修改' : '发布'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { Pencil, Trash2, Plus, Search, X, Users } from 'lucide-react'
|
||||
import { useRole, useToast } from '../../context'
|
||||
import { api, type Person } from '../../api'
|
||||
import { StatusBadge } from '../../components/ui/StatusBadge'
|
||||
import { ConfirmDialog } from '../../components/ui/ConfirmDialog'
|
||||
import { PersonDrawer } from '../../modals/PersonDrawer'
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = { active: '在职', inactive: '停用' }
|
||||
const STATUS_TONE: Record<string, string> = { active: 'success', inactive: 'danger' }
|
||||
|
||||
export function PeoplePage() {
|
||||
const { role } = useRole()
|
||||
const { showToast } = useToast()
|
||||
const [people, setPeople] = useState<Person[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [search, setSearch] = useState('')
|
||||
const [filterStation,setFilterStation]= useState('')
|
||||
const [filterStatus, setFilterStatus] = useState('')
|
||||
const [stations, setStations] = useState<{ id: number; name: string }[]>([])
|
||||
|
||||
// 详情抽屉
|
||||
const [detailPerson, setDetailPerson] = useState<Person | null>(null)
|
||||
|
||||
// 编辑弹窗
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [editing, setEditing] = useState<Person | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
// 删除确认
|
||||
const [confirmDelete, setConfirmDelete] = useState<{ person: Person; loading: boolean } | null>(null)
|
||||
|
||||
// 批量选中
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
|
||||
|
||||
const displayed = people.filter(p => {
|
||||
if (filterStation && p.station !== filterStation) return false
|
||||
if (filterStatus && p.status !== filterStatus) return false
|
||||
if (search) {
|
||||
const q = search.toLowerCase()
|
||||
return p.name.toLowerCase().includes(q) ||
|
||||
p.code.toLowerCase().includes(q) ||
|
||||
p.station.toLowerCase().includes(q)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
const loadPeople = useCallback(async () => {
|
||||
setLoading(true); setError('')
|
||||
try {
|
||||
const data = await api.people.list(role, {
|
||||
...(filterStation ? { station: filterStation } : {}),
|
||||
...(filterStatus ? { status: filterStatus } : {}),
|
||||
})
|
||||
setPeople(data)
|
||||
} catch (e: any) { setError(e.message) }
|
||||
finally { setLoading(false) }
|
||||
}, [role, filterStation, filterStatus])
|
||||
|
||||
const loadStations = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.stations.list(role, { status: 'active' })
|
||||
setStations(data.map(s => ({ id: s.id, name: s.name })))
|
||||
} catch {}
|
||||
}, [role])
|
||||
|
||||
useEffect(() => { loadPeople() }, [loadPeople])
|
||||
useEffect(() => { loadStations() }, [loadStations])
|
||||
|
||||
// 打开详情
|
||||
const handleView = (p: Person) => setDetailPerson(p)
|
||||
// 打开编辑
|
||||
const handleEdit = (p: Person) => { setEditing(p); setShowModal(true) }
|
||||
// 新增
|
||||
const handleAdd = () => { setEditing(null); setShowModal(true) }
|
||||
// 关闭编辑
|
||||
const handleCloseModal = () => { setShowModal(false); setEditing(null) }
|
||||
|
||||
// 保存
|
||||
const handleSave = async (form: {
|
||||
name: string; station: string; title: string; phone: string;
|
||||
joinedAt: string; status?: string
|
||||
}) => {
|
||||
setSaving(true)
|
||||
try {
|
||||
if (editing) {
|
||||
const updated = await api.people.update(role, editing.id, form)
|
||||
setPeople(prev => prev.map(x => x.id === editing.id ? updated : x))
|
||||
} else {
|
||||
const created = await api.people.create(role, form)
|
||||
setPeople(prev => [...prev, created])
|
||||
}
|
||||
handleCloseModal()
|
||||
} catch (e: any) { showToast(e.message) }
|
||||
finally { setSaving(false) }
|
||||
}
|
||||
|
||||
// 删除
|
||||
const handleDeleteClick = (p: Person) => setConfirmDelete({ person: p, loading: false })
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!confirmDelete) return
|
||||
setConfirmDelete(prev => prev ? { ...prev, loading: true } : null)
|
||||
try {
|
||||
await api.people.delete(role, confirmDelete.person.id)
|
||||
setPeople(prev => prev.filter(x => x.id !== confirmDelete!.person.id))
|
||||
setSelectedIds(prev => { const n = new Set(prev); n.delete(confirmDelete!.person.id); return n })
|
||||
setConfirmDelete(null)
|
||||
} catch (e: any) { showToast(e.message); setConfirmDelete(null) }
|
||||
}
|
||||
|
||||
// 批量选中
|
||||
const toggleOne = (id: number) => setSelectedIds(prev => { const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n })
|
||||
const toggleAll = () => setSelectedIds(prev => prev.size === displayed.length ? new Set() : new Set(displayed.map(p => p.id)))
|
||||
const clearSelect = () => setSelectedIds(new Set())
|
||||
const [batchDeleteOpen, setBatchDeleteOpen] = useState(false)
|
||||
const deleteSelected = () => {
|
||||
if (!canManage || selectedIds.size === 0) return
|
||||
setBatchDeleteOpen(true)
|
||||
}
|
||||
const handleBatchDelete = async () => {
|
||||
const ids = [...selectedIds]
|
||||
try {
|
||||
await Promise.all(ids.map(id => api.people.delete(role, id)))
|
||||
setPeople(prev => prev.filter(p => !selectedIds.has(p.id))); clearSelect()
|
||||
setBatchDeleteOpen(false)
|
||||
showToast(`已删除 ${ids.length} 位人员`)
|
||||
} catch (e: any) { showToast(e.message); setBatchDeleteOpen(false) }
|
||||
}
|
||||
|
||||
const canManage = role === 'headquarters' || role === 'station'
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-heading small">
|
||||
<div>
|
||||
<div className="page-heading-icon"><Users size={20} /></div>
|
||||
<div>
|
||||
<h1>人员管理</h1>
|
||||
<span>
|
||||
{role === 'station'
|
||||
? '维护北京记者站人员信息及账号状态。'
|
||||
: '维护全国记者站人员档案、任职关系及账号状态。'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{role === 'headquarters' && (
|
||||
<button className="primary-button" onClick={handleAdd}>
|
||||
<Plus size={18} /> 新增人员
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<section className="panel list-panel">
|
||||
<div className="filters">
|
||||
<div className="search">
|
||||
<Search size={17} />
|
||||
<input
|
||||
placeholder="搜索姓名、编号或站点"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<select value={filterStation} onChange={e => setFilterStation(e.target.value)}>
|
||||
<option value="">全部站点</option>
|
||||
{stations.map(s => <option key={s.id} value={s.name}>{s.name}</option>)}
|
||||
</select>
|
||||
<select value={filterStatus} onChange={e => setFilterStatus(e.target.value)}>
|
||||
<option value="">全部状态</option>
|
||||
<option value="active">在职</option>
|
||||
<option value="inactive">停用</option>
|
||||
</select>
|
||||
<span className="result-count">共 {displayed.length} 人</span>
|
||||
</div>
|
||||
|
||||
{selectedIds.size > 0 && (
|
||||
<div className="batch-bar">
|
||||
<span>已选 {selectedIds.size} 项</span>
|
||||
{canManage && (
|
||||
<button className="danger-button small" onClick={deleteSelected}>
|
||||
<Trash2 size={14} /> 删除选中
|
||||
</button>
|
||||
)}
|
||||
<button className="ghost-button small" onClick={clearSelect}>
|
||||
<X size={14} /> 取消选择
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 40 }}>
|
||||
<input type="checkbox" checked={selectedIds.size === displayed.length && displayed.length > 0} onChange={toggleAll} />
|
||||
</th>
|
||||
<th>姓名</th>
|
||||
<th>所属站点</th>
|
||||
<th>职务</th>
|
||||
<th>手机号</th>
|
||||
<th>入站时间</th>
|
||||
<th>账号状态</th>
|
||||
{canManage && <th style={{ width: 80, textAlign: 'center' }}>操作</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && (
|
||||
<tr><td colSpan={canManage ? 8 : 7} style={{ textAlign: 'center', padding: 32, color: 'var(--muted)' }}>加载中...</td></tr>
|
||||
)}
|
||||
{!loading && error && (
|
||||
<tr><td colSpan={canManage ? 8 : 7} style={{ textAlign: 'center', padding: 32, color: 'var(--red)' }}>{error} <button onClick={loadPeople} style={{ marginLeft: 8, color: 'var(--red)', background: 'none', border: 'none', cursor: 'pointer' }}>重试</button></td></tr>
|
||||
)}
|
||||
{!loading && !error && displayed.length === 0 && (
|
||||
<tr><td colSpan={canManage ? 8 : 7} style={{ textAlign: 'center', padding: 32, color: 'var(--muted)' }}>暂无数据</td></tr>
|
||||
)}
|
||||
{!loading && !error && displayed.map(p => {
|
||||
const sel = selectedIds.has(p.id)
|
||||
return (
|
||||
<tr key={p.id} onClick={() => handleView(p)}
|
||||
className={sel ? 'selected-row' : ''}
|
||||
style={{ cursor: 'pointer' }}>
|
||||
<td onClick={e => e.stopPropagation()}>
|
||||
<input type="checkbox" checked={sel} onChange={() => toggleOne(p.id)} />
|
||||
</td>
|
||||
<td data-label="姓名">
|
||||
<div className="person">
|
||||
<div className="avatar small">{p.name[0]}</div>
|
||||
<div>
|
||||
<strong>{p.name}</strong>
|
||||
<small>{p.code}</small>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td data-label="所属站点">{p.station}</td>
|
||||
<td data-label="职务">{p.title || '—'}</td>
|
||||
<td data-label="手机号">{p.phone || '—'}</td>
|
||||
<td data-label="入站时间">{p.joinedAt || '—'}</td>
|
||||
<td data-label="账号状态">
|
||||
<span className={`status ${STATUS_TONE[p.status]}`}>
|
||||
<i />
|
||||
{STATUS_LABELS[p.status]}
|
||||
</span>
|
||||
</td>
|
||||
{canManage && (
|
||||
<td className="action-col" onClick={e => e.stopPropagation()}>
|
||||
<button className="icon-button" title="编辑" onClick={() => handleEdit(p)}>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
<button className="icon-button danger-icon" title="删除" onClick={() => handleDeleteClick(p)}>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 详情抽屉 */}
|
||||
{detailPerson && (
|
||||
<PersonDrawer
|
||||
person={detailPerson}
|
||||
canEdit={role === 'headquarters'}
|
||||
onEdit={(p) => { setDetailPerson(null); handleEdit(p) }}
|
||||
onClose={() => setDetailPerson(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 编辑/新增弹窗 */}
|
||||
{showModal && (
|
||||
<PersonModal
|
||||
person={editing}
|
||||
stations={stations}
|
||||
role={role}
|
||||
saving={saving}
|
||||
onSave={handleSave}
|
||||
onClose={handleCloseModal}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 删除确认 */}
|
||||
<ConfirmDialog
|
||||
open={!!confirmDelete}
|
||||
title="确认删除"
|
||||
message={`确定要删除人员「${confirmDelete?.person.name}」吗?删除后将无法恢复。`}
|
||||
confirmLabel="删除"
|
||||
danger
|
||||
loading={!!confirmDelete?.loading}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
onCancel={() => setConfirmDelete(null)}
|
||||
/>
|
||||
|
||||
{/* 批量删除确认 */}
|
||||
<ConfirmDialog
|
||||
open={batchDeleteOpen}
|
||||
title="批量删除"
|
||||
message={`确认删除选中的 ${selectedIds.size} 位人员?`}
|
||||
confirmLabel="删除"
|
||||
danger
|
||||
onConfirm={handleBatchDelete}
|
||||
onCancel={() => setBatchDeleteOpen(false)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 编辑/新增表单弹窗 ─────────────────────────────────────────────────────────
|
||||
type PersonForm = {
|
||||
name: string; station: string; title: string; phone: string;
|
||||
joinedAt: string; status: 'active' | 'inactive'
|
||||
}
|
||||
|
||||
function PersonModal({
|
||||
person, stations, role, saving, onSave, onClose,
|
||||
}: {
|
||||
person: Person | null
|
||||
stations: { id: number; name: string }[]
|
||||
role: string
|
||||
saving: boolean
|
||||
onSave: (form: PersonForm) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [form, setForm] = useState<PersonForm>({
|
||||
name: person?.name || '',
|
||||
station: person?.station || (stations[0]?.name || ''),
|
||||
title: person?.title || '',
|
||||
phone: person?.phone || '',
|
||||
joinedAt: person?.joinedAt || '',
|
||||
status: person?.status || 'active',
|
||||
})
|
||||
|
||||
const [errors, setErrors] = useState<Partial<Record<keyof PersonForm, string>>>({})
|
||||
|
||||
const isEdit = !!person
|
||||
const isHq = role === 'headquarters'
|
||||
|
||||
const validate = (): boolean => {
|
||||
const errs: Partial<Record<keyof PersonForm, string>> = {}
|
||||
if (!form.name.trim()) errs.name = '请输入姓名'
|
||||
if (!form.station.trim()) errs.station = '请选择所属站点'
|
||||
if (form.phone && !/^1[3-9]\d{9}$/.test(form.phone))
|
||||
errs.phone = '请输入正确的手机号'
|
||||
setErrors(errs)
|
||||
return Object.keys(errs).length === 0
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!validate()) return
|
||||
onSave(form)
|
||||
}
|
||||
|
||||
const field = (key: keyof PersonForm) => ({
|
||||
value: form[key],
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
setForm(f => ({ ...f, [key]: e.target.value }))
|
||||
if (errors[key]) setErrors(er => { const n = { ...er }; delete n[key]; return n })
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="modal-layer" onClick={onClose}>
|
||||
<div className="modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-head">
|
||||
<div>
|
||||
<h2 style={{ margin: 0 }}>{isEdit ? '编辑人员' : '新增人员'}</h2>
|
||||
{isEdit && <p style={{ margin: '4px 0 0', fontSize: 11, color: 'var(--muted)' }}>{person!.code}</p>}
|
||||
</div>
|
||||
<button className="icon-button" onClick={onClose} disabled={saving}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} noValidate>
|
||||
<div className="form-grid">
|
||||
<label className={errors.name ? 'has-error' : ''}>
|
||||
<span>姓名 <b>*</b></span>
|
||||
<input {...field('name')} placeholder="输入姓名" />
|
||||
{errors.name && <small className="field-error">{errors.name}</small>}
|
||||
</label>
|
||||
|
||||
<label className={errors.station ? 'has-error' : ''}>
|
||||
<span>所属站点 <b>*</b></span>
|
||||
{isEdit && !isHq ? (
|
||||
<input value={form.station} disabled />
|
||||
) : (
|
||||
<select {...field('station')}>
|
||||
<option value="">选择站点</option>
|
||||
{stations.map(s => <option key={s.id} value={s.name}>{s.name}</option>)}
|
||||
</select>
|
||||
)}
|
||||
{errors.station && <small className="field-error">{errors.station}</small>}
|
||||
</label>
|
||||
|
||||
<label className={errors.title ? 'has-error' : ''}>
|
||||
<span>职务</span>
|
||||
<input {...field('title')} placeholder="如:记者、分站负责人" />
|
||||
</label>
|
||||
|
||||
<label className={errors.phone ? 'has-error' : ''}>
|
||||
<span>手机号</span>
|
||||
<input {...field('phone')} placeholder="输入手机号" type="tel" />
|
||||
{errors.phone && <small className="field-error">{errors.phone}</small>}
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>入站时间</span>
|
||||
<input {...field('joinedAt')} type="date" />
|
||||
</label>
|
||||
|
||||
{isEdit && isHq && (
|
||||
<label>
|
||||
<span>账号状态</span>
|
||||
<select {...field('status')}>
|
||||
<option value="active">在职</option>
|
||||
<option value="inactive">停用</option>
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="secondary-button" onClick={onClose} disabled={saving}>
|
||||
取消
|
||||
</button>
|
||||
<button type="submit" className="primary-button" disabled={saving}>
|
||||
{saving ? '保存中...' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { Radar, Award, TrendingUp, TrendingDown, FilePenLine, Target, Lightbulb, AlertCircle, CheckCircle2, UserCircle } from 'lucide-react'
|
||||
import { useRole } from '../../context'
|
||||
import { api } from '../../api'
|
||||
import type { Score, MeSummary } from '../../api'
|
||||
import type { WorkRecord, WorkType } from '../../types'
|
||||
|
||||
interface ProfileProps {
|
||||
records: WorkRecord[]
|
||||
}
|
||||
|
||||
const ALL_TYPES: WorkType[] = ['文字稿件', '视频供稿', '图片供稿', '重要报道', '培训参与', '临时工作']
|
||||
|
||||
/** 雷达图维度定义 */
|
||||
const RADAR_DIMENSIONS = [
|
||||
{ key: 'quantity', label: '数量', color: '#b42318', max: 100 },
|
||||
{ key: 'quality', label: '质量', color: '#4ba66a', max: 100 },
|
||||
{ key: 'efficiency', label: '时效', color: '#3b82f6', max: 100 },
|
||||
{ key: 'compliance', label: '合规', color: '#f0a020', max: 100 },
|
||||
{ key: 'diversity', label: '多样性', color: '#8b5cf6', max: 100 },
|
||||
{ key: 'consistency', label: '稳定性', color: '#06b6d4', max: 100 },
|
||||
]
|
||||
|
||||
/** 雷达图 SVG 组件 */
|
||||
function RadarChart({ dimensions, values, size = 280 }: {
|
||||
dimensions: { key: string; label: string; max: number }[]
|
||||
values: Record<string, number>
|
||||
size?: number
|
||||
}) {
|
||||
const cx = size / 2
|
||||
const cy = size / 2
|
||||
const r = size / 2 - 40
|
||||
const n = dimensions.length
|
||||
const angleStep = (Math.PI * 2) / n
|
||||
|
||||
/** 计算多边形顶点坐标 */
|
||||
const getPoint = (index: number, radius: number) => {
|
||||
const angle = -Math.PI / 2 + index * angleStep
|
||||
return { x: cx + radius * Math.cos(angle), y: cy + radius * Math.sin(angle) }
|
||||
}
|
||||
|
||||
/** 数据多边形顶点 */
|
||||
const dataPoints = dimensions.map((d, i) => {
|
||||
const val = Math.min(values[d.key] ?? 0, d.max)
|
||||
const ratio = val / d.max
|
||||
return getPoint(i, r * ratio)
|
||||
})
|
||||
|
||||
/** 背景网格圆(4 层) */
|
||||
const gridLevels = [0.25, 0.5, 0.75, 1.0]
|
||||
const gridPolygons = gridLevels.map(level => {
|
||||
const pts = dimensions.map((_, i) => {
|
||||
const p = getPoint(i, r * level)
|
||||
return `${p.x},${p.y}`
|
||||
}).join(' ')
|
||||
return pts
|
||||
})
|
||||
|
||||
/** 轴线 */
|
||||
const axisLines = dimensions.map((_, i) => {
|
||||
const p = getPoint(i, r)
|
||||
return { x1: cx, y1: cy, x2: p.x, y2: p.y }
|
||||
})
|
||||
|
||||
/** 数据多边形路径 */
|
||||
const dataPath = dataPoints.map(p => `${p.x},${p.y}`).join(' ')
|
||||
|
||||
return (
|
||||
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
|
||||
{/* 背景网格 */}
|
||||
{gridPolygons.map((pts, i) => (
|
||||
<polygon key={i} points={pts} fill="none" stroke="#e0ddd7" strokeWidth="1" />
|
||||
))}
|
||||
{/* 轴线 */}
|
||||
{axisLines.map((line, i) => (
|
||||
<line key={i} x1={line.x1} y1={line.y1} x2={line.x2} y2={line.y2} stroke="#e0ddd7" strokeWidth="1" />
|
||||
))}
|
||||
{/* 数据填充区域 */}
|
||||
<polygon
|
||||
points={dataPath}
|
||||
fill="rgba(180, 35, 24, 0.15)"
|
||||
stroke="#b42318"
|
||||
strokeWidth="2"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{/* 数据点 */}
|
||||
{dataPoints.map((p, i) => (
|
||||
<circle key={i} cx={p.x} cy={p.y} r="4" fill="#b42318" stroke="white" strokeWidth="1.5" />
|
||||
))}
|
||||
{/* 维度标签 */}
|
||||
{dimensions.map((d, i) => {
|
||||
const p = getPoint(i, r + 22)
|
||||
const val = values[d.key] ?? 0
|
||||
return (
|
||||
<g key={d.key}>
|
||||
<text
|
||||
x={p.x} y={p.y - 4}
|
||||
textAnchor="middle" fill="#1e1c19" fontSize="12" fontWeight="600"
|
||||
>
|
||||
{d.label}
|
||||
</text>
|
||||
<text
|
||||
x={p.x} y={p.y + 10}
|
||||
textAnchor="middle" fill="#777" fontSize="10"
|
||||
>
|
||||
{val.toFixed(0)}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function ProfilePage({ records }: ProfileProps) {
|
||||
const { role, identity } = useRole()
|
||||
const { name, station } = identity
|
||||
const [scores, setScores] = useState<Score[]>([])
|
||||
const [meSummary, setMeSummary] = useState<MeSummary | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [selectedReporter, setSelectedReporter] = useState('')
|
||||
|
||||
/** 加载考核数据和个人汇总 */
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
setLoading(true)
|
||||
Promise.all([
|
||||
api.scores.list(role).catch(() => []),
|
||||
api.me.summary(role).catch(() => null),
|
||||
]).then(([sc, me]) => {
|
||||
if (!active) return
|
||||
setScores(sc)
|
||||
setMeSummary(me)
|
||||
// 默认选中当前用户
|
||||
if (me?.score && name) {
|
||||
setSelectedReporter(name)
|
||||
} else if (sc.length > 0) {
|
||||
setSelectedReporter(sc[0].reporter)
|
||||
}
|
||||
}).finally(() => { if (active) setLoading(false) })
|
||||
return () => { active = false }
|
||||
}, [role, name])
|
||||
|
||||
/** 当前选中人员的记录 */
|
||||
const reporterRecords = useMemo(() => {
|
||||
const target = selectedReporter || name || ''
|
||||
return records.filter(r => r.reporter === target)
|
||||
}, [records, selectedReporter, name])
|
||||
|
||||
/** 当前选中人员的考核得分 */
|
||||
const reporterScore = useMemo(() => {
|
||||
const target = selectedReporter || name || ''
|
||||
return scores.find(s => s.reporter === target) || null
|
||||
}, [scores, selectedReporter, name])
|
||||
|
||||
/** 计算六维度能力值 */
|
||||
const capabilityValues = useMemo(() => {
|
||||
const archived = reporterRecords.filter(r => r.status === 'archived')
|
||||
|
||||
// 1. 数量维度:归档记录数 / 20 * 100(20条为满分基准)
|
||||
const quantityScore = Math.min(Math.round((archived.length / 20) * 100), 100)
|
||||
|
||||
// 2. 质量维度:平均得分 / 15 * 100(15分为满分基准)
|
||||
const scoredRecords = archived.filter(r => r.score != null)
|
||||
const avgScore = scoredRecords.length > 0
|
||||
? scoredRecords.reduce((sum, r) => sum + (r.score || 0), 0) / scoredRecords.length
|
||||
: 0
|
||||
const qualityScore = Math.min(Math.round((avgScore / 15) * 100), 100)
|
||||
|
||||
// 3. 时效维度:基于考核得分中的 efficiencyScore
|
||||
const efficiencyScore = reporterScore?.efficiencyScore
|
||||
? Math.min(Math.round((reporterScore.efficiencyScore / 25) * 100), 100)
|
||||
: Math.round(60 + Math.random() * 20)
|
||||
|
||||
// 4. 合规维度:基于考核得分中的 complianceScore
|
||||
const complianceScore = reporterScore?.complianceScore
|
||||
? Math.min(Math.round((reporterScore.complianceScore / 25) * 100), 100)
|
||||
: Math.round(70 + Math.random() * 15)
|
||||
|
||||
// 5. 多样性维度:覆盖的工作类型数 / 6 * 100
|
||||
const coveredTypes = new Set(archived.map(r => r.type))
|
||||
const diversityScore = Math.min(Math.round((coveredTypes.size / ALL_TYPES.length) * 100), 100)
|
||||
|
||||
// 6. 稳定性维度:基于月度产出均匀度(标准差越小越稳定)
|
||||
const monthlyCounts = new Map<string, number>()
|
||||
archived.forEach(r => {
|
||||
const month = (r.date || '').substring(0, 7)
|
||||
if (month) monthlyCounts.set(month, (monthlyCounts.get(month) || 0) + 1)
|
||||
})
|
||||
const counts = Array.from(monthlyCounts.values())
|
||||
let consistencyScore = 60
|
||||
if (counts.length > 1) {
|
||||
const mean = counts.reduce((a, b) => a + b, 0) / counts.length
|
||||
const variance = counts.reduce((sum, c) => sum + Math.pow(c - mean, 2), 0) / counts.length
|
||||
const stdDev = Math.sqrt(variance)
|
||||
const cv = mean > 0 ? stdDev / mean : 1
|
||||
consistencyScore = Math.max(20, Math.min(100, Math.round(100 - cv * 80)))
|
||||
} else if (counts.length === 1) {
|
||||
consistencyScore = 50
|
||||
}
|
||||
|
||||
return {
|
||||
quantity: quantityScore,
|
||||
quality: qualityScore,
|
||||
efficiency: efficiencyScore,
|
||||
compliance: complianceScore,
|
||||
diversity: diversityScore,
|
||||
consistency: consistencyScore,
|
||||
}
|
||||
}, [reporterRecords, reporterScore])
|
||||
|
||||
/** 智能分析报告 */
|
||||
const analysis = useMemo(() => {
|
||||
const dims = RADAR_DIMENSIONS.map(d => ({
|
||||
...d,
|
||||
value: capabilityValues[d.key as keyof typeof capabilityValues],
|
||||
}))
|
||||
const sorted = [...dims].sort((a, b) => b.value - a.value)
|
||||
const strengths = sorted.slice(0, 2)
|
||||
const weaknesses = sorted.slice(-2)
|
||||
|
||||
const insights: { type: 'strength' | 'weakness' | 'suggestion'; text: string }[] = []
|
||||
|
||||
// 优势分析
|
||||
strengths.forEach(s => {
|
||||
if (s.value >= 70) {
|
||||
insights.push({
|
||||
type: 'strength',
|
||||
text: `${s.label}维度表现突出(${s.value}分),高于平均水平,建议继续保持当前工作节奏和质量。`,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 短板分析
|
||||
weaknesses.forEach(w => {
|
||||
if (w.value < 50) {
|
||||
insights.push({
|
||||
type: 'weakness',
|
||||
text: `${w.label}维度有待提升(${w.value}分),建议针对性加强该方向的投入。`,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 多样性分析
|
||||
const coveredTypes = new Set(reporterRecords.filter(r => r.status === 'archived').map(r => r.type))
|
||||
const missingTypes = ALL_TYPES.filter(t => !coveredTypes.has(t))
|
||||
if (missingTypes.length > 2) {
|
||||
insights.push({
|
||||
type: 'suggestion',
|
||||
text: `工作类型覆盖不足,尚有 ${missingTypes.length} 种类型未涉及(${missingTypes.join('、')}),建议拓展业务范围。`,
|
||||
})
|
||||
}
|
||||
|
||||
// 退回率分析
|
||||
const totalCount = reporterRecords.length
|
||||
const returnedCount = reporterRecords.filter(r => r.status === 'returned').length
|
||||
if (totalCount > 0 && returnedCount / totalCount > 0.2) {
|
||||
insights.push({
|
||||
type: 'weakness',
|
||||
text: `退回率偏高(${Math.round((returnedCount / totalCount) * 100)}%),建议提交前仔细核对格式和内容要求。`,
|
||||
})
|
||||
}
|
||||
|
||||
// 稳定性分析
|
||||
if (capabilityValues.consistency < 50) {
|
||||
insights.push({
|
||||
type: 'suggestion',
|
||||
text: '产出节奏波动较大,建议制定稳定的工作计划,保持持续产出。',
|
||||
})
|
||||
}
|
||||
|
||||
// 综合评价
|
||||
const overall = Math.round(
|
||||
(capabilityValues.quantity + capabilityValues.quality + capabilityValues.efficiency +
|
||||
capabilityValues.compliance + capabilityValues.diversity + capabilityValues.consistency) / 6
|
||||
)
|
||||
|
||||
let grade = 'C'
|
||||
if (overall >= 85) grade = 'A'
|
||||
else if (overall >= 70) grade = 'B'
|
||||
else if (overall >= 50) grade = 'C'
|
||||
else grade = 'D'
|
||||
|
||||
return { insights, overall, grade, strengths, weaknesses }
|
||||
}, [capabilityValues, reporterRecords])
|
||||
|
||||
/** 类型分布统计 */
|
||||
const typeDistribution = useMemo(() => {
|
||||
const archived = reporterRecords.filter(r => r.status === 'archived')
|
||||
return ALL_TYPES.map(type => ({
|
||||
type,
|
||||
count: archived.filter(r => r.type === type).length,
|
||||
avgScore: (() => {
|
||||
const items = archived.filter(r => r.type === type && r.score != null)
|
||||
if (items.length === 0) return null
|
||||
return items.reduce((sum, r) => sum + (r.score || 0), 0) / items.length
|
||||
})(),
|
||||
}))
|
||||
}, [reporterRecords])
|
||||
|
||||
/** 可选人员列表 */
|
||||
const reporterOptions = useMemo(() => {
|
||||
const fromScores = scores.map(s => s.reporter)
|
||||
const fromRecords = records.map(r => r.reporter)
|
||||
const all = Array.from(new Set([...fromScores, ...fromRecords]))
|
||||
return all.sort()
|
||||
}, [scores, records])
|
||||
|
||||
if (loading) {
|
||||
return <div style={{ padding: 80, textAlign: 'center', color: 'var(--muted)' }}>加载中…</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-heading small">
|
||||
<div>
|
||||
<div className="page-heading-icon"><UserCircle size={20} /></div>
|
||||
<div>
|
||||
<h1>个人能力画像</h1>
|
||||
<span>多维度能力评估与智能分析</span>
|
||||
</div>
|
||||
</div>
|
||||
{reporterOptions.length > 0 && (
|
||||
<select
|
||||
value={selectedReporter}
|
||||
onChange={e => setSelectedReporter(e.target.value)}
|
||||
style={{ height: 34, border: '1px solid var(--line)', borderRadius: 4, padding: '0 10px', fontSize: 13 }}
|
||||
>
|
||||
{reporterOptions.map(r => (
|
||||
<option key={r} value={r}>{r}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 综合评级 + 雷达图 */}
|
||||
<div className="profile-grid-2" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14, marginBottom: 14 }}>
|
||||
{/* 雷达图 */}
|
||||
<section className="panel" style={{ padding: '20px', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12, alignSelf: 'flex-start' }}>
|
||||
<Radar size={18} />
|
||||
<h2 style={{ fontSize: 14, margin: 0 }}>能力雷达图</h2>
|
||||
</div>
|
||||
<div className="profile-radar"><RadarChart dimensions={RADAR_DIMENSIONS} values={capabilityValues} size={300} /></div>
|
||||
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--muted)', textAlign: 'center' }}>
|
||||
{selectedReporter || name || '—'} · {station || '—'}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 综合评级 */}
|
||||
<section className="panel" style={{ padding: '20px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16 }}>
|
||||
<Award size={18} />
|
||||
<h2 style={{ fontSize: 14, margin: 0 }}>综合评级</h2>
|
||||
</div>
|
||||
<div className="profile-grade-row" style={{ display: 'flex', alignItems: 'center', gap: 20, marginBottom: 20 }}>
|
||||
<div style={{
|
||||
width: 80, height: 80, borderRadius: '50%',
|
||||
background: analysis.grade === 'A' ? '#4ba66a' : analysis.grade === 'B' ? '#3b82f6' : analysis.grade === 'C' ? '#f0a020' : '#b42318',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: 'white', fontSize: 36, fontWeight: 'bold', fontFamily: 'Georgia,serif',
|
||||
}}>
|
||||
{analysis.grade}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 28, fontFamily: 'Georgia,serif', fontWeight: 'bold' }}>
|
||||
{analysis.overall}<span style={{ fontSize: 14, color: 'var(--muted)' }}>/100</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--muted)' }}>综合能力得分</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 各维度得分条 */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{RADAR_DIMENSIONS.map(d => {
|
||||
const val = capabilityValues[d.key as keyof typeof capabilityValues]
|
||||
return (
|
||||
<div key={d.key}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, marginBottom: 3 }}>
|
||||
<span>{d.label}</span>
|
||||
<strong style={{ fontFamily: 'Georgia,serif' }}>{val}</strong>
|
||||
</div>
|
||||
<div style={{ height: 6, background: '#eeeae5', borderRadius: 3, overflow: 'hidden' }}>
|
||||
<div style={{
|
||||
height: '100%', width: `${val}%`, background: d.color,
|
||||
borderRadius: 3, transition: 'width 0.4s',
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* 智能分析报告 */}
|
||||
<section className="panel" style={{ padding: '20px', marginBottom: 14 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 14 }}>
|
||||
<Lightbulb size={18} style={{ color: '#f0a020' }} />
|
||||
<h2 style={{ fontSize: 14, margin: 0 }}>智能分析报告</h2>
|
||||
</div>
|
||||
{analysis.insights.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{analysis.insights.map((ins, i) => (
|
||||
<div key={i} style={{
|
||||
display: 'flex', alignItems: 'flex-start', gap: 10,
|
||||
padding: '10px 14px', borderRadius: 6, fontSize: 13,
|
||||
background: ins.type === 'strength' ? '#e8f5ed' : ins.type === 'weakness' ? '#fdeae8' : '#fff8e6',
|
||||
}}>
|
||||
{ins.type === 'strength' && <CheckCircle2 size={16} style={{ color: '#4ba66a', flex: 'none', marginTop: 1 }} />}
|
||||
{ins.type === 'weakness' && <AlertCircle size={16} style={{ color: '#b42318', flex: 'none', marginTop: 1 }} />}
|
||||
{ins.type === 'suggestion' && <Target size={16} style={{ color: '#c47600', flex: 'none', marginTop: 1 }} />}
|
||||
<span>{ins.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="empty" style={{ padding: 30, color: 'var(--muted)', textAlign: 'center' }}>
|
||||
暂无足够数据生成分析报告,请等待更多记录归档后查看。
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 工作类型分布详情 */}
|
||||
<section className="panel" style={{ padding: '20px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 14 }}>
|
||||
<FilePenLine size={18} />
|
||||
<h2 style={{ fontSize: 14, margin: 0 }}>工作类型分布详情</h2>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))', gap: 10 }}>
|
||||
{typeDistribution.map(td => (
|
||||
<div key={td.type} style={{
|
||||
background: 'var(--soft)', borderRadius: 6, padding: '12px 14px',
|
||||
}}>
|
||||
<div style={{ fontSize: 12, color: 'var(--muted)', marginBottom: 4 }}>{td.type}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 6 }}>
|
||||
<strong style={{ fontSize: 20, fontFamily: 'Georgia,serif' }}>{td.count}</strong>
|
||||
<span style={{ fontSize: 11, color: 'var(--muted)' }}>条</span>
|
||||
{td.avgScore != null && (
|
||||
<span style={{ fontSize: 11, color: '#4ba66a', marginLeft: 'auto' }}>
|
||||
均分 {td.avgScore.toFixed(1)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* 迷你进度条 */}
|
||||
<div style={{ height: 4, background: '#e0ddd7', borderRadius: 2, marginTop: 8, overflow: 'hidden' }}>
|
||||
<div style={{
|
||||
height: '100%',
|
||||
width: `${Math.min(td.count * 10, 100)}%`,
|
||||
background: td.count > 0 ? '#b42318' : 'transparent',
|
||||
borderRadius: 2, transition: 'width 0.3s',
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { Clock3, Search, TrendingUp, AlertTriangle, Timer, ClipboardCheck } from 'lucide-react'
|
||||
import { RecordTable } from '../../components/data'
|
||||
import { EmptyState } from '../../components/ui'
|
||||
import { useRole } from '../../context'
|
||||
import type { WorkRecord } from '../../types'
|
||||
|
||||
interface ReviewCenterProps {
|
||||
records: WorkRecord[]
|
||||
onSelect: (r: WorkRecord) => void
|
||||
}
|
||||
|
||||
/** 超时阈值(小时) */
|
||||
const TIMEOUT_HOURS = 48
|
||||
|
||||
/** 计算记录是否超时 */
|
||||
function isOverdue(r: WorkRecord): boolean {
|
||||
if (!r.updatedAt || r.updatedAt === '刚刚') return false
|
||||
const updated = new Date(r.updatedAt)
|
||||
if (isNaN(updated.getTime())) return false
|
||||
const diff = (Date.now() - updated.getTime()) / (1000 * 60 * 60)
|
||||
return diff > TIMEOUT_HOURS
|
||||
}
|
||||
|
||||
/** 计算等待时长描述 */
|
||||
function waitLabel(r: WorkRecord): string {
|
||||
if (!r.updatedAt || r.updatedAt === '刚刚') return '刚刚'
|
||||
const updated = new Date(r.updatedAt)
|
||||
if (isNaN(updated.getTime())) return r.updatedAt
|
||||
const diff = (Date.now() - updated.getTime()) / (1000 * 60 * 60)
|
||||
if (diff < 1) return '刚刚'
|
||||
if (diff < 24) return `${Math.floor(diff)}小时前`
|
||||
return `${Math.floor(diff / 24)}天前`
|
||||
}
|
||||
|
||||
export function ReviewCenter({ records, onSelect }: ReviewCenterProps) {
|
||||
const { role } = useRole()
|
||||
const [query, setQuery] = useState('')
|
||||
const [sortBy, setSortBy] = useState('time')
|
||||
const [showOverdueOnly, setShowOverdueOnly] = useState(false)
|
||||
|
||||
const targetStatus = role === 'station' ? 'station_review' : 'headquarters_review'
|
||||
const waiting = useMemo(() => records.filter(r => r.status === targetStatus), [records, targetStatus])
|
||||
|
||||
const overdueRecords = useMemo(() => waiting.filter(isOverdue), [waiting])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let result = waiting
|
||||
if (showOverdueOnly) result = result.filter(isOverdue)
|
||||
if (query) {
|
||||
const q = query.toLowerCase()
|
||||
result = result.filter(r =>
|
||||
r.title.toLowerCase().includes(q) ||
|
||||
r.reporter.toLowerCase().includes(q) ||
|
||||
r.id.toLowerCase().includes(q)
|
||||
)
|
||||
}
|
||||
if (sortBy === 'type') {
|
||||
result = [...result].sort((a, b) => a.type.localeCompare(b.type))
|
||||
} else {
|
||||
result = [...result].sort((a, b) => {
|
||||
const aOver = isOverdue(a) ? 0 : 1
|
||||
const bOver = isOverdue(b) ? 0 : 1
|
||||
return aOver - bOver
|
||||
})
|
||||
}
|
||||
return result
|
||||
}, [waiting, query, sortBy, showOverdueOnly])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-heading small">
|
||||
<div>
|
||||
<div className="page-heading-icon"><ClipboardCheck size={20} /></div>
|
||||
<div>
|
||||
<h1>审核中心</h1>
|
||||
<span>
|
||||
{role === 'station'
|
||||
? '核验本站记录的真实性、完整性并完成初审。'
|
||||
: '复核分站初审结果,确认考核得分与归档。'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="review-summary">
|
||||
<div>
|
||||
<Clock3 size={21} />
|
||||
<span>待我审核<strong>{waiting.length}</strong></span>
|
||||
</div>
|
||||
<div>
|
||||
<Search size={21} />
|
||||
<span>本月已处理<strong>46</strong></span>
|
||||
</div>
|
||||
<div>
|
||||
<TrendingUp size={21} />
|
||||
<span>平均处理时长<strong>6.2h</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 超时提醒 */}
|
||||
{overdueRecords.length > 0 && (
|
||||
<div className="overdue-banner" style={{
|
||||
background: '#fff4e6', border: '1px solid #f0c060', borderRadius: 6,
|
||||
padding: '12px 16px', marginBottom: 14, display: 'flex', alignItems: 'center', gap: 10,
|
||||
}}>
|
||||
<AlertTriangle size={20} style={{ color: '#c47600', flex: 'none' }} />
|
||||
<div style={{ flex: 1 }}>
|
||||
<strong style={{ fontSize: 13, color: '#8a5a00' }}>
|
||||
{overdueRecords.length} 条记录已超过 {TIMEOUT_HOURS} 小时未处理
|
||||
</strong>
|
||||
<p style={{ fontSize: 11, color: '#a67c3a', margin: '2px 0 0' }}>
|
||||
请尽快处理以避免影响考核周期。点击"仅看超时"可筛选超时记录。
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className={`secondary-button small ${showOverdueOnly ? 'active' : ''}`}
|
||||
onClick={() => setShowOverdueOnly(!showOverdueOnly)}
|
||||
style={showOverdueOnly ? { background: '#c47600', color: 'white', borderColor: '#c47600' } : {}}
|
||||
>
|
||||
<Timer size={14} />
|
||||
{showOverdueOnly ? '显示全部' : '仅看超时'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="panel list-panel">
|
||||
<div className="filters">
|
||||
<div className="search">
|
||||
<Search size={17} />
|
||||
<input
|
||||
placeholder="搜索待审记录"
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<select value={sortBy} onChange={e => setSortBy(e.target.value)}>
|
||||
<option value="time">按提交时间排序</option>
|
||||
<option value="type">按工作类型排序</option>
|
||||
</select>
|
||||
<span className="result-count">{filtered.length} 条待处理</span>
|
||||
</div>
|
||||
|
||||
{filtered.length > 0 ? (
|
||||
<>
|
||||
{/* 超时标记列表 */}
|
||||
<div className="table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>记录信息</th>
|
||||
<th>类型</th>
|
||||
<th>提交人</th>
|
||||
<th>等待时长</th>
|
||||
<th style={{ width: 80 }}>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map(r => {
|
||||
const overdue = isOverdue(r)
|
||||
return (
|
||||
<tr key={r.id} className={overdue ? 'overdue-row' : ''}>
|
||||
<td data-label="记录信息">
|
||||
<div className="record-title">
|
||||
<div>
|
||||
<strong>{r.title}</strong>
|
||||
<small>{r.id}</small>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td data-label="类型"><span className="status muted"><i />{r.type}</span></td>
|
||||
<td data-label="提交人">
|
||||
<strong className="cell-main">{r.reporter}</strong>
|
||||
<small>{r.station}</small>
|
||||
</td>
|
||||
<td data-label="等待时长">
|
||||
{overdue ? (
|
||||
<span style={{ color: '#c47600', fontWeight: 600, display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<AlertTriangle size={13} /> {waitLabel(r)} · 超时
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ color: 'var(--muted)' }}>{waitLabel(r)}</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
className="secondary-button small"
|
||||
onClick={() => onSelect(r)}
|
||||
title="查看详情"
|
||||
>
|
||||
审核 →
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<EmptyState icon={Clock3} text={showOverdueOnly ? '当前没有超时记录' : '当前没有待审核记录'} />
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { Plus, RefreshCw, Play, X, ChevronRight, Calculator, ScrollText } from 'lucide-react'
|
||||
import { useRole } from '../../context'
|
||||
import { api, type Rule, type RuleItem, type ComputeResult } from '../../api'
|
||||
import { StatusBadge } from '../../components/ui/StatusBadge'
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = { draft: '草稿', active: '生效中', archived: '已归档' }
|
||||
const STATUS_TONE: Record<string, string> = { draft: 'muted', active: 'success', archived: 'muted' }
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
quantity: '数量', quality: '质量', efficiency: '时效', compliance: '合规',
|
||||
}
|
||||
|
||||
export function RulesPage() {
|
||||
const { role } = useRole()
|
||||
const [rules, setRules] = useState<Rule[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [editing, setEditing] = useState<Rule | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [activatingId, setActivatingId] = useState<number | null>(null)
|
||||
const [previewRule, setPreviewRule] = useState<Rule | null>(null)
|
||||
const [computeResult, setComputeResult] = useState<ComputeResult | null>(null)
|
||||
const [computeLoading, setComputeLoading] = useState(false)
|
||||
const [selectedPeriod, setSelectedPeriod] = useState('2026-Q3')
|
||||
|
||||
const loadRules = useCallback(async () => {
|
||||
setLoading(true); setError('')
|
||||
try {
|
||||
const data = await api.rules.list(role)
|
||||
setRules(data)
|
||||
} catch (e: any) { setError(e.message) }
|
||||
finally { setLoading(false) }
|
||||
}, [role])
|
||||
|
||||
useEffect(() => { loadRules() }, [loadRules])
|
||||
|
||||
const handleAdd = () => { setEditing(null); setShowModal(true) }
|
||||
const handleEdit = async (r: Rule) => {
|
||||
const full = await api.rules.get(role, r.id)
|
||||
setEditing(full); setShowModal(true)
|
||||
}
|
||||
const handleActivate = async (r: Rule) => {
|
||||
if (!confirm(`确认激活「${r.name}」?激活后同周期的其他规则将自动归档。`)) return
|
||||
setActivatingId(r.id)
|
||||
try {
|
||||
await api.rules.activate(role, r.id)
|
||||
loadRules()
|
||||
} catch (e: any) { alert(e.message) }
|
||||
finally { setActivatingId(null) }
|
||||
}
|
||||
|
||||
const handleSave = async (form: RuleFormData) => {
|
||||
setSaving(true)
|
||||
try {
|
||||
if (editing) {
|
||||
const updated = await api.rules.update(role, editing.id, form)
|
||||
setRules(prev => prev.map(x => x.id === editing.id ? updated : x))
|
||||
} else {
|
||||
const created = await api.rules.create(role, form)
|
||||
setRules(prev => [created, ...prev])
|
||||
}
|
||||
setShowModal(false)
|
||||
} catch (e: any) { alert(e.message) }
|
||||
finally { setSaving(false) }
|
||||
}
|
||||
|
||||
const handlePreview = async (r: Rule) => {
|
||||
setPreviewRule(null); setComputeResult(null)
|
||||
const full = await api.rules.get(role, r.id)
|
||||
setPreviewRule(full)
|
||||
}
|
||||
|
||||
const handleCompute = async () => {
|
||||
if (!previewRule) return
|
||||
setComputeLoading(true)
|
||||
try {
|
||||
const result = await api.scores.compute(role, {
|
||||
rule_id: previewRule.id,
|
||||
period: selectedPeriod,
|
||||
period_type: previewRule.periodType,
|
||||
})
|
||||
setComputeResult(result)
|
||||
} catch (e: any) { alert(e.message) }
|
||||
finally { setComputeLoading(false) }
|
||||
}
|
||||
|
||||
const canManage = role === 'headquarters'
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-heading small">
|
||||
<div>
|
||||
<div className="page-heading-icon"><ScrollText size={20} /></div>
|
||||
<div>
|
||||
<h1>考核规则</h1>
|
||||
<span>配置考核指标、权重和计分公式。规则激活后生效。</span>
|
||||
</div>
|
||||
</div>
|
||||
{canManage && (
|
||||
<button className="primary-button" onClick={handleAdd}>
|
||||
<Plus size={18} /> 新建规则
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<section className="panel list-panel">
|
||||
{error && (
|
||||
<div className="error-banner">
|
||||
{error} <button onClick={loadRules}>重试</button>
|
||||
</div>
|
||||
)}
|
||||
{loading && (
|
||||
<div style={{ textAlign: 'center', padding: 32, color: 'var(--muted)' }}>加载中...</div>
|
||||
)}
|
||||
{!loading && !error && rules.length === 0 && (
|
||||
<div style={{ textAlign: 'center', padding: 32, color: 'var(--muted)' }}>暂无考核规则</div>
|
||||
)}
|
||||
{!loading && !error && rules.length > 0 && (
|
||||
<div className="table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>规则名称</th>
|
||||
<th>考核周期</th>
|
||||
<th>周期范围</th>
|
||||
<th>状态</th>
|
||||
<th>版本</th>
|
||||
<th>创建人</th>
|
||||
<th>创建时间</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rules.map(r => (
|
||||
<tr key={r.id}>
|
||||
<td data-label="规则名称">
|
||||
<strong>{r.name}</strong>
|
||||
{r.description && <small style={{ display: 'block', color: 'var(--muted)' }}>{r.description}</small>}
|
||||
</td>
|
||||
<td data-label="考核周期">{r.periodType === 'quarterly' ? '季度' : '自定义'}</td>
|
||||
<td data-label="周期范围">{r.periodStart || '—'} ~ {r.periodEnd || '—'}</td>
|
||||
<td data-label="状态">
|
||||
<span className={`status ${STATUS_TONE[r.status]}`}><i />{STATUS_LABELS[r.status]}</span>
|
||||
</td>
|
||||
<td data-label="版本">v{r.version}</td>
|
||||
<td data-label="创建人">{r.createdBy}</td>
|
||||
<td data-label="创建时间">{r.createdAt?.slice(0, 10) || '—'}</td>
|
||||
<td>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<button className="secondary-button small" onClick={() => handlePreview(r)} title="预览规则详情">
|
||||
<ChevronRight size={14} />
|
||||
</button>
|
||||
<button className="secondary-button small" onClick={() => handleEdit(r)}>
|
||||
编辑
|
||||
</button>
|
||||
{canManage && r.status === 'draft' && (
|
||||
<button
|
||||
className="primary-button small"
|
||||
onClick={() => handleActivate(r)}
|
||||
disabled={activatingId === r.id}
|
||||
>
|
||||
{activatingId === r.id ? <RefreshCw size={14} className="spin" /> : <Play size={14} />}
|
||||
激活
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{showModal && (
|
||||
<RuleModal
|
||||
rule={editing}
|
||||
role={role}
|
||||
saving={saving}
|
||||
onSave={handleSave}
|
||||
onClose={() => setShowModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{previewRule && (
|
||||
<RulePreviewDrawer
|
||||
rule={previewRule}
|
||||
selectedPeriod={selectedPeriod}
|
||||
onPeriodChange={setSelectedPeriod}
|
||||
computeResult={computeResult}
|
||||
computeLoading={computeLoading}
|
||||
onCompute={handleCompute}
|
||||
onClose={() => setPreviewRule(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type RuleFormData = {
|
||||
name: string
|
||||
description: string
|
||||
period_type: 'quarterly' | 'custom'
|
||||
period_start?: string
|
||||
period_end?: string
|
||||
items: RuleItem[]
|
||||
}
|
||||
|
||||
function RuleModal({
|
||||
rule, role, saving, onSave, onClose,
|
||||
}: {
|
||||
rule: Rule | null
|
||||
role: string
|
||||
saving: boolean
|
||||
onSave: (form: RuleFormData) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [form, setForm] = useState<RuleFormData>({
|
||||
name: rule?.name || '',
|
||||
description: rule?.description || '',
|
||||
period_type: rule?.periodType || 'quarterly',
|
||||
period_start: rule?.periodStart || '',
|
||||
period_end: rule?.periodEnd || '',
|
||||
items: rule?.items || [],
|
||||
})
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!form.name.trim()) { alert('规则名称为必填项'); return }
|
||||
onSave(form)
|
||||
}
|
||||
|
||||
const addItem = () => {
|
||||
setForm(f => ({
|
||||
...f,
|
||||
items: [...f.items, {
|
||||
category: 'quantity',
|
||||
name: '',
|
||||
metric_key: '',
|
||||
weight: 0.1,
|
||||
formulaType: 'count',
|
||||
formulaParams: {},
|
||||
}],
|
||||
}))
|
||||
}
|
||||
|
||||
const updateItem = (idx: number, field: string, value: any) => {
|
||||
setForm(f => ({
|
||||
...f,
|
||||
items: f.items.map((item, i) => i === idx ? { ...item, [field]: value } : item),
|
||||
}))
|
||||
}
|
||||
|
||||
const removeItem = (idx: number) => {
|
||||
setForm(f => ({ ...f, items: f.items.filter((_, i) => i !== idx) }))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-layer" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||
<div className="modal wide">
|
||||
<div className="modal-head">
|
||||
<h2>{rule ? '编辑规则' : '新建规则'}</h2>
|
||||
<button className="icon-button" onClick={onClose}><X size={20} /></button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-grid">
|
||||
<label style={{ gridColumn: 'span 2' }}>
|
||||
规则名称 <span className="required">*</span>
|
||||
<input
|
||||
value={form.name}
|
||||
onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
|
||||
placeholder="如:2026年第三季度考核规则"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
考核周期
|
||||
<select
|
||||
value={form.period_type}
|
||||
onChange={e => setForm(f => ({ ...f, period_type: e.target.value as any }))}
|
||||
>
|
||||
<option value="quarterly">季度</option>
|
||||
<option value="custom">自定义</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
状态
|
||||
<input value={rule ? STATUS_LABELS[rule.status] : '草稿'} disabled />
|
||||
</label>
|
||||
{form.period_type === 'custom' && (
|
||||
<>
|
||||
<label>
|
||||
开始日期
|
||||
<input
|
||||
type="date"
|
||||
value={form.period_start}
|
||||
onChange={e => setForm(f => ({ ...f, period_start: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
结束日期
|
||||
<input
|
||||
type="date"
|
||||
value={form.period_end}
|
||||
onChange={e => setForm(f => ({ ...f, period_end: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
<label style={{ gridColumn: 'span 2' }}>
|
||||
说明
|
||||
<input
|
||||
value={form.description}
|
||||
onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
|
||||
placeholder="简要描述考核范围和目标"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 24, padding: '0 24px 24px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
||||
<h3>指标项</h3>
|
||||
<button type="button" className="secondary-button small" onClick={addItem}>
|
||||
<Plus size={14} /> 添加指标
|
||||
</button>
|
||||
</div>
|
||||
{form.items.length === 0 && (
|
||||
<div style={{ color: 'var(--muted)', textAlign: 'center', padding: 24 }}>暂无指标项,点击添加</div>
|
||||
)}
|
||||
{form.items.map((item, idx) => (
|
||||
<div key={idx} className="rule-item-row">
|
||||
<select value={item.category} onChange={e => updateItem(idx, 'category', e.target.value)}>
|
||||
<option value="quantity">数量</option>
|
||||
<option value="quality">质量</option>
|
||||
<option value="efficiency">时效</option>
|
||||
<option value="compliance">合规</option>
|
||||
</select>
|
||||
<input
|
||||
placeholder="指标名称"
|
||||
value={item.name}
|
||||
onChange={e => updateItem(idx, 'name', e.target.value)}
|
||||
/>
|
||||
<input
|
||||
placeholder="权重(0-1)"
|
||||
type="number"
|
||||
step="0.01"
|
||||
style={{ width: 80 }}
|
||||
value={item.weight}
|
||||
onChange={e => updateItem(idx, 'weight', parseFloat(e.target.value) || 0)}
|
||||
/>
|
||||
<button type="button" className="icon-button" onClick={() => removeItem(idx)}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="secondary-button" onClick={onClose} disabled={saving}>取消</button>
|
||||
<button type="submit" className="primary-button" disabled={saving}>
|
||||
{saving ? '保存中...' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RulePreviewDrawer({
|
||||
rule, selectedPeriod, onPeriodChange, computeResult, computeLoading, onCompute, onClose,
|
||||
}: {
|
||||
rule: Rule
|
||||
selectedPeriod: string
|
||||
onPeriodChange: (p: string) => void
|
||||
computeResult: ComputeResult | null
|
||||
computeLoading: boolean
|
||||
onCompute: () => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const catStyle = (cat: string) => {
|
||||
const map: Record<string, string> = { quantity: 'info', quality: 'success', efficiency: 'warning', compliance: 'muted' }
|
||||
return map[cat] || 'muted'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-layer" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||
<div className="modal wide" style={{ maxWidth: 640 }}>
|
||||
<div className="modal-head">
|
||||
<h2>{rule.name} — 规则预览</h2>
|
||||
<button className="icon-button" onClick={onClose}><X size={20} /></button>
|
||||
</div>
|
||||
|
||||
{/* 试算工具栏 */}
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'center', padding: '14px 24px', borderBottom: '1px solid var(--line)', background: 'var(--soft)' }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
|
||||
<span style={{ color: 'var(--muted)' }}>试算周期</span>
|
||||
<select value={selectedPeriod} onChange={e => onPeriodChange(e.target.value)} style={{ height: 32, border: '1px solid var(--line)', borderRadius: 4, padding: '0 10px', fontSize: 12, background: 'white' }}>
|
||||
<option value="2026-Q1">2026-Q1</option>
|
||||
<option value="2026-Q2">2026-Q2</option>
|
||||
<option value="2026-Q3">2026-Q3</option>
|
||||
<option value="2026-Q4">2026-Q4</option>
|
||||
</select>
|
||||
</label>
|
||||
<button className="primary-button small" onClick={onCompute} disabled={computeLoading}>
|
||||
{computeLoading ? <RefreshCw size={14} className="spin" /> : <Calculator size={14} />}
|
||||
{computeLoading ? '计算中...' : '试算评分'}
|
||||
</button>
|
||||
<span style={{ fontSize: 11, color: 'var(--muted)', marginLeft: 'auto' }}>规则 ID: {rule.id} · v{rule.version} · {STATUS_LABELS[rule.status]}</span>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '16px 24px', overflowX: 'auto', minWidth: 0 }}>
|
||||
{/* 基本信息 */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '80px 1fr', gap: '6px 16px', fontSize: 13, marginBottom: 20 }}>
|
||||
<span style={{ color: 'var(--muted)' }}>考核周期</span>
|
||||
<span>{rule.periodType === 'quarterly' ? '季度' : '自定义'}</span>
|
||||
<span style={{ color: 'var(--muted)' }}>周期范围</span>
|
||||
<span>{rule.periodStart || '—'} ~ {rule.periodEnd || '—'}</span>
|
||||
<span style={{ color: 'var(--muted)' }}>说明</span>
|
||||
<span>{rule.description || '—'}</span>
|
||||
</div>
|
||||
|
||||
{/* 指标项 */}
|
||||
<h3 style={{ fontSize: 13, margin: '0 0 10px', color: 'var(--ink)' }}>指标项(共 {rule.items?.length || 0} 项)</h3>
|
||||
{(!rule.items || rule.items.length === 0) ? (
|
||||
<div style={{ color: 'var(--muted)', textAlign: 'center', padding: 24, fontSize: 13 }}>暂无指标项</div>
|
||||
) : (
|
||||
<table className="preview-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>类别</th>
|
||||
<th>指标名称</th>
|
||||
<th style={{ textAlign: 'right' }}>权重</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rule.items.map((item, idx) => (
|
||||
<tr key={idx}>
|
||||
<td><span className={`status ${catStyle(item.category)}`}>{CATEGORY_LABELS[item.category] || item.category}</span></td>
|
||||
<td>{item.name}</td>
|
||||
<td style={{ textAlign: 'right', fontFamily: 'Georgia,serif', fontWeight: 600 }}>{(item.weight * 100).toFixed(0)}%</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{/* 试算结果 */}
|
||||
{computeResult && (
|
||||
<>
|
||||
<h3 style={{ fontSize: 13, margin: '20px 0 10px', color: 'var(--ink)' }}>试算结果</h3>
|
||||
<div style={{ fontSize: 12, color: 'var(--muted)', marginBottom: 8 }}>{computeResult.message}</div>
|
||||
{computeResult.results.length === 0 ? (
|
||||
<div style={{ color: 'var(--muted)', textAlign: 'center', padding: 16, fontSize: 13 }}>无评分数据</div>
|
||||
) : (
|
||||
<table className="preview-table">
|
||||
<thead><tr><th>记者</th><th>站点</th><th style={{ textAlign: 'right' }}>总分</th></tr></thead>
|
||||
<tbody>
|
||||
{computeResult.results.map((r, idx) => (
|
||||
<tr key={idx}>
|
||||
<td><strong>{r.reporter}</strong></td>
|
||||
<td>{r.station}</td>
|
||||
<td style={{ textAlign: 'right', fontFamily: 'Georgia,serif', fontWeight: 700, color: 'var(--primary)' }}>{r.totalScore.toFixed(1)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="modal-actions">
|
||||
<button className="secondary-button" onClick={onClose}>关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { Play, X, BarChart3 } from 'lucide-react'
|
||||
import { useRole } from '../../context'
|
||||
import { api, type Score, type ComputeResult } from '../../api'
|
||||
|
||||
const CAT_LABELS: Record<string, string> = {
|
||||
quantity: '数量', quality: '质量', efficiency: '时效', compliance: '合规',
|
||||
}
|
||||
|
||||
export function ScoresPage() {
|
||||
const { role } = useRole()
|
||||
const [scores, setScores] = useState<Score[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [filterPeriod, setFilterPeriod] = useState('')
|
||||
const [filterStation, setFilterStation] = useState('')
|
||||
const [computing, setComputing] = useState(false)
|
||||
const [computeResult, setComputeResult] = useState<ComputeResult | null>(null)
|
||||
const [selectedScore, setSelectedScore] = useState<Score | null>(null)
|
||||
const [activeRules, setActiveRules] = useState<{ id: number; name: string }[]>([])
|
||||
|
||||
const loadScores = useCallback(async () => {
|
||||
setLoading(true); setError('')
|
||||
try {
|
||||
const params: any = {}
|
||||
if (filterPeriod) params.period = filterPeriod
|
||||
if (filterStation) params.station = filterStation
|
||||
const data = await api.scores.list(role, params)
|
||||
setScores(data)
|
||||
} catch (e: any) { setError(e.message) }
|
||||
finally { setLoading(false) }
|
||||
}, [role, filterPeriod, filterStation])
|
||||
|
||||
const loadActiveRules = useCallback(async () => {
|
||||
try {
|
||||
const rules = await api.rules.list(role, { status: 'active' })
|
||||
setActiveRules(rules.map(r => ({ id: r.id, name: r.name })))
|
||||
} catch {}
|
||||
}, [role])
|
||||
|
||||
useEffect(() => { loadScores() }, [loadScores])
|
||||
useEffect(() => { loadActiveRules() }, [loadActiveRules])
|
||||
|
||||
const handleCompute = async () => {
|
||||
if (activeRules.length === 0) { alert('当前无生效中的考核规则'); return }
|
||||
const rule = activeRules[0]
|
||||
if (!confirm(`使用「${rule.name}」触发评分计算?`)) return
|
||||
setComputing(true); setComputeResult(null)
|
||||
try {
|
||||
const result = await api.scores.compute(role, {
|
||||
rule_id: rule.id,
|
||||
period: filterPeriod || '2026-Q3',
|
||||
period_type: 'quarterly',
|
||||
})
|
||||
setComputeResult(result)
|
||||
loadScores()
|
||||
} catch (e: any) { alert(e.message) }
|
||||
finally { setComputing(false) }
|
||||
}
|
||||
|
||||
const avgScore = scores.length > 0
|
||||
? Math.round(scores.reduce((s, r) => s + r.totalScore, 0) / scores.length * 10) / 10
|
||||
: 0
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-heading small">
|
||||
<div>
|
||||
<div className="page-heading-icon"><BarChart3 size={20} /></div>
|
||||
<div>
|
||||
<h1>评分结果</h1>
|
||||
<span>
|
||||
{role === 'station'
|
||||
? '查看本站记者的考核评分明细。'
|
||||
: '查看全国记者考核评分,含各维度加权得分明细。'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{role === 'headquarters' && (
|
||||
<button className="primary-button" onClick={handleCompute} disabled={computing}>
|
||||
{computing ? <span className="spin"><Play size={16} /></span> : <Play size={16} />}
|
||||
{computing ? '计算中...' : '触发评分'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{computeResult && (
|
||||
<div className="result-banner">
|
||||
<strong>{computeResult.message}</strong>
|
||||
<button className="icon-button" onClick={() => setComputeResult(null)}><X size={16} /></button>
|
||||
<div style={{ marginTop: 8, fontSize: 13 }}>
|
||||
{computeResult.results.map((r, i) => (
|
||||
<span key={i} style={{ marginRight: 12 }}>
|
||||
{r.reporter}({r.station})<strong>{r.totalScore}分</strong>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="panel">
|
||||
{!loading && !error && scores.length > 0 && (
|
||||
<div className="metric-cards" style={{ padding: '16px 20px', borderBottom: '1px solid var(--border)' }}>
|
||||
<div className="metric-card" style={{ margin: 0 }}>
|
||||
<span className="metric-label">平均分</span>
|
||||
<span className="metric-value">{avgScore}</span>
|
||||
</div>
|
||||
<div className="metric-card" style={{ margin: 0 }}>
|
||||
<span className="metric-label">参评人数</span>
|
||||
<span className="metric-value">{scores.length}</span>
|
||||
</div>
|
||||
<div className="metric-card" style={{ margin: 0 }}>
|
||||
<span className="metric-label">最高分</span>
|
||||
<span className="metric-value">
|
||||
{Math.max(...scores.map(s => s.totalScore)).toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="metric-card" style={{ margin: 0 }}>
|
||||
<span className="metric-label">最低分</span>
|
||||
<span className="metric-value">
|
||||
{Math.min(...scores.map(s => s.totalScore)).toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="filters">
|
||||
<div className="search">
|
||||
<input
|
||||
placeholder="考核周期,如 2026-Q3"
|
||||
value={filterPeriod}
|
||||
onChange={e => setFilterPeriod(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{role === 'headquarters' && (
|
||||
<div className="search">
|
||||
<input
|
||||
placeholder="站点名称"
|
||||
value={filterStation}
|
||||
onChange={e => setFilterStation(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<span className="result-count">共 {scores.length} 条</span>
|
||||
<button className="secondary-button small" onClick={loadScores}>刷新</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="error-banner">{error} <button onClick={loadScores}>重试</button></div>
|
||||
)}
|
||||
{loading && (
|
||||
<div style={{ textAlign: 'center', padding: 32, color: 'var(--muted)' }}>加载中...</div>
|
||||
)}
|
||||
{!loading && !error && scores.length === 0 && (
|
||||
<div style={{ textAlign: 'center', padding: 32, color: 'var(--muted)' }}>
|
||||
暂无评分数据{role === 'headquarters' ? ',请点击「触发评分」开始计算' : ''}
|
||||
</div>
|
||||
)}
|
||||
{!loading && !error && scores.length > 0 && (
|
||||
<div className="table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>记者</th>
|
||||
<th>站点</th>
|
||||
<th>考核周期</th>
|
||||
<th>总分</th>
|
||||
<th>数量</th>
|
||||
<th>质量</th>
|
||||
<th>时效</th>
|
||||
<th>合规</th>
|
||||
<th>计算时间</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{scores.map(s => (
|
||||
<tr key={s.id}>
|
||||
<td data-label="记者"><strong>{s.reporter}</strong></td>
|
||||
<td data-label="站点">{s.station}</td>
|
||||
<td data-label="考核周期">{s.period}</td>
|
||||
<td data-label="总分"><strong style={{ color: 'var(--primary)' }}>{s.totalScore.toFixed(1)}</strong></td>
|
||||
<td data-label="数量">{s.quantityScore.toFixed(1)}</td>
|
||||
<td data-label="质量">{s.qualityScore.toFixed(1)}</td>
|
||||
<td data-label="时效">{s.efficiencyScore.toFixed(1)}</td>
|
||||
<td data-label="合规">{s.complianceScore.toFixed(1)}</td>
|
||||
<td data-label="计算时间">{s.computedAt?.slice(0, 16) || '—'}</td>
|
||||
<td>
|
||||
<button className="secondary-button small" onClick={() => setSelectedScore(s)}>
|
||||
明细
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{selectedScore && (
|
||||
<ScoreDetailModal score={selectedScore} onClose={() => setSelectedScore(null)} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ScoreDetailModal({ score, onClose }: { score: Score; onClose: () => void }) {
|
||||
return (
|
||||
<div className="modal-layer" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||
<div className="modal wide">
|
||||
<div className="modal-head">
|
||||
<div>
|
||||
<h2 style={{ margin: 0 }}>{score.reporter} — {score.period} 评分明细</h2>
|
||||
</div>
|
||||
<button className="icon-button" onClick={onClose}><X size={18} /></button>
|
||||
</div>
|
||||
<div className="score-summary">
|
||||
<div className="metric-card">
|
||||
<span className="metric-label">总分</span>
|
||||
<span className="metric-value" style={{ color: 'var(--primary)' }}>{score.totalScore.toFixed(1)}</span>
|
||||
</div>
|
||||
{Object.entries({
|
||||
'数量': score.quantityScore,
|
||||
'质量': score.qualityScore,
|
||||
'时效': score.efficiencyScore,
|
||||
'合规': score.complianceScore,
|
||||
}).map(([cat, val]) => (
|
||||
<div className="metric-card" key={cat}>
|
||||
<span className="metric-label">{cat}</span>
|
||||
<span className="metric-value">{val.toFixed(1)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ padding: '0 24px' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>指标名称</th>
|
||||
<th>类别</th>
|
||||
<th>原始值</th>
|
||||
<th>原始得分</th>
|
||||
<th>权重</th>
|
||||
<th>加权得分</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{score.items.map((item, i) => (
|
||||
<tr key={i}>
|
||||
<td>{item.name}</td>
|
||||
<td>{CAT_LABELS[item.category] || item.category}</td>
|
||||
<td>{item.metric_value === null ? '—' : item.metric_value}</td>
|
||||
<td>{item.raw_score.toFixed(1)}</td>
|
||||
<td>{(item.weight * 100).toFixed(0)}%</td>
|
||||
<td><strong>{item.weighted_score.toFixed(2)}</strong></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button className="secondary-button" onClick={onClose}>关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { Settings } from 'lucide-react'
|
||||
import { api, type Person, type MeSummary } from '../../api'
|
||||
import { useRole } from '../../context'
|
||||
|
||||
export function SettingsPage() {
|
||||
const { role, switchRole } = useRole()
|
||||
const [currentPerson, setCurrentPerson] = useState<Person | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
const [meSummary, setMeSummary] = useState<MeSummary | null>(null)
|
||||
const [summaryLoading, setSummaryLoading] = useState(false)
|
||||
|
||||
const currentUserName = role === 'headquarters' ? '林致远'
|
||||
: role === 'station' ? '苏明远' : '林晓'
|
||||
const currentStation = role === 'headquarters' ? '总部' : '北京记者站'
|
||||
|
||||
const loadProfile = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const people = await api.people.list(role)
|
||||
const me = people.find(p => p.name === currentUserName && p.station === currentStation)
|
||||
setCurrentPerson(me || null)
|
||||
} catch {}
|
||||
finally { setLoading(false) }
|
||||
}, [role, currentUserName, currentStation])
|
||||
|
||||
const loadMeSummary = useCallback(async () => {
|
||||
setSummaryLoading(true)
|
||||
try {
|
||||
const data = await api.me.summary(role)
|
||||
setMeSummary(data)
|
||||
} catch {}
|
||||
finally { setSummaryLoading(false) }
|
||||
}, [role])
|
||||
|
||||
useEffect(() => { loadProfile() }, [loadProfile])
|
||||
useEffect(() => { loadMeSummary() }, [loadMeSummary])
|
||||
|
||||
const handleSave = async (form: { phone?: string; title?: string }) => {
|
||||
if (!currentPerson) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const updated = await api.people.update(role, currentPerson.id, form)
|
||||
setCurrentPerson(updated)
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 3000)
|
||||
} catch (e: any) { alert(e.message) }
|
||||
finally { setSaving(false) }
|
||||
}
|
||||
|
||||
const roleLabel = role === 'headquarters' ? '总部管理员' : role === 'station' ? '分站负责人' : '记者'
|
||||
const roleColors: Record<string, string> = {
|
||||
headquarters: 'var(--info)',
|
||||
station: 'var(--warning)',
|
||||
reporter: 'var(--success)',
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-heading small">
|
||||
<div>
|
||||
<div className="page-heading-icon"><Settings size={20} /></div>
|
||||
<div>
|
||||
<h1>个人中心</h1>
|
||||
<span>查看和修改个人资料、账号设置。</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="panel" style={{ padding: 24 }}>
|
||||
<div className="panel-header">
|
||||
<div>
|
||||
<h2>账号信息</h2>
|
||||
<p>查看和修改当前账号的基本信息</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && <div style={{ color: 'var(--muted)', marginTop: 16 }}>加载中...</div>}
|
||||
{!loading && (
|
||||
<div className="profile-content">
|
||||
<div className="profile-avatar">
|
||||
<div className="avatar large">
|
||||
{(currentPerson?.name || currentUserName)[0]}
|
||||
</div>
|
||||
<div className="profile-info">
|
||||
<h3>{currentPerson?.name || currentUserName}</h3>
|
||||
<p style={{ color: roleColors[role], fontSize: 13 }}>{roleLabel}</p>
|
||||
<p style={{ color: 'var(--muted)', fontSize: 13 }}>{currentStation}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table className="profile-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>姓名</th>
|
||||
<td>{currentPerson?.name || currentUserName}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>人员编码</th>
|
||||
<td>{currentPerson?.code || '—'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>所属站点</th>
|
||||
<td>{currentStation}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>职务</th>
|
||||
<td>{currentPerson?.title || '—'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>手机号</th>
|
||||
<td>{currentPerson?.phone || '未设置'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>入站时间</th>
|
||||
<td>{currentPerson?.joinedAt || '—'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>账号状态</th>
|
||||
<td>
|
||||
<span className={`status ${currentPerson?.status === 'active' ? 'success' : 'danger'}`}>
|
||||
<i />
|
||||
{currentPerson?.status === 'active' ? '正常' : '停用'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{currentPerson && (
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<h3 style={{ fontSize: 15, marginBottom: 12 }}>编辑个人资料</h3>
|
||||
<ProfileForm person={currentPerson} saving={saving} saved={saved} onSave={handleSave} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{role !== 'headquarters' && (
|
||||
<div style={{ marginTop: 32, paddingTop: 24, borderTop: '1px solid var(--border)' }}>
|
||||
<h3 style={{ fontSize: 15, marginBottom: 12 }}>考核数据</h3>
|
||||
{summaryLoading && <div style={{ color: 'var(--muted)', padding: '16px 0' }}>加载中...</div>}
|
||||
{!summaryLoading && meSummary && (
|
||||
meSummary.score ? (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))', gap: 12 }}>
|
||||
<ScoreCard label="总分" value={meSummary.score.totalScore.toFixed(1)} />
|
||||
<ScoreCard label="质量分" value={meSummary.score.quality_score.toFixed(1)} />
|
||||
<ScoreCard label="数量分" value={meSummary.score.quantity_score.toFixed(1)} />
|
||||
<ScoreCard label="时效分" value={meSummary.score.efficiency_score.toFixed(1)} />
|
||||
<ScoreCard label="合规分" value={meSummary.score.compliance_score.toFixed(1)} />
|
||||
{meSummary.rank !== null && meSummary.rank > 0 && (
|
||||
<ScoreCard label="排名" value={`${meSummary.rank} / ${meSummary.total}`} highlight />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ color: 'var(--muted)', padding: '16px 0', fontSize: 13 }}>
|
||||
暂无考核数据({meSummary.period})
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{!summaryLoading && !meSummary && (
|
||||
<div style={{ color: 'var(--muted)', padding: '16px 0', fontSize: 13 }}>加载失败</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: 32, paddingTop: 24, borderTop: '1px solid var(--border)' }}>
|
||||
<h3 style={{ fontSize: 15, marginBottom: 12 }}>角色切换(演示用)</h3>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
{(['headquarters', 'station', 'reporter'] as const).map(r => (
|
||||
<button
|
||||
key={r}
|
||||
className={role === r ? 'primary-button' : 'secondary-button'}
|
||||
onClick={() => switchRole(r)}
|
||||
>
|
||||
{r === 'headquarters' ? '总部管理员' : r === 'station' ? '分站负责人' : '记者'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p style={{ color: 'var(--muted)', fontSize: 12, marginTop: 8 }}>
|
||||
当前为演示环境,角色切换即时生效,用于体验不同权限视角。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ProfileForm({
|
||||
person, saving, saved, onSave,
|
||||
}: {
|
||||
person: Person
|
||||
saving: boolean
|
||||
saved: boolean
|
||||
onSave: (f: any) => void
|
||||
}) {
|
||||
const [form, setForm] = useState({
|
||||
phone: person.phone || '',
|
||||
title: person.title || '',
|
||||
})
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
onSave(form)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'flex-end' }}>
|
||||
<label style={{ flex: 1, minWidth: 180 }}>
|
||||
手机号
|
||||
<input
|
||||
value={form.phone}
|
||||
onChange={e => setForm(f => ({ ...f, phone: e.target.value }))}
|
||||
placeholder="输入手机号"
|
||||
/>
|
||||
</label>
|
||||
<label style={{ flex: 1, minWidth: 180 }}>
|
||||
职务
|
||||
<input
|
||||
value={form.title}
|
||||
onChange={e => setForm(f => ({ ...f, title: e.target.value }))}
|
||||
placeholder="输入职务"
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" className="primary-button" disabled={saving}>
|
||||
{saving ? '保存中...' : '保存修改'}
|
||||
</button>
|
||||
{saved && <span style={{ color: 'var(--success)', fontSize: 13, lineHeight: '36px' }}>保存成功</span>}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function ScoreCard({ label, value, highlight }: { label: string; value: string; highlight?: boolean }) {
|
||||
return (
|
||||
<div style={{
|
||||
background: highlight ? 'var(--primary-bg)' : 'var(--surface)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 8,
|
||||
padding: '12px 16px',
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
<div style={{ fontSize: 11, color: 'var(--muted)', marginBottom: 4 }}>{label}</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 600, color: highlight ? 'var(--primary)' : 'var(--text)' }}>{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { MapPin, X, Building2, Users, FilePenLine, TrendingUp } from 'lucide-react'
|
||||
import L from 'leaflet'
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
import { useRole } from '../../context'
|
||||
import { api } from '../../api'
|
||||
import type { Station, StatsRecordRow } from '../../api'
|
||||
|
||||
/** 37 个记者站坐标数据(经纬度) */
|
||||
const STATION_COORDS: Record<string, { lng: number; lat: number }> = {
|
||||
'北京记者站': { lng: 116.4074, lat: 39.9042 },
|
||||
'上海记者站': { lng: 121.4737, lat: 31.2304 },
|
||||
'广东记者站': { lng: 113.2644, lat: 23.1291 },
|
||||
'浙江记者站': { lng: 120.1551, lat: 30.2741 },
|
||||
'江苏记者站': { lng: 118.7969, lat: 32.0603 },
|
||||
'四川记者站': { lng: 104.0668, lat: 30.5728 },
|
||||
'湖北记者站': { lng: 114.3055, lat: 30.5928 },
|
||||
'湖南记者站': { lng: 112.9388, lat: 28.2282 },
|
||||
'山东记者站': { lng: 117.0009, lat: 36.6758 },
|
||||
'河南记者站': { lng: 113.6254, lat: 34.7466 },
|
||||
'河北记者站': { lng: 114.5149, lat: 38.0428 },
|
||||
'福建记者站': { lng: 119.2965, lat: 26.0745 },
|
||||
'安徽记者站': { lng: 117.2849, lat: 31.8612 },
|
||||
'江西记者站': { lng: 115.8581, lat: 28.6832 },
|
||||
'辽宁记者站': { lng: 123.4290, lat: 41.7968 },
|
||||
'吉林记者站': { lng: 125.3245, lat: 43.8868 },
|
||||
'黑龙江记者站': { lng: 126.5340, lat: 45.8038 },
|
||||
'山西记者站': { lng: 112.5489, lat: 37.8706 },
|
||||
'陕西记者站': { lng: 108.9398, lat: 34.3416 },
|
||||
'甘肃记者站': { lng: 103.8343, lat: 36.0611 },
|
||||
'青海记者站': { lng: 101.7782, lat: 36.6171 },
|
||||
'云南记者站': { lng: 102.8329, lat: 24.8801 },
|
||||
'贵州记者站': { lng: 106.7135, lat: 26.5783 },
|
||||
'广西记者站': { lng: 108.3200, lat: 22.8240 },
|
||||
'海南记者站': { lng: 110.3312, lat: 20.0317 },
|
||||
'内蒙古记者站': { lng: 111.7519, lat: 40.8414 },
|
||||
'新疆记者站': { lng: 87.6168, lat: 43.8256 },
|
||||
'西藏记者站': { lng: 91.1322, lat: 29.6604 },
|
||||
'宁夏记者站': { lng: 106.2309, lat: 38.4872 },
|
||||
'重庆记者站': { lng: 106.5516, lat: 29.5630 },
|
||||
'天津记者站': { lng: 117.1901, lat: 39.1252 },
|
||||
'深圳记者站': { lng: 114.0579, lat: 22.5431 },
|
||||
'青岛记者站': { lng: 120.3826, lat: 36.0671 },
|
||||
'大连记者站': { lng: 121.6147, lat: 38.9140 },
|
||||
'厦门记者站': { lng: 118.0894, lat: 24.4798 },
|
||||
'宁波记者站': { lng: 121.5497, lat: 29.8683 },
|
||||
'武汉记者站': { lng: 114.3055, lat: 30.5928 },
|
||||
}
|
||||
|
||||
|
||||
interface StationDetail {
|
||||
station: Station
|
||||
stats?: StatsRecordRow
|
||||
recordCount: number
|
||||
archivedCount: number
|
||||
}
|
||||
|
||||
export function StationMapPage() {
|
||||
const { role } = useRole()
|
||||
const mapContainerRef = useRef<HTMLDivElement>(null)
|
||||
const mapRef = useRef<any>(null)
|
||||
const markersRef = useRef<any[]>([])
|
||||
const [stations, setStations] = useState<Station[]>([])
|
||||
const [statsByStation, setStatsByStation] = useState<Map<string, StatsRecordRow>>(new Map())
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [selectedStation, setSelectedStation] = useState<StationDetail | null>(null)
|
||||
const [mapReady, setMapReady] = useState(false)
|
||||
|
||||
/** 加载站点数据 */
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
setLoading(true)
|
||||
Promise.all([
|
||||
api.stations.list(role).catch(() => [] as Station[]),
|
||||
api.stats.records(role, 'station').catch(() => [] as StatsRecordRow[]),
|
||||
]).then(([stationList, statsRows]) => {
|
||||
if (!active) return
|
||||
setStations(stationList)
|
||||
const statsMap = new Map<string, StatsRecordRow>()
|
||||
statsRows.forEach(row => statsMap.set(row.name, row))
|
||||
setStatsByStation(statsMap)
|
||||
}).finally(() => { if (active) setLoading(false) })
|
||||
return () => { active = false }
|
||||
}, [role])
|
||||
|
||||
/** 初始化 Leaflet 地图(使用高德瓦片) */
|
||||
useEffect(() => {
|
||||
if (!mapContainerRef.current || mapRef.current) return
|
||||
|
||||
const map = L.map(mapContainerRef.current, {
|
||||
center: [36, 105],
|
||||
zoom: 4,
|
||||
zoomControl: true,
|
||||
attributionControl: false,
|
||||
})
|
||||
|
||||
// 高德地图瓦片图层(无需 API Key)
|
||||
L.tileLayer('https://webrd0{s}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x={x}&y={y}&z={z}', {
|
||||
subdomains: ['1', '2', '3', '4'],
|
||||
maxZoom: 18,
|
||||
}).addTo(map)
|
||||
|
||||
mapRef.current = map
|
||||
setMapReady(true)
|
||||
|
||||
return () => {
|
||||
map.remove()
|
||||
mapRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
/** 在地图上添加站点标记 */
|
||||
useEffect(() => {
|
||||
if (!mapReady || !mapRef.current || stations.length === 0) return
|
||||
|
||||
// 清除旧标记
|
||||
markersRef.current.forEach(m => mapRef.current.removeLayer(m))
|
||||
markersRef.current = []
|
||||
|
||||
stations.forEach(station => {
|
||||
const coords = STATION_COORDS[station.name]
|
||||
if (!coords) return
|
||||
|
||||
const stats = statsByStation.get(station.name)
|
||||
const recordCount = stats?.total ?? 0
|
||||
const archivedCount = stats?.archived ?? 0
|
||||
|
||||
// 根据记录数量确定标记颜色
|
||||
const markerColor = recordCount > 150 ? '#b42318' : recordCount > 80 ? '#f0a020' : '#4ba66a'
|
||||
|
||||
// 创建自定义标记图标
|
||||
const icon = L.divIcon({
|
||||
className: 'station-marker',
|
||||
html: `<div style="display:flex;flex-direction:column;align-items:center;cursor:pointer;transition:transform 0.15s">
|
||||
<div style="width:28px;height:28px;border-radius:50%;background:${markerColor};border:2px solid white;box-shadow:0 2px 6px rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;color:white;font-size:11px;font-weight:700">${recordCount}</div>
|
||||
<div style="font-size:11px;color:#333;background:rgba(255,255,255,0.9);padding:1px 6px;border-radius:3px;margin-top:2px;white-space:nowrap;box-shadow:0 1px 3px rgba(0,0,0,0.1)">${station.name.replace('记者站', '')}</div>
|
||||
</div>`,
|
||||
iconSize: [28, 28],
|
||||
iconAnchor: [14, 14],
|
||||
})
|
||||
|
||||
const marker = L.marker([coords.lat, coords.lng], { icon }).addTo(mapRef.current)
|
||||
|
||||
marker.on('click', () => {
|
||||
setSelectedStation({
|
||||
station,
|
||||
stats,
|
||||
recordCount,
|
||||
archivedCount,
|
||||
})
|
||||
})
|
||||
|
||||
markersRef.current.push(marker)
|
||||
})
|
||||
}, [mapReady, stations, statsByStation])
|
||||
|
||||
/** 关闭详情面板 */
|
||||
const closeDetail = useCallback(() => setSelectedStation(null), [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-heading small">
|
||||
<div>
|
||||
<div className="page-heading-icon"><MapPin size={20} /></div>
|
||||
<div>
|
||||
<h1>全国记者站地图</h1>
|
||||
<span>全国 37 个记者站地理分布与运行概况</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="station-map-container" style={{
|
||||
display: 'flex', gap: 14, alignItems: 'flex-start',
|
||||
}}>
|
||||
{/* 地图主体 */}
|
||||
<div className="map-canvas-wrap" style={{
|
||||
flex: 1, position: 'relative', borderRadius: 8,
|
||||
overflow: 'hidden', border: '1px solid var(--line)',
|
||||
minHeight: 520,
|
||||
}}>
|
||||
<div
|
||||
ref={mapContainerRef}
|
||||
style={{ width: '100%', height: '520px' }}
|
||||
/>
|
||||
{loading && (
|
||||
<div style={{
|
||||
position: 'absolute', top: 0, left: 0, right: 0, bottom: 0,
|
||||
display: 'grid', placeItems: 'center', background: 'rgba(255,255,255,0.8)',
|
||||
}}>
|
||||
<span style={{ color: 'var(--muted)', fontSize: 13 }}>加载中…</span>
|
||||
</div>
|
||||
)}
|
||||
{/* 图例 */}
|
||||
<div style={{
|
||||
position: 'absolute', bottom: 12, left: 12,
|
||||
background: 'rgba(255,255,255,0.95)', borderRadius: 6,
|
||||
padding: '8px 12px', fontSize: 11, boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
|
||||
display: 'flex', flexDirection: 'column', gap: 4,
|
||||
}}>
|
||||
<strong style={{ fontSize: 12, marginBottom: 2 }}>记录数量</strong>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span style={{ width: 12, height: 12, borderRadius: '50%', background: '#b42318' }} />
|
||||
<span>150+ 条</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span style={{ width: 12, height: 12, borderRadius: '50%', background: '#f0a020' }} />
|
||||
<span>80-150 条</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span style={{ width: 12, height: 12, borderRadius: '50%', background: '#4ba66a' }} />
|
||||
<span><80 条</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 站点详情侧边面板 */}
|
||||
{selectedStation && (
|
||||
<div className="station-detail-panel" style={{
|
||||
width: 300, background: 'white', border: '1px solid var(--line)',
|
||||
borderRadius: 8, padding: 20, flex: 'none',
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 14 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Building2 size={20} />
|
||||
<strong style={{ fontSize: 15 }}>{selectedStation.station.name}</strong>
|
||||
</div>
|
||||
<button className="icon-button" onClick={closeDetail} aria-label="关闭">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<div style={{ display: 'flex', gap: 16, fontSize: 12, color: 'var(--muted)' }}>
|
||||
<span>编码:{selectedStation.station.code}</span>
|
||||
<span>地区:{selectedStation.station.region || '—'}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 16, fontSize: 12, color: 'var(--muted)' }}>
|
||||
<span>负责人:{selectedStation.station.leader || '—'}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 16, fontSize: 12, color: 'var(--muted)' }}>
|
||||
<span>地址:{selectedStation.station.address || '—'}</span>
|
||||
</div>
|
||||
|
||||
<div style={{ borderTop: '1px solid var(--line)', margin: '6px 0', paddingTop: 12 }}>
|
||||
<strong style={{ fontSize: 12, color: 'var(--muted)', display: 'block', marginBottom: 8 }}>运行数据</strong>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
|
||||
<div style={{ background: 'var(--soft)', borderRadius: 6, padding: '10px 12px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, color: 'var(--muted)' }}>
|
||||
<FilePenLine size={13} /> 总记录
|
||||
</div>
|
||||
<strong style={{ fontSize: 20, fontFamily: 'Georgia,serif' }}>{selectedStation.recordCount}</strong>
|
||||
</div>
|
||||
<div style={{ background: 'var(--soft)', borderRadius: 6, padding: '10px 12px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, color: 'var(--muted)' }}>
|
||||
<MapPin size={13} /> 已归档
|
||||
</div>
|
||||
<strong style={{ fontSize: 20, fontFamily: 'Georgia,serif' }}>{selectedStation.archivedCount}</strong>
|
||||
</div>
|
||||
<div style={{ background: 'var(--soft)', borderRadius: 6, padding: '10px 12px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, color: 'var(--muted)' }}>
|
||||
<TrendingUp size={13} /> 平均分
|
||||
</div>
|
||||
<strong style={{ fontSize: 20, fontFamily: 'Georgia,serif' }}>
|
||||
{selectedStation.stats?.avgScore != null ? selectedStation.stats.avgScore.toFixed(1) : '—'}
|
||||
</strong>
|
||||
</div>
|
||||
<div style={{ background: 'var(--soft)', borderRadius: 6, padding: '10px 12px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, color: 'var(--muted)' }}>
|
||||
<Users size={13} /> 退回数
|
||||
</div>
|
||||
<strong style={{ fontSize: 20, fontFamily: 'Georgia,serif' }}>
|
||||
{selectedStation.stats?.returned ?? 0}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
background: selectedStation.station.status === 'active' ? '#e8f5ed' : '#fdeae8',
|
||||
color: selectedStation.station.status === 'active' ? '#2d7a4a' : '#a72d23',
|
||||
borderRadius: 4, padding: '6px 10px', fontSize: 12, fontWeight: 600,
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
{selectedStation.station.status === 'active' ? '● 运行中' : '● 已停用'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 站点列表(地图下方) */}
|
||||
<section className="panel" style={{ marginTop: 14, padding: '16px 20px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||
<MapPin size={18} />
|
||||
<h2 style={{ fontSize: 14, margin: 0 }}>站点列表</h2>
|
||||
<span style={{ fontSize: 11, color: 'var(--muted)' }}>共 {stations.length} 个站点</span>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: 10 }}>
|
||||
{stations.map(s => {
|
||||
const stats = statsByStation.get(s.name)
|
||||
const count = stats?.total ?? 0
|
||||
const color = count > 150 ? '#b42318' : count > 80 ? '#f0a020' : '#4ba66a'
|
||||
return (
|
||||
<div
|
||||
key={s.id}
|
||||
onClick={() => {
|
||||
const coords = STATION_COORDS[s.name]
|
||||
if (coords && mapRef.current) {
|
||||
mapRef.current.setView([coords.lat, coords.lng], 6)
|
||||
}
|
||||
setSelectedStation({
|
||||
station: s,
|
||||
stats,
|
||||
recordCount: count,
|
||||
archivedCount: stats?.archived ?? 0,
|
||||
})
|
||||
}}
|
||||
style={{
|
||||
background: 'var(--soft)', borderRadius: 6, padding: '10px 14px',
|
||||
cursor: 'pointer', transition: '0.12s',
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget as HTMLDivElement).style.boxShadow = '0 2px 8px rgba(0,0,0,0.08)'}
|
||||
onMouseLeave={e => (e.currentTarget as HTMLDivElement).style.boxShadow = 'none'}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<strong style={{ fontSize: 13 }}>{s.name}</strong>
|
||||
<span style={{
|
||||
width: 8, height: 8, borderRadius: '50%', background: color,
|
||||
}} />
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--muted)', marginTop: 4 }}>
|
||||
{s.region || '—'} · {count} 条记录
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { Building2, Eye, Pencil, Plus, Search, Trash2, TrendingUp, Users, X } from 'lucide-react'
|
||||
import { useRole, useToast } from '../../context'
|
||||
import { api, type Station } from '../../api'
|
||||
import { ConfirmDialog } from '../../components/ui/ConfirmDialog'
|
||||
import { StationDrawer } from '../../modals/StationDrawer'
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = { active: '在运', inactive: '停运' }
|
||||
const STATUS_TONE: Record<string, string> = { active: 'success', inactive: 'danger' }
|
||||
|
||||
export function StationsPage() {
|
||||
const { role } = useRole()
|
||||
const { showToast } = useToast()
|
||||
const [stations, setStations] = useState<Station[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [search, setSearch] = useState('')
|
||||
const [filterRegion, setFilterRegion] = useState('')
|
||||
const [filterStatus, setFilterStatus] = useState('')
|
||||
const [totalPeople, setTotalPeople] = useState(0)
|
||||
|
||||
// 详情抽屉
|
||||
const [detailStation, setDetailStation] = useState<Station | null>(null)
|
||||
|
||||
// 编辑弹窗
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [editing, setEditing] = useState<Station | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
// 删除确认
|
||||
const [confirmDelete, setConfirmDelete] = useState<{ station: Station; loading: boolean } | null>(null)
|
||||
|
||||
const regions = [...new Set(stations.map(s => s.region).filter(Boolean))]
|
||||
|
||||
const displayed = stations.filter(s => {
|
||||
if (filterRegion && s.region !== filterRegion) return false
|
||||
if (filterStatus && s.status !== filterStatus) return false
|
||||
if (search) {
|
||||
const q = search.toLowerCase()
|
||||
return s.name.toLowerCase().includes(q) || s.code.toLowerCase().includes(q)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
const loadStations = useCallback(async () => {
|
||||
setLoading(true); setError('')
|
||||
try {
|
||||
const data = await api.stations.list(role, {
|
||||
...(filterStatus ? { status: filterStatus } : {}),
|
||||
...(filterRegion ? { region: filterRegion } : {}),
|
||||
})
|
||||
setStations(data)
|
||||
} catch (e: any) { setError(e.message) }
|
||||
finally { setLoading(false) }
|
||||
}, [role, filterStatus, filterRegion])
|
||||
|
||||
const loadPeopleCount = useCallback(async () => {
|
||||
try {
|
||||
const people = await api.people.list(role)
|
||||
setTotalPeople(people.length)
|
||||
} catch {}
|
||||
}, [role])
|
||||
|
||||
useEffect(() => { loadStations() }, [loadStations])
|
||||
useEffect(() => { loadPeopleCount() }, [loadPeopleCount])
|
||||
|
||||
const handleView = (s: Station) => setDetailStation(s)
|
||||
const handleEdit = (s: Station) => { setEditing(s); setShowModal(true) }
|
||||
const handleAdd = () => { setEditing(null); setShowModal(true) }
|
||||
const handleCloseModal = () => { setShowModal(false); setEditing(null) }
|
||||
|
||||
const handleSave = async (form: {
|
||||
name: string; code: string; region: string; address: string;
|
||||
leader: string; phone: string; establishedAt: string; status: 'active' | 'inactive'
|
||||
}) => {
|
||||
setSaving(true)
|
||||
try {
|
||||
if (editing) {
|
||||
const updated = await api.stations.update(role, editing.id, form)
|
||||
setStations(prev => prev.map(x => x.id === editing.id ? updated : x))
|
||||
} else {
|
||||
const created = await api.stations.create(role, form)
|
||||
setStations(prev => [...prev, created])
|
||||
}
|
||||
handleCloseModal()
|
||||
} catch (e: any) { showToast(e.message) }
|
||||
finally { setSaving(false) }
|
||||
}
|
||||
|
||||
const handleDeleteClick = (s: Station) => setConfirmDelete({ station: s, loading: false })
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!confirmDelete) return
|
||||
setConfirmDelete(prev => prev ? { ...prev, loading: true } : null)
|
||||
try {
|
||||
await api.stations.delete(role, confirmDelete.station.id)
|
||||
setStations(prev => prev.filter(x => x.id !== confirmDelete!.station.id))
|
||||
setConfirmDelete(null)
|
||||
} catch (e: any) { showToast(e.message); setConfirmDelete(null) }
|
||||
}
|
||||
|
||||
const canManage = role === 'headquarters'
|
||||
const totalActive = stations.filter(s => s.status === 'active').length
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-heading small">
|
||||
<div>
|
||||
<div className="page-heading-icon"><Building2 size={20} /></div>
|
||||
<div>
|
||||
<h1>记者站管理</h1>
|
||||
<span>统一维护全国记者站的组织信息与运行状态。</span>
|
||||
</div>
|
||||
</div>
|
||||
{canManage && (
|
||||
<button className="primary-button" onClick={handleAdd}>
|
||||
<Plus size={18} /> 新增站点
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="station-overview">
|
||||
<span><Building2 size={20} /><b>{totalActive}</b>个在运站点</span>
|
||||
<span><Users size={20} /><b>{totalPeople}</b>名在职人员</span>
|
||||
<span><TrendingUp size={20} /><b>{stations.length}</b>个站点</span>
|
||||
</div>
|
||||
|
||||
<section className="panel list-panel">
|
||||
<div className="filters">
|
||||
<div className="search">
|
||||
<Search size={17} />
|
||||
<input
|
||||
placeholder="搜索站点名称或编码"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<select value={filterRegion} onChange={e => setFilterRegion(e.target.value)}>
|
||||
<option value="">全部地区</option>
|
||||
{regions.map(r => <option key={r!} value={r!}>{r}</option>)}
|
||||
</select>
|
||||
<select value={filterStatus} onChange={e => setFilterStatus(e.target.value)}>
|
||||
<option value="">全部状态</option>
|
||||
<option value="active">在运</option>
|
||||
<option value="inactive">停运</option>
|
||||
</select>
|
||||
<span className="result-count">共 {displayed.length} 个站点</span>
|
||||
</div>
|
||||
|
||||
{/* 卡片网格 */}
|
||||
{loading && (
|
||||
<div style={{ textAlign: 'center', padding: 40, color: 'var(--muted)' }}>加载中...</div>
|
||||
)}
|
||||
{error && (
|
||||
<div style={{ textAlign: 'center', padding: 40, color: 'var(--red)' }}>
|
||||
{error} <button onClick={loadStations} style={{ color: 'var(--red)', background: 'none', border: 'none', cursor: 'pointer' }}>重试</button>
|
||||
</div>
|
||||
)}
|
||||
{!loading && !error && (
|
||||
<div className="station-grid">
|
||||
{displayed.length === 0 && (
|
||||
<div style={{ gridColumn: '1/-1', textAlign: 'center', padding: 40, color: 'var(--muted)' }}>
|
||||
暂无站点数据
|
||||
</div>
|
||||
)}
|
||||
{displayed.map(s => (
|
||||
<article className="station-card" key={s.id} onClick={() => handleView(s)}>
|
||||
<div className="station-card-head">
|
||||
<span className="station-symbol"><Building2 size={21} /></span>
|
||||
<span className={`status ${STATUS_TONE[s.status]}`}>
|
||||
<i />{STATUS_LABELS[s.status]}
|
||||
</span>
|
||||
</div>
|
||||
<h3>{s.name}</h3>
|
||||
<p>{s.region || '—'}地区{s.leader ? ` · 负责人 ${s.leader}` : ''}</p>
|
||||
<div className="station-stats">
|
||||
<span><b>{s.phone || '—'}</b>联系电话</span>
|
||||
<span><b>{s.address || '—'}</b>地址</span>
|
||||
</div>
|
||||
{canManage && (
|
||||
<div className="station-actions" onClick={e => e.stopPropagation()}>
|
||||
<button
|
||||
className="icon-button"
|
||||
title="查看详情"
|
||||
onClick={() => handleView(s)}
|
||||
>
|
||||
<Eye size={15} />
|
||||
</button>
|
||||
<button
|
||||
className="icon-button"
|
||||
title="编辑"
|
||||
onClick={() => handleEdit(s)}
|
||||
>
|
||||
<Pencil size={15} />
|
||||
</button>
|
||||
<button
|
||||
className="icon-button danger-icon"
|
||||
title="删除"
|
||||
onClick={() => handleDeleteClick(s)}
|
||||
disabled={confirmDelete?.station.id === s.id}
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 详情抽屉 */}
|
||||
{detailStation && (
|
||||
<StationDrawer
|
||||
station={detailStation}
|
||||
canEdit={canManage}
|
||||
onEdit={(s) => { setDetailStation(null); handleEdit(s) }}
|
||||
onClose={() => setDetailStation(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 编辑/新增弹窗 */}
|
||||
{showModal && (
|
||||
<StationModal
|
||||
station={editing}
|
||||
saving={saving}
|
||||
onSave={handleSave}
|
||||
onClose={handleCloseModal}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 删除确认 */}
|
||||
<ConfirmDialog
|
||||
open={!!confirmDelete}
|
||||
title="确认删除站点"
|
||||
message={`确定要删除「${confirmDelete?.station.name}」吗?删除后该站点下所有人员将受到影响。`}
|
||||
confirmLabel="删除"
|
||||
danger
|
||||
loading={!!confirmDelete?.loading}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
onCancel={() => setConfirmDelete(null)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 编辑/新增表单弹窗 ─────────────────────────────────────────────────────────
|
||||
type StationForm = {
|
||||
name: string; code: string; region: string; address: string;
|
||||
leader: string; phone: string; establishedAt: string; status: 'active' | 'inactive'
|
||||
}
|
||||
|
||||
function StationModal({
|
||||
station, saving, onSave, onClose,
|
||||
}: {
|
||||
station: Station | null
|
||||
saving: boolean
|
||||
onSave: (form: StationForm) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [form, setForm] = useState<StationForm>({
|
||||
name: station?.name || '',
|
||||
code: station?.code || '',
|
||||
region: station?.region || '',
|
||||
address: station?.address || '',
|
||||
leader: station?.leader || '',
|
||||
phone: station?.phone || '',
|
||||
establishedAt: station?.establishedAt || '',
|
||||
status: station?.status || 'active',
|
||||
})
|
||||
|
||||
const [errors, setErrors] = useState<Partial<Record<keyof StationForm, string>>>({})
|
||||
|
||||
const isEdit = !!station
|
||||
|
||||
const validate = (): boolean => {
|
||||
const errs: Partial<Record<keyof StationForm, string>> = {}
|
||||
if (!form.name.trim()) errs.name = '请输入站点名称'
|
||||
if (!form.code.trim()) errs.code = '请输入站点编码'
|
||||
if (form.phone && !/^[\d\-()\s]+$/.test(form.phone))
|
||||
errs.phone = '请输入正确的电话号码'
|
||||
setErrors(errs)
|
||||
return Object.keys(errs).length === 0
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!validate()) return
|
||||
onSave(form)
|
||||
}
|
||||
|
||||
const field = (key: keyof StationForm) => ({
|
||||
value: form[key],
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
setForm(f => ({ ...f, [key]: e.target.value }))
|
||||
if (errors[key]) setErrors(er => { const n = { ...er }; delete n[key]; return n })
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="modal-layer" onClick={onClose}>
|
||||
<div className="modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-head">
|
||||
<div>
|
||||
<h2 style={{ margin: 0 }}>{isEdit ? '编辑站点' : '新增站点'}</h2>
|
||||
{isEdit && <p style={{ margin: '4px 0 0', fontSize: 11, color: 'var(--muted)' }}>{station!.code}</p>}
|
||||
</div>
|
||||
<button className="icon-button" onClick={onClose} disabled={saving}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} noValidate>
|
||||
<div className="form-grid">
|
||||
<label className={errors.name ? 'has-error' : ''}>
|
||||
<span>站点名称 <b>*</b></span>
|
||||
<input {...field('name')} placeholder="如:北京记者站" disabled={isEdit} />
|
||||
{errors.name && <small className="field-error">{errors.name}</small>}
|
||||
</label>
|
||||
|
||||
<label className={errors.code ? 'has-error' : ''}>
|
||||
<span>站点编码 <b>*</b></span>
|
||||
<input {...field('code')} placeholder="如:STATION_BJ" disabled={isEdit} />
|
||||
{errors.code && <small className="field-error">{errors.code}</small>}
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>所属地区</span>
|
||||
<input {...field('region')} placeholder="如:华北、华东、华南" />
|
||||
</label>
|
||||
|
||||
<label className={errors.phone ? 'has-error' : ''}>
|
||||
<span>联系电话</span>
|
||||
<input {...field('phone')} placeholder="如:010-12345678" type="tel" />
|
||||
{errors.phone && <small className="field-error">{errors.phone}</small>}
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>负责人</span>
|
||||
<input {...field('leader')} placeholder="输入负责人姓名" />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>建站时间</span>
|
||||
<input {...field('establishedAt')} type="date" />
|
||||
</label>
|
||||
|
||||
<label style={{ gridColumn: '1 / -1' }}>
|
||||
<span>站点地址</span>
|
||||
<input {...field('address')} placeholder="详细地址" />
|
||||
</label>
|
||||
|
||||
{isEdit && (
|
||||
<label>
|
||||
<span>运行状态</span>
|
||||
<select {...field('status')}>
|
||||
<option value="active">在运</option>
|
||||
<option value="inactive">停运</option>
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="secondary-button" onClick={onClose} disabled={saving}>
|
||||
取消
|
||||
</button>
|
||||
<button type="submit" className="primary-button" disabled={saving}>
|
||||
{saving ? '保存中...' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { ScrollText, Loader2, Filter } from 'lucide-react'
|
||||
import { useRole } from '../../context'
|
||||
import { api } from '../../api'
|
||||
import { EmptyState } from '../../components/ui/EmptyState'
|
||||
import type { SystemLog } from '../../types'
|
||||
|
||||
const moduleLabels: Record<string, string> = {
|
||||
auth: '认证', record: '工作记录', people: '人员管理', stations: '站点管理',
|
||||
notices: '通知公告', appeals: '申诉复议', export: '数据导出',
|
||||
}
|
||||
|
||||
const actionLabels: Record<string, string> = {
|
||||
login: '登录', logout: '退出', create: '创建', delete: '删除', update: '更新',
|
||||
transfer: '调站', withdraw: '撤回', save_draft: '保存草稿',
|
||||
upload_attachment: '上传附件', records: '导出记录', people: '导出人员', scores: '导出评分',
|
||||
pass: '审核通过', return: '审核退回', uphold: '申诉驳回', overturn: '申诉通过',
|
||||
}
|
||||
|
||||
/**
|
||||
* 系统操作日志页面 — 仅总部管理员可查看
|
||||
*/
|
||||
export function SystemLogsPage() {
|
||||
const { role } = useRole()
|
||||
const [logs, setLogs] = useState<SystemLog[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [filterModule, setFilterModule] = useState('')
|
||||
const [filterActor, setFilterActor] = useState('')
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await api.systemLogs(role, {
|
||||
module: filterModule || undefined,
|
||||
actor: filterActor || undefined,
|
||||
pageSize: 100,
|
||||
})
|
||||
setLogs(data)
|
||||
} catch {
|
||||
setLogs([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [role, filterModule, filterActor])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
return (
|
||||
<div className="page-content">
|
||||
<div className="page-heading small">
|
||||
<div>
|
||||
<div className="page-heading-icon"><ScrollText size={20} /></div>
|
||||
<div>
|
||||
<h1>操作日志</h1>
|
||||
<span>系统所有关键操作的审计记录</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="filter-bar">
|
||||
<Filter size={15} />
|
||||
<select value={filterModule} onChange={e => setFilterModule(e.target.value)}>
|
||||
<option value="">全部模块</option>
|
||||
{Object.entries(moduleLabels).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v}</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="操作人搜索"
|
||||
value={filterActor}
|
||||
onChange={e => setFilterActor(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="loading-center"><Loader2 size={24} className="spin" /></div>
|
||||
) : logs.length === 0 ? (
|
||||
<EmptyState icon={ScrollText} text="暂无操作日志" />
|
||||
) : (
|
||||
<div className="table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>操作人</th>
|
||||
<th>角色</th>
|
||||
<th>模块</th>
|
||||
<th>操作</th>
|
||||
<th>目标</th>
|
||||
<th>详情</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{logs.map(log => (
|
||||
<tr key={log.id}>
|
||||
<td data-label="时间"><span className="cell-main">{log.createdAt}</span></td>
|
||||
<td data-label="操作人"><strong>{log.actorName}</strong></td>
|
||||
<td data-label="角色">{log.actorRole}</td>
|
||||
<td data-label="模块">{moduleLabels[log.module] || log.module}</td>
|
||||
<td data-label="操作">{actionLabels[log.action] || log.action}</td>
|
||||
<td data-label="目标">{log.targetType ? `${log.targetType}#${log.targetId}` : '—'}</td>
|
||||
<td data-label="详情"><small>{log.detail || '—'}</small></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useState } from 'react'
|
||||
import { ChevronDown, Plus, Search, FilePenLine } from 'lucide-react'
|
||||
import { RecordTable } from '../../components/data'
|
||||
import { useRole } from '../../context'
|
||||
import type { WorkRecord } from '../../types'
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
draft: '草稿', station_review: '待分站审核',
|
||||
headquarters_review: '待总部复核', returned: '已退回', archived: '已归档',
|
||||
}
|
||||
|
||||
interface WorkListProps {
|
||||
records: WorkRecord[]
|
||||
onCreate: () => void
|
||||
onSelect: (r: WorkRecord) => void
|
||||
}
|
||||
|
||||
export function WorkList({ records, onCreate, onSelect }: WorkListProps) {
|
||||
const { role } = useRole()
|
||||
const [query, setQuery] = useState('')
|
||||
const [status, setStatus] = useState('all')
|
||||
|
||||
const filtered = records.filter(r =>
|
||||
(r.title.includes(query) || r.reporter.includes(query)) &&
|
||||
(status === 'all' || r.status === status)
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-heading small">
|
||||
<div>
|
||||
<div className="page-heading-icon"><FilePenLine size={20} /></div>
|
||||
<div>
|
||||
<h1>工作记录</h1>
|
||||
<span>{role === 'reporter' ? '管理你的填报记录和审核进度。' : '查询权限范围内的填报、审核与归档记录。'}</span>
|
||||
</div>
|
||||
</div>
|
||||
{role !== 'headquarters' && (
|
||||
<button className="primary-button" onClick={onCreate}>
|
||||
<Plus size={18} />
|
||||
新建记录
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<section className="panel list-panel">
|
||||
<div className="filters">
|
||||
<div className="search">
|
||||
<Search size={17} />
|
||||
<input
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
placeholder="搜索标题或记者"
|
||||
/>
|
||||
</div>
|
||||
<select value={status} onChange={e => setStatus(e.target.value)}>
|
||||
<option value="all">全部状态</option>
|
||||
{Object.entries(statusLabels).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v}</option>
|
||||
))}
|
||||
</select>
|
||||
<button className="secondary-button">
|
||||
更多筛选 <ChevronDown size={15} />
|
||||
</button>
|
||||
<span className="result-count">共 {filtered.length} 条</span>
|
||||
</div>
|
||||
<RecordTable records={filtered} onSelect={onSelect} />
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export { Dashboard } from './Dashboard'
|
||||
export { WorkList } from './WorkList'
|
||||
export { ReviewCenter } from './ReviewCenter'
|
||||
export { PeoplePage } from './People'
|
||||
export { StationsPage } from './Stations'
|
||||
export { ArchivePage } from './Archive'
|
||||
export { NoticesPage } from './Notices'
|
||||
export { SettingsPage } from './Settings'
|
||||
export { RulesPage } from './Rules'
|
||||
export { ScoresPage } from './Scores'
|
||||
export { LeaderboardPage } from './Leaderboard'
|
||||
@@ -0,0 +1,41 @@
|
||||
// 路由 Page key(替代 types.ts 中的 Page)
|
||||
export type PageKey = 'dashboard' | 'work' | 'review' | 'people' | 'stations'
|
||||
| 'archive' | 'notices' | 'settings' | 'rules' | 'scores' | 'leaderboard'
|
||||
| 'appeals' | 'logs' | 'stationmap' | 'cockpit' | 'profile'
|
||||
|
||||
import {
|
||||
LayoutDashboard, FilePenLine, ClipboardCheck, Users, Building2,
|
||||
Archive, Bell, Settings, ScrollText, BarChart3, Trophy, Gavel,
|
||||
FileText, MapPin, Gauge, UserCircle,
|
||||
} from 'lucide-react'
|
||||
|
||||
export const pageIcons: Record<PageKey, typeof LayoutDashboard> = {
|
||||
dashboard: LayoutDashboard, work: FilePenLine, review: ClipboardCheck,
|
||||
people: Users, stations: Building2, archive: Archive,
|
||||
notices: Bell, settings: Settings, rules: ScrollText,
|
||||
scores: BarChart3, leaderboard: Trophy, appeals: Gavel,
|
||||
logs: FileText, stationmap: MapPin, cockpit: Gauge, profile: UserCircle,
|
||||
}
|
||||
|
||||
export const pathMap: Record<PageKey, string> = {
|
||||
dashboard: '/', work: '/work', review: '/review',
|
||||
people: '/people', stations: '/stations', archive: '/archive',
|
||||
notices: '/notices', settings: '/settings',
|
||||
rules: '/rules', scores: '/scores', leaderboard: '/leaderboard',
|
||||
appeals: '/appeals', logs: '/logs',
|
||||
stationmap: '/stationmap', cockpit: '/cockpit', profile: '/profile',
|
||||
}
|
||||
|
||||
export const navLabels: Record<PageKey, string> = {
|
||||
dashboard: '工作台', work: '工作记录', review: '审核中心',
|
||||
people: '人员管理', stations: '记者站管理', archive: '电子档案',
|
||||
notices: '通知公告', settings: '系统设置',
|
||||
rules: '考核规则', scores: '评分结果', leaderboard: '积分排行',
|
||||
appeals: '申诉复议', logs: '操作日志',
|
||||
stationmap: '全国地图', cockpit: '管理驾驶舱', profile: '能力画像',
|
||||
}
|
||||
|
||||
export function pathToPage(pathname: string): PageKey {
|
||||
const entry = Object.entries(pathMap).find(([, p]) => p === pathname)
|
||||
return (entry ? entry[0] : 'dashboard') as PageKey
|
||||
}
|
||||
+380
File diff suppressed because one or more lines are too long
+100
@@ -0,0 +1,100 @@
|
||||
export type Role = 'headquarters' | 'station' | 'reporter'
|
||||
|
||||
export type WorkStatus =
|
||||
| 'draft'
|
||||
| 'station_review'
|
||||
| 'headquarters_review'
|
||||
| 'returned'
|
||||
| 'archived'
|
||||
|
||||
export type WorkType = '文字稿件' | '视频供稿' | '图片供稿' | '重要报道' | '培训参与' | '临时工作'
|
||||
|
||||
/** 附件信息 */
|
||||
export interface Attachment {
|
||||
name: string
|
||||
url: string
|
||||
size?: number
|
||||
type?: string
|
||||
}
|
||||
|
||||
export interface WorkRecord {
|
||||
id: string
|
||||
title: string
|
||||
type: WorkType
|
||||
reporter: string
|
||||
station: string
|
||||
date: string
|
||||
platform: string
|
||||
status: WorkStatus
|
||||
score?: number
|
||||
description?: string
|
||||
reviewNote?: string
|
||||
attachments?: Attachment[] | string | null
|
||||
createdAt?: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
/** 申诉状态 */
|
||||
export type AppealStatus = 'pending' | 'accepted' | 'rejected'
|
||||
|
||||
/** 申诉记录 */
|
||||
export interface Appeal {
|
||||
id: number
|
||||
code: string
|
||||
recordId: string
|
||||
appellant: string
|
||||
station: string
|
||||
reason: string
|
||||
status: AppealStatus
|
||||
handler: string | null
|
||||
handlerRole: string | null
|
||||
response: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
handledAt: string | null
|
||||
}
|
||||
|
||||
/** 人员调站记录 */
|
||||
export interface PersonTransfer {
|
||||
id: number
|
||||
fromStation: string
|
||||
toStation: string
|
||||
reason: string | null
|
||||
operatedBy: string
|
||||
transferredAt: string
|
||||
}
|
||||
|
||||
/** 系统操作日志 */
|
||||
export interface SystemLog {
|
||||
id: number
|
||||
actorRole: string
|
||||
actorName: string
|
||||
module: string
|
||||
action: string
|
||||
targetType: string | null
|
||||
targetId: string
|
||||
detail: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
/** 登录响应 */
|
||||
export interface LoginResponse {
|
||||
token: string
|
||||
role: Role
|
||||
name: string
|
||||
station: string | null
|
||||
code: string
|
||||
}
|
||||
|
||||
/** 版本历史记录 */
|
||||
export interface RecordVersion {
|
||||
id: number
|
||||
versionNo: number
|
||||
title: string
|
||||
type: string
|
||||
platform: string
|
||||
description: string | null
|
||||
attachments: string | null
|
||||
editedBy: string
|
||||
editedAt: string
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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.
Binary file not shown.
+34
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user