Cool Unix + OpenAuth.Net 实现一款校园小程序的开发

该文章已生成可运行项目,

前言

此项目只是为了记录自己在学习uniapp x 于OpenAuth.Net中遇到问题,有问题欢迎指正。目前还没想好做什么小程序,暂定为校园服务类(校友圈、二手市场、跑腿等等等等....)小程序吧虽然已经烂大街的开源但是总得自己做过了才知道水有多深,有啥好的想法小伙伴可以直接留言噢😁。

持续不间断更新。。。

技术框架

前端框架

官网地址https://unix.cool-js.com/src/introduce/

Cool Unix 是一个现代化的跨端应用开发脚手架,基于 uni-app x 技术栈,为开发者提供了完整的解决方案,助力快速构建高质量、高性能的跨平台应用。

相对于 

以下是对uniapp 与uniapp x的直观比较,部分观点可能不够完善请多担待。新一代的框架虽然上手难度大,但是在技术日新月异的发展下仅仅局限于传统的开发语言往往都是比较吃亏的,长期以往使得自己成为了笼中鸟,离开了自己熟悉的地方就啥也不会了,所以适当的学习新技术也是为了更好的提升自己的能力。

对比维度uni-app (传统)uni-app X (新一代)
技术架构基于 WebView 渲染,逻辑层和视图层分离,存在通信开销 将代码编译为原生语言(如ArkTS, Kotlin),无WebView,逻辑与视图同进程,消除通信延迟 
开发语言支持 Vue.js (主要Vue2) 基于 TypeScript 的 UTS 语言,强制使用 Vue3 语法 
性能表现接近Web性能,复杂动画或大数据量场景易卡顿 接近原生性能,启动速度快,渲染帧率稳定,适合复杂动效和高频交互 
原生能力调用通过JS引擎桥接调用,存在中间层 直接调用原生API,无需中间层转换,能力更强,集成更深 
包体积包含WebView等运行时,体积相对较大 去除WebView相关代码,体积更精简 
学习与迁移成本✅ 上手容易,尤其对Vue开发者;生态成熟,资料丰富 ⚠️ 学习曲线较陡,需掌握UTS和Vue3;插件生态仍在建设中 

后端框架

我也在使用👉项目主页 | Admin.NET👈这个框架,确实不错。不过人总要尝试新事物才能不断进步。有兴趣的朋友可以了解一下,个人认为这是一款相当优秀的.NET框架。


OpenAuth.Net官网http://doc.openauth.net.cn/core/

OpenAuth.Net是基于最新版.Net的开源权限工作流快速开发框架。源于Martin Fowler企业级应用开发思想及最新技术组合(SqlSugar、EF、Quartz、AutoFac、WebAPI、Swagger、Mock、NUnit、Vue2/3、Element-ui/plus、IdentityServer等)。核心模块包括:角色授权、代码生成、API鉴权、智能打印、表单设计、工作流、定时任务等。

更多的不做介绍,直接打开官网地址即可查看,我这里使用的OpenAuth.Net 的开源版本,开源版本已经包含了所需的所有功能模块,当然有实力的大佬也可以支持开源作者的不易。

框架搭建

两款框架的搭建方法在各自官网上都有详细说明,按照官方指南操作通常不会出现问题。

Cool Unix

Cool Unix快速开始指南https://unix.cool-js.com/src/introduce/quick.html

由于 Cool Unix是使用Uniapp开发的,所以需要使用 HBuilderX 打开项目。

  1. 安装 HBuilderX 
  2. 拉取代码
    1. 使用Git拉取代码到本地,若没有安装Git,请参考Git 详细安装教程(详解 Git 安装过程的每一个步骤)_git安装-CSDN博客

    2. 拉取命令

      git clone https://gitee.com/cool-team-official/cool-unix.git
  3. 安装依赖
    1. # 推荐使用 pnpm(更快,更节省空间)
      pnpm i
  4. 使用HBuilderX导入项目,并运行

备注:我这里只是使用HBuilderX作为运行的服务,开发我使用的是vscode,因为vscode对vue3以及ts的插件更为丰富,开发效率远远高于HBuilderX。(仅个人爱好)

OpenAuth.Net

针对于OpenAuth.Net 就不过多复述了,在官网已经写的够明细了。直接参考官网的即可,唯一需要注意的是Git是否已经安装

快速开始 | OpenAuth.Nethttp://doc.openauth.net.cn/core/start/

框架文件结构如下

📦OpenAuth.Net
 ┣ 📂.cursor              //cursor rules、MCP配置
 ┣ 📂newdocs              //文档
 ┣ 📂Infrastructure       //基础工具类
 ┣ 📂OpenAuth.Repository  //数据库访问相关代码      
 ┣ 📂OpenAuth.App         //应用逻辑代码
 ┣ 📂OpenAuth.Identity    //IdentityServer4服务器,提供OAuth服务
 ┣ 📂OpenAuth.WebApi      //WebApi接口站点
 ┣ 📂Vue2                 //开源Vue2前端项目
 ┣ 📂数据库脚本           //数据库脚本
 ┣ 📜.gitattributes
 ┣ 📜.gitignore
 ┣ 📜LICENSE
 ┣ 📜.cursorignore       //cursor codebase index忽略文件
 ┣ 📜Dockerfile          //docker文件
 ┣ 📜OpenAuth.Net.sln    //解决方案
 ┗ 📜README.md

功能实现

小程序端身份校验与登录

OpenAuth.Net框架已内置登录认证功能,具体可参考👉登录认证 | OpenAuth.Net👈。当前需要实现后端与前端小程序的登录联动,由于小程序采用独立身份认证体系,因此核心在于实现一键登录功能。

登录认证 | OpenAuth.Net

首先还是需要先实现前后端的联动,同时也实现多种方法的登录。

1、启动Cool Unix项目,找到登录界面

2、修改登录页面"/pages/user/login",

        2.1、修改路径 pages\user\types\index.ts,为LoginForm增加登录参数。

因为此处后端使用账号+密码的方式进行登录,而前端设计的是验证码登录,为了节约成本就直接账号密码登录就行了啦,此处可能有人疑惑,为毛  smsCode?: string; 中间有一个问号其实这个就是允许为空的含义。


export type LoginForm = {
	phone: string;
	smsCode?: string;
	password: string;
};

        2.2、修改 /pages/user/login,完整代码如下,其中注释了验证码模块,新增了密码输入模块,修改登录参数为后端接口需要的参数。

<view class="mb-3 flex flex-row">
			<cl-input
				v-model="form.password"
				password
				prefix-icon="shield-check-fill"
				:placeholder="t('请输入密码')"
				:border="false"
				:pt="{
					className: parseClass([
						'!h-[90rpx] flex-1 !rounded-xl !px-4',
						[isDark, '!bg-surface-70', '!bg-white']
					]),
					prefixIcon: {
						className: 'mr-1'
					}
				}"
			></cl-input>
		</view>
		<!-- <view class="relative flex flex-row items-center mb-5">
			<cl-input
				v-model="form.smsCode"
				:clearable="false"
				type="number"
				prefix-icon="shield-check-fill"
				:placeholder="t('请输入验证码')"
				:maxlength="4"
				:border="false"
				:pt="{
					className: parseClass([
						'!h-[90rpx] flex-1 !rounded-xl !px-4',
						[isDark, '!bg-surface-70', '!bg-white']
					]),
					prefixIcon: {
						className: 'mr-1'
					}
				}"
			>
			</cl-input>

			<view class="absolute right-0">
				<sms-btn
					:ref="refs.set('smsBtn')"
					:phone="form.phone"
					@success="showCode = true"
				></sms-btn>
			</view>
		</view> -->

修改登录方法

await request({
		url: "/api/Check/Login",
		method: "POST",
		data: {
			"account":phone,
			"password":password,
			"appKey":"openauth"
		}
	})
		.then((res) => {
			emit("success", res);
		})
		.catch((err) => {
			ui.showToast({
				message: (err as Response).message!
			});
		});

	loading.value = false;

        3.3、记住修改后端服务为自己的地址(启动OpenAuth.Net的服务路径)

config/proxy.ts

3.4、修改 /cool/store/user.ts   中设置token的方法,因为OpenAuth.Net的token与Cool Unix的框架有出入,所以需要根据实际情况进行修改,修改如下:

setToken(data: Token) {
		this.token = data.token;
		//OpenAuth.Net 没有刷新token  所以修改
		// 获取当前时间的时间戳
		const currentTime = Date.now();

		// 计算30天后的时间戳
		const thirtyDaysLater = currentTime + (30 * 24 * 60 * 60 * 1000);
		storage.set("token", data.token, thirtyDaysLater - 5);
		storage.set("refreshToken", data.token, thirtyDaysLater - 5);

		// // 访问token,提前5秒过期,防止边界问题
		// storage.set("token", data.token, data.expire - 5);
		// // 刷新token,提前5秒过期
		// storage.set("refreshToken", data.refreshToken, data.refreshExpire - 5);
	}

3.5、修改 /cool/service/index.ts 中响应的判断,按照自己的实际需求修改如下:

else if (res.statusCode == 200) {
						if (res.data == null) {
							resolve(null);
						} else if (!isObject(res.data as any)) {
							resolve(res.data);
						} else {
							// 解析响应数据
							const { code, message, data } = parse<Response>(
								res.data ?? { code: 0 }
							)!;

							switch (code) {
								case 1000:
									resolve(data);
									break;
								case 200:
									resolve(res.data);
									break;
								default:
									reject({ message, code } as Response);
									break;
							}
						}
					} else {
						reject({ message: t("服务异常") } as Response);
					}

今日效果

在线题库

实现基础在线答题功能,具体效果如下:

实现代码

<template>
    <cl-page>
        <view>
            <cl-sticky>
                <cl-row :gutter="10" class="bg-white dark:bg-surface-800 p-4" justify="space-between">
                    <cl-col :span="8" flex>
                        <view class="icon-text-row">
                            <cl-icon name="shijian" :size="40" class=""></cl-icon>
                            <cl-countdown :minute="60" hide-zero></cl-countdown>
                        </view>
                    </cl-col>
                    <cl-col :span="8">
                        <view class="icon-text-row" flex>
                            <cl-icon name="pinglun_2" :size="40"></cl-icon>
                            {{subjectIndex+1}} / {{ subjectList.length }}
                        </view>
                    </cl-col>
                    <cl-col :span="8" flex>
                        <view class="icon-text-row">

                            <cl-icon name="wancheng" :size="40"></cl-icon>
                            提交
                        </view>
                    </cl-col>
                </cl-row>
            </cl-sticky>
        </view>
        <view class="swiper-mode bg-white dark:bg-surface-800">
            <swiper class="swiper-box bg-white dark:bg-surface-800" :current="subjectIndex" @change="SwiperChange">
                <swiper-item v-for="(subject, index) in subjectList" :key="index">
                    <scroll-view scroll-y="true" class="swiper-content">
                        <view class="swiper-content-item">
                            <view class="tag-mode">
                                <cl-tag type="success" plain>{{ questionType[subject.type].text }}</cl-tag>
                                <text>{{ subject.score }}分</text>
                            </view>
                            <view class="questionTitle-mode">
                                {{ subject.title }}
                            </view>
                            <view class="answer-mode">
                                <template v-if="subject.type === 0">
                                    <view class="answer-box" v-for="(option, indexT) in subject.optionList"
                                        :key="indexT" @click="radioboxChange(option, index, indexT)">
                                        <view class="radio-box"
                                            :class="option.id == subject.userAnswer ? 'radio-box-in' : ''">{{ option.id
                                            }}
                                        </view>
                                        <view class="text-box">{{ option.content }}</view>
                                    </view>
                                </template>
                                <template v-if="subject.type === 1">
                                    <view class="answer-box" v-for="(option, indexT) in subject.optionList"
                                        :key="indexT" @click="radioboxChange(option, index, indexT)">
                                        <view class="radio-box" style="border-radius: 4px;"
                                            :class="subject.userAnswer.includes(option.id) ? 'radio-box-in' : ''">
                                            {{ option.id }}
                                        </view>
                                        <view class="text-box">{{ option.content }}</view>
                                    </view>
                                </template>
                                <template v-if="subject.type === 2">
                                    <view class="answer-box" v-for="(option, indexT) in subject.optionList"
                                        :key="indexT" @click="radioboxChange(option, index, indexT)">
                                        <view class="radio-box"
                                            :class="option.id == subject.userAnswer ? 'radio-box-in' : ''">
                                            <uni-icons :type="option.id == 'Y' ? 'checkmarkempty' : 'closeempty'"
                                                :color="option.id == subject.userAnswer ? '#ffffff' : '#898EA2'"
                                                size="18"></uni-icons>
                                        </view>
                                        <view class="text-box">{{ option.content }}</view>
                                    </view>
                                </template>
                                <template v-if="subject.type === 3">
                                    <view class="answer-box">
                                        <cl-textarea :modelValue="(subject.userAnswer as string)" placeholder="请输入内容"
                                            @blur="" auto-height :maxlength='100' />
                                    </view>
                                </template>
                                <template v-if="subject.type === 4">
                                    <view class="answer-box">
                                        <cl-textarea :modelValue="(subject.userAnswer as any)" placeholder="请输入内容"
                                            @blur="" :height="150" :maxlength='500' />
                                    </view>
                                </template>
                                <view style="flex-direction: column;" v-show="subject.showAnswer">
                                    <cl-text>
                                        答案:{{ subject.correctAnswer }}
                                    </cl-text>
                                    <cl-text>
                                        解析:{{ subject.explanation }}
                                    </cl-text>
                                </view>
                            </view>
                        </view>
                    </scroll-view>
                </swiper-item>

            </swiper>
        </view>
        <view class="bottom-mode">
            <view class="operation-box" @click="visible =true">
                <cl-icon name="shijian" :size="40" class=""></cl-icon>
                <view class="operation-box-text">答题卡</view>
            </view>
            <view class="bottom-box">
                <cl-button @click="subjectList[subjectIndex].showAnswer = !subjectList[subjectIndex].showAnswer">提示</cl-button>
                <cl-button v-if="subjectList.length!=1" :disabled="subjectIndex==0"
                    @click="questionSelectOp('previous')">上一题</cl-button>
                <cl-button v-if="(subjectIndex != subjectList.length - 1)&&(subjectList.length!=1)"
                    @click="questionSelectOp('next')">下一题</cl-button>
                <cl-button v-if="(subjectIndex == subjectList.length - 1)||subjectList.length==1">提交</cl-button>
            </view>
        </view>
        <cl-popup v-model="visible" title="答题卡">
            <view class="popup-mode">
                <scroll-view  style="display: block !important;height: 80%;margin-left: 40rpx;">
                    <view v-for="(subject, index) in subjectList" :key="index" style="display: inline-block;width: 110rpx;">
                        <button class="popup-radio-box" :class="[subject.userAnswer.length===0?'line-grey':'bg-blue']"
                            @click="appointedSubject(index)">{{index+1}}</button>
                    </view>
                </scroll-view>
                <view class="popup-bottom bg-white dark:bg-surface-800">
                    <view>
                        共{{subjectList.length}}题,已答{{subjectIndex}}题
                    </view>
                    <view>
                        <cl-button>提交试卷</cl-button>
                    </view>
                </view>
            </view>
        </cl-popup>

    </cl-page>
</template>

<script lang="ts" setup>
import { computed, ref, watch, nextTick, onMounted } from "vue";
import { request } from "@/cool/service";



const subjectList = ref([] as any[]);
onMounted(() => {
    //console.log("detail onMounted",option);
});

onLoad((option:any) => {
    console.log("detail onLoad",option);
    getQustionData(option.id);
});


async function getQustionData(typeId: string) {
    //console.log("获取题目详情");
    await request({
        url: "/api/Questionss/Load?page=1&limit=1000&sqlWhere=typeid='" + typeId + "'",
    }).then((res) => {
        //console.log("题目详情:", res);
        if (res != null) {
            let newList = res.data.map((q: any) => ({
                'id': q.id,
                "title": q.questiontext || "未命名题目",
                "type": Number(q.questiontype),
                'typeName': q.questiontype == 0 ? '单选题' : q.questiontype == 1 ? '多选题' : q.questiontype == 2 ? '判断题' : q.questiontype == 3 ? '填空题' : '问答题',
                'score': q.score || 0,
                //拆分第一个字符为选项 后面的为内容
                "optionList": splitOptions(q.options) as any || [],
                "userAnswer": q.questiontype == 1 ? [] : "",
                "correctAnswer": q.correctanswer || "",
                "explanation": q.analysis || "",
                "showAnswer": false
            }));
            subjectList.value = shuffleArray(newList);
            //console.log("题目列表:", subjectList.value);
        }
    });
}

function shuffleArray(array) {
    for (let i = array.length - 1; i > 0; i--) {
        // 生成一个随机索引
        const j = Math.floor(Math.random() * (i + 1));
        // 交换元素
        [array[i], array[j]] = [array[j], array[i]];
    }
    return array; // 返回打乱后的数组
}


function splitOptions(input) {
     // 解析 JSON 字符串为数组
    const options = JSON.parse(input);
    const regex = /([A-Z])\.?\s*(.*)/; // 匹配选项和内容

    return options.map(option => {
        const match = option.trim().match(regex);
        return match ? { id: match[1], content: match[2] } : null; // 如果没有匹配,返回 null
    }).filter(item => item !== null); // 过滤掉 null 项
}

// const subjectList = ref([{
//     'id': '216378162783',
//     "title": "电流分有?",
//     "type": 0,
//     'typeName': '单选题',
//     'score': 3,
//     "optionList": [{
//         "id": "A",
//         "content": "直流"
//     }, {
//         "id": "B",
//         "content": "交流"
//     }, {
//         "id": "C",
//         "content": "直流和交流"
//     }],
//     "userAnswer": "",
//     "correctAnswer": "C",
//     "explanation": "电流分为直流和交流两种形式。",
//     "showAnswer": false
// },
// {
//     'id': '34345345345345',
//     "title": "酸菜鱼的味道?",
//     "type": 1,
//     'typeName': '多选题',
//     'score': 6,
//     "optionList": [{
//         "id": "A",
//         "content": "咸味",
//         "cheak": false
//     }, {
//         "id": "B",
//         "content": "辣味",
//         "cheak": false
//     }, {
//         "id": "C",
//         "content": "甜味",
//         "cheak": false
//     }, {
//         "id": "D",
//         "content": "酸味",
//         "cheak": false
//     }],
//     "userAnswer": [],
//     "correctAnswer": "A,D",
//     "explanation": "酸菜鱼的味道是咸味和酸味。",
//     "showAnswer": false
// }, {
//     'id': '34345345345345',
//     "title": "水是液体?",
//     'typeName': '判断题',
//     "type": 2,
//     'score': 3,
//     "optionList": [{
//         "id": "Y",
//         "content": "正确"
//     }, {
//         "id": "N",
//         "content": "错误"
//     }],
//     "userAnswer": "",
// },
// {
//     'id': '34345345345345',
//     "title": "床前(____)光,疑是地上霜。",
//     "type": 3,
//     'typeName': '填空题',
//     'score': 5,
//     "userAnswer": "",
//     "correctAnswer": "明月",
//     "explanation": "李白的《静夜思》:床前明月光,疑是地上霜。举头望明月,低头思故乡。",
//     "showAnswer": false
// },
// {
//     'id': '34345345345345',
//     "title": "什么美国要限制华为?",
//     "type": 4,
//     'typeName': '问答题',
//     'score': 8,
//     "userAnswer": "",
//     "correctAnswer": "因为华为的5G技术领先美国",
//     "explanation": "美国担心华为的技术领先会威胁到其全球科技霸主地位。",
//     "showAnswer": false
// },
// ] as any);

const questionType = ref([{
    type: 0,
    text: "单选题",
    questionList: [] as any
}  as any,
{
    type: 1,
    text: "多选题",
    questionList: [] as any
}as any,
{
    type: 2,
    text: "判断题",
    questionList: [] as any
}as any,
{
    type: 3,
    text: "填空题",
    questionList: [] as any    
}as any,
{
    type: 4,
    text: "问答题",
    questionList: [] as any
}as any
] as any);

const subjectIndex = ref(0);
const currentType = ref(0);
const visible = ref(false);
const showAnswer = ref(false); // 控制答案与解析显示


function SwiperChange(e) {
    let index = e.detail.current;
    if (index != undefined) {
        subjectIndex.value = index;
        currentType.value = subjectList.value[index].type;
    }
}

function questionSelectOp(type) {
    if (type == 'previous') {
        subjectIndex.value -= 1;
    } else if (type == 'next') {
        subjectIndex.value += 1;
    }
}

function radioboxChange(e, index, indexT) {
    const subject = subjectList.value[index];
    if ([0, 2].includes(subjectList.value[index].type)) {
        
        subject.userAnswer = e.id;
        // 判断是否答对
        if (e.id === subject.correctAnswer) {
            subject.showAnswer = false;
            // 自动跳到下一题
            if (subjectIndex.value < subjectList.value.length - 1) {
                subjectIndex.value += 1;
            }
        } else {
            subject.showAnswer = true; // 答错显示答案与解析
        }
    } else if (subjectList.value[index].type === 1) {
         // 多选逻辑
        subject.optionList[indexT].cheak = !subject.optionList[indexT].cheak;
        const checkedIds = subject.optionList.filter(opt => opt.cheak).map(opt => opt.id);
        subject.userAnswer = checkedIds;

        // 正确答案转数组并排序
        const correctArr = subject.correctAnswer.split(',').map(s => s.trim()).sort();
        const userArr = checkedIds.slice().sort();

        // 只要选项数量大于等于正确答案数量就判断
        if (checkedIds.length >= correctArr.length) {
            const isRight = correctArr.length === userArr.length && correctArr.every((v, i) => v === userArr[i]);
            if (isRight) {
                subject.showAnswer = false;
                if (subjectIndex.value < subjectList.value.length - 1) {
                    subjectIndex.value += 1;
                }
            } else {
                subject.showAnswer = true; // 只要数量达到或超过就显示答案和解析
            }
        } else {
            subject.showAnswer = false; // 还没选满时不显示答案
        }
    };

}

function appointedSubject(e) {
    subjectIndex.value = e;
    visible.value = false;
}
</script>

<style lang="scss" scoped>
.icon-text-row {
    display: flex;
    align-items: center;
    gap: 8px; // 图标和文字间距,可调整
    flex-direction: row;
    justify-content: center;
}



.swiper-mode {
    width: 100%;
    height: calc(100% - 108px);
    background-color: #FFFFFF;

    .swiper-box {
        width: 100%;
        height: 100%;

        .swiper-content {
            width: 100%;
            height: 100%;

            .swiper-content-item {
                width: 100%;
                padding: 14px 14px 30px;
                box-sizing: border-box;
            }
        }

    }
}

.tag-mode {
    width: 100%;
    height: 30px;
    font-size: 13px;
    color: #6B6F75;
    align-items: center;
    justify-content: space-between;
    display: flex;
    margin-bottom: 14px;
    flex-direction: row;
}

.questionTitle-mode {
    width: 100%;
    line-height: 26px;
    font-size: 15px;
    color: #000000;
}

.answer-mode {
    width: 100%;
    margin-top: 25px;

    .answer-box {
        width: 100%;
        display: flex;
        margin-bottom: 20px;
        flex-direction: row;

        .radio-box {
            width: 36px;
            height: 36px;
            font-size: 15px;
            font-weight: bold;
            color: #898EA2;
            border-radius: 100px;
            border: 1.5px solid #F0F1F5;
            box-sizing: border-box;
            align-items: center;
            justify-content: center;
            display: flex;
            margin-right: 18px;
        }

        .radio-box-in {
            color: #ffffff;
            background-color: #0080F8;
            border: 1.5px solid #0080F8;
        }

        .text-box {
            width: calc(100% - 54px);
            line-height: 26px;
            font-size: 15px;
            color: #000000;
            // align-items: center;
            display: flex;
        }
    }

    //文本


    //文本输入
    .answer-text-box {
        width: 100%;
        padding: 6px 6px 0;
        border: 1px solid #E6E6E6;
        box-sizing: border-box;

        .answer-textarea {
            width: 100%;
            padding-left: 4px;

            ::v-deep {
                uni-textarea {
                    width: 100%;
                    line-height: 22px;
                    font-size: 14px;
                    min-height: 60px;
                }
            }
        }

        .answer-textareaT {
            width: 100%;
            padding-left: 4px;

            ::v-deep {
                uni-textarea {
                    width: 100%;
                    line-height: 22px;
                    font-size: 14px;
                    min-height: 80px;
                }
            }
        }

        .text-num {
            font-size: 12px;
            color: #9EA1A3;
            text-align: end;
            padding: 6px 0;
            box-sizing: border-box;
        }

        ::v-deep .uni-easyinput__content-textarea {
            padding-bottom: 16px;
        }
    }
}

.bottom-mode {
    width: 100%;
    min-width: 0; // 防止子项溢出折叠
    height: 56px;
    padding: 0 20px;
    box-sizing: border-box;
    // background-color: #fff;
    border-top: 1px solid #E6E6E6;
    display: flex;
    flex-direction: row;
    align-items: center;
    justify-content: space-between;

    .operation-box {
        display: flex;
        align-items: center;
        gap: 8px;
        flex-shrink: 0;
        flex-direction: row;
        justify-content: center;
    }

    .bottom-box {
        display: flex;
        align-items: center;
        gap: 12px;
        flex-shrink: 0;
        flex-direction: row;
        justify-content: center;
    }
}



.popup-mode {
		width: 100%;
		height: 60vh;
		border-radius: 12px 12px 0 0;
        position: relative; // 新增

        .popup-radio-box {
                width: 33px;
                height: 33px;
                font-size: 14px;
                font-weight: 500;
                // border-radius: 100px;
                background-color: #f4f5f6;
                box-sizing: border-box;
                align-items: center;
                justify-content: center;
                display: flex;
                margin: 10px;
                flex-direction: row;
                transition: background 0.2s;
            }

		.popup-bottom {
			width: 100%;
			// height: 4vh;
			border-top: 1px solid #E6E6E6;
			// padding: 0 5px;
			box-sizing: border-box;
			align-items: center;
			justify-content: space-between;
			display: flex;
            flex-direction: row;			 
            position: absolute; // 新增
            left: 0;
            bottom: 0;
            height: 56px; // 可根据实际按钮高度调整
            // background: #fff; // 避免内容透出
            padding: 0 16px;
		}
	}

    .scroll-view {
        height: calc(60vh - 56px); // 保证不会被底部遮挡
        overflow-y: auto;
        display: flex;
        flex-direction: row !important;
        
    }

    .line-grey {
    background-color: #f4f5f6 !important;
    color: #000 !important;
    border: 1px solid #e6e6e6 !important;
}

.bg-blue {
    background-color: #0080F8 !important;
    color: #fff !important;
    border: 1px solid #0080F8 !important;
}

:global(.uni-scroll-view-content){
    // flex-wrap: wrap !important;
    display:unset !important;
    // height: 80%;
    // width: 100%;
    // margin-left: 25px;
}
</style>

小程序体验地址

本文章已经生成可运行项目
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

不学习何以强国

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值