SelectableButtonGroup.jsx 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295
  1. /*
  2. Copyright (C) 2025 QuantumNous
  3. This program is free software: you can redistribute it and/or modify
  4. it under the terms of the GNU Affero General Public License as
  5. published by the Free Software Foundation, either version 3 of the
  6. License, or (at your option) any later version.
  7. This program is distributed in the hope that it will be useful,
  8. but WITHOUT ANY WARRANTY; without even the implied warranty of
  9. MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  10. GNU Affero General Public License for more details.
  11. You should have received a copy of the GNU Affero General Public License
  12. along with this program. If not, see <https://www.gnu.org/licenses/>.
  13. For commercial licensing, please contact support@quantumnous.com
  14. */
  15. import React, { useState, useRef, useEffect } from 'react';
  16. import { useMinimumLoadingTime } from '../../../hooks/common/useMinimumLoadingTime';
  17. import { useContainerWidth } from '../../../hooks/common/useContainerWidth';
  18. import {
  19. Divider,
  20. Button,
  21. Row,
  22. Col,
  23. Collapsible,
  24. Checkbox,
  25. Skeleton,
  26. Tooltip,
  27. } from '@douyinfe/semi-ui';
  28. import { IconChevronDown, IconChevronUp } from '@douyinfe/semi-icons';
  29. /**
  30. * 通用可选择按钮组组件
  31. *
  32. * @param {string} title 标题
  33. * @param {Array<{value:any,label:string,icon?:React.ReactNode,tagCount?:number}>} items 按钮项
  34. * @param {*|Array} activeValue 当前激活的值,可以是单个值或数组(多选)
  35. * @param {(value:any)=>void} onChange 选择改变回调
  36. * @param {function} t i18n
  37. * @param {object} style 额外样式
  38. * @param {boolean} collapsible 是否支持折叠,默认true
  39. * @param {number} collapseHeight 折叠时的高度,默认200
  40. * @param {boolean} withCheckbox 是否启用前缀 Checkbox 来控制激活状态
  41. * @param {boolean} loading 是否处于加载状态
  42. * @param {string} variant 颜色变体: 'violet' | 'teal' | 'amber' | 'rose' | 'green',不传则使用默认蓝色
  43. */
  44. const SelectableButtonGroup = ({
  45. title,
  46. items = [],
  47. activeValue,
  48. onChange,
  49. t = (v) => v,
  50. style = {},
  51. collapsible = true,
  52. collapseHeight = 200,
  53. withCheckbox = false,
  54. loading = false,
  55. variant,
  56. }) => {
  57. const [isOpen, setIsOpen] = useState(false);
  58. const [skeletonCount] = useState(12);
  59. const [containerRef, containerWidth] = useContainerWidth();
  60. const ConditionalTooltipText = ({ text }) => {
  61. const textRef = useRef(null);
  62. const [isOverflowing, setIsOverflowing] = useState(false);
  63. useEffect(() => {
  64. const el = textRef.current;
  65. if (!el) return;
  66. setIsOverflowing(el.scrollWidth > el.clientWidth);
  67. }, [text, containerWidth]);
  68. const textElement = (
  69. <span ref={textRef} className='sbg-ellipsis'>
  70. {text}
  71. </span>
  72. );
  73. return isOverflowing ? (
  74. <Tooltip content={text}>{textElement}</Tooltip>
  75. ) : (
  76. textElement
  77. );
  78. };
  79. // 基于容器宽度计算响应式列数和标签显示策略
  80. const getResponsiveConfig = () => {
  81. if (containerWidth <= 280) return { columns: 1, showTags: true }; // 极窄:1列+标签
  82. if (containerWidth <= 380) return { columns: 2, showTags: true }; // 窄屏:2列+标签
  83. if (containerWidth <= 460) return { columns: 3, showTags: false }; // 中等:3列不加标签
  84. return { columns: 3, showTags: true }; // 最宽:3列+标签
  85. };
  86. const { columns: perRow, showTags: shouldShowTags } = getResponsiveConfig();
  87. const maxVisibleRows = Math.max(1, Math.floor(collapseHeight / 32)); // Approx row height 32
  88. const needCollapse = collapsible && items.length > perRow * maxVisibleRows;
  89. const showSkeleton = useMinimumLoadingTime(loading);
  90. // 统一使用紧凑的网格间距
  91. const gutterSize = [4, 4];
  92. // 计算 Semi UI Col 的 span 值
  93. const getColSpan = () => {
  94. return Math.floor(24 / perRow);
  95. };
  96. const maskStyle = isOpen
  97. ? {}
  98. : {
  99. WebkitMaskImage:
  100. 'linear-gradient(to bottom, black 0%, rgba(0, 0, 0, 1) 60%, rgba(0, 0, 0, 0.2) 80%, transparent 100%)',
  101. };
  102. const toggle = () => {
  103. setIsOpen(!isOpen);
  104. };
  105. const linkStyle = {
  106. position: 'absolute',
  107. left: 0,
  108. right: 0,
  109. textAlign: 'center',
  110. bottom: -10,
  111. fontWeight: 400,
  112. cursor: 'pointer',
  113. fontSize: '12px',
  114. color: 'var(--semi-color-text-2)',
  115. display: 'flex',
  116. alignItems: 'center',
  117. justifyContent: 'center',
  118. gap: 4,
  119. };
  120. const renderSkeletonButtons = () => {
  121. const placeholder = (
  122. <Row gutter={gutterSize} style={{ lineHeight: '32px', ...style }}>
  123. {Array.from({ length: skeletonCount }).map((_, index) => (
  124. <Col span={getColSpan()} key={index}>
  125. <div
  126. style={{
  127. width: '100%',
  128. height: '32px',
  129. display: 'flex',
  130. alignItems: 'center',
  131. justifyContent: 'flex-start',
  132. border: '1px solid var(--semi-color-border)',
  133. borderRadius: 'var(--semi-border-radius-medium)',
  134. padding: '0 12px',
  135. gap: '6px',
  136. }}
  137. >
  138. {withCheckbox && (
  139. <Skeleton.Title active style={{ width: 14, height: 14 }} />
  140. )}
  141. <Skeleton.Title
  142. active
  143. style={{
  144. width: `${60 + (index % 3) * 20}px`,
  145. height: 14,
  146. }}
  147. />
  148. </div>
  149. </Col>
  150. ))}
  151. </Row>
  152. );
  153. return (
  154. <Skeleton loading={true} active placeholder={placeholder}></Skeleton>
  155. );
  156. };
  157. const contentElement = showSkeleton ? (
  158. renderSkeletonButtons()
  159. ) : (
  160. <Row gutter={gutterSize} style={{ lineHeight: '32px', ...style }}>
  161. {items.map((item) => {
  162. const isActive = Array.isArray(activeValue)
  163. ? activeValue.includes(item.value)
  164. : activeValue === item.value;
  165. if (withCheckbox) {
  166. return (
  167. <Col span={getColSpan()} key={item.value}>
  168. <Button
  169. onClick={() => {
  170. /* disabled */
  171. }}
  172. theme={isActive ? 'light' : 'outline'}
  173. type={isActive ? 'primary' : 'tertiary'}
  174. className='sbg-button'
  175. icon={
  176. <Checkbox
  177. checked={isActive}
  178. onChange={() => onChange(item.value)}
  179. style={{ pointerEvents: 'auto' }}
  180. />
  181. }
  182. style={{ width: '100%', cursor: 'default' }}
  183. >
  184. <div className='sbg-content'>
  185. {item.icon && <span className='sbg-icon'>{item.icon}</span>}
  186. <ConditionalTooltipText text={item.label} />
  187. {item.tagCount !== undefined && shouldShowTags && (
  188. <span className={`sbg-badge ${isActive ? 'sbg-badge-active' : ''}`}>
  189. {item.tagCount}
  190. </span>
  191. )}
  192. </div>
  193. </Button>
  194. </Col>
  195. );
  196. }
  197. return (
  198. <Col span={getColSpan()} key={item.value}>
  199. <Button
  200. onClick={() => onChange(item.value)}
  201. theme={isActive ? 'light' : 'outline'}
  202. type={isActive ? 'primary' : 'tertiary'}
  203. className='sbg-button'
  204. style={{ width: '100%' }}
  205. >
  206. <div className='sbg-content'>
  207. {item.icon && <span className='sbg-icon'>{item.icon}</span>}
  208. <ConditionalTooltipText text={item.label} />
  209. {item.tagCount !== undefined && shouldShowTags && item.tagCount !== '' && (
  210. <span className={`sbg-badge ${isActive ? 'sbg-badge-active' : ''}`}>
  211. {item.tagCount}
  212. </span>
  213. )}
  214. </div>
  215. </Button>
  216. </Col>
  217. );
  218. })}
  219. </Row>
  220. );
  221. return (
  222. <div
  223. className={`mb-8 ${containerWidth <= 400 ? 'sbg-compact' : ''}${variant ? ` sbg-variant-${variant}` : ''}`}
  224. ref={containerRef}
  225. >
  226. {title && (
  227. <Divider margin='12px' align='left'>
  228. {showSkeleton ? (
  229. <Skeleton.Title active style={{ width: 80, height: 14 }} />
  230. ) : (
  231. title
  232. )}
  233. </Divider>
  234. )}
  235. {needCollapse && !showSkeleton ? (
  236. <div style={{ position: 'relative' }}>
  237. <Collapsible
  238. isOpen={isOpen}
  239. collapseHeight={collapseHeight}
  240. style={{ ...maskStyle }}
  241. >
  242. {contentElement}
  243. </Collapsible>
  244. {isOpen ? null : (
  245. <div onClick={toggle} style={{ ...linkStyle }}>
  246. <IconChevronDown size='small' />
  247. <span>{t('展开更多')}</span>
  248. </div>
  249. )}
  250. {isOpen && (
  251. <div
  252. onClick={toggle}
  253. style={{
  254. ...linkStyle,
  255. position: 'static',
  256. marginTop: 8,
  257. bottom: 'auto',
  258. }}
  259. >
  260. <IconChevronUp size='small' />
  261. <span>{t('收起')}</span>
  262. </div>
  263. )}
  264. </div>
  265. ) : (
  266. contentElement
  267. )}
  268. </div>
  269. );
  270. };
  271. export default SelectableButtonGroup;