feat(api): 添加图片管理、回收站和系统设置功能

- 在 Grouping 模型和控制器中添加删除和更新方法
- 实现完整的图片上传、删除、预览和批量操作功能
- 添加图片清理功能用于删除冗余图片文件
- 实现 Markdown 文件按分组查询、搜索和标题更新功能
- 添加回收站功能支持软删除和恢复操作
- 实现系统设置和注册码生成功能
- 配置新的 API 路由包括图片、系统和回收站相关接口
- 更新前端开发服务器代理地址从 8084 到 80 端口
This commit is contained in:
ikmkj
2026-01-26 09:48:31 +08:00
parent 90f63d9df1
commit c50d96490d
13 changed files with 772 additions and 2 deletions

View File

@@ -39,4 +39,34 @@ class GroupingController
$response->getBody()->write(json_encode(ApiResponse::success($created), JSON_UNESCAPED_UNICODE));
return $response->withHeader('Content-Type', 'application/json; charset=utf-8');
}
public function update(Request $request, Response $response, $args)
{
$id = $args['id'];
$data = $request->getParsedBody();
$grouping = $data['grouping'] ?? '';
if (empty($grouping)) {
$response->getBody()->write(json_encode(ApiResponse::fail('分组名称不能为空'), JSON_UNESCAPED_UNICODE));
return $response->withHeader('Content-Type', 'application/json; charset=utf-8')->withStatus(400);
}
$model = new Grouping();
$model->update($id, $grouping);
$updated = $model->findById($id);
$response->getBody()->write(json_encode(ApiResponse::success($updated), JSON_UNESCAPED_UNICODE));
return $response->withHeader('Content-Type', 'application/json; charset=utf-8');
}
public function delete(Request $request, Response $response, $args)
{
$id = $args['id'];
$model = new Grouping();
$model->delete($id);
$response->getBody()->write(json_encode(ApiResponse::success(null, '删除成功'), JSON_UNESCAPED_UNICODE));
return $response->withHeader('Content-Type', 'application/json; charset=utf-8');
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Controllers;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use App\Models\ImageCleanup;
use App\Utils\Response as ApiResponse;
class ImageCleanupController
{
/**
* 手动触发清理冗余图片(需要管理员权限)
*/
public function cleanupImages(Request $request, Response $response)
{
try {
$model = new ImageCleanup();
$deletedCount = $model->cleanupRedundantImages();
$message = "成功清理 {$deletedCount} 个冗余图片";
$response->getBody()->write(ApiResponse::json(ApiResponse::success($message)));
return $response->withHeader('Content-Type', 'application/json; charset=utf-8');
} catch (\Exception $e) {
$response->getBody()->write(ApiResponse::json(ApiResponse::error('清理失败: ' . $e->getMessage())));
return $response->withHeader('Content-Type', 'application/json; charset=utf-8')->withStatus(500);
}
}
}

View File

@@ -0,0 +1,212 @@
<?php
namespace App\Controllers;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use App\Models\Image;
use App\Utils\Response as ApiResponse;
class ImageController
{
/**
* 上传图片
*/
public function upload(Request $request, Response $response)
{
$uploadedFiles = $request->getUploadedFiles();
$file = $uploadedFiles['file'] ?? null;
if (!$file || $file->getError() !== UPLOAD_ERR_OK) {
$response->getBody()->write(ApiResponse::json(ApiResponse::fail('文件上传失败')));
return $response->withHeader('Content-Type', 'application/json; charset=utf-8')->withStatus(400);
}
$uploadDir = $_ENV['UPLOAD_DIR'] ?? '../uploads';
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0755, true);
}
$originalName = $file->getClientFilename();
$extension = pathinfo($originalName, PATHINFO_EXTENSION);
$storedName = uniqid() . '.' . $extension;
$filePath = $uploadDir . '/' . $storedName;
try {
$file->moveTo($filePath);
$params = $request->getParsedBody();
$markdownId = $params['markdownId'] ?? null;
$model = new Image();
$imageId = $model->create(
$originalName,
$storedName,
$storedName,
$file->getSize(),
$file->getClientMediaType(),
$markdownId
);
$image = $model->findById($imageId);
$response->getBody()->write(ApiResponse::json(ApiResponse::success($image)));
return $response->withHeader('Content-Type', 'application/json; charset=utf-8');
} catch (\Exception $e) {
$response->getBody()->write(ApiResponse::json(ApiResponse::error('上传失败: ' . $e->getMessage())));
return $response->withHeader('Content-Type', 'application/json; charset=utf-8')->withStatus(500);
}
}
/**
* 根据ID删除图片
*/
public function delete(Request $request, Response $response, $args)
{
$id = $args['id'];
$uploadDir = $_ENV['UPLOAD_DIR'] ?? '../uploads';
try {
$model = new Image();
$image = $model->findById($id);
if (!$image) {
$response->getBody()->write(ApiResponse::json(ApiResponse::fail('图片不存在')));
return $response->withHeader('Content-Type', 'application/json; charset=utf-8')->withStatus(404);
}
// 删除物理文件
$filePath = $uploadDir . '/' . $image['stored_name'];
if (file_exists($filePath)) {
unlink($filePath);
}
// 删除数据库记录
$model->delete($id);
$response->getBody()->write(ApiResponse::json(ApiResponse::success(null, '删除成功')));
return $response->withHeader('Content-Type', 'application/json; charset=utf-8');
} catch (\Exception $e) {
$response->getBody()->write(ApiResponse::json(ApiResponse::error('删除失败: ' . $e->getMessage())));
return $response->withHeader('Content-Type', 'application/json; charset=utf-8')->withStatus(500);
}
}
/**
* 在线预览图片
*/
public function preview(Request $request, Response $response, $args)
{
$url = $args['url'];
$uploadDir = $_ENV['UPLOAD_DIR'] ?? '../uploads';
$filePath = $uploadDir . '/' . $url;
if (empty($url) || !file_exists($filePath)) {
$response->getBody()->write(ApiResponse::json(ApiResponse::fail('文件不存在')));
return $response->withHeader('Content-Type', 'application/json; charset=utf-8')->withStatus(404);
}
// 获取文件 MIME 类型
$contentType = $this->getContentTypeFromFileExtension($url);
// 读取文件内容
$fileContent = file_get_contents($filePath);
$fileSize = filesize($filePath);
$response->getBody()->write($fileContent);
return $response
->withHeader('Content-Type', $contentType)
->withHeader('Content-Length', (string)$fileSize);
}
/**
* 根据URL删除图片
*/
public function deleteByUrl(Request $request, Response $response)
{
$data = $request->getParsedBody();
$url = $data['url'] ?? '';
$uploadDir = $_ENV['UPLOAD_DIR'] ?? '../uploads';
if (empty($url)) {
$response->getBody()->write(ApiResponse::json(ApiResponse::fail('URL不能为空')));
return $response->withHeader('Content-Type', 'application/json; charset=utf-8')->withStatus(400);
}
try {
// 删除物理文件
$filePath = $uploadDir . '/' . $url;
if (file_exists($filePath)) {
unlink($filePath);
}
// 删除数据库记录
$model = new Image();
$model->deleteByUrl($url);
$response->getBody()->write(ApiResponse::json(ApiResponse::success(null, '删除成功')));
return $response->withHeader('Content-Type', 'application/json; charset=utf-8');
} catch (\Exception $e) {
$response->getBody()->write(ApiResponse::json(ApiResponse::error('删除失败: ' . $e->getMessage())));
return $response->withHeader('Content-Type', 'application/json; charset=utf-8')->withStatus(500);
}
}
/**
* 批量删除图片
*/
public function batchDelete(Request $request, Response $response)
{
$data = $request->getParsedBody();
$urls = $data['urls'] ?? [];
$uploadDir = $_ENV['UPLOAD_DIR'] ?? '../uploads';
if (empty($urls) || !is_array($urls)) {
$response->getBody()->write(ApiResponse::json(ApiResponse::fail('URL列表不能为空')));
return $response->withHeader('Content-Type', 'application/json; charset=utf-8')->withStatus(400);
}
try {
// 删除物理文件
foreach ($urls as $url) {
$filePath = $uploadDir . '/' . $url;
if (file_exists($filePath)) {
unlink($filePath);
}
}
// 删除数据库记录
$model = new Image();
$model->deleteByUrls($urls);
$response->getBody()->write(ApiResponse::json(ApiResponse::success(null, '批量删除成功')));
return $response->withHeader('Content-Type', 'application/json; charset=utf-8');
} catch (\Exception $e) {
$response->getBody()->write(ApiResponse::json(ApiResponse::error('批量删除失败: ' . $e->getMessage())));
return $response->withHeader('Content-Type', 'application/json; charset=utf-8')->withStatus(500);
}
}
/**
* 根据文件扩展名获取 MIME 类型
*/
private function getContentTypeFromFileExtension($fileName)
{
if (empty($fileName) || strpos($fileName, '.') === false) {
return 'application/octet-stream';
}
$extension = strtolower(pathinfo($fileName, PATHINFO_EXTENSION));
$mimeTypes = [
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'png' => 'image/png',
'gif' => 'image/gif',
'bmp' => 'image/bmp',
'webp' => 'image/webp',
'svg' => 'image/svg+xml'
];
return $mimeTypes[$extension] ?? 'application/octet-stream';
}
}

View File

@@ -73,4 +73,59 @@ class MarkdownController
$response->getBody()->write(ApiResponse::json(ApiResponse::success(null, '删除成功')));
return $response->withHeader('Content-Type', 'application/json; charset=utf-8');
}
public function getByGroupingId(Request $request, Response $response, $args)
{
$groupingId = $args['groupingId'];
$model = new MarkdownFile();
$files = $model->getByGroupingId($groupingId);
$response->getBody()->write(ApiResponse::json(ApiResponse::success($files)));
return $response->withHeader('Content-Type', 'application/json; charset=utf-8');
}
public function search(Request $request, Response $response)
{
$params = $request->getQueryParams();
$keyword = $params['keyword'] ?? '';
$model = new MarkdownFile();
$files = $model->searchByTitle($keyword);
$response->getBody()->write(ApiResponse::json(ApiResponse::success($files)));
return $response->withHeader('Content-Type', 'application/json; charset=utf-8');
}
public function getRecent(Request $request, Response $response)
{
$model = new MarkdownFile();
$files = $model->getRecent(12);
$response->getBody()->write(ApiResponse::json(ApiResponse::success($files)));
return $response->withHeader('Content-Type', 'application/json; charset=utf-8');
}
public function updateTitle(Request $request, Response $response, $args)
{
$id = $args['id'];
$data = $request->getParsedBody();
$title = $data['title'] ?? '';
if (empty($title)) {
$response->getBody()->write(ApiResponse::json(ApiResponse::fail('标题不能为空')));
return $response->withHeader('Content-Type', 'application/json; charset=utf-8')->withStatus(400);
}
$model = new MarkdownFile();
$model->updateTitle($id, $title);
$file = $model->findById($id);
if (!$file) {
$response->getBody()->write(ApiResponse::json(ApiResponse::fail('文件未找到或更新失败')));
return $response->withHeader('Content-Type', 'application/json; charset=utf-8')->withStatus(404);
}
$response->getBody()->write(ApiResponse::json(ApiResponse::success($file)));
return $response->withHeader('Content-Type', 'application/json; charset=utf-8');
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Controllers;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use App\Models\SystemSetting;
use App\Models\RegistrationCode;
use App\Utils\Response as ApiResponse;
class SystemController
{
/**
* 获取注册功能状态
*/
public function getRegistrationStatus(Request $request, Response $response)
{
$model = new SystemSetting();
$enabled = $model->isRegistrationEnabled();
$response->getBody()->write(ApiResponse::json(ApiResponse::success($enabled)));
return $response->withHeader('Content-Type', 'application/json; charset=utf-8');
}
/**
* 切换注册功能状态(需要认证)
*/
public function toggleRegistration(Request $request, Response $response)
{
$data = $request->getParsedBody();
$enabled = $data['enabled'] ?? false;
$model = new SystemSetting();
$model->setRegistrationEnabled($enabled);
$response->getBody()->write(ApiResponse::json(ApiResponse::success(null, '设置成功')));
return $response->withHeader('Content-Type', 'application/json; charset=utf-8');
}
/**
* 生成注册码(需要认证)
*/
public function generateRegistrationCode(Request $request, Response $response)
{
$userId = $request->getAttribute('userId');
if (!$userId) {
$response->getBody()->write(ApiResponse::json(ApiResponse::fail('未授权')));
return $response->withHeader('Content-Type', 'application/json; charset=utf-8')->withStatus(401);
}
$model = new RegistrationCode();
$code = $model->generateCode($userId);
$response->getBody()->write(ApiResponse::json(ApiResponse::success($code)));
return $response->withHeader('Content-Type', 'application/json; charset=utf-8');
}
}

View File

@@ -0,0 +1,80 @@
<?php
namespace App\Controllers;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use App\Models\Trash;
use App\Utils\Response as ApiResponse;
class TrashController
{
/**
* 获取回收站列表
*/
public function getTrashItems(Request $request, Response $response)
{
$model = new Trash();
$items = $model->getTrashItems();
$response->getBody()->write(ApiResponse::json(ApiResponse::success($items)));
return $response->withHeader('Content-Type', 'application/json; charset=utf-8');
}
/**
* 恢复项目
*/
public function restoreItem(Request $request, Response $response, $args)
{
$type = $args['type'];
$id = $args['id'];
try {
$model = new Trash();
$model->restoreItem($id, $type);
$response->getBody()->write(ApiResponse::json(ApiResponse::success(null, '恢复成功')));
return $response->withHeader('Content-Type', 'application/json; charset=utf-8');
} catch (\Exception $e) {
$response->getBody()->write(ApiResponse::json(ApiResponse::error('恢复失败: ' . $e->getMessage())));
return $response->withHeader('Content-Type', 'application/json; charset=utf-8')->withStatus(500);
}
}
/**
* 永久删除项目
*/
public function permanentlyDeleteItem(Request $request, Response $response, $args)
{
$type = $args['type'];
$id = $args['id'];
try {
$model = new Trash();
$model->permanentlyDeleteItem($id, $type);
$response->getBody()->write(ApiResponse::json(ApiResponse::success(null, '永久删除成功')));
return $response->withHeader('Content-Type', 'application/json; charset=utf-8');
} catch (\Exception $e) {
$response->getBody()->write(ApiResponse::json(ApiResponse::error('删除失败: ' . $e->getMessage())));
return $response->withHeader('Content-Type', 'application/json; charset=utf-8')->withStatus(500);
}
}
/**
* 清空回收站
*/
public function cleanTrash(Request $request, Response $response)
{
try {
$model = new Trash();
$model->cleanTrash();
$response->getBody()->write(ApiResponse::json(ApiResponse::success(null, '清空成功')));
return $response->withHeader('Content-Type', 'application/json; charset=utf-8');
} catch (\Exception $e) {
$response->getBody()->write(ApiResponse::json(ApiResponse::error('清空失败: ' . $e->getMessage())));
return $response->withHeader('Content-Type', 'application/json; charset=utf-8')->withStatus(500);
}
}
}

View File

@@ -47,6 +47,14 @@ class Grouping
return $stmt->execute([$grouping, $id]);
}
public function delete($id)
{
$stmt = $this->db->prepare(
"UPDATE `grouping` SET is_deleted = 1, deleted_at = NOW() WHERE id = ?"
);
return $stmt->execute([$id]);
}
public function softDelete($id, $userId)
{
$stmt = $this->db->prepare(

View File

@@ -0,0 +1,59 @@
<?php
namespace App\Models;
use App\Utils\Database;
use PDO;
class Image
{
private $db;
public function __construct()
{
$this->db = Database::getInstance()->getConnection();
}
public function create($originalName, $storedName, $url, $size, $contentType, $markdownId = null)
{
$stmt = $this->db->prepare(
"INSERT INTO image (original_name, stored_name, url, size, content_type, markdown_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, NOW())"
);
$stmt->execute([$originalName, $storedName, $url, $size, $contentType, $markdownId]);
return $this->db->lastInsertId();
}
public function findById($id)
{
$stmt = $this->db->prepare("SELECT * FROM image WHERE id = ?");
$stmt->execute([$id]);
return $stmt->fetch();
}
public function findByUrl($url)
{
$stmt = $this->db->prepare("SELECT * FROM image WHERE url = ?");
$stmt->execute([$url]);
return $stmt->fetch();
}
public function delete($id)
{
$stmt = $this->db->prepare("DELETE FROM image WHERE id = ?");
return $stmt->execute([$id]);
}
public function deleteByUrl($url)
{
$stmt = $this->db->prepare("DELETE FROM image WHERE url = ?");
return $stmt->execute([$url]);
}
public function deleteByUrls($urls)
{
$placeholders = str_repeat('?,', count($urls) - 1) . '?';
$stmt = $this->db->prepare("DELETE FROM image WHERE url IN ($placeholders)");
return $stmt->execute($urls);
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Models;
use App\Utils\Database;
class ImageCleanup
{
private $db;
public function __construct()
{
$this->db = Database::getInstance()->getConnection();
}
/**
* 清理冗余图片
* 删除数据库中存在但文件系统中不存在的图片记录
* 以及文件系统中存在但数据库中没有记录的图片文件
*/
public function cleanupRedundantImages()
{
$uploadDir = $_ENV['UPLOAD_DIR'] ?? '../uploads';
$deletedCount = 0;
// 1. 获取数据库中所有图片记录
$stmt = $this->db->prepare("SELECT id, stored_name FROM image");
$stmt->execute();
$dbImages = $stmt->fetchAll(\PDO::FETCH_ASSOC);
$dbImageFiles = [];
foreach ($dbImages as $image) {
$dbImageFiles[$image['stored_name']] = $image['id'];
}
// 2. 获取文件系统中所有图片文件
if (!is_dir($uploadDir)) {
return 0;
}
$files = scandir($uploadDir);
$fsImages = array_filter($files, function($file) use ($uploadDir) {
return is_file($uploadDir . '/' . $file) && $file !== '.' && $file !== '..';
});
// 3. 删除数据库中存在但文件系统中不存在的记录
foreach ($dbImageFiles as $fileName => $imageId) {
$filePath = $uploadDir . '/' . $fileName;
if (!file_exists($filePath)) {
$stmt = $this->db->prepare("DELETE FROM image WHERE id = ?");
$stmt->execute([$imageId]);
$deletedCount++;
}
}
// 4. 删除文件系统中存在但数据库中没有记录的文件
foreach ($fsImages as $file) {
if (!isset($dbImageFiles[$file])) {
$filePath = $uploadDir . '/' . $file;
if (file_exists($filePath)) {
unlink($filePath);
$deletedCount++;
}
}
}
return $deletedCount;
}
}

View File

@@ -98,6 +98,14 @@ class MarkdownFile
return $stmt->execute($values);
}
public function updateTitle($id, $title)
{
$stmt = $this->db->prepare(
"UPDATE markdown_file SET title = ?, updated_at = NOW() WHERE id = ?"
);
return $stmt->execute([$title, $id]);
}
public function softDelete($id, $userId)
{
$stmt = $this->db->prepare(

View File

@@ -0,0 +1,117 @@
<?php
namespace App\Models;
use App\Utils\Database;
class Trash
{
private $db;
public function __construct()
{
$this->db = Database::getInstance()->getConnection();
}
/**
* 获取回收站列表
*/
public function getTrashItems()
{
$items = [];
// 获取已删除的 Markdown 文件
$stmt = $this->db->prepare("
SELECT
id,
title,
'markdown' as type,
deleted_at,
deleted_by
FROM markdown_file
WHERE is_deleted = 1
ORDER BY deleted_at DESC
");
$stmt->execute();
$markdownItems = $stmt->fetchAll(\PDO::FETCH_ASSOC);
// 获取已删除的分组
$stmt = $this->db->prepare("
SELECT
id,
`grouping` as title,
'grouping' as type,
deleted_at,
deleted_by
FROM `grouping`
WHERE is_deleted = 1
ORDER BY deleted_at DESC
");
$stmt->execute();
$groupingItems = $stmt->fetchAll(\PDO::FETCH_ASSOC);
// 合并并按删除时间排序
$items = array_merge($markdownItems, $groupingItems);
usort($items, function($a, $b) {
return strtotime($b['deleted_at']) - strtotime($a['deleted_at']);
});
return $items;
}
/**
* 恢复项目
*/
public function restoreItem($id, $type)
{
if ($type === 'markdown') {
$stmt = $this->db->prepare("
UPDATE markdown_file
SET is_deleted = 0, deleted_at = NULL, deleted_by = NULL
WHERE id = ?
");
} elseif ($type === 'grouping') {
$stmt = $this->db->prepare("
UPDATE `grouping`
SET is_deleted = 0, deleted_at = NULL, deleted_by = NULL
WHERE id = ?
");
} else {
throw new \Exception('不支持的类型');
}
return $stmt->execute([$id]);
}
/**
* 永久删除项目
*/
public function permanentlyDeleteItem($id, $type)
{
if ($type === 'markdown') {
$stmt = $this->db->prepare("DELETE FROM markdown_file WHERE id = ?");
} elseif ($type === 'grouping') {
$stmt = $this->db->prepare("DELETE FROM `grouping` WHERE id = ?");
} else {
throw new \Exception('不支持的类型');
}
return $stmt->execute([$id]);
}
/**
* 清空回收站
*/
public function cleanTrash()
{
// 删除所有已标记删除的 Markdown 文件
$stmt = $this->db->prepare("DELETE FROM markdown_file WHERE is_deleted = 1");
$stmt->execute();
// 删除所有已标记删除的分组
$stmt = $this->db->prepare("DELETE FROM `grouping` WHERE is_deleted = 1");
$stmt->execute();
return true;
}
}