This commit is contained in:
zpc 2026-02-09 01:01:55 +08:00
parent fae900819a
commit 82dd3e731b
8 changed files with 683 additions and 3 deletions

View File

@ -0,0 +1,96 @@
using MiAssessment.Admin.Filters;
using MiAssessment.Admin.Models.Common;
using MiAssessment.Admin.Models.Config;
using MiAssessment.Admin.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace MiAssessment.Admin.Controllers;
/// <summary>
/// 后台配置管理控制器
/// </summary>
[ApiController]
[Route("api/admin/config")]
[Authorize]
public class ConfigController : ControllerBase
{
private readonly IAdminConfigService _configService;
private readonly ILogger<ConfigController> _logger;
/// <summary>
/// 配置键常量
/// </summary>
private static class ConfigKeys
{
public const string Upload = "upload_setting";
}
public ConfigController(IAdminConfigService configService, ILogger<ConfigController> logger)
{
_configService = configService;
_logger = logger;
}
/// <summary>
/// 获取上传配置
/// </summary>
/// <returns>上传配置</returns>
[HttpGet("upload/get")]
public async Task<ApiResponse<UploadSetting>> GetUploadConfig()
{
try
{
var config = await _configService.GetConfigAsync<UploadSetting>(ConfigKeys.Upload);
return ApiResponse<UploadSetting>.Success(config ?? new UploadSetting { Type = "1" });
}
catch (Exception ex)
{
_logger.LogError(ex, "获取上传配置失败");
return ApiResponse<UploadSetting>.Error(AdminErrorCodes.InternalError, "获取上传配置失败");
}
}
/// <summary>
/// 更新上传配置
/// </summary>
/// <param name="request">上传配置</param>
/// <returns>更新结果</returns>
[HttpPost("upload/update")]
[OperationLog("配置管理", "更新上传配置")]
public async Task<ApiResponse<bool>> UpdateUploadConfig([FromBody] UploadSetting request)
{
if (string.IsNullOrWhiteSpace(request.Type))
{
return ApiResponse<bool>.Error(AdminErrorCodes.InvalidParameter, "存储类型不能为空");
}
// 验证腾讯云COS配置
if (request.Type == "3")
{
if (string.IsNullOrWhiteSpace(request.AppId))
return ApiResponse<bool>.Error(AdminErrorCodes.InvalidParameter, "AppId不能为空");
if (string.IsNullOrWhiteSpace(request.AccessKeyId))
return ApiResponse<bool>.Error(AdminErrorCodes.InvalidParameter, "SecretId不能为空");
if (string.IsNullOrWhiteSpace(request.AccessKeySecret))
return ApiResponse<bool>.Error(AdminErrorCodes.InvalidParameter, "SecretKey不能为空");
if (string.IsNullOrWhiteSpace(request.Bucket))
return ApiResponse<bool>.Error(AdminErrorCodes.InvalidParameter, "存储桶名称不能为空");
if (string.IsNullOrWhiteSpace(request.Region))
return ApiResponse<bool>.Error(AdminErrorCodes.InvalidParameter, "地域不能为空");
if (string.IsNullOrWhiteSpace(request.Domain))
return ApiResponse<bool>.Error(AdminErrorCodes.InvalidParameter, "访问域名不能为空");
}
try
{
var result = await _configService.UpdateConfigAsync(ConfigKeys.Upload, request);
return ApiResponse<bool>.Success(result, "配置更新成功");
}
catch (Exception ex)
{
_logger.LogError(ex, "更新上传配置失败");
return ApiResponse<bool>.Error(AdminErrorCodes.InternalError, "更新上传配置失败");
}
}
}

View File

@ -51,6 +51,7 @@ public static class ServiceCollectionExtensions
services.AddScoped<IPermissionService, PermissionService>();
services.AddScoped<IOperationLogService, OperationLogService>();
services.AddScoped<IDictService, DictService>();
services.AddScoped<IAdminConfigService, AdminConfigService>();
services.AddScoped<IDataSeeder, DataSeeder>();
services.AddSingleton<ICaptchaService, CaptchaService>();

View File

@ -0,0 +1,42 @@
namespace MiAssessment.Admin.Models.Config;
/// <summary>
/// 上传配置
/// </summary>
public class UploadSetting
{
/// <summary>
/// 存储类型 1本地 2阿里云 3腾讯云
/// </summary>
public string Type { get; set; } = "1";
/// <summary>
/// 腾讯云AppId
/// </summary>
public string? AppId { get; set; }
/// <summary>
/// 存储桶名称
/// </summary>
public string? Bucket { get; set; }
/// <summary>
/// 地域
/// </summary>
public string? Region { get; set; }
/// <summary>
/// SecretId
/// </summary>
public string? AccessKeyId { get; set; }
/// <summary>
/// SecretKey
/// </summary>
public string? AccessKeySecret { get; set; }
/// <summary>
/// 访问域名
/// </summary>
public string? Domain { get; set; }
}

View File

@ -0,0 +1,92 @@
using System;
using System.Text.Json;
using System.Threading.Tasks;
using MiAssessment.Admin.Data;
using MiAssessment.Admin.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace MiAssessment.Admin.Services;
/// <summary>
/// 后台配置服务实现
/// </summary>
public class AdminConfigService : IAdminConfigService
{
private readonly AdminDbContext _dbContext;
private readonly ILogger<AdminConfigService> _logger;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
public AdminConfigService(AdminDbContext dbContext, ILogger<AdminConfigService> logger)
{
_dbContext = dbContext;
_logger = logger;
}
/// <inheritdoc />
public async Task<string?> GetConfigAsync(string key)
{
var config = await _dbContext.AdminConfigs
.AsNoTracking()
.FirstOrDefaultAsync(c => c.ConfigKey == key);
return config?.ConfigValue;
}
/// <inheritdoc />
public async Task<T?> GetConfigAsync<T>(string key) where T : class
{
var value = await GetConfigAsync(key);
if (string.IsNullOrEmpty(value))
return null;
try
{
return JsonSerializer.Deserialize<T>(value, JsonOptions);
}
catch (JsonException ex)
{
_logger.LogWarning(ex, "反序列化配置失败: {Key}", key);
return null;
}
}
/// <inheritdoc />
public async Task<bool> UpdateConfigAsync(string key, string value)
{
var config = await _dbContext.AdminConfigs
.FirstOrDefaultAsync(c => c.ConfigKey == key);
if (config == null)
{
// 创建新配置
config = new AdminConfig
{
ConfigKey = key,
ConfigValue = value,
CreatedAt = DateTime.Now
};
_dbContext.AdminConfigs.Add(config);
}
else
{
// 更新现有配置
config.ConfigValue = value;
config.UpdatedAt = DateTime.Now;
}
await _dbContext.SaveChangesAsync();
return true;
}
/// <inheritdoc />
public async Task<bool> UpdateConfigAsync<T>(string key, T value) where T : class
{
var jsonValue = JsonSerializer.Serialize(value, JsonOptions);
return await UpdateConfigAsync(key, jsonValue);
}
}

View File

@ -0,0 +1,41 @@
using System.Threading.Tasks;
namespace MiAssessment.Admin.Services;
/// <summary>
/// 后台配置服务接口
/// </summary>
public interface IAdminConfigService
{
/// <summary>
/// 获取配置值
/// </summary>
/// <param name="key">配置键</param>
/// <returns>配置值</returns>
Task<string?> GetConfigAsync(string key);
/// <summary>
/// 获取配置值并反序列化
/// </summary>
/// <typeparam name="T">配置类型</typeparam>
/// <param name="key">配置键</param>
/// <returns>配置对象</returns>
Task<T?> GetConfigAsync<T>(string key) where T : class;
/// <summary>
/// 更新配置值
/// </summary>
/// <param name="key">配置键</param>
/// <param name="value">配置值</param>
/// <returns>是否成功</returns>
Task<bool> UpdateConfigAsync(string key, string value);
/// <summary>
/// 更新配置值(序列化对象)
/// </summary>
/// <typeparam name="T">配置类型</typeparam>
/// <param name="key">配置键</param>
/// <param name="value">配置对象</param>
/// <returns>是否成功</returns>
Task<bool> UpdateConfigAsync<T>(string key, T value) where T : class;
}

View File

@ -0,0 +1,46 @@
/**
* System Config API - API
* @module api/system/config
*/
import { request, type ApiResponse } from '@/utils/request'
/**
*
*/
export interface UploadSetting {
/** 存储类型 1本地 2阿里云 3腾讯云 */
type: string
/** 腾讯云AppId */
AppId?: string
/** 存储桶名称 */
Bucket?: string
/** 地域 */
Region?: string
/** SecretId */
AccessKeyId?: string
/** SecretKey */
AccessKeySecret?: string
/** 访问域名 */
Domain?: string
}
/**
*
*/
export function getUploadConfig(): Promise<ApiResponse<UploadSetting>> {
return request<UploadSetting>({
url: '/admin/config/upload/get',
method: 'get'
})
}
/**
*
*/
export function updateUploadConfig(data: UploadSetting): Promise<ApiResponse<boolean>> {
return request<boolean>({
url: '/admin/config/upload/update',
method: 'post',
data
})
}

View File

@ -4,8 +4,8 @@ import type { RouteRecordRaw } from 'vue-router'
import { getUserMenus, type MenuTree } from '@/api/menu'
import Layout from '@/layout/index.vue'
// 视图模块映射
const viewModules = import.meta.glob('@/views/**/*.vue')
// 视图模块映射 - 使用相对路径格式
const viewModules = import.meta.glob('/src/views/**/*.vue')
export const usePermissionStore = defineStore('permission', () => {
const routes = ref<RouteRecordRaw[]>([])
@ -82,7 +82,13 @@ export const usePermissionStore = defineStore('permission', () => {
// 加载组件
function loadComponent(component: string) {
const path = `/src/views/${component}.vue`
return viewModules[path] || (() => import('@/views/error/404.vue'))
if (viewModules[path]) {
return viewModules[path]
}
console.warn(`Component not found: ${component}, path: ${path}`)
return () => import('@/views/error/404.vue')
}
// 重置状态

View File

@ -0,0 +1,356 @@
<template>
<div class="upload-config-container">
<!-- 页面标题 -->
<el-card class="page-header">
<div class="header-content">
<h2 class="page-title">上传配置</h2>
<span class="page-description">配置文件上传存储方式支持本地存储和腾讯云COS</span>
</div>
</el-card>
<!-- 配置表单 -->
<el-card v-loading="state.loading" class="config-form-card">
<el-form
ref="formRef"
:model="state.formData"
:rules="formRules"
label-width="140px"
label-position="right"
>
<!-- 存储类型选择 -->
<el-form-item label="存储类型" prop="type">
<el-radio-group v-model="state.formData.type" @change="handleTypeChange">
<el-radio value="1">
<div class="storage-option">
<el-icon><FolderOpened /></el-icon>
<span>本地存储</span>
</div>
</el-radio>
<el-radio value="3">
<div class="storage-option">
<el-icon><Cloudy /></el-icon>
<span>腾讯云COS</span>
</div>
</el-radio>
</el-radio-group>
</el-form-item>
<!-- 本地存储说明 -->
<el-alert
v-if="state.formData.type === '1'"
title="本地存储说明"
type="info"
:closable="false"
show-icon
class="storage-tip"
>
文件将存储在服务器本地 uploads 目录适合小型项目或测试环境
</el-alert>
<!-- 腾讯云COS配置 -->
<template v-if="state.formData.type === '3'">
<el-divider content-position="left">腾讯云COS配置</el-divider>
<el-alert
title="配置说明"
type="warning"
:closable="false"
show-icon
class="storage-tip"
>
请前往腾讯云控制台获取相关配置信息确保存储桶已开启跨域访问(CORS)
</el-alert>
<el-form-item label="AppId" prop="AppId">
<el-input
v-model="state.formData.AppId"
placeholder="请输入腾讯云AppId"
clearable
/>
<div class="form-item-tip">腾讯云账号的AppId可在账号信息中查看</div>
</el-form-item>
<el-form-item label="SecretId" prop="AccessKeyId">
<el-input
v-model="state.formData.AccessKeyId"
placeholder="请输入SecretId"
clearable
/>
<div class="form-item-tip">API密钥的SecretId</div>
</el-form-item>
<el-form-item label="SecretKey" prop="AccessKeySecret">
<el-input
v-model="state.formData.AccessKeySecret"
placeholder="请输入SecretKey"
type="password"
show-password
clearable
/>
<div class="form-item-tip">API密钥的SecretKey请妥善保管</div>
</el-form-item>
<el-form-item label="存储桶名称" prop="Bucket">
<el-input
v-model="state.formData.Bucket"
placeholder="请输入存储桶名称,如 my-bucket-1250000000"
clearable
/>
<div class="form-item-tip">完整的存储桶名称包含AppId后缀</div>
</el-form-item>
<el-form-item label="地域" prop="Region">
<el-select v-model="state.formData.Region" placeholder="请选择地域" clearable>
<el-option label="北京 (ap-beijing)" value="ap-beijing" />
<el-option label="上海 (ap-shanghai)" value="ap-shanghai" />
<el-option label="广州 (ap-guangzhou)" value="ap-guangzhou" />
<el-option label="成都 (ap-chengdu)" value="ap-chengdu" />
<el-option label="重庆 (ap-chongqing)" value="ap-chongqing" />
<el-option label="南京 (ap-nanjing)" value="ap-nanjing" />
<el-option label="香港 (ap-hongkong)" value="ap-hongkong" />
<el-option label="新加坡 (ap-singapore)" value="ap-singapore" />
</el-select>
<div class="form-item-tip">存储桶所在地域</div>
</el-form-item>
<el-form-item label="访问域名" prop="Domain">
<el-input
v-model="state.formData.Domain"
placeholder="请输入CDN加速域名或存储桶域名"
clearable
>
<template #prepend>https://</template>
</el-input>
<div class="form-item-tip">用于访问文件的域名可使用CDN加速域名</div>
</el-form-item>
</template>
<!-- 操作按钮 -->
<el-form-item>
<el-button type="primary" :loading="state.saving" @click="handleSave">
<el-icon><Check /></el-icon>
保存配置
</el-button>
<el-button @click="handleReset">
<el-icon><Refresh /></el-icon>
重置
</el-button>
<el-button v-if="state.formData.type === '3'" @click="handleTest">
<el-icon><Connection /></el-icon>
测试连接
</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script setup lang="ts">
/**
* 上传配置页面
* @description 配置文件上传存储方式
*/
import { reactive, ref, computed, onMounted } from 'vue'
import { FolderOpened, Cloudy, Check, Refresh, Connection } from '@element-plus/icons-vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { getUploadConfig, updateUploadConfig, type UploadSetting } from '@/api/system/config'
// ============ Types ============
interface UploadConfigState {
loading: boolean
saving: boolean
formData: UploadSetting
}
// ============ Refs ============
const formRef = ref<FormInstance>()
// ============ State ============
const state = reactive<UploadConfigState>({
loading: false,
saving: false,
formData: {
type: '1',
AppId: '',
Bucket: '',
Region: '',
AccessKeyId: '',
AccessKeySecret: '',
Domain: ''
}
})
// ============ Form Rules ============
const formRules = computed<FormRules>(() => {
const rules: FormRules = {
type: [{ required: true, message: '请选择存储类型', trigger: 'change' }]
}
// COS
if (state.formData.type === '3') {
rules.AppId = [{ required: true, message: '请输入AppId', trigger: 'blur' }]
rules.AccessKeyId = [{ required: true, message: '请输入SecretId', trigger: 'blur' }]
rules.AccessKeySecret = [{ required: true, message: '请输入SecretKey', trigger: 'blur' }]
rules.Bucket = [{ required: true, message: '请输入存储桶名称', trigger: 'blur' }]
rules.Region = [{ required: true, message: '请选择地域', trigger: 'change' }]
rules.Domain = [{ required: true, message: '请输入访问域名', trigger: 'blur' }]
}
return rules
})
// ============ API Functions ============
async function loadConfig() {
state.loading = true
try {
const res = await getUploadConfig()
if (res.code === 0 && res.data) {
state.formData = {
type: res.data.type || '1',
AppId: res.data.AppId || '',
Bucket: res.data.Bucket || '',
Region: res.data.Region || '',
AccessKeyId: res.data.AccessKeyId || '',
AccessKeySecret: res.data.AccessKeySecret || '',
Domain: res.data.Domain || ''
}
}
} catch (error) {
ElMessage.error('获取配置失败')
} finally {
state.loading = false
}
}
// ============ Event Handlers ============
function handleTypeChange() {
//
formRef.value?.clearValidate()
}
async function handleSave() {
if (!formRef.value) return
try {
await formRef.value.validate()
} catch {
return
}
state.saving = true
try {
const res = await updateUploadConfig(state.formData)
if (res.code === 0) {
ElMessage.success('配置保存成功')
} else {
throw new Error(res.message || '保存失败')
}
} catch (error) {
const message = error instanceof Error ? error.message : '保存失败'
ElMessage.error(message)
} finally {
state.saving = false
}
}
function handleReset() {
loadConfig()
}
function handleTest() {
// TODO:
ElMessage.info('连接测试功能开发中')
}
// ============ Lifecycle ============
onMounted(() => {
loadConfig()
})
</script>
<style scoped>
.upload-config-container {
padding: 0;
}
.page-header {
margin-bottom: 20px;
}
.header-content {
display: flex;
align-items: baseline;
gap: 16px;
}
.page-title {
margin: 0;
font-size: 20px;
font-weight: 600;
color: var(--text-primary, #303133);
}
.page-description {
font-size: 14px;
color: var(--text-secondary, #909399);
}
.config-form-card {
max-width: 800px;
}
.config-form-card :deep(.el-card__body) {
padding: 30px 40px;
}
.storage-option {
display: inline-flex;
align-items: center;
gap: 6px;
}
.storage-tip {
margin-bottom: 20px;
}
.form-item-tip {
font-size: 12px;
color: var(--el-text-color-secondary);
line-height: 1.5;
margin-top: 4px;
}
:deep(.el-radio) {
height: auto;
padding: 12px 20px;
border: 1px solid var(--el-border-color);
border-radius: 8px;
margin-right: 16px;
}
:deep(.el-radio.is-checked) {
border-color: var(--el-color-primary);
background-color: var(--el-color-primary-light-9);
}
:deep(.el-divider__text) {
font-weight: 500;
color: var(--el-text-color-primary);
}
:deep(.el-form-item) {
margin-bottom: 22px;
}
:deep(.el-input),
:deep(.el-select) {
max-width: 400px;
}
</style>