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
......
This diff is collapsed. Click to expand it.
...@@ -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 +})
......
This diff is collapsed. Click to expand it.
...@@ -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> 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>
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 +
726 - id: file.meta_id, 687 + if (type === 'image') {
727 - video: file.value, 688 + images.push(file.value);
728 - videoCover: file.cover, 689 + } else if (type === 'video') {
729 - isPlaying: false, 690 + videoList.push({
730 - } 691 + id: file.meta_id,
731 - }) 692 + video: file.value,
732 - } else if (item.file_type === 'audio') { 693 + videoCover: file.cover,
733 - audio = item.files.map(file => { 694 + isPlaying: false,
734 - return { 695 + });
735 - title: file.name ? file.name : '打卡音频', 696 + } else if (type === 'audio') {
736 - artist: file.artist ? file.artist : '', 697 + audio.push({
737 - url: file.value, 698 + title: file.name ? file.name : '打卡音频',
738 - cover: file.cover ? file.cover : '', 699 + artist: file.artist ? file.artist : '',
700 + url: file.value,
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()
...@@ -37,7 +38,7 @@ const pdfTitle = computed(() => { ...@@ -37,7 +38,7 @@ const pdfTitle = computed(() => {
37 const handleClose = () => { 38 const handleClose = () => {
38 // 清除刷新标记 39 // 清除刷新标记
39 sessionStorage.removeItem('pdf-preview-refreshed') 40 sessionStorage.removeItem('pdf-preview-refreshed')
40 - 41 +
41 const returnId = route.query.returnId 42 const returnId = route.query.returnId
42 const openMaterials = route.query.openMaterials 43 const openMaterials = route.query.openMaterials
43 if (returnId) { 44 if (returnId) {
......
...@@ -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 - id: file.meta_id, 712 + if (type === 'image') {
713 - video: file.value, 713 + images.push(file.value);
714 - videoCover: file.cover, 714 + } else if (type === 'video') {
715 - isPlaying: false, 715 + videoList.push({
716 - } 716 + id: file.meta_id,
717 - }) 717 + video: file.value,
718 - } else if (item.file_type === 'audio') { 718 + videoCover: file.cover,
719 - audio = item.files.map(file => { 719 + isPlaying: false,
720 - return { 720 + });
721 - title: file.name ? file.name : '打卡音频', 721 + } else if (type === 'audio') {
722 - artist: file.artist ? file.artist : '', 722 + audio.push({
723 - url: file.value, 723 + title: file.name ? file.name : '打卡音频',
724 - cover: file.cover ? file.cover : '', 724 + artist: file.artist ? file.artist : '',
725 + url: file.value,
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 - id: file.meta_id, 851 + if (type === 'image') {
852 - video: file.value, 852 + images.push(file.value);
853 - videoCover: file.cover, 853 + } else if (type === 'video') {
854 - isPlaying: false, 854 + videoList.push({
855 - } 855 + id: file.meta_id,
856 - }) 856 + video: file.value,
857 - } else if (item.file_type === 'audio') { 857 + videoCover: file.cover,
858 - audio = item.files.map(file => { 858 + isPlaying: false,
859 - return { 859 + });
860 - title: file.name ? file.name : '打卡音频', 860 + } else if (type === 'audio') {
861 - artist: file.artist ? file.artist : '', 861 + audio.push({
862 - url: file.value, 862 + title: file.name ? file.name : '打卡音频',
863 - cover: file.cover ? file.cover : '', 863 + artist: file.artist ? file.artist : '',
864 + url: file.value,
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'),
......