This commit is contained in:
lcw
2026-03-29 19:46:50 +08:00
parent 60de16032f
commit af838854fa
49 changed files with 9947 additions and 1512 deletions

View File

@ -0,0 +1,226 @@
<template>
<el-dialog
class="luntan-tech-dialog"
:model-value="modelValue"
center
width="500px"
:destroy-on-close="true"
:title="title"
@close="close"
:close-on-click-modal="false"
>
<div class="avatar-upload-container">
<div class="avatar-preview">
<img v-if="avatarUrl" :src="avatarUrl" alt="预览头像" class="preview-image">
<div v-else class="preview-placeholder">
<el-icon class="placeholder-icon">
<User />
</el-icon>
<span>请上传头像</span>
</div>
</div>
<div class="upload-section">
<el-upload class="avatar-uploader" :auto-upload="false" :show-file-list="false"
:on-change="handleAvatarChange" :before-upload="beforeAvatarUpload" accept="image/*">
<el-button size="small" type="primary">选择图片</el-button>
</el-upload>
<div class="upload-tips">
<p> 支持 JPGPNG 格式</p>
<p> 图片大小不超过 2MB</p>
<p> 建议尺寸为 200x200 像素</p>
</div>
</div>
</div>
<template #footer>
<el-button @click="close">取消</el-button>
<el-button type="primary" @click="confirmUpload" :loading="uploading" :disabled="!avatarUrl || uploading">
{{ uploading ? '上传中...' : '确认更换' }}
</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, watch } from "vue";
import { ElMessage } from 'element-plus';
import { User } from '@element-plus/icons-vue';
import { upImageUploadId } from '@/api/commit';
const props = defineProps({
modelValue: {
type: Boolean,
default: false
},
title: {
type: String,
default: "更换头像"
},
heightNumber: {
type: Number,
default: 250
}
});
const emits = defineEmits(["update:modelValue", "avatarUpdated"]);
// 头像相关状态
const avatarUrl = ref('');
const uploading = ref(false);
// 监听对话框显示状态,重置头像预览
watch(() => props.modelValue, (newVal) => {
if (!newVal) {
// 对话框关闭时重置头像预览
avatarUrl.value = '';
}
});
// 当前选择的文件
const selectedFile = ref(null);
// 处理头像选择
const handleAvatarChange = (file) => {
selectedFile.value = file.raw;
// 创建临时预览URL
const reader = new FileReader();
reader.onload = (e) => {
avatarUrl.value = e.target.result;
};
reader.readAsDataURL(file.raw);
};
// 上传前检查
const beforeAvatarUpload = (file) => {
const isJPG = file.type === 'image/jpeg';
const isPNG = file.type === 'image/png';
const isLt2M = file.size / 1024 / 1024 < 2;
if (!isJPG && !isPNG) {
ElMessage.error('请上传 JPG 或 PNG 格式的图片!');
return false;
}
if (!isLt2M) {
ElMessage.error('图片大小不能超过 2MB!');
return false;
}
return true;
};
// 确认上传头像
const confirmUpload = async () => {
if (!avatarUrl.value || !selectedFile.value) {
ElMessage.warning('请先选择头像图片');
return;
}
uploading.value = true;
try {
// 创建FormData对象
const formData = new FormData();
formData.append('file', selectedFile.value);
// 调用实际的上传接口
const response = await upImageUploadId(formData);
console.log(response);
emits('avatarUpdated', response);
// 关闭对话框
close();
} catch (error) {
console.error('上传头像失败:', error);
ElMessage.error('上传头像失败,请重试');
} finally {
uploading.value = false;
}
};
// 关闭对话框
const close = () => {
emits("update:modelValue", false);
};
</script>
<style lang="scss" scoped>
@import '../styles/luntan-tech.scss';
.avatar-upload-container {
display: flex;
flex-direction: column;
align-items: center;
padding: 12px 0 8px;
}
.avatar-preview {
width: 160px;
height: 160px;
border-radius: 50%;
overflow: hidden;
border: 2px solid rgba(0, 227, 255, 0.4);
margin-bottom: 20px;
display: flex;
justify-content: center;
align-items: center;
background: rgba(10, 30, 60, 0.6);
box-shadow: 0 0 20px rgba(0, 120, 200, 0.2);
.preview-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.preview-placeholder {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
color: $lt-text-muted;
.placeholder-icon {
font-size: 48px;
margin-bottom: 8px;
color: rgba(0, 227, 255, 0.45);
}
span {
font-size: 14px;
}
}
}
.upload-section {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
.avatar-uploader {
margin-bottom: 16px;
:deep(.el-button--primary) {
background: linear-gradient(180deg, #00a3ff 0%, #0066bb 100%);
border-color: rgba(0, 227, 255, 0.5);
box-shadow: 0 0 12px rgba(0, 163, 255, 0.35);
}
}
.upload-tips {
width: 100%;
padding: 12px;
border-radius: 4px;
font-size: 12px;
color: $lt-text-muted;
background: rgba(10, 30, 60, 0.65);
border: 1px solid $lt-border-dim;
p {
margin: 4px 0;
line-height: 1.5;
}
}
</style>
<style lang="scss">
@import '../styles/luntan-dialog-tech.scss';
</style>

View File

@ -0,0 +1,534 @@
<template>
<div class="comment-list">
<div v-for="comment in comments" :key="comment.id" class="comment-item">
<!-- 一级评论 -->
<div class="comment-main">
<el-avatar :size="40" :src="comment.userAvatar" class="comment-avatar">
<img src="@/assets/images/mr.png" />
</el-avatar>
<div class="comment-content">
<div class="comment-header">
<div class="user-info">
<span class="user-name">{{ comment.userName }}</span>
<div v-if="comment.ssbm" class="author-tag">
{{ comment.ssbm }}
</div>
</div>
</div>
<div class="comment-text">{{ comment.content }}</div>
<div class="comment-footer">
<span class="comment-time">{{ comment.publishTime }}</span>
<div class="comment-actions">
<!-- <div class="action-btn" :class="{ active: comment.isLiked }" @click="handleLike(comment)">
<el-icon>
<Promotion />
</el-icon>
<span>{{ comment.likeCount || 0 }}</span>
</div> -->
<div class="action-btn" @click="handleReply(comment)">
回复
</div>
<div v-if="comment.replies && comment.replies.length > 0" class="action-btn toggle-btn"
@click="toggleReplies(comment)">
{{ comment.showReplies ? '收起' : `展开${comment.replies.length}条回复` }}
<el-icon :class="{ 'rotate-icon': comment.showReplies }">
<ArrowDown />
</el-icon>
</div>
</div>
</div>
<!-- 回复输入框 -->
<transition name="slide-fade">
<div v-if="activeReplyId === comment.id" class="reply-input-box">
<div class="reply-label">回复 {{ comment.userName }}</div>
<el-input v-model="replyContent" type="textarea" :rows="3" placeholder="输入回复内容..."
class="reply-textarea" />
<div class="reply-actions">
<V3Emoji :options-name="optionsName" @click-emoji="onEmojiClick" :recent="true" style="width: 40px;" />
<div class="reply-buttons">
<el-button size="small" @click="cancelReply">取消</el-button>
<el-button size="small" type="primary" @click="submitReply(comment)">
回复
</el-button>
</div>
</div>
</div>
</transition>
<!-- 二级评论列表 -->
<div v-if="comment.replies && comment.replies.length > 0 && comment.showReplies" class="replies-list">
<div v-for="reply in comment.replies" :key="reply.id" class="reply-item">
<el-avatar :size="32" :src="reply.userAvatar" class="reply-avatar">
<img src="@/assets/images/mr.png" />
</el-avatar>
<div class="reply-content">
<div class="reply-header">
<span class="user-name">{{ reply.userName }}</span>
<div v-if="reply.ssbm" class="author-tag">
{{ reply.ssbm }}
</div>
</div>
<div class="reply-text">
<span v-if="reply.replyToUser" class="reply-to">
回复 {{ reply.replyToUser }}:
</span>
{{ reply.content }}
</div>
<div class="reply-footer">
<span class="reply-time">{{ reply.publishTime }}</span>
<div class="reply-actions">
<!-- <div class="action-btn" :class="{ active: reply.isLiked }" @click="handleLike(reply)">
<el-icon>
<Promotion />
</el-icon>
<span v-if="reply.likeCount">{{ reply.likeCount }}</span>
</div> -->
<div class="action-btn" @click="handleReplyToReply(reply, comment)">
回复
</div>
</div>
</div>
<!-- 二级回复输入框 -->
<transition name="slide-fade">
<div v-if="activeReplyId === reply.id" class="reply-input-box">
<div class="reply-label">回复 {{ reply.userName }}</div>
<el-input v-model="replyContent" type="textarea" :rows="3" placeholder="输入回复内容..."
class="reply-textarea" />
<div class="reply-actions">
<V3Emoji :options-name="optionsName" @click-emoji="onEmojiClick" :recent="true"
style="width: 40px;" />
<div class="reply-buttons">
<el-button size="small" @click="cancelReply">取消</el-button>
<el-button size="small" type="primary" @click="submitReply(comment, reply)">
回复
</el-button>
</div>
</div>
</div>
</transition>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { Promotion, ArrowDown } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import V3Emoji from 'vue3-emoji'
import { tbGsxtXxltHfid, tbGsxtXxltHfSave, tbGsxtXxltHfSelectList } from '@/api/tbGsxtXxltHf.js'
import { getItem } from '@/utils/storage.js'
import { setAddress } from '@/utils/tools'
const optionsName = {
'Smileys & Emotion': '笑脸&表情',
'Food & Drink': '食物&饮料',
'Animals & Nature': '动物&自然',
'Travel & Places': '旅行&地点',
'People & Body': '人物&身体',
Objects: '物品',
Symbols: '符号',
Flags: '旗帜',
Activities: '活动'
}
const props = defineProps({
comments: {
type: Array,
default: () => []
}, replyTo: {
type: Object,
default: () => ({})
}
})
const emit = defineEmits(['reply', 'like'])
const activeReplyId = ref(null)
const replyContent = ref('')
const getTagType = (tag) => {
const tagMap = {
'户外活动部': 'success',
'校长': 'warning',
'校本部': 'info'
}
return tagMap[tag] || 'info'
}
const handleLike = (comment) => {
emit('like', comment)
}
const handleReply = (comment) => {
emit('reply')
activeReplyId.value = comment.id
replyContent.value = ''
}
const handleReplyToReply = (reply, parentComment) => {
emit('reply')
activeReplyId.value = reply.id
replyContent.value = `@${reply.userName}:`
}
const onEmojiClick = (emoji) => {
replyContent.value += emoji
}
const cancelReply = () => {
activeReplyId.value = null
replyContent.value = ''
}
// 切换回复列表的展开/收起
const toggleReplies = (comment) => {
if (!comment.showReplies) {
comment.showReplies = true
} else {
comment.showReplies = false
}
}
const formatReplyItem = (reply) => {
return {
...reply,
id: reply.id,
userName: reply.hfrxm || '匿名用户',
userAvatar: reply.userAvatar || (reply.hfrtx ? setAddress(reply.hfrtx) : ''),
userTag: reply.userTag || '',
content: reply.content || reply.hfnr || '',
publishTime: reply.publishTime || reply.hfsj || '',
likeCount: reply.likeCount || 0,
isLiked: reply.isLiked || false,
ssbm: reply.ssbm || '',
replyToUser: reply.replyToUser || reply.sjhfrxm || ''
}
}
const submitReply = async (parentComment, replyToComment = null) => {
console.log(parentComment);
if (!replyContent.value.trim()) {
ElMessage.warning('请输入回复内容')
return
}
try {
const ltmasg = getItem("ltmasg")
let pureContent = replyContent.value
if (pureContent.startsWith('@') && pureContent.includes(':')) {
const colonIndex = pureContent.indexOf(':')
if (colonIndex !== -1 && colonIndex < pureContent.length - 1) {
pureContent = pureContent.substring(colonIndex + 1).trim()
}
}
if (!pureContent) {
ElMessage.warning('请输入回复内容')
return
}
const targetReply = replyToComment || null
const newReply = {
hfnr: pureContent,
hfrsfzh: ltmasg.sfzh,
hfrtx: ltmasg.tx,
hfrxm: ltmasg.xm,
ltid: props.replyTo.id,
sfyjhf: 0,
sjhfid: parentComment.id,
sjhfrxm: targetReply ? targetReply.userName : ''
}
try {
const res = await tbGsxtXxltHfSave(newReply)
if (res) {
const dataxhf = await tbGsxtXxltHfSelectList({ sjhfid: parentComment.id })
const replyList = Array.isArray(dataxhf) ? dataxhf : (dataxhf?.records || [])
parentComment.replies = replyList.map(formatReplyItem)
}
} catch (error) {
console.log(error);
}
// if (!parentComment.replies) {
// parentComment.replies = []
// }
// parentComment.replies.push(newReply)
// 自动展开回复列表
parentComment.showReplies = true
ElMessage.success('回复成功')
cancelReply()
} catch (error) {
console.log(error);
ElMessage.error('回复失败')
}
}
</script>
<style scoped lang="scss">
@import '../styles/luntan-tech.scss';
.comment-list {
.comment-item {
margin-bottom: 18px;
padding-bottom: 18px;
border-bottom: 1px solid $lt-border-dim;
&:last-child {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
}
}
.comment-main {
display: flex;
gap: 12px;
align-items: flex-start;
}
.comment-avatar {
flex-shrink: 0;
border: 1px solid rgba(0, 227, 255, 0.3);
box-shadow: 0 0 8px rgba(0, 120, 180, 0.2);
}
.comment-content {
flex: 1;
min-width: 0;
padding-top: 2px;
}
.comment-header {
margin-bottom: 8px;
}
.user-info {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.user-name {
font-size: 14px;
font-weight: 600;
color: $lt-text;
}
.comment-text {
font-size: 14px;
line-height: 1.65;
color: $lt-text-dim;
margin-bottom: 8px;
word-break: break-word;
}
.comment-footer {
display: flex;
align-items: center;
justify-content: space-between;
}
.comment-time {
font-size: 12px;
color: $lt-text-muted;
}
.comment-actions {
display: flex;
gap: 16px;
}
.action-btn {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: $lt-text-muted;
cursor: pointer;
transition: color 0.2s ease;
&:hover {
color: $lt-cyan;
text-shadow: 0 0 8px rgba(0, 227, 255, 0.35);
}
&.active {
color: $lt-cyan;
}
&.toggle-btn {
.el-icon {
transition: transform 0.3s ease;
}
.rotate-icon {
transform: rotate(180deg);
}
}
}
.reply-input-box {
margin-top: 12px;
padding: 12px;
border-radius: 4px;
@include lt-panel-soft-bg;
}
.reply-label {
font-size: 12px;
color: $lt-text-muted;
margin-bottom: 8px;
}
.reply-textarea {
margin-bottom: 8px;
:deep(.el-textarea__inner) {
background: rgba(10, 28, 58, 0.88) !important;
color: $lt-text-dim;
border: 1px solid rgba(0, 227, 255, 0.28);
border-radius: 4px;
box-shadow: 0 0 12px rgba(0, 80, 140, 0.15) inset;
&::placeholder {
color: $lt-text-muted;
}
}
}
.reply-actions {
display: flex;
align-items: center;
justify-content: space-between;
}
.reply-buttons {
display: flex;
gap: 8px;
:deep(.el-button) {
border-radius: 2px;
}
:deep(.el-button--default) {
background: rgba(0, 40, 70, 0.5);
border-color: rgba(0, 163, 255, 0.35);
color: $lt-text-dim;
}
:deep(.el-button--primary) {
background: linear-gradient(180deg, #00a3ff 0%, #0066bb 100%);
border-color: rgba(0, 227, 255, 0.45);
box-shadow: 0 0 12px rgba(0, 163, 255, 0.35);
}
}
.replies-list {
margin-top: 14px;
padding-left: 12px;
border-left: 2px solid rgba(0, 227, 255, 0.25);
box-shadow: -2px 0 12px rgba(0, 100, 160, 0.08);
}
.reply-item {
display: flex;
gap: 10px;
margin-bottom: 16px;
align-items: flex-start;
&:last-child {
margin-bottom: 0;
}
}
.reply-avatar {
flex-shrink: 0;
border: 1px solid rgba(0, 227, 255, 0.22);
}
.reply-content {
flex: 1;
min-width: 0;
padding-top: 2px;
}
.reply-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
flex-wrap: wrap;
}
.reply-text {
font-size: 13px;
line-height: 1.6;
color: $lt-text-dim;
margin-bottom: 6px;
word-break: break-word;
}
.reply-to {
color: $lt-cyan;
margin-right: 4px;
}
.reply-footer {
display: flex;
align-items: center;
justify-content: space-between;
}
.reply-time {
font-size: 12px;
color: $lt-text-muted;
}
.reply-actions {
display: flex;
gap: 12px;
}
.slide-fade-enter-active {
transition: all 0.3s ease;
}
.slide-fade-leave-active {
transition: all 0.2s ease;
}
.slide-fade-enter-from,
.slide-fade-leave-to {
transform: translateY(-10px);
opacity: 0;
}
.author-tag {
padding: 2px 8px;
border-radius: 2px;
background: rgba(0, 163, 255, 0.22);
color: #b8ecff;
font-size: 11px;
line-height: 18px;
border: 1px solid rgba(0, 227, 255, 0.35);
}
</style>
<style lang="scss">
@import '../styles/luntan-v3emoji-tech.scss';
</style>

View File

@ -0,0 +1,565 @@
<template>
<div class="post-detail luntan-tech-detail">
<!-- 头部 -->
<div class="detail-header">
<el-button class="detail-back-btn" @click="handleBack">
<el-icon>
<ArrowLeft />
</el-icon>
返回
</el-button>
<div class="header-title">帖子详情</div>
</div>
<!-- 帖子内容 -->
<div class="post-main">
<div class="premium-badge" v-if="postData.isPremium">置顶</div>
<div class="post-author">
<el-avatar :size="50" :src="postData.userAvatar" class="author-avatar">
<img src="@/assets/images/mr.png" />
</el-avatar>
<div class="author-info">
<div class="author-name-row">
<span class="author-name">{{ postData.userName }}</span>
<span v-if="postData.userTag" class="level-badge">{{
postData.userTag
}}</span>
<div v-if="postData.ssbm" class="author-tag">
{{ postData.ssbm }}
</div>
</div>
<div class="publish-time">{{ postData.publishTime }}</div>
</div>
</div>
<div class="post-content-text">{{ postData.content }}</div>
<!-- 图片展示 -->
<div
class="post-images"
v-if="postData.images && postData.images.length > 0"
>
<div
v-for="(img, index) in postData.images"
:key="index"
class="image-item"
>
<el-image
:preview-teleported="true"
:src="img"
fit="cover"
:preview-src-list="postData.images"
:initial-index="index"
>
<template #error>
<div class="image-error">
<el-icon>
<Picture />
</el-icon>
</div>
</template>
</el-image>
</div>
</div>
<!-- 统计信息 -->
<div class="post-stats">
<div class="stat-item">
<el-icon>
<ChatDotRound />
</el-icon>
<span>{{ postData.commentCount || 0 }}</span>
</div>
<!-- <div class="stat-item" :class="{ active: postData.isLiked }" @click="handleLike">
<el-icon>
<Promotion />
</el-icon>
<span>{{ postData.likeCount || 0 }}</span>
</div> -->
</div>
</div>
<!-- 评论区 -->
<div class="comment-section">
<!-- Tab切换 -->
<div class="comment-tabs">
<div
class="tab-item"
:class="{ active: activeTab === 'all' }"
@click="activeTab = 'all'"
>
全部回复({{ comments.length }})
</div>
<!-- <div class="tab-item" :class="{ active: activeTab === 'author' }" @click="activeTab = 'author'">
只看楼主
</div> -->
<!-- <div class="sort-buttons">
<el-button text size="small">热门</el-button>
<el-button text size="small">正序</el-button>
<el-button text size="small">倒序</el-button>
</div> -->
</div>
<!-- 顶部输入框 - 点击打开弹窗 -->
<div class="top-input" @click="replyToData">
<el-input placeholder="发点干货 文明第一步" readonly />
</div>
<!-- 评论列表 -->
<CommentList
:comments="filteredComments"
@reply="handleReply"
:replyTo="replyTo"
@like="handleCommentLike"
/>
</div>
<!-- 回复弹窗 -->
<ReplyDialog
v-model="showReplyDialog"
:reply-to="replyTo"
@success="handleReplySuccess"
/>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from "vue";
import {
ArrowLeft,
ChatDotRound,
Promotion,
Picture
} from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import CommentList from "./CommentList.vue";
import ReplyDialog from "./ReplyDialog.vue";
import { tbGsxtXxltHfid } from "@/api/tbGsxtXxltHf";
import { setAddress } from "@/utils/tools";
const props = defineProps({
postId: {
type: [String, Number],
required: true
}
});
const emit = defineEmits(["back"]);
const activeTab = ref("all");
const showReplyDialog = ref(false);
const replyTo = ref(null);
const loading = ref(false);
// 帖子数据
const postData = ref({
id: null,
userName: "",
userAvatar: "",
userTag: "",
publishTime: "",
content: "",
images: [],
commentCount: 0,
likeCount: 0,
isPremium: false,
isLiked: false
});
// 评论数据
const comments = ref([]);
onMounted(() => {
loadPostDetail();
});
const loadPostDetail = async () => {
loading.value = true;
try {
const res = await tbGsxtXxltHfid(props.postId);
// 设置帖子数据
postData.value = {
id: res.id,
userName: res.fbrxm || "匿名用户",
userAvatar: res.fbrtx ? setAddress(res.fbrtx) : "",
userTag: res.userTag || "",
publishTime: res.time || "",
content: res.content || "",
images: res.tp ? res.tp.split(",").map((img) => setAddress(img)) : [],
commentCount: res.commentCount || 0,
likeCount: res.likeCount || 0,
isPremium: res.sfzd === 1,
isLiked: false,
ssbm: res.ssbm
};
// 设置评论数据
if (res.replyList && res.replyList.length > 0) {
comments.value = res.replyList.map((item) => ({
id: item.id,
userName: item.hfrxm || "匿名用户",
userAvatar: item.hfrtx ? setAddress(item.hfrtx) : "",
userTag: item.userTag || "",
content: item.hfnr || "",
publishTime: item.hfsj || "",
likeCount: item.likeCount || 0,
isLiked: false,
showReplies: false,
ssbm: item.ssbm,
replies: item.xjfhList
? item.xjfhList.map((reply) => ({
id: reply.id,
userName: reply.hfrxm || "匿名用户",
userAvatar: reply.hfrtx ? setAddress(reply.hfrtx) : "",
userTag: reply.userTag || "",
content: reply.hfnr || "",
publishTime: reply.hfsj || "",
likeCount: reply.likeCount || 0,
isLiked: false,
replyToUser: reply.sjhfrxm || "",
ssbm: reply.ssbm
}))
: []
}));
}
} catch (error) {
console.error("加载详情失败", error);
ElMessage.error("加载详情失败");
} finally {
loading.value = false;
}
};
const filteredComments = computed(() => {
if (activeTab.value === "author") {
return comments.value.filter((c) => c.userName === postData.value.userName);
}
return comments.value;
});
const getTagType = (tag) => {
const tagMap = {
户外活动部: "success",
校长: "warning",
校本部: "info"
};
return tagMap[tag] || "info";
};
const handleBack = () => {
emit("back");
};
const handleLike = () => {
postData.value.isLiked = !postData.value.isLiked;
postData.value.likeCount += postData.value.isLiked ? 1 : -1;
ElMessage.success(postData.value.isLiked ? "点赞成功" : "取消点赞");
};
const handleReply = () => {
replyTo.value = {
...postData.value
};
// showReplyDialog.value = true
};
const replyToData = () => {
replyTo.value = {
...postData.value
};
showReplyDialog.value = true;
};
const handleCommentLike = (comment) => {
comment.isLiked = !comment.isLiked;
comment.likeCount += comment.isLiked ? 1 : -1;
};
const handleReplySuccess = () => {
ElMessage.success("回复成功");
loadPostDetail();
};
</script>
<style scoped lang="scss">
@import "../styles/luntan-tech.scss";
.luntan-tech-detail {
background: transparent;
height: 100%;
display: flex;
flex-direction: column;
}
.detail-header {
padding: 14px 18px;
display: flex;
align-items: center;
flex-shrink: 0;
border-bottom: 1px solid $lt-border-dim;
background: linear-gradient(
90deg,
rgba(0, 163, 255, 0.08) 0%,
transparent 55%
);
:deep(.detail-back-btn.el-button) {
height: 36px;
padding: 0 14px;
font-weight: 500;
border-radius: 4px;
background: rgba(10, 30, 60, 0.75) !important;
border: 1px solid $lt-border-dim !important;
color: $lt-text-dim !important;
box-shadow: inset 0 1px 0 rgba(0, 220, 255, 0.08),
0 0 12px rgba(0, 80, 140, 0.2);
&:hover,
&:focus {
color: $lt-cyan !important;
border-color: rgba(0, 227, 255, 0.45) !important;
background: rgba(12, 40, 75, 0.92) !important;
box-shadow: 0 0 16px rgba(0, 163, 255, 0.28),
inset 0 1px 0 rgba(0, 220, 255, 0.1);
}
}
:deep(.detail-back-btn .el-icon) {
margin-right: 4px;
font-size: 16px;
color: inherit;
}
.header-title {
flex: 1;
text-align: center;
font-size: 16px;
font-weight: 600;
color: $lt-text;
margin-right: 60px;
letter-spacing: 0.08em;
text-shadow: 0 0 16px rgba(0, 227, 255, 0.35);
}
}
.post-main {
padding: 20px 18px 18px;
margin: 12px 12px 0;
position: relative;
flex-shrink: 0;
border-radius: 4px;
@include lt-panel-soft-bg;
}
.comment-section {
padding: 8px 12px 32px;
flex: 1;
overflow-y: auto;
min-height: 0;
}
.premium-badge {
position: absolute;
top: 16px;
right: 16px;
background: linear-gradient(
180deg,
rgba(0, 227, 255, 0.35) 0%,
rgba(0, 100, 180, 0.5) 100%
);
color: #fff;
padding: 4px 12px;
border-radius: 2px;
font-size: 12px;
font-weight: 600;
border: 1px solid rgba(0, 227, 255, 0.55);
box-shadow: 0 0 14px rgba(0, 227, 255, 0.35);
}
.post-author {
display: flex;
gap: 12px;
margin-bottom: 16px;
align-items: flex-start;
}
.author-avatar {
border: 2px solid rgba(0, 227, 255, 0.35);
box-shadow: 0 0 12px rgba(0, 163, 255, 0.25);
}
.author-info {
flex: 1;
padding-top: 2px;
}
.author-name-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 4px;
}
.author-name {
font-size: 16px;
font-weight: 700;
color: $lt-text;
}
.level-badge {
font-size: 11px;
padding: 2px 8px;
border-radius: 2px;
background: rgba(0, 227, 255, 0.15);
color: $lt-cyan;
font-weight: 600;
border: 1px solid rgba(0, 227, 255, 0.35);
}
.author-tag {
padding: 2px 8px;
border-radius: 2px;
background: rgba(0, 163, 255, 0.25);
color: #b8ecff;
font-size: 12px;
line-height: 18px;
border: 1px solid rgba(0, 227, 255, 0.35);
}
.publish-time {
font-size: 12px;
color: $lt-text-muted;
}
.post-content-text {
font-size: 14px;
line-height: 1.85;
color: $lt-text-dim;
margin-bottom: 16px;
word-break: break-word;
}
.post-images {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 16px;
}
.image-item {
width: 200px;
height: 140px;
border-radius: 4px;
overflow: hidden;
border: 1px solid $lt-border-dim;
box-shadow: 0 0 12px rgba(0, 100, 160, 0.2);
:deep(.el-image) {
width: 100%;
height: 100%;
}
}
.image-error {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
background: rgba(10, 30, 60, 0.6);
color: $lt-text-muted;
font-size: 24px;
}
.post-stats {
display: flex;
gap: 24px;
padding-top: 16px;
border-top: 1px solid $lt-border-dim;
}
.stat-item {
display: flex;
align-items: center;
gap: 6px;
color: $lt-text-muted;
font-size: 14px;
cursor: pointer;
transition: color 0.2s ease;
&:hover {
color: $lt-cyan;
}
.el-icon {
font-size: 18px;
}
}
.comment-tabs {
display: flex;
align-items: center;
margin-bottom: 14px;
border-bottom: 1px solid $lt-border-dim;
}
.tab-item {
padding: 12px 14px;
font-size: 14px;
color: $lt-text-muted;
cursor: pointer;
position: relative;
transition: color 0.2s ease;
&.active {
color: $lt-cyan;
font-weight: 600;
&::after {
content: "";
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg, transparent, $lt-cyan, transparent);
box-shadow: 0 0 10px rgba(0, 227, 255, 0.6);
}
}
}
.sort-buttons {
margin-left: auto;
display: flex;
gap: 8px;
}
.top-input {
margin-bottom: 16px;
cursor: pointer;
:deep(.el-input__wrapper) {
cursor: pointer;
background: rgba(10, 30, 60, 0.65) !important;
border-radius: 4px !important;
box-shadow: 0 0 0 1px rgba(0, 227, 255, 0.28) inset,
0 0 16px rgba(0, 100, 180, 0.15) !important;
&:hover {
box-shadow: 0 0 0 1px rgba(0, 227, 255, 0.45) inset,
0 0 20px rgba(0, 163, 255, 0.25) !important;
}
}
:deep(.el-input__inner) {
color: $lt-text-dim;
&::placeholder {
color: $lt-text-muted;
}
}
}
</style>

View File

@ -0,0 +1,332 @@
<template>
<div class="post-item" @click="handleClick">
<div class="premium-badge" v-if="post.isPremium">置顶</div>
<div class="post-main-content">
<el-avatar :size="44" :src="post.userAvatar" class="post-avatar">
<img src="@/assets/images/mr.png" />
</el-avatar>
<div class="post-right">
<div class="post-header">
<div class="user-name-row">
<span class="user-name">{{ post.userName }}</span>
<span v-if="post.userTag" class="level-badge">{{
post.userTag
}}</span>
<div v-if="post.ssbm" class="author-tag">
{{ post.ssbm }}
</div>
</div>
<div class="post-time">{{ post.publishTime }}</div>
</div>
<div class="post-content" v-if="postTitle || bodySource">
<div v-if="postTitle" class="post-title">{{ postTitle }}</div>
<div v-if="bodySource" class="post-text-wrap">
<div class="post-text">{{ displayBody }}</div>
<span
v-if="showFullLink"
class="full-text-link"
@click.stop="handleClick"
>全文</span
>
</div>
<!-- 图片展示 -->
<div
class="post-images"
v-if="post.images && post.images.length > 0"
:class="{ 'is-three': post.images.length >= 3 }"
>
<div
v-for="(img, index) in post.images"
:key="index"
class="image-item"
@click.stop
>
<el-image
:preview-teleported="true"
:src="img"
fit="cover"
:preview-src-list="post.images"
:initial-index="index"
>
<template #error>
<div class="image-error">
<el-icon>
<Picture />
</el-icon>
</div>
</template>
</el-image>
</div>
</div>
</div>
<div class="post-footer">
<div class="action-item">
<el-icon>
<ChatDotRound />
</el-icon>
<span>{{ post.commentCount || 0 }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from "vue";
import { ChatDotRound, Picture } from "@element-plus/icons-vue";
const props = defineProps({
post: {
type: Object,
required: true
}
});
const emit = defineEmits(["like", "click"]);
const EXCERPT_LEN = 160;
const rawContent = computed(() => (props.post.content || "").trim());
const postTitle = computed(() => {
const t = rawContent.value;
if (!t) return "";
const idx = t.indexOf("\n");
if (idx === -1) return "";
const first = t.slice(0, idx).trim();
return first.length > 0 ? first : "";
});
const bodySource = computed(() => {
const t = rawContent.value;
if (!t) return "";
const idx = t.indexOf("\n");
if (idx === -1) return t;
const rest = t.slice(idx + 1).trim();
return rest;
});
const showFullLink = computed(() => bodySource.value.length > EXCERPT_LEN);
const displayBody = computed(() => {
const b = bodySource.value;
if (!b) return "";
if (b.length <= EXCERPT_LEN) return b;
return `${b.slice(0, EXCERPT_LEN)}`;
});
const handleClick = () => {
emit("click", props.post);
};
</script>
<style scoped lang="scss">
@import "../styles/luntan-tech.scss";
.post-item {
position: relative;
padding: 18px 18px 14px;
margin-bottom: 14px;
border-radius: 4px;
cursor: pointer;
@include lt-panel-frame;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
&:hover {
border-color: rgba(0, 227, 255, 0.55);
box-shadow: $lt-glow-strong, inset 0 1px 0 rgba(0, 220, 255, 0.18);
}
}
.premium-badge {
position: absolute;
top: 14px;
right: 14px;
background: linear-gradient(
180deg,
rgba(0, 227, 255, 0.35) 0%,
rgba(0, 100, 180, 0.45) 100%
);
color: #fff;
padding: 3px 10px;
border-radius: 2px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.04em;
border: 1px solid rgba(0, 227, 255, 0.55);
box-shadow: 0 0 12px rgba(0, 227, 255, 0.35);
}
.post-main-content {
display: flex;
gap: 12px;
align-items: flex-start;
}
.post-avatar {
flex-shrink: 0;
border: 2px solid rgba(0, 227, 255, 0.35);
box-shadow: 0 0 10px rgba(0, 163, 255, 0.2);
}
.post-right {
flex: 1;
min-width: 0;
padding-top: 0;
}
.post-header {
margin-bottom: 10px;
}
.user-name-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px 8px;
margin-bottom: 4px;
}
.user-name {
font-size: 16px;
font-weight: 700;
color: $lt-text;
}
.level-badge {
font-size: 11px;
padding: 2px 8px;
border-radius: 2px;
background: rgba(0, 227, 255, 0.12);
color: $lt-cyan;
font-weight: 600;
line-height: 1.3;
border: 1px solid rgba(0, 227, 255, 0.35);
}
.post-time {
font-size: 12px;
color: $lt-text-muted;
}
.post-content {
margin-bottom: 12px;
}
.post-title {
font-size: 16px;
font-weight: 700;
color: $lt-text;
line-height: 1.45;
margin-bottom: 8px;
word-break: break-word;
}
.post-text-wrap {
font-size: 14px;
line-height: 1.65;
color: $lt-text-dim;
word-break: break-word;
}
.post-text {
display: inline;
}
.full-text-link {
margin-left: 4px;
color: $lt-cyan;
font-weight: 500;
cursor: pointer;
&:hover {
text-shadow: 0 0 8px rgba(0, 227, 255, 0.5);
}
}
.post-images {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 12px;
&.is-three .image-item {
width: calc((100% - 16px) / 3);
min-width: 0;
aspect-ratio: 4 / 3;
height: auto;
}
}
.image-item {
width: 160px;
height: 112px;
border-radius: 4px;
overflow: hidden;
cursor: pointer;
flex-shrink: 0;
border: 1px solid $lt-border-dim;
box-shadow: 0 0 10px rgba(0, 80, 140, 0.25);
:deep(.el-image) {
width: 100%;
height: 100%;
}
}
.post-images.is-three .image-item {
max-width: 200px;
}
.image-error {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
background: rgba(10, 30, 60, 0.65);
color: $lt-text-muted;
font-size: 22px;
}
.post-footer {
display: flex;
gap: 20px;
padding-top: 10px;
border-top: 1px solid $lt-border-dim;
}
.action-item {
display: flex;
align-items: center;
gap: 6px;
color: $lt-text-muted;
font-size: 13px;
cursor: pointer;
transition: color 0.2s ease;
&:hover {
color: $lt-cyan;
}
.el-icon {
font-size: 17px;
}
}
.author-tag {
padding: 2px 8px;
border-radius: 2px;
background: rgba(0, 200, 180, 0.18);
color: #9ff;
font-size: 11px;
line-height: 18px;
font-weight: 600;
border: 1px solid rgba(0, 227, 255, 0.35);
}
</style>

View File

@ -0,0 +1,463 @@
<template>
<div class="post-list">
<!-- 贴吧风格顶栏左信息 + 右操作页面背景仍为外层网格不变 -->
<div class="bar-head">
<div class="bar-head-left">
<div class="bar-logo">
<el-icon><ChatDotRound /></el-icon>
</div>
<div class="bar-head-text">
<div class="bar-title">信息论坛</div>
<div class="bar-meta">帖子 {{ totalDisplay }}</div>
</div>
</div>
<div class="bar-actions">
<button
type="button"
class="bar-pill bar-pill-primary"
@click="showPublishDialog = true"
>
<el-icon><Plus /></el-icon>
发帖
</button>
</div>
</div>
<!-- 帖子列表 -->
<div
class="posts-container"
v-loading="loading"
v-infinite-scroll="loadMore"
:infinite-scroll-disabled="scrollDisabled"
:infinite-scroll-distance="100"
>
<!-- 置顶仅标题缩略列表 -->
<div v-if="pinnedList.length" class="pinned-block">
<div class="pinned-list">
<div
v-for="post in pinnedList"
:key="'zd-' + post.id"
class="pinned-row"
@click="handlePostClick(post)"
>
<span class="pinned-badge">置顶</span>
<span class="pinned-title">{{ displayTitle(post) }}</span>
</div>
</div>
</div>
<!-- 分界线 -->
<div v-if="pinnedList.length && normalList.length" class="list-divider">
<span class="divider-line" />
<span class="divider-text">全部帖子</span>
<span class="divider-line" />
</div>
<!-- 普通帖子 -->
<PostItem
v-for="post in normalList"
:key="post.id"
:post="post"
@like="handleLike"
@click="handlePostClick(post)"
/>
<!-- 加载更多提示 -->
<div v-if="loadingMore" class="loading-more">
<el-icon class="is-loading">
<Loading />
</el-icon>
<span>加载中...</span>
</div>
<!-- 没有更多数据提示 -->
<div v-if="noMore && normalList.length > 0" class="no-more">
没有更多数据了
</div>
<!-- 空状态 -->
<el-empty
v-if="!loading && postList.length === 0"
class="post-empty"
description="暂无帖子"
/>
</div>
<!-- 发布对话框 -->
<PublishDialog
v-model="showPublishDialog"
@success="handlePublishSuccess"
/>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from "vue";
import { ChatDotRound, Loading, Plus } from "@element-plus/icons-vue";
import PostItem from "./PostItem.vue";
import PublishDialog from "./PublishDialog.vue";
import { ElMessage } from "element-plus";
import { tbGsxtXxltSelectPage } from "@/api/tbGsxtXxltHf";
import { setAddress } from "@/utils/tools";
const loading = ref(false);
const loadingMore = ref(false);
const postList = ref([]);
const showPublishDialog = ref(false);
const listQuery = ref({
pageCurrent: 1,
pageSize: 10
});
const total = ref(0);
const totalDisplay = computed(() => {
const n = total.value;
if (n >= 10000) return `${(n / 10000).toFixed(1)}W`;
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
return String(n);
});
const pinnedList = computed(() => postList.value.filter((p) => p.isPremium));
const normalList = computed(() => postList.value.filter((p) => !p.isPremium));
/** 置顶行展示标题:优先接口 title否则从正文首行截取 */
function displayTitle(post) {
const tit = (post.title || "").trim();
if (tit) {
return tit.length <= 56 ? tit : `${tit.slice(0, 56)}`;
}
const t = (post.content || "").trim();
if (!t) return "无标题";
const idx = t.indexOf("\n");
const firstLine = idx === -1 ? t : t.slice(0, idx).trim();
const line = firstLine || t.slice(0, 80);
if (line.length <= 56) return line;
return `${line.slice(0, 56)}`;
}
// 计算是否禁用滚动加载
const scrollDisabled = computed(() => {
return loadingMore.value || noMore.value;
});
// 计算是否没有更多数据
const noMore = computed(() => {
return postList.value.length >= total.value && total.value > 0;
});
onMounted(() => {
loadPosts();
});
const loadPosts = async (isLoadMore = false) => {
if (isLoadMore) {
loadingMore.value = true;
} else {
loading.value = true;
}
try {
const res = await tbGsxtXxltSelectPage(listQuery.value);
const data = (res.records || []).map((item) => ({
id: item.id,
title: item.title || "",
userName: item.fbrxm || "匿名用户",
userAvatar: item.fbrtx ? setAddress(item.fbrtx) : "",
userTag: item.userTag || "",
publishTime: item.time || "",
content: item.content || "",
images: item.tp ? item.tp.split(",").map((img) => setAddress(img)) : [],
commentCount: item.hfsl || 0,
likeCount: item.likeCount || 0,
isPremium: item.sfzd === 1,
isLiked: false,
ssbm: item.ssbm,
// 保存原始数据
rawData: item
}));
if (isLoadMore) {
postList.value = [...postList.value, ...data];
} else {
postList.value = data;
}
total.value = res.total || 0;
} catch (error) {
console.error("加载失败", error);
ElMessage.error("加载失败");
} finally {
loading.value = false;
loadingMore.value = false;
}
};
// 加载更多
const loadMore = () => {
if (postList.value.length >= total.value) {
return;
}
listQuery.value.pageCurrent++;
loadPosts(true);
};
const handleLike = (post) => {
post.isLiked = !post.isLiked;
post.likeCount += post.isLiked ? 1 : -1;
ElMessage.success(post.isLiked ? "点赞成功" : "取消点赞");
};
const handlePublishSuccess = () => {
// 重置分页并重新加载
listQuery.value.pageCurrent = 1;
loadPosts();
};
const emit = defineEmits(["openDetail"]);
const handlePostClick = (post) => {
emit("openDetail", post);
};
</script>
<style scoped lang="scss">
@import "../styles/luntan-tech.scss";
.post-list {
background: transparent;
}
// 贴吧式顶栏:圆角条 + 左图标标题统计 + 右胶囊按钮(外层页面仍为原深蓝网格背景)
.bar-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
padding: 14px 18px;
border-radius: 12px;
background: linear-gradient(
160deg,
rgba(16, 46, 96, 0.88) 0%,
rgba(12, 34, 76, 0.82) 100%
);
border: 1px solid rgba(0, 163, 255, 0.28);
box-shadow: inset 0 1px 0 rgba(0, 220, 255, 0.1),
0 4px 20px rgba(0, 0, 0, 0.25);
}
.bar-head-left {
display: flex;
align-items: center;
gap: 14px;
min-width: 0;
}
.bar-logo {
flex-shrink: 0;
width: 48px;
height: 48px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 2px 10px rgba(0, 100, 180, 0.2);
.el-icon {
font-size: 26px;
color: #00a3ff;
}
}
.bar-head-text {
min-width: 0;
}
.bar-title {
font-size: 17px;
font-weight: 700;
color: $lt-text;
letter-spacing: 0.02em;
line-height: 1.3;
}
.bar-meta {
margin-top: 4px;
font-size: 12px;
color: $lt-text-muted;
line-height: 1.4;
}
.bar-actions {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.bar-pill {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 8px 18px;
border-radius: 999px;
font-size: 13px;
font-weight: 500;
color: rgba(230, 240, 255, 0.95);
border: 1px solid rgba(255, 255, 255, 0.22);
background: rgba(255, 255, 255, 0.06);
cursor: pointer;
transition: background 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
.el-icon {
font-size: 14px;
}
&:hover {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(0, 227, 255, 0.35);
}
}
.bar-pill-primary {
border-color: rgba(0, 227, 255, 0.4);
background: rgba(0, 60, 120, 0.35);
&:hover {
background: rgba(0, 100, 180, 0.45);
box-shadow: 0 0 14px rgba(0, 163, 255, 0.25);
}
}
// 置顶缩略区(仅标题)
.pinned-block {
margin-bottom: 4px;
}
.pinned-list {
border-radius: 8px;
padding: 10px 12px;
background: rgba(220, 235, 255, 0.12);
border: 1px solid rgba(0, 163, 255, 0.22);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.pinned-row {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 8px 4px;
cursor: pointer;
border-radius: 4px;
transition: background 0.15s ease;
& + .pinned-row {
border-top: 1px solid rgba(0, 163, 255, 0.12);
}
&:hover {
background: rgba(0, 163, 255, 0.08);
}
}
.pinned-badge {
flex-shrink: 0;
padding: 2px 8px;
font-size: 11px;
font-weight: 600;
color: $lt-cyan-mid;
border: 1px solid rgba(0, 163, 255, 0.45);
border-radius: 4px;
background: rgba(0, 40, 90, 0.35);
}
.pinned-title {
flex: 1;
min-width: 0;
font-size: 14px;
font-weight: 600;
color: $lt-text;
line-height: 1.45;
word-break: break-word;
}
// 置顶与普通帖之间的分界
.list-divider {
display: flex;
align-items: center;
gap: 12px;
margin: 16px 0 14px;
padding: 0 4px;
}
.divider-line {
flex: 1;
height: 1px;
background: linear-gradient(
90deg,
transparent,
rgba(0, 163, 255, 0.45),
transparent
);
}
.divider-text {
flex-shrink: 0;
font-size: 12px;
color: $lt-text-muted;
letter-spacing: 0.08em;
}
.posts-container {
min-height: 400px;
:deep(.el-loading-mask) {
background-color: rgba(0, 5, 16, 0.65);
}
:deep(.el-loading-spinner .path) {
stroke: #3db8ff;
}
}
.post-empty {
padding: 48px 0;
:deep(.el-empty__description) {
color: rgba(180, 200, 230, 0.65);
}
:deep(.el-empty__image) {
opacity: 0.85;
filter: brightness(0.95);
}
}
.loading-more {
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
color: rgba(160, 185, 215, 0.75);
font-size: 14px;
gap: 8px;
.el-icon {
font-size: 16px;
color: #3db8ff;
}
}
.no-more {
text-align: center;
padding: 20px;
color: rgba(140, 165, 200, 0.55);
font-size: 14px;
}
</style>

View File

@ -0,0 +1,172 @@
<template>
<el-dialog
v-model="dialogVisible"
class="luntan-tech-dialog"
title="发布帖子"
width="60%"
:before-close="handleClose"
>
<div style="overflow: auto; height: 60vh">
<el-form :model="form" :rules="rules" ref="formRef" label-width="80px">
<el-form-item label="标题" prop="title">
<el-input
v-model="form.title"
placeholder="请输入帖子标题"
maxlength="50"
show-word-limit
/>
</el-form-item>
<el-form-item label="内容" prop="content">
<el-input
v-model="form.content"
type="textarea"
:rows="6"
placeholder="请输入帖子内容"
maxlength="500"
show-word-limit
/>
</el-form-item>
<el-form-item label="表情">
<V3Emoji
:options-name="optionsName"
@click-emoji="onEmojiClick"
:recent="true"
/>
</el-form-item>
<el-form-item label="图片">
<Upload v-model="imageIds" :limit="9" :isImg="true" :isAll="true" />
</el-form-item>
</el-form>
</div>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitting">
发布
</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, watch } from "vue";
import { ElMessage } from "element-plus";
import V3Emoji from "vue3-emoji";
import { tbGsxtXxltSave } from "@/api/tbGsxtXxltHf";
import { getItem } from "@/utils/storage.js";
import Upload from "@/components/MyComponents/Upload/index.vue";
const optionsName = {
"Smileys & Emotion": "笑脸&表情",
"Food & Drink": "食物&饮料",
"Animals & Nature": "动物&自然",
"Travel & Places": "旅行&地点",
"People & Body": "人物&身体",
Objects: "物品",
Symbols: "符号",
Flags: "旗帜",
Activities: "活动"
};
const props = defineProps({
modelValue: {
type: Boolean,
default: false
}
});
const emit = defineEmits(["update:modelValue", "success"]);
const dialogVisible = ref(false);
const formRef = ref();
const submitting = ref(false);
const imageIds = ref([]);
const form = ref({
title: "",
content: ""
});
const rules = {
title: [{ required: true, message: "请输入标题", trigger: "blur" }],
content: [{ required: true, message: "请输入内容", trigger: "blur" }]
};
watch(
() => props.modelValue,
(val) => {
dialogVisible.value = val;
}
);
watch(dialogVisible, (val) => {
emit("update:modelValue", val);
if (!val) {
resetForm();
}
});
const onEmojiClick = (emoji) => {
form.value.content += emoji;
};
const handleClose = () => {
dialogVisible.value = false;
};
const handleSubmit = async () => {
if (!formRef.value) return;
await formRef.value.validate(async (valid) => {
if (valid) {
submitting.value = true;
try {
const ltmasg = getItem("ltmasg");
const postData = {
title: form.value.title,
content: form.value.content,
tp: imageIds.value.join(","),
fbrsfzh: ltmasg?.sfzh || "",
fbrxm: ltmasg?.xm || "",
fbrtx: ltmasg?.tx || ""
};
await tbGsxtXxltSave(postData);
ElMessage.success("发布成功");
emit("success");
handleClose();
} catch (error) {
console.error("发布失败", error);
ElMessage.error("发布失败");
} finally {
submitting.value = false;
}
}
});
};
const resetForm = () => {
form.value = {
title: "",
content: ""
};
imageIds.value = [];
formRef.value?.resetFields();
};
</script>
<style scoped lang="scss">
// Upload 等子组件样式在各自内部
::v-deep .form-item-box {
width: 100% !important;
}
</style>
<style lang="scss">
@import "../styles/luntan-dialog-tech.scss";
</style>

View File

@ -0,0 +1,162 @@
<template>
<el-dialog
v-model="dialogVisible"
class="luntan-tech-dialog"
title="发表回复"
width="600px"
:before-close="handleClose"
>
<el-form :model="form" :rules="rules" ref="formRef">
<el-form-item prop="content">
<el-input
v-model="form.content"
type="textarea"
:rows="8"
placeholder="发点干货 文明第一步"
maxlength="500"
show-word-limit
/>
</el-form-item>
<el-form-item>
<div class="emoji-row">
<V3Emoji
:options-name="optionsName"
@click-emoji="onEmojiClick"
:recent="true"
/>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitting">
回复
</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, watch } from "vue";
import { ElMessage } from "element-plus";
import V3Emoji from "vue3-emoji";
import { getItem } from "@/utils/storage.js";
import {
tbGsxtXxltHfid,
tbGsxtXxltHfSave,
tbGsxtXxltHfSelectList
} from "@/api/tbGsxtXxltHf.js";
const optionsName = {
"Smileys & Emotion": "笑脸&表情",
"Food & Drink": "食物&饮料",
"Animals & Nature": "动物&自然",
"Travel & Places": "旅行&地点",
"People & Body": "人物&身体",
Objects: "物品",
Symbols: "符号",
Flags: "旗帜",
Activities: "活动"
};
const props = defineProps({
modelValue: {
type: Boolean,
default: false
},
replyTo: {
type: Object,
default: null
}
});
const emit = defineEmits(["update:modelValue", "success"]);
const dialogVisible = ref(false);
const formRef = ref();
const submitting = ref(false);
const form = ref({
content: ""
});
const rules = {
content: [{ required: true, message: "请输入回复内容", trigger: "blur" }]
};
watch(
() => props.modelValue,
(val) => {
dialogVisible.value = val;
}
);
watch(dialogVisible, (val) => {
emit("update:modelValue", val);
if (!val) {
resetForm();
}
});
const onEmojiClick = (emoji) => {
form.value.content += emoji;
};
const handleClose = () => {
dialogVisible.value = false;
};
const handleSubmit = async () => {
if (!formRef.value) return;
await formRef.value.validate(async (valid) => {
if (valid) {
submitting.value = true;
try {
const ltmasg = getItem("ltmasg");
const promes = {
hfnr: form.value.content,
hfrsfzh: ltmasg.sfzh,
hfrtx: ltmasg.tx,
hfrxm: ltmasg.xm,
ltid: props.replyTo.id,
sfyjhf: "1"
// hftp: hfrsfzh.value.hftp ? hfrsfzh.value.hftp.join(',') : ''
};
// 这里替换为实际的API调用
await tbGsxtXxltHfSave(promes);
// await new Promise(resolve => setTimeout(resolve, 1000))
ElMessage.success("回复成功");
emit("success", form.value);
handleClose();
} catch (error) {
console.log(error);
ElMessage.error("回复失败");
} finally {
submitting.value = false;
}
}
});
};
const resetForm = () => {
form.value = {
content: ""
};
formRef.value?.resetFields();
};
</script>
<style scoped lang="scss">
.emoji-row {
display: flex;
align-items: center;
gap: 12px;
}
</style>
<style lang="scss">
@import '../styles/luntan-dialog-tech.scss';
</style>

View File

@ -0,0 +1,346 @@
<template>
<div class="user-card">
<div class="user-card-head">
<div class="user-avatar">
<div class="avatar-wrapper" @click="showAvatarDialog = true">
<el-avatar :size="56" :src="avatarUrl">
<img src="@/assets/images/mr.png" />
</el-avatar>
<div class="avatar-overlay">
<el-icon class="upload-icon">
<Camera />
</el-icon>
</div>
</div>
</div>
<div class="user-card-head-text">
<div class="name-row">
<span class="nickname">{{ userInfo.nickname || '用户信息' }}</span>
</div>
<div class="sub-stats">内部论坛 · 已登录</div>
</div>
</div>
<div class="user-info">
<div class="info-item clickable" @click="showNicknameDialog = true">
<span class="label">昵称</span>
<span class="value">{{ userInfo.nickname || '-' }}</span>
<el-icon class="edit-icon">
<Edit />
</el-icon>
</div>
<div class="info-item">
<span class="label">账号</span>
<span class="value">{{ userInfo.account || '-' }}</span>
</div>
<div class="info-item">
<span class="label">姓名</span>
<span class="value">{{ userInfo.name || '-' }}</span>
</div>
<div class="info-item">
<span class="label">部门</span>
<span class="value">{{ userInfo.department || '-' }}</span>
</div>
</div>
</div>
<!-- 更换头像对话框 -->
<ChangeAvatar v-model="showAvatarDialog" title="更换头像" @avatarUpdated="handleAvatarUpdated" />
<!-- 编辑昵称对话框 -->
<el-dialog
v-model="showNicknameDialog"
class="luntan-tech-dialog"
title="编辑昵称"
width="400px"
center
:close-on-click-modal="false"
>
<el-form ref="nicknameFormRef" :model="nicknameForm" :rules="nicknameRules" label-width="80px">
<el-form-item label="昵称" prop="nickname">
<el-input v-model="nicknameForm.nickname" placeholder="请输入昵称" maxlength="20" show-word-limit />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showNicknameDialog = false">取消</el-button>
<el-button type="primary" @click="handleSaveNickname">保存</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Camera, Edit } from '@element-plus/icons-vue'
import { getItem, setItem, removeItem } from '@/utils/storage.js'
import { setAddress } from '@/utils/tools'
import { tbGsxtXxltTxTxQueryBySfzh, tbGsxtXxltTxTxSave } from '@/api/tbGsxtXxltHf.js'
import ChangeAvatar from './ChangeAvatar.vue'
const showAvatarDialog = ref(false)
const showNicknameDialog = ref(false)
const nicknameFormRef = ref()
const userInfo = ref({
avatar: '',
account: '',
name: '',
department: '',
nickname: ''
})
const nicknameForm = reactive({
nickname: ''
})
const nicknameRules = {
nickname: [
{ required: true, message: '请输入昵称', trigger: 'blur' },
{ min: 2, max: 20, message: '昵称长度在 2 到 20 个字符', trigger: 'blur' }
]
}
const avatarUrl = computed(() => {
return userInfo.value.avatar ? setAddress(userInfo.value.avatar) : ''
})
// 加载用户信息
const loadUserInfo = async () => {
const sfzh = getItem('idEntityCard')
let ltmasg = getItem('ltmasg')
if (!ltmasg) {
try {
const res = await tbGsxtXxltTxTxQueryBySfzh({ sfzh: sfzh })
console.log(res);
const deptId = getItem('deptId')?.[0]
ltmasg = {
...res,
deptName: deptId?.deptName || ''
}
setItem('ltmasg', ltmasg)
} catch (error) {
console.error('加载用户信息失败:', error)
}
}
if (ltmasg) {
userInfo.value = {
avatar: ltmasg.tx || '',
account: ltmasg.sfzh || '',
name: ltmasg.xm || '',
department: ltmasg.deptName || ltmasg.bm || '',
nickname: ltmasg.nc || ''
}
}
}
// 处理头像更新
const handleAvatarUpdated = async (newAvatar) => {
try {
const ltmasg = getItem('ltmasg')
const updateData = {
...ltmasg,
tx: newAvatar
}
await tbGsxtXxltTxTxSave(updateData)
removeItem('ltmasg')
await loadUserInfo()
ElMessage.success('头像更新成功')
} catch (error) {
console.error('更新头像失败:', error)
ElMessage.error('头像更新失败,请重试')
}
}
// 处理保存昵称
const handleSaveNickname = async () => {
if (!nicknameFormRef.value) return
await nicknameFormRef.value.validate(async (valid) => {
if (valid) {
try {
const ltmasg = getItem('ltmasg')
const updateData = {
...ltmasg,
nc: nicknameForm.nickname
}
await tbGsxtXxltTxTxSave(updateData)
removeItem('ltmasg')
await loadUserInfo()
showNicknameDialog.value = false
ElMessage.success('昵称保存成功')
} catch (error) {
console.error('保存昵称失败:', error)
ElMessage.error('昵称保存失败,请重试')
}
}
})
}
// 监听昵称对话框打开,初始化表单
const openNicknameDialog = () => {
nicknameForm.nickname = userInfo.value.nickname
}
// 监听对话框显示状态
const unwatchNickname = () => {
if (showNicknameDialog.value) {
openNicknameDialog()
}
}
onMounted(() => {
loadUserInfo()
})
// 监听昵称对话框
const stopWatch = () => {
if (showNicknameDialog.value) {
nicknameForm.nickname = userInfo.value.nickname
}
}
// 使用 watch 监听对话框状态
import { watch } from 'vue'
watch(showNicknameDialog, (newVal) => {
if (newVal) {
nicknameForm.nickname = userInfo.value.nickname
}
})
</script>
<style scoped lang="scss">
@import '../styles/luntan-tech.scss';
.user-card {
border-radius: 4px;
padding: 18px 16px 16px;
@include lt-panel-frame;
}
.user-card-head {
display: flex;
align-items: flex-start;
gap: 14px;
margin-bottom: 16px;
padding-bottom: 14px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.user-avatar {
flex-shrink: 0;
.avatar-wrapper {
position: relative;
cursor: pointer;
border-radius: 50%;
overflow: hidden;
border: 2px solid rgba(100, 180, 255, 0.35);
&:hover .avatar-overlay {
opacity: 1;
}
.avatar-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.55);
display: flex;
justify-content: center;
align-items: center;
opacity: 0;
transition: opacity 0.3s ease;
border-radius: 50%;
.upload-icon {
font-size: 22px;
color: white;
}
}
}
}
.user-card-head-text {
flex: 1;
min-width: 0;
}
.name-row {
margin-bottom: 6px;
}
.nickname {
font-size: 16px;
font-weight: 600;
color: #f0f6ff;
word-break: break-all;
}
.sub-stats {
font-size: 12px;
color: rgba(180, 200, 230, 0.55);
line-height: 1.4;
}
.user-info {
.info-item {
display: flex;
align-items: flex-start;
margin-bottom: 10px;
font-size: 13px;
position: relative;
&:last-child {
margin-bottom: 0;
}
&.clickable {
cursor: pointer;
padding: 6px 8px;
margin-left: -8px;
margin-right: -8px;
border-radius: 6px;
transition: background-color 0.2s ease;
&:hover {
background-color: rgba(255, 255, 255, 0.06);
.edit-icon {
opacity: 1;
}
}
}
.label {
color: rgba(160, 185, 215, 0.55);
min-width: 40px;
flex-shrink: 0;
}
.value {
color: rgba(220, 230, 245, 0.88);
flex: 1;
word-break: break-all;
}
.edit-icon {
margin-left: 6px;
color: #5eb8ff;
font-size: 14px;
opacity: 0;
transition: opacity 0.2s ease;
}
}
}
</style>
<style lang="scss">
@import '../styles/luntan-dialog-tech.scss';
</style>

View File

@ -0,0 +1,333 @@
<template>
<div class="luntan-container">
<!-- 列表页主列帖子 + 右侧信息栏 -->
<template v-if="!showDetail">
<div class="luntan-main">
<PostList @openDetail="handleOpenDetail" />
</div>
<aside class="luntan-sidebar">
<UserCard />
<div class="hot-news-card" v-loading="hotLoading">
<div class="hot-news-head">
<span class="hot-news-title">热度消息</span>
<span class="hot-news-badge">HOT</span>
</div>
<ul class="hot-news-list">
<li
v-for="(item, index) in hotList"
:key="item.id"
class="hot-news-item"
@click="handleOpenDetail(item)"
>
<span class="hot-rank" :class="{ 'is-top': index < 3 }">{{
index + 1
}}</span>
<span class="hot-item-title">{{ item.lineTitle }}</span>
</li>
<li
v-if="!hotLoading && hotList.length === 0"
class="hot-news-empty"
>
暂无热度内容
</li>
</ul>
</div>
</aside>
</template>
<!-- 详情页 -->
<template v-else>
<div class="luntan-detail-wrap">
<div class="luntan-detail">
<PostDetail :post-id="currentPostId" @back="handleBack" />
</div>
</div>
</template>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from "vue";
import UserCard from "./components/UserCard.vue";
import PostList from "./components/PostList.vue";
import PostDetail from "./components/PostDetail.vue";
import { tbGsxtXxltSelectPage } from "@/api/tbGsxtXxltHf";
import { setAddress } from "@/utils/tools";
const showDetail = ref(false);
const currentPostId = ref(null);
const hotList = ref([]);
const hotLoading = ref(false);
function lineTitleFromRecord(item) {
const tit = (item.title || "").trim();
if (tit) {
return tit.length > 40 ? `${tit.slice(0, 40)}` : tit;
}
const t = (item.content || "").trim();
if (!t) return "无标题";
const idx = t.indexOf("\n");
const first = idx === -1 ? t : t.slice(0, idx).trim();
const line = first || t.slice(0, 60);
return line.length > 40 ? `${line.slice(0, 40)}` : line;
}
function mapHotPost(item) {
return {
id: item.id,
lineTitle: lineTitleFromRecord(item),
userName: item.fbrxm || "匿名用户",
userAvatar: item.fbrtx ? setAddress(item.fbrtx) : "",
userTag: item.userTag || "",
publishTime: item.time || "",
content: item.content || "",
images: item.tp ? item.tp.split(",").map((img) => setAddress(img)) : [],
commentCount: item.hfsl || 0,
likeCount: item.likeCount || 0,
isPremium: item.sfzd === 1,
isLiked: false,
ssbm: item.ssbm,
rawData: item
};
}
const loadHotNews = async () => {
hotLoading.value = true;
try {
const res = await tbGsxtXxltSelectPage({
pageCurrent: 1,
pageSize: 12
});
const records = res.records || [];
hotList.value = records.map(mapHotPost);
} catch (e) {
console.error(e);
hotList.value = [];
} finally {
hotLoading.value = false;
}
};
onMounted(() => {
loadHotNews();
});
const handleOpenDetail = (post) => {
currentPostId.value = post.id;
showDetail.value = true;
};
const handleBack = () => {
showDetail.value = false;
currentPostId.value = null;
};
</script>
<style scoped lang="scss">
@import "./styles/luntan-tech.scss";
.luntan-container {
position: relative;
display: flex;
gap: 24px;
padding: 20px 24px;
min-height: calc(100vh - 60px);
max-height: calc(100vh - 60px);
overflow: hidden;
background: linear-gradient(165deg, $lt-bg 0%, $lt-bg-soft 42%, #0a1830 100%);
box-sizing: border-box;
&::before {
content: "";
position: absolute;
inset: 0;
background-image: linear-gradient(
rgba(0, 163, 255, 0.04) 1px,
transparent 1px
),
linear-gradient(90deg, rgba(0, 163, 255, 0.04) 1px, transparent 1px);
background-size: 32px 32px;
pointer-events: none;
z-index: 0;
}
}
.luntan-main {
position: relative;
z-index: 1;
flex: 1;
min-width: 0;
overflow-y: auto;
overflow-x: hidden;
}
.luntan-sidebar {
position: relative;
z-index: 1;
width: 300px;
flex-shrink: 0;
height: calc(100vh - 100px);
display: flex;
flex-direction: column;
gap: 16px;
overflow: hidden;
}
.hot-news-card {
position: relative;
// flex: 1;
height: calc(80% - 100px);
min-height: 0;
display: flex;
flex-direction: column;
padding: 0;
border-radius: 6px;
@include lt-panel-frame;
background: linear-gradient(
165deg,
rgba(14, 40, 82, 0.9) 0%,
rgba(10, 28, 58, 0.88) 100%
) !important;
overflow: hidden;
:deep(.el-loading-mask) {
background-color: rgba(4, 12, 28, 0.55);
}
:deep(.el-loading-spinner .path) {
stroke: $lt-cyan-mid;
}
}
.hot-news-head {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 14px 16px 12px;
border-bottom: 1px solid rgba(0, 163, 255, 0.22);
background: linear-gradient(
90deg,
rgba(0, 163, 255, 0.1) 0%,
transparent 70%
);
}
.hot-news-title {
font-size: 16px;
font-weight: 700;
color: $lt-text;
letter-spacing: 0.08em;
text-shadow: 0 0 14px rgba(0, 227, 255, 0.35);
}
.hot-news-badge {
flex-shrink: 0;
padding: 3px 10px;
font-size: 10px;
font-weight: 800;
letter-spacing: 0.12em;
color: #1a0a00;
border-radius: 4px;
background: linear-gradient(180deg, #ffb347 0%, #ff9900 100%);
border: 1px solid rgba(255, 200, 120, 0.5);
box-shadow: 0 0 12px rgba(255, 153, 0, 0.35);
}
.hot-news-list {
flex: 1;
min-height: 0;
margin: 0;
padding: 8px 10px 12px;
list-style: none;
overflow-y: auto;
}
.hot-news-item {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 8px;
border-radius: 4px;
cursor: pointer;
transition: background 0.15s ease, box-shadow 0.15s ease;
& + .hot-news-item {
border-top: 1px solid rgba(0, 163, 255, 0.1);
}
&:hover {
background: rgba(0, 163, 255, 0.1);
box-shadow: inset 0 0 0 1px rgba(0, 227, 255, 0.12);
}
}
.hot-rank {
flex-shrink: 0;
width: 22px;
height: 22px;
line-height: 22px;
text-align: center;
font-size: 12px;
font-weight: 700;
color: rgba(210, 225, 245, 0.92);
border-radius: 4px;
background: rgba(0, 40, 80, 0.45);
border: 1px solid rgba(0, 163, 255, 0.2);
&.is-top {
color: #1a0a00;
background: linear-gradient(180deg, #ffd699 0%, #ff9900 100%);
border-color: rgba(255, 180, 80, 0.55);
box-shadow: 0 0 8px rgba(255, 153, 0, 0.25);
}
}
.hot-item-title {
flex: 1;
min-width: 0;
font-size: 13px;
line-height: 1.5;
color: rgba(236, 245, 255, 0.94);
font-weight: 500;
}
.hot-news-item:hover .hot-item-title {
color: #ffffff;
}
.hot-news-empty {
padding: 28px 12px;
text-align: center;
font-size: 13px;
color: rgba(200, 218, 240, 0.82);
list-style: none;
border: none;
}
.luntan-detail-wrap {
position: relative;
z-index: 1;
flex: 1;
min-width: 0;
display: flex;
min-height: 0;
}
.luntan-detail {
height: 83vh;
flex: 1;
border-radius: 4px;
overflow-y: auto;
overflow-x: hidden;
@include lt-panel-frame;
box-shadow: $lt-glow-strong, 0 12px 40px rgba(0, 0, 0, 0.45);
}
</style>
<!-- scopedel-image-viewer 通过 Teleport 挂到 body需全局选择器 -->
<style lang="scss">
@import "./styles/luntan-tech.scss";
</style>

View File

@ -0,0 +1,104 @@
@import "./luntan-tech.scss";
@import "./luntan-v3emoji-tech.scss";
.luntan-tech-dialog.el-dialog {
background: rgba(8, 20, 48, 0.98) !important;
border: 1px solid $lt-border;
border-radius: 4px;
box-shadow: $lt-glow-strong, 0 16px 48px rgba(0, 0, 0, 0.55);
}
.luntan-tech-dialog .el-dialog__header {
border-bottom: 1px solid $lt-border-dim;
padding: 14px 18px;
margin: 0;
background: rgba(8, 20, 48, 0.98) !important;
}
.luntan-tech-dialog .el-dialog__title {
color: $lt-text;
font-weight: 600;
letter-spacing: 0.06em;
}
.luntan-tech-dialog .el-dialog__headerbtn .el-dialog__close {
color: $lt-text-muted;
}
.luntan-tech-dialog .el-dialog__headerbtn:hover .el-dialog__close {
color: $lt-cyan;
}
.luntan-tech-dialog .el-dialog__body {
padding: 18px;
}
.luntan-tech-dialog .el-dialog__footer {
border-top: 1px solid $lt-border-dim;
padding: 12px 18px;
background: rgba(8, 20, 48, 0.98) !important;
}
.luntan-tech-dialog .el-form-item__label {
color: $lt-text-muted;
}
.luntan-tech-dialog .el-input__wrapper {
background: rgba(10, 28, 58, 0.85) !important;
box-shadow: 0 0 0 1px rgba(0, 227, 255, 0.28) inset !important;
border-radius: 4px;
&:hover {
box-shadow: 0 0 0 1px rgba(0, 227, 255, 0.4) inset !important;
}
&.is-focus {
box-shadow: 0 0 0 1px rgba(0, 227, 255, 0.55) inset,
0 0 14px rgba(0, 163, 255, 0.22) !important;
}
}
.luntan-tech-dialog .el-input__inner {
color: $lt-text-dim;
}
.luntan-tech-dialog .el-input__count,
.luntan-tech-dialog .el-input__count .el-input__count-inner {
background: transparent !important;
color: $lt-text-muted;
}
.luntan-tech-dialog .el-textarea__inner {
background: rgba(10, 28, 58, 0.9) !important;
color: $lt-text-dim;
border: 1px solid rgba(0, 227, 255, 0.3);
border-radius: 4px;
box-shadow: inset 0 0 20px rgba(20, 80, 140, 0.2);
}
.luntan-tech-dialog .el-button--default {
background: rgba(15, 40, 75, 0.65);
border-color: rgba(0, 163, 255, 0.35);
color: $lt-text-dim;
}
.luntan-tech-dialog .el-button--primary {
background: linear-gradient(180deg, #00a3ff 0%, #0066bb 100%);
border-color: rgba(0, 227, 255, 0.5);
box-shadow: 0 0 16px rgba(0, 163, 255, 0.4);
}
.luntan-tech-dialog .el-upload--picture-card {
background: rgba(10, 28, 58, 0.75) !important;
border-color: rgba(0, 227, 255, 0.35) !important;
.el-icon {
color: $lt-cyan;
font-size: 22px;
}
&:hover {
border-color: rgba(0, 227, 255, 0.55) !important;
box-shadow: 0 0 14px rgba(0, 163, 255, 0.25);
}
}

View File

@ -0,0 +1,103 @@
// 论坛模块 — 大屏科技风主题变量
$lt-bg: #0a1628;
$lt-bg-soft: #0d1f3c;
$lt-cyan: #00e5ff;
$lt-cyan-mid: #00a3ff;
$lt-panel: rgba(10, 30, 60, 0.85);
$lt-panel-soft: rgba(8, 24, 52, 0.65);
$lt-border: rgba(0, 227, 255, 0.38);
$lt-border-dim: rgba(0, 163, 255, 0.2);
$lt-text: #e8f4ff;
$lt-text-dim: rgba(140, 200, 230, 0.78);
$lt-text-muted: rgba(120, 170, 210, 0.55);
$lt-glow: 0 0 18px rgba(0, 163, 255, 0.35);
$lt-glow-strong: 0 0 28px rgba(0, 227, 255, 0.28);
@mixin lt-panel-frame {
background: $lt-panel;
border: 1px solid $lt-border;
box-shadow: $lt-glow, inset 0 1px 0 rgba(0, 220, 255, 0.12);
backdrop-filter: blur(8px);
}
@mixin lt-panel-soft-bg {
background: $lt-panel-soft;
border: 1px solid $lt-border-dim;
box-shadow: inset 0 1px 0 rgba(0, 220, 255, 0.08);
}
@mixin lt-corner-brackets {
position: relative;
&::before,
&::after {
content: "";
position: absolute;
width: 12px;
height: 12px;
border-color: $lt-cyan-mid;
border-style: solid;
pointer-events: none;
opacity: 0.85;
}
&::before {
top: -1px;
left: -1px;
border-width: 2px 0 0 2px;
box-shadow: -1px -1px 8px rgba(0, 227, 255, 0.4);
}
&::after {
bottom: -1px;
right: -1px;
border-width: 0 2px 2px 0;
box-shadow: 1px 1px 8px rgba(0, 227, 255, 0.4);
}
}
// el-image-viewer 全屏预览覆盖 — Teleport 到 body全局生效
.el-image-viewer__wrapper {
background: rgba(4, 10, 24, 0.95) !important;
backdrop-filter: blur(4px);
.el-image-viewer__mask {
background: rgba(0, 10, 30, 0.78) !important;
}
.el-image-viewer__btn {
color: $lt-cyan;
border: 1px solid rgba(0, 227, 255, 0.3);
background: rgba(10, 28, 58, 0.65);
box-shadow: 0 0 10px rgba(0, 120, 200, 0.2);
&:hover {
background: rgba(15, 45, 80, 0.88);
border-color: rgba(0, 227, 255, 0.55);
}
&.el-image-viewer__close {
top: 24px;
right: 28px;
}
&.el-image-viewer__prev {
left: 24px;
top: 50%;
transform: translateY(-50%);
}
&.el-image-viewer__next {
right: 24px;
top: 50%;
transform: translateY(-50%);
}
}
.el-image-viewer__canvas {
display: flex;
align-items: center;
justify-content: center;
}
.el-icon {
font-size: 20px;
}
}

View File

@ -0,0 +1,64 @@
@import './luntan-tech.scss';
// vue3-emojidist/style.css默认浅色/白底,与论坛科技风统一
@mixin lt-v3-emoji-vars {
--V3Emoji-backgroundColor: rgba(10, 28, 58, 0.96);
--V3Emoji-hoverColor: rgba(0, 100, 160, 0.22);
--V3Emoji-activeColor: rgba(0, 130, 200, 0.3);
--V3Emoji-fontColor: #{$lt-text};
--V3Emoji-borderColor: #{rgba(0, 227, 255, 0.38)};
--V3Emoji-borderFocusColor: #{$lt-cyan};
--V3Emoji-shadowColor: rgba(0, 40, 90, 0.45);
}
.luntan-tech-dialog,
.comment-list .reply-input-box,
.luntan-tech-dialog .emoji-row {
@include lt-v3-emoji-vars;
}
// 组件内未使用 CSS 变量的原生 input / textarea仍为白底
.luntan-tech-dialog [class*='emojiInput'] input,
.comment-list .reply-input-box [class*='emojiInput'] input {
background: rgba(10, 28, 58, 0.92) !important;
color: $lt-text-dim !important;
border-color: rgba(0, 227, 255, 0.35) !important;
box-shadow: inset 0 0 12px rgba(0, 50, 100, 0.25) !important;
&::placeholder {
color: $lt-text-muted;
}
}
.luntan-tech-dialog [class*='emojiTextarea'] textarea,
.comment-list .reply-input-box [class*='emojiTextarea'] textarea {
background: rgba(10, 28, 58, 0.92) !important;
color: $lt-text-dim !important;
border-color: rgba(0, 227, 255, 0.35) !important;
box-shadow: inset 0 0 12px rgba(0, 50, 100, 0.25) !important;
}
.luntan-tech-dialog [class*='emojiContainerOpenBtn'],
.luntan-tech-dialog [class*='emojiTextareaOpenBtn'],
.comment-list .reply-input-box [class*='emojiContainerOpenBtn'],
.comment-list .reply-input-box [class*='emojiTextareaOpenBtn'] {
color: $lt-cyan !important;
filter: drop-shadow(0 0 6px rgba(0, 227, 255, 0.35));
}
// 表情面板可能 teleport / 挂到 body需单独写选择器
[class*='V3Emoji-vue'][class*='__pollup___'],
[class*='PollUp-vue'][class*='__pollup___'] {
@include lt-v3-emoji-vars;
}
[id='EmojiItem'],
[id*='EmojiItem'] {
::-webkit-scrollbar-thumb {
background-color: rgba(0, 163, 255, 0.45) !important;
}
::-webkit-scrollbar-track {
background: rgba(8, 20, 48, 0.88) !important;
}
}