Merge pull request 'feature/login-screen' (#2) from feature/login-screen into master
Reviewed-on: #2
This commit is contained in:
commit
2c404681e7
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",
|
||||
|
@ -112,3 +112,20 @@ export const userRegister = async (qq_code, password, token, captcha) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 管理员修改用户权限
|
||||
* @param {string} uuid - 用户uuid
|
||||
* @param {string} privilege - 新权限
|
||||
* @returns {Promise<void>} 无返回值,成功即为修改成功
|
||||
*/
|
||||
export const adminChangeUserPrivilege = async (uuid, privilege) => {
|
||||
try {
|
||||
await axiosInstance.put('/admin/change_user_privilege', {
|
||||
uuid,
|
||||
privilege
|
||||
});
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,926 +0,0 @@
|
||||
<template>
|
||||
<div class="code-generator-container">
|
||||
<div class="editor-pane">
|
||||
<h2 class="title">MySQL 表结构生成前端代码</h2>
|
||||
<p class="description">
|
||||
在左侧输入 <code>CREATE TABLE</code> 语句,右侧将实时预览生成的UI界面。
|
||||
</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="sql-input">SQL 输入:</label>
|
||||
<textarea
|
||||
id="sql-input"
|
||||
v-model="sqlInput"
|
||||
rows="15"
|
||||
placeholder="在此处粘贴 CREATE TABLE 语句..."
|
||||
@input="generateCode"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button
|
||||
@click="downloadCode"
|
||||
:disabled="!generatedCode"
|
||||
class="action-button download-button"
|
||||
>
|
||||
下载 .vue 文件
|
||||
</button>
|
||||
<button
|
||||
@click="downloadApiFile"
|
||||
:disabled="!generatedApiCode"
|
||||
class="action-button download-api-button"
|
||||
>
|
||||
下载 API 文件
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="generatedCode" class="generated-code-preview">
|
||||
<h3 class="preview-title">代码预览 ({{ generatedFileName }})</h3>
|
||||
<pre><code>{{ generatedCode }}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="preview-pane">
|
||||
<h2 class="preview-area-title">界面预览</h2>
|
||||
<div v-if="parsedTableInfo" class="preview-content-wrapper">
|
||||
<div class="device-frame">
|
||||
<!-- 这里是模拟的组件预览 -->
|
||||
<div class="service-hall-container-preview">
|
||||
<div class="actions-bar">
|
||||
<h3>{{ parsedTableInfo.tableComment || toPascalCase(parsedTableInfo.tableName) + '管理' }}</h3>
|
||||
<div class="buttons-wrapper">
|
||||
<button class="add-button">新增{{ parsedTableInfo.tableComment || toPascalCase(parsedTableInfo.tableName) }}</button>
|
||||
<button class="refresh-button">刷新列表</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive-wrapper">
|
||||
<table class="custom-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-for="column in parsedTableInfo.columns" :key="column.name">{{ column.comment || column.name }}</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td v-for="column in parsedTableInfo.columns" :key="column.name">示例数据 1</td>
|
||||
<td>
|
||||
<button class="action-button edit">编辑</button>
|
||||
<button class="action-button delete">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td v-for="column in parsedTableInfo.columns" :key="column.name">示例数据 2</td>
|
||||
<td>
|
||||
<button class="action-button edit">编辑</button>
|
||||
<button class="action-button delete">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 模拟弹窗 -->
|
||||
<div class="modal-overlay-preview-active">
|
||||
<div class="modal-content-preview">
|
||||
<h3>新增 {{ parsedTableInfo.tableComment || toPascalCase(parsedTableInfo.tableName) }}</h3>
|
||||
<form>
|
||||
<div class="form-group-preview" v-for="column in parsedTableInfo.columns.filter(c => c.name !== parsedTableInfo.primaryKey)" :key="column.name">
|
||||
<label>{{ column.comment || column.name }}:</label>
|
||||
<input type="text" :placeholder="`请输入${column.comment || column.name}`" />
|
||||
</div>
|
||||
<div class="form-actions-preview">
|
||||
<button type="submit" class="submit-button">保存</button>
|
||||
<button type="button" class="cancel-button">取消</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="no-preview">
|
||||
<p>输入合法的建表语句后<br/>将在此处显示界面预览</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
const sqlInput = ref('');
|
||||
const generatedCode = ref('');
|
||||
const generatedFileName = ref('');
|
||||
const generatedApiCode = ref('');
|
||||
const generatedApiFileName = ref('');
|
||||
const parsedTableInfo = ref(null);
|
||||
|
||||
/**
|
||||
* 将字符串转换为帕斯卡命名法(大驼峰)
|
||||
* @param {string} str 输入字符串 (e.g., user_list)
|
||||
* @returns {string} 帕斯卡命名法的字符串 (e.g., UserList)
|
||||
*/
|
||||
const toPascalCase = (str) => {
|
||||
if (!str) return '';
|
||||
return str
|
||||
.match(/[A-Z]{2,}(?=[A-Z][a-z]+[0-9]*|\b)|[A-Z]?[a-z]+[0-9]*|[A-Z]|[0-9]+/g)
|
||||
.map(x => x.charAt(0).toUpperCase() + x.slice(1).toLowerCase())
|
||||
.join('');
|
||||
};
|
||||
|
||||
/**
|
||||
* 解析 CREATE TABLE SQL 语句
|
||||
* @param {string} sql - CREATE TABLE 语句
|
||||
* @returns {object|null} 解析后的表结构对象,或在解析失败时返回 null
|
||||
*/
|
||||
const parseSql = (sql) => {
|
||||
try {
|
||||
const tableInfo = {
|
||||
tableName: '',
|
||||
tableComment: '',
|
||||
columns: [],
|
||||
primaryKey: 'id' // 默认主键为id
|
||||
};
|
||||
|
||||
if (!sql || !sql.trim().toLowerCase().startsWith('create table')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 1. 提取表名
|
||||
const tableNameMatch = sql.match(/CREATE TABLE `?(\w+)`?/i);
|
||||
if (!tableNameMatch) throw new Error('无法解析表名');
|
||||
tableInfo.tableName = tableNameMatch[1];
|
||||
|
||||
// 2. 提取表注释
|
||||
const tableCommentMatch = sql.match(/\)\s*ENGINE=.*COMMENT='([^']*)'/i);
|
||||
if (tableCommentMatch) {
|
||||
tableInfo.tableComment = tableCommentMatch[1];
|
||||
}
|
||||
|
||||
// 3. 提取字段定义
|
||||
const columnsContentMatch = sql.match(/\(([\s\S]*)\)/);
|
||||
if (!columnsContentMatch) throw new Error('无法解析字段定义');
|
||||
const columnsContent = columnsContentMatch[1];
|
||||
|
||||
const lines = columnsContent.split('\n');
|
||||
let columns = [];
|
||||
lines.forEach(line => {
|
||||
const trimmedLine = line.trim();
|
||||
|
||||
// 提取主键
|
||||
const primaryKeyMatch = trimmedLine.match(/PRIMARY KEY \(`?(\w+)`?\)/i);
|
||||
if (primaryKeyMatch) {
|
||||
tableInfo.primaryKey = primaryKeyMatch[1];
|
||||
return; // 继续下一行
|
||||
}
|
||||
|
||||
// 忽略 KEY `...` (索引) 或其他非字段行
|
||||
if (!trimmedLine.startsWith('`')) return;
|
||||
|
||||
const columnMatch = trimmedLine.match(/`(\w+)`\s+([\w\(\),_ ]+)\s*(.*)/);
|
||||
if (columnMatch) {
|
||||
const name = columnMatch[1];
|
||||
const type = columnMatch[2].trim();
|
||||
const rest = columnMatch[3];
|
||||
|
||||
let comment = '';
|
||||
const commentMatch = rest.match(/COMMENT '([^']*)'/i);
|
||||
if (commentMatch) {
|
||||
comment = commentMatch[1];
|
||||
}
|
||||
|
||||
columns.push({ name, type, comment: comment || name });
|
||||
}
|
||||
});
|
||||
|
||||
if (columns.length === 0) throw new Error('未能解析任何字段');
|
||||
tableInfo.columns = columns;
|
||||
return tableInfo;
|
||||
} catch (error) {
|
||||
console.error('SQL 解析失败:', error);
|
||||
alert(`SQL 解析失败: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据表结构生成 API 文件代码
|
||||
* @param {object} tableInfo - 解析后的表结构
|
||||
* @returns {string} API 文件代码字符串
|
||||
*/
|
||||
const generateApiFile = (tableInfo) => {
|
||||
const componentName = toPascalCase(tableInfo.tableName);
|
||||
const resourceName = tableInfo.tableName;
|
||||
const primaryKey = tableInfo.primaryKey || 'id';
|
||||
|
||||
return `
|
||||
import axiosInstance from './axiosConfig';
|
||||
|
||||
const API_BASE_URL = '/api/${resourceName}';
|
||||
|
||||
/**
|
||||
* 获取${tableInfo.tableComment || componentName}列表
|
||||
* @param {object} params - 查询参数
|
||||
*/
|
||||
export const get${componentName}List = (params) => {
|
||||
return axiosInstance.get(API_BASE_URL, { params });
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取单个${tableInfo.tableComment || componentName}详情
|
||||
* @param {number|string} id - 主键ID
|
||||
*/
|
||||
export const get${componentName}ById = (id) => {
|
||||
return axiosInstance.get(\`\${API_BASE_URL}/\${id}\`);
|
||||
};
|
||||
|
||||
/**
|
||||
* 创建新的${tableInfo.tableComment || componentName}
|
||||
* @param {object} data - ${tableInfo.tableComment || componentName}数据
|
||||
*/
|
||||
export const create${componentName} = (data) => {
|
||||
return axiosInstance.post(API_BASE_URL, data);
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新${tableInfo.tableComment || componentName}
|
||||
* @param {number|string} id - 主键ID
|
||||
* @param {object} data - 需要更新的数据
|
||||
*/
|
||||
export const update${componentName} = (id, data) => {
|
||||
return axiosInstance.put(\`\${API_BASE_URL}/\${id}\`, data);
|
||||
};
|
||||
|
||||
/**
|
||||
* 删除${tableInfo.tableComment || componentName}
|
||||
* @param {number|string} id - 主键ID
|
||||
*/
|
||||
export const delete${componentName} = (id) => {
|
||||
return axiosInstance.delete(\`\${API_BASE_URL}/\${id}\`);
|
||||
};
|
||||
`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据表结构生成 Vue 组件代码
|
||||
* @param {object} tableInfo - 解析后的表结构
|
||||
* @returns {string} Vue 组件代码字符串
|
||||
*/
|
||||
const generateVueComponent = (tableInfo) => {
|
||||
const componentName = toPascalCase(tableInfo.tableName);
|
||||
const tableTitle = tableInfo.tableComment || `${componentName}管理`;
|
||||
const tableComment = tableInfo.tableComment || componentName;
|
||||
|
||||
const formFields = tableInfo.columns
|
||||
.filter(c => c.name !== tableInfo.primaryKey)
|
||||
.map(c => `
|
||||
<div class="form-group">
|
||||
<label for="add-${c.name}">${c.comment || c.name}:</label>
|
||||
<input id="add-${c.name}" type="text" v-model="editableItem.${c.name}" placeholder="请输入${c.comment || c.name}" />
|
||||
</div>`).join('');
|
||||
|
||||
const dataFields = tableInfo.columns.map(c => ` ${c.name}: '',`).join('\n');
|
||||
|
||||
const tableColumns = tableInfo.columns.map(c => {
|
||||
if (c.type.toLowerCase().includes('datetime') || c.type.toLowerCase().includes('timestamp')) {
|
||||
return ` <td>{{ formatDate(item.${c.name}) }}</td>`;
|
||||
}
|
||||
return ` <td>{{ item.${c.name} }}</td>`;
|
||||
}).join('\n');
|
||||
|
||||
const styleContent = `
|
||||
.generated-component-container {
|
||||
/* This is the root container, no extra styles needed */
|
||||
}
|
||||
|
||||
.actions-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.actions-bar h3 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.buttons-wrapper {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.add-button, .refresh-button {
|
||||
padding: 10px 15px;
|
||||
border: none;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.add-button {
|
||||
background-color: #007bff;
|
||||
}
|
||||
|
||||
.add-button:hover {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
|
||||
.refresh-button {
|
||||
background-color: #6c757d;
|
||||
}
|
||||
|
||||
.refresh-button:hover {
|
||||
background-color: #5a6268;
|
||||
}
|
||||
|
||||
.table-responsive-wrapper {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.custom-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 600px;
|
||||
}
|
||||
|
||||
.custom-table th, .custom-table td {
|
||||
border: 1px solid #dee2e6;
|
||||
padding: 12px 15px;
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.custom-table thead th {
|
||||
background-color: #e9ecef;
|
||||
color: #495057;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.custom-table tbody tr:nth-of-type(even) {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.custom-table tbody tr:hover {
|
||||
background-color: #e2e6ea;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
padding: 5px 10px;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
margin-right: 5px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.action-button.edit {
|
||||
background-color: #ffc107;
|
||||
}
|
||||
|
||||
.action-button.delete {
|
||||
background-color: #dc3545;
|
||||
}
|
||||
|
||||
/* Modal Styles */
|
||||
.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: 1050;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
padding: 25px;
|
||||
box-shadow: 0 5px 15px rgba(0,0,0,0.3);
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.modal-content h2 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 20px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.form-group input, .form-group textarea {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #ccc;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.submit-button, .cancel-button {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.submit-button {
|
||||
background-color: #28a745;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.cancel-button {
|
||||
background-color: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #dc3545;
|
||||
margin-top: 15px;
|
||||
}`;
|
||||
|
||||
return `<template>
|
||||
<div class="generated-component-container">
|
||||
<div class="actions-bar">
|
||||
<h3>${tableTitle}</h3>
|
||||
<div class="buttons-wrapper">
|
||||
<button @click="openModal()" class="add-button">新增${tableComment}</button>
|
||||
<button @click="fetchItems" class="refresh-button">刷新列表</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive-wrapper">
|
||||
<table class="custom-table">
|
||||
<thead>
|
||||
<tr>
|
||||
${tableInfo.columns.map(c => ` <th>${c.comment || c.name}</th>`).join('\n')}
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="loading">
|
||||
<td :colspan="${tableInfo.columns.length + 1}" style="text-align: center;">加载中...</td>
|
||||
</tr>
|
||||
<tr v-else-if="items.length === 0">
|
||||
<td :colspan="${tableInfo.columns.length + 1}" style="text-align: center;">暂无数据</td>
|
||||
</tr>
|
||||
<tr v-for="item in items" :key="item.${tableInfo.primaryKey}">
|
||||
${tableColumns}
|
||||
<td>
|
||||
<button @click="openModal(item)" class="action-button edit">编辑</button>
|
||||
<button @click="confirmDeleteItem(item.${tableInfo.primaryKey})" class="action-button delete">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 弹窗 -->
|
||||
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
|
||||
<div class="modal-content">
|
||||
<h2>{{ isEditing ? '编辑' : '新增' }}${tableComment}</h2>
|
||||
<form @submit.prevent="saveItem">
|
||||
${formFields}
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="submit-button">保存</button>
|
||||
<button type="button" @click="closeModal" class="cancel-button">取消</button>
|
||||
</div>
|
||||
<p v-if="editError" class="error-message">{{ editError }}</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import {
|
||||
get${componentName}List,
|
||||
get${componentName}ById,
|
||||
create${componentName},
|
||||
update${componentName},
|
||||
delete${componentName}
|
||||
} from '@/api/${tableInfo.tableName}';
|
||||
|
||||
const items = ref([]);
|
||||
const loading = ref(false);
|
||||
const showModal = ref(false);
|
||||
const isEditing = ref(false);
|
||||
const editableItem = ref({
|
||||
${dataFields}
|
||||
});
|
||||
const editError = ref('');
|
||||
|
||||
onMounted(() => {
|
||||
fetchItems();
|
||||
});
|
||||
|
||||
const fetchItems = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await get${componentName}List();
|
||||
items.value = response.data;
|
||||
} catch (error) {
|
||||
console.error('获取列表失败:', error);
|
||||
items.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const openModal = async (item = null) => {
|
||||
if (item) {
|
||||
isEditing.value = true;
|
||||
try {
|
||||
const response = await get${componentName}ById(item.${tableInfo.primaryKey});
|
||||
editableItem.value = response.data;
|
||||
} catch (error) {
|
||||
console.error('获取详情失败:', error);
|
||||
editableItem.value = { ...item };
|
||||
}
|
||||
} else {
|
||||
isEditing.value = false;
|
||||
editableItem.value = {
|
||||
${dataFields}
|
||||
};
|
||||
}
|
||||
editError.value = '';
|
||||
showModal.value = true;
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
showModal.value = false;
|
||||
};
|
||||
|
||||
const saveItem = async () => {
|
||||
editError.value = '';
|
||||
try {
|
||||
if (isEditing.value) {
|
||||
await update${componentName}(editableItem.value.${tableInfo.primaryKey}, editableItem.value);
|
||||
} else {
|
||||
await create${componentName}(editableItem.value);
|
||||
}
|
||||
closeModal();
|
||||
fetchItems();
|
||||
alert('操作成功!');
|
||||
} catch (error) {
|
||||
console.error('保存失败:', error);
|
||||
editError.value = error.response?.data?.message || '操作失败,请重试。';
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDeleteItem = async (id) => {
|
||||
if (window.confirm('确定要删除此项吗?')) {
|
||||
try {
|
||||
await delete${componentName}(id);
|
||||
fetchItems();
|
||||
alert('删除成功!');
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error);
|
||||
alert('删除失败: ' + (error.response?.data?.message || '请稍后重试'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 辅助函数
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return 'N/A';
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return isNaN(date.getTime()) ? dateString : date.toLocaleString();
|
||||
} catch (e) {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
</sc' + 'ript>
|
||||
|
||||
<style scoped>
|
||||
${styleContent}
|
||||
</style>`;
|
||||
};
|
||||
|
||||
const generateCode = () => {
|
||||
if (!sqlInput.value.trim()) {
|
||||
parsedTableInfo.value = null;
|
||||
generatedCode.value = '';
|
||||
generatedApiCode.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const tableInfo = parseSql(sqlInput.value);
|
||||
|
||||
if (tableInfo) {
|
||||
generatedCode.value = generateVueComponent(tableInfo);
|
||||
generatedFileName.value = `${toPascalCase(tableInfo.tableName)}.vue`;
|
||||
generatedApiCode.value = generateApiFile(tableInfo);
|
||||
generatedApiFileName.value = `${tableInfo.tableName}.js`;
|
||||
parsedTableInfo.value = tableInfo;
|
||||
} else {
|
||||
generatedCode.value = '';
|
||||
generatedFileName.value = '';
|
||||
generatedApiCode.value = '';
|
||||
generatedApiFileName.value = '';
|
||||
parsedTableInfo.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const downloadFile = (filename, content) => {
|
||||
if (!content) return;
|
||||
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
|
||||
const href = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = href;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(href);
|
||||
}
|
||||
|
||||
const downloadCode = () => {
|
||||
downloadFile(generatedFileName.value, generatedCode.value);
|
||||
};
|
||||
|
||||
const downloadApiFile = () => {
|
||||
downloadFile(generatedApiFileName.value, generatedApiCode.value);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.code-generator-container {
|
||||
display: flex;
|
||||
height: calc(100vh - 100px);
|
||||
background-color: #fff;
|
||||
border: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.editor-pane {
|
||||
flex: 1;
|
||||
padding: 25px;
|
||||
overflow-y: auto;
|
||||
border-right: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.preview-pane {
|
||||
flex: 1;
|
||||
padding: 25px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.title {
|
||||
color: #2c3e50;
|
||||
text-align: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: #555;
|
||||
text-align: center;
|
||||
margin-bottom: 25px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 6px;
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
box-sizing: border-box;
|
||||
resize: vertical;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
justify-content: center;
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
padding: 10px 20px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s, transform 0.1s;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.action-button:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.download-button {
|
||||
background-color: #007bff;
|
||||
}
|
||||
|
||||
.download-button:hover {
|
||||
background-color: #0069d9;
|
||||
}
|
||||
|
||||
.download-api-button {
|
||||
background-color: #28a745;
|
||||
}
|
||||
|
||||
.download-api-button:hover {
|
||||
background-color: #218838;
|
||||
}
|
||||
|
||||
.action-button:disabled {
|
||||
background-color: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.generated-code-preview {
|
||||
margin-top: 20px;
|
||||
border-top: 1px solid #eee;
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.preview-title, .preview-area-title {
|
||||
color: #333;
|
||||
margin-bottom: 15px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
pre {
|
||||
background-color: #2d2d2d;
|
||||
color: #f1f1f1;
|
||||
padding: 15px;
|
||||
border-radius: 6px;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Preview Pane Styles */
|
||||
.no-preview {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: #f8f9fa;
|
||||
color: #6c757d;
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.preview-content-wrapper {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.device-frame {
|
||||
/* The frame is now the container itself, no extra styles needed */
|
||||
}
|
||||
|
||||
/* Styles for the simulated component inside preview */
|
||||
.service-hall-container-preview .actions-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.service-hall-container-preview h3 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.service-hall-container-preview .buttons-wrapper {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.service-hall-container-preview .add-button,
|
||||
.service-hall-container-preview .refresh-button {
|
||||
padding: 10px 15px;
|
||||
border: none;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.service-hall-container-preview .add-button { background-color: #007bff; }
|
||||
.service-hall-container-preview .refresh-button { background-color: #6c757d; }
|
||||
|
||||
.service-hall-container-preview .table-responsive-wrapper {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.service-hall-container-preview .custom-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 600px;
|
||||
}
|
||||
|
||||
.service-hall-container-preview .custom-table th,
|
||||
.service-hall-container-preview .custom-table td {
|
||||
border: 1px solid #dee2e6;
|
||||
padding: 12px 15px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.service-hall-container-preview .custom-table thead th {
|
||||
background-color: #e9ecef;
|
||||
}
|
||||
|
||||
.service-hall-container-preview .action-button {
|
||||
padding: 5px 10px;
|
||||
border: none;
|
||||
color: white;
|
||||
margin-right: 5px;
|
||||
}
|
||||
.service-hall-container-preview .action-button.edit { background-color: #ffc107; }
|
||||
.service-hall-container-preview .action-button.delete { background-color: #dc3545; }
|
||||
|
||||
.modal-overlay-preview-active {
|
||||
position: relative;
|
||||
margin-top: 20px;
|
||||
padding: 20px;
|
||||
background-color: #f8f9fa;
|
||||
border: 1px solid #dee2e6;
|
||||
}
|
||||
.modal-content-preview {
|
||||
background: white;
|
||||
padding: 25px;
|
||||
border: 1px solid #ccc;
|
||||
box-shadow: 0 5px 15px rgba(0,0,0,0.1);
|
||||
width: 100%;
|
||||
}
|
||||
.modal-content-preview h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.form-group-preview {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.form-group-preview label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.form-group-preview input {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
.form-actions-preview {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.form-actions-preview .submit-button,
|
||||
.form-actions-preview .cancel-button {
|
||||
background-color: #28a745;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
}
|
||||
.form-actions-preview .cancel-button {
|
||||
background-color: #6c757d;
|
||||
}
|
||||
</style>
|
@ -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>
|
@ -1,328 +0,0 @@
|
||||
<template>
|
||||
<div class="player-list-container">
|
||||
<div class="actions-bar">
|
||||
<div class="filters">
|
||||
<label for="tournamentFilter">按赛事筛选:</label>
|
||||
<select id="tournamentFilter" v-model="selectedTournamentId" @change="fetchPlayers">
|
||||
<option :value="null">所有赛事</option>
|
||||
<option v-for="t in availableTournaments" :key="t.id" :value="t.id">{{ t.name }} (ID: {{ t.id }})</option>
|
||||
</select>
|
||||
</div>
|
||||
<button @click="showAddModal = true" class="add-button">添加参赛记录</button>
|
||||
</div>
|
||||
<div class="table-responsive-wrapper">
|
||||
<table class="custom-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>赛事ID</th>
|
||||
<th>赛事名称</th>
|
||||
<th>队伍名称</th>
|
||||
<th>报名者/队长</th>
|
||||
<th>胜</th>
|
||||
<th>负</th>
|
||||
<th>状态</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="loading">
|
||||
<td colspan="9" style="text-align: center;">加载中...</td>
|
||||
</tr>
|
||||
<tr v-else-if="filteredPlayers.length === 0">
|
||||
<td colspan="9" style="text-align: center;">暂无参赛记录</td>
|
||||
</tr>
|
||||
<tr v-for="player in filteredPlayers" :key="player.id">
|
||||
<td>{{ player.id }}</td>
|
||||
<td>{{ player.tournament_id }}</td>
|
||||
<td>{{ player.tournament_name }}</td>
|
||||
<td>{{ player.team_name || '-' }}</td>
|
||||
<td>{{ player.sign_name }}</td>
|
||||
<td>{{ player.win }}</td>
|
||||
<td>{{ player.lose }}</td>
|
||||
<td>{{ player.status }}</td>
|
||||
<td>
|
||||
<button @click="editPlayer(player)" class="action-button edit">编辑</button>
|
||||
<button @click="confirmDeletePlayer(player.id)" class="action-button delete">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<PlayerForm
|
||||
v-if="showAddModal || showEditModal"
|
||||
:visible="showAddModal || showEditModal"
|
||||
:playerData="currentPlayer"
|
||||
:isEditMode="showEditModal"
|
||||
:availableTournaments="availableTournaments"
|
||||
@close="closeModal"
|
||||
@save="handleSavePlayer"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { getSignUpResultList, deleteSignUpResult, getTournamentList } from '../../api/tournament';
|
||||
import PlayerForm from './PlayerForm.vue';
|
||||
|
||||
const allPlayers = ref([]);
|
||||
const loading = ref(false);
|
||||
const showAddModal = ref(false);
|
||||
const showEditModal = ref(false);
|
||||
const currentPlayer = ref(null);
|
||||
const availableTournaments = ref([]);
|
||||
const selectedTournamentId = ref(null);
|
||||
|
||||
const fetchTournamentsForFilter = async () => {
|
||||
try {
|
||||
const response = await getTournamentList();
|
||||
availableTournaments.value = response.data || response || [];
|
||||
if (!Array.isArray(availableTournaments.value)) {
|
||||
availableTournaments.value = [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取赛事列表 (for filter) 失败:', error);
|
||||
availableTournaments.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
const fetchPlayers = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await getSignUpResultList();
|
||||
allPlayers.value = response.data || response || [];
|
||||
if (!Array.isArray(allPlayers.value)) {
|
||||
console.warn('getSignUpResultList did not return an array:', allPlayers.value);
|
||||
allPlayers.value = [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取参赛记录列表失败:', error);
|
||||
allPlayers.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const filteredPlayers = computed(() => {
|
||||
if (!selectedTournamentId.value) {
|
||||
return allPlayers.value;
|
||||
}
|
||||
return allPlayers.value.filter(p => p.tournament_id === selectedTournamentId.value);
|
||||
});
|
||||
|
||||
const editPlayer = (player) => {
|
||||
currentPlayer.value = { ...player };
|
||||
showEditModal.value = true;
|
||||
};
|
||||
|
||||
const confirmDeletePlayer = async (id) => {
|
||||
if (window.confirm('确定要删除这条参赛记录吗?')) {
|
||||
try {
|
||||
await deleteSignUpResult(id);
|
||||
fetchPlayers();
|
||||
alert('参赛记录删除成功!');
|
||||
} catch (error) {
|
||||
console.error('删除参赛记录失败:', error);
|
||||
alert('删除参赛记录失败: ' + (error.message || '请稍后重试'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
showAddModal.value = false;
|
||||
showEditModal.value = false;
|
||||
currentPlayer.value = null;
|
||||
};
|
||||
|
||||
const handleSavePlayer = () => {
|
||||
closeModal();
|
||||
fetchPlayers();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchTournamentsForFilter();
|
||||
fetchPlayers();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.player-list-container {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.actions-bar {
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.actions-bar .filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.actions-bar label {
|
||||
font-size: 0.9rem;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.actions-bar select,
|
||||
.actions-bar .add-button,
|
||||
.actions-bar .refresh-button {
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #ccc;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.actions-bar .add-button,
|
||||
.actions-bar .refresh-button {
|
||||
background-color: #1890ff;
|
||||
color: white;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.actions-bar .add-button:hover,
|
||||
.actions-bar .refresh-button:hover {
|
||||
background-color: #40a9ff;
|
||||
}
|
||||
|
||||
.table-responsive-wrapper {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.custom-table {
|
||||
width: 100%;
|
||||
min-width: 800px;
|
||||
border-collapse: collapse;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.08), 0 1px 2px rgba(0,0,0,0.12);
|
||||
}
|
||||
|
||||
.custom-table th, .custom-table td {
|
||||
border: 1px solid #e8e8e8;
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
font-size: 0.85rem;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.custom-table th {
|
||||
background-color: #fafafa;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.custom-table tbody tr:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
padding: 5px 8px;
|
||||
border-radius: 3px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
margin-right: 5px;
|
||||
color: white;
|
||||
}
|
||||
.action-button.edit { background-color: #52c41a; }
|
||||
.action-button.edit:hover { background-color: #73d13d; }
|
||||
.action-button.delete { background-color: #ff4d4f; }
|
||||
.action-button.delete:hover { background-color: #ff7875; }
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
}
|
||||
.modal-content {
|
||||
background-color: white;
|
||||
padding: 20px;
|
||||
border-radius: 4px;
|
||||
width: 90%;
|
||||
max-width: 550px;
|
||||
}
|
||||
.modal-content h2 { font-size: 1.3rem; margin-bottom: 15px; }
|
||||
.form-group { margin-bottom: 12px; }
|
||||
.form-group label { font-size: 0.85rem; margin-bottom: 4px; }
|
||||
.form-group input[type="text"],
|
||||
.form-group input[type="number"],
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
padding: 8px 10px;
|
||||
font-size: 0.85rem;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.form-actions { margin-top: 20px; }
|
||||
.submit-button, .cancel-button {
|
||||
padding: 8px 15px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.actions-bar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
.actions-bar .filters {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.actions-bar .buttons-group {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.actions-bar .add-button,
|
||||
.actions-bar .refresh-button {
|
||||
flex-grow: 1;
|
||||
margin: 0 5px;
|
||||
}
|
||||
.actions-bar .add-button:first-child { margin-left: 0; }
|
||||
.actions-bar .refresh-button:last-child { margin-right: 0; }
|
||||
|
||||
.custom-table {
|
||||
min-width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.actions-bar .filters {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.actions-bar .filters label,
|
||||
.actions-bar .filters select {
|
||||
width: 100%;
|
||||
margin-right: 0;
|
||||
}
|
||||
.actions-bar .buttons-group button {
|
||||
padding: 8px 10px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.modal-content {
|
||||
padding: 15px;
|
||||
}
|
||||
.modal-content h2 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
}
|
||||
</style>
|
@ -1,530 +0,0 @@
|
||||
<template>
|
||||
<div class="service-hall-container">
|
||||
<div class="actions-bar">
|
||||
<h3>办事大厅</h3>
|
||||
<div class="buttons-wrapper">
|
||||
<button @click="showAddDemandModal = true" class="add-button">添加需求</button>
|
||||
<button @click="fetchDemandsAdmin" class="refresh-button">刷新列表</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive-wrapper">
|
||||
<table class="custom-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>请求者</th>
|
||||
<th>QQ号</th>
|
||||
<th>请求内容</th>
|
||||
<th>悬赏金额</th>
|
||||
<th>创建日期</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="loading">
|
||||
<td :colspan="7" style="text-align: center;">加载中...</td>
|
||||
</tr>
|
||||
<tr v-else-if="demands.length === 0">
|
||||
<td :colspan="7" style="text-align: center;">暂无需求数据</td>
|
||||
</tr>
|
||||
<tr v-for="demand in demands" :key="demand.id">
|
||||
<td>{{ demand.id }}</td>
|
||||
<td>{{ demand.requester || '-' }}</td>
|
||||
<td>{{ demand.qq_code || '-' }}</td>
|
||||
<td class="content-cell" :title="demand.content">{{ truncateText(demand.content, 50) }}</td>
|
||||
<td>{{ demand.reward || '无赏金' }}</td>
|
||||
<td>{{ formatDate(demand.date) }}</td>
|
||||
<td>
|
||||
<button @click="editDemand(demand)" class="action-button edit">编辑</button>
|
||||
<button @click="confirmDeleteDemand(demand.id)" class="action-button delete">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 编辑需求模态框 -->
|
||||
<div v-if="showEditDemandModal" class="modal-overlay" @click.self="closeEditModal">
|
||||
<div class="modal-content">
|
||||
<h2>编辑需求</h2>
|
||||
<form @submit.prevent="submitEditForm">
|
||||
<div class="form-group">
|
||||
<label :for="`edit-requester-${currentDemand.id}`">请求者:</label>
|
||||
<input :id="`edit-requester-${currentDemand.id}`" type="text" v-model="editForm.requester">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label :for="`edit-qq-${currentDemand.id}`">QQ号:</label>
|
||||
<input :id="`edit-qq-${currentDemand.id}`" type="text" v-model="editForm.qq_code">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label :for="`edit-content-${currentDemand.id}`">需求内容:</label>
|
||||
<textarea :id="`edit-content-${currentDemand.id}`" v-model="editForm.sendcontent" rows="4" required></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label :for="`edit-reward-${currentDemand.id}`">悬赏金额:</label>
|
||||
<input :id="`edit-reward-${currentDemand.id}`" type="text" v-model="editForm.reward">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label :for="`edit-date-${currentDemand.id}`">日期 (YYYY-MM-DD HH:MM:SS):</label>
|
||||
<input :id="`edit-date-${currentDemand.id}`" type="text" v-model="editForm.date" readonly>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="submit-button">更新</button>
|
||||
<button type="button" @click="closeEditModal" class="cancel-button">取消</button>
|
||||
</div>
|
||||
<p v-if="editError" class="error-message">{{ editError }}</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 添加需求模态框 -->
|
||||
<div v-if="showAddDemandModal" class="modal-overlay" @click.self="closeAddModal">
|
||||
<div class="modal-content">
|
||||
<h2>添加新需求</h2>
|
||||
<form @submit.prevent="submitAddForm">
|
||||
<div class="form-group">
|
||||
<label for="add-requester">请求者 (可选):</label>
|
||||
<input id="add-requester" type="text" v-model="addForm.requester">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="add-qq">QQ号 (可选):</label>
|
||||
<input id="add-qq" type="text" v-model="addForm.qq_code">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="add-content">需求内容:</label>
|
||||
<textarea id="add-content" v-model="addForm.sendcontent" rows="4" required></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="add-reward">悬赏金额 (可选):</label>
|
||||
<input id="add-reward" type="text" v-model="addForm.reward">
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="submit-button" :disabled="addLoading">{{ addLoading ? '提交中...' : '提交' }}</button>
|
||||
<button type="button" @click="closeAddModal" class="cancel-button">取消</button>
|
||||
</div>
|
||||
<p v-if="addError" class="error-message">{{ addError }}</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ErrorDialog :visible="showErrorDialog" :message="errorDialogMsg" @close="showErrorDialog = false" />
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { getDemandsList, updateDemand, deleteDemand, addDemand } from '../../api/demands'; // 确认路径
|
||||
import ErrorDialog from '@/components/ErrorDialog.vue'
|
||||
|
||||
const demands = ref([]);
|
||||
const loading = ref(false);
|
||||
const showEditDemandModal = ref(false);
|
||||
const currentDemand = ref(null);
|
||||
const editForm = ref({});
|
||||
const editError = ref('');
|
||||
|
||||
// Refs for Add Demand Modal
|
||||
const showAddDemandModal = ref(false);
|
||||
const addLoading = ref(false);
|
||||
const initialAddFormState = {
|
||||
requester: '',
|
||||
qq_code: '',
|
||||
sendcontent: '',
|
||||
reward: ''
|
||||
// date is generated on submit
|
||||
};
|
||||
const addForm = ref({ ...initialAddFormState });
|
||||
const addError = ref('');
|
||||
|
||||
const showErrorDialog = ref(false)
|
||||
const errorDialogMsg = ref('')
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return 'N/A';
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
if (isNaN(date.getTime())) return dateString; // 如果日期无效,返回原始字符串
|
||||
return date.toLocaleString(); // 或者更复杂的格式化
|
||||
} catch (e) {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
const truncateText = (text, length) => {
|
||||
if (!text) return '';
|
||||
return text.length > length ? text.substring(0, length) + '...' : text;
|
||||
};
|
||||
|
||||
const fetchDemandsAdmin = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await getDemandsList();
|
||||
demands.value = Array.isArray(response) ? response : (response.data || []);
|
||||
} catch (error) {
|
||||
console.error('获取需求列表失败 (admin):', error);
|
||||
demands.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const editDemand = (demand) => {
|
||||
currentDemand.value = { ...demand };
|
||||
// 确保 editForm 使用 demand 中的原始 date 字符串,而不是格式化后的
|
||||
editForm.value = {
|
||||
...demand,
|
||||
sendcontent: demand.content || demand.sendcontent // API中content和sendcontent可能混用
|
||||
};
|
||||
editError.value = '';
|
||||
showEditDemandModal.value = true;
|
||||
};
|
||||
|
||||
const closeEditModal = () => {
|
||||
showEditDemandModal.value = false;
|
||||
currentDemand.value = null;
|
||||
editError.value = '';
|
||||
};
|
||||
|
||||
const submitEditForm = async () => {
|
||||
if (!currentDemand.value || !currentDemand.value.id) return;
|
||||
editError.value = '';
|
||||
try {
|
||||
const payload = {
|
||||
requester: editForm.value.requester,
|
||||
qq_code: editForm.value.qq_code,
|
||||
sendcontent: editForm.value.sendcontent,
|
||||
reward: editForm.value.reward,
|
||||
date: editForm.value.date
|
||||
};
|
||||
await updateDemand(currentDemand.value.id, payload);
|
||||
alert('需求更新成功!');
|
||||
closeEditModal();
|
||||
fetchDemandsAdmin();
|
||||
} catch (error) {
|
||||
errorDialogMsg.value = error.response?.data?.detail || error.message || '更新失败,请重试。';
|
||||
showErrorDialog.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDeleteDemand = async (id) => {
|
||||
if (window.confirm('确定要删除此需求吗?')) {
|
||||
try {
|
||||
await deleteDemand(id);
|
||||
alert('需求删除成功!');
|
||||
fetchDemandsAdmin();
|
||||
} catch (error) {
|
||||
errorDialogMsg.value = error.response?.data?.detail || error.message || '删除失败,请稍后重试';
|
||||
showErrorDialog.value = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// --- Add Demand Modal Logic ---
|
||||
const openAddModal = () => {
|
||||
addForm.value = { ...initialAddFormState };
|
||||
addError.value = '';
|
||||
showAddDemandModal.value = true;
|
||||
};
|
||||
|
||||
const closeAddModal = () => {
|
||||
showAddDemandModal.value = false;
|
||||
addError.value = '';
|
||||
};
|
||||
|
||||
const submitAddForm = async () => {
|
||||
if (!addForm.value.sendcontent.trim()) {
|
||||
addError.value = '需求内容不能为空';
|
||||
return;
|
||||
}
|
||||
addLoading.value = true;
|
||||
addError.value = '';
|
||||
try {
|
||||
const now = new Date();
|
||||
const dateStr = `${now.getFullYear()}-${(now.getMonth() + 1).toString().padStart(2, '0')}-${now.getDate().toString().padStart(2, '0')} ${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}:${now.getSeconds().toString().padStart(2, '0')}`;
|
||||
|
||||
const payload = {
|
||||
...addForm.value,
|
||||
date: dateStr,
|
||||
// content will be handled by addDemand API function if sendcontent is provided
|
||||
};
|
||||
await addDemand(payload);
|
||||
alert('新需求添加成功!');
|
||||
closeAddModal();
|
||||
fetchDemandsAdmin(); // Refresh list
|
||||
} catch (error) {
|
||||
console.error('添加需求失败:', error);
|
||||
addError.value = error.response?.data?.detail || error.message || '添加失败,请稍后重试。';
|
||||
} finally {
|
||||
addLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchDemandsAdmin();
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.service-hall-container {
|
||||
padding: 15px; /* Reduced padding for mobile */
|
||||
}
|
||||
|
||||
.actions-bar {
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
flex-wrap: wrap; /* Allow wrapping */
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 10px; /* Add gap for wrapped items */
|
||||
}
|
||||
.actions-bar h3 {
|
||||
margin: 0;
|
||||
font-size: 1.4rem; /* Slightly reduced for mobile */
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* Container for buttons on the right */
|
||||
.actions-bar .buttons-wrapper {
|
||||
display: flex;
|
||||
gap: 10px; /* Space between buttons */
|
||||
}
|
||||
|
||||
.refresh-button, .add-button {
|
||||
background-color: #1890ff;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 12px; /* Slightly reduced padding */
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem; /* Slightly reduced font size */
|
||||
}
|
||||
.refresh-button:hover, .add-button:hover {
|
||||
background-color: #40a9ff;
|
||||
}
|
||||
|
||||
.table-responsive-wrapper { /* New wrapper for table */
|
||||
width: 100%;
|
||||
overflow-x: auto; /* Enable horizontal scrolling */
|
||||
-webkit-overflow-scrolling: touch; /* Smoother scrolling on iOS */
|
||||
}
|
||||
|
||||
.custom-table {
|
||||
width: 100%;
|
||||
min-width: 750px; /* Minimum width before scrolling starts, adjust as needed */
|
||||
border-collapse: collapse;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.08), 0 1px 2px rgba(0,0,0,0.12); /* Softer shadow */
|
||||
}
|
||||
|
||||
.custom-table th, .custom-table td {
|
||||
border: 1px solid #e8e8e8;
|
||||
padding: 10px 12px; /* Adjusted padding */
|
||||
text-align: left;
|
||||
font-size: 0.85rem; /* Adjusted font size */
|
||||
vertical-align: middle;
|
||||
/* white-space: nowrap; /* Consider if content should wrap or not. Ellipsis is handled by truncateText */
|
||||
}
|
||||
|
||||
.custom-table th {
|
||||
background-color: #fafafa;
|
||||
font-weight: 600;
|
||||
color: #000000d9;
|
||||
white-space: nowrap; /* Keep headers on one line */
|
||||
}
|
||||
|
||||
.custom-table tbody tr:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.content-cell {
|
||||
max-width: 250px; /* Adjust as needed */
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
padding: 5px 8px; /* Adjusted padding */
|
||||
border-radius: 3px; /* Adjusted border-radius */
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem; /* Adjusted font size */
|
||||
margin-right: 5px;
|
||||
color: white;
|
||||
white-space: nowrap; /* Keep button text on one line */
|
||||
}
|
||||
.action-button.edit {
|
||||
background-color: #52c41a;
|
||||
}
|
||||
.action-button.edit:hover {
|
||||
background-color: #73d13d;
|
||||
}
|
||||
.action-button.delete {
|
||||
background-color: #ff4d4f;
|
||||
}
|
||||
.action-button.delete:hover {
|
||||
background-color: #ff7875;
|
||||
}
|
||||
|
||||
/* Modal Styles */
|
||||
.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;
|
||||
padding: 10px; /* Add padding for very small screens */
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background-color: white;
|
||||
padding: 20px; /* Adjusted padding */
|
||||
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: 15px;
|
||||
color: #333;
|
||||
font-size: 1.3rem; /* Adjusted font size */
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 12px; /* Adjusted margin */
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-size: 0.85rem; /* Adjusted font size */
|
||||
font-weight: 500;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.form-group input[type="text"],
|
||||
.form-group textarea {
|
||||
padding: 8px 10px; /* Adjusted padding */
|
||||
font-size: 0.85rem; /* Adjusted font size */
|
||||
width: 100%; /* Ensure form elements take full width */
|
||||
box-sizing: border-box; /* Include padding and border in the element's total width and height */
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.form-group input[type="text"]:focus,
|
||||
.form-group textarea:focus {
|
||||
border-color: #1890ff;
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
margin-top: 20px; /* Adjusted margin */
|
||||
}
|
||||
|
||||
.submit-button, .cancel-button {
|
||||
padding: 8px 15px; /* Adjusted padding */
|
||||
font-size: 0.85rem; /* Adjusted font size */
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.actions-bar {
|
||||
flex-direction: column; /* Stack title and buttons vertically */
|
||||
align-items: flex-start; /* Align items to the start */
|
||||
}
|
||||
.actions-bar h3 {
|
||||
margin-bottom: 10px; /* Add space below title when stacked */
|
||||
}
|
||||
.actions-bar .buttons-wrapper { /* Container for buttons */
|
||||
width: 100%; /* Full width on mobile */
|
||||
/* justify-content: space-between; /* Remove this if you don't want them to spread far apart */
|
||||
/* Consider justify-content: flex-start; or let gap handle spacing */
|
||||
}
|
||||
.refresh-button, .add-button {
|
||||
/* flex-grow: 1; /* Remove flex-grow if buttons should take natural width */
|
||||
/* margin: 0 5px; /* Remove or adjust margin, gap is now on buttons-wrapper */
|
||||
/* width: auto; /* Ensure buttons take their content width or a defined width */
|
||||
padding: 8px 15px; /* Ensure decent padding */
|
||||
}
|
||||
/* If you want them to still be somewhat spread on mobile but not full flex-grow:
|
||||
.actions-bar .buttons-wrapper {
|
||||
display: flex;
|
||||
justify-content: flex-end; /* Aligns buttons to the right on mobile when stacked
|
||||
}
|
||||
.refresh-button, .add-button {
|
||||
margin-left: 10px;
|
||||
}
|
||||
.refresh-button:first-child, .add-button:first-child { margin-left: 0; }
|
||||
*/
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.modal-content {
|
||||
padding: 15px;
|
||||
}
|
||||
.modal-content h2 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
.actions-bar .buttons-wrapper button { /* Target buttons within the wrapper more specifically if needed */
|
||||
padding: 8px 10px; /* Even smaller padding for very small buttons */
|
||||
font-size: 0.8rem;
|
||||
/* width: 100%; /* Optionally make buttons full width on very small screens */
|
||||
/* margin-bottom: 5px; /* If they stack vertically */
|
||||
}
|
||||
/* If buttons stack vertically on very small screens (example):
|
||||
.actions-bar .buttons-wrapper {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
.actions-bar .buttons-wrapper button {
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.actions-bar .buttons-wrapper button:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
*/
|
||||
|
||||
.content-cell {
|
||||
max-width: 100px; /* Further reduce for very small screens */
|
||||
}
|
||||
}
|
||||
</style>
|
@ -1,210 +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="name">赛事名称:</label>
|
||||
<input type="text" id="name" v-model="form.name" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="format">赛事类型:</label>
|
||||
<select id="format" v-model="form.format" required>
|
||||
<option value="single">单人赛 (single)</option>
|
||||
<option value="double">双人赛 (double)</option>
|
||||
<option value="count">计数赛 (count)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="organizer">组织者:</label>
|
||||
<input type="text" id="organizer" v-model="form.organizer" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="qq_code">组织者QQ:</label>
|
||||
<input type="text" id="qq_code" v-model="form.qq_code" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="status">状态:</label>
|
||||
<select id="status" v-model="form.status" required>
|
||||
<option value="prepare">准备中 (prepare)</option>
|
||||
<option value="starting">进行中 (starting)</option>
|
||||
<option value="finish">已结束 (finish)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="start_time">开始时间 (年/月/日):</label>
|
||||
<input type="text" id="start_time" v-model="form.start_time" placeholder="例如: 2025/05/24" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="end_time">结束时间 (年/月/日):</label>
|
||||
<input type="text" id="end_time" v-model="form.end_time" placeholder="例如: 2025/06/24" required>
|
||||
</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 { addTournament, updateTournament } from '../../api/tournament';
|
||||
|
||||
const props = defineProps({
|
||||
visible: Boolean,
|
||||
tournamentData: Object, // 用于编辑模式
|
||||
isEditMode: Boolean
|
||||
});
|
||||
|
||||
const emits = defineEmits(['close', 'save']);
|
||||
|
||||
const initialFormState = {
|
||||
name: '',
|
||||
format: 'single',
|
||||
organizer: '',
|
||||
qq_code: '',
|
||||
status: 'prepare',
|
||||
start_time: '',
|
||||
end_time: ''
|
||||
};
|
||||
|
||||
const form = ref({ ...initialFormState });
|
||||
const errorMessage = ref('');
|
||||
|
||||
watch(() => props.tournamentData, (newData) => {
|
||||
if (newData && props.isEditMode) {
|
||||
form.value = { ...newData };
|
||||
} else {
|
||||
form.value = { ...initialFormState };
|
||||
}
|
||||
}, { immediate: true, deep: true });
|
||||
|
||||
const submitForm = async () => {
|
||||
errorMessage.value = '';
|
||||
try {
|
||||
if (props.isEditMode) {
|
||||
// API updateTournament 需要 id 和 data
|
||||
const { id, ...dataToUpdate } = form.value;
|
||||
await updateTournament(id, dataToUpdate);
|
||||
alert('赛事更新成功!');
|
||||
} else {
|
||||
await addTournament(form.value);
|
||||
alert('赛事添加成功!');
|
||||
}
|
||||
emits('save');
|
||||
close();
|
||||
} catch (error) {
|
||||
console.error('保存赛事失败:', error);
|
||||
errorMessage.value = error.response?.data?.detail || error.message || '操作失败,请检查数据或联系管理员。';
|
||||
// alert('保存赛事失败: ' + (error.message || '请稍后重试'));
|
||||
}
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
emits('close');
|
||||
form.value = { ...initialFormState }; // 重置表单
|
||||
errorMessage.value = '';
|
||||
};
|
||||
|
||||
</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: 500px;
|
||||
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 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 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>
|
@ -1,294 +0,0 @@
|
||||
<template>
|
||||
<div class="tournament-list-container">
|
||||
<div class="actions-bar">
|
||||
<h3>赛事列表</h3>
|
||||
<div>
|
||||
<button @click="showAddModal = true" class="add-button">添加赛事</button>
|
||||
<button @click="fetchTournaments" class="refresh-button" :disabled="loading">刷新列表</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive-wrapper">
|
||||
<table class="custom-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>赛事名称</th>
|
||||
<th>类型</th>
|
||||
<th>组织者</th>
|
||||
<th>QQ号</th>
|
||||
<th>状态</th>
|
||||
<th>开始时间</th>
|
||||
<th>结束时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="loading">
|
||||
<td colspan="9" style="text-align: center;">加载中...</td>
|
||||
</tr>
|
||||
<tr v-else-if="tournaments.length === 0">
|
||||
<td colspan="9" style="text-align: center;">暂无赛事数据</td>
|
||||
</tr>
|
||||
<tr v-for="tournament in tournaments" :key="tournament.id">
|
||||
<td>{{ tournament.id }}</td>
|
||||
<td>{{ tournament.name }}</td>
|
||||
<td>{{ tournament.format }}</td>
|
||||
<td>{{ tournament.organizer }}</td>
|
||||
<td>{{ tournament.qq_code }}</td>
|
||||
<td>{{ tournament.status }}</td>
|
||||
<td>{{ tournament.start_time }}</td>
|
||||
<td>{{ tournament.end_time }}</td>
|
||||
<td>
|
||||
<button @click="editTournament(tournament)" class="action-button edit">编辑</button>
|
||||
<button @click="confirmDeleteTournament(tournament.id)" class="action-button delete">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 添加/编辑 模态框 -->
|
||||
<TournamentForm
|
||||
v-if="showAddModal || showEditModal"
|
||||
:visible="showAddModal || showEditModal"
|
||||
:tournamentData="currentTournament"
|
||||
:isEditMode="showEditModal"
|
||||
@close="closeModal"
|
||||
@save="handleSaveTournament"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { getTournamentList, deleteTournament } from '../../api/tournament'; // 路径可能需要根据实际调整
|
||||
import TournamentForm from './TournamentForm.vue'; // 引入表单组件
|
||||
|
||||
const tournaments = ref([]);
|
||||
const loading = ref(false);
|
||||
const showAddModal = ref(false);
|
||||
const showEditModal = ref(false);
|
||||
const currentTournament = ref(null);
|
||||
|
||||
const fetchTournaments = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await getTournamentList();
|
||||
tournaments.value = response.data || response; // API 返回结构可能是 {data: []} 或直接是 []
|
||||
if (!Array.isArray(tournaments.value)) {
|
||||
console.warn('getTournamentList did not return an array:', tournaments.value);
|
||||
tournaments.value = []; // 确保是数组
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取赛事列表失败:', error);
|
||||
tournaments.value = []; // 出错时置为空数组
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const editTournament = (tournament) => {
|
||||
currentTournament.value = { ...tournament };
|
||||
showEditModal.value = true;
|
||||
};
|
||||
|
||||
const confirmDeleteTournament = async (id) => {
|
||||
if (window.confirm('确定要删除这项赛事吗?')) {
|
||||
try {
|
||||
await deleteTournament(id);
|
||||
fetchTournaments(); // 重新加载列表
|
||||
alert('赛事删除成功!');
|
||||
} catch (error) {
|
||||
console.error('删除赛事失败:', error);
|
||||
alert('删除赛事失败: ' + (error.message || '请稍后重试'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
showAddModal.value = false;
|
||||
showEditModal.value = false;
|
||||
currentTournament.value = null;
|
||||
};
|
||||
|
||||
const handleSaveTournament = () => {
|
||||
closeModal();
|
||||
fetchTournaments(); // 保存后刷新列表
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchTournaments();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tournament-list-container {
|
||||
padding: 15px; /* Reduced padding for mobile */
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.actions-bar {
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
flex-wrap: wrap; /* Allow wrapping on small screens */
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 10px; /* Add gap for wrapped items */
|
||||
}
|
||||
.actions-bar h3 {
|
||||
margin: 0;
|
||||
font-size: 1.4rem; /* Slightly reduced for mobile */
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.add-button, .refresh-button {
|
||||
background-color: #1890ff;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 12px; /* Slightly reduced padding */
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem; /* Slightly reduced font size */
|
||||
}
|
||||
.add-button:hover, .refresh-button:hover {
|
||||
background-color: #40a9ff;
|
||||
}
|
||||
|
||||
.table-responsive-wrapper { /* New wrapper for table */
|
||||
width: 100%;
|
||||
overflow-x: auto; /* Enable horizontal scrolling */
|
||||
-webkit-overflow-scrolling: touch; /* Smoother scrolling on iOS */
|
||||
}
|
||||
|
||||
.custom-table {
|
||||
width: 100%;
|
||||
min-width: 700px; /* Minimum width before scrolling starts, adjust as needed */
|
||||
border-collapse: collapse;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.08), 0 1px 2px rgba(0,0,0,0.12); /* Softer shadow */
|
||||
}
|
||||
|
||||
.custom-table th, .custom-table td {
|
||||
border: 1px solid #e8e8e8;
|
||||
padding: 10px 12px; /* Adjusted padding */
|
||||
text-align: left;
|
||||
font-size: 0.85rem; /* Adjusted font size */
|
||||
vertical-align: middle;
|
||||
white-space: nowrap; /* Prevent content wrapping that might break layout */
|
||||
}
|
||||
.custom-table th {
|
||||
background-color: #fafafa;
|
||||
font-weight: 600;
|
||||
color: #000000d9;
|
||||
}
|
||||
|
||||
.custom-table tbody tr:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
padding: 5px 8px; /* Adjusted padding */
|
||||
border-radius: 3px; /* Adjusted border-radius */
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem; /* Adjusted font size */
|
||||
margin-right: 5px;
|
||||
color: white;
|
||||
}
|
||||
.action-button.edit {
|
||||
background-color: #52c41a;
|
||||
}
|
||||
.action-button.edit:hover {
|
||||
background-color: #73d13d;
|
||||
}
|
||||
.action-button.delete {
|
||||
background-color: #ff4d4f;
|
||||
}
|
||||
.action-button.delete:hover {
|
||||
background-color: #ff7875;
|
||||
}
|
||||
|
||||
/* Modal Styles - generally okay, ensure max-width and percentage width */
|
||||
.modal-overlay {
|
||||
/* ... existing styles ... */
|
||||
padding: 10px; /* Add padding for very small screens */
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
/* ... existing styles ... */
|
||||
width: 90%;
|
||||
max-width: 500px; /* Max width for larger screens */
|
||||
padding: 20px; /* Adjusted padding */
|
||||
}
|
||||
|
||||
.modal-content h2 {
|
||||
font-size: 1.3rem; /* Adjusted font size */
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 12px; /* Adjusted margin */
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-size: 0.85rem; /* Adjusted font size */
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.form-group input[type="text"],
|
||||
.form-group input[type="datetime-local"],
|
||||
.form-group textarea {
|
||||
padding: 8px 10px; /* Adjusted padding */
|
||||
font-size: 0.85rem; /* Adjusted font size */
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
margin-top: 20px; /* Adjusted margin */
|
||||
}
|
||||
|
||||
.submit-button, .cancel-button {
|
||||
padding: 8px 15px; /* Adjusted padding */
|
||||
font-size: 0.85rem; /* Adjusted font size */
|
||||
}
|
||||
|
||||
/* Responsive adjustments specifically for smaller screens if needed beyond table scroll */
|
||||
@media (max-width: 768px) {
|
||||
.actions-bar {
|
||||
flex-direction: column; /* Stack title and buttons vertically */
|
||||
align-items: flex-start; /* Align items to the start */
|
||||
}
|
||||
.actions-bar h3 {
|
||||
margin-bottom: 10px; /* Add space below title when stacked */
|
||||
}
|
||||
.actions-bar div { /* Container for buttons */
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between; /* Space out buttons if two, or start if one */
|
||||
}
|
||||
.add-button, .refresh-button {
|
||||
flex-grow: 1; /* Allow buttons to grow if space */
|
||||
margin: 0 5px;
|
||||
}
|
||||
.add-button:first-child { margin-left: 0; }
|
||||
.add-button:last-child { margin-right: 0; }
|
||||
|
||||
/* Further fine-tuning for table cells if absolutely necessary, but scrolling is primary */
|
||||
.custom-table th, .custom-table td {
|
||||
/* font-size: 0.8rem; /* Example: even smaller font if needed */
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.modal-content {
|
||||
padding: 15px;
|
||||
}
|
||||
.modal-content h2 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
.actions-bar div button {
|
||||
padding: 8px 10px; /* Even smaller padding for very small buttons */
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
@ -76,6 +76,21 @@ const routes = [
|
||||
path: 'editors-maps',
|
||||
name: 'EditorsMaps',
|
||||
component: () => import('@/views/index/EditorsMaps.vue')
|
||||
},
|
||||
{
|
||||
path:'terrain',
|
||||
name: 'Terrain',
|
||||
component: () => import('@/views/index/TerrainList.vue')
|
||||
},
|
||||
{
|
||||
path: 'PIC2TGA',
|
||||
name: 'PIC2TGA',
|
||||
component: () => import('@/views/index/PIC2TGA.vue')
|
||||
},
|
||||
{
|
||||
path: 'terrainGenerate',
|
||||
name: 'TerrainGenerate',
|
||||
component: () => import('@/views/index/TerrainGenerate.vue')
|
||||
}
|
||||
]
|
||||
},
|
||||
|
11
src/utils/privilege.js
Normal file
11
src/utils/privilege.js
Normal file
@ -0,0 +1,11 @@
|
||||
// 权限辅助函数
|
||||
export function hasPrivilege(privilege, required) {
|
||||
if (!privilege) return false;
|
||||
// lv-admin 拥有全部权限
|
||||
if (privilege.includes('lv-admin')) return true;
|
||||
const privArr = privilege.split(';');
|
||||
if (Array.isArray(required)) {
|
||||
return required.some(r => privArr.includes(r));
|
||||
}
|
||||
return privArr.includes(required);
|
||||
}
|
@ -5,21 +5,60 @@
|
||||
<button @click="isMobileSidebarOpen = !isMobileSidebarOpen" class="hamburger-button">
|
||||
<span class="hamburger-icon"></span>
|
||||
</button>
|
||||
<span class="mobile-header-title">后台管理</span>
|
||||
<span class="mobile-header-title">管理后台</span>
|
||||
</div>
|
||||
<div class="admin-sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h3>后台管理</h3>
|
||||
<h3>管理后台</h3>
|
||||
</div>
|
||||
<ul class="sidebar-nav">
|
||||
<li @click="selectAdminView('event-management')" :class="{ active: currentAdminView === 'event-management' }"><a>赛事管理</a></li>
|
||||
<li @click="selectAdminView('player-management')" :class="{ active: currentAdminView === 'player-management' }"><a>赛事玩家管理</a></li>
|
||||
<li @click="selectAdminView('service-hall')" :class="{ active: currentAdminView === 'service-hall' }"><a>办事大厅</a></li>
|
||||
<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>
|
||||
</div>
|
||||
<ul v-show="dropdownOpen" style="list-style:none;padding-left:10px;">
|
||||
<li :class="{active: currentAdminView === 'permission-review'}">
|
||||
<a @click="selectAdminView('permission-review')">权限审核</a>
|
||||
</li>
|
||||
<li :class="{active: currentAdminView === 'user-management'}">
|
||||
<a @click="selectAdminView('user-management')">用户管理</a>
|
||||
</li>
|
||||
<li :class="{active: currentAdminView === 'admin-edit-user-privilege'}">
|
||||
<a @click="selectAdminView('admin-edit-user-privilege')">管理员修改用户权限</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<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>
|
||||
</div>
|
||||
<ul v-show="dropdownOpen2" style="list-style:none;padding-left:10px;">
|
||||
<li :class="{active: currentAdminView === 'event-info-management'}">
|
||||
<a @click="selectAdminView('event-info-management')">赛事信息管理</a>
|
||||
</li>
|
||||
<li :class="{active: currentAdminView === 'player-management'}">
|
||||
<a @click="selectAdminView('player-management')">玩家管理</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<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>
|
||||
</div>
|
||||
<ul v-show="dropdownOpen3" style="list-style:none;padding-left:10px;">
|
||||
<li :class="{active: currentAdminView === 'affair-management'}">
|
||||
<a @click="selectAdminView('affair-management')">事项管理</a>
|
||||
</li>
|
||||
</ul>
|
||||
</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>
|
||||
@ -29,73 +68,74 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-main-content">
|
||||
<div v-if="currentAdminView === 'event-management'">
|
||||
<TournamentList />
|
||||
</div>
|
||||
<div v-else-if="currentAdminView === 'player-management'">
|
||||
<PlayerList />
|
||||
</div>
|
||||
<div v-else-if="currentAdminView === 'service-hall'">
|
||||
<ServiceHallView />
|
||||
</div>
|
||||
<div v-else-if="currentAdminView === 'code-generator'">
|
||||
<CodeGenerator />
|
||||
</div>
|
||||
<AdminEditUserPrivilege v-if="currentAdminView === 'admin-edit-user-privilege'" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { ref, onMounted, computed, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import TournamentList from '../../components/backend/TournamentList.vue'
|
||||
import PlayerList from '../../components/backend/PlayerList.vue'
|
||||
import ServiceHallView from '../../components/backend/ServiceHallView.vue'
|
||||
import CodeGenerator from '../../components/backend/CodeGenerator.vue'
|
||||
import { getUserInfo } from '@/utils/jwt'
|
||||
import AdminEditUserPrivilege from '@/components/backend/AdminEditUserPrivilege.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const hasToken = ref(false)
|
||||
const userId = ref(localStorage.getItem('user_id'))
|
||||
const currentUserData = ref(null)
|
||||
const currentAdminView = ref('event-management')
|
||||
const isMobileSidebarOpen = ref(false)
|
||||
const dropdownOpen = ref(false)
|
||||
const dropdownOpen2 = ref(false)
|
||||
const dropdownOpen3 = ref(false)
|
||||
let privilegeCheckTimer = null
|
||||
|
||||
const isAdmin = computed(() => {
|
||||
return userId.value === '1846172115' || userId.value === '1400429906' || userId.value === '3422054939'
|
||||
return currentUserData.value && currentUserData.value.privilege === 'lv-admin';
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
// 检查是否有token
|
||||
async function checkPrivilege() {
|
||||
console.log('正在验证权限');
|
||||
const token = localStorage.getItem('access_token')
|
||||
hasToken.value = !!token
|
||||
if (!token) {
|
||||
router.push('/')
|
||||
console.log('验证结束');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const userInfo = await getUserInfo();
|
||||
currentUserData.value = userInfo;
|
||||
if (!userInfo || userInfo.privilege !== 'lv-admin') {
|
||||
// 退出登录并跳转首页
|
||||
localStorage.removeItem('access_token')
|
||||
currentUserData.value = null
|
||||
router.push('/')
|
||||
console.log('验证结束');
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
// 退出登录并跳转首页
|
||||
localStorage.removeItem('access_token')
|
||||
currentUserData.value = null
|
||||
router.push('/')
|
||||
console.log('验证结束');
|
||||
}
|
||||
console.log('验证结束');
|
||||
}
|
||||
|
||||
// userId 应该已经从 localStorage 初始化了
|
||||
// 可以在这里再次检查或记录,以防 localStorage 在组件加载后发生变化
|
||||
const currentStoredUserId = localStorage.getItem('user_id');
|
||||
if (currentStoredUserId) {
|
||||
// 如果 ref 中的值与当前 localStorage 中的值不同(例如,初始为 null,现在有值了)
|
||||
if (userId.value !== currentStoredUserId) {
|
||||
userId.value = currentStoredUserId;
|
||||
console.log('Dashboard.vue - 用户ID已在 onMounted 中更新:', userId.value);
|
||||
} else {
|
||||
console.log('Dashboard.vue - 当前用户ID (onMounted, 与初始值一致):', userId.value);
|
||||
}
|
||||
} else {
|
||||
// 如果 userId.value 为 null(初始即为 null 且 onMounted 时 localStorage 仍为 null)
|
||||
// 并且我们删除了非管理员视图,管理员内容将不会显示
|
||||
console.log('Dashboard.vue - 未在 localStorage 中检测到用户ID (onMounted)');
|
||||
}
|
||||
onMounted(() => {
|
||||
checkPrivilege();
|
||||
privilegeCheckTimer = setInterval(checkPrivilege, 60 * 1000);
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (privilegeCheckTimer) clearInterval(privilegeCheckTimer);
|
||||
})
|
||||
|
||||
const handleLogout = () => {
|
||||
// 清除本地存储的 token
|
||||
localStorage.removeItem('access_token')
|
||||
// 可选:清除用户ID
|
||||
localStorage.removeItem('user_id')
|
||||
// 清除当前视图状态 (如果需要)
|
||||
currentAdminView.value = 'event-management'
|
||||
isMobileSidebarOpen.value = false; // 退出时关闭侧边栏
|
||||
|
@ -2,17 +2,14 @@
|
||||
import { computed, ref, onMounted, onUnmounted } from 'vue'
|
||||
import { getUserInfo } from '@/utils/jwt'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { hasPrivilege } from '@/utils/privilege'
|
||||
|
||||
const isLoggedIn = computed(() => {
|
||||
return !!localStorage.getItem('access_token') && !!currentUserData.value
|
||||
})
|
||||
|
||||
const isAdmin = computed(() => {
|
||||
if (currentUserData.value && currentUserData.value.qq_code) {
|
||||
const adminQQNumbers = ['1846172115', '1400429906', '3422054939'];
|
||||
return adminQQNumbers.indexOf(String(currentUserData.value.qq_code)) !== -1;
|
||||
}
|
||||
return false;
|
||||
return currentUserData.value && currentUserData.value.privilege === 'lv-admin';
|
||||
})
|
||||
|
||||
const showMobileMenu = ref(false)
|
||||
@ -28,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
|
||||
}
|
||||
@ -68,6 +117,19 @@ onUnmounted(() => {
|
||||
// 移除事件监听器
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
// 判断每个一级菜单是否有可见项
|
||||
const showTerrainList = true // 地形图列表始终可见
|
||||
const showTerrainGenerate = computed(() => isLoggedIn.value && currentUserData.value)
|
||||
const showWeaponMatch = computed(() => isLoggedIn.value && currentUserData.value && hasPrivilege(currentUserData.value.privilege, ['lv-admin', 'lv-mod']))
|
||||
const showPic2Tga = computed(() => isLoggedIn.value && currentUserData.value)
|
||||
const showDemands = computed(() => isLoggedIn.value && currentUserData.value)
|
||||
const showCompetition = computed(() => isLoggedIn.value && currentUserData.value && hasPrivilege(currentUserData.value.privilege, ['lv-admin', 'lv-competitor']))
|
||||
|
||||
const showTerrainMenu = computed(() => showTerrainList || showTerrainGenerate.value)
|
||||
const showOnlineToolsMenu = computed(() => showWeaponMatch.value || showPic2Tga.value)
|
||||
const showPublicMenu = computed(() => showDemands.value)
|
||||
const showCompetitionMenu = computed(() => showCompetition.value)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -79,12 +141,45 @@ onUnmounted(() => {
|
||||
<i class="fas fa-bars"></i>
|
||||
</button>
|
||||
<div class="nav-left" :class="{ active: showMobileMenu }">
|
||||
<router-link to="/maps" class="nav-link">最近上传地图</router-link>
|
||||
<router-link to="/weekly" class="nav-link">热门下载地图</router-link>
|
||||
<!-- 地图 一级菜单 -->
|
||||
<div class="nav-dropdown">
|
||||
<span class="nav-link">地图与作者推荐</span>
|
||||
<div class="dropdown-content">
|
||||
<router-link to="/maps" class="nav-link">全部上传地图</router-link>
|
||||
<router-link to="/weekly" class="nav-link">本周上传地图</router-link>
|
||||
<router-link to="/author" class="nav-link">活跃作者推荐</router-link>
|
||||
<router-link v-if="isLoggedIn" to="/weapon-match" class="nav-link">Weapon 匹配</router-link>
|
||||
<router-link v-if="isLoggedIn" to="/competition" class="nav-link">赛程信息</router-link>
|
||||
<router-link v-if="isLoggedIn" to="/demands" class="nav-link">办事大厅</router-link>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 地形 一级菜单 -->
|
||||
<div v-if="showTerrainMenu" class="nav-dropdown">
|
||||
<span class="nav-link">地形与纹理</span>
|
||||
<div class="dropdown-content">
|
||||
<router-link to="/terrain" class="nav-link">地形图列表</router-link>
|
||||
<router-link v-if="showTerrainGenerate" to="/terrainGenerate" class="nav-link">地形纹理合成工具</router-link>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 在线工具 一级菜单 -->
|
||||
<div v-if="showOnlineToolsMenu" class="nav-dropdown">
|
||||
<span class="nav-link">在线工具</span>
|
||||
<div class="dropdown-content">
|
||||
<router-link v-if="showWeaponMatch" to="/weapon-match" class="nav-link">Weapon 匹配</router-link>
|
||||
<router-link v-if="showPic2Tga" to="/PIC2TGA" class="nav-link">在线转tga工具</router-link>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 赛事信息 一级菜单 -->
|
||||
<div v-if="showCompetitionMenu" class="nav-dropdown">
|
||||
<span class="nav-link">赛事信息</span>
|
||||
<div class="dropdown-content">
|
||||
<router-link v-if="showCompetition" to="/competition" class="nav-link">赛程信息</router-link>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 公共信息区 一级菜单 -->
|
||||
<div v-if="showPublicMenu" class="nav-dropdown">
|
||||
<span class="nav-link">公共信息区</span>
|
||||
<div class="dropdown-content">
|
||||
<router-link v-if="showDemands" to="/demands" class="nav-link">办事大厅</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="nav-right" :class="{ active: showMobileMenu }">
|
||||
<router-link v-if="!isLoggedIn" to="/backend/login" class="nav-link login-btn">
|
||||
@ -94,6 +189,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">
|
||||
@ -171,15 +267,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 {
|
||||
@ -199,7 +296,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 {
|
||||
@ -229,14 +329,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 {
|
||||
@ -346,6 +450,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;
|
||||
@ -424,4 +560,46 @@ onUnmounted(() => {
|
||||
gap: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-dropdown {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.nav-dropdown > .nav-link {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.dropdown-content {
|
||||
display: none;
|
||||
position: absolute;
|
||||
background-color: #fff;
|
||||
min-width: 180px;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.15);
|
||||
z-index: 1001;
|
||||
border-radius: 8px;
|
||||
padding: 8px 0;
|
||||
left: 0;
|
||||
top: 100%;
|
||||
}
|
||||
|
||||
.nav-dropdown:hover .dropdown-content {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.dropdown-content .nav-link {
|
||||
color: #333;
|
||||
background: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 0;
|
||||
display: block;
|
||||
text-align: left;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.dropdown-content .nav-link:hover {
|
||||
background-color: #f5f5f5;
|
||||
color: #416bdf;
|
||||
}
|
||||
</style>
|
@ -55,13 +55,13 @@
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form class="add-modal-form" @submit.prevent="submitReply">
|
||||
<div class="form-row">
|
||||
<span class="label">回复消息:</span>
|
||||
<select v-model="addForm.replyTo" class="input">
|
||||
<option value="">无</option>
|
||||
<option v-for="msg in replyOptions" :key="msg.id" :value="msg.id">{{ msg.text }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- <div class="form-row">-->
|
||||
<!-- <span class="label">回复消息:</span>-->
|
||||
<!-- <select v-model="addForm.replyTo" class="input">-->
|
||||
<!-- <option value="">无</option>-->
|
||||
<!-- <option v-for="msg in replyOptions" :key="msg.id" :value="msg.id">{{ msg.text }}</option>-->
|
||||
<!-- </select>-->
|
||||
<!-- </div>-->
|
||||
<div class="form-row">
|
||||
<span class="label">昵称:</span>
|
||||
<input
|
||||
|
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>
|
||||
|
612
src/views/index/TerrainGenerate.vue
Normal file
612
src/views/index/TerrainGenerate.vue
Normal file
@ -0,0 +1,612 @@
|
||||
<template>
|
||||
<div class="terrain-tool-container">
|
||||
<div class="header">
|
||||
<h1>地形纹理合成工具</h1>
|
||||
<p class="subtitle">将颜色、法线和粗糙度贴图合成为地形使用的TGA格式</p>
|
||||
</div>
|
||||
|
||||
<div class="upload-section">
|
||||
<div class="upload-card" :class="{ active: colorFiles.length > 0 }">
|
||||
<label class="upload-label">
|
||||
<input type="file" multiple accept="image/*" @change="onFilesSelected('color', $event)" />
|
||||
<div class="upload-content">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
|
||||
<circle cx="8.5" cy="8.5" r="1.5"></circle>
|
||||
<polyline points="21 15 16 10 5 21"></polyline>
|
||||
</svg>
|
||||
<h3>颜色贴图</h3>
|
||||
<p v-if="colorFiles.length === 0">点击或拖放文件到这里</p>
|
||||
<p v-else>{{ colorFiles.length }} 个文件已选择</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="upload-card" :class="{ active: normalFiles.length > 0 }">
|
||||
<label class="upload-label">
|
||||
<input type="file" multiple accept="image/*" @change="onFilesSelected('normal', $event)" />
|
||||
<div class="upload-content">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 2.69l5.66 5.66a8 8 0 1 1-11.31 0z"></path>
|
||||
</svg>
|
||||
<h3>法线贴图(OpenGL格式)</h3>
|
||||
<p v-if="normalFiles.length === 0">点击或拖放文件到这里</p>
|
||||
<p v-else>{{ normalFiles.length }} 个文件已选择</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="upload-card" :class="{ active: roughnessFiles.length > 0 }">
|
||||
<label class="upload-label">
|
||||
<input type="file" multiple accept="image/*" @change="onFilesSelected('roughness', $event)" />
|
||||
<div class="upload-content">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<path d="M8 14s1.5 2 4 2 4-2 4-2"></path>
|
||||
<line x1="9" y1="9" x2="9.01" y2="9"></line>
|
||||
<line x1="15" y1="9" x2="15.01" y2="9"></line>
|
||||
</svg>
|
||||
<h3>粗糙度贴图</h3>
|
||||
<p v-if="roughnessFiles.length === 0">点击或拖放文件到这里</p>
|
||||
<p v-else>{{ roughnessFiles.length }} 个文件已选择</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-section">
|
||||
<div class="status-card">
|
||||
<div class="status-item">
|
||||
<span class="status-label">颜色贴图:</span>
|
||||
<span class="status-value">{{ colorFiles.length }}</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="status-label">法线贴图:</span>
|
||||
<span class="status-value">{{ normalFiles.length }}</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span class="status-label">粗糙度贴图:</span>
|
||||
<span class="status-value">{{ roughnessFiles.length }}</span>
|
||||
</div>
|
||||
<div class="status-item highlight">
|
||||
<span class="status-label">可匹配文件组:</span>
|
||||
<span class="status-value">{{ getMatchCount() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-buttons">
|
||||
<button
|
||||
class="btn generate-btn"
|
||||
:class="{ disabled: !canProcess }"
|
||||
@click="generateTGA"
|
||||
:disabled="!canProcess">
|
||||
<span v-if="!isProcessing">生成地形TGA文件</span>
|
||||
<span v-else class="processing">
|
||||
<svg class="spinner" viewBox="0 0 50 50">
|
||||
<circle class="path" cx="25" cy="25" r="20" fill="none" stroke-width="5"></circle>
|
||||
</svg>
|
||||
处理中...
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn download-btn"
|
||||
:class="{ disabled: generatedFiles.length === 0 }"
|
||||
@click="downloadZip"
|
||||
:disabled="generatedFiles.length === 0">
|
||||
打包下载ZIP
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="results-section" v-if="generatedFiles.length > 0">
|
||||
<h2>生成结果</h2>
|
||||
<div class="results-grid">
|
||||
<div v-for="(file, index) in generatedFiles" :key="index" class="result-item">
|
||||
<div class="file-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path>
|
||||
<polyline points="13 2 13 9 20 9"></polyline>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="file-name">{{ file.name }}</div>
|
||||
<div class="file-size">{{ formatFileSize(file.blob.size) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="instructions" v-if="generatedFiles.length === 0 && !canProcess">
|
||||
<h3>使用说明</h3>
|
||||
<ol>
|
||||
<li>上传颜色、法线和粗糙度贴图(PNG/JPG格式)</li>
|
||||
<li>确保每组贴图有相同的文件名(仅扩展名不同)</li>
|
||||
<li>点击"生成地形TGA文件"按钮</li>
|
||||
<li>处理完成后下载ZIP包</li>
|
||||
</ol>
|
||||
<p class="note">注意:系统会自动匹配文件名相同的贴图进行合成处理。</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import JSZip from "jszip";
|
||||
|
||||
export default {
|
||||
name: "TerrainTool",
|
||||
data() {
|
||||
return {
|
||||
colorFiles: [],
|
||||
normalFiles: [],
|
||||
roughnessFiles: [],
|
||||
generatedFiles: [],
|
||||
isProcessing: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
canProcess() {
|
||||
return (
|
||||
this.colorFiles.length > 0 &&
|
||||
this.normalFiles.length > 0 &&
|
||||
this.roughnessFiles.length > 0 &&
|
||||
this.getMatchCount() > 0
|
||||
);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onFilesSelected(type, e) {
|
||||
const files = Array.from(e.target.files);
|
||||
this[type + "Files"] = files;
|
||||
},
|
||||
|
||||
getMatchCount() {
|
||||
const names = (list) => list.map((f) => f.name.split('.')[0]);
|
||||
const common = names(this.colorFiles).filter(
|
||||
(n) => names(this.normalFiles).includes(n) && names(this.roughnessFiles).includes(n)
|
||||
);
|
||||
return common.length;
|
||||
},
|
||||
|
||||
async generateTGA() {
|
||||
if (!this.canProcess || this.isProcessing) return;
|
||||
|
||||
this.isProcessing = true;
|
||||
this.generatedFiles = [];
|
||||
|
||||
try {
|
||||
const nameMap = (files) => Object.fromEntries(files.map((f) => [f.name.split('.')[0], f]));
|
||||
const colorMap = nameMap(this.colorFiles);
|
||||
const normalMap = nameMap(this.normalFiles);
|
||||
const roughMap = nameMap(this.roughnessFiles);
|
||||
|
||||
const commonKeys = Object.keys(colorMap).filter(
|
||||
(k) => normalMap[k] && roughMap[k]
|
||||
);
|
||||
|
||||
for (const key of commonKeys) {
|
||||
const color = await this.loadImage(colorMap[key]);
|
||||
const normal = await this.loadImage(normalMap[key]);
|
||||
const rough = await this.loadImage(roughMap[key]);
|
||||
|
||||
const [resultColor, resultNRM] = this.processImages(color, normal, rough);
|
||||
this.generatedFiles.push({ name: `${key}_ResultColor.tga`, blob: resultColor });
|
||||
this.generatedFiles.push({ name: `${key}_ResultNRM.tga`, blob: resultNRM });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("生成过程中出错:", error);
|
||||
alert("处理过程中发生错误,请检查文件格式后再试。");
|
||||
} finally {
|
||||
this.isProcessing = false;
|
||||
}
|
||||
},
|
||||
|
||||
loadImage(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = reject;
|
||||
img.src = URL.createObjectURL(file);
|
||||
});
|
||||
},
|
||||
|
||||
processImages(colorImg, normalImg, roughImg) {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 256;
|
||||
canvas.height = 256;
|
||||
const ctx = canvas.getContext("2d");
|
||||
|
||||
// COLOR+ROUGHNESS
|
||||
ctx.drawImage(colorImg, 0, 0, 256, 256);
|
||||
const rgbData = ctx.getImageData(0, 0, 256, 256);
|
||||
|
||||
ctx.clearRect(0, 0, 256, 256);
|
||||
ctx.drawImage(roughImg, 0, 0, 256, 256);
|
||||
const roughData = ctx.getImageData(0, 0, 256, 256);
|
||||
|
||||
const rgbaData = ctx.createImageData(256, 256);
|
||||
for (let i = 0; i < rgbaData.data.length; i += 4) {
|
||||
rgbaData.data[i] = rgbData.data[i]; // R
|
||||
rgbaData.data[i + 1] = rgbData.data[i + 1]; // G
|
||||
rgbaData.data[i + 2] = rgbData.data[i + 2]; // B
|
||||
rgbaData.data[i + 3] = 255 - roughData.data[i]; // A = 反相粗糙度
|
||||
}
|
||||
ctx.putImageData(rgbaData, 0, 0);
|
||||
const resultColorBlob = this.toTGA(ctx, 256, 256);
|
||||
|
||||
// NORMAL
|
||||
ctx.clearRect(0, 0, 256, 256);
|
||||
ctx.drawImage(normalImg, 0, 0, 256, 256);
|
||||
const resultNRMBlob = this.toTGA(ctx, 256, 256);
|
||||
|
||||
return [resultColorBlob, resultNRMBlob];
|
||||
},
|
||||
|
||||
toTGA(ctx, width, height) {
|
||||
const pixels = ctx.getImageData(0, 0, width, height).data;
|
||||
const header = new Uint8Array(18);
|
||||
header[2] = 2;
|
||||
header[12] = width & 0xff;
|
||||
header[13] = (width >> 8) & 0xff;
|
||||
header[14] = height & 0xff;
|
||||
header[15] = (height >> 8) & 0xff;
|
||||
header[16] = 32;
|
||||
header[17] = 0x00; // 原点左下
|
||||
|
||||
const data = new Uint8Array(width * height * 4);
|
||||
let offset = 0;
|
||||
for (let y = height - 1; y >= 0; y--) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const i = (y * width + x) * 4;
|
||||
data[offset++] = pixels[i + 2]; // B
|
||||
data[offset++] = pixels[i + 1]; // G
|
||||
data[offset++] = pixels[i]; // R
|
||||
data[offset++] = pixels[i + 3]; // A
|
||||
}
|
||||
}
|
||||
|
||||
return new Blob([header, data], { type: "application/octet-stream" });
|
||||
},
|
||||
|
||||
async downloadZip() {
|
||||
if (this.generatedFiles.length === 0) return;
|
||||
|
||||
try {
|
||||
const zip = new JSZip();
|
||||
for (const file of this.generatedFiles) {
|
||||
zip.file(file.name, file.blob);
|
||||
}
|
||||
const blob = await zip.generateAsync({ type: "blob" });
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = "TerrainTextures.zip";
|
||||
a.click();
|
||||
} catch (error) {
|
||||
console.error("打包下载出错:", error);
|
||||
alert("创建ZIP文件时出错,请重试。");
|
||||
}
|
||||
},
|
||||
|
||||
formatFileSize(bytes) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.terrain-tool-container {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 2.2rem;
|
||||
color: #2c3e50;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #7f8c8d;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.upload-section {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.upload-card {
|
||||
border: 2px dashed #bdc3c7;
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
transition: all 0.3s ease;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.upload-card.active {
|
||||
border-color: #3498db;
|
||||
background-color: #e8f4fc;
|
||||
}
|
||||
|
||||
.upload-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.upload-label {
|
||||
display: block;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.upload-label input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.upload-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.upload-content svg {
|
||||
margin-bottom: 1rem;
|
||||
color: #7f8c8d;
|
||||
}
|
||||
|
||||
.upload-card.active svg {
|
||||
color: #3498db;
|
||||
}
|
||||
|
||||
.upload-content h3 {
|
||||
margin: 0 0 0.5rem;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.upload-content p {
|
||||
margin: 0;
|
||||
color: #7f8c8d;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.status-section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.status-card {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.status-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.status-item.highlight {
|
||||
font-weight: bold;
|
||||
color: #3498db;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.status-label {
|
||||
color: #7f8c8d;
|
||||
}
|
||||
|
||||
.status-value {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.8rem 1.8rem;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.generate-btn {
|
||||
background-color: #3498db;
|
||||
color: white;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.generate-btn:hover:not(:disabled) {
|
||||
background-color: #2980b9;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(52, 152, 219, 0.3);
|
||||
}
|
||||
|
||||
.download-btn {
|
||||
background-color: #2ecc71;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.download-btn:hover:not(:disabled) {
|
||||
background-color: #27ae60;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(46, 204, 113, 0.3);
|
||||
}
|
||||
|
||||
.btn.disabled {
|
||||
background-color: #bdc3c7;
|
||||
}
|
||||
|
||||
.processing {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
animation: rotate 2s linear infinite;
|
||||
}
|
||||
|
||||
.spinner .path {
|
||||
stroke: white;
|
||||
stroke-linecap: round;
|
||||
animation: dash 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dash {
|
||||
0% {
|
||||
stroke-dasharray: 1, 150;
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
50% {
|
||||
stroke-dasharray: 90, 150;
|
||||
stroke-dashoffset: -35;
|
||||
}
|
||||
100% {
|
||||
stroke-dasharray: 90, 150;
|
||||
stroke-dashoffset: -124;
|
||||
}
|
||||
}
|
||||
|
||||
.results-section {
|
||||
margin-top: 2rem;
|
||||
animation: fadeIn 0.5s ease;
|
||||
}
|
||||
|
||||
.results-section h2 {
|
||||
color: #2c3e50;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 2px solid #eee;
|
||||
}
|
||||
|
||||
.results-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.result-item {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.result-item:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
margin-right: 1rem;
|
||||
color: #3498db;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-size {
|
||||
color: #7f8c8d;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.instructions {
|
||||
background: #f8f9fa;
|
||||
border-radius: 10px;
|
||||
padding: 1.5rem;
|
||||
margin-top: 2rem;
|
||||
border-left: 4px solid #3498db;
|
||||
}
|
||||
|
||||
.instructions h3 {
|
||||
color: #2c3e50;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.instructions ol {
|
||||
padding-left: 1.5rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.instructions .note {
|
||||
font-style: italic;
|
||||
color: #7f8c8d;
|
||||
margin-top: 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.upload-section {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
378
src/views/index/TerrainList.vue
Normal file
378
src/views/index/TerrainList.vue
Normal file
@ -0,0 +1,378 @@
|
||||
<template>
|
||||
<div class="map-detail">
|
||||
<div class="map-header">
|
||||
<h1>地形图列表</h1>
|
||||
<div class="filter-controls">
|
||||
<label for="category-select">分类:</label>
|
||||
<select v-model="selectedCategory" id="category-select" @change="currentPage = 1">
|
||||
<option v-for="category in categoryList" :key="category" :value="category">
|
||||
{{ category }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pagination-controls">
|
||||
<button class="pagination-btn" @click="prevPage" :disabled="currentPage === 1">
|
||||
< 上一页
|
||||
</button>
|
||||
<span class="page-info">第 {{ currentPage }} 页 / 共 {{ totalPages }} 页</span>
|
||||
<button class="pagination-btn" @click="nextPage" :disabled="currentPage === totalPages">
|
||||
下一页 >
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">加载中...</div>
|
||||
|
||||
<div v-if="error" class="error">
|
||||
加载失败: {{ error }}
|
||||
<button @click="fetchTerrainList" class="back-btn">重试</button>
|
||||
</div>
|
||||
|
||||
<div class="map-content">
|
||||
<div v-if="filteredTerrainsByCategory.length > 0" class="terrain-grid">
|
||||
<div v-for="terrain in paginatedTerrains" :key="terrain.key" class="terrain-item">
|
||||
<div class="map-image">
|
||||
<img :src="getImageUrl(terrain.key)" :alt="'地形图 ' + terrain.key" />
|
||||
<div class="image-overlay">
|
||||
<span class="image-name">{{ terrain.key }}</span>
|
||||
<a :href="getImageUrl(terrain.key)" class="download-link" download title="下载">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20">
|
||||
<path fill="white" d="M5,20H19V18H5M19,9H15V3H9V9H5L12,16L19,9Z" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!loading" class="no-data">
|
||||
当前分类下没有可用的地形图数据
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pagination-controls bottom">
|
||||
<button class="pagination-btn" @click="prevPage" :disabled="currentPage === 1">
|
||||
< 上一页
|
||||
</button>
|
||||
<span class="page-info">第 {{ currentPage }} 页 / 共 {{ totalPages }} 页</span>
|
||||
<button class="pagination-btn" @click="nextPage" :disabled="currentPage === totalPages">
|
||||
下一页 >
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'TerrainList',
|
||||
data() {
|
||||
return {
|
||||
terrains: [],
|
||||
filteredTerrains: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
currentPage: 1,
|
||||
itemsPerPage: 100,
|
||||
apiBaseUrl: 'http://zybdatasupport.online:8000',
|
||||
categoryList: [],
|
||||
selectedCategory: '全部',
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
filteredTerrainsByCategory() {
|
||||
if (this.selectedCategory === '全部') {
|
||||
return this.filteredTerrains;
|
||||
}
|
||||
return this.filteredTerrains.filter(item =>
|
||||
item.key.toLowerCase().startsWith(this.selectedCategory + '_')
|
||||
);
|
||||
},
|
||||
totalPages() {
|
||||
return Math.ceil(this.filteredTerrainsByCategory.length / this.itemsPerPage);
|
||||
},
|
||||
paginatedTerrains() {
|
||||
const start = (this.currentPage - 1) * this.itemsPerPage;
|
||||
const end = start + this.itemsPerPage;
|
||||
return this.filteredTerrainsByCategory.slice(start, end);
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.fetchTerrainList();
|
||||
},
|
||||
methods: {
|
||||
async fetchTerrainList() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.apiBaseUrl}/terrain`);
|
||||
if (!response.ok) {
|
||||
throw new Error('获取地形图列表失败');
|
||||
}
|
||||
const data = await response.json();
|
||||
this.terrains = data;
|
||||
this.filteredTerrains = data.filter(item => this.isImageFile(item.key));
|
||||
this.extractCategories();
|
||||
this.currentPage = 1;
|
||||
} catch (err) {
|
||||
this.error = err.message;
|
||||
console.error('Error fetching terrain list:', err);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
extractCategories() {
|
||||
const categories = new Set();
|
||||
this.filteredTerrains.forEach(item => {
|
||||
const prefix = item.key.split('_')[0].toLowerCase();
|
||||
categories.add(prefix);
|
||||
});
|
||||
this.categoryList = ['全部', ...Array.from(categories).sort()];
|
||||
},
|
||||
|
||||
getImageUrl(key) {
|
||||
return `http://dataimg-1307694021.cos.ap-beijing.myqcloud.com/Terrain/jpg/${key}`;
|
||||
},
|
||||
|
||||
isImageFile(key) {
|
||||
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp'];
|
||||
const lowerKey = key.toLowerCase();
|
||||
return imageExtensions.some(ext => lowerKey.endsWith(ext));
|
||||
},
|
||||
|
||||
nextPage() {
|
||||
if (this.currentPage < this.totalPages) {
|
||||
this.currentPage++;
|
||||
}
|
||||
},
|
||||
|
||||
prevPage() {
|
||||
if (this.currentPage > 1) {
|
||||
this.currentPage--;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.map-detail {
|
||||
padding: 15px;
|
||||
max-width: 1800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.map-header {
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.map-header h1 {
|
||||
margin: 0;
|
||||
color: #333;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.filter-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
select {
|
||||
padding: 6px 10px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.loading, .error, .no-data {
|
||||
padding: 15px;
|
||||
text-align: center;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #d32f2f;
|
||||
background-color: #ffebee;
|
||||
}
|
||||
|
||||
.no-data {
|
||||
color: #757575;
|
||||
}
|
||||
|
||||
.terrain-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(10, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.terrain-item {
|
||||
position: relative;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
aspect-ratio: 1;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.terrain-item:hover {
|
||||
transform: scale(1.03);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.map-image {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.map-image img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.image-overlay {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: linear-gradient(transparent, rgba(0,0,0,0.7));
|
||||
padding: 8px;
|
||||
color: white;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.terrain-item:hover .image-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.image-name {
|
||||
font-size: 0.7rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 80%;
|
||||
}
|
||||
|
||||
.download-link {
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.download-link:hover {
|
||||
color: #4fc3f7;
|
||||
}
|
||||
|
||||
.pagination-controls {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.pagination-controls.bottom {
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.pagination-btn {
|
||||
padding: 8px 16px;
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.pagination-btn:hover:not(:disabled) {
|
||||
background: #e9ecef;
|
||||
border-color: #d0d0d0;
|
||||
}
|
||||
|
||||
.pagination-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.page-info {
|
||||
font-size: 0.9rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 6px;
|
||||
color: #333;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 14px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
background: #e9ecef;
|
||||
border-color: #d0d0d0;
|
||||
}
|
||||
|
||||
@media (max-width: 1800px) {
|
||||
.terrain-grid {
|
||||
grid-template-columns: repeat(8, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1400px) {
|
||||
.terrain-grid {
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.terrain-grid {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.terrain-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.map-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
.terrain-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.pagination-controls {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
Loading…
x
Reference in New Issue
Block a user