专家
All checks were successful
Deploy to Server / deploy (push) Successful in 35s

This commit is contained in:
jeremygan2021
2026-02-24 17:52:12 +08:00
parent aac110ba1e
commit 46cf1727e1
12 changed files with 775 additions and 91 deletions

View File

@@ -401,8 +401,8 @@ class OrderAdmin(ModelAdmin):
)
@admin.register(WeChatUser)
class WeChatUserAdmin(ModelAdmin):
list_display = ('nickname', 'phone_number', 'is_star', 'title', 'avatar_display', 'gender_display', 'province', 'city', 'created_at')
class WeChatUserAdmin(OrderableAdminMixin, ModelAdmin):
list_display = ('nickname', 'phone_number', 'is_star', 'title', 'avatar_display', 'gender_display', 'province', 'city', 'created_at', 'order_actions')
search_fields = ('nickname', 'openid', 'phone_number')
list_filter = ('is_star', 'gender', 'province', 'city', 'created_at')
readonly_fields = ('openid', 'unionid', 'session_key', 'created_at', 'updated_at')
@@ -418,25 +418,38 @@ class WeChatUserAdmin(ModelAdmin):
return choices.get(obj.gender, '未知')
gender_display.short_description = "性别"
fieldsets = (
('基本信息', {
'fields': ('user', 'nickname', 'phone_number', 'avatar_url', 'gender')
}),
('专家认证', {
'fields': ('is_star', 'title'),
'description': '标记为明星技术用户/专家,将在社区中展示'
}),
('位置信息', {
def get_fieldsets(self, request, obj=None):
fieldsets = [
('基本信息', {
'fields': ('user', 'nickname', 'phone_number', 'avatar_url', 'gender')
}),
]
if obj and obj.is_star:
fieldsets.append(('专家认证', {
'fields': ('is_star', 'title', 'skills', 'order'),
'description': '标记为明星技术用户/专家,将在社区中展示'
}))
else:
fieldsets.append(('专家认证', {
'fields': ('is_star',),
'description': '标记为明星技术用户/专家,将在社区中展示。保存后若为专家用户,可进一步编辑专家信息。'
}))
fieldsets.append(('位置信息', {
'fields': ('country', 'province', 'city')
}),
('认证信息', {
}))
fieldsets.append(('认证信息', {
'fields': ('openid', 'unionid', 'session_key'),
'classes': ('collapse',)
}),
('时间信息', {
}))
fieldsets.append(('时间信息', {
'fields': ('created_at', 'updated_at')
}),
)
}))
return fieldsets
@admin.register(Distributor)
class DistributorAdmin(ModelAdmin):

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0.1 on 2026-02-24 09:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shop', '0034_remove_vccourse_schedule_time_vccourse_end_time_and_more'),
]
operations = [
migrations.AddField(
model_name='wechatuser',
name='skills',
field=models.JSONField(blank=True, default=list, help_text="格式: [{'icon': 'url', 'text': 'React'}, ...]", verbose_name='专家技能'),
),
]

View File

@@ -0,0 +1,22 @@
# Generated by Django 6.0.1 on 2026-02-24 09:33
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('shop', '0035_wechatuser_skills'),
]
operations = [
migrations.AlterModelOptions(
name='wechatuser',
options={'ordering': ['order', '-created_at'], 'verbose_name': '微信用户', 'verbose_name_plural': '微信用户管理'},
),
migrations.AddField(
model_name='wechatuser',
name='order',
field=models.IntegerField(default=0, help_text='数字越小越靠前', verbose_name='排序权重'),
),
]

View File

@@ -24,6 +24,8 @@ class WeChatUser(models.Model):
# 明星技术用户/专家标识
is_star = models.BooleanField(default=False, verbose_name="是否明星技术用户")
title = models.CharField(max_length=50, default="技术专家", verbose_name="专家头衔", blank=True)
skills = models.JSONField(default=list, verbose_name="专家技能", blank=True, help_text="格式: [{'icon': 'url', 'text': 'React'}, ...]")
order = models.IntegerField(default=0, verbose_name="排序权重", help_text="数字越小越靠前")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
updated_at = models.DateTimeField(auto_now=True, verbose_name="更新时间")
@@ -34,6 +36,7 @@ class WeChatUser(models.Model):
class Meta:
verbose_name = "微信用户"
verbose_name_plural = "微信用户管理"
ordering = ['order', '-created_at']
class Distributor(models.Model):

View File

@@ -22,8 +22,8 @@ class CommissionLogSerializer(serializers.ModelSerializer):
class WeChatUserSerializer(serializers.ModelSerializer):
class Meta:
model = WeChatUser
fields = ['id', 'openid', 'nickname', 'avatar_url', 'gender', 'country', 'province', 'city', 'phone_number', 'is_star', 'title']
read_only_fields = ['id', 'openid', 'phone_number', 'is_star', 'title']
fields = ['id', 'openid', 'nickname', 'avatar_url', 'gender', 'country', 'province', 'city', 'phone_number', 'is_star', 'title', 'skills']
read_only_fields = ['id', 'openid', 'phone_number', 'is_star', 'title', 'skills']
class DistributorSerializer(serializers.ModelSerializer):
user_info = WeChatUserSerializer(source='user', read_only=True)

View File

@@ -1573,7 +1573,7 @@ class WeChatUserViewSet(viewsets.ReadOnlyModelViewSet):
"""
获取明星技术用户列表
"""
stars = WeChatUser.objects.filter(is_star=True).order_by('-created_at')
stars = WeChatUser.objects.filter(is_star=True).order_by('order', '-created_at')
serializer = self.get_serializer(stars, many=True)
return Response(serializer.data)

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Typography, Card, Avatar, Tag, Space, Button, Divider, Input, message, Upload, Tooltip, Grid, Modal } from 'antd';
import { UserOutlined, ClockCircleOutlined, EyeOutlined, CheckCircleFilled, LeftOutlined, UploadOutlined, EditOutlined } from '@ant-design/icons';
import { UserOutlined, ClockCircleOutlined, EyeOutlined, CheckCircleFilled, LeftOutlined, UploadOutlined, EditOutlined, StarFilled, CloseOutlined } from '@ant-design/icons';
import { getTopicDetail, createReply, uploadMedia, getStarUsers } from '../api';
import { useAuth } from '../context/AuthContext';
import LoginModal from '../components/LoginModal';
@@ -44,10 +44,6 @@ const ForumDetail = () => {
// Star Users State
const [starUsers, setStarUsers] = useState([]);
// User Info Modal State
const [userInfoModalVisible, setUserInfoModalVisible] = useState(false);
const [selectedUser, setSelectedUser] = useState(null);
const fetchTopic = async () => {
try {
const res = await getTopicDetail(id);
@@ -85,10 +81,16 @@ const ForumDetail = () => {
message.info(`已添加 @${nickname}`);
};
// Expert Info Modal
const [expertModalVisible, setExpertModalVisible] = useState(false);
const [selectedExpert, setSelectedExpert] = useState(null);
const showUserTitle = (author) => {
if (author) {
setSelectedUser(author);
setUserInfoModalVisible(true);
if (author?.is_star) {
setSelectedExpert(author);
setExpertModalVisible(true);
} else if (author?.title) {
message.info(author.title);
}
};
@@ -231,13 +233,13 @@ const ForumDetail = () => {
<Space size={isMobile ? 'small' : 'large'} style={{ color: '#888', marginTop: 10, flexWrap: 'wrap' }}>
<Space>
<div onClick={() => showUserTitle(topic.author_info)} style={{ cursor: 'pointer', display: 'flex', alignItems: 'center' }}>
<Avatar
src={topic.author_info?.avatar_url}
icon={<UserOutlined />}
size={isMobile ? 'small' : 'default'}
/>
</div>
<Avatar
src={topic.author_info?.avatar_url}
icon={<UserOutlined />}
size={isMobile ? 'small' : 'default'}
style={{ cursor: 'pointer' }}
onClick={() => showUserTitle(topic.author_info)}
/>
<span style={{ color: '#ccc', fontSize: isMobile ? 12 : 14 }}>{topic.author_info?.nickname}</span>
{topic.is_verified_owner && (
<Tooltip title="已验证购买过相关产品">
@@ -303,13 +305,13 @@ const ForumDetail = () => {
styles={{ body: { padding: isMobile ? '15px' : '24px' } }}
>
<div style={{ display: 'flex', gap: isMobile ? 10 : 16 }}>
<div onClick={() => showUserTitle(reply.author_info)} style={{ cursor: 'pointer', height: 'fit-content' }}>
<Avatar
src={reply.author_info?.avatar_url}
icon={<UserOutlined />}
size={isMobile ? 'small' : 'default'}
/>
</div>
<Avatar
src={reply.author_info?.avatar_url}
icon={<UserOutlined />}
size={isMobile ? 'small' : 'default'}
style={{ cursor: 'pointer' }}
onClick={() => showUserTitle(reply.author_info)}
/>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
<Space size={isMobile ? 'small' : 'middle'} align="center">
@@ -421,38 +423,48 @@ const ForumDetail = () => {
/>
<Modal
title={null}
open={userInfoModalVisible}
onCancel={() => setUserInfoModalVisible(false)}
footer={null}
centered
width={300}
styles={{ body: { textAlign: 'center', padding: '30px 20px' } }}
open={expertModalVisible}
onCancel={() => setExpertModalVisible(false)}
footer={null}
title={<Space><StarFilled style={{ color: '#ffd700' }} /><span style={{ color: '#fff' }}>技术专家信息</span></Space>}
styles={{
content: { background: 'rgba(30, 30, 30, 0.95)', border: '1px solid rgba(255, 255, 255, 0.1)', backdropFilter: 'blur(10px)' },
header: { background: 'transparent', borderBottom: '1px solid rgba(255, 255, 255, 0.1)' },
body: { background: 'transparent' },
mask: { backdropFilter: 'blur(4px)' }
}}
closeIcon={<CloseOutlined style={{ color: 'rgba(255, 255, 255, 0.5)' }} />}
bodyStyle={{ textAlign: 'center' }}
>
{selectedUser && (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<Avatar
size={80}
src={selectedUser.avatar_url}
icon={<UserOutlined />}
style={{ marginBottom: 16, border: '2px solid #f0f0f0' }}
/>
<Title level={4} style={{ marginBottom: 4, marginTop: 0 }}>{selectedUser.nickname}</Title>
{selectedExpert && (
<div>
<Avatar size={80} src={selectedExpert.avatar_url} icon={<UserOutlined />} style={{ border: '3px solid #ffd700', marginBottom: 15, boxShadow: '0 0 15px rgba(255, 215, 0, 0.2)' }} />
<Title level={4} style={{ marginBottom: 5, color: '#fff' }}>{selectedExpert.nickname}</Title>
<Tag color="gold" style={{ marginBottom: 20, background: 'rgba(255, 215, 0, 0.15)', border: '1px solid rgba(255, 215, 0, 0.4)', color: '#ffd700' }}>{selectedExpert.title || '技术专家'}</Tag>
{(selectedUser.is_star || selectedUser.title) && (
<Tag color="gold" style={{ fontSize: 14, padding: '4px 12px', marginTop: 8 }}>
{selectedUser.title || '技术专家'}
</Tag>
)}
{selectedUser.is_verified_owner && (
<div style={{ marginTop: 12, display: 'flex', alignItems: 'center', color: '#00b96b' }}>
<CheckCircleFilled style={{ marginRight: 6 }} />
<Text style={{ color: '#00b96b', fontSize: 12 }}>已认证购买用户</Text>
</div>
)}
</div>
)}
{selectedExpert.skills && selectedExpert.skills.length > 0 && (
<div style={{ marginTop: 20, textAlign: 'left' }}>
<Text strong style={{ display: 'block', marginBottom: 10, color: '#00b96b' }}>擅长技能</Text>
<Space wrap>
{selectedExpert.skills.map((skill, idx) => (
<div key={idx} style={{
display: 'flex',
alignItems: 'center',
background: 'rgba(255, 255, 255, 0.05)',
padding: '6px 12px',
borderRadius: 6,
border: '1px solid rgba(255, 255, 255, 0.1)',
transition: 'all 0.3s'
}}>
{skill.icon && <img src={skill.icon} style={{ width: 16, height: 16, marginRight: 6 }} />}
<Text style={{ fontSize: 12, color: '#ddd' }}>{skill.text}</Text>
</div>
))}
</Space>
</div>
)}
</div>
)}
</Modal>
{/* Edit Modal */}

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react';
import { Typography, Input, Button, List, Tag, Avatar, Card, Space, Spin, message, Badge, Tooltip, Tabs, Row, Col, Grid, Carousel } from 'antd';
import { SearchOutlined, PlusOutlined, UserOutlined, MessageOutlined, EyeOutlined, CheckCircleFilled, FireOutlined, StarFilled, QuestionCircleOutlined, ShareAltOutlined, SoundOutlined, RightOutlined } from '@ant-design/icons';
import { Typography, Input, Button, List, Tag, Avatar, Card, Space, Spin, message, Badge, Tooltip, Tabs, Row, Col, Grid, Carousel, Modal } from 'antd';
import { SearchOutlined, PlusOutlined, UserOutlined, MessageOutlined, EyeOutlined, CheckCircleFilled, FireOutlined, StarFilled, QuestionCircleOutlined, ShareAltOutlined, SoundOutlined, RightOutlined, CloseOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { motion } from 'framer-motion';
import { getTopics, getStarUsers, getAnnouncements } from '../api';
@@ -25,6 +25,13 @@ const ForumList = () => {
const [category, setCategory] = useState('all');
const [createModalVisible, setCreateModalVisible] = useState(false);
const [loginModalVisible, setLoginModalVisible] = useState(false);
const [expertModalVisible, setExpertModalVisible] = useState(false);
const [selectedExpert, setSelectedExpert] = useState(null);
const showExpertInfo = (user) => {
setSelectedExpert(user);
setExpertModalVisible(true);
};
const fetchTopics = async (search = '', cat = '') => {
setLoading(true);
@@ -185,9 +192,9 @@ const ForumList = () => {
{/* Mobile Experts */}
<div style={{ overflowX: 'auto', whiteSpace: 'nowrap', paddingBottom: 5, display: 'flex', gap: 15, scrollbarWidth: 'none', msOverflowStyle: 'none' }}>
{starUsers.map(u => (
<div key={u.id} style={{ textAlign: 'center', minWidth: 60 }}>
<div key={u.id} style={{ textAlign: 'center', minWidth: 60 }} onClick={() => showExpertInfo(u)}>
<Badge count={<StarFilled style={{ color: '#ffd700' }} />} offset={[-5, 5]}>
<Avatar size={48} src={u.avatar_url} icon={<UserOutlined />} style={{ border: '2px solid rgba(255, 215, 0, 0.3)' }} />
<Avatar size={48} src={u.avatar_url} icon={<UserOutlined />} style={{ border: '2px solid rgba(255, 215, 0, 0.3)', cursor: 'pointer' }} />
</Badge>
<div style={{ color: '#fff', fontSize: 12, marginTop: 5, width: 60, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{u.nickname}
@@ -307,7 +314,7 @@ const ForumList = () => {
<div style={{ textAlign: 'center', padding: '20px 0', color: '#666' }}>
{starUsers.length > 0 ? (
starUsers.map(u => (
<div key={u.id} style={{ marginBottom: 15, display: 'flex', alignItems: 'center', gap: 10 }}>
<div key={u.id} style={{ marginBottom: 15, display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer' }} onClick={() => showExpertInfo(u)}>
<Avatar size="large" src={u.avatar_url} icon={<UserOutlined />} />
<div style={{ textAlign: 'left' }}>
<div style={{ color: '#fff', fontWeight: 'bold' }}>
@@ -370,6 +377,51 @@ const ForumList = () => {
setCreateModalVisible(true);
}}
/>
<Modal
open={expertModalVisible}
onCancel={() => setExpertModalVisible(false)}
footer={null}
title={<Space><StarFilled style={{ color: '#ffd700' }} /><span style={{ color: '#fff' }}>技术专家信息</span></Space>}
styles={{
content: { background: 'rgba(30, 30, 30, 0.95)', border: '1px solid rgba(255, 255, 255, 0.1)', backdropFilter: 'blur(10px)' },
header: { background: 'transparent', borderBottom: '1px solid rgba(255, 255, 255, 0.1)' },
body: { background: 'transparent' },
mask: { backdropFilter: 'blur(4px)' }
}}
closeIcon={<CloseOutlined style={{ color: 'rgba(255, 255, 255, 0.5)' }} />}
bodyStyle={{ textAlign: 'center' }}
>
{selectedExpert && (
<div>
<Avatar size={80} src={selectedExpert.avatar_url} icon={<UserOutlined />} style={{ border: '3px solid #ffd700', marginBottom: 15, boxShadow: '0 0 15px rgba(255, 215, 0, 0.2)' }} />
<Title level={4} style={{ marginBottom: 5, color: '#fff' }}>{selectedExpert.nickname}</Title>
<Tag color="gold" style={{ marginBottom: 20, background: 'rgba(255, 215, 0, 0.15)', border: '1px solid rgba(255, 215, 0, 0.4)', color: '#ffd700' }}>{selectedExpert.title || '技术专家'}</Tag>
{selectedExpert.skills && selectedExpert.skills.length > 0 && (
<div style={{ marginTop: 20, textAlign: 'left' }}>
<Text strong style={{ display: 'block', marginBottom: 10, color: '#00b96b' }}>擅长技能</Text>
<Space wrap>
{selectedExpert.skills.map((skill, idx) => (
<div key={idx} style={{
display: 'flex',
alignItems: 'center',
background: 'rgba(255, 255, 255, 0.05)',
padding: '6px 12px',
borderRadius: 6,
border: '1px solid rgba(255, 255, 255, 0.1)',
transition: 'all 0.3s'
}}>
{skill.icon && <img src={skill.icon} style={{ width: 16, height: 16, marginRight: 6 }} />}
<Text style={{ fontSize: 12, color: '#ddd' }}>{skill.text}</Text>
</div>
))}
</Space>
</div>
)}
</div>
)}
</Modal>
</div>
);
};

View File

@@ -23,6 +23,11 @@
100% { transform: scale(1); box-shadow: 0 4px 12px rgba(0, 185, 107, 0.4); }
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.hero-section {
padding: 60px 20px 30px;
text-align: center;
@@ -455,4 +460,224 @@
color: #fff;
}
}
/* Expert Modal Styles - Tech & Dark Theme */
.at-float-layout {
.at-float-layout__overlay {
background-color: rgba(0, 0, 0, 0.8);
backdrop-filter: blur(8px);
}
.at-float-layout__container {
background-color: #0f1216 !important; /* Deep dark tech background */
border-top: 1px solid rgba(0, 185, 107, 0.3); /* Tech green border */
box-shadow: 0 -10px 40px rgba(0, 185, 107, 0.15);
border-radius: 24px 24px 0 0; /* More rounded top */
.layout-header {
background-color: #15191f;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
padding: 18px 24px;
.layout-header__title {
color: #00b96b; /* Tech green */
font-size: 18px;
font-weight: 700;
letter-spacing: 1px;
text-shadow: 0 0 10px rgba(0, 185, 107, 0.3);
}
.layout-header__btn-close {
color: #666;
}
}
.layout-body {
background-color: #0f1216;
padding: 0;
}
}
}
.expert-modal-content {
padding: 30px 24px 60px;
color: #fff;
background: radial-gradient(circle at 50% 10%, rgba(0, 185, 107, 0.08), transparent 60%);
.expert-header {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 36px;
position: relative;
.avatar-container {
position: relative;
margin-bottom: 24px;
.expert-avatar {
width: 100px;
height: 100px;
border-radius: 50%;
border: 2px solid #ffd700; /* Gold for expert */
box-shadow: 0 0 30px rgba(255, 215, 0, 0.25), inset 0 0 10px rgba(255, 215, 0, 0.2);
z-index: 2;
position: relative;
}
.avatar-ring {
position: absolute;
top: -12px; left: -12px; right: -12px; bottom: -12px;
border-radius: 50%;
border: 1px dashed rgba(255, 215, 0, 0.5);
animation: spin 12s linear infinite;
z-index: 1;
&::after {
content: '';
position: absolute;
top: -6px; left: -6px; right: -6px; bottom: -6px;
border-radius: 50%;
border: 1px solid rgba(0, 185, 107, 0.3); /* Outer green ring */
animation: spin 8s reverse linear infinite;
}
}
}
.expert-info {
text-align: center;
.expert-name {
font-size: 26px;
font-weight: 800;
color: #fff;
margin-bottom: 12px;
text-shadow: 0 0 15px rgba(0, 185, 107, 0.5);
letter-spacing: 0.5px;
}
.expert-title-badge {
display: inline-flex;
align-items: center;
gap: 8px;
background: linear-gradient(90deg, rgba(255, 215, 0, 0.15), rgba(255, 215, 0, 0.05));
padding: 6px 16px;
border-radius: 20px;
border: 1px solid rgba(255, 215, 0, 0.4);
box-shadow: 0 0 15px rgba(255, 215, 0, 0.15);
.at-icon {
text-shadow: 0 0 5px #ffd700;
}
text {
font-size: 14px;
color: #ffd700;
font-weight: 700;
letter-spacing: 1px;
}
}
}
}
.expert-skills-section {
margin-bottom: 30px;
background: rgba(255, 255, 255, 0.02);
border-radius: 20px;
padding: 24px;
border: 1px solid rgba(255, 255, 255, 0.06);
position: relative;
overflow: hidden;
/* Tech corner accent */
&::before {
content: '';
position: absolute;
top: 0; left: 0;
width: 10px; height: 10px;
border-top: 2px solid #00b96b;
border-left: 2px solid #00b96b;
border-radius: 4px 0 0 0;
}
&::after {
content: '';
position: absolute;
bottom: 0; right: 0;
width: 10px; height: 10px;
border-bottom: 2px solid #00b96b;
border-right: 2px solid #00b96b;
border-radius: 0 0 4px 0;
}
.section-label {
display: flex;
align-items: center;
margin-bottom: 20px;
.label-text {
font-size: 15px;
font-weight: 700;
color: #00b96b;
margin-right: 12px;
text-transform: uppercase;
letter-spacing: 1px;
}
.label-line {
flex: 1;
height: 1px;
background: linear-gradient(90deg, rgba(0, 185, 107, 0.5), transparent);
opacity: 0.6;
}
}
.skills-grid {
display: flex;
flex-wrap: wrap;
gap: 12px;
.skill-tag {
display: flex;
align-items: center;
background: rgba(0, 185, 107, 0.08);
padding: 8px 16px;
border-radius: 6px;
border: 1px solid rgba(0, 185, 107, 0.25);
transition: all 0.3s;
position: relative;
overflow: hidden;
/* Left accent bar */
&::before {
content: '';
position: absolute;
top: 0; left: 0; width: 3px; height: 100%;
background: #00b96b;
opacity: 0.6;
}
&:active {
background: rgba(0, 185, 107, 0.2);
transform: scale(0.98);
box-shadow: 0 0 10px rgba(0, 185, 107, 0.2);
}
.skill-icon {
width: 20px;
height: 20px;
margin-right: 8px;
filter: drop-shadow(0 0 2px rgba(0,0,0,0.5));
}
.skill-text {
font-size: 13px;
color: #e0e0e0;
font-weight: 500;
letter-spacing: 0.5px;
}
}
}
}
}
}

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect, useRef } from 'react'
import Taro, { usePullDownRefresh, useReachBottom, useDidShow, useShareAppMessage, useShareTimeline } from '@tarojs/taro'
import { View, Text, Image, Swiper, SwiperItem, ScrollView } from '@tarojs/components'
import { AtSearchBar, AtTabs, AtIcon, AtActivityIndicator } from 'taro-ui'
import { AtSearchBar, AtTabs, AtIcon, AtActivityIndicator, AtFloatLayout } from 'taro-ui'
import { getTopics, getAnnouncements, getStarUsers } from '../../api'
import './index.scss'
@@ -16,6 +16,10 @@ const ForumList = () => {
const [currentTab, setCurrentTab] = useState(0)
const isMounted = useRef(false)
// Expert Detail
const [showExpert, setShowExpert] = useState(false)
const [selectedExpert, setSelectedExpert] = useState<any>(null)
const categories = [
{ title: '全部话题', key: 'all' },
{ title: '技术讨论', key: 'discussion' },
@@ -180,10 +184,8 @@ const ForumList = () => {
const showUserTitle = (e, user) => {
e.stopPropagation()
if (user.is_star || user.title) {
Taro.showToast({
title: user.title || '技术专家',
icon: 'none'
})
setSelectedExpert(user)
setShowExpert(true)
}
}
@@ -324,6 +326,43 @@ const ForumList = () => {
<View className='fab' onClick={navigateToCreate}>
<AtIcon value='add' size='24' color='#fff' />
</View>
<AtFloatLayout isOpened={showExpert} title="技术专家信息" onClose={() => setShowExpert(false)}>
{selectedExpert && (
<View className='expert-modal-content'>
<View className='expert-header'>
<View className='avatar-container'>
<Image src={selectedExpert.avatar_url} className='expert-avatar' />
<View className='avatar-ring'></View>
</View>
<View className='expert-info'>
<View className='expert-name'>{selectedExpert.nickname}</View>
<View className='expert-title-badge'>
<AtIcon value='sketch' size='14' color='#ffd700' />
<Text>{selectedExpert.title || '技术专家'}</Text>
</View>
</View>
</View>
{selectedExpert.skills && selectedExpert.skills.length > 0 && (
<View className='expert-skills-section'>
<View className='section-label'>
<Text className='label-text'></Text>
<View className='label-line'></View>
</View>
<View className='skills-grid'>
{selectedExpert.skills.map((skill, idx) => (
<View key={idx} className='skill-tag'>
{typeof skill === 'object' && skill.icon && <Image src={skill.icon} className='skill-icon' />}
<Text className='skill-text'>{typeof skill === 'object' ? skill.text : skill}</Text>
</View>
))}
</View>
</View>
)}
</View>
)}
</AtFloatLayout>
</View>
)
}

View File

@@ -310,4 +310,255 @@
padding: 0 8px;
}
}
/* Expert Modal Styles - Tech & Dark Theme */
.at-float-layout {
.at-float-layout__overlay {
background-color: rgba(0, 0, 0, 0.8);
backdrop-filter: blur(8px);
}
.at-float-layout__container {
background-color: #0f1216 !important; /* Deep dark tech background */
border-top: 1px solid rgba(0, 185, 107, 0.3); /* Tech green border */
box-shadow: 0 -10px 40px rgba(0, 185, 107, 0.15);
border-radius: 24px 24px 0 0; /* More rounded top */
.layout-header {
background-color: #15191f;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
padding: 18px 24px;
.layout-header__title {
color: #00b96b; /* Tech green */
font-size: 18px;
font-weight: 700;
letter-spacing: 1px;
text-shadow: 0 0 10px rgba(0, 185, 107, 0.3);
}
.layout-header__btn-close {
color: #666;
}
}
.layout-body {
background-color: #0f1216;
padding: 0;
}
}
}
.expert-modal-content {
padding: 30px 24px 60px;
color: #fff;
background: radial-gradient(circle at 50% 10%, rgba(0, 185, 107, 0.08), transparent 60%);
.expert-header {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 36px;
position: relative;
.avatar-container {
position: relative;
margin-bottom: 24px;
.expert-avatar {
width: 100px;
height: 100px;
border-radius: 50%;
border: 2px solid #ffd700; /* Gold for expert */
box-shadow: 0 0 30px rgba(255, 215, 0, 0.25), inset 0 0 10px rgba(255, 215, 0, 0.2);
z-index: 2;
position: relative;
}
.avatar-ring {
position: absolute;
top: -12px; left: -12px; right: -12px; bottom: -12px;
border-radius: 50%;
border: 1px dashed rgba(255, 215, 0, 0.5);
animation: spin 12s linear infinite;
z-index: 1;
&::after {
content: '';
position: absolute;
top: -6px; left: -6px; right: -6px; bottom: -6px;
border-radius: 50%;
border: 1px solid rgba(0, 185, 107, 0.3); /* Outer green ring */
animation: spin 8s reverse linear infinite;
}
}
}
.expert-info {
text-align: center;
.expert-name {
font-size: 26px;
font-weight: 800;
color: #fff;
margin-bottom: 12px;
text-shadow: 0 0 15px rgba(0, 185, 107, 0.5);
letter-spacing: 0.5px;
}
.expert-title-badge {
display: inline-flex;
align-items: center;
gap: 8px;
background: linear-gradient(90deg, rgba(255, 215, 0, 0.15), rgba(255, 215, 0, 0.05));
padding: 6px 16px;
border-radius: 20px;
border: 1px solid rgba(255, 215, 0, 0.4);
box-shadow: 0 0 15px rgba(255, 215, 0, 0.15);
.at-icon {
text-shadow: 0 0 5px #ffd700;
}
text {
font-size: 14px;
color: #ffd700;
font-weight: 700;
letter-spacing: 1px;
}
}
}
}
.expert-skills-section {
margin-bottom: 30px;
background: rgba(255, 255, 255, 0.02);
border-radius: 20px;
padding: 24px;
border: 1px solid rgba(255, 255, 255, 0.06);
position: relative;
overflow: hidden;
/* Tech corner accent */
&::before {
content: '';
position: absolute;
top: 0; left: 0;
width: 10px; height: 10px;
border-top: 2px solid #00b96b;
border-left: 2px solid #00b96b;
border-radius: 4px 0 0 0;
}
&::after {
content: '';
position: absolute;
bottom: 0; right: 0;
width: 10px; height: 10px;
border-bottom: 2px solid #00b96b;
border-right: 2px solid #00b96b;
border-radius: 0 0 4px 0;
}
.section-label {
display: flex;
align-items: center;
margin-bottom: 20px;
.label-text {
font-size: 15px;
font-weight: 700;
color: #00b96b;
margin-right: 12px;
text-transform: uppercase;
letter-spacing: 1px;
}
.label-line {
flex: 1;
height: 1px;
background: linear-gradient(90deg, rgba(0, 185, 107, 0.5), transparent);
opacity: 0.6;
}
}
.skills-grid {
display: flex;
flex-wrap: wrap;
gap: 12px;
.skill-tag {
display: flex;
align-items: center;
background: rgba(0, 185, 107, 0.08);
padding: 8px 16px;
border-radius: 6px;
border: 1px solid rgba(0, 185, 107, 0.25);
transition: all 0.3s;
position: relative;
overflow: hidden;
/* Left accent bar */
&::before {
content: '';
position: absolute;
top: 0; left: 0; width: 3px; height: 100%;
background: #00b96b;
opacity: 0.6;
}
&:active {
background: rgba(0, 185, 107, 0.2);
transform: scale(0.98);
box-shadow: 0 0 10px rgba(0, 185, 107, 0.2);
}
.skill-icon {
width: 20px;
height: 20px;
margin-right: 8px;
filter: drop-shadow(0 0 2px rgba(0,0,0,0.5));
}
.skill-text {
font-size: 13px;
color: #e0e0e0;
font-weight: 500;
letter-spacing: 0.5px;
}
}
}
}
.expert-action {
display: flex;
justify-content: center;
.action-btn {
width: 100%;
height: 52px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #00b96b 0%, #009456 100%);
border-radius: 26px;
box-shadow: 0 4px 20px rgba(0, 185, 107, 0.3);
gap: 6px;
transition: all 0.3s;
border: 1px solid rgba(255, 255, 255, 0.1);
&:active {
transform: scale(0.98);
box-shadow: 0 2px 10px rgba(0, 185, 107, 0.2);
}
text {
color: #fff;
font-size: 16px;
font-weight: 700;
letter-spacing: 1px;
}
}
}
}
}

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react'
import Taro, { useRouter, useShareAppMessage, useDidShow } from '@tarojs/taro'
import { View, Text, Image, Video, RichText, Input, ScrollView } from '@tarojs/components'
import { AtActivityIndicator, AtIcon, AtActionSheet, AtActionSheetItem } from 'taro-ui'
import { AtActivityIndicator, AtIcon, AtActionSheet, AtActionSheetItem, AtFloatLayout } from 'taro-ui'
import { getTopicDetail, createReply, uploadMedia, getStarUsers } from '../../../api'
import { marked } from 'marked'
import './detail.scss'
@@ -21,6 +21,10 @@ const ForumDetail = () => {
const [starUsers, setStarUsers] = useState<any[]>([])
const [showStarUsers, setShowStarUsers] = useState(false)
// Expert Detail
const [showExpert, setShowExpert] = useState(false)
const [selectedExpert, setSelectedExpert] = useState<any>(null)
const fetchDetail = async () => {
try {
const res = await getTopicDetail(Number(id))
@@ -162,6 +166,14 @@ const ForumDetail = () => {
if (loading) return <View className='forum-detail-page'><AtActivityIndicator mode='center' /></View>
if (!topic) return <View className='forum-detail-page'><View style={{padding: 20, textAlign: 'center'}}></View></View>
const showUserTitle = (e, user) => {
e.stopPropagation()
if (user.is_star || user.title) {
setSelectedExpert(user)
setShowExpert(true)
}
}
return (
<View className='forum-detail-page'>
<ScrollView scrollY style={{height: '100vh'}}>
@@ -172,7 +184,7 @@ const ForumDetail = () => {
<Text className='title'>{topic.title}</Text>
<View className='meta'>
<View className='author'>
<View className='author' onClick={(e) => showUserTitle(e, topic.author_info)}>
<Image className='avatar' src={topic.author_info?.avatar_url || 'https://via.placeholder.com/30'} />
<Text style={{fontWeight: 600, color: '#ccc'}}>{topic.author_info?.nickname}</Text>
{topic.is_verified_owner && <AtIcon value='check-circle' size='14' color='#00b96b' />}
@@ -214,7 +226,7 @@ const ForumDetail = () => {
{topic.replies?.map((reply, idx) => (
<View key={reply.id} className='reply-card'>
<Image className='avatar' src={reply.author_info?.avatar_url || 'https://via.placeholder.com/30'} />
<Image className='avatar' src={reply.author_info?.avatar_url || 'https://via.placeholder.com/30'} onClick={(e) => showUserTitle(e, reply.author_info)} />
<View className='reply-main'>
<View className='reply-header'>
<View style={{display: 'flex', flexDirection: 'column'}}>
@@ -271,6 +283,43 @@ const ForumDetail = () => {
</AtActionSheetItem>
))}
</AtActionSheet>
<AtFloatLayout isOpened={showExpert} title="技术专家信息" onClose={() => setShowExpert(false)}>
{selectedExpert && (
<View className='expert-modal-content'>
<View className='expert-header'>
<View className='avatar-container'>
<Image src={selectedExpert.avatar_url} className='expert-avatar' />
<View className='avatar-ring'></View>
</View>
<View className='expert-info'>
<View className='expert-name'>{selectedExpert.nickname}</View>
<View className='expert-title-badge'>
<AtIcon value='sketch' size='14' color='#ffd700' />
<Text>{selectedExpert.title || '技术专家'}</Text>
</View>
</View>
</View>
{selectedExpert.skills && selectedExpert.skills.length > 0 && (
<View className='expert-skills-section'>
<View className='section-label'>
<Text className='label-text'></Text>
<View className='label-line'></View>
</View>
<View className='skills-grid'>
{selectedExpert.skills.map((skill, idx) => (
<View key={idx} className='skill-tag'>
{typeof skill === 'object' && skill.icon && <Image src={skill.icon} className='skill-icon' />}
<Text className='skill-text'>{typeof skill === 'object' ? skill.text : skill}</Text>
</View>
))}
</View>
</View>
)}
</View>
)}
</AtFloatLayout>
</View>
)
}