[未采用]
一、架构概览
┌─────────────────────────────────────────────────────────────┐
│ 前端 (apps/web) │
├─────────────────────────────────────────────────────────────┤
│ EmojiPicker (表情包选择器组件) │
│ ├── 分组展示 (按 group 排序) │
│ ├── 搜索功能 │
│ └── 点击插入 Markdown │
├─────────────────────────────────────────────────────────────┤
│ MarkdownEditor 集成 │
│ ├── 工具栏按钮触发 EmojiPicker │
│ └── 语法支持: :emoji_name: 或 [emoji:name] │
├─────────────────────────────────────────────────────────────┤
│ MarkdownRender 集成 │
│ └── 解析 emoji 语法渲染为图片 │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 后端 (apps/api) │
├─────────────────────────────────────────────────────────────┤
│ /api/emoji/* RESTful API │
│ ├── GET /groups - 获取分组列表 │
│ ├── POST /groups - 创建分组 │
│ ├── PATCH /groups/:id - 更新分组 │
│ ├── DELETE /groups/:id - 删除分组 │
│ ├── POST /emojis - 上传表情 │
│ ├── GET /emojis - 获取表情列表 │
│ ├── PATCH /emojis/:id - 更新表情 │
│ ├── DELETE /emojis/:id - 删除表情 │
│ └── PATCH /reorder - 批量排序 │
├─────────────────────────────────────────────────────────────┤
│ 权限控制 │
│ ├── system.emojis - 使用表情包 │
│ └── dashboard.emojis - 管理表情包 │
└─────────────────────────────────────────────────────────────┘
二、后端实现计划
2.1 数据库 Schema (apps/api/src/extensions/emoji/schema.js)
import {
integer,
pgTable,
varchar,
text,
timestamp,
boolean,
index,
} from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
// ============ 公共字段 ============
const $id = integer('id').primaryKey().generatedAlwaysAsIdentity();
const $createdAt = timestamp('created_at', { withTimezone: true }).defaultNow();
const $updatedAt = timestamp('updated_at', { withTimezone: true })
.defaultNow()
.$onUpdate(() => new Date());
// ============ 表情包分组表 ============
export const emojiGroups = pgTable(
'emoji_groups',
{
id: $id,
name: varchar('name', { length: 50 }).notNull(),
slug: varchar('slug', { length: 50 }).notNull().unique(),
icon: varchar('icon', { length: 255 }),
description: text('description'),
position: integer('position').notNull().default(0),
isActive: boolean('is_active').notNull().default(true),
createdAt: $createdAt,
updatedAt: $updatedAt,
},
(table) => [
index('emoji_groups_position_idx').on(table.position),
index('emoji_groups_is_active_idx').on(table.isActive),
]
);
// ============ 表情包表 ============
export const emojis = pgTable(
'emojis',
{
id: $id,
groupId: integer('group_id')
.notNull()
.references(() => emojiGroups.id, { onDelete: 'cascade' }),
name: varchar('name', { length: 100 }).notNull(),
url: varchar('url', { length: 500 }).notNull(),
keywords: varchar('keywords', { length: 500 }),
position: integer('position').notNull().default(0),
usageCount: integer('usage_count').notNull().default(0),
isActive: boolean('is_active').notNull().default(true),
createdAt: $createdAt,
updatedAt: $updatedAt,
},
(table) => [
index('emojis_group_id_idx').on(table.groupId),
index('emojis_position_idx').on(table.position),
index('emojis_name_idx').on(table.name),
]
);
export const emojiGroupsRelations = relations(emojiGroups, ({ many }) => ({
emojis: many(emojis),
}));
export const emojisRelations = relations(emojis, ({ one }) => ({
group: one(emojiGroups, {
fields: [emojis.groupId],
references: [emojiGroups.id],
}),
}));
2.2 RBAC 权限配置更新 (apps/api/src/config/rbac.js)
在 MODULE_SPECIAL_ACTIONS.system 中添加:
system: [
{ value: 'stats', label: '统计' },
{ value: 'emojis', label: '表情包' },
],
在 SYSTEM_PERMISSIONS 中添加:
{
slug: 'system.emojis',
name: '使用表情包',
module: 'system',
action: 'emojis',
isSystem: true,
conditions: [],
},
{
slug: 'dashboard.emojis',
name: '表情包管理',
module: 'dashboard',
action: 'emojis',
isSystem: true,
conditions: [],
},
2.3 插件入口 (apps/api/src/extensions/emoji/index.js)
import fp from 'fastify-plugin';
import emojiRoutes from './routes/index.js';
async function emojiPlugin(fastify, options) {
fastify.register(emojiRoutes, { prefix: '/api/emoji' });
fastify.log.info('[表情包] 扩展已注册');
}
export default fp(emojiPlugin, {
name: 'emoji',
dependencies: [],
});
2.4 路由实现 (apps/api/src/extensions/emoji/routes/index.js)
公开接口 (无需认证):
GET /display- 获取所有启用的表情包(用于前端展示)
管理接口 (需要 dashboard.emojis 权限):
GET /groups- 获取分组列表POST /groups- 创建分组PATCH /groups/:id- 更新分组DELETE /groups/:id- 删除分组GET /emojis- 获取表情列表POST /emojis- 创建表情PATCH /emojis/:id- 更新表情DELETE /emojis/:id- 删除表情PATCH /reorder- 批量排序
2.5 上传集成
修改 apps/api/src/routes/upload/index.js 中已存在的 emojis 分类支持,添加文件大小限制(建议 512KB)和类型限制(仅图片)。
2.6 数据库 Schema 导出
在 apps/api/src/db/schema.js 中添加:
export * from '../extensions/emoji/schema.js';
三、前端实现计划
3.1 表情包选择器组件 (apps/web/src/components/common/EmojiPicker/index.jsx)
'use client';
import { useState, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { ScrollArea } from '@/components/ui/scroll-area';
import { useDebounce } from '@/hooks/useDebounce';
import { Search, Smile } from 'lucide-react';
export function EmojiPicker({ onSelect, children }) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState('');
const debouncedSearch = useDebounce(search, 300);
const { data: groups = [] } = useQuery({
queryKey: ['emojis', 'display'],
queryFn: () => fetch('/api/emoji/display').then(r => r.json()),
enabled: open,
});
const filteredEmojis = useMemo(() => {
if (!debouncedSearch) return groups;
// 搜索逻辑
}, [groups, debouncedSearch]);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
{children || (
<Button variant="ghost" size="icon">
<Smile className="h-5 w-5" />
</Button>
)}
</PopoverTrigger>
<PopoverContent className="w-96 p-0" align="start">
<div className="p-3 border-b">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="搜索表情..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
</div>
<ScrollArea className="h-80">
{groups.map(group => (
<div key={group.id} className="p-3">
<h4 className="text-sm font-medium mb-2">{group.name}</h4>
<div className="grid grid-cols-8 gap-1">
{group.emojis.map(emoji => (
<button
key={emoji.id}
onClick={() => {
onSelect(emoji);
setOpen(false);
}}
className="hover:bg-muted rounded p-1"
title={emoji.name}
>
<img src={emoji.url} alt={emoji.name} className="w-8 h-8" />
</button>
))}
</div>
</div>
))}
</ScrollArea>
</PopoverContent>
</Popover>
);
}
3.2 管理后台页面 (apps/web/src/app/dashboard/emojis/)
dashboard/emojis/
├── page.jsx # 表情包管理首页
├── groups/
│ └── page.jsx # 分组管理
└── components/
├── EmojiGroupForm.jsx
├── EmojiUploader.jsx
└── EmojiList.jsx
3.3 DashboardSidebar 添加菜单项
{
key: 'extensions',
label: '扩展功能',
icon: Store,
children: [
{ href: '/dashboard/ledger', icon: Coins, label: '货币管理', permission: 'dashboard.extensions' },
{ href: '/dashboard/shop', icon: ShoppingCart, label: '商城管理', permission: 'dashboard.extensions' },
{ href: '/dashboard/badges', icon: Medal, label: '勋章管理', permission: 'dashboard.extensions' },
{ href: '/dashboard/emojis', icon: Smile, label: '表情包管理', permission: 'dashboard.emojis' },
],
},
3.4 MarkdownEditor 集成表情包选择器
在工具栏添加 Emoji 按钮:
// apps/web/src/components/common/MarkdownEditor/tools/emoji/EmojiTool.jsx
import { EmojiPicker } from '@/components/common/EmojiPicker';
export function EmojiTool({ editor, disabled }) {
const handleSelect = (emoji) => {
editor.insertText(`[emoji:${emoji.name}]`);
};
return (
<EmojiPicker onSelect={handleSelect}>
<Button variant="ghost" size="sm" disabled={disabled}>
<Smile className="h-4 w-4" />
</Button>
</EmojiPicker>
);
}
3.5 MarkdownRender 解析表情语法
// apps/web/src/components/common/MarkdownRender/components/EmojiImage.jsx
export function EmojiImage({ src, alt, ...props }) {
if (alt?.startsWith?.('emoji:')) {
const name = alt.replace('emoji:', '');
return <img src={src} alt={name} className="inline-block w-6 h-6" {...props} />;
}
return <img src={src} alt={alt} {...props} />;
}
四、实施步骤
第一阶段:后端基础 (2-3 小时)
- 创建
apps/api/src/extensions/emoji/目录结构 - 实现数据库 Schema
- 实现 RBAC 权限配置
- 实现 RESTful API 路由
- 更新数据库 Schema 导出
第二阶段:前端基础 (2-3 小时)
- 创建 EmojiPicker 组件
- 创建管理后台页面
- 集成到 DashboardSidebar
- 创建 API 服务函数
第三阶段:编辑器集成 (1-2 小时)
- 添加 EmojiTool 到工具栏
- 集成 EmojiPicker
- 配置 MarkdownRender 解析
第四阶段:测试与优化 (1-2 小时)
- 测试上传功能
- 测试权限控制
- 优化性能和用户体验
五、文件清单
| 文件路径 | 操作 |
|---|---|
apps/api/src/extensions/emoji/schema.js | 新建 |
apps/api/src/extensions/emoji/index.js | 新建 |
apps/api/src/extensions/emoji/routes/index.js | 新建 |
apps/api/src/extensions/emoji/services/emojiService.js | 新建 |
apps/api/src/db/schema.js | 修改 |
apps/api/src/config/rbac.js | 修改 |
apps/web/src/components/common/EmojiPicker/index.jsx | 新建 |
apps/web/src/components/common/EmojiPicker/EmojiPickerItem.jsx | 新建 |
apps/web/src/app/dashboard/emojis/page.jsx | 新建 |
apps/web/src/app/dashboard/emojis/groups/page.jsx | 新建 |
apps/web/src/app/dashboard/emojis/components/* | 新建 |
apps/web/src/components/common/MarkdownEditor/tools/emoji/EmojiTool.jsx | 新建 |
apps/web/src/components/common/MarkdownRender/components/EmojiImage.jsx | 新建 |
apps/web/src/lib/api/emoji.js | 新建 |
apps/web/src/hooks/useEmojis.js | 新建 |
apps/web/src/components/layout/DashboardSidebar.jsx | 修改 |
六、注意事项
- 上传分类:
emojis已存在于上传配置中,可直接使用 - 权限控制: 使用
system.emojis控制普通用户使用权限,dashboard.emojis控制管理权限 - 排序一致性: 前端通过
position字段与后端保持排序一致 - Markdown 语法: 建议使用
[emoji:name]格式,便于解析和避免冲突 - 缓存优化: 建议对公开接口添加 Redis 缓存