2025-06-30 14:43:02 +08:00
|
|
|
|
<script>
|
|
|
|
|
|
import router from "./static/common/js/router";
|
2025-07-10 16:41:28 +08:00
|
|
|
|
import config from "./static/common/js/config.js";
|
2025-06-30 14:43:02 +08:00
|
|
|
|
|
|
|
|
|
|
var jweixin = require("jweixin-module");
|
|
|
|
|
|
export default {
|
|
|
|
|
|
data() {
|
|
|
|
|
|
return {
|
|
|
|
|
|
show: false,
|
2025-12-05 10:49:53 +08:00
|
|
|
|
// WebSocket 实例与连接控制
|
|
|
|
|
|
ws: null, // 当前 WebSocket 连接
|
|
|
|
|
|
lockReconnect: false, // 是否处于稳定连接,防止重复重连
|
2025-12-11 16:15:27 +08:00
|
|
|
|
// timeout: 30000, // 心跳间隔(毫秒)
|
2025-12-05 10:49:53 +08:00
|
|
|
|
timeoutObj: null, // 心跳倒计时定时器
|
|
|
|
|
|
serverTimeoutObj: null, // 心跳响应等待定时器
|
|
|
|
|
|
timeoutnum: null, // 重连延时定时器
|
2025-07-10 16:41:28 +08:00
|
|
|
|
};
|
2025-06-30 14:43:02 +08:00
|
|
|
|
},
|
|
|
|
|
|
globalData: {},
|
2025-07-10 16:41:28 +08:00
|
|
|
|
created() {},
|
2025-06-30 14:43:02 +08:00
|
|
|
|
onLaunch() {
|
2025-07-10 16:41:28 +08:00
|
|
|
|
if (typeof window.entryUrl === "undefined" || window.entryUrl === "") {
|
|
|
|
|
|
window.entryUrl = location.href.split("#")[0];
|
2025-06-30 14:43:02 +08:00
|
|
|
|
}
|
|
|
|
|
|
router.initApp(this);
|
|
|
|
|
|
// 处理项目加载时白名单不生效异常问题
|
2025-07-10 16:41:28 +08:00
|
|
|
|
let isUrl = this._route.fullPath.split("?")[0];
|
|
|
|
|
|
let notNeed = config.whiteList.includes(isUrl);
|
2025-06-30 14:43:02 +08:00
|
|
|
|
if (notNeed) {
|
|
|
|
|
|
uni.navigateTo({
|
|
|
|
|
|
url: this._route.fullPath,
|
|
|
|
|
|
});
|
2025-07-10 16:41:28 +08:00
|
|
|
|
return;
|
2025-06-30 14:43:02 +08:00
|
|
|
|
}
|
|
|
|
|
|
var that = this;
|
|
|
|
|
|
uni.getSystemInfo({
|
|
|
|
|
|
success: function (res) {
|
|
|
|
|
|
// 根据 model 进行判断
|
|
|
|
|
|
if (res.model.indexOf("iPhone") >= 0) {
|
|
|
|
|
|
that.$u.vuex("vuex_iPhone", true);
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
2025-07-10 16:41:28 +08:00
|
|
|
|
|
2025-07-10 16:57:01 +08:00
|
|
|
|
if (!that.vuex_token) {
|
2025-07-30 16:32:38 +08:00
|
|
|
|
const type = that.vuex_userType || 0; // 0:学生 1:教师
|
2025-07-10 16:41:28 +08:00
|
|
|
|
this.$u.vuex("vuex_user", "");
|
|
|
|
|
|
this.$u.vuex("vuex_token", "");
|
|
|
|
|
|
uni.clearStorage();
|
2025-07-10 16:57:01 +08:00
|
|
|
|
uni.reLaunch({
|
2025-07-30 16:32:38 +08:00
|
|
|
|
url: `/pages/login/login/index?type=${type}`,
|
2025-07-10 16:41:28 +08:00
|
|
|
|
});
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return;
|
2025-06-30 14:43:02 +08:00
|
|
|
|
if (!that.vuex_user.isFill) {
|
|
|
|
|
|
this.$u.vuex("vuex_msgList", "");
|
|
|
|
|
|
this.$u.vuex("vuex_user", "");
|
|
|
|
|
|
this.$u.vuex("vuex_token", "");
|
|
|
|
|
|
uni.clearStorage();
|
|
|
|
|
|
this.$u.route({
|
|
|
|
|
|
url: "/pages/login/login/login",
|
|
|
|
|
|
});
|
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
|
|
|
|
uni.navigateTo({
|
|
|
|
|
|
url: "/pages/login/roleSelection",
|
|
|
|
|
|
});
|
2025-07-10 16:41:28 +08:00
|
|
|
|
return;
|
2025-06-30 14:43:02 +08:00
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
// onShow(){
|
|
|
|
|
|
// console.log('onShow')
|
|
|
|
|
|
// },
|
|
|
|
|
|
methods: {
|
2025-12-05 10:49:53 +08:00
|
|
|
|
// 构建 WebSocket 连接地址(与 oa-web-phone 保持一致的握手参数)
|
|
|
|
|
|
buildWsUrl() {
|
|
|
|
|
|
const protocol =
|
|
|
|
|
|
window.location.protocol.indexOf("https") === 0 ? "wss" : "ws";
|
2025-12-05 11:10:31 +08:00
|
|
|
|
const Id =
|
2025-12-05 10:49:53 +08:00
|
|
|
|
(this.vuex_user && (this.vuex_user.id || this.vuex_user.Id)) || "";
|
2025-12-05 11:10:31 +08:00
|
|
|
|
return `${protocol}://120.55.234.65:8082/api/Dialogue/HandleConnection?Id=${Id}`; // &equipmentType=0
|
2025-12-05 10:49:53 +08:00
|
|
|
|
},
|
|
|
|
|
|
// 初始化原生 WebSocket 连接
|
|
|
|
|
|
initWebSocket() {
|
|
|
|
|
|
console.log(this.buildWsUrl());
|
2025-12-05 11:05:39 +08:00
|
|
|
|
|
2025-12-05 10:49:53 +08:00
|
|
|
|
try {
|
|
|
|
|
|
this.ws = new WebSocket(this.buildWsUrl());
|
|
|
|
|
|
this.ws.onopen = () => this.handleWsOpen();
|
|
|
|
|
|
this.ws.onmessage = (e) => this.handleWsMessage(e);
|
|
|
|
|
|
this.ws.onclose = (e) => this.handleWsClose(e);
|
|
|
|
|
|
this.ws.onerror = (e) => this.handleWsError(e);
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.log("[WebSocket] 创建连接失败:", err);
|
|
|
|
|
|
this.reconnect();
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
// 重连(防抖,避免频繁重试)
|
|
|
|
|
|
reconnect() {
|
|
|
|
|
|
if (this.lockReconnect) return;
|
|
|
|
|
|
this.timeoutnum && clearTimeout(this.timeoutnum);
|
|
|
|
|
|
this.timeoutnum = setTimeout(() => {
|
|
|
|
|
|
this.startLink();
|
|
|
|
|
|
}, 5000);
|
|
|
|
|
|
},
|
|
|
|
|
|
// 心跳重置并重启
|
|
|
|
|
|
reset() {
|
|
|
|
|
|
this.timeoutObj && clearTimeout(this.timeoutObj);
|
|
|
|
|
|
this.serverTimeoutObj && clearTimeout(this.serverTimeoutObj);
|
|
|
|
|
|
this.start();
|
|
|
|
|
|
},
|
|
|
|
|
|
// 启动心跳与超时处理
|
|
|
|
|
|
start() {
|
|
|
|
|
|
this.timeoutObj = setTimeout(() => {
|
2025-12-11 16:15:27 +08:00
|
|
|
|
//这里发送一个心跳,后端收到后,返回一个心跳消息
|
|
|
|
|
|
if (
|
|
|
|
|
|
this.ws &&
|
|
|
|
|
|
this.ws.readyState === 1 &&
|
|
|
|
|
|
this.vuex_user &&
|
|
|
|
|
|
(this.vuex_user.Id || this.vuex_user.id)
|
|
|
|
|
|
) {
|
|
|
|
|
|
//如果连接正常
|
|
|
|
|
|
this.ws.send("heartCheck");
|
|
|
|
|
|
} else {
|
|
|
|
|
|
//否则重连
|
2025-12-05 10:49:53 +08:00
|
|
|
|
this.lockReconnect = false;
|
|
|
|
|
|
this.reconnect();
|
2025-06-30 14:43:02 +08:00
|
|
|
|
}
|
2025-12-05 10:49:53 +08:00
|
|
|
|
|
|
|
|
|
|
// 心跳发送后等待后端响应,超时则断开重连
|
|
|
|
|
|
this.serverTimeoutObj = setTimeout(() => {
|
2025-12-11 16:15:27 +08:00
|
|
|
|
// console.log("[WebSocket] 心跳响应超时,断开重连");
|
|
|
|
|
|
this.ws && this.ws.close();
|
2025-12-05 10:49:53 +08:00
|
|
|
|
this.lockReconnect = false;
|
|
|
|
|
|
this.reconnect();
|
2025-12-11 16:15:27 +08:00
|
|
|
|
}, 30000);
|
|
|
|
|
|
}, 30000);
|
2025-12-05 10:49:53 +08:00
|
|
|
|
},
|
|
|
|
|
|
// 连接成功
|
|
|
|
|
|
handleWsOpen() {
|
|
|
|
|
|
this.lockReconnect = true;
|
|
|
|
|
|
// 可按需缓存连接实例到全局
|
|
|
|
|
|
// this.$u.vuex('vuex_websocket', this.ws)
|
|
|
|
|
|
this.reset();
|
|
|
|
|
|
},
|
|
|
|
|
|
// 收到消息
|
|
|
|
|
|
handleWsMessage(e) {
|
|
|
|
|
|
// 收到任何消息都重置心跳
|
|
|
|
|
|
this.reset();
|
|
|
|
|
|
|
2025-12-11 16:15:27 +08:00
|
|
|
|
console.log("[WebSocket] 收到消息:", e);
|
|
|
|
|
|
|
2025-12-05 10:49:53 +08:00
|
|
|
|
// 心跳消息不处理
|
2025-12-11 16:15:27 +08:00
|
|
|
|
// if (typeof e.data === "string" && e.data.indexOf("heartCheck") >= 0) {
|
|
|
|
|
|
// return;
|
|
|
|
|
|
// }
|
2025-12-05 10:49:53 +08:00
|
|
|
|
|
|
|
|
|
|
// 尝试解析为 JSON(与 oa-web-phone 保持一致的结构:{ Type, Data })
|
|
|
|
|
|
let msgData = null;
|
|
|
|
|
|
try {
|
|
|
|
|
|
msgData = JSON.parse(e.data);
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.log("[WebSocket] 消息解析失败:", err, e.data);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const type = msgData.Type;
|
|
|
|
|
|
|
2025-12-11 16:15:27 +08:00
|
|
|
|
console.log("收到消息类型:", type);
|
|
|
|
|
|
// 收到服务端心跳响应不处理
|
|
|
|
|
|
if (type === "pong") return;
|
|
|
|
|
|
|
2025-12-05 10:49:53 +08:00
|
|
|
|
// 退出登录通知
|
|
|
|
|
|
if (type === "Exit") {
|
|
|
|
|
|
try {
|
|
|
|
|
|
this.ws && this.ws.close();
|
|
|
|
|
|
} catch (err) {}
|
|
|
|
|
|
// 清理本地登录信息并返回登录页
|
|
|
|
|
|
this.$u.vuex("vuex_user", "");
|
|
|
|
|
|
this.$u.vuex("vuex_token", "");
|
|
|
|
|
|
uni.clearStorage();
|
|
|
|
|
|
const typeNum = this.vuex_userType || 0; // 0:学生 1:教师
|
|
|
|
|
|
uni.reLaunch({ url: `/pages/login/login/index?type=${typeNum}` });
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 标记 TabBar 红点
|
|
|
|
|
|
const tab = this.vuex_tabbar || [];
|
|
|
|
|
|
if (Array.isArray(tab) && tab[1]) {
|
|
|
|
|
|
tab[1].isDot = true;
|
|
|
|
|
|
this.$u.vuex("vuex_tabbar", tab);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 维护消息提示队列(与原逻辑兼容,使用通用 key)
|
|
|
|
|
|
let msgList = this.vuex_msgList || "";
|
|
|
|
|
|
if (type === "ChatMessageDto") {
|
|
|
|
|
|
const data = msgData.Data || {};
|
|
|
|
|
|
const msgUserId = data.ChatType === 1 ? data?.ToId : data?.UserId;
|
|
|
|
|
|
const key = `chat_${msgUserId || "unknown"}`;
|
|
|
|
|
|
if (msgList.indexOf(key) < 0) {
|
|
|
|
|
|
msgList += key + ",";
|
2025-07-10 16:41:28 +08:00
|
|
|
|
this.$u.vuex("vuex_msgList", msgList);
|
2025-06-30 14:43:02 +08:00
|
|
|
|
}
|
2025-12-05 10:49:53 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
if (msgList.indexOf("WebSocketMessage") < 0) {
|
|
|
|
|
|
msgList += "WebSocketMessage,";
|
2025-07-10 16:41:28 +08:00
|
|
|
|
this.$u.vuex("vuex_msgList", msgList);
|
2025-06-30 14:43:02 +08:00
|
|
|
|
}
|
2025-12-05 10:49:53 +08:00
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
// 连接关闭
|
|
|
|
|
|
handleWsClose(e) {
|
2025-12-11 16:15:27 +08:00
|
|
|
|
// console.log(`[WebSocket] 连接关闭: code=${e.code}, reason=${e.reason}`);
|
2025-12-05 10:49:53 +08:00
|
|
|
|
this.lockReconnect = false;
|
|
|
|
|
|
this.reconnect();
|
|
|
|
|
|
},
|
|
|
|
|
|
// 连接错误
|
|
|
|
|
|
handleWsError(e) {
|
2025-12-11 16:15:27 +08:00
|
|
|
|
// console.log("[WebSocket] 连接错误:", e);
|
2025-12-05 10:49:53 +08:00
|
|
|
|
this.lockReconnect = false;
|
|
|
|
|
|
this.reconnect();
|
2025-06-30 14:43:02 +08:00
|
|
|
|
},
|
2025-12-05 10:49:53 +08:00
|
|
|
|
// 启动连接入口(需存在用户)
|
|
|
|
|
|
startLink() {
|
|
|
|
|
|
if (this.lockReconnect) return;
|
|
|
|
|
|
const hasUser =
|
|
|
|
|
|
this.vuex_user && (this.vuex_user.id || this.vuex_user.Id);
|
|
|
|
|
|
const hasToken =
|
|
|
|
|
|
!!this.vuex_token || !!window.localStorage.getItem("token");
|
2025-12-05 11:05:39 +08:00
|
|
|
|
if (hasUser && hasToken) {
|
2025-12-05 10:49:53 +08:00
|
|
|
|
this.initWebSocket();
|
2025-06-30 14:43:02 +08:00
|
|
|
|
} else {
|
2025-12-05 10:49:53 +08:00
|
|
|
|
this.lockReconnect = false;
|
|
|
|
|
|
this.reconnect();
|
2025-06-30 14:43:02 +08:00
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
mounted() {
|
2025-12-05 10:49:53 +08:00
|
|
|
|
// 使用与 oa-web-phone 相同的原生 WebSocket 通信方式
|
2025-12-11 16:15:27 +08:00
|
|
|
|
this.startLink(); // 现在一直断线重连,先注释
|
2025-06-30 14:43:02 +08:00
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<style lang="scss">
|
|
|
|
|
|
@import "uview-ui/index.scss";
|
|
|
|
|
|
@import "common/demo.scss";
|
|
|
|
|
|
|
|
|
|
|
|
.u-tabbar__content__item__button {
|
|
|
|
|
|
top: 0.07rem !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.u-navbar-placeholder,
|
|
|
|
|
|
.u-navbar-inner {
|
|
|
|
|
|
height: 0.44rem !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.u-tabbar__content__item__text {
|
|
|
|
|
|
font-size: 20rpx !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.u-tabbar__content__item__button .u-icon__img {
|
|
|
|
|
|
width: 44rpx !important;
|
|
|
|
|
|
height: 44rpx !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.u-size-mini {
|
|
|
|
|
|
font-size: 0.12rem !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
uni-modal .uni-modal__hd {
|
|
|
|
|
|
padding: 0.1rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
uni-modal .uni-modal__bd {
|
|
|
|
|
|
padding-top: 20rpx;
|
|
|
|
|
|
// font-weight: bold;
|
|
|
|
|
|
font-size: 34rpx;
|
|
|
|
|
|
color: rgba(0, 0, 0, 0.9);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
uni-modal .uni-modal__btn {
|
|
|
|
|
|
border-radius: 16rpx;
|
|
|
|
|
|
font-size: 28rpx;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
uni-modal .uni-modal__btn:after {
|
|
|
|
|
|
border: none !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
uni-modal .uni-modal__btn_default {
|
|
|
|
|
|
background: #e6f6ff;
|
2025-07-10 16:41:28 +08:00
|
|
|
|
color: #3cb5fb !important;
|
2025-06-30 14:43:02 +08:00
|
|
|
|
margin-right: 20rpx;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
uni-modal .uni-modal__ft {
|
|
|
|
|
|
line-height: 80rpx;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
uni-modal .uni-modal__ft:after {
|
|
|
|
|
|
border: none !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
uni-modal .uni-modal__btn_primary {
|
|
|
|
|
|
background: #3cb5fb;
|
|
|
|
|
|
color: #ffffff !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
uni-modal .uni-modal {
|
|
|
|
|
|
padding: 40rpx;
|
|
|
|
|
|
box-sizing: border-box;
|
|
|
|
|
|
border-radius: 30rpx;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.u-tabbar__content {
|
|
|
|
|
|
height: 0.5rem !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.phone .u-tabbar__content {
|
|
|
|
|
|
padding-bottom: 0.14rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.u-tabs-scorll-flex .u-tabs-item {
|
|
|
|
|
|
transition: all 0.1s;
|
|
|
|
|
|
font-size: 0.15rem !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
* {
|
|
|
|
|
|
font-family: "pingfang";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
html {
|
|
|
|
|
|
font-size: 200rpx;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
uni-page-body {
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
body {
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
|
2025-07-10 16:41:28 +08:00
|
|
|
|
"Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
|
|
|
|
|
|
"Microsoft Yahei", sans-serif;
|
2025-06-30 14:43:02 +08:00
|
|
|
|
-webkit-font-smoothing: antialiased;
|
|
|
|
|
|
-moz-osx-font-smoothing: grayscale;
|
|
|
|
|
|
// max-width: 1536rpx;
|
|
|
|
|
|
margin: 0 auto;
|
|
|
|
|
|
background-color: #f6f7fa;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
view,
|
|
|
|
|
|
image,
|
|
|
|
|
|
text {
|
|
|
|
|
|
box-sizing: border-box;
|
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#app {
|
|
|
|
|
|
width: 100vw;
|
|
|
|
|
|
height: 100vh;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.flex-row {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: row;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.flex-col {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.justify-start {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
justify-content: flex-start;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.justify-center {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.justify-end {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
justify-content: flex-end;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.justify-evenly {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
justify-content: space-evenly;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.justify-around {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
justify-content: space-around;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.justify-between {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.items-start {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: flex-start;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.items-center {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.items-end {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: flex-end;
|
|
|
|
|
|
}
|
|
|
|
|
|
</style>
|