This commit is contained in:
@@ -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 = (
|
||||
def get_fieldsets(self, request, obj=None):
|
||||
fieldsets = [
|
||||
('基本信息', {
|
||||
'fields': ('user', 'nickname', 'phone_number', 'avatar_url', 'gender')
|
||||
}),
|
||||
('专家认证', {
|
||||
'fields': ('is_star', 'title'),
|
||||
]
|
||||
|
||||
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):
|
||||
|
||||
18
backend/shop/migrations/0035_wechatuser_skills.py
Normal file
18
backend/shop/migrations/0035_wechatuser_skills.py
Normal 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='专家技能'),
|
||||
),
|
||||
]
|
||||
@@ -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='排序权重'),
|
||||
),
|
||||
]
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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'}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => showUserTitle(topic.author_info)}
|
||||
/>
|
||||
</div>
|
||||
<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'}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => showUserTitle(reply.author_info)}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||
<Space size={isMobile ? 'small' : 'middle'} align="center">
|
||||
@@ -421,34 +423,44 @@ const ForumDetail = () => {
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={null}
|
||||
open={userInfoModalVisible}
|
||||
onCancel={() => setUserInfoModalVisible(false)}
|
||||
open={expertModalVisible}
|
||||
onCancel={() => setExpertModalVisible(false)}
|
||||
footer={null}
|
||||
centered
|
||||
width={300}
|
||||
styles={{ body: { textAlign: 'center', padding: '30px 20px' } }}
|
||||
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>
|
||||
{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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user