首次提交:初始化项目代码
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<view
|
||||
class="app-card"
|
||||
:class="{ 'app-card--clickable': clickable, 'app-card--active': isActive && clickable }"
|
||||
:style="cardStyle"
|
||||
@touchstart="onTouchStart"
|
||||
@touchend="onTouchEnd"
|
||||
@touchcancel="onTouchEnd"
|
||||
>
|
||||
<slot></slot>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import themeMixin from '@/mixins/themeMixin.js'
|
||||
import { THEME_CHANGE_EVENT } from '@/utils/theme'
|
||||
|
||||
export default {
|
||||
name: 'AppCard',
|
||||
mixins: [themeMixin],
|
||||
props: {
|
||||
padding: {
|
||||
type: Number,
|
||||
default: 24
|
||||
},
|
||||
radius: {
|
||||
type: Number,
|
||||
default: 20
|
||||
},
|
||||
marginBottom: {
|
||||
type: Number,
|
||||
default: 24
|
||||
},
|
||||
showShadow: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
clickable: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isActive: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
cardStyle() {
|
||||
const style = {
|
||||
backgroundColor: this.themeConfig.cardBgColor,
|
||||
padding: `${this.padding}rpx`,
|
||||
borderRadius: `${this.radius}rpx`,
|
||||
marginBottom: `${this.marginBottom}rpx`
|
||||
}
|
||||
if (this.showShadow) {
|
||||
style.boxShadow = `0 4rpx 16rpx ${this.themeConfig.shadowColor || 'rgba(0, 0, 0, 0.08)'}`
|
||||
}
|
||||
return style
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.syncThemeConfig()
|
||||
this.watchThemeChange()
|
||||
},
|
||||
beforeUnmount() {
|
||||
if (this.themeChangeHandler) {
|
||||
uni.$off(THEME_CHANGE_EVENT, this.themeChangeHandler)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onTouchStart() {
|
||||
if (this.clickable) {
|
||||
this.isActive = true
|
||||
}
|
||||
},
|
||||
onTouchEnd() {
|
||||
this.isActive = false
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-card {
|
||||
box-sizing: border-box;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.app-card--clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.app-card--active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,206 @@
|
||||
<template>
|
||||
<view v-if="visible" class="bottom-sheet-wrapper" @touchmove.stop.prevent>
|
||||
<view class="mask" :class="{ 'mask-active': animating }" @click="handleMaskClick" @touchmove.stop.prevent></view>
|
||||
<view class="sheet" :class="{ 'sheet-up': animating }" :style="sheetStyle">
|
||||
<view class="handle-bar"></view>
|
||||
<view class="header" :style="headerStyle">
|
||||
<text class="title" :style="titleStyle">{{ title }}</text>
|
||||
<view
|
||||
v-if="showClose"
|
||||
class="close-btn btn btn--icon"
|
||||
@click="handleClose"
|
||||
hover-class="close-btn-hover"
|
||||
>
|
||||
<text class="close-icon">×</text>
|
||||
</view>
|
||||
</view>
|
||||
<scroll-view scroll-y class="body">
|
||||
<slot></slot>
|
||||
</scroll-view>
|
||||
<view class="footer" :style="footerStyle">
|
||||
<slot name="footer"></slot>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import themeMixin from '@/mixins/themeMixin.js'
|
||||
|
||||
export default {
|
||||
name: 'BottomSheet',
|
||||
mixins: [themeMixin],
|
||||
props: {
|
||||
visible: { type: Boolean, default: false },
|
||||
title: { type: String, default: '' },
|
||||
height: { type: String, default: '85vh' },
|
||||
showClose: { type: Boolean, default: true },
|
||||
maskClosable: { type: Boolean, default: true }
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
animating: false,
|
||||
closeTimer: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
sheetStyle() {
|
||||
return {
|
||||
maxHeight: this.height,
|
||||
background: this.themeConfig.cardBgColor
|
||||
}
|
||||
},
|
||||
headerStyle() {
|
||||
return {
|
||||
borderBottom: `2rpx solid ${this.themeConfig.borderLight}`
|
||||
}
|
||||
},
|
||||
closeBtnStyle() {
|
||||
return {
|
||||
background: this.themeConfig.borderLight
|
||||
}
|
||||
},
|
||||
titleStyle() {
|
||||
return {
|
||||
color: this.themeConfig.textPrimary
|
||||
}
|
||||
},
|
||||
footerStyle() {
|
||||
return {
|
||||
borderTop: `2rpx solid ${this.themeConfig.borderLight}`
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
visible(val) {
|
||||
if (val) {
|
||||
this.animating = false
|
||||
this.$nextTick(() => {
|
||||
setTimeout(() => {
|
||||
this.animating = true
|
||||
}, 30)
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.syncThemeConfig()
|
||||
this.watchThemeChange()
|
||||
},
|
||||
beforeUnmount() {
|
||||
if (this.themeChangeHandler) {
|
||||
uni.$off('themeChanged', this.themeChangeHandler)
|
||||
}
|
||||
if (this.closeTimer) {
|
||||
clearTimeout(this.closeTimer)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleMaskClick() {
|
||||
if (!this.maskClosable) return
|
||||
this.handleClose()
|
||||
},
|
||||
handleClose() {
|
||||
this.animating = false
|
||||
this.closeTimer = setTimeout(() => {
|
||||
this.$emit('update:visible', false)
|
||||
this.$emit('close')
|
||||
}, 300)
|
||||
},
|
||||
watchThemeChange() {
|
||||
this.themeChangeHandler = ({ key, config }) => {
|
||||
if (key && config) {
|
||||
this.themeConfig = config
|
||||
}
|
||||
}
|
||||
uni.$on('themeChanged', this.themeChangeHandler)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.bottom-sheet-wrapper {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 99999;
|
||||
}
|
||||
|
||||
.mask {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0);
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
.mask-active {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.sheet {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
border-radius: 24rpx 24rpx 0 0;
|
||||
transform: translateY(100%);
|
||||
transition: transform 0.3s ease;
|
||||
padding-bottom: constant(safe-area-inset-bottom);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.sheet-up {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.handle-bar {
|
||||
width: 80rpx;
|
||||
height: 8rpx;
|
||||
background: #cccccc;
|
||||
border-radius: 4rpx;
|
||||
margin: 16rpx auto 0;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 24rpx 32rpx;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.close-btn-hover {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
.close-icon {
|
||||
font-size: 32rpx;
|
||||
color: var(--textSecondary);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: 24rpx 32rpx;
|
||||
max-height: 60vh;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: 24rpx 32rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,127 @@
|
||||
<template>
|
||||
<view class="capsule-filter" :class="{ 'is-scrollable': scrollable }">
|
||||
<view
|
||||
v-for="(option, index) in normalizedOptions"
|
||||
:key="index"
|
||||
class="capsule-item"
|
||||
:class="{ 'is-active': isActive(option) }"
|
||||
:style="getItemStyle(option)"
|
||||
@click="handleSelect(option)"
|
||||
>
|
||||
<text class="capsule-label">{{ getOptionLabel(option) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import themeMixin from '@/mixins/themeMixin.js'
|
||||
import { getThemeConfigFromStorage, THEME_CHANGE_EVENT } from '@/utils/theme'
|
||||
|
||||
export default {
|
||||
name: 'CapsuleFilter',
|
||||
mixins: [themeMixin],
|
||||
props: {
|
||||
options: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
value: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
scrollable: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'medium'
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.syncThemeConfig()
|
||||
this.watchThemeChange()
|
||||
},
|
||||
beforeUnmount() {
|
||||
if (this.themeChangeHandler) {
|
||||
uni.$off(THEME_CHANGE_EVENT, this.themeChangeHandler)
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
normalizedOptions() {
|
||||
return this.options.map(opt => {
|
||||
if (typeof opt === 'string') {
|
||||
return { label: opt, value: opt }
|
||||
}
|
||||
return opt
|
||||
})
|
||||
},
|
||||
activeColor() {
|
||||
return this.color || this.themeConfig.primaryColor
|
||||
},
|
||||
sizeConfig() {
|
||||
const map = {
|
||||
small: { padding: '8rpx 20rpx', fontSize: '24rpx' },
|
||||
medium: { padding: '12rpx 28rpx', fontSize: '28rpx' },
|
||||
large: { padding: '16rpx 36rpx', fontSize: '32rpx' }
|
||||
}
|
||||
return map[this.size] || map.medium
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
isActive(option) {
|
||||
return option.value === this.value
|
||||
},
|
||||
getOptionLabel(option) {
|
||||
return option.label
|
||||
},
|
||||
getItemStyle(option) {
|
||||
const active = this.isActive(option)
|
||||
return {
|
||||
padding: this.sizeConfig.padding,
|
||||
fontSize: this.sizeConfig.fontSize,
|
||||
backgroundColor: active ? this.activeColor : this.themeConfig.borderColor,
|
||||
color: active ? '#ffffff' : this.themeConfig.textSecondary
|
||||
}
|
||||
},
|
||||
handleSelect(option) {
|
||||
if (option.value === this.value) return
|
||||
this.$emit('change', option.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.capsule-filter {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.capsule-filter.is-scrollable {
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.capsule-filter.is-scrollable .capsule-item {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.capsule-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 10rpx;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
|
||||
.capsule-label {
|
||||
line-height: 1.4;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,262 @@
|
||||
<template>
|
||||
<view class="center-modal" v-if="visible">
|
||||
<!-- 遮罩层:点击关闭。mask 与 dialog-wrap 是兄弟节点,点击不会相互干扰 -->
|
||||
<view class="cm-mask" @click="handleMaskClick" hover-stop-propagation></view>
|
||||
<!-- 弹窗容器,绝对定位居中 -->
|
||||
<view class="cm-dialog-wrap">
|
||||
<!-- 弹窗主体 -->
|
||||
<view class="cm-dialog" :style="{ backgroundColor: themeConfig.cardBgColor }">
|
||||
<view class="cm-header" v-if="title || $slots.header">
|
||||
<slot name="header">
|
||||
<text class="cm-title" :style="{ color: themeConfig.textPrimary }">{{ title }}</text>
|
||||
</slot>
|
||||
</view>
|
||||
<view class="cm-body" :style="{ maxHeight: maxBodyHeight }">
|
||||
<slot></slot>
|
||||
</view>
|
||||
<view class="cm-footer" v-if="showCancel || showConfirm || $slots.footer">
|
||||
<slot name="footer">
|
||||
<view
|
||||
v-if="showCancel"
|
||||
class="cm-btn cm-btn--cancel"
|
||||
:style="{ background: cancelBg, color: themeConfig.textPrimary }"
|
||||
hover-class="cm-btn--cancel--active"
|
||||
:hover-stay-time="100"
|
||||
@click="handleCancel"
|
||||
>
|
||||
<text class="cm-btn__text">{{ cancelText }}</text>
|
||||
</view>
|
||||
<view
|
||||
v-if="showConfirm"
|
||||
class="cm-btn cm-btn--confirm"
|
||||
:class="{ 'cm-btn--disabled': confirmDisabled }"
|
||||
:style="confirmBtnStyle"
|
||||
hover-class="cm-btn--confirm--active"
|
||||
:hover-stay-time="100"
|
||||
@click="handleConfirm"
|
||||
>
|
||||
<text class="cm-btn__text">{{ confirmText }}</text>
|
||||
</view>
|
||||
</slot>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 关闭按钮:放在弹窗容器内,与 dialog 平级,避免被 dialog 拦截事件 -->
|
||||
<view
|
||||
v-if="showClose"
|
||||
class="cm-close"
|
||||
:style="{ background: themeConfig.cardBgColor, color: themeConfig.textTertiary, boxShadow: `0 6rpx 20rpx ${themeConfig.shadowColor || 'rgba(0,0,0,0.15)'}` }"
|
||||
hover-class="cm-close--active"
|
||||
:hover-stay-time="100"
|
||||
@click="handleClose"
|
||||
>
|
||||
<FaIcon icon="xmark" :size="22" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import FaIcon from './FaIcon.vue'
|
||||
import themeMixin from '@/mixins/themeMixin.js'
|
||||
|
||||
export default {
|
||||
name: 'CenterModal',
|
||||
components: { FaIcon },
|
||||
mixins: [themeMixin],
|
||||
props: {
|
||||
visible: { type: Boolean, default: false },
|
||||
title: { type: String, default: '' },
|
||||
showClose: { type: Boolean, default: true },
|
||||
showCancel: { type: Boolean, default: true },
|
||||
showConfirm: { type: Boolean, default: true },
|
||||
maskClosable: { type: Boolean, default: true },
|
||||
cancelText: { type: String, default: '取消' },
|
||||
confirmText: { type: String, default: '确定' },
|
||||
confirmColor: { type: String, default: '' },
|
||||
confirmDisabled: { type: Boolean, default: false },
|
||||
maxBodyHeight: { type: String, default: '70vh' }
|
||||
},
|
||||
computed: {
|
||||
/**
|
||||
* 确认按钮样式:直接应用主题渐变色(不依赖 CSS 变量),确保主题切换立即生效
|
||||
*/
|
||||
confirmBtnStyle() {
|
||||
const bg = this.confirmColor || this.themeConfig.gradientSoft
|
||||
return {
|
||||
background: bg,
|
||||
color: '#ffffff',
|
||||
boxShadow: `0 6rpx 18rpx ${this.themeConfig.shadowColor || 'rgba(198, 142, 63, 0.35)'}`
|
||||
}
|
||||
},
|
||||
cancelBg() {
|
||||
// 跟随主题色:使用 inputBgColor 创造与主题协调的次按钮背景
|
||||
return this.themeConfig.inputBgColor || 'rgba(0, 0, 0, 0.06)'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleMaskClick() {
|
||||
if (!this.maskClosable) return
|
||||
this.handleClose()
|
||||
},
|
||||
handleClose() {
|
||||
if (!this.visible) return
|
||||
this.$emit('update:visible', false)
|
||||
this.$emit('close')
|
||||
},
|
||||
handleCancel() {
|
||||
if (!this.visible) return
|
||||
this.$emit('update:visible', false)
|
||||
this.$emit('close')
|
||||
this.$emit('cancel')
|
||||
},
|
||||
handleConfirm() {
|
||||
if (!this.visible) return
|
||||
if (this.confirmDisabled) return
|
||||
this.$emit('confirm')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.center-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 99999;
|
||||
}
|
||||
|
||||
.cm-mask {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
animation: cm-fadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes cm-fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
/* 弹窗容器:用绝对定位 + transform 居中,简单可靠 */
|
||||
.cm-dialog-wrap {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: cm-zoomIn 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
@keyframes cm-zoomIn {
|
||||
from { transform: translate(-50%, -50%) scale(0.92); opacity: 0; }
|
||||
to { transform: translate(-50%, -50%) scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
.cm-dialog {
|
||||
position: relative;
|
||||
width: 80vw;
|
||||
max-width: 600rpx;
|
||||
max-height: 85vh;
|
||||
border-radius: 24rpx;
|
||||
padding: 56rpx 40rpx 40rpx;
|
||||
box-shadow: 0 20rpx 60rpx rgba(0, 0, 0, 0.18);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.cm-header {
|
||||
text-align: center;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.cm-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.cm-body {
|
||||
font-size: 28rpx;
|
||||
line-height: 1.6;
|
||||
min-height: 60rpx;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.cm-footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 24rpx;
|
||||
margin-top: 40rpx;
|
||||
}
|
||||
|
||||
/* 关闭按钮:紧贴弹窗右上角外侧 */
|
||||
.cm-close {
|
||||
position: absolute;
|
||||
top: -28rpx;
|
||||
right: -28rpx;
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
border-radius: 50%;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2rpx solid rgba(0, 0, 0, 0.06);
|
||||
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.cm-close--active {
|
||||
transform: scale(0.9);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* 按钮:默认中等大小,不依赖 .btn 统一样式,避免被外部样式干扰 */
|
||||
.cm-btn {
|
||||
flex: 1;
|
||||
min-height: 80rpx;
|
||||
padding: 0 40rpx;
|
||||
border-radius: 14rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
outline: none;
|
||||
transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.2s ease;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.cm-btn__text {
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.cm-btn--cancel--active {
|
||||
opacity: 0.75;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.cm-btn--confirm--active {
|
||||
opacity: 0.92;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.cm-btn--disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.cm-btn:only-child {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,184 @@
|
||||
<template>
|
||||
<view class="empty-state" :class="['type-' + type, { compact: compact }]" :style="wrapperStyle">
|
||||
<view class="empty-illustration" :style="illustrationStyle">
|
||||
<!-- 圆形光晕背景 -->
|
||||
<view class="illustration-halo" :style="haloStyle"></view>
|
||||
<!-- emoji 图标(微信小程序兼容方案) -->
|
||||
<text class="illustration-emoji">{{ emojiIcon }}</text>
|
||||
</view>
|
||||
|
||||
<view v-if="title" class="empty-title">{{ title }}</view>
|
||||
<view v-if="desc" class="empty-desc">{{ desc }}</view>
|
||||
|
||||
<view v-if="actionText" class="empty-action btn btn--primary btn--pill" @tap="onAction">
|
||||
<text class="action-text">{{ actionText }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import themeMixin from '@/mixins/themeMixin.js'
|
||||
import { THEME_CHANGE_EVENT } from '@/utils/theme'
|
||||
|
||||
// 类型→emoji 映射(微信小程序不支持 inline SVG,使用 emoji 替代)
|
||||
const EMOJI_MAP = {
|
||||
'default': '📂',
|
||||
'no-data': '📂',
|
||||
'no-network': '📡',
|
||||
'no-search': '🔍',
|
||||
'mood': '📅',
|
||||
'bill': '💰',
|
||||
'todo': '📋',
|
||||
'review': '✍️',
|
||||
'growth': '🏆',
|
||||
'error': '⚠️'
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'EmptyState',
|
||||
mixins: [themeMixin],
|
||||
props: {
|
||||
// 缺省类型:default / no-data / no-network / no-search / mood / bill / todo / review / growth / error
|
||||
type: { type: String, default: 'default' },
|
||||
title: { type: String, default: '暂无数据' },
|
||||
desc: { type: String, default: '' },
|
||||
actionText: { type: String, default: '' },
|
||||
compact: { type: Boolean, default: false }
|
||||
},
|
||||
computed: {
|
||||
emojiIcon() {
|
||||
return EMOJI_MAP[this.type] || EMOJI_MAP['default']
|
||||
},
|
||||
wrapperStyle() {
|
||||
return {
|
||||
backgroundColor: this.themeConfig.bgColor
|
||||
}
|
||||
},
|
||||
illustrationStyle() {
|
||||
return {}
|
||||
},
|
||||
haloStyle() {
|
||||
const primary = this.themeConfig.primaryColor
|
||||
return {
|
||||
background: `radial-gradient(circle, ${primary}33 0%, ${primary}0D 70%, transparent 100%)`
|
||||
}
|
||||
}
|
||||
},
|
||||
created() {
|
||||
// 组件级钩子:EmptyState 不是 page,themeMixin 的 onLoad/onShow 不会触发
|
||||
// 这里手动监听主题变更事件,实时同步主题色
|
||||
if (typeof this.syncThemeConfig === 'function') {
|
||||
this.syncThemeConfig()
|
||||
}
|
||||
if (typeof this.watchThemeChange === 'function') {
|
||||
this.watchThemeChange()
|
||||
}
|
||||
},
|
||||
beforeUnmount() {
|
||||
// 组件销毁时清理监听(避免内存泄漏)
|
||||
if (this.themeChangeHandler) {
|
||||
uni.$off && uni.$off(THEME_CHANGE_EVENT, this.themeChangeHandler)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onAction() {
|
||||
this.$emit('action')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/uni.scss';
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40rpx;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
&.compact {
|
||||
padding: 40rpx 32rpx 32rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-illustration {
|
||||
width: 160rpx;
|
||||
height: 160rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 16rpx;
|
||||
animation: emptyFloat 4s ease-in-out infinite;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 圆形光晕背景 */
|
||||
.illustration-halo {
|
||||
position: absolute;
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
border-radius: 50%;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
/* emoji 图标 */
|
||||
.illustration-emoji {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
font-size: 50rpx;
|
||||
line-height: 1;
|
||||
text-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.compact .empty-illustration {
|
||||
width: 200rpx;
|
||||
height: 200rpx;
|
||||
margin-bottom: 8rpx;
|
||||
|
||||
.illustration-halo {
|
||||
width: 160rpx;
|
||||
height: 160rpx;
|
||||
}
|
||||
|
||||
.illustration-emoji {
|
||||
font-size: 72rpx;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes emptyFloat {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-6rpx); }
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: v-bind('themeConfig.textPrimary');
|
||||
margin-top: 12rpx;
|
||||
letter-spacing: 0.5rpx;
|
||||
}
|
||||
|
||||
.empty-desc {
|
||||
font-size: 24rpx;
|
||||
color: v-bind('themeConfig.textSecondary');
|
||||
margin-top: 12rpx;
|
||||
text-align: center;
|
||||
line-height: 1.6;
|
||||
max-width: 480rpx;
|
||||
}
|
||||
|
||||
.empty-action {
|
||||
margin-top: 36rpx;
|
||||
}
|
||||
|
||||
.action-text {
|
||||
font-size: 28rpx;
|
||||
letter-spacing: 1rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,229 @@
|
||||
<template>
|
||||
<text class="fa-icon" :class="[prefix]" :style="iconStyle">{{ unicode }}</text>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
const MAP = {
|
||||
// 基础
|
||||
home:'\uf015', list:'\uf022', 'list-check':'\uf14a', 'list-ul':'\uf0ca',
|
||||
check:'\uf00c', 'check-circle':'\uf058', 'check-double':'\uf560',
|
||||
xmark:'\uf00d', close:'\uf00d',
|
||||
plus:'\uf0fe', minus:'\uf056',
|
||||
search:'\uf002',
|
||||
user:'\uf007', users:'\uf0c0', 'user-plus':'\uf234', 'user-check':'\uf4fc',
|
||||
gear:'\uf013', settings:'\uf013', 'user-gear':'\uf4ff',
|
||||
envelope:'\uf0e0', mail:'\uf0e0', 'envelope-open':'\uf2b6',
|
||||
phone:'\uf095', 'phone-volume':'\uf2a0',
|
||||
bell:'\uf0f3', 'bell-slash':'\uf1f6',
|
||||
heart:'\uf004', 'heart-crack':'\uf7a9',
|
||||
star:'\uf005', 'star-half-stroke':'\uf5c0',
|
||||
camera:'\uf030',
|
||||
image:'\uf03e', picture:'\uf03e',
|
||||
// 日历 / 时间
|
||||
calendar:'\uf073', 'calendar-day':'\uf783', 'calendar-week':'\uf784',
|
||||
'calendar-days':'\uf1b3', 'calendar-check':'\uf274', 'calendar-xmark':'\uf273',
|
||||
'calendar-plus':'\uf271', 'calendar-minus':'\uf272',
|
||||
clock:'\uf017', 'clock-rotate-left':'\uf1da', time:'\uf017',
|
||||
'hourglass-half':'\uf252', 'hourglass-start':'\uf251', 'hourglass-end':'\uf253',
|
||||
// 位置 / 导航
|
||||
'location-dot':'\uf601', location:'\uf601', 'map-pin':'\uf276',
|
||||
'map-location-dot':'\uf62a', 'map':'\uf279', route:'\uf4d7', compass:'\uf14e',
|
||||
// 箭头
|
||||
'chevron-right':'\uf054', 'chevron-left':'\uf053',
|
||||
'chevron-up':'\uf077', 'chevron-down':'\uf078',
|
||||
'arrow-up':'\uf062', 'arrow-down':'\uf063',
|
||||
'arrow-right':'\uf061', 'arrow-left':'\uf060',
|
||||
'arrow-up-right-from-square':'\uf08e', 'arrow-right-to-bracket':'\uf090',
|
||||
'arrow-rotate-right':'\uf021', 'arrow-rotate-left':'\uf0e2',
|
||||
back:'\uf053', forward:'\uf04e',
|
||||
'angles-right':'\uf105', 'angles-left':'\uf104', 'angles-up':'\uf102', 'angles-down':'\uf103',
|
||||
// 工具
|
||||
filter:'\uf0b0', sliders:'\uf1de', sort:'\uf0dc', 'sort-asc':'\uf0de', 'sort-desc':'\uf0dd',
|
||||
sparkles:'\uf72b', 'wand-magic-sparkles':'\uf72b', 'wand-magic':'\uf72b',
|
||||
// 状态
|
||||
'circle-info':'\uf05a', info:'\uf05a',
|
||||
'circle-exclamation':'\uf06a', 'triangle-exclamation':'\uf071',
|
||||
'circle-question':'\uf059', 'circle-half-stroke':'\uf042',
|
||||
'circle-check':'\uf058', 'circle-xmark':'\uf05c',
|
||||
'circle-plus':'\uf055', 'circle-minus':'\uf056', 'circle-dot':'\uf192',
|
||||
eye:'\uf06e', 'eye-slash':'\uf070',
|
||||
lock:'\uf023', unlock:'\uf09c', 'lock-open':'\uf3c1',
|
||||
// 编辑
|
||||
tag:'\uf02b', tags:'\uf02c', bookmark:'\uf02e',
|
||||
trash:'\uf1f8', delete:'\uf1f8',
|
||||
'pen-to-square':'\uf044', edit:'\uf044', pen:'\uf304', pencil:'\uf040',
|
||||
'file-lines':'\uf15c', file:'\uf15c', 'file-pen':'\uf31c',
|
||||
// 图表
|
||||
'chart-simple':'\uf7a5',
|
||||
'chart-column':'\uf7ae', 'chart-bar':'\uf080',
|
||||
'chart-line':'\uf201', 'chart-pie':'\uf200', 'chart-area':'\uf1fe',
|
||||
'chart-gantt':'\uf7a0', 'chart-bullet':'\uf7a1',
|
||||
// 财务
|
||||
'dollar-sign':'\uf155', 'yen-sign':'\uf157', 'yuan-sign':'\uf157',
|
||||
coins:'\uf1e3', wallet:'\uf555', 'credit-card':'\uf09d', 'money-bill':'\uf2e1',
|
||||
'money-bill-wave':'\uf53a', 'money-bill-trend-up':'\uf4f9', 'money-bill-transfer':'\uf4f8',
|
||||
'sack-dollar':'\uf81d', 'hand-holding-dollar':'\uf4c0',
|
||||
gift:'\uf06b',
|
||||
// 成就 / 游戏
|
||||
trophy:'\uf091', medal:'\uf5a2', award:'\uf559', crown:'\uf52a',
|
||||
fire:'\uf06d', bolt:'\uf0e7', gem:'\uf3a5',
|
||||
bullseye:'\uf140', target:'\uf140', rocket:'\uf135',
|
||||
'flag-checkered':'\uf11e', flag:'\uf024', 'bookmark-star':'\uf7a1',
|
||||
// 菜单
|
||||
menu:'\uf0c9', bars:'\uf0c9', 'grip-vertical':'\uf58e', 'grip-lines':'\uf7a4',
|
||||
ellipsis:'\uf141', 'ellipsis-vertical':'\uf142',
|
||||
// 通讯
|
||||
comment:'\uf075', comments:'\uf086', 'comment-dots':'\uf4ad',
|
||||
'paper-plane':'\uf1d8',
|
||||
// 电源
|
||||
'power-off':'\uf011',
|
||||
// 文件 / 文件夹
|
||||
cloud:'\uf0c2', 'cloud-arrow-up':'\uf382', 'cloud-arrow-down':'\uf37d',
|
||||
folder:'\uf07b', 'folder-open':'\uf07c', 'folder-plus':'\uf65e',
|
||||
circle:'\uf111', 'circle-notch':'\uf1ce',
|
||||
code:'\uf121', terminal:'\uf120',
|
||||
link:'\uf08c', unlink:'\uf127', 'link-slash':'\uf127',
|
||||
shield:'\uf132', 'shield-halved':'\uf3ed', 'shield-check':'\uf2f7',
|
||||
download:'\uf019', upload:'\uf093',
|
||||
share:'\uf064', 'share-nodes':'\uf504',
|
||||
copy:'\uf0c5', save:'\uf0c7', paste:'\uf0ea',
|
||||
rotate:'\uf2f1', refresh:'\uf021', sync:'\uf021', 'arrows-rotate':'\uf021',
|
||||
signout:'\uf2f5', 'right-from-bracket':'\uf2f5', 'right-to-bracket':'\uf2f6',
|
||||
qrcode:'\uf029', barcode:'\uf02a',
|
||||
// 主题
|
||||
palette:'\uf53f', 'palette-flushed':'\uf7a2', paintbrush:'\uf1fc',
|
||||
sun:'\uf185', moon:'\uf186', 'cloud-sun':'\uf6c4', 'cloud-moon':'\uf6c3',
|
||||
// 媒体
|
||||
'volume-high':'\uf0d7', 'volume-low':'\uf027', 'volume-off':'\uf026', 'volume-xmark':'\uf6a9',
|
||||
headphones:'\uf025', music:'\uf001', microphone:'\uf130', 'microphone-lines':'\uf130',
|
||||
play:'\uf04b', pause:'\uf04c', stop:'\uf04d', forward:'\uf04e', backward:'\uf04a',
|
||||
// 设备
|
||||
idcard:'\uf2c2', mobile:'\uf10b', tablet:'\uf3fa', laptop:'\uf109', desktop:'\uf390',
|
||||
wifi:'\uf1eb', bluetooth:'\uf293', signal:'\uf012', battery:'\uf240',
|
||||
keyboard:'\uf11c', mouse:'\uf8cc',
|
||||
// 建筑 / 地点
|
||||
briefcase:'\uf0b1', building:'\uf1ad', store:'\uf54e', shop:'\uf54f',
|
||||
cart:'\uf07a', 'cart-shopping':'\uf07a', bag:'\uf290', 'bag-shopping':'\uf290',
|
||||
bed:'\uf236', house:'\uf015', 'house-chimney':'\uf3f0',
|
||||
// 食物 / 饮品
|
||||
utensils:'\uf2e7', coffee:'\uf0f4', mug:'\uf7b6', 'mug-hot':'\uf7b6', 'mug-saucer':'\uf7b5',
|
||||
'bowl-food':'\uf830', 'bowl-rice':'\uf830', 'pizza-slice':'\uf818',
|
||||
'burger':'\uf805', 'ice-cream':'\uf810', cookie:'\uf563',
|
||||
// 健康 / 运动
|
||||
dumbbell:'\uf44b', 'heart-pulse':'\uf21e', 'heartbeat':'\uf21e',
|
||||
hospital:'\uf0f8', pill:'\uf484', stethoscope:'\uf0f1',
|
||||
'person-walking':'\uf554', 'person-running':'\uf70c', 'person-biking':'\uf63a',
|
||||
'person-hiking':'\uf6ee', bicycle:'\uf206', 'car-side':'\uf5e4',
|
||||
// 学习 / 知识
|
||||
book:'\uf02d', 'book-open':'\uf518', 'bookmark':'\uf02e',
|
||||
'book-bookmark':'\uf7a1', 'book-open-reader':'\uf5da',
|
||||
'graduation-cap':'\uf19d', pen:'\uf304', pencil:'\uf040',
|
||||
lightbulb:'\uf0eb', 'face-smile':'\uf118', 'face-smile-beam':'\uf5b8',
|
||||
'face-meh':'\uf11a', 'face-frown':'\uf119', 'face-grin':'\uf580',
|
||||
'face-laugh':'\uf599', 'face-grin-stars':'\uf587',
|
||||
// 自然
|
||||
leaf:'\uf06c', seedling:'\uf32d', tree:'\uf1bb', 'tree-deciduous':'\uf1bb',
|
||||
mountain:'\uf6fc', 'mountain-sun':'\uf7a2', water:'\uf773',
|
||||
// 工具 / 维修
|
||||
hammer:'\uf6e4', wrench:'\uf0ad', tools:'\uf7d9', screwdriver:'\uf7d8',
|
||||
key:'\uf084', lock:'\uf023',
|
||||
// 法律 / 商务
|
||||
'scale-balanced':'\uf24e', 'gavel':'\uf0e3', 'briefcase':'\uf0b1',
|
||||
'file-contract':'\uf56c', 'file-invoice':'\uf570', 'file-invoice-dollar':'\uf571',
|
||||
// 人
|
||||
hand:'\uf25b', 'hand-back-fist':'\uf255', 'hand-fist':'\uf255',
|
||||
'hand-holding':'\uf4bd', 'hand-holding-heart':'\uf4be',
|
||||
'thumbs-up':'\uf164', 'thumbs-down':'\uf165',
|
||||
'user-shield':'\uf502', 'user-tie':'\uf508', 'user-graduate':'\uf501',
|
||||
'user-astronaut':'\uf4fb',
|
||||
angel:'\uf529', child:'\uf1ae', baby:'\uf77c', 'user-nurse':'\uf82f',
|
||||
'user-doctor':'\uf0f0', 'user-tag':'\uf507',
|
||||
// 地址 / 标识
|
||||
'address-book':'\uf2b9', 'id-badge':'\uf2c1', 'address-card':'\uf2bb',
|
||||
'file-clipboard':'\uf5ea', 'clipboard-list':'\uf46d', 'clipboard-check':'\uf46c',
|
||||
// 文件夹树
|
||||
'folder-tree':'\uf802', 'folder-closed':'\uf1e3',
|
||||
// 杂项
|
||||
thumbtack:'\uf08d', 'map-pin':'\uf276', 'thumb-tack':'\uf08d',
|
||||
guitar:'\uf7a6', music:'\uf001', drum:'\uf569',
|
||||
'flag':'\uf024', 'paw':'\uf1b0', 'bone':'\uf5d7',
|
||||
// 文件类型
|
||||
'file-pdf':'\uf1c1', 'file-word':'\uf1c2', 'file-excel':'\uf1c3',
|
||||
'file-powerpoint':'\uf1c4', 'file-archive':'\uf1c6',
|
||||
'file-image':'\uf1c5', 'file-video':'\uf1c8', 'file-audio':'\uf1c7',
|
||||
'file-code':'\uf1c9', 'file-csv':'\uf6dd',
|
||||
// 网络 / 连接
|
||||
'globe':'\uf0ac', 'earth-asia':'\uf57e', 'network-wired':'\uf6ff',
|
||||
'server':'\uf233', 'database':'\uf1c0', 'hdd':'\uf0a0',
|
||||
// 箭头
|
||||
'arrow-trend-up':'\uf098', 'arrow-trend-down':'\uf097',
|
||||
'up-right-and-down-left-from-center':'\uf424',
|
||||
'down-left-and-up-right-to-center':'\uf422',
|
||||
'maximize':'\uf31e', 'minimize':'\uf78c', 'expand':'\uf065', 'compress':'\uf066',
|
||||
// 其他常用
|
||||
'hashtag':'\uf292', 'at':'\uf1fa', 'percent':'\uf295', 'divide':'\uf529',
|
||||
'plus':'\uf0fe', 'minus':'\uf056', 'equals':'\uf7a0',
|
||||
'infinity':'\uf534', 'less-than':'\uf7a0', 'greater-than':'\uf7a0',
|
||||
// 心情 / 表情
|
||||
'face-sad-tear':'\uf5b4', 'face-grin-beam':'\uf582', 'face-grin-hearts':'\uf584',
|
||||
'face-kiss':'\uf596', 'face-kiss-beam':'\uf597', 'face-kiss-wink-heart':'\uf598',
|
||||
'face-laugh-beam':'\uf59a', 'face-laugh-squint':'\uf59b',
|
||||
'face-tired':'\uf5c8', 'face-rolling-eyes':'\uf5a5',
|
||||
'face-smile-wink':'\uf4da', 'face-smile-plus':'\uf5b9',
|
||||
// 状态指示
|
||||
'circle-arrow-up':'\uf0aa', 'circle-arrow-down':'\uf0ab',
|
||||
'circle-arrow-right':'\uf0a9', 'circle-arrow-left':'\uf0a8',
|
||||
'triangle-exclamation':'\uf071', 'triangle':'\uf2ec',
|
||||
'square':'\uf0c8', 'square-check':'\uf14a', 'square-plus':'\uf0fe',
|
||||
// 默认回退
|
||||
default:'\uf2dc', fallback:'\uf2dc'
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'FaIcon',
|
||||
props: {
|
||||
icon: { type: String, required: true },
|
||||
size: { type: [String, Number], default: 'inherit' },
|
||||
color: { type: String, default: '' },
|
||||
prefix: { type: String, default: 'fas' }
|
||||
},
|
||||
computed: {
|
||||
iconStyle() {
|
||||
const s = {}
|
||||
if (this.size !== 'inherit') {
|
||||
s.fontSize = typeof this.size === 'number' ? this.size + 'rpx' : this.size
|
||||
}
|
||||
if (this.color) s.color = this.color
|
||||
return s
|
||||
},
|
||||
unicode() { return MAP[this.icon] || MAP.fallback }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.fa-icon {
|
||||
font-family: 'Font Awesome 6 Free';
|
||||
font-weight: 900;
|
||||
font-style: normal;
|
||||
font-variant: normal;
|
||||
text-rendering: auto;
|
||||
display: inline-block;
|
||||
line-height: 1;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
.fa-icon.fab, .fa-icon.fa-brands {
|
||||
font-family: 'Font Awesome 6 Brands';
|
||||
font-weight: 400;
|
||||
}
|
||||
.fa-icon.far, .fa-icon.fa-regular { font-weight: 400; }
|
||||
/* 点击态 */
|
||||
.fa-icon.clickable {
|
||||
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||
}
|
||||
.fa-icon.clickable:active {
|
||||
transform: scale(0.92);
|
||||
opacity: 0.75;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,127 @@
|
||||
<template>
|
||||
<view class="growth-modal" v-if="visible">
|
||||
<view class="mask" @click="handleMaskClick" @touchmove.stop.prevent></view>
|
||||
<view class="dialog" @click.stop>
|
||||
<view class="icon-area" v-if="icon">
|
||||
<text class="icon">{{ icon }}</text>
|
||||
</view>
|
||||
<text class="title" v-if="title">{{ title }}</text>
|
||||
<view class="message" v-if="message">{{ message }}</view>
|
||||
<view class="btn-group">
|
||||
<view class="btn btn--primary btn--block btn--pill" @click="handleConfirm" hover-class="btn-hover">
|
||||
<text>{{ confirmText }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'GrowthModal',
|
||||
props: {
|
||||
visible: { type: Boolean, default: false },
|
||||
title: { type: String, default: '' },
|
||||
message: { type: String, default: '' },
|
||||
icon: { type: String, default: '' },
|
||||
confirmText: { type: String, default: '确定' }
|
||||
},
|
||||
emits: ['confirm', 'close'],
|
||||
methods: {
|
||||
handleMaskClick() {
|
||||
this.$emit('close')
|
||||
},
|
||||
handleConfirm() {
|
||||
this.$emit('confirm')
|
||||
this.$emit('close')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.growth-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 99999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.mask {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.dialog {
|
||||
position: relative;
|
||||
width: 600rpx;
|
||||
background: #ffffff;
|
||||
border-radius: 12rpx;
|
||||
padding: 56rpx 48rpx;
|
||||
text-align: center;
|
||||
box-shadow: 0 20rpx 60rpx rgba(0, 0, 0, 0.15);
|
||||
animation: scaleIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
@keyframes scaleIn {
|
||||
from {
|
||||
transform: scale(0.8);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.icon-area {
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 96rpx;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 40rpx;
|
||||
font-weight: 700;
|
||||
color: #1a1a2e;
|
||||
display: block;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.message {
|
||||
font-size: 28rpx;
|
||||
color: #666666;
|
||||
line-height: 1.8;
|
||||
white-space: pre-wrap;
|
||||
min-height: 140rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
margin-top: 40rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,466 @@
|
||||
<template>
|
||||
<view class="inline-calendar" :style="calendarStyle">
|
||||
<view class="calendar-header" v-if="showMonthNav">
|
||||
<view class="nav-btn" @click="prevMonth">
|
||||
<text class="nav-icon">‹</text>
|
||||
</view>
|
||||
<text class="month-title">{{ currentYear }}年{{ currentMonth }}月</text>
|
||||
<view class="nav-btn" @click="nextMonth">
|
||||
<text class="nav-icon">›</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="weekday-header">
|
||||
<view class="weekday-cell" v-for="(day, i) in weekdayLabels" :key="'wd-' + i">
|
||||
<text class="weekday-text" :class="{ 'is-weekend': i === 5 || i === 6 }">{{ day }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="days-grid">
|
||||
<view
|
||||
class="day-row"
|
||||
v-for="(week, wi) in weeks"
|
||||
:key="wi"
|
||||
>
|
||||
<view
|
||||
class="day-item"
|
||||
:class="getDayClass(date)"
|
||||
:style="getDayStyle(date)"
|
||||
v-for="(date, di) in week"
|
||||
:key="di"
|
||||
@click="onDateClick(date)"
|
||||
>
|
||||
<text class="day-number">{{ date.getDate() }}</text>
|
||||
<view
|
||||
class="mark-dot"
|
||||
v-if="isMarked(date) && markType === 'dot'"
|
||||
></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import themeMixin from '@/mixins/themeMixin.js'
|
||||
import { THEME_CHANGE_EVENT } from '@/utils/theme'
|
||||
|
||||
// 周一到周日的完整中文名(展示在日历数字上方)
|
||||
const WEEKDAY_LABELS = ['一', '二', '三', '四', '五', '六', '日']
|
||||
|
||||
export default {
|
||||
name: 'InlineCalendar',
|
||||
mixins: [themeMixin],
|
||||
props: {
|
||||
value: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
markDates: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
markType: {
|
||||
type: String,
|
||||
default: 'dot'
|
||||
},
|
||||
rangeSelect: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
startDate: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
endDate: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
showMonthNav: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
minDate: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
maxDate: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
currentDate: new Date(),
|
||||
innerStartDate: '',
|
||||
innerEndDate: ''
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
calendarStyle() {
|
||||
return {
|
||||
backgroundColor: this.themeConfig.cardBgColor,
|
||||
borderRadius: '20rpx'
|
||||
}
|
||||
},
|
||||
weekdayLabels() {
|
||||
return WEEKDAY_LABELS
|
||||
},
|
||||
currentYear() {
|
||||
return this.currentDate.getFullYear()
|
||||
},
|
||||
currentMonth() {
|
||||
return this.currentDate.getMonth() + 1
|
||||
},
|
||||
todayStr() {
|
||||
return this.formatDate(new Date())
|
||||
},
|
||||
weeks() {
|
||||
const year = this.currentDate.getFullYear()
|
||||
const month = this.currentDate.getMonth()
|
||||
const firstDay = new Date(year, month, 1)
|
||||
const dayOfWeek = firstDay.getDay()
|
||||
const daysSinceMonday = dayOfWeek === 0 ? 6 : dayOfWeek - 1
|
||||
const gridStart = new Date(year, month, 1 - daysSinceMonday)
|
||||
|
||||
const result = []
|
||||
for (let w = 0; w < 6; w++) {
|
||||
const week = []
|
||||
for (let d = 0; d < 7; d++) {
|
||||
const date = new Date(gridStart)
|
||||
date.setDate(gridStart.getDate() + w * 7 + d)
|
||||
week.push(date)
|
||||
}
|
||||
result.push(week)
|
||||
}
|
||||
return result
|
||||
},
|
||||
effectiveStart() {
|
||||
return this.rangeSelect ? (this.startDate || this.innerStartDate) : ''
|
||||
},
|
||||
effectiveEnd() {
|
||||
return this.rangeSelect ? (this.endDate || this.innerEndDate) : ''
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
value: {
|
||||
immediate: true,
|
||||
handler(val) {
|
||||
if (val) {
|
||||
const d = this.parseDate(val)
|
||||
if (d) {
|
||||
this.currentDate = new Date(d.getFullYear(), d.getMonth(), 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
startDate(val) {
|
||||
if (val) this.innerStartDate = val
|
||||
},
|
||||
endDate(val) {
|
||||
if (val) this.innerEndDate = val
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.syncThemeConfig()
|
||||
this.watchThemeChange()
|
||||
if (this.value) {
|
||||
const d = this.parseDate(this.value)
|
||||
if (d) {
|
||||
this.currentDate = new Date(d.getFullYear(), d.getMonth(), 1)
|
||||
}
|
||||
}
|
||||
if (this.startDate) this.innerStartDate = this.startDate
|
||||
if (this.endDate) this.innerEndDate = this.endDate
|
||||
},
|
||||
beforeUnmount() {
|
||||
if (this.themeChangeHandler) {
|
||||
uni.$off(THEME_CHANGE_EVENT, this.themeChangeHandler)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
formatDate(date) {
|
||||
const y = date.getFullYear()
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const d = String(date.getDate()).padStart(2, '0')
|
||||
return `${y}-${m}-${d}`
|
||||
},
|
||||
parseDate(str) {
|
||||
if (!str) return null
|
||||
const parts = str.split('-')
|
||||
if (parts.length !== 3) return null
|
||||
return new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2]))
|
||||
},
|
||||
isSameDay(d1, d2) {
|
||||
return d1.getFullYear() === d2.getFullYear() &&
|
||||
d1.getMonth() === d2.getMonth() &&
|
||||
d1.getDate() === d2.getDate()
|
||||
},
|
||||
isToday(date) {
|
||||
return this.isSameDay(date, new Date())
|
||||
},
|
||||
isCurrentMonth(date) {
|
||||
return date.getFullYear() === this.currentYear &&
|
||||
date.getMonth() === this.currentMonth - 1
|
||||
},
|
||||
isMarked(date) {
|
||||
const str = this.formatDate(date)
|
||||
return this.markDates.indexOf(str) !== -1
|
||||
},
|
||||
isDisabled(date) {
|
||||
const str = this.formatDate(date)
|
||||
if (this.minDate && str < this.minDate) return true
|
||||
if (this.maxDate && str > this.maxDate) return true
|
||||
return false
|
||||
},
|
||||
getDayClass(date) {
|
||||
const str = this.formatDate(date)
|
||||
const classes = {}
|
||||
if (!this.isCurrentMonth(date)) classes['day-item--other-month'] = true
|
||||
if (this.isToday(date)) classes['day-item--today'] = true
|
||||
if (this.isDisabled(date)) classes['day-item--disabled'] = true
|
||||
|
||||
if (this.rangeSelect) {
|
||||
if (this.effectiveStart && this.effectiveEnd) {
|
||||
if (str === this.effectiveStart || str === this.effectiveEnd) {
|
||||
classes['day-item--selected'] = true
|
||||
} else if (str > this.effectiveStart && str < this.effectiveEnd) {
|
||||
classes['day-item--in-range'] = true
|
||||
}
|
||||
} else if (this.effectiveStart && str === this.effectiveStart) {
|
||||
classes['day-item--selected'] = true
|
||||
}
|
||||
} else {
|
||||
if (this.value && str === this.value) {
|
||||
classes['day-item--selected'] = true
|
||||
}
|
||||
}
|
||||
|
||||
if (this.isMarked(date) && this.markType === 'bg') {
|
||||
classes['day-item--mark-bg'] = true
|
||||
}
|
||||
|
||||
return classes
|
||||
},
|
||||
getDayStyle(date) {
|
||||
const styles = {}
|
||||
if (this.isToday(date)) {
|
||||
styles.borderColor = this.themeConfig.primaryColor
|
||||
}
|
||||
if (this.isMarked(date) && this.markType === 'bg') {
|
||||
styles.backgroundColor = this.themeConfig.primaryColor + '26'
|
||||
}
|
||||
if (!this.isCurrentMonth(date)) {
|
||||
styles.color = this.themeConfig.textDisabled
|
||||
}
|
||||
return styles
|
||||
},
|
||||
onDateClick(date) {
|
||||
if (this.isDisabled(date)) return
|
||||
const str = this.formatDate(date)
|
||||
|
||||
if (this.rangeSelect) {
|
||||
if (!this.innerStartDate || (this.innerStartDate && this.innerEndDate)) {
|
||||
this.innerStartDate = str
|
||||
this.innerEndDate = ''
|
||||
} else {
|
||||
if (str <= this.innerStartDate) {
|
||||
this.innerEndDate = this.innerStartDate
|
||||
this.innerStartDate = str
|
||||
} else {
|
||||
this.innerEndDate = str
|
||||
}
|
||||
this.$emit('rangeChange', {
|
||||
start: this.innerStartDate,
|
||||
end: this.innerEndDate
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
this.$emit('change', str)
|
||||
this.$emit('input', str)
|
||||
},
|
||||
prevMonth() {
|
||||
let y = this.currentYear
|
||||
let m = this.currentMonth - 2
|
||||
if (m < 0) {
|
||||
m = 11
|
||||
y--
|
||||
}
|
||||
this.currentDate = new Date(y, m, 1)
|
||||
},
|
||||
nextMonth() {
|
||||
let y = this.currentYear
|
||||
let m = this.currentMonth
|
||||
if (m > 11) {
|
||||
m = 0
|
||||
y++
|
||||
}
|
||||
this.currentDate = new Date(y, m, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.inline-calendar {
|
||||
padding: 24rpx;
|
||||
user-select: none;
|
||||
|
||||
.calendar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
width: 60rpx;
|
||||
height: 60rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 14rpx;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.nav-btn:active {
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
font-size: 40rpx;
|
||||
color: v-bind('themeConfig.textSecondary');
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.month-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: v-bind('themeConfig.textPrimary');
|
||||
}
|
||||
|
||||
.weekday-header {
|
||||
display: flex;
|
||||
gap: 8rpx;
|
||||
margin: 0 auto 16rpx;
|
||||
width: 552rpx; /* 与 .day-row 7*72 + 6*8 保持一致 */
|
||||
}
|
||||
|
||||
.weekday-cell {
|
||||
width: 72rpx;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 36rpx;
|
||||
border-radius: 8rpx;
|
||||
background-color: v-bind('themeConfig.bgColor');
|
||||
}
|
||||
|
||||
.weekday-text {
|
||||
font-size: 24rpx;
|
||||
font-weight: 600;
|
||||
color: v-bind('themeConfig.textSecondary');
|
||||
letter-spacing: 1rpx;
|
||||
}
|
||||
|
||||
.weekday-text.is-weekend {
|
||||
color: v-bind('themeConfig.primaryColor');
|
||||
}
|
||||
|
||||
.days-grid {
|
||||
display: table;
|
||||
flex-direction: column;
|
||||
gap: 8rpx;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.day-row {
|
||||
display: flex;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.day-item {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
border-radius: 10rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
background-color: transparent;
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.day-number {
|
||||
font-size: 28rpx;
|
||||
color: v-bind('themeConfig.textPrimary');
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.day-item--other-month {
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
.day-item--other-month .day-number {
|
||||
color: v-bind('themeConfig.textDisabled');
|
||||
}
|
||||
|
||||
.day-item--today {
|
||||
border: 2rpx solid transparent;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.day-item--today .day-number {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.day-item--selected {
|
||||
background: v-bind('themeConfig.gradientSoft');
|
||||
}
|
||||
|
||||
.day-item--selected .day-number {
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.day-item--in-range {
|
||||
background-color: v-bind('themeConfig.primaryColor + "1A"');
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.day-item--in-range .day-number {
|
||||
color: v-bind('themeConfig.primaryColor');
|
||||
}
|
||||
|
||||
.day-item--disabled {
|
||||
opacity: 0.3;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.day-item--mark-bg {
|
||||
background-color: v-bind('themeConfig.primaryColor + "26"');
|
||||
}
|
||||
|
||||
.mark-dot {
|
||||
position: absolute;
|
||||
bottom: 8rpx;
|
||||
width: 8rpx;
|
||||
height: 8rpx;
|
||||
border-radius: 50%;
|
||||
background-color: v-bind('themeConfig.primaryColor');
|
||||
}
|
||||
|
||||
.day-item--selected .mark-dot {
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.day-item--in-range .mark-dot {
|
||||
background-color: v-bind('themeConfig.primaryColor');
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,160 @@
|
||||
<template>
|
||||
<view
|
||||
class="page-header"
|
||||
:class="{ 'is-transparent': transparent }"
|
||||
:style="headerStyle"
|
||||
>
|
||||
<view class="header-left">
|
||||
<view
|
||||
v-if="showBack"
|
||||
class="back-btn btn btn--icon"
|
||||
@click="handleBack"
|
||||
>
|
||||
<FaIcon icon="chevron-left" :size="22" :color="themeConfig.textPrimary" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="header-center">
|
||||
<text class="header-title" :style="{ color: themeConfig.textPrimary }">{{ title }}</text>
|
||||
<text v-if="subtitle" class="header-subtitle" :style="{ color: themeConfig.textSecondary }">{{ subtitle }}</text>
|
||||
</view>
|
||||
|
||||
<view class="header-right">
|
||||
<slot v-if="showRight" name="right"></slot>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import themeMixin from '@/mixins/themeMixin.js'
|
||||
import { getThemeConfigFromStorage, THEME_CHANGE_EVENT, THEME_PRESETS, DEFAULT_THEME_KEY } from '@/utils/theme'
|
||||
import FaIcon from '@/components/FaIcon.vue'
|
||||
|
||||
export default {
|
||||
name: 'PageHeader',
|
||||
components: { FaIcon },
|
||||
mixins: [themeMixin],
|
||||
props: {
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
subtitle: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
showBack: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
showRight: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
transparent: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
headerStyle() {
|
||||
if (this.transparent) {
|
||||
return {}
|
||||
}
|
||||
return {
|
||||
backgroundColor: this.themeConfig.bgColor,
|
||||
borderBottom: `2rpx solid ${this.themeConfig.borderLight}`
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleBack() {
|
||||
this.$emit('back')
|
||||
if (!this.$listeners || !this.$listeners.back) {
|
||||
uni.navigateBack()
|
||||
}
|
||||
},
|
||||
handleThemeChange(themeData) {
|
||||
let newTheme = null
|
||||
if (themeData && themeData.key && THEME_PRESETS[themeData.key]) {
|
||||
newTheme = THEME_PRESETS[themeData.key]
|
||||
} else if (themeData && themeData.config) {
|
||||
newTheme = themeData.config
|
||||
}
|
||||
if (newTheme) {
|
||||
this.themeConfig = newTheme
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
const appInstance = getApp({ allowDefault: true })
|
||||
const globalTheme = appInstance && appInstance.globalData && appInstance.globalData.themeConfig
|
||||
this.themeConfig = globalTheme || getThemeConfigFromStorage() || THEME_PRESETS[DEFAULT_THEME_KEY]
|
||||
this.themeChangeHandler = this.handleThemeChange.bind(this)
|
||||
uni.$on(THEME_CHANGE_EVENT, this.themeChangeHandler)
|
||||
},
|
||||
beforeDestroy() {
|
||||
uni.$off(THEME_CHANGE_EVENT, this.themeChangeHandler)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 24rpx 36rpx;
|
||||
padding-top: calc(24rpx + constant(safe-area-inset-top));
|
||||
padding-top: calc(24rpx + env(safe-area-inset-top));
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
min-height: 120rpx;
|
||||
}
|
||||
|
||||
.page-header.is-transparent {
|
||||
background: transparent !important;
|
||||
border-bottom: none !important;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
width: 68rpx;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
|
||||
.back-btn:active {
|
||||
transform: scale(0.93);
|
||||
}
|
||||
|
||||
.header-center {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4rpx;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 36rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.header-subtitle {
|
||||
font-size: 24rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
width: 68rpx;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<view class="progress-bar">
|
||||
<view
|
||||
class="progress-track"
|
||||
:style="trackStyle"
|
||||
>
|
||||
<view
|
||||
class="progress-fill"
|
||||
:style="fillStyle"
|
||||
></view>
|
||||
</view>
|
||||
<text
|
||||
v-if="showText"
|
||||
class="progress-text"
|
||||
:style="textStyle"
|
||||
>{{ clampedPercent }}%</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import themeMixin from '@/mixins/themeMixin.js'
|
||||
|
||||
export default {
|
||||
name: 'ProgressBar',
|
||||
mixins: [themeMixin],
|
||||
props: {
|
||||
percent: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
height: {
|
||||
type: Number,
|
||||
default: 12
|
||||
},
|
||||
showText: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
trackColor: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
animated: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'gradient'
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
clampedPercent() {
|
||||
return Math.max(0, Math.min(100, this.percent))
|
||||
},
|
||||
statusColor() {
|
||||
if (this.color) return this.color
|
||||
if (this.clampedPercent >= 100) return this.themeConfig.dangerColor
|
||||
if (this.clampedPercent > 80) return this.themeConfig.warningColor
|
||||
return this.themeConfig.primaryColor
|
||||
},
|
||||
trackStyle() {
|
||||
return {
|
||||
height: this.height + 'rpx',
|
||||
backgroundColor: this.trackColor || this.themeConfig.borderLight
|
||||
}
|
||||
},
|
||||
fillStyle() {
|
||||
const style = {
|
||||
width: this.clampedPercent + '%',
|
||||
transition: this.animated ? 'width 0.4s ease' : 'none'
|
||||
}
|
||||
if (this.type === 'gradient') {
|
||||
style.background = `linear-gradient(90deg, ${this.statusColor}, ${this.themeConfig.gradientEnd})`
|
||||
} else {
|
||||
style.backgroundColor = this.statusColor
|
||||
}
|
||||
return style
|
||||
},
|
||||
textStyle() {
|
||||
return {
|
||||
color: this.themeConfig.textSecondary
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.progress-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.progress-track {
|
||||
flex: 1;
|
||||
border-radius: 100rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
border-radius: 100rpx;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
font-size: 22rpx;
|
||||
margin-left: 16rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,465 @@
|
||||
<template>
|
||||
<view class="skeleton-screen" :style="screenStyle">
|
||||
<block v-if="type === 'list'">
|
||||
<view
|
||||
v-for="n in count"
|
||||
:key="'list-' + n"
|
||||
class="skeleton-list-item"
|
||||
:style="cardStyle"
|
||||
>
|
||||
<view class="skeleton-avatar" :style="shimmerStyle"></view>
|
||||
<view class="skeleton-content">
|
||||
<view class="skeleton-title" :style="shimmerStyle"></view>
|
||||
<view class="skeleton-subtitle" :style="shimmerStyle"></view>
|
||||
<view class="skeleton-subtitle skeleton-subtitle--short" :style="shimmerStyle"></view>
|
||||
<view class="skeleton-tags">
|
||||
<view class="skeleton-tag" :style="shimmerStyle"></view>
|
||||
<view class="skeleton-tag" :style="shimmerStyle"></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<block v-else-if="type === 'card'">
|
||||
<view class="skeleton-card" :style="cardStyle">
|
||||
<view class="skeleton-card-header">
|
||||
<view class="skeleton-title skeleton-title--lg" :style="shimmerStyle"></view>
|
||||
<view class="skeleton-subtitle" :style="shimmerStyle"></view>
|
||||
</view>
|
||||
<view class="skeleton-card-body">
|
||||
<view class="skeleton-line" :style="shimmerStyle"></view>
|
||||
<view class="skeleton-line" :style="shimmerStyle"></view>
|
||||
<view class="skeleton-line skeleton-line--short" :style="shimmerStyle"></view>
|
||||
</view>
|
||||
<view class="skeleton-card-footer">
|
||||
<view class="skeleton-line skeleton-line--footer" :style="shimmerStyle"></view>
|
||||
<view class="skeleton-line skeleton-line--footer" :style="shimmerStyle"></view>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<block v-else-if="type === 'stats'">
|
||||
<view
|
||||
v-for="n in count"
|
||||
:key="'stats-' + n"
|
||||
class="skeleton-stats"
|
||||
:style="cardStyle"
|
||||
>
|
||||
<view class="skeleton-stat-col">
|
||||
<view class="skeleton-stat-value" :style="shimmerStyle"></view>
|
||||
<view class="skeleton-stat-label" :style="shimmerStyle"></view>
|
||||
</view>
|
||||
<view class="skeleton-stat-col">
|
||||
<view class="skeleton-stat-value" :style="shimmerStyle"></view>
|
||||
<view class="skeleton-stat-label" :style="shimmerStyle"></view>
|
||||
</view>
|
||||
<view class="skeleton-stat-col">
|
||||
<view class="skeleton-stat-value" :style="shimmerStyle"></view>
|
||||
<view class="skeleton-stat-label" :style="shimmerStyle"></view>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<block v-else-if="type === 'dashboard'">
|
||||
<view class="skeleton-dashboard" :style="cardStyle">
|
||||
<view class="skeleton-ring-wrapper">
|
||||
<view class="skeleton-ring" :style="ringStyle"></view>
|
||||
<view class="skeleton-ring-inner" :style="shimmerStyle"></view>
|
||||
</view>
|
||||
<view class="skeleton-dashboard-stats">
|
||||
<view class="skeleton-stat-col">
|
||||
<view class="skeleton-stat-value" :style="shimmerStyle"></view>
|
||||
<view class="skeleton-stat-label" :style="shimmerStyle"></view>
|
||||
</view>
|
||||
<view class="skeleton-stat-col">
|
||||
<view class="skeleton-stat-value" :style="shimmerStyle"></view>
|
||||
<view class="skeleton-stat-label" :style="shimmerStyle"></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view
|
||||
v-for="n in dashboardListCount"
|
||||
:key="'dash-list-' + n"
|
||||
class="skeleton-list-item skeleton-list-item--compact"
|
||||
:style="cardStyle"
|
||||
>
|
||||
<view class="skeleton-dot" :style="shimmerStyle"></view>
|
||||
<view class="skeleton-content">
|
||||
<view class="skeleton-title" :style="shimmerStyle"></view>
|
||||
<view class="skeleton-subtitle" :style="shimmerStyle"></view>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<block v-else-if="type === 'form'">
|
||||
<view class="skeleton-form" :style="cardStyle">
|
||||
<view
|
||||
v-for="n in count"
|
||||
:key="'form-' + n"
|
||||
class="skeleton-form-field"
|
||||
>
|
||||
<view class="skeleton-label" :style="shimmerStyle"></view>
|
||||
<view class="skeleton-input" :style="shimmerStyle"></view>
|
||||
</view>
|
||||
<view class="skeleton-form-actions">
|
||||
<view class="skeleton-btn skeleton-btn--primary" :style="shimmerStyle"></view>
|
||||
<view class="skeleton-btn skeleton-btn--secondary" :style="shimmerStyle"></view>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<block v-else-if="type === 'custom'">
|
||||
<view class="skeleton-custom" :style="cardStyle">
|
||||
<slot></slot>
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import themeMixin from '@/mixins/themeMixin.js'
|
||||
import { THEME_CHANGE_EVENT } from '@/utils/theme'
|
||||
|
||||
export default {
|
||||
name: 'SkeletonScreen',
|
||||
mixins: [themeMixin],
|
||||
props: {
|
||||
type: {
|
||||
type: String,
|
||||
default: 'list',
|
||||
validator(val) {
|
||||
return ['list', 'card', 'stats', 'dashboard', 'form', 'custom'].indexOf(val) !== -1
|
||||
}
|
||||
},
|
||||
count: {
|
||||
type: Number,
|
||||
default: 3
|
||||
},
|
||||
animated: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
screenStyle() {
|
||||
return {
|
||||
backgroundColor: this.themeConfig.bgColor
|
||||
}
|
||||
},
|
||||
cardStyle() {
|
||||
return {
|
||||
backgroundColor: this.themeConfig.cardBgColor,
|
||||
borderRadius: '20rpx',
|
||||
padding: '24rpx',
|
||||
marginBottom: '20rpx'
|
||||
}
|
||||
},
|
||||
shimmerStyle() {
|
||||
if (!this.animated) {
|
||||
return {
|
||||
backgroundColor: this.shimmerBaseColor
|
||||
}
|
||||
}
|
||||
return {
|
||||
background: 'linear-gradient(90deg, ' +
|
||||
this.shimmerBaseColor + ' 25%, ' +
|
||||
this.shimmerHighlightColor + ' 50%, ' +
|
||||
this.shimmerBaseColor + ' 75%)',
|
||||
backgroundSize: '200% 100%',
|
||||
animation: 'skeleton-shimmer 1.5s infinite'
|
||||
}
|
||||
},
|
||||
ringStyle() {
|
||||
if (!this.animated) {
|
||||
return {
|
||||
border: '12rpx solid ' + this.shimmerBaseColor
|
||||
}
|
||||
}
|
||||
return {
|
||||
border: '12rpx solid transparent',
|
||||
borderTopColor: this.shimmerBaseColor,
|
||||
animation: 'skeleton-shimmer-ring 1.2s infinite linear'
|
||||
}
|
||||
},
|
||||
shimmerBaseColor() {
|
||||
return this.themeConfig.borderLight || '#f0f0f0'
|
||||
},
|
||||
shimmerHighlightColor() {
|
||||
return this.lightenColor(this.shimmerBaseColor, 15)
|
||||
},
|
||||
dashboardListCount() {
|
||||
return Math.min(this.count, 3)
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.syncThemeConfig()
|
||||
this.watchThemeChange()
|
||||
},
|
||||
beforeUnmount() {
|
||||
if (this.themeChangeHandler) {
|
||||
uni.$off(THEME_CHANGE_EVENT, this.themeChangeHandler)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
lightenColor(hex, percent) {
|
||||
if (!hex || hex.charAt(0) !== '#') return hex
|
||||
const num = parseInt(hex.slice(1), 16)
|
||||
const r = Math.min(255, ((num >> 16) & 0xff) + Math.round(255 * percent / 100))
|
||||
const g = Math.min(255, ((num >> 8) & 0xff) + Math.round(255 * percent / 100))
|
||||
const b = Math.min(255, (num & 0xff) + Math.round(255 * percent / 100))
|
||||
return 'rgb(' + r + ', ' + g + ', ' + b + ')'
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.skeleton-screen {
|
||||
min-height: 100vh;
|
||||
padding: 24rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.skeleton-list-item {
|
||||
display: flex;
|
||||
padding: 24rpx;
|
||||
border-radius: 20rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.skeleton-avatar {
|
||||
width: 100rpx;
|
||||
height: 100rpx;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.skeleton-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-left: 20rpx;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.skeleton-title {
|
||||
height: 36rpx;
|
||||
width: 60%;
|
||||
border-radius: 8rpx;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.skeleton-title--lg {
|
||||
height: 44rpx;
|
||||
width: 80%;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.skeleton-subtitle {
|
||||
height: 28rpx;
|
||||
width: 50%;
|
||||
border-radius: 8rpx;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.skeleton-subtitle--short {
|
||||
width: 35%;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.skeleton-tags {
|
||||
display: flex;
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
.skeleton-tag {
|
||||
height: 40rpx;
|
||||
width: 80rpx;
|
||||
border-radius: 20rpx;
|
||||
margin-right: 16rpx;
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
padding: 28rpx;
|
||||
border-radius: 20rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.skeleton-card-header {
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.skeleton-card-body {
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.skeleton-line {
|
||||
height: 28rpx;
|
||||
width: 100%;
|
||||
border-radius: 8rpx;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.skeleton-line--short {
|
||||
width: 70%;
|
||||
}
|
||||
|
||||
.skeleton-card-footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.skeleton-line--footer {
|
||||
width: 30%;
|
||||
margin-right: 24rpx;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.skeleton-stats {
|
||||
display: flex;
|
||||
padding: 32rpx 24rpx;
|
||||
border-radius: 20rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.skeleton-stat-col {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.skeleton-stat-value {
|
||||
height: 48rpx;
|
||||
width: 80%;
|
||||
border-radius: 8rpx;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.skeleton-stat-label {
|
||||
height: 24rpx;
|
||||
width: 60%;
|
||||
border-radius: 6rpx;
|
||||
}
|
||||
|
||||
.skeleton-dashboard {
|
||||
padding: 32rpx;
|
||||
border-radius: 20rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.skeleton-ring-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 24rpx 0 32rpx;
|
||||
position: relative;
|
||||
width: 280rpx;
|
||||
height: 280rpx;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.skeleton-ring {
|
||||
width: 240rpx;
|
||||
height: 240rpx;
|
||||
border-radius: 50%;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.skeleton-ring-inner {
|
||||
width: 180rpx;
|
||||
height: 180rpx;
|
||||
border-radius: 50%;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.skeleton-dashboard-stats {
|
||||
display: flex;
|
||||
padding-top: 24rpx;
|
||||
border-top: 2rpx solid;
|
||||
}
|
||||
|
||||
.skeleton-list-item--compact {
|
||||
padding: 20rpx 24rpx;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.skeleton-dot {
|
||||
width: 16rpx;
|
||||
height: 16rpx;
|
||||
border-radius: 50%;
|
||||
margin-right: 20rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.skeleton-form {
|
||||
padding: 28rpx;
|
||||
border-radius: 20rpx;
|
||||
}
|
||||
|
||||
.skeleton-form-field {
|
||||
margin-bottom: 28rpx;
|
||||
}
|
||||
|
||||
.skeleton-label {
|
||||
height: 24rpx;
|
||||
width: 30%;
|
||||
border-radius: 6rpx;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.skeleton-input {
|
||||
height: 80rpx;
|
||||
width: 100%;
|
||||
border-radius: 12rpx;
|
||||
}
|
||||
|
||||
.skeleton-form-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 32rpx;
|
||||
}
|
||||
|
||||
.skeleton-btn {
|
||||
height: 80rpx;
|
||||
border-radius: 12rpx;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.skeleton-btn--primary {
|
||||
margin-right: 24rpx;
|
||||
}
|
||||
|
||||
.skeleton-btn--secondary {
|
||||
flex: 0.6;
|
||||
}
|
||||
|
||||
.skeleton-custom {
|
||||
padding: 24rpx;
|
||||
border-radius: 20rpx;
|
||||
}
|
||||
|
||||
@keyframes skeleton-shimmer {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes skeleton-shimmer-ring {
|
||||
0% {
|
||||
transform: translate(-50%, -50%) rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: translate(-50%, -50%) rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,176 @@
|
||||
<template>
|
||||
<view class="swipeable-item">
|
||||
<view
|
||||
class="swipeable-item__inner"
|
||||
:style="{ transform: `translateX(${translateX}px)`, transition: animating ? 'transform 0.3s ease' : 'none' }"
|
||||
@touchstart="onTouchStart"
|
||||
@touchmove="onTouchMove"
|
||||
@touchend="onTouchEnd"
|
||||
@touchcancel="onTouchEnd"
|
||||
>
|
||||
<view
|
||||
class="swipeable-item__main"
|
||||
:style="{ backgroundColor: themeConfig.cardBgColor }"
|
||||
@click="onMainClick"
|
||||
>
|
||||
<slot></slot>
|
||||
</view>
|
||||
<view class="swipeable-item__actions">
|
||||
<view
|
||||
v-for="action in actions"
|
||||
:key="action.key"
|
||||
class="swipeable-item__action"
|
||||
:style="{ backgroundColor: getActionColor(action) }"
|
||||
@click.stop="onActionClick(action)"
|
||||
>
|
||||
<text class="swipeable-item__action-text">{{ action.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import themeMixin from '@/mixins/themeMixin.js'
|
||||
import { THEME_CHANGE_EVENT } from '@/utils/theme.js'
|
||||
|
||||
export default {
|
||||
name: 'SwipeableItem',
|
||||
mixins: [themeMixin],
|
||||
props: {
|
||||
actions: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
autoClose: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
index: {
|
||||
type: [Number, String],
|
||||
default: 0
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
translateX: 0,
|
||||
animating: false,
|
||||
isOpen: false,
|
||||
startX: 0,
|
||||
thresholdPx: 0,
|
||||
maxOffsetPx: 0
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
actionColorMap() {
|
||||
return {
|
||||
edit: this.themeConfig.primaryColor,
|
||||
delete: this.themeConfig.dangerColor,
|
||||
done: this.themeConfig.successColor
|
||||
}
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.syncThemeConfig()
|
||||
this.watchThemeChange()
|
||||
this.thresholdPx = uni.upx2px(80)
|
||||
this.maxOffsetPx = uni.upx2px(this.actions.length * 160)
|
||||
},
|
||||
beforeUnmount() {
|
||||
if (this.themeChangeHandler) {
|
||||
uni.$off(THEME_CHANGE_EVENT, this.themeChangeHandler)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getActionColor(action) {
|
||||
if (action.color) return action.color
|
||||
return this.actionColorMap[action.key] || this.themeConfig.primaryColor
|
||||
},
|
||||
onTouchStart(e) {
|
||||
if (this.disabled) return
|
||||
this.startX = e.touches[0].clientX
|
||||
this.animating = false
|
||||
},
|
||||
onTouchMove(e) {
|
||||
if (this.disabled) return
|
||||
const currentX = e.touches[0].clientX
|
||||
const delta = currentX - this.startX
|
||||
this.translateX = Math.max(-this.maxOffsetPx, Math.min(0, delta))
|
||||
},
|
||||
onTouchEnd() {
|
||||
if (this.disabled) return
|
||||
this.animating = true
|
||||
if (this.translateX < -this.thresholdPx) {
|
||||
this.open()
|
||||
} else {
|
||||
this.close()
|
||||
}
|
||||
},
|
||||
open() {
|
||||
this.translateX = -this.maxOffsetPx
|
||||
this.isOpen = true
|
||||
this.$emit('open', this.index)
|
||||
this.$emit('closeOthers', this.index)
|
||||
},
|
||||
close() {
|
||||
this.translateX = 0
|
||||
this.isOpen = false
|
||||
this.$emit('close', this.index)
|
||||
},
|
||||
onMainClick() {
|
||||
if (this.isOpen) {
|
||||
this.close()
|
||||
}
|
||||
},
|
||||
onActionClick(action) {
|
||||
this.$emit('action', action.key)
|
||||
if (this.autoClose) {
|
||||
this.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.swipeable-item {
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.swipeable-item__inner {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.swipeable-item__main {
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.swipeable-item__actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.swipeable-item__action {
|
||||
width: 160rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.swipeable-item__action-text {
|
||||
font-size: 24rpx;
|
||||
color: #ffffff;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<view class="title-badge" :style="{ background: badgeGradient }">
|
||||
<text class="badge-icon">{{ icon }}</text>
|
||||
<text class="badge-title">{{ title }}</text>
|
||||
<text class="badge-level">Lv.{{ level }}</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import themeMixin from '../mixins/themeMixin.js'
|
||||
import { THEME_CHANGE_EVENT } from '../utils/theme.js'
|
||||
|
||||
export default {
|
||||
name: 'TitleBadge',
|
||||
mixins: [themeMixin],
|
||||
props: {
|
||||
level: {
|
||||
type: Number,
|
||||
default: 1
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.syncThemeConfig()
|
||||
this.watchThemeChange()
|
||||
},
|
||||
beforeUnmount() {
|
||||
if (this.themeChangeHandler) {
|
||||
uni.$off(THEME_CHANGE_EVENT, this.themeChangeHandler)
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
badgeGradient() {
|
||||
const theme = this.themeConfig
|
||||
return `linear-gradient(135deg, ${theme.primaryColor}, ${theme.secondaryColor})`
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.title-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 8rpx 24rpx;
|
||||
border-radius: 12rpx;
|
||||
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.15);
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.badge-icon {
|
||||
font-size: 32rpx;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.badge-title {
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.badge-level {
|
||||
font-size: 22rpx;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
padding: 2rpx 10rpx;
|
||||
border-radius: 20rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,173 @@
|
||||
<template>
|
||||
<view class="today-progress">
|
||||
<view class="progress-header">
|
||||
<FaIcon icon="chart-column" :size="22" color="#ff9f43" />
|
||||
<text class="progress-title">今日进度</text>
|
||||
<view class="exp-display">
|
||||
<text class="exp-current">{{ todayProgress.totalExp }}</text>
|
||||
<text class="exp-separator">/</text>
|
||||
<text class="exp-max">{{ todayProgress.maxDailyExp }}</text>
|
||||
<text class="exp-unit">EXP</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="progress-stats">
|
||||
<view class="stat-item">
|
||||
<FaIcon icon="list-check" :size="22" color="#4cb8c6" />
|
||||
<text class="stat-value">{{ todayProgress.tasksCompleted }}</text>
|
||||
<text class="stat-label">任务完成</text>
|
||||
</view>
|
||||
<view class="stat-divider"></view>
|
||||
<view class="stat-item">
|
||||
<FaIcon icon="money-bill" :size="22" color="#ff9f43" />
|
||||
<text class="stat-value">{{ todayProgress.billsRecorded }}</text>
|
||||
<text class="stat-label">记账记录</text>
|
||||
</view>
|
||||
<view class="stat-divider"></view>
|
||||
<view class="stat-item">
|
||||
<FaIcon :icon="todayProgress.moodRecorded ? 'face-smile' : 'calendar-xmark'" :size="22" :color="todayProgress.moodRecorded ? '#52c41a' : '#999'" />
|
||||
<view class="stat-value" v-if="todayProgress.moodRecorded">
|
||||
<FaIcon icon="check" :size="16" color="#52c41a" />
|
||||
</view>
|
||||
<text class="stat-value" v-else>-</text>
|
||||
<text class="stat-label">心情记录</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="bonus-info">
|
||||
<view class="bonus-item">
|
||||
<FaIcon icon="fire" :size="14" color="#ff9f43" />
|
||||
<text>连续加成: +{{ todayProgress.streakBonus }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import FaIcon from '@/components/FaIcon.vue'
|
||||
|
||||
export default {
|
||||
name: 'TodayProgress',
|
||||
components: { FaIcon },
|
||||
props: {
|
||||
todayProgress: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
tasksCompleted: 0,
|
||||
billsRecorded: 0,
|
||||
moodRecorded: false,
|
||||
streakBonus: 0,
|
||||
totalExp: 0,
|
||||
maxDailyExp: 120
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.today-progress {
|
||||
background: #fff;
|
||||
border-radius: 12rpx;
|
||||
padding: 28rpx;
|
||||
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.progress-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 24rpx;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.progress-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.progress-title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.exp-display {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 4rpx;
|
||||
}
|
||||
|
||||
.exp-current {
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
color: #ff9f43;
|
||||
}
|
||||
|
||||
.exp-separator {
|
||||
font-size: 20rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.exp-max {
|
||||
font-size: 20rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.exp-unit {
|
||||
font-size: 18rpx;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.progress-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 34rpx;
|
||||
font-weight: 700;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 22rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.stat-divider {
|
||||
width: 1px;
|
||||
height: 48rpx;
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.bonus-info {
|
||||
margin-top: 20rpx;
|
||||
padding-top: 20rpx;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.06);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.bonus-item {
|
||||
font-size: 22rpx;
|
||||
color: #ff9f43;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6rpx;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user