权限控制
This commit is contained in:
parent
8b8b6b980a
commit
cd3a0672bb
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ -82,11 +82,16 @@ const routes = [
|
|||||||
name: 'Terrain',
|
name: 'Terrain',
|
||||||
component: () => import('@/views/index/TerrainList.vue')
|
component: () => import('@/views/index/TerrainList.vue')
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'PIC2TGA',
|
path: 'PIC2TGA',
|
||||||
name: 'PIC2TGA',
|
name: 'PIC2TGA',
|
||||||
component: () => import('@/views/index/PIC2TGA.vue')
|
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'}">
|
<li :class="{active: currentAdminView === 'user-management'}">
|
||||||
<a @click="selectAdminView('user-management')">用户管理</a>
|
<a @click="selectAdminView('user-management')">用户管理</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li :class="{active: currentAdminView === 'admin-edit-user-privilege'}">
|
||||||
|
<a @click="selectAdminView('admin-edit-user-privilege')">管理员修改用户权限</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
@ -65,7 +68,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="admin-main-content">
|
<div class="admin-main-content">
|
||||||
|
<AdminEditUserPrivilege v-if="currentAdminView === 'admin-edit-user-privilege'" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -75,6 +78,7 @@
|
|||||||
import { ref, onMounted, computed, onUnmounted } from 'vue'
|
import { ref, onMounted, computed, onUnmounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { getUserInfo } from '@/utils/jwt'
|
import { getUserInfo } from '@/utils/jwt'
|
||||||
|
import AdminEditUserPrivilege from '@/components/backend/AdminEditUserPrivilege.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const hasToken = ref(false)
|
const hasToken = ref(false)
|
||||||
@ -91,10 +95,12 @@ const isAdmin = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
async function checkPrivilege() {
|
async function checkPrivilege() {
|
||||||
|
console.log('正在验证权限');
|
||||||
const token = localStorage.getItem('access_token')
|
const token = localStorage.getItem('access_token')
|
||||||
hasToken.value = !!token
|
hasToken.value = !!token
|
||||||
if (!token) {
|
if (!token) {
|
||||||
router.push('/')
|
router.push('/')
|
||||||
|
console.log('验证结束');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@ -105,6 +111,7 @@ async function checkPrivilege() {
|
|||||||
localStorage.removeItem('access_token')
|
localStorage.removeItem('access_token')
|
||||||
currentUserData.value = null
|
currentUserData.value = null
|
||||||
router.push('/')
|
router.push('/')
|
||||||
|
console.log('验证结束');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -112,12 +119,14 @@ async function checkPrivilege() {
|
|||||||
localStorage.removeItem('access_token')
|
localStorage.removeItem('access_token')
|
||||||
currentUserData.value = null
|
currentUserData.value = null
|
||||||
router.push('/')
|
router.push('/')
|
||||||
|
console.log('验证结束');
|
||||||
}
|
}
|
||||||
|
console.log('验证结束');
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
checkPrivilege();
|
checkPrivilege();
|
||||||
privilegeCheckTimer = setInterval(checkPrivilege, 2 * 60 * 1000);
|
privilegeCheckTimer = setInterval(checkPrivilege, 60 * 1000);
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
@ -117,6 +117,19 @@ onUnmounted(() => {
|
|||||||
// 移除事件监听器
|
// 移除事件监听器
|
||||||
document.removeEventListener('click', handleClickOutside)
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -128,29 +141,45 @@ onUnmounted(() => {
|
|||||||
<i class="fas fa-bars"></i>
|
<i class="fas fa-bars"></i>
|
||||||
</button>
|
</button>
|
||||||
<div class="nav-left" :class="{ active: showMobileMenu }">
|
<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">
|
||||||
<router-link to="/author" class="nav-link">活跃作者推荐</router-link>
|
<span class="nav-link">地图与作者推荐</span>
|
||||||
<router-link to="/terrain" class="nav-link">地形图列表</router-link>
|
<div class="dropdown-content">
|
||||||
<!-- <router-link to="" class="nav-link">录像解析</router-link>-->
|
<router-link to="/maps" class="nav-link">全部上传地图</router-link>
|
||||||
<router-link
|
<router-link to="/weekly" class="nav-link">本周上传地图</router-link>
|
||||||
v-if="isLoggedIn && currentUserData && hasPrivilege(currentUserData.privilege, ['lv-admin', 'lv-mod', 'lv-map', 'lv-user', 'lv-competitor'])"
|
<router-link to="/author" class="nav-link">活跃作者推荐</router-link>
|
||||||
to="/PIC2TGA" class="nav-link">在线转tga工具</router-link>
|
</div>
|
||||||
<router-link
|
</div>
|
||||||
v-if="isLoggedIn && currentUserData && hasPrivilege(currentUserData.privilege, ['lv-admin', 'lv-mod'])"
|
<!-- 地形 一级菜单 -->
|
||||||
to="/weapon-match"
|
<div v-if="showTerrainMenu" class="nav-dropdown">
|
||||||
class="nav-link"
|
<span class="nav-link">地形与纹理</span>
|
||||||
>Weapon 匹配</router-link>
|
<div class="dropdown-content">
|
||||||
<router-link
|
<router-link to="/terrain" class="nav-link">地形图列表</router-link>
|
||||||
v-if="isLoggedIn && currentUserData && hasPrivilege(currentUserData.privilege, ['lv-admin', 'lv-competitor'])"
|
<router-link v-if="showTerrainGenerate" to="/terrainGenerate" class="nav-link">地形纹理合成工具</router-link>
|
||||||
to="/competition"
|
</div>
|
||||||
class="nav-link"
|
</div>
|
||||||
>赛程信息</router-link>
|
<!-- 在线工具 一级菜单 -->
|
||||||
<router-link
|
<div v-if="showOnlineToolsMenu" class="nav-dropdown">
|
||||||
v-if="isLoggedIn && currentUserData && hasPrivilege(currentUserData.privilege, ['lv-admin', 'lv-mod', 'lv-map', 'lv-user', 'lv-competitor'])"
|
<span class="nav-link">在线工具</span>
|
||||||
to="/demands"
|
<div class="dropdown-content">
|
||||||
class="nav-link"
|
<router-link v-if="showWeaponMatch" to="/weapon-match" class="nav-link">Weapon 匹配</router-link>
|
||||||
>办事大厅</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>
|
||||||
<div class="nav-right" :class="{ active: showMobileMenu }">
|
<div class="nav-right" :class="{ active: showMobileMenu }">
|
||||||
<router-link v-if="!isLoggedIn" to="/backend/login" class="nav-link login-btn">
|
<router-link v-if="!isLoggedIn" to="/backend/login" class="nav-link login-btn">
|
||||||
@ -531,4 +560,46 @@ onUnmounted(() => {
|
|||||||
gap: 6px;
|
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>
|
</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