提交修改,后台管理页面bug修复,已经发布后台管理界面V1.0版本
This commit is contained in:
@@ -190,6 +190,80 @@ namespace WxCheckMvc.Controllers
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> QueryStats()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_connection.State != ConnectionState.Open)
|
||||
{
|
||||
await _connection.OpenAsync();
|
||||
}
|
||||
|
||||
// 1) 活跃用户:最近 7 天登录(UpdateTime)且 UserKey/PhoneNumber 不为空
|
||||
long activeUsers;
|
||||
using (MySqlCommand cmd = new(@"
|
||||
SELECT COUNT(1)
|
||||
FROM xcx_users
|
||||
WHERE UpdateTime >= DATE_SUB(NOW(), INTERVAL 7 DAY)
|
||||
AND UserKey IS NOT NULL AND UserKey <> ''
|
||||
AND PhoneNumber IS NOT NULL AND PhoneNumber <> ''", _connection))
|
||||
{
|
||||
activeUsers = Convert.ToInt64(await cmd.ExecuteScalarAsync());
|
||||
}
|
||||
|
||||
// 2) 总会话记录数
|
||||
long totalConversations;
|
||||
using (MySqlCommand cmd = new("SELECT COUNT(1) FROM xcx_conversation", _connection))
|
||||
{
|
||||
totalConversations = Convert.ToInt64(await cmd.ExecuteScalarAsync());
|
||||
}
|
||||
|
||||
// 3) 今日新增会话记录(CreateTime 在今天内)
|
||||
long todayNewConversations;
|
||||
using (MySqlCommand cmd = new(@"
|
||||
SELECT COUNT(1)
|
||||
FROM xcx_conversation
|
||||
WHERE CreateTime >= CURDATE()
|
||||
AND CreateTime < DATE_ADD(CURDATE(), INTERVAL 1 DAY)", _connection))
|
||||
{
|
||||
todayNewConversations = Convert.ToInt64(await cmd.ExecuteScalarAsync());
|
||||
}
|
||||
|
||||
// 4) 总用户数:UserKey/PhoneNumber 不为空
|
||||
long totalUsers;
|
||||
using (MySqlCommand cmd = new(@"
|
||||
SELECT COUNT(1)
|
||||
FROM xcx_users
|
||||
WHERE UserKey IS NOT NULL AND UserKey <> ''
|
||||
AND PhoneNumber IS NOT NULL AND PhoneNumber <> ''", _connection))
|
||||
{
|
||||
totalUsers = Convert.ToInt64(await cmd.ExecuteScalarAsync());
|
||||
}
|
||||
|
||||
var data = new AdminStatsResponse
|
||||
{
|
||||
ActiveUsers = activeUsers,
|
||||
TotalConversations = totalConversations,
|
||||
TodayNewConversations = todayNewConversations,
|
||||
TotalUsers = totalUsers
|
||||
};
|
||||
|
||||
return Ok(new { success = true, data });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, new { success = false, message = "查询失败", error = ex.Message });
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (_connection.State == ConnectionState.Open)
|
||||
{
|
||||
await _connection.CloseAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class ConversationQueryRequest
|
||||
@@ -240,4 +314,12 @@ namespace WxCheckMvc.Controllers
|
||||
public string AvatarUrl { get; set; }
|
||||
public string Department { get; set; }
|
||||
}
|
||||
|
||||
public class AdminStatsResponse
|
||||
{
|
||||
public long ActiveUsers { get; set; }
|
||||
public long TotalConversations { get; set; }
|
||||
public long TodayNewConversations { get; set; }
|
||||
public long TotalUsers { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,6 +266,15 @@ namespace WxCheckMvc.Controllers
|
||||
await insertCmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// 用户已存在:更新最后一次接口调用时间
|
||||
using (MySqlCommand updateCmd = new MySqlCommand("UPDATE xcx_users SET UpdateTime = NOW() WHERE UserKey = @UserKey", _connection))
|
||||
{
|
||||
updateCmd.Parameters.AddWithValue("@UserKey", openId);
|
||||
await updateCmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<_PublishTargetUrl>E:\Project_Class\WX_XCX\Wx_WxCheck_Prod\WxCheckMvc\bin\Release\net8.0\publish\</_PublishTargetUrl>
|
||||
<History>True|2025-12-24T12:05:02.2999541Z||;True|2025-12-24T16:33:44.2108439+08:00||;True|2025-12-24T15:32:13.8037439+08:00||;True|2025-12-12T11:09:28.8147447+08:00||;True|2025-12-11T17:04:53.2856075+08:00||;True|2025-12-11T17:04:22.0809574+08:00||;True|2025-12-05T18:56:51.7439135+08:00||;True|2025-12-05T17:44:11.4130698+08:00||;</History>
|
||||
<History>True|2025-12-25T06:00:56.3451051Z||;True|2025-12-24T20:05:02.2999541+08:00||;True|2025-12-24T16:33:44.2108439+08:00||;True|2025-12-24T15:32:13.8037439+08:00||;True|2025-12-12T11:09:28.8147447+08:00||;True|2025-12-11T17:04:53.2856075+08:00||;True|2025-12-11T17:04:22.0809574+08:00||;True|2025-12-05T18:56:51.7439135+08:00||;True|2025-12-05T17:44:11.4130698+08:00||;</History>
|
||||
<LastFailureDetails />
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>admin-web</title>
|
||||
<title>宝来威办公助手后台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
type="text"
|
||||
:icon="Menu"
|
||||
@click="toggleSidebar"
|
||||
></el-button>
|
||||
>菜单</el-button>
|
||||
<div class="logo">
|
||||
<h1>后台管理</h1>
|
||||
<h1>宝来威办公助手后台</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -47,6 +47,7 @@ import { Menu, ArrowDown, SwitchButton } from '@element-plus/icons-vue'
|
||||
import { useAuthStore } from '../../store/auth'
|
||||
import ThemeSwitcher from '../ThemeSwitcher.vue'
|
||||
|
||||
|
||||
// 路由实例
|
||||
const router = useRouter()
|
||||
|
||||
@@ -56,9 +57,6 @@ const authStore = useAuthStore()
|
||||
// 用户名
|
||||
const username = computed(() => authStore.username)
|
||||
|
||||
// 侧边栏显示状态(手机端)
|
||||
const isSidebarOpen = ref(false)
|
||||
|
||||
// 响应式布局:判断是否为手机端
|
||||
const isMobile = ref(false)
|
||||
|
||||
@@ -78,11 +76,9 @@ onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
})
|
||||
|
||||
// 切换侧边栏显示(手机端)
|
||||
// 打开侧边栏抽屉(手机端)
|
||||
const toggleSidebar = () => {
|
||||
isSidebarOpen.value = !isSidebarOpen.value
|
||||
// 发送事件给父组件
|
||||
emit('toggleSidebar', isSidebarOpen.value)
|
||||
emit('open-menu')
|
||||
}
|
||||
|
||||
// 登出处理
|
||||
@@ -92,10 +88,10 @@ const handleLogout = () => {
|
||||
}
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['toggleSidebar'])
|
||||
const emit = defineEmits(['open-menu'])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
<style scoped lang="scss">
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -146,7 +142,7 @@ const emit = defineEmits(['toggleSidebar'])
|
||||
}
|
||||
|
||||
.logo h1 {
|
||||
font-size: 18px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.username {
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
<template>
|
||||
<div class="layout-container">
|
||||
<!-- 顶部导航栏 -->
|
||||
<Header @toggle-sidebar="handleToggleSidebar" />
|
||||
<Header @open-menu="handleOpenMenu" />
|
||||
|
||||
<!-- 侧边栏 -->
|
||||
<Sidebar
|
||||
:is-open="isSidebarOpen"
|
||||
@close-sidebar="handleCloseSidebar"
|
||||
/>
|
||||
<Sidebar v-model="isMenuOpen" />
|
||||
|
||||
<!-- 主内容区域 -->
|
||||
<main class="main-content" :class="{ 'main-content--open': isSidebarOpen }">
|
||||
<main class="main-content">
|
||||
<div class="content-wrapper">
|
||||
<router-view />
|
||||
</div>
|
||||
@@ -24,20 +21,15 @@ import Header from './Header.vue'
|
||||
import Sidebar from './Sidebar.vue'
|
||||
|
||||
// 侧边栏显示状态(手机端)
|
||||
const isSidebarOpen = ref(false)
|
||||
const isMenuOpen = ref(false)
|
||||
|
||||
// 处理侧边栏切换事件(来自Header组件)
|
||||
const handleToggleSidebar = (isOpen) => {
|
||||
isSidebarOpen.value = isOpen
|
||||
}
|
||||
|
||||
// 处理侧边栏关闭事件(来自Sidebar组件)
|
||||
const handleCloseSidebar = () => {
|
||||
isSidebarOpen.value = false
|
||||
// Header 触发:打开抽屉菜单(手机端)
|
||||
const handleOpenMenu = () => {
|
||||
isMenuOpen.value = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
<style scoped lang="scss">
|
||||
.layout-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -64,13 +56,6 @@ const handleCloseSidebar = () => {
|
||||
}
|
||||
}
|
||||
|
||||
/* 手机端侧边栏打开时,主内容区域添加左侧边距 */
|
||||
.main-content--open {
|
||||
@media (max-width: 767px) {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
|
||||
@@ -1,105 +1,105 @@
|
||||
<template>
|
||||
<aside
|
||||
class="sidebar"
|
||||
:class="{ 'sidebar--open': isOpen }"
|
||||
>
|
||||
<nav class="sidebar-nav">
|
||||
<ul class="nav-list">
|
||||
<li class="nav-item" v-for="menu in menuList" :key="menu.path">
|
||||
<router-link
|
||||
:to="menu.path"
|
||||
class="nav-link"
|
||||
:class="{ 'is-active': $route.path === menu.path }"
|
||||
@click="handleMenuClick"
|
||||
>
|
||||
<el-icon class="nav-icon"><component :is="menu.icon" /></el-icon>
|
||||
<span class="nav-text">{{ menu.title }}</span>
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<!-- 遮罩层(手机端) -->
|
||||
<div
|
||||
v-if="isOpen && isMobile"
|
||||
class="sidebar-mask"
|
||||
@click="handleMaskClick"
|
||||
></div>
|
||||
<!-- 桌面/平板:固定侧边栏(不做展开/收起动画) -->
|
||||
<aside v-if="!isMobile" class="sidebar-desktop">
|
||||
<el-menu router :default-active="activePath" class="sidebar-menu" @select="handleSelect">
|
||||
<el-menu-item index="/">
|
||||
<el-icon><HomeFilled /></el-icon>
|
||||
<span>首页</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/conversations">
|
||||
<el-icon><Message /></el-icon>
|
||||
<span>会话记录管理</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/users">
|
||||
<el-icon><User /></el-icon>
|
||||
<span>用户管理</span>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</aside>
|
||||
|
||||
<!-- 手机端:抽屉菜单(Element Plus 自带隐藏/遮罩/动画) -->
|
||||
<el-drawer
|
||||
v-else
|
||||
v-model="drawerOpen"
|
||||
direction="ltr"
|
||||
size="250px"
|
||||
:with-header="false"
|
||||
>
|
||||
<el-menu router :default-active="activePath" class="drawer-menu" @select="handleSelect">
|
||||
<el-menu-item index="/">
|
||||
<el-icon><HomeFilled /></el-icon>
|
||||
<span>首页</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/conversations">
|
||||
<el-icon><Message /></el-icon>
|
||||
<span>会话记录管理</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/users">
|
||||
<el-icon><User /></el-icon>
|
||||
<span>用户管理</span>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { HomeFilled, Message, User } from '@element-plus/icons-vue'
|
||||
|
||||
// 菜单数据
|
||||
const menuList = [
|
||||
{
|
||||
path: '/',
|
||||
title: '首页',
|
||||
icon: HomeFilled
|
||||
},
|
||||
{
|
||||
path: '/conversations',
|
||||
title: '会话记录管理',
|
||||
icon: Message
|
||||
},
|
||||
{
|
||||
path: '/users',
|
||||
title: '用户管理',
|
||||
icon: User
|
||||
}
|
||||
]
|
||||
|
||||
// 侧边栏显示状态(手机端)
|
||||
const props = defineProps({
|
||||
isOpen: {
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['closeSidebar'])
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
// 响应式布局:判断是否为手机端
|
||||
const isMobile = ref(false)
|
||||
|
||||
// 监听窗口大小变化
|
||||
const handleResize = () => {
|
||||
isMobile.value = window.innerWidth < 768
|
||||
// 如果不是手机端,关闭侧边栏
|
||||
if (!isMobile.value) {
|
||||
emit('closeSidebar')
|
||||
// 切回非手机端时,确保抽屉关闭
|
||||
if (!isMobile.value && props.modelValue) {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化响应式状态
|
||||
onMounted(() => {
|
||||
handleResize()
|
||||
window.addEventListener('resize', handleResize)
|
||||
})
|
||||
|
||||
// 清理事件监听
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
})
|
||||
|
||||
// 菜单点击处理(手机端)
|
||||
const handleMenuClick = () => {
|
||||
if (isMobile.value) {
|
||||
emit('closeSidebar')
|
||||
}
|
||||
}
|
||||
const activePath = computed(() => route.path)
|
||||
|
||||
// 遮罩层点击处理(手机端)
|
||||
const handleMaskClick = () => {
|
||||
emit('closeSidebar')
|
||||
// el-drawer v-model 适配
|
||||
const drawerOpen = computed({
|
||||
get() {
|
||||
return props.modelValue
|
||||
},
|
||||
set(val) {
|
||||
emit('update:modelValue', val)
|
||||
}
|
||||
})
|
||||
|
||||
const handleSelect = () => {
|
||||
// 手机端点击菜单项后自动关闭抽屉
|
||||
if (isMobile.value) {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sidebar {
|
||||
<style scoped lang="scss">
|
||||
.sidebar-desktop {
|
||||
position: fixed;
|
||||
top: 64px;
|
||||
left: 0;
|
||||
@@ -107,86 +107,20 @@ const handleMaskClick = () => {
|
||||
width: 250px;
|
||||
background-color: var(--background-color);
|
||||
border-right: 1px solid var(--border-color);
|
||||
transition: all 0.3s ease;
|
||||
z-index: 99;
|
||||
overflow-y: auto;
|
||||
|
||||
/* 桌面端默认显示,手机端默认隐藏 */
|
||||
@media (max-width: 767px) {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
}
|
||||
|
||||
/* 侧边栏打开状态(手机端) */
|
||||
.sidebar--open {
|
||||
@media (max-width: 767px) {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.nav-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 24px;
|
||||
color: var(--text-color);
|
||||
text-decoration: none;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--border-color-lighter);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
background-color: rgba(64, 158, 255, 0.1);
|
||||
color: var(--primary-color);
|
||||
border-right: 3px solid var(--primary-color);
|
||||
}
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
font-size: 18px;
|
||||
margin-right: 12px;
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.nav-text {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 遮罩层(手机端) */
|
||||
.sidebar-mask {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
z-index: 98;
|
||||
@media (min-width: 768px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* 平板端侧边栏宽度调整 */
|
||||
@media (min-width: 768px) and (max-width: 1023px) {
|
||||
.sidebar {
|
||||
@media (max-width: 1023px) {
|
||||
width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-menu,
|
||||
.drawer-menu {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
:deep(.el-drawer__body) {
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,14 +2,16 @@ import { defineStore } from 'pinia'
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
state: () => ({
|
||||
isLoggedIn: false,
|
||||
username: ''
|
||||
isLoggedIn: localStorage.getItem('isLoggedIn') === 'true',
|
||||
username: localStorage.getItem('username') || ''
|
||||
}),
|
||||
actions: {
|
||||
login(username, password) {
|
||||
if (username === 'Admin' && password === 'Admin') {
|
||||
this.isLoggedIn = true
|
||||
this.username = username
|
||||
localStorage.setItem('isLoggedIn', 'true')
|
||||
localStorage.setItem('username', username)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -17,6 +19,8 @@ export const useAuthStore = defineStore('auth', {
|
||||
logout() {
|
||||
this.isLoggedIn = false
|
||||
this.username = ''
|
||||
localStorage.removeItem('isLoggedIn')
|
||||
localStorage.removeItem('username')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -13,9 +13,37 @@
|
||||
</div>
|
||||
</template>
|
||||
<el-form :model="filterForm" inline label-width="80px" size="small">
|
||||
<el-form-item label="时间范围">
|
||||
<el-date-picker v-model="filterForm.dateRange" type="daterange" range-separator="至" start-placeholder="开始日期"
|
||||
end-placeholder="结束日期" format="YYYY-MM-DD" value-format="YYYY-MM-DD" clearable></el-date-picker>
|
||||
<el-form-item label="快捷范围">
|
||||
<el-select v-model="filterForm.quickRange" placeholder="请选择" clearable style="width: 150px" @change="handleQuickRangeChange">
|
||||
<el-option label="今天" value="today"></el-option>
|
||||
<el-option label="最近3天" value="last3"></el-option>
|
||||
<el-option label="最近7天" value="last7"></el-option>
|
||||
<el-option label="最近30天" value="last30"></el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="开始日期">
|
||||
<el-date-picker
|
||||
v-model="filterForm.startDate"
|
||||
type="date"
|
||||
format="YYYY-MM-DD"
|
||||
value-format="YYYY-MM-DD"
|
||||
clearable
|
||||
class="date-picker"
|
||||
@change="handleManualDateChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="结束日期">
|
||||
<el-date-picker
|
||||
v-model="filterForm.endDate"
|
||||
type="date"
|
||||
format="YYYY-MM-DD"
|
||||
value-format="YYYY-MM-DD"
|
||||
clearable
|
||||
class="date-picker"
|
||||
@change="handleManualDateChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="用户">
|
||||
@@ -110,12 +138,51 @@ const emptyText = computed(() => loading.value ? '加载中...' : '暂无数据'
|
||||
|
||||
// 筛选表单数据
|
||||
const filterForm = reactive({
|
||||
dateRange: [],
|
||||
quickRange: 'last7',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
userKey: '',
|
||||
messageType: '',
|
||||
department: ''
|
||||
})
|
||||
|
||||
const formatDate = (date) => {
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
const addDays = (date, days) => {
|
||||
const d = new Date(date)
|
||||
d.setDate(d.getDate() + days)
|
||||
return d
|
||||
}
|
||||
|
||||
const applyQuickRange = (range) => {
|
||||
const today = new Date()
|
||||
const end = formatDate(today)
|
||||
let start = end
|
||||
|
||||
if (range === 'today') start = end
|
||||
if (range === 'last3') start = formatDate(addDays(today, -2))
|
||||
if (range === 'last7') start = formatDate(addDays(today, -6))
|
||||
if (range === 'last30') start = formatDate(addDays(today, -29))
|
||||
|
||||
filterForm.startDate = start
|
||||
filterForm.endDate = end
|
||||
}
|
||||
|
||||
const handleQuickRangeChange = (value) => {
|
||||
if (!value) return
|
||||
applyQuickRange(value)
|
||||
}
|
||||
|
||||
const handleManualDateChange = () => {
|
||||
// 手动修改开始/结束日期时,取消快捷范围选中态
|
||||
filterForm.quickRange = ''
|
||||
}
|
||||
|
||||
// 会话记录列表
|
||||
const conversationList = ref([])
|
||||
|
||||
@@ -135,12 +202,13 @@ const handleQuery = () => {
|
||||
const handleReset = () => {
|
||||
// 重置表单
|
||||
Object.keys(filterForm).forEach(key => {
|
||||
if (key === 'dateRange') {
|
||||
filterForm[key] = []
|
||||
} else {
|
||||
filterForm[key] = ''
|
||||
}
|
||||
filterForm[key] = ''
|
||||
})
|
||||
|
||||
// 默认最近7天
|
||||
filterForm.quickRange = 'last7'
|
||||
applyQuickRange('last7')
|
||||
|
||||
currentPage.value = 1
|
||||
fetchConversations()
|
||||
}
|
||||
@@ -196,8 +264,8 @@ const fetchConversations = async (isLoadMore = false) => {
|
||||
try {
|
||||
// 构建请求参数
|
||||
const params = {
|
||||
startTime: filterForm.dateRange[0] ? new Date(filterForm.dateRange[0]).getTime() : null,
|
||||
endTime: filterForm.dateRange[1] ? new Date(filterForm.dateRange[1]).getTime() + 24 * 60 * 60 * 1000 - 1 : null,
|
||||
startTime: filterForm.startDate ? `${filterForm.startDate}T00:00:00` : null,
|
||||
endTime: filterForm.endDate ? `${filterForm.endDate}T23:59:59.999` : null,
|
||||
userKey: filterForm.userKey,
|
||||
messageType: filterForm.messageType ? Number(filterForm.messageType) : null,
|
||||
department: filterForm.department,
|
||||
@@ -272,6 +340,8 @@ const fetchUsers = async () => {
|
||||
|
||||
// 页面初始化
|
||||
onMounted(() => {
|
||||
// 默认最近7天
|
||||
applyQuickRange('last7')
|
||||
fetchUsers()
|
||||
fetchConversations()
|
||||
})
|
||||
|
||||
@@ -4,13 +4,26 @@
|
||||
|
||||
<!-- 系统统计卡片 -->
|
||||
<div class="stats-container">
|
||||
|
||||
<el-card class="stat-card">
|
||||
<div class="stat-content">
|
||||
<div class="stat-icon department-icon">
|
||||
<el-icon><OfficeBuilding /></el-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<h3>{{ totalUserCount }}</h3>
|
||||
<p>总用户数</p>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="stat-card">
|
||||
<div class="stat-content">
|
||||
<div class="stat-icon user-icon">
|
||||
<el-icon><User /></el-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<h3>{{ userCount }}</h3>
|
||||
<h3>{{ activeUserCount }}</h3>
|
||||
<p>活跃用户</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -22,23 +35,12 @@
|
||||
<el-icon><Message /></el-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<h3>{{ conversationCount }}</h3>
|
||||
<h3>{{ totalConversationCount }}</h3>
|
||||
<p>会话记录</p>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="stat-card">
|
||||
<div class="stat-content">
|
||||
<div class="stat-icon department-icon">
|
||||
<el-icon><OfficeBuilding /></el-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<h3>{{ departmentCount }}</h3>
|
||||
<p>部门数量</p>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
|
||||
<el-card class="stat-card">
|
||||
<div class="stat-content">
|
||||
@@ -46,8 +48,8 @@
|
||||
<el-icon><Calendar /></el-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<h3>{{ todayCount }}</h3>
|
||||
<p>今日新增</p>
|
||||
<h3>{{ todayNewConversationCount }}</h3>
|
||||
<p>今日会话</p>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
@@ -95,51 +97,25 @@ const authStore = useAuthStore()
|
||||
const username = computed(() => authStore.username)
|
||||
|
||||
// 统计数据
|
||||
const userCount = ref(0)
|
||||
const conversationCount = ref(0)
|
||||
const departmentCount = ref(0)
|
||||
const todayCount = ref(0)
|
||||
const activeUserCount = ref(0)
|
||||
const totalConversationCount = ref(0)
|
||||
const totalUserCount = ref(0)
|
||||
const todayNewConversationCount = ref(0)
|
||||
|
||||
// 最近会话记录
|
||||
const recentConversations = ref([])
|
||||
|
||||
// 数据转换:将后端返回的会话数据转换为前端所需格式
|
||||
const convertConversationData = (data) => {
|
||||
return data.map(item => ({
|
||||
id: item.Id,
|
||||
recordTime: new Date(item.RecordTime).toLocaleString('zh-CN'),
|
||||
recordTimeUTCStamp: item.RecordTimeUTCStamp,
|
||||
userName: item.UserName,
|
||||
conversationContent: item.ConversationContent,
|
||||
messageType: item.MessageType
|
||||
}))
|
||||
}
|
||||
|
||||
// 获取系统统计数据
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
// 获取用户列表数据,用于统计
|
||||
const usersData = await request.get('/Admin/QueryUsers')
|
||||
const users = Array.isArray(usersData) ? usersData : usersData.data || []
|
||||
|
||||
// 获取会话记录数据,用于统计
|
||||
const conversationsData = await request.post('/Admin/QueryConversations', {})
|
||||
const conversations = Array.isArray(conversationsData) ? conversationsData : conversationsData.data || []
|
||||
|
||||
// 计算统计数据
|
||||
userCount.value = users.length
|
||||
conversationCount.value = conversations.length
|
||||
|
||||
// 计算部门数量
|
||||
const departments = new Set(users.map(user => user.Department))
|
||||
departmentCount.value = departments.size
|
||||
|
||||
// 计算今日新增会话数
|
||||
const today = new Date().toDateString()
|
||||
const todayConversations = conversations.filter(conv => {
|
||||
return new Date(conv.RecordTime).toDateString() === today
|
||||
})
|
||||
todayCount.value = todayConversations.length
|
||||
const res = await request.get('/Admin/QueryStats')
|
||||
const stats = res?.data ?? res
|
||||
|
||||
activeUserCount.value = stats?.activeUsers ?? 0
|
||||
totalConversationCount.value = stats?.totalConversations ?? 0
|
||||
totalUserCount.value = stats?.totalUsers ?? 0
|
||||
todayNewConversationCount.value = stats?.todayNewConversations ?? 0
|
||||
|
||||
// 获取最近会话记录
|
||||
await fetchRecentConversations()
|
||||
@@ -152,21 +128,28 @@ const fetchStats = async () => {
|
||||
// 获取最近会话记录
|
||||
const fetchRecentConversations = async () => {
|
||||
try {
|
||||
// 调用API获取最近的5条会话记录
|
||||
const data = await request.post('/Admin/QueryConversations', {})
|
||||
|
||||
const params = {
|
||||
startTime: null,
|
||||
endTime: null,
|
||||
userKey: '',
|
||||
messageType: null,
|
||||
department: '',
|
||||
page: 1,
|
||||
pageSize: 8
|
||||
}
|
||||
|
||||
// 调用API获取会话记录
|
||||
const data = await request.post('/Admin/QueryConversations', JSON.stringify(params))
|
||||
|
||||
// 检查返回数据格式,确保是数组
|
||||
const rawData = Array.isArray(data) ? data : data.data || []
|
||||
|
||||
// 转换数据格式
|
||||
const convertedData = convertConversationData(rawData)
|
||||
|
||||
// 按时间排序(最新的记录在前面),并只显示最近5条
|
||||
recentConversations.value = convertedData
|
||||
recentConversations.value = rawData
|
||||
.sort((a, b) => {
|
||||
return b.recordTimeUTCStamp - a.recordTimeUTCStamp
|
||||
})
|
||||
.slice(0, 5)
|
||||
.slice(0, 10)
|
||||
} catch (error) {
|
||||
console.error('获取最近会话记录失败:', error)
|
||||
recentConversations.value = []
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive } from 'vue'
|
||||
import { ref, reactive, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAuthStore } from '../store/auth'
|
||||
@@ -45,7 +45,7 @@ const loading = ref(false)
|
||||
|
||||
// 登录表单数据
|
||||
const loginForm = reactive({
|
||||
username: '',
|
||||
username: 'Admin',
|
||||
password: ''
|
||||
})
|
||||
|
||||
@@ -65,6 +65,13 @@ const router = useRouter()
|
||||
// 认证状态管理
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// 处理键盘按键事件
|
||||
const handleKeyPress = (event) => {
|
||||
if (event.key === 'Enter') {
|
||||
handleLogin()
|
||||
}
|
||||
}
|
||||
|
||||
// 登录处理
|
||||
const handleLogin = async () => {
|
||||
// 表单验证
|
||||
@@ -92,6 +99,16 @@ const handleLogin = async () => {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 组件挂载时添加键盘监听
|
||||
onMounted(() => {
|
||||
window.addEventListener('keypress', handleKeyPress)
|
||||
})
|
||||
|
||||
// 组件卸载时移除键盘监听
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keypress', handleKeyPress)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<h2>用户管理</h2>
|
||||
|
||||
<!-- 筛选表单 -->
|
||||
<el-card class="filter-card">
|
||||
<!-- <el-card class="filter-card">
|
||||
<el-form :model="filterForm" inline label-width="80px">
|
||||
<el-form-item label="用户名">
|
||||
<el-input
|
||||
@@ -44,7 +44,7 @@
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</el-card> -->
|
||||
|
||||
<!-- 用户列表表格 -->
|
||||
<el-card class="table-card">
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# Change: 管理端侧边栏改为 Element Plus Drawer + Menu(事件触发)
|
||||
|
||||
## Why
|
||||
当前移动端侧边栏的显示/隐藏依赖 class + CSS(transform)实现,在不同样式覆盖或构建产物下容易出现“class 变化但 UI 无反应”的问题,导致点击菜单按钮无法可靠展开。
|
||||
|
||||
## What Changes
|
||||
- 移动端侧边栏改为使用 Element Plus 的 `el-drawer` 作为抽屉容器,依靠组件自带的显示/隐藏机制。
|
||||
- 菜单改为 Element Plus 的 `el-menu`(router 模式),用于导航到:首页、会话记录管理、用户管理。
|
||||
- Header 菜单按钮改为**事件触发打开抽屉**(不再通过切换 class 驱动 CSS 动画)。
|
||||
|
||||
## Non-goals
|
||||
- 不新增页面、不新增路由、不改动权限/鉴权逻辑。
|
||||
|
||||
## Impact
|
||||
- Affected specs: `openspec/specs/backend-admin/spec.md`
|
||||
- Affected code:
|
||||
- `admin-web/src/components/Layout/Header.vue`
|
||||
- `admin-web/src/components/Layout/Sidebar.vue`
|
||||
- `admin-web/src/components/Layout/Layout.vue`
|
||||
@@ -0,0 +1,18 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 菜单布局(Element Plus Drawer + Menu)
|
||||
|
||||
管理端导航菜单 SHALL 使用 Element Plus 组件实现,并在移动端使用抽屉式菜单以保证可用性:
|
||||
- 桌面/平板端:显示固定侧边栏菜单(Element Plus `el-menu`)
|
||||
- 手机端:点击 Header 菜单按钮后打开抽屉(Element Plus `el-drawer`),抽屉内部为 `el-menu`
|
||||
- 抽屉关闭:由 `el-drawer` 自带交互完成(点击遮罩/关闭动作),不依赖 class + CSS transform
|
||||
|
||||
#### Scenario: 手机端点击按钮打开菜单
|
||||
- **WHEN** 管理员在手机端点击 Header 的菜单按钮
|
||||
- **THEN** 系统通过事件触发打开 `el-drawer`
|
||||
- **AND** 抽屉内显示 `el-menu` 导航项
|
||||
|
||||
#### Scenario: 手机端点击菜单项自动收起
|
||||
- **WHEN** 管理员在抽屉中点击任一菜单项
|
||||
- **THEN** 路由跳转到对应页面
|
||||
- **AND** 抽屉自动关闭
|
||||
@@ -0,0 +1,9 @@
|
||||
# Tasks — 管理端侧边栏改为 Element Plus Drawer + Menu
|
||||
|
||||
- [x] 更新规范:在本 change 下补充 delta spec(backend-admin)
|
||||
- [x] 更新现行 spec:`openspec/specs/backend-admin/spec.md` 的“菜单布局”要求
|
||||
- [x] Header 改造:点击菜单按钮触发事件(open)
|
||||
- [x] Sidebar 改造:移动端使用 `el-drawer`,内部使用 `el-menu`
|
||||
- [x] Layout 改造:接收 Header 事件,控制抽屉打开;抽屉关闭由 `el-drawer` v-model 驱动
|
||||
- [x] 删除旧的 class+CSS 开关逻辑(不再依赖 `.sidebar--open`、transform)
|
||||
- [ ] 前端构建验证:`npm run build`(admin-web)
|
||||
@@ -0,0 +1,15 @@
|
||||
# Change: 优化管理端会话列表日期筛选(移动端)
|
||||
|
||||
## Why
|
||||
当前管理端会话列表使用单个 `daterange` 控件,在移动端交互与可用性较差;且缺少常用快捷时间范围,导致筛选效率低。
|
||||
|
||||
## What Changes
|
||||
- 将“时间范围”从单个 `daterange` 控件改为 **两个独立日期选择器(开始日期、结束日期)**。
|
||||
- 新增 **快捷范围选择器**:今天、最近3天、最近7天、最近30天。
|
||||
- 默认筛选范围为 **最近7天**。
|
||||
- 做好移动端适配:在小屏下控件纵向排列、全宽显示。
|
||||
|
||||
## Impact
|
||||
- Affected specs: `openspec/specs/backend-admin/spec.md`
|
||||
- Affected code: `admin-web/src/views/ConversationList.vue`
|
||||
- Backend API 不变:继续使用 `POST /api/Admin/QueryConversations` 的 `StartTime/EndTime` 过滤能力。
|
||||
@@ -0,0 +1,23 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 会话记录筛选(时间范围)
|
||||
|
||||
管理端会话记录页面 SHALL 提供便于移动端使用的时间范围筛选:
|
||||
- 使用两个独立日期选择器:开始日期、结束日期
|
||||
- 提供快捷范围选择器:今天、最近3天、最近7天、最近30天
|
||||
- 默认范围为最近7天
|
||||
|
||||
#### Scenario: 默认最近7天
|
||||
- **WHEN** 管理员进入会话记录页面
|
||||
- **THEN** 页面默认选中“最近7天”
|
||||
- **AND** 请求会携带对应的 `StartTime/EndTime`
|
||||
|
||||
#### Scenario: 使用快捷范围
|
||||
- **WHEN** 管理员选择“今天/最近3天/最近7天/最近30天”
|
||||
- **THEN** 系统更新开始/结束日期
|
||||
- **AND** 点击查询后按该范围过滤会话记录
|
||||
|
||||
#### Scenario: 移动端可用性
|
||||
- **WHEN** 管理员在移动端访问会话记录页面
|
||||
- **THEN** 快捷选择器与开始/结束日期选择器纵向排列
|
||||
- **AND** 控件全宽显示,避免横向挤压导致误触
|
||||
@@ -0,0 +1,10 @@
|
||||
# Tasks — 优化管理端会话列表日期筛选(移动端)
|
||||
|
||||
- [x] 更新管理端会话筛选 UI:将 `daterange` 改为开始/结束两个日期选择器
|
||||
- [x] 增加快捷范围选择器:今天、最近3天、最近7天、最近30天
|
||||
- [x] 默认值设置为最近7天,并确保“重置”后仍回到默认范围
|
||||
- [x] 调整请求参数:`StartTime/EndTime` 发送为可解析的 DateTime(字符串/ISO),确保后端可正确绑定
|
||||
- [x] 移动端适配:小屏纵向排列、控件全宽
|
||||
- [x] 更新 openspec:
|
||||
- [x] 在 `openspec/specs/backend-admin/spec.md` 补充/修改相关 Requirement & Scenario
|
||||
- [x] 在本 change 下添加 delta spec:`openspec/changes/update-admin-conversation-date-filter/specs/backend-admin/spec.md`
|
||||
@@ -0,0 +1,48 @@
|
||||
# Implementation — 修改登录并新增后台统计接口
|
||||
|
||||
## 已完成改动
|
||||
|
||||
1. 登录逻辑:`WxCheckMvc/Controllers/LoginController.cs`
|
||||
|
||||
- 修改点:在 `Login` 方法中,原来的存在用户分支不做更改;现在在判断 `count != 0`(用户存在)分支下新增:
|
||||
|
||||
```csharp
|
||||
using (MySqlCommand updateCmd = new MySqlCommand("UPDATE xcx_users SET UpdateTime = NOW() WHERE UserKey = @UserKey", _connection))
|
||||
{
|
||||
updateCmd.Parameters.AddWithValue("@UserKey", openId);
|
||||
await updateCmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
```
|
||||
|
||||
- 目的:显式写入 `UpdateTime`,表示用户在本次登录调用中活跃。
|
||||
|
||||
2. 新增统计接口:`WxCheckMvc/Controllers/AdminController.cs`
|
||||
|
||||
- 新增方法 `QueryStats()`(GET):
|
||||
- ActiveUsers:SELECT COUNT(1) FROM xcx_users WHERE UpdateTime >= DATE_SUB(NOW(), INTERVAL 7 DAY) AND UserKey <> '' AND PhoneNumber <> ''
|
||||
- TotalConversations:SELECT COUNT(1) FROM xcx_conversation
|
||||
- TodayNewConversations:SELECT COUNT(1) FROM xcx_conversation WHERE CreateTime >= CURDATE() AND CreateTime < DATE_ADD(CURDATE(), INTERVAL 1 DAY)
|
||||
- TotalUsers:SELECT COUNT(1) FROM xcx_users WHERE UserKey <> '' AND PhoneNumber <> ''
|
||||
|
||||
- 返回 DTO:`AdminStatsResponse { long ActiveUsers, long TotalConversations, long TodayNewConversations, long TotalUsers }`。
|
||||
|
||||
3. 前端首页统计卡片改造:`admin-web/src/views/Home.vue`
|
||||
|
||||
- 修改点:首页不再通过 `QueryUsers` / `QueryConversations` 拉全量数据后在前端计算统计值;改为直接调用 `GET /api/Admin/QueryStats`。
|
||||
- 映射关系:
|
||||
- 活跃用户卡片:`ActiveUsers`
|
||||
- 会话记录卡片:`TotalConversations`
|
||||
- 总用户数卡片:`TotalUsers`
|
||||
- 今日新增会话卡片:`TodayNewConversations`
|
||||
|
||||
## 测试与验证建议
|
||||
|
||||
- 本地运行 `dotnet build`:确认编译通过。
|
||||
- 使用 Postman / curl 调用 `POST /api/Login/Login`(传入可换取 openid 的 `code` 或在测试中直接模拟 openid),确认在用户存在时 `UpdateTime` 被修改。
|
||||
- 调用 `GET /api/Admin/QueryStats` 验证返回结构并和数据库结果比对。
|
||||
- 访问管理端首页,确认四张统计卡片展示值与接口返回一致。
|
||||
|
||||
## 备注
|
||||
|
||||
- `xcx_users.UpdateTime` 已在数据库 schema 中定义为 `DEFAULT current_timestamp() ON UPDATE CURRENT_TIMESTAMP`,因此显式 `UPDATE` 是可行且语义明确的(无迁移需求)。
|
||||
- 建议后续将统计接口限制为管理员访问(加入 `[Authorize(Roles = "Admin")]` 或使用策略)。
|
||||
40
openspec/changes/update-login-and-admin-stats/proposal.md
Normal file
40
openspec/changes/update-login-and-admin-stats/proposal.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# 修改登录逻辑并新增后台统计接口
|
||||
|
||||
## 1. 问题描述
|
||||
|
||||
当前系统登录逻辑(`POST /api/Login/Login`):
|
||||
- 使用微信 `code` 换取 `openid` 并以 `openid` 作为 `UserKey`。
|
||||
- 若数据库中不存在该 `UserKey`,系统会自动插入一条记录。
|
||||
- 如果用户已存在(count != 0),当前实现不对用户表做任何更新(包括 `UpdateTime`)。
|
||||
|
||||
另外,后台管理侧需要一个简洁的统计接口,用于展示系统关键指标(活跃用户、会话统计等),当前并没有统一的聚合接口。
|
||||
|
||||
同时,管理端首页当前通过拉取用户列表与会话列表在前端计算统计数据,调用开销大且不一致。需要改为直接使用聚合统计接口返回的四个指标。
|
||||
|
||||
## 2. 目标与提议
|
||||
|
||||
- 修改登录逻辑:当用户存在时,**更新该用户的 `UpdateTime` 为本次接口调用时间**(UTC/服务器当前时间)。该字段将作为“最近登录/活跃”标准。
|
||||
|
||||
- 新增后台统计接口:`GET /api/Admin/QueryStats`,返回单行统计数据(JSON),包含:
|
||||
1. ActiveUsers:最近 7 天有登录(`UpdateTime` 在最近 7 天)且 `UserKey` 与 `PhoneNumber` 都不为空的用户数
|
||||
2. TotalConversations:`xcx_conversation` 表的总记录数
|
||||
3. TodayNewConversations:`xcx_conversation` 表中 `CreateTime` 在“今天”内的记录数
|
||||
4. TotalUsers:`xcx_users` 表中 `UserKey` 与 `PhoneNumber` 都不为空的用户总数
|
||||
|
||||
- 前端首页统计卡片改造:管理端首页(Home)SHALL 直接调用 `GET /api/Admin/QueryStats`,并将返回的四个数字映射到四张统计卡片中(不再通过 QueryUsers/QueryConversations 在前端计算)。
|
||||
|
||||
## 3. 兼容性与风险
|
||||
|
||||
- 数据库层无需新增字段:`xcx_users.UpdateTime` 已存在且带有 `ON UPDATE CURRENT_TIMESTAMP`。但显式执行 `UPDATE ... SET UpdateTime = NOW()` 可以确保在登录时明确写入(而不是依赖其他 UPDATE 操作触发)。
|
||||
- 该变更不影响现有注册/资料完善逻辑。
|
||||
- 需要为统计接口添加合理权限控制(建议在未来迭代中加入鉴权)。
|
||||
|
||||
## 4. 数据验证与示例SQL
|
||||
|
||||
- 活跃用户(最近7天):
|
||||
SELECT COUNT(1) FROM xcx_users WHERE UpdateTime >= DATE_SUB(NOW(), INTERVAL 7 DAY) AND UserKey <> '' AND PhoneNumber <> '';
|
||||
|
||||
- 今日新增会话:
|
||||
SELECT COUNT(1) FROM xcx_conversation WHERE CreateTime >= CURDATE() AND CreateTime < DATE_ADD(CURDATE(), INTERVAL 1 DAY);
|
||||
|
||||
- 总会话、总用户:分别用 COUNT(1) 聚合。
|
||||
25
openspec/changes/update-login-and-admin-stats/tasks.md
Normal file
25
openspec/changes/update-login-and-admin-stats/tasks.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# Tasks — 修改登录并新增后台统计接口
|
||||
|
||||
1. 代码实现(后端) ✅
|
||||
- 修改 `WxCheckMvc/Controllers/LoginController.cs`:当已存在用户时执行 `UPDATE xcx_users SET UpdateTime = NOW() WHERE UserKey = @UserKey`。
|
||||
- 新增 `WxCheckMvc/Controllers/AdminController.cs` 的 `QueryStats()` GET 方法,返回聚合统计结果(见实现细节)。
|
||||
|
||||
2. 前端改造(后台管理首页) ✅
|
||||
- 修改 `admin-web/src/views/Home.vue`:系统统计卡片改为调用 `GET /Admin/QueryStats`,四张卡片分别展示接口返回的四个数字。
|
||||
|
||||
3. 单元/集成测试(可选但建议)
|
||||
- 登录逻辑:模拟已存在用户的登录,断言 `UpdateTime` 更新为最近时间(或至少发生了写入)。
|
||||
- 统计接口:在已知测试数据上断言返回的四个指标正确。
|
||||
|
||||
4. 文档与 Spec ✅
|
||||
- 更新 `openspec/specs/backend-api/spec.md`,记录 Login 行为变更和新增 `GET /api/Admin/QueryStats` 的 API Contract。
|
||||
- 更新 `openspec/specs/backend-admin/spec.md`,记录管理端首页统计使用 `QueryStats`。
|
||||
- 在 openspec 新建变更记录(已完成)。
|
||||
|
||||
5. 发布与验证
|
||||
- 运行 `dotnet build` 确认编译通过
|
||||
- 本地或测试环境调用接口验证结果
|
||||
|
||||
6. 可选:鉴权与监控(后续迭代)
|
||||
- 将 `QueryStats` 加上管理员鉴权(JWT/Role)
|
||||
- 添加 Prometheus 或应用层度量埋点,记录统计接口调用频次
|
||||
@@ -108,6 +108,22 @@
|
||||
- **THEN** 显示抽屉式菜单(点击菜单按钮展开)
|
||||
- **AND** 菜单包含:首页、会话记录管理、用户管理等功能入口
|
||||
|
||||
实现约束(移动端可靠性):
|
||||
- 手机端抽屉菜单 SHOULD 使用 Element Plus `el-drawer` 自带的显示/隐藏机制
|
||||
- 菜单 SHOULD 使用 Element Plus `el-menu`(router 模式)
|
||||
- 抽屉的打开 SHALL 由 Header 菜单按钮通过事件触发
|
||||
- 不依赖 class + CSS transform 的方式实现“展开/收起”(避免出现 class 变化但 UI 无响应)
|
||||
|
||||
#### Scenario: 手机端点击按钮打开菜单(事件触发)
|
||||
- **WHEN** 管理员在手机端点击 Header 的菜单按钮
|
||||
- **THEN** 系统通过事件触发打开 `el-drawer`
|
||||
- **AND** 抽屉内显示 `el-menu` 导航项
|
||||
|
||||
#### Scenario: 手机端点击菜单项自动收起
|
||||
- **WHEN** 管理员在抽屉中点击任一菜单项
|
||||
- **THEN** 路由跳转到对应页面
|
||||
- **AND** 抽屉自动关闭
|
||||
|
||||
### Requirement: 模块化设计
|
||||
前端管理网站 SHALL 采用模块化设计思路。
|
||||
|
||||
@@ -173,6 +189,23 @@
|
||||
- **WHEN** 管理端提交包含多个筛选条件的查询请求
|
||||
- **THEN** 系统返回同时满足所有条件的会话记录
|
||||
|
||||
### Requirement: 会话记录筛选(时间范围,移动端友好)
|
||||
|
||||
管理端会话记录页面 SHALL 提供便于移动端使用的时间范围筛选控件:
|
||||
- 使用两个独立日期选择器:开始日期、结束日期
|
||||
- 提供快捷范围选择器:今天、最近3天、最近7天、最近30天
|
||||
- 默认范围为最近7天
|
||||
|
||||
#### Scenario: 默认最近7天
|
||||
- **WHEN** 管理员进入会话记录页面
|
||||
- **THEN** 页面默认选中“最近7天”
|
||||
- **AND** 查询请求携带对应的 `StartTime/EndTime`
|
||||
|
||||
#### Scenario: 移动端可用性
|
||||
- **WHEN** 管理员在移动端访问会话记录页面
|
||||
- **THEN** 快捷选择器与开始/结束日期选择器纵向排列
|
||||
- **AND** 控件全宽显示,避免横向挤压
|
||||
|
||||
### Requirement: 查询用户列表(管理端)
|
||||
系统 SHALL 提供接口返回可用用户列表,用于管理侧选择/筛选。
|
||||
|
||||
@@ -195,6 +228,25 @@
|
||||
- **THEN** 返回已完善基础信息且未禁用的用户
|
||||
- **AND** 结果按首次登录时间倒序排列
|
||||
|
||||
### Requirement: 首页统计(管理端)
|
||||
|
||||
系统 SHALL 提供聚合统计接口供管理端首页展示关键指标。
|
||||
|
||||
接口:`GET /api/Admin/QueryStats`
|
||||
|
||||
返回(成功):
|
||||
- `success: true`
|
||||
- `data`:包含以下字段的对象
|
||||
- `ActiveUsers`:最近 7 天内 `UpdateTime` 有更新,且 `UserKey` 与 `PhoneNumber` 均不为空的用户数
|
||||
- `TotalConversations`:`xcx_conversation` 总记录数
|
||||
- `TodayNewConversations`:`xcx_conversation.CreateTime` 在“今天”内的记录数
|
||||
- `TotalUsers`:`xcx_users` 中 `UserKey` 与 `PhoneNumber` 均不为空的用户总数
|
||||
|
||||
#### Scenario: 首页加载统计卡片
|
||||
- **WHEN** 管理员进入首页(Home)
|
||||
- **THEN** 前端调用 `GET /api/Admin/QueryStats`
|
||||
- **AND** 将返回的四个数字分别展示到“系统统计卡片”的四个位置
|
||||
|
||||
## Known Limitations
|
||||
- 当前接口未提供分页与导出能力(若管理端数据量很大,需在后续能力中补齐)。
|
||||
- 当前未强制鉴权(JWT 配置存在但未启用认证中间件/授权标注)。
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
行为:
|
||||
- 若 `xcx_users` 不存在该 `UserKey`,系统 SHALL 自动插入一条用户记录(资料可为空)。
|
||||
- 若用户 `IsDisabled = 1`,系统 SHALL 返回 `success: false`。
|
||||
- 若 `xcx_users` 中已经存在该 `UserKey`,系统 SHALL 在登录流程中**更新该记录的 `UpdateTime` 为当前服务器时间**(用于“最近登录/活跃”判断)。
|
||||
|
||||
#### Scenario: 首次登录自动建档
|
||||
- **WHEN** 提交的 `code` 能换取有效 `openid`
|
||||
@@ -196,6 +197,23 @@
|
||||
- **WHEN** Stream 中无可读消息
|
||||
- **THEN** 返回 `success: true` 且数据为空
|
||||
|
||||
### Requirement: 管理端统计接口(QueryStats)
|
||||
|
||||
系统 SHALL 提供聚合统计接口 `GET /api/Admin/QueryStats`,返回单行 JSON,`data` 对象字段如下:
|
||||
|
||||
- `ActiveUsers` (number):最近 7 天内 `UpdateTime` >= DATE_SUB(NOW(), INTERVAL 7 DAY) 且 `UserKey` 和 `PhoneNumber` 都不为空的用户数
|
||||
- `TotalConversations` (number):`xcx_conversation` 的总记录数
|
||||
- `TodayNewConversations` (number):`xcx_conversation` 中 `CreateTime >= CURDATE() AND CreateTime < DATE_ADD(CURDATE(), INTERVAL 1 DAY)` 的记录数
|
||||
- `TotalUsers` (number):`xcx_users` 中 `UserKey` 和 `PhoneNumber` 都不为空的用户总数
|
||||
|
||||
响应(成功):
|
||||
- `success: true`
|
||||
- `data`: { `ActiveUsers`, `TotalConversations`, `TodayNewConversations`, `TotalUsers` }
|
||||
|
||||
行为/安全:
|
||||
- 当前实现为匿名访问,建议在后续迭代中加上管理员鉴权(例如 `[Authorize(Roles = "Admin")]` 或策略)。
|
||||
- 为保证查询性能,建议为 `xcx_users.UpdateTime` 和 `xcx_conversation.CreateTime` 建立适当索引(若数据量很大)。
|
||||
|
||||
## Known Limitations
|
||||
- 当前 JWT 配置存在,但认证中间件与授权标注未启用;接口默认可匿名访问。
|
||||
- `MessageType` 的过滤条件存在实现差异:部分接口仅在 `MessageType == 1` 时追加过滤(不覆盖 2)。
|
||||
|
||||
Reference in New Issue
Block a user