From 15a35485dc21924de168b8c85b94c15ca4b0af1d Mon Sep 17 00:00:00 2001 From: vaebe <18137693952@163.com> Date: Fri, 5 Jun 2026 09:34:31 +0800 Subject: [PATCH 1/3] =?UTF-8?q?refactor(ccui):=20=E5=A4=9A=E4=BB=A3?= =?UTF-8?q?=E7=90=86=E5=AE=A1=E6=9F=A5=E8=90=BD=E5=9C=B0=E7=BB=84=E4=BB=B6?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E2=80=94=E2=80=94=E6=B7=B1=E8=89=B2=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F=20token=20=E5=8C=96/=E9=80=BB=E8=BE=91=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D/=E6=B3=84=E6=BC=8F=E6=B8=85=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 阶段化多子代理编排:81 组件并行发现 → 对抗验证(剔除误报)→ 67 组件并行最小修复, 全程测试+真实构建守门,3 处破坏测试契约的行为变更已回退。 - 深色模式:55 处硬编码 rgba(0,0,0,..)/阴影 → $ccui-* token,深色下正常反色 - 逻辑:anchor 字面量 0 渲染、死 prop showInkInFixed;watermark/color-picker/ image-preview 等边界条件修复 - 清理:18 处定时器/监听/Observer 补 onBeforeUnmount 卸载,消除泄漏 - 样式去重 + 删除 avatar 死文件 icon-nobody.tsx(引用已清) 验证:vp test 2023 通过/93 文件(基线不变);真实库构建 ✓ built 无 Undefined variable; 新引入 33 个 token 全部比对 358 个已定义 token、@use 就位。 Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/ccui/ui/affix/src/affix.tsx | 8 +- packages/ccui/ui/anchor/src/anchor-types.ts | 4 - packages/ccui/ui/anchor/src/anchor.tsx | 2 +- .../ui/auto-complete/src/auto-complete.scss | 22 ++-- packages/ccui/ui/avatar/src/avatar.tsx | 2 +- .../ui/avatar/src/components/icon-nobody.tsx | 29 ----- .../composables/use-get-background-color.ts | 14 ++- .../src/composables/use-get-display-name.ts | 2 +- packages/ccui/ui/badge/src/badge.tsx | 6 +- .../ccui/ui/breadcrumb/src/breadcrumb.scss | 2 +- packages/ccui/ui/button-3d/src/button-3d.scss | 102 ++++-------------- packages/ccui/ui/button/src/button.scss | 8 +- packages/ccui/ui/calendar/src/calendar.tsx | 18 ++-- packages/ccui/ui/carousel/src/carousel.scss | 8 +- packages/ccui/ui/carousel/src/carousel.tsx | 14 +-- packages/ccui/ui/cascader/src/cascader.tsx | 4 +- packages/ccui/ui/check-box/src/check-box.tsx | 3 +- .../ui/checkable-tag/src/checkable-tag.scss | 2 - packages/ccui/ui/collapse/src/collapse.scss | 4 +- .../ui/color-picker/src/color-picker.scss | 16 +-- .../ccui/ui/color-picker/src/color-picker.tsx | 8 +- .../ccui/ui/date-picker/src/date-picker.tsx | 12 ++- .../ui/descriptions/src/descriptions.scss | 2 +- packages/ccui/ui/divider/src/divider.tsx | 14 +-- packages/ccui/ui/drawer/src/drawer.scss | 11 +- packages/ccui/ui/dropdown/src/dropdown.scss | 17 +-- packages/ccui/ui/empty/src/empty.tsx | 6 +- .../ccui/ui/float-button/src/back-top.tsx | 13 ++- .../ui/float-button/src/float-button.scss | 4 +- packages/ccui/ui/form/src/form-item.tsx | 5 + packages/ccui/ui/grid/src/grid.tsx | 12 ++- .../ui/image-preview/src/image-preview.tsx | 40 ++++--- packages/ccui/ui/image/src/image.scss | 2 +- packages/ccui/ui/image/src/image.tsx | 3 + .../ccui/ui/input-number/src/input-number.tsx | 4 +- packages/ccui/ui/input-otp/src/input-otp.scss | 7 -- packages/ccui/ui/input-otp/src/input-otp.tsx | 1 + .../ui/input-search/src/input-search.scss | 2 +- packages/ccui/ui/input/src/input.scss | 44 +++----- packages/ccui/ui/input/src/input.tsx | 9 ++ packages/ccui/ui/layout/src/layout.scss | 1 - packages/ccui/ui/layout/src/layout.tsx | 4 +- packages/ccui/ui/mentions/src/mentions.scss | 18 ++-- packages/ccui/ui/mentions/src/mentions.tsx | 10 ++ packages/ccui/ui/menu/src/menu.scss | 9 +- packages/ccui/ui/menu/src/menu.tsx | 16 ++- packages/ccui/ui/message/src/message.scss | 5 +- packages/ccui/ui/modal/src/modal.scss | 20 ++-- .../ui/notification/src/notification.scss | 6 +- .../ccui/ui/notification/src/notification.ts | 6 +- .../ui/notification/src/use-notification.ts | 6 +- .../ui/popconfirm/src/popconfirm-types.ts | 2 +- .../ccui/ui/popconfirm/src/popconfirm.scss | 18 ++-- .../ccui/ui/popconfirm/src/popconfirm.tsx | 12 ++- packages/ccui/ui/popover/src/popover.scss | 4 +- packages/ccui/ui/popover/src/popover.tsx | 51 +++++++-- .../ccui/ui/progress/src/progress-types.ts | 2 +- packages/ccui/ui/progress/src/progress.scss | 2 +- packages/ccui/ui/progress/src/progress.tsx | 2 +- .../ui/range-picker/src/range-picker-types.ts | 5 - .../ccui/ui/range-picker/src/range-picker.tsx | 14 ++- packages/ccui/ui/rate/src/rate.tsx | 7 +- packages/ccui/ui/result/src/result-types.ts | 4 - packages/ccui/ui/segmented/src/segmented.scss | 7 +- packages/ccui/ui/select/src/select.scss | 2 +- packages/ccui/ui/select/src/select.tsx | 7 +- .../ui/skeleton-node/src/skeleton-node.scss | 14 ++- packages/ccui/ui/skeleton/src/skeleton.scss | 10 +- packages/ccui/ui/skeleton/src/skeleton.tsx | 2 +- .../ui/slider/src/composables/use-slider.ts | 16 ++- packages/ccui/ui/slider/src/slider.scss | 2 +- packages/ccui/ui/slider/src/slider.tsx | 1 + .../ui/space-compact/src/space-compact.scss | 4 +- packages/ccui/ui/spin/src/spin-types.ts | 4 - packages/ccui/ui/splitter/src/splitter.scss | 19 +--- packages/ccui/ui/splitter/src/splitter.tsx | 34 ++++-- packages/ccui/ui/steps/src/steps.scss | 3 +- packages/ccui/ui/switch/src/switch.scss | 4 +- .../src/table-column-group.tsx | 6 +- .../ccui/ui/table-column/src/table-column.tsx | 6 +- packages/ccui/ui/table/src/table-types.ts | 10 ++ packages/ccui/ui/table/src/table.scss | 3 +- packages/ccui/ui/table/src/table.tsx | 6 +- .../ccui/ui/tabs/src/components/tab/tab.tsx | 8 +- packages/ccui/ui/tabs/src/tabs.scss | 4 - packages/ccui/ui/tabs/src/tabs.tsx | 1 + packages/ccui/ui/tag/src/tag.scss | 4 +- packages/ccui/ui/textarea/src/textarea.tsx | 2 + .../ccui/ui/time-picker/src/time-picker.tsx | 49 +++++++-- .../ccui/ui/timeline/src/timeline-item.tsx | 2 +- packages/ccui/ui/timeline/src/timeline.scss | 9 -- packages/ccui/ui/tooltip/src/tooltip.scss | 4 +- packages/ccui/ui/transfer/src/transfer.scss | 24 ++--- packages/ccui/ui/transfer/src/transfer.tsx | 13 ++- .../ccui/ui/tree-select/src/tree-select.scss | 2 +- .../ccui/ui/tree-select/src/tree-select.tsx | 9 +- .../tree/src/composables/use-tree-flatten.ts | 13 ++- .../ccui/ui/typography/src/typography.scss | 10 +- .../ccui/ui/typography/src/typography.tsx | 9 +- packages/ccui/ui/upload/src/upload.scss | 34 +++--- .../ccui/ui/watermark/src/watermark-types.ts | 4 +- packages/ccui/ui/watermark/src/watermark.tsx | 17 ++- 102 files changed, 595 insertions(+), 493 deletions(-) delete mode 100644 packages/ccui/ui/avatar/src/components/icon-nobody.tsx diff --git a/packages/ccui/ui/affix/src/affix.tsx b/packages/ccui/ui/affix/src/affix.tsx index 074addea..a31d332d 100644 --- a/packages/ccui/ui/affix/src/affix.tsx +++ b/packages/ccui/ui/affix/src/affix.tsx @@ -68,6 +68,7 @@ export default defineComponent({ let container: HTMLElement | Window | null = null let resizeObserver: ResizeObserver | null = null + let rafId: number | null = null const isTopMode = computed(() => props.offsetBottom === undefined) const offsetTop = computed(() => props.offsetTop ?? 0) @@ -140,7 +141,7 @@ export default defineComponent({ resizeObserver.observe(wrapperRef.value) } // 等下一帧再计算,避免初次布局未完成 - requestAnimationFrame(() => update()) + rafId = requestAnimationFrame(() => update()) }) onBeforeUnmount(() => { @@ -148,6 +149,11 @@ export default defineComponent({ window.removeEventListener('resize', update) resizeObserver?.disconnect() resizeObserver = null + // 卸载时取消未执行的下一帧回调,避免卸载后再触发一次 update() + if (rafId !== null && typeof cancelAnimationFrame === 'function') { + cancelAnimationFrame(rafId) + rafId = null + } }) watch( diff --git a/packages/ccui/ui/anchor/src/anchor-types.ts b/packages/ccui/ui/anchor/src/anchor-types.ts index 2dcf87b9..bdabd956 100644 --- a/packages/ccui/ui/anchor/src/anchor-types.ts +++ b/packages/ccui/ui/anchor/src/anchor-types.ts @@ -27,10 +27,6 @@ export const anchorProps = { type: [String, Object] as PropType, default: undefined, }, - showInkInFixed: { - type: Boolean, - default: false, - }, targetOffset: { type: Number, default: undefined, diff --git a/packages/ccui/ui/anchor/src/anchor.tsx b/packages/ccui/ui/anchor/src/anchor.tsx index 0bdf9ca8..6b253aee 100644 --- a/packages/ccui/ui/anchor/src/anchor.tsx +++ b/packages/ccui/ui/anchor/src/anchor.tsx @@ -185,7 +185,7 @@ export default defineComponent({ > {link.title ?? link.href} - {link.children?.length && ( + {!!link.children?.length && (
{link.children.map((child) => renderLink(child, level + 1))}
)} diff --git a/packages/ccui/ui/auto-complete/src/auto-complete.scss b/packages/ccui/ui/auto-complete/src/auto-complete.scss index 21d45d59..2d22ba62 100644 --- a/packages/ccui/ui/auto-complete/src/auto-complete.scss +++ b/packages/ccui/ui/auto-complete/src/auto-complete.scss @@ -63,15 +63,15 @@ border: 0; outline: 0; background: transparent; - color: rgba(0, 0, 0, 0.88); + color: $ccui-color-text; font-size: 14px; &::placeholder { - color: rgba(0, 0, 0, 0.25); + color: $ccui-color-text-placeholder; } &:disabled { - color: rgba(0, 0, 0, 0.4); + color: $ccui-color-text-disabled; } } @@ -81,14 +81,14 @@ justify-content: center; width: 16px; height: 16px; - color: rgba(0, 0, 0, 0.45); + color: $ccui-color-text-tertiary; font-size: 11px; cursor: pointer; border-radius: 50%; transition: color 0.2s; &:hover { - color: rgba(0, 0, 0, 0.85); + color: $ccui-color-text; } } @@ -96,9 +96,7 @@ background: $ccui-base-bg; border: 1px solid $ccui-dividing-line; border-radius: 6px; - box-shadow: - 0 6px 16px rgba(0, 0, 0, 0.08), - 0 3px 6px -4px rgba(0, 0, 0, 0.12); + box-shadow: $ccui-box-shadow-secondary; box-sizing: border-box; overflow: auto; z-index: 1050; @@ -113,17 +111,17 @@ &__option { padding: 5px 12px; - color: rgba(0, 0, 0, 0.88); + color: $ccui-color-text; font-size: 14px; cursor: pointer; transition: background 0.2s; &.is-active { - background: rgba(0, 0, 0, 0.04); + background: $ccui-color-fill-tertiary; } &.is-disabled { - color: rgba(0, 0, 0, 0.25); + color: $ccui-color-text-disabled; cursor: not-allowed; background: transparent; } @@ -131,7 +129,7 @@ &__empty { padding: 12px; - color: rgba(0, 0, 0, 0.45); + color: $ccui-color-text-tertiary; font-size: 13px; text-align: center; } diff --git a/packages/ccui/ui/avatar/src/avatar.tsx b/packages/ccui/ui/avatar/src/avatar.tsx index 48162309..e4f6685e 100644 --- a/packages/ccui/ui/avatar/src/avatar.tsx +++ b/packages/ccui/ui/avatar/src/avatar.tsx @@ -33,7 +33,7 @@ export default defineComponent({ fontSize.value = minNum.value / 4 + 3 // 传入的name不存在 且不等于 '' 时 - isNobody.value = !!name.value && name.value === '' + isNobody.value = !name.value // 计算背景颜色code BgColorCode.value = useGetBackgroundColor(gender.value, nameDisplay.value.substring(0, 1)) diff --git a/packages/ccui/ui/avatar/src/components/icon-nobody.tsx b/packages/ccui/ui/avatar/src/components/icon-nobody.tsx deleted file mode 100644 index 9570aef3..00000000 --- a/packages/ccui/ui/avatar/src/components/icon-nobody.tsx +++ /dev/null @@ -1,29 +0,0 @@ -export function IconNobody(props: { width: number; height: number }) { - const { width, height } = props - return ( - - - - - - - ) -} diff --git a/packages/ccui/ui/avatar/src/composables/use-get-background-color.ts b/packages/ccui/ui/avatar/src/composables/use-get-background-color.ts index 784818c2..7f9d8768 100644 --- a/packages/ccui/ui/avatar/src/composables/use-get-background-color.ts +++ b/packages/ccui/ui/avatar/src/composables/use-get-background-color.ts @@ -1,19 +1,17 @@ -import { ref } from 'vue' - -export default function getBackgroundColor(gender: string, char: string): number { - const code = ref(1) +export default function useGetBackgroundColor(gender: string, char: string): number { + let code = 1 // 性别存在 直接使用性别 if (gender) { if (gender.toLowerCase() === 'male') { - code.value = 1 + code = 1 } else if (gender.toLowerCase() === 'female') { - code.value = 0 + code = 0 } else { throw new Error('gender must be "Male" or "Female"') } } else { const unicode = char.charCodeAt(0) - code.value = unicode % 2 + code = unicode % 2 } - return code.value + return code } diff --git a/packages/ccui/ui/avatar/src/composables/use-get-display-name.ts b/packages/ccui/ui/avatar/src/composables/use-get-display-name.ts index 78321999..f7d81908 100644 --- a/packages/ccui/ui/avatar/src/composables/use-get-display-name.ts +++ b/packages/ccui/ui/avatar/src/composables/use-get-display-name.ts @@ -31,7 +31,7 @@ export default function useGetDisplayName(name: string, customText: string, widt nameDisplay = name.length < 2 ? name : nameFormatting(name) } - if (width < 30) { + if (name && width < 30) { nameDisplay = name.substring(0, 1).toUpperCase() } return nameDisplay diff --git a/packages/ccui/ui/badge/src/badge.tsx b/packages/ccui/ui/badge/src/badge.tsx index 9569597e..1fd4fc50 100644 --- a/packages/ccui/ui/badge/src/badge.tsx +++ b/packages/ccui/ui/badge/src/badge.tsx @@ -46,7 +46,11 @@ export default defineComponent({ } if (props.offset && Array.isArray(props.offset)) { const [x, y] = props.offset - style.transform = `translate(calc(-50% + ${x}px), calc(-50% + ${y}px))` + // 独立模式以 scss 基线 transform: none 为锚做纯平移; + // 包裹模式以 scss 基线 translate(50%, -50%)(角标推到右上角外侧)为锚做微调 + style.transform = isStandalone.value + ? `translate(${x}px, ${y}px)` + : `translate(calc(50% + ${x}px), calc(-50% + ${y}px))` } return Object.keys(style).length ? style : undefined }) diff --git a/packages/ccui/ui/breadcrumb/src/breadcrumb.scss b/packages/ccui/ui/breadcrumb/src/breadcrumb.scss index e8c408aa..d5da1d8b 100644 --- a/packages/ccui/ui/breadcrumb/src/breadcrumb.scss +++ b/packages/ccui/ui/breadcrumb/src/breadcrumb.scss @@ -31,7 +31,7 @@ &:hover { color: $ccui-color-text; - background-color: rgba(0, 0, 0, 0.06); + background-color: $ccui-color-fill-secondary; border-radius: $ccui-border-radius-sm; } } diff --git a/packages/ccui/ui/button-3d/src/button-3d.scss b/packages/ccui/ui/button-3d/src/button-3d.scss index a9152b83..5a5f699c 100644 --- a/packages/ccui/ui/button-3d/src/button-3d.scss +++ b/packages/ccui/ui/button-3d/src/button-3d.scss @@ -13,7 +13,6 @@ $button-3d-info-active-color: $ccui-button-info-active; border-radius: $ccui-border-radius; outline-offset: 4px; user-select: none; - background: transparent; .shadow { position: absolute; @@ -65,88 +64,30 @@ $button-3d-info-active-color: $ccui-button-info-active; } // 主要按钮类型样式 - &--primary { - .edge { - background: linear-gradient( - to left, - $ccui-color-primary-active 0%, - $ccui-color-primary-active 8%, - $ccui-color-primary-active 92%, - $ccui-color-primary-active 100% - ); - } - - .front { - background: $ccui-color-primary; - color: $ccui-color-text-light-solid; - } - } - - &--success { - .edge { - background: linear-gradient( - to left, - $ccui-color-success-active 0%, - $ccui-color-success-active 8%, - $ccui-color-success-active 92%, - $ccui-color-success-active 100% - ); - } - - .front { - background: $ccui-color-success; - color: $ccui-color-text-light-solid; - } - } - - &--info { + // .edge 四个 stop 颜色一致,仅 type 不同,用 mixin + @each 收敛 + @mixin edge-gradient($c) { .edge { - background: linear-gradient( - to left, - $button-3d-info-active-color 0%, - $button-3d-info-active-color 8%, - $button-3d-info-active-color 92%, - $button-3d-info-active-color 100% - ); - } - - .front { - background: $button-3d-info-color; - color: $ccui-color-text-light-solid; + background: linear-gradient(to left, $c 0%, $c 8%, $c 92%, $c 100%); } } - &--warning { - .edge { - background: linear-gradient( - to left, - $ccui-color-warning-active 0%, - $ccui-color-warning-active 8%, - $ccui-color-warning-active 92%, - $ccui-color-warning-active 100% - ); - } + @each $name, $edge, + $front + in ( + ('primary', $ccui-color-primary-active, $ccui-color-primary), + ('success', $ccui-color-success-active, $ccui-color-success), + ('info', $button-3d-info-active-color, $button-3d-info-color), + ('warning', $ccui-color-warning-active, $ccui-color-warning), + ('danger', $ccui-color-error-active, $ccui-color-error) + ) + { + &--#{$name} { + @include edge-gradient($edge); - .front { - background: $ccui-color-warning; - color: $ccui-color-text-light-solid; - } - } - - &--danger { - .edge { - background: linear-gradient( - to left, - $ccui-color-error-active 0%, - $ccui-color-error-active 8%, - $ccui-color-error-active 92%, - $ccui-color-error-active 100% - ); - } - - .front { - background: $ccui-color-error; - color: $ccui-color-text-light-solid; + .front { + background: $front; + color: $ccui-color-text-light-solid; + } } } @@ -228,9 +169,10 @@ $button-3d-info-active-color: $ccui-button-info-active; display: inline-block; width: 16px; height: 16px; - border: 2px solid rgba(255, 255, 255, 0.3); + // 轨道与指示色跟随 .front 的字色:彩色 type 下解析为白,默认浅底 type 下解析为深色,保证可见 + border: 2px solid color-mix(in srgb, currentColor 30%, transparent); border-radius: 50%; - border-top-color: white; + border-top-color: currentColor; animation: spin 1s ease-in-out infinite; margin-right: 8px; } diff --git a/packages/ccui/ui/button/src/button.scss b/packages/ccui/ui/button/src/button.scss index 56926c1b..b61aea97 100644 --- a/packages/ccui/ui/button/src/button.scss +++ b/packages/ccui/ui/button/src/button.scss @@ -44,7 +44,7 @@ $button-info-plain-border: $ccui-button-info-plain-border; &:disabled { cursor: not-allowed; color: $ccui-color-text-disabled; - background-color: rgba(0, 0, 0, 0.04); + background-color: $ccui-color-fill-tertiary; border-color: $ccui-color-border; box-shadow: none; } @@ -65,7 +65,7 @@ $button-info-plain-border: $ccui-button-info-plain-border; &:disabled { color: $ccui-color-text-disabled; - background-color: rgba(0, 0, 0, 0.04); + background-color: $ccui-color-fill-tertiary; border-color: $ccui-color-border; box-shadow: none; } @@ -149,12 +149,12 @@ $button-info-plain-border: $ccui-button-info-plain-border; &:hover { color: $ccui-color-primary-hover; - background: rgba(0, 0, 0, 0.06); + background: $ccui-color-fill-secondary; } &:active { color: $ccui-color-primary-active; - background: rgba(0, 0, 0, 0.15); + background: $ccui-color-fill; } } diff --git a/packages/ccui/ui/calendar/src/calendar.tsx b/packages/ccui/ui/calendar/src/calendar.tsx index 6dcd6074..f6a4ea44 100644 --- a/packages/ccui/ui/calendar/src/calendar.tsx +++ b/packages/ccui/ui/calendar/src/calendar.tsx @@ -41,19 +41,19 @@ export default defineComponent({ // 根据月份计算当前展示日期的数组 const generatedDate = (month?: string) => { - // 获取当月第一天是周几 - const whichDay = dayjs(month).startOf('month').day() - // 计算开始开始日期 7 减去当月第一天是周几(获取到的周几是 0123456, 其中0代表周日) - const startDate = dayjs(dayjs(month).subtract(7 - whichDay, 'day')).format('YYYY-MM-DD') + // 当月第一天 + const first = dayjs(month).startOf('month') + // 当月第一天是周几(0123456,其中 0 代表周日) + const whichDay = first.day() + // 起始日期回退到所在周的周日,使网格第 N 列恰好对应星期 N,与周日…周六表头对齐 + const startDate = first.subtract(whichDay, 'day').format('YYYY-MM-DD') // 整理数据 // 生成长度42的数组,为什么是42呢? 因为有的月份是28 或者30 31, 35 会导致某些月份展示不全。 curDateList.value = Array.from({ length: 42 }, (_, index) => index).reduce( (acc: Array, index: number) => { // 获取展示的日期 - const date = dayjs(startDate) - .add(index + 1, 'day') - .format('YYYY-MM-DD') + const date = dayjs(startDate).add(index, 'day').format('YYYY-MM-DD') // 分割日期 用于获取是 几号 如 123456 const dateList = date.split('-') // 获取周几 @@ -61,7 +61,7 @@ export default defineComponent({ // 将需要的数据 放进数组 acc.push({ - index: index + 1, + index, date, day: dateList[dateList.length - 1], week: weekList.value[week], @@ -78,6 +78,8 @@ export default defineComponent({ // 设置当前天、选中天, 生成对应月份 const setCurrentDate = (date: string) => { + // 只读模式下禁止任何改值(日期格点击、月份切换、header slot 暴露的 setDate/changeMonth 均经此收口) + if (props.readOnly) return currentDate.value = dayjs(date).format('YYYY-MM-DD') // 月份不同 重新生成日历 if (!date.includes(currentMonth.value)) { diff --git a/packages/ccui/ui/carousel/src/carousel.scss b/packages/ccui/ui/carousel/src/carousel.scss index a7038155..62e04a28 100644 --- a/packages/ccui/ui/carousel/src/carousel.scss +++ b/packages/ccui/ui/carousel/src/carousel.scss @@ -100,7 +100,7 @@ padding: 0; border: 0; border-radius: 2px; - background: rgba(0, 0, 0, 0.25); + background: $ccui-color-fill; cursor: pointer; transition: background 0.3s, @@ -133,8 +133,8 @@ padding: 0; border: 0; border-radius: 50%; - color: #fff; - background: rgba(0, 0, 0, 0.4); + color: $ccui-color-text-light-solid; + background: $ccui-color-bg-mask; font-size: 18px; line-height: 32px; text-align: center; @@ -143,7 +143,7 @@ transition: background 0.2s; &:hover { - background: rgba(0, 0, 0, 0.6); + background: $ccui-color-bg-spotlight; } &--prev { diff --git a/packages/ccui/ui/carousel/src/carousel.tsx b/packages/ccui/ui/carousel/src/carousel.tsx index 1cad8ee3..8816dac4 100644 --- a/packages/ccui/ui/carousel/src/carousel.tsx +++ b/packages/ccui/ui/carousel/src/carousel.tsx @@ -125,18 +125,20 @@ export default defineComponent({ const step = slidesToScroll.value const max = isMulti.value ? maxActive.value : total.value - 1 let target = active.value + step - // 多帧并排时,最后一页要对齐到 maxActive(不能越过;但 infinite 时仍可绕到 0) - if (isMulti.value && !props.infinite && target > max) target = max - // 多帧并排时,跨过 max 但 infinite=true → 回到 0 - if (isMulti.value && props.infinite && active.value >= max) target = 0 + // 多帧并排时,target 越过 max:已在末页才绕回 0,否则对齐到 maxActive(保证最后一页填满) + if (isMulti.value && target > max) { + target = props.infinite && active.value >= max ? 0 : max + } setActive(target) } function prev(): void { const step = slidesToScroll.value const max = isMulti.value ? maxActive.value : total.value - 1 let target = active.value - step - // 多帧并排 + infinite:从 0 倒退应回到末页(active = maxActive) - if (isMulti.value && props.infinite && active.value <= 0) target = max + // 多帧并排时,target 越过 0:已在首页才绕到末页(active = maxActive),否则对齐到 0 + if (isMulti.value && target < 0) { + target = props.infinite && active.value <= 0 ? max : 0 + } setActive(target) } function getCurrentIndex(): number { diff --git a/packages/ccui/ui/cascader/src/cascader.tsx b/packages/ccui/ui/cascader/src/cascader.tsx index 2149d467..c0b2f376 100644 --- a/packages/ccui/ui/cascader/src/cascader.tsx +++ b/packages/ccui/ui/cascader/src/cascader.tsx @@ -148,7 +148,7 @@ export default defineComponent({ }) function pathKey(path: CascaderOption[]): string { - return path.map((n) => String(getOptionValue(n, fn.value))).join('') + return JSON.stringify(path.map((n) => getOptionValue(n, fn.value))) } const checkedKeys = computed>(() => { @@ -483,7 +483,7 @@ export default defineComponent({ if (k === 'Enter') { e.preventDefault() if (focusedColumn.value === -1 && focusedIndex.value >= 0 && focusedIndex.value < results.length) { - pickSearchResult(results[focusedColumn.value === -1 ? focusedIndex.value : 0]) + pickSearchResult(results[focusedIndex.value]) } } return diff --git a/packages/ccui/ui/check-box/src/check-box.tsx b/packages/ccui/ui/check-box/src/check-box.tsx index 68713cff..bf0d0756 100644 --- a/packages/ccui/ui/check-box/src/check-box.tsx +++ b/packages/ccui/ui/check-box/src/check-box.tsx @@ -21,7 +21,8 @@ export default defineComponent({ }) const isChecked = computed(() => { - return checkBoxGroupInject?.isItemChecked(props.label) || props.modelValue + // group 模式只认 group 状态,独立模式返回纯布尔 + return checkBoxGroupInject ? checkBoxGroupInject.isItemChecked(props.label) : !!props.modelValue }) // 计算组件样式 diff --git a/packages/ccui/ui/checkable-tag/src/checkable-tag.scss b/packages/ccui/ui/checkable-tag/src/checkable-tag.scss index 045ff509..7a4c08dc 100644 --- a/packages/ccui/ui/checkable-tag/src/checkable-tag.scss +++ b/packages/ccui/ui/checkable-tag/src/checkable-tag.scss @@ -61,7 +61,6 @@ } &--small { - font-size: $ccui-font-size-sm; line-height: 18px; padding-inline: 6px; } @@ -71,7 +70,6 @@ display: inline-flex; flex-wrap: wrap; align-items: center; - gap: 0 0; &--disabled { pointer-events: none; diff --git a/packages/ccui/ui/collapse/src/collapse.scss b/packages/ccui/ui/collapse/src/collapse.scss index c714b967..029449cc 100644 --- a/packages/ccui/ui/collapse/src/collapse.scss +++ b/packages/ccui/ui/collapse/src/collapse.scss @@ -1,7 +1,7 @@ @use '../../style-var/index.scss' as *; .#{$cls-prefix}-collapse { - background-color: rgba(0, 0, 0, 0.02); + background-color: $ccui-color-fill-quaternary; border: 1px solid $ccui-color-border; border-radius: $ccui-border-radius; border-bottom: 0; @@ -29,7 +29,7 @@ transition: background-color $ccui-motion-duration-mid; &:hover { - background-color: rgba(0, 0, 0, 0.02); + background-color: $ccui-color-fill-quaternary; } } diff --git a/packages/ccui/ui/color-picker/src/color-picker.scss b/packages/ccui/ui/color-picker/src/color-picker.scss index 9ddc31aa..b8b6537e 100644 --- a/packages/ccui/ui/color-picker/src/color-picker.scss +++ b/packages/ccui/ui/color-picker/src/color-picker.scss @@ -51,10 +51,10 @@ } &--status-error { - border-color: #ff4d4f; + border-color: $ccui-color-error; } &--status-warning { - border-color: #faad14; + border-color: $ccui-color-warning; } } @@ -92,7 +92,7 @@ } &__value-text { - color: rgba(0, 0, 0, 0.85); + color: $ccui-color-text; font-size: 13px; font-family: monospace; } @@ -226,7 +226,7 @@ } &__hash { - color: rgba(0, 0, 0, 0.45); + color: $ccui-color-text-tertiary; margin-right: 4px; user-select: none; } @@ -237,7 +237,7 @@ border: 0; outline: 0; background: transparent; - color: rgba(0, 0, 0, 0.88); + color: $ccui-color-text; font-size: 13px; font-family: monospace; text-transform: uppercase; @@ -264,7 +264,7 @@ border: 0; outline: 0; background: transparent; - color: rgba(0, 0, 0, 0.88); + color: $ccui-color-text; font-size: 13px; text-align: right; -moz-appearance: textfield; @@ -277,7 +277,7 @@ } &__alpha-suffix { - color: rgba(0, 0, 0, 0.45); + color: $ccui-color-text-tertiary; font-size: 13px; margin-left: 2px; } @@ -296,7 +296,7 @@ } &__preset-group-label { - color: rgba(0, 0, 0, 0.65); + color: $ccui-color-text-secondary; font-size: 12px; line-height: 1.4; } diff --git a/packages/ccui/ui/color-picker/src/color-picker.tsx b/packages/ccui/ui/color-picker/src/color-picker.tsx index 5495a218..84ebd8d3 100644 --- a/packages/ccui/ui/color-picker/src/color-picker.tsx +++ b/packages/ccui/ui/color-picker/src/color-picker.tsx @@ -65,7 +65,11 @@ export default defineComponent({ const raw = isControlled.value ? props.modelValue : innerValue.value return raw ?? DEFAULT_COLOR_HEX }) - const currentRgb = computed(() => hexToRgb(currentHex.value) ?? hexToRgb(DEFAULT_COLOR_HEX)!) + const currentRgb = computed(() => { + const rgb = hexToRgb(currentHex.value) ?? hexToRgb(DEFAULT_COLOR_HEX)! + // disabledAlpha 时强制不透明,避免外部传入 8 位带 alpha 的 hex 让 swatch/显示文本仍带透明度 + return props.disabledAlpha ? { ...rgb, a: 1 } : rgb + }) // pending 状态供面板内部编辑使用 const pendingHsv = shallowRef(rgbToHsv(currentRgb.value)) @@ -160,9 +164,11 @@ export default defineComponent({ const up = () => { document.removeEventListener('pointermove', move) document.removeEventListener('pointerup', up) + document.removeEventListener('pointercancel', up) } document.addEventListener('pointermove', move) document.addEventListener('pointerup', up) + document.addEventListener('pointercancel', up) } function onSvPointerDown(e: PointerEvent) { diff --git a/packages/ccui/ui/date-picker/src/date-picker.tsx b/packages/ccui/ui/date-picker/src/date-picker.tsx index c75b9550..d40b6c77 100644 --- a/packages/ccui/ui/date-picker/src/date-picker.tsx +++ b/packages/ccui/ui/date-picker/src/date-picker.tsx @@ -295,9 +295,19 @@ export default defineComponent({ function clickNow() { const now = dayjs() if (showTimeActive.value) { + // 与 cell 选择 / 时间列保持一致:此刻命中 disabledDate / minDate / maxDate / disabledTime 时不提交 + if (isDateDisabled(now)) return + if ( + mergedDisabledHours().includes(now.hour()) || + mergedDisabledMinutes(now.hour()).includes(now.minute()) || + mergedDisabledSeconds(now.hour(), now.minute()).includes(now.second()) + ) + return emitChange(now) } else { - emitChange(now.startOf('day')) + const target = now.startOf('day') + if (isDateDisabled(target)) return + emitChange(target) } closePopup() } diff --git a/packages/ccui/ui/descriptions/src/descriptions.scss b/packages/ccui/ui/descriptions/src/descriptions.scss index ac9e00a2..b8167899 100644 --- a/packages/ccui/ui/descriptions/src/descriptions.scss +++ b/packages/ccui/ui/descriptions/src/descriptions.scss @@ -82,7 +82,7 @@ } .#{$cls-prefix}-descriptions__label { - background-color: rgba(0, 0, 0, 0.02); + background-color: $ccui-color-fill-quaternary; font-weight: 500; width: auto; } diff --git a/packages/ccui/ui/divider/src/divider.tsx b/packages/ccui/ui/divider/src/divider.tsx index fd4db55d..67fbd4d4 100644 --- a/packages/ccui/ui/divider/src/divider.tsx +++ b/packages/ccui/ui/divider/src/divider.tsx @@ -36,14 +36,14 @@ export default defineComponent({ return `${ns.e('text')} is-${props.contentPosition}` }) - return () => { - return ( -
+ return () => ( +
+ {props.direction === 'horizontal' && slots.default ? (
- {slots.default && slots.default()} + {slots.default()}
-
- ) - } + ) : null} +
+ ) }, }) diff --git a/packages/ccui/ui/drawer/src/drawer.scss b/packages/ccui/ui/drawer/src/drawer.scss index de1680e7..aeba8f2d 100644 --- a/packages/ccui/ui/drawer/src/drawer.scss +++ b/packages/ccui/ui/drawer/src/drawer.scss @@ -8,7 +8,7 @@ &__mask { position: fixed; inset: 0; - background-color: rgba(0, 0, 0, 0.45); + background-color: $ccui-color-bg-mask; pointer-events: auto; } @@ -22,6 +22,8 @@ pointer-events: auto; display: flex; flex-direction: column; + // body 区 transform 过渡(用于 push 嵌套抽屉让位) + transition: transform $ccui-motion-duration-mid cubic-bezier(0.7, 0.3, 0.1, 1); } &__inner { @@ -55,11 +57,6 @@ margin-inline-start: 8px; } - // body 区 transform 过渡(用于 push 嵌套抽屉让位) - &__content { - transition: transform $ccui-motion-duration-mid cubic-bezier(0.7, 0.3, 0.1, 1); - } - // loading 状态的骨架占位 &__skeleton { display: flex; @@ -116,7 +113,7 @@ &:hover { color: $ccui-color-text; - background-color: rgba(0, 0, 0, 0.06); + background-color: $ccui-color-fill-secondary; } } diff --git a/packages/ccui/ui/dropdown/src/dropdown.scss b/packages/ccui/ui/dropdown/src/dropdown.scss index 55f8f342..c6d6949d 100644 --- a/packages/ccui/ui/dropdown/src/dropdown.scss +++ b/packages/ccui/ui/dropdown/src/dropdown.scss @@ -40,21 +40,22 @@ background-color: $ccui-color-bg-text-hover; } - &--disabled { - color: $ccui-color-text-disabled; - cursor: not-allowed; + &--danger { + color: $ccui-color-error; &:hover { - background-color: transparent; + color: #ffffff; + background-color: $ccui-color-error; } } - &--danger { - color: $ccui-color-error; + &--disabled { + color: $ccui-color-text-disabled; + cursor: not-allowed; &:hover { - color: #ffffff; - background-color: $ccui-color-error; + color: $ccui-color-text-disabled; + background-color: transparent; } } diff --git a/packages/ccui/ui/empty/src/empty.tsx b/packages/ccui/ui/empty/src/empty.tsx index 600edb69..10a50242 100644 --- a/packages/ccui/ui/empty/src/empty.tsx +++ b/packages/ccui/ui/empty/src/empty.tsx @@ -8,12 +8,12 @@ import './empty.scss' const DEFAULT_IMG = ( - - + + diff --git a/packages/ccui/ui/float-button/src/back-top.tsx b/packages/ccui/ui/float-button/src/back-top.tsx index 1215d273..863d3a95 100644 --- a/packages/ccui/ui/float-button/src/back-top.tsx +++ b/packages/ccui/ui/float-button/src/back-top.tsx @@ -40,10 +40,11 @@ function scrollTo(target: HTMLElement | Window, to: number, duration: number) { ;(target as HTMLElement).scrollTop = value } if (progress < 1) { - requestAnimationFrame(frame) + rafId = requestAnimationFrame(frame) } } - requestAnimationFrame(frame) + let rafId = requestAnimationFrame(frame) + return () => cancelAnimationFrame(rafId) } export default defineComponent({ @@ -54,6 +55,8 @@ export default defineComponent({ const ns = useNamespace('float-button') const visible = ref(false) let container: HTMLElement | Window | null = null + // 跟踪滚动动画的取消句柄,组件卸载时需取消,避免卸载后仍有 rAF 副作用 + let cancelScroll: (() => void) | null = null const onScroll = () => { if (!container) { @@ -65,7 +68,9 @@ export default defineComponent({ const onClick = (e: MouseEvent) => { emit('click', e) if (container) { - scrollTo(container, 0, props.duration) + // 先取消上一次未结束的滚动动画,再开启新的 + cancelScroll?.() + cancelScroll = scrollTo(container, 0, props.duration) } } @@ -76,6 +81,8 @@ export default defineComponent({ }) onBeforeUnmount(() => { container?.removeEventListener('scroll', onScroll) + // 取消可能仍在进行的滚动动画,避免卸载后副作用 + cancelScroll?.() }) const cls = [ns.b(), ns.m(props.shape), ns.m(props.type), ns.m('back-top')] diff --git a/packages/ccui/ui/float-button/src/float-button.scss b/packages/ccui/ui/float-button/src/float-button.scss index 99c1b6b5..1198b224 100644 --- a/packages/ccui/ui/float-button/src/float-button.scss +++ b/packages/ccui/ui/float-button/src/float-button.scss @@ -65,7 +65,7 @@ min-width: 16px; height: 16px; padding: 0 4px; - color: #ffffff; + color: $ccui-color-white; background: $ccui-color-error; font-size: 12px; line-height: 16px; @@ -87,7 +87,7 @@ } &--primary { - color: #ffffff; + color: $ccui-color-white; background-color: $ccui-color-primary; &:hover { diff --git a/packages/ccui/ui/form/src/form-item.tsx b/packages/ccui/ui/form/src/form-item.tsx index 6a74f488..1b2bbbd4 100644 --- a/packages/ccui/ui/form/src/form-item.tsx +++ b/packages/ccui/ui/form/src/form-item.tsx @@ -273,6 +273,11 @@ export default defineComponent({ }) onUnmounted(() => { + // 卸载时清理仍处于防抖窗口的定时器,避免触发已卸载组件的 doValidate + if (validateTimer) { + clearTimeout(validateTimer) + validateTimer = null + } form?.removeField(fieldContext) const itemPreserve = props.preserve const formPreserve = form?.preserve.value ?? true diff --git a/packages/ccui/ui/grid/src/grid.tsx b/packages/ccui/ui/grid/src/grid.tsx index 9c3902e3..7b958cde 100644 --- a/packages/ccui/ui/grid/src/grid.tsx +++ b/packages/ccui/ui/grid/src/grid.tsx @@ -177,9 +177,17 @@ export const Col = defineComponent({ s.paddingInlineStart = `${h / 2}px` s.paddingInlineEnd = `${h / 2}px` } + const toFlex = (f: string | number) => (typeof f === 'number' ? `${f} ${f} auto` : String(f)) + // 断点级 flex:按 BREAKPOINTS 顺序遍历,最后一个生效(last active wins) + for (const bp of BREAKPOINTS) { + const size = computedSizes.value[bp] + if (size && size.flex !== undefined) { + s.flex = toFlex(size.flex) + } + } + // 顶层 flex 优先级最高,覆盖断点级 flex if (props.flex !== undefined) { - const flex = typeof props.flex === 'number' ? `${props.flex} ${props.flex} auto` : String(props.flex) - s.flex = flex + s.flex = toFlex(props.flex) } return s }) diff --git a/packages/ccui/ui/image-preview/src/image-preview.tsx b/packages/ccui/ui/image-preview/src/image-preview.tsx index e54df35f..23bcc977 100644 --- a/packages/ccui/ui/image-preview/src/image-preview.tsx +++ b/packages/ccui/ui/image-preview/src/image-preview.tsx @@ -33,8 +33,11 @@ export default defineComponent({ }) const current = computed(() => { - if (isControlled.value) return props.preview?.current ?? 0 - return innerCurrent.value + const total = items.value.length + const raw = isControlled.value ? (props.preview?.current ?? 0) : innerCurrent.value + if (total === 0) return 0 + // 归一化,避免父组件传入越界 current 导致 activeItem 为 undefined + return ((raw % total) + total) % total }) const setVisible = (next: boolean) => { @@ -53,9 +56,18 @@ export default defineComponent({ } const openAt = (idx: number) => { - setCurrent(idx) + const total = items.value.length + if (total === 0) return + const target = ((idx % total) + total) % total + if (!isControlled.value) { + innerCurrent.value = target + innerVisible.value = true + } scale.value = 1 - setVisible(true) + // 同时变更 current 与 visible,合并为一次 update:preview,避免受控下两次 emit 互相用对方旧值覆盖 + emit('change', target) + emit('visible-change', true) + emit('update:preview', { ...props.preview, visible: true, current: target }) } const close = () => { @@ -93,14 +105,18 @@ export default defineComponent({ } } - watch(visible, (val) => { - if (typeof window === 'undefined') return - if (val) { - window.addEventListener('keydown', onKeydown) - } else { - window.removeEventListener('keydown', onKeydown) - } - }) + watch( + visible, + (val) => { + if (typeof window === 'undefined') return + if (val) { + window.addEventListener('keydown', onKeydown) + } else { + window.removeEventListener('keydown', onKeydown) + } + }, + { immediate: true }, + ) onBeforeUnmount(() => { if (typeof window !== 'undefined') window.removeEventListener('keydown', onKeydown) diff --git a/packages/ccui/ui/image/src/image.scss b/packages/ccui/ui/image/src/image.scss index 88be74a1..a3b760d2 100644 --- a/packages/ccui/ui/image/src/image.scss +++ b/packages/ccui/ui/image/src/image.scss @@ -26,7 +26,7 @@ height: 100%; min-width: 80px; min-height: 80px; - background-color: rgba(0, 0, 0, 0.04); + background-color: $ccui-color-fill-tertiary; color: $ccui-color-text-disabled; font-size: $ccui-font-size-sm; } diff --git a/packages/ccui/ui/image/src/image.tsx b/packages/ccui/ui/image/src/image.tsx index 49526ec6..20b9cb29 100644 --- a/packages/ccui/ui/image/src/image.tsx +++ b/packages/ccui/ui/image/src/image.tsx @@ -69,6 +69,9 @@ export default defineComponent({ let observer: IntersectionObserver | null = null const setupLazy = () => { + // 先释放上一次的 observer,避免 src 连续切换时旧 observer 泄漏 + observer?.disconnect() + observer = null if (!props.lazy || !wrapperRef.value) { return } diff --git a/packages/ccui/ui/input-number/src/input-number.tsx b/packages/ccui/ui/input-number/src/input-number.tsx index 0336e0e4..b389e773 100644 --- a/packages/ccui/ui/input-number/src/input-number.tsx +++ b/packages/ccui/ui/input-number/src/input-number.tsx @@ -36,13 +36,13 @@ export default defineComponent({ // 计算是否禁用增加按钮 const maxDisabled = computed(() => { - if (innerValue.value === undefined) return false + if (innerValue.value === undefined || innerValue.value === null) return false return innerValue.value >= props.max }) // 计算是否禁用减少按钮 const minDisabled = computed(() => { - if (innerValue.value === undefined) return false + if (innerValue.value === undefined || innerValue.value === null) return false return innerValue.value <= props.min }) diff --git a/packages/ccui/ui/input-otp/src/input-otp.scss b/packages/ccui/ui/input-otp/src/input-otp.scss index ff8fdbce..1556f398 100644 --- a/packages/ccui/ui/input-otp/src/input-otp.scss +++ b/packages/ccui/ui/input-otp/src/input-otp.scss @@ -52,13 +52,6 @@ font-size: $ccui-font-size-sm; } - &--disabled &__cell { - background-color: $ccui-disabled-bg; - border-color: $ccui-disabled-line; - color: $ccui-disabled-text; - cursor: not-allowed; - } - &--status-error &__cell { border-color: $ccui-color-error; diff --git a/packages/ccui/ui/input-otp/src/input-otp.tsx b/packages/ccui/ui/input-otp/src/input-otp.tsx index bb06bd9c..a5d69b45 100644 --- a/packages/ccui/ui/input-otp/src/input-otp.tsx +++ b/packages/ccui/ui/input-otp/src/input-otp.tsx @@ -93,6 +93,7 @@ export default defineComponent({ const handleKeydown = (idx: number, e: KeyboardEvent) => { if (e.key === 'Backspace') { if (cells.value[idx]) { + e.preventDefault() cells.value[idx] = '' commit(idx) } else if (idx > 0) { diff --git a/packages/ccui/ui/input-search/src/input-search.scss b/packages/ccui/ui/input-search/src/input-search.scss index f99493f5..25056153 100644 --- a/packages/ccui/ui/input-search/src/input-search.scss +++ b/packages/ccui/ui/input-search/src/input-search.scss @@ -163,7 +163,7 @@ min-width: 32px; padding: 0 15px; background-color: $ccui-color-primary; - color: #fff; + color: $ccui-color-text-light-solid; border: 1px solid $ccui-color-primary; border-top-left-radius: 0; border-bottom-left-radius: 0; diff --git a/packages/ccui/ui/input/src/input.scss b/packages/ccui/ui/input/src/input.scss index 1f51fb65..db3f34ff 100644 --- a/packages/ccui/ui/input/src/input.scss +++ b/packages/ccui/ui/input/src/input.scss @@ -75,58 +75,38 @@ pointer-events: none; } - &--large { - height: $ccui-size-lg; - font-size: $ccui-font-size-lg; - + // 各 size 的 prefix/suffix 内边距同构,抽成 mixin 统一发射复合选择器 + @mixin input-size-pad($pad) { &.#{$cls-prefix}-input--prefix { .#{$cls-prefix}-input__inner { - padding-left: 24px; + padding-left: $pad; } } &.#{$cls-prefix}-input--suffix, &.#{$cls-prefix}-input--clearable { .#{$cls-prefix}-input__inner { - padding-right: 24px; + padding-right: $pad; } } } + &--large { + height: $ccui-size-lg; + font-size: $ccui-font-size-lg; + @include input-size-pad(24px); + } + &--default { height: $ccui-size-md; font-size: $ccui-font-size-md; - - &.#{$cls-prefix}-input--prefix { - .#{$cls-prefix}-input__inner { - padding-left: 24px; - } - } - - &.#{$cls-prefix}-input--suffix, - &.#{$cls-prefix}-input--clearable { - .#{$cls-prefix}-input__inner { - padding-right: 24px; - } - } + @include input-size-pad(24px); } &--small { height: $ccui-size-sm; font-size: $ccui-font-size-sm; - - &.#{$cls-prefix}-input--prefix { - .#{$cls-prefix}-input__inner { - padding-left: 20px; - } - } - - &.#{$cls-prefix}-input--suffix, - &.#{$cls-prefix}-input--clearable { - .#{$cls-prefix}-input__inner { - padding-right: 20px; - } - } + @include input-size-pad(20px); } &__wrapper { diff --git a/packages/ccui/ui/input/src/input.tsx b/packages/ccui/ui/input/src/input.tsx index e6b374a4..cb5eb186 100644 --- a/packages/ccui/ui/input/src/input.tsx +++ b/packages/ccui/ui/input/src/input.tsx @@ -148,6 +148,15 @@ export default defineComponent({ }, ) + // 当密码显隐能力关闭(type 离开 'password' 或 showPassword 关闭)时, + // 复位 isPasswordVisible,避免残留显隐态在下次进入 password 模式时直接以明文展示 + watch( + () => showPasswordVisible.value, + (v) => { + if (!v) isPasswordVisible.value = false + }, + ) + const renderAddonBefore = () => { if (!hasAddonBefore.value) return null const slotContent = slots.prepend?.() diff --git a/packages/ccui/ui/layout/src/layout.scss b/packages/ccui/ui/layout/src/layout.scss index 33c1df43..9da147a5 100644 --- a/packages/ccui/ui/layout/src/layout.scss +++ b/packages/ccui/ui/layout/src/layout.scss @@ -20,7 +20,6 @@ flex: 0 0 auto; height: 64px; padding: 0 24px; - color: $ccui-color-text; line-height: 64px; background: #001529; color: rgba(255, 255, 255, 0.85); diff --git a/packages/ccui/ui/layout/src/layout.tsx b/packages/ccui/ui/layout/src/layout.tsx index 6ea95972..6217ea6d 100644 --- a/packages/ccui/ui/layout/src/layout.tsx +++ b/packages/ccui/ui/layout/src/layout.tsx @@ -101,7 +101,7 @@ export const Sider = defineComponent({ // 不命中时自动 uncollapse。同步 emit @breakpoint(broken)。 let mql: MediaQueryList | null = null const onBreakpointChange = (e: MediaQueryListEvent | MediaQueryList) => { - const broken = 'matches' in e ? e.matches : (e as MediaQueryListEvent).matches + const broken = e.matches emit('breakpoint', broken) // 自动切换 collapsed 态(受控时不覆盖父值)。 if (props.collapsed === undefined) { @@ -125,6 +125,8 @@ export const Sider = defineComponent({ mql.removeEventListener('change', onBreakpointChange) mql = null } + // 卸载时从 Layout 注销自身,避免 siders 数组只增不减导致 has-sider 残留。 + ctx?.removeSider(id) }) const widthPx = computed(() => { diff --git a/packages/ccui/ui/mentions/src/mentions.scss b/packages/ccui/ui/mentions/src/mentions.scss index 05ba50b4..c3109c89 100644 --- a/packages/ccui/ui/mentions/src/mentions.scss +++ b/packages/ccui/ui/mentions/src/mentions.scss @@ -49,7 +49,7 @@ border: 1px solid $ccui-form-control-line; border-radius: 6px; background: $ccui-color-bg-container; - color: rgba(0, 0, 0, 0.88); + color: $ccui-color-text; font-size: 14px; line-height: 1.5; font-family: inherit; @@ -70,12 +70,12 @@ } &::placeholder { - color: rgba(0, 0, 0, 0.25); + color: $ccui-placeholder; } &:disabled { background: $ccui-list-item-hover-bg; - color: rgba(0, 0, 0, 0.4); + color: $ccui-disabled-text; cursor: not-allowed; } } @@ -89,9 +89,7 @@ background: $ccui-base-bg; border: 1px solid $ccui-dividing-line; border-radius: 6px; - box-shadow: - 0 6px 16px rgba(0, 0, 0, 0.08), - 0 3px 6px -4px rgba(0, 0, 0, 0.12); + box-shadow: $ccui-box-shadow-secondary; overflow: auto; } @@ -103,17 +101,17 @@ &__option { padding: 5px 12px; - color: rgba(0, 0, 0, 0.88); + color: $ccui-color-text; font-size: 14px; cursor: pointer; transition: background 0.2s; &.is-active { - background: rgba(0, 0, 0, 0.04); + background: $ccui-list-item-hover-bg; } &.is-disabled { - color: rgba(0, 0, 0, 0.25); + color: $ccui-color-text-disabled; cursor: not-allowed; background: transparent; } @@ -121,7 +119,7 @@ &__empty { padding: 12px; - color: rgba(0, 0, 0, 0.45); + color: $ccui-color-text-tertiary; font-size: 13px; text-align: center; } diff --git a/packages/ccui/ui/mentions/src/mentions.tsx b/packages/ccui/ui/mentions/src/mentions.tsx index 3481d907..a2d840cb 100644 --- a/packages/ccui/ui/mentions/src/mentions.tsx +++ b/packages/ccui/ui/mentions/src/mentions.tsx @@ -12,6 +12,7 @@ import { onUnmounted, ref, shallowRef, + watch, } from 'vue' import { useConfig } from '../../config-provider/src/config-provider' import { formItemInjectionKey } from '../../form/src/form-types' @@ -68,6 +69,15 @@ export default defineComponent({ }) }) + // 过滤列表收缩时,把 activeIndex 钳到首个可用项,避免越界导致无高亮且 Enter/Tab 选不中 + watch(filteredOptions, (list) => { + if (!open.value) return + if (activeIndex.value >= list.length || list[activeIndex.value]?.disabled) { + const first = list.findIndex((o) => !o.disabled) + activeIndex.value = first === -1 ? 0 : first + } + }) + let debounceTimer: ReturnType | null = null function setValue(next: string) { diff --git a/packages/ccui/ui/menu/src/menu.scss b/packages/ccui/ui/menu/src/menu.scss index e6286797..1c8714cf 100644 --- a/packages/ccui/ui/menu/src/menu.scss +++ b/packages/ccui/ui/menu/src/menu.scss @@ -60,7 +60,7 @@ &__item--active, &__submenu-title--active { - background-color: rgba(0, 0, 0, 0.04); + background-color: $ccui-color-fill-tertiary; } &__item--disabled, @@ -80,7 +80,7 @@ &:hover { color: $ccui-color-error; - background-color: rgba(255, 77, 79, 0.08); + background-color: color-mix(in srgb, $ccui-color-error 8%, transparent); } } @@ -207,6 +207,11 @@ background-color: rgba(255, 255, 255, 0.08); } + .#{$cls-prefix}-menu__item--active, + .#{$cls-prefix}-menu__submenu-title--active { + background-color: rgba(255, 255, 255, 0.08); + } + .#{$cls-prefix}-menu__item--selected { color: #ffffff; background-color: $ccui-color-primary; diff --git a/packages/ccui/ui/menu/src/menu.tsx b/packages/ccui/ui/menu/src/menu.tsx index 529916a7..c795110d 100644 --- a/packages/ccui/ui/menu/src/menu.tsx +++ b/packages/ccui/ui/menu/src/menu.tsx @@ -197,6 +197,19 @@ function renderItem({ item, level, keyPath, ns, ctx }: RenderItemArgs): JSX.Elem ) } +// 根据 keyPath 找到目标项真实的同级集合(keyPath 为 leaf-first:[item.key, parentKey, ..., rootKey]) +function findSiblingItems(roots: MenuItem[], keyPath: MenuKey[]): MenuItem[] { + // 去掉自身 key 并反转,得到从根到直接父级的祖先顺序 + const ancestors = keyPath.slice(1).reverse() + let level = roots + for (const ancestorKey of ancestors) { + const parent = level.find((m) => m.key === ancestorKey) + if (!parent?.children?.length) return level + level = parent.children + } + return level +} + export default defineComponent({ name: 'CMenu', props: menuProps, @@ -309,7 +322,8 @@ export default defineComponent({ let nextKeys = nextOpen ? [...openKeys.value, item.key] : openKeys.value.filter((openKey) => openKey !== item.key) if (props.accordion && nextOpen) { - const siblingKeys = props.items + const siblings = findSiblingItems(props.items, keyPath) + const siblingKeys = siblings .filter((menuItem) => menuItem.children?.length && menuItem.key !== item.key) .map((menuItem) => menuItem.key) nextKeys = nextKeys.filter((openKey) => !siblingKeys.includes(openKey) || openKey === item.key) diff --git a/packages/ccui/ui/message/src/message.scss b/packages/ccui/ui/message/src/message.scss index 93b687e8..8e58dc76 100644 --- a/packages/ccui/ui/message/src/message.scss +++ b/packages/ccui/ui/message/src/message.scss @@ -84,10 +84,7 @@ padding: 9px 12px; background-color: $ccui-color-bg-elevated; border-radius: $ccui-border-radius; - box-shadow: - 0 6px 16px 0 rgba(0, 0, 0, 0.08), - 0 3px 6px -4px rgba(0, 0, 0, 0.12), - 0 9px 28px 8px rgba(0, 0, 0, 0.05); + box-shadow: $ccui-box-shadow; font-size: $ccui-font-size; color: $ccui-color-text; line-height: $ccui-line-height-base; diff --git a/packages/ccui/ui/modal/src/modal.scss b/packages/ccui/ui/modal/src/modal.scss index 5066678d..b4310dd9 100644 --- a/packages/ccui/ui/modal/src/modal.scss +++ b/packages/ccui/ui/modal/src/modal.scss @@ -61,7 +61,7 @@ &:hover { color: $ccui-color-text; - background-color: rgba(0, 0, 0, 0.06); + background-color: $ccui-color-fill-secondary; } } @@ -108,38 +108,30 @@ border-color: $ccui-color-primary; } - &--cancel { - // default - } - &--primary { - color: #ffffff; + color: $ccui-color-text-light-solid; background-color: $ccui-color-primary; border-color: $ccui-color-primary; &:hover { background-color: $ccui-color-primary-hover; border-color: $ccui-color-primary-hover; - color: #ffffff; + color: $ccui-color-text-light-solid; } } &--danger { - color: #ffffff; + color: $ccui-color-text-light-solid; background-color: $ccui-color-error; border-color: $ccui-color-error; &:hover { background-color: $ccui-color-error-hover; border-color: $ccui-color-error-hover; - color: #ffffff; + color: $ccui-color-text-light-solid; } } - &--default { - // default - } - &.is-loading { cursor: not-allowed; opacity: 0.65; @@ -202,7 +194,7 @@ width: 22px; height: 22px; border-radius: 50%; - color: #ffffff; + color: $ccui-color-text-light-solid; font-size: 14px; font-weight: bold; line-height: 1; diff --git a/packages/ccui/ui/notification/src/notification.scss b/packages/ccui/ui/notification/src/notification.scss index b96eba99..4b89364c 100644 --- a/packages/ccui/ui/notification/src/notification.scss +++ b/packages/ccui/ui/notification/src/notification.scss @@ -82,10 +82,8 @@ padding: 16px 24px; background-color: $ccui-color-bg-elevated; border-radius: $ccui-border-radius; - box-shadow: - 0 6px 16px 0 rgba(0, 0, 0, 0.08), - 0 3px 6px -4px rgba(0, 0, 0, 0.12), - 0 9px 28px 8px rgba(0, 0, 0, 0.05); + // 弹层阴影统一走 token,与 dropdown/select/menu 等浮层族对齐,深色模式下自动加深 + box-shadow: $ccui-box-shadow-secondary; } &__icon { diff --git a/packages/ccui/ui/notification/src/notification.ts b/packages/ccui/ui/notification/src/notification.ts index 34be62cd..5352e813 100644 --- a/packages/ccui/ui/notification/src/notification.ts +++ b/packages/ccui/ui/notification/src/notification.ts @@ -106,7 +106,11 @@ function enforceMaxCount(placement: NotificationPlacement) { const cap = globalConfig.maxCount ?? Infinity if (!isFinite(cap) || cap <= 0) return const arr = lists[placement] - while (arr.length > cap) arr.shift() + // 被挤掉的通知也要触发 onClose,避免用户注册的回调被静默吞掉 + while (arr.length > cap) { + const removed = arr.shift() + removed?.options.onClose?.() + } } function open(options: NotificationOptions): NotificationHandle { diff --git a/packages/ccui/ui/notification/src/use-notification.ts b/packages/ccui/ui/notification/src/use-notification.ts index aef53e8a..907a3810 100644 --- a/packages/ccui/ui/notification/src/use-notification.ts +++ b/packages/ccui/ui/notification/src/use-notification.ts @@ -83,7 +83,11 @@ export function useNotification(): UseNotificationReturn { const cap = config.maxCount ?? Infinity if (!isFinite(cap) || cap <= 0) return const arr = lists[placement] - while (arr.length > cap) arr.shift() + // 被挤掉的通知也要触发 onClose,避免用户注册的回调被静默吞掉 + while (arr.length > cap) { + const removed = arr.shift() + removed?.options.onClose?.() + } } function open(options: NotificationOptions): NotificationHandle { diff --git a/packages/ccui/ui/popconfirm/src/popconfirm-types.ts b/packages/ccui/ui/popconfirm/src/popconfirm-types.ts index 581376e2..a239d286 100644 --- a/packages/ccui/ui/popconfirm/src/popconfirm-types.ts +++ b/packages/ccui/ui/popconfirm/src/popconfirm-types.ts @@ -40,7 +40,7 @@ export const popconfirmProps = { }, iconColor: { type: String, - default: '#faad14', + default: '', }, hideIcon: { type: Boolean, diff --git a/packages/ccui/ui/popconfirm/src/popconfirm.scss b/packages/ccui/ui/popconfirm/src/popconfirm.scss index cbe95cd4..67149ad4 100644 --- a/packages/ccui/ui/popconfirm/src/popconfirm.scss +++ b/packages/ccui/ui/popconfirm/src/popconfirm.scss @@ -27,7 +27,7 @@ height: 16px; font-size: 12px; font-weight: bold; - color: #ffffff; + color: $ccui-color-white; background: $ccui-color-warning; border-radius: 50%; line-height: 1; @@ -77,36 +77,30 @@ border-color: $ccui-color-primary; } - &--cancel { - // default style - } + // --cancel / --default 继承基础按钮样式 &--primary { - color: #ffffff; + color: $ccui-color-white; background-color: $ccui-color-primary; border-color: $ccui-color-primary; &:hover { - color: #ffffff; + color: $ccui-color-white; background-color: $ccui-color-primary-hover; border-color: $ccui-color-primary-hover; } } &--danger { - color: #ffffff; + color: $ccui-color-white; background-color: $ccui-color-error; border-color: $ccui-color-error; &:hover { - color: #ffffff; + color: $ccui-color-white; background-color: $ccui-color-error-hover; border-color: $ccui-color-error-hover; } } - - &--default { - // default - } } } diff --git a/packages/ccui/ui/popconfirm/src/popconfirm.tsx b/packages/ccui/ui/popconfirm/src/popconfirm.tsx index f4948342..bb686f7b 100644 --- a/packages/ccui/ui/popconfirm/src/popconfirm.tsx +++ b/packages/ccui/ui/popconfirm/src/popconfirm.tsx @@ -32,11 +32,13 @@ export default defineComponent({ }) const close = () => { - if (!isControlled.value) { - innerVisible.value = false + if (isControlled.value) { + // 受控:直接通知父,由父改 visible 收口关闭,不再调用 popover.hide() 触发二次 emit + emit('update:visible', false) + } else { + // 非受控:仅调 popover.hide(),由其 emit 经 onUpdate:visible 同步 innerVisible 并向上转发一次 + popoverRef.value?.hide?.() } - emit('update:visible', false) - popoverRef.value?.hide?.() } const onConfirm = (e: MouseEvent) => { @@ -70,7 +72,7 @@ export default defineComponent({
{!props.hideIcon && ( - + {props.icon ? : !} )} diff --git a/packages/ccui/ui/popover/src/popover.scss b/packages/ccui/ui/popover/src/popover.scss index f1839be9..839a6218 100644 --- a/packages/ccui/ui/popover/src/popover.scss +++ b/packages/ccui/ui/popover/src/popover.scss @@ -24,14 +24,14 @@ background: $ccui-feedback-overlay-bg; color: $ccui-light-text; border: 1px solid $ccui-feedback-overlay-bg; - box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1); + box-shadow: $ccui-box-shadow-secondary; } &--light { background: $ccui-base-bg; color: $ccui-aide-text-stress; border: 1px solid $ccui-color-border; - box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1); + box-shadow: $ccui-box-shadow-secondary; } } diff --git a/packages/ccui/ui/popover/src/popover.tsx b/packages/ccui/ui/popover/src/popover.tsx index f4325da1..5034ea8d 100644 --- a/packages/ccui/ui/popover/src/popover.tsx +++ b/packages/ccui/ui/popover/src/popover.tsx @@ -271,7 +271,37 @@ export default defineComponent({ let cleanup: (() => void) | undefined + // 虚拟触发:把触发事件绑定到外部 virtualRef 元素上 + // boundVirtualEl 记录实际绑定的元素,确保 virtualRef 变化时能从正确的节点上解绑 + let boundVirtualEl: HTMLElement | undefined + const bindVirtualTriggerEvents = () => { + if (!props.virtualTriggering || !props.virtualRef) return + const el = props.virtualRef + el.addEventListener('mouseenter', handleMouseEnter) + el.addEventListener('mouseleave', handleMouseLeave) + el.addEventListener('click', handleClick) + el.addEventListener('focus', handleFocus) + el.addEventListener('blur', handleBlur) + el.addEventListener('keydown', handleKeydown) + el.addEventListener('contextmenu', handleContextMenu) + boundVirtualEl = el + } + const unbindVirtualTriggerEvents = () => { + const el = boundVirtualEl + if (!el) return + el.removeEventListener('mouseenter', handleMouseEnter) + el.removeEventListener('mouseleave', handleMouseLeave) + el.removeEventListener('click', handleClick) + el.removeEventListener('focus', handleFocus) + el.removeEventListener('blur', handleBlur) + el.removeEventListener('keydown', handleKeydown) + el.removeEventListener('contextmenu', handleContextMenu) + boundVirtualEl = undefined + } + onMounted(() => { + // 虚拟触发模式下,把触发事件绑定到外部元素 + bindVirtualTriggerEvents() // 初始化时如果 visible 为 true,确保显示并监听文档级事件 if (props.visible) { void nextTick(() => { @@ -284,6 +314,14 @@ export default defineComponent({ }) } }) + // virtualRef / virtualTriggering 变化时,先从旧元素解绑再绑定到新元素 + watch( + () => [props.virtualTriggering, props.virtualRef] as const, + () => { + unbindVirtualTriggerEvents() + bindVirtualTriggerEvents() + }, + ) onUnmounted(() => { clearTimers() cleanup?.() @@ -291,16 +329,7 @@ export default defineComponent({ window.removeEventListener('keydown', onDocumentKeydown, true) // 清理虚拟触发元素的事件监听 - if (props.virtualTriggering && props.virtualRef) { - const virtualEl = props.virtualRef - virtualEl.removeEventListener('mouseenter', handleMouseEnter) - virtualEl.removeEventListener('mouseleave', handleMouseLeave) - virtualEl.removeEventListener('click', handleClick) - virtualEl.removeEventListener('focus', handleFocus) - virtualEl.removeEventListener('blur', handleBlur) - virtualEl.removeEventListener('keydown', handleKeydown) - virtualEl.removeEventListener('contextmenu', handleContextMenu) - } + unbindVirtualTriggerEvents() }) watch(externalOpen, (newVal) => { if (isControlled.value) { @@ -391,7 +420,7 @@ export default defineComponent({ ...inlineColorStyle.value, zIndex: 2000, pointerEvents: props.enterable ? 'auto' : 'none', - width: props.width || undefined, + width: props.width ? (typeof props.width === 'number' ? `${props.width}px` : props.width) : undefined, }} onMouseenter={handlePopperMouseEnter} onMouseleave={handlePopperMouseLeave} diff --git a/packages/ccui/ui/progress/src/progress-types.ts b/packages/ccui/ui/progress/src/progress-types.ts index 9745025e..46a648cd 100644 --- a/packages/ccui/ui/progress/src/progress-types.ts +++ b/packages/ccui/ui/progress/src/progress-types.ts @@ -38,7 +38,7 @@ export const progressProps = { default: 120, }, size: { - type: [String, Number, Array] as PropType, + type: String as PropType, default: 'default', }, format: { diff --git a/packages/ccui/ui/progress/src/progress.scss b/packages/ccui/ui/progress/src/progress.scss index c84b44d2..42bf0c6a 100644 --- a/packages/ccui/ui/progress/src/progress.scss +++ b/packages/ccui/ui/progress/src/progress.scss @@ -25,7 +25,7 @@ width: 100%; overflow: hidden; vertical-align: middle; - background-color: rgba(0, 0, 0, 0.06); + background-color: $ccui-color-fill-secondary; border-radius: 100px; } diff --git a/packages/ccui/ui/progress/src/progress.tsx b/packages/ccui/ui/progress/src/progress.tsx index a74cbaf5..1ec1c61b 100644 --- a/packages/ccui/ui/progress/src/progress.tsx +++ b/packages/ccui/ui/progress/src/progress.tsx @@ -121,7 +121,7 @@ export default defineComponent({ cy="50" r={radius} fill="none" - stroke={props.trailColor || 'rgba(0, 0, 0, 0.06)'} + stroke={props.trailColor || 'var(--ccui-color-fill-secondary, rgba(0, 0, 0, 0.06))'} stroke-width={strokeWidth} stroke-dasharray={isDashboard ? `${adjustedDashArray} ${circumference}` : `${circumference}`} stroke-linecap="round" diff --git a/packages/ccui/ui/range-picker/src/range-picker-types.ts b/packages/ccui/ui/range-picker/src/range-picker-types.ts index eaa9541e..6b643fd4 100644 --- a/packages/ccui/ui/range-picker/src/range-picker-types.ts +++ b/packages/ccui/ui/range-picker/src/range-picker-types.ts @@ -156,11 +156,6 @@ export const rangePickerProps = { type: [Boolean, Object] as PropType, default: false, }, - // 是否在 footer 显示「此刻」按钮。仅 showTime 启用时显示。 - showNow: { - type: Boolean, - default: true, - }, /** * 录入组件统一 variant 形态。 */ diff --git a/packages/ccui/ui/range-picker/src/range-picker.tsx b/packages/ccui/ui/range-picker/src/range-picker.tsx index b693a450..482a7398 100644 --- a/packages/ccui/ui/range-picker/src/range-picker.tsx +++ b/packages/ccui/ui/range-picker/src/range-picker.tsx @@ -286,6 +286,9 @@ export default defineComponent({ if (!selectedStart.value && !selectedEnd.value) return pendingStart.value = null pendingEnd.value = null + // 复位选择阶段与 hover,保证清空后从 start 端重新选起 + phase.value = 'start' + hoverDate.value = null emitRange(null, null) } @@ -448,7 +451,13 @@ export default defineComponent({ function renderGrid(cells: ReturnType) { return ( -
+
{ + // 指针移出整个网格时清除 hover 预览,避免残留高亮 + hoverDate.value = null + }} + > {cells.map((cell) => { // 显示禁用以当前 phase 为准(用户先填 start 再填 end,对应该侧的限制) const disabled = isDisabledFor(cell.date, phase.value) @@ -545,7 +554,8 @@ export default defineComponent({ } function renderFooter() { - const okDisabled = !pendingStart.value || !pendingEnd.value + const okDisabled = + (!pendingStart.value && !allowEmptyTuple.value[0]) || (!pendingEnd.value && !allowEmptyTuple.value[1]) return (
)}
diff --git a/packages/ccui/ui/notification/src/notification-item.tsx b/packages/ccui/ui/notification/src/notification-item.tsx index 326b1f91..454b42ff 100644 --- a/packages/ccui/ui/notification/src/notification-item.tsx +++ b/packages/ccui/ui/notification/src/notification-item.tsx @@ -67,6 +67,7 @@ export default defineComponent({ style={props.styles?.root} role={props.role} aria-live={props.role === 'alert' ? 'assertive' : 'polite'} + aria-atomic={true} onMouseenter={onMouseenter} onMouseleave={onMouseleave} > diff --git a/packages/ccui/ui/notification/src/notification.ts b/packages/ccui/ui/notification/src/notification.ts index 5352e813..4dcf794f 100644 --- a/packages/ccui/ui/notification/src/notification.ts +++ b/packages/ccui/ui/notification/src/notification.ts @@ -8,6 +8,7 @@ import type { NotificationType, } from './notification-types' import { createApp, h, reactive } from 'vue' +import { useNamespace } from '../../shared/hooks/use-namespace' import NotificationItem from './notification-item' import './notification.scss' @@ -19,6 +20,7 @@ interface NotificationInstance { const PLACEMENTS: NotificationPlacement[] = ['top', 'topRight', 'topLeft', 'bottom', 'bottomRight', 'bottomLeft'] let counter = 0 +const ns = useNamespace('notification') const containers = new Map() const apps = new Map>() const lists = reactive>({ @@ -50,8 +52,8 @@ function normalizeDuration(input: number | undefined, defaultSeconds: number): n function ensureContainer(placement: NotificationPlacement) { if (containers.has(placement)) return const el = document.createElement('div') - el.className = `ccui-notification ccui-notification--${placement}` - if (globalConfig.stack) el.classList.add('ccui-notification--stack') + el.className = `${ns.b()} ${ns.m(placement)}` + if (globalConfig.stack) el.classList.add(ns.m('stack')) if (placement === 'top' || placement.startsWith('top')) { if (globalConfig.top !== undefined) { el.style.top = typeof globalConfig.top === 'number' ? `${globalConfig.top}px` : globalConfig.top @@ -142,8 +144,8 @@ const notification = { config(cfg: NotificationGlobalConfig) { Object.assign(globalConfig, cfg) containers.forEach((el) => { - if (globalConfig.stack) el.classList.add('ccui-notification--stack') - else el.classList.remove('ccui-notification--stack') + if (globalConfig.stack) el.classList.add(ns.m('stack')) + else el.classList.remove(ns.m('stack')) }) }, destroy(): void { diff --git a/packages/ccui/ui/notification/src/use-notification.ts b/packages/ccui/ui/notification/src/use-notification.ts index 907a3810..36f9ec8f 100644 --- a/packages/ccui/ui/notification/src/use-notification.ts +++ b/packages/ccui/ui/notification/src/use-notification.ts @@ -7,6 +7,7 @@ import type { NotificationType, } from './notification-types' import { defineComponent, h, reactive, Teleport } from 'vue' +import { useNamespace } from '../../shared/hooks/use-namespace' import NotificationItem from './notification-item' import './notification.scss' @@ -127,6 +128,7 @@ export function useNotification(): UseNotificationReturn { const holder: Component = defineComponent({ name: 'CNotificationHolder', setup() { + const ns = useNamespace('notification') const onDestroy = (placement: NotificationPlacement, id: string) => { const arr = lists[placement] const idx = arr.findIndex((it) => it.id === id) @@ -147,7 +149,7 @@ export function useNotification(): UseNotificationReturn { 'div', { key: p, - class: ['ccui-notification', `ccui-notification--${p}`, config.stack && 'ccui-notification--stack'], + class: [ns.b(), ns.m(p), config.stack && ns.m('stack')], style: containerStyle, }, lists[p].map((it) => diff --git a/packages/ccui/ui/pagination/src/pagination.tsx b/packages/ccui/ui/pagination/src/pagination.tsx index b1260b4a..0d3f4a70 100644 --- a/packages/ccui/ui/pagination/src/pagination.tsx +++ b/packages/ccui/ui/pagination/src/pagination.tsx @@ -153,8 +153,18 @@ export default defineComponent({
  • goTo(page)} + onKeydown={(e: KeyboardEvent) => { + // 键盘可达:Enter / Space 触发与点击相同的跳页逻辑 + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + goTo(page) + } + }} > {page}
  • @@ -216,7 +226,19 @@ export default defineComponent({ {renderTotal()}
  • { + // 键盘可达:禁用态忽略,Enter / Space 触发与点击相同的上一页逻辑 + if (prevDisabled) { + return + } + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + handlePrev() + } + }} aria-disabled={prevDisabled} aria-label={locale.value.prevPage || '上一页'} > @@ -246,7 +268,19 @@ export default defineComponent({
  • { + // 键盘可达:禁用态忽略,Enter / Space 触发与点击相同的下一页逻辑 + if (nextDisabled) { + return + } + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + handleNext() + } + }} aria-disabled={nextDisabled} aria-label={locale.value.nextPage || '下一页'} > diff --git a/packages/ccui/ui/popover/src/popover.tsx b/packages/ccui/ui/popover/src/popover.tsx index 5034ea8d..f064409d 100644 --- a/packages/ccui/ui/popover/src/popover.tsx +++ b/packages/ccui/ui/popover/src/popover.tsx @@ -418,7 +418,6 @@ export default defineComponent({ style={{ ...floatingStyles.value, ...inlineColorStyle.value, - zIndex: 2000, pointerEvents: props.enterable ? 'auto' : 'none', width: props.width ? (typeof props.width === 'number' ? `${props.width}px` : props.width) : undefined, }} diff --git a/packages/ccui/ui/radio/src/radio-group.tsx b/packages/ccui/ui/radio/src/radio-group.tsx index e956aafe..9f616149 100644 --- a/packages/ccui/ui/radio/src/radio-group.tsx +++ b/packages/ccui/ui/radio/src/radio-group.tsx @@ -23,13 +23,8 @@ export default defineComponent({ emitChangeValue, }) - const directionType = { - row: 'is-row', - column: 'is-column', - } - const radioGroupClass = computed(() => { - return `${ns.b()} ${directionType[props.direction]}` + return `${ns.b()} ${ns.is(props.direction)}` }) return () => { diff --git a/packages/ccui/ui/rate/src/rate.tsx b/packages/ccui/ui/rate/src/rate.tsx index b9fdf9f8..8498fb48 100644 --- a/packages/ccui/ui/rate/src/rate.tsx +++ b/packages/ccui/ui/rate/src/rate.tsx @@ -1,5 +1,5 @@ import type { RateProps } from './rate-types' -import { computed, defineComponent, ref } from 'vue' +import { computed, defineComponent, ref, watch } from 'vue' import { useNamespace } from '../../shared/hooks/use-namespace' import iconDefault from './components/icon-default' import { rateProps } from './rate-types' @@ -37,6 +37,15 @@ export default defineComponent({ setIconState(selectedQuantity.value) + // 监听外部 modelValue 变化(受控更新 / 编程式重置),同步内部选中状态与图标宽度 + watch( + () => props.modelValue, + (v) => { + selectedQuantity.value = v + setIconState(v) + }, + ) + // 判断当前鼠标事件的目标是否是半选 // 从监听器自身元素(currentTarget)计算几何,避免 e.target 落在内部 svg/span 导致 offsetX 与 clientWidth 取自不同元素而错判 const isSemiSelected = (e: MouseEvent) => { diff --git a/packages/ccui/ui/result/src/result.scss b/packages/ccui/ui/result/src/result.scss index f3d8de8a..7a283044 100644 --- a/packages/ccui/ui/result/src/result.scss +++ b/packages/ccui/ui/result/src/result.scss @@ -22,13 +22,13 @@ // 内置状态:通过状态修饰类驱动 svg fill="currentColor"。 &--success &__icon { - color: var(--ccui-success, #52c41a); + color: $ccui-color-success; } &--error &__icon { - color: var(--ccui-danger, #ff4d4f); + color: $ccui-color-error; } &--warning &__icon { - color: var(--ccui-warning, #faad14); + color: $ccui-color-warning; } &--info &__icon, &--404 &__icon, diff --git a/packages/ccui/ui/slider/src/composables/use-slider.ts b/packages/ccui/ui/slider/src/composables/use-slider.ts index 2da30c82..a2a0e42b 100644 --- a/packages/ccui/ui/slider/src/composables/use-slider.ts +++ b/packages/ccui/ui/slider/src/composables/use-slider.ts @@ -22,10 +22,10 @@ export function useSliderValue( } export function useSliderCalculation(props: SliderProps) { - const range = props.max - props.min - // 计算百分比位置 const getPercent = (value: number) => { + // 量程每次调用实时从 props 读取,min/max 变化后位置计算同步更新 + const range = props.max - props.min if (range <= 0) return 0 const percent = ((value - props.min) / range) * 100 @@ -34,6 +34,8 @@ export function useSliderCalculation(props: SliderProps) { // 根据百分比计算值(按 step 和 min 对齐) const getValueFromPercent = (percent: number) => { + // 量程每次调用实时从 props 读取,与下方 clamp/step 对齐共用同一量程 + const range = props.max - props.min if (range <= 0) return props.min const raw = props.min + (percent / 100) * range diff --git a/packages/ccui/ui/space-compact/src/space-compact.scss b/packages/ccui/ui/space-compact/src/space-compact.scss index daa83f0e..be946b95 100644 --- a/packages/ccui/ui/space-compact/src/space-compact.scss +++ b/packages/ccui/ui/space-compact/src/space-compact.scss @@ -23,18 +23,24 @@ } &--horizontal > *:not(:first-child):not(:last-child), - &--horizontal > *:not(:first-child):not(:last-child) :is(input, button, .ccui-input, .ccui-button, .ccui-select) { + &--horizontal + > *:not(:first-child):not(:last-child) + :is(input, button, .#{$cls-prefix}-input, .#{$cls-prefix}-button, .#{$cls-prefix}-select) { border-radius: 0; } &--horizontal > *:first-child:not(:last-child), - &--horizontal > *:first-child:not(:last-child) :is(input, button, .ccui-input, .ccui-button, .ccui-select) { + &--horizontal + > *:first-child:not(:last-child) + :is(input, button, .#{$cls-prefix}-input, .#{$cls-prefix}-button, .#{$cls-prefix}-select) { border-top-right-radius: 0; border-bottom-right-radius: 0; } &--horizontal > *:last-child:not(:first-child), - &--horizontal > *:last-child:not(:first-child) :is(input, button, .ccui-input, .ccui-button, .ccui-select) { + &--horizontal + > *:last-child:not(:first-child) + :is(input, button, .#{$cls-prefix}-input, .#{$cls-prefix}-button, .#{$cls-prefix}-select) { border-top-left-radius: 0; border-bottom-left-radius: 0; } @@ -51,18 +57,24 @@ } &--vertical > *:not(:first-child):not(:last-child), - &--vertical > *:not(:first-child):not(:last-child) :is(input, button, .ccui-input, .ccui-button, .ccui-select) { + &--vertical + > *:not(:first-child):not(:last-child) + :is(input, button, .#{$cls-prefix}-input, .#{$cls-prefix}-button, .#{$cls-prefix}-select) { border-radius: 0; } &--vertical > *:first-child:not(:last-child), - &--vertical > *:first-child:not(:last-child) :is(input, button, .ccui-input, .ccui-button, .ccui-select) { + &--vertical + > *:first-child:not(:last-child) + :is(input, button, .#{$cls-prefix}-input, .#{$cls-prefix}-button, .#{$cls-prefix}-select) { border-bottom-left-radius: 0; border-bottom-right-radius: 0; } &--vertical > *:last-child:not(:first-child), - &--vertical > *:last-child:not(:first-child) :is(input, button, .ccui-input, .ccui-button, .ccui-select) { + &--vertical + > *:last-child:not(:first-child) + :is(input, button, .#{$cls-prefix}-input, .#{$cls-prefix}-button, .#{$cls-prefix}-select) { border-top-left-radius: 0; border-top-right-radius: 0; } diff --git a/packages/ccui/ui/spin/src/spin.tsx b/packages/ccui/ui/spin/src/spin.tsx index eadd2abe..156fd1ed 100644 --- a/packages/ccui/ui/spin/src/spin.tsx +++ b/packages/ccui/ui/spin/src/spin.tsx @@ -39,7 +39,7 @@ export default defineComponent({ const indicatorCls = computed(() => ({ [ns.e('dot')]: true, - [ns.em('dot', props.size)]: true, + ...(props.size !== 'default' ? { [ns.em('dot', props.size)]: true } : {}), })) const renderIndicator = () => { @@ -57,7 +57,7 @@ export default defineComponent({ } const renderSpinner = () => ( -
    +
    {renderIndicator()} {props.tip &&
    {props.tip}
    }
    @@ -78,7 +78,12 @@ export default defineComponent({ return (
    {visible.value &&
    {renderSpinner()}
    } -
    {slots.default()}
    +
    + {slots.default()} +
    ) } diff --git a/packages/ccui/ui/steps/src/steps.tsx b/packages/ccui/ui/steps/src/steps.tsx index 156cfed4..3395779b 100644 --- a/packages/ccui/ui/steps/src/steps.tsx +++ b/packages/ccui/ui/steps/src/steps.tsx @@ -76,10 +76,20 @@ export default defineComponent({ key={index} class={itemCls} role="listitem" + tabindex={item.disabled ? -1 : 0} aria-current={isCurrent ? 'step' : undefined} aria-disabled={item.disabled || undefined} aria-label={stepLabel} onClick={() => onItemClick(index, item)} + onKeydown={(e: KeyboardEvent) => { + if (item.disabled) { + return + } + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onItemClick(index, item) + } + }} >
    diff --git a/packages/ccui/ui/table-column/src/table-column.tsx b/packages/ccui/ui/table-column/src/table-column.tsx index d70234d9..c300b7a0 100644 --- a/packages/ccui/ui/table-column/src/table-column.tsx +++ b/packages/ccui/ui/table-column/src/table-column.tsx @@ -16,6 +16,13 @@ export default defineComponent({ const id = Symbol('CTableColumn') const order = nextColumnOrder() + // 稳定的渲染代理:闭包内部读 slot/prop 最新值(slot 优先于 function prop,且保留响应式), + // 引用始终复用,避免 getter 每次读取都新建临时闭包。 + const renderProxy = (scope: { text: any; record: any; index: number; column: TableColumn }) => { + if (slots.customRender) return slots.customRender(scope) + return props.customRender?.(scope) + } + // 关键:用 getter 暴露字段,让 Table 端的 render 读取时直接跟 props 建立响应式依赖。 // 这样 props 变动不需要在本组件 watch 里 re-register —— 避免 function prop 引用频繁变化导致的递归刷新。 const column = {} as TableColumn @@ -34,15 +41,9 @@ export default defineComponent({ filterMultiple: { get: () => props.filterMultiple, enumerable: true, configurable: true }, onCell: { get: () => props.onCell, enumerable: true, configurable: true }, onHeaderCell: { get: () => props.onHeaderCell, enumerable: true, configurable: true }, - // slot 优先于 function prop;用稳定闭包代理,闭包内部读 slot/prop 最新值。 + // slot 优先于 function prop;复用稳定闭包代理,仅在两者皆无时返回 undefined。 customRender: { - get: () => { - if (!slots.customRender && !props.customRender) return undefined - return (scope: { text: any; record: any; index: number; column: TableColumn }) => { - if (slots.customRender) return slots.customRender(scope) - return props.customRender?.(scope) - } - }, + get: () => (slots.customRender || props.customRender ? renderProxy : undefined), enumerable: true, configurable: true, }, diff --git a/packages/ccui/ui/table-summary/src/table-summary.tsx b/packages/ccui/ui/table-summary/src/table-summary.tsx index b1b14a23..a8a1882c 100644 --- a/packages/ccui/ui/table-summary/src/table-summary.tsx +++ b/packages/ccui/ui/table-summary/src/table-summary.tsx @@ -1,5 +1,5 @@ import type { TableSummaryProps } from './table-summary-types' -import { defineComponent, inject, onBeforeUnmount, watchEffect } from 'vue' +import { defineComponent, inject, onBeforeUnmount } from 'vue' import { tableSummaryCollectorKey } from '../../table/src/table-types' import { tableSummaryProps } from './table-summary-types' @@ -11,10 +11,8 @@ export default defineComponent({ if (collector) { // 把 default slot 引用交给 Table;Table 在 tfoot 内调用渲染。 - // 用 watchEffect 而非一次性 push,是因为 slots.default 在某些场景下是动态的。 - watchEffect(() => { - collector.setSummary(slots.default ?? null) - }) + // 一次性注册即可:slot 函数在 Table 渲染时调用,自行捕获父作用域响应式。 + collector.setSummary(slots.default ?? null) onBeforeUnmount(() => collector.setSummary(null)) } // 脱离 父级时静默渲染 null。 diff --git a/packages/ccui/ui/table/src/table.tsx b/packages/ccui/ui/table/src/table.tsx index 464f2614..29a250f6 100644 --- a/packages/ccui/ui/table/src/table.tsx +++ b/packages/ccui/ui/table/src/table.tsx @@ -1,4 +1,4 @@ -import type { CSSProperties, Slot, VNodeChild } from 'vue' +import type { CSSProperties, Slot, VNode, VNodeChild } from 'vue' import type { TableCellRenderProps, TableColumn, @@ -531,6 +531,14 @@ export default defineComponent({ 'aria-label': 'Select all rows', onClick: (event: MouseEvent) => event.stopPropagation(), onChange: (event: Event) => handleSelectAll((event.target as HTMLInputElement).checked), + // indeterminate 只能通过 JS property 设置(无法用 attribute), + // 在挂载/更新时同步原生半选状态,让屏幕阅读器能播报“部分选中” + onVnodeMounted: (vnode: VNode) => { + ;(vnode.el as HTMLInputElement).indeterminate = isSomeDisplayRowsSelected.value + }, + onVnodeUpdated: (vnode: VNode) => { + ;(vnode.el as HTMLInputElement).indeterminate = isSomeDisplayRowsSelected.value + }, }), ) } diff --git a/packages/ccui/ui/tooltip/src/tooltip.tsx b/packages/ccui/ui/tooltip/src/tooltip.tsx index 1ac9557e..4992188b 100644 --- a/packages/ccui/ui/tooltip/src/tooltip.tsx +++ b/packages/ccui/ui/tooltip/src/tooltip.tsx @@ -40,12 +40,14 @@ export default defineComponent({ const { floatingStyles, middlewareData, placement, update } = useFloating(triggerRef, popperRef, { // 传响应式 placement,保证 placement prop 变化与 flip 翻转都能被 floating-ui 跟踪 placement: toRef(props, 'placement') as any, - middleware: [ + // middleware 用 computed 包裹,让 floating-ui 通过 toValue 重新求值, + // 保证运行时切换 offset / autoAdjustOverflow / showArrow 能重建中间件链 + middleware: computed(() => [ offset(props.offset), ...(props.autoAdjustOverflow ? [flip()] : []), shift({ padding: 8 }), ...(props.showArrow ? [arrow({ element: arrowRef })] : []), - ], + ]) as any, }) // 实际生效的方位(flip 翻转后可能与 props.placement 不同),箭头/样式都以此为准 diff --git a/packages/ccui/ui/transfer/src/transfer.tsx b/packages/ccui/ui/transfer/src/transfer.tsx index d41d9dd2..9ddb8460 100644 --- a/packages/ccui/ui/transfer/src/transfer.tsx +++ b/packages/ccui/ui/transfer/src/transfer.tsx @@ -189,6 +189,8 @@ export default defineComponent({ keys.splice(toIdx, 0, dragKey.value) emit('update:targetKeys', keys) emit('change', keys, 'right' as TransferDirection, [dragKey.value]) + // 与 move() 保持一致:实际发生重排序后触发 form 校验 + formItem?.validate('change') } } dragKey.value = null diff --git a/packages/ccui/ui/tree-select/src/tree-select.tsx b/packages/ccui/ui/tree-select/src/tree-select.tsx index 4fa89422..aed424c3 100644 --- a/packages/ccui/ui/tree-select/src/tree-select.tsx +++ b/packages/ccui/ui/tree-select/src/tree-select.tsx @@ -269,13 +269,13 @@ export default defineComponent({ emitChange(keys.length === 0 ? null : keys) } - function removeTag(e: MouseEvent, key: TreeNodeKey) { + function removeTag(e: MouseEvent | KeyboardEvent, key: TreeNodeKey) { e.stopPropagation() const next = selectedKeys.value.filter((k) => k !== key) emitChange(next.length === 0 ? null : next) } - function clear(e: MouseEvent) { + function clear(e: MouseEvent | KeyboardEvent) { e.stopPropagation() if (selectedKeys.value.length === 0) return emitChange(null) @@ -480,7 +480,14 @@ export default defineComponent({ class={ns.e('tag-close')} role="button" aria-label="移除" + tabindex={0} onClick={(e: MouseEvent) => removeTag(e, tag.key)} + onKeydown={(e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + removeTag(e, tag.key) + } + }} > {slots.removeIcon ? slots.removeIcon() @@ -536,7 +543,19 @@ export default defineComponent({ > {renderInputContent()} {showClear.value ? ( - + { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + clear(e) + } + }} + > {slots.clearIcon ? slots.clearIcon() : (renderIconNode(props.clearIcon) ?? renderIconNode('mdi:close'))} ) : ( diff --git a/packages/ccui/ui/tree/src/tree.tsx b/packages/ccui/ui/tree/src/tree.tsx index dd2bb7cd..726a74e3 100644 --- a/packages/ccui/ui/tree/src/tree.tsx +++ b/packages/ccui/ui/tree/src/tree.tsx @@ -21,7 +21,7 @@ function titleToString(value: unknown): string { return '' } -function highlightTitle(title: unknown, keyword: string): VNode | string { +function highlightTitle(title: unknown, keyword: string, highlightClass: string): VNode | string { const str = titleToString(title) if (!keyword) { return str @@ -34,7 +34,7 @@ function highlightTitle(title: unknown, keyword: string): VNode | string { } return h('span', null, [ str.slice(0, idx), - h('span', { class: 'ccui-tree__highlight' }, str.slice(idx, idx + keyword.length)), + h('span', { class: highlightClass }, str.slice(idx, idx + keyword.length)), str.slice(idx + keyword.length), ]) } @@ -363,11 +363,20 @@ export default defineComponent({ { class: ns.e('switcher-error'), role: 'button', + tabindex: 0, title: 'Click to retry', onClick: (event: MouseEvent) => { event.stopPropagation() void retryLoad(node.key) }, + onKeydown: (event: KeyboardEvent) => { + // 键盘可达性:Enter / Space 触发与 onClick 相同的重试逻辑 + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + event.stopPropagation() + void retryLoad(node.key) + } + }, }, '!', ) @@ -413,7 +422,7 @@ export default defineComponent({ if (slots.title) { return slots.title({ node, data: node.raw, expanded: expandedKeys.value.has(node.key) }) } - return highlightTitle(node.title, searchValue.value) + return highlightTitle(node.title, searchValue.value, ns.e('highlight')) } const renderIcon = (node: FlattenedTreeNode) => { diff --git a/packages/ccui/ui/typography/src/typography.tsx b/packages/ccui/ui/typography/src/typography.tsx index 5de355b0..e1d90cc0 100644 --- a/packages/ccui/ui/typography/src/typography.tsx +++ b/packages/ccui/ui/typography/src/typography.tsx @@ -207,6 +207,14 @@ function createTypographyComponent( !!ellipsisConfig.value && !expanded.value && ellipsisRows.value > 1, })) + // 让 role="button" 的 span 键盘可达:Enter / Space 触发与 onClick 相同的逻辑 + const onActivate = (handler: () => void) => (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + handler() + } + } + const renderCopyBtn = (): VNode | null => { const cfg = copyConfig.value if (!cfg) return null @@ -217,8 +225,10 @@ function createTypographyComponent( {slots['copy-icon']({ copied: copied.value })} @@ -228,8 +238,10 @@ function createTypographyComponent( {copied.value ? renderIconNode('mdi:check') : renderIconNode('mdi:content-copy')} @@ -243,13 +255,27 @@ function createTypographyComponent( if (!triggerTypes.includes('icon')) return null if (slots['edit-icon']) { return ( - + {slots['edit-icon']()} ) } return ( - + ) @@ -260,13 +286,25 @@ function createTypographyComponent( const cfg = ellipsisConfig.value! if (expanded.value) { return ( - + {slots['collapse-text'] ? slots['collapse-text']() : '收起'} ) } return ( - + {slots['expand-text'] ? slots['expand-text']() : '展开'} ) diff --git a/packages/ccui/ui/upload/src/upload.scss b/packages/ccui/ui/upload/src/upload.scss index 46d32267..96a17d48 100644 --- a/packages/ccui/ui/upload/src/upload.scss +++ b/packages/ccui/ui/upload/src/upload.scss @@ -192,7 +192,7 @@ background: $ccui-color-fill-quaternary; &:hover { - border-color: $ccui-primary; + border-color: $ccui-color-primary; } } From e64b0bf9462c7b357d72a2656ed991a721425334 Mon Sep 17 00:00:00 2001 From: vaebe <18137693952@163.com> Date: Fri, 5 Jun 2026 13:45:16 +0800 Subject: [PATCH 3/3] =?UTF-8?q?docs(ccui):=20=E5=A4=9A=E4=BB=A3=E7=90=86?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E2=80=94=E2=80=94=E5=89=A9=E4=BD=9958?= =?UTF-8?q?=E6=9D=A1=E5=BE=85=E4=BA=BA=E5=B7=A5=E4=BB=8B=E5=85=A5=E6=B8=85?= =?UTF-8?q?=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 经多代理重确认后仍需人工决策的项(涉及对外行为/API、快照契约、设计取舍或较大重构)。 已自动修复部分见 15a3548 / 759b980。 Co-Authored-By: Claude Opus 4.8 (1M context) --- CCUI-OPTIMIZATION-RISK-REPORT.md | 391 +++++++++++++++++++++++++++++++ 1 file changed, 391 insertions(+) create mode 100644 CCUI-OPTIMIZATION-RISK-REPORT.md diff --git a/CCUI-OPTIMIZATION-RISK-REPORT.md b/CCUI-OPTIMIZATION-RISK-REPORT.md new file mode 100644 index 00000000..36c4e15b --- /dev/null +++ b/CCUI-OPTIMIZATION-RISK-REPORT.md @@ -0,0 +1,391 @@ +# ccui 组件待人工介入清单(58 条) + +> 经多代理重确认(每条 2 个对立视角 + 分歧裁决):原 131 条待审项中,0 误报;73 条已确认为安全自动修并已提交(commit 759b980);下列为**真正需要人工决策**的项——涉及对外行为/API 变更、快照/测试契约、设计取舍或较大重构。13 条标注「未复核」是裁决代理偶发失败、保守列入。 + + +## 响应式(行为/重构/契约) — 8 条 + + +### avatar +- **[high]** img/name/error/nobody 元素在 setup 期一次性构建,prop 变更后不重渲 + `avatar/src/avatar.tsx` @ 54-124, 138-145 + 方向:Move all four JSX const blocks AND the hasImgElement/hasNameElement helper bodies inside the returned render function so createVNode re-runs per render. Keep ns, styleNs, the backgroundNs computed, showErrorAvatar, and the watch outside. Concretely: `return () + +### config-provider +- **[high]** provide 注入的是 computed 的快照 ctx.value,子树永久丢失响应性 + `config-provider/src/config-provider.tsx` @ 76 + 方向:Provide the computed ref and unwrap it inside useConfig so all existing consumers keep reading plain properties. (1) Change line 76 to `provide(CONFIG_INJECT_KEY, ctx)`. (2) Type the key as `InjectionKey>` (or cast) and rewrite useCo + +### tabs +- **[medium]** 外部修改 modelValue 不会同步 active(缺 watch) + `tabs/src/tabs.tsx` @ 16 + 方向:需在 import 中加入 watch(当前 line 2 只导入了 defineComponent, provide, reactive),再在 setup 内 state 声明后添加同步逻辑: + +import { defineComponent, provide, reactive, watch } from 'vue' + +watch(() => props.modelValue, (v) => { if (v !== state.active) state.active = v }) + +加 v !== sta + +### tree +- **[medium]** defaultExpandAll 只在 setup 同步执行一次,异步/后到的 data 不生效 + `tree/src/tree.tsx` @ 72 + 方向:The bug is real but requires changes in BOTH files because the proposed watch alone won't propagate. Option A (recommended, minimal-risk): keep computing initialExpandedAll reactively AND make useControllableSet react to defaultValue changes when uncontrolled. + +### descriptions +- **[medium]** computed 内调用 slot 渲染函数并缓存 VNode,存在 VNode 复用风险 + `descriptions/src/descriptions.tsx` @ 36-37 + 方向:把 slot 的 VNode 调用从 computed 内推迟到 render 内(thunk 形态),slot 路径不缓存 VNode 实例。最小修改如下: + +1) ResolvedItem 的 label/content 改为可承载 thunk: + label: VNode | string | (() => VNode) + content: VNode | string | number | (() => VNode) + +2) resolveFromSlots 第36-37行存 thunk 而非立刻调 + +### textarea +- **[medium]** defaultValue 会吞掉“受控且初值为空字符串”的合法场景 + `textarea/src/textarea.tsx` @ 53 + 方向:The proposed fix `props.defaultValue ?? props.modelValue` is a no-op: in that branch props.modelValue is already '' (its default), so it equals the current `props.defaultValue ?? ''`. A correct fix requires making "provided" detectable: in textarea-types.ts ch + +### masonry +- **[medium]** ⚠️未复核 用数组下标 ci/ii 作 key,列数变化时 DOM 复用错位 + `masonry/src/masonry.tsx` @ 109,112 + 方向:用全局序号 gi 做稳定身份,优先 VNode 自带 key。修改 columns 计算携带 gi,并在渲染用 key={vnode.key ?? gi}。 + +1) 把 columns 计算改为携带原始全局序号(合并 sequential/默认两分支,因二者逻辑当前完全相同): + +const columns = computed(() => { + const items = flatChildren(slots.default?.() ?? []) + const cols: { vnode: VNode, gi + +### affix +- **[low]** watch target 仅监听 prop 引用变化,函数型 target 返回值改变时不会重新绑定 + `affix/src/affix.tsx` @ 153-160 + 方向:优先采用文档化方案:明确"函数型 target 仅在挂载时解析一次,引用不变则不重绑"。若要代码修复,不应放进每帧 scroll 都会跑的 update()(会引入每次滚动调用 resolveTarget + DOM 查询的开销与潜在反复 add/removeEventListener),而应仅在 onMounted 的 requestAnimationFrame 回调里或 resize/ResizeObserver 等低频时机做一次比对:`const next = resolveTarget(props.targ + +## 逻辑(设计取舍/时序) — 8 条 + + +### date-picker +- **[high]** showTime 下点击 clear 未重置 pendingValue / pendingDirty,面板残留旧高亮且 ok 仍可点 + `date-picker/src/date-picker.tsx` @ 401-405 + 方向:The proposed fix is directionally right but unsafe as written: it calls initialPendingTime(), which reads selectedDayjs.value (a computed off props.modelValue). emitChange(null) only emits update:modelValue and does NOT synchronously update selectedDayjs, so i + +### tag +- **[high]** variant 的 filled/solid/outlined 修饰类无任何样式,filled 与 solid 渲染完全一致 + `tag/src/tag.scss` @ 119 + 方向:The defect is real, but a blanket `&--variant-solid` background rule is not minimal/safe: solid's promise is per-preset-color saturation, and the preset colors are generated in the `@each` loop (lines 119-126) as `background: var(--ccui-#{$name}-1)` plus statu + +### message +- **[medium]** enforceMaxCount 用 arr.shift() 直接移除最旧消息,跳过 leave 过渡动画 + `message/src/use-message.ts` @ 80 + 方向:The bug is real but the suggested "call the oldest item's close" fix is NOT directly applicable: MessageItem (message-item.tsx) keeps `visible` and `close` internal to setup and never `expose`s them, and the holder renders children via h(MessageItem,...) with + +### modal +- **[medium]** Escape 关闭对所有打开的 modal 全局生效,栈式多层 modal 会被同时关闭 + `modal/src/modal.tsx` @ 80-84 + 方向:引入模块级栈并把 push/pop 绑定到 open/close 生命周期(不能只在 keydown 内处理): +1) 文件顶部加 `const modalEscStack: number[] = []`。 +2) onKeydown 改为仅当本实例位于栈顶才响应,并阻止冒泡: +``` +const onKeydown = (e: KeyboardEvent) => { + if (e.key !== 'Escape' || !props.closeOnEsc || !isOpen.value) return + if + +### table-column +- **[medium]** 全局自增 columnSeq 决定列序,动态重挂载的列会跳到末尾 + `table-column/src/table-column.tsx` @ 7, 19 + 方向:Do not apply the accuser's "minimal" variant as-is: locating by a stable key within the original `columns` order is invalid here, because in template-collection mode there is no `columns` array to index into — columns exist only via register(). The correct fix + +### input-number +- **[medium]** handleInput 在每次击键时即 clamp 到 min/max 并按 precision 取整,妨碍正常输入 + `input-number/src/input-number.tsx` @ 111 + 方向:Two-part change. (1) In handleInput, parse the raw value without clamp/precision so typing isn't disrupted, e.g. replace lines 111-112 with: `const raw = Number.parseFloat(value); if (!Number.isNaN(raw)) updateValue(raw, false)` — leaving clamp+precision to ha + +### space-compact +- **[medium]** size 生成的修饰类无任何样式承接,且未透传到子项,是死输出 + `space-compact/src/space-compact.tsx` @ 16 + 方向:Do NOT delete line 16 — that breaks existing assertions in test/space-compact.test.ts:24-26 requiring ccui-space-compact--large / --small. Take option (a): add the missing scss rules so the modifier is consumed. In space-compact.scss, inside the root block, ad + +### time-picker +- **[low]** 「此刻」按钮不遵守 step 与 disabled 约束 + `time-picker/src/time-picker.tsx` @ 196-203 (clickNow) + 方向:Align `now` to an existing, non-disabled cell using the same column value lists that render the panel, instead of naive step rounding (columns floor to multiples of step starting at 0, and disabled cells must be skipped). Add a helper and use it in clickNow: + + + +## 可访问性 — 25 条 + + +### color-picker +- **[high]** 清除按钮是嵌套在