Files
simple-memo-wx/pages/bill/bill.vue
T

2661 lines
70 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="bill-page page-enter theme-transition" :style="{ background: themeConfig.bgColor }">
<PageHeader
title="账单管理"
subtitle="理性消费,合理规划"
:showRight="true"
>
<template #right>
<view class="header-actions">
<view
class="header-btn"
:style="{ backgroundColor: themeConfig.cardBgColor }"
@click="toggleBatchMode"
>
<text
class="batch-icon"
:style="{ color: isBatchMode ? themeConfig.primaryColor : themeConfig.textSecondary }"
>{{ isBatchMode ? '✓' : '☰' }}</text>
</view>
<view
class="header-btn"
:style="{ backgroundColor: themeConfig.cardBgColor }"
@click="handleBudgetSetting"
>
<FaIcon icon="gear" :size="22" :color="themeConfig.textSecondary" />
</view>
</view>
</template>
</PageHeader>
<view class="sticky-header card-enter" :style="{ background: themeConfig.bgColor }">
<view class="search-bar">
<view
class="search-input"
:style="{ backgroundColor: themeConfig.cardBgColor, boxShadow: `0 2px 8px ${themeConfig.shadowColor}` }"
>
<FaIcon icon="search" :size="22" :color="themeConfig.textSecondary" />
<input
v-model="searchWord"
@input="handleSearch"
placeholder="搜索备注/分类"
:style="{ color: themeConfig.textPrimary }"
/>
<FaIcon
v-if="searchWord"
class="clear-icon"
icon="xmark"
:size="14"
:color="themeConfig.textSecondary"
@click="clearSearch"
/>
</view>
<view class="filter-section">
<view class="filter-row-label" :style="{ color: themeConfig.textSecondary }">类型</view>
<CapsuleFilter
:options="BILL_TYPE_FILTER_OPTIONS"
:value="billType"
@change="handleTypeFilterChange"
size="small"
/>
</view>
<view class="filter-section">
<view class="filter-row-label" :style="{ color: themeConfig.textSecondary }">时间</view>
<CapsuleFilter
:options="BILL_TIME_FILTER_OPTIONS"
:value="activeTimeTag"
@change="handleTimeFilterChange"
size="small"
:scrollable="true"
/>
</view>
</view>
</view>
<view class="modal" v-if="showCustomDateModal" @click="showCustomDateModal = false">
<view
class="modal-box date-picker-box theme-transition"
:style="{ backgroundColor: themeConfig.cardBgColor }"
@click.stop
>
<view class="modal-title" :style="{ color: themeConfig.textPrimary }">选择时间范围</view>
<view class="form-field">
<text class="field-label" :style="{ color: themeConfig.textSecondary }">开始日期</text>
<picker mode="date" :value="customStartDate" @change="e => customStartDate = e.detail.value">
<view
class="date-picker field-value"
:style="{ backgroundColor: themeConfig.bgColor, color: themeConfig.textPrimary, borderColor: themeConfig.borderColor }"
>{{ customStartDate }}</view>
</picker>
</view>
<view class="form-field">
<text class="field-label" :style="{ color: themeConfig.textSecondary }">结束日期</text>
<picker mode="date" :value="customEndDate" @change="e => customEndDate = e.detail.value">
<view
class="date-picker field-value"
:style="{ backgroundColor: themeConfig.bgColor, color: themeConfig.textPrimary, borderColor: themeConfig.borderColor }"
>{{ customEndDate }}</view>
</picker>
</view>
<view class="btns">
<button
class="cancel-btn theme-transition"
:style="{ backgroundColor: themeConfig.borderColor, color: themeConfig.textSecondary }"
@click="showCustomDateModal = false"
>取消</button>
<button
class="confirm-btn theme-transition btn btn--primary"
@click="confirmCustomDate"
>确认</button>
</view>
</view>
</view>
<scroll-view class="main-scroll" scroll-y :show-scrollbar="false">
<AppCard
class="budget-card"
:padding="24"
:radius="20"
:marginBottom="20"
>
<view class="budget-title">
<text :style="{ color: themeConfig.textPrimary }">本月预算</text>
<text class="budget-value" :style="{ color: themeConfig.primaryColor }" v-if="monthlyBudget">¥{{ monthlyBudget }}</text>
<text class="budget-set-btn" :style="{ color: themeConfig.primaryColor }" v-else @click="handleBudgetSetting">去设置</text>
</view>
<view v-if="monthlyBudget">
<view class="progress-bar" :style="{ backgroundColor: themeConfig.bgColor }">
<view class="progress-fill" :style="{ width: budgetProgress + '%', backgroundColor: getProgressColor() }"></view>
</view>
<view class="budget-info">
<text :class="{ over: budgetProgress > 100 }" :style="{ color: budgetProgress > 100 ? themeConfig.dangerColor : themeConfig.textSecondary }">已支出 ¥{{ monthExpend }} / ¥{{ monthlyBudget }}</text>
<text :style="{ color: themeConfig.textSecondary }">剩余预算:¥{{ remainingBudget }}</text>
<text :style="{ color: themeConfig.textSecondary }">{{ budgetProgress.toFixed(1) }}%</text>
</view>
</view>
<view class="budget-empty" v-else>
<text :style="{ color: themeConfig.textSecondary }">尚未设置本月预算点击右上角去设置添加</text>
</view>
</AppCard>
<AppCard
class="budget-card"
v-if="activeTimeTag === 'lastMonth'"
:padding="24"
:radius="20"
:marginBottom="20"
>
<view class="budget-title">
<text :style="{ color: themeConfig.textPrimary }">上月预算</text>
<text class="budget-value" :style="{ color: themeConfig.primaryColor }" v-if="lastMonthBudget">¥{{ lastMonthBudget }}</text>
<text class="budget-set-btn" :style="{ color: themeConfig.primaryColor }" v-else @click="handleLastMonthBudgetSetting">去设置</text>
</view>
<view v-if="lastMonthBudget">
<view class="progress-bar" :style="{ backgroundColor: themeConfig.bgColor }">
<view class="progress-fill" :style="{ width: lastMonthBudgetProgress + '%', backgroundColor: getProgressColor() }"></view>
</view>
<view class="budget-info">
<text :class="{ over: lastMonthBudgetProgress > 100 }" :style="{ color: lastMonthBudgetProgress > 100 ? themeConfig.dangerColor : themeConfig.textSecondary }">已支出 ¥{{ lastMonthExpend }} / ¥{{ lastMonthBudget }}</text>
<text :style="{ color: themeConfig.textSecondary }">剩余预算:¥{{ lastMonthRemainingBudget }}</text>
<text :style="{ color: themeConfig.textSecondary }">{{ lastMonthBudgetProgress.toFixed(1) }}%</text>
</view>
</view>
<view class="budget-empty" v-else>
<text :style="{ color: themeConfig.textSecondary }">尚未设置上月预算点击右上角去设置添加</text>
</view>
</AppCard>
<AppCard class="stat-card card-enter" :padding="24" :radius="12" :marginBottom="20">
<scroll-view class="stat-scroll" scroll-x :show-scrollbar="false">
<view class="stat-row">
<view class="stat-item">
<view class="stat-icon expend-icon">💸</view>
<view class="stat-info">
<text class="label" :style="{ color: themeConfig.textSecondary }">总支出</text>
<text class="value expend" :style="{ color: themeConfig.dangerColor }">¥{{ totalExpend.toFixed(2) }}</text>
</view>
</view>
<view class="stat-item">
<view class="stat-icon income-icon">💰</view>
<view class="stat-info">
<text class="label" :style="{ color: themeConfig.textSecondary }">总收入</text>
<text class="value income" :style="{ color: themeConfig.secondaryColor }">¥{{ totalIncome.toFixed(2) }}</text>
</view>
</view>
<view class="stat-item">
<view class="stat-icon balance-icon">📊</view>
<view class="stat-info">
<text class="label" :style="{ color: themeConfig.textSecondary }">当前结余</text>
<text class="value balance" :style="{ color: currentPeriodBalance >= 0 ? themeConfig.secondaryColor : themeConfig.dangerColor }">
{{ currentPeriodBalance >= 0 ? '+' : '-' }}¥{{ Math.abs(currentPeriodBalance).toFixed(2) }}
</text>
</view>
</view>
<view class="stat-item">
<view class="stat-icon month-icon">📅</view>
<view class="stat-info">
<text class="label" :style="{ color: themeConfig.textSecondary }">累计结余</text>
<text class="value balance" :style="{ color: monthlyTotalBalance >= 0 ? themeConfig.secondaryColor : themeConfig.dangerColor }">
{{ monthlyTotalBalance >= 0 ? '+' : '-' }}¥{{ Math.abs(monthlyTotalBalance).toFixed(2) }}
</text>
</view>
</view>
</view>
</scroll-view>
</AppCard>
<view class="cate-stat-section card-enter">
<view class="cate-stat-single" v-if="cateStatList.length <= 2">
<view
class="cate-stat-item-wide theme-transition"
v-for="item in cateStatList"
:key="item.cate"
@click="filterByCate(item.cate)"
:style="{ backgroundColor: themeConfig.cardBgColor }"
>
<view class="cate-left-wide">
<view class="cate-icon-wide" :style="{ backgroundColor: item.color }">{{ item.icon }}</view>
<text class="cate-name-wide" :style="{ color: themeConfig.textPrimary }">{{ getCateLabel(item.cate) }}</text>
</view>
<view class="cate-right-wide">
<view class="cate-money-row-wide">
<text class="cate-money expend" :style="{ color: themeConfig.dangerColor }">支出:¥{{ item.expend.toFixed(2) }}</text>
<text class="cate-money income" :style="{ color: themeConfig.secondaryColor }">收入:¥{{ item.income.toFixed(2) }}</text>
</view>
<view class="cate-rate-row-wide">
<text class="cate-rate" :style="{ color: themeConfig.textSecondary }">占比支出{{ item.expendRate }}% / 收入{{ item.incomeRate }}%</text>
</view>
</view>
</view>
</view>
<view class="cate-stat-scroll-wrapper" v-else>
<scroll-view class="cate-stat-scroll" scroll-x="true" :show-scrollbar="false" @scroll="onCateScroll">
<view class="cate-stat-container-scroll">
<view
class="cate-stat-item-scroll theme-transition"
v-for="item in cateStatList"
:key="item.cate"
@click="filterByCate(item.cate)"
:style="{ backgroundColor: themeConfig.cardBgColor }"
>
<view class="cate-icon-scroll" :style="{ backgroundColor: item.color }">{{ item.icon }}</view>
<text class="cate-name-scroll" :style="{ color: themeConfig.textPrimary }">{{ getCateLabel(item.cate) }}</text>
<view class="cate-stat-row-scroll">
<text class="cate-stat-text" :style="{ color: themeConfig.dangerColor }">支出 ¥{{ item.expend.toFixed(2) }}</text>
</view>
<view class="cate-stat-row-scroll">
<text class="cate-stat-text" :style="{ color: themeConfig.secondaryColor }">收入 ¥{{ item.income.toFixed(2) }}</text>
</view>
<view class="cate-stat-row-scroll">
<text class="cate-rate-scroll" :style="{ color: themeConfig.textSecondary }">占比 {{ item.expendRate }}%</text>
</view>
</view>
</view>
</scroll-view>
<view class="scroll-indicator">
<view
class="indicator-dot"
v-for="(item, index) in cateStatList"
:key="index"
:class="{ active: currentCateIndex === index }"
:style="{ backgroundColor: currentCateIndex === index ? themeConfig.primaryColor : themeConfig.borderColor }"
></view>
</view>
</view>
</view>
<view class="bill-list-container content-fade">
<SkeletonScreen v-if="!billListLoaded" type="list" :count="5" />
<AppCard
v-else
class="bill-list-card card-enter"
:padding="16"
:radius="20"
:marginBottom="20"
>
<view v-if="Object.keys(groupedBillList).length === 0" class="bill-empty-wrap">
<EmptyState
type="bill"
title="还没有账单记录"
desc="点击按钮记录你的第一笔收支"
actionText="记一笔"
@action="openAddModal"
/>
</view>
<view v-for="(group, dateKey) in groupedBillList" :key="dateKey" class="bill-group">
<view class="bill-group-header">
<text class="bill-group-date" :style="{ color: themeConfig.textSecondary }">{{ formatBillDate(dateKey) }}</text>
<text class="bill-group-summary" :style="{ color: themeConfig.textSecondary }">
+¥{{ group.income.toFixed(2) }} -¥{{ group.expend.toFixed(2) }}
</text>
</view>
<SwipeableItem
v-for="item in group.items"
:key="item.id"
:actions="swipeActions"
:index="item.id"
@action="(action) => handleSwipeAction(action, item)"
@closeOthers="closeOtherSwipe"
>
<view
class="bill-item-content theme-transition"
:style="{ backgroundColor: themeConfig.cardBgColor }"
>
<view class="bill-left">
<text class="cate-icon" :style="{ backgroundColor: getCateColor(item.cate) }">{{ getCateIcon(item.cate) }}</text>
<view class="bill-text">
<text class="cate-name" :style="{ color: themeConfig.textPrimary }">{{ getCateLabel(item.cate) }}</text>
<view class="bill-meta">
<text class="channel" v-if="item.channel" :style="{ color: themeConfig.textSecondary }">{{ getChannelLabel(item.channel) }}</text>
<text class="meta-divider" v-if="item.channel && item.note">·</text>
<text class="note" v-if="item.note" :style="{ color: themeConfig.textSecondary }">{{ item.note }}</text>
</view>
</view>
</view>
<text
class="money"
:class="item.type === BILL_TYPE.EXPEND ? 'expend' : 'income'"
:style="{ color: item.type === BILL_TYPE.EXPEND ? themeConfig.dangerColor : themeConfig.secondaryColor }"
>
{{ item.type === BILL_TYPE.EXPEND ? '-' : '+' }}{{ Number(item.money).toFixed(2) }}
</text>
</view>
</SwipeableItem>
</view>
<view class="batch-bar" v-if="isBatchMode">
<text :style="{ color: themeConfig.textSecondary }">已选 {{ selectedBillIds.length }} </text>
<view class="batch-actions">
<view class="batch-btn" :style="{ backgroundColor: themeConfig.dangerColor }" @click="batchDelete">批量删除</view>
</view>
</view>
<view class="scroll-tip" v-if="showScrollTip" :style="{ color: themeConfig.textSecondary }">上滑查看更多</view>
<view class="pagination-tip" v-if="hasMore" @click="loadMore">
<text :style="{ color: themeConfig.primaryColor }">加载更多</text>
</view>
<view class="pagination-tip" v-else-if="billList.length > 0 && !hasMore" :style="{ color: themeConfig.textSecondary }">
<text>已经到底了</text>
</view>
</AppCard>
</view>
</scroll-view>
</view>
<view class="modal" v-if="showBudgetModal" @click="showBudgetModal = false">
<view
class="modal-box budget-modal-box theme-transition"
:style="{ backgroundColor: themeConfig.cardBgColor }"
@click.stop
>
<view class="modal-title" :style="{ color: themeConfig.textPrimary }">设置预算</view>
<view class="form-field">
<text class="field-label" :style="{ color: themeConfig.textSecondary }">预算月份</text>
<picker mode="month" :value="selectedBudgetMonth" @change="onBudgetMonthChange">
<view
class="date-picker field-value"
:style="{ backgroundColor: themeConfig.bgColor, color: themeConfig.textPrimary, borderColor: themeConfig.borderColor }"
>{{ selectedBudgetMonth }}</view>
</picker>
</view>
<view class="form-field">
<text class="field-label" :style="{ color: themeConfig.textSecondary }">预算金额</text>
<input
v-model="tempBudget"
type="digit"
placeholder="请输入预算金额"
class="input money-input field-value"
placeholder-class="input-placeholder"
@input="validateMoneyInput(tempBudget, 'tempBudget')"
:style="{ backgroundColor: themeConfig.bgColor, color: themeConfig.textPrimary, borderColor: themeConfig.borderColor }"
/>
</view>
<view class="btns">
<button
class="cancel-btn theme-transition"
:style="{ backgroundColor: themeConfig.borderColor, color: themeConfig.textSecondary }"
@click="showBudgetModal = false"
>取消</button>
<button
class="confirm-btn theme-transition btn btn--primary"
@click="saveBudget"
>确认设置</button>
</view>
</view>
</view>
<view class="float-buttons" @click.stop v-if="!showAdd && !isBatchMode">
<view
class="add-btn theme-transition"
@click="handleAddBtnClick"
:style="{ background: themeConfig.gradientSoft, transform: addBtnScale }"
>
<text class="add-btn-icon">+</text>
</view>
</view>
<BottomSheet
:visible="showAdd"
:title="isEdit ? '编辑账单' : '记一笔'"
height="85vh"
@update:visible="val => showAdd = val"
@close="handleSheetClose"
>
<view class="type-section">
<view
class="type-btn"
:class="{ active: form.type === BILL_TYPE.EXPEND }"
:style="{
background: form.type === BILL_TYPE.EXPEND ? themeConfig.gradientSoft : themeConfig.borderColor,
color: form.type === BILL_TYPE.EXPEND ? '#fff' : themeConfig.textSecondary
}"
@click="form.type = BILL_TYPE.EXPEND"
>
<text class="type-icon">💰</text>
<text class="type-text">支出</text>
</view>
<view
class="type-btn"
:class="{ active: form.type === BILL_TYPE.INCOME }"
:style="{
background: form.type === BILL_TYPE.INCOME ? themeConfig.gradientSoft : themeConfig.borderColor,
color: form.type === BILL_TYPE.INCOME ? '#fff' : themeConfig.textSecondary
}"
@click="form.type = BILL_TYPE.INCOME"
>
<text class="type-icon">📈</text>
<text class="type-text">收入</text>
</view>
</view>
<view class="amount-section">
<text class="amount-label" :style="{ color: themeConfig.textSecondary }">金额</text>
<view class="amount-input-wrap">
<text class="amount-symbol" :style="{ color: form.type === BILL_TYPE.EXPEND ? themeConfig.dangerColor : themeConfig.secondaryColor }">¥</text>
<input
v-model="formattedMoney"
type="digit"
placeholder="0.00"
class="amount-input"
@input="handleMoneyInput"
@blur="formatMoneyOnBlur"
:style="{ color: form.type === BILL_TYPE.EXPEND ? themeConfig.dangerColor : themeConfig.secondaryColor }"
/>
</view>
<view class="quick-amounts">
<text
class="quick-amount-btn"
v-for="amount in QUICK_MONEY_AMOUNTS"
:key="amount"
@click="fillQuickMoney(amount)"
:style="{ backgroundColor: themeConfig.borderColor, color: themeConfig.textSecondary }"
>¥{{ amount }}</text>
</view>
</view>
<view class="section">
<text class="section-title" :style="{ color: themeConfig.textPrimary }">分类</text>
<view class="recent-cate-row" v-if="recentCateList.length">
<text class="recent-label" :style="{ color: themeConfig.textSecondary }">最近</text>
<view class="recent-cate-items">
<view
class="recent-cate-item"
v-for="cate in recentCateList"
:key="cate.value"
:class="{ active: form.cate === cate.value }"
@click="form.cate = cate.value"
>
<text class="recent-cate-icon" :style="{ backgroundColor: cate.color }">{{ cate.icon }}</text>
<text class="recent-cate-name" :style="{ color: themeConfig.textPrimary }">{{ cate.label }}</text>
</view>
</view>
</view>
<view class="cate-grid-new">
<view
class="cate-item-new"
v-for="cate in cateList"
:key="cate.value"
:class="{ active: form.cate === cate.value }"
@click="selectCate(cate)"
>
<view class="cate-icon-wrap" :style="{ backgroundColor: form.cate === cate.value ? cate.color : themeConfig.borderColor }">
<text class="cate-icon">{{ cate.icon }}</text>
</view>
<text class="cate-label" :style="{ color: form.cate === cate.value ? cate.color : themeConfig.textSecondary }">{{ cate.label }}</text>
</view>
<view class="cate-item-new add-cate" @click="showCustomCate = true">
<view class="cate-icon-wrap add-icon-wrap">
<text class="cate-icon">+</text>
</view>
<text class="cate-label" :style="{ color: themeConfig.textSecondary }">自定义</text>
</view>
</view>
</view>
<view class="section">
<text class="section-title" :style="{ color: themeConfig.textPrimary }">支付渠道</text>
<view class="channel-grid">
<text
class="channel-btn"
v-for="channel in currentChannels"
:key="channel.value"
:class="{ active: form.channel === channel.value }"
@click="form.channel = channel.value"
:style="{
backgroundColor: form.channel === channel.value ? themeConfig.primaryColor : 'transparent',
color: form.channel === channel.value ? '#fff' : themeConfig.textSecondary,
borderColor: form.channel === channel.value ? themeConfig.primaryColor : themeConfig.borderColor
}"
>{{ channel.label }}</text>
</view>
</view>
<view class="section">
<text class="section-title" :style="{ color: themeConfig.textPrimary }">备注</text>
<view class="note-input-wrap">
<input
v-model="form.note"
type="text"
placeholder="添加备注(选填)"
class="note-input"
:style="{ backgroundColor: themeConfig.bgColor, color: themeConfig.textPrimary }"
/>
</view>
<view class="quick-tags">
<text
class="quick-tag"
v-for="tag in QUICK_NOTE_TAGS"
:key="tag"
@click="form.note = tag"
:style="{ backgroundColor: themeConfig.borderColor, color: themeConfig.textSecondary }"
>#{{ tag }}</text>
</view>
</view>
<view class="section">
<text class="section-title" :style="{ color: themeConfig.textPrimary }">日期</text>
<picker mode="date" :value="form.date" @change="e => form.date = e.detail.value">
<view class="date-picker-wrap" :style="{ backgroundColor: themeConfig.bgColor }">
<text class="date-text" :style="{ color: themeConfig.textPrimary }">
{{ form.date === formatCurrentDate() ? '今天' : formatDate(form.date) }}
</text>
<FaIcon icon="chevron-right" :size="14" :color="themeConfig.textSecondary" />
</view>
</picker>
</view>
<view class="section" v-if="showAiSuggestion">
<text class="section-title" :style="{ color: themeConfig.textPrimary }">AI 智能建议</text>
<view
class="ai-suggestion"
:style="{ borderColor: themeConfig.primaryColor }"
@click="applyAiSuggestion"
>
<text class="ai-suggestion-text" :style="{ color: themeConfig.primaryColor }">{{ aiSuggestionText }}</text>
<text class="ai-suggestion-hint" :style="{ color: themeConfig.textSecondary }">点击应用</text>
</view>
</view>
<template #footer>
<view class="modal-footer">
<button class="btn-cancel" :style="{ color: themeConfig.textSecondary }" @click="closeModal">取消</button>
<button
class="btn-confirm"
:style="{ background: themeConfig.gradientSoft }"
@click="submitBill"
:loading="submitting"
>{{ isEdit ? '保存修改' : '确认账单' }}</button>
</view>
</template>
</BottomSheet>
<view class="modal modal-center" v-if="showCustomCate" @click="showCustomCate = false">
<view
class="custom-cate-box theme-transition"
:style="{ backgroundColor: themeConfig.cardBgColor }"
@click.stop
>
<text class="modal-title" :style="{ color: themeConfig.textPrimary }">自定义分类</text>
<input
v-model="customCateName"
placeholder="分类名称"
class="input"
maxlength="6"
:style="{ backgroundColor: themeConfig.bgColor, color: themeConfig.textPrimary, borderColor: themeConfig.borderColor }"
/>
<view class="color-select">
<text class="form-label" :style="{ color: themeConfig.textSecondary }">选择颜色</text>
<view class="color-list">
<view
class="color-item"
v-for="color in colorOptions"
:key="color"
:style="{ backgroundColor: color }"
:class="{ active: selectedColor === color }"
@click="selectedColor = color"
></view>
</view>
</view>
<input
v-model="customCateIcon"
placeholder="输入图标(如🍚)"
class="input"
maxlength="2"
:style="{ backgroundColor: themeConfig.bgColor, color: themeConfig.textPrimary, borderColor: themeConfig.borderColor }"
/>
<view class="btns">
<button
class="cancel-btn theme-transition"
:style="{ backgroundColor: themeConfig.borderColor, color: themeConfig.textSecondary }"
@click="showCustomCate = false"
>取消</button>
<button
class="confirm-btn theme-transition btn btn--primary"
@click="addCustomCate"
>确认添加</button>
</view>
</view>
</view>
<CenterModal
:visible="showDeleteConfirm"
title="确认删除"
:showCancel="true"
cancelText="取消"
confirmText="删除"
:confirmColor="themeConfig.dangerColor"
@confirm="confirmDeleteBill"
@cancel="showDeleteConfirm = false"
@update:visible="val => showDeleteConfirm = val"
>
<text class="delete-confirm-text" :style="{ color: themeConfig.textPrimary }">
{{ deleteConfirmMessage }}
</text>
</CenterModal>
<CenterModal
:visible="showBatchDeleteConfirm"
title="批量删除确认"
:showCancel="true"
cancelText="取消"
confirmText="删除"
:confirmColor="themeConfig.dangerColor"
@confirm="confirmBatchDelete"
@cancel="showBatchDeleteConfirm = false"
>
<text class="delete-confirm-text" :style="{ color: themeConfig.textPrimary }">
确定要删除选中的 {{ selectedBillIds.length }} 条账单吗
</text>
</CenterModal>
<GrowthModal
:visible="growthModalVisible"
:title="growthModalTitle"
:message="growthModalMessage"
:icon="growthModalIcon"
:confirm-text="growthModalConfirmText"
@close="growthModalVisible = false"
/>
</template>
<script>
import { getBillList, addBill, editBill, deleteBill, getMonthBudget, setMonthBudget } from '@/api'
import { aiClassify as classifyBillApi } from '@/api/index.js'
import { checkLogin } from '@/utils/auth'
import { refreshNavBarTheme, forceApplyTabBarTheme } from '@/utils/themeGlobal'
import { getThemeConfigFromStorage, THEME_PRESETS } from '@/utils/theme'
import PageHeader from '@/components/PageHeader.vue'
import CapsuleFilter from '@/components/CapsuleFilter.vue'
import AppCard from '@/components/AppCard.vue'
import BottomSheet from '@/components/BottomSheet.vue'
import CenterModal from '@/components/CenterModal.vue'
import EmptyState from '@/components/EmptyState.vue'
import FaIcon from '@/components/FaIcon.vue'
import GrowthModal from '@/components/GrowthModal.vue'
import SwipeableItem from '@/components/SwipeableItem.vue'
import SkeletonScreen from '@/components/SkeletonScreen.vue'
import themeMixin from '@/mixins/themeMixin.js'
import growthModalMixin from '@/mixins/growthModalMixin.js'
import {
DEFAULT_CATE_LIST,
BILL_TYPE,
COLOR_OPTIONS,
SLIDE_BTN_WIDTH,
BILL_FILTER_TYPE,
TIME_TAG,
QUICK_MONEY_AMOUNTS,
QUICK_NOTE_TAGS,
BUDGET_PROGRESS_COLOR,
PROGRESS_COLORS,
RECENT_CATE_MAX_COUNT,
SLIDE_THRESHOLD,
BILL_TYPE_FILTER_OPTIONS,
BILL_TIME_FILTER_OPTIONS,
PAY_CHANNELS,
getChannelsByType,
getChannelLabel,
getCateLabel
} from '@/utils/constants'
const expenseCategories = DEFAULT_CATE_LIST.filter(c => c.type === BILL_TYPE.EXPEND)
const incomeCategories = DEFAULT_CATE_LIST.filter(c => c.type === BILL_TYPE.INCOME)
const TYPE_TO_CATEGORY = {
[BILL_TYPE.EXPEND]: expenseCategories,
[BILL_TYPE.INCOME]: incomeCategories
}
const CATEGORY_TO_TYPES = {}
DEFAULT_CATE_LIST.forEach(c => {
CATEGORY_TO_TYPES[c.value] = c.type
})
export default {
components: {
PageHeader,
CapsuleFilter,
AppCard,
BottomSheet,
CenterModal,
EmptyState,
FaIcon,
GrowthModal,
SwipeableItem,
SkeletonScreen
},
mixins: [themeMixin, growthModalMixin],
data() {
return {
BILL_TYPE_FILTER_OPTIONS,
BILL_TIME_FILTER_OPTIONS,
QUICK_MONEY_AMOUNTS,
QUICK_NOTE_TAGS,
BILL_FILTER_TYPE,
TIME_TAG,
BILL_TYPE,
expenseCategories,
incomeCategories,
TYPE_TO_CATEGORY,
CATEGORY_TO_TYPES,
searchWord: '',
billType: BILL_FILTER_TYPE.ALL,
activeTimeTag: TIME_TAG.TODAY,
billList: [],
billListLoaded: false,
displayBillList: [],
showAdd: false,
isEdit: false,
currentEditId: '',
form: { type: BILL_TYPE.EXPEND, money: '', cate: '', channel: 'wechat', note: '', date: '' },
formattedMoney: '',
moneyInputError: '',
allCateList: DEFAULT_CATE_LIST,
showCustomCate: false,
customCateName: '',
customCateIcon: '',
selectedColor: COLOR_OPTIONS[0],
colorOptions: COLOR_OPTIONS,
monthlyBudget: '',
monthExpend: 0,
budgetProgress: 0,
remainingBudget: 0,
totalExpend: 0,
totalIncome: 0,
totalBalance: 0,
currentPeriodBalance: 0,
monthlyTotalBalance: 0,
cateStatList: [],
currentCateIndex: 0,
searchTimer: null,
showBudgetModal: false,
tempBudget: '',
selectedBudgetMonth: '',
refreshing: false,
submitting: false,
recentCateList: [],
filterCate: '',
showScrollTip: false,
addBtnScale: 'scale(1)',
showCustomDateModal: false,
customStartDate: '',
customEndDate: '',
customDateRange: { start: '', end: '' },
lastMonthBudget: '',
lastMonthExpend: 0,
lastMonthBudgetProgress: 0,
lastMonthRemainingBudget: 0,
isBatchMode: false,
selectedBillIds: [],
showDeleteConfirm: false,
deleteConfirmMessage: '',
pendingDeleteId: null,
showBatchDeleteConfirm: false,
currentMonthFirst: '',
currentMonthLast: '',
page: 1,
pageSize: 20,
hasMore: true,
showAiSuggestion: false,
aiSuggestionText: '',
aiSuggestionData: null,
swipeActions: [
{ key: 'edit', label: '编辑' },
{ key: 'delete', label: '删除' }
]
}
},
computed: {
currentChannels() {
return getChannelsByType(this.form.type)
},
cateList() {
return this.allCateList.filter(item => item.type === this.form.type)
},
filteredBillList() {
let list = this.getBillByTimeRange()
if (this.billType === BILL_FILTER_TYPE.EXPEND) {
list = list.filter(item => item.type === BILL_TYPE.EXPEND)
} else if (this.billType === BILL_FILTER_TYPE.INCOME) {
list = list.filter(item => item.type === BILL_TYPE.INCOME)
}
if (this.filterCate) list = list.filter(item => item.cate === this.filterCate)
if (this.searchWord) {
const keyword = this.searchWord.trim()
list = list.filter(item =>
(item.cate && item.cate.includes(keyword)) ||
(item.note && item.note.includes(keyword))
)
}
return list
},
groupedBillList() {
const groups = {}
this.displayBillList.forEach(item => {
const dateKey = item.date
if (!groups[dateKey]) {
groups[dateKey] = { date: dateKey, items: [], income: 0, expend: 0 }
}
groups[dateKey].items.push(item)
if (item.type === BILL_TYPE.INCOME) {
groups[dateKey].income += Number(item.money) || 0
} else if (item.type === BILL_TYPE.EXPEND) {
groups[dateKey].expend += Number(item.money) || 0
}
})
return groups
}
},
onLoad(options) {
this.themeChangeHandler = (themeData) => {
try {
let newTheme = null
if (themeData?.key && THEME_PRESETS[themeData.key]) {
newTheme = THEME_PRESETS[themeData.key]
} else if (themeData?.config) {
newTheme = themeData.config
}
if (newTheme) {
this.themeConfig = newTheme
this.$forceUpdate()
}
} catch (e) {
console.error('主题更新失败:', e)
}
}
uni.$on('themeChanged', this.themeChangeHandler)
const now = new Date()
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
const paramsDate = options?.date || today
this.selectedDate = paramsDate
this.form.date = paramsDate
this.initCurrentMonthDate()
this.initBudgetMonth()
this.recentCateList = uni.getStorageSync('recentCateList') || []
checkLogin()
this.getList()
this.customStartDate = paramsDate
this.customEndDate = paramsDate
this.customDateRange = { start: paramsDate, end: paramsDate }
this.getMonthBudgetData()
},
onUnload() {
uni.$off('themeChanged', this.themeChangeHandler)
},
onShow() {
const latestTheme = getThemeConfigFromStorage() || THEME_PRESETS['default']
if (JSON.stringify(latestTheme) !== JSON.stringify(this.themeConfig)) {
this.themeConfig = latestTheme
this.$forceUpdate()
}
refreshNavBarTheme()
setTimeout(() => {
forceApplyTabBarTheme(latestTheme)
}, 100)
const now = new Date()
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
this.form.date = today
this.selectedDate = today
this._onShowId = Date.now()
const currentId = this._onShowId
setTimeout(() => {
if (this._onShowId !== currentId) return
this.getMonthBudgetData()
}, 200)
},
onPullDownRefresh() {
this.refreshing = true
this.getList().finally(() => {
this.refreshing = false
uni.stopPullDownRefresh()
})
},
watch: {
activeTimeTag(newVal) {
this.page = 1
this.hasMore = true
this.getList()
if (newVal === TIME_TAG.MONTH) this.getMonthBudgetData()
if (newVal === TIME_TAG.LAST_MONTH) this.getLastMonthBudgetData()
this.$nextTick(() => {
this.calcStatistics()
})
},
billList() {
this.calcStatistics()
}
},
methods: {
initCurrentMonthDate() {
const now = new Date()
const year = now.getFullYear()
const month = String(now.getMonth() + 1).padStart(2, '0')
const firstDay = '01'
const lastDay = String(new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate()).padStart(2, '0')
this.currentMonthFirst = `${year}-${month}-${firstDay}`
this.currentMonthLast = `${year}-${month}-${lastDay}`
},
initBudgetMonth() {
const now = new Date()
const year = now.getFullYear()
const month = String(now.getMonth() + 1).padStart(2, '0')
this.selectedBudgetMonth = `${year}-${month}`
},
onBudgetMonthChange(e) {
this.selectedBudgetMonth = e.detail.value
},
formatDate(dateStr) {
return dateStr || ''
},
getChannelLabel(value) {
return getChannelLabel(value)
},
getCateLabel(value) {
return getCateLabel(value)
},
formatCurrentDate() {
const now = new Date()
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
},
formatBillDate(dateStr) {
if (!dateStr) return ''
const date = new Date(dateStr)
return `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`
},
handleSearch() {
clearTimeout(this.searchTimer)
this.searchTimer = setTimeout(() => {}, 300)
},
clearSearch() {
this.searchWord = ''
this.$forceUpdate()
},
handleTypeFilterChange(val) {
this.billType = val
this.page = 1
this.hasMore = true
this.$nextTick(() => {
this.loadFirstPage()
})
},
handleTimeFilterChange(val) {
if (val === TIME_TAG.CUSTOM) {
this.openCustomDatePicker()
} else {
this.switchTimeTag(val)
}
},
toggleBatchMode() {
this.isBatchMode = !this.isBatchMode
if (!this.isBatchMode) {
this.selectedBillIds = []
}
},
handleSwipeAction(action, item) {
if (action === 'edit') {
this.handleEditBill(item)
} else if (action === 'delete') {
this.handleDeleteBill(item.id)
}
},
closeOtherSwipe(id) {
},
async getList() {
if (this._listLoading) return
this._listLoading = true
this.billListLoaded = false
try {
const now = new Date()
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
let start = '', end = ''
if (this.activeTimeTag === TIME_TAG.TODAY) {
start = end = today
} else if (this.activeTimeTag === TIME_TAG.WEEK) {
const dayOfWeek = now.getDay() || 7
const diffDays = dayOfWeek - 1
const weekStart = new Date(now)
weekStart.setDate(now.getDate() - diffDays)
start = `${weekStart.getFullYear()}-${String(weekStart.getMonth() + 1).padStart(2, '0')}-${String(weekStart.getDate()).padStart(2, '0')}`
end = today
} else if (this.activeTimeTag === TIME_TAG.MONTH) {
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1)
const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0)
start = `${monthStart.getFullYear()}-${String(monthStart.getMonth() + 1).padStart(2, '0')}-${String(monthStart.getDate()).padStart(2, '0')}`
end = `${monthEnd.getFullYear()}-${String(monthEnd.getMonth() + 1).padStart(2, '0')}-${String(monthEnd.getDate()).padStart(2, '0')}`
} else if (this.activeTimeTag === TIME_TAG.LAST_MONTH) {
const lastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1)
const lastMonthEnd = new Date(now.getFullYear(), now.getMonth(), 0)
start = `${lastMonth.getFullYear()}-${String(lastMonth.getMonth() + 1).padStart(2, '0')}-01`
end = `${lastMonthEnd.getFullYear()}-${String(lastMonthEnd.getMonth() + 1).padStart(2, '0')}-${String(lastMonthEnd.getDate()).padStart(2, '0')}`
} else if (this.activeTimeTag === TIME_TAG.THIS_YEAR) {
start = `${now.getFullYear()}-01-01`
end = today
} else if (this.activeTimeTag === TIME_TAG.CUSTOM) {
start = this.customDateRange.start || today
end = this.customDateRange.end || today
}
const res = await getBillList({ start_time: start, end_time: end })
this.billList = res.map(item => ({
id: item.ID,
type: item.type,
money: item.money,
cate: item.cate,
channel: item.channel,
note: item.note,
date: item.date,
from_account: item.from_account,
to_account: item.to_account
}))
this.page = 1
this.hasMore = this.billList.length > this.pageSize
this.loadFirstPage()
} catch (e) {
console.error('获取账单失败:', e)
this.billList = []
} finally {
this.billListLoaded = true
this.refreshing = false
this._listLoading = false
}
},
loadFirstPage() {
const firstPageData = this.filteredBillList.slice(0, this.pageSize)
this.displayBillList = firstPageData
},
loadMore() {
if (!this.hasMore) return
this.page++
const start = (this.page - 1) * this.pageSize
const end = this.page * this.pageSize
const nextPageData = this.filteredBillList.slice(start, end)
this.displayBillList = [...this.displayBillList, ...nextPageData]
const totalFiltered = this.filteredBillList.length
this.hasMore = this.displayBillList.length < totalFiltered
},
async getMonthBudgetData() {
try {
const res = await getMonthBudget()
this.monthlyBudget = res && res.amount ? Number(res.amount) : ''
this.calcStatistics()
} catch (e) {
console.error('获取本月预算失败:', e)
this.monthlyBudget = ''
uni.showToast({ title: '获取预算失败', icon: 'none' })
}
},
getProgressColor() {
if (this.budgetProgress >= BUDGET_PROGRESS_COLOR.OVER) {
return PROGRESS_COLORS.OVER
} else if (this.budgetProgress >= BUDGET_PROGRESS_COLOR.WARNING) {
return PROGRESS_COLORS.WARNING
} else {
return PROGRESS_COLORS.NORMAL
}
},
handleBudgetSetting() {
this.tempBudget = this.monthlyBudget ? this.monthlyBudget.toString() : ''
this.initBudgetMonth()
this.showBudgetModal = true
},
async saveBudget() {
if (!checkLogin()) return
const num = Number(this.tempBudget)
if (!this.tempBudget || isNaN(num) || num <= 0) {
uni.showToast({ title: '请输入有效的预算金额', icon: 'none' })
return
}
try {
await setMonthBudget({ amount: num, month: this.selectedBudgetMonth })
const currentMonth = new Date().getMonth() + 1
const budgetMonth = Number(this.selectedBudgetMonth.split('-')[1])
if (budgetMonth === currentMonth) {
this.monthlyBudget = num
} else {
this.lastMonthBudget = num
}
this.showBudgetModal = false
this.tempBudget = ''
this.calcStatistics()
uni.showToast({ title: '预算设置成功', icon: 'success' })
} catch (err) {
console.error('设置预算失败:', err)
uni.showToast({ title: '设置失败', icon: 'none' })
}
},
calcStatistics() {
this.totalExpend = 0
this.totalIncome = 0
this.monthExpend = 0
this.monthlyTotalBalance = 0
this.lastMonthExpend = 0
const filteredByTime = this.getBillByTimeRange()
const allMonthlyBills = this.getMonthlyBills()
filteredByTime.forEach(item => {
const money = Number(item.money) || 0
if (item.type === BILL_TYPE.EXPEND) this.totalExpend += money
else if (item.type === BILL_TYPE.INCOME) this.totalIncome += money
if (item.type === BILL_TYPE.EXPEND && this.isCurrentMonth(item.date)) this.monthExpend += money
if (item.type === BILL_TYPE.EXPEND) {
if (this.isCurrentMonth(item.date)) {
this.monthExpend += money
}
if (this.isLastMonth(item.date)) {
this.lastMonthExpend += money
}
}
})
allMonthlyBills.forEach(item => {
const money = Number(item.money) || 0
if (item.type === BILL_TYPE.EXPEND) this.monthlyTotalBalance -= money
else if (item.type === BILL_TYPE.INCOME) this.monthlyTotalBalance += money
})
this.currentPeriodBalance = Number((this.totalIncome - this.totalExpend).toFixed(2))
this.totalBalance = this.currentPeriodBalance
this.monthlyTotalBalance = Number(this.monthlyTotalBalance.toFixed(2))
this.budgetProgress = this.monthlyBudget ? (this.monthExpend / this.monthlyBudget) * 100 : 0
this.remainingBudget = this.monthlyBudget ? (this.monthlyBudget - this.monthExpend).toFixed(2) : 0
this.lastMonthBudgetProgress = this.lastMonthBudget ? (this.lastMonthExpend / this.lastMonthBudget) * 100 : 0
this.lastMonthRemainingBudget = this.lastMonthBudget ? (this.lastMonthBudget - this.lastMonthExpend).toFixed(2) : 0
this.calcCateStatistics(filteredByTime)
},
getMonthlyBills() {
const now = new Date()
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1, 0, 0, 0)
const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59)
return this.billList.filter(item => {
if (!item.date) return false
const billDateStr = item.date.replace(/-/g, '/')
const billTime = new Date(billDateStr).getTime()
return billTime >= monthStart.getTime() && billTime <= monthEnd.getTime()
})
},
getBillByTimeRange() {
const now = new Date()
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0)
let startDate, endDate
if (this.activeTimeTag === TIME_TAG.TODAY) {
startDate = new Date(today)
endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59)
} else if (this.activeTimeTag === TIME_TAG.WEEK) {
const dayOfWeek = today.getDay() || 7
const diffDays = dayOfWeek - 1
const weekStart = new Date(today)
weekStart.setDate(today.getDate() - diffDays)
startDate = weekStart
endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59)
} else if (this.activeTimeTag === TIME_TAG.MONTH) {
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1, 0, 0, 0)
const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59)
startDate = monthStart
endDate = monthEnd
} else if (this.activeTimeTag === TIME_TAG.LAST_MONTH) {
const lastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1, 0, 0, 0)
const lastMonthEnd = new Date(now.getFullYear(), now.getMonth(), 0, 23, 59, 59)
startDate = lastMonth
endDate = lastMonthEnd
} else if (this.activeTimeTag === TIME_TAG.THIS_YEAR) {
const yearStart = new Date(now.getFullYear(), 0, 1, 0, 0, 0)
startDate = yearStart
endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59)
} else if (this.activeTimeTag === TIME_TAG.CUSTOM) {
if (!this.customDateRange.start || !this.customDateRange.end) {
startDate = new Date(today)
endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59)
} else {
startDate = new Date(this.customDateRange.start)
endDate = new Date(this.customDateRange.end)
endDate.setHours(23, 59, 59)
}
} else {
startDate = new Date(today)
endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59)
}
return this.billList.filter(item => {
if (!item.date) return false
const billTime = new Date(item.date).getTime()
return billTime >= startDate.getTime() && billTime <= endDate.getTime()
})
},
switchTimeTag(tag) {
this.activeTimeTag = tag
this.filterCate = ''
this.page = 1
this.hasMore = true
this.$nextTick(() => {
this.getList()
this.calcStatistics()
})
},
openCustomDatePicker() {
this.showCustomDateModal = true
const now = new Date()
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
this.customStartDate = today
this.customEndDate = today
},
confirmCustomDate() {
if (!this.customStartDate || !this.customEndDate) {
uni.showToast({ title: '请选择完整的时间范围', icon: 'none' })
return
}
if (new Date(this.customStartDate) > new Date(this.customEndDate)) {
uni.showToast({ title: '开始日期不能晚于结束日期', icon: 'none' })
return
}
this.customDateRange = {
start: this.customStartDate,
end: this.customEndDate
}
this.showCustomDateModal = false
this.activeTimeTag = TIME_TAG.CUSTOM
this.filterCate = ''
this.page = 1
this.hasMore = true
this.$nextTick(() => {
this.getList()
this.calcStatistics()
})
uni.showToast({ title: '时间范围已选择', icon: 'success' })
},
isCurrentMonth(dateStr) {
if (!dateStr) return false
const date = new Date(dateStr.replace(/-/g, '/'))
const now = new Date()
return date.getMonth() === now.getMonth() && date.getFullYear() === now.getFullYear()
},
isLastMonth(dateStr) {
if (!dateStr) return false
const date = new Date(dateStr.replace(/-/g, '/'))
const now = new Date()
const lastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1)
return date.getMonth() === lastMonth.getMonth() && date.getFullYear() === lastMonth.getFullYear()
},
calcCateStatistics(list) {
const cateMap = {}
const totalExpend = list.filter(item => item.type === BILL_TYPE.EXPEND).reduce((sum, item) => sum + Number(item.money), 0)
const totalIncome = list.filter(item => item.type === BILL_TYPE.INCOME).reduce((sum, item) => sum + Number(item.money), 0)
list.forEach(item => {
if (item.type === BILL_TYPE.EXPEND || item.type === BILL_TYPE.INCOME) {
const amount = Number(item.money)
const cate = item.cate
if (!cateMap[cate]) {
cateMap[cate] = {
cate: cate,
expend: 0,
income: 0,
expendRate: 0,
incomeRate: 0,
icon: this.getCateIcon(cate),
color: this.getCateColor(cate)
}
}
if (item.type === BILL_TYPE.EXPEND) cateMap[cate].expend += amount
else cateMap[cate].income += amount
}
})
Object.values(cateMap).forEach(item => {
item.expendRate = totalExpend > 0 ? ((item.expend / totalExpend) * 100).toFixed(1) : 0
item.incomeRate = totalIncome > 0 ? ((item.income / totalIncome) * 100).toFixed(1) : 0
})
this.cateStatList = Object.values(cateMap).sort((a, b) => {
const totalA = a.expend + a.income
const totalB = b.expend + b.income
return totalB - totalA
})
},
getCateIcon(cate) {
const findItem = this.allCateList.find(item => item.value === cate)
return findItem ? findItem.icon : '📦'
},
getCateColor(cate) {
const findItem = this.allCateList.find(item => item.value === cate)
return findItem ? findItem.color : '#84b3d9'
},
openAddModal() {
this.isEdit = false
this.currentEditId = ''
const now = new Date()
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
this.form = { type: BILL_TYPE.EXPEND, money: '', cate: '', channel: 'wechat', note: '', date: today }
this.formattedMoney = ''
this.moneyInputError = ''
this.showAdd = true
this.showAiSuggestion = false
this.aiSuggestionText = ''
this.aiSuggestionData = null
},
handleEditBill(item) {
this.isEdit = true
this.currentEditId = item.id
this.form = {
type: item.type,
money: item.money.toString(),
cate: item.cate,
channel: item.channel || 'wechat',
note: item.note || '',
date: item.date
}
this.formatMoneyOnBlur()
this.showAdd = true
this.showAiSuggestion = false
this.aiSuggestionText = ''
this.aiSuggestionData = null
},
handleDeleteBill(id) {
this.pendingDeleteId = id
this.deleteConfirmMessage = '确定要删除这条账单吗?删除后不可恢复。'
this.showDeleteConfirm = true
},
async confirmDeleteBill() {
try {
await deleteBill({ id: this.pendingDeleteId })
uni.showToast({ title: '删除成功' })
this.showDeleteConfirm = false
this.pendingDeleteId = null
this.getList()
} catch (e) {
console.error('删除账单失败:', e)
uni.showToast({ title: '删除失败', icon: 'none' })
}
},
batchEdit() {
uni.showToast({ title: '批量编辑功能开发中', icon: 'none' })
},
batchDelete() {
if (this.selectedBillIds.length === 0) {
uni.showToast({ title: '请先选择账单', icon: 'none' })
return
}
this.showBatchDeleteConfirm = true
},
async confirmBatchDelete() {
try {
const promises = this.selectedBillIds.map(id => deleteBill({ id }))
await Promise.all(promises)
uni.showToast({ title: '批量删除成功' })
this.showBatchDeleteConfirm = false
this.selectedBillIds = []
this.isBatchMode = false
this.getList()
} catch (e) {
console.error('批量删除失败:', e)
uni.showToast({ title: '批量删除失败', icon: 'none' })
}
},
handleSheetClose() {
this.showAdd = false
},
closeModal() {
this.showAdd = false
const now = new Date()
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
this.form = { type: BILL_TYPE.EXPEND, money: '', cate: '', channel: 'wechat', note: '', date: today }
this.formattedMoney = ''
this.moneyInputError = ''
this.showAiSuggestion = false
this.aiSuggestionText = ''
this.aiSuggestionData = null
},
async submitBill() {
if (!checkLogin()) return
this.formatMoneyOnBlur()
if ((!this.form.money || Number(this.form.money) <= 0) || !this.form.cate) {
return uni.showToast({ title: '请填写有效的金额和分类', icon: 'none' })
}
this.submitting = true
try {
const billData = {
...this.form,
money: parseFloat(this.form.money) || 0,
from_account: '',
to_account: ''
}
if (this.isEdit) {
await editBill({ id: this.currentEditId, ...billData })
uni.showToast({ title: '修改成功' })
} else {
const res = await addBill(billData)
uni.showToast({ title: '账单成功' })
if (res && res.growth) {
this.showGrowthModal(res.growth)
}
}
this.closeModal()
this.getList()
} catch (e) {
console.error('提交账单失败:', e)
uni.showToast({ title: this.isEdit ? '修改失败' : '账单失败', icon: 'none' })
} finally {
this.submitting = false
}
},
async classifyBill(content) {
try {
const res = await classifyBillApi({ content })
if (res && res.type === 'bill' && res.data) {
this.aiSuggestionData = res.data
const parts = []
if (res.data.cate) parts.push(`分类:${getCateLabel(res.data.cate)}`)
if (res.data.type) parts.push(`类型:${res.data.type === 1 ? '支出' : '收入'}`)
if (res.data.channel) parts.push(`渠道:${getChannelLabel(res.data.channel)}`)
this.aiSuggestionText = parts.join(' · ')
this.showAiSuggestion = true
}
} catch (e) {
console.log('AI分类未返回有效结果,跳过建议')
}
},
applyAiSuggestion() {
if (!this.aiSuggestionData) return
const data = this.aiSuggestionData
if (data.cate) this.form.cate = data.cate
if (data.type) this.form.type = data.type
if (data.channel) this.form.channel = data.channel
this.showAiSuggestion = false
this.aiSuggestionData = null
uni.showToast({ title: '已应用AI建议', icon: 'success' })
},
addCustomCate() {
if (!this.customCateName.trim()) {
return uni.showToast({ title: '请输入分类名称', icon: 'none' })
}
const isExist = this.allCateList.some(item => item.value === this.customCateName)
if (isExist) {
return uni.showToast({ title: '分类已存在', icon: 'none' })
}
const newCate = {
value: this.customCateName,
label: this.customCateName,
icon: this.customCateIcon || '📦',
color: this.selectedColor,
type: this.form.type
}
this.allCateList.push(newCate)
this.form.cate = this.customCateName
this.customCateName = ''
this.customCateIcon = ''
this.showCustomCate = false
uni.showToast({ title: '分类添加成功' })
},
selectCate(cate) {
this.form.cate = cate.value
let recent = [...this.recentCateList]
recent = recent.filter(item => item.value !== cate.value)
recent.unshift(cate)
this.recentCateList = recent.slice(0, RECENT_CATE_MAX_COUNT)
uni.setStorageSync('recentCateList', this.recentCateList)
},
handleMoneyInput(e) {
const value = e.detail.value
this.validateMoneyInput(value, 'form.money')
},
validateMoneyInput(value, target) {
this.moneyInputError = ''
let cleanValue = value === null || value === undefined ? '' : String(value)
cleanValue = cleanValue.replace(/[^\d.]/g, '')
if (cleanValue.indexOf('.') !== -1) {
const parts = cleanValue.split('.')
if (parts.length > 2) {
this.moneyInputError = '请输入有效金额,最多两位小数'
cleanValue = parts[0] + '.' + parts[1].substring(0, 2)
} else if (parts[1] && parts[1].length > 2) {
this.moneyInputError = '请输入有效金额,最多两位小数'
cleanValue = parts[0] + '.' + parts[1].substring(0, 2)
}
}
if (target === 'form.money') {
this.form.money = cleanValue
this.formattedMoney = cleanValue
} else if (target === 'tempBudget') {
this.tempBudget = cleanValue
}
},
formatMoneyOnBlur() {
if (!this.form.money) {
this.formattedMoney = ''
return
}
let num = String(this.form.money).replace(/[^\d.]/g, '')
if (num === '.') num = '0.00'
else if (num.indexOf('.') === -1) num = `${num}.00`
else {
const parts = num.split('.')
parts[1] = parts[1].padEnd(2, '0').substring(0, 2)
num = `${parts[0]}.${parts[1]}`
}
this.formattedMoney = num
this.form.money = num
},
fillQuickMoney(amount) {
this.form.money = amount.toString()
this.formattedMoney = `${amount}.00`
this.moneyInputError = ''
},
filterByCate(cate) {
this.filterCate = this.filterCate === cate ? '' : cate
uni.showToast({
title: this.filterCate ? `已筛选${cate}账单` : '已取消分类筛选',
icon: 'none'
})
const index = this.cateStatList.findIndex(item => item.cate === cate)
if (index !== -1) {
this.currentCateIndex = index
}
this.page = 1
this.hasMore = true
this.$nextTick(() => {
this.loadFirstPage()
})
},
onCateScroll(e) {
const scrollLeft = e.detail.scrollLeft
const itemWidth = 216
this.currentCateIndex = Math.round(scrollLeft / itemWidth)
},
handleScroll(e) {
const { scrollTop, scrollHeight, windowHeight } = e.detail
this.showScrollTip = scrollTop + windowHeight < scrollHeight - 100
},
handleAddBtnClick() {
this.addBtnScale = 'scale(1.1)'
setTimeout(() => {
this.addBtnScale = 'scale(1)'
this.openAddModal()
}, 150)
},
getLastMonthFormat() {
const now = new Date()
const lastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1)
const year = lastMonth.getFullYear()
const month = String(lastMonth.getMonth() + 1).padStart(2, '0')
return `${year}-${month}`
},
async getLastMonthBudgetData() {
try {
const lastMonth = this.getLastMonthFormat()
const res = await getMonthBudget({ month: lastMonth })
this.lastMonthBudget = res && res.amount ? Number(res.amount) : ''
this.calcStatistics()
} catch (e) {
console.error('获取上月预算失败:', e)
this.lastMonthBudget = ''
uni.showToast({ title: '获取上月预算失败', icon: 'none' })
}
},
handleLastMonthBudgetSetting() {
this.tempBudget = this.lastMonthBudget ? this.lastMonthBudget.toString() : ''
this.selectedBudgetMonth = this.getLastMonthFormat()
this.showBudgetModal = true
}
}
}
</script>
<style lang="scss" scoped>
.bill-page {
height: 100vh;
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
}
.sticky-header {
flex-shrink: 0;
z-index: 10;
padding: 0 32rpx;
padding-top: 12rpx;
padding-bottom: 16rpx;
}
.main-scroll {
flex: 1;
height: 0;
}
.search-bar {
display: flex;
flex-direction: column;
gap: 16rpx;
margin-bottom: 20rpx;
}
.search-input {
display: flex;
align-items: center;
border-radius: 12rpx;
padding: 0 20rpx;
height: 70rpx;
}
.search-input input {
flex: 1;
font-size: 24rpx;
}
.search-input .clear-icon {
font-size: 24rpx;
padding: 8rpx;
border-radius: 50%;
transition: all 0.2s ease;
}
.filter-section {
display: flex;
align-items: center;
gap: 16rpx;
}
.filter-row-label {
font-size: 24rpx;
flex-shrink: 0;
min-width: 60rpx;
}
.budget-card {
margin-bottom: 20rpx;
}
.budget-title {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16rpx;
}
.budget-title text {
font-size: 20rpx;
font-weight: 500;
}
.budget-value {
font-weight: 600;
font-size: 30rpx;
}
.budget-set-btn {
font-size: 26rpx;
padding: 8rpx 20rpx;
border: 1rpx solid;
border-radius: 10rpx;
transition: all 0.2s ease;
}
.budget-empty {
text-align: center;
padding: 30rpx 0;
font-size: 24rpx;
}
.progress-bar {
height: 12rpx;
border-radius: 6rpx;
overflow: hidden;
margin-bottom: 16rpx;
}
.progress-fill {
height: 100%;
border-radius: 6rpx;
transition: width 0.3s ease;
}
.budget-info {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 24rpx;
}
.budget-info text {
flex: 1;
}
.budget-info text:nth-child(2) {
text-align: center;
}
.budget-info text:last-child {
text-align: right;
}
.stat-scroll {
width: 100%;
}
.stat-row {
display: flex;
flex-wrap: nowrap;
gap: 16rpx;
}
.stat-item {
flex-shrink: 0;
width: 200rpx;
display: flex;
flex-direction: column;
align-items: center;
gap: 12rpx;
padding: 28rpx 12rpx;
border-radius: 18rpx;
background: rgba(0, 0, 0, 0.03);
box-sizing: border-box;
}
.stat-icon {
width: 52rpx;
height: 52rpx;
border-radius: 14rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 26rpx;
}
.expend-icon {
background: rgba(239, 68, 68, 0.15);
}
.income-icon {
background: rgba(34, 197, 94, 0.15);
}
.balance-icon {
background: rgba(59, 130, 246, 0.15);
}
.month-icon {
background: rgba(139, 92, 246, 0.15);
}
.stat-info {
display: flex;
flex-direction: column;
align-items: center;
gap: 6rpx;
}
.stat-info .label {
font-size: 22rpx;
}
.stat-info .value {
font-size: 28rpx;
font-weight: 700;
}
.cate-stat-section {
padding: 0 32rpx;
margin-bottom: 20rpx;
}
.cate-stat-single {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16rpx;
}
.cate-stat-item-wide {
display: flex;
align-items: center;
min-height: 140rpx;
border-radius: 20rpx;
padding: 20rpx 24rpx;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
transition: all 0.2s ease;
}
.cate-stat-item-wide:active {
transform: scale(0.99);
}
.cate-left-wide {
display: flex;
flex-direction: column;
align-items: center;
margin-right: 20rpx;
min-width: 80rpx;
}
.cate-icon-wide {
width: 64rpx;
height: 64rpx;
line-height: 64rpx;
border-radius: 20rpx;
text-align: center;
font-size: 32rpx;
color: #fff;
}
.cate-name-wide {
font-size: 28rpx;
font-weight: 500;
margin-top: 8rpx;
text-align: center;
}
.cate-right-wide {
flex: 1;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 12rpx;
}
.cate-money-row-wide {
display: flex;
gap: 24rpx;
}
.cate-money {
font-size: 24rpx;
font-weight: bold;
}
.cate-rate-row-wide {
padding-top: 10rpx;
border-top: 1rpx solid #f0f5fa;
margin-top: 6rpx;
width: 100%;
}
.cate-rate {
font-size: 22rpx;
}
.cate-stat-scroll-wrapper {
margin-bottom: 20rpx;
}
.cate-stat-scroll {
white-space: nowrap;
padding: 8rpx 0;
}
.cate-stat-container-scroll {
display: inline-flex;
gap: 16rpx;
padding: 0 20rpx;
}
.cate-stat-item-scroll {
display: flex;
flex-direction: column;
align-items: center;
width: 200rpx;
min-height: 160rpx;
border-radius: 20rpx;
padding: 20rpx 16rpx;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
transition: all 0.2s ease;
flex-shrink: 0;
}
.cate-icon-scroll {
width: 56rpx;
height: 56rpx;
line-height: 56rpx;
border-radius: 18rpx;
text-align: center;
font-size: 28rpx;
color: #fff;
}
.cate-name-scroll {
font-size: 26rpx;
font-weight: 500;
margin-top: 10rpx;
}
.cate-stat-row-scroll {
margin-top: 6rpx;
}
.cate-stat-text {
font-size: 22rpx;
font-weight: 500;
}
.cate-rate-scroll {
font-size: 20rpx;
}
.scroll-indicator {
display: flex;
justify-content: center;
gap: 8rpx;
margin-top: 12rpx;
}
.indicator-dot {
width: 10rpx;
height: 10rpx;
border-radius: 50%;
transition: all 0.2s ease;
}
.indicator-dot.active {
width: 24rpx;
border-radius: 10rpx;
}
.bill-list-container {
width: 100%;
margin-bottom: 160rpx;
}
.skeleton-container {
padding: 16rpx;
margin-top: 20rpx;
}
.skeleton-item {
display: flex;
align-items: center;
height: 140rpx;
margin: 12rpx 0;
padding: 0 24rpx;
border-radius: 20rpx;
background: #f8f9fa;
}
.skeleton-icon {
width: 56rpx;
height: 56rpx;
border-radius: 20rpx;
background: #e8e8e8;
margin-right: 20rpx;
}
.skeleton-text {
flex: 1;
}
.skeleton-line {
height: 30rpx;
background: #e8e8e8;
border-radius: 6rpx;
margin-bottom: 8rpx;
width: 60%;
}
.skeleton-line.short {
width: 30%;
height: 24rpx;
}
.skeleton-money {
width: 120rpx;
height: 36rpx;
background: #e8e8e8;
border-radius: 6rpx;
}
.bill-list-card {
min-height: 500rpx;
display: flex;
flex-direction: column;
}
.bill-group {
margin-bottom: 16rpx;
}
.bill-group-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12rpx 8rpx;
border-bottom: 1rpx solid #f0f5fa;
margin-bottom: 8rpx;
}
.bill-group-date {
font-size: 24rpx;
font-weight: 500;
}
.bill-group-summary {
font-size: 22rpx;
}
.bill-item-content {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16rpx 24rpx;
border-radius: 16rpx;
}
.bill-left {
display: flex;
align-items: center;
}
.bill-left .cate-icon {
margin-right: 20rpx;
width: 56rpx;
height: 56rpx;
line-height: 56rpx;
border-radius: 16rpx;
text-align: center;
font-size: 28rpx;
color: #fff;
}
.bill-text {
display: flex;
flex-direction: column;
}
.bill-text .cate-name {
font-size: 30rpx;
font-weight: bold;
margin-bottom: 4rpx;
}
.bill-meta {
display: flex;
align-items: center;
gap: 6rpx;
}
.bill-text .note {
font-size: 22rpx;
}
.money {
font-size: 34rpx;
font-weight: bold;
margin-left: 20rpx;
padding: 4rpx 8rpx;
border-radius: 8rpx;
}
.scroll-tip {
text-align: center;
font-size: 22rpx;
padding: 16rpx 0;
}
.pagination-tip {
text-align: center;
font-size: 24rpx;
padding: 20rpx 0;
cursor: pointer;
}
.batch-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16rpx;
margin-top: 16rpx;
border-radius: 12rpx;
background: rgba(0, 0, 0, 0.03);
}
.batch-actions {
display: flex;
gap: 16rpx;
}
.batch-btn {
padding: 12rpx 24rpx;
border-radius: 12rpx;
color: #fff;
font-size: 24rpx;
}
.header-actions {
display: flex;
gap: 16rpx;
}
.header-btn {
width: 68rpx;
height: 68rpx;
border-radius: 14rpx;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.25s ease;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.06);
}
.header-btn:active {
transform: scale(0.93);
}
.batch-icon {
font-size: 32rpx;
font-weight: 600;
}
.float-buttons {
position: fixed;
bottom: calc(40rpx + constant(safe-area-inset-bottom));
bottom: calc(40rpx + env(safe-area-inset-bottom));
right: 30rpx;
z-index: 9999;
}
.add-btn {
width: 100rpx;
height: 100rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 6px 24rpx rgba(0, 0, 0, 0.15);
position: relative;
}
.add-btn-icon {
font-size: 48rpx;
font-weight: 500;
color: #fff;
}
.modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: flex-end;
justify-content: center;
z-index: 10000;
}
.modal-center {
align-items: center;
}
.modal-box {
width: 100%;
padding: 30rpx;
padding-bottom: calc(30rpx + constant(safe-area-inset-bottom));
padding-bottom: calc(30rpx + env(safe-area-inset-bottom));
border-radius: 20rpx 20rpx 0 0;
}
.date-picker-box {
}
.modal-title {
text-align: center;
font-size: 34rpx;
font-weight: bold;
margin-bottom: 28rpx;
}
.form-field {
display: flex;
flex-direction: column;
margin-bottom: 28rpx;
}
.field-label {
font-size: 26rpx;
font-weight: 500;
margin-bottom: 12rpx;
}
.field-value {
flex: 1;
}
.date-picker {
padding: 20rpx;
border: 1rpx solid;
border-radius: 12rpx;
font-size: 26rpx;
box-sizing: border-box;
}
.input {
border: 1rpx solid #f0f5fa;
padding: 20rpx;
border-radius: 12rpx;
font-size: 26rpx;
}
.money-input {
font-size: 34rpx;
text-align: left;
}
.input-placeholder {
color: #9AA4B2;
font-size: 26rpx;
}
.btns {
display: flex;
justify-content: space-between;
margin-top: 20rpx;
gap: 16rpx;
}
.btns button {
flex: 1;
padding: 20rpx 30rpx;
border-radius: 12rpx;
font-size: 28rpx;
transition: all 0.2s ease;
}
.cancel-btn {
border: none;
}
.confirm-btn {
border: none;
color: #fff;
}
.confirm-btn::after {
border: none;
}
.budget-modal-box {
border-radius: 24rpx 24rpx 0 0;
}
.custom-cate-box {
width: 85%;
max-width: 650rpx;
padding: 40rpx;
}
.color-select {
margin-bottom: 20rpx;
}
.color-list {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
margin-top: 8rpx;
}
.color-item {
width: 40rpx;
height: 40rpx;
border-radius: 50%;
}
.color-item.active {
border: 2rpx solid #2A3A4D;
}
.delete-confirm-text {
font-size: 28rpx;
line-height: 1.6;
text-align: center;
}
.type-section {
display: flex;
gap: 12rpx;
padding: 16rpx 0;
}
.type-btn {
flex: 1;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 8rpx;
padding: 14rpx 12rpx;
border-radius: 12rpx;
transition: all 0.25s ease;
min-height: 64rpx;
}
.type-btn.active {
transform: scale(1.02);
box-shadow: 0 4rpx 12rpx rgba(132, 179, 217, 0.3);
}
.type-icon {
font-size: 28rpx;
}
.type-text {
font-size: 24rpx;
font-weight: 500;
}
.amount-section {
padding: 16rpx 0;
}
.amount-label {
font-size: 26rpx;
margin-bottom: 16rpx;
display: block;
}
.amount-input-wrap {
display: flex;
align-items: flex-start;
padding: 24rpx;
background: rgba(132, 179, 217, 0.08);
border-radius: 20rpx;
margin-bottom: 16rpx;
}
.amount-symbol {
font-size: 36rpx;
font-weight: 600;
line-height: 1.3;
margin-right: 6rpx;
}
.amount-input {
font-size: 44rpx;
font-weight: 700;
border: none;
outline: none;
background: transparent;
flex: 1;
}
.quick-amounts {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
}
.quick-amount-btn {
padding: 12rpx 24rpx;
border-radius: 12rpx;
font-size: 24rpx;
transition: all 0.2s ease;
}
.quick-amount-btn:active {
opacity: 0.8;
}
.section {
padding: 16rpx 0;
}
.section-title {
font-size: 28rpx;
font-weight: 600;
margin-bottom: 20rpx;
display: block;
}
.recent-cate-row {
display: flex;
align-items: center;
gap: 16rpx;
margin-bottom: 16rpx;
}
.recent-label {
font-size: 24rpx;
padding: 6rpx 16rpx;
background: rgba(132, 179, 217, 0.1);
border-radius: 12rpx;
}
.recent-cate-items {
display: flex;
gap: 12rpx;
flex-wrap: wrap;
flex: 1;
}
.recent-cate-item {
display: flex;
align-items: center;
gap: 8rpx;
padding: 8rpx 16rpx;
background: rgba(132, 179, 217, 0.1);
border-radius: 12rpx;
transition: all 0.2s ease;
}
.recent-cate-item.active {
background: #84b3d9;
}
.recent-cate-icon {
font-size: 24rpx;
width: 36rpx;
height: 36rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.recent-cate-name {
font-size: 22rpx;
}
.cate-grid-new {
display: flex;
flex-wrap: wrap;
gap: 8rpx;
justify-content: flex-start;
}
.cate-item-new {
display: flex;
flex-direction: column;
align-items: center;
gap: 4rpx;
width: 80rpx;
padding: 8rpx 4rpx;
border-radius: 10rpx;
transition: all 0.2s ease;
}
.cate-item-new:active {
transform: scale(0.95);
}
.cate-item-new.active {
background: rgba(132, 179, 217, 0.12);
}
.cate-icon-wrap {
width: 44rpx;
height: 44rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.cate-icon {
font-size: 22rpx;
}
.add-icon-wrap {
border: 2rpx dashed #84b3d9;
background: transparent;
}
.add-icon-wrap .cate-icon {
color: #84b3d9;
font-size: 26rpx;
font-weight: 600;
}
.cate-label {
font-size: 18rpx;
}
.channel-grid {
display: flex;
flex-wrap: wrap;
gap: 10rpx;
}
.channel-btn {
padding: 10rpx 22rpx;
border-radius: 12rpx;
font-size: 24rpx;
border: 2rpx solid;
transition: all 0.2s ease;
}
.channel-btn:active {
transform: scale(0.95);
}
.note-input-wrap {
margin-bottom: 10rpx;
}
.note-input {
width: 100%;
padding: 12rpx 16rpx;
border-radius: 10rpx;
font-size: 24rpx;
box-sizing: border-box;
min-height: 56rpx;
line-height: 1.4;
}
.quick-tags {
display: flex;
flex-wrap: wrap;
gap: 6rpx;
}
.quick-tag {
padding: 6rpx 12rpx;
border-radius: 12rpx;
font-size: 18rpx;
}
.date-picker-wrap {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx;
border-radius: 16rpx;
}
.date-text {
font-size: 28rpx;
}
.modal-footer {
display: flex;
gap: 20rpx;
}
.btn-cancel {
flex: 1;
height: 84rpx;
border-radius: 14rpx;
border: 2rpx solid transparent;
font-size: 28rpx;
font-weight: 500;
background: rgba(0, 0, 0, 0.04);
}
.btn-confirm {
flex: 2;
height: 84rpx;
border-radius: 14rpx;
border: none;
color: #fff;
font-size: 28rpx;
font-weight: 600;
transition: all 0.2s ease;
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.1);
}
.btn-confirm:active {
transform: scale(0.98);
opacity: 0.9;
}
.btn-confirm[disabled] {
opacity: 0.5;
}
.ai-suggestion {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20rpx;
border: 2rpx dashed;
border-radius: 12rpx;
}
.ai-suggestion-text {
font-size: 26rpx;
font-weight: 500;
}
.ai-suggestion-hint {
font-size: 22rpx;
}
.theme-transition {
transition: background-color 0.3s ease, color 0.3s ease,
border-color 0.3s ease, box-shadow 0.3s ease,
opacity 0.25s ease, transform 0.25s ease;
}
@keyframes pageFadeIn {
0% { opacity: 0; transform: translateY(20rpx); }
100% { opacity: 1; transform: translateY(0); }
}
@keyframes cardEnter {
0% { opacity: 0; transform: translateY(16rpx); }
100% { opacity: 1; transform: translateY(0); }
}
.content-fade {
padding: 0 32rpx;
transition: opacity 0.25s ease;
}
</style>