init
This commit is contained in:
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
3
.vscode/extensions.json
vendored
Normal file
3
.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["Vue.volar"]
|
||||||
|
}
|
||||||
5
README.md
Normal file
5
README.md
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
# Vue 3 + TypeScript + Vite
|
||||||
|
|
||||||
|
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||||
|
|
||||||
|
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
|
||||||
13
index.html
Normal file
13
index.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>wh-hpe-ui</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1433
package-lock.json
generated
Normal file
1433
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
package.json
Normal file
22
package.json
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "wh-hpe-ui",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^3.5.24"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.10.1",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
|
"@vue/tsconfig": "^0.8.1",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"vite": "^7.2.4",
|
||||||
|
"vue-tsc": "^3.1.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
public/vite.svg
Normal file
1
public/vite.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
22
src/App.vue
Normal file
22
src/App.vue
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import Pose from "./components/Pose.vue";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Pose />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.logo {
|
||||||
|
height: 6em;
|
||||||
|
padding: 1.5em;
|
||||||
|
will-change: filter;
|
||||||
|
transition: filter 300ms;
|
||||||
|
}
|
||||||
|
.logo:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #646cffaa);
|
||||||
|
}
|
||||||
|
.logo.vue:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #42b883aa);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
1
src/assets/vue.svg
Normal file
1
src/assets/vue.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 496 B |
915
src/components/Pose.vue
Normal file
915
src/components/Pose.vue
Normal file
@ -0,0 +1,915 @@
|
|||||||
|
<template>
|
||||||
|
<div class="pose-estimation-app">
|
||||||
|
<h1>人体姿态估计</h1>
|
||||||
|
|
||||||
|
<!-- 文件上传区域 -->
|
||||||
|
<div class="upload-section">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
id="fileInput"
|
||||||
|
ref="fileInput"
|
||||||
|
@change="handleFileUpload"
|
||||||
|
accept="image/jpeg,image/png"
|
||||||
|
hidden
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="upload-area"
|
||||||
|
:class="{ 'drag-over': isDragOver }"
|
||||||
|
@click="triggerFileInput"
|
||||||
|
@dragover.prevent="handleDragOver"
|
||||||
|
@dragleave="handleDragLeave"
|
||||||
|
@drop="handleDrop"
|
||||||
|
>
|
||||||
|
<div class="upload-content">
|
||||||
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M14 2H6C4.9 2 4 2.9 4 4V20C4 21.1 4.9 22 6 22H18C19.1 22 20 21.1 20 20V8L14 2ZM18 20H6V4H13V9H18V20ZM8 15.01L9.41 16.42L11 14.84V18H13V14.84L14.59 16.42L16 15.01L12.01 11L8 15.01Z" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
<p>点击或拖拽图片到这里</p>
|
||||||
|
<p class="file-types">支持 JPEG 和 PNG 格式</p>
|
||||||
|
<p class="file-types">最大 10MB</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 后端地址配置 -->
|
||||||
|
<div class="config-section">
|
||||||
|
<div class="config-item">
|
||||||
|
<label for="backendUrl">后端地址:</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="backendUrl"
|
||||||
|
v-model="backendUrl"
|
||||||
|
placeholder="http://192.168.2.184:8245"
|
||||||
|
/>
|
||||||
|
<!-- <button @click="testConnection" :disabled="isTesting">测试连接</button>-->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 加载状态 -->
|
||||||
|
<div v-if="isLoading" class="loading">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p>正在分析图片...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 连接测试状态 -->
|
||||||
|
<div v-if="connectionStatus" class="connection-status" :class="connectionStatus.type">
|
||||||
|
{{ connectionStatus.message }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 错误信息 -->
|
||||||
|
<div v-if="error" class="error-message">
|
||||||
|
<p>{{ error }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 结果显示区域 -->
|
||||||
|
<div v-if="poseData && originalImage" class="result-section">
|
||||||
|
<div class="result-header">
|
||||||
|
<h3>检测结果</h3>
|
||||||
|
<div class="stats">
|
||||||
|
<span>检测到 {{ poseData.bbox.length }} 个人体</span>
|
||||||
|
<span v-if="averageConfidence > 0">平均置信度: {{ averageConfidence.toFixed(2) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="canvas-container">
|
||||||
|
<div class="canvas-wrapper">
|
||||||
|
<canvas ref="canvas" :width="canvasWidth" :height="canvasHeight"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="controls">
|
||||||
|
<div class="control-group">
|
||||||
|
<label>显示选项:</label>
|
||||||
|
<div class="checkbox-group">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" v-model="showBoundingBox" />
|
||||||
|
边界框
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" v-model="showSkeleton" />
|
||||||
|
骨骼
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" v-model="showKeypoints" />
|
||||||
|
关键点
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="control-group">
|
||||||
|
<label>置信度阈值: {{ confidenceThreshold.toFixed(2) }}</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
v-model.number="confidenceThreshold"
|
||||||
|
min="0"
|
||||||
|
max="1"
|
||||||
|
step="0.05"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="color-palette">
|
||||||
|
<div
|
||||||
|
v-for="(color, index) in personColors"
|
||||||
|
:key="index"
|
||||||
|
class="color-item"
|
||||||
|
:style="{ backgroundColor: color }"
|
||||||
|
@click="selectColorIndex = selectColorIndex === index ? null : index"
|
||||||
|
:class="{ selected: selectColorIndex === index }"
|
||||||
|
>
|
||||||
|
<span>人物 {{ index + 1 }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button @click="downloadResult" class="download-btn">下载结果图片</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 详细信息 -->
|
||||||
|
<div v-if="poseData" class="details-section">
|
||||||
|
<h4>检测详情:</h4>
|
||||||
|
<div class="details-grid">
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="label">图片尺寸:</span>
|
||||||
|
<span class="value">{{ imageWidth }} × {{ imageHeight }} 像素</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="label">参考尺寸:</span>
|
||||||
|
<span class="value">{{ poseData.reference_size[0] }} × {{ poseData.reference_size[1] }} 像素</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="label">帧索引:</span>
|
||||||
|
<span class="value">{{ poseData.frame_index }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 人体详情 -->
|
||||||
|
<div v-for="(person, index) in poseData.bbox" :key="index" class="person-detail">
|
||||||
|
<h5>人物 {{ index + 1 }}</h5>
|
||||||
|
<div class="person-info">
|
||||||
|
<div class="info-item">
|
||||||
|
<span>边界框: </span>
|
||||||
|
<code>[{{ person[0].toFixed(1) }}, {{ person[1].toFixed(1) }}, {{ person[2].toFixed(1) }}, {{ person[3].toFixed(1) }}]</code>
|
||||||
|
</div>
|
||||||
|
<div v-if="poseData.bbox_confidence" class="info-item">
|
||||||
|
<span>置信度: </span>
|
||||||
|
<span :class="{
|
||||||
|
'confidence-high': poseData.bbox_confidence[index] > 0.7,
|
||||||
|
'confidence-medium': poseData.bbox_confidence[index] > 0.3 && poseData.bbox_confidence[index] <= 0.7,
|
||||||
|
'confidence-low': poseData.bbox_confidence[index] <= 0.3
|
||||||
|
}">
|
||||||
|
{{ (poseData.bbox_confidence[index] * 100).toFixed(1) }}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span>关键点数量: </span>
|
||||||
|
<span>{{ poseData.keypoints[index]?.length || 0 }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, watch, nextTick } from 'vue'
|
||||||
|
|
||||||
|
// 定义类型
|
||||||
|
interface PoseDetectionInfo {
|
||||||
|
frame_index: number
|
||||||
|
reference_size: [number, number]
|
||||||
|
bbox: number[][]
|
||||||
|
bbox_confidence: number[]
|
||||||
|
keypoints: number[][][]
|
||||||
|
keypoints_confidence: number[][]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 响应式数据
|
||||||
|
const fileInput = ref<HTMLInputElement | null>(null)
|
||||||
|
const canvas = ref<HTMLCanvasElement | null>(null)
|
||||||
|
const isDragOver = ref(false)
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const isTesting = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const connectionStatus = ref<{type: 'success' | 'error', message: string} | null>(null)
|
||||||
|
const poseData = ref<PoseDetectionInfo | null>(null)
|
||||||
|
const originalImage = ref<HTMLImageElement | null>(null)
|
||||||
|
const imageWidth = ref(0)
|
||||||
|
const imageHeight = ref(0)
|
||||||
|
const averageConfidence = ref(0)
|
||||||
|
const backendUrl = ref('http://192.168.2.184:8245')
|
||||||
|
const canvasWidth = ref(800)
|
||||||
|
const canvasHeight = ref(600)
|
||||||
|
const showBoundingBox = ref(true)
|
||||||
|
const showSkeleton = ref(true)
|
||||||
|
const showKeypoints = ref(true)
|
||||||
|
const confidenceThreshold = ref(0.3)
|
||||||
|
const selectColorIndex = ref<number | null>(null)
|
||||||
|
const personColors = ref(['#FF5252', '#4CAF50', '#2196F3', '#FF9800', '#9C27B0', '#00BCD4'])
|
||||||
|
|
||||||
|
// 骨骼连接关系(COCO-WholeBody格式)
|
||||||
|
const skeletonConnections = [
|
||||||
|
[0, 1], [0, 2], [1, 3], [2, 4], [5, 6], [5, 7], [6, 8], [7, 9], [8, 10],
|
||||||
|
[11, 12], [5, 11], [6, 12], [11, 13], [13, 15], [12, 14], [14, 16],
|
||||||
|
[17, 18], [18, 19], [19, 20], [20, 21], [22, 23], [23, 24], [24, 25], [25, 26],
|
||||||
|
[27, 28], [28, 29], [29, 30], [31, 32], [32, 33], [33, 34], [34, 35],
|
||||||
|
[36, 37], [37, 38], [38, 39], [39, 40], [41, 42], [42, 43], [43, 44], [44, 45],
|
||||||
|
[46, 47], [47, 48], [48, 49], [49, 50], [51, 52], [52, 53], [53, 54]
|
||||||
|
]
|
||||||
|
|
||||||
|
// 观察置信度阈值变化
|
||||||
|
watch(confidenceThreshold, () => {
|
||||||
|
if (originalImage.value && poseData.value) {
|
||||||
|
drawPoseResults(originalImage.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 观察显示选项变化
|
||||||
|
watch([showBoundingBox, showSkeleton, showKeypoints], () => {
|
||||||
|
if (originalImage.value && poseData.value) {
|
||||||
|
drawPoseResults(originalImage.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 触发文件选择
|
||||||
|
const triggerFileInput = () => {
|
||||||
|
fileInput.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理文件上传
|
||||||
|
const handleFileUpload = async (event: Event) => {
|
||||||
|
const target = event.target as HTMLInputElement
|
||||||
|
if (target.files && target.files.length > 0) {
|
||||||
|
await processImage(target.files[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理拖拽
|
||||||
|
const handleDragOver = (e: DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
isDragOver.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragLeave = () => {
|
||||||
|
isDragOver.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDrop = async (e: DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
isDragOver.value = false
|
||||||
|
|
||||||
|
if (e.dataTransfer && e.dataTransfer.files.length > 0) {
|
||||||
|
const file = e.dataTransfer.files[0]
|
||||||
|
await processImage(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试后端连接
|
||||||
|
const testConnection = async () => {
|
||||||
|
isTesting.value = true
|
||||||
|
connectionStatus.value = null
|
||||||
|
error.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(backendUrl.value + '/hpe', {
|
||||||
|
method: 'OPTIONS' // 先发送OPTIONS请求检查CORS
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
connectionStatus.value = {
|
||||||
|
type: 'success',
|
||||||
|
message: '后端连接成功!'
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
connectionStatus.value = {
|
||||||
|
type: 'error',
|
||||||
|
message: '后端连接失败,状态码:' + response.status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
connectionStatus.value = {
|
||||||
|
type: 'error',
|
||||||
|
message: '无法连接到后端:' + (err as Error).message
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isTesting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理图片分析
|
||||||
|
const processImage = async (file: File) => {
|
||||||
|
// 验证文件类型
|
||||||
|
if (!['image/jpeg', 'image/jpg', 'image/png'].includes(file.type)) {
|
||||||
|
error.value = '只支持 JPEG 和 PNG 格式的图片'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证文件大小
|
||||||
|
if (file.size > 10 * 1024 * 1024) {
|
||||||
|
error.value = '图片大小不能超过 10MB'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading.value = true
|
||||||
|
error.value = ''
|
||||||
|
poseData.value = null
|
||||||
|
connectionStatus.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 创建图片预览
|
||||||
|
const img = new Image()
|
||||||
|
const imgUrl = URL.createObjectURL(file)
|
||||||
|
img.src = imgUrl
|
||||||
|
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
img.onload = resolve
|
||||||
|
img.onerror = reject
|
||||||
|
})
|
||||||
|
|
||||||
|
originalImage.value = img
|
||||||
|
imageWidth.value = img.width
|
||||||
|
imageHeight.value = img.height
|
||||||
|
|
||||||
|
// 计算画布尺寸,保持宽高比
|
||||||
|
const maxWidth = 800
|
||||||
|
const maxHeight = 600
|
||||||
|
const aspectRatio = img.width / img.height
|
||||||
|
|
||||||
|
if (aspectRatio > 1) {
|
||||||
|
canvasWidth.value = Math.min(img.width, maxWidth)
|
||||||
|
canvasHeight.value = canvasWidth.value / aspectRatio
|
||||||
|
} else {
|
||||||
|
canvasHeight.value = Math.min(img.height, maxHeight)
|
||||||
|
canvasWidth.value = canvasHeight.value * aspectRatio
|
||||||
|
}
|
||||||
|
|
||||||
|
// 准备图片数据
|
||||||
|
const arrayBuffer = await file.arrayBuffer()
|
||||||
|
|
||||||
|
// 发送请求到后端
|
||||||
|
const response = await fetch(`${backendUrl.value}/hpe`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': file.type // 设置正确的Content-Type
|
||||||
|
},
|
||||||
|
body: arrayBuffer
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 415) {
|
||||||
|
throw new Error('不支持的图片格式,请使用JPEG或PNG格式')
|
||||||
|
} else if (response.status === 413) {
|
||||||
|
throw new Error('图片过大,请使用小于10MB的图片')
|
||||||
|
} else if (response.status === 503) {
|
||||||
|
throw new Error('服务器繁忙,请稍后重试')
|
||||||
|
} else if (response.status === 408) {
|
||||||
|
throw new Error('请求超时,请重试')
|
||||||
|
} else {
|
||||||
|
const errorText = await response.text()
|
||||||
|
throw new Error(`请求失败: ${response.status} ${response.statusText} - ${errorText}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否有检测结果
|
||||||
|
const contentLength = response.headers.get('content-length')
|
||||||
|
|
||||||
|
if (response.status === 200 && contentLength !== '0' && parseInt(contentLength || '0') > 0) {
|
||||||
|
poseData.value = await response.json()
|
||||||
|
|
||||||
|
// 计算平均置信度
|
||||||
|
if (poseData.value.bbox_confidence && poseData.value.bbox_confidence.length > 0) {
|
||||||
|
averageConfidence.value = poseData.value.bbox_confidence.reduce((a, b) => a + b, 0) / poseData.value.bbox_confidence.length
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绘制结果
|
||||||
|
await nextTick()
|
||||||
|
drawPoseResults(img)
|
||||||
|
} else {
|
||||||
|
error.value = '未检测到人体姿态'
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('分析图片时出错:', err)
|
||||||
|
error.value = err instanceof Error ? err.message : '分析图片时出错'
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绘制姿态结果
|
||||||
|
const drawPoseResults = (img: HTMLImageElement) => {
|
||||||
|
if (!canvas.value || !poseData.value) return
|
||||||
|
|
||||||
|
const ctx = canvas.value.getContext('2d')
|
||||||
|
if (!ctx) return
|
||||||
|
|
||||||
|
// 清空画布
|
||||||
|
ctx.clearRect(0, 0, canvasWidth.value, canvasHeight.value)
|
||||||
|
|
||||||
|
// 绘制缩放后的图片
|
||||||
|
ctx.drawImage(img, 0, 0, canvasWidth.value, canvasHeight.value)
|
||||||
|
|
||||||
|
// 计算缩放比例
|
||||||
|
const scaleX = canvasWidth.value / img.width
|
||||||
|
const scaleY = canvasHeight.value / img.height
|
||||||
|
|
||||||
|
poseData.value.bbox.forEach((bbox, personIndex) => {
|
||||||
|
const color = personColors.value[personIndex % personColors.value.length]
|
||||||
|
|
||||||
|
// 绘制边界框
|
||||||
|
if (showBoundingBox.value) {
|
||||||
|
ctx.strokeStyle = color!
|
||||||
|
ctx.lineWidth = 2
|
||||||
|
const scaledBbox = [
|
||||||
|
bbox[0]! * scaleX,
|
||||||
|
bbox[1]! * scaleY,
|
||||||
|
(bbox[2]! - bbox[0]!) * scaleX,
|
||||||
|
(bbox[3]! - bbox[1]!) * scaleY
|
||||||
|
]
|
||||||
|
ctx.strokeRect(scaledBbox[0], scaledBbox[1], scaledBbox[2], scaledBbox[3])
|
||||||
|
|
||||||
|
// 绘制边界框标签
|
||||||
|
ctx.fillStyle = color!
|
||||||
|
ctx.font = '12px Arial'
|
||||||
|
ctx.fillText(`Person ${personIndex + 1}`, scaledBbox[0]! + 5, scaledBbox[1]! - 5)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绘制关键点和骨骼
|
||||||
|
if (personIndex < poseData.value!.keypoints.length) {
|
||||||
|
const keypoints = poseData.value!.keypoints[personIndex]
|
||||||
|
const confidences = poseData.value!.keypoints_confidence?.[personIndex] || []
|
||||||
|
|
||||||
|
// 绘制骨骼连接
|
||||||
|
if (showSkeleton.value) {
|
||||||
|
ctx.strokeStyle = color
|
||||||
|
ctx.lineWidth = 2
|
||||||
|
|
||||||
|
skeletonConnections.forEach(([start, end]) => {
|
||||||
|
if (start < keypoints.length && end < keypoints.length) {
|
||||||
|
const startPoint = keypoints[start]
|
||||||
|
const endPoint = keypoints[end]
|
||||||
|
|
||||||
|
// 检查关键点置信度
|
||||||
|
const startConf = confidences[start] || 1
|
||||||
|
const endConf = confidences[end] || 1
|
||||||
|
|
||||||
|
if (startConf > confidenceThreshold.value && endConf > confidenceThreshold.value) {
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(startPoint[0] * scaleX, startPoint[1] * scaleY)
|
||||||
|
ctx.lineTo(endPoint[0] * scaleX, endPoint[1] * scaleY)
|
||||||
|
ctx.stroke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绘制关键点
|
||||||
|
if (showKeypoints.value) {
|
||||||
|
keypoints.forEach((point, index) => {
|
||||||
|
const confidence = confidences[index] || 1
|
||||||
|
if (confidence > confidenceThreshold.value) {
|
||||||
|
ctx.fillStyle = color
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(point[0] * scaleX, point[1] * scaleY, 1, 0, Math.PI * 2)
|
||||||
|
ctx.fill()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下载结果图片
|
||||||
|
const downloadResult = () => {
|
||||||
|
if (!canvas.value) return
|
||||||
|
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.download = 'pose-detection-result.png'
|
||||||
|
link.href = canvas.value.toDataURL('image/png')
|
||||||
|
link.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// 初始化时测试后端连接
|
||||||
|
// testConnection()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.pose-estimation-app {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
text-align: center;
|
||||||
|
color: #2c3e50;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
font-weight: 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-section {
|
||||||
|
margin-bottom: 30px;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-area {
|
||||||
|
border: 3px dashed rgba(255, 255, 255, 0.7);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 60px 20px;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-area:hover, .upload-area.drag-over {
|
||||||
|
border-color: #ffffff;
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-content svg {
|
||||||
|
color: white;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.2));
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-content p {
|
||||||
|
margin: 10px 0;
|
||||||
|
color: white;
|
||||||
|
font-size: 1.1em;
|
||||||
|
text-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-types {
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: rgba(255, 255, 255, 0.9) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-section {
|
||||||
|
background: white;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-item label {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #2c3e50;
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-item input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-item input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-item button {
|
||||||
|
padding: 8px 20px;
|
||||||
|
background: #667eea;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.3s;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-item button:hover:not(:disabled) {
|
||||||
|
background: #5a67d8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-item button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin: 20px 0;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
border: 4px solid rgba(0, 0, 0, 0.1);
|
||||||
|
border-top: 4px solid #667eea;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin: 0 auto 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-status {
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-radius: 5px;
|
||||||
|
margin: 10px 0;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-status.success {
|
||||||
|
background: #d4edda;
|
||||||
|
color: #155724;
|
||||||
|
border: 1px solid #c3e6cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-status.error {
|
||||||
|
background: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
border: 1px solid #f5c6cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
background: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
padding: 15px 20px;
|
||||||
|
border-radius: 5px;
|
||||||
|
margin: 20px 0;
|
||||||
|
text-align: center;
|
||||||
|
border: 1px solid #f5c6cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-section {
|
||||||
|
background: white;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 20px;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding-bottom: 15px;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-container {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 300px;
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-wrapper {
|
||||||
|
border: 1px solid #eee;
|
||||||
|
border-radius: 5px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #f8f9fa;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-group label {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #2c3e50;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-group label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-weight: normal;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-group input[type="checkbox"] {
|
||||||
|
margin: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-group input[type="range"] {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-palette {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-item {
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
text-align: center;
|
||||||
|
color: white;
|
||||||
|
font-size: 0.8em;
|
||||||
|
font-weight: bold;
|
||||||
|
text-shadow: 1px 1px 1px rgba(0,0,0,0.3);
|
||||||
|
min-height: 40px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-item:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 3px 6px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-item.selected {
|
||||||
|
transform: scale(1.05);
|
||||||
|
box-shadow: 0 0 0 3px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-btn {
|
||||||
|
padding: 12px 20px;
|
||||||
|
background: #4CAF50;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: background 0.3s;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-btn:hover {
|
||||||
|
background: #45a049;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-section {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-section h4 {
|
||||||
|
color: #2c3e50;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 15px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 15px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item .label {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #495057;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item .value {
|
||||||
|
color: #212529;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.person-detail {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 15px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
border-left: 4px solid #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.person-detail h5 {
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.person-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item span:first-child {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #495057;
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confidence-high {
|
||||||
|
color: #28a745;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confidence-medium {
|
||||||
|
color: #ffc107;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confidence-low {
|
||||||
|
color: #dc3545;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.canvas-container {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-item {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-item input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
5
src/main.ts
Normal file
5
src/main.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import './style.css'
|
||||||
|
import App from './App.vue'
|
||||||
|
|
||||||
|
createApp(App).mount('#app')
|
||||||
79
src/style.css
Normal file
79
src/style.css
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
:root {
|
||||||
|
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-weight: 400;
|
||||||
|
|
||||||
|
color-scheme: light dark;
|
||||||
|
color: rgba(255, 255, 255, 0.87);
|
||||||
|
background-color: #242424;
|
||||||
|
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #646cff;
|
||||||
|
text-decoration: inherit;
|
||||||
|
}
|
||||||
|
a:hover {
|
||||||
|
color: #535bf2;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
place-items: center;
|
||||||
|
min-width: 320px;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 3.2em;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
padding: 0.6em 1.2em;
|
||||||
|
font-size: 1em;
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: inherit;
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.25s;
|
||||||
|
}
|
||||||
|
button:hover {
|
||||||
|
border-color: #646cff;
|
||||||
|
}
|
||||||
|
button:focus,
|
||||||
|
button:focus-visible {
|
||||||
|
outline: 4px auto -webkit-focus-ring-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
:root {
|
||||||
|
color: #213547;
|
||||||
|
background-color: #ffffff;
|
||||||
|
}
|
||||||
|
a:hover {
|
||||||
|
color: #747bff;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
}
|
||||||
|
}
|
||||||
16
tsconfig.app.json
Normal file
16
tsconfig.app.json
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||||
|
}
|
||||||
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
26
tsconfig.node.json
Normal file
26
tsconfig.node.json
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
7
vite.config.ts
Normal file
7
vite.config.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user