@@ -8,6 +8,12 @@ import {
updateRosCarMapUrls ,
uploadFile ,
} from "@/api/rosCar" ;
import {
getMileagePointList ,
removeMileagePoint ,
saveMileagePoint ,
type RosMileagePoint ,
} from "@/api/mileage" ;
import { parseRosMapYaml } from "@/utils/rosMapYaml" ;
type DeviceStatus = "空闲" | "巡检" | "离线" | "充电中" ;
@@ -20,7 +26,9 @@ type RecognitionActionCode =
| "TUNNEL_WATER"
| "WALL_CRACK" ;
type PatrolTaskMode = "定时" | "周期" | "临时" ;
type EditorMode = "select" | "normal" | "thermal" | "pump" | "waypoint" | "dock" | "preDock" ;
type EditorMode = "select" | "normal" | "thermal" | "pump" | "waypoint" | "dock" | "preDock" | "mileage" ;
/** 地图编辑目标:巡检轮次(编辑巡检点位)或里程点(独立标定物),两者互斥 */
type MapEditTarget = "patrol" | "mileage" ;
interface RosCarRecord {
id ? : string ;
@@ -214,6 +222,20 @@ interface PatrolTask {
updatedAt : string ;
}
/**
* 里程点:设备级标定物,独立于巡检点位与轮次,仅用于告警位置参照
*/
interface MileagePointState {
/** 后端主键id, 未保存时为空 */
id ? : string ;
/** 里程点标识,人工填写,如 a区-01 */
label : string ;
/** 地图百分比坐标x */
x : number ;
/** 地图百分比坐标y */
y : number ;
}
interface DockConfigState {
dockX ? : number ;
dockY ? : number ;
@@ -236,6 +258,8 @@ interface DeviceConfigState {
points : PatrolPoint [ ] ;
selectedPointIds : string [ ] ;
plans : PatrolTask [ ] ;
/** 设备级里程点列表(独立接口维护,不随巡检任务保存) */
mileages : MileagePointState [ ] ;
dock : DockConfigState ;
}
@@ -312,6 +336,26 @@ const pointDropPosition = ref<"before" | "after">("before");
// 列表点击"前/后插入"后,记录待插入位置,等地图点选坐标后写入该位置
const pendingInsertIndex = ref < number | null > ( null ) ;
const pendingInsertLabel = ref ( "" ) ;
// 地图当前编辑对象,默认编辑巡检轮次点位
const mapEditTarget = ref < MapEditTarget > ( "patrol" ) ;
// 里程点拖动状态
const dragMileageState = reactive ( {
active : false ,
moved : false ,
mileageId : "" ,
offsetX : 0 ,
offsetY : 0 ,
} ) ;
// 里程点编辑弹窗状态
const mileageDialogVisible = ref ( false ) ;
const mileageSaving = ref ( false ) ;
const mileageMessage = ref ( "" ) ;
const mileageForm = reactive ( {
id : "" ,
label : "" ,
x : 0 ,
y : 0 ,
} ) ;
const mapStageRef = ref < HTMLElement | null > ( null ) ;
const mapCanvasRef = ref < HTMLCanvasElement | null > ( null ) ;
const mapOverviewRef = ref < HTMLCanvasElement | null > ( null ) ;
@@ -406,6 +450,9 @@ const selectedPointHighlight = {
glow : "rgba(255, 178, 110, 0.32)" ,
} ;
// 里程点独立配色(设备级标定物,不参与巡检路径)
const mileagePointColor = "#c39bff" ;
const defaultRosMapMeta : RosMapMetaState = {
image : "room.png" ,
resolution : 0.05 ,
@@ -1287,6 +1334,7 @@ function buildDeviceConfig(device: DeviceRecord): DeviceConfigState {
points ,
selectedPointIds : [ ] ,
plans ,
mileages : [ ] ,
dock : buildDockConfig ( device ) ,
} ;
}
@@ -1476,11 +1524,14 @@ async function openConfigDialog(device: DeviceRecord) {
await applyDeviceMapUrls ( device , config ) ;
config . points = config . points . map ( normalizePatrolPointActions ) ;
// 兼容热更新前生成的旧配置对象,避免里程点列表未初始化导致渲染异常
config . mileages = config . mileages ? ? [ ] ;
deviceConfigs [ device . id ] = config ;
configuringDeviceId . value = device . id ;
configDialogVisible . value = true ;
mapEditorMode . value = "select" ;
cancelInsertPoint ( ) ;
void loadMileagePoints ( device . id , config ) ;
if ( ! config . plans . some ( ( plan ) => plan . id === config . activePlanId ) ) {
config . activePlanId = config . plans [ 0 ] ? . id ? ? "" ;
@@ -1521,10 +1572,12 @@ function closeConfigDialog() {
configDialogVisible . value = false ;
planDialogVisible . value = false ;
mileageDialogVisible . value = false ;
configuringDeviceId . value = null ;
cancelInsertPoint ( ) ;
clearSelectionBox ( ) ;
clearDragPointState ( ) ;
clearDragMileageState ( ) ;
clearMapPanState ( ) ;
stopMapSyncTimer ( ) ;
}
@@ -1832,6 +1885,30 @@ function findHitPoint(event: MouseEvent) {
return null ;
}
// 里程点命中检测,用于里程模式下的拖动与编辑
function findHitMileagePoint ( event : MouseEvent ) {
const rect = getMapStageRect ( ) ;
if ( ! rect || ! currentConfig . value ) {
return null ;
}
const pointerX = event . clientX - rect . left ;
const pointerY = event . clientY - rect . top ;
const mileages = [ ... currentConfig . value . mileages ] . reverse ( ) ;
for ( const mileage of mileages ) {
const pixelPoint = percentToStagePixels ( mileage . x , mileage . y , rect ) ;
const deltaX = pixelPoint . x - pointerX ;
const deltaY = pixelPoint . y - pointerY ;
if ( Math . hypot ( deltaX , deltaY ) <= 16 ) {
return mileage ;
}
}
return null ;
}
function renderMapCanvas ( ) {
const canvas = mapCanvasRef . value ;
const rect = getMapStageRect ( ) ;
@@ -2003,6 +2080,37 @@ function renderMapCanvas() {
context . restore ( ) ;
} ) ;
// 里程点用菱形 + 标识文字绘制,与巡检点位区分
currentConfig . value ? . mileages . forEach ( ( mileage ) => {
const pixelPoint = percentToStagePixels ( mileage . x , mileage . y , rect ) ;
context . save ( ) ;
context . beginPath ( ) ;
context . moveTo ( pixelPoint . x , pixelPoint . y - 9 ) ;
context . lineTo ( pixelPoint . x + 9 , pixelPoint . y ) ;
context . lineTo ( pixelPoint . x , pixelPoint . y + 9 ) ;
context . lineTo ( pixelPoint . x - 9 , pixelPoint . y ) ;
context . closePath ( ) ;
context . fillStyle = "rgba(5, 18, 38, 0.94)" ;
context . fill ( ) ;
context . lineWidth = 2 ;
context . strokeStyle = mileagePointColor ;
context . shadowColor = "rgba(195, 155, 255, 0.35)" ;
context . shadowBlur = 12 ;
context . stroke ( ) ;
context . restore ( ) ;
if ( mileage . label ) {
context . save ( ) ;
context . fillStyle = "#e6d6ff" ;
context . font = '600 11px "Microsoft YaHei", sans-serif' ;
context . textAlign = "center" ;
context . textBaseline = "bottom" ;
context . fillText ( mileage . label , pixelPoint . x , pixelPoint . y - 12 ) ;
context . restore ( ) ;
}
} ) ;
drawDockMarker (
context ,
rect ,
@@ -2289,6 +2397,14 @@ function clearSelectionBox() {
selectionBox . endY = 0 ;
}
function clearDragMileageState ( ) {
dragMileageState . active = false ;
dragMileageState . moved = false ;
dragMileageState . mileageId = "" ;
dragMileageState . offsetX = 0 ;
dragMileageState . offsetY = 0 ;
}
function clearDragPointState ( ) {
dragPointState . active = false ;
dragPointState . moved = false ;
@@ -2353,6 +2469,30 @@ function handleMapPointerDown(event: MouseEvent) {
return ;
}
// 里程模式:只操作里程点,命中则进入拖动,未命中则平移地图
if ( mapEditTarget . value === "mileage" ) {
const hitMileage = findHitMileagePoint ( event ) ;
if ( hitMileage ) {
dragMileageState . active = true ;
dragMileageState . moved = false ;
dragMileageState . mileageId = hitMileage . id ? ? "" ;
dragMileageState . offsetX = hitMileage . x - position . x ;
dragMileageState . offsetY = hitMileage . y - position . y ;
bindMapPointerTracking ( ) ;
return ;
}
mapPanState . active = true ;
mapPanState . moved = false ;
mapPanState . startClientX = event . clientX ;
mapPanState . startClientY = event . clientY ;
mapPanState . startPanX = mapPan . x ;
mapPanState . startPanY = mapPan . y ;
bindMapPointerTracking ( ) ;
return ;
}
const hitPoint = findHitPoint ( event ) ;
if ( hitPoint ) {
if ( ! currentConfig . value . selectedPointIds . includes ( hitPoint . id ) ) {
@@ -2392,6 +2532,39 @@ function handleMapPointerMove(event: MouseEvent) {
return ;
}
if ( dragMileageState . active && currentConfig . value ) {
const position = getRelativePosition ( event ) ;
if ( ! position ) {
return ;
}
const targetMileage = currentConfig . value . mileages . find (
( mileage ) => mileage . id === dragMileageState . mileageId ,
) ;
if ( ! targetMileage ) {
clearDragMileageState ( ) ;
return ;
}
const nextPosition = clampMapPosition (
position . x + dragMileageState . offsetX ,
position . y + dragMileageState . offsetY ,
) ;
if (
Math . abs ( nextPosition . x - targetMileage . x ) > 0.05 ||
Math . abs ( nextPosition . y - targetMileage . y ) > 0.05
) {
dragMileageState . moved = true ;
}
targetMileage . x = nextPosition . x ;
targetMileage . y = nextPosition . y ;
scheduleMapCanvasRender ( ) ;
return ;
}
if ( dragPointState . active && currentConfig . value ) {
const position = getRelativePosition ( event ) ;
if ( ! position ) {
@@ -2455,6 +2628,24 @@ function handleMapPointerUp(event: MouseEvent) {
return ;
}
if ( dragMileageState . active ) {
const mileageId = dragMileageState . mileageId ;
const moved = dragMileageState . moved ;
clearDragMileageState ( ) ;
if ( moved ) {
suppressStageClick . value = true ;
const mileage = currentConfig . value ? . mileages . find ( ( item ) => item . id === mileageId ) ;
if ( mileage ) {
void persistMileagePosition ( mileage ) ;
}
}
return ;
}
if ( dragPointState . active ) {
if ( dragPointState . moved ) {
suppressStageClick . value = true ;
@@ -2721,6 +2912,13 @@ function handleMapStageClick(event: MouseEvent) {
return ;
}
// 里程模式:点中已有里程点则编辑,点空白处新增
if ( mapEditTarget . value === "mileage" ) {
const hitMileage = findHitMileagePoint ( event ) ;
openMileageDialog ( hitMileage ? undefined : position , hitMileage ? ? undefined ) ;
return ;
}
const hitPoint = findHitPoint ( event ) ;
if ( hitPoint ) {
handlePointClick ( hitPoint . id , event ) ;
@@ -2904,6 +3102,170 @@ function clearCanvas() {
updateSelection ( [ ] ) ;
}
// 里程点按设备维度独立加载,不随巡检任务一起保存
async function loadMileagePoints ( deviceId : string , config : DeviceConfigState ) {
try {
const response = await getMileagePointList ( deviceId ) ;
const rows : RosMileagePoint [ ] = response . data ? ? [ ] ;
config . mileages = rows . map ( ( item ) => {
const position = rosPoseToMapPercent ( item . poseX ? ? 0 , item . poseY ? ? 0 ) ;
return {
id : item . id ,
label : item . mileageLabel ,
x : position . x ,
y : position . y ,
} ;
} ) ;
nextTick ( ( ) => {
scheduleMapCanvasRender ( ) ;
} ) ;
} catch ( error ) {
console . error ( error ) ;
config . mileages = [ ] ;
}
}
// 切换地图编辑目标:里程模式下只操作里程点,巡检模式下只操作巡检点位
function switchMapEditTarget ( target : MapEditTarget ) {
mapEditTarget . value = target ;
cancelInsertPoint ( ) ;
if ( target === "mileage" ) {
mapEditorMode . value = "mileage" ;
return ;
}
if ( mapEditorMode . value === "mileage" ) {
mapEditorMode . value = "select" ;
}
}
// 拖动结束后把里程点新位置写回后端
async function persistMileagePosition ( mileage : MileagePointState ) {
const device = currentConfiguredDevice . value ;
if ( ! device || ! mileage . id ) {
return ;
}
const rosPosition = percentToRosPosition ( mileage . x , mileage . y ) ;
try {
await saveMileagePoint ( {
id : mileage . id ,
carId : device . id ,
mileageLabel : mileage . label ,
poseX : rosPosition . x ,
poseY : rosPosition . y ,
} ) ;
} catch ( error ) {
console . error ( error ) ;
}
}
// 打开里程点编辑弹窗; position 为地图点选得到的百分比坐标
function openMileageDialog (
position ? : { x : number ; y : number } ,
mileage ? : MileagePointState ,
) {
mileageForm . id = mileage ? . id ? ? "" ;
mileageForm . label = mileage ? . label ? ? "" ;
mileageForm . x = mileage ? . x ? ? position ? . x ? ? 0 ;
mileageForm . y = mileage ? . y ? ? position ? . y ? ? 0 ;
mileageMessage . value = "" ;
mileageDialogVisible . value = true ;
}
function closeMileageDialog ( ) {
mileageDialogVisible . value = false ;
mileageMessage . value = "" ;
mileageSaving . value = false ;
// 保持当前编辑目标:里程模式下继续可点选,巡检模式下回到查看
mapEditorMode . value = mapEditTarget . value === "mileage" ? "mileage" : "select" ;
}
// 保存里程点:把页面百分比坐标换算成 ROS 米制坐标后提交
async function saveMileage ( ) {
const config = currentConfig . value ;
const device = currentConfiguredDevice . value ;
if ( ! config || ! device || mileageSaving . value ) {
return ;
}
const label = mileageForm . label . trim ( ) ;
if ( ! label ) {
mileageMessage . value = "请填写里程点标识,例如 a区-01" ;
return ;
}
const rosPosition = percentToRosPosition ( mileageForm . x , mileageForm . y ) ;
mileageSaving . value = true ;
mileageMessage . value = "" ;
try {
const response = await saveMileagePoint ( {
id : mileageForm . id || undefined ,
carId : device . id ,
mileageLabel : label ,
poseX : rosPosition . x ,
poseY : rosPosition . y ,
} ) ;
const saved = response . data ;
if ( saved ? . id ) {
const position = rosPoseToMapPercent ( saved . poseX ? ? 0 , saved . poseY ? ? 0 ) ;
const nextMileage : MileagePointState = {
id : saved . id ,
label : saved . mileageLabel || label ,
x : position . x ,
y : position . y ,
} ;
const index = config . mileages . findIndex ( ( item ) => item . id === saved . id ) ;
if ( index >= 0 ) {
config . mileages [ index ] = nextMileage ;
} else {
config . mileages . push ( nextMileage ) ;
}
}
mileageDialogVisible . value = false ;
mileageMessage . value = "" ;
mapEditorMode . value = mapEditTarget . value === "mileage" ? "mileage" : "select" ;
nextTick ( ( ) => {
scheduleMapCanvasRender ( ) ;
} ) ;
} catch ( error ) {
console . error ( error ) ;
mileageMessage . value = "里程点保存失败,请稍后重试" ;
} finally {
mileageSaving . value = false ;
}
}
async function removeMileagePointItem ( mileage : MileagePointState ) {
const config = currentConfig . value ;
if ( ! config || ! mileage . id ) {
return ;
}
try {
await removeMileagePoint ( mileage . id ) ;
config . mileages = config . mileages . filter ( ( item ) => item . id !== mileage . id ) ;
nextTick ( ( ) => {
scheduleMapCanvasRender ( ) ;
} ) ;
} catch ( error ) {
console . error ( error ) ;
}
}
async function saveConfigDialog ( ) {
if ( ! currentConfig . value || ! currentConfiguredDevice . value || saveProgressVisible . value ) {
return ;
@@ -3459,23 +3821,40 @@ watch(
< / div >
< div class = "editor-toolbar" >
< button class = "editor-tool-btn icon" : class = "{ active: mapE ditorMode === 'select' }" type = "button"
title = "拖动查看" aria -label = " 拖动查看 " @click ="cancelInsertPoint(); mapEditorMode = 'select'" >
< svg viewBox = "0 0 24 24" aria -hidden = " true " >
< path
d = "M10 9h4V6h3l-5-5-5 5h3v3zm-1 1H6V7l-5 5 5 5v-3h3v-4zm14 2l-5-5v3h-3v4h3v3l5-5zm-9 3h-4v3H7l5 5 5-5h-3v-3z" / >
< / svg >
< / button >
< button v-for = "mode in editorModes" :key="mode.id" : class = "[
'editor-tool-btn',
{ active: mapEditorMode === mode.id },
]" type = "button" @click ="mapEditorMode = mode.id" >
{{ mode.label }}
</ button >
< button class = "editor-tool-btn" type = "button" @click ="clearCanvas ">
清空巡检点
< / button >
< div v-if = "pendingInsertIndex !== null" class="editor-tip" >
< div class = "map-e dit-target-switch" >
< button : class = "{ active: mapEditTarget === 'patrol' }" type = "button"
@click ="switchMapEditTarget('patrol')" >
巡检轮次
< / button >
< button : class = "{ active: mapEditTarget === 'mileage' }" type = "button"
@click ="switchMapEditTarget('mileage')" >
里程点
< / button >
< / div >
< template v-if = "mapEditTarget === 'patrol'" >
< button class = "editor-tool-btn icon" : class = "{ active: mapEditorMode === 'select' }" type = "button"
title = "拖动查看" aria -label = " 拖动查看 " @click ="cancelInsertPoint(); mapEditorMode = 'select' " >
< svg viewBox = "0 0 24 24" aria -hidden = " true " >
< path
d = "M10 9h4V6h3l-5-5-5 5h3v3zm-1 1H6V7l-5 5 5 5v-3h3v-4zm14 2l-5-5v3h-3v4h3v3l5-5zm-9 3h-4v3H7l5 5 5-5h-3v-3z" / >
< / svg >
< / button >
< button v-for = "mode in editorModes" :key="mode.id" : class = "[
'editor-tool-btn',
{ active: mapEditorMode === mode.id },
]" type = "button" @click ="mapEditorMode = mode.id" >
{{ mode.label }}
< / button >
< button class = "editor-tool-btn" type = "button" @click ="clearCanvas" >
清空巡检点
< / button >
< / template >
< div v-if = "mapEditTarget === 'mileage'" class="editor-tip" >
点击空白处新增里程点 , 拖动已有点位可调整位置
< / div >
< div v-else-if = "pendingInsertIndex !== null" class="editor-tip" >
在地图上点击放置新点位 , 将插入到 {{ pendingInsertLabel }}
< button class = "editor-tip-cancel" type = "button" @click ="cancelInsertPoint" > 取消插入 < / button >
< / div >
@@ -3611,6 +3990,40 @@ watch(
< / div >
< / div >
< div class = "config-side-card mileage-config-board" >
< div class = "panel-head inline" >
< div >
< div class = "panel-title" > 里程点配置 < / div >
< div class = "panel-subtitle" >
设备级定位标定物 , 独立于巡检点位与轮次 , 用于告警时定位故障位置
< / div >
< / div >
< button : class = "{ primary: mapEditTarget === 'mileage' }" class = "page-action-btn ghost" type = "button"
@click ="switchMapEditTarget('mileage')" >
地图编辑
< / button >
< / div >
< div v-if = "mapEditTarget === 'mileage'" class="mileage-pick-hint" >
请在左侧地图上点击新增里程点 , 或拖动已有点位调整位置
< / div >
< div class = "mileage-list-shell" >
< div v-for = "mileage in currentConfig.mileages" :key="mileage.id" class="mileage-list-item" >
< div class = "mileage-list-main" >
< div class = "mileage-list-label" > { { mileage . label || "未命名里程点" } } < / div >
< / div >
< div class = "point-list-actions" >
< button type = "button" @click ="openMileageDialog(undefined, mileage)" > 编辑 < / button >
< button class = "danger" type = "button" @click ="removeMileagePointItem(mileage)" > 删除 < / button >
< / div >
< / div >
< div v-if = "!currentConfig.mileages.length" class="config-empty point-list-empty" >
暂无里程点 , 点击 “ 地图点选新增 ” 后在地图上选择位置 。
< / div >
< / div >
< / div >
< div class = "config-side-card dock-config-board" >
< div class = "dock-config-head" >
< span class = "dock-config-title" > 充电回充配置 < / span >
@@ -3772,6 +4185,36 @@ watch(
< / div >
< / div >
< div v-if = "mileageDialogVisible" class="plan-dialog-mask" @click.self="closeMileageDialog" >
< div class = "plan-dialog-card" >
< div class = "panel-head inline" >
< div >
< div class = "panel-title" > 里程点 < / div >
< div class = "panel-subtitle" > 填写里程标识后保存 , 例如 a区 - 01 < / div >
< / div >
< button class = "dialog-close" type = "button" @click ="closeMileageDialog" > × < / button >
< / div >
< div class = "config-form-grid compact plan-dialog-grid" >
< label class = "config-field wide" >
< span > 里程标识 < / span >
< input v-model = "mileageForm.label" placeholder="例如: a区-01" type="text" / >
< / label >
< / div >
< div v-if = "mileageMessage" class="map-upload-message" > {{ mileageMessage }} < / div >
< div class = "plan-editor-actions" >
< button class = "page-action-btn ghost" type = "button" @click ="closeMileageDialog" >
取消
< / button >
< button class = "page-action-btn primary" :disabled = "mileageSaving" type = "button" @click ="saveMileage" >
{{ mileageSaving ? " 保存中... " : " 保存 " }}
< / button >
< / div >
< / div >
< / div >
< div v-if = "saveProgressVisible" class="save-progress-mask" >
< div class = "save-progress-card" >
< div class = "save-progress-title" > 保存 < / div >
@@ -4142,6 +4585,31 @@ watch(
gap : 10 px ;
}
. map - edit - target - switch {
display : inline - flex ;
align - items : center ;
padding : 2 px ;
border : 1 px solid rgba ( 87 , 198 , 255 , 0.26 ) ;
background : rgba ( 8 , 31 , 62 , 0.72 ) ;
}
. map - edit - target - switch button {
height : 28 px ;
padding : 0 14 px ;
border : 0 ;
background : transparent ;
color : rgba ( 192 , 235 , 250 , 0.78 ) ;
font - size : 12 px ;
cursor : pointer ;
}
. map - edit - target - switch button . active {
color : # eefcff ;
background : linear - gradient ( 90 deg ,
rgba ( 24 , 102 , 180 , 0.88 ) ,
rgba ( 35 , 158 , 215 , 0.88 ) ) ;
}
. editor - tool - btn {
height : 34 px ;
padding : 0 14 px ;
@@ -4757,8 +5225,8 @@ watch(
. rounds - board {
padding : 16 px ;
flex : 1 1 auto ;
/* 新增充电回充卡片后,轮次配置不再设为 0 基准高度,避免被压缩到看不见 */
min - height : 30 0px ;
/* 侧栏已有充电回充、里程点两张卡片,保证轮次配置的最小可用高度 */
min - height : 46 0px ;
display : flex ;
flex - direction : column ;
}
@@ -4922,6 +5390,50 @@ watch(
font - size : 11 px ;
}
. mileage - config - board {
flex : 0 0 auto ;
}
. mileage - pick - hint {
margin - top : 12 px ;
padding : 8 px 10 px ;
border : 1 px dashed rgba ( 195 , 155 , 255 , 0.5 ) ;
background : rgba ( 60 , 40 , 110 , 0.35 ) ;
color : # e2d4ff ;
font - size : 12 px ;
}
. mileage - list - shell {
margin - top : 14 px ;
display : flex ;
flex - direction : column ;
gap : 10 px ;
max - height : 180 px ;
overflow : auto ;
}
. mileage - list - item {
display : flex ;
align - items : center ;
justify - content : space - between ;
gap : 12 px ;
padding : 10 px 12 px ;
border : 1 px solid rgba ( 174 , 138 , 255 , 0.22 ) ;
background : rgba ( 30 , 18 , 62 , 0.62 ) ;
}
. mileage - list - main {
min - width : 0 ;
flex : 1 ;
}
. mileage - list - label {
color : # f2fbff ;
font - size : 14 px ;
font - weight : 700 ;
}
. point - drag - handle {
flex : 0 0 auto ;
display : inline - flex ;