feature/login-screen #2

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

87
package-lock.json generated
View File

@ -9,6 +9,7 @@
"version": "0.0.0",
"dependencies": {
"axios": "^1.9.0",
"jszip": "^3.10.1",
"process": "^0.11.10",
"vue": "^3.5.13",
"vue-router": "^4.5.1",
@ -1599,6 +1600,11 @@
"url": "https://github.com/sponsors/mesqueeb"
}
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmmirror.com/crc-32/-/crc-32-1.2.2.tgz",
@ -2097,6 +2103,16 @@
"node": ">=18.18.0"
}
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmmirror.com/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
"node_modules/is-docker": {
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/is-docker/-/is-docker-3.0.0.tgz",
@ -2193,6 +2209,11 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz",
@ -2241,12 +2262,31 @@
"graceful-fs": "^4.1.6"
}
},
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmmirror.com/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/kolorist": {
"version": "1.8.0",
"resolved": "https://registry.npmmirror.com/kolorist/-/kolorist-1.8.0.tgz",
"integrity": "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==",
"dev": true
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmmirror.com/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz",
@ -2381,6 +2421,11 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmmirror.com/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="
},
"node_modules/parse-ms": {
"version": "4.0.0",
"resolved": "https://registry.npmmirror.com/parse-ms/-/parse-ms-4.0.0.tgz",
@ -2481,11 +2526,30 @@
"node": ">= 0.6.0"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
},
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/rfdc": {
"version": "1.4.1",
"resolved": "https://registry.npmmirror.com/rfdc/-/rfdc-1.4.1.tgz",
@ -2543,6 +2607,11 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
},
"node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz",
@ -2552,6 +2621,11 @@
"semver": "bin/semver.js"
}
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmmirror.com/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz",
@ -2627,6 +2701,14 @@
"node": ">=0.8"
}
},
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/strip-final-newline": {
"version": "4.0.0",
"resolved": "https://registry.npmmirror.com/strip-final-newline/-/strip-final-newline-4.0.0.tgz",
@ -2727,6 +2809,11 @@
"browserslist": ">= 4.21.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
},
"node_modules/vite": {
"version": "6.3.4",
"resolved": "https://registry.npmmirror.com/vite/-/vite-6.3.4.tgz",

View File

@ -10,6 +10,7 @@
},
"dependencies": {
"axios": "^1.9.0",
"jszip": "^3.10.1",
"process": "^0.11.10",
"vue": "^3.5.13",
"vue-router": "^4.5.1",

View File

@ -1,281 +0,0 @@
<template>
<div v-if="visible" class="modal-overlay" @click.self="close">
<div class="modal-content">
<h2>{{ isEditMode ? '编辑参赛记录' : '添加参赛记录' }}</h2>
<form @submit.prevent="submitForm">
<div class="form-group">
<label for="tournament_id">选择赛事:</label>
<select id="tournament_id" v-model="form.tournament_id" @change="updateTournamentName" required :disabled="isEditMode">
<option disabled value="">请选择赛事</option>
<option v-for="t in availableTournaments" :key="t.id" :value="t.id">
{{ t.name }} (ID: {{ t.id }})
</option>
</select>
<input type="hidden" v-model="form.tournament_name">
</div>
<div class="form-group">
<label for="sign_name">报名者/队长名称:</label>
<input type="text" id="sign_name" v-model="form.sign_name" required>
</div>
<div class="form-group">
<label for="team_name">队伍名称 (可选):</label>
<input type="text" id="team_name" v-model="form.team_name">
</div>
<!-- API addSignUp 参数中包含 type, faction, qq, 这里简化处理 -->
<!-- 编辑模式下显示胜负和状态 -->
<template v-if="isEditMode">
<div class="form-group">
<label for="win">胜场数:</label>
<input type="number" id="win" v-model.number="form.win" min="0">
</div>
<div class="form-group">
<label for="lose">负场数:</label>
<input type="number" id="lose" v-model.number="form.lose" min="0">
</div>
<div class="form-group">
<label for="status">状态 (tie, win, lose):</label>
<input type="text" id="status" v-model="form.status">
</div>
<!-- 编辑模式下显示QQ号如果API支持 -->
<div class="form-group">
<label for="qq">QQ号 (可选, 编辑时):</label>
<input type="text" id="qq" v-model="form.qq">
</div>
</template>
<!-- 添加模式下需要QQ号如果addSignUp API需要 -->
<div v-if="!isEditMode" class="form-group">
<label for="qq_add">QQ号 (用于报名):</label>
<input type="text" id="qq_add" v-model="form.qq">
</div>
<div class="form-actions">
<button type="submit" class="submit-button">{{ isEditMode ? '更新记录' : '添加记录' }}</button>
<button type="button" @click="close" class="cancel-button">取消</button>
</div>
<p v-if="errorMessage" class="error-message">{{ errorMessage }}</p>
</form>
</div>
</div>
</template>
<script setup>
import { ref, watch, defineProps, defineEmits } from 'vue';
import { addSignUp, updateSignUpResult, getSignUpResultList } from '../../api/tournament';
const props = defineProps({
visible: Boolean,
playerData: Object, //
isEditMode: Boolean,
availableTournaments: Array //
});
const emits = defineEmits(['close', 'save']);
const initialFormState = () => ({
tournament_id: null,
tournament_name: '',
sign_name: '',
team_name: '',
qq: '', //
//
win: '0',
lose: '0',
status: 'tie',
// addSignUp ()
type: 'individual', // 'teamname' or 'individual'
faction: 'random',
id: null // player idtournament id (API addSignUp tournament_id as id)
});
const form = ref(initialFormState());
const errorMessage = ref('');
watch(() => props.playerData, (newData) => {
if (newData && props.isEditMode) {
form.value = {
...initialFormState(), // 使
...newData,
id: newData.id // id player.id
};
} else {
form.value = initialFormState();
}
}, { immediate: true, deep: true });
watch(() => props.visible, (isVisible) => {
if (isVisible && !props.isEditMode) {
form.value = initialFormState(); //
}
});
const updateTournamentName = () => {
const selected = props.availableTournaments.find(t => t.id === form.value.tournament_id);
if (selected) {
form.value.tournament_name = selected.name;
}
};
const submitForm = async () => {
errorMessage.value = '';
if (!form.value.tournament_id) {
errorMessage.value = '请选择一个赛事。';
return;
}
updateTournamentName(); // tournament_name
try {
if (props.isEditMode) {
const { id, ...originalFormData } = form.value;
// APIwinlose
const dataForApi = {
...originalFormData,
win: String(originalFormData.win || '0'),
lose: String(originalFormData.lose || '0')
};
try {
const response = await getSignUpResultList();
const existingRecord = response.find(item => item.id === id);
if (!existingRecord) {
throw new Error('找不到要更新的报名记录');
}
// 使API
await updateSignUpResult(id, dataForApi);
alert('参赛记录更新成功!');
} catch (error) {
console.error('更新参赛记录失败:', error);
errorMessage.value = error.response?.data?.detail || error.message || '更新失败,请重试';
return;
}
} else {
//
const dataToAdd = {
...form.value,
id: form.value.tournament_id,
type: form.value.team_name ? 'teamname' : 'individual',
// : form.value.win form.value.lose initialFormState '0'
// addSignUp API String()
};
await addSignUp(dataToAdd);
alert('参赛记录添加成功!');
}
emits('save');
close();
} catch (error) {
console.error('保存参赛记录失败:', error);
errorMessage.value = error.response?.data?.detail || error.message || '操作失败,请检查数据或联系管理员。';
}
};
const close = () => {
emits('close');
errorMessage.value = '';
// watch
};
</script>
<style scoped>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.6);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content {
background-color: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 5px 15px rgba(0,0,0,0.3);
width: 90%;
max-width: 550px; /* 可以适当增大宽度 */
z-index: 1001;
}
.modal-content h2 {
margin-top: 0;
margin-bottom: 20px;
color: #333;
font-size: 1.5rem;
}
.form-group {
margin-bottom: 18px;
}
.form-group label {
display: block;
margin-bottom: 6px;
font-weight: 500;
color: #555;
font-size: 0.9rem;
}
.form-group input[type="text"],
.form-group input[type="number"],
.form-group select {
width: 100%;
padding: 10px;
border: 1px solid #ccc;
border-radius: 4px;
box-sizing: border-box;
font-size: 0.9rem;
}
.form-group input[type="text"]:focus,
.form-group input[type="number"]:focus,
.form-group select:focus {
border-color: #1890ff;
outline: none;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
}
.form-actions {
margin-top: 25px;
text-align: right;
}
.submit-button, .cancel-button {
padding: 10px 18px;
border-radius: 4px;
border: none;
cursor: pointer;
font-size: 0.9rem;
font-weight: 500;
}
.submit-button {
background-color: #1890ff;
color: white;
margin-right: 10px;
}
.submit-button:hover {
background-color: #40a9ff;
}
.cancel-button {
background-color: #f0f0f0;
color: #333;
}
.cancel-button:hover {
background-color: #e0e0e0;
}
.error-message {
color: red;
margin-top: 10px;
font-size: 0.85rem;
}
</style>

View File

@ -81,7 +81,12 @@ const routes = [
path:'terrain',
name: 'Terrain',
component: () => import('@/views/index/TerrainList.vue')
}
},
{
path: 'PIC2TGA',
name: 'PIC2TGA',
component: () => import('@/views/index/PIC2TGA.vue')
},
]
},
{

View File

@ -12,7 +12,7 @@
<h3>管理后台</h3>
</div>
<ul class="sidebar-nav">
<li v-if="hasPrivilege(currentUserData.value.privilege, 'lv-admin')">
<li>
<div @click="dropdownOpen = !dropdownOpen" style="cursor:pointer;display:flex;align-items:center;justify-content:space-between;padding:15px 20px;">
<span>用户管理</span>
<span :style="{transform: dropdownOpen ? 'rotate(90deg)' : 'rotate(0deg)', transition: 'transform 0.2s'}"></span>
@ -26,7 +26,7 @@
</li>
</ul>
</li>
<li v-if="hasPrivilege(currentUserData.value.privilege, 'lv-admin')">
<li>
<div @click="dropdownOpen2 = !dropdownOpen2" style="cursor:pointer;display:flex;align-items:center;justify-content:space-between;padding:15px 20px;">
<span>赛事管理</span>
<span :style="{transform: dropdownOpen2 ? 'rotate(90deg)' : 'rotate(0deg)', transition: 'transform 0.2s'}"></span>
@ -40,7 +40,7 @@
</li>
</ul>
</li>
<li v-if="hasPrivilege(currentUserData.value.privilege, ['lv-admin', 'lv-mod', 'lv-map', 'lv-user', 'lv-competitor'])">
<li>
<div @click="dropdownOpen3 = !dropdownOpen3" style="cursor:pointer;display:flex;align-items:center;justify-content:space-between;padding:15px 20px;">
<span>办事大厅</span>
<span :style="{transform: dropdownOpen3 ? 'rotate(90deg)' : 'rotate(0deg)', transition: 'transform 0.2s'}"></span>
@ -53,9 +53,9 @@
</li>
</ul>
<div class="sidebar-footer">
<button @click="selectAdminView('code-generator')" :class="['sidebar-button', 'code-generator-button', { 'active': currentAdminView === 'code-generator' }]">
<!-- <button @click="selectAdminView('code-generator')" :class="['sidebar-button', 'code-generator-button', { 'active': currentAdminView === 'code-generator' }]">
代码生成器
</button>
</button> -->
<button @click="goToHomePage" class="home-button sidebar-button">
返回主界面
</button>
@ -72,10 +72,9 @@
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { ref, onMounted, computed, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { getUserInfo } from '@/utils/jwt'
import { hasPrivilege } from '@/utils/privilege'
const router = useRouter()
const hasToken = ref(false)
@ -85,30 +84,44 @@ const isMobileSidebarOpen = ref(false)
const dropdownOpen = ref(false)
const dropdownOpen2 = ref(false)
const dropdownOpen3 = ref(false)
let privilegeCheckTimer = null
const isAdmin = computed(() => {
return currentUserData.value && currentUserData.value.privilege === 'lv-admin';
})
onMounted(async () => {
// token
async function checkPrivilege() {
const token = localStorage.getItem('access_token')
hasToken.value = !!token
if (!token) {
router.push('/')
return;
}
//
try {
const userInfo = await getUserInfo();
currentUserData.value = userInfo;
if (!userInfo || userInfo.privilege !== 'lv-admin') {
// 退
localStorage.removeItem('access_token')
currentUserData.value = null
router.push('/')
return;
}
} catch (e) {
// 退
localStorage.removeItem('access_token')
currentUserData.value = null
router.push('/')
}
}
onMounted(() => {
checkPrivilege();
privilegeCheckTimer = setInterval(checkPrivilege, 2 * 60 * 1000);
})
onUnmounted(() => {
if (privilegeCheckTimer) clearInterval(privilegeCheckTimer);
})
const handleLogout = () => {

View File

@ -25,6 +25,58 @@ const userAvatarUrl = computed(() => {
return null
})
//
const userHighestPrivilege = computed(() => {
if (!currentUserData.value || !currentUserData.value.privilege) {
return null
}
const privileges = currentUserData.value.privilege.split(';')
//
const privilegeLevels = ['lv-admin', 'lv-mod', 'lv-competitor', 'lv-map', 'lv-user']
for (const level of privilegeLevels) {
if (privileges.includes(level)) {
return level
}
}
return null
})
//
const privilegeDisplayName = computed(() => {
const privilege = userHighestPrivilege.value
if (!privilege) return ''
const displayNames = {
'lv-admin': '管理员',
'lv-mod': '模组',
'lv-map': '地图',
'lv-user': '用户',
'lv-competitor': '竞技'
}
return displayNames[privilege] || privilege
})
//
const privilegeTagClass = computed(() => {
const privilege = userHighestPrivilege.value
if (!privilege) return ''
const tagClasses = {
'lv-admin': 'privilege-tag admin',
'lv-mod': 'privilege-tag mod',
'lv-map': 'privilege-tag map',
'lv-user': 'privilege-tag user',
'lv-competitor': 'privilege-tag competitor'
}
return tagClasses[privilege] || 'privilege-tag'
})
const toggleMobileMenu = () => {
showMobileMenu.value = !showMobileMenu.value
}
@ -80,6 +132,10 @@ onUnmounted(() => {
<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"
@ -104,6 +160,7 @@ onUnmounted(() => {
<div v-if="isLoggedIn && currentUserData" class="user-info-nav" ref="userInfoNavRef" @click="toggleDropdown">
<img v-if="userAvatarUrl" :src="userAvatarUrl" alt="User Avatar" class="nav-avatar" />
<span class="nav-username">{{ currentUserData.username }}</span>
<span v-if="userHighestPrivilege" :class="privilegeTagClass">{{ privilegeDisplayName }}</span>
<i class="fas fa-chevron-down dropdown-icon"></i>
<div v-show="showDropdown" class="dropdown-menu">
<div v-if="isAdmin" class="dropdown-item" @click.stop="router.push('/backend/dashboard'); showDropdown = false">
@ -181,15 +238,16 @@ onUnmounted(() => {
}
.nav-container {
max-width: 1200px;
max-width: 1400px;
margin: 0 auto;
padding: 0 20px;
padding: 0 12px;
height: auto;
min-height: 60px;
display: flex;
flex-wrap: wrap;
flex-wrap: nowrap;
align-items: center;
justify-content: flex-start;
overflow-x: visible;
}
.nav-brand {
@ -209,7 +267,10 @@ onUnmounted(() => {
flex-direction: row;
display: flex;
align-items: center;
gap: 6px;
gap: 12px;
flex-wrap: nowrap;
white-space: nowrap;
overflow-x: visible;
}
.nav-right {
@ -239,14 +300,18 @@ onUnmounted(() => {
.nav-link {
color: rgba(255, 255, 255, 0.9);
text-decoration: none;
padding: 8px 10px;
padding: 6px 12px;
border-radius: 6px;
transition: all 0.3s ease;
font-weight: 500;
position: relative;
width: 100%;
width: auto;
text-align: center;
font-size: 0.92rem;
font-size: 0.9rem;
white-space: nowrap;
display: inline-block;
flex-shrink: 1;
min-width: 0;
}
.nav-link:hover {
@ -356,6 +421,38 @@ onUnmounted(() => {
font-size: 0.9rem;
}
.privilege-tag {
display: inline-block;
padding: 2px 10px;
border-radius: 16px;
font-size: 0.78rem;
font-weight: 500;
color: #fff;
margin-left: 8px;
background: rgba(0,0,0,0.18);
letter-spacing: 1px;
vertical-align: middle;
box-shadow: 0 1px 4px rgba(0,0,0,0.04);
border: none;
transition: background 0.2s;
}
.privilege-tag.admin {
background: #ff7675;
}
.privilege-tag.mod {
background: #6c5ce7;
}
.privilege-tag.competitor {
background: #00b894;
}
.privilege-tag.map {
background: #0984e3;
}
.privilege-tag.user {
background: #636e72;
}
.mobile-menu-toggle {
display: block;
font-size: 1.5rem;

313
src/views/index/PIC2TGA.vue Normal file
View File

@ -0,0 +1,313 @@
<template>
<div class="image-editor">
<div class="editor-header">
<h1>图片转换工具(TGA)</h1>
<div class="editor-controls">
<input type="file" multiple @change="onFilesSelected" accept=".jpg,.jpeg,.png" />
<button @click="setMode('draw')">涂鸦</button>
<button @click="setMode('view')">查看</button>
<select v-model="channel" @change="updateChannelView">
<option value="rgba">原图</option>
<option value="r">R 通道</option>
<option value="g">G 通道</option>
<option value="b">B 通道</option>
<option value="a">Alpha 通道</option>
</select>
<button @click="downloadTGA" :disabled="!currentImage">导出TGA</button>
<button @click="downloadAllTGA" :disabled="images.length === 0">批量导出 ZIP</button>
</div>
</div>
<div class="thumbnail-list" v-if="images.length > 0">
<div
v-for="(img, idx) in images"
:key="idx"
class="thumbnail"
:class="{ active: idx === currentIndex }"
@click="loadImage(idx)"
>
<img :src="img.preview" alt="缩略图" />
</div>
</div>
<div class="editor-canvas" v-if="currentImage">
<canvas ref="canvas" @mousedown="onMouseDown" @mouseup="onMouseUp" @mousemove="onMouseMove"></canvas>
</div>
</div>
</template>
<script>
import JSZip from "jszip";
export default {
name: "ImageEditor",
data() {
return {
images: [],
currentIndex: null,
channel: "rgba",
mode: "view",
drawing: false,
originalImageData: null,
};
},
computed: {
currentImage() {
return this.images[this.currentIndex] || null;
},
},
methods: {
onFilesSelected(e) {
const files = Array.from(e.target.files);
this.images = files.map(file => ({
file,
preview: URL.createObjectURL(file),
name: file.name,
}));
if (this.images.length) this.loadImage(0);
},
loadImage(index) {
this.currentIndex = index;
const img = new Image();
img.onload = () => {
const canvas = this.$refs.canvas;
const ctx = canvas.getContext("2d");
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0);
this.originalImageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
this.updateChannelView(); // show selected channel
};
img.src = this.images[index].preview;
},
setMode(mode) {
this.mode = mode;
},
updateChannelView() {
if (!this.originalImageData) return;
const canvas = this.$refs.canvas;
const ctx = canvas.getContext("2d");
const imageData = new ImageData(
new Uint8ClampedArray(this.originalImageData.data),
this.originalImageData.width,
this.originalImageData.height
);
for (let i = 0; i < imageData.data.length; i += 4) {
let [r, g, b, a] = imageData.data.slice(i, i + 4);
switch (this.channel) {
case "r": g = b = 0; break;
case "g": r = b = 0; break;
case "b": r = g = 0; break;
case "a": r = g = b = a; break;
}
imageData.data[i] = r;
imageData.data[i + 1] = g;
imageData.data[i + 2] = b;
}
ctx.putImageData(imageData, 0, 0);
},
onMouseDown() {
if (this.mode === "draw") this.drawing = true;
},
onMouseUp() {
this.drawing = false;
},
onMouseMove(e) {
if (!this.drawing || this.mode !== "draw") return;
const canvas = this.$refs.canvas;
const ctx = canvas.getContext("2d");
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
ctx.fillStyle = "red";
ctx.beginPath();
ctx.arc(x, y, 4, 0, Math.PI * 2);
ctx.fill();
},
downloadTGA() {
const canvas = this.$refs.canvas;
const ctx = canvas.getContext("2d");
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const { width, height, data: pixels } = imageData;
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] = 24;
const data = new Uint8Array(width * height * 3);
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
}
}
const tga = new Uint8Array(header.length + data.length);
tga.set(header, 0);
tga.set(data, header.length);
const blob = new Blob([tga], { type: "application/octet-stream" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = this.currentImage?.name?.replace(/\.(jpg|jpeg|png)$/i, ".tga") || "output.tga";
a.click();
URL.revokeObjectURL(a.href);
},
async downloadAllTGA() {
const zip = new JSZip();
for (let i = 0; i < this.images.length; i++) {
const imgInfo = this.images[i];
const img = new Image();
img.src = imgInfo.preview;
await new Promise((resolve) => {
img.onload = () => {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const tgaBuffer = this.convertImageDataToTGA(imageData);
const filename = imgInfo.name.replace(/\.(jpg|jpeg|png)$/i, `.tga`);
zip.file(filename, tgaBuffer);
resolve();
};
});
}
const blob = await zip.generateAsync({ type: "blob" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = "images_export.zip";
a.click();
URL.revokeObjectURL(a.href);
},
convertImageDataToTGA(imageData) {
const { width, height, data: pixels } = imageData;
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] = 24;
const data = new Uint8Array(width * height * 3);
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
}
}
const tga = new Uint8Array(header.length + data.length);
tga.set(header, 0);
tga.set(data, header.length);
return tga;
},
},
};
</script>
<style scoped>
.image-editor {
padding: 20px;
max-width: 1400px;
margin: 0 auto;
}
.editor-header {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
gap: 10px;
margin-bottom: 20px;
}
.editor-header h1 {
font-size: 1.5rem;
color: #333;
margin: 0;
}
.editor-controls {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.editor-controls input,
.editor-controls button,
.editor-controls select {
padding: 8px 12px;
border: 1px solid #ccc;
background: #f8f9fa;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
}
.editor-controls button:hover,
.editor-controls select:hover {
background-color: #e0e0e0;
}
.thumbnail-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 15px;
}
.thumbnail {
width: 80px;
height: 80px;
border: 2px solid transparent;
cursor: pointer;
overflow: hidden;
border-radius: 6px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.thumbnail.active {
border-color: #2196f3;
}
.thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
}
.editor-canvas {
display: flex;
justify-content: center;
align-items: center;
}
canvas {
max-width: 100%;
border: 1px solid #ccc;
border-radius: 8px;
background: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
</style>