feat: 实现分类管理功能,包括数据模型、仓库层、服务层和数据库初始化脚本
This commit is contained in:
@@ -1,6 +1,259 @@
|
||||
|
||||
-- Insert System Categories
|
||||
-- =====================================================
|
||||
-- System Categories (for refund, reimbursement, etc.)
|
||||
-- =====================================================
|
||||
INSERT INTO `system_categories` (`code`, `name`, `icon`, `type`, `is_system`) VALUES
|
||||
('refund', '退款', 'mdi:cash-refund', 'income', 1),
|
||||
('reimbursement', '报销', 'mdi:receipt-text-check', 'income', 1)
|
||||
ON DUPLICATE KEY UPDATE `name`=VALUES(`name`), `icon`=VALUES(`icon`), `type`=VALUES(`type`);
|
||||
|
||||
-- =====================================================
|
||||
-- Default Categories Template Data
|
||||
-- These templates will be copied to new users on registration
|
||||
-- Icon names use Iconify format: mdi:icon-name
|
||||
-- Reference: https://icon-sets.iconify.design/mdi/
|
||||
-- Colors use HEX format for realistic brand colors
|
||||
-- =====================================================
|
||||
|
||||
-- Clear existing template data (optional, for re-initialization)
|
||||
-- DELETE FROM `default_categories`;
|
||||
|
||||
-- =====================================================
|
||||
-- 支出类主分类 (Expense Parent Categories)
|
||||
-- =====================================================
|
||||
INSERT INTO `default_categories` (`id`, `name`, `icon`, `color`, `type`, `parent_id`, `sort_order`, `is_active`, `created_at`) VALUES
|
||||
-- 餐饮 (id=1) - 橙色系
|
||||
(1, '餐饮', 'mdi:silverware-fork-knife', '#FF6B35', 'expense', NULL, 1, 1, NOW()),
|
||||
-- 交通 (id=2) - 蓝色系
|
||||
(2, '交通', 'mdi:bus', '#3B82F6', 'expense', NULL, 2, 1, NOW()),
|
||||
-- 购物 (id=3) - 粉色系
|
||||
(3, '购物', 'mdi:shopping', '#EC4899', 'expense', NULL, 3, 1, NOW()),
|
||||
-- 居住 (id=4) - 棕色系
|
||||
(4, '居住', 'mdi:home', '#92400E', 'expense', NULL, 4, 1, NOW()),
|
||||
-- 娱乐 (id=5) - 紫色系
|
||||
(5, '娱乐', 'mdi:gamepad-variant', '#8B5CF6', 'expense', NULL, 5, 1, NOW()),
|
||||
-- 医疗 (id=6) - 红色系
|
||||
(6, '医疗', 'mdi:hospital-box', '#EF4444', 'expense', NULL, 6, 1, NOW()),
|
||||
-- 教育 (id=7) - 青色系
|
||||
(7, '教育', 'mdi:school', '#06B6D4', 'expense', NULL, 7, 1, NOW()),
|
||||
-- 通讯 (id=8) - 蓝色系
|
||||
(8, '通讯', 'mdi:cellphone', '#0EA5E9', 'expense', NULL, 8, 1, NOW()),
|
||||
-- 人情 (id=9) - 红色系
|
||||
(9, '人情', 'mdi:gift', '#F43F5E', 'expense', NULL, 9, 1, NOW()),
|
||||
-- 金融 (id=10) - 金色系
|
||||
(10, '金融', 'mdi:bank', '#F59E0B', 'expense', NULL, 10, 1, NOW()),
|
||||
-- 宠物 (id=11) - 棕色系
|
||||
(11, '宠物', 'mdi:dog', '#A16207', 'expense', NULL, 11, 1, NOW()),
|
||||
-- 其他支出 (id=12) - 灰色系
|
||||
(12, '其他支出', 'mdi:dots-horizontal-circle', '#6B7280', 'expense', NULL, 99, 1, NOW())
|
||||
ON DUPLICATE KEY UPDATE `name`=VALUES(`name`), `icon`=VALUES(`icon`), `color`=VALUES(`color`);
|
||||
|
||||
-- =====================================================
|
||||
-- 收入类主分类 (Income Parent Categories)
|
||||
-- =====================================================
|
||||
INSERT INTO `default_categories` (`id`, `name`, `icon`, `color`, `type`, `parent_id`, `sort_order`, `is_active`, `created_at`) VALUES
|
||||
-- 工作收入 (id=13) - 绿色系
|
||||
(13, '工作收入', 'mdi:briefcase', '#10B981', 'income', NULL, 1, 1, NOW()),
|
||||
-- 投资收益 (id=14) - 金色系
|
||||
(14, '投资收益', 'mdi:chart-line', '#F59E0B', 'income', NULL, 2, 1, NOW()),
|
||||
-- 被动收入 (id=15) - 紫色系
|
||||
(15, '被动收入', 'mdi:cash-multiple', '#8B5CF6', 'income', NULL, 3, 1, NOW()),
|
||||
-- 其他收入 (id=16) - 蓝色系
|
||||
(16, '其他收入', 'mdi:dots-horizontal-circle', '#3B82F6', 'income', NULL, 99, 1, NOW())
|
||||
ON DUPLICATE KEY UPDATE `name`=VALUES(`name`), `icon`=VALUES(`icon`), `color`=VALUES(`color`);
|
||||
|
||||
-- =====================================================
|
||||
-- 餐饮子分类 (parent_id = 1)
|
||||
-- =====================================================
|
||||
INSERT INTO `default_categories` (`name`, `icon`, `color`, `type`, `parent_id`, `sort_order`, `is_active`, `created_at`) VALUES
|
||||
('早餐', 'mdi:food-croissant', '#FBBF24', 'expense', 1, 1, 1, NOW()),
|
||||
('午餐', 'mdi:food', '#FB923C', 'expense', 1, 2, 1, NOW()),
|
||||
('晚餐', 'mdi:food-turkey', '#F97316', 'expense', 1, 3, 1, NOW()),
|
||||
('零食', 'mdi:cookie', '#FDE047', 'expense', 1, 4, 1, NOW()),
|
||||
('饮料', 'mdi:coffee', '#A16207', 'expense', 1, 5, 1, NOW()),
|
||||
('水果', 'mdi:fruit-grapes', '#84CC16', 'expense', 1, 6, 1, NOW()),
|
||||
('外卖', 'mdi:bike-fast', '#EF4444', 'expense', 1, 7, 1, NOW()),
|
||||
('聚餐', 'mdi:food-fork-drink', '#DC2626', 'expense', 1, 8, 1, NOW())
|
||||
ON DUPLICATE KEY UPDATE `name`=VALUES(`name`);
|
||||
|
||||
-- =====================================================
|
||||
-- 交通子分类 (parent_id = 2)
|
||||
-- =====================================================
|
||||
INSERT INTO `default_categories` (`name`, `icon`, `color`, `type`, `parent_id`, `sort_order`, `is_active`, `created_at`) VALUES
|
||||
('地铁', 'mdi:subway-variant', '#3B82F6', 'expense', 2, 1, 1, NOW()),
|
||||
('公交', 'mdi:bus', '#60A5FA', 'expense', 2, 2, 1, NOW()),
|
||||
('打车', 'mdi:taxi', '#FBBF24', 'expense', 2, 3, 1, NOW()),
|
||||
('滴滴出行', 'mdi:car-connected', '#FF6600', 'expense', 2, 4, 1, NOW()),
|
||||
('加油', 'mdi:gas-station', '#EF4444', 'expense', 2, 5, 1, NOW()),
|
||||
('停车', 'mdi:parking', '#6366F1', 'expense', 2, 6, 1, NOW()),
|
||||
('高铁/火车', 'mdi:train', '#0369A1', 'expense', 2, 7, 1, NOW()),
|
||||
('飞机', 'mdi:airplane', '#0EA5E9', 'expense', 2, 8, 1, NOW()),
|
||||
('共享单车', 'mdi:bike', '#22C55E', 'expense', 2, 9, 1, NOW()),
|
||||
('汽车保养', 'mdi:car-wrench', '#64748B', 'expense', 2, 10, 1, NOW())
|
||||
ON DUPLICATE KEY UPDATE `name`=VALUES(`name`);
|
||||
|
||||
-- =====================================================
|
||||
-- 购物子分类 (parent_id = 3)
|
||||
-- =====================================================
|
||||
INSERT INTO `default_categories` (`name`, `icon`, `color`, `type`, `parent_id`, `sort_order`, `is_active`, `created_at`) VALUES
|
||||
('服饰', 'mdi:tshirt-crew', '#EC4899', 'expense', 3, 1, 1, NOW()),
|
||||
('日用品', 'mdi:basket', '#F472B6', 'expense', 3, 2, 1, NOW()),
|
||||
('电子数码', 'mdi:laptop', '#3B82F6', 'expense', 3, 3, 1, NOW()),
|
||||
('美妆护肤', 'mdi:lipstick', '#F43F5E', 'expense', 3, 4, 1, NOW()),
|
||||
('超市', 'mdi:cart', '#22C55E', 'expense', 3, 5, 1, NOW()),
|
||||
('淘宝', 'mdi:shopping', '#FF4400', 'expense', 3, 6, 1, NOW()),
|
||||
('京东', 'mdi:package-variant', '#E4002B', 'expense', 3, 7, 1, NOW()),
|
||||
('拼多多', 'mdi:basket-outline', '#E02E24', 'expense', 3, 8, 1, NOW()),
|
||||
('家电', 'mdi:television', '#0891B2', 'expense', 3, 9, 1, NOW()),
|
||||
('母婴用品', 'mdi:baby-carriage', '#F9A8D4', 'expense', 3, 10, 1, NOW())
|
||||
ON DUPLICATE KEY UPDATE `name`=VALUES(`name`);
|
||||
|
||||
-- =====================================================
|
||||
-- 居住子分类 (parent_id = 4)
|
||||
-- =====================================================
|
||||
INSERT INTO `default_categories` (`name`, `icon`, `color`, `type`, `parent_id`, `sort_order`, `is_active`, `created_at`) VALUES
|
||||
('房租', 'mdi:home-city', '#92400E', 'expense', 4, 1, 1, NOW()),
|
||||
('房贷', 'mdi:bank', '#B45309', 'expense', 4, 2, 1, NOW()),
|
||||
('水费', 'mdi:water', '#0EA5E9', 'expense', 4, 3, 1, NOW()),
|
||||
('电费', 'mdi:flash', '#FBBF24', 'expense', 4, 4, 1, NOW()),
|
||||
('燃气费', 'mdi:fire', '#F97316', 'expense', 4, 5, 1, NOW()),
|
||||
('物业费', 'mdi:office-building', '#64748B', 'expense', 4, 6, 1, NOW()),
|
||||
('宽带网络', 'mdi:wifi', '#3B82F6', 'expense', 4, 7, 1, NOW()),
|
||||
('家居装修', 'mdi:hammer-wrench', '#A16207', 'expense', 4, 8, 1, NOW()),
|
||||
('家政服务', 'mdi:broom', '#10B981', 'expense', 4, 9, 1, NOW())
|
||||
ON DUPLICATE KEY UPDATE `name`=VALUES(`name`);
|
||||
|
||||
-- =====================================================
|
||||
-- 娱乐子分类 (parent_id = 5)
|
||||
-- =====================================================
|
||||
INSERT INTO `default_categories` (`name`, `icon`, `color`, `type`, `parent_id`, `sort_order`, `is_active`, `created_at`) VALUES
|
||||
('游戏', 'mdi:controller-classic', '#8B5CF6', 'expense', 5, 1, 1, NOW()),
|
||||
('电影', 'mdi:movie-open', '#EF4444', 'expense', 5, 2, 1, NOW()),
|
||||
('音乐', 'mdi:music', '#22C55E', 'expense', 5, 3, 1, NOW()),
|
||||
('健身', 'mdi:dumbbell', '#F97316', 'expense', 5, 4, 1, NOW()),
|
||||
('旅游', 'mdi:airplane', '#0EA5E9', 'expense', 5, 5, 1, NOW()),
|
||||
('KTV', 'mdi:microphone', '#EC4899', 'expense', 5, 6, 1, NOW()),
|
||||
('演唱会', 'mdi:party-popper', '#A855F7', 'expense', 5, 7, 1, NOW()),
|
||||
('视频会员', 'mdi:youtube-subscription', '#EF4444', 'expense', 5, 8, 1, NOW()),
|
||||
('网易云音乐', 'mdi:music-box', '#E60026', 'expense', 5, 9, 1, NOW()),
|
||||
('B站', 'mdi:video', '#FB7299', 'expense', 5, 10, 1, NOW()),
|
||||
('运动', 'mdi:soccer', '#22C55E', 'expense', 5, 11, 1, NOW())
|
||||
ON DUPLICATE KEY UPDATE `name`=VALUES(`name`);
|
||||
|
||||
-- =====================================================
|
||||
-- 医疗子分类 (parent_id = 6)
|
||||
-- =====================================================
|
||||
INSERT INTO `default_categories` (`name`, `icon`, `color`, `type`, `parent_id`, `sort_order`, `is_active`, `created_at`) VALUES
|
||||
('门诊', 'mdi:stethoscope', '#EF4444', 'expense', 6, 1, 1, NOW()),
|
||||
('药品', 'mdi:pill', '#F87171', 'expense', 6, 2, 1, NOW()),
|
||||
('体检', 'mdi:clipboard-pulse', '#06B6D4', 'expense', 6, 3, 1, NOW()),
|
||||
('住院', 'mdi:hospital-building', '#DC2626', 'expense', 6, 4, 1, NOW()),
|
||||
('保健品', 'mdi:heart-pulse', '#F43F5E', 'expense', 6, 5, 1, NOW()),
|
||||
('医疗保险', 'mdi:shield-plus', '#3B82F6', 'expense', 6, 6, 1, NOW())
|
||||
ON DUPLICATE KEY UPDATE `name`=VALUES(`name`);
|
||||
|
||||
-- =====================================================
|
||||
-- 教育子分类 (parent_id = 7)
|
||||
-- =====================================================
|
||||
INSERT INTO `default_categories` (`name`, `icon`, `color`, `type`, `parent_id`, `sort_order`, `is_active`, `created_at`) VALUES
|
||||
('培训班', 'mdi:teach', '#06B6D4', 'expense', 7, 1, 1, NOW()),
|
||||
('书籍', 'mdi:book-open-page-variant', '#92400E', 'expense', 7, 2, 1, NOW()),
|
||||
('网课', 'mdi:laptop', '#8B5CF6', 'expense', 7, 3, 1, NOW()),
|
||||
('考试报名', 'mdi:file-document-edit', '#64748B', 'expense', 7, 4, 1, NOW()),
|
||||
('学费', 'mdi:school', '#3B82F6', 'expense', 7, 5, 1, NOW()),
|
||||
('文具', 'mdi:pencil', '#FBBF24', 'expense', 7, 6, 1, NOW())
|
||||
ON DUPLICATE KEY UPDATE `name`=VALUES(`name`);
|
||||
|
||||
-- =====================================================
|
||||
-- 通讯子分类 (parent_id = 8)
|
||||
-- =====================================================
|
||||
INSERT INTO `default_categories` (`name`, `icon`, `color`, `type`, `parent_id`, `sort_order`, `is_active`, `created_at`) VALUES
|
||||
('话费', 'mdi:phone', '#0EA5E9', 'expense', 8, 1, 1, NOW()),
|
||||
('流量', 'mdi:signal-4g', '#3B82F6', 'expense', 8, 2, 1, NOW()),
|
||||
('微信会员', 'mdi:wechat', '#07C160', 'expense', 8, 3, 1, NOW()),
|
||||
('QQ会员', 'mdi:qqchat', '#12B7F5', 'expense', 8, 4, 1, NOW()),
|
||||
('云服务', 'mdi:cloud', '#6366F1', 'expense', 8, 5, 1, NOW())
|
||||
ON DUPLICATE KEY UPDATE `name`=VALUES(`name`);
|
||||
|
||||
-- =====================================================
|
||||
-- 人情子分类 (parent_id = 9)
|
||||
-- =====================================================
|
||||
INSERT INTO `default_categories` (`name`, `icon`, `color`, `type`, `parent_id`, `sort_order`, `is_active`, `created_at`) VALUES
|
||||
('微信红包', 'mdi:wechat', '#07C160', 'expense', 9, 1, 1, NOW()),
|
||||
('支付宝红包', 'mdi:web', '#1677FF', 'expense', 9, 2, 1, NOW()),
|
||||
('礼物', 'mdi:gift-outline', '#F43F5E', 'expense', 9, 3, 1, NOW()),
|
||||
('请客吃饭', 'mdi:food-fork-drink', '#F97316', 'expense', 9, 4, 1, NOW()),
|
||||
('份子钱', 'mdi:hand-heart', '#EC4899', 'expense', 9, 5, 1, NOW()),
|
||||
('孝敬长辈', 'mdi:account-heart', '#EF4444', 'expense', 9, 6, 1, NOW())
|
||||
ON DUPLICATE KEY UPDATE `name`=VALUES(`name`);
|
||||
|
||||
-- =====================================================
|
||||
-- 金融子分类 (parent_id = 10)
|
||||
-- =====================================================
|
||||
INSERT INTO `default_categories` (`name`, `icon`, `color`, `type`, `parent_id`, `sort_order`, `is_active`, `created_at`) VALUES
|
||||
('信用卡还款', 'mdi:credit-card', '#6366F1', 'expense', 10, 1, 1, NOW()),
|
||||
('借款还款', 'mdi:cash-refund', '#EF4444', 'expense', 10, 2, 1, NOW()),
|
||||
('花呗还款', 'mdi:web', '#1677FF', 'expense', 10, 3, 1, NOW()),
|
||||
('白条还款', 'mdi:package-variant', '#E4002B', 'expense', 10, 4, 1, NOW()),
|
||||
('保险费', 'mdi:shield-check', '#0EA5E9', 'expense', 10, 5, 1, NOW()),
|
||||
('投资亏损', 'mdi:chart-line-variant', '#DC2626', 'expense', 10, 6, 1, NOW()),
|
||||
('手续费', 'mdi:percent', '#64748B', 'expense', 10, 7, 1, NOW())
|
||||
ON DUPLICATE KEY UPDATE `name`=VALUES(`name`);
|
||||
|
||||
-- =====================================================
|
||||
-- 宠物子分类 (parent_id = 11)
|
||||
-- =====================================================
|
||||
INSERT INTO `default_categories` (`name`, `icon`, `color`, `type`, `parent_id`, `sort_order`, `is_active`, `created_at`) VALUES
|
||||
('宠物食品', 'mdi:food-drumstick', '#A16207', 'expense', 11, 1, 1, NOW()),
|
||||
('宠物用品', 'mdi:paw', '#D97706', 'expense', 11, 2, 1, NOW()),
|
||||
('宠物医疗', 'mdi:hospital-marker', '#EF4444', 'expense', 11, 3, 1, NOW()),
|
||||
('宠物美容', 'mdi:content-cut', '#EC4899', 'expense', 11, 4, 1, NOW())
|
||||
ON DUPLICATE KEY UPDATE `name`=VALUES(`name`);
|
||||
|
||||
-- =====================================================
|
||||
-- 工作收入子分类 (parent_id = 13)
|
||||
-- =====================================================
|
||||
INSERT INTO `default_categories` (`name`, `icon`, `color`, `type`, `parent_id`, `sort_order`, `is_active`, `created_at`) VALUES
|
||||
('工资', 'mdi:briefcase', '#10B981', 'income', 13, 1, 1, NOW()),
|
||||
('奖金', 'mdi:trophy', '#FBBF24', 'income', 13, 2, 1, NOW()),
|
||||
('年终奖', 'mdi:gift', '#F59E0B', 'income', 13, 3, 1, NOW()),
|
||||
('加班费', 'mdi:clock-time-four', '#3B82F6', 'income', 13, 4, 1, NOW()),
|
||||
('兼职', 'mdi:briefcase-clock', '#8B5CF6', 'income', 13, 5, 1, NOW()),
|
||||
('提成', 'mdi:percent', '#22C55E', 'income', 13, 6, 1, NOW()),
|
||||
('稿费', 'mdi:pencil', '#F97316', 'income', 13, 7, 1, NOW())
|
||||
ON DUPLICATE KEY UPDATE `name`=VALUES(`name`);
|
||||
|
||||
-- =====================================================
|
||||
-- 投资收益子分类 (parent_id = 14)
|
||||
-- =====================================================
|
||||
INSERT INTO `default_categories` (`name`, `icon`, `color`, `type`, `parent_id`, `sort_order`, `is_active`, `created_at`) VALUES
|
||||
('股票收益', 'mdi:chart-areaspline', '#EF4444', 'income', 14, 1, 1, NOW()),
|
||||
('基金收益', 'mdi:chart-bell-curve-cumulative', '#8B5CF6', 'income', 14, 2, 1, NOW()),
|
||||
('余额宝', 'mdi:wallet', '#1677FF', 'income', 14, 3, 1, NOW()),
|
||||
('理财产品', 'mdi:cash-multiple', '#F59E0B', 'income', 14, 4, 1, NOW()),
|
||||
('银行利息', 'mdi:bank', '#3B82F6', 'income', 14, 5, 1, NOW()),
|
||||
('数字货币', 'mdi:bitcoin', '#F7931A', 'income', 14, 6, 1, NOW())
|
||||
ON DUPLICATE KEY UPDATE `name`=VALUES(`name`);
|
||||
|
||||
-- =====================================================
|
||||
-- 被动收入子分类 (parent_id = 15)
|
||||
-- =====================================================
|
||||
INSERT INTO `default_categories` (`name`, `icon`, `color`, `type`, `parent_id`, `sort_order`, `is_active`, `created_at`) VALUES
|
||||
('房租收入', 'mdi:home-city', '#92400E', 'income', 15, 1, 1, NOW()),
|
||||
('分红', 'mdi:cash-plus', '#22C55E', 'income', 15, 2, 1, NOW()),
|
||||
('版权收入', 'mdi:copyright', '#6366F1', 'income', 15, 3, 1, NOW())
|
||||
ON DUPLICATE KEY UPDATE `name`=VALUES(`name`);
|
||||
|
||||
-- =====================================================
|
||||
-- 其他收入子分类 (parent_id = 16)
|
||||
-- =====================================================
|
||||
INSERT INTO `default_categories` (`name`, `icon`, `color`, `type`, `parent_id`, `sort_order`, `is_active`, `created_at`) VALUES
|
||||
('微信红包', 'mdi:wechat', '#07C160', 'income', 16, 1, 1, NOW()),
|
||||
('支付宝红包', 'mdi:web', '#1677FF', 'income', 16, 2, 1, NOW()),
|
||||
('报销', 'mdi:receipt-text-check', '#10B981', 'income', 16, 3, 1, NOW()),
|
||||
('退款', 'mdi:cash-refund', '#3B82F6', 'income', 16, 4, 1, NOW()),
|
||||
('中奖', 'mdi:star-shooting', '#F59E0B', 'income', 16, 5, 1, NOW()),
|
||||
('闲鱼', 'mdi:fishbowl', '#FBBF24', 'income', 16, 6, 1, NOW()),
|
||||
('借款收回', 'mdi:cash-check', '#22C55E', 'income', 16, 7, 1, NOW()),
|
||||
('其他', 'mdi:dots-horizontal', '#6B7280', 'income', 16, 99, 1, NOW())
|
||||
ON DUPLICATE KEY UPDATE `name`=VALUES(`name`);
|
||||
|
||||
@@ -88,6 +88,7 @@ CREATE TABLE IF NOT EXISTS `categories` (
|
||||
`user_id` bigint(20) unsigned NOT NULL,
|
||||
`name` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL,
|
||||
`icon` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
|
||||
`color` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
|
||||
`type` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL,
|
||||
`parent_id` bigint(20) unsigned DEFAULT NULL,
|
||||
`sort_order` bigint(20) DEFAULT '0',
|
||||
@@ -98,6 +99,7 @@ CREATE TABLE IF NOT EXISTS `categories` (
|
||||
CONSTRAINT `fk_categories_parent` FOREIGN KEY (`parent_id`) REFERENCES `categories` (`id`) ON DELETE SET NULL ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
|
||||
-- Tags table
|
||||
CREATE TABLE IF NOT EXISTS `tags` (
|
||||
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
|
||||
@@ -221,6 +223,25 @@ CREATE TABLE IF NOT EXISTS `system_categories` (
|
||||
UNIQUE KEY `idx_system_categories_code` (`code`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Default Categories Template table (for new user initialization)
|
||||
-- This table stores category templates that will be copied to new users
|
||||
CREATE TABLE IF NOT EXISTS `default_categories` (
|
||||
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
|
||||
`name` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL,
|
||||
`icon` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
|
||||
`color` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
|
||||
`type` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL,
|
||||
`parent_id` bigint(20) unsigned DEFAULT NULL,
|
||||
`sort_order` bigint(20) DEFAULT '0',
|
||||
`is_active` tinyint(1) DEFAULT '1',
|
||||
`created_at` datetime(3) DEFAULT NULL,
|
||||
`updated_at` datetime(3) DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_default_categories_parent_id` (`parent_id`),
|
||||
KEY `idx_default_categories_type` (`type`),
|
||||
CONSTRAINT `fk_default_categories_parent` FOREIGN KEY (`parent_id`) REFERENCES `default_categories` (`id`) ON DELETE SET NULL ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Budgets table
|
||||
CREATE TABLE IF NOT EXISTS `budgets` (
|
||||
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
|
||||
|
||||
106
internal/models/default_category.go
Normal file
106
internal/models/default_category.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// DefaultCategory represents a category template for new user initialization
|
||||
// These templates are copied to users' categories table when they first access categories
|
||||
type DefaultCategory struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
Name string `gorm:"size:50;not null" json:"name"`
|
||||
Icon string `gorm:"size:100" json:"icon"` // Iconify format: mdi:icon-name
|
||||
Color string `gorm:"size:20" json:"color"` // HEX color code (e.g., #FF6B35)
|
||||
Type CategoryType `gorm:"size:20;not null" json:"type"` // income or expense
|
||||
ParentID *uint `gorm:"index" json:"parent_id,omitempty"`
|
||||
SortOrder int `gorm:"default:0" json:"sort_order"`
|
||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
// Relationships
|
||||
Parent *DefaultCategory `gorm:"foreignKey:ParentID" json:"parent,omitempty"`
|
||||
Children []DefaultCategory `gorm:"foreignKey:ParentID" json:"children,omitempty"`
|
||||
}
|
||||
|
||||
// TableName specifies the table name for DefaultCategory
|
||||
func (DefaultCategory) TableName() string {
|
||||
return "default_categories"
|
||||
}
|
||||
|
||||
// GetAllDefaultCategories retrieves all active default categories from the database
|
||||
func GetAllDefaultCategories(db *gorm.DB) ([]DefaultCategory, error) {
|
||||
var categories []DefaultCategory
|
||||
err := db.Where("is_active = ?", true).Order("sort_order ASC").Find(&categories).Error
|
||||
return categories, err
|
||||
}
|
||||
|
||||
// GetDefaultCategoriesWithChildren retrieves all active root categories with their children
|
||||
func GetDefaultCategoriesWithChildren(db *gorm.DB) ([]DefaultCategory, error) {
|
||||
var categories []DefaultCategory
|
||||
err := db.Where("is_active = ? AND parent_id IS NULL", true).
|
||||
Preload("Children", "is_active = ?", true).
|
||||
Order("sort_order ASC").
|
||||
Find(&categories).Error
|
||||
return categories, err
|
||||
}
|
||||
|
||||
// CopyToUserCategories copies default categories to a user's categories
|
||||
// Returns a map of old DefaultCategory ID to new Category ID for parent-child mapping
|
||||
func CopyDefaultCategoriesToUser(db *gorm.DB, userID uint) error {
|
||||
// Get all default categories
|
||||
var defaults []DefaultCategory
|
||||
if err := db.Where("is_active = ?", true).Order("sort_order ASC").Find(&defaults).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(defaults) == 0 {
|
||||
return nil // No default categories to copy
|
||||
}
|
||||
|
||||
// Map to track old ID -> new ID for parent references
|
||||
idMap := make(map[uint]uint)
|
||||
|
||||
// First pass: create all root categories (no parent)
|
||||
for _, dc := range defaults {
|
||||
if dc.ParentID == nil {
|
||||
cat := Category{
|
||||
UserID: userID,
|
||||
Name: dc.Name,
|
||||
Icon: dc.Icon,
|
||||
Color: dc.Color,
|
||||
Type: dc.Type,
|
||||
ParentID: nil,
|
||||
SortOrder: dc.SortOrder,
|
||||
}
|
||||
if err := db.Create(&cat).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
idMap[dc.ID] = cat.ID
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: create all child categories
|
||||
for _, dc := range defaults {
|
||||
if dc.ParentID != nil {
|
||||
newParentID := idMap[*dc.ParentID]
|
||||
cat := Category{
|
||||
UserID: userID,
|
||||
Name: dc.Name,
|
||||
Icon: dc.Icon,
|
||||
Color: dc.Color,
|
||||
Type: dc.Type,
|
||||
ParentID: &newParentID,
|
||||
SortOrder: dc.SortOrder,
|
||||
}
|
||||
if err := db.Create(&cat).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
idMap[dc.ID] = cat.ID
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -304,6 +304,7 @@ type Category struct {
|
||||
UserID uint `gorm:"not null;index" json:"user_id"`
|
||||
Name string `gorm:"size:50;not null" json:"name"`
|
||||
Icon string `gorm:"size:50" json:"icon"`
|
||||
Color string `gorm:"size:20" json:"color"` // HEX color code (e.g., #FF6B35)
|
||||
Type CategoryType `gorm:"size:20;not null" json:"type"` // income or expense
|
||||
ParentID *uint `gorm:"index" json:"parent_id,omitempty"`
|
||||
SortOrder int `gorm:"default:0" json:"sort_order"`
|
||||
|
||||
@@ -26,6 +26,11 @@ func NewCategoryRepository(db *gorm.DB) *CategoryRepository {
|
||||
return &CategoryRepository{db: db}
|
||||
}
|
||||
|
||||
// GetDB returns the underlying database connection
|
||||
func (r *CategoryRepository) GetDB() *gorm.DB {
|
||||
return r.db
|
||||
}
|
||||
|
||||
// Create creates a new category in the database
|
||||
func (r *CategoryRepository) Create(category *models.Category) error {
|
||||
if err := r.db.Create(category).Error; err != nil {
|
||||
|
||||
@@ -24,6 +24,7 @@ type CategoryInput struct {
|
||||
UserID uint `json:"user_id"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
Icon string `json:"icon"`
|
||||
Color string `json:"color"` // HEX color code (e.g., #FF6B35)
|
||||
Type models.CategoryType `json:"type" binding:"required"`
|
||||
ParentID *uint `json:"parent_id,omitempty"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
@@ -70,6 +71,7 @@ func (s *CategoryService) CreateCategory(input CategoryInput) (*models.Category,
|
||||
UserID: input.UserID,
|
||||
Name: input.Name,
|
||||
Icon: input.Icon,
|
||||
Color: input.Color,
|
||||
Type: input.Type,
|
||||
ParentID: input.ParentID,
|
||||
SortOrder: input.SortOrder,
|
||||
@@ -340,102 +342,129 @@ func (s *CategoryService) GetCategoryPath(userID, id uint) ([]models.Category, e
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// initDefaultCategories seeds default categories for a user
|
||||
// initDefaultCategories seeds default categories for a user from the default_categories template table
|
||||
// If no templates are found in the database, falls back to hardcoded defaults
|
||||
func (s *CategoryService) initDefaultCategories(userID uint) error {
|
||||
db := s.repo.GetDB()
|
||||
|
||||
// Try to copy from default_categories template table first
|
||||
err := models.CopyDefaultCategoriesToUser(db, userID)
|
||||
if err == nil {
|
||||
// Check if any categories were actually created
|
||||
count, countErr := s.repo.CountByType(userID, models.CategoryTypeExpense)
|
||||
if countErr == nil && count > 0 {
|
||||
return nil // Successfully copied from template
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to hardcoded defaults if template table is empty or failed
|
||||
return s.initHardcodedDefaults(userID)
|
||||
}
|
||||
|
||||
// initHardcodedDefaults seeds hardcoded default categories for a user (fallback)
|
||||
func (s *CategoryService) initHardcodedDefaults(userID uint) error {
|
||||
defaults := []struct {
|
||||
Name string
|
||||
Type models.CategoryType
|
||||
Icon string
|
||||
Color string
|
||||
SortOrder int
|
||||
Children []struct {
|
||||
Name string
|
||||
Type models.CategoryType
|
||||
Icon string
|
||||
Color string
|
||||
SortOrder int
|
||||
}
|
||||
}{
|
||||
// Expenses
|
||||
{
|
||||
Name: "餐饮", Type: models.CategoryTypeExpense, Icon: "restaurant", SortOrder: 1,
|
||||
Name: "餐饮", Type: models.CategoryTypeExpense, Icon: "mdi:silverware-fork-knife", Color: "#FF6B35", SortOrder: 1,
|
||||
Children: []struct {
|
||||
Name string
|
||||
Type models.CategoryType
|
||||
Icon string
|
||||
Color string
|
||||
SortOrder int
|
||||
}{
|
||||
{Name: "早餐", Type: models.CategoryTypeExpense, Icon: "breakfast_dining", SortOrder: 1},
|
||||
{Name: "午餐", Type: models.CategoryTypeExpense, Icon: "lunch_dining", SortOrder: 2},
|
||||
{Name: "晚餐", Type: models.CategoryTypeExpense, Icon: "dinner_dining", SortOrder: 3},
|
||||
{Name: "零食", Type: models.CategoryTypeExpense, Icon: "icecream", SortOrder: 4},
|
||||
{Name: "饮料", Type: models.CategoryTypeExpense, Icon: "local_cafe", SortOrder: 5},
|
||||
{Name: "早餐", Type: models.CategoryTypeExpense, Icon: "mdi:food-croissant", Color: "#FBBF24", SortOrder: 1},
|
||||
{Name: "午餐", Type: models.CategoryTypeExpense, Icon: "mdi:food", Color: "#FB923C", SortOrder: 2},
|
||||
{Name: "晚餐", Type: models.CategoryTypeExpense, Icon: "mdi:food-turkey", Color: "#F97316", SortOrder: 3},
|
||||
{Name: "零食", Type: models.CategoryTypeExpense, Icon: "mdi:cookie", Color: "#FDE047", SortOrder: 4},
|
||||
{Name: "饮料", Type: models.CategoryTypeExpense, Icon: "mdi:coffee", Color: "#A16207", SortOrder: 5},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "交通", Type: models.CategoryTypeExpense, Icon: "directions_bus", SortOrder: 2,
|
||||
Name: "交通", Type: models.CategoryTypeExpense, Icon: "mdi:bus", Color: "#3B82F6", SortOrder: 2,
|
||||
Children: []struct {
|
||||
Name string
|
||||
Type models.CategoryType
|
||||
Icon string
|
||||
Color string
|
||||
SortOrder int
|
||||
}{
|
||||
{Name: "地铁", Type: models.CategoryTypeExpense, Icon: "subway", SortOrder: 1},
|
||||
{Name: "公交", Type: models.CategoryTypeExpense, Icon: "directions_bus", SortOrder: 2},
|
||||
{Name: "打车", Type: models.CategoryTypeExpense, Icon: "local_taxi", SortOrder: 3},
|
||||
{Name: "加油", Type: models.CategoryTypeExpense, Icon: "local_gas_station", SortOrder: 4},
|
||||
{Name: "地铁", Type: models.CategoryTypeExpense, Icon: "mdi:subway-variant", Color: "#3B82F6", SortOrder: 1},
|
||||
{Name: "公交", Type: models.CategoryTypeExpense, Icon: "mdi:bus", Color: "#60A5FA", SortOrder: 2},
|
||||
{Name: "打车", Type: models.CategoryTypeExpense, Icon: "mdi:taxi", Color: "#FBBF24", SortOrder: 3},
|
||||
{Name: "加油", Type: models.CategoryTypeExpense, Icon: "mdi:gas-station", Color: "#EF4444", SortOrder: 4},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "购物", Type: models.CategoryTypeExpense, Icon: "shopping_bag", SortOrder: 3,
|
||||
Name: "购物", Type: models.CategoryTypeExpense, Icon: "mdi:shopping", Color: "#EC4899", SortOrder: 3,
|
||||
Children: []struct {
|
||||
Name string
|
||||
Type models.CategoryType
|
||||
Icon string
|
||||
Color string
|
||||
SortOrder int
|
||||
}{
|
||||
{Name: "服饰", Type: models.CategoryTypeExpense, Icon: "checkroom", SortOrder: 1},
|
||||
{Name: "日用", Type: models.CategoryTypeExpense, Icon: "soap", SortOrder: 2},
|
||||
{Name: "电子数码", Type: models.CategoryTypeExpense, Icon: "devices", SortOrder: 3},
|
||||
{Name: "服饰", Type: models.CategoryTypeExpense, Icon: "mdi:tshirt-crew", Color: "#EC4899", SortOrder: 1},
|
||||
{Name: "日用", Type: models.CategoryTypeExpense, Icon: "mdi:basket", Color: "#F472B6", SortOrder: 2},
|
||||
{Name: "电子数码", Type: models.CategoryTypeExpense, Icon: "mdi:laptop", Color: "#3B82F6", SortOrder: 3},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "居住", Type: models.CategoryTypeExpense, Icon: "home", SortOrder: 4,
|
||||
Name: "居住", Type: models.CategoryTypeExpense, Icon: "mdi:home", Color: "#92400E", SortOrder: 4,
|
||||
Children: []struct {
|
||||
Name string
|
||||
Type models.CategoryType
|
||||
Icon string
|
||||
Color string
|
||||
SortOrder int
|
||||
}{
|
||||
{Name: "房租", Type: models.CategoryTypeExpense, Icon: "house", SortOrder: 1},
|
||||
{Name: "水电煤", Type: models.CategoryTypeExpense, Icon: "lightbulb", SortOrder: 2},
|
||||
{Name: "物业", Type: models.CategoryTypeExpense, Icon: "security", SortOrder: 3},
|
||||
{Name: "房租", Type: models.CategoryTypeExpense, Icon: "mdi:home-city", Color: "#92400E", SortOrder: 1},
|
||||
{Name: "水电煤", Type: models.CategoryTypeExpense, Icon: "mdi:lightbulb-on", Color: "#FBBF24", SortOrder: 2},
|
||||
{Name: "物业", Type: models.CategoryTypeExpense, Icon: "mdi:office-building", Color: "#64748B", SortOrder: 3},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "娱乐", Type: models.CategoryTypeExpense, Icon: "sports_esports", SortOrder: 5,
|
||||
Name: "娱乐", Type: models.CategoryTypeExpense, Icon: "mdi:gamepad-variant", Color: "#8B5CF6", SortOrder: 5,
|
||||
Children: []struct {
|
||||
Name string
|
||||
Type models.CategoryType
|
||||
Icon string
|
||||
Color string
|
||||
SortOrder int
|
||||
}{
|
||||
{Name: "游戏", Type: models.CategoryTypeExpense, Icon: "gamepad", SortOrder: 1},
|
||||
{Name: "电影", Type: models.CategoryTypeExpense, Icon: "movie", SortOrder: 2},
|
||||
{Name: "运动", Type: models.CategoryTypeExpense, Icon: "fitness_center", SortOrder: 3},
|
||||
{Name: "游戏", Type: models.CategoryTypeExpense, Icon: "mdi:controller-classic", Color: "#8B5CF6", SortOrder: 1},
|
||||
{Name: "电影", Type: models.CategoryTypeExpense, Icon: "mdi:movie-open", Color: "#EF4444", SortOrder: 2},
|
||||
{Name: "运动", Type: models.CategoryTypeExpense, Icon: "mdi:dumbbell", Color: "#22C55E", SortOrder: 3},
|
||||
},
|
||||
},
|
||||
// Income
|
||||
{
|
||||
Name: "收入", Type: models.CategoryTypeIncome, Icon: "payments", SortOrder: 10,
|
||||
Name: "工作收入", Type: models.CategoryTypeIncome, Icon: "mdi:briefcase", Color: "#10B981", SortOrder: 10,
|
||||
Children: []struct {
|
||||
Name string
|
||||
Type models.CategoryType
|
||||
Icon string
|
||||
Color string
|
||||
SortOrder int
|
||||
}{
|
||||
{Name: "工资", Type: models.CategoryTypeIncome, Icon: "work", SortOrder: 1},
|
||||
{Name: "奖金", Type: models.CategoryTypeIncome, Icon: "star", SortOrder: 2},
|
||||
{Name: "兼职", Type: models.CategoryTypeIncome, Icon: "access_time", SortOrder: 3},
|
||||
{Name: "理财", Type: models.CategoryTypeIncome, Icon: "trending_up", SortOrder: 4},
|
||||
{Name: "工资", Type: models.CategoryTypeIncome, Icon: "mdi:briefcase", Color: "#10B981", SortOrder: 1},
|
||||
{Name: "奖金", Type: models.CategoryTypeIncome, Icon: "mdi:trophy", Color: "#FBBF24", SortOrder: 2},
|
||||
{Name: "兼职", Type: models.CategoryTypeIncome, Icon: "mdi:briefcase-clock", Color: "#8B5CF6", SortOrder: 3},
|
||||
{Name: "理财", Type: models.CategoryTypeIncome, Icon: "mdi:chart-line", Color: "#F59E0B", SortOrder: 4},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -446,6 +475,7 @@ func (s *CategoryService) initDefaultCategories(userID uint) error {
|
||||
Name: cat.Name,
|
||||
Type: cat.Type,
|
||||
Icon: cat.Icon,
|
||||
Color: cat.Color,
|
||||
SortOrder: cat.SortOrder,
|
||||
}
|
||||
if err := s.repo.Create(parent); err != nil {
|
||||
@@ -458,6 +488,7 @@ func (s *CategoryService) initDefaultCategories(userID uint) error {
|
||||
Name: child.Name,
|
||||
Type: child.Type,
|
||||
Icon: child.Icon,
|
||||
Color: child.Color,
|
||||
SortOrder: child.SortOrder,
|
||||
ParentID: &parent.ID,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user