unifare commited on
Commit ·
5fc700d
1
Parent(s): 5975319
Initial commit: ToolHub ASP.NET Core app
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +102 -0
- Controllers/AdminController.cs +109 -0
- Controllers/CategoryController.cs +202 -0
- Controllers/HomeController.cs +72 -0
- Controllers/TagController.cs +292 -0
- Controllers/ToolController.cs +345 -0
- Controllers/ToolStatisticsController.cs +153 -0
- Controllers/ToolsController.cs +27 -0
- Dockerfile +61 -0
- Models/Category.cs +34 -0
- Models/ErrorViewModel.cs +9 -0
- Models/Tag.cs +24 -0
- Models/Tool.cs +59 -0
- Models/ToolStatistics.cs +36 -0
- Models/ToolTag.cs +20 -0
- Models/User.cs +38 -0
- Models/UserFavorite.cs +20 -0
- Models/UserToolAccess.cs +40 -0
- Program.cs +150 -0
- Properties/launchSettings.json +23 -0
- README.md +124 -10
- README_HF_SPACES.md +147 -0
- Services/BaseToolService.cs +319 -0
- Services/IToolService.cs +26 -0
- Services/IUserService.cs +16 -0
- Services/ToolService.cs +192 -0
- Services/UserService.cs +152 -0
- ToolHub.csproj +15 -0
- ToolHub.csproj.user +6 -0
- Views/Admin/Categories.cshtml +387 -0
- Views/Admin/Index.cshtml +302 -0
- Views/Admin/Login.cshtml +103 -0
- Views/Admin/Tags.cshtml +349 -0
- Views/Admin/Tools.cshtml +934 -0
- Views/Category/Index.cshtml +343 -0
- Views/Home/Index.cshtml +184 -0
- Views/Home/Privacy.cshtml +6 -0
- Views/Home/Tools.cshtml +189 -0
- Views/Shared/Error.cshtml +25 -0
- Views/Shared/_AdminLayout.cshtml +286 -0
- Views/Shared/_Layout.cshtml +283 -0
- Views/Shared/_Layout.cshtml.css +48 -0
- Views/Shared/_ValidationScriptsPartial.cshtml +2 -0
- Views/Tag/Index.cshtml +349 -0
- Views/Tool/Index.cshtml +479 -0
- Views/ToolStatistics/Index.cshtml +493 -0
- Views/Tools/ImageCompressor.cshtml +479 -0
- Views/_ViewImports.cshtml +3 -0
- Views/_ViewStart.cshtml +3 -0
- appsettings.Development.json +8 -0
.dockerignore
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Git
|
| 2 |
+
.git
|
| 3 |
+
.gitignore
|
| 4 |
+
.gitattributes
|
| 5 |
+
|
| 6 |
+
# Visual Studio
|
| 7 |
+
.vs/
|
| 8 |
+
*.user
|
| 9 |
+
*.suo
|
| 10 |
+
*.userosscache
|
| 11 |
+
*.sln.docstates
|
| 12 |
+
|
| 13 |
+
# Build results
|
| 14 |
+
[Dd]ebug/
|
| 15 |
+
[Dd]ebugPublic/
|
| 16 |
+
[Rr]elease/
|
| 17 |
+
[Rr]eleases/
|
| 18 |
+
x64/
|
| 19 |
+
x86/
|
| 20 |
+
build/
|
| 21 |
+
bld/
|
| 22 |
+
[Bb]in/
|
| 23 |
+
[Oo]bj/
|
| 24 |
+
[Oo]ut/
|
| 25 |
+
msbuild.log
|
| 26 |
+
msbuild.err
|
| 27 |
+
msbuild.wrn
|
| 28 |
+
|
| 29 |
+
# NuGet
|
| 30 |
+
*.nupkg
|
| 31 |
+
*.snupkg
|
| 32 |
+
**/[Pp]ackages/*
|
| 33 |
+
!**/[Pp]ackages/build/
|
| 34 |
+
*.nuget.props
|
| 35 |
+
*.nuget.targets
|
| 36 |
+
|
| 37 |
+
# User-specific files
|
| 38 |
+
*.rsuser
|
| 39 |
+
*.suo
|
| 40 |
+
*.user
|
| 41 |
+
*.userosscache
|
| 42 |
+
*.sln.docstates
|
| 43 |
+
|
| 44 |
+
# Mono auto generated files
|
| 45 |
+
mono_crash.*
|
| 46 |
+
|
| 47 |
+
# Windows image file caches
|
| 48 |
+
Thumbs.db
|
| 49 |
+
ehthumbs.db
|
| 50 |
+
|
| 51 |
+
# Folder config file
|
| 52 |
+
[Dd]esktop.ini
|
| 53 |
+
|
| 54 |
+
# Recycle Bin used on file shares
|
| 55 |
+
$RECYCLE.BIN/
|
| 56 |
+
|
| 57 |
+
# Windows Installer files
|
| 58 |
+
*.cab
|
| 59 |
+
*.msi
|
| 60 |
+
*.msix
|
| 61 |
+
*.msm
|
| 62 |
+
*.msp
|
| 63 |
+
|
| 64 |
+
# Windows shortcuts
|
| 65 |
+
*.lnk
|
| 66 |
+
|
| 67 |
+
# JetBrains Rider
|
| 68 |
+
.idea/
|
| 69 |
+
*.sln.iml
|
| 70 |
+
|
| 71 |
+
# macOS
|
| 72 |
+
.DS_Store
|
| 73 |
+
|
| 74 |
+
# Linux
|
| 75 |
+
*~
|
| 76 |
+
|
| 77 |
+
# Temporary files
|
| 78 |
+
*.tmp
|
| 79 |
+
*.temp
|
| 80 |
+
|
| 81 |
+
# Logs
|
| 82 |
+
*.log
|
| 83 |
+
|
| 84 |
+
# Database files (will be created in container)
|
| 85 |
+
*.db
|
| 86 |
+
*.db-shm
|
| 87 |
+
*.db-wal
|
| 88 |
+
|
| 89 |
+
# Docker
|
| 90 |
+
Dockerfile*
|
| 91 |
+
docker-compose*
|
| 92 |
+
.dockerignore
|
| 93 |
+
|
| 94 |
+
# Documentation
|
| 95 |
+
README.md
|
| 96 |
+
*.md
|
| 97 |
+
|
| 98 |
+
# Test files
|
| 99 |
+
**/test/
|
| 100 |
+
**/tests/
|
| 101 |
+
**/*.Tests/
|
| 102 |
+
**/*.tests/
|
Controllers/AdminController.cs
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
using Microsoft.AspNetCore.Mvc;
|
| 2 |
+
using Microsoft.AspNetCore.Authorization;
|
| 3 |
+
using Microsoft.AspNetCore.Authentication;
|
| 4 |
+
using Microsoft.AspNetCore.Authentication.Cookies;
|
| 5 |
+
using System.Security.Claims;
|
| 6 |
+
using ToolHub.Models;
|
| 7 |
+
using ToolHub.Services;
|
| 8 |
+
|
| 9 |
+
namespace ToolHub.Controllers;
|
| 10 |
+
|
| 11 |
+
public class AdminController : Controller
|
| 12 |
+
{
|
| 13 |
+
private readonly IUserService _userService;
|
| 14 |
+
private readonly IToolService _toolService;
|
| 15 |
+
private readonly IFreeSql _freeSql;
|
| 16 |
+
|
| 17 |
+
public AdminController(IUserService userService, IToolService toolService, IFreeSql freeSql)
|
| 18 |
+
{
|
| 19 |
+
_userService = userService;
|
| 20 |
+
_toolService = toolService;
|
| 21 |
+
_freeSql = freeSql;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
[HttpGet]
|
| 25 |
+
public IActionResult Login()
|
| 26 |
+
{
|
| 27 |
+
if (User.Identity?.IsAuthenticated == true)
|
| 28 |
+
{
|
| 29 |
+
return RedirectToAction("Index");
|
| 30 |
+
}
|
| 31 |
+
return View();
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
[HttpPost]
|
| 35 |
+
public async Task<IActionResult> Login(string email, string password, bool rememberMe = false)
|
| 36 |
+
{
|
| 37 |
+
if (string.IsNullOrEmpty(email) || string.IsNullOrEmpty(password))
|
| 38 |
+
{
|
| 39 |
+
ViewBag.Error = "请输入邮箱和密码";
|
| 40 |
+
return View();
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
var user = await _userService.GetUserByEmailAsync(email);
|
| 44 |
+
if (user == null || user.Role != "Admin" || !await _userService.VerifyPasswordAsync(email, password))
|
| 45 |
+
{
|
| 46 |
+
ViewBag.Error = "邮箱或密码错误,或您不是管理员";
|
| 47 |
+
return View();
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
var claims = new List<Claim>
|
| 51 |
+
{
|
| 52 |
+
new(ClaimTypes.NameIdentifier, user.Id.ToString()),
|
| 53 |
+
new(ClaimTypes.Name, user.UserName),
|
| 54 |
+
new(ClaimTypes.Email, user.Email),
|
| 55 |
+
new(ClaimTypes.Role, user.Role)
|
| 56 |
+
};
|
| 57 |
+
|
| 58 |
+
var claimsIdentity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
|
| 59 |
+
var authProperties = new AuthenticationProperties
|
| 60 |
+
{
|
| 61 |
+
IsPersistent = rememberMe,
|
| 62 |
+
ExpiresUtc = rememberMe ? DateTimeOffset.UtcNow.AddDays(7) : DateTimeOffset.UtcNow.AddHours(1)
|
| 63 |
+
};
|
| 64 |
+
|
| 65 |
+
await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme,
|
| 66 |
+
new ClaimsPrincipal(claimsIdentity), authProperties);
|
| 67 |
+
|
| 68 |
+
return RedirectToAction("Index");
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
[HttpPost]
|
| 72 |
+
[Authorize(Roles = "Admin")]
|
| 73 |
+
public async Task<IActionResult> Logout()
|
| 74 |
+
{
|
| 75 |
+
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
| 76 |
+
return RedirectToAction("Login");
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
[Authorize(Roles = "Admin")]
|
| 80 |
+
public async Task<IActionResult> Index()
|
| 81 |
+
{
|
| 82 |
+
// 统计数据
|
| 83 |
+
ViewBag.TotalTools = await _freeSql.Select<Tool>().Where(t => t.IsActive).CountAsync();
|
| 84 |
+
ViewBag.TotalCategories = await _freeSql.Select<Category>().Where(c => c.IsActive).CountAsync();
|
| 85 |
+
ViewBag.TotalUsers = await _freeSql.Select<User>().Where(u => u.IsActive).CountAsync();
|
| 86 |
+
ViewBag.TotalViews = await _freeSql.Select<Tool>().SumAsync(t => t.ViewCount);
|
| 87 |
+
|
| 88 |
+
// 最新工具
|
| 89 |
+
ViewBag.RecentTools = await _freeSql.Select<Tool>()
|
| 90 |
+
.Include(t => t.Category)
|
| 91 |
+
.Where(t => t.IsActive)
|
| 92 |
+
.OrderByDescending(t => t.CreatedAt)
|
| 93 |
+
.Take(5)
|
| 94 |
+
.ToListAsync();
|
| 95 |
+
|
| 96 |
+
return View();
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
[Authorize(Roles = "Admin")]
|
| 100 |
+
public async Task<IActionResult> Users(int page = 1)
|
| 101 |
+
{
|
| 102 |
+
var users = await _freeSql.Select<User>()
|
| 103 |
+
.Where(u => u.IsActive)
|
| 104 |
+
.OrderByDescending(u => u.CreatedAt)
|
| 105 |
+
.Page(page, 20)
|
| 106 |
+
.ToListAsync();
|
| 107 |
+
return View(users);
|
| 108 |
+
}
|
| 109 |
+
}
|
Controllers/CategoryController.cs
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
using Microsoft.AspNetCore.Mvc;
|
| 2 |
+
using Microsoft.AspNetCore.Authorization;
|
| 3 |
+
using ToolHub.Models;
|
| 4 |
+
|
| 5 |
+
namespace ToolHub.Controllers;
|
| 6 |
+
|
| 7 |
+
[Authorize(Roles = "Admin")]
|
| 8 |
+
public class CategoryController : Controller
|
| 9 |
+
{
|
| 10 |
+
private readonly IFreeSql _freeSql;
|
| 11 |
+
|
| 12 |
+
public CategoryController(IFreeSql freeSql)
|
| 13 |
+
{
|
| 14 |
+
_freeSql = freeSql;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
// 类别管理页面
|
| 18 |
+
public async Task<IActionResult> Index(int page = 1)
|
| 19 |
+
{
|
| 20 |
+
var pageSize = 20;
|
| 21 |
+
var categories = await _freeSql.Select<Category>()
|
| 22 |
+
.IncludeMany(c => c.Tools)
|
| 23 |
+
.Where(c => c.IsActive)
|
| 24 |
+
.OrderBy(c => c.SortOrder)
|
| 25 |
+
.Page(page, pageSize)
|
| 26 |
+
.ToListAsync();
|
| 27 |
+
|
| 28 |
+
// 获取总数用于分页
|
| 29 |
+
var totalCount = (int)await _freeSql.Select<Category>()
|
| 30 |
+
.Where(c => c.IsActive)
|
| 31 |
+
.CountAsync();
|
| 32 |
+
var totalPages = (int)Math.Ceiling((double)totalCount / pageSize);
|
| 33 |
+
|
| 34 |
+
ViewBag.CurrentPage = page;
|
| 35 |
+
ViewBag.TotalCount = totalCount;
|
| 36 |
+
ViewBag.TotalPages = totalPages;
|
| 37 |
+
ViewBag.PageSize = pageSize;
|
| 38 |
+
|
| 39 |
+
return View(categories);
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
// 获取分页分类列表(AJAX)
|
| 43 |
+
[HttpGet]
|
| 44 |
+
public async Task<IActionResult> GetCategories(int page = 1)
|
| 45 |
+
{
|
| 46 |
+
try
|
| 47 |
+
{
|
| 48 |
+
var pageSize = 20;
|
| 49 |
+
var categories = await _freeSql.Select<Category>()
|
| 50 |
+
.IncludeMany(c => c.Tools)
|
| 51 |
+
.Where(c => c.IsActive)
|
| 52 |
+
.OrderBy(c => c.SortOrder)
|
| 53 |
+
.Page(page, pageSize)
|
| 54 |
+
.ToListAsync();
|
| 55 |
+
|
| 56 |
+
var totalCount = (int)await _freeSql.Select<Category>()
|
| 57 |
+
.Where(c => c.IsActive)
|
| 58 |
+
.CountAsync();
|
| 59 |
+
var totalPages = (int)Math.Ceiling((double)totalCount / pageSize);
|
| 60 |
+
|
| 61 |
+
return Json(new
|
| 62 |
+
{
|
| 63 |
+
categories = categories,
|
| 64 |
+
pagination = new
|
| 65 |
+
{
|
| 66 |
+
currentPage = page,
|
| 67 |
+
totalPages = totalPages,
|
| 68 |
+
totalCount = totalCount,
|
| 69 |
+
pageSize = pageSize,
|
| 70 |
+
hasNext = page < totalPages,
|
| 71 |
+
hasPrev = page > 1
|
| 72 |
+
}
|
| 73 |
+
});
|
| 74 |
+
}
|
| 75 |
+
catch
|
| 76 |
+
{
|
| 77 |
+
return Json(new { success = false, message = "获取分类列表失败" });
|
| 78 |
+
}
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
// 保存类别(添加/编辑)
|
| 82 |
+
[HttpPost]
|
| 83 |
+
public async Task<IActionResult> Save([FromBody] CategoryDto categoryDto)
|
| 84 |
+
{
|
| 85 |
+
try
|
| 86 |
+
{
|
| 87 |
+
if (categoryDto.Id == 0)
|
| 88 |
+
{
|
| 89 |
+
// 添加新分类
|
| 90 |
+
var category = new Category
|
| 91 |
+
{
|
| 92 |
+
Name = categoryDto.Name,
|
| 93 |
+
Description = categoryDto.Description,
|
| 94 |
+
Icon = categoryDto.Icon,
|
| 95 |
+
Color = categoryDto.Color,
|
| 96 |
+
SortOrder = categoryDto.SortOrder,
|
| 97 |
+
IsActive = true
|
| 98 |
+
};
|
| 99 |
+
await _freeSql.Insert(category).ExecuteAffrowsAsync();
|
| 100 |
+
}
|
| 101 |
+
else
|
| 102 |
+
{
|
| 103 |
+
// 更新分类
|
| 104 |
+
await _freeSql.Update<Category>()
|
| 105 |
+
.Where(c => c.Id == categoryDto.Id)
|
| 106 |
+
.Set(c => c.Name, categoryDto.Name)
|
| 107 |
+
.Set(c => c.Description, categoryDto.Description)
|
| 108 |
+
.Set(c => c.Icon, categoryDto.Icon)
|
| 109 |
+
.Set(c => c.Color, categoryDto.Color)
|
| 110 |
+
.Set(c => c.SortOrder, categoryDto.SortOrder)
|
| 111 |
+
.Set(c => c.UpdatedAt, DateTime.Now)
|
| 112 |
+
.ExecuteAffrowsAsync();
|
| 113 |
+
}
|
| 114 |
+
return Json(new { success = true });
|
| 115 |
+
}
|
| 116 |
+
catch
|
| 117 |
+
{
|
| 118 |
+
return Json(new { success = false });
|
| 119 |
+
}
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
// 切换类别状态
|
| 123 |
+
[HttpPost]
|
| 124 |
+
public async Task<IActionResult> ToggleStatus([FromBody] ToggleCategoryStatusRequest request)
|
| 125 |
+
{
|
| 126 |
+
try
|
| 127 |
+
{
|
| 128 |
+
await _freeSql.Update<Category>()
|
| 129 |
+
.Where(c => c.Id == request.Id)
|
| 130 |
+
.Set(c => c.IsActive, request.IsActive)
|
| 131 |
+
.Set(c => c.UpdatedAt, DateTime.Now)
|
| 132 |
+
.ExecuteAffrowsAsync();
|
| 133 |
+
return Json(new { success = true });
|
| 134 |
+
}
|
| 135 |
+
catch
|
| 136 |
+
{
|
| 137 |
+
return Json(new { success = false });
|
| 138 |
+
}
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
// 删除类别
|
| 142 |
+
[HttpPost]
|
| 143 |
+
public async Task<IActionResult> Delete([FromBody] DeleteCategoryRequest request)
|
| 144 |
+
{
|
| 145 |
+
try
|
| 146 |
+
{
|
| 147 |
+
// 检查是否有工具使用此分类
|
| 148 |
+
var toolCount = await _freeSql.Select<Tool>().Where(t => t.CategoryId == request.Id).CountAsync();
|
| 149 |
+
if (toolCount > 0)
|
| 150 |
+
{
|
| 151 |
+
return Json(new { success = false, message = "该分类下还有工具,无法删除" });
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
await _freeSql.Delete<Category>().Where(c => c.Id == request.Id).ExecuteAffrowsAsync();
|
| 155 |
+
return Json(new { success = true });
|
| 156 |
+
}
|
| 157 |
+
catch
|
| 158 |
+
{
|
| 159 |
+
return Json(new { success = false });
|
| 160 |
+
}
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
// 获取类别列表(用于下拉选择)
|
| 164 |
+
[HttpGet]
|
| 165 |
+
public async Task<IActionResult> GetList()
|
| 166 |
+
{
|
| 167 |
+
try
|
| 168 |
+
{
|
| 169 |
+
var categories = await _freeSql.Select<Category>()
|
| 170 |
+
.Where(c => c.IsActive)
|
| 171 |
+
.OrderBy(c => c.SortOrder)
|
| 172 |
+
.ToListAsync();
|
| 173 |
+
return Json(categories);
|
| 174 |
+
}
|
| 175 |
+
catch
|
| 176 |
+
{
|
| 177 |
+
return Json(new List<Category>());
|
| 178 |
+
}
|
| 179 |
+
}
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
// DTOs
|
| 183 |
+
public class CategoryDto
|
| 184 |
+
{
|
| 185 |
+
public int Id { get; set; }
|
| 186 |
+
public string Name { get; set; } = string.Empty;
|
| 187 |
+
public string? Description { get; set; }
|
| 188 |
+
public string? Icon { get; set; }
|
| 189 |
+
public string? Color { get; set; }
|
| 190 |
+
public int SortOrder { get; set; }
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
public class ToggleCategoryStatusRequest
|
| 194 |
+
{
|
| 195 |
+
public int Id { get; set; }
|
| 196 |
+
public bool IsActive { get; set; }
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
public class DeleteCategoryRequest
|
| 200 |
+
{
|
| 201 |
+
public int Id { get; set; }
|
| 202 |
+
}
|
Controllers/HomeController.cs
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
using System.Diagnostics;
|
| 2 |
+
using Microsoft.AspNetCore.Mvc;
|
| 3 |
+
using ToolHub.Models;
|
| 4 |
+
using ToolHub.Services;
|
| 5 |
+
|
| 6 |
+
namespace ToolHub.Controllers
|
| 7 |
+
{
|
| 8 |
+
public class HomeController : Controller
|
| 9 |
+
{
|
| 10 |
+
private readonly ILogger<HomeController> _logger;
|
| 11 |
+
private readonly IToolService _toolService;
|
| 12 |
+
|
| 13 |
+
public HomeController(ILogger<HomeController> logger, IToolService toolService)
|
| 14 |
+
{
|
| 15 |
+
_logger = logger;
|
| 16 |
+
_toolService = toolService;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
public async Task<IActionResult> Index()
|
| 20 |
+
{
|
| 21 |
+
ViewBag.Categories = await _toolService.GetCategoriesAsync();
|
| 22 |
+
ViewBag.HotTools = await _toolService.GetHotToolsAsync();
|
| 23 |
+
ViewBag.NewTools = await _toolService.GetNewToolsAsync();
|
| 24 |
+
|
| 25 |
+
return View();
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
public async Task<IActionResult> Tools(int categoryId = 0, int page = 1, string? search = null)
|
| 29 |
+
{
|
| 30 |
+
ViewBag.Categories = await _toolService.GetCategoriesAsync();
|
| 31 |
+
ViewBag.CurrentCategory = categoryId;
|
| 32 |
+
ViewBag.Search = search;
|
| 33 |
+
|
| 34 |
+
List<Tool> tools;
|
| 35 |
+
if (!string.IsNullOrEmpty(search))
|
| 36 |
+
{
|
| 37 |
+
tools = await _toolService.SearchToolsAsync(search, page);
|
| 38 |
+
}
|
| 39 |
+
else
|
| 40 |
+
{
|
| 41 |
+
tools = await _toolService.GetToolsByCategoryAsync(categoryId, page);
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
return View(tools);
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
public async Task<IActionResult> Tool(int id)
|
| 48 |
+
{
|
| 49 |
+
var tool = await _toolService.GetToolByIdAsync(id);
|
| 50 |
+
if (tool == null)
|
| 51 |
+
{
|
| 52 |
+
return NotFound();
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
// 增加浏览次数
|
| 56 |
+
await _toolService.IncrementViewCountAsync(id);
|
| 57 |
+
|
| 58 |
+
return View(tool);
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
public IActionResult Privacy()
|
| 62 |
+
{
|
| 63 |
+
return View();
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
|
| 67 |
+
public IActionResult Error()
|
| 68 |
+
{
|
| 69 |
+
return View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier });
|
| 70 |
+
}
|
| 71 |
+
}
|
| 72 |
+
}
|
Controllers/TagController.cs
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
using Microsoft.AspNetCore.Mvc;
|
| 2 |
+
using Microsoft.AspNetCore.Authorization;
|
| 3 |
+
using ToolHub.Models;
|
| 4 |
+
|
| 5 |
+
namespace ToolHub.Controllers;
|
| 6 |
+
|
| 7 |
+
[Authorize(Roles = "Admin")]
|
| 8 |
+
public class TagController : Controller
|
| 9 |
+
{
|
| 10 |
+
private readonly IFreeSql _freeSql;
|
| 11 |
+
|
| 12 |
+
public TagController(IFreeSql freeSql)
|
| 13 |
+
{
|
| 14 |
+
_freeSql = freeSql;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
// 标签管理页面
|
| 18 |
+
public async Task<IActionResult> Index(int page = 1)
|
| 19 |
+
{
|
| 20 |
+
var pageSize = 20;
|
| 21 |
+
var tags = await _freeSql.Select<Tag>()
|
| 22 |
+
.IncludeMany(t => t.ToolTags)
|
| 23 |
+
.Where(t => t.IsActive)
|
| 24 |
+
.OrderBy(t => t.Name)
|
| 25 |
+
.Page(page, pageSize)
|
| 26 |
+
.ToListAsync();
|
| 27 |
+
|
| 28 |
+
// 获取总数用于分页
|
| 29 |
+
var totalCount = (int)await _freeSql.Select<Tag>()
|
| 30 |
+
.Where(t => t.IsActive)
|
| 31 |
+
.CountAsync();
|
| 32 |
+
var totalPages = (int)Math.Ceiling((double)totalCount / pageSize);
|
| 33 |
+
|
| 34 |
+
ViewBag.CurrentPage = page;
|
| 35 |
+
ViewBag.TotalCount = totalCount;
|
| 36 |
+
ViewBag.TotalPages = totalPages;
|
| 37 |
+
ViewBag.PageSize = pageSize;
|
| 38 |
+
|
| 39 |
+
return View(tags);
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
// 获取分页标签列表(AJAX)
|
| 43 |
+
[HttpGet]
|
| 44 |
+
public async Task<IActionResult> GetTags(int page = 1)
|
| 45 |
+
{
|
| 46 |
+
try
|
| 47 |
+
{
|
| 48 |
+
var pageSize = 20;
|
| 49 |
+
var tags = await _freeSql.Select<Tag>()
|
| 50 |
+
.IncludeMany(t => t.ToolTags)
|
| 51 |
+
.Where(t => t.IsActive)
|
| 52 |
+
.OrderBy(t => t.Name)
|
| 53 |
+
.Page(page, pageSize)
|
| 54 |
+
.ToListAsync();
|
| 55 |
+
|
| 56 |
+
var totalCount = (int)await _freeSql.Select<Tag>()
|
| 57 |
+
.Where(t => t.IsActive)
|
| 58 |
+
.CountAsync();
|
| 59 |
+
var totalPages = (int)Math.Ceiling((double)totalCount / pageSize);
|
| 60 |
+
|
| 61 |
+
return Json(new
|
| 62 |
+
{
|
| 63 |
+
tags = tags,
|
| 64 |
+
pagination = new
|
| 65 |
+
{
|
| 66 |
+
currentPage = page,
|
| 67 |
+
totalPages = totalPages,
|
| 68 |
+
totalCount = totalCount,
|
| 69 |
+
pageSize = pageSize,
|
| 70 |
+
hasNext = page < totalPages,
|
| 71 |
+
hasPrev = page > 1
|
| 72 |
+
}
|
| 73 |
+
});
|
| 74 |
+
}
|
| 75 |
+
catch
|
| 76 |
+
{
|
| 77 |
+
return Json(new { success = false, message = "获取标签列表失败" });
|
| 78 |
+
}
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
// 获取单个标签信息
|
| 82 |
+
[HttpGet]
|
| 83 |
+
public async Task<IActionResult> Get(int id)
|
| 84 |
+
{
|
| 85 |
+
try
|
| 86 |
+
{
|
| 87 |
+
var tag = await _freeSql.Select<Tag>()
|
| 88 |
+
.Where(t => t.Id == id)
|
| 89 |
+
.FirstAsync();
|
| 90 |
+
|
| 91 |
+
if (tag == null)
|
| 92 |
+
return NotFound();
|
| 93 |
+
|
| 94 |
+
return Json(tag);
|
| 95 |
+
}
|
| 96 |
+
catch
|
| 97 |
+
{
|
| 98 |
+
return StatusCode(500);
|
| 99 |
+
}
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
// 保存标签(添加/编辑)
|
| 103 |
+
[HttpPost]
|
| 104 |
+
public async Task<IActionResult> Save([FromBody] TagDto tagDto)
|
| 105 |
+
{
|
| 106 |
+
try
|
| 107 |
+
{
|
| 108 |
+
if (tagDto.Id == 0)
|
| 109 |
+
{
|
| 110 |
+
// 添加新标签
|
| 111 |
+
var tag = new Tag
|
| 112 |
+
{
|
| 113 |
+
Name = tagDto.Name,
|
| 114 |
+
Color = tagDto.Color,
|
| 115 |
+
IsActive = true
|
| 116 |
+
};
|
| 117 |
+
await _freeSql.Insert(tag).ExecuteAffrowsAsync();
|
| 118 |
+
}
|
| 119 |
+
else
|
| 120 |
+
{
|
| 121 |
+
// 更新标签
|
| 122 |
+
await _freeSql.Update<Tag>()
|
| 123 |
+
.Where(t => t.Id == tagDto.Id)
|
| 124 |
+
.Set(t => t.Name, tagDto.Name)
|
| 125 |
+
.Set(t => t.Color, tagDto.Color)
|
| 126 |
+
.ExecuteAffrowsAsync();
|
| 127 |
+
}
|
| 128 |
+
return Json(new { success = true });
|
| 129 |
+
}
|
| 130 |
+
catch
|
| 131 |
+
{
|
| 132 |
+
return Json(new { success = false });
|
| 133 |
+
}
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
// 切换标签状态
|
| 137 |
+
[HttpPost]
|
| 138 |
+
public async Task<IActionResult> ToggleStatus([FromBody] ToggleTagStatusRequest request)
|
| 139 |
+
{
|
| 140 |
+
try
|
| 141 |
+
{
|
| 142 |
+
await _freeSql.Update<Tag>()
|
| 143 |
+
.Where(t => t.Id == request.Id)
|
| 144 |
+
.Set(t => t.IsActive, request.IsActive)
|
| 145 |
+
.ExecuteAffrowsAsync();
|
| 146 |
+
return Json(new { success = true });
|
| 147 |
+
}
|
| 148 |
+
catch
|
| 149 |
+
{
|
| 150 |
+
return Json(new { success = false });
|
| 151 |
+
}
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
// 删除标签
|
| 155 |
+
[HttpPost]
|
| 156 |
+
public async Task<IActionResult> Delete([FromBody] DeleteTagRequest request)
|
| 157 |
+
{
|
| 158 |
+
try
|
| 159 |
+
{
|
| 160 |
+
// 检查是否有工具使用此标签
|
| 161 |
+
var toolTagCount = await _freeSql.Select<ToolTag>().Where(tt => tt.TagId == request.Id).CountAsync();
|
| 162 |
+
if (toolTagCount > 0)
|
| 163 |
+
{
|
| 164 |
+
return Json(new { success = false, message = "该标签下还有工具,无法删除" });
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
await _freeSql.Delete<Tag>().Where(t => t.Id == request.Id).ExecuteAffrowsAsync();
|
| 168 |
+
return Json(new { success = true });
|
| 169 |
+
}
|
| 170 |
+
catch
|
| 171 |
+
{
|
| 172 |
+
return Json(new { success = false });
|
| 173 |
+
}
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
// 获取标签列表(用于下拉选择)
|
| 177 |
+
[HttpGet]
|
| 178 |
+
public async Task<IActionResult> GetList()
|
| 179 |
+
{
|
| 180 |
+
try
|
| 181 |
+
{
|
| 182 |
+
var tags = await _freeSql.Select<Tag>()
|
| 183 |
+
.Where(t => t.IsActive)
|
| 184 |
+
.OrderBy(t => t.Name)
|
| 185 |
+
.ToListAsync();
|
| 186 |
+
return Json(tags);
|
| 187 |
+
}
|
| 188 |
+
catch
|
| 189 |
+
{
|
| 190 |
+
return Json(new List<Tag>());
|
| 191 |
+
}
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
// 为工具添加标签
|
| 195 |
+
[HttpPost]
|
| 196 |
+
public async Task<IActionResult> AddToolTag([FromBody] AddToolTagRequest request)
|
| 197 |
+
{
|
| 198 |
+
try
|
| 199 |
+
{
|
| 200 |
+
// 检查是否已存在
|
| 201 |
+
var existing = await _freeSql.Select<ToolTag>()
|
| 202 |
+
.Where(tt => tt.ToolId == request.ToolId && tt.TagId == request.TagId)
|
| 203 |
+
.FirstAsync();
|
| 204 |
+
|
| 205 |
+
if (existing != null)
|
| 206 |
+
{
|
| 207 |
+
return Json(new { success = false, message = "该工具已添加此标签" });
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
var toolTag = new ToolTag
|
| 211 |
+
{
|
| 212 |
+
ToolId = request.ToolId,
|
| 213 |
+
TagId = request.TagId
|
| 214 |
+
};
|
| 215 |
+
|
| 216 |
+
await _freeSql.Insert(toolTag).ExecuteAffrowsAsync();
|
| 217 |
+
return Json(new { success = true });
|
| 218 |
+
}
|
| 219 |
+
catch
|
| 220 |
+
{
|
| 221 |
+
return Json(new { success = false });
|
| 222 |
+
}
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
// 移除工具标签
|
| 226 |
+
[HttpPost]
|
| 227 |
+
public async Task<IActionResult> RemoveToolTag([FromBody] RemoveToolTagRequest request)
|
| 228 |
+
{
|
| 229 |
+
try
|
| 230 |
+
{
|
| 231 |
+
await _freeSql.Delete<ToolTag>()
|
| 232 |
+
.Where(tt => tt.ToolId == request.ToolId && tt.TagId == request.TagId)
|
| 233 |
+
.ExecuteAffrowsAsync();
|
| 234 |
+
return Json(new { success = true });
|
| 235 |
+
}
|
| 236 |
+
catch
|
| 237 |
+
{
|
| 238 |
+
return Json(new { success = false });
|
| 239 |
+
}
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
// 获取工具的标签
|
| 243 |
+
[HttpGet]
|
| 244 |
+
public async Task<IActionResult> GetToolTags(int toolId)
|
| 245 |
+
{
|
| 246 |
+
try
|
| 247 |
+
{
|
| 248 |
+
var toolTags = await _freeSql.Select<ToolTag>()
|
| 249 |
+
.Include(tt => tt.Tag)
|
| 250 |
+
.Where(tt => tt.ToolId == toolId)
|
| 251 |
+
.ToListAsync();
|
| 252 |
+
|
| 253 |
+
var tags = toolTags.Select(tt => tt.Tag).ToList();
|
| 254 |
+
return Json(tags);
|
| 255 |
+
}
|
| 256 |
+
catch
|
| 257 |
+
{
|
| 258 |
+
return Json(new List<Tag>());
|
| 259 |
+
}
|
| 260 |
+
}
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
// DTOs
|
| 264 |
+
public class TagDto
|
| 265 |
+
{
|
| 266 |
+
public int Id { get; set; }
|
| 267 |
+
public string Name { get; set; } = string.Empty;
|
| 268 |
+
public string? Color { get; set; }
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
public class ToggleTagStatusRequest
|
| 272 |
+
{
|
| 273 |
+
public int Id { get; set; }
|
| 274 |
+
public bool IsActive { get; set; }
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
public class DeleteTagRequest
|
| 278 |
+
{
|
| 279 |
+
public int Id { get; set; }
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
public class AddToolTagRequest
|
| 283 |
+
{
|
| 284 |
+
public int ToolId { get; set; }
|
| 285 |
+
public int TagId { get; set; }
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
public class RemoveToolTagRequest
|
| 289 |
+
{
|
| 290 |
+
public int ToolId { get; set; }
|
| 291 |
+
public int TagId { get; set; }
|
| 292 |
+
}
|
Controllers/ToolController.cs
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
using Microsoft.AspNetCore.Mvc;
|
| 2 |
+
using Microsoft.AspNetCore.Authorization;
|
| 3 |
+
using ToolHub.Models;
|
| 4 |
+
using ToolHub.Services;
|
| 5 |
+
|
| 6 |
+
namespace ToolHub.Controllers;
|
| 7 |
+
|
| 8 |
+
[Authorize(Roles = "Admin")]
|
| 9 |
+
public class ToolController : Controller
|
| 10 |
+
{
|
| 11 |
+
private readonly IToolService _toolService;
|
| 12 |
+
private readonly IFreeSql _freeSql;
|
| 13 |
+
|
| 14 |
+
public ToolController(IToolService toolService, IFreeSql freeSql)
|
| 15 |
+
{
|
| 16 |
+
_toolService = toolService;
|
| 17 |
+
_freeSql = freeSql;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
// 工具管理页面
|
| 21 |
+
public async Task<IActionResult> Index(int page = 1, int categoryId = 0)
|
| 22 |
+
{
|
| 23 |
+
ViewBag.Categories = await _toolService.GetCategoriesAsync();
|
| 24 |
+
ViewBag.CurrentCategory = categoryId;
|
| 25 |
+
ViewBag.CurrentPage = page;
|
| 26 |
+
|
| 27 |
+
var pageSize = 20;
|
| 28 |
+
var tools = await _toolService.GetToolsByCategoryAsync(categoryId, page, pageSize,true);
|
| 29 |
+
|
| 30 |
+
// 获取总数用于分页
|
| 31 |
+
var totalCount = await GetToolsCountAsync(categoryId);
|
| 32 |
+
var totalPages = (int)Math.Ceiling((double)totalCount / pageSize);
|
| 33 |
+
|
| 34 |
+
ViewBag.TotalCount = totalCount;
|
| 35 |
+
ViewBag.TotalPages = totalPages;
|
| 36 |
+
ViewBag.PageSize = pageSize;
|
| 37 |
+
|
| 38 |
+
return View(tools);
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
// 获取工具总数
|
| 42 |
+
private async Task<int> GetToolsCountAsync(int categoryId = 0)
|
| 43 |
+
{
|
| 44 |
+
var query = _freeSql.Select<Tool>().Where(t => t.IsActive);
|
| 45 |
+
|
| 46 |
+
if (categoryId > 0)
|
| 47 |
+
{
|
| 48 |
+
query = query.Where(t => t.CategoryId == categoryId);
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
return (int)await query.CountAsync();
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
// 获取分页工具列表(AJAX)
|
| 55 |
+
[HttpGet]
|
| 56 |
+
public async Task<IActionResult> GetTools(int page = 1, int categoryId = 0, string search = "")
|
| 57 |
+
{
|
| 58 |
+
try
|
| 59 |
+
{
|
| 60 |
+
var pageSize = 20;
|
| 61 |
+
List<Tool> tools;
|
| 62 |
+
int totalCount;
|
| 63 |
+
|
| 64 |
+
if (!string.IsNullOrEmpty(search))
|
| 65 |
+
{
|
| 66 |
+
tools = await _toolService.SearchToolsAsync(search, page, pageSize);
|
| 67 |
+
totalCount = (int)await _freeSql.Select<Tool>()
|
| 68 |
+
.Where(t => t.IsActive && (t.Name.Contains(search) || t.Description!.Contains(search)))
|
| 69 |
+
.CountAsync();
|
| 70 |
+
}
|
| 71 |
+
else
|
| 72 |
+
{
|
| 73 |
+
tools = await _toolService.GetToolsByCategoryAsync(categoryId, page, pageSize);
|
| 74 |
+
totalCount = await GetToolsCountAsync(categoryId);
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
var totalPages = (int)Math.Ceiling((double)totalCount / pageSize);
|
| 78 |
+
|
| 79 |
+
return Json(new
|
| 80 |
+
{
|
| 81 |
+
tools = tools,
|
| 82 |
+
pagination = new
|
| 83 |
+
{
|
| 84 |
+
currentPage = page,
|
| 85 |
+
totalPages = totalPages,
|
| 86 |
+
totalCount = totalCount,
|
| 87 |
+
pageSize = pageSize,
|
| 88 |
+
hasNext = page < totalPages,
|
| 89 |
+
hasPrev = page > 1
|
| 90 |
+
}
|
| 91 |
+
});
|
| 92 |
+
}
|
| 93 |
+
catch
|
| 94 |
+
{
|
| 95 |
+
return Json(new { success = false, message = "获取工具列表失败" });
|
| 96 |
+
}
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
// 获取所有工具列表(用于统计页面)
|
| 100 |
+
[HttpGet]
|
| 101 |
+
public async Task<IActionResult> GetAllTools()
|
| 102 |
+
{
|
| 103 |
+
try
|
| 104 |
+
{
|
| 105 |
+
var tools = await _freeSql.Select<Tool>()
|
| 106 |
+
.Where(t => t.IsActive)
|
| 107 |
+
.OrderBy(t => t.Name)
|
| 108 |
+
.ToListAsync();
|
| 109 |
+
|
| 110 |
+
return Json(new { success = true, tools = tools });
|
| 111 |
+
}
|
| 112 |
+
catch
|
| 113 |
+
{
|
| 114 |
+
return Json(new { success = false, message = "获取工具列表失败" });
|
| 115 |
+
}
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
// 获取单个工具信息
|
| 119 |
+
[HttpGet]
|
| 120 |
+
public async Task<IActionResult> Get(int id)
|
| 121 |
+
{
|
| 122 |
+
try
|
| 123 |
+
{
|
| 124 |
+
var tool = await _freeSql.Select<Tool>()
|
| 125 |
+
.Where(t => t.Id == id)
|
| 126 |
+
.FirstAsync();
|
| 127 |
+
|
| 128 |
+
if (tool == null)
|
| 129 |
+
return NotFound();
|
| 130 |
+
|
| 131 |
+
return Json(tool);
|
| 132 |
+
}
|
| 133 |
+
catch
|
| 134 |
+
{
|
| 135 |
+
return StatusCode(500);
|
| 136 |
+
}
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
// 保存工具(添加/编辑)
|
| 140 |
+
[HttpPost]
|
| 141 |
+
public async Task<IActionResult> Save([FromBody] ToolDto toolDto)
|
| 142 |
+
{
|
| 143 |
+
try
|
| 144 |
+
{
|
| 145 |
+
if (toolDto.Id == 0)
|
| 146 |
+
{
|
| 147 |
+
// 添加新工具
|
| 148 |
+
var tool = new Tool
|
| 149 |
+
{
|
| 150 |
+
Name = toolDto.Name,
|
| 151 |
+
Description = toolDto.Description,
|
| 152 |
+
Icon = toolDto.Icon,
|
| 153 |
+
Image = toolDto.Image,
|
| 154 |
+
Url = toolDto.Url,
|
| 155 |
+
CategoryId = toolDto.CategoryId,
|
| 156 |
+
IsHot = toolDto.IsHot,
|
| 157 |
+
IsNew = toolDto.IsNew,
|
| 158 |
+
IsRecommended = toolDto.IsRecommended,
|
| 159 |
+
SortOrder = toolDto.SortOrder,
|
| 160 |
+
IsActive = true
|
| 161 |
+
};
|
| 162 |
+
await _freeSql.Insert(tool).ExecuteAffrowsAsync();
|
| 163 |
+
}
|
| 164 |
+
else
|
| 165 |
+
{
|
| 166 |
+
// 更新工具
|
| 167 |
+
await _freeSql.Update<Tool>()
|
| 168 |
+
.Where(t => t.Id == toolDto.Id)
|
| 169 |
+
.Set(t => t.Name, toolDto.Name)
|
| 170 |
+
.Set(t => t.Description, toolDto.Description)
|
| 171 |
+
.Set(t => t.Icon, toolDto.Icon)
|
| 172 |
+
.Set(t => t.Image, toolDto.Image)
|
| 173 |
+
.Set(t => t.Url, toolDto.Url)
|
| 174 |
+
.Set(t => t.CategoryId, toolDto.CategoryId)
|
| 175 |
+
.Set(t => t.IsHot, toolDto.IsHot)
|
| 176 |
+
.Set(t => t.IsNew, toolDto.IsNew)
|
| 177 |
+
.Set(t => t.IsRecommended, toolDto.IsRecommended)
|
| 178 |
+
.Set(t => t.SortOrder, toolDto.SortOrder)
|
| 179 |
+
.Set(t => t.UpdatedAt, DateTime.Now)
|
| 180 |
+
.ExecuteAffrowsAsync();
|
| 181 |
+
}
|
| 182 |
+
return Json(new { success = true });
|
| 183 |
+
}
|
| 184 |
+
catch
|
| 185 |
+
{
|
| 186 |
+
return Json(new { success = false });
|
| 187 |
+
}
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
// 删除工具
|
| 191 |
+
[HttpPost]
|
| 192 |
+
public async Task<IActionResult> Delete([FromBody] DeleteToolRequest request)
|
| 193 |
+
{
|
| 194 |
+
try
|
| 195 |
+
{
|
| 196 |
+
var result = await _toolService.DeleteToolAsync(request.Id);
|
| 197 |
+
return Json(new { success = result });
|
| 198 |
+
}
|
| 199 |
+
catch
|
| 200 |
+
{
|
| 201 |
+
return Json(new { success = false });
|
| 202 |
+
}
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
// 切换工具状态
|
| 206 |
+
[HttpPost]
|
| 207 |
+
public async Task<IActionResult> ToggleStatus([FromBody] ToggleToolStatusRequest request)
|
| 208 |
+
{
|
| 209 |
+
try
|
| 210 |
+
{
|
| 211 |
+
await _freeSql.Update<Tool>()
|
| 212 |
+
.Where(t => t.Id == request.Id)
|
| 213 |
+
.Set(t => t.IsActive, request.IsActive)
|
| 214 |
+
.Set(t => t.UpdatedAt, DateTime.Now)
|
| 215 |
+
.ExecuteAffrowsAsync();
|
| 216 |
+
return Json(new { success = true });
|
| 217 |
+
}
|
| 218 |
+
catch
|
| 219 |
+
{
|
| 220 |
+
return Json(new { success = false });
|
| 221 |
+
}
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
// 更新工具标志
|
| 225 |
+
[HttpPost]
|
| 226 |
+
public async Task<IActionResult> UpdateFlags([FromBody] UpdateToolFlagsRequest request)
|
| 227 |
+
{
|
| 228 |
+
try
|
| 229 |
+
{
|
| 230 |
+
await _freeSql.Update<Tool>()
|
| 231 |
+
.Where(t => t.Id == request.Id)
|
| 232 |
+
.Set(t => t.IsHot, request.IsHot)
|
| 233 |
+
.Set(t => t.IsNew, request.IsNew)
|
| 234 |
+
.Set(t => t.IsRecommended, request.IsRecommended)
|
| 235 |
+
.Set(t => t.UpdatedAt, DateTime.Now)
|
| 236 |
+
.ExecuteAffrowsAsync();
|
| 237 |
+
return Json(new { success = true });
|
| 238 |
+
}
|
| 239 |
+
catch
|
| 240 |
+
{
|
| 241 |
+
return Json(new { success = false });
|
| 242 |
+
}
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
// 初始化图片压缩工具
|
| 246 |
+
[HttpGet]
|
| 247 |
+
public async Task<IActionResult> InitImageCompressor()
|
| 248 |
+
{
|
| 249 |
+
try
|
| 250 |
+
{
|
| 251 |
+
// 检查是否已存在图片压缩工具
|
| 252 |
+
var existingTool = await _freeSql.Select<Tool>()
|
| 253 |
+
.Where(t => t.Name == "图片压缩工具" || t.Url!.Contains("/Tools/ImageCompressor"))
|
| 254 |
+
.FirstAsync();
|
| 255 |
+
|
| 256 |
+
if (existingTool != null)
|
| 257 |
+
{
|
| 258 |
+
return Json(new { success = false, message = "图片压缩工具已存在" });
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
// 查找或创建图像处理分类
|
| 262 |
+
var category = await _freeSql.Select<Category>()
|
| 263 |
+
.Where(c => c.Name == "图像处理" || c.Name == "图片工具")
|
| 264 |
+
.FirstAsync();
|
| 265 |
+
|
| 266 |
+
if (category == null)
|
| 267 |
+
{
|
| 268 |
+
// 创建图像处理分类
|
| 269 |
+
category = new Category
|
| 270 |
+
{
|
| 271 |
+
Name = "图像处理",
|
| 272 |
+
Description = "图片处理、编辑、转换等相关工具",
|
| 273 |
+
Icon = "fas fa-image",
|
| 274 |
+
Color = "#FF6B35",
|
| 275 |
+
SortOrder = 3,
|
| 276 |
+
IsActive = true,
|
| 277 |
+
CreatedAt = DateTime.Now
|
| 278 |
+
};
|
| 279 |
+
var categoryId = await _freeSql.Insert(category).ExecuteIdentityAsync();
|
| 280 |
+
category.Id = (int)categoryId;
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
// 添加图片压缩工具
|
| 284 |
+
var tool = new Tool
|
| 285 |
+
{
|
| 286 |
+
Name = "图片压缩工具",
|
| 287 |
+
Description = "快速压缩图片文件,支持JPG、PNG、WebP等格式,保持高质量的同时大幅减小文件体积",
|
| 288 |
+
Icon = "fas fa-compress-alt",
|
| 289 |
+
Image = "",
|
| 290 |
+
Url = "/Tools/ImageCompressor",
|
| 291 |
+
CategoryId = category.Id,
|
| 292 |
+
IsHot = true,
|
| 293 |
+
IsNew = true,
|
| 294 |
+
IsRecommended = true,
|
| 295 |
+
IsActive = true,
|
| 296 |
+
SortOrder = 1,
|
| 297 |
+
ViewCount = 0,
|
| 298 |
+
CreatedAt = DateTime.Now
|
| 299 |
+
};
|
| 300 |
+
|
| 301 |
+
await _freeSql.Insert(tool).ExecuteAffrowsAsync();
|
| 302 |
+
|
| 303 |
+
return Json(new { success = true, message = "图片压缩工具初始化成功" });
|
| 304 |
+
}
|
| 305 |
+
catch (Exception ex)
|
| 306 |
+
{
|
| 307 |
+
return Json(new { success = false, message = ex.Message });
|
| 308 |
+
}
|
| 309 |
+
}
|
| 310 |
+
}
|
| 311 |
+
|
| 312 |
+
// DTOs
|
| 313 |
+
public class ToolDto
|
| 314 |
+
{
|
| 315 |
+
public int Id { get; set; }
|
| 316 |
+
public string Name { get; set; } = string.Empty;
|
| 317 |
+
public string? Description { get; set; }
|
| 318 |
+
public string? Icon { get; set; }
|
| 319 |
+
public string? Image { get; set; }
|
| 320 |
+
public string? Url { get; set; }
|
| 321 |
+
public int CategoryId { get; set; }
|
| 322 |
+
public bool IsHot { get; set; }
|
| 323 |
+
public bool IsNew { get; set; }
|
| 324 |
+
public bool IsRecommended { get; set; }
|
| 325 |
+
public int SortOrder { get; set; }
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
public class DeleteToolRequest
|
| 329 |
+
{
|
| 330 |
+
public int Id { get; set; }
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
public class ToggleToolStatusRequest
|
| 334 |
+
{
|
| 335 |
+
public int Id { get; set; }
|
| 336 |
+
public bool IsActive { get; set; }
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
public class UpdateToolFlagsRequest
|
| 340 |
+
{
|
| 341 |
+
public int Id { get; set; }
|
| 342 |
+
public bool IsHot { get; set; }
|
| 343 |
+
public bool IsNew { get; set; }
|
| 344 |
+
public bool IsRecommended { get; set; }
|
| 345 |
+
}
|
Controllers/ToolStatisticsController.cs
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
using Microsoft.AspNetCore.Mvc;
|
| 2 |
+
using Microsoft.AspNetCore.Authorization;
|
| 3 |
+
using ToolHub.Services;
|
| 4 |
+
using ToolHub.Models;
|
| 5 |
+
|
| 6 |
+
namespace ToolHub.Controllers;
|
| 7 |
+
|
| 8 |
+
[Authorize(Roles = "Admin")]
|
| 9 |
+
public class ToolStatisticsController : Controller
|
| 10 |
+
{
|
| 11 |
+
private readonly IToolService _toolService;
|
| 12 |
+
|
| 13 |
+
public ToolStatisticsController(IToolService toolService)
|
| 14 |
+
{
|
| 15 |
+
_toolService = toolService;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
/// <summary>
|
| 19 |
+
/// 获取工具统计数据页面
|
| 20 |
+
/// </summary>
|
| 21 |
+
public async Task<IActionResult> Index(int toolId = 0)
|
| 22 |
+
{
|
| 23 |
+
ViewData["Title"] = "工具统计";
|
| 24 |
+
|
| 25 |
+
if (toolId > 0)
|
| 26 |
+
{
|
| 27 |
+
var tool = await _toolService.GetToolByIdAsync(toolId);
|
| 28 |
+
if (tool != null)
|
| 29 |
+
{
|
| 30 |
+
ViewData["ToolName"] = tool.Name;
|
| 31 |
+
ViewData["ToolId"] = toolId;
|
| 32 |
+
}
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
return View();
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
/// <summary>
|
| 39 |
+
/// 获取工具统计数据
|
| 40 |
+
/// </summary>
|
| 41 |
+
[HttpGet]
|
| 42 |
+
public async Task<IActionResult> GetToolStatistics(int toolId, DateTime? date = null)
|
| 43 |
+
{
|
| 44 |
+
try
|
| 45 |
+
{
|
| 46 |
+
var targetDate = date ?? DateTime.Today;
|
| 47 |
+
var stats = await _toolService.GetToolStatisticsAsync(toolId, targetDate);
|
| 48 |
+
|
| 49 |
+
return Json(new { success = true, data = stats });
|
| 50 |
+
}
|
| 51 |
+
catch (Exception ex)
|
| 52 |
+
{
|
| 53 |
+
return Json(new { success = false, message = ex.Message });
|
| 54 |
+
}
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
/// <summary>
|
| 58 |
+
/// 获取工具访问记录
|
| 59 |
+
/// </summary>
|
| 60 |
+
[HttpGet]
|
| 61 |
+
public async Task<IActionResult> GetToolAccessRecords(int toolId, DateTime? startDate = null, DateTime? endDate = null, int limit = 100)
|
| 62 |
+
{
|
| 63 |
+
try
|
| 64 |
+
{
|
| 65 |
+
var records = await _toolService.GetToolAccessRecordsAsync(toolId, startDate, endDate, limit);
|
| 66 |
+
|
| 67 |
+
return Json(new { success = true, data = records });
|
| 68 |
+
}
|
| 69 |
+
catch (Exception ex)
|
| 70 |
+
{
|
| 71 |
+
return Json(new { success = false, message = ex.Message });
|
| 72 |
+
}
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
/// <summary>
|
| 76 |
+
/// 获取用户访问记录
|
| 77 |
+
/// </summary>
|
| 78 |
+
[HttpGet]
|
| 79 |
+
public async Task<IActionResult> GetUserAccessRecords(int userId, DateTime? startDate = null, DateTime? endDate = null, int limit = 100)
|
| 80 |
+
{
|
| 81 |
+
try
|
| 82 |
+
{
|
| 83 |
+
var records = await _toolService.GetUserToolAccessRecordsAsync(userId, startDate, endDate, limit);
|
| 84 |
+
|
| 85 |
+
return Json(new { success = true, data = records });
|
| 86 |
+
}
|
| 87 |
+
catch (Exception ex)
|
| 88 |
+
{
|
| 89 |
+
return Json(new { success = false, message = ex.Message });
|
| 90 |
+
}
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
/// <summary>
|
| 94 |
+
/// 获取工具使用趋势
|
| 95 |
+
/// </summary>
|
| 96 |
+
[HttpGet]
|
| 97 |
+
public async Task<IActionResult> GetToolUsageTrend(int toolId, int days = 30)
|
| 98 |
+
{
|
| 99 |
+
try
|
| 100 |
+
{
|
| 101 |
+
var trend = await _toolService.GetToolUsageTrendAsync(toolId, days);
|
| 102 |
+
|
| 103 |
+
return Json(new { success = true, data = trend });
|
| 104 |
+
}
|
| 105 |
+
catch (Exception ex)
|
| 106 |
+
{
|
| 107 |
+
return Json(new { success = false, message = ex.Message });
|
| 108 |
+
}
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
/// <summary>
|
| 112 |
+
/// 获取热门工具
|
| 113 |
+
/// </summary>
|
| 114 |
+
[HttpGet]
|
| 115 |
+
public async Task<IActionResult> GetPopularTools(int count = 10, DateTime? startDate = null)
|
| 116 |
+
{
|
| 117 |
+
try
|
| 118 |
+
{
|
| 119 |
+
var tools = await _toolService.GetPopularToolsAsync(count, startDate);
|
| 120 |
+
|
| 121 |
+
return Json(new { success = true, data = tools });
|
| 122 |
+
}
|
| 123 |
+
catch (Exception ex)
|
| 124 |
+
{
|
| 125 |
+
return Json(new { success = false, message = ex.Message });
|
| 126 |
+
}
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
/// <summary>
|
| 130 |
+
/// 记录工具操作
|
| 131 |
+
/// </summary>
|
| 132 |
+
[HttpPost]
|
| 133 |
+
public async Task<IActionResult> RecordToolAction([FromBody] RecordToolActionRequest request)
|
| 134 |
+
{
|
| 135 |
+
try
|
| 136 |
+
{
|
| 137 |
+
await _toolService.RecordToolActionAsync(request.ToolId, request.ActionType, request.Duration);
|
| 138 |
+
|
| 139 |
+
return Json(new { success = true });
|
| 140 |
+
}
|
| 141 |
+
catch (Exception ex)
|
| 142 |
+
{
|
| 143 |
+
return Json(new { success = false, message = ex.Message });
|
| 144 |
+
}
|
| 145 |
+
}
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
public class RecordToolActionRequest
|
| 149 |
+
{
|
| 150 |
+
public int ToolId { get; set; }
|
| 151 |
+
public string ActionType { get; set; } = "view";
|
| 152 |
+
public int Duration { get; set; } = 0;
|
| 153 |
+
}
|
Controllers/ToolsController.cs
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
using Microsoft.AspNetCore.Mvc;
|
| 2 |
+
using ToolHub.Services;
|
| 3 |
+
|
| 4 |
+
namespace ToolHub.Controllers;
|
| 5 |
+
|
| 6 |
+
public class ToolsController : Controller
|
| 7 |
+
{
|
| 8 |
+
private readonly IToolService _toolService;
|
| 9 |
+
|
| 10 |
+
public ToolsController(IToolService toolService)
|
| 11 |
+
{
|
| 12 |
+
_toolService = toolService;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
// 图片压缩工具页面
|
| 16 |
+
public async Task<IActionResult> ImageCompressor()
|
| 17 |
+
{
|
| 18 |
+
// 增加访问计数
|
| 19 |
+
await _toolService.IncrementViewCountAsync( nameof(ImageCompressor));
|
| 20 |
+
|
| 21 |
+
ViewData["Title"] = "图片压缩工具";
|
| 22 |
+
ViewData["Description"] = "快速压缩图片,支持JPG、PNG、WebP等格式,保持高质量的同时大幅减小文件体积";
|
| 23 |
+
ViewData["Keywords"] = "图片压缩,在线压缩,图片优化,文件压缩";
|
| 24 |
+
|
| 25 |
+
return View();
|
| 26 |
+
}
|
| 27 |
+
}
|
Dockerfile
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 使用官方.NET 9.0运行时镜像作为基础镜像
|
| 2 |
+
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
EXPOSE 7860
|
| 5 |
+
|
| 6 |
+
# 安装supervisor和其他必要工具
|
| 7 |
+
RUN apt-get update && apt-get install -y \
|
| 8 |
+
supervisor \
|
| 9 |
+
curl \
|
| 10 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 11 |
+
|
| 12 |
+
# 创建supervisor配置目录
|
| 13 |
+
RUN mkdir -p /var/log/supervisor /etc/supervisor/conf.d
|
| 14 |
+
|
| 15 |
+
# 复制supervisor配置文件
|
| 16 |
+
COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
| 17 |
+
|
| 18 |
+
# 使用官方.NET 9.0 SDK镜像进行构建
|
| 19 |
+
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
|
| 20 |
+
WORKDIR /src
|
| 21 |
+
|
| 22 |
+
# 复制项目文件
|
| 23 |
+
COPY ["ToolHub.csproj", "./"]
|
| 24 |
+
RUN dotnet restore "ToolHub.csproj"
|
| 25 |
+
|
| 26 |
+
# 复制所有源代码
|
| 27 |
+
COPY . .
|
| 28 |
+
WORKDIR "/src"
|
| 29 |
+
|
| 30 |
+
# 构建应用
|
| 31 |
+
RUN dotnet build "ToolHub.csproj" -c Release -o /app/build
|
| 32 |
+
|
| 33 |
+
# 发布应用
|
| 34 |
+
FROM build AS publish
|
| 35 |
+
RUN dotnet publish "ToolHub.csproj" -c Release -o /app/publish /p:UseAppHost=false
|
| 36 |
+
|
| 37 |
+
# 最终镜像
|
| 38 |
+
FROM base AS final
|
| 39 |
+
WORKDIR /app
|
| 40 |
+
|
| 41 |
+
# 复制发布的应用
|
| 42 |
+
COPY --from=publish /app/publish .
|
| 43 |
+
|
| 44 |
+
# 复制启动脚本
|
| 45 |
+
COPY start.sh /app/start.sh
|
| 46 |
+
RUN chmod +x /app/start.sh
|
| 47 |
+
|
| 48 |
+
# 创建数据目录
|
| 49 |
+
RUN mkdir -p /app/data
|
| 50 |
+
|
| 51 |
+
# 设置环境变量
|
| 52 |
+
ENV ASPNETCORE_URLS=http://+:7860
|
| 53 |
+
ENV ASPNETCORE_ENVIRONMENT=Production
|
| 54 |
+
ENV DOTNET_RUNNING_IN_CONTAINER=true
|
| 55 |
+
|
| 56 |
+
# 健康检查
|
| 57 |
+
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
| 58 |
+
CMD curl -f http://localhost:7860/ || exit 1
|
| 59 |
+
|
| 60 |
+
# 使用supervisor启动应用
|
| 61 |
+
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
|
Models/Category.cs
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
using FreeSql.DataAnnotations;
|
| 2 |
+
|
| 3 |
+
namespace ToolHub.Models;
|
| 4 |
+
|
| 5 |
+
[Table(Name = "Categories")]
|
| 6 |
+
public class Category
|
| 7 |
+
{
|
| 8 |
+
[Column(IsIdentity = true, IsPrimary = true)]
|
| 9 |
+
public int Id { get; set; }
|
| 10 |
+
|
| 11 |
+
[Column(StringLength = 100)]
|
| 12 |
+
public string Name { get; set; } = string.Empty;
|
| 13 |
+
|
| 14 |
+
[Column(StringLength = 500)]
|
| 15 |
+
public string? Description { get; set; }
|
| 16 |
+
|
| 17 |
+
[Column(StringLength = 50)]
|
| 18 |
+
public string? Icon { get; set; }
|
| 19 |
+
|
| 20 |
+
[Column(StringLength = 20)]
|
| 21 |
+
public string? Color { get; set; }
|
| 22 |
+
|
| 23 |
+
public int SortOrder { get; set; } = 0;
|
| 24 |
+
|
| 25 |
+
public bool IsActive { get; set; } = true;
|
| 26 |
+
|
| 27 |
+
public DateTime CreatedAt { get; set; } = DateTime.Now;
|
| 28 |
+
|
| 29 |
+
public DateTime? UpdatedAt { get; set; }
|
| 30 |
+
|
| 31 |
+
// 导航属性
|
| 32 |
+
[Navigate(nameof(Tool.CategoryId))]
|
| 33 |
+
public List<Tool> Tools { get; set; } = new();
|
| 34 |
+
}
|
Models/ErrorViewModel.cs
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
namespace ToolHub.Models
|
| 2 |
+
{
|
| 3 |
+
public class ErrorViewModel
|
| 4 |
+
{
|
| 5 |
+
public string? RequestId { get; set; }
|
| 6 |
+
|
| 7 |
+
public bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
|
| 8 |
+
}
|
| 9 |
+
}
|
Models/Tag.cs
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
using FreeSql.DataAnnotations;
|
| 2 |
+
|
| 3 |
+
namespace ToolHub.Models;
|
| 4 |
+
|
| 5 |
+
[Table(Name = "Tags")]
|
| 6 |
+
public class Tag
|
| 7 |
+
{
|
| 8 |
+
[Column(IsIdentity = true, IsPrimary = true)]
|
| 9 |
+
public int Id { get; set; }
|
| 10 |
+
|
| 11 |
+
[Column(StringLength = 50)]
|
| 12 |
+
public string Name { get; set; } = string.Empty;
|
| 13 |
+
|
| 14 |
+
[Column(StringLength = 20)]
|
| 15 |
+
public string? Color { get; set; }
|
| 16 |
+
|
| 17 |
+
public bool IsActive { get; set; } = true;
|
| 18 |
+
|
| 19 |
+
public DateTime CreatedAt { get; set; } = DateTime.Now;
|
| 20 |
+
|
| 21 |
+
// 导航属性
|
| 22 |
+
[Navigate(nameof(ToolTag.TagId))]
|
| 23 |
+
public List<ToolTag> ToolTags { get; set; } = new();
|
| 24 |
+
}
|
Models/Tool.cs
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
using FreeSql.DataAnnotations;
|
| 2 |
+
|
| 3 |
+
namespace ToolHub.Models;
|
| 4 |
+
|
| 5 |
+
[Table(Name = "Tools")]
|
| 6 |
+
public class Tool
|
| 7 |
+
{
|
| 8 |
+
[Column(IsIdentity = true, IsPrimary = true)]
|
| 9 |
+
public int Id { get; set; }
|
| 10 |
+
|
| 11 |
+
[Column(StringLength = 200)]
|
| 12 |
+
public string Name { get; set; } = string.Empty;
|
| 13 |
+
|
| 14 |
+
[Column(StringLength = 1000)]
|
| 15 |
+
public string? Description { get; set; }
|
| 16 |
+
|
| 17 |
+
[Column(StringLength = 500)]
|
| 18 |
+
public string? Icon { get; set; }
|
| 19 |
+
|
| 20 |
+
[Column(StringLength = 500)]
|
| 21 |
+
public string? Image { get; set; }
|
| 22 |
+
|
| 23 |
+
[Column(StringLength = 500)]
|
| 24 |
+
public string? Url { get; set; }
|
| 25 |
+
|
| 26 |
+
public int CategoryId { get; set; }
|
| 27 |
+
|
| 28 |
+
public int ViewCount { get; set; } = 0;
|
| 29 |
+
|
| 30 |
+
public int FavoriteCount { get; set; } = 0;
|
| 31 |
+
|
| 32 |
+
public decimal Rating { get; set; } = 0;
|
| 33 |
+
|
| 34 |
+
public int RatingCount { get; set; } = 0;
|
| 35 |
+
|
| 36 |
+
public bool IsHot { get; set; } = false;
|
| 37 |
+
|
| 38 |
+
public bool IsNew { get; set; } = false;
|
| 39 |
+
|
| 40 |
+
public bool IsRecommended { get; set; } = false;
|
| 41 |
+
|
| 42 |
+
public bool IsActive { get; set; } = true;
|
| 43 |
+
|
| 44 |
+
public int SortOrder { get; set; } = 0;
|
| 45 |
+
|
| 46 |
+
public DateTime CreatedAt { get; set; } = DateTime.Now;
|
| 47 |
+
|
| 48 |
+
public DateTime? UpdatedAt { get; set; }
|
| 49 |
+
|
| 50 |
+
// 导航属性
|
| 51 |
+
[Navigate(nameof(CategoryId))]
|
| 52 |
+
public Category Category { get; set; } = null!;
|
| 53 |
+
|
| 54 |
+
[Navigate(nameof(UserFavorite.ToolId))]
|
| 55 |
+
public List<UserFavorite> UserFavorites { get; set; } = new();
|
| 56 |
+
|
| 57 |
+
[Navigate(nameof(ToolTag.ToolId))]
|
| 58 |
+
public List<ToolTag> ToolTags { get; set; } = new();
|
| 59 |
+
}
|
Models/ToolStatistics.cs
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
using FreeSql.DataAnnotations;
|
| 2 |
+
|
| 3 |
+
namespace ToolHub.Models;
|
| 4 |
+
|
| 5 |
+
[Table(Name = "ToolStatistics")]
|
| 6 |
+
public class ToolStatistics
|
| 7 |
+
{
|
| 8 |
+
[Column(IsIdentity = true, IsPrimary = true)]
|
| 9 |
+
public int Id { get; set; }
|
| 10 |
+
|
| 11 |
+
public int ToolId { get; set; }
|
| 12 |
+
|
| 13 |
+
public DateTime Date { get; set; } // 统计日期
|
| 14 |
+
|
| 15 |
+
public int DailyViews { get; set; } = 0; // 日访问量
|
| 16 |
+
|
| 17 |
+
public int DailyUniqueViews { get; set; } = 0; // 日独立访问量
|
| 18 |
+
|
| 19 |
+
public int DailyFavorites { get; set; } = 0; // 日收藏数
|
| 20 |
+
|
| 21 |
+
public int DailyShares { get; set; } = 0; // 日分享数
|
| 22 |
+
|
| 23 |
+
public int DailyDownloads { get; set; } = 0; // 日下载数
|
| 24 |
+
|
| 25 |
+
public decimal AverageDuration { get; set; } = 0; // 平均停留时间(秒)
|
| 26 |
+
|
| 27 |
+
public int BounceCount { get; set; } = 0; // 跳出次数
|
| 28 |
+
|
| 29 |
+
public DateTime CreatedAt { get; set; } = DateTime.Now;
|
| 30 |
+
|
| 31 |
+
public DateTime? UpdatedAt { get; set; }
|
| 32 |
+
|
| 33 |
+
// 导航属性
|
| 34 |
+
[Navigate(nameof(ToolId))]
|
| 35 |
+
public Tool Tool { get; set; } = null!;
|
| 36 |
+
}
|
Models/ToolTag.cs
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
using FreeSql.DataAnnotations;
|
| 2 |
+
|
| 3 |
+
namespace ToolHub.Models;
|
| 4 |
+
|
| 5 |
+
[Table(Name = "ToolTags")]
|
| 6 |
+
public class ToolTag
|
| 7 |
+
{
|
| 8 |
+
public int ToolId { get; set; }
|
| 9 |
+
|
| 10 |
+
public int TagId { get; set; }
|
| 11 |
+
|
| 12 |
+
public DateTime CreatedAt { get; set; } = DateTime.Now;
|
| 13 |
+
|
| 14 |
+
// 导航属性
|
| 15 |
+
[Navigate(nameof(ToolId))]
|
| 16 |
+
public Tool Tool { get; set; } = null!;
|
| 17 |
+
|
| 18 |
+
[Navigate(nameof(TagId))]
|
| 19 |
+
public Tag Tag { get; set; } = null!;
|
| 20 |
+
}
|
Models/User.cs
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
using FreeSql.DataAnnotations;
|
| 2 |
+
|
| 3 |
+
namespace ToolHub.Models;
|
| 4 |
+
|
| 5 |
+
[Table(Name = "Users")]
|
| 6 |
+
public class User
|
| 7 |
+
{
|
| 8 |
+
[Column(IsIdentity = true, IsPrimary = true)]
|
| 9 |
+
public int Id { get; set; }
|
| 10 |
+
|
| 11 |
+
[Column(StringLength = 50)]
|
| 12 |
+
public string UserName { get; set; } = string.Empty;
|
| 13 |
+
|
| 14 |
+
[Column(StringLength = 100)]
|
| 15 |
+
public string Email { get; set; } = string.Empty;
|
| 16 |
+
|
| 17 |
+
[Column(StringLength = 255)]
|
| 18 |
+
public string PasswordHash { get; set; } = string.Empty;
|
| 19 |
+
|
| 20 |
+
[Column(StringLength = 20)]
|
| 21 |
+
public string? NickName { get; set; }
|
| 22 |
+
|
| 23 |
+
[Column(StringLength = 255)]
|
| 24 |
+
public string? Avatar { get; set; }
|
| 25 |
+
|
| 26 |
+
public DateTime CreatedAt { get; set; } = DateTime.Now;
|
| 27 |
+
|
| 28 |
+
public DateTime? UpdatedAt { get; set; }
|
| 29 |
+
|
| 30 |
+
public bool IsActive { get; set; } = true;
|
| 31 |
+
|
| 32 |
+
[Column(StringLength = 10)]
|
| 33 |
+
public string Role { get; set; } = "User"; // User, Admin
|
| 34 |
+
|
| 35 |
+
// 导航属性
|
| 36 |
+
[Navigate(nameof(UserFavorite.UserId))]
|
| 37 |
+
public List<UserFavorite> UserFavorites { get; set; } = new();
|
| 38 |
+
}
|
Models/UserFavorite.cs
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
using FreeSql.DataAnnotations;
|
| 2 |
+
|
| 3 |
+
namespace ToolHub.Models;
|
| 4 |
+
|
| 5 |
+
[Table(Name = "UserFavorites")]
|
| 6 |
+
public class UserFavorite
|
| 7 |
+
{
|
| 8 |
+
public int UserId { get; set; }
|
| 9 |
+
|
| 10 |
+
public int ToolId { get; set; }
|
| 11 |
+
|
| 12 |
+
public DateTime CreatedAt { get; set; } = DateTime.Now;
|
| 13 |
+
|
| 14 |
+
// 导航属性
|
| 15 |
+
[Navigate(nameof(UserId))]
|
| 16 |
+
public User User { get; set; } = null!;
|
| 17 |
+
|
| 18 |
+
[Navigate(nameof(ToolId))]
|
| 19 |
+
public Tool Tool { get; set; } = null!;
|
| 20 |
+
}
|
Models/UserToolAccess.cs
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
using FreeSql.DataAnnotations;
|
| 2 |
+
|
| 3 |
+
namespace ToolHub.Models;
|
| 4 |
+
|
| 5 |
+
[Table(Name = "UserToolAccesses")]
|
| 6 |
+
public class UserToolAccess
|
| 7 |
+
{
|
| 8 |
+
[Column(IsIdentity = true, IsPrimary = true)]
|
| 9 |
+
public int Id { get; set; }
|
| 10 |
+
|
| 11 |
+
public int? UserId { get; set; } // 可为空,支持匿名用户
|
| 12 |
+
|
| 13 |
+
public int ToolId { get; set; }
|
| 14 |
+
|
| 15 |
+
[Column(StringLength = 50)]
|
| 16 |
+
public string? SessionId { get; set; } // 会话ID,用于匿名用户跟踪
|
| 17 |
+
|
| 18 |
+
[Column(StringLength = 45)]
|
| 19 |
+
public string? IpAddress { get; set; } // IP地址
|
| 20 |
+
|
| 21 |
+
[Column(StringLength = 500)]
|
| 22 |
+
public string? UserAgent { get; set; } // 用户代理
|
| 23 |
+
|
| 24 |
+
[Column(StringLength = 100)]
|
| 25 |
+
public string? Referer { get; set; } // 来源页面
|
| 26 |
+
|
| 27 |
+
[Column(StringLength = 20)]
|
| 28 |
+
public string AccessType { get; set; } = "view"; // view, favorite, share, download
|
| 29 |
+
|
| 30 |
+
public int Duration { get; set; } = 0; // 停留时间(秒)
|
| 31 |
+
|
| 32 |
+
public DateTime CreatedAt { get; set; } = DateTime.Now;
|
| 33 |
+
|
| 34 |
+
// 导航属性
|
| 35 |
+
[Navigate(nameof(UserId))]
|
| 36 |
+
public User? User { get; set; }
|
| 37 |
+
|
| 38 |
+
[Navigate(nameof(ToolId))]
|
| 39 |
+
public Tool Tool { get; set; } = null!;
|
| 40 |
+
}
|
Program.cs
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
using ToolHub.Services;
|
| 2 |
+
using Microsoft.AspNetCore.Authentication.Cookies;
|
| 3 |
+
|
| 4 |
+
var builder = WebApplication.CreateBuilder(args);
|
| 5 |
+
|
| 6 |
+
// Add services to the container.
|
| 7 |
+
builder.Services.AddControllersWithViews();
|
| 8 |
+
|
| 9 |
+
// 添加HttpContext访问器
|
| 10 |
+
builder.Services.AddHttpContextAccessor();
|
| 11 |
+
|
| 12 |
+
// 配置FreeSql
|
| 13 |
+
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection")
|
| 14 |
+
?? "Data Source=toolhub.db";
|
| 15 |
+
|
| 16 |
+
builder.Services.AddSingleton<IFreeSql>(provider =>
|
| 17 |
+
{
|
| 18 |
+
var freeSql = new FreeSql.FreeSqlBuilder()
|
| 19 |
+
.UseConnectionString(FreeSql.DataType.Sqlite, connectionString)
|
| 20 |
+
.UseAutoSyncStructure(true) // 自动同步数据库结构
|
| 21 |
+
.Build();
|
| 22 |
+
|
| 23 |
+
return freeSql;
|
| 24 |
+
});
|
| 25 |
+
|
| 26 |
+
// 注册服务
|
| 27 |
+
builder.Services.AddScoped<IToolService, ToolService>();
|
| 28 |
+
builder.Services.AddScoped<IUserService, UserService>();
|
| 29 |
+
|
| 30 |
+
// 配置身份验证
|
| 31 |
+
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
| 32 |
+
.AddCookie(options =>
|
| 33 |
+
{
|
| 34 |
+
options.LoginPath = "/Admin/Login";
|
| 35 |
+
options.LogoutPath = "/Admin/Logout";
|
| 36 |
+
options.ExpireTimeSpan = TimeSpan.FromDays(7);
|
| 37 |
+
});
|
| 38 |
+
|
| 39 |
+
var app = builder.Build();
|
| 40 |
+
|
| 41 |
+
// 初始化数据库和种子数据
|
| 42 |
+
using (var scope = app.Services.CreateScope())
|
| 43 |
+
{
|
| 44 |
+
var freeSql = scope.ServiceProvider.GetRequiredService<IFreeSql>();
|
| 45 |
+
|
| 46 |
+
// 确保数据库表已创建
|
| 47 |
+
freeSql.CodeFirst.SyncStructure<ToolHub.Models.User>();
|
| 48 |
+
freeSql.CodeFirst.SyncStructure<ToolHub.Models.Category>();
|
| 49 |
+
freeSql.CodeFirst.SyncStructure<ToolHub.Models.Tool>();
|
| 50 |
+
freeSql.CodeFirst.SyncStructure<ToolHub.Models.Tag>();
|
| 51 |
+
freeSql.CodeFirst.SyncStructure<ToolHub.Models.ToolTag>();
|
| 52 |
+
freeSql.CodeFirst.SyncStructure<ToolHub.Models.UserFavorite>();
|
| 53 |
+
freeSql.CodeFirst.SyncStructure<ToolHub.Models.UserToolAccess>();
|
| 54 |
+
freeSql.CodeFirst.SyncStructure<ToolHub.Models.ToolStatistics>();
|
| 55 |
+
|
| 56 |
+
// 添加初始数据
|
| 57 |
+
await SeedDataAsync(freeSql);
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
// Configure the HTTP request pipeline.
|
| 61 |
+
if (!app.Environment.IsDevelopment())
|
| 62 |
+
{
|
| 63 |
+
app.UseExceptionHandler("/Home/Error");
|
| 64 |
+
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
|
| 65 |
+
app.UseHsts();
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
app.UseHttpsRedirection();
|
| 69 |
+
app.UseRouting();
|
| 70 |
+
|
| 71 |
+
app.UseAuthentication();
|
| 72 |
+
app.UseAuthorization();
|
| 73 |
+
|
| 74 |
+
app.MapStaticAssets();
|
| 75 |
+
|
| 76 |
+
app.MapControllerRoute(
|
| 77 |
+
name: "default",
|
| 78 |
+
pattern: "{controller=Home}/{action=Index}/{id?}")
|
| 79 |
+
.WithStaticAssets();
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
app.Run();
|
| 83 |
+
|
| 84 |
+
// 数据初始化方法
|
| 85 |
+
static async Task SeedDataAsync(IFreeSql freeSql)
|
| 86 |
+
{
|
| 87 |
+
// 检查是否已有数据
|
| 88 |
+
var categoryCount = await freeSql.Select<ToolHub.Models.Category>().CountAsync();
|
| 89 |
+
if (categoryCount > 0) return;
|
| 90 |
+
|
| 91 |
+
// 添加分类
|
| 92 |
+
var categories = new List<ToolHub.Models.Category>
|
| 93 |
+
{
|
| 94 |
+
new() { Name = "PDF工具", Description = "PDF文档处理相关工具", Icon = "fas fa-file-pdf", Color = "#dc3545", SortOrder = 1 },
|
| 95 |
+
new() { Name = "图片工具", Description = "图片处理和编辑工具", Icon = "fas fa-image", Color = "#165DFF", SortOrder = 2 },
|
| 96 |
+
new() { Name = "视频工具", Description = "视频处理和编辑工具", Icon = "fas fa-video", Color = "#FF7D00", SortOrder = 3 },
|
| 97 |
+
new() { Name = "文档转换", Description = "各种文档格式转换工具", Icon = "fas fa-file-alt", Color = "#52C41A", SortOrder = 4 },
|
| 98 |
+
new() { Name = "数据计算", Description = "数据计算和统计工具", Icon = "fas fa-calculator", Color = "#36CFC9", SortOrder = 5 },
|
| 99 |
+
new() { Name = "开发工具", Description = "程序开发相关工具", Icon = "fas fa-code", Color = "#722ED1", SortOrder = 6 },
|
| 100 |
+
new() { Name = "文本工具", Description = "文本处理和编辑工具", Icon = "fas fa-font", Color = "#13C2C2", SortOrder = 7 },
|
| 101 |
+
new() { Name = "生活娱乐", Description = "日常生活和娱乐工具", Icon = "fas fa-gamepad", Color = "#FA8C16", SortOrder = 8 }
|
| 102 |
+
};
|
| 103 |
+
|
| 104 |
+
await freeSql.Insert(categories).ExecuteAffrowsAsync();
|
| 105 |
+
|
| 106 |
+
// 添加示例工具
|
| 107 |
+
var tools = new List<ToolHub.Models.Tool>
|
| 108 |
+
{
|
| 109 |
+
new() { Name = "PDF转Word", Description = "高效将PDF文件转换为可编辑的Word文档,保留原始格式和排版",
|
| 110 |
+
Icon = "fas fa-file-word", Image = "https://design.gemcoder.com/staticResource/echoAiSystemImages/33769d8fd6cd6c3290700a9a1849a860.png",
|
| 111 |
+
CategoryId = 1, IsHot = true, Rating = 4.9m, RatingCount = 1250, ViewCount = 125000, SortOrder = 1 },
|
| 112 |
+
|
| 113 |
+
new() { Name = "图片压缩", Description = "减小图片文件大小,保持高质量,支持批量处理多种图片格式",
|
| 114 |
+
Icon = "fas fa-compress", Image = "https://design.gemcoder.com/staticResource/echoAiSystemImages/34a8fb1fdc206c992dddf5cda0318963.png",
|
| 115 |
+
CategoryId = 2, IsHot = true, Rating = 4.8m, RatingCount = 980, ViewCount = 98000, SortOrder = 2 },
|
| 116 |
+
|
| 117 |
+
new() { Name = "证件照生成", Description = "快速制作符���各国签证和证件要求的标准证件照,支持多种尺寸",
|
| 118 |
+
Icon = "fas fa-user-circle", Image = "https://design.gemcoder.com/staticResource/echoAiSystemImages/47b898b061a999bcbb5ee1defbdb6800.png",
|
| 119 |
+
CategoryId = 2, IsRecommended = true, Rating = 4.7m, RatingCount = 750, ViewCount = 75000, SortOrder = 3 },
|
| 120 |
+
|
| 121 |
+
new() { Name = "在线录屏", Description = "无需安装软件,直接在浏览器中录制屏幕、摄像头和音频,支持多种格式导出",
|
| 122 |
+
Icon = "fas fa-video", Image = "https://design.gemcoder.com/staticResource/echoAiSystemImages/930732ebbce6d0418fefe467d9f74ebc.png",
|
| 123 |
+
CategoryId = 3, IsNew = true, Rating = 4.6m, RatingCount = 520, ViewCount = 52000, SortOrder = 4 },
|
| 124 |
+
|
| 125 |
+
new() { Name = "PDF转Excel", Description = "将PDF表格数据转换为可编辑的Excel文件",
|
| 126 |
+
Icon = "fas fa-file-excel", CategoryId = 4, IsNew = true, Rating = 4.5m, RatingCount = 320, ViewCount = 32000, SortOrder = 5 },
|
| 127 |
+
|
| 128 |
+
new() { Name = "房贷利率计算器", Description = "计算不同贷款方式下的月供和总利息",
|
| 129 |
+
Icon = "fas fa-calculator", CategoryId = 5, Rating = 4.6m, RatingCount = 890, ViewCount = 89000, SortOrder = 6 }
|
| 130 |
+
};
|
| 131 |
+
|
| 132 |
+
await freeSql.Insert(tools).ExecuteAffrowsAsync();
|
| 133 |
+
|
| 134 |
+
// 添加默认管理员用户
|
| 135 |
+
var adminPassword = "admin123"; // 实际应用中应该使用更安全的密码
|
| 136 |
+
using var sha256 = System.Security.Cryptography.SHA256.Create();
|
| 137 |
+
var hashedBytes = sha256.ComputeHash(System.Text.Encoding.UTF8.GetBytes(adminPassword + "ToolHub_Salt"));
|
| 138 |
+
var hashedPassword = Convert.ToBase64String(hashedBytes);
|
| 139 |
+
|
| 140 |
+
var admin = new ToolHub.Models.User
|
| 141 |
+
{
|
| 142 |
+
UserName = "admin",
|
| 143 |
+
Email = "admin@toolhub.com",
|
| 144 |
+
PasswordHash = hashedPassword,
|
| 145 |
+
NickName = "管理员",
|
| 146 |
+
Role = "Admin"
|
| 147 |
+
};
|
| 148 |
+
|
| 149 |
+
await freeSql.Insert(admin).ExecuteAffrowsAsync();
|
| 150 |
+
}
|
Properties/launchSettings.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"$schema": "https://json.schemastore.org/launchsettings.json",
|
| 3 |
+
"profiles": {
|
| 4 |
+
"http": {
|
| 5 |
+
"commandName": "Project",
|
| 6 |
+
"dotnetRunMessages": true,
|
| 7 |
+
"launchBrowser": true,
|
| 8 |
+
"applicationUrl": "http://localhost:5163",
|
| 9 |
+
"environmentVariables": {
|
| 10 |
+
"ASPNETCORE_ENVIRONMENT": "Development"
|
| 11 |
+
}
|
| 12 |
+
},
|
| 13 |
+
"https": {
|
| 14 |
+
"commandName": "Project",
|
| 15 |
+
"dotnetRunMessages": true,
|
| 16 |
+
"launchBrowser": true,
|
| 17 |
+
"applicationUrl": "https://localhost:7078;http://localhost:5163",
|
| 18 |
+
"environmentVariables": {
|
| 19 |
+
"ASPNETCORE_ENVIRONMENT": "Development"
|
| 20 |
+
}
|
| 21 |
+
}
|
| 22 |
+
}
|
| 23 |
+
}
|
README.md
CHANGED
|
@@ -1,10 +1,124 @@
|
|
| 1 |
-
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ToolHub - 工具集合平台
|
| 2 |
+
|
| 3 |
+
ToolHub 是一个现代化的工具集合平台,提供各种实用工具的集中管理和访问。
|
| 4 |
+
|
| 5 |
+
## 功能特性
|
| 6 |
+
|
| 7 |
+
### 核心功能
|
| 8 |
+
- 🛠️ **工具管理**: 完整的工具CRUD操作,支持分类、标签、状态管理
|
| 9 |
+
- 📂 **分类管理**: 工具分类系统,支持层级分类和排序
|
| 10 |
+
- 🏷️ **标签管理**: 灵活的标签系统,支持颜色自定义
|
| 11 |
+
- 👥 **用户管理**: 用户注册、登录、收藏功能
|
| 12 |
+
- 📊 **数据统计**: 访问量统计、用户行为分析
|
| 13 |
+
- 🎨 **现代化UI**: 响应式设计,支持移动端访问
|
| 14 |
+
|
| 15 |
+
### 管理后台功能
|
| 16 |
+
- 📈 **仪表板**: 数据概览和快速操作
|
| 17 |
+
- 🔧 **工具管理**:
|
| 18 |
+
- 支持分页显示(每页20条)
|
| 19 |
+
- 分类筛选和搜索
|
| 20 |
+
- 标签管理(为工具添加/移除标签)
|
| 21 |
+
- 状态切换(启用/禁用)
|
| 22 |
+
- 标志管理(热门/新品/推荐)
|
| 23 |
+
- 📂 **分类管理**:
|
| 24 |
+
- 支持分页显示
|
| 25 |
+
- 图标和颜色自定义
|
| 26 |
+
- 排序管理
|
| 27 |
+
- 🏷️ **标签管理**:
|
| 28 |
+
- 支持分页显示
|
| 29 |
+
- 颜色自定义
|
| 30 |
+
- 使用统计
|
| 31 |
+
- 👥 **用户管理**: 用户信息查看和管理
|
| 32 |
+
|
| 33 |
+
### 技术特性
|
| 34 |
+
- 🚀 **.NET 9.0**: 最新的.NET框架
|
| 35 |
+
- 🗄️ **SQLite + FreeSql**: 轻量级数据库和ORM
|
| 36 |
+
- 🎨 **现代化UI**: CSS Grid、Flexbox布局
|
| 37 |
+
- 📱 **响应式设计**: 支持桌面和移动设备
|
| 38 |
+
- 🔒 **身份认证**: 基于角色的访问控制
|
| 39 |
+
|
| 40 |
+
## 快速开始
|
| 41 |
+
|
| 42 |
+
### 环境要求
|
| 43 |
+
- .NET 9.0 SDK
|
| 44 |
+
- Visual Studio 2022 或 VS Code
|
| 45 |
+
|
| 46 |
+
### 安装步骤
|
| 47 |
+
1. 克隆项目
|
| 48 |
+
```bash
|
| 49 |
+
git clone [项目地址]
|
| 50 |
+
cd ToolHub
|
| 51 |
+
```
|
| 52 |
+
|
| 53 |
+
2. 运行项目
|
| 54 |
+
```bash
|
| 55 |
+
dotnet run
|
| 56 |
+
```
|
| 57 |
+
|
| 58 |
+
3. 访问应用
|
| 59 |
+
- 前台网站: http://localhost:5000
|
| 60 |
+
- 管理后台: http://localhost:5000/Admin
|
| 61 |
+
|
| 62 |
+
### 默认管理员账户
|
| 63 |
+
- 用户名: admin
|
| 64 |
+
- 密码: admin123
|
| 65 |
+
|
| 66 |
+
## 数据库结构
|
| 67 |
+
|
| 68 |
+
### 主要表结构
|
| 69 |
+
- **Tools**: 工具信息表
|
| 70 |
+
- **Categories**: 分类表
|
| 71 |
+
- **Tags**: 标签表
|
| 72 |
+
- **ToolTags**: 工具标签关联表
|
| 73 |
+
- **Users**: 用户表
|
| 74 |
+
- **UserFavorites**: 用户收藏表
|
| 75 |
+
|
| 76 |
+
## 新增功能 (最新版本)
|
| 77 |
+
|
| 78 |
+
### 分页功能
|
| 79 |
+
- ✅ 工具列表分页(每页20条)
|
| 80 |
+
- ✅ 分类列表分页(每页20条)
|
| 81 |
+
- ✅ 标签列表分页(每页20条)
|
| 82 |
+
- ✅ 智能分页控件,支持首页、末页、前后页跳转
|
| 83 |
+
|
| 84 |
+
### 标签管理系统
|
| 85 |
+
- ✅ 完整的标签CRUD操作
|
| 86 |
+
- ✅ 标签颜色自定义
|
| 87 |
+
- ✅ 工具标签关联管理
|
| 88 |
+
- ✅ 标签使用统计
|
| 89 |
+
- ✅ 标签状态管理(启用/禁用)
|
| 90 |
+
|
| 91 |
+
### 工具标签功能
|
| 92 |
+
- ✅ 为工具添加/移除标签
|
| 93 |
+
- ✅ 标签可视化显示
|
| 94 |
+
- ✅ 标签筛选和搜索
|
| 95 |
+
- ✅ 标签管理模态框
|
| 96 |
+
|
| 97 |
+
## 开发指南
|
| 98 |
+
|
| 99 |
+
### 项目结构
|
| 100 |
+
```
|
| 101 |
+
ToolHub/
|
| 102 |
+
├── Controllers/ # 控制器
|
| 103 |
+
├── Models/ # 数据模型
|
| 104 |
+
├── Services/ # 业务服务
|
| 105 |
+
├── Views/ # 视图文件
|
| 106 |
+
│ ├── Admin/ # 管理后台视图
|
| 107 |
+
│ ├── Home/ # 前台页面视图
|
| 108 |
+
│ └── Shared/ # 共享布局
|
| 109 |
+
└── wwwroot/ # 静态资源
|
| 110 |
+
```
|
| 111 |
+
|
| 112 |
+
### 添加新功能
|
| 113 |
+
1. 在 `Models` 文件夹中定义数据模型
|
| 114 |
+
2. 在 `Controllers` 文件夹中创建控制器
|
| 115 |
+
3. 在 `Services` 文件夹中实现业务逻辑
|
| 116 |
+
4. 在 `Views` 文件夹中创建视图页面
|
| 117 |
+
|
| 118 |
+
## 贡献指南
|
| 119 |
+
|
| 120 |
+
欢迎提交 Issue 和 Pull Request 来改进项目。
|
| 121 |
+
|
| 122 |
+
## 许可证
|
| 123 |
+
|
| 124 |
+
MIT License
|
README_HF_SPACES.md
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ToolHub - Hugging Face Spaces 部署指南
|
| 2 |
+
|
| 3 |
+
## 概述
|
| 4 |
+
|
| 5 |
+
这个项目已经配置为可以在 Hugging Face Spaces 上部署,使用 Docker 容器和 Supervisor 进行进程监控和自动重启。
|
| 6 |
+
|
| 7 |
+
## 部署文件说明
|
| 8 |
+
|
| 9 |
+
### 1. Dockerfile
|
| 10 |
+
- 基于 .NET 9.0 运行时镜像
|
| 11 |
+
- 多阶段构建,优化镜像大小
|
| 12 |
+
- 安装 Supervisor 用于进程监控
|
| 13 |
+
- 配置健康检查
|
| 14 |
+
- 暴露 7860 端口
|
| 15 |
+
|
| 16 |
+
### 2. supervisord.conf
|
| 17 |
+
- 配置 Supervisor 监控 ASP.NET Core 应用
|
| 18 |
+
- 自动重启功能
|
| 19 |
+
- 日志轮转配置
|
| 20 |
+
- 健康检查程序
|
| 21 |
+
|
| 22 |
+
### 3. start.sh
|
| 23 |
+
- 应用启动脚本
|
| 24 |
+
- 数据库初始化检查
|
| 25 |
+
- 环境变量配置
|
| 26 |
+
|
| 27 |
+
### 4. appsettings.Production.json
|
| 28 |
+
- 生产环境配置
|
| 29 |
+
- 数据库连接字符串
|
| 30 |
+
- Kestrel 服务器配置
|
| 31 |
+
|
| 32 |
+
## 在 Hugging Face Spaces 上部署
|
| 33 |
+
|
| 34 |
+
### 步骤 1: 创建 Space
|
| 35 |
+
1. 访问 [Hugging Face Spaces](https://huggingface.co/spaces)
|
| 36 |
+
2. 点击 "Create new Space"
|
| 37 |
+
3. 选择 "Docker" 作为 SDK
|
| 38 |
+
4. 填写 Space 名称和描述
|
| 39 |
+
|
| 40 |
+
### 步骤 2: 上传代码
|
| 41 |
+
将以下文件上传到你的 Space 仓库:
|
| 42 |
+
- `Dockerfile`
|
| 43 |
+
- `supervisord.conf`
|
| 44 |
+
- `start.sh`
|
| 45 |
+
- `appsettings.Production.json`
|
| 46 |
+
- `.dockerignore`
|
| 47 |
+
- 所有项目源代码
|
| 48 |
+
|
| 49 |
+
### 步骤 3: 配置环境变量(可选)
|
| 50 |
+
在 Space 设置中可以配置以下环境变量:
|
| 51 |
+
- `ASPNETCORE_ENVIRONMENT`: 设置为 "Production"
|
| 52 |
+
- `ConnectionStrings__DefaultConnection`: 自定义数据库连接字符串
|
| 53 |
+
|
| 54 |
+
### 步骤 4: 部署
|
| 55 |
+
1. 提交代码到仓库
|
| 56 |
+
2. Hugging Face 会自动构建 Docker 镜像
|
| 57 |
+
3. 构建完成后应用会自动启动
|
| 58 |
+
|
| 59 |
+
## 功能特性
|
| 60 |
+
|
| 61 |
+
### Supervisor 监控
|
| 62 |
+
- **自动重启**: 应用崩溃时自动重启
|
| 63 |
+
- **日志管理**: 自动轮转日志文件
|
| 64 |
+
- **健康检查**: 定期检查应用状态
|
| 65 |
+
- **进程管理**: 统一管理所有后台进程
|
| 66 |
+
|
| 67 |
+
### 数据库持久化
|
| 68 |
+
- SQLite 数据库存储在 `/app/data/` 目录
|
| 69 |
+
- 容器重启后数据不会丢失
|
| 70 |
+
- 自动创建数据库和表结构
|
| 71 |
+
|
| 72 |
+
### 健康检查
|
| 73 |
+
- 每 30 秒检查一次应用状态
|
| 74 |
+
- 通过 HTTP 请求验证应用是否正常运行
|
| 75 |
+
- 失败时自动重启容器
|
| 76 |
+
|
| 77 |
+
## 监控和日志
|
| 78 |
+
|
| 79 |
+
### 查看日志
|
| 80 |
+
```bash
|
| 81 |
+
# 查看应用日志
|
| 82 |
+
docker logs <container_id>
|
| 83 |
+
|
| 84 |
+
# 查看 Supervisor 日志
|
| 85 |
+
docker exec <container_id> cat /var/log/supervisor/supervisord.log
|
| 86 |
+
|
| 87 |
+
# 查看应用输出日志
|
| 88 |
+
docker exec <container_id> cat /var/log/supervisor/toolhub.log
|
| 89 |
+
```
|
| 90 |
+
|
| 91 |
+
### 监控状态
|
| 92 |
+
```bash
|
| 93 |
+
# 查看 Supervisor 状态
|
| 94 |
+
docker exec <container_id> supervisorctl status
|
| 95 |
+
|
| 96 |
+
# 重启应用
|
| 97 |
+
docker exec <container_id> supervisorctl restart toolhub
|
| 98 |
+
```
|
| 99 |
+
|
| 100 |
+
## 故障排除
|
| 101 |
+
|
| 102 |
+
### 常见问题
|
| 103 |
+
|
| 104 |
+
1. **应用无法启动**
|
| 105 |
+
- 检查端口 7860 是否被占用
|
| 106 |
+
- 查看应用日志:`docker exec <container_id> cat /var/log/supervisor/toolhub_error.log`
|
| 107 |
+
|
| 108 |
+
2. **数据库连接问题**
|
| 109 |
+
- 确保 `/app/data` 目录有写权限
|
| 110 |
+
- 检查数据库文件是否存在
|
| 111 |
+
|
| 112 |
+
3. **Supervisor 问题**
|
| 113 |
+
- 查看 Supervisor 日志:`docker exec <container_id> cat /var/log/supervisor/supervisord.log`
|
| 114 |
+
- 重启 Supervisor:`docker exec <container_id> supervisorctl reload`
|
| 115 |
+
|
| 116 |
+
### 性能优化
|
| 117 |
+
- 应用使用 .NET 9.0 运行时,性能优异
|
| 118 |
+
- SQLite 数据库适合中小型应用
|
| 119 |
+
- 容器化部署,资源隔离
|
| 120 |
+
|
| 121 |
+
## 安全注意事项
|
| 122 |
+
|
| 123 |
+
1. **默认管理员账户**
|
| 124 |
+
- 用户名:`admin`
|
| 125 |
+
- 密码:`admin123`
|
| 126 |
+
- **重要**: 部署后请立即修改默认密码
|
| 127 |
+
|
| 128 |
+
2. **数据库安全**
|
| 129 |
+
- SQLite 数据库文件存储在容器内
|
| 130 |
+
- 建议定期备份数据库文件
|
| 131 |
+
|
| 132 |
+
3. **网络安全**
|
| 133 |
+
- 应用运行在容器内,端口 7860
|
| 134 |
+
- Hugging Face Spaces 提供 HTTPS 访问
|
| 135 |
+
|
| 136 |
+
## 更新部署
|
| 137 |
+
|
| 138 |
+
1. 修改代码后提交到仓库
|
| 139 |
+
2. Hugging Face 会自动重新构建镜像
|
| 140 |
+
3. 新版本会自动部署并启动
|
| 141 |
+
|
| 142 |
+
## 联系支持
|
| 143 |
+
|
| 144 |
+
如果遇到部署问题,请检查:
|
| 145 |
+
1. Dockerfile 语法是否正确
|
| 146 |
+
2. 所有必需文件是否已上传
|
| 147 |
+
3. 应用日志中是否有错误信息
|
Services/BaseToolService.cs
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
using ToolHub.Models;
|
| 2 |
+
using Microsoft.AspNetCore.Http;
|
| 3 |
+
using System.Security.Claims;
|
| 4 |
+
|
| 5 |
+
namespace ToolHub.Services;
|
| 6 |
+
|
| 7 |
+
/// <summary>
|
| 8 |
+
/// 工具基类服务,提供统计数据和用户访问记录功能
|
| 9 |
+
/// </summary>
|
| 10 |
+
public abstract class BaseToolService
|
| 11 |
+
{
|
| 12 |
+
protected readonly IFreeSql _freeSql;
|
| 13 |
+
protected readonly IHttpContextAccessor _httpContextAccessor;
|
| 14 |
+
|
| 15 |
+
protected BaseToolService(IFreeSql freeSql, IHttpContextAccessor httpContextAccessor)
|
| 16 |
+
{
|
| 17 |
+
_freeSql = freeSql;
|
| 18 |
+
_httpContextAccessor = httpContextAccessor;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
/// <summary>
|
| 22 |
+
/// 记录工具访问
|
| 23 |
+
/// </summary>
|
| 24 |
+
protected async Task RecordToolAccessAsync(int toolId, string accessType = "view", int duration = 0)
|
| 25 |
+
{
|
| 26 |
+
try
|
| 27 |
+
{
|
| 28 |
+
var httpContext = _httpContextAccessor.HttpContext;
|
| 29 |
+
if (httpContext == null) return;
|
| 30 |
+
|
| 31 |
+
var userId = GetCurrentUserId();
|
| 32 |
+
var sessionId = GetSessionId();
|
| 33 |
+
var ipAddress = GetClientIpAddress();
|
| 34 |
+
var userAgent = GetUserAgent();
|
| 35 |
+
var referer = GetReferer();
|
| 36 |
+
|
| 37 |
+
var access = new UserToolAccess
|
| 38 |
+
{
|
| 39 |
+
UserId = userId,
|
| 40 |
+
ToolId = toolId,
|
| 41 |
+
SessionId = sessionId,
|
| 42 |
+
IpAddress = ipAddress,
|
| 43 |
+
UserAgent = userAgent,
|
| 44 |
+
Referer = referer,
|
| 45 |
+
AccessType = accessType,
|
| 46 |
+
Duration = duration
|
| 47 |
+
};
|
| 48 |
+
|
| 49 |
+
await _freeSql.Insert(access).ExecuteAffrowsAsync();
|
| 50 |
+
|
| 51 |
+
// 更新工具总访问量
|
| 52 |
+
await _freeSql.Update<Tool>()
|
| 53 |
+
.Where(t => t.Id == toolId)
|
| 54 |
+
.Set(t => t.ViewCount + 1)
|
| 55 |
+
.ExecuteAffrowsAsync();
|
| 56 |
+
|
| 57 |
+
// 更新日统计数据
|
| 58 |
+
await UpdateDailyStatisticsAsync(toolId, accessType);
|
| 59 |
+
}
|
| 60 |
+
catch (Exception ex)
|
| 61 |
+
{
|
| 62 |
+
// 记录错误但不影响主流程
|
| 63 |
+
Console.WriteLine($"记录工具访问失败: {ex.Message}");
|
| 64 |
+
}
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
/// <summary>
|
| 68 |
+
/// 更新日统计数据
|
| 69 |
+
/// </summary>
|
| 70 |
+
protected async Task UpdateDailyStatisticsAsync(int toolId, string accessType)
|
| 71 |
+
{
|
| 72 |
+
var today = DateTime.Today;
|
| 73 |
+
var stats = await _freeSql.Select<ToolStatistics>()
|
| 74 |
+
.Where(s => s.ToolId == toolId && s.Date == today)
|
| 75 |
+
.FirstAsync();
|
| 76 |
+
|
| 77 |
+
if (stats == null)
|
| 78 |
+
{
|
| 79 |
+
// 创建新的日统计记录
|
| 80 |
+
stats = new ToolStatistics
|
| 81 |
+
{
|
| 82 |
+
ToolId = toolId,
|
| 83 |
+
Date = today
|
| 84 |
+
};
|
| 85 |
+
await _freeSql.Insert(stats).ExecuteAffrowsAsync();
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
// 更新统计数据
|
| 89 |
+
var updateQuery = _freeSql.Update<ToolStatistics>()
|
| 90 |
+
.Where(s => s.Id == stats.Id);
|
| 91 |
+
|
| 92 |
+
switch (accessType.ToLower())
|
| 93 |
+
{
|
| 94 |
+
case "view":
|
| 95 |
+
updateQuery.Set(s => s.DailyViews + 1);
|
| 96 |
+
break;
|
| 97 |
+
case "favorite":
|
| 98 |
+
updateQuery.Set(s => s.DailyFavorites + 1);
|
| 99 |
+
break;
|
| 100 |
+
case "share":
|
| 101 |
+
updateQuery.Set(s => s.DailyShares + 1);
|
| 102 |
+
break;
|
| 103 |
+
case "download":
|
| 104 |
+
updateQuery.Set(s => s.DailyDownloads + 1);
|
| 105 |
+
break;
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
updateQuery.Set(s => s.UpdatedAt, DateTime.Now);
|
| 109 |
+
await updateQuery.ExecuteAffrowsAsync();
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
/// <summary>
|
| 113 |
+
/// 获取工具统计数据
|
| 114 |
+
/// </summary>
|
| 115 |
+
protected async Task<ToolStatistics?> GetToolStatisticsAsync(int toolId, DateTime date)
|
| 116 |
+
{
|
| 117 |
+
return await _freeSql.Select<ToolStatistics>()
|
| 118 |
+
.Where(s => s.ToolId == toolId && s.Date == date)
|
| 119 |
+
.FirstAsync();
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
/// <summary>
|
| 123 |
+
/// 获取工具访问记录
|
| 124 |
+
/// </summary>
|
| 125 |
+
protected async Task<List<UserToolAccess>> GetToolAccessRecordsAsync(int toolId, DateTime? startDate = null, DateTime? endDate = null, int limit = 100)
|
| 126 |
+
{
|
| 127 |
+
var query = _freeSql.Select<UserToolAccess>()
|
| 128 |
+
.Include(ua => ua.User)
|
| 129 |
+
.Where(ua => ua.ToolId == toolId);
|
| 130 |
+
|
| 131 |
+
if (startDate.HasValue)
|
| 132 |
+
query = query.Where(ua => ua.CreatedAt >= startDate.Value);
|
| 133 |
+
|
| 134 |
+
if (endDate.HasValue)
|
| 135 |
+
query = query.Where(ua => ua.CreatedAt <= endDate.Value.AddDays(1));
|
| 136 |
+
|
| 137 |
+
return await query
|
| 138 |
+
.OrderByDescending(ua => ua.CreatedAt)
|
| 139 |
+
.Take(limit)
|
| 140 |
+
.ToListAsync();
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
/// <summary>
|
| 144 |
+
/// 获取用户访问的工具记录
|
| 145 |
+
/// </summary>
|
| 146 |
+
protected async Task<List<UserToolAccess>> GetUserToolAccessRecordsAsync(int userId, DateTime? startDate = null, DateTime? endDate = null, int limit = 100)
|
| 147 |
+
{
|
| 148 |
+
var query = _freeSql.Select<UserToolAccess>()
|
| 149 |
+
.Include(ua => ua.Tool)
|
| 150 |
+
.Where(ua => ua.UserId == userId);
|
| 151 |
+
|
| 152 |
+
if (startDate.HasValue)
|
| 153 |
+
query = query.Where(ua => ua.CreatedAt >= startDate.Value);
|
| 154 |
+
|
| 155 |
+
if (endDate.HasValue)
|
| 156 |
+
query = query.Where(ua => ua.CreatedAt <= endDate.Value);
|
| 157 |
+
|
| 158 |
+
return await query
|
| 159 |
+
.OrderByDescending(ua => ua.CreatedAt)
|
| 160 |
+
.Take(limit)
|
| 161 |
+
.ToListAsync();
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
/// <summary>
|
| 165 |
+
/// 获取热门工具(基于访问量)
|
| 166 |
+
/// </summary>
|
| 167 |
+
protected async Task<List<Tool>> GetPopularToolsAsync(int count = 10, DateTime? startDate = null)
|
| 168 |
+
{
|
| 169 |
+
var query = _freeSql.Select<Tool>()
|
| 170 |
+
.Include(t => t.Category)
|
| 171 |
+
.Where(t => t.IsActive);
|
| 172 |
+
|
| 173 |
+
if (startDate.HasValue)
|
| 174 |
+
{
|
| 175 |
+
// 基于指定日期后的访问量排序
|
| 176 |
+
var toolAccessData = _freeSql.Select<UserToolAccess>()
|
| 177 |
+
.Where(ua => ua.CreatedAt >= startDate.Value)
|
| 178 |
+
.GroupBy(ua => ua.ToolId)
|
| 179 |
+
.Select(g => new { ToolId = g.Key, Count = g.Count() })
|
| 180 |
+
.OrderByDescending(x => x.Count)
|
| 181 |
+
.Take(count)
|
| 182 |
+
.ToList();
|
| 183 |
+
|
| 184 |
+
var toolIds = toolAccessData.Select(x => x.ToolId).ToList();
|
| 185 |
+
|
| 186 |
+
if (toolIds.Any())
|
| 187 |
+
{
|
| 188 |
+
query = query.Where(t => toolIds.Contains(t.Id));
|
| 189 |
+
}
|
| 190 |
+
}
|
| 191 |
+
else
|
| 192 |
+
{
|
| 193 |
+
// 基于总访问量排序
|
| 194 |
+
query = query.OrderByDescending(t => t.ViewCount);
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
return await query.Take(count).ToListAsync();
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
/// <summary>
|
| 201 |
+
/// 获取当前用户ID
|
| 202 |
+
/// </summary>
|
| 203 |
+
protected int? GetCurrentUserId()
|
| 204 |
+
{
|
| 205 |
+
var httpContext = _httpContextAccessor.HttpContext;
|
| 206 |
+
if (httpContext?.User?.Identity?.IsAuthenticated == true)
|
| 207 |
+
{
|
| 208 |
+
var userIdClaim = httpContext.User.FindFirst(ClaimTypes.NameIdentifier);
|
| 209 |
+
if (userIdClaim != null && int.TryParse(userIdClaim.Value, out int userId))
|
| 210 |
+
{
|
| 211 |
+
return userId;
|
| 212 |
+
}
|
| 213 |
+
}
|
| 214 |
+
return null;
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
/// <summary>
|
| 218 |
+
/// 获取会话ID
|
| 219 |
+
/// </summary>
|
| 220 |
+
protected string? GetSessionId()
|
| 221 |
+
{
|
| 222 |
+
string sessionId = string.Empty;
|
| 223 |
+
try
|
| 224 |
+
{
|
| 225 |
+
var httpContext = _httpContextAccessor.HttpContext;
|
| 226 |
+
return httpContext?.Session?.Id;
|
| 227 |
+
}
|
| 228 |
+
catch (Exception ex)
|
| 229 |
+
{
|
| 230 |
+
Console.WriteLine($"获取会话ID失败: {ex.Message}");
|
| 231 |
+
return sessionId;
|
| 232 |
+
}
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
/// <summary>
|
| 236 |
+
/// 获取客户端IP地址
|
| 237 |
+
/// </summary>
|
| 238 |
+
protected string? GetClientIpAddress()
|
| 239 |
+
{
|
| 240 |
+
var httpContext = _httpContextAccessor.HttpContext;
|
| 241 |
+
if (httpContext == null) return null;
|
| 242 |
+
|
| 243 |
+
// 尝试从各种头部获取真实IP
|
| 244 |
+
var ip = httpContext.Request.Headers["X-Forwarded-For"].FirstOrDefault() ??
|
| 245 |
+
httpContext.Request.Headers["X-Real-IP"].FirstOrDefault() ??
|
| 246 |
+
httpContext.Connection.RemoteIpAddress?.ToString();
|
| 247 |
+
|
| 248 |
+
return ip;
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
/// <summary>
|
| 252 |
+
/// 获取用户代理
|
| 253 |
+
/// </summary>
|
| 254 |
+
protected string? GetUserAgent()
|
| 255 |
+
{
|
| 256 |
+
var httpContext = _httpContextAccessor.HttpContext;
|
| 257 |
+
return httpContext?.Request.Headers["User-Agent"].FirstOrDefault();
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
/// <summary>
|
| 261 |
+
/// 获取来源页面
|
| 262 |
+
/// </summary>
|
| 263 |
+
protected string? GetReferer()
|
| 264 |
+
{
|
| 265 |
+
var httpContext = _httpContextAccessor.HttpContext;
|
| 266 |
+
return httpContext?.Request.Headers["Referer"].FirstOrDefault();
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
/// <summary>
|
| 270 |
+
/// 计算平均停留时间
|
| 271 |
+
/// </summary>
|
| 272 |
+
protected async Task UpdateAverageDurationAsync(int toolId)
|
| 273 |
+
{
|
| 274 |
+
var today = DateTime.Today;
|
| 275 |
+
var avgDuration = await _freeSql.Select<UserToolAccess>()
|
| 276 |
+
.Where(ua => ua.ToolId == toolId && ua.CreatedAt >= today && ua.Duration > 0)
|
| 277 |
+
.AvgAsync(ua => ua.Duration);
|
| 278 |
+
|
| 279 |
+
if (avgDuration > 0)
|
| 280 |
+
{
|
| 281 |
+
await _freeSql.Update<ToolStatistics>()
|
| 282 |
+
.Where(s => s.ToolId == toolId && s.Date == today)
|
| 283 |
+
.Set(s => s.AverageDuration, (decimal)avgDuration)
|
| 284 |
+
.ExecuteAffrowsAsync();
|
| 285 |
+
}
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
/// <summary>
|
| 289 |
+
/// 获取工具使用趋势数据
|
| 290 |
+
/// </summary>
|
| 291 |
+
protected async Task<List<object>> GetToolUsageTrendAsync(int toolId, int days = 30)
|
| 292 |
+
{
|
| 293 |
+
var endDate = DateTime.Today;
|
| 294 |
+
var startDate = endDate.AddDays(-days + 1);
|
| 295 |
+
|
| 296 |
+
var stats = await _freeSql.Select<ToolStatistics>()
|
| 297 |
+
.Where(s => s.ToolId == toolId && s.Date >= startDate && s.Date <= endDate)
|
| 298 |
+
.OrderBy(s => s.Date)
|
| 299 |
+
.ToListAsync();
|
| 300 |
+
|
| 301 |
+
var result = new List<object>();
|
| 302 |
+
for (var date = startDate; date <= endDate; date = date.AddDays(1))
|
| 303 |
+
{
|
| 304 |
+
var dayStats = stats.FirstOrDefault(s => s.Date == date);
|
| 305 |
+
result.Add(new
|
| 306 |
+
{
|
| 307 |
+
Date = date.ToString("yyyy-MM-dd"),
|
| 308 |
+
Views = dayStats?.DailyViews ?? 0,
|
| 309 |
+
UniqueViews = dayStats?.DailyUniqueViews ?? 0,
|
| 310 |
+
Favorites = dayStats?.DailyFavorites ?? 0,
|
| 311 |
+
Shares = dayStats?.DailyShares ?? 0,
|
| 312 |
+
Downloads = dayStats?.DailyDownloads ?? 0,
|
| 313 |
+
AverageDuration = dayStats?.AverageDuration ?? 0
|
| 314 |
+
});
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
return result;
|
| 318 |
+
}
|
| 319 |
+
}
|
Services/IToolService.cs
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
using ToolHub.Models;
|
| 2 |
+
|
| 3 |
+
namespace ToolHub.Services;
|
| 4 |
+
|
| 5 |
+
public interface IToolService
|
| 6 |
+
{
|
| 7 |
+
Task<List<Category>> GetCategoriesAsync();
|
| 8 |
+
Task<List<Tool>> GetHotToolsAsync(int count = 8);
|
| 9 |
+
Task<List<Tool>> GetNewToolsAsync(int count = 12);
|
| 10 |
+
Task<List<Tool>> GetToolsByCategoryAsync(int categoryId, int page = 1, int pageSize = 20, bool isAll = false);
|
| 11 |
+
Task<Tool?> GetToolByIdAsync(int id);
|
| 12 |
+
Task<bool> AddToolAsync(Tool tool);
|
| 13 |
+
Task<bool> UpdateToolAsync(Tool tool);
|
| 14 |
+
Task<bool> DeleteToolAsync(int id);
|
| 15 |
+
Task<bool> IncrementViewCountAsync(int toolId);
|
| 16 |
+
Task<bool> IncrementViewCountAsync(string toolIdentifier);
|
| 17 |
+
Task<List<Tool>> SearchToolsAsync(string keyword, int page = 1, int pageSize = 20);
|
| 18 |
+
|
| 19 |
+
// 新增统计相关方法
|
| 20 |
+
Task<List<Tool>> GetPopularToolsAsync(int count = 10, DateTime? startDate = null);
|
| 21 |
+
Task<ToolStatistics?> GetToolStatisticsAsync(int toolId, DateTime date);
|
| 22 |
+
Task<List<UserToolAccess>> GetToolAccessRecordsAsync(int toolId, DateTime? startDate = null, DateTime? endDate = null, int limit = 100);
|
| 23 |
+
Task<List<UserToolAccess>> GetUserToolAccessRecordsAsync(int userId, DateTime? startDate = null, DateTime? endDate = null, int limit = 100);
|
| 24 |
+
Task<List<object>> GetToolUsageTrendAsync(int toolId, int days = 30);
|
| 25 |
+
Task RecordToolActionAsync(int toolId, string actionType, int duration = 0);
|
| 26 |
+
}
|
Services/IUserService.cs
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
using ToolHub.Models;
|
| 2 |
+
|
| 3 |
+
namespace ToolHub.Services;
|
| 4 |
+
|
| 5 |
+
public interface IUserService
|
| 6 |
+
{
|
| 7 |
+
Task<User?> GetUserByIdAsync(int id);
|
| 8 |
+
Task<User?> GetUserByEmailAsync(string email);
|
| 9 |
+
Task<bool> CreateUserAsync(User user);
|
| 10 |
+
Task<bool> UpdateUserAsync(User user);
|
| 11 |
+
Task<bool> VerifyPasswordAsync(string email, string password);
|
| 12 |
+
Task<List<Tool>> GetUserFavoritesAsync(int userId);
|
| 13 |
+
Task<bool> AddFavoriteAsync(int userId, int toolId);
|
| 14 |
+
Task<bool> RemoveFavoriteAsync(int userId, int toolId);
|
| 15 |
+
Task<bool> IsFavoriteAsync(int userId, int toolId);
|
| 16 |
+
}
|
Services/ToolService.cs
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
using ToolHub.Models;
|
| 2 |
+
using Microsoft.AspNetCore.Http;
|
| 3 |
+
|
| 4 |
+
namespace ToolHub.Services;
|
| 5 |
+
|
| 6 |
+
public class ToolService : BaseToolService, IToolService
|
| 7 |
+
{
|
| 8 |
+
public ToolService(IFreeSql freeSql, IHttpContextAccessor httpContextAccessor)
|
| 9 |
+
: base(freeSql, httpContextAccessor)
|
| 10 |
+
{
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
public async Task<List<Category>> GetCategoriesAsync()
|
| 14 |
+
{
|
| 15 |
+
return await _freeSql.Select<Category>()
|
| 16 |
+
.Where(c => c.IsActive)
|
| 17 |
+
.OrderBy(c => c.SortOrder)
|
| 18 |
+
.ToListAsync();
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
public async Task<List<Tool>> GetHotToolsAsync(int count = 8)
|
| 22 |
+
{
|
| 23 |
+
return await _freeSql.Select<Tool>()
|
| 24 |
+
.Include(t => t.Category)
|
| 25 |
+
.IncludeMany(t => t.ToolTags)
|
| 26 |
+
.Where(t => t.IsActive && t.IsHot)
|
| 27 |
+
.OrderByDescending(t => t.ViewCount)
|
| 28 |
+
.Take(count)
|
| 29 |
+
.ToListAsync();
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
public async Task<List<Tool>> GetNewToolsAsync(int count = 12)
|
| 33 |
+
{
|
| 34 |
+
return await _freeSql.Select<Tool>()
|
| 35 |
+
.Include(t => t.Category)
|
| 36 |
+
.IncludeMany(t => t.ToolTags)
|
| 37 |
+
.Where(t => t.IsActive)
|
| 38 |
+
.OrderByDescending(t => t.CreatedAt)
|
| 39 |
+
.Take(count)
|
| 40 |
+
.ToListAsync();
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
public async Task<List<Tool>> GetToolsByCategoryAsync(int categoryId, int page = 1, int pageSize = 20, bool isAll = false)
|
| 44 |
+
{
|
| 45 |
+
var query = _freeSql.Select<Tool>()
|
| 46 |
+
.Include(t => t.Category)
|
| 47 |
+
.IncludeMany(t => t.ToolTags);
|
| 48 |
+
if (!isAll) {
|
| 49 |
+
query.Where(t => t.IsActive);
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
if (categoryId > 0)
|
| 53 |
+
{
|
| 54 |
+
query = query.Where(t => t.CategoryId == categoryId);
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
return await query
|
| 58 |
+
.OrderBy(t => t.SortOrder)
|
| 59 |
+
.Page(page, pageSize)
|
| 60 |
+
.ToListAsync();
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
public async Task<Tool?> GetToolByIdAsync(int id)
|
| 64 |
+
{
|
| 65 |
+
return await _freeSql.Select<Tool>()
|
| 66 |
+
.Include(t => t.Category)
|
| 67 |
+
.IncludeMany(t => t.ToolTags)
|
| 68 |
+
.Where(t => t.Id == id && t.IsActive)
|
| 69 |
+
.FirstAsync();
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
public async Task<bool> AddToolAsync(Tool tool)
|
| 73 |
+
{
|
| 74 |
+
try
|
| 75 |
+
{
|
| 76 |
+
await _freeSql.Insert(tool).ExecuteAffrowsAsync();
|
| 77 |
+
return true;
|
| 78 |
+
}
|
| 79 |
+
catch
|
| 80 |
+
{
|
| 81 |
+
return false;
|
| 82 |
+
}
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
public async Task<bool> UpdateToolAsync(Tool tool)
|
| 86 |
+
{
|
| 87 |
+
try
|
| 88 |
+
{
|
| 89 |
+
tool.UpdatedAt = DateTime.Now;
|
| 90 |
+
await _freeSql.Update<Tool>()
|
| 91 |
+
.SetSource(tool)
|
| 92 |
+
.ExecuteAffrowsAsync();
|
| 93 |
+
return true;
|
| 94 |
+
}
|
| 95 |
+
catch
|
| 96 |
+
{
|
| 97 |
+
return false;
|
| 98 |
+
}
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
public async Task<bool> DeleteToolAsync(int id)
|
| 102 |
+
{
|
| 103 |
+
try
|
| 104 |
+
{
|
| 105 |
+
await _freeSql.Update<Tool>()
|
| 106 |
+
.Where(t => t.Id == id)
|
| 107 |
+
.Set(t => t.IsActive, false)
|
| 108 |
+
.Set(t => t.UpdatedAt, DateTime.Now)
|
| 109 |
+
.ExecuteAffrowsAsync();
|
| 110 |
+
return true;
|
| 111 |
+
}
|
| 112 |
+
catch
|
| 113 |
+
{
|
| 114 |
+
return false;
|
| 115 |
+
}
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
public async Task<bool> IncrementViewCountAsync(int toolId)
|
| 119 |
+
{
|
| 120 |
+
try
|
| 121 |
+
{
|
| 122 |
+
await RecordToolAccessAsync(toolId, "view");
|
| 123 |
+
return true;
|
| 124 |
+
}
|
| 125 |
+
catch
|
| 126 |
+
{
|
| 127 |
+
return false;
|
| 128 |
+
}
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
public async Task<bool> IncrementViewCountAsync(string toolIdentifier)
|
| 132 |
+
{
|
| 133 |
+
try
|
| 134 |
+
{
|
| 135 |
+
var tool = await _freeSql.Select<Tool>()
|
| 136 |
+
.Where(t => t.Url!.Contains(toolIdentifier) || t.Name.Contains(toolIdentifier))
|
| 137 |
+
.FirstAsync();
|
| 138 |
+
|
| 139 |
+
if (tool != null)
|
| 140 |
+
{
|
| 141 |
+
await RecordToolAccessAsync(tool.Id, "view");
|
| 142 |
+
}
|
| 143 |
+
return true;
|
| 144 |
+
}
|
| 145 |
+
catch
|
| 146 |
+
{
|
| 147 |
+
return false;
|
| 148 |
+
}
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
public async Task<List<Tool>> SearchToolsAsync(string keyword, int page = 1, int pageSize = 20)
|
| 152 |
+
{
|
| 153 |
+
return await _freeSql.Select<Tool>()
|
| 154 |
+
.Include(t => t.Category)
|
| 155 |
+
.IncludeMany(t => t.ToolTags)
|
| 156 |
+
.Where(t => t.IsActive && (t.Name.Contains(keyword) || t.Description!.Contains(keyword)))
|
| 157 |
+
.OrderByDescending(t => t.ViewCount)
|
| 158 |
+
.Page(page, pageSize)
|
| 159 |
+
.ToListAsync();
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
// 新增统计相关方法
|
| 163 |
+
public new async Task<List<Tool>> GetPopularToolsAsync(int count = 10, DateTime? startDate = null)
|
| 164 |
+
{
|
| 165 |
+
return await base.GetPopularToolsAsync(count, startDate);
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
public new async Task<ToolStatistics?> GetToolStatisticsAsync(int toolId, DateTime date)
|
| 169 |
+
{
|
| 170 |
+
return await base.GetToolStatisticsAsync(toolId, date);
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
public new async Task<List<UserToolAccess>> GetToolAccessRecordsAsync(int toolId, DateTime? startDate = null, DateTime? endDate = null, int limit = 100)
|
| 174 |
+
{
|
| 175 |
+
return await base.GetToolAccessRecordsAsync(toolId, startDate, endDate, limit);
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
public new async Task<List<UserToolAccess>> GetUserToolAccessRecordsAsync(int userId, DateTime? startDate = null, DateTime? endDate = null, int limit = 100)
|
| 179 |
+
{
|
| 180 |
+
return await base.GetUserToolAccessRecordsAsync(userId, startDate, endDate, limit);
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
public new async Task<List<object>> GetToolUsageTrendAsync(int toolId, int days = 30)
|
| 184 |
+
{
|
| 185 |
+
return await base.GetToolUsageTrendAsync(toolId, days);
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
public async Task RecordToolActionAsync(int toolId, string actionType, int duration = 0)
|
| 189 |
+
{
|
| 190 |
+
await RecordToolAccessAsync(toolId, actionType, duration);
|
| 191 |
+
}
|
| 192 |
+
}
|
Services/UserService.cs
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
using ToolHub.Models;
|
| 2 |
+
using System.Security.Cryptography;
|
| 3 |
+
using System.Text;
|
| 4 |
+
|
| 5 |
+
namespace ToolHub.Services;
|
| 6 |
+
|
| 7 |
+
public class UserService : IUserService
|
| 8 |
+
{
|
| 9 |
+
private readonly IFreeSql _freeSql;
|
| 10 |
+
|
| 11 |
+
public UserService(IFreeSql freeSql)
|
| 12 |
+
{
|
| 13 |
+
_freeSql = freeSql;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
public async Task<User?> GetUserByIdAsync(int id)
|
| 17 |
+
{
|
| 18 |
+
return await _freeSql.Select<User>()
|
| 19 |
+
.Where(u => u.Id == id && u.IsActive)
|
| 20 |
+
.FirstAsync();
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
public async Task<User?> GetUserByEmailAsync(string email)
|
| 24 |
+
{
|
| 25 |
+
return await _freeSql.Select<User>()
|
| 26 |
+
.Where(u => u.Email == email && u.IsActive)
|
| 27 |
+
.FirstAsync();
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
public async Task<bool> CreateUserAsync(User user)
|
| 31 |
+
{
|
| 32 |
+
try
|
| 33 |
+
{
|
| 34 |
+
user.PasswordHash = HashPassword(user.PasswordHash);
|
| 35 |
+
await _freeSql.Insert(user).ExecuteAffrowsAsync();
|
| 36 |
+
return true;
|
| 37 |
+
}
|
| 38 |
+
catch
|
| 39 |
+
{
|
| 40 |
+
return false;
|
| 41 |
+
}
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
public async Task<bool> UpdateUserAsync(User user)
|
| 45 |
+
{
|
| 46 |
+
try
|
| 47 |
+
{
|
| 48 |
+
user.UpdatedAt = DateTime.Now;
|
| 49 |
+
await _freeSql.Update<User>()
|
| 50 |
+
.SetSource(user)
|
| 51 |
+
.ExecuteAffrowsAsync();
|
| 52 |
+
return true;
|
| 53 |
+
}
|
| 54 |
+
catch
|
| 55 |
+
{
|
| 56 |
+
return false;
|
| 57 |
+
}
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
public async Task<bool> VerifyPasswordAsync(string email, string password)
|
| 61 |
+
{
|
| 62 |
+
var user = await GetUserByEmailAsync(email);
|
| 63 |
+
if (user == null) return false;
|
| 64 |
+
|
| 65 |
+
return VerifyPassword(password, user.PasswordHash);
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
public async Task<List<Tool>> GetUserFavoritesAsync(int userId)
|
| 69 |
+
{
|
| 70 |
+
var favorites = await _freeSql.Select<UserFavorite>()
|
| 71 |
+
.Include(uf => uf.Tool.Category)
|
| 72 |
+
.Where(uf => uf.UserId == userId)
|
| 73 |
+
.OrderByDescending(uf => uf.CreatedAt)
|
| 74 |
+
.ToListAsync();
|
| 75 |
+
|
| 76 |
+
return favorites.Select(uf => uf.Tool).ToList();
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
public async Task<bool> AddFavoriteAsync(int userId, int toolId)
|
| 80 |
+
{
|
| 81 |
+
try
|
| 82 |
+
{
|
| 83 |
+
var existing = await _freeSql.Select<UserFavorite>()
|
| 84 |
+
.Where(uf => uf.UserId == userId && uf.ToolId == toolId)
|
| 85 |
+
.FirstAsync();
|
| 86 |
+
|
| 87 |
+
if (existing != null) return true;
|
| 88 |
+
|
| 89 |
+
await _freeSql.Insert(new UserFavorite
|
| 90 |
+
{
|
| 91 |
+
UserId = userId,
|
| 92 |
+
ToolId = toolId
|
| 93 |
+
}).ExecuteAffrowsAsync();
|
| 94 |
+
|
| 95 |
+
// 更新工具收藏数
|
| 96 |
+
await _freeSql.Update<Tool>()
|
| 97 |
+
.Where(t => t.Id == toolId)
|
| 98 |
+
.Set(t => t.FavoriteCount + 1)
|
| 99 |
+
.ExecuteAffrowsAsync();
|
| 100 |
+
|
| 101 |
+
return true;
|
| 102 |
+
}
|
| 103 |
+
catch
|
| 104 |
+
{
|
| 105 |
+
return false;
|
| 106 |
+
}
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
public async Task<bool> RemoveFavoriteAsync(int userId, int toolId)
|
| 110 |
+
{
|
| 111 |
+
try
|
| 112 |
+
{
|
| 113 |
+
await _freeSql.Delete<UserFavorite>()
|
| 114 |
+
.Where(uf => uf.UserId == userId && uf.ToolId == toolId)
|
| 115 |
+
.ExecuteAffrowsAsync();
|
| 116 |
+
|
| 117 |
+
// 更新工具收藏数
|
| 118 |
+
await _freeSql.Update<Tool>()
|
| 119 |
+
.Where(t => t.Id == toolId)
|
| 120 |
+
.Set(t => t.FavoriteCount - 1)
|
| 121 |
+
.ExecuteAffrowsAsync();
|
| 122 |
+
|
| 123 |
+
return true;
|
| 124 |
+
}
|
| 125 |
+
catch
|
| 126 |
+
{
|
| 127 |
+
return false;
|
| 128 |
+
}
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
public async Task<bool> IsFavoriteAsync(int userId, int toolId)
|
| 132 |
+
{
|
| 133 |
+
var favorite = await _freeSql.Select<UserFavorite>()
|
| 134 |
+
.Where(uf => uf.UserId == userId && uf.ToolId == toolId)
|
| 135 |
+
.FirstAsync();
|
| 136 |
+
|
| 137 |
+
return favorite != null;
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
private string HashPassword(string password)
|
| 141 |
+
{
|
| 142 |
+
using var sha256 = SHA256.Create();
|
| 143 |
+
var hashedBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(password + "ToolHub_Salt"));
|
| 144 |
+
return Convert.ToBase64String(hashedBytes);
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
private bool VerifyPassword(string password, string hash)
|
| 148 |
+
{
|
| 149 |
+
var computedHash = HashPassword(password);
|
| 150 |
+
return computedHash == hash;
|
| 151 |
+
}
|
| 152 |
+
}
|
ToolHub.csproj
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<Project Sdk="Microsoft.NET.Sdk.Web">
|
| 2 |
+
|
| 3 |
+
<PropertyGroup>
|
| 4 |
+
<TargetFramework>net9.0</TargetFramework>
|
| 5 |
+
<Nullable>enable</Nullable>
|
| 6 |
+
<ImplicitUsings>enable</ImplicitUsings>
|
| 7 |
+
</PropertyGroup>
|
| 8 |
+
|
| 9 |
+
<ItemGroup>
|
| 10 |
+
<PackageReference Include="FreeSql" Version="3.5.207" />
|
| 11 |
+
<PackageReference Include="FreeSql.Provider.Sqlite" Version="3.5.207" />
|
| 12 |
+
<PackageReference Include="Microsoft.AspNetCore.Authentication.Cookies" Version="2.2.0" />
|
| 13 |
+
</ItemGroup>
|
| 14 |
+
|
| 15 |
+
</Project>
|
ToolHub.csproj.user
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?xml version="1.0" encoding="utf-8"?>
|
| 2 |
+
<Project ToolsVersion="Current" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
| 3 |
+
<PropertyGroup>
|
| 4 |
+
<ActiveDebugProfile>https</ActiveDebugProfile>
|
| 5 |
+
</PropertyGroup>
|
| 6 |
+
</Project>
|
Views/Admin/Categories.cshtml
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@model List<ToolHub.Models.Category>
|
| 2 |
+
@{
|
| 3 |
+
ViewData["Title"] = "工具分类管理";
|
| 4 |
+
Layout = "_AdminLayout";
|
| 5 |
+
var currentPage = ViewBag.CurrentPage as int? ?? 1;
|
| 6 |
+
var totalPages = ViewBag.TotalPages as int? ?? 1;
|
| 7 |
+
var totalCount = ViewBag.TotalCount as int? ?? 0;
|
| 8 |
+
var pageSize = ViewBag.PageSize as int? ?? 20;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
<!-- 页面头部 -->
|
| 12 |
+
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;">
|
| 13 |
+
<div>
|
| 14 |
+
<h2 style="font-size: 1.5rem; font-weight: 700; margin: 0; color: var(--dark);">工具分类管理</h2>
|
| 15 |
+
<p style="color: var(--dark-2); margin: 0.5rem 0 0;">管理工具分类,组织和归类各种工具</p>
|
| 16 |
+
</div>
|
| 17 |
+
<button class="btn btn-primary" onclick="openCategoryModal()">
|
| 18 |
+
<i class="fas fa-plus"></i>
|
| 19 |
+
添加分类
|
| 20 |
+
</button>
|
| 21 |
+
</div>
|
| 22 |
+
|
| 23 |
+
<!-- 分类列表 -->
|
| 24 |
+
<div class="data-table">
|
| 25 |
+
<div style="padding: 1rem 1.5rem; border-bottom: 1px solid var(--light-2); display: flex; justify-content: space-between; align-items: center;">
|
| 26 |
+
<span style="font-weight: 600;">分类列表 (@totalCount)</span>
|
| 27 |
+
</div>
|
| 28 |
+
|
| 29 |
+
<table>
|
| 30 |
+
<thead>
|
| 31 |
+
<tr>
|
| 32 |
+
<th>分类名称</th>
|
| 33 |
+
<th>描述</th>
|
| 34 |
+
<th>图标</th>
|
| 35 |
+
<th>颜色</th>
|
| 36 |
+
<th>排序</th>
|
| 37 |
+
<th>工具数量</th>
|
| 38 |
+
<th>状态</th>
|
| 39 |
+
<th>操作</th>
|
| 40 |
+
</tr>
|
| 41 |
+
</thead>
|
| 42 |
+
<tbody>
|
| 43 |
+
@foreach (var category in Model)
|
| 44 |
+
{
|
| 45 |
+
<tr>
|
| 46 |
+
<td>
|
| 47 |
+
<div style="display: flex; align-items: center;">
|
| 48 |
+
@if (!string.IsNullOrEmpty(category.Icon))
|
| 49 |
+
{
|
| 50 |
+
<i class="@category.Icon" style="margin-right: 0.75rem; color: @(category.Color ?? "var(--primary)"); font-size: 1.25rem;"></i>
|
| 51 |
+
}
|
| 52 |
+
<strong>@category.Name</strong>
|
| 53 |
+
</div>
|
| 54 |
+
</td>
|
| 55 |
+
<td style="max-width: 200px;">
|
| 56 |
+
<span style="color: var(--dark-2);">@(category.Description ?? "暂无描述")</span>
|
| 57 |
+
</td>
|
| 58 |
+
<td>
|
| 59 |
+
<code style="background: var(--light-1); padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.75rem;">
|
| 60 |
+
@(category.Icon ?? "无")
|
| 61 |
+
</code>
|
| 62 |
+
</td>
|
| 63 |
+
<td>
|
| 64 |
+
@if (!string.IsNullOrEmpty(category.Color))
|
| 65 |
+
{
|
| 66 |
+
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
| 67 |
+
<div style="width: 20px; height: 20px; background: @category.Color; border-radius: 4px; border: 1px solid var(--light-2);"></div>
|
| 68 |
+
<code style="font-size: 0.75rem;">@category.Color</code>
|
| 69 |
+
</div>
|
| 70 |
+
}
|
| 71 |
+
else
|
| 72 |
+
{
|
| 73 |
+
<span style="color: var(--dark-2);">默认</span>
|
| 74 |
+
}
|
| 75 |
+
</td>
|
| 76 |
+
<td>
|
| 77 |
+
<span class="badge badge-secondary">@category.SortOrder</span>
|
| 78 |
+
</td>
|
| 79 |
+
<td>
|
| 80 |
+
<span class="badge badge-primary">@category.Tools?.Count</span>
|
| 81 |
+
</td>
|
| 82 |
+
<td>
|
| 83 |
+
@if (category.IsActive)
|
| 84 |
+
{
|
| 85 |
+
<span class="badge badge-success">启用</span>
|
| 86 |
+
}
|
| 87 |
+
else
|
| 88 |
+
{
|
| 89 |
+
<span class="badge badge-secondary">禁用</span>
|
| 90 |
+
}
|
| 91 |
+
</td>
|
| 92 |
+
<td>
|
| 93 |
+
<div style="display: flex; gap: 0.5rem;">
|
| 94 |
+
<button class="btn btn-outline btn-sm" onclick="editCategory(@category.Id, '@category.Name', '@category.Description', '@category.Icon', '@category.Color', @category.SortOrder)">
|
| 95 |
+
<i class="fas fa-edit"></i>
|
| 96 |
+
</button>
|
| 97 |
+
<button class="btn btn-outline btn-sm" onclick="toggleCategoryStatus(@category.Id, @category.IsActive.ToString().ToLower())" style="color: @(category.IsActive ? "var(--warning)" : "var(--success)");">
|
| 98 |
+
<i class="fas fa-@(category.IsActive ? "pause" : "play")"></i>
|
| 99 |
+
</button>
|
| 100 |
+
<button class="btn btn-outline btn-sm" onclick="deleteCategory(@category.Id, '@category.Name')" style="color: var(--danger);">
|
| 101 |
+
<i class="fas fa-trash"></i>
|
| 102 |
+
</button>
|
| 103 |
+
</div>
|
| 104 |
+
</td>
|
| 105 |
+
</tr>
|
| 106 |
+
}
|
| 107 |
+
</tbody>
|
| 108 |
+
</table>
|
| 109 |
+
|
| 110 |
+
@if (!Model.Any())
|
| 111 |
+
{
|
| 112 |
+
<div style="padding: 3rem; text-align: center; color: var(--dark-2);">
|
| 113 |
+
<i class="fas fa-tags" style="font-size: 3rem; margin-bottom: 1rem; display: block; opacity: 0.3;"></i>
|
| 114 |
+
<h3 style="margin-bottom: 0.5rem;">暂无分类</h3>
|
| 115 |
+
<p style="margin-bottom: 1.5rem;">还没有创建任何工具分类</p>
|
| 116 |
+
<button class="btn btn-primary" onclick="openCategoryModal()">
|
| 117 |
+
<i class="fas fa-plus"></i>
|
| 118 |
+
创建第一个分类
|
| 119 |
+
</button>
|
| 120 |
+
</div>
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
<!-- 分页控件 -->
|
| 124 |
+
@if (totalPages > 1)
|
| 125 |
+
{
|
| 126 |
+
<div style="padding: 1.5rem; border-top: 1px solid var(--light-2); display: flex; justify-content: center; align-items: center; gap: 0.5rem;">
|
| 127 |
+
<button class="btn btn-outline btn-sm" onclick="changePage(1)" @(currentPage == 1 ? "disabled" : "")>
|
| 128 |
+
<i class="fas fa-angle-double-left"></i>
|
| 129 |
+
</button>
|
| 130 |
+
<button class="btn btn-outline btn-sm" onclick="changePage(@(currentPage - 1))" @(currentPage == 1 ? "disabled" : "")>
|
| 131 |
+
<i class="fas fa-angle-left"></i>
|
| 132 |
+
</button>
|
| 133 |
+
|
| 134 |
+
@{
|
| 135 |
+
var startPage = Math.Max(1, currentPage - 2);
|
| 136 |
+
var endPage = Math.Min(totalPages, currentPage + 2);
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
@for (int i = startPage; i <= endPage; i++)
|
| 140 |
+
{
|
| 141 |
+
<button class="btn @(i == currentPage ? "btn-primary" : "btn-outline") btn-sm" onclick="changePage(@i)">
|
| 142 |
+
@i
|
| 143 |
+
</button>
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
<button class="btn btn-outline btn-sm" onclick="changePage(@(currentPage + 1))" @(currentPage == totalPages ? "disabled" : "")>
|
| 147 |
+
<i class="fas fa-angle-right"></i>
|
| 148 |
+
</button>
|
| 149 |
+
<button class="btn btn-outline btn-sm" onclick="changePage(@totalPages)" @(currentPage == totalPages ? "disabled" : "")>
|
| 150 |
+
<i class="fas fa-angle-double-right"></i>
|
| 151 |
+
</button>
|
| 152 |
+
</div>
|
| 153 |
+
}
|
| 154 |
+
</div>
|
| 155 |
+
|
| 156 |
+
<!-- 分类模态框 -->
|
| 157 |
+
<div id="categoryModal" class="modal" style="display: none;">
|
| 158 |
+
<div class="modal-backdrop" onclick="closeCategoryModal()"></div>
|
| 159 |
+
<div class="modal-content" style="max-width: 500px;">
|
| 160 |
+
<div class="modal-header">
|
| 161 |
+
<h3 id="modalTitle">添加分类</h3>
|
| 162 |
+
<button onclick="closeCategoryModal()" style="background: none; border: none; font-size: 1.5rem; cursor: pointer;">×</button>
|
| 163 |
+
</div>
|
| 164 |
+
|
| 165 |
+
<form id="categoryForm" onsubmit="submitCategory(event)">
|
| 166 |
+
<input type="hidden" id="categoryId" name="id" value="0" />
|
| 167 |
+
|
| 168 |
+
<div class="form-group" style="margin-bottom: 1.5rem;">
|
| 169 |
+
<label style="display: block; font-weight: 600; margin-bottom: 0.5rem;">分类名称 *</label>
|
| 170 |
+
<input type="text" id="categoryName" name="name" required
|
| 171 |
+
style="width: 100%; padding: 0.75rem; border: 1px solid var(--light-2); border-radius: var(--border-radius);"
|
| 172 |
+
placeholder="请输入分类名称" />
|
| 173 |
+
</div>
|
| 174 |
+
|
| 175 |
+
<div class="form-group" style="margin-bottom: 1.5rem;">
|
| 176 |
+
<label style="display: block; font-weight: 600; margin-bottom: 0.5rem;">描述</label>
|
| 177 |
+
<textarea id="categoryDescription" name="description" rows="3"
|
| 178 |
+
style="width: 100%; padding: 0.75rem; border: 1px solid var(--light-2); border-radius: var(--border-radius); resize: vertical;"
|
| 179 |
+
placeholder="请输入分类描述"></textarea>
|
| 180 |
+
</div>
|
| 181 |
+
|
| 182 |
+
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1.5rem;">
|
| 183 |
+
<div class="form-group">
|
| 184 |
+
<label style="display: block; font-weight: 600; margin-bottom: 0.5rem;">图标类名</label>
|
| 185 |
+
<input type="text" id="categoryIcon" name="icon"
|
| 186 |
+
style="width: 100%; padding: 0.75rem; border: 1px solid var(--light-2); border-radius: var(--border-radius);"
|
| 187 |
+
placeholder="如: fas fa-file-pdf" />
|
| 188 |
+
<small style="color: var(--dark-2); font-size: 0.75rem;">使用 Font Awesome 图标类名</small>
|
| 189 |
+
</div>
|
| 190 |
+
|
| 191 |
+
<div class="form-group">
|
| 192 |
+
<label style="display: block; font-weight: 600; margin-bottom: 0.5rem;">颜色</label>
|
| 193 |
+
<input type="color" id="categoryColor" name="color" value="#165DFF"
|
| 194 |
+
style="width: 100%; padding: 0.5rem; border: 1px solid var(--light-2); border-radius: var(--border-radius); height: 3rem;" />
|
| 195 |
+
</div>
|
| 196 |
+
</div>
|
| 197 |
+
|
| 198 |
+
<div class="form-group" style="margin-bottom: 1.5rem;">
|
| 199 |
+
<label style="display: block; font-weight: 600; margin-bottom: 0.5rem;">排序顺序</label>
|
| 200 |
+
<input type="number" id="categorySort" name="sortOrder" min="0" value="0"
|
| 201 |
+
style="width: 100%; padding: 0.75rem; border: 1px solid var(--light-2); border-radius: var(--border-radius);"
|
| 202 |
+
placeholder="数字越小排序越靠前" />
|
| 203 |
+
</div>
|
| 204 |
+
|
| 205 |
+
<div class="modal-footer">
|
| 206 |
+
<button type="button" class="btn btn-outline" onclick="closeCategoryModal()">取消</button>
|
| 207 |
+
<button type="submit" class="btn btn-primary">
|
| 208 |
+
<span id="submitText">保存</span>
|
| 209 |
+
</button>
|
| 210 |
+
</div>
|
| 211 |
+
</form>
|
| 212 |
+
</div>
|
| 213 |
+
</div>
|
| 214 |
+
|
| 215 |
+
<style>
|
| 216 |
+
.modal {
|
| 217 |
+
position: fixed;
|
| 218 |
+
top: 0;
|
| 219 |
+
left: 0;
|
| 220 |
+
right: 0;
|
| 221 |
+
bottom: 0;
|
| 222 |
+
z-index: 10000;
|
| 223 |
+
display: flex;
|
| 224 |
+
align-items: center;
|
| 225 |
+
justify-content: center;
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
.modal-backdrop {
|
| 229 |
+
position: absolute;
|
| 230 |
+
top: 0;
|
| 231 |
+
left: 0;
|
| 232 |
+
right: 0;
|
| 233 |
+
bottom: 0;
|
| 234 |
+
background: rgba(0, 0, 0, 0.5);
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
.modal-content {
|
| 238 |
+
background: white;
|
| 239 |
+
border-radius: var(--border-radius);
|
| 240 |
+
position: relative;
|
| 241 |
+
z-index: 2;
|
| 242 |
+
max-height: 90vh;
|
| 243 |
+
overflow-y: auto;
|
| 244 |
+
width: 90%;
|
| 245 |
+
max-width: 600px;
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
.modal-header {
|
| 249 |
+
padding: 1.5rem;
|
| 250 |
+
border-bottom: 1px solid var(--light-2);
|
| 251 |
+
display: flex;
|
| 252 |
+
justify-content: space-between;
|
| 253 |
+
align-items: center;
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
.modal-header h3 {
|
| 257 |
+
margin: 0;
|
| 258 |
+
font-size: 1.25rem;
|
| 259 |
+
font-weight: 600;
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
.modal-footer {
|
| 263 |
+
padding: 1.5rem;
|
| 264 |
+
border-top: 1px solid var(--light-2);
|
| 265 |
+
display: flex;
|
| 266 |
+
justify-content: flex-end;
|
| 267 |
+
gap: 1rem;
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
.form-group input:focus,
|
| 271 |
+
.form-group textarea:focus {
|
| 272 |
+
border-color: var(--primary);
|
| 273 |
+
outline: none;
|
| 274 |
+
box-shadow: 0 0 0 3px rgba(22, 93, 255, 0.1);
|
| 275 |
+
}
|
| 276 |
+
</style>
|
| 277 |
+
|
| 278 |
+
<script>
|
| 279 |
+
let isEditing = false;
|
| 280 |
+
|
| 281 |
+
function changePage(page) {
|
| 282 |
+
window.location.href = `@Url.Action("Index", "Category")?page=${page}`;
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
function openCategoryModal(editMode = false) {
|
| 286 |
+
document.getElementById('categoryModal').style.display = 'flex';
|
| 287 |
+
document.getElementById('modalTitle').textContent = editMode ? '编辑分类' : '添加分类';
|
| 288 |
+
document.getElementById('submitText').textContent = editMode ? '更新' : '保存';
|
| 289 |
+
isEditing = editMode;
|
| 290 |
+
|
| 291 |
+
if (!editMode) {
|
| 292 |
+
document.getElementById('categoryForm').reset();
|
| 293 |
+
document.getElementById('categoryId').value = '0';
|
| 294 |
+
document.getElementById('categoryColor').value = '#165DFF';
|
| 295 |
+
}
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
function closeCategoryModal() {
|
| 299 |
+
document.getElementById('categoryModal').style.display = 'none';
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
function editCategory(id, name, description, icon, color, sortOrder) {
|
| 303 |
+
openCategoryModal(true);
|
| 304 |
+
document.getElementById('categoryId').value = id;
|
| 305 |
+
document.getElementById('categoryName').value = name;
|
| 306 |
+
document.getElementById('categoryDescription').value = description || '';
|
| 307 |
+
document.getElementById('categoryIcon').value = icon || '';
|
| 308 |
+
document.getElementById('categoryColor').value = color || '#165DFF';
|
| 309 |
+
document.getElementById('categorySort').value = sortOrder;
|
| 310 |
+
}
|
| 311 |
+
|
| 312 |
+
async function submitCategory(event) {
|
| 313 |
+
event.preventDefault();
|
| 314 |
+
|
| 315 |
+
const formData = new FormData(event.target);
|
| 316 |
+
const data = Object.fromEntries(formData.entries());
|
| 317 |
+
|
| 318 |
+
try {
|
| 319 |
+
const response = await fetch('/Category/Save', {
|
| 320 |
+
method: 'POST',
|
| 321 |
+
headers: {
|
| 322 |
+
'Content-Type': 'application/json',
|
| 323 |
+
},
|
| 324 |
+
body: JSON.stringify(data)
|
| 325 |
+
});
|
| 326 |
+
|
| 327 |
+
if (response.ok) {
|
| 328 |
+
toolHub.showToast(isEditing ? '分类更新成功' : '分类添加成功', 'success');
|
| 329 |
+
closeCategoryModal();
|
| 330 |
+
setTimeout(() => location.reload(), 1000);
|
| 331 |
+
} else {
|
| 332 |
+
toolHub.showToast('操作失败,请重试', 'error');
|
| 333 |
+
}
|
| 334 |
+
} catch (error) {
|
| 335 |
+
console.error('提交失败:', error);
|
| 336 |
+
toolHub.showToast('操作失败,请重试', 'error');
|
| 337 |
+
}
|
| 338 |
+
}
|
| 339 |
+
|
| 340 |
+
async function toggleCategoryStatus(id, currentStatus) {
|
| 341 |
+
try {
|
| 342 |
+
const response = await fetch('/Category/ToggleStatus', {
|
| 343 |
+
method: 'POST',
|
| 344 |
+
headers: {
|
| 345 |
+
'Content-Type': 'application/json',
|
| 346 |
+
},
|
| 347 |
+
body: JSON.stringify({ id: id, isActive: !currentStatus })
|
| 348 |
+
});
|
| 349 |
+
|
| 350 |
+
if (response.ok) {
|
| 351 |
+
toolHub.showToast('状态更新成功', 'success');
|
| 352 |
+
setTimeout(() => location.reload(), 1000);
|
| 353 |
+
} else {
|
| 354 |
+
toolHub.showToast('操作失败,请重试', 'error');
|
| 355 |
+
}
|
| 356 |
+
} catch (error) {
|
| 357 |
+
console.error('操作失败:', error);
|
| 358 |
+
toolHub.showToast('操作失败,请重试', 'error');
|
| 359 |
+
}
|
| 360 |
+
}
|
| 361 |
+
|
| 362 |
+
async function deleteCategory(id, name) {
|
| 363 |
+
if (!confirm(`确定要删除分类"${name}"吗?\n注意:删除分类会影响该分类下的所有工具。`)) {
|
| 364 |
+
return;
|
| 365 |
+
}
|
| 366 |
+
|
| 367 |
+
try {
|
| 368 |
+
const response = await fetch('/Category/Delete', {
|
| 369 |
+
method: 'POST',
|
| 370 |
+
headers: {
|
| 371 |
+
'Content-Type': 'application/json',
|
| 372 |
+
},
|
| 373 |
+
body: JSON.stringify({ id: id })
|
| 374 |
+
});
|
| 375 |
+
|
| 376 |
+
if (response.ok) {
|
| 377 |
+
toolHub.showToast('分类删除成功', 'success');
|
| 378 |
+
setTimeout(() => location.reload(), 1000);
|
| 379 |
+
} else {
|
| 380 |
+
toolHub.showToast('删除失败,可能该分类下还有工具', 'error');
|
| 381 |
+
}
|
| 382 |
+
} catch (error) {
|
| 383 |
+
console.error('删除失败:', error);
|
| 384 |
+
toolHub.showToast('删除失败,请重试', 'error');
|
| 385 |
+
}
|
| 386 |
+
}
|
| 387 |
+
</script>
|
Views/Admin/Index.cshtml
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@{
|
| 2 |
+
ViewData["Title"] = "仪表板";
|
| 3 |
+
Layout = "_AdminLayout";
|
| 4 |
+
}
|
| 5 |
+
|
| 6 |
+
<!-- 统计卡片 -->
|
| 7 |
+
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 mb-6">
|
| 8 |
+
<div class="stats-card">
|
| 9 |
+
<div class="stats-number" style="color: var(--primary);">
|
| 10 |
+
@(ViewBag.TotalTools ?? 0)
|
| 11 |
+
</div>
|
| 12 |
+
<div class="stats-label">
|
| 13 |
+
<i class="fas fa-tools" style="margin-right: 0.5rem;"></i>
|
| 14 |
+
工具总数
|
| 15 |
+
</div>
|
| 16 |
+
</div>
|
| 17 |
+
|
| 18 |
+
<div class="stats-card">
|
| 19 |
+
<div class="stats-number" style="color: var(--secondary);">
|
| 20 |
+
@(ViewBag.TotalCategories ?? 0)
|
| 21 |
+
</div>
|
| 22 |
+
<div class="stats-label">
|
| 23 |
+
<i class="fas fa-tags" style="margin-right: 0.5rem;"></i>
|
| 24 |
+
分类总数
|
| 25 |
+
</div>
|
| 26 |
+
</div>
|
| 27 |
+
|
| 28 |
+
<div class="stats-card">
|
| 29 |
+
<div class="stats-number" style="color: var(--success);">
|
| 30 |
+
@(ViewBag.TotalUsers ?? 0)
|
| 31 |
+
</div>
|
| 32 |
+
<div class="stats-label">
|
| 33 |
+
<i class="fas fa-users" style="margin-right: 0.5rem;"></i>
|
| 34 |
+
用户总数
|
| 35 |
+
</div>
|
| 36 |
+
</div>
|
| 37 |
+
|
| 38 |
+
<div class="stats-card">
|
| 39 |
+
<div class="stats-number" style="color: var(--warning);">
|
| 40 |
+
@{
|
| 41 |
+
var totalViews = ViewBag.TotalViews as long? ?? 0;
|
| 42 |
+
var viewsDisplay = totalViews > 1000000 ? $"{totalViews / 1000000:F1}M" :
|
| 43 |
+
totalViews > 1000 ? $"{totalViews / 1000:F0}K" :
|
| 44 |
+
totalViews.ToString();
|
| 45 |
+
}
|
| 46 |
+
@viewsDisplay
|
| 47 |
+
</div>
|
| 48 |
+
<div class="stats-label">
|
| 49 |
+
<i class="fas fa-eye" style="margin-right: 0.5rem;"></i>
|
| 50 |
+
总访问量
|
| 51 |
+
</div>
|
| 52 |
+
</div>
|
| 53 |
+
</div>
|
| 54 |
+
|
| 55 |
+
<div class="grid grid-cols-1 lg:grid-cols-2">
|
| 56 |
+
<!-- 最新工具 -->
|
| 57 |
+
<div class="data-table" style="margin-bottom: 2rem;">
|
| 58 |
+
<div style="padding: 1.5rem; border-bottom: 1px solid var(--light-2);">
|
| 59 |
+
<h3 style="margin: 0; font-size: 1.1rem; font-weight: 600; color: var(--dark);">
|
| 60 |
+
<i class="fas fa-clock" style="margin-right: 0.5rem; color: var(--primary);"></i>
|
| 61 |
+
最新工具
|
| 62 |
+
</h3>
|
| 63 |
+
</div>
|
| 64 |
+
|
| 65 |
+
@if (ViewBag.RecentTools is List<ToolHub.Models.Tool> recentTools && recentTools.Any())
|
| 66 |
+
{
|
| 67 |
+
<table>
|
| 68 |
+
<thead>
|
| 69 |
+
<tr>
|
| 70 |
+
<th>工具名称</th>
|
| 71 |
+
<th>分类</th>
|
| 72 |
+
<th>状态</th>
|
| 73 |
+
<th>创建时间</th>
|
| 74 |
+
</tr>
|
| 75 |
+
</thead>
|
| 76 |
+
<tbody>
|
| 77 |
+
@foreach (var tool in recentTools)
|
| 78 |
+
{
|
| 79 |
+
<tr>
|
| 80 |
+
<td>
|
| 81 |
+
<div style="display: flex; align-items: center;">
|
| 82 |
+
@if (!string.IsNullOrEmpty(tool.Icon))
|
| 83 |
+
{
|
| 84 |
+
<i class="@tool.Icon" style="margin-right: 0.75rem; color: var(--primary);"></i>
|
| 85 |
+
}
|
| 86 |
+
<div>
|
| 87 |
+
<strong>@tool.Name</strong>
|
| 88 |
+
@if (!string.IsNullOrEmpty(tool.Description) && tool.Description.Length > 30)
|
| 89 |
+
{
|
| 90 |
+
<br><small style="color: var(--dark-2);">@(tool.Description.Substring(0, 30))...</small>
|
| 91 |
+
}
|
| 92 |
+
</div>
|
| 93 |
+
</div>
|
| 94 |
+
</td>
|
| 95 |
+
<td>
|
| 96 |
+
<span class="badge badge-primary">@tool.Category?.Name</span>
|
| 97 |
+
</td>
|
| 98 |
+
<td>
|
| 99 |
+
@if (tool.IsHot)
|
| 100 |
+
{
|
| 101 |
+
<span class="badge badge-danger">热门</span>
|
| 102 |
+
}
|
| 103 |
+
@if (tool.IsNew)
|
| 104 |
+
{
|
| 105 |
+
<span class="badge badge-warning">新品</span>
|
| 106 |
+
}
|
| 107 |
+
@if (tool.IsRecommended)
|
| 108 |
+
{
|
| 109 |
+
<span class="badge badge-success">推荐</span>
|
| 110 |
+
}
|
| 111 |
+
@if (!tool.IsHot && !tool.IsNew && !tool.IsRecommended)
|
| 112 |
+
{
|
| 113 |
+
<span class="badge badge-secondary">普通</span>
|
| 114 |
+
}
|
| 115 |
+
</td>
|
| 116 |
+
<td style="color: var(--dark-2); font-size: 0.875rem;">
|
| 117 |
+
@tool.CreatedAt.ToString("MM-dd HH:mm")
|
| 118 |
+
</td>
|
| 119 |
+
</tr>
|
| 120 |
+
}
|
| 121 |
+
</tbody>
|
| 122 |
+
</table>
|
| 123 |
+
}
|
| 124 |
+
else
|
| 125 |
+
{
|
| 126 |
+
<div style="padding: 2rem; text-align: center; color: var(--dark-2);">
|
| 127 |
+
<i class="fas fa-inbox" style="font-size: 2rem; margin-bottom: 1rem; display: block;"></i>
|
| 128 |
+
暂无工具数据
|
| 129 |
+
</div>
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
<div style="padding: 1rem; border-top: 1px solid var(--light-2); text-align: center;">
|
| 133 |
+
<a href="@Url.Action("Index", "Tool")" class="btn btn-outline btn-sm">
|
| 134 |
+
查看所有工具
|
| 135 |
+
</a>
|
| 136 |
+
</div>
|
| 137 |
+
</div>
|
| 138 |
+
|
| 139 |
+
<!-- 快速操作 -->
|
| 140 |
+
<div class="data-table">
|
| 141 |
+
<div style="padding: 1.5rem; border-bottom: 1px solid var(--light-2);">
|
| 142 |
+
<h3 style="margin: 0; font-size: 1.1rem; font-weight: 600; color: var(--dark);">
|
| 143 |
+
<i class="fas fa-bolt" style="margin-right: 0.5rem; color: var(--warning);"></i>
|
| 144 |
+
快速操作
|
| 145 |
+
</h3>
|
| 146 |
+
</div>
|
| 147 |
+
|
| 148 |
+
<div style="padding: 1.5rem;">
|
| 149 |
+
<div style="display: grid; gap: 1rem;">
|
| 150 |
+
<a href="@Url.Action("Index", "Tool")"
|
| 151 |
+
style="display: flex; align-items: center; padding: 1rem; background: var(--light-1); border-radius: var(--border-radius); text-decoration: none; color: var(--dark); transition: var(--transition);"
|
| 152 |
+
onmouseover="this.style.background='var(--primary)'; this.style.color='white';"
|
| 153 |
+
onmouseout="this.style.background='var(--light-1)'; this.style.color='var(--dark)';">
|
| 154 |
+
<i class="fas fa-plus-circle" style="font-size: 1.5rem; margin-right: 1rem; color: var(--primary);"></i>
|
| 155 |
+
<div>
|
| 156 |
+
<strong>工具管理</strong>
|
| 157 |
+
<br><small style="opacity: 0.8;">管理平台上的工具</small>
|
| 158 |
+
</div>
|
| 159 |
+
</a>
|
| 160 |
+
|
| 161 |
+
<a href="@Url.Action("Index", "Category")"
|
| 162 |
+
style="display: flex; align-items: center; padding: 1rem; background: var(--light-1); border-radius: var(--border-radius); text-decoration: none; color: var(--dark); transition: var(--transition);"
|
| 163 |
+
onmouseover="this.style.background='var(--secondary)'; this.style.color='white';"
|
| 164 |
+
onmouseout="this.style.background='var(--light-1)'; this.style.color='var(--dark)';">
|
| 165 |
+
<i class="fas fa-tag" style="font-size: 1.5rem; margin-right: 1rem; color: var(--secondary);"></i>
|
| 166 |
+
<div>
|
| 167 |
+
<strong>分类管理</strong>
|
| 168 |
+
<br><small style="opacity: 0.8;">管理工具分类</small>
|
| 169 |
+
</div>
|
| 170 |
+
</a>
|
| 171 |
+
|
| 172 |
+
<a href="@Url.Action("Index", "Tag")"
|
| 173 |
+
style="display: flex; align-items: center; padding: 1rem; background: var(--light-1); border-radius: var(--border-radius); text-decoration: none; color: var(--dark); transition: var(--transition);"
|
| 174 |
+
onmouseover="this.style.background='var(--accent)'; this.style.color='white';"
|
| 175 |
+
onmouseout="this.style.background='var(--light-1)'; this.style.color='var(--dark)';">
|
| 176 |
+
<i class="fas fa-tags" style="font-size: 1.5rem; margin-right: 1rem; color: var(--accent);"></i>
|
| 177 |
+
<div>
|
| 178 |
+
<strong>标签管理</strong>
|
| 179 |
+
<br><small style="opacity: 0.8;">管理工具标签</small>
|
| 180 |
+
</div>
|
| 181 |
+
</a>
|
| 182 |
+
|
| 183 |
+
<a href="@Url.Action("Users", "Admin")"
|
| 184 |
+
style="display: flex; align-items: center; padding: 1rem; background: var(--light-1); border-radius: var(--border-radius); text-decoration: none; color: var(--dark); transition: var(--transition);"
|
| 185 |
+
onmouseover="this.style.background='var(--success)'; this.style.color='white';"
|
| 186 |
+
onmouseout="this.style.background='var(--light-1)'; this.style.color='var(--dark)';">
|
| 187 |
+
<i class="fas fa-user-cog" style="font-size: 1.5rem; margin-right: 1rem; color: var(--success);"></i>
|
| 188 |
+
<div>
|
| 189 |
+
<strong>用户管理</strong>
|
| 190 |
+
<br><small style="opacity: 0.8;">管理注册用户</small>
|
| 191 |
+
</div>
|
| 192 |
+
</a>
|
| 193 |
+
|
| 194 |
+
<a href="@Url.Action("Index", "Home")" target="_blank"
|
| 195 |
+
style="display: flex; align-items: center; padding: 1rem; background: var(--light-1); border-radius: var(--border-radius); text-decoration: none; color: var(--dark); transition: var(--transition);"
|
| 196 |
+
onmouseover="this.style.background='var(--accent)'; this.style.color='white';"
|
| 197 |
+
onmouseout="this.style.background='var(--light-1)'; this.style.color='var(--dark)';">
|
| 198 |
+
<i class="fas fa-external-link-alt" style="font-size: 1.5rem; margin-right: 1rem; color: var(--accent);"></i>
|
| 199 |
+
<div>
|
| 200 |
+
<strong>访问网站</strong>
|
| 201 |
+
<br><small style="opacity: 0.8;">查看前台网站</small>
|
| 202 |
+
</div>
|
| 203 |
+
</a>
|
| 204 |
+
|
| 205 |
+
<button onclick="initImageCompressor()"
|
| 206 |
+
style="display: flex; align-items: center; padding: 1rem; background: var(--light-1); border-radius: var(--border-radius); border: none; color: var(--dark); transition: var(--transition); cursor: pointer; width: 100%;"
|
| 207 |
+
onmouseover="this.style.background='var(--warning)'; this.style.color='white';"
|
| 208 |
+
onmouseout="this.style.background='var(--light-1)'; this.style.color='var(--dark)';">
|
| 209 |
+
<i class="fas fa-compress-alt" style="font-size: 1.5rem; margin-right: 1rem; color: var(--warning);"></i>
|
| 210 |
+
<div style="text-align: left;">
|
| 211 |
+
<strong>初始化图片压缩工具</strong>
|
| 212 |
+
<br><small style="opacity: 0.8;">添加图片压缩工具到数据库</small>
|
| 213 |
+
</div>
|
| 214 |
+
</button>
|
| 215 |
+
</div>
|
| 216 |
+
</div>
|
| 217 |
+
</div>
|
| 218 |
+
</div>
|
| 219 |
+
|
| 220 |
+
<!-- 系统信息 -->
|
| 221 |
+
<div class="data-table mt-6">
|
| 222 |
+
<div style="padding: 1.5rem; border-bottom: 1px solid var(--light-2);">
|
| 223 |
+
<h3 style="margin: 0; font-size: 1.1rem; font-weight: 600; color: var(--dark);">
|
| 224 |
+
<i class="fas fa-info-circle" style="margin-right: 0.5rem; color: var(--primary);"></i>
|
| 225 |
+
系统信息
|
| 226 |
+
</h3>
|
| 227 |
+
</div>
|
| 228 |
+
|
| 229 |
+
<div style="padding: 1.5rem;">
|
| 230 |
+
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4">
|
| 231 |
+
<div>
|
| 232 |
+
<strong>系统版本</strong>
|
| 233 |
+
<p style="color: var(--dark-2); margin: 0.25rem 0 0;">ToolHub v1.0.0</p>
|
| 234 |
+
</div>
|
| 235 |
+
<div>
|
| 236 |
+
<strong>运行环境</strong>
|
| 237 |
+
<p style="color: var(--dark-2); margin: 0.25rem 0 0;">.NET 9.0</p>
|
| 238 |
+
</div>
|
| 239 |
+
<div>
|
| 240 |
+
<strong>数据库</strong>
|
| 241 |
+
<p style="color: var(--dark-2); margin: 0.25rem 0 0;">SQLite + FreeSql</p>
|
| 242 |
+
</div>
|
| 243 |
+
<div>
|
| 244 |
+
<strong>服务器时间</strong>
|
| 245 |
+
<p style="color: var(--dark-2); margin: 0.25rem 0 0;">@DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")</p>
|
| 246 |
+
</div>
|
| 247 |
+
</div>
|
| 248 |
+
</div>
|
| 249 |
+
</div>
|
| 250 |
+
|
| 251 |
+
<script>
|
| 252 |
+
async function initImageCompressor() {
|
| 253 |
+
try {
|
| 254 |
+
const button = event.target.closest('button');
|
| 255 |
+
const originalContent = button.innerHTML;
|
| 256 |
+
|
| 257 |
+
// 显示加载状态
|
| 258 |
+
button.innerHTML = `
|
| 259 |
+
<i class="fas fa-spinner fa-spin" style="font-size: 1.5rem; margin-right: 1rem; color: var(--warning);"></i>
|
| 260 |
+
<div style="text-align: left;">
|
| 261 |
+
<strong>初始化中...</strong>
|
| 262 |
+
<br><small style="opacity: 0.8;">请稍候</small>
|
| 263 |
+
</div>
|
| 264 |
+
`;
|
| 265 |
+
button.disabled = true;
|
| 266 |
+
|
| 267 |
+
const response = await fetch('/Tool/InitImageCompressor');
|
| 268 |
+
const result = await response.json();
|
| 269 |
+
|
| 270 |
+
if (result.success) {
|
| 271 |
+
toolHub.showToast('图片压缩工具初始化成功!', 'success');
|
| 272 |
+
|
| 273 |
+
// 恢复按钮状态
|
| 274 |
+
button.innerHTML = `
|
| 275 |
+
<i class="fas fa-check-circle" style="font-size: 1.5rem; margin-right: 1rem; color: var(--success);"></i>
|
| 276 |
+
<div style="text-align: left;">
|
| 277 |
+
<strong>初始化完成</strong>
|
| 278 |
+
<br><small style="opacity: 0.8;">图片压缩工具已添加</small>
|
| 279 |
+
</div>
|
| 280 |
+
`;
|
| 281 |
+
|
| 282 |
+
// 3秒后恢复原状态
|
| 283 |
+
setTimeout(() => {
|
| 284 |
+
button.innerHTML = originalContent;
|
| 285 |
+
button.disabled = false;
|
| 286 |
+
}, 3000);
|
| 287 |
+
} else {
|
| 288 |
+
toolHub.showToast(result.message || '初始化失败', 'error');
|
| 289 |
+
button.innerHTML = originalContent;
|
| 290 |
+
button.disabled = false;
|
| 291 |
+
}
|
| 292 |
+
} catch (error) {
|
| 293 |
+
console.error('初始化失败:', error);
|
| 294 |
+
toolHub.showToast('初始化失败,请重试', 'error');
|
| 295 |
+
|
| 296 |
+
// 恢复按钮状态
|
| 297 |
+
const button = event.target.closest('button');
|
| 298 |
+
button.innerHTML = originalContent;
|
| 299 |
+
button.disabled = false;
|
| 300 |
+
}
|
| 301 |
+
}
|
| 302 |
+
</script>
|
Views/Admin/Login.cshtml
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@{
|
| 2 |
+
ViewData["Title"] = "管理员登录";
|
| 3 |
+
Layout = null;
|
| 4 |
+
}
|
| 5 |
+
|
| 6 |
+
<!DOCTYPE html>
|
| 7 |
+
<html lang="zh-CN">
|
| 8 |
+
<head>
|
| 9 |
+
<meta charset="utf-8" />
|
| 10 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 11 |
+
<title>@ViewData["Title"] - ToolHub</title>
|
| 12 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
| 13 |
+
<link href="https://cdn.bootcdn.net/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet" />
|
| 14 |
+
<link rel="stylesheet" href="~/css/toolhub.css" asp-append-version="true" />
|
| 15 |
+
</head>
|
| 16 |
+
<body style="background: linear-gradient(135deg, var(--primary), var(--secondary)); min-height: 100vh; display: flex; align-items: center; justify-content: center;">
|
| 17 |
+
<div class="container" style="max-width: 400px;">
|
| 18 |
+
<div class="card" style="padding: 2rem; text-align: center;">
|
| 19 |
+
<!-- Logo -->
|
| 20 |
+
<div style="margin-bottom: 2rem;">
|
| 21 |
+
<div class="brand-icon" style="margin: 0 auto 1rem;">
|
| 22 |
+
<i class="fas fa-wrench"></i>
|
| 23 |
+
</div>
|
| 24 |
+
<h1 style="font-size: 1.5rem; font-weight: 700; margin: 0;">
|
| 25 |
+
<span class="text-gradient">ToolHub</span> 管理后台
|
| 26 |
+
</h1>
|
| 27 |
+
<p style="color: var(--dark-2); margin-top: 0.5rem;">请登录您的管理员账户</p>
|
| 28 |
+
</div>
|
| 29 |
+
|
| 30 |
+
<!-- 错误消息 -->
|
| 31 |
+
@if (ViewBag.Error != null)
|
| 32 |
+
{
|
| 33 |
+
<div style="background: rgba(255, 77, 79, 0.1); color: var(--danger); padding: 0.75rem; border-radius: var(--border-radius); margin-bottom: 1.5rem; border-left: 4px solid var(--danger);">
|
| 34 |
+
<i class="fas fa-exclamation-circle" style="margin-right: 0.5rem;"></i>
|
| 35 |
+
@ViewBag.Error
|
| 36 |
+
</div>
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
<!-- 登录表单 -->
|
| 40 |
+
<form method="post" action="@Url.Action("Login", "Admin")">
|
| 41 |
+
<div style="margin-bottom: 1.25rem;">
|
| 42 |
+
<input type="email"
|
| 43 |
+
name="email"
|
| 44 |
+
placeholder="管理员邮箱"
|
| 45 |
+
required
|
| 46 |
+
style="width: 100%; padding: 0.875rem; border: 1px solid var(--light-2); border-radius: var(--border-radius); font-size: 0.875rem; transition: var(--transition);"
|
| 47 |
+
onfocus="this.style.borderColor='var(--primary)'; this.style.boxShadow='0 0 0 3px rgba(22, 93, 255, 0.1)'"
|
| 48 |
+
onblur="this.style.borderColor='var(--light-2)'; this.style.boxShadow='none'" />
|
| 49 |
+
</div>
|
| 50 |
+
|
| 51 |
+
<div style="margin-bottom: 1.25rem;">
|
| 52 |
+
<input type="password"
|
| 53 |
+
name="password"
|
| 54 |
+
placeholder="密码"
|
| 55 |
+
required
|
| 56 |
+
style="width: 100%; padding: 0.875rem; border: 1px solid var(--light-2); border-radius: var(--border-radius); font-size: 0.875rem; transition: var(--transition);"
|
| 57 |
+
onfocus="this.style.borderColor='var(--primary)'; this.style.boxShadow='0 0 0 3px rgba(22, 93, 255, 0.1)'"
|
| 58 |
+
onblur="this.style.borderColor='var(--light-2)'; this.style.boxShadow='none'" />
|
| 59 |
+
</div>
|
| 60 |
+
|
| 61 |
+
<div style="margin-bottom: 1.5rem; display: flex; align-items: center; gap: 0.5rem;">
|
| 62 |
+
<input type="checkbox" name="rememberMe" id="rememberMe" value="true" />
|
| 63 |
+
<label for="rememberMe" style="font-size: 0.875rem; color: var(--dark-2); cursor: pointer;">
|
| 64 |
+
记住我
|
| 65 |
+
</label>
|
| 66 |
+
</div>
|
| 67 |
+
|
| 68 |
+
<button type="submit" class="btn btn-primary" style="width: 100%; padding: 0.875rem; font-weight: 600;">
|
| 69 |
+
<i class="fas fa-sign-in-alt" style="margin-right: 0.5rem;"></i>
|
| 70 |
+
登录
|
| 71 |
+
</button>
|
| 72 |
+
</form>
|
| 73 |
+
|
| 74 |
+
<!-- 说明文字 -->
|
| 75 |
+
<div style="margin-top: 2rem; padding-top: 1.5rem; border-top: 1px solid var(--light-2);">
|
| 76 |
+
<p style="font-size: 0.75rem; color: var(--dark-2); margin: 0;">
|
| 77 |
+
<i class="fas fa-shield-alt" style="margin-right: 0.25rem;"></i>
|
| 78 |
+
默认管理员账户:admin@toolhub.com / admin123
|
| 79 |
+
</p>
|
| 80 |
+
<p style="font-size: 0.75rem; color: var(--dark-2); margin: 0.5rem 0 0;">
|
| 81 |
+
<a href="@Url.Action("Index", "Home")" style="color: var(--primary); text-decoration: none;">
|
| 82 |
+
<i class="fas fa-arrow-left" style="margin-right: 0.25rem;"></i>
|
| 83 |
+
返回首页
|
| 84 |
+
</a>
|
| 85 |
+
</p>
|
| 86 |
+
</div>
|
| 87 |
+
</div>
|
| 88 |
+
</div>
|
| 89 |
+
|
| 90 |
+
<script>
|
| 91 |
+
// 简单的表单验证
|
| 92 |
+
document.querySelector('form').addEventListener('submit', function(e) {
|
| 93 |
+
const email = this.email.value.trim();
|
| 94 |
+
const password = this.password.value.trim();
|
| 95 |
+
|
| 96 |
+
if (!email || !password) {
|
| 97 |
+
e.preventDefault();
|
| 98 |
+
alert('请填写完整的登录信息');
|
| 99 |
+
}
|
| 100 |
+
});
|
| 101 |
+
</script>
|
| 102 |
+
</body>
|
| 103 |
+
</html>
|
Views/Admin/Tags.cshtml
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@model List<ToolHub.Models.Tag>
|
| 2 |
+
@{
|
| 3 |
+
ViewData["Title"] = "标签管理";
|
| 4 |
+
Layout = "_AdminLayout";
|
| 5 |
+
var currentPage = ViewBag.CurrentPage as int? ?? 1;
|
| 6 |
+
var totalPages = ViewBag.TotalPages as int? ?? 1;
|
| 7 |
+
var totalCount = ViewBag.TotalCount as int? ?? 0;
|
| 8 |
+
var pageSize = ViewBag.PageSize as int? ?? 20;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
<!-- 页面头部 -->
|
| 12 |
+
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;">
|
| 13 |
+
<div>
|
| 14 |
+
<h2 style="font-size: 1.5rem; font-weight: 700; margin: 0; color: var(--dark);">标签管理</h2>
|
| 15 |
+
<p style="color: var(--dark-2); margin: 0.5rem 0 0;">管理工具标签,为工具添加分类标识</p>
|
| 16 |
+
</div>
|
| 17 |
+
<button class="btn btn-primary" onclick="openTagModal()">
|
| 18 |
+
<i class="fas fa-plus"></i>
|
| 19 |
+
添加标签
|
| 20 |
+
</button>
|
| 21 |
+
</div>
|
| 22 |
+
|
| 23 |
+
<!-- 标签列表 -->
|
| 24 |
+
<div class="data-table">
|
| 25 |
+
<div style="padding: 1rem 1.5rem; border-bottom: 1px solid var(--light-2); display: flex; justify-content: space-between; align-items: center;">
|
| 26 |
+
<span style="font-weight: 600;">标签列表 (@totalCount)</span>
|
| 27 |
+
</div>
|
| 28 |
+
|
| 29 |
+
<table>
|
| 30 |
+
<thead>
|
| 31 |
+
<tr>
|
| 32 |
+
<th>标签名称</th>
|
| 33 |
+
<th>颜色</th>
|
| 34 |
+
<th>使用次数</th>
|
| 35 |
+
<th>创建时间</th>
|
| 36 |
+
<th>状态</th>
|
| 37 |
+
<th>操作</th>
|
| 38 |
+
</tr>
|
| 39 |
+
</thead>
|
| 40 |
+
<tbody>
|
| 41 |
+
@foreach (var tag in Model)
|
| 42 |
+
{
|
| 43 |
+
<tr>
|
| 44 |
+
<td>
|
| 45 |
+
<div style="display: flex; align-items: center;">
|
| 46 |
+
<span class="badge" style="background: @(tag.Color ?? "var(--primary)"); color: white; margin-right: 0.75rem;">
|
| 47 |
+
@tag.Name
|
| 48 |
+
</span>
|
| 49 |
+
<strong>@tag.Name</strong>
|
| 50 |
+
</div>
|
| 51 |
+
</td>
|
| 52 |
+
<td>
|
| 53 |
+
@if (!string.IsNullOrEmpty(tag.Color))
|
| 54 |
+
{
|
| 55 |
+
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
| 56 |
+
<div style="width: 20px; height: 20px; background: @tag.Color; border-radius: 4px; border: 1px solid var(--light-2);"></div>
|
| 57 |
+
<code style="font-size: 0.75rem;">@tag.Color</code>
|
| 58 |
+
</div>
|
| 59 |
+
}
|
| 60 |
+
else
|
| 61 |
+
{
|
| 62 |
+
<span style="color: var(--dark-2);">默认</span>
|
| 63 |
+
}
|
| 64 |
+
</td>
|
| 65 |
+
<td>
|
| 66 |
+
<span class="badge badge-primary">@tag.ToolTags?.Count</span>
|
| 67 |
+
</td>
|
| 68 |
+
<td style="color: var(--dark-2); font-size: 0.875rem;">
|
| 69 |
+
@tag.CreatedAt.ToString("yyyy-MM-dd")
|
| 70 |
+
</td>
|
| 71 |
+
<td>
|
| 72 |
+
@if (tag.IsActive)
|
| 73 |
+
{
|
| 74 |
+
<span class="badge badge-success">启用</span>
|
| 75 |
+
}
|
| 76 |
+
else
|
| 77 |
+
{
|
| 78 |
+
<span class="badge badge-secondary">禁用</span>
|
| 79 |
+
}
|
| 80 |
+
</td>
|
| 81 |
+
<td>
|
| 82 |
+
<div style="display: flex; gap: 0.5rem;">
|
| 83 |
+
<button class="btn btn-outline btn-sm" onclick="editTag(@tag.Id, '@tag.Name', '@tag.Color')">
|
| 84 |
+
<i class="fas fa-edit"></i>
|
| 85 |
+
</button>
|
| 86 |
+
<button class="btn btn-outline btn-sm" onclick="toggleTagStatus(@tag.Id, @tag.IsActive.ToString().ToLower())" style="color: @(tag.IsActive ? "var(--warning)" : "var(--success)");">
|
| 87 |
+
<i class="fas fa-@(tag.IsActive ? "pause" : "play")"></i>
|
| 88 |
+
</button>
|
| 89 |
+
<button class="btn btn-outline btn-sm" onclick="deleteTag(@tag.Id, '@tag.Name')" style="color: var(--danger);">
|
| 90 |
+
<i class="fas fa-trash"></i>
|
| 91 |
+
</button>
|
| 92 |
+
</div>
|
| 93 |
+
</td>
|
| 94 |
+
</tr>
|
| 95 |
+
}
|
| 96 |
+
</tbody>
|
| 97 |
+
</table>
|
| 98 |
+
|
| 99 |
+
@if (!Model.Any())
|
| 100 |
+
{
|
| 101 |
+
<div style="padding: 3rem; text-align: center; color: var(--dark-2);">
|
| 102 |
+
<i class="fas fa-tags" style="font-size: 3rem; margin-bottom: 1rem; display: block; opacity: 0.3;"></i>
|
| 103 |
+
<h3 style="margin-bottom: 0.5rem;">暂无标签</h3>
|
| 104 |
+
<p style="margin-bottom: 1.5rem;">还没有创建任何标签</p>
|
| 105 |
+
<button class="btn btn-primary" onclick="openTagModal()">
|
| 106 |
+
<i class="fas fa-plus"></i>
|
| 107 |
+
创建第一个标签
|
| 108 |
+
</button>
|
| 109 |
+
</div>
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
<!-- 分页控件 -->
|
| 113 |
+
@if (totalPages > 1)
|
| 114 |
+
{
|
| 115 |
+
<div style="padding: 1.5rem; border-top: 1px solid var(--light-2); display: flex; justify-content: center; align-items: center; gap: 0.5rem;">
|
| 116 |
+
<button class="btn btn-outline btn-sm" onclick="changePage(1)" @(currentPage == 1 ? "disabled" : "")>
|
| 117 |
+
<i class="fas fa-angle-double-left"></i>
|
| 118 |
+
</button>
|
| 119 |
+
<button class="btn btn-outline btn-sm" onclick="changePage(@(currentPage - 1))" @(currentPage == 1 ? "disabled" : "")>
|
| 120 |
+
<i class="fas fa-angle-left"></i>
|
| 121 |
+
</button>
|
| 122 |
+
|
| 123 |
+
@{
|
| 124 |
+
var startPage = Math.Max(1, currentPage - 2);
|
| 125 |
+
var endPage = Math.Min(totalPages, currentPage + 2);
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
@for (int i = startPage; i <= endPage; i++)
|
| 129 |
+
{
|
| 130 |
+
<button class="btn @(i == currentPage ? "btn-primary" : "btn-outline") btn-sm" onclick="changePage(@i)">
|
| 131 |
+
@i
|
| 132 |
+
</button>
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
<button class="btn btn-outline btn-sm" onclick="changePage(@(currentPage + 1))" @(currentPage == totalPages ? "disabled" : "")>
|
| 136 |
+
<i class="fas fa-angle-right"></i>
|
| 137 |
+
</button>
|
| 138 |
+
<button class="btn btn-outline btn-sm" onclick="changePage(@totalPages)" @(currentPage == totalPages ? "disabled" : "")>
|
| 139 |
+
<i class="fas fa-angle-double-right"></i>
|
| 140 |
+
</button>
|
| 141 |
+
</div>
|
| 142 |
+
}
|
| 143 |
+
</div>
|
| 144 |
+
|
| 145 |
+
<!-- 标签模态框 -->
|
| 146 |
+
<div id="tagModal" class="modal" style="display: none;">
|
| 147 |
+
<div class="modal-backdrop" onclick="closeTagModal()"></div>
|
| 148 |
+
<div class="modal-content" style="max-width: 400px;">
|
| 149 |
+
<div class="modal-header">
|
| 150 |
+
<h3 id="modalTitle">添加标签</h3>
|
| 151 |
+
<button onclick="closeTagModal()" style="background: none; border: none; font-size: 1.5rem; cursor: pointer;">×</button>
|
| 152 |
+
</div>
|
| 153 |
+
|
| 154 |
+
<form id="tagForm" onsubmit="submitTag(event)">
|
| 155 |
+
<input type="hidden" id="tagId" name="id" value="0" />
|
| 156 |
+
|
| 157 |
+
<div class="form-group" style="margin-bottom: 1.5rem;">
|
| 158 |
+
<label style="display: block; font-weight: 600; margin-bottom: 0.5rem;">标签名称 *</label>
|
| 159 |
+
<input type="text" id="tagName" name="name" required
|
| 160 |
+
style="width: 100%; padding: 0.75rem; border: 1px solid var(--light-2); border-radius: var(--border-radius);"
|
| 161 |
+
placeholder="请输入标签名称" />
|
| 162 |
+
</div>
|
| 163 |
+
|
| 164 |
+
<div class="form-group" style="margin-bottom: 1.5rem;">
|
| 165 |
+
<label style="display: block; font-weight: 600; margin-bottom: 0.5rem;">标签颜色</label>
|
| 166 |
+
<input type="color" id="tagColor" name="color" value="#165DFF"
|
| 167 |
+
style="width: 100%; padding: 0.5rem; border: 1px solid var(--light-2); border-radius: var(--border-radius); height: 3rem;" />
|
| 168 |
+
<small style="color: var(--dark-2); font-size: 0.75rem;">选择标签的显示颜色</small>
|
| 169 |
+
</div>
|
| 170 |
+
|
| 171 |
+
<div class="modal-footer">
|
| 172 |
+
<button type="button" class="btn btn-outline" onclick="closeTagModal()">取消</button>
|
| 173 |
+
<button type="submit" class="btn btn-primary">
|
| 174 |
+
<span id="submitText">保存</span>
|
| 175 |
+
</button>
|
| 176 |
+
</div>
|
| 177 |
+
</form>
|
| 178 |
+
</div>
|
| 179 |
+
</div>
|
| 180 |
+
|
| 181 |
+
<style>
|
| 182 |
+
.modal {
|
| 183 |
+
position: fixed;
|
| 184 |
+
top: 0;
|
| 185 |
+
left: 0;
|
| 186 |
+
right: 0;
|
| 187 |
+
bottom: 0;
|
| 188 |
+
z-index: 10000;
|
| 189 |
+
display: flex;
|
| 190 |
+
align-items: center;
|
| 191 |
+
justify-content: center;
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
.modal-backdrop {
|
| 195 |
+
position: absolute;
|
| 196 |
+
top: 0;
|
| 197 |
+
left: 0;
|
| 198 |
+
right: 0;
|
| 199 |
+
bottom: 0;
|
| 200 |
+
background: rgba(0, 0, 0, 0.5);
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
.modal-content {
|
| 204 |
+
background: white;
|
| 205 |
+
border-radius: var(--border-radius);
|
| 206 |
+
position: relative;
|
| 207 |
+
z-index: 2;
|
| 208 |
+
max-height: 90vh;
|
| 209 |
+
overflow-y: auto;
|
| 210 |
+
width: 90%;
|
| 211 |
+
max-width: 600px;
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
.modal-header {
|
| 215 |
+
padding: 1.5rem;
|
| 216 |
+
border-bottom: 1px solid var(--light-2);
|
| 217 |
+
display: flex;
|
| 218 |
+
justify-content: space-between;
|
| 219 |
+
align-items: center;
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
.modal-header h3 {
|
| 223 |
+
margin: 0;
|
| 224 |
+
font-size: 1.25rem;
|
| 225 |
+
font-weight: 600;
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
.modal-footer {
|
| 229 |
+
padding: 1.5rem;
|
| 230 |
+
border-top: 1px solid var(--light-2);
|
| 231 |
+
display: flex;
|
| 232 |
+
justify-content: flex-end;
|
| 233 |
+
gap: 1rem;
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
.form-group input:focus {
|
| 237 |
+
border-color: var(--primary);
|
| 238 |
+
outline: none;
|
| 239 |
+
box-shadow: 0 0 0 3px rgba(22, 93, 255, 0.1);
|
| 240 |
+
}
|
| 241 |
+
</style>
|
| 242 |
+
|
| 243 |
+
<script>
|
| 244 |
+
let isEditing = false;
|
| 245 |
+
|
| 246 |
+
function changePage(page) {
|
| 247 |
+
window.location.href = `@Url.Action("Index", "Tag")?page=${page}`;
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
function openTagModal(editMode = false) {
|
| 251 |
+
document.getElementById('tagModal').style.display = 'flex';
|
| 252 |
+
document.getElementById('modalTitle').textContent = editMode ? '编辑标签' : '添加标签';
|
| 253 |
+
document.getElementById('submitText').textContent = editMode ? '更新' : '保存';
|
| 254 |
+
isEditing = editMode;
|
| 255 |
+
|
| 256 |
+
if (!editMode) {
|
| 257 |
+
document.getElementById('tagForm').reset();
|
| 258 |
+
document.getElementById('tagId').value = '0';
|
| 259 |
+
document.getElementById('tagColor').value = '#165DFF';
|
| 260 |
+
}
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
function closeTagModal() {
|
| 264 |
+
document.getElementById('tagModal').style.display = 'none';
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
function editTag(id, name, color) {
|
| 268 |
+
openTagModal(true);
|
| 269 |
+
document.getElementById('tagId').value = id;
|
| 270 |
+
document.getElementById('tagName').value = name;
|
| 271 |
+
document.getElementById('tagColor').value = color || '#165DFF';
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
async function submitTag(event) {
|
| 275 |
+
event.preventDefault();
|
| 276 |
+
|
| 277 |
+
const formData = new FormData(event.target);
|
| 278 |
+
const data = Object.fromEntries(formData.entries());
|
| 279 |
+
|
| 280 |
+
try {
|
| 281 |
+
const response = await fetch('/Tag/Save', {
|
| 282 |
+
method: 'POST',
|
| 283 |
+
headers: {
|
| 284 |
+
'Content-Type': 'application/json',
|
| 285 |
+
},
|
| 286 |
+
body: JSON.stringify(data)
|
| 287 |
+
});
|
| 288 |
+
|
| 289 |
+
if (response.ok) {
|
| 290 |
+
toolHub.showToast(isEditing ? '标签更新成功' : '标签添加成功', 'success');
|
| 291 |
+
closeTagModal();
|
| 292 |
+
setTimeout(() => location.reload(), 1000);
|
| 293 |
+
} else {
|
| 294 |
+
toolHub.showToast('操作失败,请重试', 'error');
|
| 295 |
+
}
|
| 296 |
+
} catch (error) {
|
| 297 |
+
console.error('提交失败:', error);
|
| 298 |
+
toolHub.showToast('操作失败,请重试', 'error');
|
| 299 |
+
}
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
async function toggleTagStatus(id, currentStatus) {
|
| 303 |
+
try {
|
| 304 |
+
const response = await fetch('/Tag/ToggleStatus', {
|
| 305 |
+
method: 'POST',
|
| 306 |
+
headers: {
|
| 307 |
+
'Content-Type': 'application/json',
|
| 308 |
+
},
|
| 309 |
+
body: JSON.stringify({ id: id, isActive: !currentStatus })
|
| 310 |
+
});
|
| 311 |
+
|
| 312 |
+
if (response.ok) {
|
| 313 |
+
toolHub.showToast('状态更新成功', 'success');
|
| 314 |
+
setTimeout(() => location.reload(), 1000);
|
| 315 |
+
} else {
|
| 316 |
+
toolHub.showToast('操作失败,请重试', 'error');
|
| 317 |
+
}
|
| 318 |
+
} catch (error) {
|
| 319 |
+
console.error('操作失败:', error);
|
| 320 |
+
toolHub.showToast('操作失败,请重试', 'error');
|
| 321 |
+
}
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
async function deleteTag(id, name) {
|
| 325 |
+
if (!confirm(`确定要删除标签"${name}"吗?\n注意:删除标签会影响使用该标签的工具。`)) {
|
| 326 |
+
return;
|
| 327 |
+
}
|
| 328 |
+
|
| 329 |
+
try {
|
| 330 |
+
const response = await fetch('/Tag/Delete', {
|
| 331 |
+
method: 'POST',
|
| 332 |
+
headers: {
|
| 333 |
+
'Content-Type': 'application/json',
|
| 334 |
+
},
|
| 335 |
+
body: JSON.stringify({ id: id })
|
| 336 |
+
});
|
| 337 |
+
|
| 338 |
+
if (response.ok) {
|
| 339 |
+
toolHub.showToast('标签删除成功', 'success');
|
| 340 |
+
setTimeout(() => location.reload(), 1000);
|
| 341 |
+
} else {
|
| 342 |
+
toolHub.showToast('删除失败,可能该标签下还有工具', 'error');
|
| 343 |
+
}
|
| 344 |
+
} catch (error) {
|
| 345 |
+
console.error('删除失败:', error);
|
| 346 |
+
toolHub.showToast('删除失败,请重试', 'error');
|
| 347 |
+
}
|
| 348 |
+
}
|
| 349 |
+
</script>
|
Views/Admin/Tools.cshtml
ADDED
|
@@ -0,0 +1,934 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@model List<ToolHub.Models.Tool>
|
| 2 |
+
@{
|
| 3 |
+
ViewData["Title"] = "工具管理";
|
| 4 |
+
Layout = "_AdminLayout";
|
| 5 |
+
var categories = ViewBag.Categories as List<ToolHub.Models.Category> ?? new List<ToolHub.Models.Category>();
|
| 6 |
+
var currentCategory = ViewBag.CurrentCategory as int? ?? 0;
|
| 7 |
+
var currentPage = ViewBag.CurrentPage as int? ?? 1;
|
| 8 |
+
var totalPages = ViewBag.TotalPages as int? ?? 1;
|
| 9 |
+
var totalCount = ViewBag.TotalCount as int? ?? 0;
|
| 10 |
+
var pageSize = ViewBag.PageSize as int? ?? 20;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
<!-- 页面头部 -->
|
| 14 |
+
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;">
|
| 15 |
+
<div>
|
| 16 |
+
<h2 style="font-size: 1.5rem; font-weight: 700; margin: 0; color: var(--dark);">工具管理</h2>
|
| 17 |
+
<p style="color: var(--dark-2); margin: 0.5rem 0 0;">管理平台上的所有工具,添加、编辑或删除工具</p>
|
| 18 |
+
</div>
|
| 19 |
+
<div style="display: flex; gap: 1rem;">
|
| 20 |
+
<a href="@Url.Action("Index", "Tag")" class="btn btn-secondary">
|
| 21 |
+
<i class="fas fa-tags"></i>
|
| 22 |
+
标签管理
|
| 23 |
+
</a>
|
| 24 |
+
<button class="btn btn-primary" onclick="openToolModal()">
|
| 25 |
+
<i class="fas fa-plus"></i>
|
| 26 |
+
添加工具
|
| 27 |
+
</button>
|
| 28 |
+
</div>
|
| 29 |
+
</div>
|
| 30 |
+
|
| 31 |
+
<!-- 筛选和搜索 -->
|
| 32 |
+
<div style="background: white; padding: 1.5rem; border-radius: var(--border-radius); box-shadow: var(--shadow-sm); margin-bottom: 2rem;">
|
| 33 |
+
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr auto; gap: 1rem; align-items: end;">
|
| 34 |
+
<div>
|
| 35 |
+
<label style="display: block; font-weight: 600; margin-bottom: 0.5rem; font-size: 0.875rem;">分类筛选</label>
|
| 36 |
+
<select id="categoryFilter" onchange="filterTools()" style="width: 100%; padding: 0.75rem; border: 1px solid var(--light-2); border-radius: var(--border-radius);">
|
| 37 |
+
<option value="0">全部分类</option>
|
| 38 |
+
@foreach (var category in categories)
|
| 39 |
+
{
|
| 40 |
+
<option value="@category.Id" selected="@(currentCategory == category.Id)">@category.Name</option>
|
| 41 |
+
}
|
| 42 |
+
</select>
|
| 43 |
+
</div>
|
| 44 |
+
|
| 45 |
+
<div>
|
| 46 |
+
<label style="display: block; font-weight: 600; margin-bottom: 0.5rem; font-size: 0.875rem;">状态筛选</label>
|
| 47 |
+
<select id="statusFilter" onchange="filterTools()" style="width: 100%; padding: 0.75rem; border: 1px solid var(--light-2); border-radius: var(--border-radius);">
|
| 48 |
+
<option value="">全部状态</option>
|
| 49 |
+
<option value="active">启用</option>
|
| 50 |
+
<option value="inactive">禁用</option>
|
| 51 |
+
<option value="hot">热门</option>
|
| 52 |
+
<option value="new">新品</option>
|
| 53 |
+
<option value="recommended">推荐</option>
|
| 54 |
+
</select>
|
| 55 |
+
</div>
|
| 56 |
+
|
| 57 |
+
<div>
|
| 58 |
+
<label style="display: block; font-weight: 600; margin-bottom: 0.5rem; font-size: 0.875rem;">搜索工具</label>
|
| 59 |
+
<input type="text" id="searchInput" placeholder="输入工具名称..." onkeyup="filterTools()"
|
| 60 |
+
style="width: 100%; padding: 0.75rem; border: 1px solid var(--light-2); border-radius: var(--border-radius);" />
|
| 61 |
+
</div>
|
| 62 |
+
|
| 63 |
+
<button class="btn btn-outline" onclick="clearFilters()">
|
| 64 |
+
<i class="fas fa-undo"></i>
|
| 65 |
+
重置
|
| 66 |
+
</button>
|
| 67 |
+
</div>
|
| 68 |
+
</div>
|
| 69 |
+
|
| 70 |
+
<!-- 工具列表 -->
|
| 71 |
+
<div class="data-table">
|
| 72 |
+
<div style="padding: 1rem 1.5rem; border-bottom: 1px solid var(--light-2); display: flex; justify-content: space-between; align-items: center;">
|
| 73 |
+
<span style="font-weight: 600;">工具列表 (@totalCount)</span>
|
| 74 |
+
<div style="display: flex; gap: 0.5rem;">
|
| 75 |
+
<button class="btn btn-outline btn-sm" onclick="toggleView('table')" id="tableViewBtn">
|
| 76 |
+
<i class="fas fa-list"></i> 表格
|
| 77 |
+
</button>
|
| 78 |
+
<button class="btn btn-outline btn-sm" onclick="toggleView('grid')" id="gridViewBtn">
|
| 79 |
+
<i class="fas fa-th"></i> 网格
|
| 80 |
+
</button>
|
| 81 |
+
</div>
|
| 82 |
+
</div>
|
| 83 |
+
|
| 84 |
+
<!-- 表格视图 -->
|
| 85 |
+
<div id="tableView">
|
| 86 |
+
<table>
|
| 87 |
+
<thead>
|
| 88 |
+
<tr>
|
| 89 |
+
<th>工具信息</th>
|
| 90 |
+
<th>分类</th>
|
| 91 |
+
<th>统计</th>
|
| 92 |
+
<th>标签</th>
|
| 93 |
+
<th>状态</th>
|
| 94 |
+
<th>创建时间</th>
|
| 95 |
+
<th>操作</th>
|
| 96 |
+
</tr>
|
| 97 |
+
</thead>
|
| 98 |
+
<tbody id="toolsTableBody">
|
| 99 |
+
@foreach (var tool in Model)
|
| 100 |
+
{
|
| 101 |
+
<tr data-category="@tool.CategoryId" data-status="@(tool.IsActive ? "active" : "inactive")"
|
| 102 |
+
data-hot="@tool.IsHot.ToString().ToLower()" data-new="@tool.IsNew.ToString().ToLower()" data-recommended="@tool.IsRecommended.ToString().ToLower()">
|
| 103 |
+
<td>
|
| 104 |
+
<div style="display: flex; align-items: center;">
|
| 105 |
+
@if (!string.IsNullOrEmpty(tool.Image))
|
| 106 |
+
{
|
| 107 |
+
<img src="@tool.Image" alt="@tool.Name" style="width: 40px; height: 40px; border-radius: var(--border-radius); margin-right: 0.75rem; object-fit: cover;" />
|
| 108 |
+
}
|
| 109 |
+
else
|
| 110 |
+
{
|
| 111 |
+
<div style="width: 40px; height: 40px; background: linear-gradient(135deg, var(--primary), var(--secondary)); border-radius: var(--border-radius); display: flex; align-items: center; justify-content: center; margin-right: 0.75rem;">
|
| 112 |
+
<i class="@tool.Icon" style="color: white;"></i>
|
| 113 |
+
</div>
|
| 114 |
+
}
|
| 115 |
+
<div>
|
| 116 |
+
<strong class="tool-name">@tool.Name</strong>
|
| 117 |
+
@if (!string.IsNullOrEmpty(tool.Description))
|
| 118 |
+
{
|
| 119 |
+
<br><small style="color: var(--dark-2);">@(tool.Description.Length > 50 ? tool.Description.Substring(0, 50) + "..." : tool.Description)</small>
|
| 120 |
+
}
|
| 121 |
+
</div>
|
| 122 |
+
</div>
|
| 123 |
+
</td>
|
| 124 |
+
<td>
|
| 125 |
+
<span class="badge badge-primary">@tool.Category?.Name</span>
|
| 126 |
+
</td>
|
| 127 |
+
<td>
|
| 128 |
+
<div style="font-size: 0.875rem;">
|
| 129 |
+
<div style="margin-bottom: 0.25rem;">
|
| 130 |
+
<i class="fas fa-eye" style="color: var(--primary); margin-right: 0.25rem;"></i>
|
| 131 |
+
@(tool.ViewCount > 1000 ? (tool.ViewCount / 1000).ToString("F0") + "K" : tool.ViewCount.ToString())
|
| 132 |
+
</div>
|
| 133 |
+
@if (tool.Rating > 0)
|
| 134 |
+
{
|
| 135 |
+
<div>
|
| 136 |
+
<i class="fas fa-star" style="color: var(--warning); margin-right: 0.25rem;"></i>
|
| 137 |
+
@tool.Rating.ToString("F1")
|
| 138 |
+
</div>
|
| 139 |
+
}
|
| 140 |
+
</div>
|
| 141 |
+
</td>
|
| 142 |
+
<td>
|
| 143 |
+
<div style="display: flex; flex-wrap: wrap; gap: 0.25rem;">
|
| 144 |
+
@if (tool.IsHot)
|
| 145 |
+
{
|
| 146 |
+
<span class="badge badge-danger">热门</span>
|
| 147 |
+
}
|
| 148 |
+
@if (tool.IsNew)
|
| 149 |
+
{
|
| 150 |
+
<span class="badge badge-warning">新品</span>
|
| 151 |
+
}
|
| 152 |
+
@if (tool.IsRecommended)
|
| 153 |
+
{
|
| 154 |
+
<span class="badge badge-success">推荐</span>
|
| 155 |
+
}
|
| 156 |
+
<button class="btn btn-outline btn-xs" onclick="manageToolTags(@tool.Id, '@tool.Name')" title="管理标签">
|
| 157 |
+
<i class="fas fa-tags"></i>
|
| 158 |
+
</button>
|
| 159 |
+
</div>
|
| 160 |
+
</td>
|
| 161 |
+
<td>
|
| 162 |
+
@if (tool.IsActive)
|
| 163 |
+
{
|
| 164 |
+
<span class="badge badge-success">启用</span>
|
| 165 |
+
}
|
| 166 |
+
else
|
| 167 |
+
{
|
| 168 |
+
<span class="badge badge-secondary">禁用</span>
|
| 169 |
+
}
|
| 170 |
+
</td>
|
| 171 |
+
<td style="color: var(--dark-2); font-size: 0.875rem;">
|
| 172 |
+
@tool.CreatedAt.ToString("yyyy-MM-dd")
|
| 173 |
+
</td>
|
| 174 |
+
<td>
|
| 175 |
+
<div style="display: flex; gap: 0.5rem;">
|
| 176 |
+
<button class="btn btn-outline btn-sm" onclick="editTool(@tool.Id)" title="编辑">
|
| 177 |
+
<i class="fas fa-edit"></i>
|
| 178 |
+
</button>
|
| 179 |
+
<a href="@Url.Action("Index", "ToolStatistics", new { toolId = tool.Id })" class="btn btn-outline btn-sm" title="统计">
|
| 180 |
+
<i class="fas fa-chart-bar"></i>
|
| 181 |
+
</a>
|
| 182 |
+
<button class="btn btn-outline btn-sm" onclick="toggleToolStatus(@tool.Id, @tool.IsActive.ToString().ToLower())"
|
| 183 |
+
style="color: @(tool.IsActive ? "var(--warning)" : "var(--success)");" title="@(tool.IsActive ? "禁用" : "启用")">
|
| 184 |
+
<i class="fas fa-@(tool.IsActive ? "pause" : "play")"></i>
|
| 185 |
+
</button>
|
| 186 |
+
<button class="btn btn-outline btn-sm" onclick="deleteTool(@tool.Id, '@tool.Name')"
|
| 187 |
+
style="color: var(--danger);" title="删除">
|
| 188 |
+
<i class="fas fa-trash"></i>
|
| 189 |
+
</button>
|
| 190 |
+
</div>
|
| 191 |
+
</td>
|
| 192 |
+
</tr>
|
| 193 |
+
}
|
| 194 |
+
</tbody>
|
| 195 |
+
</table>
|
| 196 |
+
</div>
|
| 197 |
+
|
| 198 |
+
@if (!Model.Any())
|
| 199 |
+
{
|
| 200 |
+
<div style="padding: 3rem; text-align: center; color: var(--dark-2);">
|
| 201 |
+
<i class="fas fa-tools" style="font-size: 3rem; margin-bottom: 1rem; display: block; opacity: 0.3;"></i>
|
| 202 |
+
<h3 style="margin-bottom: 0.5rem;">暂无工具</h3>
|
| 203 |
+
<p style="margin-bottom: 1.5rem;">还没有添加任何工具</p>
|
| 204 |
+
<button class="btn btn-primary" onclick="openToolModal()">
|
| 205 |
+
<i class="fas fa-plus"></i>
|
| 206 |
+
添加第一个工具
|
| 207 |
+
</button>
|
| 208 |
+
</div>
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
<!-- 分页控件 -->
|
| 212 |
+
@if (totalPages > 1)
|
| 213 |
+
{
|
| 214 |
+
<div style="padding: 1.5rem; border-top: 1px solid var(--light-2); display: flex; justify-content: center; align-items: center; gap: 0.5rem;">
|
| 215 |
+
<button class="btn btn-outline btn-sm" onclick="changePage(1)" @(currentPage == 1 ? "disabled" : "")>
|
| 216 |
+
<i class="fas fa-angle-double-left"></i>
|
| 217 |
+
</button>
|
| 218 |
+
<button class="btn btn-outline btn-sm" onclick="changePage(@(currentPage - 1))" @(currentPage == 1 ? "disabled" : "")>
|
| 219 |
+
<i class="fas fa-angle-left"></i>
|
| 220 |
+
</button>
|
| 221 |
+
|
| 222 |
+
@{
|
| 223 |
+
var startPage = Math.Max(1, currentPage - 2);
|
| 224 |
+
var endPage = Math.Min(totalPages, currentPage + 2);
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
@for (int i = startPage; i <= endPage; i++)
|
| 228 |
+
{
|
| 229 |
+
<button class="btn @(i == currentPage ? "btn-primary" : "btn-outline") btn-sm" onclick="changePage(@i)">
|
| 230 |
+
@i
|
| 231 |
+
</button>
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
<button class="btn btn-outline btn-sm" onclick="changePage(@(currentPage + 1))" @(currentPage == totalPages ? "disabled" : "")>
|
| 235 |
+
<i class="fas fa-angle-right"></i>
|
| 236 |
+
</button>
|
| 237 |
+
<button class="btn btn-outline btn-sm" onclick="changePage(@totalPages)" @(currentPage == totalPages ? "disabled" : "")>
|
| 238 |
+
<i class="fas fa-angle-double-right"></i>
|
| 239 |
+
</button>
|
| 240 |
+
</div>
|
| 241 |
+
}
|
| 242 |
+
</div>
|
| 243 |
+
|
| 244 |
+
<!-- 工具模态框 -->
|
| 245 |
+
<div id="toolModal" class="modal" style="display: none;">
|
| 246 |
+
<div class="modal-backdrop" onclick="closeToolModal()"></div>
|
| 247 |
+
<div class="modal-content" style="max-width: 600px;">
|
| 248 |
+
<div class="modal-header">
|
| 249 |
+
<h3 id="toolModalTitle">添加工具</h3>
|
| 250 |
+
<button onclick="closeToolModal()" style="background: none; border: none; font-size: 1.5rem; cursor: pointer;">×</button>
|
| 251 |
+
</div>
|
| 252 |
+
|
| 253 |
+
<form id="toolForm" onsubmit="submitTool(event)">
|
| 254 |
+
<div class="modal-body">
|
| 255 |
+
<input type="hidden" id="toolId" name="id" value="0" />
|
| 256 |
+
|
| 257 |
+
<div class="form-group">
|
| 258 |
+
<label>工具名称 *</label>
|
| 259 |
+
<input type="text" id="toolName" name="name" required placeholder="请输入工具名称" />
|
| 260 |
+
</div>
|
| 261 |
+
|
| 262 |
+
<div class="form-group">
|
| 263 |
+
<label>工具描述</label>
|
| 264 |
+
<textarea id="toolDescription" name="description" rows="3" placeholder="请输入工具描述"></textarea>
|
| 265 |
+
</div>
|
| 266 |
+
|
| 267 |
+
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
|
| 268 |
+
<div class="form-group">
|
| 269 |
+
<label>所属分类 *</label>
|
| 270 |
+
<select id="toolCategory" name="categoryId" required>
|
| 271 |
+
<option value="">请选择分类</option>
|
| 272 |
+
@foreach (var category in categories)
|
| 273 |
+
{
|
| 274 |
+
<option value="@category.Id">@category.Name</option>
|
| 275 |
+
}
|
| 276 |
+
</select>
|
| 277 |
+
</div>
|
| 278 |
+
|
| 279 |
+
<div class="form-group">
|
| 280 |
+
<label>图标选择</label>
|
| 281 |
+
<div style="display: flex; gap: 0.5rem;">
|
| 282 |
+
<input type="text" id="toolIcon" name="icon" placeholder="如: fas fa-file-pdf" readonly style="flex: 1;" />
|
| 283 |
+
<button type="button" class="btn btn-outline" onclick="openIconSelector()">
|
| 284 |
+
<i class="fas fa-icons"></i>
|
| 285 |
+
选择图标
|
| 286 |
+
</button>
|
| 287 |
+
</div>
|
| 288 |
+
</div>
|
| 289 |
+
</div>
|
| 290 |
+
|
| 291 |
+
<div class="form-group">
|
| 292 |
+
<label>工具链接</label>
|
| 293 |
+
<input type="url" id="toolUrl" name="url" placeholder="https://example.com" />
|
| 294 |
+
</div>
|
| 295 |
+
|
| 296 |
+
<div class="form-group">
|
| 297 |
+
<label>工具图片链接</label>
|
| 298 |
+
<input type="url" id="toolImage" name="image" placeholder="https://example.com/image.jpg" />
|
| 299 |
+
</div>
|
| 300 |
+
|
| 301 |
+
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 1rem;">
|
| 302 |
+
<div class="form-group">
|
| 303 |
+
<label style="display: flex; align-items: center; gap: 0.5rem;">
|
| 304 |
+
<input type="checkbox" id="toolIsHot" name="isHot" />
|
| 305 |
+
<span>热门工具</span>
|
| 306 |
+
</label>
|
| 307 |
+
</div>
|
| 308 |
+
|
| 309 |
+
<div class="form-group">
|
| 310 |
+
<label style="display: flex; align-items: center; gap: 0.5rem;">
|
| 311 |
+
<input type="checkbox" id="toolIsNew" name="isNew" />
|
| 312 |
+
<span>新品工具</span>
|
| 313 |
+
</label>
|
| 314 |
+
</div>
|
| 315 |
+
|
| 316 |
+
<div class="form-group">
|
| 317 |
+
<label style="display: flex; align-items: center; gap: 0.5rem;">
|
| 318 |
+
<input type="checkbox" id="toolIsRecommended" name="isRecommended" />
|
| 319 |
+
<span>推荐工具</span>
|
| 320 |
+
</label>
|
| 321 |
+
</div>
|
| 322 |
+
</div>
|
| 323 |
+
|
| 324 |
+
<div class="form-group">
|
| 325 |
+
<label>排序顺序</label>
|
| 326 |
+
<input type="number" id="toolSort" name="sortOrder" min="0" value="0" placeholder="数字越小排序越靠前" />
|
| 327 |
+
</div>
|
| 328 |
+
</div>
|
| 329 |
+
|
| 330 |
+
<div class="modal-footer">
|
| 331 |
+
<button type="button" class="btn btn-outline" onclick="closeToolModal()">取消</button>
|
| 332 |
+
<button type="submit" class="btn btn-primary">
|
| 333 |
+
<span id="toolSubmitText">保存</span>
|
| 334 |
+
</button>
|
| 335 |
+
</div>
|
| 336 |
+
</form>
|
| 337 |
+
</div>
|
| 338 |
+
</div>
|
| 339 |
+
|
| 340 |
+
<!-- 标签管理模态框 -->
|
| 341 |
+
<div id="tagModal" class="modal" style="display: none;">
|
| 342 |
+
<div class="modal-backdrop" onclick="closeTagModal()"></div>
|
| 343 |
+
<div class="modal-content" style="max-width: 500px;">
|
| 344 |
+
<div class="modal-header">
|
| 345 |
+
<h3 id="tagModalTitle">管理工具标签</h3>
|
| 346 |
+
<button onclick="closeTagModal()" style="background: none; border: none; font-size: 1.5rem; cursor: pointer;">×</button>
|
| 347 |
+
</div>
|
| 348 |
+
|
| 349 |
+
<div class="modal-body">
|
| 350 |
+
<div style="margin-bottom: 1rem;">
|
| 351 |
+
<label style="display: block; font-weight: 600; margin-bottom: 0.5rem;">当前标签</label>
|
| 352 |
+
<div id="currentTags" style="display: flex; flex-wrap: wrap; gap: 0.5rem; min-height: 2rem; padding: 0.5rem; border: 1px solid var(--light-2); border-radius: var(--border-radius);">
|
| 353 |
+
<span style="color: var(--dark-2); font-style: italic;">加载中...</span>
|
| 354 |
+
</div>
|
| 355 |
+
</div>
|
| 356 |
+
|
| 357 |
+
<div>
|
| 358 |
+
<label style="display: block; font-weight: 600; margin-bottom: 0.5rem;">添加标签</label>
|
| 359 |
+
<div style="display: flex; gap: 0.5rem;">
|
| 360 |
+
<select id="tagSelect" style="flex: 1; padding: 0.5rem; border: 1px solid var(--light-2); border-radius: var(--border-radius);">
|
| 361 |
+
<option value="">选择标签...</option>
|
| 362 |
+
</select>
|
| 363 |
+
<button class="btn btn-primary btn-sm" onclick="addToolTag()">
|
| 364 |
+
<i class="fas fa-plus"></i>
|
| 365 |
+
</button>
|
| 366 |
+
</div>
|
| 367 |
+
</div>
|
| 368 |
+
</div>
|
| 369 |
+
|
| 370 |
+
<div class="modal-footer">
|
| 371 |
+
<button type="button" class="btn btn-outline" onclick="closeTagModal()">关闭</button>
|
| 372 |
+
</div>
|
| 373 |
+
</div>
|
| 374 |
+
</div>
|
| 375 |
+
|
| 376 |
+
<!-- 图标选择器模态框 -->
|
| 377 |
+
<div id="iconSelectorModal" class="modal" style="display: none;">
|
| 378 |
+
<div class="modal-backdrop" onclick="closeIconSelector()"></div>
|
| 379 |
+
<div class="modal-content" style="max-width: 800px; max-height: 80vh;">
|
| 380 |
+
<div class="modal-header">
|
| 381 |
+
<h3>选择图标</h3>
|
| 382 |
+
<button onclick="closeIconSelector()" style="background: none; border: none; font-size: 1.5rem; cursor: pointer;">×</button>
|
| 383 |
+
</div>
|
| 384 |
+
|
| 385 |
+
<div class="modal-body" style="max-height: 60vh; overflow-y: auto;">
|
| 386 |
+
<div style="margin-bottom: 1rem;">
|
| 387 |
+
<input type="text" id="iconSearch" placeholder="搜索图标..."
|
| 388 |
+
style="width: 100%; padding: 0.75rem; border: 1px solid var(--light-2); border-radius: var(--border-radius);"
|
| 389 |
+
onkeyup="filterIcons()" />
|
| 390 |
+
</div>
|
| 391 |
+
|
| 392 |
+
<div id="iconGrid" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 1rem;">
|
| 393 |
+
<!-- 图标将通过JavaScript动态加载 -->
|
| 394 |
+
</div>
|
| 395 |
+
</div>
|
| 396 |
+
|
| 397 |
+
<div class="modal-footer">
|
| 398 |
+
<button type="button" class="btn btn-outline" onclick="closeIconSelector()">取消</button>
|
| 399 |
+
</div>
|
| 400 |
+
</div>
|
| 401 |
+
</div>
|
| 402 |
+
|
| 403 |
+
<script>
|
| 404 |
+
let currentView = 'table';
|
| 405 |
+
let isEditingTool = false;
|
| 406 |
+
let currentToolId = 0;
|
| 407 |
+
let currentToolName = '';
|
| 408 |
+
|
| 409 |
+
function changePage(page) {
|
| 410 |
+
const categoryFilter = document.getElementById('categoryFilter').value;
|
| 411 |
+
const searchInput = document.getElementById('searchInput').value;
|
| 412 |
+
|
| 413 |
+
let url = `@Url.Action("Index", "Tool")?page=${page}`;
|
| 414 |
+
if (categoryFilter !== '0') {
|
| 415 |
+
url += `&categoryId=${categoryFilter}`;
|
| 416 |
+
}
|
| 417 |
+
if (searchInput) {
|
| 418 |
+
url += `&search=${encodeURIComponent(searchInput)}`;
|
| 419 |
+
}
|
| 420 |
+
|
| 421 |
+
window.location.href = url;
|
| 422 |
+
}
|
| 423 |
+
|
| 424 |
+
function toggleView(view) {
|
| 425 |
+
currentView = view;
|
| 426 |
+
document.getElementById('tableView').style.display = view === 'table' ? 'block' : 'none';
|
| 427 |
+
|
| 428 |
+
document.getElementById('tableViewBtn').classList.toggle('btn-primary', view === 'table');
|
| 429 |
+
document.getElementById('tableViewBtn').classList.toggle('btn-outline', view !== 'table');
|
| 430 |
+
}
|
| 431 |
+
|
| 432 |
+
function filterTools() {
|
| 433 |
+
const categoryFilter = document.getElementById('categoryFilter').value;
|
| 434 |
+
const statusFilter = document.getElementById('statusFilter').value;
|
| 435 |
+
const searchInput = document.getElementById('searchInput').value.toLowerCase();
|
| 436 |
+
|
| 437 |
+
const tableRows = document.querySelectorAll('#toolsTableBody tr');
|
| 438 |
+
|
| 439 |
+
tableRows.forEach(row => {
|
| 440 |
+
const category = row.getAttribute('data-category');
|
| 441 |
+
const status = row.getAttribute('data-status');
|
| 442 |
+
const isHot = row.getAttribute('data-hot') === 'true';
|
| 443 |
+
const isNew = row.getAttribute('data-new') === 'true';
|
| 444 |
+
const isRecommended = row.getAttribute('data-recommended') === 'true';
|
| 445 |
+
|
| 446 |
+
const toolName = row.querySelector('.tool-name').textContent.toLowerCase();
|
| 447 |
+
|
| 448 |
+
let showRow = true;
|
| 449 |
+
|
| 450 |
+
// 分类筛选
|
| 451 |
+
if (categoryFilter !== '0' && category !== categoryFilter) {
|
| 452 |
+
showRow = false;
|
| 453 |
+
}
|
| 454 |
+
|
| 455 |
+
// 状态筛选
|
| 456 |
+
if (statusFilter) {
|
| 457 |
+
switch(statusFilter) {
|
| 458 |
+
case 'active':
|
| 459 |
+
if (status !== 'active') showRow = false;
|
| 460 |
+
break;
|
| 461 |
+
case 'inactive':
|
| 462 |
+
if (status !== 'inactive') showRow = false;
|
| 463 |
+
break;
|
| 464 |
+
case 'hot':
|
| 465 |
+
if (!isHot) showRow = false;
|
| 466 |
+
break;
|
| 467 |
+
case 'new':
|
| 468 |
+
if (!isNew) showRow = false;
|
| 469 |
+
break;
|
| 470 |
+
case 'recommended':
|
| 471 |
+
if (!isRecommended) showRow = false;
|
| 472 |
+
break;
|
| 473 |
+
}
|
| 474 |
+
}
|
| 475 |
+
|
| 476 |
+
// 搜索筛选
|
| 477 |
+
if (searchInput && !toolName.includes(searchInput)) {
|
| 478 |
+
showRow = false;
|
| 479 |
+
}
|
| 480 |
+
|
| 481 |
+
row.style.display = showRow ? '' : 'none';
|
| 482 |
+
});
|
| 483 |
+
}
|
| 484 |
+
|
| 485 |
+
function clearFilters() {
|
| 486 |
+
document.getElementById('categoryFilter').value = '0';
|
| 487 |
+
document.getElementById('statusFilter').value = '';
|
| 488 |
+
document.getElementById('searchInput').value = '';
|
| 489 |
+
filterTools();
|
| 490 |
+
}
|
| 491 |
+
|
| 492 |
+
function openToolModal(editMode = false) {
|
| 493 |
+
document.getElementById('toolModal').style.display = 'flex';
|
| 494 |
+
document.getElementById('toolModalTitle').textContent = editMode ? '编辑工具' : '添加工具';
|
| 495 |
+
document.getElementById('toolSubmitText').textContent = editMode ? '更新' : '保存';
|
| 496 |
+
isEditingTool = editMode;
|
| 497 |
+
|
| 498 |
+
if (!editMode) {
|
| 499 |
+
document.getElementById('toolForm').reset();
|
| 500 |
+
document.getElementById('toolId').value = '0';
|
| 501 |
+
}
|
| 502 |
+
}
|
| 503 |
+
|
| 504 |
+
function closeToolModal() {
|
| 505 |
+
document.getElementById('toolModal').style.display = 'none';
|
| 506 |
+
}
|
| 507 |
+
|
| 508 |
+
async function editTool(id) {
|
| 509 |
+
try {
|
| 510 |
+
const response = await fetch(`/Tool/Get?id=${id}`);
|
| 511 |
+
if (response.ok) {
|
| 512 |
+
const tool = await response.json();
|
| 513 |
+
openToolModal(true);
|
| 514 |
+
|
| 515 |
+
document.getElementById('toolId').value = tool.id;
|
| 516 |
+
document.getElementById('toolName').value = tool.name;
|
| 517 |
+
document.getElementById('toolDescription').value = tool.description || '';
|
| 518 |
+
document.getElementById('toolCategory').value = tool.categoryId;
|
| 519 |
+
document.getElementById('toolIcon').value = tool.icon || '';
|
| 520 |
+
document.getElementById('toolUrl').value = tool.url || '';
|
| 521 |
+
document.getElementById('toolImage').value = tool.image || '';
|
| 522 |
+
document.getElementById('toolIsHot').checked = tool.isHot;
|
| 523 |
+
document.getElementById('toolIsNew').checked = tool.isNew;
|
| 524 |
+
document.getElementById('toolIsRecommended').checked = tool.isRecommended;
|
| 525 |
+
document.getElementById('toolSort').value = tool.sortOrder;
|
| 526 |
+
}
|
| 527 |
+
} catch (error) {
|
| 528 |
+
console.error('获取工具信息失败:', error);
|
| 529 |
+
toolHub.showToast('获取工具信息失败', 'error');
|
| 530 |
+
}
|
| 531 |
+
}
|
| 532 |
+
|
| 533 |
+
async function submitTool(event) {
|
| 534 |
+
event.preventDefault();
|
| 535 |
+
|
| 536 |
+
const formData = new FormData(event.target);
|
| 537 |
+
const data = Object.fromEntries(formData.entries());
|
| 538 |
+
|
| 539 |
+
// 处理复选框
|
| 540 |
+
data.isHot = document.getElementById('toolIsHot').checked;
|
| 541 |
+
data.isNew = document.getElementById('toolIsNew').checked;
|
| 542 |
+
data.isRecommended = document.getElementById('toolIsRecommended').checked;
|
| 543 |
+
|
| 544 |
+
try {
|
| 545 |
+
const response = await fetch('/Tool/Save', {
|
| 546 |
+
method: 'POST',
|
| 547 |
+
headers: {
|
| 548 |
+
'Content-Type': 'application/json',
|
| 549 |
+
},
|
| 550 |
+
body: JSON.stringify(data)
|
| 551 |
+
});
|
| 552 |
+
|
| 553 |
+
if (response.ok) {
|
| 554 |
+
toolHub.showToast(isEditingTool ? '工具更新成功' : '工具添加成功', 'success');
|
| 555 |
+
closeToolModal();
|
| 556 |
+
setTimeout(() => location.reload(), 1000);
|
| 557 |
+
} else {
|
| 558 |
+
toolHub.showToast('操作失败,请重试', 'error');
|
| 559 |
+
}
|
| 560 |
+
} catch (error) {
|
| 561 |
+
console.error('提交失败:', error);
|
| 562 |
+
toolHub.showToast('操作失败,请重试', 'error');
|
| 563 |
+
}
|
| 564 |
+
}
|
| 565 |
+
|
| 566 |
+
async function toggleToolStatus(id, currentStatus) {
|
| 567 |
+
try {
|
| 568 |
+
const response = await fetch('/Tool/ToggleStatus', {
|
| 569 |
+
method: 'POST',
|
| 570 |
+
headers: {
|
| 571 |
+
'Content-Type': 'application/json',
|
| 572 |
+
},
|
| 573 |
+
body: JSON.stringify({ id: id, isActive: !currentStatus })
|
| 574 |
+
});
|
| 575 |
+
|
| 576 |
+
if (response.ok) {
|
| 577 |
+
toolHub.showToast('状态更新成功', 'success');
|
| 578 |
+
setTimeout(() => location.reload(), 1000);
|
| 579 |
+
} else {
|
| 580 |
+
toolHub.showToast('操作失败,请重试', 'error');
|
| 581 |
+
}
|
| 582 |
+
} catch (error) {
|
| 583 |
+
console.error('操作失败:', error);
|
| 584 |
+
toolHub.showToast('操作失败,请重试', 'error');
|
| 585 |
+
}
|
| 586 |
+
}
|
| 587 |
+
|
| 588 |
+
async function deleteTool(id, name) {
|
| 589 |
+
if (!confirm(`确定要删除工具"${name}"吗?此操作不可恢复。`)) {
|
| 590 |
+
return;
|
| 591 |
+
}
|
| 592 |
+
|
| 593 |
+
try {
|
| 594 |
+
const response = await fetch('/Tool/Delete', {
|
| 595 |
+
method: 'POST',
|
| 596 |
+
headers: {
|
| 597 |
+
'Content-Type': 'application/json',
|
| 598 |
+
},
|
| 599 |
+
body: JSON.stringify({ id: id })
|
| 600 |
+
});
|
| 601 |
+
|
| 602 |
+
if (response.ok) {
|
| 603 |
+
toolHub.showToast('工具删除成功', 'success');
|
| 604 |
+
setTimeout(() => location.reload(), 1000);
|
| 605 |
+
} else {
|
| 606 |
+
toolHub.showToast('删除失败,请重试', 'error');
|
| 607 |
+
}
|
| 608 |
+
} catch (error) {
|
| 609 |
+
console.error('删除失败:', error);
|
| 610 |
+
toolHub.showToast('删除失败,请重试', 'error');
|
| 611 |
+
}
|
| 612 |
+
}
|
| 613 |
+
|
| 614 |
+
// 标签管理功能
|
| 615 |
+
async function manageToolTags(toolId, toolName) {
|
| 616 |
+
currentToolId = toolId;
|
| 617 |
+
currentToolName = toolName;
|
| 618 |
+
|
| 619 |
+
document.getElementById('tagModal').style.display = 'flex';
|
| 620 |
+
document.getElementById('tagModalTitle').textContent = `管理工具标签 - ${toolName}`;
|
| 621 |
+
|
| 622 |
+
await loadToolTags();
|
| 623 |
+
await loadAvailableTags();
|
| 624 |
+
}
|
| 625 |
+
|
| 626 |
+
function closeTagModal() {
|
| 627 |
+
document.getElementById('tagModal').style.display = 'none';
|
| 628 |
+
}
|
| 629 |
+
|
| 630 |
+
async function loadToolTags() {
|
| 631 |
+
try {
|
| 632 |
+
const response = await fetch(`/Tag/GetToolTags?toolId=${currentToolId}`);
|
| 633 |
+
const tags = await response.json();
|
| 634 |
+
|
| 635 |
+
const currentTagsDiv = document.getElementById('currentTags');
|
| 636 |
+
if (tags.length === 0) {
|
| 637 |
+
currentTagsDiv.innerHTML = '<span style="color: var(--dark-2); font-style: italic;">暂无标签</span>';
|
| 638 |
+
} else {
|
| 639 |
+
currentTagsDiv.innerHTML = tags.map(tag => `
|
| 640 |
+
<span class="badge" style="background: ${tag.color || 'var(--primary)'}; color: white; display: flex; align-items: center; gap: 0.25rem;">
|
| 641 |
+
${tag.name}
|
| 642 |
+
<button onclick="removeToolTag(${tag.id})" style="background: none; border: none; color: white; cursor: pointer; font-size: 0.75rem;">
|
| 643 |
+
<i class="fas fa-times"></i>
|
| 644 |
+
</button>
|
| 645 |
+
</span>
|
| 646 |
+
`).join('');
|
| 647 |
+
}
|
| 648 |
+
} catch (error) {
|
| 649 |
+
console.error('加载工具标签失败:', error);
|
| 650 |
+
toolHub.showToast('加载工具标签失败', 'error');
|
| 651 |
+
}
|
| 652 |
+
}
|
| 653 |
+
|
| 654 |
+
async function loadAvailableTags() {
|
| 655 |
+
try {
|
| 656 |
+
const response = await fetch('/Tag/GetList');
|
| 657 |
+
const tags = await response.json();
|
| 658 |
+
|
| 659 |
+
const tagSelect = document.getElementById('tagSelect');
|
| 660 |
+
tagSelect.innerHTML = '<option value="">选择标签...</option>';
|
| 661 |
+
|
| 662 |
+
tags.forEach(tag => {
|
| 663 |
+
const option = document.createElement('option');
|
| 664 |
+
option.value = tag.id;
|
| 665 |
+
option.textContent = tag.name;
|
| 666 |
+
tagSelect.appendChild(option);
|
| 667 |
+
});
|
| 668 |
+
} catch (error) {
|
| 669 |
+
console.error('加载可用标签失败:', error);
|
| 670 |
+
toolHub.showToast('加载可用标签失败', 'error');
|
| 671 |
+
}
|
| 672 |
+
}
|
| 673 |
+
|
| 674 |
+
async function addToolTag() {
|
| 675 |
+
const tagId = document.getElementById('tagSelect').value;
|
| 676 |
+
if (!tagId) {
|
| 677 |
+
toolHub.showToast('请选择要添加的标签', 'warning');
|
| 678 |
+
return;
|
| 679 |
+
}
|
| 680 |
+
|
| 681 |
+
try {
|
| 682 |
+
const response = await fetch('/Tag/AddToolTag', {
|
| 683 |
+
method: 'POST',
|
| 684 |
+
headers: {
|
| 685 |
+
'Content-Type': 'application/json',
|
| 686 |
+
},
|
| 687 |
+
body: JSON.stringify({ toolId: currentToolId, tagId: parseInt(tagId) })
|
| 688 |
+
});
|
| 689 |
+
|
| 690 |
+
const result = await response.json();
|
| 691 |
+
if (result.success) {
|
| 692 |
+
toolHub.showToast('标签添加成功', 'success');
|
| 693 |
+
document.getElementById('tagSelect').value = '';
|
| 694 |
+
await loadToolTags();
|
| 695 |
+
} else {
|
| 696 |
+
toolHub.showToast(result.message || '添加失败', 'error');
|
| 697 |
+
}
|
| 698 |
+
} catch (error) {
|
| 699 |
+
console.error('添加标签失败:', error);
|
| 700 |
+
toolHub.showToast('添加标签失败', 'error');
|
| 701 |
+
}
|
| 702 |
+
}
|
| 703 |
+
|
| 704 |
+
async function removeToolTag(tagId) {
|
| 705 |
+
if (!confirm('确定要移除这个标签吗?')) {
|
| 706 |
+
return;
|
| 707 |
+
}
|
| 708 |
+
|
| 709 |
+
try {
|
| 710 |
+
const response = await fetch('/Tag/RemoveToolTag', {
|
| 711 |
+
method: 'POST',
|
| 712 |
+
headers: {
|
| 713 |
+
'Content-Type': 'application/json',
|
| 714 |
+
},
|
| 715 |
+
body: JSON.stringify({ toolId: currentToolId, tagId: tagId })
|
| 716 |
+
});
|
| 717 |
+
|
| 718 |
+
const result = await response.json();
|
| 719 |
+
if (result.success) {
|
| 720 |
+
toolHub.showToast('标签移除成功', 'success');
|
| 721 |
+
await loadToolTags();
|
| 722 |
+
} else {
|
| 723 |
+
toolHub.showToast('移除失败', 'error');
|
| 724 |
+
}
|
| 725 |
+
} catch (error) {
|
| 726 |
+
console.error('移除标签失败:', error);
|
| 727 |
+
toolHub.showToast('移除标签失败', 'error');
|
| 728 |
+
}
|
| 729 |
+
}
|
| 730 |
+
|
| 731 |
+
// 图标选择器功能
|
| 732 |
+
const iconList = [
|
| 733 |
+
// 文件类型图标
|
| 734 |
+
{ name: 'PDF文件', class: 'fas fa-file-pdf', category: '文件' },
|
| 735 |
+
{ name: 'Word文档', class: 'fas fa-file-word', category: '文件' },
|
| 736 |
+
{ name: 'Excel表格', class: 'fas fa-file-excel', category: '文件' },
|
| 737 |
+
{ name: 'PowerPoint', class: 'fas fa-file-powerpoint', category: '文件' },
|
| 738 |
+
{ name: '文本文件', class: 'fas fa-file-alt', category: '文件' },
|
| 739 |
+
{ name: '图片文件', class: 'fas fa-file-image', category: '文件' },
|
| 740 |
+
{ name: '压缩文件', class: 'fas fa-file-archive', category: '文件' },
|
| 741 |
+
{ name: '代码文件', class: 'fas fa-file-code', category: '文件' },
|
| 742 |
+
{ name: '音频文件', class: 'fas fa-file-audio', category: '文件' },
|
| 743 |
+
{ name: '视频文件', class: 'fas fa-file-video', category: '文件' },
|
| 744 |
+
|
| 745 |
+
// 工具类图标
|
| 746 |
+
{ name: '工具', class: 'fas fa-tools', category: '工具' },
|
| 747 |
+
{ name: '扳手', class: 'fas fa-wrench', category: '工具' },
|
| 748 |
+
{ name: '锤子', class: 'fas fa-hammer', category: '工具' },
|
| 749 |
+
{ name: '螺丝刀', class: 'fas fa-screwdriver', category: '工具' },
|
| 750 |
+
{ name: '齿轮', class: 'fas fa-cog', category: '工具' },
|
| 751 |
+
{ name: '设置', class: 'fas fa-cogs', category: '工具' },
|
| 752 |
+
|
| 753 |
+
// 媒体图标
|
| 754 |
+
{ name: '图片', class: 'fas fa-image', category: '媒体' },
|
| 755 |
+
{ name: '视频', class: 'fas fa-video', category: '媒体' },
|
| 756 |
+
{ name: '音乐', class: 'fas fa-music', category: '媒体' },
|
| 757 |
+
{ name: '相机', class: 'fas fa-camera', category: '媒体' },
|
| 758 |
+
{ name: '麦克风', class: 'fas fa-microphone', category: '媒体' },
|
| 759 |
+
{ name: '播放', class: 'fas fa-play', category: '媒体' },
|
| 760 |
+
{ name: '���停', class: 'fas fa-pause', category: '媒体' },
|
| 761 |
+
{ name: '停止', class: 'fas fa-stop', category: '媒体' },
|
| 762 |
+
|
| 763 |
+
// 网络图标
|
| 764 |
+
{ name: '网络', class: 'fas fa-network-wired', category: '网络' },
|
| 765 |
+
{ name: 'WiFi', class: 'fas fa-wifi', category: '网络' },
|
| 766 |
+
{ name: '链接', class: 'fas fa-link', category: '网络' },
|
| 767 |
+
{ name: '下载', class: 'fas fa-download', category: '网络' },
|
| 768 |
+
{ name: '上传', class: 'fas fa-upload', category: '网络' },
|
| 769 |
+
{ name: '云存储', class: 'fas fa-cloud', category: '网络' },
|
| 770 |
+
{ name: '服务器', class: 'fas fa-server', category: '网络' },
|
| 771 |
+
{ name: '数据库', class: 'fas fa-database', category: '网络' },
|
| 772 |
+
|
| 773 |
+
// 设计图标
|
| 774 |
+
{ name: '画笔', class: 'fas fa-paint-brush', category: '设计' },
|
| 775 |
+
{ name: '调色板', class: 'fas fa-palette', category: '设计' },
|
| 776 |
+
{ name: '设计', class: 'fas fa-drafting-compass', category: '设计' },
|
| 777 |
+
{ name: '图层', class: 'fas fa-layer-group', category: '设计' },
|
| 778 |
+
{ name: '裁剪', class: 'fas fa-crop', category: '设计' },
|
| 779 |
+
{ name: '滤镜', class: 'fas fa-magic', category: '设计' },
|
| 780 |
+
|
| 781 |
+
// 开发图标
|
| 782 |
+
{ name: '代码', class: 'fas fa-code', category: '开发' },
|
| 783 |
+
{ name: '终端', class: 'fas fa-terminal', category: '开发' },
|
| 784 |
+
{ name: '调试', class: 'fas fa-bug', category: '开发' },
|
| 785 |
+
{ name: 'Git', class: 'fab fa-git-alt', category: '开发' },
|
| 786 |
+
{ name: 'GitHub', class: 'fab fa-github', category: '开发' },
|
| 787 |
+
{ name: 'HTML', class: 'fab fa-html5', category: '开发' },
|
| 788 |
+
{ name: 'CSS', class: 'fab fa-css3-alt', category: '开发' },
|
| 789 |
+
{ name: 'JavaScript', class: 'fab fa-js-square', category: '开发' },
|
| 790 |
+
{ name: 'React', class: 'fab fa-react', category: '开发' },
|
| 791 |
+
{ name: 'Node.js', class: 'fab fa-node-js', category: '开发' },
|
| 792 |
+
{ name: 'Python', class: 'fab fa-python', category: '开发' },
|
| 793 |
+
{ name: 'Java', class: 'fab fa-java', category: '开发' },
|
| 794 |
+
{ name: 'PHP', class: 'fab fa-php', category: '开发' },
|
| 795 |
+
|
| 796 |
+
// 通用图标
|
| 797 |
+
{ name: '主页', class: 'fas fa-home', category: '通用' },
|
| 798 |
+
{ name: '用户', class: 'fas fa-user', category: '通用' },
|
| 799 |
+
{ name: '用户组', class: 'fas fa-users', category: '通用' },
|
| 800 |
+
{ name: '设置', class: 'fas fa-cog', category: '通用' },
|
| 801 |
+
{ name: '搜索', class: 'fas fa-search', category: '通用' },
|
| 802 |
+
{ name: '编辑', class: 'fas fa-edit', category: '通用' },
|
| 803 |
+
{ name: '删除', class: 'fas fa-trash', category: '通用' },
|
| 804 |
+
{ name: '添加', class: 'fas fa-plus', category: '通用' },
|
| 805 |
+
{ name: '保存', class: 'fas fa-save', category: '通用' },
|
| 806 |
+
{ name: '关闭', class: 'fas fa-times', category: '通用' },
|
| 807 |
+
{ name: '检查', class: 'fas fa-check', category: '通用' },
|
| 808 |
+
{ name: '警告', class: 'fas fa-exclamation-triangle', category: '通用' },
|
| 809 |
+
{ name: '信息', class: 'fas fa-info-circle', category: '通用' },
|
| 810 |
+
{ name: '问号', class: 'fas fa-question-circle', category: '通用' },
|
| 811 |
+
{ name: '星标', class: 'fas fa-star', category: '通用' },
|
| 812 |
+
{ name: '心形', class: 'fas fa-heart', category: '通用' },
|
| 813 |
+
{ name: '点赞', class: 'fas fa-thumbs-up', category: '通用' },
|
| 814 |
+
{ name: '分享', class: 'fas fa-share', category: '通用' },
|
| 815 |
+
{ name: '邮件', class: 'fas fa-envelope', category: '通用' },
|
| 816 |
+
{ name: '电话', class: 'fas fa-phone', category: '通用' },
|
| 817 |
+
{ name: '日历', class: 'fas fa-calendar', category: '通用' },
|
| 818 |
+
{ name: '时钟', class: 'fas fa-clock', category: '通用' },
|
| 819 |
+
{ name: '地图', class: 'fas fa-map-marker-alt', category: '通用' },
|
| 820 |
+
{ name: '购物车', class: 'fas fa-shopping-cart', category: '通用' },
|
| 821 |
+
{ name: '钱包', class: 'fas fa-wallet', category: '通用' },
|
| 822 |
+
{ name: '信用卡', class: 'fas fa-credit-card', category: '通用' },
|
| 823 |
+
{ name: '锁', class: 'fas fa-lock', category: '通用' },
|
| 824 |
+
{ name: '钥匙', class: 'fas fa-key', category: '通用' },
|
| 825 |
+
{ name: '盾牌', class: 'fas fa-shield-alt', category: '通用' },
|
| 826 |
+
{ name: '眼睛', class: 'fas fa-eye', category: '通用' },
|
| 827 |
+
{ name: '眼睛斜杠', class: 'fas fa-eye-slash', category: '通用' },
|
| 828 |
+
{ name: '打印', class: 'fas fa-print', category: '通用' },
|
| 829 |
+
{ name: '扫描', class: 'fas fa-scanner', category: '通用' },
|
| 830 |
+
{ name: '传真', class: 'fas fa-fax', category: '通用' },
|
| 831 |
+
{ name: '计算器', class: 'fas fa-calculator', category: '通用' },
|
| 832 |
+
{ name: '图表', class: 'fas fa-chart-bar', category: '通用' },
|
| 833 |
+
{ name: '饼图', class: 'fas fa-chart-pie', category: '通用' },
|
| 834 |
+
{ name: '折线图', class: 'fas fa-chart-line', category: '通用' },
|
| 835 |
+
{ name: '表格', class: 'fas fa-table', category: '通用' },
|
| 836 |
+
{ name: '列表', class: 'fas fa-list', category: '通用' },
|
| 837 |
+
{ name: '网格', class: 'fas fa-th', category: '通用' },
|
| 838 |
+
{ name: '标签', class: 'fas fa-tags', category: '通用' },
|
| 839 |
+
{ name: '书签', class: 'fas fa-bookmark', category: '通用' },
|
| 840 |
+
{ name: '文件夹', class: 'fas fa-folder', category: '通用' },
|
| 841 |
+
{ name: '文件', class: 'fas fa-file', category: '通用' },
|
| 842 |
+
{ name: '剪贴板', class: 'fas fa-clipboard', category: '通用' },
|
| 843 |
+
{ name: '复制', class: 'fas fa-copy', category: '通用' },
|
| 844 |
+
{ name: '粘贴', class: 'fas fa-paste', category: '通用' },
|
| 845 |
+
{ name: '剪切', class: 'fas fa-cut', category: '通用' },
|
| 846 |
+
{ name: '撤销', class: 'fas fa-undo', category: '通用' },
|
| 847 |
+
{ name: '重做', class: 'fas fa-redo', category: '通用' },
|
| 848 |
+
{ name: '刷新', class: 'fas fa-sync', category: '通用' },
|
| 849 |
+
{ name: '旋转', class: 'fas fa-sync-alt', category: '通用' },
|
| 850 |
+
{ name: '箭头', class: 'fas fa-arrow-right', category: '通用' },
|
| 851 |
+
{ name: '左箭头', class: 'fas fa-arrow-left', category: '通用' },
|
| 852 |
+
{ name: '上箭头', class: 'fas fa-arrow-up', category: '通用' },
|
| 853 |
+
{ name: '下箭头', class: 'fas fa-arrow-down', category: '通用' },
|
| 854 |
+
{ name: '外部链接', class: 'fas fa-external-link-alt', category: '通用' },
|
| 855 |
+
{ name: '返回', class: 'fas fa-arrow-circle-left', category: '通用' },
|
| 856 |
+
{ name: '前进', class: 'fas fa-arrow-circle-right', category: '通用' },
|
| 857 |
+
{ name: '展开', class: 'fas fa-chevron-down', category: '通用' },
|
| 858 |
+
{ name: '收起', class: 'fas fa-chevron-up', category: '通用' },
|
| 859 |
+
{ name: '菜单', class: 'fas fa-bars', category: '通用' },
|
| 860 |
+
{ name: '汉堡菜单', class: 'fas fa-hamburger', category: '通用' },
|
| 861 |
+
{ name: '更多', class: 'fas fa-ellipsis-h', category: '通用' },
|
| 862 |
+
{ name: '垂直更多', class: 'fas fa-ellipsis-v', category: '通用' }
|
| 863 |
+
];
|
| 864 |
+
|
| 865 |
+
function openIconSelector() {
|
| 866 |
+
document.getElementById('iconSelectorModal').style.display = 'flex';
|
| 867 |
+
loadIconGrid();
|
| 868 |
+
}
|
| 869 |
+
|
| 870 |
+
function closeIconSelector() {
|
| 871 |
+
document.getElementById('iconSelectorModal').style.display = 'none';
|
| 872 |
+
document.getElementById('iconSearch').value = '';
|
| 873 |
+
}
|
| 874 |
+
|
| 875 |
+
function loadIconGrid() {
|
| 876 |
+
const iconGrid = document.getElementById('iconGrid');
|
| 877 |
+
iconGrid.innerHTML = '';
|
| 878 |
+
|
| 879 |
+
iconList.forEach(icon => {
|
| 880 |
+
const iconDiv = document.createElement('div');
|
| 881 |
+
iconDiv.className = 'icon-item';
|
| 882 |
+
iconDiv.style.cssText = `
|
| 883 |
+
display: flex;
|
| 884 |
+
flex-direction: column;
|
| 885 |
+
align-items: center;
|
| 886 |
+
padding: 1rem;
|
| 887 |
+
border: 1px solid var(--light-2);
|
| 888 |
+
border-radius: var(--border-radius);
|
| 889 |
+
cursor: pointer;
|
| 890 |
+
transition: all 0.2s;
|
| 891 |
+
background: white;
|
| 892 |
+
`;
|
| 893 |
+
iconDiv.onmouseover = () => {
|
| 894 |
+
iconDiv.style.borderColor = 'var(--primary)';
|
| 895 |
+
iconDiv.style.backgroundColor = 'var(--light-1)';
|
| 896 |
+
};
|
| 897 |
+
iconDiv.onmouseout = () => {
|
| 898 |
+
iconDiv.style.borderColor = 'var(--light-2)';
|
| 899 |
+
iconDiv.style.backgroundColor = 'white';
|
| 900 |
+
};
|
| 901 |
+
iconDiv.onclick = () => selectIcon(icon.class);
|
| 902 |
+
|
| 903 |
+
iconDiv.innerHTML = `
|
| 904 |
+
<i class="${icon.class}" style="font-size: 2rem; color: var(--primary); margin-bottom: 0.5rem;"></i>
|
| 905 |
+
<div style="font-size: 0.75rem; text-align: center; color: var(--dark-2);">${icon.name}</div>
|
| 906 |
+
<div style="font-size: 0.625rem; text-align: center; color: var(--dark-3); margin-top: 0.25rem;">${icon.class}</div>
|
| 907 |
+
`;
|
| 908 |
+
|
| 909 |
+
iconGrid.appendChild(iconDiv);
|
| 910 |
+
});
|
| 911 |
+
}
|
| 912 |
+
|
| 913 |
+
function selectIcon(iconClass) {
|
| 914 |
+
document.getElementById('toolIcon').value = iconClass;
|
| 915 |
+
closeIconSelector();
|
| 916 |
+
toolHub.showToast('图标已选择', 'success');
|
| 917 |
+
}
|
| 918 |
+
|
| 919 |
+
function filterIcons() {
|
| 920 |
+
const searchTerm = document.getElementById('iconSearch').value.toLowerCase();
|
| 921 |
+
const iconItems = document.querySelectorAll('.icon-item');
|
| 922 |
+
|
| 923 |
+
iconItems.forEach(item => {
|
| 924 |
+
const iconName = item.querySelector('div').textContent.toLowerCase();
|
| 925 |
+
const iconClass = item.querySelector('div:last-child').textContent.toLowerCase();
|
| 926 |
+
|
| 927 |
+
if (iconName.includes(searchTerm) || iconClass.includes(searchTerm)) {
|
| 928 |
+
item.style.display = 'flex';
|
| 929 |
+
} else {
|
| 930 |
+
item.style.display = 'none';
|
| 931 |
+
}
|
| 932 |
+
});
|
| 933 |
+
}
|
| 934 |
+
</script>
|
Views/Category/Index.cshtml
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@model List<ToolHub.Models.Category>
|
| 2 |
+
@{
|
| 3 |
+
ViewData["Title"] = "工具分类管理";
|
| 4 |
+
Layout = "_AdminLayout";
|
| 5 |
+
}
|
| 6 |
+
|
| 7 |
+
<!-- 页面头部 -->
|
| 8 |
+
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;">
|
| 9 |
+
<div>
|
| 10 |
+
<h2 style="font-size: 1.5rem; font-weight: 700; margin: 0; color: var(--dark);">工具分类管理</h2>
|
| 11 |
+
<p style="color: var(--dark-2); margin: 0.5rem 0 0;">管理工具分类,组织和归类各种工具</p>
|
| 12 |
+
</div>
|
| 13 |
+
<button class="btn btn-primary" onclick="openCategoryModal()">
|
| 14 |
+
<i class="fas fa-plus"></i>
|
| 15 |
+
添加分类
|
| 16 |
+
</button>
|
| 17 |
+
</div>
|
| 18 |
+
|
| 19 |
+
<!-- 分类列表 -->
|
| 20 |
+
<div class="data-table">
|
| 21 |
+
<table>
|
| 22 |
+
<thead>
|
| 23 |
+
<tr>
|
| 24 |
+
<th>分类名称</th>
|
| 25 |
+
<th>描述</th>
|
| 26 |
+
<th>图标</th>
|
| 27 |
+
<th>颜色</th>
|
| 28 |
+
<th>排序</th>
|
| 29 |
+
<th>工具数量</th>
|
| 30 |
+
<th>状态</th>
|
| 31 |
+
<th>操作</th>
|
| 32 |
+
</tr>
|
| 33 |
+
</thead>
|
| 34 |
+
<tbody>
|
| 35 |
+
@foreach (var category in Model)
|
| 36 |
+
{
|
| 37 |
+
<tr>
|
| 38 |
+
<td>
|
| 39 |
+
<div style="display: flex; align-items: center;">
|
| 40 |
+
@if (!string.IsNullOrEmpty(category.Icon))
|
| 41 |
+
{
|
| 42 |
+
<i class="@category.Icon" style="margin-right: 0.75rem; color: @(category.Color ?? "var(--primary)"); font-size: 1.25rem;"></i>
|
| 43 |
+
}
|
| 44 |
+
<strong>@category.Name</strong>
|
| 45 |
+
</div>
|
| 46 |
+
</td>
|
| 47 |
+
<td style="max-width: 200px;">
|
| 48 |
+
<span style="color: var(--dark-2);">@(category.Description ?? "暂无描述")</span>
|
| 49 |
+
</td>
|
| 50 |
+
<td>
|
| 51 |
+
<code style="background: var(--light-1); padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.75rem;">
|
| 52 |
+
@(category.Icon ?? "无")
|
| 53 |
+
</code>
|
| 54 |
+
</td>
|
| 55 |
+
<td>
|
| 56 |
+
@if (!string.IsNullOrEmpty(category.Color))
|
| 57 |
+
{
|
| 58 |
+
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
| 59 |
+
<div style="width: 20px; height: 20px; background: @category.Color; border-radius: 4px; border: 1px solid var(--light-2);"></div>
|
| 60 |
+
<code style="font-size: 0.75rem;">@category.Color</code>
|
| 61 |
+
</div>
|
| 62 |
+
}
|
| 63 |
+
else
|
| 64 |
+
{
|
| 65 |
+
<span style="color: var(--dark-2);">默认</span>
|
| 66 |
+
}
|
| 67 |
+
</td>
|
| 68 |
+
<td>
|
| 69 |
+
<span class="badge badge-secondary">@category.SortOrder</span>
|
| 70 |
+
</td>
|
| 71 |
+
<td>
|
| 72 |
+
<span class="badge badge-primary">@category.Tools?.Count</span>
|
| 73 |
+
</td>
|
| 74 |
+
<td>
|
| 75 |
+
@if (category.IsActive)
|
| 76 |
+
{
|
| 77 |
+
<span class="badge badge-success">启用</span>
|
| 78 |
+
}
|
| 79 |
+
else
|
| 80 |
+
{
|
| 81 |
+
<span class="badge badge-secondary">禁用</span>
|
| 82 |
+
}
|
| 83 |
+
</td>
|
| 84 |
+
<td>
|
| 85 |
+
<div style="display: flex; gap: 0.5rem;">
|
| 86 |
+
<button class="btn btn-outline btn-sm" onclick="editCategory(@category.Id, '@category.Name', '@category.Description', '@category.Icon', '@category.Color', @category.SortOrder)">
|
| 87 |
+
<i class="fas fa-edit"></i>
|
| 88 |
+
</button>
|
| 89 |
+
<button class="btn btn-outline btn-sm" onclick="toggleCategoryStatus(@category.Id, @category.IsActive.ToString().ToLower())" style="color: @(category.IsActive ? "var(--warning)" : "var(--success)");">
|
| 90 |
+
<i class="fas fa-@(category.IsActive ? "pause" : "play")"></i>
|
| 91 |
+
</button>
|
| 92 |
+
<button class="btn btn-outline btn-sm" onclick="deleteCategory(@category.Id, '@category.Name')" style="color: var(--danger);">
|
| 93 |
+
<i class="fas fa-trash"></i>
|
| 94 |
+
</button>
|
| 95 |
+
</div>
|
| 96 |
+
</td>
|
| 97 |
+
</tr>
|
| 98 |
+
}
|
| 99 |
+
</tbody>
|
| 100 |
+
</table>
|
| 101 |
+
|
| 102 |
+
@if (!Model.Any())
|
| 103 |
+
{
|
| 104 |
+
<div style="padding: 3rem; text-align: center; color: var(--dark-2);">
|
| 105 |
+
<i class="fas fa-tags" style="font-size: 3rem; margin-bottom: 1rem; display: block; opacity: 0.3;"></i>
|
| 106 |
+
<h3 style="margin-bottom: 0.5rem;">暂无分类</h3>
|
| 107 |
+
<p style="margin-bottom: 1.5rem;">还没有创建任何工具分类</p>
|
| 108 |
+
<button class="btn btn-primary" onclick="openCategoryModal()">
|
| 109 |
+
<i class="fas fa-plus"></i>
|
| 110 |
+
创建第一个分类
|
| 111 |
+
</button>
|
| 112 |
+
</div>
|
| 113 |
+
}
|
| 114 |
+
</div>
|
| 115 |
+
|
| 116 |
+
<!-- 分类模态框 -->
|
| 117 |
+
<div id="categoryModal" class="modal" style="display: none;">
|
| 118 |
+
<div class="modal-backdrop" onclick="closeCategoryModal()"></div>
|
| 119 |
+
<div class="modal-content" style="max-width: 500px;">
|
| 120 |
+
<div class="modal-header">
|
| 121 |
+
<h3 id="modalTitle">添加分类</h3>
|
| 122 |
+
<button onclick="closeCategoryModal()" style="background: none; border: none; font-size: 1.5rem; cursor: pointer;">×</button>
|
| 123 |
+
</div>
|
| 124 |
+
|
| 125 |
+
<form id="categoryForm" onsubmit="submitCategory(event)">
|
| 126 |
+
<input type="hidden" id="categoryId" name="id" value="0" />
|
| 127 |
+
|
| 128 |
+
<div class="form-group" style="margin-bottom: 1.5rem;">
|
| 129 |
+
<label style="display: block; font-weight: 600; margin-bottom: 0.5rem;">分类名称 *</label>
|
| 130 |
+
<input type="text" id="categoryName" name="name" required
|
| 131 |
+
style="width: 100%; padding: 0.75rem; border: 1px solid var(--light-2); border-radius: var(--border-radius);"
|
| 132 |
+
placeholder="请输入分类名称" />
|
| 133 |
+
</div>
|
| 134 |
+
|
| 135 |
+
<div class="form-group" style="margin-bottom: 1.5rem;">
|
| 136 |
+
<label style="display: block; font-weight: 600; margin-bottom: 0.5rem;">描述</label>
|
| 137 |
+
<textarea id="categoryDescription" name="description" rows="3"
|
| 138 |
+
style="width: 100%; padding: 0.75rem; border: 1px solid var(--light-2); border-radius: var(--border-radius); resize: vertical;"
|
| 139 |
+
placeholder="请输入分类描述"></textarea>
|
| 140 |
+
</div>
|
| 141 |
+
|
| 142 |
+
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1.5rem;">
|
| 143 |
+
<div class="form-group">
|
| 144 |
+
<label style="display: block; font-weight: 600; margin-bottom: 0.5rem;">图标类名</label>
|
| 145 |
+
<input type="text" id="categoryIcon" name="icon"
|
| 146 |
+
style="width: 100%; padding: 0.75rem; border: 1px solid var(--light-2); border-radius: var(--border-radius);"
|
| 147 |
+
placeholder="如: fas fa-file-pdf" />
|
| 148 |
+
<small style="color: var(--dark-2); font-size: 0.75rem;">使用 Font Awesome 图标类名</small>
|
| 149 |
+
</div>
|
| 150 |
+
|
| 151 |
+
<div class="form-group">
|
| 152 |
+
<label style="display: block; font-weight: 600; margin-bottom: 0.5rem;">颜色</label>
|
| 153 |
+
<input type="color" id="categoryColor" name="color" value="#165DFF"
|
| 154 |
+
style="width: 100%; padding: 0.5rem; border: 1px solid var(--light-2); border-radius: var(--border-radius); height: 3rem;" />
|
| 155 |
+
</div>
|
| 156 |
+
</div>
|
| 157 |
+
|
| 158 |
+
<div class="form-group" style="margin-bottom: 1.5rem;">
|
| 159 |
+
<label style="display: block; font-weight: 600; margin-bottom: 0.5rem;">排序顺序</label>
|
| 160 |
+
<input type="number" id="categorySort" name="sortOrder" min="0" value="0"
|
| 161 |
+
style="width: 100%; padding: 0.75rem; border: 1px solid var(--light-2); border-radius: var(--border-radius);"
|
| 162 |
+
placeholder="数字越小排序越靠前" />
|
| 163 |
+
</div>
|
| 164 |
+
|
| 165 |
+
<div class="modal-footer">
|
| 166 |
+
<button type="button" class="btn btn-outline" onclick="closeCategoryModal()">取消</button>
|
| 167 |
+
<button type="submit" class="btn btn-primary">
|
| 168 |
+
<span id="submitText">保存</span>
|
| 169 |
+
</button>
|
| 170 |
+
</div>
|
| 171 |
+
</form>
|
| 172 |
+
</div>
|
| 173 |
+
</div>
|
| 174 |
+
|
| 175 |
+
<style>
|
| 176 |
+
.modal {
|
| 177 |
+
position: fixed;
|
| 178 |
+
top: 0;
|
| 179 |
+
left: 0;
|
| 180 |
+
right: 0;
|
| 181 |
+
bottom: 0;
|
| 182 |
+
z-index: 10000;
|
| 183 |
+
display: flex;
|
| 184 |
+
align-items: center;
|
| 185 |
+
justify-content: center;
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
.modal-backdrop {
|
| 189 |
+
position: absolute;
|
| 190 |
+
top: 0;
|
| 191 |
+
left: 0;
|
| 192 |
+
right: 0;
|
| 193 |
+
bottom: 0;
|
| 194 |
+
background: rgba(0, 0, 0, 0.5);
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
.modal-content {
|
| 198 |
+
background: white;
|
| 199 |
+
border-radius: var(--border-radius);
|
| 200 |
+
position: relative;
|
| 201 |
+
z-index: 2;
|
| 202 |
+
max-height: 90vh;
|
| 203 |
+
overflow-y: auto;
|
| 204 |
+
width: 90%;
|
| 205 |
+
max-width: 600px;
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
.modal-header {
|
| 209 |
+
padding: 1.5rem;
|
| 210 |
+
border-bottom: 1px solid var(--light-2);
|
| 211 |
+
display: flex;
|
| 212 |
+
justify-content: space-between;
|
| 213 |
+
align-items: center;
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
.modal-header h3 {
|
| 217 |
+
margin: 0;
|
| 218 |
+
font-size: 1.25rem;
|
| 219 |
+
font-weight: 600;
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
.modal-footer {
|
| 223 |
+
padding: 1.5rem;
|
| 224 |
+
border-top: 1px solid var(--light-2);
|
| 225 |
+
display: flex;
|
| 226 |
+
justify-content: flex-end;
|
| 227 |
+
gap: 1rem;
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
.form-group input:focus,
|
| 231 |
+
.form-group textarea:focus {
|
| 232 |
+
border-color: var(--primary);
|
| 233 |
+
outline: none;
|
| 234 |
+
box-shadow: 0 0 0 3px rgba(22, 93, 255, 0.1);
|
| 235 |
+
}
|
| 236 |
+
</style>
|
| 237 |
+
|
| 238 |
+
<script>
|
| 239 |
+
let isEditing = false;
|
| 240 |
+
|
| 241 |
+
function openCategoryModal(editMode = false) {
|
| 242 |
+
document.getElementById('categoryModal').style.display = 'flex';
|
| 243 |
+
document.getElementById('modalTitle').textContent = editMode ? '编辑分类' : '添加分类';
|
| 244 |
+
document.getElementById('submitText').textContent = editMode ? '更新' : '保存';
|
| 245 |
+
isEditing = editMode;
|
| 246 |
+
|
| 247 |
+
if (!editMode) {
|
| 248 |
+
document.getElementById('categoryForm').reset();
|
| 249 |
+
document.getElementById('categoryId').value = '0';
|
| 250 |
+
document.getElementById('categoryColor').value = '#165DFF';
|
| 251 |
+
}
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
function closeCategoryModal() {
|
| 255 |
+
document.getElementById('categoryModal').style.display = 'none';
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
function editCategory(id, name, description, icon, color, sortOrder) {
|
| 259 |
+
openCategoryModal(true);
|
| 260 |
+
document.getElementById('categoryId').value = id;
|
| 261 |
+
document.getElementById('categoryName').value = name;
|
| 262 |
+
document.getElementById('categoryDescription').value = description || '';
|
| 263 |
+
document.getElementById('categoryIcon').value = icon || '';
|
| 264 |
+
document.getElementById('categoryColor').value = color || '#165DFF';
|
| 265 |
+
document.getElementById('categorySort').value = sortOrder;
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
async function submitCategory(event) {
|
| 269 |
+
event.preventDefault();
|
| 270 |
+
|
| 271 |
+
const formData = new FormData(event.target);
|
| 272 |
+
const data = Object.fromEntries(formData.entries());
|
| 273 |
+
|
| 274 |
+
try {
|
| 275 |
+
const response = await fetch('/Category/Save', {
|
| 276 |
+
method: 'POST',
|
| 277 |
+
headers: {
|
| 278 |
+
'Content-Type': 'application/json',
|
| 279 |
+
},
|
| 280 |
+
body: JSON.stringify(data)
|
| 281 |
+
});
|
| 282 |
+
|
| 283 |
+
if (response.ok) {
|
| 284 |
+
toolHub.showToast(isEditing ? '分类更新成功' : '分类添加成功', 'success');
|
| 285 |
+
closeCategoryModal();
|
| 286 |
+
setTimeout(() => location.reload(), 1000);
|
| 287 |
+
} else {
|
| 288 |
+
toolHub.showToast('操作失败,请重试', 'error');
|
| 289 |
+
}
|
| 290 |
+
} catch (error) {
|
| 291 |
+
console.error('提交失败:', error);
|
| 292 |
+
toolHub.showToast('操作失败,请重试', 'error');
|
| 293 |
+
}
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
async function toggleCategoryStatus(id, currentStatus) {
|
| 297 |
+
try {
|
| 298 |
+
const response = await fetch('/Category/ToggleStatus', {
|
| 299 |
+
method: 'POST',
|
| 300 |
+
headers: {
|
| 301 |
+
'Content-Type': 'application/json',
|
| 302 |
+
},
|
| 303 |
+
body: JSON.stringify({ id: id, isActive: !currentStatus })
|
| 304 |
+
});
|
| 305 |
+
|
| 306 |
+
if (response.ok) {
|
| 307 |
+
toolHub.showToast('状态更新成功', 'success');
|
| 308 |
+
setTimeout(() => location.reload(), 1000);
|
| 309 |
+
} else {
|
| 310 |
+
toolHub.showToast('操作失败,请重试', 'error');
|
| 311 |
+
}
|
| 312 |
+
} catch (error) {
|
| 313 |
+
console.error('操作失败:', error);
|
| 314 |
+
toolHub.showToast('操作失败,请重试', 'error');
|
| 315 |
+
}
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
async function deleteCategory(id, name) {
|
| 319 |
+
if (!confirm(`确定要删除分类"${name}"吗?\n注意:删除分类会影响该分类下的所有工具。`)) {
|
| 320 |
+
return;
|
| 321 |
+
}
|
| 322 |
+
|
| 323 |
+
try {
|
| 324 |
+
const response = await fetch('/Category/Delete', {
|
| 325 |
+
method: 'POST',
|
| 326 |
+
headers: {
|
| 327 |
+
'Content-Type': 'application/json',
|
| 328 |
+
},
|
| 329 |
+
body: JSON.stringify({ id: id })
|
| 330 |
+
});
|
| 331 |
+
|
| 332 |
+
if (response.ok) {
|
| 333 |
+
toolHub.showToast('分类删除成功', 'success');
|
| 334 |
+
setTimeout(() => location.reload(), 1000);
|
| 335 |
+
} else {
|
| 336 |
+
toolHub.showToast('删除失败,可能该分类下还有工具', 'error');
|
| 337 |
+
}
|
| 338 |
+
} catch (error) {
|
| 339 |
+
console.error('删除失败:', error);
|
| 340 |
+
toolHub.showToast('删除失败,请重试', 'error');
|
| 341 |
+
}
|
| 342 |
+
}
|
| 343 |
+
</script>
|
Views/Home/Index.cshtml
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@{
|
| 2 |
+
ViewData["Title"] = "首页";
|
| 3 |
+
var categories = ViewBag.Categories as List<ToolHub.Models.Category> ?? new List<ToolHub.Models.Category>();
|
| 4 |
+
var hotTools = ViewBag.HotTools as List<ToolHub.Models.Tool> ?? new List<ToolHub.Models.Tool>();
|
| 5 |
+
var newTools = ViewBag.NewTools as List<ToolHub.Models.Tool> ?? new List<ToolHub.Models.Tool>();
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
<!-- 页面头部 -->
|
| 9 |
+
<div class="container" style="padding: 2rem 0 1rem;">
|
| 10 |
+
<div style="text-align: center; margin-bottom: 2rem;">
|
| 11 |
+
<h1 style="font-size: 2rem; font-weight: 700; margin-bottom: 0.5rem; color: var(--dark);">
|
| 12 |
+
<span class="text-gradient">ToolHub</span> 工具平台
|
| 13 |
+
</h1>
|
| 14 |
+
<p style="color: var(--dark-2); font-size: 1rem;">发现和使用最好的在线工具</p>
|
| 15 |
+
</div>
|
| 16 |
+
|
| 17 |
+
<!-- 快速工具入口 -->
|
| 18 |
+
<div style="display: flex; justify-content: center; flex-wrap: wrap; gap: 1rem; margin-bottom: 2rem;">
|
| 19 |
+
<a href="@Url.Action("Tools", "Home", new { categoryId = 1 })" class="quick-tool">
|
| 20 |
+
<i class="fas fa-file-pdf" style="color: var(--danger);"></i>
|
| 21 |
+
<span>PDF转换</span>
|
| 22 |
+
</a>
|
| 23 |
+
<a href="@Url.Action("Tools", "Home", new { categoryId = 2 })" class="quick-tool">
|
| 24 |
+
<i class="fas fa-image" style="color: var(--primary);"></i>
|
| 25 |
+
<span>图片工具</span>
|
| 26 |
+
</a>
|
| 27 |
+
<a href="@Url.Action("Tools", "Home", new { categoryId = 5 })" class="quick-tool">
|
| 28 |
+
<i class="fas fa-calculator" style="color: var(--success);"></i>
|
| 29 |
+
<span>数据换算</span>
|
| 30 |
+
</a>
|
| 31 |
+
<a href="@Url.Action("Tools", "Home")" class="quick-tool">
|
| 32 |
+
<i class="fas fa-ellipsis-h" style="color: var(--dark-2);"></i>
|
| 33 |
+
<span>更多工具</span>
|
| 34 |
+
</a>
|
| 35 |
+
</div>
|
| 36 |
+
</div>
|
| 37 |
+
|
| 38 |
+
<!-- 用户最近使用工具 -->
|
| 39 |
+
@if (User.Identity?.IsAuthenticated == true)
|
| 40 |
+
{
|
| 41 |
+
<section class="container mb-8">
|
| 42 |
+
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;">
|
| 43 |
+
<h3 style="font-size: 1.25rem; font-weight: 700; color: var(--dark);">
|
| 44 |
+
<i class="fas fa-clock" style="color: var(--warning); margin-right: 0.5rem;"></i>
|
| 45 |
+
最近使用
|
| 46 |
+
</h3>
|
| 47 |
+
<a href="@Url.Action("Tools", "Home")" class="btn btn-outline btn-sm">
|
| 48 |
+
查看更多
|
| 49 |
+
</a>
|
| 50 |
+
</div>
|
| 51 |
+
|
| 52 |
+
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6">
|
| 53 |
+
<!-- 这里将通过AJAX动态加载最近使用的工具 -->
|
| 54 |
+
<div id="recent-tools-container">
|
| 55 |
+
<div style="grid-column: 1 / -1; text-align: center; padding: 2rem; color: var(--dark-2);">
|
| 56 |
+
<i class="fas fa-clock" style="font-size: 2rem; margin-bottom: 1rem; display: block; opacity: 0.5;"></i>
|
| 57 |
+
暂无最近使用的工具
|
| 58 |
+
</div>
|
| 59 |
+
</div>
|
| 60 |
+
</div>
|
| 61 |
+
</section>
|
| 62 |
+
|
| 63 |
+
<!-- 用户收藏工具 -->
|
| 64 |
+
<section class="container mb-8">
|
| 65 |
+
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;">
|
| 66 |
+
<h3 style="font-size: 1.25rem; font-weight: 700; color: var(--dark);">
|
| 67 |
+
<i class="fas fa-heart" style="color: var(--danger); margin-right: 0.5rem;"></i>
|
| 68 |
+
我的收藏
|
| 69 |
+
</h3>
|
| 70 |
+
<a href="@Url.Action("Tools", "Home")" class="btn btn-outline btn-sm">
|
| 71 |
+
查看更多
|
| 72 |
+
</a>
|
| 73 |
+
</div>
|
| 74 |
+
|
| 75 |
+
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6">
|
| 76 |
+
<!-- 这里将通过AJAX动态加载收藏的工具 -->
|
| 77 |
+
<div id="favorite-tools-container">
|
| 78 |
+
<div style="grid-column: 1 / -1; text-align: center; padding: 2rem; color: var(--dark-2);">
|
| 79 |
+
<i class="fas fa-heart" style="font-size: 2rem; margin-bottom: 1rem; display: block; opacity: 0.5;"></i>
|
| 80 |
+
暂无收藏的工具
|
| 81 |
+
</div>
|
| 82 |
+
</div>
|
| 83 |
+
</div>
|
| 84 |
+
</section>
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
<!-- 最新工具区域 -->
|
| 88 |
+
@if (newTools.Any())
|
| 89 |
+
{
|
| 90 |
+
<section class="container mb-8">
|
| 91 |
+
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;">
|
| 92 |
+
<h3 style="font-size: 1.25rem; font-weight: 700; color: var(--dark);">
|
| 93 |
+
<i class="fas fa-sparkles" style="color: var(--accent); margin-right: 0.5rem;"></i>
|
| 94 |
+
最新工具
|
| 95 |
+
</h3>
|
| 96 |
+
<a href="@Url.Action("Tools", "Home")" class="btn btn-outline btn-sm">
|
| 97 |
+
查看更多
|
| 98 |
+
</a>
|
| 99 |
+
</div>
|
| 100 |
+
|
| 101 |
+
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6">
|
| 102 |
+
@foreach (var tool in newTools.Take(6))
|
| 103 |
+
{
|
| 104 |
+
<div class="card" style="padding: 1.5rem; text-align: center;" data-tool-id="@tool.Id">
|
| 105 |
+
<div style="width: 3.5rem; height: 3.5rem; border-radius: 50%; background: var(--light-1); display: flex; align-items: center; justify-content: center; margin: 0 auto 1rem; color: var(--primary); transition: var(--transition);">
|
| 106 |
+
<i class="@tool.Icon" style="font-size: 1.5rem;"></i>
|
| 107 |
+
</div>
|
| 108 |
+
<h4 style="font-weight: 600; margin-bottom: 0.5rem; font-size: 0.9rem;">@tool.Name</h4>
|
| 109 |
+
<p style="font-size: 0.75rem; color: var(--dark-2); line-height: 1.4; margin: 0;">
|
| 110 |
+
@(tool.Description?.Length > 30 ? tool.Description.Substring(0, 30) + "..." : tool.Description)
|
| 111 |
+
</p>
|
| 112 |
+
@if (tool.IsNew)
|
| 113 |
+
{
|
| 114 |
+
<span class="badge" style="background: var(--accent); color: white; font-size: 0.65rem; margin-top: 0.5rem; display: inline-block;">新品</span>
|
| 115 |
+
}
|
| 116 |
+
</div>
|
| 117 |
+
}
|
| 118 |
+
</div>
|
| 119 |
+
</section>
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
<!-- 热门工具区域 -->
|
| 123 |
+
@if (hotTools.Any())
|
| 124 |
+
{
|
| 125 |
+
<section class="container mb-8">
|
| 126 |
+
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;">
|
| 127 |
+
<h3 style="font-size: 1.25rem; font-weight: 700; color: var(--dark);">
|
| 128 |
+
<i class="fas fa-fire" style="color: var(--danger); margin-right: 0.5rem;"></i>
|
| 129 |
+
热门工具
|
| 130 |
+
</h3>
|
| 131 |
+
<a href="@Url.Action("Tools", "Home")" class="btn btn-outline btn-sm">
|
| 132 |
+
查看更多
|
| 133 |
+
</a>
|
| 134 |
+
</div>
|
| 135 |
+
|
| 136 |
+
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4">
|
| 137 |
+
@foreach (var tool in hotTools.Take(4))
|
| 138 |
+
{
|
| 139 |
+
<div class="card tool-card" data-tool-id="@tool.Id">
|
| 140 |
+
@if (!string.IsNullOrEmpty(tool.Image))
|
| 141 |
+
{
|
| 142 |
+
<img src="@tool.Image" alt="@tool.Name" class="card-img" />
|
| 143 |
+
}
|
| 144 |
+
else
|
| 145 |
+
{
|
| 146 |
+
<div class="card-img" style="background: linear-gradient(135deg, var(--primary), var(--secondary)); display: flex; align-items: center; justify-content: center;">
|
| 147 |
+
<i class="@tool.Icon" style="font-size: 3rem; color: white;"></i>
|
| 148 |
+
</div>
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
@if (tool.IsHot)
|
| 152 |
+
{
|
| 153 |
+
<div class="tool-badge hot">热门</div>
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
<div class="card-body">
|
| 157 |
+
<h4 class="card-title">@tool.Name</h4>
|
| 158 |
+
<p class="card-text">@(tool.Description ?? "")</p>
|
| 159 |
+
|
| 160 |
+
<div class="card-footer">
|
| 161 |
+
<div class="tool-stats">
|
| 162 |
+
@if (tool.Rating > 0)
|
| 163 |
+
{
|
| 164 |
+
<div class="tool-rating">
|
| 165 |
+
<i class="fas fa-star rating-stars"></i>
|
| 166 |
+
<span>@tool.Rating.ToString("F1")</span>
|
| 167 |
+
</div>
|
| 168 |
+
}
|
| 169 |
+
<span>@(tool.ViewCount > 1000 ? (tool.ViewCount / 1000).ToString("F0") + "K" : tool.ViewCount.ToString()) 次使用</span>
|
| 170 |
+
</div>
|
| 171 |
+
<a href="@(tool.Url ?? "#")" class="btn btn-primary btn-sm" target="_blank">
|
| 172 |
+
使用
|
| 173 |
+
</a>
|
| 174 |
+
</div>
|
| 175 |
+
</div>
|
| 176 |
+
</div>
|
| 177 |
+
}
|
| 178 |
+
</div>
|
| 179 |
+
</section>
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
@section Scripts {
|
| 183 |
+
<script src="https://cdn.bootcdn.net/ajax/libs/echarts/5.4.3/echarts.min.js"></script>
|
| 184 |
+
}
|
Views/Home/Privacy.cshtml
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@{
|
| 2 |
+
ViewData["Title"] = "Privacy Policy";
|
| 3 |
+
}
|
| 4 |
+
<h1>@ViewData["Title"]</h1>
|
| 5 |
+
|
| 6 |
+
<p>Use this page to detail your site's privacy policy.</p>
|
Views/Home/Tools.cshtml
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@model List<ToolHub.Models.Tool>
|
| 2 |
+
@{
|
| 3 |
+
ViewData["Title"] = "工具列表";
|
| 4 |
+
var categories = ViewBag.Categories as List<ToolHub.Models.Category> ?? new List<ToolHub.Models.Category>();
|
| 5 |
+
var currentCategory = ViewBag.CurrentCategory as int? ?? 0;
|
| 6 |
+
var search = ViewBag.Search as string;
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
<div class="container">
|
| 10 |
+
<!-- 页面标题 -->
|
| 11 |
+
<div class="text-center mb-6" style="padding: 2rem 0;">
|
| 12 |
+
<h1 style="font-size: 2rem; font-weight: 700; margin-bottom: 1rem; color: var(--dark);">
|
| 13 |
+
@if (!string.IsNullOrEmpty(search))
|
| 14 |
+
{
|
| 15 |
+
<span>搜索结果: "@search"</span>
|
| 16 |
+
}
|
| 17 |
+
else if (currentCategory > 0)
|
| 18 |
+
{
|
| 19 |
+
var categoryName = categories.FirstOrDefault(c => c.Id == currentCategory)?.Name ?? "工具";
|
| 20 |
+
<span>@categoryName</span>
|
| 21 |
+
}
|
| 22 |
+
else
|
| 23 |
+
{
|
| 24 |
+
<span>所有工具</span>
|
| 25 |
+
}
|
| 26 |
+
</h1>
|
| 27 |
+
<p style="color: var(--dark-2);">发现更多实用工具,提升您的工作效率</p>
|
| 28 |
+
</div>
|
| 29 |
+
|
| 30 |
+
<!-- 分类标签 -->
|
| 31 |
+
@if (string.IsNullOrEmpty(search))
|
| 32 |
+
{
|
| 33 |
+
<div class="category-tabs mb-6">
|
| 34 |
+
<a href="@Url.Action("Tools", "Home")" class="category-tab @(currentCategory == 0 ? "active" : "")">
|
| 35 |
+
全部工具
|
| 36 |
+
</a>
|
| 37 |
+
@foreach (var category in categories)
|
| 38 |
+
{
|
| 39 |
+
<a href="@Url.Action("Tools", "Home", new { categoryId = category.Id })"
|
| 40 |
+
class="category-tab @(currentCategory == category.Id ? "active" : "")">
|
| 41 |
+
<i class="@category.Icon" style="margin-right: 0.5rem;"></i>
|
| 42 |
+
@category.Name
|
| 43 |
+
</a>
|
| 44 |
+
}
|
| 45 |
+
</div>
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
<!-- 工具网格 -->
|
| 49 |
+
@if (Model.Any())
|
| 50 |
+
{
|
| 51 |
+
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
| 52 |
+
@foreach (var tool in Model)
|
| 53 |
+
{
|
| 54 |
+
<div class="card tool-card" data-tool-id="@tool.Id">
|
| 55 |
+
@if (!string.IsNullOrEmpty(tool.Image))
|
| 56 |
+
{
|
| 57 |
+
<img src="@tool.Image" alt="@tool.Name" class="card-img" />
|
| 58 |
+
}
|
| 59 |
+
else
|
| 60 |
+
{
|
| 61 |
+
<div class="card-img" style="background: linear-gradient(135deg, var(--primary), var(--secondary)); display: flex; align-items: center; justify-content: center;">
|
| 62 |
+
<i class="@tool.Icon" style="font-size: 3rem; color: white;"></i>
|
| 63 |
+
</div>
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
<!-- 工具标签 -->
|
| 67 |
+
@if (tool.IsHot)
|
| 68 |
+
{
|
| 69 |
+
<div class="tool-badge hot">热门</div>
|
| 70 |
+
}
|
| 71 |
+
@if (tool.IsNew)
|
| 72 |
+
{
|
| 73 |
+
<div class="tool-badge new">新品</div>
|
| 74 |
+
}
|
| 75 |
+
@if (tool.IsRecommended)
|
| 76 |
+
{
|
| 77 |
+
<div class="tool-badge recommended">推荐</div>
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
<div class="card-body">
|
| 81 |
+
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.75rem;">
|
| 82 |
+
<div style="flex: 1;">
|
| 83 |
+
<h4 class="card-title">@tool.Name</h4>
|
| 84 |
+
<p class="card-text">@(tool.Description ?? "")</p>
|
| 85 |
+
</div>
|
| 86 |
+
</div>
|
| 87 |
+
|
| 88 |
+
<div class="card-footer">
|
| 89 |
+
<div class="tool-stats">
|
| 90 |
+
@if (tool.Rating > 0)
|
| 91 |
+
{
|
| 92 |
+
<div class="tool-rating">
|
| 93 |
+
<i class="fas fa-star rating-stars"></i>
|
| 94 |
+
<span>@tool.Rating.ToString("F1")</span>
|
| 95 |
+
</div>
|
| 96 |
+
}
|
| 97 |
+
<span>@(tool.ViewCount > 1000 ? (tool.ViewCount / 1000).ToString("F0") + "K" : tool.ViewCount.ToString()) 次使用</span>
|
| 98 |
+
</div>
|
| 99 |
+
<a href="@(tool.Url ?? "#")" class="btn btn-primary btn-sm" target="_blank">
|
| 100 |
+
使用
|
| 101 |
+
</a>
|
| 102 |
+
</div>
|
| 103 |
+
</div>
|
| 104 |
+
</div>
|
| 105 |
+
}
|
| 106 |
+
</div>
|
| 107 |
+
|
| 108 |
+
<!-- 分页 (如果需要的话) -->
|
| 109 |
+
<div class="text-center mt-8">
|
| 110 |
+
<button class="btn btn-outline" id="loadMore" style="display: none;">
|
| 111 |
+
<span>加载更多</span>
|
| 112 |
+
<i class="fas fa-chevron-down"></i>
|
| 113 |
+
</button>
|
| 114 |
+
</div>
|
| 115 |
+
}
|
| 116 |
+
else
|
| 117 |
+
{
|
| 118 |
+
<!-- 空状态 -->
|
| 119 |
+
<div class="text-center" style="padding: 4rem 2rem;">
|
| 120 |
+
<div style="font-size: 4rem; color: var(--light-3); margin-bottom: 1rem;">
|
| 121 |
+
<i class="fas fa-search"></i>
|
| 122 |
+
</div>
|
| 123 |
+
<h3 style="font-size: 1.25rem; font-weight: 600; margin-bottom: 0.5rem; color: var(--dark);">
|
| 124 |
+
@if (!string.IsNullOrEmpty(search))
|
| 125 |
+
{
|
| 126 |
+
<span>未找到相关工具</span>
|
| 127 |
+
}
|
| 128 |
+
else
|
| 129 |
+
{
|
| 130 |
+
<span>暂无工具</span>
|
| 131 |
+
}
|
| 132 |
+
</h3>
|
| 133 |
+
<p style="color: var(--dark-2); margin-bottom: 2rem;">
|
| 134 |
+
@if (!string.IsNullOrEmpty(search))
|
| 135 |
+
{
|
| 136 |
+
<span>试试其他关键词,或浏览其他分类的工具</span>
|
| 137 |
+
}
|
| 138 |
+
else
|
| 139 |
+
{
|
| 140 |
+
<span>该分类下暂时没有工具,敬请期待</span>
|
| 141 |
+
}
|
| 142 |
+
</p>
|
| 143 |
+
<a href="@Url.Action("Tools", "Home")" class="btn btn-primary">
|
| 144 |
+
<i class="fas fa-th-large" style="margin-right: 0.5rem;"></i>
|
| 145 |
+
<span>浏览所有工具</span>
|
| 146 |
+
</a>
|
| 147 |
+
</div>
|
| 148 |
+
}
|
| 149 |
+
</div>
|
| 150 |
+
|
| 151 |
+
<style>
|
| 152 |
+
/* 工具页面特定样式 */
|
| 153 |
+
.category-tabs {
|
| 154 |
+
scrollbar-width: none;
|
| 155 |
+
-ms-overflow-style: none;
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
.category-tabs::-webkit-scrollbar {
|
| 159 |
+
display: none;
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
@@media (max-width: 767px) {
|
| 163 |
+
.category-tabs {
|
| 164 |
+
padding-bottom: 1rem;
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
.grid {
|
| 168 |
+
grid-template-columns: repeat(1, 1fr);
|
| 169 |
+
}
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
@@media (min-width: 768px) and (max-width: 1023px) {
|
| 173 |
+
.grid {
|
| 174 |
+
grid-template-columns: repeat(2, 1fr);
|
| 175 |
+
}
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
@@media (min-width: 1024px) and (max-width: 1279px) {
|
| 179 |
+
.grid {
|
| 180 |
+
grid-template-columns: repeat(3, 1fr);
|
| 181 |
+
}
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
@@media (min-width: 1280px) {
|
| 185 |
+
.grid {
|
| 186 |
+
grid-template-columns: repeat(4, 1fr);
|
| 187 |
+
}
|
| 188 |
+
}
|
| 189 |
+
</style>
|
Views/Shared/Error.cshtml
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@model ErrorViewModel
|
| 2 |
+
@{
|
| 3 |
+
ViewData["Title"] = "Error";
|
| 4 |
+
}
|
| 5 |
+
|
| 6 |
+
<h1 class="text-danger">Error.</h1>
|
| 7 |
+
<h2 class="text-danger">An error occurred while processing your request.</h2>
|
| 8 |
+
|
| 9 |
+
@if (Model.ShowRequestId)
|
| 10 |
+
{
|
| 11 |
+
<p>
|
| 12 |
+
<strong>Request ID:</strong> <code>@Model.RequestId</code>
|
| 13 |
+
</p>
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
<h3>Development Mode</h3>
|
| 17 |
+
<p>
|
| 18 |
+
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
|
| 19 |
+
</p>
|
| 20 |
+
<p>
|
| 21 |
+
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
|
| 22 |
+
It can result in displaying sensitive information from exceptions to end users.
|
| 23 |
+
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
|
| 24 |
+
and restarting the app.
|
| 25 |
+
</p>
|
Views/Shared/_AdminLayout.cshtml
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="zh-CN">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>@ViewData["Title"] - ToolHub 管理后台</title>
|
| 7 |
+
|
| 8 |
+
<!-- 外部字体和图标 -->
|
| 9 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
| 10 |
+
<link href="https://cdn.bootcdn.net/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet" />
|
| 11 |
+
|
| 12 |
+
<!-- 样式文件 -->
|
| 13 |
+
<link rel="stylesheet" href="~/css/toolhub.css" asp-append-version="true" />
|
| 14 |
+
|
| 15 |
+
<style>
|
| 16 |
+
.admin-sidebar {
|
| 17 |
+
width: 250px;
|
| 18 |
+
background: white;
|
| 19 |
+
box-shadow: var(--shadow);
|
| 20 |
+
position: fixed;
|
| 21 |
+
top: 0;
|
| 22 |
+
left: 0;
|
| 23 |
+
height: 100vh;
|
| 24 |
+
z-index: 1000;
|
| 25 |
+
overflow-y: auto;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
.admin-main {
|
| 29 |
+
margin-left: 250px;
|
| 30 |
+
min-height: 100vh;
|
| 31 |
+
background: #fafafa;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
.admin-header {
|
| 35 |
+
background: white;
|
| 36 |
+
padding: 1rem 2rem;
|
| 37 |
+
box-shadow: var(--shadow-sm);
|
| 38 |
+
display: flex;
|
| 39 |
+
justify-content: space-between;
|
| 40 |
+
align-items: center;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
.admin-content {
|
| 44 |
+
padding: 2rem;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
.sidebar-brand {
|
| 48 |
+
padding: 1.5rem;
|
| 49 |
+
border-bottom: 1px solid var(--light-2);
|
| 50 |
+
text-align: center;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
.sidebar-nav {
|
| 54 |
+
padding: 1rem 0;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
.sidebar-nav-item {
|
| 58 |
+
display: block;
|
| 59 |
+
padding: 0.75rem 1.5rem;
|
| 60 |
+
color: var(--dark-2);
|
| 61 |
+
text-decoration: none;
|
| 62 |
+
transition: var(--transition);
|
| 63 |
+
border-left: 3px solid transparent;
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
.sidebar-nav-item:hover,
|
| 67 |
+
.sidebar-nav-item.active {
|
| 68 |
+
background: var(--light-1);
|
| 69 |
+
color: var(--primary);
|
| 70 |
+
border-left-color: var(--primary);
|
| 71 |
+
text-decoration: none;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
.sidebar-nav-item i {
|
| 75 |
+
width: 1.5rem;
|
| 76 |
+
margin-right: 0.75rem;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
.stats-card {
|
| 80 |
+
background: white;
|
| 81 |
+
padding: 1.5rem;
|
| 82 |
+
border-radius: var(--border-radius);
|
| 83 |
+
box-shadow: var(--shadow-sm);
|
| 84 |
+
transition: var(--transition);
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
.stats-card:hover {
|
| 88 |
+
transform: translateY(-2px);
|
| 89 |
+
box-shadow: var(--shadow);
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
.stats-number {
|
| 93 |
+
font-size: 2rem;
|
| 94 |
+
font-weight: 700;
|
| 95 |
+
margin-bottom: 0.5rem;
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
.stats-label {
|
| 99 |
+
color: var(--dark-2);
|
| 100 |
+
font-size: 0.875rem;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
.data-table {
|
| 104 |
+
background: white;
|
| 105 |
+
border-radius: var(--border-radius);
|
| 106 |
+
box-shadow: var(--shadow-sm);
|
| 107 |
+
overflow: hidden;
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
.data-table table {
|
| 111 |
+
width: 100%;
|
| 112 |
+
border-collapse: collapse;
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
.data-table th,
|
| 116 |
+
.data-table td {
|
| 117 |
+
padding: 0.75rem 1rem;
|
| 118 |
+
text-align: left;
|
| 119 |
+
border-bottom: 1px solid var(--light-2);
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
.data-table th {
|
| 123 |
+
background: var(--light-1);
|
| 124 |
+
font-weight: 600;
|
| 125 |
+
color: var(--dark);
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
.data-table tbody tr:hover {
|
| 129 |
+
background: var(--light-1);
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
.badge {
|
| 133 |
+
display: inline-block;
|
| 134 |
+
padding: 0.25rem 0.75rem;
|
| 135 |
+
font-size: 0.75rem;
|
| 136 |
+
font-weight: 500;
|
| 137 |
+
border-radius: 50px;
|
| 138 |
+
color: white;
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
.badge-primary { background: var(--primary); }
|
| 142 |
+
.badge-success { background: var(--success); }
|
| 143 |
+
.badge-warning { background: var(--warning); }
|
| 144 |
+
.badge-danger { background: var(--danger); }
|
| 145 |
+
.badge-secondary { background: var(--dark-2); }
|
| 146 |
+
|
| 147 |
+
@@media (max-width: 768px) {
|
| 148 |
+
.admin-sidebar {
|
| 149 |
+
transform: translateX(-100%);
|
| 150 |
+
transition: transform 0.3s ease;
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
.admin-sidebar.show {
|
| 154 |
+
transform: translateX(0);
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
.admin-main {
|
| 158 |
+
margin-left: 0;
|
| 159 |
+
}
|
| 160 |
+
}
|
| 161 |
+
</style>
|
| 162 |
+
|
| 163 |
+
@await RenderSectionAsync("Styles", required: false)
|
| 164 |
+
</head>
|
| 165 |
+
<body class="admin-page">
|
| 166 |
+
<!-- 侧边栏 -->
|
| 167 |
+
<div class="admin-sidebar">
|
| 168 |
+
<div class="sidebar-brand">
|
| 169 |
+
<div class="brand-icon" style="margin-bottom: 0.5rem;">
|
| 170 |
+
<i class="fas fa-wrench"></i>
|
| 171 |
+
</div>
|
| 172 |
+
<h5 style="margin: 0; font-weight: 700;">
|
| 173 |
+
<span class="text-gradient">ToolHub</span>
|
| 174 |
+
</h5>
|
| 175 |
+
<p style="font-size: 0.75rem; color: var(--dark-2); margin: 0.25rem 0 0;">管理后台</p>
|
| 176 |
+
</div>
|
| 177 |
+
|
| 178 |
+
<nav class="sidebar-nav">
|
| 179 |
+
<a href="@Url.Action("Index", "Admin")" class="sidebar-nav-item @(ViewContext.RouteData.Values["action"]?.ToString() == "Index" ? "active" : "")">
|
| 180 |
+
<i class="fas fa-chart-line"></i>
|
| 181 |
+
仪表板
|
| 182 |
+
</a>
|
| 183 |
+
<a href="@Url.Action("Index", "Category")" class="sidebar-nav-item @(ViewContext.RouteData.Values["controller"]?.ToString() == "Category" ? "active" : "")">
|
| 184 |
+
<i class="fas fa-tags"></i>
|
| 185 |
+
工具分类
|
| 186 |
+
</a>
|
| 187 |
+
<a href="@Url.Action("Index", "Tag")" class="sidebar-nav-item @(ViewContext.RouteData.Values["controller"]?.ToString() == "Tag" ? "active" : "")">
|
| 188 |
+
<i class="fas fa-tags"></i>
|
| 189 |
+
标签管理
|
| 190 |
+
</a>
|
| 191 |
+
<a href="@Url.Action("Index", "Tool")" class="sidebar-nav-item @(ViewContext.RouteData.Values["controller"]?.ToString() == "Tool" ? "active" : "")">
|
| 192 |
+
<i class="fas fa-tools"></i>
|
| 193 |
+
工具管理
|
| 194 |
+
</a>
|
| 195 |
+
<a href="@Url.Action("Index", "ToolStatistics")" class="sidebar-nav-item @(ViewContext.RouteData.Values["controller"]?.ToString() == "ToolStatistics" ? "active" : "")">
|
| 196 |
+
<i class="fas fa-chart-bar"></i>
|
| 197 |
+
工具统计
|
| 198 |
+
</a>
|
| 199 |
+
<a href="@Url.Action("Users", "Admin")" class="sidebar-nav-item @(ViewContext.RouteData.Values["action"]?.ToString() == "Users" ? "active" : "")">
|
| 200 |
+
<i class="fas fa-users"></i>
|
| 201 |
+
用户管理
|
| 202 |
+
</a>
|
| 203 |
+
<a href="@Url.Action("Index", "Home")" class="sidebar-nav-item" target="_blank">
|
| 204 |
+
<i class="fas fa-external-link-alt"></i>
|
| 205 |
+
查看网站
|
| 206 |
+
</a>
|
| 207 |
+
</nav>
|
| 208 |
+
</div>
|
| 209 |
+
|
| 210 |
+
<!-- 主内容区 -->
|
| 211 |
+
<div class="admin-main">
|
| 212 |
+
<!-- 顶部导航 -->
|
| 213 |
+
<header class="admin-header">
|
| 214 |
+
<div style="display: flex; align-items: center;">
|
| 215 |
+
<button class="btn btn-outline btn-sm mobile-sidebar-toggle" style="display: none; margin-right: 1rem;">
|
| 216 |
+
<i class="fas fa-bars"></i>
|
| 217 |
+
</button>
|
| 218 |
+
<h1 style="font-size: 1.25rem; font-weight: 600; margin: 0; color: var(--dark);">
|
| 219 |
+
@ViewData["Title"]
|
| 220 |
+
</h1>
|
| 221 |
+
</div>
|
| 222 |
+
|
| 223 |
+
<div style="display: flex; align-items: center; gap: 1rem;">
|
| 224 |
+
<span style="font-size: 0.875rem; color: var(--dark-2);">
|
| 225 |
+
欢迎,<strong>@User.Identity?.Name</strong>
|
| 226 |
+
</span>
|
| 227 |
+
<form method="post" action="@Url.Action("Logout", "Admin")" style="margin: 0;">
|
| 228 |
+
<button type="submit" class="btn btn-outline btn-sm">
|
| 229 |
+
<i class="fas fa-sign-out-alt"></i>
|
| 230 |
+
退出
|
| 231 |
+
</button>
|
| 232 |
+
</form>
|
| 233 |
+
</div>
|
| 234 |
+
</header>
|
| 235 |
+
|
| 236 |
+
<!-- 内容区 -->
|
| 237 |
+
<main class="admin-content">
|
| 238 |
+
@RenderBody()
|
| 239 |
+
</main>
|
| 240 |
+
</div>
|
| 241 |
+
|
| 242 |
+
<!-- JavaScript -->
|
| 243 |
+
<script src="~/js/toolhub.js" asp-append-version="true"></script>
|
| 244 |
+
|
| 245 |
+
<script>
|
| 246 |
+
// 移动端侧边栏切换
|
| 247 |
+
document.addEventListener('DOMContentLoaded', function() {
|
| 248 |
+
const toggleBtn = document.querySelector('.mobile-sidebar-toggle');
|
| 249 |
+
const sidebar = document.querySelector('.admin-sidebar');
|
| 250 |
+
|
| 251 |
+
if (toggleBtn && sidebar) {
|
| 252 |
+
// 显示移动端按钮
|
| 253 |
+
if (window.innerWidth <= 768) {
|
| 254 |
+
toggleBtn.style.display = 'inline-flex';
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
// 切换侧边栏
|
| 258 |
+
toggleBtn.addEventListener('click', function() {
|
| 259 |
+
sidebar.classList.toggle('show');
|
| 260 |
+
});
|
| 261 |
+
|
| 262 |
+
// 点击外部关闭
|
| 263 |
+
document.addEventListener('click', function(e) {
|
| 264 |
+
if (window.innerWidth <= 768 &&
|
| 265 |
+
!sidebar.contains(e.target) &&
|
| 266 |
+
!toggleBtn.contains(e.target)) {
|
| 267 |
+
sidebar.classList.remove('show');
|
| 268 |
+
}
|
| 269 |
+
});
|
| 270 |
+
|
| 271 |
+
// 响应式处理
|
| 272 |
+
window.addEventListener('resize', function() {
|
| 273 |
+
if (window.innerWidth <= 768) {
|
| 274 |
+
toggleBtn.style.display = 'inline-flex';
|
| 275 |
+
} else {
|
| 276 |
+
toggleBtn.style.display = 'none';
|
| 277 |
+
sidebar.classList.remove('show');
|
| 278 |
+
}
|
| 279 |
+
});
|
| 280 |
+
}
|
| 281 |
+
});
|
| 282 |
+
</script>
|
| 283 |
+
|
| 284 |
+
@await RenderSectionAsync("Scripts", required: false)
|
| 285 |
+
</body>
|
| 286 |
+
</html>
|
Views/Shared/_Layout.cshtml
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="zh-CN">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>@ViewData["Title"] - ToolHub</title>
|
| 7 |
+
<meta name="description" content="ToolHub - 一站式在线工具平台,集成图片处理、文档转换、数据计算等多种实用工具" />
|
| 8 |
+
<meta name="keywords" content="在线工具,PDF转换,图片处理,文档转换,数据计算" />
|
| 9 |
+
|
| 10 |
+
<!-- Favicon -->
|
| 11 |
+
<link rel="icon" type="image/x-icon" href="~/favicon.ico" />
|
| 12 |
+
|
| 13 |
+
<!-- 外部字体和图标 -->
|
| 14 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
| 15 |
+
<link href="https://cdn.bootcdn.net/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet" />
|
| 16 |
+
|
| 17 |
+
<!-- 样式文件 -->
|
| 18 |
+
<link rel="stylesheet" href="~/css/toolhub.css" asp-append-version="true" />
|
| 19 |
+
|
| 20 |
+
@await RenderSectionAsync("Styles", required: false)
|
| 21 |
+
</head>
|
| 22 |
+
<body>
|
| 23 |
+
<!-- 导航栏 -->
|
| 24 |
+
<header class="navbar">
|
| 25 |
+
<div class="container">
|
| 26 |
+
<div style="display: flex; justify-content: space-between; align-items: center; width: 100%;">
|
| 27 |
+
<!-- Logo -->
|
| 28 |
+
<a href="@Url.Action("Index", "Home")" class="navbar-brand">
|
| 29 |
+
<div class="brand-icon">
|
| 30 |
+
<i class="fas fa-wrench"></i>
|
| 31 |
+
</div>
|
| 32 |
+
<span class="text-gradient">ToolHub</span>
|
| 33 |
+
</a>
|
| 34 |
+
|
| 35 |
+
<!-- 搜索框 - 桌面端 -->
|
| 36 |
+
<div class="search-container" style="display: none;">
|
| 37 |
+
<form class="search-form" action="@Url.Action("Tools", "Home")" method="get">
|
| 38 |
+
<input type="text" name="search" class="search-input" placeholder="搜索工具、文档或教程..." value="@ViewBag.Search" />
|
| 39 |
+
<i class="fas fa-search search-icon"></i>
|
| 40 |
+
</form>
|
| 41 |
+
</div>
|
| 42 |
+
|
| 43 |
+
<!-- 导航链接和按钮 -->
|
| 44 |
+
<div style="display: flex; align-items: center; gap: 1rem;">
|
| 45 |
+
<!-- 桌面端链接 -->
|
| 46 |
+
<nav style="display: none;">
|
| 47 |
+
<a href="@Url.Action("Tools", "Home")" class="btn btn-outline btn-sm">
|
| 48 |
+
<i class="fas fa-th-large"></i>
|
| 49 |
+
<span>所有工具</span>
|
| 50 |
+
</a>
|
| 51 |
+
<a href="@Url.Action("Privacy", "Home")" class="btn btn-outline btn-sm">
|
| 52 |
+
<i class="fas fa-shield-alt"></i>
|
| 53 |
+
<span>隐私政策</span>
|
| 54 |
+
</a>
|
| 55 |
+
</nav>
|
| 56 |
+
|
| 57 |
+
<!-- 移动端菜单按钮 -->
|
| 58 |
+
<button class="mobile-menu-toggle btn btn-outline btn-sm">
|
| 59 |
+
<i class="fas fa-bars"></i>
|
| 60 |
+
</button>
|
| 61 |
+
</div>
|
| 62 |
+
</div>
|
| 63 |
+
|
| 64 |
+
<!-- 移动端搜索容器 -->
|
| 65 |
+
<div class="mobile-search-container" style="margin-top: 1rem; display: none;">
|
| 66 |
+
<form class="search-form" action="@Url.Action("Tools", "Home")" method="get">
|
| 67 |
+
<input type="text" name="search" class="search-input" placeholder="搜索工具..." value="@ViewBag.Search" />
|
| 68 |
+
<i class="fas fa-search search-icon"></i>
|
| 69 |
+
</form>
|
| 70 |
+
</div>
|
| 71 |
+
</div>
|
| 72 |
+
|
| 73 |
+
<!-- 移动端菜单 -->
|
| 74 |
+
<div class="mobile-menu" style="display: none;">
|
| 75 |
+
<div class="container">
|
| 76 |
+
<div style="padding: 1rem 0; border-top: 1px solid var(--light-2);">
|
| 77 |
+
<a href="@Url.Action("Tools", "Home")" style="display: flex; align-items: center; padding: 0.75rem; text-decoration: none; color: var(--dark);">
|
| 78 |
+
<i class="fas fa-th-large" style="width: 1.5rem; margin-right: 0.75rem;"></i>
|
| 79 |
+
<span>所有工具</span>
|
| 80 |
+
</a>
|
| 81 |
+
<a href="@Url.Action("Privacy", "Home")" style="display: flex; align-items: center; padding: 0.75rem; text-decoration: none; color: var(--dark);">
|
| 82 |
+
<i class="fas fa-shield-alt" style="width: 1.5rem; margin-right: 0.75rem;"></i>
|
| 83 |
+
<span>隐私政策</span>
|
| 84 |
+
</a>
|
| 85 |
+
</div>
|
| 86 |
+
</div>
|
| 87 |
+
</div>
|
| 88 |
+
</header>
|
| 89 |
+
|
| 90 |
+
<!-- 主内容区 -->
|
| 91 |
+
<main style="min-height: calc(100vh - 200px); padding-top: 5rem;">
|
| 92 |
+
@RenderBody()
|
| 93 |
+
</main>
|
| 94 |
+
|
| 95 |
+
<!-- 页脚 -->
|
| 96 |
+
<footer class="footer">
|
| 97 |
+
<div class="container">
|
| 98 |
+
<div class="footer-grid">
|
| 99 |
+
<!-- 品牌信息 -->
|
| 100 |
+
<div class="footer-section">
|
| 101 |
+
<div style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1rem;">
|
| 102 |
+
<div class="brand-icon">
|
| 103 |
+
<i class="fas fa-wrench"></i>
|
| 104 |
+
</div>
|
| 105 |
+
<span style="font-size: 1.25rem; font-weight: 700; color: white;">ToolHub</span>
|
| 106 |
+
</div>
|
| 107 |
+
<p style="margin-bottom: 1.5rem; max-width: 300px;">
|
| 108 |
+
一站式在线工具平台,集成图片处理、文档转换、数据计算等多种实用工具,满足您的日常工作和学习需求。
|
| 109 |
+
</p>
|
| 110 |
+
<div class="social-links">
|
| 111 |
+
<a href="#" class="social-link">
|
| 112 |
+
<i class="fab fa-weixin"></i>
|
| 113 |
+
</a>
|
| 114 |
+
<a href="#" class="social-link">
|
| 115 |
+
<i class="fab fa-weibo"></i>
|
| 116 |
+
</a>
|
| 117 |
+
<a href="#" class="social-link">
|
| 118 |
+
<i class="fab fa-qq"></i>
|
| 119 |
+
</a>
|
| 120 |
+
<a href="#" class="social-link">
|
| 121 |
+
<i class="fab fa-github"></i>
|
| 122 |
+
</a>
|
| 123 |
+
</div>
|
| 124 |
+
</div>
|
| 125 |
+
|
| 126 |
+
<!-- 快速链接 -->
|
| 127 |
+
<div class="footer-section">
|
| 128 |
+
<h4>快速链接</h4>
|
| 129 |
+
<ul>
|
| 130 |
+
<li><a href="@Url.Action("Index", "Home")">首页</a></li>
|
| 131 |
+
<li><a href="@Url.Action("Tools", "Home")">全部工具</a></li>
|
| 132 |
+
<li><a href="#">关于我们</a></li>
|
| 133 |
+
<li><a href="#">联系我们</a></li>
|
| 134 |
+
</ul>
|
| 135 |
+
</div>
|
| 136 |
+
|
| 137 |
+
<!-- 支持 -->
|
| 138 |
+
<div class="footer-section">
|
| 139 |
+
<h4>支持</h4>
|
| 140 |
+
<ul>
|
| 141 |
+
<li><a href="#">帮助中心</a></li>
|
| 142 |
+
<li><a href="#">使用教程</a></li>
|
| 143 |
+
<li><a href="#">常见问题</a></li>
|
| 144 |
+
<li><a href="#">反馈建议</a></li>
|
| 145 |
+
</ul>
|
| 146 |
+
</div>
|
| 147 |
+
|
| 148 |
+
<!-- 法律 -->
|
| 149 |
+
<div class="footer-section">
|
| 150 |
+
<h4>法律</h4>
|
| 151 |
+
<ul>
|
| 152 |
+
<li><a href="@Url.Action("Privacy", "Home")">隐私政策</a></li>
|
| 153 |
+
<li><a href="#">服务条款</a></li>
|
| 154 |
+
<li><a href="#">版权声明</a></li>
|
| 155 |
+
<li><a href="#">免责声明</a></li>
|
| 156 |
+
</ul>
|
| 157 |
+
</div>
|
| 158 |
+
</div>
|
| 159 |
+
|
| 160 |
+
<div class="footer-bottom">
|
| 161 |
+
<p style="color: var(--light-3); font-size: 0.875rem;">
|
| 162 |
+
© 2025 ToolHub. 保留所有权利。
|
| 163 |
+
</p>
|
| 164 |
+
<div style="display: flex; gap: 1.5rem;">
|
| 165 |
+
<a href="@Url.Action("Privacy", "Home")" style="color: var(--light-3); font-size: 0.875rem; text-decoration: none;">隐私政策</a>
|
| 166 |
+
<a href="#" style="color: var(--light-3); font-size: 0.875rem; text-decoration: none;">服务条款</a>
|
| 167 |
+
<a href="#" style="color: var(--light-3); font-size: 0.875rem; text-decoration: none;">Cookies</a>
|
| 168 |
+
</div>
|
| 169 |
+
</div>
|
| 170 |
+
</div>
|
| 171 |
+
</footer>
|
| 172 |
+
|
| 173 |
+
<!-- 返回顶部按钮 -->
|
| 174 |
+
<button class="back-to-top">
|
| 175 |
+
<i class="fas fa-chevron-up"></i>
|
| 176 |
+
</button>
|
| 177 |
+
|
| 178 |
+
<!-- 提示消息容器 -->
|
| 179 |
+
<div id="toast-container" style="position: fixed; top: 2rem; right: 2rem; z-index: 10000;"></div>
|
| 180 |
+
|
| 181 |
+
<!-- JavaScript -->
|
| 182 |
+
<script src="~/js/toolhub.js" asp-append-version="true"></script>
|
| 183 |
+
|
| 184 |
+
@await RenderSectionAsync("Scripts", required: false)
|
| 185 |
+
|
| 186 |
+
<style>
|
| 187 |
+
/* 移动端响应式 */
|
| 188 |
+
@@media (min-width: 768px) {
|
| 189 |
+
.search-container {
|
| 190 |
+
display: block !important;
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
nav {
|
| 194 |
+
display: flex !important;
|
| 195 |
+
gap: 0.5rem;
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
.mobile-menu-toggle {
|
| 199 |
+
display: none !important;
|
| 200 |
+
}
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
/* 提示消息样式 */
|
| 204 |
+
.toast {
|
| 205 |
+
position: relative;
|
| 206 |
+
background: white;
|
| 207 |
+
border-radius: var(--border-radius);
|
| 208 |
+
box-shadow: var(--shadow-lg);
|
| 209 |
+
padding: 1rem;
|
| 210 |
+
margin-bottom: 0.5rem;
|
| 211 |
+
transform: translateX(100%);
|
| 212 |
+
transition: transform 0.3s ease;
|
| 213 |
+
border-left: 4px solid var(--primary);
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
.toast.show {
|
| 217 |
+
transform: translateX(0);
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
.toast.toast-success {
|
| 221 |
+
border-left-color: var(--success);
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
.toast.toast-error {
|
| 225 |
+
border-left-color: var(--danger);
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
.toast.toast-warning {
|
| 229 |
+
border-left-color: var(--warning);
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
.toast-content {
|
| 233 |
+
display: flex;
|
| 234 |
+
align-items: center;
|
| 235 |
+
gap: 0.75rem;
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
.toast-content i {
|
| 239 |
+
font-size: 1.25rem;
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
.toast-success .toast-content i {
|
| 243 |
+
color: var(--success);
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
.toast-error .toast-content i {
|
| 247 |
+
color: var(--danger);
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
.toast-warning .toast-content i {
|
| 251 |
+
color: var(--warning);
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
.toast-info .toast-content i {
|
| 255 |
+
color: var(--primary);
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
/* 移动端菜单动画 */
|
| 259 |
+
.mobile-menu {
|
| 260 |
+
background: white;
|
| 261 |
+
border-top: 1px solid var(--light-2);
|
| 262 |
+
box-shadow: var(--shadow);
|
| 263 |
+
max-height: 0;
|
| 264 |
+
overflow: hidden;
|
| 265 |
+
transition: max-height 0.3s ease;
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
.mobile-menu.show {
|
| 269 |
+
max-height: 300px;
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
.mobile-search-container {
|
| 273 |
+
max-height: 0;
|
| 274 |
+
overflow: hidden;
|
| 275 |
+
transition: max-height 0.3s ease;
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
.mobile-search-container.show {
|
| 279 |
+
max-height: 100px;
|
| 280 |
+
}
|
| 281 |
+
</style>
|
| 282 |
+
</body>
|
| 283 |
+
</html>
|
Views/Shared/_Layout.cshtml.css
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Please see documentation at https://learn.microsoft.com/aspnet/core/client-side/bundling-and-minification
|
| 2 |
+
for details on configuring this project to bundle and minify static web assets. */
|
| 3 |
+
|
| 4 |
+
a.navbar-brand {
|
| 5 |
+
white-space: normal;
|
| 6 |
+
text-align: center;
|
| 7 |
+
word-break: break-all;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
a {
|
| 11 |
+
color: #0077cc;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
.btn-primary {
|
| 15 |
+
color: #fff;
|
| 16 |
+
background-color: #1b6ec2;
|
| 17 |
+
border-color: #1861ac;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
.nav-pills .nav-link.active, .nav-pills .show > .nav-link {
|
| 21 |
+
color: #fff;
|
| 22 |
+
background-color: #1b6ec2;
|
| 23 |
+
border-color: #1861ac;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
.border-top {
|
| 27 |
+
border-top: 1px solid #e5e5e5;
|
| 28 |
+
}
|
| 29 |
+
.border-bottom {
|
| 30 |
+
border-bottom: 1px solid #e5e5e5;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
.box-shadow {
|
| 34 |
+
box-shadow: 0 .25rem .75rem rgba(0, 0, 0, .05);
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
button.accept-policy {
|
| 38 |
+
font-size: 1rem;
|
| 39 |
+
line-height: inherit;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
.footer {
|
| 43 |
+
position: absolute;
|
| 44 |
+
bottom: 0;
|
| 45 |
+
width: 100%;
|
| 46 |
+
white-space: nowrap;
|
| 47 |
+
line-height: 60px;
|
| 48 |
+
}
|
Views/Shared/_ValidationScriptsPartial.cshtml
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<script src="~/lib/jquery-validation/dist/jquery.validate.min.js"></script>
|
| 2 |
+
<script src="~/lib/jquery-validation-unobtrusive/dist/jquery.validate.unobtrusive.min.js"></script>
|
Views/Tag/Index.cshtml
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@model List<ToolHub.Models.Tag>
|
| 2 |
+
@{
|
| 3 |
+
ViewData["Title"] = "标签管理";
|
| 4 |
+
Layout = "_AdminLayout";
|
| 5 |
+
var currentPage = ViewBag.CurrentPage as int? ?? 1;
|
| 6 |
+
var totalPages = ViewBag.TotalPages as int? ?? 1;
|
| 7 |
+
var totalCount = ViewBag.TotalCount as int? ?? 0;
|
| 8 |
+
var pageSize = ViewBag.PageSize as int? ?? 20;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
<!-- 页面头部 -->
|
| 12 |
+
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;">
|
| 13 |
+
<div>
|
| 14 |
+
<h2 style="font-size: 1.5rem; font-weight: 700; margin: 0; color: var(--dark);">标签管理</h2>
|
| 15 |
+
<p style="color: var(--dark-2); margin: 0.5rem 0 0;">管理工具标签,为工具添加分类标识</p>
|
| 16 |
+
</div>
|
| 17 |
+
<button class="btn btn-primary" onclick="openTagModal()">
|
| 18 |
+
<i class="fas fa-plus"></i>
|
| 19 |
+
添加标签
|
| 20 |
+
</button>
|
| 21 |
+
</div>
|
| 22 |
+
|
| 23 |
+
<!-- 标签列表 -->
|
| 24 |
+
<div class="data-table">
|
| 25 |
+
<div style="padding: 1rem 1.5rem; border-bottom: 1px solid var(--light-2); display: flex; justify-content: space-between; align-items: center;">
|
| 26 |
+
<span style="font-weight: 600;">标签列表 (@totalCount)</span>
|
| 27 |
+
</div>
|
| 28 |
+
|
| 29 |
+
<table>
|
| 30 |
+
<thead>
|
| 31 |
+
<tr>
|
| 32 |
+
<th>标签名称</th>
|
| 33 |
+
<th>颜色</th>
|
| 34 |
+
<th>使用次数</th>
|
| 35 |
+
<th>创建时间</th>
|
| 36 |
+
<th>状态</th>
|
| 37 |
+
<th>操作</th>
|
| 38 |
+
</tr>
|
| 39 |
+
</thead>
|
| 40 |
+
<tbody>
|
| 41 |
+
@foreach (var tag in Model)
|
| 42 |
+
{
|
| 43 |
+
<tr>
|
| 44 |
+
<td>
|
| 45 |
+
<div style="display: flex; align-items: center;">
|
| 46 |
+
<span class="badge" style="background: @(tag.Color ?? "var(--primary)"); color: white; margin-right: 0.75rem;">
|
| 47 |
+
@tag.Name
|
| 48 |
+
</span>
|
| 49 |
+
<strong>@tag.Name</strong>
|
| 50 |
+
</div>
|
| 51 |
+
</td>
|
| 52 |
+
<td>
|
| 53 |
+
@if (!string.IsNullOrEmpty(tag.Color))
|
| 54 |
+
{
|
| 55 |
+
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
| 56 |
+
<div style="width: 20px; height: 20px; background: @tag.Color; border-radius: 4px; border: 1px solid var(--light-2);"></div>
|
| 57 |
+
<code style="font-size: 0.75rem;">@tag.Color</code>
|
| 58 |
+
</div>
|
| 59 |
+
}
|
| 60 |
+
else
|
| 61 |
+
{
|
| 62 |
+
<span style="color: var(--dark-2);">默认</span>
|
| 63 |
+
}
|
| 64 |
+
</td>
|
| 65 |
+
<td>
|
| 66 |
+
<span class="badge badge-primary">@tag.ToolTags?.Count</span>
|
| 67 |
+
</td>
|
| 68 |
+
<td style="color: var(--dark-2); font-size: 0.875rem;">
|
| 69 |
+
@tag.CreatedAt.ToString("yyyy-MM-dd")
|
| 70 |
+
</td>
|
| 71 |
+
<td>
|
| 72 |
+
@if (tag.IsActive)
|
| 73 |
+
{
|
| 74 |
+
<span class="badge badge-success">启用</span>
|
| 75 |
+
}
|
| 76 |
+
else
|
| 77 |
+
{
|
| 78 |
+
<span class="badge badge-secondary">禁用</span>
|
| 79 |
+
}
|
| 80 |
+
</td>
|
| 81 |
+
<td>
|
| 82 |
+
<div style="display: flex; gap: 0.5rem;">
|
| 83 |
+
<button class="btn btn-outline btn-sm" onclick="editTag(@tag.Id, '@tag.Name', '@tag.Color')">
|
| 84 |
+
<i class="fas fa-edit"></i>
|
| 85 |
+
</button>
|
| 86 |
+
<button class="btn btn-outline btn-sm" onclick="toggleTagStatus(@tag.Id, @tag.IsActive.ToString().ToLower())" style="color: @(tag.IsActive ? "var(--warning)" : "var(--success)");">
|
| 87 |
+
<i class="fas fa-@(tag.IsActive ? "pause" : "play")"></i>
|
| 88 |
+
</button>
|
| 89 |
+
<button class="btn btn-outline btn-sm" onclick="deleteTag(@tag.Id, '@tag.Name')" style="color: var(--danger);">
|
| 90 |
+
<i class="fas fa-trash"></i>
|
| 91 |
+
</button>
|
| 92 |
+
</div>
|
| 93 |
+
</td>
|
| 94 |
+
</tr>
|
| 95 |
+
}
|
| 96 |
+
</tbody>
|
| 97 |
+
</table>
|
| 98 |
+
|
| 99 |
+
@if (!Model.Any())
|
| 100 |
+
{
|
| 101 |
+
<div style="padding: 3rem; text-align: center; color: var(--dark-2);">
|
| 102 |
+
<i class="fas fa-tags" style="font-size: 3rem; margin-bottom: 1rem; display: block; opacity: 0.3;"></i>
|
| 103 |
+
<h3 style="margin-bottom: 0.5rem;">暂无标签</h3>
|
| 104 |
+
<p style="margin-bottom: 1.5rem;">还没有创建任何标签</p>
|
| 105 |
+
<button class="btn btn-primary" onclick="openTagModal()">
|
| 106 |
+
<i class="fas fa-plus"></i>
|
| 107 |
+
创建第一个标签
|
| 108 |
+
</button>
|
| 109 |
+
</div>
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
<!-- 分页控件 -->
|
| 113 |
+
@if (totalPages > 1)
|
| 114 |
+
{
|
| 115 |
+
<div style="padding: 1.5rem; border-top: 1px solid var(--light-2); display: flex; justify-content: center; align-items: center; gap: 0.5rem;">
|
| 116 |
+
<button class="btn btn-outline btn-sm" onclick="changePage(1)" @(currentPage == 1 ? "disabled" : "")>
|
| 117 |
+
<i class="fas fa-angle-double-left"></i>
|
| 118 |
+
</button>
|
| 119 |
+
<button class="btn btn-outline btn-sm" onclick="changePage(@(currentPage - 1))" @(currentPage == 1 ? "disabled" : "")>
|
| 120 |
+
<i class="fas fa-angle-left"></i>
|
| 121 |
+
</button>
|
| 122 |
+
|
| 123 |
+
@{
|
| 124 |
+
var startPage = Math.Max(1, currentPage - 2);
|
| 125 |
+
var endPage = Math.Min(totalPages, currentPage + 2);
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
@for (int i = startPage; i <= endPage; i++)
|
| 129 |
+
{
|
| 130 |
+
<button class="btn @(i == currentPage ? "btn-primary" : "btn-outline") btn-sm" onclick="changePage(@i)">
|
| 131 |
+
@i
|
| 132 |
+
</button>
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
<button class="btn btn-outline btn-sm" onclick="changePage(@(currentPage + 1))" @(currentPage == totalPages ? "disabled" : "")>
|
| 136 |
+
<i class="fas fa-angle-right"></i>
|
| 137 |
+
</button>
|
| 138 |
+
<button class="btn btn-outline btn-sm" onclick="changePage(@totalPages)" @(currentPage == totalPages ? "disabled" : "")>
|
| 139 |
+
<i class="fas fa-angle-double-right"></i>
|
| 140 |
+
</button>
|
| 141 |
+
</div>
|
| 142 |
+
}
|
| 143 |
+
</div>
|
| 144 |
+
|
| 145 |
+
<!-- 标签模态框 -->
|
| 146 |
+
<div id="tagModal" class="modal" style="display: none;">
|
| 147 |
+
<div class="modal-backdrop" onclick="closeTagModal()"></div>
|
| 148 |
+
<div class="modal-content" style="max-width: 400px;">
|
| 149 |
+
<div class="modal-header">
|
| 150 |
+
<h3 id="modalTitle">添加标签</h3>
|
| 151 |
+
<button onclick="closeTagModal()" style="background: none; border: none; font-size: 1.5rem; cursor: pointer;">×</button>
|
| 152 |
+
</div>
|
| 153 |
+
|
| 154 |
+
<form id="tagForm" onsubmit="submitTag(event)">
|
| 155 |
+
<input type="hidden" id="tagId" name="id" value="0" />
|
| 156 |
+
|
| 157 |
+
<div class="form-group" style="margin-bottom: 1.5rem;">
|
| 158 |
+
<label style="display: block; font-weight: 600; margin-bottom: 0.5rem;">标签名称 *</label>
|
| 159 |
+
<input type="text" id="tagName" name="name" required
|
| 160 |
+
style="width: 100%; padding: 0.75rem; border: 1px solid var(--light-2); border-radius: var(--border-radius);"
|
| 161 |
+
placeholder="请输入标签名称" />
|
| 162 |
+
</div>
|
| 163 |
+
|
| 164 |
+
<div class="form-group" style="margin-bottom: 1.5rem;">
|
| 165 |
+
<label style="display: block; font-weight: 600; margin-bottom: 0.5rem;">标签颜色</label>
|
| 166 |
+
<input type="color" id="tagColor" name="color" value="#165DFF"
|
| 167 |
+
style="width: 100%; padding: 0.5rem; border: 1px solid var(--light-2); border-radius: var(--border-radius); height: 3rem;" />
|
| 168 |
+
<small style="color: var(--dark-2); font-size: 0.75rem;">选择标签的显示颜色</small>
|
| 169 |
+
</div>
|
| 170 |
+
|
| 171 |
+
<div class="modal-footer">
|
| 172 |
+
<button type="button" class="btn btn-outline" onclick="closeTagModal()">取消</button>
|
| 173 |
+
<button type="submit" class="btn btn-primary">
|
| 174 |
+
<span id="submitText">保存</span>
|
| 175 |
+
</button>
|
| 176 |
+
</div>
|
| 177 |
+
</form>
|
| 178 |
+
</div>
|
| 179 |
+
</div>
|
| 180 |
+
|
| 181 |
+
<style>
|
| 182 |
+
.modal {
|
| 183 |
+
position: fixed;
|
| 184 |
+
top: 0;
|
| 185 |
+
left: 0;
|
| 186 |
+
right: 0;
|
| 187 |
+
bottom: 0;
|
| 188 |
+
z-index: 10000;
|
| 189 |
+
display: flex;
|
| 190 |
+
align-items: center;
|
| 191 |
+
justify-content: center;
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
.modal-backdrop {
|
| 195 |
+
position: absolute;
|
| 196 |
+
top: 0;
|
| 197 |
+
left: 0;
|
| 198 |
+
right: 0;
|
| 199 |
+
bottom: 0;
|
| 200 |
+
background: rgba(0, 0, 0, 0.5);
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
.modal-content {
|
| 204 |
+
background: white;
|
| 205 |
+
border-radius: var(--border-radius);
|
| 206 |
+
position: relative;
|
| 207 |
+
z-index: 2;
|
| 208 |
+
max-height: 90vh;
|
| 209 |
+
overflow-y: auto;
|
| 210 |
+
width: 90%;
|
| 211 |
+
max-width: 600px;
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
.modal-header {
|
| 215 |
+
padding: 1.5rem;
|
| 216 |
+
border-bottom: 1px solid var(--light-2);
|
| 217 |
+
display: flex;
|
| 218 |
+
justify-content: space-between;
|
| 219 |
+
align-items: center;
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
.modal-header h3 {
|
| 223 |
+
margin: 0;
|
| 224 |
+
font-size: 1.25rem;
|
| 225 |
+
font-weight: 600;
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
.modal-footer {
|
| 229 |
+
padding: 1.5rem;
|
| 230 |
+
border-top: 1px solid var(--light-2);
|
| 231 |
+
display: flex;
|
| 232 |
+
justify-content: flex-end;
|
| 233 |
+
gap: 1rem;
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
.form-group input:focus {
|
| 237 |
+
border-color: var(--primary);
|
| 238 |
+
outline: none;
|
| 239 |
+
box-shadow: 0 0 0 3px rgba(22, 93, 255, 0.1);
|
| 240 |
+
}
|
| 241 |
+
</style>
|
| 242 |
+
|
| 243 |
+
<script>
|
| 244 |
+
let isEditing = false;
|
| 245 |
+
|
| 246 |
+
function changePage(page) {
|
| 247 |
+
window.location.href = `@Url.Action("Index", "Tag")?page=${page}`;
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
function openTagModal(editMode = false) {
|
| 251 |
+
document.getElementById('tagModal').style.display = 'flex';
|
| 252 |
+
document.getElementById('modalTitle').textContent = editMode ? '编辑标签' : '添加标签';
|
| 253 |
+
document.getElementById('submitText').textContent = editMode ? '更新' : '保存';
|
| 254 |
+
isEditing = editMode;
|
| 255 |
+
|
| 256 |
+
if (!editMode) {
|
| 257 |
+
document.getElementById('tagForm').reset();
|
| 258 |
+
document.getElementById('tagId').value = '0';
|
| 259 |
+
document.getElementById('tagColor').value = '#165DFF';
|
| 260 |
+
}
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
function closeTagModal() {
|
| 264 |
+
document.getElementById('tagModal').style.display = 'none';
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
function editTag(id, name, color) {
|
| 268 |
+
openTagModal(true);
|
| 269 |
+
document.getElementById('tagId').value = id;
|
| 270 |
+
document.getElementById('tagName').value = name;
|
| 271 |
+
document.getElementById('tagColor').value = color || '#165DFF';
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
async function submitTag(event) {
|
| 275 |
+
event.preventDefault();
|
| 276 |
+
|
| 277 |
+
const formData = new FormData(event.target);
|
| 278 |
+
const data = Object.fromEntries(formData.entries());
|
| 279 |
+
|
| 280 |
+
try {
|
| 281 |
+
const response = await fetch('/Tag/Save', {
|
| 282 |
+
method: 'POST',
|
| 283 |
+
headers: {
|
| 284 |
+
'Content-Type': 'application/json',
|
| 285 |
+
},
|
| 286 |
+
body: JSON.stringify(data)
|
| 287 |
+
});
|
| 288 |
+
|
| 289 |
+
if (response.ok) {
|
| 290 |
+
toolHub.showToast(isEditing ? '标签更新成功' : '标签添加成功', 'success');
|
| 291 |
+
closeTagModal();
|
| 292 |
+
setTimeout(() => location.reload(), 1000);
|
| 293 |
+
} else {
|
| 294 |
+
toolHub.showToast('操作失败,请重试', 'error');
|
| 295 |
+
}
|
| 296 |
+
} catch (error) {
|
| 297 |
+
console.error('提交失败:', error);
|
| 298 |
+
toolHub.showToast('操作失败,请重试', 'error');
|
| 299 |
+
}
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
async function toggleTagStatus(id, currentStatus) {
|
| 303 |
+
try {
|
| 304 |
+
const response = await fetch('/Tag/ToggleStatus', {
|
| 305 |
+
method: 'POST',
|
| 306 |
+
headers: {
|
| 307 |
+
'Content-Type': 'application/json',
|
| 308 |
+
},
|
| 309 |
+
body: JSON.stringify({ id: id, isActive: !currentStatus })
|
| 310 |
+
});
|
| 311 |
+
|
| 312 |
+
if (response.ok) {
|
| 313 |
+
toolHub.showToast('状态更新成功', 'success');
|
| 314 |
+
setTimeout(() => location.reload(), 1000);
|
| 315 |
+
} else {
|
| 316 |
+
toolHub.showToast('操作失败,请重试', 'error');
|
| 317 |
+
}
|
| 318 |
+
} catch (error) {
|
| 319 |
+
console.error('操作失败:', error);
|
| 320 |
+
toolHub.showToast('操作失败,请重试', 'error');
|
| 321 |
+
}
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
async function deleteTag(id, name) {
|
| 325 |
+
if (!confirm(`确定要删除标签"${name}"吗?\n注意:删除标签会影响使用该标签的工具。`)) {
|
| 326 |
+
return;
|
| 327 |
+
}
|
| 328 |
+
|
| 329 |
+
try {
|
| 330 |
+
const response = await fetch('/Tag/Delete', {
|
| 331 |
+
method: 'POST',
|
| 332 |
+
headers: {
|
| 333 |
+
'Content-Type': 'application/json',
|
| 334 |
+
},
|
| 335 |
+
body: JSON.stringify({ id: id })
|
| 336 |
+
});
|
| 337 |
+
|
| 338 |
+
if (response.ok) {
|
| 339 |
+
toolHub.showToast('标签删除成功', 'success');
|
| 340 |
+
setTimeout(() => location.reload(), 1000);
|
| 341 |
+
} else {
|
| 342 |
+
toolHub.showToast('删除失败,可能该标签下还有工具', 'error');
|
| 343 |
+
}
|
| 344 |
+
} catch (error) {
|
| 345 |
+
console.error('删除失败:', error);
|
| 346 |
+
toolHub.showToast('删除失败,请重试', 'error');
|
| 347 |
+
}
|
| 348 |
+
}
|
| 349 |
+
</script>
|
Views/Tool/Index.cshtml
ADDED
|
@@ -0,0 +1,479 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@model List<ToolHub.Models.Tool>
|
| 2 |
+
@{
|
| 3 |
+
ViewData["Title"] = "工具管理";
|
| 4 |
+
Layout = "_AdminLayout";
|
| 5 |
+
var categories = ViewBag.Categories as List<ToolHub.Models.Category> ?? new List<ToolHub.Models.Category>();
|
| 6 |
+
var currentCategory = ViewBag.CurrentCategory as int? ?? 0;
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
<!-- 页面头部 -->
|
| 10 |
+
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;">
|
| 11 |
+
<div>
|
| 12 |
+
<h2 style="font-size: 1.5rem; font-weight: 700; margin: 0; color: var(--dark);">工具管理</h2>
|
| 13 |
+
<p style="color: var(--dark-2); margin: 0.5rem 0 0;">管理平台上的所有工具,添加、编辑或删除工具</p>
|
| 14 |
+
</div>
|
| 15 |
+
<button class="btn btn-primary" onclick="openToolModal()">
|
| 16 |
+
<i class="fas fa-plus"></i>
|
| 17 |
+
添加工具
|
| 18 |
+
</button>
|
| 19 |
+
</div>
|
| 20 |
+
|
| 21 |
+
<!-- 筛选和搜索 -->
|
| 22 |
+
<div style="background: white; padding: 1.5rem; border-radius: var(--border-radius); box-shadow: var(--shadow-sm); margin-bottom: 2rem;">
|
| 23 |
+
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr auto; gap: 1rem; align-items: end;">
|
| 24 |
+
<div>
|
| 25 |
+
<label style="display: block; font-weight: 600; margin-bottom: 0.5rem; font-size: 0.875rem;">分类筛选</label>
|
| 26 |
+
<select id="categoryFilter" onchange="filterTools()" style="width: 100%; padding: 0.75rem; border: 1px solid var(--light-2); border-radius: var(--border-radius);">
|
| 27 |
+
<option value="0">全部分类</option>
|
| 28 |
+
@foreach (var category in categories)
|
| 29 |
+
{
|
| 30 |
+
<option value="@category.Id" selected="@(currentCategory == category.Id)">@category.Name</option>
|
| 31 |
+
}
|
| 32 |
+
</select>
|
| 33 |
+
</div>
|
| 34 |
+
|
| 35 |
+
<div>
|
| 36 |
+
<label style="display: block; font-weight: 600; margin-bottom: 0.5rem; font-size: 0.875rem;">状态筛选</label>
|
| 37 |
+
<select id="statusFilter" onchange="filterTools()" style="width: 100%; padding: 0.75rem; border: 1px solid var(--light-2); border-radius: var(--border-radius);">
|
| 38 |
+
<option value="">全部状态</option>
|
| 39 |
+
<option value="active">启用</option>
|
| 40 |
+
<option value="inactive">禁用</option>
|
| 41 |
+
<option value="hot">热门</option>
|
| 42 |
+
<option value="new">新品</option>
|
| 43 |
+
<option value="recommended">推荐</option>
|
| 44 |
+
</select>
|
| 45 |
+
</div>
|
| 46 |
+
|
| 47 |
+
<div>
|
| 48 |
+
<label style="display: block; font-weight: 600; margin-bottom: 0.5rem; font-size: 0.875rem;">搜索工具</label>
|
| 49 |
+
<input type="text" id="searchInput" placeholder="输入工具名称..." onkeyup="filterTools()"
|
| 50 |
+
style="width: 100%; padding: 0.75rem; border: 1px solid var(--light-2); border-radius: var(--border-radius);" />
|
| 51 |
+
</div>
|
| 52 |
+
|
| 53 |
+
<button class="btn btn-outline" onclick="clearFilters()">
|
| 54 |
+
<i class="fas fa-undo"></i>
|
| 55 |
+
重置
|
| 56 |
+
</button>
|
| 57 |
+
</div>
|
| 58 |
+
</div>
|
| 59 |
+
|
| 60 |
+
<!-- 工具列表 -->
|
| 61 |
+
<div class="data-table">
|
| 62 |
+
<div style="padding: 1rem 1.5rem; border-bottom: 1px solid var(--light-2); display: flex; justify-content: space-between; align-items: center;">
|
| 63 |
+
<span style="font-weight: 600;">工具列表 (@Model.Count)</span>
|
| 64 |
+
<div style="display: flex; gap: 0.5rem;">
|
| 65 |
+
<button class="btn btn-outline btn-sm" onclick="toggleView('table')" id="tableViewBtn">
|
| 66 |
+
<i class="fas fa-list"></i> 表格
|
| 67 |
+
</button>
|
| 68 |
+
<button class="btn btn-outline btn-sm" onclick="toggleView('grid')" id="gridViewBtn">
|
| 69 |
+
<i class="fas fa-th"></i> 网格
|
| 70 |
+
</button>
|
| 71 |
+
</div>
|
| 72 |
+
</div>
|
| 73 |
+
|
| 74 |
+
<!-- 表格视图 -->
|
| 75 |
+
<div id="tableView">
|
| 76 |
+
<table>
|
| 77 |
+
<thead>
|
| 78 |
+
<tr>
|
| 79 |
+
<th>工具信息</th>
|
| 80 |
+
<th>分类</th>
|
| 81 |
+
<th>统计</th>
|
| 82 |
+
<th>标签</th>
|
| 83 |
+
<th>状态</th>
|
| 84 |
+
<th>创建时间</th>
|
| 85 |
+
<th>操作</th>
|
| 86 |
+
</tr>
|
| 87 |
+
</thead>
|
| 88 |
+
<tbody id="toolsTableBody">
|
| 89 |
+
@foreach (var tool in Model)
|
| 90 |
+
{
|
| 91 |
+
<tr data-category="@tool.CategoryId" data-status="@(tool.IsActive ? "active" : "inactive")"
|
| 92 |
+
data-hot="@tool.IsHot.ToString().ToLower()" data-new="@tool.IsNew.ToString().ToLower()" data-recommended="@tool.IsRecommended.ToString().ToLower()">
|
| 93 |
+
<td>
|
| 94 |
+
<div style="display: flex; align-items: center;">
|
| 95 |
+
@if (!string.IsNullOrEmpty(tool.Image))
|
| 96 |
+
{
|
| 97 |
+
<img src="@tool.Image" alt="@tool.Name" style="width: 40px; height: 40px; border-radius: var(--border-radius); margin-right: 0.75rem; object-fit: cover;" />
|
| 98 |
+
}
|
| 99 |
+
else
|
| 100 |
+
{
|
| 101 |
+
<div style="width: 40px; height: 40px; background: linear-gradient(135deg, var(--primary), var(--secondary)); border-radius: var(--border-radius); display: flex; align-items: center; justify-content: center; margin-right: 0.75rem;">
|
| 102 |
+
<i class="@tool.Icon" style="color: white;"></i>
|
| 103 |
+
</div>
|
| 104 |
+
}
|
| 105 |
+
<div>
|
| 106 |
+
<strong class="tool-name">@tool.Name</strong>
|
| 107 |
+
@if (!string.IsNullOrEmpty(tool.Description))
|
| 108 |
+
{
|
| 109 |
+
<br><small style="color: var(--dark-2);">@(tool.Description.Length > 50 ? tool.Description.Substring(0, 50) + "..." : tool.Description)</small>
|
| 110 |
+
}
|
| 111 |
+
</div>
|
| 112 |
+
</div>
|
| 113 |
+
</td>
|
| 114 |
+
<td>
|
| 115 |
+
<span class="badge badge-primary">@tool.Category?.Name</span>
|
| 116 |
+
</td>
|
| 117 |
+
<td>
|
| 118 |
+
<div style="font-size: 0.875rem;">
|
| 119 |
+
<div style="margin-bottom: 0.25rem;">
|
| 120 |
+
<i class="fas fa-eye" style="color: var(--primary); margin-right: 0.25rem;"></i>
|
| 121 |
+
@(tool.ViewCount > 1000 ? (tool.ViewCount / 1000).ToString("F0") + "K" : tool.ViewCount.ToString())
|
| 122 |
+
</div>
|
| 123 |
+
@if (tool.Rating > 0)
|
| 124 |
+
{
|
| 125 |
+
<div>
|
| 126 |
+
<i class="fas fa-star" style="color: var(--warning); margin-right: 0.25rem;"></i>
|
| 127 |
+
@tool.Rating.ToString("F1")
|
| 128 |
+
</div>
|
| 129 |
+
}
|
| 130 |
+
</div>
|
| 131 |
+
</td>
|
| 132 |
+
<td>
|
| 133 |
+
<div style="display: flex; flex-wrap: wrap; gap: 0.25rem;">
|
| 134 |
+
@if (tool.IsHot)
|
| 135 |
+
{
|
| 136 |
+
<span class="badge badge-danger">热门</span>
|
| 137 |
+
}
|
| 138 |
+
@if (tool.IsNew)
|
| 139 |
+
{
|
| 140 |
+
<span class="badge badge-warning">新品</span>
|
| 141 |
+
}
|
| 142 |
+
@if (tool.IsRecommended)
|
| 143 |
+
{
|
| 144 |
+
<span class="badge badge-success">推荐</span>
|
| 145 |
+
}
|
| 146 |
+
</div>
|
| 147 |
+
</td>
|
| 148 |
+
<td>
|
| 149 |
+
@if (tool.IsActive)
|
| 150 |
+
{
|
| 151 |
+
<span class="badge badge-success">启用</span>
|
| 152 |
+
}
|
| 153 |
+
else
|
| 154 |
+
{
|
| 155 |
+
<span class="badge badge-secondary">禁用</span>
|
| 156 |
+
}
|
| 157 |
+
</td>
|
| 158 |
+
<td style="color: var(--dark-2); font-size: 0.875rem;">
|
| 159 |
+
@tool.CreatedAt.ToString("yyyy-MM-dd")
|
| 160 |
+
</td>
|
| 161 |
+
<td>
|
| 162 |
+
<div style="display: flex; gap: 0.5rem;">
|
| 163 |
+
<button class="btn btn-outline btn-sm" onclick="editTool(@tool.Id)" title="编辑">
|
| 164 |
+
<i class="fas fa-edit"></i>
|
| 165 |
+
</button>
|
| 166 |
+
<button class="btn btn-outline btn-sm" onclick="toggleToolStatus(@tool.Id, @tool.IsActive.ToString().ToLower())"
|
| 167 |
+
style="color: @(tool.IsActive ? "var(--warning)" : "var(--success)");" title="@(tool.IsActive ? "禁用" : "启用")">
|
| 168 |
+
<i class="fas fa-@(tool.IsActive ? "pause" : "play")"></i>
|
| 169 |
+
</button>
|
| 170 |
+
<button class="btn btn-outline btn-sm" onclick="deleteTool(@tool.Id, '@tool.Name')"
|
| 171 |
+
style="color: var(--danger);" title="删除">
|
| 172 |
+
<i class="fas fa-trash"></i>
|
| 173 |
+
</button>
|
| 174 |
+
</div>
|
| 175 |
+
</td>
|
| 176 |
+
</tr>
|
| 177 |
+
}
|
| 178 |
+
</tbody>
|
| 179 |
+
</table>
|
| 180 |
+
</div>
|
| 181 |
+
|
| 182 |
+
@if (!Model.Any())
|
| 183 |
+
{
|
| 184 |
+
<div style="padding: 3rem; text-align: center; color: var(--dark-2);">
|
| 185 |
+
<i class="fas fa-tools" style="font-size: 3rem; margin-bottom: 1rem; display: block; opacity: 0.3;"></i>
|
| 186 |
+
<h3 style="margin-bottom: 0.5rem;">暂无工具</h3>
|
| 187 |
+
<p style="margin-bottom: 1.5rem;">还没有添加任何工具</p>
|
| 188 |
+
<button class="btn btn-primary" onclick="openToolModal()">
|
| 189 |
+
<i class="fas fa-plus"></i>
|
| 190 |
+
添加第一个工具
|
| 191 |
+
</button>
|
| 192 |
+
</div>
|
| 193 |
+
}
|
| 194 |
+
</div>
|
| 195 |
+
|
| 196 |
+
<!-- 工具模态框 -->
|
| 197 |
+
<div id="toolModal" class="modal" style="display: none;">
|
| 198 |
+
<div class="modal-backdrop" onclick="closeToolModal()"></div>
|
| 199 |
+
<div class="modal-content" style="max-width: 600px;">
|
| 200 |
+
<div class="modal-header">
|
| 201 |
+
<h3 id="toolModalTitle">添加工具</h3>
|
| 202 |
+
<button onclick="closeToolModal()" style="background: none; border: none; font-size: 1.5rem; cursor: pointer;">×</button>
|
| 203 |
+
</div>
|
| 204 |
+
|
| 205 |
+
<form id="toolForm" onsubmit="submitTool(event)">
|
| 206 |
+
<div class="modal-body">
|
| 207 |
+
<input type="hidden" id="toolId" name="id" value="0" />
|
| 208 |
+
|
| 209 |
+
<div class="form-group">
|
| 210 |
+
<label>工具名称 *</label>
|
| 211 |
+
<input type="text" id="toolName" name="name" required placeholder="请输入工具名称" />
|
| 212 |
+
</div>
|
| 213 |
+
|
| 214 |
+
<div class="form-group">
|
| 215 |
+
<label>工具描述</label>
|
| 216 |
+
<textarea id="toolDescription" name="description" rows="3" placeholder="请输入工具描述"></textarea>
|
| 217 |
+
</div>
|
| 218 |
+
|
| 219 |
+
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
|
| 220 |
+
<div class="form-group">
|
| 221 |
+
<label>所属分类 *</label>
|
| 222 |
+
<select id="toolCategory" name="categoryId" required>
|
| 223 |
+
<option value="">请选择分类</option>
|
| 224 |
+
@foreach (var category in categories)
|
| 225 |
+
{
|
| 226 |
+
<option value="@category.Id">@category.Name</option>
|
| 227 |
+
}
|
| 228 |
+
</select>
|
| 229 |
+
</div>
|
| 230 |
+
|
| 231 |
+
<div class="form-group">
|
| 232 |
+
<label>图标类名</label>
|
| 233 |
+
<input type="text" id="toolIcon" name="icon" placeholder="如: fas fa-file-pdf" />
|
| 234 |
+
</div>
|
| 235 |
+
</div>
|
| 236 |
+
|
| 237 |
+
<div class="form-group">
|
| 238 |
+
<label>工具链接</label>
|
| 239 |
+
<input type="url" id="toolUrl" name="url" placeholder="https://example.com" />
|
| 240 |
+
</div>
|
| 241 |
+
|
| 242 |
+
<div class="form-group">
|
| 243 |
+
<label>工具图片链接</label>
|
| 244 |
+
<input type="url" id="toolImage" name="image" placeholder="https://example.com/image.jpg" />
|
| 245 |
+
</div>
|
| 246 |
+
|
| 247 |
+
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 1rem;">
|
| 248 |
+
<div class="form-group">
|
| 249 |
+
<label style="display: flex; align-items: center; gap: 0.5rem;">
|
| 250 |
+
<input type="checkbox" id="toolIsHot" name="isHot" />
|
| 251 |
+
<span>热门工具</span>
|
| 252 |
+
</label>
|
| 253 |
+
</div>
|
| 254 |
+
|
| 255 |
+
<div class="form-group">
|
| 256 |
+
<label style="display: flex; align-items: center; gap: 0.5rem;">
|
| 257 |
+
<input type="checkbox" id="toolIsNew" name="isNew" />
|
| 258 |
+
<span>新品工具</span>
|
| 259 |
+
</label>
|
| 260 |
+
</div>
|
| 261 |
+
|
| 262 |
+
<div class="form-group">
|
| 263 |
+
<label style="display: flex; align-items: center; gap: 0.5rem;">
|
| 264 |
+
<input type="checkbox" id="toolIsRecommended" name="isRecommended" />
|
| 265 |
+
<span>推荐工具</span>
|
| 266 |
+
</label>
|
| 267 |
+
</div>
|
| 268 |
+
</div>
|
| 269 |
+
|
| 270 |
+
<div class="form-group">
|
| 271 |
+
<label>排序顺序</label>
|
| 272 |
+
<input type="number" id="toolSort" name="sortOrder" min="0" value="0" placeholder="数字越小排序越靠前" />
|
| 273 |
+
</div>
|
| 274 |
+
</div>
|
| 275 |
+
|
| 276 |
+
<div class="modal-footer">
|
| 277 |
+
<button type="button" class="btn btn-outline" onclick="closeToolModal()">取消</button>
|
| 278 |
+
<button type="submit" class="btn btn-primary">
|
| 279 |
+
<span id="toolSubmitText">保存</span>
|
| 280 |
+
</button>
|
| 281 |
+
</div>
|
| 282 |
+
</form>
|
| 283 |
+
</div>
|
| 284 |
+
</div>
|
| 285 |
+
|
| 286 |
+
<script>
|
| 287 |
+
let currentView = 'table';
|
| 288 |
+
let isEditingTool = false;
|
| 289 |
+
|
| 290 |
+
function toggleView(view) {
|
| 291 |
+
currentView = view;
|
| 292 |
+
document.getElementById('tableView').style.display = view === 'table' ? 'block' : 'none';
|
| 293 |
+
|
| 294 |
+
document.getElementById('tableViewBtn').classList.toggle('btn-primary', view === 'table');
|
| 295 |
+
document.getElementById('tableViewBtn').classList.toggle('btn-outline', view !== 'table');
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
function filterTools() {
|
| 299 |
+
const categoryFilter = document.getElementById('categoryFilter').value;
|
| 300 |
+
const statusFilter = document.getElementById('statusFilter').value;
|
| 301 |
+
const searchInput = document.getElementById('searchInput').value.toLowerCase();
|
| 302 |
+
|
| 303 |
+
const tableRows = document.querySelectorAll('#toolsTableBody tr');
|
| 304 |
+
|
| 305 |
+
tableRows.forEach(row => {
|
| 306 |
+
const category = row.getAttribute('data-category');
|
| 307 |
+
const status = row.getAttribute('data-status');
|
| 308 |
+
const isHot = row.getAttribute('data-hot') === 'true';
|
| 309 |
+
const isNew = row.getAttribute('data-new') === 'true';
|
| 310 |
+
const isRecommended = row.getAttribute('data-recommended') === 'true';
|
| 311 |
+
|
| 312 |
+
const toolName = row.querySelector('.tool-name').textContent.toLowerCase();
|
| 313 |
+
|
| 314 |
+
let showRow = true;
|
| 315 |
+
|
| 316 |
+
// 分类筛选
|
| 317 |
+
if (categoryFilter !== '0' && category !== categoryFilter) {
|
| 318 |
+
showRow = false;
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
// 状态筛选
|
| 322 |
+
if (statusFilter) {
|
| 323 |
+
switch(statusFilter) {
|
| 324 |
+
case 'active':
|
| 325 |
+
if (status !== 'active') showRow = false;
|
| 326 |
+
break;
|
| 327 |
+
case 'inactive':
|
| 328 |
+
if (status !== 'inactive') showRow = false;
|
| 329 |
+
break;
|
| 330 |
+
case 'hot':
|
| 331 |
+
if (!isHot) showRow = false;
|
| 332 |
+
break;
|
| 333 |
+
case 'new':
|
| 334 |
+
if (!isNew) showRow = false;
|
| 335 |
+
break;
|
| 336 |
+
case 'recommended':
|
| 337 |
+
if (!isRecommended) showRow = false;
|
| 338 |
+
break;
|
| 339 |
+
}
|
| 340 |
+
}
|
| 341 |
+
|
| 342 |
+
// 搜索筛选
|
| 343 |
+
if (searchInput && !toolName.includes(searchInput)) {
|
| 344 |
+
showRow = false;
|
| 345 |
+
}
|
| 346 |
+
|
| 347 |
+
row.style.display = showRow ? '' : 'none';
|
| 348 |
+
});
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
function clearFilters() {
|
| 352 |
+
document.getElementById('categoryFilter').value = '0';
|
| 353 |
+
document.getElementById('statusFilter').value = '';
|
| 354 |
+
document.getElementById('searchInput').value = '';
|
| 355 |
+
filterTools();
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
function openToolModal(editMode = false) {
|
| 359 |
+
document.getElementById('toolModal').style.display = 'flex';
|
| 360 |
+
document.getElementById('toolModalTitle').textContent = editMode ? '编辑工具' : '添加工具';
|
| 361 |
+
document.getElementById('toolSubmitText').textContent = editMode ? '更新' : '保存';
|
| 362 |
+
isEditingTool = editMode;
|
| 363 |
+
|
| 364 |
+
if (!editMode) {
|
| 365 |
+
document.getElementById('toolForm').reset();
|
| 366 |
+
document.getElementById('toolId').value = '0';
|
| 367 |
+
}
|
| 368 |
+
}
|
| 369 |
+
|
| 370 |
+
function closeToolModal() {
|
| 371 |
+
document.getElementById('toolModal').style.display = 'none';
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
async function editTool(id) {
|
| 375 |
+
try {
|
| 376 |
+
const response = await fetch(`/Tool/Get?id=${id}`);
|
| 377 |
+
if (response.ok) {
|
| 378 |
+
const tool = await response.json();
|
| 379 |
+
openToolModal(true);
|
| 380 |
+
|
| 381 |
+
document.getElementById('toolId').value = tool.id;
|
| 382 |
+
document.getElementById('toolName').value = tool.name;
|
| 383 |
+
document.getElementById('toolDescription').value = tool.description || '';
|
| 384 |
+
document.getElementById('toolCategory').value = tool.categoryId;
|
| 385 |
+
document.getElementById('toolIcon').value = tool.icon || '';
|
| 386 |
+
document.getElementById('toolUrl').value = tool.url || '';
|
| 387 |
+
document.getElementById('toolImage').value = tool.image || '';
|
| 388 |
+
document.getElementById('toolIsHot').checked = tool.isHot;
|
| 389 |
+
document.getElementById('toolIsNew').checked = tool.isNew;
|
| 390 |
+
document.getElementById('toolIsRecommended').checked = tool.isRecommended;
|
| 391 |
+
document.getElementById('toolSort').value = tool.sortOrder;
|
| 392 |
+
}
|
| 393 |
+
} catch (error) {
|
| 394 |
+
console.error('获取工具信息失败:', error);
|
| 395 |
+
toolHub.showToast('获取工具信息失败', 'error');
|
| 396 |
+
}
|
| 397 |
+
}
|
| 398 |
+
|
| 399 |
+
async function submitTool(event) {
|
| 400 |
+
event.preventDefault();
|
| 401 |
+
|
| 402 |
+
const formData = new FormData(event.target);
|
| 403 |
+
const data = Object.fromEntries(formData.entries());
|
| 404 |
+
|
| 405 |
+
// 处理复选框
|
| 406 |
+
data.isHot = document.getElementById('toolIsHot').checked;
|
| 407 |
+
data.isNew = document.getElementById('toolIsNew').checked;
|
| 408 |
+
data.isRecommended = document.getElementById('toolIsRecommended').checked;
|
| 409 |
+
|
| 410 |
+
try {
|
| 411 |
+
const response = await fetch('/Tool/Save', {
|
| 412 |
+
method: 'POST',
|
| 413 |
+
headers: {
|
| 414 |
+
'Content-Type': 'application/json',
|
| 415 |
+
},
|
| 416 |
+
body: JSON.stringify(data)
|
| 417 |
+
});
|
| 418 |
+
|
| 419 |
+
if (response.ok) {
|
| 420 |
+
toolHub.showToast(isEditingTool ? '工具更新成功' : '工具添加成功', 'success');
|
| 421 |
+
closeToolModal();
|
| 422 |
+
setTimeout(() => location.reload(), 1000);
|
| 423 |
+
} else {
|
| 424 |
+
toolHub.showToast('操作失败,请重试', 'error');
|
| 425 |
+
}
|
| 426 |
+
} catch (error) {
|
| 427 |
+
console.error('提交失败:', error);
|
| 428 |
+
toolHub.showToast('操作失败,请重试', 'error');
|
| 429 |
+
}
|
| 430 |
+
}
|
| 431 |
+
|
| 432 |
+
async function toggleToolStatus(id, currentStatus) {
|
| 433 |
+
try {
|
| 434 |
+
const response = await fetch('/Tool/ToggleStatus', {
|
| 435 |
+
method: 'POST',
|
| 436 |
+
headers: {
|
| 437 |
+
'Content-Type': 'application/json',
|
| 438 |
+
},
|
| 439 |
+
body: JSON.stringify({ id: id, isActive: !currentStatus })
|
| 440 |
+
});
|
| 441 |
+
|
| 442 |
+
if (response.ok) {
|
| 443 |
+
toolHub.showToast('状态更新成功', 'success');
|
| 444 |
+
setTimeout(() => location.reload(), 1000);
|
| 445 |
+
} else {
|
| 446 |
+
toolHub.showToast('操作失败,请重试', 'error');
|
| 447 |
+
}
|
| 448 |
+
} catch (error) {
|
| 449 |
+
console.error('操作失败:', error);
|
| 450 |
+
toolHub.showToast('操作失败,请重试', 'error');
|
| 451 |
+
}
|
| 452 |
+
}
|
| 453 |
+
|
| 454 |
+
async function deleteTool(id, name) {
|
| 455 |
+
if (!confirm(`确定要删除工具"${name}"吗?此操作不可恢复。`)) {
|
| 456 |
+
return;
|
| 457 |
+
}
|
| 458 |
+
|
| 459 |
+
try {
|
| 460 |
+
const response = await fetch('/Tool/Delete', {
|
| 461 |
+
method: 'POST',
|
| 462 |
+
headers: {
|
| 463 |
+
'Content-Type': 'application/json',
|
| 464 |
+
},
|
| 465 |
+
body: JSON.stringify({ id: id })
|
| 466 |
+
});
|
| 467 |
+
|
| 468 |
+
if (response.ok) {
|
| 469 |
+
toolHub.showToast('工具删除成功', 'success');
|
| 470 |
+
setTimeout(() => location.reload(), 1000);
|
| 471 |
+
} else {
|
| 472 |
+
toolHub.showToast('删除失败,请重试', 'error');
|
| 473 |
+
}
|
| 474 |
+
} catch (error) {
|
| 475 |
+
console.error('删除失败:', error);
|
| 476 |
+
toolHub.showToast('删除失败,请重试', 'error');
|
| 477 |
+
}
|
| 478 |
+
}
|
| 479 |
+
</script>
|
Views/ToolStatistics/Index.cshtml
ADDED
|
@@ -0,0 +1,493 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@{
|
| 2 |
+
ViewData["Title"] = "工具统计";
|
| 3 |
+
Layout = "_AdminLayout";
|
| 4 |
+
var toolId = ViewData["ToolId"] as int? ?? 0;
|
| 5 |
+
var toolName = ViewData["ToolName"] as string ?? "所有工具";
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
@section Scripts {
|
| 9 |
+
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.js"></script>
|
| 10 |
+
<script>
|
| 11 |
+
// 确保Chart.js加载完成
|
| 12 |
+
window.addEventListener('load', function() {
|
| 13 |
+
if (typeof Chart === 'undefined') {
|
| 14 |
+
console.error('Chart.js 未能正确加载');
|
| 15 |
+
} else {
|
| 16 |
+
console.log('Chart.js 加载成功');
|
| 17 |
+
}
|
| 18 |
+
});
|
| 19 |
+
</script>
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
<!-- 页面头部 -->
|
| 23 |
+
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;">
|
| 24 |
+
<div>
|
| 25 |
+
<h2 style="font-size: 1.5rem; font-weight: 700; margin: 0; color: var(--dark);">工具统计</h2>
|
| 26 |
+
<p style="color: var(--dark-2); margin: 0.5rem 0 0;">查看工具的详细使用统计和用户访问记录</p>
|
| 27 |
+
</div>
|
| 28 |
+
<div style="display: flex; gap: 1rem;">
|
| 29 |
+
<a href="@Url.Action("Index", "Tool")" class="btn btn-secondary">
|
| 30 |
+
<i class="fas fa-arrow-left"></i>
|
| 31 |
+
返回工具管理
|
| 32 |
+
</a>
|
| 33 |
+
</div>
|
| 34 |
+
</div>
|
| 35 |
+
|
| 36 |
+
<!-- 工具选择 -->
|
| 37 |
+
<div style="background: white; padding: 1.5rem; border-radius: var(--border-radius); box-shadow: var(--shadow-sm); margin-bottom: 2rem;">
|
| 38 |
+
<div style="display: grid; grid-template-columns: 1fr auto; gap: 1rem; align-items: end;">
|
| 39 |
+
<div>
|
| 40 |
+
<label style="display: block; font-weight: 600; margin-bottom: 0.5rem; font-size: 0.875rem;">选择工具</label>
|
| 41 |
+
<select id="toolSelector" onchange="loadToolStatistics()" style="width: 100%; padding: 0.75rem; border: 1px solid var(--light-2); border-radius: var(--border-radius);">
|
| 42 |
+
<option value="0">所有工具</option>
|
| 43 |
+
</select>
|
| 44 |
+
</div>
|
| 45 |
+
<div>
|
| 46 |
+
<label style="display: block; font-weight: 600; margin-bottom: 0.5rem; font-size: 0.875rem;">统计日期</label>
|
| 47 |
+
<input type="date" id="statDate" onchange="loadToolStatistics()"
|
| 48 |
+
value="@DateTime.Today.ToString("yyyy-MM-dd")"
|
| 49 |
+
style="padding: 0.75rem; border: 1px solid var(--light-2); border-radius: var(--border-radius);" />
|
| 50 |
+
</div>
|
| 51 |
+
</div>
|
| 52 |
+
</div>
|
| 53 |
+
|
| 54 |
+
<!-- 统计概览 -->
|
| 55 |
+
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 1.5rem; margin-bottom: 2rem;">
|
| 56 |
+
<div style="background: white; padding: 1.5rem; border-radius: var(--border-radius); box-shadow: var(--shadow-sm);">
|
| 57 |
+
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem;">
|
| 58 |
+
<h3 style="font-size: 1rem; font-weight: 600; margin: 0; color: var(--dark);">今日访问量</h3>
|
| 59 |
+
<i class="fas fa-eye" style="color: var(--primary); font-size: 1.25rem;"></i>
|
| 60 |
+
</div>
|
| 61 |
+
<div id="todayViews" style="font-size: 2rem; font-weight: 700; color: var(--primary);">-</div>
|
| 62 |
+
<div style="font-size: 0.875rem; color: var(--dark-2); margin-top: 0.5rem;">总访问量: <span id="totalViews">-</span></div>
|
| 63 |
+
</div>
|
| 64 |
+
|
| 65 |
+
<div style="background: white; padding: 1.5rem; border-radius: var(--border-radius); box-shadow: var(--shadow-sm);">
|
| 66 |
+
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem;">
|
| 67 |
+
<h3 style="font-size: 1rem; font-weight: 600; margin: 0; color: var(--dark);">今日收藏</h3>
|
| 68 |
+
<i class="fas fa-heart" style="color: var(--danger); font-size: 1.25rem;"></i>
|
| 69 |
+
</div>
|
| 70 |
+
<div id="todayFavorites" style="font-size: 2rem; font-weight: 700; color: var(--danger);">-</div>
|
| 71 |
+
<div style="font-size: 0.875rem; color: var(--dark-2); margin-top: 0.5rem;">总收藏数: <span id="totalFavorites">-</span></div>
|
| 72 |
+
</div>
|
| 73 |
+
|
| 74 |
+
<div style="background: white; padding: 1.5rem; border-radius: var(--border-radius); box-shadow: var(--shadow-sm);">
|
| 75 |
+
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem;">
|
| 76 |
+
<h3 style="font-size: 1rem; font-weight: 600; margin: 0; color: var(--dark);">今日分享</h3>
|
| 77 |
+
<i class="fas fa-share" style="color: var(--success); font-size: 1.25rem;"></i>
|
| 78 |
+
</div>
|
| 79 |
+
<div id="todayShares" style="font-size: 2rem; font-weight: 700; color: var(--success);">-</div>
|
| 80 |
+
<div style="font-size: 0.875rem; color: var(--dark-2); margin-top: 0.5rem;">平均停留时间: <span id="avgDuration">-</span>秒</div>
|
| 81 |
+
</div>
|
| 82 |
+
|
| 83 |
+
<div style="background: white; padding: 1.5rem; border-radius: var(--border-radius); box-shadow: var(--shadow-sm);">
|
| 84 |
+
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem;">
|
| 85 |
+
<h3 style="font-size: 1rem; font-weight: 600; margin: 0; color: var(--dark);">今日下载</h3>
|
| 86 |
+
<i class="fas fa-download" style="color: var(--warning); font-size: 1.25rem;"></i>
|
| 87 |
+
</div>
|
| 88 |
+
<div id="todayDownloads" style="font-size: 2rem; font-weight: 700; color: var(--warning);">-</div>
|
| 89 |
+
<div style="font-size: 0.875rem; color: var(--dark-2); margin-top: 0.5rem;">跳出率: <span id="bounceRate">-</span>%</div>
|
| 90 |
+
</div>
|
| 91 |
+
</div>
|
| 92 |
+
|
| 93 |
+
<!-- 使用趋势图表 -->
|
| 94 |
+
<div style="background: white; padding: 1.5rem; border-radius: var(--border-radius); box-shadow: var(--shadow-sm); margin-bottom: 2rem;">
|
| 95 |
+
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
|
| 96 |
+
<h3 style="font-size: 1.25rem; font-weight: 600; margin: 0; color: var(--dark);">使用趋势 (最近30天)</h3>
|
| 97 |
+
<div style="display: flex; gap: 0.5rem;">
|
| 98 |
+
<button class="btn btn-outline btn-sm" onclick="loadUsageTrend(7)">7天</button>
|
| 99 |
+
<button class="btn btn-outline btn-sm" onclick="loadUsageTrend(30)">30天</button>
|
| 100 |
+
<button class="btn btn-outline btn-sm" onclick="loadUsageTrend(90)">90天</button>
|
| 101 |
+
</div>
|
| 102 |
+
</div>
|
| 103 |
+
<div id="trendChart" style="height: 300px; display: flex; align-items: center; justify-content: center; color: var(--dark-2);">
|
| 104 |
+
<div style="text-align: center;">
|
| 105 |
+
<i class="fas fa-chart-line" style="font-size: 3rem; margin-bottom: 1rem; display: block; opacity: 0.3;"></i>
|
| 106 |
+
<p>请选择工具查看使用趋势</p>
|
| 107 |
+
</div>
|
| 108 |
+
</div>
|
| 109 |
+
</div>
|
| 110 |
+
|
| 111 |
+
<!-- 访问记录 -->
|
| 112 |
+
<div style="background: white; padding: 1.5rem; border-radius: var(--border-radius); box-shadow: var(--shadow-sm);">
|
| 113 |
+
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
|
| 114 |
+
<h3 style="font-size: 1.25rem; font-weight: 600; margin: 0; color: var(--dark);">最近访问记录</h3>
|
| 115 |
+
<div style="display: flex; gap: 0.5rem;">
|
| 116 |
+
<input type="date" id="startDate" onchange="loadAccessRecords()"
|
| 117 |
+
value="@DateTime.Today.AddDays(-7).ToString("yyyy-MM-dd")"
|
| 118 |
+
style="padding: 0.5rem; border: 1px solid var(--light-2); border-radius: var(--border-radius);" />
|
| 119 |
+
<input type="date" id="endDate" onchange="loadAccessRecords()"
|
| 120 |
+
value="@DateTime.Today.ToString("yyyy-MM-dd")"
|
| 121 |
+
style="padding: 0.5rem; border: 1px solid var(--light-2); border-radius: var(--border-radius);" />
|
| 122 |
+
</div>
|
| 123 |
+
</div>
|
| 124 |
+
<div id="accessRecords" style="min-height: 200px; display: flex; align-items: center; justify-content: center; color: var(--dark-2);">
|
| 125 |
+
<div style="text-align: center;">
|
| 126 |
+
<i class="fas fa-list" style="font-size: 3rem; margin-bottom: 1rem; display: block; opacity: 0.3;"></i>
|
| 127 |
+
<p>请选择工具查看访问记录</p>
|
| 128 |
+
</div>
|
| 129 |
+
</div>
|
| 130 |
+
</div>
|
| 131 |
+
|
| 132 |
+
<script>
|
| 133 |
+
let currentToolId = @toolId;
|
| 134 |
+
let currentDays = 30;
|
| 135 |
+
|
| 136 |
+
// 页面加载时初始化
|
| 137 |
+
document.addEventListener('DOMContentLoaded', function() {
|
| 138 |
+
loadTools();
|
| 139 |
+
// 等待Chart.js加载完成
|
| 140 |
+
setTimeout(() => {
|
| 141 |
+
if (currentToolId > 0) {
|
| 142 |
+
loadToolStatistics();
|
| 143 |
+
loadUsageTrend(currentDays);
|
| 144 |
+
loadAccessRecords();
|
| 145 |
+
}
|
| 146 |
+
}, 500);
|
| 147 |
+
});
|
| 148 |
+
|
| 149 |
+
// 加载工具列表
|
| 150 |
+
async function loadTools() {
|
| 151 |
+
try {
|
| 152 |
+
const response = await fetch('/Tool/GetAllTools');
|
| 153 |
+
const result = await response.json();
|
| 154 |
+
|
| 155 |
+
if (result.success && result.tools) {
|
| 156 |
+
const toolSelector = document.getElementById('toolSelector');
|
| 157 |
+
toolSelector.innerHTML = '<option value="0">所有工具</option>';
|
| 158 |
+
|
| 159 |
+
result.tools.forEach(tool => {
|
| 160 |
+
const option = document.createElement('option');
|
| 161 |
+
option.value = tool.id;
|
| 162 |
+
option.textContent = tool.name;
|
| 163 |
+
if (tool.id === currentToolId) {
|
| 164 |
+
option.selected = true;
|
| 165 |
+
}
|
| 166 |
+
toolSelector.appendChild(option);
|
| 167 |
+
});
|
| 168 |
+
}
|
| 169 |
+
} catch (error) {
|
| 170 |
+
console.error('加载工具列表失败:', error);
|
| 171 |
+
}
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
// 加载工具统计
|
| 175 |
+
async function loadToolStatistics() {
|
| 176 |
+
const toolId = document.getElementById('toolSelector').value;
|
| 177 |
+
const date = document.getElementById('statDate').value;
|
| 178 |
+
|
| 179 |
+
if (toolId === '0') {
|
| 180 |
+
resetStatistics();
|
| 181 |
+
return;
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
try {
|
| 185 |
+
const response = await fetch(`/ToolStatistics/GetToolStatistics?toolId=${toolId}&date=${date}`);
|
| 186 |
+
const result = await response.json();
|
| 187 |
+
|
| 188 |
+
if (result.success && result.data) {
|
| 189 |
+
updateStatistics(result.data);
|
| 190 |
+
}
|
| 191 |
+
else{
|
| 192 |
+
resetStatistics();
|
| 193 |
+
}
|
| 194 |
+
currentToolId = toolId;
|
| 195 |
+
loadUsageTrend(currentDays);
|
| 196 |
+
loadAccessRecords();
|
| 197 |
+
} catch (error) {
|
| 198 |
+
console.error('加载统计数据失败:', error);
|
| 199 |
+
}
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
// 更新统计数据
|
| 203 |
+
function updateStatistics(stats) {
|
| 204 |
+
document.getElementById('todayViews').textContent = stats.dailyViews || 0;
|
| 205 |
+
document.getElementById('todayFavorites').textContent = stats.dailyFavorites || 0;
|
| 206 |
+
document.getElementById('todayShares').textContent = stats.dailyShares || 0;
|
| 207 |
+
document.getElementById('todayDownloads').textContent = stats.dailyDownloads || 0;
|
| 208 |
+
document.getElementById('avgDuration').textContent = Math.round(stats.averageDuration || 0);
|
| 209 |
+
|
| 210 |
+
// 这里可以添加获取总数据的逻辑
|
| 211 |
+
document.getElementById('totalViews').textContent = '-';
|
| 212 |
+
document.getElementById('totalFavorites').textContent = '-';
|
| 213 |
+
document.getElementById('bounceRate').textContent = '-';
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
// 重置统计数据
|
| 217 |
+
function resetStatistics() {
|
| 218 |
+
document.getElementById('todayViews').textContent = '-';
|
| 219 |
+
document.getElementById('todayFavorites').textContent = '-';
|
| 220 |
+
document.getElementById('todayShares').textContent = '-';
|
| 221 |
+
document.getElementById('todayDownloads').textContent = '-';
|
| 222 |
+
document.getElementById('avgDuration').textContent = '-';
|
| 223 |
+
document.getElementById('totalViews').textContent = '-';
|
| 224 |
+
document.getElementById('totalFavorites').textContent = '-';
|
| 225 |
+
document.getElementById('bounceRate').textContent = '-';
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
// 加载使用趋势
|
| 229 |
+
async function loadUsageTrend(days) {
|
| 230 |
+
const toolId = document.getElementById('toolSelector').value;
|
| 231 |
+
currentDays = days;
|
| 232 |
+
|
| 233 |
+
if (toolId === '0') {
|
| 234 |
+
document.getElementById('trendChart').innerHTML = `
|
| 235 |
+
<div style="text-align: center;">
|
| 236 |
+
<i class="fas fa-chart-line" style="font-size: 3rem; margin-bottom: 1rem; display: block; opacity: 0.3;"></i>
|
| 237 |
+
<p>请选择工具查看使用趋势</p>
|
| 238 |
+
</div>
|
| 239 |
+
`;
|
| 240 |
+
return;
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
try {
|
| 244 |
+
const response = await fetch(`/ToolStatistics/GetToolUsageTrend?toolId=${toolId}&days=${days}`);
|
| 245 |
+
const result = await response.json();
|
| 246 |
+
|
| 247 |
+
if (result.success && result.data) {
|
| 248 |
+
renderTrendChart(result.data);
|
| 249 |
+
}
|
| 250 |
+
} catch (error) {
|
| 251 |
+
console.error('加载使用趋势失败:', error);
|
| 252 |
+
}
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
// 渲染趋势图表
|
| 256 |
+
function renderTrendChart(data) {
|
| 257 |
+
const chartDiv = document.getElementById('trendChart');
|
| 258 |
+
|
| 259 |
+
// 清空容器
|
| 260 |
+
chartDiv.innerHTML = '<canvas id="trendCanvas" style="width: 100%; height: 100%;"></canvas>';
|
| 261 |
+
|
| 262 |
+
// 准备数据
|
| 263 |
+
const labels = data.map(item => item.date);
|
| 264 |
+
const viewsData = data.map(item => item.views);
|
| 265 |
+
const favoritesData = data.map(item => item.favorites);
|
| 266 |
+
const sharesData = data.map(item => item.shares);
|
| 267 |
+
const downloadsData = data.map(item => item.downloads);
|
| 268 |
+
|
| 269 |
+
// 创建图表
|
| 270 |
+
const ctx = document.getElementById('trendCanvas').getContext('2d');
|
| 271 |
+
|
| 272 |
+
// 销毁现有图表
|
| 273 |
+
if (window.trendChart && typeof window.trendChart.destroy === 'function') {
|
| 274 |
+
window.trendChart.destroy();
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
// 检查Chart.js是否可用
|
| 278 |
+
if (typeof Chart === 'undefined') {
|
| 279 |
+
chartDiv.innerHTML = `
|
| 280 |
+
<div style="text-align: center; padding: 2rem;">
|
| 281 |
+
<i class="fas fa-exclamation-triangle" style="font-size: 2rem; margin-bottom: 1rem; display: block; color: var(--warning);"></i>
|
| 282 |
+
<p style="color: var(--dark-2); margin: 0;">Chart.js 库加载失败,请刷新页面重试</p>
|
| 283 |
+
</div>
|
| 284 |
+
`;
|
| 285 |
+
return;
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
try {
|
| 289 |
+
window.trendChart = new Chart(ctx, {
|
| 290 |
+
type: 'line',
|
| 291 |
+
data: {
|
| 292 |
+
labels: labels,
|
| 293 |
+
datasets: [
|
| 294 |
+
{
|
| 295 |
+
label: '访问量',
|
| 296 |
+
data: viewsData,
|
| 297 |
+
borderColor: '#165DFF',
|
| 298 |
+
backgroundColor: 'rgba(22, 93, 255, 0.1)',
|
| 299 |
+
borderWidth: 2,
|
| 300 |
+
fill: true,
|
| 301 |
+
tension: 0.4
|
| 302 |
+
},
|
| 303 |
+
{
|
| 304 |
+
label: '收藏',
|
| 305 |
+
data: favoritesData,
|
| 306 |
+
borderColor: '#FF4D4F',
|
| 307 |
+
backgroundColor: 'rgba(255, 77, 79, 0.1)',
|
| 308 |
+
borderWidth: 2,
|
| 309 |
+
fill: true,
|
| 310 |
+
tension: 0.4
|
| 311 |
+
},
|
| 312 |
+
{
|
| 313 |
+
label: '分享',
|
| 314 |
+
data: sharesData,
|
| 315 |
+
borderColor: '#52C41A',
|
| 316 |
+
backgroundColor: 'rgba(82, 196, 26, 0.1)',
|
| 317 |
+
borderWidth: 2,
|
| 318 |
+
fill: true,
|
| 319 |
+
tension: 0.4
|
| 320 |
+
},
|
| 321 |
+
{
|
| 322 |
+
label: '下载',
|
| 323 |
+
data: downloadsData,
|
| 324 |
+
borderColor: '#FAAD14',
|
| 325 |
+
backgroundColor: 'rgba(250, 173, 20, 0.1)',
|
| 326 |
+
borderWidth: 2,
|
| 327 |
+
fill: true,
|
| 328 |
+
tension: 0.4
|
| 329 |
+
}
|
| 330 |
+
]
|
| 331 |
+
},
|
| 332 |
+
options: {
|
| 333 |
+
responsive: true,
|
| 334 |
+
maintainAspectRatio: false,
|
| 335 |
+
plugins: {
|
| 336 |
+
legend: {
|
| 337 |
+
position: 'top',
|
| 338 |
+
labels: {
|
| 339 |
+
usePointStyle: true,
|
| 340 |
+
padding: 20,
|
| 341 |
+
font: {
|
| 342 |
+
size: 12
|
| 343 |
+
}
|
| 344 |
+
}
|
| 345 |
+
},
|
| 346 |
+
tooltip: {
|
| 347 |
+
mode: 'index',
|
| 348 |
+
intersect: false,
|
| 349 |
+
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
| 350 |
+
titleColor: '#1D2129',
|
| 351 |
+
bodyColor: '#4E5969',
|
| 352 |
+
borderColor: '#E5E6EB',
|
| 353 |
+
borderWidth: 1,
|
| 354 |
+
cornerRadius: 8,
|
| 355 |
+
displayColors: true
|
| 356 |
+
}
|
| 357 |
+
},
|
| 358 |
+
scales: {
|
| 359 |
+
x: {
|
| 360 |
+
grid: {
|
| 361 |
+
color: '#F5F5F5'
|
| 362 |
+
},
|
| 363 |
+
ticks: {
|
| 364 |
+
color: '#4E5969',
|
| 365 |
+
maxRotation: 45
|
| 366 |
+
}
|
| 367 |
+
},
|
| 368 |
+
y: {
|
| 369 |
+
beginAtZero: true,
|
| 370 |
+
grid: {
|
| 371 |
+
color: '#F5F5F5'
|
| 372 |
+
},
|
| 373 |
+
ticks: {
|
| 374 |
+
color: '#4E5969'
|
| 375 |
+
}
|
| 376 |
+
}
|
| 377 |
+
},
|
| 378 |
+
interaction: {
|
| 379 |
+
mode: 'nearest',
|
| 380 |
+
axis: 'x',
|
| 381 |
+
intersect: false
|
| 382 |
+
}
|
| 383 |
+
}
|
| 384 |
+
});
|
| 385 |
+
} catch (error) {
|
| 386 |
+
console.error('创建图表失败:', error);
|
| 387 |
+
chartDiv.innerHTML = `
|
| 388 |
+
<div style="text-align: center; padding: 2rem;">
|
| 389 |
+
<i class="fas fa-exclamation-triangle" style="font-size: 2rem; margin-bottom: 1rem; display: block; color: var(--warning);"></i>
|
| 390 |
+
<p style="color: var(--dark-2); margin: 0;">图表渲染失败,请刷新页面重试</p>
|
| 391 |
+
<small style="color: var(--dark-3);">错误信息: ${error.message}</small>
|
| 392 |
+
</div>
|
| 393 |
+
`;
|
| 394 |
+
}
|
| 395 |
+
}
|
| 396 |
+
|
| 397 |
+
// 加载访问记录
|
| 398 |
+
async function loadAccessRecords() {
|
| 399 |
+
const toolId = document.getElementById('toolSelector').value;
|
| 400 |
+
const startDate = document.getElementById('startDate').value;
|
| 401 |
+
const endDate = document.getElementById('endDate').value;
|
| 402 |
+
|
| 403 |
+
if (toolId === '0') {
|
| 404 |
+
document.getElementById('accessRecords').innerHTML = `
|
| 405 |
+
<div style="text-align: center;">
|
| 406 |
+
<i class="fas fa-list" style="font-size: 3rem; margin-bottom: 1rem; display: block; opacity: 0.3;"></i>
|
| 407 |
+
<p>请选择工具查看访问记录</p>
|
| 408 |
+
</div>
|
| 409 |
+
`;
|
| 410 |
+
return;
|
| 411 |
+
}
|
| 412 |
+
|
| 413 |
+
try {
|
| 414 |
+
const response = await fetch(`/ToolStatistics/GetToolAccessRecords?toolId=${toolId}&startDate=${startDate}&endDate=${endDate}&limit=50`);
|
| 415 |
+
const result = await response.json();
|
| 416 |
+
|
| 417 |
+
if (result.success && result.data) {
|
| 418 |
+
renderAccessRecords(result.data);
|
| 419 |
+
}
|
| 420 |
+
} catch (error) {
|
| 421 |
+
console.error('加载访问记录失败:', error);
|
| 422 |
+
}
|
| 423 |
+
}
|
| 424 |
+
|
| 425 |
+
// 渲染访问记录
|
| 426 |
+
function renderAccessRecords(records) {
|
| 427 |
+
const recordsDiv = document.getElementById('accessRecords');
|
| 428 |
+
|
| 429 |
+
if (records.length === 0) {
|
| 430 |
+
recordsDiv.innerHTML = `
|
| 431 |
+
<div style="text-align: center; padding: 3rem;">
|
| 432 |
+
<i class="fas fa-inbox" style="font-size: 3rem; margin-bottom: 1rem; display: block; opacity: 0.3; color: var(--dark-2);"></i>
|
| 433 |
+
<p style="color: var(--dark-2); margin: 0;">暂无访问记录</p>
|
| 434 |
+
</div>
|
| 435 |
+
`;
|
| 436 |
+
return;
|
| 437 |
+
}
|
| 438 |
+
|
| 439 |
+
let html = '<div style="overflow-x: auto;">';
|
| 440 |
+
html += '<table style="width: 100%; border-collapse: collapse; font-size: 0.875rem;">';
|
| 441 |
+
html += '<thead><tr style="background: var(--light-1);">';
|
| 442 |
+
html += '<th style="padding: 0.75rem; border: 1px solid var(--light-2); text-align: left; font-weight: 600;">时间</th>';
|
| 443 |
+
html += '<th style="padding: 0.75rem; border: 1px solid var(--light-2); text-align: left; font-weight: 600;">用户</th>';
|
| 444 |
+
html += '<th style="padding: 0.75rem; border: 1px solid var(--light-2); text-align: left; font-weight: 600;">IP地址</th>';
|
| 445 |
+
html += '<th style="padding: 0.75rem; border: 1px solid var(--light-2); text-align: left; font-weight: 600;">操作类型</th>';
|
| 446 |
+
html += '<th style="padding: 0.75rem; border: 1px solid var(--light-2); text-align: left; font-weight: 600;">停留时间</th>';
|
| 447 |
+
html += '<th style="padding: 0.75rem; border: 1px solid var(--light-2); text-align: left; font-weight: 600;">用户代理</th>';
|
| 448 |
+
html += '</tr></thead>';
|
| 449 |
+
html += '<tbody>';
|
| 450 |
+
|
| 451 |
+
records.forEach((record, index) => {
|
| 452 |
+
const user = record.user ? record.user.nickName || record.user.userName : '匿名用户';
|
| 453 |
+
const duration = record.duration > 0 ? `${record.duration}秒` : '-';
|
| 454 |
+
const userAgent = record.userAgent ? record.userAgent.substring(0, 50) + (record.userAgent.length > 50 ? '...' : '') : '-';
|
| 455 |
+
const rowStyle = index % 2 === 0 ? 'background: white;' : 'background: var(--light-1);';
|
| 456 |
+
|
| 457 |
+
html += `<tr style="${rowStyle}">`;
|
| 458 |
+
html += `<td style="padding: 0.75rem; border: 1px solid var(--light-2);">${new Date(record.createdAt).toLocaleString()}</td>`;
|
| 459 |
+
html += `<td style="padding: 0.75rem; border: 1px solid var(--light-2);">${user}</td>`;
|
| 460 |
+
html += `<td style="padding: 0.75rem; border: 1px solid var(--light-2); font-family: monospace;">${record.ipAddress || '-'}</td>`;
|
| 461 |
+
html += `<td style="padding: 0.75rem; border: 1px solid var(--light-2);">${getAccessTypeBadge(record.accessType)}</td>`;
|
| 462 |
+
html += `<td style="padding: 0.75rem; border: 1px solid var(--light-2);">${duration}</td>`;
|
| 463 |
+
html += `<td style="padding: 0.75rem; border: 1px solid var(--light-2); font-size: 0.75rem; color: var(--dark-2);" title="${record.userAgent || ''}">${userAgent}</td>`;
|
| 464 |
+
html += '</tr>';
|
| 465 |
+
});
|
| 466 |
+
|
| 467 |
+
html += '</tbody></table>';
|
| 468 |
+
html += '</div>';
|
| 469 |
+
recordsDiv.innerHTML = html;
|
| 470 |
+
}
|
| 471 |
+
|
| 472 |
+
// 获取操作类型徽章
|
| 473 |
+
function getAccessTypeBadge(accessType) {
|
| 474 |
+
const badges = {
|
| 475 |
+
'view': '<span style="background: #E6F7FF; color: #1890FF; padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.75rem;">查看</span>',
|
| 476 |
+
'favorite': '<span style="background: #FFF2E8; color: #FA541C; padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.75rem;">收藏</span>',
|
| 477 |
+
'share': '<span style="background: #F6FFED; color: #52C41A; padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.75rem;">分享</span>',
|
| 478 |
+
'download': '<span style="background: #FFF7E6; color: #FA8C16; padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.75rem;">下载</span>'
|
| 479 |
+
};
|
| 480 |
+
return badges[accessType] || `<span style="background: #F5F5F5; color: #666; padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.75rem;">${accessType}</span>`;
|
| 481 |
+
}
|
| 482 |
+
|
| 483 |
+
// 获取操作类型文本
|
| 484 |
+
function getAccessTypeText(accessType) {
|
| 485 |
+
const types = {
|
| 486 |
+
'view': '查看',
|
| 487 |
+
'favorite': '收藏',
|
| 488 |
+
'share': '分享',
|
| 489 |
+
'download': '下载'
|
| 490 |
+
};
|
| 491 |
+
return types[accessType] || accessType;
|
| 492 |
+
}
|
| 493 |
+
</script>
|
Views/Tools/ImageCompressor.cshtml
ADDED
|
@@ -0,0 +1,479 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@{
|
| 2 |
+
ViewData["Title"] = "图片压缩工具";
|
| 3 |
+
Layout = "_Layout";
|
| 4 |
+
}
|
| 5 |
+
|
| 6 |
+
<div class="container" style="padding-top: 6rem; padding-bottom: 4rem;">
|
| 7 |
+
<!-- 页面头部 -->
|
| 8 |
+
<div class="text-center mb-8">
|
| 9 |
+
<div class="brand-icon" style="width: 4rem; height: 4rem; margin: 0 auto 1.5rem; font-size: 1.8rem;">
|
| 10 |
+
<i class="fas fa-compress-alt"></i>
|
| 11 |
+
</div>
|
| 12 |
+
<h1 class="hero-title">图片压缩工具</h1>
|
| 13 |
+
<p class="hero-description">快速压缩图片文件,支持JPG、PNG、WebP等格式<br>
|
| 14 |
+
保持高质量的同时大幅减小文件体积,提升网站加载速度</p>
|
| 15 |
+
</div>
|
| 16 |
+
|
| 17 |
+
<!-- 工具主体 -->
|
| 18 |
+
<div class="card" style="max-width: 800px; margin: 0 auto;">
|
| 19 |
+
<div class="card-body" style="padding: 2rem;">
|
| 20 |
+
<!-- 上传区域 -->
|
| 21 |
+
<div id="uploadArea" class="upload-area">
|
| 22 |
+
<div class="upload-content">
|
| 23 |
+
<i class="fas fa-cloud-upload-alt" style="font-size: 3rem; color: var(--primary); margin-bottom: 1rem;"></i>
|
| 24 |
+
<h3>拖拽图片到此处或点击选择</h3>
|
| 25 |
+
<p style="color: var(--dark-2); margin-bottom: 1.5rem;">支持 JPG、PNG、WebP、GIF 格式,单文件最大 10MB</p>
|
| 26 |
+
<button type="button" class="btn btn-primary" onclick="selectFiles()">
|
| 27 |
+
<i class="fas fa-upload"></i>
|
| 28 |
+
选择图片
|
| 29 |
+
</button>
|
| 30 |
+
</div>
|
| 31 |
+
<input type="file" id="fileInput" multiple accept="image/*" style="display: none;" onchange="handleFiles(this.files)">
|
| 32 |
+
</div>
|
| 33 |
+
|
| 34 |
+
<!-- 压缩设置 -->
|
| 35 |
+
<div id="settingsPanel" class="settings-panel" style="display: none;">
|
| 36 |
+
<h4 style="margin-bottom: 1rem;">压缩设置</h4>
|
| 37 |
+
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; margin-bottom: 1.5rem;">
|
| 38 |
+
<div>
|
| 39 |
+
<label>压缩质量</label>
|
| 40 |
+
<div style="display: flex; align-items: center; gap: 1rem;">
|
| 41 |
+
<input type="range" id="qualitySlider" min="0.1" max="1" step="0.1" value="0.8"
|
| 42 |
+
style="flex: 1;" oninput="updateQualityDisplay(this.value)">
|
| 43 |
+
<span id="qualityDisplay" style="font-weight: 600; min-width: 50px;">80%</span>
|
| 44 |
+
</div>
|
| 45 |
+
</div>
|
| 46 |
+
<div>
|
| 47 |
+
<label>输出格式</label>
|
| 48 |
+
<select id="formatSelect" style="width: 100%; padding: 0.75rem; border: 1px solid var(--light-2); border-radius: var(--border-radius);">
|
| 49 |
+
<option value="original">保持原格式</option>
|
| 50 |
+
<option value="jpeg">JPEG</option>
|
| 51 |
+
<option value="png">PNG</option>
|
| 52 |
+
<option value="webp">WebP</option>
|
| 53 |
+
</select>
|
| 54 |
+
</div>
|
| 55 |
+
</div>
|
| 56 |
+
<button type="button" class="btn btn-primary" onclick="compressAllImages()" id="compressBtn">
|
| 57 |
+
<i class="fas fa-compress"></i>
|
| 58 |
+
开始压缩
|
| 59 |
+
</button>
|
| 60 |
+
</div>
|
| 61 |
+
|
| 62 |
+
<!-- 文件列表 -->
|
| 63 |
+
<div id="fileList" class="file-list" style="display: none;">
|
| 64 |
+
<h4 style="margin-bottom: 1rem;">图片列表</h4>
|
| 65 |
+
<div id="imageItems"></div>
|
| 66 |
+
</div>
|
| 67 |
+
|
| 68 |
+
<!-- 压缩结果 -->
|
| 69 |
+
<div id="resultsPanel" class="results-panel" style="display: none;">
|
| 70 |
+
<h4 style="margin-bottom: 1rem;">压缩完成</h4>
|
| 71 |
+
<div id="compressResults"></div>
|
| 72 |
+
<div style="margin-top: 1.5rem; text-align: center;">
|
| 73 |
+
<button type="button" class="btn btn-primary" onclick="downloadAll()">
|
| 74 |
+
<i class="fas fa-download"></i>
|
| 75 |
+
下载全部
|
| 76 |
+
</button>
|
| 77 |
+
<button type="button" class="btn btn-outline" onclick="resetTool()" style="margin-left: 1rem;">
|
| 78 |
+
<i class="fas fa-redo"></i>
|
| 79 |
+
重新开始
|
| 80 |
+
</button>
|
| 81 |
+
</div>
|
| 82 |
+
</div>
|
| 83 |
+
</div>
|
| 84 |
+
</div>
|
| 85 |
+
|
| 86 |
+
<!-- 使用说明 -->
|
| 87 |
+
<div class="card mt-8">
|
| 88 |
+
<div class="card-body">
|
| 89 |
+
<h3 style="margin-bottom: 1.5rem; text-align: center;">使用说明</h3>
|
| 90 |
+
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 2rem;">
|
| 91 |
+
<div style="text-align: center;">
|
| 92 |
+
<div style="width: 3rem; height: 3rem; background: linear-gradient(135deg, var(--primary), var(--secondary)); border-radius: 50%; display: flex; align-items: center; justify-content: center; margin: 0 auto 1rem; color: white; font-size: 1.2rem;">1</div>
|
| 93 |
+
<h4>选择图片</h4>
|
| 94 |
+
<p style="color: var(--dark-2); font-size: 0.875rem;">支持拖拽上传或点击选择,可同时处理多张图片</p>
|
| 95 |
+
</div>
|
| 96 |
+
<div style="text-align: center;">
|
| 97 |
+
<div style="width: 3rem; height: 3rem; background: linear-gradient(135deg, var(--primary), var(--secondary)); border-radius: 50%; display: flex; align-items: center; justify-content: center; margin: 0 auto 1rem; color: white; font-size: 1.2rem;">2</div>
|
| 98 |
+
<h4>调整设置</h4>
|
| 99 |
+
<p style="color: var(--dark-2); font-size: 0.875rem;">设置压缩质量和输出格式,质量越低文件越小</p>
|
| 100 |
+
</div>
|
| 101 |
+
<div style="text-align: center;">
|
| 102 |
+
<div style="width: 3rem; height: 3rem; background: linear-gradient(135deg, var(--primary), var(--secondary)); border-radius: 50%; display: flex; align-items: center; justify-content: center; margin: 0 auto 1rem; color: white; font-size: 1.2rem;">3</div>
|
| 103 |
+
<h4>下载结果</h4>
|
| 104 |
+
<p style="color: var(--dark-2); font-size: 0.875rem;">压缩完成后可单独下载或批量下载所有图片</p>
|
| 105 |
+
</div>
|
| 106 |
+
</div>
|
| 107 |
+
</div>
|
| 108 |
+
</div>
|
| 109 |
+
</div>
|
| 110 |
+
|
| 111 |
+
<style>
|
| 112 |
+
.upload-area {
|
| 113 |
+
border: 2px dashed var(--light-2);
|
| 114 |
+
border-radius: var(--border-radius-lg);
|
| 115 |
+
padding: 3rem 2rem;
|
| 116 |
+
text-align: center;
|
| 117 |
+
background: var(--light-1);
|
| 118 |
+
transition: var(--transition);
|
| 119 |
+
cursor: pointer;
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
.upload-area:hover {
|
| 123 |
+
border-color: var(--primary);
|
| 124 |
+
background: rgba(22, 93, 255, 0.05);
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
.upload-area.dragover {
|
| 128 |
+
border-color: var(--primary);
|
| 129 |
+
background: rgba(22, 93, 255, 0.1);
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
.settings-panel, .file-list, .results-panel {
|
| 133 |
+
margin-top: 2rem;
|
| 134 |
+
padding-top: 2rem;
|
| 135 |
+
border-top: 1px solid var(--light-2);
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
.image-item {
|
| 139 |
+
display: flex;
|
| 140 |
+
align-items: center;
|
| 141 |
+
padding: 1rem;
|
| 142 |
+
border: 1px solid var(--light-2);
|
| 143 |
+
border-radius: var(--border-radius);
|
| 144 |
+
margin-bottom: 1rem;
|
| 145 |
+
background: white;
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
.image-preview {
|
| 149 |
+
width: 60px;
|
| 150 |
+
height: 60px;
|
| 151 |
+
object-fit: cover;
|
| 152 |
+
border-radius: var(--border-radius);
|
| 153 |
+
margin-right: 1rem;
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
.image-info {
|
| 157 |
+
flex: 1;
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
.image-actions {
|
| 161 |
+
display: flex;
|
| 162 |
+
gap: 0.5rem;
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
.progress-bar {
|
| 166 |
+
width: 100%;
|
| 167 |
+
height: 6px;
|
| 168 |
+
background: var(--light-2);
|
| 169 |
+
border-radius: 3px;
|
| 170 |
+
overflow: hidden;
|
| 171 |
+
margin-top: 0.5rem;
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
.progress-fill {
|
| 175 |
+
height: 100%;
|
| 176 |
+
background: linear-gradient(135deg, var(--primary), var(--secondary));
|
| 177 |
+
transition: width 0.3s ease;
|
| 178 |
+
width: 0%;
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
.compression-stats {
|
| 182 |
+
display: grid;
|
| 183 |
+
grid-template-columns: 1fr 1fr 1fr;
|
| 184 |
+
gap: 1rem;
|
| 185 |
+
text-align: center;
|
| 186 |
+
padding: 1rem;
|
| 187 |
+
background: var(--light-1);
|
| 188 |
+
border-radius: var(--border-radius);
|
| 189 |
+
margin-bottom: 1rem;
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
.stat-item h5 {
|
| 193 |
+
margin: 0 0 0.25rem;
|
| 194 |
+
font-size: 1.2rem;
|
| 195 |
+
font-weight: 700;
|
| 196 |
+
color: var(--primary);
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
.stat-item p {
|
| 200 |
+
margin: 0;
|
| 201 |
+
font-size: 0.875rem;
|
| 202 |
+
color: var(--dark-2);
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
/* 响应式调整 */
|
| 206 |
+
@@media (max-width: 768px) {
|
| 207 |
+
.upload-area {
|
| 208 |
+
padding: 2rem 1rem;
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
.compression-stats {
|
| 212 |
+
grid-template-columns: 1fr;
|
| 213 |
+
gap: 0.5rem;
|
| 214 |
+
}
|
| 215 |
+
}
|
| 216 |
+
</style>
|
| 217 |
+
|
| 218 |
+
<script>
|
| 219 |
+
let selectedFiles = [];
|
| 220 |
+
let compressedFiles = [];
|
| 221 |
+
|
| 222 |
+
// 文件选择
|
| 223 |
+
function selectFiles() {
|
| 224 |
+
document.getElementById('fileInput').click();
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
// 处理文件选择
|
| 228 |
+
function handleFiles(files) {
|
| 229 |
+
if (files.length === 0) return;
|
| 230 |
+
|
| 231 |
+
selectedFiles = Array.from(files).filter(file => {
|
| 232 |
+
return file.type.startsWith('image/') && file.size <= 10 * 1024 * 1024; // 10MB限制
|
| 233 |
+
});
|
| 234 |
+
|
| 235 |
+
if (selectedFiles.length === 0) {
|
| 236 |
+
toolHub.showToast('请选择有效的图片文件(小于10MB)', 'error');
|
| 237 |
+
return;
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
displaySelectedFiles();
|
| 241 |
+
document.getElementById('settingsPanel').style.display = 'block';
|
| 242 |
+
document.getElementById('fileList').style.display = 'block';
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
// 显示选中的文件
|
| 246 |
+
function displaySelectedFiles() {
|
| 247 |
+
const container = document.getElementById('imageItems');
|
| 248 |
+
container.innerHTML = '';
|
| 249 |
+
|
| 250 |
+
selectedFiles.forEach((file, index) => {
|
| 251 |
+
const item = document.createElement('div');
|
| 252 |
+
item.className = 'image-item';
|
| 253 |
+
item.innerHTML = `
|
| 254 |
+
<img class="image-preview" src="${URL.createObjectURL(file)}" alt="${file.name}">
|
| 255 |
+
<div class="image-info">
|
| 256 |
+
<div style="font-weight: 600; margin-bottom: 0.25rem;">${file.name}</div>
|
| 257 |
+
<div style="font-size: 0.875rem; color: var(--dark-2);">
|
| 258 |
+
${formatFileSize(file.size)} • ${file.type}
|
| 259 |
+
</div>
|
| 260 |
+
<div class="progress-bar" id="progress-${index}" style="display: none;">
|
| 261 |
+
<div class="progress-fill" id="progress-fill-${index}"></div>
|
| 262 |
+
</div>
|
| 263 |
+
</div>
|
| 264 |
+
<div class="image-actions">
|
| 265 |
+
<button class="btn btn-outline btn-sm" onclick="removeFile(${index})">
|
| 266 |
+
<i class="fas fa-times"></i>
|
| 267 |
+
</button>
|
| 268 |
+
</div>
|
| 269 |
+
`;
|
| 270 |
+
container.appendChild(item);
|
| 271 |
+
});
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
// 移除文件
|
| 275 |
+
function removeFile(index) {
|
| 276 |
+
selectedFiles.splice(index, 1);
|
| 277 |
+
if (selectedFiles.length === 0) {
|
| 278 |
+
document.getElementById('settingsPanel').style.display = 'none';
|
| 279 |
+
document.getElementById('fileList').style.display = 'none';
|
| 280 |
+
} else {
|
| 281 |
+
displaySelectedFiles();
|
| 282 |
+
}
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
// 更新质量显示
|
| 286 |
+
function updateQualityDisplay(value) {
|
| 287 |
+
document.getElementById('qualityDisplay').textContent = Math.round(value * 100) + '%';
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
// 压缩所有图片
|
| 291 |
+
async function compressAllImages() {
|
| 292 |
+
const quality = parseFloat(document.getElementById('qualitySlider').value);
|
| 293 |
+
const format = document.getElementById('formatSelect').value;
|
| 294 |
+
|
| 295 |
+
compressedFiles = [];
|
| 296 |
+
document.getElementById('compressBtn').disabled = true;
|
| 297 |
+
document.getElementById('compressBtn').innerHTML = '<i class="fas fa-spinner fa-spin"></i> 压缩中...';
|
| 298 |
+
|
| 299 |
+
for (let i = 0; i < selectedFiles.length; i++) {
|
| 300 |
+
const file = selectedFiles[i];
|
| 301 |
+
document.getElementById(`progress-${i}`).style.display = 'block';
|
| 302 |
+
|
| 303 |
+
try {
|
| 304 |
+
const compressedFile = await compressImage(file, quality, format, (progress) => {
|
| 305 |
+
document.getElementById(`progress-fill-${i}`).style.width = progress + '%';
|
| 306 |
+
});
|
| 307 |
+
compressedFiles.push(compressedFile);
|
| 308 |
+
} catch (error) {
|
| 309 |
+
console.error('压缩失败:', error);
|
| 310 |
+
toolHub.showToast(`压缩 ${file.name} 失败`, 'error');
|
| 311 |
+
}
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
showResults();
|
| 315 |
+
document.getElementById('compressBtn').disabled = false;
|
| 316 |
+
document.getElementById('compressBtn').innerHTML = '<i class="fas fa-compress"></i> 开始压缩';
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
// 压缩单个图片
|
| 320 |
+
function compressImage(file, quality, format, onProgress) {
|
| 321 |
+
return new Promise((resolve, reject) => {
|
| 322 |
+
const canvas = document.createElement('canvas');
|
| 323 |
+
const ctx = canvas.getContext('2d');
|
| 324 |
+
const img = new Image();
|
| 325 |
+
|
| 326 |
+
img.onload = function() {
|
| 327 |
+
canvas.width = img.width;
|
| 328 |
+
canvas.height = img.height;
|
| 329 |
+
|
| 330 |
+
ctx.drawImage(img, 0, 0);
|
| 331 |
+
|
| 332 |
+
const outputFormat = format === 'original' ? file.type : `image/${format}`;
|
| 333 |
+
|
| 334 |
+
canvas.toBlob((blob) => {
|
| 335 |
+
if (blob) {
|
| 336 |
+
const compressedFile = new File([blob],
|
| 337 |
+
getCompressedFileName(file.name, format),
|
| 338 |
+
{ type: outputFormat }
|
| 339 |
+
);
|
| 340 |
+
onProgress(100);
|
| 341 |
+
resolve({
|
| 342 |
+
original: file,
|
| 343 |
+
compressed: compressedFile,
|
| 344 |
+
compressionRatio: ((file.size - blob.size) / file.size * 100).toFixed(1)
|
| 345 |
+
});
|
| 346 |
+
} else {
|
| 347 |
+
reject(new Error('压缩失败'));
|
| 348 |
+
}
|
| 349 |
+
}, outputFormat, quality);
|
| 350 |
+
};
|
| 351 |
+
|
| 352 |
+
img.onerror = () => reject(new Error('图片加载失败'));
|
| 353 |
+
img.src = URL.createObjectURL(file);
|
| 354 |
+
|
| 355 |
+
// 模拟进度
|
| 356 |
+
let progress = 0;
|
| 357 |
+
const progressInterval = setInterval(() => {
|
| 358 |
+
progress += 10;
|
| 359 |
+
if (progress >= 90) {
|
| 360 |
+
clearInterval(progressInterval);
|
| 361 |
+
} else {
|
| 362 |
+
onProgress(progress);
|
| 363 |
+
}
|
| 364 |
+
}, 50);
|
| 365 |
+
});
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
// 获取压缩后的文件名
|
| 369 |
+
function getCompressedFileName(originalName, format) {
|
| 370 |
+
if (format === 'original') return originalName;
|
| 371 |
+
|
| 372 |
+
const nameWithoutExt = originalName.substring(0, originalName.lastIndexOf('.'));
|
| 373 |
+
return `${nameWithoutExt}_compressed.${format === 'jpeg' ? 'jpg' : format}`;
|
| 374 |
+
}
|
| 375 |
+
|
| 376 |
+
// 显示压缩结果
|
| 377 |
+
function showResults() {
|
| 378 |
+
const totalOriginalSize = compressedFiles.reduce((sum, item) => sum + item.original.size, 0);
|
| 379 |
+
const totalCompressedSize = compressedFiles.reduce((sum, item) => sum + item.compressed.size, 0);
|
| 380 |
+
const totalSavings = ((totalOriginalSize - totalCompressedSize) / totalOriginalSize * 100).toFixed(1);
|
| 381 |
+
|
| 382 |
+
document.getElementById('compressResults').innerHTML = `
|
| 383 |
+
<div class="compression-stats">
|
| 384 |
+
<div class="stat-item">
|
| 385 |
+
<h5>${formatFileSize(totalOriginalSize)}</h5>
|
| 386 |
+
<p>原始大小</p>
|
| 387 |
+
</div>
|
| 388 |
+
<div class="stat-item">
|
| 389 |
+
<h5>${formatFileSize(totalCompressedSize)}</h5>
|
| 390 |
+
<p>压缩后大小</p>
|
| 391 |
+
</div>
|
| 392 |
+
<div class="stat-item">
|
| 393 |
+
<h5>${totalSavings}%</h5>
|
| 394 |
+
<p>压缩率</p>
|
| 395 |
+
</div>
|
| 396 |
+
</div>
|
| 397 |
+
${compressedFiles.map((item, index) => `
|
| 398 |
+
<div class="image-item">
|
| 399 |
+
<img class="image-preview" src="${URL.createObjectURL(item.compressed)}" alt="${item.compressed.name}">
|
| 400 |
+
<div class="image-info">
|
| 401 |
+
<div style="font-weight: 600; margin-bottom: 0.25rem;">${item.compressed.name}</div>
|
| 402 |
+
<div style="font-size: 0.875rem; color: var(--dark-2);">
|
| 403 |
+
${formatFileSize(item.original.size)} → ${formatFileSize(item.compressed.size)} (节省 ${item.compressionRatio}%)
|
| 404 |
+
</div>
|
| 405 |
+
</div>
|
| 406 |
+
<div class="image-actions">
|
| 407 |
+
<button class="btn btn-primary btn-sm" onclick="downloadFile(${index})">
|
| 408 |
+
<i class="fas fa-download"></i> 下载
|
| 409 |
+
</button>
|
| 410 |
+
</div>
|
| 411 |
+
</div>
|
| 412 |
+
`).join('')}
|
| 413 |
+
`;
|
| 414 |
+
|
| 415 |
+
document.getElementById('resultsPanel').style.display = 'block';
|
| 416 |
+
}
|
| 417 |
+
|
| 418 |
+
// 下载单个文件
|
| 419 |
+
function downloadFile(index) {
|
| 420 |
+
const item = compressedFiles[index];
|
| 421 |
+
const url = URL.createObjectURL(item.compressed);
|
| 422 |
+
const a = document.createElement('a');
|
| 423 |
+
a.href = url;
|
| 424 |
+
a.download = item.compressed.name;
|
| 425 |
+
a.click();
|
| 426 |
+
URL.revokeObjectURL(url);
|
| 427 |
+
}
|
| 428 |
+
|
| 429 |
+
// 下载所有文件
|
| 430 |
+
function downloadAll() {
|
| 431 |
+
compressedFiles.forEach((item, index) => {
|
| 432 |
+
setTimeout(() => downloadFile(index), index * 200);
|
| 433 |
+
});
|
| 434 |
+
}
|
| 435 |
+
|
| 436 |
+
// 重置工具
|
| 437 |
+
function resetTool() {
|
| 438 |
+
selectedFiles = [];
|
| 439 |
+
compressedFiles = [];
|
| 440 |
+
document.getElementById('fileInput').value = '';
|
| 441 |
+
document.getElementById('settingsPanel').style.display = 'none';
|
| 442 |
+
document.getElementById('fileList').style.display = 'none';
|
| 443 |
+
document.getElementById('resultsPanel').style.display = 'none';
|
| 444 |
+
document.getElementById('imageItems').innerHTML = '';
|
| 445 |
+
document.getElementById('compressResults').innerHTML = '';
|
| 446 |
+
}
|
| 447 |
+
|
| 448 |
+
// 格式化文件大小
|
| 449 |
+
function formatFileSize(bytes) {
|
| 450 |
+
if (bytes === 0) return '0 B';
|
| 451 |
+
const k = 1024;
|
| 452 |
+
const sizes = ['B', 'KB', 'MB', 'GB'];
|
| 453 |
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
| 454 |
+
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
| 455 |
+
}
|
| 456 |
+
|
| 457 |
+
// 拖拽上传
|
| 458 |
+
document.addEventListener('DOMContentLoaded', function() {
|
| 459 |
+
const uploadArea = document.getElementById('uploadArea');
|
| 460 |
+
|
| 461 |
+
uploadArea.addEventListener('dragover', function(e) {
|
| 462 |
+
e.preventDefault();
|
| 463 |
+
uploadArea.classList.add('dragover');
|
| 464 |
+
});
|
| 465 |
+
|
| 466 |
+
uploadArea.addEventListener('dragleave', function(e) {
|
| 467 |
+
e.preventDefault();
|
| 468 |
+
uploadArea.classList.remove('dragover');
|
| 469 |
+
});
|
| 470 |
+
|
| 471 |
+
uploadArea.addEventListener('drop', function(e) {
|
| 472 |
+
e.preventDefault();
|
| 473 |
+
uploadArea.classList.remove('dragover');
|
| 474 |
+
handleFiles(e.dataTransfer.files);
|
| 475 |
+
});
|
| 476 |
+
|
| 477 |
+
uploadArea.addEventListener('click', selectFiles);
|
| 478 |
+
});
|
| 479 |
+
</script>
|
Views/_ViewImports.cshtml
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@using ToolHub
|
| 2 |
+
@using ToolHub.Models
|
| 3 |
+
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
|
Views/_ViewStart.cshtml
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@{
|
| 2 |
+
Layout = "_Layout";
|
| 3 |
+
}
|
appsettings.Development.json
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"Logging": {
|
| 3 |
+
"LogLevel": {
|
| 4 |
+
"Default": "Information",
|
| 5 |
+
"Microsoft.AspNetCore": "Warning"
|
| 6 |
+
}
|
| 7 |
+
}
|
| 8 |
+
}
|