hookehuyr

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

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