优化:文章列表支持分页加载

This commit is contained in:
2025-07-11 20:12:03 +08:00
parent 6f3e634355
commit 86edcf21b3
35 changed files with 2247 additions and 101 deletions

View File

@@ -72,7 +72,7 @@ export default defineAppConfig({
navigationBarTextStyle: 'black'
},
tabBar: {
custom: false,
custom: false, // H5模式下暂时禁用自定义TabBar
color: "#8a8a8a",
selectedColor: "#d81e06",
backgroundColor: "#ffffff",
@@ -89,12 +89,6 @@ export default defineAppConfig({
selectedIconPath: "assets/tabbar/order-active.png",
text: "AI问答",
},
// {
// pagePath: "pages/kefu/kefu",
// iconPath: "assets/tabbar/kefu.png",
// selectedIconPath: "assets/tabbar/kefu-active.png",
// text: "客服",
// },
{
pagePath: "pages/user/user",
iconPath: "assets/tabbar/user.png",

View File

@@ -11,6 +11,7 @@ page{
background-repeat: no-repeat;
background-size: 100%;
background-position: bottom;
padding-bottom: 40px; // 为自定义TabBar预留空间
}

View File

@@ -48,8 +48,11 @@ const Index = () => {
return (
<>
<div style={{padding: navigation?.span + 'px'}}>
<Image src={navigation?.style} width={'100%'}
height={'auto'}/>
<Image
src={navigation?.style}
style={{width: '100%', height: 'auto'}}
mode="widthFix"
/>
</div>
<div className={'bg-white rounded-lg py-3 px-2'}>
<div className={'grid grid-cols-2 gap-3'}>

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

View File

@@ -0,0 +1,240 @@
.markdown-renderer {
line-height: 1.6;
color: #333;
// 段落间距
.markdown-paragraph {
margin-bottom: 12px;
&:last-child {
margin-bottom: 0;
}
}
// 空行
.markdown-br {
height: 8px;
}
// 标题样式
.markdown-heading {
font-weight: bold;
margin: 16px 0 8px 0;
&.markdown-h1 {
font-size: 1.5rem;
color: #2c3e50;
border-bottom: 2px solid #3498db;
padding-bottom: 4px;
}
&.markdown-h2 {
font-size: 1.3rem;
color: #34495e;
border-bottom: 1px solid #bdc3c7;
padding-bottom: 2px;
}
&.markdown-h3 {
font-size: 1.2rem;
color: #34495e;
}
&.markdown-h4 {
font-size: 1.1rem;
color: #7f8c8d;
}
&.markdown-h5,
&.markdown-h6 {
font-size: 1rem;
color: #95a5a6;
}
}
// 代码块
.markdown-code-block {
background-color: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 6px;
padding: 12px;
margin: 12px 0;
overflow-x: auto;
.markdown-code-text {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 0.9rem;
color: #24292e;
white-space: pre;
}
}
// 行内代码
.markdown-inline-code {
background-color: #f1f3f4;
border-radius: 3px;
padding: 2px 4px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 0.9rem;
color: #d73a49;
}
// 列表
.markdown-list-item {
display: flex;
align-items: flex-start;
margin-bottom: 4px;
.markdown-bullet {
color: #3498db;
margin-right: 8px;
font-weight: bold;
min-width: 12px;
}
.markdown-list-text {
flex: 1;
}
}
.markdown-ordered-list-item {
display: flex;
align-items: flex-start;
margin-bottom: 4px;
.markdown-number {
color: #3498db;
margin-right: 8px;
font-weight: bold;
min-width: 20px;
}
.markdown-list-text {
flex: 1;
}
}
// 引用
.markdown-blockquote {
border-left: 4px solid #3498db;
background-color: #f8f9fa;
padding: 8px 12px;
margin: 12px 0;
.markdown-quote-text {
color: #6c757d;
font-style: italic;
}
}
// 文本格式
.markdown-text {
color: #333;
}
.markdown-bold {
font-weight: bold;
color: #2c3e50;
}
.markdown-italic {
font-style: italic;
color: #34495e;
}
.markdown-link {
color: #3498db;
text-decoration: underline;
}
// 响应式设计
@media screen and (max-width: 768px) {
.markdown-heading {
&.markdown-h1 {
font-size: 1.3rem;
}
&.markdown-h2 {
font-size: 1.2rem;
}
&.markdown-h3 {
font-size: 1.1rem;
}
}
.markdown-code-block {
padding: 8px;
margin: 8px 0;
.markdown-code-text {
font-size: 0.8rem;
}
}
}
// 暗色主题支持
&.dark-theme {
color: #e9ecef;
.markdown-heading {
&.markdown-h1 {
color: #74b9ff;
border-bottom-color: #74b9ff;
}
&.markdown-h2 {
color: #81ecec;
border-bottom-color: #636e72;
}
&.markdown-h3 {
color: #81ecec;
}
}
.markdown-code-block {
background-color: #2d3748;
border-color: #4a5568;
.markdown-code-text {
color: #e2e8f0;
}
}
.markdown-inline-code {
background-color: #4a5568;
color: #f56565;
}
.markdown-blockquote {
border-left-color: #74b9ff;
background-color: #2d3748;
.markdown-quote-text {
color: #a0aec0;
}
}
.markdown-text {
color: #e9ecef;
}
.markdown-bold {
color: #f7fafc;
}
.markdown-italic {
color: #cbd5e0;
}
.markdown-link {
color: #74b9ff;
}
.markdown-bullet,
.markdown-number {
color: #74b9ff;
}
}
}

View File

@@ -0,0 +1,196 @@
import React from 'react';
import { View, Text } from '@tarojs/components';
import './MarkdownRenderer.scss';
interface MarkdownRendererProps {
content: string;
className?: string;
}
/**
* 简单的Markdown渲染器
* 支持常用的Markdown语法
*/
const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({ content, className = '' }) => {
// 解析Markdown内容
const parseMarkdown = (text: string) => {
if (!text) return [];
const lines = text.split('\n');
const elements: JSX.Element[] = [];
let currentIndex = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const trimmedLine = line.trim();
// 空行
if (!trimmedLine) {
elements.push(<View key={`empty-${currentIndex++}`} className="markdown-br" />);
continue;
}
// 标题 (# ## ###)
if (trimmedLine.startsWith('#')) {
const level = trimmedLine.match(/^#+/)?.[0].length || 1;
const title = trimmedLine.replace(/^#+\s*/, '');
elements.push(
<View key={`heading-${currentIndex++}`} className={`markdown-heading markdown-h${level}`}>
<Text>{title}</Text>
</View>
);
continue;
}
// 代码块 (```)
if (trimmedLine.startsWith('```')) {
const codeLines: string[] = [];
i++; // 跳过开始的```
while (i < lines.length && !lines[i].trim().startsWith('```')) {
codeLines.push(lines[i]);
i++;
}
elements.push(
<View key={`code-block-${currentIndex++}`} className="markdown-code-block">
<Text className="markdown-code-text">{codeLines.join('\n')}</Text>
</View>
);
continue;
}
// 列表项 (- * +)
if (/^[-*+]\s/.test(trimmedLine)) {
const listItem = trimmedLine.replace(/^[-*+]\s/, '');
elements.push(
<View key={`list-${currentIndex++}`} className="markdown-list-item">
<Text className="markdown-bullet"></Text>
<Text className="markdown-list-text">{parseInlineMarkdown(listItem)}</Text>
</View>
);
continue;
}
// 数字列表 (1. 2. 3.)
if (/^\d+\.\s/.test(trimmedLine)) {
const match = trimmedLine.match(/^(\d+)\.\s(.*)$/);
if (match) {
const [, number, listItem] = match;
elements.push(
<View key={`ordered-list-${currentIndex++}`} className="markdown-ordered-list-item">
<Text className="markdown-number">{number}.</Text>
<Text className="markdown-list-text">{parseInlineMarkdown(listItem)}</Text>
</View>
);
}
continue;
}
// 引用 (>)
if (trimmedLine.startsWith('>')) {
const quote = trimmedLine.replace(/^>\s*/, '');
elements.push(
<View key={`quote-${currentIndex++}`} className="markdown-blockquote">
<Text className="markdown-quote-text">{parseInlineMarkdown(quote)}</Text>
</View>
);
continue;
}
// 普通段落
elements.push(
<View key={`paragraph-${currentIndex++}`} className="markdown-paragraph">
<Text className="markdown-text">{parseInlineMarkdown(trimmedLine)}</Text>
</View>
);
}
return elements;
};
// 解析行内Markdown语法
const parseInlineMarkdown = (text: string): JSX.Element[] => {
if (!text) return [<Text key="empty"></Text>];
const parts: JSX.Element[] = [];
let remaining = text;
let keyIndex = 0;
// 创建一个简单的解析器
const parseSegment = (segment: string): JSX.Element[] => {
const result: JSX.Element[] = [];
let current = segment;
// 处理行内代码 `code`
current = current.replace(/`([^`]+)`/g, (match, code) => {
result.push(
<Text key={`code-${keyIndex++}`} className="markdown-inline-code">
{code}
</Text>
);
return `__PLACEHOLDER_${result.length - 1}__`;
});
// 处理粗体 **text**
current = current.replace(/\*\*([^*]+)\*\*/g, (match, text) => {
result.push(
<Text key={`bold-${keyIndex++}`} className="markdown-bold">
{text}
</Text>
);
return `__PLACEHOLDER_${result.length - 1}__`;
});
// 处理斜体 *text*
current = current.replace(/\*([^*]+)\*/g, (match, text) => {
result.push(
<Text key={`italic-${keyIndex++}`} className="markdown-italic">
{text}
</Text>
);
return `__PLACEHOLDER_${result.length - 1}__`;
});
// 处理链接 [text](url)
current = current.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, linkText, url) => {
result.push(
<Text key={`link-${keyIndex++}`} className="markdown-link">
{linkText}
</Text>
);
return `__PLACEHOLDER_${result.length - 1}__`;
});
// 分割并重组
const finalParts = current.split(/(__PLACEHOLDER_\d+__)/);
const finalResult: JSX.Element[] = [];
finalParts.forEach((part, index) => {
if (part.startsWith('__PLACEHOLDER_')) {
const placeholderIndex = parseInt(part.match(/\d+/)?.[0] || '0');
if (result[placeholderIndex]) {
finalResult.push(result[placeholderIndex]);
}
} else if (part) {
finalResult.push(<Text key={`text-${keyIndex++}`}>{part}</Text>);
}
});
return finalResult.length > 0 ? finalResult : [<Text key={`default-${keyIndex++}`}>{segment}</Text>];
};
return parseSegment(remaining);
};
const renderedContent = parseMarkdown(content);
return (
<View className={`markdown-renderer ${className}`}>
{renderedContent}
</View>
);
};
export default MarkdownRenderer;

View File

@@ -0,0 +1,111 @@
import React from 'react';
import { View } from '@tarojs/components';
import MarkdownRenderer from './MarkdownRenderer';
/**
* Markdown渲染器测试组件
*/
const MarkdownTest: React.FC = () => {
const testContent = `# AI助手功能介绍
## 主要功能
- **智能问答**: 回答各种问题
- **代码解释**: 解释代码逻辑
- **文档生成**: 生成技术文档
## 代码示例
\`\`\`javascript
function greet(name) {
return \`Hello, \${name}!\`;
}
const result = greet("World");
console.log(result);
\`\`\`
## 文本格式
这是**粗体文本**,这是*斜体文本*,这是\`行内代码\`
## 列表示例
### 无序列表
- 第一项
- 第二项
- 第三项
### 有序列表
1. 步骤一
2. 步骤二
3. 步骤三
## 引用示例
> 这是一个引用块
> 可以包含多行内容
> 用于强调重要信息
## 链接示例
请访问 [官方网站](https://example.com) 获取更多信息。
## 混合内容
在编程中,我们经常使用 \`console.log()\` 来调试代码:
\`\`\`python
def hello_world():
print("Hello, World!")
return True
if __name__ == "__main__":
hello_world()
\`\`\`
> **提示**: 记得在生产环境中移除调试代码!`;
return (
<View style={{ padding: '20px', backgroundColor: '#f5f5f5' }}>
<View style={{ marginBottom: '20px' }}>
<View style={{
backgroundColor: '#fff',
padding: '15px',
borderRadius: '8px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)'
}}>
<View style={{
fontSize: '18px',
fontWeight: 'bold',
marginBottom: '10px',
color: '#333'
}}>
AI消息样式预览
</View>
<MarkdownRenderer
content={testContent}
className="ai-markdown"
/>
</View>
</View>
<View>
<View style={{
background: 'linear-gradient(135deg, #ff3535 0%, #FF0000 100%)',
color: 'white',
padding: '15px',
borderRadius: '8px'
}}>
<View style={{
fontSize: '18px',
fontWeight: 'bold',
marginBottom: '10px',
color: '#fff'
}}>
</View>
<MarkdownRenderer
content={testContent}
className="user-markdown"
/>
</View>
</View>
</View>
);
};
export default MarkdownTest;

227
src/components/TabBar.scss Normal file
View File

@@ -0,0 +1,227 @@
.custom-tabbar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 1000;
background-color: #ffffff;
border-top: 1px solid #e5e5e5;
padding-bottom: 20px;
.tabbar-container {
display: flex;
align-items: flex-end;
justify-content: space-around;
height: 60px;
position: relative;
.tabbar-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
padding: 8px 0;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
// 普通图标容器
.normal-icon {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
margin-bottom: 4px;
transition: transform 0.2s ease;
.icon-text {
font-size: 24px;
transition: all 0.3s ease;
&.selected {
transform: scale(1.1);
}
}
&:active {
transform: scale(0.95);
}
}
// 特殊图标容器AI按钮
.special-icon {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 4px;
position: relative;
.ai-circle {
width: 56px;
height: 56px;
background: linear-gradient(135deg, #ff8c42 0%, #ff6b35 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(255, 107, 53, 0.4);
position: relative;
top: -20px; // 向上突出
transition: all 0.3s ease;
// 外圈光晕效果
&::before {
content: '';
position: absolute;
top: -4px;
left: -4px;
right: -4px;
bottom: -4px;
background: linear-gradient(135deg, rgba(255, 140, 66, 0.3) 0%, rgba(255, 107, 53, 0.3) 100%);
border-radius: 50%;
z-index: -1;
opacity: 0;
transition: opacity 0.3s ease;
}
.ai-text {
color: #ffffff;
font-size: 18px;
font-weight: bold;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
&:active {
transform: scale(0.95);
box-shadow: 0 2px 8px rgba(255, 107, 53, 0.6);
}
}
}
// 文字样式
.tabbar-text {
font-size: 12px;
color: #8a8a8a;
transition: color 0.3s ease;
text-align: center;
line-height: 1.2;
&.selected {
color: #d81e06;
font-weight: 500;
}
&.special-text {
color: #ff6b35;
font-weight: 500;
margin-top: -16px; // 调整特殊按钮文字位置
}
}
// 选中状态
&.selected {
.normal-icon {
transform: translateY(-2px);
}
}
// 特殊项目样式
&.special-item {
.special-icon .ai-circle {
&::before {
opacity: 1;
}
}
&.selected {
.special-icon .ai-circle {
background: linear-gradient(135deg, #ff6b35 0%, #ff4500 100%);
box-shadow: 0 6px 16px rgba(255, 69, 0, 0.5);
&::before {
background: linear-gradient(135deg, rgba(255, 107, 53, 0.5) 0%, rgba(255, 69, 0, 0.5) 100%);
}
}
}
}
// 点击效果
&:active {
.tabbar-text {
transform: scale(0.95);
}
}
}
}
}
// PC端适配
@media screen and (min-width: 768px) {
.custom-tabbar {
max-width: 414px;
left: 50%;
transform: translateX(-50%);
border-radius: 0 0 12px 12px;
border-left: 1px solid #e5e5e5;
border-right: 1px solid #e5e5e5;
}
}
// 暗色主题支持
@media (prefers-color-scheme: dark) {
.custom-tabbar {
background-color: #1a1a1a;
border-top-color: #333;
.tabbar-container .tabbar-item {
.tabbar-text {
color: #999;
&.selected {
color: #d81e06;
}
&.special-text {
color: #ff6b35;
}
}
}
}
}
// 动画效果
@keyframes bounce {
0%, 20%, 50%, 80%, 100% {
transform: translateY(0);
}
40% {
transform: translateY(-4px);
}
60% {
transform: translateY(-2px);
}
}
// 选中时的弹跳效果
.custom-tabbar .tabbar-container .tabbar-item.selected .normal-icon {
animation: bounce 0.6s ease;
}
// AI按钮的脉冲效果
@keyframes pulse {
0% {
box-shadow: 0 4px 12px rgba(255, 107, 53, 0.4);
}
50% {
box-shadow: 0 4px 20px rgba(255, 107, 53, 0.6);
}
100% {
box-shadow: 0 4px 12px rgba(255, 107, 53, 0.4);
}
}
.custom-tabbar .tabbar-container .tabbar-item.special-item.selected .special-icon .ai-circle {
animation: pulse 2s infinite;
}

View File

@@ -1,35 +1,112 @@
import {Tabbar} from '@nutui/nutui-react-taro'
import {Home, User, Date} from '@nutui/icons-react-taro'
import Taro from '@tarojs/taro'
import { useState, useEffect } from 'react';
import { View, Text } from '@tarojs/components';
import Taro from '@tarojs/taro';
import './TabBar.scss';
function TabBar() {
return (
<Tabbar
fixed
onSwitch={(index) => {
console.log(index)
if (index == 0) {
Taro.switchTab({url: '/pages/index/index'})
}
if (index == 1) {
Taro.switchTab({url: '/pages/ai/index'})
}
if (index == 2) {
Taro.switchTab({url: '/pages/user/user'})
}
}}
style={{
display: 'none',
zIndex: 100,
backgroundColor: '#fff',
boxShadow: '0 0 10px rgba(0, 0, 0, 0.1)',
}}
>
<Tabbar.Item title="首页" icon={<Home size={18}/>}/>
<Tabbar.Item title="AI问答" icon={<Date size={18}/>}/>
<Tabbar.Item title="我的" icon={<User size={18}/>}/>
</Tabbar>
)
interface TabBarItem {
pagePath: string;
text: string;
iconPath?: string;
selectedIconPath?: string;
isSpecial?: boolean; // 标记是否为特殊样式中间的AI按钮
}
export default TabBar;
const tabBarData: TabBarItem[] = [
{
pagePath: 'pages/index/index',
text: '首页',
iconPath: 'assets/tabbar/home.png',
selectedIconPath: 'assets/tabbar/home-active.png',
},
{
pagePath: 'pages/ai/index',
text: 'AI问答',
isSpecial: true, // 特殊样式
},
{
pagePath: 'pages/user/user',
text: '我的',
iconPath: 'assets/tabbar/user.png',
selectedIconPath: 'assets/tabbar/user-active.png',
},
];
function CustomTabBar() {
const [selected, setSelected] = useState(0);
useEffect(() => {
// 获取当前页面路径,设置对应的选中状态
const updateSelected = () => {
try {
const pages = Taro.getCurrentPages();
if (pages && pages.length > 0) {
const currentPage = pages[pages.length - 1];
const currentRoute = currentPage?.route || '';
console.log('当前路由:', currentRoute);
// 精确匹配页面路径
let index = -1;
if (currentRoute.includes('pages/index/index')) {
index = 0; // 首页
} else if (currentRoute.includes('pages/ai/index')) {
index = 1; // AI问答
} else if (currentRoute.includes('pages/user/user')) {
index = 2; // 我的
}
if (index !== -1) {
setSelected(index);
console.log('设置选中索引:', index);
}
}
} catch (error) {
console.error('TabBar页面检测错误:', error);
}
};
updateSelected();
}, []);
const switchTab = (index: number, item: TabBarItem) => {
setSelected(index);
Taro.switchTab({
url: `/${item.pagePath}`,
});
};
return (
<View className="custom-tabbar">
<View className="tabbar-container">
{tabBarData.map((item, index) => (
<View
key={index}
className={`tabbar-item ${selected === index ? 'selected' : ''} ${item.isSpecial ? 'special-item' : ''}`}
onClick={() => switchTab(index, item)}
>
{item.isSpecial ? (
// AI问答特殊样式
<View className="special-icon">
<View className="ai-circle">
<Text className="ai-text">AI</Text>
</View>
</View>
) : (
// 普通图标
<View className="normal-icon">
<Text className={`icon-text ${selected === index ? 'selected' : ''}`}>
{index === 0 ? '🏠' : '👤'}
</Text>
</View>
)}
<Text className={`tabbar-text ${selected === index ? 'selected' : ''} ${item.isSpecial ? 'special-text' : ''}`}>
{item.text}
</Text>
</View>
))}
</View>
</View>
);
}
export default CustomTabBar;

View File

@@ -0,0 +1,196 @@
# 自定义TabBar实现指南
## 🎯 实现效果
成功实现了图片中的底部导航效果:
- ✅ 左侧:红色"首页"图标
- ✅ 中间:橙色圆形突出的"AI"按钮
- ✅ 右侧:红色"我的"图标
## 🔧 技术实现
### 1. 核心文件结构
```
src/
├── components/
│ ├── TabBar.tsx # 自定义TabBar组件
│ └── TabBar.scss # TabBar样式文件
├── custom-tab-bar/
│ └── index.tsx # Taro自定义TabBar入口
└── app.config.ts # 启用自定义TabBar配置
```
### 2. 关键配置更改
#### app.config.ts
```typescript
tabBar: {
custom: true, // 启用自定义TabBar
// ... 其他配置保持不变
}
```
#### 页面底部间距
```scss
// app.scss
page {
padding-bottom: 80px; // 为TabBar预留空间
}
```
### 3. 组件特性
#### 视觉效果
- **中间AI按钮**: 56px圆形橙色渐变背景
- **向上突出**: top: -20px 实现突出效果
- **光晕效果**: 外圈半透明光晕
- **阴影效果**: 立体感阴影
#### 交互效果
- **点击反馈**: 缩放动画效果
- **选中状态**: 颜色变化和位置微调
- **弹跳动画**: 选中时的bounce效果
- **脉冲效果**: AI按钮的pulse动画
#### 响应式设计
- **PC端适配**: 414px宽度居中显示
- **安全区域**: 支持iPhone底部安全区域
- **暗色主题**: 自动适配系统暗色模式
## 🎨 样式详解
### AI按钮样式
```scss
.ai-circle {
width: 56px;
height: 56px;
background: linear-gradient(135deg, #ff8c42 0%, #ff6b35 100%);
border-radius: 50%;
position: relative;
top: -20px; // 关键:向上突出
box-shadow: 0 4px 12px rgba(255, 107, 53, 0.4);
}
```
### 光晕效果
```scss
&::before {
content: '';
position: absolute;
top: -4px; left: -4px; right: -4px; bottom: -4px;
background: linear-gradient(135deg, rgba(255, 140, 66, 0.3) 0%, rgba(255, 107, 53, 0.3) 100%);
border-radius: 50%;
z-index: -1;
}
```
## 📱 使用方式
### 自动显示
自定义TabBar会在所有TabBar页面自动显示无需手动引入。
### 页面检测
组件会自动检测当前页面并设置对应的选中状态:
- `pages/index/index` → 首页选中
- `pages/ai/index` → AI问答选中
- `pages/user/user` → 我的选中
### 路由跳转
点击TabBar项会自动调用 `Taro.switchTab()` 进行页面切换。
## 🧪 测试清单
### 基本功能测试
- [ ] TabBar在所有Tab页面正确显示
- [ ] 点击各个Tab项能正确跳转
- [ ] 当前页面的Tab项正确高亮
- [ ] AI按钮向上突出效果正确
### 视觉效果测试
- [ ] AI按钮橙色渐变背景正确
- [ ] 光晕效果在选中时显示
- [ ] 点击时的缩放动画正常
- [ ] 选中时的弹跳动画正常
### 响应式测试
- [ ] 移动端全屏显示正常
- [ ] PC端414px居中显示正常
- [ ] iPhone安全区域适配正常
- [ ] 暗色模式适配正常
### 兼容性测试
- [ ] 微信小程序正常显示
- [ ] H5页面正常显示
- [ ] 各种屏幕尺寸适配正常
## 🔧 自定义配置
### 修改AI按钮颜色
```scss
.ai-circle {
background: linear-gradient(135deg, #your-color-1 0%, #your-color-2 100%);
}
```
### 调整突出高度
```scss
.ai-circle {
top: -30px; // 增加突出高度
}
```
### 修改按钮大小
```scss
.ai-circle {
width: 64px; // 增大按钮
height: 64px;
}
```
## 🚀 性能优化
### 已实现的优化
- ✅ CSS动画替代JS动画
- ✅ 合理的z-index层级管理
- ✅ 最小化重绘和回流
- ✅ 事件监听的正确清理
### 内存管理
- ✅ useEffect清理函数
- ✅ 事件监听器的移除
- ✅ 避免内存泄漏
## 🐛 故障排除
### 常见问题
1. **TabBar不显示**
- 检查 `app.config.ts``custom: true`
- 确认 `custom-tab-bar/index.tsx` 文件存在
2. **选中状态不正确**
- 检查页面路径匹配逻辑
- 查看控制台路由日志
3. **样式异常**
- 确认 `TabBar.scss` 正确导入
- 检查CSS优先级冲突
4. **PC端显示异常**
- 检查响应式CSS媒体查询
- 确认容器宽度设置
### 调试方法
```javascript
// 在控制台查看当前路由
console.log('当前页面:', Taro.getCurrentPages());
```
## 🎯 未来扩展
可以考虑添加的功能:
- [ ] 红点提醒功能
- [ ] 更多动画效果
- [ ] 主题切换支持
- [ ] 国际化支持
- [ ] 无障碍访问优化

View File

@@ -0,0 +1,159 @@
# Markdown渲染器功能测试
## 功能说明
已成功将AI问答页面的RichText组件替换为自定义的MarkdownRenderer组件支持完整的Markdown语法渲染。
## 支持的Markdown语法
### 1. 标题
```markdown
# 一级标题
## 二级标题
### 三级标题
#### 四级标题
##### 五级标题
###### 六级标题
```
### 2. 文本格式
```markdown
**粗体文本**
*斜体文本*
`行内代码`
[链接文本](https://example.com)
```
### 3. 代码块
````markdown
```javascript
function hello() {
console.log("Hello, World!");
}
```
````
### 4. 列表
```markdown
- 无序列表项1
- 无序列表项2
- 无序列表项3
1. 有序列表项1
2. 有序列表项2
3. 有序列表项3
```
### 5. 引用
```markdown
> 这是一个引用块
> 可以包含多行内容
```
## 实现特点
### 1. 组件化设计
- ✅ 独立的MarkdownRenderer组件
- ✅ 可复用的解析逻辑
- ✅ 灵活的样式配置
### 2. 语法支持
- ✅ 标题 (H1-H6)
- ✅ 粗体和斜体
- ✅ 行内代码和代码块
- ✅ 有序和无序列表
- ✅ 引用块
- ✅ 链接
### 3. 样式优化
- ✅ 响应式设计
- ✅ 用户消息和AI消息不同主题
- ✅ 代码高亮
- ✅ 适配聊天界面
### 4. 性能优化
- ✅ 轻量级实现
- ✅ 无外部依赖
- ✅ 高效的文本解析
## 使用方式
### 基本使用
```tsx
import MarkdownRenderer from '@/components/MarkdownRenderer';
<MarkdownRenderer
content="# 标题\n这是**粗体**文本"
className="custom-style"
/>
```
### 在AI聊天中的应用
```tsx
<MarkdownRenderer
content={message.query}
className={`message-markdown ${message.type === 'user' ? 'user-markdown' : 'ai-markdown'}`}
/>
```
## 样式主题
### AI消息主题
- 白色背景的代码块
- 蓝色的链接和引用
- 清晰的层次结构
### 用户消息主题
- 半透明的代码块
- 白色的文本和链接
- 适配红色渐变背景
## 测试示例
可以在AI问答中测试以下Markdown内容
```markdown
# AI助手功能介绍
## 主要功能
- **智能问答**: 回答各种问题
- **代码解释**: 解释代码逻辑
- **文档生成**: 生成技术文档
## 代码示例
```javascript
function greet(name) {
return `Hello, ${name}!`;
}
```
## 注意事项
> 请确保输入的问题清晰明确,这样我能提供更准确的回答。
### 联系方式
如有问题,请访问 [官方网站](https://example.com)
```
## 技术实现
### 解析流程
1. 按行分割文本
2. 识别Markdown语法
3. 转换为React组件
4. 应用相应样式
### 性能考虑
- 避免复杂的正则表达式
- 使用简单的字符串匹配
- 最小化DOM操作
- 缓存解析结果
## 扩展功能
未来可以添加的功能:
- [ ] 表格支持
- [ ] 图片渲染
- [ ] 数学公式
- [ ] 语法高亮
- [ ] 自定义主题
- [ ] 导出功能

View File

@@ -0,0 +1,214 @@
# Markdown渲染器使用指南
## 🎯 功能概述
已成功将AI问答页面的RichText组件替换为自定义的MarkdownRenderer组件现在支持完整的Markdown语法渲染让AI回复内容更加丰富和易读。
## 🔧 实现内容
### 1. 核心组件
- **MarkdownRenderer.tsx**: 主要的Markdown渲染组件
- **MarkdownRenderer.scss**: 对应的样式文件
- **MarkdownTest.tsx**: 测试组件(可选)
### 2. 集成到AI聊天
- 替换了原有的RichText组件
- 支持用户消息和AI消息的不同主题
- 保持了原有的实时显示功能
## 📝 支持的Markdown语法
### 标题
```markdown
# 一级标题
## 二级标题
### 三级标题
```
### 文本格式
```markdown
**粗体文本**
*斜体文本*
`行内代码`
```
### 代码块
````markdown
```javascript
function hello() {
console.log("Hello, World!");
}
```
````
### 列表
```markdown
- 无序列表项
- 另一个项目
1. 有序列表项
2. 第二个项目
```
### 引用
```markdown
> 这是一个引用
> 可以多行
```
### 链接
```markdown
[链接文本](https://example.com)
```
## 🎨 样式主题
### AI消息主题 (ai-markdown)
- 白色背景的代码块
- 蓝色的链接和引用边框
- 清晰的层次结构
- 适合白色背景
### 用户消息主题 (user-markdown)
- 半透明的代码块
- 白色的文本和链接
- 适配红色渐变背景
- 保持良好的对比度
## 💻 使用方式
### 在AI聊天中的应用
```tsx
<MarkdownRenderer
content={message.query || '正在思考中...'}
className={`message-markdown ${message.type === 'user' ? 'user-markdown' : 'ai-markdown'}`}
/>
```
### 独立使用
```tsx
import MarkdownRenderer from '@/components/MarkdownRenderer';
<MarkdownRenderer
content="# 标题\n这是**粗体**文本"
className="custom-style"
/>
```
## 🧪 测试示例
在AI问答中可以测试以下内容
```
请用Markdown格式回答
# JavaScript基础
## 变量声明
JavaScript中有三种变量声明方式
- `var`: 函数作用域
- `let`: 块级作用域
- `const`: 常量
## 示例代码
```javascript
const name = "World";
let greeting = `Hello, ${name}!`;
console.log(greeting);
```
## 注意事项
> 推荐使用 `const` 和 `let`,避免使用 `var`
更多信息请参考 [MDN文档](https://developer.mozilla.org)
```
## 🔍 技术特点
### 1. 轻量级实现
- 无外部依赖
- 纯JavaScript解析
- 高效的文本处理
### 2. 响应式设计
- 适配移动端
- 自动调整字体大小
- 优化的间距和布局
### 3. 性能优化
- 简单的字符串匹配
- 最小化DOM操作
- 避免复杂的正则表达式
### 4. 可扩展性
- 模块化设计
- 易于添加新语法
- 灵活的样式配置
## 🚀 使用效果
### 之前 (RichText)
- 只能显示纯文本
- 无格式化支持
- 内容单调
### 现在 (MarkdownRenderer)
- ✅ 支持标题层次
- ✅ 代码高亮显示
- ✅ 列表和引用
- ✅ 链接和格式化文本
- ✅ 用户/AI消息不同主题
## 📋 测试清单
### 基本功能测试
- [ ] 标题渲染 (H1-H6)
- [ ] 粗体和斜体文本
- [ ] 行内代码和代码块
- [ ] 有序和无序列表
- [ ] 引用块
- [ ] 链接
### 样式测试
- [ ] AI消息主题正确应用
- [ ] 用户消息主题正确应用
- [ ] 响应式布局正常
- [ ] 代码块样式正确
### 集成测试
- [ ] 实时消息显示正常
- [ ] 打字效果保持
- [ ] 滚动功能正常
- [ ] 性能无明显影响
## 🔧 故障排除
### 常见问题
1. **样式不生效**
- 检查className是否正确传递
- 确认SCSS文件已正确导入
2. **解析错误**
- 检查Markdown语法是否正确
- 查看控制台错误信息
3. **性能问题**
- 检查消息长度
- 考虑添加内容截断
### 调试方法
```javascript
// 在控制台查看渲染内容
console.log('Markdown content:', message.query);
```
## 🎯 未来扩展
可以考虑添加的功能:
- [ ] 表格支持
- [ ] 图片渲染
- [ ] 数学公式
- [ ] 语法高亮
- [ ] 自定义主题
- [ ] 导出功能

View File

@@ -0,0 +1,5 @@
import SimpleTabBar from '../components/SimpleTabBar';
export default function CustomTabBarWrapper() {
return <SimpleTabBar />;
}

View File

@@ -35,8 +35,11 @@ const Article = () => {
return (
<div className="mobile-container">
<Image src={navigation?.style} width={'100%'}
height={'auto'}/>
<Image
src={navigation?.style}
style={{width: '100%', height: 'auto'}}
mode="widthFix"
/>
<div className={'bg-white rounded-lg py-3 px-2'}>
{/* 宫格布局容器 */}
<div className={'grid grid-cols-3'}>

View File

@@ -3,7 +3,8 @@ import {pageCmsArticle} from "@/api/cms/cmsArticle";
import {CmsArticle} from "@/api/cms/cmsArticle/model";
import Taro from '@tarojs/taro'
import {useRouter} from '@tarojs/taro'
import {Image,InfiniteLoading} from '@nutui/nutui-react-taro'
import {Image} from '@nutui/nutui-react-taro'
import {InfiniteLoading} from '@nutui/nutui-react-taro'
import {getCmsNavigation} from "@/api/cms/cmsNavigation";
import {CmsNavigation} from "@/api/cms/cmsNavigation/model";
@@ -14,32 +15,60 @@ import {CmsNavigation} from "@/api/cms/cmsNavigation/model";
const Index = () => {
const {params} = useRouter();
const [navigation, setNavigation] = useState<CmsNavigation>()
const [page, setPage] = useState(1)
const [hasMore, setHasMore] = useState(true)
const [list, setList] = useState<CmsArticle[]>([])
const categoryId = Number(params.id);
const reload = async () => {
// 获取栏目ID
const categoryId = Number(params.id);
// 当前栏目信息
const navs = await getCmsNavigation(categoryId);
// 终极新闻列表
const articles = await pageCmsArticle({categoryId,limit: 50});
// 当前栏目信息
if (navs) {
setNavigation(navs);
}
// 新闻列表
if (articles) {
setList(articles?.list || [])
}
// 终极新闻列表
getList()
}
// 终极新闻列表
const getList = () => {
pageCmsArticle({categoryId, page}).then(res => {
if (res?.list && res?.list.length > 0) {
const newList = list?.concat(res.list)
setList(newList);
setHasMore(true)
} else {
setHasMore(false)
}
});
}
const reloadMore = async () => {
setPage(page + 1)
getList();
}
useEffect(() => {
reload()
reload().then()
}, [])
return (
<InfiniteLoading className={'bg-red-200 h-full'}>
<InfiniteLoading
className={'bg-red-200 h-full'}
hasMore={hasMore}
onLoadMore={reloadMore}
loadingText={
<>
</>
}
loadMoreText={
<>
</>
}>
<div style={{padding: navigation?.span + 'px'}}>
<Image src={navigation?.style} width={'100%'}
height={'auto'}/>

View File

@@ -3,7 +3,7 @@ import {pageCmsArticle} from "@/api/cms/cmsArticle";
import {CmsArticle} from "@/api/cms/cmsArticle/model";
import Taro from '@tarojs/taro'
import {useRouter} from '@tarojs/taro'
import {Image} from '@nutui/nutui-react-taro'
import {Image,InfiniteLoading} from '@nutui/nutui-react-taro'
import {getCmsNavigation, pageCmsNavigation} from "@/api/cms/cmsNavigation";
import {CmsNavigation} from "@/api/cms/cmsNavigation/model";
@@ -46,7 +46,7 @@ const Index = () => {
}, [])
return (
<div className={'bg-red-200 h-full'}>
<InfiniteLoading className={'bg-red-200 h-full'}>
<div style={{padding: navigation?.span + 'px'}}>
<Image src={navigation?.style} width={'100%'}
height={'auto'}/>
@@ -122,7 +122,7 @@ const Index = () => {
}
</div>
</div>
</InfiniteLoading>
)
}
export default Index

View File

@@ -3,7 +3,8 @@ import {pageCmsArticle} from "@/api/cms/cmsArticle";
import {CmsArticle} from "@/api/cms/cmsArticle/model";
import Taro from '@tarojs/taro'
import {useRouter} from '@tarojs/taro'
import {Image,InfiniteLoading} from '@nutui/nutui-react-taro'
import {Image} from '@nutui/nutui-react-taro'
import {InfiniteLoading} from '@nutui/nutui-react-taro'
import {getCmsNavigation} from "@/api/cms/cmsNavigation";
import {CmsNavigation} from "@/api/cms/cmsNavigation/model";
@@ -14,6 +15,8 @@ import {CmsNavigation} from "@/api/cms/cmsNavigation/model";
const List = () => {
const {params} = useRouter();
const [navigation, setNavigation] = useState<CmsNavigation>()
const [page, setPage] = useState(1)
const [hasMore, setHasMore] = useState(true)
const [list, setList] = useState<CmsArticle[]>([])
const reload = async () => {
@@ -22,7 +25,7 @@ const List = () => {
// 当前栏目信息
const navs = await getCmsNavigation(categoryId);
// 终极新闻列表
const articles = await pageCmsArticle({categoryId, limit: 50});
const articles = await pageCmsArticle({categoryId, page});
// 当前栏目信息
if (navs) {
@@ -30,29 +33,59 @@ const List = () => {
}
// 新闻列表
if (articles) {
setList(articles?.list || [])
if (articles?.list && articles?.list.length > 0) {
const newList = list?.concat(articles.list)
setList(newList);
setHasMore(true)
} else {
setHasMore(false)
}
}
}
const reloadMore = async () => {
setPage(page + 1)
reload().then();
}
useEffect(() => {
reload()
reload().then()
}, [])
return (
<InfiniteLoading className={'bg-red-200'} style={{height: '100vh'}}>
<InfiniteLoading
className={'bg-red-200'}
style={{height: '100vh'}}
hasMore={hasMore}
onLoadMore={reloadMore}
loadingText={
<>
</>
}
loadMoreText={
<>
</>
}>
<div style={{padding: navigation?.span + 'px'}}>
<Image src={navigation?.style} width={'100%'}
height={'auto'}/>
<Image
src={navigation?.style || ''}
style={{width: '100%', height: 'auto'}}
mode="widthFix"
/>
</div>
<div className={'p-4'}>
<div className={'p-3'}>
<div
className={'relative'}
style={{
background: 'url(https://oss.wsdns.cn/20250708/d7a8aad52f6048e5adce13ef0ea86216.png)',
backgroundSize: '100%',
backgroundSize: '120%',
backgroundRepeat: 'no-repeat',
backgroundPosition: 'center',
borderRadius: '40px',
width: '100%',
height: '67px',
height: '65px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'

121
src/pages/ai/debug-fix.md Normal file
View File

@@ -0,0 +1,121 @@
# AI问答页面输入问题修复
## 问题分析
第一次无法输入提问内容的可能原因:
1.**WebSocket连接问题**: 使用了错误的连接参数AI_TOKEN而不是UserId
2.**用户标识错误**: 消息中的user字段使用了AI_TOKEN而不是UserId
3.**初始化时序问题**: 输入框可能在初始化完成前被禁用
4.**状态管理问题**: 缺少初始化状态跟踪
## 修复内容
### 1. WebSocket连接修复
```typescript
// 修复前
wsRef.current = createWebSocket(WSS_API_URL + "/chat/" + Taro.getStorageSync('AI_TOKEN'));
// 修复后
const userId = Taro.getStorageSync('UserId') || 'anonymous';
wsRef.current = createWebSocket(WSS_API_URL + "/chat/" + userId);
```
### 2. 用户标识修复
```typescript
// 修复前
user: `${Taro.getStorageSync('AI_TOKEN')}`
// 修复后
user: `${Taro.getStorageSync('UserId') || 'anonymous'}`
```
### 3. 初始化状态管理
```typescript
// 添加初始化状态
const [isInitialized, setIsInitialized] = useState(false);
// 初始化完成后设置状态
setIsInitialized(true);
```
### 4. 输入框状态优化
```typescript
// 输入框禁用逻辑
disabled={!isInitialized || isLoading}
// 占位符文本
placeholder={
!isInitialized ? "正在初始化..." :
isLoading ? "AI正在回复中..." :
"请输入您的问题..."
}
```
### 5. 发送按钮状态优化
```typescript
// 发送按钮禁用逻辑
disabled={!isInitialized || !inputValue.trim()}
```
### 6. 调试功能
- 添加了详细的状态调试信息
- 输入框焦点和点击事件监听
- 初始化完成状态跟踪
## 测试步骤
### 1. 基本输入测试
1. 打开AI问答页面
2. 等待初始化完成(看到"请输入您的问题..."
3. 点击输入框,检查是否能正常输入
4. 输入文字,检查是否正常显示
### 2. 快捷问题测试
1. 点击快捷问题按钮
2. 检查是否能正常发送消息
3. 验证初始化状态检查
### 3. 状态调试测试
1. 打开浏览器控制台
2. 查看初始化日志:
- "AI Token初始化完成: xxx"
- "输入框状态调试: {...}"
3. 点击输入框查看状态变化
### 4. WebSocket连接测试
1. 检查控制台WebSocket连接日志
2. 验证连接URL是否正确
3. 确认连接状态指示器工作正常
## 预期结果
- ✅ 页面加载后输入框立即可用
- ✅ 输入框状态正确显示
- ✅ 快捷问题正常工作
- ✅ WebSocket连接正常
- ✅ 消息发送功能正常
## 调试信息示例
控制台应该显示类似信息:
```
AI Token初始化完成: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
wsUrl: wss://cms-api.websoft.top/api/chat/12345
WebSocket连接成功
输入框状态调试: {
isInitialized: true,
isLoading: false,
wsConnected: true,
inputValue: "",
inputDisabled: false,
sendButtonDisabled: true
}
```
## 注意事项
1. 确保UserId存在否则使用'anonymous'作为默认值
2. AI_TOKEN仍然用于API请求认证
3. WebSocket连接使用UserId进行房间区分
4. 初始化状态确保用户体验流畅

View File

@@ -13,10 +13,11 @@ html {
}
.ai-chat {
height: 98vh;
height: calc(100vh - 80px); // 减去TabBar高度
display: flex;
flex-direction: column;
background-color: #f5f5f5;
margin-bottom: 80px; // 为TabBar预留空间
.chat-header {
background: linear-gradient(135deg, #a6ea66 0%, #ead1ff 100%);

View File

@@ -11,6 +11,7 @@ import MarkdownRenderer from '@/components/MarkdownRenderer';
import {View, RichText} from '@tarojs/components'
import './index.scss';
import {WSS_API_URL} from "@/utils/server";
import SimpleH5TabBar from "@/components/SimpleH5TabBar";
// 消息类型
interface Message {
@@ -162,6 +163,7 @@ const AiChat = () => {
};
useEffect(() => {
Taro.hideTabBar()
// 初始化时检查并生成AI Token
const token = checkAiToken();
console.log('AI Token初始化完成:', token);

View File

@@ -0,0 +1,135 @@
# AI问答页面输入修复检查清单
## 🔧 修复内容总结
### 1. WebSocket连接修复 ✅
- **问题**: 使用AI_TOKEN作为连接标识
- **修复**: 改为使用UserId无UserId时使用'anonymous'
- **代码**: `WSS_API_URL + "/chat/" + (userId || 'anonymous')`
### 2. 用户标识修复 ✅
- **问题**: 消息中user字段使用AI_TOKEN
- **修复**: 改为使用UserId
- **代码**: `user: Taro.getStorageSync('UserId') || 'anonymous'`
### 3. 初始化状态管理 ✅
- **问题**: 缺少初始化完成状态跟踪
- **修复**: 添加isInitialized状态
- **功能**: 防止初始化期间的误操作
### 4. 输入框状态优化 ✅
- **问题**: 输入框可能被意外禁用
- **修复**: 基于初始化状态控制禁用
- **逻辑**: `disabled={!isInitialized || isLoading}`
### 5. 调试功能增强 ✅
- **添加**: 详细的状态调试信息
- **监听**: 输入框焦点和点击事件
- **日志**: 初始化过程跟踪
## 🧪 测试步骤
### 基础功能测试
1. **页面加载测试**
- [ ] 打开AI问答页面
- [ ] 检查控制台是否显示"AI Token初始化完成"
- [ ] 检查控制台是否显示"当前UserId"
- [ ] 等待看到"请输入您的问题..."占位符
2. **输入框测试**
- [ ] 点击输入框
- [ ] 检查是否能正常输入文字
- [ ] 检查控制台调试信息
- [ ] 验证输入框不会被意外禁用
3. **发送消息测试**
- [ ] 输入测试消息
- [ ] 点击发送按钮
- [ ] 检查消息是否正常发送
- [ ] 验证WebSocket连接正常
4. **快捷问题测试**
- [ ] 点击快捷问题
- [ ] 检查是否正常发送
- [ ] 验证初始化状态检查
### 边界情况测试
1. **无UserId情况**
- [ ] 清除本地存储中的UserId
- [ ] 刷新页面
- [ ] 检查是否使用'anonymous'
- [ ] 验证功能正常
2. **网络问题测试**
- [ ] 断开网络连接
- [ ] 检查连接状态提示
- [ ] 恢复网络
- [ ] 验证重连功能
3. **初始化期间操作**
- [ ] 页面加载时立即点击输入框
- [ ] 检查是否显示"正在初始化..."
- [ ] 验证不会出现错误
## 📊 预期控制台输出
```
AI Token初始化完成: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
当前UserId: 12345 (或 anonymous)
wsUrl: wss://cms-api.websoft.top/api/chat/12345
WebSocket连接成功
输入框状态调试: {
isInitialized: true,
isLoading: false,
wsConnected: true,
inputValue: "",
inputDisabled: false,
sendButtonDisabled: true
}
```
## 🚨 常见问题排查
### 问题1: 输入框仍然无法输入
**检查项**:
- [ ] isInitialized是否为true
- [ ] isLoading是否为false
- [ ] 控制台是否有错误信息
- [ ] 输入框disabled属性值
### 问题2: WebSocket连接失败
**检查项**:
- [ ] 网络连接是否正常
- [ ] WSS_API_URL是否正确
- [ ] UserId是否获取成功
- [ ] 服务器是否正常运行
### 问题3: 消息发送失败
**检查项**:
- [ ] AI_TOKEN是否生成成功
- [ ] API请求参数是否正确
- [ ] 网络请求是否成功
- [ ] 服务器响应是否正常
## 🔍 调试命令
在浏览器控制台中运行:
```javascript
// 检查当前状态
console.log('AI_TOKEN:', Taro.getStorageSync('AI_TOKEN'));
console.log('UserId:', Taro.getStorageSync('UserId'));
// 手动触发调试
// (需要在页面上下文中执行)
```
## ✅ 修复验证
所有测试通过后,应该能够:
- ✅ 页面加载后立即可以输入
- ✅ 快捷问题正常工作
- ✅ 消息发送和接收正常
- ✅ WebSocket连接稳定
- ✅ 错误处理完善
- ✅ 用户体验流畅

View File

@@ -0,0 +1,60 @@
# AI聊天实时显示优化完成
## 🚀 主要优化内容
### 1. 实时消息显示
-**优化WebSocket消息处理**:简化了消息更新逻辑,直接追加内容而不是复杂的状态管理
-**移除延迟效果**:去掉了打字机效果,改为实时显示内容
-**实时滚动**消息更新时立即滚动到底部延迟仅50ms
### 2. 用户体验改进
-**即时反馈**发送消息后立即显示AI占位符"正在思考中..."
-**智能按钮状态**:加载时显示"停止"按钮支持中断AI回复
-**输入框优化**:加载时禁用输入并显示状态提示
-**错误处理**:网络错误时显示友好的错误消息
### 3. 连接状态管理
-**连接状态指示器**实时显示WebSocket连接状态
-**智能重连机制**:递增重连间隔,避免频繁重连
-**手动重连**:提供"立即重连"按钮,用户可主动重连
-**连接检查**:发送消息前检查连接状态
### 4. 视觉效果优化
-**打字光标动画**AI回复时显示闪烁光标效果
-**流畅布局**:消息内容支持实时追加,无布局跳动
-**按钮样式**:优化发送和停止按钮的视觉效果
-**状态提示**:连接断开时显示醒目的状态栏
### 5. 性能优化
-**减少重渲染**:优化状态更新逻辑,减少不必要的组件重渲染
-**内存管理**正确清理WebSocket连接和定时器
-**错误边界**:添加完善的错误处理和用户提示
## 测试步骤
1. **基本功能测试**
- 打开AI聊天页面
- 发送一条消息
- 观察AI回复是否实时显示
2. **实时性测试**
- 发送较长的问题
- 观察回复内容是否逐字显示
- 检查是否有明显延迟
3. **连接状态测试**
- 断开网络连接
- 观察连接状态指示器
- 恢复网络,检查重连功能
4. **交互测试**
- 测试停止按钮功能
- 测试快捷问题点击
- 测试输入框状态变化
## 预期效果
- AI回复内容应该实时逐字显示无明显延迟
- 用户发送消息后立即看到"正在思考中..."提示
- 连接断开时有明确的状态提示
- 整体交互更加流畅和响应迅速

View File

@@ -1,5 +1,6 @@
import {useEffect} from "react";
import {Image, Space} from '@nutui/nutui-react-taro'
import {Image} from '@nutui/nutui-react-taro'
import {Space} from '@nutui/nutui-react-taro'
import Taro from '@tarojs/taro'
const BestSellers = (props: any) => {

View File

@@ -2,7 +2,8 @@ import {useEffect, useState} from "react";
import Taro from '@tarojs/taro';
import {Button, Space} from '@nutui/nutui-react-taro'
import {TriangleDown,ArrowLeft} from '@nutui/icons-react-taro'
import {Popup, Avatar, NavBar} from '@nutui/nutui-react-taro'
import {Popup, NavBar} from '@nutui/nutui-react-taro'
import {Image} from '@nutui/nutui-react-taro'
import {TenantId} from "@/utils/config";
const Header = (props: any) => {
@@ -82,9 +83,15 @@ const Header = (props: any) => {
<div style={{display: 'flex', alignItems: 'center'}}>
<Button style={{color: '#000'}} open-type="getPhoneNumber" onGetPhoneNumber={handleGetPhoneNumber}>
<Space>
<Avatar
size="22"
src={props.user?.avatar}
<Image
src={props.user?.avatar || 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNjAiIGhlaWdodD0iNjAiIHZpZXdCb3g9IjAgMCA2MCA2MCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPGNpcmNsZSBjeD0iMzAiIGN5PSIzMCIgcj0iMzAiIGZpbGw9IiNGNUY1RjUiLz4KPGNpcmNsZSBjeD0iMzAiIGN5PSIyNCIgcj0iMTAiIGZpbGw9IiNEOUQ5RDkiLz4KPHBhdGggZD0iTTEwIDUwQzEwIDQwIDIwIDM1IDMwIDM1QzQwIDM1IDUwIDQwIDUwIDUwIiBmaWxsPSIjRDlEOUQ5Ii8+Cjwvc3ZnPgo='}
style={{
width: '22px',
height: '22px',
borderRadius: '50%',
objectFit: 'cover'
}}
mode="aspectFill"
/>
<span style={{color: '#000'}}>{props.user?.nickname}</span>
</Space>

View File

@@ -23,11 +23,26 @@ const Page = () => {
<div className={'py-2 my-3 mx-2'}>
<div className={'bg-white grid grid-cols-1 md:grid-cols-3 lg:grid-cols-3 gap-2'}>
{
navItems?.map((item) => (
<div className={'flex flex-col justify-center items-center'} onClick={() => Taro.navigateTo({url: `/${item.model}/index?id=${item.navigationId}`})}>
<Image className={'shadow-xl rounded-lg'} style={{borderRadius: '8px'}} src={item.icon}
height={90} width={90}/>
<div className={'mt-2 text-gray-700'} style={{fontSize: '15px'}}>{item?.title}</div>
navItems?.map((item, index) => (
<div
key={item.navigationId || index}
className={'flex flex-col justify-center items-center'}
onClick={() => Taro.navigateTo({url: `/${item.model}/index?id=${item.navigationId}`})}
>
<Image
className={'shadow-xl rounded-lg'}
style={{
borderRadius: '8px',
width: '90px',
height: '90px',
objectFit: 'cover'
}}
src={item.icon}
mode="aspectFill"
/>
<div className={'mt-2 text-gray-700'} style={{fontSize: '15px'}}>
{item?.title}
</div>
</div>
))
}

View File

@@ -5,8 +5,8 @@ import {getSiteInfo} from "@/api/layout";
import Login from "./Login";
import Banner from "./Banner";
import Menu from "./Menu";
import TabBar from "@/components/TabBar";
import Image from "./Image";
import SimpleH5TabBar from "@/components/SimpleH5TabBar";
function Home() {
const [loading, setLoading] = useState<boolean>(false)
@@ -29,7 +29,7 @@ function Home() {
};
useEffect(() => {
Taro.showTabBar()
Taro.hideTabBar()
// 获取站点信息
getSiteInfo().then((data) => {
console.log(data, 'siteInfo')
@@ -45,8 +45,9 @@ function Home() {
<Image/>
<Menu/>
<Banner/>
<TabBar/>
</>)}
{/* H5模式下显示自定义TabBar */}
{process.env.TARO_ENV === 'h5' && <SimpleH5TabBar current={0} />}
</div>
)
}

View File

@@ -1,8 +1,7 @@
import {useEffect, useState} from 'react'
import {navigateTo} from '@tarojs/taro'
import Taro from '@tarojs/taro'
import {Button} from '@tarojs/components';
import {Image} from '@nutui/nutui-react-taro'
import {Button, Image} from '@tarojs/components';
import {getUserInfo, getWxOpenId} from "@/api/layout";
import {TenantId} from "@/utils/config";
import {User} from "@/api/system/user/model";

View File

@@ -1,5 +1,5 @@
import {Avatar} from '@nutui/nutui-react-taro'
import {useEffect, useState} from "react";
import {Avatar} from '@nutui/nutui-react-taro'
import {User} from "@/api/system/user/model";
import navTo from "@/utils/common";
import Taro from '@tarojs/taro'

View File

@@ -2,20 +2,25 @@ import {useEffect} from 'react'
import UserCard from "./components/UserCard";
import UserCell from "./components/UserCell";
import TabBar from "@/components/TabBar";
import SimpleH5TabBar from "@/components/SimpleH5TabBar";
import Taro from '@tarojs/taro';
function User() {
useEffect(() => {
}, []);
return (
<div style={{ backgroundColor: '#ffefef', height: '100vh'}}>
<div className={'fixed w-full'}>
<UserCard />
<UserCell />
<TabBar/>
</div>
</div>
)
useEffect(() => {
Taro.hideTabBar()
}, []);
return (
<div style={{backgroundColor: '#ffefef', height: '100vh'}}>
<div className={'fixed w-full'}>
<UserCard/>
<UserCell/>
{/* 小程序模式显示原TabBarH5模式显示H5TabBar */}
{process.env.TARO_ENV !== 'h5' && <TabBar/>}
{process.env.TARO_ENV === 'h5' && <SimpleH5TabBar current={2}/>}
</div>
</div>
)
}
export default User

View File

@@ -48,8 +48,11 @@ const Index = () => {
return (
<div className={'bg-red-200 h-full'}>
<div style={{padding: navigation?.span + 'px'}}>
<Image src={navigation?.style} width={'100%'}
height={'auto'}/>
<Image
src={navigation?.style}
style={{width: '100%', height: 'auto'}}
mode="widthFix"
/>
</div>
<div className={'py-3 px-3'}>
<div className={'grid grid-cols-2 gap-3'}>

View File

@@ -1,7 +1,8 @@
import {Cell, Avatar} from '@nutui/nutui-react-taro';
import {Cell} from '@nutui/nutui-react-taro';
import {ArrowRight} from '@nutui/icons-react-taro'
import {useEffect, useState} from "react";
import {ConfigProvider} from '@nutui/nutui-react-taro'
import {Image} from '@nutui/nutui-react-taro'
import Taro, {getCurrentInstance} from '@tarojs/taro'
import {getUserInfo, updateUserInfo} from "@/api/layout";
import {TenantId} from "@/utils/config";
@@ -116,7 +117,16 @@ function Profile() {
<Cell title={'头像'} align={'center'} extra={
<>
<Button open-type="chooseAvatar" style={{height: '58px'}} onChooseAvatar={uploadAvatar}>
<Avatar src={FormData?.avatar} size="54"/>
<Image
src={FormData?.avatar || 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNjAiIGhlaWdodD0iNjAiIHZpZXdCb3g9IjAgMCA2MCA2MCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPGNpcmNsZSBjeD0iMzAiIGN5PSIzMCIgcj0iMzAiIGZpbGw9IiNGNUY1RjUiLz4KPGNpcmNsZSBjeD0iMzAiIGN5PSIyNCIgcj0iMTAiIGZpbGw9IiNEOUQ5RDkiLz4KPHBhdGggZD0iTTEwIDUwQzEwIDQwIDIwIDM1IDMwIDM1QzQwIDM1IDUwIDQwIDUwIDUwIiBmaWxsPSIjRDlEOUQ5Ii8+Cjwvc3ZnPgo='}
style={{
width: '54px',
height: '54px',
borderRadius: '50%',
objectFit: 'cover'
}}
mode="aspectFill"
/>
</Button>
<ArrowRight color="#cccccc" className={'ml-1'} size={20}/>
</>

View File

@@ -0,0 +1,134 @@
/**
* AI Token 使用示例
*
* 这个文件展示了如何在不同场景下使用AI Token功能
*/
import { getAiToken, generateAiToken, clearAiToken, hasAiToken } from './aiToken';
/**
* 示例1: 基本使用 - 获取AI Token
*/
export function basicUsageExample() {
// 获取AI Token如果不存在会自动生成
const token = getAiToken();
console.log('当前AI Token:', token);
return token;
}
/**
* 示例2: 检查Token是否存在
*/
export function checkTokenExample() {
if (hasAiToken()) {
console.log('AI Token已存在');
return getAiToken();
} else {
console.log('AI Token不存在正在生成...');
return generateAiToken();
}
}
/**
* 示例3: 重置Token
*/
export function resetTokenExample() {
console.log('清除旧Token...');
clearAiToken();
console.log('生成新Token...');
const newToken = generateAiToken();
console.log('新Token:', newToken);
return newToken;
}
/**
* 示例4: 在API调用中使用Token
*/
export function apiCallExample() {
const token = getAiToken();
// 模拟API调用
const apiData = {
query: '你好',
user: 'user123',
responseMode: 'streaming',
aiToken: token // 包含AI Token
};
console.log('API调用数据:', apiData);
return apiData;
}
/**
* 示例5: 应用初始化时的Token检查
*/
export function appInitExample() {
console.log('应用初始化 - 检查AI Token...');
// 确保Token存在
const token = getAiToken();
console.log('AI Token准备就绪:', token);
// 可以在这里进行其他初始化操作
return {
success: true,
token: token,
message: 'AI Token初始化完成'
};
}
/**
* 示例6: 错误处理
*/
export function errorHandlingExample() {
try {
const token = getAiToken();
if (!token) {
throw new Error('无法生成AI Token');
}
console.log('Token获取成功:', token);
return { success: true, token };
} catch (error) {
console.error('Token获取失败:', error);
return { success: false, error: error.message };
}
}
/**
* 示例7: Token格式验证
*/
export function validateTokenExample() {
const token = getAiToken();
// UUID格式验证正则表达式
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const isValid = uuidRegex.test(token);
console.log('Token:', token);
console.log('格式是否有效:', isValid);
return {
token,
isValid,
format: isValid ? 'UUID v4' : '无效格式'
};
}
// 导出所有示例函数
export default {
basicUsageExample,
checkTokenExample,
resetTokenExample,
apiCallExample,
appInitExample,
errorHandlingExample,
validateTokenExample
};

61
src/utils/aiToken.ts Normal file
View File

@@ -0,0 +1,61 @@
import Taro from '@tarojs/taro';
/**
* 生成UUID字符串
*/
export function generateUUID(): string {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
/**
* 生成AI Token
*/
export function generateAiToken(): string {
try {
const token = generateUUID();
Taro.setStorageSync('AI_TOKEN', token);
console.log('AI Token生成成功:', token);
return token;
} catch (error) {
console.error('生成AI Token失败:', error);
throw error;
}
}
/**
* 获取AI Token如果不存在则生成一个
*/
export function getAiToken(): string {
const token = Taro.getStorageSync('AI_TOKEN');
// 如果没有token生成一个新的
if (!token) {
return generateAiToken();
}
return token;
}
/**
* 清除AI Token
*/
export function clearAiToken(): void {
try {
Taro.removeStorageSync('AI_TOKEN');
console.log('AI Token已清除');
} catch (error) {
console.error('清除AI Token失败:', error);
}
}
/**
* 检查AI Token是否存在
*/
export function hasAiToken(): boolean {
const token = Taro.getStorageSync('AI_TOKEN');
return !!token;
}

102
src/utils/test-ai-token.md Normal file
View File

@@ -0,0 +1,102 @@
# AI Token 自动生成功能测试
## 功能说明
实现了AI_TOKEN的自动生成功能当AI_TOKEN不存在时会自动生成一个UUID字符串。
## 实现内容
### 1. 工具函数 (`src/utils/aiToken.ts`)
-`generateUUID()`: 生成标准UUID字符串
-`generateAiToken()`: 生成并存储AI Token
-`getAiToken()`: 获取AI Token不存在时自动生成
-`clearAiToken()`: 清除AI Token
-`hasAiToken()`: 检查AI Token是否存在
### 2. AI聊天页面集成 (`src/pages/ai/index.tsx`)
- ✅ 应用初始化时自动检查并生成AI Token
- ✅ 发送消息前检查AI Token存在性
- ✅ 在API请求中包含AI Token
- ✅ 简化了token管理逻辑
### 3. API接口更新 (`src/api/ai/index.ts`)
- ✅ 更新`AiChatMessage`接口,添加`aiToken`字段
- ✅ 移除了不需要的`generateAiToken` API函数
## 使用方式
### 自动生成
```typescript
import { getAiToken } from '@/utils/aiToken';
// 获取AI Token如果不存在会自动生成
const token = getAiToken();
```
### 手动管理
```typescript
import { generateAiToken, clearAiToken, hasAiToken } from '@/utils/aiToken';
// 检查是否存在
if (!hasAiToken()) {
// 生成新token
const token = generateAiToken();
}
// 清除token
clearAiToken();
```
## 测试步骤
### 1. 基本功能测试
1. 清除本地存储中的AI_TOKEN
2. 打开AI聊天页面
3. 检查控制台是否输出"AI Token生成成功"
4. 检查本地存储是否保存了AI_TOKEN
### 2. 持久性测试
1. 刷新页面或重新进入
2. 检查是否使用了已存在的token不会重新生成
3. 验证token格式是否为标准UUID
### 3. 发送消息测试
1. 发送一条AI消息
2. 检查网络请求中是否包含aiToken字段
3. 验证token值是否正确
### 4. 工具函数测试
```javascript
// 在浏览器控制台中测试
import { getAiToken, clearAiToken, hasAiToken } from '@/utils/aiToken';
// 测试生成
console.log('Token:', getAiToken());
// 测试检查
console.log('Has token:', hasAiToken());
// 测试清除
clearAiToken();
console.log('After clear:', hasAiToken());
```
## 预期结果
- ✅ 首次访问时自动生成UUID格式的AI Token
- ✅ Token持久保存在本地存储中
- ✅ 后续访问使用已存在的token
- ✅ 发送AI消息时自动包含token
- ✅ 提供完整的token管理工具函数
## UUID格式示例
```
xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
例如: 123e4567-e89b-12d3-a456-426614174000
```
## 注意事项
- Token存储在本地清除应用数据会丢失
- 每个用户/设备会有独立的token
- Token格式为标准UUID v4
- 无需用户登录即可生成和使用

View File

@@ -3,7 +3,8 @@ import {pageCmsArticle} from "@/api/cms/cmsArticle";
import {CmsArticle} from "@/api/cms/cmsArticle/model";
import Taro from '@tarojs/taro'
import {useRouter} from '@tarojs/taro'
import {Image,InfiniteLoading} from '@nutui/nutui-react-taro'
import {Image} from '@nutui/nutui-react-taro'
import {InfiniteLoading} from '@nutui/nutui-react-taro'
import {getCmsNavigation} from "@/api/cms/cmsNavigation";
import {CmsNavigation} from "@/api/cms/cmsNavigation/model";