添加微信分享功能
This commit is contained in:
@@ -9,7 +9,7 @@ export default defineAppConfig({
|
||||
}
|
||||
},
|
||||
seo: {
|
||||
siteName: 'Nuxt Docs Template'
|
||||
siteName: 'Estel Docs'
|
||||
},
|
||||
header: {
|
||||
title: 'Estel Docs',
|
||||
|
@@ -6,6 +6,15 @@ const { footer } = useAppConfig()
|
||||
<UFooter>
|
||||
<template #left>
|
||||
{{ footer.credits }}
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
to="https://beian.miit.gov.cn/"
|
||||
target="_blank"
|
||||
>
|
||||
陕ICP备2021012926号-3
|
||||
</UButton>
|
||||
</template>
|
||||
|
||||
<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)
|
||||
})
|
||||
|
||||
// ===== 微信分享(测试按钮用)=====
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -147,7 +178,7 @@ const links = computed(() => {
|
||||
编辑页面
|
||||
</UButton>
|
||||
or
|
||||
<UButton
|
||||
<!-- <UButton
|
||||
variant="link"
|
||||
color="neutral"
|
||||
:to="`${appConfig.github.url}/issues/new/choose`"
|
||||
@@ -156,10 +187,29 @@ const links = computed(() => {
|
||||
:ui="{ leadingIcon: 'size-4' }"
|
||||
>
|
||||
提交问题
|
||||
</UButton> -->
|
||||
|
||||
<UButton
|
||||
variant="link"
|
||||
color="neutral"
|
||||
icon="lucide-share-2"
|
||||
:ui="{ leadingIcon: 'size-4' }"
|
||||
@click="handleShareClick()"
|
||||
>
|
||||
微信分享
|
||||
</UButton>
|
||||
</div>
|
||||
</USeparator>
|
||||
<UContentSurround :surround="surround" />
|
||||
|
||||
<!-- 激活后挂载分享组件(无可视内容) -->
|
||||
<SharedWxShare
|
||||
v-if="wxShareActive"
|
||||
:url="shareLink"
|
||||
:title="shareTitle"
|
||||
:desc="shareDesc"
|
||||
:img-url="shareImg"
|
||||
/>
|
||||
</UPageBody>
|
||||
|
||||
<template
|
||||
|
@@ -83,7 +83,7 @@ export default defineNuxtConfig({
|
||||
}
|
||||
},
|
||||
llms: {
|
||||
domain: 'https://docs.jiwei.xin',
|
||||
domain: 'https://lijue.me',
|
||||
title: 'Estel Docs',
|
||||
description: 'Estel Docs 文档系统',
|
||||
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