热门角色不仅是灵感来源,更是你的效率助手。通过精挑细选的角色提示词,你可以快速生成高质量内容、提升创作灵感,并找到最契合你需求的解决方案。让创作更轻松,让价值更直接!
我们根据不同用户需求,持续更新角色库,让你总能找到合适的灵感入口。
本提示词帮助用户通过专家视角学习编程,清晰讲解各语言核心概念、最佳实践及工具使用,指导项目构建与问题解决,并跟进行业趋势,提升学习效率与实战能力。
下面给出一个完整、可直接运行和测试的项目示例,满足你对教学与实践的全部要求。包含:
#!/usr/bin/env python3
""" 电商订单数据清洗与统计脚本:data_pipeline.py
功能概述:
示例 CSV(UTF-8,7 列): order_id,item_name,category,price,quantity,status,created_at 1001,水杯,家居,19.9,2,paid,2024-06-01T10:11:00Z 1002,数据线,数码,12.5,1,paid,2024-06-01T12:40:00Z 1003,运动袜,服饰,15.0,3,refunded,2024-06-02T09:20:00Z 1004,鼠标,数码,79.0,1,paid,2024-06-02T11:05:00Z 1005,抱枕,家居,35.0,1,paid,2024-06-03T08:00:00Z
示例运行: python data_pipeline.py --input ./orders.csv --out-dir ./out --start-date 2024-06-01 --end-date 2024-06-03 --top-n 5 --log-level INFO
预期输出(控制台日志片段示意): [INFO] Loaded 5 rows in 0.01s [INFO] Cleaned data: 4 rows remain (dropped=1, refunded removed) [INFO] Date range filter applied: 2024-06-01 00:00:00+08:00 ~ 2024-06-03 23:59:59.999999+08:00 -> 4 rows [INFO] Category summary generated (top 5), Daily summary generated [INFO] Saved category summary to out/category_summary_top5.csv [INFO] Saved daily summary to out/daily_summary.csv [INFO] Benchmark amount calc: vectorized=0.0003s, loop=0.0121s
输出文件:
from future import annotations
import argparse import logging import os import sys import time from typing import Optional, Tuple
import pandas as pd from dateutil import parser as du_parser from dateutil.tz import tzlocal
REQUIRED_COLUMNS = ["order_id", "item_name", "category", "price", "quantity", "status", "created_at"]
logger = logging.getLogger(name)
def setup_logging(level: str) -> None: """ 配置全局日志格式与级别。 """ log_level = getattr(logging, level.upper(), logging.INFO) logging.basicConfig( level=log_level, format="%(asctime)s [%(levelname)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S", )
def read_data(csv_path: str) -> pd.DataFrame: """ 从 CSV 读取订单数据。
Parameters:
csv_path: 输入 CSV 文件路径
Returns:
DataFrame 包含原始数据
Raises:
FileNotFoundError: 当文件不存在时
ValueError: 当缺少必要列时
"""
start = time.perf_counter()
if not os.path.exists(csv_path):
logger.error(f"Input file does not exist: {csv_path}")
raise FileNotFoundError(csv_path)
try:
df = pd.read_csv(csv_path, encoding="utf-8")
except Exception as e:
logger.error(f"Failed to read CSV: {e}")
raise
missing_cols = [c for c in REQUIRED_COLUMNS if c not in df.columns]
if missing_cols:
raise ValueError(f"Missing required columns: {missing_cols}")
logger.info(f"Loaded {len(df)} rows in {time.perf_counter() - start:.4f}s")
return df
def clean_data(df: pd.DataFrame) -> pd.DataFrame: """ 清洗数据: - 保留必要列;去除关键字段缺失的行 - price/quantity 转数值,created_at 解析为本地时区的 tz-aware datetime - 过滤退款订单(status != 'refunded') - 新增 amount = price * quantity(保留两位小数)
Parameters:
df: 原始 DataFrame
Returns:
清洗后的 DataFrame(包含 amount 列,created_at 为本地时区 tz-aware)
"""
t0 = time.perf_counter()
# 只保留必要列(保持顺序)
df = df[[c for c in REQUIRED_COLUMNS if c in df.columns]].copy()
# 丢弃关键字段为空的行(字符串空值在 to_numeric 时转 NaN)
before = len(df)
# 转换价格与数量为数值
df["price"] = pd.to_numeric(df["price"], errors="coerce")
df["quantity"] = pd.to_numeric(df["quantity"], errors="coerce")
# 解析时间:先转为 UTC,再转本地时区
# errors='coerce' 将异常解析设为 NaT,后续丢弃
created_at_utc = pd.to_datetime(df["created_at"], utc=True, errors="coerce")
local_tz = tzlocal()
df["created_at"] = created_at_utc.dt.tz_convert(local_tz)
# 丢弃关键字段为空或 NaT
df = df.dropna(subset=["order_id", "category", "price", "quantity", "created_at"])
# 过滤退款
df = df[df["status"].astype(str).str.lower() != "refunded"]
# 计算金额(向量化)
df["amount"] = (df["price"] * df["quantity"]).round(2)
after = len(df)
logger.info(
f"Cleaned data: {after} rows remain (dropped={before - after}) in {time.perf_counter() - t0:.4f}s"
)
return df
def parse_date_arg(s: Optional[str]) -> Optional[pd.Timestamp]: """ 将命令行的日期字符串解析为本地时区的 tz-aware Timestamp。 - 若为空返回 None - 若无时区信息,按本地时区解释 - 若仅提供日期(YYYY-MM-DD),调用方负责设定为当天起止时间
Parameters:
s: 输入字符串
Returns:
tz-aware Timestamp 或 None
Raises:
ValueError: 解析失败
"""
if not s:
return None
try:
dt = du_parser.parse(s)
except Exception as e:
raise ValueError(f"Invalid date value: {s}, error: {e}") from e
local_tz = tzlocal()
if dt.tzinfo is None:
# 视为本地时间
dt = dt.replace(tzinfo=local_tz)
else:
# 转换到本地时区
dt = pd.Timestamp(dt).tz_convert(local_tz).to_pydatetime()
return pd.Timestamp(dt)
def normalize_date_range( start: Optional[pd.Timestamp], end: Optional[pd.Timestamp] ) -> Tuple[Optional[pd.Timestamp], Optional[pd.Timestamp]]: """ 规范化起止时间(含边界): - 若只给出日期(无时分秒),start 设为当天 00:00:00,本地时区;end 设为当天 23:59:59.999999,本地时区 - 确保 start <= end
Returns:
(start, end) 规范化后的时间
"""
local_tz = tzlocal()
def is_pure_date(ts: pd.Timestamp) -> bool:
return ts.hour == 0 and ts.minute == 0 and ts.second == 0 and ts.microsecond == 0
if start is not None:
if is_pure_date(start):
start = start.replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=start.tzinfo or local_tz)
if end is not None:
if is_pure_date(end):
end = end.replace(hour=23, minute=59, second=59, microsecond=999999, tzinfo=end.tzinfo or local_tz)
if start and end and start > end:
raise ValueError(f"start-date {start} must be <= end-date {end}")
return start, end
def filter_by_date_range( df: pd.DataFrame, start: Optional[pd.Timestamp], end: Optional[pd.Timestamp] ) -> pd.DataFrame: """ 按日期时间范围筛选(含边界)。
Parameters:
df: 清洗后的 DataFrame(created_at 为本地时区 tz-aware)
start: 起始时间(含),或 None
end: 结束时间(含),或 None
Returns:
过滤后的 DataFrame
"""
t0 = time.perf_counter()
if start is None and end is None:
return df
mask = pd.Series([True] * len(df), index=df.index)
if start is not None:
mask &= df["created_at"] >= start
if end is not None:
mask &= df["created_at"] <= end
out = df[mask].copy()
logger.info(
f"Date range filter applied: {start} ~ {end} -> {len(out)} rows (from {len(df)}) in "
f"{time.perf_counter() - t0:.4f}s"
)
return out
def compute_category_summary(df: pd.DataFrame, top_n: int = 10) -> pd.DataFrame: """ 计算按品类的汇总(GMV、订单数、客单价),并按 GMV 降序取前 top_n。
Parameters:
df: 清洗后的 DataFrame(包含 amount, created_at)
top_n: 取前 N
Returns:
DataFrame 列:category, gmv, order_count, aov
"""
t0 = time.perf_counter()
grp = df.groupby("category").agg(
gmv=("amount", "sum"),
order_count=("order_id", pd.Series.nunique),
)
grp["aov"] = (grp["gmv"] / grp["order_count"]).round(2)
grp = grp.sort_values("gmv", ascending=False)
res = grp.head(top_n).reset_index()
# 数值保留两位
res["gmv"] = res["gmv"].round(2)
logger.info(f"Category summary generated (top {top_n}) in {time.perf_counter() - t0:.4f}s")
return res
def compute_daily_summary(df: pd.DataFrame) -> pd.DataFrame: """ 按日汇总 GMV 与订单数。
Parameters:
df: 清洗后的 DataFrame
Returns:
DataFrame 列:date, gmv, order_count
"""
t0 = time.perf_counter()
tmp = df.copy()
tmp["date"] = tmp["created_at"].dt.date
grp = tmp.groupby("date").agg(
gmv=("amount", "sum"),
order_count=("order_id", pd.Series.nunique),
)
res = grp.reset_index()
res["gmv"] = res["gmv"].round(2)
logger.info(f"Daily summary generated in {time.perf_counter() - t0:.4f}s")
return res
def save_reports( category_df: pd.DataFrame, daily_df: pd.DataFrame, out_dir: str, top_n: int, ) -> Tuple[str, str]: """ 保存报表到 CSV。
Returns:
(category_path, daily_path)
"""
os.makedirs(out_dir, exist_ok=True)
category_path = os.path.join(out_dir, f"category_summary_top{top_n}.csv")
daily_path = os.path.join(out_dir, "daily_summary.csv")
category_df.to_csv(category_path, index=False, encoding="utf-8")
daily_df.to_csv(daily_path, index=False, encoding="utf-8")
logger.info(f"Saved category summary to {category_path}")
logger.info(f"Saved daily summary to {daily_path}")
return category_path, daily_path
def benchmark_vectorized_vs_loop(df: pd.DataFrame) -> Tuple[float, float]: """ 简单性能基准:对比向量化与 for 循环的 amount 计算。
Parameters:
df: 清洗前或清洗后的 DataFrame(需包含 price, quantity)
Returns:
(t_vec, t_loop) 两种方式的耗时(秒)
"""
# 仅使用 price 与 quantity 计算,避免影响原列
tmp = df[["price", "quantity"]].copy()
# 向量化实现
t0 = time.perf_counter()
_ = (tmp["price"] * tmp["quantity"]).round(2)
t_vec = time.perf_counter() - t0
# for 循环实现
t0 = time.perf_counter()
res = []
for row in tmp.itertuples(index=False):
res.append(round(float(row.price) * float(row.quantity), 2))
# 防止优化器忽略计算
_ = res
t_loop = time.perf_counter() - t0
logger.info(f"Benchmark amount calc: vectorized={t_vec:.6f}s, loop={t_loop:.6f}s")
return t_vec, t_loop
def parse_args(argv: Optional[list[str]] = None) -> argparse.Namespace: parser = argparse.ArgumentParser(description="E-commerce order data cleaning and reporting") parser.add_argument("--input", required=True, help="Input CSV path (UTF-8)") parser.add_argument("--out-dir", required=True, help="Output directory") parser.add_argument("--start-date", default=None, help="Start date/time (inclusive), e.g. 2024-06-01 or 2024-06-01T10:00") parser.add_argument("--end-date", default=None, help="End date/time (inclusive)") parser.add_argument("--top-n", type=int, default=10, help="Top N categories by GMV (default: 10)") parser.add_argument("--log-level", default="INFO", help="Logging level (DEBUG, INFO, WARNING, ...)") return parser.parse_args(argv)
def main(argv: Optional[list[str]] = None) -> int: args = parse_args(argv) setup_logging(args.log_level)
try:
raw_df = read_data(args.input)
cleaned = clean_data(raw_df)
# 解析时间范围
start = parse_date_arg(args.start_date)
end = parse_date_arg(args.end_date)
start, end = normalize_date_range(start, end)
if start is not None or end is not None:
cleaned = filter_by_date_range(cleaned, start, end)
if cleaned.empty:
logger.warning("No data after cleaning/filtering. No reports generated.")
return 0
cat_df = compute_category_summary(cleaned, top_n=args.top_n)
daily_df = compute_daily_summary(cleaned)
save_reports(cat_df, daily_df, args.out_dir, top_n=args.top_n)
# 简单基准(对清洗后的数据)
benchmark_vectorized_vs_loop(cleaned)
return 0
except FileNotFoundError:
# 已在 read_data 中记录
return 2
except ValueError as e:
logger.error(str(e))
return 3
except Exception as e:
logger.exception(f"Unexpected error: {e}")
return 1
if name == "main": sys.exit(main())
pandas>=2.0 python-dateutil>=2.8.2 pytest>=7.0
import pandas as pd import pytest
from data_pipeline import ( clean_data, filter_by_date_range, compute_category_summary, parse_date_arg, normalize_date_range, )
@pytest.fixture def raw_df_fixture(): """ 构造内存 DataFrame,覆盖: - 正常行 - 退款行 - price/quantity/order_id 缺失 - 多个品类与订单 """ data = [ # valid { "order_id": "1001", "item_name": "A", "category": "Cat1", "price": 10.0, "quantity": 2, "status": "paid", "created_at": "2024-06-01T12:00:00Z", }, # refunded -> should be filtered { "order_id": "1002", "item_name": "B", "category": "Cat1", "price": 5.0, "quantity": 1, "status": "refunded", "created_at": "2024-06-01T13:00:00Z", }, # missing price -> drop { "order_id": "1003", "item_name": "C", "category": "Cat2", "price": None, "quantity": 1, "status": "paid", "created_at": "2024-06-02T10:00:00Z", }, # missing order_id -> drop { "order_id": None, "item_name": "D", "category": "Cat2", "price": 20.0, "quantity": 1, "status": "paid", "created_at": "2024-06-02T11:00:00Z", }, # another valid { "order_id": "1004", "item_name": "E", "category": "Cat2", "price": 30.0, "quantity": 1, "status": "paid", "created_at": "2024-06-02T15:00:00Z", }, ] return pd.DataFrame(data)
def test_cleaning_logic(raw_df_fixture): df_clean = clean_data(raw_df_fixture) # refunded + missing price + missing order_id -> 3 rows removed, leaving 2 assert len(df_clean) == 2 # amount calculation accuracy # rows: 1001 amount=20.0, 1004 amount=30.0 amounts = sorted(df_clean["amount"].tolist()) assert amounts == [20.0, 30.0] # created_at should be tz-aware (local timezone) assert str(df_clean["created_at"].dt.tz) != "None"
def test_date_range_filter_inclusive(raw_df_fixture): df_clean = clean_data(raw_df_fixture) # Use inclusive boundaries that cover both valid rows (2024-06-01 and 2024-06-02, local time) start = parse_date_arg("2024-06-01") end = parse_date_arg("2024-06-02") start, end = normalize_date_range(start, end) df_filtered = filter_by_date_range(df_clean, start, end) # Both valid rows fall within these two days (local time), inclusive assert len(df_filtered) == 2
def test_category_summary_topn(raw_df_fixture): df_clean = clean_data(raw_df_fixture) # Cat1 total amount: 20.0 (order 1001) # Cat2 total amount: 30.0 (order 1004) res = compute_category_summary(df_clean, top_n=1) assert len(res) == 1 assert res.iloc[0]["category"] == "Cat2" assert float(res.iloc[0]["gmv"]) == 30.0 assert int(res.iloc[0]["order_count"]) == 1
基础概念理解
函数与方法
数据结构与算法
项目实践
调试技巧
代码优化
库与框架使用
工具使用与运行
跟新趋势与技术发展
这套代码与说明,覆盖从基础概念到项目化落地、从调试到优化的完整学习路径,适合入门者按“案例驱动 + 项目导向”的方式快速上手并形成实践能力。
下面给出完整可用的 React 18 实时搜索组件实现(SearchBox.jsx、ResultsList.jsx)、自定义 Hook(useSearch.js)、样式(Search.module.css)、测试(Jest + @testing-library/react),以及使用示例(index.jsx)与简短 README。并在最后总结关键可访问性与性能优化点。
文件:src/hooks/useSearch.js
/**
* useSearch - 即时搜索逻辑 Hook
* - 300ms 防抖
* - AbortController 取消前一个请求
* - 指数退避重试(最多3次,初始300ms)
* - 最近10条查询的 LRU 缓存(Map)
* - 网络失败与空结果分别提示,正确处理取消
*
* @module useSearch
*/
import { useCallback, useEffect, useRef, useState } from 'react';
/**
* @typedef {Object} SearchItem
* @property {string} id
* @property {string} title
*/
/**
* @typedef {Object} UseSearchOptions
* @property {string} [endpoint="/api/books?query="] 基础查询接口
* @property {number} [debounce=300] 防抖毫秒
* @property {number} [maxRetries=3] 最大重试次数
* @property {number} [retryBaseDelay=300] 初始重试等待毫秒(指数退避基数)
* @property {number} [cacheSize=10] LRU 缓存容量
*/
/**
* @returns {{
* query: string,
* setQuery: (q:string)=>void,
* results: SearchItem[],
* loading: boolean,
* error: string | null,
* status: 'idle' | 'loading' | 'success' | 'empty' | 'error' | 'cancelled',
* selectedIndex: number,
* setSelectedIndex: (n:number)=>void,
* onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>)=>void,
* clear: ()=>void,
* hasResults: boolean,
* }}
*/
export function useSearch({
endpoint = '/api/books?query=',
debounce = 300,
maxRetries = 3,
retryBaseDelay = 300,
cacheSize = 10,
} = {}) {
const [query, setQuery] = useState('');
const [results, setResults] = useState(/** @type {SearchItem[]} */ ([]));
const [loading, setLoading] = useState(false);
const [error, setError] = useState(/** @type {string|null} */ (null));
const [status, setStatus] = useState('idle');
const [selectedIndex, setSelectedIndex] = useState(-1);
const controllerRef = useRef(/** @type {AbortController|null} */ (null));
const debounceTimerRef = useRef(/** @type {ReturnType<typeof setTimeout> | null} */ (null));
const currentQueryRef = useRef('');
const cacheRef = useRef(new Map()); // LRU: recent -> end
// 清理与取消当前请求
const abortCurrent = useCallback(() => {
if (controllerRef.current) {
controllerRef.current.abort();
controllerRef.current = null;
}
}, []);
// LRU 操作
const touchCacheKey = useCallback((key) => {
const cache = cacheRef.current;
if (!cache.has(key)) return;
const val = cache.get(key);
cache.delete(key);
cache.set(key, val);
}, []);
const setCache = useCallback((key, value) => {
const cache = cacheRef.current;
if (cache.has(key)) cache.delete(key);
cache.set(key, value);
if (cache.size > cacheSize) {
const oldestKey = cache.keys().next().value;
cache.delete(oldestKey);
}
}, [cacheSize]);
const clear = useCallback(() => {
abortCurrent();
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
}
setQuery('');
setResults([]);
setSelectedIndex(-1);
setError(null);
setStatus('idle');
}, [abortCurrent]);
// 退避等待,支持在等待中被 abort
const waitWithAbort = (ms, signal) =>
new Promise((resolve, reject) => {
const t = setTimeout(resolve, ms);
const onAbort = () => {
clearTimeout(t);
reject(new DOMException('Aborted', 'AbortError'));
};
if (signal) {
if (signal.aborted) onAbort();
else signal.addEventListener('abort', onAbort, { once: true });
}
});
const isRetryableStatus = (status) =>
status === 429 || (status >= 500 && status < 600);
const fetchWithRetry = useCallback(async (q, signal) => {
let attempt = 0;
let delay = retryBaseDelay;
const url = `${endpoint}${encodeURIComponent(q)}`;
// eslint-disable-next-line no-constant-condition
while (true) {
try {
const res = await fetch(url, { signal });
if (!res.ok) {
if (isRetryableStatus(res.status) && attempt < maxRetries - 1) {
attempt += 1;
await waitWithAbort(delay, signal);
delay *= 2;
continue;
}
const text = await res.text().catch(() => '');
throw new Error(`HTTP ${res.status}${text ? `: ${text}` : ''}`);
}
const json = await res.json();
const items = Array.isArray(json?.items) ? json.items : [];
return items;
} catch (err) {
if (err?.name === 'AbortError') throw err;
// 网络异常重试
if (attempt < maxRetries - 1) {
attempt += 1;
await waitWithAbort(delay, signal);
delay *= 2;
continue;
}
throw err;
}
}
}, [endpoint, maxRetries, retryBaseDelay]);
// 处理键盘导航(上下选择+回车选择由组件层决定)
const onKeyDown = useCallback((e) => {
if (!results || results.length === 0) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex((prev) => (prev + 1) % results.length);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex((prev) => (prev - 1 + results.length) % results.length);
}
}, [results]);
// 主 effect:响应 query 变化
useEffect(() => {
const q = query.trim();
// 每次输入变化,先取消上一个请求
abortCurrent();
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
}
if (!q) {
setResults([]);
setLoading(false);
setError(null);
setStatus('idle');
setSelectedIndex(-1);
return;
}
// 缓存命中:不发请求,立即返回
if (cacheRef.current.has(q)) {
touchCacheKey(q);
const cached = cacheRef.current.get(q);
setResults(cached);
setLoading(false);
setError(null);
setStatus(cached.length ? 'success' : 'empty');
setSelectedIndex(cached.length ? 0 : -1);
return;
}
setLoading(true);
setError(null);
setStatus('loading');
setSelectedIndex(-1);
// 防抖后发起请求
debounceTimerRef.current = setTimeout(async () => {
currentQueryRef.current = q;
const controller = new AbortController();
controllerRef.current = controller;
try {
const items = await fetchWithRetry(q, controller.signal);
// 仅处理最新查询结果
if (currentQueryRef.current !== q) return;
setCache(q, items);
setResults(items);
setLoading(false);
setError(null);
setStatus(items.length ? 'success' : 'empty');
setSelectedIndex(items.length ? 0 : -1);
} catch (err) {
if (err?.name === 'AbortError') {
setLoading(false);
setStatus('cancelled');
return;
}
setLoading(false);
setError(err?.message || '网络错误');
setStatus('error');
} finally {
controllerRef.current = null;
}
}, debounce);
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
}
};
}, [query, abortCurrent, debounce, fetchWithRetry, setCache, touchCacheKey]);
useEffect(() => {
// 卸载清理
return () => {
abortCurrent();
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
};
}, [abortCurrent]);
const hasResults = results && results.length > 0;
return {
query,
setQuery,
results,
loading,
error,
status,
selectedIndex,
setSelectedIndex,
onKeyDown,
clear,
hasResults,
};
}
文件:src/components/ResultsList.jsx
/**
* ResultsList - 结果列表(无状态展示组件)
* 无障碍:
* - role="listbox"
* - item role="option" + aria-selected
*
* @module ResultsList
*/
import React from 'react';
import styles from './Search.module.css';
/**
* @param {{
* id: string,
* results: {id:string, title:string}[],
* selectedIndex: number,
* onItemClick: (item:any)=>void,
* onItemHover: (index:number)=>void,
* visible: boolean
* }} props
*/
export default function ResultsList({
id,
results,
selectedIndex,
onItemClick,
onItemHover,
visible,
}) {
if (!visible) return null;
return (
<ul
id={id}
className={styles.listbox}
role="listbox"
aria-label="搜索结果"
>
{results.map((item, idx) => {
const optionId = `${id}-opt-${item.id ?? idx}`;
const selected = idx === selectedIndex;
return (
<li
key={item.id ?? idx}
id={optionId}
role="option"
aria-selected={selected}
className={`${styles.option} ${selected ? styles.optionActive : ''}`}
onMouseEnter={() => onItemHover(idx)}
onMouseDown={(e) => e.preventDefault()} // 防止失焦
onClick={() => onItemClick(item, idx)}
title={item.title}
>
{item.title}
</li>
);
})}
</ul>
);
}
文件:src/components/SearchBox.jsx
/**
* SearchBox - 可直接使用的即时搜索组件
* - 集成 useSearch 数据逻辑
* - 无障碍与键盘导航
*/
import React, { useId, useMemo } from 'react';
import { useSearch } from '../hooks/useSearch';
import ResultsList from './ResultsList';
import styles from './Search.module.css';
/**
* @param {{
* endpoint?: string,
* ariaLabel?: string,
* onSelect?: (item: {id:string,title:string})=>void
* }} props
*/
export default function SearchBox({
endpoint = '/api/books?query=',
ariaLabel = '搜索图书',
onSelect,
}) {
const listId = useId();
const {
query, setQuery,
results, loading, error, status,
selectedIndex, setSelectedIndex,
onKeyDown, clear, hasResults,
} = useSearch({ endpoint });
const showList = useMemo(() => {
const hasQuery = query.trim().length > 0;
return hasQuery && (loading || hasResults || !!error || status === 'empty');
}, [query, loading, hasResults, error, status]);
const activeDescendant = showList && selectedIndex >= 0 && results[selectedIndex]
? `${listId}-opt-${results[selectedIndex].id ?? selectedIndex}`
: undefined;
const handleEnter = () => {
if (selectedIndex >= 0 && results[selectedIndex]) {
const item = results[selectedIndex];
onSelect?.(item);
setQuery(item.title); // 将选中项回填输入框
}
};
return (
<div className={styles.wrapper}>
<div className={styles.inputRow}>
<input
type="search"
className={styles.input}
value={query}
placeholder="搜索书籍(即时搜索)"
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleEnter();
return;
}
if (e.key === 'Escape') {
e.preventDefault();
clear();
return;
}
onKeyDown(e);
}}
aria-label={ariaLabel}
aria-controls={listId}
aria-expanded={showList}
aria-activedescendant={activeDescendant}
aria-autocomplete="list"
autoComplete="off"
/>
{query && (
<button
type="button"
className={styles.clearBtn}
onClick={clear}
aria-label="清除搜索"
title="清除搜索"
>
×
</button>
)}
</div>
<div className={styles.statusRow} aria-live="polite" role="status">
{loading && <span className={styles.loading}>正在加载…</span>}
{!loading && status === 'empty' && (
<span className={styles.hint}>未找到匹配结果</span>
)}
{!loading && error && (
<span className={styles.error}>请求失败:{error}</span>
)}
</div>
<ResultsList
id={listId}
results={results}
selectedIndex={selectedIndex}
onItemHover={(idx) => setSelectedIndex(idx)}
onItemClick={(item, idx) => {
onSelect?.(item);
setSelectedIndex(idx);
setQuery(item.title);
}}
visible={showList && hasResults}
/>
</div>
);
}
文件:src/components/Search.module.css
.wrapper {
position: relative;
max-width: 420px;
margin: 1rem auto;
font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji";
}
.inputRow {
position: relative;
display: flex;
align-items: center;
}
.input {
flex: 1;
padding: 0.5rem 2rem 0.5rem 0.75rem;
font-size: 16px;
border: 1px solid #d0d7de;
border-radius: 6px;
outline: none;
}
.input:focus {
border-color: #0969da;
box-shadow: 0 0 0 3px rgba(9, 105, 218, 0.2);
}
.clearBtn {
position: absolute;
right: 6px;
background: #f3f4f6;
border: 1px solid #d0d7de;
border-radius: 4px;
width: 26px;
height: 26px;
cursor: pointer;
font-size: 18px;
line-height: 1;
}
.clearBtn:hover {
background: #e5e7eb;
}
.statusRow {
min-height: 1.25rem;
margin-top: 0.25rem;
font-size: 12px;
color: #6b7280;
}
.loading {
color: #2563eb;
}
.error {
color: #b91c1c;
}
.hint {
color: #6b7280;
}
.listbox {
position: absolute;
z-index: 10;
top: calc(100% + 6px);
left: 0;
right: 0;
max-height: 288px;
overflow: auto;
background: #fff;
border: 1px solid #d0d7de;
border-radius: 6px;
padding: 4px 0;
box-shadow: 0 6px 18px rgba(0,0,0,0.08);
}
.option {
padding: 8px 10px;
font-size: 14px;
cursor: pointer;
}
.option:hover,
.optionActive {
background: #eff6ff;
}
@media (prefers-reduced-motion: reduce) {
* {
scroll-behavior: auto !important;
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
文件:src/index.jsx(演示用法)
import React from 'react';
import { createRoot } from 'react-dom/client';
import SearchBox from './components/SearchBox.jsx';
function App() {
return (
<div style={{ padding: 24 }}>
<h1>资料库搜索</h1>
<p>试试输入:js、react、node ...</p>
<SearchBox
endpoint="/api/books?query="
onSelect={(item) => {
// 选中后自定义行为
alert(`选中:${item.title} (id: ${item.id})`);
}}
/>
</div>
);
}
const root = createRoot(document.getElementById('root'));
root.render(<App />);
测试:src/tests/Search.test.jsx
/**
* 测试重点:
* a) 防抖与请求取消
* b) 键盘导航与选中
* c) 缓存命中不发请求
* d) 错误重试逻辑(mock fetch)
*/
import React from 'react';
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import SearchBox from '../components/SearchBox.jsx';
// CSS Modules stub
jest.mock('../components/Search.module.css', () => new Proxy({}, {
get: (target, prop) => prop,
}));
beforeEach(() => {
jest.useFakeTimers();
global.fetch = jest.fn();
});
afterEach(() => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
jest.resetAllMocks();
});
const flushMicrotasks = () => Promise.resolve();
test('a) 防抖:连续输入只触发一次请求;取消:新查询应中止上一次', async () => {
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
const signals = [];
// 第一次请求保持悬而未决,便于检测 abort
let resolve1;
const p1 = new Promise((r) => { resolve1 = r; });
// 第二次请求快速返回
const p2 = Promise.resolve({
ok: true,
json: async () => ({ items: [{ id: '2', title: 'React 指南' }] }),
});
global.fetch.mockImplementation((url, opts = {}) => {
signals.push(opts.signal);
if (signals.length === 1) return p1;
return p2;
});
render(<SearchBox endpoint="/api/books?query=" />);
const input = screen.getByRole('textbox', { name: /搜索图书/i });
// 连续输入 - 只触发一次请求(防抖 300ms)
await user.type(input, 're');
jest.advanceTimersByTime(200);
await user.type(input, 'a');
// 还未到300ms,不应触发
expect(global.fetch).toHaveBeenCalledTimes(0);
// 超过防抖时间,触发第1次请求
jest.advanceTimersByTime(300);
await flushMicrotasks();
expect(global.fetch).toHaveBeenCalledTimes(1);
expect(global.fetch).toHaveBeenLastCalledWith('/api/books?query=rea', expect.any(Object));
// 输入新查询,需取消上一次请求
await user.type(input, 'c');
jest.advanceTimersByTime(300);
await flushMicrotasks();
// 触发第2次请求,并检查第1个 signal 已被中止
expect(global.fetch).toHaveBeenCalledTimes(2);
expect(signals[0].aborted).toBe(true);
// 返回第二次结果
await flushMicrotasks();
const listbox = await screen.findByRole('listbox', { name: /搜索结果/ });
expect(within(listbox).getByText('React 指南')).toBeInTheDocument();
// 释放第一个挂起请求以避免泄漏
resolve1({
ok: true,
json: async () => ({ items: [] }),
});
});
test('b) 键盘导航与选中(ArrowDown + Enter 回填输入框)', async () => {
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
global.fetch.mockResolvedValue({
ok: true,
json: async () => ({ items: [
{ id: '1', title: 'JavaScript 基础' },
{ id: '2', title: 'React 指南' },
]}),
});
render(<SearchBox endpoint="/api/books?query=" />);
const input = screen.getByRole('textbox', { name: /搜索图书/i });
await user.type(input, 'js');
jest.advanceTimersByTime(300);
const listbox = await screen.findByRole('listbox');
expect(within(listbox).getByText('JavaScript 基础')).toBeInTheDocument();
await user.keyboard('{ArrowDown}{ArrowDown}{Enter}');
expect(input).toHaveValue('React 指南');
});
test('c) 缓存命中不发请求', async () => {
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
global.fetch.mockResolvedValue({
ok: true,
json: async () => ({ items: [{ id: '2', title: 'React 指南' }] }),
});
render(<SearchBox endpoint="/api/books?query=" />);
const input = screen.getByRole('textbox', { name: /搜索图书/i });
// 第一次查询 - 触发网络请求
await user.type(input, 'react');
jest.advanceTimersByTime(300);
await Promise.resolve();
expect(global.fetch).toHaveBeenCalledTimes(1);
// 清除再输入同样的查询 - 应命中缓存,不再发请求
await user.clear(input);
await user.type(input, 'react');
// 由于缓存命中,防抖计时结束后也不应再 fetch
jest.advanceTimersByTime(350);
await Promise.resolve();
expect(global.fetch).toHaveBeenCalledTimes(1);
});
test('d) 错误重试逻辑(指数退避:300ms, 600ms;第3次成功)', async () => {
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
// 第1/2次失败,第三次成功
const seq = [
Promise.resolve({ ok: false, status: 500, text: async () => 'Server err' }),
Promise.resolve({ ok: false, status: 500, text: async () => 'Server err' }),
Promise.resolve({ ok: true, json: async () => ({ items: [{ id: '9', title: '重试成功' }] }) })
];
let call = 0;
global.fetch.mockImplementation(() => seq[call++]);
render(<SearchBox endpoint="/api/books?query=" />);
const input = screen.getByRole('textbox', { name: /搜索图书/i });
await user.type(input, 'retry');
jest.advanceTimersByTime(300); // 触发初次请求
// 第一次返回失败 -> 等待300ms
await Promise.resolve();
jest.advanceTimersByTime(300);
await Promise.resolve();
// 第二次返回失败 -> 等待600ms
jest.advanceTimersByTime(600);
await Promise.resolve();
// 第三次成功
const listbox = await screen.findByRole('listbox');
expect(within(listbox).getByText('重试成功')).toBeInTheDocument();
expect(global.fetch).toHaveBeenCalledTimes(3);
});
API 返回 JSON 示例
{
"items": [
{ "id": "1", "title": "JavaScript 基础" },
{ "id": "2", "title": "React 指南" }
]
}
简短 README(README.md)
# React 18 即时搜索组件(可访问 & 高性能)
功能
- 300ms 防抖、AbortController 取消上一个请求
- 指数退避重试(最多3次,初始300ms)
- 最近 10 条查询 LRU 缓存(Map)
- 键盘导航(↑/↓/Enter),无障碍 role=listbox/option、aria-* 属性
- 空查询隐藏结果区、清除按钮、加载/错误/空结果提示
快速开始(Vite)
1) 创建项目
npm create vite@latest my-app -- --template react
cd my-app
npm i
npm i -D @testing-library/react @testing-library/jest-dom @testing-library/user-event jest jest-environment-jsdom
2) 复制以下文件到 src:
- hooks/useSearch.js
- components/SearchBox.jsx
- components/ResultsList.jsx
- components/Search.module.css
- index.jsx
- __tests__/Search.test.jsx
3) 替换 main.jsx 为 index.jsx 或在 index.html 中挂载 root,即可运行:
npm run dev
4) 测试配置(示例 package.json)
{
"type": "module",
"scripts": {
"test": "jest"
},
"jest": {
"testEnvironment": "jsdom",
"transform": {},
"moduleNameMapper": {
"\\.(css|less|scss|sass)$": "<rootDir>/styleMock.js"
},
"setupFilesAfterEnv": ["@testing-library/jest-dom"]
}
}
新建 styleMock.js:
module.exports = new Proxy({}, { get: (t, p) => p });
5) 运行测试
npm test
API 说明
- 组件通过 GET /api/books?query=<q> 请求数据,返回 { items: [{id, title}] }
- 注意区分空数组(空结果)与网络/HTTP 错误(会显示错误信息并进行退避重试)
关键可访问性与性能优化点
教学要点(简短)
如需扩展:可加入结果高亮关键词、虚拟化长列表、SSR 预取、以及 ARIA combobox 模式等。
下面给出一个基于 Gradle 的 Java 17 控制台小项目(包名:app.taskmgr),围绕“任务管理器”场景,完整展示面向对象设计(不可变实体+构建器、策略模式、泛型+Comparator、分层架构)、线程安全的内存仓储、Javadoc、日志、受检异常、以及 JUnit 5 单元测试。示例 Main 会创建项目与任务,演示三种排序与状态变更。
一、项目结构树
二、Gradle 配置 build.gradle
plugins {
id 'java'
id 'application'
}
group = 'app.taskmgr'
version = '1.0.0'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
}
}
repositories {
mavenCentral()
}
dependencies {
testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'
}
test {
useJUnitPlatform()
}
application {
mainClass = 'app.taskmgr.AppMain'
}
settings.gradle
rootProject.name = 'taskmgr'
三、源代码
src/main/java/app/taskmgr/model/Priority.java
package app.taskmgr.model;
/**
* Task priority. Natural order: HIGH < MEDIUM < LOW (so ascending = high-first).
*/
public enum Priority {
HIGH, MEDIUM, LOW
}
src/main/java/app/taskmgr/model/Status.java
package app.taskmgr.model;
/** Task status. */
public enum Status {
OPEN, CLOSED
}
src/main/java/app/taskmgr/model/Project.java
package app.taskmgr.model;
import java.util.Objects;
/**
* Project entity (immutable).
*/
public final class Project {
private final long id;
private final String name;
public Project(long id, String name) {
this.id = id;
this.name = Objects.requireNonNull(name, "name");
}
public long getId() { return id; }
public String getName() { return name; }
@Override public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Project)) return false;
Project that = (Project) o;
return id == that.id;
}
@Override public int hashCode() { return Long.hashCode(id); }
@Override public String toString() {
return "Project{id=" + id + ", name='" + name + "'}";
}
}
src/main/java/app/taskmgr/model/User.java
package app.taskmgr.model;
import java.util.Objects;
/**
* User entity (immutable).
*/
public final class User {
private final long id;
private final String name;
public User(long id, String name) {
this.id = id;
this.name = Objects.requireNonNull(name, "name");
}
public long getId() { return id; }
public String getName() { return name; }
@Override public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof User)) return false;
User that = (User) o;
return id == that.id;
}
@Override public int hashCode() { return Long.hashCode(id); }
@Override public String toString() {
return "User{id=" + id + ", name='" + name + "'}";
}
}
src/main/java/app/taskmgr/model/Task.java
package app.taskmgr.model;
import java.time.LocalDate;
import java.util.Objects;
/**
* Task entity: immutable; modifications produce a new instance via builder.
* Key immutable field: id.
*/
public final class Task {
private final long id; // 0 means unsaved/new
private final String title;
private final String description;
private final Priority priority;
private final LocalDate dueDate;
private final Status status;
Task(long id, String title, String description, Priority priority, LocalDate dueDate, Status status) {
this.id = id;
this.title = Objects.requireNonNull(title, "title");
this.description = description;
this.priority = Objects.requireNonNull(priority, "priority");
this.dueDate = dueDate;
this.status = Objects.requireNonNull(status, "status");
}
public long getId() { return id; }
public String getTitle() { return title; }
public String getDescription() { return description; }
public Priority getPriority() { return priority; }
public LocalDate getDueDate() { return dueDate; }
public Status getStatus() { return status; }
/** Create a builder pre-filled from this task. */
public TaskBuilder toBuilder() {
return new TaskBuilder()
.id(this.id)
.title(this.title)
.description(this.description)
.priority(this.priority)
.dueDate(this.dueDate)
.status(this.status);
}
@Override public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Task)) return false;
Task that = (Task) o;
return id > 0 && id == that.id; // Persisted identity
}
@Override public int hashCode() {
return id > 0 ? Long.hashCode(id) : Objects.hash(title, priority, dueDate, status);
}
@Override public String toString() {
return "Task{id=" + id +
", title='" + title + '\'' +
", priority=" + priority +
", dueDate=" + dueDate +
", status=" + status +
'}';
}
}
src/main/java/app/taskmgr/model/TaskBuilder.java
package app.taskmgr.model;
import java.time.LocalDate;
/**
* Builder for Task. Provides sensible defaults and validation in build().
*/
public final class TaskBuilder {
private long id = 0L;
private String title;
private String description = "";
private Priority priority = Priority.MEDIUM;
private LocalDate dueDate;
private Status status = Status.OPEN;
public TaskBuilder id(long id) { this.id = id; return this; }
public TaskBuilder title(String title) { this.title = title; return this; }
public TaskBuilder description(String description) { this.description = description; return this; }
public TaskBuilder priority(Priority priority) { this.priority = priority; return this; }
public TaskBuilder dueDate(LocalDate dueDate) { this.dueDate = dueDate; return this; }
public TaskBuilder status(Status status) { this.status = status; return this; }
/**
* Build immutable Task. Validates required fields.
*/
public Task build() {
if (title == null || title.isBlank()) throw new IllegalArgumentException("title is required");
if (priority == null) throw new IllegalArgumentException("priority is required");
if (status == null) status = Status.OPEN;
if (description == null) description = "";
return new Task(id, title, description, priority, dueDate, status);
}
}
src/main/java/app/taskmgr/repository/Repository.java
package app.taskmgr.repository;
import java.util.List;
import java.util.Optional;
/**
* Generic repository interface for simple CRUD-like operations.
*/
public interface Repository<T, ID> {
Optional<T> findById(ID id);
List<T> findAll();
T save(T entity); // insert or update
boolean deleteById(ID id);
long count();
}
src/main/java/app/taskmgr/repository/InMemoryTaskRepository.java
package app.taskmgr.repository;
import app.taskmgr.model.Task;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.atomic.AtomicLong;
/**
* Thread-safe in-memory Task repository using ConcurrentHashMap.
* Assigns IDs for new tasks (id <= 0).
*/
public class InMemoryTaskRepository implements Repository<Task, Long> {
private final ConcurrentMap<Long, Task> store = new ConcurrentHashMap<>();
private final AtomicLong seq = new AtomicLong(0L);
@Override
public Optional<Task> findById(Long id) {
if (id == null) return Optional.empty();
return Optional.ofNullable(store.get(id));
}
@Override
public List<Task> findAll() {
return new ArrayList<>(store.values());
}
@Override
public Task save(Task entity) {
Task toSave = entity;
if (entity.getId() <= 0) {
long newId = seq.incrementAndGet();
toSave = entity.toBuilder().id(newId).build();
}
store.put(toSave.getId(), toSave);
return toSave;
}
@Override
public boolean deleteById(Long id) {
return store.remove(id) != null;
}
@Override
public long count() {
return store.size();
}
}
src/main/java/app/taskmgr/repository/InMemoryProjectRepository.java
package app.taskmgr.repository;
import app.taskmgr.model.Project;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
/** Simple thread-safe in-memory repository for Project. */
public class InMemoryProjectRepository implements Repository<Project, Long> {
private final ConcurrentHashMap<Long, Project> store = new ConcurrentHashMap<>();
private final AtomicLong seq = new AtomicLong(0L);
@Override
public Optional<Project> findById(Long id) {
return Optional.ofNullable(store.get(id));
}
@Override
public List<Project> findAll() {
return new ArrayList<>(store.values());
}
@Override
public Project save(Project entity) {
Project proj = entity;
if (entity.getId() <= 0) {
long id = seq.incrementAndGet();
proj = new Project(id, entity.getName());
}
store.put(proj.getId(), proj);
return proj;
}
@Override
public boolean deleteById(Long id) {
return store.remove(id) != null;
}
@Override
public long count() {
return store.size();
}
}
src/main/java/app/taskmgr/repository/InMemoryUserRepository.java
package app.taskmgr.repository;
import app.taskmgr.model.User;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
/** Simple thread-safe in-memory repository for User. */
public class InMemoryUserRepository implements Repository<User, Long> {
private final ConcurrentHashMap<Long, User> store = new ConcurrentHashMap<>();
private final AtomicLong seq = new AtomicLong(0L);
@Override
public Optional<User> findById(Long id) {
return Optional.ofNullable(store.get(id));
}
@Override
public List<User> findAll() {
return new ArrayList<>(store.values());
}
@Override
public User save(User entity) {
User u = entity;
if (entity.getId() <= 0) {
long id = seq.incrementAndGet();
u = new User(id, entity.getName());
}
store.put(u.getId(), u);
return u;
}
@Override
public boolean deleteById(Long id) {
return store.remove(id) != null;
}
@Override
public long count() {
return store.size();
}
}
src/main/java/app/taskmgr/exception/InvalidTaskOperationException.java
package app.taskmgr.exception;
/**
* Checked exception for invalid task operations (precondition violations, illegal state transitions).
*/
public class InvalidTaskOperationException extends Exception {
public InvalidTaskOperationException(String message) {
super(message);
}
}
src/main/java/app/taskmgr/service/sort/TaskSortStrategy.java
package app.taskmgr.service.sort;
import app.taskmgr.model.Task;
import java.util.Comparator;
/**
* Strategy for sorting tasks.
*/
public interface TaskSortStrategy {
Comparator<Task> comparator();
default String name() {
return this.getClass().getSimpleName();
}
}
src/main/java/app/taskmgr/service/sort/ByPriorityStrategy.java
package app.taskmgr.service.sort;
import app.taskmgr.model.Task;
import java.util.Comparator;
/**
* Sort tasks by priority (HIGH first), then dueDate asc, then title asc.
*/
public class ByPriorityStrategy implements TaskSortStrategy {
private static final Comparator<Task> CMP =
Comparator.comparing(Task::getPriority) // enum: HIGH < MEDIUM < LOW
.thenComparing(Task::getDueDate, Comparator.nullsLast(Comparator.naturalOrder()))
.thenComparing(Task::getTitle, String.CASE_INSENSITIVE_ORDER);
@Override
public Comparator<Task> comparator() { return CMP; }
}
src/main/java/app/taskmgr/service/sort/ByDueDateStrategy.java
package app.taskmgr.service.sort;
import app.taskmgr.model.Task;
import java.util.Comparator;
/**
* Sort by due date asc (nulls last), then priority, then title.
*/
public class ByDueDateStrategy implements TaskSortStrategy {
private static final Comparator<Task> CMP =
Comparator.comparing(Task::getDueDate, Comparator.nullsLast(Comparator.naturalOrder()))
.thenComparing(Task::getPriority)
.thenComparing(Task::getTitle, String.CASE_INSENSITIVE_ORDER);
@Override
public Comparator<Task> comparator() { return CMP; }
}
src/main/java/app/taskmgr/service/sort/ByTitleStrategy.java
package app.taskmgr.service.sort;
import app.taskmgr.model.Task;
import java.util.Comparator;
/**
* Sort by title (case-insensitive), then due date.
*/
public class ByTitleStrategy implements TaskSortStrategy {
private static final Comparator<Task> CMP =
Comparator.comparing(Task::getTitle, String.CASE_INSENSITIVE_ORDER)
.thenComparing(Task::getDueDate, Comparator.nullsLast(Comparator.naturalOrder()));
@Override
public Comparator<Task> comparator() { return CMP; }
}
src/main/java/app/taskmgr/service/sort/Sorter.java
package app.taskmgr.service.sort;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
/**
* Generic reusable sorter using Comparator and generics.
*/
public final class Sorter<T> {
public List<T> sort(List<T> list, Comparator<? super T> comparator) {
var copy = new ArrayList<>(list);
copy.sort(comparator);
return copy;
// original list remains unchanged (functional style)
}
}
src/main/java/app/taskmgr/service/TaskService.java
package app.taskmgr.service;
import app.taskmgr.exception.InvalidTaskOperationException;
import app.taskmgr.model.*;
import app.taskmgr.repository.Repository;
import app.taskmgr.service.sort.Sorter;
import app.taskmgr.service.sort.TaskSortStrategy;
import java.time.LocalDate;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* Application service for Tasks: creation, update, close, sorting, assign to project.
* Uses repositories and enforces preconditions and state transitions.
*/
public class TaskService {
private static final Logger log = Logger.getLogger(TaskService.class.getName());
private final Repository<Task, Long> taskRepo;
private final Repository<Project, Long> projectRepo;
private final Repository<User, Long> userRepo;
// Project to task IDs mapping (thread-safe)
private final ConcurrentHashMap<Long, Set<Long>> projectTasks = new ConcurrentHashMap<>();
private final Sorter<Task> sorter = new Sorter<>();
public TaskService(Repository<Task, Long> taskRepo,
Repository<Project, Long> projectRepo,
Repository<User, Long> userRepo) {
this.taskRepo = taskRepo;
this.projectRepo = projectRepo;
this.userRepo = userRepo;
}
/** Create a project. */
public Project createProject(String name) throws InvalidTaskOperationException {
if (name == null || name.isBlank()) throw new InvalidTaskOperationException("Project name required");
Project p = new Project(0L, name);
p = projectRepo.save(p);
projectTasks.putIfAbsent(p.getId(), ConcurrentHashMap.newKeySet());
log.info(() -> "Created project: " + p);
return p;
}
/** Create a user. */
public User createUser(String name) throws InvalidTaskOperationException {
if (name == null || name.isBlank()) throw new InvalidTaskOperationException("User name required");
User u = new User(0L, name);
u = userRepo.save(u);
log.info(() -> "Created user: " + u);
return u;
}
/**
* Create a task and assign to a project.
* Preconditions: title not blank; status must be OPEN at creation; due date not in the past (if provided).
*/
public Task createTask(TaskBuilder builder, long projectId, long userId) throws InvalidTaskOperationException {
Objects.requireNonNull(builder, "builder");
Project p = projectRepo.findById(projectId)
.orElseThrow(() -> new InvalidTaskOperationException("Project not found: " + projectId));
userRepo.findById(userId)
.orElseThrow(() -> new InvalidTaskOperationException("User not found: " + userId));
Task task = builder.build();
if (task.getStatus() == Status.CLOSED) {
throw new InvalidTaskOperationException("Cannot create task in CLOSED state");
}
if (task.getDueDate() != null && task.getDueDate().isBefore(LocalDate.now())) {
throw new InvalidTaskOperationException("Due date cannot be in the past");
}
Task saved = taskRepo.save(task);
projectTasks.computeIfAbsent(p.getId(), k -> ConcurrentHashMap.newKeySet())
.add(saved.getId());
log.info(() -> "Created task " + saved + " in project " + p.getName());
return saved;
}
/**
* Update editable fields of a task (title, description, priority, due date).
* Closed tasks cannot be updated.
*/
public Task updateTask(long taskId, String newTitle, String newDescription,
Priority newPriority, LocalDate newDueDate) throws InvalidTaskOperationException {
Task existing = taskRepo.findById(taskId)
.orElseThrow(() -> new InvalidTaskOperationException("Task not found: " + taskId));
if (existing.getStatus() == Status.CLOSED) {
throw new InvalidTaskOperationException("Cannot update a CLOSED task");
}
if (newTitle == null || newTitle.isBlank()) {
throw new InvalidTaskOperationException("Title cannot be blank");
}
if (newDueDate != null && newDueDate.isBefore(LocalDate.now())) {
throw new InvalidTaskOperationException("Due date cannot be in the past");
}
if (newPriority == null) {
throw new InvalidTaskOperationException("Priority required");
}
Task updated = existing.toBuilder()
.title(newTitle)
.description(newDescription == null ? "" : newDescription)
.priority(newPriority)
.dueDate(newDueDate)
.build();
updated = taskRepo.save(updated);
log.info(() -> "Updated task " + updated);
return updated;
}
/** Close a task (state transition OPEN -> CLOSED only). */
public Task closeTask(long taskId) throws InvalidTaskOperationException {
Task existing = taskRepo.findById(taskId)
.orElseThrow(() -> new InvalidTaskOperationException("Task not found: " + taskId));
if (existing.getStatus() == Status.CLOSED) {
throw new InvalidTaskOperationException("Task already CLOSED");
}
Task closed = existing.toBuilder().status(Status.CLOSED).build();
closed = taskRepo.save(closed);
log.info(() -> "Closed task " + closed);
return closed;
}
/** Get tasks of a project. */
public List<Task> findTasksByProject(long projectId) throws InvalidTaskOperationException {
if (projectRepo.findById(projectId).isEmpty())
throw new InvalidTaskOperationException("Project not found: " + projectId);
var ids = projectTasks.getOrDefault(projectId, Set.of());
List<Task> result = new ArrayList<>();
for (Long id : ids) {
taskRepo.findById(id).ifPresent(result::add);
}
return result;
}
/** Sort tasks using a strategy. */
public List<Task> sortTasks(List<Task> tasks, TaskSortStrategy strategy) {
Objects.requireNonNull(strategy, "strategy");
try {
return sorter.sort(tasks, strategy.comparator());
} catch (Exception e) {
log.log(Level.WARNING, "Sorting failed", e);
return List.copyOf(tasks);
}
}
/** Get all tasks (for convenience). */
public List<Task> findAllTasks() {
return taskRepo.findAll();
}
}
src/main/java/app/taskmgr/AppMain.java
package app.taskmgr;
import app.taskmgr.exception.InvalidTaskOperationException;
import app.taskmgr.model.*;
import app.taskmgr.repository.InMemoryProjectRepository;
import app.taskmgr.repository.InMemoryTaskRepository;
import app.taskmgr.repository.InMemoryUserRepository;
import app.taskmgr.service.TaskService;
import app.taskmgr.service.sort.ByDueDateStrategy;
import app.taskmgr.service.sort.ByPriorityStrategy;
import app.taskmgr.service.sort.ByTitleStrategy;
import java.time.LocalDate;
import java.util.List;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* Console demo: create tasks, assign to project, sort by different strategies, change status.
*/
public class AppMain {
private static final Logger log = Logger.getLogger(AppMain.class.getName());
public static void main(String[] args) {
// Basic logger config
Logger.getLogger("").getHandlers()[0].setLevel(Level.INFO);
var taskRepo = new InMemoryTaskRepository();
var projectRepo = new InMemoryProjectRepository();
var userRepo = new InMemoryUserRepository();
var service = new TaskService(taskRepo, projectRepo, userRepo);
try {
Project demo = service.createProject("Team Tools");
User alice = service.createUser("Alice");
// Create at least 5 tasks with various priority and dates
Task t1 = service.createTask(new TaskBuilder()
.title("Design CLI layout")
.description("Define command structure and help message")
.priority(Priority.HIGH)
.dueDate(LocalDate.now().plusDays(3)), demo.getId(), alice.getId());
Task t2 = service.createTask(new TaskBuilder()
.title("Implement sorting")
.description("Priority, due date, title")
.priority(Priority.MEDIUM)
.dueDate(LocalDate.now().plusDays(5)), demo.getId(), alice.getId());
Task t3 = service.createTask(new TaskBuilder()
.title("Write unit tests")
.description("Cover builder, service, strategies, concurrency")
.priority(Priority.HIGH)
.dueDate(LocalDate.now().plusDays(2)), demo.getId(), alice.getId());
Task t4 = service.createTask(new TaskBuilder()
.title("Add logging")
.description("Use java.util.logging")
.priority(Priority.LOW)
.dueDate(LocalDate.now().plusDays(7)), demo.getId(), alice.getId());
Task t5 = service.createTask(new TaskBuilder()
.title("Polish docs")
.description("README and Javadoc")
.priority(Priority.MEDIUM)
.dueDate(LocalDate.now().plusDays(4)), demo.getId(), alice.getId());
log.info("All tasks (unsorted):");
print(service.findTasksByProject(demo.getId()));
log.info("Sorted by priority:");
print(service.sortTasks(service.findTasksByProject(demo.getId()), new ByPriorityStrategy()));
log.info("Sorted by due date:");
print(service.sortTasks(service.findTasksByProject(demo.getId()), new ByDueDateStrategy()));
log.info("Sorted by title:");
print(service.sortTasks(service.findTasksByProject(demo.getId()), new ByTitleStrategy()));
// Update and close
Task updated = service.updateTask(t2.getId(), "Implement strategies", "Refactor into strategy classes",
Priority.HIGH, LocalDate.now().plusDays(1));
log.info("Updated: " + updated);
Task closed = service.closeTask(t1.getId());
log.info("Closed: " + closed);
log.info("After update/close, by priority:");
print(service.sortTasks(service.findTasksByProject(demo.getId()), new ByPriorityStrategy()));
} catch (InvalidTaskOperationException e) {
log.log(Level.SEVERE, "Operation failed: " + e.getMessage(), e);
}
}
private static void print(List<Task> tasks) {
tasks.forEach(t -> System.out.println(" - " + t));
}
}
四、测试代码
src/test/java/app/taskmgr/TaskBuilderTest.java
package app.taskmgr;
import app.taskmgr.model.*;
import org.junit.jupiter.api.Test;
import java.time.LocalDate;
import static org.junit.jupiter.api.Assertions.*;
/**
* Test builder and entity immutability behavior.
*/
public class TaskBuilderTest {
@Test
void buildTaskWithDefaults() {
Task t = new TaskBuilder()
.title("Sample")
.priority(Priority.MEDIUM)
.build();
assertEquals("Sample", t.getTitle());
assertEquals(Priority.MEDIUM, t.getPriority());
assertEquals(Status.OPEN, t.getStatus());
assertEquals(0L, t.getId());
}
@Test
void immutabilityCopyOnChange() {
Task t1 = new TaskBuilder()
.title("A")
.description("d")
.priority(Priority.HIGH)
.dueDate(LocalDate.now().plusDays(3))
.build();
Task t2 = t1.toBuilder().title("B").build();
assertNotSame(t1, t2);
assertEquals("A", t1.getTitle());
assertEquals("B", t2.getTitle());
}
@Test
void equalsHashCodeForPersistedId() {
Task t1 = new TaskBuilder().id(1).title("X").priority(Priority.HIGH).build();
Task t2 = new TaskBuilder().id(1).title("Y").priority(Priority.LOW).build();
assertEquals(t1, t2);
assertEquals(t1.hashCode(), t2.hashCode());
}
}
src/test/java/app/taskmgr/TaskServiceTest.java
package app.taskmgr;
import app.taskmgr.exception.InvalidTaskOperationException;
import app.taskmgr.model.*;
import app.taskmgr.repository.InMemoryProjectRepository;
import app.taskmgr.repository.InMemoryTaskRepository;
import app.taskmgr.repository.InMemoryUserRepository;
import app.taskmgr.service.TaskService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.time.LocalDate;
import static org.junit.jupiter.api.Assertions.*;
/**
* Test service flows for create/update/close.
*/
public class TaskServiceTest {
TaskService service;
Project project;
User user;
@BeforeEach
void setup() throws InvalidTaskOperationException {
service = new TaskService(new InMemoryTaskRepository(),
new InMemoryProjectRepository(),
new InMemoryUserRepository());
project = service.createProject("Demo");
user = service.createUser("Tester");
}
@Test
void createUpdateCloseFlow() throws Exception {
Task created = service.createTask(new TaskBuilder()
.title("T1")
.priority(Priority.MEDIUM)
.dueDate(LocalDate.now().plusDays(2)), project.getId(), user.getId());
assertTrue(created.getId() > 0);
assertEquals(Status.OPEN, created.getStatus());
Task updated = service.updateTask(created.getId(), "T1-upd", "desc", Priority.HIGH, LocalDate.now().plusDays(3));
assertEquals("T1-upd", updated.getTitle());
assertEquals(Priority.HIGH, updated.getPriority());
Task closed = service.closeTask(updated.getId());
assertEquals(Status.CLOSED, closed.getStatus());
assertThrows(InvalidTaskOperationException.class, () -> service.updateTask(closed.getId(),
"again", "x", Priority.LOW, LocalDate.now().plusDays(1)));
assertThrows(InvalidTaskOperationException.class, () -> service.closeTask(closed.getId()));
}
}
src/test/java/app/taskmgr/SortingStrategyTest.java
package app.taskmgr;
import app.taskmgr.model.*;
import app.taskmgr.service.sort.ByDueDateStrategy;
import app.taskmgr.service.sort.ByPriorityStrategy;
import app.taskmgr.service.sort.ByTitleStrategy;
import app.taskmgr.service.sort.Sorter;
import org.junit.jupiter.api.Test;
import java.time.LocalDate;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
/**
* Test that strategies produce expected ordering.
*/
public class SortingStrategyTest {
private Task t(String title, Priority p, int duePlusDays) {
return new TaskBuilder()
.title(title)
.priority(p)
.dueDate(LocalDate.now().plusDays(duePlusDays))
.build();
}
@Test
void byPriority() {
List<Task> src = List.of(
t("b", Priority.MEDIUM, 3),
t("a", Priority.HIGH, 5),
t("c", Priority.LOW, 1)
);
var sorted = new Sorter<Task>().sort(src, new ByPriorityStrategy().comparator());
assertEquals("a", sorted.get(0).getTitle()); // HIGH first
}
@Test
void byDueDate() {
List<Task> src = List.of(
t("x", Priority.MEDIUM, 5),
t("y", Priority.HIGH, 1),
t("z", Priority.LOW, 3)
);
var sorted = new Sorter<Task>().sort(src, new ByDueDateStrategy().comparator());
assertEquals("y", sorted.get(0).getTitle()); // earliest due date first
}
@Test
void byTitle() {
List<Task> src = List.of(
t("Zoo", Priority.LOW, 1),
t("alpha", Priority.HIGH, 2),
t("Beta", Priority.MEDIUM, 3)
);
var sorted = new Sorter<Task>().sort(src, new ByTitleStrategy().comparator());
assertEquals("alpha", sorted.get(0).getTitle());
}
}
src/test/java/app/taskmgr/RepositoryConcurrencyTest.java
package app.taskmgr;
import app.taskmgr.model.Priority;
import app.taskmgr.model.Task;
import app.taskmgr.model.TaskBuilder;
import app.taskmgr.repository.InMemoryTaskRepository;
import org.junit.jupiter.api.Test;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import static org.junit.jupiter.api.Assertions.*;
/**
* Basic concurrent safety test: concurrent inserts produce correct count.
*/
public class RepositoryConcurrencyTest {
@Test
void concurrentInserts() throws InterruptedException {
var repo = new InMemoryTaskRepository();
int threads = 10;
int perThread = 100;
CountDownLatch latch = new CountDownLatch(threads);
List<Thread> ts = new ArrayList<>();
for (int i = 0; i < threads; i++) {
Thread t = new Thread(() -> {
for (int j = 0; j < perThread; j++) {
Task task = new TaskBuilder()
.title("T-" + Thread.currentThread().getId() + "-" + j)
.priority(Priority.MEDIUM)
.dueDate(LocalDate.now().plusDays(1))
.build();
repo.save(task);
}
latch.countDown();
});
ts.add(t);
t.start();
}
latch.await();
assertEquals(threads * perThread, repo.count());
}
}
五、运行与测试
六、关键设计取舍与教学要点
七、学习建议(面向入门者)
至此,项目可直接构建运行与测试,展示了面向对象设计、策略模式、线程安全仓储、受检异常、日志与单元测试的综合实践。
提供一个高效、清晰的编程教学解决方案,帮助用户快速掌握编程概念、技术要点及最佳实践,同时提升学习效率和实践能力。
帮助零基础学员快速入门,系统学习编程概念和基础技能,为职业发展或兴趣学习打下扎实基础。
为工作中的开发者提供即时技术支持,无论是掌握新语言还是解决具体问题,都能助力效率提升。
为教育工作者提供教学辅助工具,优化教案编写与讲解细节,提升学生学习参与度和效果。
将模板生成的提示词复制粘贴到您常用的 Chat 应用(如 ChatGPT、Claude 等),即可直接对话使用,无需额外开发。适合个人快速体验和轻量使用场景。
把提示词模板转化为 API,您的程序可任意修改模板参数,通过接口直接调用,轻松实现自动化与批量处理。适合开发者集成与业务系统嵌入。
在 MCP client 中配置对应的 server 地址,让您的 AI 应用自动调用提示词模板。适合高级用户和团队协作,让提示词在不同 AI 工具间无缝衔接。
半价获取高级提示词-优惠即将到期