feat: 增加裂变页面

share-code
xjs 2026-05-26 10:13:02 +08:00
parent 0d59ff4f74
commit 021f65a7f9
15 changed files with 674 additions and 76 deletions

2
env/.env vendored
View File

@ -2,7 +2,7 @@ VITE_APP_TITLE = '六纬中考通'
VITE_APP_PORT = 9000
VITE_UNI_APPID = 'H57F2ACE4'
VITE_WX_APPID = 'wxc48ad15d58a3e417'
VITE_WX_APPID = 'wx4b925e36c17dd54a'
# h5部署网站的base配置到 manifest.config.ts 里的 h5.router.base
# https://uniapp.dcloud.net.cn/collocation/manifest.html#h5-router

View File

@ -107,6 +107,7 @@
"pinia": "2.0.36",
"pinia-plugin-persistedstate": "3.2.1",
"sard-uniapp": "^1.22.1",
"uqrcodejs": "^4.0.7",
"vue": "^3.4.21",
"z-paging": "^2.8.8"
},

View File

@ -9,14 +9,14 @@
</text>
<view class="mt-[20rpx] text-center">
<view class="text-[#000] text-[26rpx] font-700">学习风格表现</view>
<view class="mt-[10rpx]" v-for="(item, index) in item.learning_performance" :key="index">
{{ item }}
<view class="mt-[10rpx]" v-for="(sonItem, index) in item.learning_performance" :key="index">
{{ sonItem }}
</view>
</view>
<view class="mt-[20rpx] text-center">
<view class="text-[#000] text-[26rpx] font-700">学习风格特点</view>
<view class="mt-[10rpx]" v-for="(item, index) in item.features" :key="index">
{{ item }}
<view class="mt-[10rpx]" v-for="(sonItem, index) in item.features" :key="index">
{{ sonItem }}
</view>
</view>
</view>

View File

@ -16,37 +16,6 @@
避免过度放松保持适度的学习节奏
</view>
</view>
<view class="flex flex-col gap-[12rpx]">
<view class="flex items-center gap-[10rpx]">
<view class="w-[38rpx] h-[38rpx]">
<image
src="https://api.static.ycymedu.com/src/images/home/diet-icon.png"
mode="scaleToFill"
class="w-[38rpx] h-[38rpx]"
/>
</view>
<text class="text-[32rpx] text-[#000] font-700">饮食建议</text>
</view>
<view class="text-[26rpx] text-[#666] font-400">
保持规律作息早睡早起避免熬夜 每天适当运动如散步跑步保持精力充沛
避免过度放松保持适度的学习节奏
</view>
</view>
<view class="flex flex-col gap-[12rpx]">
<view class="flex items-center gap-[10rpx]">
<view class="w-[38rpx] h-[38rpx]">
<image
src="https://api.static.ycymedu.com/src/images/home/learn-icon.png"
mode="scaleToFill"
class="w-[38rpx] h-[38rpx]"
/>
</view>
<text class="text-[32rpx] text-[#000] font-700">学习建议</text>
</view>
<view class="text-[26rpx] text-[#666] font-400">
保持规律作息早睡早起避免熬夜 每天适当运动如散步跑步保持精力充沛
避免过度放松保持适度的学习节奏
</view>
</view>
</view>
</template>

View File

@ -14,7 +14,7 @@
</template>
</Navbar>
<view class="flex-1 overflow-auto relative">
<view class="flex-1 overflow-auto relative flex flex-col">
<view class="flex flex-col flex-1 overflow-auto pb-[20rpx]">
<!-- 顶部卡片 -->
<view class="mt-[30rpx] mx-[24rpx]">

View File

@ -14,7 +14,7 @@
</template>
</Navbar>
<view class="flex-1 overflow-auto relative">
<view class="flex-1 overflow-auto relative flex flex-col">
<view class="flex flex-col flex-1 overflow-auto pb-[20rpx]">
<!-- 顶部卡片 -->
<view class="mt-[30rpx] mx-[24rpx]">

View File

@ -1,37 +1,37 @@
<script lang="ts" setup>
// code here
import RequestComp from './components/request.vue'
definePage({
style: {
navigationBarTitleText: '分包页面',
},
})
function gotoScroll() {
uni.navigateTo({
url: '/pages-sub/demo/scroll',
function navigateToVideoFn() {
uni.openChannelsLive({
finderUserName: 'sphju9MCfZetYHP',
success: () => {
},
fail: (err) => {
console.error('跳转失败:', err)
},
})
}
onLoad(() => {
uni.getChannelsLiveInfo({
finderUserName: 'sphju9MCfZetYHP',
success: (res) => {
console.log('res', res)
},
})
})
</script>
<template>
<view class="text-center">
<view class="m-8">
http://localhost:9000/#/pages-sub/demo/index
</view>
<view class="my-4 text-green-500">
分包页面demo
</view>
<view class="text-blue-500">
分包页面里面的components示例
</view>
<button class="my-4" type="primary" size="mini" @click="gotoScroll">
跳转到上拉刷新和下拉加载更多
<button @click="navigateToVideoFn">
点击跳视频号
</button>
<view>
<RequestComp />
</view>
</view>
</template>

View File

@ -101,23 +101,26 @@ const handleChange = () => {
onShow(() => {
getAreaList().then(resp => {
if (resp.code === 200) {
areaList.value = [{ value: '', label: '不限' }, ...resp.result]
}
Promise.all([
getAreaList().then(resp => {
if (resp.code === 200) {
areaList.value = [{ value: '', label: '不限' }, ...resp.result]
}
}),
getSchoolNature().then(resp => {
if (resp.code === 200) {
natureList.value = [{ value: '', label: '不限' }, ...resp.result]
}
}),
getHistoryYearList().then(resp => {
if (resp.code === 200) {
yearList.value = [...resp.result]
searchParams.value.year = yearList.value[yearList.value.length - 1]?.value
}
})
]).then(() => {
handleChange()
})
getSchoolNature().then(resp => {
if (resp.code === 200) {
natureList.value = [{ value: '', label: '不限' }, ...resp.result]
}
})
getHistoryYearList().then(resp => {
if (resp.code === 200) {
yearList.value = [{ value: '', label: '不限' }, ...resp.result]
}
})
handleChange()
})
</script>

View File

@ -0,0 +1,92 @@
<script lang="ts" setup>
import { trackPromoterRedirect } from '@/service'
import { useInviteStore } from '@/store/invite'
const inviteStore = useInviteStore()
let weChatLiveId = ''
let douyinLiveUrl = ''
onLoad(() => {
const referralCode = inviteStore.currentPromoter?.referralCode
if (!referralCode) {
return
}
trackPromoterRedirect(referralCode).then((resp: any) => {
weChatLiveId = resp?.liveLinks?.wechatLiveUrl || 'sphju9MCfZetYHP'
douyinLiveUrl = resp?.liveLinks?.douyinLiveUrl || ''
}).catch((error) => {
console.error('[redirect]', error)
})
})
function navigateToVideoFn() {
if (!weChatLiveId) {
uni.showToast({ title: '直播链接获取中,请稍后再试', icon: 'none' })
return
}
uni.openChannelsLive({
finderUserName: weChatLiveId,
success: () => {
},
fail: (err) => {
console.error('跳转失败:', err)
},
})
}
function navigateToDouyinFn() {
if (!douyinLiveUrl) {
uni.showToast({ title: '直播链接获取中,请稍后再试', icon: 'none' })
return
}
uni.setClipboardData({
data: douyinLiveUrl,
success: () => {
uni.hideToast()
uni.showModal({
title: '链接已复制',
content: '请打开抖音 App 粘贴查看直播',
showCancel: false,
confirmText: '我知道了',
})
},
fail: () => {
uni.showToast({ title: '复制失败,请稍后重试', icon: 'none' })
},
})
}
</script>
<template>
<view class="h-screen w-screen flex flex-col bg-[#F4F6FA]">
<image
src="https://lw-zk.oss-cn-hangzhou.aliyuncs.com/liebian/ZZBG.png"
mode="widthFix"
class="w-full"
/>
<view class="mt-[80rpx] px-[54rpx]">
<view class="w-full flex items-center justify-center gap-[16rpx] rounded-[210rpx] bg-[#1580FF] py-[30rpx] text-white font-500" @click="navigateToVideoFn">
<view class="h-[39rpx] w-[46rpx] rounded-[210rpx] text-[32rpx]">
<image
src="https://lw-zk.oss-cn-hangzhou.aliyuncs.com/liebian/shipin.png"
mode="widthFix"
/>
</view>
视频号查看
</view>
<view class="mt-[48rpx] w-full flex items-center justify-center gap-[16rpx] border-1 border-[#1580FF] rounded-[210rpx] border-solid bg-white py-[30rpx] text-[#1580FF] font-500" @click="navigateToDouyinFn">
<view class="h-[39rpx] w-[46rpx] rounded-[210rpx] text-[32rpx]">
<image
src="https://lw-zk.oss-cn-hangzhou.aliyuncs.com/liebian/douyin.png"
mode="widthFix"
/>
</view>
抖音查看
</view>
</view>
</view>
</template>

View File

@ -0,0 +1,171 @@
<template>
<view class="relative min-h-screen flex flex-col bg-[#F6F8FB]">
<image
src="https://lw-zk.oss-cn-hangzhou.aliyuncs.com/liebian/fenxiang.png"
mode="widthFix"
class="absolute left-0 top-0 aspect-[1.75/1] w-full"
/>
<form
class="relative z-1 mx-[30rpx] mt-[280rpx] h-max flex flex-col rounded-[20rpx] bg-[#fff] px-[30rpx] pb-[60rpx] pt-[30rpx]"
@submit="handleSubmit"
>
<label class="block">
<view class="mb-[12rpx] block text-[28rpx] text-[#111111] font-500 leading-[1]">
<text class="text-[#EB5241]">*</text>
<text>教师姓名</text>
</view>
<view class="h-[88rpx] flex items-center gap-[18rpx] border border-[#D9D9D9] rounded-[10rpx] border-solid px-[20rpx]">
<input
v-model.trim="form.name"
class="min-w-0 flex-1 select-text border-none bg-transparent p-0 text-[30rpx] text-[#1F2329] outline-none"
type="text"
placeholder="请填写教师姓名"
confirm-type="next"
placeholder-style="color:#A6AFBD;font-size:30rpx;text-align:left;"
>
</view>
</label>
<label class="mt-[50rpx] block">
<view class="mb-[12rpx] block text-[30rpx] text-[#111111] font-500 leading-[1]">
<text class="text-[#EB5241]">*</text>
<text>教师手机号</text>
</view>
<view class="h-[88rpx] flex items-center gap-[18rpx] border border-[#D9D9D9] rounded-[10rpx] border-solid px-[20rpx]">
<input
v-model.trim="form.phone"
class="min-w-0 flex-1 select-text border-none bg-transparent p-0 text-[30rpx] text-[#1F2329] outline-none"
type="number"
:maxlength="11"
inputmode="numeric"
placeholder="请填写教师手机号"
confirm-type="done"
placeholder-style="color:#A6AFBD;font-size:30rpx;text-align:left;"
@input="handlePhoneInput"
>
</view>
</label>
<button
form-type="submit"
class="mx-auto mt-[60rpx] h-[88rpx] w-[480rpx] rounded-[12rpx] border-none bg-[#1580FF] text-[32rpx] text-[#fff] font-500 active:bg-[#1580FF] disabled:bg-[#A9D0FF]"
:disabled="!canSubmit || loading"
:loading="loading"
>
{{ loading ? '生成中...' : '生成' }}
</button>
<view
v-if="errorMessage"
class="mb-0 mt-[24rpx] text-center text-[24rpx] text-[#EB5241] leading-[1.4]"
>
{{ errorMessage }}
</view>
</form>
<view class="mt-[50rpx] px-[30rpx] text-[26rpx] text-[#666] leading-[36rpx]">
填写教师信息后系统将生成专属直播二维码教师可将二维码分享给学生或家长学生扫码进入直播间后系统会自动统计进入人数
</view>
</view>
</template>
<script lang="ts" setup>
import type { Promoter } from '@/service/invite'
import { createPromoter } from '@/service'
import { useInviteStore } from '@/store/invite'
definePage({
style: {
navigationBarTitleText: '教师直播码',
},
})
const inviteStore = useInviteStore()
const loading = ref(false)
const errorMessage = ref('')
const promoter = ref<Promoter | null>(null)
const form = reactive({
name: '',
phone: '',
referralCode: '',
})
onLoad((options) => {
console.log('canshu', options)
const referralCode = options?.referralCode || options?.code
if (referralCode) {
form.referralCode = decodeURIComponent(referralCode)
}
})
onShareAppMessage(() => {
return {
title: '六纬中考通',
path: '/pages-sub/invite/login',
imageUrl: 'https://lw-zk.oss-cn-hangzhou.aliyuncs.com/liebian/zhuanshumafenxiangtu.png',
}
})
onShareTimeline(() => {
return {
title: '六纬中考通',
}
})
const canSubmit = computed(() => {
return form.name.trim().length > 0 && /^1[3-9]\d{9}$/.test(form.phone)
})
function handlePhoneInput(event: { detail?: { value?: string } }) {
form.phone = (event.detail?.value || '').replace(/\D/g, '').slice(0, 11)
}
async function handleSubmit() {
if (loading.value) {
return
}
if (!form.name.trim()) {
uni.showToast({ title: '请填写教师姓名', icon: 'none' })
return
}
if (!/^1[3-9]\d{9}$/.test(form.phone)) {
uni.showToast({ title: '请填写正确手机号', icon: 'none' })
return
}
loading.value = true
errorMessage.value = ''
try {
const result = await createPromoter({
name: form.name.trim(),
phone: form.phone,
referralCode: form.referralCode || undefined,
})
promoter.value = result
inviteStore.setPromoter(result)
uni.showToast({ title: '生成成功', icon: 'success' })
uni.navigateTo({ url: '/pages-sub/invite/qrcode' })
}
catch (error) {
const message = error instanceof Error ? error.message : '提交失败,请稍后重试'
errorMessage.value = message
uni.showToast({ title: message, icon: 'none' })
}
finally {
loading.value = false
}
}
</script>
<style lang="scss" scoped>
/* uni-app 中 button 默认带边框,使用原子化 border-none 已覆盖;如有需要可在此处补充覆盖样式 */
button::after {
border: none;
}
</style>

View File

@ -0,0 +1,247 @@
<template>
<view class="min-h-screen flex flex-col items-center bg-[#F6F8FB]">
<canvas
id="poster"
canvas-id="poster"
class="block"
:style="{ width: `${canvasWidth}px`, height: `${canvasHeight}px` }"
/>
<view v-if="errorMessage" class="mt-[40rpx] px-[40rpx] text-center text-[26rpx] text-[#EB5241] leading-[1.5]">
{{ errorMessage }}
</view>
<view class="grid grid-cols-2 mb-[30rpx] mt-auto w-full gap-[20rpx] bg-[#fff] pt-[12rpx] pb-safe">
<button
class="ml-[30rpx] mr-0 h-[88rpx] rounded-[12rpx] border-none bg-[#F5F5F5] text-[32rpx] text-[#333]"
@click="handleRegenerate"
>
重新生成
</button>
<button
class="ml-0 mr-[30rpx] h-[88rpx] rounded-[12rpx] border-none bg-[#1580FF] text-[32rpx] text-[#fff]"
@click="handleSave"
>
保存到手机
</button>
</view>
</view>
</template>
<script lang="ts" setup>
import UQRCode from 'uqrcodejs'
definePage({
style: {
navigationBarTitleText: '海报测试',
},
})
const POSTER_IMAGE_URL = 'https://lw-zk.oss-cn-hangzhou.aliyuncs.com/liebian/erweima.png'
const POSTER_DESIGN_HEIGHT_RPX = 1171
// 稿 rpx
const QR_OFFSET_LEFT_RPX = 40
const QR_OFFSET_TOP_RPX = 991
const QR_WIDTH_RPX = 140
const QR_HEIGHT_RPX = 140
const QR_CONTENT = '你好'
const { windowWidth } = uni.getSystemInfoSync()
const canvasWidth = ref(windowWidth)
const canvasHeight = ref(uni.upx2px(POSTER_DESIGN_HEIGHT_RPX))
const errorMessage = ref('')
const saving = ref(false)
const posterReady = ref(false)
onReady(() => {
renderPoster()
})
async function renderPoster() {
errorMessage.value = ''
posterReady.value = false
try {
await nextTick()
const ctx = uni.createCanvasContext('poster')
// 1.
const localPath = await getLocalImagePath(POSTER_IMAGE_URL)
ctx.drawImage(localPath, 0, 0, canvasWidth.value, canvasHeight.value)
// 2.
drawQrCode(ctx, QR_CONTENT, {
x: uni.upx2px(QR_OFFSET_LEFT_RPX),
y: uni.upx2px(QR_OFFSET_TOP_RPX),
width: uni.upx2px(QR_WIDTH_RPX),
height: uni.upx2px(QR_HEIGHT_RPX),
})
// 3.
await new Promise<void>(resolve => ctx.draw(false, () => resolve()))
posterReady.value = true
}
catch (error) {
const message = error instanceof Error ? error.message : '海报绘制失败'
errorMessage.value = message
console.error('[poster]', error)
}
}
function handleRegenerate() {
uni.showLoading({ title: '重新生成中...', mask: true })
renderPoster().finally(() => uni.hideLoading())
}
async function handleSave() {
if (saving.value) {
return
}
saving.value = true
try {
const tempFilePath = await canvasToTempFile('poster')
// #ifdef H5
downloadFileH5(tempFilePath, 'poster.png')
uni.showToast({ title: '已下载到本地', icon: 'success' })
// #endif
// #ifndef H5
await ensureAlbumAuth()
await saveToAlbum(tempFilePath)
uni.showToast({ title: '已保存到相册', icon: 'success' })
// #endif
}
catch (error) {
const message = error instanceof Error ? error.message : '保存失败,请稍后重试'
uni.showToast({ title: message, icon: 'none' })
console.error('[save]', error)
}
finally {
saving.value = false
}
}
interface QrRect {
x: number
y: number
width: number
height: number
}
function drawQrCode(ctx: UniApp.CanvasContext, data: string, rect: QrRect) {
const qr = new UQRCode()
qr.data = data
qr.size = Math.max(rect.width, rect.height) // rect
qr.margin = 0
qr.make()
const moduleCount = qr.moduleCount
const cellW = rect.width / moduleCount
const cellH = rect.height / moduleCount
//
ctx.setFillStyle('#FFFFFF')
ctx.fillRect(rect.x, rect.y, rect.width, rect.height)
// 0.5px
ctx.setFillStyle('#000000')
for (let row = 0; row < moduleCount; row++) {
for (let col = 0; col < moduleCount; col++) {
if (qr.modules[row][col]?.isBlack) {
ctx.fillRect(
rect.x + col * cellW,
rect.y + row * cellH,
cellW + 0.5,
cellH + 0.5,
)
}
}
}
}
function getLocalImagePath(src: string) {
return new Promise<string>((resolve, reject) => {
uni.getImageInfo({
src,
success: res => resolve(res.path),
fail: err => reject(new Error(err?.errMsg || '海报背景图加载失败')),
})
})
}
function canvasToTempFile(canvasId: string) {
return new Promise<string>((resolve, reject) => {
uni.canvasToTempFilePath({
canvasId,
fileType: 'png',
quality: 1,
success: res => resolve(res.tempFilePath),
fail: err => reject(new Error(err?.errMsg || '生成图片失败')),
})
})
}
function saveToAlbum(filePath: string) {
return new Promise<void>((resolve, reject) => {
uni.saveImageToPhotosAlbum({
filePath,
success: () => resolve(),
fail: err => reject(new Error(err?.errMsg || '保存到相册失败')),
})
})
}
//
function ensureAlbumAuth() {
return new Promise<void>((resolve, reject) => {
uni.getSetting({
success: (res) => {
const authorized = res.authSetting['scope.writePhotosAlbum']
if (authorized || authorized === undefined) {
// saveImageToPhotosAlbum
resolve()
return
}
uni.showModal({
title: '提示',
content: '需要您授权保存到相册',
success: (modalRes) => {
if (!modalRes.confirm) {
reject(new Error('已取消保存'))
return
}
uni.openSetting({
success: (settingRes) => {
if (settingRes.authSetting['scope.writePhotosAlbum']) {
resolve()
}
else {
reject(new Error('未授权保存到相册'))
}
},
fail: () => reject(new Error('授权失败')),
})
},
})
},
fail: () => resolve(), //
})
})
}
// #ifdef H5
function downloadFileH5(url: string, filename: string) {
const link = document.createElement('a')
link.href = url
link.download = filename
link.style.display = 'none'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
// #endif
</script>
<style lang="scss" scoped>
//
</style>

View File

@ -6,3 +6,4 @@ export * from './listAll';
export * from './info';
export * from "./requestApi"
export * from './invite'

83
src/service/invite.ts Normal file
View File

@ -0,0 +1,83 @@
export interface CreatePromoterParams {
name: string
phone: string
referralCode?: string
}
export interface Promoter {
_id?: string
id: string
name: string
phone: string
referralCode: string
promotionCount: number
promotionUrl: string
createdAt: string
updatedAt: string
}
type InviteErrorData = {
message?: string
msg?: string
error?: string
}
const INVITE_API_URL = 'https://liveroom.ycymedu.com/api/h5/promoters'
const INVITE_API_KEY = '28fd3c9a8739424ff5f38'
export const createPromoter = (params: CreatePromoterParams) => {
return new Promise<Promoter>((resolve, reject) => {
uni.request({
url: INVITE_API_URL,
method: 'POST',
data: params,
header: {
'Content-Type': 'application/json',
'x-api-key': INVITE_API_KEY,
},
success: (res) => {
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve(res.data as Promoter)
return
}
reject(new Error(getInviteErrorMessage(res.data)))
},
fail: () => {
reject(new Error('提交失败,请稍后重试'))
},
})
})
}
const getInviteErrorMessage = (data: unknown) => {
const errorData = data as InviteErrorData | undefined
return errorData?.message || errorData?.msg || errorData?.error || '提交失败,请稍后重试'
}
const REDIRECT_API_URL = 'https://liveroom.ycymedu.com/api/h5/promotion-visits'
export const trackPromoterRedirect = (referralCode: string) => {
return new Promise<unknown>((resolve, reject) => {
uni.request({
url: REDIRECT_API_URL,
method: 'POST',
data: { from: referralCode },
header: {
'Content-Type': 'application/json',
'x-api-key': INVITE_API_KEY,
},
success: (res) => {
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve(res.data)
return
}
reject(new Error(getInviteErrorMessage(res.data)))
},
fail: () => {
reject(new Error('请求失败,请稍后重试'))
},
})
})
}

View File

@ -16,6 +16,7 @@ setActivePinia(store)
export default store
// 模块统一导出
export * from './invite'
export * from './token'
export * from './user'
export * from "./wishlist"
export * from './wishlist'

30
src/store/invite.ts Normal file
View File

@ -0,0 +1,30 @@
import type { Promoter } from '@/service/invite'
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
export const useInviteStore = defineStore(
'invite',
() => {
const promoter = ref<Promoter | null>(null)
const currentPromoter = computed(() => promoter.value)
const setPromoter = (val: Promoter) => {
promoter.value = val
}
const clearPromoter = () => {
promoter.value = null
uni.removeStorageSync('invite')
}
return {
promoter,
currentPromoter,
setPromoter,
clearPromoter,
}
},
{
persist: true,
},
)