feature/login-screen #2

Merged
zyb merged 6 commits from feature/login-screen into master 2025-06-27 17:18:19 +08:00
5 changed files with 744 additions and 30 deletions
Showing only changes of commit cd3a0672bb - Show all commits

View File

@ -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;
}
}

View File

@ -82,11 +82,16 @@ const routes = [
name: 'Terrain',
component: () => import('@/views/index/TerrainList.vue')
},
{
path: 'PIC2TGA',
name: 'PIC2TGA',
component: () => import('@/views/index/PIC2TGA.vue')
},
{
path: 'PIC2TGA',
name: 'PIC2TGA',
component: () => import('@/views/index/PIC2TGA.vue')
},
{
path: 'terrainGenerate',
name: 'TerrainGenerate',
component: () => import('@/views/index/TerrainGenerate.vue')
}
]
},
{

View File

@ -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(() => {

View File

@ -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>
<router-link to="/author" class="nav-link">活跃作者推荐</router-link>
<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>
<!-- 地图 一级菜单 -->
<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 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>

View 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>