mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-10-17 06:55:38 +00:00
chore: 初始化项目基础结构与配置
添加了nvm配置、静态资源、项目配置文件、工具类、api模块、状态管理、国际化、路由配置等基础代码,搭建了uniapp前端项目的初始框架。
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
/*
|
||||
* @Author: weisheng
|
||||
* @Date: 2025-09-02 09:42:36
|
||||
* @LastEditTime: 2026-04-10 11:01:37
|
||||
* @LastEditors: weisheng
|
||||
* @Description:
|
||||
* @FilePath: /wot-starter/src/composables/types/theme.ts
|
||||
* 记得注释
|
||||
*/
|
||||
import type { ConfigProviderThemeVars } from '@wot-ui/ui'
|
||||
|
||||
export type PrimaryShadeKey
|
||||
= | 'primary1'
|
||||
| 'primary2'
|
||||
| 'primary3'
|
||||
| 'primary4'
|
||||
| 'primary5'
|
||||
| 'primary6'
|
||||
| 'primary7'
|
||||
| 'primary8'
|
||||
| 'primary9'
|
||||
| 'primary10'
|
||||
|
||||
export type ThemePrimaryShades = Record<PrimaryShadeKey, string>
|
||||
|
||||
/**
|
||||
* 主题色选项接口
|
||||
*/
|
||||
export interface ThemeColorOption {
|
||||
/** i18n key(如 theme.colors.blue) */
|
||||
labelKey: string
|
||||
value: string
|
||||
// 主色 6,用于列表和当前主题色圆点展示
|
||||
primary: string
|
||||
// 完整主色阶,用于注入 ConfigProvider 主题变量
|
||||
primaryShades: ThemePrimaryShades
|
||||
}
|
||||
|
||||
/**
|
||||
* 主题类型
|
||||
*/
|
||||
export type ThemeMode = 'light' | 'dark'
|
||||
|
||||
/**
|
||||
* 主题状态接口
|
||||
*/
|
||||
export interface ThemeState {
|
||||
theme: ThemeMode
|
||||
followSystem: boolean
|
||||
hasUserSet: boolean
|
||||
currentThemeColor: ThemeColorOption
|
||||
themeVars: ConfigProviderThemeVars
|
||||
}
|
||||
|
||||
/**
|
||||
* 系统主题状态接口(简化版)
|
||||
*/
|
||||
export interface SystemThemeState {
|
||||
theme: ThemeMode
|
||||
themeVars: ConfigProviderThemeVars
|
||||
}
|
||||
|
||||
/**
|
||||
* 预定义的主题色选项
|
||||
*/
|
||||
export const themeColorOptions: ThemeColorOption[] = [
|
||||
{
|
||||
labelKey: 'theme.colors.blue',
|
||||
value: 'blue',
|
||||
primary: '#1C64FD',
|
||||
primaryShades: {
|
||||
primary1: '#F5F8FF',
|
||||
primary2: '#E5EDFF',
|
||||
primary3: '#B8CFFF',
|
||||
primary4: '#7CA4FF',
|
||||
primary5: '#4480FF',
|
||||
primary6: '#1C64FD',
|
||||
primary7: '#164ED1',
|
||||
primary8: '#1341AD',
|
||||
primary9: '#0F3285',
|
||||
primary10: '#0A235C',
|
||||
},
|
||||
},
|
||||
{
|
||||
labelKey: 'theme.colors.orange',
|
||||
value: 'orange',
|
||||
primary: '#FF7D00',
|
||||
primaryShades: {
|
||||
primary1: '#FFF7F0',
|
||||
primary2: '#FFEAD6',
|
||||
primary3: '#FFD0A8',
|
||||
primary4: '#FFB06E',
|
||||
primary5: '#FF9338',
|
||||
primary6: '#FF7D00',
|
||||
primary7: '#D96800',
|
||||
primary8: '#B35600',
|
||||
primary9: '#8A4200',
|
||||
primary10: '#5F2D00',
|
||||
},
|
||||
},
|
||||
{
|
||||
labelKey: 'theme.colors.green',
|
||||
value: 'green',
|
||||
primary: '#07C160',
|
||||
primaryShades: {
|
||||
primary1: '#F1FCF6',
|
||||
primary2: '#DCF7E8',
|
||||
primary3: '#B4EFD0',
|
||||
primary4: '#7FE2AF',
|
||||
primary5: '#42D28A',
|
||||
primary6: '#07C160',
|
||||
primary7: '#049F4F',
|
||||
primary8: '#028241',
|
||||
primary9: '#016532',
|
||||
primary10: '#014825',
|
||||
},
|
||||
},
|
||||
{
|
||||
labelKey: 'theme.colors.pink',
|
||||
value: 'pink',
|
||||
primary: '#FF69B4',
|
||||
primaryShades: {
|
||||
primary1: '#FFF2F9',
|
||||
primary2: '#FFE2F1',
|
||||
primary3: '#FFC2E3',
|
||||
primary4: '#FF99D1',
|
||||
primary5: '#FF7FC2',
|
||||
primary6: '#FF69B4',
|
||||
primary7: '#E84E9F',
|
||||
primary8: '#CC3F8A',
|
||||
primary9: '#A9336F',
|
||||
primary10: '#7A2450',
|
||||
},
|
||||
},
|
||||
{
|
||||
labelKey: 'theme.colors.purple',
|
||||
value: 'purple',
|
||||
primary: '#8A2BE2',
|
||||
primaryShades: {
|
||||
primary1: '#F7F2FF',
|
||||
primary2: '#EEE2FF',
|
||||
primary3: '#D9BDFF',
|
||||
primary4: '#BC8DFF',
|
||||
primary5: '#A05EFF',
|
||||
primary6: '#8A2BE2',
|
||||
primary7: '#7423BF',
|
||||
primary8: '#5E1D9C',
|
||||
primary9: '#491679',
|
||||
primary10: '#321056',
|
||||
},
|
||||
},
|
||||
{
|
||||
labelKey: 'theme.colors.red',
|
||||
value: 'red',
|
||||
primary: '#FF4757',
|
||||
primaryShades: {
|
||||
primary1: '#FFF3F4',
|
||||
primary2: '#FFE3E5',
|
||||
primary3: '#FFC0C6',
|
||||
primary4: '#FF909A',
|
||||
primary5: '#FF6672',
|
||||
primary6: '#FF4757',
|
||||
primary7: '#DB3445',
|
||||
primary8: '#B7293A',
|
||||
primary9: '#931E2E',
|
||||
primary10: '#6F1421',
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,118 @@
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
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 { t } = useI18n()
|
||||
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 || t('chat.wsConnectFailed'))
|
||||
})
|
||||
task.onClose(() => {
|
||||
socketTask = null
|
||||
if (!closedByUser) {
|
||||
isStreaming.value = false
|
||||
handlers.onError?.(t('chat.wsDisconnected'))
|
||||
}
|
||||
})
|
||||
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?.(t('chat.wsSendFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
/** 停止当前生成(后端停止后返回 [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,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { DialogOptions, DialogResult } from '@wot-ui/ui/components/wd-dialog/types'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export type GlobalDialogOptions = DialogOptions & {
|
||||
success?: (res: DialogResult) => void
|
||||
fail?: (res: DialogResult) => void
|
||||
}
|
||||
|
||||
interface GlobalDialog {
|
||||
dialogOptions: GlobalDialogOptions | null
|
||||
currentPage: string
|
||||
}
|
||||
|
||||
type DialogType = NonNullable<DialogOptions['type']>
|
||||
|
||||
function isButtonPropsObject(value: unknown): value is Record<string, any> {
|
||||
return value !== null && CommonUtil.isObj(value)
|
||||
}
|
||||
|
||||
function normalizeButtonProps(props: unknown, text?: string) {
|
||||
if (props === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (isButtonPropsObject(props)) {
|
||||
return {
|
||||
...props,
|
||||
...(text ? { text } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
if (CommonUtil.isString(props) || text) {
|
||||
return {
|
||||
text: text || props,
|
||||
}
|
||||
}
|
||||
|
||||
if (props === undefined) {
|
||||
return {}
|
||||
}
|
||||
|
||||
return props
|
||||
}
|
||||
|
||||
function withDefaultTypeOptions(option: GlobalDialogOptions, type?: DialogType): GlobalDialogOptions {
|
||||
const next: GlobalDialogOptions = {
|
||||
...option,
|
||||
...(type ? { type } : {}),
|
||||
}
|
||||
|
||||
if (next.showCancelButton === undefined) {
|
||||
if (next.type === 'alert') {
|
||||
next.showCancelButton = false
|
||||
}
|
||||
else if (next.type === 'confirm' || next.type === 'prompt') {
|
||||
next.showCancelButton = true
|
||||
}
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
function normalizeDialogOptions(option: GlobalDialogOptions, type?: DialogType): GlobalDialogOptions {
|
||||
const next = withDefaultTypeOptions(option, type)
|
||||
|
||||
next.confirmButtonProps = normalizeButtonProps(next.confirmButtonProps, next.confirmButtonText) as DialogOptions['confirmButtonProps']
|
||||
|
||||
if (next.showCancelButton === false) {
|
||||
next.cancelButtonProps = null
|
||||
}
|
||||
else if (next.showCancelButton === true || next.cancelButtonProps !== undefined || next.cancelButtonText) {
|
||||
next.cancelButtonProps = normalizeButtonProps(next.cancelButtonProps, next.cancelButtonText) as DialogOptions['cancelButtonProps']
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
function normalizeOption(option: GlobalDialogOptions | string, type?: DialogType): GlobalDialogOptions {
|
||||
return normalizeDialogOptions(CommonUtil.isString(option) ? { title: option } : option, type)
|
||||
}
|
||||
|
||||
export const useGlobalDialog = defineStore('global-Dialog', {
|
||||
state: (): GlobalDialog => ({
|
||||
dialogOptions: null,
|
||||
currentPage: '',
|
||||
}),
|
||||
actions: {
|
||||
show(option: GlobalDialogOptions | string, type?: DialogType) {
|
||||
this.currentPage = getCurrentPath()
|
||||
this.dialogOptions = normalizeOption(option, type)
|
||||
},
|
||||
alert(option: GlobalDialogOptions | string) {
|
||||
this.show(option, 'alert')
|
||||
},
|
||||
confirm(option: GlobalDialogOptions | string) {
|
||||
this.show(option, 'confirm')
|
||||
},
|
||||
prompt(option: GlobalDialogOptions | string) {
|
||||
this.show(option, 'prompt')
|
||||
},
|
||||
close() {
|
||||
this.dialogOptions = null
|
||||
this.currentPage = ''
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* @Author: weisheng
|
||||
* @Date: 2025-09-25 20:32:22
|
||||
* @LastEditTime: 2026-04-07 14:06:20
|
||||
* @LastEditors: weisheng
|
||||
* @Description:
|
||||
* @FilePath: /wot-starter/src/composables/useGlobalLoading.ts
|
||||
* 记得注释
|
||||
*/
|
||||
import type { ToastOptions } from '@wot-ui/ui/components/wd-toast/types'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
interface GlobalLoading {
|
||||
loadingOptions: ToastOptions
|
||||
currentPage: string
|
||||
}
|
||||
|
||||
const defaultOptions: ToastOptions = {
|
||||
show: false,
|
||||
}
|
||||
export const useGlobalLoading = defineStore('global-loading', {
|
||||
state: (): GlobalLoading => ({
|
||||
loadingOptions: defaultOptions,
|
||||
currentPage: '',
|
||||
}),
|
||||
getters: {},
|
||||
actions: {
|
||||
// 加载提示
|
||||
loading(option: ToastOptions | string) {
|
||||
this.currentPage = getCurrentPath()
|
||||
this.loadingOptions = CommonUtil.deepMerge({
|
||||
iconName: 'loading',
|
||||
duration: 0,
|
||||
cover: true,
|
||||
position: 'middle',
|
||||
show: true,
|
||||
}, typeof option === 'string' ? { msg: option } : option) as ToastOptions
|
||||
},
|
||||
// 关闭Toast
|
||||
close() {
|
||||
this.loadingOptions = defaultOptions
|
||||
this.currentPage = ''
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export interface GlobalMessageOptions {
|
||||
title?: string
|
||||
content?: string
|
||||
type?: 'alert' | 'confirm'
|
||||
showCancel?: boolean
|
||||
confirmText?: string
|
||||
cancelText?: string
|
||||
success?: (res: { confirm: boolean, cancel: boolean }) => void
|
||||
fail?: (err: unknown) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 全局消息 store:GlobalMessage 组件监听 messageOptions,
|
||||
* 仅当触发页面与 currentPage 一致时展示,避免跨页误弹
|
||||
*/
|
||||
export const useGlobalMessage = defineStore('global-message', {
|
||||
state: () => ({
|
||||
messageOptions: null as GlobalMessageOptions | null,
|
||||
currentPage: '',
|
||||
}),
|
||||
actions: {
|
||||
show(options: GlobalMessageOptions) {
|
||||
this.currentPage = getCurrentPath()
|
||||
this.messageOptions = options
|
||||
},
|
||||
close() {
|
||||
this.messageOptions = null
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { ToastOptions } from '@wot-ui/ui/components/wd-toast/types'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
interface GlobalToast {
|
||||
toastOptions: ToastOptions
|
||||
currentPage: string
|
||||
}
|
||||
|
||||
const defaultOptions: ToastOptions = {
|
||||
duration: 2000,
|
||||
show: false,
|
||||
}
|
||||
export const useGlobalToast = defineStore('global-toast', {
|
||||
state: (): GlobalToast => ({
|
||||
toastOptions: defaultOptions,
|
||||
currentPage: '',
|
||||
}),
|
||||
getters: {},
|
||||
actions: {
|
||||
// 打开Toast
|
||||
show(option: ToastOptions | string) {
|
||||
this.currentPage = getCurrentPath()
|
||||
const options = CommonUtil.deepMerge(defaultOptions, typeof option === 'string' ? { msg: option } : option) as ToastOptions
|
||||
this.toastOptions = CommonUtil.deepMerge(options, {
|
||||
show: true,
|
||||
position: options.position || 'middle',
|
||||
}) as ToastOptions
|
||||
},
|
||||
// 成功提示
|
||||
success(option: ToastOptions | string) {
|
||||
this.show(CommonUtil.deepMerge({
|
||||
iconName: 'success',
|
||||
iconColor: '#07c160',
|
||||
duration: 1500,
|
||||
}, typeof option === 'string' ? { msg: option } : option) as ToastOptions)
|
||||
},
|
||||
// 关闭提示
|
||||
error(option: ToastOptions | string) {
|
||||
this.show(CommonUtil.deepMerge({
|
||||
iconName: 'error',
|
||||
iconColor: '#fa5151',
|
||||
direction: 'vertical',
|
||||
}, typeof option === 'string' ? { msg: option } : option) as ToastOptions)
|
||||
},
|
||||
// 常规提示
|
||||
info(option: ToastOptions | string) {
|
||||
this.show(CommonUtil.deepMerge({
|
||||
iconName: 'info',
|
||||
iconColor: '#10aeff',
|
||||
}, typeof option === 'string' ? { msg: option } : option) as ToastOptions)
|
||||
},
|
||||
// 警告提示
|
||||
warning(option: ToastOptions | string) {
|
||||
this.show(CommonUtil.deepMerge({
|
||||
iconName: 'warning',
|
||||
iconColor: '#ff9f0a',
|
||||
}, typeof option === 'string' ? { msg: option } : option) as ToastOptions)
|
||||
},
|
||||
// 关闭Toast
|
||||
close() {
|
||||
this.toastOptions = defaultOptions
|
||||
this.currentPage = ''
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
/**
|
||||
* 页面导航栏标题国际化
|
||||
* definePage 的 navigationBarTitleText 是编译期静态文案,
|
||||
* 这里在 onShow 时按当前语言动态覆盖标题,语言切换时也能即时刷新。
|
||||
*/
|
||||
export function useI18nNavTitle(key: string) {
|
||||
const { t, locale } = useI18n()
|
||||
function apply() {
|
||||
uni.setNavigationBarTitle({ title: t(key) })
|
||||
}
|
||||
onShow(apply)
|
||||
watch(locale, apply)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { Method } from 'alova'
|
||||
import { usePagination } from 'alova/client'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
export interface ListPageParams {
|
||||
page_no: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
export interface ListPageOptions<T> {
|
||||
fetcher: (params: ListPageParams) => Promise<PageResult<T>>
|
||||
pageSize?: number
|
||||
onError?: (error: unknown) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用列表分页逻辑(基于 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 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() {
|
||||
try {
|
||||
await send(pageParams.value.page_no, pageParams.value.page_size)
|
||||
}
|
||||
catch {
|
||||
// 错误已由 onPageError → onError 统一处理,避免未捕获 Promise 告警
|
||||
}
|
||||
finally {
|
||||
// 收起下拉刷新指示器(onPullDownRefresh → loadData 场景;非刷新场景调用无害)
|
||||
uni.stopPullDownRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
/** 上一页 */
|
||||
async function loadPrev() {
|
||||
if (pageParams.value.page_no <= 1)
|
||||
return
|
||||
pageParams.value.page_no -= 1
|
||||
await loadData()
|
||||
}
|
||||
|
||||
/** 下一页 */
|
||||
async function loadNext() {
|
||||
pageParams.value.page_no += 1
|
||||
await loadData()
|
||||
}
|
||||
|
||||
/** 跳回第一页(搜索/重置时使用) */
|
||||
async function toFirst() {
|
||||
pageParams.value.page_no = 1
|
||||
await loadData()
|
||||
}
|
||||
|
||||
return {
|
||||
list: computed<T[]>(() => data.value ?? []),
|
||||
total: computed<number>(() => total.value ?? 0),
|
||||
loading,
|
||||
error,
|
||||
pageParams,
|
||||
loadData,
|
||||
loadPrev,
|
||||
loadNext,
|
||||
toFirst,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import type { ThemeColorOption, ThemeMode } from '@/composables/types/theme'
|
||||
import { themeColorOptions } from '@/composables/types/theme'
|
||||
import { initializeThemeOnce, subscribeSystemThemeChange } from '@/utils/systemTheme'
|
||||
|
||||
/**
|
||||
* 完整版主题管理组合式API
|
||||
*
|
||||
* 功能特性:
|
||||
* - 支持手动切换暗黑模式
|
||||
* - 支持主题色选择
|
||||
* - 支持跟随系统主题
|
||||
* - 自动同步导航栏颜色
|
||||
* - 持久化用户设置
|
||||
*
|
||||
* 适用场景:
|
||||
* - 需要用户手动控制主题的应用
|
||||
* - 需要主题色自定义的应用
|
||||
* - 需要完整主题管理功能的复杂应用
|
||||
*
|
||||
* @example
|
||||
* ```vue
|
||||
* <script setup>
|
||||
* import { useManualTheme } from '@/composables/useManualTheme'
|
||||
*
|
||||
* const {
|
||||
* theme,
|
||||
* isDark,
|
||||
* toggleTheme,
|
||||
* openThemeColorPicker,
|
||||
* currentThemeColor,
|
||||
* themeVars
|
||||
* } = useManualTheme()
|
||||
* </script>
|
||||
*
|
||||
* <template>
|
||||
* <wd-config-provider :theme="theme" :theme-vars="themeVars">
|
||||
* <view :class="{ 'dark-mode': isDark }">
|
||||
* <wd-button @click="toggleTheme">切换主题</wd-button>
|
||||
* <wd-button @click="openThemeColorPicker">选择主题色</wd-button>
|
||||
* </view>
|
||||
* </wd-config-provider>
|
||||
* </template>
|
||||
* ```
|
||||
*/
|
||||
export function useManualTheme() {
|
||||
const store = useManualThemeStore()
|
||||
const showThemeColorSheet = ref(false)
|
||||
let stopThemeChangeListener: (() => void) | undefined
|
||||
|
||||
/**
|
||||
* 切换暗黑模式
|
||||
* @param mode 指定主题模式,不传则自动切换
|
||||
* @param isFollowSystem 是否跟随系统
|
||||
*/
|
||||
function toggleTheme(mode?: ThemeMode, isFollowSystem: boolean = false) {
|
||||
store.toggleTheme(mode, isFollowSystem)
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开主题色选择器
|
||||
*/
|
||||
function openThemeColorPicker() {
|
||||
showThemeColorSheet.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭主题色选择器
|
||||
*/
|
||||
function closeThemeColorPicker() {
|
||||
showThemeColorSheet.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 选择主题色
|
||||
* @param option 主题色选项
|
||||
*/
|
||||
function selectThemeColor(option: ThemeColorOption) {
|
||||
store.setCurrentThemeColor(option)
|
||||
closeThemeColorPicker()
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化主题
|
||||
*/
|
||||
function initTheme() {
|
||||
store.initTheme()
|
||||
}
|
||||
|
||||
// 组件挂载前初始化主题
|
||||
onBeforeMount(() => {
|
||||
initializeThemeOnce(store, initTheme)
|
||||
stopThemeChangeListener = subscribeSystemThemeChange(store, (res) => {
|
||||
if (store.followSystem) {
|
||||
store.toggleTheme(res.theme, true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// 页面显示时更新导航栏颜色,确保每次切换页面时导航栏颜色都是正确的
|
||||
onShow(() => {
|
||||
store.setNavigationBarColor()
|
||||
})
|
||||
|
||||
// 组件卸载时清理监听
|
||||
onUnmounted(() => {
|
||||
stopThemeChangeListener?.()
|
||||
stopThemeChangeListener = undefined
|
||||
})
|
||||
|
||||
return {
|
||||
// 状态
|
||||
theme: computed(() => store.theme),
|
||||
isDark: computed(() => store.isDark),
|
||||
followSystem: computed(() => store.followSystem),
|
||||
hasUserSet: computed(() => store.hasUserSet),
|
||||
currentThemeColor: computed(() => store.currentThemeColor),
|
||||
themeVars: computed(() => store.themeVars),
|
||||
showThemeColorSheet,
|
||||
|
||||
// 常量
|
||||
themeColorOptions,
|
||||
|
||||
// 方法
|
||||
initTheme,
|
||||
toggleTheme,
|
||||
setFollowSystem: store.setFollowSystem,
|
||||
openThemeColorPicker,
|
||||
closeThemeColorPicker,
|
||||
selectThemeColor,
|
||||
}
|
||||
}
|
||||
|
||||
// 导出类型和常量供外部使用
|
||||
export type { ThemeColorOption, ThemeMode }
|
||||
export { themeColorOptions }
|
||||
@@ -0,0 +1,62 @@
|
||||
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
/** 分享配置 */
|
||||
interface ShareConfig {
|
||||
/** 分享标题 */
|
||||
title: string
|
||||
/** 分享路径(带 / 开头,例如 /pages/index/index) */
|
||||
path?: string
|
||||
/** 分享图片 URL(可选,不传则使用页面截图) */
|
||||
imageUrl?: string
|
||||
}
|
||||
|
||||
/** 默认分享路径(全局兜底) */
|
||||
const DEFAULT_SHARE_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 { t } = useI18n()
|
||||
const getConfig = (): ShareConfig => {
|
||||
// 未指定分享标题时使用全局默认(随当前语言)
|
||||
const defaults: ShareConfig = { title: t('common.shareDefaultTitle'), path: DEFAULT_SHARE_PATH }
|
||||
if (typeof configOrGetter === 'function')
|
||||
return { ...defaults, ...configOrGetter() }
|
||||
if (configOrGetter)
|
||||
return { ...defaults, ...configOrGetter }
|
||||
return defaults
|
||||
}
|
||||
|
||||
onShareAppMessage(() => {
|
||||
const cfg = getConfig()
|
||||
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,144 @@
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
/**
|
||||
* 微信订阅消息 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() {
|
||||
const { t } = useI18n()
|
||||
/**
|
||||
* 请求订阅消息授权
|
||||
*
|
||||
* @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: t('common.subscribeTitle'),
|
||||
content: t('common.subscribeContent'),
|
||||
confirmText: t('common.subscribeGoSetting'),
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
export interface TabbarItem {
|
||||
name: string
|
||||
value?: number
|
||||
active: boolean
|
||||
titleKey: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
const tabbarItems = ref<TabbarItem[]>([
|
||||
{ name: 'home', active: true, titleKey: 'common.tab.home', icon: 'home' },
|
||||
{ name: 'work', active: false, titleKey: 'common.tab.work', icon: 'apps' },
|
||||
{ name: 'mine', active: false, titleKey: 'common.tab.mine', icon: 'user' },
|
||||
])
|
||||
|
||||
export function useTabbar() {
|
||||
const tabbarList = computed(() => tabbarItems.value)
|
||||
|
||||
const activeTabbar = computed(() => {
|
||||
const item = tabbarItems.value.find(item => item.active)
|
||||
return item || tabbarItems.value[0]
|
||||
})
|
||||
|
||||
const getTabbarItemValue = (name: string) => {
|
||||
const item = tabbarItems.value.find(item => item.name === name)
|
||||
return item?.value
|
||||
}
|
||||
|
||||
const setTabbarItem = (name: string, value: number) => {
|
||||
const tabbarItem = tabbarItems.value.find(item => item.name === name)
|
||||
if (tabbarItem) {
|
||||
tabbarItem.value = value
|
||||
}
|
||||
}
|
||||
|
||||
const setTabbarItemActive = (name: string) => {
|
||||
tabbarItems.value.forEach((item) => {
|
||||
if (item.name === name) {
|
||||
item.active = true
|
||||
}
|
||||
else {
|
||||
item.active = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
tabbarList,
|
||||
activeTabbar,
|
||||
getTabbarItemValue,
|
||||
setTabbarItem,
|
||||
setTabbarItemActive,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { initializeThemeOnce, subscribeSystemThemeChange } from '@/utils/systemTheme'
|
||||
|
||||
/**
|
||||
* 简化版系统主题管理组合式API
|
||||
*
|
||||
* 功能特性:
|
||||
* - 仅跟随系统主题变化
|
||||
* - 自动响应系统主题切换
|
||||
* - 导航栏颜色通过 theme.json 自动处理
|
||||
* - 轻量级,无额外功能
|
||||
*
|
||||
* 适用场景:
|
||||
* - 只需要系统主题适应的简单应用
|
||||
* - 不需要用户手动控制主题的应用
|
||||
* - 追求轻量级主题管理的应用
|
||||
*
|
||||
* 注意事项:
|
||||
* - 不支持手动切换主题
|
||||
* - 不支持主题色自定义
|
||||
* - 导航栏颜色依赖 theme.json 配置
|
||||
*
|
||||
* @example
|
||||
* ```vue
|
||||
* <script setup>
|
||||
* import { useTheme } from '@/composables/useTheme'
|
||||
*
|
||||
* const { theme, isDark, themeVars } = useTheme()
|
||||
* </script>
|
||||
*
|
||||
* <template>
|
||||
* <wd-config-provider :theme="theme" :theme-vars="themeVars">
|
||||
* <view :class="{ 'dark-mode': isDark }">
|
||||
* <text>当前主题: {{ theme }}</text>
|
||||
* </view>
|
||||
* </wd-config-provider>
|
||||
* </template>
|
||||
* ```
|
||||
*/
|
||||
export function useTheme() {
|
||||
const store = useThemeStore()
|
||||
let stopThemeChangeListener: (() => void) | undefined
|
||||
|
||||
// 组件挂载前初始化系统主题
|
||||
onBeforeMount(() => {
|
||||
initializeThemeOnce(store, () => store.initSystemTheme())
|
||||
stopThemeChangeListener = subscribeSystemThemeChange(store, (res) => {
|
||||
// 系统主题变化时自动更新,导航栏颜色由 theme.json 自动处理
|
||||
store.setTheme(res.theme)
|
||||
})
|
||||
})
|
||||
|
||||
// 组件卸载时清理监听
|
||||
onUnmounted(() => {
|
||||
stopThemeChangeListener?.()
|
||||
stopThemeChangeListener = undefined
|
||||
})
|
||||
|
||||
return {
|
||||
// 状态(只读)
|
||||
theme: computed(() => store.theme),
|
||||
isDark: computed(() => store.isDark),
|
||||
themeVars: computed(() => store.themeVars),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { computed, onUnmounted, ref } from 'vue'
|
||||
import { WATERMARK_KEY } from '@/constants'
|
||||
import { useUserStore } from '@/store/userStore'
|
||||
import { Storage } from '@/utils/storage'
|
||||
|
||||
/**
|
||||
* 小程序水印逻辑(配合 wd-watermark 使用)
|
||||
* - 显示条件:本地偏好开启(默认开,由设置页"水印"开关控制)&& 已登录
|
||||
* - 水印内容为当前登录用户名(username 优先,回退 name)
|
||||
* - 响应式:登录用户 / 设置页开关变更后自动更新(layout 常驻不重挂载,经 uni.$on 同步)
|
||||
*/
|
||||
export function useWatermark() {
|
||||
const userStore = useUserStore()
|
||||
const themeStore = useManualThemeStore()
|
||||
// 兼容历史脏对象 { value },统一兜底为布尔(wd-switch change 事件参数为对象)
|
||||
const stored = Storage.get<boolean | { value: boolean }>(WATERMARK_KEY)
|
||||
const localSwitch = ref(typeof stored === 'object' && stored ? Boolean(stored.value) : (stored ?? true))
|
||||
|
||||
const content = computed(() => userStore.userInfo?.username || userStore.userInfo?.name || '')
|
||||
const enabled = computed(() => localSwitch.value && !!content.value)
|
||||
// 水印文字使用当前主题色,主题色切换后经 wd-watermark 的 props deep watch 自动重绘
|
||||
const color = computed(() => themeStore.currentThemeColor.primary)
|
||||
|
||||
// 设置页切换水印开关时同步本地状态(uni.$on 在 H5 / 小程序均可用)
|
||||
const onSwitchChange = (value: boolean) => {
|
||||
localSwitch.value = value
|
||||
}
|
||||
uni.$on('updateWatermarkSwitch', onSwitchChange)
|
||||
onUnmounted(() => {
|
||||
uni.$off('updateWatermarkSwitch', onSwitchChange)
|
||||
})
|
||||
|
||||
return { enabled, content, color }
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useI18n } from 'vue-i18n'
|
||||
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 { t } = useI18n()
|
||||
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(t('login.wxAuthFailed'))
|
||||
|
||||
await userStore.wxLogin({ code })
|
||||
// #endif
|
||||
|
||||
// #ifndef MP-WEIXIN
|
||||
uni.showToast({ title: t('login.wxOnly'), 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(t('login.phoneCodeFailed'))
|
||||
|
||||
await userStore.wxPhoneLogin({ code: phoneCode })
|
||||
// #endif
|
||||
|
||||
// #ifndef MP-WEIXIN
|
||||
uni.showToast({ title: t('login.wxOnly'), icon: 'none' })
|
||||
// #endif
|
||||
}
|
||||
|
||||
return {
|
||||
wxLogin,
|
||||
wxPhoneLogin,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user