新增黄金价格查看功能,通过定时任务每15分钟查询一次最新的黄金价格;修改账户余额和预算的修改位置。

This commit is contained in:
ni ziyi 2026-01-23 21:28:05 +08:00
parent 70715bb0c8
commit 0642ff72c1
24 changed files with 3797 additions and 1215 deletions

93
MINE_PAGE_README.md Normal file
View File

@ -0,0 +1,93 @@
# "我的"页面功能说明
## 功能概述
新增了"我的"页面,整合了以下功能:
- 用户信息展示
- 账户余额查看
- 预算进度展示
- 快捷功能入口
- 退出登录
## 页面结构
### 1. 用户信息卡片
- 显示用户头像emoji
- 显示用户昵称/用户名
- 显示用户ID
### 2. 账户余额卡片(可点击)
- 显示当前账户余额
- 显示总收入和总支出
- 点击可跳转到账户管理页面
### 3. 预算卡片(可点击)
- 显示本月预算进度条
- 显示预算金额、已用金额、剩余金额
- 点击可跳转到预算设置页面
### 4. 功能菜单
- 统计分析:跳转到统计页面
- 账户管理:跳转到账户管理页面
- 预算设置:跳转到预算设置页面
### 5. 退出登录按钮
- 点击后弹出确认对话框
- 确认后清除本地存储并跳转到登录页
## 技术特点
### 1. 滚动功能
- 使用 `<scroll-view>` 组件实现页面滚动
- 支持长内容的流畅滚动
- 底部留有安全区域,避免被导航栏遮挡
### 2. 样式设计
- 延续现有的金色渐变主题风格
- 使用 rpx 单位,适配不同屏幕尺寸
- 卡片式布局,清晰美观
- 添加阴影和圆角,提升视觉效果
### 3. 交互体验
- 卡片点击有视觉反馈(背景色变化)
- 按钮点击有缩放动画
- 进度条有动画效果
- 退出登录有二次确认
### 4. 数据加载
- 页面加载时自动获取用户信息、账户信息、预算信息
- 使用 computed 计算预算进度百分比
- 错误处理,避免数据加载失败导致页面崩溃
## 导航栏配置
`pages.json` 中添加了:
1. 页面路由配置
2. TabBar 配置(新增"我的"标签)
## 图标说明
临时使用了 home 图标作为占位符,建议后续替换为专门的"我的"图标。
详细说明请查看:`frontend/static/tabbar/ICON_GUIDE.md`
## 使用方法
1. 启动应用后底部导航栏会显示4个标签首页、记账、统计、我的
2. 点击"我的"标签即可进入该页面
3. 在页面中可以查看账户信息、预算信息,并快速跳转到相关功能页面
## 注意事项
1. 确保用户已登录,否则用户信息可能显示不完整
2. 账户和预算数据需要后端API支持
3. 图标文件需要替换为合适的"我的"图标
4. 页面使用了 `scroll-view`,确保内容可以正常滚动
## 后续优化建议
1. 添加更多个人设置选项(如修改密码、修改昵称等)
2. 添加数据统计概览(如本月记账天数、最大单笔支出等)
3. 添加主题切换功能
4. 添加关于页面和帮助文档入口
5. 优化用户头像,支持上传自定义头像

23
api/edit-modal.js Normal file
View File

@ -0,0 +1,23 @@
import { put } from '../utils/request'
/**
* 更新账户余额
* @param {number} initialBalance - 新的初始余额
* @returns {Promise}
*/
export function updateAccountBalance(initialBalance) {
return put('/accounts/balance', {
initialBalance
})
}
/**
* 更新当前月份预算
* @param {number} amount - 新的预算金额
* @returns {Promise}
*/
export function updateBudget(amount) {
return put('/budgets/current', {
amount
})
}

26
api/gold-price.js Normal file
View File

@ -0,0 +1,26 @@
import { get, post } from '../utils/request'
// 获取当前黄金价格
export function getCurrentGoldPrice(goldId = '1053') {
return get('/gold-price/current', { goldId })
}
// 获取历史黄金价格
export function getGoldPriceHistory(goldId = '1053', days = 30) {
return get('/gold-price/history', { goldId, days })
}
// 获取指定日期的黄金价格记录
export function getGoldPricesByDate(goldId = '1053', date) {
return get('/gold-price/by-date', { goldId, date })
}
// 获取最近有数据的日期
export function getLatestGoldPriceDate(goldId = '1053') {
return get('/gold-price/latest-date', { goldId })
}
// 立即刷新黄金价格
export function refreshGoldPrice(goldId = '1053') {
return post('/gold-price/refresh?goldId=' + goldId, {})
}

View File

@ -1,14 +1,17 @@
<template>
<view class="chart-container">
<view :id="chartId" class="echart-container"></view>
<view
:id="chartId"
class="echart-container"
:change:prop="echartModule.updateChart"
:prop="chartOption"
></view>
</view>
</template>
<script setup>
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
import * as echarts from 'echarts'
const props = defineProps({
<script>
export default {
props: {
budget: {
type: Number,
default: 0
@ -21,52 +24,81 @@ const props = defineProps({
type: String,
default: 'budgetPieChart'
}
})
let chartInstance = null
const chartData = computed(() => {
const total = props.budget || 1
const used = Math.min(props.used, total)
},
computed: {
chartOption() {
const total = this.budget || 1
const used = Math.min(this.used, total)
const remaining = Math.max(total - used, 0)
return {
budget: props.budget,
chartId: this.chartId,
used: used,
remaining: remaining
}
})
}
}
}
</script>
const initChart = () => {
// DOM
nextTick(() => {
// 使 uni.createSelectorQuery DOM
const query = uni.createSelectorQuery()
query.select(`#${props.chartId}`).boundingClientRect()
query.exec((res) => {
if (!res || !res[0]) {
console.error('Chart container not found')
<script module="echartModule" lang="renderjs">
const chartInstances = {}
export default {
mounted() {
// renderjs mounted
if (typeof window !== 'undefined') {
// echarts
this.loadEcharts()
}
},
methods: {
loadEcharts() {
//
if (window.echarts) {
return
}
// H5 使 document.getElementById
// #ifdef H5
const chartDom = document.getElementById(props.chartId)
if (chartDom) {
chartInstance = echarts.init(chartDom)
updateChart()
// echarts
const script = document.createElement('script')
script.src = 'https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js'
script.onload = () => {
console.log('ECharts loaded successfully')
}
// #endif
})
})
}
script.onerror = () => {
console.error('Failed to load ECharts')
}
document.head.appendChild(script)
},
updateChart(newValue, oldValue, ownerInstance, instance) {
// echarts
const checkEcharts = () => {
if (!window.echarts) {
setTimeout(checkEcharts, 100)
return
}
this.renderChart(newValue)
}
checkEcharts()
},
renderChart(option) {
if (!window.echarts || !option) return
const updateChart = () => {
if (!chartInstance) return
const chartDom = document.getElementById(option.chartId)
if (!chartDom) {
console.error('Chart container not found:', option.chartId)
return
}
const { used, remaining } = chartData.value
// ID
if (!chartInstances[option.chartId]) {
chartInstances[option.chartId] = window.echarts.init(chartDom)
}
const option = {
const chartInstance = chartInstances[option.chartId]
//
const chartOption = {
backgroundColor: 'transparent',
series: [
{
@ -82,18 +114,18 @@ const updateChart = () => {
show: true,
position: 'center',
formatter: function() {
return `{a|剩余预算}\n{b|¥${remaining.toFixed(2)}}`
return `{a|剩余预算}\n{b|¥${option.remaining.toFixed(2)}}`
},
rich: {
a: {
fontSize: 13,
color: 'rgba(255, 255, 255, 0.75)',
color: '#8D6E63',
lineHeight: 20,
fontWeight: 'normal'
},
b: {
fontSize: 17,
color: '#fff',
color: '#5D4037',
fontWeight: 'bold',
lineHeight: 32
}
@ -104,7 +136,7 @@ const updateChart = () => {
},
data: [
{
value: used,
value: option.used,
name: '已用',
itemStyle: {
color: {
@ -114,16 +146,16 @@ const updateChart = () => {
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: '#8673C3' },
{ offset: 1, color: '#8673C3' }
{ offset: 0, color: '#FA3534' },
{ offset: 1, color: '#FF6B6B' }
]
},
shadowBlur: 10,
shadowColor: 'rgba(255, 107, 107, 0.3)'
shadowColor: 'rgba(250, 53, 52, 0.3)'
}
},
{
value: remaining,
value: option.remaining,
name: '剩余',
itemStyle: {
color: {
@ -133,12 +165,12 @@ const updateChart = () => {
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: '#69db7c' },
{ offset: 1, color: '#51cf66' }
{ offset: 0, color: '#FFD700' },
{ offset: 1, color: '#F5D59E' }
]
},
shadowBlur: 10,
shadowColor: 'rgba(81, 207, 102, 0.3)'
shadowColor: 'rgba(255, 215, 0, 0.3)'
}
}
],
@ -158,25 +190,24 @@ const updateChart = () => {
]
}
chartInstance.setOption(option, true)
}
chartInstance.setOption(chartOption, true)
watch(() => [props.budget, props.used], () => {
updateChart()
}, { deep: true })
onMounted(() => {
//
setTimeout(() => {
initChart()
}, 200)
})
onBeforeUnmount(() => {
if (chartInstance) {
chartInstance.dispose()
chartInstance = null
chartInstance.resize()
}, 100)
}
})
},
beforeDestroy() {
//
Object.keys(chartInstances).forEach(key => {
if (chartInstances[key]) {
chartInstances[key].dispose()
delete chartInstances[key]
}
})
}
}
</script>
<style scoped>
@ -184,10 +215,16 @@ onBeforeUnmount(() => {
width: 280rpx;
height: 280rpx;
margin: 0 auto;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.echart-container {
width: 100%;
height: 100%;
min-height: 200px;
min-width: 200px;
}
</style>

View File

@ -0,0 +1,278 @@
<template>
<view v-if="visible" class="modal-overlay" @click="handleBackdropClick">
<view class="modal-content" @click.stop>
<view class="modal-header">
<text class="modal-title">{{ title }}</text>
<text class="modal-close" @click="handleCancel"></text>
</view>
<view class="modal-body">
<input
v-model="inputValue"
:type="inputType"
:placeholder="placeholder"
class="modal-input"
@input="handleInput"
/>
<text v-if="errorMessage" class="error-message">{{ errorMessage }}</text>
</view>
<view class="modal-footer">
<button class="btn-cancel" @click="handleCancel">取消</button>
<button class="btn-confirm" @click="handleConfirm" :disabled="loading">
{{ loading ? '加载中...' : '确认' }}
</button>
</view>
</view>
</view>
</template>
<script setup>
import { ref, watch } from 'vue'
const props = defineProps({
visible: {
type: Boolean,
default: false
},
title: {
type: String,
default: '编辑'
},
placeholder: {
type: String,
default: '请输入数值'
},
inputType: {
type: String,
default: 'number'
},
currentValue: {
type: [String, Number],
default: ''
}
})
const emit = defineEmits(['confirm', 'cancel'])
const inputValue = ref('')
const errorMessage = ref('')
const loading = ref(false)
// currentValue
watch(() => props.currentValue, (newVal) => {
inputValue.value = String(newVal || '')
errorMessage.value = ''
}, { immediate: true })
// visible
watch(() => props.visible, (newVal) => {
if (newVal) {
inputValue.value = String(props.currentValue || '')
errorMessage.value = ''
}
})
const validateInput = (value) => {
//
if (!value || value.trim() === '') {
errorMessage.value = '请输入有效的数值'
return false
}
//
const numValue = parseFloat(value)
//
if (isNaN(numValue)) {
errorMessage.value = '请输入数字'
return false
}
//
if (numValue < 0) {
errorMessage.value = '数值不能为负数'
return false
}
errorMessage.value = ''
return true
}
const formatDecimal = (value) => {
const numValue = parseFloat(value)
if (isNaN(numValue)) return value
return numValue.toFixed(2)
}
const handleInput = (event) => {
const value = event.detail.value
inputValue.value = value
//
if (value) {
validateInput(value)
} else {
errorMessage.value = ''
}
}
const handleConfirm = async () => {
if (!validateInput(inputValue.value)) {
return
}
loading.value = true
try {
const formattedValue = formatDecimal(inputValue.value)
emit('confirm', formattedValue)
} finally {
loading.value = false
}
}
const handleCancel = () => {
emit('cancel')
}
const handleBackdropClick = () => {
emit('cancel')
}
</script>
<style scoped>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: rgba(255, 255, 255, 0.98);
border-radius: 25rpx;
width: 80%;
max-width: 600rpx;
box-shadow: 0 20rpx 60rpx rgba(0, 0, 0, 0.3);
border: 3rpx solid #C8956E;
animation: slideUp 0.3s ease-out;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(50rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 30rpx;
border-bottom: 2rpx solid rgba(200, 149, 110, 0.2);
}
.modal-title {
font-size: 32rpx;
font-weight: bold;
color: #5D4037;
}
.modal-close {
font-size: 36rpx;
color: #C8956E;
cursor: pointer;
transition: transform 0.2s;
}
.modal-close:active {
transform: scale(1.2);
}
.modal-body {
padding: 30rpx;
}
.modal-input {
width: 100%;
height: 80rpx;
padding: 0 20rpx;
font-size: 28rpx;
border: 2rpx solid #C8956E;
border-radius: 15rpx;
background: rgba(255, 255, 255, 0.9);
color: #5D4037;
box-sizing: border-box;
transition: border-color 0.3s;
}
.modal-input:focus {
border-color: #FFD700;
outline: none;
}
.error-message {
display: block;
margin-top: 15rpx;
font-size: 24rpx;
color: #FA3534;
text-align: center;
}
.modal-footer {
display: flex;
gap: 20rpx;
padding: 30rpx;
border-top: 2rpx solid rgba(200, 149, 110, 0.2);
}
.btn-cancel,
.btn-confirm {
flex: 1;
height: 80rpx;
border-radius: 15rpx;
font-size: 28rpx;
font-weight: bold;
border: none;
cursor: pointer;
transition: all 0.3s;
}
.btn-cancel {
background: rgba(200, 149, 110, 0.2);
color: #5D4037;
border: 2rpx solid #C8956E;
}
.btn-cancel:active {
background: rgba(200, 149, 110, 0.3);
transform: scale(0.98);
}
.btn-confirm {
background: linear-gradient(135deg, #C8956E, #FFD700);
color: #fff;
border: 2rpx solid #C8956E;
}
.btn-confirm:active:not(:disabled) {
transform: scale(0.98);
box-shadow: 0 4rpx 12rpx rgba(200, 149, 110, 0.3);
}
.btn-confirm:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>

View File

@ -38,8 +38,42 @@
"<uses-permission android:name=\"android.permission.WRITE_EXTERNAL_STORAGE\"/>"
]
},
"ios" : {},
"sdkConfigs" : {}
"ios" : {
"dSYMs" : false
},
"sdkConfigs" : {},
"icons" : {
"android" : {
"hdpi" : "unpackage/res/icons/72x72.png",
"xhdpi" : "unpackage/res/icons/96x96.png",
"xxhdpi" : "unpackage/res/icons/144x144.png",
"xxxhdpi" : "unpackage/res/icons/192x192.png"
},
"ios" : {
"appstore" : "unpackage/res/icons/1024x1024.png",
"ipad" : {
"app" : "unpackage/res/icons/76x76.png",
"app@2x" : "unpackage/res/icons/152x152.png",
"notification" : "unpackage/res/icons/20x20.png",
"notification@2x" : "unpackage/res/icons/40x40.png",
"proapp@2x" : "unpackage/res/icons/167x167.png",
"settings" : "unpackage/res/icons/29x29.png",
"settings@2x" : "unpackage/res/icons/58x58.png",
"spotlight" : "unpackage/res/icons/40x40.png",
"spotlight@2x" : "unpackage/res/icons/80x80.png"
},
"iphone" : {
"app@2x" : "unpackage/res/icons/120x120.png",
"app@3x" : "unpackage/res/icons/180x180.png",
"notification@2x" : "unpackage/res/icons/40x40.png",
"notification@3x" : "unpackage/res/icons/60x60.png",
"settings@2x" : "unpackage/res/icons/58x58.png",
"settings@3x" : "unpackage/res/icons/87x87.png",
"spotlight@2x" : "unpackage/res/icons/80x80.png",
"spotlight@3x" : "unpackage/res/icons/120x120.png"
}
}
}
}
},
"quickapp" : {},

View File

@ -6,13 +6,6 @@
}
},
"pages": [
{
"path": "pages/login/login",
"style": {
"navigationBarTitleText": "登录",
"navigationStyle": "custom"
}
},
{
"path": "pages/index/index",
"style": {
@ -32,6 +25,19 @@
"navigationBarTitleText": "统计"
}
},
{
"path": "pages/mine/mine",
"style": {
"navigationBarTitleText": "我的"
}
},
{
"path": "pages/login/login",
"style": {
"navigationBarTitleText": "登录",
"navigationStyle": "custom"
}
},
{
"path": "pages/bill/detail",
"style": {
@ -49,6 +55,12 @@
"style": {
"navigationBarTitleText": "预算管理"
}
},
{
"path": "pages/gold-price/gold-price",
"style": {
"navigationBarTitleText": "黄金价格"
}
}
],
"tabBar": {
@ -74,6 +86,12 @@
"iconPath": "static/tabbar/statistics.png",
"selectedIconPath": "static/tabbar/statistic-active.png",
"text": "统计"
},
{
"pagePath": "pages/mine/mine",
"iconPath": "static/tabbar/mine.png",
"selectedIconPath": "static/tabbar/mine-active.png",
"text": "我的"
}
]
},

View File

@ -1,302 +1,3 @@
<template>
<scroll-view scroll-y class="page-scroll">
<view class="container">
<view class="account-card">
<view class="card-header">
<text class="card-title">账户信息</text>
</view>
<view class="card-body">
<view class="balance-section">
<text class="balance-label">当前余额</text>
<text class="balance-amount" :class="account.balance >= 0 ? 'positive' : 'negative'">
¥{{ account.balance ? account.balance.toFixed(2) : '0.00' }}
</text>
</view>
<view class="stats-section">
<view class="stat-item">
<text class="stat-label">总收入</text>
<text class="stat-value income">¥{{ account.totalIncome ? account.totalIncome.toFixed(2) : '0.00' }}</text>
</view>
<view class="stat-item">
<text class="stat-label">总支出</text>
<text class="stat-value expense">¥{{ account.totalExpense ? account.totalExpense.toFixed(2) : '0.00' }}</text>
</view>
</view>
<view class="form-section">
<view class="form-item">
<text class="label">账户名称</text>
<input
v-model="form.name"
type="text"
placeholder="请输入账户名称"
/>
</view>
<view class="form-item">
<text class="label">初始余额</text>
<input
v-model="form.initialBalance"
type="text"
inputmode="decimal"
placeholder="请输入初始余额"
@input="onInitialBalanceInput"
/>
<text class="form-tip">初始余额 + 收入 - 支出 = 当前余额</text>
</view>
</view>
</view>
<view class="card-footer">
<button class="save-btn" @click="saveAccount" :loading="saving">保存</button>
</view>
</view>
</view>
</scroll-view>
123
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { getAccount, updateAccount } from '../../api/account'
const account = ref({
id: null,
name: '默认账户',
initialBalance: 0,
balance: 0,
totalIncome: 0,
totalExpense: 0
})
const form = ref({
name: '默认账户',
initialBalance: '0.00'
})
const saving = ref(false)
const loadAccount = async () => {
try {
const data = await getAccount()
if (data) {
account.value = data
form.value.name = data.name || '默认账户'
form.value.initialBalance = data.initialBalance ? data.initialBalance.toFixed(2) : '0.00'
}
} catch (error) {
console.error('加载账户失败', error)
uni.showToast({
title: '加载账户失败',
icon: 'none'
})
}
}
const onInitialBalanceInput = (e) => {
let value = e.detail.value || ''
value = value.replace(/[^\d.]/g, '')
const parts = value.split('.')
if (parts.length > 2) {
value = parts[0] + '.' + parts.slice(1).join('')
}
if (parts.length === 2 && parts[1].length > 2) {
value = parts[0] + '.' + parts[1].substring(0, 2)
}
form.value.initialBalance = value
}
const saveAccount = async () => {
if (!form.value.name || form.value.name.trim() === '') {
uni.showToast({
title: '请输入账户名称',
icon: 'none'
})
return
}
const initialBalance = parseFloat(form.value.initialBalance) || 0
saving.value = true
try {
const data = await updateAccount({
name: form.value.name.trim(),
initialBalance: initialBalance
})
if (data) {
account.value = data
uni.showToast({
title: '保存成功',
icon: 'success'
})
}
} catch (error) {
console.error('保存账户失败', error)
uni.showToast({
title: error.message || '保存失败',
icon: 'none'
})
} finally {
saving.value = false
}
}
onMounted(() => {
loadAccount()
})
</script>
<style scoped>
.page-scroll {
height: 100vh;
width: 100%;
padding-bottom: calc(50px + env(safe-area-inset-bottom));
}
.container {
min-height: calc(100vh - 100rpx);
background: #f5f5f5;
padding: 20rpx;
padding-bottom: calc(170rpx + env(safe-area-inset-bottom));
}
.account-card {
background: #fff;
border-radius: 12rpx;
overflow: hidden;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.08);
}
.card-header {
padding: 30rpx;
border-bottom: 1rpx solid #f0f0f0;
}
.card-title {
font-size: 36rpx;
font-weight: bold;
color: #333;
}
.card-body {
padding: 30rpx;
}
.balance-section {
text-align: center;
padding: 40rpx 0;
border-bottom: 1rpx solid #f0f0f0;
margin-bottom: 30rpx;
}
.balance-label {
display: block;
font-size: 28rpx;
color: #666;
margin-bottom: 20rpx;
}
.balance-amount {
font-size: 64rpx;
font-weight: bold;
display: block;
}
.balance-amount.positive {
color: #667eea;
}
.balance-amount.negative {
color: #fa3534;
}
.stats-section {
display: flex;
gap: 20rpx;
margin-bottom: 30rpx;
padding-bottom: 30rpx;
border-bottom: 1rpx solid #f0f0f0;
}
.stat-item {
flex: 1;
text-align: center;
}
.stat-label {
display: block;
font-size: 24rpx;
color: #999;
margin-bottom: 10rpx;
}
.stat-value {
display: block;
font-size: 32rpx;
font-weight: bold;
}
.stat-value.income {
color: #667eea;
}
.stat-value.expense {
color: #fa3534;
}
.form-section {
margin-top: 20rpx;
}
.form-item {
margin-bottom: 30rpx;
}
.label {
display: block;
font-size: 28rpx;
color: #333;
margin-bottom: 20rpx;
}
.input {
width: 100%;
padding: 20rpx;
background: #f8f8f8;
border-radius: 8rpx;
font-size: 28rpx;
box-sizing: border-box;
border: none;
outline: none;
-webkit-appearance: none;
}
.form-tip {
display: block;
font-size: 24rpx;
color: #999;
margin-top: 10rpx;
}
.card-footer {
padding: 0 30rpx 30rpx;
}
.save-btn {
width: 100%;
height: 88rpx;
background: #667eea;
color: #fff;
border-radius: 12rpx;
font-size: 32rpx;
border: none;
display: flex;
align-items: center;
justify-content: center;
}
</style>

View File

@ -65,11 +65,12 @@
@input="onAmountInput"
@focus="onAmountFocus"
/>
</view>
<view class="form-item">
<text class="label">日期</text>
<picker mode="date" v-model="form.billDate" @change="onDateChange">
<picker mode="date" :value="form.billDate" @change="onDateChange">
<view class="picker">
<text>{{ form.billDate || '选择日期' }}</text>
<text class="arrow">></text>
@ -533,57 +534,66 @@ onMounted(() => {
height: 100vh;
width: 100%;
padding-bottom: calc(50px + env(safe-area-inset-bottom));
background: linear-gradient(135deg, #FFE5CC 0%, #FFD4A8 50%, #FFC08A 100%);
}
.container {
min-height: calc(100vh - 100rpx);
background: #f5f5f5;
background: transparent;
padding: 20rpx;
padding-bottom: calc(170rpx + env(safe-area-inset-bottom));
}
.tabs {
display: flex;
background: #fff;
border-radius: 12rpx;
background: rgba(255, 255, 255, 0.9);
border-radius: 20rpx;
margin-bottom: 20rpx;
padding: 10rpx;
border: 3rpx solid #C8956E;
box-shadow: 0 8rpx 20rpx rgba(200, 149, 110, 0.15);
}
.tab {
flex: 1;
text-align: center;
padding: 20rpx;
border-radius: 8rpx;
color: #666;
border-radius: 15rpx;
color: #8D6E63;
font-size: 28rpx;
font-weight: 600;
transition: all 0.3s;
}
.tab.active {
background: #667eea;
color: #fff;
background: linear-gradient(135deg, #C8956E, #F5D59E);
color: #5D4037;
box-shadow: 0 4rpx 15rpx rgba(200, 149, 110, 0.3);
}
.form {
background: #fff;
border-radius: 12rpx;
padding: 30rpx;
background: rgba(255, 255, 255, 0.95);
border-radius: 25rpx;
padding: 40rpx 30rpx;
border: 3rpx solid #C8956E;
box-shadow: 0 10rpx 30rpx rgba(200, 149, 110, 0.15);
}
.form-item {
margin-bottom: 30rpx;
margin-bottom: 35rpx;
}
.label {
display: block;
font-size: 28rpx;
color: #333;
color: #5D4037;
margin-bottom: 20rpx;
font-weight: 600;
}
.type-selector-compact {
display: flex;
gap: 15rpx;
gap: 20rpx;
justify-content: center;
}
@ -592,32 +602,32 @@ onMounted(() => {
flex-direction: column;
align-items: center;
justify-content: center;
padding: 15rpx 30rpx;
background: #f8f8f8;
border-radius: 8rpx;
border: 2rpx solid transparent;
padding: 20rpx 40rpx;
background: rgba(255, 255, 255, 0.9);
border-radius: 15rpx;
border: 3rpx solid #C8956E;
transition: all 0.3s;
min-width: 120rpx;
min-width: 140rpx;
box-shadow: 0 4rpx 15rpx rgba(200, 149, 110, 0.1);
}
.type-option-compact.active {
background: #f0f7ff;
border-color: #667eea;
background: linear-gradient(135deg, #FFD700, #F5D59E);
border-color: #FFD700;
transform: scale(1.05);
box-shadow: 0 8rpx 20rpx rgba(255, 215, 0, 0.3);
}
.type-icon-compact {
font-size: 32rpx;
margin-bottom: 5rpx;
font-size: 40rpx;
margin-bottom: 8rpx;
filter: drop-shadow(0 2rpx 4rpx rgba(0, 0, 0, 0.1));
}
.type-text-compact {
font-size: 24rpx;
color: #333;
}
.type-option-compact.active .type-text-compact {
color: #667eea;
font-weight: bold;
font-size: 26rpx;
color: #5D4037;
font-weight: 600;
}
.category-grid {
@ -636,215 +646,279 @@ onMounted(() => {
}
.category-icon-circle {
width: 100rpx;
height: 100rpx;
width: 110rpx;
height: 110rpx;
border-radius: 50%;
background: #f8f8f8;
background: rgba(255, 255, 255, 0.9);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 8rpx;
border: 3rpx solid transparent;
margin-bottom: 10rpx;
border: 3rpx solid #C8956E;
transition: all 0.3s;
box-shadow: 0 4rpx 15rpx rgba(200, 149, 110, 0.1);
}
.category-icon-item.active .category-icon-circle {
background: #f0f7ff;
border-color: #667eea;
transform: scale(1.1);
background: linear-gradient(135deg, #FFD700, #F5D59E);
border-color: #FFD700;
transform: scale(1.15);
box-shadow: 0 8rpx 20rpx rgba(255, 215, 0, 0.4);
}
.category-icon-emoji {
font-size: 48rpx;
font-size: 52rpx;
line-height: 1;
filter: drop-shadow(0 2rpx 4rpx rgba(0, 0, 0, 0.1));
}
.category-icon-name {
font-size: 22rpx;
color: #666;
color: #5D4037;
text-align: center;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
}
.category-icon-item.active .category-icon-name {
color: #667eea;
color: #FFD700;
font-weight: bold;
text-shadow: 0 2rpx 4rpx rgba(255, 215, 0, 0.3);
}
.category-empty {
text-align: center;
padding: 40rpx 0;
color: #999;
padding: 50rpx 0;
color: #8D6E63;
font-size: 26rpx;
background: rgba(255, 255, 255, 0.5);
border-radius: 15rpx;
border: 2rpx dashed #C8956E;
}
.input, .textarea {
width: 100%;
padding: 20rpx;
background: #f8f8f8;
border-radius: 8rpx;
padding: 25rpx;
background: rgba(255, 255, 255, 0.9);
border-radius: 15rpx;
font-size: 28rpx;
box-sizing: border-box;
border: none;
border: 3rpx solid #C8956E;
outline: none;
-webkit-appearance: none;
-webkit-tap-highlight-color: transparent;
pointer-events: auto;
user-select: text;
-webkit-user-select: text;
color: #5D4037;
box-shadow: 0 4rpx 15rpx rgba(200, 149, 110, 0.1);
transition: all 0.3s;
}
.input:focus, .textarea:focus {
border-color: #FFD700;
box-shadow: 0 0 0 4rpx rgba(255, 215, 0, 0.3), 0 8rpx 25rpx rgba(255, 215, 0, 0.2);
}
.input::placeholder, .textarea::placeholder {
color: #BCAAA4;
}
.textarea {
min-height: 200rpx;
min-height: 180rpx;
}
.category-selector, .picker {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20rpx;
background: #f8f8f8;
border-radius: 8rpx;
padding: 25rpx;
background: rgba(255, 255, 255, 0.9);
border-radius: 15rpx;
border: 3rpx solid #C8956E;
box-shadow: 0 4rpx 15rpx rgba(200, 149, 110, 0.1);
}
.placeholder {
color: #999;
color: #BCAAA4;
}
.arrow {
color: #999;
color: #C8956E;
font-weight: bold;
}
.submit-btn {
width: 100%;
height: 88rpx;
background: #667eea;
color: #fff;
border-radius: 12rpx;
height: 96rpx;
background: linear-gradient(135deg, #C8956E, #F5D59E, #FFD700);
color: #5D4037;
border-radius: 50rpx;
font-size: 32rpx;
font-weight: bold;
margin-top: 40rpx;
margin-bottom: 20rpx;
border: none;
border: 4rpx solid #5D4037;
box-shadow: 0 10rpx 30rpx rgba(200, 149, 110, 0.4);
transition: all 0.3s;
}
.submit-btn:active {
transform: scale(0.98);
box-shadow: 0 5rpx 15rpx rgba(200, 149, 110, 0.3);
}
.ocr-container {
background: #fff;
border-radius: 12rpx;
background: rgba(255, 255, 255, 0.95);
border-radius: 25rpx;
padding: 30rpx;
border: 3rpx solid #C8956E;
box-shadow: 0 10rpx 30rpx rgba(200, 149, 110, 0.15);
}
.image-section {
margin-bottom: 30rpx;
background: #fff;
border-radius: 12rpx;
background: rgba(255, 255, 255, 0.8);
border-radius: 20rpx;
overflow: hidden;
border: 3rpx solid #C8956E;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20rpx;
background: #f8f8f8;
border-radius: 12rpx;
padding: 25rpx;
background: linear-gradient(135deg, rgba(200, 149, 110, 0.15), rgba(255, 215, 0, 0.15));
border-radius: 17rpx;
cursor: pointer;
user-select: none;
transition: background 0.3s;
}
.section-header:active {
background: #f0f0f0;
background: linear-gradient(135deg, rgba(200, 149, 110, 0.25), rgba(255, 215, 0, 0.25));
}
.section-title {
font-size: 28rpx;
font-weight: bold;
color: #333;
color: #5D4037;
}
.list-title {
font-size: 28rpx;
color: #5D4037;
font-weight: bold;
}
.section-content {
padding: 20rpx 0;
padding: 25rpx;
animation: slideDown 0.3s ease-out;
}
.upload-area {
width: 100%;
height: 400rpx;
border: 2rpx dashed #ddd;
border-radius: 12rpx;
border: 3rpx dashed #C8956E;
border-radius: 20rpx;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 30rpx;
background: rgba(255, 255, 255, 0.5);
transition: all 0.3s;
}
.upload-area:active {
border-color: #FFD700;
background: rgba(255, 215, 0, 0.1);
}
.preview-image {
width: 100%;
height: 100%;
border-radius: 12rpx;
border-radius: 17rpx;
}
.upload-placeholder {
text-align: center;
color: #999;
color: #8D6E63;
display: flex;
flex-direction: column;
align-items: center;
}
.upload-icon {
font-size: 80rpx;
font-size: 100rpx;
display: block;
margin-bottom: 20rpx;
filter: drop-shadow(0 4rpx 8rpx rgba(0, 0, 0, 0.1));
}
.recognize-btn {
width: 100%;
height: 88rpx;
background: #19be6b;
height: 96rpx;
background: linear-gradient(135deg, #19BE6B, #51CF66);
color: #fff;
border-radius: 12rpx;
border-radius: 50rpx;
font-size: 32rpx;
font-weight: bold;
margin-bottom: 30rpx;
border: none;
border: 4rpx solid #108043;
box-shadow: 0 10rpx 30rpx rgba(25, 190, 107, 0.3);
transition: all 0.3s;
}
.recognize-btn:active {
transform: scale(0.98);
}
.direct-save-btn {
width: 100%;
height: 88rpx;
background: linear-gradient(135deg, #FFD700, #F5D59E);
color: #5D4037;
border-radius: 50rpx;
font-size: 30rpx;
font-weight: bold;
margin-bottom: 20rpx;
border: 3rpx solid #5D4037;
box-shadow: 0 8rpx 25rpx rgba(255, 215, 0, 0.3);
transition: all 0.3s;
}
.direct-save-btn:active {
transform: scale(0.98);
}
.ocr-result-list-container {
margin-top: 30rpx;
background: #fff;
border-radius: 12rpx;
background: rgba(255, 255, 255, 0.8);
border-radius: 20rpx;
overflow: hidden;
}
.list-title {
font-size: 28rpx;
color: #333;
font-weight: bold;
border: 3rpx solid #C8956E;
}
.result-list-wrapper {
max-height: 600rpx;
overflow-y: auto;
padding: 0 20rpx 20rpx 20rpx;
-webkit-overflow-scrolling: touch;
}
.result-card {
background: #fff;
border: 2rpx solid #e0e0e0;
border-radius: 12rpx;
padding: 20rpx;
background: rgba(255, 255, 255, 0.95);
border: 3rpx solid #C8956E;
border-radius: 20rpx;
padding: 25rpx;
margin-bottom: 20rpx;
transition: all 0.3s;
overflow: hidden;
box-shadow: 0 4rpx 15rpx rgba(200, 149, 110, 0.1);
}
.result-card.active {
border-color: #667eea;
background: #f0f7ff;
border-color: #FFD700;
background: linear-gradient(135deg, rgba(255, 215, 0, 0.1), rgba(245, 213, 158, 0.1));
box-shadow: 0 8rpx 20rpx rgba(255, 215, 0, 0.3);
}
.result-header {
@ -869,37 +943,37 @@ onMounted(() => {
.result-index {
font-size: 24rpx;
color: #667eea;
color: #FFD700;
font-weight: bold;
margin-right: 15rpx;
background: #5D4037;
padding: 6rpx 12rpx;
border-radius: 10rpx;
}
.result-value.amount.summary {
font-size: 30rpx;
font-size: 32rpx;
font-weight: bold;
color: #fa3534;
color: #FA3534;
}
.result-confidence {
font-size: 22rpx;
color: #999;
color: #8D6E63;
}
.expand-icon {
font-size: 24rpx;
color: #667eea;
color: #C8956E;
transition: transform 0.3s;
margin-left: 10rpx;
}
.result-card.expanded .expand-icon {
transform: rotate(90deg);
font-weight: bold;
}
.result-details {
margin-top: 20rpx;
padding-top: 20rpx;
border-top: 1rpx solid #f0f0f0;
margin-top: 25rpx;
padding-top: 25rpx;
border-top: 2rpx solid rgba(200, 149, 110, 0.3);
animation: slideDown 0.3s ease-out;
}
@ -916,46 +990,54 @@ onMounted(() => {
.result-item {
display: flex;
padding: 10rpx 0;
padding: 12rpx 0;
align-items: center;
}
.result-label {
font-size: 26rpx;
color: #666;
color: #8D6E63;
width: 100rpx;
flex-shrink: 0;
font-weight: 600;
}
.result-value {
font-size: 28rpx;
color: #333;
color: #5D4037;
flex: 1;
}
.result-value.amount {
font-weight: bold;
color: #fa3534;
color: #FA3534;
font-size: 32rpx;
}
.select-btn {
width: 100%;
height: 70rpx;
background: #667eea;
color: #fff;
border-radius: 8rpx;
font-size: 26rpx;
height: 76rpx;
background: linear-gradient(135deg, #C8956E, #F5D59E);
color: #5D4037;
border-radius: 50rpx;
font-size: 28rpx;
font-weight: bold;
margin-top: 20rpx;
border: none;
border: 3rpx solid #5D4037;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 6rpx 18rpx rgba(200, 149, 110, 0.3);
transition: all 0.3s;
}
.select-btn:active {
transform: scale(0.98);
}
.ocr-result {
margin-top: 30rpx;
padding-top: 30rpx;
border-top: 2rpx solid #667eea;
border-top: 3rpx solid #C8956E;
}
</style>

View File

@ -64,7 +64,7 @@
<view class="form-item">
<text class="label">日期</text>
<picker mode="date" v-model="form.billDate" @change="onDateChange">
<picker mode="date" :value="form.billDate" @change="onDateChange">
<view class="picker">
<text>{{ form.billDate || '选择日期' }}</text>
<text class="arrow">></text>
@ -283,31 +283,36 @@ onLoad((options) => {
height: 100vh;
width: 100%;
padding-bottom: calc(50px + env(safe-area-inset-bottom));
background: linear-gradient(135deg, #FFE5CC 0%, #FFD4A8 50%, #FFC08A 100%);
}
.container {
min-height: calc(100vh - 100rpx);
background: #f5f5f5;
background: transparent;
padding: 20rpx;
padding-bottom: calc(170rpx + env(safe-area-inset-bottom));
}
.form-card {
background: #fff;
border-radius: 12rpx;
padding: 30rpx;
background: rgba(255, 255, 255, 0.95);
border-radius: 25rpx;
padding: 40rpx 30rpx;
border: 3rpx solid #C8956E;
box-shadow: 0 10rpx 30rpx rgba(93, 64, 55, 0.15);
}
.form-header {
margin-bottom: 30rpx;
padding-bottom: 20rpx;
border-bottom: 1rpx solid #f0f0f0;
margin-bottom: 35rpx;
padding-bottom: 25rpx;
border-bottom: 3rpx solid rgba(200, 149, 110, 0.3);
text-align: center;
}
.form-title {
font-size: 36rpx;
font-size: 40rpx;
font-weight: bold;
color: #333;
color: #5D4037;
text-shadow: 2rpx 2rpx 0 rgba(255, 215, 0, 0.3);
}
.form-body {
@ -315,19 +320,20 @@ onLoad((options) => {
}
.form-item {
margin-bottom: 30rpx;
margin-bottom: 35rpx;
}
.label {
display: block;
font-size: 28rpx;
color: #333;
color: #5D4037;
margin-bottom: 20rpx;
font-weight: 600;
}
.type-selector-compact {
display: flex;
gap: 15rpx;
gap: 20rpx;
justify-content: center;
}
@ -336,32 +342,32 @@ onLoad((options) => {
flex-direction: column;
align-items: center;
justify-content: center;
padding: 15rpx 30rpx;
background: #f8f8f8;
border-radius: 8rpx;
border: 2rpx solid transparent;
padding: 20rpx 40rpx;
background: rgba(255, 255, 255, 0.9);
border-radius: 15rpx;
border: 3rpx solid #C8956E;
transition: all 0.3s;
min-width: 120rpx;
min-width: 140rpx;
box-shadow: 0 4rpx 15rpx rgba(200, 149, 110, 0.1);
}
.type-option-compact.active {
background: #f0f7ff;
border-color: #667eea;
background: linear-gradient(135deg, #FFD700, #F5D59E);
border-color: #FFD700;
transform: scale(1.05);
box-shadow: 0 8rpx 20rpx rgba(255, 215, 0, 0.3);
}
.type-icon-compact {
font-size: 32rpx;
margin-bottom: 5rpx;
font-size: 40rpx;
margin-bottom: 8rpx;
filter: drop-shadow(0 2rpx 4rpx rgba(0, 0, 0, 0.1));
}
.type-text-compact {
font-size: 24rpx;
color: #333;
}
.type-option-compact.active .type-text-compact {
color: #667eea;
font-weight: bold;
font-size: 26rpx;
color: #5D4037;
font-weight: 600;
}
.category-grid {
@ -380,82 +386,100 @@ onLoad((options) => {
}
.category-icon-circle {
width: 100rpx;
height: 100rpx;
width: 110rpx;
height: 110rpx;
border-radius: 50%;
background: #f8f8f8;
background: rgba(255, 255, 255, 0.9);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 8rpx;
border: 3rpx solid transparent;
margin-bottom: 10rpx;
border: 3rpx solid #C8956E;
transition: all 0.3s;
box-shadow: 0 4rpx 15rpx rgba(200, 149, 110, 0.1);
}
.category-icon-item.active .category-icon-circle {
background: #f0f7ff;
border-color: #667eea;
transform: scale(1.1);
background: linear-gradient(135deg, #FFD700, #F5D59E);
border-color: #FFD700;
transform: scale(1.15);
box-shadow: 0 8rpx 20rpx rgba(255, 215, 0, 0.4);
}
.category-icon-emoji {
font-size: 48rpx;
font-size: 52rpx;
line-height: 1;
filter: drop-shadow(0 2rpx 4rpx rgba(0, 0, 0, 0.1));
}
.category-icon-name {
font-size: 22rpx;
color: #666;
color: #5D4037;
text-align: center;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
}
.category-icon-item.active .category-icon-name {
color: #667eea;
color: #FFD700;
font-weight: bold;
text-shadow: 0 2rpx 4rpx rgba(255, 215, 0, 0.3);
}
.category-empty {
text-align: center;
padding: 40rpx 0;
color: #999;
padding: 50rpx 0;
color: #8D6E63;
font-size: 26rpx;
background: rgba(255, 255, 255, 0.5);
border-radius: 15rpx;
border: 2rpx dashed #C8956E;
}
.input, .textarea {
width: 100%;
padding: 20rpx;
background: #f8f8f8;
border-radius: 8rpx;
padding: 25rpx;
background: rgba(255, 255, 255, 0.9);
border-radius: 15rpx;
font-size: 28rpx;
box-sizing: border-box;
border: none;
border: 3rpx solid #C8956E;
outline: none;
-webkit-appearance: none;
-webkit-tap-highlight-color: transparent;
pointer-events: auto;
user-select: text;
-webkit-user-select: text;
color: #5D4037;
box-shadow: 0 4rpx 15rpx rgba(200, 149, 110, 0.1);
transition: all 0.3s;
}
.input:focus, .textarea:focus {
border-color: #FFD700;
box-shadow: 0 0 0 4rpx rgba(255, 215, 0, 0.3), 0 8rpx 25rpx rgba(255, 215, 0, 0.2);
}
.input::placeholder, .textarea::placeholder {
color: #BCAAA4;
}
.textarea {
min-height: 200rpx;
min-height: 180rpx;
}
.picker {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20rpx;
background: #f8f8f8;
border-radius: 8rpx;
padding: 25rpx;
background: rgba(255, 255, 255, 0.9);
border-radius: 15rpx;
border: 3rpx solid #C8956E;
box-shadow: 0 4rpx 15rpx rgba(200, 149, 110, 0.1);
}
.arrow {
color: #999;
color: #C8956E;
font-weight: bold;
}
.form-footer {
@ -466,27 +490,41 @@ onLoad((options) => {
.delete-btn {
flex: 1;
height: 88rpx;
background: #fa3534;
height: 96rpx;
background: linear-gradient(135deg, #FA3534, #FF6B6B);
color: #fff;
border-radius: 12rpx;
border-radius: 50rpx;
font-size: 28rpx;
border: none;
font-weight: bold;
border: 3rpx solid #D32F2F;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 25rpx rgba(250, 53, 52, 0.3);
transition: all 0.3s;
}
.delete-btn:active {
transform: scale(0.98);
}
.submit-btn {
flex: 2;
height: 88rpx;
background: #667eea;
color: #fff;
border-radius: 12rpx;
height: 96rpx;
background: linear-gradient(135deg, #C8956E, #F5D59E, #FFD700);
color: #5D4037;
border-radius: 50rpx;
font-size: 32rpx;
border: none;
font-weight: bold;
border: 4rpx solid #5D4037;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 10rpx 30rpx rgba(200, 149, 110, 0.4);
transition: all 0.3s;
}
.submit-btn:active {
transform: scale(0.98);
}
</style>

View File

@ -1,301 +1,3 @@
<template>
<scroll-view scroll-y class="page-scroll">
<view class="container">
<view class="budget-card">
<view class="card-header">
<text class="card-title">预算设置</text>
<text class="card-subtitle">{{ currentMonthText }}</text>
</view>
<view class="card-body">
<view class="form-item">
<text class="label">预算金额</text>
<input
v-model="form.amount"
type="text"
inputmode="decimal"
placeholder="请输入预算金额"
@input="onAmountInput"
/>
<text class="form-tip">设置本月预算金额用于控制支出</text>
</view>
<view v-if="budget.usedAmount !== undefined" class="info-section">
<view class="info-item">
<text class="info-label">本月已支出</text>
<text class="info-value expense">¥{{ budget.usedAmount ? budget.usedAmount.toFixed(2) : '0.00' }}</text>
</view>
<view class="info-item">
<text class="info-label">剩余预算</text>
<text class="info-value" :class="budget.remainingAmount >= 0 ? 'positive' : 'negative'">
¥{{ budget.remainingAmount !== undefined ? budget.remainingAmount.toFixed(2) : '0.00' }}
</text>
</view>
<view v-if="budget.remainingDaily !== undefined && budget.remainingDaily > 0" class="info-item">
<text class="info-label">剩余日均</text>
<text class="info-value">¥{{ budget.remainingDaily.toFixed(2) }}</text>
</view>
</view>
</view>
<view class="card-footer">
<button class="save-btn" @click="saveBudget" :loading="saving">保存</button>
</view>
</view>
</view>
</scroll-view>
123
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { getBudget, setBudget } from '../../api/budget'
const budget = ref({
id: null,
year: null,
month: null,
amount: 0,
usedAmount: 0,
remainingAmount: 0,
remainingDaily: 0
})
const form = ref({
year: null,
month: null,
amount: '0.00'
})
const saving = ref(false)
const currentMonthText = computed(() => {
if (budget.value.year && budget.value.month) {
return `${budget.value.year}${budget.value.month}`
}
const now = new Date()
return `${now.getFullYear()}${now.getMonth() + 1}`
})
const loadBudget = async () => {
try {
const data = await getBudget()
if (data) {
budget.value = data
form.value.year = data.year
form.value.month = data.month
form.value.amount = data.amount ? data.amount.toFixed(2) : '0.00'
} else {
// 使
const now = new Date()
budget.value = {
year: now.getFullYear(),
month: now.getMonth() + 1,
amount: 0,
usedAmount: 0,
remainingAmount: 0,
remainingDaily: 0
}
form.value.year = now.getFullYear()
form.value.month = now.getMonth() + 1
form.value.amount = '0.00'
}
} catch (error) {
console.error('加载预算失败', error)
uni.showToast({
title: '加载预算失败',
icon: 'none'
})
}
}
const onAmountInput = (e) => {
let value = e.detail.value || ''
value = value.replace(/[^\d.]/g, '')
const parts = value.split('.')
if (parts.length > 2) {
value = parts[0] + '.' + parts.slice(1).join('')
}
if (parts.length === 2 && parts[1].length > 2) {
value = parts[0] + '.' + parts[1].substring(0, 2)
}
form.value.amount = value
}
const saveBudget = async () => {
const amount = parseFloat(form.value.amount) || 0
if (amount < 0) {
uni.showToast({
title: '预算金额不能为负数',
icon: 'none'
})
return
}
saving.value = true
try {
const data = await setBudget({
year: form.value.year,
month: form.value.month,
amount: amount
})
if (data) {
budget.value = data
uni.showToast({
title: '保存成功',
icon: 'success'
})
//
uni.$emit('refreshBills')
setTimeout(() => {
uni.navigateBack()
}, 1500)
}
} catch (error) {
console.error('保存预算失败', error)
uni.showToast({
title: error.message || '保存失败',
icon: 'none'
})
} finally {
saving.value = false
}
}
onMounted(() => {
loadBudget()
})
</script>
<style scoped>
.page-scroll {
height: 100vh;
width: 100%;
padding-bottom: calc(50px + env(safe-area-inset-bottom));
}
.container {
min-height: calc(100vh - 100rpx);
background: #f5f5f5;
padding: 20rpx;
padding-bottom: calc(170rpx + env(safe-area-inset-bottom));
}
.budget-card {
background: #fff;
border-radius: 12rpx;
overflow: hidden;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.08);
}
.card-header {
padding: 30rpx;
border-bottom: 1rpx solid #f0f0f0;
text-align: center;
}
.card-title {
font-size: 36rpx;
font-weight: bold;
color: #333;
display: block;
margin-bottom: 10rpx;
}
.card-subtitle {
font-size: 28rpx;
color: #666;
display: block;
}
.card-body {
padding: 30rpx;
}
.form-item {
margin-bottom: 30rpx;
}
.label {
display: block;
font-size: 28rpx;
color: #333;
margin-bottom: 20rpx;
}
.input {
width: 100%;
padding: 20rpx;
background: #f8f8f8;
border-radius: 8rpx;
font-size: 32rpx;
box-sizing: border-box;
border: none;
outline: none;
-webkit-appearance: none;
}
.form-tip {
display: block;
font-size: 24rpx;
color: #999;
margin-top: 10rpx;
}
.info-section {
margin-top: 40rpx;
padding-top: 30rpx;
border-top: 1rpx solid #f0f0f0;
}
.info-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20rpx 0;
}
.info-label {
font-size: 28rpx;
color: #666;
}
.info-value {
font-size: 32rpx;
font-weight: bold;
}
.info-value.positive {
color: #667eea;
}
.info-value.negative {
color: #fa3534;
}
.info-value.expense {
color: #fa3534;
}
.card-footer {
padding: 0 30rpx 30rpx;
}
.save-btn {
width: 100%;
height: 88rpx;
background: #667eea;
color: #fff;
border-radius: 12rpx;
font-size: 32rpx;
border: none;
display: flex;
align-items: center;
justify-content: center;
}
</style>

View File

@ -0,0 +1,372 @@
<template>
<input v-model="form.amount" type="text" />
<input v-model="form.amount" type="text" />
<input v-model="form.amount" type="text" />
<input v-model="form.amount" type="text" />
<input v-model="form.amount" type="text" />
<scroll-view scroll-y class="page-scroll">
<view class="container">
<view class="budget-card">
<view class="card-header">
<text class="card-title">预算设置</text>
<text class="card-subtitle">{{ currentMonthText }}</text>
</view>
<view class="card-body">
<view class="form-item">
<text class="label">预算金额1</text>
<input
v-model="amountInput"
type="text"
inputmode="decimal"
placeholder="请输入预算金额"
@focus="onAmountFocus"
/>
<view class="uni-form-item uni-column">
<view class="title">实时获取输入值{{inputValue}}</view>
<input class="uni-input" @input="onKeyInput" placeholder="输入同步到view中" />
</view>
<text class="form-tip">设置本月预算金额,用于控制支出</text>
</view>
<view v-if="budget.usedAmount !== undefined" class="info-section">
<view class="info-item">
<text class="info-label">本月预算</text>
<text class="info-value expense">¥{{ budget.amount ? budget.amount.toFixed(2) : '0.00' }}</text>
</view>
<view class="info-item">
<text class="info-label">本月已支出</text>
<text class="info-value expense">¥{{ budget.usedAmount ? budget.usedAmount.toFixed(2) : '0.00' }}</text>
</view>
<view class="info-item">
<text class="info-label">剩余预算</text>
<text class="info-value" :class="budget.remainingAmount >= 0 ? 'positive' : 'negative'">
¥{{ budget.remainingAmount !== undefined ? budget.remainingAmount.toFixed(2) : '0.00' }}
</text>
</view>
<view v-if="budget.remainingDaily !== undefined && budget.remainingDaily > 0" class="info-item">
<text class="info-label">剩余日均</text>
<text class="info-value">¥{{ budget.remainingDaily.toFixed(2) }}</text>
</view>
</view>
</view>
<view class="card-footer">
<button class="save-btn" @click="saveBudget" :loading="saving">保存</button>
</view>
</view>
</view>
</scroll-view>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { getBudget, setBudget } from '../../api/budget'
const budget = ref({
id: null,
year: null,
month: null,
amount: 0,
usedAmount: 0,
remainingAmount: 0,
remainingDaily: 0
})
const inputValue = ref('');
const form = ref({
year: null,
month: null,
amount: ''
})
const saving = ref(false)
//
const amountInput = computed({
get() {
return form.value.amount
},
set(value) {
//
let processedValue = value || ''
//
processedValue = processedValue.replace(/[^\d.]/g, '')
//
const parts = processedValue.split('.')
if (parts.length > 2) {
processedValue = parts[0] + '.' + parts.slice(1).join('')
}
// 2
if (parts.length === 2 && parts[1].length > 2) {
processedValue = parts[0] + '.' + parts[1].substring(0, 2)
}
form.value.amount = processedValue
}
})
const currentMonthText = computed(() => {
if (budget.value.year && budget.value.month) {
return `${budget.value.year}${budget.value.month}`
}
const now = new Date()
return `${now.getFullYear()}${now.getMonth() + 1}`
})
const loadBudget = async () => {
try {
const data = await getBudget()
if (data) {
budget.value = data
form.value.year = data.year
form.value.month = data.month
form.value.amount = data.amount ? data.amount.toString() : ''
} else {
// 使
const now = new Date()
budget.value = {
year: now.getFullYear(),
month: now.getMonth() + 1,
amount: 0,
usedAmount: 0,
remainingAmount: 0,
remainingDaily: 0
}
form.value.year = now.getFullYear()
form.value.month = now.getMonth() + 1
form.value.amount = ''
}
} catch (error) {
console.error('加载预算失败', error)
uni.showToast({
title: '加载预算失败',
icon: 'none'
})
}
}
const onAmountFocus = () => {
//
console.log('预算金额输入框聚焦')
}
const saveBudget = async () => {
const amount = parseFloat(form.value.amount) || 0
if (amount < 0) {
uni.showToast({
title: '预算金额不能为负数',
icon: 'none'
})
return
}
saving.value = true
try {
const data = await setBudget({
year: form.value.year,
month: form.value.month,
amount: amount
})
if (data) {
budget.value = data
uni.showToast({
title: '保存成功',
icon: 'success'
})
//
uni.$emit('refreshBills')
setTimeout(() => {
uni.navigateBack()
}, 1500)
}
} catch (error) {
console.error('保存预算失败', error)
uni.showToast({
title: error.message || '保存失败',
icon: 'none'
})
} finally {
saving.value = false
}
}
const onKeyInput = (event) => {
inputValue.value = event.detail.value
}
onMounted(() => {
loadBudget()
})
</script>
<style scoped>
.page-scroll {
height: 100vh;
width: 100%;
padding-bottom: calc(50px + env(safe-area-inset-bottom));
background: linear-gradient(135deg, #FFE5CC 0%, #FFD4A8 50%, #FFC08A 100%);
}
.container {
min-height: calc(100vh - 100rpx);
background: transparent;
padding: 20rpx;
padding-bottom: calc(170rpx + env(safe-area-inset-bottom));
}
.budget-card {
background: rgba(255, 255, 255, 0.95);
border-radius: 25rpx;
box-shadow: 0 10rpx 30rpx rgba(93, 64, 55, 0.15);
border: 3rpx solid #C8956E;
position: relative;
}
.card-header {
padding: 40rpx 30rpx;
background: linear-gradient(135deg, #C8956E, #F5D59E, #FFD700);
border-bottom: 3rpx solid #5D4037;
text-align: center;
}
.card-title {
font-size: 40rpx;
font-weight: bold;
color: #5D4037;
display: block;
margin-bottom: 12rpx;
text-shadow: 2rpx 2rpx 0 rgba(255, 215, 0, 0.3);
}
.card-subtitle {
font-size: 28rpx;
color: #8D6E63;
display: block;
font-weight: 600;
}
.card-body {
padding: 40rpx 30rpx;
}
.form-item {
margin-bottom: 35rpx;
position: relative;
z-index: 1;
}
.label {
display: block;
font-size: 28rpx;
color: #5D4037;
margin-bottom: 20rpx;
font-weight: 600;
}
input {
width: 100%;
padding: 25rpx;
background: rgba(255, 255, 255, 0.9);
border-radius: 15rpx;
font-size: 32rpx;
box-sizing: border-box;
border: 3rpx solid #C8956E;
outline: none;
color: #5D4037;
box-shadow: 0 4rpx 15rpx rgba(200, 149, 110, 0.1);
transition: all 0.3s;
pointer-events: auto;
user-select: text;
-webkit-user-select: text;
position: relative;
z-index: 1;
}
input:focus {
border-color: #FFD700;
box-shadow: 0 0 0 4rpx rgba(255, 215, 0, 0.3), 0 8rpx 25rpx rgba(255, 215, 0, 0.2);
}
input::placeholder {
color: #BCAAA4;
}
.form-tip {
display: block;
font-size: 24rpx;
color: #8D6E63;
margin-top: 12rpx;
padding-left: 10rpx;
}
.info-section {
margin-top: 40rpx;
padding-top: 30rpx;
border-top: 2rpx solid rgba(200, 149, 110, 0.3);
}
.info-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 25rpx;
margin-bottom: 15rpx;
background: rgba(255, 255, 255, 0.8);
border-radius: 15rpx;
border: 2rpx solid #C8956E;
}
.info-label {
font-size: 28rpx;
color: #8D6E63;
font-weight: 600;
}
.info-value {
font-size: 34rpx;
font-weight: bold;
}
.info-value.positive {
color: #FFD700;
}
.info-value.negative {
color: #FA3534;
}
.info-value.expense {
color: #FA3534;
}
.card-footer {
padding: 0 30rpx 40rpx;
}
.save-btn {
width: 100%;
height: 96rpx;
background: linear-gradient(135deg, #C8956E, #F5D59E, #FFD700);
color: #5D4037;
border-radius: 50rpx;
font-size: 32rpx;
font-weight: bold;
border: 4rpx solid #5D4037;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 10rpx 30rpx rgba(200, 149, 110, 0.4);
transition: all 0.3s;
}
.save-btn:active {
transform: scale(0.98);
box-shadow: 0 5rpx 15rpx rgba(200, 149, 110, 0.3);
}
</style>

View File

@ -162,50 +162,62 @@ onMounted(() => {
<style scoped>
.container {
min-height: 100vh;
background: #f5f5f5;
background: linear-gradient(135deg, #FFE5CC 0%, #FFD4A8 50%, #FFC08A 100%);
padding-bottom: calc(50rpx + env(safe-area-inset-bottom));
}
.header {
background: #fff;
padding: 30rpx;
background: linear-gradient(135deg, #C8956E, #F5D59E, #FFD700);
padding: 40rpx 30rpx;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1rpx solid #eee;
border-bottom: 3rpx solid #5D4037;
box-shadow: 0 8rpx 20rpx rgba(93, 64, 55, 0.15);
}
.title {
font-size: 36rpx;
font-size: 40rpx;
font-weight: bold;
color: #5D4037;
text-shadow: 2rpx 2rpx 0 rgba(255, 215, 0, 0.3);
}
.add-btn {
background: #667eea;
color: #fff;
padding: 10rpx 20rpx;
border-radius: 8rpx;
background: rgba(255, 255, 255, 0.9);
color: #5D4037;
padding: 15rpx 30rpx;
border-radius: 50rpx;
font-size: 26rpx;
border: none;
font-weight: bold;
border: 3rpx solid #5D4037;
box-shadow: 0 4rpx 15rpx rgba(93, 64, 55, 0.2);
}
.tabs {
display: flex;
background: #fff;
margin-top: 20rpx;
background: rgba(255, 255, 255, 0.9);
margin: 20rpx;
padding: 10rpx;
border-radius: 20rpx;
border: 3rpx solid #C8956E;
box-shadow: 0 8rpx 20rpx rgba(200, 149, 110, 0.15);
}
.tab {
flex: 1;
text-align: center;
padding: 20rpx;
border-radius: 8rpx;
color: #666;
border-radius: 15rpx;
color: #8D6E63;
font-weight: 600;
transition: all 0.3s;
}
.tab.active {
background: #667eea;
color: #fff;
background: linear-gradient(135deg, #C8956E, #F5D59E);
color: #5D4037;
box-shadow: 0 4rpx 15rpx rgba(200, 149, 110, 0.3);
}
.category-list {
@ -213,46 +225,63 @@ onMounted(() => {
}
.category-item {
background: #fff;
border-radius: 12rpx;
background: rgba(255, 255, 255, 0.95);
border-radius: 20rpx;
padding: 30rpx;
margin-bottom: 20rpx;
display: flex;
align-items: center;
border: 3rpx solid #C8956E;
box-shadow: 0 8rpx 20rpx rgba(200, 149, 110, 0.15);
transition: all 0.3s;
}
.category-item:active {
transform: scale(0.98);
box-shadow: 0 4rpx 10rpx rgba(200, 149, 110, 0.1);
}
.category-icon {
font-size: 48rpx;
margin-right: 20rpx;
font-size: 52rpx;
margin-right: 25rpx;
filter: drop-shadow(0 3rpx 6rpx rgba(0, 0, 0, 0.15));
}
.category-name {
flex: 1;
font-size: 30rpx;
color: #333;
color: #5D4037;
font-weight: 600;
}
.tag {
background: #f0f0f0;
color: #999;
padding: 4rpx 12rpx;
border-radius: 4rpx;
background: linear-gradient(135deg, rgba(200, 149, 110, 0.2), rgba(255, 215, 0, 0.2));
color: #8D6E63;
padding: 8rpx 20rpx;
border-radius: 50rpx;
font-size: 22rpx;
margin-right: 20rpx;
font-weight: 600;
border: 2rpx solid #C8956E;
}
.actions {
display: flex;
gap: 20rpx;
gap: 25rpx;
}
.action-btn {
color: #667eea;
color: #FFD700;
font-size: 26rpx;
font-weight: 600;
padding: 8rpx 15rpx;
border-radius: 10rpx;
background: rgba(255, 215, 0, 0.1);
}
.action-btn.delete {
color: #fa3534;
color: #FA3534;
background: rgba(250, 53, 52, 0.1);
}
.modal {
@ -261,69 +290,131 @@ onMounted(() => {
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
background: rgba(93, 64, 55, 0.7);
backdrop-filter: blur(10rpx);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: fadeIn 0.3s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.modal-content {
background: #fff;
border-radius: 12rpx;
width: 80%;
background: rgba(255, 255, 255, 0.98);
border-radius: 25rpx;
width: 85%;
max-width: 600rpx;
border: 3rpx solid #C8956E;
box-shadow: 0 20rpx 60rpx rgba(93, 64, 55, 0.3);
animation: slideUp 0.3s ease-out;
}
@keyframes slideUp {
from {
transform: translateY(100rpx);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 30rpx;
border-bottom: 1rpx solid #eee;
padding: 35rpx 30rpx;
background: linear-gradient(135deg, #C8956E, #F5D59E);
border-bottom: 3rpx solid #5D4037;
border-radius: 22rpx 22rpx 0 0;
}
.modal-title {
font-size: 32rpx;
font-size: 34rpx;
font-weight: bold;
color: #5D4037;
}
.close-btn {
font-size: 48rpx;
color: #999;
font-size: 50rpx;
color: #5D4037;
width: 60rpx;
height: 60rpx;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.8);
border-radius: 50%;
border: 2rpx solid #5D4037;
transition: all 0.3s;
}
.close-btn:active {
transform: scale(0.9);
background: #fff;
}
.modal-body {
padding: 30rpx;
padding: 40rpx 30rpx;
}
.form-item {
margin-bottom: 30rpx;
margin-bottom: 35rpx;
}
.label {
display: block;
font-size: 28rpx;
color: #333;
color: #5D4037;
margin-bottom: 20rpx;
font-weight: 600;
}
.input {
width: 100%;
padding: 20rpx;
background: #f8f8f8;
border-radius: 8rpx;
padding: 25rpx;
background: rgba(255, 255, 255, 0.9);
border-radius: 15rpx;
font-size: 28rpx;
box-sizing: border-box;
border: 3rpx solid #C8956E;
outline: none;
color: #5D4037;
box-shadow: 0 4rpx 15rpx rgba(200, 149, 110, 0.1);
transition: all 0.3s;
}
.input:focus {
border-color: #FFD700;
box-shadow: 0 0 0 4rpx rgba(255, 215, 0, 0.3), 0 8rpx 25rpx rgba(255, 215, 0, 0.2);
}
.input::placeholder {
color: #BCAAA4;
}
.submit-btn {
width: 100%;
height: 88rpx;
background: #667eea;
color: #fff;
border-radius: 12rpx;
height: 96rpx;
background: linear-gradient(135deg, #C8956E, #F5D59E, #FFD700);
color: #5D4037;
border-radius: 50rpx;
font-size: 32rpx;
border: none;
font-weight: bold;
border: 4rpx solid #5D4037;
box-shadow: 0 10rpx 30rpx rgba(200, 149, 110, 0.4);
transition: all 0.3s;
}
.submit-btn:active {
transform: scale(0.98);
box-shadow: 0 5rpx 15rpx rgba(200, 149, 110, 0.3);
}
</style>

View File

@ -0,0 +1,726 @@
<template>
<scroll-view scroll-y="true" class="page-scroll">
<view class="container">
<view class="header">
<text class="title">黄金实时价格</text>
<text class="subtitle">黄金9999 (1053)</text>
</view>
<!-- 当前价格卡片 -->
<view class="price-card" v-if="currentPrice">
<view class="price-main">
<view class="price-main-left">
<text class="price-label">当前价格</text>
<text class="price-value">¥{{ formatPrice(currentPrice.price) }}</text>
</view>
<view class="price-main-right">
<view class="refresh-btn" @click="handleRefreshPrice" :class="{ loading: refreshing }">
<text>{{ refreshing ? '查询中...' : '立马查询' }}</text>
</view>
</view>
</view>
<view class="price-change" :class="priceChangeClass">
<text class="change-value">{{ priceChangeText }}</text>
<text class="change-percent">{{ priceChangePercentText }}</text>
</view>
<view class="price-details">
<view class="detail-item">
<text class="detail-label">最高</text>
<text class="detail-value">¥{{ formatPrice(currentPrice.highPrice) }}</text>
</view>
<view class="detail-item">
<text class="detail-label">最低</text>
<text class="detail-value">¥{{ formatPrice(currentPrice.lowPrice) }}</text>
</view>
<view class="detail-item">
<text class="detail-label">开盘</text>
<text class="detail-value">¥{{ formatPrice(currentPrice.openPrice) }}</text>
</view>
<view class="detail-item">
<text class="detail-label">昨收</text>
<text class="detail-value">¥{{ formatPrice(currentPrice.yesterdayClose) }}</text>
</view>
</view>
<view class="update-time">
<text>更新时间: {{ currentPrice.updateTime }}</text>
</view>
</view>
<!-- 加载状态 -->
<view class="loading-card" v-if="loading">
<text class="loading-text">加载中...</text>
</view>
<!-- 无数据状态 -->
<view class="error-card" v-if="!loading && !error && !currentPrice">
<text class="error-text">暂无黄金价格数据请稍后再试</text>
</view>
<!-- 错误状态 -->
<view class="error-card" v-if="error && !loading">
<text class="error-text">{{ error }}</text>
<view class="retry-btn" @click="loadCurrentPrice">
<text>重试</text>
</view>
</view>
<!-- 日期切换区域 -->
<view class="date-switch-section" v-if="currentDate">
<view class="date-switch-container">
<view class="date-btn" @click="goToPreviousDay" :class="{ disabled: isFirstDay }">
<text>前一天</text>
</view>
<view class="date-display">
<text class="date-text">{{ currentDate }}</text>
</view>
<view class="date-btn" @click="goToNextDay" :class="{ disabled: isToday }">
<text>后一天</text>
</view>
</view>
</view>
<!-- 历史价格图表 -->
<view class="chart-section" v-if="dailyPrices.length > 0">
<text class="section-title">{{ currentDate }} 价格走势</text>
<view class="chart-container">
<canvas canvas-id="priceChart" id="priceChart" type="2d" class="price-chart"></canvas>
</view>
</view>
<!-- 无数据提示 -->
<view class="error-card" v-if="!loading && dailyPrices.length === 0 && currentDate">
<text class="error-text">该日期暂无数据</text>
</view>
<!-- 历史价格列表 -->
<view class="history-section" v-if="dailyPrices.length > 0">
<text class="section-title">{{ currentDate }} 价格记录</text>
<view class="history-list">
<view class="history-item" v-for="(item, index) in dailyPrices" :key="index">
<text class="history-date">{{ item.updateTime }}</text>
<text class="history-price">¥{{ formatPrice(item.price) }}</text>
<text class="history-change" :class="getChangeClass(item.priceChange)">
{{ formatChange(item.priceChange) }}
</text>
</view>
</view>
</view>
<!-- 底部预留空白区域避免历史记录被底部导航遮挡 -->
<view class="bottom-spacer"></view>
</view>
</scroll-view>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
import { getCurrentGoldPrice, getGoldPricesByDate, getLatestGoldPriceDate, refreshGoldPrice } from '../../api/gold-price';
const currentPrice = ref(null);
const dailyPrices = ref([]);
const currentDate = ref('');
const loading = ref(false);
const error = ref('');
const refreshing = ref(false);
const priceChangeClass = computed(() => {
if (!currentPrice.value || !currentPrice.value.priceChange) return '';
return currentPrice.value.priceChange >= 0 ? 'up' : 'down';
});
const priceChangeText = computed(() => {
if (!currentPrice.value || currentPrice.value.priceChange === null) return '--';
const change = currentPrice.value.priceChange;
return change >= 0 ? `+${formatPrice(change)}` : formatPrice(change);
});
const priceChangePercentText = computed(() => {
if (!currentPrice.value || currentPrice.value.priceChangePercent === null) return '--';
const percent = currentPrice.value.priceChangePercent;
return percent >= 0 ? `+${percent.toFixed(2)}%` : `${percent.toFixed(2)}%`;
});
const isToday = computed(() => {
if (!currentDate.value) return false;
const today = formatDate(new Date());
return currentDate.value === today;
});
const isFirstDay = computed(() => {
//
//
return false;
});
const formatPrice = (price) => {
if (price === null || price === undefined) return '--';
return Number(price).toFixed(2);
};
const formatChange = (change) => {
if (change === null || change === undefined) return '--';
return change >= 0 ? `+${Number(change).toFixed(2)}` : Number(change).toFixed(2);
};
const getChangeClass = (change) => {
if (change === null || change === undefined) return '';
return change >= 0 ? 'up' : 'down';
};
const formatDate = (date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
const formatTime = (dateTimeStr) => {
if (!dateTimeStr) return '';
// dateTimeStr : "2024-01-15 10:15:00"
return dateTimeStr;
};
const loadCurrentPrice = async () => {
loading.value = true;
error.value = '';
try {
const res = await getCurrentGoldPrice();
if (res && res.code === 200) {
if (res.data) {
currentPrice.value = res.data;
} else {
currentPrice.value = null;
}
} else {
error.value = res?.message || '获取价格失败';
}
} catch (e) {
console.error('获取当前价格失败', e);
error.value = '网络请求失败,请稍后重试';
} finally {
loading.value = false;
}
};
const handleRefreshPrice = async () => {
if (refreshing.value) return;
refreshing.value = true;
try {
const res = await refreshGoldPrice('1053');
if (res && res.code === 200) {
if (res.data) {
currentPrice.value = res.data;
uni.showToast({
title: '查询成功',
icon: 'success'
});
//
const today = formatDate(new Date());
if (currentDate.value === today) {
await loadDailyPrices(today);
}
} else {
uni.showToast({
title: '查询失败',
icon: 'none'
});
}
} else {
uni.showToast({
title: res?.message || '查询失败',
icon: 'none'
});
}
} catch (e) {
console.error('立即查询失败', e);
uni.showToast({
title: '查询失败,请稍后重试',
icon: 'none'
});
} finally {
refreshing.value = false;
}
};
const loadDailyPrices = async (date) => {
try {
const res = await getGoldPricesByDate('1053', date);
if (res && res.code === 200) {
dailyPrices.value = res.data || [];
if (dailyPrices.value.length > 0) {
//
dailyPrices.value.sort((a, b) => {
return new Date(a.updateTime) - new Date(b.updateTime);
});
// canvas
setTimeout(() => {
drawChart();
}, 100);
}
}
} catch (e) {
console.error('获取当天价格失败', e);
}
};
const goToPreviousDay = () => {
if (isFirstDay.value) return;
const date = new Date(currentDate.value);
date.setDate(date.getDate() - 1);
currentDate.value = formatDate(date);
loadDailyPrices(currentDate.value);
};
const goToNextDay = () => {
if (isToday.value) return;
const date = new Date(currentDate.value);
date.setDate(date.getDate() + 1);
const newDate = formatDate(date);
const today = formatDate(new Date());
if (newDate > today) return;
currentDate.value = newDate;
loadDailyPrices(currentDate.value);
};
const drawChart = () => {
const ctx = uni.createCanvasContext('priceChart');
const data = [...dailyPrices.value];
if (data.length === 0) return;
const width = 320;
const height = 180;
const padding = 40;
const chartWidth = width - padding * 2;
const chartHeight = height - padding * 2;
const prices = data.map((item) => Number(item.price));
const minPrice = Math.min(...prices) * 0.998;
const maxPrice = Math.max(...prices) * 1.002;
const priceRange = maxPrice - minPrice;
//
ctx.setFillStyle('#FFF8E1');
ctx.fillRect(0, 0, width, height);
// 线
ctx.setStrokeStyle('#E0E0E0');
ctx.setLineWidth(0.5);
for (let i = 0; i <= 4; i++) {
const y = padding + (chartHeight / 4) * i;
ctx.beginPath();
ctx.moveTo(padding, y);
ctx.lineTo(width - padding, y);
ctx.stroke();
}
// 线
ctx.setStrokeStyle('#FFB300');
ctx.setLineWidth(2);
ctx.beginPath();
data.forEach((item, index) => {
const x = padding + (chartWidth / (data.length - 1 || 1)) * index;
const y = padding + chartHeight - ((Number(item.price) - minPrice) / priceRange) * chartHeight;
if (index === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
});
ctx.stroke();
//
ctx.setFillStyle('#FF8F00');
data.forEach((item, index) => {
const x = padding + (chartWidth / (data.length - 1 || 1)) * index;
const y = padding + chartHeight - ((Number(item.price) - minPrice) / priceRange) * chartHeight;
ctx.beginPath();
ctx.arc(x, y, 3, 0, 2 * Math.PI);
ctx.fill();
});
// Y
ctx.setFillStyle('#5D4037');
ctx.setFontSize(10);
for (let i = 0; i <= 4; i++) {
const price = maxPrice - (priceRange / 4) * i;
const y = padding + (chartHeight / 4) * i;
ctx.fillText(price.toFixed(1), 5, y + 3);
}
// X
ctx.setFillStyle('#5D4037');
ctx.setFontSize(9);
const labelInterval = Math.max(1, Math.floor(data.length / 6)); // 6
data.forEach((item, index) => {
if (index % labelInterval === 0 || index === data.length - 1) {
const x = padding + (chartWidth / (data.length - 1 || 1)) * index;
// updateTime HH:mm
const timeStr = item.updateTime ? item.updateTime.split(' ')[1]?.substring(0, 5) : '';
if (timeStr) {
ctx.fillText(timeStr, x - 15, height - 10);
}
}
});
ctx.draw();
};
const initPage = async () => {
//
await loadCurrentPrice();
//
try {
const res = await getLatestGoldPriceDate('1053');
if (res && res.code === 200 && res.data) {
const latestDate = res.data;
const today = formatDate(new Date());
//
currentDate.value = latestDate === today ? today : latestDate;
} else {
//
currentDate.value = formatDate(new Date());
}
} catch (e) {
console.error('获取最近日期失败', e);
//
currentDate.value = formatDate(new Date());
}
//
if (currentDate.value) {
await loadDailyPrices(currentDate.value);
}
};
onMounted(() => {
initPage();
});
</script>
<style scoped>
.page-scroll {
height: 100vh;
}
.container {
min-height: 100vh;
background: linear-gradient(135deg, #ffe5cc 0%, #ffd4a8 50%, #ffc08a 100%);
padding: 20rpx;
padding-bottom: calc(40rpx + env(safe-area-inset-bottom));
}
.header {
text-align: center;
padding: 30rpx 0;
}
.title {
display: block;
font-size: 44rpx;
font-weight: bold;
color: #5d4037;
text-shadow: 2rpx 2rpx 0 #ffd700;
}
.subtitle {
display: block;
font-size: 26rpx;
color: #8d6e63;
margin-top: 10rpx;
}
.price-card {
background: rgba(255, 255, 255, 0.95);
border-radius: 20rpx;
padding: 30rpx;
margin-bottom: 20rpx;
border: 3rpx solid #c8956e;
box-shadow: 0 8rpx 20rpx rgba(200, 149, 110, 0.15);
}
.price-main {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20rpx;
}
.price-main-left {
flex: 1;
text-align: center;
}
.price-main-right {
flex: 0 0 auto;
margin-left: 20rpx;
}
.price-label {
display: block;
font-size: 26rpx;
color: #8d6e63;
margin-bottom: 10rpx;
}
.price-value {
display: block;
font-size: 60rpx;
font-weight: bold;
color: #5d4037;
}
.refresh-btn {
padding: 12rpx 24rpx;
background: linear-gradient(135deg, #c8956e, #ffd700);
border-radius: 20rpx;
border: 2rpx solid #5d4037;
text-align: center;
min-width: 120rpx;
}
.refresh-btn text {
font-size: 24rpx;
color: #5d4037;
font-weight: 600;
}
.refresh-btn.loading {
opacity: 0.7;
background: linear-gradient(135deg, #bdbdbd, #e0e0e0);
}
.refresh-btn.loading text {
color: #757575;
}
.price-change {
display: flex;
justify-content: center;
gap: 20rpx;
margin-bottom: 30rpx;
}
.price-change.up .change-value,
.price-change.up .change-percent {
color: #fa3534;
}
.price-change.down .change-value,
.price-change.down .change-percent {
color: #19be6b;
}
.change-value,
.change-percent {
font-size: 32rpx;
font-weight: 600;
}
.price-details {
display: flex;
flex-wrap: wrap;
gap: 15rpx;
}
.detail-item {
flex: 1;
min-width: 45%;
background: linear-gradient(135deg, rgba(200, 149, 110, 0.1), rgba(255, 215, 0, 0.1));
padding: 15rpx;
border-radius: 10rpx;
text-align: center;
}
.detail-label {
display: block;
font-size: 22rpx;
color: #8d6e63;
margin-bottom: 5rpx;
}
.detail-value {
display: block;
font-size: 28rpx;
font-weight: 600;
color: #5d4037;
}
.update-time {
text-align: center;
margin-top: 20rpx;
font-size: 22rpx;
color: #bcaaa4;
}
.loading-card,
.error-card {
background: rgba(255, 255, 255, 0.95);
border-radius: 20rpx;
padding: 60rpx 30rpx;
margin-bottom: 20rpx;
border: 3rpx solid #c8956e;
text-align: center;
}
.loading-text {
font-size: 28rpx;
color: #8d6e63;
}
.error-text {
display: block;
font-size: 28rpx;
color: #fa3534;
margin-bottom: 20rpx;
}
.retry-btn {
display: inline-block;
padding: 15rpx 40rpx;
background: linear-gradient(135deg, #c8956e, #ffd700);
border-radius: 30rpx;
border: 2rpx solid #5d4037;
}
.retry-btn text {
font-size: 26rpx;
color: #5d4037;
font-weight: 600;
}
.date-switch-section {
background: rgba(255, 255, 255, 0.95);
border-radius: 20rpx;
padding: 20rpx 30rpx;
margin-bottom: 20rpx;
border: 3rpx solid #c8956e;
}
.date-switch-container {
display: flex;
justify-content: space-between;
align-items: center;
}
.date-btn {
flex: 1;
padding: 15rpx 20rpx;
background: linear-gradient(135deg, #c8956e, #ffd700);
border-radius: 15rpx;
border: 2rpx solid #5d4037;
text-align: center;
}
.date-btn text {
font-size: 26rpx;
color: #5d4037;
font-weight: 600;
}
.date-btn.disabled {
background: #e0e0e0;
border-color: #bdbdbd;
opacity: 0.5;
}
.date-btn.disabled text {
color: #9e9e9e;
}
.date-display {
flex: 2;
text-align: center;
padding: 0 20rpx;
}
.date-text {
font-size: 32rpx;
font-weight: 600;
color: #5d4037;
}
.chart-section,
.history-section {
background: rgba(255, 255, 255, 0.95);
border-radius: 20rpx;
padding: 30rpx;
margin-bottom: 20rpx;
border: 3rpx solid #c8956e;
}
.section-title {
display: block;
font-size: 30rpx;
font-weight: 600;
color: #5d4037;
margin-bottom: 20rpx;
}
.chart-container {
width: 100%;
height: 360rpx;
display: flex;
justify-content: center;
align-items: center;
}
.price-chart {
width: 640rpx;
height: 360rpx;
}
.history-list {
max-height: 500rpx;
overflow-y: auto;
}
.history-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20rpx 0;
border-bottom: 1rpx solid rgba(200, 149, 110, 0.2);
}
.history-item:last-child {
border-bottom: none;
}
.history-date {
font-size: 24rpx;
color: #8d6e63;
flex: 1.5;
}
.history-price {
font-size: 28rpx;
font-weight: 600;
color: #5d4037;
flex: 1;
text-align: center;
}
.history-change {
font-size: 26rpx;
font-weight: 600;
flex: 1;
text-align: right;
}
.history-change.up {
color: #fa3534;
}
.history-change.down {
color: #19be6b;
}
.bottom-spacer {
height: 200px;
}
</style>

View File

@ -1,14 +1,12 @@
<template>
<scroll-view class="page-scroll" scroll-y="true" show-scrollbar="false">
<view class="container">
<view class="header">
<view class="header-top">
<text class="title">我的账单</text>
<view class="header-actions">
<view class="action-btn" @click="goToBudget">
<text class="action-icon">📊</text>
</view>
<view class="action-btn" @click="goToAccount">
<text class="action-icon">💰</text>
<view class="action-btn" @click="goToGoldPrice">
<text class="action-icon">🪙</text>
</view>
</view>
</view>
@ -47,7 +45,8 @@
</view>
</view>
<scroll-view class="bill-list" scroll-y="true" show-scrollbar="false" enhanced="true" :bounce="false">
<!-- 账单列表 -->
<view class="bill-list">
<view v-for="(group, date) in groupedBills" :key="date" class="bill-group">
<view class="group-header">
<text class="date">{{ date }}</text>
@ -73,8 +72,9 @@
<view v-if="loading" class="loading">
<text>加载中...</text>
</view>
</scroll-view>
</view>
</view>
</scroll-view>
</template>
<script setup>
@ -174,6 +174,32 @@ const goToAccount = () => {
})
}
const goToGoldPrice = () => {
//
// APKpages.json
uni.navigateTo({
url: '/pages/gold-price/gold-price',
success: () => {
console.log('跳转成功')
},
fail: (err) => {
console.error('跳转失败:', err)
// navigateTo使reLaunch
uni.reLaunch({
url: '/pages/gold-price/gold-price',
fail: (err2) => {
console.error('reLaunch也失败:', err2)
uni.showToast({
title: '页面跳转失败,请检查路径配置',
icon: 'none',
duration: 2000
})
}
})
}
})
}
const goToBudget = () => {
//
uni.navigateTo({
@ -230,16 +256,65 @@ export default {
</script>
<style scoped>
/* 页面滚动容器 */
.page-scroll {
height: 100vh;
width: 100%;
}
/* 卡通风格配色 */
.container {
min-height: calc(100vh - 100rpx);
background: #f5f5f5;
min-height: 100vh;
background: linear-gradient(135deg, #FFE5CC 0%, #FFD4A8 50%, #FFC08A 100%);
padding-bottom: calc(170rpx + env(safe-area-inset-bottom));
position: relative;
overflow: visible;
}
/* 背景装饰圆球 */
.container::before,
.container::after {
content: '';
position: absolute;
border-radius: 50%;
opacity: 0.2;
filter: blur(60rpx);
pointer-events: none;
}
.container::before {
width: 300rpx;
height: 300rpx;
background: linear-gradient(135deg, #C8956E, #FFB6C1);
top: 100rpx;
right: -50rpx;
animation: float 8s ease-in-out infinite;
}
.container::after {
width: 250rpx;
height: 250rpx;
background: linear-gradient(135deg, #FFD700, #F5D59E);
bottom: 200rpx;
left: -50rpx;
animation: float 10s ease-in-out infinite reverse;
}
@keyframes float {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(-20rpx, 30rpx); }
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
background: linear-gradient(135deg, #C8956E 0%, #F5D59E 50%, #FFD700 100%);
padding: 40rpx 30rpx;
color: #fff;
color: #5D4037;
border-radius: 0 0 40rpx 40rpx;
box-shadow: 0 10rpx 30rpx rgba(93, 64, 55, 0.15);
border: 3rpx solid #5D4037;
border-top: none;
position: relative;
z-index: 10;
}
.header-top {
@ -250,9 +325,13 @@ export default {
}
.title {
font-size: 40rpx;
font-size: 44rpx;
font-weight: bold;
display: block;
text-shadow:
2rpx 2rpx 0 #FFD700,
-1rpx -1rpx 0 #FFD700,
0 4rpx 10rpx rgba(255, 215, 0, 0.3);
}
.header-actions {
@ -261,30 +340,34 @@ export default {
}
.action-btn {
width: 60rpx;
height: 60rpx;
width: 70rpx;
height: 70rpx;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.9);
border-radius: 50%;
cursor: pointer;
transition: all 0.3s;
border: 3rpx solid #5D4037;
box-shadow: 0 4rpx 15rpx rgba(93, 64, 55, 0.2);
}
.action-btn:active {
background: rgba(255, 255, 255, 0.3);
transform: scale(0.95);
background: #fff;
transform: scale(0.9);
box-shadow: 0 2rpx 8rpx rgba(93, 64, 55, 0.15);
}
.action-icon {
font-size: 36rpx;
font-size: 40rpx;
filter: drop-shadow(0 2rpx 4rpx rgba(0, 0, 0, 0.1));
}
.summary-row {
display: flex;
justify-content: space-around;
margin-bottom: 20rpx;
margin-bottom: 25rpx;
}
.summary-item {
@ -294,30 +377,34 @@ export default {
.label {
display: block;
font-size: 24rpx;
opacity: 0.8;
font-size: 26rpx;
opacity: 0.85;
margin-bottom: 10rpx;
font-weight: 500;
}
.amount {
display: block;
font-size: 36rpx;
font-size: 40rpx;
font-weight: bold;
text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
}
.amount.income {
color: #51cf66;
color: #19BE6B;
}
.amount.expense {
color: #ff6b6b;
color: #FA3534;
}
.budget-section {
background: rgba(255, 255, 255, 0.15);
border-radius: 12rpx;
background: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(10rpx);
border-radius: 20rpx;
padding: 30rpx 20rpx 20rpx;
backdrop-filter: blur(10px);
border: 3rpx solid #5D4037;
box-shadow: 0 8rpx 20rpx rgba(93, 64, 55, 0.15);
}
.budget-chart-wrapper {
@ -336,36 +423,51 @@ export default {
.budget-stat-item {
flex: 1;
text-align: center;
background: rgba(255, 255, 255, 0.1);
background: linear-gradient(135deg, rgba(200, 149, 110, 0.2), rgba(255, 215, 0, 0.2));
padding: 20rpx 15rpx;
border-radius: 10rpx;
border-radius: 15rpx;
border: 2rpx solid #C8956E;
}
.stat-label {
display: block;
font-size: 22rpx;
font-size: 24rpx;
opacity: 0.85;
margin-bottom: 10rpx;
color: #fff;
color: #5D4037;
font-weight: 500;
}
.stat-value {
display: block;
font-size: 30rpx;
font-size: 32rpx;
font-weight: bold;
color: #fff;
color: #5D4037;
}
.bill-list {
padding: 20rpx;
height: calc(100vh - 520rpx - env(safe-area-inset-bottom));
}
.bill-group {
background: #fff;
border-radius: 12rpx;
background: rgba(255, 255, 255, 0.95);
border-radius: 20rpx;
margin-bottom: 20rpx;
overflow: hidden;
border: 3rpx solid #C8956E;
box-shadow: 0 8rpx 20rpx rgba(200, 149, 110, 0.15);
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(20rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.group-header {
@ -373,26 +475,32 @@ export default {
justify-content: space-between;
align-items: center;
padding: 20rpx 30rpx;
background: #f8f8f8;
border-bottom: 1rpx solid #eee;
background: linear-gradient(135deg, rgba(200, 149, 110, 0.15), rgba(255, 215, 0, 0.15));
border-bottom: 2rpx solid #FFD700;
}
.date {
font-size: 28rpx;
color: #666;
color: #5D4037;
font-weight: 600;
}
.total {
font-size: 28rpx;
font-size: 30rpx;
font-weight: bold;
color: #333;
color: #5D4037;
}
.bill-item {
display: flex;
align-items: center;
padding: 30rpx;
border-bottom: 1rpx solid #f0f0f0;
border-bottom: 1rpx solid rgba(200, 149, 110, 0.2);
transition: background 0.2s;
}
.bill-item:active {
background: rgba(255, 215, 0, 0.1);
}
.bill-item:last-child {
@ -400,8 +508,9 @@ export default {
}
.bill-icon {
font-size: 48rpx;
font-size: 52rpx;
margin-right: 20rpx;
filter: drop-shadow(0 2rpx 6rpx rgba(0, 0, 0, 0.15));
}
.bill-info {
@ -412,46 +521,58 @@ export default {
.bill-category {
font-size: 30rpx;
color: #333;
color: #5D4037;
margin-bottom: 8rpx;
font-weight: 600;
}
.bill-desc {
font-size: 24rpx;
color: #999;
color: #8D6E63;
}
.bill-amount {
font-size: 32rpx;
font-size: 34rpx;
font-weight: bold;
}
.bill-amount.income {
color: #19be6b;
color: #19BE6B;
}
.bill-amount.expense {
color: #fa3534;
color: #FA3534;
}
.empty {
text-align: center;
padding: 100rpx 0;
color: #999;
padding: 120rpx 0;
color: #8D6E63;
display: flex;
flex-direction: column;
align-items: center;
background: rgba(255, 255, 255, 0.8);
border-radius: 20rpx;
margin: 20rpx;
border: 3rpx dashed #C8956E;
}
.empty text:first-child {
font-size: 32rpx;
font-weight: 600;
color: #5D4037;
}
.empty-tip {
font-size: 24rpx;
font-size: 26rpx;
margin-top: 20rpx;
color: #ccc;
color: #BCAAA4;
}
.loading {
text-align: center;
padding: 40rpx 0;
color: #999;
color: #8D6E63;
font-size: 28rpx;
}
</style>

View File

@ -1,66 +1,159 @@
<template>
<view class="login-container">
<!-- 背景装饰圆球 -->
<view class="bg-ball ball-1"></view>
<view class="bg-ball ball-2"></view>
<view class="bg-ball ball-3"></view>
<view class="bg-ball ball-4"></view>
<view class="login-header">
<!-- Logo区域 -->
<view class="login-header fade-in">
<image class="app-logo" src="/static/images/logo.png" mode="aspectFit"></image>
<view class="title-decoration">
<view class="deco-line left"></view>
<text class="app-title">强宝爱记账</text>
<view class="deco-line right"></view>
</view>
<text class="app-subtitle">智能记账轻松管理</text>
</view>
<view class="login-form">
<!-- 登录表单卡片 -->
<view class="login-form glass-card">
<!-- 用户名输入框 -->
<view class="form-item">
<view :class="['input-wrapper', focusField === 'username' ? 'focused' : '']">
<text class="input-icon">👤</text>
<input
v-model="form.username"
placeholder="请输入用户名"
class="input"
maxlength="20"
@focus="focusField = 'username'"
@blur="focusField = ''"
/>
</view>
</view>
<!-- 密码输入框 -->
<view class="form-item">
<view :class="['input-wrapper', focusField === 'password' ? 'focused' : '']">
<text class="input-icon">🔒</text>
<input
v-model="form.password"
placeholder="请输入密码"
type="password"
:type="showPassword ? 'text' : 'password'"
class="input"
maxlength="20"
@focus="focusField = 'password'"
@blur="focusField = ''"
/>
<text class="eye-icon" @click="togglePassword">
{{ showPassword ? '👁️' : '👁️‍🗨️' }}
</text>
</view>
</view>
<!-- 昵称输入框注册时 -->
<view v-if="isRegister" class="form-item">
<view :class="['input-wrapper', focusField === 'nickname' ? 'focused' : '']">
<text class="input-icon"></text>
<input
v-model="form.nickname"
placeholder="请输入昵称(可选)"
class="input"
maxlength="20"
@focus="focusField = 'nickname'"
@blur="focusField = ''"
/>
</view>
</view>
<button class="login-btn" @click="handleSubmit" :loading="loading">
{{ isRegister ? '注册' : '登录' }}
<!-- 记住密码和忘记密码 -->
<view v-if="!isRegister" class="options-row">
<view class="checkbox-wrapper" @click="rememberMe = !rememberMe">
<view :class="['checkbox', rememberMe ? 'checked' : '']">
<text v-if="rememberMe" class="check-icon"></text>
</view>
<text class="checkbox-label">记住密码</text>
</view>
</view>
<!-- 协议勾选注册时 -->
<view v-if="isRegister" class="options-row">
<view class="checkbox-wrapper" @click="agreeProtocol = !agreeProtocol">
<view :class="['checkbox', agreeProtocol ? 'checked' : '']">
<text v-if="agreeProtocol" class="check-icon"></text>
</view>
<text class="checkbox-label">我已阅读并同意用户协议和隐私政策</text>
</view>
</view>
<!-- 登录/注册按钮 -->
<button class="login-btn gradient-btn" @click="handleSubmit" :loading="loading" :disabled="loading">
<view v-if="!loading" class="btn-content">
{{ isRegister ? '🎉 立即注册' : '🚀 立即登录' }}
</view>
<view v-else class="btn-loading">
<view class="loading-spinner"></view>
<text>{{ isRegister ? '注册中...' : '登录中...' }}</text>
</view>
</button>
<!-- 切换模式 -->
<view class="switch-mode" @click="switchMode">
<text>{{ isRegister ? '已有账号?去登录' : '没有账号?去注册' }}</text>
<text>{{ isRegister ? '已有账号?去登录' : '没有账号?去注册' }}</text>
</view>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { ref, onMounted } from 'vue'
import { register, login } from '../../api/auth'
import { useUserStore } from '../../store/user'
import { encryptPassword, decryptPassword } from '../../utils/crypto'
const userStore = useUserStore()
const isRegister = ref(false)
const loading = ref(false)
const showPassword = ref(false)
const focusField = ref('')
const rememberMe = ref(false)
const agreeProtocol = ref(false)
const form = ref({
username: '',
password: '',
nickname: ''
})
//
onMounted(() => {
try {
const savedUsername = uni.getStorageSync('saved_username')
const encryptedPassword = uni.getStorageSync('saved_password')
if (savedUsername && encryptedPassword) {
form.value.username = savedUsername
//
form.value.password = decryptPassword(encryptedPassword)
rememberMe.value = true
}
} catch (error) {
console.error('加载保存的密码失败:', error)
//
uni.removeStorageSync('saved_username')
uni.removeStorageSync('saved_password')
}
})
// /
const togglePassword = () => {
showPassword.value = !showPassword.value
}
// /
const switchMode = () => {
isRegister.value = !isRegister.value
form.value = {
@ -68,8 +161,10 @@ const switchMode = () => {
password: '',
nickname: ''
}
agreeProtocol.value = false
}
//
const handleSubmit = async () => {
if (!form.value.username || !form.value.password) {
uni.showToast({
@ -87,6 +182,15 @@ const handleSubmit = async () => {
return
}
//
if (isRegister.value && !agreeProtocol.value) {
uni.showToast({
title: '请先阅读并同意用户协议',
icon: 'none'
})
return
}
loading.value = true
try {
@ -110,8 +214,18 @@ const handleSubmit = async () => {
nickname: response.nickname
})
//
if (!isRegister.value && rememberMe.value) {
uni.setStorageSync('saved_username', form.value.username)
//
uni.setStorageSync('saved_password', encryptPassword(form.value.password))
} else if (!rememberMe.value) {
uni.removeStorageSync('saved_username')
uni.removeStorageSync('saved_password')
}
uni.showToast({
title: isRegister.value ? '注册成功' : '登录成功',
title: isRegister.value ? '🎉 注册成功' : '🚀 登录成功',
icon: 'success'
})
@ -135,73 +249,436 @@ const handleSubmit = async () => {
</script>
<style scoped>
/* 主容器 - 卡通背景 */
.login-container {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
background: linear-gradient(135deg, #FFE5CC 0%, #FFD4A8 50%, #FFC08A 100%);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40rpx;
position: relative;
overflow: hidden;
}
/* 背景装饰圆球 */
.bg-ball {
position: absolute;
border-radius: 50%;
opacity: 0.3;
filter: blur(80rpx);
animation: float 6s ease-in-out infinite;
}
.ball-1 {
width: 400rpx;
height: 400rpx;
background: linear-gradient(135deg, #C8956E, #FFB6C1);
top: -100rpx;
left: -100rpx;
animation-delay: 0s;
}
.ball-2 {
width: 300rpx;
height: 300rpx;
background: linear-gradient(135deg, #F5D59E, #FFD700);
top: 200rpx;
right: -50rpx;
animation-delay: 1s;
}
.ball-3 {
width: 350rpx;
height: 350rpx;
background: linear-gradient(135deg, #FFB6C1, #C8956E);
bottom: 100rpx;
left: 50rpx;
animation-delay: 2s;
}
.ball-4 {
width: 250rpx;
height: 250rpx;
background: linear-gradient(135deg, #FFD700, #F5D59E);
bottom: -50rpx;
right: 100rpx;
animation-delay: 3s;
}
@keyframes float {
0%, 100% {
transform: translate(0, 0) scale(1);
}
25% {
transform: translate(20rpx, -30rpx) scale(1.1);
}
50% {
transform: translate(-20rpx, 20rpx) scale(0.9);
}
75% {
transform: translate(30rpx, 10rpx) scale(1.05);
}
}
/* Logo区域 */
.login-header {
text-align: center;
margin-bottom: 80rpx;
margin-bottom: 60rpx;
position: relative;
z-index: 10;
}
/* 入场动画 */
.fade-in {
animation: fadeInDown 0.8s ease-out;
}
@keyframes fadeInDown {
from {
opacity: 0;
transform: translateY(-50rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Logo图片 */
.app-logo {
width: 280rpx;
height: 280rpx;
margin-bottom: 30rpx;
filter: drop-shadow(0 8rpx 20rpx rgba(93, 64, 55, 0.3));
animation: bounce 2s ease-in-out infinite;
}
@keyframes bounce {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-20rpx);
}
}
/* 标题装饰容器 */
.title-decoration {
display: flex;
align-items: center;
justify-content: center;
gap: 20rpx;
margin-bottom: 20rpx;
}
/* 装饰线条 */
.deco-line {
width: 60rpx;
height: 6rpx;
background: linear-gradient(90deg, #C8956E, #FFD700);
border-radius: 10rpx;
position: relative;
}
.deco-line::after {
content: '';
position: absolute;
width: 12rpx;
height: 12rpx;
background: #FFD700;
border-radius: 50%;
top: 50%;
transform: translateY(-50%);
box-shadow: 0 0 10rpx #FFD700;
}
.deco-line.left::after {
left: -6rpx;
}
.deco-line.right::after {
right: -6rpx;
}
/* 标题文字 */
.app-title {
display: block;
font-size: 56rpx;
font-weight: bold;
color: #fff;
margin-bottom: 20rpx;
color: #5D4037;
text-shadow:
3rpx 3rpx 0 #FFD700,
-1rpx -1rpx 0 #FFD700,
1rpx -1rpx 0 #FFD700,
-1rpx 1rpx 0 #FFD700,
0 8rpx 20rpx rgba(255, 215, 0, 0.4);
letter-spacing: 4rpx;
}
/* 副标题 */
.app-subtitle {
display: block;
font-size: 28rpx;
color: rgba(255, 255, 255, 0.8);
color: #8D6E63;
font-weight: 500;
}
/* 表单卡片 - 毛玻璃效果 */
.login-form {
width: 100%;
max-width: 600rpx;
position: relative;
z-index: 10;
}
.glass-card {
background: rgba(255, 255, 255, 0.75);
backdrop-filter: blur(20rpx);
border-radius: 40rpx;
padding: 60rpx 50rpx;
box-shadow:
0 20rpx 60rpx rgba(93, 64, 55, 0.15),
0 0 0 2rpx rgba(255, 255, 255, 0.5),
inset 0 2rpx 4rpx rgba(255, 255, 255, 0.8);
border: 3rpx solid #5D4037;
animation: slideUp 0.8s ease-out 0.3s both;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(100rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 表单项 */
.form-item {
margin-bottom: 30rpx;
}
.input {
width: 100%;
height: 88rpx;
box-sizing: border-box;
background: #fff;
border-radius: 12rpx;
/* 输入框容器 */
.input-wrapper {
position: relative;
display: flex;
align-items: center;
background: rgba(255, 255, 255, 0.9);
border-radius: 20rpx;
padding: 0 30rpx;
font-size: 28rpx;
height: 96rpx;
border: 3rpx solid #C8956E;
box-shadow: 0 4rpx 15rpx rgba(200, 149, 110, 0.2);
transition: all 0.3s ease;
}
.login-btn {
width: 100%;
height: 88rpx;
background: #fff;
color: #667eea;
border-radius: 12rpx;
font-size: 32rpx;
font-weight: bold;
margin-top: 40rpx;
.input-wrapper.focused {
border-color: #FFD700;
box-shadow:
0 0 0 4rpx rgba(255, 215, 0, 0.3),
0 8rpx 25rpx rgba(255, 215, 0, 0.4);
transform: translateY(-2rpx);
}
/* 输入框图标 */
.input-icon {
font-size: 40rpx;
margin-right: 20rpx;
filter: drop-shadow(0 2rpx 4rpx rgba(0, 0, 0, 0.1));
}
/* 输入框 */
.input {
flex: 1;
height: 100%;
font-size: 28rpx;
color: #5D4037;
background: transparent;
border: none;
}
.input::placeholder {
color: #BCAAA4;
}
/* 眼睛图标 */
.eye-icon {
font-size: 36rpx;
padding: 10rpx;
cursor: pointer;
user-select: none;
transition: transform 0.2s;
}
.eye-icon:active {
transform: scale(0.9);
}
/* 选项行 */
.options-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 30rpx;
}
/* 复选框容器 */
.checkbox-wrapper {
display: flex;
align-items: center;
cursor: pointer;
user-select: none;
}
/* 复选框 */
.checkbox {
width: 40rpx;
height: 40rpx;
border: 3rpx solid #C8956E;
border-radius: 10rpx;
background: #fff;
display: flex;
align-items: center;
justify-content: center;
margin-right: 15rpx;
transition: all 0.3s ease;
}
.checkbox.checked {
background: linear-gradient(135deg, #FFD700, #F5D59E);
border-color: #FFD700;
box-shadow: 0 0 10rpx rgba(255, 215, 0, 0.5);
}
.check-icon {
color: #5D4037;
font-size: 28rpx;
font-weight: bold;
}
/* 复选框标签 */
.checkbox-label {
font-size: 26rpx;
color: #6D4C41;
}
/* 渐变按钮 */
.login-btn {
width: 100%;
height: 100rpx;
background: linear-gradient(135deg, #C8956E 0%, #F5D59E 50%, #FFD700 100%);
background-size: 200% 200%;
color: #5D4037;
border-radius: 50rpx;
font-size: 32rpx;
font-weight: bold;
margin-top: 40rpx;
border: 4rpx solid #5D4037;
box-shadow:
0 10rpx 30rpx rgba(200, 149, 110, 0.4),
inset 0 2rpx 4rpx rgba(255, 255, 255, 0.6);
position: relative;
overflow: hidden;
transition: all 0.3s ease;
}
.login-btn::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg,
transparent,
rgba(255, 255, 255, 0.4),
transparent
);
transition: left 0.5s;
}
.login-btn:active::before {
left: 100%;
}
.login-btn:active {
transform: scale(0.98);
box-shadow:
0 5rpx 15rpx rgba(200, 149, 110, 0.3),
inset 0 2rpx 4rpx rgba(255, 255, 255, 0.6);
}
.login-btn[disabled] {
opacity: 0.7;
}
/* 按钮内容 */
.btn-content {
display: flex;
align-items: center;
justify-content: center;
gap: 10rpx;
}
/* 加载动画 */
.btn-loading {
display: flex;
align-items: center;
justify-content: center;
gap: 20rpx;
}
.loading-spinner {
width: 40rpx;
height: 40rpx;
border: 4rpx solid rgba(93, 64, 55, 0.2);
border-top-color: #5D4037;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* 切换模式 */
.switch-mode {
text-align: center;
margin-top: 40rpx;
padding: 20rpx;
}
.switch-mode text {
color: rgba(255, 255, 255, 0.9);
font-size: 26rpx;
color: #6D4C41;
font-size: 28rpx;
font-weight: 500;
position: relative;
cursor: pointer;
transition: color 0.3s;
}
.switch-mode text::after {
content: '';
position: absolute;
bottom: -5rpx;
left: 0;
width: 0;
height: 3rpx;
background: linear-gradient(90deg, #C8956E, #FFD700);
transition: width 0.3s;
}
.switch-mode:active text {
color: #FFD700;
}
.switch-mode:active text::after {
width: 100%;
}
</style>

586
pages/mine/mine.vue Normal file
View File

@ -0,0 +1,586 @@
<template>
<scroll-view scroll-y class="mine-page">
<view class="mine-container">
<!-- 用户信息卡片 -->
<view class="user-card">
<view class="user-avatar">👤</view>
<view class="user-info">
<text class="user-name">{{ userInfo.nickname || userInfo.username || '用户' }}</text>
<text class="user-id">ID: {{ userInfo.id || '-' }}</text>
</view>
</view>
<!-- 账户余额卡片 -->
<view class="balance-card" @click="openBalanceModal">
<view class="balance-header">
<text class="balance-title">账户余额</text>
<text class="balance-arrow">></text>
</view>
<text class="balance-amount" :class="account.balance >= 0 ? 'balance-positive' : 'balance-negative'">
¥{{ account.balance ? account.balance.toFixed(2) : '0.00' }}
</text>
<view class="balance-stats">
<view class="balance-stat-item">
<text class="balance-stat-label">总收入</text>
<text class="balance-stat-value income">¥{{ account.totalIncome ? account.totalIncome.toFixed(2) : '0.00' }}</text>
</view>
<view class="balance-stat-item">
<text class="balance-stat-label">总支出</text>
<text class="balance-stat-value expense">¥{{ account.totalExpense ? account.totalExpense.toFixed(2) : '0.00' }}</text>
</view>
</view>
</view>
<!-- 预算卡片 -->
<view class="budget-card" @click="openBudgetModal">
<view class="budget-header">
<text class="budget-title">本月预算</text>
<text class="budget-arrow">></text>
</view>
<view class="budget-content">
<view class="budget-progress-wrapper">
<view class="budget-progress-bar">
<view class="budget-progress-fill" :style="{ width: budgetProgress + '%' }"></view>
</view>
<text class="budget-progress-text">{{ budgetProgress.toFixed(0) }}%</text>
</view>
<view class="budget-stats">
<view class="budget-stat-item">
<text class="budget-stat-label">预算</text>
<text class="budget-stat-value">¥{{ budget.amount ? budget.amount.toFixed(2) : '0.00' }}</text>
</view>
<view class="budget-stat-item">
<text class="budget-stat-label">已用</text>
<text class="budget-stat-value expense">¥{{ budget.usedAmount ? budget.usedAmount.toFixed(2) : '0.00' }}</text>
</view>
<view class="budget-stat-item">
<text class="budget-stat-label">剩余</text>
<text class="budget-stat-value" :class="budget.remainingAmount >= 0 ? 'positive' : 'negative'">
¥{{ budget.remainingAmount !== undefined ? budget.remainingAmount.toFixed(2) : '0.00' }}
</text>
</view>
</view>
</view>
</view>
<!-- 功能菜单 -->
<view class="menu-section">
<view class="menu-item" @click="goToStatistics">
<view class="menu-icon-wrapper">
<text class="menu-icon">📊</text>
</view>
<text class="menu-text">统计分析</text>
<text class="menu-arrow">></text>
</view>
<view class="menu-item" @click="goToAccount">
<view class="menu-icon-wrapper">
<text class="menu-icon">💰</text>
</view>
<text class="menu-text">账户管理</text>
<text class="menu-arrow">></text>
</view>
<view class="menu-item" @click="goToBudget">
<view class="menu-icon-wrapper">
<text class="menu-icon">📝</text>
</view>
<text class="menu-text">预算设置</text>
<text class="menu-arrow">></text>
</view>
</view>
<!-- 退出登录按钮 -->
<button class="logout-button" @click="logout">退出登录</button>
</view>
<!-- 编辑弹窗 -->
<EditModal
:visible="modalState.visible"
:title="modalState.title"
:placeholder="modalState.placeholder"
:currentValue="modalState.currentValue"
@confirm="handleModalConfirm"
@cancel="handleModalCancel"
/>
</scroll-view>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { getAccount } from '../../api/account'
import { getBudget } from '../../api/budget'
import { updateAccountBalance, updateBudget } from '../../api/edit-modal'
import EditModal from '../../components/EditModal/EditModal.vue'
const userInfo = ref({
id: null,
username: '',
nickname: ''
})
const account = ref({
balance: 0,
totalIncome: 0,
totalExpense: 0
})
const budget = ref({
amount: 0,
usedAmount: 0,
remainingAmount: 0
})
const modalState = ref({
visible: false,
title: '',
placeholder: '',
currentValue: '',
type: '' // 'balance' 'budget'
})
const budgetProgress = computed(() => {
if (!budget.value.amount || budget.value.amount === 0) return 0
const progress = (budget.value.usedAmount / budget.value.amount) * 100
return Math.min(progress, 100)
})
const loadUserInfo = () => {
try {
const info = uni.getStorageSync('userInfo')
if (info) {
userInfo.value = typeof info === 'string' ? JSON.parse(info) : info
}
} catch (error) {
console.error('加载用户信息失败', error)
}
}
const loadAccount = async () => {
try {
const data = await getAccount()
if (data) {
account.value = data
}
} catch (error) {
console.error('加载账户失败', error)
}
}
const loadBudget = async () => {
try {
const data = await getBudget()
if (data) {
budget.value = data
}
} catch (error) {
console.error('加载预算失败', error)
}
}
const openBalanceModal = () => {
modalState.value = {
visible: true,
title: '编辑账户余额',
placeholder: '请输入新的账户余额',
currentValue: account.value.balance || 0,
type: 'balance'
}
}
const openBudgetModal = () => {
modalState.value = {
visible: true,
title: '编辑本月预算',
placeholder: '请输入新的预算金额',
currentValue: budget.value.amount || 0,
type: 'budget'
}
}
const handleModalConfirm = async (value) => {
try {
if (modalState.value.type === 'balance') {
await updateAccountBalance(parseFloat(value))
account.value.balance = parseFloat(value)
uni.showToast({
title: '账户余额已更新',
icon: 'success',
duration: 2000
})
} else if (modalState.value.type === 'budget') {
await updateBudget(parseFloat(value))
budget.value.amount = parseFloat(value)
uni.showToast({
title: '预算已更新',
icon: 'success',
duration: 2000
})
}
handleModalCancel()
} catch (error) {
console.error('更新失败', error)
uni.showToast({
title: '更新失败,请重试',
icon: 'error',
duration: 2000
})
}
}
const handleModalCancel = () => {
modalState.value.visible = false
}
const goToStatistics = () => {
uni.switchTab({
url: '/pages/statistics/statistics'
})
}
const goToAccount = () => {
uni.navigateTo({
url: '/pages/account/account'
})
}
const goToBudget = () => {
uni.navigateTo({
url: '/pages/budget/budget'
})
}
const logout = () => {
uni.showModal({
title: '提示',
content: '确定要退出登录吗?',
success: (res) => {
if (res.confirm) {
uni.removeStorageSync('token')
uni.removeStorageSync('userInfo')
uni.reLaunch({
url: '/pages/login/login'
})
}
}
})
}
onMounted(() => {
loadUserInfo()
loadAccount()
loadBudget()
})
</script>
<style scoped>
.mine-page {
height: 100vh;
background: linear-gradient(135deg, #FFE5CC 0%, #FFD4A8 50%, #FFC08A 100%);
}
.mine-container {
min-height: 100vh;
padding: 20rpx;
padding-bottom: calc(170rpx + env(safe-area-inset-bottom));
}
/* 用户信息卡片 */
.user-card {
background: linear-gradient(135deg, #C8956E, #F5D59E, #FFD700);
border-radius: 25rpx;
padding: 40rpx 30rpx;
margin-bottom: 20rpx;
display: flex;
align-items: center;
border: 4rpx solid #5D4037;
box-shadow: 0 10rpx 30rpx rgba(93, 64, 55, 0.2);
}
.user-avatar {
width: 120rpx;
height: 120rpx;
background: rgba(255, 255, 255, 0.9);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 60rpx;
margin-right: 30rpx;
border: 3rpx solid #5D4037;
box-shadow: 0 4rpx 15rpx rgba(0, 0, 0, 0.1);
}
.user-info {
flex: 1;
}
.user-name {
display: block;
font-size: 36rpx;
font-weight: bold;
color: #5D4037;
margin-bottom: 8rpx;
}
.user-id {
display: block;
font-size: 24rpx;
color: #8D6E63;
}
/* 账户余额卡片 */
.balance-card {
background: rgba(255, 255, 255, 0.95);
border-radius: 25rpx;
padding: 35rpx 30rpx;
margin-bottom: 20rpx;
border: 3rpx solid #C8956E;
box-shadow: 0 10rpx 30rpx rgba(200, 149, 110, 0.15);
}
.balance-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20rpx;
}
.balance-title {
font-size: 28rpx;
color: #8D6E63;
font-weight: 600;
}
.balance-arrow {
font-size: 28rpx;
color: #C8956E;
font-weight: bold;
}
.balance-amount {
font-size: 56rpx;
font-weight: bold;
display: block;
margin-bottom: 25rpx;
}
.balance-positive {
color: #FFD700;
}
.balance-negative {
color: #FA3534;
}
.balance-stats {
display: flex;
gap: 20rpx;
}
.balance-stat-item {
flex: 1;
background: rgba(255, 255, 255, 0.8);
border-radius: 15rpx;
padding: 20rpx;
text-align: center;
border: 2rpx solid #C8956E;
}
.balance-stat-label {
display: block;
font-size: 24rpx;
color: #8D6E63;
margin-bottom: 8rpx;
}
.balance-stat-value {
display: block;
font-size: 28rpx;
font-weight: bold;
color: #5D4037;
}
.balance-stat-value.income {
color: #19BE6B;
}
.balance-stat-value.expense {
color: #FA3534;
}
/* 预算卡片 */
.budget-card {
background: rgba(255, 255, 255, 0.95);
border-radius: 25rpx;
padding: 35rpx 30rpx;
margin-bottom: 20rpx;
border: 3rpx solid #C8956E;
box-shadow: 0 10rpx 30rpx rgba(200, 149, 110, 0.15);
}
.budget-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 25rpx;
}
.budget-title {
font-size: 28rpx;
color: #8D6E63;
font-weight: 600;
}
.budget-arrow {
font-size: 28rpx;
color: #C8956E;
font-weight: bold;
}
.budget-content {
}
.budget-progress-wrapper {
display: flex;
align-items: center;
margin-bottom: 20rpx;
}
.budget-progress-bar {
flex: 1;
height: 20rpx;
background: rgba(200, 149, 110, 0.2);
border-radius: 20rpx;
overflow: hidden;
margin-right: 15rpx;
}
.budget-progress-fill {
height: 100%;
background: linear-gradient(90deg, #C8956E, #FFD700);
border-radius: 20rpx;
transition: width 0.5s ease-out;
}
.budget-progress-text {
font-size: 24rpx;
color: #5D4037;
font-weight: bold;
min-width: 60rpx;
text-align: right;
}
.budget-stats {
display: flex;
gap: 15rpx;
}
.budget-stat-item {
flex: 1;
background: rgba(255, 255, 255, 0.8);
border-radius: 15rpx;
padding: 20rpx;
text-align: center;
border: 2rpx solid #C8956E;
}
.budget-stat-label {
display: block;
font-size: 24rpx;
color: #8D6E63;
margin-bottom: 8rpx;
}
.budget-stat-value {
display: block;
font-size: 28rpx;
font-weight: bold;
color: #5D4037;
}
.budget-stat-value.positive {
color: #19BE6B;
}
.budget-stat-value.negative {
color: #FA3534;
}
.budget-stat-value.expense {
color: #FA3534;
}
/* 功能菜单 */
.menu-section {
background: rgba(255, 255, 255, 0.95);
border-radius: 25rpx;
padding: 20rpx 0;
margin-bottom: 20rpx;
border: 3rpx solid #C8956E;
box-shadow: 0 10rpx 30rpx rgba(200, 149, 110, 0.15);
}
.menu-item {
display: flex;
align-items: center;
padding: 30rpx 30rpx;
transition: background 0.3s;
}
.menu-item:active {
background: rgba(255, 215, 0, 0.1);
}
.menu-icon-wrapper {
width: 80rpx;
height: 80rpx;
background: linear-gradient(135deg, rgba(200, 149, 110, 0.2), rgba(255, 215, 0, 0.2));
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-right: 25rpx;
border: 2rpx solid #C8956E;
}
.menu-icon {
font-size: 40rpx;
}
.menu-text {
flex: 1;
font-size: 30rpx;
color: #5D4037;
font-weight: 600;
}
.menu-arrow {
font-size: 28rpx;
color: #C8956E;
font-weight: bold;
}
/* 退出登录按钮 */
.logout-button {
width: 100%;
height: 96rpx;
background: rgba(255, 255, 255, 0.95);
color: #FA3534;
border-radius: 50rpx;
font-size: 32rpx;
font-weight: bold;
border: 3rpx solid #FA3534;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 20rpx rgba(250, 53, 52, 0.2);
transition: all 0.3s;
}
.logout-button:active {
transform: scale(0.98);
background: rgba(250, 53, 52, 0.1);
}
</style>

View File

@ -122,88 +122,157 @@ onMounted(() => {
<style scoped>
.container {
min-height: calc(100vh - 100rpx);
background: #f5f5f5;
background: linear-gradient(135deg, #FFE5CC 0%, #FFD4A8 50%, #FFC08A 100%);
padding: 20rpx;
padding-bottom: calc(170rpx + env(safe-area-inset-bottom));
position: relative;
overflow: hidden;
}
/* 背景装饰 */
.container::before {
content: '';
position: absolute;
width: 280rpx;
height: 280rpx;
background: linear-gradient(135deg, #C8956E, #FFB6C1);
border-radius: 50%;
opacity: 0.2;
filter: blur(60rpx);
top: 150rpx;
left: -80rpx;
animation: float 10s ease-in-out infinite;
pointer-events: none;
}
@keyframes float {
0%, 100% { transform: translate(0, 0); }
50% { transform: translate(-15rpx, 25rpx); }
}
.tabs {
display: flex;
background: #fff;
border-radius: 12rpx;
background: rgba(255, 255, 255, 0.9);
border-radius: 20rpx;
margin-bottom: 20rpx;
padding: 10rpx;
border: 3rpx solid #C8956E;
box-shadow: 0 8rpx 20rpx rgba(200, 149, 110, 0.15);
}
.tab {
flex: 1;
text-align: center;
padding: 20rpx;
border-radius: 8rpx;
color: #666;
border-radius: 15rpx;
color: #8D6E63;
font-size: 28rpx;
font-weight: 600;
transition: all 0.3s;
}
.tab.active {
background: #667eea;
color: #fff;
background: linear-gradient(135deg, #C8956E, #F5D59E);
color: #5D4037;
box-shadow: 0 4rpx 15rpx rgba(200, 149, 110, 0.3);
}
.summary-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 12rpx;
padding: 40rpx;
background: linear-gradient(135deg, #C8956E 0%, #F5D59E 50%, #FFD700 100%);
border-radius: 25rpx;
padding: 50rpx 40rpx;
margin-bottom: 20rpx;
display: flex;
justify-content: space-around;
border: 4rpx solid #5D4037;
box-shadow: 0 15rpx 40rpx rgba(93, 64, 55, 0.2);
position: relative;
overflow: hidden;
}
.summary-card::before {
content: '';
position: absolute;
top: -50%;
right: -50%;
width: 200%;
height: 200%;
background: radial-gradient(circle, rgba(255, 255, 255, 0.2) 0%, transparent 70%);
pointer-events: none;
}
.summary-item {
text-align: center;
color: #fff;
color: #5D4037;
position: relative;
z-index: 1;
}
.label {
display: block;
font-size: 24rpx;
opacity: 0.8;
margin-bottom: 10rpx;
font-size: 26rpx;
opacity: 0.85;
margin-bottom: 12rpx;
font-weight: 600;
}
.amount {
display: block;
font-size: 36rpx;
font-size: 40rpx;
font-weight: bold;
text-shadow: 2rpx 2rpx 0 rgba(255, 255, 255, 0.3);
}
.amount.income {
color: #19BE6B;
}
.amount.expense {
color: #FA3534;
}
.amount.balance {
color: #5D4037;
}
.category-stats {
background: #fff;
border-radius: 12rpx;
padding: 30rpx;
background: rgba(255, 255, 255, 0.95);
border-radius: 25rpx;
padding: 40rpx 30rpx;
border: 3rpx solid #C8956E;
box-shadow: 0 10rpx 30rpx rgba(200, 149, 110, 0.15);
}
.section-title {
display: block;
font-size: 32rpx;
font-size: 34rpx;
font-weight: bold;
margin-bottom: 30rpx;
color: #333;
margin-bottom: 35rpx;
color: #5D4037;
text-shadow: 2rpx 2rpx 0 rgba(255, 215, 0, 0.2);
}
.category-item {
display: flex;
align-items: center;
padding: 20rpx 0;
border-bottom: 1rpx solid #f0f0f0;
padding: 25rpx 0;
border-bottom: 2rpx solid rgba(200, 149, 110, 0.2);
transition: all 0.3s;
}
.category-item:last-child {
border-bottom: none;
}
.category-item:active {
background: rgba(255, 215, 0, 0.05);
padding-left: 10rpx;
}
.category-icon {
font-size: 48rpx;
margin-right: 20rpx;
font-size: 56rpx;
margin-right: 25rpx;
filter: drop-shadow(0 3rpx 6rpx rgba(0, 0, 0, 0.15));
}
.category-info {
@ -212,35 +281,48 @@ onMounted(() => {
.category-name {
display: block;
font-size: 28rpx;
color: #333;
margin-bottom: 10rpx;
font-size: 30rpx;
color: #5D4037;
margin-bottom: 12rpx;
font-weight: 600;
}
.progress-bar {
width: 100%;
height: 8rpx;
background: #f0f0f0;
border-radius: 4rpx;
height: 12rpx;
background: rgba(200, 149, 110, 0.2);
border-radius: 20rpx;
overflow: hidden;
box-shadow: inset 0 2rpx 4rpx rgba(0, 0, 0, 0.1);
}
.progress-fill {
height: 100%;
background: #667eea;
transition: width 0.3s;
background: linear-gradient(90deg, #C8956E, #FFD700);
transition: width 0.5s ease-out;
border-radius: 20rpx;
box-shadow: 0 0 10rpx rgba(255, 215, 0, 0.5);
}
.category-amount {
font-size: 28rpx;
font-size: 32rpx;
font-weight: bold;
color: #333;
margin-left: 20rpx;
color: #5D4037;
margin-left: 25rpx;
}
.empty {
text-align: center;
padding: 60rpx 0;
color: #999;
padding: 100rpx 0;
color: #8D6E63;
background: rgba(255, 255, 255, 0.5);
border-radius: 20rpx;
border: 3rpx dashed #C8956E;
margin: 20rpx 0;
}
.empty text {
font-size: 30rpx;
font-weight: 600;
}
</style>

BIN
static/images/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

@ -0,0 +1,39 @@
# TabBar 图标说明
## 需要添加的图标
为了让"我的"页面正常显示,你需要添加以下两个图标文件:
### 1. mine.png未选中状态
- 文件路径:`frontend/static/tabbar/mine.png`
- 建议尺寸81x81 像素
- 颜色:#7A7E83灰色
- 图标内容:用户头像或"我的"相关图标
### 2. mine-active.png选中状态
- 文件路径:`frontend/static/tabbar/mine-active.png`
- 建议尺寸81x81 像素
- 颜色:#667eea紫色
- 图标内容与mine.png相同但颜色为选中状态
## 图标获取方式
1. **使用在线图标库**
- iconfont (https://www.iconfont.cn/)
- iconpark (https://iconpark.oceanengine.com/)
2. **使用设计工具**
- Figma
- Sketch
- Adobe Illustrator
3. **参考现有图标**
- 可以参考 home.png、add.png、statistics.png 的风格
- 保持统一的设计风格
## 临时解决方案
如果暂时没有图标,可以:
1. 复制 home.png 并重命名为 mine.png
2. 复制 home-active.png 并重命名为 mine-active.png
3. 这样至少可以让页面正常运行,之后再替换为合适的图标

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

BIN
static/tabbar/mine.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

56
utils/crypto.js Normal file
View File

@ -0,0 +1,56 @@
// 简单的密码加密/解密工具Base64编码 + 简单混淆)
// 注意:这不是强加密,只是防止密码明文存储
const SECRET_KEY = 'QIANGBAO_AI_JIZHANG_2024' // 密钥
/**
* 加密密码
* @param {string} password - 原始密码
* @returns {string} - 加密后的密码
*/
export function encryptPassword(password) {
if (!password) return ''
try {
// 1. 将密码转为字符数组
const chars = password.split('')
// 2. 与密钥进行简单的XOR混淆
const encrypted = chars.map((char, index) => {
const keyChar = SECRET_KEY[index % SECRET_KEY.length]
return String.fromCharCode(char.charCodeAt(0) ^ keyChar.charCodeAt(0))
}).join('')
// 3. Base64编码
return btoa(unescape(encodeURIComponent(encrypted)))
} catch (error) {
console.error('密码加密失败:', error)
return password // 失败时返回原密码
}
}
/**
* 解密密码
* @param {string} encryptedPassword - 加密的密码
* @returns {string} - 解密后的密码
*/
export function decryptPassword(encryptedPassword) {
if (!encryptedPassword) return ''
try {
// 1. Base64解码
const decoded = decodeURIComponent(escape(atob(encryptedPassword)))
// 2. 与密钥进行XOR反混淆
const chars = decoded.split('')
const decrypted = chars.map((char, index) => {
const keyChar = SECRET_KEY[index % SECRET_KEY.length]
return String.fromCharCode(char.charCodeAt(0) ^ keyChar.charCodeAt(0))
}).join('')
return decrypted
} catch (error) {
console.error('密码解密失败:', error)
return '' // 失败时返回空字符串
}
}

View File

@ -1,7 +1,7 @@
// HTTP请求封装
const BASE_URL = process.env.NODE_ENV === 'development'
? 'http://localhost:8080/api'
: 'https://your-api-domain.com/api'
? 'http://localhost:12345/api'
: 'https://accounting.aqroid.cn/api'
// 获取token
function getToken() {