feat: 支付闭环 + 运营助手 Tool Calling + 管理后台完善

- 支付系统:微信支付 mock 自动完成、NATIVE 扫码支付、JSAPI 集成
- 运营助手:Tool Calling 架构,19 个可执行工具,AI 驱动操作
- 角色管理:表格布局 + Dialog 表单 + 权限勾选
- 配置统一:config.ts 单一数据源
- API 审计:补齐 status toggle / comments 端点
- 暗黑模式硬件编码颜色全部替换为 CSS 变量
This commit is contained in:
yuzhiran-dev
2026-05-20 18:29:00 +08:00
parent 728edc59ef
commit 23edb74bce
76 changed files with 1589 additions and 473 deletions
+96 -134
View File
@@ -2,100 +2,97 @@
import { useState, useRef, useEffect, FormEvent } from 'react';
import { usePathname, useRouter } from 'next/navigation';
import { MessageCircle, X, Send, Minus, Sparkles, FileText, BookOpen, Image, Settings } from 'lucide-react';
import { useT } from '@/i18n';
import { X, Send, Minus } from 'lucide-react';
import { getAdminToken } from '@/lib/auth';
import { toast } from 'sonner';
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api/v1';
import { API_BASE } from '@/lib/config';
interface Message {
role: 'user' | 'assistant';
content: string;
}
interface AdminContext {
page: string;
systemPrompt: string;
starters: string[];
}
const AVAILABLE_TOOLS_DESC = `可用工具列表(需要执行操作时,返回 JSON{"tool":"工具名","params":{...},"description":"简述"}):
const adminContexts: Record<string, AdminContext> = {
'/admin': {
page: 'dashboard',
systemPrompt: '你是宇之然AI管理后台的智能助手。帮助管理员完成日常运营工作,包括:数据分析、用户管理、内容审核、订单处理等。',
starters: ['查看今日数据', '最近有哪些新用户', '待处理订单数量', '系统运行状态'],
},
'/admin/users': {
page: 'users',
systemPrompt: '你是用户管理助手。可以帮助:查看用户列表、搜索用户、修改用户状态、查看用户详情。',
starters: ['列出最近注册的用户', '查找某个用户', '批量启用/禁用用户', '查看用户详情'],
},
'/admin/courses': {
page: 'courses',
systemPrompt: '你是课程管理助手。可以帮助:创建新课程、编辑课程信息、上下架课程、管理课程章节。',
starters: ['创建新课程', '课程列表', '下架某个课程', '添加课程章节'],
},
'/admin/contents': {
page: 'contents',
systemPrompt: '你是内容管理助手。可以帮助:创建文章、编辑内容、设置分类、发布/下架。',
starters: ['创建新文章', '内容列表', '编辑某篇文章', '设置文章分类'],
},
'/admin/prompts': {
page: 'prompts',
systemPrompt: '你是提示词管理助手。可以帮助:创建提示词、审核提示词、设置分类、推荐优质提示词。',
starters: ['创建新提示词', '待审核列表', '热门提示词', '添加提示词标签'],
},
'/admin/orders': {
page: 'orders',
systemPrompt: '你是订单管理助手。可以帮助:查看订单列表、订单详情、退款处理、收入统计。',
starters: ['今日订单', '待处理订单', '收入统计', '订单详情'],
},
'/admin/analytics': {
page: 'analytics',
systemPrompt: '你是数据分析助手。可以帮助:解读数据指标、分析趋势、生成报表建议。',
starters: ['用户增长趋势', '收入分析', '热门内容', '数据摘要'],
},
'/admin/operations': {
page: 'operations',
systemPrompt: '你是运营助手。可以帮助:创建Banner、发送推送通知、管理活动。',
starters: ['创建Banner', '发送系统通知', '查看推送记录', '运营数据'],
},
'/admin/settings': {
page: 'settings',
systemPrompt: '你是系统设置助手。可以帮助:修改系统配置、查看配置项、批量设置。',
starters: ['查看AI配置', '修改会员价格', '站点设置', '配置说明'],
},
};
- **get-dashboard**: 获取仪表盘概览数据(用户数、课程数、内容数、提示词数、订单数)
- **list-users**: 列出用户,可按关键词搜索(参数: search, page, pageSize
- **get-user**: 查看用户详情(参数: id)
- **update-user-status**: 修改用户状态(参数: id, status: ACTIVE|INACTIVE|BANNED
- **list-orders**: 查看最近订单列表
- **get-analytics-overview**: 获取数据分析概览(用户增长、收入、趋势)
- **list-comments**: 查看评论(参数: status: PENDING_REVIEW|PUBLISHED|REJECTED
- **approve-comment**: 通过评论(参数: id
- **reject-comment**: 拒绝评论(参数: id, reason?
- **list-banners**: 查看所有Banner
- **list-notifications**: 查看系统通知
- **list-config**: 查看系统配置
- **list-roles**: 查看管理角色
- **list-admins**: 查看管理员
- **get-enterprise-orgs**: 查看企业版组织
- **toggle-course-status**: 切换课程上下架(参数: id)
- **toggle-content-status**: 切换内容上下架(参数: id)
- **toggle-prompt-status**: 切换提示词上下架(参数: id)
- **navigate**: 跳转到某个管理页面(参数: path — 如 /admin/users, /admin/orders, /admin/analytics, /admin/enterprise, /admin/operations/banners, /admin/operations/notifications, /admin/settings/roles, /admin/settings/config, /admin/comments, /admin/courses, /admin/prompts, /admin/contents, /admin/tools
const ACTION_FORMAT = `\n\n【快捷指令】当需要执行操作时,可以返回 JSON 指令:\n- {"action":"navigate","path":"/admin/courses","description":"跳转到课程管理"}\n- {"action":"search","keyword":"xxx","target":"users","description":"搜索用户"}\n- {"action":"create","type":"course","data":{"title":"课程名"},"description":"创建课程"}\n只有确实需要跳转或执行操作时才返回指令`;
当用户请求执行操作时,先调用对应工具。工具执行完毕后会用自然语言总结结果`;
function getAdminContext(pathname: string): AdminContext {
const sorted = Object.keys(adminContexts).sort((a, b) => b.length - a.length);
for (const key of sorted) {
if (pathname.startsWith(key)) {
const ctx = { ...adminContexts[key] };
ctx.systemPrompt = ctx.systemPrompt + ACTION_FORMAT;
return ctx;
const SYSTEM_PROMPT = `你是宇之然AI管理后台的智能助手,帮助管理员完成日常运营工作。
你可以:
1. 回答管理员的问题
2. 调用工具执行操作(如查看数据、管理用户、审核评论等)
3. 跳转到各个管理页面
${AVAILABLE_TOOLS_DESC}
注意:每次只需要返回一个 JSON 工具调用,不要包含多余文字。执行结果会自动呈现给用户。`;
function parseToolCall(text: string): { tool: string; params: Record<string, any>; description?: string } | null {
let braceDepth = 0;
let start = -1;
for (let i = 0; i < text.length; i++) {
if (text[i] === '{') {
if (start === -1) start = i;
braceDepth++;
} else if (text[i] === '}') {
braceDepth--;
if (braceDepth === 0 && start !== -1) {
try {
const parsed = JSON.parse(text.slice(start, i + 1));
if (parsed.tool) return parsed;
} catch {}
start = -1;
}
}
}
const defaultCtx = { ...adminContexts['/admin'] };
defaultCtx.systemPrompt = defaultCtx.systemPrompt + ACTION_FORMAT;
return defaultCtx;
}
function parseActionCommand(text: string): any | null {
const jsonMatch = text.match(/\{[\s\S]*?"action"\s*:\s*?"[^"]+"[\s\S]*?\}/);
if (!jsonMatch) return null;
try {
const parsed = JSON.parse(jsonMatch[0]);
if (parsed.action) return parsed;
} catch {}
return null;
}
async function postChat(messages: { role: string; content: string }[]): Promise<string> {
const tk = getAdminToken();
if (!tk) return '请先登录管理账号';
const res = await fetch(`${API_BASE}/admin/ai-assistant/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${tk}` },
body: JSON.stringify({ messages }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.message || '请求失败');
return data.reply;
}
async function executeTool(tool: string, params: Record<string, any>, messages: { role: string; content: string }[]): Promise<{ reply: string }> {
const tk = getAdminToken();
const res = await fetch(`${API_BASE}/admin/ai-assistant/action`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${tk}` },
body: JSON.stringify({ tool, params, messages }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.message || '工具执行失败');
return data;
}
export function AdminAIAssistant() {
const t = useT();
const pathname = usePathname();
const router = useRouter();
const [open, setOpen] = useState(false);
@@ -103,18 +100,9 @@ export function AdminAIAssistant() {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [sending, setSending] = useState(false);
const [started, setStarted] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (open && !started) {
const ctx = getAdminContext(pathname);
setMessages([{ role: 'assistant', content: ctx.systemPrompt }]);
setStarted(true);
}
}, [open, pathname, started]);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
@@ -134,51 +122,25 @@ export function AdminAIAssistant() {
setSending(true);
try {
const tk = getAdminToken();
let reply = '';
if (tk) {
const ctx = getAdminContext(pathname);
const apiMessages = [
{ role: 'system', content: ctx.systemPrompt },
...messages.filter(m => m.role === 'user' || m.role === 'assistant').map(m => ({ role: m.role, content: m.content })),
{ role: 'user', content: text },
];
const apiMessages = [
{ role: 'system', content: SYSTEM_PROMPT },
...messages.map(m => ({ role: m.role, content: m.content })),
{ role: 'user', content: text },
];
const res = await fetch(`${API_BASE}/sandbox/chat`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${tk}`,
},
body: JSON.stringify({
conversationId: crypto.randomUUID(),
model: 'general',
messages: apiMessages,
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.message || '请求失败');
reply = data.reply;
} else {
await new Promise(r => setTimeout(r, 400));
reply = '请先登录管理账号';
}
let reply = await postChat(apiMessages);
const toolCall = parseToolCall(reply);
const action = parseActionCommand(reply);
const hasAction = !!action;
if (hasAction) {
const cleanReply = reply.replace(/\{[\s\S]*?"action"\s*:\s*?"[^"]+"[\s\S]*?\}/, '').trim();
const finalReply = cleanReply || '收到指令,正在处理...';
setMessages(prev => [...prev, { role: 'assistant', content: finalReply }]);
if (action.action === 'navigate' && action.path) {
router.push(action.path);
toast.success(`正在跳转:${action.description || action.path}`);
if (toolCall) {
if (toolCall.tool === 'navigate') {
const path = toolCall.params.path;
setMessages(prev => [...prev, { role: 'assistant', content: `正在跳转到 ${path}...` }]);
toast.success(toolCall.description || `跳转到 ${path}`);
router.push(path);
} else {
toast.info(action.description || '收到操作指令');
const result = await executeTool(toolCall.tool, toolCall.params, apiMessages);
reply = result.reply;
setMessages(prev => [...prev, { role: 'assistant', content: reply }]);
}
} else {
setMessages(prev => [...prev, { role: 'assistant', content: reply }]);
@@ -210,7 +172,7 @@ export function AdminAIAssistant() {
);
}
const ctx = getAdminContext(pathname);
const starters = ['查看仪表盘数据', '列出最近用户', '查看待审核评论', '查看企业版组织'];
return (
<div className="fixed bottom-6 right-6 z-50 flex flex-col items-end gap-2">
@@ -229,7 +191,7 @@ export function AdminAIAssistant() {
<button onClick={() => setMinimized(!minimized)} className="p-1.5 text-muted-foreground hover:text-foreground hover:bg-accent rounded-lg">
<Minus className="w-4 h-4" />
</button>
<button onClick={() => { setOpen(false); setMinimized(false); }} className="p-1.5 text-muted-foreground hover:text-foreground hover:bg-accent rounded-lg">
<button onClick={() => { setOpen(false); setMinimized(false); setMessages([]); }} className="p-1.5 text-muted-foreground hover:text-foreground hover:bg-accent rounded-lg">
<X className="w-4 h-4" />
</button>
</div>
@@ -238,13 +200,13 @@ export function AdminAIAssistant() {
{!minimized && (
<>
<div className="overflow-y-auto p-3 space-y-3" style={{ maxHeight: '320px' }}>
{messages.length === 1 && messages[0].role === 'assistant' && (
{messages.length === 0 && (
<div className="mb-2">
<p className="text-xs text-muted-foreground mb-3">
</p>
<div className="flex flex-wrap gap-1.5">
{ctx.starters.map((q, i) => (
{starters.map((q, i) => (
<button key={i} onClick={() => handleStarter(q)}
className="text-xs px-2.5 py-1.5 bg-muted text-muted-foreground rounded-full border border-border hover:bg-accent hover:text-foreground">
{q}
@@ -305,4 +267,4 @@ export function AdminAIAssistant() {
</div>
</div>
);
}
}
+2 -3
View File
@@ -1,8 +1,7 @@
'use client';
import { useState, useRef } from 'react';
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
import { API_BASE } from '@/lib/config';
interface ImageUploadProps {
onUploaded: (url: string) => void;
@@ -33,7 +32,7 @@ export function ImageUpload({ onUploaded, defaultImage, accept = 'image/*' }: Im
formData.append('file', file);
try {
const res = await fetch(`${API_BASE}/api/v1/upload`, {
const res = await fetch(`${API_BASE}/upload`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: formData,
@@ -0,0 +1,154 @@
'use client';
import { useEffect, useState, useRef } from 'react';
import QRCode from 'qrcode';
import { apiFetch } from '@/lib/auth';
import { isWeChatBrowser } from '@/lib/wechat';
interface PayResult {
prepay_id?: string;
nonceStr?: string;
timeStamp?: string;
package?: string;
paySign?: string;
signType?: string;
codeUrl?: string;
}
interface Props {
open: boolean;
orderNo: string;
payResult: PayResult;
tradeType: 'JSAPI' | 'NATIVE';
onPaid: () => void;
onClose: () => void;
}
export default function PaymentModal({ open, orderNo, payResult, tradeType, onPaid, onClose }: Props) {
const [status, setStatus] = useState<'pending' | 'paid' | 'failed'>('pending');
const [qrDataUrl, setQrDataUrl] = useState('');
const [message, setMessage] = useState('');
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
const wechatBridgeCalled = useRef(false);
useEffect(() => {
if (!open) {
setStatus('pending');
setMessage('');
wechatBridgeCalled.current = false;
if (pollingRef.current) { clearInterval(pollingRef.current); pollingRef.current = null; }
return;
}
// NATIVE: render QR code
if (tradeType === 'NATIVE' && payResult?.codeUrl) {
QRCode.toDataURL(payResult.codeUrl, { margin: 1, width: 280 }, (err, url) => {
if (!err) setQrDataUrl(url);
});
setMessage('请使用微信扫描二维码完成支付');
startPolling();
}
// JSAPI in WeChat: call WeixinJSBridge
if (tradeType === 'JSAPI' && isWeChatBrowser() && !wechatBridgeCalled.current) {
wechatBridgeCalled.current = true;
setMessage('正在调起微信支付...');
callWechatJsapi(payResult);
startPolling();
}
}, [open, tradeType, payResult?.codeUrl]);
function startPolling() {
if (pollingRef.current) clearInterval(pollingRef.current);
pollingRef.current = setInterval(async () => {
try {
const res = await apiFetch(`/payment/wxpay/query?outTradeNo=${orderNo}`);
const data = await res.json();
if (data.trade_state === 'SUCCESS' || data.localStatus === 'PAID') {
setStatus('paid');
setMessage('支付成功!');
if (pollingRef.current) clearInterval(pollingRef.current);
setTimeout(onPaid, 1500);
}
} catch {}
}, 3000);
}
function callWechatJsapi(params: PayResult) {
if (typeof WeixinJSBridge === 'undefined') {
document.addEventListener('WeixinJSBridgeReady', () => doInvoke(params), false);
} else {
doInvoke(params);
}
}
function doInvoke(params: PayResult) {
WeixinJSBridge.invoke(
'getBrandWCPayRequest',
{
appId: '', // filled by WeChat
timeStamp: params.timeStamp || '',
nonceStr: params.nonceStr || '',
package: params.package || '',
signType: params.signType || 'RSA',
paySign: params.paySign || '',
},
(res: any) => {
if (res.err_msg === 'get_brand_wcpay_request:ok') {
setStatus('paid');
setMessage('支付成功!');
if (pollingRef.current) clearInterval(pollingRef.current);
setTimeout(onPaid, 1500);
} else if (res.err_msg === 'get_brand_wcpay_request:cancel') {
setMessage('已取消支付');
setStatus('pending');
} else {
setMessage('支付失败,请重试');
setStatus('failed');
}
}
);
}
useEffect(() => {
return () => { if (pollingRef.current) clearInterval(pollingRef.current); };
}, []);
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
<div className="bg-card rounded-2xl p-8 w-full max-w-sm mx-4 shadow-xl border border-border" onClick={e => e.stopPropagation()}>
<h3 className="text-lg font-semibold text-foreground text-center mb-4"></h3>
{tradeType === 'NATIVE' && (
<div className="flex justify-center mb-4">
{qrDataUrl ? (
<img src={qrDataUrl} alt="支付二维码" className="w-56 h-56 rounded-xl border border-border" />
) : (
<div className="w-56 h-56 bg-muted rounded-xl animate-pulse" />
)}
</div>
)}
{status === 'paid' ? (
<div className="text-center">
<div className="text-5xl mb-3"></div>
<p className="text-green-600 font-medium">{message}</p>
</div>
) : (
<>
<p className="text-sm text-muted-foreground text-center mb-4">{message}</p>
<div className="flex items-center justify-center gap-2 text-xs text-muted-foreground">
<span className="w-2 h-2 bg-brand-600 rounded-full animate-pulse" />
...
</div>
<button onClick={onClose} className="mt-4 w-full py-2 text-sm text-muted-foreground border border-border rounded-xl hover:bg-accent">
</button>
</>
)}
</div>
</div>
);
}