权限控制
This commit is contained in:
		
							parent
							
								
									75af99c222
								
							
						
					
					
						commit
						8b8b6b980a
					
				
							
								
								
									
										87
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										87
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							@ -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",
 | 
			
		||||
 | 
			
		||||
@ -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",
 | 
			
		||||
 | 
			
		||||
@ -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',
 | 
			
		||||
        name: 'Terrain',
 | 
			
		||||
        component: () => import('@/views/index/TerrainList.vue')
 | 
			
		||||
      }
 | 
			
		||||
      },
 | 
			
		||||
        {
 | 
			
		||||
          path: 'PIC2TGA',
 | 
			
		||||
          name: 'PIC2TGA',
 | 
			
		||||
          component: () => import('@/views/index/PIC2TGA.vue')
 | 
			
		||||
        },
 | 
			
		||||
    ]
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
 | 
			
		||||
@ -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 = () => {
 | 
			
		||||
 | 
			
		||||
@ -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
									
								
							
							
						
						
									
										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