feature/login-screen #2

Merged
zyb merged 6 commits from feature/login-screen into master 2025-06-27 17:18:19 +08:00
17 changed files with 1722 additions and 2639 deletions

87
package-lock.json generated
View File

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

View File

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

View File

@ -112,3 +112,20 @@ export const userRegister = async (qq_code, password, token, captcha) => {
} }
}; };
/**
* 管理员修改用户权限
* @param {string} uuid - 用户uuid
* @param {string} privilege - 新权限
* @returns {Promise<void>} 无返回值成功即为修改成功
*/
export const adminChangeUserPrivilege = async (uuid, privilege) => {
try {
await axiosInstance.put('/admin/change_user_privilege', {
uuid,
privilege
});
} catch (error) {
throw error;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -76,6 +76,21 @@ const routes = [
path: 'editors-maps', path: 'editors-maps',
name: 'EditorsMaps', name: 'EditorsMaps',
component: () => import('@/views/index/EditorsMaps.vue') 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
View 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);
}

View File

@ -5,21 +5,60 @@
<button @click="isMobileSidebarOpen = !isMobileSidebarOpen" class="hamburger-button"> <button @click="isMobileSidebarOpen = !isMobileSidebarOpen" class="hamburger-button">
<span class="hamburger-icon"></span> <span class="hamburger-icon"></span>
</button> </button>
<span class="mobile-header-title">后台管理</span> <span class="mobile-header-title">管理后台</span>
</div> </div>
<div class="admin-sidebar"> <div class="admin-sidebar">
<div class="sidebar-header"> <div class="sidebar-header">
<h3>后台管理</h3> <h3>管理后台</h3>
</div> </div>
<ul class="sidebar-nav"> <ul class="sidebar-nav">
<li @click="selectAdminView('event-management')" :class="{ active: currentAdminView === 'event-management' }"><a>赛事管理</a></li> <li>
<li @click="selectAdminView('player-management')" :class="{ active: currentAdminView === 'player-management' }"><a>赛事玩家管理</a></li> <div @click="dropdownOpen = !dropdownOpen" style="cursor:pointer;display:flex;align-items:center;justify-content:space-between;padding:15px 20px;">
<li @click="selectAdminView('service-hall')" :class="{ active: currentAdminView === 'service-hall' }"><a>办事大厅</a></li> <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> </ul>
<div class="sidebar-footer"> <div class="sidebar-footer">
<button @click="selectAdminView('code-generator')" :class="['sidebar-button', 'code-generator-button', { 'active': currentAdminView === 'code-generator' }]"> <!-- <button @click="selectAdminView('code-generator')" :class="['sidebar-button', 'code-generator-button', { 'active': currentAdminView === 'code-generator' }]">
代码生成器 代码生成器
</button> </button> -->
<button @click="goToHomePage" class="home-button sidebar-button"> <button @click="goToHomePage" class="home-button sidebar-button">
返回主界面 返回主界面
</button> </button>
@ -29,73 +68,74 @@
</div> </div>
</div> </div>
<div class="admin-main-content"> <div class="admin-main-content">
<div v-if="currentAdminView === 'event-management'"> <AdminEditUserPrivilege v-if="currentAdminView === 'admin-edit-user-privilege'" />
<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>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted, computed } from 'vue' import { ref, onMounted, computed, onUnmounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import TournamentList from '../../components/backend/TournamentList.vue' import { getUserInfo } from '@/utils/jwt'
import PlayerList from '../../components/backend/PlayerList.vue' import AdminEditUserPrivilege from '@/components/backend/AdminEditUserPrivilege.vue'
import ServiceHallView from '../../components/backend/ServiceHallView.vue'
import CodeGenerator from '../../components/backend/CodeGenerator.vue'
const router = useRouter() const router = useRouter()
const hasToken = ref(false) const hasToken = ref(false)
const userId = ref(localStorage.getItem('user_id')) const currentUserData = ref(null)
const currentAdminView = ref('event-management') const currentAdminView = ref('event-management')
const isMobileSidebarOpen = ref(false) const isMobileSidebarOpen = ref(false)
const dropdownOpen = ref(false)
const dropdownOpen2 = ref(false)
const dropdownOpen3 = ref(false)
let privilegeCheckTimer = null
const isAdmin = computed(() => { const isAdmin = computed(() => {
return userId.value === '1846172115' || userId.value === '1400429906' || userId.value === '3422054939' return currentUserData.value && currentUserData.value.privilege === 'lv-admin';
}) })
onMounted(() => { async function checkPrivilege() {
// token console.log('正在验证权限');
const token = localStorage.getItem('access_token') const token = localStorage.getItem('access_token')
hasToken.value = !!token hasToken.value = !!token
if (!token) { if (!token) {
router.push('/') router.push('/')
console.log('验证结束');
return; 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 onMounted(() => {
// localStorage checkPrivilege();
const currentStoredUserId = localStorage.getItem('user_id'); privilegeCheckTimer = setInterval(checkPrivilege, 60 * 1000);
if (currentStoredUserId) { })
// ref localStorage null
if (userId.value !== currentStoredUserId) { onUnmounted(() => {
userId.value = currentStoredUserId; if (privilegeCheckTimer) clearInterval(privilegeCheckTimer);
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)');
}
}) })
const handleLogout = () => { const handleLogout = () => {
// token // token
localStorage.removeItem('access_token') localStorage.removeItem('access_token')
// ID
localStorage.removeItem('user_id')
// () // ()
currentAdminView.value = 'event-management' currentAdminView.value = 'event-management'
isMobileSidebarOpen.value = false; // 退 isMobileSidebarOpen.value = false; // 退

View File

@ -2,17 +2,14 @@
import { computed, ref, onMounted, onUnmounted } from 'vue' import { computed, ref, onMounted, onUnmounted } from 'vue'
import { getUserInfo } from '@/utils/jwt' import { getUserInfo } from '@/utils/jwt'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { hasPrivilege } from '@/utils/privilege'
const isLoggedIn = computed(() => { const isLoggedIn = computed(() => {
return !!localStorage.getItem('access_token') && !!currentUserData.value return !!localStorage.getItem('access_token') && !!currentUserData.value
}) })
const isAdmin = computed(() => { const isAdmin = computed(() => {
if (currentUserData.value && currentUserData.value.qq_code) { return currentUserData.value && currentUserData.value.privilege === 'lv-admin';
const adminQQNumbers = ['1846172115', '1400429906', '3422054939'];
return adminQQNumbers.indexOf(String(currentUserData.value.qq_code)) !== -1;
}
return false;
}) })
const showMobileMenu = ref(false) const showMobileMenu = ref(false)
@ -28,6 +25,58 @@ const userAvatarUrl = computed(() => {
return null return null
}) })
//
const userHighestPrivilege = computed(() => {
if (!currentUserData.value || !currentUserData.value.privilege) {
return null
}
const privileges = currentUserData.value.privilege.split(';')
//
const privilegeLevels = ['lv-admin', 'lv-mod', 'lv-competitor', 'lv-map', 'lv-user']
for (const level of privilegeLevels) {
if (privileges.includes(level)) {
return level
}
}
return null
})
//
const privilegeDisplayName = computed(() => {
const privilege = userHighestPrivilege.value
if (!privilege) return ''
const displayNames = {
'lv-admin': '管理员',
'lv-mod': '模组',
'lv-map': '地图',
'lv-user': '用户',
'lv-competitor': '竞技'
}
return displayNames[privilege] || privilege
})
//
const privilegeTagClass = computed(() => {
const privilege = userHighestPrivilege.value
if (!privilege) return ''
const tagClasses = {
'lv-admin': 'privilege-tag admin',
'lv-mod': 'privilege-tag mod',
'lv-map': 'privilege-tag map',
'lv-user': 'privilege-tag user',
'lv-competitor': 'privilege-tag competitor'
}
return tagClasses[privilege] || 'privilege-tag'
})
const toggleMobileMenu = () => { const toggleMobileMenu = () => {
showMobileMenu.value = !showMobileMenu.value showMobileMenu.value = !showMobileMenu.value
} }
@ -68,6 +117,19 @@ onUnmounted(() => {
// //
document.removeEventListener('click', handleClickOutside) 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> </script>
<template> <template>
@ -79,12 +141,45 @@ onUnmounted(() => {
<i class="fas fa-bars"></i> <i class="fas fa-bars"></i>
</button> </button>
<div class="nav-left" :class="{ active: showMobileMenu }"> <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 to="/author" class="nav-link">活跃作者推荐</router-link>
<router-link v-if="isLoggedIn" to="/weapon-match" class="nav-link">Weapon 匹配</router-link> </div>
<router-link v-if="isLoggedIn" to="/competition" class="nav-link">赛程信息</router-link> </div>
<router-link v-if="isLoggedIn" to="/demands" class="nav-link">办事大厅</router-link> <!-- 地形 一级菜单 -->
<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>
<div class="nav-right" :class="{ active: showMobileMenu }"> <div class="nav-right" :class="{ active: showMobileMenu }">
<router-link v-if="!isLoggedIn" to="/backend/login" class="nav-link login-btn"> <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"> <div v-if="isLoggedIn && currentUserData" class="user-info-nav" ref="userInfoNavRef" @click="toggleDropdown">
<img v-if="userAvatarUrl" :src="userAvatarUrl" alt="User Avatar" class="nav-avatar" /> <img v-if="userAvatarUrl" :src="userAvatarUrl" alt="User Avatar" class="nav-avatar" />
<span class="nav-username">{{ currentUserData.username }}</span> <span class="nav-username">{{ currentUserData.username }}</span>
<span v-if="userHighestPrivilege" :class="privilegeTagClass">{{ privilegeDisplayName }}</span>
<i class="fas fa-chevron-down dropdown-icon"></i> <i class="fas fa-chevron-down dropdown-icon"></i>
<div v-show="showDropdown" class="dropdown-menu"> <div v-show="showDropdown" class="dropdown-menu">
<div v-if="isAdmin" class="dropdown-item" @click.stop="router.push('/backend/dashboard'); showDropdown = false"> <div v-if="isAdmin" class="dropdown-item" @click.stop="router.push('/backend/dashboard'); showDropdown = false">
@ -171,15 +267,16 @@ onUnmounted(() => {
} }
.nav-container { .nav-container {
max-width: 1200px; max-width: 1400px;
margin: 0 auto; margin: 0 auto;
padding: 0 20px; padding: 0 12px;
height: auto; height: auto;
min-height: 60px; min-height: 60px;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: nowrap;
align-items: center; align-items: center;
justify-content: flex-start; justify-content: flex-start;
overflow-x: visible;
} }
.nav-brand { .nav-brand {
@ -199,7 +296,10 @@ onUnmounted(() => {
flex-direction: row; flex-direction: row;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 12px;
flex-wrap: nowrap;
white-space: nowrap;
overflow-x: visible;
} }
.nav-right { .nav-right {
@ -229,14 +329,18 @@ onUnmounted(() => {
.nav-link { .nav-link {
color: rgba(255, 255, 255, 0.9); color: rgba(255, 255, 255, 0.9);
text-decoration: none; text-decoration: none;
padding: 8px 10px; padding: 6px 12px;
border-radius: 6px; border-radius: 6px;
transition: all 0.3s ease; transition: all 0.3s ease;
font-weight: 500; font-weight: 500;
position: relative; position: relative;
width: 100%; width: auto;
text-align: center; text-align: center;
font-size: 0.92rem; font-size: 0.9rem;
white-space: nowrap;
display: inline-block;
flex-shrink: 1;
min-width: 0;
} }
.nav-link:hover { .nav-link:hover {
@ -346,6 +450,38 @@ onUnmounted(() => {
font-size: 0.9rem; font-size: 0.9rem;
} }
.privilege-tag {
display: inline-block;
padding: 2px 10px;
border-radius: 16px;
font-size: 0.78rem;
font-weight: 500;
color: #fff;
margin-left: 8px;
background: rgba(0,0,0,0.18);
letter-spacing: 1px;
vertical-align: middle;
box-shadow: 0 1px 4px rgba(0,0,0,0.04);
border: none;
transition: background 0.2s;
}
.privilege-tag.admin {
background: #ff7675;
}
.privilege-tag.mod {
background: #6c5ce7;
}
.privilege-tag.competitor {
background: #00b894;
}
.privilege-tag.map {
background: #0984e3;
}
.privilege-tag.user {
background: #636e72;
}
.mobile-menu-toggle { .mobile-menu-toggle {
display: block; display: block;
font-size: 1.5rem; font-size: 1.5rem;
@ -424,4 +560,46 @@ onUnmounted(() => {
gap: 6px; 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> </style>

View File

@ -55,13 +55,13 @@
</div> </div>
<div class="modal-body"> <div class="modal-body">
<form class="add-modal-form" @submit.prevent="submitReply"> <form class="add-modal-form" @submit.prevent="submitReply">
<div class="form-row"> <!-- <div class="form-row">-->
<span class="label">回复消息</span> <!-- <span class="label">回复消息</span>-->
<select v-model="addForm.replyTo" class="input"> <!-- <select v-model="addForm.replyTo" class="input">-->
<option value=""></option> <!-- <option value=""></option>-->
<option v-for="msg in replyOptions" :key="msg.id" :value="msg.id">{{ msg.text }}</option> <!-- <option v-for="msg in replyOptions" :key="msg.id" :value="msg.id">{{ msg.text }}</option>-->
</select> <!-- </select>-->
</div> <!-- </div>-->
<div class="form-row"> <div class="form-row">
<span class="label">昵称</span> <span class="label">昵称</span>
<input <input

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

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

View File

@ -0,0 +1,612 @@
<template>
<div class="terrain-tool-container">
<div class="header">
<h1>地形纹理合成工具</h1>
<p class="subtitle">将颜色法线和粗糙度贴图合成为地形使用的TGA格式</p>
</div>
<div class="upload-section">
<div class="upload-card" :class="{ active: colorFiles.length > 0 }">
<label class="upload-label">
<input type="file" multiple accept="image/*" @change="onFilesSelected('color', $event)" />
<div class="upload-content">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<circle cx="8.5" cy="8.5" r="1.5"></circle>
<polyline points="21 15 16 10 5 21"></polyline>
</svg>
<h3>颜色贴图</h3>
<p v-if="colorFiles.length === 0">点击或拖放文件到这里</p>
<p v-else>{{ colorFiles.length }} 个文件已选择</p>
</div>
</label>
</div>
<div class="upload-card" :class="{ active: normalFiles.length > 0 }">
<label class="upload-label">
<input type="file" multiple accept="image/*" @change="onFilesSelected('normal', $event)" />
<div class="upload-content">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2.69l5.66 5.66a8 8 0 1 1-11.31 0z"></path>
</svg>
<h3>法线贴图(OpenGL格式)</h3>
<p v-if="normalFiles.length === 0">点击或拖放文件到这里</p>
<p v-else>{{ normalFiles.length }} 个文件已选择</p>
</div>
</label>
</div>
<div class="upload-card" :class="{ active: roughnessFiles.length > 0 }">
<label class="upload-label">
<input type="file" multiple accept="image/*" @change="onFilesSelected('roughness', $event)" />
<div class="upload-content">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<path d="M8 14s1.5 2 4 2 4-2 4-2"></path>
<line x1="9" y1="9" x2="9.01" y2="9"></line>
<line x1="15" y1="9" x2="15.01" y2="9"></line>
</svg>
<h3>粗糙度贴图</h3>
<p v-if="roughnessFiles.length === 0">点击或拖放文件到这里</p>
<p v-else>{{ roughnessFiles.length }} 个文件已选择</p>
</div>
</label>
</div>
</div>
<div class="status-section">
<div class="status-card">
<div class="status-item">
<span class="status-label">颜色贴图:</span>
<span class="status-value">{{ colorFiles.length }}</span>
</div>
<div class="status-item">
<span class="status-label">法线贴图:</span>
<span class="status-value">{{ normalFiles.length }}</span>
</div>
<div class="status-item">
<span class="status-label">粗糙度贴图:</span>
<span class="status-value">{{ roughnessFiles.length }}</span>
</div>
<div class="status-item highlight">
<span class="status-label">可匹配文件组:</span>
<span class="status-value">{{ getMatchCount() }}</span>
</div>
</div>
</div>
<div class="action-buttons">
<button
class="btn generate-btn"
:class="{ disabled: !canProcess }"
@click="generateTGA"
:disabled="!canProcess">
<span v-if="!isProcessing">生成地形TGA文件</span>
<span v-else class="processing">
<svg class="spinner" viewBox="0 0 50 50">
<circle class="path" cx="25" cy="25" r="20" fill="none" stroke-width="5"></circle>
</svg>
处理中...
</span>
</button>
<button
class="btn download-btn"
:class="{ disabled: generatedFiles.length === 0 }"
@click="downloadZip"
:disabled="generatedFiles.length === 0">
打包下载ZIP
</button>
</div>
<div class="results-section" v-if="generatedFiles.length > 0">
<h2>生成结果</h2>
<div class="results-grid">
<div v-for="(file, index) in generatedFiles" :key="index" class="result-item">
<div class="file-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path>
<polyline points="13 2 13 9 20 9"></polyline>
</svg>
</div>
<div class="file-name">{{ file.name }}</div>
<div class="file-size">{{ formatFileSize(file.blob.size) }}</div>
</div>
</div>
</div>
<div class="instructions" v-if="generatedFiles.length === 0 && !canProcess">
<h3>使用说明</h3>
<ol>
<li>上传颜色法线和粗糙度贴图PNG/JPG格式</li>
<li>确保每组贴图有相同的文件名仅扩展名不同</li>
<li>点击"生成地形TGA文件"按钮</li>
<li>处理完成后下载ZIP包</li>
</ol>
<p class="note">注意系统会自动匹配文件名相同的贴图进行合成处理</p>
</div>
</div>
</template>
<script>
import JSZip from "jszip";
export default {
name: "TerrainTool",
data() {
return {
colorFiles: [],
normalFiles: [],
roughnessFiles: [],
generatedFiles: [],
isProcessing: false,
};
},
computed: {
canProcess() {
return (
this.colorFiles.length > 0 &&
this.normalFiles.length > 0 &&
this.roughnessFiles.length > 0 &&
this.getMatchCount() > 0
);
},
},
methods: {
onFilesSelected(type, e) {
const files = Array.from(e.target.files);
this[type + "Files"] = files;
},
getMatchCount() {
const names = (list) => list.map((f) => f.name.split('.')[0]);
const common = names(this.colorFiles).filter(
(n) => names(this.normalFiles).includes(n) && names(this.roughnessFiles).includes(n)
);
return common.length;
},
async generateTGA() {
if (!this.canProcess || this.isProcessing) return;
this.isProcessing = true;
this.generatedFiles = [];
try {
const nameMap = (files) => Object.fromEntries(files.map((f) => [f.name.split('.')[0], f]));
const colorMap = nameMap(this.colorFiles);
const normalMap = nameMap(this.normalFiles);
const roughMap = nameMap(this.roughnessFiles);
const commonKeys = Object.keys(colorMap).filter(
(k) => normalMap[k] && roughMap[k]
);
for (const key of commonKeys) {
const color = await this.loadImage(colorMap[key]);
const normal = await this.loadImage(normalMap[key]);
const rough = await this.loadImage(roughMap[key]);
const [resultColor, resultNRM] = this.processImages(color, normal, rough);
this.generatedFiles.push({ name: `${key}_ResultColor.tga`, blob: resultColor });
this.generatedFiles.push({ name: `${key}_ResultNRM.tga`, blob: resultNRM });
}
} catch (error) {
console.error("生成过程中出错:", error);
alert("处理过程中发生错误,请检查文件格式后再试。");
} finally {
this.isProcessing = false;
}
},
loadImage(file) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = URL.createObjectURL(file);
});
},
processImages(colorImg, normalImg, roughImg) {
const canvas = document.createElement("canvas");
canvas.width = 256;
canvas.height = 256;
const ctx = canvas.getContext("2d");
// COLOR+ROUGHNESS
ctx.drawImage(colorImg, 0, 0, 256, 256);
const rgbData = ctx.getImageData(0, 0, 256, 256);
ctx.clearRect(0, 0, 256, 256);
ctx.drawImage(roughImg, 0, 0, 256, 256);
const roughData = ctx.getImageData(0, 0, 256, 256);
const rgbaData = ctx.createImageData(256, 256);
for (let i = 0; i < rgbaData.data.length; i += 4) {
rgbaData.data[i] = rgbData.data[i]; // R
rgbaData.data[i + 1] = rgbData.data[i + 1]; // G
rgbaData.data[i + 2] = rgbData.data[i + 2]; // B
rgbaData.data[i + 3] = 255 - roughData.data[i]; // A =
}
ctx.putImageData(rgbaData, 0, 0);
const resultColorBlob = this.toTGA(ctx, 256, 256);
// NORMAL
ctx.clearRect(0, 0, 256, 256);
ctx.drawImage(normalImg, 0, 0, 256, 256);
const resultNRMBlob = this.toTGA(ctx, 256, 256);
return [resultColorBlob, resultNRMBlob];
},
toTGA(ctx, width, height) {
const pixels = ctx.getImageData(0, 0, width, height).data;
const header = new Uint8Array(18);
header[2] = 2;
header[12] = width & 0xff;
header[13] = (width >> 8) & 0xff;
header[14] = height & 0xff;
header[15] = (height >> 8) & 0xff;
header[16] = 32;
header[17] = 0x00; //
const data = new Uint8Array(width * height * 4);
let offset = 0;
for (let y = height - 1; y >= 0; y--) {
for (let x = 0; x < width; x++) {
const i = (y * width + x) * 4;
data[offset++] = pixels[i + 2]; // B
data[offset++] = pixels[i + 1]; // G
data[offset++] = pixels[i]; // R
data[offset++] = pixels[i + 3]; // A
}
}
return new Blob([header, data], { type: "application/octet-stream" });
},
async downloadZip() {
if (this.generatedFiles.length === 0) return;
try {
const zip = new JSZip();
for (const file of this.generatedFiles) {
zip.file(file.name, file.blob);
}
const blob = await zip.generateAsync({ type: "blob" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = "TerrainTextures.zip";
a.click();
} catch (error) {
console.error("打包下载出错:", error);
alert("创建ZIP文件时出错请重试。");
}
},
formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
},
};
</script>
<style scoped>
.terrain-tool-container {
max-width: 1000px;
margin: 0 auto;
padding: 2rem;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
color: #333;
}
.header {
text-align: center;
margin-bottom: 2rem;
}
.header h1 {
font-size: 2.2rem;
color: #2c3e50;
margin-bottom: 0.5rem;
}
.subtitle {
color: #7f8c8d;
font-size: 1.1rem;
}
.upload-section {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.upload-card {
border: 2px dashed #bdc3c7;
border-radius: 12px;
padding: 1.5rem;
transition: all 0.3s ease;
background-color: #f8f9fa;
}
.upload-card.active {
border-color: #3498db;
background-color: #e8f4fc;
}
.upload-card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1);
}
.upload-label {
display: block;
cursor: pointer;
}
.upload-label input {
display: none;
}
.upload-content {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.upload-content svg {
margin-bottom: 1rem;
color: #7f8c8d;
}
.upload-card.active svg {
color: #3498db;
}
.upload-content h3 {
margin: 0 0 0.5rem;
color: #2c3e50;
}
.upload-content p {
margin: 0;
color: #7f8c8d;
font-size: 0.9rem;
}
.status-section {
margin-bottom: 2rem;
}
.status-card {
background: #fff;
border-radius: 10px;
padding: 1.5rem;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
.status-item {
display: flex;
justify-content: space-between;
padding: 0.5rem 0;
border-bottom: 1px solid #eee;
}
.status-item.highlight {
font-weight: bold;
color: #3498db;
border-bottom: none;
}
.status-label {
color: #7f8c8d;
}
.status-value {
font-weight: bold;
}
.action-buttons {
display: flex;
justify-content: center;
gap: 1rem;
margin-bottom: 2rem;
flex-wrap: wrap;
}
.btn {
padding: 0.8rem 1.8rem;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
justify-content: center;
}
.btn:disabled {
cursor: not-allowed;
opacity: 0.7;
}
.generate-btn {
background-color: #3498db;
color: white;
min-width: 200px;
}
.generate-btn:hover:not(:disabled) {
background-color: #2980b9;
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(52, 152, 219, 0.3);
}
.download-btn {
background-color: #2ecc71;
color: white;
}
.download-btn:hover:not(:disabled) {
background-color: #27ae60;
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(46, 204, 113, 0.3);
}
.btn.disabled {
background-color: #bdc3c7;
}
.processing {
display: flex;
align-items: center;
gap: 0.5rem;
}
.spinner {
width: 20px;
height: 20px;
animation: rotate 2s linear infinite;
}
.spinner .path {
stroke: white;
stroke-linecap: round;
animation: dash 1.5s ease-in-out infinite;
}
@keyframes rotate {
100% {
transform: rotate(360deg);
}
}
@keyframes dash {
0% {
stroke-dasharray: 1, 150;
stroke-dashoffset: 0;
}
50% {
stroke-dasharray: 90, 150;
stroke-dashoffset: -35;
}
100% {
stroke-dasharray: 90, 150;
stroke-dashoffset: -124;
}
}
.results-section {
margin-top: 2rem;
animation: fadeIn 0.5s ease;
}
.results-section h2 {
color: #2c3e50;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid #eee;
}
.results-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1rem;
}
.result-item {
background: #fff;
border-radius: 8px;
padding: 1rem;
display: flex;
align-items: center;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
transition: transform 0.2s ease;
}
.result-item:hover {
transform: translateY(-3px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.file-icon {
margin-right: 1rem;
color: #3498db;
}
.file-name {
flex: 1;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-size {
color: #7f8c8d;
font-size: 0.85rem;
}
.instructions {
background: #f8f9fa;
border-radius: 10px;
padding: 1.5rem;
margin-top: 2rem;
border-left: 4px solid #3498db;
}
.instructions h3 {
color: #2c3e50;
margin-top: 0;
}
.instructions ol {
padding-left: 1.5rem;
line-height: 1.6;
}
.instructions .note {
font-style: italic;
color: #7f8c8d;
margin-top: 1rem;
font-size: 0.9rem;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 768px) {
.upload-section {
grid-template-columns: 1fr;
}
.action-buttons {
flex-direction: column;
}
.btn {
width: 100%;
}
}
</style>

View 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">
&lt; 上一页
</button>
<span class="page-info"> {{ currentPage }} / {{ totalPages }} </span>
<button class="pagination-btn" @click="nextPage" :disabled="currentPage === totalPages">
下一页 &gt;
</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">
&lt; 上一页
</button>
<span class="page-info"> {{ currentPage }} / {{ totalPages }} </span>
<button class="pagination-btn" @click="nextPage" :disabled="currentPage === totalPages">
下一页 &gt;
</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>