提交修改,后台管理页面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
|
public class ConversationQueryRequest
|
||||||
@@ -240,4 +314,12 @@ namespace WxCheckMvc.Controllers
|
|||||||
public string AvatarUrl { get; set; }
|
public string AvatarUrl { get; set; }
|
||||||
public string Department { 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();
|
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>
|
<Project>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<_PublishTargetUrl>E:\Project_Class\WX_XCX\Wx_WxCheck_Prod\WxCheckMvc\bin\Release\net8.0\publish\</_PublishTargetUrl>
|
<_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 />
|
<LastFailureDetails />
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
</Project>
|
</Project>
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>admin-web</title>
|
<title>宝来威办公助手后台</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -6,9 +6,9 @@
|
|||||||
type="text"
|
type="text"
|
||||||
:icon="Menu"
|
:icon="Menu"
|
||||||
@click="toggleSidebar"
|
@click="toggleSidebar"
|
||||||
></el-button>
|
>菜单</el-button>
|
||||||
<div class="logo">
|
<div class="logo">
|
||||||
<h1>后台管理</h1>
|
<h1>宝来威办公助手后台</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -47,6 +47,7 @@ import { Menu, ArrowDown, SwitchButton } from '@element-plus/icons-vue'
|
|||||||
import { useAuthStore } from '../../store/auth'
|
import { useAuthStore } from '../../store/auth'
|
||||||
import ThemeSwitcher from '../ThemeSwitcher.vue'
|
import ThemeSwitcher from '../ThemeSwitcher.vue'
|
||||||
|
|
||||||
|
|
||||||
// 路由实例
|
// 路由实例
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
@@ -56,9 +57,6 @@ const authStore = useAuthStore()
|
|||||||
// 用户名
|
// 用户名
|
||||||
const username = computed(() => authStore.username)
|
const username = computed(() => authStore.username)
|
||||||
|
|
||||||
// 侧边栏显示状态(手机端)
|
|
||||||
const isSidebarOpen = ref(false)
|
|
||||||
|
|
||||||
// 响应式布局:判断是否为手机端
|
// 响应式布局:判断是否为手机端
|
||||||
const isMobile = ref(false)
|
const isMobile = ref(false)
|
||||||
|
|
||||||
@@ -78,11 +76,9 @@ onUnmounted(() => {
|
|||||||
window.removeEventListener('resize', handleResize)
|
window.removeEventListener('resize', handleResize)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 切换侧边栏显示(手机端)
|
// 打开侧边栏抽屉(手机端)
|
||||||
const toggleSidebar = () => {
|
const toggleSidebar = () => {
|
||||||
isSidebarOpen.value = !isSidebarOpen.value
|
emit('open-menu')
|
||||||
// 发送事件给父组件
|
|
||||||
emit('toggleSidebar', isSidebarOpen.value)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 登出处理
|
// 登出处理
|
||||||
@@ -92,10 +88,10 @@ const handleLogout = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 定义事件
|
// 定义事件
|
||||||
const emit = defineEmits(['toggleSidebar'])
|
const emit = defineEmits(['open-menu'])
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped lang="scss">
|
||||||
.header {
|
.header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -146,7 +142,7 @@ const emit = defineEmits(['toggleSidebar'])
|
|||||||
}
|
}
|
||||||
|
|
||||||
.logo h1 {
|
.logo h1 {
|
||||||
font-size: 18px;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.username {
|
.username {
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="layout-container">
|
<div class="layout-container">
|
||||||
<!-- 顶部导航栏 -->
|
<!-- 顶部导航栏 -->
|
||||||
<Header @toggle-sidebar="handleToggleSidebar" />
|
<Header @open-menu="handleOpenMenu" />
|
||||||
|
|
||||||
<!-- 侧边栏 -->
|
<!-- 侧边栏 -->
|
||||||
<Sidebar
|
<Sidebar v-model="isMenuOpen" />
|
||||||
:is-open="isSidebarOpen"
|
|
||||||
@close-sidebar="handleCloseSidebar"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- 主内容区域 -->
|
<!-- 主内容区域 -->
|
||||||
<main class="main-content" :class="{ 'main-content--open': isSidebarOpen }">
|
<main class="main-content">
|
||||||
<div class="content-wrapper">
|
<div class="content-wrapper">
|
||||||
<router-view />
|
<router-view />
|
||||||
</div>
|
</div>
|
||||||
@@ -24,20 +21,15 @@ import Header from './Header.vue'
|
|||||||
import Sidebar from './Sidebar.vue'
|
import Sidebar from './Sidebar.vue'
|
||||||
|
|
||||||
// 侧边栏显示状态(手机端)
|
// 侧边栏显示状态(手机端)
|
||||||
const isSidebarOpen = ref(false)
|
const isMenuOpen = ref(false)
|
||||||
|
|
||||||
// 处理侧边栏切换事件(来自Header组件)
|
// Header 触发:打开抽屉菜单(手机端)
|
||||||
const handleToggleSidebar = (isOpen) => {
|
const handleOpenMenu = () => {
|
||||||
isSidebarOpen.value = isOpen
|
isMenuOpen.value = true
|
||||||
}
|
|
||||||
|
|
||||||
// 处理侧边栏关闭事件(来自Sidebar组件)
|
|
||||||
const handleCloseSidebar = () => {
|
|
||||||
isSidebarOpen.value = false
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped lang="scss">
|
||||||
.layout-container {
|
.layout-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -64,13 +56,6 @@ const handleCloseSidebar = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 手机端侧边栏打开时,主内容区域添加左侧边距 */
|
|
||||||
.main-content--open {
|
|
||||||
@media (max-width: 767px) {
|
|
||||||
margin-left: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-wrapper {
|
.content-wrapper {
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
|
|||||||
@@ -1,105 +1,105 @@
|
|||||||
<template>
|
<template>
|
||||||
<aside
|
<!-- 桌面/平板:固定侧边栏(不做展开/收起动画) -->
|
||||||
class="sidebar"
|
<aside v-if="!isMobile" class="sidebar-desktop">
|
||||||
:class="{ 'sidebar--open': isOpen }"
|
<el-menu router :default-active="activePath" class="sidebar-menu" @select="handleSelect">
|
||||||
>
|
<el-menu-item index="/">
|
||||||
<nav class="sidebar-nav">
|
<el-icon><HomeFilled /></el-icon>
|
||||||
<ul class="nav-list">
|
<span>首页</span>
|
||||||
<li class="nav-item" v-for="menu in menuList" :key="menu.path">
|
</el-menu-item>
|
||||||
<router-link
|
<el-menu-item index="/conversations">
|
||||||
:to="menu.path"
|
<el-icon><Message /></el-icon>
|
||||||
class="nav-link"
|
<span>会话记录管理</span>
|
||||||
:class="{ 'is-active': $route.path === menu.path }"
|
</el-menu-item>
|
||||||
@click="handleMenuClick"
|
<el-menu-item index="/users">
|
||||||
>
|
<el-icon><User /></el-icon>
|
||||||
<el-icon class="nav-icon"><component :is="menu.icon" /></el-icon>
|
<span>用户管理</span>
|
||||||
<span class="nav-text">{{ menu.title }}</span>
|
</el-menu-item>
|
||||||
</router-link>
|
</el-menu>
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<!-- 遮罩层(手机端) -->
|
|
||||||
<div
|
|
||||||
v-if="isOpen && isMobile"
|
|
||||||
class="sidebar-mask"
|
|
||||||
@click="handleMaskClick"
|
|
||||||
></div>
|
|
||||||
</aside>
|
</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>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
import { HomeFilled, Message, User } from '@element-plus/icons-vue'
|
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({
|
const props = defineProps({
|
||||||
isOpen: {
|
modelValue: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false
|
default: false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 定义事件
|
const emit = defineEmits(['update:modelValue'])
|
||||||
const emit = defineEmits(['closeSidebar'])
|
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
// 响应式布局:判断是否为手机端
|
// 响应式布局:判断是否为手机端
|
||||||
const isMobile = ref(false)
|
const isMobile = ref(false)
|
||||||
|
|
||||||
// 监听窗口大小变化
|
|
||||||
const handleResize = () => {
|
const handleResize = () => {
|
||||||
isMobile.value = window.innerWidth < 768
|
isMobile.value = window.innerWidth < 768
|
||||||
// 如果不是手机端,关闭侧边栏
|
// 切回非手机端时,确保抽屉关闭
|
||||||
if (!isMobile.value) {
|
if (!isMobile.value && props.modelValue) {
|
||||||
emit('closeSidebar')
|
emit('update:modelValue', false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 初始化响应式状态
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
handleResize()
|
handleResize()
|
||||||
window.addEventListener('resize', handleResize)
|
window.addEventListener('resize', handleResize)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 清理事件监听
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('resize', handleResize)
|
window.removeEventListener('resize', handleResize)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 菜单点击处理(手机端)
|
const activePath = computed(() => route.path)
|
||||||
const handleMenuClick = () => {
|
|
||||||
if (isMobile.value) {
|
|
||||||
emit('closeSidebar')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 遮罩层点击处理(手机端)
|
// el-drawer v-model 适配
|
||||||
const handleMaskClick = () => {
|
const drawerOpen = computed({
|
||||||
emit('closeSidebar')
|
get() {
|
||||||
|
return props.modelValue
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
emit('update:modelValue', val)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSelect = () => {
|
||||||
|
// 手机端点击菜单项后自动关闭抽屉
|
||||||
|
if (isMobile.value) {
|
||||||
|
emit('update:modelValue', false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped lang="scss">
|
||||||
.sidebar {
|
.sidebar-desktop {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 64px;
|
top: 64px;
|
||||||
left: 0;
|
left: 0;
|
||||||
@@ -107,86 +107,20 @@ const handleMaskClick = () => {
|
|||||||
width: 250px;
|
width: 250px;
|
||||||
background-color: var(--background-color);
|
background-color: var(--background-color);
|
||||||
border-right: 1px solid var(--border-color);
|
border-right: 1px solid var(--border-color);
|
||||||
transition: all 0.3s ease;
|
|
||||||
z-index: 99;
|
z-index: 99;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|
||||||
/* 桌面端默认显示,手机端默认隐藏 */
|
@media (max-width: 1023px) {
|
||||||
@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 {
|
|
||||||
width: 200px;
|
width: 200px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-menu,
|
||||||
|
.drawer-menu {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-drawer__body) {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -2,14 +2,16 @@ import { defineStore } from 'pinia'
|
|||||||
|
|
||||||
export const useAuthStore = defineStore('auth', {
|
export const useAuthStore = defineStore('auth', {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
isLoggedIn: false,
|
isLoggedIn: localStorage.getItem('isLoggedIn') === 'true',
|
||||||
username: ''
|
username: localStorage.getItem('username') || ''
|
||||||
}),
|
}),
|
||||||
actions: {
|
actions: {
|
||||||
login(username, password) {
|
login(username, password) {
|
||||||
if (username === 'Admin' && password === 'Admin') {
|
if (username === 'Admin' && password === 'Admin') {
|
||||||
this.isLoggedIn = true
|
this.isLoggedIn = true
|
||||||
this.username = username
|
this.username = username
|
||||||
|
localStorage.setItem('isLoggedIn', 'true')
|
||||||
|
localStorage.setItem('username', username)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
@@ -17,6 +19,8 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
logout() {
|
logout() {
|
||||||
this.isLoggedIn = false
|
this.isLoggedIn = false
|
||||||
this.username = ''
|
this.username = ''
|
||||||
|
localStorage.removeItem('isLoggedIn')
|
||||||
|
localStorage.removeItem('username')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -13,9 +13,37 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<el-form :model="filterForm" inline label-width="80px" size="small">
|
<el-form :model="filterForm" inline label-width="80px" size="small">
|
||||||
<el-form-item label="时间范围">
|
<el-form-item label="快捷范围">
|
||||||
<el-date-picker v-model="filterForm.dateRange" type="daterange" range-separator="至" start-placeholder="开始日期"
|
<el-select v-model="filterForm.quickRange" placeholder="请选择" clearable style="width: 150px" @change="handleQuickRangeChange">
|
||||||
end-placeholder="结束日期" format="YYYY-MM-DD" value-format="YYYY-MM-DD" clearable></el-date-picker>
|
<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>
|
||||||
|
|
||||||
<el-form-item label="用户">
|
<el-form-item label="用户">
|
||||||
@@ -110,12 +138,51 @@ const emptyText = computed(() => loading.value ? '加载中...' : '暂无数据'
|
|||||||
|
|
||||||
// 筛选表单数据
|
// 筛选表单数据
|
||||||
const filterForm = reactive({
|
const filterForm = reactive({
|
||||||
dateRange: [],
|
quickRange: 'last7',
|
||||||
|
startDate: '',
|
||||||
|
endDate: '',
|
||||||
userKey: '',
|
userKey: '',
|
||||||
messageType: '',
|
messageType: '',
|
||||||
department: ''
|
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([])
|
const conversationList = ref([])
|
||||||
|
|
||||||
@@ -135,12 +202,13 @@ const handleQuery = () => {
|
|||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
// 重置表单
|
// 重置表单
|
||||||
Object.keys(filterForm).forEach(key => {
|
Object.keys(filterForm).forEach(key => {
|
||||||
if (key === 'dateRange') {
|
|
||||||
filterForm[key] = []
|
|
||||||
} else {
|
|
||||||
filterForm[key] = ''
|
filterForm[key] = ''
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 默认最近7天
|
||||||
|
filterForm.quickRange = 'last7'
|
||||||
|
applyQuickRange('last7')
|
||||||
|
|
||||||
currentPage.value = 1
|
currentPage.value = 1
|
||||||
fetchConversations()
|
fetchConversations()
|
||||||
}
|
}
|
||||||
@@ -196,8 +264,8 @@ const fetchConversations = async (isLoadMore = false) => {
|
|||||||
try {
|
try {
|
||||||
// 构建请求参数
|
// 构建请求参数
|
||||||
const params = {
|
const params = {
|
||||||
startTime: filterForm.dateRange[0] ? new Date(filterForm.dateRange[0]).getTime() : null,
|
startTime: filterForm.startDate ? `${filterForm.startDate}T00:00:00` : null,
|
||||||
endTime: filterForm.dateRange[1] ? new Date(filterForm.dateRange[1]).getTime() + 24 * 60 * 60 * 1000 - 1 : null,
|
endTime: filterForm.endDate ? `${filterForm.endDate}T23:59:59.999` : null,
|
||||||
userKey: filterForm.userKey,
|
userKey: filterForm.userKey,
|
||||||
messageType: filterForm.messageType ? Number(filterForm.messageType) : null,
|
messageType: filterForm.messageType ? Number(filterForm.messageType) : null,
|
||||||
department: filterForm.department,
|
department: filterForm.department,
|
||||||
@@ -272,6 +340,8 @@ const fetchUsers = async () => {
|
|||||||
|
|
||||||
// 页面初始化
|
// 页面初始化
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
// 默认最近7天
|
||||||
|
applyQuickRange('last7')
|
||||||
fetchUsers()
|
fetchUsers()
|
||||||
fetchConversations()
|
fetchConversations()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,13 +4,26 @@
|
|||||||
|
|
||||||
<!-- 系统统计卡片 -->
|
<!-- 系统统计卡片 -->
|
||||||
<div class="stats-container">
|
<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">
|
<el-card class="stat-card">
|
||||||
<div class="stat-content">
|
<div class="stat-content">
|
||||||
<div class="stat-icon user-icon">
|
<div class="stat-icon user-icon">
|
||||||
<el-icon><User /></el-icon>
|
<el-icon><User /></el-icon>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-info">
|
<div class="stat-info">
|
||||||
<h3>{{ userCount }}</h3>
|
<h3>{{ activeUserCount }}</h3>
|
||||||
<p>活跃用户</p>
|
<p>活跃用户</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -22,23 +35,12 @@
|
|||||||
<el-icon><Message /></el-icon>
|
<el-icon><Message /></el-icon>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-info">
|
<div class="stat-info">
|
||||||
<h3>{{ conversationCount }}</h3>
|
<h3>{{ totalConversationCount }}</h3>
|
||||||
<p>会话记录</p>
|
<p>会话记录</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
</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">
|
<el-card class="stat-card">
|
||||||
<div class="stat-content">
|
<div class="stat-content">
|
||||||
@@ -46,8 +48,8 @@
|
|||||||
<el-icon><Calendar /></el-icon>
|
<el-icon><Calendar /></el-icon>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-info">
|
<div class="stat-info">
|
||||||
<h3>{{ todayCount }}</h3>
|
<h3>{{ todayNewConversationCount }}</h3>
|
||||||
<p>今日新增</p>
|
<p>今日会话</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
@@ -95,51 +97,25 @@ const authStore = useAuthStore()
|
|||||||
const username = computed(() => authStore.username)
|
const username = computed(() => authStore.username)
|
||||||
|
|
||||||
// 统计数据
|
// 统计数据
|
||||||
const userCount = ref(0)
|
const activeUserCount = ref(0)
|
||||||
const conversationCount = ref(0)
|
const totalConversationCount = ref(0)
|
||||||
const departmentCount = ref(0)
|
const totalUserCount = ref(0)
|
||||||
const todayCount = ref(0)
|
const todayNewConversationCount = ref(0)
|
||||||
|
|
||||||
// 最近会话记录
|
// 最近会话记录
|
||||||
const recentConversations = ref([])
|
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 () => {
|
const fetchStats = async () => {
|
||||||
try {
|
try {
|
||||||
// 获取用户列表数据,用于统计
|
const res = await request.get('/Admin/QueryStats')
|
||||||
const usersData = await request.get('/Admin/QueryUsers')
|
const stats = res?.data ?? res
|
||||||
const users = Array.isArray(usersData) ? usersData : usersData.data || []
|
|
||||||
|
|
||||||
// 获取会话记录数据,用于统计
|
activeUserCount.value = stats?.activeUsers ?? 0
|
||||||
const conversationsData = await request.post('/Admin/QueryConversations', {})
|
totalConversationCount.value = stats?.totalConversations ?? 0
|
||||||
const conversations = Array.isArray(conversationsData) ? conversationsData : conversationsData.data || []
|
totalUserCount.value = stats?.totalUsers ?? 0
|
||||||
|
todayNewConversationCount.value = stats?.todayNewConversations ?? 0
|
||||||
// 计算统计数据
|
|
||||||
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
|
|
||||||
|
|
||||||
// 获取最近会话记录
|
// 获取最近会话记录
|
||||||
await fetchRecentConversations()
|
await fetchRecentConversations()
|
||||||
@@ -152,21 +128,28 @@ const fetchStats = async () => {
|
|||||||
// 获取最近会话记录
|
// 获取最近会话记录
|
||||||
const fetchRecentConversations = async () => {
|
const fetchRecentConversations = async () => {
|
||||||
try {
|
try {
|
||||||
// 调用API获取最近的5条会话记录
|
const params = {
|
||||||
const data = await request.post('/Admin/QueryConversations', {})
|
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 rawData = Array.isArray(data) ? data : data.data || []
|
||||||
|
|
||||||
// 转换数据格式
|
|
||||||
const convertedData = convertConversationData(rawData)
|
|
||||||
|
|
||||||
// 按时间排序(最新的记录在前面),并只显示最近5条
|
// 按时间排序(最新的记录在前面),并只显示最近5条
|
||||||
recentConversations.value = convertedData
|
recentConversations.value = rawData
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
return b.recordTimeUTCStamp - a.recordTimeUTCStamp
|
return b.recordTimeUTCStamp - a.recordTimeUTCStamp
|
||||||
})
|
})
|
||||||
.slice(0, 5)
|
.slice(0, 10)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取最近会话记录失败:', error)
|
console.error('获取最近会话记录失败:', error)
|
||||||
recentConversations.value = []
|
recentConversations.value = []
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive } from 'vue'
|
import { ref, reactive, onMounted, onUnmounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { useAuthStore } from '../store/auth'
|
import { useAuthStore } from '../store/auth'
|
||||||
@@ -45,7 +45,7 @@ const loading = ref(false)
|
|||||||
|
|
||||||
// 登录表单数据
|
// 登录表单数据
|
||||||
const loginForm = reactive({
|
const loginForm = reactive({
|
||||||
username: '',
|
username: 'Admin',
|
||||||
password: ''
|
password: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -65,6 +65,13 @@ const router = useRouter()
|
|||||||
// 认证状态管理
|
// 认证状态管理
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
// 处理键盘按键事件
|
||||||
|
const handleKeyPress = (event) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
handleLogin()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 登录处理
|
// 登录处理
|
||||||
const handleLogin = async () => {
|
const handleLogin = async () => {
|
||||||
// 表单验证
|
// 表单验证
|
||||||
@@ -92,6 +99,16 @@ const handleLogin = async () => {
|
|||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 组件挂载时添加键盘监听
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('keypress', handleKeyPress)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 组件卸载时移除键盘监听
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('keypress', handleKeyPress)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<h2>用户管理</h2>
|
<h2>用户管理</h2>
|
||||||
|
|
||||||
<!-- 筛选表单 -->
|
<!-- 筛选表单 -->
|
||||||
<el-card class="filter-card">
|
<!-- <el-card class="filter-card">
|
||||||
<el-form :model="filterForm" inline label-width="80px">
|
<el-form :model="filterForm" inline label-width="80px">
|
||||||
<el-form-item label="用户名">
|
<el-form-item label="用户名">
|
||||||
<el-input
|
<el-input
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
<el-button @click="handleReset">重置</el-button>
|
<el-button @click="handleReset">重置</el-button>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
</el-card>
|
</el-card> -->
|
||||||
|
|
||||||
<!-- 用户列表表格 -->
|
<!-- 用户列表表格 -->
|
||||||
<el-card class="table-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** 显示抽屉式菜单(点击菜单按钮展开)
|
- **THEN** 显示抽屉式菜单(点击菜单按钮展开)
|
||||||
- **AND** 菜单包含:首页、会话记录管理、用户管理等功能入口
|
- **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: 模块化设计
|
### Requirement: 模块化设计
|
||||||
前端管理网站 SHALL 采用模块化设计思路。
|
前端管理网站 SHALL 采用模块化设计思路。
|
||||||
|
|
||||||
@@ -173,6 +189,23 @@
|
|||||||
- **WHEN** 管理端提交包含多个筛选条件的查询请求
|
- **WHEN** 管理端提交包含多个筛选条件的查询请求
|
||||||
- **THEN** 系统返回同时满足所有条件的会话记录
|
- **THEN** 系统返回同时满足所有条件的会话记录
|
||||||
|
|
||||||
|
### Requirement: 会话记录筛选(时间范围,移动端友好)
|
||||||
|
|
||||||
|
管理端会话记录页面 SHALL 提供便于移动端使用的时间范围筛选控件:
|
||||||
|
- 使用两个独立日期选择器:开始日期、结束日期
|
||||||
|
- 提供快捷范围选择器:今天、最近3天、最近7天、最近30天
|
||||||
|
- 默认范围为最近7天
|
||||||
|
|
||||||
|
#### Scenario: 默认最近7天
|
||||||
|
- **WHEN** 管理员进入会话记录页面
|
||||||
|
- **THEN** 页面默认选中“最近7天”
|
||||||
|
- **AND** 查询请求携带对应的 `StartTime/EndTime`
|
||||||
|
|
||||||
|
#### Scenario: 移动端可用性
|
||||||
|
- **WHEN** 管理员在移动端访问会话记录页面
|
||||||
|
- **THEN** 快捷选择器与开始/结束日期选择器纵向排列
|
||||||
|
- **AND** 控件全宽显示,避免横向挤压
|
||||||
|
|
||||||
### Requirement: 查询用户列表(管理端)
|
### Requirement: 查询用户列表(管理端)
|
||||||
系统 SHALL 提供接口返回可用用户列表,用于管理侧选择/筛选。
|
系统 SHALL 提供接口返回可用用户列表,用于管理侧选择/筛选。
|
||||||
|
|
||||||
@@ -195,6 +228,25 @@
|
|||||||
- **THEN** 返回已完善基础信息且未禁用的用户
|
- **THEN** 返回已完善基础信息且未禁用的用户
|
||||||
- **AND** 结果按首次登录时间倒序排列
|
- **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
|
## Known Limitations
|
||||||
- 当前接口未提供分页与导出能力(若管理端数据量很大,需在后续能力中补齐)。
|
- 当前接口未提供分页与导出能力(若管理端数据量很大,需在后续能力中补齐)。
|
||||||
- 当前未强制鉴权(JWT 配置存在但未启用认证中间件/授权标注)。
|
- 当前未强制鉴权(JWT 配置存在但未启用认证中间件/授权标注)。
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
行为:
|
行为:
|
||||||
- 若 `xcx_users` 不存在该 `UserKey`,系统 SHALL 自动插入一条用户记录(资料可为空)。
|
- 若 `xcx_users` 不存在该 `UserKey`,系统 SHALL 自动插入一条用户记录(资料可为空)。
|
||||||
- 若用户 `IsDisabled = 1`,系统 SHALL 返回 `success: false`。
|
- 若用户 `IsDisabled = 1`,系统 SHALL 返回 `success: false`。
|
||||||
|
- 若 `xcx_users` 中已经存在该 `UserKey`,系统 SHALL 在登录流程中**更新该记录的 `UpdateTime` 为当前服务器时间**(用于“最近登录/活跃”判断)。
|
||||||
|
|
||||||
#### Scenario: 首次登录自动建档
|
#### Scenario: 首次登录自动建档
|
||||||
- **WHEN** 提交的 `code` 能换取有效 `openid`
|
- **WHEN** 提交的 `code` 能换取有效 `openid`
|
||||||
@@ -196,6 +197,23 @@
|
|||||||
- **WHEN** Stream 中无可读消息
|
- **WHEN** Stream 中无可读消息
|
||||||
- **THEN** 返回 `success: true` 且数据为空
|
- **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
|
## Known Limitations
|
||||||
- 当前 JWT 配置存在,但认证中间件与授权标注未启用;接口默认可匿名访问。
|
- 当前 JWT 配置存在,但认证中间件与授权标注未启用;接口默认可匿名访问。
|
||||||
- `MessageType` 的过滤条件存在实现差异:部分接口仅在 `MessageType == 1` 时追加过滤(不覆盖 2)。
|
- `MessageType` 的过滤条件存在实现差异:部分接口仅在 `MessageType == 1` 时追加过滤(不覆盖 2)。
|
||||||
|
|||||||
Reference in New Issue
Block a user