hookehuyr

Merge branch 'feature/多附件上传功能' into develop

......@@ -15,3 +15,6 @@ VITE_CONSOLE = 0
# appID相关
VITE_APPID=微信appID
# 是否开启多附件功能
VITE_CHECKIN_MULTI_ATTACHMENT = 0
......
美乐爱觉项目
# 美乐爱觉项目(mlaj)
测试环境网站
https://oa-dev.onwall.cn/f/mlaj
测试环境网站:<https://oa-dev.onwall.cn/f/mlaj>
## 项目概览
本项目是面向移动端(以微信内置浏览器为主)的教育业务 H5,覆盖「用户登录/微信授权 → 课程浏览/购买 → 学习与内容预览 → 打卡作业(学生端/教师端)→ 活动报名 → 召回促活 → 积分/订单」等完整链路。
## 技术栈
- Vue 3 + Vite 6(Hash 路由)
- UI:Vant 4 + TailwindCSS 3 + Less(Tailwind 负责布局,Less 做补充)
- 路由:Vue Router 4(带全局守卫与白名单/Meta 双策略鉴权)
- 请求:Axios(统一注入认证头、统一 401 策略)
- 测试:Vitest(已有 composables/utils 单测)
- 微信生态:weixin-js-sdk、OAuth/OpenID 登录态探测与手动触发授权、微信支付
- 媒体与文档:Video.js、PDF/Office 预览组件
## 项目结构(快速索引)
```
src/
├── api/ # 按业务域拆分的接口封装(auth/course/checkin/teacher/...)
├── components/ # 组件(按业务域归类)
├── composables/ # 组合式函数(逻辑复用,含单测)
├── contexts/ # 全局状态(provide/inject:auth/cart)
├── router/ # 路由定义与守卫
├── utils/ # 工具层(axios、上传、鉴权存储、版本更新等)
└── views/ # 页面(按业务域归类)
```
## 可复用能力清单
- 认证与登录态:[/src/contexts/auth.js](file:///Users/huyirui/program/itomix/git/mlaj/src/contexts/auth.js)
- 购物车:[/src/contexts/cart.js](file:///Users/huyirui/program/itomix/git/mlaj/src/contexts/cart.js)
- Axios 统一策略:[/src/utils/axios.js](file:///Users/huyirui/program/itomix/git/mlaj/src/utils/axios.js)
- 微信能力封装:[/src/router/guards.js](file:///Users/huyirui/program/itomix/git/mlaj/src/router/guards.js)[/src/api/wx](file:///Users/huyirui/program/itomix/git/mlaj/src/api/wx)
- 上传与文件处理:[/src/utils/upload.js](file:///Users/huyirui/program/itomix/git/mlaj/src/utils/upload.js)[/src/utils/qiniuFileHash.js](file:///Users/huyirui/program/itomix/git/mlaj/src/utils/qiniuFileHash.js)
- 内容预览组件:[/src/components/media](file:///Users/huyirui/program/itomix/git/mlaj/src/components/media)
- 滚动/播放/图片容错等组合式函数:[/src/composables](file:///Users/huyirui/program/itomix/git/mlaj/src/composables)
- 版本更新提示:[/src/utils/versionUpdater.js](file:///Users/huyirui/program/itomix/git/mlaj/src/utils/versionUpdater.js)
## 项目优缺点(基于现状扫描)
### 优点
- 业务域拆分清晰:api / views / components / composables 的分层直观
- 复用意识强:打卡、媒体预览、海报、鉴权与请求拦截均做了抽象
- 微信场景适配完整:授权、JSSDK、支付链路齐全,并避免路由守卫自动授权死循环
- 具备基础测试资产:composables/utils 已引入 Vitest 并有用例
### 缺点与风险
- 文档与代码存在不一致:README/CLAUDE 中的组件目录、路径描述与实际目录不一致
- 依赖与工程规范不够统一:同时存在 pnpm/yarn/npm 的 lock 文件,容易引起依赖漂移
- 状态来源较多:localStorage + contexts + axios 默认头并存,一致性风险偏高
- 全局屏蔽 warnHandler:[/src/main.js](file:///Users/huyirui/program/itomix/git/mlaj/src/main.js) 会吞掉 Vue 警告,可能掩盖潜在问题
- 布局目录已归一:统一使用 [/src/components/layout](file:///Users/huyirui/program/itomix/git/mlaj/src/components/layout),已移除 /src/layouts
## 文档索引
- 近期功能更新记录:[/docs/CHANGELOG.md](file:///Users/huyirui/program/itomix/git/mlaj/docs/CHANGELOG.md)
- 架构实现与工程配置:[/docs/ARCHITECTURE.md](file:///Users/huyirui/program/itomix/git/mlaj/docs/ARCHITECTURE.md)
- /src/components 组件目录索引:[/docs/COMPONENTS.md](file:///Users/huyirui/program/itomix/git/mlaj/docs/COMPONENTS.md)
- 代码风格与最佳实践:[/VUE_CODE_STYLE_GUIDE.md](file:///Users/huyirui/program/itomix/git/mlaj/VUE_CODE_STYLE_GUIDE.md)
- 已知问题与改进建议:[/ISSUES_TO_FIX.md](file:///Users/huyirui/program/itomix/git/mlaj/ISSUES_TO_FIX.md)
- 协作说明(较长):[/CLAUDE.md](file:///Users/huyirui/program/itomix/git/mlaj/CLAUDE.md)
## 业务系统架构
......@@ -62,222 +125,16 @@ https://oa-dev.onwall.cn/f/mlaj
- **订单**:查看订单状态(待支付/已支付/退款),支持取消订单。
- **积分**:查看积分余额与变动明细,积分可用于抵扣或兑换(视具体业务规则)。
## 核心技术栈与实现
### 状态管理
- **Context API**:项目主要使用 Vue 3 `provide/inject` 模式进行全局状态管理。
- `auth.js`:用户认证、登录态、Token 管理。
- `cart.js`:购物车逻辑、商品增删改、结算流程。
- **持久化**:关键状态同步至 `localStorage`,页面刷新不丢失。
### 文件处理
## 架构实现与工程配置
- **七牛云上传**
- 前端计算文件 Hash (`browser-md5-file` / `qiniuFileHash`)。
- 申请上传 Token(支持 Hash 秒传检测)。
- 直传七牛云对象存储。
- 回填业务服务器保存元数据。
- **文档预览**
- PDF: `@sunsetglow/vue-pdf-viewer`
- Office: `@vue-office/docx`, `@vue-office/excel`, `@vue-office/pptx`
- Video: `@videojs-player/vue` (Video.js 7.x)
### 路由与权限
- **路由守卫** (`guards.js`):
- `checkAuth`:基于白名单或 `meta.requiresAuth` 拦截未登录访问。
- `startWxAuth`:按需触发微信授权流程(非自动触发,避免死循环)。
## 目录结构说明
```
src/
├── api/ # API 接口层
│ ├── auth.js # 认证
│ ├── checkin.js # 打卡/作业
│ ├── course.js # 课程
│ ├── teacher.js # 教师端
│ ├── recall_users.js # 召回系统
│ └── ...
├── components/ # UI 组件
│ ├── ui/ # 通用组件 (VideoPlayer, CheckInDialog, SearchBar)
│ ├── checkin/ # 打卡业务组件
│ ├── teacher/ # 教师端组件
│ └── ...
├── composables/ # 逻辑复用 (useCheckin, useAuth, useStudyRecordTracker)
├── contexts/ # 全局状态 (auth, cart)
├── router/ # 路由配置
│ ├── checkin.js # 打卡路由
│ ├── teacher.js # 教师路由
│ └── ...
├── views/ # 页面视图
│ ├── auth/ # 登录/注册
│ ├── checkin/ # 打卡相关页面 (Index, Detail, Upload)
│ ├── courses/ # 课程相关页面
│ ├── teacher/ # 教师端页面 (Task, Student, Class)
│ ├── recall/ # 召回系统页面
│ ├── profile/ # 个人中心
│ └── ...
└── ...
```
实现细节(登录态注入、401 策略、微信授权、上传/预览、Vite 代理与环境变量等)已迁移到:[/docs/ARCHITECTURE.md](file:///Users/huyirui/program/itomix/git/mlaj/docs/ARCHITECTURE.md)
## 功能更新记录 (Recent Changes)
- **打卡详情页重构 (`/checkin/detail`)**
- 统一了文本、媒体上传和计数打卡的入口。
- 实现了基于 `useCheckin` 的通用提交流程。
- 优化了附件预览和编辑回填逻辑。
- **教师端功能完善**
- 新增作业管理、作业主页(统计与日历视图)、学员作业记录页面。
- **基础体验优化**
- 登录逻辑调整:仅在点击微信图标时触发授权。
- 搜索栏优化:支持 iOS 软键盘搜索键。
- 课程详情页:增加动态 Open Graph 标签,优化分享体验。
功能更新记录
- 教师端新增作业管理页面:路径 `/teacher/tasks`,标题“作业管理”。
- 列表展示:作业名称、开始时间、截止时间。
- 当前数据来源为Mock,后续可替换为真实接口数据。
- 教师端新增作业主页:路径 `/teacher/tasks/:id`,标题“作业主页”。
- 头部:作业名称、介绍文案、细项信息(周期、频次、时间段、附件类型)。
- 统计:出勤率与任务完成率(参考 `myClassPage.vue` 统计样式,数据Mock)。
- 日历:使用 `van-calendar` 单选模式,选择日期后展示当日学生完成情况。
- 学生完成情况:参考图片2样式,勾选代表已完成,未勾选代表未完成(数据Mock)。
- 教师端新增学员作业记录页面:路径 `/teacher/student-record`,标题“学员作业记录”。
- 在作业主页的学生列表点击卡片可跳转至该页面(当前版本为固定示例页面)。
- 列表展示:作业帖子、图片/视频/音频、点赞与点评弹窗(与 `studentPage.vue` 的作业记录样式一致)。
- 接口参数固定:`user_id=817017``group_id=816653`(后续可替换为动态参数)。
- 学习详情页标签指示条修复:`/src/views/profile/StudyCoursePage.vue`
- 现象:首次进入且存在“打卡互动”时,底部绿色指示条定位错误。
- 修复:新增标签容器 `ref``ResizeObserver`,按栏目数量对容器进行等分,指示条宽度与位移按分段和索引计算,异步加载第三个栏目时不再错位。
- 登录逻辑调整:仅在登录页微信图标点击时触发授权
- 变更文件:`/src/views/auth/LoginPage.vue``/src/router/guards.js``/src/router/index.js`
- 路由守卫:移除自动微信授权检查,新增 `startWxAuth` 供手动触发。
- 登录页:微信图标绑定点击事件,非微信环境提示“请在微信内打开”。
- 使用方式:进入登录页,点击微信图标进行授权登录。
- 课程详情页动态 Open Graph 元标签
- 行为:进入课程详情页时,在 `<head>` 中插入 4 个 `meta` 标签:`og:title`(课程 `title`)、`og:description`(课程 `subtitle`)、`og:image`(课程 `cover`)、`og:url`(当前页面 URL);这些 `meta` 必须插在 `<title>` 标签的前面;离开页面时移除。
- CDN 规则:若图片域名为 `cdn.ipadbiz.cn`,自动追加 `?imageMogr2/thumbnail/200x/strip/quality/70`
- 位置:`/src/views/courses/CourseDetailPage.vue`,在 `onMounted` 插入,`onUnmounted` 清理。
- 函数:`build_og_image_url(src)``set_og_meta(payload)``remove_og_meta()`
- 课程详情页咨询弹窗(Mock)
- 入口:详情页顶部快捷操作中的“咨询”按钮。
- 展示:底部弹出层,仅底部关闭按钮;展示联系人列表,可点击名称或号码直接拨打(`tel:`)。
- 联系人:支持多个联系人,展示姓名与号码;电话号码使用绿色高亮;联系人与号码字体大小一致。
- 位置:`/src/views/courses/CourseDetailPage.vue`,“咨询弹窗”模板与交互逻辑(`open_consult_dialog``close_consult_dialog``consult_contacts``call_phone(phone)`)。
- 购买流程环境校验
- 行为:仅对非免费课程在详情页点击“购买”时进行校验;生产环境下必须为微信内置浏览器(`wxInfo().isWeiXin`)。
- 免费课程:跳过微信环境校验,允许直接进入结算流程。
- 非微信环境(付费课):提示“请在微信内打开进行购买”,不进入结算。
- 位置:`/src/views/courses/CourseDetailPage.vue``handlePurchase` 中,使用 `wxInfo` 进行环境判断。
- 微信授权自动触发(微信环境内)
- 行为:在微信内置浏览器环境下点击“购买”时,若检测到未完成微信授权(`openid_has=false`),系统将自动发起一次微信授权流程并中止本次购买;授权完成后再次点击可进入结算。
- 开发环境:不触发微信授权流程(保留现有调试行为)。
- 位置:`/src/views/courses/CourseDetailPage.vue``handlePurchase` 中,调用 `getAuthInfoAPI()` 探测并使用 `startWxAuth()` 触发授权。
- 401拦截策略优化(公开页面不再跳登录)
- 行为:接口返回 `code=401` 时,不再对公开页面(如课程详情 `/courses/:id`)进行登录重定向;仅当当前路由确实需要登录权限时才跳转至登录页。
- 原理:响应拦截器调用路由守卫 `checkAuth` 判断当前路由是否为受限页面,受限则清理登录信息并附带 `redirect` 重定向至登录页;公开页面保持当前页,由业务自行处理401。
- 位置:`/src/utils/axios.js`,在响应拦截器中按需处理重定向。
- 搜索栏回车搜索兼容性提升
- 行为:将输入框类型改为 `search`,并可选开启 `form @submit.prevent` 机制以提升 iOS 设备软键盘“搜索”键触发稳定性;同时保留 `@keyup.enter` 回车触发搜索。
- 路由行为:
- 课程页(`isCoursePage=true`):回车或搜索键触发后跳转至 `/courses-list` 并携带 `keyword` 参数。
- 列表页:仅更新当前路由的查询参数 `keyword`
- 位置:`/src/components/ui/SearchBar.vue`,新增 `useFormSubmit` 可选参数(默认开启)。
- 课程列表页搜索触发修复
- 现象:进入列表页后,输入关键字并更新为 `/courses-list?keyword=xxx` 时未触发搜索。
- 原因:父组件 `handleSearch` 对“相同关键字”进行了拦截,导致路由参数变化不执行请求。
- 修复:移除拦截逻辑;无论关键字是否变化均触发搜索(防抖控制频率)。
- 位置:`/src/views/courses/CourseListPage.vue``handleSearch`
- 分享海报弹窗(通用组件)
- 入口:课程详情页底部操作栏“分享”按钮。
- 组件:`/src/components/ui/SharePoster.vue`(支持复用),`v-model:show` 控制显隐,`course` 传入课程信息,`qr_url` 可指定二维码内容地址(默认取当前页面 URL)。
- 布局:上部封面图;下部信息区左侧二维码,右侧课程标题、副标题、精简介绍与日期范围。
- 样式:优先使用 TailwindCSS 布局;组件内部使用 Less 做层级嵌套的样式补充。
- 图片规则:当封面图域名为 `cdn.ipadbiz.cn` 时,自动追加 `?imageMogr2/thumbnail/200x/strip/quality/70` 压缩参数。
- 接入位置:`/src/views/courses/CourseDetailPage.vue`,导入并渲染 `<SharePoster v-model:show="show_share_poster" :course="course" />`
- Canvas 合成:弹窗打开时使用 Canvas 直接合成海报(封面图、二维码、文案),生成 `dataURL` 并以 `<img>` 展示,用户可直接长按图片保存到手机(无需额外按钮)。
- 依赖:`pnpm add qrcode`(在 Canvas 内本地生成二维码,避免跨域图片导致画布污染)。
- 跨域:通过 `crossorigin="anonymous"` 加载封面,并追加时间戳防缓存;若封面跨域不允许,则显示降级卡片,仍可长按截图保存。
- 文案:使用中文字体并自动换行限制行数,末行超出追加省略号。
- 打卡弹窗统一为通用组件 CheckInDialog
- 目的:统一 CourseDetailPage、StudyCoursePage、StudyDetailPage 三处页面的打卡弹窗与交互,避免重复逻辑。
- 组件:`/src/components/ui/CheckInDialog.vue``v-model:show` 控制显隐;支持外部传入任务列表。
- Props:
- `items_today`:今日打卡任务数组(外部传入)。
- `items_history`:历史打卡任务数组(外部传入)。
- 数据结构:每项需包含 `id``title(name)``task_type``checkin`/`upload`)、`is_gray`
- 使用位置:
- `/src/views/courses/CourseDetailPage.vue`
- `/src/views/profile/StudyCoursePage.vue`
- `/src/views/study/StudyDetailPage.vue`
- 清理:上述页面已移除旧弹窗的冗余状态与方法(如 `default_list``showTaskList``showTimeoutTaskList``selectedCheckIn` 等),统一由组件内部处理。
- 打卡列表组件 CheckInList(复用)
- 目的:抽取首页与弹窗内重复的“打卡类型列表 + 提交按钮”UI与交互逻辑,提升复用性与维护性。
- 位置:`/src/components/ui/CheckInList.vue`,样式补充:`/src/components/ui/CheckInList.less`(使用 Less 层级嵌套)。
- Props:
- `items`:打卡任务数组,元素包含 `id``name``task_type``checkin`/`upload`)、`is_gray`
- `dense`:是否使用更紧凑的栅格与间距。
- `scroll`:是否启用滚动容器(最大高度 13rem)。
- Emits:
- `submit-success`:提交成功后触发,由父组件决定后续行为(轻提示、关闭弹窗等)。
- 行为:
- 点击置灰的 `checkin` 项时提示“您已经完成了今天的打卡”。
- 点击 `upload` 类型时跳转到 `/checkin/index?id=xxx` 上传页面。
- 选择 `checkin` 类型后显示提交按钮,点击后调用接口提交;成功后抛出 `submit-success` 并重置选中项。
- 使用位置:
- 首页:`/src/views/HomePage.vue`(替换原重复 UI,监听 `submit-success` 显示“打卡成功”)。
- 弹窗:`/src/components/ui/CheckInDialog.vue`(以 `active_list` 作为数据源,监听 `submit-success` 转发为 `check-in-success` 并延时关闭)。
- 打卡详情页(统一提交/编辑页)CheckinDetailPage
- 路径:`/checkin/detail`,页面文件:`/src/views/checkin/CheckinDetailPage.vue`
- 目标:统一“提交作业/打卡”的入口,覆盖文本/图片/视频/音频上传,以及计数打卡;同时承接“编辑已提交动态”的入口。
- 路由参数约定(query):
- 新增提交:`task_id`(大作业ID)、`subtask_id`(可选,默认选中小作业)、`date`(YYYY-MM-DD,用于补卡与月份计算)、`is_patch`(是否补卡:'1'/'0')、`task_type`(当前作业类型,常见为 `upload`/`count`)。
- 编辑提交:额外携带 `status=edit``post_id`(打卡动态ID)、`type`(原 `file_type`,实际以详情接口回填为准)。
- 初始化数据:
- 大作业详情:调用 `getTaskDetailAPI({ i: task_id, month })` 获取作业描述、默认附件类型、完成状态等。
- 小作业列表:调用 `getSubtaskListAPI({ task_id, date })` 获取小作业列表并生成 Picker 选项(包含 `note/field_list/person_type/attachment_type/is_makeup` 等)。
- 编辑回填:通过 `useCheckin().initEditData``getUploadTaskInfoAPI({ i: post_id })` 回填 `note/file_type/files/subtask_id/gratitude_count` 等。
- 附件类型与上传限制:
- `normalizeAttachmentTypeConfig` 解析后端 `attachment_type` 配置,生成 `attachmentTypeOptions`,并通过 `setMaxFileSizeMbMap` 写入不同类型的最大上传大小。
- 计数打卡(`task_type=count`)下过滤掉 `text` 类型;非计数模式下当 `activeType` 不在选项中时自动重置为第一个可用类型。
- 提交流程(核心封装在 `useCheckin`):
- 上传:`beforeRead/afterRead` 先校验数量/大小/类型,再走七牛上传(hash 去重 → token → upload → saveFile),成功回填 `meta_id/url/status`
- 提交:`onSubmit` 新增走 `addUploadTaskAPI`,编辑走 `editUploadTaskInfoAPI`;成功后写 `sessionStorage.checkin_refresh_flag/checkin_refresh_id` 供列表页局部刷新,并 `router.back()` 返回。
- 页面层校验:文本打卡至少 10 字;计数打卡必须选择对象且次数 > 0;非文本类型必须有文件。
- 计数打卡交互:
- 通过 `reuseGratitudeFormAPI({ subtask_id })` 获取可选对象列表 `targetList` 与最近使用 `last_used_list`,进入页面会自动勾选最近使用项。
- 选中对象首次会弹窗确认(标记 `has_confirmed`),支持新增/编辑;删除对象调用 `gratitudeDeleteAPI`(当前页面内未引入该 API,需补齐后才可生效)。
- 预览能力:
- 上传组件点击预览时,根据扩展名识别:音频使用 `AudioPlayer` 底部弹窗;视频使用 `VideoPlayer` 居中弹窗(封面 → 点击播放 → 关闭时重置进度);图片使用 `van-image-preview`
详细记录已迁移到:[/docs/CHANGELOG.md](file:///Users/huyirui/program/itomix/git/mlaj/docs/CHANGELOG.md)
## /src/components 目录下组件
| 目标目录(src/components/) | 包含组件 | 说明 |
| --------------------------- | ------------------------------------------------------------ | ---------------- |
| `checkin/` | `CheckInDialog.vue`, `CheckInList.vue`, `CheckInResult.vue` | 打卡相关组件 |
| `media/` | `AudioPlayer.vue`, `VideoPlayer.vue`, `MusicPlayer.vue` | 音视频播放组件 |
| `activity/` | `ActivityApplyHistoryPopup.vue`, `ActivityCard.vue`, `ActivityStatusBadge.vue`, `ActivityTicket.vue` | 活动相关组件 |
| `common/` | `ConfirmDialog.vue`, `GradientHeader.vue`, `MenuItem.vue`, `SearchBar.vue`, `TermsPopup.vue`, `UserAgreement.vue` | 通用基础组件 |
| `effects/` | `FrostedGlass.vue`, `LoadingSpinner.vue` | 视觉特效组件 |
| `courses/` | `CourseCard.vue`, `LiveStreamCard.vue` | 课程展示组件 |
| `payment/` | `WechatPayment.vue` | 支付组件 |
| `studyDetail/` | `StudyMaterialsPopup.vue` | 学习资料弹窗 |
| `layout/` | `AppLayout.vue`, `BottomNav.vue` | 布局与导航 |
| `share/` | `SharePoster.vue` | 分享海报 |
| `files/` | `FilePreview.vue` | 文件预览 |
| `feedback/` | `FeedbackForm.vue` | 反馈表单 |
组件目录索引已迁移到:[/docs/COMPONENTS.md](file:///Users/huyirui/program/itomix/git/mlaj/docs/COMPONENTS.md)
---
......
......@@ -204,7 +204,7 @@ Vue 官方建议在 SFC + Composition API 场景使用 `<script setup>`,因为
4) 清理“重复/并存”的实现:避免同名组件在不同目录各自演进
- 目标:减少认知负担与误用风险(例如存在两个 `AppLayout`
- 涉及文件:[layouts/AppLayout.vue](file:///Users/huyirui/program/itomix/git/mlaj/src/layouts/AppLayout.vue)[components/layout/AppLayout.vue](file:///Users/huyirui/program/itomix/git/mlaj/src/components/layout/AppLayout.vue)
- 涉及文件:[AppLayout.vue](file:///Users/huyirui/program/itomix/git/mlaj/src/components/layout/AppLayout.vue)(已完成归一,移除 /src/layouts)
5) 降低“语法形态”混用成本:能不用 JSX 就别用
......
# 架构实现与工程配置
## 入口与初始化
- 应用入口:[/src/main.js](file:///Users/huyirui/program/itomix/git/mlaj/src/main.js)
- 创建 App、注册全局 Icon 组件、挂载路由
- 全局注入 axios 到 app.config.globalProperties.$http
- 根组件:[/src/App.vue](file:///Users/huyirui/program/itomix/git/mlaj/src/App.vue)
- 初始化全局认证与购物车:provideAuth / provideCart
- 生产环境 + 微信环境:初始化微信 JSSDK(配置签名 URL)
- 生产环境:版本更新探测(弹窗提示刷新)
## 路由与权限
- 路由入口:[/src/router/index.js](file:///Users/huyirui/program/itomix/git/mlaj/src/router/index.js)
- Hash 路由:createWebHashHistory(import.meta.env.VITE_BASE || '/')
- beforeEach:统一登录页回跳处理,并在必要时探测“是否已登录”
- 鉴权策略:[/src/router/guards.js](file:///Users/huyirui/program/itomix/git/mlaj/src/router/guards.js)
- 白名单 + meta.requiresAuth 双策略判断
- 未登录时重定向 /login 并带 redirect
- 微信授权策略:[/src/router/guards.js](file:///Users/huyirui/program/itomix/git/mlaj/src/router/guards.js)
- 不在路由守卫自动触发授权,避免循环
- 仅在用户触发(如点击微信图标/购买流程探测)时调用 startWxAuth
## 请求与登录态注入
- Axios 封装:[/src/utils/axios.js](file:///Users/huyirui/program/itomix/git/mlaj/src/utils/axios.js)
- 请求拦截:动态读取本地 user_info 并注入 User-Id / User-Token
- 响应拦截:code=401 时,仅当当前路由确实需要登录才跳转登录(公开页面不强制跳转)
- 登录态管理:[/src/contexts/auth.js](file:///Users/huyirui/program/itomix/git/mlaj/src/contexts/auth.js)
- provide/inject 维护 currentUser/loading/login/logout
- localStorage 持久化 currentUser
- 初始化流程中会探测授权/登录态并拉取用户信息
## 购物车与结算
- 购物车上下文:[/src/contexts/cart.js](file:///Users/huyirui/program/itomix/git/mlaj/src/contexts/cart.js)
- 单品/多品两种模式(App.vue 默认使用单品模式)
- localStorage 带时间戳的过期策略(默认一天过期)
- handleCheckout 负责构建订单数据并提交订单
## 上传与预览
- 上传工具:[/src/utils/upload.js](file:///Users/huyirui/program/itomix/git/mlaj/src/utils/upload.js)[/src/utils/qiniuFileHash.js](file:///Users/huyirui/program/itomix/git/mlaj/src/utils/qiniuFileHash.js)
- 前端计算文件 Hash,支持“秒传检测 + 直传对象存储”
- 关键业务复用:[/src/composables/useCheckin.js](file:///Users/huyirui/program/itomix/git/mlaj/src/composables/useCheckin.js)
- 打卡/作业提交流程:校验、上传、提交、编辑回填等
- 预览组件:[/src/components/media](file:///Users/huyirui/program/itomix/git/mlaj/src/components/media)
- 视频/音频播放器、PDF/Office 预览等
## Vite 配置与环境变量
- Vite 配置:[/vite.config.js](file:///Users/huyirui/program/itomix/git/mlaj/vite.config.js)
- 自动按需引入 Vant 组件(unplugin-auto-import / unplugin-vue-components)
- 别名:@ / @components / @utils / @api 等
- 本地代理:createProxy(viteEnv.VITE_PROXY_PREFIX, viteEnv.VITE_PROXY_TARGET)
- 环境变量示例:[.env](file:///Users/huyirui/program/itomix/git/mlaj/.env)
- VITE_PORT:开发端口
- VITE_PROXY_TARGET / VITE_PROXY_PREFIX:接口代理目标与前缀
- VITE_OUTDIR:构建输出目录
- VITE_CONSOLE:调试开关
## 目录结构(详细)
```
mlaj/
├── build/ # Vite 代理封装
├── docs/ # 项目文档(本目录)
├── public/ # 静态资源
├── src/
│ ├── api/ # 按业务域拆分的接口封装(auth/course/checkin/teacher/...)
│ ├── assets/ # 图片等资源
│ ├── common/ # 常量
│ ├── components/ # 组件(按业务域归类)
│ ├── composables/ # 组合式函数(逻辑复用,含单测)
│ ├── contexts/ # 全局状态(provide/inject:auth/cart)
│ ├── router/ # 路由定义与守卫
│ ├── utils/ # 工具层(axios、上传、鉴权存储、版本更新等)
│ └── views/ # 页面(按业务域归类)
├── tailwind.config.js # Tailwind 配置
└── vite.config.js # Vite 配置
```
# 功能更新记录(Recent Changes)
说明:该章节从 README 迁移到本文件,避免 README 过长。后续新增变更建议追加在文件顶部。
## 打卡详情页重构(/checkin/detail)
- 统一了文本、媒体上传和计数打卡的入口
- 实现了基于 composables 的通用提交流程:[/src/composables/useCheckin.js](file:///Users/huyirui/program/itomix/git/mlaj/src/composables/useCheckin.js)
- 页面入口:[/src/views/checkin/CheckinDetailPage.vue](file:///Users/huyirui/program/itomix/git/mlaj/src/views/checkin/CheckinDetailPage.vue)
- 优化了附件预览与编辑回填逻辑(音频/视频/图片预览能力)
## 教师端功能完善(/teacher)
- 新增作业管理页面:/teacher/tasks(列表展示:名称、开始/截止时间)
- 新增作业主页:/teacher/tasks/:id(统计 + 日历视图)
- 新增学员作业记录页:/teacher/student-record(作业帖子 + 点赞/点评)
## 基础体验优化
- 登录逻辑调整:仅在登录页点击微信图标时触发授权(避免路由守卫自动授权导致的循环)
- 关键文件:[/src/router/guards.js](file:///Users/huyirui/program/itomix/git/mlaj/src/router/guards.js)[/src/router/index.js](file:///Users/huyirui/program/itomix/git/mlaj/src/router/index.js)[/src/views/auth/LoginPage.vue](file:///Users/huyirui/program/itomix/git/mlaj/src/views/auth/LoginPage.vue)
- 搜索栏优化:提升 iOS 软键盘“搜索”键触发稳定性
- 课程详情页:增加动态 Open Graph 标签,优化分享体验
## 课程详情页动态 Open Graph 元标签
- 行为:进入课程详情页时,在 head 中插入 og:title / og:description / og:image / og:url;离开页面时移除
- CDN 规则:图片域名为 cdn.ipadbiz.cn 时,追加 ?imageMogr2/thumbnail/200x/strip/quality/70
- 位置:[/src/views/courses/CourseDetailPage.vue](file:///Users/huyirui/program/itomix/git/mlaj/src/views/courses/CourseDetailPage.vue)
## 购买流程环境校验与微信授权探测
- 行为:仅对非免费课程在详情页点击“购买”时进行校验;生产环境必须为微信内置浏览器
- 微信环境内:若未完成微信授权(openid_has=false),会自动发起一次微信授权并中止本次购买,授权后再次点击进入结算
- 位置:[/src/views/courses/CourseDetailPage.vue](file:///Users/huyirui/program/itomix/git/mlaj/src/views/courses/CourseDetailPage.vue)
## 401 拦截策略优化(公开页面不再跳登录)
- 行为:接口返回 code=401 时,仅当当前路由确实需要登录时才重定向登录
- 位置:[/src/utils/axios.js](file:///Users/huyirui/program/itomix/git/mlaj/src/utils/axios.js)
## 搜索栏回车搜索兼容性提升
- 行为:输入框类型改为 search,并可选开启 form submit 机制,同时保留 keyup.enter
- 位置:[/src/components/common/SearchBar.vue](file:///Users/huyirui/program/itomix/git/mlaj/src/components/common/SearchBar.vue)
## 分享海报弹窗(可复用)
- 入口:课程详情页底部操作栏“分享”按钮
- 组件:[/src/components/poster/SharePoster.vue](file:///Users/huyirui/program/itomix/git/mlaj/src/components/poster/SharePoster.vue)
- 能力:弹窗打开时通过 Canvas 合成海报(封面、二维码、文案),生成 dataURL 展示,用户长按保存
## 打卡弹窗与列表组件(可复用)
- 打卡弹窗:[/src/components/checkin/CheckInDialog.vue](file:///Users/huyirui/program/itomix/git/mlaj/src/components/checkin/CheckInDialog.vue)
- 打卡列表:[/src/components/checkin/CheckInList.vue](file:///Users/huyirui/program/itomix/git/mlaj/src/components/checkin/CheckInList.vue)
# /src/components 组件目录索引
## 目录划分
| 目录 | 代表组件 | 说明 |
| --- | --- | --- |
| activity/ | ActivityCard.vue、ActivityApplyHistoryPopup.vue | 活动卡片、报名/历史相关弹窗 |
| calendar/ | CollapsibleCalendar.vue、TaskCalendar.vue | 日历与任务日历组件 |
| checkin/ | CheckInDialog.vue、CheckInList.vue、CheckinCard.vue、UploadVideoPopup.vue | 打卡/作业相关组件(弹窗、列表、卡片、上传) |
| common/ | ConfirmDialog.vue、GradientHeader.vue、SearchBar.vue、UserAgreement.vue | 通用基础组件(确认、头部、搜索、协议) |
| count/ | AddTargetDialog.vue、CheckinTargetList.vue、postCountModel.vue | 计数型打卡相关组件 |
| courses/ | CourseCard.vue、CourseList.vue、LiveStreamCard.vue、ReviewPopup.vue | 课程展示与列表、直播卡片、评价弹窗等 |
| effects/ | FrostedGlass.vue、StarryBackground.vue | 视觉效果组件 |
| homePage/ | FeaturedCoursesSection.vue、LatestActivitiesSection.vue | 首页区块组件(精选/活动/推荐等) |
| infoEntry/ | formPage.vue | 信息录入相关组件 |
| layout/ | AppLayout.vue、BottomNav.vue | 页面布局与底部导航 |
| media/ | AudioPlayer.vue、VideoPlayer.vue、PdfPreview.vue、OfficeViewer.vue | 音视频播放器与文档预览 |
| payment/ | WechatPayment.vue | 微信支付相关组件 |
| poster/ | RecallPoster.vue、SharePoster.vue | 海报生成与分享相关组件 |
| studyDetail/ | StudyCatalogPopup.vue、StudyCommentsSection.vue、StudyMaterialsPopup.vue | 学习详情页的弹窗与评论区 |
| teacher/ | TaskFilter.vue、TaskCascaderFilter.vue | 教师端筛选与任务相关组件 |
## 备注
- 布局目录已归一:统一使用 [/src/components/layout](file:///Users/huyirui/program/itomix/git/mlaj/src/components/layout),已移除 /src/layouts
......@@ -130,7 +130,7 @@ export const getUploadTaskInfoAPI = (params) => fn(fetch.get(Api.TASK_UPLOAD_IN
* @param gratitude_form_list 感恩表单数据 [{id,name,city,unit,其他信息字段}]
* @returns
*/
export const editUploadTaskInfoAPI = (params) => fn(fetch.get(Api.TASK_UPLOAD_EDIT, params))
export const editUploadTaskInfoAPI = (params) => fn(fetch.post(Api.TASK_UPLOAD_EDIT, params))
/**
* @description: 删除打卡动态详情
......
......@@ -69,7 +69,6 @@ declare module 'vue' {
VanConfigProvider: typeof import('vant/es')['ConfigProvider']
VanDatePicker: typeof import('vant/es')['DatePicker']
VanDialog: typeof import('vant/es')['Dialog']
VanDivider: typeof import('vant/es')['Divider']
VanDropdownItem: typeof import('vant/es')['DropdownItem']
VanDropdownMenu: typeof import('vant/es')['DropdownMenu']
VanEmpty: typeof import('vant/es')['Empty']
......@@ -95,8 +94,6 @@ declare module 'vue' {
VanSwipe: typeof import('vant/es')['Swipe']
VanSwipeItem: typeof import('vant/es')['SwipeItem']
VanTab: typeof import('vant/es')['Tab']
VanTabbar: typeof import('vant/es')['Tabbar']
VanTabbarItem: typeof import('vant/es')['TabbarItem']
VanTabs: typeof import('vant/es')['Tabs']
VanTag: typeof import('vant/es')['Tag']
VanTimePicker: typeof import('vant/es')['TimePicker']
......
......@@ -11,7 +11,7 @@
<!-- Activity Image -->
<div class="w-1/3 h-28 relative">
<img
:src="activity.imageUrl"
:src="buildCdnImageUrl(activity.imageUrl)"
:alt="activity.title"
class="w-full h-full object-cover"
/>
......@@ -76,6 +76,7 @@
<script setup>
import FrostedGlass from '@/components/effects/FrostedGlass.vue'
import { buildCdnImageUrl } from '@/utils/tools'
const props = defineProps({
activity: {
......
......@@ -9,7 +9,7 @@
<router-link :to="linkTo || `/courses/${course.id}`" class="flex bg-white rounded-lg overflow-hidden shadow-sm">
<div class="w-1/3 h-28 relative">
<img
:src="course.cover || 'https://cdn.ipadbiz.cn/mlaj/images/default_block.png'"
:src="buildCdnImageUrl(course.cover || 'https://cdn.ipadbiz.cn/mlaj/images/default_block.png')"
:alt="course.title"
class="w-full h-full object-cover"
/>
......@@ -44,6 +44,8 @@
</template>
<script setup>
import { buildCdnImageUrl } from '@/utils/tools'
/**
* @description 课程卡片组件
* @property {Object} course 课程对象
......
......@@ -15,7 +15,7 @@
<router-link :to="`/courses/${stream.id}`" class="block rounded-lg overflow-hidden shadow-sm relative">
<img
:src="stream.imageUrl"
:src="buildCdnImageUrl(stream.imageUrl)"
:alt="stream.title"
class="w-full h-28 object-cover"
/>
......@@ -43,6 +43,8 @@
</template>
<script setup>
import { buildCdnImageUrl } from '@/utils/tools'
defineProps({
/**
* 直播流数据对象
......
......@@ -16,7 +16,7 @@
>
<div class="relative rounded-xl overflow-hidden shadow-lg h-48">
<img
:src="course.cover || 'https://cdn.ipadbiz.cn/mlaj/images/default_block.png'"
:src="buildCdnImageUrl(course.cover || 'https://cdn.ipadbiz.cn/mlaj/images/default_block.png', 400)"
:alt="course.title"
class="w-full h-full object-cover"
/>
......@@ -79,6 +79,7 @@
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { getCourseListAPI } from '@/api/course'
import { buildCdnImageUrl } from '@/utils/tools'
const courses = ref([])
const carouselRef = ref(null)
......
......@@ -117,7 +117,9 @@ const fetch_external_activities = async () => {
const mapped = list.map((item) => {
const xs_price = number_or_null(item?.xs_price)
const sc_price = number_or_null(item?.sc_price)
const imageUrl = item?.sl_img || item?.fx_img || item?.banner_img || 'https://cdn.ipadbiz.cn/mlaj/images/default_block.png'
let imageUrl = item?.sl_img || item?.fx_img || item?.banner_img || 'https://cdn.ipadbiz.cn/mlaj/images/default_block.png'
// 压缩阿里云图片资源
imageUrl = imageUrl+ '?x-oss-process=image/quality,q_70/resize,w_100'
const period = format_period(item?.act_start_at, item?.act_end_at)
const upper = number_or_null(item?.stu_num_upper)
......
......@@ -21,7 +21,7 @@
<div class="flex flex-col h-full" @click="go_to_course_detail(item)">
<div class="h-28 mb-2 rounded-lg overflow-hidden relative">
<img
:src="item.cover || 'https://cdn.ipadbiz.cn/mlaj/images/default_block.png'"
:src="buildCdnImageUrl(item.cover || 'https://cdn.ipadbiz.cn/mlaj/images/default_block.png')"
:alt="item.title"
class="w-full h-full object-cover"
/>
......@@ -45,6 +45,7 @@ import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import FrostedGlass from '@/components/effects/FrostedGlass.vue'
import { getCourseListAPI } from '@/api/course'
import { buildCdnImageUrl } from '@/utils/tools'
const router = useRouter()
......
......@@ -116,8 +116,9 @@
</template>
<script setup>
import { ref, watch, nextTick, onUnmounted } from 'vue';
import PDF from "pdf-vue3";
import { defineAsyncComponent, ref, watch, nextTick, onUnmounted } from 'vue';
const PDF = defineAsyncComponent(() => import("pdf-vue3"));
/**
* 组件属性定义
......
......@@ -62,11 +62,18 @@
</template>
<script setup>
import { ref } from "vue";
import { VideoPlayer } from "@videojs-player/vue";
import "video.js/dist/video-js.css";
import { defineAsyncComponent, ref } from "vue";
import { useVideoPlayer } from "@/composables/useVideoPlayer";
const VideoPlayer = defineAsyncComponent(async () => {
await import("video.js/dist/video-js.css");
await import("videojs-hls-quality-selector/dist/videojs-hls-quality-selector.css");
await import("videojs-contrib-quality-levels");
await import("videojs-hls-quality-selector");
const mod = await import("@videojs-player/vue");
return mod.VideoPlayer;
});
const props = defineProps({
options: {
type: Object,
......
import { describe, expect, it, vi, beforeEach } from 'vitest'
let mock_query = {}
vi.mock('vue-router', () => {
const router = {
push: vi.fn(),
back: vi.fn()
}
return {
useRoute: () => ({
query: {}
query: mock_query
}),
useRouter: () => ({
push: vi.fn()
})
useRouter: () => router
}
})
......@@ -51,10 +55,23 @@ vi.mock('@/contexts/auth', async () => {
import { useCheckin } from '../useCheckin'
import { showToast } from 'vant'
import { addUploadTaskAPI } from '@/api/checkin'
describe('useCheckin 上传大小限制', () => {
beforeEach(() => {
vi.clearAllMocks()
mock_query = { enable_multi: '0' }
if (!globalThis.sessionStorage) {
let store = {}
globalThis.sessionStorage = {
getItem: (key) => store[key] ?? null,
setItem: (key, value) => { store[key] = String(value) },
removeItem: (key) => { delete store[key] },
clear: () => { store = {} },
}
} else {
sessionStorage.clear()
}
})
it('setMaxFileSizeMbMap 能更新并保留其他类型默认值', () => {
......@@ -119,3 +136,71 @@ describe('useCheckin 上传大小限制', () => {
})
})
describe('useCheckin 提交兼容', () => {
beforeEach(() => {
vi.clearAllMocks()
mock_query = { enable_multi: '0' }
if (globalThis.sessionStorage?.clear) sessionStorage.clear()
})
it('新结构失败会回退旧结构提交', async () => {
addUploadTaskAPI
.mockResolvedValueOnce({ code: 0, msg: '参数错误' })
.mockResolvedValueOnce({ code: 1, data: { id: 1 } })
const { activeType, fileList, message, onSubmit } = useCheckin()
activeType.value = 'audio'
message.value = '随便写点内容'
fileList.value = [
{ status: 'done', meta_id: 11, file_type: 'audio' }
]
await onSubmit({ subtask_id: 's1' })
expect(addUploadTaskAPI).toHaveBeenCalledTimes(2)
expect(addUploadTaskAPI.mock.calls[0][0]).toHaveProperty('files')
expect(addUploadTaskAPI.mock.calls[0][0]).toHaveProperty('meta_id')
expect(addUploadTaskAPI.mock.calls[1][0]).toHaveProperty('meta_id')
expect(addUploadTaskAPI.mock.calls[1][0]).not.toHaveProperty('files')
expect(showToast).toHaveBeenCalledWith('提交成功')
})
it('混合附件且新结构失败时会提示分别提交', async () => {
const { activeType, fileList, message, onSubmit } = useCheckin()
activeType.value = 'image'
message.value = '随便写点内容'
fileList.value = [
{ status: 'done', meta_id: 11, file_type: 'image' },
{ status: 'done', meta_id: 12, file_type: 'audio' },
]
await onSubmit({ subtask_id: 's1' })
expect(addUploadTaskAPI).toHaveBeenCalledTimes(0)
expect(showToast).toHaveBeenCalledWith('当前接口暂不支持多类型附件,请分别提交')
})
it('开启多附件开关时允许 mixed files 提交', async () => {
mock_query = { enable_multi: '1' }
addUploadTaskAPI.mockResolvedValueOnce({ code: 1, data: { id: 1 } })
const { activeType, fileList, message, onSubmit } = useCheckin()
activeType.value = 'image'
message.value = '随便写点内容'
fileList.value = [
{ status: 'done', meta_id: 11, file_type: 'image' },
{ status: 'done', meta_id: 12, file_type: 'audio' },
]
await onSubmit({ subtask_id: 's1' })
expect(addUploadTaskAPI).toHaveBeenCalledTimes(1)
expect(addUploadTaskAPI.mock.calls[0][0]).toHaveProperty('files')
expect(addUploadTaskAPI.mock.calls[0][0]).not.toHaveProperty('meta_id')
expect(addUploadTaskAPI.mock.calls[0][0]).toHaveProperty('file_type', 'mixed')
expect(showToast).toHaveBeenCalledWith('提交成功')
})
})
......
......@@ -23,6 +23,16 @@ export function useCheckin() {
const router = useRouter()
const { currentUser } = useAuth()
// TAG: 多附件功能开关
const multiAttachmentEnabled = computed(() => {
const query_value = String(route?.query?.enable_multi ?? '')
if (query_value === '1') return true
if (query_value === '0') return false
const fromEnv = String(import.meta.env.VITE_CHECKIN_MULTI_ATTACHMENT || '') === '1'
return fromEnv
})
// ==================== 状态定义 ====================
/** @type {import('vue').Ref<boolean>} 上传中状态 */
......@@ -98,12 +108,12 @@ export function useCheckin() {
* 用于记忆不同类型的文件列表,切换类型时恢复
* @type {import('vue').Ref<Object>}
*/
const fileListMemory = ref({
text: [],
image: [],
video: [],
audio: []
})
// const fileListMemory = ref({
// text: [],
// image: [],
// video: [],
// audio: []
// })
/**
* 是否可以提交
......@@ -205,7 +215,17 @@ export function useCheckin() {
const suffix = /.[^.]+$/.exec(file.file.name) || ''
let fileName = ''
if (activeType.value === 'image') {
// 根据当前 activeType 或文件类型判断
let currentFileType = activeType.value
if (!['image', 'video', 'audio'].includes(currentFileType)) {
// 如果 activeType 不是这三种(比如 text),尝试从文件类型推断
if (file.file.type.startsWith('image/')) currentFileType = 'image'
else if (file.file.type.startsWith('video/')) currentFileType = 'video'
else if (file.file.type.startsWith('audio/')) currentFileType = 'audio'
else currentFileType = 'file' // 默认
}
if (currentFileType === 'image') {
fileName = `mlaj/upload/checkin/${currentUser.value.mobile}/img/${md5}${suffix}`
} else {
fileName = `mlaj/upload/checkin/${currentUser.value.mobile}/file/${md5}${suffix}`
......@@ -226,13 +246,13 @@ export function useCheckin() {
}
// 图片类型需要保存尺寸信息
if (activeType.value === 'image' && uploadResult.image_info) {
if (currentFileType === 'image' && uploadResult.image_info) {
saveData.height = uploadResult.image_info.height
saveData.width = uploadResult.image_info.width
}
const { data } = await saveFileAPI(saveData)
return data
return { ...data, file_type: currentFileType }
}
}
return null
......@@ -323,6 +343,7 @@ export function useCheckin() {
item.url = result.url
item.meta_id = result.meta_id
item.name = result.name || item.file.name
item.file_type = result.file_type || activeType.value // 记录文件类型
} else {
item.status = 'failed'
item.message = '上传失败'
......@@ -438,21 +459,47 @@ export function useCheckin() {
try {
// 准备提交数据
// 构造 files 数组
const files = fileList.value
.filter(item => item.status === 'done' && item.meta_id)
.map(item => ({
meta_id: item.meta_id,
file_type: item.file_type || activeType.value // 优先使用 item 自己的 type
}))
const submitData = {
note: message.value,
file_type: activeType.value,
meta_id: [],
// file_type: activeType.value, // 不再依赖顶层 file_type
// meta_id: [], // 废弃
files: files,
makeup_time: isMakeup.value ? route.query.date : '',
...extraData
}
// 如果有文件,添加文件ID
if (fileList.value.length > 0) {
submitData.meta_id = fileList.value
.filter(item => item.status === 'done' && item.meta_id)
.map(item => item.meta_id)
// 如果没有 files,尝试推断 file_type (仅为了兼容旧逻辑或文本打卡)
if (files.length === 0 && activeType.value === 'text') {
submitData.file_type = 'text'
} else if (files.length > 0) {
const types = new Set(files.map(f => f.file_type).filter(Boolean))
const hasMixedTypes = types.size > 1
console.warn(multiAttachmentEnabled.value);
if (hasMixedTypes && !multiAttachmentEnabled.value) {
showToast('当前接口暂不支持多类型附件,请分别提交')
return
}
// 如果有文件,file_type 可能是 'mixed' 或者取第一个文件的类型作为主类型
// 这里暂时保留 file_type 字段以防后端必须校验,取第一个文件的类型,或者传 'mixed'
// 如果后端已更新为只看 files 数组,则此字段可能无效
submitData.file_type = hasMixedTypes ? 'mixed' : files[0].file_type
if (!multiAttachmentEnabled.value) {
submitData.meta_id = files.map(f => f.meta_id)
}
}
// 说明:submitData.meta_id 为旧接口兜底字段,后端完全支持 files 后再移除
let result
if (route.query.status === 'edit') {
// 编辑打卡
......@@ -460,7 +507,8 @@ export function useCheckin() {
i: route.query.post_id,
subtask_id: submitData.subtask_id || route.query.subtask_id,
note: submitData.note,
meta_id: submitData.meta_id,
...(submitData.meta_id ? { meta_id: submitData.meta_id } : {}),
files: submitData.files,
file_type: submitData.file_type,
}
......@@ -478,7 +526,46 @@ export function useCheckin() {
result = await addUploadTaskAPI(submitData)
}
if (result.code === 1) {
if (result?.code !== 1) {
const types = new Set((files || []).map(f => f.file_type).filter(Boolean))
const hasMixedTypes = types.size > 1
if (hasMixedTypes) {
if (!multiAttachmentEnabled.value) {
showToast('当前接口暂不支持多类型附件,请分别提交')
return
}
showToast(result?.msg || '提交失败,请重试')
return
}
const legacy_type = files?.[0]?.file_type || submitData.file_type || activeType.value
const legacy_meta_id = (files || []).filter(f => f.file_type === legacy_type).map(f => f.meta_id)
const legacy_payload = {
...extraData,
note: submitData.note,
file_type: legacy_type,
meta_id: legacy_meta_id,
makeup_time: submitData.makeup_time,
}
if (route.query.status === 'edit') {
result = await editUploadTaskInfoAPI({
i: route.query.post_id,
subtask_id: legacy_payload.subtask_id || route.query.subtask_id,
note: legacy_payload.note,
meta_id: legacy_payload.meta_id,
file_type: legacy_payload.file_type,
gratitude_form_list: legacy_payload.gratitude_form_list,
gratitude_count: legacy_payload.gratitude_count,
})
} else {
result = await addUploadTaskAPI(legacy_payload)
}
}
if (result?.code === 1) {
showToast('提交成功')
// 设置刷新标记,用于列表页更新数据
......@@ -492,6 +579,8 @@ export function useCheckin() {
}
router.back()
} else {
showToast(result?.msg || '提交失败,请重试')
}
} catch (error) {
showToast('提交失败,请重试')
......@@ -503,19 +592,10 @@ export function useCheckin() {
/**
* 切换打卡类型
* @param {string} type - 打卡类型
* @description 切换时会保存当前类型的文件列表,并恢复新类型的文件列表
* @description 切换类型不再清空文件列表,支持多类型混合上传
*/
const switchType = (type) => {
if (activeType.value !== type) {
// 保存当前类型的文件列表到记忆中
fileListMemory.value[activeType.value] = [...fileList.value]
// 切换到新类型
activeType.value = type
// 恢复新类型的文件列表
fileList.value = [...fileListMemory.value[type]]
}
activeType.value = type
}
/**
......@@ -528,12 +608,12 @@ export function useCheckin() {
uploading.value = false
loading.value = false
// 清空文件列表记忆
fileListMemory.value = {
text: [],
image: [],
video: [],
audio: []
}
// fileListMemory.value = {
// text: [],
// image: [],
// video: [],
// audio: []
// }
}
// 计数打卡相关数据
......@@ -608,11 +688,12 @@ export function useCheckin() {
status: 'done',
message: '已上传',
meta_id: item.meta_id,
name: item.name || ''
name: item.name || '',
file_type: item.file_type || data.file_type || 'image' // 恢复 file_type
}
// 对于图片类型,添加isImage标记确保正确显示
if (activeType.value === 'image') {
if (fileItem.file_type === 'image') {
fileItem.isImage = true
}
......@@ -626,7 +707,7 @@ export function useCheckin() {
// 将文件列表保存到当前类型的记忆中
fileList.value = files
fileListMemory.value[activeType.value] = [...files]
// fileListMemory.value[activeType.value] = [...files]
}
}
} catch (error) {
......
......@@ -15,7 +15,7 @@ export function useImageLoader() {
/**
* 默认头像 URL
*/
const DEFAULT_AVATAR = 'https://cdn.ipadbiz.cn/mlaj/images/default-avatar.jpeg'
const DEFAULT_AVATAR = 'https://cdn.ipadbiz.cn/mlaj/images/default-avatar.jpeg?imageMogr2/thumbnail/200x/strip/quality/70'
/**
* 处理图片加载错误
......
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue';
import { wxInfo } from "@/utils/tools";
import videojs from "video.js";
import { buildVideoSources, canPlayHlsNatively } from "./videoPlayerSource";
import { useVideoProbe } from "./useVideoProbe";
import { useVideoPlaybackOverlays } from "./useVideoPlaybackOverlays";
// 新增:引入多码率切换插件
import 'videojs-contrib-quality-levels'; // 用于读取 m3u8 中的多码率信息
import 'videojs-hls-quality-selector'; // 用于在播放器控制条显示“清晰度”切换菜单(支持 Auto/720p/480p 等)。
import 'videojs-hls-quality-selector/dist/videojs-hls-quality-selector.css';
const is_safari_browser = () => {
if (typeof navigator === 'undefined') return false;
const ua = navigator.userAgent || '';
const is_safari = /safari/i.test(ua) && !/chrome|crios|android|fxios|edg/i.test(ua);
return is_safari;
};
/**
* - 使用方法 :您无需修改业务代码。只要传入的视频 URL 是七牛云生成的多码率 .m3u8 地址,播放器控制条右下角会自动出现“齿轮”图标,用户点击即可切换清晰度(或选择 Auto 自动切换)。
......@@ -271,7 +273,7 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
// 5. Video.js 播放器逻辑 (PC/Android)
const shouldOverrideNativeHls = computed(() => {
if (!isM3U8.value) return false;
if (videojs.browser.IS_SAFARI) return false;
if (is_safari_browser()) return false;
// 非 Safari 且不具备原生 HLS 时,强制 video.js 的 VHS 来解 m3u8
return !canPlayHlsNatively();
});
......
<!-- src/layouts/AppLayout.vue -->
<template>
<div class="app-layout">
<!-- Header -->
<header class="app-header" v-if="hasTitle">
<div v-if="showBack" class="header-back" @click="goBack">
<van-icon name="arrow-left" size="20" />
</div>
<h1 class="header-title">{{ title }}</h1>
<div class="header-right">
<slot name="header-right"></slot>
</div>
</header>
<!-- Main Content -->
<main class="app-content" :class="{ 'has-bottom-nav': showBottomNav, 'no-header': !hasTitle }">
<slot></slot>
</main>
<!-- Bottom Navigation -->
<van-tabbar v-if="showBottomNav" route safe-area-inset-bottom>
<van-tabbar-item to="/home" icon="home-o">首页</van-tabbar-item>
<van-tabbar-item to="/courses" icon="orders-o">课程</van-tabbar-item>
<van-tabbar-item to="/activities" icon="friends-o">活动</van-tabbar-item>
<van-tabbar-item to="/community" icon="chat-o">社区</van-tabbar-item>
<van-tabbar-item to="/profile" icon="user-o">我的</van-tabbar-item>
</van-tabbar>
</div>
</template>
<script>
import { ref, onMounted, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
export default {
name: 'AppLayout',
props: {
title: {
type: String,
default: '美乐爱觉教育'
},
showBack: {
type: Boolean,
default: false
},
showBottomNav: {
type: Boolean,
default: true
},
hasTitle: {
type: Boolean,
default: true
},
},
setup(props) {
const router = useRouter()
const route = useRoute()
const goBack = () => {
if (window.history.length > 1) {
router.back()
} else {
router.push('/')
}
}
return {
goBack,
}
}
}
</script>
<style scoped>
.app-layout {
display: flex;
flex-direction: column;
min-height: 100vh;
background-color: var(--background-color);
}
.app-header {
position: sticky;
top: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
height: 46px;
padding: 0 16px;
background-color: var(--white);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
}
.header-back {
position: absolute;
left: 16px;
display: flex;
align-items: center;
justify-content: center;
}
.header-title {
font-size: 18px;
font-weight: 600;
margin: 0;
}
.header-right {
position: absolute;
right: 16px;
display: flex;
align-items: center;
}
.app-content {
flex: 1;
overflow-y: auto;
padding-bottom: 20px;
-webkit-overflow-scrolling: touch;
}
.app-content.has-bottom-nav {
padding-bottom: 50px;
}
.app-content.no-header {
padding-top: 0;
}
</style>
/*
* @Date: 2025-03-21 13:28:30
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-11-17 13:54:31
* @LastEditTime: 2026-01-24 15:29:54
* @FilePath: /mlaj/src/router/checkin.js
* @Description: 文件描述
*/
......@@ -61,42 +61,6 @@ export default [
}
},
{
path: '/checkin/image',
name: 'ImageCheckIn',
component: () => import('@/views/checkin/upload/image.vue'),
meta: {
title: '打卡图片',
requiresAuth: true
}
},
{
path: '/checkin/video',
name: 'VideoCheckIn',
component: () => import('@root/src/views/checkin/upload/video.vue'),
meta: {
title: '打卡视频',
requiresAuth: true
}
},
{
path: '/checkin/audio',
name: 'AudioCheckIn',
component: () => import('@root/src/views/checkin/upload/audio.vue'),
meta: {
title: '打卡音频',
requiresAuth: true
}
},
{
path: '/checkin/text',
name: 'TextCheckIn',
component: () => import('@root/src/views/checkin/upload/text.vue'),
meta: {
title: '打卡文本',
requiresAuth: true
}
},
{
path: '/checkin/join',
name: 'JoinCheckIn',
component: () => import('@root/src/views/checkin/JoinCheckInPage.vue'),
......
/*
* @Date: 2022-04-18 15:59:42
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2026-01-21 13:33:36
* @LastEditTime: 2026-01-24 14:35:27
* @FilePath: /mlaj/src/utils/tools.js
* @Description: 文件描述
*/
......@@ -127,6 +127,35 @@ const formatDuration = (seconds) => {
};
/**
* @description 为 CDN 图片追加七牛压缩参数,降低首页等场景的图片体积。
* @param {string} src 原始图片地址
* @param {number} [width=200] 缩略图宽度(像素)
* @param {number} [quality=70] 图片质量(1-100)
* @returns {string} 处理后的图片地址
*/
const buildCdnImageUrl = (src, width = 200, quality = 70) => {
const url = typeof src === 'string' ? src : '';
if (!url) return '';
// 已包含七牛处理参数时不重复追加,避免不确定行为
if (url.includes('imageMogr2')) return url;
try {
const u = new URL(url, window.location.origin);
// 兼容多个 CDN 域名:只要域名前缀以 cdn 开头,就允许追加七牛参数
// 例如:cdn.ipadbiz.cn / cdn.xxx.com / cdn1.xxx.com
if (!/^cdn/i.test(u.hostname)) return url;
const [base, hash] = url.split('#');
const param = `imageMogr2/thumbnail/${width}x/strip/quality/${quality}`;
const next = base + (base.includes('?') ? '&' : '?') + param;
return hash ? `${next}#${hash}` : next;
} catch (e) {
return url;
}
};
/**
* @description 归一化“打卡任务列表”字段,避免各页面散落 map 导致漏改。
* @param {Array<any>} list 原始任务列表(通常为接口返回 task_list/timeout_task_list)
* @returns {Array<{id: number|string, name: string, task_type: string, is_gray: boolean, is_finish: boolean, checkin_subtask_id: number|string|undefined}>}
......@@ -289,6 +318,7 @@ export {
getUrlParams,
stringifyQuery,
formatDuration,
buildCdnImageUrl,
normalizeCheckinTaskItems,
normalizeAttachmentTypeConfig,
};
......
......@@ -38,7 +38,7 @@
<div class="flex items-center">
<div class="w-10 h-10 rounded-full overflow-hidden mr-3">
<img
:src="currentUser?.avatar || 'https://cdn.ipadbiz.cn/mlaj/images/icon_1.jpeg'"
:src="buildCdnImageUrl(currentUser?.avatar || 'https://cdn.ipadbiz.cn/mlaj/images/icon_1.jpeg')"
class="w-full h-full object-cover"
@error="handleImageError" />
</div>
......@@ -231,7 +231,7 @@
<div class="flex items-center">
<div class="w-12 h-12 bg-green-100 rounded-lg overflow-hidden mr-3 flex-shrink-0">
<img
:src="item.image"
:src="buildCdnImageUrl(item.image)"
:alt="item.title"
class="w-full h-full object-cover"
@error="handleImageError"
......@@ -287,7 +287,7 @@
>
<template v-if="activeVideoIndex !== index">
<img
:src="item.image"
:src="buildCdnImageUrl(item.image, 400)"
:alt="item.title"
class="w-full h-full object-cover"
@error="handleImageError"
......@@ -350,7 +350,7 @@ import { useImageLoader } from '@/composables/useImageLoader'
// 导入接口
import { getTaskListAPI } from "@/api/checkin";
import { normalizeCheckinTaskItems } from '@/utils/tools'
import { normalizeCheckinTaskItems, buildCdnImageUrl } from '@/utils/tools'
// 图片加载错误处理
const { handleImageError } = useImageLoader()
......
<!--
* @Date: 2025-09-30 17:05
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2026-01-22 22:11:29
* @LastEditTime: 2026-01-24 15:32:45
* @FilePath: /mlaj/src/views/checkin/CheckinDetailPage.vue
* @Description: 用户打卡详情页
-->
......@@ -84,19 +84,22 @@
<div class="tab-title">{{ taskType === 'count' ? '附件类型(可选)' : '附件类型' }}</div>
<div class="tabs-nav">
<div v-for="option in attachmentTypeOptions" :key="option.key"
@click="switchType(option.key)" :class="['tab-item', {
active: activeType === option.key
}]">
<van-icon :name="getIconName(option.key)" size="1.2rem" />
<span class="tab-text">{{ option.value }}</span>
</div>
@click="switchType(option.key)" :class="['tab-item', 'relative', {
active: activeType === option.key
}]">
<van-icon :name="getIconName(option.key)" size="1.2rem" />
<span class="tab-text">{{ option.value }}</span>
<div v-if="getTypeCount(option.key) > 0" class="absolute -top-2 -right-2 bg-red-500 text-white text-[10px] rounded-full min-w-[16px] h-[16px] flex items-center justify-center px-1">
{{ getTypeCount(option.key) }}
</div>
</div>
</div>
</div>
<!-- 文件上传区域 -->
<div v-if="activeType !== '' && activeType !== 'text'" class="upload-area">
<van-uploader v-model="fileList" :max-count="maxCount" :max-size="maxFileSizeBytes"
:before-read="beforeRead" :after-read="afterRead" @delete="onDelete"
<van-uploader v-model="displayFileList" :max-count="maxCount" :max-size="maxFileSizeBytes"
:before-read="beforeReadGuard" :after-read="afterRead" @delete="onDelete"
@click-preview="onClickPreview" multiple :accept="getAcceptType()" result-type="file"
:deletable="true" upload-icon="plus" />
......@@ -188,7 +191,7 @@ import AudioPlayer from '@/components/media/AudioPlayer.vue'
import VideoPlayer from '@/components/media/VideoPlayer.vue'
import AddTargetDialog from '@/components/count/AddTargetDialog.vue'
import CheckinTargetList from '@/components/count/CheckinTargetList.vue'
import { showToast, showLoadingToast } from 'vant'
import { showToast, showLoadingToast, showDialog } from 'vant'
import dayjs from 'dayjs'
const route = useRoute()
......@@ -221,6 +224,115 @@ const {
gratitudeFormList
} = useCheckin()
const beforeReadGuard = (file) => {
const files = Array.isArray(file) ? file : [file]
if (activeType.value === 'video') {
const hasMov = files.some(item => {
const fileName = String(item?.name || '').toLowerCase()
const fileType = String(item?.type || '').toLowerCase()
return fileName.endsWith('.mov') || fileType.includes('quicktime')
})
if (hasMov) {
showDialog({
title: '不支持 MOV 格式',
message: 'MOV(QuickTime)在非苹果系统/部分播放器兼容性较差,可能出现无法打开、黑屏、无声等问题。\n\n请将视频导出/转换为 MP4(更通用)后再上传。',
confirmButtonText: '我知道了',
})
return false
}
return beforeRead(file)
}
if (activeType.value === 'audio') {
const supportedExt = ['mp3', 'm4a', 'aac', 'wav']
const unsupportedFiles = files.filter(item => {
const fileName = String(item?.name || '').toLowerCase()
const ext = fileName.includes('.') ? fileName.split('.').pop() : ''
if (supportedExt.includes(ext)) return false
const fileType = String(item?.type || '').toLowerCase()
if (!fileType) return true
if (!fileType.startsWith('audio/')) return true
return true
})
if (unsupportedFiles.length > 0) {
showDialog({
title: '不支持的音频格式',
message: '当前音频播放基于系统浏览器能力,不同机型/系统对音频格式支持差异较大(例如 .wma 等常见无法播放)。\n\n为避免上传后无法播放,请使用 .mp3 或 .m4a(推荐)重新导出/转换后再上传。',
confirmButtonText: '我知道了',
})
return false
}
return beforeRead(file)
}
return beforeRead(file)
}
/**
* 获取指定类型的文件数量
* @param {string} type - 文件类型
* @returns {number} 文件数量
*/
const getTypeCount = (type) => {
return fileList.value.filter(item => {
if (item.file_type) {
return item.file_type === type
}
// 处理刚选择但未上传完成的文件(尝试推断类型)
if (item.file && item.file.type) {
if (type === 'image') return item.file.type.startsWith('image/')
if (type === 'video') return item.file.type.startsWith('video/')
if (type === 'audio') return item.file.type.startsWith('audio/')
}
return false
}).length
}
/**
* 当前显示的(经过类型过滤的)文件列表
* @description
* 1. getter: 根据 activeType 过滤 fileList,只显示当前类型的文件
* 2. setter: 处理 van-uploader 的更新(添加/删除),同步回 fileList
*/
const displayFileList = computed({
get: () => {
return fileList.value.filter(item => {
if (item.file_type) {
return item.file_type === activeType.value
}
if (item.file && item.file.type) {
if (activeType.value === 'image') return item.file.type.startsWith('image/')
if (activeType.value === 'video') return item.file.type.startsWith('video/')
if (activeType.value === 'audio') return item.file.type.startsWith('audio/')
}
return false
})
},
set: (val) => {
// 找出不属于当前视图的其他文件(保留它们)
const otherFiles = fileList.value.filter(item => {
if (item.file_type) {
return item.file_type !== activeType.value
}
if (item.file && item.file.type) {
if (activeType.value === 'image') return !item.file.type.startsWith('image/')
if (activeType.value === 'video') return !item.file.type.startsWith('video/')
if (activeType.value === 'audio') return !item.file.type.startsWith('audio/')
}
// 如果无法判断类型,且 activeType 不是 text,保守起见保留它?
// 或者:如果 activeType 是 image,那么所有 image/ 相关的都算当前视图,非 image/ 的算 other
return true
})
// 合并其他文件和当前视图的新文件列表
fileList.value = [...otherFiles, ...val]
}
})
// 动态字段文字
const dynamicFieldText = ref('感恩')
......@@ -521,7 +633,7 @@ const isSubmitDisabled = computed(() => {
// 文本打卡:必须填写内容且长度不少于10个字符
return !message.value.trim() || message.value.trim().length < 10
} else {
// 其他类型:必须有文件
// 其他类型:必须有文件 (如果是混合模式,只要有文件就行)
return fileList.value.length === 0
}
})
......@@ -621,8 +733,8 @@ const getFileIcon = () => {
const getAcceptType = () => {
const acceptMap = {
'image': 'image/*',
'video': 'video/*',
'audio': '.mp3,.wav,.aac,.flac,.ogg,.wma,.m4a'
'video': '.mp4,video/mp4',
'audio': '.mp3,.m4a,.aac,.wav'
}
return acceptMap[activeType.value] || '*'
}
......@@ -634,8 +746,8 @@ const getAcceptType = () => {
const getUploadTips = () => {
const tipsMap = {
'image': '支持格式:.jpg/.jpeg/.png',
'video': '支持格式:视频文件',
'audio': '支持格式:.mp3/.wav/.aac/.flac/.ogg/.wma/.m4a'
'video': '支持格式:.mp4(不支持 .mov)',
'audio': '支持格式:.mp3/.m4a/.aac/.wav(不支持 .wma)'
}
return tipsMap[activeType.value] || ''
}
......@@ -745,18 +857,20 @@ const onClickPreview = (file, detail) => {
}
// 根据打卡类型或文件扩展名判断文件类型
if (activeType.value === 'audio' || isAudioFile(fileName)) {
const finalFileType = file.file_type || (isAudioFile(fileName) ? 'audio' : (isVideoFile(fileName) ? 'video' : 'image'))
if (finalFileType === 'audio') {
console.log('准备播放音频:', fileName, fileUrl)
showAudio(fileName, fileUrl)
} else if (activeType.value === 'video' || isVideoFile(fileName)) {
} else if (finalFileType === 'video') {
console.log('准备播放视频:', fileName, fileUrl)
showVideo(fileName, fileUrl)
} else if (activeType.value === 'image' || isImageFile(fileName)) {
} else if (finalFileType === 'image') {
console.log('图片预览由van-uploader组件处理,跳过文件列表点击预览')
// 图片预览由van-uploader的@click-preview事件处理,避免重复弹出
return
} else {
console.log('该文件类型不支持预览,文件名:', fileName, '类型:', activeType.value)
console.log('该文件类型不支持预览,文件名:', fileName, '类型:', finalFileType)
showToast('该文件类型不支持预览')
}
}
......
<!--
* @Date: 2025-05-29 15:34:17
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2026-01-23 10:46:15
* @LastEditTime: 2026-01-24 15:29:28
* @FilePath: /mlaj/src/views/checkin/IndexCheckInPage.vue
* @Description: 用户打卡主页
-->
......@@ -460,45 +460,6 @@ const goToCheckinDetailPage = () => {
})
}
// const goToCheckinTextPage = () => {
// router.push({
// path: '/checkin/text',
// query: {
// id: route.query.id,
// type: 'text'
// }
// })
// }
// const goToCheckinImagePage = () => {
// router.push({
// path: '/checkin/image',
// query: {
// id: route.query.id,
// type: 'image'
// }
// })
// }
// const goToCheckinVideoPage = (type) => {
// router.push({
// path: '/checkin/video',
// query: {
// id: route.query.id,
// type: 'video',
// }
// })
// }
// const goToCheckinAudioPage = (type) => {
// router.push({
// path: '/checkin/audio',
// query: {
// id: route.query.id,
// type: 'audio',
// }
// })
// }
const handLike = async (post) => {
if (!post.is_liked) {
const { code, data } = await likeUploadTaskInfoAPI({ checkin_id: post.id, })
......@@ -716,29 +677,33 @@ const formatData = (data) => {
let images = [];
let audio = [];
let videoList = [];
if (item.file_type === 'image') {
images = item.files.map(file => {
return file.value;
});
} else if (item.file_type === 'video') {
videoList = item.files.map(file => {
return {
id: file.meta_id,
video: file.value,
videoCover: file.cover,
isPlaying: false,
}
})
} else if (item.file_type === 'audio') {
audio = item.files.map(file => {
return {
title: file.name ? file.name : '打卡音频',
artist: file.artist ? file.artist : '',
url: file.value,
cover: file.cover ? file.cover : '',
// 支持多类型混合显示:遍历 files 数组根据 file_type 分类
if (item.files && Array.isArray(item.files)) {
item.files.forEach(file => {
// 优先使用文件自身的 file_type,如果没有则回退到 item.file_type
const type = file.file_type || item.file_type;
if (type === 'image') {
images.push(file.value);
} else if (type === 'video') {
videoList.push({
id: file.meta_id,
video: file.value,
videoCover: file.cover,
isPlaying: false,
});
} else if (type === 'audio') {
audio.push({
title: file.name ? file.name : '打卡音频',
artist: file.artist ? file.artist : '',
url: file.value,
cover: file.cover ? file.cover : '',
});
}
})
});
}
return {
id: item.id,
task_id: item.task_id,
......
<!--
* @Date: 2025-06-03 09:41:41
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-06-13 14:34:57
* @FilePath: /mlaj/src/views/checkin/upload/audio.vue
* @Description: 音视频文件上传组件
-->
<template>
<div class="checkin-upload-file p-4">
<div class="title text-center pb-4 font-bold">{{route.meta.title}}</div>
<!-- 文件上传区域 -->
<div class="mb-4">
<van-uploader
v-model="fileList"
:max-count="max_count"
:max-size="20 * 1024 * 1024"
:before-read="beforeRead"
:after-read="afterRead"
@delete="onDelete"
multiple
accept=".mp3,.wav,.aac"
result-type="file"
upload-icon="plus"
:deletable="false"
>
</van-uploader>
<van-row v-for="(item, index) in fileList" :key="index" class="mt-2 text-s text-gray-500">
<van-col span="22">{{ item.name }}</van-col>
<van-col span="2" @click="delItem(item)"><van-icon name="clear" /></van-col>
</van-row>
<van-divider />
<div class="mt-2 text-xs text-gray-500">最多上传{{ max_count }}个文件,每个不超过20M</div>
<div class="mt-2 text-xs text-gray-500">上传类型:&nbsp;.mp3,.wav,.aac 格式音频文件</div>
</div>
<!-- 文字留言区域 -->
<div class="mb-4 border">
<van-field
v-model="message"
rows="4"
autosize
type="textarea"
placeholder="请输入打卡留言"
/>
</div>
<!-- 提交按钮 -->
<div class="fixed bottom-0 left-0 right-0 p-4 bg-white">
<van-button
type="primary"
block
:loading="uploading"
:disabled="!canSubmit"
@click="onSubmit"
>
提交
</van-button>
</div>
<!-- 上传加载遮罩 -->
<van-overlay :show="loading">
<div class="wrapper" @click.stop>
<van-loading vertical color="#FFFFFF">上传中...</van-loading>
</div>
</van-overlay>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { showToast, showLoadingToast } from 'vant'
import { qiniuTokenAPI, qiniuUploadAPI, saveFileAPI } from '@/api/common'
import { addUploadTaskAPI, getUploadTaskInfoAPI, editUploadTaskInfoAPI } from "@/api/checkin";
import { qiniuFileHash } from '@/utils/qiniuFileHash';
import _ from 'lodash'
import { useTitle } from '@vueuse/core';
import { useAuth } from '@/contexts/auth'
const route = useRoute()
const router = useRouter()
const { currentUser } = useAuth()
useTitle(route.meta.title);
const max_count = ref(5);
// 文件列表
const fileList = ref([])
// 留言内容
const message = ref('')
// 上传状态
const uploading = ref(false)
// 上传loading
const loading = ref(false)
// 是否可以提交
const canSubmit = computed(() => {
return fileList.value.length > 0 && message.value.trim() !== ''
})
// 文件校验
const beforeRead = (file) => {
let flag = true
if (Array.isArray(file)) {
// 多个文件
const invalidTypes = file.filter(item => {
const fileType = item.type.toLowerCase();
return !fileType.startsWith('audio/');
})
if (invalidTypes.length) {
flag = false
showToast('请上传音频文件')
}
if (fileList.value.length + file.length > max_count.value) {
flag = false
showToast(`最大上传数量为${max_count.value}个`)
}
} else {
const fileType = file.type.toLowerCase();
if (!fileType.startsWith('audio/')) {
showToast('请上传音频文件')
flag = false
}
if (fileList.value.length + 1 > max_count.value) {
flag = false
showToast(`最大上传数量为${max_count.value}个`)
}
if ((file.size / 1024 / 1024).toFixed(2) > 20) {
flag = false
showToast('最大文件体积为20MB')
}
}
return flag
}
/**
* 获取文件哈希(与七牛云ETag一致)
* @param {File} file 文件对象
* @returns {Promise<string>} 哈希字符串
* 注释:使用 qiniuFileHash 进行计算,替代浏览器MD5方案。
*/
const getFileMD5 = async (file) => {
return await qiniuFileHash(file)
}
// 上传到七牛云
const uploadToQiniu = async (file, token, fileName) => {
const formData = new FormData()
formData.append('file', file)
formData.append('token', token)
formData.append('key', fileName)
const config = {
headers: { 'Content-Type': 'multipart/form-data' }
}
// 根据协议选择上传地址
const qiniuUploadUrl = window.location.protocol === 'https:'
? 'https://up.qbox.me'
: 'http://upload.qiniu.com'
return await qiniuUploadAPI(qiniuUploadUrl, formData, config)
}
// 处理单个文件上传
const handleUpload = async (file) => {
loading.value = true
try {
// 获取MD5值
const md5 = await getFileMD5(file.file)
// 获取七牛token
const tokenResult = await qiniuTokenAPI({
name: file.file.name,
hash: md5
})
// 文件已存在,直接返回
if (tokenResult.data) {
return tokenResult.data
}
// 新文件上传
if (tokenResult.token) {
const suffix = /.[^.]+$/.exec(file.file.name) || ''
const fileName = `mlaj/upload/checkin/${currentUser.value.mobile}/file/${md5}${suffix}`
const { filekey } = await uploadToQiniu(
file.file,
tokenResult.token,
fileName
)
if (filekey) {
// 保存文件信息
const { data } = await saveFileAPI({
name: file.file.name,
filekey,
hash: md5
})
return data
}
}
return null
} catch (error) {
console.error('Upload error:', error)
return null
} finally {
loading.value = false
}
}
// 文件读取后的处理
const afterRead = async (file) => {
if (Array.isArray(file)) {
// 多文件上传
for (const item of file) {
item.status = 'uploading'
item.message = '上传中...'
const result = await handleUpload(item)
if (result) {
item.status = 'done'
item.message = '上传成功'
item.url = result.url
item.meta_id = result.meta_id
item.name = result.name
} else {
item.status = 'failed'
item.message = '上传失败'
showToast('上传失败,请重试')
}
}
} else {
// 单文件上传
file.status = 'uploading'
file.message = '上传中...'
const result = await handleUpload(file)
if (result) {
file.status = 'done'
file.message = '上传成功'
file.url = result.url
file.meta_id = result.meta_id
file.name = result.name
} else {
file.status = 'failed'
file.message = '上传失败'
showToast('上传失败,请重试')
}
}
}
// 删除文件
const onDelete = (file) => {
const index = fileList.value.indexOf(file)
if (index !== -1) {
fileList.value.splice(index, 1)
}
}
// 提交表单
const onSubmit = async () => {
if (uploading.value) return
// 检查是否所有文件都上传完成
const hasUploadingFiles = fileList.value.some(file => file.status === 'uploading')
if (hasUploadingFiles) {
showToast('请等待所有文件上传完成')
return
}
uploading.value = true
const toast = showLoadingToast({
message: '提交中...',
forbidClick: true,
})
try {
if (route.query.status === 'edit') {
// 编辑打卡接口
const { code, data } = await editUploadTaskInfoAPI({
i: route.query.post_id,
note: message.value,
meta_id: fileList.value.map(item => item.meta_id),
file_type: route.query.type,
});
if (code === 1) {
showToast('提交成功')
router.back()
}
} else {
// 新增打卡接口
const { code, data } = await addUploadTaskAPI({
task_id: route.query.id,
note: message.value,
meta_id: fileList.value.map(item => item.meta_id),
file_type: route.query.type,
});
if (code === 1) {
showToast('提交成功')
router.back()
}
}
} catch (error) {
showToast('提交失败,请重试')
} finally {
toast.close()
uploading.value = false
}
}
onMounted(async () => {
if (route.query.status === 'edit') {
const { code, data } = await getUploadTaskInfoAPI({ i: route.query.post_id });
if (code === 1) {
fileList.value = data.files.map(item => ({
name: item.name,
url: item.value,
status: 'done',
message: '上传成功',
meta_id: item.meta_id,
}))
message.value = data.note
}
}
});
const delItem = (item) => {
const index = fileList.value.indexOf(item)
if (index!== -1) {
fileList.value.splice(index, 1)
}
}
</script>
<style lang="less" scoped>
.checkin-upload-file {
min-height: 100vh;
padding-bottom: 80px;
}
.wrapper {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.preview-cover {
position: absolute;
bottom: 0;
box-sizing: border-box;
width: 100%;
padding: 4px;
color: #fff;
font-size: 12px;
text-align: center;
background: rgba(0, 0, 0, 0.3);
}
</style>
<template>
<div class="checkin-upload-image p-4">
<div class="title text-center pb-4 font-bold">{{route.meta.title}}</div>
<!-- 图片上传区域 -->
<div class="mb-4">
<van-uploader
v-model="fileList"
:max-count="max_count"
:max-size="20 * 1024 * 1024"
:before-read="beforeRead"
:after-read="afterRead"
@delete="onDelete"
multiple
accept="image/*"
result-type="file"
>
</van-uploader>
<div class="mt-2 text-xs text-gray-500">最多上传{{ max_count }}张图片,每张不超过20M</div>
<div class="mt-2 text-xs text-gray-500">上传类型:&nbsp;{{ type_text }}</div>
</div>
<!-- 文字留言区域 -->
<div class="mb-4 border">
<van-field
v-model="message"
rows="4"
autosize
type="textarea"
placeholder="请输入打卡留言"
/>
</div>
<!-- 提交按钮 -->
<div class="fixed bottom-0 left-0 right-0 p-4 bg-white">
<van-button
type="primary"
block
:loading="uploading"
:disabled="!canSubmit"
@click="onSubmit"
>
提交
</van-button>
</div>
<!-- 上传加载遮罩 -->
<van-overlay :show="loading">
<div class="wrapper" @click.stop>
<van-loading vertical color="#FFFFFF">上传中...</van-loading>
</div>
</van-overlay>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { showToast, showLoadingToast } from 'vant'
import { qiniuTokenAPI, qiniuUploadAPI, saveFileAPI } from '@/api/common'
import { addUploadTaskAPI, getUploadTaskInfoAPI, editUploadTaskInfoAPI } from "@/api/checkin";
import { qiniuFileHash } from '@/utils/qiniuFileHash';
import _ from 'lodash'
import { useTitle } from '@vueuse/core';
import { useAuth } from '@/contexts/auth'
const route = useRoute()
const router = useRouter()
const { currentUser } = useAuth()
useTitle(route.meta.title);
const max_count = ref(5);
// 文件列表
const fileList = ref([])
// 留言内容
const message = ref('')
// 上传状态
const uploading = ref(false)
// 上传loading
const loading = ref(false)
// 是否可以提交
const canSubmit = computed(() => {
return fileList.value.length > 0 && message.value.trim() !== ''
})
// 固定类型限制
const imageTypes = "jpg/jpeg/png";
// 文件类型中文页面显示
const type_text = computed(() => {
// return props.item.component_props.image_type;
return imageTypes;
});
// 文件校验
const beforeRead = (file) => {
const image_types = _.map(imageTypes.split("/"), (item) => `image/${item}`);
let flag = true
if (Array.isArray(file)) {
// 多张图片
const invalidTypes = file.filter(item => {
const fileType = item.type.toLowerCase();
return !image_types.some(type => fileType.includes(type.split('/')[1]));
})
if (invalidTypes.length) {
flag = false
showToast('请上传指定格式图片')
}
if (fileList.value.length + file.length > max_count.value) {
flag = false
showToast(`最大上传数量为${max_count.value}张`)
}
} else {
const fileType = file.type.toLowerCase();
if (!image_types.some(type => fileType.includes(type.split('/')[1]))) {
showToast('请上传指定格式图片')
flag = false
}
if (fileList.value.length + 1 > max_count.value) {
flag = false
showToast(`最大上传数量为${max_count.value}张`)
}
if ((file.size / 1024 / 1024).toFixed(2) > 20) {
flag = false
showToast('最大文件体积为20MB')
}
}
return flag
}
/**
* 获取文件哈希(与七牛云ETag一致)
* @param {File} file 文件对象
* @returns {Promise<string>} 哈希字符串
* 注释:使用 qiniuFileHash 进行计算,替代浏览器MD5方案。
*/
const getFileMD5 = async (file) => {
return await qiniuFileHash(file)
}
// 上传到七牛云
const uploadToQiniu = async (file, token, fileName) => {
const formData = new FormData()
formData.append('file', file)
formData.append('token', token)
formData.append('key', fileName)
const config = {
headers: { 'Content-Type': 'multipart/form-data' }
}
// 根据协议选择上传地址
const qiniuUploadUrl = window.location.protocol === 'https:'
? 'https://up.qbox.me'
: 'http://upload.qiniu.com'
return await qiniuUploadAPI(qiniuUploadUrl, formData, config)
}
// 处理单个文件上传
const handleUpload = async (file) => {
loading.value = true
try {
// 获取MD5值
const md5 = await getFileMD5(file.file)
// 获取七牛token
const tokenResult = await qiniuTokenAPI({
name: file.file.name,
hash: md5
})
// 文件已存在,直接返回
if (tokenResult.data) {
return tokenResult.data
}
// 新文件上传
if (tokenResult.token) {
const suffix = /.[^.]+$/.exec(file.file.name) || ''
const fileName = `mlaj/upload/checkin/${currentUser.value.mobile}/img/${md5}${suffix}`
const { filekey, image_info } = await uploadToQiniu(
file.file,
tokenResult.token,
fileName
)
if (filekey) {
// 保存文件信息
const { data } = await saveFileAPI({
name: file.file.name,
filekey,
hash: md5,
height: image_info?.height,
width: image_info?.width
})
return data
}
}
return null
} catch (error) {
console.error('Upload error:', error)
return null
} finally {
loading.value = false
}
}
// 文件读取后的处理
const afterRead = async (file) => {
if (Array.isArray(file)) {
// 多文件上传
for (const item of file) {
item.status = 'uploading'
item.message = '上传中...'
const result = await handleUpload(item)
if (result) {
item.status = 'done'
item.message = '上传成功'
item.url = result.url
item.meta_id = result.meta_id
} else {
item.status = 'failed'
item.message = '上传失败'
showToast('上传失败,请重试')
}
}
} else {
// 单文件上传
file.status = 'uploading'
file.message = '上传中...'
const result = await handleUpload(file)
if (result) {
file.status = 'done'
file.message = '上传成功'
file.url = result.url
file.meta_id = result.meta_id
} else {
file.status = 'failed'
file.message = '上传失败'
showToast('上传失败,请重试')
}
}
}
// 删除文件
const onDelete = (file) => {
const index = fileList.value.indexOf(file)
if (index !== -1) {
fileList.value.splice(index, 1)
}
}
// 提交表单
const onSubmit = async () => {
if (uploading.value) return
// 检查是否所有文件都上传完成
const hasUploadingFiles = fileList.value.some(file => file.status === 'uploading')
if (hasUploadingFiles) {
showToast('请等待所有文件上传完成')
return
}
uploading.value = true
const toast = showLoadingToast({
message: '提交中...',
forbidClick: true,
})
try {
if (route.query.status === 'edit') {
// 编辑打卡接口
const { code, data } = await editUploadTaskInfoAPI({
i: route.query.post_id,
note: message.value,
meta_id: fileList.value.map(item => item.meta_id),
file_type: route.query.type,
});
if (code === 1) {
showToast('提交成功')
router.back()
}
} else {
// 新增打卡接口
const { code, data } = await addUploadTaskAPI({
task_id: route.query.id,
note: message.value,
meta_id: fileList.value.map(item => item.meta_id),
file_type: route.query.type,
});
if (code === 1) {
showToast('提交成功')
router.back()
}
}
} catch (error) {
showToast('提交失败,请重试')
} finally {
toast.close()
uploading.value = false
}
}
onMounted(async () => {
if (route.query.status === 'edit') {
const { code, data } = await getUploadTaskInfoAPI({ i: route.query.post_id });
if (code === 1) {
fileList.value = data.files.map(item => ({
url: item.value,
status: 'done',
message: '上传成功',
meta_id: item.meta_id,
}))
message.value = data.note
}
}
})
</script>
<style lang="less" scoped>
.checkin-upload-image {
min-height: 100vh;
padding-bottom: 80px;
}
.wrapper {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
</style>
<template>
<div class="checkin-upload-text p-4">
<div class="title text-center pb-4 font-bold">{{ route.meta.title }}</div>
<!-- 文字输入区域 -->
<div class="mb-4 border">
<van-field
v-model="message"
rows="8"
autosize
type="textarea"
placeholder="请输入打卡内容, 至少需要10个字符"
maxlength="500"
show-word-limit
/>
</div>
<!-- 提交按钮 -->
<div class="fixed bottom-0 left-0 right-0 p-4 bg-white">
<van-button
type="primary"
block
:loading="uploading"
:disabled="!canSubmit"
@click="onSubmit"
>
提交
</van-button>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { showToast, showLoadingToast } from 'vant'
import { addUploadTaskAPI, getUploadTaskInfoAPI, editUploadTaskInfoAPI } from "@/api/checkin";
import { useTitle } from '@vueuse/core';
const route = useRoute()
const router = useRouter()
useTitle(route.meta.title);
// 留言内容
const message = ref('')
// 上传状态
const uploading = ref(false)
/**
* 是否可以提交
*/
const canSubmit = computed(() => {
return message.value.trim() !== '' && message.value.trim().length >= 10
})
/**
* 提交表单
*/
const onSubmit = async () => {
if (uploading.value) return
if (message.value.trim().length < 10) {
showToast('打卡内容至少需要10个字符')
return
}
uploading.value = true
const toast = showLoadingToast({
message: '提交中...',
forbidClick: true,
})
try {
if (route.query.status === 'edit') {
// 编辑打卡接口
const { code, data } = await editUploadTaskInfoAPI({
i: route.query.post_id,
note: message.value,
meta_id: [], // 文本类型不需要文件
file_type: route.query.type,
});
if (code === 1) {
showToast('提交成功')
router.back()
}
} else {
// 新增打卡接口
const { code, data } = await addUploadTaskAPI({
task_id: route.query.id,
note: message.value,
meta_id: [], // 文本类型不需要文件
file_type: route.query.type,
});
if (code === 1) {
showToast('提交成功')
router.back()
}
}
} catch (error) {
showToast('提交失败,请重试')
} finally {
uploading.value = false
}
}
/**
* 页面挂载时的初始化逻辑
*/
onMounted(async () => {
if (route.query.status === 'edit') {
const { code, data } = await getUploadTaskInfoAPI({ i: route.query.post_id });
if (code === 1) {
message.value = data.note
}
}
})
</script>
<style lang="less" scoped>
.checkin-upload-text {
min-height: 100vh;
padding-bottom: 80px;
}
.van-field {
border-radius: 8px;
background-color: #f8f9fa;
}
.van-field__control {
font-size: 16px;
line-height: 1.5;
}
</style>
<!--
* @Date: 2025-06-03 09:41:41
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-07-02 18:33:29
* @FilePath: /mlaj/src/views/checkin/upload/video.vue
* @Description: 音视频文件上传组件
-->
<template>
<div class="checkin-upload-file p-4">
<div class="title text-center pb-4 font-bold">{{route.meta.title}}</div>
<!-- 文件上传区域 -->
<div class="mb-4">
<van-uploader
v-model="fileList"
:max-count="max_count"
:max-size="20 * 1024 * 1024"
:before-read="beforeRead"
:after-read="afterRead"
@delete="onDelete"
multiple
accept="video/*"
result-type="file"
upload-icon="plus"
:deletable="false"
>
</van-uploader>
<van-row v-for="(item, index) in fileList" :key="index" class="mt-2 text-s text-gray-500">
<van-col span="22">{{ item.name }}</van-col>
<van-col span="2" @click="delItem(item)"><van-icon name="clear" /></van-col>
</van-row>
<van-divider />
<div class="mt-2 text-xs text-gray-500">最多上传{{ max_count }}个文件,每个不超过20M</div>
<div class="mt-2 text-xs text-gray-500">上传类型:&nbsp;视频文件</div>
</div>
<!-- 文字留言区域 -->
<div class="mb-4 border">
<van-field
v-model="message"
rows="4"
autosize
type="textarea"
placeholder="请输入打卡留言"
/>
</div>
<!-- 提交按钮 -->
<div class="fixed bottom-0 left-0 right-0 p-4 bg-white">
<van-button
type="primary"
block
:loading="uploading"
:disabled="!canSubmit"
@click="onSubmit"
>
提交
</van-button>
</div>
<!-- 上传加载遮罩 -->
<van-overlay :show="loading">
<div class="wrapper" @click.stop>
<van-loading vertical color="#FFFFFF">上传中...</van-loading>
</div>
</van-overlay>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { showToast, showLoadingToast } from 'vant'
import { qiniuTokenAPI, qiniuUploadAPI, saveFileAPI } from '@/api/common'
import { addUploadTaskAPI, getUploadTaskInfoAPI, editUploadTaskInfoAPI } from "@/api/checkin";
import { qiniuFileHash } from '@/utils/qiniuFileHash';
import _ from 'lodash'
import { useTitle } from '@vueuse/core';
import { useAuth } from '@/contexts/auth'
const route = useRoute()
const router = useRouter()
const { currentUser } = useAuth()
useTitle(route.meta.title);
const max_count = ref(5);
// 文件列表
const fileList = ref([])
// 留言内容
const message = ref('')
// 上传状态
const uploading = ref(false)
// 上传loading
const loading = ref(false)
// 是否可以提交
const canSubmit = computed(() => {
return fileList.value.length > 0 && message.value.trim() !== ''
})
// 文件校验
const beforeRead = (file) => {
let flag = true
if (Array.isArray(file)) {
// 多个文件
const invalidTypes = file.filter(item => {
const fileType = item.type.toLowerCase();
return !fileType.startsWith('video/');
})
if (invalidTypes.length) {
flag = false
showToast('请上传视频文件')
}
if (fileList.value.length + file.length > max_count.value) {
flag = false
showToast(`最大上传数量为${max_count.value}个`)
}
} else {
const fileType = file.type.toLowerCase();
if (!fileType.startsWith('video/')) {
showToast('请上传视频文件')
flag = false
}
if (fileList.value.length + 1 > max_count.value) {
flag = false
showToast(`最大上传数量为${max_count.value}个`)
}
if ((file.size / 1024 / 1024).toFixed(2) > 20) {
flag = false
showToast('最大文件体积为20MB')
}
}
return flag
}
/**
* 获取文件哈希(与七牛云ETag一致)
* @param {File} file 文件对象
* @returns {Promise<string>} 哈希字符串
* 注释:使用 qiniuFileHash 进行计算,替代浏览器MD5方案。
*/
const getFileMD5 = async (file) => {
return await qiniuFileHash(file)
}
// 上传到七牛云
const uploadToQiniu = async (file, token, fileName) => {
const formData = new FormData()
formData.append('file', file)
formData.append('token', token)
formData.append('key', fileName)
const config = {
headers: { 'Content-Type': 'multipart/form-data' }
}
// 根据协议选择上传地址
const qiniuUploadUrl = window.location.protocol === 'https:'
? 'https://up.qbox.me'
: 'http://upload.qiniu.com'
return await qiniuUploadAPI(qiniuUploadUrl, formData, config)
}
// 处理单个文件上传
const handleUpload = async (file) => {
loading.value = true
try {
// 获取MD5值
const md5 = await getFileMD5(file.file)
// 获取七牛token
const tokenResult = await qiniuTokenAPI({
name: file.file.name,
hash: md5
})
// 文件已存在,直接返回
if (tokenResult.data) {
return tokenResult.data
}
// 新文件上传
if (tokenResult.token) {
const suffix = /.[^.]+$/.exec(file.file.name) || ''
const fileName = `mlaj/upload/checkin/${currentUser.value.mobile}/file/${md5}${suffix}`
const { filekey } = await uploadToQiniu(
file.file,
tokenResult.token,
fileName
)
if (filekey) {
// 保存文件信息
const { data } = await saveFileAPI({
name: file.file.name,
filekey,
hash: md5
})
return data
}
}
return null
} catch (error) {
console.error('Upload error:', error)
return null
} finally {
loading.value = false
}
}
// 文件读取后的处理
const afterRead = async (file) => {
if (Array.isArray(file)) {
// 多文件上传
for (const item of file) {
item.status = 'uploading'
item.message = '上传中...'
const result = await handleUpload(item)
if (result) {
item.status = 'done'
item.message = '上传成功'
item.url = result.url
item.meta_id = result.meta_id
item.name = result.name
} else {
item.status = 'failed'
item.message = '上传失败'
showToast('上传失败,请重试')
}
}
} else {
// 单文件上传
file.status = 'uploading'
file.message = '上传中...'
const result = await handleUpload(file)
if (result) {
file.status = 'done'
file.message = '上传成功'
file.url = result.url
file.meta_id = result.meta_id
file.name = result.name
} else {
file.status = 'failed'
file.message = '上传失败'
showToast('上传失败,请重试')
}
}
}
// 删除文件
const onDelete = (file) => {
const index = fileList.value.indexOf(file)
if (index !== -1) {
fileList.value.splice(index, 1)
}
}
// 提交表单
const onSubmit = async () => {
if (uploading.value) return
// 检查是否所有文件都上传完成
const hasUploadingFiles = fileList.value.some(file => file.status === 'uploading')
if (hasUploadingFiles) {
showToast('请等待所有文件上传完成')
return
}
uploading.value = true
const toast = showLoadingToast({
message: '提交中...',
forbidClick: true,
})
try {
if (route.query.status === 'edit') {
// 编辑打卡接口
const { code, data } = await editUploadTaskInfoAPI({
i: route.query.post_id,
note: message.value,
meta_id: fileList.value.map(item => item.meta_id),
file_type: route.query.type,
});
if (code === 1) {
showToast('提交成功')
router.back()
}
} else {
// 新增打卡接口
const { code, data } = await addUploadTaskAPI({
task_id: route.query.id,
note: message.value,
meta_id: fileList.value.map(item => item.meta_id),
file_type: route.query.type,
});
if (code === 1) {
showToast('提交成功')
router.back()
}
}
} catch (error) {
showToast('提交失败,请重试')
} finally {
toast.close()
uploading.value = false
}
}
onMounted(async () => {
if (route.query.status === 'edit') {
const { code, data } = await getUploadTaskInfoAPI({ i: route.query.post_id });
if (code === 1) {
fileList.value = data.files.map(item => ({
url: item.value,
status: 'done',
message: '上传成功',
meta_id: item.meta_id,
name: item.name,
}))
message.value = data.note
}
}
});
const delItem = (item) => {
const index = fileList.value.indexOf(item)
if (index!== -1) {
fileList.value.splice(index, 1)
}
}
</script>
<style lang="less" scoped>
.checkin-upload-file {
min-height: 100vh;
padding-bottom: 80px;
}
.wrapper {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
</style>
<!--
* @Date: 2025-10-22 10:45:51
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-10-22 10:54:10
* @LastEditTime: 2026-01-24 14:00:37
* @FilePath: /mlaj/src/views/study/PdfPreviewPage.vue
* @Description: 文件描述
* @Description: PDF预览页
-->
<template>
<div class="pdf-preview-page">
......@@ -12,9 +12,10 @@
</template>
<script setup>
import { computed, onMounted, onBeforeUnmount } from 'vue'
import { computed, defineAsyncComponent, onMounted, onBeforeUnmount } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import PdfViewer from '@/components/media/PdfViewer.vue'
const PdfViewer = defineAsyncComponent(() => import('@/components/media/PdfViewer.vue'))
const route = useRoute()
const router = useRouter()
......@@ -37,7 +38,7 @@ const pdfTitle = computed(() => {
const handleClose = () => {
// 清除刷新标记
sessionStorage.removeItem('pdf-preview-refreshed')
const returnId = route.query.returnId
const openMaterials = route.query.openMaterials
if (returnId) {
......
......@@ -702,29 +702,33 @@ const formatData = (data) => {
let images = [];
let audio = [];
let videoList = [];
if (item.file_type === 'image') {
images = item.files.map(file => {
return file.value;
});
} else if (item.file_type === 'video') {
videoList = item.files.map(file => {
return {
id: file.meta_id,
video: file.value,
videoCover: file.cover,
isPlaying: false,
}
})
} else if (item.file_type === 'audio') {
audio = item.files.map(file => {
return {
title: file.name ? file.name : '打卡音频',
artist: file.artist ? file.artist : '',
url: file.value,
cover: file.cover ? file.cover : '',
// 支持多类型混合显示:遍历 files 数组根据 file_type 分类
if (item.files && Array.isArray(item.files)) {
item.files.forEach(file => {
// 优先使用文件自身的 file_type,如果没有则回退到 item.file_type
const type = file.file_type || item.file_type;
if (type === 'image') {
images.push(file.value);
} else if (type === 'video') {
videoList.push({
id: file.meta_id,
video: file.value,
videoCover: file.cover,
isPlaying: false,
});
} else if (type === 'audio') {
audio.push({
title: file.name ? file.name : '打卡音频',
artist: file.artist ? file.artist : '',
url: file.value,
cover: file.cover ? file.cover : '',
});
}
})
});
}
return {
id: item.id,
task_id: item.task_id,
......
......@@ -166,8 +166,7 @@
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import AppLayout from '@/layouts/AppLayout.vue'
import { useRouter, useRoute } from 'vue-router'
import { useTitle } from '@vueuse/core';
import { useAuth } from '@/contexts/auth'
import CourseGroupCascader from '@/components/courses/CourseGroupCascader.vue'
......
......@@ -841,29 +841,33 @@ const formatData = (data) => {
let images = [];
let audio = [];
let videoList = [];
if (item.file_type === 'image') {
images = item.files.map(file => {
return file.value;
});
} else if (item.file_type === 'video') {
videoList = item.files.map(file => {
return {
id: file.meta_id,
video: file.value,
videoCover: file.cover,
isPlaying: false,
}
})
} else if (item.file_type === 'audio') {
audio = item.files.map(file => {
return {
title: file.name ? file.name : '打卡音频',
artist: file.artist ? file.artist : '',
url: file.value,
cover: file.cover ? file.cover : '',
// 支持多类型混合显示:遍历 files 数组根据 file_type 分类
if (item.files && Array.isArray(item.files)) {
item.files.forEach(file => {
// 优先使用文件自身的 file_type,如果没有则回退到 item.file_type
const type = file.file_type || item.file_type;
if (type === 'image') {
images.push(file.value);
} else if (type === 'video') {
videoList.push({
id: file.meta_id,
video: file.value,
videoCover: file.cover,
isPlaying: false,
});
} else if (type === 'audio') {
audio.push({
title: file.name ? file.name : '打卡音频',
artist: file.artist ? file.artist : '',
url: file.value,
cover: file.cover ? file.cover : '',
});
}
})
});
}
return {
id: item.id,
task_id: item.task_id,
......
/*
* @Date: 2025-03-20 19:53:12
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2026-01-18 23:17:57
* @LastEditTime: 2026-01-24 13:56:05
* @FilePath: /mlaj/vite.config.js
* @Description: 文件描述
*/
......@@ -111,6 +111,11 @@ export default ({ mode }) => {
chunkFileNames: 'static/js/[name]-[hash].js',
entryFileNames: 'static/js/[name]-[hash].js',
assetFileNames: 'static/[ext]/[name]-[hash].[ext]',
manualChunks: (id) => {
if (!id.includes('node_modules')) return;
if (id.includes('@vue-office/docx') || id.includes('@vue-office/excel') || id.includes('@vue-office/pptx')) return 'vue-office';
if (id.includes('html2canvas') || id.includes('html-to-image')) return 'image-tools';
},
},
input: { // 多页面应用模式, 打包时配置,运行配置要处理root
main: path.resolve(__dirname, 'index.html'),
......