Initial commit

This commit is contained in:
tanxin
2026-10-29 10:08:39 +08:00
commit 4c6243a274
124 changed files with 17359 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
node_modules/
unpackage/
.hbuilderx/
.env
.env.*
*.keystore
*.jks
*.p12
+51
View File
@@ -0,0 +1,51 @@
<script setup lang="ts">
// #ifdef APP-ANDROID || APP-HARMONY
let firstBackTime = 0
// #endif
onLaunch(() => {
console.log('App Launch')
})
onAppShow(() => {
console.log('App Show')
})
onAppHide(() => {
console.log('App Hide')
})
// #ifdef APP-ANDROID || APP-HARMONY
onLastPageBackPress(() => {
console.log('App LastPageBackPress')
if (firstBackTime == 0) {
uni.showToast({
title: '再按一次退出应用',
position: 'bottom',
})
firstBackTime = Date.now()
setTimeout(() => {
firstBackTime = 0
}, 2000)
} else if (Date.now() - firstBackTime < 2000) {
firstBackTime = Date.now()
uni.exit()
}
})
onExit(() => {
console.log('App Exit')
})
// #endif
</script>
<style>
/*每个页面公共css */
.uni-row {
flex-direction: row;
}
.uni-column {
flex-direction: column;
}
</style>
+557
View File
@@ -0,0 +1,557 @@
# 爱学蝶变家长端
## 1. 文档用途
本文档是本项目的实现契约。阅读本文档后,其他 AI 应能够在不阅读原始页面代码的情况下,使用 uni-app x 重新生成结构、功能、校验、接口调用、状态共享和页面跳转基本一致的家长端应用。
实现或重构时必须以本文档为准,并保持以下行为不变:
- 登录、验证码登录、找回密码和家长注册流程完整可用。
- 注册流程最多绑定 3 名学生,至少绑定 1 名学生才能继续。
- 学生信息不能通过 URL 参数传递。
- Web 与 Android/iOS 的扫码实现必须区分平台。
- 业务 API 使用统一请求封装,当前默认启用 mock 响应。
- 表单和页面必须适配手机窄屏及底部安全区。
## 2. 技术基线
| 项目项 | 当前约定 |
| --- | --- |
| 框架 | uni-app x |
| Vue | Vue 3,`<script setup lang="ts">` |
| 渲染模式 | Vapor 配置开启;当前 Web 编译日志显示 VDOM,需以实际目标平台编译结果为准 |
| HBuilderX | 5.33 |
| 目标平台 | Web、Android、iOS |
| 样式隔离 | style isolation 2.0 |
| API 状态 | `utils/request.ts` 中 `USE_MOCK_API = true` |
| mock 基地址 | `https://mock-api.example.com` |
| Web 扫码 | `@zxing/browser` + 浏览器 `HTMLVideoElement` |
| 原生扫码 | uni-app x 内置 `camera`,`mode="scanCode"` |
### 2.1 uni-app x 约束
- 所有 `display: flex` 必须显式声明 `flex-direction`。
- 页面样式只使用 class 选择器,不使用标签选择器、子选择器和通配选择器。
- 长页面使用纵向 `scroll-view`,底部固定操作区时正文必须预留底部空间。
- 密码框直接使用原生 `input type="password"`,不要添加显示/隐藏按钮。
- 传统 uni-app 的 `uni-easyinput`、`uni-forms`、`uni-icons` 不能直接当作 uni-app x Vapor 组件使用。
- 项目已安装的 uni-ui x 组件可通过 EasyCom 使用,例如 `uni-badge-view`、`uni-nav-bar`、`uni-tab` 等。没有对应官方组件时使用内置 `input`、`button`、`form`、`picker`、`scroll-view`。
- 不使用 emoji、箭头字符、叉号字符作为界面图标。需要状态标记时使用 CSS 图形或明确的文字按钮。
## 3. 页面与路由
| 页面 | 路径 | 页面职责 | 主要进入方式 |
| --- | --- | --- | --- |
| 密码登录 | `pages/login/index` | 手机号密码登录、记住密码、跳转验证码登录、找回密码、注册 | 应用入口、退出登录、未授权跳转 |
| 验证码登录 | `pages/login/verify-code` | 手机号验证码登录 | 密码登录页点击“验证码登录” |
| 忘记密码 | `pages/login/forgot-password` | 发送验证码、验证验证码、重置密码 | 密码登录页点击“忘记密码” |
| 注册入口 | `pages/register/index` | 清空注册学生状态并转到扫码绑定页 | 登录页点击“注册” |
| 扫码绑定学生 | `pages/register/scan-student` | 扫码、解析二维码、校验并添加学生 | 注册入口、输入页切换 |
| 输入学生账号 | `pages/register/input-student` | 手动填写学生账号和姓名并校验 | 扫码页切换 |
| 设置家长账号 | `pages/register/parent-account` | 填写手机号、关系、密码并提交注册 | 两个学生绑定页点击“下一步” |
| 注册完成 | `pages/register/complete` | 展示注册结果和已绑定学生 | 注册成功后 redirect |
| 首页 | `pages/index/index` | 登录后的学生概览、预警和常用入口 | 登录成功、完成页返回首页、底部导航 |
| 学情 | `pages/study/index` | 周综合表现、学习趋势和学科表现 | 底部导航“学情” |
| 任务 | `pages/tasks/index` | 待完成、即将截止和近期完成的学习任务 | 底部导航“任务” |
| 我的 | `pages/profile/index` | 家长账户信息、设置入口和退出登录 | 底部导航“我的” |
| 验证学生 | `pages/profile/verify-student` | 登录后扫码或手动验证学生,不创建绑定 | 我的页面入口 |
`pages/register/index` 是流程初始化页,不展示业务内容。`onLoad` 中调用 `resetRegistrationStudents()`,然后 `redirectTo('/pages/register/scan-student')`。
## 4. 全局状态
### 4.1 注册学生状态
文件:`store/registration.ts`
```ts
export const registrationStudents = ref<StudentInfo[]>([])
```
规则:
- 扫码页、手动输入页、家长账号页、完成页都直接 import 同一个 `registrationStudents`。
- 不通过页面 URL 传递学生对象,不使用 `JSON.stringify` 拼接导航参数。
- 注册入口进入时清空列表;注册完成页读取当前列表用于展示。
- 学生列表对象类型为 `StudentInfo`:`id`、`name`、`account`、`avatar`、`grade`、`class`。
### 4.2 用户状态
文件:`store/user.ts`
登录或注册成功后调用 `setUserInfo(userInfo)`,保存:
- `token`
- `refreshToken`
- `userInfo`
本地存储 key:`token`、`refreshToken`、`userInfo`、`rememberInfo`。
密码登录页 `onLoad` 调用 `getRememberInfo()` 恢复手机号、密码和记住密码状态。用户取消记住密码时删除 `rememberInfo`。
### 4.3 首页学生资料缓存
文件:`store/home.ts`
- `homeStudentProfile` 仅在当前登录会话内保存首页学生资料,不写入本地存储。
- 首页首次进入且缓存为空时调用 `apiGetStudentHomeProfile()`;从学情、任务、我的切回首页时直接复用缓存,不重复请求。
- 首页 `scroll-view` 开启 `refresher-enabled`,用户下拉触发 `@refresherrefresh` 后强制重新请求,并用新响应替换缓存。
- `clearUserInfo()` 会同时清空该缓存,确保退出登录后的下一次登录重新获取学生信息。
## 5. 页面详细契约
### 5.1 密码登录页
路径:`/pages/login/index`
#### UI
- 顶部显示 `/static/logo.png`、应用名和副标题。
- 表单包含手机号输入框、密码输入框、记住密码、忘记密码入口。
- 密码输入使用原生 `type="password"`,不添加额外按钮。
- 底部或表单下方提供“验证码登录”和“注册”入口。
- 登录按钮根据 `canSubmit` 展示可用/禁用状态。
#### 状态
```ts
type LoginForm = {
phone: string
password: string
remember: boolean
}
```
`canSubmit`:手机号匹配 `/^1[3-9]\d{9}$/` 且密码长度至少 6 位。
#### 行为
1. 点击登录时,`canSubmit` 为 false 直接返回。
2. 调用 `apiLogin({ phone, password, remember })`。
3. 成功后调用 `setUserInfo` 和 `saveRememberInfo`。
4. 显示“登录成功”,约 1 秒后 `reLaunch('/pages/index/index')`。
5. 点击“验证码登录”使用 `navigateTo('/pages/login/verify-code')`。
6. 点击“忘记密码”使用 `navigateTo('/pages/login/forgot-password')`。
7. 点击“注册”使用 `navigateTo('/pages/register/index')`。
### 5.2 验证码登录页
路径:`/pages/login/verify-code`
#### 状态与校验
- `phone`:手机号。
- `code`:验证码。
- `canSubmit`:手机号有效且验证码长度为 6。
#### 行为
- 使用 `CountdownBtn` 组件发送验证码,发送类型为 `login`。
- 手机号不合法时提示“请输入正确的手机号”,不发请求。
- 发送成功后启动 60 秒倒计时,按钮显示剩余秒数,结束后显示“重新发送”。
- 调用 `apiLoginByCode({ phone, code })` 登录。
- 成功后保存用户信息并 `reLaunch('/pages/index/index')`。
- “密码登录”使用 `navigateBack()`。
- “注册”使用 `navigateTo('/pages/register/index')`。
### 5.3 忘记密码页
路径:`/pages/login/forgot-password`
页面是 3 个步骤,由 `step` 控制:
#### Step 1:验证手机号
- 输入手机号和 6 位验证码。
- 使用 `CountdownBtn`,验证码类型为 `forgot-password`。
- `canStep1`:手机号有效且验证码长度为 6。
- 调用 `apiVerifyCode({ phone, code })`。
- 成功后保存返回的 `resetToken`,进入 Step 2。
#### Step 2:设置新密码
- 输入新密码和确认密码。
- 两个密码长度必须为 6 至 20 位,且完全一致。
- 不一致提示“两次密码输入不一致”。
- 调用 `apiResetPassword({ phone, newPassword, resetToken })`。
- 成功后进入 Step 3。
#### Step 3:完成
- 显示重置成功状态。
- 点击“去登录”使用 `navigateTo('/pages/login/index')`。
- 页面返回操作使用 `navigateBack()`。
### 5.4 注册入口页
路径:`/pages/register/index`
只做初始化:
1. 调用 `resetRegistrationStudents()`。
2. `redirectTo('/pages/register/scan-student')`。
这样可以保证用户重新进入注册流程时不会带入上一次未完成注册的学生列表。
### 5.5 扫码绑定学生页
路径:`/pages/register/scan-student`
#### UI
- 显示三步进度:扫码绑定孩子、家长账号与密码、注册完成。
- 提供扫码绑定和输入学生账号两个切换入口。
- 显示扫码区域、学生列表和底部“下一步”按钮。
- 学生数量使用 `uni-badge-view` 展示,例如 `1/3`。
- 列表中的移除操作使用文字按钮“移除”,不使用叉号字符。
#### 扫码平台差异
Web 条件编译:
- 创建原生 `HTMLVideoElement` 并插入 `#web-scan-video-container`。
- 设置 `autoplay`、`muted`、`playsInline`。
- 使用 `BrowserQRCodeReader.decodeFromConstraints`,摄像头约束使用后置摄像头 `facingMode: 'environment'`。
- 页面挂载时启动扫描,页面卸载时停止扫描并清理控制器。
- 识别到结果后停止当前扫描,5 秒后重新启动;最多绑定 3 人后不再重启。
Android/iOS 条件编译:
- 使用内置 `<camera mode="scanCode" device-position="back" @scancode="handleCameraScan">`。
- 摄像头错误提示“无法使用摄像头,请检查权限”。
#### 二维码格式
二维码文本必须是:
```text
学生账号,学生姓名
```
使用第一个逗号拆分:逗号前是 `account`,逗号后是 `name`。缺少逗号、账号为空或姓名为空时提示“二维码格式无效”。
#### 防重复与绑定规则
- `MAX_STUDENTS = 3`。
- 扫码时若正在验证,或距离上次扫码不足 `5000ms`,直接忽略。
- 请求前按 `student.account` 检查重复。
- 请求成功后再次按返回数据的 `account` 检查重复。
- 成功调用 `apiVerifyStudentQRCode({ qrCode, account, name })`,将返回学生 push 到共享列表。
- 没有学生时点击下一步提示“请至少绑定一个学生”。
- 有学生时 `navigateTo('/pages/register/parent-account')`。
- 切换到手动输入使用 `redirectTo('/pages/register/input-student')`。
### 5.6 手动输入学生页
路径:`/pages/register/input-student`
#### 表单
```ts
type StudentForm = {
account: string
name: string
}
```
#### 校验顺序
1. 账号和姓名不能为空。
2. 学生数量不能达到 3 人上限。
3. 当前列表不能已有相同账号。
4. 姓名必须匹配 `/^[\u4e00-\u9fa5]{2,10}$/`,即 2 至 10 个汉字。
#### 行为
- 调用 `apiVerifyStudentAccount({ account, name })`。
- 成功后再次检查返回账号是否重复,再 push 到 `registrationStudents`。
- 成功后清空输入表单并提示“添加成功”。
- 点击学生“移除”前使用 `uni.showModal` 二次确认。
- 点击“扫码绑定”使用 `redirectTo('/pages/register/scan-student')`。
- 点击“下一步”前必须至少有一名学生,然后 `navigateTo('/pages/register/parent-account')`。
### 5.7 设置家长账号页
路径:`/pages/register/parent-account`
#### 表单
```ts
type ParentAccountForm = {
parentName: string
phone: string
relationship: string
password: string
confirmPassword: string
}
```
家长姓名为必填项,必须匹配 `/^[\u4e00-\u9fa5]{2,10}$/`,即 2 至 10 个汉字。
关系选择器使用内置 `picker`,选项固定为:
```text
爸爸、妈妈、爷爷、奶奶、外公、外婆、其他亲属
```
密码和确认密码均使用原生 `type="password"`,不额外添加显示/隐藏按钮。
#### 提交条件
`canSubmit` 同时满足:
- 手机号匹配 `/^1[3-9]\d{9}$/`。
- 家长姓名匹配 `/^[\u4e00-\u9fa5]{2,10}$/`。
- 已选择关系。
- 密码长度 6 至 20 位。
- 两次密码完全一致。
#### 提交流程
1. 不满足条件时按家长姓名、手机号、关系、密码长度、密码一致性的顺序提示。
2. 调用 `apiCheckPhone(form.phone)` 检查手机号。
3. `exists === true` 时提示手机号已注册,停止注册并引导登录。
4. 将共享学生列表转换为 ID 数组:`students.value.map(s => s.id)`。
5. 调用:
```ts
apiRegister({
parentName,
phone,
relationship,
password,
students: studentIds
})
```
6. 注册成功后调用 `setUserInfo`。
7. 使用 `redirectTo('/pages/register/complete')`,不把学生信息放到 URL。
### 5.8 注册完成页
路径:`/pages/register/complete`
- 直接读取 `registrationStudents`,不解析路由参数。
- 显示注册完成、绑定学生数量和学生列表。
- “去登录”使用 `reLaunch('/pages/login/index')`。
- “返回首页”使用 `reLaunch('/pages/index/index')`。
### 5.9 首页
路径:`/pages/index/index`
首页按参考视觉稿实现为三段式结构:固定顶部 Header、中间独立滚动内容、固定底部 Footer。页面根节点使用纵向 flex,只有 `content-scroll` 设置 `scroll-y="true"`,因此上下区域不会随着中间内容滚动。
#### 5.9.1 顶部 Header
- 背景为紫色渐变,左右下角保持页面顶部的整幅色块效果。
- 左侧依次显示问候语“下午好,王女士”、主标题“孩子今天的学习情况”、数据更新时间说明。
- 右侧是紫色半透明圆形通知按钮,内部显示白色铃铛和黄色未读点。
- Header 属于根布局的固定子节点,不放进 `scroll-view`。
#### 5.9.2 学生概览卡
- 白色圆角卡片,包含学生头像、姓名“张一诺”、年级标签“初二”和“切换”按钮。
- 学生头像采用紫色圆角底和 CSS 图形字符组合,不依赖不兼容的图标组件。
- 学生头像、姓名、年级和综合等级通过 `apiGetStudentHomeProfile()` 获取;头像为空时显示姓名首字作为降级展示。
- 学生资料和六项学习概览仅首次进入首页自动请求;切换一级页面后返回首页复用会话缓存。用户下拉刷新,重新获取并替换当前学生的资料和概览。
- 点击“切换”使用内置 `page-container` 从底部打开学生选择卡片。首次打开时调用 `apiGetBoundStudents()`,每张卡片展示头像、姓名、账号、年级和学校。
- 选择卡片仅更新临时选择;点击“确定”后才调用 `apiGetStudentHomeProfile(studentId)` 更新首页。点击“取消”、遮罩返回或下滑关闭不改变首页当前学生。
- 预警提醒通过 `apiGetStudentAlerts(studentId, 1, 20, 3)` 获取,首页最多展示 3 条。点击“全部预警”或顶部通知图标进入 `pages/alerts/index` 查看当前学生的预警列表。
- 常用入口固定展示“今日学习”“周报月报”“英语词汇”“成长激励”四个板块,仅保留图标和名称。
- 显示综合等级徽章、标签“综合等级”和绿色等级值。接口等级枚举 `excellent`、`good`、`qualified`、`needs_improvement` 分别转换为“优秀”“良好”“合格”“待提升”。
- 下方为 3 列、2 行指标网格:今日上线、今日打卡、今日学习时长、今日正确率、本周学习天数、待完成作业。
- 指标值分别为“已上线”“已打卡”“2h35m”“92%”“5/5 天”“1 个”,绿色表示完成状态,蓝色表示周统计,橙色表示待处理事项。
#### 5.9.3 预警提醒卡
- 白色圆角卡片标题为“预警提醒”,右侧显示“全部预警 >”。
- 三条提醒使用浅灰背景的横向条目,每条由彩色标签和可换行正文组成。
- 提醒内容依次为作业未完成、正确率下降、读完失败。
- 黄色表示待提交事项,红色表示成绩或任务异常。
#### 5.9.4 常用入口
- 标题为“常用入口”,下方显示学习报告、成长记录两个白色入口卡片。
- 每个入口包含 CSS 图形图标、标题和辅助说明。
- 入口位于 `content-scroll` 中,内容超出屏幕高度时随中间区域滚动。
#### 5.9.5 底部 Footer
- 白色固定底栏,顶部有浅灰分割线,并预留 `var(--uni-safe-area-inset-bottom)` 安全区。
- 四项导航从左到右为:首页、学情、任务、我的。
- 当前首页使用紫色图标和文字,其余导航使用灰色图标和文字。
- 图标使用页面内独立 class 节点绘制,避免 emoji、字符箭头、叉号和传统 `uni-icons`。
- Footer 属于根布局的固定子节点,不放进 `scroll-view`。
#### 5.9.6 视觉参数
- 页面背景:`#F5F7FB`。
- 主色:`#554BE9` 至 `#8236E9` 的紫色系,正文使用深蓝灰,状态使用绿、蓝、橙、红区分。
- 内容卡片圆角约 12 至 16px;Header 不做浮动卡片处理。
- 内容左右边距 20px,卡片内边距约 16px,指标卡使用 3 列等宽布局。
- 所有 flex 容器必须显式设置 `flex-direction`;页面样式只使用 class 选择器。
### 5.10 一级导航页面
首页、学情、任务、我的共用 `components/main-tab-bar.uvue`。组件接收 `activeTab`,高亮当前入口;点击其他入口使用 `uni.reLaunch` 跳转,避免一级页面堆叠在导航栈中。每个一级页面都由固定 Header、可滚动 `scroll-view` 和固定 Footer 组成。
#### 5.10.1 学情
- 路径:`/pages/study/index`。
- 显示学生名和年级、本周综合得分、进度条、近 7 天柱状学习趋势和数学、语文、英语的学科表现。
- 该页面当前使用展示数据,后续接入学习报告接口时保持现有字段布局。
#### 5.10.2 任务
- 路径:`/pages/tasks/index`。
- 顶部统计待完成、已完成、即将截止数量。
- 中间展示待完成任务卡、截止时间、任务状态和查看入口;下方展示近期已完成任务。
- 该页面当前使用展示数据,后续接入任务接口时保持任务卡字段布局。
#### 5.10.3 我的
- 路径:`/pages/profile/index`。
- 从本地 `userInfo` 读取并显示家长昵称和手机号。
- 包含孩子管理、账号与安全、消息设置、帮助与反馈、关于爱学蝶变入口;尚未接入的功能统一提示“功能开发中”。
- 点击“退出登录”调用 `clearUserInfo()` 后 `reLaunch('/pages/login/index')`。
- 扫码及手动入口进入学生验证页;该页先读取已绑定列表,加载失败时可重试,不将失败视为空列表。最多 3 名的限制及重复提示基于已加载列表。
- 扫码复用 `qr-scanner` 和 `apiVerifyStudentQRCode`,手动输入使用 `apiVerifyStudentAccount`。验证通过仅展示“已验证,尚未绑定”,不会写入已绑定列表或注册状态;登录后新增绑定需要接入真实后端接口。当前项目默认 mock,验证结果也可能来自 mock,不能作为真实身份或绑定凭据。
## 6. 接口契约
所有接口必须通过 `api/*.ts` 调用 `utils/request.ts` 的统一方法,不在页面中直接调用 `uni.request`。
### 6.1 响应格式
```ts
type ApiResponse<T> = {
code: number
message: string
data: T | null
}
```
`code === 0` 表示业务成功。失败时统一 toast,接口返回 `null` 时页面不得继续执行成功跳转。
### 6.2 认证接口
| 函数 | 方法 | 地址 | 参数 |
| --- | --- | --- | --- |
| `apiLogin` | POST | `/api/auth/login` | `phone`、`password`、可选 `remember` |
| `apiLoginByCode` | POST | `/api/auth/login-by-code` | `phone`、`code` |
| `apiSendCode` | POST | `/api/auth/send-code` | `phone`、`type` |
| `apiVerifyCode` | POST | `/api/auth/forgot-password/verify` | `phone`、`code` |
| `apiResetPassword` | POST | `/api/auth/forgot-password/reset` | `phone`、`newPassword`、`resetToken` |
| `apiRegister` | POST | `/api/auth/register` | `parentName`、`phone`、`relationship`、`password`、`students` |
验证码类型:`login`、`register`、`forgot-password`。当前注册页面不发送注册验证码,但类型保留用于后续扩展。
### 6.3 学生接口
| 函数 | 方法 | 地址 | 参数 |
| --- | --- | --- | --- |
| `apiVerifyStudentQRCode` | POST | `/api/student/verify-qrcode` | `qrCode`、`account`、`name` |
| `apiVerifyStudentAccount` | POST | `/api/student/verify-account` | `account`、`name` |
| `apiCheckPhone` | GET | `/api/parent/check-phone?phone=...` | 手机号 |
| `apiGetStudentHomeProfile` | GET | `/api/parent/home-profile` | 无;服务端由 Bearer Token 确定当前家长的默认绑定学生 |
| `apiGetBoundStudents` | GET | `/api/parent/students` | 无;返回当前家长绑定的学生列表和默认学生 ID |
| `apiGetStudentHomeMetrics` | GET | `/api/parent/home-metrics?studentId={studentId}` | 无;返回今日上线、打卡、时长、正确率、本周学习天数、待完成作业和更新时间 |
| `apiGetStudentAlerts` | GET | `/api/parent/alerts?studentId={studentId}&limit=3` | 无;首页最多返回 3 条预警,列表页以 `page`、`pageSize` 分页获取 |
`apiGetStudentHomeProfile(studentId?)` 返回 `id`、`name`、`account`、`grade`、`school`、`avatar`、`comprehensiveLevel`。传入 `studentId` 时真实服务必须校验请求 token 与该学生的绑定关系;未登录返回 401,无绑定权限返回 403。
`apiGetStudentHomeMetrics(studentId)` 返回 `onlineToday`、`checkedInToday`、`learningMinutesToday`、`accuracyToday`、`learningDaysThisWeek`、`pendingHomeworkCount` 和 `updatedAt`。其中学习时长的单位为分钟;无答题记录时 `accuracyToday` 为 `null`,页面显示 `--`。
`apiGetStudentAlerts` 的单条预警包含 `level`、`title`、`content`、`occurredAt` 和 `isRead`。真实服务必须校验当前家长与 `studentId` 的绑定关系。
首页下拉刷新调用学生资料和学习概览接口时传入 `showLoading = false`,仅显示 `scroll-view` 自身的刷新状态,不展示全局 loading 覆盖层。
### Mock 网络时延
`USE_MOCK_API = true` 时,所有 mock 接口统一随机延迟 `0` 至 `2000ms` 后返回,用于模拟正常网络环境。真实后端接口不施加额外时延。
### 6.4 mock 行为
当 `USE_MOCK_API = true` 时,请求约 150ms 后返回成功:
- 登录、注册返回测试家长 `test-parent-001`、`test-token` 和 `test-refresh-token`。
- 发送验证码返回 `expireTime: 300`。
- 找回密码验证码返回 `test-reset-token`。
- 学生验证根据请求账号和姓名生成学生对象,学生 ID 为 `test-student-${account}`。
- 检查手机号始终返回 `exists: false`。
- 首页学生信息返回测试学生“张一诺”、年级“初二”、综合等级 `excellent`。
## 7. 统一请求和错误处理
- 请求拦截器将相对路径拼接到 `BASE_URL`。
- 自动设置 `Content-Type: application/json`。
- 本地存在 token 时自动设置 `Authorization: Bearer <token>`。
- 默认显示全局 loading,使用引用计数避免并发请求提前隐藏 loading。
- HTTP 非 200 或业务 `code !== 0` 时显示错误提示。
- 401 时清除 `token`、`userInfo` 并 `reLaunch('/pages/login/index')`。
- 403、404、500 分别提示无权限、资源不存在、服务器错误。
- 页面提交、验证码发送、学生校验期间应避免重复操作。
## 8. AI 复刻顺序
生成相似项目时按以下顺序实施:
1. 创建 uni-app x Vue 3 项目并启用 TypeScript、Vapor、style isolation 2.0。
2. 创建 `api/auth.ts`、`api/student.ts`、`utils/request.ts`、`store/user.ts`、`store/registration.ts`。
3. 先实现登录和用户持久化,再实现验证码登录和找回密码。
4. 创建注册入口、扫码绑定、手动输入、家长账号、完成页五个页面。
5. 使用模块级 `registrationStudents` 贯穿注册流程,禁止用 URL 传学生对象。
6. 使用条件编译实现 Web ZXing 扫码和原生 camera 扫码。
7. 添加所有表单校验、上限校验、重复学生校验、loading、toast 和确认弹窗。
8. 最后实现首页概览并统一页面间跳转方式。
9. 编译 Web 和 Android,确认页面无横向溢出、固定底栏不遮挡内容、扫码资源正确释放。
## 9. 验收清单
- [ ] 密码登录成功后保存 token 并进入首页。
- [ ] 记住密码可以恢复和清除。
- [ ] 验证码发送有 60 秒倒计时,倒计时期间不可重复发送。
- [ ] 验证码登录成功后进入首页。
- [ ] 找回密码必须依次完成验证码验证和新密码设置。
- [ ] 注册入口会清空旧学生列表。
- [ ] Web 扫码使用浏览器摄像头,原生端使用 camera 扫码。
- [ ] 二维码按“账号,姓名”解析,格式错误有提示。
- [ ] 学生最多 3 人,至少 1 人才能下一步。
- [ ] 相同学生账号不能重复添加。
- [ ] 手动输入姓名必须是 2 至 10 个汉字。
- [ ] 注册页关系为必选项,密码为 6 至 20 位且两次一致。
- [ ] 学生信息从不出现在页面 URL 中。
- [ ] 注册成功后完成页能显示共享学生列表。
- [ ] 401 会清理登录态并回到登录页。
- [ ] 所有密码框使用原生默认密码样式,无额外显隐按钮。
- [ ] 没有 emoji、箭头字符或叉号字符充当 UI 图标。
- [ ] Web 编译成功且控制台没有新增错误。
## 10. 常用命令
```powershell
# Web 发行构建
& "F:\HBuilderX\cli.exe" publish web --platform Web --project "E:\project\App.k12.Parents" --webTitle "爱学蝶变家长端"
# Android Vapor 编译验证
& "F:\HBuilderX\cli.exe" launch app-android --project "E:\project\App.k12.Parents" --compile true
```
Web 构建产物目录:`unpackage/dist/build/web`。
## 11. 文件职责
| 文件 | 职责 |
| --- | --- |
| `manifest.json` | 应用名称、AppID、平台配置,当前名称为“爱学蝶变家长端” |
| `pages.json` | 页面注册和导航栏配置 |
| `api/auth.ts` | 认证接口类型和方法 |
| `api/student.ts` | 学生验证和手机号检查接口 |
| `utils/request.ts` | 请求拦截、mock、loading、错误处理 |
| `store/user.ts` | 用户登录态、token、记住密码 |
| `store/registration.ts` | 注册流程共享学生列表 |
| `components/countdown-btn.uvue` | 验证码发送和 60 秒倒计时 |
| `pages/login/*` | 登录、验证码登录、找回密码 |
| `pages/register/*` | 学生绑定、家长账号、注册完成 |
| `pages/index/index.uvue` | 登录后首页概览 |
+138
View File
@@ -0,0 +1,138 @@
/**
* 认证相关API接口
*/
import { post } from '../utils/request'
import type { ApiResponse } from '../utils/request'
// ==================== 类型定义 ====================
// 用户信息类型
export type UserInfo = {
userId: string
phone: string
nickname: string
avatar: string
token: string
refreshToken: string
}
// 登录请求参数
export type LoginParams = {
phone: string
password: string
remember?: boolean
}
// 验证码登录请求参数
export type LoginByCodeParams = {
phone: string
code: string
}
// 发送验证码请求参数
export type SendCodeParams = {
phone: string
type: 'login' | 'register' | 'forgot-password'
}
// 发送验证码响应
export type SendCodeResponse = {
expireTime: number
}
// 注册请求参数
export type RegisterParams = {
parentName: string
phone: string
password: string
relationship: string
students: string[] // 学生ID列表
}
// 验证验证码请求参数
export type VerifyCodeParams = {
phone: string
code: string
}
// 验证验证码响应
export type VerifyCodeResponse = {
token: string
}
// 重置密码请求参数
export type ResetPasswordParams = {
phone: string
newPassword: string
resetToken: string
}
// 刷新Token请求参数
export type RefreshTokenParams = {
refreshToken: string
}
// 刷新Token响应
export type RefreshTokenResponse = {
token: string
refreshToken: string
}
// ==================== API方法 ====================
/**
* 密码登录
*/
export function apiLogin(params: LoginParams): Promise<ApiResponse<UserInfo> | null> {
return post<UserInfo>('/api/auth/login', params)
}
/**
* 验证码登录
*/
export function apiLoginByCode(params: LoginByCodeParams): Promise<ApiResponse<UserInfo> | null> {
return post<UserInfo>('/api/auth/login-by-code', params)
}
/**
* 发送验证码
*/
export function apiSendCode(params: SendCodeParams): Promise<ApiResponse<SendCodeResponse> | null> {
return post<SendCodeResponse>('/api/auth/send-code', params)
}
/**
* 注册
*/
export function apiRegister(params: RegisterParams): Promise<ApiResponse<UserInfo> | null> {
return post<UserInfo>('/api/auth/register', params)
}
/**
* 验证验证码(用于忘记密码流程)
*/
export function apiVerifyCode(params: VerifyCodeParams): Promise<ApiResponse<VerifyCodeResponse> | null> {
return post<VerifyCodeResponse>('/api/auth/forgot-password/verify', params)
}
/**
* 重置密码
*/
export function apiResetPassword(params: ResetPasswordParams): Promise<ApiResponse<null> | null> {
return post<null>('/api/auth/forgot-password/reset', params)
}
/**
* 刷新Token
*/
export function apiRefreshToken(params: RefreshTokenParams): Promise<ApiResponse<RefreshTokenResponse> | null> {
return post<RefreshTokenResponse>('/api/auth/refresh-token', params)
}
/**
* 退出登录
*/
export function apiLogout(): Promise<ApiResponse<null> | null> {
return post<null>('/api/auth/logout')
}
+343
View File
@@ -0,0 +1,343 @@
/**
* 学生相关API接口
*/
import { post, get } from '../utils/request'
import type { ApiResponse } from '../utils/request'
// ==================== 类型定义 ====================
// 学生信息类型
export type StudentInfo = {
id: string
name: string
account: string
avatar: string
grade: string
class: string
}
// 首页当前学生基础信息
export type StudentHomeProfile = {
id: string
name: string
account: string
grade: string
school: string
avatar: string
comprehensiveLevel: 'excellent' | 'good' | 'qualified' | 'needs_improvement'
}
// 家长已绑定学生的选择列表项
export type BoundStudentSummary = {
id: string
name: string
account: string
grade: string
school: string
avatar: string
}
// 首页今日学习概览,所有数值均归属于 studentId 对应的学生。
export type StudentHomeMetrics = {
studentId: string
onlineToday: boolean
checkedInToday: boolean
learningMinutesToday: number
accuracyToday: number | null
learningDaysThisWeek: number
pendingHomeworkCount: number
updatedAt: string
}
export type TodayLearningCourse = {
id: string
time: string
subject: string
content: string
checkedIn: boolean
}
export type TodayLearningScore = {
accuracy: number
yesterdayAccuracy: number
change: number
}
export type TodayLearningPoint = {
id: string
label: string
points: number
}
export type StudyReportSubject = {
id: string
name: string
}
export type StudyReportTrendPoint = {
label: string
value: number
average: number
valueHeight: number
averageHeight: number
}
export type StudyReportTrend = {
subjectId: string
points: StudyReportTrendPoint[]
}
export type StudyReportActivityPoint = {
label: string
studyMinutes: number
plannedMinutes: number
newMasteredCount: number
studyHeight: number
plannedHeight: number
newMasteredHeight: number
}
export type StudyReportActivity = {
summary: string
activeDays: number
totalDurationText: string
points: StudyReportActivityPoint[]
}
export type StudyReportSubjectMastery = {
subjectName: string
masteredCount: number
totalCount: number
masteredPercent: number
}
export type StudyReportPeriod = {
rangeText: string
statusTitle: string
statusDescription: string
newMasteredPoints: number
consecutiveDays: number
averageAccuracy: number
scaleLabels: string[]
trends: StudyReportTrend[]
activity: StudyReportActivity
subjectMasteries: StudyReportSubjectMastery[]
}
export type StudentStudyReport = {
studentId: string
studentName: string
studentGrade: string
studentClass: string
updatedAtText: string
subjects: StudyReportSubject[]
week: StudyReportPeriod
month: StudyReportPeriod
}
export type TodayLearningDetail = {
studentId: string
studentName: string
dateText: string
checkedIn: boolean
learningMinutes: number
answerMinutes: number
answerCount: number
completedPackages: number
totalPackages: number
masteredPoints: number
courses: TodayLearningCourse[]
score: TodayLearningScore
pointsTotal: number
pointDetails: TodayLearningPoint[]
}
export type VocabularyBook = {
id: string
name: string
subtitle: string
totalWords: number
learnedWords: number
status: 'learning' | 'unfinished' | 'completed' | 'not_started'
statusText: string
levelText: string
color: 'green' | 'yellow' | 'blue' | 'gray'
}
export type StudentVocabulary = {
studentId: string
studentName: string
studentGrade: string
studentClass: string
bookCount: number
books: VocabularyBook[]
}
export type GrowthPointRecord = {
id: string
title: string
timeText: string
points: number
icon: 'calendar' | 'assignment' | 'streak' | 'accuracy' | 'mastery' | 'note'
}
export type StudentGrowth = {
studentId: string
studentName: string
studentGrade: string
studentClass: string
pointsBalance: number
level: number
rank: number
streakDays: number
totalPoints: number
records: GrowthPointRecord[]
}
export type LearningTaskItem = {
id: string
title: string
status: 'not_started' | 'in_progress' | 'success' | 'failed'
statusText: string
accuracyText: string
answerText: string
deadlineText: string
}
export type LearningTaskGroup = {
id: string
subject: string
unit: string
lesson: string
startText: string
endText: string
knowledgeCount: number
note: string
tasks: LearningTaskItem[]
}
export type StudentLearningTasks = {
studentId: string
studentName: string
studentGrade: string
studentClass: string
pendingCount: number
completedCount: number
failedCount: number
groups: LearningTaskGroup[]
}
export type StudentAlertLevel = 'critical' | 'high' | 'medium' | 'low'
export type StudentAlert = {
id: string
level: StudentAlertLevel
title: string
content: string
occurredAt: string
isRead: boolean
}
export type StudentAlertList = {
list: StudentAlert[]
pagination: {
page: number
pageSize: number
total: number
hasMore: boolean
}
}
// 验证学生二维码请求参数
export type VerifyQRCodeParams = {
qrCode: string
account: string
name: string
}
// 验证学生账号请求参数
export type VerifyAccountParams = {
account: string
name: string
}
// ==================== API方法 ====================
/**
* 验证学生二维码
*/
export function apiVerifyStudentQRCode(params: VerifyQRCodeParams): Promise<ApiResponse<StudentInfo> | null> {
return post<StudentInfo>('/api/student/verify-qrcode', params)
}
/**
* 验证学生账号
*/
export function apiVerifyStudentAccount(params: VerifyAccountParams): Promise<ApiResponse<StudentInfo> | null> {
return post<StudentInfo>('/api/student/verify-account', params)
}
/**
* 检查手机号是否已注册
*/
export function apiCheckPhone(phone: string): Promise<ApiResponse<{ exists: boolean }> | null> {
return get<{ exists: boolean }>(`/api/parent/check-phone?phone=${phone}`)
}
/**
* 获取当前家长默认绑定学生的首页基础信息。
* 服务端根据 Authorization Token 识别家长并校验学生绑定关系。
*/
export function apiGetStudentHomeProfile(studentId?: string, showLoading: boolean = true): Promise<ApiResponse<StudentHomeProfile> | null> {
const url = studentId ? `/api/parent/home-profile?studentId=${studentId}` : '/api/parent/home-profile'
return get<StudentHomeProfile>(url, undefined, { showLoading })
}
/**
* 获取当前家长绑定的全部学生,用于首页切换学生。
*/
export function apiGetBoundStudents(): Promise<ApiResponse<{ students: BoundStudentSummary[], currentStudentId: string | null }> | null> {
return get<{ students: BoundStudentSummary[], currentStudentId: string | null }>('/api/parent/students')
}
/**
* 获取指定学生的首页今日学习概览。
*/
export function apiGetStudentHomeMetrics(studentId: string, showLoading: boolean = true): Promise<ApiResponse<StudentHomeMetrics> | null> {
return get<StudentHomeMetrics>(`/api/parent/home-metrics?studentId=${studentId}`, undefined, { showLoading })
}
/**
* 获取学生今日学习详情及课程打卡记录。
*/
export function apiGetTodayLearningDetail(studentId: string, showLoading: boolean = true): Promise<ApiResponse<TodayLearningDetail> | null> {
return get<TodayLearningDetail>(`/api/parent/today-learning?studentId=${studentId}`, undefined, { showLoading })
}
/**
* 获取学生学情报告。接口一次返回周报、月报和全部科目趋势数据。
*/
export function apiGetStudentStudyReport(studentId: string, showLoading: boolean = true): Promise<ApiResponse<StudentStudyReport> | null> {
return get<StudentStudyReport>(`/api/parent/study-report?studentId=${studentId}`, undefined, { showLoading })
}
export function apiGetStudentVocabulary(studentId: string, showLoading: boolean = true): Promise<ApiResponse<StudentVocabulary> | null> {
return get<StudentVocabulary>(`/api/parent/vocabulary?studentId=${studentId}`, undefined, { showLoading })
}
export function apiGetStudentGrowth(studentId: string, showLoading: boolean = true): Promise<ApiResponse<StudentGrowth> | null> {
return get<StudentGrowth>(`/api/parent/growth?studentId=${studentId}`, undefined, { showLoading })
}
export function apiGetStudentLearningTasks(studentId: string, showLoading: boolean = true): Promise<ApiResponse<StudentLearningTasks> | null> {
return get<StudentLearningTasks>(`/api/parent/learning-tasks?studentId=${studentId}`, undefined, { showLoading })
}
/**
* 获取当前学生的预警信息。首页调用时 limit 固定为 3。
*/
export function apiGetStudentAlerts(studentId: string, page: number = 1, pageSize: number = 20, limit?: number, showLoading: boolean = true): Promise<ApiResponse<StudentAlertList> | null> {
const suffix = limit ? `limit=${limit}` : `page=${page}&pageSize=${pageSize}`
return get<StudentAlertList>(`/api/parent/alerts?studentId=${studentId}&${suffix}`, undefined, { showLoading })
}
+90
View File
@@ -0,0 +1,90 @@
<template>
<view class="countdown-btn-wrap">
<text
class="countdown-btn-text"
:class="canSend ? 'countdown-btn-active' : 'countdown-btn-disabled'"
@tap="handleTap"
>{{ btnText }}</text>
</view>
</template>
<script setup lang="ts">
type Props = {
phone?: string
codeType?: string
}
const props = withDefaults(defineProps<Props>(), {
phone: '',
codeType: 'login'
})
const emit = defineEmits<{
(e: 'send', phone: string): void
}>()
const COUNTDOWN = 60
const canSend = ref(true)
const countdown = ref(COUNTDOWN)
const btnText = ref('获取验证码')
let timer : number | null = null
function handleTap() {
if (!canSend.value) return
emit('send', props.phone)
}
/**
* 开始倒计时 - 由父组件在发送成功后调用
*/
function startCountdown() {
canSend.value = false
countdown.value = COUNTDOWN
btnText.value = `${countdown.value}s后重发`
timer = setInterval(() => {
countdown.value--
if (countdown.value <= 0) {
stopCountdown()
} else {
btnText.value = `${countdown.value}s后重发`
}
}, 1000)
}
function stopCountdown() {
if (timer !== null) {
clearInterval(timer)
timer = null
}
canSend.value = true
countdown.value = COUNTDOWN
btnText.value = '重新发送'
}
onUnmounted(() => {
stopCountdown()
})
defineExpose({ startCountdown })
</script>
<style>
.countdown-btn-wrap {
flex-shrink: 0;
}
.countdown-btn-text {
font-size: 14px;
padding: 4px 8px;
}
.countdown-btn-active {
color: #6C63FF;
}
.countdown-btn-disabled {
color: #BBBBBB;
}
</style>
+64
View File
@@ -0,0 +1,64 @@
<template>
<view class="footer-nav">
<view class="nav-item" :class="activeTab === 'home' ? 'nav-item-active' : ''" @tap="goPage('/pages/index/index')">
<view class="nav-icon-slot"><UniIcons :type="activeTab === 'home' ? 'home-filled' : 'home'" class="nav-icon" :class="activeTab === 'home' ? 'nav-icon-active' : ''"></UniIcons></view>
<text class="nav-label" :class="activeTab === 'home' ? 'nav-label-active' : ''">首页</text>
</view>
<view class="nav-item" :class="activeTab === 'study' ? 'nav-item-active' : ''" @tap="goPage('/pages/study/index')">
<view class="nav-icon-slot"><view class="nav-chart">
<view class="nav-chart-bar nav-chart-short" :class="activeTab === 'study' ? 'nav-chart-active' : ''"></view>
<view class="nav-chart-bar nav-chart-tall" :class="activeTab === 'study' ? 'nav-chart-active' : ''"></view>
<view class="nav-chart-bar nav-chart-medium" :class="activeTab === 'study' ? 'nav-chart-active' : ''"></view>
</view></view>
<text class="nav-label" :class="activeTab === 'study' ? 'nav-label-active' : ''">学情</text>
</view>
<view class="nav-item" :class="activeTab === 'tasks' ? 'nav-item-active' : ''" @tap="goPage('/pages/tasks/index')">
<view class="nav-icon-slot"><UniIcons :type="activeTab === 'tasks' ? 'checkbox-filled' : 'checkbox'" class="nav-icon" :class="activeTab === 'tasks' ? 'nav-icon-active' : ''"></UniIcons></view>
<text class="nav-label" :class="activeTab === 'tasks' ? 'nav-label-active' : ''">任务</text>
</view>
<view class="nav-item" :class="activeTab === 'profile' ? 'nav-item-active' : ''" @tap="goPage('/pages/profile/index')">
<view class="nav-icon-slot"><UniIcons :type="activeTab === 'profile' ? 'person-filled' : 'person'" class="nav-icon" :class="activeTab === 'profile' ? 'nav-icon-active' : ''"></UniIcons></view>
<text class="nav-label" :class="activeTab === 'profile' ? 'nav-label-active' : ''">我的</text>
</view>
</view>
</template>
<script setup lang="ts">
import UniIcons from '../uni_modules/uni-icons-x/components/uni-icons/uni-icons.uvue'
const props = defineProps<{ activeTab: string }>()
function goPage(url: string) {
const pages = getCurrentPages()
const route = normalizeRoute(url)
const currentPage = pages[pages.length - 1]
if (currentPage && normalizeRoute(currentPage.route) === route) return
for (let index = 0; index < pages.length; index++) {
if (normalizeRoute(pages[index].route) === route) {
uni.navigateBack({ delta: pages.length - index - 1 })
return
}
}
uni.navigateTo({ url })
}
function normalizeRoute(route: string): string {
return route.split('?')[0].replace(/^\/+/, '')
}
</script>
<style>
.footer-nav { display: flex; flex-direction: row; align-items: flex-start; justify-content: space-around; padding: 10px 14px calc(9px + var(--uni-safe-area-inset-bottom)); background-color: #ffffff; border-top: 1px solid #e5e8f0; }
.nav-item { display: flex; flex-direction: column; align-items: center; width: 25%; }
.nav-icon-slot { display: flex; flex-direction: row; align-items: center; justify-content: center; width: 26px; height: 26px; margin-bottom: 5px; }
.nav-icon { font-size: 22px; color: #9da8ba; }
.nav-icon-active { color: #554be9; }
.nav-chart { display: flex; flex-direction: row; align-items: flex-end; justify-content: center; width: 22px; height: 22px; padding-bottom: 2px; }
.nav-chart-bar { width: 4px; margin: 0 1px; border-radius: 2px; background-color: #9da8ba; }
.nav-chart-short { height: 9px; }
.nav-chart-tall { height: 18px; }
.nav-chart-medium { height: 13px; }
.nav-chart-active { background-color: #554be9; }
.nav-label { font-size: 11px; color: #9da8ba; }
.nav-label-active { color: #554be9; }
</style>
+149
View File
@@ -0,0 +1,149 @@
<template>
<view class="qr-scanner">
<!-- #ifdef WEB -->
<view :id="videoContainerId" class="qr-scanner-video"></view>
<!-- #endif -->
<!-- #ifndef WEB -->
<camera
class="qr-scanner-camera"
mode="scanCode"
device-position="back"
@scancode="handleCameraScan"
@error="handleCameraError"
></camera>
<!-- #endif -->
</view>
</template>
<script setup lang="ts">
// #ifdef WEB
import { BrowserQRCodeReader } from '@zxing/browser'
import type { IScannerControls } from '@zxing/browser'
// #endif
type Props = {
cooldown?: number
active?: boolean
}
const props = withDefaults(defineProps<Props>(), {
cooldown: 5000,
active: true
})
const emit = defineEmits<{
(e: 'scan', result: string): void
(e: 'error'): void
}>()
let lastScannedAt = 0
// #ifdef WEB
const videoContainerId = `qr-scanner-${Date.now()}`
let scannerControls: IScannerControls | null = null
let scanVideo: HTMLVideoElement | null = null
let isPageActive = false
let restartTimer: number | null = null
// #endif
function emitScan(result: string) {
const now = Date.now()
if (!props.active || !result || now - lastScannedAt < props.cooldown) return
lastScannedAt = now
emit('scan', result)
}
function handleCameraScan(event: any) {
emitScan(event.detail?.result as string ?? '')
}
function handleCameraError() {
emit('error')
}
// #ifdef WEB
function getScanVideo(): HTMLVideoElement | null {
if (scanVideo != null) return scanVideo
const container = document.getElementById(videoContainerId)
if (container == null) return null
const video = document.createElement('video') as HTMLVideoElement
video.autoplay = true
video.muted = true
video.playsInline = true
video.setAttribute('style', 'display:block;width:100%;height:100%;object-fit:cover;background-color:#000000;')
container.appendChild(video)
scanVideo = video
return video
}
async function startWebScanner() {
if (!isPageActive || !props.active || scannerControls != null) return
const video = getScanVideo()
if (video == null) {
handleCameraError()
return
}
try {
const reader = new BrowserQRCodeReader()
scannerControls = await reader.decodeFromConstraints(
{ video: { facingMode: 'environment' } },
video,
(result, _error, controls) => {
if (!result) return
controls.stop()
scannerControls = null
emitScan(result.getText())
scheduleRestart()
}
)
} catch (_error) {
handleCameraError()
}
}
function scheduleRestart() {
if (!isPageActive || !props.active || restartTimer != null) return
restartTimer = setTimeout(() => {
restartTimer = null
void startWebScanner()
}, props.cooldown)
}
function stopWebScanner() {
if (restartTimer != null) {
clearTimeout(restartTimer)
restartTimer = null
}
if (scannerControls != null) {
scannerControls.stop()
scannerControls = null
}
}
watch(() => props.active, (active) => {
if (active) {
void startWebScanner()
} else {
stopWebScanner()
}
})
onMounted(() => {
isPageActive = true
void startWebScanner()
})
onBeforeUnmount(() => {
isPageActive = false
stopWebScanner()
scanVideo = null
})
// #endif
</script>
<style>
.qr-scanner { width: 100%; height: 224px; overflow: hidden; border-radius: 16px; border: 2px solid #6C63FF; background-color: #000000; }
.qr-scanner-camera { width: 100%; height: 100%; }
.qr-scanner-video { width: 100%; height: 100%; }
</style>
+29
View File
@@ -0,0 +1,29 @@
<template>
<view class="activity-chart"><view class="activity-scale"><text class="activity-scale-text">时长(分)</text><text class="activity-scale-text">200</text><text class="activity-scale-text">100</text><text class="activity-scale-text">0</text></view><view class="activity-columns"><view v-for="point in points" :key="point.label" class="activity-column"><view class="activity-bar-wrap"><view class="activity-grid-line activity-grid-line-top"></view><view class="activity-grid-line activity-grid-line-middle"></view><view class="activity-grid-line activity-grid-line-bottom"></view><view class="activity-planned-bar" :style="{ height: point.plannedHeight + 'px' }"></view><view class="activity-study-bar" :style="{ height: point.studyHeight + 'px' }"></view><view v-if="point.newMasteredCount > 0" class="activity-mastery-point" :style="{ bottom: point.newMasteredHeight + 'px' }"><text class="activity-mastery-count">{{ point.newMasteredCount }}</text></view></view><text class="activity-day">{{ point.label }}</text></view></view><view class="activity-mastery-scale"><text class="activity-mastery-scale-text">新增掌握(个)</text><text class="activity-mastery-scale-text">3 个</text><text class="activity-mastery-scale-text">2 个</text><text class="activity-mastery-scale-text">1 个</text><text class="activity-mastery-scale-text">0 个</text></view></view>
</template>
<script setup lang="ts">
import type { StudyReportActivityPoint } from '../api/student'
defineProps<{ points: StudyReportActivityPoint[] }>()
</script>
<style>
.activity-chart { display: flex; flex-direction: row; height: 150px; margin-top: 10px; }
.activity-scale { display: flex; flex-direction: column; justify-content: space-between; width: 38px; padding-bottom: 20px; }
.activity-scale-text { font-size: 9px; color: #8992a5; }
.activity-columns { display: flex; flex-direction: row; justify-content: space-between; flex: 1; padding-bottom: 20px; }
.activity-column { display: flex; flex-direction: column; align-items: center; justify-content: flex-end; height: 130px; }
.activity-bar-wrap { position: relative; display: flex; flex-direction: row; align-items: flex-end; height: 108px; }
.activity-grid-line { position: absolute; left: 0; right: 0; width: 34px; height: 1px; background-color: #edf0f5; }
.activity-grid-line-top { top: 0; }
.activity-grid-line-middle { top: 53px; }
.activity-grid-line-bottom { bottom: 0; }
.activity-planned-bar { width: 9px; margin-right: 4px; border-radius: 4px 4px 0 0; background-color: #d3dce8; }
.activity-study-bar { width: 9px; border-radius: 4px 4px 0 0; background-color: #6558e9; }
.activity-mastery-point { position: absolute; right: -4px; display: flex; flex-direction: column; align-items: center; justify-content: center; width: 10px; height: 10px; border-radius: 5px; background-color: #ffab26; }
.activity-mastery-count { position: absolute; bottom: 11px; font-size: 9px; color: #f08b00; }
.activity-day { margin-top: 6px; font-size: 10px; color: #8992a5; }
.activity-mastery-scale { display: flex; flex-direction: column; justify-content: space-between; width: 42px; padding-bottom: 20px; }
.activity-mastery-scale-text { font-size: 9px; color: #8992a5; }
</style>
+57
View File
@@ -0,0 +1,57 @@
<template>
<view class="trend-chart"><view class="scale-column"><text v-for="scale in scaleLabels" :key="scale" class="scale-label">{{ scale }}</text></view><view class="trend-plot"><view class="trend-lines"><view class="trend-grid-line trend-grid-line-top"></view><view class="trend-grid-line trend-grid-line-upper"></view><view class="trend-grid-line trend-grid-line-lower"></view><view class="trend-grid-line trend-grid-line-bottom"></view><view v-for="segment in averageSegments" :key="segment.key" class="trend-line-segment trend-line-average" :style="segment.style"></view><view v-for="segment in studentSegments" :key="segment.key" class="trend-line-segment trend-line-student" :style="segment.style"></view><view v-for="point in points" :key="point.label + '-average'" class="trend-point trend-point-average" :style="pointStyle(point, false)"></view><view v-for="point in points" :key="point.label + '-student'" class="trend-point trend-point-student" :style="pointStyle(point, true)"></view></view><view class="trend-labels"><text v-for="point in points" :key="point.label" class="trend-day">{{ point.label }}</text></view></view></view>
</template>
<script setup lang="ts">
import type { StudyReportTrendPoint } from '../api/student'
type TrendLineSegment = { key: string, style: string }
const props = defineProps<{ scaleLabels: string[], points: StudyReportTrendPoint[] }>()
const studentSegments = computed((): TrendLineSegment[] => buildSegments(true))
const averageSegments = computed((): TrendLineSegment[] => buildSegments(false))
function buildSegments(isStudent: boolean): TrendLineSegment[] {
const segments: TrendLineSegment[] = []
if (props.points.length < 2) return segments
const chartWidth = 270
const horizontalStep = (chartWidth - 16) / (props.points.length - 1)
for (let index = 0; index < props.points.length - 1; index++) {
const currentHeight = isStudent ? props.points[index].valueHeight : props.points[index].averageHeight
const nextHeight = isStudent ? props.points[index + 1].valueHeight : props.points[index + 1].averageHeight
const verticalStep = nextHeight - currentHeight
const lineWidth = Math.sqrt(horizontalStep * horizontalStep + verticalStep * verticalStep) / chartWidth * 100
const angle = -Math.atan2(verticalStep, horizontalStep) * 180 / Math.PI
const left = 8 + horizontalStep * index
segments.push({ key: `${isStudent ? 'student' : 'average'}-${index}`, style: `left:${left / chartWidth * 100}%;bottom:${currentHeight + 8}px;width:${lineWidth}%;transform:rotate(${angle}deg);` })
}
return segments
}
function pointStyle(point: StudyReportTrendPoint, isStudent: boolean): string {
const index = props.points.indexOf(point)
const left = props.points.length > 1 ? 8 + (270 - 16) / (props.points.length - 1) * index : 135
const pointHeight = isStudent ? point.valueHeight : point.averageHeight
return `left:${left / 270 * 100}%;bottom:${pointHeight + 8}px;`
}
</script>
<style>
.trend-chart { display: flex; flex-direction: row; height: 132px; margin-top: 10px; }
.scale-column { display: flex; flex-direction: column; justify-content: space-between; width: 35px; padding-bottom: 18px; }
.scale-label { font-size: 10px; color: #8992a5; }
.trend-plot { display: flex; flex-direction: column; flex: 1; padding-bottom: 18px; border-bottom: 1px solid #edf0f5; }
.trend-lines { position: relative; height: 90px; }
.trend-grid-line { position: absolute; left: 0; right: 0; height: 1px; background-color: #edf0f5; }
.trend-grid-line-top { top: 8px; }
.trend-grid-line-upper { top: 32px; }
.trend-grid-line-lower { top: 56px; }
.trend-grid-line-bottom { bottom: 8px; }
.trend-line-segment { position: absolute; height: 2px; transform-origin: left center; }
.trend-line-student { background-color: #6558e9; }
.trend-line-average { height: 1px; background-color: #9aa8bc; }
.trend-point { position: absolute; width: 7px; height: 7px; margin-left: -4px; margin-bottom: -4px; border-radius: 4px; background-color: #ffffff; }
.trend-point-student { border: 2px solid #6558e9; }
.trend-point-average { border: 2px solid #9aa8bc; }
.trend-labels { display: flex; flex-direction: row; justify-content: space-between; margin-top: 6px; }
.trend-day { font-size: 10px; color: #8992a5; }
</style>
+20
View File
@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<script>
var coverSupport = 'CSS' in window && typeof CSS.supports === 'function' && (CSS.supports('top: env(a)') ||
CSS.supports('top: constant(a)'))
document.write(
'<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0' +
(coverSupport ? ', viewport-fit=cover' : '') + '" />')
</script>
<title></title>
<!--preload-links-->
<!--app-context-->
</head>
<body>
<div id="app"><!--app-html--></div>
<script type="module" src="/main"></script>
</body>
</html>
+9
View File
@@ -0,0 +1,9 @@
import App from './App.uvue'
import { createSSRApp } from 'vue'
export function createApp() {
const app = createSSRApp(App)
return {
app
}
}
+72
View File
@@ -0,0 +1,72 @@
{
"name": "爱学蝶变家长端",
"appid": "__UNI__800D1E3",
"description": "",
"versionName": "1.0.0",
"versionCode": "100",
"uni-app-x": {
"styleIsolationVersion": "2",
"vapor": true,
"vapor-default-script-lang": "ts",
"vapor-render-target": "bytecode"
},
"quickapp": {},
"mp-weixin": {
"appid": "",
"setting": {
"urlCheck": false
},
"usingComponents": true
},
"mp-alipay": {
"usingComponents": true
},
"mp-baidu": {
"usingComponents": true
},
"mp-toutiao": {
"usingComponents": true
},
"uniStatistics": {
"enable": false
},
"vueVersion": "3",
"app": {
"distribute": {
"icons": {
"android": {
"hdpi": "",
"xhdpi": "",
"xxhdpi": "",
"xxxhdpi": ""
}
}
}
},
"app-android": {
"distribute": {
"modules": {},
"icons": {
"hdpi": "",
"xhdpi": "",
"xxhdpi": "",
"xxxhdpi": ""
},
"splashScreens": {
"default": {}
}
}
},
"app-ios": {
"distribute": {
"modules": {},
"icons": {},
"splashScreens": {}
}
},
"web": {
"router": {
"mode": ""
}
}
}
+60
View File
@@ -0,0 +1,60 @@
{
"name": "app.k12.parents",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "app.k12.parents",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"@zxing/browser": "^0.2.1"
}
},
"node_modules/@zxing/browser": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/@zxing/browser/-/browser-0.2.1.tgz",
"integrity": "sha512-92pVfVDUXbc15xu9vIEmhNyqIEASMEkDF92CwT6T+7sg2EHXJRktH9CQKoQSXe51UtbNsj+Fm09H/JBWHMs/Sw==",
"license": "MIT",
"optionalDependencies": {
"@zxing/text-encoding": "^0.9.0"
},
"peerDependencies": {
"@zxing/library": "^0.23.0"
}
},
"node_modules/@zxing/library": {
"version": "0.23.0",
"resolved": "https://registry.npmjs.org/@zxing/library/-/library-0.23.0.tgz",
"integrity": "sha512-6fkkoFwP8CHxl6ugnPsj74PLJgX2iRv5zczGAyt5OBzQgxFhuhF0NCEc4t4OvSr8xAv2MRLlI0Iu9ZGDZQ2urA==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"ts-custom-error": "^3.3.1"
},
"engines": {
"node": ">= 24.0.0"
},
"optionalDependencies": {
"@zxing/text-encoding": "~0.9.0"
}
},
"node_modules/@zxing/text-encoding": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@zxing/text-encoding/-/text-encoding-0.9.0.tgz",
"integrity": "sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==",
"license": "(Unlicense OR Apache-2.0)",
"optional": true
},
"node_modules/ts-custom-error": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/ts-custom-error/-/ts-custom-error-3.3.1.tgz",
"integrity": "sha512-5OX1tzOjxWEgsr/YEUWSuPrQ00deKLh6D7OTWcvNHm12/7QPyRh8SYpyWvA4IZv8H/+GQWQEh/kwo95Q9OVW1A==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
}
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"name": "app.k12.parents",
"version": "1.0.0",
"description": "家长学情是基于 uni-app x Vapor 模式开发的移动端应用。当前实现家长账号的注册、密码登录、验证码登录和密码重置流程。",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"@zxing/browser": "^0.2.1"
}
}
+130
View File
@@ -0,0 +1,130 @@
{
"easycom": {
"autoscan": true
},
"pages": [
{
"path": "pages/login/index",
"style": {
"navigationBarTitleText": "登录",
"navigationBarBackgroundColor": "#FFFFFF",
"navigationBarTextStyle": "black"
}
},
{
"path": "pages/index/index",
"style": {
"navigationBarTitleText": "首页"
}
},
{
"path": "pages/study/index",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/vocabulary/index",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/growth/index",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/tasks/index",
"style": {
"navigationBarTitleText": "任务"
}
},
{
"path": "pages/profile/index",
"style": {
"navigationBarTitleText": "我的"
}
},
{
"path": "pages/profile/verify-student",
"style": { "navigationBarTitleText": "验证学生" }
},
{
"path": "pages/alerts/index",
"style": {
"navigationBarTitleText": "预警信息"
}
},
{
"path": "pages/today-learning/index",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/login/verify-code",
"style": {
"navigationBarTitleText": "验证码登录",
"navigationBarBackgroundColor": "#FFFFFF",
"navigationBarTextStyle": "black"
}
},
{
"path": "pages/login/forgot-password",
"style": {
"navigationBarTitleText": "忘记密码",
"navigationBarBackgroundColor": "#FFFFFF",
"navigationBarTextStyle": "black"
}
},
{
"path": "pages/register/index",
"style": {
"navigationBarTitleText": "家长注册",
"navigationBarBackgroundColor": "#FFFFFF",
"navigationBarTextStyle": "black"
}
},
{
"path": "pages/register/scan-student",
"style": {
"navigationBarTitleText": "家长注册",
"navigationBarBackgroundColor": "#FFFFFF",
"navigationBarTextStyle": "black"
}
},
{
"path": "pages/register/input-student",
"style": {
"navigationBarTitleText": "家长注册",
"navigationBarBackgroundColor": "#FFFFFF",
"navigationBarTextStyle": "black"
}
},
{
"path": "pages/register/parent-account",
"style": {
"navigationBarTitleText": "家长注册",
"navigationBarBackgroundColor": "#FFFFFF",
"navigationBarTextStyle": "black"
}
},
{
"path": "pages/register/complete",
"style": {
"navigationBarTitleText": "注册完成",
"navigationBarBackgroundColor": "#FFFFFF",
"navigationBarTextStyle": "black"
}
}
],
"globalStyle": {
"navigationBarTextStyle": "black",
"navigationBarTitleText": "家长学情",
"navigationBarBackgroundColor": "#FFFFFF",
"backgroundColor": "#F5F5F5"
},
"uniIdRouter": {}
}
+68
View File
@@ -0,0 +1,68 @@
<template>
<view class="page-container">
<view class="header-panel"><text class="header-title">预警信息</text><text class="header-subtitle">{{ studentName }}的学习动态</text></view>
<scroll-view class="content-scroll" direction="vertical" scroll-y="true">
<view v-if="isLoading" class="status-panel"><text class="status-text">预警信息加载中</text></view>
<view v-else-if="alerts.length === 0" class="status-panel"><text class="status-text">暂无预警信息</text></view>
<view v-else v-for="alert in alerts" :key="alert.id" class="alert-card">
<view class="alert-card-top"><view class="alert-level-dot" :class="levelDotClass(alert.level)"></view><text class="alert-title">{{ alert.title }}</text><text v-if="!alert.isRead" class="unread-mark">未读</text></view>
<text class="alert-content">{{ alert.content }}</text>
<text class="alert-time">{{ formatTime(alert.occurredAt) }}</text>
</view>
</scroll-view>
</view>
</template>
<script setup lang="ts">
import { apiGetStudentAlerts, type StudentAlert, type StudentAlertLevel } from '../../api/student'
import { homeStudentProfile } from '../../store/home'
const alerts = ref<StudentAlert[]>([])
const isLoading = ref(true)
const studentName = computed((): string => homeStudentProfile.value?.name ?? '孩子')
onLoad(() => {
loadAlerts()
})
async function loadAlerts() {
const studentId = homeStudentProfile.value?.id ?? ''
if (!studentId) {
isLoading.value = false
return
}
const res = await apiGetStudentAlerts(studentId, 1, 20)
if (res && res.data) {
alerts.value = res.data.list
}
isLoading.value = false
}
function levelDotClass(level: StudentAlertLevel): string {
return level === 'low' ? 'alert-level-low' : level === 'medium' ? 'alert-level-medium' : 'alert-level-high'
}
function formatTime(value: string): string {
return value.length > 0 ? value.substring(5, 16).replace('T', ' ') : ''
}
</script>
<style>
.page-container { display: flex; flex-direction: column; flex: 1; background-color: #f5f7fb; }
.header-panel { display: flex; flex-direction: column; padding: 18px 20px; background-color: #5d3de8; }
.header-title { font-size: 21px; font-weight: 700; color: #ffffff; }
.header-subtitle { margin-top: 6px; font-size: 13px; color: #e5ddff; }
.content-scroll { flex: 1; padding: 16px 20px; }
.status-panel { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 160px; }
.status-text { font-size: 14px; color: #8992a5; }
.alert-card { display: flex; flex-direction: column; margin-bottom: 12px; padding: 16px; border-radius: 14px; background-color: #ffffff; }
.alert-card-top { display: flex; flex-direction: row; align-items: center; }
.alert-level-dot { width: 9px; height: 9px; margin-right: 8px; border-radius: 5px; }
.alert-level-high { background-color: #e25a5a; }
.alert-level-medium { background-color: #e8a129; }
.alert-level-low { background-color: #4d87cf; }
.alert-title { flex: 1; font-size: 15px; font-weight: 700; color: #30384a; }
.unread-mark { padding: 3px 7px; border-radius: 8px; font-size: 10px; color: #554be9; background-color: #eeefff; }
.alert-content { margin-top: 10px; font-size: 13px; line-height: 20px; color: #626c7e; }
.alert-time { margin-top: 10px; font-size: 11px; color: #9aa2b1; }
</style>
+75
View File
@@ -0,0 +1,75 @@
<template>
<view class="page-container">
<view class="header-panel"><view class="header-back" @tap="goHome"><UniIcons type="back" class="header-back-icon"></UniIcons></view><view class="header-copy"><text class="header-title">成长激励</text><text class="header-subtitle">{{ growth.studentName }} · {{ growth.studentGrade }}·{{ growth.studentClass }}</text></view><view class="header-action"><UniIcons type="person-filled" class="header-action-icon"></UniIcons></view></view>
<scroll-view class="content-scroll" direction="vertical" scroll-y="true"><view v-if="isLoading" class="status-panel"><text class="status-text">成长数据加载中</text></view><view v-else class="content-panel"><view class="points-card"><view class="points-heading"><view class="points-copy"><text class="points-label">积分余额</text><text class="points-value">{{ growth.pointsBalance }}</text></view><view class="level-badge"><text class="level-label">等级</text><text class="level-value">Lv.{{ growth.level }}</text></view></view><view class="points-stats"><view class="points-stat"><text class="points-stat-value">{{ growth.rank }}</text><text class="points-stat-label">班级排名</text></view><view class="points-stat points-stat-middle"><text class="points-stat-value">{{ growth.streakDays }}</text><text class="points-stat-label">未能连续</text></view><view class="points-stat"><text class="points-stat-value">{{ growth.totalPoints }}</text><text class="points-stat-label">获得总量</text></view></view></view><view class="flow-card"><text class="section-title">积分流水</text><text class="section-description">记录孩子每天做了哪些正向行为</text><view class="flow-list"><view v-for="record in growth.records" :key="record.id" class="flow-item"><view class="flow-icon" :class="'flow-icon-' + record.icon"><UniIcons :type="record.icon === 'calendar' ? 'calendar' : record.icon === 'assignment' ? 'checkbox' : record.icon === 'streak' ? 'fire' : record.icon === 'accuracy' ? 'circle-filled' : record.icon === 'mastery' ? 'star-filled' : 'compose'" class="flow-icon-glyph"></UniIcons></view><view class="flow-copy"><text class="flow-title">{{ record.title }}</text><text class="flow-time">{{ record.timeText }}</text></view><text class="flow-points">+{{ record.points }}</text></view></view></view></view></scroll-view>
</view>
</template>
<script setup lang="ts">
import UniIcons from '../../uni_modules/uni-icons-x/components/uni-icons/uni-icons.uvue'
import { apiGetStudentGrowth, apiGetStudentHomeProfile, type StudentGrowth } from '../../api/student'
import { homeStudentProfile, setHomeStudentProfile } from '../../store/home'
const growth = ref<StudentGrowth>({ studentId: '', studentName: '', studentGrade: '', studentClass: '', pointsBalance: 0, level: 0, rank: 0, streakDays: 0, totalPoints: 0, records: [] })
const isLoading = ref(true)
onLoad(() => { loadGrowth() })
async function loadGrowth() {
let studentId = homeStudentProfile.value?.id ?? ''
if (!studentId) {
const profileRes = await apiGetStudentHomeProfile(undefined, false)
if (profileRes && profileRes.data) {
setHomeStudentProfile(profileRes.data)
studentId = profileRes.data.id
}
}
if (studentId) {
const res = await apiGetStudentGrowth(studentId)
if (res && res.data) growth.value = res.data
}
isLoading.value = false
}
function goHome() { uni.reLaunch({ url: '/pages/index/index' }) }
</script>
<style>
.page-container { display: flex; flex-direction: column; flex: 1; background-color: #f5f7fb; }
.header-panel { display: flex; flex-direction: row; align-items: center; padding: 12px 18px; background-color: #ffffff; }
.header-back { display: flex; flex-direction: column; align-items: center; justify-content: center; width: 36px; height: 36px; border-radius: 18px; background-color: #f1f4f8; }
.header-back-icon { font-size: 18px; color: #66758d; }
.header-copy { display: flex; flex-direction: column; flex: 1; margin-left: 12px; }
.header-title { font-size: 17px; font-weight: 700; color: #22283a; }
.header-subtitle { margin-top: 3px; font-size: 12px; color: #8992a5; }
.header-action { display: flex; flex-direction: column; align-items: center; justify-content: center; width: 36px; height: 36px; border-radius: 18px; background-color: #f0f1ff; }
.header-action-icon { font-size: 18px; color: #554be9; }
.content-scroll { flex: 1; }
.content-panel { display: flex; flex-direction: column; padding: 16px 20px 28px; }
.status-panel { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 260px; }
.status-text { font-size: 13px; color: #8992a5; }
.points-card { display: flex; flex-direction: column; padding: 16px; border-radius: 16px; background-color: #ff9e19; }
.points-heading { display: flex; flex-direction: row; justify-content: space-between; }
.points-copy { display: flex; flex-direction: column; }
.points-label { font-size: 12px; color: #fff4d8; }
.points-value { margin-top: 2px; font-size: 31px; font-weight: 700; color: #ffffff; }
.level-badge { display: flex; flex-direction: column; align-items: center; justify-content: center; width: 54px; height: 54px; border-radius: 18px; background-color: rgba(255, 255, 255, 0.22); }
.level-label { font-size: 10px; color: #fff4d8; }
.level-value { margin-top: 2px; font-size: 15px; font-weight: 700; color: #ffffff; }
.points-stats { display: flex; flex-direction: row; margin-top: 11px; padding: 10px 0; border-radius: 12px; background-color: rgba(255, 255, 255, 0.18); }
.points-stat { display: flex; flex-direction: column; align-items: center; flex: 1; }
.points-stat-middle { border-left: 1px solid rgba(255, 255, 255, 0.3); border-right: 1px solid rgba(255, 255, 255, 0.3); }
.points-stat-value { font-size: 16px; font-weight: 700; color: #ffffff; }
.points-stat-label { margin-top: 4px; font-size: 10px; color: #fff4d8; }
.flow-card { display: flex; flex-direction: column; margin-top: 16px; padding: 16px; border-radius: 16px; background-color: #ffffff; }
.section-title { font-size: 16px; font-weight: 700; color: #242b3d; }
.section-description { margin-top: 6px; font-size: 11px; color: #8992a5; }
.flow-list { display: flex; flex-direction: column; margin-top: 12px; }
.flow-item { display: flex; flex-direction: row; align-items: center; margin-bottom: 8px; padding: 10px; border-radius: 12px; background-color: #f6f8fb; }
.flow-icon { display: flex; flex-direction: column; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 8px; background-color: #ffffff; }
.flow-icon-glyph { font-size: 16px; color: #ff9e19; }
.flow-copy { display: flex; flex-direction: column; flex: 1; margin-left: 10px; }
.flow-title { font-size: 13px; color: #242b3d; }
.flow-time { margin-top: 3px; font-size: 10px; color: #94a0b1; }
.flow-points { font-size: 13px; font-weight: 700; color: #f29900; }
</style>
+649
View File
@@ -0,0 +1,649 @@
<template>
<view class="page-container">
<!-- 固定顶部信息区 -->
<view class="header-panel">
<view class="header-copy">
<text class="header-greeting">下午好,王女士</text>
<text class="header-title">孩子今天的学习情况</text>
<text class="header-update">数据更新时间 {{ metricsUpdateText }} · 系统定时预计暂停,非实时</text>
</view>
<view class="notification-button" @tap="goAlerts">
<view class="notification-bell"></view>
<view class="notification-clapper"></view>
<view class="notification-dot"></view>
</view>
</view>
<!-- 仅此区域滚动 -->
<scroll-view class="content-scroll" direction="vertical" scroll-y="true" :refresher-enabled="true" :refresher-triggered="isRefreshing" @refresherrefresh="handleRefresh">
<view class="student-panel">
<view class="student-row">
<view class="student-avatar">
<image v-if="student.avatar" class="student-avatar-image" :src="student.avatar" mode="aspectFill"></image>
<text v-else class="avatar-face">{{ studentInitial }}</text>
</view>
<view class="student-identity">
<view class="student-name-row">
<text class="student-name">{{ student.name }}</text>
<text class="student-grade">{{ student.grade }}</text>
</view>
<view class="student-level-row">
<view class="level-medal"></view>
<text class="student-level-label">综合等级</text>
<text class="student-level-value">{{ levelText }}</text>
</view>
</view>
<view class="switch-button" @tap="openStudentPicker">
<view class="switch-icon"></view>
<text class="switch-text">切换</text>
</view>
</view>
<view class="metrics-grid">
<view class="metric-item">
<text class="metric-label">今日上线</text>
<text class="metric-value" :class="metrics.onlineToday ? 'metric-green' : ''">{{ metrics.onlineToday ? '已上线' : '未上线' }}</text>
</view>
<view class="metric-item">
<text class="metric-label">今日打卡</text>
<text class="metric-value" :class="metrics.checkedInToday ? 'metric-green' : ''">{{ metrics.checkedInToday ? '已打卡' : '未打卡' }}</text>
</view>
<view class="metric-item">
<text class="metric-label">今日学习时长</text>
<text class="metric-value">{{ learningDurationText }}</text>
</view>
<view class="metric-item">
<text class="metric-label">今日正确率</text>
<text class="metric-value">{{ accuracyText }}</text>
</view>
<view class="metric-item">
<text class="metric-label">本周学习天数</text>
<text class="metric-value metric-blue">{{ metrics.learningDaysThisWeek }}/7 天</text>
</view>
<view class="metric-item">
<text class="metric-label">待完成作业</text>
<text class="metric-value metric-orange">{{ metrics.pendingHomeworkCount }} 个</text>
</view>
</view>
</view>
<view class="reminder-section">
<view class="section-heading">
<text class="section-title">预警提醒</text>
<text class="section-link" @tap="goAlerts">全部预警 ></text>
</view>
<view class="reminder-list">
<view v-if="alerts.length === 0" class="reminder-empty"><text class="reminder-empty-text">暂无预警信息</text></view>
<view v-else v-for="alert in alerts" :key="alert.id" class="reminder-item"><text class="reminder-tag" :class="alertLevelClass(alert.level)">{{ alert.title }}</text><text class="reminder-text">{{ alert.content }}</text></view>
</view>
</view>
<view class="entry-section">
<text class="section-title">常用入口</text>
<view class="entry-grid">
<view class="entry-card" @tap="goTodayLearning"><UniIcons type="calendar" class="entry-icon"></UniIcons><text class="entry-title">今日学习</text></view>
<view class="entry-card" @tap="goStudyReport"><UniIcons type="calendar-filled" class="entry-icon"></UniIcons><text class="entry-title">周报月报</text></view>
<view class="entry-card" @tap="goVocabulary"><UniIcons type="font" class="entry-icon"></UniIcons><text class="entry-title">英语词汇</text></view>
<view class="entry-card" @tap="goGrowth"><UniIcons type="gift-filled" class="entry-icon"></UniIcons><text class="entry-title">成长激励</text></view>
</view>
</view>
</scroll-view>
<MainTabBar active-tab="home"></MainTabBar>
<page-container :show="showStudentPicker" position="bottom" :round="true" :overlay="true" :close-on-slide-down="true" @beforeleave="closeStudentPicker">
<view class="student-picker-panel">
<view class="student-picker-header">
<text class="student-picker-title">选择孩子</text>
<text class="student-picker-subtitle">选择后将更新首页学习信息</text>
</view>
<scroll-view class="student-picker-list" direction="vertical" scroll-y="true">
<view v-if="isStudentsLoading" class="student-picker-empty"><text class="student-picker-empty-text">学生信息加载中</text></view>
<view v-else-if="boundStudents.length === 0" class="student-picker-empty"><text class="student-picker-empty-text">暂无已绑定学生</text></view>
<view v-else v-for="item in boundStudents" :key="item.id" class="student-picker-item" :class="selectedStudentId === item.id ? 'student-picker-item-selected' : ''" @tap="selectStudent(item.id)">
<view class="student-picker-avatar"><image v-if="item.avatar" class="student-picker-avatar-image" :src="item.avatar" mode="aspectFill"></image><text v-else class="student-picker-avatar-text">{{ item.name.substring(0, 1) }}</text></view>
<view class="student-picker-info"><view class="student-picker-name-row"><text class="student-picker-name">{{ item.name }}</text><text class="student-picker-grade">{{ item.grade }}</text></view><text class="student-picker-detail">账号:{{ item.account }}</text><text class="student-picker-detail">学校:{{ item.school }}</text></view>
<view v-if="selectedStudentId === item.id" class="student-picker-check"><text class="student-picker-check-text">已选</text></view>
</view>
</scroll-view>
<view class="student-picker-footer"><view class="student-picker-cancel" @tap="closeStudentPicker"><text class="student-picker-cancel-text">取消</text></view><view class="student-picker-confirm" @tap="confirmStudent"><text class="student-picker-confirm-text">确定</text></view></view>
</view>
</page-container>
</view>
</template>
<script setup lang="ts">
import MainTabBar from '../../components/main-tab-bar.uvue'
import UniIcons from '../../uni_modules/uni-icons-x/components/uni-icons/uni-icons.uvue'
import { apiGetBoundStudents, apiGetStudentAlerts, apiGetStudentHomeMetrics, apiGetStudentHomeProfile, type BoundStudentSummary, type StudentAlert, type StudentAlertLevel, type StudentHomeMetrics, type StudentHomeProfile } from '../../api/student'
import { homeStudentAlerts, homeStudentMetrics, homeStudentProfile, setHomeStudentAlerts, setHomeStudentMetrics, setHomeStudentProfile } from '../../store/home'
const loadingStudent = ref<StudentHomeProfile>({
id: '',
name: '加载中',
account: '',
grade: '',
school: '',
avatar: '',
comprehensiveLevel: 'qualified'
})
const isRefreshing = ref(false)
const showStudentPicker = ref(false)
const isStudentsLoading = ref(false)
const boundStudents = ref<BoundStudentSummary[]>([])
const selectedStudentId = ref('')
const student = computed((): StudentHomeProfile => homeStudentProfile.value ?? loadingStudent.value)
const metrics = computed((): StudentHomeMetrics => homeStudentMetrics.value ?? {
studentId: '', onlineToday: false, checkedInToday: false, learningMinutesToday: 0,
accuracyToday: null, learningDaysThisWeek: 0, pendingHomeworkCount: 0, updatedAt: ''
})
const alerts = computed((): StudentAlert[] => homeStudentAlerts.value ?? [])
const studentInitial = computed((): string => {
return student.value.name.length > 0 ? student.value.name.substring(0, 1) : '学'
})
const levelText = computed((): string => {
if (student.value.comprehensiveLevel === 'excellent') return '优秀'
if (student.value.comprehensiveLevel === 'good') return '良好'
if (student.value.comprehensiveLevel === 'qualified') return '合格'
return '待提升'
})
const learningDurationText = computed((): string => {
const hours = Math.floor(metrics.value.learningMinutesToday / 60)
const minutes = metrics.value.learningMinutesToday % 60
return hours > 0 ? `${hours}h${minutes}m` : `${minutes}m`
})
const accuracyText = computed((): string => {
return metrics.value.accuracyToday === null ? '--' : `${metrics.value.accuracyToday}%`
})
const metricsUpdateText = computed((): string => {
return metrics.value.updatedAt.length > 0 ? metrics.value.updatedAt.substring(5, 16).replace('T', ' ') : '--'
})
onLoad(() => {
if (!homeStudentProfile.value || !homeStudentMetrics.value || homeStudentAlerts.value === null) {
loadHomeData()
}
})
async function loadStudentHomeProfile(studentId?: string, showLoading: boolean = true) {
const res = await apiGetStudentHomeProfile(studentId, showLoading)
if (res && res.data) {
setHomeStudentProfile(res.data)
}
}
async function loadStudentHomeMetrics(studentId: string, showLoading: boolean = true) {
const res = await apiGetStudentHomeMetrics(studentId, showLoading)
if (res && res.data) {
setHomeStudentMetrics(res.data)
}
}
async function loadStudentAlerts(studentId: string, showLoading: boolean = true) {
const res = await apiGetStudentAlerts(studentId, 1, 20, 3, showLoading)
if (res && res.data) {
setHomeStudentAlerts(res.data.list)
}
}
async function loadHomeData(studentId?: string, showLoading: boolean = true) {
await loadStudentHomeProfile(studentId, showLoading)
if (student.value.id) {
await Promise.all([
loadStudentHomeMetrics(student.value.id, showLoading),
loadStudentAlerts(student.value.id, showLoading)
])
}
}
async function handleRefresh() {
if (isRefreshing.value) return
isRefreshing.value = true
await loadHomeData(student.value.id, false)
isRefreshing.value = false
}
async function openStudentPicker() {
showStudentPicker.value = true
selectedStudentId.value = student.value.id
if (boundStudents.value.length > 0) return
isStudentsLoading.value = true
const res = await apiGetBoundStudents()
if (res && res.data) {
boundStudents.value = res.data.students
if (!selectedStudentId.value && res.data.currentStudentId) {
selectedStudentId.value = res.data.currentStudentId
}
}
isStudentsLoading.value = false
}
function selectStudent(studentId: string) {
selectedStudentId.value = studentId
}
function alertLevelClass(level: StudentAlertLevel): string {
return level === 'low' ? 'reminder-blue' : level === 'medium' ? 'reminder-yellow' : 'reminder-red'
}
function goAlerts() {
uni.navigateTo({ url: '/pages/alerts/index' })
}
function goTodayLearning() {
uni.navigateTo({ url: '/pages/today-learning/index' })
}
function goStudyReport() {
uni.navigateTo({ url: '/pages/study/index' })
}
function goVocabulary() {
uni.navigateTo({ url: '/pages/vocabulary/index' })
}
function goGrowth() {
uni.navigateTo({ url: '/pages/growth/index' })
}
function closeStudentPicker() {
showStudentPicker.value = false
}
async function confirmStudent() {
if (!selectedStudentId.value) {
uni.showToast({ title: '请选择学生', icon: 'none' })
return
}
closeStudentPicker()
if (selectedStudentId.value !== student.value.id) {
await loadHomeData(selectedStudentId.value)
}
}
</script>
<style>
.page-container {
display: flex;
flex-direction: column;
flex: 1;
background-color: #f5f7fb;
}
.header-panel {
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: space-between;
padding: 15px 20px 17px;
background: linear-gradient(135deg, #5d3de8 0%, #8236e9 100%);
border-radius: 0 0 0 0;
}
.header-copy {
display: flex;
flex-direction: column;
flex: 1;
}
.header-greeting {
font-size: 13px;
color: #e5ddff;
margin-bottom: 5px;
}
.header-title {
font-size: 20px;
font-weight: 700;
color: #ffffff;
margin-bottom: 8px;
}
.header-update {
font-size: 10px;
color: #e5ddff;
line-height: 15px;
}
.notification-button {
position: relative;
width: 42px;
height: 42px;
margin-top: 2px;
margin-left: 12px;
border-radius: 22px;
background-color: rgba(255, 255, 255, 0.18);
}
.notification-bell {
position: absolute;
left: 14px;
top: 13px;
width: 14px;
height: 14px;
border-radius: 8px 8px 3px 3px;
background-color: #ffffff;
}
.notification-clapper {
position: absolute;
left: 12px;
bottom: 11px;
width: 18px;
height: 3px;
border-radius: 2px;
background-color: #ffffff;
}
.notification-dot {
position: absolute;
right: 9px;
top: 9px;
width: 7px;
height: 7px;
border-radius: 4px;
background-color: #ffd64a;
}
.content-scroll {
flex: 1;
padding: 12px 20px 18px;
}
.student-panel,
.reminder-section {
padding: 16px;
background-color: #ffffff;
border-radius: 16px;
margin-bottom: 12px;
}
.student-row {
display: flex;
flex-direction: row;
align-items: center;
}
.student-avatar {
position: relative;
width: 48px;
height: 48px;
border-radius: 15px;
background-color: #7161ed;
margin-right: 12px;
}
.student-avatar-image {
width: 48px;
height: 48px;
border-radius: 15px;
}
.student-picker-panel { display: flex; flex-direction: column; background-color: #ffffff; }
.student-picker-header { display: flex; flex-direction: column; padding: 18px 20px 12px; }
.student-picker-title { font-size: 18px; font-weight: 700; color: #242b3d; }
.student-picker-subtitle { margin-top: 5px; font-size: 12px; color: #8992a5; }
.student-picker-list { max-height: 340px; padding: 0 20px; }
.student-picker-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 150px; }
.student-picker-empty-text { font-size: 13px; color: #8992a5; }
.student-picker-item { display: flex; flex-direction: row; align-items: center; padding: 14px 12px; margin-bottom: 10px; border-radius: 13px; background-color: #f7f9fc; }
.student-picker-item-selected { background-color: #f0efff; }
.student-picker-avatar { display: flex; flex-direction: column; align-items: center; justify-content: center; width: 46px; height: 46px; border-radius: 14px; background-color: #7161ed; }
.student-picker-avatar-image { width: 46px; height: 46px; border-radius: 14px; }
.student-picker-avatar-text { font-size: 18px; font-weight: 700; color: #ffffff; }
.student-picker-info { display: flex; flex-direction: column; flex: 1; margin-left: 11px; }
.student-picker-name-row { display: flex; flex-direction: row; align-items: center; margin-bottom: 5px; }
.student-picker-name { margin-right: 8px; font-size: 15px; font-weight: 700; color: #2a3042; }
.student-picker-grade { padding: 2px 6px; border-radius: 7px; font-size: 10px; color: #554be9; background-color: #e8e7ff; }
.student-picker-detail { margin-top: 2px; font-size: 11px; color: #7e8799; }
.student-picker-check { padding: 4px 7px; border-radius: 9px; background-color: #554be9; }
.student-picker-check-text { font-size: 10px; color: #ffffff; }
.student-picker-footer { display: flex; flex-direction: row; padding: 12px 20px calc(12px + var(--uni-safe-area-inset-bottom)); border-top: 1px solid #edf0f5; }
.student-picker-cancel, .student-picker-confirm { display: flex; flex-direction: column; align-items: center; justify-content: center; flex: 1; height: 44px; border-radius: 11px; }
.student-picker-cancel { margin-right: 10px; background-color: #f0f2f7; }
.student-picker-confirm { background-color: #554be9; }
.student-picker-cancel-text { font-size: 15px; color: #596275; }
.student-picker-confirm-text { font-size: 15px; color: #ffffff; }
.avatar-face {
position: absolute;
left: 8px;
top: 12px;
font-size: 20px;
font-weight: 700;
color: #ffd69c;
}
.avatar-cap {
position: absolute;
right: 4px;
top: 6px;
font-size: 15px;
font-weight: 700;
color: #35394d;
}
.student-identity {
display: flex;
flex-direction: column;
flex: 1;
}
.student-name-row,
.student-level-row {
display: flex;
flex-direction: row;
align-items: center;
}
.student-name-row {
margin-bottom: 8px;
}
.student-name {
font-size: 17px;
font-weight: 700;
color: #22283a;
margin-right: 8px;
}
.student-grade {
padding: 2px 7px;
border-radius: 8px;
font-size: 11px;
color: #635ce8;
background-color: #eef0ff;
}
.level-medal {
width: 13px;
height: 13px;
border-radius: 7px;
background-color: #f4a400;
margin-right: 7px;
}
.student-level-label,
.metric-label {
font-size: 11px;
color: #8992a5;
}
.student-level-label {
margin-right: 9px;
}
.student-level-value {
font-size: 12px;
font-weight: 600;
color: #18a56b;
}
.switch-button {
display: flex;
flex-direction: row;
align-items: center;
padding: 8px 10px;
border-radius: 17px;
background-color: #eef1ff;
}
.switch-icon {
width: 13px;
height: 10px;
border-radius: 7px 7px 3px 3px;
background-color: #5b58e9;
margin-right: 5px;
}
.switch-text {
font-size: 12px;
color: #5b58e9;
}
.metrics-grid {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-between;
margin-top: 13px;
}
.metric-item {
display: flex;
flex-direction: column;
width: 31.5%;
padding: 10px 8px;
margin-bottom: 7px;
border-radius: 12px;
background-color: #f7f9fc;
}
.metric-label {
margin-bottom: 7px;
}
.metric-value {
font-size: 15px;
font-weight: 700;
color: #20283a;
}
.metric-green {
color: #079b69;
}
.metric-blue {
color: #4b62d8;
}
.metric-orange {
color: #ee8900;
}
.section-heading {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.section-title {
font-size: 16px;
font-weight: 700;
color: #242b3d;
}
.section-link {
font-size: 12px;
color: #514ce8;
}
.reminder-list {
display: flex;
flex-direction: column;
}
.reminder-item {
display: flex;
flex-direction: row;
align-items: flex-start;
padding: 11px 10px;
margin-bottom: 8px;
border-radius: 12px;
background-color: #f7f9fc;
}
.reminder-item-last {
margin-bottom: 0;
}
.reminder-tag {
flex-shrink: 0;
padding: 3px 7px;
margin-right: 9px;
border-radius: 9px;
font-size: 10px;
}
.reminder-yellow {
color: #e69300;
background-color: #fff5d9;
}
.reminder-red {
color: #ed4a5c;
background-color: #fff0f2;
}
.reminder-text {
flex: 1;
font-size: 12px;
line-height: 20px;
color: #596275;
}
.reminder-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 72px; }
.reminder-empty-text { font-size: 13px; color: #8992a5; }
.reminder-blue { color: #3c79cf; background-color: #e8f1ff; }
.entry-section {
padding-bottom: 10px;
}
.entry-grid {
display: flex;
flex-direction: row;
justify-content: space-between;
margin-top: 12px;
}
.entry-card {
display: flex;
flex-direction: column;
align-items: center;
width: 23%;
padding: 8px 2px;
border-radius: 8px;
background-color: #ffffff;
}
.entry-icon {
margin-bottom: 9px;
font-size: 24px;
line-height: 24px;
color: #6558e9;
}
.entry-title {
font-size: 13px;
font-weight: 600;
color: #2a3042;
}
</style>
+360
View File
@@ -0,0 +1,360 @@
<template>
<view class="page-container">
<scroll-view class="content-scroll" direction="vertical">
<!-- 步骤指示器 -->
<view class="steps-bar">
<view class="step-item">
<view :class="step >= 1 ? 'step-dot-active' : 'step-dot'"></view>
<text :class="step >= 1 ? 'step-label-active' : 'step-label'">验证手机</text>
</view>
<view class="step-line"></view>
<view class="step-item">
<view :class="step >= 2 ? 'step-dot-active' : 'step-dot'"></view>
<text :class="step >= 2 ? 'step-label-active' : 'step-label'">设置密码</text>
</view>
<view class="step-line"></view>
<view class="step-item">
<view :class="step >= 3 ? 'step-dot-active' : 'step-dot'"></view>
<text :class="step >= 3 ? 'step-label-active' : 'step-label'">完成</text>
</view>
</view>
<!-- 步骤一: 验证手机号 -->
<view v-if="step === 1" class="form-section">
<text class="section-title">验证手机号</text>
<text class="section-desc">请输入注册时使用的手机号</text>
<view class="input-wrapper">
<input
class="input-field"
type="number"
v-model="phone"
placeholder="请输入手机号"
maxlength="11"
/>
</view>
<view class="input-wrapper">
<input
class="input-field"
type="number"
v-model="code"
placeholder="请输入验证码"
maxlength="6"
/>
<countdown-btn
ref="countdownBtnRef"
:phone="phone"
codeType="forgot-password"
@send="handleSendCode"
/>
</view>
<view
class="btn-main"
:class="canStep1 ? 'btn-active' : 'btn-disabled'"
@tap="handleStep1"
>
<text class="btn-text">下一步</text>
</view>
</view>
<!-- 步骤二: 设置新密码 -->
<view v-if="step === 2" class="form-section">
<text class="section-title">设置新密码</text>
<text class="section-desc">请设置6-20位新密码</text>
<view class="input-wrapper">
<input
class="input-field"
type="password"
v-model="newPassword"
placeholder="请输入新密码(6-20位)"
maxlength="20"
/>
</view>
<view class="input-wrapper">
<input
class="input-field"
type="password"
v-model="confirmPassword"
placeholder="请再次输入新密码"
maxlength="20"
/>
</view>
<view
class="btn-main"
:class="canStep2 ? 'btn-active' : 'btn-disabled'"
@tap="handleStep2"
>
<text class="btn-text">完成</text>
</view>
</view>
<!-- 步骤三: 完成 -->
<view v-if="step === 3" class="success-section">
<view class="success-icon-wrap">
<view class="success-mark"></view>
</view>
<text class="success-title">密码重置成功</text>
<text class="success-desc">请使用新密码登录</text>
<view class="btn-main btn-active" @tap="handleGoLogin">
<text class="btn-text">去登录</text>
</view>
</view>
<!-- 返回登录 -->
<text class="back-text" @tap="handleBack">返回登录</text>
</scroll-view>
</view>
</template>
<script setup lang="ts">
import { apiSendCode, apiVerifyCode, apiResetPassword } from '../../api/auth'
import CountdownBtn from '../../components/countdown-btn.uvue'
const step = ref(1)
const phone = ref('')
const code = ref('')
const resetToken = ref('')
const newPassword = ref('')
const confirmPassword = ref('')
const countdownBtnRef = ref<any>(null)
const canStep1 = computed((): boolean => {
return /^1[3-9]\d{9}$/.test(phone.value) && code.value.length === 6
})
const canStep2 = computed((): boolean => {
return (
newPassword.value.length >= 6 &&
newPassword.value.length <= 20 &&
newPassword.value === confirmPassword.value
)
})
async function handleSendCode(p: string) {
if (!/^1[3-9]\d{9}$/.test(p)) {
uni.showToast({ title: '请输入正确的手机号', icon: 'none' })
return
}
const res = await apiSendCode({ phone: p, type: 'forgot-password' })
if (res && res.code === 0) {
uni.showToast({ title: '验证码已发送', icon: 'success' })
countdownBtnRef.value?.startCountdown()
}
}
async function handleStep1() {
if (!canStep1.value) return
const res = await apiVerifyCode({ phone: phone.value, code: code.value })
if (res && res.data) {
resetToken.value = res.data.token
step.value = 2
}
}
async function handleStep2() {
if (!canStep2.value) {
if (newPassword.value !== confirmPassword.value) {
uni.showToast({ title: '两次密码输入不一致', icon: 'none' })
}
return
}
const res = await apiResetPassword({
phone: phone.value,
newPassword: newPassword.value,
resetToken: resetToken.value
})
if (res && res.code === 0) {
step.value = 3
}
}
function handleGoLogin() {
uni.navigateTo({ url: '/pages/login/index' })
}
function handleBack() {
uni.navigateBack()
}
</script>
<style>
.page-container {
display: flex;
flex-direction: column;
flex: 1;
background-color: #FFFFFF;
}
.content-scroll {
flex: 1;
padding: 24px 24px calc(32px + var(--uni-safe-area-inset-bottom));
}
/* 步骤条 */
.steps-bar {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-bottom: 40px;
}
.step-item {
display: flex;
flex-direction: column;
align-items: center;
}
.step-dot {
width: 24px;
height: 24px;
border-radius: 12px;
background-color: #E5E7EB;
margin-bottom: 4px;
}
.step-dot-active {
width: 24px;
height: 24px;
border-radius: 12px;
background-color: #6C63FF;
margin-bottom: 4px;
}
.step-label {
font-size: 12px;
color: #9CA3AF;
}
.step-label-active {
font-size: 12px;
color: #6C63FF;
font-weight: 600;
}
.step-line {
flex: 1;
height: 2px;
background-color: #E5E7EB;
margin: 0 8px;
margin-bottom: 20px;
}
/* 表单 */
.form-section {
display: flex;
flex-direction: column;
}
.section-title {
font-size: 20px;
font-weight: 600;
color: #1F2937;
margin-bottom: 8px;
}
.section-desc {
font-size: 14px;
color: #6B7280;
margin-bottom: 28px;
}
.input-wrapper {
display: flex;
flex-direction: row;
align-items: center;
height: 52px;
background-color: #F9FAFB;
border-radius: 12px;
padding: 0 16px;
margin-bottom: 16px;
}
.input-field {
flex: 1;
font-size: 16px;
color: #1F2937;
}
/* 按钮 */
.btn-main {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
height: 52px;
border-radius: 26px;
margin-top: 8px;
}
.btn-active {
background-color: #6C63FF;
}
.btn-disabled {
background-color: #E5E7EB;
}
.btn-text {
font-size: 16px;
font-weight: 600;
color: #FFFFFF;
}
/* 成功页 */
.success-section {
display: flex;
flex-direction: column;
align-items: center;
padding-top: 40px;
}
.success-icon-wrap {
width: 80px;
height: 80px;
background-color: #ECFDF5;
border-radius: 40px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-bottom: 24px;
}
.success-mark {
width: 28px;
height: 14px;
border-left: 4px solid #10B981;
border-bottom: 4px solid #10B981;
transform: rotate(-45deg);
}
.success-title {
font-size: 22px;
font-weight: 600;
color: #1F2937;
margin-bottom: 8px;
}
.success-desc {
font-size: 14px;
color: #6B7280;
margin-bottom: 40px;
}
/* 返回 */
.back-text {
font-size: 14px;
color: #6C63FF;
text-align: center;
margin-top: 20px;
}
</style>
+333
View File
@@ -0,0 +1,333 @@
<template>
<view class="login-container">
<scroll-view class="content-scroll" direction="vertical">
<!-- Logo区域 -->
<view class="logo-section">
<image class="logo" src="/static/logo.png" mode="aspectFit"></image>
<text class="app-title">家长学情</text>
<text class="app-subtitle">陪伴孩子的每一次进步</text>
</view>
<!-- 表单区域 -->
<view class="form-section">
<text class="form-title">登录家长端</text>
<text class="form-subtitle">使用注册的手机号与密码登录,查看孩子学情</text>
<!-- 手机号输入 -->
<view class="input-wrapper">
<input
class="input-field"
type="number"
v-model="form.phone"
placeholder="请输入手机号"
maxlength="11"
/>
</view>
<!-- 密码输入 -->
<view class="input-wrapper">
<input
class="input-field"
type="password"
v-model="form.password"
placeholder="请输入密码"
maxlength="20"
/>
</view>
<!-- 记住密码和忘记密码 -->
<view class="option-row">
<view class="remember-row" @tap="form.remember = !form.remember">
<view :class="form.remember ? 'checkbox-checked' : 'checkbox'"></view>
<text class="remember-text">记住密码</text>
</view>
<text class="forgot-text" @tap="handleForgotPassword">忘记密码?</text>
</view>
<!-- 登录按钮 -->
<view
class="btn-login"
:class="canSubmit ? 'btn-login-active' : 'btn-login-disabled'"
@tap="handleLogin"
>
<text class="btn-text">登录</text>
</view>
<!-- 切换验证码登录 -->
<text class="switch-text" @tap="handleSwitchToCodeLogin">验证码登录</text>
</view>
<!-- 底部注册入口 -->
<view class="footer-section">
<text class="footer-text">还没有账号? </text>
<text class="register-text" @tap="handleRegister">注册</text>
</view>
</scroll-view>
</view>
</template>
<script setup lang="ts">
import { apiLogin } from '../../api/auth'
import { setUserInfo, saveRememberInfo, getRememberInfo } from '../../store/user'
type LoginForm = {
phone: string
password: string
remember: boolean
}
const form = reactive<LoginForm>({
phone: '',
password: '',
remember: false
})
// 计算是否可以提交
const canSubmit = computed((): boolean => {
const phoneReg = /^1[3-9]\d{9}$/
return phoneReg.test(form.phone) && form.password.length >= 6
})
onLoad(() => {
// 加载记住的密码信息
const rememberInfo = getRememberInfo()
if (rememberInfo.remember) {
form.phone = rememberInfo.phone
form.password = rememberInfo.password
form.remember = true
}
})
/**
* 登录
*/
async function handleLogin() {
if (!canSubmit.value) return
const res = await apiLogin({
phone: form.phone,
password: form.password,
remember: form.remember
})
if (res && res.data) {
// 保存用户信息
setUserInfo(res.data)
// 保存记住密码信息
saveRememberInfo(form.phone, form.password, form.remember)
uni.showToast({
title: '登录成功',
icon: 'success'
})
// 跳转首页
setTimeout(() => {
uni.reLaunch({
url: '/pages/index/index'
})
}, 1000)
}
}
/**
* 切换到验证码登录
*/
function handleSwitchToCodeLogin() {
uni.navigateTo({
url: '/pages/login/verify-code'
})
}
/**
* 忘记密码
*/
function handleForgotPassword() {
uni.navigateTo({
url: '/pages/login/forgot-password'
})
}
/**
* 注册
*/
function handleRegister() {
uni.navigateTo({
url: '/pages/register/index'
})
}
</script>
<style>
.login-container {
display: flex;
flex-direction: column;
flex: 1;
background-color: #FFFFFF;
}
.content-scroll {
flex: 1;
padding: 24px 24px calc(24px + var(--uni-safe-area-inset-bottom));
}
/* Logo区域 */
.logo-section {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 32px;
}
.logo {
width: 80px;
height: 80px;
border-radius: 16px;
margin-bottom: 16px;
}
.app-title {
font-size: 24px;
font-weight: 600;
color: #1F2937;
margin-bottom: 8px;
}
.app-subtitle {
font-size: 14px;
color: #9CA3AF;
}
/* 表单区域 */
.form-section {
display: flex;
flex-direction: column;
flex: 1;
}
.form-title {
font-size: 20px;
font-weight: 600;
color: #1F2937;
margin-bottom: 8px;
}
.form-subtitle {
font-size: 14px;
color: #6B7280;
margin-bottom: 32px;
}
/* 输入框 */
.input-wrapper {
display: flex;
flex-direction: row;
align-items: center;
height: 52px;
background-color: #F9FAFB;
border-radius: 12px;
padding: 0 16px;
margin-bottom: 16px;
}
.input-field {
flex: 1;
font-size: 16px;
color: #1F2937;
}
/* 选项行 */
.option-row {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
}
.remember-row {
display: flex;
flex-direction: row;
align-items: center;
}
.checkbox {
width: 18px;
height: 18px;
border: 2px solid #D1D5DB;
border-radius: 4px;
margin-right: 8px;
}
.checkbox-checked {
width: 18px;
height: 18px;
background-color: #6C63FF;
border-radius: 4px;
margin-right: 8px;
}
.remember-text {
font-size: 14px;
color: #6B7280;
}
.forgot-text {
font-size: 14px;
color: #6C63FF;
}
/* 登录按钮 */
.btn-login {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
height: 52px;
border-radius: 26px;
margin-bottom: 20px;
}
.btn-login-active {
background-color: #6C63FF;
}
.btn-login-disabled {
background-color: #E5E7EB;
}
.btn-text {
font-size: 16px;
font-weight: 600;
color: #FFFFFF;
}
/* 切换文字 */
.switch-text {
font-size: 14px;
color: #6C63FF;
text-align: center;
}
/* 底部区域 */
.footer-section {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-top: auto;
padding-top: 24px;
}
.footer-text {
font-size: 14px;
color: #6B7280;
}
.register-text {
font-size: 14px;
color: #6C63FF;
font-weight: 600;
}
</style>
+230
View File
@@ -0,0 +1,230 @@
<template>
<view class="page-container">
<scroll-view class="content-scroll" direction="vertical">
<!-- 顶部 -->
<view class="logo-section">
<image class="logo" src="/static/logo.png" mode="aspectFit"></image>
<text class="page-title">验证码登录</text>
<text class="page-subtitle">使用手机验证码快速登录</text>
</view>
<!-- 表单区域 -->
<view class="form-section">
<!-- 手机号输入 -->
<view class="input-wrapper">
<input
class="input-field"
type="number"
v-model="phone"
placeholder="请输入手机号"
maxlength="11"
/>
</view>
<!-- 验证码输入 -->
<view class="input-wrapper">
<input
class="input-field"
type="number"
v-model="code"
placeholder="请输入验证码"
maxlength="6"
/>
<countdown-btn
ref="countdownBtnRef"
:phone="phone"
codeType="login"
@send="handleSendCode"
/>
</view>
<!-- 登录按钮 -->
<view
class="btn-login"
:class="canSubmit ? 'btn-active' : 'btn-disabled'"
@tap="handleLogin"
>
<text class="btn-text">登录</text>
</view>
<!-- 切换到密码登录 -->
<text class="switch-text" @tap="handleBack">密码登录</text>
</view>
<!-- 底部注册 -->
<view class="footer-section">
<text class="footer-text">还没有账号? </text>
<text class="register-text" @tap="handleRegister">注册</text>
</view>
</scroll-view>
</view>
</template>
<script setup lang="ts">
import { apiLoginByCode, apiSendCode } from '../../api/auth'
import CountdownBtn from '../../components/countdown-btn.uvue'
import { setUserInfo } from '../../store/user'
const phone = ref('')
const code = ref('')
const countdownBtnRef = ref<any>(null)
const canSubmit = computed((): boolean => {
const phoneReg = /^1[3-9]\d{9}$/
return phoneReg.test(phone.value) && code.value.length === 6
})
/**
* 发送验证码
*/
async function handleSendCode(p: string) {
if (!/^1[3-9]\d{9}$/.test(p)) {
uni.showToast({ title: '请输入正确的手机号', icon: 'none' })
return
}
const res = await apiSendCode({ phone: p, type: 'login' })
if (res && res.code === 0) {
uni.showToast({ title: '验证码已发送', icon: 'success' })
countdownBtnRef.value?.startCountdown()
}
}
/**
* 登录
*/
async function handleLogin() {
if (!canSubmit.value) return
const res = await apiLoginByCode({ phone: phone.value, code: code.value })
if (res && res.data) {
setUserInfo(res.data)
uni.showToast({ title: '登录成功', icon: 'success' })
setTimeout(() => {
uni.reLaunch({ url: '/pages/index/index' })
}, 1000)
}
}
function handleBack() {
uni.navigateBack()
}
function handleRegister() {
uni.navigateTo({ url: '/pages/register/index' })
}
</script>
<style>
.page-container {
display: flex;
flex-direction: column;
flex: 1;
background-color: #FFFFFF;
}
.content-scroll {
flex: 1;
padding: 24px 24px calc(24px + var(--uni-safe-area-inset-bottom));
}
.logo-section {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 32px;
}
.logo {
width: 80px;
height: 80px;
border-radius: 16px;
margin-bottom: 16px;
}
.page-title {
font-size: 24px;
font-weight: 600;
color: #1F2937;
margin-bottom: 8px;
}
.page-subtitle {
font-size: 14px;
color: #9CA3AF;
}
.form-section {
display: flex;
flex-direction: column;
flex: 1;
}
.input-wrapper {
display: flex;
flex-direction: row;
align-items: center;
height: 52px;
background-color: #F9FAFB;
border-radius: 12px;
padding: 0 16px;
margin-bottom: 16px;
}
.input-field {
flex: 1;
font-size: 16px;
color: #1F2937;
}
.btn-login {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
height: 52px;
border-radius: 26px;
margin-bottom: 20px;
margin-top: 8px;
}
.btn-active {
background-color: #6C63FF;
}
.btn-disabled {
background-color: #E5E7EB;
}
.btn-text {
font-size: 16px;
font-weight: 600;
color: #FFFFFF;
}
.switch-text {
font-size: 14px;
color: #6C63FF;
text-align: center;
}
.footer-section {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-top: auto;
padding-top: 24px;
}
.footer-text {
font-size: 14px;
color: #6B7280;
}
.register-text {
font-size: 14px;
color: #6C63FF;
font-weight: 600;
}
</style>
+159
View File
@@ -0,0 +1,159 @@
<template>
<view class="page-container">
<scroll-view class="content-scroll" direction="vertical" scroll-y="true">
<view class="parent-card">
<view class="parent-avatar">
<text class="parent-avatar-text">👩</text>
</view>
<view class="parent-copy">
<text class="parent-name">{{ profileName }}</text>
<text class="parent-detail">{{ listFailed ? '学生信息加载失败' : isLoading ? '学生信息加载中' : '已关联 ' + students.length + ' 名学生' }} · 手机尾号</text>
<text class="parent-phone">6688</text>
</view>
<text class="parent-account">家长账号</text>
</view>
<view class="children-card">
<view class="section-heading">
<text class="section-title">我的孩子</text>
<text class="section-hint">点卡片可切换查看</text>
</view>
<view v-if="isLoading" class="loading-row">
<text class="loading-text">孩子信息加载中</text>
</view>
<view v-else-if="listFailed" class="loading-row" @tap="loadStudents"><text class="loading-text">加载失败,点击重试</text></view>
<view v-else v-for="(student, index) in students" :key="student.id" class="child-row" :class="selectedStudentId === student.id ? 'child-row-selected' : ''" @tap="selectStudent(student.id)">
<view class="child-avatar" :class="'child-avatar-' + index">
<text class="child-avatar-text">{{ student.name.substring(1, 2) }}</text>
</view>
<view class="child-copy">
<view class="child-name-row">
<text class="child-name">{{ student.name }}</text>
<text class="child-role">{{ index === 0 ? '儿子' : index === 1 ? '侄子' : '女儿' }}</text>
<text class="child-status">{{ index === 1 ? '中上' : '优秀' }}</text>
</view>
<text class="child-school">{{ student.school }} · {{ student.grade }}({{ index + 1 }})班</text>
</view>
<text class="child-action" :class="selectedStudentId === student.id ? 'child-action-selected' : ''">{{ selectedStudentId === student.id ? '当前查看' : '切换' }}</text>
</view>
<view class="binding-actions">
<view class="scan-button" @tap="openVerification('scan')">
<text class="binding-icon">▦</text>
<text class="binding-text binding-text-primary">扫码绑定</text>
</view>
<view class="manual-button" @tap="openVerification('manual')">
<text class="binding-icon binding-icon-dark">▤</text>
<text class="binding-text">手动输入</text>
</view>
</view>
<text class="binding-note">扫码或手动输入可验证学生信息;新增绑定功能尚需接入真实接口。如需解绑,请联系运营人员。</text>
</view>
<view class="explanation-card">
<text class="section-title">数据说明</text>
<text class="explanation-text">● 报告类数据(日报、周报、月报)由定时任务预计算,非实时,页面均展示“数据更新时间”。</text>
</view>
<view class="logout-card" @tap="handleLogout">
<text class="logout-text">退出登录</text>
</view>
</scroll-view>
<MainTabBar active-tab="profile"></MainTabBar>
</view>
</template>
<script setup lang="ts">
import MainTabBar from '../../components/main-tab-bar.uvue'
import { apiGetBoundStudents, type BoundStudentSummary } from '../../api/student'
import { homeStudentProfile, setHomeStudentProfile } from '../../store/home'
import type { UserInfo } from '../../api/auth'
import { clearUserInfo } from '../../store/user'
const user = uni.getStorageSync('userInfo') as UserInfo | null
const profileName = user?.nickname || '大大怪'
const students = ref<BoundStudentSummary[]>([])
const selectedStudentId = ref(homeStudentProfile.value?.id ?? '')
const isLoading = ref(true)
const listFailed = ref(false)
onLoad(() => { loadStudents() })
async function loadStudents() {
isLoading.value = true
listFailed.value = false
try {
const res = await apiGetBoundStudents()
if (res && res.code === 0 && res.data) {
students.value = res.data.students
if (!selectedStudentId.value) selectedStudentId.value = res.data.currentStudentId ?? ''
} else listFailed.value = true
} catch (_error) { listFailed.value = true }
finally { isLoading.value = false }
}
function selectStudent(studentId: string) {
selectedStudentId.value = studentId
const student = students.value.find((item) => item.id === studentId)
if (student) setHomeStudentProfile({ id: student.id, name: student.name, account: student.account, grade: student.grade, school: student.school, avatar: student.avatar, comprehensiveLevel: 'good' })
}
function openVerification(mode: string) { uni.navigateTo({ url: '/pages/profile/verify-student?mode=' + mode }) }
function handleLogout() {
uni.showModal({
title: '退出登录',
content: '确定要退出当前账号吗?',
confirmText: '退出登录',
cancelText: '取消',
success: (result) => {
if (result.confirm) {
clearUserInfo()
uni.reLaunch({ url: '/pages/login/index' })
}
}
})
}
</script>
<style>
.page-container { display: flex; flex-direction: column; flex: 1; background-color: #f5f7fb; }
.content-scroll { flex: 1; }
.parent-card { display: flex; flex-direction: row; align-items: center; margin: 20px 14px 0; padding: 18px 16px; border-radius: 22px 22px 0 0; background-color: #6542eb; }
.parent-avatar { display: flex; flex-direction: column; align-items: center; justify-content: center; width: 46px; height: 46px; border-radius: 14px; background-color: #8469f1; }
.parent-avatar-text { font-size: 25px; }
.parent-copy { display: flex; flex-direction: column; flex: 1; margin-left: 12px; }
.parent-name { font-size: 18px; font-weight: 700; color: #ffffff; }
.parent-detail { margin-top: 4px; font-size: 11px; color: #eee9ff; }
.parent-phone { margin-top: 1px; font-size: 11px; color: #eee9ff; }
.parent-account { padding: 7px 11px; border-radius: 16px; font-size: 11px; color: #ffffff; background-color: #8064ed; }
.children-card { display: flex; flex-direction: column; margin: 0 14px; padding: 16px; border-radius: 0 0 16px 16px; background-color: #ffffff; }
.explanation-card { display: flex; flex-direction: column; margin: 0 14px; padding: 16px; background-color: #ffffff; }
.section-heading { display: flex; flex-direction: row; align-items: center; justify-content: space-between; }
.section-title { font-size: 16px; font-weight: 700; color: #242b3d; }
.section-hint { font-size: 11px; color: #8c9ab0; }
.loading-row { display: flex; flex-direction: column; align-items: center; padding: 26px 0; }
.loading-text { font-size: 12px; color: #8992a5; }
.child-row { display: flex; flex-direction: row; align-items: center; margin-top: 10px; padding: 10px; border: 1px solid #edf0f5; border-radius: 15px; background-color: #ffffff; }
.child-row-selected { border: 2px solid #554be9; padding: 9px; background-color: #fafaff; }
.child-avatar { display: flex; flex-direction: column; align-items: center; justify-content: center; width: 42px; height: 42px; border-radius: 12px; }
.child-avatar-0 { background-color: #6558e9; }
.child-avatar-1 { background-color: #12a9df; }
.child-avatar-2 { background-color: #e63a9c; }
.child-avatar-text { font-size: 18px; color: #ffffff; }
.child-copy { display: flex; flex-direction: column; flex: 1; margin-left: 10px; min-width: 0; }
.child-name-row { display: flex; flex-direction: row; align-items: center; }
.child-name { font-size: 14px; color: #242b3d; }
.child-role { margin-left: 7px; padding: 2px 6px; border-radius: 8px; font-size: 9px; color: #7c8ba1; background-color: #f1f4f8; }
.child-status { margin-left: 7px; padding: 2px 6px; border-radius: 8px; font-size: 9px; color: #16a979; background-color: #e7faf2; }
.child-school { margin-top: 4px; font-size: 10px; color: #8a99ae; }
.child-action { flex-shrink: 0; padding: 5px 8px; border-radius: 10px; font-size: 9px; color: #6e7c91; background-color: #f1f4f8; }
.child-action-selected { color: #ffffff; background-color: #554be9; }
.binding-actions { display: flex; flex-direction: row; margin-top: 10px; }
.scan-button { display: flex; flex-direction: row; align-items: center; justify-content: center; flex: 1; height: 38px; border-radius: 11px; background-color: #554be9; }
.manual-button { display: flex; flex-direction: row; align-items: center; justify-content: center; flex: 1; height: 38px; border-radius: 11px; margin-left: 8px; background-color: #f1f4f8; }
.binding-icon { margin-right: 6px; font-size: 14px; color: #ffffff; }
.binding-icon-dark { color: #65758b; }
.binding-text { font-size: 12px; color: #65758b; }
.binding-text-primary { color: #ffffff; }
.binding-note { margin-top: 9px; padding: 10px; font-size: 10px; line-height: 17px; color: #8a99ae; background-color: #f6f8fb; }
.explanation-card { margin-top: 16px; border-radius: 16px 16px 0 0; }
.explanation-text { margin-top: 14px; font-size: 11px; line-height: 20px; color: #718097; }
.logout-card { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 48px; margin: 16px 14px 24px; border-radius: 14px; background-color: #ffffff; }
.logout-text { font-size: 14px; color: #ec5365; }
</style>
+130
View File
@@ -0,0 +1,130 @@
<template>
<view class="page">
<scroll-view class="content" direction="vertical">
<view class="intro">
<text class="title">验证孩子的学生信息</text>
<text class="description">扫码或输入学生账号和姓名进行验证。验证不等于绑定,新增绑定仍需接入真实接口。</text>
</view>
<view class="card">
<text class="section-title">已绑定学生</text>
<text v-if="loading" class="hint">正在加载已绑定列表...</text>
<view v-else-if="loadFailed" class="retry" @tap="loadStudents"><text class="retry-text">列表加载失败,点击重试</text></view>
<text v-else class="hint">当前 {{ students.length }} / 3 名{{ students.length >= 3 ? ',已达到上限' : '' }}</text>
</view>
<view class="card">
<view class="tabs">
<view class="tab" :class="mode === 'scan' ? 'tab-active' : ''" @tap="changeMode('scan')"><text class="tab-text" :class="mode === 'scan' ? 'tab-text-active' : ''">扫码验证</text></view>
<view class="tab" :class="mode === 'manual' ? 'tab-active' : ''" @tap="changeMode('manual')"><text class="tab-text" :class="mode === 'manual' ? 'tab-text-active' : ''">手动输入</text></view>
</view>
<view v-if="mode === 'scan'" class="form">
<text class="section-title">对准学生二维码</text>
<view v-if="visible && !loading && !loadFailed && !verified" class="scanner"><QrScanner @scan="handleScan" @error="handleCameraError"></QrScanner></view>
<text class="hint">二维码格式为学生账号,学生姓名。请允许使用摄像头。</text>
</view>
<view v-else class="form">
<text class="label">学生账号</text>
<input class="input" v-model="account" placeholder="请输入学生账号" />
<text class="label">学生姓名</text>
<input class="input" v-model="name" placeholder="请输入学生姓名" />
<view class="submit" @tap="handleManual"><text class="submit-text">{{ verifying ? '验证中...' : '验证学生' }}</text></view>
</view>
</view>
<view v-if="verified" class="result">
<text class="result-title">已验证,尚未绑定</text>
<text class="result-detail">{{ verified.name }} · {{ verified.account }}</text>
<text class="result-detail">当前没有登录后新增绑定接口,需接入真实接口才能完成绑定。验证结果可能来自项目配置的 mock 服务。</text>
<view class="retry" @tap="verified = null"><text class="retry-text">验证其他学生</text></view>
</view>
</scroll-view>
</view>
</template>
<script setup lang="ts">
import QrScanner from '../../components/qr-scanner.uvue'
import { apiGetBoundStudents, apiVerifyStudentQRCode, apiVerifyStudentAccount, type BoundStudentSummary, type StudentInfo } from '../../api/student'
const students = ref<BoundStudentSummary[]>([])
const loading = ref(true)
const loadFailed = ref(false)
const verifying = ref(false)
const visible = ref(false)
const mode = ref('scan')
const account = ref('')
const name = ref('')
const verified = ref<StudentInfo | null>(null)
onLoad((options) => { if (options?.mode === 'manual') mode.value = 'manual'; loadStudents() })
onShow(() => { visible.value = true })
onHide(() => { visible.value = false })
async function loadStudents() {
loading.value = true
loadFailed.value = false
try {
const res = await apiGetBoundStudents()
if (res && res.code === 0 && res.data) students.value = res.data.students
else loadFailed.value = true
} catch (_error) { loadFailed.value = true }
finally { loading.value = false }
}
function changeMode(value: string) { if (!verifying.value) { mode.value = value; verified.value = null } }
function handleCameraError() { uni.showToast({ title: '无法使用摄像头,请检查权限', icon: 'none' }) }
function handleScan(qrCode: string) {
const separator = qrCode.indexOf(',')
const studentAccount = separator > 0 ? qrCode.slice(0, separator).trim() : ''
const studentName = separator > 0 ? qrCode.slice(separator + 1).trim() : ''
if (!studentAccount || !studentName) { uni.showToast({ title: '二维码格式无效', icon: 'none' }); return }
verify(studentAccount, studentName, qrCode)
}
function handleManual() {
const studentAccount = account.value.trim()
const studentName = name.value.trim()
if (!studentAccount || !studentName) { uni.showToast({ title: '请输入学生账号和姓名', icon: 'none' }); return }
if (!/^[\u4e00-\u9fa5]{2,10}$/.test(studentName)) { uni.showToast({ title: '姓名需为2至10个汉字', icon: 'none' }); return }
verify(studentAccount, studentName, '')
}
async function verify(studentAccount: string, studentName: string, qrCode: string) {
if (verifying.value) return
verified.value = null
if (loading.value || loadFailed.value) { uni.showToast({ title: '请先加载已绑定学生列表', icon: 'none' }); return }
if (students.value.some(item => item.account === studentAccount)) { uni.showToast({ title: '该学生已绑定', icon: 'none' }); return }
if (students.value.length >= 3) { uni.showToast({ title: '最多绑定3名学生', icon: 'none' }); return }
verifying.value = true
try {
const res = qrCode ? await apiVerifyStudentQRCode({ qrCode, account: studentAccount, name: studentName }) : await apiVerifyStudentAccount({ account: studentAccount, name: studentName })
if (res && res.code === 0 && res.data) {
if (students.value.some(item => item.id === res.data!.id || item.account === res.data!.account)) uni.showToast({ title: '该学生已绑定', icon: 'none' })
else verified.value = res.data
}
} catch (_error) { uni.showToast({ title: '学生信息验证失败', icon: 'none' }) }
finally { verifying.value = false }
}
</script>
<style>
.page { display: flex; flex-direction: column; flex: 1; background-color: #f5f7fb; }
.content { flex: 1; }
.intro { display: flex; flex-direction: column; margin: 20px 14px 0; padding: 20px 16px; border-radius: 16px; background-color: #6542eb; }
.title { font-size: 18px; font-weight: 700; color: #ffffff; }
.description { margin-top: 8px; font-size: 12px; line-height: 20px; color: #eee9ff; }
.card { display: flex; flex-direction: column; margin: 14px 14px 0; padding: 16px; border-radius: 16px; background-color: #ffffff; }
.section-title { font-size: 15px; font-weight: 700; color: #242b3d; }
.hint { margin-top: 10px; font-size: 12px; line-height: 19px; color: #8a99ae; }
.retry { margin-top: 12px; }
.retry-text { font-size: 12px; color: #554be9; }
.tabs { display: flex; flex-direction: row; padding: 4px; border-radius: 12px; background-color: #f1f4f8; }
.tab { display: flex; flex-direction: column; flex: 1; align-items: center; justify-content: center; height: 38px; border-radius: 9px; }
.tab-active { background-color: #554be9; }
.tab-text { font-size: 13px; color: #65758b; }
.tab-text-active { color: #ffffff; }
.form { display: flex; flex-direction: column; margin-top: 18px; }
.scanner { margin-top: 12px; }
.label { margin-top: 12px; font-size: 13px; color: #242b3d; }
.input { height: 44px; margin-top: 8px; padding: 0 12px; border-radius: 10px; font-size: 14px; background-color: #f5f7fb; }
.submit { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 44px; margin-top: 20px; border-radius: 11px; background-color: #554be9; }
.submit-text { font-size: 14px; color: #ffffff; }
.result { display: flex; flex-direction: column; margin: 14px 14px 24px; padding: 16px; border-radius: 16px; background-color: #ffffff; }
.result-title { font-size: 15px; font-weight: 700; color: #554be9; }
.result-detail { margin-top: 8px; font-size: 12px; line-height: 20px; color: #65758b; }
</style>
+322
View File
@@ -0,0 +1,322 @@
<template>
<view class="page-container">
<scroll-view class="content-scroll" direction="vertical">
<!-- 步骤进度 -->
<view class="step-indicator">
<view class="step-item">
<view class="step-dot step-dot-completed"></view>
<text class="step-text">扫码绑定孩子</text>
</view>
<view class="step-line step-line-completed"></view>
<view class="step-item">
<view class="step-dot step-dot-completed"></view>
<text class="step-text">家长账号与密码</text>
</view>
<view class="step-line step-line-completed"></view>
<view class="step-item step-active">
<view class="step-dot step-dot-active"></view>
<text class="step-text step-text-active">注册完成</text>
</view>
</view>
<!-- 成功图标 -->
<view class="success-section">
<view class="success-icon-wrap">
<view class="success-mark"></view>
</view>
<text class="success-title">注册完成</text>
<text class="success-desc">已绑定 {{ students.length }} 名学生</text>
<text class="success-tip">使用手机号即可登录家长端查看孩子学情</text>
</view>
<!-- 已绑定学生列表 -->
<view class="student-list-section">
<view class="section-header">
<text class="section-title">已绑定学生</text>
<text class="section-count">共 {{ students.length }} 名</text>
</view>
<view class="student-item" v-for="(student, index) in students" :key="student.id">
<text class="student-index">{{ index + 1 }}</text>
<image class="student-avatar" :src="student.avatar || '/static/avatar-default.png'" mode="aspectFill"></image>
<view class="student-info">
<text class="student-name">{{ student.name }}</text>
<text class="student-account">账号 {{ student.account }}</text>
</view>
</view>
</view>
<!-- 操作按钮 -->
<view class="button-group">
<view class="btn-primary" @tap="handleGoHome">
<text class="btn-primary-text">去登录</text>
</view>
<view class="btn-secondary" @tap="handleGoLogin">
<text class="btn-secondary-text">返回首页</text>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup lang="ts">
import { registrationStudents } from '../../store/registration'
const students = registrationStudents
function handleGoHome() {
uni.reLaunch({
url: '/pages/login/index'
})
}
function handleGoLogin() {
uni.reLaunch({
url: '/pages/index/index'
})
}
</script>
<style>
.page-container {
display: flex;
flex-direction: column;
flex: 1;
background-color: #F5F5F5;
}
.content-scroll {
flex: 1;
padding: 16px;
padding-bottom: calc(16px + var(--uni-safe-area-inset-bottom));
}
/* 步骤指示器 */
.step-indicator {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 16px;
background-color: #FFFFFF;
border-radius: 8px;
margin-bottom: 24px;
}
.step-item {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
}
.step-dot {
width: 8px;
height: 8px;
border-radius: 4px;
background-color: #E5E5E5;
margin-bottom: 8px;
}
.step-dot-completed {
background-color: #10B981;
}
.step-dot-active {
background-color: #6C63FF;
width: 12px;
height: 12px;
border-radius: 6px;
}
.step-text {
font-size: 11px;
color: #999999;
text-align: center;
}
.step-text-active {
color: #6C63FF;
font-weight: 600;
}
.step-line {
flex: 1;
height: 2px;
background-color: #E5E5E5;
margin: 0 4px;
margin-bottom: 20px;
}
.step-line-completed {
background-color: #10B981;
}
/* 成功区域 */
.success-section {
display: flex;
flex-direction: column;
align-items: center;
padding: 32px 16px;
background-color: #FFFFFF;
border-radius: 12px;
margin-bottom: 24px;
}
.success-icon-wrap {
width: 80px;
height: 80px;
background-color: #ECFDF5;
border-radius: 40px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-bottom: 20px;
}
.success-check {
font-size: 0;
}
.success-mark {
width: 28px;
height: 14px;
border-left: 4px solid #10B981;
border-bottom: 4px solid #10B981;
transform: rotate(-45deg);
}
.success-title {
font-size: 24px;
font-weight: 700;
color: #1F2937;
margin-bottom: 8px;
}
.success-desc {
font-size: 16px;
color: #6B7280;
margin-bottom: 8px;
}
.success-tip {
font-size: 13px;
color: #9CA3AF;
}
/* 学生列表 */
.student-list-section {
display: flex;
flex-direction: column;
padding: 16px;
background-color: #FFFFFF;
border-radius: 12px;
margin-bottom: 32px;
}
.section-header {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.section-title {
font-size: 16px;
font-weight: 600;
color: #333333;
}
.section-count {
font-size: 13px;
color: #999999;
}
.student-item {
display: flex;
flex-direction: row;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid #F3F4F6;
}
.student-index {
width: 28px;
height: 28px;
background-color: #EEF2FF;
border-radius: 14px;
color: #6C63FF;
font-size: 14px;
font-weight: 600;
text-align: center;
line-height: 28px;
margin-right: 12px;
flex-shrink: 0;
}
.student-avatar {
width: 44px;
height: 44px;
border-radius: 22px;
margin-right: 12px;
background-color: #F5F5F5;
}
.student-info {
display: flex;
flex-direction: column;
flex: 1;
}
.student-name {
font-size: 16px;
font-weight: 600;
color: #333333;
margin-bottom: 4px;
}
.student-account {
font-size: 12px;
color: #999999;
}
/* 操作按钮 */
.button-group {
display: flex;
flex-direction: column;
}
.btn-primary {
height: 52px;
background-color: #6C63FF;
border-radius: 26px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-bottom: 12px;
}
.btn-primary-text {
font-size: 16px;
font-weight: 600;
color: #FFFFFF;
}
.btn-secondary {
height: 52px;
background-color: #FFFFFF;
border-radius: 26px;
border: 1px solid #E5E5E5;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
.btn-secondary-text {
font-size: 16px;
color: #6B7280;
}
</style>
+14
View File
@@ -0,0 +1,14 @@
<template>
<view></view>
</template>
<script setup lang="ts">
import { resetRegistrationStudents } from '../../store/registration'
onLoad(() => {
resetRegistrationStudents()
uni.redirectTo({
url: '/pages/register/scan-student'
})
})
</script>
+546
View File
@@ -0,0 +1,546 @@
<template>
<view class="page-container">
<scroll-view class="content-scroll" direction="vertical">
<!-- 步骤进度 -->
<view class="step-indicator">
<view class="step-item step-active">
<view class="step-dot step-dot-active"></view>
<text class="step-text step-text-active">扫码绑定孩子</text>
</view>
<view class="step-line"></view>
<view class="step-item">
<view class="step-dot"></view>
<text class="step-text">家长账号与密码</text>
</view>
<view class="step-line"></view>
<view class="step-item">
<view class="step-dot"></view>
<text class="step-text">注册完成</text>
</view>
</view>
<!-- 提示信息 -->
<view class="info-banner">
<text class="info-text">先扫描孩子的学生二维码,或输入学生账号完成绑定。再设置家长账号,最多可绑定 3 个孩子。</text>
</view>
<!-- 切换按钮 -->
<view class="toggle-buttons">
<view class="toggle-btn toggle-btn-inactive toggle-btn-first" @tap="handleTabChange('scan')">
<text class="toggle-btn-text toggle-btn-text-inactive">扫码绑定</text>
</view>
<view class="toggle-btn toggle-btn-active" @tap="handleTabChange('input')">
<text class="toggle-btn-text toggle-btn-text-active">输入学生账号</text>
</view>
</view>
<!-- 表单区域 -->
<view class="form-section">
<view class="form-header">
<text class="form-title">填写要绑定的学生信息</text>
<text class="form-description">学生账号与姓名需与学生信息保持一致,校验通过后即可加入下方已绑定列表。</text>
</view>
<view class="form-group">
<text class="form-label">学生账号</text>
<input
class="form-input"
type="text"
v-model="studentForm.account"
placeholder="输入学生的账号或账号"
/>
</view>
<view class="form-group">
<text class="form-label">学生姓名</text>
<input
class="form-input"
type="text"
v-model="studentForm.name"
placeholder="需与学生本人姓名一致"
/>
</view>
<view
class="add-student-btn"
@tap="handleAddStudent"
>
<text class="add-student-btn-text">确认添加学生</text>
</view>
</view>
<!-- 已绑定学生列表 -->
<view class="student-list" v-if="studentList.length > 0">
<view class="list-title-row">
<text class="list-title">已绑定学生</text>
<uni-badge-view :text="studentList.length + '/3'" badge-class="student-count-badge"></uni-badge-view>
</view>
<view class="student-item" v-for="(student, index) in studentList" :key="student.id">
<image class="student-avatar" :src="student.avatar || '/static/avatar-default.png'" mode="aspectFill"></image>
<view class="student-info">
<text class="student-name">{{ student.name }}</text>
<text class="student-detail">{{ student.grade }} {{ student.class }} | {{ student.account }}</text>
</view>
<button class="remove-btn" @tap="handleRemoveStudent(index)">移除</button>
</view>
</view>
</scroll-view>
<!-- 底部按钮 -->
<view class="footer-button-container">
<view
class="footer-button"
@tap="handleNext"
>
<text class="footer-button-text">下一步</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { apiVerifyStudentAccount } from '../../api/student'
import { registrationStudents } from '../../store/registration'
type StudentForm = {
account: string
name: string
}
const studentForm = reactive<StudentForm>({
account: '',
name: ''
})
const studentList = registrationStudents
const MAX_STUDENTS = 3
const canAdd = computed((): boolean => {
return studentForm.account.trim().length > 0 && studentForm.name.trim().length > 0
})
const canNext = computed((): boolean => {
return studentList.value.length > 0
})
/**
* 切换标签页
*/
function handleTabChange(tab: string) {
if (tab === 'scan') {
uni.redirectTo({
url: '/pages/register/scan-student'
})
}
}
/**
* 添加学生
*/
async function handleAddStudent() {
if (!canAdd.value) return
const account = studentForm.account.trim()
const name = studentForm.name.trim()
// 检查是否达到上限
if (studentList.value.length >= MAX_STUDENTS) {
uni.showToast({
title: `最多只能绑定${MAX_STUDENTS}个孩子`,
icon: 'none'
})
return
}
if (studentList.value.some(student => student.account === account)) {
uni.showToast({
title: '该学生已添加',
icon: 'none'
})
return
}
// 验证姓名格式
const nameReg = /^[\u4e00-\u9fa5]{2,10}$/
if (!nameReg.test(name)) {
uni.showToast({
title: '请输入正确的学生姓名(2-10个汉字)',
icon: 'none'
})
return
}
uni.showLoading({ title: '验证中...' })
const res = await apiVerifyStudentAccount({
account,
name
})
uni.hideLoading()
if (res && res.code === 0 && res.data) {
// 检查是否重复绑定
const exists = studentList.value.find(s => s.account === res.data!.account)
if (exists) {
uni.showToast({
title: '该学生已添加',
icon: 'none'
})
return
}
// 添加到列表
studentList.value.push(res.data)
// 清空表单
studentForm.account = ''
studentForm.name = ''
uni.showToast({
title: '添加成功',
icon: 'success'
})
} else {
uni.showToast({
title: res?.message || '学生信息验证失败,请检查账号和姓名是否正确',
icon: 'none'
})
}
}
/**
* 移除学生
*/
function handleRemoveStudent(index: number) {
uni.showModal({
title: '提示',
content: '确定要移除该学生吗?',
success: (res) => {
if (res.confirm) {
studentList.value.splice(index, 1)
uni.showToast({
title: '已移除',
icon: 'success'
})
}
}
})
}
/**
* 下一步
*/
function handleNext() {
if (!canNext.value) {
uni.showToast({
title: '请至少绑定一个学生',
icon: 'none'
})
return
}
uni.navigateTo({
url: '/pages/register/parent-account'
})
}
</script>
<style>
.page-container {
display: flex;
flex-direction: column;
flex: 1;
background-color: #F5F5F5;
}
.content-scroll {
flex: 1;
padding: 16px 16px calc(96px + var(--uni-safe-area-inset-bottom));
}
/* 步骤指示器 - 复用扫码页样式 */
.step-indicator {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 16px;
background-color: #FFFFFF;
border-radius: 8px;
margin-bottom: 16px;
}
.step-item {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
}
.step-dot {
width: 8px;
height: 8px;
border-radius: 4px;
background-color: #E5E5E5;
margin-bottom: 8px;
}
.step-dot-active {
background-color: #6C63FF;
width: 12px;
height: 12px;
border-radius: 6px;
}
.step-text {
font-size: 11px;
color: #999999;
text-align: center;
}
.step-text-active {
color: #6C63FF;
font-weight: 600;
}
.step-line {
flex: 1;
height: 2px;
background-color: #E5E5E5;
margin: 0 4px;
margin-bottom: 20px;
}
/* 提示信息 */
.info-banner {
display: flex;
flex-direction: row;
align-items: flex-start;
padding: 12px;
background-color: #EEF2FF;
border-radius: 8px;
margin-bottom: 16px;
}
.info-icon {
font-size: 16px;
margin-right: 8px;
flex-shrink: 0;
}
.info-text {
flex: 1;
font-size: 13px;
color: #4F46E5;
line-height: 1.6;
}
/* 切换按钮 */
.toggle-buttons {
display: flex;
flex-direction: row;
margin-bottom: 24px;
}
.toggle-btn-first {
margin-right: 12px;
}
.toggle-btn {
flex: 1;
height: 40px;
border-radius: 20px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
.toggle-btn-active {
background-color: #6C63FF;
}
.toggle-btn-inactive {
background-color: #FFFFFF;
border: 1px solid #E5E5E5;
}
.toggle-btn-text {
font-size: 14px;
}
.toggle-btn-text-active {
color: #FFFFFF;
}
.toggle-btn-text-inactive {
color: #666666;
}
/* 表单区域 */
.form-section {
display: flex;
flex-direction: column;
padding: 16px;
background-color: #FFFFFF;
border-radius: 8px;
margin-bottom: 16px;
}
.form-header {
display: flex;
flex-direction: column;
margin-bottom: 20px;
}
.form-title {
font-size: 16px;
font-weight: 600;
color: #333333;
margin-bottom: 8px;
}
.form-description {
font-size: 13px;
color: #666666;
line-height: 1.6;
}
.form-group {
display: flex;
flex-direction: column;
margin-bottom: 16px;
}
.form-label {
font-size: 14px;
color: #333333;
margin-bottom: 8px;
font-weight: 500;
}
.form-input {
height: 44px;
padding: 0 16px;
background-color: #F8F9FA;
border-radius: 8px;
border: 1px solid #E5E5E5;
font-size: 14px;
color: #333333;
}
.add-student-btn {
height: 40px;
background-color: #EEF2FF;
border-radius: 8px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-top: 8px;
}
.add-student-btn-text {
font-size: 14px;
color: #6C63FF;
}
/* 学生列表 */
.student-list {
display: flex;
flex-direction: column;
margin-bottom: 16px;
}
.list-title {
font-size: 14px;
font-weight: 600;
color: #333333;
margin-bottom: 12px;
}
.list-title-row {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.student-count-badge {
background-color: #6C63FF;
color: #FFFFFF;
}
.student-item {
display: flex;
flex-direction: row;
align-items: center;
padding: 12px;
background-color: #FFFFFF;
border-radius: 8px;
margin-bottom: 8px;
}
.student-avatar {
width: 48px;
height: 48px;
border-radius: 24px;
margin-right: 12px;
background-color: #F5F5F5;
}
.student-info {
display: flex;
flex-direction: column;
flex: 1;
}
.student-name {
font-size: 16px;
font-weight: 600;
color: #333333;
margin-bottom: 4px;
}
.student-detail {
font-size: 12px;
color: #999999;
}
.remove-btn {
width: 56px;
height: 32px;
padding: 0;
font-size: 12px;
color: #EF4444;
background-color: #FEF2F2;
}
/* 底部按钮 */
.footer-button-container {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 16px 16px calc(16px + var(--uni-safe-area-inset-bottom));
background-color: #F5F5F5;
}
.footer-button {
height: 48px;
background-color: #6C63FF;
border-radius: 24px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
.footer-button-text {
font-size: 16px;
color: #FFFFFF;
font-weight: 500;
}
</style>
+496
View File
@@ -0,0 +1,496 @@
<template>
<view class="page-container">
<scroll-view class="content-scroll" direction="vertical">
<view class="page-back-action" @tap="handleBackToLogin">
<text class="page-back-text">返回登录</text>
</view>
<!-- 步骤进度 -->
<view class="step-indicator">
<view class="step-item">
<view class="step-dot step-dot-completed"></view>
<text class="step-text">扫码绑定孩子</text>
</view>
<view class="step-line step-line-completed"></view>
<view class="step-item step-active">
<view class="step-dot step-dot-active"></view>
<text class="step-text step-text-active">家长账号与密码</text>
</view>
<view class="step-line"></view>
<view class="step-item">
<view class="step-dot"></view>
<text class="step-text">注册完成</text>
</view>
</view>
<!-- 已绑定学生展示 -->
<view class="student-summary">
<view class="summary-title-row">
<text class="summary-title">已绑定学生</text>
<uni-badge-view :text="students.length.toString()" badge-class="student-count-badge"></uni-badge-view>
</view>
<view class="student-card" v-for="student in students" :key="student.id">
<image class="student-avatar-small" :src="student.avatar || '/static/avatar-default.png'" mode="aspectFill"></image>
<text class="student-name-small">{{ student.name }}</text>
</view>
</view>
<!-- 表单区域 -->
<view class="form-section">
<text class="form-title">设置家长账号信息</text>
<view class="form-group">
<text class="form-label">家长姓名</text>
<input
class="form-input"
type="text"
v-model="form.parentName"
placeholder="请输入家长姓名"
maxlength="10"
/>
</view>
<view class="form-group">
<text class="form-label">手机号</text>
<input
class="form-input"
type="number"
v-model="form.phone"
placeholder="请输入手机号"
maxlength="11"
/>
</view>
<view class="form-group">
<text class="form-label">与学生的关系</text>
<picker class="relationship-picker" :value="relationshipIndex" :range="relationshipOptions" @change="handleRelationshipChange">
<view class="relationship-picker-content">
<text :class="form.relationship ? 'relationship-value' : 'relationship-placeholder'">{{ form.relationship || '请选择与学生的关系' }}</text>
</view>
</picker>
</view>
<view class="form-group">
<text class="form-label">登录密码</text>
<view class="input-wrapper">
<input
class="form-input-inner"
type="password"
v-model="form.password"
placeholder="请设置登录密码(6-20位)"
maxlength="20"
/>
</view>
</view>
<view class="form-group">
<text class="form-label">确认密码</text>
<view class="input-wrapper">
<input
class="form-input-inner"
type="password"
v-model="form.confirmPassword"
placeholder="请再次输入密码"
maxlength="20"
/>
</view>
</view>
</view>
</scroll-view>
<!-- 底部按钮 -->
<view class="footer-button-container">
<view
class="footer-button"
:class="canSubmit ? '' : 'footer-button-disabled'"
@tap="handleSubmit"
>
<text class="footer-button-text" :class="canSubmit ? '' : 'footer-button-text-disabled'">完成注册</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { apiRegister } from '../../api/auth'
import { apiCheckPhone } from '../../api/student'
import { registrationStudents } from '../../store/registration'
import { setUserInfo } from '../../store/user'
type ParentAccountForm = {
parentName: string
phone: string
relationship: string
password: string
confirmPassword: string
}
const form = reactive<ParentAccountForm>({
parentName: '',
phone: '',
relationship: '',
password: '',
confirmPassword: ''
})
const students = registrationStudents
const relationshipOptions = ['爸爸', '妈妈', '爷爷', '奶奶', '外公', '外婆', '其他亲属']
const relationshipIndex = ref(0)
const canSubmit = computed((): boolean => {
const parentNameReg = /^[\u4e00-\u9fa5]{2,10}$/
const phoneReg = /^1[3-9]\d{9}$/
return (
parentNameReg.test(form.parentName) &&
phoneReg.test(form.phone) &&
form.relationship.length > 0 &&
form.password.length >= 6 &&
form.password.length <= 20 &&
form.password === form.confirmPassword
)
})
function handleRelationshipChange(event: UniPickerChangeEvent) {
const index = event.detail.value as number
relationshipIndex.value = index
form.relationship = relationshipOptions[index]
}
/**
* 提交注册
*/
async function handleSubmit() {
if (!canSubmit.value) {
if (!form.parentName) {
uni.showToast({ title: '请输入家长姓名', icon: 'none' })
} else if (!/^[\u4e00-\u9fa5]{2,10}$/.test(form.parentName)) {
uni.showToast({ title: '请输入正确的家长姓名(2-10个汉字)', icon: 'none' })
} else if (!form.phone) {
uni.showToast({ title: '请输入手机号', icon: 'none' })
} else if (!/^1[3-9]\d{9}$/.test(form.phone)) {
uni.showToast({ title: '请输入正确的手机号', icon: 'none' })
} else if (!form.relationship) {
uni.showToast({ title: '请选择与学生的关系', icon: 'none' })
} else if (form.password.length < 6 || form.password.length > 20) {
uni.showToast({ title: '密码长度应为6-20位', icon: 'none' })
} else if (form.password !== form.confirmPassword) {
uni.showToast({ title: '两次输入的密码不一致', icon: 'none' })
}
return
}
// 检查手机号是否已注册
uni.showLoading({ title: '检查中...' })
const checkRes = await apiCheckPhone(form.phone)
uni.hideLoading()
if (checkRes && checkRes.data && checkRes.data.exists) {
uni.showToast({
title: '该手机号已注册,请直接登录',
icon: 'none'
})
return
}
// 提交注册
uni.showLoading({ title: '注册中...' })
const studentIds = students.value.map(s => s.id)
const res = await apiRegister({
parentName: form.parentName,
phone: form.phone,
relationship: form.relationship,
password: form.password,
students: studentIds
})
uni.hideLoading()
if (res && res.code === 0 && res.data) {
// 保存用户信息
setUserInfo(res.data)
// 跳转到注册完成页
uni.redirectTo({
url: '/pages/register/complete'
})
} else {
uni.showToast({
title: res?.message || '注册失败,请稍后重试',
icon: 'none'
})
}
}
function handleBackToLogin() {
uni.reLaunch({
url: '/pages/login/index'
})
}
</script>
<style>
.page-container {
display: flex;
flex-direction: column;
flex: 1;
background-color: #F5F5F5;
}
.content-scroll {
flex: 1;
padding: 16px 16px calc(96px + var(--uni-safe-area-inset-bottom));
}
.page-back-action {
align-self: flex-start;
padding: 8px 0;
margin-bottom: 8px;
}
.page-back-text {
font-size: 14px;
color: #6C63FF;
}
/* 步骤指示器 */
.step-indicator {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 16px;
background-color: #FFFFFF;
border-radius: 8px;
margin-bottom: 16px;
}
.step-item {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
}
.step-dot {
width: 8px;
height: 8px;
border-radius: 4px;
background-color: #E5E5E5;
margin-bottom: 8px;
}
.step-dot-completed {
background-color: #10B981;
width: 8px;
height: 8px;
}
.step-dot-active {
background-color: #6C63FF;
width: 12px;
height: 12px;
border-radius: 6px;
}
.footer-button-text-disabled {
color: #999999;
}
.step-text {
font-size: 11px;
color: #999999;
text-align: center;
}
.step-text-active {
color: #6C63FF;
font-weight: 600;
}
.step-line {
flex: 1;
height: 2px;
background-color: #E5E5E5;
margin: 0 4px;
margin-bottom: 20px;
}
.step-line-completed {
background-color: #10B981;
}
/* 学生摘要 */
.student-summary {
display: flex;
flex-direction: column;
padding: 16px;
background-color: #FFFFFF;
border-radius: 8px;
margin-bottom: 16px;
}
.summary-title {
font-size: 14px;
font-weight: 600;
color: #333333;
margin-bottom: 12px;
}
.student-card {
display: flex;
flex-direction: row;
align-items: center;
padding: 8px;
background-color: #F8F9FA;
border-radius: 6px;
margin-bottom: 8px;
}
.student-avatar-small {
width: 32px;
height: 32px;
border-radius: 16px;
margin-right: 8px;
background-color: #E5E5E5;
}
.student-name-small {
font-size: 14px;
color: #333333;
}
/* 表单区域 */
.form-section {
display: flex;
flex-direction: column;
padding: 16px;
background-color: #FFFFFF;
border-radius: 8px;
margin-bottom: 16px;
}
.form-title {
font-size: 16px;
font-weight: 600;
color: #333333;
margin-bottom: 20px;
}
.form-group {
display: flex;
flex-direction: column;
margin-bottom: 16px;
}
.form-label {
font-size: 14px;
color: #333333;
margin-bottom: 8px;
font-weight: 500;
}
.form-input {
height: 44px;
padding: 0 16px;
background-color: #F8F9FA;
border-radius: 8px;
border: 1px solid #E5E5E5;
font-size: 14px;
color: #333333;
}
.relationship-picker {
height: 44px;
background-color: #F8F9FA;
border-radius: 8px;
border: 1px solid #E5E5E5;
}
.relationship-picker-content {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
height: 44px;
padding: 0 16px;
}
.summary-title-row {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.student-count-badge {
background-color: #6C63FF;
color: #FFFFFF;
}
.relationship-value {
font-size: 14px;
color: #333333;
}
.relationship-placeholder {
font-size: 14px;
color: #999999;
}
.relationship-picker-arrow {
font-size: 16px;
color: #999999;
}
.input-wrapper {
display: flex;
flex-direction: row;
align-items: center;
height: 44px;
background-color: #F8F9FA;
border-radius: 8px;
border: 1px solid #E5E5E5;
padding: 0 16px;
}
.form-input-inner {
flex: 1;
font-size: 14px;
color: #333333;
}
/* 底部按钮 */
.footer-button-container {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 16px 16px calc(16px + var(--uni-safe-area-inset-bottom));
background-color: #F5F5F5;
}
.footer-button {
height: 48px;
background-color: #6C63FF;
border-radius: 24px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
.footer-button-disabled {
background-color: #E5E5E5;
}
.footer-button-text {
font-size: 16px;
color: #FFFFFF;
font-weight: 500;
}
.footer-button-text-disabled {
color: #999999;
}
</style>
+515
View File
@@ -0,0 +1,515 @@
<template>
<view class="page-container">
<scroll-view class="content-scroll" direction="vertical">
<!-- 步骤进度 -->
<view class="step-indicator">
<view class="step-item step-active">
<view class="step-dot step-dot-active"></view>
<text class="step-text step-text-active">扫码绑定孩子</text>
</view>
<view class="step-line"></view>
<view class="step-item">
<view class="step-dot"></view>
<text class="step-text">家长账号与密码</text>
</view>
<view class="step-line"></view>
<view class="step-item">
<view class="step-dot"></view>
<text class="step-text">注册完成</text>
</view>
</view>
<!-- 提示信息 -->
<view class="info-banner">
<text class="info-text">先扫描孩子的学生二维码,或输入学生账号完成绑定。再设置家长账号,最多可绑定 3 个孩子。</text>
</view>
<!-- 切换按钮 -->
<view class="toggle-buttons">
<view class="toggle-btn toggle-btn-active toggle-btn-first" @tap="handleTabChange('scan')">
<text class="toggle-btn-text toggle-btn-text-active">扫码绑定</text>
</view>
<view class="toggle-btn toggle-btn-inactive" @tap="handleTabChange('input')">
<text class="toggle-btn-text toggle-btn-text-inactive">输入学生账号</text>
</view>
</view>
<!-- 扫码区域 -->
<view class="scan-section">
<view class="scan-status">
<text class="status-text">对准学生二维码</text>
</view>
<view class="scan-area">
<QrScanner :active="studentList.length < MAX_STUDENTS" @scan="handleScannedQRCode" @error="handleCameraError"></QrScanner>
</view>
<text class="scan-hint">将学生二维码放入框内,即可自动识别</text>
<text class="scan-note">学生账号/密码可以在学校指定平台或在班主任或家长群可获取</text>
</view>
<!-- 已绑定学生列表 -->
<view class="student-list" v-if="studentList.length > 0">
<view class="list-title-row">
<text class="list-title">已绑定学生</text>
<uni-badge-view :text="studentList.length + '/3'" badge-class="student-count-badge"></uni-badge-view>
</view>
<view class="student-item" v-for="(student, index) in studentList" :key="student.id">
<image class="student-avatar" :src="student.avatar || '/static/avatar-default.png'" mode="aspectFill"></image>
<view class="student-info">
<text class="student-name">{{ student.name }}</text>
<text class="student-detail">{{ student.grade }} {{ student.class }} | {{ student.account }}</text>
</view>
<button class="remove-btn" @tap="handleRemoveStudent(index)">移除</button>
</view>
</view>
</scroll-view>
<!-- 底部按钮 -->
<view class="footer-button-container">
<view
class="footer-button"
:class="canNext ? '' : 'footer-button-disabled'"
@tap="handleNext"
>
<text class="footer-button-text" :class="canNext ? '' : 'footer-button-text-disabled'">下一步</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { apiVerifyStudentQRCode } from '../../api/student'
import { registrationStudents } from '../../store/registration'
import QrScanner from '../../components/qr-scanner.uvue'
const studentList = registrationStudents
const MAX_STUDENTS = 3
const isVerifying = ref(false)
const canNext = computed((): boolean => {
return studentList.value.length > 0
})
/**
* 切换标签页
*/
function handleTabChange(tab: string) {
if (tab === 'input') {
uni.redirectTo({
url: '/pages/register/input-student'
})
}
}
async function handleScannedQRCode(qrCode: string) {
if (!qrCode || isVerifying.value) return
const separatorIndex = qrCode.indexOf(',')
if (separatorIndex <= 0 || separatorIndex === qrCode.length - 1) {
uni.showToast({
title: '二维码格式无效',
icon: 'none'
})
return
}
const account = qrCode.slice(0, separatorIndex).trim()
const name = qrCode.slice(separatorIndex + 1).trim()
if (!account || !name) return
isVerifying.value = true
try {
await verifyStudentQRCode(qrCode, account, name)
} finally {
isVerifying.value = false
}
}
function handleCameraError() {
uni.showToast({
title: '无法使用摄像头,请检查权限',
icon: 'none'
})
}
/**
* 验证学生二维码
*/
async function verifyStudentQRCode(qrCode: string, account: string, name: string) {
// 检查是否达到上限
if (studentList.value.length >= MAX_STUDENTS) {
uni.showToast({
title: `最多只能绑定${MAX_STUDENTS}个孩子`,
icon: 'none'
})
return
}
if (studentList.value.some(student => student.account === account)) {
uni.showToast({
title: '该学生已添加',
icon: 'none'
})
return
}
uni.showLoading({ title: '验证中...' })
const res = await apiVerifyStudentQRCode({ qrCode, account, name })
uni.hideLoading()
if (res && res.code === 0 && res.data) {
// 检查是否重复绑定
const exists = studentList.value.find(s => s.account === res.data!.account)
if (exists) {
uni.showToast({
title: '该学生已添加',
icon: 'none'
})
return
}
// 添加到列表
studentList.value.push(res.data)
uni.showToast({
title: '绑定成功',
icon: 'success'
})
} else {
uni.showToast({
title: res?.message || '学生信息验证失败',
icon: 'none'
})
}
}
/**
* 移除学生
*/
function handleRemoveStudent(index: number) {
uni.showModal({
title: '提示',
content: '确定要移除该学生吗?',
success: (res) => {
if (res.confirm) {
studentList.value.splice(index, 1)
uni.showToast({
title: '已移除',
icon: 'success'
})
}
}
})
}
/**
* 下一步
*/
function handleNext() {
if (!canNext.value) {
uni.showToast({
title: '请至少绑定一个学生',
icon: 'none'
})
return
}
uni.navigateTo({
url: '/pages/register/parent-account'
})
}
</script>
<style>
.page-container {
display: flex;
flex-direction: column;
flex: 1;
background-color: #F5F5F5;
}
.content-scroll {
flex: 1;
padding: 16px 16px calc(96px + var(--uni-safe-area-inset-bottom));
}
/* 步骤指示器 */
.step-indicator {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 16px;
background-color: #FFFFFF;
border-radius: 8px;
margin-bottom: 16px;
}
.step-item {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
}
.step-dot {
width: 8px;
height: 8px;
border-radius: 4px;
background-color: #E5E5E5;
margin-bottom: 8px;
}
.step-dot-active {
background-color: #6C63FF;
width: 12px;
height: 12px;
border-radius: 6px;
}
.step-text {
font-size: 11px;
color: #999999;
text-align: center;
}
.step-text-active {
color: #6C63FF;
font-weight: 600;
}
.step-line {
flex: 1;
height: 2px;
background-color: #E5E5E5;
margin: 0 4px;
margin-bottom: 20px;
}
/* 提示信息 */
.info-banner {
display: flex;
flex-direction: row;
align-items: flex-start;
padding: 12px;
background-color: #EEF2FF;
border-radius: 8px;
margin-bottom: 16px;
}
.info-icon {
font-size: 16px;
margin-right: 8px;
flex-shrink: 0;
}
.info-text {
flex: 1;
font-size: 13px;
color: #4F46E5;
line-height: 1.6;
}
/* 切换按钮 */
.toggle-buttons {
display: flex;
flex-direction: row;
margin-bottom: 24px;
}
.toggle-btn-first {
margin-right: 12px;
}
.toggle-btn {
flex: 1;
height: 40px;
border-radius: 20px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
.toggle-btn-active {
background-color: #6C63FF;
}
.toggle-btn-inactive {
background-color: #FFFFFF;
border: 1px solid #E5E5E5;
}
.toggle-btn-text {
font-size: 14px;
}
.toggle-btn-text-active {
color: #FFFFFF;
}
.toggle-btn-text-inactive {
color: #666666;
}
/* 扫码区域 */
.scan-section {
display: flex;
flex-direction: column;
margin-bottom: 24px;
}
.scan-status {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background-color: #1E293B;
border-radius: 8px 8px 0 0;
}
.status-text {
font-size: 14px;
color: #FFFFFF;
}
.status-icon {
font-size: 18px;
}
.scan-area {
background-color: #0F172A;
padding: 16px;
border-radius: 0 0 8px 8px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.scan-hint {
font-size: 14px;
color: #F8FAFC;
text-align: center;
margin-top: 16px;
}
.scan-note {
font-size: 12px;
color: #94A3B8;
text-align: center;
margin-top: 8px;
line-height: 1.5;
}
/* 学生列表 */
.student-list {
display: flex;
flex-direction: column;
margin-bottom: 16px;
}
.list-title {
font-size: 14px;
font-weight: 600;
color: #333333;
margin-bottom: 12px;
}
.list-title-row {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.student-count-badge {
background-color: #6C63FF;
color: #FFFFFF;
}
.student-item {
display: flex;
flex-direction: row;
align-items: center;
padding: 12px;
background-color: #FFFFFF;
border-radius: 8px;
margin-bottom: 8px;
}
.student-avatar {
width: 48px;
height: 48px;
border-radius: 24px;
margin-right: 12px;
background-color: #F5F5F5;
}
.student-info {
display: flex;
flex-direction: column;
flex: 1;
}
.student-name {
font-size: 16px;
font-weight: 600;
color: #333333;
margin-bottom: 4px;
}
.student-detail {
font-size: 12px;
color: #999999;
}
.remove-btn {
width: 56px;
height: 32px;
padding: 0;
font-size: 12px;
color: #EF4444;
background-color: #FEF2F2;
}
/* 底部按钮 */
.footer-button-container {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 16px 16px calc(16px + var(--uni-safe-area-inset-bottom));
background-color: #F5F5F5;
}
.footer-button {
height: 48px;
background-color: #6C63FF;
border-radius: 24px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
.footer-button-disabled {
background-color: #E5E5E5;
}
.footer-button-text {
font-size: 16px;
color: #FFFFFF;
font-weight: 500;
}
.footer-button-text-disabled {
color: #999999;
}
</style>
+155
View File
@@ -0,0 +1,155 @@
<template>
<view class="page-container">
<view class="header-panel">
<view class="header-copy"><text class="header-title">学情报告</text><text class="header-subtitle">{{ report.studentName }} · {{ report.studentGrade }} · {{ report.studentClass }}</text></view>
<view class="header-action"><UniIcons type="person-filled" class="header-action-icon"></UniIcons></view>
</view>
<view class="report-meta"><text class="report-meta-text">{{ activeReport.rangeText }} · 数据更新到 {{ report.updatedAtText }}</text></view>
<scroll-view class="content-scroll" direction="vertical" scroll-y="true" refresher-enabled="true" :refresher-triggered="isRefreshing" @refresherrefresh="refreshReport">
<view v-if="isLoading" class="status-panel"><text class="status-text">学情报告加载中</text></view>
<view v-else class="content-panel">
<view class="filters-card">
<view class="period-tabs"><view class="period-tab" :class="activePeriod === 'week' ? 'period-tab-active' : ''" @tap="setPeriod('week')"><text class="period-tab-text" :class="activePeriod === 'week' ? 'period-tab-text-active' : ''">周报</text></view><view class="period-tab" :class="activePeriod === 'month' ? 'period-tab-active' : ''" @tap="setPeriod('month')"><text class="period-tab-text" :class="activePeriod === 'month' ? 'period-tab-text-active' : ''">月报</text></view></view>
<scroll-view class="subject-scroll" direction="horizontal" scroll-x="true"><view class="subject-tabs"><view v-for="subject in report.subjects" :key="subject.id" class="subject-tab" :class="activeSubjectId === subject.id ? 'subject-tab-active' : ''" @tap="setSubject(subject.id)"><text class="subject-tab-text" :class="activeSubjectId === subject.id ? 'subject-tab-text-active' : ''">{{ subject.name }}</text></view></view></scroll-view>
</view>
<view class="summary-card"><text class="summary-title">{{ activeReport.statusTitle }}</text><text class="summary-description">{{ activeReport.statusDescription }}</text><view class="summary-stats"><view class="summary-stat"><text class="summary-value">{{ activeReport.newMasteredPoints }} 个</text><text class="summary-label">新攻克知识点</text></view><view class="summary-stat summary-stat-middle"><text class="summary-value">{{ activeReport.consecutiveDays }} 天</text><text class="summary-label">连续学习</text></view><view class="summary-stat"><text class="summary-value">{{ activeReport.averageAccuracy }}%</text><text class="summary-label">平均正确率</text></view></view></view>
<view class="trend-card"><text class="section-title">已掌握知识点成长曲线</text><view class="trend-legend"><view class="legend-item"><view class="legend-mark legend-mark-student"></view><text class="legend-text">本学生</text></view><view class="legend-item"><view class="legend-mark legend-mark-average"></view><text class="legend-text">班级平均</text></view></view><StudyTrendChart :scale-labels="activeReport.scaleLabels" :points="activeTrend"></StudyTrendChart></view>
<view class="activity-card"><text class="section-title">{{ reportPeriodLabel }}学习活跃度与效率</text><text class="activity-summary">{{ activeReport.activity.summary }}</text><text class="activity-total">学习 {{ activeReport.activity.activeDays }} 天,累计 {{ activeReport.activity.totalDurationText }}</text><StudyActivityChart :points="activeReport.activity.points"></StudyActivityChart><view class="activity-legend"><view class="legend-item activity-legend-item"><view class="legend-mark legend-mark-student"></view><text class="legend-text">高质量(有产出)</text></view><view class="legend-item activity-legend-item"><view class="legend-mark legend-mark-plan"></view><text class="legend-text">普通学习(无新产出)</text></view><view class="legend-item activity-legend-item"><view class="legend-mark legend-mark-mastery"></view><text class="legend-text">新增掌握</text></view></view></view>
<view v-if="activeSubjectId === 'all'" class="mastery-card"><text class="section-title">各科知识点掌握情况</text><view v-for="mastery in activeReport.subjectMasteries" :key="mastery.subjectName" class="mastery-item"><view class="mastery-heading"><text class="mastery-subject">{{ mastery.subjectName }}</text><text class="mastery-value">{{ mastery.masteredCount }} / {{ mastery.totalCount }}</text></view><view class="mastery-track"><view class="mastery-progress" :style="{ width: mastery.masteredPercent + '%' }"></view><view class="mastery-remaining"></view></view></view><view class="mastery-legend"><view class="mastery-legend-item"><view class="mastery-legend-color mastery-legend-mastered"></view><text class="mastery-legend-text">已掌握</text></view><view class="mastery-legend-item"><view class="mastery-legend-color mastery-legend-unmastered"></view><text class="mastery-legend-text">未掌握</text></view></view></view>
</view>
</scroll-view>
<MainTabBar active-tab="study"></MainTabBar>
</view>
</template>
<script setup lang="ts">
import MainTabBar from '../../components/main-tab-bar.uvue'
import StudyActivityChart from '../../components/study-activity-chart.uvue'
import StudyTrendChart from '../../components/study-trend-chart.uvue'
import UniIcons from '../../uni_modules/uni-icons-x/components/uni-icons/uni-icons.uvue'
import { apiGetStudentHomeProfile, apiGetStudentStudyReport, type StudentStudyReport, type StudyReportActivity, type StudyReportPeriod, type StudyReportSubjectMastery, type StudyReportTrendPoint } from '../../api/student'
import { homeStudentProfile, setHomeStudentProfile } from '../../store/home'
const emptyActivity: StudyReportActivity = { summary: '', activeDays: 0, totalDurationText: '', points: [] }
const emptyPeriod: StudyReportPeriod = { rangeText: '', statusTitle: '', statusDescription: '', newMasteredPoints: 0, consecutiveDays: 0, averageAccuracy: 0, scaleLabels: [], trends: [], activity: emptyActivity, subjectMasteries: [] as StudyReportSubjectMastery[] }
const report = ref<StudentStudyReport>({ studentId: '', studentName: '', studentGrade: '', studentClass: '', updatedAtText: '', subjects: [], week: emptyPeriod, month: emptyPeriod })
const isLoading = ref(true)
const isRefreshing = ref(false)
const activePeriod = ref('week')
const activeSubjectId = ref('all')
const activeReport = computed((): StudyReportPeriod => activePeriod.value === 'week' ? report.value.week : report.value.month)
const reportPeriodLabel = computed((): string => activePeriod.value === 'week' ? '本周' : '本月')
const activeTrend = computed((): StudyReportTrendPoint[] => {
for (let index = 0; index < activeReport.value.trends.length; index++) {
const trend = activeReport.value.trends[index]
if (trend.subjectId === activeSubjectId.value) return trend.points
}
return activeReport.value.trends.length > 0 ? activeReport.value.trends[0].points : []
})
onLoad(() => {
loadReport()
})
async function loadReport() {
let studentId = homeStudentProfile.value?.id ?? ''
if (!studentId) {
const profileRes = await apiGetStudentHomeProfile(undefined, false)
if (profileRes && profileRes.data) {
setHomeStudentProfile(profileRes.data)
studentId = profileRes.data.id
}
}
if (!studentId) { isLoading.value = false; return }
const res = await apiGetStudentStudyReport(studentId)
if (res && res.data) report.value = res.data
isLoading.value = false
}
async function refreshReport() {
isRefreshing.value = true
await loadReport()
isRefreshing.value = false
}
function setPeriod(period: string) {
activePeriod.value = period
}
function setSubject(subjectId: string) {
activeSubjectId.value = subjectId
}
</script>
<style>
.page-container { display: flex; flex-direction: column; flex: 1; background-color: #f5f7fb; }
.header-panel { display: flex; flex-direction: row; align-items: center; padding: 12px 18px; background-color: #ffffff; }
.header-back { display: flex; flex-direction: column; align-items: center; justify-content: center; width: 36px; height: 36px; border-radius: 18px; background-color: #f1f4f8; }
.header-back-icon { font-size: 18px; color: #66758d; }
.header-copy { display: flex; flex-direction: column; flex: 1; margin-left: 12px; }
.header-title { font-size: 17px; font-weight: 700; color: #22283a; }
.header-subtitle { margin-top: 3px; font-size: 12px; color: #8992a5; }
.header-action { display: flex; flex-direction: column; align-items: center; justify-content: center; width: 36px; height: 36px; border-radius: 18px; background-color: #f0f1ff; }
.header-action-icon { font-size: 18px; color: #6558e9; }
.report-meta { padding: 8px 18px; background-color: #ffffff; border-top: 1px solid #edf0f5; }
.report-meta-text { font-size: 11px; color: #8992a5; }
.content-scroll { flex: 1; }
.status-panel { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 260px; }
.status-text { font-size: 13px; color: #8992a5; }
.content-panel { display: flex; flex-direction: column; padding: 12px 18px 24px; }
.filters-card { display: flex; flex-direction: column; padding: 12px; border-radius: 8px; background-color: #ffffff; }
.period-tabs { display: flex; flex-direction: row; }
.period-tab { padding: 7px 14px; margin-right: 8px; border-radius: 16px; background-color: #f1f4f8; }
.period-tab-active { background-color: #554be9; }
.period-tab-text { font-size: 12px; color: #64748b; }
.period-tab-text-active { color: #ffffff; }
.subject-scroll { margin-top: 10px; }
.subject-tabs { display: flex; flex-direction: row; }
.subject-tab { flex-shrink: 0; padding: 7px 14px; margin-right: 8px; border-radius: 16px; background-color: #f1f4f8; }
.subject-tab-active { background-color: #554be9; }
.subject-tab-text { font-size: 12px; color: #64748b; }
.subject-tab-text-active { color: #ffffff; }
.summary-card { display: flex; flex-direction: column; margin-top: 16px; padding: 16px; border-radius: 14px; background-color: #6545e8; }
.summary-title { font-size: 15px; font-weight: 700; color: #ffffff; }
.summary-description { margin-top: 8px; font-size: 12px; line-height: 19px; color: #efeaff; }
.summary-stats { display: flex; flex-direction: row; margin-top: 14px; padding: 10px 0; border-radius: 8px; background-color: #7659ee; }
.summary-stat { display: flex; flex-direction: column; align-items: center; flex: 1; }
.summary-stat-middle { border-left: 1px solid #927cf3; border-right: 1px solid #927cf3; }
.summary-value { font-size: 15px; font-weight: 700; color: #ffffff; }
.summary-label { margin-top: 4px; font-size: 10px; color: #e9e2ff; }
.trend-card { display: flex; flex-direction: column; margin-top: 16px; padding: 16px; border-radius: 8px; background-color: #ffffff; }
.section-title { font-size: 16px; font-weight: 700; color: #242b3d; }
.trend-legend { display: flex; flex-direction: row; justify-content: flex-end; margin-top: 12px; }
.legend-item { display: flex; flex-direction: row; align-items: center; margin-left: 12px; }
.legend-mark { width: 11px; height: 5px; margin-right: 5px; border-radius: 2px; }
.legend-mark-student { background-color: #6558e9; }
.legend-mark-average { background-color: #9aa8bc; }
.legend-mark-plan { background-color: #d3dce8; }
.legend-mark-mastery { width: 7px; height: 7px; border-radius: 4px; background-color: #ffab26; }
.legend-text { font-size: 10px; color: #596275; }
.activity-card { display: flex; flex-direction: column; margin-top: 16px; padding: 16px; border-radius: 8px; background-color: #ffffff; }
.activity-summary { margin-top: 8px; font-size: 12px; color: #596275; }
.activity-total { margin-top: 4px; font-size: 12px; color: #8992a5; }
.activity-legend { display: flex; flex-direction: row; flex-wrap: wrap; margin-top: 6px; }
.activity-legend-item { margin-left: 0; margin-right: 12px; margin-bottom: 4px; }
.mastery-card { display: flex; flex-direction: column; margin-top: 16px; padding: 16px; border-radius: 8px; background-color: #ffffff; }
.mastery-legend { display: flex; flex-direction: row; margin-top: 16px; }
.mastery-legend-item { display: flex; flex-direction: row; align-items: center; margin-right: 16px; }
.mastery-legend-color { width: 8px; height: 8px; margin-right: 5px; border-radius: 2px; }
.mastery-legend-mastered { background-color: #16bd88; }
.mastery-legend-unmastered { background-color: #f34d54; }
.mastery-legend-text { font-size: 11px; color: #8992a5; }
.mastery-item { display: flex; flex-direction: column; margin-top: 14px; }
.mastery-heading { display: flex; flex-direction: row; justify-content: space-between; }
.mastery-subject { font-size: 13px; color: #4a5568; }
.mastery-value { font-size: 13px; font-weight: 700; color: #242b3d; }
.mastery-track { display: flex; flex-direction: row; height: 10px; margin-top: 7px; overflow: hidden; border-radius: 5px; background-color: #f1f4f8; }
.mastery-progress { height: 10px; border-radius: 5px 0 0 5px; background-color: #16bd88; }
.mastery-remaining { flex: 1; border-radius: 0 5px 5px 0; background-color: #f34d54; }
</style>
+85
View File
@@ -0,0 +1,85 @@
<template>
<view class="page-container"><view class="header-panel"><view class="header-copy"><text class="header-title">作业任务</text><text class="header-subtitle">{{ tasks.studentName }} · 待完成 {{ tasks.pendingCount }} 个</text></view><view class="header-action"><UniIcons type="person-filled" class="header-action-icon"></UniIcons></view></view><view class="filter-panel"><view v-for="filter in filters" :key="filter.id" class="filter-tab" :class="activeFilter === filter.id ? 'filter-tab-active' : ''" @tap="setFilter(filter.id)"><text class="filter-text" :class="activeFilter === filter.id ? 'filter-text-active' : ''">{{ filter.name }}</text></view></view><scroll-view class="content-scroll" direction="vertical" scroll-y="true" refresher-enabled="true" :refresher-triggered="isRefreshing" @refresherrefresh="refreshTasks"><view v-if="isLoading" class="status-panel"><text class="status-text">任务加载中</text></view><view v-else class="content-panel"><view v-for="group in visibleGroups" :key="group.id" class="group-card"><view class="group-heading"><view class="group-tags"><text class="subject-tag">{{ group.subject }}</text><text class="unit-tag">{{ group.unit }}</text><text class="lesson-title">{{ group.lesson }}</text></view><text class="knowledge-count">{{ group.knowledgeCount > 0 ? group.knowledgeCount + ' 个知识点' : '暂无学习内容' }}</text></view><text class="lesson-time">● {{ group.startText }}-{{ group.endText }}</text><view v-if="group.note" class="group-note"><text class="group-note-icon">i</text><text class="group-note-text">{{ group.note }}</text></view><view v-for="task in group.tasks" :key="task.id" v-show="matchesFilter(task)" class="detail-task"><view class="detail-task-heading"><text class="detail-task-title">{{ task.title }}</text><text class="detail-task-status" :class="'detail-task-status-' + task.status">{{ task.statusText }}</text></view><text class="detail-task-result" :class="'detail-task-result-' + task.status">● {{ task.statusText }} · 共 {{ task.status === 'success' ? '2' : '3' }} 次</text><text v-if="task.accuracyText" class="detail-task-meta">{{ task.accuracyText }} · {{ task.answerText }} · {{ task.deadlineText }}</text></view></view><view v-if="visibleGroups.length === 0" class="empty-panel"><text class="empty-text">暂无对应任务</text></view></view></scroll-view><MainTabBar active-tab="tasks"></MainTabBar></view>
</template>
<script setup lang="ts">
import MainTabBar from '../../components/main-tab-bar.uvue'
import UniIcons from '../../uni_modules/uni-icons-x/components/uni-icons/uni-icons.uvue'
import { apiGetStudentHomeProfile, apiGetStudentLearningTasks, type LearningTaskItem, type StudentLearningTasks } from '../../api/student'
import { homeStudentProfile, setHomeStudentProfile } from '../../store/home'
const filters = [{ id: 'all', name: '全部' }, { id: 'pending', name: '待完成' }, { id: 'completed', name: '已完成' }, { id: 'failed', name: '未通过' }]
const activeFilter = ref('all')
const isLoading = ref(true)
const isRefreshing = ref(false)
const tasks = ref<StudentLearningTasks>({ studentId: '', studentName: '', studentGrade: '', studentClass: '', pendingCount: 0, completedCount: 0, failedCount: 0, groups: [] })
const visibleGroups = computed(() => tasks.value.groups.filter((group) => group.tasks.length === 0 || group.tasks.some((task) => matchesFilter(task))))
onLoad(() => { loadTasks() })
async function loadTasks() {
let studentId = homeStudentProfile.value?.id ?? ''
if (!studentId) {
const profileRes = await apiGetStudentHomeProfile(undefined, false)
if (profileRes && profileRes.data) { setHomeStudentProfile(profileRes.data); studentId = profileRes.data.id }
}
if (studentId) { const res = await apiGetStudentLearningTasks(studentId); if (res && res.data) tasks.value = res.data }
isLoading.value = false
}
async function refreshTasks() {
isRefreshing.value = true
await loadTasks()
isRefreshing.value = false
}
function setFilter(filter: string) { activeFilter.value = filter }
function matchesFilter(task: LearningTaskItem): boolean {
if (activeFilter.value === 'all') return true
if (activeFilter.value === 'pending') return task.status === 'not_started' || task.status === 'in_progress'
if (activeFilter.value === 'completed') return task.status === 'success'
return task.status === 'failed'
}
</script>
<style>
.page-container { display: flex; flex-direction: column; flex: 1; background-color: #f5f7fb; }
.header-panel { display: flex; flex-direction: row; align-items: center; justify-content: space-between; padding: 16px 20px; background-color: #ffffff; border-bottom: 1px solid #edf0f5; }
.header-copy { display: flex; flex-direction: column; }
.header-title { font-size: 18px; font-weight: 700; color: #242b3d; }
.header-subtitle { margin-top: 4px; font-size: 11px; color: #8992a5; }
.header-action { display: flex; flex-direction: column; align-items: center; justify-content: center; width: 36px; height: 36px; border-radius: 18px; background-color: #f0f1ff; }
.header-action-icon { font-size: 18px; color: #554be9; }
.filter-panel { display: flex; flex-direction: row; padding: 12px 20px; background-color: #ffffff; }
.filter-tab { display: flex; flex-direction: column; align-items: center; flex: 1; margin-right: 8px; padding: 7px 0; border-radius: 16px; text-align: center; background-color: #f1f4f8; }
.filter-tab-active { background-color: #554be9; }
.filter-text { font-size: 11px; color: #64748b; }
.filter-text-active { color: #ffffff; }
.content-scroll { flex: 1; }
.content-panel { display: flex; flex-direction: column; padding: 14px 16px 24px; }
.status-panel, .empty-panel { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 60px 0; }
.status-text, .empty-text { font-size: 13px; color: #8992a5; }
.group-card { display: flex; flex-direction: column; margin-bottom: 12px; padding: 16px; border-radius: 15px; background-color: #ffffff; }
.group-heading { display: flex; flex-direction: row; align-items: center; justify-content: space-between; }
.group-tags { display: flex; flex-direction: row; align-items: center; flex: 1; }
.subject-tag, .unit-tag { padding: 3px 7px; margin-right: 6px; border-radius: 8px; font-size: 10px; color: #554be9; background-color: #eeefff; }
.unit-tag { color: #1598dc; background-color: #e6f5ff; }
.lesson-title { font-size: 13px; font-weight: 700; color: #242b3d; }
.knowledge-count { flex-shrink: 0; font-size: 10px; color: #8992a5; }
.lesson-time { margin-top: 7px; font-size: 11px; color: #8ca0bd; }
.group-note { display: flex; flex-direction: row; align-items: flex-start; margin-top: 12px; padding: 12px; border-radius: 12px; background-color: #f6f8fb; }
.group-note-icon { width: 12px; height: 12px; margin-right: 7px; border-radius: 6px; text-align: center; font-size: 9px; color: #ffffff; background-color: #9aa8bc; }
.group-note-text { flex: 1; font-size: 11px; line-height: 18px; color: #68778b; }
.detail-task { display: flex; flex-direction: column; margin-top: 10px; padding: 12px; border: 1px solid #edf0f5; border-radius: 12px; background-color: #f9fbfd; }
.detail-task-heading { display: flex; flex-direction: row; justify-content: space-between; }
.detail-task-title { font-size: 13px; color: #242b3d; }
.detail-task-status { padding: 3px 7px; border-radius: 8px; font-size: 10px; }
.detail-task-status-success { color: #16a979; background-color: #e8fbf3; }
.detail-task-status-in_progress { color: #5147e9; background-color: #eeefff; }
.detail-task-status-failed { color: #f34d54; background-color: #fff0f1; }
.detail-task-result { margin-top: 8px; font-size: 11px; }
.detail-task-result-success { color: #16a979; }
.detail-task-result-in_progress { color: #5147e9; }
.detail-task-result-failed { color: #f34d54; }
.detail-task-meta { margin-top: 8px; padding-top: 8px; border-top: 1px solid #e4eaf1; font-size: 10px; color: #8992a5; }
</style>
+147
View File
@@ -0,0 +1,147 @@
<template>
<view class="page-container">
<view class="header-panel">
<view class="header-back" @tap="goBack"><UniIcons type="back" class="header-back-icon"></UniIcons></view>
<view class="header-copy"><text class="header-title">今日学习</text><text class="header-subtitle">{{ detail.dateText }} {{ detail.studentName }}</text></view>
<view class="header-action" @tap="goBack"><UniIcons type="home-filled" class="header-action-icon"></UniIcons></view>
</view>
<scroll-view class="content-scroll" direction="vertical" scroll-y="true">
<view v-if="isLoading" class="status-panel"><text class="status-text">今日学习信息加载中</text></view>
<view v-else class="content-panel">
<view class="overview-card">
<view class="overview-header"><text class="section-title">今日学习概览</text><text class="checkin-status" :class="detail.checkedIn ? 'checkin-complete' : 'checkin-pending'">{{ detail.checkedIn ? '已打卡' : '未打卡' }}</text></view>
<view class="metrics-grid">
<view class="metric-card"><text class="metric-label">学习时长</text><text class="metric-value">{{ detail.learningMinutes }} 分钟</text></view>
<view class="metric-card"><text class="metric-label">答题时长</text><text class="metric-value">{{ detail.answerMinutes }} 分钟</text></view>
<view class="metric-card"><text class="metric-label">答题数量</text><text class="metric-value metric-blue">{{ detail.answerCount }} 题</text></view>
<view class="metric-card"><text class="metric-label">完成学习包</text><text class="metric-value metric-purple">{{ detail.completedPackages }} / {{ detail.totalPackages }}</text></view>
<view class="metric-card"><text class="metric-label">攻克知识点</text><text class="metric-value metric-green">{{ detail.masteredPoints }} 个</text></view>
</view>
</view>
<view class="courses-card">
<text class="section-title">今日课表与每课打卡</text>
<view class="course-list">
<view v-for="course in detail.courses" :key="course.id" class="course-item">
<text class="course-time">{{ course.time }}</text>
<view class="course-copy"><text class="course-subject">{{ course.subject }}</text><text class="course-content">{{ course.content }}</text></view>
<text class="course-status" :class="course.checkedIn ? 'course-complete' : 'course-pending'">{{ course.checkedIn ? '已打卡' : '未打卡' }}</text>
</view>
</view>
</view>
<view class="score-card">
<text class="section-title">正确率(对比昨日)</text>
<text class="score-description">整体正确率较昨日全部答题目统计,与昨日对比</text>
<view class="score-row"><text class="score-label">整体正确率</text><view class="score-values"><text class="score-value">{{ detail.score.accuracy }}%</text><text class="score-change">+{{ detail.score.change }}%</text></view></view>
<text class="score-yesterday">昨日 {{ detail.score.yesterdayAccuracy }}%</text>
<view class="score-track"><view class="score-progress" :style="{ width: detail.score.accuracy + '%' }"></view></view>
</view>
<view class="points-card">
<view class="points-header"><text class="section-title">今日获得积分</text><text class="points-link">积分明细 ›</text></view>
<view class="points-total"><text class="points-number">+{{ detail.pointsTotal }}</text><text class="points-unit">积分</text></view>
<view class="points-list">
<view v-for="item in detail.pointDetails" :key="item.id" class="point-item"><text class="point-label">{{ item.label }}</text><text class="point-value">+{{ item.points }}</text></view>
</view>
</view>
</view>
</scroll-view>
<MainTabBar active-tab="home"></MainTabBar>
</view>
</template>
<script setup lang="ts">
import MainTabBar from '../../components/main-tab-bar.uvue'
import UniIcons from '../../uni_modules/uni-icons-x/components/uni-icons/uni-icons.uvue'
import { apiGetTodayLearningDetail, type TodayLearningDetail } from '../../api/student'
import { homeStudentProfile } from '../../store/home'
const isLoading = ref(true)
const detail = ref<TodayLearningDetail>({
studentId: '', studentName: '', dateText: '', checkedIn: false, learningMinutes: 0, answerMinutes: 0,
answerCount: 0, completedPackages: 0, totalPackages: 0, masteredPoints: 0, courses: [],
score: { accuracy: 0, yesterdayAccuracy: 0, change: 0 }, pointsTotal: 0, pointDetails: []
})
onLoad(() => {
loadTodayLearning()
})
async function loadTodayLearning() {
const studentId = homeStudentProfile.value?.id ?? ''
if (!studentId) {
isLoading.value = false
return
}
const res = await apiGetTodayLearningDetail(studentId)
if (res && res.data) {
detail.value = res.data
}
isLoading.value = false
}
function goBack() {
uni.navigateBack()
}
</script>
<style>
.page-container { display: flex; flex-direction: column; flex: 1; background-color: #f5f7fb; }
.header-panel { display: flex; flex-direction: row; align-items: center; padding: 12px 18px; background-color: #ffffff; }
.header-back { display: flex; flex-direction: column; align-items: center; justify-content: center; width: 36px; height: 36px; border-radius: 18px; background-color: #f1f4f8; }
.header-back-icon { font-size: 18px; color: #66758d; }
.header-copy { display: flex; flex-direction: column; flex: 1; margin-left: 12px; }
.header-title { font-size: 17px; font-weight: 700; color: #22283a; }
.header-subtitle { margin-top: 3px; font-size: 12px; color: #8992a5; }
.header-action { display: flex; flex-direction: column; align-items: center; justify-content: center; width: 36px; height: 36px; border-radius: 18px; background-color: #f0f1ff; }
.header-action-icon { font-size: 18px; color: #6558e9; }
.content-scroll { flex: 1; }
.status-panel { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 260px; }
.status-text { font-size: 13px; color: #8992a5; }
.content-panel { display: flex; flex-direction: column; padding: 16px 18px 24px; }
.overview-card { display: flex; flex-direction: column; padding: 16px; border-radius: 8px; background-color: #ffffff; }
.overview-header { display: flex; flex-direction: row; align-items: center; justify-content: space-between; }
.section-title { font-size: 16px; font-weight: 700; color: #242b3d; }
.checkin-status { padding: 4px 10px; border-radius: 12px; font-size: 11px; }
.checkin-complete { color: #00a66a; background-color: #e9fbf3; }
.checkin-pending { color: #f08a1a; background-color: #fff4e5; }
.metrics-grid { display: flex; flex-direction: row; flex-wrap: wrap; justify-content: space-between; margin-top: 14px; }
.metric-card { display: flex; flex-direction: column; width: 48%; margin-bottom: 10px; padding: 12px; border-radius: 8px; background-color: #f6f8fb; }
.metric-label { font-size: 11px; color: #8992a5; }
.metric-value { margin-top: 6px; font-size: 15px; font-weight: 700; color: #20283a; }
.metric-blue { color: #008ddb; }
.metric-purple { color: #6558e9; }
.metric-green { color: #00a66a; }
.courses-card { display: flex; flex-direction: column; margin-top: 16px; padding: 16px; border-radius: 8px; background-color: #ffffff; }
.course-list { display: flex; flex-direction: column; margin-top: 14px; }
.course-item { display: flex; flex-direction: row; align-items: center; min-height: 58px; margin-bottom: 10px; padding: 10px 12px; border-radius: 8px; background-color: #f6f8fb; }
.course-time { width: 56px; font-size: 12px; color: #596275; }
.course-copy { display: flex; flex-direction: column; flex: 1; }
.course-subject { font-size: 13px; font-weight: 600; color: #2a3042; }
.course-content { margin-top: 4px; font-size: 12px; color: #596275; }
.course-status { padding: 4px 7px; border-radius: 10px; font-size: 10px; }
.course-complete { color: #00a66a; background-color: #e9fbf3; }
.course-pending { color: #f08a1a; background-color: #fff4e5; }
.score-card { display: flex; flex-direction: column; margin-top: 16px; padding: 16px; border-radius: 8px; background-color: #ffffff; }
.score-description { margin-top: 6px; font-size: 11px; color: #8992a5; }
.score-row { display: flex; flex-direction: row; align-items: center; justify-content: space-between; margin-top: 14px; }
.score-label { font-size: 12px; color: #596275; }
.score-values { display: flex; flex-direction: row; align-items: center; }
.score-value { font-size: 13px; font-weight: 700; color: #20283a; }
.score-change { margin-left: 8px; font-size: 12px; color: #00a66a; }
.score-yesterday { margin-top: 6px; font-size: 12px; color: #8992a5; }
.score-track { height: 8px; margin-top: 8px; border-radius: 4px; background-color: #edf1f7; }
.score-progress { height: 8px; border-radius: 4px; background-color: #7659ee; }
.points-card { display: flex; flex-direction: column; margin-top: 16px; padding: 16px; border-radius: 8px; background-color: #ffffff; }
.points-header { display: flex; flex-direction: row; align-items: center; justify-content: space-between; }
.points-link { font-size: 12px; color: #514ce8; }
.points-total { display: flex; flex-direction: row; align-items: baseline; margin-top: 10px; }
.points-number { font-size: 24px; font-weight: 700; color: #ee9500; }
.points-unit { margin-left: 5px; font-size: 12px; color: #8992a5; }
.points-list { display: flex; flex-direction: column; margin-top: 12px; }
.point-item { display: flex; flex-direction: row; justify-content: space-between; margin-bottom: 8px; padding: 10px 12px; border-radius: 8px; background-color: #f6f8fb; }
.point-label { font-size: 12px; color: #596275; }
.point-value { font-size: 12px; font-weight: 600; color: #ee9500; }
</style>
+92
View File
@@ -0,0 +1,92 @@
<template>
<view class="page-container">
<view class="header-panel"><view class="header-back" @tap="goHome"><UniIcons type="back" class="header-back-icon"></UniIcons></view><view class="header-copy"><text class="header-title">英语词汇</text><text class="header-subtitle">{{ vocabulary.studentName }} · {{ vocabulary.studentGrade }}·{{ vocabulary.studentClass }}</text></view><view class="header-action"><UniIcons type="person-filled" class="header-action-icon"></UniIcons></view></view>
<scroll-view class="content-scroll" direction="vertical" scroll-y="true"><view v-if="isLoading" class="status-panel"><text class="status-text">词汇书加载中</text></view><view v-else class="content-panel"><view class="list-heading"><text class="list-title">全部词书</text><text class="book-count">{{ vocabulary.bookCount }} 本</text></view><text class="list-description">每本词书的总词量与已学进度</text><view v-for="book in vocabulary.books" :key="book.id" class="book-card" :class="'book-card-' + book.color"><view class="book-heading"><view class="book-icon" :class="'book-icon-' + book.color"><UniIcons type="font" class="book-icon-glyph" :class="'book-icon-glyph-' + book.color"></UniIcons></view><view class="book-copy"><text class="book-name">{{ book.name }}</text><view class="book-meta"><text class="book-total">{{ book.subtitle }}</text><text class="book-level">{{ book.levelText }}</text></view></view><text class="book-status" :class="'book-status-' + book.color">{{ book.statusText }}</text></view><view class="progress-heading"><text class="progress-label">学习进度</text><text class="progress-value">{{ book.learnedWords > 0 ? '已学 ' + book.learnedWords + ' / ' + book.totalWords + ' 词 · ' + progressPercent(book) + '%' : '未开始' }}</text></view><view class="progress-track"><view class="progress-fill" :class="'progress-fill-' + book.color" :style="{ width: progressPercent(book) + '%' }"></view></view></view></view></scroll-view>
</view>
</template>
<script setup lang="ts">
import UniIcons from '../../uni_modules/uni-icons-x/components/uni-icons/uni-icons.uvue'
import { apiGetStudentHomeProfile, apiGetStudentVocabulary, type StudentVocabulary, type VocabularyBook } from '../../api/student'
import { homeStudentProfile, setHomeStudentProfile } from '../../store/home'
const vocabulary = ref<StudentVocabulary>({ studentId: '', studentName: '', studentGrade: '', studentClass: '', bookCount: 0, books: [] })
const isLoading = ref(true)
onLoad(() => { loadVocabulary() })
async function loadVocabulary() {
let studentId = homeStudentProfile.value?.id ?? ''
if (!studentId) {
const profileRes = await apiGetStudentHomeProfile(undefined, false)
if (profileRes && profileRes.data) {
setHomeStudentProfile(profileRes.data)
studentId = profileRes.data.id
}
}
if (studentId) {
const res = await apiGetStudentVocabulary(studentId)
if (res && res.data) vocabulary.value = res.data
}
isLoading.value = false
}
function progressPercent(book: VocabularyBook): number {
if (book.totalWords === 0) return 0
return Math.round(book.learnedWords / book.totalWords * 100)
}
function goHome() { uni.reLaunch({ url: '/pages/index/index' }) }
</script>
<style>
.page-container { display: flex; flex-direction: column; flex: 1; background-color: #f5f7fb; }
.header-panel { display: flex; flex-direction: row; align-items: center; padding: 12px 18px; background-color: #ffffff; }
.header-back { display: flex; flex-direction: column; align-items: center; justify-content: center; width: 36px; height: 36px; border-radius: 18px; background-color: #f1f4f8; }
.header-back-icon { font-size: 18px; color: #66758d; }
.header-copy { display: flex; flex-direction: column; flex: 1; margin-left: 12px; }
.header-title { font-size: 17px; font-weight: 700; color: #22283a; }
.header-subtitle { margin-top: 3px; font-size: 12px; color: #8992a5; }
.header-action { display: flex; flex-direction: column; align-items: center; justify-content: center; width: 36px; height: 36px; border-radius: 18px; background-color: #f0f1ff; }
.header-action-icon { font-size: 18px; color: #554be9; }
.content-scroll { flex: 1; }
.content-panel { display: flex; flex-direction: column; padding: 18px 22px 28px; }
.status-panel { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 260px; }
.status-text { font-size: 13px; color: #8992a5; }
.list-heading { display: flex; flex-direction: row; align-items: center; justify-content: space-between; }
.list-title { font-size: 16px; font-weight: 700; color: #242b3d; }
.book-count { font-size: 12px; color: #8992a5; }
.list-description { margin-top: 6px; font-size: 11px; color: #8992a5; }
.book-card { display: flex; flex-direction: column; margin-top: 12px; padding: 16px; border: 1px solid; border-radius: 16px; }
.book-card-green { border-color: #8ee8ca; background-color: #f0fff9; }
.book-card-yellow { border-color: #ffd66e; background-color: #fffaf0; }
.book-card-blue { border-color: #9cd7ff; background-color: #eff8ff; }
.book-card-gray { border-color: #dbe2eb; background-color: #f2f6fa; }
.book-heading { display: flex; flex-direction: row; align-items: flex-start; }
.book-icon { display: flex; flex-direction: column; align-items: center; justify-content: center; width: 40px; height: 40px; border-radius: 11px; }
.book-icon-green { background-color: #16bd88; }
.book-icon-yellow { background-color: #fff0c5; }
.book-icon-blue { background-color: #d9f0ff; }
.book-icon-gray { background-color: #ffffff; }
.book-icon-glyph { font-size: 20px; color: #ffffff; }
.book-icon-glyph-yellow, .book-icon-glyph-blue { color: #1598dc; }
.book-copy { display: flex; flex-direction: column; flex: 1; margin-left: 12px; min-width: 0; }
.book-name { font-size: 14px; font-weight: 700; color: #203329; }
.book-meta { display: flex; flex-direction: row; align-items: center; margin-top: 5px; }
.book-total { font-size: 10px; color: #657487; }
.book-level { margin-left: 9px; padding: 2px 6px; border-radius: 8px; font-size: 9px; color: #657487; background-color: #e4f8ef; }
.book-status { flex-shrink: 0; padding: 7px 11px; border-radius: 16px; font-size: 10px; }
.book-status-green { color: #ffffff; background-color: #16bd88; }
.book-status-yellow { color: #d99000; border: 1px solid #ffd66e; background-color: #fff4cf; }
.book-status-blue { color: #168ed0; border: 1px solid #9cd7ff; background-color: #e2f4ff; }
.book-status-gray { color: #68778b; border: 1px solid #d5dee9; background-color: #ffffff; }
.progress-heading { display: flex; flex-direction: row; justify-content: space-between; margin-top: 13px; }
.progress-label, .progress-value { font-size: 11px; color: #567084; }
.progress-value { text-align: right; }
.progress-track { height: 6px; margin-top: 7px; overflow: hidden; border-radius: 3px; background-color: rgba(150, 170, 185, 0.18); }
.progress-fill { height: 6px; border-radius: 3px; }
.progress-fill-green { background-color: #16bd88; }
.progress-fill-yellow { background-color: #ffc126; }
.progress-fill-blue { background-color: #159fe8; }
.progress-fill-gray { background-color: #ccd5df; }
</style>
+7
View File
@@ -0,0 +1,7 @@
// 参考链接 https://doc.dcloud.net.cn/uni-app-x/tutorial/ls-plugin.html#setting
{
"targets": [
"APP-ANDROID"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

+24
View File
@@ -0,0 +1,24 @@
# 静态资源说明
## 需要添加的图片资源
请在 `static` 目录下添加以下图片:
### 1. logo.png
- **用途**: 应用Logo,用于登录页、注册页等
- **尺寸建议**: 256x256px 或更大
- **格式**: PNG (支持透明背景)
- **路径**: `/static/logo.png`
## 临时处理
如果暂时没有logo图片,可以:
1. 使用任意图片重命名为 `logo.png` 放到此目录
2. 或者注释掉页面中的 `<image>` 标签
## 其他资源
后续可以根据需要添加其他图片资源,如:
- 引导页图片
- 默认头像
- 占位图等
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

+24
View File
@@ -0,0 +1,24 @@
import type { StudentAlert, StudentHomeMetrics, StudentHomeProfile } from '../api/student'
// 仅在本次登录会话内缓存首页学生资料,切换一级页面时不重复请求。
export const homeStudentProfile = ref<StudentHomeProfile | null>(null)
export const homeStudentMetrics = ref<StudentHomeMetrics | null>(null)
export const homeStudentAlerts = ref<StudentAlert[] | null>(null)
export function setHomeStudentProfile(profile: StudentHomeProfile) {
homeStudentProfile.value = profile
}
export function setHomeStudentMetrics(metrics: StudentHomeMetrics) {
homeStudentMetrics.value = metrics
}
export function setHomeStudentAlerts(alerts: StudentAlert[]) {
homeStudentAlerts.value = alerts
}
export function clearHomeStudentProfile() {
homeStudentProfile.value = null
homeStudentMetrics.value = null
homeStudentAlerts.value = null
}
+8
View File
@@ -0,0 +1,8 @@
import type { StudentInfo } from '../api/student'
// 仅在注册流程中保存待绑定学生,供扫码与手动输入页面共同使用。
export const registrationStudents = ref<StudentInfo[]>([])
export function resetRegistrationStudents() {
registrationStudents.value.splice(0)
}
+170
View File
@@ -0,0 +1,170 @@
/**
* 用户状态管理
* 使用 uni-app x 的响应式系统
*/
import type { UserInfo } from '../api/auth'
import { clearHomeStudentProfile } from './home'
// 用户状态类型
type UserState = {
userInfo: UserInfo | null
token: string
isLoggedIn: boolean
rememberPassword: boolean
rememberedPhone: string
rememberedPassword: string
}
// 创建响应式用户状态
const userState = reactive<UserState>({
userInfo: null,
token: '',
isLoggedIn: false,
rememberPassword: false,
rememberedPhone: '',
rememberedPassword: ''
})
/**
* 初始化用户状态
* 从本地存储恢复登录状态和记住密码信息
*/
export function initUserStore() {
// 恢复登录状态
const token = uni.getStorageSync('token') as string
const userInfo = uni.getStorageSync('userInfo') as UserInfo | null
if (token && userInfo) {
userState.token = token
userState.userInfo = userInfo
userState.isLoggedIn = true
}
// 恢复记住密码信息
const rememberInfo = uni.getStorageSync('rememberInfo') as any
if (rememberInfo && rememberInfo.remember) {
userState.rememberPassword = true
userState.rememberedPhone = rememberInfo.phone
userState.rememberedPassword = rememberInfo.password
}
}
/**
* 设置用户信息
*/
export function setUserInfo(userInfo: UserInfo) {
userState.userInfo = userInfo
userState.token = userInfo.token
userState.isLoggedIn = true
// 保存到本地存储
uni.setStorageSync('token', userInfo.token)
uni.setStorageSync('refreshToken', userInfo.refreshToken)
uni.setStorageSync('userInfo', userInfo)
}
/**
* 保存记住密码信息
*/
export function saveRememberInfo(phone: string, password: string, remember: boolean) {
userState.rememberPassword = remember
userState.rememberedPhone = phone
userState.rememberedPassword = password
if (remember) {
uni.setStorageSync('rememberInfo', {
phone,
password,
remember: true
})
} else {
uni.removeStorageSync('rememberInfo')
}
}
/**
* 获取记住的密码信息
*/
export function getRememberInfo(): { phone: string, password: string, remember: boolean } {
return {
phone: userState.rememberedPhone,
password: userState.rememberedPassword,
remember: userState.rememberPassword
}
}
/**
* 清除用户状态
*/
export function clearUserInfo() {
userState.userInfo = null
userState.token = ''
userState.isLoggedIn = false
// 清除本地存储
uni.removeStorageSync('token')
uni.removeStorageSync('refreshToken')
uni.removeStorageSync('userInfo')
clearHomeStudentProfile()
}
/**
* 退出登录
*/
export function logout() {
clearUserInfo()
// 跳转到登录页
uni.reLaunch({
url: '/pages/login/index'
})
}
/**
* 获取用户信息
*/
export function getUserInfo(): UserInfo | null {
return userState.userInfo
}
/**
* 获取Token
*/
export function getToken(): string {
return userState.token
}
/**
* 获取登录状态
*/
export function getIsLoggedIn(): boolean {
return userState.isLoggedIn
}
/**
* 更新Token
*/
export function updateToken(token: string, refreshToken: string) {
userState.token = token
uni.setStorageSync('token', token)
uni.setStorageSync('refreshToken', refreshToken)
if (userState.userInfo !== null) {
userState.userInfo.token = token
userState.userInfo.refreshToken = refreshToken
uni.setStorageSync('userInfo', userState.userInfo)
}
}
// 导出用户状态(只读)
export function useUserStore() {
return {
userInfo: computed(() => userState.userInfo),
token: computed(() => userState.token),
isLoggedIn: computed(() => userState.isLoggedIn),
rememberPassword: computed(() => userState.rememberPassword),
rememberedPhone: computed(() => userState.rememberedPhone),
rememberedPassword: computed(() => userState.rememberedPassword)
}
}
+76
View File
@@ -0,0 +1,76 @@
/**
* 这里是uni-app内置的常用样式变量
*
* uni-app 官方扩展插件及插件市场(https://ext.dcloud.net.cn)上很多三方插件均使用了这些样式变量
* 如果你是插件开发者,建议你使用scss预处理,并在插件代码中直接使用这些变量(无需 import 这个文件),方便用户通过搭积木的方式开发整体风格一致的App
*
*/
/**
* 如果你是App开发者(插件使用者),你可以通过修改这些变量来定制自己的插件主题,实现自定义主题功能
*
* 如果你的项目同样使用了scss预处理,你也可以直接在你的 scss 代码中使用如下变量,同时无需 import 这个文件
*/
/* 颜色变量 */
/* 行为相关颜色 */
$uni-color-primary: #007aff;
$uni-color-success: #4cd964;
$uni-color-warning: #f0ad4e;
$uni-color-error: #dd524d;
/* 文字基本颜色 */
$uni-text-color:#333;//基本色
$uni-text-color-inverse:#fff;//反色
$uni-text-color-grey:#999;//辅助灰色,如加载更多的提示信息
$uni-text-color-placeholder: #808080;
$uni-text-color-disable:#c0c0c0;
/* 背景颜色 */
$uni-bg-color:#ffffff;
$uni-bg-color-grey:#f8f8f8;
$uni-bg-color-hover:#f1f1f1;//点击状态颜色
$uni-bg-color-mask:rgba(0, 0, 0, 0.4);//遮罩颜色
/* 边框颜色 */
$uni-border-color:#c8c7cc;
/* 尺寸变量 */
/* 文字尺寸 */
$uni-font-size-sm:12px;
$uni-font-size-base:14px;
$uni-font-size-lg:16px;
/* 图片尺寸 */
$uni-img-size-sm:20px;
$uni-img-size-base:26px;
$uni-img-size-lg:40px;
/* Border Radius */
$uni-border-radius-sm: 2px;
$uni-border-radius-base: 3px;
$uni-border-radius-lg: 6px;
$uni-border-radius-circle: 50%;
/* 水平间距 */
$uni-spacing-row-sm: 5px;
$uni-spacing-row-base: 10px;
$uni-spacing-row-lg: 15px;
/* 垂直间距 */
$uni-spacing-col-sm: 4px;
$uni-spacing-col-base: 8px;
$uni-spacing-col-lg: 12px;
/* 透明度 */
$uni-opacity-disabled: 0.3; // 组件禁用态的透明度
/* 文章场景相关 */
$uni-color-title: #2C405A; // 文章标题颜色
$uni-font-size-title:20px;
$uni-color-subtitle: #555555; // 二级标题颜色
$uni-font-size-subtitle:26px;
$uni-color-paragraph: #3F536E; // 文章段落颜色
$uni-font-size-paragraph:15px;
+6
View File
@@ -0,0 +1,6 @@
## 1.0.2(2026-05-18)
- 修复类名拼写错误
## 1.0.1(2026-04-30)
- 更新文档
## 1.0.0(2026-04-30)
- 支持 uni-badge-view 组件
@@ -0,0 +1,287 @@
<template>
<!-- 如果不显示badge,则不包裹,直接显示子内容。保证常态下的高性能。但如果设置了alwaysWrapWithContent且有slot内容,则仍然保持包裹层 -->
<slot v-if="text == '0' && alwaysWrapWithContent== false"></slot>
<!-- 如果有多个子slot,且不设badge,去掉包裹层直接露出slot,会导致flex方向错乱。此时应设置alwaysWrapWithContent为true,总是保留包裹层 -->
<!-- 微信小程序不支持外部绑定事件 -->
<template v-else>
<view v-if="hasDefaultSlot" style="overflow: visible;" class="badge-wrap" v-bind="$attrs">
<!-- 目标内容插槽 -->
<slot></slot>
<!-- badge 文字模式 -->
<text v-if="text != '' && text != '0' " :class="['text', 'position', badgeClass]" :style="badgeStyle">
{{ text }}
</text>
<!-- badge 红点模式 -->
<view v-else-if="text != '0'" :class="['dot', 'position', badgeClass]" :style="badgeStyle"></view>
</view>
<!-- 不包裹,直接显示badge 文字模式 -->
<text v-else-if="text != ''" :class="['text', badgeClass]" :style="badgeStyle">
{{ text }}
</text>
<!-- 不包裹,直接显示badge 红点模式 -->
<view v-else :class="['dot', badgeClass]" :style="badgeStyle"></view>
</template>
</template>
<script setup lang="ts">
/**
* Badge 数字角标
* @description 数字角标(徽章)组件
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.08"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "5.31"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
*/
// 禁用自动属性继承,手动控制属性绑定位置
defineOptions({
inheritAttrs: false,
externalClasses: ['badge-class']
})
// 定义组件属性接口
type Props = {
/**
* @description badge 的内容。为 "0" 时不显示 badge;为空字符串时显示圆点;有文字则显示文字
* @default ""
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "5.31"
* }
* }
* }
*/
text? : string,
/**
* @description badge 的内联样式,用于覆盖内置 style
* @default ""
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "5.31"
* }
* }
* }
*/
badgeStyle? : string,
/**
* @description badge 的样式类,用于覆盖内置 class
* @default ""
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "5.31"
* }
* }
* }
*/
badgeClass? : string.ClassString,
/**
* @description 当有 slot 内容时,即使 text 为 "0" 也保留包裹层(badge-wrap),避免多个子 slot 时 flex 方向错乱
* @default false
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "5.31"
* }
* }
* }
*/
alwaysWrapWithContent? : boolean
}
// 使用 withDefaults 设置默认值
const props = withDefaults(defineProps<Props>(), {
text: '',
badgeStyle: '',
badgeClass: '',
alwaysWrapWithContent: false
})
// 1. 获取插槽对象
const slots = useSlots();
// 2. 定义计算属性来判断默认插槽是否有内容
// TODO 这种判断方式的性能有待商榷,这利用了模板的隐式转换
const hasDefaultSlot = computed(() => {
// console.log('slot: ',slots['default']);
return slots['default'] != null;
});
</script>
<style>
.text {
/* background-color: rgba(225, 80, 80, 0.9); */ /* 有透明度*/
background-color: rgb(225, 80, 80); /* 无透明度*/
min-width: 14px;
height: 14px;
line-height: 14px;
border-radius: 7px;
/* padding-left: 4px; *//* 保证圆角不裁剪文字 */
/* padding-right: 4px; *//* 保证圆角不裁剪文字 */
font-size: 10px;
color: white;
text-align: center;
white-space: nowrap;
}
.dot {
background-color: rgb(225, 80, 80);
width: 8px;
height: 8px;
border-radius: 4px;
}
/* 有包裹内容,才定位在右上角。所以这个样式是一个单独的class,在slot有内容时才生效 */
.position {
position: absolute;
top: 0px; /* 定位在顶部*/
right: 0px; /* 定位在右边*/
transform: translate(50%, -50%); /* 中心向右上角偏移,保证中心在元素右上角顶点*/
}
.badge-wrap{
/* #ifdef WEB */
/* width: fit-content; */
/* #endif */
align-self: flex-start; /* 默认这样,不然width会变100%。但需要居中时得覆盖重设*/
}
</style>
+74
View File
@@ -0,0 +1,74 @@
{
"id": "uni-badge-view",
"displayName": "uni-badge-view 数字角标",
"version": "1.0.2",
"description": "数字角标(徽章)组件,在元素周围展示消息提醒,一般用于列表、九宫格、按钮等地方。",
"isExt": true,
"extUrl": "https://ext.dcloud.net.cn/plugin?id=27844",
"keywords": [
"badge",
"uni-ui",
"uniui",
"数字角标",
"徽章"
],
"repository": "https://gitcode.com/dcloud/uni-ui-x",
"engines": {
"HBuilderX": "^5.08",
"uni-app-x": "^5.08",
"uni-app": ""
},
"directories": {
"example": "../../temps/example_temps"
},
"dcloudext": {
"sale": {
"regular": {
"price": "0.00"
},
"sourcecode": {
"price": "0.00"
}
},
"contact": {
"qq": ""
},
"declaration": {
"ads": "无",
"data": "无",
"permissions": "无"
},
"type": "component-vue",
"darkmode": "√",
"i18n": "√",
"widescreen": "√",
"npmurl": ""
},
"uni_modules": {
"dependencies": [],
"encrypt": [],
"platforms": {
"client": {
"uni-app-x": {
"web": {
"safari": "√",
"chrome": "√"
},
"app": {
"android": "√",
"ios": "√",
"harmony": "√"
},
"mp": {
"weixin": "√"
}
}
},
"cloud": {
"tcb": "x",
"aliyun": "x",
"alipay": "x"
}
}
}
}
+34
View File
@@ -0,0 +1,34 @@
badge角标,可以用于显示红点、被红圈或其他颜色圈包裹的数字、文字。提醒用户此时有需要注意的新信息。
### 文件路径
项目下有一个 /pages/uni-ui/badge/badge.uvue 页面,里面演示了badge组件的各种用法。
### 设计
#### badge组件有多种用法:
1. 独立显示
如果没有slot内容,视为要显示独立的红点和数字,此时不是角标,就是普通的红点和圆背景数字。
在按钮或列表项的右侧,独立显示数字。也可以显示红点,但这种场景数字更多一些。
2. 作为一个容器组件,包含一个slot,在包裹内容的右上角显示一个角标view。
badge组件包裹一个目标view时,badge组件内部的角标view的中心点位于目标view的右上角顶点。
此时角标view是通过transform位移到右上角的,实际内容已经超出了容器本身。
所以需要注意容器的父级的设置,留出超出容器的尺寸(即top和right边距需要留出角标view的高度和宽度的一半),避免被父级裁剪。
#### badge的text属性的用法:
在badge作为容器组件时,text属性的不同设置,可以代表不同的效果。具体见属性章节。
#### 样式自定义
badge组件的badge-class和badge-style属性,直接作用在角标view上,可以修改角标view的样式。
角标view本质是一个text组件,可以覆盖前景、背景、位置等各种样式。
#### badge组件有如下属性:
- text
类型为 string|null
* 值为"0"时,不显示角标;所以永远不会显示一个为0的角标。此时直接显示slot子内容,不包裹外层,减少层级,提高性能;
* 值为null或""时,代表一个长宽为8px的红色圆点;
* 否则显示为该文字内容的圆角红底圈套文字。
- badge-class
直接作用于角标view的class
- badge-style
直接作用于角标view的style
+4
View File
@@ -0,0 +1,4 @@
## 1.0.1(2026-05-14)
- 支持 content-wrap-class 定义内层容器样式
## 1.0.0(2026-05-07)
- 支持 uni-collapse 组件
@@ -0,0 +1,3 @@
export const UNI_COLLAPSE_REGISTER_CHILD = 'uni-collapse-register-child'
export const UNI_COLLAPSE_UNREGISTER_CHILD = 'uni-collapse-unregister-child'
export const UNI_COLLAPSE_CHILD_TOGGLE = 'uni-collapse-child-toggle'
@@ -0,0 +1,6 @@
export type ItemChildType = {
openCollapse?: (show: boolean) => void;
openOrClose: (show: boolean) => void;
is_open: Ref<boolean>;
elId: string;
};
@@ -0,0 +1,739 @@
<template>
<view>
<view class="uni-collapse-item__title" :class="props.titleWrapClass" @click="openCollapse(!is_open)">
<view class="uni-collapse-item__title-main">
<slot name="title" :open="is_open" :disabled="props.disabled">
<text class="uni-collapse-item__title-text"
:class="[{'open--active': is_open, 'is-disabled': props.disabled}, props.titleClass, is_open ? props.titleOpenClass : '', props.disabled ? props.titleDisabledClass : '']">{{ props.title }}</text>
</slot>
</view>
<view class="down_arrow"
:class="[arrowPlatformClass, arrowStateClass, arrowDisabledClass, props.arrowClass, is_open ? props.arrowOpenClass : '', props.disabled ? props.arrowDisabledClass : '']"></view>
</view>
<view ref="contentWrapRef" class="uni-collapse-item__content"
:class="[transition_enabled ? 'content-transition--active' : '', box_is_open ? 'content-state-open' : 'content-state-close', contentWrapClass]">
<view ref="contentRef" class="uni-collapse-item__content-box"
:class="[transition_enabled ? 'content-transition--active' : '', box_is_open ? 'content-box-open' : 'content-box-close']">
<slot></slot>
</view>
</view>
</view>
</template>
<script lang="ts" setup>
/**
* 折叠面板子项
* @description 折叠面板子项组件
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "5.31"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
*/
import type { ItemChildType } from '../type.ts'
import {
UNI_COLLAPSE_REGISTER_CHILD,
UNI_COLLAPSE_UNREGISTER_CHILD,
UNI_COLLAPSE_CHILD_TOGGLE
} from '../common.ts'
defineOptions({
name: 'UniCollapseItem',
styleIsolation: 'app',
externalClasses: ['title-wrap-class', 'title-class', 'title-open-class', 'title-disabled-class', 'content-wrap-class', 'arrow-class', 'arrow-open-class', 'arrow-disabled-class']
})
type Props = {
/**
* @description 折叠项标题文字
* @default ""
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "5.31"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
*/
title? : string,
/**
* @description 是否默认展开
* @default false
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "5.31"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
*/
open? : boolean,
/**
* @description 是否禁用,禁用后点击标题不会展开或收起
* @default false
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "5.31"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
*/
disabled? : boolean,
/**
* @description 标题行(整行容器)的样式类
* @default ""
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "5.31"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
*/
titleWrapClass? : string.ClassString,
/**
* @description 标题文字的基础样式类
* @default ""
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "5.31"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
*/
titleClass? : string.ClassString,
/**
* @description 展开状态下叠加到标题文字的样式类
* @default ""
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "5.31"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
*/
titleOpenClass? : string.ClassString,
/**
* @description 禁用状态下叠加到标题文字的样式类
* @default ""
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "5.31"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
*/
titleDisabledClass? : string.ClassString,
/**
* @description 内容外层容器的样式类
* @default ""
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "5.31"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
*/
contentWrapClass? : string.ClassString,
/**
* @description 箭头图标的基础样式类
* @default ""
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "5.31"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
*/
arrowClass? : string.ClassString,
/**
* @description 展开状态下叠加到箭头图标的样式类
* @default ""
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "5.31"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
*/
arrowOpenClass? : string.ClassString,
/**
* @description 禁用状态下叠加到箭头图标的样式类
* @default ""
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "5.31"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
*/
arrowDisabledClass? : string.ClassString
}
const props = withDefaults(defineProps<Props>(), {
title: '',
open: false,
disabled: false,
titleWrapClass: '',
titleClass: '',
titleOpenClass: '',
titleDisabledClass: '',
contentWrapClass: '',
arrowClass: '',
arrowOpenClass: '',
arrowDisabledClass: ''
})
const emit = defineEmits<{
change: [open: boolean]
}>()
defineSlots<{
default(props : {}) : any,
title(props : { open : boolean, disabled : boolean }) : any
}>()
let is_open = ref<boolean>(props.open)
let box_is_open = ref<boolean>(props.open)
let transition_enabled = ref<boolean>(false)
let contentWrapRef = ref<UniViewElement | null>(null)
let contentRef = ref<UniViewElement | null>(null)
const elId = ref(`uni_collapse_item_${Math.ceil(Math.random() * 10e5).toString(36)}`)
const contentTransitionDelay = 16
let enableTransitionTimer = 0
let isAndroidArrowRenderFix = false
// #ifdef APP-ANDROID
// Android arrow fix:
// keep transform rotation, but avoid animating border colors for border-drawn arrows.
// Keep split border-* properties here. Do not use border shorthand with var().
isAndroidArrowRenderFix = true
// #endif
const registerChild = inject<((child : ItemChildType) => void) | null>(UNI_COLLAPSE_REGISTER_CHILD, null)
const unregisterChild = inject<((elId : string) => void) | null>(UNI_COLLAPSE_UNREGISTER_CHILD, null)
const collapseToggle = inject<((elId : string) => void) | null>(UNI_COLLAPSE_CHILD_TOGGLE, null)
const arrowPlatformClass = computed(() : string => {
return isAndroidArrowRenderFix ? 'down_arrow--android-fix' : ''
})
const arrowStateClass = computed(() : string => {
if (!is_open.value) {
return ''
}
return isAndroidArrowRenderFix ? 'down_arrow--android-active' : 'down_arrow--active'
})
const arrowDisabledClass = computed(() : string => {
return props.disabled ? 'down_arrow--disabled' : ''
})
function clearEnableTransitionTimer() {
if (enableTransitionTimer != 0) {
clearTimeout(enableTransitionTimer)
enableTransitionTimer = 0
}
}
function openOrClose(open : boolean) {
box_is_open.value = open
}
function applyOpenState(open : boolean, syncSiblings : boolean, triggerEmit : boolean) {
if (is_open.value == open && box_is_open.value == open) {
return
}
if (open && syncSiblings && collapseToggle != null) {
collapseToggle(elId.value)
}
is_open.value = open
openOrClose(open)
if (triggerEmit) {
emit('change', open)
}
}
function openCollapse(open : boolean) {
if (props.disabled) {
return
}
applyOpenState(open, true, true)
}
onMounted(() => {
if (registerChild != null) {
const child : ItemChildType = {
is_open,
elId: elId.value,
openOrClose
}
registerChild(child)
}
if (is_open.value && collapseToggle != null) {
collapseToggle(elId.value)
}
nextTick(() => {
enableTransitionTimer = setTimeout(() => {
enableTransitionTimer = 0
transition_enabled.value = true
}, contentTransitionDelay)
})
})
onUnmounted(() => {
clearEnableTransitionTimer()
if (unregisterChild != null) {
unregisterChild(elId.value)
}
})
watch(() : boolean => props.open, (value : boolean) => {
applyOpenState(value, false, false)
})
defineExpose({
is_open,
openOrClose,
openCollapse
})
</script>
<style>
.uni-collapse-item__title {
flex-direction: row;
align-items: center;
padding: 12px 18px;
background-color: #ffffff;
}
.uni-collapse-item__title-main {
flex: 1;
flex-direction: row;
align-items: center;
}
.uni-collapse-item__title-text {
flex: 1;
font-size: 14px;
font-weight: 400;
color: #333333;
}
.open--active {
opacity: 0.78;
}
.is-disabled {
opacity: 0.45;
}
.down_arrow {
width: 8px;
height: 8px;
margin-left: 12px;
margin-top: -3px;
transform: rotate(45deg);
border-right-width: 1px;
border-right-style: solid;
border-right-color: #999999;
border-bottom-width: 1px;
border-bottom-style: solid;
border-bottom-color: #999999;
transition-property: transform, border-right-color, border-bottom-color;
transition-duration: 150ms;
}
.down_arrow--active {
margin-top: 0px;
transform: rotate(-135deg);
}
.down_arrow--android-fix {
transition-property: transform;
}
.down_arrow--android-active {
margin-top: 0px;
transform: rotate(-135deg);
}
.down_arrow--disabled {
opacity: 0.45;
}
.uni-collapse-item__content {
overflow: hidden;
width: 100%;
max-height: 0px;
background-color: #ffffff;
}
.uni-collapse-item__content-box {
width: 100%;
opacity: 0;
transform: translateY(-8px);
}
.content-state-open {
max-height: 2000px;
}
.content-state-close {
max-height: 0px;
}
.content-box-open {
opacity: 1;
transform: translateY(0px);
}
.content-box-close {
opacity: 0;
transform: translateY(-8px);
}
.content-transition--active {
transition-duration: 180ms;
transition-timing-function: ease;
}
/* 不要恢复旧的“运行时测量高度 + 内联 style.height”方案。
该方案在 HarmonyOS 上会出现默认展开时外层容器仍为 0 高度,导致内容不显示。 */
.uni-collapse-item__content {
transition-property: max-height;
}
.uni-collapse-item__content-box {
transition-property: opacity, transform;
}
</style>
@@ -0,0 +1,129 @@
<template>
<view>
<slot></slot>
</view>
</template>
<script lang="ts" setup>
/**
* 折叠面板
* @description 折叠面板组件
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "5.31"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
*/
import type { ItemChildType } from '../type.ts'
import {
UNI_COLLAPSE_REGISTER_CHILD,
UNI_COLLAPSE_UNREGISTER_CHILD,
UNI_COLLAPSE_CHILD_TOGGLE
} from '../common.ts'
defineOptions({
name: 'UniCollapse'
})
type Props = {
/**
* @description 是否开启手风琴模式,开启后同一时间仅允许一个子项展开
* @default true
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "5.31"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
*/
accordion? : boolean
}
const props = withDefaults(defineProps<Props>(), {
accordion: true
})
let childNodes = [] as ItemChildType[]
function registerChild(child : ItemChildType) {
const existIndex = childNodes.findIndex((item : ItemChildType) : boolean => item.elId == child.elId)
if (existIndex == -1) {
childNodes.push(child)
}
}
function unregisterChild(elId : string) {
childNodes = childNodes.filter((item : ItemChildType) : boolean => item.elId != elId)
}
function toggle(elId : string) {
if (props.accordion && childNodes.length > 0) {
childNodes.forEach((item : ItemChildType) => {
const isOpen = item.is_open.value as boolean
if (isOpen && item.elId != elId) {
item.is_open.value = false
item.openOrClose(false)
}
})
}
}
provide(UNI_COLLAPSE_REGISTER_CHILD, registerChild)
provide(UNI_COLLAPSE_UNREGISTER_CHILD, unregisterChild)
provide(UNI_COLLAPSE_CHILD_TOGGLE, toggle)
</script>
+74
View File
@@ -0,0 +1,74 @@
{
"id": "uni-collapse-x",
"displayName": "uni-collapse 折叠面板",
"version": "1.0.1",
"description": "Collapse 折叠面板组件,可自定义折叠面板的展开和收起。",
"isExt": true,
"extUrl": "https://ext.dcloud.net.cn/plugin?id=27899",
"keywords": [
"uni-ui",
"uniui",
"collapse",
"折叠面板",
"uni-app-x"
],
"engines": {
"HBuilderX": "",
"uni-app": "",
"uni-app-x": "^5.07"
},
"repository": "https://gitcode.com/dcloud/uni-ui-x",
"directories": {
"example": "../../temps/example_temps"
},
"dcloudext": {
"sale": {
"regular": {
"price": "0.00"
},
"sourcecode": {
"price": "0.00"
}
},
"contact": {
"qq": ""
},
"declaration": {
"ads": "无",
"data": "无",
"permissions": "无"
},
"type": "component-vue",
"darkmode": "x",
"i18n": "x",
"widescreen": "x",
"npmurl": ""
},
"uni_modules": {
"encrypt": [],
"dependencies": [],
"platforms": {
"cloud": {
"tcb": "x",
"aliyun": "x",
"alipay": "x"
},
"client": {
"uni-app-x": {
"web": {
"safari": "√",
"chrome": "√"
},
"app": {
"android": "√",
"ios": "√",
"harmony": "√"
},
"mp": {
"weixin": "√"
}
}
}
}
}
}
+96
View File
@@ -0,0 +1,96 @@
折叠面板组件,包含:
- `uni-collapse`:父容器
- `uni-collapse-item`:子面板
一个折叠面板组件可以包含若干uni-collapse-item。每个uni-collapse-item包括顶部标题栏和通过slot传入的内容区。
### 基本用法
```html
<uni-collapse :accordion="true">
<uni-collapse-item title="标题 1" :open="true">
<view><text>内容 1</text></view>
</uni-collapse-item>
<uni-collapse-item title="标题 2">
<view><text>内容 2</text></view>
</uni-collapse-item>
</uni-collapse>
```
uni-collapse-item的标题栏的自定义:
- 标题栏容器可通过 `title-wrap-class` 自定义背景等容器样式。
- 标题栏支持title属性,可通过title-class自定义文字样式。
- 标题展开态可通过 `title-open-class` 自定义激活文字样式。组件默认使用透明度区分状态,便于外部继续覆盖文字颜色。
- 标题禁用态可通过 `title-disabled-class` 自定义禁用文字样式。若同时存在展开态和禁用态,以禁用态为最终状态。
- 内容外层容器可通过 `content-wrap-class` 自定义背景等容器样式。
- 标题栏右边默认带有箭头,可通过arrow-class自定义箭头样式。该箭头为一个view包括2个直角边的边框,然后旋转而成。
- 箭头展开态和禁用态可分别通过 `arrow-open-class`、`arrow-disabled-class` 覆盖。组件默认也使用透明度区分状态,便于外部继续覆盖箭头颜色。
- 如果完全不想要默认包含的title和arrow,也可以传入一个名为title的具名插槽来替换。
### 具名插槽标题
```html
<uni-collapse-item>
<template #title="{ open, disabled }">
<view class="custom-title">
<view class="custom-title-icon"></view>
<text class="custom-title-text">{{ open ? '已展开' : '未展开' }}</text>
</view>
</template>
<view><text>自定义标题内容</text></view>
</uni-collapse-item>
```
### externalClass
```html
<uni-collapse-item
title="自定义样式"
title-wrap-class="my-title-wrap-class"
title-class="my-title-class"
title-open-class="my-title-open-class"
content-wrap-class="my-content-wrap-class"
arrow-class="my-arrow-class"
arrow-open-class="my-arrow-open-class"
>
<view><text>内容区域</text></view>
</uni-collapse-item>
```
### Slots
#### uni-collapse-item
| 插槽名 | 说明 | 插槽参数 |
| --- | --- | --- |
| default | 面板内容 | - |
| title | 自定义标题区域 | `open: boolean`、`disabled: boolean` |
### Events
#### uni-collapse-item
| 事件名 | 说明 | 回调参数 |
| --- | --- | --- |
| change | 面板展开状态变化时触发 | `open: boolean` |
### Expose
#### uni-collapse-item
| 方法名 | 说明 |
| --- | --- |
| openCollapse(open: boolean) | 执行展开/收起,会遵循 `disabled` 和手风琴逻辑 |
| openOrClose(open: boolean) | 直接切换内容显示状态 |
### 暗黑模式适配
建议通过组件 `class` 和 externalClass 组合传入暗色样式:
- 用组件 `class` 统一控制根节点和内容区等外层视觉。
- 用 `title-wrap-class`、`title-class`、`title-open-class`、`content-wrap-class`、`arrow-class`、`arrow-open-class` 等 externalClass 覆盖标题区、内容外层和箭头样式。
- 默认展开态和禁用态主要通过透明度表达,减少颜色覆盖时的交叉冲突。
### 注意
- 本组件在Android上需HBuilderX 5.09+。在低版本上箭头变颜色+旋转时,在Android上会消失
+6
View File
@@ -0,0 +1,6 @@
## 1.0.2 (2026-10-08)
- 修复安卓蒸汽模式会出现内容不显示的问题
## 1.0.1(2026-08-28)
- 修复 web 运行报错的问题
## 1.0.0(2026-08-28)
- 新增 uni-combox 组合框组件。
@@ -0,0 +1,660 @@
<template>
<view :id="anchorId" class="uni-combox">
<view
class="uni-combox__field"
@click="onFieldClick"
<!-- #ifdef WEB -->
tabindex="0"
@keydown="onInputKeydown"
<!-- #endif -->
>
<view v-if="inputMode" class="uni-combox__input-slot">
<slot
name="input"
:value="currentValue"
:input="onInput"
:focus="onInputFocus"
></slot>
</view>
<text
v-else
class="uni-combox__result"
:class="[resultTextClass, currentValue == '' ? 'uni-combox__result--placeholder' : '']"
max-lines="1"
>{{ displayText }}</text>
<view class="uni-combox__arrow-wrap" @click.stop="toggleOptionsByArrow">
<view class="uni-combox__arrow" :class="[arrowClass, opened ? 'uni-combox__arrow--open' : '']"></view>
</view>
</view>
<!-- #ifdef WEB || (APP-ANDROID && !VUE3-VAPOR) -->
<teleport v-if="opened && popupReady" to="body">
<!-- #endif -->
<!-- #ifndef WEB || (APP-ANDROID && !VUE3-VAPOR) -->
<teleport v-if="opened && popupReady" to="page">
<!-- #endif -->
<view class="uni-combox__mask" @click="closeOptions">
<view
class="uni-combox__options"
:class="optionsViewClass"
:style="optionsStyle"
@click.stop="noop"
>
<scroll-view
direction="vertical"
class="uni-combox__options-scroll"
:scroll-into-view="scrollIntoViewId"
>
<view class="uni-combox__options-content">
<view
v-if="visibleOptions.length == 0"
class="uni-combox__option-row"
>
<text
class="uni-combox__option uni-combox__option--empty"
:class="optionTextClass"
max-lines="1"
>无匹配项</text>
</view>
<view
v-for="(item, index) in visibleOptions"
:id="getOptionId(index)"
:key="item + '_' + index.toString()"
class="uni-combox__option-row"
:class="index == highlightedIndex ? 'uni-combox__option-row--highlight' : ''"
@click.stop="selectOption(index)"
>
<text
class="uni-combox__option"
:class="[optionTextClass, index == highlightedIndex ? optionTextHighlightClass : '']"
max-lines="1"
>{{ item }}</text>
</view>
</view>
</scroll-view>
</view>
</view>
</teleport>
</view>
</template>
<script setup lang="ts">
/**
* Combox 组合框
* @description 默认是单选选择框,通过 input 具名插槽切换为可输入、可选择的组合框。
* @uniPlatform {
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "5.31"
* }
* }
* }
* @property {Array<String>} options 候选字符串数组
* @property {String} modelValue 当前值,支持 v-model
* @property {String} placeholder 占位文字
* @property {String} arrowClass 右侧箭头自定义样式类
* @property {String} optionsViewClass 候选浮层外层自定义样式类
* @property {String} optionTextClass 候选项文字自定义样式类
* @property {String} optionTextHighlightClass 候选项高亮自定义样式类
* @property {String} resultTextClass 单选模式结果文字自定义样式类
* @event {Function} update:modelValue 值更新时触发
* @event {Function} selectchange 从候选项选中时触发
*/
defineOptions({
externalClasses: [
'arrow-class',
'options-view-class',
'option-text-class',
'option-text-highlight-class',
'result-text-class'
]
})
type Props = {
options? : string[],
modelValue? : string,
placeholder? : string,
arrowClass? : string.ClassString,
optionsViewClass? : string.ClassString,
optionTextClass? : string.ClassString,
optionTextHighlightClass? : string.ClassString,
resultTextClass? : string.ClassString
}
const props = withDefaults(defineProps<Props>(), {
options: () : string[] => [] as string[],
modelValue: '',
placeholder: '',
arrowClass: '',
optionsViewClass: '',
optionTextClass: '',
optionTextHighlightClass: '',
resultTextClass: ''
})
const emit = defineEmits<{
'update:modelValue': [value : string],
selectchange: [value : string]
}>()
defineSlots<{
input(props : {
value : string,
input : (event : UniInputEvent) => void,
focus : (event : UniInputFocusEvent) => void
}) : any
}>()
const slots = useSlots()
const anchorId = `uni_combox_x_${Math.ceil(Math.random() * 10e8).toString(36)}`
const optionIdPrefix = `uni_combox_x_option_${Math.ceil(Math.random() * 10e8).toString(36)}_`
const optionHeight = 36
const defaultMaxOptionsHeight = 200
const popupVerticalPadding = 8
const viewportGap = 8
const popupGap = 4
function getOptionId(index : number) : string {
return optionIdPrefix + index.toString()
}
const currentValue = ref<string>(props.modelValue)
const opened = ref<boolean>(false)
const popupReady = ref<boolean>(false)
const filterEnabled = ref<boolean>(false)
const highlightedIndex = ref<number>(-1)
const popupLeft = ref<number>(0)
const popupTop = ref<number>(0)
const popupWidth = ref<number>(0)
const popupHeight = ref<number>(optionHeight + popupVerticalPadding)
const keyboardHeight = ref<number>(0)
let positionRequestId = 0
let isUnmounted = false
const inputMode = computed(() : boolean => {
return slots['input'] != null
})
const displayText = computed(() : string => {
if (currentValue.value == '') {
return props.placeholder
}
return currentValue.value
})
const visibleOptions = computed(() : string[] => {
if (!filterEnabled.value || currentValue.value == '') {
return props.options
}
return props.options.filter((item : string) : boolean => {
return item.indexOf(currentValue.value) >= 0
})
})
const optionsStyle = computed(() : string => {
return `left: ${popupLeft.value}px; top: ${popupTop.value}px; width: ${popupWidth.value}px; height: ${popupHeight.value}px;`
})
const scrollIntoViewId = ref<string>('')
function findMatchedIndex(list : string[], keyword : string) : number {
if (keyword == '') {
return -1
}
const exactIndex = list.indexOf(keyword)
if (exactIndex >= 0) {
return exactIndex
}
return list.findIndex((item : string) : boolean => {
return item.indexOf(keyword) == 0
})
}
function syncHighlight() {
const list = visibleOptions.value
if (list.length == 0) {
highlightedIndex.value = -1
return
}
const matchedIndex = findMatchedIndex(list, currentValue.value)
highlightedIndex.value = matchedIndex
}
function refreshScrollTarget() {
const targetId = highlightedIndex.value >= 0 ? getOptionId(highlightedIndex.value) : ''
scrollIntoViewId.value = ''
if (targetId == '') {
return
}
nextTick(() => {
if (opened.value) {
scrollIntoViewId.value = targetId
}
})
}
function emitValue(value : string) {
currentValue.value = value
emit('update:modelValue', value)
}
function noop() {
}
function applyPopupRect(rect : DOMRect) {
const rectTop = rect.top != null ? rect.top : 0
const rectLeft = rect.left != null ? rect.left : 0
let rectWidth = rect.width != null ? rect.width : 0
const rectHeight = rect.height != null ? rect.height : 0
const rectBottom = rect.bottom != null ? rect.bottom : rectTop + rectHeight
if (rectWidth <= 0) {
rectWidth = uni.getWindowInfo().windowWidth - 32
}
const windowInfo = uni.getWindowInfo()
const windowHeight = Math.max(0, windowInfo.windowHeight - keyboardHeight.value)
const optionCount = Math.max(1, visibleOptions.value.length)
const expectedHeight = Math.min(defaultMaxOptionsHeight, optionCount * optionHeight) + popupVerticalPadding
const minOptionsHeight = optionHeight + popupVerticalPadding
const belowSpace = windowHeight - rectBottom - viewportGap
const aboveSpace = rectTop - viewportGap
let height = expectedHeight
let top = rectBottom + popupGap
if (belowSpace < expectedHeight && aboveSpace > belowSpace) {
height = Math.max(minOptionsHeight, Math.min(expectedHeight, aboveSpace - popupGap))
top = rectTop - popupGap - height
} else {
height = Math.max(minOptionsHeight, Math.min(expectedHeight, belowSpace - popupGap))
}
popupLeft.value = rectLeft
popupTop.value = top
popupWidth.value = rectWidth
popupHeight.value = height
}
function updatePosition() {
const element = uni.getElementById(anchorId)
if (element == null) {
return
}
const rectPromise = element.getBoundingClientRectAsync()
if (rectPromise == null) {
return
}
rectPromise.then((rect : DOMRect) => {
if (isUnmounted || !opened.value) {
return
}
applyPopupRect(rect)
})
}
function updatePositionThenOpen() {
const requestId = positionRequestId + 1
positionRequestId = requestId
const element = uni.getElementById(anchorId)
if (element == null) {
if (isUnmounted || requestId != positionRequestId) {
return
}
popupReady.value = true
opened.value = true
refreshScrollTarget()
return
}
const rectPromise = element.getBoundingClientRectAsync()
if (rectPromise == null) {
if (isUnmounted || requestId != positionRequestId) {
return
}
popupReady.value = true
opened.value = true
refreshScrollTarget()
return
}
rectPromise.then((rect : DOMRect) => {
if (isUnmounted || requestId != positionRequestId) {
return
}
applyPopupRect(rect)
popupReady.value = true
opened.value = true
refreshScrollTarget()
})
}
function openOptionsWithMode(filter : boolean) {
filterEnabled.value = filter
syncHighlight()
popupReady.value = false
updatePositionThenOpen()
}
function openOptions() {
openOptionsWithMode(false)
}
function closeOptions() {
positionRequestId += 1
opened.value = false
popupReady.value = false
}
function toggleOptionsByArrow() {
if (opened.value) {
closeOptions()
return
}
openOptions()
}
function onFieldClick() {
if (inputMode.value) {
openOptionsWithMode(true)
return
}
openOptions()
}
function onInput(event : UniInputEvent) {
const value = event.detail.value
emitValue(value)
openOptionsWithMode(true)
}
function onInputFocus(_event : UniInputFocusEvent) {
openOptionsWithMode(true)
}
function onInputBlur() {
}
function onKeyboardHeightChange(res : OnKeyboardHeightChangeCallbackResult) {
if (isUnmounted) {
return
}
keyboardHeight.value = Math.max(0, res.height)
if (opened.value) {
nextTick(() => {
if (!isUnmounted && opened.value) {
updatePosition()
}
})
}
}
let keyboardListenerId = 0
onMounted(() => {
keyboardListenerId = uni.onKeyboardHeightChange(onKeyboardHeightChange)
})
onBeforeUnmount(() => {
isUnmounted = true
positionRequestId += 1
opened.value = false
popupReady.value = false
})
onUnmounted(() => {
if (keyboardListenerId != 0) {
uni.offKeyboardHeightChange(keyboardListenerId)
keyboardListenerId = 0
}
})
// #ifdef WEB
function onInputKeydown(event : KeyboardEvent) {
const key = event.key as string
if (key == 'Escape') {
event.preventDefault()
closeOptions()
return
}
if (key == 'ArrowDown') {
event.preventDefault()
if (!opened.value) {
openOptions()
return
}
if (visibleOptions.value.length == 0) {
return
}
highlightedIndex.value = Math.min(visibleOptions.value.length - 1, highlightedIndex.value + 1)
refreshScrollTarget()
return
}
if (key == 'ArrowUp') {
event.preventDefault()
if (!opened.value) {
openOptions()
return
}
if (visibleOptions.value.length == 0) {
return
}
highlightedIndex.value = Math.max(0, highlightedIndex.value - 1)
refreshScrollTarget()
return
}
if (key == 'Enter' && opened.value && highlightedIndex.value >= 0) {
event.preventDefault()
selectOption(highlightedIndex.value)
}
}
// #endif
function selectOption(index : number) {
if (index < 0 || index >= visibleOptions.value.length) {
return
}
const value = visibleOptions.value[index]
emitValue(value)
emit('selectchange', value)
closeOptions()
}
function setInputValue(value : string) {
emitValue(value)
openOptionsWithMode(true)
}
defineExpose({
setInputValue,
openOptions,
closeOptions
})
watch(() : string => props.modelValue, (value : string) => {
currentValue.value = value
if (opened.value) {
syncHighlight()
nextTick(() => {
updatePosition()
refreshScrollTarget()
})
}
})
watch(() : string[] => props.options, () => {
if (opened.value) {
syncHighlight()
nextTick(() => {
updatePosition()
refreshScrollTarget()
})
}
})
onBackPress(() : boolean => {
if (opened.value) {
closeOptions()
return true
}
return false
})
</script>
<style>
.uni-combox {
width: 100%;
}
.uni-combox__field {
width: 100%;
box-sizing: border-box;
height: 36px;
border: 1px solid #DCDFE6;
border-radius: 4px;
background-color: #ffffff;
flex-direction: row;
align-items: center;
padding-left: 10px;
}
.uni-combox__input-slot {
flex: 1;
height: 22px;
flex-direction: row;
align-items: center;
}
.uni-combox__result {
flex: 1;
height: 22px;
line-height: 22px;
padding-right: 10px;
font-size: 14px;
color: #333333;
text-overflow: ellipsis;
}
.uni-combox__result--placeholder {
color: #999999;
}
.uni-combox__arrow-wrap {
width: 28px;
height: 36px;
align-items: center;
justify-content: center;
}
.uni-combox__arrow {
width: 7px;
height: 7px;
border-right: 1px solid #999999;
border-bottom: 1px solid #999999;
transform: rotate(45deg);
}
.uni-combox__arrow--open {
transform: rotate(225deg);
}
.uni-combox__mask {
position: fixed;
left: 0;
top: 0;
right: 0;
bottom: 0;
z-index: 999;
background-color: rgba(0, 0, 0, 0);
}
.uni-combox__options {
position: fixed;
box-sizing: border-box;
background-color: #ffffff;
border: 1px solid #EBEEF5;
border-radius: 6px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
z-index: 1000;
overflow: hidden;
}
.uni-combox__options-scroll {
width: 100%;
height: 100%;
box-sizing: border-box;
}
.uni-combox__options-content {
width: 100%;
box-sizing: border-box;
padding-top: 4px;
padding-bottom: 4px;
}
.uni-combox__option-row {
width: 100%;
height: 36px;
flex-direction: row;
align-items: center;
}
.uni-combox__option-row--highlight {
background-color: #f9f9f9;
}
.uni-combox__option {
flex: 1;
height: 36px;
line-height: 36px;
padding-left: 10px;
padding-right: 10px;
font-size: 14px;
color: #333333;
text-overflow: ellipsis;
}
.uni-combox__option--empty {
color: #999999;
}
@media (prefers-color-scheme: dark) {
.uni-combox__field {
border-color: rgba(255, 255, 255, 0.2);
background-color: #2d2d2d;
}
.uni-combox__result {
color: #ffffff;
}
.uni-combox__option {
color: #ffffff;
}
.uni-combox__result--placeholder {
color: #8f8f8f;
}
.uni-combox__option--empty {
color: #8f8f8f;
}
.uni-combox__arrow {
border-right-color: #8f8f8f;
border-bottom-color: #8f8f8f;
}
.uni-combox__options {
background-color: #2d2d2d;
border-color: rgba(255, 255, 255, 0.2);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.35);
}
.uni-combox__option-row--highlight {
background-color: #3b3b3b;
}
}
</style>
+91
View File
@@ -0,0 +1,91 @@
{
"id": "uni-combox-x",
"displayName": "uni-combox 组合框",
"version": "1.0.2",
"description": "Combox 组合框组件,支持默认单选和通过 input 插槽启用可输可选模式。",
"isExt": true,
"extUrl": "https://ext.dcloud.net.cn/plugin?id=29441",
"keywords": [
"组合框",
"combox",
"uni-app-x"
],
"repository": "https://gitcode.com/dcloud/uni-ui-x",
"engines": {
"HBuilderX": "",
"uni-app": "",
"uni-app-x": "^5.31"
},
"directories": {
"example": "../../temps/example_temps"
},
"dcloudext": {
"sale": {
"regular": {
"price": "0.00"
},
"sourcecode": {
"price": "0.00"
}
},
"contact": {
"qq": ""
},
"declaration": {
"ads": "无",
"data": "无",
"permissions": "无"
},
"type": "component-vue",
"darkmode": "√",
"i18n": "x",
"widescreen": "√",
"npmurl": "",
"vapor": "√"
},
"uni_modules": {
"dependencies": [],
"encrypt": [],
"platforms": {
"cloud": {
"tcb": "x",
"aliyun": "x",
"alipay": "x"
},
"client": {
"uni-app-x": {
"web": {
"safari": {
"extVersion": "1.0.2",
"minVersion": ""
},
"chrome": {
"extVersion": "1.0.2",
"minVersion": ""
}
},
"app": {
"android": {
"extVersion": "1.0.2",
"minVersion": ""
},
"ios": {
"extVersion": "1.0.2",
"minVersion": ""
},
"harmony": {
"extVersion": "1.0.2",
"minVersion": ""
}
},
"mp": {
"weixin": {
"extVersion": "1.0.2",
"minVersion": ""
}
}
}
}
}
}
}
+122
View File
@@ -0,0 +1,122 @@
uni-combox 组合框组件。
默认为单选模式,类似 select 组件,只能选择不能输入。通过具名插槽 `input` 传入 input 组件后,切换为可输入、可选择的组合模式。
### 基本用法
```html
<uni-combox :options="options" placeholder="请选择" v-model="value" />
```
### options
```html
<uni-combox :options="gradeOptions" placeholder="请选择年级" v-model="grade" />
```
### modelValue / v-model
```html
<uni-combox :options="cityOptions" v-model="city" placeholder="请选择城市" />
```
### placeholder
```html
<uni-combox :options="cityOptions" placeholder="这里展示 placeholder" v-model="value" />
```
### 组合模式
```html
<uni-combox :options="options" v-model="city">
<template #input="{ value, input }">
<input
:value="value"
placeholder="选择或输入城市"
@input="input"
/>
</template>
</uni-combox>
```
输入时会打开候选浮层,并按 startsWith 规则过滤候选项。点击右侧箭头打开时展示完整候选列表,并高亮当前输入匹配项。
### 自定义样式
```html
<uni-combox
:options="options"
v-model="value"
arrow-class="demo-arrow"
options-view-class="demo-options-view"
option-text-class="demo-option-text"
option-text-highlight-class="demo-option-highlight"
result-text-class="demo-result-text"
/>
```
也可以单独传入任意一个样式类属性。
```html
<uni-combox :options="options" v-model="value" arrow-class="demo-arrow" />
<uni-combox :options="options" v-model="value" options-view-class="demo-options-view" />
<uni-combox :options="options" v-model="value" option-text-class="demo-option-text" />
<uni-combox :options="options" v-model="value" option-text-highlight-class="demo-option-highlight" />
<uni-combox :options="options" v-model="value" result-text-class="demo-result-text" />
```
### 事件
```html
<uni-combox
:options="options"
v-model="value"
@update:modelValue="onUpdateModelValue"
@selectchange="onSelectChange"
/>
```
### 浮层
候选浮层通过 `teleport` 渲染到页面层,不受组件自身尺寸裁剪。下方空间不足且上方空间更充足时,会自动向上展开。默认最大高度为 200px。
候选浮层内部使用 `scroll-view`。
### External Classes
| 类名 | 说明 |
| --- | --- |
| arrow-class | 右侧箭头样式 |
| options-view-class | 候选浮层外层样式 |
| option-text-class | 候选项文字样式 |
| option-text-highlight-class | 候选项高亮样式 |
| result-text-class | 单选模式结果文字样式 |
### Props
| 属性名 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| options | Array\<String\> | [] | 候选字符串数组 |
| modelValue | String | '' | 当前值,支持 v-model |
| placeholder | String | '' | 占位文字 |
| arrowClass | String | '' | 右侧箭头自定义样式类 |
| optionsViewClass | String | '' | 候选浮层外层自定义样式类 |
| optionTextClass | String | '' | 候选项文字自定义样式类 |
| optionTextHighlightClass | String | '' | 候选项高亮自定义样式类 |
| resultTextClass | String | '' | 单选模式结果文字自定义样式类 |
### Events
| 事件名 | 说明 | 回调参数 |
| --- | --- | --- |
| update:modelValue | 值更新时触发 | value: string |
| selectchange | 从候选项选中时触发 | value: string |
### Expose
| 方法名 | 说明 |
| --- | --- |
| setInputValue(value: string) | 设置当前值并打开过滤候选 |
| openOptions() | 打开候选浮层 |
| closeOptions() | 关闭候选浮层 |
+2
View File
@@ -0,0 +1,2 @@
## 1.0.0(2026-04-30)
- 支持 uni-drag-cell 组件
File diff suppressed because it is too large Load Diff
+73
View File
@@ -0,0 +1,73 @@
{
"id": "uni-drag-cell",
"displayName": "uni-drag-cell",
"version": "1.0.0",
"description": "可拖拽排序组件",
"isExt": true,
"extUrl": "https://ext.dcloud.net.cn/plugin?id=27845",
"keywords": [
"uni-drag-cell",
"uni-app-x",
"uni-app x",
"组件"
],
"repository": "https://gitcode.com/dcloud/uni-ui-x",
"engines": {
"HBuilderX": "^5.08",
"uni-app-x": "^5.08",
"uni-app": ""
},
"directories": {
"example": "../../temps/example_temps"
},
"dcloudext": {
"type": "component-vue",
"sale": {
"regular": {
"price": "0.00"
},
"sourcecode": {
"price": "0.00"
}
},
"contact": {
"qq": ""
},
"declaration": {
"ads": "无",
"data": "无",
"permissions": "无"
},
"darkmode": "x",
"i18n": "x",
"widescreen": "x",
"npmurl": ""
},
"uni_modules": {
"dependencies": [],
"encrypt": [],
"platforms": {
"cloud": {
"tcb": "x",
"aliyun": "x",
"alipay": "x"
},
"client": {
"uni-app-x": {
"web": {
"safari": "√",
"chrome": "√"
},
"app": {
"android": "√",
"ios": "√",
"harmony": "√"
},
"mp": {
"weixin": "√"
}
}
}
}
}
}
+331
View File
@@ -0,0 +1,331 @@
`uni-drag-cell` 是一个基于 `uni-app x + uvue + UTS` 实现的可拖拽排序组件。
它适合这些场景:
- 标签排序
- 宫格排序
- 单列列表排序
- 带删除能力的拖拽
- 尾部追加“+”入口
- 手柄拖拽排序
- 九宫格图片选择和排序
- 支付方式排序
组件通过 v-model 绑定了一个数组,通过插槽渲染绑定数组的内容,通过拖拽调整数组项的顺序。
组件还提供了show-close属性,显示底部删除条,拖到删除条即可删除。
组件还提供了show-append属性,显示尾部的添加插槽。该插槽不参与拖拽排序,始终在最后一个。(添加数组项的逻辑需自行实现)
通过这些能力,本组件具备对数组数据排序、添加、删除的能力。
但本组件不适合超长虚拟列表。不具备拖动到顶部或底部后自动滚动列表的功能。
### 设计目标
这个组件的设计不是“拖一下就立刻改数组”,而是分成两个阶段:
1. **拖拽预览阶段**
- 拖动过程中,通过 `transform` 动态调整视觉顺序
- 其他 item 会根据碰撞结果做位移动画
- 此时外部 `v-model` 还不会被正式改写
2. **拖拽提交阶段**
- 松手后,才正式修改内部数组顺序
- 然后通过 `update:modelValue` 和 `change` 把最终结果抛给外部
这样做的好处是:
- 拖动过程更平滑
- 外部响应式更新次数更少
- 更容易支持删除条、追加入口、手柄拖拽等扩展能力
### 交互模式
组件目前支持两种进入拖拽态的方式:长按拖拽和手柄拖拽。
#### 1. 长按拖拽
默认模式。
- 长按某个 item 后进入拖拽态
- 当前长按触发时间为 `350ms`
- 进入拖拽态后,item 会跟随手指/鼠标移动,并且会scale放大1.12倍。
- 拖动过程中持续做碰撞检测,并实时预览排序结果
补充:
- 如果按下后移动距离超过组件内部阈值,会取消本次长按判定,避免页面滚动时误触发拖拽
适合:
- 标签排序
- 宫格排序
- 图片排序
#### 2. 手柄拖拽
当 `handleMode` 为 `true`,且同时提供 `handle` 插槽时,会启用手柄模式。
- 不再需要长按
- 只有拖动手柄才会开始排序
- 内容区本身可以继续点击、滚动、预览
适合:
- 设置项排序
- 表单项排序
- 内容区本身还有点击行为的列表
### 插槽
| 插槽名 | 插槽参数 | 说明 |
| --- | --- | --- |
| `default` | `{ item, index }` | 默认内容插槽,用于渲染每一个可排序项。组件不限制 item 的视觉形式,可以自由渲染标签、卡片、宫格、图片等内容。 |
| `append` | - | 列表末尾的固定入口。不参与拖拽排序,不会写入 `v-model`,不会被删除,适合放“新增按钮”“上传入口”“更多入口”等内容。 |
| `handle` | `{ item, index }` | 拖拽手柄插槽,仅在 `handleMode` 开启时生效。手柄由组件放在每个 item 的右上角,建议给内容区预留右侧空间,避免手柄遮挡正文。 |
### 事件
| 事件名 | 回调参数 | 触发时机 | 说明 |
| --- | --- | --- | --- |
| `change` | 最新数组 | 排序完成后、删除完成后 | 最终结果事件。拖动中的视觉预览不会频繁触发 `change`。 |
### 使用方式
#### 1. 基础标签排序
```vue
<uni-drag-cell v-model="tagList">
<template #default="{ item, index }">
<view class="tag-item" @click="onClickTag(item, index)">
<text>{{ item }}</text>
</view>
</template>
</uni-drag-cell>
```
适合:
- 标签拖拽
- 轻量横向流式布局
说明:
- 默认使用长按触发拖拽
- 当前长按触发时间为 `350ms`
#### 2. 宫格排序
```vue
<uni-drag-cell v-model="gridList" :column="3">
<template #default="{ item }">
<view class="grid-item">
<text>{{ item.name }}</text>
</view>
</template>
</uni-drag-cell>
```
适合:
- 应用图标
- 功能入口宫格
- 九宫格类布局
#### 3. 带删除条的拖拽
```vue
<uni-drag-cell v-model="list" :show-close="true">
<template #default="{ item }">
<view class="cell-item">
<text>{{ item }}</text>
</view>
</template>
</uni-drag-cell>
```
交互:
- 长按进入拖拽
- 拖到屏幕底部删除条
- 删除条高亮后松手即删除
#### 4. 尾部追加一个固定入口
```vue
<uni-drag-cell v-model="tagList" :show-append="true">
<template #default="{ item }">
<view class="tag-item">
<text>{{ item }}</text>
</view>
</template>
<template #append>
<view class="tag-add" @click="addTag">
<text>+</text>
</view>
</template>
</uni-drag-cell>
```
说明:
- `append` 是通用设计,不局限于图片场景
- 拖拽排序时,`append` 不参与位置计算
#### 5. 九宫格图片选择
```vue
<uni-drag-cell
v-model="imageList"
:column="3"
:show-close="true"
:show-append="imageList.length < 9">
<template #default="{ item, index }">
<view class="image-item" @click="previewImage(index)">
<image :src="item" mode="aspectFill"></image>
</view>
</template>
<template #append>
<view class="image-add" @click="chooseImages">
<text>+</text>
</view>
</template>
</uni-drag-cell>
```
设计说明:
- 图片数组本身就是 `v-model`
- `append` 用来放“添加图片”入口
- 当达到上限时,把 `showAppend` 设为 `false`
#### 6. 手柄拖拽排序
```vue
<uni-drag-cell v-model="list" :column="1" :handle-mode="true">
<template #default="{ item }">
<view class="cell-item">
<text>{{ item }}</text>
</view>
</template>
<template #handle>
<view class="cell-handle">
<text>≡</text>
</view>
</template>
</uni-drag-cell>
```
说明:
- 手柄模式下,不需要长按
- 当前实现中,手柄模式不会再把整个 item 放大
- 这样可以避免单列场景下被父容器裁剪
### 组件内部设计说明
#### 1. 排序预览不是直接改数组
拖动过程中,组件内部维护的是“预览顺序”。
实现方式:
- 记录每个 item 的稳定 id
- 根据碰撞结果生成一份预览顺序
- 通过 `transform` 让其他项移动到目标位置
- 松手后再正式更新数组和 id 顺序
这样可以避免:
- 拖动中频繁改外部数组
- 频繁触发父组件重渲染
- item 身份丢失导致动画错乱
#### 2. 删除判定基于拖动内容区域
删除命中不是简单按手指坐标判断,而是优先按“拖动内容的真实边界”和“删除条边界”是否接触来判断。
这样做是为了避免:
- item 外层有 padding 时提前触发删除
- 不同高度 item 在删除区表现不一致
- web 与 app 坐标系不一致时出现明显偏移
#### 3. 追加项与删除条互不干扰
`append` 项的设计目标是“固定入口”,因此:
- 不参与碰撞排序
- 不参与删除
- 不写入绑定数组
#### 4. 手柄模式与长按模式共用同一套拖拽链路
也就是说:
- 排序逻辑相同
- 删除逻辑相同
- 只是“进入拖拽态”的入口不同
这样可以减少两套逻辑分叉导致的维护成本。
### 性能注意
拖拽时耗费性能的点主要有两个:
#### 1. 拖动中的碰撞检测
每次 move 都会更新:
- 当前拖动偏移
- 删除条命中状态
- 目标悬停位置
这是拖拽组件的必要开销,当前实现已经把重测布局的频率控制在较低水平,只在必要时重新测量。
#### 2. 删除条命中时的实时矩形读取
当启用 `showClose` 时,拖动中会优先读取当前拖动内容的实时矩形,以保证删除命中准确。
这部分是“精度优先”的设计,开销可接受,但不建议把它用于超大数量、超复杂 item 的高频拖拽场景。
### 使用注意事项
#### 1. 建议 item 尺寸稳定
尤其是宫格模式下,建议每个 item 的宽高尽量稳定。
否则可能带来:
- 拖动命中感不一致
- 排序预览观感不稳定
#### 2. 手柄模式下给内容区预留右侧空间
因为手柄是覆盖在 item 右上角的。
建议:
- 内容区右侧留白
- 或者把手柄区域设计成视觉上独立的一块
#### 3. `append` 只是入口,不是数据项
这点非常重要。
不要指望:
- `append` 参与排序
- `append` 进入 `v-model`
- `append` 被删除
如果你需要“真正的数据项”,请直接写进 `modelValue`。
#### 4. 外部不要在拖拽过程中频繁整体替换列表
建议:
- 由组件在松手后统一回传结果
- 父组件收到结果后再更新自己的数据源
不建议:
- 拖动过程中再由父组件持续重建整个数组
#### 5. 不建议用于超大列表或虚拟列表场景
这个组件当前更适合:
- 小中型标签列表
- 宫格
- 表单排序
- 九宫格图片选择
如果是超长列表、虚拟滚动列表,应该使用专门为大数据量设计的方案。
### 平台说明
#### Web
- 支持 pointer / touch 交互
#### Android
- 支持触摸拖拽
+2
View File
@@ -0,0 +1,2 @@
## 1.0.0(2026-04-30)
- 支持 uni-fab-button 组件
@@ -0,0 +1,152 @@
<template>
<view style="align-items: center; justify-content: center;" class="fab" @click="handleClick">
<view flatten class="plus" :class="plusClass" style="position: absolute;"></view>
<view flatten class="plus" :class="plusClass" style="position: absolute;transform: rotate(90deg);"></view>
</view>
</template>
<script lang="ts" setup>
/**
* 悬浮加号按钮
* @description 悬浮加号按钮组件
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "5.31"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
*/
defineOptions({
externalClasses: ['plus-class']
})
const props = defineProps({
/**
* @description 加号图标的自定义样式类,用于调整加号颜色、尺寸等样式
* @default ""
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "5.31"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
*/
plusClass: { type: String, default: "" }
})
const emit = defineEmits<{
/**
* @description 点击事件,参数为事件对象,类型为 UniPointerEvent
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "5.31"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
*/
click: [event: UniPointerEvent]
}>()
const handleClick = (event: UniPointerEvent) => {
emit('click', event)
}
</script>
<style>
.fab {
position: relative;
width: 44px;
height: 44px;
background-color: #007AFF;
border-radius: 50%;
box-shadow: 0 1px 5px 2px rgba(0, 0, 0, .3);
/* #ifdef WEB */
cursor: pointer;
/* #endif */
}
.plus {
background-color: white;
border-radius: 2px; /* 给加号边缘一点圆角 */
width: 40%;
height: 2px;
}
</style>
+73
View File
@@ -0,0 +1,73 @@
{
"id": "uni-fab-button",
"displayName": "uni-fab-button",
"version": "1.0.0",
"description": "uni-fab-button",
"isExt": true,
"extUrl": "https://ext.dcloud.net.cn/plugin?id=27846",
"keywords": [
"uni-fab-button",
"uni-app-x",
"ui",
"组件"
],
"repository": "https://gitcode.com/dcloud/uni-ui-x",
"engines": {
"HBuilderX": "^5.07",
"uni-app-x": "^5.07",
"uni-app": ""
},
"directories": {
"example": "../../temps/example_temps"
},
"dcloudext": {
"type": "component-vue",
"sale": {
"regular": {
"price": "0.00"
},
"sourcecode": {
"price": "0.00"
}
},
"contact": {
"qq": ""
},
"declaration": {
"ads": "无",
"data": "无",
"permissions": "无"
},
"darkmode": "x",
"i18n": "x",
"widescreen": "x",
"npmurl": ""
},
"uni_modules": {
"dependencies": [],
"encrypt": [],
"platforms": {
"cloud": {
"tcb": "x",
"aliyun": "x",
"alipay": "x"
},
"client": {
"uni-app-x": {
"web": {
"safari": "√",
"chrome": "√"
},
"app": {
"android": "√",
"ios": "√",
"harmony": "√"
},
"mp": {
"weixin": "√"
}
}
}
}
}
}
+14
View File
@@ -0,0 +1,14 @@
悬浮加号按钮
本组件常用于页面中右下角悬浮的、里面有一个加号的圆形按钮。
组件的根view默认是一个圆形,蓝色背景(#007AFF),并带有阴影。使用组件时注意组件的父容器要留出阴影的空间,避免被裁剪。
组件的二级view是2个view交叉组成的加号。其中垂直的view是通过transform变换方向实现的。
组件的属性为 plus-class,它是一个externalClass,用于外部修改组件内部的、组成加号的2个view的样式。
组件中加号的默认样式为背景白色、粗细为2px、尺寸为40%。
### 注意
- 鸿蒙平台按下fab-button不缩写的问题,需升级到HBuilderX 5.09+解决
+4
View File
@@ -0,0 +1,4 @@
## 1.0.0 (2026-10-16)
- 新增 uni-app x 和蒸汽模式支持
- 新增组合式 API 实现
- 调整 支持直接使用 `class` 或 `style` 自定义颜色、字号和字体
@@ -0,0 +1,169 @@
/**
* uni-icons 图标名称与 Unicode 字符映射表
* @description 独立映射表,可在其它组件中直接 import 复用
*/
export const uniIconsMap = {
'arrow-down': '\ue6be',
'arrow-left': '\ue6bc',
'arrow-right': '\ue6bb',
'arrow-up': '\ue6bd',
'auth': '\ue6ab',
'auth-filled': '\ue6cc',
'back': '\ue6b9',
'bars': '\ue627',
'calendar': '\ue6a0',
'calendar-filled': '\ue6c0',
'camera': '\ue65a',
'camera-filled': '\ue658',
'cart': '\ue631',
'cart-filled': '\ue6d0',
'chat': '\ue65d',
'chat-filled': '\ue659',
'chatboxes': '\ue696',
'chatboxes-filled': '\ue692',
'chatbubble': '\ue697',
'chatbubble-filled': '\ue694',
'checkbox': '\ue62b',
'checkbox-filled': '\ue62c',
'checkmarkempty': '\ue65c',
'circle': '\ue65b',
'circle-filled': '\ue65e',
'clear': '\ue66d',
'close': '\ue673',
'closeempty': '\ue66c',
'cloud-download': '\ue647',
'cloud-download-filled': '\ue646',
'cloud-upload': '\ue645',
'cloud-upload-filled': '\ue648',
'color': '\ue6cf',
'color-filled': '\ue6c9',
'compose': '\ue67f',
'contact': '\ue693',
'contact-filled': '\ue695',
'down': '\ue6b8',
'bottom': '\ue6b8',
'download': '\ue68d',
'download-filled': '\ue681',
'email': '\ue69e',
'email-filled': '\ue69a',
'eye': '\ue651',
'eye-filled': '\ue66a',
'eye-slash': '\ue6b3',
'eye-slash-filled': '\ue6b4',
'fire': '\ue6a1',
'fire-filled': '\ue6c5',
'flag': '\ue65f',
'flag-filled': '\ue660',
'folder-add': '\ue6a9',
'folder-add-filled': '\ue6c8',
'font': '\ue6a3',
'forward': '\ue6ba',
'gear': '\ue664',
'gear-filled': '\ue661',
'gift': '\ue6a4',
'gift-filled': '\ue6c4',
'hand-down': '\ue63d',
'hand-down-filled': '\ue63c',
'hand-up': '\ue63f',
'hand-up-filled': '\ue63e',
'headphones': '\ue630',
'heart': '\ue639',
'heart-filled': '\ue641',
'help': '\ue679',
'help-filled': '\ue674',
'home': '\ue662',
'home-filled': '\ue663',
'image': '\ue670',
'image-filled': '\ue678',
'images': '\ue650',
'images-filled': '\ue64b',
'info': '\ue669',
'info-filled': '\ue649',
'left': '\ue6b7',
'link': '\ue6a5',
'list': '\ue644',
'location': '\ue6ae',
'location-filled': '\ue6af',
'locked': '\ue66b',
'locked-filled': '\ue668',
'loop': '\ue633',
'mail-open': '\ue643',
'mail-open-filled': '\ue63a',
'map': '\ue667',
'map-filled': '\ue666',
'map-pin': '\ue6ad',
'map-pin-ellipse': '\ue6ac',
'medal': '\ue6a2',
'medal-filled': '\ue6c3',
'mic': '\ue671',
'mic-filled': '\ue677',
'micoff': '\ue67e',
'micoff-filled': '\ue6b0',
'minus': '\ue66f',
'minus-filled': '\ue67d',
'more': '\ue64d',
'more-filled': '\ue64e',
'navigate': '\ue66e',
'navigate-filled': '\ue67a',
'notification': '\ue6a6',
'notification-filled': '\ue6c1',
'paperclip': '\ue652',
'paperplane': '\ue672',
'paperplane-filled': '\ue675',
'person': '\ue699',
'person-filled': '\ue69d',
'personadd': '\ue69f',
'personadd-filled': '\ue698',
'personadd-filled-copy': '\ue6d1',
'phone': '\ue69c',
'phone-filled': '\ue69b',
'plus': '\ue676',
'plus-filled': '\ue6c7',
'plusempty': '\ue67b',
'pulldown': '\ue632',
'pyq': '\ue682',
'qq': '\ue680',
'redo': '\ue64a',
'redo-filled': '\ue655',
'refresh': '\ue657',
'refresh-filled': '\ue656',
'refreshempty': '\ue6bf',
'reload': '\ue6b2',
'right': '\ue6b5',
'scan': '\ue62a',
'search': '\ue654',
'settings': '\ue653',
'settings-filled': '\ue6ce',
'shop': '\ue62f',
'shop-filled': '\ue6cd',
'smallcircle': '\ue67c',
'smallcircle-filled': '\ue665',
'sound': '\ue684',
'sound-filled': '\ue686',
'spinner-cycle': '\ue68a',
'staff': '\ue6a7',
'staff-filled': '\ue6cb',
'star': '\ue688',
'star-filled': '\ue68f',
'starhalf': '\ue683',
'trash': '\ue687',
'trash-filled': '\ue685',
'tune': '\ue6aa',
'tune-filled': '\ue6ca',
'undo': '\ue64f',
'undo-filled': '\ue64c',
'up': '\ue6b6',
'top': '\ue6b6',
'upload': '\ue690',
'upload-filled': '\ue68e',
'videocam': '\ue68c',
'videocam-filled': '\ue689',
'vip': '\ue6a8',
'vip-filled': '\ue6c6',
'wallet': '\ue6b1',
'wallet-filled': '\ue6c2',
'weibo': '\ue68b',
'weixin': '\ue691',
}
export type IconsType = keyof typeof uniIconsMap
@@ -0,0 +1,66 @@
<template>
<text style="font-family: UniIconsFontFamily;">{{ unicode }}</text>
</template>
<script setup lang="ts">
import { uniIconsMap, IconsType } from './icons.uts'
type Props = {
/**
* @description 图标名称,取值范围见 icons.uts 导出的 IconsType,未传入或取值不存在时不显示图标
* @default ""
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "5.31"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
*/
type? : IconsType
}
const props = defineProps<Props>()
const unicode = ref('')
const updateUnicode = () : void => {
const type = props.type
unicode.value = type != null ? uniIconsMap[type] : ''
}
watch(() : IconsType | undefined => props.type, updateUnicode, { immediate: true })
</script>
<style>
@font-face {
font-family: UniIconsFontFamily;
src: url('./uniicons.ttf');
}
</style>
+100
View File
@@ -0,0 +1,100 @@
{
"id": "uni-icons-x",
"displayName": "uni-icons 图标",
"version": "1.0.0",
"description": "适用于 uni-app x 的字体图标组件,可自定义颜色、大小和字体。",
"keywords": [
"uni-ui-x",
"uni-app-x",
"icon",
"图标"
],
"repository": "https://github.com/dcloudio/uni-ui-x",
"engines": {
"HBuilderX": "^5.0",
"uni-app-x": "^5.0",
"uni-app": ""
},
"dcloudext": {
"type": "component-vue",
"sale": {
"regular": {
"price": "0.00"
},
"sourcecode": {
"price": "0.00"
}
},
"contact": {
"qq": ""
},
"declaration": {
"ads": "无",
"data": "无",
"permissions": "无"
},
"npmurl": "https://www.npmjs.com/package/@dcloudio/uni-ui-x",
"darkmode": "x",
"i18n": "x",
"widescreen": "x"
},
"uni_modules": {
"dependencies": [],
"encrypt": [],
"platforms": {
"cloud": {
"tcb": "x",
"aliyun": "x",
"alipay": "x"
},
"client": {
"uni-app": {
"vue": {
"vue2": "x",
"vue3": "x"
},
"web": {
"safari": "-",
"chrome": "-"
},
"app": {
"vue": "x",
"nvue": "x",
"android": "-",
"ios": "-",
"harmony": "-"
},
"mp": {
"weixin": "-",
"alipay": "-",
"toutiao": "-",
"baidu": "-",
"kuaishou": "-",
"jd": "-",
"harmony": "-",
"qq": "-",
"lark": "-"
},
"quickapp": {
"huawei": "-",
"union": "-"
}
},
"uni-app-x": {
"web": {
"safari": "√",
"chrome": "√"
},
"app": {
"android": "√",
"ios": "√",
"harmony": "√"
},
"mp": {
"weixin": "√"
}
}
}
}
}
}
+1
View File
@@ -0,0 +1 @@
### [查看文档](https://doc.dcloud.net.cn/uni-app-x/component/uni-ui-x/uni-icons.html)
+4
View File
@@ -0,0 +1,4 @@
## 1.0.1(2026-04-30)
- 更新文档
## 1.0.0(2026-04-30)
- 支持 uni-index-bar 组件
@@ -0,0 +1,384 @@
<template>
<!-- #ifdef MP-ALIPAY -->
<view class="index-text" ref="indexTextRef" v-bind="$attrs" @touchstart="onTouchStart" @touchmove.stop.prevent="onTouchMove"
@touchend="onTouchEnd">
<text v-for="(item, index) in indexList" :key="item" class="index-item">{{ item }}</text>
</view>
<!-- #endif -->
<!-- #ifndef MP-ALIPAY -->
<text class="index-text" ref="indexTextRef" v-bind="$attrs" @touchstart="onTouchStart" @touchmove.stop.prevent="onTouchMove"
@touchend="onTouchEnd">{{ indexs }}</text>
<!-- #endif -->
<!-- indicator with arrow -->
<view v-show="touching" class="indicator" :class="indicatorViewClass" :style="indicatorStyle">
<text class="indicator-text" :class="indicatorTextClass">{{ currentIndex }}</text>
</view>
</template>
<script lang="ts" setup>
/**
* 索引列表
* @description 索引列表组件
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.08"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "5.31"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
*/
defineOptions({
externalClasses: ['indicator-view-class', 'indicator-text-class']
})
type Props = {
/**
* @description 索引字符列表,使用换行符 \n 分隔,每行作为一个索引项
* @default "A\nB\nC\nD\nE\nF\nG\nH\nI\nJ\nK\nL\nM\nN\nO\nP\nQ\nR\nS\nT\nU\nV\nW\nX\nY\nZ"
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.08"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "5.31"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
*/
indexs? : string,
/**
* @description 指示器容器的自定义样式类,触摸时显示当前索引的气泡
* @default ""
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.08"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "5.31"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
*/
indicatorViewClass? : string.ClassString,
/**
* @description 指示器内文字的自定义样式类
* @default ""
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.08"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "5.31"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
*/
indicatorTextClass? : string.ClassString
}
const props = withDefaults(defineProps<Props>(), {
indexs: 'A\nB\nC\nD\nE\nF\nG\nH\nI\nJ\nK\nL\nM\nN\nO\nP\nQ\nR\nS\nT\nU\nV\nW\nX\nY\nZ',
indicatorViewClass: '',
indicatorTextClass: ''
})
const emit = defineEmits<{
/**
* @description 选择索引时触发,参数为当前选择的索引字符,类型为 string
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.08"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "5.31"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
*/
select: [index: string]
}>()
// 将索引字符串按换行分割为数组
const indexList = computed(() : string[] => props.indexs.split('\n'))
const currentIndex = ref('')
const touching = ref(false)
const indicatorTop = ref(0)
const indicatorStyle = computed(() : string => {
return `top: ${indicatorTop.value}px;`
})
// 布局缓存变量
let textTop = 0
let itemHeight = 0
let indexCount = 0
let cachedIndexList : string[] = []
const navBarHeight = uni.getWindowInfo().windowTop
// #ifndef MP
const indexTextRef = ref<UniTextElement | null>(null)
// #endif
// 更新布局信息
const updateLayout = () => {
// 缓存索引数组和长度,避免在 touch 事件中重复访问 computed
cachedIndexList = indexList.value
indexCount = cachedIndexList.length
// #ifndef MP
// App/Web 端使用 getBoundingClientRect 同步获取
const textEl = indexTextRef.value
if (textEl != null) {
const rect = textEl.getBoundingClientRect()
textTop = rect.top
itemHeight = rect.height / indexCount
}
// #endif
}
// #ifdef MP
// 小程序需要在 setup 阶段保存 instance,因为异步回调中 getCurrentInstance() 返回 null
const instance = getCurrentInstance()
// 小程序端使用 createSelectorQuery 异步获取布局
const updateLayoutAsync = () => {
cachedIndexList = indexList.value
indexCount = cachedIndexList.length
if (instance == null) return
uni.createSelectorQuery()
.in(instance.proxy)
.select('.index-text')
.boundingClientRect((rect) => {
if (rect != null) {
textTop = rect.top as number
itemHeight = (rect.height as number) / indexCount
}
})
.exec()
}
onMounted(() => {
nextTick(() => {
updateLayoutAsync()
})
})
watch(() => props.indexs, () => {
nextTick(() => {
updateLayoutAsync()
})
})
// #endif
const getIndexFromY = (y : number) : number => {
const relY = y - textTop
let idx = Math.floor(relY / itemHeight)
if (idx < 0) idx = 0
if (idx >= indexCount) idx = indexCount - 1
return idx
}
// 更新指示器位置
const updateIndicator = (idx : number) => {
indicatorTop.value = textTop + idx * itemHeight + itemHeight / 2 - 25 - navBarHeight
}
const onTouchStart = (e : UniTouchEvent) => {
updateLayout()
touching.value = true
const touch = e.touches[0]
const idx = getIndexFromY(touch.clientY)
const selectedIndex = cachedIndexList[idx]
currentIndex.value = selectedIndex
updateIndicator(idx)
emit('select', selectedIndex)
}
const onTouchMove = (e : UniTouchEvent) => {
// #ifdef APP && UNI-APP-X
e.preventDefault()
// #endif
// #ifdef WEB && UNI-APP-X
if (e.cancelable) {
e.preventDefault()
}
// #endif
const touch = e.touches[0]
const idx = getIndexFromY(touch.clientY)
const newIndex = cachedIndexList[idx]
if (newIndex != currentIndex.value) {
currentIndex.value = newIndex
updateIndicator(idx)
emit('select', newIndex)
}
}
const onTouchEnd = (_e : UniTouchEvent) => {
touching.value = false
}
</script>
<style>
.index-text {
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
width: 28px;
font-size: 11px;
color: #757575;
text-align: center;
line-height: 1.4;
z-index: 1000;
}
.index-item {
text-align: center;
}
.indicator {
position: absolute;
right: 50px;
width: 50px;
height: 50px;
background-color: #c9c9c9;
border-radius: 25px 25px 25px 0;
transform: rotate(225deg);
align-items: center;
justify-content: center;
z-index: 99;
}
.indicator-text {
font-size: 22px;
color: #ffffff;
text-align: center;
transform: rotate(-225deg);
}
</style>
+75
View File
@@ -0,0 +1,75 @@
{
"id": "uni-index-bar",
"displayName": "uni-index-bar 索引栏",
"version": "1.0.1",
"description": "uni-index-bar 索引栏组件,用于显示索引栏,支持自定义索引栏的样式和内容。",
"isExt": true,
"extUrl": "https://ext.dcloud.net.cn/plugin?id=27847",
"keywords": [
"uni-index-bar",
"索引栏",
"index-bar",
"uni-app-x",
"组件"
],
"repository": "https://gitcode.com/dcloud/uni-ui-x",
"engines": {
"HBuilderX": "^5.08",
"uni-app-x": "^5.08",
"uni-app": ""
},
"directories": {
"example": "../../temps/example_temps"
},
"dcloudext": {
"type": "component-vue",
"sale": {
"regular": {
"price": "0.00"
},
"sourcecode": {
"price": "0.00"
}
},
"contact": {
"qq": ""
},
"declaration": {
"ads": "无",
"data": "无",
"permissions": "无"
},
"darkmode": "x",
"i18n": "x",
"widescreen": "x",
"npmurl": ""
},
"uni_modules": {
"dependencies": [],
"encrypt": [],
"platforms": {
"cloud": {
"tcb": "x",
"aliyun": "x",
"alipay": "x"
},
"client": {
"uni-app-x": {
"web": {
"safari": "√",
"chrome": "√"
},
"app": {
"android": "√",
"ios": "√",
"harmony": "√"
},
"mp": {
"weixin": "√",
"alipay": "√"
}
}
}
}
}
}
+98
View File
@@ -0,0 +1,98 @@
### 介绍
本组件以竖排方式显示字母索引,常悬浮或固定于列表右侧,用户通过点击或滑动索引条上的字母索引,触发 `select` 事件。在该事件中通过代码驱动指定的列表滚动到该字母索引所在的位置。
本组件为绝对定位,默认垂直居中于父容器右侧。
本组件不包括 list-view 组件,需要配合 list-view 使用。list-view 中需要有对应索引的 list-item,且这些 list-item 需要有 id 属性,可以通过 `scroll-into-view` 属性或 `scrollTop` 属性跳转。
本组件包括一个指示器(indicator)子组件。当用户手指触摸在索引条上的某个字母时,在触摸的字母左边悬浮显示一个水滴形气泡,箭头指向当前触摸的字母。
### 基本用法
```html
<view style="flex: 1; position: relative;">
<list-view ref="listRef" style="flex: 1;">
<!-- 列表内容 -->
</list-view>
<uni-index-bar @select="onSelect"></uni-index-bar>
</view>
```
```ts
const onSelect = (index: string) => {
// 处理索引选择,滚动列表到对应位置
}
```
### 事件
| 事件名 | 说明 | 回调参数 |
|--------|------|----------|
| select | 选择索引时触发 | index: string - 当前选择的索引字符 |
### 外部样式类
| 类名 | 说明 |
|------|------|
| indicator-view-class | 指示器容器样式,可自定义气泡背景色等 |
| indicator-text-class | 指示器文字样式,可自定义文字颜色等 |
### 示例
#### 自定义索引
```html
<uni-index-bar :indexs="'☆\nA\nB\nC\n#'" @select="onSelect"></uni-index-bar>
```
#### 自定义样式
```html
<uni-index-bar
indicator-view-class="custom-indicator"
indicator-text-class="custom-indicator-text"
style="color: aqua;"
@select="onSelect">
</uni-index-bar>
```
```css
.custom-indicator {
background-color: #007aff;
}
.custom-indicator-text {
color: #ffff00;
}
```
#### 配合 list-view 使用
```html
<view style="flex: 1; position: relative;">
<list-view ref="listRef" style="flex: 1;" :scroll-into-view="indexViewID">
<template v-for="group in cityGroups" :key="group.index">
<list-item :id="'idx-' + group.index" type="header">
<view class="group-header">
<text>{{ group.index }}</text>
</view>
</list-item>
<list-item v-for="city in group.cities" :key="city.id" type="city">
<view class="city-item">
<text>{{ city.name }}</text>
</view>
</list-item>
</template>
</list-view>
<uni-index-bar :indexs="indexList" @select="onSelect"></uni-index-bar>
</view>
```
```ts
const indexList = cityGroups.map((g): string => g.index).join('\n')
const indexViewID = ref("")
const onSelect = (index: string) => {
indexViewID.value = 'idx-' + index
}
```
+2
View File
@@ -0,0 +1,2 @@
## 1.0.0(2026-05-07)
- 支持 uni-link 组件
@@ -0,0 +1,362 @@
<template>
<text style="text-decoration-line: underline" @click="openURL">
<slot></slot>
</text>
</template>
<script lang="ts" setup>
/**
* 链接
* @description 链接组件
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "5.31"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
*/
// #ifdef APP
import { openSchema } from '@/uni_modules/uts-openSchema'
// #endif
defineOptions({
name: 'UniLink',
styleIsolation: 'app'
})
type Props = {
/**
* @description 链接地址,点击组件时要打开的 URL
* @default ""
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "5.31"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
*/
href?: string
/**
* @description 打开方式。"_self" 在当前应用内置 webview 中打开;"_blank" 在外部浏览器中打开(小程序端会复制链接提示用户外部打开)
* @default "_self"
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "5.31"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
*/
target?: '_blank' | '_self'
/**
* @description `target="_blank"`失败时,可以通过本属性控制失败回退策略。"turn-self"使用_self方式打开;"copy-link-modal"复制链接并弹框提醒;"none"不回退,触发失败回调
* @default "turn-self"
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "5.31"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
*/
blankFallback: 'turn-self' | 'copy-link-modal' | 'none'
}
type LocaleType = 'zh-Hans' | 'zh-Hant' | 'en' | 'fr' | 'la'
type LocaleMessages = {
emptyHref: string
popupBlocked: string
internalOpenFailed: string
copiedOpenInBrowserPrefix: string
copyFailedOpenInBrowser: string
}
const LINK_WEBVIEW_PAGE_PATH = '/uni_modules/uni-link-x/pages/link-webview'
const props = withDefaults(defineProps<Props>(), {
href: '',
target: '_self',
blankFallback: 'turn-self'
})
const emit = defineEmits<{
error: [message: string]
}>()
const appLanguage = (uni.getAppBaseInfo().appLanguage ?? 'en').toLowerCase()
function resolveLocaleType() : LocaleType {
if (
appLanguage.indexOf('zh-hant') == 0 ||
appLanguage.indexOf('zh-tw') == 0 ||
appLanguage.indexOf('zh-hk') == 0 ||
appLanguage.indexOf('zh-mo') == 0
) {
return 'zh-Hant'
}
if (appLanguage.indexOf('zh') == 0) {
return 'zh-Hans'
}
if (appLanguage.indexOf('fr') == 0) {
return 'fr'
}
if (appLanguage.indexOf('la') == 0 || appLanguage.indexOf('lat') == 0) {
return 'la'
}
return 'en'
}
function getLocaleMessages() : LocaleMessages {
const localeType = resolveLocaleType()
if (localeType == 'zh-Hans') {
return {
emptyHref: '链接地址不能为空',
popupBlocked: '浏览器拦截了新窗口',
internalOpenFailed: '应用中未找到uni-link组件下的link-webview页面',
copiedOpenInBrowserPrefix: '已复制链接,请使用外部浏览器打开:',
copyFailedOpenInBrowser: '复制链接失败,请手动复制链接并使用外部浏览器打开'
}
}
if (localeType == 'zh-Hant') {
return {
emptyHref: '連結地址不能為空',
popupBlocked: '瀏覽器封鎖了新視窗',
internalOpenFailed: '應用中未找到 uni-link 組件下的 link-webview 頁面',
copiedOpenInBrowserPrefix: '已複製連結,請使用外部瀏覽器開啟:',
copyFailedOpenInBrowser: '複製連結失敗,請手動複製連結並使用外部瀏覽器開啟'
}
}
if (localeType == 'fr') {
return {
emptyHref: 'L’URL ne peut pas être vide',
popupBlocked: 'Le navigateur a bloqué la nouvelle fenêtre',
internalOpenFailed: 'Échec de l\'ouverture de la page @uni_modules/uni-link-x/pages/link-webview',
copiedOpenInBrowserPrefix: 'Le lien a été copié. Veuillez l’ouvrir dans un navigateur externe :',
copyFailedOpenInBrowser: 'Échec de la copie du lien. Veuillez le copier manuellement et l’ouvrir dans un navigateur externe.'
}
}
if (localeType == 'la') {
return {
emptyHref: 'Inscriptio nexus vacua esse non potest',
popupBlocked: 'Navigator fenestram novam prohibuit',
internalOpenFailed: 'Pagina aperiri non potuit @uni_modules/uni-link-x/pages/link-webview',
copiedOpenInBrowserPrefix: 'Nexus descriptus est. Quaeso eum in navigatro externo aperi:',
copyFailedOpenInBrowser: 'Descriptio nexus defecit. Quaeso nexum manu describe et in navigatro externo aperi.'
}
}
return {
emptyHref: 'The link URL cannot be empty',
popupBlocked: 'The browser blocked the new window',
internalOpenFailed: 'Failed to open page @uni_modules/uni-link-x/pages/link-webview',
copiedOpenInBrowserPrefix: 'The link has been copied. Please open it in an external browser:',
copyFailedOpenInBrowser: 'Failed to copy the link. Please copy it manually and open it in an external browser.'
}
}
function showPrompt(content : string) {
uni.showModal({
content,
showCancel: false
})
}
function copyLinkAndPrompt(url : string) {
const messages = getLocaleMessages()
uni.setClipboardData({
data: url,
showToast: false,
success: () => {
showPrompt(messages.copiedOpenInBrowserPrefix + '\n' + url)
},
fail: () => {
showPrompt(messages.copyFailedOpenInBrowser)
}
})
}
function reportOpenError(message : string) {
emit('error', message)
}
function openInBuiltInWebview(url : string) {
uni.navigateTo({
url: `${LINK_WEBVIEW_PAGE_PATH}?url=${encodeURIComponent(url)}`,
success: () => {},
fail: () => {
// 异常情况下可能pages.json中漏配了本组件下的link-webview.uvue页面
reportOpenError(getLocaleMessages().internalOpenFailed)
}
})
}
function openURL() {
const url = props.href.trim()
if (url.length == 0) {
reportOpenError(getLocaleMessages().emptyHref)
return
}
if (props.target == '_self') {
openInBuiltInWebview(url)
}
else{
// _blank方式
// #ifdef WEB
const targetWindow = window.open(url, '_blank')
if (targetWindow != null) {
return
}
else if (props.blankFallback=="turn-self"){
openInBuiltInWebview(url)
return
}
else if(props.blankFallback=="copy-link-modal"){
copyLinkAndPrompt(url)
return
}
reportOpenError(getLocaleMessages().popupBlocked)
// #endif
// #ifdef MP
if (props.blankFallback=="turn-self"){
openInBuiltInWebview(url)
}
else if(props.blankFallback=="copy-link-modal"){
copyLinkAndPrompt(url)
}
// #endif
// #ifdef APP
try {
openSchema(url)
} catch (error) {
// 这里常见的情况是iOS设备在真机运行时缺少xcode环境编译openSchema失败
if (props.blankFallback=="turn-self"){
openInBuiltInWebview(url)
}
else if(props.blankFallback=="copy-link-modal"){
copyLinkAndPrompt(url)
}
else{
reportOpenError((error as Error).message) // 报错后发送错误事件,这里的错误原因未知,无法自行提前国际化
}
}
// #endif
}
}
</script>
<style>
</style>
+76
View File
@@ -0,0 +1,76 @@
{
"id": "uni-link-x",
"displayName": "uni-link 链接",
"version": "1.0.0",
"description": "Link 链接组件,可自定义链接的打开方式。",
"isExt": true,
"extUrl": "https://ext.dcloud.net.cn/plugin?id=27898",
"keywords": [
"uni-ui",
"uniui",
"link",
"链接",
"uni-app-x"
],
"engines": {
"HBuilderX": "",
"uni-app": "",
"uni-app-x": "^5.07"
},
"repository": "https://gitcode.com/dcloud/uni-ui-x",
"directories": {
"example": "../../temps/example_temps"
},
"dcloudext": {
"sale": {
"regular": {
"price": "0.00"
},
"sourcecode": {
"price": "0.00"
}
},
"contact": {
"qq": ""
},
"declaration": {
"ads": "无",
"data": "无",
"permissions": "无"
},
"type": "component-vue",
"darkmode": "x",
"i18n": "x",
"widescreen": "x",
"npmurl": ""
},
"uni_modules": {
"dependencies": [
"uts-openSchema"
],
"encrypt": [],
"platforms": {
"cloud": {
"tcb": "√",
"aliyun": "√",
"alipay": "x"
},
"client": {
"uni-app-x": {
"web": {
"safari": "√",
"chrome": "√"
},
"app": {
"android": "√",
"ios": "√",
"harmony": "√"
},
"mp": {
"weixin": "√"
}
}
}
}
}
}
@@ -0,0 +1,28 @@
<template>
<web-view style="flex: 1;" :src="currentUrl" @error="onWebViewError"></web-view>
</template>
<script setup lang="ts">
const currentUrl = ref<string>('')
onLoad((options : UTSJSONObject | null) => {
if (options == null) {
return
}
const rawUrl = options['url']
if (rawUrl == null) {
return
}
const nextUrl = rawUrl as string
currentUrl.value = decodeURIComponent(nextUrl) ?? nextUrl
})
function onWebViewError(event : UniWebViewErrorEvent) {
const detail = event.detail
const errMsg = detail.errMsg ?? ''
uni.showModal({
content: `Error: ${errMsg}`,
showCancel: false
})
}
</script>
+45
View File
@@ -0,0 +1,45 @@
打开web链接组件。点击后通过内置或外部浏览器打开链接,加载 `href` 属性中配置的 URL。
- `target="_self"` 时,使用 `uni_modules/uni-link-x/pages/link-webview` 页面内的 `web-view` 打开链接。
* 注意小程序需要将域名加入应用的域名白名单。
- `target="_blank"` 时,
* APP平台调用手机浏览器打开链接
* Web调用 `window.open(url, '_blank')`
* 小程序平台无法打开手机浏览器,此时会走到失败回退逻辑中
`target="_blank"`失败时,可以通过组件属性`blank-fallback`来控制失败后的回退处理策略,`blank-fallback`属性有3个值:
1. turn-self :使用_self方式打开。此值为默认值。也就是 `target="_blank"` 时,小程序下默认转到内置浏览器打开。
2. copy-link-modal :复制链接并弹框提醒。会复制URL到剪贴板中,并弹框提示用户在外部浏览器中打开。
3. none :不回退。会触发组件的error事件。
- 开发者也可以通过条件编译组合使用,比如APP平台使用`_black`,小程序平台使用`_self`
常见失败原因:
- 安装本组件时未在项目的pages.json中注册组件的内置页面。(HBuilderX导入本组件到项目下时会弹框提醒注册内置页面)
- 小程序平台未将域名加入应用的域名白名单。
- iOS运行基座未包括openSchema插件,仅在无xcode时运行会触发。打包其实不会发生此错误。
- app平台,有些手机系统会弹框询问用户是否允许app打开浏览器,如果用户点了拒绝,这种情况没有失败回调。但有种判断方式是app的onHide生命周期是否触发,如果没有触发,那么意味着并没有启动系统浏览器。
### 基本用法
```html
<uni-link href="https://uniapp.dcloud.net.cn">uni-app x 文档
</uni-link>
```
### Events
| 事件名 | 说明 | 回调参数 |
| --- | --- | --- |
| error | 打开失败时触发 | `message: string` |
### 依赖
`APP` 平台依赖 `uts-openSchema`。`uni-link` 已在 `uni_modules/uni-link-x/package.json` 中声明该依赖。
组件目录下有 `pages_init.json`,会向应用的 pages.json 中注册组件中自带的内置浏览器页面。如在HBuilderX中弹框询问是否将组件中的页面注册到项目pages.json中时,请选择同意。
### 示例页面
`/pages/uni-ui/link/link`
+4
View File
@@ -0,0 +1,4 @@
## 1.0.1(2026-05-20)
- 纠正类名拼写错误
## 1.0.0(2026-05-07)
- 支持 uni-nav-bar 组件
@@ -0,0 +1,361 @@
<template>
<view style="flex-direction: row;padding-top: var(--status-bar-height);box-sizing: content-box;align-items: center;position: relative;">
<view style="height: 44px;align-items: center;justify-content: center;
<!-- #ifdef WEB -->
cursor: pointer
<!-- #endif -->
" class="uni-left-class-builtin" :class="leftClass">
<!-- 旋转边框的view实现返回箭头。因需要扩大点击区域,外面多套了一层 -->
<view v-if="!hideDefaultBack && slots['left']==null" style="width: 44px;height: 44px;justify-content: center;align-items: center;" @click="back">
<view style="width: 12px;height: 12px;transform: rotate(45deg);
border-left: 2px solid; border-bottom: 2px solid;" :style="backIconStyle"></view>
</view>
<slot name="left" v-else></slot>
</view>
<view style="position: absolute; height: 44px; flex-direction: row; align-items: center;" class="uni-mid-class-builtin" :class="midClass" flatten>
<text v-if="slots['mid']==null" :style="titleStyle" flatten> {{ title }} </text>
<slot name="mid" v-else></slot>
</view>
<view style="position: absolute; height: 44px; flex-direction: row; align-items: center;right: 0;" class="uni-right-class-builtin" :class="rightClass" flatten>
<slot name="right"></slot>
</view>
</view>
</template>
<script lang="ts" setup>
/**
* 自定义导航栏
* @description 自定义导航栏组件
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "5.31"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
*/
defineOptions({
// inheritAttrs: false,
externalClasses: ['left-class','mid-class','right-class']
})
const props = defineProps({
/**
* @description 是否隐藏默认的返回箭头。为 true 时需要通过 left 插槽自定义返回按钮
* @default false
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "5.31"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
*/
hideDefaultBack: { type: Boolean, default: false },
/**
* @description 导航栏中间显示的标题文字,若使用 mid 插槽则该属性无效
* @default ""
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "5.31"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
*/
title: { type: String, default: "" },
// navigationBarTextStyle: { type: "#000000" | "#ffffff" | "#000" | "#fff" | "black" | "white" | "", default: "" },
/**
* @description 导航栏前景色(文字和返回箭头颜色)。非小程序端未传入时会自动读取 pageStyle 的 navigationBarTextStyle
* @default ""
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "5.31"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
*/
navigationBarTextStyle: { type: String as PropType<"white" | "black" | "">, default: "" },
/**
* @description 左侧区域的自定义样式类
* @default ""
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "5.31"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
*/
leftClass: { type: String, default: "" },
/**
* @description 中间区域的自定义样式类
* @default ""
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "5.31"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
*/
midClass: { type: String, default: "" },
/**
* @description 右侧区域的自定义样式类
* @default ""
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "5.31"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
*/
rightClass: { type: String, default: "" }
// stat: { type: Boolean, default: false }
})
const slots = useSlots();
const backIconStyle = computed(() : string => {
return props.navigationBarTextStyle == "" ? "" : `border-left-color: ${props.navigationBarTextStyle}; border-bottom-color: ${props.navigationBarTextStyle};`;
})
const titleStyle = computed(() : string => {
return props.navigationBarTextStyle == "" ? "" : `color: ${props.navigationBarTextStyle};`;
})
// #ifndef MP
// 获取pageStyle在web和app上都没有兼容theme.json的场景,这段代码注释掉。需要先修改底层bug。本组件使用了其他方案
/* const currentPage = getCurrentInstance()?.proxy?.$page
console.log("props.navigationBarTextStyle:",props.navigationBarTextStyle);
console.log("navigationBarTextStyle:",currentPage?.getPageStyle()["navigationBarTextStyle"]);
console.log("backgroundColorContent:",currentPage?.getPageStyle()["backgroundColorContent"]);
const foreColor = (props.navigationBarTextStyle=="") ? currentPage?.getPageStyle()["navigationBarTextStyle"] : props.navigationBarTextStyle; //如果外部没有传入前景色,从pageStyle获取
console.log("foreColor:",foreColor); */
// #endif
// #ifdef MP
// const foreColor = props.navigationBarTextStyle; //小程序端无法获取pageStyle,需要显式传入前景色
// #endif
/* const foreColor = computed(() : string => {
return props.navigationBarTextStyle;
}) */
// let statusBarHeight = ref(0)
onMounted(() => {
// #ifdef MP-WEIXIN
// TODO 部分小程序平台不支持getWindowInfo
// statusBarHeight.value = uni.getWindowInfo().statusBarHeight
// #endif
/* if (props.stat && props.title != '') {
uni.report({
name: 'title',
options: props.title
})
} */
})
const back = () => {
uni.navigateBack({});
}
</script>
<style>
.uni-left-class-builtin {
width: 44px;
margin-left: 6px;
}
.uni-mid-class-builtin {
justify-content: center;
left: 52px; /* padding 6 + 44 = 52*/
right: 52px;
}
.uni-right-class-builtin {
width: 44px;
margin-right: 6px;
}
</style>
+73
View File
@@ -0,0 +1,73 @@
{
"id": "uni-nav-bar-x",
"displayName": "uni-nav-bar",
"version": "1.0.1",
"description": "uni-nav-bar",
"isExt": true,
"extUrl": "https://ext.dcloud.net.cn/plugin?id=27897",
"keywords": [
"uni-nav-bar",
"uni-app-x",
"导航栏",
"组件"
],
"repository": "https://gitcode.com/dcloud/uni-ui-x",
"engines": {
"HBuilderX": "^5.0",
"uni-app-x": "^5.07",
"uni-app": ""
},
"directories": {
"example": "../../temps/example_temps"
},
"dcloudext": {
"type": "component-vue",
"sale": {
"regular": {
"price": "0.00"
},
"sourcecode": {
"price": "0.00"
}
},
"contact": {
"qq": ""
},
"declaration": {
"ads": "无",
"data": "无",
"permissions": "无"
},
"darkmode": "x",
"i18n": "x",
"widescreen": "x",
"npmurl": ""
},
"uni_modules": {
"dependencies": [],
"encrypt": [],
"platforms": {
"cloud": {
"tcb": "x",
"aliyun": "x",
"alipay": "x"
},
"client": {
"uni-app-x": {
"web": {
"safari": "√",
"chrome": "√"
},
"app": {
"android": "√",
"ios": "√",
"harmony": "√"
},
"mp": {
"weixin": "√"
}
}
}
}
}
}
+63
View File
@@ -0,0 +1,63 @@
自定义导航栏组件
在页面pages.json中关闭原生导航栏后,即使用`"style": {"navigationStyle": "custom"}`,可以使用本组件实现自定义导航栏。
同时注意在pages.json中配置页面style增加属性`disableScroll:true`,即禁止页面滚动。此时只有uni-nav-bar组件的下方才可以滚动。否则页面的回弹bounce效果会把自定义导航栏也拉下来。
本组件自动适配顶部安全区。用padding-top让出顶部状态栏的高度。除去状态栏高度后,本组件的高度为44px。
本组件左右两边默认各让出了6px的边距。也可以在left-class和right-class中自定义边距。
本组件分为left、mid、right 3个区域。
- left区域默认显示一个返回箭头,大小为44*44px。可以通过属性hideDefaultBack来隐藏,也可以传入一个slot name='left'来替代。可以通过left-class来修饰样式。
- mid区域默认显示title属性设置的标题。也可以传入一个slot name='mid'来替代。其默认宽度为屏幕宽度-两边默认边距-left区域默认宽度-right区域默认宽度。可以通过mid-class来修饰样式。
- right区域默认不显示内容,可以传入一个slot name='right'来显示自定义内容。right区域默认width也是44px,可以通过right-class来修饰样式。
支持属性:
- hideDefaultBack: 隐藏返回箭头
- title: 通过属性方便设置标题。如果传入mid slot,则不生效
- navigationBarTextStyle: 返回箭头和属性设置的标题,它们的颜色均由该属性控制,可选 white|black
本组件默认没有背景色,即透明,会透显页面的背景色,开发者可通过组件的class自行设置背景色。
如果遇到顶部安全区避让失效的问题,可升级到 HBuilderX 5.25 版本。
在小程序端,如果需要规避右上角胶囊按钮,可以参考下方代码设置 margin-right 让出胶囊按钮的宽度和右侧间距。
```html
<template>
<uni-nav-bar title="标题" right-class="nav-right">
<template #right>
<view :style="{ transform: 'translateX(-' + rightMargin + 'px)' }">
<text class="txt-button">right</text>
</view>
</template>
</uni-nav-bar>
</template>
<script setup lang="ts">
const rightMargin = ref(0)
onMounted(() => {
// #ifdef MP-WEIXIN
const menuButtonInfo = uni.getMenuButtonBoundingClientRect()
const windowInfo = uni.getWindowInfo()
// 预留胶囊按钮宽度和右侧间距,让右侧插槽显示在胶囊按钮左边
rightMargin.value = windowInfo.windowWidth - menuButtonInfo.right + menuButtonInfo.width
// #endif
})
</script>
<style>
.nav-right {
overflow: visible;
}
.txt-button {
width: 44px;
height: 44px;
line-height: 44px;
text-align: center;
}
</style>
```
@@ -0,0 +1,4 @@
## 1.0.1(2026-05-07)
- 移除无用依赖
## 1.0.0(2026-05-07)
- 支持 uni-number-box 组件
@@ -0,0 +1,802 @@
<template>
<view class="uni-numbox">
<view
class="uni-numbox__btn uni-numbox__minus"
:class="btnClass"
<!-- #ifdef WEB -->
@pointerdown="onMinus"
<!-- #endif -->
<!-- #ifndef WEB -->
@click="onMinus"
<!-- #endif -->
>
<!-- Chrome 133版本上有点击响应慢的bug,为规避bug在条件编译中使用 @pointerdown-->
<view class="uni-numbox__icon-line" :class="[iconClass, minusDisabled ? 'uni-numbox__icon-line--disabled' : '']"></view>
</view>
<input
class="uni-numbox__input"
:class="inputClass"
:disabled="disabled"
:type="step < 1 ? 'digit' : 'number'"
v-model="inputValue"
@focus="onFocus"
@blur="onBlur"
/>
<view
class="uni-numbox__btn uni-numbox__plus"
:class="btnClass"
<!-- #ifdef WEB -->
@pointerdown="onPlus"
<!-- #endif -->
<!-- #ifndef WEB -->
@click="onPlus"
<!-- #endif -->
>
<view class="uni-numbox__icon-line" :class="[iconClass, plusDisabled ? 'uni-numbox__icon-line--disabled' : '']"></view>
<view class="uni-numbox__icon-line uni-numbox__icon-line--vertical" :class="[iconClass, plusDisabled ? 'uni-numbox__icon-line--disabled' : '']"></view>
</view>
</view>
</template>
<script setup lang="ts">
/**
* NumberBox 数字输入框
* @description 带加减按钮的数字输入框,常见于购物车商品数量选择
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "5.31"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
* @property {Number} value 输入框当前值,默认值 1
* @property {Number} modelValue v-model 绑定值,默认值 1
* @property {Number} min 最小值,默认值 0
* @property {Number} max 最大值,默认值 100
* @property {Number} step 步长,每次点击改变的间隔大小,默认值 1,支持小数
* @property {Boolean} disabled 是否禁用,默认值 false
* @property {String} btnClass 按钮自定义样式类
* @property {String} inputClass 输入框自定义样式类
* @property {String} iconClass 图标自定义样式类
* @event {Function} change 值改变时触发,参数为当前值
* @event {Function} focus 输入框聚焦时触发
* @event {Function} blur 输入框失焦时触发
*/
defineOptions({
externalClasses: ['btn-class', 'input-class', 'icon-class']
})
type Props = {
/**
* @description 输入框当前值,不使用 v-model 时通过该属性传入初始值
* @default 1
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "5.31"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
*/
value?: number,
/**
* @description v-model 绑定值,支持双向绑定
* @default 1
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "5.31"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
*/
modelValue?: number,
/**
* @description 允许输入的最小值
* @default 0
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "5.31"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
*/
min?: number,
/**
* @description 允许输入的最大值
* @default 100
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "5.31"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
*/
max?: number,
/**
* @description 每次点击加/减按钮改变的步长,支持小数
* @default 1
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "5.31"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
*/
step?: number,
/**
* @description 是否禁用组件,禁用时输入框和加减按钮都不可操作
* @default false
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "5.31"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
*/
disabled?: boolean,
/**
* @description 加减按钮的自定义样式类
* @default ""
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "5.31"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
*/
btnClass?: string.ClassString,
/**
* @description 输入框的自定义样式类
* @default ""
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "5.31"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
*/
inputClass?: string.ClassString,
/**
* @description 加减按钮内部图标的自定义样式类
* @default ""
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "5.31"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
*/
iconClass?: string.ClassString
}
const props = withDefaults(defineProps<Props>(), {
value: 1,
modelValue: 1,
min: 0,
max: 100,
step: 1,
disabled: false,
btnClass: '',
inputClass: '',
iconClass: ''
})
const emit = defineEmits<{
/**
* @description 值改变时触发,参数为当前值,类型为 number
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "5.31"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
*/
change: [value: number]
/**
* @description v-model 绑定值变化事件,参数为当前值,类型为 number
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "5.31"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
*/
'update:modelValue': [value: number]
/**
* @description 输入框失焦时触发,参数为事件对象,类型为 UniInputBlurEvent
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "5.31"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
*/
blur: [event: UniInputBlurEvent]
/**
* @description 输入框聚焦时触发,参数为事件对象,类型为 UniInputFocusEvent
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "5.31"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
*/
focus: [event: UniInputFocusEvent]
}>()
// 内部输入值
const inputValue = ref<string>('')
// 获取小数精度
const getDecimalScale = () : number => {
let scale = 1
// 浮点型
if (Math.floor(props.step) != props.step) {
const stepStr = props.step.toString()
const dotIndex = stepStr.indexOf('.')
if (dotIndex != -1) {
const decimalPart = stepStr.substring(dotIndex + 1)
scale = Math.pow(10, decimalPart.length) as number
}
}
return scale
}
// 格式化数值
const formatValue = (value : number) : string => {
const scale = getDecimalScale()
const scaleStr = scale.toString()
const precision = scaleStr.length - 1
return value.toFixed(precision)
}
// 发射变更事件
const emitChange = (value : number) : void => {
emit('update:modelValue', value)
emit('change', value)
}
// 初始化值
const initValue = () : void => {
let val : number = props.value
if (props.modelValue != 1) {
val = props.modelValue
}
inputValue.value = formatValue(val)
}
// 判断减号是否禁用
const minusDisabled = computed(() : boolean => {
const currentVal = parseFloat(inputValue.value)
if (isNaN(currentVal)) return true
return currentVal <= props.min || props.disabled
})
// 判断加号是否禁用
const plusDisabled = computed(() : boolean => {
const currentVal = parseFloat(inputValue.value)
if (isNaN(currentVal)) return true
return currentVal >= props.max || props.disabled
})
// 点击减号
const onMinus = () : void => {
if (props.disabled) return
const scale = getDecimalScale()
let currentVal = parseFloat(inputValue.value)
if (isNaN(currentVal)) {
currentVal = props.min
}
let value = currentVal * scale
const stepScaled = props.step * scale
value -= stepScaled
if (value < props.min * scale) return
if (value > props.max * scale) {
value = props.max * scale
}
const newValue = value / scale
inputValue.value = formatValue(newValue)
emitChange(newValue)
}
// 点击加号
const onPlus = () : void => {
if (props.disabled) return
const scale = getDecimalScale()
let currentVal = parseFloat(inputValue.value)
if (isNaN(currentVal)) {
currentVal = props.min
}
let value = currentVal * scale
const stepScaled = props.step * scale
value += stepScaled
if (value > props.max * scale) return
if (value < props.min * scale) {
value = props.min * scale
}
const newValue = value / scale
inputValue.value = formatValue(newValue)
emitChange(newValue)
}
// 失焦事件
const onBlur = (event : UniInputBlurEvent) : void => {
emit('blur', event)
let value = parseFloat(event.detail.value)
if (isNaN(value)) {
inputValue.value = formatValue(props.value)
return
}
if (value > props.max) {
value = props.max
} else if (value < props.min) {
value = props.min
}
inputValue.value = formatValue(value)
emitChange(value)
}
// 聚焦事件
const onFocus = (event : UniInputFocusEvent) : void => {
emit('focus', event)
}
// 监听 props 变化
watch(() : number | null => {
return props.modelValue ?? props.value ?? null
}, (newValue : number | null) => {
if (newValue != null) {
inputValue.value = formatValue(newValue)
}
})
// 初始化
initValue()
</script>
<style>
.uni-numbox {
flex-direction: row;
align-items: center;
/* #ifdef WEB */
user-select: none;
/* #endif */
}
.uni-numbox__btn {
width: 26px;
height: 26px;
background-color: #f5f5f5;
align-items: center;
justify-content: center;
/* #ifdef WEB */
cursor: pointer;
/* #endif */
}
.uni-numbox__minus {
border-top-left-radius: 2px;
border-bottom-left-radius: 2px;
}
.uni-numbox__plus {
border-top-right-radius: 2px;
border-bottom-right-radius: 2px;
}
.uni-numbox__icon-line {
position: absolute;
width: 10px;
height: 1px;
background-color: #333;
}
.uni-numbox__icon-line--vertical {
transform: rotate(90deg);
}
.uni-numbox__icon-line--disabled {
background-color: #c0c0c0;
}
.uni-numbox__input {
margin: 0 2px;
background-color: #f5f5f5;
width: 40px;
height: 26px;
text-align: center;
font-size: 14px;
border-width: 0;
color: #333;
}
</style>
+74
View File
@@ -0,0 +1,74 @@
{
"id": "uni-number-box-x",
"displayName": "uni-number-box 数字输入框",
"version": "1.0.1",
"description": "NumberBox 带加减按钮的数字输入框组件,用户可以控制每次点击增加的数值,支持小数。",
"isExt": true,
"extUrl": "https://ext.dcloud.net.cn/plugin?id=27896",
"keywords": [
"uni-ui",
"uniui",
"数字输入框",
"uni-app-x"
],
"repository": "https://gitcode.com/dcloud/uni-ui-x",
"engines": {
"HBuilderX": "",
"uni-app": "",
"uni-app-x": "^5.07"
},
"directories": {
"example": "../../temps/example_temps"
},
"dcloudext": {
"sale": {
"regular": {
"price": "0.00"
},
"sourcecode": {
"price": "0.00"
}
},
"contact": {
"qq": ""
},
"declaration": {
"ads": "无",
"data": "无",
"permissions": "无"
},
"type": "component-vue",
"darkmode": "x",
"i18n": "x",
"widescreen": "x",
"npmurl": ""
},
"uni_modules": {
"dependencies": [
],
"encrypt": [],
"platforms": {
"cloud": {
"tcb": "x",
"aliyun": "x",
"alipay": "x"
},
"client": {
"uni-app-x": {
"web": {
"safari": "√",
"chrome": "√"
},
"app": {
"android": "√",
"ios": "√",
"harmony": "√"
},
"mp": {
"weixin": "√"
}
}
}
}
}
}
+58
View File
@@ -0,0 +1,58 @@
带加减按钮的数字输入框,常见于购物车商品数量选择。
### 基本用法
```html
<uni-number-box v-model="value" />
```
### 设置范围
```html
<uni-number-box v-model="value" :min="5" :max="20" />
```
### 设置步长
```html
<!-- 整数步长 -->
<uni-number-box v-model="value" :step="5" />
<!-- 小数步长 -->
<uni-number-box v-model="value" :step="0.1" />
```
### 禁用状态
```html
<uni-number-box :value="5" :disabled="true" />
```
### 自定义样式
通过 `btn-class`、`input-class`、`icon-class` 自定义组件样式:
```html
<uni-number-box v-model="value" btn-class="custom-btn" input-class="custom-input" icon-class="custom-icon" />
```
```css
.custom-btn {
background-color: #007aff;
}
.custom-input {
background-color: #e8f4ff;
color: #007aff;
}
.custom-icon {
background-color: #fff;
}
```
### Events
| 事件名 | 说明 | 回调参数 |
| --- | --- | --- |
| change | 值改变时触发 | value: number |
| focus | 输入框聚焦时触发 | event: UniInputFocusEvent |
| blur | 输入框失焦时触发 | event: UniInputBlurEvent |
+4
View File
@@ -0,0 +1,4 @@
## 1.0.1(2026-05-11)
- 修复 starClass 和 starActiveClass 设置不生效的问题
## 1.0.0(2026-05-07)
- 支持 uni-rate 组件
@@ -0,0 +1,624 @@
<template>
<!-- TODO Android/iOS平台不支持裁剪显示半个文字,高性能场景包裹一层view不合适,需要等App平台解决-->
<text v-if="props.full!=true" class="active-color" :class="props.starActiveClass ?? ''" :style="props.starActiveStyle ?? ''">{{ stars }}</text> <!-- 高性能,单层,只读,不显示灰星,不支持小数。必须给value-->
<view v-else
<!-- #ifdef MP -->
:id="containerId"
<!-- #endif -->
style="position: relative;align-self: flex-start;" @click="onTap">
<text class="stars-pos gray-stars" style="white-space: nowrap;" :class="props.starClass ?? ''" :style="props.starStyle ?? ''">{{ stars }}</text> <!-- 背景灰星-->
<!-- Android/iOS平台不支持裁剪显示半个文字,需通过包裹一层view裁剪实现 -->
<view class="stars-pos active-init-width" :style="'width:'+calculatedWidth">
<text v-if="props.full" class="active-color" :class="props.starActiveClass ?? ''"
:style="'white-space: nowrap; align-self: flex-start;' + props.starStyle + ';' + props.starActiveStyle">★★★★★</text>
</view>
<!-- 前景亮星,宽度根据value变化-->
</view>
</template>
<script setup lang="ts">
/**
* Rate 评分
* @description 评分组件
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "5.31"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
*/
defineOptions({
externalClasses: ['star-class', 'star-active-class']
})
const props = defineProps<{
/**
* @description 当前评分,取值范围 0~5 的数字
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "5.31"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
*/
value?: number,
/**
* @description v-model 绑定值,支持双向绑定,优先级高于 value
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "5.31"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
*/
modelValue?: number,
/**
* @description 是否显示完整 5 颗星。为 true 时用灰星填充未选中位置;为 false 时仅显示亮星,性能更高但不支持点击交互
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "5.31"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
*/
full?: boolean,
/**
* @description 是否只读,为 true 时不响应点击评分(full 为 false 时始终只读)
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "5.31"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
*/
readonly?: boolean,
/**
* @description 灰星是否使用镂空样式(☆),为 false 时使用实心灰星(★)
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "5.31"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
*/
plain?: boolean,
/**
* @description 灰星(未选中)的自定义样式类
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "5.31"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
*/
starClass?: string.ClassString,
/**
* @description 灰星(未选中)的内联样式
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "5.31"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
*/
starStyle?: string
/**
* @description 亮星(选中)的自定义样式类
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "5.31"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
*/
starActiveClass?: string.ClassString,
/**
* @description 亮星(选中)的内联样式
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "5.31"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
*/
starActiveStyle?: string
}>()
// 计算星星显示
const stars = computed(() => {
if (props.full!=true) {
const value = Math.max(0, Math.min(5, props.value ?? 0))
return '★'.repeat(Math.round(value)) //返回亮星数量
} else {
return props.plain == true ? '☆'.repeat(5) : '★'.repeat(5) //返回灰星数量
}
})
// 问题:props.full != true 时,下面的Script其实都没有意义,但却需要执行。但vue里又无法用if包裹下面的代码,不灵活
const emit = defineEmits<{
/**
* @description 评分变化事件,参数为评分值,类型为 number
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "5.31"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
*/
change: [value: number]
/**
* @description v-model 绑定值变化事件,参数为评分值,类型为 number
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "5.31"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
*/
'update:modelValue': [value: number]
}>()
// #ifdef MP
const instance = getCurrentInstance()
const containerId = `uni_rate_${instance?.uid || 'default'}`
// #endif
// 响应式数据 - 优先使用modelValue,其次使用value
const currentValue = ref<number>(0)
currentValue.value = props.modelValue ?? props.value ?? 0
// 安全初始化函数
/* function safeInitValue() {
const initialValue = props.modelValue ?? props.value ?? 0
if (typeof initialValue === 'number' && !isNaN(initialValue)) {
currentValue.value = initialValue
} else {
currentValue.value = 0
}
}
// 初始化
safeInitValue() */
// 监听props变化
watch(() : number | null => {
return props.modelValue ?? props.value ?? null
}, (newValue : number | null) => {
if (newValue != null && typeof newValue === 'number' && !isNaN(newValue as number)) {
currentValue.value = newValue as number
}
}, { immediate: true })
// 计算点亮层的宽度
const calculatedWidth = computed(() => {
// 确保在 0 到 5 之间
const value = currentValue.value
const rate = Math.max(0, Math.min(5, value))
// console.log('rate: ',rate);
// TODO Android端小数无法正常渲染,不是宽度计算的问题
return `${(rate / 5) * 100}%`
})
// #ifndef MP
// 处理点击事件
function onTap(event: UniPointerEvent) : void {
// 如果是禁用模式,不处理点击
if (props.readonly == true) {
return
}
// 获取点击位置 - 优化的事件处理
let clickX = event.x
// 获取容器宽度 - 使用 currentTarget 获取绑定事件的 view 容器,而不是被点击的 text 元素
let containerWidth = 0
try {
if (event.currentTarget?.offsetWidth != null && (event.currentTarget as UniElement).offsetWidth > 0) {
containerWidth = (event.currentTarget as UniElement).offsetWidth
}
} catch (e) {
// 忽略错误,使用默认值
console.log('e: ',e);
}
// console.log("containerWidth:" + containerWidth);
// 计算点击的是第几颗星(从0开始)
const finalValue = Math.round(clickX / containerWidth * 5)
// console.log('finalValue: ',finalValue);
// 计算每颗星的宽度
// const starWidth = containerWidth / 5
// 计算点击位置在星内的偏移量
// const offsetInStar = (clickX % starWidth) / starWidth
/* // 根据偏移量决定是整星还是半星
let newValue: number
if (offsetInStar <= 0.5) {
newValue = starIndex + 0.5 // 半星
} else {
newValue = starIndex + 1 // 整星
}
// 确保值在有效范围内 */
// const finalValue = Math.max(0.5, Math.min(5, starIndex + 1))
// 更新值
currentValue.value = finalValue
// 触发change事件
emit('change', finalValue)
// 触发v-model更新
emit('update:modelValue', finalValue)
}
// #endif
// #ifdef MP
async function onTap(event: UniPointerEvent) : Promise<void> {
if (props.readonly == true) {
return
}
const rect = await uni.getElementById(containerId)?.getBoundingClientRectAsync()
if (rect == null || rect.width <= 0) {
return
}
const localX = event.clientX - rect.left
if (!isFinite(localX)) {
return
}
const clampedX = Math.max(0, Math.min(rect.width, localX))
const finalValue = Math.max(0, Math.min(5, Math.round(clampedX / rect.width * 5)))
currentValue.value = finalValue
emit('change', finalValue)
emit('update:modelValue', finalValue)
}
// #endif
// TODO 接入form表单规范,可以submit
</script>
<style>
.stars-pos {
/* 确保两个 star 的位置重叠 */
position: absolute;
top: 0;
left: 0;
overflow: hidden; /* 关键:裁剪点亮层 */
}
.gray-stars {
color: #ccc; /* 未点亮颜色 */
position: relative; /* 确保底层可见 */
}
.active-init-width {
width: 0%; /* full时高亮星的初始宽度 */
}
.active-color{
color: #ffca3e; /* 点亮颜色 */
}
</style>
+73
View File
@@ -0,0 +1,73 @@
{
"id": "uni-rate-x",
"displayName": "uni-rate 评分",
"version": "1.0.1",
"description": "Rate 评分组件,可自定义评分星星图标的大小、间隔、评分数。",
"isExt": true,
"extUrl": "https://ext.dcloud.net.cn/plugin?id=27900",
"keywords": [
"uni-ui",
"uniui",
"评分",
"uni-app-x"
],
"repository": "https://gitcode.com/dcloud/uni-ui-x",
"engines": {
"HBuilderX": "",
"uni-app": "",
"uni-app-x": "^5.07"
},
"directories": {
"example": "../../temps/example_temps"
},
"dcloudext": {
"sale": {
"regular": {
"price": "0.00"
},
"sourcecode": {
"price": "0.00"
}
},
"contact": {
"qq": ""
},
"declaration": {
"ads": "无",
"data": "无",
"permissions": "无"
},
"type": "component-vue",
"darkmode": "x",
"i18n": "x",
"widescreen": "x",
"npmurl": ""
},
"uni_modules": {
"dependencies": [],
"encrypt": [],
"platforms": {
"cloud": {
"tcb": "x",
"aliyun": "x",
"alipay": "x"
},
"client": {
"uni-app-x": {
"web": {
"safari": "√",
"chrome": "√"
},
"app": {
"android": "√",
"ios": "√",
"harmony": "√"
},
"mp": {
"weixin": "√"
}
}
}
}
}
}
+32
View File
@@ -0,0 +1,32 @@
评分组件,多用于购买商品后,对商品进行评价等场景
### 文件路径
项目下有一个 /pages/uni-ui/rate/rate.uvue 页面,里面演示了rate组件的各种用法。
### 设计
rate组件是投票评分使用的组件。它使用unicode字符实现,通过修改字体的样式,实现各种效果。
它分2个版本,一个版本是高性能版本,只有一层,不显示灰星,仅显示亮星,亮星不支持小数点,不能点击。适于长列表中显示。
另一个版本是普通版本,由背景灰星和前景亮星叠加而成,支持小数点,支持点击。
这2个版本通过属性full来切换。
本组件支持v-model绑定响应式数据。
rate组件有如下属性:
- full
是否显示全部5颗星。当黄色不够5星时,是否填充灰星以达到5星的固定长度。不填充灰星性能更高
- readonly
是否响应点击变化。full属性为false时不生效,即无灰星时一定只读
- plain
灰星是否镂空
- star-class
星星的样式
- star-style
星星的样式
- star-active-class
选中的星星的样式
- star-active-style
选中的星星的样式
rate组件有onChange事件,事件回调中会给出点击触发的新评分值。
+2
View File
@@ -0,0 +1,2 @@
## 1.0.0(2026-04-30)
- 支持 uni-refresh-box 组件
@@ -0,0 +1,526 @@
<template>
<view slot="refresher" class="uni-refresh-box-buildin"> <!-- TODO 鸿蒙需要在这个view上补属性slot="refresher",不认外层传入-->
<slot name="loading" :state="currentState">
<loading ref="loadingRef" :paused="currentState != 2" class="uni-loading-class-buildin" :class="loadingClass" bold></loading>
</slot>
<text class="uni-text-class-buildin" :class="textClass">{{ tipText }}</text>
</view>
</template>
<script setup lang="ts">
/**
* 自定义下拉刷新
* @description 自定义下拉刷新组件
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "x"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
*/
defineOptions({
externalClasses: ['loading-class', 'text-class']
})
const props = defineProps({
/**
* @description 当前下拉距离(px),通常由外部 scroll-view 的 refresher 状态传入
* @default 0
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "x"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
*/
pullingDistance: {
type: Number,
default: 0
},
/**
* @description 是否正在刷新中,外部控制;为 true 时显示 loading 动画
* @default false
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "x"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
*/
refreshing: {
type: Boolean,
default: false
},
/**
* @description 触发刷新的下拉阈值(px),下拉距离超过该值时进入“松手刷新”状态
* @default 45
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "x"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
*/
threshold: {
type: Number,
default: 45
},
/**
* @description 下拉过程中(未达到阈值)显示的提示文字
* @default "下拉刷新"
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "x"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
*/
pullingText: {
type: String,
default: '下拉刷新'
},
/**
* @description 下拉超过阈值后显示的提示文字
* @default "松手刷新"
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "x"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
*/
loosingText: {
type: String,
default: '松手刷新'
},
/**
* @description 刷新中显示的提示文字
* @default "正在刷新"
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "x"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
*/
loadingText: {
type: String,
default: '正在刷新'
},
/**
* @description 刷新完成瞬间显示的提示文字,为空则不展示
* @default ""
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "x"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
*/
completeText: {
type: String,
default: ''
},
/**
* @description 提示文字的自定义样式类
* @default ""
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "x"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
*/
textClass: {
type: String,
default: ''
},
/**
* @description loading 图标的自定义样式类
* @default ""
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "x"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
*/
loadingClass: {
type: String,
default: ''
}
})
// Data
const loadingRef = ref<UniElement | null>(null)
const resetting = ref(false)
// 标记是否正在归位中(从刷新结束到完全收起)
const restoring = ref(false)
// 状态:0-下拉中 1-松手可刷新 2-刷新中 3-刷新完成 4-归位中(不显示文字)
const currentState = computed((): number => {
if (resetting.value) {
return 3
}
if (props.refreshing) {
return 2
}
// 归位中不显示文字
if (restoring.value) {
return 4
}
if (props.pullingDistance > props.threshold) {
return 1
}
return 0
})
// 提示文字
const tipText = computed((): string => {
switch (currentState.value) {
case 0:
return props.pullingText
case 1:
return props.loosingText
case 2:
return props.loadingText
case 3:
return props.completeText
case 4:
return '' // 归位中不显示文字
default:
return props.pullingText
}
})
// 监听外部 refreshing 变化
watch((): boolean => props.refreshing, (newVal: boolean, oldVal: boolean) => {
if (!newVal && oldVal) {
// 外部结束刷新,显示完成状态
resetting.value = true
restoring.value = true
setTimeout(() => {
resetting.value = false
}, 300)
}
})
// 监听 pullingDistance 变化
watch((): number => props.pullingDistance, (distance: number, oldDistance: number) => {
// 归零时结束归位状态
if (distance == 0 && restoring.value) {
restoring.value = false
}
// 开始新的下拉时(从0变为大于0),重置归位状态
if (distance > 0 && oldDistance == 0 && !props.refreshing) {
restoring.value = false
}
// 更新 loading 旋转角度
const el = loadingRef.value
if (el != null && !props.refreshing) {
const maxDistance = 200
const maxRotation = 540
const rotation = Math.min((distance / maxDistance) * maxRotation, maxRotation)
el.style.setProperty('transform', `rotate(${rotation}deg)`)
}
})
// 暴露状态给外部
defineExpose({
currentState
})
</script>
<style>
.uni-refresh-box-buildin {
justify-content: center;
align-items: center;
flex-direction: row;
width: 100%;
height: 30px;
}
.uni-loading-class-buildin {
width: 14px;
height: 14px;
border-color: #888;
}
.uni-text-class-buildin {
color: #888;
font-size: 14px;
margin-left: 4px;
}
</style>
+73
View File
@@ -0,0 +1,73 @@
{
"id": "uni-refresh-box",
"displayName": "uni-refresh-box",
"version": "1.0.0",
"description": "uni-refresh-box",
"isExt": true,
"extUrl": "https://ext.dcloud.net.cn/plugin?id=27848",
"keywords": [
"uni-refresh-box",
"uni-app-x",
"uni-app x",
"组件"
],
"repository": "https://gitcode.com/dcloud/uni-ui-x",
"engines": {
"HBuilderX": "^5.07",
"uni-app-x": "^5.07",
"uni-app": ""
},
"directories": {
"example": "../../temps/example_temps"
},
"dcloudext": {
"type": "component-vue",
"sale": {
"regular": {
"price": "0.00"
},
"sourcecode": {
"price": "0.00"
}
},
"contact": {
"qq": ""
},
"declaration": {
"ads": "无",
"data": "无",
"permissions": "无"
},
"darkmode": "x",
"i18n": "x",
"widescreen": "x",
"npmurl": ""
},
"uni_modules": {
"dependencies": [],
"encrypt": [],
"platforms": {
"cloud": {
"tcb": "x",
"aliyun": "x",
"alipay": "x"
},
"client": {
"uni-app-x": {
"web": {
"safari": "√",
"chrome": "√"
},
"app": {
"android": "√",
"ios": "√",
"harmony": "√"
},
"mp": {
"weixin": "√"
}
}
}
}
}
}
+80
View File
@@ -0,0 +1,80 @@
自定义下拉刷新组件。
本组件封装了 scroll-view/list-view等滚动容器的下拉刷新功能,使用者只需在滚动容器中加入本组件,设置组件的属性和样式,即可实现自定义下拉刷新功能。
本组件自带一个全平台通用的下拉刷新样式,即左边一个 loading 圈,右边为下拉刷新相关的文字。也提供了各种自定义方法,包括文字、样式,甚至通过插槽传入完全不同的自定义下拉效果。
### 基本用法
```vue
<template>
<scroll-view
style="flex: 1;"
:refresher-enabled="true"
:refresher-triggered="refreshing1"
refresher-default-style="none"
:refresher-threshold="45"
refresher-max-drag-distance="200px"
@refresherpulling="onRefresherpulling1"
@refresherrefresh="onRefresherrefresh1"
@refresherrestore="onRefresherrestore1"
@refresherabort="onRefresherabort1"
>
<!-- 列表内容 -->
<view v-for="i in listCount1" :key="i" class="content-item">
<text class="text">item-{{ i }}</text>
</view>
<!-- refresher 插槽使用 uni-refresh-box 组件 -->
<uni-refresh-box
slot="refresher"
:pulling-distance="pullingDistance1"
:refreshing="refreshing1"
></uni-refresh-box>
</scroll-view>
</template>
<script setup lang="ts">
const listCount1 = ref(3)
const refreshing1 = ref(false)
const pullingDistance1 = ref(0)
function onRefresherpulling1(e: RefresherEvent) {
pullingDistance1.value = e.detail.dy
}
function onRefresherrefresh1() {
refreshing1.value = true
console.log('列表1 触发刷新')
setTimeout(() => {
listCount1.value += 5
refreshing1.value = false
console.log('列表1 刷新完成')
}, 1500)
}
function onRefresherrestore1() {
pullingDistance1.value = 0
}
function onRefresherabort1() {
pullingDistance1.value = 0
}
</script>
```
#### state 状态说明
| 值 | 说明 |
| :-- | :-- |
| 0 | 下拉中(未达到阈值) |
| 1 | 松手可刷新(已达到阈值) |
| 2 | 刷新中 |
| 3 | 刷新完成 |
| 4 | 归位中(不显示文字) |
### Slots
| 名称 | 说明 | 插槽参数 |
| :-- | :-- | :-- |
| loading | 自定义图标 | - |
+6
View File
@@ -0,0 +1,6 @@
## 1.0.2(2026-07-16)
- 修复微信小程序滚动内容穿透底部 tab-bar
## 1.0.1(2026-07-07)
- 优化组件的宽度计算方式
## 1.0.0(2026-04-30)
- 支持 uni-tab-bar 组件
@@ -0,0 +1,139 @@
<template>
<view class="uni-tab-list-root" :class="tabBarRootClass">
<slot></slot>
</view>
</template>
<script setup lang="ts">
/**
* 底部选项卡栏
* @description 底部选项卡栏组件
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "5.31"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
*/
const tabContentHeightFull = inject<Ref<boolean>>('uniTabContentHeightFull', ref(false))
const changeTab = inject<(index: number) => void>('uniTabBarChangeTab', (index: number) => {})
const itemCount = ref<number>(0)
const hasMidButtonItem = ref<boolean>(false)
const tabBarRootClass = computed(() : string => {
return tabContentHeightFull.value ? 'uni-tab-list-root-full' : 'uni-tab-list-root-normal'
})
const validMidButtonLayout = computed(() : boolean => {
return hasMidButtonItem.value == false || (itemCount.value > 0 && itemCount.value % 2 == 0)
})
function registerItem() : number {
const index = itemCount.value
itemCount.value++
return index
}
function registerMidButton(width : number, height : number) : void {
hasMidButtonItem.value = true
if (width <= 0) {
console.warn('uni-tab-midbutton: width 必须大于 0')
}
if (height <= 0) {
console.warn('uni-tab-midbutton: height 必须大于 0')
}
}
function syncOccupiedHeight() : void {
if (hasMidButtonItem.value && validMidButtonLayout.value == false) {
console.warn('uni-tab-bar: mid-button 需要偶数个普通 uni-tab-item,才能保持居中均分布局')
}
}
onMounted(() => {
syncOccupiedHeight()
})
watch(() : number => itemCount.value, () => {
syncOccupiedHeight()
})
watch(() : boolean => hasMidButtonItem.value, () => {
syncOccupiedHeight()
})
provide('uniTabListRegisterItem', registerItem)
provide('uniTabListRegisterMidButton', registerMidButton)
provide('uniTabListChangeTab', changeTab)
</script>
<style>
.uni-tab-list-root {
display: flex;
flex-direction: row;
width: 100%;
height: var(--uni-tab-bar-height, 50px);
flex-shrink: 0;
box-sizing: content-box;
padding-bottom: var(--uni-safe-area-inset-bottom);
justify-content: flex-start;
align-items: flex-end;
overflow: visible;
border-top-width: 0.5px;
border-top-color: rgba(0,0,0,0.33);
border-top-style: solid;
}
.uni-tab-list-root-normal {
position: relative;
z-index: 10;
background-color: #ffffff;
}
/* #ifdef MP-ALIPAY */
.uni-tab-list-root-normal {
position: fixed;
left: 0;
right: 0;
bottom: 0;
}
/* #endif */
.uni-tab-list-root-full {
position: absolute;
left: 0;
right: 0;
bottom: 0;
z-index: 10;
background-color: #ffffff;
}
</style>
@@ -0,0 +1,104 @@
<template>
<view v-if="hasRendered" class="uni-tab-content-root"
:style="contentStyle">
<slot></slot>
</view>
</template>
<script setup lang="ts">
/**
* 选项卡内容
* @description 选项卡内容组件
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "5.31"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
*/
// 从父组件注入数据和方法
const currentIndex = inject<Ref<number>>('uniTabBarCurrentIndex', ref(0))
const registerContent = inject<() => number>('uniTabBarRegisterContent', () => 0)
const tabContentHeightFull = inject<Ref<boolean>>('uniTabContentHeightFull', ref(false))
// 当前 content 的索引
const contentIndex = ref<number>(-1)
// 是否已经渲染过(按需加载)
const hasRendered = ref<boolean>(false)
// 组件挂载时分配索引
onMounted(() => {
contentIndex.value = registerContent()
// 如果是第一个激活的 tab-content,立即渲染
if (contentIndex.value == currentIndex.value) {
hasRendered.value = true
}
})
// 计算是否激活
const isActive = computed((): boolean => {
return currentIndex.value == contentIndex.value
})
// 监听激活状态,首次激活时渲染
watch((): boolean => {
return isActive.value
}, (newValue: boolean) => {
if (newValue && hasRendered.value == false) {
hasRendered.value = true
}
})
const contentStyle = computed(() => {
const active = isActive.value
return {
visibility: active ? 'visible' : 'hidden',
position: active ? 'relative' : 'absolute',
left: active ? 'auto' : '0px',
top: active ? 'auto' : '0px',
right: active ? 'auto' : '0px',
bottom: active ? 'auto' : (tabContentHeightFull.value ? '0px' : 'auto')
}
})
</script>
<style>
.uni-tab-content-root {
flex: 1;
min-height: 0px;
width: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
z-index: 0;
}
</style>
@@ -0,0 +1,187 @@
<template>
<view class="uni-tab-list-item-root" @click.stop="handleClick">
<slot></slot>
<text v-if="props.badgeText != null && props.badgeText != '0' && props.badgeText != ''" class="uni-tab-list-item-badge-position uni-tab-list-item-badge-text" :class="props.badgeClass">{{ props.badgeText }}</text>
<view v-else-if="props.badgeText != null && props.badgeText != '0'" class="uni-tab-list-item-badge-position uni-tab-list-item-badge-dot" :class="props.badgeClass"></view>
</view>
</template>
<script setup lang="ts">
/**
* 选项卡项
* @description 选项卡项组件
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "5.31"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
*/
defineOptions({
externalClasses: ['badge-class']
})
// 定义组件属性
type Props = {
/**
* @description badge 的内容
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "5.31"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
*/
badgeText?: string,
/**
* @description badge 的样式类
* @uniPlatform {
* "app": {
* "android": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.21"
* },
* "ios": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.11"
* },
* "harmony": {
* "uniVer": "x",
* "unixVer": "5.07",
* "unixVaporVer": "5.07"
* }
* },
* "mp": {
* "weixin": {
* "uniVer": "x",
* "unixVer": "5.07"
* },
* "alipay": {
* "uniVer": "x",
* "unixVer": "5.31"
* }
* },
* "web": {
* "uniVer": "x",
* "unixVer": "5.07"
* }
* }
*/
badgeClass?: string.ClassString,
}
const props = withDefaults(defineProps<Props>(), {
badgeClass: ''
})
const registerItem = inject<() => number>('uniTabListRegisterItem', () => 0)
const changeTab = inject<(index: number) => void>('uniTabListChangeTab', (index: number) => {})
const itemIndex = ref<number>(-1)
onMounted(() => {
itemIndex.value = registerItem()
})
function handleClick() : void {
if (itemIndex.value >= 0) {
changeTab(itemIndex.value)
}
}
</script>
<style>
.uni-tab-list-item-root {
flex: 1;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
position: relative;
overflow: visible;
}
.uni-tab-list-item-badge-position {
position: absolute;
top: 7px;
left: 50%;
transform: translate(25%, -50%);
}
.uni-tab-list-item-badge-text {
background-color: rgb(225, 80, 80);
min-width: 14px;
height: 14px;
line-height: 14px;
border-radius: 7px;
font-size: 10px;
color: white;
text-align: center;
white-space: nowrap;
}
.uni-tab-list-item-badge-dot {
background-color: rgb(225, 80, 80);
width: 8px;
height: 8px;
border-radius: 4px;
margin-left: 7px;
}
</style>

Some files were not shown because too many files have changed in this diff Show More