添加微信分享功能
This commit is contained in:
@@ -9,7 +9,7 @@ export default defineAppConfig({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
seo: {
|
seo: {
|
||||||
siteName: 'Nuxt Docs Template'
|
siteName: 'Estel Docs'
|
||||||
},
|
},
|
||||||
header: {
|
header: {
|
||||||
title: 'Estel Docs',
|
title: 'Estel Docs',
|
||||||
|
@@ -6,6 +6,15 @@ const { footer } = useAppConfig()
|
|||||||
<UFooter>
|
<UFooter>
|
||||||
<template #left>
|
<template #left>
|
||||||
{{ footer.credits }}
|
{{ footer.credits }}
|
||||||
|
<UButton
|
||||||
|
color="neutral"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
to="https://beian.miit.gov.cn/"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
陕ICP备2021012926号-3
|
||||||
|
</UButton>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #right>
|
<template #right>
|
||||||
|
96
app/components/shared/wxShare.vue
Normal file
96
app/components/shared/wxShare.vue
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
url: string
|
||||||
|
title?: string
|
||||||
|
desc?: string
|
||||||
|
imgUrl?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// 仅在客户端挂载后执行,避免 SSR 阶段访问 window/location
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
console.log('[WxShare] mounted with props:', { ...props })
|
||||||
|
await loadWxSdk()
|
||||||
|
console.log('[WxShare] wx sdk loaded:', !!(window as any).wx)
|
||||||
|
const { appId, timestamp, nonceStr, signature } = await getWxConfig()
|
||||||
|
console.log('[WxShare] got config:', { appId, timestamp, nonceStr, signature: signature.slice(0, 8) + '...' })
|
||||||
|
setupShare(appId, timestamp, nonceStr, signature)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[WxShare] init error:', err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function loadWxSdk(): Promise<void> {
|
||||||
|
console.log('loadWxSdk')
|
||||||
|
if (typeof window === 'undefined') return Promise.resolve()
|
||||||
|
if ((window as any).wx) return Promise.resolve()
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const existing = document.getElementById('wx-jssdk') as HTMLScriptElement | null
|
||||||
|
if (existing && (window as any).wx) return resolve()
|
||||||
|
const script = existing ?? document.createElement('script')
|
||||||
|
if (!existing) {
|
||||||
|
script.id = 'wx-jssdk'
|
||||||
|
script.src = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js'
|
||||||
|
script.async = true
|
||||||
|
script.onload = () => resolve()
|
||||||
|
script.onerror = () => reject(new Error('Failed to load jweixin.js'))
|
||||||
|
document.head.appendChild(script)
|
||||||
|
} else {
|
||||||
|
script.onload = () => resolve()
|
||||||
|
script.onerror = () => reject(new Error('Failed to load jweixin.js'))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getWxConfig(): Promise<{ appId: string, timestamp: number, nonceStr: string, signature: string }> {
|
||||||
|
const currentUrl = encodeURIComponent((location.href.split('#')[0]) || location.href)
|
||||||
|
return await $fetch('/api/wechat/wx-config', { params: { url: currentUrl } })
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupShare(appId: string, timestamp: number, nonceStr: string, signature: string) {
|
||||||
|
const wx = (window as any).wx
|
||||||
|
const shareTitle = props.title || document.title || ''
|
||||||
|
const shareDesc = props.desc || document.title || ''
|
||||||
|
const shareLink = props.url
|
||||||
|
const shareImg = props.imgUrl || '/images/default-blog.jpg'
|
||||||
|
|
||||||
|
wx.config({
|
||||||
|
debug: false,
|
||||||
|
appId,
|
||||||
|
timestamp,
|
||||||
|
nonceStr,
|
||||||
|
signature,
|
||||||
|
jsApiList: ['updateTimelineShareData', 'updateAppMessageShareData']
|
||||||
|
})
|
||||||
|
|
||||||
|
wx.ready(() => {
|
||||||
|
console.log('[WxShare] wx.ready')
|
||||||
|
wx.updateTimelineShareData({
|
||||||
|
title: shareTitle,
|
||||||
|
link: shareLink,
|
||||||
|
imgUrl: shareImg,
|
||||||
|
success: () => {}
|
||||||
|
})
|
||||||
|
wx.updateAppMessageShareData({
|
||||||
|
title: shareTitle,
|
||||||
|
desc: shareDesc,
|
||||||
|
link: shareLink,
|
||||||
|
imgUrl: shareImg,
|
||||||
|
success: () => {}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
wx.error((e: unknown) => {
|
||||||
|
console.error('[WxShare] wx.error:', e)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div style="display: none" />
|
||||||
|
<!-- 该组件无可视内容,仅用于初始化微信分享 -->
|
||||||
|
</template>
|
@@ -98,6 +98,37 @@ const links = computed(() => {
|
|||||||
|
|
||||||
return [...links, ...(appConfig.toc?.bottom?.links || [])].filter(Boolean)
|
return [...links, ...(appConfig.toc?.bottom?.links || [])].filter(Boolean)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ===== 微信分享(测试按钮用)=====
|
||||||
|
const wxShareActive = ref(false)
|
||||||
|
// const contentRoot = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
const shareLink = 'https://lijue.me' + decodeURIComponent(path.value)
|
||||||
|
const shareTitle = computed(() => title)
|
||||||
|
const shareDesc = computed(() => description || title)
|
||||||
|
const shareImg = page?.value?.img
|
||||||
|
// const shareImg = ref<string>('/images/default-blog.jpg')
|
||||||
|
|
||||||
|
// onMounted(() => {
|
||||||
|
// // 从正文中抓取第一张图片作为分享图
|
||||||
|
// const el = contentRoot.value
|
||||||
|
// const firstImg = el?.querySelector('img') as HTMLImageElement | null
|
||||||
|
// if (firstImg?.src) {
|
||||||
|
// shareImg.value = firstImg.src
|
||||||
|
// }
|
||||||
|
// })
|
||||||
|
|
||||||
|
// Toast:点击测试分享后给出指引
|
||||||
|
const toast = useToast()
|
||||||
|
function handleShareClick() {
|
||||||
|
wxShareActive.value = true
|
||||||
|
toast.add({
|
||||||
|
title: '已获取分享接口',
|
||||||
|
description: '点击右上角分享吧',
|
||||||
|
duration: 3000,
|
||||||
|
icon: 'i-lucide-share-2'
|
||||||
|
})
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -147,7 +178,7 @@ const links = computed(() => {
|
|||||||
编辑页面
|
编辑页面
|
||||||
</UButton>
|
</UButton>
|
||||||
or
|
or
|
||||||
<UButton
|
<!-- <UButton
|
||||||
variant="link"
|
variant="link"
|
||||||
color="neutral"
|
color="neutral"
|
||||||
:to="`${appConfig.github.url}/issues/new/choose`"
|
:to="`${appConfig.github.url}/issues/new/choose`"
|
||||||
@@ -156,10 +187,29 @@ const links = computed(() => {
|
|||||||
:ui="{ leadingIcon: 'size-4' }"
|
:ui="{ leadingIcon: 'size-4' }"
|
||||||
>
|
>
|
||||||
提交问题
|
提交问题
|
||||||
|
</UButton> -->
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
variant="link"
|
||||||
|
color="neutral"
|
||||||
|
icon="lucide-share-2"
|
||||||
|
:ui="{ leadingIcon: 'size-4' }"
|
||||||
|
@click="handleShareClick()"
|
||||||
|
>
|
||||||
|
微信分享
|
||||||
</UButton>
|
</UButton>
|
||||||
</div>
|
</div>
|
||||||
</USeparator>
|
</USeparator>
|
||||||
<UContentSurround :surround="surround" />
|
<UContentSurround :surround="surround" />
|
||||||
|
|
||||||
|
<!-- 激活后挂载分享组件(无可视内容) -->
|
||||||
|
<SharedWxShare
|
||||||
|
v-if="wxShareActive"
|
||||||
|
:url="shareLink"
|
||||||
|
:title="shareTitle"
|
||||||
|
:desc="shareDesc"
|
||||||
|
:img-url="shareImg"
|
||||||
|
/>
|
||||||
</UPageBody>
|
</UPageBody>
|
||||||
|
|
||||||
<template
|
<template
|
||||||
|
@@ -83,7 +83,7 @@ export default defineNuxtConfig({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
llms: {
|
llms: {
|
||||||
domain: 'https://docs.jiwei.xin',
|
domain: 'https://lijue.me',
|
||||||
title: 'Estel Docs',
|
title: 'Estel Docs',
|
||||||
description: 'Estel Docs 文档系统',
|
description: 'Estel Docs 文档系统',
|
||||||
sections: [
|
sections: [
|
||||||
|
1
public/MP_verify_XJytJeqSNv67T3iY.txt
Normal file
1
public/MP_verify_XJytJeqSNv67T3iY.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
XJytJeqSNv67T3iY
|
156
server/api/wechat/wx-config.get.ts
Normal file
156
server/api/wechat/wx-config.get.ts
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import { defineEventHandler, getQuery, setResponseHeader, createError } from 'h3'
|
||||||
|
import { createHash, randomBytes } from 'node:crypto'
|
||||||
|
|
||||||
|
type AccessTokenResponse = {
|
||||||
|
access_token: string
|
||||||
|
expires_in: number
|
||||||
|
errcode?: number
|
||||||
|
errmsg?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type JsapiTicketResponse = {
|
||||||
|
errcode: number
|
||||||
|
errmsg: string
|
||||||
|
ticket: string
|
||||||
|
expires_in: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type WxConfigPayload = {
|
||||||
|
appId: string
|
||||||
|
timestamp: number
|
||||||
|
nonceStr: string
|
||||||
|
signature: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/wechat/jssdk-config?url=<encoded_url>
|
||||||
|
*
|
||||||
|
* 仅新增本文件,不修改其他任何代码。
|
||||||
|
* 从环境变量读取:
|
||||||
|
* - WECHAT_APP_ID
|
||||||
|
* - WECHAT_APP_SECRET
|
||||||
|
*
|
||||||
|
* 按微信官方文档生成 JS-SDK 所需签名配置:appId、timestamp、nonceStr、signature。
|
||||||
|
* 会在内存中缓存 access_token 与 jsapi_ticket,缓存有效期比官方返回略短(预留安全缓冲)。
|
||||||
|
*
|
||||||
|
* 参考文档:
|
||||||
|
* - JS-SDK 使用步骤(签名计算与 ticket 缓存):https://developers.weixin.qq.com/doc/service/guide/h5/jssdk.html#JSSDK%E4%BD%BF%E7%94%A8%E6%AD%A5%E9%AA%A4
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TOKEN_SAFETY_BUFFER_SECONDS = 300 // 5 分钟安全缓冲
|
||||||
|
|
||||||
|
let cachedAccessToken: { value: string, expiresAt: number } | null = null
|
||||||
|
let cachedJsapiTicket: { value: string, expiresAt: number } | null = null
|
||||||
|
|
||||||
|
function isExpired(cache: { expiresAt: number } | null): boolean {
|
||||||
|
if (!cache) return true
|
||||||
|
return Date.now() >= cache.expiresAt
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAccessToken(appId: string, appSecret: string): Promise<string> {
|
||||||
|
if (!isExpired(cachedAccessToken)) {
|
||||||
|
return cachedAccessToken!.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${encodeURIComponent(appId)}&secret=${encodeURIComponent(appSecret)}`
|
||||||
|
const response = await $fetch<AccessTokenResponse>(url, { method: 'GET' })
|
||||||
|
|
||||||
|
if (!response || !response.access_token) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 502,
|
||||||
|
statusMessage: `Failed to obtain access_token: ${response?.errcode ?? ''} ${response?.errmsg ?? ''}`.trim()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiresInSeconds = Math.max(0, (response.expires_in ?? 7200) - TOKEN_SAFETY_BUFFER_SECONDS)
|
||||||
|
cachedAccessToken = {
|
||||||
|
value: response.access_token,
|
||||||
|
expiresAt: Date.now() + expiresInSeconds * 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.access_token
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJsapiTicket(accessToken: string): Promise<string> {
|
||||||
|
if (!isExpired(cachedJsapiTicket)) {
|
||||||
|
return cachedJsapiTicket!.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `https://api.weixin.qq.com/cgi-bin/ticket/getticket?access_token=${encodeURIComponent(accessToken)}&type=jsapi`
|
||||||
|
const response = await $fetch<JsapiTicketResponse>(url, { method: 'GET' })
|
||||||
|
|
||||||
|
if (!response || response.errcode !== 0 || !response.ticket) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 502,
|
||||||
|
statusMessage: `Failed to obtain jsapi_ticket: ${response?.errcode ?? ''} ${response?.errmsg ?? ''}`.trim()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiresInSeconds = Math.max(0, (response.expires_in ?? 7200) - TOKEN_SAFETY_BUFFER_SECONDS)
|
||||||
|
cachedJsapiTicket = {
|
||||||
|
value: response.ticket,
|
||||||
|
expiresAt: Date.now() + expiresInSeconds * 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.ticket
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateNonceStr(bytes: number = 16): string {
|
||||||
|
// 生成由 [a-z0-9] 组成的随机字符串
|
||||||
|
return randomBytes(bytes).toString('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateTimestamp(): number {
|
||||||
|
return Math.floor(Date.now() / 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSignature(jsapiTicket: string, nonceStr: string, timestamp: number, url: string): string {
|
||||||
|
// 注意:参数名必须为全小写,且按字典序构造字符串
|
||||||
|
// 官方示例顺序:jsapi_ticket, noncestr, timestamp, url
|
||||||
|
const rawString = `jsapi_ticket=${jsapiTicket}&noncestr=${nonceStr}×tamp=${timestamp}&url=${url}`
|
||||||
|
return createHash('sha1').update(rawString).digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event): Promise<WxConfigPayload> => {
|
||||||
|
// 避免代理缓存
|
||||||
|
setResponseHeader(event, 'Cache-Control', 'no-store')
|
||||||
|
|
||||||
|
const appId = process.env.WECHAT_APP_ID || ''
|
||||||
|
const appSecret = process.env.WECHAT_APP_SECRET || ''
|
||||||
|
|
||||||
|
if (!appId || !appSecret) {
|
||||||
|
throw createError({ statusCode: 500, statusMessage: 'Missing WECHAT_APP_ID or WECHAT_APP_SECRET in environment.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = getQuery(event)
|
||||||
|
let pageUrlRaw = typeof query.url === 'string' ? query.url : ''
|
||||||
|
// 若未传入 url,则使用当前请求完整地址(去除 hash),通常建议前端传入当前页面 URL
|
||||||
|
if (!pageUrlRaw) {
|
||||||
|
const requestUrl = event.node.req.headers['x-forwarded-proto'] && event.node.req.headers['x-forwarded-host']
|
||||||
|
? `${event.node.req.headers['x-forwarded-proto']}://${event.node.req.headers['x-forwarded-host']}${event.node.req.url ?? ''}`
|
||||||
|
: new URL(event.node.req.url ?? '/', `http://${event.node.req.headers.host}`).toString()
|
||||||
|
pageUrlRaw = requestUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageUrlNoHash = pageUrlRaw.includes('#')
|
||||||
|
? pageUrlRaw.slice(0, pageUrlRaw.indexOf('#'))
|
||||||
|
: pageUrlRaw
|
||||||
|
const pageUrl = decodeURIComponent(pageUrlNoHash)
|
||||||
|
if (!/^https?:\/\//i.test(pageUrl)) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: 'Invalid url parameter. Expecting an absolute http/https URL.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessToken = await fetchAccessToken(appId, appSecret)
|
||||||
|
const jsapiTicket = await fetchJsapiTicket(accessToken)
|
||||||
|
|
||||||
|
const timestamp = generateTimestamp()
|
||||||
|
const nonceStr = generateNonceStr(16)
|
||||||
|
const signature = buildSignature(jsapiTicket, nonceStr, timestamp, pageUrl)
|
||||||
|
|
||||||
|
return {
|
||||||
|
appId,
|
||||||
|
timestamp,
|
||||||
|
nonceStr,
|
||||||
|
signature
|
||||||
|
}
|
||||||
|
})
|
Reference in New Issue
Block a user