首次提交:初始化项目代码

This commit is contained in:
sunct
2026-07-31 14:42:05 +08:00
commit ecca407485
84 changed files with 24158 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules/
unpackage/
.hbuilderx/
.DS_Store
+3
View File
@@ -0,0 +1,3 @@
{
"topic_20260729-054021_a698c4e41c426d52": 1785303621642
}
@@ -0,0 +1,3 @@
{
"topic_20260729-054021_a698c4e41c426d52": "auto"
}
+3
View File
@@ -0,0 +1,3 @@
{
"topic_20260729-054021_a698c4e41c426d52": "新的会话"
}
+658
View File
@@ -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('【激励机制】进入 handleGrowthIncentivesgrowth:', 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>
+650
View File
@@ -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
})
+97
View File
@@ -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>
+206
View File
@@ -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>
+127
View File
@@ -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>
+262
View File
@@ -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>
+184
View File
@@ -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 不是 pagethemeMixin 的 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>
+229
View File
@@ -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>
+127
View File
@@ -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>
+466
View File
@@ -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>
+160
View File
@@ -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>
+114
View File
@@ -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>
+465
View File
@@ -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>
+176
View File
@@ -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>
+76
View File
@@ -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>
+173
View File
@@ -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>
+238
View File
@@ -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月*
+1109
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -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>
+32
View File
@@ -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 }
}
+75
View File
@@ -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"
}
+48
View File
@@ -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
}
}
}
}
+61
View File
@@ -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)
}
}
}
+95
View File
@@ -0,0 +1,95 @@
// mixins/themeMixin.jsVue3 适配版)
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() {
// 空实现
}
}
}
+133
View File
@@ -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"
}
+575
View File
@@ -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 形如 #RRGGBBpercent 负数表示变暗
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>
+2661
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1363
View File
File diff suppressed because it is too large Load Diff
+1128
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+235
View File
@@ -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>
+626
View File
@@ -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>
+602
View File
@@ -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>
+677
View File
@@ -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() {
// switchTabtabBar> 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>
+1410
View File
File diff suppressed because it is too large Load Diff
+210
View File
@@ -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>
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 757 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 458 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 476 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 709 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 725 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 972 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 867 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 908 B

+25
View File
File diff suppressed because one or more lines are too long
+18
View File
@@ -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');
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
+156
View File
@@ -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;
}
+13
View File
@@ -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])
});
});
},
});
+93
View File
@@ -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';
+58
View File
@@ -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
}
+67
View File
@@ -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
}
+112
View File
@@ -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)
}
+287
View File
@@ -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%)'
}
}
+25
View File
File diff suppressed because one or more lines are too long
+109
View File
@@ -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
}
+98
View File
@@ -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
}
+245
View File
@@ -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
+524
View File
@@ -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]))
}
}
+340
View File
@@ -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
}
+136
View File
@@ -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: '备忘录' // 替换为你的小程序名称
}
}
+33
View File
@@ -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;
});
}
}
};