feat(mp): 小程序统一组件库 Phase 1 — Token 扩展 + 10 组件 + useListPage Hook

三层架构组件库:
- 第 1 层原子组件:PageShell/ContentCard/StatusTag/SectionTitle/LoadingCard
- 第 2 层组合模式:PageHeader/SearchSection/CardList/PaginationBar
- 第 3 层 Hook:useListPage(列表页通用逻辑抽象)

Token 扩展:新增 --tk-card-*/--tk-gap-*/--tk-page-* 等结构化 CSS 变量,
关怀模式通过变量覆写自动生效,新组件零额外代码即获关怀支持。

设计规格:docs/superpowers/specs/2026-05-16-miniprogram-unified-components-design.md
This commit is contained in:
iven
2026-05-16 00:47:39 +08:00
parent 3fb5a77ac0
commit d758563a13
21 changed files with 1138 additions and 0 deletions

View File

@@ -0,0 +1,163 @@
import { useState, useCallback, useEffect, useRef, useMemo } from 'react';
interface UseListPageConfig<T> {
fetchFn: (params: {
page: number;
pageSize: number;
keyword: string;
filter?: string;
}) => Promise<{ items: T[]; total: number }>;
pageSize?: number;
autoLoad?: boolean;
}
interface UseListPageReturn<T> {
items: T[];
loading: boolean;
error: string | null;
isEmpty: boolean;
page: number;
total: number;
pageSize: number;
keyword: string;
setKeyword: (v: string) => void;
filter: string | undefined;
setFilter: (v: string | undefined) => void;
refresh: () => void;
listPageProps: {
items: T[];
loading: boolean;
error: string | null;
isEmpty: boolean;
searchProps: {
value: string;
onChange: (v: string) => void;
onSearch: () => void;
};
paginationProps: {
current: number;
total: number;
pageSize: number;
onChange: (page: number) => void;
};
};
}
export function useListPage<T>(
config: UseListPageConfig<T>,
): UseListPageReturn<T> {
const { fetchFn, pageSize = 10, autoLoad = true } = config;
const [items, setItems] = useState<T[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [keyword, setKeywordState] = useState('');
const [filter, setFilterState] = useState<string | undefined>(undefined);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
const mountedRef = useRef(false);
const isEmpty = !loading && items.length === 0;
const fetchData = useCallback(
async (targetPage: number, kw: string, ft?: string) => {
setLoading(true);
setError(null);
try {
const result = await fetchFn({
page: targetPage,
pageSize,
keyword: kw,
filter: ft,
});
setItems(result.items);
setTotal(result.total);
setPage(targetPage);
} catch (err) {
setError(err instanceof Error ? err.message : '加载失败');
} finally {
setLoading(false);
}
},
[fetchFn, pageSize],
);
const refresh = useCallback(() => {
fetchData(page, keyword, filter);
}, [fetchData, page, keyword, filter]);
const handleKeywordChange = useCallback(
(v: string) => {
setKeywordState(v);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
fetchData(1, v, filter);
}, 300);
},
[fetchData, filter],
);
const handleFilterChange = useCallback(
(v: string | undefined) => {
setFilterState(v);
fetchData(1, keyword, v);
},
[fetchData, keyword],
);
const handlePageChange = useCallback(
(p: number) => {
fetchData(p, keyword, filter);
},
[fetchData, keyword, filter],
);
useEffect(() => {
if (autoLoad && !mountedRef.current) {
mountedRef.current = true;
fetchData(1, '', undefined);
}
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [autoLoad, fetchData]);
const listPageProps = useMemo(
() => ({
items,
loading,
error,
isEmpty,
searchProps: {
value: keyword,
onChange: handleKeywordChange,
onSearch: refresh,
},
paginationProps: {
current: page,
total,
pageSize,
onChange: handlePageChange,
},
}),
[items, loading, error, isEmpty, keyword, handleKeywordChange, refresh, page, total, pageSize, handlePageChange],
);
return {
items,
loading,
error,
isEmpty,
page,
total,
pageSize,
keyword,
setKeyword: handleKeywordChange,
filter,
setFilter: handleFilterChange,
refresh,
listPageProps,
};
}