You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1077 lines
40 KiB
1077 lines
40 KiB
<template>
|
|
<section class="operation-page">
|
|
<header class="operation-page-header">
|
|
<div>
|
|
<h1>任务管理</h1>
|
|
<span>今日 {{ todayTaskCount }} 个计划 · {{ runningTaskCount }} 个执行中</span>
|
|
</div>
|
|
<t-button theme="primary" @click="openCreateTask"><svg><use href="#i-plus" /></svg>新建任务</t-button>
|
|
</header>
|
|
|
|
<div class="business-metric-band task-metrics">
|
|
<div><span>今日任务</span><strong>{{ todayTaskCount }}</strong><small>全部计划</small></div>
|
|
<div><span>执行中</span><strong class="blue-text">{{ runningTaskCount }}</strong><small>当前执行任务</small></div>
|
|
<div><span>待执行</span><strong>{{ pendingTaskCount }}</strong><small>等待执行</small></div>
|
|
<div><span>今日完成</span><strong class="green-text">{{ completedTodayCount }}</strong><small>执行成功</small></div>
|
|
</div>
|
|
|
|
<div class="business-workspace">
|
|
<t-tabs v-model="view" class="business-tdesign-tabs" @change="setView">
|
|
<t-tab-panel value="plans" :label="plansTabLabel" :destroy-on-hide="false" />
|
|
<t-tab-panel value="records" :label="recordsTabLabel" :destroy-on-hide="false" />
|
|
<t-tab-panel value="routes" :label="routesTabLabel" :destroy-on-hide="false" />
|
|
</t-tabs>
|
|
|
|
<!-- 任务计划 -->
|
|
<div class="business-panel" :class="{ active: view === 'plans' }">
|
|
<div class="business-toolbar">
|
|
<div class="business-search">
|
|
<t-input v-model="search" clearable placeholder="搜索任务名称、编号或设备">
|
|
<template #prefix-icon><t-icon name="search" /></template>
|
|
</t-input>
|
|
</div>
|
|
<div class="toolbar-group">
|
|
<t-select v-model="planFilter" :options="planFilterOptions" placeholder="全部状态" style="width: 140px" />
|
|
<t-button variant="outline" @click="resetPlanFilter">重置</t-button>
|
|
</div>
|
|
</div>
|
|
<div class="business-table-wrap">
|
|
<t-table
|
|
row-key="id"
|
|
:data="filteredPlans"
|
|
:columns="planColumns"
|
|
empty="暂无任务计划"
|
|
>
|
|
<template #name="{ row }">
|
|
<div class="primary-table-cell">
|
|
<span class="task-table-icon" :class="{ muted: row.statusClass === 'offline' }"><svg><use href="#i-route" /></svg></span>
|
|
<span><strong>{{ row.name }}</strong><small>{{ row.code }} · {{ row.route }}</small></span>
|
|
</div>
|
|
</template>
|
|
<template #next="{ row }">
|
|
<span :class="{ 'blue-text': row.statusClass === 'mission' }">{{ row.next }}</span>
|
|
</template>
|
|
<template #videoPolicy="{ row }">
|
|
<span class="media-policy" :class="row.videoPolicyClass">{{ row.videoPolicy }}</span>
|
|
</template>
|
|
<template #status="{ row }">
|
|
<span class="table-status" :class="row.statusClass"><i></i>{{ row.status }}</span>
|
|
</template>
|
|
<template #actions="{ row }">
|
|
<div class="row-actions">
|
|
<t-button v-if="canExecuteTask(row)" variant="text" theme="primary" @click="executeTask(row)">立即执行</t-button>
|
|
<t-button variant="text" theme="danger" @click="deleteTask(row)">删除</t-button>
|
|
</div>
|
|
</template>
|
|
</t-table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 执行记录 -->
|
|
<div class="business-panel" :class="{ active: view === 'records' }">
|
|
<div class="business-toolbar">
|
|
<div class="toolbar-group">
|
|
<t-select v-model="execFilter" :options="execFilterOptions" placeholder="全部设备" style="width: 160px" />
|
|
<div class="business-search">
|
|
<t-input v-model="execSearch" clearable placeholder="搜索执行记录">
|
|
<template #prefix-icon><t-icon name="search" /></template>
|
|
</t-input>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="record-header"><div><h2>执行记录</h2><span>最近 30 天执行记录</span></div><t-select v-model="execResultFilter" :options="execResultFilterOptions" placeholder="全部执行结果" style="width: 160px" /></div>
|
|
<div class="business-table-wrap">
|
|
<t-table
|
|
row-key="id"
|
|
:data="filteredRecords"
|
|
:columns="recordColumns"
|
|
empty="暂无执行记录"
|
|
>
|
|
<template #name="{ row }">
|
|
<div class="primary-table-cell">
|
|
<span class="task-table-icon"><svg><use href="#i-route" /></svg></span>
|
|
<span><strong>{{ row.name }}</strong><small>{{ row.type }}</small></span>
|
|
</div>
|
|
</template>
|
|
<template #result="{ row }">
|
|
<span class="table-status" :class="row.statusClass"><i></i>{{ row.result }}</span>
|
|
</template>
|
|
<template #actions="{ row }">
|
|
<div class="row-actions">
|
|
<t-button variant="text" theme="primary" :disabled="row.statusClass === 'mission'" @click="goReplay(row)">{{ row.statusClass === 'mission' ? '执行中' : '轨迹回放' }}</t-button>
|
|
<t-button v-if="row.rawVideoId" variant="text" theme="primary" :disabled="row.rawVideoStatus !== 'ready'" @click="downloadRawVideo(row)">{{ row.rawVideoStatus === 'ready' ? '下载原视频' : '视频处理中' }}</t-button>
|
|
</div>
|
|
</template>
|
|
</t-table>
|
|
</div>
|
|
</div>
|
|
|
|
|
|
<!-- 航线库 -->
|
|
<div class="business-panel route-library" :class="{ active: view === 'routes' }">
|
|
<div class="route-library-view" :hidden="routeEditorOpen">
|
|
<div class="business-toolbar">
|
|
<div class="business-search">
|
|
<t-input v-model="routeSearch" clearable placeholder="搜索航线名称或区域">
|
|
<template #prefix-icon><t-icon name="search" /></template>
|
|
</t-input>
|
|
</div>
|
|
<t-button variant="outline" @click="openRouteEditor"><svg><use href="#i-plus" /></svg>新建航线</t-button>
|
|
</div>
|
|
<div class="route-grid">
|
|
<RouteCard
|
|
v-for="r in filteredRoutes"
|
|
:key="r.id"
|
|
:name="r.name"
|
|
:waypoints="r.waypoints"
|
|
:distance="r.distance"
|
|
:collected-at="r.collectedAt"
|
|
:map-class="r.mapClass"
|
|
/>
|
|
<div v-if="!filteredRoutes.length" class="table-empty" style="grid-column:1/-1">暂无航线</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="route-editor" :hidden="!routeEditorOpen">
|
|
<header class="route-editor-header">
|
|
<t-button class="back-command" variant="text" type="button" @click="closeRouteEditor"><svg><use href="#i-chevron" /></svg>返回航线库</t-button>
|
|
<div><strong>新建航线</strong><span>{{ routeSummary }}</span></div>
|
|
<div><t-button variant="outline" type="button" :disabled="!routePoints.length" @click="undoRoutePoint"><svg><use href="#i-refresh" /></svg>撤销</t-button><t-button variant="outline" type="button" :disabled="!routePoints.length" @click="clearRoutePoints"><svg><use href="#i-x" /></svg>清空</t-button><t-button theme="primary" type="button" :disabled="routeSaving" @click="submitRoute">{{ routeSaving ? '保存中…' : '保存航线' }}</t-button></div>
|
|
</header>
|
|
<div class="route-editor-body">
|
|
<aside class="route-config-panel">
|
|
<label><span>航线名称 <b>*</b></span><t-input v-model="routeForm.name" :maxlength="128" placeholder="请输入航线名称" /></label>
|
|
<div class="route-default-title"><strong>新增航点默认参数</strong></div>
|
|
<div class="route-default-row">
|
|
<label><span>高度</span><div class="number-input"><t-input-number v-model="routeForm.altitude" theme="normal" :min="20" :max="120" style="width: 100%" /><em>m</em></div></label>
|
|
<label><span>速度</span><div class="number-input"><t-input-number v-model="routeForm.speed" theme="normal" :min="1" :max="20" :step="0.1" style="width: 100%" /><em>m/s</em></div></label>
|
|
</div>
|
|
<label><span>转弯方式</span><t-radio-group v-model="routeForm.turnMode" class="task-mode-radio" theme="button" variant="default-filled" :options="turnModeOptions" /></label>
|
|
<t-button class="apply-all-waypoints" variant="outline" type="button" :disabled="!routePoints.length" @click="applyRouteDefaults"><svg><use href="#i-settings" /></svg>应用到全部航点</t-button>
|
|
<div class="waypoint-heading"><strong>航点列表</strong><span>{{ routePoints.length }}</span></div>
|
|
<div class="waypoint-list">
|
|
<div v-if="!routePoints.length" class="waypoint-empty">点击右侧地图添加航点</div>
|
|
<div v-for="(point, index) in routePoints" :key="index" class="waypoint-item" @click="openWaypointEditor(index)"><b>{{ index + 1 }}</b><span><strong>{{ point.longitude.toFixed(6) }}, {{ point.latitude.toFixed(6) }}</strong><small>{{ point.altitude }} m · {{ point.speed }} m/s · {{ point.turnMode === 'auto' ? '自动转弯' : '定点转弯' }}</small></span><div class="waypoint-item-actions"><t-button variant="text" shape="square" type="button" title="删除航点" @click.stop="removeWaypoint(index)"><svg><use href="#i-x" /></svg></t-button></div></div>
|
|
</div>
|
|
<div class="route-form-error">{{ routeFormError }}</div>
|
|
</aside>
|
|
<div ref="routeMapEl" class="route-planner-map">
|
|
<div class="route-map-status"><span><i></i>{{ routeMapReady ? '地图已加载' : '地图加载中…' }}</span><span>{{ routeCursor }}</span></div>
|
|
<div class="route-map-legend"><span><i class="start"></i>起点</span><span><i></i>航点</span><span><i class="end"></i>终点</span></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<t-dialog
|
|
v-model:visible="createTaskOpen"
|
|
header="新建任务"
|
|
width="520px"
|
|
destroy-on-close
|
|
dialog-class-name="add-dock-dialog task-dialog"
|
|
placement="center"
|
|
>
|
|
<form novalidate @submit.prevent="submitCreateTask">
|
|
<label><span>任务名称<b>*</b></span><t-input v-model="taskForm.name" :maxlength="40" placeholder="例如:园区西侧道路巡检" /></label>
|
|
<div class="form-row">
|
|
<label><span>执行机巢<b>*</b></span>
|
|
<t-select v-model="taskForm.dockId" :options="dockOptions" placeholder="请选择机巢" style="width: 100%" />
|
|
</label>
|
|
<label><span>飞行航线<b>*</b></span>
|
|
<t-select v-model="taskForm.routeId" :options="routeOptions" placeholder="请选择航线" style="width: 100%" />
|
|
</label>
|
|
</div>
|
|
<div class="task-schedule-settings">
|
|
<div class="task-video-title"><strong>执行方式</strong><span>按计划自动执行</span></div>
|
|
<t-radio-group
|
|
class="task-mode-radio task-schedule-radio"
|
|
:value="scheduleMode"
|
|
theme="button"
|
|
variant="default-filled"
|
|
:options="scheduleModeOptions"
|
|
@change="setScheduleType"
|
|
/>
|
|
<div v-if="taskForm.scheduleType === 'cron' && taskForm.schedulePeriod !== 'custom'" class="form-row">
|
|
<label v-if="taskForm.schedulePeriod === 'week'"><span>星期</span><t-select v-model="taskForm.scheduleWeekday" :options="weekdayOptions" style="width: 100%" /></label>
|
|
<label><span>执行时间</span><t-time-picker v-model="taskForm.scheduleTime" format="HH:mm" allow-input style="width: 100%" /></label>
|
|
</div>
|
|
<label v-if="taskForm.scheduleType === 'cron' && taskForm.schedulePeriod === 'custom'"><span>Cron 表达式<b>*</b></span><t-input v-model.trim="taskForm.scheduleCron" :maxlength="64" placeholder="例如:0 8 * * 1-5" /><small>标准五段式:分 时 日 月 周,不支持秒级。</small></label>
|
|
</div>
|
|
<div class="task-video-settings">
|
|
<div class="task-video-title"><strong>视频策略</strong><span>按任务配置</span></div>
|
|
<label class="task-video-option"><span><strong>原始视频录制</strong><small>任务结束后自动上传原始文件</small></span><t-switch v-model="taskForm.rawRecordingEnabled" /></label>
|
|
</div>
|
|
<div class="form-error">{{ taskFormError }}</div>
|
|
</form>
|
|
<template #footer>
|
|
<t-button variant="outline" type="button" @click="createTaskOpen = false">取消</t-button>
|
|
<t-button theme="primary" type="button" :disabled="creatingTask" @click="submitCreateTask">{{ creatingTask ? '创建中…' : '创建任务' }}</t-button>
|
|
</template>
|
|
</t-dialog>
|
|
|
|
<t-dialog
|
|
v-model:visible="waypointEditorOpen"
|
|
:header="`编辑航点 ${editingWaypointIndex + 1}`"
|
|
width="540px"
|
|
destroy-on-close
|
|
dialog-class-name="add-dock-dialog waypoint-dialog"
|
|
placement="center"
|
|
>
|
|
<form novalidate @submit.prevent="saveWaypoint">
|
|
<div class="waypoint-id-row"><span>航点 ID</span><strong>{{ editingWaypointIndex + 1 }}</strong></div>
|
|
<div class="form-row"><label><span>经度 <b>*</b></span><t-input-number v-model="waypointForm.longitude" theme="normal" :min="-180" :max="180" :step="0.000001" style="width: 100%" /></label><label><span>纬度 <b>*</b></span><t-input-number v-model="waypointForm.latitude" theme="normal" :min="-90" :max="90" :step="0.000001" style="width: 100%" /></label></div>
|
|
<div class="form-row"><label><span>高度 <b>*</b></span><div class="number-input"><t-input-number v-model="waypointForm.altitude" theme="normal" :min="20" :max="120" style="width: 100%" /><em>m</em></div></label><label><span>速度 <b>*</b></span><div class="number-input"><t-input-number v-model="waypointForm.speed" theme="normal" :min="1" :max="20" :step="0.1" style="width: 100%" /><em>m/s</em></div></label></div>
|
|
<label><span>转弯方式</span><t-radio-group v-model="waypointForm.turnMode" class="task-mode-radio" theme="button" variant="default-filled" :options="turnModeOptions" /></label>
|
|
<div class="route-form-error">{{ waypointFormError }}</div>
|
|
</form>
|
|
<template #footer>
|
|
<t-button variant="outline" type="button" @click="closeWaypointEditor">取消</t-button>
|
|
<t-button theme="primary" type="button" @click="saveWaypoint">保存航点</t-button>
|
|
</template>
|
|
</t-dialog>
|
|
</section>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
import { useUiStore } from '@/stores/modules/uiStore'
|
|
import request from '@/utils/http'
|
|
import { useDeviceStore } from '@/stores/modules/devicesStore'
|
|
import * as urls from '@/config/urls'
|
|
import commonRefs from '@/utils/commonRefs'
|
|
import mapHelper from '@/core/mapHelper'
|
|
import RouteCard from '@/components/RouteCard.vue'
|
|
|
|
const ROUTE_SOURCE = 'task-route'
|
|
const ROUTE_LINE = 'task-route-line'
|
|
const WP_SOURCE = 'task-waypoints'
|
|
const WP_CIRCLE = 'task-waypoints-circle'
|
|
const WP_LABEL = 'task-waypoints-label'
|
|
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
const ui = useUiStore()
|
|
const deviceStore = useDeviceStore()
|
|
|
|
const view = ref(route.query.view || 'plans')
|
|
const search = ref('')
|
|
const planFilter = ref('')
|
|
const planFilterOptions = [
|
|
{ label: '全部状态', value: '' },
|
|
{ label: '执行中', value: 'mission' },
|
|
{ label: '待执行', value: 'pending' },
|
|
{ label: '已暂停', value: 'offline' }
|
|
]
|
|
const execSearch = ref('')
|
|
const execFilter = ref('')
|
|
const execResultFilter = ref('')
|
|
const execResultFilterOptions = [
|
|
{ label: '全部执行结果', value: '' },
|
|
{ label: '成功', value: 'succeeded' },
|
|
{ label: '失败', value: 'failed' },
|
|
{ label: '已取消', value: 'cancelled' }
|
|
]
|
|
const routeSearch = ref('')
|
|
const routeEditorOpen = ref(false)
|
|
const routeSaving = ref(false)
|
|
const routeFormError = ref('')
|
|
const routeCursor = ref('--')
|
|
const routeMapEl = ref(null)
|
|
const routeMapReady = ref(false)
|
|
let routeMapInstance = null
|
|
let routeMapAttached = false
|
|
let pendingStyleHandler = null
|
|
|
|
const routeForm = reactive({ name: '', altitude: 50, speed: 10, turnMode: 'auto' })
|
|
const routePoints = ref([])
|
|
const waypointEditorOpen = ref(false)
|
|
const waypointFormError = ref('')
|
|
const editingWaypointIndex = ref(-1)
|
|
const waypointForm = reactive({ longitude: 0, latitude: 0, altitude: 50, speed: 10, turnMode: 'auto' })
|
|
|
|
const plans = ref([])
|
|
const records = ref([])
|
|
let refreshTimer = null
|
|
const routes = ref([])
|
|
const devices = computed(() => [...deviceStore.drones, ...deviceStore.docks])
|
|
const execFilterOptions = computed(() => [
|
|
{ label: '全部设备', value: '' },
|
|
...devices.value.map((d) => ({ label: d.name, value: d.dockId || d.droneSn }))
|
|
])
|
|
const dockOptions = computed(() =>
|
|
deviceStore.docks.map((dock) => ({
|
|
label: `${dock.name} · ${dock.dockId}`,
|
|
value: dock.dockId
|
|
}))
|
|
)
|
|
const routeOptions = computed(() =>
|
|
routes.value.map((taskRoute) => ({
|
|
label: taskRoute.name,
|
|
value: taskRoute.id
|
|
}))
|
|
)
|
|
|
|
const createTaskOpen = ref(false)
|
|
const creatingTask = ref(false)
|
|
const taskFormError = ref('')
|
|
const taskForm = reactive({
|
|
name: '',
|
|
dockId: '',
|
|
routeId: '',
|
|
scheduleType: 'once',
|
|
schedulePeriod: 'day',
|
|
scheduleWeekday: 1,
|
|
scheduleTime: '08:00',
|
|
scheduleCron: '',
|
|
rawRecordingEnabled: false
|
|
})
|
|
|
|
const weekdayOptions = [
|
|
{ value: 1, label: '星期一' },
|
|
{ value: 2, label: '星期二' },
|
|
{ value: 3, label: '星期三' },
|
|
{ value: 4, label: '星期四' },
|
|
{ value: 5, label: '星期五' },
|
|
{ value: 6, label: '星期六' },
|
|
{ value: 0, label: '星期日' }
|
|
]
|
|
|
|
const turnModeOptions = [
|
|
{ label: '自动转弯', value: 'auto' },
|
|
{ label: '定点转弯', value: 'stop' }
|
|
]
|
|
|
|
const scheduleModeOptions = [
|
|
{ label: '手动执行', value: 'once' },
|
|
{ label: '每天', value: 'day' },
|
|
{ label: '每周', value: 'week' },
|
|
{ label: '自定义 Cron', value: 'custom' }
|
|
]
|
|
|
|
const scheduleMode = computed(() => {
|
|
if (taskForm.scheduleType === 'once') return 'once'
|
|
return taskForm.schedulePeriod || 'day'
|
|
})
|
|
|
|
const plansTabLabel = computed(() => `任务计划 ${plans.value.length}`)
|
|
const recordsTabLabel = computed(() => `执行记录 ${records.value.length}`)
|
|
const routesTabLabel = computed(() => `航线库 ${routes.value.length}`)
|
|
|
|
const planColumns = [
|
|
{ colKey: 'name', title: '任务名称' },
|
|
{ colKey: 'device', title: '执行设备' },
|
|
{ colKey: 'cycle', title: '执行方式' },
|
|
{ colKey: 'next', title: '下次执行' },
|
|
{ colKey: 'videoPolicy', title: '视频策略' },
|
|
{ colKey: 'status', title: '任务状态' },
|
|
{ colKey: 'lastResult', title: '最近结果' },
|
|
{ colKey: 'actions', title: '操作', width: '16%' }
|
|
]
|
|
|
|
const recordColumns = [
|
|
{ colKey: 'id', title: '执行编号' },
|
|
{ colKey: 'name', title: '任务名称' },
|
|
{ colKey: 'device', title: '执行设备' },
|
|
{ colKey: 'start', title: '开始时间' },
|
|
{ colKey: 'duration', title: '飞行时长' },
|
|
{ colKey: 'collect', title: '采集成果' },
|
|
{ colKey: 'result', title: '执行结果' },
|
|
{ colKey: 'actions', title: '操作', width: '18%' }
|
|
]
|
|
|
|
|
|
watch(
|
|
() => route.query.view,
|
|
(v) => { if (v) view.value = v }
|
|
)
|
|
|
|
function setView(v) {
|
|
router.push({ name: 'tasks', query: { view: v } })
|
|
}
|
|
|
|
const TASK_STATUS = {
|
|
enabled: { status: '待执行', statusClass: 'pending' },
|
|
running: { status: '执行中', statusClass: 'mission' },
|
|
paused: { status: '已暂停', statusClass: 'offline' },
|
|
draft: { status: '待执行', statusClass: 'pending' },
|
|
disabled: { status: '已暂停', statusClass: 'offline' }
|
|
}
|
|
|
|
const EXEC_STATUS = {
|
|
pending: '等待设备开始',
|
|
running: '执行中',
|
|
succeeded: '已完成',
|
|
failed: '失败',
|
|
cancelled: '已取消'
|
|
}
|
|
|
|
function formatSchedule(task) {
|
|
if (task.scheduleType !== 'cron' || !task.scheduleCron) return '手动执行'
|
|
const parts = task.scheduleCron.trim().split(/\s+/)
|
|
if (parts.length !== 5) return `自定义 Cron:${task.scheduleCron}`
|
|
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts
|
|
if (dayOfMonth === '*' && month === '*' && dayOfWeek === '*') {
|
|
return `每日 ${hour.padStart(2, '0')}:${minute.padStart(2, '0')}`
|
|
}
|
|
if (dayOfMonth === '*' && month === '*' && /^\d$/.test(dayOfWeek)) {
|
|
return `每周${dayOfWeek === '0' ? '日' : dayOfWeek} ${hour.padStart(2, '0')}:${minute.padStart(2, '0')}`
|
|
}
|
|
return `自定义 Cron:${task.scheduleCron}`
|
|
}
|
|
|
|
function fmtTime(t) {
|
|
if (!t) return '--'
|
|
const d = new Date(t)
|
|
if (Number.isNaN(d.getTime())) return '--'
|
|
return d.toLocaleString('zh-CN', { hour12: false })
|
|
}
|
|
|
|
function toExecutionRecord(r, taskName, type, rawVideo) {
|
|
const dur = r.startTime && r.endTime
|
|
? Math.max(0, Math.round((new Date(r.endTime) - new Date(r.startTime)) / 60000))
|
|
: null
|
|
return {
|
|
id: String(r.id),
|
|
name: taskName || r.taskId || '手动任务',
|
|
type: type || (r.taskId ? '任务计划' : '手动任务'),
|
|
device: r.droneSn || r.dockId || '--',
|
|
start: fmtTime(r.startTime),
|
|
duration: dur != null ? `${dur} min` : '--',
|
|
collect: rawVideo ? (rawVideo.status === 'ready' ? '原始视频已上传' : '原始视频处理中') : r.status === 'running' ? '原始视频录制中' : '--',
|
|
result: EXEC_STATUS[r.status] || r.status || '--',
|
|
status: r.status || '',
|
|
statusClass: r.status === 'running' ? 'mission' : r.status === 'succeeded' ? 'online' : r.status === 'failed' ? 'danger' : 'pending',
|
|
rawVideoId: rawVideo?.id || '',
|
|
rawVideoStatus: rawVideo?.status || ''
|
|
}
|
|
}
|
|
|
|
async function load() {
|
|
try {
|
|
await deviceStore.load()
|
|
const [taskPage, execPage, routePage, videoPage] = await Promise.all([
|
|
request.get(urls.TASKS, { params: { pageNum: 1, pageSize: 100 } }),
|
|
request.get(urls.EXECUTIONS, { params: { pageNum: 1, pageSize: 100 } }),
|
|
request.get(urls.ROUTES, { params: { pageNum: 1, pageSize: 100 } }),
|
|
request.get(urls.VIDEOS, { params: { pageNum: 1, pageSize: 100 } })
|
|
])
|
|
|
|
plans.value = (taskPage?.records || []).map((t) => {
|
|
return {
|
|
id: t.id,
|
|
code: `JG-TSK-${String(t.id).slice(-8)}`,
|
|
name: t.name,
|
|
route: t.routeId ? `航线 #${t.routeId}` : '未绑定航线',
|
|
device: t.dockId || '--',
|
|
cycle: formatSchedule(t),
|
|
next: t.scheduleType === 'cron' ? formatSchedule(t) : '待手动执行',
|
|
videoPolicy: t.videoPolicy === 'raw' ? '原始录制' : '未启用',
|
|
videoPolicyClass: t.videoPolicy === 'raw' ? 'record' : 'none',
|
|
rawStatus: t.planStatus || t.status || 'enabled',
|
|
status: (TASK_STATUS[t.planStatus] || TASK_STATUS[t.status] || TASK_STATUS.draft).status,
|
|
statusClass: (TASK_STATUS[t.planStatus] || TASK_STATUS[t.status] || TASK_STATUS.draft).statusClass,
|
|
lastResult: t.latestExecution ? (EXEC_STATUS[t.latestExecution.status] || t.latestExecution.status || '未知') : '尚未执行'
|
|
}
|
|
})
|
|
|
|
const taskNames = new Map((taskPage?.records || []).map((task) => [task.id, task.name]))
|
|
const videosByExecution = new Map((videoPage?.records || []).map((video) => [String(video.executionId), video]))
|
|
records.value = (execPage?.records || []).map((r) => {
|
|
const video = videosByExecution.get(String(r.id))
|
|
return toExecutionRecord(r, taskNames.get(r.taskId), '', video ? { id: String(video.id), status: video.status } : null)
|
|
})
|
|
|
|
routes.value = (routePage?.records || []).map((r, i) => ({
|
|
id: String(r.id),
|
|
name: r.name,
|
|
waypoints: Array.isArray(r.waypoints) ? r.waypoints.length : 0,
|
|
distance: Array.isArray(r.waypoints) ? `${routeDistance(r.waypoints.map((p) => ({ longitude: Number(p.longitude), latitude: Number(p.latitude) })) ).toFixed(1)} km` : '--',
|
|
collectedAt: fmtTime(r.createdAt),
|
|
mapClass: i % 2 === 0 ? 'route-a' : 'route-c'
|
|
}))
|
|
const hasActiveExecution = (execPage?.records || []).some((r) => ['pending', 'running'].includes(r.status))
|
|
if (hasActiveExecution && !refreshTimer) refreshTimer = window.setInterval(load, 5000)
|
|
if (!hasActiveExecution && refreshTimer) {
|
|
window.clearInterval(refreshTimer)
|
|
refreshTimer = null
|
|
}
|
|
} catch (e) {
|
|
ui.toast(e.message || '加载任务数据失败')
|
|
}
|
|
}
|
|
|
|
onMounted(load)
|
|
|
|
onUnmounted(() => {
|
|
if (refreshTimer) window.clearInterval(refreshTimer)
|
|
teardownRouteMap()
|
|
})
|
|
|
|
const filteredPlans = computed(() => {
|
|
const q = search.value.trim().toLowerCase()
|
|
return plans.value.filter((p) => {
|
|
if (planFilter.value && p.statusClass !== planFilter.value) return false
|
|
if (!q) return true
|
|
return p.name.toLowerCase().includes(q) || p.device.toLowerCase().includes(q)
|
|
})
|
|
})
|
|
|
|
const filteredRecords = computed(() => {
|
|
const q = execSearch.value.trim().toLowerCase()
|
|
return records.value.filter((r) => {
|
|
if (execFilter.value && r.device !== execFilter.value) return false
|
|
if (execResultFilter.value && r.status !== execResultFilter.value) return false
|
|
if (!q) return true
|
|
return r.name.toLowerCase().includes(q) || r.device.toLowerCase().includes(q)
|
|
})
|
|
})
|
|
|
|
const filteredRoutes = computed(() => {
|
|
const q = routeSearch.value.trim().toLowerCase()
|
|
if (!q) return routes.value
|
|
return routes.value.filter((r) => r.name.toLowerCase().includes(q))
|
|
})
|
|
|
|
const routeSummary = computed(() => `${routePoints.value.length} 个航点 · ${routeDistance(routePoints.value).toFixed(1)} km`)
|
|
|
|
function formatCoord(lon, lat) {
|
|
if (!Number.isFinite(lon) || !Number.isFinite(lat)) return '--'
|
|
return `${lon.toFixed(6)}, ${lat.toFixed(6)}`
|
|
}
|
|
|
|
function resetRouteEditor() {
|
|
Object.assign(routeForm, { name: '', altitude: 50, speed: 10, turnMode: 'auto' })
|
|
routePoints.value = []
|
|
routeFormError.value = ''
|
|
routeCursor.value = '--'
|
|
}
|
|
|
|
function clearTaskLayers(map) {
|
|
if (!map) return
|
|
for (const id of [WP_LABEL, WP_CIRCLE, ROUTE_LINE]) {
|
|
if (map.getLayer(id)) map.removeLayer(id)
|
|
}
|
|
for (const id of [WP_SOURCE, ROUTE_SOURCE]) {
|
|
if (map.getSource(id)) map.removeSource(id)
|
|
}
|
|
}
|
|
|
|
function ensureTaskLayers(map) {
|
|
if (!map || !map.isStyleLoaded()) return false
|
|
if (!map.getSource(ROUTE_SOURCE)) {
|
|
map.addSource(ROUTE_SOURCE, {
|
|
type: 'geojson',
|
|
data: { type: 'FeatureCollection', features: [] },
|
|
})
|
|
}
|
|
if (!map.getSource(WP_SOURCE)) {
|
|
map.addSource(WP_SOURCE, {
|
|
type: 'geojson',
|
|
data: { type: 'FeatureCollection', features: [] },
|
|
})
|
|
}
|
|
if (!map.getLayer(ROUTE_LINE)) {
|
|
map.addLayer({
|
|
id: ROUTE_LINE,
|
|
type: 'line',
|
|
source: ROUTE_SOURCE,
|
|
paint: {
|
|
'line-color': '#2d82da',
|
|
'line-width': 3,
|
|
'line-opacity': 0.95,
|
|
},
|
|
})
|
|
}
|
|
if (!map.getLayer(WP_CIRCLE)) {
|
|
map.addLayer({
|
|
id: WP_CIRCLE,
|
|
type: 'circle',
|
|
source: WP_SOURCE,
|
|
paint: {
|
|
'circle-radius': 9,
|
|
'circle-color': ['get', 'color'],
|
|
'circle-stroke-color': '#fff',
|
|
'circle-stroke-width': 2,
|
|
},
|
|
})
|
|
}
|
|
if (!map.getLayer(WP_LABEL)) {
|
|
map.addLayer({
|
|
id: WP_LABEL,
|
|
type: 'symbol',
|
|
source: WP_SOURCE,
|
|
layout: {
|
|
'text-field': ['get', 'indexLabel'],
|
|
'text-size': 11,
|
|
'text-allow-overlap': true,
|
|
},
|
|
paint: { 'text-color': '#fff' },
|
|
})
|
|
}
|
|
return true
|
|
}
|
|
|
|
function syncRouteLayer() {
|
|
if (!routeMapInstance || !routeMapInstance.isStyleLoaded()) return
|
|
if (!ensureTaskLayers(routeMapInstance)) return
|
|
const coords = routePoints.value
|
|
.map((p) => [Number(p.longitude), Number(p.latitude)])
|
|
.filter(([lon, lat]) => Number.isFinite(lon) && Number.isFinite(lat))
|
|
|
|
const lineData = {
|
|
type: 'FeatureCollection',
|
|
features: coords.length >= 2
|
|
? [{ type: 'Feature', geometry: { type: 'LineString', coordinates: coords }, properties: {} }]
|
|
: [],
|
|
}
|
|
const wpData = {
|
|
type: 'FeatureCollection',
|
|
features: coords.map((c, i) => {
|
|
const isStart = i === 0
|
|
const isEnd = i === coords.length - 1 && coords.length > 1
|
|
return {
|
|
type: 'Feature',
|
|
properties: {
|
|
index: i,
|
|
indexLabel: String(i + 1),
|
|
color: isStart ? '#35a66a' : isEnd ? '#c56c28' : '#2d82da',
|
|
},
|
|
geometry: { type: 'Point', coordinates: c },
|
|
}
|
|
}),
|
|
}
|
|
routeMapInstance.getSource(ROUTE_SOURCE)?.setData(lineData)
|
|
routeMapInstance.getSource(WP_SOURCE)?.setData(wpData)
|
|
}
|
|
|
|
function onRouteMapClick(e) {
|
|
if (!routeMapAttached || !routeMapInstance) return
|
|
const hit = routeMapInstance.queryRenderedFeatures(e.point, {
|
|
layers: [WP_CIRCLE, WP_LABEL].filter((id) => routeMapInstance.getLayer(id)),
|
|
})
|
|
if (hit[0]?.properties?.index != null) {
|
|
openWaypointEditor(Number(hit[0].properties.index))
|
|
return
|
|
}
|
|
if (!validRouteDefaults()) return
|
|
const { lng, lat } = e.lngLat
|
|
routePoints.value.push({
|
|
longitude: lng,
|
|
latitude: lat,
|
|
altitude: Number(routeForm.altitude),
|
|
speed: Number(routeForm.speed),
|
|
turnMode: routeForm.turnMode,
|
|
})
|
|
routeCursor.value = formatCoord(lng, lat)
|
|
routeFormError.value = ''
|
|
syncRouteLayer()
|
|
}
|
|
|
|
function onRouteMapMove(e) {
|
|
if (!routeMapAttached) return
|
|
routeCursor.value = formatCoord(e.lngLat.lng, e.lngLat.lat)
|
|
}
|
|
|
|
async function attachRouteMap() {
|
|
await nextTick()
|
|
const host = routeMapEl.value
|
|
if (!host) return
|
|
|
|
const map = await commonRefs.getRef('map')
|
|
if (!map) {
|
|
routeMapReady.value = false
|
|
ui.toast('地图未初始化(缺少 Mapbox token)')
|
|
return
|
|
}
|
|
|
|
routeMapInstance = map
|
|
mapHelper.replaceMapContainer(host)
|
|
mapHelper.toggleMapMode({ is2D: true })
|
|
routeMapAttached = true
|
|
routeMapReady.value = false
|
|
|
|
const boot = () => {
|
|
pendingStyleHandler = null
|
|
if (!routeMapAttached || routeMapInstance !== map) return
|
|
clearTaskLayers(map)
|
|
if (!ensureTaskLayers(map)) return
|
|
map.on('click', onRouteMapClick)
|
|
map.on('mousemove', onRouteMapMove)
|
|
const c = map.getCenter()
|
|
routeCursor.value = formatCoord(c.lng, c.lat)
|
|
routeMapReady.value = true
|
|
requestAnimationFrame(() => {
|
|
map.resize()
|
|
syncRouteLayer()
|
|
})
|
|
}
|
|
|
|
if (map.isStyleLoaded()) {
|
|
boot()
|
|
} else {
|
|
pendingStyleHandler = boot
|
|
map.once('load', pendingStyleHandler)
|
|
}
|
|
}
|
|
|
|
function teardownRouteMap() {
|
|
if (routeMapInstance && pendingStyleHandler) {
|
|
routeMapInstance.off('load', pendingStyleHandler)
|
|
pendingStyleHandler = null
|
|
}
|
|
if (routeMapInstance) {
|
|
routeMapInstance.off('click', onRouteMapClick)
|
|
routeMapInstance.off('mousemove', onRouteMapMove)
|
|
clearTaskLayers(routeMapInstance)
|
|
}
|
|
if (routeMapAttached) {
|
|
mapHelper.restoreMapContainer()
|
|
routeMapAttached = false
|
|
}
|
|
routeMapInstance = null
|
|
routeMapReady.value = false
|
|
}
|
|
|
|
function openRouteEditor() {
|
|
resetRouteEditor()
|
|
routeEditorOpen.value = true
|
|
attachRouteMap()
|
|
}
|
|
|
|
function closeRouteEditor() {
|
|
teardownRouteMap()
|
|
routeEditorOpen.value = false
|
|
waypointEditorOpen.value = false
|
|
}
|
|
|
|
function routeDistance(points) {
|
|
return points.slice(1).reduce((total, point, index) => {
|
|
const previous = points[index]
|
|
const lat1 = previous.latitude * Math.PI / 180
|
|
const lat2 = point.latitude * Math.PI / 180
|
|
const dLat = lat2 - lat1
|
|
const dLon = (point.longitude - previous.longitude) * Math.PI / 180
|
|
const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2
|
|
return total + 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
|
|
}, 0)
|
|
}
|
|
|
|
function validRouteDefaults() {
|
|
if (routeForm.altitude < 20 || routeForm.altitude > 120) {
|
|
routeFormError.value = '默认高度应在 20~120 米之间。'
|
|
return false
|
|
}
|
|
if (routeForm.speed < 1 || routeForm.speed > 20) {
|
|
routeFormError.value = '默认速度应在 1~20 m/s 之间。'
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
function undoRoutePoint() {
|
|
routePoints.value.pop()
|
|
syncRouteLayer()
|
|
}
|
|
|
|
function clearRoutePoints() {
|
|
routePoints.value = []
|
|
syncRouteLayer()
|
|
}
|
|
|
|
function applyRouteDefaults() {
|
|
if (!validRouteDefaults()) return
|
|
routePoints.value = routePoints.value.map((point) => ({
|
|
longitude: point.longitude,
|
|
latitude: point.latitude,
|
|
altitude: Number(routeForm.altitude),
|
|
speed: Number(routeForm.speed),
|
|
turnMode: routeForm.turnMode,
|
|
}))
|
|
routeFormError.value = ''
|
|
syncRouteLayer()
|
|
}
|
|
|
|
function openWaypointEditor(index) {
|
|
const point = routePoints.value[index]
|
|
if (!point) return
|
|
editingWaypointIndex.value = index
|
|
Object.assign(waypointForm, {
|
|
longitude: point.longitude,
|
|
latitude: point.latitude,
|
|
altitude: point.altitude,
|
|
speed: point.speed,
|
|
turnMode: point.turnMode,
|
|
})
|
|
waypointFormError.value = ''
|
|
waypointEditorOpen.value = true
|
|
}
|
|
|
|
function closeWaypointEditor() {
|
|
waypointEditorOpen.value = false
|
|
editingWaypointIndex.value = -1
|
|
}
|
|
|
|
function saveWaypoint() {
|
|
if (!Number.isFinite(waypointForm.longitude) || !Number.isFinite(waypointForm.latitude)) {
|
|
waypointFormError.value = '请输入有效的经纬度。'
|
|
return
|
|
}
|
|
if (waypointForm.longitude < -180 || waypointForm.longitude > 180 || waypointForm.latitude < -90 || waypointForm.latitude > 90) {
|
|
waypointFormError.value = '经纬度超出有效范围。'
|
|
return
|
|
}
|
|
if (waypointForm.altitude < 20 || waypointForm.altitude > 120) {
|
|
waypointFormError.value = '高度应在 20~120 米之间。'
|
|
return
|
|
}
|
|
if (waypointForm.speed < 1 || waypointForm.speed > 20) {
|
|
waypointFormError.value = '速度应在 1~20 m/s 之间。'
|
|
return
|
|
}
|
|
routePoints.value[editingWaypointIndex.value] = {
|
|
longitude: Number(waypointForm.longitude),
|
|
latitude: Number(waypointForm.latitude),
|
|
altitude: Number(waypointForm.altitude),
|
|
speed: Number(waypointForm.speed),
|
|
turnMode: waypointForm.turnMode,
|
|
}
|
|
syncRouteLayer()
|
|
closeWaypointEditor()
|
|
}
|
|
|
|
function removeWaypoint(index) {
|
|
routePoints.value.splice(index, 1)
|
|
syncRouteLayer()
|
|
}
|
|
|
|
|
|
async function submitRoute() {
|
|
const name = routeForm.name.trim()
|
|
if (!name) {
|
|
routeFormError.value = '请输入航线名称。'
|
|
return
|
|
}
|
|
if (routePoints.value.length < 2) {
|
|
routeFormError.value = '至少需要设置 2 个航点。'
|
|
return
|
|
}
|
|
routeSaving.value = true
|
|
try {
|
|
await request.post(urls.ROUTES, {
|
|
name,
|
|
waypoints: routePoints.value.map((point) => ({ longitude: point.longitude, latitude: point.latitude, altitude: point.altitude, speed: point.speed, yaw: 0, holdSec: point.turnMode === 'stop' ? 1 : 0 }))
|
|
})
|
|
closeRouteEditor()
|
|
ui.toast('航线已保存')
|
|
await load()
|
|
} catch (e) {
|
|
routeFormError.value = e.message || '保存航线失败'
|
|
} finally {
|
|
routeSaving.value = false
|
|
}
|
|
}
|
|
|
|
function resetPlanFilter() {
|
|
search.value = ''
|
|
planFilter.value = ''
|
|
}
|
|
|
|
async function deleteTask(task) {
|
|
const ok = await ui.confirm(`确定永久删除任务“${task.name}”吗?`, '此操作不可恢复。')
|
|
if (!ok) return
|
|
try {
|
|
await request.delete(urls.TASK(task.id))
|
|
ui.toast('任务已删除')
|
|
await load()
|
|
} catch (e) {
|
|
ui.toast(e.message || '删除任务失败')
|
|
}
|
|
}
|
|
|
|
function canExecuteTask(task) {
|
|
return task.rawStatus !== 'disabled' && task.rawStatus !== 'paused' && task.statusClass !== 'mission'
|
|
}
|
|
|
|
async function executeTask(task) {
|
|
try {
|
|
const result = await request.post(urls.TASK_EXECUTE(task.id))
|
|
const execution = result?.execution
|
|
task.lastResult = EXEC_STATUS[execution?.status] || '等待设备开始'
|
|
if (execution) {
|
|
records.value.unshift(toExecutionRecord(execution, task.name, task.cycle))
|
|
}
|
|
ui.toast('任务已下发')
|
|
await load()
|
|
} catch (e) {
|
|
ui.toast(e.message || '任务执行失败')
|
|
}
|
|
}
|
|
|
|
function setScheduleType(type) {
|
|
if (type === 'once') {
|
|
taskForm.scheduleType = 'once'
|
|
taskForm.scheduleCron = ''
|
|
return
|
|
}
|
|
taskForm.scheduleType = 'cron'
|
|
taskForm.schedulePeriod = type
|
|
if (type !== 'custom') taskForm.scheduleCron = ''
|
|
}
|
|
|
|
function buildScheduleCron() {
|
|
if (taskForm.scheduleType === 'once') return ''
|
|
if (taskForm.schedulePeriod === 'custom') return taskForm.scheduleCron.trim()
|
|
const [hour, minute] = taskForm.scheduleTime.split(':').map(Number)
|
|
if (!Number.isInteger(hour) || !Number.isInteger(minute)) return ''
|
|
const dayOfWeek = taskForm.schedulePeriod === 'week' ? taskForm.scheduleWeekday : '*'
|
|
return `${minute} ${hour} * * ${dayOfWeek}`
|
|
}
|
|
|
|
function isBasicCronExpression(value) {
|
|
const parts = value.trim().split(/\s+/)
|
|
return parts.length === 5 && parts.every((part) => /^[\d*/?,\-]+$/.test(part))
|
|
}
|
|
|
|
const todayTaskCount = computed(() => plans.value.length)
|
|
const runningTaskCount = computed(() => plans.value.filter((p) => p.statusClass === 'mission').length)
|
|
const pendingTaskCount = computed(() => plans.value.filter((p) => p.statusClass === 'pending').length)
|
|
const completedTodayCount = computed(() => records.value.filter((r) => r.status === 'succeeded').length)
|
|
|
|
function openCreateTask() {
|
|
Object.assign(taskForm, {
|
|
name: '',
|
|
dockId: deviceStore.docks[0]?.dockId || '',
|
|
routeId: '',
|
|
scheduleType: 'once',
|
|
schedulePeriod: 'day',
|
|
scheduleWeekday: 1,
|
|
scheduleTime: '08:00',
|
|
scheduleCron: '',
|
|
rawRecordingEnabled: false
|
|
})
|
|
taskFormError.value = ''
|
|
createTaskOpen.value = true
|
|
}
|
|
|
|
async function submitCreateTask() {
|
|
taskFormError.value = ''
|
|
const name = taskForm.name.trim()
|
|
if (!name || !taskForm.dockId || !taskForm.routeId) {
|
|
taskFormError.value = '请完整填写任务名称、执行机巢和飞行航线。'
|
|
return
|
|
}
|
|
const scheduleCron = buildScheduleCron()
|
|
if (taskForm.scheduleType === 'cron' && taskForm.schedulePeriod === 'custom' && !isBasicCronExpression(scheduleCron)) {
|
|
taskFormError.value = '请输入合法的五段式 Cron 表达式,例如:0 8 * * 1-5。'
|
|
return
|
|
}
|
|
if (taskForm.scheduleType === 'cron' && !scheduleCron) {
|
|
taskFormError.value = '请选择有效的执行时间。'
|
|
return
|
|
}
|
|
|
|
creatingTask.value = true
|
|
try {
|
|
await request.post(urls.TASKS, {
|
|
name,
|
|
dockId: taskForm.dockId,
|
|
routeId: Number(taskForm.routeId) || 0,
|
|
scheduleType: taskForm.scheduleType,
|
|
scheduleCron,
|
|
videoPolicy: taskForm.rawRecordingEnabled ? 'raw' : 'replay'
|
|
})
|
|
createTaskOpen.value = false
|
|
ui.toast('任务已创建')
|
|
await load()
|
|
} catch (e) {
|
|
taskFormError.value = e.message || '创建任务失败'
|
|
} finally {
|
|
creatingTask.value = false
|
|
}
|
|
}
|
|
|
|
function goReplay(r) {
|
|
router.push({ name: 'replay', params: { key: r.id } })
|
|
}
|
|
|
|
async function downloadRawVideo(record) {
|
|
if (!record.rawVideoId || record.rawVideoStatus !== 'ready') return
|
|
try {
|
|
const res = await request.post(urls.VIDEO_DOWNLOAD(record.rawVideoId))
|
|
if (res?.downloadUrl) {
|
|
window.open(res.downloadUrl, '_blank')
|
|
ui.toast('开始下载原视频')
|
|
} else {
|
|
ui.toast('暂无下载地址')
|
|
}
|
|
} catch (e) {
|
|
ui.toast(e.message || '下载失败')
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.business-search :deep(.t-input) {
|
|
width: 100%;
|
|
}
|
|
.business-search {
|
|
border: 0;
|
|
padding: 0;
|
|
height: auto;
|
|
background: transparent;
|
|
}
|
|
.toolbar-group {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
.toolbar-group :deep(.t-select) {
|
|
flex-shrink: 0;
|
|
}
|
|
.business-tdesign-tabs :deep(.t-tabs__nav-container) {
|
|
padding: 0 18px;
|
|
}
|
|
.business-tdesign-tabs :deep(.t-tabs__nav-wrap) {
|
|
margin-left: 0;
|
|
}
|
|
.business-table-wrap :deep(.t-table) {
|
|
background: transparent;
|
|
}
|
|
.task-mode-radio {
|
|
width: 100%;
|
|
display: flex;
|
|
}
|
|
.task-mode-radio :deep(.t-radio-button) {
|
|
flex: 1;
|
|
justify-content: center;
|
|
}
|
|
.task-schedule-radio :deep(.t-radio-button) {
|
|
min-width: 0;
|
|
padding: 0 4px;
|
|
white-space: nowrap;
|
|
}
|
|
.route-config-panel :deep(.t-input),
|
|
.route-config-panel :deep(.t-input-number) {
|
|
width: 100%;
|
|
}
|
|
.number-input :deep(.t-input-number) {
|
|
width: 100%;
|
|
}
|
|
.number-input :deep(.t-input) {
|
|
padding-right: 34px;
|
|
}
|
|
.task-video-option :deep(.t-switch) {
|
|
justify-self: end;
|
|
}
|
|
</style>
|
|
|
|
<style>
|
|
.add-dock-dialog .t-input,
|
|
.add-dock-dialog .t-input-number,
|
|
.add-dock-dialog .t-select,
|
|
.add-dock-dialog .t-time-picker {
|
|
width: 100%;
|
|
}
|
|
</style>
|
|
|