refactor: 小程序端代码瘦身与功能补齐

- 清理冗余代码与配置:
  - 删除废弃的 mock 适配器、通用 mock 模块与示例API
  - 移除冗余的 ts 类型文件与 patch 目录
  - 简化 tsconfig 组件路径配置
- 新增功能与优化:
  - 新增全局水印组件与水印配置
  - 接入微信小程序登录/手机号登录
  - 新增系统参数配置 store 与国际化支持
  - 新增 markdown 渲染插件与样式配置
  - 优化 pinia 持久化逻辑修复
  - 新增微信小程序 access_token 缓存配置
  - 修复登录趋势数据结构定义
  - 优化全局请求防抖重定向逻辑
- 页面与路由优化:
  - 补全引导页首次启动逻辑
  - 统一页面底部安全区适配
  - 更新页面路由类型声明
- 代码质量优化:
  - 移除无用 console.log 调试代码
  - 修复全局弹窗重复重登录问题
  - 优化 mp-html 组件配置
This commit is contained in:
zhangtao
2026-08-05 23:47:49 +08:00
parent 372b277a9f
commit ffbe2453f2
126 changed files with 5288 additions and 12246 deletions
+116
View File
@@ -0,0 +1,116 @@
import { ref } from 'vue'
import { useUserStore } from '@/store/userStore'
export interface AiChatStreamHandlers {
/** 收到内容分片(追加到当前 AI 消息) */
onChunk?: (text: string) => void
/** 生成结束([DONE] / [STOPPED]) */
onDone?: () => void
/** 连接或生成错误 */
onError?: (message: string) => void
}
/** 构建 AI 对话 WebSocket 地址:优先 VITE_APP_WS_ENDPOINT,否则从 API 域名推导(http→ws) */
function buildChatWsUrl(): string {
const userStore = useUserStore()
const token = userStore.getAccessToken() || ''
let wsBase = import.meta.env.VITE_APP_WS_ENDPOINT || ''
if (!wsBase) {
const apiBase = import.meta.env.VITE_API_BASE_URL || ''
wsBase = apiBase.replace(/^http/, 'ws')
}
const apiPrefix = import.meta.env.VITE_APP_BASE_API || '/api/v1'
// token 走 query(小程序 WebSocket 不支持自定义 subprotocol,后端已兼容 ?token=)
return `${wsBase}${apiPrefix}/ai/chat/ws?token=${encodeURIComponent(token)}`
}
/**
* AI 对话流式输出(WebSocket)
* 协议(对应后端 /ai/chat/ws):
* - 发送:{"message": "...", "session_id": "..."} | {"action": "stop"}
* - 接收:内容分片(纯文本)→ [DONE] 结束 / [STOPPED] 停止确认
*/
export function useAiChat() {
const isStreaming = ref(false)
let socketTask: ReturnType<typeof uni.connectSocket> | null = null
let pendingOpen: Promise<void> | null = null
let resolveOpen: (() => void) | null = null
let closedByUser = false
let handlers: AiChatStreamHandlers = {}
function ensureConnected(): Promise<void> {
if (socketTask)
return Promise.resolve()
closedByUser = false
pendingOpen = new Promise<void>((resolve) => {
resolveOpen = resolve
})
const task = uni.connectSocket({ url: buildChatWsUrl(), complete: () => {} })
socketTask = task
task.onOpen(() => resolveOpen?.())
task.onMessage((res) => {
const text = typeof res.data === 'string' ? res.data : ''
if (text === '[DONE]' || text === '[STOPPED]') {
isStreaming.value = false
handlers.onDone?.()
}
else if (text) {
handlers.onChunk?.(text)
}
})
task.onError((err) => {
resolveOpen?.()
isStreaming.value = false
handlers.onError?.(err?.errMsg || 'WebSocket 连接失败')
})
task.onClose(() => {
socketTask = null
if (!closedByUser) {
isStreaming.value = false
handlers.onError?.('连接已断开,请重试')
}
})
return pendingOpen
}
/** 发送一条流式对话消息(自动建立/复用连接) */
async function sendMessage(
payload: { message: string, session_id?: string | null },
handler: AiChatStreamHandlers,
): Promise<void> {
handlers = handler
isStreaming.value = true
try {
await ensureConnected()
socketTask?.send({
data: JSON.stringify({ message: payload.message, session_id: payload.session_id || undefined }),
})
}
catch {
isStreaming.value = false
handlers.onError?.('连接失败,请稍后重试')
}
}
/** 停止当前生成(后端停止后返回 [STOPPED]) */
function stop() {
if (!isStreaming.value)
return
try {
socketTask?.send({ data: JSON.stringify({ action: 'stop' }) })
}
catch { /* 忽略发送失败 */ }
}
/** 关闭连接(页面卸载时调用) */
function close() {
closedByUser = true
isStreaming.value = false
if (socketTask) {
socketTask.close({ code: 1000, reason: 'page unload' })
socketTask = null
}
}
return { isStreaming, sendMessage, stop, close }
}
@@ -0,0 +1,129 @@
import { TicketAPI } from '@/api/module_system/ticket'
/**
* 缓存的请求结果
*/
interface CachedData<T> {
data: T
timestamp: number
}
/**
* 请求中标记(防重复并发请求)
*/
const pendingRequests = new Map<string, Promise<any>>()
/**
* 本地缓存存储
*/
const cacheStore = new Map<string, CachedData<any>>()
/** 默认缓存有效期(30秒) */
const DEFAULT_TTL = 30_000
/**
* 带缓存的请求 composable
*
* 解决多个页面重复请求同一接口的问题(如 work 和 mine 页面都调用工单统计)。
* 提供本地内存缓存 + 并发去重 + TTL 过期机制。
*
* @example
* ```ts
* const { getCached } = useCachedRequest()
*
* // 多页面共享缓存,30秒内不重复请求
* const stats = await getCached('ticket-stats', () => fetchTicketStats(), 30_000)
* ```
*/
export function useCachedRequest() {
/**
* 获取带缓存的数据
*
* @param key 缓存键
* @param fetcher 数据获取函数
* @param ttl 缓存有效期(毫秒),默认 30 秒
* @returns 数据
*/
async function getCached<T>(key: string, fetcher: () => Promise<T>, ttl = DEFAULT_TTL): Promise<T> {
// 检查缓存是否有效
const cached = cacheStore.get(key)
if (cached && Date.now() - cached.timestamp < ttl) {
return cached.data as T
}
// 检查是否有进行中的相同请求
const pending = pendingRequests.get(key)
if (pending) {
return pending as Promise<T>
}
// 发起新请求
const promise = fetcher()
.then((data) => {
cacheStore.set(key, { data, timestamp: Date.now() })
pendingRequests.delete(key)
return data
})
.catch((error) => {
pendingRequests.delete(key)
throw error
})
pendingRequests.set(key, promise)
return promise
}
/** 强制刷新缓存 */
async function refresh<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
cacheStore.delete(key)
return getCached(key, fetcher, 0)
}
/** 清除指定缓存 */
function invalidate(key: string): void {
cacheStore.delete(key)
}
/** 清除所有缓存 */
function clearAll(): void {
cacheStore.clear()
}
return {
getCached,
refresh,
invalidate,
clearAll,
}
}
/**
* 工单统计缓存(work 和 mine 页面共享)
*/
export interface TicketStats {
pending: number
processing: number
done: number
}
/**
* 获取工单统计(带缓存,多页面共享)
*
* 缓存键 'ticket-stats',TTL 30 秒。
* work 页面和 mine 页面切换时不会重复请求。
*/
export async function getTicketStats(): Promise<TicketStats> {
const { getCached } = useCachedRequest()
return getCached('ticket-stats', async () => {
const [pending, processing, done] = await Promise.allSettled([
TicketAPI.getPage({ page_no: 1, page_size: 1, status: '0' }),
TicketAPI.getPage({ page_no: 1, page_size: 1, status: '1' }),
TicketAPI.getPage({ page_no: 1, page_size: 1, status: '2' }),
])
return {
pending: pending.status === 'fulfilled' ? (pending.value.total || 0) : 0,
processing: processing.status === 'fulfilled' ? (processing.value.total || 0) : 0,
done: done.status === 'fulfilled' ? (done.value.total || 0) : 0,
}
})
}
+59 -20
View File
@@ -1,4 +1,6 @@
import { shallowRef } from 'vue'
import type { Method } from 'alova'
import { usePagination } from 'alova/client'
import { computed, ref } from 'vue'
export interface ListPageParams {
page_no: number
@@ -12,52 +14,89 @@ export interface ListPageOptions<T> {
}
/**
* 通用列表分页逻辑
* 统一管理页码、总数、加载态与翻页动作,配合 PaginationBar 使用
* 通用列表分页逻辑(基于 alova usePagination 封装)
* 底层使用 alova 状态管理,统一暴露 list/total/loading/error 三态与翻页动作
* 说明:fetcher 运行时返回的是 alova Method(http 层 get/post 均为 Method 实例,Method extends Promise),
* 这里仅做类型桥接,请求发送与错误处理完全交给 alova hook 管理
*/
export function useListPage<T>(options: ListPageOptions<T>) {
const { fetcher, pageSize = 10, onError } = options
const list = shallowRef<T[]>([])
const total = ref(0)
const loading = ref(false)
const pageParams = ref<ListPageParams>({ page_no: 1, page_size: pageSize })
const {
data,
total,
loading,
error,
send,
onError: onPageError,
} = usePagination<any, T[], any>(
(pageNo: number, pageSizeNo: number) =>
fetcher({ page_no: pageNo, page_size: pageSizeNo }) as unknown as Method,
{
initialPage: 1,
initialPageSize: pageSize,
// 由页面手动触发(onLoad/搜索/翻页),禁用自动监听与预加载
immediate: false,
watchingStates: [],
preloadNextPage: false,
preloadPreviousPage: false,
// 列表页总是请求最新数据,禁用 alova 响应缓存
force: true,
// 响应已由 http 层 responded 解包为业务结构 { list, total }
data: res => (res as PageResult<T>).list ?? [],
total: res => (res as PageResult<T>).total ?? 0,
},
)
onPageError(({ error: e }) => {
onError?.(e)
})
/** 加载当前页数据 */
async function loadData() {
loading.value = true
try {
const res = await fetcher({ page_no: pageParams.value.page_no, page_size: pageParams.value.page_size })
list.value = res.list ?? []
total.value = res.total ?? 0
await send(pageParams.value.page_no, pageParams.value.page_size)
}
catch (error) {
onError?.(error)
catch {
// 错误已由 onPageError → onError 统一处理,避免未捕获 Promise 告警
}
finally {
loading.value = false
// 收起下拉刷新指示器(onPullDownRefresh → loadData 场景;非刷新场景调用无害)
uni.stopPullDownRefresh()
}
}
/** 上一页 */
function loadPrev() {
async function loadPrev() {
if (pageParams.value.page_no <= 1)
return
pageParams.value.page_no -= 1
loadData()
await loadData()
}
/** 下一页 */
function loadNext() {
async function loadNext() {
pageParams.value.page_no += 1
loadData()
await loadData()
}
/** 跳回第一页(搜索/重置时使用) */
function toFirst() {
async function toFirst() {
pageParams.value.page_no = 1
loadData()
await loadData()
}
return { list, total, loading, pageParams, loadData, loadPrev, loadNext, toFirst }
return {
list: computed<T[]>(() => data.value ?? []),
total: computed<number>(() => total.value ?? 0),
loading,
error,
pageParams,
loadData,
loadPrev,
loadNext,
toFirst,
}
}
+63
View File
@@ -0,0 +1,63 @@
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
/** 分享配置 */
interface ShareConfig {
/** 分享标题 */
title: string
/** 分享路径(带 / 开头,例如 /pages/index/index) */
path?: string
/** 分享图片 URL(可选,不传则使用页面截图) */
imageUrl?: string
}
/** 默认分享配置(全局兜底) */
const DEFAULT_SHARE: ShareConfig = {
title: 'FastapiAdmin - 高效的企业管理移动平台',
path: '/pages/index/index',
}
/**
* 全局分享 composable
*
* 在页面 setup 中调用即可注册 onShareAppMessage + onShareTimeline。
* 微信小程序中,页面必须调用此 composable 才能开启右上角"分享"按钮。
*
* @example
* ```ts
* // 静态分享
* useShare({ title: '工作台', path: '/pages/work/index' })
*
* // 动态分享(标题随数据变化)
* const shareTitle = computed(() => `${userInfo.value?.name || '管理员'} 邀请你使用`)
* useShare(() => ({ title: shareTitle.value, path: '/pages/index/index' }))
* ```
*/
export function useShare(configOrGetter?: ShareConfig | (() => ShareConfig)) {
const getConfig = (): ShareConfig => {
if (typeof configOrGetter === 'function')
return { ...DEFAULT_SHARE, ...configOrGetter() }
if (configOrGetter)
return { ...DEFAULT_SHARE, ...configOrGetter }
return { ...DEFAULT_SHARE }
}
onShareAppMessage((options) => {
const cfg = getConfig()
// 从分享来源判断:如果是按钮分享,可携带额外参数
const fromButton = options?.from === 'button'
return {
title: cfg.title,
path: cfg.path || DEFAULT_SHARE.path,
imageUrl: cfg.imageUrl,
}
})
onShareTimeline(() => {
const cfg = getConfig()
return {
title: cfg.title,
query: '', // 朋友圈分享不支持 path,用 query 传参
imageUrl: cfg.imageUrl,
}
})
}
@@ -0,0 +1,247 @@
/**
* 小程序码与分享海报 composable
*
* 封装小程序码生成、海报绘制、保存到相册等功能。
* 用于"邀请好友"场景,生成带参小程序码供扫码进入。
*
* 使用前提:
* 1. 后端实现 /system/auth/wx-qrcode/generate 接口(调用微信 getwxacodeunlimit API)
* 2. 小程序已上线或处于体验版(开发版无法生成小程序码)
*
* @example
* ```ts
* const { generateQrCode, saveImageToAlbum } = useSharePoster()
*
* // 生成带参小程序码
* const qrUrl = await generateQrCode({ scene: 'invite_123', page: 'pages/index/index' })
*
* // 保存到相册
* await saveImageToAlbum(tempFilePath)
* ```
*/
import AuthAPI from '@/api/module_system/auth'
/** 小程序码生成参数 */
interface QrCodeParams {
/** 场景值(最大32字符,如 invite_123) */
scene: string
/** 目标页面路径(不带 /,如 pages/index/index) */
page?: string
/** 宽度(px),默认 430 */
width?: number
/** 是否有 hyaline 透明底 */
isHyaline?: boolean
}
/**
* 分享海报 composable
*/
export function useSharePoster() {
/**
* 生成小程序码
* 调用后端接口,后端通过微信 API getwxacodeunlimit 生成
* 返回 base64 data URI,可直接用于 <image> 或 Canvas 绘制
*
* @param params 生成参数
* @returns 图片 URL(data:image/png;base64,...)
*/
async function generateQrCode(params: QrCodeParams): Promise<string> {
// #ifdef MP-WEIXIN
try {
const result = await AuthAPI.generateWxQrCode({
scene: params.scene,
page: params.page || 'pages/index/index',
width: params.width || 430,
})
return result.url
}
catch (error) {
console.error('[useSharePoster] 生成小程序码失败', error)
uni.showToast({ title: '生成小程序码失败', icon: 'none' })
return ''
}
// #endif
// #ifndef MP-WEIXIN
uni.showToast({ title: '请在微信小程序中使用', icon: 'none' })
return ''
// #endif
}
/**
* 下载网络图片到本地临时路径
*
* @param url 网络图片 URL
* @returns 本地临时文件路径
*/
async function downloadImage(url: string): Promise<string> {
try {
const res = await uni.downloadFile({ url })
if (res.statusCode === 200)
return res.tempFilePath
throw new Error('下载图片失败')
}
catch (error) {
console.error('[useSharePoster] 下载图片失败', error)
return ''
}
}
/**
* 保存图片到相册
*
* @param tempFilePath 本地临时文件路径
*/
async function saveImageToAlbum(tempFilePath: string): Promise<boolean> {
// #ifdef MP-WEIXIN
try {
// 检查相册权限
const setting = await uni.getSetting({})
if (!(setting as any).authSetting?.['scope.writePhotosAlbum']) {
await uni.authorize({ scope: 'scope.writePhotosAlbum' })
}
await uni.saveImageToPhotosAlbum({ filePath: tempFilePath })
uni.showToast({ title: '已保存到相册', icon: 'success' })
return true
}
catch (error) {
console.error('[useSharePoster] 保存到相册失败', error)
// 用户拒绝权限时引导去设置
if ((error as any)?.errMsg?.includes('auth deny')) {
uni.showModal({
title: '需要相册权限',
content: '保存图片需要相册权限,是否前往设置开启?',
confirmText: '去设置',
success: (res) => {
if (res.confirm)
uni.openSetting({})
},
})
}
return false
}
// #endif
// #ifndef MP-WEIXIN
uni.showToast({ title: '请在微信小程序中使用', icon: 'none' })
return false
// #endif
}
/**
* 生成分享海报(Canvas 绘制)
* 将品牌信息 + 用户信息 + 小程序码绘制到一张图片上
*
* @param options 海报配置
* @param options.qrCodeUrl 小程序码图片 URL
* @param options.userName 用户名
* @param options.brandTitle 品牌标题
* @param options.brandSubtitle 品牌副标题
* @returns 本地临时文件路径
*/
async function generatePoster(options: {
qrCodeUrl: string
userName: string
brandTitle?: string
brandSubtitle?: string
}): Promise<string> {
const { qrCodeUrl, userName, brandTitle = 'FastapiAdmin', brandSubtitle = '高效的企业管理移动平台' } = options
// #ifdef MP-WEIXIN
try {
// 下载小程序码到本地
const localQrPath = await downloadImage(qrCodeUrl)
if (!localQrPath)
return ''
// 使用 Canvas 绘制海报
const canvasId = 'share-poster-canvas'
const ctx = uni.createCanvasContext(canvasId)
// 海报尺寸 750x1200
const W = 750
const H = 1200
// 背景:白色
ctx.setFillStyle('#FFFFFF')
ctx.fillRect(0, 0, W, H)
// 顶部渐变背景
const gradient = ctx.createLinearGradient(0, 0, 0, 400)
gradient.addColorStop(0, '#4F8CFF')
gradient.addColorStop(1, '#2563EB')
ctx.setFillStyle(gradient)
ctx.fillRect(0, 0, W, 400)
// 品牌标题
ctx.setFillStyle('#FFFFFF')
ctx.setFontSize(36)
ctx.setTextAlign('center')
ctx.fillText(brandTitle, W / 2, 120)
// 品牌副标题
ctx.setFontSize(24)
ctx.setFillStyle('rgba(255, 255, 255, 0.85)')
ctx.fillText(brandSubtitle, W / 2, 160)
// 用户名
ctx.setFontSize(28)
ctx.setFillStyle('#FFFFFF')
ctx.fillText(`${userName} 邀请你加入`, W / 2, 220)
// 中间区域:功能介绍
ctx.setFillStyle('#1A1628')
ctx.setFontSize(32)
ctx.fillText('工单管理 · AI助手 · 数据监控', W / 2, 520)
ctx.setFontSize(24)
ctx.setFillStyle('#6B7280')
ctx.fillText('移动办公更高效,随时随地理业务', W / 2, 560)
// 小程序码
const qrSize = 280
const qrX = (W - qrSize) / 2
const qrY = 640
ctx.drawImage(localQrPath, qrX, qrY, qrSize, qrSize)
// 底部提示
ctx.setFontSize(22)
ctx.setFillStyle('#9CA3AF')
ctx.fillText('长按识别小程序码,立即体验', W / 2, 980)
// 绘制并导出
return new Promise((resolve) => {
ctx.draw(false, () => {
setTimeout(() => {
uni.canvasToTempFilePath({
canvasId,
width: W,
height: H,
destWidth: W * 2,
destHeight: H * 2,
success: (res) => resolve(res.tempFilePath),
fail: () => resolve(''),
})
}, 200)
})
})
}
catch (error) {
console.error('[useSharePoster] 生成海报失败', error)
return ''
}
// #endif
// #ifndef MP-WEIXIN
return ''
// #endif
}
return {
generateQrCode,
downloadImage,
saveImageToAlbum,
generatePoster,
}
}
@@ -0,0 +1,141 @@
/**
* 微信订阅消息 composable
*
* 封装 wx.requestSubscribeMessage,在用户操作时请求订阅消息授权。
* 常见场景:工单状态变更、通知公告发布、系统到期提醒等。
*
* 使用前提:
* 1. 在微信公众平台配置订阅消息模板
* 2. 将模板 ID 填入此处常量
* 3. 仅微信小程序端有效
*
* @example
* ```ts
* const { subscribe } = useSubscribeMessage()
*
* // 提交工单时订阅"工单状态变更"通知
* async function submitTicket() {
* await subscribe([TEMPLATE_IDS.TICKET_STATUS])
* // ...提交工单
* }
* ```
*/
/** 订阅消息模板 ID(在微信公众平台 → 订阅消息中配置) */
export const TEMPLATE_IDS = {
/** 工单状态变更通知 */
TICKET_STATUS: '',
/** 通知公告发布提醒 */
NOTICE_PUBLISH: '',
/** 系统到期提醒 */
SYSTEM_EXPIRY: '',
/** 工单回复通知 */
TICKET_REPLY: '',
} as const
/** 订阅消息模板 ID 类型 */
export type TemplateId = keyof typeof TEMPLATE_IDS
/** 订阅结果 */
interface SubscribeResult {
/** 模板 ID */
templateId: string
/** 订阅状态:accept=同意, reject=拒绝, ban=被禁止 */
status: 'accept' | 'reject' | 'ban' | 'filter'
}
/**
* 订阅消息 composable
*/
export function useSubscribeMessage() {
/**
* 请求订阅消息授权
*
* @param templateIds 模板 ID 列表(支持传 key 名称或完整 ID)
* @returns 订阅结果数组,每项包含模板 ID 和用户选择状态
*/
async function subscribe(templateIds: Array<TemplateId | string>): Promise<SubscribeResult[]> {
// #ifdef MP-WEIXIN
// 将 key 名称解析为实际模板 ID
const ids = templateIds.map((id) => {
if (id in TEMPLATE_IDS)
return TEMPLATE_IDS[id as TemplateId]
return id
}).filter(Boolean)
if (ids.length === 0) {
console.warn('[useSubscribeMessage] 未配置有效的模板 ID')
return []
}
try {
const res = await uni.requestSubscribeMessage({
tmplIds: ids,
}) as unknown as Record<string, string>
// 微信返回格式: { [templateId]: 'accept' | 'reject' | 'ban' | 'filter' }
const results: SubscribeResult[] = ids.map(templateId => ({
templateId,
status: res[templateId] as SubscribeResult['status'],
}))
// 记录用户选择,可用于后续引导
const accepted = results.filter(r => r.status === 'accept')
if (accepted.length > 0)
console.log(`[useSubscribeMessage] 用户已订阅 ${accepted.length} 个模板`)
return results
}
catch (error) {
console.error('[useSubscribeMessage] 订阅失败', error)
return []
}
// #endif
// #ifndef MP-WEIXIN
return []
// #endif
}
/**
* 检查订阅消息设置状态
* 用户可能在设置中关闭了订阅消息,此方法检测并引导用户开启
*
* @returns 是否可以发送订阅消息
*/
async function checkSubscriptionStatus(): Promise<boolean> {
// #ifdef MP-WEIXIN
try {
const settings = await uni.getSetting({})
const subscription = (settings as any).authSetting?.['subscribeMessage']
// 如果用户曾经拒绝过,引导去设置页开启
if (subscription === false) {
uni.showModal({
title: '开启消息通知',
content: '您已关闭消息通知,是否前往设置开启?',
confirmText: '去设置',
success: (res) => {
if (res.confirm)
uni.openSetting({})
},
})
return false
}
return true
}
catch {
return true
}
// #endif
// #ifndef MP-WEIXIN
return false
// #endif
}
return {
subscribe,
checkSubscriptionStatus,
TEMPLATE_IDS,
}
}
-91
View File
@@ -1,91 +0,0 @@
import { onMounted, ref } from 'vue'
export interface TeamMember {
avatar: string
name: string
title: string
tags?: string[]
desc?: string
github?: string
twitter?: string
}
type TeamMemberPayload = TeamMember & {
status?: string
}
const urls = [
'https://sponsor.wot-ui.cn',
'https://wot-sponsors.pages.dev',
]
const mockMembers: TeamMember[] = [
]
const data = ref<TeamMember[]>([])
function normalizeMembers(members: TeamMemberPayload[]): TeamMember[] {
return members
.filter(member => member?.avatar && member?.name && member?.title)
.map((member) => {
const tags = Array.isArray(member.tags)
? member.tags
: (member.status ? [member.status] : [])
return {
avatar: member.avatar,
name: member.name,
title: member.title,
tags,
desc: member.desc,
github: member.github,
twitter: member.twitter,
}
})
}
function requestMembers(url: string): Promise<TeamMemberPayload[]> {
return new Promise((resolve, reject) => {
uni.request({
url: `${url}/team.json?t=${Date.now()}`,
method: 'GET',
timeout: 5000,
success: (response) => {
const payload = response.data as { members?: TeamMemberPayload[] } | undefined
resolve(Array.isArray(payload?.members) ? payload.members : [])
},
fail: reject,
})
})
}
async function fetchMembers() {
for (const url of urls) {
try {
const members = await requestMembers(url)
const normalizedMembers = normalizeMembers(members)
if (normalizedMembers.length > 0) {
return normalizedMembers
}
}
catch {
console.warn(`Failed to fetch team from ${url}`)
}
}
return normalizeMembers(mockMembers)
}
export function useTeam() {
onMounted(async () => {
if (data.value.length) {
return
}
data.value = await fetchMembers()
})
return { data }
}
-1
View File
@@ -46,7 +46,6 @@ export function useTheme() {
stopThemeChangeListener = subscribeSystemThemeChange(store, (res) => {
// 系统主题变化时自动更新,导航栏颜色由 theme.json 自动处理
store.setTheme(res.theme)
console.log('系统主题已切换至:', res.theme)
})
})
@@ -0,0 +1,23 @@
import { computed, onMounted, ref } from 'vue'
import { WATERMARK_KEY } from '@/constants'
import { useUserStore } from '@/store/userStore'
import { Storage } from '@/utils/storage'
/**
* 小程序水印逻辑(配合 wd-watermark 使用)
* - 显示条件:本地偏好开启(默认开,由设置页"水印"开关控制)&& 已登录
* - 水印内容为当前登录用户名(username 优先,回退 name)
*/
export function useWatermark() {
const userStore = useUserStore()
const enabled = ref(false)
const content = computed(() => userStore.userInfo?.username || userStore.userInfo?.name || '')
onMounted(() => {
const localSwitch = Storage.get<boolean>(WATERMARK_KEY) ?? true
enabled.value = localSwitch && !!content.value
})
return { enabled, content }
}
@@ -0,0 +1,74 @@
import { useUserStore } from '@/store/userStore'
/**
* 微信小程序登录 composable
*
* 封装 uni.login → 后端换 token 的完整流程。
* 仅在微信小程序端生效(#ifdef MP-WEIXIN)。
*
* @example
* ```ts
* const { wxLogin, wxPhoneLogin } = useWxLogin()
*
* // 微信一键登录
* async function handleWxLogin() {
* await wxLogin()
* uni.reLaunch({ url: '/pages/index/index' })
* }
*
* // 手机号登录(配合 <button open-type="getPhoneNumber">)
* async function handlePhoneLogin(e) {
* if (e.detail.errMsg !== 'getPhoneNumber:ok') return
* await wxPhoneLogin(e.detail.code)
* uni.reLaunch({ url: '/pages/index/index' })
* }
* ```
*/
export function useWxLogin() {
const userStore = useUserStore()
/**
* 微信一键登录
* 1. uni.login 获取 code
* 2. 可选获取用户头像昵称(需用户授权)
* 3. 发送 code 给后端换取 JWT
*/
async function wxLogin(): Promise<void> {
// #ifdef MP-WEIXIN
const { code } = await uni.login({ provider: 'weixin' })
if (!code)
throw new Error('微信登录授权失败')
await userStore.wxLogin({ code })
// #endif
// #ifndef MP-WEIXIN
uni.showToast({ title: '请在微信小程序中使用此功能', icon: 'none' })
// #endif
}
/**
* 微信手机号快速登录
* 配合 <button open-type="getPhoneNumber"> 使用
* 使用 2023+ 新方案:仅需 getPhoneNumber 回调返回的 code,无需 encryptedData/iv
*
* @param phoneCode getPhoneNumber 回调返回的动态令牌(e.detail.code)
*/
async function wxPhoneLogin(phoneCode: string): Promise<void> {
// #ifdef MP-WEIXIN
if (!phoneCode)
throw new Error('手机号授权码获取失败')
await userStore.wxPhoneLogin({ code: phoneCode })
// #endif
// #ifndef MP-WEIXIN
uni.showToast({ title: '请在微信小程序中使用此功能', icon: 'none' })
// #endif
}
return {
wxLogin,
wxPhoneLogin,
}
}