Skip to content
项目
群组
代码片段
帮助
当前项目
正在载入...
登录 / 注册
切换导航面板
C
center-book
项目
项目
详情
活动
周期分析
仓库
仓库
文件
提交
分支
标签
贡献者
图表
比较
统计图
议题
0
议题
0
列表
看板
标记
里程碑
合并请求
0
合并请求
0
CI / CD
CI / CD
流水线
作业
日程
统计图
Wiki
Wiki
代码片段
代码片段
成员
成员
折叠边栏
关闭边栏
活动
图像
聊天
创建新问题
作业
提交
问题看板
Open sidebar
EzijingWeb
center-book
Commits
2840a7ad
提交
2840a7ad
authored
7月 20, 2026
作者:
王鹏飞
浏览文件
操作
浏览文件
下载
电子邮件补丁
差异文件
feat(admin): add controlled AppList component
上级
f6c89058
隐藏空白字符变更
内嵌
并排
正在显示
5 个修改的文件
包含
498 行增加
和
0 行删除
+498
-0
AppList.less
apps/admin/src/shared/components/AppList/AppList.less
+41
-0
AppList.test.tsx
apps/admin/src/shared/components/AppList/AppList.test.tsx
+286
-0
AppList.tsx
apps/admin/src/shared/components/AppList/AppList.tsx
+118
-0
index.ts
apps/admin/src/shared/components/AppList/index.ts
+2
-0
types.ts
apps/admin/src/shared/components/AppList/types.ts
+51
-0
没有找到文件。
apps/admin/src/shared/components/AppList/AppList.less
0 → 100644
浏览文件 @
2840a7ad
.app-list {
display: flex;
flex-direction: column;
gap: 16px;
&__filters {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
}
&__filter-items {
display: flex;
flex: 1;
flex-wrap: wrap;
align-items: flex-end;
gap: 16px;
}
&__filter-item {
display: flex;
flex-direction: column;
gap: 8px;
}
&__filter-label {
color: rgb(0 0 0 / 88%);
}
&__toolbar {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
&__blocking-error {
padding: 48px 24px;
}
}
apps/admin/src/shared/components/AppList/AppList.test.tsx
0 → 100644
浏览文件 @
2840a7ad
import
{
cleanup
,
fireEvent
,
render
,
screen
,
within
}
from
"@testing-library/react"
;
import
userEvent
from
"@testing-library/user-event"
;
import
{
Button
,
Input
,
Switch
}
from
"antd"
;
import
{
afterEach
,
describe
,
expect
,
it
,
vi
}
from
"vitest"
;
import
{
AppList
}
from
"./AppList"
;
import
type
{
AppListFilterItem
}
from
"./types"
;
Object
.
defineProperty
(
window
,
"matchMedia"
,
{
writable
:
true
,
value
:
vi
.
fn
().
mockImplementation
((
query
:
string
)
=>
({
matches
:
false
,
media
:
query
,
onchange
:
null
,
addListener
:
vi
.
fn
(),
removeListener
:
vi
.
fn
(),
addEventListener
:
vi
.
fn
(),
removeEventListener
:
vi
.
fn
(),
dispatchEvent
:
vi
.
fn
(),
})),
});
const
getComputedStyle
=
window
.
getComputedStyle
;
Object
.
defineProperty
(
window
,
"getComputedStyle"
,
{
value
:
(
element
:
Element
)
=>
getComputedStyle
(
element
),
});
afterEach
(
cleanup
);
type
Book
=
{
code
:
string
;
title
:
string
;
};
type
Filters
=
{
keyword
:
string
;
count
:
number
;
enabled
:
boolean
;
};
const
columns
=
[
{
title
:
"书名"
,
dataIndex
:
"title"
,
},
];
const
rows
:
Book
[]
=
[{
code
:
"book-1"
,
title
:
"受控组件设计"
}];
function
renderList
(
overrides
:
Record
<
string
,
unknown
>
=
{})
{
return
render
(
<
AppList
<
Book
,
Filters
>
rowKey="code"
columns=
{
columns
}
data=
{
{
rows
,
total
:
41
,
loading
:
false
}
}
{
...
overrides
}
/
>
,
);
}
describe("AppList", () =
>
{
it
(
"renders rows and columns with the explicit rowKey"
,
()
=>
{
const
{
container
}
=
renderList
();
expect
(
screen
.
getByRole
(
"columnheader"
,
{
name
:
"书名"
})).
toBeInTheDocument
();
expect
(
screen
.
getByRole
(
"cell"
,
{
name
:
"受控组件设计"
})).
toBeInTheDocument
();
expect
(
container
.
querySelector
(
'tr[data-row-key="book-1"]'
)).
toBeInTheDocument
();
});
it
(
"renders controlled filter values and only reports changes"
,
async
()
=>
{
const
user
=
userEvent
.
setup
();
const
onChange
=
vi
.
fn
();
const
items
:
AppListFilterItem
<
Filters
>
[]
=
[
{
name
:
"keyword"
,
label
:
"关键词"
,
render
:
(
field
)
=>
(
<
Input
aria
-
label=
"关键词"
value=
{
field
.
value
}
onChange=
{
(
event
)
=>
field
.
onChange
(
event
.
target
.
value
)
}
/>
),
},
];
renderList
({
filters
:
{
value
:
{
keyword
:
"原值"
,
count
:
0
,
enabled
:
false
},
items
,
onChange
,
onSearch
:
vi
.
fn
(),
onReset
:
vi
.
fn
(),
onRefresh
:
vi
.
fn
(),
},
});
const
input
=
screen
.
getByRole
(
"textbox"
,
{
name
:
"关键词"
});
expect
(
input
).
toHaveValue
(
"原值"
);
await
user
.
clear
(
input
);
expect
(
onChange
).
toHaveBeenLastCalledWith
({
keyword
:
""
,
count
:
0
,
enabled
:
false
});
expect
(
input
).
toHaveValue
(
"原值"
);
});
it
(
"reports search, reset, and refresh without doing work itself"
,
async
()
=>
{
const
user
=
userEvent
.
setup
();
const
onSearch
=
vi
.
fn
();
const
onReset
=
vi
.
fn
();
const
onRefresh
=
vi
.
fn
();
renderList
({
filters
:
{
value
:
{
keyword
:
""
,
count
:
0
,
enabled
:
false
},
items
:
[],
onChange
:
vi
.
fn
(),
onSearch
,
onReset
,
onRefresh
,
},
});
await
user
.
click
(
screen
.
getByRole
(
"button"
,
{
name
:
/搜
\s
*索/
}));
await
user
.
click
(
screen
.
getByRole
(
"button"
,
{
name
:
/重
\s
*置/
}));
await
user
.
click
(
screen
.
getByRole
(
"button"
,
{
name
:
/刷
\s
*新/
}));
expect
(
onSearch
).
toHaveBeenCalledTimes
(
1
);
expect
(
onReset
).
toHaveBeenCalledTimes
(
1
);
expect
(
onRefresh
).
toHaveBeenCalledTimes
(
1
);
});
it
(
"uses controlled page, pageSize, and total and reports pagination changes"
,
async
()
=>
{
const
user
=
userEvent
.
setup
();
const
onChange
=
vi
.
fn
();
const
onPageSizeChange
=
vi
.
fn
();
renderList
({
pagination
:
{
page
:
2
,
pageSize
:
10
,
pageSizeOptions
:
[
10
,
20
,
30
,
40
],
onChange
,
onPageSizeChange
,
},
});
expect
(
screen
.
getByText
(
"共 41 条"
)).
toBeInTheDocument
();
expect
(
screen
.
getByTitle
(
"2"
)).
toHaveClass
(
"ant-pagination-item-active"
);
await
user
.
click
(
screen
.
getByTitle
(
"3"
));
expect
(
onChange
).
toHaveBeenCalledWith
(
3
);
expect
(
screen
.
getByTitle
(
"2"
)).
toHaveClass
(
"ant-pagination-item-active"
);
const
sizeSelector
=
screen
.
getByRole
(
"combobox"
,
{
name
:
"Page Size"
});
fireEvent
.
mouseDown
(
sizeSelector
);
await
user
.
click
(
await
screen
.
findByRole
(
"option"
,
{
name
:
"20 / page"
}));
expect
(
onPageSizeChange
).
toHaveBeenCalledWith
(
20
);
expect
(
sizeSelector
.
closest
(
".ant-select"
)).
toHaveTextContent
(
"10 / page"
);
});
it
(
"prioritizes loading, then renders retryable error and empty states"
,
async
()
=>
{
const
user
=
userEvent
.
setup
();
const
onRetry
=
vi
.
fn
();
const
{
container
,
rerender
}
=
render
(
<
AppList
<
Book
,
Filters
>
rowKey="code"
columns=
{
columns
}
data=
{
{
rows
:
[],
total
:
0
,
loading
:
true
,
error
:
new
Error
(
"网络异常"
)
}
}
onRetry=
{
onRetry
}
/
>
,
);
expect(container.querySelector(".ant-spin-spinning")).toBeInTheDocument();
expect(screen.queryByText("网络异常")).not.toBeInTheDocument();
rerender(
<
AppList
<
Book
,
Filters
>
rowKey="code"
columns=
{
columns
}
data=
{
{
rows
:
[],
total
:
0
,
loading
:
false
,
error
:
new
Error
(
"网络异常"
)
}
}
onRetry=
{
onRetry
}
/
>
,
);
expect(screen.getByText("网络异常")).toBeInTheDocument();
await user.click(screen.getByRole("button",
{
name
:
/
重\
s
*
试
/
}
));
expect(onRetry).toHaveBeenCalledTimes(1);
rerender(
<
AppList
<
Book
,
Filters
>
rowKey="code"
columns=
{
columns
}
data=
{
{
rows
:
[],
total
:
0
,
loading
:
false
}
}
/
>
,
);
expect(screen.getByText("暂无数据")).toBeInTheDocument();
});
it("keeps existing rows visible when a refresh fails", () =
>
{
renderList
({
data
:
{
rows
,
total
:
1
,
loading
:
false
,
error
:
new
Error
(
"刷新失败"
)
},
onRetry
:
vi
.
fn
(),
});
expect
(
screen
.
getByRole
(
"alert"
)).
toHaveTextContent
(
"刷新失败"
);
expect
(
screen
.
getByRole
(
"cell"
,
{
name
:
"受控组件设计"
})).
toBeInTheDocument
();
expect
(
screen
.
getByRole
(
"button"
,
{
name
:
/重
\s
*试/
})).
toBeInTheDocument
();
}
);
it("renders toolbar without filters and forwards rowSelection", async () =
>
{
const
user
=
userEvent
.
setup
();
const
onSelectionChange
=
vi
.
fn
();
renderList
({
toolbar
:
<
Button
>
新增图书
</
Button
>,
selection
:
{
onChange
:
onSelectionChange
},
});
expect
(
screen
.
getByRole
(
"button"
,
{
name
:
"新增图书"
})).
toBeInTheDocument
();
const
checkboxes
=
screen
.
getAllByRole
(
"checkbox"
);
await
user
.
click
(
checkboxes
[
1
]);
expect
(
onSelectionChange
).
toHaveBeenCalledWith
([
"book-1"
],
rows
,
{
type
:
"single"
});
}
);
it("preserves controlled 0 and false values when another filter changes", async () =
>
{
const
user
=
userEvent
.
setup
();
const
onChange
=
vi
.
fn
();
const
items
:
AppListFilterItem
<
Filters
>
[]
=
[
{
name
:
"keyword"
,
label
:
"关键词"
,
render
:
(
field
)
=>
<
Button
onClick=
{
()
=>
field
.
onChange
(
"新值"
)
}
>
修改关键词
</
Button
>,
},
{
name
:
"count"
,
label
:
"数量"
,
render
:
(
field
)
=>
<
span
data
-
testid=
"count-value"
>
{
field
.
value
}
</
span
>,
},
{
name
:
"enabled"
,
label
:
"启用"
,
render
:
(
field
)
=>
(
<
Switch
aria
-
label=
"启用"
checked=
{
field
.
value
}
onChange=
{
field
.
onChange
}
/>
),
},
];
renderList
({
filters
:
{
value
:
{
keyword
:
""
,
count
:
0
,
enabled
:
false
},
items
,
onChange
,
onSearch
:
vi
.
fn
(),
onReset
:
vi
.
fn
(),
onRefresh
:
vi
.
fn
(),
},
});
expect
(
screen
.
getByTestId
(
"count-value"
)).
toHaveTextContent
(
"0"
);
expect
(
screen
.
getByRole
(
"switch"
,
{
name
:
"启用"
})).
not
.
toBeChecked
();
await
user
.
click
(
screen
.
getByRole
(
"button"
,
{
name
:
"修改关键词"
}));
expect
(
onChange
).
toHaveBeenCalledWith
({
keyword
:
"新值"
,
count
:
0
,
enabled
:
false
});
}
);
it("does not let tableProps override controlled table data", () =
>
{
renderList
({
tableProps
:
{
columns
:
[{
title
:
"错误列"
,
dataIndex
:
"title"
}],
dataSource
:
[{
code
:
"wrong"
,
title
:
"错误数据"
}],
loading
:
false
,
pagination
:
false
,
rowKey
:
"wrongKey"
,
},
});
const
table
=
screen
.
getByRole
(
"table"
);
expect
(
within
(
table
).
getByText
(
"受控组件设计"
)).
toBeInTheDocument
();
expect
(
within
(
table
).
queryByText
(
"错误数据"
)).
not
.
toBeInTheDocument
();
expect
(
within
(
table
).
queryByText
(
"错误列"
)).
not
.
toBeInTheDocument
();
}
);
}
);
apps/admin/src/shared/components/AppList/AppList.tsx
0 → 100644
浏览文件 @
2840a7ad
import
{
Alert
,
Button
,
Empty
,
Space
,
Table
}
from
"antd"
;
import
type
{
TablePaginationConfig
}
from
"antd/es/table"
;
import
type
{
AppListFilterItem
,
AppListProps
}
from
"./types"
;
import
"./AppList.less"
;
const
DEFAULT_PAGE_SIZE_OPTIONS
=
[
10
,
20
,
30
,
40
];
export
function
AppList
<
Row
extends
object
,
Filters
extends
object
=
Record
<
string
,
never
>>
({
rowKey
,
columns
,
data
,
filters
,
pagination
,
selection
,
toolbar
,
onRetry
,
tableProps
,
}:
AppListProps
<
Row
,
Filters
>
)
{
const
hasBlockingError
=
!
data
.
loading
&&
data
.
error
&&
data
.
rows
.
length
===
0
;
const
hasRefreshError
=
!
data
.
loading
&&
data
.
error
&&
data
.
rows
.
length
>
0
;
const
tablePagination
:
false
|
TablePaginationConfig
=
pagination
?
{
current
:
pagination
.
page
,
pageSize
:
pagination
.
pageSize
,
total
:
data
.
total
,
pageSizeOptions
:
pagination
.
pageSizeOptions
??
DEFAULT_PAGE_SIZE_OPTIONS
,
showQuickJumper
:
true
,
showSizeChanger
:
true
,
showTotal
:
(
total
)
=>
`共
${
total
}
条`
,
onChange
:
(
page
,
pageSize
)
=>
{
if
(
pageSize
!==
pagination
.
pageSize
)
{
pagination
.
onPageSizeChange
(
pageSize
);
return
;
}
pagination
.
onChange
(
page
);
},
}
:
false
;
const
updateFilter
=
<
Name
extends
keyof
Filters
>
(
item: AppListFilterItem
<
Filters
>
&
{
name
:
Name
}
,
value: Filters[Name],
) =
>
{
filters
?.
onChange
({
...
filters
.
value
,
[
item
.
name
]:
value
});
}
;
return (
<
div
className=
"app-list"
>
{
filters
?
(
<
section
className=
"app-list__filters"
aria
-
label=
"筛选条件"
>
<
div
className=
"app-list__filter-items"
>
{
filters
.
items
.
map
((
item
)
=>
(
<
div
className=
"app-list__filter-item"
key=
{
String
(
item
.
name
)
}
>
<
span
className=
"app-list__filter-label"
>
{
item
.
label
}
</
span
>
{
item
.
render
({
value
:
filters
.
value
[
item
.
name
],
onChange
:
(
value
)
=>
updateFilter
(
item
,
value
),
})
}
</
div
>
))
}
</
div
>
<
Space
wrap
>
<
Button
type=
"primary"
onClick=
{
filters
.
onSearch
}
>
搜索
</
Button
>
<
Button
onClick=
{
filters
.
onReset
}
>
重置
</
Button
>
<
Button
onClick=
{
filters
.
onRefresh
}
>
刷新
</
Button
>
</
Space
>
</
section
>
)
:
null
}
{
toolbar
?
<
div
className=
"app-list__toolbar"
>
{
toolbar
}
</
div
>
:
null
}
{
hasRefreshError
?
(
<
Alert
className=
"app-list__error"
type=
"error"
showIcon
message=
{
data
.
error
?.
message
}
action=
{
onRetry
?
(
<
Button
size=
"small"
danger
onClick=
{
onRetry
}
>
重试
</
Button
>
)
:
null
}
/>
)
:
null
}
{
hasBlockingError
?
(
<
div
className=
"app-list__blocking-error"
role=
"alert"
>
<
Empty
description=
{
data
.
error
?.
message
}
>
{
onRetry
?
(
<
Button
type=
"primary"
danger
onClick=
{
onRetry
}
>
重试
</
Button
>
)
:
null
}
</
Empty
>
</
div
>
)
:
(
<
Table
<
Row
>
{
...
tableProps
}
rowKey=
{
rowKey
}
columns=
{
columns
}
dataSource=
{
data
.
rows
}
loading=
{
data
.
loading
}
pagination=
{
tablePagination
}
rowSelection=
{
selection
}
locale=
{
{
...
tableProps
?.
locale
,
emptyText
:
<
Empty
description=
"暂无数据"
/>
}
}
/
>
)
}
</
div
>
);
}
apps/admin/src/shared/components/AppList/index.ts
0 → 100644
浏览文件 @
2840a7ad
export
{
AppList
}
from
"./AppList"
;
export
type
{
AppListFilterField
,
AppListFilterItem
,
AppListProps
}
from
"./types"
;
apps/admin/src/shared/components/AppList/types.ts
0 → 100644
浏览文件 @
2840a7ad
import
type
{
ColumnsType
,
TableProps
}
from
"antd/es/table"
;
import
type
{
TableRowSelection
}
from
"antd/es/table/interface"
;
import
type
React
from
"react"
;
export
interface
AppListFilterField
<
Value
>
{
value
:
Value
;
onChange
:
(
next
:
Value
)
=>
void
;
}
export
type
AppListFilterItem
<
Filters
extends
object
>
=
{
[
Name
in
keyof
Filters
]:
{
name
:
Name
;
label
:
React
.
ReactNode
;
render
:
(
field
:
AppListFilterField
<
Filters
[
Name
]
>
)
=>
React
.
ReactNode
;
};
}[
keyof
Filters
];
export
interface
AppListProps
<
Row
extends
object
,
Filters
extends
object
=
Record
<
string
,
never
>>
{
rowKey
:
string
|
((
row
:
Row
)
=>
React
.
Key
);
columns
:
ColumnsType
<
Row
>
;
data
:
{
rows
:
Row
[];
total
:
number
;
loading
:
boolean
;
error
?:
Error
|
null
;
};
filters
?:
{
value
:
Filters
;
items
:
AppListFilterItem
<
Filters
>
[];
onChange
:
(
next
:
Filters
)
=>
void
;
onSearch
:
()
=>
void
;
onReset
:
()
=>
void
;
onRefresh
:
()
=>
void
;
};
pagination
?:
|
false
|
{
page
:
number
;
pageSize
:
number
;
pageSizeOptions
?:
number
[];
onChange
:
(
page
:
number
)
=>
void
;
onPageSizeChange
:
(
pageSize
:
number
)
=>
void
;
};
selection
?:
TableRowSelection
<
Row
>
;
toolbar
?:
React
.
ReactNode
;
onRetry
?:
()
=>
void
;
tableProps
?:
Omit
<
TableProps
<
Row
>
,
"rowKey"
|
"columns"
|
"dataSource"
|
"loading"
|
"pagination"
|
"rowSelection"
>
;
}
编写
预览
Markdown
格式
0%
重试
或
添加新文件
添加附件
取消
您添加了
0
人
到此讨论。请谨慎行事。
请先完成此评论的编辑!
取消
请
注册
或者
登录
后发表评论