fix:联调评论接口、新增流程管理列表模块页面

This commit is contained in:
liangdong
2026-01-08 18:34:05 +08:00
parent cbdc1231ce
commit 6d93092f10
24 changed files with 824 additions and 645 deletions

1
components.d.ts vendored
View File

@@ -79,6 +79,7 @@ declare module 'vue' {
StageBreadcrumbs: typeof import('./src/components/stageBreadcrumbs/index.vue')['default']
StandardMenu: typeof import('./src/components/standardMenu/index.vue')['default']
StandMenu: typeof import('./src/components/standMenu/index.vue')['default']
UserSelector: typeof import('./src/components/userSelector/index.vue')['default']
Xxx: typeof import('./src/components/comment/xxx.vue')['default']
Xxxx: typeof import('./src/components/xxxx/index.vue')['default']
}

View File

@@ -21,16 +21,16 @@ interface addCommentProps {
// 获取评论
export const getComment = (params: commentProps) => {
return request.get('/comment/getComment', { params });
export const getComment = (data: commentProps) => {
return request.post('/communicate/v1/comment/list', data);
}
// 添加评论-回复评论
export const addReplyComment = (data: addCommentProps) => {
return request.post('/comment/addComment', data)
return request.post('/communicate/v1/comment/add', data)
};
// 删除评论
export const deleteComment = (id: string) => {
return request.delete(`/comment/deleteComment/${id}`);
return request.delete(`/communicate/v1/comment/del/${id}`);
}

View File

@@ -1,128 +1,89 @@
<template>
<div class="mj-card-container mj-grid-container">
<div
class="mj-card-container"
:style="containerStyle"
class="mj-card-item"
v-for="(card, index) in list"
:key="index"
@click="cardItemClick(card, index)"
>
<div
class="mj-card-standard"
v-for="(card,carIndex) in list"
:key="carIndex"
@click="cardItemClick(card, carIndex)"
>
<slot name="cardTip">
<slot name="cardCover" :item="card">
<div class="mj-card-standard-tip" :style="standardTopStyle"></div>
</slot>
<div class="mj-card-standard-content">
<slot name="content"></slot>
<slot name="content" :item="card" :index="index"></slot>
</div>
<div v-if="$slots.actions" class="mj-card-actions">
<slot name="actions" :item="card"></slot>
</div>
</div>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: 'CardItem' });
const { list = [], maxColumns = 4, standardTopStyle } = defineProps<{
list: any[]
maxColumns?: number // 每行最大展示数量,默认 4
standardTopStyle?: string | Record<string, any> // 外部传入的顶部样式
}>()
defineOptions({ name: "CardItem" });
interface Props {
list: any[];
standardTopStyle?: string | Record<string, any>;
}
const props = withDefaults(defineProps<Props>(), {
list: () => [],
standardTopStyle: "",
});
// 向外抛出的事件
const emits = defineEmits<{
/**
* 卡片点击事件
* @param e 事件名
* @param payload 点击的卡片数据和索引
*/
(e: 'card-click', payload: { item: any; index: number }): void
}>()
(e: "card-click", payload: { item: any; index: number }): void;
}>();
// 根据屏幕宽度和最大列数计算实际列数
const screenWidth = ref(window.innerWidth)
const containerStyle = computed(() => {
let cols = maxColumns
// 响应式调整
if (screenWidth.value <= 640) {
cols = 1
} else if (screenWidth.value <= 900) {
cols = Math.min(maxColumns, 2)
} else if (screenWidth.value <= 1200) {
cols = Math.min(maxColumns, 3)
}
return {
gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`
}
})
// 卡片点击事件,向外抛出当前卡片数据和索引
const cardItemClick = (item: any, index: number) => {
emits('card-click', { item, index })
}
// 监听窗口大小变化
const handleResize = () => {
screenWidth.value = window.innerWidth
}
onMounted(() => {
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
})
emits("card-click", { item, index });
};
</script>
<style lang="scss" scoped>
.mj-card-container {
display: grid;
gap: 16px;
width: 100%;
.mj-card-standard {
--radius: 16px;
--padding-standard: 20px;
--border-color: #e5e7eb;
--background-color: #fff;
--tw-shadow: 0 8px 12px #0000001a, 0 0px 12px #0000001a;
background-color: var(--background-color);
min-height: 140px;
width: 100%;
box-sizing: border-box;
<style lang="scss" scoped>
.mj-grid-container {
// 卡片主体样式
.mj-card-item {
--radius: 12px;
--primary-color: #409eff;
--border-color: #e4e7ed;
display: flex;
flex-direction: column;
background-color: #fff;
border-radius: var(--radius);
border: 1px solid var(--border-color);
transition: box-shadow 0.2s ease;
overflow: hidden;
cursor: pointer;
// 丝滑动画过渡
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&:hover {
box-shadow: var(--tw-shadow);
box-shadow: 0 12px 24px rgba(64, 158, 255, 0.12);
}
.mj-card-standard-tip {
--height: 8px;
height: var(--height);
border-top-left-radius: inherit;
border-top-right-radius: inherit;
background-color: red;
height: 8px;
background-color: var(--primary-color);
}
.mj-card-standard-content {
padding: var(--padding-standard);
padding: 16px;
flex: 1;
}
}
}
// 平板:调整间距
@media (max-width: 900px) {
.mj-card-container {
gap: 14px;
.mj-card-actions {
padding: 12px 16px;
border-top: 1px solid #f0f0f0;
}
}
// 移动端:调整间距
@media (max-width: 640px) {
.mj-card-container {
gap: 12px;
// 封面图内部图片的平滑过渡
:deep(img) {
transition: transform 0.5s ease;
}
}
}
</style>

View File

@@ -11,7 +11,7 @@
v-for="(item, index) in visibleItems"
:key="item[itemMap.id]"
class="tab-item"
:class="{ active: modelValue === item[itemMap.id] }"
:class="{ 'active': modelValue === item[itemMap.id] }"
@click="$emit('update:modelValue', item[itemMap.id])"
>
<span class="tab-text">{{ item[itemMap.label] }}</span>
@@ -106,8 +106,6 @@ const calculateLayout = () => {
for (let i = 0; i < itemWidths.value.length; i++) {
const w = itemWidths.value[i];
// 加上 20px 的 padding 补偿 (对应你 CSS 里的 padding: 0 20px)
// 最好在 measureWidths 阶段就包含 padding或者在这里统一加
const fullWidth = w;
if (currentWidth + fullWidth > containerWidth) {
@@ -164,8 +162,6 @@ onMounted(async () => {
updateActiveBar();
resizeObserver = new ResizeObserver(() => {
// 尽量不要在 Resize 里用太长的 debounce
// 否则你会感觉 Tab 是“跳”出来的,而不是“滑”出来的
handleResize();
});
@@ -190,6 +186,9 @@ watch(() => props.items, async () => {
<style scoped lang="scss">
.tabs-outer-container {
--more-left-line:#f0f2f5;
--item-color:#9DA1B9;
--item-size:12px;
width: 100%;
position: relative;
@@ -209,7 +208,7 @@ watch(() => props.items, async () => {
height: 100%;
position: relative;
user-select: none;
width: 100%; // 确保占满父级
width: 100%;
}
.tab-item {
@@ -218,38 +217,37 @@ watch(() => props.items, async () => {
align-items: center;
padding: 0 20px;
cursor: pointer;
color: #606266;
font-size: 14px;
color: var(--item-color);
font-size: var(--item-size);
white-space: nowrap;
flex-shrink: 0;
&.active {
color: #409eff;
font-weight: 600;
color: var(--el-color-primary);
}
&.is-more-active {
color: #409eff;
color: var(--el-color-primary);
}
}
.more-trigger {
margin-left: auto;
border-left: 1px solid #f0f2f5;
border-left: 1px solid var( --more-left-line);
}
.active-bar {
position: absolute;
height: 2px;
background-color: #409eff;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
bottom: 0;
background-color: var(--el-color-primary);
transition: all 0.12s cubic-bezier(0.4, 0, 0.2, 1);
bottom: -1px;
pointer-events: none;
}
}
.is-active-item-overflow-tabs {
color: #409eff;
color: var(--el-color-primary);
font-weight: 600;
}
</style>

View File

@@ -1,5 +1,5 @@
<template>
<div class="stage-breadcrumbs">
<div class="stage-breadcrumbs" :class="styleClass">
<div class="mj-panel-title">{{ title }}</div>
<div class="stage-breadcrumbs-content">
<slot name="content"></slot>
@@ -12,13 +12,14 @@
<script setup lang="ts">
defineOptions({ name: "StageBreadcrumbs" });
const { title } = defineProps<{
const { title,styleClass } = defineProps<{
title: string;
styleClass: string;
}>();
</script>
<style lang="scss" scoped>
.stage-breadcrumbs{
padding: $mj-padding-standard 0;
padding: 0 0 $mj-padding-standard 0;
align-items: center;
background-color: transparent;
border-bottom: 1px solid #E2E8F099;
@@ -48,4 +49,8 @@ const { title } = defineProps<{
}
.stage-breadcrumbs-list{
padding: 0;
}
</style>

View File

@@ -0,0 +1,17 @@
<template>
<div class="user-selector-modules">
<name-avatar :name="name" :src="avatar" :size="size" style="margin-right: 12px" />
<span>{{ userAvatarName }}</span>
</div>
</template>
<script setup lang="ts">
import nameAvatar from "../nameAvatar/index.vue";
defineOptions({ name: "UserSelector" });
const props = defineProps({
userAvatarName: { type: String, default: "" },
avatar: { type: String, default: "" },
size: { type: Number, default: 24 },
name: { type: String, default: "" },
});
</script>
<style lang="scss" scoped></style>

View File

@@ -1,90 +0,0 @@
// useTokenRefresh.ts
import { ref } from "vue";
import axios from "axios";
interface TokenData {
accessToken: string; // token内容
refreshToken: string; // 刷新的token内容
expiresAt?: number; // 过期时间戳
}
export default function useTokenRefresh(baseUrl: string) {
let refreshPromise: Promise<TokenData> | null = null;
let lastRefreshError: any = null;
// 当前Token数据使用localStorage持久化 可根据实际情况改成localStorage存储
const tokenData = ref<TokenData>({
accessToken: localStorage.getItem("accessToken") || "",
refreshToken: localStorage.getItem("refreshToken") || "",
expiresAt: Number(localStorage.getItem("expiresAt")) || 0,
});
// 核心刷新方法
const refreshToken = async (): Promise<TokenData> => {
if (refreshPromise) return refreshPromise;
// 如果上次刷新失败,短时间内不重复刷新,直接抛错
if (lastRefreshError) {
return Promise.reject(lastRefreshError);
}
const plainClient = axios.create({
baseURL: baseUrl,
timeout: 5 * 1000,
headers: {
"Content-Type": "application/json;charset=utf-8",
},
});
refreshPromise = (async () => {
try {
const refreshToken = getRefreshToken();
if (!refreshToken) throw new Error("refreshToken is null");
const res = await plainClient.post("/auth/refresh", {
refreshToken,
});
const newTokenData: TokenData = {
accessToken: res.data.accessToken,
refreshToken: res.data.refreshToken,
expiresAt: Date.now() + res.data.expiresIn * 1000,
};
setTokens(newTokenData);
lastRefreshError = null; // 成功清除错误
return newTokenData;
} catch (error) {
// 刷新失败处理
lastRefreshError = error;
clearTokens();
throw error;
} finally {
refreshPromise = null;
}
})();
return refreshPromise;
};
// 保存token
const setTokens = (data: TokenData) => {
tokenData.value = data;
localStorage.setItem("accessToken", data.accessToken);
localStorage.setItem("refreshToken", data.refreshToken);
localStorage.setItem("expiresAt", data.expiresAt.toString());
};
// 清理Token
const clearTokens = () => {
localStorage.removeItem("accessToken");
localStorage.removeItem("refreshToken");
localStorage.removeItem("expiresAt");
tokenData.value = { accessToken: "", refreshToken: "", expiresAt: 0 };
};
// 获取当前Token
const getAccessToken = () => tokenData.value.accessToken;
// 获取refreshToken
const getRefreshToken = () => tokenData.value.refreshToken;
return {
refreshToken,
getRefreshToken,
setTokens,
getAccessToken,
clearTokens,
};
}

View File

@@ -1,52 +0,0 @@
/**
* 登录相关 Mock 数据
*/
/**
* Mock 登录响应
*/
export const getMockLoginResponse = (username: string, password: string) => {
// 模拟登录验证
if (username && password) {
return {
code: 0,
data: {
accessToken: 'mock_access_token_' + Date.now(),
refreshToken: 'mock_refresh_token_' + Date.now(),
expiresIn: 7200, // 2小时
userInfo: {
id: 1,
username: username,
name: username === 'admin' ? '管理员' : '普通用户',
role: username === 'admin' ? 'admin' : 'user',
avatar: '',
},
},
msg: '登录成功',
}
} else {
return {
code: 400,
data: null,
msg: '用户名或密码不能为空',
}
}
}
/**
* Mock 用户信息响应
*/
export const getMockUserInfoResponse = () => {
return {
code: 0,
data: {
id: 1,
username: 'admin',
name: '管理员',
role: 'admin',
avatar: '',
},
msg: '获取用户信息成功',
}
}

View File

@@ -1,97 +0,0 @@
/**
* Mock 数据管理
* 用于开发阶段模拟后端接口返回的数据
*/
import { getMockMenuResponse } from './menu'
import { getMockLoginResponse, getMockUserInfoResponse } from './auth'
// 是否启用 Mock可以通过环境变量控制
export const ENABLE_MOCK = import.meta.env.VITE_USE_MOCK === 'true' || import.meta.env.DEV
/**
* Mock API 响应映射
* key: API 路径
* value: 返回的 Mock 数据函数
*/
const mockApiMap: Record<string, (params?: any, data?: any) => any> = {
'/api/menus': () => getMockMenuResponse(),
'/api/auth/login': (_params?: any, data?: any) => {
return getMockLoginResponse(data?.username || '', data?.password || '')
},
'/api/user/info': () => getMockUserInfoResponse(),
// 可以在这里添加更多的 mock 接口
// '/api/user/list': getMockUserList,
}
/**
* 获取 Mock 数据
* @param url API 路径
* @param params 请求参数GET
* @param data 请求体POST/PUT
* @returns Mock 数据或 null
*/
export const getMockData = (url: string, params?: any, data?: any): any => {
if (!ENABLE_MOCK || !url) {
return null
}
// 精确匹配
if (mockApiMap[url]) {
return mockApiMap[url](params, data)
}
// 模糊匹配(支持带查询参数的 URL
const urlWithoutQuery = url.split('?')[0]
if (urlWithoutQuery && mockApiMap[urlWithoutQuery]) {
return mockApiMap[urlWithoutQuery](params, data)
}
return null
}
/**
* 检查是否应该使用 Mock 数据
* @param url API 路径
* @returns 是否应该使用 Mock
*/
export const shouldUseMock = (url: string): boolean => {
if (!ENABLE_MOCK || !url) {
return false
}
// 精确匹配
if (mockApiMap[url]) {
return true
}
// 模糊匹配(支持带查询参数的 URL
const urlWithoutQuery = url.split('?')[0]
return !!(urlWithoutQuery && mockApiMap[urlWithoutQuery])
}
/**
* 添加 Mock 接口
* @param url API 路径
* @param mockFn Mock 数据函数
*/
export const addMockApi = (url: string, mockFn: (params?: any, data?: any) => any) => {
mockApiMap[url] = mockFn
}
/**
* 移除 Mock 接口
* @param url API 路径
*/
export const removeMockApi = (url: string) => {
delete mockApiMap[url]
}
export default {
getMockData,
shouldUseMock,
addMockApi,
removeMockApi,
ENABLE_MOCK,
}

View File

@@ -296,6 +296,45 @@ export const mockMenuData: MockMenuRoute[] = [
},
];
export const mockBackendMenuData = [
{
"name": "字典管理",
"code": "dict",
"icon": "OfficeBuilding",
"metadata": null,
"children": null
},
{
"name": "组织管理",
"code": "origanization",
"icon": "OfficeBuilding",
"metadata": null,
"children": null
},
{
"name": "人员管理",
"code": "personnel",
"icon": "OfficeBuilding",
"metadata": null,
"children": null
},
{
"name": "权限管理",
"code": "permission",
"icon": "OfficeBuilding",
"metadata": null,
"children": null
},
{
"name": "流程管理",
"code": "flow",
"icon": "OfficeBuilding",
"metadata": null,
"children": null
}
]
/**
* 获取 Mock 菜单数据的响应格式
* 模拟后端接口返回的数据结构

View File

@@ -189,7 +189,6 @@ $color-white: #fff;
.sub-list-controls {
display: flex;
align-items: center;
margin-top: 12px;
padding-left: 46px; // 与头像对齐的偏移量
.expand-line {
@@ -230,6 +229,12 @@ $color-white: #fff;
background-color: rgba(64, 158, 255, 0.2);
}
}
.observer-anchor{
text-align: center;
font-size: 12px;
color: #808080;
}
}
// 评论组件骨架屏

View File

@@ -51,14 +51,14 @@
<!-- 这个地方需要添加 查看更多-收起 -->
<div class="parent-node">
<name-avatar
:name="item.employee.name"
:name="item.employee.username"
:src="item.employee.avatar"
:size="36"
/>
<div class="node-main">
<!-- 当前用户信息展示 -->
<div class="user-info">
<span class="nickname">{{ item.employee.name }}</span>
<span class="nickname">{{ item.employee.username }}</span>
<span class="createTime">{{
formatTime(item.createTime)
}}</span>
@@ -78,30 +78,25 @@
link
class="delete-btn"
@click="deleteMainComment(item)"
v-if="currentUser.id === item?.employee.id"
v-if="currentUser.id === item?.employee.userId"
>
<el-icon><Delete /></el-icon>&nbsp;删除
</el-button>
</div>
<!-- 回复内容展示二级-子集评论内容 -->
<!-- 回复内容展示二级-子集评论内容) 通过id进行关联加载二级 -->
<div
v-if="item.children?.length || item.localReplies?.length"
v-if="item.childrenCount || item.localReplies?.length"
class="sub-container"
>
<!-- 临时数据 -->
<div
v-for="replies in [
...(item.localReplies || []),
...(item.showAllReplies
? item.children
: item.children.slice(0, 1)),
]"
v-for="replies in item.children"
:key="replies.id"
class="sub-node"
>
<name-avatar
:name="replies.employee.name"
:name="replies.employee.username"
:src="replies.employee.avatar"
:size="36"
/>
@@ -109,13 +104,12 @@
<div class="sub-header">
<div class="sub-user-info">
<span class="nickname">{{
replies.employee.name
replies.employee.username
}}</span>
<!-- TODO:这个地方判断 不是c-b这种回复就不展示 -->
<template v-if="replies.reply">
<template v-if="replies.replyId && replies.replyId !== replies.employee.userId">
<span class="reply-text">回复</span>
<span class="target-name"
>@{{ replies.reply.name }}</span
>@{{ replies.replyUser.username }}</span
>
</template>
</div>
@@ -136,7 +130,7 @@
<el-button
link
class="delete-btn"
v-if="currentUser.id === replies?.employee.id"
v-if="currentUser.id === replies?.employee.userId"
@click="deleteReply(replies, item)"
>
删除
@@ -152,7 +146,8 @@
<el-button
v-if="!item.showAllReplies"
link
@click="item.showAllReplies = true"
:loading="item.loading"
@click="handleExpand(item)"
>
展开 {{ item.childrenCount }} 条回复
<el-icon><ArrowDown /></el-icon>
@@ -162,7 +157,7 @@
v-else-if="item.children.length < item.childrenCount"
link
:loading="item.loading"
@click="loadReplies(item)"
@click="loadMoreReplies(item)"
>
更多
{{ item.childrenCount - item.children.length }} 条回复
@@ -173,8 +168,7 @@
v-if="item.showAllReplies"
link
@click="collapseReplies(item)"
>
收起 <el-icon><ArrowUp /></el-icon>
>收起 <el-icon><ArrowUp /></el-icon>
</el-button>
</div>
</div>
@@ -182,6 +176,11 @@
</div>
</div>
</template>
<!-- 滚动加载元素 -->
<div ref="loadMoreAnchor" class="observer-anchor">
<el-icon v-if="infinityLoading" class="is-loading" size="20"><Loading/></el-icon>
<span v-else-if="noMore">没有更多评论了</span>
</div>
</el-scrollbar>
</div>
@@ -219,6 +218,7 @@
<name-avatar
:name="item.name"
:size="24"
:src="item.avatar"
style="margin-right: 10px"
/>
<span class="mention-name">{{ item.name }}</span>
@@ -264,6 +264,11 @@ import { useUserStore } from "@/store";
import { useRelativeTime } from "@/hooks/useRelativeTime";
import { useUserSearch } from "./useUserSearch";
import { parseMention } from "./utils";
import {
getComment,
addReplyComment,
deleteComment,
} from "@/api/modules/Comment";
const { formatTime } = useRelativeTime();
const userStore = useUserStore();
const { t } = useI18n();
@@ -279,7 +284,7 @@ const props = defineProps({
queryParams: {
//外部传入的请求参数-获取评论
type: Object,
default: () => ({}),
default: () => ({ instanceId: 1, moduleId: 1 }),
},
});
const {
@@ -293,88 +298,22 @@ const {
} = useUserSearch((keyword, signal) => handleFetchSearch(keyword, signal));
// 评论业务逻辑
const activeReply = reactive({ parentId: null, targetName: "" }); // 点击reply回复的数据信息
const activeReply = reactive({
replyUserId: "",
parentId: null,
targetName: "",
}); // 点击reply回复的数据信息
const mainInput = ref("");
const loading = ref(true); //当前骨架屏显示
const expandingCount = ref(0); //展开收起统计
// 评论数据
const commentData = ref([
{
id: 1,
content: "这是我的测试评论数据信息@张三1 😄",
createTime: "2023-10-27T14:30:00",
mentions: [{
"id": 4,
"name": "张三1",
"start": 12,
"end": 16
}],
employee: {
id: 1,
name: "李星倩",
avatar: "",
},
childrenCount: 10,
children: [
{
content: "好的那我来测试下艾特人员信息@冯娜 @张三1 你们好啊",
mentions: [
{
id: 2,
name: "冯娜",
start: 14,
end: 17,
},
{
id: 4,
name: "张三1",
start: 18,
end: 22,
},
],
employee: {
id: 1,
name: "李星倩",
avatar: "",
},
reply: {
id: 1,
name: 1,
},
},
{
id: 102,
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=2",
content: "收到,数据已入库,我马上看下。",
createTime: 1767604936684,
mentions: [{ userId: 2, name: "冯娜", start: 11, end: 15 }],
replyEmployee: {
id: 1,
name: "冯娜",
},
employee: {
id: 2,
name: "zhanghan",
avatar: "",
},
},
{
id: 103,
avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=2",
content: "收到,数据已入库,我马上看下。",
createTime: 1767604936684,
mentions: [{ userId: 2, name: "冯娜", start: 11, end: 15 }],
employee: {
id: 3,
name: "王五",
avatar: "",
},
},
],
},
]);
const commentData = ref([]);
// TODO:请求用户列表的接口函数
// 滚动加载
const infinityLoading = ref(false);
const loadMoreAnchor = ref(null);
const noMore = ref(false);
// FIXME:请求用户列表的接口函数
const handleFetchSearch = async (keyword, signal) => {
console.log("获取参数信息", keyword, signal);
await new Promise((resolve) => setTimeout(resolve, 300));
@@ -392,6 +331,22 @@ const handleFetchSearch = async (keyword, signal) => {
];
};
// 获取当前的的评论信息
const getCommentData = async (childItem) => {
const queryData = {
pageNo: 1,
pageSize: 20,
...props.queryParams,
...childItem,
};
try {
const response = await getComment(queryData);
return response;
} catch (error) {
console.log("comment error:", error);
}
};
// 点击回复插入 mentions 块
const handleKeyDown = (e) => {
if (e.key === "Backspace") {
@@ -412,19 +367,20 @@ const handleKeyDown = (e) => {
// 用户选中@圈人的操作
const onUserSelect = (user: any) => {
console.log("获取当前返回的用户信息:", user);
recordSelection(user);
};
// 回复
const openReply = (target, group) => {
// 1. 设置回复的目标关系
activeReply.groupId = group.id; // 根评论ID
activeReply.parentId = target.id; // 直接父级ID
activeReply.targetName = target.employee.name;
activeReply.groupId = target.rootId; // 根评论ID
activeReply.replyUserId = target.employee.userId; //回复-人的id
activeReply.parentId = target.parentId; // 直接父级ID
activeReply.targetName = target.employee.username; //回复人员的username
activeReply.id = target.id;
// 2. 沿用 @ 逻辑:自动在输入框插入 @某人
const mentionStr = `@${target.employee.name} `;
const mentionStr = `@${target.employee.username} `;
// 如果输入框里已经有内容了,在前面追加,否则直接赋值
if (!mainInput.value.includes(mentionStr)) {
@@ -433,13 +389,33 @@ const openReply = (target, group) => {
};
// 删除回复-删除评论
const deleteReply = (target, group) => {
const deleteReply = async (target, group) => {
try {
// 删除成功
await deleteComment(target.id);
ElMessage.success("删除成功");
const index = group.children.findIndex((item) => item.id === target.id);
if (index !== -1) {
group.children.splice(index, 1);
if (group.childrenCount > 0) {
group.childrenCount--;
}
}
// 删除后如果子评论数量为0则隐藏所有回复 childrenCount为0时会自动隐藏展开操作
if (group.childrenCount === 0) {
group.showAllReplies = false;
}
} catch (error) {
console.log("删除失败", error);
}
};
// 删除主评论-以及所有的子评论
const deleteMainComment = (target) => {
const deleteMainComment = async (target) => {
try {
await deleteComment(target.id);
ElMessage.success("删除成功");
// 前端动态操作
const index = commentData.value.findIndex((item) => item.id === target.id);
if (index !== -1) {
commentData.value.splice(index, 1);
@@ -447,6 +423,9 @@ const deleteMainComment = (target) => {
cancelReply();
}
}
} catch (error) {
console.log("删除失败", error);
}
};
// emoji输入框选择
@@ -474,17 +453,18 @@ const handleSendComment = async () => {
let rawText = mainInput.value;
if (!rawText.trim()) return ElMessage.warning("内容不能为空");
let finalParentId = null;
let finalReplyId = null;
const expectedPrefix = `@${activeReply.targetName} `;
const isActuallyReply =
activeReply.parentId && rawText.startsWith(expectedPrefix);
activeReply.replyUserId && rawText.startsWith(expectedPrefix);
if (isActuallyReply) {
finalParentId = activeReply.parentId;
finalReplyId = activeReply.replyUserId;
rawText = rawText.slice(expectedPrefix.length);
} else {
finalParentId = null;
finalReplyId = null;
}
const type = finalParentId ? "reply" : "main"; //ui构造渲染判断
const type = finalReplyId ? "reply" : "main"; //ui构造渲染判断
// 构造 Payload
const mentionList: any[] = [];
const localCache = new Map();
@@ -513,24 +493,23 @@ const handleSendComment = async () => {
}
// 组装接口请求的参数
const params = {
content: rawText,
mentions: mentionList,
// TODO:如果是回复,带上关联 ID
reply: {
id: activeReply.groupId,
name: activeReply.groupId,
},
mentions: Object.keys(mentionList).length ? mentionList : null,
...props.queryParams,
};
console.log("获取传递给后端的数据信息:", params);
// 回复数据复制
if(type === "reply"){
params.rootId = activeReply.groupId ? activeReply.groupId : activeReply.id;
params.parentId = activeReply.parentId;
params.replyUserId = activeReply.replyUserId;
}
try {
// 请求后端接口提交数据
// const res = await api.postComment(params);
const res = await addReplyComment(params);
// 前端 UI 更新
updateUIAfterSend(type, params);
updateUIAfterSend(type, params, res);
// 清空输入框
cancelReply();
clearSelection();
@@ -541,13 +520,18 @@ const handleSendComment = async () => {
};
// 更新当前的UI层
const updateUIAfterSend = (type, params) => {
const updateUIAfterSend = (type, params, response) => {
const newComment = {
id: Date.now(), // TODO:这个地方需要替换为后端返回的真实id 用作后续的删除
id: response,
employee: {
...currentUser.value,
},
reply: params.reply,
replyId: params.replyUserId,
replyUser: {
userId: activeReply.replyUserId,
username: activeReply.targetName,
},
rootId: params.groupId,
content: params.content,
mentions: params.mentionList,
createTime: new Date().valueOf(),
@@ -559,87 +543,114 @@ const updateUIAfterSend = (type, params) => {
commentData.value.unshift(newComment);
} else {
//回复某人的数据渲染
const targetGroup = commentData.value.find((i) => i.id === params.reply.id);
const targetGroup = commentData.value.find(
(i) => i.id === params.rootId
); //获取返回的数据信息
if (targetGroup) {
newComment.replyTo = activeReply.targetName;
if (!targetGroup.localReplies) targetGroup.localReplies = [];
targetGroup.localReplies.unshift(newComment);
if (!targetGroup.children) targetGroup.children = [];
targetGroup.children.unshift(newComment);
targetGroup.childrenCount = targetGroup.children.length;
}
}
};
// 滚动加载数据
const setupObserver = () => {
const observer = new IntersectionObserver(
(entries) => {
// 如果探测器进入视口,且当前没在加载,且还有更多数据
if (
entries[0].isIntersecting &&
!infinityLoading.value &&
!noMore.value
) {
loadMainComments();
}
},
{
threshold: 0.1,
}
);
if (loadMoreAnchor.value) {
observer.observe(loadMoreAnchor.value);
}
};
// TODO:展开
const MOCK_REPLIES_POOL = Array.from({ length: 20 }, (_, i) => ({
id: 200 + i,
content: `这是模拟的第 ${i + 1} 条回复内容,@张三 用于测试分页加载。`,
createTime: Date.now() - i * 100000,
mentions: [{ id: 1, name: "张三", start: 3, end: 10 }],
employee: {
id: 10 + i,
name: `同事${i + 1}`,
avatar: "",
},
reply: null,
}));
const loadReplies = async (item) => {
if (item.loading) return;
expandingCount.value++;
const nextPage = ref(1);
const loadMainComments = async () => {
infinityLoading.value = true;
try {
const res = await getCommentData({ pageNo: nextPage.value });
const processedRecords = res.records.map(item => ({
...item,
children: item.children || [],
localReplies: [],
loading: false,
showAllReplies: false,
currentPage: 1
}));
commentData.value = [...commentData.value,...processedRecords];
if (nextPage.value >= res.totalPage) {
noMore.value = true;
return;
}
} finally {
infinityLoading.value = false;
}
};
// TODO:展开 加载二级内容(包含回复)
const PAGE_SIZE_MORE = 3;
const handleExpand = async (item) => {
item.loading = true;
try {
// 后端获取最终的数据信息
// const res = await xxxxxx()
// 模拟延迟
await new Promise((resolve) => setTimeout(resolve, 800));
const res = [];
const response = await getCommentData({
rootId: item.id,
pageNo: 1,
pageSize: PAGE_SIZE_MORE,
});
// 模拟的数据
const currentLength = item.children.length;
const pageSize = 3;
const nextBatch = MOCK_REPLIES_POOL.slice(
currentLength,
currentLength + pageSize
);
const combined = [
...(item.localReplies || []),
...res,
...item.children,
...nextBatch,
];
item.children = combined.filter(
(v, i, a) => a.findIndex((t) => t.id === v.id) === i
);
item.localReplies = [];
item.children = response.records; // 填充数据
item.showAllReplies = true;
item.currentPage = 1; // 记录当前页码
} catch (error) {
console.log("error", error);
console.error("加载回复失败", error);
} finally {
item.loading = false;
setTimeout(() => {
expandingCount.value--;
}, 100);
}
};
// 加载更多的页码
const loadMoreReplies = async (item) => {
item.loading = true;
const nextPage = (item.currentPage || 1) + 1;
try {
const res = await getCommentData({
rootId: item.id,
pageNum: nextPage,
pageSize: PAGE_SIZE_MORE,
});
// 将新数据追加到列表末尾
item.children = [...item.children, ...res];
item.currentPage = nextPage;
} finally {
item.loading = false;
}
};
// 收起
const collapseReplies = (item) => {
expandingCount.value++;
item.showAllReplies = false;
console.log("收起数据", expandingCount.value);
nextTick(() => {
const el = document.getElementById(`comment-${item.id}`);
el?.scrollIntoView({ behavior: "smooth", block: "nearest" });
setTimeout(() => {
expandingCount.value--;
}, 300);
});
item.children = []; //清空数据
item.currentPage = 0; //重置页码
};
// 初始化 (骨架屏展示)
onMounted(() => {
setTimeout(() => {
loading.value = false;
}, 500);
}, 300);
setupObserver();
});
</script>

View File

@@ -37,8 +37,7 @@
<!-- 右侧用户的内容 -->
<rightMenuGroup @on-stage-manage="onStageManage" />
</el-header>
<el-main>
<!-- <card-item :list="[1,2,3,4,5,6]"/> -->
<el-main class="mj-main-backend-content">
<router-view />
</el-main>
</el-container>
@@ -218,6 +217,7 @@ onUnmounted(() => {
:deep(.el-main) {
--el-main-padding: 16px;
padding: var(--el-main-padding) calc(var(--el-main-padding) * 2);
background-color: #f8fafc;
}

View File

@@ -90,6 +90,7 @@ import { ElMessage, type FormInstance, type FormRules } from "element-plus";
import { login } from "@/api";
import { useUserStore } from "@/store";
import TokenManager from '@/utils/storage';
import { Lock,Message,Right } from '@element-plus/icons-vue';
defineOptions({ name: "Login" });
@@ -113,8 +114,8 @@ const loginFormRef = ref<FormInstance>();
const loading = ref(false);
const loginForm = reactive({
username: "user",
password: "password",
username: "18280362106",
password: "123456789",
remember:false
});
@@ -141,9 +142,10 @@ const handleLogin = async () => {
grant_type: 'password'
});
if (response) {
const { access_token, refresh_token, expires_in,username,userId,avatar } = response;
const { access_token, refresh_token, expires_in,username,userId,avatar,nickname } = response;
const userInfo = {
username,
nickname,
userId,
avatar
}
@@ -167,7 +169,7 @@ const handleLogin = async () => {
}
} catch (error: any) {
console.error("Login error:", error);
ElMessage.error(error.msg || "登录失败,请稍后重试");
ElMessage.error(error.error || "登录失败,请稍后重试");
} finally {
loading.value = false;
}

View File

@@ -13,7 +13,7 @@
<div class="mj-drawer-top-container">
<div class="top-toolbar">
<div class="left-actions">
<div class="search-dict-input">
<div class="search-auto-expand-input">
<el-input
v-model="searchQuery"
placeholder="搜索字段名称..."

View File

@@ -47,7 +47,7 @@
>
</div>
</CommonFilter>
<div class="search-dict-input">
<div class="search-auto-expand-input">
<el-input
placeholder="搜索字典..."
class="auto-expand-input"

View File

@@ -0,0 +1,96 @@
<template>
<div class="flow-card">
<div class="card-cover">
<img src="https://via.placeholder.com/300x180" />
</div>
<div class="card-content">
<div class="title-row">
<h3 class="mj-ellipsis-one-line title">商机通用跟进流程</h3>
<el-icon class="more-icon"><MoreFilled /></el-icon>
</div>
<div class="meta-info">
<span>创建人程彬</span>
<span class="divider">|</span>
<span>2024-12-20</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { MoreFilled } from "@element-plus/icons-vue";
defineOptions({ name: "FlowCard" });
</script>
<style lang="scss" scoped>
.flow-card {
--card-active-border-color: #409eff;
--card-radius:5px;
width: 100%;
background: #ffffff;
border-radius: var(--card-radius);
overflow: hidden;
border: 1px solid #dce2e7;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&:hover {
border-color: var(--card-active-border-color);
transform: translateY(-1px);
}
.card-cover {
width: 100%;
height: 160px;
background-color: #f5f7fa;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.5s ease;
}
}
&:hover .card-cover img {
transform: scale(1.05);
}
.card-content {
padding: 5px 12px;
.title-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 5px;
.title {
margin: 0;
font-size: 12px;
font-weight: 600;
color: #303133;
}
.more-icon {
color: #909399;
font-size: 14px;
&:hover {
color: var(--el-color-primary);
}
}
}
.meta-info {
display: flex;
align-items: center;
font-size: 10px;
color: #90a1b9;
.divider {
margin: 0 8px;
color: #dce2e7;
}
}
}
}
</style>

View File

@@ -1,12 +1,85 @@
<template>
<div class="">
</div>
<div class="mj-flow-container">
<stageBreadcrumbs title="SOP流程管理" styleClass="stage-breadcrumbs-list">
<template #content>
<OverflowTabs
:itemMap="{ id: 'id', label: 'name' }"
v-model="activeTab"
:items="tabList"
:height="60"
/>
</template>
<template #action>
<div class="search-auto-expand-input">
<el-input
placeholder="搜索流程名称..."
class="auto-expand-input"
:prefix-icon="'Search'"
v-model="searchVal"
@keyup.enter="fetchTableData"
></el-input>
</div>
</template>
</stageBreadcrumbs>
<div class="mj-flow-content">
<subTabs v-model:tabs="tabsIndex" :menuList="menuList" />
<div class="mj-card-container mj-flow-card-container">
<div v-for="t in 5" :key="t" class="mj-flow-card-grid">
<flowCard />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {reactive,ref,onMounted} from "vue"
import subTabs from "./subTabs.vue";
import flowCard from "./flowCard.vue";
defineOptions({})
defineOptions({ name: "Flow" });
const activeTab = ref<string>(1);
const searchVal = ref<string>("");
const tabsIndex = ref(1);
const menuList = ref([
{
id: 1,
name: "线索管理",
},
{
id: 2,
name: "客户管理",
icon: "Setting",
},
{
id: 3,
name: "游戏与工作室",
},
{
id: 4,
name: "商机管理",
},
]);
const tabList = ref([
{
id: 1,
name: "商机",
},
{
id: 2,
name: "合同",
},
{
id: 3,
name: "项目",
},
{
id: 4,
name: "招聘",
},
]);
const fetchTableData = () => {
console.log("fetchTableData");
};
</script>
<style lang="scss" scoped>

View File

@@ -0,0 +1,55 @@
<template>
<div class="mj-subnav-container">
<div
v-for="(item, index) in menuList"
:key="index"
:class="['nav-item', { 'is-active': tabs === index }]"
@click="tabs = index"
>
{{ item.name }}
</div>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: "subTabs" });
const props = defineProps({
menuList: { type: Array, default: () => [] },
});
const tabs = defineModel("tabs");
</script>
<style lang="scss" scoped>
.mj-subnav-container {
$primary-color: var(--el-color-primary);
$bg-active: #EFF6FF;
$text-idle: #90A1B9;
$text-active: var(--el-color-primary);
$border-active:#DEECFE;
display: flex;
align-items: center;
gap: 7px;
padding: 12px 0;
.nav-item {
cursor: pointer;
padding: 2px 12px;
border-radius: 20px;
font-size: 11px;
color: $text-idle;
font-weight: 500;
transition: all 0.12s cubic-bezier(0.645, 0.045, 0.355, 1);
border: 1px solid transparent;
user-select: none;
&:hover {
color: $text-active;
}
&.is-active {
color: $text-active;
background-color: $bg-active;
border: 1px solid $border-active;
}
}
}
</style>

View File

@@ -2,7 +2,7 @@
<div class="mj-organization">
<!-- 顶部的tabs菜单 -->
<div class="organization-tabs">
<stageBreadcrumbs title="组织管理">
<stageBreadcrumbs title="组织管理" style-class="stage-breadcrumbs-list">
<template #content>
<OverflowTabs :itemMap="{id:'id',label:'name'}" v-model="activeTab" :items="tabList" :height="60" />
</template>
@@ -47,7 +47,7 @@
:color="getIconColor(node)"
/>
<AutoTooltip :content="node.label" class="tree-node-label" />
<AutoTooltip :content="node.label" class="mj-ellipsis-one-line tree-node-label" />
</div>
<div class="org-tree-item-right">
<el-icon :size="15" @click.stop="onOrgTreeDelete(node,data)">
@@ -266,11 +266,6 @@ onMounted(()=>{
@use "sass:math";
.mj-organization {
height: 100%;
.organization-tabs {
:deep(.stage-breadcrumbs) {
padding: 0;
}
}
.mj-organization-card {
border-radius: 16px;
border: 1px solid #e2e8f099;
@@ -325,9 +320,6 @@ onMounted(()=>{
}
.tree-node-label{
display: inline-block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
width: 100%;
}
}

View File

@@ -1,12 +1,163 @@
<template>
<div class="mj-permission-management">
<div class="mj-permission-management">
<stageBreadcrumbs title="权限管理" style-class="stage-breadcrumbs-list">
<template #content>
<OverflowTabs v-model="activeTab" :items="tabList" :height="60" />
</template>
<template #action>
<div class="mj-permission-actions">
<div class="search-auto-expand-input">
<el-input
placeholder="搜索字典..."
class="auto-expand-input"
:prefix-icon="'Search'"
v-model="searchVal"
@keyup.enter="fetchTableData"
></el-input>
</div>
<div class="mj-dict-actions-right">
<el-button :icon="'Plus'" type="primary" plain @click="addRoles">新增角色</el-button>
</div>
</div>
</template>
</stageBreadcrumbs>
</div>
<!-- 表格 -->
<CommonTable
ref="dictTableRef"
:columns="columns"
v-model:data="dataList"
v-model:total="total"
pagination
:request-api="getTableData"
>
</CommonTable>
</div>
</template>
<script setup lang="ts">
import dayjs from "dayjs";
import CommonTable from "@/components/proTable/index.vue";
defineOptions({ name: "PermissionManagement" });
const activeTab = ref(1);
const dictTableRef = ref(null);
const dataList = ref([]);
const total = ref(0);
defineOptions({ name: "PermissionManagement"})
const tabList = [
{
label: "角色与权限",
id: 1,
},
{
label: "用户管理",
id: 2,
},
{
label: "权限配置",
id: 3,
},
];
const columns = [
{ prop: "id", label: "编号", width: "80", align: "center", slot: "number" },
{ prop: "name", label: "字典名称", align: "center", slot: "name" },
{
prop: "key",
label: "成员数量",
align: "center",
},
{
prop: "key",
label: "权限数量",
align: "center",
},
{
prop: "status",
label: "状态",
align: "center",
slot: "status",
},
{
prop: "remark",
label: "角色类型",
align: "center",
showOverflowTooltip: true,
},
{
prop: "remark",
label: "关联岗位",
align: "center",
showOverflowTooltip: true,
},
{
prop: "createTime",
label: "更新时间",
align: "center",
showOverflowTooltip: true,
formatter: (val) => {
return val.createTime
? dayjs(val.createTime).format("YYYY-MM-DD HH:mm")
: "-";
},
},
{
prop: "updateByName",
label: "更新人",
align: "center",
},
{
prop: "actions",
label: "操作",
align: "right",
width: "300",
actions: [
{
label: "权限",
type: "primary",
link: true,
permission: ["edit"],
onClick: (row) => handleEdit(row),
},
{
label: "复制",
type: "default",
link: true,
permission: ["edit"],
onClick: (row) => handleEdit(row),
},
{
label: "编辑",
type: "default",
link: true,
permission: ["config"],
onClick: (row) => handlefieldsConfig(row),
},
{
label: "删除",
type: "danger",
link: true,
permission: ["delete"],
onClick: (row) => handleDelete(row),
},
],
},
];
// 请求数据信息
const fetchTableData = () => {};
// 新增角色
const addRoles = () => {};
</script>
<style lang="scss" scoped>
.mj-permission-management {
:deep(.stage-breadcrumbs) {
margin-bottom: 16px;
}
.mj-permission-actions {
display: flex;
align-items: center;
gap: 10px;
}
}
</style>

View File

@@ -3,16 +3,16 @@ import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from "axios";
import { ElNotification } from "element-plus";
import { VITE_APP_BASE_API } from "../../config.js";
import TokenManager from "@/utils/storage";
import { getMockData, shouldUseMock } from "@/mock"; //mock数据信息
const tokenManager = TokenManager.getInstance();
const baseUrl = import.meta.env.MODE === "development" ? "/api" : VITE_APP_BASE_API;
const baseUrl =
import.meta.env.MODE === "development" ? "/api" : VITE_APP_BASE_API;
// 1. 锁和队列定义在类外部,确保全局唯一
let isRefreshing = false;
let requestsQueue: Array<(token: string) => void> = [];
// 登录接口 传递参数不一样
const AUTH_OAUTH2_TOKEN_URL = "/auth/oauth2/token";
const CLIENT_CREDENTIALS = "Basic " + window.btoa("mversion:secret");
const CLIENT_CREDENTIALS = "Basic " + window.btoa("mversion:H7mHCOfjhV2VqlR31OHi6ruVMeOQvluz");
class HttpRequest {
private instance: AxiosInstance;
@@ -127,13 +127,23 @@ class HttpRequest {
}
// 其它业务错误
ElNotification.error({ title: "提示", message: res.msg || "服务异常" });
ElNotification.error({
title: "提示",
dangerouslyUseHTMLString: true,
message: `${res.msg}<br/>错误码:${res.subCode}` || "服务异常",
});
return Promise.reject(res);
},
(error) => {
// 网络层错误处理
if (error.response?.status === 401) {
this.clearTokens();
} else {
if(this.isAuthEndpoint(error.config.url)){
ElNotification.error({ title: "提示", message: error.response.data.error || '未知错误' });
return;
}
ElNotification.error({ title: "提示", message: "服务异常" });
}
return Promise.reject(error);
}

View File

@@ -9,7 +9,7 @@ import TokenManager from "@/utils/storage";
import Login from "@/pages/Login/index.vue";
import HomeView from "@/pages/Layout/index.vue";
import { mockBackendMenuData } from "@/mock/menu";
const tokenManager = TokenManager.getInstance();
// 基础路由(不需要权限验证)
const constantRoutes: RouteRecordRaw[] = [
@@ -143,44 +143,7 @@ const addDynamicRoutes = async () => {
// 从后端获取路由菜单数据 (这边需要区分 后台的菜单 和用户的菜单)
let allRoutes:any[] = [];
if (userStore.isBackendUser) {
// const backendResponse = await getRouteMenus();
const backendResponse = [
{
"name": "字典管理",
"code": "dict",
"icon": "OfficeBuilding",
"metadata": null,
"children": null
},
{
"name": "组织管理",
"code": "origanization",
"icon": "OfficeBuilding",
"metadata": null,
"children": null
},
{
"name": "人员管理",
"code": "personnel",
"icon": "OfficeBuilding",
"metadata": null,
"children": null
},
{
"name": "权限管理",
"code": "permission",
"icon": "OfficeBuilding",
"metadata": null,
"children": null
},
{
"name": "流程管理",
"code": "flow",
"icon": "OfficeBuilding",
"metadata": null,
"children": null
}
];
const backendResponse = await getRouteMenus();
allRoutes = [
{
code: "stage",
@@ -189,7 +152,7 @@ const addDynamicRoutes = async () => {
meta:{
title:'管理中心'
},
children: backendResponse,
children: Object.keys(backendResponse).length ? backendResponse : mockBackendMenuData,
},
];
} else {

View File

@@ -22,13 +22,14 @@ body {
}
// popover 筛选框的全局样式
.filter-popper.el-popover.el-popper {
--el-popover-padding: 0;
border-radius: var(--mj-popper-radius);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
}
// 全局重element相关样式
// 全局重element相关样式
.mj-input-form {
.el-input {
--el-border-radius-base: 10px;
@@ -38,7 +39,7 @@ body {
// 搜索框动画
.search-dict-input {
.search-auto-expand-input {
--default-width: 160px;
--max-width: 224px;
width: var(--default-width);
@@ -71,7 +72,7 @@ body {
}
// 筛选框全局样式内容
// 筛选框内容的全局样式内容
.mj-filter-content {
min-width: 380px;
background: #fff;
@@ -133,3 +134,41 @@ body {
}
}
}
// 全局单行省略样式
.mj-ellipsis-one-line {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
// 卡片的全局样式
.mj-card-container{
display: grid;
gap: 16px;
width: 100%;
// 默认:一行 5 列
grid-template-columns: repeat(5, 1fr);
// 1400px 以下:觉得 5 个太挤,切到一行 4 列
@media (max-width: 1400px) {
grid-template-columns: repeat(4, 1fr);
}
// 1100px 以下 (iPad横屏区间):一行 3 列
@media (max-width: 1100px) {
grid-template-columns: repeat(3, 1fr);
}
// 768px 以下 (iPad竖屏):一行 2 列
@media (max-width: 768px) {
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
// 480px 以下 (手机端):一行 1 列
@media (max-width: 480px) {
grid-template-columns: repeat(1, 1fr);
}
}