feature/login-screen #2
@ -112,3 +112,20 @@ export const userRegister = async (qq_code, password, token, captcha) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 管理员修改用户权限
|
||||
* @param {string} uuid - 用户uuid
|
||||
* @param {string} privilege - 新权限
|
||||
* @returns {Promise<void>} 无返回值,成功即为修改成功
|
||||
*/
|
||||
export const adminChangeUserPrivilege = async (uuid, privilege) => {
|
||||
try {
|
||||
await axiosInstance.put('/admin/change_user_privilege', {
|
||||
uuid,
|
||||
privilege
|
||||
});
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -87,6 +87,11 @@ const routes = [
|
||||
name: 'PIC2TGA',
|
||||
component: () => import('@/views/index/PIC2TGA.vue')
|
||||
},
|
||||
{
|
||||
path: 'terrainGenerate',
|
||||
name: 'TerrainGenerate',
|
||||
component: () => import('@/views/index/TerrainGenerate.vue')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
|
@ -24,6 +24,9 @@
|
||||
<li :class="{active: currentAdminView === 'user-management'}">
|
||||
<a @click="selectAdminView('user-management')">用户管理</a>
|
||||
</li>
|
||||
<li :class="{active: currentAdminView === 'admin-edit-user-privilege'}">
|
||||
<a @click="selectAdminView('admin-edit-user-privilege')">管理员修改用户权限</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
@ -65,7 +68,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-main-content">
|
||||
|
||||
<AdminEditUserPrivilege v-if="currentAdminView === 'admin-edit-user-privilege'" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -75,6 +78,7 @@
|
||||
import { ref, onMounted, computed, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getUserInfo } from '@/utils/jwt'
|
||||
import AdminEditUserPrivilege from '@/components/backend/AdminEditUserPrivilege.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const hasToken = ref(false)
|
||||
@ -91,10 +95,12 @@ const isAdmin = computed(() => {
|
||||
})
|
||||
|
||||
async function checkPrivilege() {
|
||||
console.log('正在验证权限');
|
||||
const token = localStorage.getItem('access_token')
|
||||
hasToken.value = !!token
|
||||
if (!token) {
|
||||
router.push('/')
|
||||
console.log('验证结束');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@ -105,6 +111,7 @@ async function checkPrivilege() {
|
||||
localStorage.removeItem('access_token')
|
||||
currentUserData.value = null
|
||||
router.push('/')
|
||||
console.log('验证结束');
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
@ -112,12 +119,14 @@ async function checkPrivilege() {
|
||||
localStorage.removeItem('access_token')
|
||||
currentUserData.value = null
|
||||
router.push('/')
|
||||
console.log('验证结束');
|
||||
}
|
||||
console.log('验证结束');
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkPrivilege();
|
||||
privilegeCheckTimer = setInterval(checkPrivilege, 2 * 60 * 1000);
|
||||
privilegeCheckTimer = setInterval(checkPrivilege, 60 * 1000);
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
|
@ -117,6 +117,19 @@ onUnmounted(() => {
|
||||
// 移除事件监听器
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
// 判断每个一级菜单是否有可见项
|
||||
const showTerrainList = true // 地形图列表始终可见
|
||||
const showTerrainGenerate = computed(() => isLoggedIn.value && currentUserData.value)
|
||||
const showWeaponMatch = computed(() => isLoggedIn.value && currentUserData.value && hasPrivilege(currentUserData.value.privilege, ['lv-admin', 'lv-mod']))
|
||||
const showPic2Tga = computed(() => isLoggedIn.value && currentUserData.value)
|
||||
const showDemands = computed(() => isLoggedIn.value && currentUserData.value)
|
||||
const showCompetition = computed(() => isLoggedIn.value && currentUserData.value && hasPrivilege(currentUserData.value.privilege, ['lv-admin', 'lv-competitor']))
|
||||
|
||||
const showTerrainMenu = computed(() => showTerrainList || showTerrainGenerate.value)
|
||||
const showOnlineToolsMenu = computed(() => showWeaponMatch.value || showPic2Tga.value)
|
||||
const showPublicMenu = computed(() => showDemands.value)
|
||||
const showCompetitionMenu = computed(() => showCompetition.value)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -128,29 +141,45 @@ onUnmounted(() => {
|
||||
<i class="fas fa-bars"></i>
|
||||
</button>
|
||||
<div class="nav-left" :class="{ active: showMobileMenu }">
|
||||
<router-link to="/maps" class="nav-link">最近上传地图</router-link>
|
||||
<router-link to="/weekly" class="nav-link">热门下载地图</router-link>
|
||||
<!-- 地图 一级菜单 -->
|
||||
<div class="nav-dropdown">
|
||||
<span class="nav-link">地图与作者推荐</span>
|
||||
<div class="dropdown-content">
|
||||
<router-link to="/maps" class="nav-link">全部上传地图</router-link>
|
||||
<router-link to="/weekly" class="nav-link">本周上传地图</router-link>
|
||||
<router-link to="/author" class="nav-link">活跃作者推荐</router-link>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 地形 一级菜单 -->
|
||||
<div v-if="showTerrainMenu" class="nav-dropdown">
|
||||
<span class="nav-link">地形与纹理</span>
|
||||
<div class="dropdown-content">
|
||||
<router-link to="/terrain" class="nav-link">地形图列表</router-link>
|
||||
<!-- <router-link to="" class="nav-link">录像解析</router-link>-->
|
||||
<router-link
|
||||
v-if="isLoggedIn && currentUserData && hasPrivilege(currentUserData.privilege, ['lv-admin', 'lv-mod', 'lv-map', 'lv-user', 'lv-competitor'])"
|
||||
to="/PIC2TGA" class="nav-link">在线转tga工具</router-link>
|
||||
<router-link
|
||||
v-if="isLoggedIn && currentUserData && hasPrivilege(currentUserData.privilege, ['lv-admin', 'lv-mod'])"
|
||||
to="/weapon-match"
|
||||
class="nav-link"
|
||||
>Weapon 匹配</router-link>
|
||||
<router-link
|
||||
v-if="isLoggedIn && currentUserData && hasPrivilege(currentUserData.privilege, ['lv-admin', 'lv-competitor'])"
|
||||
to="/competition"
|
||||
class="nav-link"
|
||||
>赛程信息</router-link>
|
||||
<router-link
|
||||
v-if="isLoggedIn && currentUserData && hasPrivilege(currentUserData.privilege, ['lv-admin', 'lv-mod', 'lv-map', 'lv-user', 'lv-competitor'])"
|
||||
to="/demands"
|
||||
class="nav-link"
|
||||
>办事大厅</router-link>
|
||||
<router-link v-if="showTerrainGenerate" to="/terrainGenerate" class="nav-link">地形纹理合成工具</router-link>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 在线工具 一级菜单 -->
|
||||
<div v-if="showOnlineToolsMenu" class="nav-dropdown">
|
||||
<span class="nav-link">在线工具</span>
|
||||
<div class="dropdown-content">
|
||||
<router-link v-if="showWeaponMatch" to="/weapon-match" class="nav-link">Weapon 匹配</router-link>
|
||||
<router-link v-if="showPic2Tga" to="/PIC2TGA" class="nav-link">在线转tga工具</router-link>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 赛事信息 一级菜单 -->
|
||||
<div v-if="showCompetitionMenu" class="nav-dropdown">
|
||||
<span class="nav-link">赛事信息</span>
|
||||
<div class="dropdown-content">
|
||||
<router-link v-if="showCompetition" to="/competition" class="nav-link">赛程信息</router-link>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 公共信息区 一级菜单 -->
|
||||
<div v-if="showPublicMenu" class="nav-dropdown">
|
||||
<span class="nav-link">公共信息区</span>
|
||||
<div class="dropdown-content">
|
||||
<router-link v-if="showDemands" to="/demands" class="nav-link">办事大厅</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="nav-right" :class="{ active: showMobileMenu }">
|
||||
<router-link v-if="!isLoggedIn" to="/backend/login" class="nav-link login-btn">
|
||||
@ -531,4 +560,46 @@ onUnmounted(() => {
|
||||
gap: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-dropdown {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.nav-dropdown > .nav-link {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.dropdown-content {
|
||||
display: none;
|
||||
position: absolute;
|
||||
background-color: #fff;
|
||||
min-width: 180px;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.15);
|
||||
z-index: 1001;
|
||||
border-radius: 8px;
|
||||
padding: 8px 0;
|
||||
left: 0;
|
||||
top: 100%;
|
||||
}
|
||||
|
||||
.nav-dropdown:hover .dropdown-content {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.dropdown-content .nav-link {
|
||||
color: #333;
|
||||
background: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 0;
|
||||
display: block;
|
||||
text-align: left;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.dropdown-content .nav-link:hover {
|
||||
background-color: #f5f5f5;
|
||||
color: #416bdf;
|
||||
}
|
||||
</style>
|
612
src/views/index/TerrainGenerate.vue
Normal file
612
src/views/index/TerrainGenerate.vue
Normal file
@ -0,0 +1,612 @@
|
||||
<template>
|
||||
<div class="terrain-tool-container">
|
||||
<div class="header">
|
||||
<h1>地形纹理合成工具</h1>
|
||||
<p class="subtitle">将颜色、法线和粗糙度贴图合成为地形使用的TGA格式</p>
|
||||
</div>
|
||||
|
||||
<div class="upload-section">
|
||||
<div class="upload-card" :class="{ active: colorFiles.length > 0 }">
|
||||
<label class="upload-label">
|
||||
<input type="file" multiple accept="image/*" @change="onFilesSelected('color', $event)" />
|
||||
<div class="upload-content">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
|
||||
<circle cx="8.5" cy="8.5" r="1.5"></circle>
|
||||
<polyline points="21 15 16 10 5 21"></polyline>
|
||||
</svg>
|
||||
<h3>颜色贴图</h3>
|
||||
<p v-if="colorFiles.length === 0">点击或拖放文件到这里</p>
|
||||
<p v-else>{{ colorFiles.length }} 个文件已选择</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="upload-card" :class="{ active: normalFiles.length > 0 }">
|
||||
<label class="upload-label">
|
||||
<input type="file" multiple accept="image/*" @change="onFilesSelected('normal', $event)" />
|
||||
<div class="upload-content">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 2.69l5.66 5.66a8 8 0 1 1-11.31 0z"></path>
|
||||
</svg>
|
||||
<h3>法线贴图(OpenGL格式)</h3>
|
||||
<p v-if="normalFiles.length === 0">点击或拖放文件到这里</p>
|
||||
<p v-else>{{ normalFiles.length }} 个文件已选择</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="upload-card" :class="{ active: roughnessFiles.length > 0 }">
|
||||
<label class="upload-label">
|
||||
<input type="file" multiple accept="image/*" @change="onFilesSelected('roughness', $event)" />
|
||||
<div class="upload-content">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<path d="M8 14s1.5 2 4 2 4-2 4-2"></path>
|
||||
<line x1="9" y1="9" x2="9.01" y2="9"></line>
|
||||
<line x1="15" y1="9" x2="15.01" y2="9"></line>
|
||||
</svg>
|
||||
<h3>粗糙度贴图</h3>
|
||||
<p v-if="roughnessFiles.length === 0">点击或拖放文件到这里</p>
|
||||
<p v-else>{{ roughnessFiles.length }} 个文件已选择</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-section">
|
||||
<div class="status-card">
|
||||
<div class="status-item">
|
||||
<span class="status-label">颜色贴图:</span>
|
||||
<span class="status-value">{{ colorFiles.length }}</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="status-label">法线贴图:</span>
|
||||
<span class="status-value">{{ normalFiles.length }}</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="status-label">粗糙度贴图:</span>
|
||||
<span class="status-value">{{ roughnessFiles.length }}</span>
|
||||
</div>
|
||||
<div class="status-item highlight">
|
||||
<span class="status-label">可匹配文件组:</span>
|
||||
<span class="status-value">{{ getMatchCount() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-buttons">
|
||||
<button
|
||||
class="btn generate-btn"
|
||||
:class="{ disabled: !canProcess }"
|
||||
@click="generateTGA"
|
||||
:disabled="!canProcess">
|
||||
<span v-if="!isProcessing">生成地形TGA文件</span>
|
||||
<span v-else class="processing">
|
||||
<svg class="spinner" viewBox="0 0 50 50">
|
||||
<circle class="path" cx="25" cy="25" r="20" fill="none" stroke-width="5"></circle>
|
||||
</svg>
|
||||
处理中...
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn download-btn"
|
||||
:class="{ disabled: generatedFiles.length === 0 }"
|
||||
@click="downloadZip"
|
||||
:disabled="generatedFiles.length === 0">
|
||||
打包下载ZIP
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="results-section" v-if="generatedFiles.length > 0">
|
||||
<h2>生成结果</h2>
|
||||
<div class="results-grid">
|
||||
<div v-for="(file, index) in generatedFiles" :key="index" class="result-item">
|
||||
<div class="file-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path>
|
||||
<polyline points="13 2 13 9 20 9"></polyline>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="file-name">{{ file.name }}</div>
|
||||
<div class="file-size">{{ formatFileSize(file.blob.size) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="instructions" v-if="generatedFiles.length === 0 && !canProcess">
|
||||
<h3>使用说明</h3>
|
||||
<ol>
|
||||
<li>上传颜色、法线和粗糙度贴图(PNG/JPG格式)</li>
|
||||
<li>确保每组贴图有相同的文件名(仅扩展名不同)</li>
|
||||
<li>点击"生成地形TGA文件"按钮</li>
|
||||
<li>处理完成后下载ZIP包</li>
|
||||
</ol>
|
||||
<p class="note">注意:系统会自动匹配文件名相同的贴图进行合成处理。</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import JSZip from "jszip";
|
||||
|
||||
export default {
|
||||
name: "TerrainTool",
|
||||
data() {
|
||||
return {
|
||||
colorFiles: [],
|
||||
normalFiles: [],
|
||||
roughnessFiles: [],
|
||||
generatedFiles: [],
|
||||
isProcessing: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
canProcess() {
|
||||
return (
|
||||
this.colorFiles.length > 0 &&
|
||||
this.normalFiles.length > 0 &&
|
||||
this.roughnessFiles.length > 0 &&
|
||||
this.getMatchCount() > 0
|
||||
);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onFilesSelected(type, e) {
|
||||
const files = Array.from(e.target.files);
|
||||
this[type + "Files"] = files;
|
||||
},
|
||||
|
||||
getMatchCount() {
|
||||
const names = (list) => list.map((f) => f.name.split('.')[0]);
|
||||
const common = names(this.colorFiles).filter(
|
||||
(n) => names(this.normalFiles).includes(n) && names(this.roughnessFiles).includes(n)
|
||||
);
|
||||
return common.length;
|
||||
},
|
||||
|
||||
async generateTGA() {
|
||||
if (!this.canProcess || this.isProcessing) return;
|
||||
|
||||
this.isProcessing = true;
|
||||
this.generatedFiles = [];
|
||||
|
||||
try {
|
||||
const nameMap = (files) => Object.fromEntries(files.map((f) => [f.name.split('.')[0], f]));
|
||||
const colorMap = nameMap(this.colorFiles);
|
||||
const normalMap = nameMap(this.normalFiles);
|
||||
const roughMap = nameMap(this.roughnessFiles);
|
||||
|
||||
const commonKeys = Object.keys(colorMap).filter(
|
||||
(k) => normalMap[k] && roughMap[k]
|
||||
);
|
||||
|
||||
for (const key of commonKeys) {
|
||||
const color = await this.loadImage(colorMap[key]);
|
||||
const normal = await this.loadImage(normalMap[key]);
|
||||
const rough = await this.loadImage(roughMap[key]);
|
||||
|
||||
const [resultColor, resultNRM] = this.processImages(color, normal, rough);
|
||||
this.generatedFiles.push({ name: `${key}_ResultColor.tga`, blob: resultColor });
|
||||
this.generatedFiles.push({ name: `${key}_ResultNRM.tga`, blob: resultNRM });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("生成过程中出错:", error);
|
||||
alert("处理过程中发生错误,请检查文件格式后再试。");
|
||||
} finally {
|
||||
this.isProcessing = false;
|
||||
}
|
||||
},
|
||||
|
||||
loadImage(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = reject;
|
||||
img.src = URL.createObjectURL(file);
|
||||
});
|
||||
},
|
||||
|
||||
processImages(colorImg, normalImg, roughImg) {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 256;
|
||||
canvas.height = 256;
|
||||
const ctx = canvas.getContext("2d");
|
||||
|
||||
// COLOR+ROUGHNESS
|
||||
ctx.drawImage(colorImg, 0, 0, 256, 256);
|
||||
const rgbData = ctx.getImageData(0, 0, 256, 256);
|
||||
|
||||
ctx.clearRect(0, 0, 256, 256);
|
||||
ctx.drawImage(roughImg, 0, 0, 256, 256);
|
||||
const roughData = ctx.getImageData(0, 0, 256, 256);
|
||||
|
||||
const rgbaData = ctx.createImageData(256, 256);
|
||||
for (let i = 0; i < rgbaData.data.length; i += 4) {
|
||||
rgbaData.data[i] = rgbData.data[i]; // R
|
||||
rgbaData.data[i + 1] = rgbData.data[i + 1]; // G
|
||||
rgbaData.data[i + 2] = rgbData.data[i + 2]; // B
|
||||
rgbaData.data[i + 3] = 255 - roughData.data[i]; // A = 反相粗糙度
|
||||
}
|
||||
ctx.putImageData(rgbaData, 0, 0);
|
||||
const resultColorBlob = this.toTGA(ctx, 256, 256);
|
||||
|
||||
// NORMAL
|
||||
ctx.clearRect(0, 0, 256, 256);
|
||||
ctx.drawImage(normalImg, 0, 0, 256, 256);
|
||||
const resultNRMBlob = this.toTGA(ctx, 256, 256);
|
||||
|
||||
return [resultColorBlob, resultNRMBlob];
|
||||
},
|
||||
|
||||
toTGA(ctx, width, height) {
|
||||
const pixels = ctx.getImageData(0, 0, width, height).data;
|
||||
const header = new Uint8Array(18);
|
||||
header[2] = 2;
|
||||
header[12] = width & 0xff;
|
||||
header[13] = (width >> 8) & 0xff;
|
||||
header[14] = height & 0xff;
|
||||
header[15] = (height >> 8) & 0xff;
|
||||
header[16] = 32;
|
||||
header[17] = 0x00; // 原点左下
|
||||
|
||||
const data = new Uint8Array(width * height * 4);
|
||||
let offset = 0;
|
||||
for (let y = height - 1; y >= 0; y--) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const i = (y * width + x) * 4;
|
||||
data[offset++] = pixels[i + 2]; // B
|
||||
data[offset++] = pixels[i + 1]; // G
|
||||
data[offset++] = pixels[i]; // R
|
||||
data[offset++] = pixels[i + 3]; // A
|
||||
}
|
||||
}
|
||||
|
||||
return new Blob([header, data], { type: "application/octet-stream" });
|
||||
},
|
||||
|
||||
async downloadZip() {
|
||||
if (this.generatedFiles.length === 0) return;
|
||||
|
||||
try {
|
||||
const zip = new JSZip();
|
||||
for (const file of this.generatedFiles) {
|
||||
zip.file(file.name, file.blob);
|
||||
}
|
||||
const blob = await zip.generateAsync({ type: "blob" });
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = "TerrainTextures.zip";
|
||||
a.click();
|
||||
} catch (error) {
|
||||
console.error("打包下载出错:", error);
|
||||
alert("创建ZIP文件时出错,请重试。");
|
||||
}
|
||||
},
|
||||
|
||||
formatFileSize(bytes) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.terrain-tool-container {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 2.2rem;
|
||||
color: #2c3e50;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #7f8c8d;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.upload-section {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.upload-card {
|
||||
border: 2px dashed #bdc3c7;
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
transition: all 0.3s ease;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.upload-card.active {
|
||||
border-color: #3498db;
|
||||
background-color: #e8f4fc;
|
||||
}
|
||||
|
||||
.upload-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.upload-label {
|
||||
display: block;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.upload-label input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.upload-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.upload-content svg {
|
||||
margin-bottom: 1rem;
|
||||
color: #7f8c8d;
|
||||
}
|
||||
|
||||
.upload-card.active svg {
|
||||
color: #3498db;
|
||||
}
|
||||
|
||||
.upload-content h3 {
|
||||
margin: 0 0 0.5rem;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.upload-content p {
|
||||
margin: 0;
|
||||
color: #7f8c8d;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.status-section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.status-card {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.status-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.status-item.highlight {
|
||||
font-weight: bold;
|
||||
color: #3498db;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.status-label {
|
||||
color: #7f8c8d;
|
||||
}
|
||||
|
||||
.status-value {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.8rem 1.8rem;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.generate-btn {
|
||||
background-color: #3498db;
|
||||
color: white;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.generate-btn:hover:not(:disabled) {
|
||||
background-color: #2980b9;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(52, 152, 219, 0.3);
|
||||
}
|
||||
|
||||
.download-btn {
|
||||
background-color: #2ecc71;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.download-btn:hover:not(:disabled) {
|
||||
background-color: #27ae60;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(46, 204, 113, 0.3);
|
||||
}
|
||||
|
||||
.btn.disabled {
|
||||
background-color: #bdc3c7;
|
||||
}
|
||||
|
||||
.processing {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
animation: rotate 2s linear infinite;
|
||||
}
|
||||
|
||||
.spinner .path {
|
||||
stroke: white;
|
||||
stroke-linecap: round;
|
||||
animation: dash 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dash {
|
||||
0% {
|
||||
stroke-dasharray: 1, 150;
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
50% {
|
||||
stroke-dasharray: 90, 150;
|
||||
stroke-dashoffset: -35;
|
||||
}
|
||||
100% {
|
||||
stroke-dasharray: 90, 150;
|
||||
stroke-dashoffset: -124;
|
||||
}
|
||||
}
|
||||
|
||||
.results-section {
|
||||
margin-top: 2rem;
|
||||
animation: fadeIn 0.5s ease;
|
||||
}
|
||||
|
||||
.results-section h2 {
|
||||
color: #2c3e50;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 2px solid #eee;
|
||||
}
|
||||
|
||||
.results-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.result-item {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.result-item:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
margin-right: 1rem;
|
||||
color: #3498db;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-size {
|
||||
color: #7f8c8d;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.instructions {
|
||||
background: #f8f9fa;
|
||||
border-radius: 10px;
|
||||
padding: 1.5rem;
|
||||
margin-top: 2rem;
|
||||
border-left: 4px solid #3498db;
|
||||
}
|
||||
|
||||
.instructions h3 {
|
||||
color: #2c3e50;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.instructions ol {
|
||||
padding-left: 1.5rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.instructions .note {
|
||||
font-style: italic;
|
||||
color: #7f8c8d;
|
||||
margin-top: 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.upload-section {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
Loading…
x
Reference in New Issue
Block a user