This commit is contained in:
2026-01-18 19:00:13 +08:00
parent 9259bda54b
commit 7e05f5e76f
34 changed files with 4200 additions and 145 deletions

View File

View File

@@ -0,0 +1,73 @@
<?php
// +----------------------------------------------------------------------
// | SentCMS [ WE CAN DO IT JUST THINK IT ]
// +----------------------------------------------------------------------
// | Copyright (c) 2024 http://www.tensent.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: molong <molong@tensent.cn> <http://www.tensent.cn>
// +----------------------------------------------------------------------
namespace Modules\Account\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class AccountController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index()
{
return view('account::index');
}
/**
* Show the form for creating a new resource.
*/
public function create()
{
return view('account::create');
}
/**
* Store a newly created resource in storage.
*/
public function store(Request $request): RedirectResponse
{
//
}
/**
* Show the specified resource.
*/
public function show($id)
{
return view('account::show');
}
/**
* Show the form for editing the specified resource.
*/
public function edit($id)
{
return view('account::edit');
}
/**
* Update the specified resource in storage.
*/
public function update(Request $request, $id): RedirectResponse
{
//
}
/**
* Remove the specified resource from storage.
*/
public function destroy($id)
{
//
}
}

View File

View File

View File

@@ -0,0 +1,120 @@
<?php
namespace Modules\Account\Providers;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\ServiceProvider;
class AccountServiceProvider extends ServiceProvider
{
protected string $moduleName = 'Account';
protected string $moduleNameLower = 'account';
/**
* Boot the application events.
*/
public function boot(): void
{
$this->registerCommands();
$this->registerCommandSchedules();
$this->registerTranslations();
$this->registerConfig();
$this->registerViews();
$this->loadMigrationsFrom(module_path($this->moduleName, 'database/migrations'));
}
/**
* Register the service provider.
*/
public function register(): void
{
$this->app->register(EventServiceProvider::class);
$this->app->register(RouteServiceProvider::class);
}
/**
* Register commands in the format of Command::class
*/
protected function registerCommands(): void
{
// $this->commands([]);
}
/**
* Register command Schedules.
*/
protected function registerCommandSchedules(): void
{
// $this->app->booted(function () {
// $schedule = $this->app->make(Schedule::class);
// $schedule->command('inspire')->hourly();
// });
}
/**
* Register translations.
*/
public function registerTranslations(): void
{
$langPath = resource_path('lang/modules/'.$this->moduleNameLower);
if (is_dir($langPath)) {
$this->loadTranslationsFrom($langPath, $this->moduleNameLower);
$this->loadJsonTranslationsFrom($langPath);
} else {
$this->loadTranslationsFrom(module_path($this->moduleName, 'lang'), $this->moduleNameLower);
$this->loadJsonTranslationsFrom(module_path($this->moduleName, 'lang'));
}
}
/**
* Register config.
*/
protected function registerConfig(): void
{
$this->publishes([module_path($this->moduleName, 'config/config.php') => config_path($this->moduleNameLower.'.php')], 'config');
$this->mergeConfigFrom(module_path($this->moduleName, 'config/config.php'), $this->moduleNameLower);
}
/**
* Register views.
*/
public function registerViews(): void
{
$viewPath = resource_path('views/modules/'.$this->moduleNameLower);
$sourcePath = module_path($this->moduleName, 'resources/views');
$this->publishes([$sourcePath => $viewPath], ['views', $this->moduleNameLower.'-module-views']);
$this->loadViewsFrom(array_merge($this->getPublishableViewPaths(), [$sourcePath]), $this->moduleNameLower);
$componentNamespace = str_replace('/', '\\', config('modules.namespace').'\\'.$this->moduleName.'\\'.ltrim(config('modules.paths.generator.component-class.path'), config('modules.paths.app_folder', '')));
Blade::componentNamespace($componentNamespace, $this->moduleNameLower);
}
/**
* Get the services provided by the provider.
*
* @return array<string>
*/
public function provides(): array
{
return [];
}
/**
* @return array<string>
*/
private function getPublishableViewPaths(): array
{
$paths = [];
foreach (config('view.paths') as $path) {
if (is_dir($path.'/modules/'.$this->moduleNameLower)) {
$paths[] = $path.'/modules/'.$this->moduleNameLower;
}
}
return $paths;
}
}

View File

@@ -0,0 +1,38 @@
<?php
// +----------------------------------------------------------------------
// | SentCMS [ WE CAN DO IT JUST THINK IT ]
// +----------------------------------------------------------------------
// | Copyright (c) 2024 http://www.tensent.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: molong <molong@tensent.cn> <http://www.tensent.cn>
// +----------------------------------------------------------------------
namespace Modules\Account\Providers;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
class EventServiceProvider extends ServiceProvider
{
/**
* The event handler mappings for the application.
*
* @var array<string, array<int, string>>
*/
protected $listen = [];
/**
* Indicates if events should be discovered.
*
* @var bool
*/
protected static $shouldDiscoverEvents = true;
/**
* Configure the proper event listeners for email verification.
*
* @return void
*/
protected function configureEmailVerification(): void
{
}
}

View File

@@ -0,0 +1,67 @@
<?php
// +----------------------------------------------------------------------
// | SentCMS [ WE CAN DO IT JUST THINK IT ]
// +----------------------------------------------------------------------
// | Copyright (c) 2024 http://www.tensent.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: molong <molong@tensent.cn> <http://www.tensent.cn>
// +----------------------------------------------------------------------
namespace Modules\Account\Providers;
use Illuminate\Support\Facades\Route;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
class RouteServiceProvider extends ServiceProvider
{
/**
* Called before routes are registered.
*
* Register any model bindings or pattern based filters.
*/
public function boot(): void
{
parent::boot();
}
/**
* Define the routes for the application.
*/
public function map(): void
{
$this->mapApiRoutes();
$this->mapWebRoutes();
$this->mapAdminRoutes();
}
/**
* Define the "web" routes for the application.
*
* These routes all receive session state, CSRF protection, etc.
*/
protected function mapWebRoutes(): void
{
Route::middleware('web')->group(module_path('Account', '/routes/web.php'));
}
/**
* Define the "api" routes for the application.
*
* These routes are typically stateless.
*/
protected function mapApiRoutes(): void
{
Route::middleware('api')->prefix('api')->name('api.')->group(module_path('Account', '/routes/api.php'));
}
/**
* Define the "api" routes for the application.
*
* These routes are typically stateless.
*/
protected function mapAdminRoutes(): void
{
Route::middleware('api')->prefix('admin')->name('admin.')->group(module_path('Account', '/routes/admin.php'));
}
}

View File

View File

@@ -0,0 +1,30 @@
{
"name": "tensent/account",
"description": "",
"authors": [
{
"name": "molong",
"email": "molong@tensent.cn"
}
],
"extra": {
"laravel": {
"providers": [],
"aliases": {
}
}
},
"autoload": {
"psr-4": {
"Modules\\Account\\": "app/",
"Modules\\Account\\Database\\Factories\\": "database/factories/",
"Modules\\Account\\Database\\Seeders\\": "database/seeders/"
}
},
"autoload-dev": {
"psr-4": {
"Modules\\Account\\Tests\\": "tests/"
}
}
}

View File

View File

@@ -0,0 +1,5 @@
<?php
return [
'name' => 'Account',
];

View File

@@ -0,0 +1,22 @@
<?php
// +----------------------------------------------------------------------
// | SentCMS [ WE CAN DO IT JUST THINK IT ]
// +----------------------------------------------------------------------
// | Copyright (c) 2024 http://www.tensent.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: molong <molong@tensent.cn> <http://www.tensent.cn>
// +----------------------------------------------------------------------
namespace Modules\Account\Database\Seeders;
use Illuminate\Database\Seeder;
class AccountDatabaseSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
// $this->call([]);
}
}

View File

@@ -0,0 +1,11 @@
{
"name": "Account",
"alias": "account",
"description": "",
"keywords": [],
"priority": 0,
"providers": [
"Modules\\Account\\Providers\\AccountServiceProvider"
],
"files": []
}

View File

View File

@@ -0,0 +1,14 @@
<?php
// +----------------------------------------------------------------------
// | SentCMS [ WE CAN DO IT JUST THINK IT ]
// +----------------------------------------------------------------------
// | Copyright (c) 2024 http://www.tensent.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: molong <molong@tensent.cn> <http://www.tensent.cn>
// +----------------------------------------------------------------------
use Illuminate\Support\Facades\Route;
use Modules\Account\Controllers\AccountController;
Route::middleware(['auth.check:admin'])->group(function () {
Route::apiResource('account', AccountController::class)->names('account');
});

View File

@@ -0,0 +1,14 @@
<?php
// +----------------------------------------------------------------------
// | SentCMS [ WE CAN DO IT JUST THINK IT ]
// +----------------------------------------------------------------------
// | Copyright (c) 2024 http://www.tensent.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: molong <molong@tensent.cn> <http://www.tensent.cn>
// +----------------------------------------------------------------------
use Illuminate\Support\Facades\Route;
use Modules\Account\Controllers\AccountController;
Route::middleware(['auth.check:api'])->group(function () {
Route::apiResource('account', AccountController::class)->names('account');
});

View File

@@ -0,0 +1,14 @@
<?php
// +----------------------------------------------------------------------
// | SentCMS [ WE CAN DO IT JUST THINK IT ]
// +----------------------------------------------------------------------
// | Copyright (c) 2024 http://www.tensent.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: molong <molong@tensent.cn> <http://www.tensent.cn>
// +----------------------------------------------------------------------
use Illuminate\Support\Facades\Route;
use Modules\Account\Controllers\AccountController;
Route::group([], function () {
Route::resource('account', AccountController::class)->names('account');
});

205
resources/mobile/README.md Normal file
View File

@@ -0,0 +1,205 @@
# 家庭记账APP - 前端开发文档
## 项目概述
这是一个基于 uni-app 框架开发的跨平台家庭记账应用前端,支持个人记账和家庭共享记账功能。
## 已实现功能
### 1. 账单管理
- ✅ 添加账单(收入/支出)
- ✅ 账单列表(按日期分组显示)
- ✅ 月份切换查看
- ✅ 本月收支统计
- ✅ 多种分类(餐饮、交通、购物、娱乐等)
- ✅ 支持备注和日期选择
### 2. 家庭管理
- ✅ 创建家庭
- ✅ 加入家庭(通过邀请码)
- ✅ 查看家庭信息
- ✅ 查看家庭成员列表
- ✅ 家主功能:
- 生成/重新生成邀请码
- 复制邀请码
- 移除家庭成员
- ✅ 退出家庭
- ⏳ 转让家主(待实现)
### 3. 统计分析
- ✅ 收支概览(收入、支出、结余)
- ✅ 月份切换
- ✅ 支出分类统计(带进度条和百分比)
- ✅ 收支趋势图最近7天
- ✅ 可视化图表展示
### 4. 用户中心
- ✅ 登录/注册
- ✅ 个人信息管理
- ✅ 用户协议和隐私政策
## 技术栈
- **框架**: uni-app (Vue 3)
- **状态管理**: Vuex
- **UI组件**: uni-ui
- **HTTP请求**: luch-request
- **样式**: SCSS
## 项目结构
```
resources/mobile/
├── api/ # API接口模块
│ ├── index.js # API自动导入
│ └── modules/
│ ├── auth.js # 认证相关API
│ ├── bill.js # 账单相关API
│ ├── family.js # 家庭管理API
│ ├── sms.js # 短信相关API
│ └── statistics.js # 统计分析API
├── components/ # 公共组件
│ ├── pages/ # 页面组件
│ └── tab-bar/ # 底部导航栏
├── config/ # 配置文件
├── mixins/ # 混入
│ └── auth.js # 登录验证混入
├── pages/ # 页面
│ ├── index/ # 首页
│ ├── account/ # 账单管理
│ │ ├── bill/ # 账单页面
│ │ │ ├── add.vue # 添加账单
│ │ │ └── index.vue # 账单列表
│ │ └── statistics/ # 统计分析
│ │ └── index.vue # 统计页面
│ ├── family/ # 家庭管理
│ │ ├── index.vue # 家庭主页
│ │ ├── create.vue # 创建家庭
│ │ └── join.vue # 加入家庭
│ └── ucenter/ # 用户中心
│ ├── index.vue # 个人中心
│ ├── login/ # 登录
│ └── register/ # 注册
├── static/ # 静态资源
├── store/ # Vuex状态管理
│ ├── index.js # Store自动导入
│ └── modules/
│ ├── user.js # 用户状态
│ └── family.js # 家庭状态
└── utils/ # 工具函数
├── auth.js # 认证工具
├── request.js # HTTP请求封装
└── tool.js # 通用工具
```
## API接口说明
### 账单API (`/api/bill/*`)
- `GET /api/bill/list` - 获取账单列表
- `POST /api/bill/add` - 添加账单
- `POST /api/bill/edit` - 编辑账单
- `POST /api/bill/delete` - 删除账单
- `GET /api/bill/detail` - 获取账单详情
### 家庭API (`/api/family/*`)
- `GET /api/family/info` - 获取家庭信息
- `POST /api/family/create` - 创建家庭
- `POST /api/family/join` - 加入家庭
- `POST /api/family/leave` - 退出家庭
- `GET /api/family/invite-code` - 获取邀请码
- `POST /api/family/regenerate-invite-code` - 重新生成邀请码
- `POST /api/family/remove-member` - 移除家庭成员
- `GET /api/family/members` - 获取家庭成员列表
- `POST /api/family/transfer-owner` - 转让家主
### 统计API (`/api/statistics/*`)
- `GET /api/statistics/overview` - 获取统计概览
- `GET /api/statistics/trend` - 获取收支趋势
- `GET /api/statistics/category` - 获取分类统计
- `GET /api/statistics/monthly` - 获取月度报表
- `GET /api/statistics/yearly` - 获取年度报表
## 功能特点
### 1. 家庭管理
- 一个人只能创建或加入一个家庭
- 家主可以管理家庭成员(添加、移除)
- 家主可以生成邀请码邀请成员
- 支持转让家主功能
### 2. 账单分类
- 支出分类:餐饮、交通、购物、娱乐、医疗、教育、居住、其他
- 收入分类:工资、奖金、投资、兼职、其他
- 每个分类都有独立的图标和颜色
### 3. 数据可视化
- 支出分类统计使用进度条展示占比
- 收支趋势使用柱状图展示最近7天数据
- 支持月份切换查看不同时间段的数据
### 4. 用户体验
- 渐变色主题设计
- 流畅的动画效果
- 下拉刷新支持
- 加载状态提示
- 空数据友好提示
## 开发说明
### 环境要求
- Node.js >= 14
- HBuilderX 或 VS Code
- uni-app CLI
### 安装依赖
```bash
cd resources/mobile
yarn install
```
### 运行项目
- 使用 HBuilderX 运行到不同平台
- 或使用 CLI 命令:`npm run dev:mp-weixin` (微信小程序)
### 构建项目
```bash
npm run build:mp-weixin
```
## 注意事项
1. **API接口**: 需要后端提供对应的接口支持,返回格式统一为:
```json
{
"code": 200,
"message": "success",
"data": {}
}
```
2. **Token认证**: 所有需要登录的接口都需要在请求头中携带Token
```
Authorization: Bearer {token}
```
3. **家庭限制**: 用户只能加入一个家庭,后端需要验证此限制
4. **邀请码**: 邀请码建议使用10位随机字符串确保唯一性
5. **日期格式**: 统一使用 `YYYY-MM-DD` 格式
## 待优化功能
1. 账单编辑功能
2. 转让家主功能
3. 账单搜索和筛选
4. 导出数据功能
5. 预算管理
6. 账单提醒功能
7. 更多的统计图表(饼图、折线图等)
## 作者
molong <ycgpp@126.com>
## 许可证
MIT

View File

@@ -0,0 +1,44 @@
import request from '@/utils/request'
export default {
// 账单列表
list: {
url: '/api/account/bill/list',
name: '账单列表',
get: async function(params) {
return request.get(this.url, {params: params})
}
},
// 添加账单
add: {
url: '/api/account/bill/add',
name: '添加账单',
post: async function(params) {
return request.post(this.url, params)
}
},
// 编辑账单
edit: {
url: '/api/account/bill/edit',
name: '编辑账单',
post: async function(params) {
return request.post(this.url, params)
}
},
// 删除账单
delete: {
url: '/api/account/bill/delete',
name: '删除账单',
post: async function(params) {
return request.post(this.url, params)
}
},
// 账单详情
detail: {
url: '/api/account/bill/detail',
name: '账单详情',
get: async function(params) {
return request.get(this.url, {params: params})
}
}
}

View File

@@ -0,0 +1,76 @@
import request from '@/utils/request'
export default {
// 获取家庭信息
info: {
url: '/api/account/family/info',
name: '家庭信息',
get: async function(params) {
return request.get(this.url, {params: params})
}
},
// 创建家庭
create: {
url: '/api/account/family/create',
name: '创建家庭',
post: async function(params) {
return request.post(this.url, params)
}
},
// 加入家庭
join: {
url: '/api/account/family/join',
name: '加入家庭',
post: async function(params) {
return request.post(this.url, params)
}
},
// 退出家庭
leave: {
url: '/api/account/family/leave',
name: '退出家庭',
post: async function(params) {
return request.post(this.url, params)
}
},
// 获取家庭邀请码
inviteCode: {
url: '/api/account/family/invite-code',
name: '家庭邀请码',
get: async function(params) {
return request.get(this.url, {params: params})
}
},
// 重新生成邀请码
regenerateInviteCode: {
url: '/api/account/family/regenerate-invite-code',
name: '重新生成邀请码',
post: async function(params) {
return request.post(this.url, params)
}
},
// 移除家庭成员
removeMember: {
url: '/api/account/family/remove-member',
name: '移除家庭成员',
post: async function(params) {
return request.post(this.url, params)
}
},
// 获取家庭成员列表
members: {
url: '/api/account/family/members',
name: '家庭成员列表',
get: async function(params) {
return request.get(this.url, {params: params})
}
},
// 转让家主
transferOwner: {
url: '/api/account/family/transfer-owner',
name: '转让家主',
post: async function(params) {
return request.post(this.url, params)
}
}
}

View File

@@ -0,0 +1,44 @@
import request from '@/utils/request'
export default {
// 获取统计概览
overview: {
url: '/api/account/statistics/overview',
name: '统计概览',
get: async function(params) {
return request.get(this.url, {params: params})
}
},
// 获取收支趋势
trend: {
url: '/api/account/statistics/trend',
name: '收支趋势',
get: async function(params) {
return request.get(this.url, {params: params})
}
},
// 获取分类统计
category: {
url: '/api/account/statistics/category',
name: '分类统计',
get: async function(params) {
return request.get(this.url, {params: params})
}
},
// 获取月度报表
monthly: {
url: '/api/account/statistics/monthly',
name: '月度报表',
get: async function(params) {
return request.get(this.url, {params: params})
}
},
// 获取年度报表
yearly: {
url: '/api/account/statistics/yearly',
name: '年度报表',
get: async function(params) {
return request.get(this.url, {params: params})
}
}
}

View File

@@ -6,9 +6,21 @@
{
"path": "pages/account/bill/index"
},
{
"path": "pages/account/bill/add"
},
{
"path": "pages/account/statistics/index"
},
{
"path": "pages/family/index"
},
{
"path": "pages/family/create"
},
{
"path": "pages/family/join"
},
{
"path": "pages/ucenter/index/index"
},

View File

@@ -0,0 +1,378 @@
<template>
<un-pages
:show-nav-bar="true"
nav-bar-title="记一笔"
:show-back="true"
>
<view class="page-content">
<!-- 收支类型切换 -->
<view class="type-switch">
<view
class="type-item"
:class="{active: form.type === 'expense'}"
@tap="switchType('expense')"
>
<text>支出</text>
</view>
<view
class="type-item"
:class="{active: form.type === 'income'}"
@tap="switchType('income')"
>
<text>收入</text>
</view>
</view>
<!-- 金额输入 -->
<view class="amount-section">
<view class="amount-input-wrapper">
<text class="currency">¥</text>
<input
class="amount-input"
type="digit"
v-model="form.amount"
placeholder="0.00"
placeholder-style="color: #ddd"
/>
</view>
</view>
<!-- 分类选择 -->
<view class="form-section">
<view class="section-title">选择分类</view>
<view class="category-grid">
<view
v-for="category in currentCategories"
:key="category.id"
class="category-item"
:class="{active: form.categoryId === category.id}"
@tap="selectCategory(category)"
>
<view class="category-icon" :style="{background: category.color}">
<text>{{ category.icon }}</text>
</view>
<text class="category-name">{{ category.name }}</text>
</view>
</view>
</view>
<!-- 备注 -->
<view class="form-section">
<view class="section-title">备注</view>
<view class="textarea-wrapper">
<textarea
v-model="form.remark"
placeholder="添加备注(可选)"
placeholder-style="color: #ccc"
maxlength="100"
/>
<text class="char-count">{{ form.remark.length }}/100</text>
</view>
</view>
<!-- 日期选择 -->
<view class="form-section">
<view class="section-title">日期</view>
<picker mode="date" :value="form.date" @change="onDateChange">
<view class="picker-wrapper">
<text class="picker-text">{{ form.date }}</text>
<uni-icons type="right" size="16" color="#999"></uni-icons>
</view>
</picker>
</view>
<!-- 提交按钮 -->
<view class="submit-section">
<button class="submit-btn" @tap="handleSubmit" :loading="loading">
保存
</button>
</view>
</view>
</un-pages>
</template>
<script>
export default {
data() {
return {
loading: false,
form: {
type: 'expense',
amount: '',
categoryId: '',
remark: '',
date: this.getTodayDate()
},
expenseCategories: [
{ id: 1, name: '餐饮', icon: '🍜', color: '#FF6B6B' },
{ id: 2, name: '交通', icon: '🚗', color: '#4ECDC4' },
{ id: 3, name: '购物', icon: '🛒', color: '#45B7D1' },
{ id: 4, name: '娱乐', icon: '🎮', color: '#96CEB4' },
{ id: 5, name: '医疗', icon: '💊', color: '#FFEAA7' },
{ id: 6, name: '教育', icon: '📚', color: '#DDA0DD' },
{ id: 7, name: '居住', icon: '🏠', color: '#98D8C8' },
{ id: 8, name: '其他', icon: '📦', color: '#BDC3C7' }
],
incomeCategories: [
{ id: 101, name: '工资', icon: '💰', color: '#2ECC71' },
{ id: 102, name: '奖金', icon: '🎁', color: '#E74C3C' },
{ id: 103, name: '投资', icon: '📈', color: '#3498DB' },
{ id: 104, name: '兼职', icon: '💼', color: '#9B59B6' },
{ id: 105, name: '其他', icon: '💎', color: '#1ABC9C' }
]
}
},
computed: {
currentCategories() {
return this.form.type === 'expense' ? this.expenseCategories : this.incomeCategories
}
},
methods: {
getTodayDate() {
const today = new Date()
const year = today.getFullYear()
const month = String(today.getMonth() + 1).padStart(2, '0')
const day = String(today.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
},
switchType(type) {
this.form.type = type
this.form.categoryId = ''
},
selectCategory(category) {
this.form.categoryId = category.id
},
onDateChange(e) {
this.form.date = e.detail.value
},
async handleSubmit() {
// 验证
if (!this.form.amount || parseFloat(this.form.amount) <= 0) {
uni.showToast({
title: '请输入有效金额',
icon: 'none'
})
return
}
if (!this.form.categoryId) {
uni.showToast({
title: '请选择分类',
icon: 'none'
})
return
}
this.loading = true
try {
const res = await this.$api.bill.add.post({
type: this.form.type,
amount: parseFloat(this.form.amount),
category_id: this.form.categoryId,
remark: this.form.remark,
date: this.form.date
})
if (res.code === 200) {
uni.showToast({
title: '保存成功',
icon: 'success'
})
setTimeout(() => {
uni.navigateBack()
}, 1500)
} else {
uni.showToast({
title: res.message || '保存失败',
icon: 'none'
})
}
} catch (error) {
console.error('保存账单失败', error)
uni.showToast({
title: '保存失败,请重试',
icon: 'none'
})
} finally {
this.loading = false
}
}
}
}
</script>
<style lang="scss" scoped>
.page-content {
padding: 30rpx;
}
.type-switch {
display: flex;
background: #f5f5f5;
border-radius: 50rpx;
padding: 8rpx;
margin-bottom: 40rpx;
.type-item {
flex: 1;
text-align: center;
padding: 20rpx 0;
border-radius: 50rpx;
transition: all 0.3s;
text {
font-size: 28rpx;
color: #999;
}
&.active {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
text {
color: #fff;
font-weight: bold;
}
}
}
}
.amount-section {
background: #fff;
border-radius: 20rpx;
padding: 40rpx 30rpx;
margin-bottom: 30rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.05);
.amount-input-wrapper {
display: flex;
align-items: center;
.currency {
font-size: 60rpx;
font-weight: bold;
color: #333;
margin-right: 20rpx;
}
.amount-input {
flex: 1;
font-size: 60rpx;
font-weight: bold;
color: #333;
}
}
}
.form-section {
background: #fff;
border-radius: 20rpx;
padding: 30rpx;
margin-bottom: 20rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.05);
.section-title {
font-size: 28rpx;
color: #666;
margin-bottom: 20rpx;
}
}
.category-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20rpx;
.category-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 20rpx 0;
.category-icon {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 12rpx;
opacity: 0.6;
transition: all 0.3s;
text {
font-size: 36rpx;
}
}
.category-name {
font-size: 24rpx;
color: #666;
}
&.active {
.category-icon {
opacity: 1;
transform: scale(1.1);
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.15);
}
.category-name {
color: #667eea;
font-weight: bold;
}
}
}
}
.textarea-wrapper {
position: relative;
textarea {
width: 100%;
min-height: 120rpx;
font-size: 28rpx;
color: #333;
}
.char-count {
position: absolute;
right: 0;
bottom: 0;
font-size: 22rpx;
color: #999;
}
}
.picker-wrapper {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20rpx 0;
.picker-text {
font-size: 28rpx;
color: #333;
}
}
.submit-section {
margin-top: 40rpx;
.submit-btn {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
border: none;
border-radius: 50rpx;
height: 90rpx;
line-height: 90rpx;
font-size: 32rpx;
font-weight: bold;
box-shadow: 0 8rpx 24rpx rgba(102, 126, 234, 0.3);
&::after {
border: none;
}
}
}
</style>

View File

@@ -7,9 +7,69 @@
@tab-change="handleTabChange"
>
<view class="page-content">
<view class="empty-state">
<uni-icons type="list" size="100" color="#ddd"></uni-icons>
<text class="empty-text">账单功能开发中</text>
<!-- 统计卡片 -->
<view class="stats-card">
<view class="stat-item">
<text class="stat-label">本月支出</text>
<text class="stat-value expense">¥{{ monthExpense.toFixed(2) }}</text>
</view>
<view class="stat-divider"></view>
<view class="stat-item">
<text class="stat-label">本月收入</text>
<text class="stat-value income">¥{{ monthIncome.toFixed(2) }}</text>
</view>
</view>
<!-- 月份选择 -->
<view class="month-selector">
<picker mode="date" fields="month" :value="currentMonth" @change="onMonthChange">
<view class="month-picker">
<uni-icons type="left" size="20" color="#666"></uni-icons>
<text class="month-text">{{ currentMonth }}</text>
<uni-icons type="right" size="20" color="#666"></uni-icons>
</view>
</picker>
</view>
<!-- 账单列表 -->
<view class="bill-list">
<view v-if="loading" class="loading-state">
<uni-load-more status="loading"></uni-load-more>
</view>
<view v-else-if="billList.length === 0" class="empty-state">
<uni-icons type="list" size="100" color="#ddd"></uni-icons>
<text class="empty-text">暂无账单记录</text>
<button class="add-btn" @tap="addBill">
<uni-icons type="plus" size="18" color="#fff"></uni-icons>
<text>记一笔</text>
</button>
</view>
<view v-else class="bill-group" v-for="(group, date) in groupedBills" :key="date">
<view class="group-header">
<text class="group-date">{{ date }}</text>
<text class="group-amount">支出: ¥{{ group.expense.toFixed(2) }} 收入: ¥{{ group.income.toFixed(2) }}</text>
</view>
<view class="bill-item" v-for="bill in group.bills" :key="bill.id" @tap="editBill(bill)">
<view class="bill-icon" :style="{background: getCategoryColor(bill.category_id)}">
<text>{{ getCategoryIcon(bill.category_id) }}</text>
</view>
<view class="bill-info">
<text class="bill-category">{{ getCategoryName(bill.category_id) }}</text>
<text class="bill-remark" v-if="bill.remark">{{ bill.remark }}</text>
</view>
<view class="bill-amount" :class="bill.type">
<text>{{ bill.type === 'expense' ? '-' : '+' }}¥{{ bill.amount.toFixed(2) }}</text>
</view>
</view>
</view>
</view>
<!-- 添加按钮 -->
<view class="fab-button" @tap="addBill">
<uni-icons type="plus" size="24" color="#fff"></uni-icons>
</view>
</view>
</un-pages>
@@ -17,9 +77,120 @@
<script>
export default {
data() {
return {
loading: false,
currentMonth: this.getCurrentMonth(),
billList: [],
monthExpense: 0,
monthIncome: 0,
categoryMap: {
// 支出分类
1: { name: '餐饮', icon: '🍜', color: '#FF6B6B' },
2: { name: '交通', icon: '🚗', color: '#4ECDC4' },
3: { name: '购物', icon: '🛒', color: '#45B7D1' },
4: { name: '娱乐', icon: '🎮', color: '#96CEB4' },
5: { name: '医疗', icon: '💊', color: '#FFEAA7' },
6: { name: '教育', icon: '📚', color: '#DDA0DD' },
7: { name: '居住', icon: '🏠', color: '#98D8C8' },
8: { name: '其他', icon: '📦', color: '#BDC3C7' },
// 收入分类
101: { name: '工资', icon: '💰', color: '#2ECC71' },
102: { name: '奖金', icon: '🎁', color: '#E74C3C' },
103: { name: '投资', icon: '📈', color: '#3498DB' },
104: { name: '兼职', icon: '💼', color: '#9B59B6' },
105: { name: '其他', icon: '💎', color: '#1ABC9C' }
}
}
},
computed: {
groupedBills() {
const groups = {}
this.billList.forEach(bill => {
const date = bill.date
if (!groups[date]) {
groups[date] = {
bills: [],
expense: 0,
income: 0
}
}
groups[date].bills.push(bill)
if (bill.type === 'expense') {
groups[date].expense += bill.amount
} else {
groups[date].income += bill.amount
}
})
return groups
}
},
onLoad() {
this.loadBillList()
},
onPullDownRefresh() {
this.loadBillList().then(() => {
uni.stopPullDownRefresh()
})
},
methods: {
handleTabChange(path) {
console.log('Tab changed to:', path)
},
getCurrentMonth() {
const today = new Date()
const year = today.getFullYear()
const month = String(today.getMonth() + 1).padStart(2, '0')
return `${year}-${month}`
},
onMonthChange(e) {
this.currentMonth = e.detail.value
this.loadBillList()
},
async loadBillList() {
this.loading = true
try {
const [year, month] = this.currentMonth.split('-')
const res = await this.$api.bill.list.get({
year: parseInt(year),
month: parseInt(month)
})
if (res.code === 200) {
this.billList = res.data?.list || []
this.monthExpense = res.data?.month_expense || 0
this.monthIncome = res.data?.month_income || 0
}
} catch (error) {
console.error('加载账单列表失败', error)
uni.showToast({
title: '加载失败,请重试',
icon: 'none'
})
} finally {
this.loading = false
}
},
addBill() {
uni.navigateTo({
url: '/pages/account/bill/add'
})
},
editBill(bill) {
// TODO: 实现编辑功能
uni.showToast({
title: '编辑功能开发中',
icon: 'none'
})
},
getCategoryName(categoryId) {
return this.categoryMap[categoryId]?.name || '未知'
},
getCategoryIcon(categoryId) {
return this.categoryMap[categoryId]?.icon || '📦'
},
getCategoryColor(categoryId) {
return this.categoryMap[categoryId]?.color || '#BDC3C7'
}
}
}
@@ -27,20 +198,201 @@ export default {
<style lang="scss" scoped>
.page-content {
padding: 40rpx 30rpx;
padding: 30rpx;
padding-bottom: 150rpx;
}
.empty-state {
.stats-card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 200rpx 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 20rpx;
padding: 40rpx 30rpx;
margin-bottom: 30rpx;
box-shadow: 0 8rpx 24rpx rgba(102, 126, 234, 0.3);
.empty-text {
font-size: 28rpx;
color: #999;
margin-top: 40rpx;
.stat-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
.stat-label {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.8);
margin-bottom: 12rpx;
}
.stat-value {
font-size: 36rpx;
font-weight: bold;
color: #fff;
&.expense {
color: #FFEAA7;
}
&.income {
color: #98D8C8;
}
}
}
.stat-divider {
width: 2rpx;
background: rgba(255, 255, 255, 0.3);
margin: 0 30rpx;
}
}
.month-selector {
display: flex;
justify-content: center;
margin-bottom: 30rpx;
.month-picker {
display: flex;
align-items: center;
background: #fff;
border-radius: 50rpx;
padding: 16rpx 40rpx;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.08);
.month-text {
font-size: 28rpx;
color: #333;
font-weight: bold;
margin: 0 20rpx;
}
}
}
.bill-list {
.loading-state {
padding: 60rpx 0;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 120rpx 0;
.empty-text {
font-size: 28rpx;
color: #999;
margin: 30rpx 0 40rpx;
}
.add-btn {
display: flex;
align-items: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
border: none;
border-radius: 50rpx;
padding: 20rpx 50rpx;
font-size: 28rpx;
box-shadow: 0 8rpx 24rpx rgba(102, 126, 234, 0.3);
uni-icons {
margin-right: 10rpx;
}
&::after {
border: none;
}
}
}
}
.bill-group {
margin-bottom: 30rpx;
.group-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20rpx 10rpx;
.group-date {
font-size: 26rpx;
color: #666;
}
.group-amount {
font-size: 24rpx;
color: #999;
}
}
.bill-item {
display: flex;
align-items: center;
background: #fff;
border-radius: 16rpx;
padding: 30rpx;
margin-bottom: 16rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
.bill-icon {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-right: 24rpx;
text {
font-size: 36rpx;
}
}
.bill-info {
flex: 1;
display: flex;
flex-direction: column;
.bill-category {
font-size: 28rpx;
color: #333;
font-weight: bold;
margin-bottom: 8rpx;
}
.bill-remark {
font-size: 24rpx;
color: #999;
}
}
.bill-amount {
font-size: 32rpx;
font-weight: bold;
&.expense {
color: #FF6B6B;
}
&.income {
color: #2ECC71;
}
}
}
}
.fab-button {
position: fixed;
right: 40rpx;
bottom: 160rpx;
width: 100rpx;
height: 100rpx;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 24rpx rgba(102, 126, 234, 0.5);
z-index: 100;
}
</style>

View File

@@ -1,15 +1,121 @@
<template>
<un-pages
:show-nav-bar="true"
nav-bar-title="统计"
:show-back="false"
nav-bar-title="统计分析"
:show-back="true"
:show-tab-bar="true"
@tab-change="handleTabChange"
>
<view class="page-content">
<view class="empty-state">
<uni-icons type="chart" size="100" color="#ddd"></uni-icons>
<text class="empty-text">统计功能开发中</text>
<!-- 月份选择 -->
<view class="month-selector">
<picker mode="date" fields="month" :value="currentMonth" @change="onMonthChange">
<view class="month-picker">
<uni-icons type="left" size="20" color="#666"></uni-icons>
<text class="month-text">{{ currentMonth }}</text>
<uni-icons type="right" size="20" color="#666"></uni-icons>
</view>
</picker>
</view>
<!-- 概览卡片 -->
<view class="overview-section">
<view class="overview-card income">
<text class="card-label">本月收入</text>
<text class="card-amount">¥{{ overview.income.toFixed(2) }}</text>
</view>
<view class="overview-card expense">
<text class="card-label">本月支出</text>
<text class="card-amount">¥{{ overview.expense.toFixed(2) }}</text>
</view>
<view class="overview-card balance">
<text class="card-label">结余</text>
<text class="card-amount">¥{{ overview.balance.toFixed(2) }}</text>
</view>
</view>
<!-- 分类统计 -->
<view class="category-section">
<view class="section-header">
<text class="section-title">支出分类</text>
<text class="section-total"> ¥{{ categoryTotal.toFixed(2) }}</text>
</view>
<view v-if="loading" class="loading-state">
<uni-load-more status="loading"></uni-load-more>
</view>
<view v-else-if="categoryList.length === 0" class="empty-state">
<text class="empty-text">暂无数据</text>
</view>
<view v-else class="category-list">
<view class="category-item" v-for="(item, index) in categoryList" :key="index">
<view class="category-info">
<view class="category-icon" :style="{background: item.color}">
<text>{{ item.icon }}</text>
</view>
<view class="category-details">
<text class="category-name">{{ item.name }}</text>
<view class="progress-bar">
<view class="progress" :style="{width: item.percent + '%', background: item.color}"></view>
</view>
</view>
</view>
<view class="category-amount">
<text class="amount">¥{{ item.amount.toFixed(2) }}</text>
<text class="percent">{{ item.percent }}%</text>
</view>
</view>
</view>
</view>
<!-- 收支趋势 -->
<view class="trend-section">
<view class="section-header">
<text class="section-title">收支趋势</text>
<text class="period-text">最近7天</text>
</view>
<view v-if="loading" class="loading-state">
<uni-load-more status="loading"></uni-load-more>
</view>
<view v-else-if="trendList.length === 0" class="empty-state">
<text class="empty-text">暂无数据</text>
</view>
<view v-else class="trend-chart">
<view class="chart-container">
<view
class="chart-bar"
v-for="(item, index) in trendList"
:key="index"
>
<view class="bar-wrapper">
<view
class="bar income-bar"
:style="{height: item.incomeHeight + '%'}"
></view>
<view
class="bar expense-bar"
:style="{height: item.expenseHeight + '%'}"
></view>
</view>
<text class="bar-label">{{ item.dateLabel }}</text>
</view>
</view>
<view class="chart-legend">
<view class="legend-item">
<view class="legend-color income-legend"></view>
<text class="legend-text">收入</text>
</view>
<view class="legend-item">
<view class="legend-color expense-legend"></view>
<text class="legend-text">支出</text>
</view>
</view>
</view>
</view>
</view>
</un-pages>
@@ -17,9 +123,133 @@
<script>
export default {
data() {
return {
loading: false,
currentMonth: this.getCurrentMonth(),
overview: {
income: 0,
expense: 0,
balance: 0
},
categoryList: [],
categoryTotal: 0,
trendList: [],
categoryMap: {
1: { name: '餐饮', icon: '🍜', color: '#FF6B6B' },
2: { name: '交通', icon: '🚗', color: '#4ECDC4' },
3: { name: '购物', icon: '🛒', color: '#45B7D1' },
4: { name: '娱乐', icon: '🎮', color: '#96CEB4' },
5: { name: '医疗', icon: '💊', color: '#FFEAA7' },
6: { name: '教育', icon: '📚', color: '#DDA0DD' },
7: { name: '居住', icon: '🏠', color: '#98D8C8' },
8: { name: '其他', icon: '📦', color: '#BDC3C7' }
}
}
},
onLoad() {
this.loadStatistics()
},
onPullDownRefresh() {
this.loadStatistics().then(() => {
uni.stopPullDownRefresh()
})
},
methods: {
handleTabChange(path) {
console.log('Tab changed to:', path)
},
getCurrentMonth() {
const today = new Date()
const year = today.getFullYear()
const month = String(today.getMonth() + 1).padStart(2, '0')
return `${year}-${month}`
},
onMonthChange(e) {
this.currentMonth = e.detail.value
this.loadStatistics()
},
async loadStatistics() {
this.loading = true
try {
const [year, month] = this.currentMonth.split('-')
// 获取统计概览
const overviewRes = await this.$api.statistics.overview.get({
year: parseInt(year),
month: parseInt(month)
})
if (overviewRes.code === 200) {
this.overview = overviewRes.data || { income: 0, expense: 0, balance: 0 }
}
// 获取分类统计
const categoryRes = await this.$api.statistics.category.get({
year: parseInt(year),
month: parseInt(month)
})
if (categoryRes.code === 200) {
this.processCategoryData(categoryRes.data || [])
}
// 获取收支趋势
const trendRes = await this.$api.statistics.trend.get({
year: parseInt(year),
month: parseInt(month)
})
if (trendRes.code === 200) {
this.processTrendData(trendRes.data || [])
}
} catch (error) {
console.error('加载统计数据失败', error)
uni.showToast({
title: '加载失败,请重试',
icon: 'none'
})
} finally {
this.loading = false
}
},
processCategoryData(data) {
let total = 0
const list = data.map(item => {
total += item.amount
return item
})
this.categoryTotal = total
this.categoryList = list.map(item => {
const categoryInfo = this.categoryMap[item.category_id] || { name: '未知', icon: '📦', color: '#BDC3C7' }
return {
...categoryInfo,
amount: item.amount,
percent: total > 0 ? Math.round((item.amount / total) * 100) : 0
}
}).sort((a, b) => b.amount - a.amount)
},
processTrendData(data) {
// 找出最大值用于计算高度百分比
let maxIncome = 0
let maxExpense = 0
data.forEach(item => {
maxIncome = Math.max(maxIncome, item.income)
maxExpense = Math.max(maxExpense, item.expense)
})
this.trendList = data.map(item => {
const date = new Date(item.date)
const dateLabel = `${date.getMonth() + 1}/${date.getDate()}`
return {
date: item.date,
dateLabel,
income: item.income,
expense: item.expense,
incomeHeight: maxIncome > 0 ? (item.income / maxIncome) * 100 : 0,
expenseHeight: maxExpense > 0 ? (item.expense / maxExpense) * 100 : 0
}
})
}
}
}
@@ -27,20 +257,296 @@ export default {
<style lang="scss" scoped>
.page-content {
padding: 40rpx 30rpx;
padding: 30rpx;
padding-bottom: 150rpx;
}
.empty-state {
.month-selector {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 200rpx 0;
margin-bottom: 30rpx;
.empty-text {
font-size: 28rpx;
color: #999;
margin-top: 40rpx;
.month-picker {
display: flex;
align-items: center;
background: #fff;
border-radius: 50rpx;
padding: 16rpx 40rpx;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.08);
.month-text {
font-size: 28rpx;
color: #333;
font-weight: bold;
margin: 0 20rpx;
}
}
}
.overview-section {
display: flex;
gap: 20rpx;
margin-bottom: 30rpx;
.overview-card {
flex: 1;
background: #fff;
border-radius: 20rpx;
padding: 30rpx 20rpx;
display: flex;
flex-direction: column;
align-items: center;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.05);
.card-label {
font-size: 24rpx;
color: #999;
margin-bottom: 12rpx;
}
.card-amount {
font-size: 32rpx;
font-weight: bold;
color: #333;
}
&.income .card-amount {
color: #2ECC71;
}
&.expense .card-amount {
color: #FF6B6B;
}
&.balance .card-amount {
color: #667eea;
}
}
}
.category-section {
background: #fff;
border-radius: 20rpx;
padding: 30rpx;
margin-bottom: 30rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.05);
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30rpx;
.section-title {
font-size: 28rpx;
font-weight: bold;
color: #333;
}
.section-total {
font-size: 24rpx;
color: #999;
}
}
.loading-state,
.empty-state {
padding: 60rpx 0;
text-align: center;
.empty-text {
font-size: 26rpx;
color: #999;
}
}
.category-list {
.category-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20rpx 0;
border-bottom: 2rpx solid #f5f5f5;
&:last-child {
border-bottom: none;
}
.category-info {
flex: 1;
display: flex;
align-items: center;
.category-icon {
width: 60rpx;
height: 60rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-right: 20rpx;
text {
font-size: 28rpx;
}
}
.category-details {
flex: 1;
.category-name {
font-size: 26rpx;
color: #333;
margin-bottom: 12rpx;
display: block;
}
.progress-bar {
width: 100%;
height: 8rpx;
background: #f5f5f5;
border-radius: 4rpx;
overflow: hidden;
.progress {
height: 100%;
transition: width 0.3s;
}
}
}
}
.category-amount {
text-align: right;
margin-left: 20rpx;
.amount {
display: block;
font-size: 26rpx;
font-weight: bold;
color: #333;
margin-bottom: 4rpx;
}
.percent {
display: block;
font-size: 22rpx;
color: #999;
}
}
}
}
}
.trend-section {
background: #fff;
border-radius: 20rpx;
padding: 30rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.05);
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30rpx;
.section-title {
font-size: 28rpx;
font-weight: bold;
color: #333;
}
.period-text {
font-size: 24rpx;
color: #999;
}
}
.loading-state,
.empty-state {
padding: 60rpx 0;
text-align: center;
.empty-text {
font-size: 26rpx;
color: #999;
}
}
.trend-chart {
.chart-container {
display: flex;
justify-content: space-between;
align-items: flex-end;
height: 300rpx;
padding: 20rpx 0;
margin-bottom: 20rpx;
.chart-bar {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
.bar-wrapper {
display: flex;
align-items: flex-end;
gap: 4rpx;
margin-bottom: 12rpx;
.bar {
width: 24rpx;
min-height: 4rpx;
border-radius: 4rpx;
transition: height 0.3s;
&.income-bar {
background: #2ECC71;
}
&.expense-bar {
background: #FF6B6B;
}
}
}
.bar-label {
font-size: 22rpx;
color: #999;
}
}
}
.chart-legend {
display: flex;
justify-content: center;
gap: 40rpx;
.legend-item {
display: flex;
align-items: center;
.legend-color {
width: 16rpx;
height: 16rpx;
border-radius: 4rpx;
margin-right: 8rpx;
&.income-legend {
background: #2ECC71;
}
&.expense-legend {
background: #FF6B6B;
}
}
.legend-text {
font-size: 24rpx;
color: #666;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,182 @@
<template>
<un-pages
:show-nav-bar="true"
nav-bar-title="创建家庭"
:show-back="true"
>
<view class="page-content">
<view class="form-section">
<view class="section-title">家庭名称</view>
<view class="input-wrapper">
<input
v-model="familyName"
placeholder="请输入家庭名称"
placeholder-style="color: #ccc"
maxlength="20"
/>
<text class="char-count">{{ familyName.length }}/20</text>
</view>
</view>
<view class="tips-section">
<view class="tip-item">
<uni-icons type="info" size="18" color="#667eea"></uni-icons>
<text class="tip-text">创建后您将成为家主可以管理家庭成员</text>
</view>
<view class="tip-item">
<uni-icons type="info" size="18" color="#667eea"></uni-icons>
<text class="tip-text">每个用户只能创建或加入一个家庭</text>
</view>
<view class="tip-item">
<uni-icons type="info" size="18" color="#667eea"></uni-icons>
<text class="tip-text">创建家庭后家庭成员可以查看和记录家庭账单</text>
</view>
</view>
<view class="submit-section">
<button class="submit-btn" @tap="handleCreate" :loading="loading">
创建家庭
</button>
</view>
</view>
</un-pages>
</template>
<script>
export default {
data() {
return {
familyName: '',
loading: false
}
},
methods: {
async handleCreate() {
if (!this.familyName.trim()) {
uni.showToast({
title: '请输入家庭名称',
icon: 'none'
})
return
}
this.loading = true
try {
const res = await this.$store.dispatch('family/createFamily', this.familyName.trim())
if (res.code === 200) {
uni.showToast({
title: '创建成功',
icon: 'success'
})
setTimeout(() => {
uni.navigateBack()
}, 1500)
} else {
uni.showToast({
title: res.message || '创建失败',
icon: 'none'
})
}
} catch (error) {
console.error('创建家庭失败', error)
uni.showToast({
title: '创建失败,请重试',
icon: 'none'
})
} finally {
this.loading = false
}
}
}
}
</script>
<style lang="scss" scoped>
.page-content {
padding: 30rpx;
}
.form-section {
background: #fff;
border-radius: 20rpx;
padding: 30rpx;
margin-bottom: 30rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.05);
.section-title {
font-size: 28rpx;
color: #666;
margin-bottom: 20rpx;
}
.input-wrapper {
position: relative;
input {
width: 100%;
height: 80rpx;
font-size: 28rpx;
color: #333;
padding: 0;
}
.char-count {
position: absolute;
right: 0;
bottom: -30rpx;
font-size: 22rpx;
color: #999;
}
}
}
.tips-section {
background: rgba(102, 126, 234, 0.05);
border-radius: 20rpx;
padding: 30rpx;
margin-bottom: 40rpx;
.tip-item {
display: flex;
align-items: flex-start;
margin-bottom: 20rpx;
&:last-child {
margin-bottom: 0;
}
uni-icons {
margin-right: 12rpx;
margin-top: 4rpx;
}
.tip-text {
flex: 1;
font-size: 26rpx;
color: #666;
line-height: 1.6;
}
}
}
.submit-section {
margin-top: 60rpx;
.submit-btn {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
border: none;
border-radius: 50rpx;
height: 90rpx;
line-height: 90rpx;
font-size: 32rpx;
font-weight: bold;
box-shadow: 0 8rpx 24rpx rgba(102, 126, 234, 0.3);
&::after {
border: none;
}
}
}
</style>

View File

@@ -0,0 +1,588 @@
<template>
<un-pages
:show-nav-bar="true"
nav-bar-title="家庭管理"
:show-back="true"
>
<view class="page-content">
<view v-if="loading" class="loading-state">
<uni-load-more status="loading"></uni-load-more>
</view>
<!-- 已加入家庭 -->
<view v-else-if="hasFamily" class="family-container">
<!-- 家庭信息卡片 -->
<view class="family-card">
<view class="family-header">
<view class="family-icon">
<text>🏠</text>
</view>
<view class="family-info">
<text class="family-name">{{ familyInfo.name }}</text>
<text class="family-role" v-if="isOwner">我是家主</text>
<text class="family-role" v-else>家庭成员</text>
</view>
</view>
<!-- 家庭邀请码仅家主可见 -->
<view v-if="isOwner" class="invite-section">
<view class="invite-info">
<text class="invite-label">家庭邀请码</text>
<view class="invite-code-wrapper">
<text class="invite-code">{{ inviteCode || '加载中...' }}</text>
<view class="copy-btn" @tap="copyInviteCode">
<uni-icons type="copy" size="16" color="#667eea"></uni-icons>
<text>复制</text>
</view>
</view>
</view>
<button class="regenerate-btn" @tap="regenerateInviteCode" :loading="regenerating">
重新生成邀请码
</button>
</view>
</view>
<!-- 家庭成员列表 -->
<view class="members-section">
<view class="section-header">
<text class="section-title">家庭成员</text>
<text class="member-count">{{ memberList.length }}</text>
</view>
<view class="member-list">
<view class="member-item" v-for="member in memberList" :key="member.id">
<view class="member-avatar">
<text>{{ member.name ? member.name.charAt(0) : 'U' }}</text>
</view>
<view class="member-info">
<text class="member-name">{{ member.name || member.username }}</text>
<text class="member-role" v-if="member.is_owner">家主</text>
</view>
<!-- 移除成员按钮仅家主可见 -->
<view v-if="isOwner && !member.is_owner" class="remove-btn" @tap="removeMember(member)">
<uni-icons type="clear" size="20" color="#FF6B6B"></uni-icons>
</view>
</view>
</view>
<!-- 转让家主仅家主可见 -->
<view v-if="isOwner && memberList.length > 1" class="action-btn">
<button class="secondary-btn" @tap="transferOwner">
转让家主
</button>
</view>
</view>
<!-- 退出家庭按钮 -->
<view class="action-btn">
<button class="exit-btn" @tap="confirmLeaveFamily">
退出家庭
</button>
</view>
</view>
<!-- 未加入家庭 -->
<view v-else class="no-family-container">
<view class="no-family-icon">
<text>🏠</text>
</view>
<text class="no-family-title">您还未加入任何家庭</text>
<text class="no-family-desc">创建或加入家庭与家人一起记账</text>
<view class="action-buttons">
<button class="primary-btn" @tap="goToCreate">
<uni-icons type="plus" size="20" color="#fff"></uni-icons>
<text>创建家庭</text>
</button>
<button class="secondary-btn" @tap="goToJoin">
<uni-icons type="personadd" size="20" color="#667eea"></uni-icons>
<text>加入家庭</text>
</button>
</view>
</view>
</view>
</un-pages>
</template>
<script>
export default {
data() {
return {
loading: false,
familyInfo: {},
inviteCode: '',
regenerating: false,
memberList: []
}
},
computed: {
hasFamily() {
if (!this.$store || !this.$store.getters) {
return false
}
return this.$store.getters['family/hasFamily'] || false
},
isOwner() {
if (!this.$store || !this.$store.state || !this.$store.state.family) {
return false
}
return this.$store.state.family.isOwner || false
}
},
onLoad() {
this.loadFamilyData()
},
onShow() {
// 从其他页面返回时刷新数据
this.loadFamilyData()
},
methods: {
async loadFamilyData() {
if (!this.hasFamily) {
return
}
this.loading = true
try {
// 获取家庭信息
const familyRes = await this.$api.family.info.get()
if (familyRes.code === 200) {
this.familyInfo = familyRes.data
this.$store.commit('family/setFamilyInfo', familyRes.data)
}
// 获取邀请码(仅家主)
if (this.isOwner) {
const codeRes = await this.$api.family.inviteCode.get()
if (codeRes.code === 200) {
this.inviteCode = codeRes.data.invite_code
}
}
// 获取家庭成员列表
const membersRes = await this.$api.family.members.get()
if (membersRes.code === 200) {
this.memberList = membersRes.data || []
}
} catch (error) {
console.error('加载家庭数据失败', error)
uni.showToast({
title: '加载失败,请重试',
icon: 'none'
})
} finally {
this.loading = false
}
},
copyInviteCode() {
uni.setClipboardData({
data: this.inviteCode,
success: () => {
uni.showToast({
title: '邀请码已复制',
icon: 'success'
})
}
})
},
async regenerateInviteCode() {
uni.showModal({
title: '提示',
content: '重新生成邀请码后,旧的邀请码将失效,确定要重新生成吗?',
success: async (res) => {
if (res.confirm) {
this.regenerating = true
try {
const res = await this.$api.family.regenerateInviteCode.post()
if (res.code === 200) {
this.inviteCode = res.data.invite_code
uni.showToast({
title: '邀请码已更新',
icon: 'success'
})
}
} catch (error) {
console.error('重新生成邀请码失败', error)
uni.showToast({
title: '操作失败,请重试',
icon: 'none'
})
} finally {
this.regenerating = false
}
}
}
})
},
removeMember(member) {
uni.showModal({
title: '确认移除',
content: `确定要将 ${member.name || member.username} 移出家庭吗?`,
success: async (res) => {
if (res.confirm) {
try {
const result = await this.$api.family.removeMember.post({
user_id: member.id
})
if (result.code === 200) {
uni.showToast({
title: '移除成功',
icon: 'success'
})
this.loadFamilyData()
}
} catch (error) {
console.error('移除成员失败', error)
uni.showToast({
title: '操作失败,请重试',
icon: 'none'
})
}
}
}
})
},
transferOwner() {
// TODO: 实现转让家主功能
uni.showToast({
title: '功能开发中',
icon: 'none'
})
},
confirmLeaveFamily() {
uni.showModal({
title: '确认退出',
content: '退出家庭后,您将无法查看和记录家庭账单,确定要退出吗?',
success: async (res) => {
if (res.confirm) {
try {
const result = await this.$store.dispatch('family/leaveFamily')
if (result.code === 200) {
uni.showToast({
title: '已退出家庭',
icon: 'success'
})
setTimeout(() => {
uni.navigateBack()
}, 1500)
}
} catch (error) {
console.error('退出家庭失败', error)
uni.showToast({
title: '操作失败,请重试',
icon: 'none'
})
}
}
}
})
},
goToCreate() {
uni.navigateTo({
url: '/pages/family/create'
})
},
goToJoin() {
uni.navigateTo({
url: '/pages/family/join'
})
}
}
}
</script>
<style lang="scss" scoped>
.page-content {
padding: 30rpx;
min-height: 100vh;
}
.loading-state {
padding: 120rpx 0;
}
.no-family-container {
display: flex;
flex-direction: column;
align-items: center;
padding: 120rpx 0;
.no-family-icon {
width: 160rpx;
height: 160rpx;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 40rpx;
box-shadow: 0 8rpx 24rpx rgba(102, 126, 234, 0.3);
text {
font-size: 80rpx;
}
}
.no-family-title {
font-size: 32rpx;
font-weight: bold;
color: #333;
margin-bottom: 16rpx;
}
.no-family-desc {
font-size: 26rpx;
color: #999;
margin-bottom: 60rpx;
}
.action-buttons {
width: 100%;
padding: 0 40rpx;
button {
margin-bottom: 30rpx;
}
}
}
.family-container {
.family-card {
background: #fff;
border-radius: 20rpx;
padding: 40rpx 30rpx;
margin-bottom: 30rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.05);
.family-header {
display: flex;
align-items: center;
margin-bottom: 30rpx;
.family-icon {
width: 100rpx;
height: 100rpx;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 20rpx;
display: flex;
align-items: center;
justify-content: center;
margin-right: 24rpx;
text {
font-size: 50rpx;
}
}
.family-info {
flex: 1;
display: flex;
flex-direction: column;
.family-name {
font-size: 32rpx;
font-weight: bold;
color: #333;
margin-bottom: 8rpx;
}
.family-role {
font-size: 24rpx;
color: #999;
}
}
}
.invite-section {
border-top: 2rpx solid #f5f5f5;
padding-top: 30rpx;
.invite-info {
margin-bottom: 20rpx;
.invite-label {
font-size: 26rpx;
color: #666;
margin-bottom: 16rpx;
display: block;
}
.invite-code-wrapper {
display: flex;
align-items: center;
justify-content: space-between;
background: #f8f8f8;
border-radius: 12rpx;
padding: 20rpx 24rpx;
.invite-code {
font-size: 32rpx;
font-weight: bold;
color: #333;
letter-spacing: 4rpx;
}
.copy-btn {
display: flex;
align-items: center;
padding: 10rpx 20rpx;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 50rpx;
text {
font-size: 24rpx;
color: #fff;
margin-left: 6rpx;
}
}
}
}
.regenerate-btn {
width: 100%;
height: 70rpx;
line-height: 70rpx;
font-size: 26rpx;
background: #f8f8f8;
color: #666;
border: none;
border-radius: 50rpx;
&::after {
border: none;
}
}
}
}
}
.members-section {
background: #fff;
border-radius: 20rpx;
padding: 30rpx;
margin-bottom: 30rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.05);
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30rpx;
.section-title {
font-size: 28rpx;
font-weight: bold;
color: #333;
}
.member-count {
font-size: 24rpx;
color: #999;
}
}
.member-list {
.member-item {
display: flex;
align-items: center;
padding: 24rpx 0;
border-bottom: 2rpx solid #f5f5f5;
&:last-child {
border-bottom: none;
}
.member-avatar {
width: 80rpx;
height: 80rpx;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-right: 20rpx;
text {
font-size: 32rpx;
color: #fff;
font-weight: bold;
}
}
.member-info {
flex: 1;
display: flex;
align-items: center;
.member-name {
font-size: 28rpx;
color: #333;
font-weight: bold;
margin-right: 16rpx;
}
.member-role {
font-size: 22rpx;
color: #667eea;
background: rgba(102, 126, 234, 0.1);
padding: 4rpx 12rpx;
border-radius: 20rpx;
}
}
.remove-btn {
padding: 10rpx;
}
}
}
}
.action-btn {
margin-bottom: 30rpx;
button {
width: 100%;
height: 90rpx;
line-height: 90rpx;
border-radius: 50rpx;
font-size: 28rpx;
&::after {
border: none;
}
}
}
.primary-btn {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
border: none;
box-shadow: 0 8rpx 24rpx rgba(102, 126, 234, 0.3);
display: flex;
align-items: center;
justify-content: center;
uni-icons {
margin-right: 10rpx;
}
}
.secondary-btn {
background: #f8f8f8;
color: #667eea;
border: none;
display: flex;
align-items: center;
justify-content: center;
uni-icons {
margin-right: 10rpx;
}
}
.exit-btn {
background: #fff;
color: #FF6B6B;
border: 2rpx solid #FF6B6B;
}
</style>

View File

@@ -0,0 +1,180 @@
<template>
<un-pages
:show-nav-bar="true"
nav-bar-title="加入家庭"
:show-back="true"
>
<view class="page-content">
<view class="form-section">
<view class="section-title">家庭邀请码</view>
<view class="input-wrapper">
<input
v-model="inviteCode"
placeholder="请输入家庭邀请码"
placeholder-style="color: #ccc"
maxlength="10"
/>
</view>
</view>
<view class="tips-section">
<view class="tip-item">
<uni-icons type="info" size="18" color="#667eea"></uni-icons>
<text class="tip-text">邀请码由家庭家主提供</text>
</view>
<view class="tip-item">
<uni-icons type="info" size="18" color="#667eea"></uni-icons>
<text class="tip-text">每个用户只能加入一个家庭</text>
</view>
<view class="tip-item">
<uni-icons type="info" size="18" color="#667eea"></uni-icons>
<text class="tip-text">加入家庭后可以查看和记录家庭账单</text>
</view>
</view>
<view class="submit-section">
<button class="submit-btn" @tap="handleJoin" :loading="loading">
加入家庭
</button>
</view>
</view>
</un-pages>
</template>
<script>
export default {
data() {
return {
inviteCode: '',
loading: false
}
},
methods: {
async handleJoin() {
if (!this.inviteCode.trim()) {
uni.showToast({
title: '请输入邀请码',
icon: 'none'
})
return
}
if (this.inviteCode.trim().length !== 10) {
uni.showToast({
title: '邀请码格式不正确',
icon: 'none'
})
return
}
this.loading = true
try {
const res = await this.$store.dispatch('family/joinFamily', this.inviteCode.trim())
if (res.code === 200) {
uni.showToast({
title: '加入成功',
icon: 'success'
})
setTimeout(() => {
uni.navigateBack()
}, 1500)
} else {
uni.showToast({
title: res.message || '加入失败',
icon: 'none'
})
}
} catch (error) {
console.error('加入家庭失败', error)
uni.showToast({
title: '加入失败,请重试',
icon: 'none'
})
} finally {
this.loading = false
}
}
}
}
</script>
<style lang="scss" scoped>
.page-content {
padding: 30rpx;
}
.form-section {
background: #fff;
border-radius: 20rpx;
padding: 30rpx;
margin-bottom: 30rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.05);
.section-title {
font-size: 28rpx;
color: #666;
margin-bottom: 20rpx;
}
.input-wrapper {
input {
width: 100%;
height: 80rpx;
font-size: 28rpx;
color: #333;
padding: 0;
letter-spacing: 4rpx;
}
}
}
.tips-section {
background: rgba(102, 126, 234, 0.05);
border-radius: 20rpx;
padding: 30rpx;
margin-bottom: 40rpx;
.tip-item {
display: flex;
align-items: flex-start;
margin-bottom: 20rpx;
&:last-child {
margin-bottom: 0;
}
uni-icons {
margin-right: 12rpx;
margin-top: 4rpx;
}
.tip-text {
flex: 1;
font-size: 26rpx;
color: #666;
line-height: 1.6;
}
}
}
.submit-section {
margin-top: 60rpx;
.submit-btn {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
border: none;
border-radius: 50rpx;
height: 90rpx;
line-height: 90rpx;
font-size: 32rpx;
font-weight: bold;
box-shadow: 0 8rpx 24rpx rgba(102, 126, 234, 0.3);
&::after {
border: none;
}
}
}
</style>

View File

@@ -7,30 +7,112 @@
@tab-change="handleTabChange"
>
<view class="page-content">
<view class="welcome-section">
<image class="logo" src="/static/logo.png" mode="aspectFit"></image>
<text class="welcome-title">欢迎使用家庭记账</text>
<text class="welcome-desc">简单好用的家庭记账助手</text>
<!-- 用户信息头部 -->
<view class="user-header">
<view class="user-avatar">
<text>{{ userName ? userName.charAt(0) : 'U' }}</text>
</view>
<view class="user-info">
<text class="greeting">你好{{ userName || '用户' }}</text>
<text class="date-text">{{ currentDate }}</text>
</view>
</view>
<!-- 收支概览卡片 -->
<view class="overview-card">
<view class="overview-header">
<text class="overview-title">本月概览</text>
<text class="overview-month">{{ currentMonth }}</text>
</view>
<view class="overview-body">
<view class="overview-item income">
<view class="overview-icon">
<text class="icon-text"></text>
</view>
<view class="overview-data">
<text class="overview-label">收入</text>
<text class="overview-amount">¥0.00</text>
</view>
</view>
<view class="overview-divider"></view>
<view class="overview-item expense">
<view class="overview-icon">
<text class="icon-text"></text>
</view>
<view class="overview-data">
<text class="overview-label">支出</text>
<text class="overview-amount">¥0.00</text>
</view>
</view>
</view>
</view>
<!-- 快捷操作 -->
<view class="quick-actions">
<view class="action-card" @tap="navigateTo('/pages/account/bill/add')">
<uni-icons type="plus" size="40" color="#667eea"></uni-icons>
<view class="action-icon-wrapper">
<uni-icons type="plus" size="36" color="#fff"></uni-icons>
</view>
<text class="action-title">记一笔</text>
<text class="action-desc">快速记录</text>
</view>
<view class="action-card" @tap="navigateTo('/pages/account/bill/index')">
<uni-icons type="list" size="40" color="#f093fb"></uni-icons>
<view class="action-icon-wrapper">
<uni-icons type="list" size="36" color="#fff"></uni-icons>
</view>
<text class="action-title">账单</text>
<text class="action-desc">查看明细</text>
</view>
<view class="action-card" @tap="navigateTo('/pages/account/statistics/index')">
<view class="action-icon-wrapper">
<uni-icons type="color-filled" size="36" color="#fff"></uni-icons>
</view>
<text class="action-title">统计</text>
<text class="action-desc">数据分析</text>
</view>
</view>
<!-- 最近记录 -->
<view class="recent-section">
<view class="section-header">
<text class="section-title">最近记录</text>
<text class="section-more" @tap="navigateTo('/pages/account/bill/index')">查看更多 ></text>
<view class="section-more" @tap="navigateTo('/pages/account/bill/index')">
<text>全部</text>
<uni-icons type="right" size="14" color="#667eea"></uni-icons>
</view>
</view>
<view class="empty-list">
<view class="empty-icon">
<uni-icons type="list" size="80" color="#ddd"></uni-icons>
</view>
<text class="empty-text">暂无记录</text>
<button class="quick-add-btn" @tap="navigateTo('/pages/account/bill/add')">
<uni-icons type="plus" size="16" color="#fff"></uni-icons>
<text>记一笔</text>
</button>
</view>
</view>
<!-- 家庭管理 -->
<view class="family-section">
<view class="section-header">
<text class="section-title">家庭管理</text>
<view class="section-more" @tap="navigateTo('/pages/family/index')">
<text>管理</text>
<uni-icons type="right" size="14" color="#667eea"></uni-icons>
</view>
</view>
<view class="family-card" @tap="navigateTo('/pages/family/index')">
<view class="family-icon-wrapper">
<text class="family-icon-text">🏠</text>
</view>
<view class="family-info">
<text class="family-status">{{ hasFamily ? '已加入家庭' : '未加入家庭' }}</text>
<text class="family-desc">{{ hasFamily ? '点击查看家庭成员' : '点击创建或加入家庭' }}</text>
</view>
<view class="family-arrow">
<uni-icons type="right" size="18" color="#ccc"></uni-icons>
</view>
</view>
</view>
</view>
@@ -39,16 +121,49 @@
<script>
export default {
data() {
return {
currentDate: '',
currentMonth: ''
}
},
computed: {
hasFamily() {
if (!this.$store || !this.$store.getters) {
return false
}
return this.$store.getters['family/hasFamily'] || false
},
userName() {
if (!this.$store || !this.$store.state || !this.$store.state.user || !this.$store.state.user.userInfo) {
return ''
}
return this.$store.state.user.userInfo.name || this.$store.state.user.userInfo.username || ''
}
},
onLoad() {
this.updateDate()
},
onShow() {
this.updateDate()
},
methods: {
updateDate() {
const now = new Date()
const weekDays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
const month = now.getMonth() + 1
const date = now.getDate()
const day = weekDays[now.getDay()]
this.currentDate = `${month}${date}${day}`
this.currentMonth = `${month}`
},
handleTabChange(path) {
console.log('Tab changed to:', path)
// tabbar组件已经处理了跳转这里不需要额外处理
},
navigateTo(url) {
uni.showToast({
title: '功能开发中',
icon: 'none',
duration: 2000
uni.navigateTo({
url: url
})
}
}
@@ -57,95 +172,512 @@ export default {
<style lang="scss" scoped>
.page-content {
padding: 40rpx 30rpx;
padding: 30rpx;
padding-bottom: 150rpx;
background: linear-gradient(180deg, #F5F7FA 0%, #FFFFFF 100%);
min-height: 100vh;
position: relative;
&::before {
content: '';
position: absolute;
top: -100rpx;
right: -100rpx;
width: 300rpx;
height: 300rpx;
background: radial-gradient(circle, rgba(102, 126, 234, 0.1) 0%, transparent 70%);
border-radius: 50%;
z-index: 0;
}
&::after {
content: '';
position: absolute;
bottom: 200rpx;
left: -100rpx;
width: 250rpx;
height: 250rpx;
background: radial-gradient(circle, rgba(118, 75, 162, 0.08) 0%, transparent 70%);
border-radius: 50%;
z-index: 0;
}
> view {
position: relative;
z-index: 1;
}
}
.welcome-section {
.user-header {
display: flex;
flex-direction: column;
align-items: center;
padding: 60rpx 0;
padding: 20rpx 0 40rpx;
animation: fadeInDown 0.6s ease-out;
.logo {
width: 160rpx;
height: 160rpx;
margin-bottom: 30rpx;
border-radius: 20rpx;
background: rgba(255, 255, 255, 0.9);
padding: 20rpx;
.user-avatar {
width: 88rpx;
height: 88rpx;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-right: 28rpx;
box-shadow: 0 8rpx 20rpx rgba(102, 126, 234, 0.35);
position: relative;
&::before {
content: '';
position: absolute;
top: -4rpx;
right: -4rpx;
width: 20rpx;
height: 20rpx;
background: #4cd964;
border-radius: 50%;
border: 3rpx solid #fff;
}
text {
font-size: 36rpx;
color: #fff;
font-weight: bold;
}
}
.welcome-title {
font-size: 44rpx;
font-weight: bold;
color: #333;
margin-bottom: 16rpx;
.user-info {
flex: 1;
display: flex;
flex-direction: column;
.greeting {
font-size: 36rpx;
font-weight: bold;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 8rpx;
}
.date-text {
font-size: 26rpx;
color: #999;
font-weight: 500;
}
}
}
.overview-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 24rpx;
padding: 40rpx 32rpx;
margin-bottom: 40rpx;
box-shadow: 0 8rpx 24rpx rgba(102, 126, 234, 0.4);
position: relative;
overflow: hidden;
animation: fadeInUp 0.6s ease-out 0.1s both;
&::before {
content: '';
position: absolute;
top: -50%;
right: -50%;
width: 100%;
height: 100%;
background: radial-gradient(circle, rgba(255, 255, 255, 0.1) 0%, transparent 70%);
}
.welcome-desc {
font-size: 28rpx;
color: #999;
&::after {
content: '';
position: absolute;
bottom: -30%;
left: -30%;
width: 80%;
height: 80%;
background: radial-gradient(circle, rgba(255, 255, 255, 0.05) 0%, transparent 70%);
}
.overview-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 32rpx;
position: relative;
z-index: 1;
.overview-title {
font-size: 30rpx;
color: rgba(255, 255, 255, 0.95);
font-weight: 600;
}
.overview-month {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.85);
background: rgba(255, 255, 255, 0.25);
padding: 8rpx 20rpx;
border-radius: 24rpx;
backdrop-filter: blur(10rpx);
}
}
.overview-body {
display: flex;
align-items: center;
position: relative;
z-index: 1;
.overview-item {
flex: 1;
display: flex;
align-items: center;
transition: transform 0.3s ease;
&:active {
transform: scale(0.98);
}
&.income {
.overview-icon {
background: rgba(46, 204, 113, 0.25);
box-shadow: 0 4rpx 12rpx rgba(46, 204, 113, 0.3);
}
.overview-amount {
background: linear-gradient(135deg, #98D8C8 0%, #7FDBDA 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
}
&.expense {
.overview-icon {
background: rgba(255, 107, 107, 0.25);
box-shadow: 0 4rpx 12rpx rgba(255, 107, 107, 0.3);
}
.overview-amount {
background: linear-gradient(135deg, #FFEAA7 0%, #FD79A8 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
}
.overview-icon {
width: 64rpx;
height: 64rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-right: 20rpx;
transition: transform 0.3s ease;
.icon-text {
font-size: 26rpx;
color: #fff;
font-weight: bold;
}
}
.overview-data {
flex: 1;
display: flex;
flex-direction: column;
.overview-label {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.8);
margin-bottom: 8rpx;
font-weight: 500;
}
.overview-amount {
font-size: 36rpx;
font-weight: bold;
letter-spacing: 0.5rpx;
}
}
}
.overview-divider {
width: 2rpx;
height: 72rpx;
background: rgba(255, 255, 255, 0.3);
margin: 0 32rpx;
}
}
}
.quick-actions {
display: flex;
justify-content: space-between;
margin: 60rpx 0;
margin-bottom: 40rpx;
animation: fadeInUp 0.6s ease-out 0.2s both;
.action-card {
flex: 1;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 20rpx;
padding: 40rpx 20rpx;
margin: 0 10rpx;
background: #fff;
border-radius: 24rpx;
padding: 40rpx 16rpx;
margin: 0 8rpx;
display: flex;
flex-direction: column;
align-items: center;
box-shadow: 0 6rpx 20rpx rgba(0, 0, 0, 0.08);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
overflow: hidden;
&:last-child {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4rpx;
opacity: 0;
transition: opacity 0.3s ease;
}
&:active {
transform: translateY(-4rpx) scale(0.98);
box-shadow: 0 12rpx 28rpx rgba(0, 0, 0, 0.12);
.action-icon-wrapper {
transform: scale(1.1);
}
}
&:nth-child(1) {
.action-icon-wrapper {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
&::before {
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
}
}
&:nth-child(2) {
.action-icon-wrapper {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
}
&::before {
background: linear-gradient(90deg, #f093fb 0%, #f5576c 100%);
}
}
&:nth-child(3) {
.action-icon-wrapper {
background: linear-gradient(135deg, #4ECDC4 0%, #44A08D 100%);
}
&::before {
background: linear-gradient(90deg, #4ECDC4 0%, #44A08D 100%);
}
}
.action-icon-wrapper {
width: 88rpx;
height: 88rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 20rpx;
box-shadow: 0 6rpx 16rpx rgba(0, 0, 0, 0.15);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.action-title {
font-size: 28rpx;
color: #fff;
margin-top: 20rpx;
font-size: 30rpx;
color: #333;
font-weight: bold;
margin-bottom: 8rpx;
}
.action-desc {
font-size: 24rpx;
color: #999;
font-weight: 500;
}
}
}
.recent-section,
.family-section {
background: #fff;
border-radius: 24rpx;
padding: 32rpx;
margin-bottom: 30rpx;
box-shadow: 0 6rpx 20rpx rgba(0, 0, 0, 0.08);
animation: fadeInUp 0.6s ease-out both;
}
.recent-section {
margin-top: 40rpx;
animation-delay: 0.3s;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30rpx;
.family-section {
animation-delay: 0.4s;
}
.section-title {
font-size: 32rpx;
font-weight: bold;
color: #333;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 32rpx;
.section-more {
font-size: 26rpx;
color: #667eea;
}
.section-title {
font-size: 30rpx;
font-weight: bold;
background: linear-gradient(135deg, #333 0%, #666 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.empty-list {
background: #fff;
.section-more {
display: flex;
align-items: center;
font-size: 26rpx;
color: #667eea;
font-weight: 500;
padding: 8rpx 16rpx;
border-radius: 20rpx;
padding: 80rpx 40rpx;
text-align: center;
background: rgba(102, 126, 234, 0.08);
transition: all 0.3s ease;
.empty-text {
font-size: 28rpx;
color: #999;
&:active {
background: rgba(102, 126, 234, 0.15);
}
text {
margin-right: 6rpx;
}
}
}
.empty-list {
display: flex;
flex-direction: column;
align-items: center;
padding: 60rpx 0;
.empty-icon {
margin-bottom: 20rpx;
opacity: 0.5;
}
.empty-text {
font-size: 26rpx;
color: #999;
margin-bottom: 30rpx;
}
.quick-add-btn {
display: flex;
align-items: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
border: none;
border-radius: 50rpx;
padding: 16rpx 36rpx;
font-size: 26rpx;
box-shadow: 0 4rpx 12rpx rgba(102, 126, 234, 0.3);
transition: all 0.3s ease;
&:active {
transform: scale(0.95);
box-shadow: 0 6rpx 16rpx rgba(102, 126, 234, 0.4);
}
uni-icons {
margin-right: 8rpx;
}
&::after {
border: none;
}
}
}
.family-card {
display: flex;
align-items: center;
padding: 12rpx 0;
transition: transform 0.3s ease;
&:active {
transform: translateX(4rpx);
}
.family-icon-wrapper {
width: 80rpx;
height: 80rpx;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 20rpx;
display: flex;
align-items: center;
justify-content: center;
margin-right: 24rpx;
box-shadow: 0 6rpx 16rpx rgba(102, 126, 234, 0.3);
transition: all 0.3s ease;
.family-icon-text {
font-size: 40rpx;
}
}
.family-info {
flex: 1;
display: flex;
flex-direction: column;
.family-status {
font-size: 30rpx;
font-weight: bold;
color: #333;
margin-bottom: 8rpx;
}
.family-desc {
font-size: 24rpx;
color: #999;
font-weight: 500;
}
}
.family-arrow {
padding: 8rpx;
}
}
// 动画关键帧
@keyframes fadeInDown {
from {
opacity: 0;
transform: translateY(-30rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(30rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@@ -9,43 +9,135 @@
<view class="page-content">
<!-- 用户信息头部 -->
<view class="user-header">
<view class="header-bg"></view>
<view class="user-avatar">
<uni-icons type="person-filled" size="60" color="#fff"></uni-icons>
<uni-icons type="person-filled" size="70" color="#fff"></uni-icons>
<view class="vip-badge" v-if="isLogin">
<text>VIP</text>
</view>
</view>
<view class="user-info">
<text class="user-name">{{ userInfo.nickname || '未登录' }}</text>
<text class="user-desc">{{ userInfo.username || '点击登录' }}</text>
<text class="user-name">{{ userInfo.nickname || userInfo.username || '未登录' }}</text>
<text class="user-desc" v-if="isLogin">{{ userInfo.email || '暂无邮箱' }}</text>
<text class="user-desc" v-else>点击登录体验更多功能</text>
</view>
<view class="edit-profile" @tap="handleEditProfile" v-if="isLogin">
<uni-icons type="compose" size="18" color="rgba(255,255,255,0.8)"></uni-icons>
</view>
</view>
<!-- 数据统计卡片 -->
<view class="stats-section" v-if="isLogin">
<view class="stat-item">
<text class="stat-value">{{ stats.billCount || 0 }}</text>
<text class="stat-label">账单数</text>
</view>
<view class="stat-divider"></view>
<view class="stat-item">
<text class="stat-value">{{ stats.days || 0 }}</text>
<text class="stat-label">记账天数</text>
</view>
<view class="stat-divider"></view>
<view class="stat-item">
<text class="stat-value">{{ stats.familyMembers || 0 }}</text>
<text class="stat-label">家庭成员</text>
</view>
</view>
<!-- 快捷功能 -->
<view class="quick-section">
<view class="section-title">快捷功能</view>
<view class="quick-grid">
<view class="quick-item" @tap="navigateTo('/pages/account/bill/add')">
<view class="quick-icon" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);">
<uni-icons type="plus" size="24" color="#fff"></uni-icons>
</view>
<text class="quick-text">记一笔</text>
</view>
<view class="quick-item" @tap="navigateTo('/pages/family/index')">
<view class="quick-icon" style="background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);">
<text class="emoji-icon">👥</text>
</view>
<text class="quick-text">家庭管理</text>
</view>
<view class="quick-item" @tap="navigateTo('/pages/account/statistics/index')">
<view class="quick-icon" style="background: linear-gradient(135deg, #4ECDC4 0%, #44A08D 100%);">
<uni-icons type="chart" size="24" color="#fff"></uni-icons>
</view>
<text class="quick-text">统计分析</text>
</view>
<view class="quick-item" @tap="handleExport">
<view class="quick-icon" style="background: linear-gradient(135deg, #fa709a 0%, #fee140 100%);">
<uni-icons type="download" size="24" color="#fff"></uni-icons>
</view>
<text class="quick-text">数据导出</text>
</view>
</view>
</view>
<!-- 功能菜单 -->
<view class="menu-section">
<view class="menu-item" @tap="navigateTo('/pages/ucenter/profile')">
<view class="menu-left">
<uni-icons type="gear" size="20" color="#667eea"></uni-icons>
<text class="menu-text">个人设置</text>
<view class="section-title">设置</view>
<view class="menu-list">
<view class="menu-item" @tap="navigateTo('/pages/ucenter/profile')">
<view class="menu-left">
<view class="menu-icon-wrapper">
<uni-icons type="gear" size="20" color="#667eea"></uni-icons>
</view>
<view class="menu-content">
<text class="menu-text">个人设置</text>
<text class="menu-desc">修改个人信息</text>
</view>
</view>
<uni-icons type="right" size="16" color="#ccc"></uni-icons>
</view>
<uni-icons type="right" size="16" color="#ccc"></uni-icons>
</view>
<view class="menu-item" @tap="navigateTo('/pages/ucenter/help')">
<view class="menu-left">
<uni-icons type="help" size="20" color="#667eea"></uni-icons>
<text class="menu-text">帮助中心</text>
<view class="menu-item" @tap="navigateTo('/pages/ucenter/help')">
<view class="menu-left">
<view class="menu-icon-wrapper">
<uni-icons type="help" size="20" color="#667eea"></uni-icons>
</view>
<view class="menu-content">
<text class="menu-text">帮助中心</text>
<text class="menu-desc">常见问题解答</text>
</view>
</view>
<uni-icons type="right" size="16" color="#ccc"></uni-icons>
</view>
<uni-icons type="right" size="16" color="#ccc"></uni-icons>
</view>
<view class="menu-item" @tap="navigateTo('/pages/ucenter/about')">
<view class="menu-left">
<uni-icons type="info" size="20" color="#667eea"></uni-icons>
<text class="menu-text">关于我们</text>
<view class="menu-item" @tap="navigateTo('/pages/ucenter/about')">
<view class="menu-left">
<view class="menu-icon-wrapper">
<uni-icons type="info" size="20" color="#667eea"></uni-icons>
</view>
<view class="menu-content">
<text class="menu-text">关于我们</text>
<text class="menu-desc">版本信息</text>
</view>
</view>
<uni-icons type="right" size="16" color="#ccc"></uni-icons>
</view>
<view class="menu-item" @tap="handleClearCache">
<view class="menu-left">
<view class="menu-icon-wrapper">
<uni-icons type="trash" size="20" color="#667eea"></uni-icons>
</view>
<view class="menu-content">
<text class="menu-text">清除缓存</text>
<text class="menu-desc">释放存储空间</text>
</view>
</view>
<text class="cache-size">{{ cacheSize }}</text>
</view>
<uni-icons type="right" size="16" color="#ccc"></uni-icons>
</view>
</view>
<!-- 退出登录按钮 -->
<view class="logout-section" v-if="isLogin">
<button class="logout-btn" @tap="handleLogout">退出登录</button>
<!-- 登录/退出按钮 -->
<view class="auth-section">
<button class="auth-btn login-btn" @tap="handleLogin" v-if="!isLogin">
立即登录
</button>
<button class="auth-btn logout-btn" @tap="handleLogout" v-else>
退出登录
</button>
</view>
</view>
</un-pages>
@@ -58,11 +150,18 @@ export default {
data() {
return {
userInfo: {},
isLogin: false
isLogin: false,
stats: {
billCount: 0,
days: 0,
familyMembers: 0
},
cacheSize: '0 MB'
}
},
onLoad() {
this.checkLoginStatus()
this.calculateCacheSize()
},
onShow() {
this.checkLoginStatus()
@@ -72,10 +171,58 @@ export default {
checkLoginStatus() {
this.isLogin = isLogin()
this.userInfo = uni.getStorageSync('userInfo') || {}
if (this.isLogin) {
this.loadUserStats()
}
},
// 加载用户统计数据
loadUserStats() {
// 这里应该调用API获取实际数据
// 暂时使用模拟数据
this.stats = {
billCount: 128,
days: 45,
familyMembers: 3
}
},
// 计算缓存大小
calculateCacheSize() {
try {
const info = uni.getStorageInfoSync()
const size = (info.currentSize / 1024).toFixed(2)
this.cacheSize = `${size} MB`
} catch (e) {
this.cacheSize = '0 MB'
}
},
// 页面跳转
navigateTo(url) {
if (!this.isLogin && url !== '/pages/ucenter/login/index') {
uni.navigateTo({
url: '/pages/ucenter/login/index'
})
return
}
// 功能开发中提示
uni.showToast({
title: '功能开发中',
icon: 'none',
duration: 2000
})
},
// 编辑个人资料
handleEditProfile() {
this.navigateTo('/pages/ucenter/profile')
},
// 数据导出
handleExport() {
if (!this.isLogin) {
uni.navigateTo({
url: '/pages/ucenter/login/index'
@@ -90,6 +237,38 @@ export default {
})
},
// 清除缓存
handleClearCache() {
uni.showModal({
title: '提示',
content: '确定要清除缓存吗?',
success: (res) => {
if (res.confirm) {
try {
uni.clearStorageSync()
this.calculateCacheSize()
uni.showToast({
title: '缓存已清除',
icon: 'success'
})
} catch (e) {
uni.showToast({
title: '清除失败',
icon: 'none'
})
}
}
}
})
},
// 登录
handleLogin() {
uni.navigateTo({
url: '/pages/ucenter/login/index'
})
},
// 退出登录
handleLogout() {
uni.showModal({
@@ -98,6 +277,7 @@ export default {
success: (res) => {
if (res.confirm) {
logout()
this.checkLoginStatus()
}
}
})
@@ -113,92 +293,365 @@ export default {
<style lang="scss" scoped>
.page-content {
padding: 40rpx 30rpx;
padding: 30rpx;
padding-bottom: 150rpx;
background: linear-gradient(180deg, #F5F7FA 0%, #FFFFFF 100%);
min-height: 100vh;
position: relative;
&::before {
content: '';
position: absolute;
top: -50rpx;
right: -80rpx;
width: 280rpx;
height: 280rpx;
background: radial-gradient(circle, rgba(102, 126, 234, 0.08) 0%, transparent 70%);
border-radius: 50%;
z-index: 0;
}
> view {
position: relative;
z-index: 1;
}
}
.user-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 20rpx;
border-radius: 24rpx;
padding: 60rpx 40rpx;
margin: 40rpx 0;
margin: 20rpx 0 30rpx;
display: flex;
align-items: center;
position: relative;
overflow: hidden;
box-shadow: 0 8rpx 24rpx rgba(102, 126, 234, 0.35);
animation: fadeInDown 0.6s ease-out;
.header-bg {
position: absolute;
top: -50%;
right: -50%;
width: 100%;
height: 100%;
background: radial-gradient(circle, rgba(255, 255, 255, 0.1) 0%, transparent 70%);
}
.user-avatar {
width: 120rpx;
height: 120rpx;
background: rgba(255, 255, 255, 0.3);
background: rgba(255, 255, 255, 0.25);
border-radius: 60rpx;
display: flex;
align-items: center;
justify-content: center;
margin-right: 30rpx;
margin-right: 28rpx;
position: relative;
backdrop-filter: blur(10rpx);
border: 3rpx solid rgba(255, 255, 255, 0.3);
box-shadow: 0 8rpx 20rpx rgba(0, 0, 0, 0.15);
.vip-badge {
position: absolute;
bottom: -6rpx;
right: -6rpx;
background: linear-gradient(135deg, #FFD700 0%, #FFA500 100%);
border-radius: 12rpx;
padding: 4rpx 12rpx;
border: 2rpx solid #fff;
box-shadow: 0 4rpx 8rpx rgba(0, 0, 0, 0.2);
text {
font-size: 18rpx;
color: #fff;
font-weight: bold;
}
}
}
.user-info {
flex: 1;
display: flex;
flex-direction: column;
position: relative;
z-index: 1;
.user-name {
font-size: 36rpx;
font-weight: bold;
color: #fff;
margin-bottom: 16rpx;
margin-bottom: 12rpx;
text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
}
.user-desc {
font-size: 28rpx;
font-size: 26rpx;
color: rgba(255, 255, 255, 0.9);
font-weight: 500;
}
}
.edit-profile {
padding: 16rpx;
background: rgba(255, 255, 255, 0.2);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
position: relative;
z-index: 1;
transition: all 0.3s ease;
&:active {
transform: scale(0.95);
background: rgba(255, 255, 255, 0.3);
}
}
}
.menu-section {
.stats-section {
background: #fff;
border-radius: 20rpx;
overflow: hidden;
border-radius: 24rpx;
padding: 40rpx 30rpx;
margin-bottom: 30rpx;
display: flex;
align-items: center;
box-shadow: 0 6rpx 20rpx rgba(0, 0, 0, 0.08);
animation: fadeInUp 0.6s ease-out 0.1s both;
.menu-item {
.stat-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
padding: 30rpx 40rpx;
border-bottom: 1rpx solid #f5f5f5;
transition: transform 0.3s ease;
&:last-child {
border-bottom: none;
&:active {
transform: scale(0.95);
}
.menu-left {
display: flex;
align-items: center;
.stat-value {
font-size: 48rpx;
font-weight: bold;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 8rpx;
}
.menu-text {
font-size: 32rpx;
.stat-label {
font-size: 26rpx;
color: #999;
font-weight: 500;
}
}
.stat-divider {
width: 2rpx;
height: 60rpx;
background: #f0f0f0;
margin: 0 20rpx;
}
}
.quick-section {
margin-bottom: 30rpx;
animation: fadeInUp 0.6s ease-out 0.2s both;
.section-title {
font-size: 28rpx;
font-weight: bold;
color: #333;
margin-bottom: 20rpx;
padding-left: 8rpx;
}
.quick-grid {
background: #fff;
border-radius: 24rpx;
padding: 30rpx;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20rpx;
box-shadow: 0 6rpx 20rpx rgba(0, 0, 0, 0.08);
.quick-item {
display: flex;
flex-direction: column;
align-items: center;
transition: transform 0.3s ease;
&:active {
transform: translateY(-4rpx);
}
.quick-icon {
width: 96rpx;
height: 96rpx;
border-radius: 24rpx;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 16rpx;
box-shadow: 0 6rpx 16rpx rgba(0, 0, 0, 0.12);
.emoji-icon {
font-size: 44rpx;
}
}
.quick-text {
font-size: 24rpx;
color: #333;
margin-left: 20rpx;
font-weight: 500;
}
}
}
}
.logout-section {
margin-top: 60rpx;
.menu-section {
margin-bottom: 30rpx;
animation: fadeInUp 0.6s ease-out 0.3s both;
.logout-btn {
width: 100%;
height: 90rpx;
line-height: 90rpx;
border-radius: 45rpx;
.section-title {
font-size: 28rpx;
font-weight: bold;
color: #333;
margin-bottom: 20rpx;
padding-left: 8rpx;
}
.menu-list {
background: #fff;
color: #f56c6c;
border-radius: 24rpx;
overflow: hidden;
box-shadow: 0 6rpx 20rpx rgba(0, 0, 0, 0.08);
.menu-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 32rpx 30rpx;
border-bottom: 1rpx solid #f5f5f5;
transition: all 0.3s ease;
&:last-child {
border-bottom: none;
}
&:active {
background: #f8f8f8;
}
.menu-left {
display: flex;
align-items: center;
flex: 1;
.menu-icon-wrapper {
width: 64rpx;
height: 64rpx;
background: rgba(102, 126, 234, 0.1);
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
margin-right: 20rpx;
}
.menu-content {
display: flex;
flex-direction: column;
.menu-text {
font-size: 30rpx;
color: #333;
font-weight: 500;
margin-bottom: 6rpx;
}
.menu-desc {
font-size: 24rpx;
color: #999;
}
}
}
.cache-size {
font-size: 24rpx;
color: #999;
font-weight: 500;
}
}
}
}
.auth-section {
margin-top: 40rpx;
padding: 0 20rpx;
animation: fadeInUp 0.6s ease-out 0.4s both;
.auth-btn {
width: 100%;
height: 96rpx;
border-radius: 48rpx;
font-size: 32rpx;
border: 2rpx solid #f56c6c;
font-weight: bold;
display: flex;
align-items: center;
justify-content: center;
border: none;
box-shadow: 0 6rpx 20rpx rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
&::after {
border: none;
}
&:active {
transform: scale(0.98);
}
}
.login-btn {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
box-shadow: 0 8rpx 24rpx rgba(102, 126, 234, 0.4);
}
.logout-btn {
background: #fff;
color: #f56c6c;
border: 2rpx solid #f56c6c;
box-shadow: 0 6rpx 20rpx rgba(245, 108, 108, 0.15);
&:active {
background: #fef0f0;
}
}
}
@keyframes fadeInDown {
from {
opacity: 0;
transform: translateY(-30rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(30rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@@ -0,0 +1,95 @@
import tool from '../../utils/tool'
import api from '@/api'
export default{
state: {
familyId: tool.data.get('familyId') || '',
familyInfo: tool.data.get('familyInfo') || {},
isOwner: tool.data.get('isOwner') || false
},
mutations:{
setFamilyInfo(state, data){
if(data && data.id){
tool.data.set('familyId', data.id)
state.familyId = data.id
tool.data.set('familyInfo', data)
state.familyInfo = data
}else{
tool.data.set('familyId', '')
state.familyId = ''
tool.data.set('familyInfo', {})
state.familyInfo = {}
}
},
setIsOwner(state, isOwner){
tool.data.set('isOwner', isOwner)
state.isOwner = isOwner
},
clearFamily(state){
tool.data.set('familyId', '')
state.familyId = ''
tool.data.set('familyInfo', {})
state.familyInfo = {}
tool.data.set('isOwner', false)
state.isOwner = false
}
},
getters:{
hasFamily(state){
return !!state.familyId
}
},
actions:{
async getFamilyInfo({commit}){
try {
const res = await api.family.info.get()
if(res.data){
commit('setFamilyInfo', res.data)
commit('setIsOwner', res.data.is_owner || false)
}
return res
}catch(e){
console.error('获取家庭信息失败', e)
throw e
}
},
async createFamily({commit}, familyName){
try {
const res = await api.family.create.post({name: familyName})
if(res.data){
commit('setFamilyInfo', res.data)
commit('setIsOwner', true)
}
return res
}catch(e){
console.error('创建家庭失败', e)
throw e
}
},
async joinFamily({commit}, inviteCode){
try {
const res = await api.family.join.post({invite_code: inviteCode})
if(res.data){
commit('setFamilyInfo', res.data)
commit('setIsOwner', res.data.is_owner || false)
}
return res
}catch(e){
console.error('加入家庭失败', e)
throw e
}
},
async leaveFamily({commit}){
try {
const res = await api.family.leave.post()
if(res.code === 200){
commit('clearFamily')
}
return res
}catch(e){
console.error('退出家庭失败', e)
throw e
}
}
}
}