feature/login-screen #2
87
package-lock.json
generated
87
package-lock.json
generated
@ -9,6 +9,7 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.9.0",
|
"axios": "^1.9.0",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
"process": "^0.11.10",
|
"process": "^0.11.10",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
"vue-router": "^4.5.1",
|
"vue-router": "^4.5.1",
|
||||||
@ -1599,6 +1600,11 @@
|
|||||||
"url": "https://github.com/sponsors/mesqueeb"
|
"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": {
|
"node_modules/crc-32": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmmirror.com/crc-32/-/crc-32-1.2.2.tgz",
|
"resolved": "https://registry.npmmirror.com/crc-32/-/crc-32-1.2.2.tgz",
|
||||||
@ -2097,6 +2103,16 @@
|
|||||||
"node": ">=18.18.0"
|
"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": {
|
"node_modules/is-docker": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/is-docker/-/is-docker-3.0.0.tgz",
|
"resolved": "https://registry.npmmirror.com/is-docker/-/is-docker-3.0.0.tgz",
|
||||||
@ -2193,6 +2209,11 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/isexe": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz",
|
"resolved": "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz",
|
||||||
@ -2241,12 +2262,31 @@
|
|||||||
"graceful-fs": "^4.1.6"
|
"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": {
|
"node_modules/kolorist": {
|
||||||
"version": "1.8.0",
|
"version": "1.8.0",
|
||||||
"resolved": "https://registry.npmmirror.com/kolorist/-/kolorist-1.8.0.tgz",
|
"resolved": "https://registry.npmmirror.com/kolorist/-/kolorist-1.8.0.tgz",
|
||||||
"integrity": "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==",
|
"integrity": "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/lru-cache": {
|
||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz",
|
"resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||||
@ -2381,6 +2421,11 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/parse-ms": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/parse-ms/-/parse-ms-4.0.0.tgz",
|
"resolved": "https://registry.npmmirror.com/parse-ms/-/parse-ms-4.0.0.tgz",
|
||||||
@ -2481,11 +2526,30 @@
|
|||||||
"node": ">= 0.6.0"
|
"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": {
|
"node_modules/proxy-from-env": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
"resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
|
"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": {
|
"node_modules/rfdc": {
|
||||||
"version": "1.4.1",
|
"version": "1.4.1",
|
||||||
"resolved": "https://registry.npmmirror.com/rfdc/-/rfdc-1.4.1.tgz",
|
"resolved": "https://registry.npmmirror.com/rfdc/-/rfdc-1.4.1.tgz",
|
||||||
@ -2543,6 +2607,11 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/semver": {
|
||||||
"version": "6.3.1",
|
"version": "6.3.1",
|
||||||
"resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz",
|
"resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz",
|
||||||
@ -2552,6 +2621,11 @@
|
|||||||
"semver": "bin/semver.js"
|
"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": {
|
"node_modules/shebang-command": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz",
|
"resolved": "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
@ -2627,6 +2701,14 @@
|
|||||||
"node": ">=0.8"
|
"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": {
|
"node_modules/strip-final-newline": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/strip-final-newline/-/strip-final-newline-4.0.0.tgz",
|
"resolved": "https://registry.npmmirror.com/strip-final-newline/-/strip-final-newline-4.0.0.tgz",
|
||||||
@ -2727,6 +2809,11 @@
|
|||||||
"browserslist": ">= 4.21.0"
|
"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": {
|
"node_modules/vite": {
|
||||||
"version": "6.3.4",
|
"version": "6.3.4",
|
||||||
"resolved": "https://registry.npmmirror.com/vite/-/vite-6.3.4.tgz",
|
"resolved": "https://registry.npmmirror.com/vite/-/vite-6.3.4.tgz",
|
||||||
|
@ -10,6 +10,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.9.0",
|
"axios": "^1.9.0",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
"process": "^0.11.10",
|
"process": "^0.11.10",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
"vue-router": "^4.5.1",
|
"vue-router": "^4.5.1",
|
||||||
|
@ -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 id,添加模式下是tournament 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;
|
|
||||||
|
|
||||||
// 为API准备数据,确保win和lose是字符串
|
|
||||||
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>
|
|
@ -81,7 +81,12 @@ const routes = [
|
|||||||
path:'terrain',
|
path:'terrain',
|
||||||
name: 'Terrain',
|
name: 'Terrain',
|
||||||
component: () => import('@/views/index/TerrainList.vue')
|
component: () => import('@/views/index/TerrainList.vue')
|
||||||
}
|
},
|
||||||
|
{
|
||||||
|
path: 'PIC2TGA',
|
||||||
|
name: 'PIC2TGA',
|
||||||
|
component: () => import('@/views/index/PIC2TGA.vue')
|
||||||
|
},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -12,7 +12,7 @@
|
|||||||
<h3>管理后台</h3>
|
<h3>管理后台</h3>
|
||||||
</div>
|
</div>
|
||||||
<ul class="sidebar-nav">
|
<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;">
|
<div @click="dropdownOpen = !dropdownOpen" style="cursor:pointer;display:flex;align-items:center;justify-content:space-between;padding:15px 20px;">
|
||||||
<span>用户管理</span>
|
<span>用户管理</span>
|
||||||
<span :style="{transform: dropdownOpen ? 'rotate(90deg)' : 'rotate(0deg)', transition: 'transform 0.2s'}">▶</span>
|
<span :style="{transform: dropdownOpen ? 'rotate(90deg)' : 'rotate(0deg)', transition: 'transform 0.2s'}">▶</span>
|
||||||
@ -26,7 +26,7 @@
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</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;">
|
<div @click="dropdownOpen2 = !dropdownOpen2" style="cursor:pointer;display:flex;align-items:center;justify-content:space-between;padding:15px 20px;">
|
||||||
<span>赛事管理</span>
|
<span>赛事管理</span>
|
||||||
<span :style="{transform: dropdownOpen2 ? 'rotate(90deg)' : 'rotate(0deg)', transition: 'transform 0.2s'}">▶</span>
|
<span :style="{transform: dropdownOpen2 ? 'rotate(90deg)' : 'rotate(0deg)', transition: 'transform 0.2s'}">▶</span>
|
||||||
@ -40,7 +40,7 @@
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</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;">
|
<div @click="dropdownOpen3 = !dropdownOpen3" style="cursor:pointer;display:flex;align-items:center;justify-content:space-between;padding:15px 20px;">
|
||||||
<span>办事大厅</span>
|
<span>办事大厅</span>
|
||||||
<span :style="{transform: dropdownOpen3 ? 'rotate(90deg)' : 'rotate(0deg)', transition: 'transform 0.2s'}">▶</span>
|
<span :style="{transform: dropdownOpen3 ? 'rotate(90deg)' : 'rotate(0deg)', transition: 'transform 0.2s'}">▶</span>
|
||||||
@ -53,9 +53,9 @@
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div class="sidebar-footer">
|
<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 @click="goToHomePage" class="home-button sidebar-button">
|
||||||
返回主界面
|
返回主界面
|
||||||
</button>
|
</button>
|
||||||
@ -72,10 +72,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, computed } 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 { hasPrivilege } from '@/utils/privilege'
|
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const hasToken = ref(false)
|
const hasToken = ref(false)
|
||||||
@ -85,30 +84,44 @@ const isMobileSidebarOpen = ref(false)
|
|||||||
const dropdownOpen = ref(false)
|
const dropdownOpen = ref(false)
|
||||||
const dropdownOpen2 = ref(false)
|
const dropdownOpen2 = ref(false)
|
||||||
const dropdownOpen3 = ref(false)
|
const dropdownOpen3 = ref(false)
|
||||||
|
let privilegeCheckTimer = null
|
||||||
|
|
||||||
const isAdmin = computed(() => {
|
const isAdmin = computed(() => {
|
||||||
return currentUserData.value && currentUserData.value.privilege === 'lv-admin';
|
return currentUserData.value && currentUserData.value.privilege === 'lv-admin';
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
async function checkPrivilege() {
|
||||||
// 检查是否有token
|
|
||||||
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('/')
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取用户信息
|
|
||||||
try {
|
try {
|
||||||
const userInfo = await getUserInfo();
|
const userInfo = await getUserInfo();
|
||||||
currentUserData.value = userInfo;
|
currentUserData.value = userInfo;
|
||||||
if (!userInfo || userInfo.privilege !== 'lv-admin') {
|
if (!userInfo || userInfo.privilege !== 'lv-admin') {
|
||||||
|
// 退出登录并跳转首页
|
||||||
|
localStorage.removeItem('access_token')
|
||||||
|
currentUserData.value = null
|
||||||
router.push('/')
|
router.push('/')
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
// 退出登录并跳转首页
|
||||||
|
localStorage.removeItem('access_token')
|
||||||
|
currentUserData.value = null
|
||||||
router.push('/')
|
router.push('/')
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
checkPrivilege();
|
||||||
|
privilegeCheckTimer = setInterval(checkPrivilege, 2 * 60 * 1000);
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (privilegeCheckTimer) clearInterval(privilegeCheckTimer);
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
|
@ -25,6 +25,58 @@ const userAvatarUrl = computed(() => {
|
|||||||
return null
|
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 = () => {
|
const toggleMobileMenu = () => {
|
||||||
showMobileMenu.value = !showMobileMenu.value
|
showMobileMenu.value = !showMobileMenu.value
|
||||||
}
|
}
|
||||||
@ -80,6 +132,10 @@ onUnmounted(() => {
|
|||||||
<router-link to="/weekly" 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="/author" class="nav-link">活跃作者推荐</router-link>
|
||||||
<router-link to="/terrain" 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
|
<router-link
|
||||||
v-if="isLoggedIn && currentUserData && hasPrivilege(currentUserData.privilege, ['lv-admin', 'lv-mod'])"
|
v-if="isLoggedIn && currentUserData && hasPrivilege(currentUserData.privilege, ['lv-admin', 'lv-mod'])"
|
||||||
to="/weapon-match"
|
to="/weapon-match"
|
||||||
@ -104,6 +160,7 @@ onUnmounted(() => {
|
|||||||
<div v-if="isLoggedIn && currentUserData" class="user-info-nav" ref="userInfoNavRef" @click="toggleDropdown">
|
<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" />
|
<img v-if="userAvatarUrl" :src="userAvatarUrl" alt="User Avatar" class="nav-avatar" />
|
||||||
<span class="nav-username">{{ currentUserData.username }}</span>
|
<span class="nav-username">{{ currentUserData.username }}</span>
|
||||||
|
<span v-if="userHighestPrivilege" :class="privilegeTagClass">{{ privilegeDisplayName }}</span>
|
||||||
<i class="fas fa-chevron-down dropdown-icon"></i>
|
<i class="fas fa-chevron-down dropdown-icon"></i>
|
||||||
<div v-show="showDropdown" class="dropdown-menu">
|
<div v-show="showDropdown" class="dropdown-menu">
|
||||||
<div v-if="isAdmin" class="dropdown-item" @click.stop="router.push('/backend/dashboard'); showDropdown = false">
|
<div v-if="isAdmin" class="dropdown-item" @click.stop="router.push('/backend/dashboard'); showDropdown = false">
|
||||||
@ -181,15 +238,16 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nav-container {
|
.nav-container {
|
||||||
max-width: 1200px;
|
max-width: 1400px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 0 20px;
|
padding: 0 12px;
|
||||||
height: auto;
|
height: auto;
|
||||||
min-height: 60px;
|
min-height: 60px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: nowrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
|
overflow-x: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-brand {
|
.nav-brand {
|
||||||
@ -209,7 +267,10 @@ onUnmounted(() => {
|
|||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 12px;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow-x: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-right {
|
.nav-right {
|
||||||
@ -239,14 +300,18 @@ onUnmounted(() => {
|
|||||||
.nav-link {
|
.nav-link {
|
||||||
color: rgba(255, 255, 255, 0.9);
|
color: rgba(255, 255, 255, 0.9);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
padding: 8px 10px;
|
padding: 6px 12px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: auto;
|
||||||
text-align: center;
|
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 {
|
.nav-link:hover {
|
||||||
@ -356,6 +421,38 @@ onUnmounted(() => {
|
|||||||
font-size: 0.9rem;
|
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 {
|
.mobile-menu-toggle {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
|
313
src/views/index/PIC2TGA.vue
Normal file
313
src/views/index/PIC2TGA.vue
Normal 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>
|
||||||
|
|
Loading…
x
Reference in New Issue
Block a user