重构Play.vue界面,优化标签页和状态显示,添加游戏截图展示区域,更新状态管理逻辑,提升用户体验。

This commit is contained in:
zyh
2025-08-27 17:29:17 +08:00
parent e3db781926
commit dbdea7aaa3
5 changed files with 1679 additions and 204 deletions

101
src/api/config.js Normal file
View File

@@ -0,0 +1,101 @@
import http from '../plugins/http'
/**
* 系统配置管理API
*/
// 获取配置列表
export function getConfigList(params) {
return http.get('/api/admin/config/list', { params })
}
// 根据键获取配置
export function getConfigByKey(configKey) {
return http.get(`/api/admin/config/key/${configKey}`)
}
// 根据类型获取配置
export function getConfigByType(configType) {
return http.get(`/api/admin/config/type/${configType}`)
}
// 创建配置
export function createConfig(data) {
return http.post('/api/admin/config', data)
}
// 更新配置
export function updateConfig(id, data) {
return http.put(`/api/admin/config/${id}`, data)
}
// 删除配置
export function deleteConfig(id) {
return http.delete(`/api/admin/config/${id}`)
}
// 根据键删除配置
export function deleteConfigByKey(configKey) {
return http.delete(`/api/admin/config/key/${configKey}`)
}
// 根据键快速更新配置值
export function updateConfigValue(configKey, value) {
return http.put(`/api/admin/config/key/${configKey}`, value, {
headers: {
'Content-Type': 'application/json'
}
})
}
// 批量更新配置
export function batchUpdateConfigs(configs) {
return http.post('/api/admin/config/batch', { configs })
}
// 获取链接默认配置
export function getLinkDefaults() {
return http.get('/api/admin/config/link/defaults')
}
// 获取脚本配置
export function getScriptConfig() {
return http.get('/api/admin/config/script/config')
}
// 获取用户端配置
export function getUserConfig() {
return http.get('/api/admin/config/user/config')
}
// 验证配置值
export function validateConfigValue(configKey, value, configType) {
return http.post('/api/admin/config/validate', {
configKey,
value,
configType
})
}
// 重置配置到默认值
export function resetConfig(configKey) {
return http.post(`/api/admin/config/reset/${configKey}`)
}
// 导出配置
export function exportConfigs() {
return http.get('/api/admin/config/export', {
responseType: 'blob'
})
}
// 导入配置
export function importConfigs(file) {
const formData = new FormData()
formData.append('file', file)
return http.post('/api/admin/config/import', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
}

View File

@@ -84,61 +84,50 @@
<!-- 二界面 -->
<div v-else-if="state.status === 'LOGGED_IN'" class="game-page">
<div class="game-header">
<div class="browser-bar">
<span class="url">{{ getCurrentUrl() }}</span>
</div>
<!-- 顶部标签页 -->
<div class="tab-header">
<div class="tab-item active">代练大区</div>
<div class="tab-item status-tab">状态</div>
<div class="tab-item target-tab">目标点数</div>
</div>
<div class="tab-header">
<div class="tab-item active" v-if="state.region === 'Q'">QQ</div>
<div class="tab-item active" v-if="state.region === 'V'">微信</div>
<div class="tab-item status-tab">状态</div>
<div class="tab-item target-tab">{{ state.totalPoints || 0 }}</div>
</div>
<div class="tab-content">
<div class="game-content">
<!-- 大厅选择显示 -->
<div class="hall-info">
<span class="hall-label">选择大厅:</span>
<span class="hall-value">{{ getRegionName() }}</span>
<!-- 状态提示 -->
<div class="status-message">
正在代练中期间请勿操号耐心等待代练完成......
</div>
<!-- 状态显示 -->
<div class="status-info">
<span class="status-label">状态:</span>
<span class="status-value" :class="getStatusClass()">{{ getGameStatus() }}</span>
</div>
<!-- 目标点数进度 -->
<div class="progress-section">
<div class="progress-header">
<span class="progress-label">目标点数</span>
<span class="progress-value">{{ state.currentPoints || 0 }}/{{ state.totalPoints || 1000 }}</span>
<!-- 游戏截图展示区域 -->
<div class="image-gallery">
<div class="image-item">
<img src="http://36.138.184.60:12345/f1/首次主页.png" alt="首次主页" class="game-image" />
<div class="image-label">首次主页</div>
</div>
<div class="progress-bar">
<div class="progress-fill" :style="{ width: getProgressPercent() + '%' }"></div>
<div class="image-item">
<img src="http://36.138.184.60:12345/f1/首次赏金.png" alt="首次赏金" class="game-image" style="transform: rotate(-90deg);" />
<div class="image-label">首次赏金</div>
</div>
<div class="image-item">
<img src="http://36.138.184.60:12345/f1/中途赏金.png" alt="中途赏金" class="game-image" />
<div class="image-label">中途赏金</div>
</div>
<div class="image-item">
<img src="http://36.138.184.60:12345/f1/结束赏金.png" alt="结束赏金" class="game-image" />
<div class="image-label">结束赏金</div>
</div>
</div>
<!-- 游戏截图区域 -->
<div class="game-images" v-if="state.assets">
<div class="image-container">
<img
v-if="getCurrentGameImage()"
:src="getCurrentGameImage()"
class="game-screenshot"
alt="游戏截图"
/>
<div v-else class="placeholder-image">
<p>正在上号中请稍等...</p>
</div>
</div>
</div>
<!-- 操作按钮区域 -->
<div class="game-actions">
<button class="action-btn completed-btn">已完成</button>
<button class="action-btn current-btn">进行中</button>
<button class="action-btn pending-btn">未开始</button>
</div>
<!-- 底部信息 -->
<div class="bottom-info">
<p class="safe-text">安全可靠的代练服务</p>
<!-- 底部状态显示 -->
<div class="bottom-status">
ss{{ state.currentPoints || 0 }}
</div>
</div>
</div>
@@ -299,10 +288,18 @@ export default {
state.status = 'LOGGED_IN'
state.assets = gameData.assets
// 从游戏接口数据中更新总点数和当前进度
if (gameData.assets && gameData.assets.totalPoints) {
// 更新区域信息
if (gameData.region) {
state.region = gameData.region
}
// 从游戏接口数据中更新总点数 - 优先使用根级别的totalPoints
if (gameData.totalPoints) {
state.totalPoints = gameData.totalPoints
} else if (gameData.assets && gameData.assets.totalPoints) {
state.totalPoints = gameData.assets.totalPoints
}
// 初始化当前点数为0代表刚开始
state.currentPoints = 0
@@ -329,7 +326,12 @@ export default {
state.region = data.region
state.assets = data.assets
// 如果有游戏数据,更新点数信息
// 更新点数信息 - totalPoints 在根级别
if (data.totalPoints) {
state.totalPoints = data.totalPoints
}
// 如果有游戏数据也检查assets中的点数信息
if (data.assets && data.assets.totalPoints) {
state.totalPoints = data.assets.totalPoints
// 如果没有当前进度初始化为0
@@ -939,178 +941,194 @@ export default {
background: white;
display: flex;
flex-direction: column;
min-height: 100vh;
}
.game-header {
background: #f8f9fa;
padding: 12px;
border-bottom: 1px solid #e9ecef;
}
.browser-bar {
background: white;
border: 1px solid #dee2e6;
border-radius: 20px;
padding: 8px 16px;
font-size: 14px;
color: #666;
text-align: center;
}
.game-content {
flex: 1;
padding: 20px;
overflow-y: auto;
}
.hall-info, .status-info {
/* 标签页头部 */
.tab-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
background: #f8f9fa;
border-bottom: 1px solid #e9ecef;
font-size: 16px;
}
.hall-label, .status-label {
color: #666;
.tab-item {
flex: 1;
padding: 12px 16px;
text-align: center;
font-size: 14px;
font-weight: 500;
}
.hall-value {
color: #333;
font-weight: 600;
}
.status-value {
font-weight: 600;
padding: 4px 12px;
border-radius: 12px;
font-size: 14px;
}
.status-completed {
background: #d4edda;
color: #155724;
}
.status-playing {
background: #fff3cd;
color: #856404;
}
.status-idle {
background: #d1ecf1;
color: #0c5460;
}
.progress-section {
margin: 20px 0;
}
.progress-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.progress-label {
font-size: 16px;
font-weight: 600;
color: #333;
}
.progress-value {
font-size: 16px;
font-weight: 600;
color: #667eea;
}
.progress-bar {
height: 8px;
background: #e9ecef;
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #667eea, #764ba2);
border-radius: 4px;
transition: width 0.3s ease;
}
.game-images {
margin: 20px 0;
}
.image-container {
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 12px;
min-height: 200px;
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
}
.game-screenshot {
max-width: 100%;
max-height: 300px;
object-fit: contain;
}
.placeholder-image {
color: #666;
text-align: center;
padding: 40px;
}
.game-actions {
display: flex;
gap: 12px;
margin: 20px 0;
}
.action-btn {
flex: 1;
padding: 12px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
border-bottom: 2px solid transparent;
cursor: pointer;
transition: all 0.3s ease;
}
.completed-btn {
background: #28a745;
.tab-item.active {
background: #4CAF50;
color: white;
border-bottom-color: #4CAF50;
}
.tab-item.status-tab {
background: #2196F3;
color: white;
}
.current-btn {
background: #ffc107;
color: #212529;
.tab-item.target-tab {
color: #f44336;
font-weight: 600;
}
.pending-btn {
background: #6c757d;
color: white;
/* 标签内容区域 */
.tab-content {
flex: 1;
padding: 16px;
display: flex;
flex-direction: column;
}
.bottom-info {
text-align: center;
padding: 20px 0;
border-top: 1px solid #e9ecef;
margin-top: auto;
/* 标签按钮区域 */
.tab-buttons {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 16px;
}
.safe-text {
color: #666;
.tab-btn {
padding: 8px 16px;
border: 1px solid #ddd;
border-radius: 4px;
background: white;
font-size: 14px;
margin: 0;
cursor: pointer;
transition: all 0.3s ease;
}
.tab-btn.qq-active {
background: #4CAF50;
color: white;
border-color: #4CAF50;
}
.tab-btn.ios-btn {
background: #2196F3;
color: white;
border-color: #2196F3;
}
.target-points {
margin-left: auto;
color: #f44336;
font-weight: 600;
font-size: 16px;
}
/* 状态消息 */
.status-message {
background: #fff3cd;
color: #856404;
padding: 12px 16px;
border-radius: 4px;
margin-bottom: 20px;
text-align: center;
font-size: 14px;
border-left: 4px solid #f44336;
}
/* 功能按钮网格 */
.function-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
margin-bottom: 20px;
flex: 1;
}
.function-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px 12px;
border: 1px solid #e9ecef;
border-radius: 8px;
background: white;
cursor: pointer;
transition: all 0.3s ease;
min-height: 100px;
}
.function-item:hover {
background: #f8f9fa;
border-color: #4CAF50;
}
.function-icon {
font-size: 32px;
margin-bottom: 8px;
line-height: 1;
}
.function-text {
font-size: 12px;
color: #333;
text-align: center;
line-height: 1.3;
font-weight: 500;
}
/* 游戏截图展示区域 */
.image-gallery {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
margin-bottom: 20px;
}
.image-item {
display: flex;
flex-direction: column;
align-items: center;
background: white;
border: 1px solid #e9ecef;
border-radius: 8px;
padding: 12px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
}
.image-item:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
.game-image {
width: 100%;
max-width: 150px;
height: auto;
border-radius: 6px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
margin-bottom: 8px;
}
.image-label {
font-size: 12px;
color: #333;
font-weight: 500;
text-align: center;
}
/* 底部状态显示 */
.bottom-status {
text-align: center;
padding: 20px;
background: #f8f9fa;
border-top: 1px solid #e9ecef;
margin: 0 -16px -16px -16px;
font-size: 18px;
font-weight: 600;
color: #333;
}
/* 刷新等待界面 */
@@ -1240,8 +1258,69 @@ export default {
height: 150px;
}
.game-actions {
/* 移动端二界面优化 */
.tab-item {
padding: 10px 8px;
font-size: 12px;
}
.tab-buttons {
flex-direction: column;
gap: 8px;
align-items: stretch;
}
.tab-btn {
padding: 12px;
text-align: center;
}
.target-points {
margin-left: 0;
text-align: center;
margin-top: 8px;
}
.function-grid {
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.function-item {
padding: 16px 8px;
min-height: 80px;
}
.function-icon {
font-size: 24px;
}
.function-text {
font-size: 11px;
}
.status-message {
font-size: 13px;
padding: 10px 12px;
}
/* 移动端图片画廊优化 */
.image-gallery {
grid-template-columns: repeat(2, 1fr);
gap: 8px;
margin-bottom: 16px;
}
.image-item {
padding: 8px;
}
.game-image {
max-width: 120px;
}
.image-label {
font-size: 11px;
}
}
</style>

View File

@@ -1,10 +1,549 @@
<template>
<el-card shadow="hover">
<template #header>系统设置</template>
<p>系统设置空页面参数配置权限主题</p>
</el-card>
<div class="system-config">
<!-- 页面标题 -->
<div class="page-header">
<h2>系统参数配置</h2>
<p>管理系统运行参数和配置项</p>
</div>
<!-- 操作按钮 -->
<div class="action-bar">
<el-button type="primary" @click="handleAdd" v-permission="'setting:manage'">
<el-icon><Plus /></el-icon>
添加配置
</el-button>
<el-button type="success" @click="handleBatchSave" :disabled="!hasChanges">
<el-icon><Check /></el-icon>
批量保存
</el-button>
<el-button type="warning" @click="handleExport">
<el-icon><Download /></el-icon>
导出配置
</el-button>
<el-button type="info" @click="handleImport">
<el-icon><Upload /></el-icon>
导入配置
</el-button>
<el-button @click="handleRefresh">
<el-icon><Refresh /></el-icon>
刷新
</el-button>
</div>
<!-- 配置分类标签页 -->
<el-tabs v-model="activeTab" @tab-change="handleTabChange">
<el-tab-pane label="链接配置" name="link">
<config-group
:configs="linkConfigs"
@update="handleConfigUpdate"
@delete="handleConfigDelete"
/>
</el-tab-pane>
<el-tab-pane label="脚本配置" name="script">
<config-group
:configs="scriptConfigs"
@update="handleConfigUpdate"
@delete="handleConfigDelete"
/>
</el-tab-pane>
<el-tab-pane label="用户端配置" name="user">
<config-group
:configs="userConfigs"
@update="handleConfigUpdate"
@delete="handleConfigDelete"
/>
</el-tab-pane>
<el-tab-pane label="全部配置" name="all">
<config-table
:configs="allConfigs"
:loading="loading"
@update="handleConfigUpdate"
@delete="handleConfigDelete"
@edit="handleEdit"
/>
</el-tab-pane>
</el-tabs>
<!-- 添加/编辑配置对话框 -->
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="600px"
@close="handleDialogClose"
>
<el-form
ref="formRef"
:model="form"
:rules="formRules"
label-width="120px"
label-position="right"
>
<el-form-item label="配置键" prop="configKey">
<el-input
v-model="form.configKey"
placeholder="请输入配置键link.default_quantity"
:disabled="isEdit"
/>
</el-form-item>
<el-form-item label="配置值" prop="configValue">
<el-input
v-if="form.configType !== 'JSON'"
v-model="form.configValue"
:placeholder="getValuePlaceholder(form.configType)"
:type="form.configType === 'INTEGER' ? 'number' : 'text'"
/>
<el-input
v-else
v-model="form.configValue"
type="textarea"
:rows="4"
placeholder="请输入有效的JSON格式"
/>
</el-form-item>
<el-form-item label="配置类型" prop="configType">
<el-select v-model="form.configType" placeholder="请选择配置类型">
<el-option label="字符串" value="STRING" />
<el-option label="整数" value="INTEGER" />
<el-option label="布尔值" value="BOOLEAN" />
<el-option label="JSON" value="JSON" />
</el-select>
</el-form-item>
<el-form-item label="配置描述" prop="description">
<el-input
v-model="form.description"
type="textarea"
:rows="2"
placeholder="请输入配置描述"
/>
</el-form-item>
<el-form-item label="系统配置">
<el-switch
v-model="form.isSystem"
active-text=""
inactive-text=""
/>
<div class="form-tip">系统配置项通常不建议删除</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitLoading">
确定
</el-button>
</template>
</el-dialog>
<!-- 导入文件对话框 -->
<el-dialog v-model="importDialogVisible" title="导入配置" width="400px">
<el-upload
ref="uploadRef"
class="upload-demo"
drag
:auto-upload="false"
:limit="1"
accept=".json"
@change="handleFileChange"
>
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
<div class="el-upload__text">
将JSON文件拖到此处<em>点击上传</em>
</div>
<template #tip>
<div class="el-upload__tip">
只能上传json文件且不超过500kb
</div>
</template>
</el-upload>
<template #footer>
<el-button @click="importDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleImportConfirm" :loading="importLoading">
导入
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, nextTick } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Plus,
Check,
Download,
Upload,
Refresh,
UploadFilled
} from '@element-plus/icons-vue'
import {
getConfigList,
createConfig,
updateConfig,
deleteConfig,
batchUpdateConfigs,
exportConfigs,
importConfigs,
getLinkDefaults,
getScriptConfig,
getUserConfig
} from '@/api/config'
import ConfigGroup from './components/ConfigGroup.vue'
import ConfigTable from './components/ConfigTable.vue'
// 响应式数据
const loading = ref(false)
const activeTab = ref('link')
const allConfigs = ref([])
const changedConfigs = ref(new Map())
// 对话框相关
const dialogVisible = ref(false)
const importDialogVisible = ref(false)
const isEdit = ref(false)
const submitLoading = ref(false)
const importLoading = ref(false)
const formRef = ref()
const uploadRef = ref()
const uploadFile = ref(null)
// 表单数据
const form = reactive({
id: null,
configKey: '',
configValue: '',
configType: 'STRING',
description: '',
isSystem: false
})
// 表单验证规则
const formRules = {
configKey: [
{ required: true, message: '请输入配置键', trigger: 'blur' },
{ min: 2, max: 100, message: '配置键长度在 2 到 100 个字符', trigger: 'blur' },
{ pattern: /^[a-zA-Z][a-zA-Z0-9_.]*$/, message: '配置键只能包含字母、数字、点和下划线,且以字母开头', trigger: 'blur' }
],
configValue: [
{ required: true, message: '请输入配置值', trigger: 'blur' }
],
configType: [
{ required: true, message: '请选择配置类型', trigger: 'change' }
],
description: [
{ max: 500, message: '描述长度不能超过 500 个字符', trigger: 'blur' }
]
}
// 计算属性
const dialogTitle = computed(() => isEdit.value ? '编辑配置' : '添加配置')
const hasChanges = computed(() => changedConfigs.value.size > 0)
const linkConfigs = computed(() => {
return allConfigs.value.filter(config => config.configKey.startsWith('link.'))
})
const scriptConfigs = computed(() => {
return allConfigs.value.filter(config => config.configKey.startsWith('script.'))
})
const userConfigs = computed(() => {
return allConfigs.value.filter(config => config.configKey.startsWith('user.'))
})
// 生命周期
onMounted(() => {
loadConfigs()
})
// 方法
async function loadConfigs() {
try {
loading.value = true
const response = await getConfigList({ page: 1, size: 1000 })
allConfigs.value = response.data?.items || []
changedConfigs.value.clear()
} catch (error) {
console.error('加载配置失败:', error)
ElMessage.error('加载配置失败')
} finally {
loading.value = false
}
}
function handleTabChange(tabName) {
activeTab.value = tabName
}
function handleAdd() {
isEdit.value = false
resetForm()
dialogVisible.value = true
}
function handleEdit(config) {
isEdit.value = true
form.id = config.id
form.configKey = config.configKey
form.configValue = config.configValue
form.configType = config.configType
form.description = config.description
form.isSystem = config.isSystem
dialogVisible.value = true
}
function resetForm() {
form.id = null
form.configKey = ''
form.configValue = ''
form.configType = 'STRING'
form.description = ''
form.isSystem = false
nextTick(() => {
formRef.value?.clearValidate()
})
}
function handleDialogClose() {
resetForm()
}
async function handleSubmit() {
try {
await formRef.value.validate()
// 验证配置值格式
if (!validateConfigValue(form.configValue, form.configType)) {
return
}
submitLoading.value = true
const data = {
configKey: form.configKey,
configValue: form.configValue,
configType: form.configType,
description: form.description,
isSystem: form.isSystem
}
if (isEdit.value) {
await updateConfig(form.id, data)
ElMessage.success('更新配置成功')
} else {
await createConfig(data)
ElMessage.success('添加配置成功')
}
dialogVisible.value = false
await loadConfigs()
} catch (error) {
console.error('提交配置失败:', error)
ElMessage.error(error.response?.data?.message || '操作失败')
} finally {
submitLoading.value = false
}
}
function validateConfigValue(value, type) {
switch (type) {
case 'INTEGER':
if (!/^\d+$/.test(value)) {
ElMessage.error('整数类型的值必须是数字')
return false
}
break
case 'BOOLEAN':
if (!['true', 'false'].includes(value.toLowerCase())) {
ElMessage.error('布尔类型的值必须是 true 或 false')
return false
}
break
case 'JSON':
try {
JSON.parse(value)
} catch {
ElMessage.error('JSON类型的值必须是有效的JSON格式')
return false
}
break
}
return true
}
function getValuePlaceholder(type) {
switch (type) {
case 'INTEGER':
return '请输入整数100'
case 'BOOLEAN':
return '请输入 true 或 false'
case 'STRING':
return '请输入字符串值'
default:
return '请输入配置值'
}
}
function handleConfigUpdate(config) {
changedConfigs.value.set(config.configKey, config)
}
async function handleConfigDelete(config) {
try {
await ElMessageBox.confirm(
`确定要删除配置项 "${config.configKey}" 吗?`,
'确认删除',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}
)
await deleteConfig(config.id)
ElMessage.success('删除成功')
await loadConfigs()
} catch (error) {
if (error !== 'cancel') {
console.error('删除配置失败:', error)
ElMessage.error('删除失败')
}
}
}
async function handleBatchSave() {
if (changedConfigs.value.size === 0) {
ElMessage.warning('没有需要保存的更改')
return
}
try {
const configs = Array.from(changedConfigs.value.values())
await batchUpdateConfigs(configs)
ElMessage.success(`成功保存 ${configs.length} 个配置项`)
changedConfigs.value.clear()
await loadConfigs()
} catch (error) {
console.error('批量保存失败:', error)
ElMessage.error('批量保存失败')
}
}
async function handleExport() {
try {
const response = await exportConfigs()
const blob = new Blob([response.data], { type: 'application/json' })
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `system-config-${new Date().toISOString().split('T')[0]}.json`
link.click()
window.URL.revokeObjectURL(url)
ElMessage.success('导出成功')
} catch (error) {
console.error('导出失败:', error)
ElMessage.error('导出失败')
}
}
function handleImport() {
importDialogVisible.value = true
}
function handleFileChange(file) {
uploadFile.value = file.raw
}
async function handleImportConfirm() {
if (!uploadFile.value) {
ElMessage.warning('请选择要导入的文件')
return
}
try {
importLoading.value = true
await importConfigs(uploadFile.value)
ElMessage.success('导入成功')
importDialogVisible.value = false
uploadFile.value = null
uploadRef.value?.clearFiles()
await loadConfigs()
} catch (error) {
console.error('导入失败:', error)
ElMessage.error('导入失败')
} finally {
importLoading.value = false
}
}
function handleRefresh() {
loadConfigs()
}
</script>
<style scoped>
.system-config {
padding: 20px;
}
.page-header {
margin-bottom: 24px;
}
.page-header h2 {
margin: 0 0 8px 0;
color: #303133;
font-size: 24px;
font-weight: 600;
}
.page-header p {
margin: 0;
color: #606266;
font-size: 14px;
}
.action-bar {
margin-bottom: 24px;
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.form-tip {
font-size: 12px;
color: #909399;
margin-top: 4px;
}
.upload-demo {
width: 100%;
}
:deep(.el-tabs__content) {
padding-top: 20px;
}
:deep(.el-card) {
margin-bottom: 20px;
}
:deep(.el-form-item__label) {
font-weight: 500;
}
:deep(.el-dialog__body) {
padding-top: 10px;
}
</style>

View File

@@ -0,0 +1,362 @@
<template>
<div class="config-group">
<el-card v-for="config in configs" :key="config.id" class="config-card" shadow="hover">
<template #header>
<div class="card-header">
<div class="config-info">
<h4 class="config-key">{{ config.configKey }}</h4>
<span class="config-type" :class="`type-${config.configType.toLowerCase()}`">
{{ getTypeLabel(config.configType) }}
</span>
</div>
<div class="card-actions">
<el-button
type="primary"
link
@click="handleEdit(config)"
v-permission="'setting:manage'"
>
编辑
</el-button>
<el-button
type="danger"
link
@click="handleDelete(config)"
v-permission="'setting:manage'"
:disabled="config.isSystem"
>
删除
</el-button>
</div>
</div>
</template>
<div class="config-content">
<div class="config-description">
<span class="label">描述</span>
<span class="value">{{ config.description || '无描述' }}</span>
</div>
<div class="config-value-section">
<div class="value-label">
<span class="label">配置值</span>
<el-tag v-if="config.isSystem" type="warning" size="small">系统配置</el-tag>
</div>
<div class="value-input">
<el-input
v-if="config.configType !== 'JSON'"
v-model="localValues[config.configKey]"
:type="config.configType === 'INTEGER' ? 'number' : 'text'"
@blur="handleValueChange(config)"
@keyup.enter="handleValueChange(config)"
:placeholder="getValuePlaceholder(config.configType)"
/>
<el-input
v-else
v-model="localValues[config.configKey]"
type="textarea"
:rows="3"
@blur="handleValueChange(config)"
placeholder="请输入有效的JSON格式"
/>
</div>
<div v-if="hasChanged(config)" class="value-status">
<el-tag type="success" size="small">
<el-icon><Edit /></el-icon>
已修改
</el-tag>
</div>
</div>
<div class="config-meta">
<div class="meta-item">
<span class="label">创建时间</span>
<span class="value">{{ formatDate(config.createdAt) }}</span>
</div>
<div class="meta-item">
<span class="label">更新时间</span>
<span class="value">{{ formatDate(config.updatedAt) }}</span>
</div>
</div>
</div>
</el-card>
<el-empty v-if="configs.length === 0" description="暂无配置项" />
</div>
</template>
<script setup>
import { ref, reactive, watch, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Edit } from '@element-plus/icons-vue'
const props = defineProps({
configs: {
type: Array,
default: () => []
}
})
const emit = defineEmits(['update', 'delete'])
const localValues = reactive({})
const originalValues = reactive({})
// 监听props变化初始化本地值
watch(() => props.configs, (newConfigs) => {
newConfigs.forEach(config => {
localValues[config.configKey] = config.configValue
originalValues[config.configKey] = config.configValue
})
}, { immediate: true, deep: true })
onMounted(() => {
props.configs.forEach(config => {
localValues[config.configKey] = config.configValue
originalValues[config.configKey] = config.configValue
})
})
function getTypeLabel(type) {
const typeMap = {
STRING: '字符串',
INTEGER: '整数',
BOOLEAN: '布尔值',
JSON: 'JSON'
}
return typeMap[type] || type
}
function getValuePlaceholder(type) {
switch (type) {
case 'INTEGER':
return '请输入整数'
case 'BOOLEAN':
return '请输入 true 或 false'
case 'STRING':
return '请输入字符串值'
default:
return '请输入配置值'
}
}
function hasChanged(config) {
return localValues[config.configKey] !== originalValues[config.configKey]
}
function handleValueChange(config) {
const newValue = localValues[config.configKey]
// 验证配置值
if (!validateConfigValue(newValue, config.configType)) {
// 验证失败,恢复原值
localValues[config.configKey] = originalValues[config.configKey]
return
}
// 如果值有变化,触发更新事件
if (hasChanged(config)) {
emit('update', {
...config,
configValue: newValue
})
}
}
function validateConfigValue(value, type) {
if (!value && value !== '0' && value !== 'false') {
ElMessage.error('配置值不能为空')
return false
}
switch (type) {
case 'INTEGER':
if (!/^\d+$/.test(value)) {
ElMessage.error('整数类型的值必须是数字')
return false
}
break
case 'BOOLEAN':
if (!['true', 'false'].includes(value.toLowerCase())) {
ElMessage.error('布尔类型的值必须是 true 或 false')
return false
}
break
case 'JSON':
try {
JSON.parse(value)
} catch {
ElMessage.error('JSON类型的值必须是有效的JSON格式')
return false
}
break
}
return true
}
function handleEdit(config) {
// 这里可以触发编辑事件,由父组件处理
console.log('编辑配置:', config)
}
function handleDelete(config) {
emit('delete', config)
}
function formatDate(dateString) {
if (!dateString) return '-'
return new Date(dateString).toLocaleString('zh-CN')
}
</script>
<style scoped>
.config-group {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 20px;
}
.config-card {
border: 1px solid #e4e7ed;
transition: all 0.3s ease;
}
.config-card:hover {
border-color: #409eff;
transform: translateY(-2px);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.config-info {
flex: 1;
}
.config-key {
margin: 0 0 8px 0;
font-size: 16px;
font-weight: 600;
color: #303133;
word-break: break-all;
}
.config-type {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.type-string {
background-color: #f0f9ff;
color: #0ea5e9;
border: 1px solid #bae6fd;
}
.type-integer {
background-color: #f0fdf4;
color: #16a34a;
border: 1px solid #bbf7d0;
}
.type-boolean {
background-color: #fef3c7;
color: #d97706;
border: 1px solid #fde68a;
}
.type-json {
background-color: #fdf2f8;
color: #ec4899;
border: 1px solid #fbcfe8;
}
.card-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.config-content {
display: flex;
flex-direction: column;
gap: 16px;
}
.config-description {
display: flex;
align-items: flex-start;
gap: 8px;
}
.config-value-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.value-label {
display: flex;
justify-content: space-between;
align-items: center;
}
.value-input {
position: relative;
}
.value-status {
display: flex;
justify-content: flex-end;
}
.config-meta {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
padding-top: 12px;
border-top: 1px solid #f0f0f0;
}
.meta-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.label {
font-weight: 500;
color: #606266;
font-size: 14px;
}
.value {
color: #303133;
font-size: 14px;
word-break: break-all;
}
:deep(.el-card__header) {
padding: 16px 20px;
border-bottom: 1px solid #f0f0f0;
}
:deep(.el-card__body) {
padding: 20px;
}
:deep(.el-input__wrapper) {
border-radius: 6px;
}
:deep(.el-textarea__inner) {
border-radius: 6px;
}
</style>

View File

@@ -0,0 +1,394 @@
<template>
<div class="config-table">
<el-table
:data="configs"
:loading="loading"
stripe
border
height="600"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="50" />
<el-table-column prop="configKey" label="配置键" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<div class="config-key-cell">
<span class="key-text">{{ row.configKey }}</span>
<el-tag v-if="row.isSystem" type="warning" size="small">系统</el-tag>
</div>
</template>
</el-table-column>
<el-table-column prop="configValue" label="配置值" min-width="200">
<template #default="{ row }">
<div class="config-value-cell">
<el-input
v-if="row.configType !== 'JSON'"
v-model="localValues[row.configKey]"
:type="row.configType === 'INTEGER' ? 'number' : 'text'"
size="small"
@blur="handleValueChange(row)"
@keyup.enter="handleValueChange(row)"
/>
<el-input
v-else
v-model="localValues[row.configKey]"
type="textarea"
:rows="2"
size="small"
@blur="handleValueChange(row)"
/>
<div v-if="hasChanged(row)" class="value-changed">
<el-icon color="#67c23a"><Check /></el-icon>
</div>
</div>
</template>
</el-table-column>
<el-table-column prop="configType" label="类型" width="100" align="center">
<template #default="{ row }">
<el-tag :type="getTypeTagType(row.configType)" size="small">
{{ getTypeLabel(row.configType) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="description" label="描述" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<span class="description-text">{{ row.description || '-' }}</span>
</template>
</el-table-column>
<el-table-column prop="updatedAt" label="更新时间" width="160" align="center">
<template #default="{ row }">
<span class="time-text">{{ formatDate(row.updatedAt) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="120" align="center" fixed="right">
<template #default="{ row }">
<div class="action-buttons">
<el-button
type="primary"
link
size="small"
@click="handleEdit(row)"
v-permission="'setting:manage'"
>
编辑
</el-button>
<el-button
type="danger"
link
size="small"
@click="handleDelete(row)"
v-permission="'setting:manage'"
:disabled="row.isSystem"
>
删除
</el-button>
</div>
</template>
</el-table-column>
</el-table>
<!-- 批量操作栏 -->
<div v-if="selectedConfigs.length > 0" class="batch-actions">
<div class="selected-info">
已选择 {{ selectedConfigs.length }}
</div>
<div class="batch-buttons">
<el-button
type="danger"
size="small"
@click="handleBatchDelete"
:disabled="selectedConfigs.some(c => c.isSystem)"
>
批量删除
</el-button>
<el-button size="small" @click="clearSelection">
取消选择
</el-button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, watch, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Check } from '@element-plus/icons-vue'
const props = defineProps({
configs: {
type: Array,
default: () => []
},
loading: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update', 'delete', 'edit'])
const localValues = reactive({})
const originalValues = reactive({})
const selectedConfigs = ref([])
// 监听props变化初始化本地值
watch(() => props.configs, (newConfigs) => {
newConfigs.forEach(config => {
localValues[config.configKey] = config.configValue
originalValues[config.configKey] = config.configValue
})
}, { immediate: true, deep: true })
onMounted(() => {
props.configs.forEach(config => {
localValues[config.configKey] = config.configValue
originalValues[config.configKey] = config.configValue
})
})
function getTypeLabel(type) {
const typeMap = {
STRING: '字符串',
INTEGER: '整数',
BOOLEAN: '布尔值',
JSON: 'JSON'
}
return typeMap[type] || type
}
function getTypeTagType(type) {
const typeMap = {
STRING: '',
INTEGER: 'success',
BOOLEAN: 'warning',
JSON: 'danger'
}
return typeMap[type] || ''
}
function hasChanged(config) {
return localValues[config.configKey] !== originalValues[config.configKey]
}
function handleValueChange(config) {
const newValue = localValues[config.configKey]
// 验证配置值
if (!validateConfigValue(newValue, config.configType)) {
// 验证失败,恢复原值
localValues[config.configKey] = originalValues[config.configKey]
return
}
// 如果值有变化,触发更新事件
if (hasChanged(config)) {
emit('update', {
...config,
configValue: newValue
})
}
}
function validateConfigValue(value, type) {
if (!value && value !== '0' && value !== 'false') {
ElMessage.error('配置值不能为空')
return false
}
switch (type) {
case 'INTEGER':
if (!/^\d+$/.test(value)) {
ElMessage.error('整数类型的值必须是数字')
return false
}
break
case 'BOOLEAN':
if (!['true', 'false'].includes(value.toLowerCase())) {
ElMessage.error('布尔类型的值必须是 true 或 false')
return false
}
break
case 'JSON':
try {
JSON.parse(value)
} catch {
ElMessage.error('JSON类型的值必须是有效的JSON格式')
return false
}
break
}
return true
}
function handleEdit(config) {
emit('edit', config)
}
function handleDelete(config) {
emit('delete', config)
}
function handleSelectionChange(selection) {
selectedConfigs.value = selection
}
async function handleBatchDelete() {
const systemConfigs = selectedConfigs.value.filter(c => c.isSystem)
if (systemConfigs.length > 0) {
ElMessage.warning('选择的配置中包含系统配置,无法删除')
return
}
try {
await ElMessageBox.confirm(
`确定要删除选中的 ${selectedConfigs.value.length} 个配置项吗?`,
'确认批量删除',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}
)
// 依次删除选中的配置
for (const config of selectedConfigs.value) {
emit('delete', config)
}
selectedConfigs.value = []
} catch (error) {
if (error !== 'cancel') {
console.error('批量删除失败:', error)
}
}
}
function clearSelection() {
selectedConfigs.value = []
}
function formatDate(dateString) {
if (!dateString) return '-'
const date = new Date(dateString)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
</script>
<style scoped>
.config-table {
position: relative;
}
.config-key-cell {
display: flex;
align-items: center;
gap: 8px;
}
.key-text {
flex: 1;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 13px;
color: #303133;
}
.config-value-cell {
position: relative;
display: flex;
align-items: center;
gap: 8px;
}
.value-changed {
display: flex;
align-items: center;
color: #67c23a;
}
.description-text {
color: #606266;
font-size: 14px;
}
.time-text {
color: #909399;
font-size: 13px;
}
.action-buttons {
display: flex;
justify-content: center;
gap: 8px;
}
.batch-actions {
position: sticky;
bottom: 0;
left: 0;
right: 0;
background: #f5f7fa;
border: 1px solid #e4e7ed;
border-top: none;
padding: 12px 16px;
display: flex;
justify-content: space-between;
align-items: center;
z-index: 10;
}
.selected-info {
color: #606266;
font-size: 14px;
}
.batch-buttons {
display: flex;
gap: 8px;
}
:deep(.el-table) {
border-radius: 6px 6px 0 0;
}
:deep(.el-table__header) {
background-color: #fafafa;
}
:deep(.el-table th) {
background-color: #fafafa;
color: #303133;
font-weight: 600;
}
:deep(.el-table td) {
padding: 12px 0;
}
:deep(.el-input__wrapper) {
border-radius: 4px;
box-shadow: 0 0 0 1px #dcdfe6;
}
:deep(.el-input__wrapper:hover) {
box-shadow: 0 0 0 1px #c0c4cc;
}
:deep(.el-input__wrapper.is-focus) {
box-shadow: 0 0 0 1px #409eff;
}
:deep(.el-textarea__inner) {
border-radius: 4px;
}
</style>