mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-04 23:18:44 +08:00
feat: 添加响应式输入适配器,优化小屏幕录入控件布局
This commit is contained in:
+1
-1
@@ -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",
|
||||||
|
|||||||
@@ -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),
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user