首次提交:初始化项目代码
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
unpackage/
|
||||||
|
.hbuilderx/
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"topic_20260729-054021_a698c4e41c426d52": 1785303621642
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"topic_20260729-054021_a698c4e41c426d52": "auto"
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"topic_20260729-054021_a698c4e41c426d52": "新的会话"
|
||||||
|
}
|
||||||
@@ -0,0 +1,658 @@
|
|||||||
|
<script>
|
||||||
|
import { getToken, setToken } from '@/utils/auth'
|
||||||
|
import { login } from './api'
|
||||||
|
import { FA_SOLID_DATA, FA_BRANDS_DATA, FA_SOLID_FAMILY, FA_BRANDS_FAMILY } from '@/static/utils/fontData'
|
||||||
|
// 1. 新增:导入用户配置接口
|
||||||
|
import { getUserConfig, editUserConfig } from './api'
|
||||||
|
// 1. 引入主题工具方法 + 预设常量(补充缺失的导入)
|
||||||
|
import {
|
||||||
|
getThemeFromServer,
|
||||||
|
switchTheme,
|
||||||
|
injectTheme,
|
||||||
|
THEME_PRESETS,
|
||||||
|
DEFAULT_THEME_KEY,
|
||||||
|
THEME_CHANGE_EVENT // 补充导入事件常量,统一事件名
|
||||||
|
} from './utils/theme'
|
||||||
|
// 引入全局主题适配器(导航栏、TabBar等)
|
||||||
|
import {
|
||||||
|
initGlobalTheme,
|
||||||
|
registerGlobalThemeListener,
|
||||||
|
applyGlobalTheme,
|
||||||
|
applyNavigationBarTheme
|
||||||
|
} from './utils/themeGlobal'
|
||||||
|
// 激励机制事件常量
|
||||||
|
const GROWTH_UPDATE_EVENT = 'growthUpdate'
|
||||||
|
|
||||||
|
function hexToRgb(hex, fallback = '198,142,63') {
|
||||||
|
if (!hex || hex[0] !== '#') return fallback
|
||||||
|
let h = hex.slice(1)
|
||||||
|
if (h.length === 3) h = h.split('').map(c => c + c).join('')
|
||||||
|
const n = parseInt(h, 16)
|
||||||
|
return `${(n >> 16) & 255},${(n >> 8) & 255},${n & 255}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
// 初始化全局数据(小程序全局状态核心)
|
||||||
|
globalData: {
|
||||||
|
themeKey: DEFAULT_THEME_KEY,
|
||||||
|
themeConfig: THEME_PRESETS[DEFAULT_THEME_KEY],
|
||||||
|
token: '',
|
||||||
|
lastToken: '',
|
||||||
|
fontReady: false
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
themeConfig: THEME_PRESETS[DEFAULT_THEME_KEY]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
// 将主题色注入为全局 CSS 变量,供 .btn 等统一按钮类使用
|
||||||
|
rootVars() {
|
||||||
|
const c = this.themeConfig || {}
|
||||||
|
const pRGB = hexToRgb(c.primaryColor)
|
||||||
|
const dRGB = hexToRgb(c.dangerColor || '#F56C6C')
|
||||||
|
const sRGB = hexToRgb(c.successColor || '#67C23A')
|
||||||
|
const isDark = c.isDark || false
|
||||||
|
const secondaryBg = isDark ? 'rgba(255,255,255,0.16)' : 'rgba(0,0,0,0.06)'
|
||||||
|
const secondaryText = isDark ? 'rgba(255,255,255,0.9)' : '#333'
|
||||||
|
return {
|
||||||
|
'--primaryColor': c.primaryColor,
|
||||||
|
'--primaryColorRGB': pRGB,
|
||||||
|
'--secondaryColor': c.secondaryColor,
|
||||||
|
'--dangerColor': c.dangerColor || '#F56C6C',
|
||||||
|
'--dangerColorRGB': dRGB,
|
||||||
|
'--successColor': c.successColor || '#67C23A',
|
||||||
|
'--successColorRGB': sRGB,
|
||||||
|
'--warningColor': c.warningColor || '#E6A23C',
|
||||||
|
'--incomeColor': c.incomeColor || '#67C23A',
|
||||||
|
'--expendColor': c.expendColor || '#F56C6C',
|
||||||
|
'--textPrimary': c.textPrimary,
|
||||||
|
'--textSecondary': c.textSecondary,
|
||||||
|
'--textTertiary': c.textTertiary || c.textDisabled || '#909399',
|
||||||
|
'--textDisabled': c.textDisabled || '#C0C4CC',
|
||||||
|
'--bgColor': c.bgColor,
|
||||||
|
'--cardBgColor': c.cardBgColor,
|
||||||
|
'--borderColor': c.borderColor,
|
||||||
|
'--borderLight': c.borderLight,
|
||||||
|
'--inputBgColor': c.inputBgColor || c.cardBgColor,
|
||||||
|
'--gradientSoft': c.gradientSoft,
|
||||||
|
'--shadowColor': c.shadowColor,
|
||||||
|
'--btn-secondary-bg': secondaryBg,
|
||||||
|
'--btn-secondary-text': secondaryText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 小程序启动入口(优先初始化主题,再处理登录)
|
||||||
|
*/
|
||||||
|
onLaunch: async function() {
|
||||||
|
try {
|
||||||
|
await this.loadFonts()
|
||||||
|
console.log('✅ App启动 - 字体加载完成')
|
||||||
|
|
||||||
|
// ========== 第一步:优先初始化主题(确保样式先加载,避免闪屏) ==========
|
||||||
|
await this.initTheme()
|
||||||
|
this.themeConfig = this.globalData.themeConfig
|
||||||
|
// 注册全局主题变更监听:同步 CSS 变量驱动的统一按钮样式
|
||||||
|
uni.$on(THEME_CHANGE_EVENT, (payload) => {
|
||||||
|
const cfg = (payload && payload.config) || this.globalData.themeConfig || THEME_PRESETS[DEFAULT_THEME_KEY]
|
||||||
|
this.themeConfig = cfg
|
||||||
|
})
|
||||||
|
console.log('✅ App启动 - 主题初始化完成')
|
||||||
|
|
||||||
|
// ========== 第二步:注册激励机制全局事件监听 ==========
|
||||||
|
this.registerGrowthListener()
|
||||||
|
console.log(' 激励机制事件监听注册完成')
|
||||||
|
|
||||||
|
// ========== 第三步:处理登录逻辑(原有逻辑加固) ==========
|
||||||
|
this.globalData.token = getToken() // 同步token到全局
|
||||||
|
this.globalData.lastToken = this.globalData.token // 初始化lastToken,用于检测登录状态变化
|
||||||
|
const token = this.globalData.token
|
||||||
|
|
||||||
|
// 已登录 → 先同步配置再跳首页
|
||||||
|
if (token) {
|
||||||
|
console.log('✅ 已登录,同步用户配置')
|
||||||
|
await this.syncUserConfig()
|
||||||
|
console.log('✅ 跳转到首页')
|
||||||
|
uni.switchTab({
|
||||||
|
url: '/pages/index/index',
|
||||||
|
fail: (err) => {
|
||||||
|
console.error('跳转到首页失败:', err)
|
||||||
|
// 兜底:如果tabBar跳转失败,直接打开首页
|
||||||
|
uni.redirectTo({ url: '/pages/index/index' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未登录 → 执行自动登录(保留注释,逻辑加固)
|
||||||
|
// await this.handleAutoLogin()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('❌ App启动流程异常:', err)
|
||||||
|
// 终极兜底:无论启动失败原因,确保进入基础页面
|
||||||
|
uni.redirectTo({ url: '/pages/login/login' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 小程序切前台时触发(兜底更新主题,防止样式不一致)
|
||||||
|
*/
|
||||||
|
onShow: function() {
|
||||||
|
// 重新应用全局主题到导航栏(确保切回前台时样式一致)
|
||||||
|
if (this.globalData.themeConfig) {
|
||||||
|
applyNavigationBarTheme(this.globalData.themeConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测登录状态变化(token从无到有)
|
||||||
|
const currentToken = getToken()
|
||||||
|
if (currentToken && currentToken !== this.globalData.lastToken) {
|
||||||
|
console.log('【App】检测到登录状态变化,同步用户配置')
|
||||||
|
this.syncUserConfig()
|
||||||
|
this.globalData.lastToken = currentToken
|
||||||
|
}
|
||||||
|
|
||||||
|
// 触发全局主题事件,通知所有页面更新
|
||||||
|
uni.$emit(THEME_CHANGE_EVENT, {
|
||||||
|
key: this.globalData.themeKey,
|
||||||
|
config: this.globalData.themeConfig
|
||||||
|
})
|
||||||
|
console.log('✅ 小程序切前台 - 触发主题同步')
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 方法集合(主题 + 登录)
|
||||||
|
*/
|
||||||
|
methods: {
|
||||||
|
/**
|
||||||
|
* 加载 Font Awesome 字体(微信小程序专用 wx.loadFontFace API)
|
||||||
|
* 注意:wx.loadFontFace 的 source 参数必须为 url("...") 格式,
|
||||||
|
* 且仅支持 https 网络资源或 base64 data URI,不支持本地静态路径。
|
||||||
|
*/
|
||||||
|
loadFonts() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const fontConfigs = [
|
||||||
|
{ family: FA_SOLID_FAMILY, data: FA_SOLID_DATA },
|
||||||
|
{ family: FA_BRANDS_FAMILY, data: FA_BRANDS_DATA }
|
||||||
|
]
|
||||||
|
let loaded = 0
|
||||||
|
fontConfigs.forEach((config) => {
|
||||||
|
uni.loadFontFace({
|
||||||
|
family: config.family,
|
||||||
|
source: `url("${config.data}")`,
|
||||||
|
global: true,
|
||||||
|
success: () => {
|
||||||
|
loaded++
|
||||||
|
console.log(`✅ 字体加载成功 [${config.family}] via base64`)
|
||||||
|
if (loaded === fontConfigs.length) {
|
||||||
|
this.globalData.fontReady = true
|
||||||
|
uni.$emit('fontReady')
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: (err) => {
|
||||||
|
console.warn(`字体加载失败 [${config.family}]:`, err.errMsg || err)
|
||||||
|
loaded++
|
||||||
|
if (loaded === fontConfigs.length) {
|
||||||
|
this.globalData.fontReady = loaded > 0
|
||||||
|
uni.$emit('fontReady')
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
setTimeout(() => {
|
||||||
|
if (loaded < fontConfigs.length) {
|
||||||
|
console.warn('字体加载超时,部分字体可能不可用')
|
||||||
|
}
|
||||||
|
this.globalData.fontReady = loaded > 0
|
||||||
|
uni.$emit('fontReady')
|
||||||
|
resolve()
|
||||||
|
}, 10000)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 初始化主题(兼容后端/本地/默认,强容错)
|
||||||
|
*/
|
||||||
|
async initTheme() {
|
||||||
|
try {
|
||||||
|
// 1. 从后端/本地获取合法的主题key
|
||||||
|
const themeKey = await getThemeFromServer()
|
||||||
|
// 2. 注入全局(更新storage + 全局事件 + globalData)
|
||||||
|
injectTheme(themeKey)
|
||||||
|
// 3. 强制同步globalData(小程序getApp()依赖此数据)
|
||||||
|
this.globalData.themeKey = themeKey
|
||||||
|
this.globalData.themeConfig = THEME_PRESETS[themeKey] || THEME_PRESETS[DEFAULT_THEME_KEY]
|
||||||
|
// 4. 应用全局主题到导航栏和TabBar
|
||||||
|
applyGlobalTheme(this.globalData.themeConfig)
|
||||||
|
// 5. 注册全局主题变更监听
|
||||||
|
registerGlobalThemeListener()
|
||||||
|
console.log(`【主题】初始化完成,当前主题:${this.globalData.themeKey}`)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('【主题】初始化失败,使用默认主题:', error)
|
||||||
|
// 兜底:强制注入默认主题
|
||||||
|
injectTheme(DEFAULT_THEME_KEY)
|
||||||
|
this.globalData.themeKey = DEFAULT_THEME_KEY
|
||||||
|
this.globalData.themeConfig = THEME_PRESETS[DEFAULT_THEME_KEY]
|
||||||
|
applyGlobalTheme(this.globalData.themeConfig)
|
||||||
|
registerGlobalThemeListener()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 全局切换主题(供任意页面调用)
|
||||||
|
* @param {string} themeKey 目标主题key
|
||||||
|
*/
|
||||||
|
async updateTheme(themeKey, showToast = true) {
|
||||||
|
if (!themeKey || themeKey === this.globalData.themeKey) return
|
||||||
|
try {
|
||||||
|
// 调用核心切换方法(后端+本地+全局事件)
|
||||||
|
await switchTheme(themeKey, showToast)
|
||||||
|
// 同步更新globalData(关键:保证getApp()获取最新值)
|
||||||
|
this.globalData.themeKey = themeKey
|
||||||
|
this.globalData.themeConfig = THEME_PRESETS[themeKey] || THEME_PRESETS[DEFAULT_THEME_KEY]
|
||||||
|
// 应用全局主题到导航栏和TabBar
|
||||||
|
applyGlobalTheme(this.globalData.themeConfig)
|
||||||
|
console.log(`【主题】全局切换完成:${themeKey}`)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`【主题】切换失败:${themeKey}`, error)
|
||||||
|
// 兜底:本地强制切换
|
||||||
|
injectTheme(themeKey)
|
||||||
|
this.globalData.themeKey = themeKey
|
||||||
|
this.globalData.themeConfig = THEME_PRESETS[themeKey] || THEME_PRESETS[DEFAULT_THEME_KEY]
|
||||||
|
applyGlobalTheme(this.globalData.themeConfig)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 自动登录逻辑(抽离为独立方法,便于维护)
|
||||||
|
*/
|
||||||
|
async handleAutoLogin() {
|
||||||
|
try {
|
||||||
|
const res = await uni.login()
|
||||||
|
if (!res.code) {
|
||||||
|
throw new Error('获取微信code失败')
|
||||||
|
}
|
||||||
|
const tokenData = await login(res.code)
|
||||||
|
|
||||||
|
if (tokenData?.token) {
|
||||||
|
setToken(tokenData.token)
|
||||||
|
this.globalData.token = tokenData.token // 同步到全局
|
||||||
|
console.log('✅ 自动登录成功')
|
||||||
|
|
||||||
|
await this.syncUserConfig()
|
||||||
|
|
||||||
|
console.log('✅ 跳转到首页')
|
||||||
|
uni.switchTab({
|
||||||
|
url: '/pages/index/index',
|
||||||
|
fail: () => uni.redirectTo({ url: '/pages/index/index' })
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
console.log('❌ 自动登录失败 - 未获取到token')
|
||||||
|
uni.navigateTo({ url: '/pages/login/login' })
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.log('❌ 自动登录失败', err)
|
||||||
|
uni.navigateTo({ url: '/pages/login/login' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 同步用户配置(登录成功后调用,从后端获取主题等配置)
|
||||||
|
*/
|
||||||
|
async syncUserConfig() {
|
||||||
|
try {
|
||||||
|
const res = await getUserConfig()
|
||||||
|
if (res?.theme) {
|
||||||
|
await this.updateTheme(res.theme, false)
|
||||||
|
}
|
||||||
|
console.log('✅ 用户配置同步完成')
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('❌ 用户配置同步失败:', error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 注册激励机制全局事件监听
|
||||||
|
*/
|
||||||
|
registerGrowthListener() {
|
||||||
|
console.log('【激励机制】开始注册全局事件监听:', GROWTH_UPDATE_EVENT)
|
||||||
|
uni.$on(GROWTH_UPDATE_EVENT, (growth) => {
|
||||||
|
console.log('【激励机制】✅ 收到全局事件触发,growth数据:', JSON.stringify(growth))
|
||||||
|
this.handleGrowthIncentives(growth)
|
||||||
|
})
|
||||||
|
console.log('【激励机制】✅ 全局事件监听注册成功')
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 处理成长激励(升级、成就、特权)
|
||||||
|
* @param {Object} growth 成长数据
|
||||||
|
*/
|
||||||
|
handleGrowthIncentives(growth) {
|
||||||
|
console.log('【激励机制】进入 handleGrowthIncentives,growth:', growth)
|
||||||
|
|
||||||
|
if (!growth) {
|
||||||
|
console.log('【激励机制】❌ growth为空,直接返回')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('【激励机制】✅ growth数据有效')
|
||||||
|
console.log('【激励机制】levelUp:', growth.levelUp, 'newAchievements长度:', growth.newAchievements?.length || 0, 'newPrivileges长度:', growth.newPrivileges?.length || 0)
|
||||||
|
|
||||||
|
// 优先级:升级 > 成就 > 特权
|
||||||
|
|
||||||
|
// 1. 检测升级
|
||||||
|
if (growth.levelUp) {
|
||||||
|
console.log('【激励机制】✅ 检测到升级,levelUp:', growth.levelUp)
|
||||||
|
this.showLevelUpCelebration(growth)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 检测新成就
|
||||||
|
if (growth.newAchievements && growth.newAchievements.length > 0) {
|
||||||
|
console.log('【激励机制】✅ 检测到新成就,数量:', growth.newAchievements.length)
|
||||||
|
this.showAchievementCelebration(growth.newAchievements)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 检测新特权
|
||||||
|
// if (growth.newPrivileges && growth.newPrivileges.length > 0) {
|
||||||
|
// console.log('【激励机制】✅ 检测到新特权,数量:', growth.newPrivileges.length)
|
||||||
|
// this.showPrivilegeNotification(growth.newPrivileges)
|
||||||
|
// }
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 显示升级庆祝弹窗
|
||||||
|
*/
|
||||||
|
showLevelUpCelebration(growth) {
|
||||||
|
console.log('【激励机制】进入 showLevelUpCelebration,准备显示升级弹窗')
|
||||||
|
|
||||||
|
const privilegesText = growth.newPrivileges && growth.newPrivileges.length
|
||||||
|
? '\n特权:' + growth.newPrivileges.join('、')
|
||||||
|
: ''
|
||||||
|
|
||||||
|
const content = `等级:Lv.${growth.oldLevel || growth.currentLevel - 1} → Lv.${growth.currentLevel}\n称号:${growth.levelTitle || '暂无'}\n获得经验:+${growth.expGained || 0} EXP${privilegesText}`
|
||||||
|
|
||||||
|
uni.showModal({
|
||||||
|
title: '🎉 恭喜升级!',
|
||||||
|
content: content,
|
||||||
|
showCancel: false,
|
||||||
|
confirmText: '太棒了!',
|
||||||
|
confirmColor: this.globalData.themeConfig?.primaryColor || '#6366F1',
|
||||||
|
success: () => {
|
||||||
|
console.log('【激励机制】升级弹窗已关闭')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('【激励机制】✅ 升级弹窗已显示')
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 显示成就获得弹窗(支持连续展示多个)
|
||||||
|
*/
|
||||||
|
showAchievementCelebration(achievements) {
|
||||||
|
if (!achievements || achievements.length === 0) return
|
||||||
|
|
||||||
|
console.log('【激励机制】进入 showAchievementCelebration,成就数量:', achievements.length)
|
||||||
|
|
||||||
|
const current = achievements[0]
|
||||||
|
const content = `${current.name || ''}\n${current.description || current.desc || ''}\n稀有度:${current.rarityName || current.rarity || '初级'}\n获得经验:+${current.expReward || current.exp || 0} EXP`
|
||||||
|
|
||||||
|
const remainingAchievements = achievements.length > 1 ? achievements.slice(1) : []
|
||||||
|
|
||||||
|
uni.showModal({
|
||||||
|
title: '🏆 获得成就!',
|
||||||
|
content: content,
|
||||||
|
showCancel: false,
|
||||||
|
confirmText: '太棒了!',
|
||||||
|
confirmColor: this.globalData.themeConfig?.primaryColor || '#6366F1',
|
||||||
|
success: () => {
|
||||||
|
console.log('【激励机制】成就弹窗已关闭')
|
||||||
|
if (remainingAchievements.length > 0) {
|
||||||
|
setTimeout(() => {
|
||||||
|
this.showAchievementCelebration(remainingAchievements)
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('【激励机制】✅ 成就弹窗已显示')
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 显示特权解锁提示
|
||||||
|
*/
|
||||||
|
showPrivilegeNotification(privileges) {
|
||||||
|
const privilegeText = privileges.join('、')
|
||||||
|
uni.showToast({
|
||||||
|
title: `解锁新特权: ${privilegeText}`,
|
||||||
|
icon: 'success',
|
||||||
|
duration: 2500
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="app-root" :style="rootVars">
|
||||||
|
<slot></slot>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* 字体加载:仅通过 JS 层 uni.loadFontFace() 加载(base64 data URI) */
|
||||||
|
/* 微信小程序不支持 CSS @font-face 本地路径,已在 loadFonts() 中用 wx.loadFontFace 处理 */
|
||||||
|
|
||||||
|
/* ========== 主题过渡动画(小程序全兼容) ========== */
|
||||||
|
.theme-transition {
|
||||||
|
transition: background-color 0.3s ease,
|
||||||
|
color 0.3s ease,
|
||||||
|
border-color 0.3s ease,
|
||||||
|
box-shadow 0.3s ease,
|
||||||
|
opacity 0.25s ease,
|
||||||
|
transform 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 页面过渡动画关键帧 ========== */
|
||||||
|
@keyframes pageFadeIn {
|
||||||
|
0% { opacity: 0; transform: translateY(20rpx); }
|
||||||
|
100% { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cardEnter {
|
||||||
|
0% { opacity: 0; transform: translateY(16rpx); }
|
||||||
|
100% { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { background-position: -200% 0; }
|
||||||
|
100% { background-position: 200% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 动画工具类 ========== */
|
||||||
|
.page-enter {
|
||||||
|
animation: pageFadeIn 0.3s ease-out both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-enter {
|
||||||
|
animation: cardEnter 0.25s ease-out both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-fade {
|
||||||
|
transition: opacity 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shimmer-bg {
|
||||||
|
background: linear-gradient(90deg,
|
||||||
|
rgba(0, 0, 0, 0.04) 25%,
|
||||||
|
rgba(0, 0, 0, 0.08) 50%,
|
||||||
|
rgba(0, 0, 0, 0.04) 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-root {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 全局基础样式(动态值由 :root CSS 变量决定) ========== */
|
||||||
|
page {
|
||||||
|
background: var(--bgColor, #F7F3ED);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
font-size: 28rpx;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 卡片基础样式 */
|
||||||
|
.card {
|
||||||
|
background: var(--cardBgColor, #FFFFFF);
|
||||||
|
border-radius: 12rpx;
|
||||||
|
box-shadow: 0 6rpx 20rpx rgba(0, 0, 0, 0.04);
|
||||||
|
padding: 30rpx;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 旧版主按钮基础样式(保留兼容,优先使用 styles/buttons.scss 中的 .btn--primary) */
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--gradientSoft, linear-gradient(135deg, #6366F1 0%, #818CF8 100%));
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 14rpx;
|
||||||
|
height: 88rpx;
|
||||||
|
line-height: 88rpx;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 32rpx;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 文本样式 - 跟随主题 */
|
||||||
|
.text-primary {
|
||||||
|
color: var(--textPrimary, #2c3e50);
|
||||||
|
}
|
||||||
|
.text-secondary {
|
||||||
|
color: var(--textSecondary, #7f8c8d);
|
||||||
|
}
|
||||||
|
.text-tertiary {
|
||||||
|
color: var(--textTertiary, #909399);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 边框样式 - 跟随主题 */
|
||||||
|
.border {
|
||||||
|
border: 1rpx solid var(--borderLight, #F2EDE6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 金额颜色(收入/支出)- 跟随主题 */
|
||||||
|
.income-color {
|
||||||
|
color: var(--incomeColor, #67C23A);
|
||||||
|
}
|
||||||
|
.expend-color {
|
||||||
|
color: var(--expendColor, #F56C6C);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 清除默认样式(小程序通用优化) ========== */
|
||||||
|
button {
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
button::after {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
view, text, image {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 全局点击交互动效(丝滑手感) ========== */
|
||||||
|
/* 通用按压反馈:轻微缩放 + 透明度 */
|
||||||
|
.tap-feedback {
|
||||||
|
transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
opacity 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
.tap-feedback:active {
|
||||||
|
transform: scale(0.96);
|
||||||
|
opacity: 0.78;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 卡片按压反馈:轻微缩放 + 阴影减弱 */
|
||||||
|
.tap-card {
|
||||||
|
transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
box-shadow 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
.tap-card:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
opacity: 0.92;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 涟漪波纹效果 */
|
||||||
|
.ripple {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.ripple::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.32);
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
transition: width 0.45s ease, height 0.45s ease, opacity 0.6s ease;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.ripple:active::after {
|
||||||
|
width: 220rpx;
|
||||||
|
height: 220rpx;
|
||||||
|
opacity: 1;
|
||||||
|
transition: 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 滑动进场动画(列表/卡片用) ========== */
|
||||||
|
@keyframes slideUpFade {
|
||||||
|
0% { opacity: 0; transform: translateY(24rpx); }
|
||||||
|
100% { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
@keyframes slideRightFade {
|
||||||
|
0% { opacity: 0; transform: translateX(-24rpx); }
|
||||||
|
100% { opacity: 1; transform: translateX(0); }
|
||||||
|
}
|
||||||
|
@keyframes scaleFade {
|
||||||
|
0% { opacity: 0; transform: scale(0.92); }
|
||||||
|
100% { opacity: 1; transform: scale(1); }
|
||||||
|
}
|
||||||
|
/* 主题色脉冲光晕 - 通过 CSS 变量驱动 */
|
||||||
|
@keyframes pulseGlow {
|
||||||
|
0%, 100% { box-shadow: 0 0 0 0 rgba(var(--primaryColorRGB, 99, 102, 241), 0.4); }
|
||||||
|
50% { box-shadow: 0 0 0 16rpx rgba(var(--primaryColorRGB, 99, 102, 241), 0); }
|
||||||
|
}
|
||||||
|
@keyframes rotate360 {
|
||||||
|
from { transform: rotate(0); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
@keyframes shake {
|
||||||
|
0%, 100% { transform: translateX(0); }
|
||||||
|
25% { transform: translateX(-6rpx); }
|
||||||
|
75% { transform: translateX(6rpx); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-up { animation: slideUpFade 0.35s cubic-bezier(0.4, 0, 0.2, 1) both; }
|
||||||
|
.slide-right { animation: slideRightFade 0.35s cubic-bezier(0.4, 0, 0.2, 1) both; }
|
||||||
|
.scale-fade { animation: scaleFade 0.3s cubic-bezier(0.4, 0, 0.2, 1) both; }
|
||||||
|
.pulse-glow { animation: pulseGlow 2s ease-in-out infinite; }
|
||||||
|
.spin-slow { animation: rotate360 12s linear infinite; }
|
||||||
|
.shake-anim { animation: shake 0.4s ease; }
|
||||||
|
|
||||||
|
/* 交错延迟(用于列表元素错开进场) */
|
||||||
|
.delay-1 { animation-delay: 0.05s; }
|
||||||
|
.delay-2 { animation-delay: 0.1s; }
|
||||||
|
.delay-3 { animation-delay: 0.15s; }
|
||||||
|
.delay-4 { animation-delay: 0.2s; }
|
||||||
|
.delay-5 { animation-delay: 0.25s; }
|
||||||
|
|
||||||
|
/* ========== 文字交互态 ========== */
|
||||||
|
.text-link {
|
||||||
|
transition: opacity 0.2s ease, color 0.2s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.text-link:active { opacity: 0.6; }
|
||||||
|
|
||||||
|
/* ========== 主题渐变文字工具(CSS 变量驱动) ========== */
|
||||||
|
.text-gradient {
|
||||||
|
background: var(--gradientSoft, linear-gradient(135deg, #6366F1 0%, #818CF8 100%));
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,650 @@
|
|||||||
|
import {
|
||||||
|
clearCache,
|
||||||
|
getPreferences
|
||||||
|
} from '../utils/utils.js'
|
||||||
|
|
||||||
|
// 所有接口统一出口
|
||||||
|
import request from '../utils/request.js'
|
||||||
|
|
||||||
|
|
||||||
|
// ===================== 用户相关接口 =====================
|
||||||
|
/**
|
||||||
|
* 微信登录
|
||||||
|
* @param {string} code - 微信登录code
|
||||||
|
* @returns {Promise<{token: string, userInfo: Object}>}
|
||||||
|
*/
|
||||||
|
export const login = (code) => request({
|
||||||
|
url: '/user/login',
|
||||||
|
method: 'POST',
|
||||||
|
data: {
|
||||||
|
code
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户信息
|
||||||
|
* @returns {Promise<Object>}
|
||||||
|
*/
|
||||||
|
export const getUserInfo = () => request({
|
||||||
|
url: '/user/info',
|
||||||
|
method: 'POST'
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新用户信息
|
||||||
|
* @returns {Promise<Object>}
|
||||||
|
*/
|
||||||
|
export const editUserInfo = (params) => request({
|
||||||
|
url: '/user/edit',
|
||||||
|
method: 'POST',
|
||||||
|
data: params
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 退出登录
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
export const logout = () => request({
|
||||||
|
url: '/user/logout',
|
||||||
|
method: 'POST'
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
// ===================== 用户配置相关接口(新增) =====================
|
||||||
|
/**
|
||||||
|
* 获取用户配置(主题)
|
||||||
|
* @returns {Promise<Object>} - {theme: string}
|
||||||
|
*/
|
||||||
|
export const getUserConfig = () => request({
|
||||||
|
url: '/user/config/info',
|
||||||
|
method: 'POST'
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 编辑用户配置
|
||||||
|
* @param {Object} params - 配置参数
|
||||||
|
* @param {string} [params.theme] - 主题
|
||||||
|
* @returns {Promise<Object>} - 更新后的配置信息
|
||||||
|
*/
|
||||||
|
export const editUserConfig = (params) => request({
|
||||||
|
url: '/user/config/edit',
|
||||||
|
method: 'POST',
|
||||||
|
data: params
|
||||||
|
})
|
||||||
|
|
||||||
|
// ===================== 头像相关接口 =====================
|
||||||
|
/**
|
||||||
|
* @typedef {Object} AvatarItem 头像项
|
||||||
|
* @property {string} id - 头像ID
|
||||||
|
* @property {string} url - 头像图片地址
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取头像列表
|
||||||
|
* @returns {Promise<AvatarItem[]>}
|
||||||
|
*/
|
||||||
|
export const getAvatarList = () => request({
|
||||||
|
url: '/user/avatar/list',
|
||||||
|
method: 'POST'
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
// ===================== 待办相关接口 =====================
|
||||||
|
/**
|
||||||
|
* 添加待办
|
||||||
|
* @param {Object} data - 待办数据
|
||||||
|
* @param {string} data.content - 待办内容
|
||||||
|
* @param {string} data.date - 日期 YYYY-MM-DD
|
||||||
|
* @param {string} [data.remindTime] - 提醒时间
|
||||||
|
* @param {number} [data.repeatType=0] - 重复类型
|
||||||
|
* @returns {Promise<TodoItem>}
|
||||||
|
*/
|
||||||
|
export const addTodo = (data) => request({
|
||||||
|
url: '/todo/add',
|
||||||
|
method: 'POST',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取待办列表
|
||||||
|
* @param {Object} params - 查询参数
|
||||||
|
* @param {string} params.date - 日期 YYYY-MM-DD
|
||||||
|
* @param {number} [params.status] - 状态 0-未完成 1-已完成
|
||||||
|
* @returns {Promise<TodoItem[]>}
|
||||||
|
*/
|
||||||
|
export const getTodoList = (params) => request({
|
||||||
|
url: '/todo/list',
|
||||||
|
method: 'POST',
|
||||||
|
data: params
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除待办
|
||||||
|
* @param {Object} data - 删除参数
|
||||||
|
* @param {string} data.id - 待办ID
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
export const deleteTodo = (data) => request({
|
||||||
|
url: '/todo/delete',
|
||||||
|
method: 'POST',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 完成/取消完成待办
|
||||||
|
* @param {Object} data - 操作参数
|
||||||
|
* @param {string} data.id - 待办ID
|
||||||
|
* @param {number} data.status - 状态 0-取消完成 1-完成
|
||||||
|
* @returns {Promise<TodoItem>}
|
||||||
|
*/
|
||||||
|
export const doneTodo = (data) => request({
|
||||||
|
url: '/todo/done',
|
||||||
|
method: 'POST',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 编辑待办
|
||||||
|
* @param {Object} data - 待办数据
|
||||||
|
* @param {string} data.id - 待办ID
|
||||||
|
* @param {string} [data.content] - 待办内容
|
||||||
|
* @param {string} [data.date] - 日期
|
||||||
|
* @param {string} [data.remindTime] - 提醒时间
|
||||||
|
* @param {number} [data.repeatType] - 重复类型
|
||||||
|
* @returns {Promise<TodoItem>}
|
||||||
|
*/
|
||||||
|
export const editTodo = (data) => request({
|
||||||
|
url: '/todo/edit',
|
||||||
|
method: 'POST',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
|
||||||
|
// ===================== 账单相关接口 =====================
|
||||||
|
/**
|
||||||
|
* 添加账单
|
||||||
|
* @param {Object} data - 账单数据
|
||||||
|
* @param {number} data.type - 类型 1-支出 2-收入 3-转账
|
||||||
|
* @param {string} data.money - 金额
|
||||||
|
* @param {string} data.cate - 分类
|
||||||
|
* @param {string} [data.note] - 备注
|
||||||
|
* @param {string} data.date - 日期 YYYY-MM-DD
|
||||||
|
* @param {string} [data.fromAccount] - 转出账户
|
||||||
|
* @param {string} [data.toAccount] - 转入账户
|
||||||
|
* @returns {Promise<BillItem>}
|
||||||
|
*/
|
||||||
|
export const addBill = (data) => request({
|
||||||
|
url: '/bill/add',
|
||||||
|
method: 'POST',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 编辑账单
|
||||||
|
* @param {Object} data - 账单数据
|
||||||
|
* @param {string} data.id - 账单ID
|
||||||
|
* @param {number} [data.type] - 类型
|
||||||
|
* @param {string} [data.money] - 金额
|
||||||
|
* @param {string} [data.cate] - 分类
|
||||||
|
* @param {string} [data.note] - 备注
|
||||||
|
* @param {string} [data.date] - 日期
|
||||||
|
* @param {string} [data.fromAccount] - 转出账户
|
||||||
|
* @param {string} [data.toAccount] - 转入账户
|
||||||
|
* @returns {Promise<BillItem>}
|
||||||
|
*/
|
||||||
|
export const editBill = (data) => request({
|
||||||
|
url: '/bill/edit',
|
||||||
|
method: 'POST',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取账单列表
|
||||||
|
* @param {Object} params - 查询参数
|
||||||
|
* @param {string} params.date - 日期 YYYY-MM-DD
|
||||||
|
* @param {number} [params.type] - 类型 1-支出 2-收入 3-转账
|
||||||
|
* @param {string} [params.keyword] - 搜索关键词
|
||||||
|
* @returns {Promise<BillItem[]>}
|
||||||
|
*/
|
||||||
|
export const getBillList = (params) => request({
|
||||||
|
url: '/bill/list',
|
||||||
|
method: 'POST',
|
||||||
|
data: params
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除账单
|
||||||
|
* @param {Object} params - 删除参数
|
||||||
|
* @param {string} params.id - 账单ID
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
export const deleteBill = (params) => request({
|
||||||
|
url: '/bill/delete',
|
||||||
|
method: 'POST',
|
||||||
|
data: params
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取月度统计
|
||||||
|
* @param {Object} [params] - 查询参数
|
||||||
|
* @param {string} [params.month] - 月份 YYYY-MM
|
||||||
|
* @returns {Promise<MonthStat>}
|
||||||
|
*/
|
||||||
|
export const getMonthStat = (params = {}) => request({
|
||||||
|
url: '/bill/stat/month',
|
||||||
|
method: 'POST',
|
||||||
|
data: params
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取分类统计
|
||||||
|
* @param {Object} params - 查询参数
|
||||||
|
* @param {string} params.month - 月份 YYYY-MM
|
||||||
|
* @param {number} [params.type] - 类型 1-支出 2-收入
|
||||||
|
* @returns {Promise<Object[]>}
|
||||||
|
*/
|
||||||
|
export const getCateStat = (params) => request({
|
||||||
|
url: '/bill/stat/category',
|
||||||
|
method: 'POST',
|
||||||
|
data: params
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出账单
|
||||||
|
* @param {Object} params - 导出参数
|
||||||
|
* @param {string} params.startDate - 开始日期 YYYY-MM-DD
|
||||||
|
* @param {string} params.endDate - 结束日期 YYYY-MM-DD
|
||||||
|
* @param {number} [params.type] - 账单类型 1-支出 2-收入 3-转账
|
||||||
|
* @returns {Promise<ExportBillRes>}
|
||||||
|
*/
|
||||||
|
export const exportBill = (params) => request({
|
||||||
|
url: '/bill/export',
|
||||||
|
method: 'POST',
|
||||||
|
data: params
|
||||||
|
})
|
||||||
|
|
||||||
|
// ===================== 预算相关接口(新增) =====================
|
||||||
|
/**
|
||||||
|
* 获取月度预算
|
||||||
|
* @param {Object} [params] - 查询参数
|
||||||
|
* @param {string} [params.month] - 月份 YYYY-MM,默认当前月
|
||||||
|
* @returns {Promise<BudgetItem>}
|
||||||
|
*/
|
||||||
|
export const getMonthBudget = (params = {}) => request({
|
||||||
|
url: '/budget/get',
|
||||||
|
method: 'POST',
|
||||||
|
data: params
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置/更新月度预算
|
||||||
|
* @param {Object} data - 预算数据
|
||||||
|
* @param {number} data.amount - 预算金额
|
||||||
|
* @param {string} [data.month] - 月份 YYYY-MM,默认当前月
|
||||||
|
* @returns {Promise<BudgetItem>}
|
||||||
|
*/
|
||||||
|
export const setMonthBudget = (data) => request({
|
||||||
|
url: '/budget/set',
|
||||||
|
method: 'POST',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除月度预算
|
||||||
|
* @param {Object} params - 删除参数
|
||||||
|
* @param {string} [params.month] - 月份 YYYY-MM,默认当前月
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
export const deleteMonthBudget = (params = {}) => request({
|
||||||
|
url: '/budget/delete',
|
||||||
|
method: 'POST',
|
||||||
|
data: params
|
||||||
|
})
|
||||||
|
|
||||||
|
// ===================== 分类管理接口 =====================
|
||||||
|
/**
|
||||||
|
* 获取自定义分类列表
|
||||||
|
* @param {number} [type] - 类型 1-支出 2-收入
|
||||||
|
* @returns {Promise<Object[]>}
|
||||||
|
*/
|
||||||
|
export const getCustomCateList = (type) => request({
|
||||||
|
url: '/category/list',
|
||||||
|
method: 'POST',
|
||||||
|
data: type ? {
|
||||||
|
type
|
||||||
|
} : {}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加自定义分类
|
||||||
|
* @param {Object} data - 分类数据
|
||||||
|
* @param {string} data.name - 分类名称
|
||||||
|
* @param {number} data.type - 类型 1-支出 2-收入
|
||||||
|
* @param {string} [data.icon] - 图标
|
||||||
|
* @param {string} [data.color] - 颜色
|
||||||
|
* @returns {Promise<Object>}
|
||||||
|
*/
|
||||||
|
export const addCustomCate = (data) => request({
|
||||||
|
url: '/category/add',
|
||||||
|
method: 'POST',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除自定义分类
|
||||||
|
* @param {Object} data - 删除参数
|
||||||
|
* @param {string} data.id - 分类ID
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
export const deleteCustomCate = (data) => request({
|
||||||
|
url: '/category/delete',
|
||||||
|
method: 'POST',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
|
||||||
|
// ===================== 心情相关接口 =====================
|
||||||
|
/**
|
||||||
|
* 获取某天的心情
|
||||||
|
* @param {Object} params - 查询参数
|
||||||
|
* @param {string} params.date - 日期 YYYY-MM-DD
|
||||||
|
* @returns {Promise<MoodItem>}
|
||||||
|
*/
|
||||||
|
export const getMoodByDate = (params) => request({
|
||||||
|
url: '/mood/get',
|
||||||
|
method: 'POST',
|
||||||
|
data: params
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存心情
|
||||||
|
* @param {Object} data - 心情数据
|
||||||
|
* @param {string} data.date - 日期 YYYY-MM-DD
|
||||||
|
* @param {string} data.emoji - 心情表情
|
||||||
|
* @param {string} [data.content] - 心情内容(可选)
|
||||||
|
* @returns {Promise<MoodItem>}
|
||||||
|
*/
|
||||||
|
export const saveMood = (data) => request({
|
||||||
|
url: '/mood/save',
|
||||||
|
method: 'POST',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
|
||||||
|
export const getAiMoodResult = (moodId) => request({
|
||||||
|
url: '/ai/mood/result',
|
||||||
|
method: 'POST',
|
||||||
|
data: { mood_id: moodId }
|
||||||
|
})
|
||||||
|
|
||||||
|
export const regenerateAiMood = (moodId) => request({
|
||||||
|
url: '/ai/mood/regenerate',
|
||||||
|
method: 'POST',
|
||||||
|
data: { mood_id: moodId }
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取心情列表
|
||||||
|
* @param {Object} params - 查询参数
|
||||||
|
* @param {string} [params.start_date] - 开始日期 YYYY-MM-DD
|
||||||
|
* @param {string} [params.end_date] - 结束日期 YYYY-MM-DD
|
||||||
|
* @returns {Promise<MoodItem[]>}
|
||||||
|
*/
|
||||||
|
export const getMoodList = (params = {}) => request({
|
||||||
|
url: '/mood/list',
|
||||||
|
method: 'POST',
|
||||||
|
data: params
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除心情
|
||||||
|
* @param {Object} data - 删除参数
|
||||||
|
* @param {string} data.id - 心情ID
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
export const deleteMood = (data) => request({
|
||||||
|
url: '/mood/delete',
|
||||||
|
method: 'POST',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
export const getCaptcha = (data) => request({
|
||||||
|
url: '/captcha/digit',
|
||||||
|
method: 'POST',
|
||||||
|
needLogin: false,
|
||||||
|
showLoading: false
|
||||||
|
})
|
||||||
|
|
||||||
|
// ===================== 邮箱相关接口 =====================
|
||||||
|
/**
|
||||||
|
* 获取邮箱验证码
|
||||||
|
* @param {Object} data - 请求参数
|
||||||
|
* @param {string} data.email - 邮箱地址
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
export const sendEmailCode = (data) => request({
|
||||||
|
url: '/user/email/code',
|
||||||
|
method: 'POST',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置/修改邮箱
|
||||||
|
* @param {Object} data - 请求参数
|
||||||
|
* @param {string} data.email - 邮箱地址
|
||||||
|
* @param {string} data.code - 验证码
|
||||||
|
* @returns {Promise<Object>}
|
||||||
|
*/
|
||||||
|
export const setUserEmail = (data) => request({
|
||||||
|
url: '/user/email/set',
|
||||||
|
method: 'POST',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
|
||||||
|
export const emailLogin = (data) => request({
|
||||||
|
url: '/user/email/login',
|
||||||
|
method: 'POST',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
|
||||||
|
export const register = (data) => request({
|
||||||
|
url: '/user/register',
|
||||||
|
method: 'POST',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
|
||||||
|
export const resetPassword = (data) => request({
|
||||||
|
url: '/user/reset-password',
|
||||||
|
method: 'POST',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
export const changePassword = (data) => request({
|
||||||
|
url: '/user/change-password',
|
||||||
|
method: 'POST',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const checkEmailExists = (data) => request({
|
||||||
|
url: '/user/email/exists',
|
||||||
|
method: 'POST',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
|
||||||
|
export const bindWechat = (data) => request({
|
||||||
|
url: '/user/bind-wechat',
|
||||||
|
method: 'POST',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
// ===================== 复盘相关接口(新增) =====================
|
||||||
|
/**
|
||||||
|
* 保存复盘数据
|
||||||
|
* @param {Object} data - 复盘数据
|
||||||
|
* @param {string} data.date - 日期 YYYY-MM-DD
|
||||||
|
* @param {string} data.method - 复盘方法 kpt/grai
|
||||||
|
* @param {Object} [data.kpt] - KPT数据
|
||||||
|
* @param {string} data.kpt.keep - 保持
|
||||||
|
* @param {string} data.kpt.problem - 问题
|
||||||
|
* @param {string} data.kpt.try - 尝试
|
||||||
|
* @param {Object} [data.grai] - GRAI数据
|
||||||
|
* @param {string} data.grai.goal - 目标
|
||||||
|
* @param {string} data.grai.result - 结果
|
||||||
|
* @param {string} data.grai.analysis - 分析
|
||||||
|
* @param {string} data.grai.insight - 洞察
|
||||||
|
* @returns {Promise<ReviewItem>}
|
||||||
|
*/
|
||||||
|
export const saveReview = (data) => request({
|
||||||
|
url: '/review/save',
|
||||||
|
method: 'POST',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取单条复盘数据
|
||||||
|
* @param {Object} params - 查询参数
|
||||||
|
* @param {string} params.date - 日期 YYYY-MM-DD
|
||||||
|
* @returns {Promise<ReviewItem>}
|
||||||
|
*/
|
||||||
|
export const getReviewByDate = (params) => request({
|
||||||
|
url: '/review/get',
|
||||||
|
method: 'POST',
|
||||||
|
data: params
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取复盘列表
|
||||||
|
* @param {Object} params - 查询参数
|
||||||
|
* @param {string} [params.start_date] - 开始日期 YYYY-MM-DD
|
||||||
|
* @param {string} [params.end_date] - 结束日期 YYYY-MM-DD
|
||||||
|
* @param {string} [params.keyword]
|
||||||
|
* @param {string} [params.page]
|
||||||
|
* @param {string} [params.page_size]
|
||||||
|
* @returns {Promise<ReviewItem[]>}
|
||||||
|
*/
|
||||||
|
export const getReviewList = (params = {}) => request({
|
||||||
|
url: '/review/list',
|
||||||
|
method: 'POST',
|
||||||
|
data: params
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除复盘
|
||||||
|
* @param {Object} params - 删除参数
|
||||||
|
* @param {string} params.id - 复盘ID
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
export const deleteReview = (params) => request({
|
||||||
|
url: '/review/delete',
|
||||||
|
method: 'POST',
|
||||||
|
data: params
|
||||||
|
})
|
||||||
|
|
||||||
|
// ===================== 成长系统相关接口 =====================
|
||||||
|
/**
|
||||||
|
* 获取成长信息
|
||||||
|
* @returns {Promise<Object>}
|
||||||
|
*/
|
||||||
|
export const getGrowthInfo = () => request({
|
||||||
|
url: '/growth/info',
|
||||||
|
method: 'POST'
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取成就列表
|
||||||
|
* @returns {Promise<Object[]>}
|
||||||
|
*/
|
||||||
|
export const getGrowthAchievements = () => request({
|
||||||
|
url: '/growth/achievements',
|
||||||
|
method: 'POST'
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取成长规则
|
||||||
|
* @returns {Promise<Object>}
|
||||||
|
*/
|
||||||
|
export const getGrowthRules = () => request({
|
||||||
|
url: '/growth/rules',
|
||||||
|
method: 'POST'
|
||||||
|
})
|
||||||
|
|
||||||
|
// ===================== AI 智能分类接口 =====================
|
||||||
|
/**
|
||||||
|
* AI 智能分类 - 提交内容后由后端自动分类并批量创建记录
|
||||||
|
* @param {Object} data - 分类数据
|
||||||
|
* @param {string} data.content - 用户输入的文本内容
|
||||||
|
* @param {string} [data.date] - 日期 YYYY-MM-DD
|
||||||
|
* @returns {Promise<{ items: Array<{category: string, data: Object}>, message: string }>}
|
||||||
|
* items[i].category: 'todo' | 'bill' | 'mood' | 'review'
|
||||||
|
* items[i].data - 对应类型的记录数据:
|
||||||
|
* todo: { id, title, priority, date, end_time, ... }
|
||||||
|
* bill: { id, money, cate, note, date, ... }
|
||||||
|
* mood: { id, content, date, ... }
|
||||||
|
* review: { id, content, method, date, ... }
|
||||||
|
*/
|
||||||
|
export const aiClassify = (data) => request({
|
||||||
|
url: '/ai/classify',
|
||||||
|
method: 'POST',
|
||||||
|
data,
|
||||||
|
showLoading: false
|
||||||
|
})
|
||||||
|
|
||||||
|
// ===================== 首页聚合接口 =====================
|
||||||
|
/**
|
||||||
|
* 获取首页每日聚合数据
|
||||||
|
* 替代首页并发调用 getTodoList + getBillList + getMoodByDate
|
||||||
|
* @param {Object} [params] - 查询参数
|
||||||
|
* @param {string} [params.date] - 日期 YYYY-MM-DD,不传默认今天
|
||||||
|
* @returns {Promise<Object>} 聚合数据,包含 todo/bill/mood/review/growth
|
||||||
|
*/
|
||||||
|
export const getDashboardDaily = (params = {}) => request({
|
||||||
|
url: '/dashboard/daily',
|
||||||
|
method: 'POST',
|
||||||
|
data: params,
|
||||||
|
showLoading: false
|
||||||
|
})
|
||||||
|
|
||||||
|
// ===================== 待办统计接口 =====================
|
||||||
|
/**
|
||||||
|
* 获取待办统计数据
|
||||||
|
* @param {Object} params - 查询参数
|
||||||
|
* @param {string} params.start_date - 开始日期 YYYY-MM-DD
|
||||||
|
* @param {string} params.end_date - 结束日期 YYYY-MM-DD
|
||||||
|
* @returns {Promise<Object>} 统计数据
|
||||||
|
*/
|
||||||
|
export const getTodoStat = (params) => request({
|
||||||
|
url: '/todo/stat',
|
||||||
|
method: 'POST',
|
||||||
|
data: params,
|
||||||
|
showLoading: false
|
||||||
|
})
|
||||||
|
|
||||||
|
// ===================== 心情统计接口 =====================
|
||||||
|
/**
|
||||||
|
* 获取心情统计数据
|
||||||
|
* @param {Object} params - 查询参数
|
||||||
|
* @param {string} params.month - 月份 YYYY-MM
|
||||||
|
* @returns {Promise<Object>} 统计数据
|
||||||
|
*/
|
||||||
|
export const getMoodStat = (params) => request({
|
||||||
|
url: '/mood/stat',
|
||||||
|
method: 'POST',
|
||||||
|
data: params,
|
||||||
|
showLoading: false
|
||||||
|
})
|
||||||
|
|
||||||
|
// ===================== 账单聚合统计接口 =====================
|
||||||
|
/**
|
||||||
|
* 获取账单聚合统计(整合 getMonthStat + getCateStat)
|
||||||
|
* @param {Object} params - 查询参数
|
||||||
|
* @param {string} params.start_date - 开始日期 YYYY-MM-DD
|
||||||
|
* @param {string} params.end_date - 结束日期 YYYY-MM-DD
|
||||||
|
* @param {number} [params.type] - 类型筛选 1-支出 2-收入
|
||||||
|
* @returns {Promise<Object>} 聚合统计数据
|
||||||
|
*/
|
||||||
|
export const getBillAggregateStat = (params) => request({
|
||||||
|
url: '/bill/stat/aggregate',
|
||||||
|
method: 'POST',
|
||||||
|
data: params,
|
||||||
|
showLoading: false
|
||||||
|
})
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<template>
|
||||||
|
<view
|
||||||
|
class="app-card"
|
||||||
|
:class="{ 'app-card--clickable': clickable, 'app-card--active': isActive && clickable }"
|
||||||
|
:style="cardStyle"
|
||||||
|
@touchstart="onTouchStart"
|
||||||
|
@touchend="onTouchEnd"
|
||||||
|
@touchcancel="onTouchEnd"
|
||||||
|
>
|
||||||
|
<slot></slot>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import themeMixin from '@/mixins/themeMixin.js'
|
||||||
|
import { THEME_CHANGE_EVENT } from '@/utils/theme'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'AppCard',
|
||||||
|
mixins: [themeMixin],
|
||||||
|
props: {
|
||||||
|
padding: {
|
||||||
|
type: Number,
|
||||||
|
default: 24
|
||||||
|
},
|
||||||
|
radius: {
|
||||||
|
type: Number,
|
||||||
|
default: 20
|
||||||
|
},
|
||||||
|
marginBottom: {
|
||||||
|
type: Number,
|
||||||
|
default: 24
|
||||||
|
},
|
||||||
|
showShadow: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
clickable: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
isActive: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
cardStyle() {
|
||||||
|
const style = {
|
||||||
|
backgroundColor: this.themeConfig.cardBgColor,
|
||||||
|
padding: `${this.padding}rpx`,
|
||||||
|
borderRadius: `${this.radius}rpx`,
|
||||||
|
marginBottom: `${this.marginBottom}rpx`
|
||||||
|
}
|
||||||
|
if (this.showShadow) {
|
||||||
|
style.boxShadow = `0 4rpx 16rpx ${this.themeConfig.shadowColor || 'rgba(0, 0, 0, 0.08)'}`
|
||||||
|
}
|
||||||
|
return style
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this.syncThemeConfig()
|
||||||
|
this.watchThemeChange()
|
||||||
|
},
|
||||||
|
beforeUnmount() {
|
||||||
|
if (this.themeChangeHandler) {
|
||||||
|
uni.$off(THEME_CHANGE_EVENT, this.themeChangeHandler)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
onTouchStart() {
|
||||||
|
if (this.clickable) {
|
||||||
|
this.isActive = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onTouchEnd() {
|
||||||
|
this.isActive = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.app-card {
|
||||||
|
box-sizing: border-box;
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-card--clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-card--active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
<template>
|
||||||
|
<view v-if="visible" class="bottom-sheet-wrapper" @touchmove.stop.prevent>
|
||||||
|
<view class="mask" :class="{ 'mask-active': animating }" @click="handleMaskClick" @touchmove.stop.prevent></view>
|
||||||
|
<view class="sheet" :class="{ 'sheet-up': animating }" :style="sheetStyle">
|
||||||
|
<view class="handle-bar"></view>
|
||||||
|
<view class="header" :style="headerStyle">
|
||||||
|
<text class="title" :style="titleStyle">{{ title }}</text>
|
||||||
|
<view
|
||||||
|
v-if="showClose"
|
||||||
|
class="close-btn btn btn--icon"
|
||||||
|
@click="handleClose"
|
||||||
|
hover-class="close-btn-hover"
|
||||||
|
>
|
||||||
|
<text class="close-icon">×</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<scroll-view scroll-y class="body">
|
||||||
|
<slot></slot>
|
||||||
|
</scroll-view>
|
||||||
|
<view class="footer" :style="footerStyle">
|
||||||
|
<slot name="footer"></slot>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import themeMixin from '@/mixins/themeMixin.js'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'BottomSheet',
|
||||||
|
mixins: [themeMixin],
|
||||||
|
props: {
|
||||||
|
visible: { type: Boolean, default: false },
|
||||||
|
title: { type: String, default: '' },
|
||||||
|
height: { type: String, default: '85vh' },
|
||||||
|
showClose: { type: Boolean, default: true },
|
||||||
|
maskClosable: { type: Boolean, default: true }
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
animating: false,
|
||||||
|
closeTimer: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
sheetStyle() {
|
||||||
|
return {
|
||||||
|
maxHeight: this.height,
|
||||||
|
background: this.themeConfig.cardBgColor
|
||||||
|
}
|
||||||
|
},
|
||||||
|
headerStyle() {
|
||||||
|
return {
|
||||||
|
borderBottom: `2rpx solid ${this.themeConfig.borderLight}`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
closeBtnStyle() {
|
||||||
|
return {
|
||||||
|
background: this.themeConfig.borderLight
|
||||||
|
}
|
||||||
|
},
|
||||||
|
titleStyle() {
|
||||||
|
return {
|
||||||
|
color: this.themeConfig.textPrimary
|
||||||
|
}
|
||||||
|
},
|
||||||
|
footerStyle() {
|
||||||
|
return {
|
||||||
|
borderTop: `2rpx solid ${this.themeConfig.borderLight}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
visible(val) {
|
||||||
|
if (val) {
|
||||||
|
this.animating = false
|
||||||
|
this.$nextTick(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
this.animating = true
|
||||||
|
}, 30)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this.syncThemeConfig()
|
||||||
|
this.watchThemeChange()
|
||||||
|
},
|
||||||
|
beforeUnmount() {
|
||||||
|
if (this.themeChangeHandler) {
|
||||||
|
uni.$off('themeChanged', this.themeChangeHandler)
|
||||||
|
}
|
||||||
|
if (this.closeTimer) {
|
||||||
|
clearTimeout(this.closeTimer)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
handleMaskClick() {
|
||||||
|
if (!this.maskClosable) return
|
||||||
|
this.handleClose()
|
||||||
|
},
|
||||||
|
handleClose() {
|
||||||
|
this.animating = false
|
||||||
|
this.closeTimer = setTimeout(() => {
|
||||||
|
this.$emit('update:visible', false)
|
||||||
|
this.$emit('close')
|
||||||
|
}, 300)
|
||||||
|
},
|
||||||
|
watchThemeChange() {
|
||||||
|
this.themeChangeHandler = ({ key, config }) => {
|
||||||
|
if (key && config) {
|
||||||
|
this.themeConfig = config
|
||||||
|
}
|
||||||
|
}
|
||||||
|
uni.$on('themeChanged', this.themeChangeHandler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.bottom-sheet-wrapper {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 99999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mask {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0);
|
||||||
|
transition: background 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mask-active {
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
border-radius: 24rpx 24rpx 0 0;
|
||||||
|
transform: translateY(100%);
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
padding-bottom: constant(safe-area-inset-bottom);
|
||||||
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-up {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle-bar {
|
||||||
|
width: 80rpx;
|
||||||
|
height: 8rpx;
|
||||||
|
background: #cccccc;
|
||||||
|
border-radius: 4rpx;
|
||||||
|
margin: 16rpx auto 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 24rpx 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn-hover {
|
||||||
|
transform: scale(0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-icon {
|
||||||
|
font-size: 32rpx;
|
||||||
|
color: var(--textSecondary);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
padding: 24rpx 32rpx;
|
||||||
|
max-height: 60vh;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
padding: 24rpx 32rpx;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
<template>
|
||||||
|
<view class="capsule-filter" :class="{ 'is-scrollable': scrollable }">
|
||||||
|
<view
|
||||||
|
v-for="(option, index) in normalizedOptions"
|
||||||
|
:key="index"
|
||||||
|
class="capsule-item"
|
||||||
|
:class="{ 'is-active': isActive(option) }"
|
||||||
|
:style="getItemStyle(option)"
|
||||||
|
@click="handleSelect(option)"
|
||||||
|
>
|
||||||
|
<text class="capsule-label">{{ getOptionLabel(option) }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import themeMixin from '@/mixins/themeMixin.js'
|
||||||
|
import { getThemeConfigFromStorage, THEME_CHANGE_EVENT } from '@/utils/theme'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'CapsuleFilter',
|
||||||
|
mixins: [themeMixin],
|
||||||
|
props: {
|
||||||
|
options: {
|
||||||
|
type: Array,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
value: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
scrollable: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
type: String,
|
||||||
|
default: 'medium'
|
||||||
|
},
|
||||||
|
color: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this.syncThemeConfig()
|
||||||
|
this.watchThemeChange()
|
||||||
|
},
|
||||||
|
beforeUnmount() {
|
||||||
|
if (this.themeChangeHandler) {
|
||||||
|
uni.$off(THEME_CHANGE_EVENT, this.themeChangeHandler)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
normalizedOptions() {
|
||||||
|
return this.options.map(opt => {
|
||||||
|
if (typeof opt === 'string') {
|
||||||
|
return { label: opt, value: opt }
|
||||||
|
}
|
||||||
|
return opt
|
||||||
|
})
|
||||||
|
},
|
||||||
|
activeColor() {
|
||||||
|
return this.color || this.themeConfig.primaryColor
|
||||||
|
},
|
||||||
|
sizeConfig() {
|
||||||
|
const map = {
|
||||||
|
small: { padding: '8rpx 20rpx', fontSize: '24rpx' },
|
||||||
|
medium: { padding: '12rpx 28rpx', fontSize: '28rpx' },
|
||||||
|
large: { padding: '16rpx 36rpx', fontSize: '32rpx' }
|
||||||
|
}
|
||||||
|
return map[this.size] || map.medium
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
isActive(option) {
|
||||||
|
return option.value === this.value
|
||||||
|
},
|
||||||
|
getOptionLabel(option) {
|
||||||
|
return option.label
|
||||||
|
},
|
||||||
|
getItemStyle(option) {
|
||||||
|
const active = this.isActive(option)
|
||||||
|
return {
|
||||||
|
padding: this.sizeConfig.padding,
|
||||||
|
fontSize: this.sizeConfig.fontSize,
|
||||||
|
backgroundColor: active ? this.activeColor : this.themeConfig.borderColor,
|
||||||
|
color: active ? '#ffffff' : this.themeConfig.textSecondary
|
||||||
|
}
|
||||||
|
},
|
||||||
|
handleSelect(option) {
|
||||||
|
if (option.value === this.value) return
|
||||||
|
this.$emit('change', option.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.capsule-filter {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.capsule-filter.is-scrollable {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.capsule-filter.is-scrollable .capsule-item {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.capsule-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 10rpx;
|
||||||
|
transition: all 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.capsule-label {
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
<template>
|
||||||
|
<view class="center-modal" v-if="visible">
|
||||||
|
<!-- 遮罩层:点击关闭。mask 与 dialog-wrap 是兄弟节点,点击不会相互干扰 -->
|
||||||
|
<view class="cm-mask" @click="handleMaskClick" hover-stop-propagation></view>
|
||||||
|
<!-- 弹窗容器,绝对定位居中 -->
|
||||||
|
<view class="cm-dialog-wrap">
|
||||||
|
<!-- 弹窗主体 -->
|
||||||
|
<view class="cm-dialog" :style="{ backgroundColor: themeConfig.cardBgColor }">
|
||||||
|
<view class="cm-header" v-if="title || $slots.header">
|
||||||
|
<slot name="header">
|
||||||
|
<text class="cm-title" :style="{ color: themeConfig.textPrimary }">{{ title }}</text>
|
||||||
|
</slot>
|
||||||
|
</view>
|
||||||
|
<view class="cm-body" :style="{ maxHeight: maxBodyHeight }">
|
||||||
|
<slot></slot>
|
||||||
|
</view>
|
||||||
|
<view class="cm-footer" v-if="showCancel || showConfirm || $slots.footer">
|
||||||
|
<slot name="footer">
|
||||||
|
<view
|
||||||
|
v-if="showCancel"
|
||||||
|
class="cm-btn cm-btn--cancel"
|
||||||
|
:style="{ background: cancelBg, color: themeConfig.textPrimary }"
|
||||||
|
hover-class="cm-btn--cancel--active"
|
||||||
|
:hover-stay-time="100"
|
||||||
|
@click="handleCancel"
|
||||||
|
>
|
||||||
|
<text class="cm-btn__text">{{ cancelText }}</text>
|
||||||
|
</view>
|
||||||
|
<view
|
||||||
|
v-if="showConfirm"
|
||||||
|
class="cm-btn cm-btn--confirm"
|
||||||
|
:class="{ 'cm-btn--disabled': confirmDisabled }"
|
||||||
|
:style="confirmBtnStyle"
|
||||||
|
hover-class="cm-btn--confirm--active"
|
||||||
|
:hover-stay-time="100"
|
||||||
|
@click="handleConfirm"
|
||||||
|
>
|
||||||
|
<text class="cm-btn__text">{{ confirmText }}</text>
|
||||||
|
</view>
|
||||||
|
</slot>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<!-- 关闭按钮:放在弹窗容器内,与 dialog 平级,避免被 dialog 拦截事件 -->
|
||||||
|
<view
|
||||||
|
v-if="showClose"
|
||||||
|
class="cm-close"
|
||||||
|
:style="{ background: themeConfig.cardBgColor, color: themeConfig.textTertiary, boxShadow: `0 6rpx 20rpx ${themeConfig.shadowColor || 'rgba(0,0,0,0.15)'}` }"
|
||||||
|
hover-class="cm-close--active"
|
||||||
|
:hover-stay-time="100"
|
||||||
|
@click="handleClose"
|
||||||
|
>
|
||||||
|
<FaIcon icon="xmark" :size="22" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import FaIcon from './FaIcon.vue'
|
||||||
|
import themeMixin from '@/mixins/themeMixin.js'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'CenterModal',
|
||||||
|
components: { FaIcon },
|
||||||
|
mixins: [themeMixin],
|
||||||
|
props: {
|
||||||
|
visible: { type: Boolean, default: false },
|
||||||
|
title: { type: String, default: '' },
|
||||||
|
showClose: { type: Boolean, default: true },
|
||||||
|
showCancel: { type: Boolean, default: true },
|
||||||
|
showConfirm: { type: Boolean, default: true },
|
||||||
|
maskClosable: { type: Boolean, default: true },
|
||||||
|
cancelText: { type: String, default: '取消' },
|
||||||
|
confirmText: { type: String, default: '确定' },
|
||||||
|
confirmColor: { type: String, default: '' },
|
||||||
|
confirmDisabled: { type: Boolean, default: false },
|
||||||
|
maxBodyHeight: { type: String, default: '70vh' }
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
/**
|
||||||
|
* 确认按钮样式:直接应用主题渐变色(不依赖 CSS 变量),确保主题切换立即生效
|
||||||
|
*/
|
||||||
|
confirmBtnStyle() {
|
||||||
|
const bg = this.confirmColor || this.themeConfig.gradientSoft
|
||||||
|
return {
|
||||||
|
background: bg,
|
||||||
|
color: '#ffffff',
|
||||||
|
boxShadow: `0 6rpx 18rpx ${this.themeConfig.shadowColor || 'rgba(198, 142, 63, 0.35)'}`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
cancelBg() {
|
||||||
|
// 跟随主题色:使用 inputBgColor 创造与主题协调的次按钮背景
|
||||||
|
return this.themeConfig.inputBgColor || 'rgba(0, 0, 0, 0.06)'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
handleMaskClick() {
|
||||||
|
if (!this.maskClosable) return
|
||||||
|
this.handleClose()
|
||||||
|
},
|
||||||
|
handleClose() {
|
||||||
|
if (!this.visible) return
|
||||||
|
this.$emit('update:visible', false)
|
||||||
|
this.$emit('close')
|
||||||
|
},
|
||||||
|
handleCancel() {
|
||||||
|
if (!this.visible) return
|
||||||
|
this.$emit('update:visible', false)
|
||||||
|
this.$emit('close')
|
||||||
|
this.$emit('cancel')
|
||||||
|
},
|
||||||
|
handleConfirm() {
|
||||||
|
if (!this.visible) return
|
||||||
|
if (this.confirmDisabled) return
|
||||||
|
this.$emit('confirm')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.center-modal {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 99999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-mask {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
animation: cm-fadeIn 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cm-fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 弹窗容器:用绝对定位 + transform 居中,简单可靠 */
|
||||||
|
.cm-dialog-wrap {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
animation: cm-zoomIn 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cm-zoomIn {
|
||||||
|
from { transform: translate(-50%, -50%) scale(0.92); opacity: 0; }
|
||||||
|
to { transform: translate(-50%, -50%) scale(1); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-dialog {
|
||||||
|
position: relative;
|
||||||
|
width: 80vw;
|
||||||
|
max-width: 600rpx;
|
||||||
|
max-height: 85vh;
|
||||||
|
border-radius: 24rpx;
|
||||||
|
padding: 56rpx 40rpx 40rpx;
|
||||||
|
box-shadow: 0 20rpx 60rpx rgba(0, 0, 0, 0.18);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-title {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-body {
|
||||||
|
font-size: 28rpx;
|
||||||
|
line-height: 1.6;
|
||||||
|
min-height: 60rpx;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 24rpx;
|
||||||
|
margin-top: 40rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 关闭按钮:紧贴弹窗右上角外侧 */
|
||||||
|
.cm-close {
|
||||||
|
position: absolute;
|
||||||
|
top: -28rpx;
|
||||||
|
right: -28rpx;
|
||||||
|
width: 64rpx;
|
||||||
|
height: 64rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 2rpx solid rgba(0, 0, 0, 0.06);
|
||||||
|
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-close--active {
|
||||||
|
transform: scale(0.9);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 按钮:默认中等大小,不依赖 .btn 统一样式,避免被外部样式干扰 */
|
||||||
|
.cm-btn {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 80rpx;
|
||||||
|
padding: 0 40rpx;
|
||||||
|
border-radius: 14rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.2s ease;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-btn__text {
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-btn--cancel--active {
|
||||||
|
opacity: 0.75;
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-btn--confirm--active {
|
||||||
|
opacity: 0.92;
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-btn--disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cm-btn:only-child {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
<template>
|
||||||
|
<view class="empty-state" :class="['type-' + type, { compact: compact }]" :style="wrapperStyle">
|
||||||
|
<view class="empty-illustration" :style="illustrationStyle">
|
||||||
|
<!-- 圆形光晕背景 -->
|
||||||
|
<view class="illustration-halo" :style="haloStyle"></view>
|
||||||
|
<!-- emoji 图标(微信小程序兼容方案) -->
|
||||||
|
<text class="illustration-emoji">{{ emojiIcon }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="title" class="empty-title">{{ title }}</view>
|
||||||
|
<view v-if="desc" class="empty-desc">{{ desc }}</view>
|
||||||
|
|
||||||
|
<view v-if="actionText" class="empty-action btn btn--primary btn--pill" @tap="onAction">
|
||||||
|
<text class="action-text">{{ actionText }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import themeMixin from '@/mixins/themeMixin.js'
|
||||||
|
import { THEME_CHANGE_EVENT } from '@/utils/theme'
|
||||||
|
|
||||||
|
// 类型→emoji 映射(微信小程序不支持 inline SVG,使用 emoji 替代)
|
||||||
|
const EMOJI_MAP = {
|
||||||
|
'default': '📂',
|
||||||
|
'no-data': '📂',
|
||||||
|
'no-network': '📡',
|
||||||
|
'no-search': '🔍',
|
||||||
|
'mood': '📅',
|
||||||
|
'bill': '💰',
|
||||||
|
'todo': '📋',
|
||||||
|
'review': '✍️',
|
||||||
|
'growth': '🏆',
|
||||||
|
'error': '⚠️'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'EmptyState',
|
||||||
|
mixins: [themeMixin],
|
||||||
|
props: {
|
||||||
|
// 缺省类型:default / no-data / no-network / no-search / mood / bill / todo / review / growth / error
|
||||||
|
type: { type: String, default: 'default' },
|
||||||
|
title: { type: String, default: '暂无数据' },
|
||||||
|
desc: { type: String, default: '' },
|
||||||
|
actionText: { type: String, default: '' },
|
||||||
|
compact: { type: Boolean, default: false }
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
emojiIcon() {
|
||||||
|
return EMOJI_MAP[this.type] || EMOJI_MAP['default']
|
||||||
|
},
|
||||||
|
wrapperStyle() {
|
||||||
|
return {
|
||||||
|
backgroundColor: this.themeConfig.bgColor
|
||||||
|
}
|
||||||
|
},
|
||||||
|
illustrationStyle() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
haloStyle() {
|
||||||
|
const primary = this.themeConfig.primaryColor
|
||||||
|
return {
|
||||||
|
background: `radial-gradient(circle, ${primary}33 0%, ${primary}0D 70%, transparent 100%)`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
// 组件级钩子:EmptyState 不是 page,themeMixin 的 onLoad/onShow 不会触发
|
||||||
|
// 这里手动监听主题变更事件,实时同步主题色
|
||||||
|
if (typeof this.syncThemeConfig === 'function') {
|
||||||
|
this.syncThemeConfig()
|
||||||
|
}
|
||||||
|
if (typeof this.watchThemeChange === 'function') {
|
||||||
|
this.watchThemeChange()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
beforeUnmount() {
|
||||||
|
// 组件销毁时清理监听(避免内存泄漏)
|
||||||
|
if (this.themeChangeHandler) {
|
||||||
|
uni.$off && uni.$off(THEME_CHANGE_EVENT, this.themeChangeHandler)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
onAction() {
|
||||||
|
this.$emit('action')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import '@/uni.scss';
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 40rpx;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
&.compact {
|
||||||
|
padding: 40rpx 32rpx 32rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-illustration {
|
||||||
|
width: 160rpx;
|
||||||
|
height: 160rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
animation: emptyFloat 4s ease-in-out infinite;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 圆形光晕背景 */
|
||||||
|
.illustration-halo {
|
||||||
|
position: absolute;
|
||||||
|
width: 120rpx;
|
||||||
|
height: 120rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* emoji 图标 */
|
||||||
|
.illustration-emoji {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
font-size: 50rpx;
|
||||||
|
line-height: 1;
|
||||||
|
text-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact .empty-illustration {
|
||||||
|
width: 200rpx;
|
||||||
|
height: 200rpx;
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
|
||||||
|
.illustration-halo {
|
||||||
|
width: 160rpx;
|
||||||
|
height: 160rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.illustration-emoji {
|
||||||
|
font-size: 72rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes emptyFloat {
|
||||||
|
0%, 100% { transform: translateY(0); }
|
||||||
|
50% { transform: translateY(-6rpx); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-title {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: v-bind('themeConfig.textPrimary');
|
||||||
|
margin-top: 12rpx;
|
||||||
|
letter-spacing: 0.5rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-desc {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: v-bind('themeConfig.textSecondary');
|
||||||
|
margin-top: 12rpx;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.6;
|
||||||
|
max-width: 480rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-action {
|
||||||
|
margin-top: 36rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-text {
|
||||||
|
font-size: 28rpx;
|
||||||
|
letter-spacing: 1rpx;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
<template>
|
||||||
|
<text class="fa-icon" :class="[prefix]" :style="iconStyle">{{ unicode }}</text>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const MAP = {
|
||||||
|
// 基础
|
||||||
|
home:'\uf015', list:'\uf022', 'list-check':'\uf14a', 'list-ul':'\uf0ca',
|
||||||
|
check:'\uf00c', 'check-circle':'\uf058', 'check-double':'\uf560',
|
||||||
|
xmark:'\uf00d', close:'\uf00d',
|
||||||
|
plus:'\uf0fe', minus:'\uf056',
|
||||||
|
search:'\uf002',
|
||||||
|
user:'\uf007', users:'\uf0c0', 'user-plus':'\uf234', 'user-check':'\uf4fc',
|
||||||
|
gear:'\uf013', settings:'\uf013', 'user-gear':'\uf4ff',
|
||||||
|
envelope:'\uf0e0', mail:'\uf0e0', 'envelope-open':'\uf2b6',
|
||||||
|
phone:'\uf095', 'phone-volume':'\uf2a0',
|
||||||
|
bell:'\uf0f3', 'bell-slash':'\uf1f6',
|
||||||
|
heart:'\uf004', 'heart-crack':'\uf7a9',
|
||||||
|
star:'\uf005', 'star-half-stroke':'\uf5c0',
|
||||||
|
camera:'\uf030',
|
||||||
|
image:'\uf03e', picture:'\uf03e',
|
||||||
|
// 日历 / 时间
|
||||||
|
calendar:'\uf073', 'calendar-day':'\uf783', 'calendar-week':'\uf784',
|
||||||
|
'calendar-days':'\uf1b3', 'calendar-check':'\uf274', 'calendar-xmark':'\uf273',
|
||||||
|
'calendar-plus':'\uf271', 'calendar-minus':'\uf272',
|
||||||
|
clock:'\uf017', 'clock-rotate-left':'\uf1da', time:'\uf017',
|
||||||
|
'hourglass-half':'\uf252', 'hourglass-start':'\uf251', 'hourglass-end':'\uf253',
|
||||||
|
// 位置 / 导航
|
||||||
|
'location-dot':'\uf601', location:'\uf601', 'map-pin':'\uf276',
|
||||||
|
'map-location-dot':'\uf62a', 'map':'\uf279', route:'\uf4d7', compass:'\uf14e',
|
||||||
|
// 箭头
|
||||||
|
'chevron-right':'\uf054', 'chevron-left':'\uf053',
|
||||||
|
'chevron-up':'\uf077', 'chevron-down':'\uf078',
|
||||||
|
'arrow-up':'\uf062', 'arrow-down':'\uf063',
|
||||||
|
'arrow-right':'\uf061', 'arrow-left':'\uf060',
|
||||||
|
'arrow-up-right-from-square':'\uf08e', 'arrow-right-to-bracket':'\uf090',
|
||||||
|
'arrow-rotate-right':'\uf021', 'arrow-rotate-left':'\uf0e2',
|
||||||
|
back:'\uf053', forward:'\uf04e',
|
||||||
|
'angles-right':'\uf105', 'angles-left':'\uf104', 'angles-up':'\uf102', 'angles-down':'\uf103',
|
||||||
|
// 工具
|
||||||
|
filter:'\uf0b0', sliders:'\uf1de', sort:'\uf0dc', 'sort-asc':'\uf0de', 'sort-desc':'\uf0dd',
|
||||||
|
sparkles:'\uf72b', 'wand-magic-sparkles':'\uf72b', 'wand-magic':'\uf72b',
|
||||||
|
// 状态
|
||||||
|
'circle-info':'\uf05a', info:'\uf05a',
|
||||||
|
'circle-exclamation':'\uf06a', 'triangle-exclamation':'\uf071',
|
||||||
|
'circle-question':'\uf059', 'circle-half-stroke':'\uf042',
|
||||||
|
'circle-check':'\uf058', 'circle-xmark':'\uf05c',
|
||||||
|
'circle-plus':'\uf055', 'circle-minus':'\uf056', 'circle-dot':'\uf192',
|
||||||
|
eye:'\uf06e', 'eye-slash':'\uf070',
|
||||||
|
lock:'\uf023', unlock:'\uf09c', 'lock-open':'\uf3c1',
|
||||||
|
// 编辑
|
||||||
|
tag:'\uf02b', tags:'\uf02c', bookmark:'\uf02e',
|
||||||
|
trash:'\uf1f8', delete:'\uf1f8',
|
||||||
|
'pen-to-square':'\uf044', edit:'\uf044', pen:'\uf304', pencil:'\uf040',
|
||||||
|
'file-lines':'\uf15c', file:'\uf15c', 'file-pen':'\uf31c',
|
||||||
|
// 图表
|
||||||
|
'chart-simple':'\uf7a5',
|
||||||
|
'chart-column':'\uf7ae', 'chart-bar':'\uf080',
|
||||||
|
'chart-line':'\uf201', 'chart-pie':'\uf200', 'chart-area':'\uf1fe',
|
||||||
|
'chart-gantt':'\uf7a0', 'chart-bullet':'\uf7a1',
|
||||||
|
// 财务
|
||||||
|
'dollar-sign':'\uf155', 'yen-sign':'\uf157', 'yuan-sign':'\uf157',
|
||||||
|
coins:'\uf1e3', wallet:'\uf555', 'credit-card':'\uf09d', 'money-bill':'\uf2e1',
|
||||||
|
'money-bill-wave':'\uf53a', 'money-bill-trend-up':'\uf4f9', 'money-bill-transfer':'\uf4f8',
|
||||||
|
'sack-dollar':'\uf81d', 'hand-holding-dollar':'\uf4c0',
|
||||||
|
gift:'\uf06b',
|
||||||
|
// 成就 / 游戏
|
||||||
|
trophy:'\uf091', medal:'\uf5a2', award:'\uf559', crown:'\uf52a',
|
||||||
|
fire:'\uf06d', bolt:'\uf0e7', gem:'\uf3a5',
|
||||||
|
bullseye:'\uf140', target:'\uf140', rocket:'\uf135',
|
||||||
|
'flag-checkered':'\uf11e', flag:'\uf024', 'bookmark-star':'\uf7a1',
|
||||||
|
// 菜单
|
||||||
|
menu:'\uf0c9', bars:'\uf0c9', 'grip-vertical':'\uf58e', 'grip-lines':'\uf7a4',
|
||||||
|
ellipsis:'\uf141', 'ellipsis-vertical':'\uf142',
|
||||||
|
// 通讯
|
||||||
|
comment:'\uf075', comments:'\uf086', 'comment-dots':'\uf4ad',
|
||||||
|
'paper-plane':'\uf1d8',
|
||||||
|
// 电源
|
||||||
|
'power-off':'\uf011',
|
||||||
|
// 文件 / 文件夹
|
||||||
|
cloud:'\uf0c2', 'cloud-arrow-up':'\uf382', 'cloud-arrow-down':'\uf37d',
|
||||||
|
folder:'\uf07b', 'folder-open':'\uf07c', 'folder-plus':'\uf65e',
|
||||||
|
circle:'\uf111', 'circle-notch':'\uf1ce',
|
||||||
|
code:'\uf121', terminal:'\uf120',
|
||||||
|
link:'\uf08c', unlink:'\uf127', 'link-slash':'\uf127',
|
||||||
|
shield:'\uf132', 'shield-halved':'\uf3ed', 'shield-check':'\uf2f7',
|
||||||
|
download:'\uf019', upload:'\uf093',
|
||||||
|
share:'\uf064', 'share-nodes':'\uf504',
|
||||||
|
copy:'\uf0c5', save:'\uf0c7', paste:'\uf0ea',
|
||||||
|
rotate:'\uf2f1', refresh:'\uf021', sync:'\uf021', 'arrows-rotate':'\uf021',
|
||||||
|
signout:'\uf2f5', 'right-from-bracket':'\uf2f5', 'right-to-bracket':'\uf2f6',
|
||||||
|
qrcode:'\uf029', barcode:'\uf02a',
|
||||||
|
// 主题
|
||||||
|
palette:'\uf53f', 'palette-flushed':'\uf7a2', paintbrush:'\uf1fc',
|
||||||
|
sun:'\uf185', moon:'\uf186', 'cloud-sun':'\uf6c4', 'cloud-moon':'\uf6c3',
|
||||||
|
// 媒体
|
||||||
|
'volume-high':'\uf0d7', 'volume-low':'\uf027', 'volume-off':'\uf026', 'volume-xmark':'\uf6a9',
|
||||||
|
headphones:'\uf025', music:'\uf001', microphone:'\uf130', 'microphone-lines':'\uf130',
|
||||||
|
play:'\uf04b', pause:'\uf04c', stop:'\uf04d', forward:'\uf04e', backward:'\uf04a',
|
||||||
|
// 设备
|
||||||
|
idcard:'\uf2c2', mobile:'\uf10b', tablet:'\uf3fa', laptop:'\uf109', desktop:'\uf390',
|
||||||
|
wifi:'\uf1eb', bluetooth:'\uf293', signal:'\uf012', battery:'\uf240',
|
||||||
|
keyboard:'\uf11c', mouse:'\uf8cc',
|
||||||
|
// 建筑 / 地点
|
||||||
|
briefcase:'\uf0b1', building:'\uf1ad', store:'\uf54e', shop:'\uf54f',
|
||||||
|
cart:'\uf07a', 'cart-shopping':'\uf07a', bag:'\uf290', 'bag-shopping':'\uf290',
|
||||||
|
bed:'\uf236', house:'\uf015', 'house-chimney':'\uf3f0',
|
||||||
|
// 食物 / 饮品
|
||||||
|
utensils:'\uf2e7', coffee:'\uf0f4', mug:'\uf7b6', 'mug-hot':'\uf7b6', 'mug-saucer':'\uf7b5',
|
||||||
|
'bowl-food':'\uf830', 'bowl-rice':'\uf830', 'pizza-slice':'\uf818',
|
||||||
|
'burger':'\uf805', 'ice-cream':'\uf810', cookie:'\uf563',
|
||||||
|
// 健康 / 运动
|
||||||
|
dumbbell:'\uf44b', 'heart-pulse':'\uf21e', 'heartbeat':'\uf21e',
|
||||||
|
hospital:'\uf0f8', pill:'\uf484', stethoscope:'\uf0f1',
|
||||||
|
'person-walking':'\uf554', 'person-running':'\uf70c', 'person-biking':'\uf63a',
|
||||||
|
'person-hiking':'\uf6ee', bicycle:'\uf206', 'car-side':'\uf5e4',
|
||||||
|
// 学习 / 知识
|
||||||
|
book:'\uf02d', 'book-open':'\uf518', 'bookmark':'\uf02e',
|
||||||
|
'book-bookmark':'\uf7a1', 'book-open-reader':'\uf5da',
|
||||||
|
'graduation-cap':'\uf19d', pen:'\uf304', pencil:'\uf040',
|
||||||
|
lightbulb:'\uf0eb', 'face-smile':'\uf118', 'face-smile-beam':'\uf5b8',
|
||||||
|
'face-meh':'\uf11a', 'face-frown':'\uf119', 'face-grin':'\uf580',
|
||||||
|
'face-laugh':'\uf599', 'face-grin-stars':'\uf587',
|
||||||
|
// 自然
|
||||||
|
leaf:'\uf06c', seedling:'\uf32d', tree:'\uf1bb', 'tree-deciduous':'\uf1bb',
|
||||||
|
mountain:'\uf6fc', 'mountain-sun':'\uf7a2', water:'\uf773',
|
||||||
|
// 工具 / 维修
|
||||||
|
hammer:'\uf6e4', wrench:'\uf0ad', tools:'\uf7d9', screwdriver:'\uf7d8',
|
||||||
|
key:'\uf084', lock:'\uf023',
|
||||||
|
// 法律 / 商务
|
||||||
|
'scale-balanced':'\uf24e', 'gavel':'\uf0e3', 'briefcase':'\uf0b1',
|
||||||
|
'file-contract':'\uf56c', 'file-invoice':'\uf570', 'file-invoice-dollar':'\uf571',
|
||||||
|
// 人
|
||||||
|
hand:'\uf25b', 'hand-back-fist':'\uf255', 'hand-fist':'\uf255',
|
||||||
|
'hand-holding':'\uf4bd', 'hand-holding-heart':'\uf4be',
|
||||||
|
'thumbs-up':'\uf164', 'thumbs-down':'\uf165',
|
||||||
|
'user-shield':'\uf502', 'user-tie':'\uf508', 'user-graduate':'\uf501',
|
||||||
|
'user-astronaut':'\uf4fb',
|
||||||
|
angel:'\uf529', child:'\uf1ae', baby:'\uf77c', 'user-nurse':'\uf82f',
|
||||||
|
'user-doctor':'\uf0f0', 'user-tag':'\uf507',
|
||||||
|
// 地址 / 标识
|
||||||
|
'address-book':'\uf2b9', 'id-badge':'\uf2c1', 'address-card':'\uf2bb',
|
||||||
|
'file-clipboard':'\uf5ea', 'clipboard-list':'\uf46d', 'clipboard-check':'\uf46c',
|
||||||
|
// 文件夹树
|
||||||
|
'folder-tree':'\uf802', 'folder-closed':'\uf1e3',
|
||||||
|
// 杂项
|
||||||
|
thumbtack:'\uf08d', 'map-pin':'\uf276', 'thumb-tack':'\uf08d',
|
||||||
|
guitar:'\uf7a6', music:'\uf001', drum:'\uf569',
|
||||||
|
'flag':'\uf024', 'paw':'\uf1b0', 'bone':'\uf5d7',
|
||||||
|
// 文件类型
|
||||||
|
'file-pdf':'\uf1c1', 'file-word':'\uf1c2', 'file-excel':'\uf1c3',
|
||||||
|
'file-powerpoint':'\uf1c4', 'file-archive':'\uf1c6',
|
||||||
|
'file-image':'\uf1c5', 'file-video':'\uf1c8', 'file-audio':'\uf1c7',
|
||||||
|
'file-code':'\uf1c9', 'file-csv':'\uf6dd',
|
||||||
|
// 网络 / 连接
|
||||||
|
'globe':'\uf0ac', 'earth-asia':'\uf57e', 'network-wired':'\uf6ff',
|
||||||
|
'server':'\uf233', 'database':'\uf1c0', 'hdd':'\uf0a0',
|
||||||
|
// 箭头
|
||||||
|
'arrow-trend-up':'\uf098', 'arrow-trend-down':'\uf097',
|
||||||
|
'up-right-and-down-left-from-center':'\uf424',
|
||||||
|
'down-left-and-up-right-to-center':'\uf422',
|
||||||
|
'maximize':'\uf31e', 'minimize':'\uf78c', 'expand':'\uf065', 'compress':'\uf066',
|
||||||
|
// 其他常用
|
||||||
|
'hashtag':'\uf292', 'at':'\uf1fa', 'percent':'\uf295', 'divide':'\uf529',
|
||||||
|
'plus':'\uf0fe', 'minus':'\uf056', 'equals':'\uf7a0',
|
||||||
|
'infinity':'\uf534', 'less-than':'\uf7a0', 'greater-than':'\uf7a0',
|
||||||
|
// 心情 / 表情
|
||||||
|
'face-sad-tear':'\uf5b4', 'face-grin-beam':'\uf582', 'face-grin-hearts':'\uf584',
|
||||||
|
'face-kiss':'\uf596', 'face-kiss-beam':'\uf597', 'face-kiss-wink-heart':'\uf598',
|
||||||
|
'face-laugh-beam':'\uf59a', 'face-laugh-squint':'\uf59b',
|
||||||
|
'face-tired':'\uf5c8', 'face-rolling-eyes':'\uf5a5',
|
||||||
|
'face-smile-wink':'\uf4da', 'face-smile-plus':'\uf5b9',
|
||||||
|
// 状态指示
|
||||||
|
'circle-arrow-up':'\uf0aa', 'circle-arrow-down':'\uf0ab',
|
||||||
|
'circle-arrow-right':'\uf0a9', 'circle-arrow-left':'\uf0a8',
|
||||||
|
'triangle-exclamation':'\uf071', 'triangle':'\uf2ec',
|
||||||
|
'square':'\uf0c8', 'square-check':'\uf14a', 'square-plus':'\uf0fe',
|
||||||
|
// 默认回退
|
||||||
|
default:'\uf2dc', fallback:'\uf2dc'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'FaIcon',
|
||||||
|
props: {
|
||||||
|
icon: { type: String, required: true },
|
||||||
|
size: { type: [String, Number], default: 'inherit' },
|
||||||
|
color: { type: String, default: '' },
|
||||||
|
prefix: { type: String, default: 'fas' }
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
iconStyle() {
|
||||||
|
const s = {}
|
||||||
|
if (this.size !== 'inherit') {
|
||||||
|
s.fontSize = typeof this.size === 'number' ? this.size + 'rpx' : this.size
|
||||||
|
}
|
||||||
|
if (this.color) s.color = this.color
|
||||||
|
return s
|
||||||
|
},
|
||||||
|
unicode() { return MAP[this.icon] || MAP.fallback }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.fa-icon {
|
||||||
|
font-family: 'Font Awesome 6 Free';
|
||||||
|
font-weight: 900;
|
||||||
|
font-style: normal;
|
||||||
|
font-variant: normal;
|
||||||
|
text-rendering: auto;
|
||||||
|
display: inline-block;
|
||||||
|
line-height: 1;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
.fa-icon.fab, .fa-icon.fa-brands {
|
||||||
|
font-family: 'Font Awesome 6 Brands';
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
.fa-icon.far, .fa-icon.fa-regular { font-weight: 400; }
|
||||||
|
/* 点击态 */
|
||||||
|
.fa-icon.clickable {
|
||||||
|
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
.fa-icon.clickable:active {
|
||||||
|
transform: scale(0.92);
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
<template>
|
||||||
|
<view class="growth-modal" v-if="visible">
|
||||||
|
<view class="mask" @click="handleMaskClick" @touchmove.stop.prevent></view>
|
||||||
|
<view class="dialog" @click.stop>
|
||||||
|
<view class="icon-area" v-if="icon">
|
||||||
|
<text class="icon">{{ icon }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="title" v-if="title">{{ title }}</text>
|
||||||
|
<view class="message" v-if="message">{{ message }}</view>
|
||||||
|
<view class="btn-group">
|
||||||
|
<view class="btn btn--primary btn--block btn--pill" @click="handleConfirm" hover-class="btn-hover">
|
||||||
|
<text>{{ confirmText }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'GrowthModal',
|
||||||
|
props: {
|
||||||
|
visible: { type: Boolean, default: false },
|
||||||
|
title: { type: String, default: '' },
|
||||||
|
message: { type: String, default: '' },
|
||||||
|
icon: { type: String, default: '' },
|
||||||
|
confirmText: { type: String, default: '确定' }
|
||||||
|
},
|
||||||
|
emits: ['confirm', 'close'],
|
||||||
|
methods: {
|
||||||
|
handleMaskClick() {
|
||||||
|
this.$emit('close')
|
||||||
|
},
|
||||||
|
handleConfirm() {
|
||||||
|
this.$emit('confirm')
|
||||||
|
this.$emit('close')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.growth-modal {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 99999;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
animation: fadeIn 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mask {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog {
|
||||||
|
position: relative;
|
||||||
|
width: 600rpx;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
padding: 56rpx 48rpx;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 20rpx 60rpx rgba(0, 0, 0, 0.15);
|
||||||
|
animation: scaleIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes scaleIn {
|
||||||
|
from {
|
||||||
|
transform: scale(0.8);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-area {
|
||||||
|
margin-bottom: 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
font-size: 96rpx;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 40rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1a1a2e;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #666666;
|
||||||
|
line-height: 1.8;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
min-height: 140rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-group {
|
||||||
|
margin-top: 40rpx;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,466 @@
|
|||||||
|
<template>
|
||||||
|
<view class="inline-calendar" :style="calendarStyle">
|
||||||
|
<view class="calendar-header" v-if="showMonthNav">
|
||||||
|
<view class="nav-btn" @click="prevMonth">
|
||||||
|
<text class="nav-icon">‹</text>
|
||||||
|
</view>
|
||||||
|
<text class="month-title">{{ currentYear }}年{{ currentMonth }}月</text>
|
||||||
|
<view class="nav-btn" @click="nextMonth">
|
||||||
|
<text class="nav-icon">›</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="weekday-header">
|
||||||
|
<view class="weekday-cell" v-for="(day, i) in weekdayLabels" :key="'wd-' + i">
|
||||||
|
<text class="weekday-text" :class="{ 'is-weekend': i === 5 || i === 6 }">{{ day }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="days-grid">
|
||||||
|
<view
|
||||||
|
class="day-row"
|
||||||
|
v-for="(week, wi) in weeks"
|
||||||
|
:key="wi"
|
||||||
|
>
|
||||||
|
<view
|
||||||
|
class="day-item"
|
||||||
|
:class="getDayClass(date)"
|
||||||
|
:style="getDayStyle(date)"
|
||||||
|
v-for="(date, di) in week"
|
||||||
|
:key="di"
|
||||||
|
@click="onDateClick(date)"
|
||||||
|
>
|
||||||
|
<text class="day-number">{{ date.getDate() }}</text>
|
||||||
|
<view
|
||||||
|
class="mark-dot"
|
||||||
|
v-if="isMarked(date) && markType === 'dot'"
|
||||||
|
></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import themeMixin from '@/mixins/themeMixin.js'
|
||||||
|
import { THEME_CHANGE_EVENT } from '@/utils/theme'
|
||||||
|
|
||||||
|
// 周一到周日的完整中文名(展示在日历数字上方)
|
||||||
|
const WEEKDAY_LABELS = ['一', '二', '三', '四', '五', '六', '日']
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'InlineCalendar',
|
||||||
|
mixins: [themeMixin],
|
||||||
|
props: {
|
||||||
|
value: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
markDates: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
markType: {
|
||||||
|
type: String,
|
||||||
|
default: 'dot'
|
||||||
|
},
|
||||||
|
rangeSelect: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
startDate: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
endDate: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
showMonthNav: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
minDate: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
maxDate: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
currentDate: new Date(),
|
||||||
|
innerStartDate: '',
|
||||||
|
innerEndDate: ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
calendarStyle() {
|
||||||
|
return {
|
||||||
|
backgroundColor: this.themeConfig.cardBgColor,
|
||||||
|
borderRadius: '20rpx'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
weekdayLabels() {
|
||||||
|
return WEEKDAY_LABELS
|
||||||
|
},
|
||||||
|
currentYear() {
|
||||||
|
return this.currentDate.getFullYear()
|
||||||
|
},
|
||||||
|
currentMonth() {
|
||||||
|
return this.currentDate.getMonth() + 1
|
||||||
|
},
|
||||||
|
todayStr() {
|
||||||
|
return this.formatDate(new Date())
|
||||||
|
},
|
||||||
|
weeks() {
|
||||||
|
const year = this.currentDate.getFullYear()
|
||||||
|
const month = this.currentDate.getMonth()
|
||||||
|
const firstDay = new Date(year, month, 1)
|
||||||
|
const dayOfWeek = firstDay.getDay()
|
||||||
|
const daysSinceMonday = dayOfWeek === 0 ? 6 : dayOfWeek - 1
|
||||||
|
const gridStart = new Date(year, month, 1 - daysSinceMonday)
|
||||||
|
|
||||||
|
const result = []
|
||||||
|
for (let w = 0; w < 6; w++) {
|
||||||
|
const week = []
|
||||||
|
for (let d = 0; d < 7; d++) {
|
||||||
|
const date = new Date(gridStart)
|
||||||
|
date.setDate(gridStart.getDate() + w * 7 + d)
|
||||||
|
week.push(date)
|
||||||
|
}
|
||||||
|
result.push(week)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
},
|
||||||
|
effectiveStart() {
|
||||||
|
return this.rangeSelect ? (this.startDate || this.innerStartDate) : ''
|
||||||
|
},
|
||||||
|
effectiveEnd() {
|
||||||
|
return this.rangeSelect ? (this.endDate || this.innerEndDate) : ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
value: {
|
||||||
|
immediate: true,
|
||||||
|
handler(val) {
|
||||||
|
if (val) {
|
||||||
|
const d = this.parseDate(val)
|
||||||
|
if (d) {
|
||||||
|
this.currentDate = new Date(d.getFullYear(), d.getMonth(), 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
startDate(val) {
|
||||||
|
if (val) this.innerStartDate = val
|
||||||
|
},
|
||||||
|
endDate(val) {
|
||||||
|
if (val) this.innerEndDate = val
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this.syncThemeConfig()
|
||||||
|
this.watchThemeChange()
|
||||||
|
if (this.value) {
|
||||||
|
const d = this.parseDate(this.value)
|
||||||
|
if (d) {
|
||||||
|
this.currentDate = new Date(d.getFullYear(), d.getMonth(), 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.startDate) this.innerStartDate = this.startDate
|
||||||
|
if (this.endDate) this.innerEndDate = this.endDate
|
||||||
|
},
|
||||||
|
beforeUnmount() {
|
||||||
|
if (this.themeChangeHandler) {
|
||||||
|
uni.$off(THEME_CHANGE_EVENT, this.themeChangeHandler)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
formatDate(date) {
|
||||||
|
const y = date.getFullYear()
|
||||||
|
const m = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const d = String(date.getDate()).padStart(2, '0')
|
||||||
|
return `${y}-${m}-${d}`
|
||||||
|
},
|
||||||
|
parseDate(str) {
|
||||||
|
if (!str) return null
|
||||||
|
const parts = str.split('-')
|
||||||
|
if (parts.length !== 3) return null
|
||||||
|
return new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2]))
|
||||||
|
},
|
||||||
|
isSameDay(d1, d2) {
|
||||||
|
return d1.getFullYear() === d2.getFullYear() &&
|
||||||
|
d1.getMonth() === d2.getMonth() &&
|
||||||
|
d1.getDate() === d2.getDate()
|
||||||
|
},
|
||||||
|
isToday(date) {
|
||||||
|
return this.isSameDay(date, new Date())
|
||||||
|
},
|
||||||
|
isCurrentMonth(date) {
|
||||||
|
return date.getFullYear() === this.currentYear &&
|
||||||
|
date.getMonth() === this.currentMonth - 1
|
||||||
|
},
|
||||||
|
isMarked(date) {
|
||||||
|
const str = this.formatDate(date)
|
||||||
|
return this.markDates.indexOf(str) !== -1
|
||||||
|
},
|
||||||
|
isDisabled(date) {
|
||||||
|
const str = this.formatDate(date)
|
||||||
|
if (this.minDate && str < this.minDate) return true
|
||||||
|
if (this.maxDate && str > this.maxDate) return true
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
getDayClass(date) {
|
||||||
|
const str = this.formatDate(date)
|
||||||
|
const classes = {}
|
||||||
|
if (!this.isCurrentMonth(date)) classes['day-item--other-month'] = true
|
||||||
|
if (this.isToday(date)) classes['day-item--today'] = true
|
||||||
|
if (this.isDisabled(date)) classes['day-item--disabled'] = true
|
||||||
|
|
||||||
|
if (this.rangeSelect) {
|
||||||
|
if (this.effectiveStart && this.effectiveEnd) {
|
||||||
|
if (str === this.effectiveStart || str === this.effectiveEnd) {
|
||||||
|
classes['day-item--selected'] = true
|
||||||
|
} else if (str > this.effectiveStart && str < this.effectiveEnd) {
|
||||||
|
classes['day-item--in-range'] = true
|
||||||
|
}
|
||||||
|
} else if (this.effectiveStart && str === this.effectiveStart) {
|
||||||
|
classes['day-item--selected'] = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (this.value && str === this.value) {
|
||||||
|
classes['day-item--selected'] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isMarked(date) && this.markType === 'bg') {
|
||||||
|
classes['day-item--mark-bg'] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return classes
|
||||||
|
},
|
||||||
|
getDayStyle(date) {
|
||||||
|
const styles = {}
|
||||||
|
if (this.isToday(date)) {
|
||||||
|
styles.borderColor = this.themeConfig.primaryColor
|
||||||
|
}
|
||||||
|
if (this.isMarked(date) && this.markType === 'bg') {
|
||||||
|
styles.backgroundColor = this.themeConfig.primaryColor + '26'
|
||||||
|
}
|
||||||
|
if (!this.isCurrentMonth(date)) {
|
||||||
|
styles.color = this.themeConfig.textDisabled
|
||||||
|
}
|
||||||
|
return styles
|
||||||
|
},
|
||||||
|
onDateClick(date) {
|
||||||
|
if (this.isDisabled(date)) return
|
||||||
|
const str = this.formatDate(date)
|
||||||
|
|
||||||
|
if (this.rangeSelect) {
|
||||||
|
if (!this.innerStartDate || (this.innerStartDate && this.innerEndDate)) {
|
||||||
|
this.innerStartDate = str
|
||||||
|
this.innerEndDate = ''
|
||||||
|
} else {
|
||||||
|
if (str <= this.innerStartDate) {
|
||||||
|
this.innerEndDate = this.innerStartDate
|
||||||
|
this.innerStartDate = str
|
||||||
|
} else {
|
||||||
|
this.innerEndDate = str
|
||||||
|
}
|
||||||
|
this.$emit('rangeChange', {
|
||||||
|
start: this.innerStartDate,
|
||||||
|
end: this.innerEndDate
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$emit('change', str)
|
||||||
|
this.$emit('input', str)
|
||||||
|
},
|
||||||
|
prevMonth() {
|
||||||
|
let y = this.currentYear
|
||||||
|
let m = this.currentMonth - 2
|
||||||
|
if (m < 0) {
|
||||||
|
m = 11
|
||||||
|
y--
|
||||||
|
}
|
||||||
|
this.currentDate = new Date(y, m, 1)
|
||||||
|
},
|
||||||
|
nextMonth() {
|
||||||
|
let y = this.currentYear
|
||||||
|
let m = this.currentMonth
|
||||||
|
if (m > 11) {
|
||||||
|
m = 0
|
||||||
|
y++
|
||||||
|
}
|
||||||
|
this.currentDate = new Date(y, m, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.inline-calendar {
|
||||||
|
padding: 24rpx;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
.calendar-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn {
|
||||||
|
width: 60rpx;
|
||||||
|
height: 60rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 14rpx;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn:active {
|
||||||
|
background-color: rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-icon {
|
||||||
|
font-size: 40rpx;
|
||||||
|
color: v-bind('themeConfig.textSecondary');
|
||||||
|
font-weight: 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.month-title {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: v-bind('themeConfig.textPrimary');
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekday-header {
|
||||||
|
display: flex;
|
||||||
|
gap: 8rpx;
|
||||||
|
margin: 0 auto 16rpx;
|
||||||
|
width: 552rpx; /* 与 .day-row 7*72 + 6*8 保持一致 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekday-cell {
|
||||||
|
width: 72rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 36rpx;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
background-color: v-bind('themeConfig.bgColor');
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekday-text {
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: v-bind('themeConfig.textSecondary');
|
||||||
|
letter-spacing: 1rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.weekday-text.is-weekend {
|
||||||
|
color: v-bind('themeConfig.primaryColor');
|
||||||
|
}
|
||||||
|
|
||||||
|
.days-grid {
|
||||||
|
display: table;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8rpx;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-item {
|
||||||
|
width: 72rpx;
|
||||||
|
height: 72rpx;
|
||||||
|
border-radius: 10rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
background-color: transparent;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-number {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: v-bind('themeConfig.textPrimary');
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-item--other-month {
|
||||||
|
opacity: 0.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-item--other-month .day-number {
|
||||||
|
color: v-bind('themeConfig.textDisabled');
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-item--today {
|
||||||
|
border: 2rpx solid transparent;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-item--today .day-number {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-item--selected {
|
||||||
|
background: v-bind('themeConfig.gradientSoft');
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-item--selected .day-number {
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-item--in-range {
|
||||||
|
background-color: v-bind('themeConfig.primaryColor + "1A"');
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-item--in-range .day-number {
|
||||||
|
color: v-bind('themeConfig.primaryColor');
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-item--disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-item--mark-bg {
|
||||||
|
background-color: v-bind('themeConfig.primaryColor + "26"');
|
||||||
|
}
|
||||||
|
|
||||||
|
.mark-dot {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 8rpx;
|
||||||
|
width: 8rpx;
|
||||||
|
height: 8rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: v-bind('themeConfig.primaryColor');
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-item--selected .mark-dot {
|
||||||
|
background-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-item--in-range .mark-dot {
|
||||||
|
background-color: v-bind('themeConfig.primaryColor');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
<template>
|
||||||
|
<view
|
||||||
|
class="page-header"
|
||||||
|
:class="{ 'is-transparent': transparent }"
|
||||||
|
:style="headerStyle"
|
||||||
|
>
|
||||||
|
<view class="header-left">
|
||||||
|
<view
|
||||||
|
v-if="showBack"
|
||||||
|
class="back-btn btn btn--icon"
|
||||||
|
@click="handleBack"
|
||||||
|
>
|
||||||
|
<FaIcon icon="chevron-left" :size="22" :color="themeConfig.textPrimary" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="header-center">
|
||||||
|
<text class="header-title" :style="{ color: themeConfig.textPrimary }">{{ title }}</text>
|
||||||
|
<text v-if="subtitle" class="header-subtitle" :style="{ color: themeConfig.textSecondary }">{{ subtitle }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="header-right">
|
||||||
|
<slot v-if="showRight" name="right"></slot>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import themeMixin from '@/mixins/themeMixin.js'
|
||||||
|
import { getThemeConfigFromStorage, THEME_CHANGE_EVENT, THEME_PRESETS, DEFAULT_THEME_KEY } from '@/utils/theme'
|
||||||
|
import FaIcon from '@/components/FaIcon.vue'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'PageHeader',
|
||||||
|
components: { FaIcon },
|
||||||
|
mixins: [themeMixin],
|
||||||
|
props: {
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
showBack: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
showRight: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
transparent: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
headerStyle() {
|
||||||
|
if (this.transparent) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
backgroundColor: this.themeConfig.bgColor,
|
||||||
|
borderBottom: `2rpx solid ${this.themeConfig.borderLight}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
handleBack() {
|
||||||
|
this.$emit('back')
|
||||||
|
if (!this.$listeners || !this.$listeners.back) {
|
||||||
|
uni.navigateBack()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
handleThemeChange(themeData) {
|
||||||
|
let newTheme = null
|
||||||
|
if (themeData && themeData.key && THEME_PRESETS[themeData.key]) {
|
||||||
|
newTheme = THEME_PRESETS[themeData.key]
|
||||||
|
} else if (themeData && themeData.config) {
|
||||||
|
newTheme = themeData.config
|
||||||
|
}
|
||||||
|
if (newTheme) {
|
||||||
|
this.themeConfig = newTheme
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
const appInstance = getApp({ allowDefault: true })
|
||||||
|
const globalTheme = appInstance && appInstance.globalData && appInstance.globalData.themeConfig
|
||||||
|
this.themeConfig = globalTheme || getThemeConfigFromStorage() || THEME_PRESETS[DEFAULT_THEME_KEY]
|
||||||
|
this.themeChangeHandler = this.handleThemeChange.bind(this)
|
||||||
|
uni.$on(THEME_CHANGE_EVENT, this.themeChangeHandler)
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
uni.$off(THEME_CHANGE_EVENT, this.themeChangeHandler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 24rpx 36rpx;
|
||||||
|
padding-top: calc(24rpx + constant(safe-area-inset-top));
|
||||||
|
padding-top: calc(24rpx + env(safe-area-inset-top));
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
min-height: 120rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header.is-transparent {
|
||||||
|
background: transparent !important;
|
||||||
|
border-bottom: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
width: 68rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn {
|
||||||
|
transition: all 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn:active {
|
||||||
|
transform: scale(0.93);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-center {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-title {
|
||||||
|
font-size: 36rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-subtitle {
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
width: 68rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
<template>
|
||||||
|
<view class="progress-bar">
|
||||||
|
<view
|
||||||
|
class="progress-track"
|
||||||
|
:style="trackStyle"
|
||||||
|
>
|
||||||
|
<view
|
||||||
|
class="progress-fill"
|
||||||
|
:style="fillStyle"
|
||||||
|
></view>
|
||||||
|
</view>
|
||||||
|
<text
|
||||||
|
v-if="showText"
|
||||||
|
class="progress-text"
|
||||||
|
:style="textStyle"
|
||||||
|
>{{ clampedPercent }}%</text>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import themeMixin from '@/mixins/themeMixin.js'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'ProgressBar',
|
||||||
|
mixins: [themeMixin],
|
||||||
|
props: {
|
||||||
|
percent: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
},
|
||||||
|
height: {
|
||||||
|
type: Number,
|
||||||
|
default: 12
|
||||||
|
},
|
||||||
|
showText: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
color: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
trackColor: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
animated: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
type: String,
|
||||||
|
default: 'gradient'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
clampedPercent() {
|
||||||
|
return Math.max(0, Math.min(100, this.percent))
|
||||||
|
},
|
||||||
|
statusColor() {
|
||||||
|
if (this.color) return this.color
|
||||||
|
if (this.clampedPercent >= 100) return this.themeConfig.dangerColor
|
||||||
|
if (this.clampedPercent > 80) return this.themeConfig.warningColor
|
||||||
|
return this.themeConfig.primaryColor
|
||||||
|
},
|
||||||
|
trackStyle() {
|
||||||
|
return {
|
||||||
|
height: this.height + 'rpx',
|
||||||
|
backgroundColor: this.trackColor || this.themeConfig.borderLight
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fillStyle() {
|
||||||
|
const style = {
|
||||||
|
width: this.clampedPercent + '%',
|
||||||
|
transition: this.animated ? 'width 0.4s ease' : 'none'
|
||||||
|
}
|
||||||
|
if (this.type === 'gradient') {
|
||||||
|
style.background = `linear-gradient(90deg, ${this.statusColor}, ${this.themeConfig.gradientEnd})`
|
||||||
|
} else {
|
||||||
|
style.backgroundColor = this.statusColor
|
||||||
|
}
|
||||||
|
return style
|
||||||
|
},
|
||||||
|
textStyle() {
|
||||||
|
return {
|
||||||
|
color: this.themeConfig.textSecondary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.progress-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-track {
|
||||||
|
flex: 1;
|
||||||
|
border-radius: 100rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 100rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-text {
|
||||||
|
font-size: 22rpx;
|
||||||
|
margin-left: 16rpx;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,465 @@
|
|||||||
|
<template>
|
||||||
|
<view class="skeleton-screen" :style="screenStyle">
|
||||||
|
<block v-if="type === 'list'">
|
||||||
|
<view
|
||||||
|
v-for="n in count"
|
||||||
|
:key="'list-' + n"
|
||||||
|
class="skeleton-list-item"
|
||||||
|
:style="cardStyle"
|
||||||
|
>
|
||||||
|
<view class="skeleton-avatar" :style="shimmerStyle"></view>
|
||||||
|
<view class="skeleton-content">
|
||||||
|
<view class="skeleton-title" :style="shimmerStyle"></view>
|
||||||
|
<view class="skeleton-subtitle" :style="shimmerStyle"></view>
|
||||||
|
<view class="skeleton-subtitle skeleton-subtitle--short" :style="shimmerStyle"></view>
|
||||||
|
<view class="skeleton-tags">
|
||||||
|
<view class="skeleton-tag" :style="shimmerStyle"></view>
|
||||||
|
<view class="skeleton-tag" :style="shimmerStyle"></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</block>
|
||||||
|
|
||||||
|
<block v-else-if="type === 'card'">
|
||||||
|
<view class="skeleton-card" :style="cardStyle">
|
||||||
|
<view class="skeleton-card-header">
|
||||||
|
<view class="skeleton-title skeleton-title--lg" :style="shimmerStyle"></view>
|
||||||
|
<view class="skeleton-subtitle" :style="shimmerStyle"></view>
|
||||||
|
</view>
|
||||||
|
<view class="skeleton-card-body">
|
||||||
|
<view class="skeleton-line" :style="shimmerStyle"></view>
|
||||||
|
<view class="skeleton-line" :style="shimmerStyle"></view>
|
||||||
|
<view class="skeleton-line skeleton-line--short" :style="shimmerStyle"></view>
|
||||||
|
</view>
|
||||||
|
<view class="skeleton-card-footer">
|
||||||
|
<view class="skeleton-line skeleton-line--footer" :style="shimmerStyle"></view>
|
||||||
|
<view class="skeleton-line skeleton-line--footer" :style="shimmerStyle"></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</block>
|
||||||
|
|
||||||
|
<block v-else-if="type === 'stats'">
|
||||||
|
<view
|
||||||
|
v-for="n in count"
|
||||||
|
:key="'stats-' + n"
|
||||||
|
class="skeleton-stats"
|
||||||
|
:style="cardStyle"
|
||||||
|
>
|
||||||
|
<view class="skeleton-stat-col">
|
||||||
|
<view class="skeleton-stat-value" :style="shimmerStyle"></view>
|
||||||
|
<view class="skeleton-stat-label" :style="shimmerStyle"></view>
|
||||||
|
</view>
|
||||||
|
<view class="skeleton-stat-col">
|
||||||
|
<view class="skeleton-stat-value" :style="shimmerStyle"></view>
|
||||||
|
<view class="skeleton-stat-label" :style="shimmerStyle"></view>
|
||||||
|
</view>
|
||||||
|
<view class="skeleton-stat-col">
|
||||||
|
<view class="skeleton-stat-value" :style="shimmerStyle"></view>
|
||||||
|
<view class="skeleton-stat-label" :style="shimmerStyle"></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</block>
|
||||||
|
|
||||||
|
<block v-else-if="type === 'dashboard'">
|
||||||
|
<view class="skeleton-dashboard" :style="cardStyle">
|
||||||
|
<view class="skeleton-ring-wrapper">
|
||||||
|
<view class="skeleton-ring" :style="ringStyle"></view>
|
||||||
|
<view class="skeleton-ring-inner" :style="shimmerStyle"></view>
|
||||||
|
</view>
|
||||||
|
<view class="skeleton-dashboard-stats">
|
||||||
|
<view class="skeleton-stat-col">
|
||||||
|
<view class="skeleton-stat-value" :style="shimmerStyle"></view>
|
||||||
|
<view class="skeleton-stat-label" :style="shimmerStyle"></view>
|
||||||
|
</view>
|
||||||
|
<view class="skeleton-stat-col">
|
||||||
|
<view class="skeleton-stat-value" :style="shimmerStyle"></view>
|
||||||
|
<view class="skeleton-stat-label" :style="shimmerStyle"></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view
|
||||||
|
v-for="n in dashboardListCount"
|
||||||
|
:key="'dash-list-' + n"
|
||||||
|
class="skeleton-list-item skeleton-list-item--compact"
|
||||||
|
:style="cardStyle"
|
||||||
|
>
|
||||||
|
<view class="skeleton-dot" :style="shimmerStyle"></view>
|
||||||
|
<view class="skeleton-content">
|
||||||
|
<view class="skeleton-title" :style="shimmerStyle"></view>
|
||||||
|
<view class="skeleton-subtitle" :style="shimmerStyle"></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</block>
|
||||||
|
|
||||||
|
<block v-else-if="type === 'form'">
|
||||||
|
<view class="skeleton-form" :style="cardStyle">
|
||||||
|
<view
|
||||||
|
v-for="n in count"
|
||||||
|
:key="'form-' + n"
|
||||||
|
class="skeleton-form-field"
|
||||||
|
>
|
||||||
|
<view class="skeleton-label" :style="shimmerStyle"></view>
|
||||||
|
<view class="skeleton-input" :style="shimmerStyle"></view>
|
||||||
|
</view>
|
||||||
|
<view class="skeleton-form-actions">
|
||||||
|
<view class="skeleton-btn skeleton-btn--primary" :style="shimmerStyle"></view>
|
||||||
|
<view class="skeleton-btn skeleton-btn--secondary" :style="shimmerStyle"></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</block>
|
||||||
|
|
||||||
|
<block v-else-if="type === 'custom'">
|
||||||
|
<view class="skeleton-custom" :style="cardStyle">
|
||||||
|
<slot></slot>
|
||||||
|
</view>
|
||||||
|
</block>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import themeMixin from '@/mixins/themeMixin.js'
|
||||||
|
import { THEME_CHANGE_EVENT } from '@/utils/theme'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'SkeletonScreen',
|
||||||
|
mixins: [themeMixin],
|
||||||
|
props: {
|
||||||
|
type: {
|
||||||
|
type: String,
|
||||||
|
default: 'list',
|
||||||
|
validator(val) {
|
||||||
|
return ['list', 'card', 'stats', 'dashboard', 'form', 'custom'].indexOf(val) !== -1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
count: {
|
||||||
|
type: Number,
|
||||||
|
default: 3
|
||||||
|
},
|
||||||
|
animated: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
screenStyle() {
|
||||||
|
return {
|
||||||
|
backgroundColor: this.themeConfig.bgColor
|
||||||
|
}
|
||||||
|
},
|
||||||
|
cardStyle() {
|
||||||
|
return {
|
||||||
|
backgroundColor: this.themeConfig.cardBgColor,
|
||||||
|
borderRadius: '20rpx',
|
||||||
|
padding: '24rpx',
|
||||||
|
marginBottom: '20rpx'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
shimmerStyle() {
|
||||||
|
if (!this.animated) {
|
||||||
|
return {
|
||||||
|
backgroundColor: this.shimmerBaseColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
background: 'linear-gradient(90deg, ' +
|
||||||
|
this.shimmerBaseColor + ' 25%, ' +
|
||||||
|
this.shimmerHighlightColor + ' 50%, ' +
|
||||||
|
this.shimmerBaseColor + ' 75%)',
|
||||||
|
backgroundSize: '200% 100%',
|
||||||
|
animation: 'skeleton-shimmer 1.5s infinite'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ringStyle() {
|
||||||
|
if (!this.animated) {
|
||||||
|
return {
|
||||||
|
border: '12rpx solid ' + this.shimmerBaseColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
border: '12rpx solid transparent',
|
||||||
|
borderTopColor: this.shimmerBaseColor,
|
||||||
|
animation: 'skeleton-shimmer-ring 1.2s infinite linear'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
shimmerBaseColor() {
|
||||||
|
return this.themeConfig.borderLight || '#f0f0f0'
|
||||||
|
},
|
||||||
|
shimmerHighlightColor() {
|
||||||
|
return this.lightenColor(this.shimmerBaseColor, 15)
|
||||||
|
},
|
||||||
|
dashboardListCount() {
|
||||||
|
return Math.min(this.count, 3)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this.syncThemeConfig()
|
||||||
|
this.watchThemeChange()
|
||||||
|
},
|
||||||
|
beforeUnmount() {
|
||||||
|
if (this.themeChangeHandler) {
|
||||||
|
uni.$off(THEME_CHANGE_EVENT, this.themeChangeHandler)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
lightenColor(hex, percent) {
|
||||||
|
if (!hex || hex.charAt(0) !== '#') return hex
|
||||||
|
const num = parseInt(hex.slice(1), 16)
|
||||||
|
const r = Math.min(255, ((num >> 16) & 0xff) + Math.round(255 * percent / 100))
|
||||||
|
const g = Math.min(255, ((num >> 8) & 0xff) + Math.round(255 * percent / 100))
|
||||||
|
const b = Math.min(255, (num & 0xff) + Math.round(255 * percent / 100))
|
||||||
|
return 'rgb(' + r + ', ' + g + ', ' + b + ')'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.skeleton-screen {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 24rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-list-item {
|
||||||
|
display: flex;
|
||||||
|
padding: 24rpx;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-avatar {
|
||||||
|
width: 100rpx;
|
||||||
|
height: 100rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding-left: 20rpx;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-title {
|
||||||
|
height: 36rpx;
|
||||||
|
width: 60%;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-title--lg {
|
||||||
|
height: 44rpx;
|
||||||
|
width: 80%;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-subtitle {
|
||||||
|
height: 28rpx;
|
||||||
|
width: 50%;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-subtitle--short {
|
||||||
|
width: 35%;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-tags {
|
||||||
|
display: flex;
|
||||||
|
margin-top: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-tag {
|
||||||
|
height: 40rpx;
|
||||||
|
width: 80rpx;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
margin-right: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-card {
|
||||||
|
padding: 28rpx;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-card-header {
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-card-body {
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-line {
|
||||||
|
height: 28rpx;
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-line--short {
|
||||||
|
width: 70%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-card-footer {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-line--footer {
|
||||||
|
width: 30%;
|
||||||
|
margin-right: 24rpx;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-stats {
|
||||||
|
display: flex;
|
||||||
|
padding: 32rpx 24rpx;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-stat-col {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-stat-value {
|
||||||
|
height: 48rpx;
|
||||||
|
width: 80%;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-stat-label {
|
||||||
|
height: 24rpx;
|
||||||
|
width: 60%;
|
||||||
|
border-radius: 6rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-dashboard {
|
||||||
|
padding: 32rpx;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-ring-wrapper {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 24rpx 0 32rpx;
|
||||||
|
position: relative;
|
||||||
|
width: 280rpx;
|
||||||
|
height: 280rpx;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-ring {
|
||||||
|
width: 240rpx;
|
||||||
|
height: 240rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-ring-inner {
|
||||||
|
width: 180rpx;
|
||||||
|
height: 180rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-dashboard-stats {
|
||||||
|
display: flex;
|
||||||
|
padding-top: 24rpx;
|
||||||
|
border-top: 2rpx solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-list-item--compact {
|
||||||
|
padding: 20rpx 24rpx;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-dot {
|
||||||
|
width: 16rpx;
|
||||||
|
height: 16rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-right: 20rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-form {
|
||||||
|
padding: 28rpx;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-form-field {
|
||||||
|
margin-bottom: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-label {
|
||||||
|
height: 24rpx;
|
||||||
|
width: 30%;
|
||||||
|
border-radius: 6rpx;
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-input {
|
||||||
|
height: 80rpx;
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-form-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-btn {
|
||||||
|
height: 80rpx;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-btn--primary {
|
||||||
|
margin-right: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-btn--secondary {
|
||||||
|
flex: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-custom {
|
||||||
|
padding: 24rpx;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes skeleton-shimmer {
|
||||||
|
0% {
|
||||||
|
background-position: 200% 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: -200% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes skeleton-shimmer-ring {
|
||||||
|
0% {
|
||||||
|
transform: translate(-50%, -50%) rotate(0deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translate(-50%, -50%) rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
<template>
|
||||||
|
<view class="swipeable-item">
|
||||||
|
<view
|
||||||
|
class="swipeable-item__inner"
|
||||||
|
:style="{ transform: `translateX(${translateX}px)`, transition: animating ? 'transform 0.3s ease' : 'none' }"
|
||||||
|
@touchstart="onTouchStart"
|
||||||
|
@touchmove="onTouchMove"
|
||||||
|
@touchend="onTouchEnd"
|
||||||
|
@touchcancel="onTouchEnd"
|
||||||
|
>
|
||||||
|
<view
|
||||||
|
class="swipeable-item__main"
|
||||||
|
:style="{ backgroundColor: themeConfig.cardBgColor }"
|
||||||
|
@click="onMainClick"
|
||||||
|
>
|
||||||
|
<slot></slot>
|
||||||
|
</view>
|
||||||
|
<view class="swipeable-item__actions">
|
||||||
|
<view
|
||||||
|
v-for="action in actions"
|
||||||
|
:key="action.key"
|
||||||
|
class="swipeable-item__action"
|
||||||
|
:style="{ backgroundColor: getActionColor(action) }"
|
||||||
|
@click.stop="onActionClick(action)"
|
||||||
|
>
|
||||||
|
<text class="swipeable-item__action-text">{{ action.label }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import themeMixin from '@/mixins/themeMixin.js'
|
||||||
|
import { THEME_CHANGE_EVENT } from '@/utils/theme.js'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'SwipeableItem',
|
||||||
|
mixins: [themeMixin],
|
||||||
|
props: {
|
||||||
|
actions: {
|
||||||
|
type: Array,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
autoClose: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
index: {
|
||||||
|
type: [Number, String],
|
||||||
|
default: 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
translateX: 0,
|
||||||
|
animating: false,
|
||||||
|
isOpen: false,
|
||||||
|
startX: 0,
|
||||||
|
thresholdPx: 0,
|
||||||
|
maxOffsetPx: 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
actionColorMap() {
|
||||||
|
return {
|
||||||
|
edit: this.themeConfig.primaryColor,
|
||||||
|
delete: this.themeConfig.dangerColor,
|
||||||
|
done: this.themeConfig.successColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this.syncThemeConfig()
|
||||||
|
this.watchThemeChange()
|
||||||
|
this.thresholdPx = uni.upx2px(80)
|
||||||
|
this.maxOffsetPx = uni.upx2px(this.actions.length * 160)
|
||||||
|
},
|
||||||
|
beforeUnmount() {
|
||||||
|
if (this.themeChangeHandler) {
|
||||||
|
uni.$off(THEME_CHANGE_EVENT, this.themeChangeHandler)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
getActionColor(action) {
|
||||||
|
if (action.color) return action.color
|
||||||
|
return this.actionColorMap[action.key] || this.themeConfig.primaryColor
|
||||||
|
},
|
||||||
|
onTouchStart(e) {
|
||||||
|
if (this.disabled) return
|
||||||
|
this.startX = e.touches[0].clientX
|
||||||
|
this.animating = false
|
||||||
|
},
|
||||||
|
onTouchMove(e) {
|
||||||
|
if (this.disabled) return
|
||||||
|
const currentX = e.touches[0].clientX
|
||||||
|
const delta = currentX - this.startX
|
||||||
|
this.translateX = Math.max(-this.maxOffsetPx, Math.min(0, delta))
|
||||||
|
},
|
||||||
|
onTouchEnd() {
|
||||||
|
if (this.disabled) return
|
||||||
|
this.animating = true
|
||||||
|
if (this.translateX < -this.thresholdPx) {
|
||||||
|
this.open()
|
||||||
|
} else {
|
||||||
|
this.close()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
open() {
|
||||||
|
this.translateX = -this.maxOffsetPx
|
||||||
|
this.isOpen = true
|
||||||
|
this.$emit('open', this.index)
|
||||||
|
this.$emit('closeOthers', this.index)
|
||||||
|
},
|
||||||
|
close() {
|
||||||
|
this.translateX = 0
|
||||||
|
this.isOpen = false
|
||||||
|
this.$emit('close', this.index)
|
||||||
|
},
|
||||||
|
onMainClick() {
|
||||||
|
if (this.isOpen) {
|
||||||
|
this.close()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onActionClick(action) {
|
||||||
|
this.$emit('action', action.key)
|
||||||
|
if (this.autoClose) {
|
||||||
|
this.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.swipeable-item {
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swipeable-item__inner {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
width: 100%;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swipeable-item__main {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swipeable-item__actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swipeable-item__action {
|
||||||
|
width: 160rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swipeable-item__action-text {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<template>
|
||||||
|
<view class="title-badge" :style="{ background: badgeGradient }">
|
||||||
|
<text class="badge-icon">{{ icon }}</text>
|
||||||
|
<text class="badge-title">{{ title }}</text>
|
||||||
|
<text class="badge-level">Lv.{{ level }}</text>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import themeMixin from '../mixins/themeMixin.js'
|
||||||
|
import { THEME_CHANGE_EVENT } from '../utils/theme.js'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'TitleBadge',
|
||||||
|
mixins: [themeMixin],
|
||||||
|
props: {
|
||||||
|
level: {
|
||||||
|
type: Number,
|
||||||
|
default: 1
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
icon: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this.syncThemeConfig()
|
||||||
|
this.watchThemeChange()
|
||||||
|
},
|
||||||
|
beforeUnmount() {
|
||||||
|
if (this.themeChangeHandler) {
|
||||||
|
uni.$off(THEME_CHANGE_EVENT, this.themeChangeHandler)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
badgeGradient() {
|
||||||
|
const theme = this.themeConfig
|
||||||
|
return `linear-gradient(135deg, ${theme.primaryColor}, ${theme.secondaryColor})`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.title-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8rpx 24rpx;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.15);
|
||||||
|
gap: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-icon {
|
||||||
|
font-size: 32rpx;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-title {
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-level {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
background: rgba(255, 255, 255, 0.25);
|
||||||
|
padding: 2rpx 10rpx;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
<template>
|
||||||
|
<view class="today-progress">
|
||||||
|
<view class="progress-header">
|
||||||
|
<FaIcon icon="chart-column" :size="22" color="#ff9f43" />
|
||||||
|
<text class="progress-title">今日进度</text>
|
||||||
|
<view class="exp-display">
|
||||||
|
<text class="exp-current">{{ todayProgress.totalExp }}</text>
|
||||||
|
<text class="exp-separator">/</text>
|
||||||
|
<text class="exp-max">{{ todayProgress.maxDailyExp }}</text>
|
||||||
|
<text class="exp-unit">EXP</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="progress-stats">
|
||||||
|
<view class="stat-item">
|
||||||
|
<FaIcon icon="list-check" :size="22" color="#4cb8c6" />
|
||||||
|
<text class="stat-value">{{ todayProgress.tasksCompleted }}</text>
|
||||||
|
<text class="stat-label">任务完成</text>
|
||||||
|
</view>
|
||||||
|
<view class="stat-divider"></view>
|
||||||
|
<view class="stat-item">
|
||||||
|
<FaIcon icon="money-bill" :size="22" color="#ff9f43" />
|
||||||
|
<text class="stat-value">{{ todayProgress.billsRecorded }}</text>
|
||||||
|
<text class="stat-label">记账记录</text>
|
||||||
|
</view>
|
||||||
|
<view class="stat-divider"></view>
|
||||||
|
<view class="stat-item">
|
||||||
|
<FaIcon :icon="todayProgress.moodRecorded ? 'face-smile' : 'calendar-xmark'" :size="22" :color="todayProgress.moodRecorded ? '#52c41a' : '#999'" />
|
||||||
|
<view class="stat-value" v-if="todayProgress.moodRecorded">
|
||||||
|
<FaIcon icon="check" :size="16" color="#52c41a" />
|
||||||
|
</view>
|
||||||
|
<text class="stat-value" v-else>-</text>
|
||||||
|
<text class="stat-label">心情记录</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="bonus-info">
|
||||||
|
<view class="bonus-item">
|
||||||
|
<FaIcon icon="fire" :size="14" color="#ff9f43" />
|
||||||
|
<text>连续加成: +{{ todayProgress.streakBonus }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import FaIcon from '@/components/FaIcon.vue'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'TodayProgress',
|
||||||
|
components: { FaIcon },
|
||||||
|
props: {
|
||||||
|
todayProgress: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({
|
||||||
|
tasksCompleted: 0,
|
||||||
|
billsRecorded: 0,
|
||||||
|
moodRecorded: false,
|
||||||
|
streakBonus: 0,
|
||||||
|
totalExp: 0,
|
||||||
|
maxDailyExp: 120
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.today-progress {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
padding: 28rpx;
|
||||||
|
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
gap: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-title {
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exp-display {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exp-current {
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ff9f43;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exp-separator {
|
||||||
|
font-size: 20rpx;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exp-max {
|
||||||
|
font-size: 20rpx;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exp-unit {
|
||||||
|
font-size: 18rpx;
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-stats {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-around;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8rpx;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 48rpx;
|
||||||
|
background: rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bonus-info {
|
||||||
|
margin-top: 20rpx;
|
||||||
|
padding-top: 20rpx;
|
||||||
|
border-top: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bonus-item {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #ff9f43;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6rpx;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
# 🌱 成长之路 - 用户指南
|
||||||
|
|
||||||
|
欢迎来到成长系统!在这里,你的每一次记录都在积累力量,让我们一起看看如何快速成长吧~
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 我的等级
|
||||||
|
|
||||||
|
### 当前等级:Lv.1 新手冒险家 🎒
|
||||||
|
|
||||||
|
| 我的经验 | 下一等级所需 |
|
||||||
|
| :--- | :--- |
|
||||||
|
| 0 EXP | 200 EXP |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⬆️ 如何升级
|
||||||
|
|
||||||
|
### 升级规则
|
||||||
|
|
||||||
|
```
|
||||||
|
累计经验达到对应等级的要求,即可自动升级!
|
||||||
|
```
|
||||||
|
|
||||||
|
| 等级 | 头衔 | 所需经验 | 图标 |
|
||||||
|
| :--- | :--- | :---: | :---: |
|
||||||
|
| Lv.1 | 新手冒险家 | 0 | 🎒 |
|
||||||
|
| Lv.2 | 坚持学徒 | 200 | 📝 |
|
||||||
|
| Lv.3 | 自律达人 | 600 | ⭐ |
|
||||||
|
| Lv.4 | 习惯大师 | 1,500 | 💎 |
|
||||||
|
| Lv.5 | 传奇记录者 | 3,000 | 👑 |
|
||||||
|
| Lv.6 | 时光守望者 | 6,000 | 🏆 |
|
||||||
|
| Lv.7 | 超级达人 | 10,000 | 💫 |
|
||||||
|
| Lv.8 | 传说存在 | 15,000 | 🌟 |
|
||||||
|
| Lv.9 | 永恒传奇 | 22,000 | ⭐ |
|
||||||
|
| Lv.10 | 至高王者 | 30,000 | 👑 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✨ 如何获取经验
|
||||||
|
|
||||||
|
### 基础获取方式
|
||||||
|
|
||||||
|
| 每日活动 | 经验奖励 | 每日上限 |
|
||||||
|
| :--- | :---: | :---: |
|
||||||
|
| ✅ 完成待办任务 | 5 EXP/个 | 任务做越多,奖励越高 |
|
||||||
|
| 💰 记录账单 | 3 EXP/笔 | 账单记越多,奖励越高 |
|
||||||
|
| 😊 记录心情 | 5 EXP | 限1次/天 |
|
||||||
|
|
||||||
|
### 🎯 做任务 - 经验递增
|
||||||
|
|
||||||
|
| 完成任务数 | 获得经验 |
|
||||||
|
| :--- | :--- |
|
||||||
|
| 1个 | 5 EXP |
|
||||||
|
| 2个 | 10 EXP |
|
||||||
|
| 3个 | 20 EXP |
|
||||||
|
| 5个 | 45 EXP |
|
||||||
|
|
||||||
|
> 💡 提示:一天完成的任务越多,每个任务的奖励就越高!
|
||||||
|
|
||||||
|
### 💰 记账 - 经验递增
|
||||||
|
|
||||||
|
| 记账笔数 | 获得经验 |
|
||||||
|
| :--- | :--- |
|
||||||
|
| 1笔 | 3 EXP |
|
||||||
|
| 2笔 | 6 EXP |
|
||||||
|
| 3笔 | 14 EXP |
|
||||||
|
| 5笔 | 33 EXP |
|
||||||
|
|
||||||
|
> 💡 提示:一天记录5笔账单,获得的经验是做1笔的5倍多!
|
||||||
|
|
||||||
|
### 📅 连续打卡加成
|
||||||
|
|
||||||
|
坚持记录,连续天数越久,每天额外奖励越多!
|
||||||
|
|
||||||
|
| 连续天数 | 每日额外加成 |
|
||||||
|
| :--- | :---: |
|
||||||
|
| 1-7天 | +2 EXP/天 |
|
||||||
|
| 8-30天 | +5 EXP/天 |
|
||||||
|
| 31-90天 | +10 EXP/天 |
|
||||||
|
| 91天以上 | +15 EXP/天 |
|
||||||
|
|
||||||
|
**示例**:
|
||||||
|
- 坚持7天 → 每天多拿2 EXP
|
||||||
|
- 坚持30天 → 每天多拿5 EXP
|
||||||
|
- 坚持100天 → 每天多拿15 EXP
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛡️ 新手保护
|
||||||
|
|
||||||
|
新用户前7天享受 **保护期** 特权:
|
||||||
|
|
||||||
|
- ✨ 每天额外获得 **50%** 加成
|
||||||
|
- 🛡️ 忘记打卡不会完全重置连续天数
|
||||||
|
- 💪 保护期内的连续加成会 **翻倍**
|
||||||
|
|
||||||
|
> 💡 即使偶尔忘记打卡,也不会“一夜回到解放前”哦~
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔢 每日经验上限
|
||||||
|
|
||||||
|
**每天最多获得 120 EXP**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎖️ 成就系统
|
||||||
|
|
||||||
|
完成特定目标,解锁成就并获得额外经验奖励!
|
||||||
|
|
||||||
|
### 📅 坚持成就
|
||||||
|
|
||||||
|
| 成就 | 条件 | 奖励 |
|
||||||
|
| :--- | :--- | :---: |
|
||||||
|
| 初次尝试 | 完成第1天记录 | +10 EXP |
|
||||||
|
| 3天坚持 | 连续打卡3天 | +20 EXP |
|
||||||
|
| 一周打卡 | 连续打卡7天 | +50 EXP |
|
||||||
|
| 月度坚持 | 连续打卡30天 | +200 EXP |
|
||||||
|
| 百日筑基 | 连续打卡100天 | +500 EXP |
|
||||||
|
| 年度传奇 | 连续打卡365天 | +2000 EXP |
|
||||||
|
|
||||||
|
### ✅ 任务成就
|
||||||
|
|
||||||
|
| 成就 | 条件 | 奖励 |
|
||||||
|
| :--- | :--- | :---: |
|
||||||
|
| 任务新手 | 完成1个任务 | +10 EXP |
|
||||||
|
| 任务达人 | 累计100个任务 | +200 EXP |
|
||||||
|
| 任务大师 | 累计500个任务 | +800 EXP |
|
||||||
|
| 完美一天 | 单日完成所有待办 | +30 EXP |
|
||||||
|
| 完美一周 | 连续7天100%完成 | +200 EXP |
|
||||||
|
|
||||||
|
### 💰 记账成就
|
||||||
|
|
||||||
|
| 成就 | 条件 | 奖励 |
|
||||||
|
| :--- | :--- | :---: |
|
||||||
|
| 记账入门 | 记账1次 | +10 EXP |
|
||||||
|
| 记账达人 | 累计100笔 | +200 EXP |
|
||||||
|
| 记账大师 | 累计500笔 | +500 EXP |
|
||||||
|
|
||||||
|
### 😊 心情成就
|
||||||
|
|
||||||
|
| 成就 | 条件 | 奖励 |
|
||||||
|
| :--- | :--- | :---: |
|
||||||
|
| 心情记录者 | 第1次记录心情 | +10 EXP |
|
||||||
|
| 月度心情家 | 累计30次心情 | +100 EXP |
|
||||||
|
| 心情收藏家 | 累计100次心情 | +300 EXP |
|
||||||
|
| 阳光达人 | 连续10天记录开心 | +150 EXP |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎁 等级特权
|
||||||
|
|
||||||
|
等级越高,解锁的特权越多!
|
||||||
|
|
||||||
|
| 等级 | 解锁特权 |
|
||||||
|
| :--- | :--- |
|
||||||
|
| Lv.1 | 基础功能 |
|
||||||
|
| Lv.2 | 🌈 解锁心情日历 |
|
||||||
|
| Lv.3 | 🌈 解锁心情日历 📊 解锁数据报表 |
|
||||||
|
| Lv.4 | 🌈 解锁心情日历 📊 解锁数据报表 🎨 解锁自定义主题 |
|
||||||
|
| Lv.5 | 🌈 解锁心情日历 📊 解锁数据报表 🎨 解锁自定义主题 🚀 解锁全部功能 |
|
||||||
|
| Lv.6 | 🌈 解锁心情日历 📊 解锁数据报表 🎨 解锁自定义主题 🚀 解锁全部功能 📅 解锁年度回顾 |
|
||||||
|
| Lv.7+ | 🌈 解锁心情日历 📊 解锁数据报表 🎨 解锁自定义主题 🚀 解锁全部功能 📅 解锁年度回顾 📤 解锁数据导出 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💡 成长小贴士
|
||||||
|
|
||||||
|
### 🌟 快速升级秘籍
|
||||||
|
|
||||||
|
1. **每日必做**:每天完成至少3个待办任务
|
||||||
|
- 3个任务 = 20 EXP
|
||||||
|
- 加上连续加成 = 25 EXP/天
|
||||||
|
|
||||||
|
2. **坚持记账**:每天记录3-5笔账单
|
||||||
|
- 5笔账单 = 33 EXP
|
||||||
|
- 加上连续加成 = 38 EXP/天
|
||||||
|
|
||||||
|
3. **别忘心情**:每天记录一次心情
|
||||||
|
- 心情记录 = 5 EXP
|
||||||
|
- 轻松简单,积少成多!
|
||||||
|
|
||||||
|
4. **保持连续**:尽量不要断签
|
||||||
|
- 连续越久,每天加成越高
|
||||||
|
- 断了也没关系,保护期会保留部分天数
|
||||||
|
|
||||||
|
### 📈 每日收益估算
|
||||||
|
|
||||||
|
| 每日完成度 | 预计经验 |
|
||||||
|
| :--- | :---: |
|
||||||
|
| 只做1个任务 | ~5-7 EXP |
|
||||||
|
| 3个任务 + 心情 | ~30-40 EXP |
|
||||||
|
| 5个任务 + 5笔账单 + 心情 | ~80-100 EXP |
|
||||||
|
| 满额(连续加成拉满) | **120 EXP** |
|
||||||
|
|
||||||
|
> 💰 按每天100 EXP计算:
|
||||||
|
> - Lv.1 → Lv.2:只需 **2天**
|
||||||
|
> - Lv.2 → Lv.3:只需 **4天**
|
||||||
|
> - Lv.3 → Lv.4:只需 **9天**
|
||||||
|
> - Lv.4 → Lv.5:只需 **15天**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ❓ 常见问题
|
||||||
|
|
||||||
|
**Q: 忘记打卡会怎样?**
|
||||||
|
A: 不用担心!如果连续天数不长,会重新从1开始计算。如果连续天数很长,会保留一部分(保留3-15天)。
|
||||||
|
|
||||||
|
**Q: 每天的经验有上限吗?**
|
||||||
|
A: 有的,每天最多获得120 EXP。
|
||||||
|
|
||||||
|
**Q: 成就可以重复解锁吗?**
|
||||||
|
A: 不行,每个成就只能解锁一次。
|
||||||
|
|
||||||
|
**Q: 经验会清零吗?**
|
||||||
|
A: 不会!你的等级和经验会一直保留。
|
||||||
|
|
||||||
|
**Q: 如何查看自己还差多少升级?**
|
||||||
|
A: 在「成长之路」页面可以查看当前经验和下一等级所需经验。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📱 查看入口
|
||||||
|
|
||||||
|
在我的页面点击 **「成长之路」** 卡片,可以查看:
|
||||||
|
- 当前等级和经验进度
|
||||||
|
- 今日完成情况
|
||||||
|
- 已解锁的成就
|
||||||
|
- 享有的特权
|
||||||
|
|
||||||
|
点击 **「查看成长规则」** 按钮,可以查看详细的规则说明~
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**祝你玩得开心,记录美好生活!** 🌟
|
||||||
|
|
||||||
|
*最后更新:2024年8月*
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<script>
|
||||||
|
var coverSupport = 'CSS' in window && typeof CSS.supports === 'function' && (CSS.supports('top: env(a)') ||
|
||||||
|
CSS.supports('top: constant(a)'))
|
||||||
|
document.write(
|
||||||
|
'<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0' +
|
||||||
|
(coverSupport ? ', viewport-fit=cover' : '') + '" />')
|
||||||
|
</script>
|
||||||
|
<title></title>
|
||||||
|
|
||||||
|
<!-- 👇 👇 只加这一行!本地 utils 里面的 crypto-js!!! -->
|
||||||
|
<script src="./utils/crypto-js.min.js"></script>
|
||||||
|
|
||||||
|
<!--preload-links-->
|
||||||
|
<!--app-context-->
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"><!--app-html--></div>
|
||||||
|
<script type="module" src="/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { createSSRApp } from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
|
||||||
|
// 1. 引入主题核心方法
|
||||||
|
import {
|
||||||
|
THEME_PRESETS,
|
||||||
|
switchTheme,
|
||||||
|
getThemeConfigFromStorage,
|
||||||
|
initTheme
|
||||||
|
} from './utils/theme.js'
|
||||||
|
|
||||||
|
// 2. 导入主题混入文件
|
||||||
|
import themeMixin from './mixins/themeMixin.js'
|
||||||
|
|
||||||
|
|
||||||
|
export function createApp() {
|
||||||
|
const app = createSSRApp(App)
|
||||||
|
|
||||||
|
// 注册全局混入
|
||||||
|
app.mixin(themeMixin)
|
||||||
|
|
||||||
|
|
||||||
|
// 挂载的全局主题方法
|
||||||
|
app.config.globalProperties.$theme = {
|
||||||
|
presets: THEME_PRESETS,
|
||||||
|
switchTheme: switchTheme,
|
||||||
|
getConfig: getThemeConfigFromStorage,
|
||||||
|
initTheme: initTheme
|
||||||
|
}
|
||||||
|
|
||||||
|
return { app }
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
{
|
||||||
|
"name" : "simple-memo-wx",
|
||||||
|
"appid" : "",
|
||||||
|
"description" : "",
|
||||||
|
"versionName" : "1.0.0",
|
||||||
|
"versionCode" : "100",
|
||||||
|
"transformPx" : false,
|
||||||
|
/* 5+App特有相关 */
|
||||||
|
"app-plus" : {
|
||||||
|
"usingComponents" : true,
|
||||||
|
"nvueStyleCompiler" : "uni-app",
|
||||||
|
"compilerVersion" : 3,
|
||||||
|
"splashscreen" : {
|
||||||
|
"alwaysShowBeforeRender" : true,
|
||||||
|
"waiting" : true,
|
||||||
|
"autoclose" : true,
|
||||||
|
"delay" : 0
|
||||||
|
},
|
||||||
|
/* 模块配置 */
|
||||||
|
"modules" : {},
|
||||||
|
/* 应用发布信息 */
|
||||||
|
"distribute" : {
|
||||||
|
/* android打包配置 */
|
||||||
|
"android" : {
|
||||||
|
"permissions" : [
|
||||||
|
"<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.READ_LOGS\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
|
||||||
|
"<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.GET_ACCOUNTS\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.READ_PHONE_STATE\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.FLASHLIGHT\"/>",
|
||||||
|
"<uses-feature android:name=\"android.hardware.camera\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
/* ios打包配置 */
|
||||||
|
"ios" : {},
|
||||||
|
/* SDK配置 */
|
||||||
|
"sdkConfigs" : {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/* 快应用特有相关 */
|
||||||
|
"quickapp" : {},
|
||||||
|
/* 小程序特有相关 */
|
||||||
|
"mp-weixin" : {
|
||||||
|
"appid" : "wx6a3215bd78a6ff13",
|
||||||
|
"setting" : {
|
||||||
|
"urlCheck" : false,
|
||||||
|
"es6" : true,
|
||||||
|
"postcss" : true,
|
||||||
|
"minified" : true
|
||||||
|
},
|
||||||
|
"usingComponents" : true
|
||||||
|
},
|
||||||
|
"mp-alipay" : {
|
||||||
|
"usingComponents" : true
|
||||||
|
},
|
||||||
|
"mp-baidu" : {
|
||||||
|
"usingComponents" : true
|
||||||
|
},
|
||||||
|
"mp-toutiao" : {
|
||||||
|
"usingComponents" : true
|
||||||
|
},
|
||||||
|
"uniStatistics" : {
|
||||||
|
"enable" : false
|
||||||
|
},
|
||||||
|
"vueVersion" : "3"
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
growthModalVisible: false,
|
||||||
|
growthModalTitle: '',
|
||||||
|
growthModalMessage: '',
|
||||||
|
growthModalIcon: '',
|
||||||
|
growthModalConfirmText: '太棒了!',
|
||||||
|
growthModalCallback: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
showGrowthModal(growth) {
|
||||||
|
this.showGrowthModalWithCallback(growth, null)
|
||||||
|
},
|
||||||
|
showGrowthModalWithCallback(growth, callback) {
|
||||||
|
this.growthModalCallback = callback
|
||||||
|
if (growth.levelUp) {
|
||||||
|
const privilegesText = growth.newPrivileges && growth.newPrivileges.length
|
||||||
|
? '\n解锁特权:' + growth.newPrivileges.join('、')
|
||||||
|
: ''
|
||||||
|
this.growthModalTitle = '🎉 恭喜升级!'
|
||||||
|
this.growthModalMessage = `等级:Lv.${growth.oldLevel || growth.currentLevel - 1} → Lv.${growth.currentLevel}\n称号:${growth.levelTitle || '暂无'}\n获得经验:+${growth.expGained || 0} EXP${privilegesText}`
|
||||||
|
this.growthModalIcon = '🎉'
|
||||||
|
this.growthModalConfirmText = '太棒了!'
|
||||||
|
this.growthModalVisible = true
|
||||||
|
} else if (growth.newAchievements && growth.newAchievements.length > 0) {
|
||||||
|
const ach = growth.newAchievements[0]
|
||||||
|
this.growthModalTitle = '🏆 获得成就!'
|
||||||
|
this.growthModalMessage = `${ach.name || ''}\n${ach.description || ''}\n稀有度:${ach.rarityName || '初级'}\n获得经验:+${ach.expReward || 0} EXP`
|
||||||
|
this.growthModalIcon = ach.icon || '🏆'
|
||||||
|
this.growthModalConfirmText = '太棒了!'
|
||||||
|
this.growthModalVisible = true
|
||||||
|
} else {
|
||||||
|
if (typeof callback === 'function') {
|
||||||
|
callback()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onGrowthModalClose() {
|
||||||
|
this.growthModalVisible = false
|
||||||
|
if (typeof this.growthModalCallback === 'function') {
|
||||||
|
this.growthModalCallback()
|
||||||
|
this.growthModalCallback = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { getMoodList } from '@/api'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
moodList: [],
|
||||||
|
moodPage: 1,
|
||||||
|
moodPageSize: 20,
|
||||||
|
moodTotal: 0,
|
||||||
|
moodLoading: false,
|
||||||
|
moodHasMore: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async loadMoodList(params = {}) {
|
||||||
|
if (this.moodLoading) return
|
||||||
|
this.moodLoading = true
|
||||||
|
try {
|
||||||
|
const res = await getMoodList({
|
||||||
|
page: this.moodPage,
|
||||||
|
pageSize: this.moodPageSize,
|
||||||
|
...params
|
||||||
|
})
|
||||||
|
const list = res?.list || res?.data || []
|
||||||
|
this.moodTotal = res?.total || list.length
|
||||||
|
if (this.moodPage === 1) {
|
||||||
|
this.moodList = list
|
||||||
|
} else {
|
||||||
|
this.moodList = [...this.moodList, ...list]
|
||||||
|
}
|
||||||
|
this.moodHasMore = this.moodList.length < this.moodTotal
|
||||||
|
return list
|
||||||
|
} catch (e) {
|
||||||
|
console.error('加载心情列表失败:', e)
|
||||||
|
return []
|
||||||
|
} finally {
|
||||||
|
this.moodLoading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async refreshMoodList(params = {}) {
|
||||||
|
this.moodPage = 1
|
||||||
|
this.moodHasMore = true
|
||||||
|
return this.loadMoodList(params)
|
||||||
|
},
|
||||||
|
async loadMoreMood(params = {}) {
|
||||||
|
if (!this.moodHasMore || this.moodLoading) return
|
||||||
|
this.moodPage++
|
||||||
|
return this.loadMoodList(params)
|
||||||
|
},
|
||||||
|
resetMoodList() {
|
||||||
|
this.moodList = []
|
||||||
|
this.moodPage = 1
|
||||||
|
this.moodTotal = 0
|
||||||
|
this.moodHasMore = true
|
||||||
|
},
|
||||||
|
removeMoodItem(id) {
|
||||||
|
this.moodList = this.moodList.filter(item => item.id !== id)
|
||||||
|
this.moodTotal = Math.max(0, this.moodTotal - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
// mixins/themeMixin.js(Vue3 适配版)
|
||||||
|
import {
|
||||||
|
getThemeConfigFromStorage,
|
||||||
|
THEME_CHANGE_EVENT,
|
||||||
|
THEME_PRESETS,
|
||||||
|
DEFAULT_THEME_KEY
|
||||||
|
} from '../utils/theme.js' // 注意路径:Vue3 中 @ 别名需确认,这里先用相对路径
|
||||||
|
|
||||||
|
export default {
|
||||||
|
// Vue3 中 data 需为函数(和Vue2一致,无需改)
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
themeConfig: getThemeConfigFromStorage()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Vue3 计算属性写法不变
|
||||||
|
computed: {
|
||||||
|
pageThemeStyle() {
|
||||||
|
return {
|
||||||
|
backgroundColor: this.themeConfig.bgColor,
|
||||||
|
color: this.themeConfig.textPrimary,
|
||||||
|
minHeight: '100vh'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
cardThemeStyle() {
|
||||||
|
return {
|
||||||
|
backgroundColor: this.themeConfig.cardBgColor,
|
||||||
|
borderColor: this.themeConfig.borderColor,
|
||||||
|
boxShadow: `0 6rpx 20rpx ${this.themeConfig.shadowColor}`,
|
||||||
|
borderRadius: '28rpx'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
btnPrimaryStyle() {
|
||||||
|
return {
|
||||||
|
backgroundColor: this.themeConfig.primaryColor,
|
||||||
|
color: '#ffffff',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '14rpx'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==================== 关键:Vue3 生命周期适配 ====================
|
||||||
|
// 小程序中 Vue3 仍支持 onLoad/onShow/onUnload 等原生生命周期
|
||||||
|
onLoad() {
|
||||||
|
this.syncThemeConfig()
|
||||||
|
this.watchThemeChange()
|
||||||
|
},
|
||||||
|
|
||||||
|
onShow() {
|
||||||
|
this.syncThemeConfig()
|
||||||
|
},
|
||||||
|
|
||||||
|
onUnload() {
|
||||||
|
// 移除监听,避免内存泄漏
|
||||||
|
uni.$off(THEME_CHANGE_EVENT, this.themeChangeHandler)
|
||||||
|
},
|
||||||
|
|
||||||
|
// 方法部分(和Vue2逻辑一致,无需改)
|
||||||
|
methods: {
|
||||||
|
/**
|
||||||
|
* 同步最新主题配置(优先级:全局 > 本地 > 默认)
|
||||||
|
*/
|
||||||
|
syncThemeConfig() {
|
||||||
|
const appInstance = getApp({ allowDefault: true })
|
||||||
|
const globalTheme = appInstance?.globalData?.themeConfig
|
||||||
|
|
||||||
|
this.themeConfig = globalTheme
|
||||||
|
|| getThemeConfigFromStorage()
|
||||||
|
|| THEME_PRESETS[DEFAULT_THEME_KEY]
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 监听全局主题变更事件
|
||||||
|
*/
|
||||||
|
watchThemeChange() {
|
||||||
|
this.themeChangeHandler = ({ key, config }) => {
|
||||||
|
if (key && config) {
|
||||||
|
this.themeConfig = config
|
||||||
|
// 可选:页面自定义主题更新逻辑
|
||||||
|
this.onThemeUpdated && this.onThemeUpdated()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
uni.$on(THEME_CHANGE_EVENT, this.themeChangeHandler)
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 【可选】页面自定义主题更新逻辑(供页面重写)
|
||||||
|
*/
|
||||||
|
onThemeUpdated() {
|
||||||
|
// 空实现
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
{
|
||||||
|
"pages": [
|
||||||
|
{
|
||||||
|
"path": "pages/index/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "简记",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/login/login",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "登录"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/todo/todo",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "待办",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/bill/bill",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "记账",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/mine/mine",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "我的"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/userAgreement/userAgreement",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "用户协议"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/privacyPolicy/privacyPolicy",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "隐私政策"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/themeSetting/themeSetting",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "主题设置"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/about/about",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "关于"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/mood/mood",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "心情"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/moodCalendar/moodCalendar",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "心情日历"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/growth/growth",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "成长之路"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/growth/rules",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "成长规则"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/review/review",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "复盘"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/review/reviewList",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "复盘历史",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tabBar": {
|
||||||
|
"color": "#7f8c8d",
|
||||||
|
"selectedColor": "#4cb8c6",
|
||||||
|
"backgroundColor": "#ffffff",
|
||||||
|
"borderStyle": "white",
|
||||||
|
"height": "56px",
|
||||||
|
"list": [{
|
||||||
|
"pagePath": "pages/index/index",
|
||||||
|
"text": "今日",
|
||||||
|
"iconPath": "/static/tabbar/index_default.png",
|
||||||
|
"selectedIconPath": "/static/tabbar/index_selected.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pagePath": "pages/mine/mine",
|
||||||
|
"text": "我的",
|
||||||
|
"iconPath": "/static/tabbar/mine_default.png",
|
||||||
|
"selectedIconPath": "/static/tabbar/mine_selected.png"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"globalStyle": {
|
||||||
|
"navigationBarTextStyle": "black",
|
||||||
|
"navigationBarTitleText": "简记memo",
|
||||||
|
"navigationBarBackgroundColor": "#f9fbfc",
|
||||||
|
"backgroundColor": "#f9fbfc",
|
||||||
|
"backgroundTextStyle": "dark"
|
||||||
|
},
|
||||||
|
"app-plus": {
|
||||||
|
"titleNView": {
|
||||||
|
"backgroundColor": "#f9fbfc",
|
||||||
|
"titleColor": "#2c3e50"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lazyCodeLoading": "requiredComponents",
|
||||||
|
"sitemapLocation": "sitemap.json"
|
||||||
|
}
|
||||||
@@ -0,0 +1,575 @@
|
|||||||
|
<template>
|
||||||
|
<view class="about-page theme-transition" :style="{ background: themeConfig.bgColor }">
|
||||||
|
<!-- 顶部品牌区 -->
|
||||||
|
<view class="header-card" :style="{ background: headerGradient }">
|
||||||
|
<view class="header-decoration">
|
||||||
|
<view class="decoration-circle c1"></view>
|
||||||
|
<view class="decoration-circle c2"></view>
|
||||||
|
<view class="decoration-circle c3"></view>
|
||||||
|
<view class="decoration-circle c4"></view>
|
||||||
|
</view>
|
||||||
|
<view class="header-content">
|
||||||
|
<view class="logo-wrapper">
|
||||||
|
<view class="logo-box" v-if="!logoLoaded" :style="{ background: 'rgba(255,255,255,0.95)' }">
|
||||||
|
<text class="logo-text-fallback" :style="{ color: themeConfig.primaryColor }">M</text>
|
||||||
|
</view>
|
||||||
|
<image
|
||||||
|
class="app-logo"
|
||||||
|
:src="logoConfig.path"
|
||||||
|
mode="aspectFit"
|
||||||
|
@load="logoLoaded = true"
|
||||||
|
@error="logoLoaded = false"
|
||||||
|
v-show="logoLoaded"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<text class="app-name">{{ logoConfig.name }}</text>
|
||||||
|
<text class="app-subtitle">{{ logoConfig.subtitle }}</text>
|
||||||
|
<view class="version-tag">
|
||||||
|
<text class="version-text">{{ logoConfig.version }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="content-area">
|
||||||
|
<!-- 联系与支持 -->
|
||||||
|
<view class="info-card theme-transition" :style="{ background: themeConfig.cardBgColor }">
|
||||||
|
<view class="card-header">
|
||||||
|
<view class="card-icon" :style="{ background: themeConfig.primaryColor + '18' }">
|
||||||
|
<FaIcon icon="headphones" :size="16" :color="themeConfig.primaryColor" />
|
||||||
|
</view>
|
||||||
|
<text class="card-title" :style="{ color: themeConfig.textPrimary }">联系与支持</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-list">
|
||||||
|
<view class="info-row">
|
||||||
|
<view class="info-icon-box" :style="{ background: themeConfig.primaryColor + '12' }">
|
||||||
|
<FaIcon icon="mobile" :size="16" :color="themeConfig.primaryColor" />
|
||||||
|
</view>
|
||||||
|
<view class="info-body">
|
||||||
|
<text class="info-label" :style="{ color: themeConfig.textSecondary }">版本号</text>
|
||||||
|
<text class="info-value" :style="{ color: themeConfig.textPrimary }">{{ logoConfig.version }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="info-row divider" @click="copyEmail">
|
||||||
|
<view class="info-icon-box" :style="{ background: (themeConfig.warningColor || '#FFAA33') + '15' }">
|
||||||
|
<FaIcon icon="envelope" :size="16" :color="themeConfig.warningColor || '#FFAA33'" />
|
||||||
|
</view>
|
||||||
|
<view class="info-body">
|
||||||
|
<text class="info-label" :style="{ color: themeConfig.textSecondary }">反馈邮箱</text>
|
||||||
|
<text class="info-value" :style="{ color: themeConfig.textPrimary }">{{ logoConfig.email }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="copy-btn" :style="{ color: themeConfig.primaryColor, borderColor: themeConfig.primaryColor + '40', background: themeConfig.primaryColor + '10' }">
|
||||||
|
<FaIcon icon="copy" :size="12" :color="themeConfig.primaryColor" />
|
||||||
|
<text class="copy-btn-text" :style="{ color: themeConfig.primaryColor }">复制</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="info-row" @click="copyWechat">
|
||||||
|
<view class="info-icon-box" :style="{ background: (themeConfig.successColor || '#22C55E') + '15' }">
|
||||||
|
<FaIcon icon="comment" :size="16" :color="themeConfig.successColor || '#22C55E'" />
|
||||||
|
</view>
|
||||||
|
<view class="info-body">
|
||||||
|
<text class="info-label" :style="{ color: themeConfig.textSecondary }">微信公众号</text>
|
||||||
|
<text class="info-value" :style="{ color: themeConfig.textPrimary }">{{ logoConfig.wechat.name }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="copy-btn" :style="{ color: themeConfig.primaryColor, borderColor: themeConfig.primaryColor + '40', background: themeConfig.primaryColor + '10' }">
|
||||||
|
<FaIcon icon="copy" :size="12" :color="themeConfig.primaryColor" />
|
||||||
|
<text class="copy-btn-text" :style="{ color: themeConfig.primaryColor }">复制</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 法律与协议 -->
|
||||||
|
<view class="menu-card theme-transition" :style="{ background: themeConfig.cardBgColor }">
|
||||||
|
<view class="menu-item" @click="gotoUserAgreement">
|
||||||
|
<view class="menu-icon-box" :style="{ background: themeConfig.primaryColor + '15' }">
|
||||||
|
<FaIcon icon="file-lines" :size="16" :color="themeConfig.primaryColor" />
|
||||||
|
</view>
|
||||||
|
<text class="menu-text" :style="{ color: themeConfig.textPrimary }">用户协议</text>
|
||||||
|
<FaIcon icon="chevron-right" :size="18" :color="themeConfig.textDisabled" />
|
||||||
|
</view>
|
||||||
|
<view class="menu-item" @click="gotoPrivacyPolicy">
|
||||||
|
<view class="menu-icon-box" :style="{ background: themeConfig.secondaryColor + '15' }">
|
||||||
|
<FaIcon icon="shield-halved" :size="16" :color="themeConfig.secondaryColor" />
|
||||||
|
</view>
|
||||||
|
<text class="menu-text" :style="{ color: themeConfig.textPrimary }">隐私政策</text>
|
||||||
|
<FaIcon icon="chevron-right" :size="18" :color="themeConfig.textDisabled" />
|
||||||
|
</view>
|
||||||
|
<view class="menu-item logout-item" @click="handleLogout">
|
||||||
|
<view class="menu-icon-box" :style="{ background: (themeConfig.dangerColor || '#F53F3F') + '15' }">
|
||||||
|
<FaIcon icon="right-from-bracket" :size="16" :color="themeConfig.dangerColor || '#F53F3F'" />
|
||||||
|
</view>
|
||||||
|
<text class="menu-text" :style="{ color: themeConfig.dangerColor || '#F53F3F' }">退出登录</text>
|
||||||
|
<FaIcon icon="chevron-right" :size="18" :color="themeConfig.textDisabled" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 品牌理念 -->
|
||||||
|
<view class="brand-card theme-transition" :style="{ background: themeConfig.cardBgColor }">
|
||||||
|
<view class="brand-inner" :style="{ background: brandBgGradient }">
|
||||||
|
<view class="brand-icon-wrap" :style="{ background: themeConfig.primaryColor + '20' }">
|
||||||
|
<FaIcon icon="sparkles" :size="22" :color="themeConfig.primaryColor" />
|
||||||
|
</view>
|
||||||
|
<text class="brand-slogan" :style="{ color: themeConfig.textPrimary }">{{ logoConfig.slogan }}</text>
|
||||||
|
<text class="brand-tagline" :style="{ color: themeConfig.primaryColor }">用心记录每一刻</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="footer-area">
|
||||||
|
<text class="copyright" :style="{ color: themeConfig.textSecondary }">{{ logoConfig.copyright }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { getThemeConfigFromStorage, THEME_PRESETS } from '@/utils/theme'
|
||||||
|
import { refreshNavBarTheme } from '@/utils/themeGlobal'
|
||||||
|
import { LOGO_CONFIG } from '@/utils/constants'
|
||||||
|
import { logout as logoutApi } from '@/api'
|
||||||
|
import { logout as authLogout } from '@/utils/auth'
|
||||||
|
import FaIcon from '@/components/FaIcon.vue'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
FaIcon
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
themeConfig: getThemeConfigFromStorage() || THEME_PRESETS['default'],
|
||||||
|
logoConfig: LOGO_CONFIG,
|
||||||
|
logoLoaded: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
headerGradient() {
|
||||||
|
const c = this.themeConfig.primaryColor || '#9B8AFB'
|
||||||
|
const c2 = this.themeConfig.secondaryColor || this.lightenColor(c, -15)
|
||||||
|
return `linear-gradient(135deg, ${c} 0%, ${c2} 60%, ${this.lightenColor(c, -25)} 100%)`
|
||||||
|
},
|
||||||
|
brandBgGradient() {
|
||||||
|
const c = this.themeConfig.primaryColor || '#9B8AFB'
|
||||||
|
return `linear-gradient(135deg, ${c}12 0%, ${c}08 100%)`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onLoad() {
|
||||||
|
refreshNavBarTheme()
|
||||||
|
this.themeChangeHandler = (themeData) => {
|
||||||
|
let newTheme = null
|
||||||
|
if (themeData?.key && THEME_PRESETS[themeData.key]) {
|
||||||
|
newTheme = THEME_PRESETS[themeData.key]
|
||||||
|
} else if (themeData?.config) {
|
||||||
|
newTheme = themeData.config
|
||||||
|
}
|
||||||
|
if (newTheme) {
|
||||||
|
this.themeConfig = newTheme
|
||||||
|
refreshNavBarTheme()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
uni.$on('themeChanged', this.themeChangeHandler)
|
||||||
|
},
|
||||||
|
onShow() {
|
||||||
|
const latestTheme = getThemeConfigFromStorage() || THEME_PRESETS['default']
|
||||||
|
if (JSON.stringify(latestTheme) !== JSON.stringify(this.themeConfig)) {
|
||||||
|
this.themeConfig = latestTheme
|
||||||
|
}
|
||||||
|
refreshNavBarTheme()
|
||||||
|
},
|
||||||
|
onUnload() {
|
||||||
|
uni.$off('themeChanged', this.themeChangeHandler)
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
lightenColor(hex, percent) {
|
||||||
|
// 简单变暗:hex 形如 #RRGGBB,percent 负数表示变暗
|
||||||
|
const num = parseInt(hex.replace('#', ''), 16)
|
||||||
|
const r = Math.max(0, Math.min(255, (num >> 16) + Math.round(255 * percent / 100)))
|
||||||
|
const g = Math.max(0, Math.min(255, ((num >> 8) & 0xff) + Math.round(255 * percent / 100)))
|
||||||
|
const b = Math.max(0, Math.min(255, (num & 0xff) + Math.round(255 * percent / 100)))
|
||||||
|
return '#' + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)
|
||||||
|
},
|
||||||
|
gotoUserAgreement() {
|
||||||
|
uni.navigateTo({ url: '/pages/userAgreement/userAgreement' })
|
||||||
|
},
|
||||||
|
gotoPrivacyPolicy() {
|
||||||
|
uni.navigateTo({ url: '/pages/privacyPolicy/privacyPolicy' })
|
||||||
|
},
|
||||||
|
copyEmail() {
|
||||||
|
uni.vibrateShort && uni.vibrateShort({ type: 'light' })
|
||||||
|
uni.setClipboardData({
|
||||||
|
data: this.logoConfig.email,
|
||||||
|
success: () => {
|
||||||
|
uni.showToast({ title: '邮箱已复制', icon: 'success' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
copyWechat() {
|
||||||
|
uni.vibrateShort && uni.vibrateShort({ type: 'light' })
|
||||||
|
uni.setClipboardData({
|
||||||
|
data: this.logoConfig.wechat.account,
|
||||||
|
success: () => {
|
||||||
|
uni.showToast({ title: '公众号已复制', icon: 'success' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async handleLogout() {
|
||||||
|
uni.showModal({
|
||||||
|
title: '确认退出',
|
||||||
|
content: '确定要退出登录吗?',
|
||||||
|
confirmColor: this.themeConfig.dangerColor || '#F53F3F',
|
||||||
|
success: async (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
try {
|
||||||
|
await logoutApi()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('退出登录失败:', e)
|
||||||
|
} finally {
|
||||||
|
authLogout()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import '@/uni.scss';
|
||||||
|
|
||||||
|
.about-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 20rpx 24rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-transition {
|
||||||
|
transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-card {
|
||||||
|
border-radius: 28rpx;
|
||||||
|
padding: 72rpx 32rpx 60rpx;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
box-shadow: 0 12rpx 40rpx rgba(155, 138, 251, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-decoration {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.decoration-circle {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.decoration-circle.c1 {
|
||||||
|
width: 220rpx;
|
||||||
|
height: 220rpx;
|
||||||
|
top: -80rpx;
|
||||||
|
right: -60rpx;
|
||||||
|
}
|
||||||
|
.decoration-circle.c2 {
|
||||||
|
width: 120rpx;
|
||||||
|
height: 120rpx;
|
||||||
|
bottom: -20rpx;
|
||||||
|
left: -30rpx;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
.decoration-circle.c3 {
|
||||||
|
width: 70rpx;
|
||||||
|
height: 70rpx;
|
||||||
|
top: 50%;
|
||||||
|
right: 20%;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
.decoration-circle.c4 {
|
||||||
|
width: 40rpx;
|
||||||
|
height: 40rpx;
|
||||||
|
top: 20%;
|
||||||
|
left: 15%;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-wrapper {
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
position: relative;
|
||||||
|
width: 128rpx;
|
||||||
|
height: 128rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-box {
|
||||||
|
position: absolute;
|
||||||
|
width: 128rpx;
|
||||||
|
height: 128rpx;
|
||||||
|
border-radius: 32rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 10rpx 32rpx rgba(0, 0, 0, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-text-fallback {
|
||||||
|
font-size: 60rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -2rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-logo {
|
||||||
|
position: absolute;
|
||||||
|
width: 128rpx;
|
||||||
|
height: 128rpx;
|
||||||
|
border-radius: 32rpx;
|
||||||
|
box-shadow: 0 10rpx 32rpx rgba(0, 0, 0, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-name {
|
||||||
|
font-size: 42rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
letter-spacing: 1.5rpx;
|
||||||
|
text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-subtitle {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: rgba(255, 255, 255, 0.88);
|
||||||
|
letter-spacing: 1rpx;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-tag {
|
||||||
|
padding: 6rpx 18rpx;
|
||||||
|
background: rgba(255, 255, 255, 0.22);
|
||||||
|
border-radius: 24rpx;
|
||||||
|
backdrop-filter: blur(8rpx);
|
||||||
|
-webkit-backdrop-filter: blur(8rpx);
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-text {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #fff;
|
||||||
|
letter-spacing: 1rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-area {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-card,
|
||||||
|
.menu-card,
|
||||||
|
.brand-card {
|
||||||
|
border-radius: 20rpx;
|
||||||
|
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
|
||||||
|
transition: background-color 0.3s ease, box-shadow 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-card { padding: 28rpx; }
|
||||||
|
.menu-card { overflow: hidden; }
|
||||||
|
.brand-card { padding: 12rpx; }
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12rpx;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-icon {
|
||||||
|
width: 48rpx;
|
||||||
|
height: 48rpx;
|
||||||
|
border-radius: 14rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.5rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 22rpx 0;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row.divider {
|
||||||
|
border-top: 1rpx solid rgba(0, 0, 0, 0.06);
|
||||||
|
border-bottom: 1rpx solid rgba(0, 0, 0, 0.06);
|
||||||
|
margin: 4rpx -28rpx;
|
||||||
|
padding-left: 28rpx;
|
||||||
|
padding-right: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row:active {
|
||||||
|
background: rgba(155, 138, 251, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-icon-box {
|
||||||
|
width: 56rpx;
|
||||||
|
height: 56rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-right: 18rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-body {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4rpx;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
font-size: 24rpx;
|
||||||
|
opacity: 0.55;
|
||||||
|
letter-spacing: 0.5rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4rpx;
|
||||||
|
padding: 8rpx 16rpx;
|
||||||
|
border: 1rpx solid;
|
||||||
|
border-radius: 24rpx;
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-btn:active {
|
||||||
|
transform: scale(0.94);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-btn-text {
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.5rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 28rpx 28rpx;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
border-bottom: 1rpx solid rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item:last-child { border-bottom: none; }
|
||||||
|
.menu-item:active { background: rgba(155, 138, 251, 0.06); }
|
||||||
|
|
||||||
|
.menu-icon-box {
|
||||||
|
width: 56rpx;
|
||||||
|
height: 56rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-right: 18rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-text {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 28rpx;
|
||||||
|
letter-spacing: 0.5rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-inner {
|
||||||
|
border-radius: 16rpx;
|
||||||
|
padding: 36rpx 28rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-icon-wrap {
|
||||||
|
width: 72rpx;
|
||||||
|
height: 72rpx;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
animation: floatBrand 3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes floatBrand {
|
||||||
|
0%, 100% { transform: translateY(0) scale(1); }
|
||||||
|
50% { transform: translateY(-4rpx) scale(1.04); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-slogan {
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.5rpx;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.7;
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-tagline {
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 2rpx;
|
||||||
|
padding: 6rpx 18rpx;
|
||||||
|
background: rgba(255, 255, 255, 0.6);
|
||||||
|
border-radius: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-area {
|
||||||
|
padding: 36rpx 24rpx 60rpx;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copyright {
|
||||||
|
font-size: 22rpx;
|
||||||
|
opacity: 0.45;
|
||||||
|
letter-spacing: 0.5rpx;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
<template>
|
||||||
|
<view class="agreement-page" :style="{ background: themeConfig.bgColor }">
|
||||||
|
<!-- 协议内容滚动区 -->
|
||||||
|
<scroll-view class="content-scroll" scroll-y>
|
||||||
|
<view class="agreement-content" :style="{ background: themeConfig.cardBgColor }">
|
||||||
|
<view class="title" :style="{ color: themeConfig.textPrimary }">简记memo隐私政策</view>
|
||||||
|
<view class="effective-date" :style="{ color: themeConfig.textSecondary }">生效日期:2025年04月24日</view>
|
||||||
|
|
||||||
|
<view class="section">
|
||||||
|
<view class="section-title" :style="{
|
||||||
|
color: themeConfig.primaryColor,
|
||||||
|
background: themeConfig.primaryColor + '10',
|
||||||
|
borderLeftColor: themeConfig.primaryColor
|
||||||
|
}">1. 总则</view>
|
||||||
|
<view class="section-text" :style="{ color: themeConfig.textSecondary }">1.1 为保护用户隐私,规范本小程序的个人信息处理行为,本小程序运营者(个人开发者,以下简称“运营者”)依据《中华人民共和国个人信息保护法》《中华人民共和国网络安全法》《微信小程序平台运营规范》等法律法规及规范,制定本隐私政策。</view>
|
||||||
|
<view class="section-text" :style="{ color: themeConfig.textSecondary }">1.2 本隐私政策适用于您使用本小程序过程中涉及的个人信息处理活动,您使用本小程序即视为同意本政策;若您不同意本政策,请勿使用本小程序。</view>
|
||||||
|
<view class="section-text" :style="{ color: themeConfig.textSecondary }">1.3 本小程序由个人独立开发及运营,无所属企业/公司主体,所有信息处理行为均由运营者个人负责。</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="section">
|
||||||
|
<view class="section-title" :style="{
|
||||||
|
color: themeConfig.primaryColor,
|
||||||
|
background: themeConfig.primaryColor + '10',
|
||||||
|
borderLeftColor: themeConfig.primaryColor
|
||||||
|
}">2. 个人信息的收集</view>
|
||||||
|
<view class="section-text" :style="{ color: themeConfig.textSecondary }">2.1 登录相关信息:您使用微信授权登录本小程序时,运营者仅获取微信开放平台提供的唯一标识(OpenID),该信息仅用于识别您的用户身份,不会收集您的微信昵称、头像、手机号等其他微信账号信息。</view>
|
||||||
|
<view class="section-text" :style="{ color: themeConfig.textSecondary }">2.2 业务数据:您在本小程序内录入的备忘录、待办事项、账单内容、编辑记录、时间戳等数据,将存储于运营者的服务器中(而非本地设备),仅用于为您提供数据同步、查看、编辑等核心功能。</view>
|
||||||
|
<view class="section-text" :style="{ color: themeConfig.textSecondary }">2.3 设备与日志信息:为保障小程序正常运行,可能被动获取微信平台提供的基础设备信息(如设备型号、系统版本)、操作日志(如页面访问记录、功能使用记录),该类信息仅用于故障排查、功能优化,且仅与OpenID关联(不关联其他个人身份信息)。</view>
|
||||||
|
<view class="section-text" :style="{ color: themeConfig.textSecondary }">2.4 运营者不会主动收集您的姓名、身份证号、手机号、地理位置等其他个人敏感信息。</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="section">
|
||||||
|
<view class="section-title" :style="{
|
||||||
|
color: themeConfig.primaryColor,
|
||||||
|
background: themeConfig.primaryColor + '10',
|
||||||
|
borderLeftColor: themeConfig.primaryColor
|
||||||
|
}">3. 个人信息的使用</view>
|
||||||
|
<view class="section-text" :style="{ color: themeConfig.textSecondary }">3.1 OpenID仅用于识别您的用户身份,保障您能正常访问、管理自己存储在服务器的业务数据。</view>
|
||||||
|
<view class="section-text" :style="{ color: themeConfig.textSecondary }">3.2 您的业务数据仅用于为您本人提供查看、编辑、删除、同步等核心功能,运营者不会利用该数据进行商业推广、数据分析、第三方共享等其他用途。</view>
|
||||||
|
<view class="section-text" :style="{ color: themeConfig.textSecondary }">3.3 设备与日志信息仅用于定位和解决小程序运行故障、优化产品体验,不会用于识别个人身份或向第三方披露。</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="section">
|
||||||
|
<view class="section-title" :style="{
|
||||||
|
color: themeConfig.primaryColor,
|
||||||
|
background: themeConfig.primaryColor + '10',
|
||||||
|
borderLeftColor: themeConfig.primaryColor
|
||||||
|
}">4. 个人信息的存储与保护</view>
|
||||||
|
<view class="section-text" :style="{ color: themeConfig.textSecondary }">4.1 存储方式:OpenID及您的业务数据存储于运营者的服务器中;设备与日志信息仅在故障排查期间临时存储,排查完成后即时删除。</view>
|
||||||
|
<view class="section-text" :style="{ color: themeConfig.textSecondary }">4.2 存储期限:OpenID与业务数据将持续存储,直至您主动申请删除账号(可通过联系运营者完成);设备与日志信息存储期限不超过7日。</view>
|
||||||
|
<view class="section-text" :style="{ color: themeConfig.textSecondary }">4.3 安全措施:运营者通过服务器访问权限控制、数据加密传输等技术手段保障数据安全,但因网络攻击、服务器故障、微信平台漏洞等非运营方可控因素导致的数据泄露,运营者不承担责任。</view>
|
||||||
|
<view class="section-text" :style="{ color: themeConfig.textSecondary }">4.4 您可通过小程序内的删除功能清理自身业务数据,如需彻底删除账号及所有数据,可联系运营者协助处理。</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="section">
|
||||||
|
<view class="section-title" :style="{
|
||||||
|
color: themeConfig.primaryColor,
|
||||||
|
background: themeConfig.primaryColor + '10',
|
||||||
|
borderLeftColor: themeConfig.primaryColor
|
||||||
|
}">5. 个人信息的共享、转让与公开披露</view>
|
||||||
|
<view class="section-text" :style="{ color: themeConfig.textSecondary }">5.1 运营者不会将您的OpenID、业务数据、设备信息等任何信息共享、转让给第三方,除非获得您的明确授权,或法律法规、司法机关/行政机关的强制性要求。</view>
|
||||||
|
<view class="section-text" :style="{ color: themeConfig.textSecondary }">5.2 未经您同意,运营者不会公开披露您的任何信息;因法律法规要求披露时,将在允许范围内尽可能提前通知您。</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="section">
|
||||||
|
<view class="section-title" :style="{
|
||||||
|
color: themeConfig.primaryColor,
|
||||||
|
background: themeConfig.primaryColor + '10',
|
||||||
|
borderLeftColor: themeConfig.primaryColor
|
||||||
|
}">6. 您的权利</view>
|
||||||
|
<view class="section-text" :style="{ color: themeConfig.textSecondary }">6.1 您有权随时查看、编辑、删除自己存储在本小程序内的业务数据;</view>
|
||||||
|
<view class="section-text" :style="{ color: themeConfig.textSecondary }">6.2 您有权联系运营者申请删除账号及所有关联数据(包括OpenID、业务数据);</view>
|
||||||
|
<view class="section-text" :style="{ color: themeConfig.textSecondary }">6.3 您有权向运营者查询自身个人信息的处理情况,运营者将在合理期限内予以答复;</view>
|
||||||
|
<view class="section-text" :style="{ color: themeConfig.textSecondary }">6.4 若您发现本小程序存在违规处理您信息的行为,有权向运营者反馈或向微信平台投诉。</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="section">
|
||||||
|
<view class="section-title" :style="{
|
||||||
|
color: themeConfig.primaryColor,
|
||||||
|
background: themeConfig.primaryColor + '10',
|
||||||
|
borderLeftColor: themeConfig.primaryColor
|
||||||
|
}">7. 未成年人保护</view>
|
||||||
|
<view class="section-text" :style="{ color: themeConfig.textSecondary }">7.1 本小程序面向所有年龄段用户,若未成年人使用本小程序,应在监护人指导下进行,监护人应承担相应监护责任。</view>
|
||||||
|
<view class="section-text" :style="{ color: themeConfig.textSecondary }">7.2 运营者不会主动收集未成年人的任何额外个人信息,若监护人发现未成年人的信息被不当处理,可联系运营者协助处理。</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="section">
|
||||||
|
<view class="section-title" :style="{
|
||||||
|
color: themeConfig.primaryColor,
|
||||||
|
background: themeConfig.primaryColor + '10',
|
||||||
|
borderLeftColor: themeConfig.primaryColor
|
||||||
|
}">8. 隐私政策的变更</view>
|
||||||
|
<view class="section-text" :style="{ color: themeConfig.textSecondary }">8.1 运营者可根据法律法规调整或业务优化,修改本隐私政策,修改后的政策将在小程序内公示,公示满7日后生效。</view>
|
||||||
|
<view class="section-text" :style="{ color: themeConfig.textSecondary }">8.2 若您不同意修改后的政策,应停止使用本小程序并可申请删除账号数据;继续使用的,视为同意修改后的政策。</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="section">
|
||||||
|
<view class="section-title" :style="{
|
||||||
|
color: themeConfig.primaryColor,
|
||||||
|
background: themeConfig.primaryColor + '10',
|
||||||
|
borderLeftColor: themeConfig.primaryColor
|
||||||
|
}">9. 联系我们</view>
|
||||||
|
<view class="section-text" :style="{ color: themeConfig.textSecondary }">9.1 若您对本隐私政策有任何疑问、意见或投诉,可通过以下方式联系运营者:</view>
|
||||||
|
<view class="section-text" :style="{ color: themeConfig.textSecondary }">联系邮箱:memo@miaoall.cn</view>
|
||||||
|
<view class="section-text" :style="{ color: themeConfig.textSecondary }">微信公众号:孙三苗(sunsanmiao)</view>
|
||||||
|
<view class="section-text" :style="{ color: themeConfig.textSecondary }">9.2 运营者将在15个工作日内回复您的咨询或处理请求。</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="section">
|
||||||
|
<view class="section-title" :style="{
|
||||||
|
color: themeConfig.primaryColor,
|
||||||
|
background: themeConfig.primaryColor + '10',
|
||||||
|
borderLeftColor: themeConfig.primaryColor
|
||||||
|
}">10. 其他</view>
|
||||||
|
<view class="section-text" :style="{ color: themeConfig.textSecondary }">10.1 本隐私政策的解释权归运营者个人所有;</view>
|
||||||
|
<view class="section-text" :style="{ color: themeConfig.textSecondary }">10.2 若本政策与法律法规冲突,以法律法规为准;</view>
|
||||||
|
<view class="section-text" :style="{ color: themeConfig.textSecondary }">10.3 您使用本小程序的行为,同时受微信平台的隐私政策及运营规范约束。</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { getThemeConfigFromStorage, THEME_PRESETS } from '@/utils/theme'
|
||||||
|
import { refreshNavBarTheme } from '@/utils/themeGlobal'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "privacyPolicy",
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
themeConfig: getThemeConfigFromStorage() || THEME_PRESETS['default']
|
||||||
|
};
|
||||||
|
},
|
||||||
|
onLoad() {
|
||||||
|
refreshNavBarTheme()
|
||||||
|
this.themeChangeHandler = (themeData) => {
|
||||||
|
let newTheme = null
|
||||||
|
if (themeData?.key && THEME_PRESETS[themeData.key]) {
|
||||||
|
newTheme = THEME_PRESETS[themeData.key]
|
||||||
|
} else if (themeData?.config) {
|
||||||
|
newTheme = themeData.config
|
||||||
|
}
|
||||||
|
if (newTheme) {
|
||||||
|
this.themeConfig = newTheme
|
||||||
|
refreshNavBarTheme()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
uni.$on('themeChanged', this.themeChangeHandler)
|
||||||
|
},
|
||||||
|
onShow() {
|
||||||
|
const latestTheme = getThemeConfigFromStorage() || THEME_PRESETS['default']
|
||||||
|
if (JSON.stringify(latestTheme) !== JSON.stringify(this.themeConfig)) {
|
||||||
|
this.themeConfig = latestTheme
|
||||||
|
}
|
||||||
|
refreshNavBarTheme()
|
||||||
|
},
|
||||||
|
onUnload() {
|
||||||
|
uni.$off('themeChanged', this.themeChangeHandler)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.agreement-page {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 20rpx 24rpx;
|
||||||
|
padding-bottom: 40px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-scroll {
|
||||||
|
height: calc(100vh - 40rpx);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agreement-content {
|
||||||
|
padding: 40rpx;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
min-height: calc(100% - 48rpx);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 36rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
letter-spacing: 1rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effective-date {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 24rpx;
|
||||||
|
margin-bottom: 40rpx;
|
||||||
|
padding-bottom: 24rpx;
|
||||||
|
border-bottom: 1px dashed rgba(132, 179, 217, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
margin-bottom: 36rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 18rpx;
|
||||||
|
padding: 12rpx 16rpx;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
border-left: 4rpx solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-text {
|
||||||
|
font-size: 28rpx;
|
||||||
|
line-height: 2;
|
||||||
|
margin-bottom: 14rpx;
|
||||||
|
text-indent: 56rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-info {
|
||||||
|
margin-top: 48rpx;
|
||||||
|
padding-top: 24rpx;
|
||||||
|
border-top: 1px dashed rgba(132, 179, 217, 0.2);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-text {
|
||||||
|
font-size: 24rpx;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-link {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,626 @@
|
|||||||
|
<template>
|
||||||
|
<view class="review-page" :style="{ background: themeConfig.bgColor }">
|
||||||
|
<PageHeader
|
||||||
|
title="今日复盘"
|
||||||
|
:subtitle="formatDisplayDate(currentDate)"
|
||||||
|
:show-right="true"
|
||||||
|
:show-back="false"
|
||||||
|
>
|
||||||
|
<template #right>
|
||||||
|
<view class="header-actions">
|
||||||
|
<view class="action-btn" :style="{ background: themeConfig.cardBgColor }" @click="toggleCalendar">
|
||||||
|
<FaIcon icon="calendar-days" :size="20" :color="themeConfig.textPrimary" />
|
||||||
|
</view>
|
||||||
|
<view class="action-btn" :style="{ background: themeConfig.cardBgColor }" @click="goHistory">
|
||||||
|
<FaIcon icon="list" :size="20" :color="themeConfig.textPrimary" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<view class="calendar-wrap" v-if="showCalendar">
|
||||||
|
<InlineCalendar
|
||||||
|
:value="currentDate"
|
||||||
|
@change="handleDateChange"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<scroll-view class="main-content" scroll-y :show-scrollbar="false">
|
||||||
|
<view class="content-wrap">
|
||||||
|
<view class="loading-state" v-if="loading">
|
||||||
|
<view class="loading-spinner" :style="{ borderTopColor: themeConfig.primaryColor }"></view>
|
||||||
|
<text class="loading-text" :style="{ color: themeConfig.textSecondary }">加载中...</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<AppCard
|
||||||
|
:padding="26"
|
||||||
|
:radius="22"
|
||||||
|
:marginBottom="20"
|
||||||
|
:showShadow="true"
|
||||||
|
>
|
||||||
|
<view class="method-intro">
|
||||||
|
<view class="intro-icon" :style="{ background: themeConfig.bgColor }">
|
||||||
|
<FaIcon :icon="currentIntro.iconName" :size="22" :color="themeConfig.primaryColor" />
|
||||||
|
</view>
|
||||||
|
<view class="intro-texts">
|
||||||
|
<text class="intro-title" :style="{ color: themeConfig.textPrimary }">{{ currentIntro.title }}</text>
|
||||||
|
<text class="intro-desc" :style="{ color: themeConfig.textSecondary }">{{ currentIntro.desc }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</AppCard>
|
||||||
|
|
||||||
|
<view class="review-content">
|
||||||
|
<AppCard
|
||||||
|
v-for="(field, idx) in currentFields"
|
||||||
|
:key="field.key"
|
||||||
|
:padding="26"
|
||||||
|
:radius="20"
|
||||||
|
:marginBottom="idx < currentFields.length - 1 ? 20 : 0"
|
||||||
|
:showShadow="true"
|
||||||
|
>
|
||||||
|
<view class="input-card">
|
||||||
|
<view class="card-header">
|
||||||
|
<view class="card-icon-wrap" :style="{ background: getFieldBg(field.colorKey) }">
|
||||||
|
<FaIcon :icon="field.iconName" :size="22" :color="getProgressColor(field.colorKey)" />
|
||||||
|
</view>
|
||||||
|
<view class="card-title-wrap">
|
||||||
|
<text class="card-title" :style="{ color: themeConfig.textPrimary }">{{ field.title }}</text>
|
||||||
|
<text class="card-subtitle" :style="{ color: themeConfig.textSecondary }">{{ field.subtitle }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="card-body">
|
||||||
|
<textarea
|
||||||
|
class="card-input"
|
||||||
|
v-model="currentData[field.key]"
|
||||||
|
:placeholder="field.placeholder"
|
||||||
|
:style="{ color: themeConfig.textPrimary }"
|
||||||
|
auto-height
|
||||||
|
:maxlength="500"
|
||||||
|
show-confirm-bar="{{ false }}"
|
||||||
|
></textarea>
|
||||||
|
</view>
|
||||||
|
<view class="card-footer">
|
||||||
|
<view class="progress-wrap">
|
||||||
|
<ProgressBar
|
||||||
|
:percent="getProgressPercent(currentData[field.key])"
|
||||||
|
:height="8"
|
||||||
|
:color="getProgressColor(field.colorKey)"
|
||||||
|
:show-text="false"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<text class="word-count" :style="{ color: themeConfig.textSecondary }">
|
||||||
|
{{ currentData[field.key].length }}/500
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</AppCard>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<view class="bottom-spacer"></view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
|
||||||
|
<view class="footer-section">
|
||||||
|
<view
|
||||||
|
class="submit-button"
|
||||||
|
:class="{ disabled: !canSave }"
|
||||||
|
@click="handleSave"
|
||||||
|
:style="{
|
||||||
|
background: themeConfig.gradientVibrant,
|
||||||
|
boxShadow: `0 12rpx 36rpx ${themeConfig.shadowColor}`
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<view class="btn-content">
|
||||||
|
<text class="btn-text">{{ isEdit ? '更新复盘' : '保存复盘' }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<GrowthModal
|
||||||
|
:visible="growthModalVisible"
|
||||||
|
:title="growthModalTitle"
|
||||||
|
:message="growthModalMessage"
|
||||||
|
:icon="growthModalIcon"
|
||||||
|
:confirm-text="growthModalConfirmText"
|
||||||
|
@close="onGrowthModalClose"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { saveReview, getReviewByDate } from '@/api'
|
||||||
|
import growthModalMixin from '@/mixins/growthModalMixin.js'
|
||||||
|
import themeMixin from '@/mixins/themeMixin.js'
|
||||||
|
import PageHeader from '@/components/PageHeader.vue'
|
||||||
|
import InlineCalendar from '@/components/InlineCalendar.vue'
|
||||||
|
import ProgressBar from '@/components/ProgressBar.vue'
|
||||||
|
import AppCard from '@/components/AppCard.vue'
|
||||||
|
import EmptyState from '@/components/EmptyState.vue'
|
||||||
|
import GrowthModal from '@/components/GrowthModal.vue'
|
||||||
|
import FaIcon from '@/components/FaIcon.vue'
|
||||||
|
import { isLogin } from '@/utils/auth'
|
||||||
|
import { refreshNavBarTheme } from '@/utils/themeGlobal'
|
||||||
|
|
||||||
|
const REVIEW_STORAGE_PREFIX = 'review_'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'ReviewPage',
|
||||||
|
components: {
|
||||||
|
PageHeader,
|
||||||
|
InlineCalendar,
|
||||||
|
ProgressBar,
|
||||||
|
AppCard,
|
||||||
|
EmptyState,
|
||||||
|
GrowthModal,
|
||||||
|
FaIcon
|
||||||
|
},
|
||||||
|
mixins: [growthModalMixin, themeMixin],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
currentDate: '',
|
||||||
|
kptData: {
|
||||||
|
keep: '',
|
||||||
|
problem: '',
|
||||||
|
try: ''
|
||||||
|
},
|
||||||
|
showCalendar: false,
|
||||||
|
loading: false,
|
||||||
|
isEdit: false,
|
||||||
|
kptFields: [
|
||||||
|
{ key: 'keep', title: 'Keep(保持)', subtitle: '做得好的地方', iconName: 'lightbulb', colorKey: 'success', placeholder: '今天哪些做得好的地方值得保持?' },
|
||||||
|
{ key: 'problem', title: 'Problem(问题)', subtitle: '遇到的挑战', iconName: 'triangle-exclamation', colorKey: 'danger', placeholder: '今天遇到了什么问题和挑战?' },
|
||||||
|
{ key: 'try', title: 'Try(尝试)', subtitle: '改进方向', iconName: 'rocket', colorKey: 'secondary', placeholder: '明天尝试什么改进方法?' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
currentFields() {
|
||||||
|
return this.kptFields
|
||||||
|
},
|
||||||
|
currentData() {
|
||||||
|
return this.kptData
|
||||||
|
},
|
||||||
|
canSave() {
|
||||||
|
return Object.values(this.kptData).some(v => v && v.trim())
|
||||||
|
},
|
||||||
|
storageKey() {
|
||||||
|
return `${REVIEW_STORAGE_PREFIX}${this.currentDate}`
|
||||||
|
},
|
||||||
|
currentIntro() {
|
||||||
|
return {
|
||||||
|
iconName: 'lightbulb',
|
||||||
|
title: 'KPT 复盘法',
|
||||||
|
desc: '通过记录做得好的、遇到的问题、改进方向,快速复盘成长'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onLoad(options) {
|
||||||
|
if (!isLogin()) {
|
||||||
|
uni.redirectTo({ url: '/pages/login/login' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const now = new Date()
|
||||||
|
this.currentDate = options.date || this.formatDate(now)
|
||||||
|
refreshNavBarTheme()
|
||||||
|
this.loadReview()
|
||||||
|
},
|
||||||
|
onShow() {
|
||||||
|
refreshNavBarTheme()
|
||||||
|
},
|
||||||
|
onUnload() {
|
||||||
|
this.autoSaveDraft()
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
formatDate(date) {
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
|
return `${year}-${month}-${day}`
|
||||||
|
},
|
||||||
|
formatDisplayDate(dateStr) {
|
||||||
|
if (!dateStr) return ''
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
const today = new Date()
|
||||||
|
const yesterday = new Date(today)
|
||||||
|
yesterday.setDate(yesterday.getDate() - 1)
|
||||||
|
const tomorrow = new Date(today)
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1)
|
||||||
|
|
||||||
|
if (date.toDateString() === today.toDateString()) {
|
||||||
|
return '今天'
|
||||||
|
} else if (date.toDateString() === yesterday.toDateString()) {
|
||||||
|
return '昨天'
|
||||||
|
} else if (date.toDateString() === tomorrow.toDateString()) {
|
||||||
|
return '明天'
|
||||||
|
} else {
|
||||||
|
const weekDays = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']
|
||||||
|
const month = date.getMonth() + 1
|
||||||
|
const day = date.getDate()
|
||||||
|
const weekDay = weekDays[date.getDay()]
|
||||||
|
return `${month}月${day}日 ${weekDay}`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getProgressPercent(text) {
|
||||||
|
if (!text) return 0
|
||||||
|
return Math.min(100, (text.length / 500) * 100)
|
||||||
|
},
|
||||||
|
getProgressColor(colorKey) {
|
||||||
|
const colorMap = {
|
||||||
|
success: this.themeConfig.successColor,
|
||||||
|
danger: this.themeConfig.dangerColor,
|
||||||
|
secondary: this.themeConfig.secondaryColor,
|
||||||
|
primary: this.themeConfig.primaryColor,
|
||||||
|
warning: this.themeConfig.warningColor,
|
||||||
|
accent: this.themeConfig.accentColor
|
||||||
|
}
|
||||||
|
return colorMap[colorKey] || this.themeConfig.primaryColor
|
||||||
|
},
|
||||||
|
getFieldBg(colorKey) {
|
||||||
|
const color = this.getProgressColor(colorKey)
|
||||||
|
return color + '18'
|
||||||
|
},
|
||||||
|
toggleCalendar() {
|
||||||
|
this.showCalendar = !this.showCalendar
|
||||||
|
},
|
||||||
|
handleDateChange(dateStr) {
|
||||||
|
this.currentDate = dateStr
|
||||||
|
this.showCalendar = false
|
||||||
|
this.loadReview()
|
||||||
|
},
|
||||||
|
goHistory() {
|
||||||
|
uni.navigateTo({ url: '/pages/review/reviewList' })
|
||||||
|
},
|
||||||
|
async loadReview() {
|
||||||
|
this.loading = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await getReviewByDate({ date: this.currentDate })
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
this.isEdit = true
|
||||||
|
// Support both nested {kpt: {keep, problem, try}} and flat {keep, problem, try}
|
||||||
|
const kptData = result.kpt || result
|
||||||
|
this.kptData = {
|
||||||
|
keep: kptData.keep || '',
|
||||||
|
problem: kptData.problem || '',
|
||||||
|
try: kptData.try || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('API加载复盘失败,尝试本地存储:', e)
|
||||||
|
this.loadReviewFromLocal()
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
loadReviewFromLocal() {
|
||||||
|
try {
|
||||||
|
const storedData = uni.getStorageSync(this.storageKey)
|
||||||
|
if (storedData) {
|
||||||
|
const data = JSON.parse(storedData)
|
||||||
|
const kptData = data.kpt || data
|
||||||
|
this.kptData = {
|
||||||
|
keep: kptData.keep || '',
|
||||||
|
problem: kptData.problem || '',
|
||||||
|
try: kptData.try || ''
|
||||||
|
}
|
||||||
|
this.isEdit = !data.isDraft
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('本地存储加载失败:', e)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
autoSaveDraft() {
|
||||||
|
try {
|
||||||
|
const dataToSave = {
|
||||||
|
date: this.currentDate,
|
||||||
|
method: 'kpt',
|
||||||
|
kpt: this.kptData,
|
||||||
|
isDraft: true,
|
||||||
|
saveTime: new Date().toISOString()
|
||||||
|
}
|
||||||
|
uni.setStorageSync(this.storageKey, JSON.stringify(dataToSave))
|
||||||
|
} catch (e) {
|
||||||
|
console.error('保存草稿失败:', e)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async handleSave() {
|
||||||
|
if (!this.canSave) {
|
||||||
|
uni.showToast({ title: '请填写至少一项内容', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
uni.showLoading({ title: '保存中...', mask: true })
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dataToSave = {
|
||||||
|
date: this.currentDate,
|
||||||
|
kpt: this.kptData
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await saveReview(dataToSave)
|
||||||
|
|
||||||
|
uni.hideLoading()
|
||||||
|
uni.showToast({ title: '保存成功', icon: 'success' })
|
||||||
|
|
||||||
|
this.isEdit = true
|
||||||
|
|
||||||
|
if (result && (result.levelUp || (result.newAchievements && result.newAchievements.length > 0))) {
|
||||||
|
this.showGrowthModalWithCallback(result, () => {
|
||||||
|
setTimeout(() => uni.navigateBack(), 1500)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
this.showGrowthModal({
|
||||||
|
type: 'review',
|
||||||
|
exp: result && result.expGained ? result.expGained : 10,
|
||||||
|
level: result && result.currentLevel ? result.currentLevel : null
|
||||||
|
})
|
||||||
|
setTimeout(() => {
|
||||||
|
uni.navigateBack()
|
||||||
|
}, 1500)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
uni.hideLoading()
|
||||||
|
console.error('保存复盘失败:', e)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dataToSave = {
|
||||||
|
date: this.currentDate,
|
||||||
|
method: 'kpt',
|
||||||
|
kpt: this.kptData,
|
||||||
|
isDraft: false,
|
||||||
|
saveTime: new Date().toISOString()
|
||||||
|
}
|
||||||
|
uni.setStorageSync(this.storageKey, JSON.stringify(dataToSave))
|
||||||
|
this.isEdit = true
|
||||||
|
uni.showToast({ title: '已本地保存', icon: 'success' })
|
||||||
|
setTimeout(() => uni.navigateBack(), 1500)
|
||||||
|
} catch (fallbackErr) {
|
||||||
|
console.error('本地保存也失败:', fallbackErr)
|
||||||
|
uni.showToast({ title: '保存失败,请重试', icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onGrowthModalClose() {
|
||||||
|
this.growthModalVisible = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.review-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
width: 60rpx;
|
||||||
|
height: 60rpx;
|
||||||
|
border-radius: 18rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:active {
|
||||||
|
transform: scale(0.93);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-icon {
|
||||||
|
font-size: 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-wrap {
|
||||||
|
margin: 0 36rpx 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
height: calc(100vh - 200rpx);
|
||||||
|
position: relative;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-wrap {
|
||||||
|
padding: 0 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 120rpx 0;
|
||||||
|
gap: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner {
|
||||||
|
width: 60rpx;
|
||||||
|
height: 60rpx;
|
||||||
|
border: 4rpx solid #f0f0f0;
|
||||||
|
border-top-color: #333;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-text {
|
||||||
|
font-size: 26rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.method-intro {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro-icon {
|
||||||
|
width: 56rpx;
|
||||||
|
height: 56rpx;
|
||||||
|
border-radius: 14rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro-texts {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro-title {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro-desc {
|
||||||
|
font-size: 24rpx;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-icon-wrap {
|
||||||
|
width: 60rpx;
|
||||||
|
height: 60rpx;
|
||||||
|
border-radius: 14rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-icon {
|
||||||
|
font-size: 30rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title-wrap {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-subtitle {
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
margin-bottom: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-input {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 160rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
line-height: 1.8;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16rpx;
|
||||||
|
padding-top: 12rpx;
|
||||||
|
border-top: 1rpx solid rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-wrap {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-count {
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
min-width: 100rpx;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-section {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
padding: 26rpx 36rpx;
|
||||||
|
padding-bottom: calc(26rpx + constant(safe-area-inset-bottom));
|
||||||
|
padding-bottom: calc(26rpx + env(safe-area-inset-bottom));
|
||||||
|
background: linear-gradient(to top, rgba(248, 250, 252, 0.99) 0%, rgba(248, 250, 252, 0.9) 55%, transparent 100%);
|
||||||
|
backdrop-filter: blur(14rpx);
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-button {
|
||||||
|
width: 100%;
|
||||||
|
height: 108rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 18rpx;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-button.disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14rpx;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-text {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-emoji {
|
||||||
|
margin-left: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-spacer {
|
||||||
|
height: 60rpx;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,602 @@
|
|||||||
|
<template>
|
||||||
|
<view class="review-list-page" :style="{ backgroundColor: themeConfig.bgColor }">
|
||||||
|
<PageHeader
|
||||||
|
title="复盘历史"
|
||||||
|
subtitle="回顾与成长"
|
||||||
|
:showBack="false"
|
||||||
|
:showRight="true"
|
||||||
|
>
|
||||||
|
<template #right>
|
||||||
|
<view class="header-actions">
|
||||||
|
<view
|
||||||
|
class="action-btn"
|
||||||
|
:class="{ 'action-btn--active': searchVisible }"
|
||||||
|
:style="{ backgroundColor: themeConfig.cardBgColor }"
|
||||||
|
@click="toggleSearch"
|
||||||
|
>
|
||||||
|
<FaIcon :icon="searchVisible ? 'times' : 'search'" :size="18" :color="searchVisible ? themeConfig.primaryColor : themeConfig.textPrimary" />
|
||||||
|
</view>
|
||||||
|
<view
|
||||||
|
class="action-btn"
|
||||||
|
:class="{ 'action-btn--active': calendarVisible }"
|
||||||
|
:style="{ backgroundColor: themeConfig.cardBgColor }"
|
||||||
|
@click="toggleCalendar"
|
||||||
|
>
|
||||||
|
<FaIcon :icon="calendarVisible ? 'times' : 'calendar'" :size="18" :color="calendarVisible ? themeConfig.primaryColor : themeConfig.textPrimary" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<!-- 筛选区域 -->
|
||||||
|
<view class="filter-section" v-if="searchVisible || calendarVisible" :style="{ backgroundColor: themeConfig.bgColor }">
|
||||||
|
<!-- 搜索栏 -->
|
||||||
|
<view v-if="searchVisible" class="search-bar" :style="{ backgroundColor: themeConfig.cardBgColor }">
|
||||||
|
<FaIcon icon="search" :size="16" :color="themeConfig.textSecondary" />
|
||||||
|
<input
|
||||||
|
class="search-input"
|
||||||
|
:style="{ color: themeConfig.textPrimary }"
|
||||||
|
v-model="searchKeyword"
|
||||||
|
placeholder="搜索复盘内容..."
|
||||||
|
:placeholder-style="{ color: themeConfig.textDisabled }"
|
||||||
|
confirm-type="search"
|
||||||
|
@confirm="onSearch"
|
||||||
|
/>
|
||||||
|
<view v-if="searchKeyword" class="clear-btn" @click="clearSearch">
|
||||||
|
<FaIcon icon="times-circle" :size="16" :color="themeConfig.textSecondary" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 日历范围选择 -->
|
||||||
|
<view v-if="calendarVisible" class="calendar-section">
|
||||||
|
<InlineCalendar
|
||||||
|
rangeSelect
|
||||||
|
:startDate="dateRange.start"
|
||||||
|
:endDate="dateRange.end"
|
||||||
|
@rangeChange="onDateRangeChange"
|
||||||
|
/>
|
||||||
|
<view v-if="dateRange.start" class="date-range-info">
|
||||||
|
<text class="range-text" :style="{ color: themeConfig.textSecondary }">
|
||||||
|
{{ dateRange.start }} ~ {{ dateRange.end || '至今' }}
|
||||||
|
</text>
|
||||||
|
<view class="clear-range-btn" @click="clearDateRange">
|
||||||
|
<text class="clear-range-text" :style="{ color: themeConfig.primaryColor }">清除</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 列表 -->
|
||||||
|
<scroll-view
|
||||||
|
class="list-scroll"
|
||||||
|
scroll-y
|
||||||
|
refresher-enabled
|
||||||
|
:refresher-triggered="refresherTriggered"
|
||||||
|
@refresherrefresh="onRefresh"
|
||||||
|
:show-scrollbar="false"
|
||||||
|
@scrolltolower="loadMore"
|
||||||
|
>
|
||||||
|
<view class="list-content" v-if="reviewList.length > 0">
|
||||||
|
<SwipeableItem
|
||||||
|
v-for="item in reviewList"
|
||||||
|
:key="item.id"
|
||||||
|
:actions="swipeActions"
|
||||||
|
:index="item.id"
|
||||||
|
@action="(key) => onSwipeAction(key, item)"
|
||||||
|
@open="onSwipeOpen"
|
||||||
|
>
|
||||||
|
<AppCard
|
||||||
|
:padding="24"
|
||||||
|
:radius="20"
|
||||||
|
:clickable="true"
|
||||||
|
:marginBottom="16"
|
||||||
|
@click="goToDetail(item)"
|
||||||
|
>
|
||||||
|
<view class="review-item">
|
||||||
|
<!-- 头部:日期 + 时间 -->
|
||||||
|
<view class="item-header">
|
||||||
|
<view class="date-wrap">
|
||||||
|
<text class="item-date" :style="{ color: themeConfig.textPrimary }">{{ formatDate(item.date) }}</text>
|
||||||
|
<text class="item-weekday" :style="{ color: themeConfig.textSecondary }">{{ getWeekday(item.date) }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="item-time" :style="{ color: themeConfig.textDisabled }">{{ formatTime(item.created_at || item.date) }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- KPT 三字段 -->
|
||||||
|
<view class="kpt-fields">
|
||||||
|
<view
|
||||||
|
class="kpt-field"
|
||||||
|
:style="{ backgroundColor: themeConfig.successColor + '10' }"
|
||||||
|
v-if="item.kpt && item.kpt.keep"
|
||||||
|
>
|
||||||
|
<view class="kpt-label" :style="{ backgroundColor: themeConfig.successColor, color: '#fff' }">
|
||||||
|
<text>Keep</text>
|
||||||
|
</view>
|
||||||
|
<text class="kpt-text" :style="{ color: themeConfig.textSecondary }">{{ item.kpt.keep }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view
|
||||||
|
class="kpt-field"
|
||||||
|
:style="{ backgroundColor: themeConfig.warningColor + '10' }"
|
||||||
|
v-if="item.kpt && item.kpt.problem"
|
||||||
|
>
|
||||||
|
<view class="kpt-label" :style="{ backgroundColor: themeConfig.warningColor, color: '#fff' }">
|
||||||
|
<text>Problem</text>
|
||||||
|
</view>
|
||||||
|
<text class="kpt-text" :style="{ color: themeConfig.textSecondary }">{{ item.kpt.problem }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view
|
||||||
|
class="kpt-field"
|
||||||
|
:style="{ backgroundColor: (themeConfig.infoColor || themeConfig.primaryColor) + '10' }"
|
||||||
|
v-if="item.kpt && item.kpt.try"
|
||||||
|
>
|
||||||
|
<view class="kpt-label" :style="{ backgroundColor: (themeConfig.infoColor || themeConfig.primaryColor), color: '#fff' }">
|
||||||
|
<text>Try</text>
|
||||||
|
</view>
|
||||||
|
<text class="kpt-text" :style="{ color: themeConfig.textSecondary }">{{ item.kpt.try }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</AppCard>
|
||||||
|
</SwipeableItem>
|
||||||
|
|
||||||
|
<!-- 底部状态 -->
|
||||||
|
<view class="list-footer" v-if="loading && hasMore">
|
||||||
|
<view class="loading-dot" :style="{ backgroundColor: themeConfig.primaryColor }"></view>
|
||||||
|
<text class="footer-text" :style="{ color: themeConfig.textSecondary }">加载中...</text>
|
||||||
|
</view>
|
||||||
|
<view class="list-footer" v-else-if="!hasMore">
|
||||||
|
<text class="footer-text" :style="{ color: themeConfig.textDisabled }">— 没有更多了 —</text>
|
||||||
|
</view>
|
||||||
|
<view class="bottom-spacer"></view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 空状态 -->
|
||||||
|
<view class="empty-wrap" v-else-if="!loading">
|
||||||
|
<EmptyState
|
||||||
|
type="review"
|
||||||
|
title="还没有复盘记录"
|
||||||
|
desc="坚持每日复盘,见证成长轨迹"
|
||||||
|
actionText="开始复盘"
|
||||||
|
@action="goToTodayReview"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 首次加载中 -->
|
||||||
|
<view class="initial-loading" v-if="loading && reviewList.length === 0">
|
||||||
|
<view class="loading-spinner" :style="{ borderColor: themeConfig.primaryColor }"></view>
|
||||||
|
<text :style="{ color: themeConfig.textSecondary }">加载中...</text>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { getReviewList, deleteReview } from '@/api'
|
||||||
|
import themeMixin from '@/mixins/themeMixin.js'
|
||||||
|
import PageHeader from '@/components/PageHeader.vue'
|
||||||
|
import AppCard from '@/components/AppCard.vue'
|
||||||
|
import EmptyState from '@/components/EmptyState.vue'
|
||||||
|
import InlineCalendar from '@/components/InlineCalendar.vue'
|
||||||
|
import SwipeableItem from '@/components/SwipeableItem.vue'
|
||||||
|
import FaIcon from '@/components/FaIcon.vue'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'ReviewList',
|
||||||
|
mixins: [themeMixin],
|
||||||
|
components: {
|
||||||
|
PageHeader,
|
||||||
|
AppCard,
|
||||||
|
EmptyState,
|
||||||
|
InlineCalendar,
|
||||||
|
SwipeableItem,
|
||||||
|
FaIcon
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
reviewList: [],
|
||||||
|
searchKeyword: '',
|
||||||
|
searchVisible: false,
|
||||||
|
calendarVisible: false,
|
||||||
|
dateRange: {
|
||||||
|
start: '',
|
||||||
|
end: ''
|
||||||
|
},
|
||||||
|
page: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
hasMore: true,
|
||||||
|
loading: false,
|
||||||
|
loaded: false,
|
||||||
|
refresherTriggered: false,
|
||||||
|
swipeActions: [
|
||||||
|
{ key: 'delete', label: '删除' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onLoad() {
|
||||||
|
this.loadList(true)
|
||||||
|
},
|
||||||
|
onShow() {
|
||||||
|
this.syncThemeConfig()
|
||||||
|
// 从编辑页返回时刷新列表
|
||||||
|
if (this.loaded) {
|
||||||
|
this.loadList(true)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async loadList(reset = false) {
|
||||||
|
if (this.loading) return
|
||||||
|
this.loading = true
|
||||||
|
|
||||||
|
if (reset) {
|
||||||
|
this.page = 1
|
||||||
|
this.hasMore = true
|
||||||
|
this.reviewList = []
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await getReviewList({
|
||||||
|
keyword: this.searchKeyword,
|
||||||
|
startDate: this.dateRange.start,
|
||||||
|
endDate: this.dateRange.end,
|
||||||
|
page: this.page,
|
||||||
|
pageSize: this.pageSize
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = res
|
||||||
|
const list = (data && data.list) ? data.list : (Array.isArray(data) ? data : [])
|
||||||
|
const total = (data && data.total) != null ? data.total : list.length
|
||||||
|
|
||||||
|
if (reset) {
|
||||||
|
this.reviewList = list
|
||||||
|
} else {
|
||||||
|
this.reviewList = [...this.reviewList, ...list]
|
||||||
|
}
|
||||||
|
|
||||||
|
this.hasMore = this.reviewList.length < total
|
||||||
|
this.page++
|
||||||
|
this.loaded = true
|
||||||
|
} catch (e) {
|
||||||
|
console.error('加载复盘列表失败:', e)
|
||||||
|
if (!this.loaded) {
|
||||||
|
uni.showToast({ title: '加载失败,下拉刷新重试', icon: 'none' })
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
this.refresherTriggered = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
loadMore() {
|
||||||
|
if (!this.hasMore || this.loading) return
|
||||||
|
this.loadList(false)
|
||||||
|
},
|
||||||
|
|
||||||
|
async onRefresh() {
|
||||||
|
this.refresherTriggered = true
|
||||||
|
await this.loadList(true)
|
||||||
|
},
|
||||||
|
|
||||||
|
onSearch() {
|
||||||
|
this.loadList(true)
|
||||||
|
},
|
||||||
|
|
||||||
|
clearSearch() {
|
||||||
|
this.searchKeyword = ''
|
||||||
|
this.loadList(true)
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleSearch() {
|
||||||
|
this.searchVisible = !this.searchVisible
|
||||||
|
if (!this.searchVisible && this.searchKeyword) {
|
||||||
|
this.searchKeyword = ''
|
||||||
|
this.loadList(true)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleCalendar() {
|
||||||
|
this.calendarVisible = !this.calendarVisible
|
||||||
|
},
|
||||||
|
|
||||||
|
onDateRangeChange(range) {
|
||||||
|
this.dateRange.start = range.start || ''
|
||||||
|
this.dateRange.end = range.end || ''
|
||||||
|
this.searchVisible = false
|
||||||
|
this.calendarVisible = false
|
||||||
|
this.loadList(true)
|
||||||
|
},
|
||||||
|
|
||||||
|
clearDateRange() {
|
||||||
|
this.dateRange.start = ''
|
||||||
|
this.dateRange.end = ''
|
||||||
|
this.loadList(true)
|
||||||
|
},
|
||||||
|
|
||||||
|
onSwipeOpen() {},
|
||||||
|
|
||||||
|
async onSwipeAction(actionKey, item) {
|
||||||
|
if (actionKey === 'delete') {
|
||||||
|
uni.showModal({
|
||||||
|
title: '确认删除',
|
||||||
|
content: '确定要删除这条复盘记录吗?删除后不可恢复。',
|
||||||
|
success: async (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
await this.handleDelete(item.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async handleDelete(itemId) {
|
||||||
|
try {
|
||||||
|
await deleteReview({ id: itemId })
|
||||||
|
this.reviewList = this.reviewList.filter(item => item.id !== itemId)
|
||||||
|
uni.showToast({ title: '删除成功', icon: 'success' })
|
||||||
|
} catch (e) {
|
||||||
|
console.error('删除失败:', e)
|
||||||
|
uni.showToast({ title: '删除失败,请重试', icon: 'none' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
goToDetail(item) {
|
||||||
|
uni.navigateTo({
|
||||||
|
url: `/pages/review/review?date=${item.date}`
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
goToTodayReview() {
|
||||||
|
const now = new Date()
|
||||||
|
const y = now.getFullYear()
|
||||||
|
const m = String(now.getMonth() + 1).padStart(2, '0')
|
||||||
|
const d = String(now.getDate()).padStart(2, '0')
|
||||||
|
const today = `${y}-${m}-${d}`
|
||||||
|
uni.navigateTo({
|
||||||
|
url: `/pages/review/review?date=${today}`
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
formatDate(dateStr) {
|
||||||
|
if (!dateStr) return ''
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
const m = date.getMonth() + 1
|
||||||
|
const d = date.getDate()
|
||||||
|
return `${m}月${d}日`
|
||||||
|
},
|
||||||
|
|
||||||
|
getWeekday(dateStr) {
|
||||||
|
if (!dateStr) return ''
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
const weekdays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
|
||||||
|
return weekdays[date.getDay()]
|
||||||
|
},
|
||||||
|
|
||||||
|
formatTime(dateStr) {
|
||||||
|
if (!dateStr) return ''
|
||||||
|
// 处理 '2026-07-28 20:30' 或 '2026-07-28' 格式
|
||||||
|
const parts = dateStr.split(' ')
|
||||||
|
if (parts.length > 1) {
|
||||||
|
const timeParts = parts[1].split(':')
|
||||||
|
return `${timeParts[0]}:${timeParts[1]}`
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.review-list-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 头部操作按钮
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
width: 60rpx;
|
||||||
|
height: 60rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn--active {
|
||||||
|
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:active {
|
||||||
|
transform: scale(0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 筛选区域
|
||||||
|
.filter-section {
|
||||||
|
padding: 16rpx 32rpx 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12rpx;
|
||||||
|
padding: 16rpx 24rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 28rpx;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-section {
|
||||||
|
margin-top: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-range-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16rpx 24rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.range-text {
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-range-btn {
|
||||||
|
padding: 8rpx 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-range-text {
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 列表
|
||||||
|
.list-scroll {
|
||||||
|
flex: 1;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-content {
|
||||||
|
padding: 8rpx 32rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复盘卡片
|
||||||
|
.review-item {
|
||||||
|
padding: 4rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-date {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-weekday {
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-time {
|
||||||
|
font-size: 22rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
// KPT 字段
|
||||||
|
.kpt-fields {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpt-field {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12rpx;
|
||||||
|
padding: 14rpx 16rpx;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpt-label {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 4rpx 14rpx;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
font-size: 20rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.4;
|
||||||
|
min-width: 80rpx;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpt-text {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 26rpx;
|
||||||
|
line-height: 1.6;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 底部状态
|
||||||
|
.list-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12rpx;
|
||||||
|
padding: 36rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-dot {
|
||||||
|
width: 12rpx;
|
||||||
|
height: 12rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: loadingPulse 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes loadingPulse {
|
||||||
|
0%, 100% { opacity: 0.3; transform: scale(1); }
|
||||||
|
50% { opacity: 1; transform: scale(1.3); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-text {
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-spacer {
|
||||||
|
height: 60rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 空状态
|
||||||
|
.empty-wrap {
|
||||||
|
padding: 120rpx 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 首次加载
|
||||||
|
.initial-loading {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 200rpx 0;
|
||||||
|
gap: 20rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner {
|
||||||
|
width: 48rpx;
|
||||||
|
height: 48rpx;
|
||||||
|
border: 4rpx solid;
|
||||||
|
border-top-color: transparent !important;
|
||||||
|
border-right-color: transparent !important;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,677 @@
|
|||||||
|
<template>
|
||||||
|
<view class="theme-page theme-transition" :style="{ background: themeConfig.bgColor }">
|
||||||
|
<!-- 背景装饰 -->
|
||||||
|
<view class="page-decoration deco-1" :style="{ background: themeConfig.primaryColor + '10' }"></view>
|
||||||
|
<view class="page-decoration deco-2" :style="{ background: themeConfig.secondaryColor + '10' }"></view>
|
||||||
|
|
||||||
|
<!-- 页面头部 -->
|
||||||
|
<view class="page-header">
|
||||||
|
<view class="header-decoration">
|
||||||
|
<view class="header-glow" :style="{ background: `radial-gradient(circle, ${themeConfig.primaryColor}20 0%, transparent 70%)` }"></view>
|
||||||
|
</view>
|
||||||
|
<view class="header-title-row">
|
||||||
|
<view class="header-icon-wrap" :style="{ background: themeConfig.primaryColor + '15' }">
|
||||||
|
<text class="header-icon">🎨</text>
|
||||||
|
</view>
|
||||||
|
<view class="header-text">
|
||||||
|
<text class="page-title theme-transition" :style="{ color: themeConfig.textPrimary }">主题皮肤</text>
|
||||||
|
<text class="page-subtitle theme-transition" :style="{ color: themeConfig.textSecondary }">选择你喜欢的配色方案</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="theme-list">
|
||||||
|
<!-- 遍历主题列表 -->
|
||||||
|
<view
|
||||||
|
class="theme-item card theme-transition"
|
||||||
|
v-for="(theme, key) in validThemeList"
|
||||||
|
:key="`theme-${key}`"
|
||||||
|
@click="selectTheme(key)"
|
||||||
|
:style="getThemeItemStyle(theme, key)"
|
||||||
|
>
|
||||||
|
<!-- 主题预览区域 -->
|
||||||
|
<view class="theme-preview"
|
||||||
|
:style="{ background: theme.bgColor }">
|
||||||
|
<view class="preview-shine"></view>
|
||||||
|
<view class="preview-circle-wrap">
|
||||||
|
<view class="preview-circle" :style="{ background: `linear-gradient(135deg, ${theme.primaryColor} 0%, ${theme.secondaryColor} 100%)` }">
|
||||||
|
<view class="preview-circle-inner" :style="{ background: theme.cardBgColor }"></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="preview-colors">
|
||||||
|
<view class="preview-color-item primary" :style="{ background: theme.primaryColor }"></view>
|
||||||
|
<view class="preview-color-item secondary" :style="{ background: theme.secondaryColor }"></view>
|
||||||
|
<view class="preview-color-item accent" :style="{ background: theme.warningColor }"></view>
|
||||||
|
<view class="preview-color-item text" :style="{ background: theme.textPrimary }"></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 主题信息区 -->
|
||||||
|
<view class="theme-info">
|
||||||
|
<view class="theme-name-row">
|
||||||
|
<text class="theme-name theme-transition" :style="{ color: theme.textPrimary }">
|
||||||
|
{{ theme.name }}
|
||||||
|
</text>
|
||||||
|
<view class="theme-badge" v-if="currentThemeKey === key" :style="{ background: theme.primaryColor }">
|
||||||
|
<text class="badge-text">当前</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<text class="theme-desc theme-transition" :style="{ color: theme.textSecondary }">
|
||||||
|
{{ getThemeDesc(key) }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 选中标识 -->
|
||||||
|
<view class="active-indicator theme-transition" v-if="currentThemeKey === key">
|
||||||
|
<view class="indicator-ring" :style="{ borderColor: theme.primaryColor }"></view>
|
||||||
|
<view class="indicator-inner" :style="{ background: themeConfig.primaryColor }">
|
||||||
|
<FaIcon icon="check" :size="12" color="#fff" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 底部提示 -->
|
||||||
|
<view class="bottom-area">
|
||||||
|
<view class="tip-card theme-transition" :style="{ background: themeConfig.cardBgColor }">
|
||||||
|
<view class="tip-icon-wrap" :style="{ background: themeConfig.primaryColor + '15' }">
|
||||||
|
<text class="tip-icon">💡</text>
|
||||||
|
</view>
|
||||||
|
<view class="tip-content">
|
||||||
|
<text class="tip-title theme-transition" :style="{ color: themeConfig.textPrimary }">提示</text>
|
||||||
|
<text class="tip-text theme-transition" :style="{ color: themeConfig.textSecondary }">主题变更将同步到所有页面</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import {
|
||||||
|
THEME_PRESETS,
|
||||||
|
getThemeConfigFromStorage,
|
||||||
|
getThemeKeyFromStorage,
|
||||||
|
switchTheme,
|
||||||
|
DEFAULT_THEME_KEY,
|
||||||
|
THEME_CHANGE_EVENT
|
||||||
|
} from '@/utils/theme.js'
|
||||||
|
import { refreshNavBarTheme, forceApplyTabBarTheme } from '@/utils/themeGlobal'
|
||||||
|
import FaIcon from '@/components/FaIcon.vue'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: { FaIcon },
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
currentThemeKey: '',
|
||||||
|
themeConfig: getThemeConfigFromStorage() || THEME_PRESETS[DEFAULT_THEME_KEY],
|
||||||
|
THEME_PRESETS,
|
||||||
|
isSwitching: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// 计算属性:过滤有效主题 + 实时获取配置
|
||||||
|
computed: {
|
||||||
|
// 过滤掉无效的主题配置,避免循环异常
|
||||||
|
validThemeList() {
|
||||||
|
const validThemes = {}
|
||||||
|
Object.keys(THEME_PRESETS).forEach(key => {
|
||||||
|
if (THEME_PRESETS[key]?.name && THEME_PRESETS[key]?.primaryColor) {
|
||||||
|
validThemes[key] = THEME_PRESETS[key]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return validThemes
|
||||||
|
},
|
||||||
|
// 实时读取最新主题配置(优先级:全局 > 本地 > 默认)
|
||||||
|
currentThemeConfig() {
|
||||||
|
const appInstance = getApp({ allowDefault: true })
|
||||||
|
return appInstance?.globalData?.themeConfig
|
||||||
|
|| getThemeConfigFromStorage()
|
||||||
|
|| THEME_PRESETS[DEFAULT_THEME_KEY]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onLoad() {
|
||||||
|
this.initPage()
|
||||||
|
},
|
||||||
|
onShow() {
|
||||||
|
this.syncThemeState()
|
||||||
|
refreshNavBarTheme()
|
||||||
|
},
|
||||||
|
onUnload() {
|
||||||
|
uni.$off(THEME_CHANGE_EVENT, this.themeChangeHandler)
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
initPage() {
|
||||||
|
this.syncThemeState()
|
||||||
|
this.watchThemeChange()
|
||||||
|
},
|
||||||
|
|
||||||
|
syncThemeState() {
|
||||||
|
const appInstance = getApp({ allowDefault: true })
|
||||||
|
this.currentThemeKey = appInstance?.globalData?.themeKey
|
||||||
|
|| getThemeKeyFromStorage()
|
||||||
|
|| DEFAULT_THEME_KEY
|
||||||
|
this.themeConfig = appInstance?.globalData?.themeConfig
|
||||||
|
|| getThemeConfigFromStorage()
|
||||||
|
|| THEME_PRESETS[DEFAULT_THEME_KEY]
|
||||||
|
},
|
||||||
|
|
||||||
|
watchThemeChange() {
|
||||||
|
this.themeChangeHandler = ({ key, config }) => {
|
||||||
|
if (!key || !config) return
|
||||||
|
if (this.currentThemeKey !== key) {
|
||||||
|
this.currentThemeKey = key
|
||||||
|
this.themeConfig = config
|
||||||
|
}
|
||||||
|
}
|
||||||
|
uni.$on(THEME_CHANGE_EVENT, this.themeChangeHandler)
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取主题项样式(简化逻辑 + 性能优化)
|
||||||
|
*/
|
||||||
|
getThemeItemStyle(theme, key) {
|
||||||
|
const isActive = this.currentThemeKey === key
|
||||||
|
return {
|
||||||
|
backgroundColor: theme.cardBgColor,
|
||||||
|
color: theme.textPrimary,
|
||||||
|
borderRadius: '28rpx',
|
||||||
|
boxShadow: `0 6rpx 20rpx ${theme.shadowColor}`,
|
||||||
|
border: `2rpx solid ${isActive ? theme.primaryColor : theme.borderColor}`,
|
||||||
|
marginBottom: isActive ? '0' : '20rpx' // 选中项减少下边距,突出显示
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取主题描述(增强用户认知)
|
||||||
|
*/
|
||||||
|
getThemeDesc(key) {
|
||||||
|
const descMap = {
|
||||||
|
default: '暖阳黄 · 明亮活泼,适合日常',
|
||||||
|
mint: '清新青 · 自然舒适,清爽怡人',
|
||||||
|
cherry: '樱花粉 · 梦幻浪漫,少女心满满',
|
||||||
|
sky: '晴空蓝 · 清爽明亮,干净利落',
|
||||||
|
dark: '深邃夜 · 护眼深色,夜间更舒适',
|
||||||
|
cream: '薰衣草 · 温柔治愈,浪漫梦幻',
|
||||||
|
ocean: '柑橘橙 · 元气满满,活力四射',
|
||||||
|
matcha: '薄荷青 · 清凉冰爽,沁心舒爽'
|
||||||
|
}
|
||||||
|
return descMap[key] || '自定义主题风格'
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 选择主题(防重复点击 + 友好交互)
|
||||||
|
*/
|
||||||
|
async selectTheme(themeKey) {
|
||||||
|
// 防重复点击 + 合法性校验
|
||||||
|
if (this.isSwitching || this.currentThemeKey === themeKey || !this.validThemeList[themeKey]) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isSwitching = true // 上锁
|
||||||
|
uni.showLoading({ title: '切换中...', mask: true }) // 遮罩防止误操作
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 调用核心切换方法
|
||||||
|
const success = await switchTheme(themeKey)
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
// 延迟返回,等待提示展示(提升体验)
|
||||||
|
setTimeout(() => {
|
||||||
|
this.jumpToMyPage()
|
||||||
|
}, 800)
|
||||||
|
} else {
|
||||||
|
uni.showToast({
|
||||||
|
title: '主题已生效,云端同步失败',
|
||||||
|
icon: 'none',
|
||||||
|
duration: 2000
|
||||||
|
})
|
||||||
|
// 同步失败也返回页面
|
||||||
|
setTimeout(() => this.jumpToMyPage(), 2000)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('切换主题异常:', error)
|
||||||
|
uni.showToast({
|
||||||
|
title: '切换失败,请重试',
|
||||||
|
icon: 'none',
|
||||||
|
duration: 2000
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
uni.hideLoading()
|
||||||
|
this.isSwitching = false // 解锁
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 跳转回我的页面(多层兜底,适配所有场景)
|
||||||
|
*/
|
||||||
|
jumpToMyPage() {
|
||||||
|
const pages = getCurrentPages()
|
||||||
|
// 场景1:从我的页面跳转 → 回退
|
||||||
|
if (pages.length > 1) {
|
||||||
|
uni.navigateBack({
|
||||||
|
delta: 1,
|
||||||
|
fail: () => this.forceJumpToMyPage(),
|
||||||
|
success: () => this.postJumpRefresh()
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// 场景2:直接打开 → 强制跳转
|
||||||
|
this.forceJumpToMyPage()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 强制跳转我的页面(终极兜底)
|
||||||
|
*/
|
||||||
|
forceJumpToMyPage() {
|
||||||
|
// 优先级:switchTab(tabBar)> redirectTo > navigateTo
|
||||||
|
const jumpMethods = [
|
||||||
|
() => uni.switchTab({
|
||||||
|
url: '/pages/mine/mine',
|
||||||
|
success: () => this.postJumpRefresh(),
|
||||||
|
fail: () => {}
|
||||||
|
}),
|
||||||
|
() => uni.redirectTo({
|
||||||
|
url: '/pages/mine/mine',
|
||||||
|
success: () => this.postJumpRefresh(),
|
||||||
|
fail: () => {}
|
||||||
|
}),
|
||||||
|
() => uni.navigateTo({
|
||||||
|
url: '/pages/mine/mine',
|
||||||
|
success: () => this.postJumpRefresh(),
|
||||||
|
fail: () => {}
|
||||||
|
})
|
||||||
|
]
|
||||||
|
|
||||||
|
// 依次尝试跳转,直到成功
|
||||||
|
const tryJump = (index = 0) => {
|
||||||
|
if (index >= jumpMethods.length) return
|
||||||
|
jumpMethods[index]()
|
||||||
|
setTimeout(() => tryJump(index + 1), 300)
|
||||||
|
}
|
||||||
|
tryJump()
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 跳转后刷新TabBar样式(关键:确保TabBar颜色立即更新)
|
||||||
|
*/
|
||||||
|
postJumpRefresh() {
|
||||||
|
setTimeout(() => {
|
||||||
|
const appInstance = getApp({ allowDefault: true })
|
||||||
|
if (appInstance?.globalData?.themeConfig) {
|
||||||
|
forceApplyTabBarTheme(appInstance.globalData.themeConfig)
|
||||||
|
}
|
||||||
|
}, 100)
|
||||||
|
setTimeout(() => {
|
||||||
|
const appInstance = getApp({ allowDefault: true })
|
||||||
|
if (appInstance?.globalData?.themeConfig) {
|
||||||
|
forceApplyTabBarTheme(appInstance.globalData.themeConfig)
|
||||||
|
}
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 页面容器 */
|
||||||
|
.theme-page {
|
||||||
|
padding: 20rpx 24rpx;
|
||||||
|
min-height: 100vh;
|
||||||
|
box-sizing: border-box;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 页面装饰 */
|
||||||
|
.page-decoration {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 50%;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deco-1 {
|
||||||
|
width: 300rpx;
|
||||||
|
height: 300rpx;
|
||||||
|
top: -100rpx;
|
||||||
|
right: -100rpx;
|
||||||
|
animation: float1 8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deco-2 {
|
||||||
|
width: 200rpx;
|
||||||
|
height: 200rpx;
|
||||||
|
bottom: 100rpx;
|
||||||
|
left: -60rpx;
|
||||||
|
animation: float2 6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes float1 {
|
||||||
|
0%, 100% { transform: translate(0, 0); }
|
||||||
|
50% { transform: translate(20rpx, 20rpx); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes float2 {
|
||||||
|
0%, 100% { transform: translate(0, 0); }
|
||||||
|
50% { transform: translate(-15rpx, -15rpx); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 页面头部 */
|
||||||
|
.page-header {
|
||||||
|
margin-bottom: 40rpx;
|
||||||
|
padding: 40rpx 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-decoration {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-glow {
|
||||||
|
position: absolute;
|
||||||
|
top: -50rpx;
|
||||||
|
left: -50rpx;
|
||||||
|
width: 300rpx;
|
||||||
|
height: 300rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20rpx;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-icon-wrap {
|
||||||
|
width: 88rpx;
|
||||||
|
height: 88rpx;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 8rpx 24rpx rgba(0,0,0,0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-icon {
|
||||||
|
font-size: 44rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 42rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 2rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-subtitle {
|
||||||
|
font-size: 28rpx;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 主题列表 */
|
||||||
|
.theme-list {
|
||||||
|
margin-bottom: 40rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 主题项 */
|
||||||
|
.theme-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 32rpx;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-item:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-item::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 4rpx;
|
||||||
|
background: linear-gradient(90deg, transparent, var(--primaryColor), transparent);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-item.active::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 主题预览区 */
|
||||||
|
.theme-preview {
|
||||||
|
width: 150rpx;
|
||||||
|
height: 110rpx;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 14rpx;
|
||||||
|
margin-right: 28rpx;
|
||||||
|
border: 2rpx solid rgba(0,0,0,0.04);
|
||||||
|
box-shadow: 0 4rpx 16rpx rgba(0,0,0,0.06);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-shine {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 50%;
|
||||||
|
background: linear-gradient(180deg, rgba(255,255,255,0.3) 0%, transparent 100%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-circle-wrap {
|
||||||
|
width: 64rpx;
|
||||||
|
height: 64rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-circle {
|
||||||
|
width: 56rpx;
|
||||||
|
height: 56rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 6rpx 20rpx rgba(0,0,0,0.12);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-circle-inner {
|
||||||
|
width: 24rpx;
|
||||||
|
height: 24rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-colors {
|
||||||
|
display: flex;
|
||||||
|
gap: 8rpx;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-color-item {
|
||||||
|
width: 18rpx;
|
||||||
|
height: 18rpx;
|
||||||
|
border-radius: 6rpx;
|
||||||
|
box-shadow: 0 2rpx 6rpx rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-color-item.primary {
|
||||||
|
border-radius: 8rpx 8rpx 2rpx 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-color-item.secondary {
|
||||||
|
border-radius: 8rpx 8rpx 8rpx 2rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-color-item.accent {
|
||||||
|
border-radius: 8rpx 2rpx 8rpx 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-color-item.text {
|
||||||
|
border-radius: 2rpx 8rpx 8rpx 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 主题信息区 */
|
||||||
|
.theme-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-name-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16rpx;
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-name {
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-badge {
|
||||||
|
padding: 6rpx 16rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-text {
|
||||||
|
font-size: 20rpx;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-desc {
|
||||||
|
font-size: 26rpx;
|
||||||
|
opacity: 0.7;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 选中标识 */
|
||||||
|
.active-indicator {
|
||||||
|
width: 56rpx;
|
||||||
|
height: 56rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicator-ring {
|
||||||
|
position: absolute;
|
||||||
|
width: 52rpx;
|
||||||
|
height: 52rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 3rpx solid;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
animation: ringPulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicator-inner {
|
||||||
|
width: 36rpx;
|
||||||
|
height: 36rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 4rpx 12rpx rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-icon {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 20rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ringPulse {
|
||||||
|
0%, 100% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.15);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 底部区域 */
|
||||||
|
.bottom-area {
|
||||||
|
padding-bottom: 60rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tip-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 20rpx;
|
||||||
|
padding: 28rpx 32rpx;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
box-shadow: 0 6rpx 24rpx rgba(0,0,0,0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tip-icon-wrap {
|
||||||
|
width: 56rpx;
|
||||||
|
height: 56rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tip-icon {
|
||||||
|
font-size: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tip-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tip-title {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tip-text {
|
||||||
|
font-size: 26rpx;
|
||||||
|
opacity: 0.75;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 全局过渡类 */
|
||||||
|
.theme-transition {
|
||||||
|
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 覆盖card基础样式 */
|
||||||
|
.card {
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
<template>
|
||||||
|
<view class="agreement-page" :style="{ background: themeConfig.bgColor }">
|
||||||
|
<!-- 协议内容滚动区 -->
|
||||||
|
<scroll-view class="content-scroll" scroll-y>
|
||||||
|
<view class="agreement-content" :style="{ background: themeConfig.cardBgColor }">
|
||||||
|
<view class="title" :style="{ color: themeConfig.textPrimary }">简记memo用户协议</view>
|
||||||
|
<view class="effective-date" :style="{ color: themeConfig.textSecondary }">生效日期:2025年04月24日</view>
|
||||||
|
|
||||||
|
<view class="section">
|
||||||
|
<view class="section-title" :style="{
|
||||||
|
color: themeConfig.primaryColor,
|
||||||
|
background: themeConfig.primaryColor + '10',
|
||||||
|
borderLeftColor: themeConfig.primaryColor
|
||||||
|
}">1. 协议主体与范围</view>
|
||||||
|
<view class="section-text" :style="{ color: themeConfig.textSecondary }">1.1 本协议是您(以下简称“用户”)与简记memo小程序(以下简称“本小程序”)个人运营者(以下简称“运营者”)之间关于使用本小程序所订立的契约,运营者为个人独立开发者,无所属企业/公司主体。</view>
|
||||||
|
<view class="section-text" :style="{ color: themeConfig.textSecondary }">1.2 您通过微信小程序入口访问、使用本小程序的行为,即视为您已阅读、理解并同意本协议全部条款及《隐私政策》;若您不同意本协议或《隐私政策》,应立即停止使用本小程序。</view>
|
||||||
|
<view class="section-text" :style="{ color: themeConfig.textSecondary }">1.3 本协议适用于您使用本小程序的所有行为,包括但不限于微信授权登录、数据录入、存储、编辑等核心功能。</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="section">
|
||||||
|
<view class="section-title" :style="{
|
||||||
|
color: themeConfig.primaryColor,
|
||||||
|
background: themeConfig.primaryColor + '10',
|
||||||
|
borderLeftColor: themeConfig.primaryColor
|
||||||
|
}">2. 服务内容与范围</view>
|
||||||
|
<view class="section-text" :style="{ color: themeConfig.textSecondary }">2.1 本小程序为用户提供免费的备忘录、待办事项、账单记录服务,核心功能包括文字内容录入、服务器存储、编辑、删除、数据同步等,所有功能仅基于微信OpenID识别用户身份提供。</view>
|
||||||
|
<view class="section-text" :style="{ color: themeConfig.textSecondary }">2.2 运营者有权根据技术发展、用户需求、法律法规调整等因素,不定期优化、调整或暂停部分/全部服务,重大调整将通过小程序内公告提前7日告知用户。</view>
|
||||||
|
<view class="section-text" :style="{ color: themeConfig.textSecondary }">2.3 本小程序为免费服务,运营者不承诺提供商业级别的服务稳定性,仅保障基础功能的正常可用。</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="section">
|
||||||
|
<view class="section-title" :style="{
|
||||||
|
color: themeConfig.primaryColor,
|
||||||
|
background: themeConfig.primaryColor + '10',
|
||||||
|
borderLeftColor: themeConfig.primaryColor
|
||||||
|
}">3. 用户权利与义务</view>
|
||||||
|
<view class="section-text" :style="{ color: themeConfig.textSecondary }">3.1 权利:您有权按照本协议约定使用本小程序核心功能;有权查看、编辑、删除自身存储在服务器的所有数据;有权向运营者反馈功能问题或优化建议;在遵守本协议的前提下,运营者保障您正常使用本小程序的基础权益。</view>
|
||||||
|
<view class="section-text" :style="{ color: themeConfig.textSecondary }">3.2 义务:您保证使用本小程序的行为符合《中华人民共和国网络安全法》《互联网信息服务管理办法》等法律法规及公序良俗;不得利用本小程序存储、传播违法、违规、侵权、色情、暴力等内容;应对自身录入的内容及使用行为承担全部法律责任;不得对本小程序进行反向工程、篡改、破解、爬虫抓取等损害小程序正常运行的操作。</view>
|
||||||
|
<view class="section-text" :style="{ color: themeConfig.textSecondary }">3.3 您理解并同意,微信OpenID仅用于身份识别,运营者不会通过该信息获取您的其他微信账号信息。</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="section">
|
||||||
|
<view class="section-title" :style="{
|
||||||
|
color: themeConfig.primaryColor,
|
||||||
|
background: themeConfig.primaryColor + '10',
|
||||||
|
borderLeftColor: themeConfig.primaryColor
|
||||||
|
}">4. 服务的暂停与终止</view>
|
||||||
|
<view class="section-text" :style="{ color: themeConfig.textSecondary }">4.1 运营者有权在以下情形暂停/终止服务:(1)您违反本协议约定且未在通知期限内纠正;(2)法律法规、微信平台规则要求暂停/终止;(3)不可抗力(如服务器故障、网络中断);(4)运营者自主停止运营(提前30日在小程序内公告)。</view>
|
||||||
|
<view class="section-text" :style="{ color: themeConfig.textSecondary }">4.2 服务暂停期间,运营者将尽力恢复服务,但不承诺恢复时限;服务终止后,您存储在服务器的所有数据将保留7日,逾期将永久删除,运营者不承担数据找回、备份或赔偿责任。</view>
|
||||||
|
<view class="section-text" :style="{ color: themeConfig.textSecondary }">4.3 您可主动申请终止服务(联系运营者删除账号及所有数据),申请生效后,运营者将在15个工作日内完成数据清理。</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="section">
|
||||||
|
<view class="section-title" :style="{
|
||||||
|
color: themeConfig.primaryColor,
|
||||||
|
background: themeConfig.primaryColor + '10',
|
||||||
|
borderLeftColor: themeConfig.primaryColor
|
||||||
|
}">5. 免责声明</view>
|
||||||
|
<view class="section-text" :style="{ color: themeConfig.textSecondary }">5.1 本小程序仅提供基础数据存储与管理功能,运营者不对您存储内容的完整性、准确性、安全性做绝对担保;因网络故障、服务器维护、微信平台规则调整、您的设备问题等非运营方可控因素导致的数据丢失、功能异常,运营者不承担赔偿责任。</view>
|
||||||
|
<view class="section-text" :style="{ color: themeConfig.textSecondary }">5.2 本小程序为个人开发的免费服务,运营者无义务提供7×24小时技术支持,仅根据实际情况在合理期限内解答用户咨询(通过预留邮箱/公众号)。</view>
|
||||||
|
<view class="section-text" :style="{ color: themeConfig.textSecondary }">5.3 因您违反法律法规或本协议约定,导致第三方索赔、行政机关处罚的,由您自行承担全部责任,运营者不承担连带责任。</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="section">
|
||||||
|
<view class="section-title" :style="{
|
||||||
|
color: themeConfig.primaryColor,
|
||||||
|
background: themeConfig.primaryColor + '10',
|
||||||
|
borderLeftColor: themeConfig.primaryColor
|
||||||
|
}">6. 协议的变更</view>
|
||||||
|
<view class="section-text" :style="{ color: themeConfig.textSecondary }">6.1 运营者可根据法律法规调整、业务优化需要修改本协议,修改后的协议将在小程序内公示,公示满7日后自动生效。</view>
|
||||||
|
<view class="section-text" :style="{ color: themeConfig.textSecondary }">6.2 若您不同意修改后的协议,应立即停止使用本小程序并可申请删除账号数据;继续使用本小程序的,视为您同意修改后的协议。</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="section">
|
||||||
|
<view class="section-title" :style="{
|
||||||
|
color: themeConfig.primaryColor,
|
||||||
|
background: themeConfig.primaryColor + '10',
|
||||||
|
borderLeftColor: themeConfig.primaryColor
|
||||||
|
}">7. 法律适用与争议解决</view>
|
||||||
|
<view class="section-text" :style="{ color: themeConfig.textSecondary }">7.1 本协议适用中华人民共和国法律(不含港澳台地区法律)。</view>
|
||||||
|
<view class="section-text" :style="{ color: themeConfig.textSecondary }">7.2 因本协议产生的争议,双方应友好协商解决;协商不成的,任何一方可向运营者所在地有管辖权的人民法院提起诉讼。</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="section">
|
||||||
|
<view class="section-title" :style="{
|
||||||
|
color: themeConfig.primaryColor,
|
||||||
|
background: themeConfig.primaryColor + '10',
|
||||||
|
borderLeftColor: themeConfig.primaryColor
|
||||||
|
}">8. 联系与其他</view>
|
||||||
|
<view class="section-text" :style="{ color: themeConfig.textSecondary }">8.1 若您对本协议有任何疑问,可通过以下方式联系运营者:联系邮箱:memo@miaoall.cn;微信公众号:孙三苗(sunsanmiao)。</view>
|
||||||
|
<view class="section-text" :style="{ color: themeConfig.textSecondary }">8.2 本协议的条款如被有权机关认定为无效或不可执行,不影响其余条款的效力。</view>
|
||||||
|
<view class="section-text" :style="{ color: themeConfig.textSecondary }">8.3 本协议与《隐私政策》共同构成您与运营者之间的完整约定,两者冲突时,以《隐私政策》中关于个人信息保护的内容为准。</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { getThemeConfigFromStorage, THEME_PRESETS } from '@/utils/theme'
|
||||||
|
import { refreshNavBarTheme } from '@/utils/themeGlobal'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "userAgreement",
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
themeConfig: getThemeConfigFromStorage() || THEME_PRESETS['default']
|
||||||
|
};
|
||||||
|
},
|
||||||
|
onLoad() {
|
||||||
|
refreshNavBarTheme()
|
||||||
|
this.themeChangeHandler = (themeData) => {
|
||||||
|
let newTheme = null
|
||||||
|
if (themeData?.key && THEME_PRESETS[themeData.key]) {
|
||||||
|
newTheme = THEME_PRESETS[themeData.key]
|
||||||
|
} else if (themeData?.config) {
|
||||||
|
newTheme = themeData.config
|
||||||
|
}
|
||||||
|
if (newTheme) {
|
||||||
|
this.themeConfig = newTheme
|
||||||
|
refreshNavBarTheme()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
uni.$on('themeChanged', this.themeChangeHandler)
|
||||||
|
},
|
||||||
|
onShow() {
|
||||||
|
const latestTheme = getThemeConfigFromStorage() || THEME_PRESETS['default']
|
||||||
|
if (JSON.stringify(latestTheme) !== JSON.stringify(this.themeConfig)) {
|
||||||
|
this.themeConfig = latestTheme
|
||||||
|
}
|
||||||
|
refreshNavBarTheme()
|
||||||
|
},
|
||||||
|
onUnload() {
|
||||||
|
uni.$off('themeChanged', this.themeChangeHandler)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.agreement-page {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 20rpx 24rpx;
|
||||||
|
padding-bottom: 40px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-scroll {
|
||||||
|
height: calc(100vh - 40rpx);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agreement-content {
|
||||||
|
padding: 40rpx;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
min-height: calc(100% - 48rpx);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 36rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
letter-spacing: 1rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effective-date {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 24rpx;
|
||||||
|
margin-bottom: 40rpx;
|
||||||
|
padding-bottom: 24rpx;
|
||||||
|
border-bottom: 1px dashed rgba(132, 179, 217, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
margin-bottom: 36rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 18rpx;
|
||||||
|
padding: 12rpx 16rpx;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
border-left: 4rpx solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-text {
|
||||||
|
font-size: 28rpx;
|
||||||
|
line-height: 2;
|
||||||
|
margin-bottom: 14rpx;
|
||||||
|
text-indent: 56rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-info {
|
||||||
|
margin-top: 48rpx;
|
||||||
|
padding-top: 24rpx;
|
||||||
|
border-top: 1px dashed rgba(132, 179, 217, 0.2);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-text {
|
||||||
|
font-size: 24rpx;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-link {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 757 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 458 B |
|
After Width: | Height: | Size: 476 B |
|
After Width: | Height: | Size: 709 B |
|
After Width: | Height: | Size: 725 B |
|
After Width: | Height: | Size: 972 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 867 B |
|
After Width: | Height: | Size: 908 B |
@@ -0,0 +1,18 @@
|
|||||||
|
/* Font Awesome 6.4.0 - 本地字体文件(无需第三方域名) */
|
||||||
|
/* 字体文件位于 static/webfonts/ 目录下 */
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Font Awesome 6 Free';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 900;
|
||||||
|
font-display: block;
|
||||||
|
src: url('/static/webfonts/fa-solid-900.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Font Awesome 6 Brands';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: block;
|
||||||
|
src: url('/static/webfonts/fa-brands-400.woff2') format('woff2');
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
// =============================================================
|
||||||
|
// Apple HIG 按钮设计规范(全局统一)
|
||||||
|
// 设计原则:清晰层级 / 统一圆角 / 充足点击热区(≥44pt) / 流畅按压反馈
|
||||||
|
// 颜色通过全局 CSS 变量(--primaryColor 等) 驱动,跟随主题切换
|
||||||
|
// 所有变量均带 fallback,未注入时也保证可用
|
||||||
|
// =============================================================
|
||||||
|
|
||||||
|
// ---------- 基础按钮 ----------
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
font-family: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.3rpx;
|
||||||
|
border-radius: 14rpx; // iOS 标准圆角
|
||||||
|
min-height: 88rpx; // ≥44pt 点击热区
|
||||||
|
padding: 0 40rpx;
|
||||||
|
font-size: 30rpx;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: var(--textPrimary, #3D3630);
|
||||||
|
background: var(--btn-secondary-bg, rgba(0, 0, 0, 0.06));
|
||||||
|
transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
opacity 0.2s ease,
|
||||||
|
background-color 0.2s ease,
|
||||||
|
box-shadow 0.2s ease;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
user-select: none;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
// 兼容小程序 text 子节点
|
||||||
|
text {
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 主按钮:填充主题色(渐变) ----------
|
||||||
|
.btn--primary {
|
||||||
|
background: var(--gradientSoft, linear-gradient(135deg, #C68E3F 0%, #D4AA60 100%));
|
||||||
|
color: #ffffff;
|
||||||
|
box-shadow: 0 8rpx 24rpx rgba(var(--primaryColorRGB, 198, 142, 63), 0.28);
|
||||||
|
}
|
||||||
|
.btn--primary:active {
|
||||||
|
transform: scale(0.97);
|
||||||
|
opacity: 0.92;
|
||||||
|
box-shadow: 0 4rpx 12rpx rgba(var(--primaryColorRGB, 198, 142, 63), 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 强调按钮:纯色主题填充(无渐变,更克制) ----------
|
||||||
|
.btn--solid {
|
||||||
|
background: var(--primaryColor, #C68E3F);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
.btn--solid:active {
|
||||||
|
transform: scale(0.97);
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 次要按钮:iOS 经典灰底 ----------
|
||||||
|
.btn--secondary {
|
||||||
|
background: var(--btn-secondary-bg, rgba(0, 0, 0, 0.06));
|
||||||
|
color: var(--textPrimary, #3D3630);
|
||||||
|
}
|
||||||
|
.btn--secondary:active {
|
||||||
|
transform: scale(0.97);
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 描边/幽灵按钮:透明底 + 主题色 + 细边框 ----------
|
||||||
|
.btn--ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--primaryColor, #C68E3F);
|
||||||
|
border: 2rpx solid var(--primaryColor, #C68E3F);
|
||||||
|
}
|
||||||
|
.btn--ghost:active {
|
||||||
|
transform: scale(0.97);
|
||||||
|
background: rgba(var(--primaryColorRGB, 198, 142, 63), 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 纯文字按钮:无底无框 ----------
|
||||||
|
.btn--plain {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--primaryColor, #C68E3F);
|
||||||
|
min-height: 72rpx;
|
||||||
|
padding: 0 24rpx;
|
||||||
|
}
|
||||||
|
.btn--plain:active {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 危险按钮 ----------
|
||||||
|
.btn--danger {
|
||||||
|
background: var(--dangerColor, #C75B4A);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
.btn--danger:active {
|
||||||
|
transform: scale(0.97);
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 图标按钮(圆形) ----------
|
||||||
|
.btn--icon {
|
||||||
|
width: 72rpx;
|
||||||
|
height: 72rpx;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--btn-secondary-bg, rgba(0, 0, 0, 0.06));
|
||||||
|
color: var(--textPrimary, #3D3630);
|
||||||
|
}
|
||||||
|
.btn--icon:active {
|
||||||
|
transform: scale(0.92);
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 尺寸变体 ----------
|
||||||
|
.btn--block {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.btn--sm {
|
||||||
|
min-height: 64rpx;
|
||||||
|
padding: 0 28rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
}
|
||||||
|
.btn--lg {
|
||||||
|
min-height: 96rpx;
|
||||||
|
font-size: 32rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
}
|
||||||
|
.btn--pill {
|
||||||
|
border-radius: 999rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 禁用态 ----------
|
||||||
|
.btn[disabled],
|
||||||
|
.btn.is-disabled,
|
||||||
|
.btn.disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 按钮组(Action Sheet 风格,iOS 等分排列) ----------
|
||||||
|
.btn-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 24rpx;
|
||||||
|
}
|
||||||
|
.btn-group .btn {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
uni.addInterceptor({
|
||||||
|
returnValue (res) {
|
||||||
|
if (!(!!res && (typeof res === "object" || typeof res === "function") && typeof res.then === "function")) {
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
res.then((res) => {
|
||||||
|
if (!res) return resolve(res)
|
||||||
|
return res[0] ? reject(res[0]) : resolve(res[1])
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
/**
|
||||||
|
* 这里是uni-app内置的常用样式变量
|
||||||
|
*
|
||||||
|
* uni-app 官方扩展插件及插件市场(https://ext.dcloud.net.cn)上很多三方插件均使用了这些样式变量
|
||||||
|
* 如果你是插件开发者,建议你使用scss预处理,并在插件代码中直接使用这些变量(无需 import 这个文件),方便用户通过搭积木的方式开发整体风格一致的App
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 如果你是App开发者(插件使用者),你可以通过修改这些变量来定制自己的插件主题,实现自定义主题功能
|
||||||
|
*
|
||||||
|
* 如果你的项目同样使用了scss预处理,你也可以直接在你的 scss 代码中使用如下变量,同时无需 import 这个文件
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* 颜色变量 */
|
||||||
|
|
||||||
|
/* 行为相关颜色 */
|
||||||
|
$uni-color-primary: #007aff;
|
||||||
|
$uni-color-success: #4cd964;
|
||||||
|
$uni-color-warning: #f0ad4e;
|
||||||
|
$uni-color-error: #dd524d;
|
||||||
|
|
||||||
|
/* 文字基本颜色 */
|
||||||
|
$uni-text-color:#333;//基本色
|
||||||
|
$uni-text-color-inverse:#fff;//反色
|
||||||
|
$uni-text-color-grey:#999;//辅助灰色,如加载更多的提示信息
|
||||||
|
$uni-text-color-placeholder: #808080;
|
||||||
|
$uni-text-color-disable:#c0c0c0;
|
||||||
|
|
||||||
|
/* 背景颜色 */
|
||||||
|
$uni-bg-color:#ffffff;
|
||||||
|
$uni-bg-color-grey:#f8f8f8;
|
||||||
|
$uni-bg-color-hover:#f1f1f1;//点击状态颜色
|
||||||
|
$uni-bg-color-mask:rgba(0, 0, 0, 0.4);//遮罩颜色
|
||||||
|
|
||||||
|
/* 边框颜色 */
|
||||||
|
$uni-border-color:#c8c7cc;
|
||||||
|
|
||||||
|
/* 尺寸变量 */
|
||||||
|
|
||||||
|
/* 文字尺寸 */
|
||||||
|
$uni-font-size-sm:12px;
|
||||||
|
$uni-font-size-base:14px;
|
||||||
|
$uni-font-size-lg:16px;
|
||||||
|
|
||||||
|
/* 图片尺寸 */
|
||||||
|
$uni-img-size-sm:20px;
|
||||||
|
$uni-img-size-base:26px;
|
||||||
|
$uni-img-size-lg:40px;
|
||||||
|
|
||||||
|
/* Border Radius */
|
||||||
|
$uni-border-radius-sm: 2px;
|
||||||
|
$uni-border-radius-base: 3px;
|
||||||
|
$uni-border-radius-lg: 6px;
|
||||||
|
$uni-border-radius-circle: 50%;
|
||||||
|
|
||||||
|
/* 水平间距 */
|
||||||
|
$uni-spacing-row-sm: 5px;
|
||||||
|
$uni-spacing-row-base: 10px;
|
||||||
|
$uni-spacing-row-lg: 15px;
|
||||||
|
|
||||||
|
/* 垂直间距 */
|
||||||
|
$uni-spacing-col-sm: 4px;
|
||||||
|
$uni-spacing-col-base: 8px;
|
||||||
|
$uni-spacing-col-lg: 12px;
|
||||||
|
|
||||||
|
/* 透明度 */
|
||||||
|
$uni-opacity-disabled: 0.3; // 组件禁用态的透明度
|
||||||
|
|
||||||
|
/* 文章场景相关 */
|
||||||
|
$uni-color-title: #2C405A; // 文章标题颜色
|
||||||
|
$uni-font-size-title:20px;
|
||||||
|
$uni-color-subtitle: #555555; // 二级标题颜色
|
||||||
|
$uni-font-size-subtitle:26px;
|
||||||
|
$uni-color-paragraph: #3F536E; // 文章段落颜色
|
||||||
|
$uni-font-size-paragraph:15px;
|
||||||
|
|
||||||
|
|
||||||
|
/* 映射 CSS 变量到 SCSS 变量(可选,简化 SCSS 中调用) */
|
||||||
|
$primaryColor: var(--primaryColor);
|
||||||
|
$secondaryColor: var(--secondaryColor);
|
||||||
|
$dangerColor: var(--dangerColor);
|
||||||
|
$bgColor: var(--bgColor);
|
||||||
|
$cardBgColor: var(--cardBgColor);
|
||||||
|
$textPrimary: var(--textPrimary);
|
||||||
|
$textSecondary: var(--textSecondary);
|
||||||
|
$borderColor: var(--borderColor);
|
||||||
|
$shadowColor: var(--shadowColor);
|
||||||
|
|
||||||
|
$page-padding: 20rpx 24rpx;
|
||||||
|
|
||||||
|
/* ============ Apple HIG 统一按钮系统 ============ */
|
||||||
|
@import '@/styles/buttons.scss';
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { getEnvConfig } from './env'
|
||||||
|
|
||||||
|
const envConfig = getEnvConfig()
|
||||||
|
const AES_KEY = envConfig.aesKey || '12345678901234567890123456789012'
|
||||||
|
const AES_IV = envConfig.aesIv || '1234567890123456'
|
||||||
|
|
||||||
|
const CryptoJS = require('../static/utils/crypto-js.min.js')
|
||||||
|
|
||||||
|
function encrypt(data) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
try {
|
||||||
|
let str = typeof data === 'string' ? data : JSON.stringify(data)
|
||||||
|
let encrypted = CryptoJS.AES.encrypt(
|
||||||
|
CryptoJS.enc.Utf8.parse(str),
|
||||||
|
CryptoJS.enc.Utf8.parse(AES_KEY),
|
||||||
|
{
|
||||||
|
iv: CryptoJS.enc.Utf8.parse(AES_IV),
|
||||||
|
mode: CryptoJS.mode.CBC,
|
||||||
|
padding: CryptoJS.pad.Pkcs7
|
||||||
|
}
|
||||||
|
)
|
||||||
|
resolve(encrypted.toString())
|
||||||
|
} catch (e) {
|
||||||
|
console.error('加密失败', e)
|
||||||
|
reject(e)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function decrypt(data) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
try {
|
||||||
|
let decrypted = CryptoJS.AES.decrypt(
|
||||||
|
data,
|
||||||
|
CryptoJS.enc.Utf8.parse(AES_KEY),
|
||||||
|
{
|
||||||
|
iv: CryptoJS.enc.Utf8.parse(AES_IV),
|
||||||
|
mode: CryptoJS.mode.CBC,
|
||||||
|
padding: CryptoJS.pad.Pkcs7
|
||||||
|
}
|
||||||
|
)
|
||||||
|
let str = decrypted.toString(CryptoJS.enc.Utf8)
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(str))
|
||||||
|
} catch {
|
||||||
|
resolve(str)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('解密失败', e)
|
||||||
|
reject(e)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
encrypt,
|
||||||
|
decrypt
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
const cache = new Map()
|
||||||
|
const CACHE_TTL = 30000
|
||||||
|
const PENDING = new Map()
|
||||||
|
|
||||||
|
export const cachedRequest = async (key, requestFn, ttl = CACHE_TTL) => {
|
||||||
|
const now = Date.now()
|
||||||
|
const cached = cache.get(key)
|
||||||
|
if (cached && now - cached.time < ttl) {
|
||||||
|
return cached.data
|
||||||
|
}
|
||||||
|
|
||||||
|
if (PENDING.has(key)) {
|
||||||
|
return PENDING.get(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
const promise = requestFn().then(data => {
|
||||||
|
cache.set(key, { data, time: Date.now() })
|
||||||
|
PENDING.delete(key)
|
||||||
|
return data
|
||||||
|
}).catch(err => {
|
||||||
|
PENDING.delete(key)
|
||||||
|
throw err
|
||||||
|
})
|
||||||
|
|
||||||
|
PENDING.set(key, promise)
|
||||||
|
return promise
|
||||||
|
}
|
||||||
|
|
||||||
|
export const clearCache = (key) => {
|
||||||
|
if (key) {
|
||||||
|
cache.delete(key)
|
||||||
|
} else {
|
||||||
|
cache.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getCacheKey = (prefix, params) => {
|
||||||
|
return `${prefix}:${JSON.stringify(params || {})}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createCachedRequest = (cachePrefix) => {
|
||||||
|
return async (url, config = {}) => {
|
||||||
|
const key = getCacheKey(cachePrefix || url, config.data)
|
||||||
|
return cachedRequest(key, () => {
|
||||||
|
if (typeof config.request === 'function') {
|
||||||
|
return config.request(url, config)
|
||||||
|
}
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
uni.request({
|
||||||
|
url: url,
|
||||||
|
method: config.method || 'POST',
|
||||||
|
data: config.data || {},
|
||||||
|
header: config.header || {},
|
||||||
|
timeout: config.timeout || 15000,
|
||||||
|
success: (res) => resolve(res.data),
|
||||||
|
fail: (err) => reject(new Error(err.errMsg || '请求失败'))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}, config.cacheTTL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
cachedRequest,
|
||||||
|
clearCache,
|
||||||
|
createCachedRequest
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { clearCache } from './apiCache.js'
|
||||||
|
|
||||||
|
const TOKEN_KEY = 'user_token'
|
||||||
|
|
||||||
|
// 获取token
|
||||||
|
export function getToken() {
|
||||||
|
try {
|
||||||
|
return uni.getStorageSync(TOKEN_KEY) || ''
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取token失败:', err)
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存token
|
||||||
|
export function setToken(token) {
|
||||||
|
try {
|
||||||
|
if (token && typeof token === 'string') {
|
||||||
|
uni.setStorageSync(TOKEN_KEY, token)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('保存token失败:', err)
|
||||||
|
uni.showToast({
|
||||||
|
title: '登录状态保存失败',
|
||||||
|
icon: 'none'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除token
|
||||||
|
export function removeToken() {
|
||||||
|
try {
|
||||||
|
uni.removeStorageSync(TOKEN_KEY)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('删除token失败:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判断是否登录
|
||||||
|
export function isLogin() {
|
||||||
|
const token = getToken()
|
||||||
|
return !!token && token.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验登录(未登录跳转)
|
||||||
|
export function checkLogin() {
|
||||||
|
if (!isLogin()) {
|
||||||
|
const pages = getCurrentPages()
|
||||||
|
const currentPage = pages[pages.length - 1]
|
||||||
|
if (currentPage?.route !== 'pages/login/login') {
|
||||||
|
uni.navigateTo({
|
||||||
|
url: '/pages/login/login'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 退出登录
|
||||||
|
export function logout() {
|
||||||
|
try {
|
||||||
|
// 1. 删除token(保留原有核心逻辑)
|
||||||
|
removeToken()
|
||||||
|
clearCache()
|
||||||
|
// 2. 清空用户信息缓存(解决用户名/头像/签名/邮箱残留)
|
||||||
|
uni.removeStorageSync('userName')
|
||||||
|
uni.removeStorageSync('userAvatar')
|
||||||
|
uni.removeStorageSync('userSignature')
|
||||||
|
uni.removeStorageSync('userEmail')
|
||||||
|
// 如有其他登录相关缓存,继续补充:
|
||||||
|
// uni.removeStorageSync('userInfo')
|
||||||
|
|
||||||
|
// 3. 重置全局变量(如有)
|
||||||
|
if (getApp().globalData?.userInfo) {
|
||||||
|
getApp().globalData.userInfo = null
|
||||||
|
}
|
||||||
|
if (getApp().globalData?.token) {
|
||||||
|
getApp().globalData.token = ''
|
||||||
|
}
|
||||||
|
if (getApp().globalData?.lastToken) {
|
||||||
|
getApp().globalData.lastToken = ''
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('清空登录缓存失败:', err)
|
||||||
|
uni.showToast({
|
||||||
|
title: '清空登录状态失败',
|
||||||
|
icon: 'none'
|
||||||
|
})
|
||||||
|
return // 出错则终止,避免错误跳转
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 提示(保留原有)
|
||||||
|
uni.showToast({
|
||||||
|
title: '已退出登录',
|
||||||
|
icon: 'success',
|
||||||
|
duration: 1500
|
||||||
|
})
|
||||||
|
|
||||||
|
// 5. 关键修改:用reLaunch替代switchTab,强制重启小程序
|
||||||
|
setTimeout(() => {
|
||||||
|
uni.reLaunch({
|
||||||
|
url: '/pages/login/login', // 直接跳登录页,而非首页
|
||||||
|
fail: () => {
|
||||||
|
// 兜底:重启失败则直接跳转
|
||||||
|
uni.redirectTo({
|
||||||
|
url: '/pages/login/login'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, 1500)
|
||||||
|
}
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
import { getDomain } from './env'
|
||||||
|
|
||||||
|
// 头像基础路径(远端)
|
||||||
|
const AVATAR_BASE_URL = getDomain() + '/static/avatar/'
|
||||||
|
|
||||||
|
// 图片基础路径(远端)
|
||||||
|
const IMAGE_BASE_URL = getDomain() + '/static/images/'
|
||||||
|
|
||||||
|
// ========== 记账分类常量 start ==========
|
||||||
|
export const DEFAULT_CATE_LIST = [
|
||||||
|
// 支出类(type: 1)
|
||||||
|
{ value: 'food', label: '餐饮', icon: '🍚', color: '#ff6b6b', type: 1 },
|
||||||
|
{ value: 'transport', label: '交通', icon: '🚇', color: '#4ecdc4', type: 1 },
|
||||||
|
{ value: 'shopping', label: '购物', icon: '🛍️', color: '#ffe66d', type: 1 },
|
||||||
|
{ value: 'entertainment', label: '娱乐', icon: '🎮', color: '#a855f7', type: 1 },
|
||||||
|
{ value: 'home', label: '居家', icon: '🏠', color: '#84b3d9', type: 1 },
|
||||||
|
{ value: 'medical', label: '医疗', icon: '🏥', color: '#ef4444', type: 1 },
|
||||||
|
{ value: 'education', label: '教育', icon: '📚', color: '#22c55e', type: 1 },
|
||||||
|
{ value: 'social', label: '社交', icon: '👥', color: '#f97316', type: 1 },
|
||||||
|
{ value: 'digital', label: '数码', icon: '📱', color: '#3b82f6', type: 1 },
|
||||||
|
{ value: 'clothing', label: '服饰', icon: '👔', color: '#ec4899', type: 1 },
|
||||||
|
{ value: 'beauty', label: '美容', icon: '💄', color: '#d946ef', type: 1 },
|
||||||
|
{ value: 'sports', label: '运动', icon: '⚽', color: '#10b981', type: 1 },
|
||||||
|
{ value: 'pet', label: '宠物', icon: '🐶', color: '#fbbf24', type: 1 },
|
||||||
|
{ value: 'baby', label: '育儿', icon: '🍼', color: '#fb7185', type: 1 },
|
||||||
|
{ value: 'travel', label: '旅游', icon: '✈️', color: '#06b6d4', type: 1 },
|
||||||
|
{ value: 'other_expense', label: '其他', icon: '📦', color: '#9ca3af', type: 1 },
|
||||||
|
|
||||||
|
// 收入类(type: 2)
|
||||||
|
{ value: 'salary', label: '工资', icon: '💼', color: '#22c55e', type: 2 },
|
||||||
|
{ value: 'bonus', label: '奖金', icon: '🎁', color: '#fbbf24', type: 2 },
|
||||||
|
{ value: 'side_hustle', label: '副业', icon: '💻', color: '#3b82f6', type: 2 },
|
||||||
|
{ value: 'investment', label: '理财', icon: '📈', color: '#a855f7', type: 2 },
|
||||||
|
{ value: 'transfer', label: '转账', icon: '💳', color: '#84b3d9', type: 2 },
|
||||||
|
{ value: 'red_packet', label: '红包', icon: '🧧', color: '#ef4444', type: 2 },
|
||||||
|
{ value: 'other_income', label: '其他', icon: '🎯', color: '#6b7280', type: 2 }
|
||||||
|
]
|
||||||
|
|
||||||
|
// 例如:账单类型常量
|
||||||
|
export const BILL_TYPE = {
|
||||||
|
DEFAULT: 0, // 全部
|
||||||
|
EXPEND: 1, // 支出
|
||||||
|
INCOME: 2, // 收入
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 我的页面菜单图标 ==========
|
||||||
|
export const MINE_MENU_ICONS = {
|
||||||
|
GROWTH: '/static/icon/growth.png',
|
||||||
|
BUDGET: '/static/icon/monthly_budget.png',
|
||||||
|
THEME: '/static/icon/theme.png',
|
||||||
|
ABOUT: '/static/icon/about.png',
|
||||||
|
EMAIL: '/static/icon/email.png',
|
||||||
|
WECHAT: '/static/icon/wechat.png'
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// 分类颜色选项
|
||||||
|
export const COLOR_OPTIONS = [
|
||||||
|
'#84b3d9', '#a0d0e0', '#b8d8e6', '#d0e0e8', '#e8e8e8',
|
||||||
|
'#f0f0f0', '#f8f0f0', '#f0f8f0', '#f0f0f8', '#e0f0f8'
|
||||||
|
]
|
||||||
|
|
||||||
|
// 滑动按钮宽度(rpx)
|
||||||
|
export const SLIDE_BTN_WIDTH = 240
|
||||||
|
|
||||||
|
// 筛选Tab 常量
|
||||||
|
export const BILL_FILTER_TYPE = {
|
||||||
|
ALL: 'all',
|
||||||
|
EXPEND: 'expend',
|
||||||
|
INCOME: 'income'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 账单类型筛选选项(用于循环渲染)
|
||||||
|
export const BILL_TYPE_FILTER_OPTIONS = [
|
||||||
|
{ label: '全部', value: BILL_FILTER_TYPE.ALL },
|
||||||
|
{ label: '支出', value: BILL_FILTER_TYPE.EXPEND },
|
||||||
|
{ label: '收入', value: BILL_FILTER_TYPE.INCOME }
|
||||||
|
]
|
||||||
|
|
||||||
|
// 时间筛选标签
|
||||||
|
export const TIME_TAG = {
|
||||||
|
ALL: 'all',
|
||||||
|
TODAY: 'today',
|
||||||
|
WEEK: 'week',
|
||||||
|
MONTH: 'month',
|
||||||
|
LAST_MONTH: 'lastMonth',
|
||||||
|
THIS_YEAR: 'thisYear',
|
||||||
|
CUSTOM: 'custom'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 账单时间筛选选项(用于循环渲染)
|
||||||
|
export const BILL_TIME_FILTER_OPTIONS = [
|
||||||
|
{ label: '今日', value: TIME_TAG.TODAY },
|
||||||
|
{ label: '本周', value: TIME_TAG.WEEK },
|
||||||
|
{ label: '本月', value: TIME_TAG.MONTH },
|
||||||
|
{ label: '上月', value: TIME_TAG.LAST_MONTH },
|
||||||
|
{ label: '本年', value: TIME_TAG.THIS_YEAR },
|
||||||
|
{ label: '自定义', value: TIME_TAG.CUSTOM }
|
||||||
|
]
|
||||||
|
|
||||||
|
// 4. 金额快捷按钮数值
|
||||||
|
export const QUICK_MONEY_AMOUNTS = [10, 20, 50, 100, 200, 500];
|
||||||
|
|
||||||
|
// 5. 备注快捷标签
|
||||||
|
export const QUICK_NOTE_TAGS = ['午餐', '打车', '地铁', '工资', '购物', '娱乐'];
|
||||||
|
|
||||||
|
// 6. 预算进度颜色阈值
|
||||||
|
export const BUDGET_PROGRESS_COLOR = {
|
||||||
|
OVER: 100, // 超支(红色)
|
||||||
|
WARNING: 80, // 警告(橙色)
|
||||||
|
NORMAL: 0 // 正常(蓝色)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. 预算进度颜色值
|
||||||
|
export const PROGRESS_COLORS = {
|
||||||
|
OVER: '#F53F3F',
|
||||||
|
WARNING: '#FFAA33',
|
||||||
|
NORMAL: '#E8F3FF'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. 最近选择分类最大数量
|
||||||
|
export const RECENT_CATE_MAX_COUNT = 3
|
||||||
|
|
||||||
|
// 9. 滑动阈值(触发显示按钮的最小滑动距离)
|
||||||
|
export const SLIDE_THRESHOLD = 50
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// ========== 记账分类常量 end ==========
|
||||||
|
|
||||||
|
|
||||||
|
// ========== 待办(TODO)相关常量 start ==========
|
||||||
|
// 待办基础分类(对应你原有的 BASE_CATE)
|
||||||
|
export const TODO_BASE_CATE = Object.freeze([
|
||||||
|
{
|
||||||
|
value: 'work',
|
||||||
|
label: '工作',
|
||||||
|
color: '#84b3d9'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'life',
|
||||||
|
label: '生活',
|
||||||
|
color: '#a0d0e0'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'study',
|
||||||
|
label: '学习',
|
||||||
|
color: '#b8d8e6'
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
// 星期名称映射(从周一开始,对应你原有的 WEEK_DAYS)
|
||||||
|
export const WEEK_DAYS = Object.freeze(['一', '二', '三', '四', '五', '六', '日'])
|
||||||
|
|
||||||
|
// 结构化的筛选常量(替代硬编码)
|
||||||
|
export const TIME_FILTER_OPTIONS = [
|
||||||
|
{ label: '今日', value: 'today' },
|
||||||
|
{ label: '本周', value: 'week' },
|
||||||
|
{ label: '本月', value: 'month' },
|
||||||
|
{ label: '自定义', value: 'custom' }
|
||||||
|
]
|
||||||
|
|
||||||
|
export const STATUS_FILTER_OPTIONS = [
|
||||||
|
{ label: '全部', value: 'all' },
|
||||||
|
{ label: '待办', value: 'undone' },
|
||||||
|
{ label: '已完成', value: 'done' }
|
||||||
|
]
|
||||||
|
|
||||||
|
// 优先级常量(结构化)
|
||||||
|
export const TODO_PRIORITY_LIST = [
|
||||||
|
{ label: '高', value: 1, icon: '🔴' },
|
||||||
|
{ label: '中', value: 2, icon: '🟡' },
|
||||||
|
{ label: '低', value: 3, icon: '🟢' }
|
||||||
|
]
|
||||||
|
// ========== 待办(TODO)相关常量 end ==========
|
||||||
|
|
||||||
|
// ========== 收支渠道常量 ==========
|
||||||
|
export const PAY_CHANNELS = [
|
||||||
|
// 通用渠道(支持收入和支出)
|
||||||
|
{ value: 'wechat', label: '微信', types: [1, 2] }, // 1=支出, 2=收入
|
||||||
|
{ value: 'alipay', label: '支付宝', types: [1, 2] },
|
||||||
|
{ value: 'unionpay', label: '云闪付', types: [1, 2] }, // 新增
|
||||||
|
{ value: 'bank', label: '银行卡', types: [1, 2] },
|
||||||
|
{ value: 'cash', label: '现金', types: [1, 2] },
|
||||||
|
{ value: 'online_banking', label: '网银', types: [1, 2] }, // 新增
|
||||||
|
{ value: 'pos', label: 'POS机', types: [1, 2] }, // 新增
|
||||||
|
{ value: 'other', label: '其他', types: [1, 2] }, // 新增
|
||||||
|
|
||||||
|
// 仅收入渠道
|
||||||
|
{ value: 'salary', label: '工资', types: [2] },
|
||||||
|
{ value: 'red_packet', label: '红包', types: [2] },
|
||||||
|
{ value: 'transfer_in', label: '转账', types: [2] },
|
||||||
|
{ value: 'corporate', label: '对公转账', types: [2] }, // 新增
|
||||||
|
|
||||||
|
// 仅支出渠道
|
||||||
|
{ value: 'credit', label: '信用卡', types: [1] },
|
||||||
|
{ value: 'huawei', label: '花呗', types: [1] },
|
||||||
|
{ value: 'white', label: '白条', types: [1] },
|
||||||
|
{ value: 'jd_pay', label: '京东支付', types: [1] }, // 新增
|
||||||
|
{ value: 'meituan_pay', label: '美团支付', types: [1] }, // 新增
|
||||||
|
{ value: 'gift_card', label: '储值卡', types: [1] } // 新增
|
||||||
|
]
|
||||||
|
|
||||||
|
// 获取指定类型的渠道列表
|
||||||
|
export const getChannelsByType = (type) => {
|
||||||
|
return PAY_CHANNELS.filter(channel => channel.types.includes(type))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据渠道值获取渠道名称
|
||||||
|
export const getChannelLabel = (value) => {
|
||||||
|
const channel = PAY_CHANNELS.find(c => c.value === value)
|
||||||
|
return channel ? channel.label : value
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据分类值获取分类名称
|
||||||
|
export const getCateLabel = (value) => {
|
||||||
|
const cate = DEFAULT_CATE_LIST.find(c => c.value === value)
|
||||||
|
return cate ? cate.label : value
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 可扩展:其他全局常量 ==========
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// 远端头像资源配置(根据当前环境动态获取域名)
|
||||||
|
export const DEFAULT_AVATARS = [
|
||||||
|
AVATAR_BASE_URL + 'ginger_cat.jpg', // 橘猫
|
||||||
|
AVATAR_BASE_URL + 'ragdoll.jpg', // 布偶猫
|
||||||
|
AVATAR_BASE_URL + 'hamster.jpg', // 小仓鼠
|
||||||
|
AVATAR_BASE_URL + 'dragon.jpg', // 龙
|
||||||
|
AVATAR_BASE_URL + 'pony.jpg', // 小马
|
||||||
|
AVATAR_BASE_URL + 'lamb.jpg', // 小羊
|
||||||
|
AVATAR_BASE_URL + 'corgi.jpg', // 柯基
|
||||||
|
AVATAR_BASE_URL + 'piglet.jpg', // 小猪
|
||||||
|
]
|
||||||
|
|
||||||
|
// 例如:请求状态码常量
|
||||||
|
export const HTTP_CODE = {
|
||||||
|
SUCCESS: 200,
|
||||||
|
TOKEN_EXPIRE: 401,
|
||||||
|
SERVER_ERROR: 500
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 心情相关常量 ==========
|
||||||
|
export const MOOD_LIST = [
|
||||||
|
{ emoji: '😊', label: '开心', phrases: ['今天心情真好', '阳光灿烂的一天', '嘴角不自觉上扬', '满心欢喜'] },
|
||||||
|
{ emoji: '😌', label: '平静', phrases: ['岁月静好', '内心一片安宁', '享受这份宁静', '平和自在'] },
|
||||||
|
{ emoji: '😔', label: '低落', phrases: ['有点小忧伤', '心情沉沉的', '需要一点温暖', '静待花开'] },
|
||||||
|
{ emoji: '😤', label: '生气', phrases: ['有点烦躁呢', '深呼吸冷静一下', '需要冷静片刻', '平复一下心情'] },
|
||||||
|
{ emoji: '😴', label: '疲惫', phrases: ['有点累了', '好好休息一下', '给自己放个假', '累并快乐着'] },
|
||||||
|
{ emoji: '😰', label: '焦虑', phrases: ['有点担心', '相信一切会好', '慢慢来不着急', '放宽心'] },
|
||||||
|
{ emoji: '😄', label: '兴奋', phrases: ['超级开心!', '激动人心的时刻', '迫不及待了', '满心期待'] },
|
||||||
|
{ emoji: '🤔', label: '思考', phrases: ['正在思考中', '让我想想', '深思熟虑', '思绪万千'] },
|
||||||
|
{ emoji: '😢', label: '难过', phrases: ['有点难过', '需要一个拥抱', '一切都会过去', '明天会更好'] },
|
||||||
|
{ emoji: '😎', label: '酷', phrases: ['今天超酷的', '自信满满', '做自己就好', '洒脱自在'] },
|
||||||
|
{ emoji: '🥰', label: '幸福', phrases: ['被幸福包围', '甜蜜的感觉', '幸福感爆棚', '满心欢喜'] },
|
||||||
|
{ emoji: '😠', label: '愤怒', phrases: ['真的生气了', '需要冷静一下', '平复一下情绪', '深呼吸'] },
|
||||||
|
{ emoji: '🥳', label: '庆祝', phrases: ['值得庆祝!', '太棒了!', '举杯庆祝', '喜笑颜开'] },
|
||||||
|
{ emoji: '😱', label: '惊讶', phrases: ['哇!好惊喜', '太意外了', '不可思议', '令人震惊'] },
|
||||||
|
{ emoji: '😇', label: '感恩', phrases: ['心怀感恩', '感谢生活', '感恩遇见', '心存感激'] },
|
||||||
|
{ emoji: '💪', label: '加油', phrases: ['加油加油!', '相信自己', '勇往直前', '全力以赴'] },
|
||||||
|
{ emoji: '😘', label: '害羞', phrases: ['有点害羞', '不好意思啦', '脸红心跳', '羞涩一笑'] },
|
||||||
|
{ emoji: '🤩', label: '崇拜', phrases: ['超级崇拜!', '偶像光芒', '闪闪发光', '心生敬意'] },
|
||||||
|
{ emoji: '😪', label: '困倦', phrases: ['好困呀', '眼皮打架了', '需要小憩', '睡个好觉'] },
|
||||||
|
{ emoji: '😋', label: '满足', phrases: ['心满意足', '幸福感满满', '知足常乐', '十分满足'] }
|
||||||
|
]
|
||||||
|
|
||||||
|
// ========== Logo 与品牌信息常量 ==========
|
||||||
|
export const LOGO_CONFIG = {
|
||||||
|
path: IMAGE_BASE_URL + 'logo.png',
|
||||||
|
name: '简记memo',
|
||||||
|
subtitle: '让记录更简单',
|
||||||
|
slogan: '记录,是对抗遗忘的方式。\n生活的美好藏在点滴细节里,\n用简记memo,写下时光的足迹。',
|
||||||
|
version: 'v1.0.0',
|
||||||
|
copyright: `© ${new Date().getFullYear()} 简记memo. All rights reserved.`,
|
||||||
|
email: 'memo@miaoall.cn',
|
||||||
|
wechat: {
|
||||||
|
name: '孙三苗',
|
||||||
|
account: 'sunsanmiao'
|
||||||
|
},
|
||||||
|
brand: {
|
||||||
|
primaryColor: '#84b3d9',
|
||||||
|
secondaryColor: '#6a99c2',
|
||||||
|
gradient: 'linear-gradient(135deg, #84b3d9 0%, #6a99c2 100%)'
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* 环境配置
|
||||||
|
* 根据编译环境自动切换 API 地址
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 环境列表
|
||||||
|
export const ENV = {
|
||||||
|
DEV: 'development',
|
||||||
|
PROD: 'production',
|
||||||
|
TEST: 'test'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 当前环境(默认测试环境)
|
||||||
|
let currentEnv = ENV.TEST
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置当前环境
|
||||||
|
* @param {string} env - 环境名称
|
||||||
|
*/
|
||||||
|
export const setEnv = (env) => {
|
||||||
|
if (Object.values(ENV).includes(env)) {
|
||||||
|
currentEnv = env
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前环境
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export const getEnv = () => currentEnv
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 环境配置
|
||||||
|
* 只配置域名,路由前缀在 request.js 中单独配置
|
||||||
|
*/
|
||||||
|
const ENV_CONFIG = {
|
||||||
|
[ENV.DEV]: {
|
||||||
|
name: '开发环境',
|
||||||
|
domain: 'https://memo.test.miaoall.cn',
|
||||||
|
debug: true,
|
||||||
|
aesKey: '12345678901234567890123456789012',
|
||||||
|
aesIv: '1234567890123456'
|
||||||
|
},
|
||||||
|
[ENV.TEST]: {
|
||||||
|
name: '测试环境',
|
||||||
|
domain: 'https://memo.test.miaoall.cn',
|
||||||
|
debug: true,
|
||||||
|
aesKey: '12345678901234567890123456789012',
|
||||||
|
aesIv: '1234567890123456'
|
||||||
|
},
|
||||||
|
[ENV.PROD]: {
|
||||||
|
name: '生产环境',
|
||||||
|
domain: 'https://memo.miaoall.cn',
|
||||||
|
debug: false,
|
||||||
|
aesKey: 'a1s2d3f4g5h6j7k8l9z0xq1w2e3r4t5y6',
|
||||||
|
aesIv: 'q1w2e3r4t5y6u7i8'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前环境的配置
|
||||||
|
* @returns {Object}
|
||||||
|
*/
|
||||||
|
export const getEnvConfig = () => {
|
||||||
|
return ENV_CONFIG[currentEnv] || ENV_CONFIG[ENV.TEST]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 API 域名
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export const getDomain = () => getEnvConfig().domain
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前环境名称
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export const getEnvName = () => getEnvConfig().name
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否为开发模式
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export const isDev = () => currentEnv === ENV.DEV
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否为生产模式
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export const isProd = () => currentEnv === ENV.PROD
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否为测试模式
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export const isTest = () => currentEnv === ENV.TEST
|
||||||
|
|
||||||
|
// 默认导出配置
|
||||||
|
export default {
|
||||||
|
ENV,
|
||||||
|
getEnv,
|
||||||
|
setEnv,
|
||||||
|
getEnvConfig,
|
||||||
|
getDomain,
|
||||||
|
getEnvName,
|
||||||
|
isDev,
|
||||||
|
isProd,
|
||||||
|
isTest
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { MOOD_LIST } from './constants.js'
|
||||||
|
|
||||||
|
export function formatMoodEmoji(emoji) {
|
||||||
|
if (!emoji) return ''
|
||||||
|
const item = MOOD_LIST.find(m => m.emoji === emoji)
|
||||||
|
return item ? item.emoji : emoji
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMoodDateRange(range) {
|
||||||
|
const now = new Date()
|
||||||
|
let startDate = ''
|
||||||
|
let endDate = ''
|
||||||
|
|
||||||
|
switch (range) {
|
||||||
|
case 'today':
|
||||||
|
startDate = formatDate(now)
|
||||||
|
endDate = formatDate(now)
|
||||||
|
break
|
||||||
|
case 'week':
|
||||||
|
const weekStart = new Date(now)
|
||||||
|
const dayOfWeek = now.getDay() || 7
|
||||||
|
weekStart.setDate(now.getDate() - dayOfWeek + 1)
|
||||||
|
const weekEnd = new Date(weekStart)
|
||||||
|
weekEnd.setDate(weekStart.getDate() + 6)
|
||||||
|
startDate = formatDate(weekStart)
|
||||||
|
endDate = formatDate(weekEnd)
|
||||||
|
break
|
||||||
|
case 'month':
|
||||||
|
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1)
|
||||||
|
const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0)
|
||||||
|
startDate = formatDate(monthStart)
|
||||||
|
endDate = formatDate(monthEnd)
|
||||||
|
break
|
||||||
|
case 'all':
|
||||||
|
default:
|
||||||
|
startDate = formatDate(now)
|
||||||
|
endDate = formatDate(now)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
return { startDate, endDate }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMoodTypeName(type) {
|
||||||
|
const map = {
|
||||||
|
happy: '开心',
|
||||||
|
neutral: '平静',
|
||||||
|
sad: '低落',
|
||||||
|
angry: '生气',
|
||||||
|
tired: '疲惫',
|
||||||
|
anxious: '焦虑',
|
||||||
|
excited: '兴奋',
|
||||||
|
thinking: '思考',
|
||||||
|
sad2: '难过',
|
||||||
|
cool: '酷',
|
||||||
|
happy2: '幸福',
|
||||||
|
angry2: '愤怒',
|
||||||
|
celebrate: '庆祝',
|
||||||
|
surprised: '惊讶',
|
||||||
|
grateful: '感恩',
|
||||||
|
cheer: '加油',
|
||||||
|
shy: '害羞',
|
||||||
|
admire: '崇拜',
|
||||||
|
sleepy: '困倦',
|
||||||
|
satisfied: '满足'
|
||||||
|
}
|
||||||
|
return map[type] || type || '未知'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMoodLabel(emoji) {
|
||||||
|
const item = MOOD_LIST.find(m => m.emoji === emoji)
|
||||||
|
return item ? item.label : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMoodTypeByEmoji(emoji) {
|
||||||
|
const item = MOOD_LIST.find(m => m.emoji === emoji)
|
||||||
|
if (!item) return 'neutral'
|
||||||
|
const label = item.label
|
||||||
|
if (['开心', '兴奋', '幸福', '庆祝', '满足'].includes(label)) return 'happy'
|
||||||
|
if (['低落', '难过', '焦虑', '困倦'].includes(label)) return 'sad'
|
||||||
|
if (['生气', '愤怒'].includes(label)) return 'angry'
|
||||||
|
if (['疲惫'].includes(label)) return 'tired'
|
||||||
|
return 'neutral'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(date) {
|
||||||
|
const y = date.getFullYear()
|
||||||
|
const m = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const d = String(date.getDate()).padStart(2, '0')
|
||||||
|
return `${y}-${m}-${d}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
getMoodDateRange,
|
||||||
|
getMoodTypeName,
|
||||||
|
getMoodLabel,
|
||||||
|
getMoodTypeByEmoji
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
import AES from './aes.js'
|
||||||
|
import { getToken, checkLogin, removeToken, setToken } from './auth'
|
||||||
|
import { login } from '../api'
|
||||||
|
import { getDomain, getEnvName } from './env'
|
||||||
|
import { cachedRequest, getCacheKey, clearCache } from './apiCache.js'
|
||||||
|
|
||||||
|
const API_PREFIX = '/api/app'
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
BASE_URL: getDomain() + API_PREFIX,
|
||||||
|
API_PREFIX: API_PREFIX,
|
||||||
|
DOMAIN: getDomain(),
|
||||||
|
ENV_NAME: getEnvName(),
|
||||||
|
TIMEOUT: 15000,
|
||||||
|
LOGIN_PAGE: '/pages/login/login',
|
||||||
|
CODE: {
|
||||||
|
SUCCESS: 200,
|
||||||
|
NEED_LOGIN: 1000,
|
||||||
|
TOKEN_EXPIRED: 1001,
|
||||||
|
TOKEN_INVALID: 1002
|
||||||
|
},
|
||||||
|
MAX_RETRY: 2
|
||||||
|
}
|
||||||
|
|
||||||
|
let isRefreshing = false
|
||||||
|
let refreshPromise = null
|
||||||
|
|
||||||
|
const isLoginPage = () => {
|
||||||
|
try {
|
||||||
|
const pages = getCurrentPages()
|
||||||
|
const currentPage = pages[pages.length - 1]
|
||||||
|
return currentPage?.route === 'pages/login/login'
|
||||||
|
} catch (err) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCurrentPageInfo = () => {
|
||||||
|
try {
|
||||||
|
const pages = getCurrentPages()
|
||||||
|
const currentPage = pages[pages.length - 1]
|
||||||
|
return {
|
||||||
|
route: currentPage?.route || 'unknown',
|
||||||
|
options: currentPage?.options || {}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
return { route: 'unknown', options: {} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshToken = async () => {
|
||||||
|
if (isRefreshing) {
|
||||||
|
return refreshPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentPage = getCurrentPageInfo()
|
||||||
|
console.log(`📋 当前页面: ${currentPage.route}`)
|
||||||
|
|
||||||
|
if (isLoginPage()) {
|
||||||
|
console.log('⚠️ 当前在登录页,不执行自动刷新Token')
|
||||||
|
return Promise.reject(new Error('当前在登录页'))
|
||||||
|
}
|
||||||
|
|
||||||
|
isRefreshing = true
|
||||||
|
refreshPromise = new Promise(async (resolve, reject) => {
|
||||||
|
try {
|
||||||
|
console.log(`🔄 尝试自动刷新Token... (触发页面: ${currentPage.route})`)
|
||||||
|
const loginRes = await uni.login()
|
||||||
|
if (!loginRes.code) {
|
||||||
|
throw new Error('获取微信code失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenData = await login(loginRes.code)
|
||||||
|
if (!tokenData?.token) {
|
||||||
|
throw new Error('刷新Token失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
setToken(tokenData.token)
|
||||||
|
console.log(`✅ Token刷新成功 (触发页面: ${currentPage.route})`)
|
||||||
|
resolve(tokenData.token)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`❌ Token刷新失败(触发页面: ${currentPage.route}):`, err)
|
||||||
|
removeToken()
|
||||||
|
clearCache()
|
||||||
|
reject(err)
|
||||||
|
} finally {
|
||||||
|
isRefreshing = false
|
||||||
|
refreshPromise = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return refreshPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
const executeRequest = async (options, retryCount = 0) => {
|
||||||
|
const { url, data = {}, needLogin = true, showLoading = true, encrypt = true } = options
|
||||||
|
|
||||||
|
if (showLoading) {
|
||||||
|
uni.showLoading({ title: '处理中...', mask: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (needLogin && url !== '/user/login' && !checkLogin()) {
|
||||||
|
const currentPage = getCurrentPageInfo()
|
||||||
|
console.log(`🔴 未登录检测 - 页面: ${currentPage.route}, 接口: ${url}`)
|
||||||
|
if (retryCount === 0) {
|
||||||
|
try {
|
||||||
|
console.log(`🟡 准备调用 refreshToken - 页面: ${currentPage.route}`)
|
||||||
|
await refreshToken()
|
||||||
|
console.log(`🟢 refreshToken 成功,重试请求 - 页面: ${currentPage.route}`)
|
||||||
|
return await executeRequest(options, retryCount + 1)
|
||||||
|
} catch (err) {
|
||||||
|
console.log(`🔴 refreshToken 失败: ${err.message} - 页面: ${currentPage.route}`)
|
||||||
|
uni.showToast({ title: '请先登录', icon: 'none' })
|
||||||
|
redirectToLogin()
|
||||||
|
return Promise.reject(new Error('请先登录'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
uni.showToast({ title: '请先登录', icon: 'none' })
|
||||||
|
redirectToLogin()
|
||||||
|
return Promise.reject(new Error('请先登录'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = getToken()
|
||||||
|
console.log(`🔵 [POST] 请求接口:${url},参数:`, data)
|
||||||
|
|
||||||
|
let requestData = {}
|
||||||
|
if (encrypt) {
|
||||||
|
requestData.data = await AES.encrypt(data)
|
||||||
|
} else {
|
||||||
|
requestData = data
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await new Promise((resolve, reject) => {
|
||||||
|
const requestTask = uni.request({
|
||||||
|
url: config.BASE_URL + url,
|
||||||
|
method: 'POST',
|
||||||
|
header: { token: token || '', 'Content-Type': 'application/json' },
|
||||||
|
data: requestData,
|
||||||
|
timeout: config.TIMEOUT,
|
||||||
|
success: (res) => resolve(res),
|
||||||
|
fail: (err) => reject(new Error(err.errMsg?.includes('timeout') ? '请求超时' : '网络异常'))
|
||||||
|
})
|
||||||
|
setTimeout(() => requestTask.abort(), config.TIMEOUT)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.statusCode !== 200) {
|
||||||
|
console.log(`🔵 [POST] 请求接口:${url},Error:${response.statusCode}`)
|
||||||
|
throw new Error(`请求出错啦 o(╥﹏╥)o`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const encryptRes = response.data.data || ''
|
||||||
|
let result = {}
|
||||||
|
if (encrypt && encryptRes) {
|
||||||
|
const decryptStr = await AES.decrypt(encryptRes)
|
||||||
|
result = typeof decryptStr === 'string' ? JSON.parse(decryptStr) : decryptStr
|
||||||
|
} else {
|
||||||
|
result = response.data
|
||||||
|
}
|
||||||
|
console.log(`🟢 [POST] 请求接口:${url} 响应结果:`, result)
|
||||||
|
|
||||||
|
if (result.code === config.CODE.SUCCESS) {
|
||||||
|
return result.data ?? true
|
||||||
|
} else if (result.code === config.CODE.NEED_LOGIN ||
|
||||||
|
result.code === config.CODE.TOKEN_EXPIRED ||
|
||||||
|
result.code === config.CODE.TOKEN_INVALID) {
|
||||||
|
if (retryCount < config.MAX_RETRY) {
|
||||||
|
console.log(`🔄 Token失效,尝试刷新(第${retryCount + 1}次)`)
|
||||||
|
try {
|
||||||
|
await refreshToken()
|
||||||
|
return await executeRequest(options, retryCount + 1)
|
||||||
|
} catch (err) {
|
||||||
|
uni.showToast({ title: '登录已过期', icon: 'none' })
|
||||||
|
removeToken()
|
||||||
|
clearCache()
|
||||||
|
setTimeout(() => redirectToLogin(), 1500)
|
||||||
|
return Promise.reject(new Error('登录已过期'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
uni.showToast({ title: '登录已过期', icon: 'none' })
|
||||||
|
removeToken()
|
||||||
|
clearCache()
|
||||||
|
setTimeout(() => redirectToLogin(), 1500)
|
||||||
|
return Promise.reject(new Error('登录已过期'))
|
||||||
|
} else {
|
||||||
|
throw new Error(result.msg || '请求失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`🔴 [POST] 请求异常:`, err)
|
||||||
|
if (err.message.includes('网络异常')) {
|
||||||
|
uni.showToast({ title: '网络连接异常,请检查网络', icon: 'none', duration: 2000 })
|
||||||
|
} else if (err.message.includes('请求超时')) {
|
||||||
|
uni.showToast({ title: '请求超时,请稍后重试', icon: 'none', duration: 2000 })
|
||||||
|
} else {
|
||||||
|
uni.showToast({ title: err.message || '系统异常', icon: 'none', duration: 2000 })
|
||||||
|
}
|
||||||
|
return Promise.reject(err)
|
||||||
|
} finally {
|
||||||
|
if (showLoading) uni.hideLoading()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = async (options, retryCount = 0) => {
|
||||||
|
const { cache } = options
|
||||||
|
|
||||||
|
if (cache === true && retryCount === 0) {
|
||||||
|
const cacheKey = getCacheKey(options.url, options.data)
|
||||||
|
return cachedRequest(cacheKey, () => executeRequest(options, retryCount), options.cacheTTL)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cache === 'bypass' && retryCount === 0) {
|
||||||
|
const cacheKey = getCacheKey(options.url, options.data)
|
||||||
|
clearCache(cacheKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
return executeRequest(options, retryCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
function redirectToLogin() {
|
||||||
|
try {
|
||||||
|
const pages = getCurrentPages()
|
||||||
|
const currentPage = pages[pages.length - 1]
|
||||||
|
if (currentPage?.route !== 'pages/login/login') {
|
||||||
|
uni.redirectTo({ url: config.LOGIN_PAGE })
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
uni.reLaunch({ url: config.LOGIN_PAGE })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
request.post = function (url, data = {}, options = {}) {
|
||||||
|
return this({ url, data, ...options })
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getRequestConfig = () => ({
|
||||||
|
domain: config.DOMAIN,
|
||||||
|
apiPrefix: config.API_PREFIX,
|
||||||
|
baseUrl: config.BASE_URL,
|
||||||
|
envName: config.ENV_NAME
|
||||||
|
})
|
||||||
|
|
||||||
|
export { clearCache }
|
||||||
|
|
||||||
|
export default request
|
||||||
@@ -0,0 +1,524 @@
|
|||||||
|
import { getToken } from '@/utils/auth'
|
||||||
|
import { getUserConfig, editUserConfig } from '@/api'
|
||||||
|
import { forceApplyTabBarTheme } from '@/utils/themeGlobal'
|
||||||
|
|
||||||
|
// 全新主题预设 - 温暖生活风
|
||||||
|
// 设计特点:燕麦底色、低饱和、手作感、留白充足、去AI化
|
||||||
|
export const THEME_PRESETS = {
|
||||||
|
// 暖陶色 - 默认主主题(温暖、自然、生活感)
|
||||||
|
default: {
|
||||||
|
name: '暖陶',
|
||||||
|
primaryColor: '#C68E3F',
|
||||||
|
secondaryColor: '#4A9E7F',
|
||||||
|
accentColor: '#B85C3A',
|
||||||
|
dangerColor: '#C75B4A',
|
||||||
|
warningColor: '#D4A24A',
|
||||||
|
successColor: '#5B9E6E',
|
||||||
|
successColorRGB: '91, 158, 110',
|
||||||
|
infoColor: '#5B8DB8',
|
||||||
|
pendingColor: '#F5EDE4',
|
||||||
|
bgColor: '#FAF6F0',
|
||||||
|
bgGradientStart: '#F7F3ED',
|
||||||
|
bgGradientEnd: '#FAF6F0',
|
||||||
|
cardBgColor: '#FFFCF8',
|
||||||
|
cardBgBlur: 'rgba(255, 252, 248, 0.92)',
|
||||||
|
textPrimary: '#3D3630',
|
||||||
|
textSecondary: '#7A7066',
|
||||||
|
textTertiary: '#9A9088',
|
||||||
|
textDisabled: '#B0A89E',
|
||||||
|
textLight: '#EDE8E1',
|
||||||
|
borderColor: '#E8E2DA',
|
||||||
|
borderLight: '#F2EDE6',
|
||||||
|
inputBgColor: '#F7F3ED',
|
||||||
|
shadowColor: 'rgba(198, 142, 63, 0.18)',
|
||||||
|
shadowLight: 'rgba(61, 54, 48, 0.04)',
|
||||||
|
shadowMedium: 'rgba(61, 54, 48, 0.08)',
|
||||||
|
shadowStrong: 'rgba(61, 54, 48, 0.12)',
|
||||||
|
gradientStart: '#B85C3A',
|
||||||
|
gradientEnd: '#C68E3F',
|
||||||
|
gradientSoft: 'linear-gradient(135deg, #B85C3A 0%, #C68E3F 100%)',
|
||||||
|
gradientVibrant: 'linear-gradient(135deg, #A04A2E 0%, #B85C3A 50%, #C68E3F 100%)',
|
||||||
|
priorityHigh: '#C75B4A',
|
||||||
|
priorityMiddle: '#D4A24A',
|
||||||
|
priorityLow: '#5B9E6E',
|
||||||
|
checkboxColor: '#FFFFFF',
|
||||||
|
moodHappy: '#D4A24A',
|
||||||
|
moodCalm: '#5B9E6E',
|
||||||
|
moodSad: '#7B9EB8',
|
||||||
|
moodAngry: '#C75B4A',
|
||||||
|
moodNeutral: '#B0A89E',
|
||||||
|
incomeColor: '#5B9E6E',
|
||||||
|
expendColor: '#C75B4A'
|
||||||
|
},
|
||||||
|
|
||||||
|
// 青苔 - 自然清新
|
||||||
|
mint: {
|
||||||
|
name: '青苔',
|
||||||
|
primaryColor: '#4A9E7F',
|
||||||
|
secondaryColor: '#6DB89A',
|
||||||
|
accentColor: '#3D8568',
|
||||||
|
dangerColor: '#C75B4A',
|
||||||
|
warningColor: '#D4A24A',
|
||||||
|
successColor: '#5B9E6E',
|
||||||
|
infoColor: '#5B8DB8',
|
||||||
|
pendingColor: '#EEF5F1',
|
||||||
|
bgColor: '#F5F8F3',
|
||||||
|
bgGradientStart: '#F0F4ED',
|
||||||
|
bgGradientEnd: '#F5F8F3',
|
||||||
|
cardBgColor: '#FAFCF8',
|
||||||
|
cardBgBlur: 'rgba(250, 252, 248, 0.92)',
|
||||||
|
textPrimary: '#2D3A30',
|
||||||
|
textSecondary: '#6B7D6E',
|
||||||
|
textTertiary: '#8B9A8E',
|
||||||
|
textDisabled: '#A3B0A6',
|
||||||
|
textLight: '#E5EBE7',
|
||||||
|
borderColor: '#D8E2DA',
|
||||||
|
borderLight: '#E8EDE9',
|
||||||
|
inputBgColor: '#EEF5F1',
|
||||||
|
shadowColor: 'rgba(74, 158, 127, 0.1)',
|
||||||
|
shadowLight: 'rgba(45, 58, 48, 0.04)',
|
||||||
|
shadowMedium: 'rgba(45, 58, 48, 0.07)',
|
||||||
|
shadowStrong: 'rgba(45, 58, 48, 0.11)',
|
||||||
|
gradientStart: '#3D8568',
|
||||||
|
gradientEnd: '#4A9E7F',
|
||||||
|
gradientSoft: 'linear-gradient(135deg, #3D8568 0%, #4A9E7F 100%)',
|
||||||
|
gradientVibrant: 'linear-gradient(135deg, #2D6B53 0%, #3D8568 50%, #4A9E7F 100%)',
|
||||||
|
priorityHigh: '#C75B4A',
|
||||||
|
priorityMiddle: '#D4A24A',
|
||||||
|
priorityLow: '#5B9E6E',
|
||||||
|
checkboxColor: '#FFFFFF',
|
||||||
|
moodHappy: '#D4A24A',
|
||||||
|
moodCalm: '#4A9E7F',
|
||||||
|
moodSad: '#7B9EB8',
|
||||||
|
moodAngry: '#C75B4A',
|
||||||
|
moodNeutral: '#A3B0A6',
|
||||||
|
incomeColor: '#5B9E6E',
|
||||||
|
expendColor: '#C75B4A'
|
||||||
|
},
|
||||||
|
|
||||||
|
// 藕粉 - 柔和温婉
|
||||||
|
cherry: {
|
||||||
|
name: '藕粉',
|
||||||
|
primaryColor: '#C4908A',
|
||||||
|
secondaryColor: '#D5A9A4',
|
||||||
|
accentColor: '#B07872',
|
||||||
|
dangerColor: '#C75B4A',
|
||||||
|
warningColor: '#D4A24A',
|
||||||
|
successColor: '#5B9E6E',
|
||||||
|
infoColor: '#5B8DB8',
|
||||||
|
pendingColor: '#F8F0EF',
|
||||||
|
bgColor: '#FAF5F3',
|
||||||
|
bgGradientStart: '#F6F0EE',
|
||||||
|
bgGradientEnd: '#FAF5F3',
|
||||||
|
cardBgColor: '#FDF9F8',
|
||||||
|
cardBgBlur: 'rgba(253, 249, 248, 0.92)',
|
||||||
|
textPrimary: '#3D3332',
|
||||||
|
textSecondary: '#7A6C6A',
|
||||||
|
textTertiary: '#9A8C8A',
|
||||||
|
textDisabled: '#B0A5A3',
|
||||||
|
textLight: '#EDE5E3',
|
||||||
|
borderColor: '#E5DAD7',
|
||||||
|
borderLight: '#F0E8E5',
|
||||||
|
inputBgColor: '#F8F0EF',
|
||||||
|
shadowColor: 'rgba(196, 144, 138, 0.1)',
|
||||||
|
shadowLight: 'rgba(61, 51, 50, 0.04)',
|
||||||
|
shadowMedium: 'rgba(61, 51, 50, 0.07)',
|
||||||
|
shadowStrong: 'rgba(61, 51, 50, 0.11)',
|
||||||
|
gradientStart: '#B07872',
|
||||||
|
gradientEnd: '#C4908A',
|
||||||
|
gradientSoft: 'linear-gradient(135deg, #B07872 0%, #C4908A 100%)',
|
||||||
|
gradientVibrant: 'linear-gradient(135deg, #9A5E5A 0%, #B07872 50%, #C4908A 100%)',
|
||||||
|
priorityHigh: '#C75B4A',
|
||||||
|
priorityMiddle: '#D4A24A',
|
||||||
|
priorityLow: '#5B9E6E',
|
||||||
|
checkboxColor: '#FFFFFF',
|
||||||
|
moodHappy: '#D4A24A',
|
||||||
|
moodCalm: '#5B9E6E',
|
||||||
|
moodSad: '#7B9EB8',
|
||||||
|
moodAngry: '#C75B4A',
|
||||||
|
moodNeutral: '#B0A5A3',
|
||||||
|
incomeColor: '#5B9E6E',
|
||||||
|
expendColor: '#C75B4A'
|
||||||
|
},
|
||||||
|
|
||||||
|
// 暮蓝 - 沉静夜色
|
||||||
|
sky: {
|
||||||
|
name: '暮蓝',
|
||||||
|
primaryColor: '#5B8DB8',
|
||||||
|
secondaryColor: '#7BA7C6',
|
||||||
|
accentColor: '#4978A0',
|
||||||
|
dangerColor: '#C75B4A',
|
||||||
|
warningColor: '#D4A24A',
|
||||||
|
successColor: '#5B9E6E',
|
||||||
|
infoColor: '#5B8DB8',
|
||||||
|
pendingColor: '#EEF2F6',
|
||||||
|
bgColor: '#F3F5F8',
|
||||||
|
bgGradientStart: '#EEF1F5',
|
||||||
|
bgGradientEnd: '#F3F5F8',
|
||||||
|
cardBgColor: '#F8FAFB',
|
||||||
|
cardBgBlur: 'rgba(248, 250, 251, 0.92)',
|
||||||
|
textPrimary: '#2D343D',
|
||||||
|
textSecondary: '#6B7580',
|
||||||
|
textTertiary: '#8B95A0',
|
||||||
|
textDisabled: '#A3ABB5',
|
||||||
|
textLight: '#E5E9ED',
|
||||||
|
borderColor: '#D8DEE4',
|
||||||
|
borderLight: '#E8ECF0',
|
||||||
|
inputBgColor: '#EEF2F6',
|
||||||
|
shadowColor: 'rgba(91, 141, 184, 0.1)',
|
||||||
|
shadowLight: 'rgba(45, 52, 61, 0.04)',
|
||||||
|
shadowMedium: 'rgba(45, 52, 61, 0.07)',
|
||||||
|
shadowStrong: 'rgba(45, 52, 61, 0.11)',
|
||||||
|
gradientStart: '#4978A0',
|
||||||
|
gradientEnd: '#5B8DB8',
|
||||||
|
gradientSoft: 'linear-gradient(135deg, #4978A0 0%, #5B8DB8 100%)',
|
||||||
|
gradientVibrant: 'linear-gradient(135deg, #355E80 0%, #4978A0 50%, #5B8DB8 100%)',
|
||||||
|
priorityHigh: '#C75B4A',
|
||||||
|
priorityMiddle: '#D4A24A',
|
||||||
|
priorityLow: '#5B9E6E',
|
||||||
|
checkboxColor: '#FFFFFF',
|
||||||
|
moodHappy: '#D4A24A',
|
||||||
|
moodCalm: '#5B9E6E',
|
||||||
|
moodSad: '#5B8DB8',
|
||||||
|
moodAngry: '#C75B4A',
|
||||||
|
moodNeutral: '#A3ABB5',
|
||||||
|
incomeColor: '#5B9E6E',
|
||||||
|
expendColor: '#C75B4A'
|
||||||
|
},
|
||||||
|
|
||||||
|
// 深夜模式 - 暗色温暖
|
||||||
|
dark: {
|
||||||
|
name: '深夜',
|
||||||
|
primaryColor: '#D4AA60',
|
||||||
|
secondaryColor: '#7DB89A',
|
||||||
|
accentColor: '#C68E3F',
|
||||||
|
dangerColor: '#D47060',
|
||||||
|
warningColor: '#D4AA60',
|
||||||
|
successColor: '#6DB89A',
|
||||||
|
infoColor: '#7BA7C6',
|
||||||
|
pendingColor: '#2A2824',
|
||||||
|
bgColor: '#1E1D1A',
|
||||||
|
bgGradientStart: '#252420',
|
||||||
|
bgGradientEnd: '#1E1D1A',
|
||||||
|
cardBgColor: '#2A2824',
|
||||||
|
cardBgBlur: 'rgba(42, 40, 36, 0.92)',
|
||||||
|
textPrimary: '#E8E2DA',
|
||||||
|
textSecondary: '#9A9088',
|
||||||
|
textTertiary: '#807870',
|
||||||
|
textDisabled: '#6B6360',
|
||||||
|
textLight: '#2A2824',
|
||||||
|
borderColor: '#3A3632',
|
||||||
|
borderLight: '#302E2A',
|
||||||
|
inputBgColor: '#252420',
|
||||||
|
shadowColor: 'rgba(212, 170, 96, 0.15)',
|
||||||
|
shadowLight: 'rgba(0, 0, 0, 0.3)',
|
||||||
|
shadowMedium: 'rgba(0, 0, 0, 0.4)',
|
||||||
|
shadowStrong: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
gradientStart: '#D4AA60',
|
||||||
|
gradientEnd: '#C68E3F',
|
||||||
|
gradientSoft: 'linear-gradient(135deg, #D4AA60 0%, #C68E3F 100%)',
|
||||||
|
gradientVibrant: 'linear-gradient(135deg, #C68E3F 0%, #D4AA60 50%, #E0C080 100%)',
|
||||||
|
priorityHigh: '#D47060',
|
||||||
|
priorityMiddle: '#D4AA60',
|
||||||
|
priorityLow: '#6DB89A',
|
||||||
|
checkboxColor: '#2A2824',
|
||||||
|
moodHappy: '#D4AA60',
|
||||||
|
moodCalm: '#6DB89A',
|
||||||
|
moodSad: '#7BA7C6',
|
||||||
|
moodAngry: '#D47060',
|
||||||
|
moodNeutral: '#9A9088',
|
||||||
|
incomeColor: '#6DB89A',
|
||||||
|
expendColor: '#D47060'
|
||||||
|
},
|
||||||
|
|
||||||
|
// 薰衣 - 梦幻紫韵
|
||||||
|
cream: {
|
||||||
|
name: '薰衣',
|
||||||
|
primaryColor: '#957BB7',
|
||||||
|
secondaryColor: '#B099D4',
|
||||||
|
accentColor: '#7A60A0',
|
||||||
|
dangerColor: '#C87888',
|
||||||
|
warningColor: '#D4A060',
|
||||||
|
successColor: '#7AB884',
|
||||||
|
infoColor: '#9A8AC8',
|
||||||
|
pendingColor: '#F3EFF8',
|
||||||
|
bgColor: '#F6F3FA',
|
||||||
|
bgGradientStart: '#F0ECF6',
|
||||||
|
bgGradientEnd: '#F6F3FA',
|
||||||
|
cardBgColor: '#FBFAFD',
|
||||||
|
cardBgBlur: 'rgba(251, 250, 253, 0.92)',
|
||||||
|
textPrimary: '#2E2240',
|
||||||
|
textSecondary: '#6B5B7A',
|
||||||
|
textTertiary: '#8B7B9A',
|
||||||
|
textDisabled: '#B8AEC8',
|
||||||
|
textLight: '#E8E0F0',
|
||||||
|
borderColor: '#DDD4E8',
|
||||||
|
borderLight: '#EBE4F0',
|
||||||
|
inputBgColor: '#F3EFF8',
|
||||||
|
shadowColor: 'rgba(149, 123, 183, 0.1)',
|
||||||
|
shadowLight: 'rgba(46, 34, 64, 0.04)',
|
||||||
|
shadowMedium: 'rgba(46, 34, 64, 0.07)',
|
||||||
|
shadowStrong: 'rgba(46, 34, 64, 0.11)',
|
||||||
|
gradientStart: '#7A60A0',
|
||||||
|
gradientEnd: '#957BB7',
|
||||||
|
gradientSoft: 'linear-gradient(135deg, #7A60A0 0%, #957BB7 100%)',
|
||||||
|
gradientVibrant: 'linear-gradient(135deg, #5A4580 0%, #7A60A0 50%, #957BB7 100%)',
|
||||||
|
priorityHigh: '#C87888',
|
||||||
|
priorityMiddle: '#D4A060',
|
||||||
|
priorityLow: '#7AB884',
|
||||||
|
checkboxColor: '#FFFFFF',
|
||||||
|
moodHappy: '#D4A060',
|
||||||
|
moodCalm: '#7AB884',
|
||||||
|
moodSad: '#957BB7',
|
||||||
|
moodAngry: '#C87888',
|
||||||
|
moodNeutral: '#B8AEC8',
|
||||||
|
incomeColor: '#7AB884',
|
||||||
|
expendColor: '#C87888'
|
||||||
|
},
|
||||||
|
|
||||||
|
// 柑橘 - 活力明亮
|
||||||
|
ocean: {
|
||||||
|
name: '柑橘',
|
||||||
|
primaryColor: '#D4824A',
|
||||||
|
secondaryColor: '#E09A6A',
|
||||||
|
accentColor: '#C07040',
|
||||||
|
dangerColor: '#C86060',
|
||||||
|
warningColor: '#C8A040',
|
||||||
|
successColor: '#6AA87A',
|
||||||
|
infoColor: '#B89070',
|
||||||
|
pendingColor: '#FDF0E8',
|
||||||
|
bgColor: '#FDF6F0',
|
||||||
|
bgGradientStart: '#F8F0E8',
|
||||||
|
bgGradientEnd: '#FDF6F0',
|
||||||
|
cardBgColor: '#FEFCFA',
|
||||||
|
cardBgBlur: 'rgba(254, 252, 250, 0.92)',
|
||||||
|
textPrimary: '#3D281A',
|
||||||
|
textSecondary: '#7A5E48',
|
||||||
|
textTertiary: '#9A7E68',
|
||||||
|
textDisabled: '#C8B8A8',
|
||||||
|
textLight: '#F0E4D8',
|
||||||
|
borderColor: '#E8D8CC',
|
||||||
|
borderLight: '#F2E8E0',
|
||||||
|
inputBgColor: '#FDF0E8',
|
||||||
|
shadowColor: 'rgba(212, 130, 74, 0.1)',
|
||||||
|
shadowLight: 'rgba(61, 40, 26, 0.04)',
|
||||||
|
shadowMedium: 'rgba(61, 40, 26, 0.07)',
|
||||||
|
shadowStrong: 'rgba(61, 40, 26, 0.11)',
|
||||||
|
gradientStart: '#C07040',
|
||||||
|
gradientEnd: '#D4824A',
|
||||||
|
gradientSoft: 'linear-gradient(135deg, #C07040 0%, #D4824A 100%)',
|
||||||
|
gradientVibrant: 'linear-gradient(135deg, #9A5530 0%, #C07040 50%, #D4824A 100%)',
|
||||||
|
priorityHigh: '#C86060',
|
||||||
|
priorityMiddle: '#C8A040',
|
||||||
|
priorityLow: '#6AA87A',
|
||||||
|
checkboxColor: '#FFFFFF',
|
||||||
|
moodHappy: '#D4824A',
|
||||||
|
moodCalm: '#6AA87A',
|
||||||
|
moodSad: '#7B9EB8',
|
||||||
|
moodAngry: '#C86060',
|
||||||
|
moodNeutral: '#C8B8A8',
|
||||||
|
incomeColor: '#6AA87A',
|
||||||
|
expendColor: '#C86060'
|
||||||
|
},
|
||||||
|
|
||||||
|
// 薄荷 - 清凉冰爽
|
||||||
|
matcha: {
|
||||||
|
name: '薄荷',
|
||||||
|
primaryColor: '#5BA8A0',
|
||||||
|
secondaryColor: '#7BC4BC',
|
||||||
|
accentColor: '#4A968E',
|
||||||
|
dangerColor: '#C07078',
|
||||||
|
warningColor: '#C8A860',
|
||||||
|
successColor: '#5A9B6A',
|
||||||
|
infoColor: '#7AB8B0',
|
||||||
|
pendingColor: '#E8F4F2',
|
||||||
|
bgColor: '#F0F7F6',
|
||||||
|
bgGradientStart: '#E8F2F0',
|
||||||
|
bgGradientEnd: '#F0F7F6',
|
||||||
|
cardBgColor: '#F8FBFB',
|
||||||
|
cardBgBlur: 'rgba(248, 251, 251, 0.92)',
|
||||||
|
textPrimary: '#1A3330',
|
||||||
|
textSecondary: '#4A7A74',
|
||||||
|
textTertiary: '#6A9A94',
|
||||||
|
textDisabled: '#A0C8C0',
|
||||||
|
textLight: '#D8EBE8',
|
||||||
|
borderColor: '#C8DDD8',
|
||||||
|
borderLight: '#E0EBE8',
|
||||||
|
inputBgColor: '#E8F4F2',
|
||||||
|
shadowColor: 'rgba(91, 168, 160, 0.1)',
|
||||||
|
shadowLight: 'rgba(26, 51, 48, 0.04)',
|
||||||
|
shadowMedium: 'rgba(26, 51, 48, 0.07)',
|
||||||
|
shadowStrong: 'rgba(26, 51, 48, 0.11)',
|
||||||
|
gradientStart: '#4A968E',
|
||||||
|
gradientEnd: '#5BA8A0',
|
||||||
|
gradientSoft: 'linear-gradient(135deg, #4A968E 0%, #5BA8A0 100%)',
|
||||||
|
gradientVibrant: 'linear-gradient(135deg, #357A72 0%, #4A968E 50%, #5BA8A0 100%)',
|
||||||
|
priorityHigh: '#C07078',
|
||||||
|
priorityMiddle: '#C8A860',
|
||||||
|
priorityLow: '#5A9B6A',
|
||||||
|
checkboxColor: '#FFFFFF',
|
||||||
|
moodHappy: '#D4A24A',
|
||||||
|
moodCalm: '#5BA8A0',
|
||||||
|
moodSad: '#7B9EB8',
|
||||||
|
moodAngry: '#C07078',
|
||||||
|
moodNeutral: '#A0C8C0',
|
||||||
|
incomeColor: '#5A9B6A',
|
||||||
|
expendColor: '#C07078'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 统一本地存储key(核心存储完整配置,避免冗余)
|
||||||
|
export const THEME_STORAGE_KEY = 'user_current_theme'
|
||||||
|
// 默认主题key
|
||||||
|
export const DEFAULT_THEME_KEY = 'default'
|
||||||
|
// 全局主题变更事件名(标准化)
|
||||||
|
export const THEME_CHANGE_EVENT = 'themeChanged'
|
||||||
|
|
||||||
|
// ===================== 本地存储方法(统一逻辑 + 强容错) =====================
|
||||||
|
export const saveThemeConfigToStorage = (themeConfig) => {
|
||||||
|
try {
|
||||||
|
const pureConfig = JSON.parse(JSON.stringify(themeConfig))
|
||||||
|
uni.setStorageSync(THEME_STORAGE_KEY, JSON.stringify(pureConfig))
|
||||||
|
} catch (e) {
|
||||||
|
console.error('【主题】保存到本地失败:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getThemeConfigFromStorage = () => {
|
||||||
|
try {
|
||||||
|
const themeStr = uni.getStorageSync(THEME_STORAGE_KEY)
|
||||||
|
if (!themeStr) return THEME_PRESETS[DEFAULT_THEME_KEY]
|
||||||
|
|
||||||
|
const config = JSON.parse(themeStr)
|
||||||
|
const requiredFields = ['primaryColor', 'bgColor', 'textPrimary']
|
||||||
|
const isValid = requiredFields.every(field => config[field])
|
||||||
|
return isValid ? config : THEME_PRESETS[DEFAULT_THEME_KEY]
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('【主题】读取本地配置失败,使用默认:', e)
|
||||||
|
return THEME_PRESETS[DEFAULT_THEME_KEY]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const saveThemeKeyToStorage = (themeKey) => {
|
||||||
|
const validKey = THEME_PRESETS[themeKey] ? themeKey : DEFAULT_THEME_KEY
|
||||||
|
uni.setStorageSync('selectedTheme', validKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getThemeKeyFromStorage = () => {
|
||||||
|
const themeKey = uni.getStorageSync('selectedTheme')
|
||||||
|
return THEME_PRESETS[themeKey] ? themeKey : DEFAULT_THEME_KEY
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== 后端接口相关(异步容错 + 无token降级) =====================
|
||||||
|
export const getThemeFromServer = async () => {
|
||||||
|
if (!getToken()) {
|
||||||
|
console.log('【主题】未登录,使用本地主题key')
|
||||||
|
return getThemeKeyFromStorage()
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await getUserConfig()
|
||||||
|
const serverThemeKey = res?.theme
|
||||||
|
console.log('【主题】从后端获取到key:', serverThemeKey)
|
||||||
|
|
||||||
|
const validKey = THEME_PRESETS[serverThemeKey] ? serverThemeKey : DEFAULT_THEME_KEY
|
||||||
|
saveThemeKeyToStorage(validKey)
|
||||||
|
saveThemeConfigToStorage(THEME_PRESETS[validKey])
|
||||||
|
|
||||||
|
return validKey
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('【主题】从后端获取失败,使用本地缓存:', error)
|
||||||
|
return getThemeKeyFromStorage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const saveThemeToServer = async (themeKey) => {
|
||||||
|
if (!getToken() || !THEME_PRESETS[themeKey]) return false
|
||||||
|
|
||||||
|
try {
|
||||||
|
await editUserConfig({ theme: themeKey })
|
||||||
|
console.log(`【主题】同步到后端成功:${themeKey}`)
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`【主题】同步到后端失败:${themeKey}`, error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== 全局主题注入(核心:保证全项目数据一致) =====================
|
||||||
|
export const injectTheme = (themeKey) => {
|
||||||
|
const validKey = THEME_PRESETS[themeKey] ? themeKey : DEFAULT_THEME_KEY
|
||||||
|
const themeConfig = THEME_PRESETS[validKey]
|
||||||
|
console.log('【主题】注入全局配置:', validKey, themeConfig)
|
||||||
|
|
||||||
|
const appInstance = getApp({ allowDefault: true })
|
||||||
|
if (appInstance) {
|
||||||
|
appInstance.globalData = appInstance.globalData || {}
|
||||||
|
appInstance.globalData.themeConfig = JSON.parse(JSON.stringify(themeConfig))
|
||||||
|
appInstance.globalData.themeKey = validKey
|
||||||
|
}
|
||||||
|
|
||||||
|
saveThemeKeyToStorage(validKey)
|
||||||
|
saveThemeConfigToStorage(themeConfig)
|
||||||
|
|
||||||
|
uni.$emit(THEME_CHANGE_EVENT, {
|
||||||
|
key: validKey,
|
||||||
|
config: themeConfig
|
||||||
|
})
|
||||||
|
|
||||||
|
Promise.resolve().then(() => {
|
||||||
|
forceApplyTabBarTheme(themeConfig)
|
||||||
|
setTimeout(() => {
|
||||||
|
forceApplyTabBarTheme(themeConfig)
|
||||||
|
}, 100)
|
||||||
|
setTimeout(() => {
|
||||||
|
forceApplyTabBarTheme(themeConfig)
|
||||||
|
}, 300)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== 对外暴露的核心方法(一站式调用) =====================
|
||||||
|
export const switchTheme = async (themeKey, showToast = true) => {
|
||||||
|
const validKey = THEME_PRESETS[themeKey] ? themeKey : DEFAULT_THEME_KEY
|
||||||
|
|
||||||
|
try {
|
||||||
|
const serverPromise = saveThemeToServer(validKey)
|
||||||
|
injectTheme(validKey)
|
||||||
|
|
||||||
|
if (showToast) {
|
||||||
|
const serverSuccess = await serverPromise
|
||||||
|
uni.showToast({
|
||||||
|
title: serverSuccess
|
||||||
|
? `${THEME_PRESETS[validKey].name}切换成功`
|
||||||
|
: `${THEME_PRESETS[validKey].name}已生效,云端同步失败`,
|
||||||
|
icon: 'none',
|
||||||
|
duration: 1500
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await serverPromise
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
injectTheme(validKey)
|
||||||
|
if (showToast) {
|
||||||
|
uni.showToast({
|
||||||
|
title: `${THEME_PRESETS[validKey].name}已生效,云端同步失败`,
|
||||||
|
icon: 'none',
|
||||||
|
duration: 2000
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const initTheme = async () => {
|
||||||
|
try {
|
||||||
|
const themeKey = await getThemeFromServer()
|
||||||
|
injectTheme(themeKey)
|
||||||
|
return JSON.parse(JSON.stringify(THEME_PRESETS[themeKey]))
|
||||||
|
} catch (error) {
|
||||||
|
console.error('【主题】初始化失败,使用默认配置:', error)
|
||||||
|
injectTheme(DEFAULT_THEME_KEY)
|
||||||
|
return JSON.parse(JSON.stringify(THEME_PRESETS[DEFAULT_THEME_KEY]))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
// utils/themeGlobal.js
|
||||||
|
// 全局主题适配器 - 处理导航栏、TabBar 等全局UI主题
|
||||||
|
import { THEME_PRESETS, getThemeConfigFromStorage, THEME_CHANGE_EVENT } from './theme'
|
||||||
|
|
||||||
|
let isInitializing = false
|
||||||
|
let currentThemeConfig = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用全局主题到导航栏和TabBar
|
||||||
|
* @param {Object} themeConfig 主题配置对象
|
||||||
|
*/
|
||||||
|
export const applyGlobalTheme = (themeConfig) => {
|
||||||
|
if (!themeConfig || isInitializing) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const app = getApp({ allowDefault: true })
|
||||||
|
if (app) {
|
||||||
|
app.globalData = app.globalData || {}
|
||||||
|
app.globalData.themeConfig = themeConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
currentThemeConfig = themeConfig
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
applyNavigationBarTheme(themeConfig)
|
||||||
|
applyTabBarThemeSafe(themeConfig)
|
||||||
|
}, 100)
|
||||||
|
|
||||||
|
console.log('【全局主题】已应用到导航栏和TabBar')
|
||||||
|
} catch (e) {
|
||||||
|
console.error('【全局主题】应用失败:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安全应用TabBar主题(先检查是否在TabBar页面)
|
||||||
|
* @param {Object} themeConfig 主题配置
|
||||||
|
*/
|
||||||
|
const applyTabBarThemeSafe = (themeConfig) => {
|
||||||
|
try {
|
||||||
|
const pages = getCurrentPages()
|
||||||
|
if (pages.length === 0) {
|
||||||
|
console.warn('【全局主题】当前无页面,跳过TabBar设置')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentPage = pages[pages.length - 1]
|
||||||
|
const currentRoute = currentPage.route || ''
|
||||||
|
|
||||||
|
const tabBarPages = ['pages/index/index', 'pages/todo/todo', 'pages/bill/bill', 'pages/mine/mine']
|
||||||
|
|
||||||
|
if (tabBarPages.includes(currentRoute)) {
|
||||||
|
applyTabBarTheme(themeConfig)
|
||||||
|
} else {
|
||||||
|
console.log('【全局主题】当前非TabBar页面,跳过TabBar设置')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('【全局主题】TabBar安全检测失败:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 强制应用TabBar主题(先检查当前页面是否是TabBar页面)
|
||||||
|
* @param {Object} themeConfig 主题配置
|
||||||
|
*/
|
||||||
|
export const forceApplyTabBarTheme = (themeConfig) => {
|
||||||
|
try {
|
||||||
|
const pages = getCurrentPages()
|
||||||
|
if (pages.length === 0) {
|
||||||
|
console.log('【全局主题】当前无页面,跳过TabBar设置')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentPage = pages[pages.length - 1]
|
||||||
|
const currentRoute = currentPage.route || ''
|
||||||
|
|
||||||
|
const tabBarPages = ['pages/index/index', 'pages/todo/todo', 'pages/bill/bill', 'pages/mine/mine']
|
||||||
|
|
||||||
|
if (!tabBarPages.includes(currentRoute)) {
|
||||||
|
console.log('【全局主题】当前非TabBar页面(' + currentRoute + '),跳过TabBar设置')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
uni.setTabBarStyle({
|
||||||
|
color: validateColor(themeConfig.textSecondary),
|
||||||
|
selectedColor: validateColor(themeConfig.primaryColor),
|
||||||
|
backgroundColor: validateColor(themeConfig.cardBgColor),
|
||||||
|
borderStyle: themeConfig.bgColor === '#1a202c' ? 'black' : 'white',
|
||||||
|
success: () => {
|
||||||
|
console.log('【全局主题】强制刷新TabBar样式成功')
|
||||||
|
},
|
||||||
|
fail: (err) => {
|
||||||
|
console.warn('【全局主题】强制刷新TabBar样式失败:', err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.error('【全局主题】强制刷新TabBar失败:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用导航栏主题
|
||||||
|
* @param {Object} themeConfig 主题配置
|
||||||
|
*/
|
||||||
|
export const applyNavigationBarTheme = (themeConfig) => {
|
||||||
|
try {
|
||||||
|
const bgColor = validateColor(themeConfig.bgColor)
|
||||||
|
const isDark = isDarkColor(bgColor)
|
||||||
|
|
||||||
|
console.log('【全局主题】设置导航栏颜色 - bgColor:', bgColor, 'isDark:', isDark)
|
||||||
|
|
||||||
|
if (isDark) {
|
||||||
|
setNavBarWhite(bgColor)
|
||||||
|
} else {
|
||||||
|
setNavBarBlack(bgColor)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('【全局主题】导航栏主题设置失败:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 简化版本:直接应用导航栏主题(页面调用)
|
||||||
|
*/
|
||||||
|
export const refreshNavBarTheme = () => {
|
||||||
|
try {
|
||||||
|
if (!currentThemeConfig) {
|
||||||
|
currentThemeConfig = getThemeConfigFromStorage() || THEME_PRESETS['default']
|
||||||
|
}
|
||||||
|
applyNavigationBarTheme(currentThemeConfig)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('【全局主题】刷新导航栏失败:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断颜色是否为深色
|
||||||
|
* @param {string} color 颜色值
|
||||||
|
* @returns {boolean} 是否为深色
|
||||||
|
*/
|
||||||
|
const isDarkColor = (color) => {
|
||||||
|
if (!color || color.length < 7) return false
|
||||||
|
|
||||||
|
const hex = color.replace('#', '')
|
||||||
|
const r = parseInt(hex.substr(0, 2), 16)
|
||||||
|
const g = parseInt(hex.substr(2, 2), 16)
|
||||||
|
const b = parseInt(hex.substr(4, 2), 16)
|
||||||
|
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
|
||||||
|
|
||||||
|
return luminance <= 0.5
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置导航栏为白色前景(用于深色背景)
|
||||||
|
*/
|
||||||
|
const setNavBarWhite = (bgColor) => {
|
||||||
|
try {
|
||||||
|
console.log('【全局主题】调用 setNavigationBarColor - frontColor: white, #ffffff:', bgColor)
|
||||||
|
|
||||||
|
uni.setNavigationBarColor({
|
||||||
|
frontColor: '#ffffff',
|
||||||
|
backgroundColor: bgColor,
|
||||||
|
success: () => {
|
||||||
|
console.log('【全局主题】导航栏颜色设置成功(白色前景)')
|
||||||
|
},
|
||||||
|
fail: (err) => {
|
||||||
|
console.warn('【全局主题】导航栏颜色设置失败(白色前景):', err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.error('【全局主题】setNavBarWhite 失败:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置导航栏为黑色前景(用于浅色背景)
|
||||||
|
*/
|
||||||
|
const setNavBarBlack = (bgColor) => {
|
||||||
|
try {
|
||||||
|
console.log('【全局主题】调用 setNavigationBarColor - frontColor: #000000, bgColor:', bgColor)
|
||||||
|
|
||||||
|
uni.setNavigationBarColor({
|
||||||
|
frontColor: '#000000',
|
||||||
|
backgroundColor: bgColor,
|
||||||
|
success: () => {
|
||||||
|
console.log('【全局主题】导航栏颜色设置成功(黑色前景)')
|
||||||
|
},
|
||||||
|
fail: (err) => {
|
||||||
|
console.warn('【全局主题】导航栏颜色设置失败(黑色前景):', err)
|
||||||
|
console.log('【全局主题】尝试只设置背景色')
|
||||||
|
uni.setNavigationBarColor({
|
||||||
|
backgroundColor: bgColor,
|
||||||
|
success: () => {
|
||||||
|
console.log('【全局主题】导航栏背景色设置成功')
|
||||||
|
},
|
||||||
|
fail: (err2) => {
|
||||||
|
console.warn('【全局主题】导航栏背景色设置也失败:', err2)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.error('【全局主题】setNavBarBlack 失败:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证并修复颜色值
|
||||||
|
* @param {string} color 颜色值
|
||||||
|
* @returns {string} 验证后的颜色值
|
||||||
|
*/
|
||||||
|
const validateColor = (color) => {
|
||||||
|
if (!color) return '#ffffff'
|
||||||
|
|
||||||
|
if (!color.startsWith('#')) {
|
||||||
|
return '#' + color
|
||||||
|
}
|
||||||
|
|
||||||
|
if (color.length === 4) {
|
||||||
|
return '#' + color[1] + color[1] + color[2] + color[2] + color[3] + color[3]
|
||||||
|
}
|
||||||
|
|
||||||
|
return color
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用TabBar主题
|
||||||
|
* @param {Object} themeConfig 主题配置
|
||||||
|
*/
|
||||||
|
export const applyTabBarTheme = (themeConfig) => {
|
||||||
|
try {
|
||||||
|
uni.setTabBarStyle({
|
||||||
|
color: validateColor(themeConfig.textSecondary),
|
||||||
|
selectedColor: validateColor(themeConfig.primaryColor),
|
||||||
|
backgroundColor: validateColor(themeConfig.cardBgColor),
|
||||||
|
borderStyle: themeConfig.bgColor === '#1a202c' ? 'black' : 'white',
|
||||||
|
success: () => {
|
||||||
|
console.log('【全局主题】TabBar样式设置成功')
|
||||||
|
},
|
||||||
|
fail: (err) => {
|
||||||
|
console.warn('【全局主题】TabBar样式设置失败:', err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.error('【全局主题】TabBar主题设置失败:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据背景色判断前景色(文字颜色)
|
||||||
|
* @param {string} bgColor 背景色
|
||||||
|
* @returns {string} '#000000' 或 '#ffffff'
|
||||||
|
*/
|
||||||
|
export const getFrontColor = (bgColor) => {
|
||||||
|
return isDarkColor(bgColor) ? '#ffffff' : '#000000'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化全局主题(在App.vue或页面入口调用)
|
||||||
|
*/
|
||||||
|
export const initGlobalTheme = () => {
|
||||||
|
isInitializing = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const themeConfig = getThemeConfigFromStorage() || THEME_PRESETS['default']
|
||||||
|
currentThemeConfig = themeConfig
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
applyGlobalTheme(themeConfig)
|
||||||
|
isInitializing = false
|
||||||
|
}, 300)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('【全局主题】初始化失败:', e)
|
||||||
|
isInitializing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册全局主题变更监听
|
||||||
|
*/
|
||||||
|
export const registerGlobalThemeListener = () => {
|
||||||
|
uni.$on(THEME_CHANGE_EVENT, (themeData) => {
|
||||||
|
try {
|
||||||
|
let newThemeConfig = null
|
||||||
|
|
||||||
|
if (themeData?.config) {
|
||||||
|
newThemeConfig = themeData.config
|
||||||
|
} else if (themeData?.key && THEME_PRESETS[themeData.key]) {
|
||||||
|
newThemeConfig = THEME_PRESETS[themeData.key]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newThemeConfig) {
|
||||||
|
applyGlobalTheme(newThemeConfig)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('【全局主题】监听处理失败:', e)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('【全局主题】已注册主题变更监听')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移除全局主题监听
|
||||||
|
*/
|
||||||
|
export const unregisterGlobalThemeListener = () => {
|
||||||
|
uni.$off(THEME_CHANGE_EVENT)
|
||||||
|
console.log('【全局主题】已移除主题变更监听')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在页面onShow中调用的混入(用于页面自动应用全局主题)
|
||||||
|
*/
|
||||||
|
export const globalThemeMixin = {
|
||||||
|
onShow() {
|
||||||
|
const themeConfig = getThemeConfigFromStorage() || THEME_PRESETS['default']
|
||||||
|
applyNavigationBarTheme(themeConfig)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 页面级导航栏刷新方法(简化调用)
|
||||||
|
*/
|
||||||
|
export const onPageShow = () => {
|
||||||
|
refreshNavBarTheme()
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
applyGlobalTheme,
|
||||||
|
applyNavigationBarTheme,
|
||||||
|
applyTabBarTheme,
|
||||||
|
forceApplyTabBarTheme,
|
||||||
|
initGlobalTheme,
|
||||||
|
registerGlobalThemeListener,
|
||||||
|
unregisterGlobalThemeListener,
|
||||||
|
getFrontColor,
|
||||||
|
refreshNavBarTheme,
|
||||||
|
onPageShow,
|
||||||
|
globalThemeMixin
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
/**
|
||||||
|
* 本地工具类(无需接口的功能)
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ===================== 本地缓存清理(核心实现) =====================
|
||||||
|
/**
|
||||||
|
* 清理小程序缓存
|
||||||
|
* @returns {Promise<{size: string, success: boolean}>}
|
||||||
|
*/
|
||||||
|
export function clearCache() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
try {
|
||||||
|
// 1. 清理本地存储(除了token,避免清理后直接退出)
|
||||||
|
const token = uni.getStorageSync('user_token') // 保留登录态
|
||||||
|
uni.clearStorageSync()
|
||||||
|
if (token) uni.setStorageSync('user_token', token) // 恢复token
|
||||||
|
|
||||||
|
// 2. 清理临时文件(如图片、视频缓存)
|
||||||
|
uni.getFileSystemManager().rmdir({
|
||||||
|
dirPath: `${wx.env.USER_DATA_PATH}/tmp`, // 微信小程序临时目录
|
||||||
|
recursive: true,
|
||||||
|
fail: () => {} // 目录不存在时忽略
|
||||||
|
})
|
||||||
|
|
||||||
|
// 3. 计算清理的缓存大小(模拟,真实场景可通过getStorageInfo获取)
|
||||||
|
uni.getStorageInfo({
|
||||||
|
success: (res) => {
|
||||||
|
const clearSize = (res.currentSize || 0).toFixed(2)
|
||||||
|
uni.showToast({ title: `清理完成,释放${clearSize}KB`, icon: 'success' })
|
||||||
|
resolve({
|
||||||
|
success: true,
|
||||||
|
size: `${clearSize}KB`
|
||||||
|
})
|
||||||
|
},
|
||||||
|
fail: () => {
|
||||||
|
uni.showToast({ title: '缓存清理成功', icon: 'success' })
|
||||||
|
resolve({ success: true, size: '未知' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error('清理缓存失败:', err)
|
||||||
|
uni.showToast({ title: '缓存清理失败', icon: 'none' })
|
||||||
|
resolve({ success: false, size: '0KB' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== 偏好设置管理(适配后端接口) =====================
|
||||||
|
// 偏好设置本地存储键名
|
||||||
|
const PREFERENCE_KEY = 'user_preferences'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 默认偏好设置(后端易适配的核心配置)
|
||||||
|
*/
|
||||||
|
const DEFAULT_PREFERENCES = {
|
||||||
|
theme: 'light', // 主题:light(浅色)/dark(深色)/auto(跟随系统)
|
||||||
|
listStyle: 'card', // 列表样式:card(卡片)/list(列表)
|
||||||
|
budgetWarn: true, // 预算超限提醒:true/false
|
||||||
|
autoSync: true, // 自动同步(后端接口适配:true-后台自动同步/false-手动同步)
|
||||||
|
remindTime: '08:00', // 待办提醒时间:HH:mm
|
||||||
|
monthBudget: 5000, // 月预算(数字,后端用于超限提醒)
|
||||||
|
currency: 'CNY' // 货币单位(后端统一适配)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取偏好设置(本地优先,无则返回默认值)
|
||||||
|
*/
|
||||||
|
export function getPreferences() {
|
||||||
|
try {
|
||||||
|
const localPrefs = uni.getStorageSync(PREFERENCE_KEY)
|
||||||
|
if (localPrefs && typeof localPrefs === 'object') {
|
||||||
|
// 合并默认值(避免新增配置项缺失)
|
||||||
|
return { ...DEFAULT_PREFERENCES, ...localPrefs }
|
||||||
|
}
|
||||||
|
return { ...DEFAULT_PREFERENCES }
|
||||||
|
} catch (err) {
|
||||||
|
console.error('获取偏好设置失败:', err)
|
||||||
|
return { ...DEFAULT_PREFERENCES }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存偏好设置(本地+可选同步到后端)
|
||||||
|
* @param {Object} prefs - 要修改的偏好设置
|
||||||
|
* @param {boolean} [syncToServer=true] - 是否同步到后端
|
||||||
|
* @returns {Promise<Object>} 最新的偏好设置
|
||||||
|
*/
|
||||||
|
export async function savePreferences(prefs, syncToServer = true) {
|
||||||
|
try {
|
||||||
|
// 1. 合并并保存到本地
|
||||||
|
const currentPrefs = getPreferences()
|
||||||
|
const newPrefs = { ...currentPrefs, ...prefs }
|
||||||
|
uni.setStorageSync(PREFERENCE_KEY, newPrefs)
|
||||||
|
|
||||||
|
// 2. 同步到后端(可选)
|
||||||
|
if (syncToServer) {
|
||||||
|
try {
|
||||||
|
// 导入request(避免循环依赖)
|
||||||
|
const request = (await import('./request.js')).default
|
||||||
|
await request.post('/settings/save', { preferences: newPrefs })
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('偏好设置同步后端失败:', err)
|
||||||
|
// 同步失败不影响本地保存
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
uni.showToast({ title: '设置保存成功', icon: 'success' })
|
||||||
|
return newPrefs
|
||||||
|
} catch (err) {
|
||||||
|
console.error('保存偏好设置失败:', err)
|
||||||
|
uni.showToast({ title: '设置保存失败', icon: 'none' })
|
||||||
|
return getPreferences()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重置偏好设置为默认值
|
||||||
|
*/
|
||||||
|
export function resetPreferences() {
|
||||||
|
uni.setStorageSync(PREFERENCE_KEY, DEFAULT_PREFERENCES)
|
||||||
|
uni.showToast({ title: '已恢复默认设置', icon: 'success' })
|
||||||
|
return { ...DEFAULT_PREFERENCES }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== 其他本地实用功能 =====================
|
||||||
|
/**
|
||||||
|
* 获取小程序版本信息
|
||||||
|
*/
|
||||||
|
export function getAppVersion() {
|
||||||
|
const systemInfo = uni.getSystemInfoSync()
|
||||||
|
return {
|
||||||
|
version: systemInfo.version || '未知',
|
||||||
|
platform: systemInfo.platform || '未知',
|
||||||
|
appName: '备忘录' // 替换为你的小程序名称
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// vue.config.js - 简化版(无需安装任何插件)
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
configureWebpack: {
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './'),
|
||||||
|
'@utils': path.resolve(__dirname, './utils')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
optimization: {
|
||||||
|
usedExports: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// uniapp 静态资源复制(核心:将utils目录标记为静态资源)
|
||||||
|
chainWebpack: (config) => {
|
||||||
|
// 针对微信小程序,复制整个utils目录到打包目录
|
||||||
|
if (process.env.UNI_PLATFORM === 'mp-weixin') {
|
||||||
|
config.plugin('copy').tap((args) => {
|
||||||
|
// 新增:复制utils目录下所有文件
|
||||||
|
args[0].push({
|
||||||
|
from: path.resolve(__dirname, 'utils'),
|
||||||
|
to: path.join(config.output.get('path'), 'utils'),
|
||||||
|
force: true, // 强制覆盖
|
||||||
|
// 排除不需要复制的文件(如果有)
|
||||||
|
ignore: ['**/node_modules/**']
|
||||||
|
});
|
||||||
|
return args;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||