fix:联调评论接口、新增流程管理列表模块页面
This commit is contained in:
1
components.d.ts
vendored
1
components.d.ts
vendored
@@ -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']
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
17
src/components/userSelector/index.vue
Normal file
17
src/components/userSelector/index.vue
Normal 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>
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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: '获取用户信息成功',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 菜单数据的响应格式
|
||||
* 模拟后端接口返回的数据结构
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
// 评论组件骨架屏
|
||||
|
||||
@@ -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> 删除
|
||||
</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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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="搜索字段名称..."
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
>
|
||||
</div>
|
||||
</CommonFilter>
|
||||
<div class="search-dict-input">
|
||||
<div class="search-auto-expand-input">
|
||||
<el-input
|
||||
placeholder="搜索字典..."
|
||||
class="auto-expand-input"
|
||||
|
||||
96
src/pages/stage/flow/flowCard.vue
Normal file
96
src/pages/stage/flow/flowCard.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
55
src/pages/stage/flow/subTabs.vue
Normal file
55
src/pages/stage/flow/subTabs.vue
Normal 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>
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user