chore: 初始化项目基础结构与配置

添加了nvm配置、静态资源、项目配置文件、工具类、api模块、状态管理、国际化、路由配置等基础代码,搭建了uniapp前端项目的初始框架。
This commit is contained in:
zhangtao
2026-08-10 22:23:32 +08:00
parent 9e41358945
commit 50eac1872b
216 changed files with 44085 additions and 15 deletions
+169
View File
@@ -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',
},
},
]
+118
View File
@@ -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)
}
+102
View File
@@ -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 }
+62
View File
@@ -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,
}
}
+53
View File
@@ -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,
}
}
+64
View File
@@ -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,
}
}