feat:添加本地数据库
This commit is contained in:
1526
package-lock.json
generated
1526
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,13 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@capacitor-community/sqlite": "^5.7.2",
|
||||
"@capacitor/core": "^5.7.8",
|
||||
"@phosphor-icons/web": "^2.1.2",
|
||||
"jeep-sqlite": "^2.7.1",
|
||||
"pinia": "^2.1.7",
|
||||
"pinia-plugin-persistedstate": "^3.2.0",
|
||||
"uuid": "^11.0.2",
|
||||
"vue": "^3.5.24",
|
||||
"vue-router": "^4.6.3"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { RouterView } from 'vue-router'
|
||||
import BottomDock from './components/BottomDock.vue'
|
||||
import { useTransactionStore } from './stores/transactions'
|
||||
|
||||
const transactionStore = useTransactionStore()
|
||||
|
||||
onMounted(() => {
|
||||
transactionStore.ensureInitialized()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
124
src/composables/useTransactionEntry.js
Normal file
124
src/composables/useTransactionEntry.js
Normal file
@@ -0,0 +1,124 @@
|
||||
import { ref } from 'vue'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { useTransactionStore } from '../stores/transactions'
|
||||
|
||||
const CATEGORY_HINTS = [
|
||||
{ keywords: ['咖啡', '奶茶', '星巴克', 'luckin', 'coffee', 'familymart', '7-eleven'], category: 'Food' },
|
||||
{ keywords: ['健身', 'gym', '运动', '瑜伽'], category: 'Health' },
|
||||
{ keywords: ['滴滴', '打车', '出租', '地铁', '出行'], category: 'Transport' },
|
||||
{ keywords: ['超市', '盒马', 'sam', '山姆'], category: 'Groceries' },
|
||||
{ keywords: ['工资', 'salary', 'bonus', '收入'], category: 'Income' },
|
||||
]
|
||||
|
||||
const inferCategory = (merchant) => {
|
||||
const lower = merchant.toLowerCase()
|
||||
const matched = CATEGORY_HINTS.find((hint) => hint.keywords.some((key) => lower.includes(key)))
|
||||
if (matched) return matched.category
|
||||
return 'Uncategorized'
|
||||
}
|
||||
|
||||
const seedNotifications = () => [
|
||||
{
|
||||
id: uuidv4(),
|
||||
channel: '支付宝',
|
||||
text: '支付宝提醒:你向 瑞幸咖啡 支付 ¥18.00',
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: uuidv4(),
|
||||
channel: '微信支付',
|
||||
text: '微信支付:美团外卖 实付 ¥46.50 · 订单已送达',
|
||||
createdAt: new Date(Date.now() - 1000 * 60 * 8).toISOString(),
|
||||
},
|
||||
{
|
||||
id: uuidv4(),
|
||||
channel: '支付宝',
|
||||
text: '支付宝到账:工资收入 ¥12500.00,来自 Echo Studio',
|
||||
createdAt: new Date(Date.now() - 1000 * 60 * 32).toISOString(),
|
||||
},
|
||||
]
|
||||
|
||||
const notifications = ref([])
|
||||
const processingId = ref('')
|
||||
let seeded = false
|
||||
|
||||
const ensureSeeded = () => {
|
||||
if (!seeded) {
|
||||
notifications.value = seedNotifications()
|
||||
seeded = true
|
||||
}
|
||||
}
|
||||
|
||||
export const useTransactionEntry = () => {
|
||||
const transactionStore = useTransactionStore()
|
||||
ensureSeeded()
|
||||
|
||||
const parseNotification = (text) => {
|
||||
const amountMatch = text.match(/(?:¥|¥)\s?(-?\d+(?:\.\d{1,2})?)/)
|
||||
const amount = amountMatch ? parseFloat(amountMatch[1]) : 0
|
||||
|
||||
let merchant = 'Unknown'
|
||||
const merchantMatch = text.match(/向\s?([\u4e00-\u9fa5A-Za-z0-9\s&]+)/)
|
||||
if (merchantMatch?.[1]) {
|
||||
merchant = merchantMatch[1].trim()
|
||||
} else if (text.includes(':')) {
|
||||
merchant = text.split(':')[1]?.split(' ')[0]?.trim() || merchant
|
||||
}
|
||||
|
||||
const isIncome = /收入|到账|received/i.test(text)
|
||||
const normalizedAmount = isIncome ? Math.abs(amount) : -Math.abs(amount || 0)
|
||||
|
||||
return {
|
||||
id: uuidv4(),
|
||||
amount: normalizedAmount,
|
||||
merchant,
|
||||
category: inferCategory(merchant),
|
||||
date: new Date().toISOString(),
|
||||
note: text,
|
||||
syncStatus: 'pending',
|
||||
}
|
||||
}
|
||||
|
||||
const removeNotification = (id) => {
|
||||
notifications.value = notifications.value.filter((item) => item.id !== id)
|
||||
}
|
||||
|
||||
const confirmNotification = async (id) => {
|
||||
const target = notifications.value.find((item) => item.id === id)
|
||||
if (!target || processingId.value) return
|
||||
processingId.value = id
|
||||
try {
|
||||
await transactionStore.ensureInitialized()
|
||||
const parsed = parseNotification(target.text)
|
||||
await transactionStore.addTransaction(parsed)
|
||||
removeNotification(id)
|
||||
} finally {
|
||||
processingId.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const dismissNotification = (id) => {
|
||||
removeNotification(id)
|
||||
}
|
||||
|
||||
const simulateNotification = (text) => {
|
||||
notifications.value = [
|
||||
{
|
||||
id: uuidv4(),
|
||||
channel: '模拟',
|
||||
text,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
...notifications.value,
|
||||
]
|
||||
}
|
||||
|
||||
return {
|
||||
notifications,
|
||||
processingId,
|
||||
parseNotification,
|
||||
confirmNotification,
|
||||
dismissNotification,
|
||||
simulateNotification,
|
||||
}
|
||||
}
|
||||
82
src/lib/sqlite.js
Normal file
82
src/lib/sqlite.js
Normal file
@@ -0,0 +1,82 @@
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { CapacitorSQLite, SQLiteConnection } from '@capacitor-community/sqlite'
|
||||
import { defineCustomElements as defineJeep } from 'jeep-sqlite/loader'
|
||||
|
||||
const DB_NAME = 'echo_local'
|
||||
const DB_VERSION = 1
|
||||
const TRANSACTION_TABLE_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS transactions (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
amount REAL NOT NULL,
|
||||
merchant TEXT NOT NULL,
|
||||
category TEXT DEFAULT 'Uncategorized',
|
||||
date TEXT NOT NULL,
|
||||
note TEXT,
|
||||
sync_status INTEGER DEFAULT 0
|
||||
);
|
||||
`
|
||||
|
||||
let sqliteConnection
|
||||
let db
|
||||
let initialized = false
|
||||
|
||||
const ensureJeepElement = async () => {
|
||||
if (!customElements.get('jeep-sqlite')) {
|
||||
defineJeep(window)
|
||||
}
|
||||
if (!document.querySelector('jeep-sqlite')) {
|
||||
const jeepEl = document.createElement('jeep-sqlite')
|
||||
document.body.appendChild(jeepEl)
|
||||
}
|
||||
await CapacitorSQLite.initWebStore()
|
||||
}
|
||||
|
||||
const prepareConnection = async () => {
|
||||
if (!sqliteConnection) {
|
||||
sqliteConnection = new SQLiteConnection(CapacitorSQLite)
|
||||
}
|
||||
|
||||
if (Capacitor.getPlatform() === 'web') {
|
||||
await ensureJeepElement()
|
||||
}
|
||||
|
||||
const consistency = await sqliteConnection.checkConnectionsConsistency()
|
||||
if (!consistency.result) {
|
||||
await sqliteConnection.closeAllConnections()
|
||||
}
|
||||
|
||||
const isConn = (await sqliteConnection.isConnection(DB_NAME, false)).result
|
||||
if (isConn) {
|
||||
db = await sqliteConnection.retrieveConnection(DB_NAME, false)
|
||||
} else {
|
||||
db = await sqliteConnection.createConnection(DB_NAME, false, 'no-encryption', DB_VERSION, false)
|
||||
}
|
||||
|
||||
await db.open()
|
||||
await db.execute(TRANSACTION_TABLE_SQL)
|
||||
initialized = true
|
||||
}
|
||||
|
||||
export const initSQLite = async () => {
|
||||
if (!initialized) {
|
||||
await prepareConnection()
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
export const getDb = async () => {
|
||||
if (!initialized) {
|
||||
await initSQLite()
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
export const closeSQLite = async () => {
|
||||
if (db) {
|
||||
await db.close()
|
||||
db = null
|
||||
}
|
||||
initialized = false
|
||||
}
|
||||
|
||||
export const databaseName = DB_NAME
|
||||
@@ -5,8 +5,13 @@ import '@phosphor-icons/web/bold'
|
||||
import '@phosphor-icons/web/fill'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import { createPinia } from 'pinia'
|
||||
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
|
||||
|
||||
// 创建应用根实例并挂载路由
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
pinia.use(piniaPluginPersistedstate)
|
||||
|
||||
app.use(router)
|
||||
app.use(pinia)
|
||||
app.mount('#app')
|
||||
|
||||
108
src/services/transactionService.js
Normal file
108
src/services/transactionService.js
Normal file
@@ -0,0 +1,108 @@
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { getDb } from '../lib/sqlite'
|
||||
|
||||
const statusMap = {
|
||||
pending: 0,
|
||||
synced: 1,
|
||||
error: 2,
|
||||
}
|
||||
|
||||
const normalizeStatus = (value) => {
|
||||
if (value === 1) return 'synced'
|
||||
if (value === 2) return 'error'
|
||||
return 'pending'
|
||||
}
|
||||
|
||||
const mapRow = (row) => ({
|
||||
id: row.id,
|
||||
amount: Number(row.amount),
|
||||
merchant: row.merchant,
|
||||
category: row.category || 'Uncategorized',
|
||||
date: row.date,
|
||||
note: row.note || '',
|
||||
syncStatus: normalizeStatus(row.sync_status),
|
||||
})
|
||||
|
||||
export const fetchTransactions = async () => {
|
||||
const db = await getDb()
|
||||
const result = await db.query(
|
||||
`
|
||||
SELECT id, amount, merchant, category, date, note, sync_status
|
||||
FROM transactions
|
||||
ORDER BY date DESC
|
||||
`,
|
||||
)
|
||||
return (result?.values || []).map(mapRow)
|
||||
}
|
||||
|
||||
export const insertTransaction = async (payload) => {
|
||||
const db = await getDb()
|
||||
const id = payload.id || uuidv4()
|
||||
const sanitized = {
|
||||
merchant: payload.merchant?.trim() || 'Unknown',
|
||||
category: payload.category?.trim() || 'Uncategorized',
|
||||
note: payload.note?.trim() || '',
|
||||
date: payload.date,
|
||||
amount: Number(payload.amount),
|
||||
syncStatus: statusMap[payload.syncStatus] ?? statusMap.pending,
|
||||
}
|
||||
|
||||
await db.run(
|
||||
`
|
||||
INSERT INTO transactions (id, amount, merchant, category, date, note, sync_status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
[
|
||||
id,
|
||||
sanitized.amount,
|
||||
sanitized.merchant,
|
||||
sanitized.category,
|
||||
sanitized.date,
|
||||
sanitized.note || null,
|
||||
sanitized.syncStatus,
|
||||
],
|
||||
)
|
||||
|
||||
return {
|
||||
id,
|
||||
...sanitized,
|
||||
syncStatus: payload.syncStatus || 'pending',
|
||||
}
|
||||
}
|
||||
|
||||
export const updateTransaction = async (payload) => {
|
||||
const db = await getDb()
|
||||
await db.run(
|
||||
`
|
||||
UPDATE transactions
|
||||
SET amount = ?, merchant = ?, category = ?, date = ?, note = ?, sync_status = ?
|
||||
WHERE id = ?
|
||||
`,
|
||||
[
|
||||
Number(payload.amount),
|
||||
payload.merchant?.trim() || 'Unknown',
|
||||
payload.category?.trim() || 'Uncategorized',
|
||||
payload.date,
|
||||
payload.note?.trim() || null,
|
||||
statusMap[payload.syncStatus] ?? statusMap.pending,
|
||||
payload.id,
|
||||
],
|
||||
)
|
||||
|
||||
const result = await db.query(
|
||||
`
|
||||
SELECT id, amount, merchant, category, date, note, sync_status
|
||||
FROM transactions
|
||||
WHERE id = ?
|
||||
`,
|
||||
[payload.id],
|
||||
)
|
||||
|
||||
const updated = result?.values?.[0]
|
||||
return updated ? mapRow(updated) : null
|
||||
}
|
||||
|
||||
export const deleteTransaction = async (id) => {
|
||||
const db = await getDb()
|
||||
await db.run(`DELETE FROM transactions WHERE id = ?`, [id])
|
||||
}
|
||||
181
src/stores/transactions.js
Normal file
181
src/stores/transactions.js
Normal file
@@ -0,0 +1,181 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import {
|
||||
deleteTransaction,
|
||||
fetchTransactions,
|
||||
insertTransaction,
|
||||
updateTransaction,
|
||||
} from '../services/transactionService'
|
||||
|
||||
const toIsoDay = (value) => {
|
||||
const date = value instanceof Date ? value : new Date(value)
|
||||
return date.toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
const formatDayLabel = (dayKey) => {
|
||||
const todayKey = toIsoDay(new Date())
|
||||
const yesterdayKey = toIsoDay(new Date(Date.now() - 86400000))
|
||||
if (dayKey === todayKey) return '今天'
|
||||
if (dayKey === yesterdayKey) return '昨天'
|
||||
const [y, m, d] = dayKey.split('-').map((part) => Number(part))
|
||||
const localDate = new Date(y, m - 1, d)
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
weekday: 'short',
|
||||
}).format(localDate)
|
||||
}
|
||||
|
||||
export const useTransactionStore = defineStore(
|
||||
'transactions',
|
||||
() => {
|
||||
const transactions = ref([])
|
||||
const initialized = ref(false)
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const filters = ref({
|
||||
category: 'all',
|
||||
showOnlyToday: false,
|
||||
})
|
||||
|
||||
const sortedTransactions = computed(() =>
|
||||
[...transactions.value].sort((a, b) => new Date(b.date) - new Date(a.date)),
|
||||
)
|
||||
|
||||
const todaysTransactions = computed(() => {
|
||||
const todayKey = toIsoDay(new Date())
|
||||
return sortedTransactions.value.filter((tx) => toIsoDay(tx.date) === todayKey)
|
||||
})
|
||||
|
||||
const totalExpense = computed(() =>
|
||||
sortedTransactions.value.reduce((sum, tx) => (tx.amount < 0 ? sum + tx.amount : sum), 0),
|
||||
)
|
||||
|
||||
const totalIncome = computed(() =>
|
||||
sortedTransactions.value.reduce((sum, tx) => (tx.amount > 0 ? sum + tx.amount : sum), 0),
|
||||
)
|
||||
|
||||
const todaysExpense = computed(() =>
|
||||
todaysTransactions.value.reduce((sum, tx) => (tx.amount < 0 ? sum + tx.amount : sum), 0),
|
||||
)
|
||||
|
||||
const todaysIncome = computed(() =>
|
||||
todaysTransactions.value.reduce((sum, tx) => (tx.amount > 0 ? sum + tx.amount : sum), 0),
|
||||
)
|
||||
|
||||
const latestTransactions = computed(() => sortedTransactions.value.slice(0, 5))
|
||||
|
||||
const groupedTransactions = computed(() => {
|
||||
const appliedCategory = filters.value.category
|
||||
const showTodayOnly = filters.value.showOnlyToday
|
||||
const groups = new Map()
|
||||
|
||||
sortedTransactions.value.forEach((tx) => {
|
||||
if (appliedCategory !== 'all' && tx.category !== appliedCategory) return
|
||||
if (showTodayOnly && toIsoDay(tx.date) !== toIsoDay(new Date())) return
|
||||
const dayKey = toIsoDay(tx.date)
|
||||
if (!groups.has(dayKey)) {
|
||||
groups.set(dayKey, [])
|
||||
}
|
||||
groups.get(dayKey).push(tx)
|
||||
})
|
||||
|
||||
return Array.from(groups.entries())
|
||||
.sort((a, b) => new Date(b[0]) - new Date(a[0]))
|
||||
.map(([dayKey, records]) => ({
|
||||
dayKey,
|
||||
label: formatDayLabel(dayKey),
|
||||
totalExpense: records
|
||||
.filter((tx) => tx.amount < 0)
|
||||
.reduce((sum, tx) => sum + Math.abs(tx.amount), 0),
|
||||
items: records,
|
||||
}))
|
||||
})
|
||||
|
||||
const hydrateTransactions = async () => {
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
const rows = await fetchTransactions()
|
||||
transactions.value = rows
|
||||
initialized.value = true
|
||||
} catch (err) {
|
||||
errorMessage.value = err?.message || '无法加载本地账本'
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const ensureInitialized = async () => {
|
||||
if (!initialized.value && !loading.value) {
|
||||
await hydrateTransactions()
|
||||
}
|
||||
}
|
||||
|
||||
const addTransaction = async (payload) => {
|
||||
const normalized = {
|
||||
...payload,
|
||||
date: payload.date ? new Date(payload.date).toISOString() : new Date().toISOString(),
|
||||
}
|
||||
const created = await insertTransaction(normalized)
|
||||
transactions.value = [created, ...transactions.value]
|
||||
return created
|
||||
}
|
||||
|
||||
const editTransaction = async (payload) => {
|
||||
const normalized = {
|
||||
...payload,
|
||||
date: payload.date ? new Date(payload.date).toISOString() : new Date().toISOString(),
|
||||
}
|
||||
const updated = await updateTransaction(normalized)
|
||||
if (updated) {
|
||||
transactions.value = transactions.value.map((tx) => (tx.id === updated.id ? updated : tx))
|
||||
}
|
||||
return updated
|
||||
}
|
||||
|
||||
const removeTransaction = async (id) => {
|
||||
await deleteTransaction(id)
|
||||
transactions.value = transactions.value.filter((tx) => tx.id !== id)
|
||||
}
|
||||
|
||||
const upsertFromNotification = async (payload) => {
|
||||
const existing = transactions.value.find((tx) => tx.id === payload.id)
|
||||
if (existing) {
|
||||
return editTransaction(payload)
|
||||
}
|
||||
return addTransaction(payload)
|
||||
}
|
||||
|
||||
const findById = (id) => transactions.value.find((tx) => tx.id === id)
|
||||
|
||||
return {
|
||||
transactions,
|
||||
initialized,
|
||||
loading,
|
||||
errorMessage,
|
||||
filters,
|
||||
sortedTransactions,
|
||||
groupedTransactions,
|
||||
todaysTransactions,
|
||||
todaysExpense,
|
||||
todaysIncome,
|
||||
totalExpense,
|
||||
totalIncome,
|
||||
latestTransactions,
|
||||
hydrateTransactions,
|
||||
ensureInitialized,
|
||||
addTransaction,
|
||||
editTransaction,
|
||||
removeTransaction,
|
||||
upsertFromNotification,
|
||||
findById,
|
||||
}
|
||||
},
|
||||
{
|
||||
persist: {
|
||||
paths: ['filters'],
|
||||
},
|
||||
},
|
||||
)
|
||||
9
src/types/transaction.d.ts
vendored
Normal file
9
src/types/transaction.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
export interface Transaction {
|
||||
id: string;
|
||||
amount: number;
|
||||
merchant: string;
|
||||
category: string;
|
||||
date: string;
|
||||
note?: string;
|
||||
syncStatus: 'pending' | 'synced' | 'error';
|
||||
}
|
||||
@@ -1,81 +1,243 @@
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useTransactionStore } from '../stores/transactions'
|
||||
|
||||
// 这里将原来的底部弹窗拆分成独立页面,通过路由进入和返回
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const transactionStore = useTransactionStore()
|
||||
|
||||
const handleClose = () => {
|
||||
const editingId = computed(() => route.query.id || '')
|
||||
const feedback = ref('')
|
||||
const saving = ref(false)
|
||||
|
||||
const categories = ['Food', 'Transport', 'Health', 'Groceries', 'Income', 'Uncategorized']
|
||||
|
||||
const toDatetimeLocal = (value) => {
|
||||
const date = value ? new Date(value) : new Date()
|
||||
const offset = date.getTimezoneOffset()
|
||||
return new Date(date.getTime() - offset * 60000).toISOString().slice(0, 16)
|
||||
}
|
||||
|
||||
const form = reactive({
|
||||
type: 'expense',
|
||||
amount: '',
|
||||
merchant: '',
|
||||
category: 'Food',
|
||||
note: '',
|
||||
date: toDatetimeLocal(),
|
||||
})
|
||||
|
||||
const resetForm = () => {
|
||||
form.type = 'expense'
|
||||
form.amount = ''
|
||||
form.merchant = ''
|
||||
form.category = 'Food'
|
||||
form.note = ''
|
||||
form.date = toDatetimeLocal()
|
||||
}
|
||||
|
||||
const hydrateForm = () => {
|
||||
if (!editingId.value) {
|
||||
resetForm()
|
||||
return
|
||||
}
|
||||
const target = transactionStore.findById(editingId.value)
|
||||
if (!target) {
|
||||
resetForm()
|
||||
return
|
||||
}
|
||||
form.type = target.amount >= 0 ? 'income' : 'expense'
|
||||
form.amount = Math.abs(target.amount).toString()
|
||||
form.merchant = target.merchant
|
||||
form.category = target.category
|
||||
form.note = target.note || ''
|
||||
form.date = toDatetimeLocal(target.date)
|
||||
}
|
||||
|
||||
watch(editingId, hydrateForm, { immediate: true })
|
||||
|
||||
const closePanel = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
const validate = () => {
|
||||
if (!form.merchant.trim()) {
|
||||
feedback.value = '请输入商户或来源'
|
||||
return false
|
||||
}
|
||||
const parsedAmount = Number(form.amount)
|
||||
if (!Number.isFinite(parsedAmount) || parsedAmount <= 0) {
|
||||
feedback.value = '请输入有效的金额'
|
||||
return false
|
||||
}
|
||||
feedback.value = ''
|
||||
return true
|
||||
}
|
||||
|
||||
const submitForm = async () => {
|
||||
if (!validate()) return
|
||||
saving.value = true
|
||||
try {
|
||||
const amountNumber = Number(form.amount)
|
||||
const normalizedAmount =
|
||||
form.type === 'income' ? Math.abs(amountNumber) : -Math.abs(amountNumber)
|
||||
const payload = {
|
||||
id: editingId.value || undefined,
|
||||
merchant: form.merchant.trim(),
|
||||
category: form.category,
|
||||
amount: normalizedAmount,
|
||||
note: form.note?.trim(),
|
||||
date: new Date(form.date).toISOString(),
|
||||
syncStatus: 'pending',
|
||||
}
|
||||
if (editingId.value) {
|
||||
await transactionStore.editTransaction(payload)
|
||||
} else {
|
||||
await transactionStore.addTransaction(payload)
|
||||
}
|
||||
closePanel()
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deleteEntry = async () => {
|
||||
if (!editingId.value) return
|
||||
const confirmed = window.confirm('确认删除该条记录吗?')
|
||||
if (!confirmed) return
|
||||
await transactionStore.removeTransaction(editingId.value)
|
||||
closePanel()
|
||||
}
|
||||
|
||||
transactionStore.ensureInitialized()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 保持与原弹窗一致的视觉:半透明遮罩 + 底部浮起卡片 -->
|
||||
<div class="absolute inset-0 z-50 flex flex-col justify-end bg-stone-900/30 backdrop-blur-sm">
|
||||
<div class="bg-white w-full rounded-t-[32px] p-6 pb-10 shadow-2xl">
|
||||
<div class="w-12 h-1.5 bg-stone-200 rounded-full mx-auto mb-8" />
|
||||
<div class="w-12 h-1.5 bg-stone-200 rounded-full mx-auto mb-6" />
|
||||
|
||||
<h3 class="text-xl font-extrabold text-stone-800 mb-6 px-2">
|
||||
记录新生活
|
||||
</h3>
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<p class="text-xs text-stone-400 font-bold uppercase tracking-widest">
|
||||
{{ editingId ? '编辑记录' : '记录新消费' }}
|
||||
</p>
|
||||
<h3 class="text-xl font-extrabold text-stone-800">
|
||||
{{ editingId ? '更新本地账本' : '快速入账' }}
|
||||
</h3>
|
||||
</div>
|
||||
<button class="text-stone-400 text-sm font-bold" @click="closePanel">关闭</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="flex items-center gap-2 mb-6 bg-stone-100 rounded-3xl p-1">
|
||||
<button
|
||||
class="p-5 rounded-3xl bg-stone-50 border border-stone-100 flex flex-col items-center gap-3 active:bg-stone-100 transition group"
|
||||
class="flex-1 py-2 rounded-2xl text-sm font-bold transition"
|
||||
:class="
|
||||
form.type === 'expense'
|
||||
? 'bg-white shadow text-rose-500'
|
||||
: 'text-stone-400'
|
||||
"
|
||||
@click="form.type = 'expense'"
|
||||
>
|
||||
<div
|
||||
class="w-12 h-12 rounded-2xl bg-blue-100 text-blue-600 flex items-center justify-center group-hover:scale-110 transition"
|
||||
>
|
||||
<i class="ph-fill ph-pencil-simple text-2xl" />
|
||||
</div>
|
||||
<span class="text-sm font-bold text-stone-600">手动记账</span>
|
||||
<i class="ph-bold ph-arrow-up-right" /> 支出
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="p-5 rounded-3xl bg-stone-50 border border-stone-100 flex flex-col items-center gap-3 active:bg-stone-100 transition group"
|
||||
class="flex-1 py-2 rounded-2xl text-sm font-bold transition"
|
||||
:class="
|
||||
form.type === 'income'
|
||||
? 'bg-white shadow text-emerald-500'
|
||||
: 'text-stone-400'
|
||||
"
|
||||
@click="form.type = 'income'"
|
||||
>
|
||||
<div
|
||||
class="w-12 h-12 rounded-2xl bg-emerald-100 text-emerald-600 flex items-center justify-center group-hover:scale-110 transition"
|
||||
>
|
||||
<i class="ph-fill ph-receipt text-2xl" />
|
||||
</div>
|
||||
<span class="text-sm font-bold text-stone-600">扫描小票</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="col-span-2 p-6 rounded-3xl bg-gradient-to-r from-orange-50 to-red-50 border border-orange-100 flex items-center justify-between active:scale-[0.98] transition group relative overflow-hidden"
|
||||
>
|
||||
<div class="flex items-center gap-4 z-10">
|
||||
<div
|
||||
class="w-12 h-12 rounded-2xl bg-orange-500 text-white flex items-center justify-center shadow-lg shadow-orange-200 group-hover:rotate-12 transition"
|
||||
>
|
||||
<i class="ph-fill ph-camera text-2xl" />
|
||||
</div>
|
||||
<div class="text-left">
|
||||
<span class="block text-base font-bold text-stone-800">
|
||||
拍食物 AI 分析
|
||||
</span>
|
||||
<span class="text-xs text-stone-400 font-bold">
|
||||
计算卡路里 & 自动记账
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<i class="ph-bold ph-arrow-right text-orange-300 z-10" />
|
||||
|
||||
<!-- 装饰圆形光晕 -->
|
||||
<div
|
||||
class="absolute right-0 bottom-0 w-32 h-32 bg-orange-100 rounded-full blur-2xl opacity-50 translate-x-10 translate-y-10"
|
||||
/>
|
||||
<i class="ph-bold ph-arrow-down-left" /> 收入
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<label class="block">
|
||||
<span class="text-xs font-bold text-stone-400">金额</span>
|
||||
<div class="mt-2 flex items-center gap-2 rounded-2xl border border-stone-200 px-4 py-3">
|
||||
<span class="text-stone-400 text-sm">¥</span>
|
||||
<input
|
||||
v-model="form.amount"
|
||||
type="number"
|
||||
inputmode="decimal"
|
||||
step="0.01"
|
||||
placeholder="0.00"
|
||||
class="flex-1 bg-transparent text-2xl font-bold text-stone-800 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="text-xs font-bold text-stone-400">商户 / 来源</span>
|
||||
<input
|
||||
v-model="form.merchant"
|
||||
class="mt-2 w-full rounded-2xl border border-stone-200 px-4 py-3 text-sm focus:outline-none focus:border-stone-400"
|
||||
placeholder="如 Starbucks / 滴滴出行"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<span class="text-xs font-bold text-stone-400">分类</span>
|
||||
<div class="mt-2 flex gap-2 overflow-x-auto hide-scrollbar pb-1">
|
||||
<button
|
||||
v-for="category in categories"
|
||||
:key="category"
|
||||
class="px-4 py-2 rounded-2xl text-xs font-bold border transition"
|
||||
:class="
|
||||
form.category === category
|
||||
? 'bg-gradient-warm text-white border-transparent'
|
||||
: 'bg-stone-50 text-stone-500 border-stone-100'
|
||||
"
|
||||
@click="form.category = category"
|
||||
>
|
||||
{{ category }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="block">
|
||||
<span class="text-xs font-bold text-stone-400">日期 / 时间</span>
|
||||
<input
|
||||
v-model="form.date"
|
||||
type="datetime-local"
|
||||
class="mt-2 w-full rounded-2xl border border-stone-200 px-4 py-3 text-sm focus:outline-none focus:border-stone-400"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="text-xs font-bold text-stone-400">备注</span>
|
||||
<textarea
|
||||
v-model="form.note"
|
||||
rows="2"
|
||||
class="mt-2 w-full rounded-2xl border border-stone-200 px-4 py-3 text-sm focus:outline-none focus:border-stone-400 resize-none"
|
||||
placeholder="可记录口味、心情或健康状态"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p v-if="feedback" class="text-xs text-rose-500 font-bold mt-3">
|
||||
{{ feedback }}
|
||||
</p>
|
||||
|
||||
<button
|
||||
class="mt-8 w-full py-4 rounded-2xl text-stone-400 text-sm font-bold hover:bg-stone-50 transition"
|
||||
@click="handleClose"
|
||||
class="mt-6 w-full py-4 rounded-2xl text-white font-bold bg-gradient-warm shadow-lg shadow-orange-200 active:scale-[0.99] transition disabled:opacity-60"
|
||||
:disabled="saving"
|
||||
@click="submitForm"
|
||||
>
|
||||
关闭
|
||||
{{ saving ? '保存中...' : editingId ? '保存修改' : '立即入账' }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="editingId"
|
||||
class="mt-3 w-full py-3 rounded-2xl text-sm font-bold text-rose-500 bg-rose-50 border border-rose-100"
|
||||
@click="deleteEntry"
|
||||
>
|
||||
删除记录
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,209 +1,290 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useTransactionStore } from '../stores/transactions'
|
||||
import { useTransactionEntry } from '../composables/useTransactionEntry'
|
||||
|
||||
const notifications = ref([
|
||||
{ merchant: '支付宝 - 瑞幸咖啡', amount: '18.00', time: '14:30' }
|
||||
])
|
||||
const transactionStore = useTransactionStore()
|
||||
const { totalIncome, totalExpense, todaysIncome, todaysExpense, latestTransactions } =
|
||||
storeToRefs(transactionStore)
|
||||
const { notifications, confirmNotification, dismissNotification, processingId } =
|
||||
useTransactionEntry()
|
||||
|
||||
const confirmNotif = (index) => {
|
||||
notifications.value.splice(index, 1)
|
||||
// In a real app, this would trigger a store action or API call
|
||||
alert('已自动入账,并同步至 AI 饮食分析')
|
||||
const emit = defineEmits(['changeTab'])
|
||||
const monthlyBudget = 12000
|
||||
|
||||
const currentDayLabel = computed(
|
||||
() =>
|
||||
new Intl.DateTimeFormat('zh-CN', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
weekday: 'long',
|
||||
}).format(new Date()),
|
||||
)
|
||||
|
||||
const budgetUsage = computed(() => {
|
||||
const expense = Math.abs(totalExpense.value || 0)
|
||||
if (!monthlyBudget) return 0
|
||||
return Math.min(expense / monthlyBudget, 1)
|
||||
})
|
||||
|
||||
const balance = computed(() => (totalIncome.value || 0) + (totalExpense.value || 0))
|
||||
const remainingBudget = computed(() =>
|
||||
Math.max(monthlyBudget - Math.abs(totalExpense.value || 0), 0),
|
||||
)
|
||||
|
||||
const todayIncomeValue = computed(() => todaysIncome.value || 0)
|
||||
const todayExpenseValue = computed(() => Math.abs(todaysExpense.value || 0))
|
||||
|
||||
const formatCurrency = (value) => `¥ ${Math.abs(value || 0).toFixed(2)}`
|
||||
const formatAmount = (value) =>
|
||||
`${value >= 0 ? '+' : '-'} ¥${Math.abs(value || 0).toFixed(2)}`
|
||||
const formatTime = (value) =>
|
||||
new Intl.DateTimeFormat('zh-CN', { hour: '2-digit', minute: '2-digit' }).format(
|
||||
new Date(value),
|
||||
)
|
||||
|
||||
const recentTransactions = computed(() => latestTransactions.value.slice(0, 4))
|
||||
|
||||
const categoryMeta = {
|
||||
Food: { icon: 'ph-bowl-food', bg: 'bg-orange-100', color: 'text-orange-600' },
|
||||
Transport: { icon: 'ph-taxi', bg: 'bg-blue-100', color: 'text-blue-600' },
|
||||
Health: { icon: 'ph-heartbeat', bg: 'bg-rose-100', color: 'text-rose-600' },
|
||||
Groceries: { icon: 'ph-basket', bg: 'bg-amber-100', color: 'text-amber-600' },
|
||||
Income: { icon: 'ph-wallet', bg: 'bg-emerald-100', color: 'text-emerald-600' },
|
||||
Uncategorized: { icon: 'ph-note-pencil', bg: 'bg-stone-100', color: 'text-stone-500' },
|
||||
default: { icon: 'ph-note-pencil', bg: 'bg-stone-100', color: 'text-stone-500' },
|
||||
}
|
||||
|
||||
defineEmits(['changeTab'])
|
||||
const getCategoryMeta = (category) => categoryMeta[category] || categoryMeta.default
|
||||
|
||||
transactionStore.ensureInitialized()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6 animate-fade-in pb-28">
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<p class="text-xs text-stone-400 font-bold tracking-wider">HI, ALEX</p>
|
||||
<h1 class="text-2xl font-extrabold text-stone-800">今日概览</h1>
|
||||
</div>
|
||||
<div class="w-10 h-10 rounded-full bg-orange-50 p-0.5 cursor-pointer border border-orange-100"
|
||||
@click="$emit('changeTab', 'settings')">
|
||||
<img src="https://api.dicebear.com/7.x/avataaars/svg?seed=Felix" class="rounded-full" alt="User">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Asset Card -->
|
||||
<div
|
||||
class="w-full h-48 rounded-3xl bg-gradient-warm text-white p-6 shadow-xl shadow-orange-200/50 relative overflow-hidden group transition-all hover:shadow-orange-300/50">
|
||||
<div class="absolute -right-10 -top-10 w-40 h-40 bg-white opacity-10 rounded-full blur-2xl"></div>
|
||||
<div class="absolute left-0 bottom-0 w-full h-1/2 bg-gradient-to-t from-black/10 to-transparent"></div>
|
||||
|
||||
<div class="relative z-10 flex flex-col h-full justify-between">
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-white/80 text-xs font-bold tracking-widest uppercase">本月结余</p>
|
||||
<button
|
||||
class="w-8 h-8 rounded-full bg-white/20 flex items-center justify-center backdrop-blur-md hover:bg-white/30 transition">
|
||||
<i class="ph-bold ph-eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
<h2 class="text-4xl font-extrabold mt-2">¥ 4,285<span
|
||||
class="text-lg font-medium opacity-80">.00</span></h2>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-8">
|
||||
<div>
|
||||
<p class="text-white/70 text-xs mb-0.5 flex items-center gap-1"><i
|
||||
class="ph-bold ph-arrow-down-left"></i> 收入</p>
|
||||
<p class="font-bold text-lg">¥ 12,500</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-white/70 text-xs mb-0.5 flex items-center gap-1"><i
|
||||
class="ph-bold ph-arrow-up-right"></i> 支出</p>
|
||||
<p class="font-bold text-lg">¥ 8,215</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Budget Bar -->
|
||||
<div class="bg-white rounded-2xl p-4 shadow-sm border border-stone-100">
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<span class="text-xs font-bold text-stone-500">本月预算</span>
|
||||
<span class="text-xs font-bold text-stone-800">65%</span>
|
||||
</div>
|
||||
<div class="w-full bg-stone-100 rounded-full h-2.5 overflow-hidden">
|
||||
<div class="bg-gradient-warm h-2.5 rounded-full" style="width: 65%"></div>
|
||||
</div>
|
||||
<div class="flex justify-between mt-2 text-[10px] text-stone-400">
|
||||
<span>已用 ¥8,215</span>
|
||||
<span>剩余 ¥4,285</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Weekly Trend Chart -->
|
||||
<div class="bg-white rounded-3xl p-5 shadow-sm border border-stone-100">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="font-bold text-stone-700">支出趋势</h3>
|
||||
<div class="flex gap-2">
|
||||
<span class="text-[10px] px-2 py-1 rounded-full bg-stone-100 text-stone-500 font-bold">周</span>
|
||||
<span class="text-[10px] px-2 py-1 rounded-full text-stone-400">月</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-end justify-between h-24 gap-2">
|
||||
<!-- Mon -->
|
||||
<div class="flex flex-col items-center gap-1 flex-1">
|
||||
<div class="w-full bg-stone-100 rounded-t-md relative group h-full flex items-end overflow-hidden">
|
||||
<div class="w-full bg-orange-200 group-hover:bg-orange-300 transition-all h-[40%] rounded-t-md">
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-[10px] text-stone-400 font-medium">一</span>
|
||||
</div>
|
||||
<!-- Tue -->
|
||||
<div class="flex flex-col items-center gap-1 flex-1">
|
||||
<div class="w-full bg-stone-100 rounded-t-md relative group h-full flex items-end overflow-hidden">
|
||||
<div class="w-full bg-orange-200 group-hover:bg-orange-300 transition-all h-[60%] rounded-t-md">
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-[10px] text-stone-400 font-medium">二</span>
|
||||
</div>
|
||||
<!-- Wed -->
|
||||
<div class="flex flex-col items-center gap-1 flex-1">
|
||||
<div class="w-full bg-stone-100 rounded-t-md relative group h-full flex items-end overflow-hidden">
|
||||
<div class="w-full bg-orange-200 group-hover:bg-orange-300 transition-all h-[30%] rounded-t-md">
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-[10px] text-stone-400 font-medium">三</span>
|
||||
</div>
|
||||
<!-- Thu (High) -->
|
||||
<div class="flex flex-col items-center gap-1 flex-1">
|
||||
<div class="w-full bg-stone-100 rounded-t-md relative group h-full flex items-end overflow-hidden">
|
||||
<div
|
||||
class="w-full bg-gradient-warm transition-all h-[85%] rounded-t-md shadow-lg shadow-orange-200">
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-[10px] text-stone-800 font-bold">四</span>
|
||||
</div>
|
||||
<!-- Fri -->
|
||||
<div class="flex flex-col items-center gap-1 flex-1">
|
||||
<div class="w-full bg-stone-100 rounded-t-md relative group h-full flex items-end overflow-hidden">
|
||||
<div class="w-full bg-orange-200 group-hover:bg-orange-300 transition-all h-[50%] rounded-t-md">
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-[10px] text-stone-400 font-medium">五</span>
|
||||
</div>
|
||||
<!-- Sat -->
|
||||
<div class="flex flex-col items-center gap-1 flex-1">
|
||||
<div class="w-full bg-stone-100 rounded-t-md relative group h-full flex items-end overflow-hidden">
|
||||
<div class="w-full bg-orange-200 group-hover:bg-orange-300 transition-all h-[20%] rounded-t-md">
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-[10px] text-stone-400 font-medium">六</span>
|
||||
</div>
|
||||
<!-- Sun -->
|
||||
<div class="flex flex-col items-center gap-1 flex-1">
|
||||
<div class="w-full bg-stone-100 rounded-t-md relative group h-full flex items-end overflow-hidden">
|
||||
<div class="w-full bg-orange-200 group-hover:bg-orange-300 transition-all h-[35%] rounded-t-md">
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-[10px] text-stone-400 font-medium">日</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notifications -->
|
||||
<div v-if="notifications.length > 0" class="space-y-3">
|
||||
<div class="flex justify-between items-end">
|
||||
<h3 class="font-bold text-lg text-stone-700">待确认</h3>
|
||||
<span class="text-[10px] bg-orange-100 text-orange-600 px-2 py-1 rounded-full font-bold">自动捕获</span>
|
||||
</div>
|
||||
|
||||
<div v-for="(notif, idx) in notifications" :key="idx"
|
||||
class="glass-warm p-4 rounded-2xl flex items-center gap-4 shadow-sm">
|
||||
<div
|
||||
class="w-12 h-12 rounded-2xl bg-[#1677FF] text-white flex items-center justify-center shadow-md shadow-blue-200">
|
||||
<i class="ph-fill ph-alipay-logo text-2xl"></i>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h4 class="font-bold text-stone-800">{{ notif.merchant }}</h4>
|
||||
<p class="text-xs text-stone-400">{{ notif.time }} · 自动识别</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="font-bold text-stone-800">- ¥{{ notif.amount }}</p>
|
||||
<button @click="confirmNotif(idx)"
|
||||
class="mt-1.5 text-xs bg-gradient-warm text-white px-4 py-1.5 rounded-full font-bold active:scale-95 transition shadow-md shadow-orange-200">
|
||||
入账
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Feature Entries -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="bg-white p-5 rounded-3xl shadow-sm border border-stone-100 flex flex-col justify-between h-32 relative overflow-hidden group"
|
||||
@click="$emit('changeTab', 'analysis')">
|
||||
<div
|
||||
class="absolute right-[-10px] top-[-10px] w-20 h-20 bg-purple-50 rounded-full blur-xl group-hover:bg-purple-100 transition">
|
||||
</div>
|
||||
<div class="w-10 h-10 rounded-full bg-purple-100 text-purple-600 flex items-center justify-center z-10">
|
||||
<i class="ph-fill ph-robot text-xl"></i>
|
||||
</div>
|
||||
<div class="z-10">
|
||||
<h4 class="font-bold text-stone-700">AI 顾问</h4>
|
||||
<p class="text-xs text-stone-400 mt-1">分析我的消费习惯</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="bg-white p-5 rounded-3xl shadow-sm border border-stone-100 flex flex-col justify-between h-32 relative overflow-hidden group">
|
||||
<div
|
||||
class="absolute right-[-10px] top-[-10px] w-20 h-20 bg-emerald-50 rounded-full blur-xl group-hover:bg-emerald-100 transition">
|
||||
</div>
|
||||
<div
|
||||
class="w-10 h-10 rounded-full bg-emerald-100 text-emerald-600 flex items-center justify-center z-10">
|
||||
<i class="ph-fill ph-carrot text-xl"></i>
|
||||
</div>
|
||||
<div class="z-10">
|
||||
<h4 class="font-bold text-stone-700">热量管理</h4>
|
||||
<p class="text-xs text-stone-400 mt-1">今日剩余 750 kcal</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-6 animate-fade-in pb-28">
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<p class="text-xs text-stone-400 font-bold tracking-wider">
|
||||
{{ currentDayLabel }}
|
||||
</p>
|
||||
<h1 class="text-2xl font-extrabold text-stone-800">今日概览</h1>
|
||||
</div>
|
||||
<div
|
||||
class="w-10 h-10 rounded-full bg-orange-50 p-0.5 cursor-pointer border border-orange-100"
|
||||
@click="emit('changeTab', 'settings')"
|
||||
>
|
||||
<img
|
||||
src="https://api.dicebear.com/7.x/avataaars/svg?seed=Echo"
|
||||
class="rounded-full"
|
||||
alt="User"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="w-full h-48 rounded-3xl bg-gradient-warm text-white p-6 shadow-xl shadow-orange-200/50 relative overflow-hidden group transition-all hover:shadow-orange-300/50"
|
||||
>
|
||||
<div class="absolute -right-10 -top-10 w-40 h-40 bg-white opacity-10 rounded-full blur-2xl" />
|
||||
<div class="absolute left-0 bottom-0 w-full h-1/2 bg-gradient-to-t from-black/10 to-transparent" />
|
||||
|
||||
<div class="relative z-10 flex flex-col h-full justify-between">
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-white/80 text-xs font-bold tracking-widest uppercase">本月结余</p>
|
||||
<button
|
||||
class="w-8 h-8 rounded-full bg-white/20 flex items-center justify-center backdrop-blur-md hover:bg-white/30 transition"
|
||||
>
|
||||
<i class="ph-bold ph-eye" />
|
||||
</button>
|
||||
</div>
|
||||
<h2 class="text-4xl font-extrabold mt-2">
|
||||
{{ formatCurrency(balance) }}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-8">
|
||||
<div>
|
||||
<p class="text-white/70 text-xs mb-0.5 flex items-center gap-1">
|
||||
<i class="ph-bold ph-arrow-down-left" /> 收入
|
||||
</p>
|
||||
<p class="font-bold text-lg">{{ formatCurrency(totalIncome) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-white/70 text-xs mb-0.5 flex items-center gap-1">
|
||||
<i class="ph-bold ph-arrow-up-right" /> 支出
|
||||
</p>
|
||||
<p class="font-bold text-lg">{{ formatCurrency(Math.abs(totalExpense)) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-2xl p-4 shadow-sm border border-stone-100">
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<span class="text-xs font-bold text-stone-500">本月预算</span>
|
||||
<span class="text-xs font-bold text-stone-800">
|
||||
{{ Math.round(budgetUsage * 100) }}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="w-full bg-stone-100 rounded-full h-2.5 overflow-hidden">
|
||||
<div
|
||||
class="bg-gradient-warm h-2.5 rounded-full transition-all duration-500"
|
||||
:style="{ width: `${Math.round(budgetUsage * 100)}%` }"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-between mt-2 text-[10px] text-stone-400">
|
||||
<span>已用 {{ formatCurrency(Math.abs(totalExpense)) }}</span>
|
||||
<span>剩余 {{ formatCurrency(remainingBudget) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="bg-white rounded-2xl p-4 border border-stone-100 shadow-sm">
|
||||
<p class="text-xs text-stone-400 mb-2">今日收入</p>
|
||||
<p class="text-xl font-bold text-emerald-600">{{ formatCurrency(todayIncomeValue) }}</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-2xl p-4 border border-stone-100 shadow-sm">
|
||||
<p class="text-xs text-stone-400 mb-2">今日支出</p>
|
||||
<p class="text-xl font-bold text-rose-600">- {{ formatCurrency(todayExpenseValue) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-3xl p-5 shadow-sm border border-stone-100">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="font-bold text-stone-700">最新流水</h3>
|
||||
<button
|
||||
class="text-xs font-bold text-gradient-warm"
|
||||
@click="emit('changeTab', 'list')"
|
||||
>
|
||||
查看全部
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="recentTransactions.length" class="space-y-4">
|
||||
<div
|
||||
v-for="tx in recentTransactions"
|
||||
:key="tx.id"
|
||||
class="flex items-center justify-between"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
:class="[
|
||||
'w-10 h-10 rounded-2xl flex items-center justify-center',
|
||||
getCategoryMeta(tx.category).bg,
|
||||
getCategoryMeta(tx.category).color,
|
||||
]"
|
||||
>
|
||||
<i :class="['ph-fill text-lg', getCategoryMeta(tx.category).icon]" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-bold text-stone-800 text-sm">{{ tx.merchant }}</p>
|
||||
<p class="text-[11px] text-stone-400">
|
||||
{{ formatTime(tx.date) }} · {{ tx.category }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
:class="[
|
||||
'text-sm font-bold',
|
||||
tx.amount < 0 ? 'text-stone-800' : 'text-emerald-600',
|
||||
]"
|
||||
>
|
||||
{{ formatAmount(tx.amount) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-sm text-stone-400 text-center py-4">
|
||||
还没有记录,快去添加第一笔消费吧~
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between items-end">
|
||||
<h3 class="font-bold text-lg text-stone-700">待确认通知</h3>
|
||||
<span class="text-[10px] bg-orange-100 text-orange-600 px-2 py-1 rounded-full font-bold">
|
||||
自动捕获
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="notifications.length"
|
||||
class="space-y-3"
|
||||
>
|
||||
<div
|
||||
v-for="notif in notifications"
|
||||
:key="notif.id"
|
||||
class="glass-warm p-4 rounded-2xl flex items-center gap-4 shadow-sm"
|
||||
>
|
||||
<div
|
||||
class="w-12 h-12 rounded-2xl bg-[#1677FF] text-white flex items-center justify-center shadow-md shadow-blue-200"
|
||||
>
|
||||
<i class="ph-fill ph-bell-ringing text-2xl" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h4 class="font-bold text-stone-800 text-sm">
|
||||
{{ notif.channel }} · {{ formatTime(notif.createdAt) }}
|
||||
</h4>
|
||||
<p class="text-xs text-stone-400 leading-relaxed">
|
||||
{{ notif.text }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<button
|
||||
class="text-xs bg-gradient-warm text-white px-4 py-1.5 rounded-full font-bold active:scale-95 transition shadow-md shadow-orange-200 disabled:opacity-60"
|
||||
:disabled="processingId === notif.id"
|
||||
@click="confirmNotification(notif.id)"
|
||||
>
|
||||
入账
|
||||
</button>
|
||||
<button
|
||||
class="text-[11px] text-stone-400"
|
||||
@click="dismissNotification(notif.id)"
|
||||
>
|
||||
忽略
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-sm text-stone-400 text-center py-4 border border-dashed border-stone-100 rounded-2xl">
|
||||
暂无新通知,享受慢生活 ☕️
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div
|
||||
class="bg-white p-5 rounded-3xl shadow-sm border border-stone-100 flex flex-col justify-between h-32 relative overflow-hidden group"
|
||||
@click="emit('changeTab', 'analysis')"
|
||||
>
|
||||
<div
|
||||
class="absolute right-[-10px] top-[-10px] w-20 h-20 bg-purple-50 rounded-full blur-xl group-hover:bg-purple-100 transition"
|
||||
/>
|
||||
<div
|
||||
class="w-10 h-10 rounded-full bg-purple-100 text-purple-600 flex items-center justify-center z-10"
|
||||
>
|
||||
<i class="ph-fill ph-robot text-xl" />
|
||||
</div>
|
||||
<div class="z-10">
|
||||
<h4 class="font-bold text-stone-700">AI 顾问</h4>
|
||||
<p class="text-xs text-stone-400 mt-1">分析我的消费习惯</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="bg-white p-5 rounded-3xl shadow-sm border border-stone-100 flex flex-col justify-between h-32 relative overflow-hidden group"
|
||||
>
|
||||
<div
|
||||
class="absolute right-[-10px] top-[-10px] w-20 h-20 bg-emerald-50 rounded-full blur-xl group-hover:bg-emerald-100 transition"
|
||||
/>
|
||||
<div
|
||||
class="w-10 h-10 rounded-full bg-emerald-100 text-emerald-600 flex items-center justify-center z-10"
|
||||
>
|
||||
<i class="ph-fill ph-carrot text-xl" />
|
||||
</div>
|
||||
<div class="z-10">
|
||||
<h4 class="font-bold text-stone-700">热量管理</h4>
|
||||
<p class="text-xs text-stone-400 mt-1">今日剩余 750 kcal</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,156 +1,184 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useTransactionStore } from '../stores/transactions'
|
||||
|
||||
// 按日期分组的交易列表(当前为纯前端 Mock 数据,后续会接 Pinia/SQLite)
|
||||
const groupedTransactions = ref([
|
||||
{
|
||||
date: '今天, 5月20日',
|
||||
totalExpense: '42.50',
|
||||
items: [
|
||||
{
|
||||
title: '全家便利店',
|
||||
type: 'expense',
|
||||
amount: '24.50',
|
||||
time: '10:30',
|
||||
icon: 'ph-coffee',
|
||||
bg: 'bg-orange-100',
|
||||
color: 'text-orange-600',
|
||||
note: '早餐',
|
||||
},
|
||||
{
|
||||
title: '滴滴出行',
|
||||
type: 'expense',
|
||||
amount: '18.00',
|
||||
time: '09:15',
|
||||
icon: 'ph-taxi',
|
||||
bg: 'bg-blue-100',
|
||||
color: 'text-blue-600',
|
||||
note: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
date: '昨天, 5月19日',
|
||||
totalExpense: '380.00',
|
||||
items: [
|
||||
{
|
||||
title: '工资收入',
|
||||
type: 'income',
|
||||
amount: '12,500.00',
|
||||
time: '18:00',
|
||||
icon: 'ph-wallet',
|
||||
bg: 'bg-emerald-100',
|
||||
color: 'text-emerald-600',
|
||||
note: '包含奖金',
|
||||
},
|
||||
{
|
||||
title: '乐刻健身',
|
||||
type: 'expense',
|
||||
amount: '380.00',
|
||||
time: '12:30',
|
||||
icon: 'ph-barbell',
|
||||
bg: 'bg-purple-100',
|
||||
color: 'text-purple-600',
|
||||
note: '月卡续费',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
date: '5月18日',
|
||||
totalExpense: '120.00',
|
||||
items: [
|
||||
{
|
||||
title: '山姆会员店',
|
||||
type: 'expense',
|
||||
amount: '120.00',
|
||||
time: '15:40',
|
||||
icon: 'ph-basket',
|
||||
bg: 'bg-red-100',
|
||||
color: 'text-red-600',
|
||||
note: '零食采购',
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
const router = useRouter()
|
||||
const transactionStore = useTransactionStore()
|
||||
const { groupedTransactions, filters, loading } = storeToRefs(transactionStore)
|
||||
const deletingId = ref('')
|
||||
|
||||
const categoryChips = [
|
||||
{ label: '全部', value: 'all', icon: 'ph-asterisk' },
|
||||
{ label: '餐饮', value: 'Food', icon: 'ph-bowl-food' },
|
||||
{ label: '通勤', value: 'Transport', icon: 'ph-taxi' },
|
||||
{ label: '健康', value: 'Health', icon: 'ph-heartbeat' },
|
||||
{ label: '买菜', value: 'Groceries', icon: 'ph-basket' },
|
||||
{ label: '收入', value: 'Income', icon: 'ph-wallet' },
|
||||
{ label: '其他', value: 'Uncategorized', icon: 'ph-dots-three-outline' },
|
||||
]
|
||||
|
||||
const setCategory = (value) => {
|
||||
filters.value.category = value
|
||||
}
|
||||
|
||||
const toggleTodayOnly = () => {
|
||||
filters.value.showOnlyToday = !filters.value.showOnlyToday
|
||||
}
|
||||
|
||||
const formatAmount = (value) =>
|
||||
`${value >= 0 ? '+' : '-'} ¥${Math.abs(value || 0).toFixed(2)}`
|
||||
|
||||
const formatTime = (value) =>
|
||||
new Intl.DateTimeFormat('zh-CN', { hour: '2-digit', minute: '2-digit' }).format(
|
||||
new Date(value),
|
||||
)
|
||||
|
||||
const handleEdit = (item) => {
|
||||
router.push({ name: 'add', query: { id: item.id } })
|
||||
}
|
||||
|
||||
const handleDelete = async (item) => {
|
||||
if (deletingId.value) return
|
||||
const confirmed = window.confirm('确认删除这条流水吗?')
|
||||
if (!confirmed) return
|
||||
deletingId.value = item.id
|
||||
try {
|
||||
await transactionStore.removeTransaction(item.id)
|
||||
} finally {
|
||||
deletingId.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const hasData = computed(() => groupedTransactions.value.length > 0)
|
||||
|
||||
transactionStore.ensureInitialized()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4 pb-10 animate-fade-in">
|
||||
<!-- 顶部月份与统计卡片 -->
|
||||
<div class="space-y-5 pb-10 animate-fade-in">
|
||||
<div class="sticky top-0 z-20 bg-warmOffwhite/95 backdrop-blur-sm py-2">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-2xl font-extrabold text-stone-800">账单明细</h2>
|
||||
<div
|
||||
class="flex items-center gap-2 bg-white border border-stone-100 px-3 py-1.5 rounded-full shadow-sm"
|
||||
<button
|
||||
class="flex items-center gap-2 bg-white border border-stone-100 px-3 py-1.5 rounded-full shadow-sm text-xs font-bold text-stone-500"
|
||||
@click="router.push({ name: 'add' })"
|
||||
>
|
||||
<span class="text-sm font-bold text-stone-600">2024年 5月</span>
|
||||
<i class="ph-bold ph-caret-down text-xs text-stone-400" />
|
||||
</div>
|
||||
<i class="ph-bold ph-plus" /> 新增
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 汇总统计 -->
|
||||
<div class="flex gap-4 overflow-x-auto hide-scrollbar pb-2">
|
||||
<div
|
||||
class="shrink-0 bg-stone-700 text-white px-5 py-3 rounded-2xl shadow-lg shadow-stone-200 flex flex-col justify-center min-w-[140px]"
|
||||
<div class="flex gap-2 overflow-x-auto pb-2 hide-scrollbar">
|
||||
<button
|
||||
v-for="chip in categoryChips"
|
||||
:key="chip.value"
|
||||
class="px-3 py-1.5 rounded-full text-xs font-bold flex items-center gap-1 border transition"
|
||||
:class="
|
||||
filters.category === chip.value
|
||||
? 'bg-gradient-warm text-white border-transparent shadow-sm'
|
||||
: 'bg-white text-stone-500 border-stone-100'
|
||||
"
|
||||
@click="setCategory(chip.value)"
|
||||
>
|
||||
<span class="text-xs text-white/60 mb-1">总支出</span>
|
||||
<span class="font-bold text-xl">¥ 8,215.40</span>
|
||||
</div>
|
||||
<div
|
||||
class="shrink-0 bg-white text-stone-800 border border-stone-100 px-5 py-3 rounded-2xl flex flex-col justify-center min-w-[140px]"
|
||||
<i :class="['text-sm', chip.icon]" />
|
||||
{{ chip.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between text-[11px] text-stone-400 font-bold px-1">
|
||||
<span>长按一条记录可快速编辑</span>
|
||||
<button
|
||||
class="flex items-center gap-1 px-2 py-1 rounded-full border border-stone-200"
|
||||
:class="filters.showOnlyToday ? 'bg-gradient-warm text-white border-transparent' : 'bg-white'"
|
||||
@click="toggleTodayOnly"
|
||||
>
|
||||
<span class="text-xs text-stone-400 mb-1">总收入</span>
|
||||
<span class="font-bold text-xl text-emerald-500">+ ¥ 12,500</span>
|
||||
</div>
|
||||
<i class="ph-bold ph-sun-dim text-xs" />
|
||||
今日
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 按日期分组列表 -->
|
||||
<div class="space-y-6 pb-10">
|
||||
<div v-for="(group, gIndex) in groupedTransactions" :key="gIndex">
|
||||
<div class="flex justify-between items-center mb-3 px-1">
|
||||
<h4 class="text-sm font-bold text-stone-400">{{ group.date }}</h4>
|
||||
<span class="text-xs text-stone-300 font-bold">支出 {{ group.totalExpense }}</span>
|
||||
<div v-if="loading" class="text-center text-stone-400 py-10 text-sm">
|
||||
正在同步本地账本...
|
||||
</div>
|
||||
|
||||
<div v-else-if="hasData" class="space-y-6 pb-10">
|
||||
<div
|
||||
v-for="group in groupedTransactions"
|
||||
:key="group.dayKey"
|
||||
class="space-y-3"
|
||||
>
|
||||
<div class="flex justify-between items-center px-1">
|
||||
<div>
|
||||
<h4 class="text-sm font-bold text-stone-500">{{ group.label }}</h4>
|
||||
<p class="text-[10px] text-stone-400">
|
||||
支出 ¥{{ group.totalExpense.toFixed(2) }}
|
||||
</p>
|
||||
</div>
|
||||
<span class="text-xs text-stone-300 font-bold">{{ group.dayKey }}</span>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-3xl p-1 shadow-sm border border-stone-50">
|
||||
<div class="bg-white rounded-3xl p-1 shadow-sm border border-stone-50 divide-y divide-stone-50">
|
||||
<div
|
||||
v-for="(item, iIndex) in group.items"
|
||||
:key="iIndex"
|
||||
class="flex items-center justify-between p-4 hover:bg-stone-50 rounded-2xl transition"
|
||||
v-for="item in group.items"
|
||||
:key="item.id"
|
||||
class="flex items-center justify-between p-4 hover:bg-stone-50 rounded-2xl transition group"
|
||||
@click="handleEdit(item)"
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<div
|
||||
:class="[
|
||||
'w-10 h-10 rounded-full flex items-center justify-center text-lg shrink-0',
|
||||
item.bg,
|
||||
item.color,
|
||||
]"
|
||||
class="w-11 h-11 rounded-2xl bg-stone-100 flex items-center justify-center text-stone-500 group-active:scale-95 transition"
|
||||
>
|
||||
<i :class="['ph-fill', item.icon]" />
|
||||
<i
|
||||
:class="[
|
||||
'ph-bold text-lg',
|
||||
item.amount < 0 ? 'ph-arrow-up-right' : 'ph-arrow-down-left',
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-bold text-stone-800 text-sm">
|
||||
{{ item.title }}
|
||||
{{ item.merchant }}
|
||||
</p>
|
||||
<p class="text-xs text-stone-400 mt-0.5">
|
||||
{{ item.time }}
|
||||
<span v-if="item.note" class="text-stone-300">| {{ item.note }}</span>
|
||||
{{ formatTime(item.date) }}
|
||||
<span v-if="item.category" class="text-stone-300">
|
||||
· {{ item.category }}
|
||||
</span>
|
||||
<span v-if="item.note" class="text-stone-300">
|
||||
· {{ item.note }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
:class="[
|
||||
'font-bold text-base',
|
||||
item.type === 'expense' ? 'text-stone-800' : 'text-emerald-500',
|
||||
]"
|
||||
>
|
||||
{{ item.type === 'expense' ? '-' : '+' }} {{ item.amount }}
|
||||
</span>
|
||||
<div class="text-right">
|
||||
<p
|
||||
:class="[
|
||||
'font-bold text-base',
|
||||
item.amount < 0 ? 'text-stone-800' : 'text-emerald-600',
|
||||
]"
|
||||
>
|
||||
{{ formatAmount(item.amount) }}
|
||||
</p>
|
||||
<button
|
||||
class="text-[11px] text-stone-400 mt-1"
|
||||
:disabled="deletingId === item.id"
|
||||
@click.stop="handleDelete(item)"
|
||||
>
|
||||
{{ deletingId === item.id ? '删除中...' : '删除' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="py-12 text-center text-stone-400 text-sm border border-dashed border-stone-100 rounded-3xl"
|
||||
>
|
||||
暂无符合筛选条件的记录
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user