管理后台

This commit is contained in:
18631081161 2026-04-03 16:35:38 +08:00
parent 741b4457bd
commit 7fb22198c7
27 changed files with 1135 additions and 69 deletions

View File

@ -10,6 +10,9 @@ declare module 'vue' {
export interface GlobalComponents {
ElAlert: typeof import('element-plus/es')['ElAlert']
ElAside: typeof import('element-plus/es')['ElAside']
ElAvatar: typeof import('element-plus/es')['ElAvatar']
ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
ElButton: typeof import('element-plus/es')['ElButton']
ElCard: typeof import('element-plus/es')['ElCard']
ElCol: typeof import('element-plus/es')['ElCol']
@ -36,6 +39,7 @@ declare module 'vue' {
ElPagination: typeof import('element-plus/es')['ElPagination']
ElPopconfirm: typeof import('element-plus/es')['ElPopconfirm']
ElRow: typeof import('element-plus/es')['ElRow']
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']

View File

@ -9,6 +9,7 @@
"version": "1.0.0",
"dependencies": {
"axios": "^1.9.0",
"echarts": "^6.0.0",
"element-plus": "^2.9.8",
"pinia": "^3.0.2",
"sortablejs": "^1.15.7",
@ -1829,6 +1830,16 @@
"node": ">= 0.4"
}
},
"node_modules/echarts": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.0.0.tgz",
"integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "2.3.0",
"zrender": "6.0.0"
}
},
"node_modules/element-plus": {
"version": "2.13.6",
"resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.13.6.tgz",
@ -2757,6 +2768,12 @@
"node": ">=8.0"
}
},
"node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
},
"node_modules/typescript": {
"version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
@ -3130,6 +3147,15 @@
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
"dev": true,
"license": "MIT"
},
"node_modules/zrender": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz",
"integrity": "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==",
"license": "BSD-3-Clause",
"dependencies": {
"tslib": "2.3.0"
}
}
}
}

View File

@ -10,6 +10,7 @@
},
"dependencies": {
"axios": "^1.9.0",
"echarts": "^6.0.0",
"element-plus": "^2.9.8",
"pinia": "^3.0.2",
"sortablejs": "^1.15.7",

6
admin/src/api/auth.ts Normal file
View File

@ -0,0 +1,6 @@
import request from '@/utils/request'
// 管理员认证 API
export function login(data: { username: string; password: string }) {
return request.post('/auth/login', data)
}

View File

@ -0,0 +1,14 @@
import request from '@/utils/request'
// 仪表盘 API
export function getDashboardStats() {
return request.get('/dashboard/stats')
}
export function getUserTrend() {
return request.get('/dashboard/user-trend')
}
export function getCouponDistribution() {
return request.get('/dashboard/coupon-distribution')
}

View File

@ -1,7 +1,9 @@
// 管理后台 API 模块统一导出
export * from './auth'
export * from './banner'
export * from './coupon'
export * from './content'
export * from './dashboard'
export * from './entry'
export * from './membership'
export * from './points'

View File

@ -1,43 +1,59 @@
<template>
<el-container class="admin-layout">
<!-- 侧边栏 -->
<el-aside :width="isCollapse ? '64px' : '220px'" class="aside">
<el-aside :width="isCollapse ? '64px' : '240px'" class="aside">
<div class="logo">
<span v-if="!isCollapse">贩卖机管理后台</span>
<span v-else>VM</span>
<img src="/favicon.svg" alt="logo" class="logo-img" />
<span v-if="!isCollapse" class="logo-text">贩卖机管理后台</span>
</div>
<el-menu
:default-active="currentRoute"
:collapse="isCollapse"
router
background-color="#304156"
text-color="#bfcbd9"
active-text-color="#409eff"
>
<el-menu-item
v-for="item in menuItems"
:key="item.path"
:index="item.path"
<el-scrollbar>
<el-menu
:default-active="currentRoute"
:collapse="isCollapse"
router
background-color="#1d1e1f"
text-color="rgba(255,255,255,0.65)"
active-text-color="#ffffff"
:collapse-transition="false"
class="side-menu"
>
<el-icon><component :is="item.icon" /></el-icon>
<template #title>{{ item.title }}</template>
</el-menu-item>
</el-menu>
<el-menu-item
v-for="item in menuItems"
:key="item.path"
:index="item.path"
>
<el-icon><component :is="item.icon" /></el-icon>
<template #title>{{ item.title }}</template>
</el-menu-item>
</el-menu>
</el-scrollbar>
</el-aside>
<el-container>
<el-container class="main-container">
<!-- 顶部栏 -->
<el-header class="header">
<el-icon class="collapse-btn" @click="isCollapse = !isCollapse">
<Fold v-if="!isCollapse" />
<Expand v-else />
</el-icon>
<div class="header-left">
<el-icon class="collapse-btn" @click="isCollapse = !isCollapse">
<Fold v-if="!isCollapse" />
<Expand v-else />
</el-icon>
<el-breadcrumb separator="/" class="breadcrumb">
<el-breadcrumb-item :to="{ path: '/dashboard' }">首页</el-breadcrumb-item>
<el-breadcrumb-item v-if="currentTitle">{{ currentTitle }}</el-breadcrumb-item>
</el-breadcrumb>
</div>
<div class="header-right">
<el-dropdown @command="handleCommand">
<span class="admin-user">管理员</span>
<el-dropdown @command="handleCommand" trigger="click">
<span class="admin-user">
<el-avatar :size="28" style="background:#409eff;margin-right:8px;"></el-avatar>
管理员
<el-icon style="margin-left:4px"><ArrowDown /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="logout">退出登录</el-dropdown-item>
<el-dropdown-item command="logout">
<el-icon><SwitchButton /></el-icon>退
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
@ -45,7 +61,7 @@
</el-header>
<!-- 主内容区 -->
<el-main>
<el-main class="main-content">
<router-view />
</el-main>
</el-container>
@ -55,7 +71,7 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Fold, Expand } from '@element-plus/icons-vue'
import { Fold, Expand, ArrowDown, SwitchButton } from '@element-plus/icons-vue'
import { useAuthStore } from '@/store/auth'
const route = useRoute()
@ -64,6 +80,10 @@ const authStore = useAuthStore()
const isCollapse = ref(false)
const currentRoute = computed(() => route.path)
const currentTitle = computed(() => {
const item = menuItems.find(m => m.path === route.path)
return item?.title || ''
})
//
const menuItems = [
@ -85,3 +105,118 @@ function handleCommand(command: string) {
}
}
</script>
<style scoped>
.admin-layout {
height: 100vh;
}
.aside {
background: #1d1e1f;
overflow: hidden;
transition: width 0.3s;
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.15);
}
.logo {
height: 56px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.logo-img {
width: 28px;
height: 28px;
flex-shrink: 0;
}
.logo-text {
color: #fff;
font-size: 15px;
font-weight: 600;
margin-left: 10px;
white-space: nowrap;
letter-spacing: 1px;
}
.side-menu {
border-right: none;
}
.side-menu .el-menu-item {
height: 48px;
line-height: 48px;
margin: 2px 8px;
border-radius: 6px;
}
.side-menu .el-menu-item.is-active {
background: #409eff !important;
color: #fff !important;
}
.side-menu .el-menu-item:hover:not(.is-active) {
background: rgba(255, 255, 255, 0.08) !important;
}
.main-container {
background: #f0f2f5;
}
.header {
background: #fff;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
height: 56px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
z-index: 1;
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
}
.collapse-btn {
font-size: 20px;
cursor: pointer;
color: #333;
transition: color 0.2s;
}
.collapse-btn:hover {
color: #409eff;
}
.breadcrumb {
font-size: 14px;
}
.header-right {
display: flex;
align-items: center;
}
.admin-user {
display: flex;
align-items: center;
cursor: pointer;
font-size: 14px;
color: #333;
}
.admin-user:hover {
color: #409eff;
}
.main-content {
padding: 20px;
overflow-y: auto;
}
</style>

View File

@ -1,29 +1,37 @@
<template>
<div>
<h2>仪表盘</h2>
<el-row :gutter="20">
<el-col :span="6">
<el-card shadow="hover">
<template #header>用户总数</template>
<div class="stat-value">--</div>
<div class="dashboard">
<h2 class="page-title">仪表盘</h2>
<p class="page-desc">欢迎回来管理员以下是系统概览数据</p>
<!-- 统计卡片 -->
<el-row :gutter="16" class="stat-row">
<el-col :xs="12" :sm="6" v-for="item in statCards" :key="item.label">
<div class="stat-card" :style="{ borderTop: `3px solid ${item.color}` }">
<div class="stat-header">
<span class="stat-label">{{ item.label }}</span>
</div>
<div class="stat-value" :style="{ color: item.color }">{{ item.value }}</div>
<div class="stat-footer">{{ item.desc }}</div>
</div>
</el-col>
</el-row>
<!-- 图表区域 -->
<el-row :gutter="16" class="chart-row">
<el-col :xs="24" :sm="14">
<el-card shadow="never" class="section-card">
<template #header>
<span class="section-title">近7天用户增长趋势</span>
</template>
<div ref="lineChartRef" class="chart-container"></div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<template #header>会员总数</template>
<div class="stat-value">--</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<template #header>今日积分发放</template>
<div class="stat-value">--</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<template #header>优惠券兑换次数</template>
<div class="stat-value">--</div>
<el-col :xs="24" :sm="10">
<el-card shadow="never" class="section-card">
<template #header>
<span class="section-title">优惠券类型分布</span>
</template>
<div ref="pieChartRef" class="chart-container"></div>
</el-card>
</el-col>
</el-row>
@ -31,14 +39,167 @@
</template>
<script setup lang="ts">
// TODO:
import { ref, onMounted, onBeforeUnmount } from 'vue'
import * as echarts from 'echarts'
import { getDashboardStats, getUserTrend, getCouponDistribution } from '@/api/dashboard'
const lineChartRef = ref<HTMLElement>()
const pieChartRef = ref<HTMLElement>()
let lineChart: echarts.ECharts | null = null
let pieChart: echarts.ECharts | null = null
const statCards = ref([
{ label: '用户总数', value: '--', icon: 'User', color: '#409eff', bg: '#ecf5ff', desc: '注册用户数量' },
{ label: '会员总数', value: '--', icon: 'UserFilled', color: '#67c23a', bg: '#f0f9eb', desc: '活跃会员数量' },
{ label: '今日积分发放', value: '--', icon: 'Coin', color: '#e6a23c', bg: '#fdf6ec', desc: '今日新增积分' },
{ label: '优惠券兑换', value: '--', icon: 'Ticket', color: '#f56c6c', bg: '#fef0f0', desc: '累计兑换次数' },
])
onMounted(async () => {
window.addEventListener('resize', handleResize)
//
try {
const res: any = await getDashboardStats()
if (res?.data) {
statCards.value[0].value = String(res.data.totalUsers)
statCards.value[1].value = String(res.data.totalMembers)
statCards.value[2].value = String(res.data.todayPoints)
statCards.value[3].value = String(res.data.totalRedeems)
}
} catch { /* 忽略 */ }
//
try {
const res: any = await getUserTrend()
if (res?.data) {
const dates = res.data.map((d: any) => d.date)
const users = res.data.map((d: any) => d.newUsers)
const members = res.data.map((d: any) => d.newMembers)
initLineChart(dates, users, members)
}
} catch {
initLineChart([], [], [])
}
//
try {
const res: any = await getCouponDistribution()
if (res?.data) {
initPieChart(res.data)
}
} catch {
initPieChart([])
}
})
onBeforeUnmount(() => {
window.removeEventListener('resize', handleResize)
lineChart?.dispose()
pieChart?.dispose()
})
function handleResize() {
lineChart?.resize()
pieChart?.resize()
}
function initLineChart(dates: string[], users: number[], members: number[]) {
if (!lineChartRef.value) return
lineChart = echarts.init(lineChartRef.value)
lineChart.setOption({
tooltip: { trigger: 'axis' },
legend: { data: ['新增用户', '新增会员'], bottom: 0, textStyle: { color: '#86909c' } },
grid: { left: 40, right: 20, top: 16, bottom: 36 },
xAxis: {
type: 'category',
data: dates,
axisLine: { lineStyle: { color: '#e5e6eb' } },
axisLabel: { color: '#86909c' },
},
yAxis: {
type: 'value',
minInterval: 1,
splitLine: { lineStyle: { color: '#f0f0f0' } },
axisLabel: { color: '#86909c' },
},
series: [
{
name: '新增用户',
type: 'line',
smooth: true,
data: users,
itemStyle: { color: '#409eff' },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(64,158,255,0.3)' },
{ offset: 1, color: 'rgba(64,158,255,0.02)' },
]),
},
},
{
name: '新增会员',
type: 'line',
smooth: true,
data: members,
itemStyle: { color: '#67c23a' },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(103,194,58,0.3)' },
{ offset: 1, color: 'rgba(103,194,58,0.02)' },
]),
},
},
],
})
}
const pieColors = ['#409eff', '#67c23a', '#e6a23c', '#c0c4cc']
function initPieChart(data: any[]) {
if (!pieChartRef.value) return
pieChart = echarts.init(pieChartRef.value)
const chartData = data.map((d: any, i: number) => ({
...d,
itemStyle: { color: pieColors[i] || '#909399' },
}))
pieChart.setOption({
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
legend: { bottom: 0, textStyle: { color: '#86909c' } },
series: [
{
type: 'pie',
radius: ['40%', '65%'],
center: ['50%', '45%'],
avoidLabelOverlap: true,
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
label: { show: false },
emphasis: { label: { show: true, fontSize: 14, fontWeight: 'bold' } },
data: chartData,
},
],
})
}
</script>
<style scoped>
.stat-value {
font-size: 32px;
font-weight: bold;
text-align: center;
color: #409eff;
.dashboard { width: 100%; }
.page-title { font-size: 22px; font-weight: 600; color: #1d2129; margin: 0 0 4px 0; }
.page-desc { color: #86909c; font-size: 14px; margin: 0 0 20px 0; }
.stat-card {
background: #fff; border-radius: 8px; padding: 20px;
box-shadow: 0 1px 3px rgba(0,0,0,0.06); transition: all 0.25s; margin-bottom: 16px;
}
.stat-card:hover { box-shadow: 0 6px 16px rgba(0,0,0,0.1); transform: translateY(-2px); }
.stat-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.stat-label { font-size: 14px; color: #86909c; }
.stat-icon-wrap {
width: 40px; height: 40px; border-radius: 10px;
display: flex; align-items: center; justify-content: center;
}
.stat-value { font-size: 32px; font-weight: 700; margin-bottom: 8px; }
.stat-footer { font-size: 12px; color: #c0c4cc; }
.section-card { border-radius: 8px; width: 100%; margin-bottom: 16px; }
.section-title { font-weight: 600; font-size: 15px; }
.chart-container { width: 100%; height: 300px; }
</style>

View File

@ -30,6 +30,7 @@ import { reactive } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useAuthStore } from '@/store/auth'
import { login } from '@/api/auth'
const router = useRouter()
const authStore = useAuthStore()
@ -39,15 +40,27 @@ const form = reactive({
password: '',
})
function handleLogin() {
async function handleLogin() {
if (!form.username || !form.password) {
ElMessage.warning('请输入用户名和密码')
return
}
// TODO: 使
authStore.setToken('admin-token-placeholder')
ElMessage.success('登录成功')
router.push('/dashboard')
try {
const res: any = await login({
username: form.username,
password: form.password,
})
authStore.setToken(res.data.token)
ElMessage.success('登录成功')
await router.push('/dashboard')
// push
if (router.currentRoute.value.path !== '/dashboard') {
window.location.href = '/dashboard'
}
} catch (err: any) {
const msg = err.response?.data?.message || '登录失败'
ElMessage.error(msg)
}
}
</script>

View File

@ -0,0 +1,79 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using VendingMachine.Application.Common;
using VendingMachine.Domain.Entities;
using VendingMachine.Infrastructure.Data;
namespace VendingMachine.Api.Controllers;
[ApiController]
[Route("api/admin/auth")]
public class AdminAuthController : ControllerBase
{
private readonly AppDbContext _db;
private readonly IConfiguration _config;
public AdminAuthController(AppDbContext db, IConfiguration config)
{
_db = db;
_config = config;
}
/// <summary>
/// 管理员登录
/// </summary>
[HttpPost("login")]
public async Task<IActionResult> Login([FromBody] AdminLoginRequest request)
{
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password))
return BadRequest(ApiResponse.Fail("请输入用户名和密码"));
var admin = await _db.AdminUsers.FirstOrDefaultAsync(a => a.Username == request.Username);
if (admin == null || admin.PasswordHash != HashPassword(request.Password))
return Unauthorized(ApiResponse.Fail("用户名或密码错误"));
var token = GenerateAdminToken(admin);
return Ok(ApiResponse<object>.Ok(new { token, username = admin.Username }));
}
private string GenerateAdminToken(AdminUser admin)
{
var jwtSection = _config.GetSection("Jwt");
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSection["Secret"]!));
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, admin.Id.ToString()),
new Claim(ClaimTypes.Name, admin.Username),
new Claim(ClaimTypes.Role, "Admin")
};
var token = new JwtSecurityToken(
issuer: jwtSection["Issuer"],
audience: jwtSection["Audience"],
claims: claims,
expires: DateTime.UtcNow.AddHours(24),
signingCredentials: credentials);
return new JwtSecurityTokenHandler().WriteToken(token);
}
// SHA256 哈希密码
internal static string HashPassword(string password)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(password));
return Convert.ToHexStringLower(bytes);
}
}
public class AdminLoginRequest
{
public string Username { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
}

View File

@ -0,0 +1,86 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using VendingMachine.Application.Common;
using VendingMachine.Domain.Entities;
using VendingMachine.Infrastructure.Data;
namespace VendingMachine.Api.Controllers;
[ApiController]
[Route("api/admin/dashboard")]
public class AdminDashboardController : ControllerBase
{
private readonly AppDbContext _db;
public AdminDashboardController(AppDbContext db)
{
_db = db;
}
/// <summary>
/// 仪表盘统计数据
/// </summary>
[HttpGet("stats")]
public async Task<IActionResult> GetStats()
{
var totalUsers = await _db.Users.CountAsync();
var totalMembers = await _db.Users.CountAsync(u => u.IsMember);
var today = DateTime.UtcNow.Date;
var todayPoints = await _db.PointRecords
.Where(p => p.Type == PointRecordType.Earn && p.CreatedAt >= today)
.SumAsync(p => p.Amount);
var totalRedeems = await _db.UserCoupons.CountAsync();
return Ok(ApiResponse<object>.Ok(new
{
totalUsers,
totalMembers,
todayPoints,
totalRedeems
}));
}
/// <summary>
/// 近7天用户增长趋势
/// </summary>
[HttpGet("user-trend")]
public async Task<IActionResult> GetUserTrend()
{
var days = new List<object>();
for (int i = 6; i >= 0; i--)
{
var date = DateTime.UtcNow.Date.AddDays(-i);
var nextDate = date.AddDays(1);
var newUsers = await _db.Users.CountAsync(u => u.CreatedAt >= date && u.CreatedAt < nextDate);
var newMembers = await _db.Users.CountAsync(u => u.IsMember && u.CreatedAt >= date && u.CreatedAt < nextDate);
days.Add(new
{
date = date.ToString("M/d"),
newUsers,
newMembers
});
}
return Ok(ApiResponse<object>.Ok(days));
}
/// <summary>
/// 优惠券类型分布
/// </summary>
[HttpGet("coupon-distribution")]
public async Task<IActionResult> GetCouponDistribution()
{
var threshold = await _db.CouponTemplates.CountAsync(c => c.Type == CouponType.ThresholdDiscount && !c.IsStamp && c.IsActive);
var direct = await _db.CouponTemplates.CountAsync(c => c.Type == CouponType.DirectDiscount && !c.IsStamp && c.IsActive);
var stamp = await _db.CouponTemplates.CountAsync(c => c.IsStamp && c.IsActive);
var expired = await _db.CouponTemplates.CountAsync(c => !c.IsActive);
return Ok(ApiResponse<object>.Ok(new[]
{
new { name = "满减券", value = threshold },
new { name = "抵扣券", value = direct },
new { name = "节日印花", value = stamp },
new { name = "已下架", value = expired }
}));
}
}

View File

@ -84,4 +84,20 @@ app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
// 初始化默认管理员账号
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
db.Database.Migrate();
if (!db.AdminUsers.Any())
{
db.AdminUsers.Add(new VendingMachine.Domain.Entities.AdminUser
{
Username = "admin",
PasswordHash = VendingMachine.Api.Controllers.AdminAuthController.HashPassword("admin123")
});
db.SaveChanges();
}
}
app.Run();

View File

@ -7,8 +7,8 @@
},
"AllowedHosts": "*",
"ConnectionStrings": {
"DefaultConnection": "Server=localhost;Database=VendingMachineDb;Trusted_Connection=True;TrustServerCertificate=True;",
"Redis": "localhost:6379"
"DefaultConnection": "Server=tcp:192.168.195.15,1433;Database=VendingMachineDb;User Id=sa;Password=Dbt@com@123;TrustServerCertificate=True;Encrypt=False;",
"Redis": "192.168.195.15:6379,defaultDatabase=1"
},
"Jwt": {
"Secret": "YourSuperSecretKeyHere_ChangeInProduction_AtLeast32Chars!",

View File

@ -0,0 +1,10 @@
namespace VendingMachine.Domain.Entities;
// 管理员用户实体
public class AdminUser
{
public int Id { get; set; }
public string Username { get; set; } = string.Empty;
public string PasswordHash { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}

View File

@ -17,6 +17,7 @@ public class AppDbContext : DbContext
public DbSet<HomeEntry> HomeEntries => Set<HomeEntry>();
public DbSet<ContentConfig> ContentConfigs => Set<ContentConfig>();
public DbSet<VendingPaymentRecord> VendingPaymentRecords => Set<VendingPaymentRecord>();
public DbSet<AdminUser> AdminUsers => Set<AdminUser>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@ -106,5 +107,13 @@ public class AppDbContext : DbContext
e.Property(v => v.TransactionId).HasMaxLength(100);
e.HasIndex(v => v.UserId);
});
modelBuilder.Entity<AdminUser>(e =>
{
e.HasKey(a => a.Id);
e.Property(a => a.Username).HasMaxLength(50);
e.Property(a => a.PasswordHash).HasMaxLength(200);
e.HasIndex(a => a.Username).IsUnique();
});
}
}

View File

@ -0,0 +1,432 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using VendingMachine.Infrastructure.Data;
#nullable disable
namespace VendingMachine.Infrastructure.Data.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260403081502_AddAdminUser")]
partial class AddAdminUser
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.5")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("VendingMachine.Domain.Entities.AdminUser", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("PasswordHash")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.HasKey("Id");
b.HasIndex("Username")
.IsUnique();
b.ToTable("AdminUsers");
});
modelBuilder.Entity("VendingMachine.Domain.Entities.Banner", b =>
{
b.Property<string>("Id")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("ImageUrlEn")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("ImageUrlZhCn")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("ImageUrlZhTw")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("LinkType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<string>("LinkUrl")
.HasColumnType("nvarchar(max)");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.HasKey("Id");
b.ToTable("Banners");
});
modelBuilder.Entity("VendingMachine.Domain.Entities.ContentConfig", b =>
{
b.Property<string>("Key")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("ContentEn")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("ContentZhCn")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("ContentZhTw")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime2");
b.HasKey("Key");
b.ToTable("ContentConfigs");
});
modelBuilder.Entity("VendingMachine.Domain.Entities.CouponTemplate", b =>
{
b.Property<string>("Id")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<decimal>("DiscountAmount")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.Property<DateTime>("ExpireAt")
.HasColumnType("datetime2");
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<bool>("IsStamp")
.HasColumnType("bit");
b.Property<string>("NameEn")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("NameZhCn")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("NameZhTw")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int>("PointsCost")
.HasColumnType("int");
b.Property<decimal?>("ThresholdAmount")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.Property<int>("Type")
.HasColumnType("int");
b.HasKey("Id");
b.ToTable("CouponTemplates");
});
modelBuilder.Entity("VendingMachine.Domain.Entities.HomeEntry", b =>
{
b.Property<string>("Id")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("ImageUrlEn")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("ImageUrlZhCn")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("ImageUrlZhTw")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.HasKey("Id");
b.ToTable("HomeEntries");
});
modelBuilder.Entity("VendingMachine.Domain.Entities.MembershipProduct", b =>
{
b.Property<string>("Id")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("AppleProductId")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("Currency")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("nvarchar(10)");
b.Property<string>("DescriptionEn")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("DescriptionZhCn")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("DescriptionZhTw")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int>("DurationDays")
.HasColumnType("int");
b.Property<string>("GoogleProductId")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<decimal>("Price")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.Property<int>("Type")
.HasColumnType("int");
b.HasKey("Id");
b.ToTable("MembershipProducts");
});
modelBuilder.Entity("VendingMachine.Domain.Entities.PointRecord", b =>
{
b.Property<string>("Id")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<int>("Amount")
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("Source")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<int>("Type")
.HasColumnType("int");
b.Property<string>("UserId")
.IsRequired()
.HasMaxLength(12)
.HasColumnType("nvarchar(12)");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("PointRecords");
});
modelBuilder.Entity("VendingMachine.Domain.Entities.PointsConfig", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<decimal>("ConversionRate")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.HasKey("Id");
b.ToTable("PointsConfigs");
});
modelBuilder.Entity("VendingMachine.Domain.Entities.User", b =>
{
b.Property<string>("Uid")
.HasMaxLength(12)
.HasColumnType("nvarchar(12)");
b.Property<string>("AreaCode")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("nvarchar(10)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<bool>("IsMember")
.HasColumnType("bit");
b.Property<string>("Language")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("nvarchar(10)");
b.Property<DateTime?>("MembershipExpireAt")
.HasColumnType("datetime2");
b.Property<int>("MembershipType")
.HasColumnType("int");
b.Property<string>("Nickname")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("Phone")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<int>("PointsBalance")
.HasColumnType("int");
b.Property<DateTime?>("PointsExpireAt")
.HasColumnType("datetime2");
b.HasKey("Uid");
b.ToTable("Users");
});
modelBuilder.Entity("VendingMachine.Domain.Entities.UserCoupon", b =>
{
b.Property<string>("Id")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("CouponTemplateId")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<DateTime>("ExpireAt")
.HasColumnType("datetime2");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<DateTime?>("UsedAt")
.HasColumnType("datetime2");
b.Property<string>("UserId")
.IsRequired()
.HasMaxLength(12)
.HasColumnType("nvarchar(12)");
b.HasKey("Id");
b.HasIndex("CouponTemplateId");
b.HasIndex("UserId");
b.ToTable("UserCoupons");
});
modelBuilder.Entity("VendingMachine.Domain.Entities.VendingPaymentRecord", b =>
{
b.Property<string>("Id")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("MachineId")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<decimal>("PaymentAmount")
.HasPrecision(18, 2)
.HasColumnType("decimal(18,2)");
b.Property<string>("PaymentStatus")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<string>("TransactionId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("UsedCouponId")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("UserId")
.IsRequired()
.HasMaxLength(12)
.HasColumnType("nvarchar(12)");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("VendingPaymentRecords");
});
modelBuilder.Entity("VendingMachine.Domain.Entities.UserCoupon", b =>
{
b.HasOne("VendingMachine.Domain.Entities.CouponTemplate", "CouponTemplate")
.WithMany()
.HasForeignKey("CouponTemplateId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("CouponTemplate");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,43 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace VendingMachine.Infrastructure.Data.Migrations
{
/// <inheritdoc />
public partial class AddAdminUser : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "AdminUsers",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Username = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
PasswordHash = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AdminUsers", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_AdminUsers_Username",
table: "AdminUsers",
column: "Username",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AdminUsers");
}
}
}

View File

@ -22,6 +22,35 @@ namespace VendingMachine.Infrastructure.Data.Migrations
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("VendingMachine.Domain.Entities.AdminUser", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("PasswordHash")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.HasKey("Id");
b.HasIndex("Username")
.IsUnique();
b.ToTable("AdminUsers");
});
modelBuilder.Entity("VendingMachine.Domain.Entities.Banner", b =>
{
b.Property<string>("Id")

View File

@ -1,7 +1,7 @@
{
"pages": [
{
"path": "pages/home/index",
"path": "pages/index/index",
"style": {
"navigationBarTitleText": "首页",
"navigationStyle": "custom"
@ -74,7 +74,7 @@
"borderStyle": "black",
"list": [
{
"pagePath": "pages/home/index",
"pagePath": "pages/index/index",
"text": "首页"
},
{

View File

@ -54,7 +54,7 @@ async function handleDeleteAccount() {
showDeleteConfirm.value = false
uni.showToast({ title: t('about.deleteSuccess'), icon: 'none' })
//
uni.switchTab({ url: '/pages/home/index' })
uni.switchTab({ url: '/pages/index/index' })
} catch {
// request
}

View File

@ -154,7 +154,7 @@ async function handleLogin() {
const info = await getUserInfo()
userStore.setUserInfo(info)
//
uni.switchTab({ url: '/pages/home/index' })
uni.switchTab({ url: '/pages/index/index' })
} catch {
// request
}