hookehuyr

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

...@@ -15,3 +15,6 @@ VITE_CONSOLE = 0 ...@@ -15,3 +15,6 @@ VITE_CONSOLE = 0
15 15
16 # appID相关 16 # appID相关
17 VITE_APPID=微信appID 17 VITE_APPID=微信appID
18 +
19 +# 是否开启多附件功能
20 +VITE_CHECKIN_MULTI_ATTACHMENT = 0
......
1 -美乐爱觉项目 1 +# 美乐爱觉项目(mlaj)
2 2
3 -测试环境网站 3 +测试环境网站:<https://oa-dev.onwall.cn/f/mlaj>
4 -https://oa-dev.onwall.cn/f/mlaj 4 +
5 +## 项目概览
6 +
7 +本项目是面向移动端(以微信内置浏览器为主)的教育业务 H5,覆盖「用户登录/微信授权 → 课程浏览/购买 → 学习与内容预览 → 打卡作业(学生端/教师端)→ 活动报名 → 召回促活 → 积分/订单」等完整链路。
8 +
9 +## 技术栈
10 +
11 +- Vue 3 + Vite 6(Hash 路由)
12 +- UI:Vant 4 + TailwindCSS 3 + Less(Tailwind 负责布局,Less 做补充)
13 +- 路由:Vue Router 4(带全局守卫与白名单/Meta 双策略鉴权)
14 +- 请求:Axios(统一注入认证头、统一 401 策略)
15 +- 测试:Vitest(已有 composables/utils 单测)
16 +- 微信生态:weixin-js-sdk、OAuth/OpenID 登录态探测与手动触发授权、微信支付
17 +- 媒体与文档:Video.js、PDF/Office 预览组件
18 +
19 +## 项目结构(快速索引)
20 +
21 +```
22 +src/
23 +├── api/ # 按业务域拆分的接口封装(auth/course/checkin/teacher/...)
24 +├── components/ # 组件(按业务域归类)
25 +├── composables/ # 组合式函数(逻辑复用,含单测)
26 +├── contexts/ # 全局状态(provide/inject:auth/cart)
27 +├── router/ # 路由定义与守卫
28 +├── utils/ # 工具层(axios、上传、鉴权存储、版本更新等)
29 +└── views/ # 页面(按业务域归类)
30 +```
31 +
32 +## 可复用能力清单
33 +
34 +- 认证与登录态:[/src/contexts/auth.js](file:///Users/huyirui/program/itomix/git/mlaj/src/contexts/auth.js)
35 +- 购物车:[/src/contexts/cart.js](file:///Users/huyirui/program/itomix/git/mlaj/src/contexts/cart.js)
36 +- Axios 统一策略:[/src/utils/axios.js](file:///Users/huyirui/program/itomix/git/mlaj/src/utils/axios.js)
37 +- 微信能力封装:[/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)
38 +- 上传与文件处理:[/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)
39 +- 内容预览组件:[/src/components/media](file:///Users/huyirui/program/itomix/git/mlaj/src/components/media)
40 +- 滚动/播放/图片容错等组合式函数:[/src/composables](file:///Users/huyirui/program/itomix/git/mlaj/src/composables)
41 +- 版本更新提示:[/src/utils/versionUpdater.js](file:///Users/huyirui/program/itomix/git/mlaj/src/utils/versionUpdater.js)
42 +
43 +## 项目优缺点(基于现状扫描)
44 +
45 +### 优点
46 +
47 +- 业务域拆分清晰:api / views / components / composables 的分层直观
48 +- 复用意识强:打卡、媒体预览、海报、鉴权与请求拦截均做了抽象
49 +- 微信场景适配完整:授权、JSSDK、支付链路齐全,并避免路由守卫自动授权死循环
50 +- 具备基础测试资产:composables/utils 已引入 Vitest 并有用例
51 +
52 +### 缺点与风险
53 +
54 +- 文档与代码存在不一致:README/CLAUDE 中的组件目录、路径描述与实际目录不一致
55 +- 依赖与工程规范不够统一:同时存在 pnpm/yarn/npm 的 lock 文件,容易引起依赖漂移
56 +- 状态来源较多:localStorage + contexts + axios 默认头并存,一致性风险偏高
57 +- 全局屏蔽 warnHandler:[/src/main.js](file:///Users/huyirui/program/itomix/git/mlaj/src/main.js) 会吞掉 Vue 警告,可能掩盖潜在问题
58 +- 布局目录已归一:统一使用 [/src/components/layout](file:///Users/huyirui/program/itomix/git/mlaj/src/components/layout),已移除 /src/layouts
59 +
60 +## 文档索引
61 +
62 +- 近期功能更新记录:[/docs/CHANGELOG.md](file:///Users/huyirui/program/itomix/git/mlaj/docs/CHANGELOG.md)
63 +- 架构实现与工程配置:[/docs/ARCHITECTURE.md](file:///Users/huyirui/program/itomix/git/mlaj/docs/ARCHITECTURE.md)
64 +- /src/components 组件目录索引:[/docs/COMPONENTS.md](file:///Users/huyirui/program/itomix/git/mlaj/docs/COMPONENTS.md)
65 +- 代码风格与最佳实践:[/VUE_CODE_STYLE_GUIDE.md](file:///Users/huyirui/program/itomix/git/mlaj/VUE_CODE_STYLE_GUIDE.md)
66 +- 已知问题与改进建议:[/ISSUES_TO_FIX.md](file:///Users/huyirui/program/itomix/git/mlaj/ISSUES_TO_FIX.md)
67 +- 协作说明(较长):[/CLAUDE.md](file:///Users/huyirui/program/itomix/git/mlaj/CLAUDE.md)
5 68
6 ## 业务系统架构 69 ## 业务系统架构
7 70
...@@ -62,222 +125,16 @@ https://oa-dev.onwall.cn/f/mlaj ...@@ -62,222 +125,16 @@ https://oa-dev.onwall.cn/f/mlaj
62 - **订单**:查看订单状态(待支付/已支付/退款),支持取消订单。 125 - **订单**:查看订单状态(待支付/已支付/退款),支持取消订单。
63 - **积分**:查看积分余额与变动明细,积分可用于抵扣或兑换(视具体业务规则)。 126 - **积分**:查看积分余额与变动明细,积分可用于抵扣或兑换(视具体业务规则)。
64 127
65 -## 核心技术栈与实现 128 +## 架构实现与工程配置
66 -
67 -### 状态管理
68 -
69 -- **Context API**:项目主要使用 Vue 3 `provide/inject` 模式进行全局状态管理。
70 - - `auth.js`:用户认证、登录态、Token 管理。
71 - - `cart.js`:购物车逻辑、商品增删改、结算流程。
72 -- **持久化**:关键状态同步至 `localStorage`,页面刷新不丢失。
73 -
74 -### 文件处理
75 129
76 -- **七牛云上传** 130 +实现细节(登录态注入、401 策略、微信授权、上传/预览、Vite 代理与环境变量等)已迁移到:[/docs/ARCHITECTURE.md](file:///Users/huyirui/program/itomix/git/mlaj/docs/ARCHITECTURE.md)
77 - - 前端计算文件 Hash (`browser-md5-file` / `qiniuFileHash`)。
78 - - 申请上传 Token(支持 Hash 秒传检测)。
79 - - 直传七牛云对象存储。
80 - - 回填业务服务器保存元数据。
81 -- **文档预览**
82 - - PDF: `@sunsetglow/vue-pdf-viewer`
83 - - Office: `@vue-office/docx`, `@vue-office/excel`, `@vue-office/pptx`
84 - - Video: `@videojs-player/vue` (Video.js 7.x)
85 -
86 -### 路由与权限
87 -
88 -- **路由守卫** (`guards.js`):
89 - - `checkAuth`:基于白名单或 `meta.requiresAuth` 拦截未登录访问。
90 - - `startWxAuth`:按需触发微信授权流程(非自动触发,避免死循环)。
91 -
92 -## 目录结构说明
93 -
94 -```
95 -src/
96 -├── api/ # API 接口层
97 -│ ├── auth.js # 认证
98 -│ ├── checkin.js # 打卡/作业
99 -│ ├── course.js # 课程
100 -│ ├── teacher.js # 教师端
101 -│ ├── recall_users.js # 召回系统
102 -│ └── ...
103 -├── components/ # UI 组件
104 -│ ├── ui/ # 通用组件 (VideoPlayer, CheckInDialog, SearchBar)
105 -│ ├── checkin/ # 打卡业务组件
106 -│ ├── teacher/ # 教师端组件
107 -│ └── ...
108 -├── composables/ # 逻辑复用 (useCheckin, useAuth, useStudyRecordTracker)
109 -├── contexts/ # 全局状态 (auth, cart)
110 -├── router/ # 路由配置
111 -│ ├── checkin.js # 打卡路由
112 -│ ├── teacher.js # 教师路由
113 -│ └── ...
114 -├── views/ # 页面视图
115 -│ ├── auth/ # 登录/注册
116 -│ ├── checkin/ # 打卡相关页面 (Index, Detail, Upload)
117 -│ ├── courses/ # 课程相关页面
118 -│ ├── teacher/ # 教师端页面 (Task, Student, Class)
119 -│ ├── recall/ # 召回系统页面
120 -│ ├── profile/ # 个人中心
121 -│ └── ...
122 -└── ...
123 -```
124 131
125 ## 功能更新记录 (Recent Changes) 132 ## 功能更新记录 (Recent Changes)
126 133
127 -- **打卡详情页重构 (`/checkin/detail`)** 134 +详细记录已迁移到:[/docs/CHANGELOG.md](file:///Users/huyirui/program/itomix/git/mlaj/docs/CHANGELOG.md)
128 - - 统一了文本、媒体上传和计数打卡的入口。
129 - - 实现了基于 `useCheckin` 的通用提交流程。
130 - - 优化了附件预览和编辑回填逻辑。
131 -
132 -- **教师端功能完善**
133 - - 新增作业管理、作业主页(统计与日历视图)、学员作业记录页面。
134 -- **基础体验优化**
135 - - 登录逻辑调整:仅在点击微信图标时触发授权。
136 - - 搜索栏优化:支持 iOS 软键盘搜索键。
137 - - 课程详情页:增加动态 Open Graph 标签,优化分享体验。
138 -
139 -功能更新记录
140 -
141 -- 教师端新增作业管理页面:路径 `/teacher/tasks`,标题“作业管理”。
142 - - 列表展示:作业名称、开始时间、截止时间。
143 - - 当前数据来源为Mock,后续可替换为真实接口数据。
144 -- 教师端新增作业主页:路径 `/teacher/tasks/:id`,标题“作业主页”。
145 - - 头部:作业名称、介绍文案、细项信息(周期、频次、时间段、附件类型)。
146 - - 统计:出勤率与任务完成率(参考 `myClassPage.vue` 统计样式,数据Mock)。
147 - - 日历:使用 `van-calendar` 单选模式,选择日期后展示当日学生完成情况。
148 - - 学生完成情况:参考图片2样式,勾选代表已完成,未勾选代表未完成(数据Mock)。
149 -- 教师端新增学员作业记录页面:路径 `/teacher/student-record`,标题“学员作业记录”。
150 - - 在作业主页的学生列表点击卡片可跳转至该页面(当前版本为固定示例页面)。
151 - - 列表展示:作业帖子、图片/视频/音频、点赞与点评弹窗(与 `studentPage.vue` 的作业记录样式一致)。
152 - - 接口参数固定:`user_id=817017``group_id=816653`(后续可替换为动态参数)。
153 -- 学习详情页标签指示条修复:`/src/views/profile/StudyCoursePage.vue`
154 - - 现象:首次进入且存在“打卡互动”时,底部绿色指示条定位错误。
155 - - 修复:新增标签容器 `ref``ResizeObserver`,按栏目数量对容器进行等分,指示条宽度与位移按分段和索引计算,异步加载第三个栏目时不再错位。
156 -- 登录逻辑调整:仅在登录页微信图标点击时触发授权
157 - - 变更文件:`/src/views/auth/LoginPage.vue``/src/router/guards.js``/src/router/index.js`
158 - - 路由守卫:移除自动微信授权检查,新增 `startWxAuth` 供手动触发。
159 - - 登录页:微信图标绑定点击事件,非微信环境提示“请在微信内打开”。
160 - - 使用方式:进入登录页,点击微信图标进行授权登录。
161 -
162 -- 课程详情页动态 Open Graph 元标签
163 - - 行为:进入课程详情页时,在 `<head>` 中插入 4 个 `meta` 标签:`og:title`(课程 `title`)、`og:description`(课程 `subtitle`)、`og:image`(课程 `cover`)、`og:url`(当前页面 URL);这些 `meta` 必须插在 `<title>` 标签的前面;离开页面时移除。
164 - - CDN 规则:若图片域名为 `cdn.ipadbiz.cn`,自动追加 `?imageMogr2/thumbnail/200x/strip/quality/70`
165 - - 位置:`/src/views/courses/CourseDetailPage.vue`,在 `onMounted` 插入,`onUnmounted` 清理。
166 - - 函数:`build_og_image_url(src)``set_og_meta(payload)``remove_og_meta()`
167 -
168 -- 课程详情页咨询弹窗(Mock)
169 - - 入口:详情页顶部快捷操作中的“咨询”按钮。
170 - - 展示:底部弹出层,仅底部关闭按钮;展示联系人列表,可点击名称或号码直接拨打(`tel:`)。
171 - - 联系人:支持多个联系人,展示姓名与号码;电话号码使用绿色高亮;联系人与号码字体大小一致。
172 - - 位置:`/src/views/courses/CourseDetailPage.vue`,“咨询弹窗”模板与交互逻辑(`open_consult_dialog``close_consult_dialog``consult_contacts``call_phone(phone)`)。
173 -
174 -- 购买流程环境校验
175 - - 行为:仅对非免费课程在详情页点击“购买”时进行校验;生产环境下必须为微信内置浏览器(`wxInfo().isWeiXin`)。
176 - - 免费课程:跳过微信环境校验,允许直接进入结算流程。
177 - - 非微信环境(付费课):提示“请在微信内打开进行购买”,不进入结算。
178 - - 位置:`/src/views/courses/CourseDetailPage.vue``handlePurchase` 中,使用 `wxInfo` 进行环境判断。
179 -
180 -- 微信授权自动触发(微信环境内)
181 - - 行为:在微信内置浏览器环境下点击“购买”时,若检测到未完成微信授权(`openid_has=false`),系统将自动发起一次微信授权流程并中止本次购买;授权完成后再次点击可进入结算。
182 - - 开发环境:不触发微信授权流程(保留现有调试行为)。
183 - - 位置:`/src/views/courses/CourseDetailPage.vue``handlePurchase` 中,调用 `getAuthInfoAPI()` 探测并使用 `startWxAuth()` 触发授权。
184 -
185 -- 401拦截策略优化(公开页面不再跳登录)
186 - - 行为:接口返回 `code=401` 时,不再对公开页面(如课程详情 `/courses/:id`)进行登录重定向;仅当当前路由确实需要登录权限时才跳转至登录页。
187 - - 原理:响应拦截器调用路由守卫 `checkAuth` 判断当前路由是否为受限页面,受限则清理登录信息并附带 `redirect` 重定向至登录页;公开页面保持当前页,由业务自行处理401。
188 - - 位置:`/src/utils/axios.js`,在响应拦截器中按需处理重定向。
189 -- 搜索栏回车搜索兼容性提升
190 - - 行为:将输入框类型改为 `search`,并可选开启 `form @submit.prevent` 机制以提升 iOS 设备软键盘“搜索”键触发稳定性;同时保留 `@keyup.enter` 回车触发搜索。
191 - - 路由行为:
192 - - 课程页(`isCoursePage=true`):回车或搜索键触发后跳转至 `/courses-list` 并携带 `keyword` 参数。
193 - - 列表页:仅更新当前路由的查询参数 `keyword`
194 - - 位置:`/src/components/ui/SearchBar.vue`,新增 `useFormSubmit` 可选参数(默认开启)。
195 -- 课程列表页搜索触发修复
196 - - 现象:进入列表页后,输入关键字并更新为 `/courses-list?keyword=xxx` 时未触发搜索。
197 - - 原因:父组件 `handleSearch` 对“相同关键字”进行了拦截,导致路由参数变化不执行请求。
198 - - 修复:移除拦截逻辑;无论关键字是否变化均触发搜索(防抖控制频率)。
199 - - 位置:`/src/views/courses/CourseListPage.vue``handleSearch`
200 -
201 -- 分享海报弹窗(通用组件)
202 - - 入口:课程详情页底部操作栏“分享”按钮。
203 - - 组件:`/src/components/ui/SharePoster.vue`(支持复用),`v-model:show` 控制显隐,`course` 传入课程信息,`qr_url` 可指定二维码内容地址(默认取当前页面 URL)。
204 - - 布局:上部封面图;下部信息区左侧二维码,右侧课程标题、副标题、精简介绍与日期范围。
205 - - 样式:优先使用 TailwindCSS 布局;组件内部使用 Less 做层级嵌套的样式补充。
206 - - 图片规则:当封面图域名为 `cdn.ipadbiz.cn` 时,自动追加 `?imageMogr2/thumbnail/200x/strip/quality/70` 压缩参数。
207 - - 接入位置:`/src/views/courses/CourseDetailPage.vue`,导入并渲染 `<SharePoster v-model:show="show_share_poster" :course="course" />`
208 - - Canvas 合成:弹窗打开时使用 Canvas 直接合成海报(封面图、二维码、文案),生成 `dataURL` 并以 `<img>` 展示,用户可直接长按图片保存到手机(无需额外按钮)。
209 - - 依赖:`pnpm add qrcode`(在 Canvas 内本地生成二维码,避免跨域图片导致画布污染)。
210 - - 跨域:通过 `crossorigin="anonymous"` 加载封面,并追加时间戳防缓存;若封面跨域不允许,则显示降级卡片,仍可长按截图保存。
211 - - 文案:使用中文字体并自动换行限制行数,末行超出追加省略号。
212 -
213 -- 打卡弹窗统一为通用组件 CheckInDialog
214 - - 目的:统一 CourseDetailPage、StudyCoursePage、StudyDetailPage 三处页面的打卡弹窗与交互,避免重复逻辑。
215 - - 组件:`/src/components/ui/CheckInDialog.vue``v-model:show` 控制显隐;支持外部传入任务列表。
216 - - Props:
217 - - `items_today`:今日打卡任务数组(外部传入)。
218 - - `items_history`:历史打卡任务数组(外部传入)。
219 - - 数据结构:每项需包含 `id``title(name)``task_type``checkin`/`upload`)、`is_gray`
220 - - 使用位置:
221 - - `/src/views/courses/CourseDetailPage.vue`
222 - - `/src/views/profile/StudyCoursePage.vue`
223 - - `/src/views/study/StudyDetailPage.vue`
224 - - 清理:上述页面已移除旧弹窗的冗余状态与方法(如 `default_list``showTaskList``showTimeoutTaskList``selectedCheckIn` 等),统一由组件内部处理。
225 -
226 -- 打卡列表组件 CheckInList(复用)
227 - - 目的:抽取首页与弹窗内重复的“打卡类型列表 + 提交按钮”UI与交互逻辑,提升复用性与维护性。
228 - - 位置:`/src/components/ui/CheckInList.vue`,样式补充:`/src/components/ui/CheckInList.less`(使用 Less 层级嵌套)。
229 - - Props:
230 - - `items`:打卡任务数组,元素包含 `id``name``task_type``checkin`/`upload`)、`is_gray`
231 - - `dense`:是否使用更紧凑的栅格与间距。
232 - - `scroll`:是否启用滚动容器(最大高度 13rem)。
233 - - Emits:
234 - - `submit-success`:提交成功后触发,由父组件决定后续行为(轻提示、关闭弹窗等)。
235 - - 行为:
236 - - 点击置灰的 `checkin` 项时提示“您已经完成了今天的打卡”。
237 - - 点击 `upload` 类型时跳转到 `/checkin/index?id=xxx` 上传页面。
238 - - 选择 `checkin` 类型后显示提交按钮,点击后调用接口提交;成功后抛出 `submit-success` 并重置选中项。
239 - - 使用位置:
240 - - 首页:`/src/views/HomePage.vue`(替换原重复 UI,监听 `submit-success` 显示“打卡成功”)。
241 - - 弹窗:`/src/components/ui/CheckInDialog.vue`(以 `active_list` 作为数据源,监听 `submit-success` 转发为 `check-in-success` 并延时关闭)。
242 -
243 -- 打卡详情页(统一提交/编辑页)CheckinDetailPage
244 - - 路径:`/checkin/detail`,页面文件:`/src/views/checkin/CheckinDetailPage.vue`
245 - - 目标:统一“提交作业/打卡”的入口,覆盖文本/图片/视频/音频上传,以及计数打卡;同时承接“编辑已提交动态”的入口。
246 - - 路由参数约定(query):
247 - - 新增提交:`task_id`(大作业ID)、`subtask_id`(可选,默认选中小作业)、`date`(YYYY-MM-DD,用于补卡与月份计算)、`is_patch`(是否补卡:'1'/'0')、`task_type`(当前作业类型,常见为 `upload`/`count`)。
248 - - 编辑提交:额外携带 `status=edit``post_id`(打卡动态ID)、`type`(原 `file_type`,实际以详情接口回填为准)。
249 - - 初始化数据:
250 - - 大作业详情:调用 `getTaskDetailAPI({ i: task_id, month })` 获取作业描述、默认附件类型、完成状态等。
251 - - 小作业列表:调用 `getSubtaskListAPI({ task_id, date })` 获取小作业列表并生成 Picker 选项(包含 `note/field_list/person_type/attachment_type/is_makeup` 等)。
252 - - 编辑回填:通过 `useCheckin().initEditData``getUploadTaskInfoAPI({ i: post_id })` 回填 `note/file_type/files/subtask_id/gratitude_count` 等。
253 - - 附件类型与上传限制:
254 - - `normalizeAttachmentTypeConfig` 解析后端 `attachment_type` 配置,生成 `attachmentTypeOptions`,并通过 `setMaxFileSizeMbMap` 写入不同类型的最大上传大小。
255 - - 计数打卡(`task_type=count`)下过滤掉 `text` 类型;非计数模式下当 `activeType` 不在选项中时自动重置为第一个可用类型。
256 - - 提交流程(核心封装在 `useCheckin`):
257 - - 上传:`beforeRead/afterRead` 先校验数量/大小/类型,再走七牛上传(hash 去重 → token → upload → saveFile),成功回填 `meta_id/url/status`
258 - - 提交:`onSubmit` 新增走 `addUploadTaskAPI`,编辑走 `editUploadTaskInfoAPI`;成功后写 `sessionStorage.checkin_refresh_flag/checkin_refresh_id` 供列表页局部刷新,并 `router.back()` 返回。
259 - - 页面层校验:文本打卡至少 10 字;计数打卡必须选择对象且次数 > 0;非文本类型必须有文件。
260 - - 计数打卡交互:
261 - - 通过 `reuseGratitudeFormAPI({ subtask_id })` 获取可选对象列表 `targetList` 与最近使用 `last_used_list`,进入页面会自动勾选最近使用项。
262 - - 选中对象首次会弹窗确认(标记 `has_confirmed`),支持新增/编辑;删除对象调用 `gratitudeDeleteAPI`(当前页面内未引入该 API,需补齐后才可生效)。
263 - - 预览能力:
264 - - 上传组件点击预览时,根据扩展名识别:音频使用 `AudioPlayer` 底部弹窗;视频使用 `VideoPlayer` 居中弹窗(封面 → 点击播放 → 关闭时重置进度);图片使用 `van-image-preview`
265 135
266 ## /src/components 目录下组件 136 ## /src/components 目录下组件
267 137
268 -| 目标目录(src/components/) | 包含组件 | 说明 | 138 +组件目录索引已迁移到:[/docs/COMPONENTS.md](file:///Users/huyirui/program/itomix/git/mlaj/docs/COMPONENTS.md)
269 -| --------------------------- | ------------------------------------------------------------ | ---------------- |
270 -| `checkin/` | `CheckInDialog.vue`, `CheckInList.vue`, `CheckInResult.vue` | 打卡相关组件 |
271 -| `media/` | `AudioPlayer.vue`, `VideoPlayer.vue`, `MusicPlayer.vue` | 音视频播放组件 |
272 -| `activity/` | `ActivityApplyHistoryPopup.vue`, `ActivityCard.vue`, `ActivityStatusBadge.vue`, `ActivityTicket.vue` | 活动相关组件 |
273 -| `common/` | `ConfirmDialog.vue`, `GradientHeader.vue`, `MenuItem.vue`, `SearchBar.vue`, `TermsPopup.vue`, `UserAgreement.vue` | 通用基础组件 |
274 -| `effects/` | `FrostedGlass.vue`, `LoadingSpinner.vue` | 视觉特效组件 |
275 -| `courses/` | `CourseCard.vue`, `LiveStreamCard.vue` | 课程展示组件 |
276 -| `payment/` | `WechatPayment.vue` | 支付组件 |
277 -| `studyDetail/` | `StudyMaterialsPopup.vue` | 学习资料弹窗 |
278 -| `layout/` | `AppLayout.vue`, `BottomNav.vue` | 布局与导航 |
279 -| `share/` | `SharePoster.vue` | 分享海报 |
280 -| `files/` | `FilePreview.vue` | 文件预览 |
281 -| `feedback/` | `FeedbackForm.vue` | 反馈表单 |
282 139
283 --- 140 ---
......
...@@ -204,7 +204,7 @@ Vue 官方建议在 SFC + Composition API 场景使用 `<script setup>`,因为 ...@@ -204,7 +204,7 @@ Vue 官方建议在 SFC + Composition API 场景使用 `<script setup>`,因为
204 4) 清理“重复/并存”的实现:避免同名组件在不同目录各自演进 204 4) 清理“重复/并存”的实现:避免同名组件在不同目录各自演进
205 205
206 - 目标:减少认知负担与误用风险(例如存在两个 `AppLayout` 206 - 目标:减少认知负担与误用风险(例如存在两个 `AppLayout`
207 -- 涉及文件:[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) 207 +- 涉及文件:[AppLayout.vue](file:///Users/huyirui/program/itomix/git/mlaj/src/components/layout/AppLayout.vue)(已完成归一,移除 /src/layouts)
208 208
209 5) 降低“语法形态”混用成本:能不用 JSX 就别用 209 5) 降低“语法形态”混用成本:能不用 JSX 就别用
210 210
......
1 +# 架构实现与工程配置
2 +
3 +## 入口与初始化
4 +
5 +- 应用入口:[/src/main.js](file:///Users/huyirui/program/itomix/git/mlaj/src/main.js)
6 + - 创建 App、注册全局 Icon 组件、挂载路由
7 + - 全局注入 axios 到 app.config.globalProperties.$http
8 +- 根组件:[/src/App.vue](file:///Users/huyirui/program/itomix/git/mlaj/src/App.vue)
9 + - 初始化全局认证与购物车:provideAuth / provideCart
10 + - 生产环境 + 微信环境:初始化微信 JSSDK(配置签名 URL)
11 + - 生产环境:版本更新探测(弹窗提示刷新)
12 +
13 +## 路由与权限
14 +
15 +- 路由入口:[/src/router/index.js](file:///Users/huyirui/program/itomix/git/mlaj/src/router/index.js)
16 + - Hash 路由:createWebHashHistory(import.meta.env.VITE_BASE || '/')
17 + - beforeEach:统一登录页回跳处理,并在必要时探测“是否已登录”
18 +- 鉴权策略:[/src/router/guards.js](file:///Users/huyirui/program/itomix/git/mlaj/src/router/guards.js)
19 + - 白名单 + meta.requiresAuth 双策略判断
20 + - 未登录时重定向 /login 并带 redirect
21 +- 微信授权策略:[/src/router/guards.js](file:///Users/huyirui/program/itomix/git/mlaj/src/router/guards.js)
22 + - 不在路由守卫自动触发授权,避免循环
23 + - 仅在用户触发(如点击微信图标/购买流程探测)时调用 startWxAuth
24 +
25 +## 请求与登录态注入
26 +
27 +- Axios 封装:[/src/utils/axios.js](file:///Users/huyirui/program/itomix/git/mlaj/src/utils/axios.js)
28 + - 请求拦截:动态读取本地 user_info 并注入 User-Id / User-Token
29 + - 响应拦截:code=401 时,仅当当前路由确实需要登录才跳转登录(公开页面不强制跳转)
30 +- 登录态管理:[/src/contexts/auth.js](file:///Users/huyirui/program/itomix/git/mlaj/src/contexts/auth.js)
31 + - provide/inject 维护 currentUser/loading/login/logout
32 + - localStorage 持久化 currentUser
33 + - 初始化流程中会探测授权/登录态并拉取用户信息
34 +
35 +## 购物车与结算
36 +
37 +- 购物车上下文:[/src/contexts/cart.js](file:///Users/huyirui/program/itomix/git/mlaj/src/contexts/cart.js)
38 + - 单品/多品两种模式(App.vue 默认使用单品模式)
39 + - localStorage 带时间戳的过期策略(默认一天过期)
40 + - handleCheckout 负责构建订单数据并提交订单
41 +
42 +## 上传与预览
43 +
44 +- 上传工具:[/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)
45 + - 前端计算文件 Hash,支持“秒传检测 + 直传对象存储”
46 +- 关键业务复用:[/src/composables/useCheckin.js](file:///Users/huyirui/program/itomix/git/mlaj/src/composables/useCheckin.js)
47 + - 打卡/作业提交流程:校验、上传、提交、编辑回填等
48 +- 预览组件:[/src/components/media](file:///Users/huyirui/program/itomix/git/mlaj/src/components/media)
49 + - 视频/音频播放器、PDF/Office 预览等
50 +
51 +## Vite 配置与环境变量
52 +
53 +- Vite 配置:[/vite.config.js](file:///Users/huyirui/program/itomix/git/mlaj/vite.config.js)
54 + - 自动按需引入 Vant 组件(unplugin-auto-import / unplugin-vue-components)
55 + - 别名:@ / @components / @utils / @api 等
56 + - 本地代理:createProxy(viteEnv.VITE_PROXY_PREFIX, viteEnv.VITE_PROXY_TARGET)
57 +- 环境变量示例:[.env](file:///Users/huyirui/program/itomix/git/mlaj/.env)
58 + - VITE_PORT:开发端口
59 + - VITE_PROXY_TARGET / VITE_PROXY_PREFIX:接口代理目标与前缀
60 + - VITE_OUTDIR:构建输出目录
61 + - VITE_CONSOLE:调试开关
62 +
63 +## 目录结构(详细)
64 +
65 +```
66 +mlaj/
67 +├── build/ # Vite 代理封装
68 +├── docs/ # 项目文档(本目录)
69 +├── public/ # 静态资源
70 +├── src/
71 +│ ├── api/ # 按业务域拆分的接口封装(auth/course/checkin/teacher/...)
72 +│ ├── assets/ # 图片等资源
73 +│ ├── common/ # 常量
74 +│ ├── components/ # 组件(按业务域归类)
75 +│ ├── composables/ # 组合式函数(逻辑复用,含单测)
76 +│ ├── contexts/ # 全局状态(provide/inject:auth/cart)
77 +│ ├── router/ # 路由定义与守卫
78 +│ ├── utils/ # 工具层(axios、上传、鉴权存储、版本更新等)
79 +│ └── views/ # 页面(按业务域归类)
80 +├── tailwind.config.js # Tailwind 配置
81 +└── vite.config.js # Vite 配置
82 +```
1 +# 功能更新记录(Recent Changes)
2 +
3 +说明:该章节从 README 迁移到本文件,避免 README 过长。后续新增变更建议追加在文件顶部。
4 +
5 +## 打卡详情页重构(/checkin/detail)
6 +
7 +- 统一了文本、媒体上传和计数打卡的入口
8 +- 实现了基于 composables 的通用提交流程:[/src/composables/useCheckin.js](file:///Users/huyirui/program/itomix/git/mlaj/src/composables/useCheckin.js)
9 +- 页面入口:[/src/views/checkin/CheckinDetailPage.vue](file:///Users/huyirui/program/itomix/git/mlaj/src/views/checkin/CheckinDetailPage.vue)
10 +- 优化了附件预览与编辑回填逻辑(音频/视频/图片预览能力)
11 +
12 +## 教师端功能完善(/teacher)
13 +
14 +- 新增作业管理页面:/teacher/tasks(列表展示:名称、开始/截止时间)
15 +- 新增作业主页:/teacher/tasks/:id(统计 + 日历视图)
16 +- 新增学员作业记录页:/teacher/student-record(作业帖子 + 点赞/点评)
17 +
18 +## 基础体验优化
19 +
20 +- 登录逻辑调整:仅在登录页点击微信图标时触发授权(避免路由守卫自动授权导致的循环)
21 + - 关键文件:[/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)
22 +- 搜索栏优化:提升 iOS 软键盘“搜索”键触发稳定性
23 +- 课程详情页:增加动态 Open Graph 标签,优化分享体验
24 +
25 +## 课程详情页动态 Open Graph 元标签
26 +
27 +- 行为:进入课程详情页时,在 head 中插入 og:title / og:description / og:image / og:url;离开页面时移除
28 +- CDN 规则:图片域名为 cdn.ipadbiz.cn 时,追加 ?imageMogr2/thumbnail/200x/strip/quality/70
29 +- 位置:[/src/views/courses/CourseDetailPage.vue](file:///Users/huyirui/program/itomix/git/mlaj/src/views/courses/CourseDetailPage.vue)
30 +
31 +## 购买流程环境校验与微信授权探测
32 +
33 +- 行为:仅对非免费课程在详情页点击“购买”时进行校验;生产环境必须为微信内置浏览器
34 +- 微信环境内:若未完成微信授权(openid_has=false),会自动发起一次微信授权并中止本次购买,授权后再次点击进入结算
35 +- 位置:[/src/views/courses/CourseDetailPage.vue](file:///Users/huyirui/program/itomix/git/mlaj/src/views/courses/CourseDetailPage.vue)
36 +
37 +## 401 拦截策略优化(公开页面不再跳登录)
38 +
39 +- 行为:接口返回 code=401 时,仅当当前路由确实需要登录时才重定向登录
40 +- 位置:[/src/utils/axios.js](file:///Users/huyirui/program/itomix/git/mlaj/src/utils/axios.js)
41 +
42 +## 搜索栏回车搜索兼容性提升
43 +
44 +- 行为:输入框类型改为 search,并可选开启 form submit 机制,同时保留 keyup.enter
45 +- 位置:[/src/components/common/SearchBar.vue](file:///Users/huyirui/program/itomix/git/mlaj/src/components/common/SearchBar.vue)
46 +
47 +## 分享海报弹窗(可复用)
48 +
49 +- 入口:课程详情页底部操作栏“分享”按钮
50 +- 组件:[/src/components/poster/SharePoster.vue](file:///Users/huyirui/program/itomix/git/mlaj/src/components/poster/SharePoster.vue)
51 +- 能力:弹窗打开时通过 Canvas 合成海报(封面、二维码、文案),生成 dataURL 展示,用户长按保存
52 +
53 +## 打卡弹窗与列表组件(可复用)
54 +
55 +- 打卡弹窗:[/src/components/checkin/CheckInDialog.vue](file:///Users/huyirui/program/itomix/git/mlaj/src/components/checkin/CheckInDialog.vue)
56 +- 打卡列表:[/src/components/checkin/CheckInList.vue](file:///Users/huyirui/program/itomix/git/mlaj/src/components/checkin/CheckInList.vue)
1 +# /src/components 组件目录索引
2 +
3 +## 目录划分
4 +
5 +| 目录 | 代表组件 | 说明 |
6 +| --- | --- | --- |
7 +| activity/ | ActivityCard.vue、ActivityApplyHistoryPopup.vue | 活动卡片、报名/历史相关弹窗 |
8 +| calendar/ | CollapsibleCalendar.vue、TaskCalendar.vue | 日历与任务日历组件 |
9 +| checkin/ | CheckInDialog.vue、CheckInList.vue、CheckinCard.vue、UploadVideoPopup.vue | 打卡/作业相关组件(弹窗、列表、卡片、上传) |
10 +| common/ | ConfirmDialog.vue、GradientHeader.vue、SearchBar.vue、UserAgreement.vue | 通用基础组件(确认、头部、搜索、协议) |
11 +| count/ | AddTargetDialog.vue、CheckinTargetList.vue、postCountModel.vue | 计数型打卡相关组件 |
12 +| courses/ | CourseCard.vue、CourseList.vue、LiveStreamCard.vue、ReviewPopup.vue | 课程展示与列表、直播卡片、评价弹窗等 |
13 +| effects/ | FrostedGlass.vue、StarryBackground.vue | 视觉效果组件 |
14 +| homePage/ | FeaturedCoursesSection.vue、LatestActivitiesSection.vue | 首页区块组件(精选/活动/推荐等) |
15 +| infoEntry/ | formPage.vue | 信息录入相关组件 |
16 +| layout/ | AppLayout.vue、BottomNav.vue | 页面布局与底部导航 |
17 +| media/ | AudioPlayer.vue、VideoPlayer.vue、PdfPreview.vue、OfficeViewer.vue | 音视频播放器与文档预览 |
18 +| payment/ | WechatPayment.vue | 微信支付相关组件 |
19 +| poster/ | RecallPoster.vue、SharePoster.vue | 海报生成与分享相关组件 |
20 +| studyDetail/ | StudyCatalogPopup.vue、StudyCommentsSection.vue、StudyMaterialsPopup.vue | 学习详情页的弹窗与评论区 |
21 +| teacher/ | TaskFilter.vue、TaskCascaderFilter.vue | 教师端筛选与任务相关组件 |
22 +
23 +## 备注
24 +
25 +- 布局目录已归一:统一使用 [/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 ...@@ -130,7 +130,7 @@ export const getUploadTaskInfoAPI = (params) => fn(fetch.get(Api.TASK_UPLOAD_IN
130 * @param gratitude_form_list 感恩表单数据 [{id,name,city,unit,其他信息字段}] 130 * @param gratitude_form_list 感恩表单数据 [{id,name,city,unit,其他信息字段}]
131 * @returns 131 * @returns
132 */ 132 */
133 -export const editUploadTaskInfoAPI = (params) => fn(fetch.get(Api.TASK_UPLOAD_EDIT, params)) 133 +export const editUploadTaskInfoAPI = (params) => fn(fetch.post(Api.TASK_UPLOAD_EDIT, params))
134 134
135 /** 135 /**
136 * @description: 删除打卡动态详情 136 * @description: 删除打卡动态详情
......
...@@ -69,7 +69,6 @@ declare module 'vue' { ...@@ -69,7 +69,6 @@ declare module 'vue' {
69 VanConfigProvider: typeof import('vant/es')['ConfigProvider'] 69 VanConfigProvider: typeof import('vant/es')['ConfigProvider']
70 VanDatePicker: typeof import('vant/es')['DatePicker'] 70 VanDatePicker: typeof import('vant/es')['DatePicker']
71 VanDialog: typeof import('vant/es')['Dialog'] 71 VanDialog: typeof import('vant/es')['Dialog']
72 - VanDivider: typeof import('vant/es')['Divider']
73 VanDropdownItem: typeof import('vant/es')['DropdownItem'] 72 VanDropdownItem: typeof import('vant/es')['DropdownItem']
74 VanDropdownMenu: typeof import('vant/es')['DropdownMenu'] 73 VanDropdownMenu: typeof import('vant/es')['DropdownMenu']
75 VanEmpty: typeof import('vant/es')['Empty'] 74 VanEmpty: typeof import('vant/es')['Empty']
...@@ -95,8 +94,6 @@ declare module 'vue' { ...@@ -95,8 +94,6 @@ declare module 'vue' {
95 VanSwipe: typeof import('vant/es')['Swipe'] 94 VanSwipe: typeof import('vant/es')['Swipe']
96 VanSwipeItem: typeof import('vant/es')['SwipeItem'] 95 VanSwipeItem: typeof import('vant/es')['SwipeItem']
97 VanTab: typeof import('vant/es')['Tab'] 96 VanTab: typeof import('vant/es')['Tab']
98 - VanTabbar: typeof import('vant/es')['Tabbar']
99 - VanTabbarItem: typeof import('vant/es')['TabbarItem']
100 VanTabs: typeof import('vant/es')['Tabs'] 97 VanTabs: typeof import('vant/es')['Tabs']
101 VanTag: typeof import('vant/es')['Tag'] 98 VanTag: typeof import('vant/es')['Tag']
102 VanTimePicker: typeof import('vant/es')['TimePicker'] 99 VanTimePicker: typeof import('vant/es')['TimePicker']
......
...@@ -11,7 +11,7 @@ ...@@ -11,7 +11,7 @@
11 <!-- Activity Image --> 11 <!-- Activity Image -->
12 <div class="w-1/3 h-28 relative"> 12 <div class="w-1/3 h-28 relative">
13 <img 13 <img
14 - :src="activity.imageUrl" 14 + :src="buildCdnImageUrl(activity.imageUrl)"
15 :alt="activity.title" 15 :alt="activity.title"
16 class="w-full h-full object-cover" 16 class="w-full h-full object-cover"
17 /> 17 />
...@@ -76,6 +76,7 @@ ...@@ -76,6 +76,7 @@
76 76
77 <script setup> 77 <script setup>
78 import FrostedGlass from '@/components/effects/FrostedGlass.vue' 78 import FrostedGlass from '@/components/effects/FrostedGlass.vue'
79 +import { buildCdnImageUrl } from '@/utils/tools'
79 80
80 const props = defineProps({ 81 const props = defineProps({
81 activity: { 82 activity: {
......
...@@ -9,7 +9,7 @@ ...@@ -9,7 +9,7 @@
9 <router-link :to="linkTo || `/courses/${course.id}`" class="flex bg-white rounded-lg overflow-hidden shadow-sm"> 9 <router-link :to="linkTo || `/courses/${course.id}`" class="flex bg-white rounded-lg overflow-hidden shadow-sm">
10 <div class="w-1/3 h-28 relative"> 10 <div class="w-1/3 h-28 relative">
11 <img 11 <img
12 - :src="course.cover || 'https://cdn.ipadbiz.cn/mlaj/images/default_block.png'" 12 + :src="buildCdnImageUrl(course.cover || 'https://cdn.ipadbiz.cn/mlaj/images/default_block.png')"
13 :alt="course.title" 13 :alt="course.title"
14 class="w-full h-full object-cover" 14 class="w-full h-full object-cover"
15 /> 15 />
...@@ -44,6 +44,8 @@ ...@@ -44,6 +44,8 @@
44 </template> 44 </template>
45 45
46 <script setup> 46 <script setup>
47 +import { buildCdnImageUrl } from '@/utils/tools'
48 +
47 /** 49 /**
48 * @description 课程卡片组件 50 * @description 课程卡片组件
49 * @property {Object} course 课程对象 51 * @property {Object} course 课程对象
......
...@@ -15,7 +15,7 @@ ...@@ -15,7 +15,7 @@
15 15
16 <router-link :to="`/courses/${stream.id}`" class="block rounded-lg overflow-hidden shadow-sm relative"> 16 <router-link :to="`/courses/${stream.id}`" class="block rounded-lg overflow-hidden shadow-sm relative">
17 <img 17 <img
18 - :src="stream.imageUrl" 18 + :src="buildCdnImageUrl(stream.imageUrl)"
19 :alt="stream.title" 19 :alt="stream.title"
20 class="w-full h-28 object-cover" 20 class="w-full h-28 object-cover"
21 /> 21 />
...@@ -43,6 +43,8 @@ ...@@ -43,6 +43,8 @@
43 </template> 43 </template>
44 44
45 <script setup> 45 <script setup>
46 +import { buildCdnImageUrl } from '@/utils/tools'
47 +
46 defineProps({ 48 defineProps({
47 /** 49 /**
48 * 直播流数据对象 50 * 直播流数据对象
......
...@@ -16,7 +16,7 @@ ...@@ -16,7 +16,7 @@
16 > 16 >
17 <div class="relative rounded-xl overflow-hidden shadow-lg h-48"> 17 <div class="relative rounded-xl overflow-hidden shadow-lg h-48">
18 <img 18 <img
19 - :src="course.cover || 'https://cdn.ipadbiz.cn/mlaj/images/default_block.png'" 19 + :src="buildCdnImageUrl(course.cover || 'https://cdn.ipadbiz.cn/mlaj/images/default_block.png', 400)"
20 :alt="course.title" 20 :alt="course.title"
21 class="w-full h-full object-cover" 21 class="w-full h-full object-cover"
22 /> 22 />
...@@ -79,6 +79,7 @@ ...@@ -79,6 +79,7 @@
79 <script setup> 79 <script setup>
80 import { ref, onMounted, onUnmounted } from 'vue' 80 import { ref, onMounted, onUnmounted } from 'vue'
81 import { getCourseListAPI } from '@/api/course' 81 import { getCourseListAPI } from '@/api/course'
82 +import { buildCdnImageUrl } from '@/utils/tools'
82 83
83 const courses = ref([]) 84 const courses = ref([])
84 const carouselRef = ref(null) 85 const carouselRef = ref(null)
......
...@@ -117,7 +117,9 @@ const fetch_external_activities = async () => { ...@@ -117,7 +117,9 @@ const fetch_external_activities = async () => {
117 const mapped = list.map((item) => { 117 const mapped = list.map((item) => {
118 const xs_price = number_or_null(item?.xs_price) 118 const xs_price = number_or_null(item?.xs_price)
119 const sc_price = number_or_null(item?.sc_price) 119 const sc_price = number_or_null(item?.sc_price)
120 - const imageUrl = item?.sl_img || item?.fx_img || item?.banner_img || 'https://cdn.ipadbiz.cn/mlaj/images/default_block.png' 120 + let imageUrl = item?.sl_img || item?.fx_img || item?.banner_img || 'https://cdn.ipadbiz.cn/mlaj/images/default_block.png'
121 + // 压缩阿里云图片资源
122 + imageUrl = imageUrl+ '?x-oss-process=image/quality,q_70/resize,w_100'
121 const period = format_period(item?.act_start_at, item?.act_end_at) 123 const period = format_period(item?.act_start_at, item?.act_end_at)
122 124
123 const upper = number_or_null(item?.stu_num_upper) 125 const upper = number_or_null(item?.stu_num_upper)
......
...@@ -21,7 +21,7 @@ ...@@ -21,7 +21,7 @@
21 <div class="flex flex-col h-full" @click="go_to_course_detail(item)"> 21 <div class="flex flex-col h-full" @click="go_to_course_detail(item)">
22 <div class="h-28 mb-2 rounded-lg overflow-hidden relative"> 22 <div class="h-28 mb-2 rounded-lg overflow-hidden relative">
23 <img 23 <img
24 - :src="item.cover || 'https://cdn.ipadbiz.cn/mlaj/images/default_block.png'" 24 + :src="buildCdnImageUrl(item.cover || 'https://cdn.ipadbiz.cn/mlaj/images/default_block.png')"
25 :alt="item.title" 25 :alt="item.title"
26 class="w-full h-full object-cover" 26 class="w-full h-full object-cover"
27 /> 27 />
...@@ -45,6 +45,7 @@ import { ref, onMounted } from 'vue' ...@@ -45,6 +45,7 @@ import { ref, onMounted } from 'vue'
45 import { useRouter } from 'vue-router' 45 import { useRouter } from 'vue-router'
46 import FrostedGlass from '@/components/effects/FrostedGlass.vue' 46 import FrostedGlass from '@/components/effects/FrostedGlass.vue'
47 import { getCourseListAPI } from '@/api/course' 47 import { getCourseListAPI } from '@/api/course'
48 +import { buildCdnImageUrl } from '@/utils/tools'
48 49
49 const router = useRouter() 50 const router = useRouter()
50 51
......
...@@ -116,8 +116,9 @@ ...@@ -116,8 +116,9 @@
116 </template> 116 </template>
117 117
118 <script setup> 118 <script setup>
119 -import { ref, watch, nextTick, onUnmounted } from 'vue'; 119 +import { defineAsyncComponent, ref, watch, nextTick, onUnmounted } from 'vue';
120 -import PDF from "pdf-vue3"; 120 +
121 +const PDF = defineAsyncComponent(() => import("pdf-vue3"));
121 122
122 /** 123 /**
123 * 组件属性定义 124 * 组件属性定义
......
...@@ -62,11 +62,18 @@ ...@@ -62,11 +62,18 @@
62 </template> 62 </template>
63 63
64 <script setup> 64 <script setup>
65 -import { ref } from "vue"; 65 +import { defineAsyncComponent, ref } from "vue";
66 -import { VideoPlayer } from "@videojs-player/vue";
67 -import "video.js/dist/video-js.css";
68 import { useVideoPlayer } from "@/composables/useVideoPlayer"; 66 import { useVideoPlayer } from "@/composables/useVideoPlayer";
69 67
68 +const VideoPlayer = defineAsyncComponent(async () => {
69 + await import("video.js/dist/video-js.css");
70 + await import("videojs-hls-quality-selector/dist/videojs-hls-quality-selector.css");
71 + await import("videojs-contrib-quality-levels");
72 + await import("videojs-hls-quality-selector");
73 + const mod = await import("@videojs-player/vue");
74 + return mod.VideoPlayer;
75 +});
76 +
70 const props = defineProps({ 77 const props = defineProps({
71 options: { 78 options: {
72 type: Object, 79 type: Object,
......
1 import { describe, expect, it, vi, beforeEach } from 'vitest' 1 import { describe, expect, it, vi, beforeEach } from 'vitest'
2 2
3 +let mock_query = {}
4 +
3 vi.mock('vue-router', () => { 5 vi.mock('vue-router', () => {
6 + const router = {
7 + push: vi.fn(),
8 + back: vi.fn()
9 + }
4 return { 10 return {
5 useRoute: () => ({ 11 useRoute: () => ({
6 - query: {} 12 + query: mock_query
7 }), 13 }),
8 - useRouter: () => ({ 14 + useRouter: () => router
9 - push: vi.fn()
10 - })
11 } 15 }
12 }) 16 })
13 17
...@@ -51,10 +55,23 @@ vi.mock('@/contexts/auth', async () => { ...@@ -51,10 +55,23 @@ vi.mock('@/contexts/auth', async () => {
51 55
52 import { useCheckin } from '../useCheckin' 56 import { useCheckin } from '../useCheckin'
53 import { showToast } from 'vant' 57 import { showToast } from 'vant'
58 +import { addUploadTaskAPI } from '@/api/checkin'
54 59
55 describe('useCheckin 上传大小限制', () => { 60 describe('useCheckin 上传大小限制', () => {
56 beforeEach(() => { 61 beforeEach(() => {
57 vi.clearAllMocks() 62 vi.clearAllMocks()
63 + mock_query = { enable_multi: '0' }
64 + if (!globalThis.sessionStorage) {
65 + let store = {}
66 + globalThis.sessionStorage = {
67 + getItem: (key) => store[key] ?? null,
68 + setItem: (key, value) => { store[key] = String(value) },
69 + removeItem: (key) => { delete store[key] },
70 + clear: () => { store = {} },
71 + }
72 + } else {
73 + sessionStorage.clear()
74 + }
58 }) 75 })
59 76
60 it('setMaxFileSizeMbMap 能更新并保留其他类型默认值', () => { 77 it('setMaxFileSizeMbMap 能更新并保留其他类型默认值', () => {
...@@ -119,3 +136,71 @@ describe('useCheckin 上传大小限制', () => { ...@@ -119,3 +136,71 @@ describe('useCheckin 上传大小限制', () => {
119 }) 136 })
120 }) 137 })
121 138
139 +describe('useCheckin 提交兼容', () => {
140 + beforeEach(() => {
141 + vi.clearAllMocks()
142 + mock_query = { enable_multi: '0' }
143 + if (globalThis.sessionStorage?.clear) sessionStorage.clear()
144 + })
145 +
146 + it('新结构失败会回退旧结构提交', async () => {
147 + addUploadTaskAPI
148 + .mockResolvedValueOnce({ code: 0, msg: '参数错误' })
149 + .mockResolvedValueOnce({ code: 1, data: { id: 1 } })
150 +
151 + const { activeType, fileList, message, onSubmit } = useCheckin()
152 +
153 + activeType.value = 'audio'
154 + message.value = '随便写点内容'
155 + fileList.value = [
156 + { status: 'done', meta_id: 11, file_type: 'audio' }
157 + ]
158 +
159 + await onSubmit({ subtask_id: 's1' })
160 +
161 + expect(addUploadTaskAPI).toHaveBeenCalledTimes(2)
162 + expect(addUploadTaskAPI.mock.calls[0][0]).toHaveProperty('files')
163 + expect(addUploadTaskAPI.mock.calls[0][0]).toHaveProperty('meta_id')
164 + expect(addUploadTaskAPI.mock.calls[1][0]).toHaveProperty('meta_id')
165 + expect(addUploadTaskAPI.mock.calls[1][0]).not.toHaveProperty('files')
166 + expect(showToast).toHaveBeenCalledWith('提交成功')
167 + })
168 +
169 + it('混合附件且新结构失败时会提示分别提交', async () => {
170 + const { activeType, fileList, message, onSubmit } = useCheckin()
171 +
172 + activeType.value = 'image'
173 + message.value = '随便写点内容'
174 + fileList.value = [
175 + { status: 'done', meta_id: 11, file_type: 'image' },
176 + { status: 'done', meta_id: 12, file_type: 'audio' },
177 + ]
178 +
179 + await onSubmit({ subtask_id: 's1' })
180 +
181 + expect(addUploadTaskAPI).toHaveBeenCalledTimes(0)
182 + expect(showToast).toHaveBeenCalledWith('当前接口暂不支持多类型附件,请分别提交')
183 + })
184 +
185 + it('开启多附件开关时允许 mixed files 提交', async () => {
186 + mock_query = { enable_multi: '1' }
187 + addUploadTaskAPI.mockResolvedValueOnce({ code: 1, data: { id: 1 } })
188 +
189 + const { activeType, fileList, message, onSubmit } = useCheckin()
190 +
191 + activeType.value = 'image'
192 + message.value = '随便写点内容'
193 + fileList.value = [
194 + { status: 'done', meta_id: 11, file_type: 'image' },
195 + { status: 'done', meta_id: 12, file_type: 'audio' },
196 + ]
197 +
198 + await onSubmit({ subtask_id: 's1' })
199 +
200 + expect(addUploadTaskAPI).toHaveBeenCalledTimes(1)
201 + expect(addUploadTaskAPI.mock.calls[0][0]).toHaveProperty('files')
202 + expect(addUploadTaskAPI.mock.calls[0][0]).not.toHaveProperty('meta_id')
203 + expect(addUploadTaskAPI.mock.calls[0][0]).toHaveProperty('file_type', 'mixed')
204 + expect(showToast).toHaveBeenCalledWith('提交成功')
205 + })
206 +})
......
...@@ -23,6 +23,16 @@ export function useCheckin() { ...@@ -23,6 +23,16 @@ export function useCheckin() {
23 const router = useRouter() 23 const router = useRouter()
24 const { currentUser } = useAuth() 24 const { currentUser } = useAuth()
25 25
26 + // TAG: 多附件功能开关
27 + const multiAttachmentEnabled = computed(() => {
28 + const query_value = String(route?.query?.enable_multi ?? '')
29 + if (query_value === '1') return true
30 + if (query_value === '0') return false
31 +
32 + const fromEnv = String(import.meta.env.VITE_CHECKIN_MULTI_ATTACHMENT || '') === '1'
33 + return fromEnv
34 + })
35 +
26 // ==================== 状态定义 ==================== 36 // ==================== 状态定义 ====================
27 37
28 /** @type {import('vue').Ref<boolean>} 上传中状态 */ 38 /** @type {import('vue').Ref<boolean>} 上传中状态 */
...@@ -98,12 +108,12 @@ export function useCheckin() { ...@@ -98,12 +108,12 @@ export function useCheckin() {
98 * 用于记忆不同类型的文件列表,切换类型时恢复 108 * 用于记忆不同类型的文件列表,切换类型时恢复
99 * @type {import('vue').Ref<Object>} 109 * @type {import('vue').Ref<Object>}
100 */ 110 */
101 - const fileListMemory = ref({ 111 + // const fileListMemory = ref({
102 - text: [], 112 + // text: [],
103 - image: [], 113 + // image: [],
104 - video: [], 114 + // video: [],
105 - audio: [] 115 + // audio: []
106 - }) 116 + // })
107 117
108 /** 118 /**
109 * 是否可以提交 119 * 是否可以提交
...@@ -205,7 +215,17 @@ export function useCheckin() { ...@@ -205,7 +215,17 @@ export function useCheckin() {
205 const suffix = /.[^.]+$/.exec(file.file.name) || '' 215 const suffix = /.[^.]+$/.exec(file.file.name) || ''
206 let fileName = '' 216 let fileName = ''
207 217
208 - if (activeType.value === 'image') { 218 + // 根据当前 activeType 或文件类型判断
219 + let currentFileType = activeType.value
220 + if (!['image', 'video', 'audio'].includes(currentFileType)) {
221 + // 如果 activeType 不是这三种(比如 text),尝试从文件类型推断
222 + if (file.file.type.startsWith('image/')) currentFileType = 'image'
223 + else if (file.file.type.startsWith('video/')) currentFileType = 'video'
224 + else if (file.file.type.startsWith('audio/')) currentFileType = 'audio'
225 + else currentFileType = 'file' // 默认
226 + }
227 +
228 + if (currentFileType === 'image') {
209 fileName = `mlaj/upload/checkin/${currentUser.value.mobile}/img/${md5}${suffix}` 229 fileName = `mlaj/upload/checkin/${currentUser.value.mobile}/img/${md5}${suffix}`
210 } else { 230 } else {
211 fileName = `mlaj/upload/checkin/${currentUser.value.mobile}/file/${md5}${suffix}` 231 fileName = `mlaj/upload/checkin/${currentUser.value.mobile}/file/${md5}${suffix}`
...@@ -226,13 +246,13 @@ export function useCheckin() { ...@@ -226,13 +246,13 @@ export function useCheckin() {
226 } 246 }
227 247
228 // 图片类型需要保存尺寸信息 248 // 图片类型需要保存尺寸信息
229 - if (activeType.value === 'image' && uploadResult.image_info) { 249 + if (currentFileType === 'image' && uploadResult.image_info) {
230 saveData.height = uploadResult.image_info.height 250 saveData.height = uploadResult.image_info.height
231 saveData.width = uploadResult.image_info.width 251 saveData.width = uploadResult.image_info.width
232 } 252 }
233 253
234 const { data } = await saveFileAPI(saveData) 254 const { data } = await saveFileAPI(saveData)
235 - return data 255 + return { ...data, file_type: currentFileType }
236 } 256 }
237 } 257 }
238 return null 258 return null
...@@ -323,6 +343,7 @@ export function useCheckin() { ...@@ -323,6 +343,7 @@ export function useCheckin() {
323 item.url = result.url 343 item.url = result.url
324 item.meta_id = result.meta_id 344 item.meta_id = result.meta_id
325 item.name = result.name || item.file.name 345 item.name = result.name || item.file.name
346 + item.file_type = result.file_type || activeType.value // 记录文件类型
326 } else { 347 } else {
327 item.status = 'failed' 348 item.status = 'failed'
328 item.message = '上传失败' 349 item.message = '上传失败'
...@@ -438,21 +459,47 @@ export function useCheckin() { ...@@ -438,21 +459,47 @@ export function useCheckin() {
438 459
439 try { 460 try {
440 // 准备提交数据 461 // 准备提交数据
462 + // 构造 files 数组
463 + const files = fileList.value
464 + .filter(item => item.status === 'done' && item.meta_id)
465 + .map(item => ({
466 + meta_id: item.meta_id,
467 + file_type: item.file_type || activeType.value // 优先使用 item 自己的 type
468 + }))
469 +
441 const submitData = { 470 const submitData = {
442 note: message.value, 471 note: message.value,
443 - file_type: activeType.value, 472 + // file_type: activeType.value, // 不再依赖顶层 file_type
444 - meta_id: [], 473 + // meta_id: [], // 废弃
474 + files: files,
445 makeup_time: isMakeup.value ? route.query.date : '', 475 makeup_time: isMakeup.value ? route.query.date : '',
446 ...extraData 476 ...extraData
447 } 477 }
448 478
449 - // 如果有文件,添加文件ID 479 + // 如果没有 files,尝试推断 file_type (仅为了兼容旧逻辑或文本打卡)
450 - if (fileList.value.length > 0) { 480 + if (files.length === 0 && activeType.value === 'text') {
451 - submitData.meta_id = fileList.value 481 + submitData.file_type = 'text'
452 - .filter(item => item.status === 'done' && item.meta_id) 482 + } else if (files.length > 0) {
453 - .map(item => item.meta_id) 483 + const types = new Set(files.map(f => f.file_type).filter(Boolean))
484 + const hasMixedTypes = types.size > 1
485 + console.warn(multiAttachmentEnabled.value);
486 + if (hasMixedTypes && !multiAttachmentEnabled.value) {
487 + showToast('当前接口暂不支持多类型附件,请分别提交')
488 + return
489 + }
490 +
491 + // 如果有文件,file_type 可能是 'mixed' 或者取第一个文件的类型作为主类型
492 + // 这里暂时保留 file_type 字段以防后端必须校验,取第一个文件的类型,或者传 'mixed'
493 + // 如果后端已更新为只看 files 数组,则此字段可能无效
494 + submitData.file_type = hasMixedTypes ? 'mixed' : files[0].file_type
495 +
496 + if (!multiAttachmentEnabled.value) {
497 + submitData.meta_id = files.map(f => f.meta_id)
498 + }
454 } 499 }
455 500
501 + // 说明:submitData.meta_id 为旧接口兜底字段,后端完全支持 files 后再移除
502 +
456 let result 503 let result
457 if (route.query.status === 'edit') { 504 if (route.query.status === 'edit') {
458 // 编辑打卡 505 // 编辑打卡
...@@ -460,7 +507,8 @@ export function useCheckin() { ...@@ -460,7 +507,8 @@ export function useCheckin() {
460 i: route.query.post_id, 507 i: route.query.post_id,
461 subtask_id: submitData.subtask_id || route.query.subtask_id, 508 subtask_id: submitData.subtask_id || route.query.subtask_id,
462 note: submitData.note, 509 note: submitData.note,
463 - meta_id: submitData.meta_id, 510 + ...(submitData.meta_id ? { meta_id: submitData.meta_id } : {}),
511 + files: submitData.files,
464 file_type: submitData.file_type, 512 file_type: submitData.file_type,
465 } 513 }
466 514
...@@ -478,7 +526,46 @@ export function useCheckin() { ...@@ -478,7 +526,46 @@ export function useCheckin() {
478 result = await addUploadTaskAPI(submitData) 526 result = await addUploadTaskAPI(submitData)
479 } 527 }
480 528
481 - if (result.code === 1) { 529 + if (result?.code !== 1) {
530 + const types = new Set((files || []).map(f => f.file_type).filter(Boolean))
531 + const hasMixedTypes = types.size > 1
532 +
533 + if (hasMixedTypes) {
534 + if (!multiAttachmentEnabled.value) {
535 + showToast('当前接口暂不支持多类型附件,请分别提交')
536 + return
537 + }
538 + showToast(result?.msg || '提交失败,请重试')
539 + return
540 + }
541 +
542 + const legacy_type = files?.[0]?.file_type || submitData.file_type || activeType.value
543 + const legacy_meta_id = (files || []).filter(f => f.file_type === legacy_type).map(f => f.meta_id)
544 +
545 + const legacy_payload = {
546 + ...extraData,
547 + note: submitData.note,
548 + file_type: legacy_type,
549 + meta_id: legacy_meta_id,
550 + makeup_time: submitData.makeup_time,
551 + }
552 +
553 + if (route.query.status === 'edit') {
554 + result = await editUploadTaskInfoAPI({
555 + i: route.query.post_id,
556 + subtask_id: legacy_payload.subtask_id || route.query.subtask_id,
557 + note: legacy_payload.note,
558 + meta_id: legacy_payload.meta_id,
559 + file_type: legacy_payload.file_type,
560 + gratitude_form_list: legacy_payload.gratitude_form_list,
561 + gratitude_count: legacy_payload.gratitude_count,
562 + })
563 + } else {
564 + result = await addUploadTaskAPI(legacy_payload)
565 + }
566 + }
567 +
568 + if (result?.code === 1) {
482 showToast('提交成功') 569 showToast('提交成功')
483 570
484 // 设置刷新标记,用于列表页更新数据 571 // 设置刷新标记,用于列表页更新数据
...@@ -492,6 +579,8 @@ export function useCheckin() { ...@@ -492,6 +579,8 @@ export function useCheckin() {
492 } 579 }
493 580
494 router.back() 581 router.back()
582 + } else {
583 + showToast(result?.msg || '提交失败,请重试')
495 } 584 }
496 } catch (error) { 585 } catch (error) {
497 showToast('提交失败,请重试') 586 showToast('提交失败,请重试')
...@@ -503,19 +592,10 @@ export function useCheckin() { ...@@ -503,19 +592,10 @@ export function useCheckin() {
503 /** 592 /**
504 * 切换打卡类型 593 * 切换打卡类型
505 * @param {string} type - 打卡类型 594 * @param {string} type - 打卡类型
506 - * @description 切换时会保存当前类型的文件列表,并恢复新类型的文件列表 595 + * @description 切换类型不再清空文件列表,支持多类型混合上传
507 */ 596 */
508 const switchType = (type) => { 597 const switchType = (type) => {
509 - if (activeType.value !== type) {
510 - // 保存当前类型的文件列表到记忆中
511 - fileListMemory.value[activeType.value] = [...fileList.value]
512 -
513 - // 切换到新类型
514 activeType.value = type 598 activeType.value = type
515 -
516 - // 恢复新类型的文件列表
517 - fileList.value = [...fileListMemory.value[type]]
518 - }
519 } 599 }
520 600
521 /** 601 /**
...@@ -528,12 +608,12 @@ export function useCheckin() { ...@@ -528,12 +608,12 @@ export function useCheckin() {
528 uploading.value = false 608 uploading.value = false
529 loading.value = false 609 loading.value = false
530 // 清空文件列表记忆 610 // 清空文件列表记忆
531 - fileListMemory.value = { 611 + // fileListMemory.value = {
532 - text: [], 612 + // text: [],
533 - image: [], 613 + // image: [],
534 - video: [], 614 + // video: [],
535 - audio: [] 615 + // audio: []
536 - } 616 + // }
537 } 617 }
538 618
539 // 计数打卡相关数据 619 // 计数打卡相关数据
...@@ -608,11 +688,12 @@ export function useCheckin() { ...@@ -608,11 +688,12 @@ export function useCheckin() {
608 status: 'done', 688 status: 'done',
609 message: '已上传', 689 message: '已上传',
610 meta_id: item.meta_id, 690 meta_id: item.meta_id,
611 - name: item.name || '' 691 + name: item.name || '',
692 + file_type: item.file_type || data.file_type || 'image' // 恢复 file_type
612 } 693 }
613 694
614 // 对于图片类型,添加isImage标记确保正确显示 695 // 对于图片类型,添加isImage标记确保正确显示
615 - if (activeType.value === 'image') { 696 + if (fileItem.file_type === 'image') {
616 fileItem.isImage = true 697 fileItem.isImage = true
617 } 698 }
618 699
...@@ -626,7 +707,7 @@ export function useCheckin() { ...@@ -626,7 +707,7 @@ export function useCheckin() {
626 707
627 // 将文件列表保存到当前类型的记忆中 708 // 将文件列表保存到当前类型的记忆中
628 fileList.value = files 709 fileList.value = files
629 - fileListMemory.value[activeType.value] = [...files] 710 + // fileListMemory.value[activeType.value] = [...files]
630 } 711 }
631 } 712 }
632 } catch (error) { 713 } catch (error) {
......
...@@ -15,7 +15,7 @@ export function useImageLoader() { ...@@ -15,7 +15,7 @@ export function useImageLoader() {
15 /** 15 /**
16 * 默认头像 URL 16 * 默认头像 URL
17 */ 17 */
18 - const DEFAULT_AVATAR = 'https://cdn.ipadbiz.cn/mlaj/images/default-avatar.jpeg' 18 + const DEFAULT_AVATAR = 'https://cdn.ipadbiz.cn/mlaj/images/default-avatar.jpeg?imageMogr2/thumbnail/200x/strip/quality/70'
19 19
20 /** 20 /**
21 * 处理图片加载错误 21 * 处理图片加载错误
......
1 import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'; 1 import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue';
2 import { wxInfo } from "@/utils/tools"; 2 import { wxInfo } from "@/utils/tools";
3 -import videojs from "video.js";
4 import { buildVideoSources, canPlayHlsNatively } from "./videoPlayerSource"; 3 import { buildVideoSources, canPlayHlsNatively } from "./videoPlayerSource";
5 import { useVideoProbe } from "./useVideoProbe"; 4 import { useVideoProbe } from "./useVideoProbe";
6 import { useVideoPlaybackOverlays } from "./useVideoPlaybackOverlays"; 5 import { useVideoPlaybackOverlays } from "./useVideoPlaybackOverlays";
7 -// 新增:引入多码率切换插件 6 +
8 -import 'videojs-contrib-quality-levels'; // 用于读取 m3u8 中的多码率信息 7 +const is_safari_browser = () => {
9 -import 'videojs-hls-quality-selector'; // 用于在播放器控制条显示“清晰度”切换菜单(支持 Auto/720p/480p 等)。 8 + if (typeof navigator === 'undefined') return false;
10 -import 'videojs-hls-quality-selector/dist/videojs-hls-quality-selector.css'; 9 + const ua = navigator.userAgent || '';
10 + const is_safari = /safari/i.test(ua) && !/chrome|crios|android|fxios|edg/i.test(ua);
11 + return is_safari;
12 +};
11 13
12 /** 14 /**
13 * - 使用方法 :您无需修改业务代码。只要传入的视频 URL 是七牛云生成的多码率 .m3u8 地址,播放器控制条右下角会自动出现“齿轮”图标,用户点击即可切换清晰度(或选择 Auto 自动切换)。 15 * - 使用方法 :您无需修改业务代码。只要传入的视频 URL 是七牛云生成的多码率 .m3u8 地址,播放器控制条右下角会自动出现“齿轮”图标,用户点击即可切换清晰度(或选择 Auto 自动切换)。
...@@ -271,7 +273,7 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) { ...@@ -271,7 +273,7 @@ export function useVideoPlayer(props, emit, videoRef, nativeVideoRef) {
271 // 5. Video.js 播放器逻辑 (PC/Android) 273 // 5. Video.js 播放器逻辑 (PC/Android)
272 const shouldOverrideNativeHls = computed(() => { 274 const shouldOverrideNativeHls = computed(() => {
273 if (!isM3U8.value) return false; 275 if (!isM3U8.value) return false;
274 - if (videojs.browser.IS_SAFARI) return false; 276 + if (is_safari_browser()) return false;
275 // 非 Safari 且不具备原生 HLS 时,强制 video.js 的 VHS 来解 m3u8 277 // 非 Safari 且不具备原生 HLS 时,强制 video.js 的 VHS 来解 m3u8
276 return !canPlayHlsNatively(); 278 return !canPlayHlsNatively();
277 }); 279 });
......
1 -<!-- src/layouts/AppLayout.vue -->
2 -<template>
3 - <div class="app-layout">
4 - <!-- Header -->
5 - <header class="app-header" v-if="hasTitle">
6 - <div v-if="showBack" class="header-back" @click="goBack">
7 - <van-icon name="arrow-left" size="20" />
8 - </div>
9 - <h1 class="header-title">{{ title }}</h1>
10 - <div class="header-right">
11 - <slot name="header-right"></slot>
12 - </div>
13 - </header>
14 -
15 - <!-- Main Content -->
16 - <main class="app-content" :class="{ 'has-bottom-nav': showBottomNav, 'no-header': !hasTitle }">
17 - <slot></slot>
18 - </main>
19 -
20 - <!-- Bottom Navigation -->
21 - <van-tabbar v-if="showBottomNav" route safe-area-inset-bottom>
22 - <van-tabbar-item to="/home" icon="home-o">首页</van-tabbar-item>
23 - <van-tabbar-item to="/courses" icon="orders-o">课程</van-tabbar-item>
24 - <van-tabbar-item to="/activities" icon="friends-o">活动</van-tabbar-item>
25 - <van-tabbar-item to="/community" icon="chat-o">社区</van-tabbar-item>
26 - <van-tabbar-item to="/profile" icon="user-o">我的</van-tabbar-item>
27 - </van-tabbar>
28 - </div>
29 -</template>
30 -
31 -<script>
32 -import { ref, onMounted, computed } from 'vue'
33 -import { useRouter, useRoute } from 'vue-router'
34 -
35 -export default {
36 - name: 'AppLayout',
37 - props: {
38 - title: {
39 - type: String,
40 - default: '美乐爱觉教育'
41 - },
42 - showBack: {
43 - type: Boolean,
44 - default: false
45 - },
46 - showBottomNav: {
47 - type: Boolean,
48 - default: true
49 - },
50 - hasTitle: {
51 - type: Boolean,
52 - default: true
53 - },
54 - },
55 - setup(props) {
56 - const router = useRouter()
57 - const route = useRoute()
58 -
59 - const goBack = () => {
60 - if (window.history.length > 1) {
61 - router.back()
62 - } else {
63 - router.push('/')
64 - }
65 - }
66 -
67 - return {
68 - goBack,
69 - }
70 - }
71 -}
72 -</script>
73 -
74 -<style scoped>
75 -.app-layout {
76 - display: flex;
77 - flex-direction: column;
78 - min-height: 100vh;
79 - background-color: var(--background-color);
80 -}
81 -
82 -.app-header {
83 - position: sticky;
84 - top: 0;
85 - z-index: 100;
86 - display: flex;
87 - align-items: center;
88 - justify-content: center;
89 - height: 46px;
90 - padding: 0 16px;
91 - background-color: var(--white);
92 - box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
93 -}
94 -
95 -.header-back {
96 - position: absolute;
97 - left: 16px;
98 - display: flex;
99 - align-items: center;
100 - justify-content: center;
101 -}
102 -
103 -.header-title {
104 - font-size: 18px;
105 - font-weight: 600;
106 - margin: 0;
107 -}
108 -
109 -.header-right {
110 - position: absolute;
111 - right: 16px;
112 - display: flex;
113 - align-items: center;
114 -}
115 -
116 -.app-content {
117 - flex: 1;
118 - overflow-y: auto;
119 - padding-bottom: 20px;
120 - -webkit-overflow-scrolling: touch;
121 -}
122 -
123 -.app-content.has-bottom-nav {
124 - padding-bottom: 50px;
125 -}
126 -
127 -.app-content.no-header {
128 - padding-top: 0;
129 -}
130 -</style>
1 /* 1 /*
2 * @Date: 2025-03-21 13:28:30 2 * @Date: 2025-03-21 13:28:30
3 * @LastEditors: hookehuyr hookehuyr@gmail.com 3 * @LastEditors: hookehuyr hookehuyr@gmail.com
4 - * @LastEditTime: 2025-11-17 13:54:31 4 + * @LastEditTime: 2026-01-24 15:29:54
5 * @FilePath: /mlaj/src/router/checkin.js 5 * @FilePath: /mlaj/src/router/checkin.js
6 * @Description: 文件描述 6 * @Description: 文件描述
7 */ 7 */
...@@ -61,42 +61,6 @@ export default [ ...@@ -61,42 +61,6 @@ export default [
61 } 61 }
62 }, 62 },
63 { 63 {
64 - path: '/checkin/image',
65 - name: 'ImageCheckIn',
66 - component: () => import('@/views/checkin/upload/image.vue'),
67 - meta: {
68 - title: '打卡图片',
69 - requiresAuth: true
70 - }
71 - },
72 - {
73 - path: '/checkin/video',
74 - name: 'VideoCheckIn',
75 - component: () => import('@root/src/views/checkin/upload/video.vue'),
76 - meta: {
77 - title: '打卡视频',
78 - requiresAuth: true
79 - }
80 - },
81 - {
82 - path: '/checkin/audio',
83 - name: 'AudioCheckIn',
84 - component: () => import('@root/src/views/checkin/upload/audio.vue'),
85 - meta: {
86 - title: '打卡音频',
87 - requiresAuth: true
88 - }
89 - },
90 - {
91 - path: '/checkin/text',
92 - name: 'TextCheckIn',
93 - component: () => import('@root/src/views/checkin/upload/text.vue'),
94 - meta: {
95 - title: '打卡文本',
96 - requiresAuth: true
97 - }
98 - },
99 - {
100 path: '/checkin/join', 64 path: '/checkin/join',
101 name: 'JoinCheckIn', 65 name: 'JoinCheckIn',
102 component: () => import('@root/src/views/checkin/JoinCheckInPage.vue'), 66 component: () => import('@root/src/views/checkin/JoinCheckInPage.vue'),
......
1 /* 1 /*
2 * @Date: 2022-04-18 15:59:42 2 * @Date: 2022-04-18 15:59:42
3 * @LastEditors: hookehuyr hookehuyr@gmail.com 3 * @LastEditors: hookehuyr hookehuyr@gmail.com
4 - * @LastEditTime: 2026-01-21 13:33:36 4 + * @LastEditTime: 2026-01-24 14:35:27
5 * @FilePath: /mlaj/src/utils/tools.js 5 * @FilePath: /mlaj/src/utils/tools.js
6 * @Description: 文件描述 6 * @Description: 文件描述
7 */ 7 */
...@@ -127,6 +127,35 @@ const formatDuration = (seconds) => { ...@@ -127,6 +127,35 @@ const formatDuration = (seconds) => {
127 }; 127 };
128 128
129 /** 129 /**
130 + * @description 为 CDN 图片追加七牛压缩参数,降低首页等场景的图片体积。
131 + * @param {string} src 原始图片地址
132 + * @param {number} [width=200] 缩略图宽度(像素)
133 + * @param {number} [quality=70] 图片质量(1-100)
134 + * @returns {string} 处理后的图片地址
135 + */
136 +const buildCdnImageUrl = (src, width = 200, quality = 70) => {
137 + const url = typeof src === 'string' ? src : '';
138 + if (!url) return '';
139 +
140 + // 已包含七牛处理参数时不重复追加,避免不确定行为
141 + if (url.includes('imageMogr2')) return url;
142 +
143 + try {
144 + const u = new URL(url, window.location.origin);
145 + // 兼容多个 CDN 域名:只要域名前缀以 cdn 开头,就允许追加七牛参数
146 + // 例如:cdn.ipadbiz.cn / cdn.xxx.com / cdn1.xxx.com
147 + if (!/^cdn/i.test(u.hostname)) return url;
148 +
149 + const [base, hash] = url.split('#');
150 + const param = `imageMogr2/thumbnail/${width}x/strip/quality/${quality}`;
151 + const next = base + (base.includes('?') ? '&' : '?') + param;
152 + return hash ? `${next}#${hash}` : next;
153 + } catch (e) {
154 + return url;
155 + }
156 +};
157 +
158 +/**
130 * @description 归一化“打卡任务列表”字段,避免各页面散落 map 导致漏改。 159 * @description 归一化“打卡任务列表”字段,避免各页面散落 map 导致漏改。
131 * @param {Array<any>} list 原始任务列表(通常为接口返回 task_list/timeout_task_list) 160 * @param {Array<any>} list 原始任务列表(通常为接口返回 task_list/timeout_task_list)
132 * @returns {Array<{id: number|string, name: string, task_type: string, is_gray: boolean, is_finish: boolean, checkin_subtask_id: number|string|undefined}>} 161 * @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 { ...@@ -289,6 +318,7 @@ export {
289 getUrlParams, 318 getUrlParams,
290 stringifyQuery, 319 stringifyQuery,
291 formatDuration, 320 formatDuration,
321 + buildCdnImageUrl,
292 normalizeCheckinTaskItems, 322 normalizeCheckinTaskItems,
293 normalizeAttachmentTypeConfig, 323 normalizeAttachmentTypeConfig,
294 }; 324 };
......
...@@ -38,7 +38,7 @@ ...@@ -38,7 +38,7 @@
38 <div class="flex items-center"> 38 <div class="flex items-center">
39 <div class="w-10 h-10 rounded-full overflow-hidden mr-3"> 39 <div class="w-10 h-10 rounded-full overflow-hidden mr-3">
40 <img 40 <img
41 - :src="currentUser?.avatar || 'https://cdn.ipadbiz.cn/mlaj/images/icon_1.jpeg'" 41 + :src="buildCdnImageUrl(currentUser?.avatar || 'https://cdn.ipadbiz.cn/mlaj/images/icon_1.jpeg')"
42 class="w-full h-full object-cover" 42 class="w-full h-full object-cover"
43 @error="handleImageError" /> 43 @error="handleImageError" />
44 </div> 44 </div>
...@@ -231,7 +231,7 @@ ...@@ -231,7 +231,7 @@
231 <div class="flex items-center"> 231 <div class="flex items-center">
232 <div class="w-12 h-12 bg-green-100 rounded-lg overflow-hidden mr-3 flex-shrink-0"> 232 <div class="w-12 h-12 bg-green-100 rounded-lg overflow-hidden mr-3 flex-shrink-0">
233 <img 233 <img
234 - :src="item.image" 234 + :src="buildCdnImageUrl(item.image)"
235 :alt="item.title" 235 :alt="item.title"
236 class="w-full h-full object-cover" 236 class="w-full h-full object-cover"
237 @error="handleImageError" 237 @error="handleImageError"
...@@ -287,7 +287,7 @@ ...@@ -287,7 +287,7 @@
287 > 287 >
288 <template v-if="activeVideoIndex !== index"> 288 <template v-if="activeVideoIndex !== index">
289 <img 289 <img
290 - :src="item.image" 290 + :src="buildCdnImageUrl(item.image, 400)"
291 :alt="item.title" 291 :alt="item.title"
292 class="w-full h-full object-cover" 292 class="w-full h-full object-cover"
293 @error="handleImageError" 293 @error="handleImageError"
...@@ -350,7 +350,7 @@ import { useImageLoader } from '@/composables/useImageLoader' ...@@ -350,7 +350,7 @@ import { useImageLoader } from '@/composables/useImageLoader'
350 350
351 // 导入接口 351 // 导入接口
352 import { getTaskListAPI } from "@/api/checkin"; 352 import { getTaskListAPI } from "@/api/checkin";
353 -import { normalizeCheckinTaskItems } from '@/utils/tools' 353 +import { normalizeCheckinTaskItems, buildCdnImageUrl } from '@/utils/tools'
354 354
355 // 图片加载错误处理 355 // 图片加载错误处理
356 const { handleImageError } = useImageLoader() 356 const { handleImageError } = useImageLoader()
......
1 <!-- 1 <!--
2 * @Date: 2025-09-30 17:05 2 * @Date: 2025-09-30 17:05
3 * @LastEditors: hookehuyr hookehuyr@gmail.com 3 * @LastEditors: hookehuyr hookehuyr@gmail.com
4 - * @LastEditTime: 2026-01-22 22:11:29 4 + * @LastEditTime: 2026-01-24 15:32:45
5 * @FilePath: /mlaj/src/views/checkin/CheckinDetailPage.vue 5 * @FilePath: /mlaj/src/views/checkin/CheckinDetailPage.vue
6 * @Description: 用户打卡详情页 6 * @Description: 用户打卡详情页
7 --> 7 -->
...@@ -84,19 +84,22 @@ ...@@ -84,19 +84,22 @@
84 <div class="tab-title">{{ taskType === 'count' ? '附件类型(可选)' : '附件类型' }}</div> 84 <div class="tab-title">{{ taskType === 'count' ? '附件类型(可选)' : '附件类型' }}</div>
85 <div class="tabs-nav"> 85 <div class="tabs-nav">
86 <div v-for="option in attachmentTypeOptions" :key="option.key" 86 <div v-for="option in attachmentTypeOptions" :key="option.key"
87 - @click="switchType(option.key)" :class="['tab-item', { 87 + @click="switchType(option.key)" :class="['tab-item', 'relative', {
88 active: activeType === option.key 88 active: activeType === option.key
89 }]"> 89 }]">
90 <van-icon :name="getIconName(option.key)" size="1.2rem" /> 90 <van-icon :name="getIconName(option.key)" size="1.2rem" />
91 <span class="tab-text">{{ option.value }}</span> 91 <span class="tab-text">{{ option.value }}</span>
92 + <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">
93 + {{ getTypeCount(option.key) }}
94 + </div>
92 </div> 95 </div>
93 </div> 96 </div>
94 </div> 97 </div>
95 98
96 <!-- 文件上传区域 --> 99 <!-- 文件上传区域 -->
97 <div v-if="activeType !== '' && activeType !== 'text'" class="upload-area"> 100 <div v-if="activeType !== '' && activeType !== 'text'" class="upload-area">
98 - <van-uploader v-model="fileList" :max-count="maxCount" :max-size="maxFileSizeBytes" 101 + <van-uploader v-model="displayFileList" :max-count="maxCount" :max-size="maxFileSizeBytes"
99 - :before-read="beforeRead" :after-read="afterRead" @delete="onDelete" 102 + :before-read="beforeReadGuard" :after-read="afterRead" @delete="onDelete"
100 @click-preview="onClickPreview" multiple :accept="getAcceptType()" result-type="file" 103 @click-preview="onClickPreview" multiple :accept="getAcceptType()" result-type="file"
101 :deletable="true" upload-icon="plus" /> 104 :deletable="true" upload-icon="plus" />
102 105
...@@ -188,7 +191,7 @@ import AudioPlayer from '@/components/media/AudioPlayer.vue' ...@@ -188,7 +191,7 @@ import AudioPlayer from '@/components/media/AudioPlayer.vue'
188 import VideoPlayer from '@/components/media/VideoPlayer.vue' 191 import VideoPlayer from '@/components/media/VideoPlayer.vue'
189 import AddTargetDialog from '@/components/count/AddTargetDialog.vue' 192 import AddTargetDialog from '@/components/count/AddTargetDialog.vue'
190 import CheckinTargetList from '@/components/count/CheckinTargetList.vue' 193 import CheckinTargetList from '@/components/count/CheckinTargetList.vue'
191 -import { showToast, showLoadingToast } from 'vant' 194 +import { showToast, showLoadingToast, showDialog } from 'vant'
192 import dayjs from 'dayjs' 195 import dayjs from 'dayjs'
193 196
194 const route = useRoute() 197 const route = useRoute()
...@@ -221,6 +224,115 @@ const { ...@@ -221,6 +224,115 @@ const {
221 gratitudeFormList 224 gratitudeFormList
222 } = useCheckin() 225 } = useCheckin()
223 226
227 +const beforeReadGuard = (file) => {
228 + const files = Array.isArray(file) ? file : [file]
229 + if (activeType.value === 'video') {
230 + const hasMov = files.some(item => {
231 + const fileName = String(item?.name || '').toLowerCase()
232 + const fileType = String(item?.type || '').toLowerCase()
233 + return fileName.endsWith('.mov') || fileType.includes('quicktime')
234 + })
235 +
236 + if (hasMov) {
237 + showDialog({
238 + title: '不支持 MOV 格式',
239 + message: 'MOV(QuickTime)在非苹果系统/部分播放器兼容性较差,可能出现无法打开、黑屏、无声等问题。\n\n请将视频导出/转换为 MP4(更通用)后再上传。',
240 + confirmButtonText: '我知道了',
241 + })
242 + return false
243 + }
244 +
245 + return beforeRead(file)
246 + }
247 +
248 + if (activeType.value === 'audio') {
249 + const supportedExt = ['mp3', 'm4a', 'aac', 'wav']
250 + const unsupportedFiles = files.filter(item => {
251 + const fileName = String(item?.name || '').toLowerCase()
252 + const ext = fileName.includes('.') ? fileName.split('.').pop() : ''
253 + if (supportedExt.includes(ext)) return false
254 + const fileType = String(item?.type || '').toLowerCase()
255 + if (!fileType) return true
256 + if (!fileType.startsWith('audio/')) return true
257 + return true
258 + })
259 +
260 + if (unsupportedFiles.length > 0) {
261 + showDialog({
262 + title: '不支持的音频格式',
263 + message: '当前音频播放基于系统浏览器能力,不同机型/系统对音频格式支持差异较大(例如 .wma 等常见无法播放)。\n\n为避免上传后无法播放,请使用 .mp3 或 .m4a(推荐)重新导出/转换后再上传。',
264 + confirmButtonText: '我知道了',
265 + })
266 + return false
267 + }
268 +
269 + return beforeRead(file)
270 + }
271 +
272 + return beforeRead(file)
273 +}
274 +
275 +/**
276 + * 获取指定类型的文件数量
277 + * @param {string} type - 文件类型
278 + * @returns {number} 文件数量
279 + */
280 +const getTypeCount = (type) => {
281 + return fileList.value.filter(item => {
282 + if (item.file_type) {
283 + return item.file_type === type
284 + }
285 + // 处理刚选择但未上传完成的文件(尝试推断类型)
286 + if (item.file && item.file.type) {
287 + if (type === 'image') return item.file.type.startsWith('image/')
288 + if (type === 'video') return item.file.type.startsWith('video/')
289 + if (type === 'audio') return item.file.type.startsWith('audio/')
290 + }
291 + return false
292 + }).length
293 +}
294 +
295 +/**
296 + * 当前显示的(经过类型过滤的)文件列表
297 + * @description
298 + * 1. getter: 根据 activeType 过滤 fileList,只显示当前类型的文件
299 + * 2. setter: 处理 van-uploader 的更新(添加/删除),同步回 fileList
300 + */
301 +const displayFileList = computed({
302 + get: () => {
303 + return fileList.value.filter(item => {
304 + if (item.file_type) {
305 + return item.file_type === activeType.value
306 + }
307 + if (item.file && item.file.type) {
308 + if (activeType.value === 'image') return item.file.type.startsWith('image/')
309 + if (activeType.value === 'video') return item.file.type.startsWith('video/')
310 + if (activeType.value === 'audio') return item.file.type.startsWith('audio/')
311 + }
312 + return false
313 + })
314 + },
315 + set: (val) => {
316 + // 找出不属于当前视图的其他文件(保留它们)
317 + const otherFiles = fileList.value.filter(item => {
318 + if (item.file_type) {
319 + return item.file_type !== activeType.value
320 + }
321 + if (item.file && item.file.type) {
322 + if (activeType.value === 'image') return !item.file.type.startsWith('image/')
323 + if (activeType.value === 'video') return !item.file.type.startsWith('video/')
324 + if (activeType.value === 'audio') return !item.file.type.startsWith('audio/')
325 + }
326 + // 如果无法判断类型,且 activeType 不是 text,保守起见保留它?
327 + // 或者:如果 activeType 是 image,那么所有 image/ 相关的都算当前视图,非 image/ 的算 other
328 + return true
329 + })
330 +
331 + // 合并其他文件和当前视图的新文件列表
332 + fileList.value = [...otherFiles, ...val]
333 + }
334 +})
335 +
224 // 动态字段文字 336 // 动态字段文字
225 const dynamicFieldText = ref('感恩') 337 const dynamicFieldText = ref('感恩')
226 338
...@@ -521,7 +633,7 @@ const isSubmitDisabled = computed(() => { ...@@ -521,7 +633,7 @@ const isSubmitDisabled = computed(() => {
521 // 文本打卡:必须填写内容且长度不少于10个字符 633 // 文本打卡:必须填写内容且长度不少于10个字符
522 return !message.value.trim() || message.value.trim().length < 10 634 return !message.value.trim() || message.value.trim().length < 10
523 } else { 635 } else {
524 - // 其他类型:必须有文件 636 + // 其他类型:必须有文件 (如果是混合模式,只要有文件就行)
525 return fileList.value.length === 0 637 return fileList.value.length === 0
526 } 638 }
527 }) 639 })
...@@ -621,8 +733,8 @@ const getFileIcon = () => { ...@@ -621,8 +733,8 @@ const getFileIcon = () => {
621 const getAcceptType = () => { 733 const getAcceptType = () => {
622 const acceptMap = { 734 const acceptMap = {
623 'image': 'image/*', 735 'image': 'image/*',
624 - 'video': 'video/*', 736 + 'video': '.mp4,video/mp4',
625 - 'audio': '.mp3,.wav,.aac,.flac,.ogg,.wma,.m4a' 737 + 'audio': '.mp3,.m4a,.aac,.wav'
626 } 738 }
627 return acceptMap[activeType.value] || '*' 739 return acceptMap[activeType.value] || '*'
628 } 740 }
...@@ -634,8 +746,8 @@ const getAcceptType = () => { ...@@ -634,8 +746,8 @@ const getAcceptType = () => {
634 const getUploadTips = () => { 746 const getUploadTips = () => {
635 const tipsMap = { 747 const tipsMap = {
636 'image': '支持格式:.jpg/.jpeg/.png', 748 'image': '支持格式:.jpg/.jpeg/.png',
637 - 'video': '支持格式:视频文件', 749 + 'video': '支持格式:.mp4(不支持 .mov)',
638 - 'audio': '支持格式:.mp3/.wav/.aac/.flac/.ogg/.wma/.m4a' 750 + 'audio': '支持格式:.mp3/.m4a/.aac/.wav(不支持 .wma)'
639 } 751 }
640 return tipsMap[activeType.value] || '' 752 return tipsMap[activeType.value] || ''
641 } 753 }
...@@ -745,18 +857,20 @@ const onClickPreview = (file, detail) => { ...@@ -745,18 +857,20 @@ const onClickPreview = (file, detail) => {
745 } 857 }
746 858
747 // 根据打卡类型或文件扩展名判断文件类型 859 // 根据打卡类型或文件扩展名判断文件类型
748 - if (activeType.value === 'audio' || isAudioFile(fileName)) { 860 + const finalFileType = file.file_type || (isAudioFile(fileName) ? 'audio' : (isVideoFile(fileName) ? 'video' : 'image'))
861 +
862 + if (finalFileType === 'audio') {
749 console.log('准备播放音频:', fileName, fileUrl) 863 console.log('准备播放音频:', fileName, fileUrl)
750 showAudio(fileName, fileUrl) 864 showAudio(fileName, fileUrl)
751 - } else if (activeType.value === 'video' || isVideoFile(fileName)) { 865 + } else if (finalFileType === 'video') {
752 console.log('准备播放视频:', fileName, fileUrl) 866 console.log('准备播放视频:', fileName, fileUrl)
753 showVideo(fileName, fileUrl) 867 showVideo(fileName, fileUrl)
754 - } else if (activeType.value === 'image' || isImageFile(fileName)) { 868 + } else if (finalFileType === 'image') {
755 console.log('图片预览由van-uploader组件处理,跳过文件列表点击预览') 869 console.log('图片预览由van-uploader组件处理,跳过文件列表点击预览')
756 // 图片预览由van-uploader的@click-preview事件处理,避免重复弹出 870 // 图片预览由van-uploader的@click-preview事件处理,避免重复弹出
757 return 871 return
758 } else { 872 } else {
759 - console.log('该文件类型不支持预览,文件名:', fileName, '类型:', activeType.value) 873 + console.log('该文件类型不支持预览,文件名:', fileName, '类型:', finalFileType)
760 showToast('该文件类型不支持预览') 874 showToast('该文件类型不支持预览')
761 } 875 }
762 } 876 }
......
1 <!-- 1 <!--
2 * @Date: 2025-05-29 15:34:17 2 * @Date: 2025-05-29 15:34:17
3 * @LastEditors: hookehuyr hookehuyr@gmail.com 3 * @LastEditors: hookehuyr hookehuyr@gmail.com
4 - * @LastEditTime: 2026-01-23 10:46:15 4 + * @LastEditTime: 2026-01-24 15:29:28
5 * @FilePath: /mlaj/src/views/checkin/IndexCheckInPage.vue 5 * @FilePath: /mlaj/src/views/checkin/IndexCheckInPage.vue
6 * @Description: 用户打卡主页 6 * @Description: 用户打卡主页
7 --> 7 -->
...@@ -460,45 +460,6 @@ const goToCheckinDetailPage = () => { ...@@ -460,45 +460,6 @@ const goToCheckinDetailPage = () => {
460 }) 460 })
461 } 461 }
462 462
463 -// const goToCheckinTextPage = () => {
464 -// router.push({
465 -// path: '/checkin/text',
466 -// query: {
467 -// id: route.query.id,
468 -// type: 'text'
469 -// }
470 -// })
471 -// }
472 -
473 -// const goToCheckinImagePage = () => {
474 -// router.push({
475 -// path: '/checkin/image',
476 -// query: {
477 -// id: route.query.id,
478 -// type: 'image'
479 -// }
480 -// })
481 -// }
482 -// const goToCheckinVideoPage = (type) => {
483 -// router.push({
484 -// path: '/checkin/video',
485 -// query: {
486 -// id: route.query.id,
487 -// type: 'video',
488 -// }
489 -// })
490 -// }
491 -
492 -// const goToCheckinAudioPage = (type) => {
493 -// router.push({
494 -// path: '/checkin/audio',
495 -// query: {
496 -// id: route.query.id,
497 -// type: 'audio',
498 -// }
499 -// })
500 -// }
501 -
502 const handLike = async (post) => { 463 const handLike = async (post) => {
503 if (!post.is_liked) { 464 if (!post.is_liked) {
504 const { code, data } = await likeUploadTaskInfoAPI({ checkin_id: post.id, }) 465 const { code, data } = await likeUploadTaskInfoAPI({ checkin_id: post.id, })
...@@ -716,29 +677,33 @@ const formatData = (data) => { ...@@ -716,29 +677,33 @@ const formatData = (data) => {
716 let images = []; 677 let images = [];
717 let audio = []; 678 let audio = [];
718 let videoList = []; 679 let videoList = [];
719 - if (item.file_type === 'image') { 680 +
720 - images = item.files.map(file => { 681 + // 支持多类型混合显示:遍历 files 数组根据 file_type 分类
721 - return file.value; 682 + if (item.files && Array.isArray(item.files)) {
722 - }); 683 + item.files.forEach(file => {
723 - } else if (item.file_type === 'video') { 684 + // 优先使用文件自身的 file_type,如果没有则回退到 item.file_type
724 - videoList = item.files.map(file => { 685 + const type = file.file_type || item.file_type;
725 - return { 686 +
687 + if (type === 'image') {
688 + images.push(file.value);
689 + } else if (type === 'video') {
690 + videoList.push({
726 id: file.meta_id, 691 id: file.meta_id,
727 video: file.value, 692 video: file.value,
728 videoCover: file.cover, 693 videoCover: file.cover,
729 isPlaying: false, 694 isPlaying: false,
730 - } 695 + });
731 - }) 696 + } else if (type === 'audio') {
732 - } else if (item.file_type === 'audio') { 697 + audio.push({
733 - audio = item.files.map(file => {
734 - return {
735 title: file.name ? file.name : '打卡音频', 698 title: file.name ? file.name : '打卡音频',
736 artist: file.artist ? file.artist : '', 699 artist: file.artist ? file.artist : '',
737 url: file.value, 700 url: file.value,
738 cover: file.cover ? file.cover : '', 701 cover: file.cover ? file.cover : '',
702 + });
739 } 703 }
740 - }) 704 + });
741 } 705 }
706 +
742 return { 707 return {
743 id: item.id, 708 id: item.id,
744 task_id: item.task_id, 709 task_id: item.task_id,
......
1 -<!--
2 - * @Date: 2025-06-03 09:41:41
3 - * @LastEditors: hookehuyr hookehuyr@gmail.com
4 - * @LastEditTime: 2025-06-13 14:34:57
5 - * @FilePath: /mlaj/src/views/checkin/upload/audio.vue
6 - * @Description: 音视频文件上传组件
7 --->
8 -<template>
9 - <div class="checkin-upload-file p-4">
10 - <div class="title text-center pb-4 font-bold">{{route.meta.title}}</div>
11 - <!-- 文件上传区域 -->
12 - <div class="mb-4">
13 - <van-uploader
14 - v-model="fileList"
15 - :max-count="max_count"
16 - :max-size="20 * 1024 * 1024"
17 - :before-read="beforeRead"
18 - :after-read="afterRead"
19 - @delete="onDelete"
20 - multiple
21 - accept=".mp3,.wav,.aac"
22 - result-type="file"
23 - upload-icon="plus"
24 - :deletable="false"
25 - >
26 - </van-uploader>
27 - <van-row v-for="(item, index) in fileList" :key="index" class="mt-2 text-s text-gray-500">
28 - <van-col span="22">{{ item.name }}</van-col>
29 - <van-col span="2" @click="delItem(item)"><van-icon name="clear" /></van-col>
30 - </van-row>
31 - <van-divider />
32 - <div class="mt-2 text-xs text-gray-500">最多上传{{ max_count }}个文件,每个不超过20M</div>
33 - <div class="mt-2 text-xs text-gray-500">上传类型:&nbsp;.mp3,.wav,.aac 格式音频文件</div>
34 - </div>
35 -
36 - <!-- 文字留言区域 -->
37 - <div class="mb-4 border">
38 - <van-field
39 - v-model="message"
40 - rows="4"
41 - autosize
42 - type="textarea"
43 - placeholder="请输入打卡留言"
44 - />
45 - </div>
46 -
47 - <!-- 提交按钮 -->
48 - <div class="fixed bottom-0 left-0 right-0 p-4 bg-white">
49 - <van-button
50 - type="primary"
51 - block
52 - :loading="uploading"
53 - :disabled="!canSubmit"
54 - @click="onSubmit"
55 - >
56 - 提交
57 - </van-button>
58 - </div>
59 -
60 - <!-- 上传加载遮罩 -->
61 - <van-overlay :show="loading">
62 - <div class="wrapper" @click.stop>
63 - <van-loading vertical color="#FFFFFF">上传中...</van-loading>
64 - </div>
65 - </van-overlay>
66 - </div>
67 -</template>
68 -
69 -<script setup>
70 -import { ref, computed } from 'vue'
71 -import { useRoute, useRouter } from 'vue-router'
72 -import { showToast, showLoadingToast } from 'vant'
73 -import { qiniuTokenAPI, qiniuUploadAPI, saveFileAPI } from '@/api/common'
74 -import { addUploadTaskAPI, getUploadTaskInfoAPI, editUploadTaskInfoAPI } from "@/api/checkin";
75 -import { qiniuFileHash } from '@/utils/qiniuFileHash';
76 -import _ from 'lodash'
77 -import { useTitle } from '@vueuse/core';
78 -import { useAuth } from '@/contexts/auth'
79 -
80 -const route = useRoute()
81 -const router = useRouter()
82 -const { currentUser } = useAuth()
83 -useTitle(route.meta.title);
84 -
85 -const max_count = ref(5);
86 -
87 -// 文件列表
88 -const fileList = ref([])
89 -// 留言内容
90 -const message = ref('')
91 -// 上传状态
92 -const uploading = ref(false)
93 -// 上传loading
94 -const loading = ref(false)
95 -
96 -// 是否可以提交
97 -const canSubmit = computed(() => {
98 - return fileList.value.length > 0 && message.value.trim() !== ''
99 -})
100 -
101 -// 文件校验
102 -const beforeRead = (file) => {
103 - let flag = true
104 -
105 - if (Array.isArray(file)) {
106 - // 多个文件
107 - const invalidTypes = file.filter(item => {
108 - const fileType = item.type.toLowerCase();
109 - return !fileType.startsWith('audio/');
110 - })
111 - if (invalidTypes.length) {
112 - flag = false
113 - showToast('请上传音频文件')
114 - }
115 - if (fileList.value.length + file.length > max_count.value) {
116 - flag = false
117 - showToast(`最大上传数量为${max_count.value}个`)
118 - }
119 - } else {
120 - const fileType = file.type.toLowerCase();
121 - if (!fileType.startsWith('audio/')) {
122 - showToast('请上传音频文件')
123 - flag = false
124 - }
125 - if (fileList.value.length + 1 > max_count.value) {
126 - flag = false
127 - showToast(`最大上传数量为${max_count.value}个`)
128 - }
129 - if ((file.size / 1024 / 1024).toFixed(2) > 20) {
130 - flag = false
131 - showToast('最大文件体积为20MB')
132 - }
133 - }
134 - return flag
135 -}
136 -
137 -/**
138 - * 获取文件哈希(与七牛云ETag一致)
139 - * @param {File} file 文件对象
140 - * @returns {Promise<string>} 哈希字符串
141 - * 注释:使用 qiniuFileHash 进行计算,替代浏览器MD5方案。
142 - */
143 -const getFileMD5 = async (file) => {
144 - return await qiniuFileHash(file)
145 -}
146 -
147 -// 上传到七牛云
148 -const uploadToQiniu = async (file, token, fileName) => {
149 - const formData = new FormData()
150 - formData.append('file', file)
151 - formData.append('token', token)
152 - formData.append('key', fileName)
153 -
154 - const config = {
155 - headers: { 'Content-Type': 'multipart/form-data' }
156 - }
157 -
158 - // 根据协议选择上传地址
159 - const qiniuUploadUrl = window.location.protocol === 'https:'
160 - ? 'https://up.qbox.me'
161 - : 'http://upload.qiniu.com'
162 -
163 - return await qiniuUploadAPI(qiniuUploadUrl, formData, config)
164 -}
165 -
166 -// 处理单个文件上传
167 -const handleUpload = async (file) => {
168 - loading.value = true
169 - try {
170 - // 获取MD5值
171 - const md5 = await getFileMD5(file.file)
172 -
173 - // 获取七牛token
174 - const tokenResult = await qiniuTokenAPI({
175 - name: file.file.name,
176 - hash: md5
177 - })
178 -
179 - // 文件已存在,直接返回
180 - if (tokenResult.data) {
181 - return tokenResult.data
182 - }
183 -
184 - // 新文件上传
185 - if (tokenResult.token) {
186 - const suffix = /.[^.]+$/.exec(file.file.name) || ''
187 - const fileName = `mlaj/upload/checkin/${currentUser.value.mobile}/file/${md5}${suffix}`
188 -
189 - const { filekey } = await uploadToQiniu(
190 - file.file,
191 - tokenResult.token,
192 - fileName
193 - )
194 -
195 - if (filekey) {
196 - // 保存文件信息
197 - const { data } = await saveFileAPI({
198 - name: file.file.name,
199 - filekey,
200 - hash: md5
201 - })
202 - return data
203 - }
204 - }
205 - return null
206 - } catch (error) {
207 - console.error('Upload error:', error)
208 - return null
209 - } finally {
210 - loading.value = false
211 - }
212 -}
213 -
214 -// 文件读取后的处理
215 -const afterRead = async (file) => {
216 - if (Array.isArray(file)) {
217 - // 多文件上传
218 - for (const item of file) {
219 - item.status = 'uploading'
220 - item.message = '上传中...'
221 - const result = await handleUpload(item)
222 - if (result) {
223 - item.status = 'done'
224 - item.message = '上传成功'
225 - item.url = result.url
226 - item.meta_id = result.meta_id
227 - item.name = result.name
228 - } else {
229 - item.status = 'failed'
230 - item.message = '上传失败'
231 - showToast('上传失败,请重试')
232 - }
233 - }
234 - } else {
235 - // 单文件上传
236 - file.status = 'uploading'
237 - file.message = '上传中...'
238 - const result = await handleUpload(file)
239 - if (result) {
240 - file.status = 'done'
241 - file.message = '上传成功'
242 - file.url = result.url
243 - file.meta_id = result.meta_id
244 - file.name = result.name
245 - } else {
246 - file.status = 'failed'
247 - file.message = '上传失败'
248 - showToast('上传失败,请重试')
249 - }
250 - }
251 -}
252 -
253 -// 删除文件
254 -const onDelete = (file) => {
255 - const index = fileList.value.indexOf(file)
256 - if (index !== -1) {
257 - fileList.value.splice(index, 1)
258 - }
259 -}
260 -
261 -// 提交表单
262 -const onSubmit = async () => {
263 - if (uploading.value) return
264 -
265 - // 检查是否所有文件都上传完成
266 - const hasUploadingFiles = fileList.value.some(file => file.status === 'uploading')
267 - if (hasUploadingFiles) {
268 - showToast('请等待所有文件上传完成')
269 - return
270 - }
271 -
272 - uploading.value = true
273 - const toast = showLoadingToast({
274 - message: '提交中...',
275 - forbidClick: true,
276 - })
277 -
278 - try {
279 - if (route.query.status === 'edit') {
280 - // 编辑打卡接口
281 - const { code, data } = await editUploadTaskInfoAPI({
282 - i: route.query.post_id,
283 - note: message.value,
284 - meta_id: fileList.value.map(item => item.meta_id),
285 - file_type: route.query.type,
286 - });
287 - if (code === 1) {
288 - showToast('提交成功')
289 - router.back()
290 - }
291 - } else {
292 - // 新增打卡接口
293 - const { code, data } = await addUploadTaskAPI({
294 - task_id: route.query.id,
295 - note: message.value,
296 - meta_id: fileList.value.map(item => item.meta_id),
297 - file_type: route.query.type,
298 - });
299 - if (code === 1) {
300 - showToast('提交成功')
301 - router.back()
302 - }
303 - }
304 - } catch (error) {
305 - showToast('提交失败,请重试')
306 - } finally {
307 - toast.close()
308 - uploading.value = false
309 - }
310 -}
311 -
312 -onMounted(async () => {
313 - if (route.query.status === 'edit') {
314 - const { code, data } = await getUploadTaskInfoAPI({ i: route.query.post_id });
315 - if (code === 1) {
316 - fileList.value = data.files.map(item => ({
317 - name: item.name,
318 - url: item.value,
319 - status: 'done',
320 - message: '上传成功',
321 - meta_id: item.meta_id,
322 - }))
323 - message.value = data.note
324 - }
325 - }
326 -});
327 -
328 -const delItem = (item) => {
329 - const index = fileList.value.indexOf(item)
330 - if (index!== -1) {
331 - fileList.value.splice(index, 1)
332 - }
333 -}
334 -</script>
335 -
336 -<style lang="less" scoped>
337 -.checkin-upload-file {
338 - min-height: 100vh;
339 - padding-bottom: 80px;
340 -}
341 -
342 -.wrapper {
343 - display: flex;
344 - align-items: center;
345 - justify-content: center;
346 - height: 100%;
347 -}
348 -
349 -.preview-cover {
350 - position: absolute;
351 - bottom: 0;
352 - box-sizing: border-box;
353 - width: 100%;
354 - padding: 4px;
355 - color: #fff;
356 - font-size: 12px;
357 - text-align: center;
358 - background: rgba(0, 0, 0, 0.3);
359 - }
360 -</style>
1 -<template>
2 - <div class="checkin-upload-image p-4">
3 - <div class="title text-center pb-4 font-bold">{{route.meta.title}}</div>
4 - <!-- 图片上传区域 -->
5 - <div class="mb-4">
6 - <van-uploader
7 - v-model="fileList"
8 - :max-count="max_count"
9 - :max-size="20 * 1024 * 1024"
10 - :before-read="beforeRead"
11 - :after-read="afterRead"
12 - @delete="onDelete"
13 - multiple
14 - accept="image/*"
15 - result-type="file"
16 - >
17 - </van-uploader>
18 - <div class="mt-2 text-xs text-gray-500">最多上传{{ max_count }}张图片,每张不超过20M</div>
19 - <div class="mt-2 text-xs text-gray-500">上传类型:&nbsp;{{ type_text }}</div>
20 - </div>
21 -
22 - <!-- 文字留言区域 -->
23 - <div class="mb-4 border">
24 - <van-field
25 - v-model="message"
26 - rows="4"
27 - autosize
28 - type="textarea"
29 - placeholder="请输入打卡留言"
30 - />
31 - </div>
32 -
33 - <!-- 提交按钮 -->
34 - <div class="fixed bottom-0 left-0 right-0 p-4 bg-white">
35 - <van-button
36 - type="primary"
37 - block
38 - :loading="uploading"
39 - :disabled="!canSubmit"
40 - @click="onSubmit"
41 - >
42 - 提交
43 - </van-button>
44 - </div>
45 -
46 - <!-- 上传加载遮罩 -->
47 - <van-overlay :show="loading">
48 - <div class="wrapper" @click.stop>
49 - <van-loading vertical color="#FFFFFF">上传中...</van-loading>
50 - </div>
51 - </van-overlay>
52 - </div>
53 -</template>
54 -
55 -<script setup>
56 -import { ref, computed } from 'vue'
57 -import { useRoute, useRouter } from 'vue-router'
58 -import { showToast, showLoadingToast } from 'vant'
59 -import { qiniuTokenAPI, qiniuUploadAPI, saveFileAPI } from '@/api/common'
60 -import { addUploadTaskAPI, getUploadTaskInfoAPI, editUploadTaskInfoAPI } from "@/api/checkin";
61 -import { qiniuFileHash } from '@/utils/qiniuFileHash';
62 -import _ from 'lodash'
63 -import { useTitle } from '@vueuse/core';
64 -import { useAuth } from '@/contexts/auth'
65 -
66 -const route = useRoute()
67 -const router = useRouter()
68 -const { currentUser } = useAuth()
69 -useTitle(route.meta.title);
70 -
71 -const max_count = ref(5);
72 -
73 -// 文件列表
74 -const fileList = ref([])
75 -// 留言内容
76 -const message = ref('')
77 -// 上传状态
78 -const uploading = ref(false)
79 -// 上传loading
80 -const loading = ref(false)
81 -
82 -// 是否可以提交
83 -const canSubmit = computed(() => {
84 - return fileList.value.length > 0 && message.value.trim() !== ''
85 -})
86 -
87 -// 固定类型限制
88 -const imageTypes = "jpg/jpeg/png";
89 -
90 -// 文件类型中文页面显示
91 -const type_text = computed(() => {
92 - // return props.item.component_props.image_type;
93 - return imageTypes;
94 -});
95 -
96 -// 文件校验
97 -const beforeRead = (file) => {
98 - const image_types = _.map(imageTypes.split("/"), (item) => `image/${item}`);
99 - let flag = true
100 -
101 - if (Array.isArray(file)) {
102 - // 多张图片
103 - const invalidTypes = file.filter(item => {
104 - const fileType = item.type.toLowerCase();
105 - return !image_types.some(type => fileType.includes(type.split('/')[1]));
106 - })
107 - if (invalidTypes.length) {
108 - flag = false
109 - showToast('请上传指定格式图片')
110 - }
111 - if (fileList.value.length + file.length > max_count.value) {
112 - flag = false
113 - showToast(`最大上传数量为${max_count.value}张`)
114 - }
115 - } else {
116 - const fileType = file.type.toLowerCase();
117 - if (!image_types.some(type => fileType.includes(type.split('/')[1]))) {
118 - showToast('请上传指定格式图片')
119 - flag = false
120 - }
121 - if (fileList.value.length + 1 > max_count.value) {
122 - flag = false
123 - showToast(`最大上传数量为${max_count.value}张`)
124 - }
125 - if ((file.size / 1024 / 1024).toFixed(2) > 20) {
126 - flag = false
127 - showToast('最大文件体积为20MB')
128 - }
129 - }
130 - return flag
131 -}
132 -
133 -/**
134 - * 获取文件哈希(与七牛云ETag一致)
135 - * @param {File} file 文件对象
136 - * @returns {Promise<string>} 哈希字符串
137 - * 注释:使用 qiniuFileHash 进行计算,替代浏览器MD5方案。
138 - */
139 -const getFileMD5 = async (file) => {
140 - return await qiniuFileHash(file)
141 -}
142 -
143 -// 上传到七牛云
144 -const uploadToQiniu = async (file, token, fileName) => {
145 - const formData = new FormData()
146 - formData.append('file', file)
147 - formData.append('token', token)
148 - formData.append('key', fileName)
149 -
150 - const config = {
151 - headers: { 'Content-Type': 'multipart/form-data' }
152 - }
153 -
154 - // 根据协议选择上传地址
155 - const qiniuUploadUrl = window.location.protocol === 'https:'
156 - ? 'https://up.qbox.me'
157 - : 'http://upload.qiniu.com'
158 -
159 - return await qiniuUploadAPI(qiniuUploadUrl, formData, config)
160 -}
161 -
162 -// 处理单个文件上传
163 -const handleUpload = async (file) => {
164 - loading.value = true
165 - try {
166 - // 获取MD5值
167 - const md5 = await getFileMD5(file.file)
168 -
169 - // 获取七牛token
170 - const tokenResult = await qiniuTokenAPI({
171 - name: file.file.name,
172 - hash: md5
173 - })
174 -
175 - // 文件已存在,直接返回
176 - if (tokenResult.data) {
177 - return tokenResult.data
178 - }
179 -
180 - // 新文件上传
181 - if (tokenResult.token) {
182 - const suffix = /.[^.]+$/.exec(file.file.name) || ''
183 - const fileName = `mlaj/upload/checkin/${currentUser.value.mobile}/img/${md5}${suffix}`
184 -
185 - const { filekey, image_info } = await uploadToQiniu(
186 - file.file,
187 - tokenResult.token,
188 - fileName
189 - )
190 -
191 - if (filekey) {
192 - // 保存文件信息
193 - const { data } = await saveFileAPI({
194 - name: file.file.name,
195 - filekey,
196 - hash: md5,
197 - height: image_info?.height,
198 - width: image_info?.width
199 - })
200 - return data
201 - }
202 - }
203 - return null
204 - } catch (error) {
205 - console.error('Upload error:', error)
206 - return null
207 - } finally {
208 - loading.value = false
209 - }
210 -}
211 -
212 -// 文件读取后的处理
213 -const afterRead = async (file) => {
214 - if (Array.isArray(file)) {
215 - // 多文件上传
216 - for (const item of file) {
217 - item.status = 'uploading'
218 - item.message = '上传中...'
219 - const result = await handleUpload(item)
220 - if (result) {
221 - item.status = 'done'
222 - item.message = '上传成功'
223 - item.url = result.url
224 - item.meta_id = result.meta_id
225 - } else {
226 - item.status = 'failed'
227 - item.message = '上传失败'
228 - showToast('上传失败,请重试')
229 - }
230 - }
231 - } else {
232 - // 单文件上传
233 - file.status = 'uploading'
234 - file.message = '上传中...'
235 - const result = await handleUpload(file)
236 - if (result) {
237 - file.status = 'done'
238 - file.message = '上传成功'
239 - file.url = result.url
240 - file.meta_id = result.meta_id
241 - } else {
242 - file.status = 'failed'
243 - file.message = '上传失败'
244 - showToast('上传失败,请重试')
245 - }
246 - }
247 -}
248 -
249 -// 删除文件
250 -const onDelete = (file) => {
251 - const index = fileList.value.indexOf(file)
252 - if (index !== -1) {
253 - fileList.value.splice(index, 1)
254 - }
255 -}
256 -
257 -// 提交表单
258 -const onSubmit = async () => {
259 - if (uploading.value) return
260 -
261 - // 检查是否所有文件都上传完成
262 - const hasUploadingFiles = fileList.value.some(file => file.status === 'uploading')
263 - if (hasUploadingFiles) {
264 - showToast('请等待所有文件上传完成')
265 - return
266 - }
267 -
268 - uploading.value = true
269 - const toast = showLoadingToast({
270 - message: '提交中...',
271 - forbidClick: true,
272 - })
273 -
274 - try {
275 - if (route.query.status === 'edit') {
276 - // 编辑打卡接口
277 - const { code, data } = await editUploadTaskInfoAPI({
278 - i: route.query.post_id,
279 - note: message.value,
280 - meta_id: fileList.value.map(item => item.meta_id),
281 - file_type: route.query.type,
282 - });
283 - if (code === 1) {
284 - showToast('提交成功')
285 - router.back()
286 - }
287 - } else {
288 - // 新增打卡接口
289 - const { code, data } = await addUploadTaskAPI({
290 - task_id: route.query.id,
291 - note: message.value,
292 - meta_id: fileList.value.map(item => item.meta_id),
293 - file_type: route.query.type,
294 - });
295 - if (code === 1) {
296 - showToast('提交成功')
297 - router.back()
298 - }
299 - }
300 - } catch (error) {
301 - showToast('提交失败,请重试')
302 - } finally {
303 - toast.close()
304 - uploading.value = false
305 - }
306 -}
307 -
308 -onMounted(async () => {
309 - if (route.query.status === 'edit') {
310 - const { code, data } = await getUploadTaskInfoAPI({ i: route.query.post_id });
311 - if (code === 1) {
312 - fileList.value = data.files.map(item => ({
313 - url: item.value,
314 - status: 'done',
315 - message: '上传成功',
316 - meta_id: item.meta_id,
317 - }))
318 - message.value = data.note
319 - }
320 - }
321 -})
322 -</script>
323 -
324 -<style lang="less" scoped>
325 -.checkin-upload-image {
326 - min-height: 100vh;
327 - padding-bottom: 80px;
328 -}
329 -
330 -.wrapper {
331 - display: flex;
332 - align-items: center;
333 - justify-content: center;
334 - height: 100%;
335 -}
336 -</style>
1 -<template>
2 - <div class="checkin-upload-text p-4">
3 - <div class="title text-center pb-4 font-bold">{{ route.meta.title }}</div>
4 -
5 - <!-- 文字输入区域 -->
6 - <div class="mb-4 border">
7 - <van-field
8 - v-model="message"
9 - rows="8"
10 - autosize
11 - type="textarea"
12 - placeholder="请输入打卡内容, 至少需要10个字符"
13 - maxlength="500"
14 - show-word-limit
15 - />
16 - </div>
17 -
18 - <!-- 提交按钮 -->
19 - <div class="fixed bottom-0 left-0 right-0 p-4 bg-white">
20 - <van-button
21 - type="primary"
22 - block
23 - :loading="uploading"
24 - :disabled="!canSubmit"
25 - @click="onSubmit"
26 - >
27 - 提交
28 - </van-button>
29 - </div>
30 - </div>
31 -</template>
32 -
33 -<script setup>
34 -import { ref, computed, onMounted } from 'vue'
35 -import { useRoute, useRouter } from 'vue-router'
36 -import { showToast, showLoadingToast } from 'vant'
37 -import { addUploadTaskAPI, getUploadTaskInfoAPI, editUploadTaskInfoAPI } from "@/api/checkin";
38 -import { useTitle } from '@vueuse/core';
39 -
40 -const route = useRoute()
41 -const router = useRouter()
42 -useTitle(route.meta.title);
43 -
44 -// 留言内容
45 -const message = ref('')
46 -// 上传状态
47 -const uploading = ref(false)
48 -
49 -/**
50 - * 是否可以提交
51 - */
52 -const canSubmit = computed(() => {
53 - return message.value.trim() !== '' && message.value.trim().length >= 10
54 -})
55 -
56 -/**
57 - * 提交表单
58 - */
59 -const onSubmit = async () => {
60 - if (uploading.value) return
61 -
62 - if (message.value.trim().length < 10) {
63 - showToast('打卡内容至少需要10个字符')
64 - return
65 - }
66 -
67 - uploading.value = true
68 - const toast = showLoadingToast({
69 - message: '提交中...',
70 - forbidClick: true,
71 - })
72 -
73 - try {
74 - if (route.query.status === 'edit') {
75 - // 编辑打卡接口
76 - const { code, data } = await editUploadTaskInfoAPI({
77 - i: route.query.post_id,
78 - note: message.value,
79 - meta_id: [], // 文本类型不需要文件
80 - file_type: route.query.type,
81 - });
82 - if (code === 1) {
83 - showToast('提交成功')
84 - router.back()
85 - }
86 - } else {
87 - // 新增打卡接口
88 - const { code, data } = await addUploadTaskAPI({
89 - task_id: route.query.id,
90 - note: message.value,
91 - meta_id: [], // 文本类型不需要文件
92 - file_type: route.query.type,
93 - });
94 - if (code === 1) {
95 - showToast('提交成功')
96 - router.back()
97 - }
98 - }
99 - } catch (error) {
100 - showToast('提交失败,请重试')
101 - } finally {
102 - uploading.value = false
103 - }
104 -}
105 -
106 -/**
107 - * 页面挂载时的初始化逻辑
108 - */
109 -onMounted(async () => {
110 - if (route.query.status === 'edit') {
111 - const { code, data } = await getUploadTaskInfoAPI({ i: route.query.post_id });
112 - if (code === 1) {
113 - message.value = data.note
114 - }
115 - }
116 -})
117 -</script>
118 -
119 -<style lang="less" scoped>
120 -.checkin-upload-text {
121 - min-height: 100vh;
122 - padding-bottom: 80px;
123 -}
124 -
125 -.van-field {
126 - border-radius: 8px;
127 - background-color: #f8f9fa;
128 -}
129 -
130 -.van-field__control {
131 - font-size: 16px;
132 - line-height: 1.5;
133 -}
134 -</style>
1 -<!--
2 - * @Date: 2025-06-03 09:41:41
3 - * @LastEditors: hookehuyr hookehuyr@gmail.com
4 - * @LastEditTime: 2025-07-02 18:33:29
5 - * @FilePath: /mlaj/src/views/checkin/upload/video.vue
6 - * @Description: 音视频文件上传组件
7 --->
8 -<template>
9 - <div class="checkin-upload-file p-4">
10 - <div class="title text-center pb-4 font-bold">{{route.meta.title}}</div>
11 - <!-- 文件上传区域 -->
12 - <div class="mb-4">
13 - <van-uploader
14 - v-model="fileList"
15 - :max-count="max_count"
16 - :max-size="20 * 1024 * 1024"
17 - :before-read="beforeRead"
18 - :after-read="afterRead"
19 - @delete="onDelete"
20 - multiple
21 - accept="video/*"
22 - result-type="file"
23 - upload-icon="plus"
24 - :deletable="false"
25 - >
26 - </van-uploader>
27 - <van-row v-for="(item, index) in fileList" :key="index" class="mt-2 text-s text-gray-500">
28 - <van-col span="22">{{ item.name }}</van-col>
29 - <van-col span="2" @click="delItem(item)"><van-icon name="clear" /></van-col>
30 - </van-row>
31 - <van-divider />
32 - <div class="mt-2 text-xs text-gray-500">最多上传{{ max_count }}个文件,每个不超过20M</div>
33 - <div class="mt-2 text-xs text-gray-500">上传类型:&nbsp;视频文件</div>
34 - </div>
35 -
36 - <!-- 文字留言区域 -->
37 - <div class="mb-4 border">
38 - <van-field
39 - v-model="message"
40 - rows="4"
41 - autosize
42 - type="textarea"
43 - placeholder="请输入打卡留言"
44 - />
45 - </div>
46 -
47 - <!-- 提交按钮 -->
48 - <div class="fixed bottom-0 left-0 right-0 p-4 bg-white">
49 - <van-button
50 - type="primary"
51 - block
52 - :loading="uploading"
53 - :disabled="!canSubmit"
54 - @click="onSubmit"
55 - >
56 - 提交
57 - </van-button>
58 - </div>
59 -
60 - <!-- 上传加载遮罩 -->
61 - <van-overlay :show="loading">
62 - <div class="wrapper" @click.stop>
63 - <van-loading vertical color="#FFFFFF">上传中...</van-loading>
64 - </div>
65 - </van-overlay>
66 - </div>
67 -</template>
68 -
69 -<script setup>
70 -import { ref, computed } from 'vue'
71 -import { useRoute, useRouter } from 'vue-router'
72 -import { showToast, showLoadingToast } from 'vant'
73 -import { qiniuTokenAPI, qiniuUploadAPI, saveFileAPI } from '@/api/common'
74 -import { addUploadTaskAPI, getUploadTaskInfoAPI, editUploadTaskInfoAPI } from "@/api/checkin";
75 -import { qiniuFileHash } from '@/utils/qiniuFileHash';
76 -import _ from 'lodash'
77 -import { useTitle } from '@vueuse/core';
78 -import { useAuth } from '@/contexts/auth'
79 -
80 -const route = useRoute()
81 -const router = useRouter()
82 -const { currentUser } = useAuth()
83 -useTitle(route.meta.title);
84 -
85 -const max_count = ref(5);
86 -
87 -// 文件列表
88 -const fileList = ref([])
89 -// 留言内容
90 -const message = ref('')
91 -// 上传状态
92 -const uploading = ref(false)
93 -// 上传loading
94 -const loading = ref(false)
95 -
96 -// 是否可以提交
97 -const canSubmit = computed(() => {
98 - return fileList.value.length > 0 && message.value.trim() !== ''
99 -})
100 -
101 -// 文件校验
102 -const beforeRead = (file) => {
103 - let flag = true
104 -
105 - if (Array.isArray(file)) {
106 - // 多个文件
107 - const invalidTypes = file.filter(item => {
108 - const fileType = item.type.toLowerCase();
109 - return !fileType.startsWith('video/');
110 - })
111 - if (invalidTypes.length) {
112 - flag = false
113 - showToast('请上传视频文件')
114 - }
115 - if (fileList.value.length + file.length > max_count.value) {
116 - flag = false
117 - showToast(`最大上传数量为${max_count.value}个`)
118 - }
119 - } else {
120 - const fileType = file.type.toLowerCase();
121 - if (!fileType.startsWith('video/')) {
122 - showToast('请上传视频文件')
123 - flag = false
124 - }
125 - if (fileList.value.length + 1 > max_count.value) {
126 - flag = false
127 - showToast(`最大上传数量为${max_count.value}个`)
128 - }
129 - if ((file.size / 1024 / 1024).toFixed(2) > 20) {
130 - flag = false
131 - showToast('最大文件体积为20MB')
132 - }
133 - }
134 - return flag
135 -}
136 -
137 -/**
138 - * 获取文件哈希(与七牛云ETag一致)
139 - * @param {File} file 文件对象
140 - * @returns {Promise<string>} 哈希字符串
141 - * 注释:使用 qiniuFileHash 进行计算,替代浏览器MD5方案。
142 - */
143 -const getFileMD5 = async (file) => {
144 - return await qiniuFileHash(file)
145 -}
146 -
147 -// 上传到七牛云
148 -const uploadToQiniu = async (file, token, fileName) => {
149 - const formData = new FormData()
150 - formData.append('file', file)
151 - formData.append('token', token)
152 - formData.append('key', fileName)
153 -
154 - const config = {
155 - headers: { 'Content-Type': 'multipart/form-data' }
156 - }
157 -
158 - // 根据协议选择上传地址
159 - const qiniuUploadUrl = window.location.protocol === 'https:'
160 - ? 'https://up.qbox.me'
161 - : 'http://upload.qiniu.com'
162 -
163 - return await qiniuUploadAPI(qiniuUploadUrl, formData, config)
164 -}
165 -
166 -// 处理单个文件上传
167 -const handleUpload = async (file) => {
168 - loading.value = true
169 - try {
170 - // 获取MD5值
171 - const md5 = await getFileMD5(file.file)
172 -
173 - // 获取七牛token
174 - const tokenResult = await qiniuTokenAPI({
175 - name: file.file.name,
176 - hash: md5
177 - })
178 -
179 - // 文件已存在,直接返回
180 - if (tokenResult.data) {
181 - return tokenResult.data
182 - }
183 -
184 - // 新文件上传
185 - if (tokenResult.token) {
186 - const suffix = /.[^.]+$/.exec(file.file.name) || ''
187 - const fileName = `mlaj/upload/checkin/${currentUser.value.mobile}/file/${md5}${suffix}`
188 -
189 - const { filekey } = await uploadToQiniu(
190 - file.file,
191 - tokenResult.token,
192 - fileName
193 - )
194 -
195 - if (filekey) {
196 - // 保存文件信息
197 - const { data } = await saveFileAPI({
198 - name: file.file.name,
199 - filekey,
200 - hash: md5
201 - })
202 - return data
203 - }
204 - }
205 - return null
206 - } catch (error) {
207 - console.error('Upload error:', error)
208 - return null
209 - } finally {
210 - loading.value = false
211 - }
212 -}
213 -
214 -// 文件读取后的处理
215 -const afterRead = async (file) => {
216 - if (Array.isArray(file)) {
217 - // 多文件上传
218 - for (const item of file) {
219 - item.status = 'uploading'
220 - item.message = '上传中...'
221 - const result = await handleUpload(item)
222 - if (result) {
223 - item.status = 'done'
224 - item.message = '上传成功'
225 - item.url = result.url
226 - item.meta_id = result.meta_id
227 - item.name = result.name
228 - } else {
229 - item.status = 'failed'
230 - item.message = '上传失败'
231 - showToast('上传失败,请重试')
232 - }
233 - }
234 - } else {
235 - // 单文件上传
236 - file.status = 'uploading'
237 - file.message = '上传中...'
238 - const result = await handleUpload(file)
239 - if (result) {
240 - file.status = 'done'
241 - file.message = '上传成功'
242 - file.url = result.url
243 - file.meta_id = result.meta_id
244 - file.name = result.name
245 - } else {
246 - file.status = 'failed'
247 - file.message = '上传失败'
248 - showToast('上传失败,请重试')
249 - }
250 - }
251 -}
252 -
253 -// 删除文件
254 -const onDelete = (file) => {
255 - const index = fileList.value.indexOf(file)
256 - if (index !== -1) {
257 - fileList.value.splice(index, 1)
258 - }
259 -}
260 -
261 -// 提交表单
262 -const onSubmit = async () => {
263 - if (uploading.value) return
264 -
265 - // 检查是否所有文件都上传完成
266 - const hasUploadingFiles = fileList.value.some(file => file.status === 'uploading')
267 - if (hasUploadingFiles) {
268 - showToast('请等待所有文件上传完成')
269 - return
270 - }
271 -
272 - uploading.value = true
273 - const toast = showLoadingToast({
274 - message: '提交中...',
275 - forbidClick: true,
276 - })
277 -
278 - try {
279 - if (route.query.status === 'edit') {
280 - // 编辑打卡接口
281 - const { code, data } = await editUploadTaskInfoAPI({
282 - i: route.query.post_id,
283 - note: message.value,
284 - meta_id: fileList.value.map(item => item.meta_id),
285 - file_type: route.query.type,
286 - });
287 - if (code === 1) {
288 - showToast('提交成功')
289 - router.back()
290 - }
291 - } else {
292 - // 新增打卡接口
293 - const { code, data } = await addUploadTaskAPI({
294 - task_id: route.query.id,
295 - note: message.value,
296 - meta_id: fileList.value.map(item => item.meta_id),
297 - file_type: route.query.type,
298 - });
299 - if (code === 1) {
300 - showToast('提交成功')
301 - router.back()
302 - }
303 - }
304 - } catch (error) {
305 - showToast('提交失败,请重试')
306 - } finally {
307 - toast.close()
308 - uploading.value = false
309 - }
310 -}
311 -
312 -onMounted(async () => {
313 - if (route.query.status === 'edit') {
314 - const { code, data } = await getUploadTaskInfoAPI({ i: route.query.post_id });
315 - if (code === 1) {
316 - fileList.value = data.files.map(item => ({
317 - url: item.value,
318 - status: 'done',
319 - message: '上传成功',
320 - meta_id: item.meta_id,
321 - name: item.name,
322 - }))
323 - message.value = data.note
324 - }
325 - }
326 -});
327 -
328 -const delItem = (item) => {
329 - const index = fileList.value.indexOf(item)
330 - if (index!== -1) {
331 - fileList.value.splice(index, 1)
332 - }
333 -}
334 -</script>
335 -
336 -<style lang="less" scoped>
337 -.checkin-upload-file {
338 - min-height: 100vh;
339 - padding-bottom: 80px;
340 -}
341 -
342 -.wrapper {
343 - display: flex;
344 - align-items: center;
345 - justify-content: center;
346 - height: 100%;
347 -}
348 -</style>
1 <!-- 1 <!--
2 * @Date: 2025-10-22 10:45:51 2 * @Date: 2025-10-22 10:45:51
3 * @LastEditors: hookehuyr hookehuyr@gmail.com 3 * @LastEditors: hookehuyr hookehuyr@gmail.com
4 - * @LastEditTime: 2025-10-22 10:54:10 4 + * @LastEditTime: 2026-01-24 14:00:37
5 * @FilePath: /mlaj/src/views/study/PdfPreviewPage.vue 5 * @FilePath: /mlaj/src/views/study/PdfPreviewPage.vue
6 - * @Description: 文件描述 6 + * @Description: PDF预览页
7 --> 7 -->
8 <template> 8 <template>
9 <div class="pdf-preview-page"> 9 <div class="pdf-preview-page">
...@@ -12,9 +12,10 @@ ...@@ -12,9 +12,10 @@
12 </template> 12 </template>
13 13
14 <script setup> 14 <script setup>
15 -import { computed, onMounted, onBeforeUnmount } from 'vue' 15 +import { computed, defineAsyncComponent, onMounted, onBeforeUnmount } from 'vue'
16 import { useRoute, useRouter } from 'vue-router' 16 import { useRoute, useRouter } from 'vue-router'
17 -import PdfViewer from '@/components/media/PdfViewer.vue' 17 +
18 +const PdfViewer = defineAsyncComponent(() => import('@/components/media/PdfViewer.vue'))
18 19
19 const route = useRoute() 20 const route = useRoute()
20 const router = useRouter() 21 const router = useRouter()
......
...@@ -702,29 +702,33 @@ const formatData = (data) => { ...@@ -702,29 +702,33 @@ const formatData = (data) => {
702 let images = []; 702 let images = [];
703 let audio = []; 703 let audio = [];
704 let videoList = []; 704 let videoList = [];
705 - if (item.file_type === 'image') { 705 +
706 - images = item.files.map(file => { 706 + // 支持多类型混合显示:遍历 files 数组根据 file_type 分类
707 - return file.value; 707 + if (item.files && Array.isArray(item.files)) {
708 - }); 708 + item.files.forEach(file => {
709 - } else if (item.file_type === 'video') { 709 + // 优先使用文件自身的 file_type,如果没有则回退到 item.file_type
710 - videoList = item.files.map(file => { 710 + const type = file.file_type || item.file_type;
711 - return { 711 +
712 + if (type === 'image') {
713 + images.push(file.value);
714 + } else if (type === 'video') {
715 + videoList.push({
712 id: file.meta_id, 716 id: file.meta_id,
713 video: file.value, 717 video: file.value,
714 videoCover: file.cover, 718 videoCover: file.cover,
715 isPlaying: false, 719 isPlaying: false,
716 - } 720 + });
717 - }) 721 + } else if (type === 'audio') {
718 - } else if (item.file_type === 'audio') { 722 + audio.push({
719 - audio = item.files.map(file => {
720 - return {
721 title: file.name ? file.name : '打卡音频', 723 title: file.name ? file.name : '打卡音频',
722 artist: file.artist ? file.artist : '', 724 artist: file.artist ? file.artist : '',
723 url: file.value, 725 url: file.value,
724 cover: file.cover ? file.cover : '', 726 cover: file.cover ? file.cover : '',
727 + });
725 } 728 }
726 - }) 729 + });
727 } 730 }
731 +
728 return { 732 return {
729 id: item.id, 733 id: item.id,
730 task_id: item.task_id, 734 task_id: item.task_id,
......
...@@ -166,8 +166,7 @@ ...@@ -166,8 +166,7 @@
166 166
167 <script setup> 167 <script setup>
168 import { ref, computed, onMounted, onUnmounted } from 'vue' 168 import { ref, computed, onMounted, onUnmounted } from 'vue'
169 -import { useRouter } from 'vue-router' 169 +import { useRouter, useRoute } from 'vue-router'
170 -import AppLayout from '@/layouts/AppLayout.vue'
171 import { useTitle } from '@vueuse/core'; 170 import { useTitle } from '@vueuse/core';
172 import { useAuth } from '@/contexts/auth' 171 import { useAuth } from '@/contexts/auth'
173 import CourseGroupCascader from '@/components/courses/CourseGroupCascader.vue' 172 import CourseGroupCascader from '@/components/courses/CourseGroupCascader.vue'
......
...@@ -841,29 +841,33 @@ const formatData = (data) => { ...@@ -841,29 +841,33 @@ const formatData = (data) => {
841 let images = []; 841 let images = [];
842 let audio = []; 842 let audio = [];
843 let videoList = []; 843 let videoList = [];
844 - if (item.file_type === 'image') { 844 +
845 - images = item.files.map(file => { 845 + // 支持多类型混合显示:遍历 files 数组根据 file_type 分类
846 - return file.value; 846 + if (item.files && Array.isArray(item.files)) {
847 - }); 847 + item.files.forEach(file => {
848 - } else if (item.file_type === 'video') { 848 + // 优先使用文件自身的 file_type,如果没有则回退到 item.file_type
849 - videoList = item.files.map(file => { 849 + const type = file.file_type || item.file_type;
850 - return { 850 +
851 + if (type === 'image') {
852 + images.push(file.value);
853 + } else if (type === 'video') {
854 + videoList.push({
851 id: file.meta_id, 855 id: file.meta_id,
852 video: file.value, 856 video: file.value,
853 videoCover: file.cover, 857 videoCover: file.cover,
854 isPlaying: false, 858 isPlaying: false,
855 - } 859 + });
856 - }) 860 + } else if (type === 'audio') {
857 - } else if (item.file_type === 'audio') { 861 + audio.push({
858 - audio = item.files.map(file => {
859 - return {
860 title: file.name ? file.name : '打卡音频', 862 title: file.name ? file.name : '打卡音频',
861 artist: file.artist ? file.artist : '', 863 artist: file.artist ? file.artist : '',
862 url: file.value, 864 url: file.value,
863 cover: file.cover ? file.cover : '', 865 cover: file.cover ? file.cover : '',
866 + });
864 } 867 }
865 - }) 868 + });
866 } 869 }
870 +
867 return { 871 return {
868 id: item.id, 872 id: item.id,
869 task_id: item.task_id, 873 task_id: item.task_id,
......
1 /* 1 /*
2 * @Date: 2025-03-20 19:53:12 2 * @Date: 2025-03-20 19:53:12
3 * @LastEditors: hookehuyr hookehuyr@gmail.com 3 * @LastEditors: hookehuyr hookehuyr@gmail.com
4 - * @LastEditTime: 2026-01-18 23:17:57 4 + * @LastEditTime: 2026-01-24 13:56:05
5 * @FilePath: /mlaj/vite.config.js 5 * @FilePath: /mlaj/vite.config.js
6 * @Description: 文件描述 6 * @Description: 文件描述
7 */ 7 */
...@@ -111,6 +111,11 @@ export default ({ mode }) => { ...@@ -111,6 +111,11 @@ export default ({ mode }) => {
111 chunkFileNames: 'static/js/[name]-[hash].js', 111 chunkFileNames: 'static/js/[name]-[hash].js',
112 entryFileNames: 'static/js/[name]-[hash].js', 112 entryFileNames: 'static/js/[name]-[hash].js',
113 assetFileNames: 'static/[ext]/[name]-[hash].[ext]', 113 assetFileNames: 'static/[ext]/[name]-[hash].[ext]',
114 + manualChunks: (id) => {
115 + if (!id.includes('node_modules')) return;
116 + if (id.includes('@vue-office/docx') || id.includes('@vue-office/excel') || id.includes('@vue-office/pptx')) return 'vue-office';
117 + if (id.includes('html2canvas') || id.includes('html-to-image')) return 'image-tools';
118 + },
114 }, 119 },
115 input: { // 多页面应用模式, 打包时配置,运行配置要处理root 120 input: { // 多页面应用模式, 打包时配置,运行配置要处理root
116 main: path.resolve(__dirname, 'index.html'), 121 main: path.resolve(__dirname, 'index.html'),
......