Compare commits

...

7 Commits

Author SHA1 Message Date
768c72d7b7 feat: 金额编辑器交互优化 — 支持光标定位、插入删除、长按删除
- 新增 AmountEditor 组件,页面只接一个组件即可获得完整金额编辑体验
- Numpad 增强:支持 cursorIndex 光标输入 + 长按连续删除
- 金额编辑纯函数 amount-edit.ts 集中管理插入/删除/校验规则
- 记一笔、预算页、周期账单弹窗统一接入 AmountEditor
- 默认聚焦金额、点击备注/日期/分类隐藏 Numpad、再次点击金额恢复
- 空值输入小数点规范为 0.
2026-06-09 14:09:14 +08:00
ac20a70a20 refactor: 移除前端接口缓存 — 每次 onShow 都实时请求最新数据 2026-06-09 09:21:16 +08:00
4e4b7dac69 fix: 周期账单弹窗 — Numpad 移到 scroll-view 外 + 防重复提交 + @confirm 绑定 2026-06-09 09:13:28 +08:00
df6f1a54d6 refactor: Numpad 组件支持 fixed prop — 弹窗内嵌 / 页面固定两种模式 2026-06-09 09:10:36 +08:00
7bf899f0b1 refactor: 周期账单 Numpad 改为弹窗内嵌,移除 fixed 定位 2026-06-09 09:08:03 +08:00
a277f1533b fix: 首页刷新/反馈按钮/周期键盘三个 bug 修复 2026-06-09 09:06:44 +08:00
f745f4b4f0 fix: 修复 feedback 状态类型 — string → Feedback['status'] 联合类型 2026-06-09 09:02:02 +08:00
11 changed files with 620 additions and 228 deletions

1
DEV.md
View File

@@ -119,6 +119,7 @@ onPullDownRefresh(async () => {
- Never 使用 git stash - Never 使用 git stash
- Never 切换分支 - Never 切换分支
- Never 过度封装,保持代码简洁明了 - Never 过度封装,保持代码简洁明了
- Never 未确定需求就开始实现
## Requirements ## Requirements

View File

@@ -30,6 +30,6 @@ export function getFeedbackList(params?: { page?: number; pageSize?: number; sta
} }
/** 更新反馈状态(管理员) */ /** 更新反馈状态(管理员) */
export function updateFeedbackStatus(id: number, status: string, admin_reply?: string) { export function updateFeedbackStatus(id: number, status: Feedback['status'], admin_reply?: string) {
return request({ url: `/feedback/${id}/status`, method: 'PUT', data: { status, admin_reply } }) return request({ url: `/feedback/${id}/status`, method: 'PUT', data: { status, admin_reply } })
} }

View File

@@ -0,0 +1,263 @@
<template>
<view class="amount-editor" :class="[`amount-editor--${size}`, { 'amount-editor--focused': innerFocused }]">
<view class="amount-display" @tap="focusAtEnd">
<text class="currency">¥</text>
<view class="amount-value">
<template v-if="displayChars.length > 0">
<template v-for="(char, index) in displayChars" :key="`${char}-${index}`">
<text v-if="innerFocused && cursorIndex === index" class="cursor"></text>
<text class="amount-char" @tap.stop="focusAt(index + 1)">{{ char }}</text>
</template>
<text v-if="innerFocused && cursorIndex === displayChars.length" class="cursor"></text>
</template>
<template v-else>
<text class="placeholder" @tap.stop="focusAtEnd">{{ placeholder }}</text>
<text v-if="innerFocused" class="cursor"></text>
</template>
</view>
<text v-if="unit" class="unit">{{ unit }}</text>
</view>
<Numpad
v-model="innerValue"
v-model:cursorIndex="cursorIndex"
:max="max"
:fixed="fixed"
:visible="innerFocused && visible"
@confirm="emit('confirm')"
/>
</view>
</template>
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import Numpad from '@/components/Numpad/Numpad.vue'
import { clampCursorIndex } from '@/components/AmountEditor/amount-edit'
const props = withDefaults(defineProps<{
modelValue?: string
max?: number
fixed?: boolean
visible?: boolean
autoFocus?: boolean
size?: 'small' | 'medium' | 'large'
placeholder?: string
unit?: string
}>(), {
modelValue: '',
max: 9999999.99,
fixed: true,
visible: true,
autoFocus: true,
size: 'large',
placeholder: '0',
unit: ''
})
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
(e: 'focus'): void
(e: 'blur'): void
(e: 'confirm'): void
}>()
const innerFocused = ref(false)
const cursorIndex = ref(0)
const innerValue = computed({
get: () => props.modelValue || '',
set: (value: string) => emit('update:modelValue', value)
})
const displayChars = computed(() => innerValue.value.split(''))
watch(
() => props.modelValue,
(value) => {
cursorIndex.value = clampCursorIndex(value || '', cursorIndex.value)
}
)
watch(
() => props.visible,
(visible) => {
if (visible && props.autoFocus) {
nextTick(focus)
} else if (!visible) {
blur()
}
},
{ immediate: true }
)
function focus() {
cursorIndex.value = innerValue.value.length
if (!innerFocused.value) {
innerFocused.value = true
emit('focus')
}
}
function blur() {
if (innerFocused.value) {
innerFocused.value = false
emit('blur')
}
}
function focusAt(index: number) {
if (!props.visible) return
cursorIndex.value = clampCursorIndex(innerValue.value, index)
if (!innerFocused.value) {
innerFocused.value = true
emit('focus')
}
}
function focusAtEnd() {
focusAt(innerValue.value.length)
}
defineExpose({ focus, blur })
</script>
<style lang="scss" scoped>
@import '@/styles/mixins.scss';
.amount-editor {
width: 100%;
}
.amount-display {
min-height: 156rpx;
padding: $space-lg $space-xl;
background: $surface;
border: 2rpx solid $border;
border-radius: $radius-2xl;
box-shadow: $shadow-clay;
display: flex;
align-items: center;
justify-content: center;
gap: $space-xs;
-webkit-tap-highlight-color: transparent;
transition: border-color $transition-normal, box-shadow $transition-normal, transform $transition-fast;
&:active {
transform: scale(0.99);
}
}
.amount-editor--focused .amount-display {
border-color: $primary-light;
box-shadow: $shadow-clay, 0 8rpx 24rpx rgba(255, 140, 105, 0.16);
}
.currency,
.unit {
flex-shrink: 0;
color: $text-sec;
font-weight: 600;
}
.currency {
align-self: flex-start;
margin-top: 12rpx;
font-family: 'Fredoka', sans-serif;
font-size: 40rpx;
}
.unit {
align-self: flex-end;
margin-bottom: 14rpx;
font-size: $font-md;
}
.amount-value {
min-width: 0;
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
font-family: 'Fredoka', sans-serif;
font-weight: 700;
color: $text;
line-height: 1.1;
word-break: break-all;
}
.amount-char,
.placeholder {
display: inline-flex;
align-items: center;
min-height: 1.1em;
}
.amount-char {
-webkit-tap-highlight-color: transparent;
}
.placeholder {
color: $text-muted;
}
.cursor {
width: 4rpx;
height: 1em;
margin: 0 2rpx;
border-radius: 999rpx;
background: $primary;
animation: cursor-blink 1s steps(2, start) infinite;
}
.amount-editor--small {
.amount-display {
min-height: 112rpx;
padding: $space-md $space-lg;
border-radius: $radius-xl;
}
.amount-value {
font-size: 52rpx;
}
.currency {
margin-top: 6rpx;
font-size: 28rpx;
}
}
.amount-editor--medium {
.amount-display {
min-height: 132rpx;
}
.amount-value {
font-size: 68rpx;
}
.currency {
font-size: 34rpx;
}
}
.amount-editor--large {
.amount-value {
font-size: 88rpx;
}
}
@keyframes cursor-blink {
0%, 45% { opacity: 1; }
46%, 100% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.amount-display {
transition: none;
}
.cursor {
animation: none;
}
}
</style>

View File

@@ -0,0 +1,108 @@
export type AmountKey = '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | '00' | '.'
export type AmountActionKey = AmountKey | 'delete'
export interface AmountEditOptions {
max?: number
maxLength?: number
}
export interface AmountEditResult {
value: string
cursorIndex: number
accepted: boolean
}
const DEFAULT_MAX = 9999999.99
// 对应最大金额字符串 '9999999.99' 的长度。
const DEFAULT_MAX_LENGTH = 10
const MAX_DECIMAL_PLACES = 2
export function clampCursorIndex(value: string, cursorIndex: number): number {
if (cursorIndex < 0) return 0
if (cursorIndex > value.length) return value.length
return cursorIndex
}
export function insertAmountKey(
value: string,
cursorIndex: number,
key: AmountKey,
options: AmountEditOptions = {}
): AmountEditResult {
const safeCursor = clampCursorIndex(value, cursorIndex)
// 替换模式:当当前值只有一个 '0' 且光标在末尾时,非 '.' 输入应替换 '0'
if (value === '0' && safeCursor === value.length && key !== '.') {
// key 可能是 '00',需要规范化
const insert = normalizeInsert('', 0, key)
if (insert === '') return { value, cursorIndex: safeCursor, accepted: true }
const next = insert
if (!isValidAmountInput(next, options)) {
return { value, cursorIndex: safeCursor, accepted: false }
}
return {
value: next,
cursorIndex: insert.length,
accepted: true,
}
}
const normalizedInsert = normalizeInsert(value, safeCursor, key)
const next = value.slice(0, safeCursor) + normalizedInsert + value.slice(safeCursor)
if (!isValidAmountInput(next, options)) {
return { value, cursorIndex: safeCursor, accepted: false }
}
return {
value: next,
cursorIndex: safeCursor + normalizedInsert.length,
accepted: true,
}
}
export function deleteAmountChar(value: string, cursorIndex: number): AmountEditResult {
const safeCursor = clampCursorIndex(value, cursorIndex)
if (safeCursor === 0) {
return { value, cursorIndex: safeCursor, accepted: false }
}
return {
value: value.slice(0, safeCursor - 1) + value.slice(safeCursor),
cursorIndex: safeCursor - 1,
accepted: true,
}
}
export function applyAmountKey(
value: string,
cursorIndex: number,
key: AmountActionKey,
options: AmountEditOptions = {}
): AmountEditResult {
if (key === 'delete') return deleteAmountChar(value, cursorIndex)
return insertAmountKey(value, cursorIndex, key, options)
}
export function isValidAmountInput(value: string, options: AmountEditOptions = {}): boolean {
if (value === '') return true
if (value.length > (options.maxLength ?? DEFAULT_MAX_LENGTH)) return false
if (!/^\d*(\.\d*)?$/.test(value)) return false
const dotIndex = value.indexOf('.')
if (dotIndex >= 0 && value.length - dotIndex - 1 > MAX_DECIMAL_PLACES) return false
const num = parseFloat(value)
if (!Number.isNaN(num) && num > (options.max ?? DEFAULT_MAX)) return false
return true
}
function normalizeInsert(value: string, cursorIndex: number, key: AmountKey): string {
if ((key === '0' || key === '00') && value === '') return '0'
if ((key === '0' || key === '00') && value === '0' && cursorIndex === value.length) return ''
if (value === '0' && cursorIndex === value.length && key !== '.') return key
// 空值时输入小数点,补前导零,保证合法格式
if (value === '' && key === '.') return '0.'
return key
}

View File

@@ -1,9 +1,15 @@
<template> <template>
<view class="numpad"> <view class="numpad" :class="{ 'numpad-fixed': fixed, 'numpad-hidden': !visible }">
<view class="key" v-for="key in keys" :key="key" @tap="onKeyTap(key)"> <view class="key" v-for="key in keys" :key="key" @tap="onKeyTap(key)">
<text class="key-text">{{ key }}</text> <text class="key-text">{{ key }}</text>
</view> </view>
<view class="key fn" @tap="onDelete"> <view
class="key fn"
@tap="onDeleteTap"
@touchstart="onDeleteTouchStart"
@touchend="onDeleteTouchEnd"
@touchcancel="onDeleteTouchEnd"
>
<view class="backspace-icon"> <view class="backspace-icon">
<view class="backspace-arrow"></view> <view class="backspace-arrow"></view>
<view class="backspace-line"></view> <view class="backspace-line"></view>
@@ -16,73 +22,118 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed, onBeforeUnmount } from 'vue'
import Icon from '@/components/Icon/Icon.vue' import Icon from '@/components/Icon/Icon.vue'
import {
applyAmountKey,
clampCursorIndex,
type AmountKey
} from '@/components/AmountEditor/amount-edit'
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
/** 金额字符串v-model */ /** 金额字符串v-model */
modelValue?: string modelValue?: string
/** 光标位置 */
cursorIndex?: number
/** 最大金额(元) */ /** 最大金额(元) */
max?: number max?: number
/** 是否固定在底部(弹窗中使用时设为 false */
fixed?: boolean
/** 是否显示fixed 模式下控制滑入/滑出) */
visible?: boolean
}>(), { }>(), {
modelValue: '', modelValue: '',
max: 9999999.99 max: 9999999.99,
fixed: true,
visible: true
}) })
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'update:modelValue', value: string): void (e: 'update:modelValue', value: string): void
(e: 'update:cursorIndex', index: number): void
(e: 'confirm'): void (e: 'confirm'): void
}>() }>()
const keys = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '.', '0', '00'] const keys: AmountKey[] = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '.', '0', '00']
const amountStr = computed(() => props.modelValue) const amountStr = computed(() => props.modelValue)
const effectiveCursorIndex = computed(() =>
clampCursorIndex(amountStr.value, props.cursorIndex ?? amountStr.value.length)
)
let deleteTimer: ReturnType<typeof setTimeout> | null = null
let deleteLongPressed = false
function vibrate() { function vibrate() {
try { uni.vibrateShort({ type: 'light' }) } catch {} try { uni.vibrateShort({ type: 'light' }) } catch {}
} }
function emitValue(val: string) { function applyKey(key: AmountKey | 'delete') {
emit('update:modelValue', val)
}
function onKeyTap(key: string) {
vibrate() vibrate()
const current = amountStr.value const result = applyAmountKey(
amountStr.value,
if (key === '.') { effectiveCursorIndex.value,
if (current.includes('.')) return key,
if (!current || current === '0') { emitValue('0.'); return } { max: props.max }
} )
if (result.accepted) {
let next: string emit('update:modelValue', result.value)
if (!current || current === '0') { emit('update:cursorIndex', result.cursorIndex)
next = (key === '0' || key === '00') ? '0' : key
} else {
next = current + key
}
if (next.includes('.')) {
const decimals = next.split('.')[1]
if (decimals && decimals.length > 2) return
}
if (next.length > 10) return
const num = parseFloat(next)
if (!isNaN(num) && num > props.max) return
emitValue(next)
}
function onDelete() {
vibrate()
const current = amountStr.value
if (current.length > 1) {
emitValue(current.slice(0, -1))
} else {
emitValue('')
} }
} }
function onKeyTap(key: AmountKey) {
applyKey(key)
}
function onDeleteTap() {
if (deleteLongPressed) {
deleteLongPressed = false
return
}
applyKey('delete')
}
function doDeleteRepeat() {
deleteLongPressed = true
const result = applyAmountKey(
amountStr.value,
effectiveCursorIndex.value,
'delete',
{ max: props.max }
)
if (result.accepted) {
vibrate()
emit('update:modelValue', result.value)
emit('update:cursorIndex', result.cursorIndex)
deleteTimer = setTimeout(doDeleteRepeat, 120)
} else {
deleteLongPressed = false
deleteTimer = null
}
}
function onDeleteTouchStart() {
if (deleteTimer !== null) clearTimeout(deleteTimer)
deleteLongPressed = false
deleteTimer = setTimeout(doDeleteRepeat, 400)
}
function onDeleteTouchEnd() {
if (deleteTimer !== null) {
clearTimeout(deleteTimer)
deleteTimer = null
}
}
onBeforeUnmount(() => {
if (deleteTimer !== null) {
clearTimeout(deleteTimer)
deleteTimer = null
}
})
function onConfirm() { function onConfirm() {
vibrate() vibrate()
emit('confirm') emit('confirm')
@@ -96,18 +147,55 @@ function onConfirm() {
display: grid; display: grid;
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(4, 1fr);
gap: $space-sm; gap: $space-sm;
padding: 0 $space-xl; padding: $space-sm $space-xl;
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom); // 非 fixed 模式的显示/隐藏
&:not(.numpad-fixed) {
transition: max-height 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.25s ease;
overflow: hidden;
max-height: 600rpx;
&.numpad-hidden {
max-height: 0;
opacity: 0;
padding-top: 0;
padding-bottom: 0;
}
}
// fixed 模式的滑入/滑出
&.numpad-fixed {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: $bg;
padding-bottom: env(safe-area-inset-bottom);
z-index: 100;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
&.numpad-hidden {
transform: translateY(100%);
}
}
} }
.key { .key {
height: 96rpx; height: 96rpx;
border-radius: $radius-xl; border-radius: $radius-xl;
background: $bg; background: $surface;
border: 2rpx solid $border;
box-shadow: $shadow-clay;
@include flex-center; @include flex-center;
transition: all $transition-fast;
-webkit-tap-highlight-color: transparent;
user-select: none;
&:active { background: $border; } &:active {
background: $surface-warm;
box-shadow: $shadow-clay-pressed;
transform: scale(0.96);
}
} }
.key-text { .key-text {
@@ -119,13 +207,25 @@ function onConfirm() {
.key.fn { .key.fn {
background: $primary-light; background: $primary-light;
&:active { background: #FFD0C0; } border-color: #FFD0C0;
&:active {
background: #FFD0C0;
box-shadow: $shadow-clay-pressed;
transform: scale(0.96);
}
} }
.key.eq { .key.eq {
background: linear-gradient(135deg, $primary, $primary-dark); background: linear-gradient(135deg, $primary, $primary-dark);
box-shadow: 0 4rpx 16rpx rgba(255, 140, 105, 0.3); border: none;
&:active { background: $primary-dark; } box-shadow: $shadow-md, 0 4rpx 12rpx rgba(255, 140, 105, 0.3);
&:active {
background: $primary-dark;
box-shadow: $shadow-clay-pressed;
transform: scale(0.96);
}
} }
// 自绘 backspace 图标 // 自绘 backspace 图标

View File

@@ -19,13 +19,16 @@
</view> </view>
</view> </view>
<view class="amount-display"> <AmountEditor
<text class="currency">¥</text> ref="amountEditorRef"
<text class="digits">{{ displayAmount }}</text> v-model="amountStr"
<text class="cursor">|</text> :fixed="true"
</view> size="large"
:auto-focus="true"
@confirm="save"
/>
<scroll-view class="category-scroll" scroll-y :scroll-into-view="scrollToCat"> <scroll-view class="category-scroll" scroll-y :scroll-into-view="scrollToCat" @tap="blurAmountEditor">
<view class="category-grid"> <view class="category-grid">
<view v-for="cat in currentCategories" :key="cat.id" :id="'cat-' + cat.id" class="cat-item" :class="{ suggested: suggestedCatId === cat.id && selectedCat !== cat.id }" @tap="selectedCat = cat.id"> <view v-for="cat in currentCategories" :key="cat.id" :id="'cat-' + cat.id" class="cat-item" :class="{ suggested: suggestedCatId === cat.id && selectedCat !== cat.id }" @tap="selectedCat = cat.id">
<view class="cat-icon-wrap"> <view class="cat-icon-wrap">
@@ -41,18 +44,18 @@
<view class="extra-fields"> <view class="extra-fields">
<picker mode="date" :value="selectedDate" :end="todayStr" @change="onDateChange"> <picker mode="date" :value="selectedDate" :end="todayStr" @change="onDateChange">
<view class="extra-row"> <view class="extra-row" @tap="blurAmountEditor">
<Icon name="calendar" :size="28" color="#8B7E7E" /> <Icon name="calendar" :size="28" color="#8B7E7E" />
<text class="extra-text">{{ dateLabel }}</text> <text class="extra-text">{{ dateLabel }}</text>
</view> </view>
</picker> </picker>
<view class="extra-row"> <view class="extra-row">
<Icon name="edit" :size="28" color="#8B7E7E" /> <Icon name="edit" :size="28" color="#8B7E7E" />
<input class="extra-input" v-model="note" placeholder="添加备注..." placeholder-class="placeholder" maxlength="200" /> <input class="extra-input" v-model="note" placeholder="添加备注..." placeholder-class="placeholder" maxlength="200" @focus="blurAmountEditor" />
</view> </view>
</view> </view>
<Numpad v-model="amountStr" @confirm="save" />
<SaveSuccess <SaveSuccess
v-model:visible="showSuccess" v-model:visible="showSuccess"
@@ -71,7 +74,7 @@ import { useCategoryStore } from '@/stores/category'
import { useTransactionStore } from '@/stores/transaction' import { useTransactionStore } from '@/stores/transaction'
import { waitForReady } from '@/utils/app-ready' import { waitForReady } from '@/utils/app-ready'
import { suggestCategory } from '@/api/stats' import { suggestCategory } from '@/api/stats'
import Numpad from '@/components/Numpad/Numpad.vue' import AmountEditor from '@/components/AmountEditor/AmountEditor.vue'
import CategoryIcon from '@/components/CategoryIcon/CategoryIcon.vue' import CategoryIcon from '@/components/CategoryIcon/CategoryIcon.vue'
import Icon from '@/components/Icon/Icon.vue' import Icon from '@/components/Icon/Icon.vue'
import SaveSuccess from '@/components/SaveSuccess/SaveSuccess.vue' import SaveSuccess from '@/components/SaveSuccess/SaveSuccess.vue'
@@ -95,6 +98,7 @@ const editId = ref<number | null>(null)
const showSuccess = ref(false) const showSuccess = ref(false)
const scrollToCat = ref('') const scrollToCat = ref('')
const todayStr = localToday const todayStr = localToday
const amountEditorRef = ref<InstanceType<typeof AmountEditor> | null>(null)
// Remember last selected category per type // Remember last selected category per type
const lastCatByType: Record<string, number | null> = { expense: null, income: null } const lastCatByType: Record<string, number | null> = { expense: null, income: null }
@@ -103,10 +107,6 @@ const dateLabel = computed(() => {
return `${d.getMonth() + 1}${d.getDate()}` return `${d.getMonth() + 1}${d.getDate()}`
}) })
const displayAmount = computed(() => {
if (!amountStr.value) return ''
return amountStr.value
})
const currentCategories = computed(() => catStore.getByType(type.value)) const currentCategories = computed(() => catStore.getByType(type.value))
@@ -217,6 +217,10 @@ function onDateChange(e: any) {
selectedDate.value = e.detail.value selectedDate.value = e.detail.value
} }
function blurAmountEditor() {
amountEditorRef.value?.blur()
}
async function save() { async function save() {
if (saving.value) return if (saving.value) return
const amount = Math.round(parseFloat(amountStr.value || '0') * 100) const amount = Math.round(parseFloat(amountStr.value || '0') * 100)
@@ -261,6 +265,7 @@ function onContinue() {
amountStr.value = '' amountStr.value = ''
note.value = '' note.value = ''
showSuccess.value = false showSuccess.value = false
nextTick(() => amountEditorRef.value?.focus())
} }
/** 返回上一页 */ /** 返回上一页 */
@@ -290,10 +295,14 @@ function goBack() {
@import '@/styles/mixins.scss'; @import '@/styles/mixins.scss';
.page { .page {
@include page-base; min-height: 100vh;
background: $bg;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding-bottom: 0; // AmountEditor fixed Numpad 占位: 4行×96rpx + 3间隙×16rpx + padding×2×16rpx = 464rpx
$numpad-h: 464rpx;
padding-bottom: calc(#{$numpad-h} + constant(safe-area-inset-bottom));
padding-bottom: calc(#{$numpad-h} + env(safe-area-inset-bottom));
} }
.header-fixed { .header-fixed {
@@ -358,38 +367,6 @@ function goBack() {
.income .slider { transform: translateX(160rpx); } .income .slider { transform: translateX(160rpx); }
.amount-display {
text-align: center;
padding: $space-lg $space-xl $space-md;
}
.currency {
font-family: 'Fredoka', sans-serif;
font-size: $space-2xl;
font-weight: 500;
color: $text-muted;
margin-right: $space-xs;
}
.digits {
font-family: 'Fredoka', sans-serif;
font-size: 88rpx;
font-weight: 700;
color: $text;
font-variant-numeric: tabular-nums;
}
.cursor {
font-family: 'Fredoka', monospace;
font-size: 88rpx;
font-weight: 300;
color: $primary;
animation: blink 1s step-end infinite;
}
@keyframes blink {
50% { opacity: 0; }
}
.category-scroll { .category-scroll {
max-height: 320rpx; max-height: 320rpx;
@@ -472,10 +449,6 @@ function goBack() {
.placeholder { color: $text-muted; } .placeholder { color: $text-muted; }
@media (prefers-reduced-motion: reduce) {
.cursor {
animation: none;
opacity: 1;
}
}
</style> </style>

View File

@@ -52,13 +52,13 @@
<!-- 操作按钮 --> <!-- 操作按钮 -->
<view class="actions"> <view class="actions">
<view v-if="item.status === 'pending'" class="action-btn process" @tap="openReplyModal(item)"> <view v-if="item.status === 'pending'" class="action-btn process" @tap.stop="openReplyModal(item)">
<text class="action-text">标记处理</text> <text class="action-text">标记处理</text>
</view> </view>
<view v-if="item.status === 'pending'" class="action-btn ignore" @tap="openReplyModal(item, 'ignored')"> <view v-if="item.status === 'pending'" class="action-btn ignore" @tap.stop="openReplyModal(item, 'ignored')">
<text class="action-text">忽略</text> <text class="action-text">忽略</text>
</view> </view>
<view v-if="item.status !== 'pending'" class="action-btn pending" @tap="updateStatus(item, 'pending')"> <view v-if="item.status !== 'pending'" class="action-btn pending" @tap.stop="updateStatus(item, 'pending')">
<text class="action-text">重新打开</text> <text class="action-text">重新打开</text>
</view> </view>
</view> </view>
@@ -217,7 +217,7 @@ async function handleWithReply() {
await doUpdateStatus(replyTarget.value, replyAction.value, replyText.value.trim() || undefined) await doUpdateStatus(replyTarget.value, replyAction.value, replyText.value.trim() || undefined)
} }
async function doUpdateStatus(item: Feedback, status: string, admin_reply?: string) { async function doUpdateStatus(item: Feedback, status: Feedback['status'], admin_reply?: string) {
try { try {
await updateFeedbackStatus(item.id, status, admin_reply) await updateFeedbackStatus(item.id, status, admin_reply)
item.status = status item.status = status
@@ -228,7 +228,7 @@ async function doUpdateStatus(item: Feedback, status: string, admin_reply?: stri
} }
} }
async function updateStatus(item: Feedback, status: string) { async function updateStatus(item: Feedback, status: Feedback['status']) {
try { try {
await updateFeedbackStatus(item.id, status) await updateFeedbackStatus(item.id, status)
item.status = status item.status = status

View File

@@ -110,21 +110,11 @@ import type { Dashboard } from '@/api/admin'
const loading = ref(true) const loading = ref(true)
const dashboard = ref<Dashboard | null>(null) const dashboard = ref<Dashboard | null>(null)
// 仪表盘缓存5分钟内不重复请求
let dashboardCache: { data: Dashboard; time: number } | null = null
const CACHE_TTL = 5 * 60 * 1000
onMounted(async () => { onMounted(async () => {
await waitForReady() await waitForReady()
try { try {
// 检查缓存是否有效 dashboard.value = await getDashboard()
if (dashboardCache && Date.now() - dashboardCache.time < CACHE_TTL) {
dashboard.value = dashboardCache.data
} else {
dashboard.value = await getDashboard()
dashboardCache = { data: dashboard.value, time: Date.now() }
}
} catch (e: any) { } catch (e: any) {
uni.showToast({ title: e.message || '加载失败', icon: 'none' }) uni.showToast({ title: e.message || '加载失败', icon: 'none' })
} finally { } finally {
@@ -132,13 +122,10 @@ onMounted(async () => {
} }
}) })
// 静默刷新(强制刷新缓存) // 静默刷新
async function refreshDashboard(force = false) { async function refreshDashboard() {
try { try {
if (force || !dashboardCache || Date.now() - dashboardCache.time >= CACHE_TTL) { dashboard.value = await getDashboard()
dashboard.value = await getDashboard()
dashboardCache = { data: dashboard.value, time: Date.now() }
}
} catch {} } catch {}
} }
@@ -148,7 +135,7 @@ onShow(() => {
}) })
onPullDownRefresh(async () => { onPullDownRefresh(async () => {
await refreshDashboard(true) await refreshDashboard()
uni.stopPullDownRefresh() uni.stopPullDownRefresh()
}) })

View File

@@ -60,15 +60,15 @@
<!-- 自定义金额 --> <!-- 自定义金额 -->
<view class="section-title">自定义金额</view> <view class="section-title">自定义金额</view>
<view class="custom-input"> <AmountEditor
<text class="input-prefix">¥</text> ref="amountEditorRef"
<text class="input-value" :class="{ placeholder: !amountStr }">{{ amountStr }}</text> v-model="amountStr"
<text class="input-cursor">|</text> :fixed="true"
<text class="input-unit"></text> size="large"
</view> unit="元"
:auto-focus="true"
<!-- Numpad --> @confirm="onConfirm"
<Numpad v-model="amountStr" @confirm="onConfirm" /> />
</view> </view>
</view> </view>
</template> </template>
@@ -84,7 +84,7 @@ import { formatAmount, getCurrentMonth } from '@/utils/format'
import type { Budget } from '@/api/budget' import type { Budget } from '@/api/budget'
import { statusBarHeight } from '@/utils/system' import { statusBarHeight } from '@/utils/system'
import BudgetBar from '@/components/BudgetBar/BudgetBar.vue' import BudgetBar from '@/components/BudgetBar/BudgetBar.vue'
import Numpad from '@/components/Numpad/Numpad.vue' import AmountEditor from '@/components/AmountEditor/AmountEditor.vue'
import Icon from '@/components/Icon/Icon.vue' import Icon from '@/components/Icon/Icon.vue'
const budgetStore = useBudgetStore() const budgetStore = useBudgetStore()
@@ -97,6 +97,7 @@ const monthExpense = ref(0)
const loading = ref(true) const loading = ref(true)
const initialLoaded = ref(false) const initialLoaded = ref(false)
const amountStr = ref('') const amountStr = ref('')
const amountEditorRef = ref<InstanceType<typeof AmountEditor> | null>(null)
const presets = [1000, 2000, 3000, 5000, 10000] const presets = [1000, 2000, 3000, 5000, 10000]
@@ -162,6 +163,7 @@ onPullDownRefresh(async () => {
function selectPreset(val: number) { function selectPreset(val: number) {
amountStr.value = String(val) amountStr.value = String(val)
amountEditorRef.value?.focus()
} }
async function onConfirm() { async function onConfirm() {
@@ -367,60 +369,11 @@ function goBack() {
color: $text; color: $text;
} }
.custom-input {
display: flex;
align-items: baseline;
gap: $space-xs;
margin-bottom: $space-lg;
padding: $space-md $space-lg;
background: $surface;
border-radius: $radius-xl;
border: 2rpx solid $border;
}
.input-prefix {
font-family: 'Fredoka', sans-serif;
font-size: $font-3xl;
font-weight: 600;
color: $primary;
}
.input-value {
font-family: 'Fredoka', sans-serif;
font-size: $font-4xl;
font-weight: 600;
color: $text;
flex: 1;
&.placeholder {
color: #D0C4C4;
}
}
.input-unit {
font-size: $font-lg;
color: $text-sec;
}
.input-cursor {
font-family: 'Fredoka', monospace;
font-size: $font-4xl;
font-weight: 300;
color: $primary;
animation: blink 1s step-end infinite;
}
@keyframes blink {
50% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.preview-skeleton { .preview-skeleton {
animation: none; animation: none;
} }
.input-cursor {
animation: none;
opacity: 1;
}
} }
</style> </style>

View File

@@ -204,18 +204,9 @@ function openLink(url: string) {
// #endif // #endif
} }
// 数据新鲜度控制30秒内不重复请求 async function loadData(silent = false) {
let lastLoadTime = 0
const CACHE_TTL = 30000
async function loadData(silent = false, force = false) {
await waitForReady() await waitForReady()
// 数据新鲜度判断30秒内不重复请求
if (!force && silent && Date.now() - lastLoadTime < CACHE_TTL) {
return
}
try { try {
if (!silent) loading.value = true if (!silent) loading.value = true
loadError.value = false loadError.value = false
@@ -226,7 +217,6 @@ async function loadData(silent = false, force = false) {
txStore.fetchTransactions({ page: 1 }) txStore.fetchTransactions({ page: 1 })
]) ])
recentTx.value = txStore.transactions.slice(0, 5) recentTx.value = txStore.transactions.slice(0, 5)
lastLoadTime = Date.now()
// 第二级:次要数据(异步,不阻塞首屏) // 第二级:次要数据(异步,不阻塞首屏)
Promise.all([ Promise.all([
@@ -271,11 +261,11 @@ onMounted(() => {
// Refresh data when returning from other pages (e.g., after adding a transaction) // Refresh data when returning from other pages (e.g., after adding a transaction)
onShow(() => { onShow(() => {
if (initialLoaded.value && !loading.value) loadData(true, false) if (initialLoaded.value && !loading.value) loadData(true)
}) })
onPullDownRefresh(() => { onPullDownRefresh(() => {
loadData(false, true).finally(() => uni.stopPullDownRefresh()) loadData().finally(() => uni.stopPullDownRefresh())
}) })
function goAdd(type: string) { function goAdd(type: string) {

View File

@@ -65,11 +65,11 @@
</scroll-view> </scroll-view>
<!-- 添加/编辑弹窗 --> <!-- 添加/编辑弹窗 -->
<view class="modal-mask" v-if="showModal" @tap="showModal = false"> <view class="modal-mask" v-if="showModal" @tap="closeModal">
<view class="form-modal" @tap.stop> <view class="form-modal" @tap.stop>
<view class="modal-header"> <view class="modal-header">
<text class="modal-title">{{ editingId ? '编辑周期账单' : '添加周期账单' }}</text> <text class="modal-title">{{ editingId ? '编辑周期账单' : '添加周期账单' }}</text>
<view class="modal-close" @tap="showModal = false"> <view class="modal-close" @tap="closeModal">
<text class="modal-close-text">×</text> <text class="modal-close-text">×</text>
</view> </view>
</view> </view>
@@ -84,13 +84,17 @@
</view> </view>
<!-- 金额 --> <!-- 金额 -->
<view class="amount-row"> <AmountEditor
<text class="currency">¥</text> ref="amountEditorRef"
<text class="digits" :class="{ placeholder: !amountStr }">{{ amountStr || '0' }}</text> v-model="amountStr"
</view> :fixed="false"
size="medium"
:auto-focus="showModal"
@confirm="handleSubmit"
/>
<!-- 分类 --> <!-- 分类 -->
<scroll-view class="category-scroll" scroll-x> <scroll-view class="category-scroll" scroll-x @tap="blurAmountEditor">
<view class="category-row"> <view class="category-row">
<view v-for="cat in currentCategories" :key="cat.id" class="cat-item" @tap="form.category_id = cat.id"> <view v-for="cat in currentCategories" :key="cat.id" class="cat-item" @tap="form.category_id = cat.id">
<CategoryIcon :label="cat.name[0]" :color="cat.color" :bg-color="cat.color + '15'" size="sm" :selected="form.category_id === cat.id" /> <CategoryIcon :label="cat.name[0]" :color="cat.color" :bg-color="cat.color + '15'" size="sm" :selected="form.category_id === cat.id" />
@@ -102,14 +106,14 @@
<!-- 备注 --> <!-- 备注 -->
<view class="form-item"> <view class="form-item">
<Icon name="edit" :size="28" color="#8B7E7E" /> <Icon name="edit" :size="28" color="#8B7E7E" />
<input class="form-input" v-model="form.note" placeholder="备注(可选)" maxlength="50" /> <input class="form-input" v-model="form.note" placeholder="备注(可选)" maxlength="50" @focus="blurAmountEditor" />
</view> </view>
<!-- 周期 --> <!-- 周期 -->
<view class="form-item"> <view class="form-item">
<Icon name="calendar" :size="28" color="#8B7E7E" /> <Icon name="calendar" :size="28" color="#8B7E7E" />
<view class="freq-options"> <view class="freq-options">
<view v-for="f in frequencies" :key="f.value" class="freq-tab" :class="{ active: form.frequency === f.value }" @tap="form.frequency = f.value"> <view v-for="f in frequencies" :key="f.value" class="freq-tab" :class="{ active: form.frequency === f.value }" @tap="selectFrequency(f.value)">
<text class="freq-text">{{ f.label }}</text> <text class="freq-text">{{ f.label }}</text>
</view> </view>
</view> </view>
@@ -134,20 +138,22 @@
</view> </view>
</picker> </picker>
</view> </view>
</scroll-view> </scroll-view>
<view class="modal-footer" @tap="handleSubmit"> <view class="modal-footer" @tap="handleSubmit">
<text class="submit-text">{{ editingId ? '保存修改' : '添加' }}</text> <text class="submit-text">{{ editingId ? '保存修改' : '添加' }}</text>
</view> </view>
</view> </view>
</view> </view>
<!-- Numpad -->
<Numpad v-model="amountStr" />
</view> </view>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted, nextTick } from 'vue'
import { waitForReady } from '@/utils/app-ready' import { waitForReady } from '@/utils/app-ready'
import { statusBarHeight, capsuleRight } from '@/utils/system' import { statusBarHeight, capsuleRight } from '@/utils/system'
import { formatAmount } from '@/utils/format' import { formatAmount } from '@/utils/format'
@@ -155,9 +161,8 @@ import { useCategoryStore } from '@/stores/category'
import { getRecurringTemplates, createRecurringTemplate, updateRecurringTemplate, deleteRecurringTemplate, syncRecurring } from '@/api/recurring' import { getRecurringTemplates, createRecurringTemplate, updateRecurringTemplate, deleteRecurringTemplate, syncRecurring } from '@/api/recurring'
import Icon from '@/components/Icon/Icon.vue' import Icon from '@/components/Icon/Icon.vue'
import CategoryIcon from '@/components/CategoryIcon/CategoryIcon.vue' import CategoryIcon from '@/components/CategoryIcon/CategoryIcon.vue'
import Numpad from '@/components/Numpad/Numpad.vue' import AmountEditor from '@/components/AmountEditor/AmountEditor.vue'
import type { RecurringTemplate } from '@/api/recurring' import type { RecurringTemplate } from '@/api/recurring'
import type { Category } from '@/stores/category'
const catStore = useCategoryStore() const catStore = useCategoryStore()
@@ -166,7 +171,9 @@ const loading = ref(true)
const showModal = ref(false) const showModal = ref(false)
const editingId = ref<number | null>(null) const editingId = ref<number | null>(null)
const amountStr = ref('') const amountStr = ref('')
const saving = ref(false)
const syncCount = ref(0) const syncCount = ref(0)
const amountEditorRef = ref<InstanceType<typeof AmountEditor> | null>(null)
const freqMap: Record<string, string> = { const freqMap: Record<string, string> = {
weekly: '每周', monthly: '每月', yearly: '每年' weekly: '每周', monthly: '每月', yearly: '每年'
@@ -226,6 +233,7 @@ function openAddModal() {
if (cats.length > 0) form.value.category_id = cats[0].id if (cats.length > 0) form.value.category_id = cats[0].id
amountStr.value = '' amountStr.value = ''
showModal.value = true showModal.value = true
nextTick(() => amountEditorRef.value?.focus())
} }
function openEditModal(item: RecurringTemplate) { function openEditModal(item: RecurringTemplate) {
@@ -240,6 +248,7 @@ function openEditModal(item: RecurringTemplate) {
} }
amountStr.value = (item.amount / 100).toString() amountStr.value = (item.amount / 100).toString()
showModal.value = true showModal.value = true
nextTick(() => amountEditorRef.value?.focus())
} }
async function toggleActive(item: RecurringTemplate) { async function toggleActive(item: RecurringTemplate) {
@@ -253,6 +262,7 @@ async function toggleActive(item: RecurringTemplate) {
} }
async function handleSubmit() { async function handleSubmit() {
if (saving.value) return
const amount = Math.round(parseFloat(amountStr.value || '0') * 100) const amount = Math.round(parseFloat(amountStr.value || '0') * 100)
if (isNaN(amount) || amount <= 0) { if (isNaN(amount) || amount <= 0) {
uni.showToast({ title: '请输入金额', icon: 'none' }) uni.showToast({ title: '请输入金额', icon: 'none' })
@@ -263,6 +273,7 @@ async function handleSubmit() {
return return
} }
saving.value = true
try { try {
const data = { const data = {
amount, amount,
@@ -278,11 +289,13 @@ async function handleSubmit() {
} else { } else {
await createRecurringTemplate(data) await createRecurringTemplate(data)
} }
showModal.value = false closeModal()
await loadList() await loadList()
uni.showToast({ title: editingId.value ? '已更新' : '已添加', icon: 'success' }) uni.showToast({ title: editingId.value ? '已更新' : '已添加', icon: 'success' })
} catch { } catch {
uni.showToast({ title: '保存失败', icon: 'none' }) uni.showToast({ title: '保存失败', icon: 'none' })
} finally {
saving.value = false
} }
} }
@@ -304,6 +317,20 @@ async function handleDelete(item: RecurringTemplate) {
}) })
} }
function closeModal() {
amountEditorRef.value?.blur()
showModal.value = false
}
function blurAmountEditor() {
amountEditorRef.value?.blur()
}
function selectFrequency(value: 'weekly' | 'monthly' | 'yearly') {
blurAmountEditor()
form.value.frequency = value
}
function goBack() { uni.navigateBack() } function goBack() { uni.navigateBack() }
</script> </script>
@@ -425,7 +452,7 @@ function goBack() { uni.navigateBack() }
&:active { background: darken($border, 5%); } &:active { background: darken($border, 5%); }
} }
.modal-close-text { font-size: $font-2xl; color: $text-sec; line-height: 1; } .modal-close-text { font-size: $font-2xl; color: $text-sec; line-height: 1; }
.modal-body { flex: 1; padding: $space-md $space-xl; max-height: 60vh; } .modal-body { flex: 1; padding: $space-md $space-xl; max-height: 45vh; }
.modal-footer { .modal-footer {
padding: $space-md $space-xl; border-top: 2rpx solid $border; padding: $space-md $space-xl; border-top: 2rpx solid $border;
} }
@@ -455,18 +482,7 @@ function goBack() { uni.navigateBack() }
} }
.income .slider { transform: translateX(140rpx); } .income .slider { transform: translateX(140rpx); }
/* 金额 */
.amount-row {
text-align: center; padding: $space-sm 0 $space-md;
}
.currency {
font-family: 'Fredoka', sans-serif; font-size: $space-2xl; font-weight: 500;
color: $text-muted; margin-right: $space-xs;
}
.digits {
font-family: 'Fredoka', sans-serif; font-size: 72rpx; font-weight: 600; color: $text;
&.placeholder { color: #D0C4C4; }
}
/* 分类 */ /* 分类 */
.category-scroll { margin-bottom: $space-md; } .category-scroll { margin-bottom: $space-md; }
@@ -497,4 +513,5 @@ function goBack() { uni.navigateBack() }
&.active { background: $primary; color: $surface; border-color: $primary; } &.active { background: $primary; color: $surface; border-color: $primary; }
&:active { opacity: 0.8; } &:active { opacity: 0.8; }
} }
</style> </style>