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

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
+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>