refactor: 首页界面优化

This commit is contained in:
Ray.Hao
2026-03-10 08:17:02 +08:00
parent 3de2d6a235
commit 7ce897744a
8 changed files with 334 additions and 115 deletions

View File

@@ -7,8 +7,7 @@ VITE_APP_PORT = 4096
VITE_APP_BASE_API = '/dev-api'
# API 服务器的 URL
# VITE_APP_API_URL = http://localhost:8000
VITE_APP_API_URL = https://api.youlai.tech
VITE_APP_API_URL = http://localhost:8000
# WebSocket 服务器的 URL不配置默认关闭 WebSocket
#VITE_APP_WS_ENDPOINT= ws://localhost:4096/ws
#VITE_APP_WS_ENDPOINT= ws://localhost:4096/ws

View File

@@ -2,3 +2,4 @@
# API 基础路径,开发环境下的请求前缀
VITE_APP_BASE_API = '/prod-api'
VITE_APP_API_URL = https://api.youlai.tech

5
src/config/index.ts Normal file
View File

@@ -0,0 +1,5 @@
/**
* 配置统一导出
*/
export * from "./menu";

View File

@@ -14,7 +14,7 @@ export interface WorkMenuGroup {
children: WorkMenuItem[];
}
export const workMenuConfig: WorkMenuGroup[] = [
export const menuConfig: WorkMenuGroup[] = [
{
title: "系统管理",
children: [

View File

@@ -31,59 +31,59 @@
<!-- 通知公告 -->
<view class="section">
<wd-notice-bar
:text="noticeText"
color="#34D19D"
type="info"
@click="handleNoticeClick"
>
<template #prefix>
<wd-tag color="#FAA21E" bg-color="#FAA21E" plain custom-style="margin-right: 10rpx">
通知公告
</wd-tag>
</template>
</wd-notice-bar>
<view class="notice-bar" @click="handleNoticeClick">
<view class="notice-bar__icon">
<wd-icon name="check-outline" size="32rpx" color="#34D19D" />
</view>
<view class="notice-bar__content">
<text class="notice-bar__text">{{ noticeText || "暂无通知公告" }}</text>
</view>
</view>
</view>
<!-- 数据统计 -->
<view class="section">
<wd-card>
<template #title>
<view class="overview-header">
<text class="overview-header__title">今日数据概览</text>
<view class="overview-header__more" @click="handleOverviewMore">查看更多</view>
<view class="stat-grid">
<view class="stat-card stat-card--green">
<view class="stat-card__bg">
<view class="stat-card__icon stat-card__icon--user"></view>
</view>
</template>
<view class="overview-grid">
<view class="overview-item">
<image class="overview-item__icon" src="/static/icons/visitor.png" />
<view class="overview-item__info">
<text class="overview-item__label">访客数</text>
<text class="overview-item__value">{{ visitStatsData.todayUvCount }}</text>
</view>
</view>
<view class="overview-item">
<image class="overview-item__icon" src="/static/icons/browser.png" />
<view class="overview-item__info">
<text class="overview-item__label">浏览量</text>
<text class="overview-item__value">{{ visitStatsData.todayPvCount }}</text>
</view>
</view>
<view class="overview-item" v-if="appVersion">
<image class="overview-item__icon" src="/static/icons/setting.png" />
<view class="overview-item__info">
<text class="overview-item__label">版本</text>
<text class="overview-item__value">{{ appVersion }}</text>
</view>
<view class="stat-card__head">
<text class="stat-card__label">访客数</text>
<view class="stat-card__dot stat-card__dot--green"></view>
</view>
<text class="stat-card__num stat-card__num--green">
{{ visitStatsData.todayUvCount }}
</text>
</view>
</wd-card>
<view class="stat-card stat-card--blue">
<view class="stat-card__bg">
<view class="stat-card__icon stat-card__icon--eye"></view>
</view>
<view class="stat-card__head">
<text class="stat-card__label">浏览量</text>
<view class="stat-card__dot stat-card__dot--blue"></view>
</view>
<text class="stat-card__num stat-card__num--blue">
{{ visitStatsData.todayPvCount }}
</text>
</view>
<view v-if="appVersion" class="stat-card stat-card--orange stat-card--full">
<view class="stat-card__bg">
<view class="stat-card__icon stat-card__icon--gear"></view>
</view>
<view class="stat-card__head">
<text class="stat-card__label">应用版本</text>
<view class="stat-card__dot stat-card__dot--orange"></view>
</view>
<text class="stat-card__num stat-card__num--orange">{{ appVersion }}</text>
</view>
</view>
</view>
<!-- 访问趋势图表 -->
<view class="section">
<wd-card>
<wd-card custom-class="chart-card">
<template #title>
<view class="chart-header">
<text class="chart-header__title">访问趋势</text>
@@ -130,7 +130,8 @@ import { onReady } from "@dcloudio/uni-app";
import { dayjs } from "wot-design-uni";
import { useRouter } from "uni-mini-router";
import { useUserStore } from "@/store";
import { workMenuConfig } from "@/constants/work-menu";
import { menuConfig } from "@/config/menu";
import { checkLogin, isLoggedIn } from "@/utils/auth";
import LogAPI, { type VisitStatsVO as ApiVisitStatsVO, type VisitTrendVO } from "@/api/log";
import NoticeAPI, { type NoticePageVO } from "@/api/notice";
@@ -190,16 +191,36 @@ const noticeText = computed(() => {
// 用户权限列表
const userPerms = computed(() => userStore.userInfo?.perms || []);
// 是否已登录
const isLogged = computed(() => isLoggedIn());
// 检查是否有权限
const hasPermission = (perm: string) => {
if (!perm) return true;
return userPerms.value.includes(perm) || userPerms.value.includes("*:*:*");
};
// 快捷入口:按权限过滤后取前 4 个
const quickNavList = computed(() => {
// 默认菜单(未登录时显示)
const defaultNavList = computed(() => {
const result: { icon: string; title: string; url: string; perm: string }[] = [];
for (const group of workMenuConfig) {
for (const group of menuConfig) {
for (const item of group.children) {
result.push(item);
if (result.length >= 4) {
return result;
}
}
}
return result;
});
// 快捷入口:已登录按权限过滤,未登录显示默认菜单
const quickNavList = computed(() => {
if (!isLogged.value) {
return defaultNavList.value;
}
const result: { icon: string; title: string; url: string; perm: string }[] = [];
for (const group of menuConfig) {
for (const item of group.children) {
if (hasPermission(item.perm)) {
result.push(item);
@@ -252,6 +273,11 @@ function loadAppVersion() {
}
async function loadNoticeData() {
// 未登录时不调用通知接口
if (!isLogged.value) {
noticeList.value = [];
return;
}
try {
const { list } = await NoticeAPI.getMyNoticePage({ pageNum: 1, pageSize: 2 });
noticeList.value = list || [];
@@ -293,6 +319,10 @@ async function loadVisitTrendData() {
// ============================================================================
function handleNavClick(item: NavItem) {
// 未登录时先检查登录状态并跳转登录页
if (!checkLogin()) {
return;
}
router.push({ path: item.url });
}
@@ -300,10 +330,6 @@ function handleNoticeClick() {
router.push({ path: "/pages/work/notice/index" });
}
function handleOverviewMore() {
router.push({ path: "/pages/work/log/index" });
}
function handleSwiperClick(e: any) {
console.log("Swiper click:", e);
}
@@ -435,8 +461,8 @@ onReady(() => {
.section--overlay {
position: relative;
z-index: 2;
margin-top: -72rpx;
padding: 16rpx 8rpx;
margin-top: -140rpx;
padding: 18rpx 8rpx;
background: var(--color-bg);
border-radius: 24rpx;
box-shadow: 0 16rpx 36rpx rgba(0, 0, 0, 0.08);
@@ -466,45 +492,202 @@ onReady(() => {
}
// ============================================================================
// 数据统计网格
// 通知栏
// ============================================================================
.stats-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 24rpx;
}
.stats-card {
.notice-bar {
display: flex;
align-items: center;
padding: 32rpx;
background-color: var(--color-bg);
border-radius: 24rpx;
background: #fff;
border-radius: 16rpx;
padding: 24rpx 24rpx 24rpx 20rpx;
border: 1rpx solid rgba(0, 0, 0, 0.04);
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.04);
&__icon {
width: 80rpx;
height: 80rpx;
border-radius: 16rpx;
flex-shrink: 0;
width: 48rpx;
height: 48rpx;
display: flex;
align-items: center;
justify-content: center;
background: rgba(52, 209, 157, 0.1);
border-radius: 12rpx;
margin-right: 16rpx;
}
&__info {
margin-left: 32rpx;
&__content {
flex: 1;
overflow: hidden;
}
&__text {
font-size: 26rpx;
color: #333;
font-weight: 500;
display: -webkit-box;
line-clamp: 1;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
}
// ============================================================================
// 统计卡片(简洁大气风格)
// ============================================================================
.stat-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16rpx;
}
.stat-card {
background: #fff;
border-radius: 16rpx;
padding: 24rpx 20rpx;
display: flex;
flex-direction: column;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.04);
border: 1rpx solid rgba(0, 0, 0, 0.04);
position: relative;
overflow: hidden;
// 背景图标容器
&__bg {
position: absolute;
right: 12rpx;
bottom: 12rpx;
width: 70rpx;
height: 70rpx;
opacity: 0.08;
pointer-events: none;
}
// SVG 装饰图形
&__icon {
width: 100%;
height: 100%;
// 用户组图标 - 访客数
&--user {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48' fill='%232ab789'%3E%3Ccircle cx='24' cy='16' r='8'/%3E%3Ccircle cx='10' cy='20' r='6'/%3E%3Ccircle cx='38' cy='20' r='6'/%3E%3Cpath d='M24 28c-8 0-14 4-14 8v6h28v-6c0-4-6-8-14-8z'/%3E%3Cpath d='M10 28c-4 0-8 2-8 5v5h8v-5c0-2 1-4 3-5-1 0-2 0-3 0z'/%3E%3Cpath d='M38 28c4 0 8 2 8 5v5h-8v-5c0-2-1-4-3-5 1 0 2 0 3 0z'/%3E%3C/svg%3E");
background-size: contain;
background-repeat: no-repeat;
}
// 图表图标 - 浏览量
&--eye {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48' fill='%233a8ee6'%3E%3Crect x='4' y='24' width='8' height='20' rx='2'/%3E%3Crect x='16' y='14' width='8' height='30' rx='2'/%3E%3Crect x='28' y='20' width='8' height='24' rx='2'/%3E%3Crect x='40' y='8' width='8' height='36' rx='2'/%3E%3C/svg%3E");
background-size: contain;
background-repeat: no-repeat;
}
// 盾牌徽章图标 - 版本
&--gear {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48' fill='%23e8a838'%3E%3Cpath d='M24 4L8 10v12c0 11 8 18 16 22 8-4 16-11 16-22V10L24 4z'/%3E%3Cpath d='M20 24l4 4 8-8' stroke='white' stroke-width='3' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-size: contain;
background-repeat: no-repeat;
}
}
// 彩色卡片变体
&--green {
background: linear-gradient(135deg, #f0fdf9 0%, #fff 100%);
}
&--blue {
background: linear-gradient(135deg, #f0f7ff 0%, #fff 100%);
}
&--orange {
background: linear-gradient(135deg, #fffbf0 0%, #fff 100%);
}
&__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16rpx;
position: relative;
z-index: 1;
}
&__label {
display: block;
font-size: 26rpx;
font-size: 24rpx;
color: #999;
font-weight: 500;
color: var(--color-text);
}
&__value {
display: block;
margin-top: 8rpx;
font-size: 36rpx;
font-weight: 600;
color: var(--color-text);
&__dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
&--green {
background: #34d19d;
box-shadow: 0 0 10rpx rgba(52, 209, 157, 0.4);
}
&--blue {
background: #409eff;
box-shadow: 0 0 10rpx rgba(64, 158, 255, 0.4);
}
&--orange {
background: #faa21e;
box-shadow: 0 0 12rpx rgba(250, 162, 30, 0.4);
}
}
&__num {
font-size: 48rpx;
font-weight: 700;
line-height: 1;
letter-spacing: -1rpx;
position: relative;
z-index: 1;
&--green {
color: #2ab789;
text-shadow: 0 4rpx 12rpx rgba(42, 183, 137, 0.3);
}
&--blue {
color: #3a8ee6;
text-shadow: 0 4rpx 12rpx rgba(58, 142, 230, 0.3);
}
&--orange {
color: #e8a838;
text-shadow: 0 4rpx 12rpx rgba(232, 168, 56, 0.3);
}
}
&--full {
grid-column: 1 / -1;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 20rpx 20rpx;
.stat-card__head {
margin-bottom: 0;
}
.stat-card__num {
font-size: 28rpx;
letter-spacing: 0;
}
.stat-card__bg {
width: 50rpx;
height: 50rpx;
right: 16rpx;
bottom: 50%;
transform: translateY(50%);
}
}
}
@@ -529,4 +712,17 @@ onReady(() => {
height: 300px;
margin-bottom: 40rpx;
}
:deep(.chart-card) {
margin: 0 !important;
}
:deep(.chart-card .wd-card) {
margin: 0 !important;
border-radius: 16rpx;
}
:deep(.chart-card .wd-card__body) {
padding: 24rpx !important;
}
</style>

View File

@@ -23,7 +23,7 @@
<script lang="ts" setup>
import { useRouter } from "uni-mini-router";
import { useUserStore } from "@/store";
import { workMenuConfig } from "@/constants/work-menu";
import { menuConfig } from "@/config/menu";
const router = useRouter();
const userStore = useUserStore();
@@ -39,7 +39,7 @@ const hasPermission = (perm: string) => {
// 根据权限过滤后的菜单列表
const visibleGridList = computed(() => {
return workMenuConfig
return menuConfig
.map((group) => ({
...group,
children: group.children.filter((item) => hasPermission(item.perm)),

View File

@@ -10,6 +10,7 @@ declare global {
const EffectScope: typeof import('vue')['EffectScope']
const PERM_ALL: typeof import('../utils/permission')['PERM_ALL']
const ROLE_ROOT: typeof import('../utils/permission')['ROLE_ROOT']
const RequestError: typeof import('../utils/request')['RequestError']
const Storage: typeof import('../utils/storage')['Storage']
const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
const applyThemeOnPageShow: typeof import('../utils/theme')['applyThemeOnPageShow']
@@ -204,6 +205,9 @@ declare global {
// @ts-ignore
export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
// @ts-ignore
export type { RequestError } from '../utils/request'
import('../utils/request')
}
// for vue template auto import
@@ -213,6 +217,7 @@ declare module 'vue' {
interface ComponentCustomProperties {
readonly CommonUtil: UnwrapRef<typeof import('wot-design-uni')['CommonUtil']>
readonly EffectScope: UnwrapRef<typeof import('vue')['EffectScope']>
readonly RequestError: UnwrapRef<typeof import('../utils/request')['RequestError']>
readonly Storage: UnwrapRef<typeof import('../utils/storage')['Storage']>
readonly acceptHMRUpdate: UnwrapRef<typeof import('pinia')['acceptHMRUpdate']>
readonly auth: UnwrapRef<typeof import('../api/auth')['default']>

View File

@@ -1,4 +1,21 @@
import { getAccessToken } from "./auth";
import { getAccessToken, clearTokens } from "./auth";
/**
* 请求错误类
*/
export class RequestError extends Error {
/** HTTP 状态码 */
statusCode: number;
/** 业务错误码 */
code: number;
constructor(message: string, statusCode: number, code?: number) {
super(message);
this.name = "RequestError";
this.statusCode = statusCode;
this.code = code ?? statusCode;
}
}
// 请求配置
interface RequestOptions<T = any> {
@@ -8,27 +25,25 @@ interface RequestOptions<T = any> {
header?: Record<string, string>;
timeout?: number;
responseType?: "text" | "arraybuffer";
skipAuth?: boolean; // 标记是否跳过认证
/** 是否跳过错误提示(如 Toast */
skipErrorToast?: boolean;
}
// 请求函数
/**
* 请求函数
* - 有 token 则自动添加 Authorization 头
* - 无 token 则直接发送请求,由后端判断是否需要认证
* - 401 时清除 token 并跳转登录页
*/
function request<T = any>(options: RequestOptions): Promise<T> {
return new Promise<T>((resolve, reject) => {
// 构建请求头
const header = Object.assign({}, options.header || {});
// 检查是否需要添加认证令牌
if (!options.skipAuth) {
const token = getAccessToken();
if (token) {
header["Authorization"] = `Bearer ${token}`;
} else {
// 需要认证但没有令牌,跳转到登录页
uni.navigateTo({
url: "/pages/login/index",
});
return reject(new Error("请先登录"));
}
// 有 token 则添加认证头,无 token 则不添加(由后端判断)
const token = getAccessToken();
if (token) {
header["Authorization"] = `Bearer ${token}`;
}
// 根据平台决定URL前缀
@@ -57,38 +72,36 @@ function request<T = any>(options: RequestOptions): Promise<T> {
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve(res.data.data);
}
// 未授权错误
// 未授权错误:清除 token 并跳转登录页
else if (res.statusCode === 401) {
// 如果需要认证且未授权,跳转到登录页
if (!options.skipAuth) {
uni.navigateTo({
url: "/pages/login/index",
});
}
reject(new Error(serverMsg || "未授权,请重新登录"));
clearTokens();
uni.navigateTo({
url: "/pages/login/index",
});
reject(new RequestError(serverMsg || "未授权,请重新登录", 401));
}
// 其他错误
else {
const errorMsg = serverMsg || `请求失败: ${res.statusCode}`;
reject(new Error(errorMsg));
const errorCode = res?.data?.code || res.statusCode;
reject(
new RequestError(serverMsg || `请求失败: ${res.statusCode}`, res.statusCode, errorCode)
);
}
},
fail: (err) => {
reject(new Error(err.errMsg || "网络请求失败"));
reject(new RequestError(err.errMsg || "网络请求失败", 0));
},
});
});
}
/**
* 无需认证的请求
* @param options 请求配置
* 无需认证的请求(兼容旧调用)
*
* 说明:当前 request 实现不会在“无 token”时阻断请求认证与否由后端决定。
*/
export function publicRequest<T = any>(options: RequestOptions): Promise<T> {
return request<T>({
...options,
skipAuth: true,
});
return request<T>(options);
}
export default request;