第一次上传

This commit is contained in:
xxk
2026-06-11 09:53:11 +08:00
commit e257f2009e
89 changed files with 4336 additions and 0 deletions
+85
View File
@@ -0,0 +1,85 @@
<template>
<view class="surface-card tabbar">
<view
v-for="item in tabs"
:key="item.id"
class="tab-item"
:class="{ active: current === item.id }"
@click="navigate(item)"
>
<view class="tab-dot" :style="{ background: current === item.id ? item.color : 'var(--line-soft)' }"></view>
<text class="tab-label">{{ item.label }}</text>
</view>
</view>
</template>
<script setup>
const props = defineProps({
current: {
type: String,
default: 'home'
}
})
const tabs = [
{ id: 'home', label: '首页', path: '/pages/home/index', color: '#1f6f5f' },
{ id: 'bills', label: '账单', path: '/pages/bills/index', color: '#d36c43' },
{ id: 'budget', label: '预算', path: '/pages/budget/index', color: '#5f8df5' },
{ id: 'stats', label: '报表', path: '/pages/stats/index', color: '#7f56d9' },
{ id: 'mine', label: '我的', path: '/pages/mine/index', color: '#44546a' }
]
function navigate(item) {
if (item.id === props.current) {
return
}
uni.redirectTo({
url: item.path
})
}
</script>
<style lang="scss" scoped>
.tabbar {
position: fixed;
left: 24rpx;
right: 24rpx;
bottom: 24rpx;
display: flex;
align-items: center;
justify-content: space-between;
padding: 18rpx 12rpx calc(env(safe-area-inset-bottom));
z-index: 20;
}
.tab-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 10rpx;
padding: 14rpx 0;
border-radius: 22rpx;
}
.tab-item.active {
background: var(--brand-soft);
}
.tab-dot {
width: 18rpx;
height: 18rpx;
border-radius: 50%;
}
.tab-label {
font-size: 22rpx;
color: var(--text-secondary);
}
.tab-item.active .tab-label {
color: var(--text-primary);
font-weight: 600;
}
</style>
+342
View File
@@ -0,0 +1,342 @@
<template>
<view v-if="visible" class="popup-shell" @touchmove.stop.prevent="">
<view class="popup-mask" @click="emit('close')"></view>
<view class="surface-card popup-panel">
<view class="popup-head">
<view>
<text class="section-title">{{ form.id ? '编辑账单' : '新增账单' }}</text>
<text class="section-subtitle">3 步完成记录所有数据仅保存在本机</text>
</view>
<text class="close-text" @click="emit('close')">关闭</text>
</view>
<scroll-view scroll-y class="popup-body">
<view class="field-block">
<text class="field-label">收支类型</text>
<view class="pill-row">
<view
v-for="item in typeOptions"
:key="item.value"
class="pill-button"
:class="{ active: form.type === item.value }"
@click="form.type = item.value"
>
{{ item.label }}
</view>
</view>
</view>
<view class="field-block">
<text class="field-label">金额</text>
<view class="input-shell amount-shell">
<text class="prefix-text">¥</text>
<input v-model="form.amount" type="digit" placeholder="输入金额" />
</view>
</view>
<view class="field-block">
<text class="field-label">分类</text>
<view class="chip-grid">
<view
v-for="item in currentCategories"
:key="item.id"
class="chip-item"
:class="{ active: form.categoryId === item.id }"
@click="form.categoryId = item.id"
>
<view class="chip-dot" :style="{ background: item.color }"></view>
<text>{{ item.name }}</text>
</view>
</view>
</view>
<view class="field-block">
<text class="field-label">账户</text>
<view class="chip-grid">
<view
v-for="item in accounts"
:key="item.id"
class="chip-item"
:class="{ active: form.accountId === item.id }"
@click="form.accountId = item.id"
>
<view class="chip-dot" :style="{ background: item.color }"></view>
<text>{{ item.name }}</text>
</view>
</view>
</view>
<view class="field-block">
<text class="field-label">日期</text>
<picker mode="date" :value="form.date" @change="onDateChange">
<view class="input-shell picker-shell">
<text>{{ form.date }}</text>
<text class="tiny-text">选择</text>
</view>
</picker>
</view>
<view class="field-block">
<text class="field-label">备注</text>
<view class="input-shell textarea-shell">
<textarea
v-model="form.note"
maxlength="40"
placeholder="补充说明,便于后续搜索"
></textarea>
</view>
</view>
</scroll-view>
<view class="popup-foot">
<view class="ghost-button" @click="emit('close')">取消</view>
<view class="primary-button save-button" @click="handleSave">保存账单</view>
</view>
</view>
</view>
</template>
<script setup>
import { computed, reactive, watch } from 'vue'
import { toDateKey } from '../utils/date'
const props = defineProps({
visible: {
type: Boolean,
default: false
},
entry: {
type: Object,
default: null
},
categories: {
type: Object,
required: true
},
accounts: {
type: Array,
required: true
},
defaultType: {
type: String,
default: 'expense'
},
initialCategoryId: {
type: String,
default: ''
}
})
const emit = defineEmits(['close', 'save'])
const typeOptions = [
{ label: '支出', value: 'expense' },
{ label: '收入', value: 'income' }
]
const form = reactive({
id: '',
type: 'expense',
amount: '',
categoryId: '',
accountId: '',
date: toDateKey(),
note: '',
createdAt: 0
})
const currentCategories = computed(() => props.categories[form.type] || [])
function hydrateForm() {
const source = props.entry || {}
const nextType = source.type || props.defaultType || 'expense'
const defaultCategory = props.initialCategoryId && (props.categories[nextType] || []).some((item) => item.id === props.initialCategoryId)
? props.initialCategoryId
: (props.categories[nextType]?.[0]?.id || '')
form.id = source.id || ''
form.type = nextType
form.amount = source.amount ? String(source.amount) : ''
form.categoryId = source.categoryId || defaultCategory
form.accountId = source.accountId || props.accounts[0]?.id || ''
form.date = source.date || toDateKey()
form.note = source.note || ''
form.createdAt = source.createdAt || 0
}
watch(
() => props.visible,
(visible) => {
if (visible) {
hydrateForm()
}
},
{ immediate: true }
)
watch(
() => form.type,
(nextType) => {
const availableIds = (props.categories[nextType] || []).map((item) => item.id)
if (!availableIds.includes(form.categoryId)) {
form.categoryId = availableIds[0] || ''
}
}
)
function onDateChange(event) {
form.date = event.detail.value
}
function handleSave() {
if (!Number(form.amount)) {
uni.showToast({
title: '请输入有效金额',
icon: 'none'
})
return
}
if (!form.categoryId || !form.accountId) {
uni.showToast({
title: '请选择分类和账户',
icon: 'none'
})
return
}
emit('save', {
id: form.id,
type: form.type,
amount: Number(form.amount),
categoryId: form.categoryId,
accountId: form.accountId,
date: form.date,
note: form.note.trim(),
createdAt: form.createdAt
})
emit('close')
}
</script>
<style lang="scss" scoped>
.popup-shell {
position: fixed;
inset: 0;
z-index: 50;
}
.popup-mask {
position: absolute;
inset: 0;
background: rgba(3, 12, 21, 0.42);
}
.popup-panel {
position: absolute;
left: 16rpx;
right: 16rpx;
bottom: 16rpx;
max-height: 84vh;
padding: 28rpx 28rpx 32rpx;
display: flex;
flex-direction: column;
gap: 24rpx;
}
.popup-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16rpx;
}
.close-text {
padding: 10rpx 0;
font-size: 24rpx;
color: var(--text-secondary);
}
.popup-body {
max-height: 58vh;
}
.field-block {
display: flex;
flex-direction: column;
gap: 18rpx;
margin-bottom: 24rpx;
}
.field-label {
font-size: 26rpx;
font-weight: 600;
color: var(--text-primary);
}
.pill-row,
.chip-grid {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
}
.chip-item {
display: flex;
align-items: center;
gap: 10rpx;
padding: 16rpx 20rpx;
border-radius: 22rpx;
background: var(--surface-muted);
color: var(--text-secondary);
font-size: 24rpx;
}
.chip-item.active {
background: var(--brand-soft);
color: var(--text-primary);
}
.chip-dot {
width: 14rpx;
height: 14rpx;
border-radius: 50%;
}
.amount-shell {
gap: 12rpx;
}
.prefix-text {
font-size: 36rpx;
font-weight: 600;
color: var(--text-primary);
}
.picker-shell {
justify-content: space-between;
}
.textarea-shell {
padding: 20rpx 24rpx;
min-height: 160rpx;
align-items: flex-start;
}
.textarea-shell textarea {
min-height: 120rpx;
}
.popup-foot {
display: flex;
align-items: center;
gap: 16rpx;
}
.popup-foot .ghost-button {
flex: 0 0 180rpx;
}
.save-button {
flex: 1;
}
</style>
+45
View File
@@ -0,0 +1,45 @@
<template>
<view class="surface-card card">
<view class="head" v-if="title || subtitle || $slots.action">
<view class="title-group">
<text v-if="title" class="section-title">{{ title }}</text>
<text v-if="subtitle" class="section-subtitle">{{ subtitle }}</text>
</view>
<slot name="action"></slot>
</view>
<slot></slot>
</view>
</template>
<script setup>
defineProps({
title: {
type: String,
default: ''
},
subtitle: {
type: String,
default: ''
}
})
</script>
<style lang="scss" scoped>
.card {
padding: 28rpx;
}
.head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16rpx;
margin-bottom: 24rpx;
}
.title-group {
display: flex;
flex-direction: column;
gap: 10rpx;
}
</style>