feat: 实现可编辑已有的实验

This commit is contained in:
SikongJueluo 2025-08-13 16:11:06 +08:00
parent 76342553ad
commit 7a59c29e06
No known key found for this signature in database
7 changed files with 578 additions and 593 deletions

View File

@ -28,7 +28,7 @@ public class ExamController : ControllerBase
[Authorize] [Authorize]
[HttpGet("list")] [HttpGet("list")]
[EnableCors("Users")] [EnableCors("Users")]
[ProducesResponseType(typeof(ExamSummary[]), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ExamInfo[]), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)] [ProducesResponseType(StatusCodes.Status500InternalServerError)]
public IActionResult GetExamList() public IActionResult GetExamList()
@ -37,19 +37,10 @@ public class ExamController : ControllerBase
{ {
var exams = _examManager.GetAllExams(); var exams = _examManager.GetAllExams();
var examSummaries = exams.Select(exam => new ExamSummary var examInfos = exams.Select(exam => new ExamInfo(exam)).ToArray();
{
ID = exam.ID,
Name = exam.Name,
CreatedTime = exam.CreatedTime,
UpdatedTime = exam.UpdatedTime,
Tags = exam.GetTagsList(),
Difficulty = exam.Difficulty,
IsVisibleToUsers = exam.IsVisibleToUsers
}).ToArray();
logger.Info($"成功获取实验列表,共 {examSummaries.Length} 个实验"); logger.Info($"成功获取实验列表,共 {examInfos.Length} 个实验");
return Ok(examSummaries); return Ok(examInfos);
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -93,17 +84,7 @@ public class ExamController : ControllerBase
} }
var exam = result.Value.Value; var exam = result.Value.Value;
var examInfo = new ExamInfo var examInfo = new ExamInfo(exam);
{
ID = exam.ID,
Name = exam.Name,
Description = exam.Description,
CreatedTime = exam.CreatedTime,
UpdatedTime = exam.UpdatedTime,
Tags = exam.GetTagsList(),
Difficulty = exam.Difficulty,
IsVisibleToUsers = exam.IsVisibleToUsers
};
logger.Info($"成功获取实验信息: {examId}"); logger.Info($"成功获取实验信息: {examId}");
return Ok(examInfo); return Ok(examInfo);
@ -121,7 +102,7 @@ public class ExamController : ControllerBase
/// <param name="request">创建实验请求</param> /// <param name="request">创建实验请求</param>
/// <returns>创建结果</returns> /// <returns>创建结果</returns>
[Authorize("Admin")] [Authorize("Admin")]
[HttpPost] [HttpPost("create")]
[EnableCors("Users")] [EnableCors("Users")]
[ProducesResponseType(typeof(ExamInfo), StatusCodes.Status201Created)] [ProducesResponseType(typeof(ExamInfo), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status400BadRequest)]
@ -129,7 +110,7 @@ public class ExamController : ControllerBase
[ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status409Conflict)] [ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)] [ProducesResponseType(StatusCodes.Status500InternalServerError)]
public IActionResult CreateExam([FromBody] CreateExamRequest request) public IActionResult CreateExam([FromBody] ExamDto request)
{ {
if (string.IsNullOrWhiteSpace(request.ID) || string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.Description)) if (string.IsNullOrWhiteSpace(request.ID) || string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.Description))
return BadRequest("实验ID、名称和描述不能为空"); return BadRequest("实验ID、名称和描述不能为空");
@ -148,17 +129,7 @@ public class ExamController : ControllerBase
} }
var exam = result.Value; var exam = result.Value;
var examInfo = new ExamInfo var examInfo = new ExamInfo(exam);
{
ID = exam.ID,
Name = exam.Name,
Description = exam.Description,
CreatedTime = exam.CreatedTime,
UpdatedTime = exam.UpdatedTime,
Tags = exam.GetTagsList(),
Difficulty = exam.Difficulty,
IsVisibleToUsers = exam.IsVisibleToUsers
};
logger.Info($"成功创建实验: {request.ID}"); logger.Info($"成功创建实验: {request.ID}");
return CreatedAtAction(nameof(GetExam), new { examId = request.ID }, examInfo); return CreatedAtAction(nameof(GetExam), new { examId = request.ID }, examInfo);
@ -170,26 +141,97 @@ public class ExamController : ControllerBase
} }
} }
/// <summary>
/// 更新实验信息
/// </summary>
/// <param name="request">更新实验请求</param>
/// <returns>更新结果</returns>
[Authorize("Admin")]
[HttpPost("update")]
[EnableCors("Users")]
[ProducesResponseType(typeof(ExamInfo), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public IActionResult UpdateExam([FromBody] ExamDto request)
{
var examId = request.ID;
try
{
// 首先检查实验是否存在
var existingExamResult = _examManager.GetExamByID(examId);
if (!existingExamResult.IsSuccessful)
{
logger.Error($"检查实验是否存在时出错: {existingExamResult.Error.Message}");
return StatusCode(StatusCodes.Status500InternalServerError, $"检查实验失败: {existingExamResult.Error.Message}");
}
if (!existingExamResult.Value.HasValue)
{
logger.Warn($"要更新的实验不存在: {examId}");
return NotFound($"实验 {examId} 不存在");
}
// 执行更新
var updateResult = _examManager.UpdateExam(
examId,
request.Name,
request.Description,
request.Tags,
request.Difficulty,
request.IsVisibleToUsers
);
if (!updateResult.IsSuccessful)
{
logger.Error($"更新实验时出错: {updateResult.Error.Message}");
return StatusCode(StatusCodes.Status500InternalServerError, $"更新实验失败: {updateResult.Error.Message}");
}
// 获取更新后的实验信息并返回
var updatedExamResult = _examManager.GetExamByID(examId);
if (!updatedExamResult.IsSuccessful || !updatedExamResult.Value.HasValue)
{
logger.Error($"获取更新后的实验信息失败: {examId}");
return StatusCode(StatusCodes.Status500InternalServerError, "更新成功但获取更新后信息失败");
}
var updatedExam = updatedExamResult.Value.Value;
var examInfo = new ExamInfo(updatedExam);
logger.Info($"成功更新实验: {examId},更新记录数: {updateResult.Value}");
return Ok(examInfo);
}
catch (Exception ex)
{
logger.Error($"更新实验 {examId} 时出错: {ex.Message}");
return StatusCode(StatusCodes.Status500InternalServerError, $"更新实验失败: {ex.Message}");
}
}
}
/// <summary> /// <summary>
/// 实验信息类 /// 实验信息
/// </summary> /// </summary>
public class ExamInfo public class ExamInfo
{ {
/// <summary> /// <summary>
/// 实验的唯一标识符 /// 实验的唯一标识符
/// </summary> /// </summary>
public required string ID { get; set; } public string ID { get; set; }
/// <summary> /// <summary>
/// 实验名称 /// 实验名称
/// </summary> /// </summary>
public required string Name { get; set; } public string Name { get; set; }
/// <summary> /// <summary>
/// 实验描述 /// 实验描述
/// </summary> /// </summary>
public required string Description { get; set; } public string Description { get; set; }
/// <summary> /// <summary>
/// 实验创建时间 /// 实验创建时间
@ -215,12 +257,24 @@ public class ExamController : ControllerBase
/// 普通用户是否可见 /// 普通用户是否可见
/// </summary> /// </summary>
public bool IsVisibleToUsers { get; set; } = true; public bool IsVisibleToUsers { get; set; } = true;
public ExamInfo(Database.Exam exam)
{
ID = exam.ID;
Name = exam.Name;
Description = exam.Description;
CreatedTime = exam.CreatedTime;
UpdatedTime = exam.UpdatedTime;
Tags = exam.GetTagsList();
Difficulty = exam.Difficulty;
IsVisibleToUsers = exam.IsVisibleToUsers;
}
} }
/// <summary> /// <summary>
/// 实验简要信息类(用于列表显示) /// 统一的实验数据传输对象
/// </summary> /// </summary>
public class ExamSummary public class ExamDto
{ {
/// <summary> /// <summary>
/// 实验的唯一标识符 /// 实验的唯一标识符
@ -232,47 +286,6 @@ public class ExamController : ControllerBase
/// </summary> /// </summary>
public required string Name { get; set; } public required string Name { get; set; }
/// <summary>
/// 实验创建时间
/// </summary>
public DateTime CreatedTime { get; set; }
/// <summary>
/// 实验最后更新时间
/// </summary>
public DateTime UpdatedTime { get; set; }
/// <summary>
/// 实验标签
/// </summary>
public string[] Tags { get; set; } = Array.Empty<string>();
/// <summary>
/// 实验难度1-5
/// </summary>
public int Difficulty { get; set; } = 1;
/// <summary>
/// 普通用户是否可见
/// </summary>
public bool IsVisibleToUsers { get; set; } = true;
}
/// <summary>
/// 创建实验请求类
/// </summary>
public class CreateExamRequest
{
/// <summary>
/// 实验ID
/// </summary>
public required string ID { get; set; }
/// <summary>
/// 实验名称
/// </summary>
public required string Name { get; set; }
/// <summary> /// <summary>
/// 实验描述 /// 实验描述
/// </summary> /// </summary>
@ -293,4 +306,3 @@ public class ExamController : ControllerBase
/// </summary> /// </summary>
public bool IsVisibleToUsers { get; set; } = true; public bool IsVisibleToUsers { get; set; } = true;
} }
}

View File

@ -165,7 +165,7 @@ public class ResourceManager
if (duplicateResource != null && duplicateResource.ResourceName == resourceName) if (duplicateResource != null && duplicateResource.ResourceName == resourceName)
{ {
logger.Info($"资源已存在: {resourceName}"); logger.Info($"资源已存在: {resourceName}");
return new(new Exception($"资源已存在: {resourceName}")); return duplicateResource;
} }
var nowTime = DateTime.Now; var nowTime = DateTime.Now;

View File

@ -299,7 +299,7 @@ export class VideoStreamClient {
return Promise.resolve<boolean>(null as any); return Promise.resolve<boolean>(null as any);
} }
setVideoStreamEnable(enable: boolean | undefined, cancelToken?: CancelToken): Promise<FileResponse | null> { setVideoStreamEnable(enable: boolean | undefined, cancelToken?: CancelToken): Promise<any> {
let url_ = this.baseUrl + "/api/VideoStream/SetVideoStreamEnable?"; let url_ = this.baseUrl + "/api/VideoStream/SetVideoStreamEnable?";
if (enable === null) if (enable === null)
throw new Error("The parameter 'enable' cannot be null."); throw new Error("The parameter 'enable' cannot be null.");
@ -308,11 +308,10 @@ export class VideoStreamClient {
url_ = url_.replace(/[?&]$/, ""); url_ = url_.replace(/[?&]$/, "");
let options_: AxiosRequestConfig = { let options_: AxiosRequestConfig = {
responseType: "blob",
method: "POST", method: "POST",
url: url_, url: url_,
headers: { headers: {
"Accept": "application/octet-stream" "Accept": "application/json"
}, },
cancelToken cancelToken
}; };
@ -328,7 +327,7 @@ export class VideoStreamClient {
}); });
} }
protected processSetVideoStreamEnable(response: AxiosResponse): Promise<FileResponse | null> { protected processSetVideoStreamEnable(response: AxiosResponse): Promise<any> {
const status = response.status; const status = response.status;
let _headers: any = {}; let _headers: any = {};
if (response.headers && typeof response.headers === "object") { if (response.headers && typeof response.headers === "object") {
@ -338,22 +337,27 @@ export class VideoStreamClient {
} }
} }
} }
if (status === 200 || status === 206) { if (status === 200) {
const contentDisposition = response.headers ? response.headers["content-disposition"] : undefined; const _responseText = response.data;
let fileNameMatch = contentDisposition ? /filename\*=(?:(\\?['"])(.*?)\1|(?:[^\s]+'.*?')?([^;\n]*))/g.exec(contentDisposition) : undefined; let result200: any = null;
let fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[3] || fileNameMatch[2] : undefined; let resultData200 = _responseText;
if (fileName) { result200 = resultData200 !== undefined ? resultData200 : <any>null;
fileName = decodeURIComponent(fileName);
} else { return Promise.resolve<any>(result200);
fileNameMatch = contentDisposition ? /filename="?([^"]*?)"?(;|$)/g.exec(contentDisposition) : undefined;
fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[1] : undefined; } else if (status === 500) {
} const _responseText = response.data;
return Promise.resolve({ fileName: fileName, status: status, data: new Blob([response.data], { type: response.headers["content-type"] }), headers: _headers }); let result500: any = null;
let resultData500 = _responseText;
result500 = resultData500 !== undefined ? resultData500 : <any>null;
return throwException("A server side error occurred.", status, _responseText, _headers, result500);
} else if (status !== 200 && status !== 204) { } else if (status !== 200 && status !== 204) {
const _responseText = response.data; const _responseText = response.data;
return throwException("An unexpected server error occurred.", status, _responseText, _headers); return throwException("An unexpected server error occurred.", status, _responseText, _headers);
} }
return Promise.resolve<FileResponse | null>(null as any); return Promise.resolve<any>(null as any);
} }
/** /**
@ -2505,7 +2509,7 @@ export class ExamClient {
* *
* @return * @return
*/ */
getExamList( cancelToken?: CancelToken): Promise<ExamSummary[]> { getExamList( cancelToken?: CancelToken): Promise<ExamInfo[]> {
let url_ = this.baseUrl + "/api/Exam/list"; let url_ = this.baseUrl + "/api/Exam/list";
url_ = url_.replace(/[?&]$/, ""); url_ = url_.replace(/[?&]$/, "");
@ -2529,7 +2533,7 @@ export class ExamClient {
}); });
} }
protected processGetExamList(response: AxiosResponse): Promise<ExamSummary[]> { protected processGetExamList(response: AxiosResponse): Promise<ExamInfo[]> {
const status = response.status; const status = response.status;
let _headers: any = {}; let _headers: any = {};
if (response.headers && typeof response.headers === "object") { if (response.headers && typeof response.headers === "object") {
@ -2546,12 +2550,12 @@ export class ExamClient {
if (Array.isArray(resultData200)) { if (Array.isArray(resultData200)) {
result200 = [] as any; result200 = [] as any;
for (let item of resultData200) for (let item of resultData200)
result200!.push(ExamSummary.fromJS(item)); result200!.push(ExamInfo.fromJS(item));
} }
else { else {
result200 = <any>null; result200 = <any>null;
} }
return Promise.resolve<ExamSummary[]>(result200); return Promise.resolve<ExamInfo[]>(result200);
} else if (status === 401) { } else if (status === 401) {
const _responseText = response.data; const _responseText = response.data;
@ -2568,7 +2572,7 @@ export class ExamClient {
const _responseText = response.data; const _responseText = response.data;
return throwException("An unexpected server error occurred.", status, _responseText, _headers); return throwException("An unexpected server error occurred.", status, _responseText, _headers);
} }
return Promise.resolve<ExamSummary[]>(null as any); return Promise.resolve<ExamInfo[]>(null as any);
} }
/** /**
@ -2657,8 +2661,8 @@ export class ExamClient {
* @param request * @param request
* @return * @return
*/ */
createExam(request: CreateExamRequest, cancelToken?: CancelToken): Promise<ExamInfo> { createExam(request: ExamDto, cancelToken?: CancelToken): Promise<ExamInfo> {
let url_ = this.baseUrl + "/api/Exam"; let url_ = this.baseUrl + "/api/Exam/create";
url_ = url_.replace(/[?&]$/, ""); url_ = url_.replace(/[?&]$/, "");
const content_ = JSON.stringify(request); const content_ = JSON.stringify(request);
@ -2740,6 +2744,95 @@ export class ExamClient {
} }
return Promise.resolve<ExamInfo>(null as any); return Promise.resolve<ExamInfo>(null as any);
} }
/**
*
* @param request
* @return
*/
updateExam(request: ExamDto, cancelToken?: CancelToken): Promise<ExamInfo> {
let url_ = this.baseUrl + "/api/Exam/update";
url_ = url_.replace(/[?&]$/, "");
const content_ = JSON.stringify(request);
let options_: AxiosRequestConfig = {
data: content_,
method: "POST",
url: url_,
headers: {
"Content-Type": "application/json",
"Accept": "application/json"
},
cancelToken
};
return this.instance.request(options_).catch((_error: any) => {
if (isAxiosError(_error) && _error.response) {
return _error.response;
} else {
throw _error;
}
}).then((_response: AxiosResponse) => {
return this.processUpdateExam(_response);
});
}
protected processUpdateExam(response: AxiosResponse): Promise<ExamInfo> {
const status = response.status;
let _headers: any = {};
if (response.headers && typeof response.headers === "object") {
for (const k in response.headers) {
if (response.headers.hasOwnProperty(k)) {
_headers[k] = response.headers[k];
}
}
}
if (status === 200) {
const _responseText = response.data;
let result200: any = null;
let resultData200 = _responseText;
result200 = ExamInfo.fromJS(resultData200);
return Promise.resolve<ExamInfo>(result200);
} else if (status === 400) {
const _responseText = response.data;
let result400: any = null;
let resultData400 = _responseText;
result400 = ProblemDetails.fromJS(resultData400);
return throwException("A server side error occurred.", status, _responseText, _headers, result400);
} else if (status === 401) {
const _responseText = response.data;
let result401: any = null;
let resultData401 = _responseText;
result401 = ProblemDetails.fromJS(resultData401);
return throwException("A server side error occurred.", status, _responseText, _headers, result401);
} else if (status === 403) {
const _responseText = response.data;
let result403: any = null;
let resultData403 = _responseText;
result403 = ProblemDetails.fromJS(resultData403);
return throwException("A server side error occurred.", status, _responseText, _headers, result403);
} else if (status === 404) {
const _responseText = response.data;
let result404: any = null;
let resultData404 = _responseText;
result404 = ProblemDetails.fromJS(resultData404);
return throwException("A server side error occurred.", status, _responseText, _headers, result404);
} else if (status === 500) {
const _responseText = response.data;
return throwException("A server side error occurred.", status, _responseText, _headers);
} else if (status !== 200 && status !== 204) {
const _responseText = response.data;
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
}
return Promise.resolve<ExamInfo>(null as any);
}
} }
export class HdmiVideoStreamClient { export class HdmiVideoStreamClient {
@ -7802,94 +7895,7 @@ export interface IChannelCaptureData {
data: string; data: string;
} }
/** 实验简要信息类(用于列表显示) */ /** 实验信息 */
export class ExamSummary implements IExamSummary {
/** 实验的唯一标识符 */
id!: string;
/** 实验名称 */
name!: string;
/** 实验创建时间 */
createdTime!: Date;
/** 实验最后更新时间 */
updatedTime!: Date;
/** 实验标签 */
tags!: string[];
/** 实验难度1-5 */
difficulty!: number;
/** 普通用户是否可见 */
isVisibleToUsers!: boolean;
constructor(data?: IExamSummary) {
if (data) {
for (var property in data) {
if (data.hasOwnProperty(property))
(<any>this)[property] = (<any>data)[property];
}
}
if (!data) {
this.tags = [];
}
}
init(_data?: any) {
if (_data) {
this.id = _data["id"];
this.name = _data["name"];
this.createdTime = _data["createdTime"] ? new Date(_data["createdTime"].toString()) : <any>undefined;
this.updatedTime = _data["updatedTime"] ? new Date(_data["updatedTime"].toString()) : <any>undefined;
if (Array.isArray(_data["tags"])) {
this.tags = [] as any;
for (let item of _data["tags"])
this.tags!.push(item);
}
this.difficulty = _data["difficulty"];
this.isVisibleToUsers = _data["isVisibleToUsers"];
}
}
static fromJS(data: any): ExamSummary {
data = typeof data === 'object' ? data : {};
let result = new ExamSummary();
result.init(data);
return result;
}
toJSON(data?: any) {
data = typeof data === 'object' ? data : {};
data["id"] = this.id;
data["name"] = this.name;
data["createdTime"] = this.createdTime ? this.createdTime.toISOString() : <any>undefined;
data["updatedTime"] = this.updatedTime ? this.updatedTime.toISOString() : <any>undefined;
if (Array.isArray(this.tags)) {
data["tags"] = [];
for (let item of this.tags)
data["tags"].push(item);
}
data["difficulty"] = this.difficulty;
data["isVisibleToUsers"] = this.isVisibleToUsers;
return data;
}
}
/** 实验简要信息类(用于列表显示) */
export interface IExamSummary {
/** 实验的唯一标识符 */
id: string;
/** 实验名称 */
name: string;
/** 实验创建时间 */
createdTime: Date;
/** 实验最后更新时间 */
updatedTime: Date;
/** 实验标签 */
tags: string[];
/** 实验难度1-5 */
difficulty: number;
/** 普通用户是否可见 */
isVisibleToUsers: boolean;
}
/** 实验信息类 */
export class ExamInfo implements IExamInfo { export class ExamInfo implements IExamInfo {
/** 实验的唯一标识符 */ /** 实验的唯一标识符 */
id!: string; id!: string;
@ -7962,7 +7968,7 @@ export class ExamInfo implements IExamInfo {
} }
} }
/** 实验信息 */ /** 实验信息 */
export interface IExamInfo { export interface IExamInfo {
/** 实验的唯一标识符 */ /** 实验的唯一标识符 */
id: string; id: string;
@ -7982,9 +7988,9 @@ export interface IExamInfo {
isVisibleToUsers: boolean; isVisibleToUsers: boolean;
} }
/** 创建实验请求类 */ /** 统一的实验数据传输对象 */
export class CreateExamRequest implements ICreateExamRequest { export class ExamDto implements IExamDto {
/** 实验ID */ /** 实验的唯一标识符 */
id!: string; id!: string;
/** 实验名称 */ /** 实验名称 */
name!: string; name!: string;
@ -7997,7 +8003,7 @@ export class CreateExamRequest implements ICreateExamRequest {
/** 普通用户是否可见 */ /** 普通用户是否可见 */
isVisibleToUsers!: boolean; isVisibleToUsers!: boolean;
constructor(data?: ICreateExamRequest) { constructor(data?: IExamDto) {
if (data) { if (data) {
for (var property in data) { for (var property in data) {
if (data.hasOwnProperty(property)) if (data.hasOwnProperty(property))
@ -8024,9 +8030,9 @@ export class CreateExamRequest implements ICreateExamRequest {
} }
} }
static fromJS(data: any): CreateExamRequest { static fromJS(data: any): ExamDto {
data = typeof data === 'object' ? data : {}; data = typeof data === 'object' ? data : {};
let result = new CreateExamRequest(); let result = new ExamDto();
result.init(data); result.init(data);
return result; return result;
} }
@ -8047,9 +8053,9 @@ export class CreateExamRequest implements ICreateExamRequest {
} }
} }
/** 创建实验请求类 */ /** 统一的实验数据传输对象 */
export interface ICreateExamRequest { export interface IExamDto {
/** 实验ID */ /** 实验的唯一标识符 */
id: string; id: string;
/** 实验名称 */ /** 实验名称 */
name: string; name: string;

View File

@ -2,7 +2,10 @@
<div class="flex items-center justify-center min-h-screen bg-base-200"> <div class="flex items-center justify-center min-h-screen bg-base-200">
<div class="relative w-full max-w-md"> <div class="relative w-full max-w-md">
<!-- Login Card --> <!-- Login Card -->
<div v-if="!showSignUp" class="card card-dash h-80 w-100 shadow-xl bg-base-100"> <div
v-if="!showSignUp"
class="card card-dash h-80 w-100 shadow-xl bg-base-100"
>
<div class="card-body"> <div class="card-body">
<h1 class="card-title place-self-center my-3 text-2xl">用户登录</h1> <h1 class="card-title place-self-center my-3 text-2xl">用户登录</h1>
<div class="flex flex-col w-full h-full"> <div class="flex flex-col w-full h-full">
@ -44,7 +47,10 @@
</div> </div>
<!-- Sign Up Card --> <!-- Sign Up Card -->
<div v-if="showSignUp" class="card card-dash h-96 w-100 shadow-xl bg-base-100"> <div
v-if="showSignUp"
class="card card-dash h-96 w-100 shadow-xl bg-base-100"
>
<div class="card-body"> <div class="card-body">
<h1 class="card-title place-self-center my-3 text-2xl">用户注册</h1> <h1 class="card-title place-self-center my-3 text-2xl">用户注册</h1>
<div class="flex flex-col w-full h-full"> <div class="flex flex-col w-full h-full">
@ -122,7 +128,7 @@ const isSignUpLoading = ref(false);
const signUpData = ref({ const signUpData = ref({
username: "", username: "",
email: "", email: "",
password: "" password: "",
}); });
// //
@ -149,7 +155,7 @@ const handleLogin = async () => {
// project // project
setTimeout(async () => { setTimeout(async () => {
await router.push("/project"); router.go(-1);
}, 1000); }, 1000);
} catch (error: any) { } catch (error: any) {
console.error("Login error:", error); console.error("Login error:", error);
@ -180,7 +186,7 @@ const handleRegister = () => {
signUpData.value = { signUpData.value = {
username: "", username: "",
email: "", email: "",
password: "" password: "",
}; };
}; };
@ -227,7 +233,7 @@ const handleSignUp = async () => {
const result = await dataClient.signUpUser( const result = await dataClient.signUpUser(
signUpData.value.username.trim(), signUpData.value.username.trim(),
signUpData.value.email.trim(), signUpData.value.email.trim(),
signUpData.value.password.trim() signUpData.value.password.trim(),
); );
if (result) { if (result) {
@ -271,7 +277,7 @@ const checkExistingToken = async () => {
const isValid = await AuthManager.verifyToken(); const isValid = await AuthManager.verifyToken();
if (isValid) { if (isValid) {
// tokenproject // tokenproject
await router.push("/project"); router.go(-1);
} }
} catch (error) { } catch (error) {
// token // token

View File

View File

@ -1,14 +1,13 @@
<template> <template>
<div v-if="show" class="modal modal-open overflow-hidden"> <div v-if="isShowModal" class="modal modal-open overflow-hidden">
<div class="modal-box w-full max-w-7xl max-h-[90vh] p-0 overflow-hidden"> <div class="modal-box w-full max-w-7xl max-h-[90vh] p-0 overflow-hidden">
<div <div
class="flex justify-between items-center p-6 border-b border-base-300" class="flex justify-between items-center p-6 border-b border-base-300"
> >
<h2 class="text-2xl font-bold text-base-content">创建新实验</h2> <h2 class="text-2xl font-bold text-base-content">
<button {{ mode === "create" ? "新建实验" : "编辑实验" }}
@click="closeCreateModal" </h2>
class="btn btn-sm btn-circle btn-ghost" <button @click="close" class="btn btn-sm btn-circle btn-ghost">
>
<svg <svg
class="w-6 h-6" class="w-6 h-6"
fill="none" fill="none"
@ -40,7 +39,7 @@
</label> </label>
<input <input
type="text" type="text"
v-model="newExam.id" v-model="editExamInfo.id"
class="input input-bordered w-full" class="input input-bordered w-full"
placeholder="例如: EXP001" placeholder="例如: EXP001"
required required
@ -54,7 +53,7 @@
</label> </label>
<input <input
type="text" type="text"
v-model="newExam.name" v-model="editExamInfo.name"
class="input input-bordered w-full" class="input input-bordered w-full"
placeholder="实验名称" placeholder="实验名称"
required required
@ -67,7 +66,7 @@
<span class="label-text font-medium">实验描述 *</span> <span class="label-text font-medium">实验描述 *</span>
</label> </label>
<textarea <textarea
v-model="newExam.description" v-model="editExamInfo.description"
class="textarea textarea-bordered w-full h-32" class="textarea textarea-bordered w-full h-32"
placeholder="详细描述实验内容、目标和要求..." placeholder="详细描述实验内容、目标和要求..."
required required
@ -78,7 +77,7 @@
<div class="form-control"> <div class="form-control">
<div class="flex flex-wrap gap-2 mb-3 min-h-[2rem]"> <div class="flex flex-wrap gap-2 mb-3 min-h-[2rem]">
<span <span
v-for="(tag, index) in newExam.tags" v-for="(tag, index) in editExamInfo.tags"
:key="index" :key="index"
class="badge badge-primary gap-2" class="badge badge-primary gap-2"
> >
@ -126,12 +125,12 @@
:key="i" :key="i"
type="radio" type="radio"
:value="i" :value="i"
v-model="newExam.difficulty" v-model="editExamInfo.difficulty"
class="mask mask-star-2 bg-orange-400" class="mask mask-star-2 bg-orange-400"
/> />
</div> </div>
<span class="text-lg font-medium text-base-content" <span class="text-lg font-medium text-base-content"
>({{ newExam.difficulty }}/5)</span >({{ editExamInfo.difficulty }}/5)</span
> >
</div> </div>
</div> </div>
@ -143,7 +142,7 @@
<label class="label cursor-pointer justify-start gap-4"> <label class="label cursor-pointer justify-start gap-4">
<input <input
type="checkbox" type="checkbox"
v-model="newExam.isVisibleToUsers" v-model="editExamInfo.isVisibleToUsers"
class="checkbox checkbox-primary" class="checkbox checkbox-primary"
/> />
<div> <div>
@ -161,14 +160,22 @@
<div class="space-y-3"> <div class="space-y-3">
<button <button
type="submit" type="submit"
:disabled="isCreating || !canCreateExam" :disabled="isUpdating || !canCreateExam"
class="btn btn-primary w-full" class="btn btn-primary w-full"
> >
<span <span
v-if="isCreating" v-if="isUpdating"
class="loading loading-spinner loading-sm mr-2" class="loading loading-spinner loading-sm mr-2"
></span> ></span>
{{ isCreating ? "创建中..." : "创建实验" }} {{
mode === "create"
? isUpdating
? "创建中..."
: "创建实验"
: isUpdating
? "更新中..."
: "更新实验"
}}
</button> </button>
</div> </div>
</div> </div>
@ -194,44 +201,22 @@
@click="mdFileInput?.click()" @click="mdFileInput?.click()"
@dragover.prevent @dragover.prevent
@dragenter.prevent @dragenter.prevent
@drop.prevent="handleMdFileDrop" @drop.prevent="(e) => handleFileDrop(e, 'md')"
> >
<div <div
v-if="!uploadFiles.mdFile" v-if="!uploadFiles.mdFile"
class="flex flex-col items-center gap-3" class="flex flex-col items-center gap-3"
> >
<svg <FileTextIcon
class="w-12 h-12 text-base-content/40" class="w-12 h-12 text-base-content opacity-40"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/> />
</svg>
<div class="text-sm text-base-content/70 text-center"> <div class="text-sm text-base-content/70 text-center">
<div class="font-medium mb-1">点击或拖拽上传</div> <div class="font-medium mb-1">点击或拖拽上传</div>
<div class="text-xs">支持 .md 文件</div> <div class="text-xs">支持 .md 文件</div>
</div> </div>
</div> </div>
<div v-else class="flex flex-col items-center gap-2"> <div v-else class="flex flex-col items-center gap-2">
<svg <FileTextIcon class="w-8 h-8 text-success" />
class="w-8 h-8 text-success"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<div class="text-xs font-medium text-success text-center"> <div class="text-xs font-medium text-success text-center">
{{ uploadFiles.mdFile.name }} {{ uploadFiles.mdFile.name }}
</div> </div>
@ -241,7 +226,7 @@
<input <input
type="file" type="file"
ref="mdFileInput" ref="mdFileInput"
@change="handleMdFileChange" @change="(e) => handleFileChange(e, 'md')"
accept=".md" accept=".md"
class="hidden" class="hidden"
/> />
@ -257,44 +242,20 @@
@click="imageFilesInput?.click()" @click="imageFilesInput?.click()"
@dragover.prevent @dragover.prevent
@dragenter.prevent @dragenter.prevent
@drop.prevent="handleImageFilesDrop" @drop.prevent="(e) => handleFileDrop(e, 'image')"
> >
<div <div
v-if="uploadFiles.imageFiles.length === 0" v-if="uploadFiles.imageFiles.length === 0"
class="flex flex-col items-center gap-3" class="flex flex-col items-center gap-3"
> >
<svg <ImageIcon class="w-12 h-12 text-base-content opacity-40" />
class="w-12 h-12 text-base-content/40"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
<div class="text-sm text-base-content/70 text-center"> <div class="text-sm text-base-content/70 text-center">
<div class="font-medium mb-1">点击或拖拽上传</div> <div class="font-medium mb-1">点击或拖拽上传</div>
<div class="text-xs">支持 PNG, JPG, GIF 等图片格式</div> <div class="text-xs">支持 PNG, JPG, GIF 等图片格式</div>
</div> </div>
</div> </div>
<div v-else class="flex flex-col items-center gap-2"> <div v-else class="flex flex-col items-center gap-2">
<svg <ImageIcon class="w-8 h-8 text-success" />
class="w-8 h-8 text-success"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
<div class="text-xs font-medium text-success"> <div class="text-xs font-medium text-success">
{{ uploadFiles.imageFiles.length }} 个文件 {{ uploadFiles.imageFiles.length }} 个文件
</div> </div>
@ -304,7 +265,7 @@
<input <input
type="file" type="file"
ref="imageFilesInput" ref="imageFilesInput"
@change="handleImageFilesChange" @change="(e) => handleFileChange(e, 'image')"
accept="image/*" accept="image/*"
multiple multiple
class="hidden" class="hidden"
@ -324,44 +285,22 @@
@click="bitstreamFilesInput?.click()" @click="bitstreamFilesInput?.click()"
@dragover.prevent @dragover.prevent
@dragenter.prevent @dragenter.prevent
@drop.prevent="handleBitstreamFilesDrop" @drop.prevent="(e) => handleFileDrop(e, 'bitstream')"
> >
<div <div
v-if="uploadFiles.bitstreamFiles.length === 0" v-if="uploadFiles.bitstreamFiles.length === 0"
class="flex flex-col items-center gap-3" class="flex flex-col items-center gap-3"
> >
<svg <BinaryIcon
class="w-12 h-12 text-base-content/40" class="w-12 h-12 text-base-content opacity-40"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 10V3L4 14h7v7l9-11h-7z"
/> />
</svg>
<div class="text-sm text-base-content/70 text-center"> <div class="text-sm text-base-content/70 text-center">
<div class="font-medium mb-1">点击或拖拽上传</div> <div class="font-medium mb-1">点击或拖拽上传</div>
<div class="text-xs">支持 .sbit, .bit, .bin 文件</div> <div class="text-xs">支持 .sbit, .bit, .bin 文件</div>
</div> </div>
</div> </div>
<div v-else class="flex flex-col items-center gap-2"> <div v-else class="flex flex-col items-center gap-2">
<svg <BinaryIcon class="w-8 h-8 text-success" />
class="w-8 h-8 text-success"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
<div class="text-xs font-medium text-success"> <div class="text-xs font-medium text-success">
{{ uploadFiles.bitstreamFiles.length }} 个文件 {{ uploadFiles.bitstreamFiles.length }} 个文件
</div> </div>
@ -371,7 +310,7 @@
<input <input
type="file" type="file"
ref="bitstreamFilesInput" ref="bitstreamFilesInput"
@change="handleBitstreamFilesChange" @change="(e) => handleFileChange(e, 'bitstream')"
accept=".sbit,.bit,.bin" accept=".sbit,.bit,.bin"
multiple multiple
class="hidden" class="hidden"
@ -388,44 +327,22 @@
@click="canvasFilesInput?.click()" @click="canvasFilesInput?.click()"
@dragover.prevent @dragover.prevent
@dragenter.prevent @dragenter.prevent
@drop.prevent="handleCanvasFilesDrop" @drop.prevent="(e) => handleFileDrop(e, 'canvas')"
> >
<div <div
v-if="uploadFiles.canvasFiles.length === 0" v-if="uploadFiles.canvasFiles.length === 0"
class="flex flex-col items-center gap-3" class="flex flex-col items-center gap-3"
> >
<svg <FileJsonIcon
class="w-12 h-12 text-base-content/40" class="w-12 h-12 text-base-content opacity-40"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z"
/> />
</svg>
<div class="text-sm text-base-content/70 text-center"> <div class="text-sm text-base-content/70 text-center">
<div class="font-medium mb-1">点击或拖拽上传</div> <div class="font-medium mb-1">点击或拖拽上传</div>
<div class="text-xs">支持 .json 文件</div> <div class="text-xs">支持 .json 文件</div>
</div> </div>
</div> </div>
<div v-else class="flex flex-col items-center gap-2"> <div v-else class="flex flex-col items-center gap-2">
<svg <FileJsonIcon class="w-8 h-8 text-success" />
class="w-8 h-8 text-success"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z"
/>
</svg>
<div class="text-xs font-medium text-success"> <div class="text-xs font-medium text-success">
{{ uploadFiles.canvasFiles.length }} 个文件 {{ uploadFiles.canvasFiles.length }} 个文件
</div> </div>
@ -435,7 +352,7 @@
<input <input
type="file" type="file"
ref="canvasFilesInput" ref="canvasFilesInput"
@change="handleCanvasFilesChange" @change="(e) => handleFileChange(e, 'canvas')"
accept=".json" accept=".json"
multiple multiple
class="hidden" class="hidden"
@ -454,44 +371,22 @@
@click="resourceFileInput?.click()" @click="resourceFileInput?.click()"
@dragover.prevent @dragover.prevent
@dragenter.prevent @dragenter.prevent
@drop.prevent="handleResourceFileDrop" @drop.prevent="(e) => handleFileDrop(e, 'resource')"
> >
<div <div
v-if="!uploadFiles.resourceFile" v-if="!uploadFiles.resourceFile"
class="flex flex-col items-center gap-3" class="flex flex-col items-center gap-3"
> >
<svg <FileArchiveIcon
class="w-12 h-12 text-base-content/40" class="w-12 h-12 text-base-content opacity-40"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/> />
</svg>
<div class="text-sm text-base-content/70 text-center"> <div class="text-sm text-base-content/70 text-center">
<div class="font-medium mb-1">点击或拖拽上传</div> <div class="font-medium mb-1">点击或拖拽上传</div>
<div class="text-xs">支持 .zip, .rar, .7z 文件</div> <div class="text-xs">支持 .zip, .rar, .7z 文件</div>
</div> </div>
</div> </div>
<div v-else class="flex flex-col items-center gap-2"> <div v-else class="flex flex-col items-center gap-2">
<svg <FileArchiveIcon class="w-8 h-8 text-success" />
class="w-8 h-8 text-success"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<div class="text-xs font-medium text-success text-center"> <div class="text-xs font-medium text-success text-center">
{{ uploadFiles.resourceFile.name }} {{ uploadFiles.resourceFile.name }}
</div> </div>
@ -501,7 +396,7 @@
<input <input
type="file" type="file"
ref="resourceFileInput" ref="resourceFileInput"
@change="handleResourceFileChange" @change="(e) => handleFileChange(e, 'resource')"
accept=".zip,.rar,.7z" accept=".zip,.rar,.7z"
class="hidden" class="hidden"
/> />
@ -511,28 +406,39 @@
</div> </div>
</form> </form>
</div> </div>
<div class="modal-backdrop" @click="closeCreateModal"></div> <div class="modal-backdrop" @click="close"></div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { CreateExamRequest, type FileParameter } from "@/APIClient"; import {
FileTextIcon,
ImageIcon,
BinaryIcon,
FileArchiveIcon,
FileJsonIcon,
} from "lucide-vue-next";
import { ExamDto, type FileParameter } from "@/APIClient";
import { useAlertStore } from "@/components/Alert"; import { useAlertStore } from "@/components/Alert";
import { AuthManager } from "@/utils/AuthManager"; import { AuthManager } from "@/utils/AuthManager";
import { useRequiredInjection } from "@/utils/Common"; import { useRequiredInjection } from "@/utils/Common";
import { defineModel, ref, computed } from "vue"; import { defineModel, ref, computed } from "vue";
import { mod } from "mathjs";
import type { ExamInfo } from "@/APIClient";
const show = defineModel<boolean>("show", { type Mode = "create" | "edit";
const isShowModal = defineModel<boolean>("isShowModal", {
default: false, default: false,
}); });
const emits = defineEmits<{ const emits = defineEmits<{
createFinished: [examId: string]; editFinished: [examId: string];
}>(); }>();
const alertStore = useRequiredInjection(useAlertStore); const alert = useRequiredInjection(useAlertStore);
const newExam = ref({ const editExamInfo = ref({
id: "", id: "",
name: "", name: "",
description: "", description: "",
@ -541,7 +447,8 @@ const newExam = ref({
isVisibleToUsers: true, isVisibleToUsers: true,
}); });
const isCreating = ref(false); const isUpdating = ref(false);
const mode = ref<Mode>("create");
const newTagInput = ref(""); const newTagInput = ref("");
// //
@ -563,65 +470,62 @@ const resourceFileInput = ref<HTMLInputElement>();
// //
const canCreateExam = computed(() => { const canCreateExam = computed(() => {
return ( return (
newExam.value.id.trim() !== "" && editExamInfo.value.id.trim() !== "" &&
newExam.value.name.trim() !== "" && editExamInfo.value.name.trim() !== "" &&
newExam.value.description.trim() !== "" && editExamInfo.value.description.trim() !== "" &&
uploadFiles.value.mdFile !== null uploadFiles.value.mdFile !== null
); );
}); });
const handleResourceFileChange = (event: Event) => { //
const target = event.target as HTMLInputElement; type FileType = "md" | "image" | "bitstream" | "canvas" | "resource";
if (target.files && target.files.length > 0) {
uploadFiles.value.resourceFile = target.files[0];
}
};
// //
const handleMdFileChange = (event: Event) => { const handleFileChange = (event: Event, fileType: FileType) => {
const target = event.target as HTMLInputElement; const target = event.target as HTMLInputElement;
if (target.files && target.files.length > 0) { if (!target.files) return;
switch (fileType) {
case "md":
if (target.files.length > 0) {
uploadFiles.value.mdFile = target.files[0]; uploadFiles.value.mdFile = target.files[0];
} }
}; break;
case "image":
const handleMdFileDrop = (event: DragEvent) => {
const files = event.dataTransfer?.files;
if (files && files.length > 0) {
const file = files[0];
if (file.name.endsWith(".md")) {
uploadFiles.value.mdFile = file;
}
}
};
const handleImageFilesChange = (event: Event) => {
const target = event.target as HTMLInputElement;
if (target.files) {
uploadFiles.value.imageFiles = Array.from(target.files); uploadFiles.value.imageFiles = Array.from(target.files);
break;
case "bitstream":
uploadFiles.value.bitstreamFiles = Array.from(target.files);
break;
case "canvas":
uploadFiles.value.canvasFiles = Array.from(target.files);
break;
case "resource":
if (target.files.length > 0) {
uploadFiles.value.resourceFile = target.files[0];
}
break;
} }
}; };
const handleImageFilesDrop = (event: DragEvent) => { const handleFileDrop = (event: DragEvent, fileType: FileType) => {
const files = event.dataTransfer?.files; const files = event.dataTransfer?.files;
if (files && files.length > 0) { if (!files || files.length === 0) return;
switch (fileType) {
case "md":
const mdFile = files[0];
if (mdFile.name.endsWith(".md")) {
uploadFiles.value.mdFile = mdFile;
}
break;
case "image":
const imageFiles = Array.from(files).filter((file) => const imageFiles = Array.from(files).filter((file) =>
file.type.startsWith("image/"), file.type.startsWith("image/"),
); );
uploadFiles.value.imageFiles = imageFiles; uploadFiles.value.imageFiles = imageFiles;
} break;
}; case "bitstream":
const handleBitstreamFilesChange = (event: Event) => {
const target = event.target as HTMLInputElement;
if (target.files) {
uploadFiles.value.bitstreamFiles = Array.from(target.files);
}
};
const handleBitstreamFilesDrop = (event: DragEvent) => {
const files = event.dataTransfer?.files;
if (files && files.length > 0) {
const bitstreamFiles = Array.from(files).filter( const bitstreamFiles = Array.from(files).filter(
(file) => (file) =>
file.name.endsWith(".sbit") || file.name.endsWith(".sbit") ||
@ -629,23 +533,23 @@ const handleBitstreamFilesDrop = (event: DragEvent) => {
file.name.endsWith(".bin"), file.name.endsWith(".bin"),
); );
uploadFiles.value.bitstreamFiles = bitstreamFiles; uploadFiles.value.bitstreamFiles = bitstreamFiles;
} break;
}; case "canvas":
const handleCanvasFilesChange = (event: Event) => {
const target = event.target as HTMLInputElement;
if (target.files) {
uploadFiles.value.canvasFiles = Array.from(target.files);
}
};
const handleCanvasFilesDrop = (event: DragEvent) => {
const files = event.dataTransfer?.files;
if (files && files.length > 0) {
const canvasFiles = Array.from(files).filter((file) => const canvasFiles = Array.from(files).filter((file) =>
file.name.endsWith(".json"), file.name.endsWith(".json"),
); );
uploadFiles.value.canvasFiles = canvasFiles; uploadFiles.value.canvasFiles = canvasFiles;
break;
case "resource":
const resourceFile = files[0];
if (
resourceFile.name.endsWith(".zip") ||
resourceFile.name.endsWith(".rar") ||
resourceFile.name.endsWith(".7z")
) {
uploadFiles.value.resourceFile = resourceFile;
}
break;
} }
}; };
@ -656,18 +560,18 @@ const addTag = (event?: Event) => {
event.stopPropagation(); event.stopPropagation();
} }
const tag = newTagInput.value.trim(); const tag = newTagInput.value.trim();
if (tag && !newExam.value.tags.includes(tag)) { if (tag && !editExamInfo.value.tags.includes(tag)) {
newExam.value.tags.push(tag); editExamInfo.value.tags.push(tag);
newTagInput.value = ""; newTagInput.value = "";
} }
}; };
const removeTag = (index: number) => { const removeTag = (index: number) => {
newExam.value.tags.splice(index, 1); editExamInfo.value.tags.splice(index, 1);
}; };
const resetCreateForm = () => { const resetCreateForm = () => {
newExam.value = { editExamInfo.value = {
id: "", id: "",
name: "", name: "",
description: "", description: "",
@ -692,75 +596,81 @@ const resetCreateForm = () => {
if (resourceFileInput.value) resourceFileInput.value.value = ""; if (resourceFileInput.value) resourceFileInput.value.value = "";
}; };
const closeCreateModal = () => {
show.value = false;
resetCreateForm();
};
const handleResourceFileDrop = (event: DragEvent) => {
const files = event.dataTransfer?.files;
if (files && files.length > 0) {
const file = files[0];
if (
file.name.endsWith(".zip") ||
file.name.endsWith(".rar") ||
file.name.endsWith(".7z")
) {
uploadFiles.value.resourceFile = file;
}
}
};
// //
const submitCreateExam = async () => { const submitCreateExam = async () => {
if (isCreating.value) return; if (isUpdating.value) return;
// //
if (!newExam.value.id || !newExam.value.name || !newExam.value.description) { if (
alertStore?.error("请填写所有必填字段"); !editExamInfo.value.id ||
!editExamInfo.value.name ||
!editExamInfo.value.description
) {
alert?.error("请填写所有必填字段");
return; return;
} }
if (!uploadFiles.value.mdFile) { if (!uploadFiles.value.mdFile) {
alertStore?.error("请上传MD文档"); alert.error("请上传MD文档");
return; return;
} }
isCreating.value = true; isUpdating.value = true;
try { try {
const client = AuthManager.createAuthenticatedExamClient(); const client = AuthManager.createAuthenticatedExamClient();
let exam: ExamInfo;
if (mode.value === "create") {
// //
const createRequest = new CreateExamRequest({ const createRequest = new ExamDto({
id: newExam.value.id, id: editExamInfo.value.id,
name: newExam.value.name, name: editExamInfo.value.name,
description: newExam.value.description, description: editExamInfo.value.description,
tags: newExam.value.tags, tags: editExamInfo.value.tags,
difficulty: newExam.value.difficulty, difficulty: editExamInfo.value.difficulty,
isVisibleToUsers: newExam.value.isVisibleToUsers, isVisibleToUsers: editExamInfo.value.isVisibleToUsers,
}); });
// //
const createdExam = await client.createExam(createRequest); exam = await client.createExam(createRequest);
console.log("实验创建成功:", createdExam); console.log("实验创建成功:", exam);
} else if (mode.value === "edit") {
//
const editRequest = new ExamDto({
id: editExamInfo.value.id,
name: editExamInfo.value.name,
description: editExamInfo.value.description,
tags: editExamInfo.value.tags,
difficulty: editExamInfo.value.difficulty,
isVisibleToUsers: editExamInfo.value.isVisibleToUsers,
});
//
exam = await client.updateExam(editRequest);
console.log("实验编辑成功:", exam);
} else {
//
console.error("未知的模式:", mode.value);
throw new Error("未知的模式");
}
// //
await uploadExamResources(createdExam.id); await uploadExamResources(exam.id);
alertStore?.success("实验创建成功"); alert.success("实验创建成功");
closeCreateModal(); close();
emits("createFinished", createdExam.id); emits("editFinished", exam.id);
} catch (err: any) { } catch (err: any) {
console.error("创建实验失败:", err); console.error("创建实验失败:", err);
alertStore?.error(err.message || "创建实验失败"); alert.error(err.message || "创建实验失败");
} finally { } finally {
isCreating.value = false; isUpdating.value = false;
} }
}; };
// //
const uploadExamResources = async (examId: string) => { async function uploadExamResources(examId: string) {
const client = AuthManager.createAuthenticatedResourceClient(); const client = AuthManager.createAuthenticatedResourceClient();
try { try {
@ -825,9 +735,42 @@ const uploadExamResources = async (examId: string) => {
} }
} catch (err: any) { } catch (err: any) {
console.error("资源上传失败:", err); console.error("资源上传失败:", err);
alertStore?.error("部分资源上传失败: " + (err.message || "未知错误")); alert?.error("部分资源上传失败: " + (err.message || "未知错误"));
} }
}
function show() {
isShowModal.value = true;
}
function close() {
isShowModal.value = false;
mode.value = "create";
resetCreateForm();
}
async function editExam(examId: string) {
const client = AuthManager.createAuthenticatedExamClient();
const examInfo = await client.getExam(examId);
editExamInfo.value = {
id: examInfo.id,
name: examInfo.name,
description: examInfo.description,
tags: examInfo.tags,
difficulty: examInfo.difficulty,
isVisibleToUsers: examInfo.isVisibleToUsers,
}; };
mode.value = "edit";
show();
}
defineExpose({
show,
close,
editExam,
});
</script> </script>
<style lang="postcss" scoped></style> <style lang="postcss" scoped></style>

View File

@ -62,7 +62,7 @@
<div <div
v-if="isAdmin" v-if="isAdmin"
class="card bg-base-200 shadow-lg hover:shadow-xl transition-all duration-200 cursor-pointer hover:scale-[1.02]" class="card bg-base-200 shadow-lg hover:shadow-xl transition-all duration-200 cursor-pointer hover:scale-[1.02]"
@click="showCreateModal = true" @click="() => examEditModalRef?.show()"
> >
<div class="card-body flex items-center justify-center text-center"> <div class="card-body flex items-center justify-center text-center">
<div class="text-primary text-6xl mb-4">+</div> <div class="text-primary text-6xl mb-4">+</div>
@ -75,16 +75,27 @@
v-for="exam in exams" v-for="exam in exams"
:key="exam.id" :key="exam.id"
class="card bg-base-200 shadow-lg hover:shadow-xl transition-all duration-200 cursor-pointer hover:scale-[1.02] relative overflow-hidden" class="card bg-base-200 shadow-lg hover:shadow-xl transition-all duration-200 cursor-pointer hover:scale-[1.02] relative overflow-hidden"
@click="viewExam(exam.id)" @click="handleCardClicked($event, exam.id)"
> >
<div class="card-body"> <div class="card-body">
<div class="flex justify-between items-start mb-4"> <div class="flex justify-between items-start mb-4">
<h3 class="card-title text-base-content">{{ exam.name }}</h3> <h3 class="card-title text-base-content">{{ exam.name }}</h3>
<div class="flex flex-row items-center gap-2">
<button
class="btn btn-ghost text-error hover:underline group"
@click="handleEditExamClicked($event, exam.id)"
>
<EditIcon
class="w-4 h-4 transition-transform duration-200 group-hover:scale-110"
/>
编辑
</button>
<span <span
class="card-title text-sm text-blue-600/50 dark:text-sky-400/50" class="card-title text-sm text-blue-600/50 dark:text-sky-400/50"
>{{ exam.id }}</span >{{ exam.id }}</span
> >
</div> </div>
</div>
<!-- 实验标签 --> <!-- 实验标签 -->
<div <div
@ -160,8 +171,8 @@
<!-- 创建实验模态框 --> <!-- 创建实验模态框 -->
<ExamEditModal <ExamEditModal
v-model:show="showCreateModal" ref="examEditModalRef"
@create-finished="handleCreateExamFinished" @edit-finished="handleEditExamFinished"
/> />
</div> </div>
</template> </template>
@ -170,36 +181,27 @@
import { ref, onMounted, computed } from "vue"; import { ref, onMounted, computed } from "vue";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
import { AuthManager } from "@/utils/AuthManager"; import { AuthManager } from "@/utils/AuthManager";
import { type ExamSummary, type ExamInfo } from "@/APIClient"; import { type ExamInfo } from "@/APIClient";
import { formatDate } from "@/utils/Common"; import { formatDate } from "@/utils/Common";
import ExamInfoModal from "./ExamInfoModal.vue"; import ExamInfoModal from "./ExamInfoModal.vue";
import ExamEditModal from "./ExamEditModal.vue"; import ExamEditModal from "./ExamEditModal.vue";
import router from "@/router";
import { EditIcon } from "lucide-vue-next";
import { templateRef } from "@vueuse/core";
// //
const route = useRoute(); const route = useRoute();
const exams = ref<ExamSummary[]>([]); const exams = ref<ExamInfo[]>([]);
const selectedExam = ref<ExamInfo | null>(null); const selectedExam = ref<ExamInfo | null>(null);
const loading = ref(false); const loading = ref(false);
const error = ref<string>(""); const error = ref<string>("");
const isAdmin = ref(false); const isAdmin = ref(false);
// Modal // Modal
const showCreateModal = ref(false); const examEditModalRef = templateRef("examEditModalRef");
const showInfoModal = ref(false); const showInfoModal = ref(false);
// async function refreshExams() {
const checkAdminStatus = async () => {
console.log("检查管理员权限...");
try {
isAdmin.value = await AuthManager.verifyAdminAuth();
console.log("管理员权限:", isAdmin.value);
} catch (err) {
console.warn("无法验证管理员权限:", err);
isAdmin.value = false;
}
};
const refreshExams = async () => {
loading.value = true; loading.value = true;
error.value = ""; error.value = "";
@ -212,9 +214,9 @@ const refreshExams = async () => {
} finally { } finally {
loading.value = false; loading.value = false;
} }
}; }
const viewExam = async (examId: string) => { async function viewExam(examId: string) {
try { try {
const client = AuthManager.createAuthenticatedExamClient(); const client = AuthManager.createAuthenticatedExamClient();
selectedExam.value = await client.getExam(examId); selectedExam.value = await client.getExam(examId);
@ -222,16 +224,32 @@ const viewExam = async (examId: string) => {
} catch (err: any) { } catch (err: any) {
error.value = err.message || "获取实验详情失败"; error.value = err.message || "获取实验详情失败";
console.error("获取实验详情失败:", err); console.error("获取实验详情失败:", err);
showInfoModal.value = false;
}
} }
};
async function handleCreateExamFinished() { async function handleEditExamFinished() {
await refreshExams(); await refreshExams();
} }
async function handleCardClicked(event: MouseEvent, examId: string) {
if (event.target instanceof HTMLButtonElement) return;
await viewExam(examId);
}
async function handleEditExamClicked(event: MouseEvent, examId: string) {
examEditModalRef?.value?.editExam(examId);
}
// //
onMounted(async () => { onMounted(async () => {
await checkAdminStatus(); const isAuthenticated = await AuthManager.isAuthenticated();
if (!isAuthenticated) {
router.push("/login");
}
isAdmin.value = await AuthManager.verifyAdminAuth();
await refreshExams(); await refreshExams();
// examId // examId