feat: 添加响应式输入适配器,优化小屏幕录入控件布局

This commit is contained in:
jxxghp
2026-07-14 12:45:11 +08:00
parent 2df3ce1b86
commit cec9563963
6 changed files with 338 additions and 2 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "moviepilot", "name": "moviepilot",
"version": "2.14.3", "version": "2.14.4",
"private": true, "private": true,
"type": "module", "type": "module",
"bin": "dist/service.js", "bin": "dist/service.js",
+153
View File
@@ -0,0 +1,153 @@
import type { Component } from 'vue'
import { defineComponent, getCurrentInstance, h, ref } from 'vue'
import { useDisplay } from 'vuetify'
type ResponsiveInputKind = 'choice' | 'field' | 'group' | 'multiline' | 'range'
interface ResponsiveInputOptions {
kind: ResponsiveInputKind
name: string
}
interface ForwardedInputInstance {
blur?: () => void
focus?: () => void
reset?: () => void
resetValidation?: () => void
validate?: (silent?: boolean) => unknown
}
/**
* 判断标签或提示是否包含可展示的文本。
*/
function hasDisplayText(value: unknown): value is string | number {
return (typeof value === 'string' && value.trim().length > 0) || typeof value === 'number'
}
/**
* 解析包装组件尚未声明的 Vue 布尔属性值。
*/
function isBooleanAttributeEnabled(value: unknown) {
return value === '' || value === true || value === 'true'
}
/**
* 合并控件已有的描述引用与移动端提示、校验信息引用。
*/
function mergeDescribedBy(...values: unknown[]) {
return [...new Set(
values
.filter((value): value is string => typeof value === 'string')
.flatMap(value => value.split(/\s+/))
.filter(Boolean),
)].join(' ')
}
/**
* 为原生 Vuetify 录入组件创建小屏两栏适配器,桌面端保持原组件渲染路径。
*/
export function createResponsiveInputAdapter(component: Component, options: ResponsiveInputOptions) {
return defineComponent({
name: `App${options.name}`,
inheritAttrs: false,
props: {
mobileLayout: {
type: Boolean,
default: true,
},
},
/**
* 根据 Vuetify 断点切换布局,并保留原生控件常用的公开方法。
*/
setup(props, { attrs, expose, slots }) {
const display = useDisplay()
const instanceId = getCurrentInstance()?.uid ?? 0
const controlRef = ref<ForwardedInputInstance>()
/** 聚焦内部原生控件。 */
const focus = () => controlRef.value?.focus?.()
/** 移除内部原生控件的焦点。 */
const blur = () => controlRef.value?.blur?.()
/** 触发内部原生控件校验。 */
const validate = (silent?: boolean) => controlRef.value?.validate?.(silent)
/** 重置内部原生控件值与校验状态。 */
const reset = () => controlRef.value?.reset?.()
/** 仅重置内部原生控件校验状态。 */
const resetValidation = () => controlRef.value?.resetValidation?.()
expose({ blur, focus, reset, resetValidation, validate })
return () => {
const label = attrs.label
const useMobileLayout = props.mobileLayout && display.smAndDown.value && hasDisplayText(label)
if (!useMobileLayout) {
return h(component, { ...attrs, ref: controlRef }, slots)
}
const hint = attrs.hint
const hideDetails = attrs.hideDetails ?? attrs['hide-details']
const showHint = hasDisplayText(hint) && !isBooleanAttributeEnabled(hideDetails)
const controlId = String(attrs.id ?? `app-responsive-input-${instanceId}`)
const hintId = `${controlId}-hint`
const rootClass = attrs.class
const rootStyle = attrs.style
const controlAttrs: Record<string, unknown> = { ...attrs }
for (const key of ['class', 'hint', 'label', 'persistent-hint', 'persistentHint', 'style']) {
delete controlAttrs[key]
}
controlAttrs.id = controlId
controlAttrs.ref = controlRef
controlAttrs.class = 'app-responsive-input__native'
controlAttrs.label = undefined
controlAttrs.hint = undefined
controlAttrs.persistentHint = false
controlAttrs.density ??= 'compact'
controlAttrs['aria-label'] ??= String(label)
if (options.kind === 'field' || options.kind === 'multiline') {
controlAttrs.variant = 'plain'
controlAttrs.singleLine = true
}
if (showHint) {
controlAttrs['aria-describedby'] = mergeDescribedBy(
controlAttrs['aria-describedby'],
hintId,
`${controlId}-messages`,
)
}
const { label: labelSlot, ...controlSlots } = slots
const labelContent = labelSlot?.({ label, props: { for: controlId } }) ?? String(label)
const disabled = isBooleanAttributeEnabled(attrs.disabled)
return h('div', {
class: [
'app-responsive-input',
`app-responsive-input--${options.kind}`,
{ 'app-responsive-input--disabled': disabled },
rootClass,
],
style: rootStyle,
}, [
h('div', { class: 'app-responsive-input__meta' }, [
h('label', { class: 'app-responsive-input__label', for: controlId }, labelContent),
showHint
? h('div', { id: hintId, class: 'app-responsive-input__hint' }, String(hint))
: null,
]),
h('div', { class: 'app-responsive-input__control' }, [
h(component, controlAttrs, controlSlots),
]),
])
}
},
})
}
+20 -1
View File
@@ -3,10 +3,28 @@ import * as components from 'vuetify/components'
import { VBtn } from 'vuetify/components/VBtn' import { VBtn } from 'vuetify/components/VBtn'
import * as labsComponents from 'vuetify/labs/components' import * as labsComponents from 'vuetify/labs/components'
import AppDialog from './AppDialog' import AppDialog from './AppDialog'
import { createResponsiveInputAdapter } from './AppInput'
import defaults from './defaults' import defaults from './defaults'
import { icons } from './icons' import { icons } from './icons'
import type { ResponsiveInputComponentName } from './responsiveInputNames'
import theme from './theme' import theme from './theme'
const responsiveInputComponents = {
VAutocomplete: createResponsiveInputAdapter(components.VAutocomplete, { name: 'Autocomplete', kind: 'field' }),
VCheckbox: createResponsiveInputAdapter(components.VCheckbox, { name: 'Checkbox', kind: 'choice' }),
VCombobox: createResponsiveInputAdapter(components.VCombobox, { name: 'Combobox', kind: 'field' }),
VDateInput: createResponsiveInputAdapter(labsComponents.VDateInput, { name: 'DateInput', kind: 'field' }),
VFileInput: createResponsiveInputAdapter(components.VFileInput, { name: 'FileInput', kind: 'field' }),
VNumberInput: createResponsiveInputAdapter(labsComponents.VNumberInput, { name: 'NumberInput', kind: 'field' }),
VRadioGroup: createResponsiveInputAdapter(components.VRadioGroup, { name: 'RadioGroup', kind: 'group' }),
VRangeSlider: createResponsiveInputAdapter(components.VRangeSlider, { name: 'RangeSlider', kind: 'range' }),
VSelect: createResponsiveInputAdapter(components.VSelect, { name: 'Select', kind: 'field' }),
VSlider: createResponsiveInputAdapter(components.VSlider, { name: 'Slider', kind: 'range' }),
VSwitch: createResponsiveInputAdapter(components.VSwitch, { name: 'Switch', kind: 'choice' }),
VTextarea: createResponsiveInputAdapter(components.VTextarea, { name: 'Textarea', kind: 'multiline' }),
VTextField: createResponsiveInputAdapter(components.VTextField, { name: 'TextField', kind: 'field' }),
} satisfies Record<ResponsiveInputComponentName, ReturnType<typeof createResponsiveInputAdapter>>
export default createVuetify({ export default createVuetify({
aliases: { aliases: {
IconBtn: VBtn, IconBtn: VBtn,
@@ -16,7 +34,8 @@ export default createVuetify({
theme, theme,
components: { components: {
...components, ...components,
VDialog: AppDialog,
...labsComponents, ...labsComponents,
VDialog: AppDialog,
...responsiveInputComponents,
}, },
}) })
@@ -0,0 +1,21 @@
export const responsiveInputCoreComponentNames = [
'VAutocomplete',
'VCheckbox',
'VCombobox',
'VFileInput',
'VRadioGroup',
'VRangeSlider',
'VSelect',
'VSlider',
'VSwitch',
'VTextarea',
'VTextField',
] as const
export const responsiveInputComponentNames = [
...responsiveInputCoreComponentNames,
'VDateInput',
'VNumberInput',
] as const
export type ResponsiveInputComponentName = typeof responsiveInputComponentNames[number]
+138
View File
@@ -500,6 +500,144 @@ html[data-theme-radius='extra'] {
} }
} }
// 小屏录入控件统一为左侧说明、右侧操作的紧凑行;桌面端由适配器直接渲染 Vuetify 原组件。
.app-responsive-input {
display: grid;
min-block-size: 4.5rem;
min-inline-size: 0;
padding-block: 0.75rem;
border-block-end: 1px solid rgba(var(--v-theme-on-surface), 0.12);
align-items: center;
column-gap: 1rem;
grid-template-columns: minmax(0, 1fr) minmax(7rem, 42%);
}
.app-responsive-input__meta,
.app-responsive-input__control {
min-inline-size: 0;
}
.app-responsive-input__label {
display: block;
color: rgb(var(--v-theme-on-surface));
cursor: default;
font-size: 0.9375rem;
font-weight: 500;
line-height: 1.35;
overflow-wrap: anywhere;
}
.app-responsive-input__hint {
margin-block-start: 0.25rem;
color: rgba(var(--v-theme-on-surface), 0.56);
font-size: 0.75rem;
line-height: 1.4;
overflow-wrap: anywhere;
}
.app-responsive-input__control {
display: flex;
align-items: center;
}
.app-responsive-input__native {
inline-size: 100%;
margin: 0;
}
.app-responsive-input__native .v-input__control,
.app-responsive-input__native .v-field,
.app-responsive-input__native .v-field__field,
.app-responsive-input__native .v-field__input {
min-inline-size: 0;
}
// 移动录入行只保留值本身,统一移除字段内部的装饰与操作图标。
.app-responsive-input__native .v-field__prepend-inner,
.app-responsive-input__native .v-field__append-inner,
.app-responsive-input__native .v-field__clearable {
display: none;
}
.app-responsive-input--field .v-field,
.app-responsive-input--multiline .v-field {
border-radius: var(--app-control-radius) !important;
background-color: transparent;
transition: background-color 0.18s ease, box-shadow 0.18s ease;
}
.app-responsive-input--field .v-field__outline,
.app-responsive-input--field .v-field__overlay,
.app-responsive-input--multiline .v-field__outline,
.app-responsive-input--multiline .v-field__overlay {
display: none;
}
.app-responsive-input--field .v-field__input,
.app-responsive-input--multiline .v-field__input {
min-block-size: 2.75rem;
padding: 0.5rem;
font-size: 0.9375rem;
line-height: 1.4;
}
.app-responsive-input--field .v-field--focused,
.app-responsive-input--multiline .v-field--focused {
background-color: rgba(var(--v-theme-primary), 0.08);
box-shadow: inset 0 0 0 1px rgba(var(--v-theme-primary), 0.2);
}
.app-responsive-input--multiline {
align-items: start;
}
.app-responsive-input--multiline .app-responsive-input__control {
padding-block-start: 0.125rem;
}
.app-responsive-input--multiline .v-field__input {
min-block-size: 5.5rem;
align-items: start;
}
.app-responsive-input--choice .app-responsive-input__control,
.app-responsive-input--choice .v-input__control,
.app-responsive-input--group .v-selection-control-group {
justify-content: end;
}
.app-responsive-input--choice .v-input__control {
display: flex;
}
.app-responsive-input--choice .v-selection-control {
flex: 0 0 auto;
min-block-size: 2.75rem;
}
.app-responsive-input--range .v-input__control {
min-inline-size: 0;
}
.app-responsive-input--range .v-slider {
padding-block: 0.375rem;
}
.app-responsive-input__native .v-input__details {
padding-inline: 0.5rem 0;
}
.app-responsive-input--disabled .app-responsive-input__meta {
opacity: var(--v-disabled-opacity);
}
@media (hover: hover) {
.app-responsive-input--field .v-field:hover,
.app-responsive-input--multiline .v-field:hover {
background-color: rgba(var(--v-theme-on-surface), 0.04);
}
}
.v-btn:not(.v-btn--variant-plain, .v-btn--variant-text, .v-btn--flat, .theme-horizontal-nav__item) { .v-btn:not(.v-btn--variant-plain, .v-btn--variant-text, .v-btn--flat, .theme-horizontal-nav__item) {
box-shadow: var(--app-surface-shadow) !important; box-shadow: var(--app-surface-shadow) !important;
transition: box-shadow 0.2s ease; transition: box-shadow 0.2s ease;
+5
View File
@@ -11,6 +11,7 @@ import { resolve } from 'node:path'
import federation from '@originjs/vite-plugin-federation' import federation from '@originjs/vite-plugin-federation'
import topLevelAwait from 'vite-plugin-top-level-await' import topLevelAwait from 'vite-plugin-top-level-await'
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
import { responsiveInputCoreComponentNames } from './src/plugins/vuetify/responsiveInputNames'
// 读取 package.json 获取版本号 // 读取 package.json 获取版本号
const packageJson = JSON.parse(readFileSync('./package.json', 'utf-8')) const packageJson = JSON.parse(readFileSync('./package.json', 'utf-8'))
@@ -23,6 +24,10 @@ export default defineConfig({
vue(), vue(),
vueJsx(), vueJsx(),
vuetify({ vuetify({
autoImport: {
// 这些录入控件由 Vuetify 全局适配器接管,避免模板局部导入绕过移动布局。
ignore: [...responsiveInputCoreComponentNames],
},
styles: { styles: {
configFile: 'src/styles/variables/_vuetify.scss', configFile: 'src/styles/variables/_vuetify.scss',
}, },