Compare commits
11 Commits
6908ffed0e
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 324a3ec5c9 | |||
| 9f5802d634 | |||
| f586f9e117 | |||
| 6b26951b1b | |||
| 768c72d7b7 | |||
| ac20a70a20 | |||
| 4e4b7dac69 | |||
| df6f1a54d6 | |||
| 7bf899f0b1 | |||
| a277f1533b | |||
| f745f4b4f0 |
1
DEV.md
1
DEV.md
@@ -119,6 +119,7 @@ onPullDownRefresh(async () => {
|
|||||||
- Never 使用 git stash
|
- Never 使用 git stash
|
||||||
- Never 切换分支
|
- Never 切换分支
|
||||||
- Never 过度封装,保持代码简洁明了
|
- Never 过度封装,保持代码简洁明了
|
||||||
|
- Never 未确定需求就开始实现
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
|
|||||||
@@ -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 } })
|
||||||
}
|
}
|
||||||
|
|||||||
272
client/src/components/AmountEditor/AmountEditor.vue
Normal file
272
client/src/components/AmountEditor/AmountEditor.vue
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
<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 {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8rpx;
|
||||||
|
padding: $space-lg $space-xl $space-md;
|
||||||
|
border-radius: $radius-xl;
|
||||||
|
border: 2rpx solid transparent;
|
||||||
|
background: transparent;
|
||||||
|
transition: background $transition-normal, border-color $transition-normal, box-shadow $transition-normal;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount-editor--focused .amount-display {
|
||||||
|
background: $surface;
|
||||||
|
border-color: $border;
|
||||||
|
box-shadow: $shadow-clay;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ¥ 符号
|
||||||
|
.currency {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-family: 'Fredoka', sans-serif;
|
||||||
|
font-weight: 500;
|
||||||
|
color: $text-muted;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 单位(元)
|
||||||
|
.unit {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text-sec;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 金额字符容器
|
||||||
|
.amount-value {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-family: 'Fredoka', sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $text;
|
||||||
|
line-height: 1.2;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount-char,
|
||||||
|
.placeholder {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount-char {
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder {
|
||||||
|
color: #D0C4C4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 光标
|
||||||
|
.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 {
|
||||||
|
padding: $space-md $space-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount-value {
|
||||||
|
font-size: 52rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.currency {
|
||||||
|
font-size: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unit {
|
||||||
|
font-size: $font-sm;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount-editor--medium {
|
||||||
|
.amount-display {
|
||||||
|
padding: $space-md $space-xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount-value {
|
||||||
|
font-size: 68rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.currency {
|
||||||
|
font-size: 34rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unit {
|
||||||
|
font-size: $font-md;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount-editor--large {
|
||||||
|
.amount-value {
|
||||||
|
font-size: 88rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.currency {
|
||||||
|
font-size: 40rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unit {
|
||||||
|
font-size: $font-lg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cursor-blink {
|
||||||
|
0%, 45% { opacity: 1; }
|
||||||
|
46%, 100% { opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.amount-display {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cursor {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
118
client/src/components/AmountEditor/amount-edit.ts
Normal file
118
client/src/components/AmountEditor/amount-edit.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
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)
|
||||||
|
let next = value.slice(0, safeCursor) + normalizedInsert + value.slice(safeCursor)
|
||||||
|
|
||||||
|
if (!isValidAmountInput(next, options)) {
|
||||||
|
// 小数位超出上限时,从右侧挤出一个字符(小数部分左移)
|
||||||
|
const dotIdx = next.indexOf('.')
|
||||||
|
if (dotIdx >= 0 && next.length - dotIdx - 1 > MAX_DECIMAL_PLACES) {
|
||||||
|
next = next.slice(0, -1)
|
||||||
|
const newCursor = Math.min(safeCursor + normalizedInsert.length, next.length)
|
||||||
|
if (!isValidAmountInput(next, options)) {
|
||||||
|
return { value, cursorIndex: safeCursor, accepted: false }
|
||||||
|
}
|
||||||
|
return { value: next, cursorIndex: newCursor, accepted: true }
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
emit('update:modelValue', result.value)
|
||||||
|
emit('update:cursorIndex', result.cursorIndex)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let next: string
|
function onKeyTap(key: AmountKey) {
|
||||||
if (!current || current === '0') {
|
applyKey(key)
|
||||||
next = (key === '0' || key === '00') ? '0' : key
|
|
||||||
} else {
|
|
||||||
next = current + key
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (next.includes('.')) {
|
function onDeleteTap() {
|
||||||
const decimals = next.split('.')[1]
|
if (deleteLongPressed) {
|
||||||
if (decimals && decimals.length > 2) return
|
deleteLongPressed = false
|
||||||
|
return
|
||||||
}
|
}
|
||||||
if (next.length > 10) return
|
applyKey('delete')
|
||||||
const num = parseFloat(next)
|
|
||||||
if (!isNaN(num) && num > props.max) return
|
|
||||||
|
|
||||||
emitValue(next)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onDelete() {
|
function doDeleteRepeat() {
|
||||||
|
deleteLongPressed = true
|
||||||
|
const result = applyAmountKey(
|
||||||
|
amountStr.value,
|
||||||
|
effectiveCursorIndex.value,
|
||||||
|
'delete',
|
||||||
|
{ max: props.max }
|
||||||
|
)
|
||||||
|
if (result.accepted) {
|
||||||
vibrate()
|
vibrate()
|
||||||
const current = amountStr.value
|
emit('update:modelValue', result.value)
|
||||||
if (current.length > 1) {
|
emit('update:cursorIndex', result.cursorIndex)
|
||||||
emitValue(current.slice(0, -1))
|
deleteTimer = setTimeout(doDeleteRepeat, 120)
|
||||||
} else {
|
} else {
|
||||||
emitValue('')
|
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);
|
|
||||||
|
// 非 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);
|
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 图标
|
||||||
|
|||||||
@@ -19,13 +19,18 @@
|
|||||||
</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"
|
||||||
|
@focus="keyboardVisible = true"
|
||||||
|
@blur="keyboardVisible = false"
|
||||||
|
@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 +46,23 @@
|
|||||||
|
|
||||||
<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" />
|
<!-- 底部保存按钮(键盘收起时显示) -->
|
||||||
|
<view class="save-bar" v-if="!showSuccess && !keyboardVisible">
|
||||||
|
<view class="save-btn" @tap="save">
|
||||||
|
<text class="save-btn-text">{{ editId ? '保存修改' : '保存' }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<SaveSuccess
|
<SaveSuccess
|
||||||
v-model:visible="showSuccess"
|
v-model:visible="showSuccess"
|
||||||
@@ -71,7 +81,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 +105,9 @@ 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)
|
||||||
|
const keyboardVisible = ref(false)
|
||||||
|
|
||||||
// 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 +116,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))
|
||||||
|
|
||||||
@@ -195,6 +204,7 @@ onMounted(async () => {
|
|||||||
// 等待分类列表渲染完成后滚动到选中项
|
// 等待分类列表渲染完成后滚动到选中项
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
setTimeout(() => { scrollToCat.value = 'cat-' + existing.category_id }, 200)
|
setTimeout(() => { scrollToCat.value = 'cat-' + existing.category_id }, 200)
|
||||||
|
amountEditorRef.value?.focus()
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
uni.showToast({ title: '记录不存在', icon: 'none' })
|
uni.showToast({ title: '记录不存在', icon: 'none' })
|
||||||
@@ -217,6 +227,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 +275,7 @@ function onContinue() {
|
|||||||
amountStr.value = ''
|
amountStr.value = ''
|
||||||
note.value = ''
|
note.value = ''
|
||||||
showSuccess.value = false
|
showSuccess.value = false
|
||||||
|
nextTick(() => amountEditorRef.value?.focus())
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 返回上一页 */
|
/** 返回上一页 */
|
||||||
@@ -290,10 +305,51 @@ 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;
|
||||||
|
$save-bar-h: 144rpx;
|
||||||
|
padding-bottom: calc(#{$numpad-h} + #{$save-bar-h} + constant(safe-area-inset-bottom));
|
||||||
|
padding-bottom: calc(#{$numpad-h} + #{$save-bar-h} + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 底部保存按钮栏
|
||||||
|
.save-bar {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
padding: $space-md $space-xl;
|
||||||
|
padding-bottom: calc(#{$space-md} + constant(safe-area-inset-bottom));
|
||||||
|
padding-bottom: calc(#{$space-md} + env(safe-area-inset-bottom));
|
||||||
|
background: $surface;
|
||||||
|
border-top: 2rpx solid $border;
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-btn {
|
||||||
|
height: 96rpx;
|
||||||
|
background: linear-gradient(135deg, $primary, #E67355);
|
||||||
|
border-radius: $radius-xl;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 4rpx 12rpx rgba(255, 140, 105, 0.3);
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
opacity: 0.8;
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-btn-text {
|
||||||
|
font-size: $font-xl;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $surface;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-fixed {
|
.header-fixed {
|
||||||
@@ -358,38 +414,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 +496,6 @@ function goBack() {
|
|||||||
|
|
||||||
.placeholder { color: $text-muted; }
|
.placeholder { color: $text-muted; }
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.cursor {
|
|
||||||
animation: none;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -607,4 +607,9 @@ function goBack() { uni.navigateBack() }
|
|||||||
.skip & { color: $text-sec; }
|
.skip & { color: $text-sec; }
|
||||||
.confirm & { color: $surface; }
|
.confirm & { color: $surface; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modal-mask {
|
||||||
|
@include modal-mask;
|
||||||
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -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 {
|
||||||
// 检查缓存是否有效
|
|
||||||
if (dashboardCache && Date.now() - dashboardCache.time < CACHE_TTL) {
|
|
||||||
dashboard.value = dashboardCache.data
|
|
||||||
} else {
|
|
||||||
dashboard.value = await getDashboard()
|
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()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -61,13 +61,19 @@
|
|||||||
<view class="query-section">
|
<view class="query-section">
|
||||||
<view class="query-header">
|
<view class="query-header">
|
||||||
<text class="section-title">事件记录</text>
|
<text class="section-title">事件记录</text>
|
||||||
<scroll-view class="filter-tabs" scroll-x>
|
<scroll-view scroll-x>
|
||||||
|
<view class="filter-tabs">
|
||||||
<view class="filter-tab" :class="{ active: !filterEvent }" @tap="filterEvent = ''">全部</view>
|
<view class="filter-tab" :class="{ active: !filterEvent }" @tap="filterEvent = ''">全部</view>
|
||||||
<view class="filter-tab" :class="{ active: filterEvent === 'page_view' }" @tap="filterEvent = 'page_view'">页面</view>
|
<view class="filter-tab" :class="{ active: filterEvent === 'page_view' }" @tap="filterEvent = 'page_view'">
|
||||||
<view class="filter-tab" :class="{ active: filterEvent === 'action' }" @tap="filterEvent = 'action'">操作</view>
|
页面</view>
|
||||||
|
<view class="filter-tab" :class="{ active: filterEvent === 'action' }" @tap="filterEvent = 'action'">操作
|
||||||
|
</view>
|
||||||
<view class="filter-tab" :class="{ active: filterEvent === 'error' }" @tap="filterEvent = 'error'">错误</view>
|
<view class="filter-tab" :class="{ active: filterEvent === 'error' }" @tap="filterEvent = 'error'">错误</view>
|
||||||
<view class="filter-tab" :class="{ active: filterEvent === 'api_error' }" @tap="filterEvent = 'api_error'">API错误</view>
|
<view class="filter-tab" :class="{ active: filterEvent === 'api_error' }" @tap="filterEvent = 'api_error'">
|
||||||
<view class="filter-tab" :class="{ active: filterEvent === 'app_launch' }" @tap="filterEvent = 'app_launch'">启动</view>
|
API错误</view>
|
||||||
|
<view class="filter-tab" :class="{ active: filterEvent === 'app_launch' }"
|
||||||
|
@tap="filterEvent = 'app_launch'">启动</view>
|
||||||
|
</view>
|
||||||
</scroll-view>
|
</scroll-view>
|
||||||
</view>
|
</view>
|
||||||
<view class="filter-row">
|
<view class="filter-row">
|
||||||
@@ -95,7 +101,8 @@
|
|||||||
</view>
|
</view>
|
||||||
<text class="record-page" v-if="item.page">{{ item.page }}</text>
|
<text class="record-page" v-if="item.page">{{ item.page }}</text>
|
||||||
<text class="record-user" v-if="item.nickname">{{ item.nickname }}</text>
|
<text class="record-user" v-if="item.nickname">{{ item.nickname }}</text>
|
||||||
<text class="record-data" v-if="item.data && Object.keys(item.data).length > 0">{{ JSON.stringify(item.data) }}</text>
|
<text class="record-data" v-if="item.data && Object.keys(item.data).length > 0">{{ JSON.stringify(item.data)
|
||||||
|
}}</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view v-if="loading" class="loading-box">
|
<view v-if="loading" class="loading-box">
|
||||||
@@ -274,7 +281,9 @@ function goBack() {
|
|||||||
@include sticky-header;
|
@include sticky-header;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-bar { @include status-bar; }
|
.status-bar {
|
||||||
|
@include status-bar;
|
||||||
|
}
|
||||||
|
|
||||||
.nav-bar {
|
.nav-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -282,7 +291,9 @@ function goBack() {
|
|||||||
padding: $space-sm $space-xl $space-md;
|
padding: $space-sm $space-xl $space-md;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-back { @include nav-back; }
|
.nav-back {
|
||||||
|
@include nav-back;
|
||||||
|
}
|
||||||
|
|
||||||
.nav-title {
|
.nav-title {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -292,7 +303,9 @@ function goBack() {
|
|||||||
color: $text;
|
color: $text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-placeholder { width: 88rpx; }
|
.nav-placeholder {
|
||||||
|
width: 88rpx;
|
||||||
|
}
|
||||||
|
|
||||||
.stats-card {
|
.stats-card {
|
||||||
margin: $space-md $space-xl;
|
margin: $space-md $space-xl;
|
||||||
@@ -333,7 +346,9 @@ function goBack() {
|
|||||||
gap: $space-sm;
|
gap: $space-sm;
|
||||||
padding: $space-xs 0;
|
padding: $space-xs 0;
|
||||||
|
|
||||||
&:active { opacity: 0.7; }
|
&:active {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-tag {
|
.event-tag {
|
||||||
@@ -341,18 +356,34 @@ function goBack() {
|
|||||||
border-radius: $radius-xs;
|
border-radius: $radius-xs;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|
||||||
&.page { background: #E3F2FD; }
|
&.page {
|
||||||
&.action { background: #E8F5E9; }
|
background: #E3F2FD;
|
||||||
&.error { background: #FFEBEE; }
|
}
|
||||||
|
|
||||||
|
&.action {
|
||||||
|
background: #E8F5E9;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.error {
|
||||||
|
background: #FFEBEE;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-tag-text {
|
.event-tag-text {
|
||||||
font-size: $font-sm;
|
font-size: $font-sm;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|
||||||
.page & { color: #1976D2; }
|
.page & {
|
||||||
.action & { color: #388E3C; }
|
color: #1976D2;
|
||||||
.error & { color: #D32F2F; }
|
}
|
||||||
|
|
||||||
|
.action & {
|
||||||
|
color: #388E3C;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error & {
|
||||||
|
color: #D32F2F;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-bar {
|
.event-bar {
|
||||||
@@ -472,7 +503,9 @@ function goBack() {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
&:active { opacity: 0.8; }
|
&:active {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-btn-text {
|
.search-btn-text {
|
||||||
@@ -491,7 +524,6 @@ function goBack() {
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: $space-xs;
|
gap: $space-xs;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
width: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-tab {
|
.filter-tab {
|
||||||
@@ -520,7 +552,9 @@ function goBack() {
|
|||||||
padding: $space-md;
|
padding: $space-md;
|
||||||
border-bottom: 1rpx solid $border;
|
border-bottom: 1rpx solid $border;
|
||||||
|
|
||||||
&:last-child { border-bottom: none; }
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.record-header {
|
.record-header {
|
||||||
@@ -534,18 +568,34 @@ function goBack() {
|
|||||||
padding: 2rpx 12rpx;
|
padding: 2rpx 12rpx;
|
||||||
border-radius: $radius-xs;
|
border-radius: $radius-xs;
|
||||||
|
|
||||||
&.page { background: #E3F2FD; }
|
&.page {
|
||||||
&.action { background: #E8F5E9; }
|
background: #E3F2FD;
|
||||||
&.error { background: #FFEBEE; }
|
}
|
||||||
|
|
||||||
|
&.action {
|
||||||
|
background: #E8F5E9;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.error {
|
||||||
|
background: #FFEBEE;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.record-event-text {
|
.record-event-text {
|
||||||
font-size: $font-xs;
|
font-size: $font-xs;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|
||||||
.page & { color: #1976D2; }
|
.page & {
|
||||||
.action & { color: #388E3C; }
|
color: #1976D2;
|
||||||
.error & { color: #D32F2F; }
|
}
|
||||||
|
|
||||||
|
.action & {
|
||||||
|
color: #388E3C;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error & {
|
||||||
|
color: #D32F2F;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.record-time {
|
.record-time {
|
||||||
@@ -578,7 +628,8 @@ function goBack() {
|
|||||||
border-radius: $radius-sm;
|
border-radius: $radius-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-box, .empty-box {
|
.loading-box,
|
||||||
|
.empty-box {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -601,6 +652,8 @@ function goBack() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.bar-fill { transition: none; }
|
.bar-fill {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -242,7 +242,7 @@ async function onDelete(item: any) {
|
|||||||
|
|
||||||
// 触底加载更多
|
// 触底加载更多
|
||||||
onReachBottom(() => {
|
onReachBottom(() => {
|
||||||
if (noMore.value || loading) return
|
if (noMore.value || loading.value) return
|
||||||
page.value++
|
page.value++
|
||||||
loadTx(false)
|
loadTx(false)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -60,15 +60,24 @@
|
|||||||
|
|
||||||
<!-- 自定义金额 -->
|
<!-- 自定义金额 -->
|
||||||
<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"
|
||||||
|
unit="元"
|
||||||
|
:auto-focus="false"
|
||||||
|
@focus="keyboardVisible = true"
|
||||||
|
@blur="keyboardVisible = false"
|
||||||
|
@confirm="onConfirm"
|
||||||
|
/>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Numpad -->
|
<!-- 底部保存按钮(键盘收起时显示) -->
|
||||||
<Numpad v-model="amountStr" @confirm="onConfirm" />
|
<view class="save-bar" v-if="!keyboardVisible">
|
||||||
|
<view class="save-btn" @tap="onConfirm">
|
||||||
|
<text class="save-btn-text">保存预算</text>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
@@ -84,7 +93,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 +106,8 @@ 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 keyboardVisible = ref(false)
|
||||||
|
|
||||||
const presets = [1000, 2000, 3000, 5000, 10000]
|
const presets = [1000, 2000, 3000, 5000, 10000]
|
||||||
|
|
||||||
@@ -162,6 +173,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() {
|
||||||
@@ -195,7 +207,11 @@ function goBack() {
|
|||||||
.page {
|
.page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: $bg;
|
background: $bg;
|
||||||
padding-bottom: 120rpx;
|
// AmountEditor fixed Numpad 占位 + 保存按钮占位
|
||||||
|
$numpad-h: 464rpx;
|
||||||
|
$save-bar-h: 144rpx;
|
||||||
|
padding-bottom: calc(#{$numpad-h} + #{$save-bar-h} + constant(safe-area-inset-bottom));
|
||||||
|
padding-bottom: calc(#{$numpad-h} + #{$save-bar-h} + env(safe-area-inset-bottom));
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-fixed {
|
.header-fixed {
|
||||||
@@ -367,60 +383,45 @@ function goBack() {
|
|||||||
color: $text;
|
color: $text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-input {
|
// 底部保存按钮栏
|
||||||
display: flex;
|
.save-bar {
|
||||||
align-items: baseline;
|
position: fixed;
|
||||||
gap: $space-xs;
|
bottom: 0;
|
||||||
margin-bottom: $space-lg;
|
left: 0;
|
||||||
padding: $space-md $space-lg;
|
right: 0;
|
||||||
|
padding: $space-md $space-xl;
|
||||||
|
padding-bottom: calc(#{$space-md} + constant(safe-area-inset-bottom));
|
||||||
|
padding-bottom: calc(#{$space-md} + env(safe-area-inset-bottom));
|
||||||
background: $surface;
|
background: $surface;
|
||||||
|
border-top: 2rpx solid $border;
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-btn {
|
||||||
|
height: 96rpx;
|
||||||
|
background: linear-gradient(135deg, $primary, #E67355);
|
||||||
border-radius: $radius-xl;
|
border-radius: $radius-xl;
|
||||||
border: 2rpx solid $border;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 4rpx 12rpx rgba(255, 140, 105, 0.3);
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
opacity: 0.8;
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-prefix {
|
.save-btn-text {
|
||||||
font-family: 'Fredoka', sans-serif;
|
font-size: $font-xl;
|
||||||
font-size: $font-3xl;
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: $primary;
|
color: $surface;
|
||||||
}
|
}
|
||||||
|
|
||||||
.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>
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,15 +138,17 @@
|
|||||||
</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>
|
||||||
|
|
||||||
@@ -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: '每年'
|
||||||
@@ -253,6 +260,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 +271,7 @@ async function handleSubmit() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
const data = {
|
const data = {
|
||||||
amount,
|
amount,
|
||||||
@@ -278,11 +287,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 +315,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 +450,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 +480,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 +511,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>
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export const useGroupStore = defineStore('group', () => {
|
|||||||
if (currentGroupId.value !== null) {
|
if (currentGroupId.value !== null) {
|
||||||
const stillValid = groups.value.some(g => g.id === currentGroupId.value)
|
const stillValid = groups.value.some(g => g.id === currentGroupId.value)
|
||||||
if (!stillValid) {
|
if (!stillValid) {
|
||||||
switchToPersonal()
|
await switchToPersonal()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -83,7 +83,7 @@ export const useGroupStore = defineStore('group', () => {
|
|||||||
async function leaveGroup(groupId: number) {
|
async function leaveGroup(groupId: number) {
|
||||||
await api.leaveGroup(groupId)
|
await api.leaveGroup(groupId)
|
||||||
if (currentGroupId.value === groupId) {
|
if (currentGroupId.value === groupId) {
|
||||||
switchToPersonal()
|
await switchToPersonal()
|
||||||
}
|
}
|
||||||
await fetchGroups()
|
await fetchGroups()
|
||||||
}
|
}
|
||||||
@@ -92,7 +92,7 @@ export const useGroupStore = defineStore('group', () => {
|
|||||||
async function deleteGroup(groupId: number) {
|
async function deleteGroup(groupId: number) {
|
||||||
await api.deleteGroup(groupId)
|
await api.deleteGroup(groupId)
|
||||||
if (currentGroupId.value === groupId) {
|
if (currentGroupId.value === groupId) {
|
||||||
switchToPersonal()
|
await switchToPersonal()
|
||||||
}
|
}
|
||||||
await fetchGroups()
|
await fetchGroups()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,11 @@ export function formatDate(date: string | Date): string {
|
|||||||
if (diff === 1) return '昨天'
|
if (diff === 1) return '昨天'
|
||||||
if (diff === 2) return '前天'
|
if (diff === 2) return '前天'
|
||||||
|
|
||||||
return `${d.getMonth() + 1}月${d.getDate()}日`
|
// 跨年显示年份
|
||||||
|
const isCurrentYear = d.getFullYear() === now.getFullYear()
|
||||||
|
return isCurrentYear
|
||||||
|
? `${d.getMonth() + 1}月${d.getDate()}日`
|
||||||
|
: `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatMonth(date: string): string {
|
export function formatMonth(date: string): string {
|
||||||
|
|||||||
@@ -124,9 +124,9 @@ router.post('/:id/migrate', async (req: AuthRequest, res: Response) => {
|
|||||||
|
|
||||||
await conn.beginTransaction()
|
await conn.beginTransaction()
|
||||||
|
|
||||||
// 验证源分类存在且属于当前用户
|
// 验证源分类存在且属于当前用户(仅允许迁移自定义分类)
|
||||||
const [sourceRows] = await conn.query(
|
const [sourceRows] = await conn.query(
|
||||||
'SELECT id, type FROM categories WHERE id = ? AND (user_id = 0 OR user_id = ?)',
|
'SELECT id, type FROM categories WHERE id = ? AND is_custom = 1 AND user_id = ?',
|
||||||
[req.params.id, req.userId]
|
[req.params.id, req.userId]
|
||||||
)
|
)
|
||||||
const sourceCategory = (sourceRows as any[])[0]
|
const sourceCategory = (sourceRows as any[])[0]
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ const upload = multer({
|
|||||||
|
|
||||||
/** 公告编辑权限检查(管理员可编辑所有公告,群主可编辑自己群组的公告) */
|
/** 公告编辑权限检查(管理员可编辑所有公告,群主可编辑自己群组的公告) */
|
||||||
async function requireNotificationEditAuth(req: AuthRequest, res: Response, next: NextFunction) {
|
async function requireNotificationEditAuth(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
'SELECT type, group_id FROM notifications WHERE id = ?',
|
'SELECT type, group_id FROM notifications WHERE id = ?',
|
||||||
[req.params.id]
|
[req.params.id]
|
||||||
@@ -75,6 +76,10 @@ async function requireNotificationEditAuth(req: AuthRequest, res: Response, next
|
|||||||
}
|
}
|
||||||
|
|
||||||
return res.status(403).json({ code: 40300, message: '需要管理员或群主权限' })
|
return res.status(403).json({ code: 40300, message: '需要管理员或群主权限' })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Notification] editAuth error:', err)
|
||||||
|
return res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取通知列表 */
|
/** 获取通知列表 */
|
||||||
@@ -283,8 +288,8 @@ function isValidUrl(url: string): boolean {
|
|||||||
const parsed = new URL(url)
|
const parsed = new URL(url)
|
||||||
return ['http:', 'https:'].includes(parsed.protocol)
|
return ['http:', 'https:'].includes(parsed.protocol)
|
||||||
} catch {
|
} catch {
|
||||||
// 相对路径也允许
|
// 非标准 URL 格式,小程序不支持相对路径,拒绝
|
||||||
return url.startsWith('/') || url.startsWith('./')
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -177,21 +177,10 @@ router.post('/sync', async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新 next_date 到下一周期
|
// 更新 next_date:循环结束后 next 已推进到今天之后的下一周期,直接使用
|
||||||
let newNext: Date
|
|
||||||
if (t.frequency === 'weekly') {
|
|
||||||
newNext = new Date(new Date(t.next_date).getTime() + 7 * 86400000)
|
|
||||||
} else if (t.frequency === 'monthly') {
|
|
||||||
const d = new Date(t.next_date)
|
|
||||||
newNext = new Date(d.getFullYear(), d.getMonth() + 1, d.getDate())
|
|
||||||
} else {
|
|
||||||
const d = new Date(t.next_date)
|
|
||||||
newNext = new Date(d.getFullYear() + 1, d.getMonth(), d.getDate())
|
|
||||||
}
|
|
||||||
|
|
||||||
await pool.execute(
|
await pool.execute(
|
||||||
'UPDATE recurring_templates SET next_date = ? WHERE id = ?',
|
'UPDATE recurring_templates SET next_date = ? WHERE id = ?',
|
||||||
[newNext.toISOString().slice(0, 10), t.id]
|
[next.toISOString().slice(0, 10), t.id]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -132,7 +132,11 @@ router.post('/avatar', (req: AuthRequest, res: Response) => {
|
|||||||
if (oldUrl) {
|
if (oldUrl) {
|
||||||
const oldPath = path.join(AVATAR_DIR, oldUrl)
|
const oldPath = path.join(AVATAR_DIR, oldUrl)
|
||||||
if (fs.existsSync(oldPath)) {
|
if (fs.existsSync(oldPath)) {
|
||||||
|
try {
|
||||||
fs.unlinkSync(oldPath)
|
fs.unlinkSync(oldPath)
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[User] 删除旧头像失败:', e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user