mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-10-17 06:55:38 +00:00
refactor: 小程序端代码瘦身与功能补齐
- 清理冗余代码与配置: - 删除废弃的 mock 适配器、通用 mock 模块与示例API - 移除冗余的 ts 类型文件与 patch 目录 - 简化 tsconfig 组件路径配置 - 新增功能与优化: - 新增全局水印组件与水印配置 - 接入微信小程序登录/手机号登录 - 新增系统参数配置 store 与国际化支持 - 新增 markdown 渲染插件与样式配置 - 优化 pinia 持久化逻辑修复 - 新增微信小程序 access_token 缓存配置 - 修复登录趋势数据结构定义 - 优化全局请求防抖重定向逻辑 - 页面与路由优化: - 补全引导页首次启动逻辑 - 统一页面底部安全区适配 - 更新页面路由类型声明 - 代码质量优化: - 移除无用 console.log 调试代码 - 修复全局弹窗重复重登录问题 - 优化 mp-html 组件配置
This commit is contained in:
@@ -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,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user