添加设备信息和教学视频目录,完成基础ui
This commit is contained in:
18
src/api/system/info.js
Normal file
18
src/api/system/info.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 查询设备基本信息列表
|
||||
export function listInfo(query) {
|
||||
return request({
|
||||
url: '/system/info/list',
|
||||
method: 'get',
|
||||
params: query
|
||||
})
|
||||
}
|
||||
|
||||
// 查询设备基本信息详细
|
||||
export function getInfo(deviceId) {
|
||||
return request({
|
||||
url: '/system/info/' + deviceId,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
18
src/api/system/video.js
Normal file
18
src/api/system/video.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 查询教学视频列表
|
||||
export function listVideo(query) {
|
||||
return request({
|
||||
url: '/system/video/list',
|
||||
method: 'get',
|
||||
params: query
|
||||
})
|
||||
}
|
||||
|
||||
// 查询教学视频详细
|
||||
export function getVideo(videoId) {
|
||||
return request({
|
||||
url: '/system/video/' + videoId,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
@@ -58,6 +58,14 @@ const usePermissionStore = defineStore(
|
||||
// 遍历后台传来的路由字符串,转换为组件对象
|
||||
function filterAsyncRouter(asyncRouterMap, lastRouter = false, type = false) {
|
||||
return asyncRouterMap.filter(route => {
|
||||
// 兼容:后端把顶级菜单配置成了具体页面组件(非Layout)时,会导致进入后丢失整体布局(侧边栏/顶部栏)。
|
||||
// 这里将该类顶级叶子路由自动包一层 Layout,子路由使用空 path 以保持原 URL 不变。
|
||||
if (!lastRouter && route.component && !route.children && !['Layout', 'ParentView', 'InnerLink'].includes(route.component) && typeof route.path === 'string' && route.path.startsWith('/')) {
|
||||
const inner = { ...route }
|
||||
route.component = 'Layout'
|
||||
route.name = inner.name ? `${inner.name}Wrapper` : route.name
|
||||
route.children = [Object.assign(inner, { path: '' })]
|
||||
}
|
||||
if (type && route.children) {
|
||||
route.children = filterChildren(route.children)
|
||||
}
|
||||
|
||||
545
src/views/system/info/index.vue
Normal file
545
src/views/system/info/index.vue
Normal file
@@ -0,0 +1,545 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<div class="m-page">
|
||||
<div v-if="!detailOpen" class="m-header">
|
||||
<div class="m-title">设备信息</div>
|
||||
<div class="m-subtitle">查看您公司拥有的焊接机器人设备信息</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!detailOpen" class="m-list">
|
||||
<div v-if="loading && infoList.length === 0" class="m-hint">正在加载...</div>
|
||||
<div v-else-if="!loading && infoList.length === 0" class="m-hint">暂无设备</div>
|
||||
<div
|
||||
v-for="item in infoList"
|
||||
:key="item.deviceId"
|
||||
class="m-device-card"
|
||||
@click="handleDetail(item)"
|
||||
>
|
||||
<div class="m-device-row">
|
||||
<div class="m-device-name">{{ item.deviceName }}</div>
|
||||
<div class="m-device-status" :class="statusClass(item.deviceStatus)">
|
||||
<span class="m-dot" />
|
||||
<span>{{ formatDeviceStatus(item.deviceStatus) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="m-device-meta">
|
||||
<div class="m-meta-item"><span class="m-meta-label">设备ID</span><span class="m-meta-value">{{ item.deviceId }}</span></div>
|
||||
<div class="m-meta-item"><span class="m-meta-label">设备编号</span><span class="m-meta-value">{{ item.deviceCode }}</span></div>
|
||||
</div>
|
||||
<div class="m-device-action">
|
||||
<span class="m-action-text">查看详情</span>
|
||||
<el-icon class="m-arrow"><ArrowRight /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref="loadMoreRef" class="m-load-more">
|
||||
<span v-if="loadingMore">加载中...</span>
|
||||
<span v-else-if="finished && infoList.length > 0">已到底</span>
|
||||
<span v-else-if="infoList.length > 0">上滑加载更多</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="m-detail" v-loading="detailLoading" element-loading-text="加载详情中...">
|
||||
<div class="m-detail-top">
|
||||
<el-button class="m-back" plain @click="detailOpen = false">
|
||||
<el-icon><ArrowLeft /></el-icon>
|
||||
返回
|
||||
</el-button>
|
||||
<div class="m-title m-center">设备信息</div>
|
||||
</div>
|
||||
|
||||
<div class="m-card m-hero-card">
|
||||
<div class="m-hero">
|
||||
<img v-if="detail.deviceImage" :src="detail.deviceImage" alt="device" />
|
||||
<div v-else class="m-hero-placeholder"></div>
|
||||
</div>
|
||||
<div class="m-hero-body">
|
||||
<div class="m-hero-title">{{ detail.deviceName || '设备详情' }}</div>
|
||||
<div class="m-hero-sub">设备编号:{{ detail.deviceCode || '-' }}</div>
|
||||
<div class="m-badges">
|
||||
<div class="m-badge" :class="statusClass(detail.deviceStatus)">
|
||||
<span class="m-dot" />
|
||||
<span>{{ formatDeviceStatus(detail.deviceStatus) }}</span>
|
||||
</div>
|
||||
<div class="m-badge" :class="warrantyStatus().cls">
|
||||
<span class="m-dot" />
|
||||
<span>质保{{ warrantyStatus().text }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="m-section">
|
||||
<div class="m-section-title">基础信息</div>
|
||||
<div class="m-kv"><span class="m-kv-key">型号规格</span><span class="m-kv-val">{{ detail.modelSpec || '-' }}</span></div>
|
||||
<div class="m-kv"><span class="m-kv-key">购买日期</span><span class="m-kv-val">{{ detail.purchaseDate || '-' }}</span></div>
|
||||
<div class="m-kv"><span class="m-kv-key">设备序列号</span><span class="m-kv-val">{{ detail.serialNumber || '-' }}</span></div>
|
||||
<div class="m-kv"><span class="m-kv-key">安装地址</span><span class="m-kv-val">{{ detail.installAddress || '-' }}</span></div>
|
||||
<div class="m-kv"><span class="m-kv-key">负责人</span><span class="m-kv-val">{{ detail.responsiblePerson || '-' }}</span></div>
|
||||
</div>
|
||||
|
||||
<div class="m-section">
|
||||
<div class="m-section-title">质保信息</div>
|
||||
<div class="m-kv">
|
||||
<span class="m-kv-key">质保状态</span>
|
||||
<span class="m-kv-val">
|
||||
<span class="m-chip" :class="warrantyStatus().cls"> {{ warrantyStatus().text }} </span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="m-kv"><span class="m-kv-key">质保开始日期</span><span class="m-kv-val">{{ detail.warrantyStartDate || '-' }}</span></div>
|
||||
<div class="m-kv"><span class="m-kv-key">质保结束日期</span><span class="m-kv-val">{{ detail.warrantyEndDate || '-' }}</span></div>
|
||||
<div class="m-kv">
|
||||
<span class="m-kv-key">质保{{ warrantyStatus().leftLabel }}</span>
|
||||
<span class="m-kv-val">{{ leftDaysText(warrantyStatus().leftDays) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup name="Info">
|
||||
import { listInfo, getInfo } from "@/api/system/info"
|
||||
import { ArrowLeft, ArrowRight } from "@element-plus/icons-vue"
|
||||
|
||||
const { proxy } = getCurrentInstance()
|
||||
|
||||
const infoList = ref([])
|
||||
const detailOpen = ref(false)
|
||||
const detail = ref({})
|
||||
const detailLoading = ref(false)
|
||||
const detailLock = ref(false)
|
||||
const loading = ref(true)
|
||||
const loadingMore = ref(false)
|
||||
const finished = ref(false)
|
||||
const loadMoreRef = ref(null)
|
||||
let observer = null
|
||||
const loadingLock = ref(false)
|
||||
const loadedIds = ref(new Set())
|
||||
|
||||
const data = reactive({
|
||||
queryParams: {
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
}
|
||||
})
|
||||
|
||||
const { queryParams } = toRefs(data)
|
||||
|
||||
/** 查询设备基本信息列表 */
|
||||
function getList(reset = false) {
|
||||
if (reset) {
|
||||
queryParams.value.pageNum = 1
|
||||
finished.value = false
|
||||
infoList.value = []
|
||||
loadedIds.value = new Set()
|
||||
}
|
||||
if (finished.value) return
|
||||
if (loadingLock.value) return
|
||||
|
||||
const isFirstPage = queryParams.value.pageNum === 1
|
||||
if (isFirstPage) loading.value = true
|
||||
else loadingMore.value = true
|
||||
loadingLock.value = true
|
||||
|
||||
listInfo(queryParams.value).then(response => {
|
||||
const rows = response.rows || []
|
||||
const next = []
|
||||
for (const r of rows) {
|
||||
const id = r?.deviceId
|
||||
if (id == null) continue
|
||||
if (loadedIds.value.has(id)) continue
|
||||
loadedIds.value.add(id)
|
||||
next.push(r)
|
||||
}
|
||||
infoList.value = infoList.value.concat(next)
|
||||
if (rows.length < queryParams.value.pageSize) {
|
||||
finished.value = true
|
||||
} else {
|
||||
queryParams.value.pageNum += 1
|
||||
}
|
||||
}).finally(() => {
|
||||
loading.value = false
|
||||
loadingMore.value = false
|
||||
loadingLock.value = false
|
||||
})
|
||||
}
|
||||
|
||||
/** 详情按钮操作 */
|
||||
function handleDetail(row) {
|
||||
if (detailLock.value) return
|
||||
detailLock.value = true
|
||||
detailLoading.value = true
|
||||
detail.value = {}
|
||||
detailOpen.value = true
|
||||
const _deviceId = row.deviceId
|
||||
getInfo(_deviceId).then(response => {
|
||||
detail.value = response.data
|
||||
}).finally(() => {
|
||||
detailLoading.value = false
|
||||
detailLock.value = false
|
||||
})
|
||||
}
|
||||
function formatDeviceStatus(val) {
|
||||
const map = {
|
||||
"0": "运行中",
|
||||
"1": "故障",
|
||||
"2": "停机",
|
||||
"3": "维护"
|
||||
}
|
||||
return map[val] || "-"
|
||||
}
|
||||
function statusClass(val) {
|
||||
const map = {
|
||||
"0": "is-ok",
|
||||
"1": "is-bad",
|
||||
"2": "is-warn",
|
||||
"3": "is-info"
|
||||
}
|
||||
return map[val] || "is-info"
|
||||
}
|
||||
|
||||
function warrantyStatus() {
|
||||
const start = parseYmd(detail.value?.warrantyStartDate)
|
||||
const end = parseYmd(detail.value?.warrantyEndDate)
|
||||
if (!start || !end) return { text: "未知", cls: "is-info", leftDays: null, leftLabel: "质保剩余" }
|
||||
const today = startOfToday()
|
||||
|
||||
if (today.getTime() < start.getTime()) {
|
||||
return { text: "未开始", cls: "is-info", leftDays: diffDays(today, start), leftLabel: "距开始" }
|
||||
}
|
||||
if (today.getTime() <= end.getTime()) {
|
||||
return { text: "在保", cls: "is-ok", leftDays: diffDays(today, end), leftLabel: "剩余" }
|
||||
}
|
||||
return { text: "已过保", cls: "is-bad", leftDays: diffDays(end, today), leftLabel: "已过期" }
|
||||
}
|
||||
|
||||
function startOfToday() {
|
||||
const d = new Date()
|
||||
d.setHours(0, 0, 0, 0)
|
||||
return d
|
||||
}
|
||||
|
||||
function parseYmd(val) {
|
||||
if (!val) return null
|
||||
// 支持 "YYYY-MM-DD" 或 Date 可解析字符串
|
||||
const s = String(val)
|
||||
const m = s.match(/^(\d{4})-(\d{2})-(\d{2})/)
|
||||
if (m) {
|
||||
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]))
|
||||
d.setHours(0, 0, 0, 0)
|
||||
return isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
const d = new Date(s)
|
||||
d.setHours(0, 0, 0, 0)
|
||||
return isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
|
||||
function diffDays(a, b) {
|
||||
const ms = b.getTime() - a.getTime()
|
||||
return Math.floor(ms / 86400000)
|
||||
}
|
||||
|
||||
function leftDaysText(days) {
|
||||
if (days === null || days === undefined) return "-"
|
||||
const d = Number(days)
|
||||
if (!Number.isFinite(d)) return "-"
|
||||
return `${Math.max(0, Math.floor(d))} 天`
|
||||
}
|
||||
|
||||
function setupObserver() {
|
||||
if (observer) observer.disconnect()
|
||||
if (!loadMoreRef.value) return
|
||||
observer = new IntersectionObserver((entries) => {
|
||||
if (detailOpen.value) return
|
||||
const entry = entries[0]
|
||||
if (entry && entry.isIntersecting) {
|
||||
getList(false)
|
||||
}
|
||||
}, { root: null, rootMargin: "200px", threshold: 0 })
|
||||
observer.observe(loadMoreRef.value)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getList(true)
|
||||
nextTick(setupObserver)
|
||||
})
|
||||
|
||||
watch(detailOpen, (v) => {
|
||||
if (!v) nextTick(setupObserver)
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.m-page {
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
padding: 12px;
|
||||
min-height: calc(100vh - 84px);
|
||||
background: linear-gradient(180deg, rgba(37, 99, 235, 0.06), rgba(37, 99, 235, 0) 140px);
|
||||
}
|
||||
.m-header {
|
||||
padding: 4px 4px 12px 4px;
|
||||
}
|
||||
.m-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
line-height: 24px;
|
||||
}
|
||||
.m-subtitle {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
.m-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.m-device-card {
|
||||
background: var(--el-bg-color);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
box-shadow: 0 2px 10px rgba(15, 23, 42, 0.06);
|
||||
transition: transform 0.08s ease, box-shadow 0.12s ease;
|
||||
}
|
||||
.m-device-card:active {
|
||||
transform: scale(0.99);
|
||||
box-shadow: 0 1px 6px rgba(15, 23, 42, 0.05);
|
||||
}
|
||||
.m-device-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.m-device-name {
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
line-height: 20px;
|
||||
}
|
||||
.m-device-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(16, 185, 129, 0.10);
|
||||
color: var(--el-color-success);
|
||||
}
|
||||
.m-device-status.is-bad {
|
||||
background: rgba(239, 68, 68, 0.10);
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
.m-device-status.is-warn {
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
color: var(--el-color-warning);
|
||||
}
|
||||
.m-device-status.is-info {
|
||||
background: rgba(100, 116, 139, 0.12);
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
.m-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: currentColor;
|
||||
display: inline-block;
|
||||
}
|
||||
.m-device-meta {
|
||||
margin-top: 10px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.m-meta-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.m-meta-label {
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
.m-device-action {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
.m-action-text {
|
||||
font-weight: 600;
|
||||
}
|
||||
.m-arrow {
|
||||
font-size: 14px;
|
||||
}
|
||||
.m-hint {
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
.m-load-more {
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
.m-detail-top {
|
||||
display: grid;
|
||||
grid-template-columns: 92px 1fr 92px;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.m-center {
|
||||
text-align: center;
|
||||
}
|
||||
.m-back {
|
||||
justify-self: start;
|
||||
border-radius: 999px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
.m-detail-top .m-title {
|
||||
text-align: center;
|
||||
}
|
||||
.m-card {
|
||||
background: var(--el-bg-color);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
.m-hero-card {
|
||||
padding: 10px;
|
||||
}
|
||||
.m-hero {
|
||||
margin-top: 0;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
background: #0f172a;
|
||||
}
|
||||
.m-hero img {
|
||||
width: 100%;
|
||||
height: 180px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.m-hero-placeholder {
|
||||
width: 100%;
|
||||
height: 180px;
|
||||
background: linear-gradient(135deg, #0f172a, #334155);
|
||||
}
|
||||
.m-hero-body {
|
||||
padding: 10px 4px 2px 4px;
|
||||
}
|
||||
.m-hero-title {
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
line-height: 22px;
|
||||
}
|
||||
.m-hero-sub {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
.m-badges {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.m-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(100, 116, 139, 0.12);
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
.m-badge.is-ok {
|
||||
background: rgba(16, 185, 129, 0.10);
|
||||
color: var(--el-color-success);
|
||||
}
|
||||
.m-badge.is-bad {
|
||||
background: rgba(239, 68, 68, 0.10);
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
.m-badge.is-warn {
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
color: var(--el-color-warning);
|
||||
}
|
||||
.m-badge.is-info {
|
||||
background: rgba(100, 116, 139, 0.12);
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
.m-kv-key {
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
.m-kv-val {
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
.m-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
background: rgba(100, 116, 139, 0.12);
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
.m-chip.is-ok {
|
||||
background: rgba(16, 185, 129, 0.10);
|
||||
color: var(--el-color-success);
|
||||
}
|
||||
.m-chip.is-bad {
|
||||
background: rgba(239, 68, 68, 0.10);
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
.m-chip.is-info {
|
||||
background: rgba(100, 116, 139, 0.12);
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
.m-device-code {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
.m-section {
|
||||
margin-top: 12px;
|
||||
background: var(--el-bg-color);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
.m-section-title {
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.m-kv {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 0;
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
font-size: 13px;
|
||||
}
|
||||
.m-kv:first-of-type {
|
||||
border-top: none;
|
||||
padding-top: 0;
|
||||
}
|
||||
.m-kv span:first-child {
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
329
src/views/system/video/index.vue
Normal file
329
src/views/system/video/index.vue
Normal file
@@ -0,0 +1,329 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<div class="m-page">
|
||||
<div class="m-header">
|
||||
<div class="m-title">焊接机器人教学视频</div>
|
||||
<div class="m-subtitle">这里提供焊接机器人使用教程与常见问题解决方案</div>
|
||||
</div>
|
||||
|
||||
<div class="m-tabs">
|
||||
<el-segmented v-model="activeTab" :options="tabOptions" size="small" />
|
||||
</div>
|
||||
|
||||
<div class="m-list">
|
||||
<div v-if="loading && videoList.length === 0" class="m-hint">正在加载...</div>
|
||||
<div v-else-if="!loading && videoList.length === 0" class="m-hint">暂无视频</div>
|
||||
<div
|
||||
v-for="item in videoList"
|
||||
:key="item.videoId"
|
||||
class="m-video-card"
|
||||
@click="openVideo(item)"
|
||||
>
|
||||
<div class="m-cover">
|
||||
<img v-if="item.coverUrl" :src="biliCoverProxy(item.coverUrl)" alt="cover" />
|
||||
<div v-else class="m-cover-placeholder" />
|
||||
<div class="m-play">▶</div>
|
||||
<div class="m-duration">{{ formatDuration(item.durationSeconds) }}</div>
|
||||
</div>
|
||||
<div class="m-content">
|
||||
<div class="m-video-title">{{ item.title }}</div>
|
||||
<div class="m-video-desc">{{ item.summary }}</div>
|
||||
<div class="m-meta">
|
||||
<span>{{ formatViews(item.viewCount) }}</span>
|
||||
<span>·</span>
|
||||
<span>{{ item.publishDate || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref="loadMoreRef" class="m-load-more">
|
||||
<span v-if="loadingMore">加载中...</span>
|
||||
<span v-else-if="finished && videoList.length > 0">已到底</span>
|
||||
<span v-else-if="videoList.length > 0">上滑加载更多</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog title="播放视频" v-model="playerOpen" width="720px" append-to-body>
|
||||
<div class="m-player">
|
||||
<iframe
|
||||
v-if="isBiliEmbed(currentVideo)"
|
||||
:src="currentVideo.videoUrl"
|
||||
frameborder="0"
|
||||
allowfullscreen="true"
|
||||
referrerpolicy="no-referrer"
|
||||
style="width: 100%; height: 420px"
|
||||
/>
|
||||
<video v-else-if="currentVideo?.videoUrl" controls playsinline style="width: 100%">
|
||||
<source :src="currentVideo.videoUrl" />
|
||||
</video>
|
||||
<div v-else class="m-empty">
|
||||
暂无视频地址
|
||||
<div v-if="currentVideo?.sourceUrl" style="margin-top: 10px">
|
||||
<el-button type="primary" plain @click="openSourceUrl(currentVideo.sourceUrl)">打开B站</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="m-player-title">{{ currentVideo?.title }}</div>
|
||||
<div class="m-player-desc">{{ currentVideo?.summary }}</div>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button v-if="currentVideo?.sourceUrl" plain @click="openSourceUrl(currentVideo.sourceUrl)">打开B站</el-button>
|
||||
<el-button @click="playerOpen = false">关 闭</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup name="Video">
|
||||
import { listVideo, getVideo } from "@/api/system/video"
|
||||
|
||||
const videoList = ref([])
|
||||
const playerOpen = ref(false)
|
||||
const currentVideo = ref(null)
|
||||
const loading = ref(true)
|
||||
const loadingMore = ref(false)
|
||||
const finished = ref(false)
|
||||
const loadMoreRef = ref(null)
|
||||
let observer = null
|
||||
const activeTab = ref("ALL")
|
||||
const tabOptions = [
|
||||
{ label: "全部视频", value: "ALL" },
|
||||
{ label: "基础教学", value: "BASIC" },
|
||||
{ label: "常见问题", value: "FAQ" },
|
||||
{ label: "故障排除", value: "TROUBLE" }
|
||||
]
|
||||
|
||||
const data = reactive({
|
||||
queryParams: {
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
categoryCode: null,
|
||||
}
|
||||
})
|
||||
|
||||
const { queryParams } = toRefs(data)
|
||||
|
||||
/** 查询教学视频列表 */
|
||||
function getList(reset = false) {
|
||||
if (reset) {
|
||||
queryParams.value.pageNum = 1
|
||||
finished.value = false
|
||||
videoList.value = []
|
||||
}
|
||||
if (finished.value) return
|
||||
|
||||
queryParams.value.categoryCode = activeTab.value === "ALL" ? null : activeTab.value
|
||||
const isFirstPage = queryParams.value.pageNum === 1
|
||||
if (isFirstPage) loading.value = true
|
||||
else loadingMore.value = true
|
||||
|
||||
listVideo(queryParams.value).then(response => {
|
||||
const rows = response.rows || []
|
||||
videoList.value = videoList.value.concat(rows)
|
||||
if (rows.length < queryParams.value.pageSize) {
|
||||
finished.value = true
|
||||
} else {
|
||||
queryParams.value.pageNum += 1
|
||||
}
|
||||
}).finally(() => {
|
||||
loading.value = false
|
||||
loadingMore.value = false
|
||||
})
|
||||
}
|
||||
|
||||
watch(activeTab, () => {
|
||||
getList(true)
|
||||
})
|
||||
|
||||
function openVideo(row) {
|
||||
getVideo(row.videoId).then(res => {
|
||||
currentVideo.value = res.data
|
||||
playerOpen.value = true
|
||||
})
|
||||
}
|
||||
|
||||
function setupObserver() {
|
||||
if (observer) observer.disconnect()
|
||||
if (!loadMoreRef.value) return
|
||||
observer = new IntersectionObserver((entries) => {
|
||||
const entry = entries[0]
|
||||
if (entry && entry.isIntersecting) {
|
||||
getList(false)
|
||||
}
|
||||
}, { root: null, rootMargin: "200px", threshold: 0 })
|
||||
observer.observe(loadMoreRef.value)
|
||||
}
|
||||
|
||||
function formatDuration(sec) {
|
||||
const s = Number(sec || 0)
|
||||
const m = Math.floor(s / 60)
|
||||
const r = s % 60
|
||||
return `${String(m).padStart(2, "0")}:${String(r).padStart(2, "0")}`
|
||||
}
|
||||
|
||||
function formatViews(v) {
|
||||
const n = Number(v || 0)
|
||||
if (n >= 10000) return `${(n / 10000).toFixed(1)}w 次播放`
|
||||
return `${n} 次播放`
|
||||
}
|
||||
|
||||
function biliCoverProxy(url) {
|
||||
if (!url) return ""
|
||||
const u = String(url).replace(/^http:\/\//i, "https://")
|
||||
return `${import.meta.env.VITE_APP_BASE_API}/system/bili/cover?url=${encodeURIComponent(u)}`
|
||||
}
|
||||
|
||||
function isBiliEmbed(v) {
|
||||
const url = v?.videoUrl || ""
|
||||
return typeof url === "string" && url.includes("player.bilibili.com/player.html")
|
||||
}
|
||||
|
||||
function openSourceUrl(url) {
|
||||
if (!url) return
|
||||
window.open(url, "_blank")
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getList(true)
|
||||
nextTick(setupObserver)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.m-page {
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
padding: 12px;
|
||||
min-height: calc(100vh - 84px);
|
||||
background: linear-gradient(180deg, rgba(37, 99, 235, 0.06), rgba(37, 99, 235, 0) 140px);
|
||||
}
|
||||
.m-header {
|
||||
padding: 4px 4px 12px 4px;
|
||||
}
|
||||
.m-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
line-height: 24px;
|
||||
}
|
||||
.m-subtitle {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
.m-tabs {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.m-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.m-video-card {
|
||||
background: var(--el-bg-color);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 10px rgba(15, 23, 42, 0.06);
|
||||
transition: transform 0.08s ease, box-shadow 0.12s ease;
|
||||
}
|
||||
.m-video-card:active {
|
||||
transform: scale(0.99);
|
||||
box-shadow: 0 1px 6px rgba(15, 23, 42, 0.05);
|
||||
}
|
||||
.m-cover {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 180px;
|
||||
background: #0f172a;
|
||||
}
|
||||
.m-cover img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.m-cover-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, #0f172a, #334155);
|
||||
}
|
||||
.m-play {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 42px;
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
text-shadow: 0 2px 16px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.m-duration {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
bottom: 10px;
|
||||
font-size: 12px;
|
||||
color: #fff;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
.m-content {
|
||||
padding: 12px;
|
||||
}
|
||||
.m-video-title {
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
line-height: 20px;
|
||||
}
|
||||
.m-video-desc {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
line-height: 18px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.m-meta {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.m-hint {
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
.m-load-more {
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
.m-player {
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
background: #000;
|
||||
}
|
||||
.m-empty {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
}
|
||||
.m-player-title {
|
||||
margin-top: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.m-player-desc {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user