This commit is contained in:
aaron 2026-05-16 23:59:13 +08:00
parent f105cb369c
commit 02c35c146d
27 changed files with 1527 additions and 295 deletions

View File

@ -55,6 +55,30 @@ async def get_announcements(
)
@router.get("/{announcement_id}", response_model=AnnouncementOut)
async def get_announcement_detail(
announcement_id: int,
user: User = Depends(require_role("super_admin", "teacher", "student")),
db: AsyncSession = Depends(get_db),
):
announcement = await get_announcement_by_id(db, announcement_id)
if announcement is None:
raise HTTPException(status_code=404, detail="Announcement not found")
ensure_class_access(user, announcement.class_id)
await ensure_class_module_enabled(db, announcement.class_id, "announcements")
return AnnouncementOut(
id=announcement.id,
class_id=announcement.class_id,
author_id=announcement.author_id,
author_name=announcement.author.name if announcement.author else "Unknown",
title=announcement.title,
content=announcement.content,
is_pinned=announcement.is_pinned,
created_at=announcement.created_at,
updated_at=announcement.updated_at,
)
@router.post("/", response_model=AnnouncementOut)
async def create_new_announcement(
data: AnnouncementCreate,

View File

@ -8,6 +8,7 @@
"pages/module/index",
"pages/manage/index",
"pages/member-detail/index",
"pages/announcement-detail/index",
"pages/schedule-detail/index",
"pages/vote-detail/index",
"pages/fund-detail/index",
@ -42,7 +43,7 @@
},
{
"pagePath": "pages/interact/index",
"text": "互动",
"text": "班级圈",
"iconPath": "assets/tabbar/interact.png",
"selectedIconPath": "assets/tabbar/interact-active.png"
},

View File

@ -462,6 +462,41 @@ page {
color: #7a4b2b;
}
.member-role-badge.committee {
background: #d6a653;
color: #3a221d;
}
.schedule-side {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 10rpx;
flex: none;
}
.schedule-countdown {
min-height: 40rpx;
padding: 0 16rpx;
border-radius: 999rpx;
background: #e7efff;
color: #53698e;
font-size: 22rpx;
font-weight: 760;
line-height: 40rpx;
white-space: nowrap;
}
.schedule-countdown.urgent {
background: #fff0d6;
color: #8b5a14;
}
.schedule-countdown.overdue {
background: #fde4df;
color: #b42318;
}
.vote-meta {
display: flex;
flex-wrap: wrap;

View File

@ -0,0 +1,45 @@
const { get } = require("../../utils/api");
const { showError } = require("../../utils/page-helpers");
function formatDateTime(value) {
if (!value) return "";
return String(value).replace("T", " ").slice(0, 16);
}
Page({
data: {
id: null,
item: null,
loading: false
},
onLoad(options) {
wx.setNavigationBarTitle({ title: "公告详情" });
this.setData({ id: options.id || null });
this.load(options.id);
},
async onPullDownRefresh() {
await this.load(this.data.id);
wx.stopPullDownRefresh();
},
async load(id) {
if (!id) return;
this.setData({ loading: true });
try {
const item = await get(`/api/announcements/${id}`);
this.setData({
item: {
...item,
created_at_text: formatDateTime(item.created_at),
updated_at_text: formatDateTime(item.updated_at)
}
});
} catch (error) {
showError(error, "加载公告失败");
} finally {
this.setData({ loading: false });
}
}
});

View File

@ -0,0 +1,3 @@
{
"usingComponents": {}
}

View File

@ -0,0 +1,22 @@
<view class="page" wx:if="{{item}}">
<view class="hero">
<view class="eyebrow">ANNOUNCEMENT</view>
<view class="hero-title">{{item.title}}</view>
<view class="hero-subtitle">{{item.author_name}} · {{item.created_at_text}}</view>
<view wx:if="{{item.is_pinned}}" class="announcement-pin">置顶公告</view>
</view>
<view class="announcement-article">
<view class="announcement-content">{{item.content || "暂无内容"}}</view>
<view class="announcement-meta">
<view>发布 {{item.created_at_text}}</view>
<view wx:if="{{item.updated_at_text}}">更新 {{item.updated_at_text}}</view>
</view>
</view>
</view>
<view class="page" wx:elif="{{!loading}}">
<view class="empty">
<view class="muted">未找到公告</view>
</view>
</view>

View File

@ -0,0 +1,40 @@
.announcement-pin {
position: relative;
display: inline-flex;
align-items: center;
min-height: 48rpx;
margin-top: 28rpx;
padding: 0 22rpx;
border: 1rpx solid rgba(255, 248, 237, 0.18);
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 248, 237, 0.9);
font-size: 24rpx;
font-weight: 650;
}
.announcement-article {
margin-top: 30rpx;
padding: 34rpx;
border-radius: 30rpx;
background: rgba(255, 252, 247, 0.94);
box-shadow: 0 16rpx 42rpx rgba(68, 39, 27, 0.07);
}
.announcement-content {
color: #4f3930;
font-size: 28rpx;
line-height: 1.7;
white-space: pre-wrap;
}
.announcement-meta {
display: flex;
flex-wrap: wrap;
gap: 10rpx 22rpx;
margin-top: 30rpx;
padding-top: 22rpx;
border-top: 1rpx solid rgba(121, 84, 54, 0.1);
color: #8a7b70;
font-size: 23rpx;
}

View File

@ -27,6 +27,18 @@ function scheduleTypeText(type) {
}[type] || type || "排期";
}
function countdownText(value) {
if (!value) return "";
const target = new Date(value).getTime();
if (Number.isNaN(target)) return "";
const now = Date.now();
const diffDays = Math.ceil((target - now) / (24 * 60 * 60 * 1000));
if (diffDays < 0) return `已逾期 ${Math.abs(diffDays)}`;
if (diffDays === 0) return "今天截止";
if (diffDays === 1) return "明天截止";
return `还有 ${diffDays}`;
}
Page({
data: {
className: "HKU ICB",
@ -77,10 +89,6 @@ Page({
names.push("votes");
tasks.push(get("/api/votes/", { page_size: 3, class_id: classId }));
}
if (isModuleEnabled("timeline", enabledModules)) {
names.push("timelines");
tasks.push(get("/api/timeline/", { page_size: 3, class_id: classId }));
}
const results = await Promise.all(tasks);
const next = { announcements: [], schedules: [], votes: [], timelines: [] };
@ -92,7 +100,8 @@ Page({
next.schedules = (value || []).map((item) => ({
...item,
schedule_time_text: formatScheduleTime(item),
schedule_type_text: scheduleTypeText(item.type)
schedule_type_text: scheduleTypeText(item.type),
countdown_text: item.type === "deadline" ? countdownText(item.start_time) : ""
}));
}
if (name === "votes") {
@ -115,7 +124,7 @@ Page({
label: "下一项排期",
title: schedule.title,
detail: schedule.schedule_time_text,
badge: schedule.schedule_type_text
badge: schedule.countdown_text || schedule.schedule_type_text
});
}
if (pendingVotes.length) {
@ -170,6 +179,10 @@ Page({
wx.navigateTo({ url: `/pages/vote-detail/index?id=${id}` });
return;
}
if (type === "announcements") {
wx.navigateTo({ url: `/pages/announcement-detail/index?id=${id}` });
return;
}
wx.navigateTo({ url: `/pages/module/index?module=${type}` });
},

View File

@ -34,23 +34,7 @@
</view>
</view>
<view wx:if="{{timelines.length}}" class="section">
<view class="section-head">
<view class="section-title">班级动态</view>
<view class="section-action" bindtap="openModule" data-key="timeline">浏览</view>
</view>
<view wx:for="{{timelines}}" wx:key="id" class="card" bindtap="openTimeline" data-id="{{item.id}}">
<view class="list-row">
<view class="row-mark">动</view>
<view class="row-body">
<view class="card-title">{{item.title}}</view>
<view class="muted">{{item.author_name}}</view>
</view>
</view>
</view>
</view>
<view wx:if="{{!loading && !focusItems.length && !quickModules.length && !timelines.length}}" class="empty">
<view wx:if="{{!loading && !focusItems.length && !quickModules.length}}" class="empty">
<view class="muted">暂无可展示内容</view>
</view>
</view>

View File

@ -1,16 +1,279 @@
const { requireLogin } = require("../../utils/auth");
const { visibleModules } = require("../../utils/modules");
const { getEnabledModules } = require("../../utils/page-helpers");
const { del, get, post } = require("../../utils/api");
const { getEnabledModules, getActiveClassId, showError } = require("../../utils/page-helpers");
const { isModuleEnabled } = require("../../utils/modules");
function initialOf(name) {
return String(name || "班").slice(0, 1);
}
function formatTime(value) {
return String(value || "").replace("T", " ").slice(0, 16);
}
function sameId(left, right) {
return Number(left) === Number(right);
}
function datasetBoolean(value) {
return value === true || value === "true" || value === 1 || value === "1";
}
function normalizeTimeline(item, comments = [], currentUserId = null) {
return {
...item,
author_initial: initialOf(item.author_name),
created_at_text: formatTime(item.created_at),
like_action_text: item.has_liked ? "已赞" : "赞",
like_action_class: item.has_liked ? "active" : "",
comments: comments.map((comment) => ({
...comment,
created_at_text: formatTime(comment.created_at),
can_delete: sameId(currentUserId, comment.author_id)
}))
};
}
Page({
data: { modules: [] },
data: {
timelines: [],
timelineEnabled: false,
commentPostId: null,
commentText: "",
replyToName: "",
commentPlaceholder: "写评论",
commentInputFocus: false,
keyboardOpen: false,
loading: false,
likingId: null,
commenting: false
},
onShow() {
if (!requireLogin()) return;
this.setData({ modules: visibleModules("interact", getEnabledModules()) });
this.load();
},
openModule(event) {
wx.navigateTo({ url: `/pages/module/index?module=${event.currentTarget.dataset.key}` });
async load() {
const enabledModules = getEnabledModules();
const classId = getActiveClassId();
const timelineEnabled = isModuleEnabled("timeline", enabledModules);
if (!timelineEnabled) {
this.setData({ timelines: [], timelineEnabled: false });
return;
}
this.setData({ loading: true });
try {
const currentUser = getApp().globalData.user || wx.getStorageSync("auth_user") || {};
const res = await get("/api/timeline/", { page_size: 20, class_id: classId });
const commentResults = await Promise.all(
(res.items || []).map((item) =>
get(`/api/timeline/${item.id}/comments`, { page_size: 6 }).catch(() => ({ items: [] }))
)
);
const timelines = (res.items || []).map((item, index) => (
normalizeTimeline(item, commentResults[index].items || [], currentUser.id)
));
this.setData({ timelines, timelineEnabled: true });
} catch (error) {
showError(error);
} finally {
this.setData({ loading: false });
}
},
openTimeline(event) {
wx.navigateTo({ url: `/pages/timeline-detail/index?id=${event.currentTarget.dataset.id}` });
},
openCompose() {
wx.navigateTo({ url: "/pages/timeline-create/index" });
},
previewImage(event) {
const current = event.currentTarget.dataset.src;
const postId = Number(event.currentTarget.dataset.postId);
const postItem = this.data.timelines.find((item) => item.id === postId);
const urls = postItem && postItem.image_urls ? postItem.image_urls : [];
if (!current || !urls.length) return;
wx.previewImage({ current, urls });
},
async toggleLike(event) {
const id = Number(event.currentTarget.dataset.id);
if (!id || this.data.likingId) return;
this.setData({ likingId: id });
try {
const result = await post(`/api/timeline/${id}/like`);
const timelines = this.data.timelines.map((item) => {
if (item.id !== id) return item;
return normalizeTimeline({
...item,
has_liked: Boolean(result.liked),
like_count: Number(result.like_count || 0)
}, item.comments || [], (getApp().globalData.user || wx.getStorageSync("auth_user") || {}).id);
});
this.setData({ timelines });
} catch (error) {
showError(error, "操作失败");
} finally {
this.setData({ likingId: null });
}
},
openComment(event) {
const id = Number(event.currentTarget.dataset.id);
const postItem = this.data.timelines.find((item) => item.id === id);
this.setData({
commentPostId: id,
commentText: "",
replyToName: "",
commentPlaceholder: postItem ? `评论 ${postItem.author_name}` : "写评论",
commentInputFocus: false
}, () => {
this.focusCommentInput();
});
},
replyComment(event) {
const postId = Number(event.currentTarget.dataset.postId);
const commentId = Number(event.currentTarget.dataset.commentId);
const name = event.currentTarget.dataset.name || "";
const canDelete = datasetBoolean(event.currentTarget.dataset.canDelete);
if (canDelete && commentId) {
this.openOwnCommentActions(postId, commentId, name);
return;
}
this.startReply(postId, name);
},
startReply(postId, name) {
this.setData({
commentPostId: postId,
commentText: "",
replyToName: name,
commentPlaceholder: name ? `回复 ${name}` : "写评论",
commentInputFocus: false
}, () => {
this.focusCommentInput();
});
},
closeComment() {
if (this.data.commenting) return;
this.setData({
commentPostId: null,
commentText: "",
replyToName: "",
commentInputFocus: false,
keyboardOpen: false
});
},
noop() {},
onCommentInput(event) {
this.setData({ commentText: event.detail.value });
},
onCommentFocus(event) {
const height = event.detail.height || 0;
this.setData({
commentInputFocus: true,
keyboardOpen: height > 0
});
},
onKeyboardHeightChange(event) {
const height = event.detail.height || 0;
this.setData({ keyboardOpen: height > 0 });
},
onCommentBlur() {
this.setData({ commentInputFocus: false, keyboardOpen: false });
},
focusCommentInput() {
const focus = () => {
if (this.data.commentPostId) {
this.setData({ commentInputFocus: true });
}
};
if (wx.nextTick) {
wx.nextTick(() => setTimeout(focus, 120));
return;
}
setTimeout(focus, 120);
},
async submitComment() {
const postId = this.data.commentPostId;
const text = this.data.commentText.trim();
if (!postId || !text) {
wx.showToast({ title: "请输入评论", icon: "none" });
return;
}
const content = this.data.replyToName ? `回复 @${this.data.replyToName}${text}` : text;
this.setData({ commenting: true });
try {
await post(`/api/timeline/${postId}/comments`, { content });
const detail = await get(`/api/timeline/${postId}`);
const currentUser = getApp().globalData.user || wx.getStorageSync("auth_user") || {};
const timelines = this.data.timelines.map((item) => (
item.id === postId ? normalizeTimeline(detail, detail.comments || [], currentUser.id) : item
));
this.setData({
timelines,
commentPostId: null,
commentText: "",
replyToName: "",
commentInputFocus: false,
keyboardOpen: false
});
wx.showToast({ title: "已评论", icon: "success" });
} catch (error) {
showError(error, "评论失败");
} finally {
this.setData({ commenting: false });
}
},
deleteComment(postId, commentId) {
wx.showModal({
title: "删除评论",
content: "确认删除这条评论?",
confirmText: "删除",
confirmColor: "#b42318",
success: async (res) => {
if (!res.confirm) return;
try {
await del(`/api/timeline/comments/${commentId}`);
const detail = await get(`/api/timeline/${postId}`);
const currentUser = getApp().globalData.user || wx.getStorageSync("auth_user") || {};
const timelines = this.data.timelines.map((item) => (
item.id === postId ? normalizeTimeline(detail, detail.comments || [], currentUser.id) : item
));
this.setData({ timelines });
wx.showToast({ title: "已删除", icon: "success" });
} catch (error) {
showError(error, "删除失败");
}
}
});
},
openOwnCommentActions(postId, commentId, name) {
wx.showActionSheet({
itemList: ["回复", "删除评论"],
itemColor: "#6b1f2b",
success: (res) => {
if (res.tapIndex === 0) {
this.startReply(postId, name);
}
if (res.tapIndex === 1) {
this.deleteComment(postId, commentId);
}
}
});
}
});

View File

@ -1,3 +1,3 @@
{
"navigationBarTitleText": "互动"
"navigationBarTitleText": "班级圈"
}

View File

@ -1,21 +1,48 @@
<view class="page">
<view class="hero">
<view class="eyebrow">CLASS INTERACTION</view>
<view class="hero-title">互动协作</view>
<view class="hero-subtitle">轻量表达、投票决策和阅读分享,适合在手机上快速完成。</view>
<view class="circle-head">
<view>
<view class="circle-title">班级圈</view>
<view class="circle-subtitle">同学近况、照片和想法都在这里</view>
</view>
<view wx:if="{{timelineEnabled}}" class="circle-compose" bindtap="openCompose">发布</view>
</view>
<view class="section-head section">
<view class="section-title">可用互动</view>
<view class="section-action">按权限开放</view>
</view>
<view class="grid">
<view wx:for="{{modules}}" wx:key="key" class="module-tile" data-key="{{item.key}}" bindtap="openModule">
<view class="module-icon">{{item.icon}}</view>
<view class="module-title">{{item.title}}</view>
<view class="module-desc">{{item.desc}}</view>
<view wx:if="{{timelines.length}}" class="circle-list">
<view wx:for="{{timelines}}" wx:key="id" class="circle-post">
<view class="circle-avatar">{{item.author_initial}}</view>
<view class="circle-body">
<view class="circle-author" data-id="{{item.id}}" bindtap="openTimeline">{{item.author_name}}</view>
<view class="circle-post-title" data-id="{{item.id}}" bindtap="openTimeline">{{item.title}}</view>
<view wx:if="{{item.content}}" class="circle-content" data-id="{{item.id}}" bindtap="openTimeline">{{item.content}}</view>
<view wx:if="{{item.image_urls && item.image_urls.length}}" class="circle-images">
<image wx:for="{{item.image_urls}}" wx:for-item="img" wx:key="*this" src="{{img}}" mode="aspectFill" data-src="{{img}}" data-post-id="{{item.id}}" bindtap="previewImage" />
</view>
<view class="circle-meta">
<view>{{item.created_at_text}}</view>
<view class="circle-actions">
<view class="circle-action {{item.like_action_class}}" data-id="{{item.id}}" bindtap="toggleLike">{{item.like_action_text}} {{item.like_count}}</view>
<view class="circle-action" data-id="{{item.id}}" bindtap="openComment">评论 {{item.comment_count}}</view>
</view>
</view>
<view wx:if="{{item.like_count || item.comments.length}}" class="circle-feedback">
<view wx:if="{{item.like_count}}" class="circle-likes">赞 {{item.like_count}}</view>
<view wx:for="{{item.comments}}" wx:for-item="comment" wx:key="id" class="circle-comment" data-post-id="{{item.id}}" data-comment-id="{{comment.id}}" data-name="{{comment.author_name}}" data-can-delete="{{comment.can_delete}}" bindtap="replyComment">
<text class="circle-comment-name">{{comment.author_name}}</text>
<text>{{comment.content}}</text>
</view>
</view>
</view>
</view>
</view>
<view wx:if="{{!modules.length}}" class="empty">
<view class="muted">当前班级暂无开放的互动模块</view>
<view wx:else class="empty">
<view class="muted">{{timelineEnabled ? "还没有动态,先发一条吧" : "当前班级未开放班级动态"}}</view>
<view wx:if="{{timelineEnabled}}" class="empty-action" bindtap="openCompose">发布动态</view>
</view>
<view wx:if="{{commentPostId}}" class="comment-mask {{keyboardOpen ? 'keyboard-open' : ''}}" catchtap="closeComment">
<view class="comment-bar" catchtap="noop">
<input value="{{commentText}}" bindinput="onCommentInput" bindfocus="onCommentFocus" bindblur="onCommentBlur" bindkeyboardheightchange="onKeyboardHeightChange" placeholder="{{commentPlaceholder}}" adjust-position="{{true}}" cursor-spacing="{{0}}" focus="{{commentInputFocus}}" hold-keyboard="{{true}}" />
<button loading="{{commenting}}" bindtap="submitComment">发送</button>
</view>
</view>
</view>

View File

@ -1 +1,245 @@
.circle-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24rpx;
margin-bottom: 22rpx;
padding: 26rpx 4rpx 12rpx;
}
.circle-title {
color: #2f211c;
font-size: 44rpx;
font-weight: 780;
line-height: 1.15;
}
.circle-subtitle {
margin-top: 8rpx;
color: #8a7b70;
font-size: 24rpx;
}
.circle-compose {
flex: none;
min-width: 108rpx;
height: 58rpx;
border-radius: 999rpx;
background: #6b1f2b;
color: #fff8ed;
font-size: 25rpx;
font-weight: 720;
line-height: 58rpx;
text-align: center;
}
.circle-list {
border-top: 1rpx solid rgba(121, 84, 54, 0.1);
}
.circle-post {
display: flex;
gap: 20rpx;
padding: 28rpx 0;
border-bottom: 1rpx solid rgba(121, 84, 54, 0.1);
}
.circle-avatar {
flex: none;
width: 76rpx;
height: 76rpx;
border-radius: 18rpx;
background: linear-gradient(145deg, #6b1f2b, #d6a653);
color: #fff8ed;
font-size: 30rpx;
font-weight: 780;
line-height: 76rpx;
text-align: center;
}
.circle-body {
flex: 1;
min-width: 0;
}
.circle-author {
color: #53698e;
font-size: 28rpx;
font-weight: 760;
}
.circle-post-title {
margin-top: 6rpx;
color: #2f211c;
font-size: 30rpx;
font-weight: 760;
line-height: 1.35;
}
.circle-content {
margin-top: 8rpx;
color: #3d2c25;
font-size: 28rpx;
line-height: 1.55;
white-space: pre-wrap;
}
.circle-images {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8rpx;
margin-top: 14rpx;
max-width: 520rpx;
}
.circle-images image {
width: 100%;
height: 156rpx;
border-radius: 8rpx;
background: #efe0ca;
}
.circle-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18rpx;
margin-top: 14rpx;
color: #9a8c80;
font-size: 23rpx;
}
.circle-actions {
display: flex;
align-items: center;
gap: 10rpx;
flex: none;
}
.circle-action {
min-height: 44rpx;
padding: 0 16rpx;
border-radius: 999rpx;
background: #f1e4d4;
color: #725d4d;
font-size: 23rpx;
font-weight: 700;
line-height: 44rpx;
}
.circle-action.active {
background: #e7efff;
color: #53698e;
}
.circle-feedback {
position: relative;
margin-top: 12rpx;
padding: 14rpx 16rpx;
border-radius: 12rpx;
background: #f1e4d4;
}
.circle-feedback::before {
content: "";
position: absolute;
top: -10rpx;
left: 24rpx;
width: 0;
height: 0;
border-left: 10rpx solid transparent;
border-right: 10rpx solid transparent;
border-bottom: 10rpx solid #f1e4d4;
}
.circle-likes {
color: #53698e;
font-size: 24rpx;
font-weight: 720;
line-height: 1.45;
}
.circle-comment {
margin-top: 6rpx;
color: #4f3930;
font-size: 24rpx;
line-height: 1.5;
}
.circle-comment:first-child {
margin-top: 0;
}
.circle-comment-name {
color: #53698e;
font-weight: 720;
}
.empty-action {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 66rpx;
margin-top: 22rpx;
padding: 0 26rpx;
border-radius: 999rpx;
background: #6b1f2b;
color: #fff8ed;
font-size: 24rpx;
font-weight: 700;
}
.comment-mask {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
z-index: 50;
display: flex;
align-items: flex-end;
background: rgba(47, 33, 28, 0.16);
transition: bottom 0.18s ease;
}
.comment-bar {
display: flex;
gap: 14rpx;
width: 100%;
box-sizing: border-box;
margin: 0;
padding: 14rpx 14rpx calc(18rpx + env(safe-area-inset-bottom));
border-radius: 28rpx 28rpx 0 0;
background: #fffaf3;
box-shadow: 0 14rpx 38rpx rgba(47, 33, 28, 0.18);
}
.comment-mask.keyboard-open .comment-bar {
padding-bottom: 14rpx;
border-radius: 0;
}
.comment-bar input {
flex: 1;
min-width: 0;
height: 74rpx;
padding: 0 22rpx;
border-radius: 20rpx;
background: #f1e4d4;
color: #2f211c;
font-size: 26rpx;
}
.comment-bar button {
width: 152rpx;
flex: none;
height: 74rpx;
margin: 0;
padding: 0;
border-radius: 20rpx;
background: #6b1f2b;
color: #fff8ed;
font-size: 25rpx;
font-weight: 720;
line-height: 74rpx;
}

View File

@ -5,7 +5,7 @@ Page({
data: { member: null, loading: false },
onLoad(options) {
wx.setNavigationBarTitle({ title: "同学资料" });
wx.setNavigationBarTitle({ title: "成员资料" });
this.load(options.id);
},
@ -14,14 +14,17 @@ Page({
this.setData({ loading: true });
try {
const member = await get(`/api/directory/${id}`);
const isTeacher = member.membership_role === "teacher";
const isCommittee = !isTeacher && Boolean(member.committee_role);
this.setData({
member: {
...member,
role_text: member.membership_role === "teacher" ? "老师" : "同学",
role_mark: member.membership_role === "teacher" ? "师" : "同",
class_role_text: member.membership_role === "teacher"
role_text: isTeacher ? "老师" : (isCommittee ? member.committee_role : ""),
show_role_text: isTeacher || isCommittee,
role_mark: isTeacher ? "师" : (isCommittee ? "委" : "成"),
class_role_text: isTeacher
? "老师"
: member.committee_role || "同学"
: member.committee_role || "班级成员"
}
});
} catch (error) {

View File

@ -1,50 +1,44 @@
<view class="page" wx:if="{{member}}">
<view class="hero">
<view class="eyebrow">{{member.role_text}}</view>
<view class="hero-title">{{member.name}}</view>
<view class="hero-subtitle">{{member.company || "公司未填写"}} · {{member.position || "职位未填写"}}</view>
<view class="member-profile-hero">
<view class="member-profile-avatar">{{member.role_mark}}</view>
<view class="member-profile-main">
<view class="member-profile-top">
<view class="member-profile-name">{{member.name}}</view>
<view wx:if="{{member.show_role_text}}" class="member-profile-badge">{{member.role_text}}</view>
</view>
<view class="member-profile-subtitle">{{member.company || "公司未填写"}}</view>
<view class="member-profile-subtitle">{{member.position || "职位未填写"}}</view>
</view>
</view>
<view class="section">
<view class="card">
<view class="list-row">
<view class="row-mark">业</view>
<view class="row-body">
<view class="card-title">行业</view>
<view class="muted">{{member.industry || "未填写"}}</view>
</view>
</view>
<view class="member-contact-card">
<view class="contact-item">
<view class="contact-label">微信</view>
<view class="contact-value">{{member.wechat_id || "未填写"}}</view>
</view>
<view class="card">
<view class="list-row">
<view class="row-mark">班</view>
<view class="row-body">
<view class="card-title">班级角色</view>
<view class="muted">{{member.class_role_text}}</view>
</view>
</view>
</view>
<view class="card">
<view class="list-row">
<view class="row-mark">微</view>
<view class="row-body">
<view class="card-title">微信</view>
<view class="muted">{{member.wechat_id || "未填写"}}</view>
</view>
</view>
</view>
<view class="card">
<view class="list-row">
<view class="row-mark">电</view>
<view class="row-body">
<view class="card-title">电话</view>
<view class="muted">{{member.phone || "未填写"}}</view>
</view>
</view>
</view>
<view class="card">
<view class="card-title">简介</view>
<view class="muted">{{member.bio || "暂无简介"}}</view>
<view class="contact-divider"></view>
<view class="contact-item">
<view class="contact-label">电话</view>
<view class="contact-value">{{member.phone || "未填写"}}</view>
</view>
</view>
<view class="member-section">
<view class="member-section-title">职业信息</view>
<view class="member-info-grid">
<view class="member-info-cell">
<view class="member-info-label">行业</view>
<view class="member-info-value">{{member.industry || "未填写"}}</view>
</view>
<view class="member-info-cell">
<view class="member-info-label">班级角色</view>
<view class="member-info-value">{{member.class_role_text}}</view>
</view>
</view>
</view>
<view class="member-section">
<view class="member-section-title">个人简介</view>
<view class="member-bio">{{member.bio || "暂无简介"}}</view>
</view>
</view>

View File

@ -1 +1,141 @@
.member-profile-hero {
display: flex;
align-items: center;
gap: 26rpx;
padding: 34rpx;
border: 1rpx solid rgba(107, 31, 43, 0.12);
border-radius: 34rpx;
background: linear-gradient(145deg, #fffaf3 0%, #f1e4d4 100%);
box-shadow: 0 18rpx 48rpx rgba(68, 39, 27, 0.08);
}
.member-profile-avatar {
display: flex;
align-items: center;
justify-content: center;
width: 112rpx;
height: 112rpx;
flex: none;
border-radius: 34rpx;
background: linear-gradient(145deg, #6b1f2b, #d6a653);
color: #fff8ed;
font-size: 38rpx;
font-weight: 780;
}
.member-profile-main {
flex: 1;
min-width: 0;
}
.member-profile-top {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 14rpx;
}
.member-profile-name {
color: #2f211c;
font-size: 42rpx;
font-weight: 780;
line-height: 1.2;
}
.member-profile-badge {
min-height: 40rpx;
padding: 0 16rpx;
border-radius: 999rpx;
background: #d6a653;
color: #3a221d;
font-size: 22rpx;
font-weight: 760;
line-height: 40rpx;
}
.member-profile-subtitle {
margin-top: 10rpx;
color: #756458;
font-size: 25rpx;
line-height: 1.45;
}
.member-contact-card {
display: flex;
margin-top: 28rpx;
padding: 26rpx;
border-radius: 30rpx;
background: #fffcf7;
box-shadow: 0 14rpx 36rpx rgba(68, 39, 27, 0.07);
}
.contact-item {
flex: 1;
min-width: 0;
}
.contact-label,
.member-info-label {
color: #8a7b70;
font-size: 23rpx;
font-weight: 650;
}
.contact-value {
margin-top: 12rpx;
color: #2f211c;
font-size: 29rpx;
font-weight: 720;
line-height: 1.35;
word-break: break-all;
}
.contact-divider {
width: 1rpx;
margin: 0 24rpx;
background: rgba(121, 84, 54, 0.12);
}
.member-section {
margin-top: 24rpx;
padding: 28rpx;
border-radius: 28rpx;
background: rgba(255, 252, 247, 0.92);
}
.member-section-title {
color: #3a221d;
font-size: 30rpx;
font-weight: 760;
}
.member-info-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18rpx;
margin-top: 20rpx;
}
.member-info-cell {
min-height: 118rpx;
box-sizing: border-box;
padding: 20rpx;
border-radius: 22rpx;
background: #f7f0e8;
}
.member-info-value {
margin-top: 12rpx;
color: #2f211c;
font-size: 27rpx;
font-weight: 720;
line-height: 1.4;
}
.member-bio {
margin-top: 14rpx;
color: #5b493f;
font-size: 27rpx;
line-height: 1.7;
white-space: pre-wrap;
}

View File

@ -36,6 +36,35 @@ function scheduleTypeText(type) {
}[type] || type || "排期";
}
function parseDate(value) {
if (!value) return null;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return null;
return date;
}
function startOfLocalDay(date) {
return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
}
function countdownText(item) {
if (!item || item.type !== "deadline") return "";
const targetDate = parseDate(item.start_time);
if (!targetDate) return "";
const diffDays = Math.round((startOfLocalDay(targetDate) - startOfLocalDay(new Date())) / (24 * 60 * 60 * 1000));
if (diffDays < 0) return `已逾期 ${Math.abs(diffDays)}`;
if (diffDays === 0) return "今天截止";
if (diffDays === 1) return "明天截止";
return `还有 ${diffDays}`;
}
function countdownClass(text) {
if (!text) return "";
if (text.indexOf("已逾期") === 0) return "overdue";
if (text === "今天截止" || text === "明天截止") return "urgent";
return "";
}
Page({
data: {
moduleKey: "",
@ -111,28 +140,36 @@ Page({
const currentUser = getApp().globalData.user || {};
const currentUserId = currentUser.id;
const formatAmount = (value) => Number(value || 0).toFixed(2);
const items = rawItems.map((item) => ({
...item,
can_delete: this.data.moduleKey === "timeline" && item.author_id === currentUserId,
initial: String(item.name || item.author_name || this.data.title || "项").slice(0, 1),
member_role_text: item.membership_role === "teacher" ? "老师" : "同学",
member_role_class: item.membership_role === "teacher" ? "teacher" : "student",
show_student_id: item.membership_role !== "teacher" && item.student_id,
schedule_day: item.start_time ? String(item.start_time).slice(8, 10) : "",
schedule_month: item.start_time ? `${String(item.start_time).slice(5, 7)}` : "",
schedule_time_text: this.data.moduleKey === "schedule" ? formatScheduleTime(item) : "",
schedule_type_text: this.data.moduleKey === "schedule" ? scheduleTypeText(item.type) : "",
vote_status_text: item.status === "open" ? "进行中" : "已关闭",
vote_type_text: item.vote_type === "multiple" ? `多选,最多 ${item.max_choices || 1}` : "单选",
vote_action_text: item.has_voted ? "已参与" : "待参与",
vote_pill_class: item.has_voted ? "done" : "",
vote_options_text: Array.isArray(item.options) ? `${item.options.length} 个选项` : "",
committee_text: item.committee_role ? ` · ${item.committee_role}` : "",
fund_type_text: item.type === "income" ? "收入" : "支出",
fund_type_class: item.type === "income" ? "income" : "expense",
amount_text: formatAmount(item.amount),
image_urls: Array.isArray(item.image_urls) ? item.image_urls : []
}));
const items = rawItems.map((item) => {
const isTeacher = item.membership_role === "teacher";
const isCommittee = !isTeacher && Boolean(item.committee_role);
const scheduleCountdownText = this.data.moduleKey === "schedule" ? countdownText(item) : "";
return {
...item,
can_delete: this.data.moduleKey === "timeline" && item.author_id === currentUserId,
initial: String(item.name || item.author_name || this.data.title || "项").slice(0, 1),
member_role_text: isTeacher ? "老师" : (isCommittee ? item.committee_role : ""),
member_role_class: isTeacher ? "teacher" : (isCommittee ? "committee" : ""),
show_member_role: isTeacher || isCommittee,
show_student_id: item.membership_role !== "teacher" && item.student_id,
schedule_day: item.start_time ? String(item.start_time).slice(8, 10) : "",
schedule_month: item.start_time ? `${String(item.start_time).slice(5, 7)}` : "",
schedule_time_text: this.data.moduleKey === "schedule" ? formatScheduleTime(item) : "",
schedule_type_text: this.data.moduleKey === "schedule" ? scheduleTypeText(item.type) : "",
schedule_countdown_text: scheduleCountdownText,
schedule_countdown_class: countdownClass(scheduleCountdownText),
vote_status_text: item.status === "open" ? "进行中" : "已关闭",
vote_type_text: item.vote_type === "multiple" ? `多选,最多 ${item.max_choices || 1}` : "单选",
vote_action_text: item.has_voted ? "已参与" : "待参与",
vote_pill_class: item.has_voted ? "done" : "",
vote_options_text: Array.isArray(item.options) ? `${item.options.length} 个选项` : "",
committee_text: "",
fund_type_text: item.type === "income" ? "收入" : "支出",
fund_type_class: item.type === "income" ? "income" : "expense",
amount_text: formatAmount(item.amount),
image_urls: Array.isArray(item.image_urls) ? item.image_urls : []
};
});
const fundStats = stats ? {
...stats,
total_income_text: formatAmount(stats.total_income),
@ -175,6 +212,10 @@ Page({
}
if (key === "fund") {
wx.navigateTo({ url: `/pages/fund-detail/index?id=${id}` });
return;
}
if (key === "announcements") {
wx.navigateTo({ url: `/pages/announcement-detail/index?id=${id}` });
}
},

View File

@ -57,7 +57,7 @@
<view class="row-body">
<view class="member-title-line">
<view class="card-title">{{item.name}}</view>
<view class="member-role-badge {{item.member_role_class}}">{{item.member_role_text}}</view>
<view wx:if="{{item.show_member_role}}" class="member-role-badge {{item.member_role_class}}">{{item.member_role_text}}</view>
</view>
<view class="muted">{{item.company || "公司未填写"}} · {{item.position || "职位未填写"}}</view>
<view class="muted">{{item.industry || "行业未填写"}}{{item.committee_text}}</view>
@ -75,7 +75,10 @@
<view class="muted">{{item.location || "地点待定"}}</view>
<view class="muted">{{item.schedule_time_text}}</view>
</view>
<view class="pill">{{item.schedule_type_text}}</view>
<view class="schedule-side">
<view wx:if="{{item.schedule_countdown_text}}" class="schedule-countdown {{item.schedule_countdown_class}}">{{item.schedule_countdown_text}}</view>
<view class="pill">{{item.schedule_type_text}}</view>
</view>
</view>
<view wx:elif="{{isVotes}}" class="vote-row">

View File

@ -14,6 +14,27 @@ function scheduleTypeText(type) {
}[type] || type || "排期";
}
function countdownText(value) {
if (!value) return "";
const targetDate = new Date(value);
if (Number.isNaN(targetDate.getTime())) return "";
const today = new Date();
const targetDay = new Date(targetDate.getFullYear(), targetDate.getMonth(), targetDate.getDate()).getTime();
const todayDay = new Date(today.getFullYear(), today.getMonth(), today.getDate()).getTime();
const diffDays = Math.round((targetDay - todayDay) / (24 * 60 * 60 * 1000));
if (diffDays < 0) return `已逾期 ${Math.abs(diffDays)}`;
if (diffDays === 0) return "今天截止";
if (diffDays === 1) return "明天截止";
return `还有 ${diffDays}`;
}
function countdownClass(text) {
if (!text) return "";
if (text.indexOf("已逾期") === 0) return "overdue";
if (text === "今天截止" || text === "明天截止") return "urgent";
return "";
}
Page({
data: { item: null, loading: false },
@ -27,13 +48,18 @@ Page({
this.setData({ loading: true });
try {
const item = await get(`/api/schedule/${id}`);
const countdown_text = item.type === "deadline" ? countdownText(item.start_time) : "";
this.setData({
item: {
...item,
start_time_text: formatDateTime(item.start_time),
end_time_text: formatDateTime(item.end_time),
type_text: scheduleTypeText(item.type),
start_label: item.type === "deadline" ? "截止时间" : "开始时间"
start_label: item.type === "deadline" ? "截止时间" : "开始时间",
countdown_text,
countdown_class: countdownClass(countdown_text),
location_text: item.location || "地点待定",
description_text: item.description || "暂无说明"
}
});
} catch (error) {

View File

@ -1,41 +1,27 @@
<view class="page" wx:if="{{item}}">
<view class="hero">
<view class="eyebrow">SCHEDULE</view>
<view class="hero-title">{{item.title}}</view>
<view class="hero-subtitle">{{item.location || "地点待定"}}</view>
<view class="schedule-hero">
<view class="schedule-hero-top">
<view class="schedule-type-chip">{{item.type_text}}</view>
<view wx:if="{{item.countdown_text}}" class="deadline-pill {{item.countdown_class}}">{{item.countdown_text}}</view>
</view>
<view class="schedule-title">{{item.title}}</view>
<view class="schedule-location">{{item.location_text}}</view>
</view>
<view class="section">
<view class="card">
<view class="list-row">
<view class="row-mark">类</view>
<view class="row-body">
<view class="card-title">类型</view>
<view class="muted">{{item.type_text}}</view>
</view>
</view>
<view class="schedule-time-card">
<view class="time-block primary">
<view class="time-label">{{item.start_label}}</view>
<view class="time-value">{{item.start_time_text}}</view>
</view>
<view class="card">
<view class="list-row">
<view class="row-mark">始</view>
<view class="row-body">
<view class="card-title">{{item.start_label}}</view>
<view class="muted">{{item.start_time_text}}</view>
</view>
</view>
</view>
<view class="card" wx:if="{{item.end_time}}">
<view class="list-row">
<view class="row-mark">止</view>
<view class="row-body">
<view class="card-title">结束时间</view>
<view class="muted">{{item.end_time_text}}</view>
</view>
</view>
</view>
<view class="card">
<view class="card-title">说明</view>
<view class="muted">{{item.description || "暂无说明"}}</view>
<view wx:if="{{item.end_time}}" class="time-divider"></view>
<view wx:if="{{item.end_time}}" class="time-block">
<view class="time-label">结束时间</view>
<view class="time-value">{{item.end_time_text}}</view>
</view>
</view>
<view class="schedule-section">
<view class="schedule-section-title">详情说明</view>
<view class="schedule-description">{{item.description_text}}</view>
</view>
</view>

View File

@ -1 +1,121 @@
.schedule-hero {
position: relative;
overflow: hidden;
padding: 34rpx;
border: 1rpx solid rgba(107, 31, 43, 0.14);
border-radius: 34rpx;
background: linear-gradient(145deg, #fffaf3 0%, #f1e4d4 100%);
box-shadow: 0 18rpx 48rpx rgba(68, 39, 27, 0.08);
}
.schedule-hero-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
}
.schedule-type-chip {
min-height: 44rpx;
padding: 0 18rpx;
border-radius: 999rpx;
background: #6b1f2b;
color: #fff8ed;
font-size: 23rpx;
font-weight: 720;
line-height: 44rpx;
}
.schedule-title {
margin-top: 26rpx;
color: #2f211c;
font-size: 42rpx;
font-weight: 780;
line-height: 1.25;
}
.schedule-location {
margin-top: 14rpx;
color: #756458;
font-size: 26rpx;
line-height: 1.5;
}
.deadline-pill {
flex: none;
min-height: 48rpx;
padding: 0 22rpx;
border-radius: 999rpx;
background: #e7efff;
color: #53698e;
font-size: 24rpx;
font-weight: 760;
line-height: 48rpx;
}
.deadline-pill.urgent {
background: #fff0d6;
color: #8b5a14;
}
.deadline-pill.overdue {
background: #fde4df;
color: #b42318;
}
.schedule-time-card {
margin-top: 28rpx;
padding: 30rpx;
border-radius: 30rpx;
background: #fffcf7;
box-shadow: 0 14rpx 36rpx rgba(68, 39, 27, 0.07);
}
.time-block {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24rpx;
}
.time-label {
flex: none;
color: #8a7b70;
font-size: 24rpx;
font-weight: 650;
}
.time-value {
color: #2f211c;
font-size: 30rpx;
font-weight: 760;
line-height: 1.35;
text-align: right;
}
.time-divider {
height: 1rpx;
margin: 24rpx 0;
background: rgba(121, 84, 54, 0.12);
}
.schedule-section {
margin-top: 24rpx;
padding: 28rpx;
border-radius: 28rpx;
background: rgba(255, 252, 247, 0.92);
}
.schedule-section-title {
color: #3a221d;
font-size: 30rpx;
font-weight: 760;
}
.schedule-description {
margin-top: 14rpx;
color: #5b493f;
font-size: 27rpx;
line-height: 1.7;
white-space: pre-wrap;
}

View File

@ -1,21 +1,55 @@
const { del, get, post } = require("../../utils/api");
const { showError } = require("../../utils/page-helpers");
function initialOf(name) {
return String(name || "班").slice(0, 1);
}
function formatTime(value) {
return String(value || "").replace("T", " ").slice(0, 16);
}
function sameId(left, right) {
return Number(left) === Number(right);
}
function datasetBoolean(value) {
return value === true || value === "true" || value === 1 || value === "1";
}
function normalizePost(post, currentUserId = null) {
if (!post) return null;
const comments = Array.isArray(post.comments) ? post.comments : [];
return {
...post,
author_initial: initialOf(post.author_name),
created_at_text: formatTime(post.created_at),
like_action_text: post.has_liked ? "已赞" : "赞",
like_action_class: post.has_liked ? "active" : "",
comments: comments.map((comment) => ({
...comment,
created_at_text: formatTime(comment.created_at),
can_delete: sameId(currentUserId, comment.author_id)
}))
};
}
Page({
data: {
id: null,
post: null,
canDelete: false,
comments: [],
commentText: "",
replyTo: "",
inputPlaceholder: "写下评论",
replyToName: "",
inputPlaceholder: "写评论",
inputFocus: false,
keyboardOpen: false,
loading: false,
submitting: false
},
onLoad(options) {
wx.setNavigationBarTitle({ title: "动态详情" });
wx.setNavigationBarTitle({ title: "班级圈" });
this.setData({ id: options.id });
this.load();
},
@ -29,18 +63,11 @@ Page({
if (!this.data.id) return;
this.setData({ loading: true });
try {
const [postDetail, commentsRes] = await Promise.all([
get(`/api/timeline/${this.data.id}`),
get(`/api/timeline/${this.data.id}/comments`, { page_size: 50 })
]);
const currentUser = getApp().globalData.user || {};
const postDetail = await get(`/api/timeline/${this.data.id}`);
const currentUser = getApp().globalData.user || wx.getStorageSync("auth_user") || {};
this.setData({
post: postDetail,
canDelete: postDetail.author_id === currentUser.id,
comments: (commentsRes.items || []).map((item) => ({
...item,
initial: String(item.author_name || "评").slice(0, 1)
}))
post: normalizePost(postDetail, currentUser.id),
canDelete: postDetail.author_id === currentUser.id
});
} catch (error) {
showError(error, "加载动态失败");
@ -53,8 +80,14 @@ Page({
if (!this.data.id || this.data.submitting) return;
this.setData({ submitting: true });
try {
await post(`/api/timeline/${this.data.id}/like`);
await this.load();
const result = await post(`/api/timeline/${this.data.id}/like`);
this.setData({
post: normalizePost({
...this.data.post,
has_liked: Boolean(result.liked),
like_count: Number(result.like_count || 0)
}, (getApp().globalData.user || wx.getStorageSync("auth_user") || {}).id)
});
} catch (error) {
showError(error, "操作失败");
} finally {
@ -66,6 +99,26 @@ Page({
this.setData({ commentText: event.detail.value });
},
onCommentFocus(event) {
const height = event.detail.height || 0;
this.setData({
keyboardOpen: height > 0,
inputFocus: true
});
},
onKeyboardHeightChange(event) {
const height = event.detail.height || 0;
this.setData({ keyboardOpen: height > 0 });
},
onCommentBlur() {
this.setData({
keyboardOpen: false,
inputFocus: false
});
},
previewImage(event) {
const current = event.currentTarget.dataset.src;
const urls = this.data.post && this.data.post.image_urls ? this.data.post.image_urls : [];
@ -90,7 +143,7 @@ Page({
wx.showToast({ title: "已删除", icon: "success" });
const pages = getCurrentPages();
const previousPage = pages[pages.length - 2];
if (previousPage && previousPage.setData) previousPage.setData({ needsRefresh: true });
if (previousPage && previousPage.load) previousPage.load();
setTimeout(() => wx.navigateBack(), 500);
} catch (error) {
showError(error, "删除失败");
@ -101,35 +154,76 @@ Page({
});
},
replyComment(event) {
const name = event.currentTarget.dataset.name || "";
openComment() {
const post = this.data.post;
this.setData({
replyTo: name,
inputPlaceholder: name ? `回复 ${name}` : "写下评论"
replyToName: "",
inputPlaceholder: post ? `评论 ${post.author_name}` : "写评论",
inputFocus: false
}, () => {
this.focusCommentInput();
});
},
replyComment(event) {
const commentId = Number(event.currentTarget.dataset.commentId);
const name = event.currentTarget.dataset.name || "";
const canDelete = datasetBoolean(event.currentTarget.dataset.canDelete);
if (canDelete && commentId) {
this.openOwnCommentActions(commentId, name);
return;
}
this.startReply(name);
},
startReply(name) {
this.setData({
replyToName: name,
inputPlaceholder: name ? `回复 ${name}` : "写评论",
inputFocus: false
}, () => {
this.focusCommentInput();
});
},
cancelReply() {
this.setData({
replyTo: "",
inputPlaceholder: "写下评论"
replyToName: "",
inputPlaceholder: "写评论",
inputFocus: false,
keyboardOpen: false
});
},
focusCommentInput() {
const focus = () => {
if (this.data.post) {
this.setData({ inputFocus: true });
}
};
if (wx.nextTick) {
wx.nextTick(() => setTimeout(focus, 120));
return;
}
setTimeout(focus, 120);
},
async submitComment() {
const text = this.data.commentText.trim();
if (!text) {
wx.showToast({ title: "请输入评论", icon: "none" });
return;
}
const content = this.data.replyTo ? `回复 @${this.data.replyTo}${text}` : text;
const content = this.data.replyToName ? `回复 @${this.data.replyToName}${text}` : text;
this.setData({ submitting: true });
try {
await post(`/api/timeline/${this.data.id}/comments`, { content });
this.setData({
commentText: "",
replyTo: "",
inputPlaceholder: "写下评论"
replyToName: "",
inputPlaceholder: "写评论",
inputFocus: false,
keyboardOpen: false
});
await this.load();
} catch (error) {
@ -137,5 +231,39 @@ Page({
} finally {
this.setData({ submitting: false });
}
},
deleteComment(commentId) {
wx.showModal({
title: "删除评论",
content: "确认删除这条评论?",
confirmText: "删除",
confirmColor: "#b42318",
success: async (res) => {
if (!res.confirm) return;
try {
await del(`/api/timeline/comments/${commentId}`);
await this.load();
wx.showToast({ title: "已删除", icon: "success" });
} catch (error) {
showError(error, "删除失败");
}
}
});
},
openOwnCommentActions(commentId, name) {
wx.showActionSheet({
itemList: ["回复", "删除评论"],
itemColor: "#6b1f2b",
success: (res) => {
if (res.tapIndex === 0) {
this.startReply(name);
}
if (res.tapIndex === 1) {
this.deleteComment(commentId);
}
}
});
}
});

View File

@ -1,58 +1,48 @@
<view class="page" wx:if="{{post}}">
<view class="hero">
<view class="eyebrow">CLASS FEED</view>
<view class="circle-detail-head">
<view class="circle-avatar large">{{post.author_initial}}</view>
<view class="circle-head-body">
<view class="circle-author">{{post.author_name}}</view>
<view class="circle-time">{{post.created_at_text}}</view>
</view>
<view wx:if="{{canDelete}}" class="detail-more" bindtap="openActions">···</view>
<view class="hero-title">{{post.title}}</view>
<view class="hero-subtitle">{{post.author_name}} · {{post.created_at}}</view>
</view>
<view class="section">
<view class="card">
<view class="feed-content">{{post.content || "暂无正文"}}</view>
<view wx:if="{{post.image_urls && post.image_urls.length}}" class="feed-images detail-images">
<image wx:for="{{post.image_urls}}" wx:for-item="img" wx:key="*this" src="{{img}}" mode="aspectFill" data-src="{{img}}" bindtap="previewImage" />
</view>
<view class="detail-action-bar">
<view class="action-chip {{post.has_liked ? 'active' : ''}}" bindtap="toggleLike">
<text class="action-icon">赞</text>
<text>{{post.has_liked ? "已点赞" : "点赞"}} · {{post.like_count}}</text>
</view>
<view class="action-chip">
<text class="action-icon">评</text>
<text>评论 · {{comments.length}}</text>
</view>
</view>
<view class="circle-detail">
<view class="circle-post-title">{{post.title}}</view>
<view wx:if="{{post.content}}" class="circle-content">{{post.content}}</view>
<view wx:if="{{post.image_urls && post.image_urls.length}}" class="circle-images detail-images">
<image wx:for="{{post.image_urls}}" wx:key="*this" src="{{item}}" mode="aspectFill" data-src="{{item}}" bindtap="previewImage" />
</view>
<view class="circle-actions-row">
<view class="circle-action {{post.like_action_class}}" bindtap="toggleLike">{{post.like_action_text}} {{post.like_count}}</view>
<view class="circle-action" bindtap="openComment">评论 {{post.comment_count}}</view>
</view>
</view>
<view class="section">
<view class="section-head">
<view class="section-title">评论</view>
<view class="section-action">{{comments.length}} 条</view>
</view>
<view wx:for="{{comments}}" wx:key="id" class="comment-card">
<view class="list-row">
<view class="avatar">{{item.initial}}</view>
<view class="row-body">
<view class="comment-head">
<view class="card-title">{{item.author_name}}</view>
<view class="reply-link" data-name="{{item.author_name}}" bindtap="replyComment">回复</view>
</view>
<view class="comment-content">{{item.content}}</view>
</view>
</view>
</view>
<view wx:if="{{!comments.length}}" class="card">
<view class="muted">还没有评论</view>
<view class="circle-feedback detail-feedback">
<view wx:if="{{post.like_count}}" class="circle-likes">赞 {{post.like_count}}</view>
<view wx:for="{{post.comments}}" wx:key="id" class="circle-comment" data-comment-id="{{item.id}}" data-name="{{item.author_name}}" data-can-delete="{{item.can_delete}}" bindtap="replyComment">
<text class="circle-comment-name">{{item.author_name}}</text>
<text>{{item.content}}</text>
</view>
<view wx:if="{{!post.like_count && !post.comments.length}}" class="muted">还没有点赞和评论</view>
</view>
<view wx:if="{{replyTo}}" class="replying-bar">
<view>正在回复 {{replyTo}}</view>
<view bindtap="cancelReply">取消</view>
</view>
<view class="comment-box">
<input value="{{commentText}}" bindinput="onCommentInput" placeholder="{{inputPlaceholder}}" />
<button loading="{{submitting}}" bindtap="submitComment">发送</button>
<view class="comment-dock {{keyboardOpen ? 'keyboard-open' : ''}}">
<view wx:if="{{replyToName}}" class="replying-bar">
<view>回复 {{replyToName}}</view>
<view bindtap="cancelReply">取消</view>
</view>
<view class="comment-box">
<input value="{{commentText}}" bindinput="onCommentInput" bindfocus="onCommentFocus" bindblur="onCommentBlur" bindkeyboardheightchange="onKeyboardHeightChange" placeholder="{{inputPlaceholder}}" adjust-position="{{true}}" cursor-spacing="{{0}}" focus="{{inputFocus}}" hold-keyboard="{{true}}" />
<button loading="{{submitting}}" bindtap="submitComment">发送</button>
</view>
</view>
</view>
<view class="page" wx:elif="{{!loading}}">
<view class="empty">
<view class="muted">未找到动态</view>
</view>
</view>

View File

@ -1,88 +1,173 @@
.detail-images image {
height: 210rpx;
.circle-detail-head {
display: flex;
align-items: center;
gap: 20rpx;
padding: 24rpx 0 18rpx;
border-bottom: 1rpx solid rgba(121, 84, 54, 0.1);
}
.circle-avatar {
flex: none;
width: 76rpx;
height: 76rpx;
border-radius: 18rpx;
background: linear-gradient(145deg, #6b1f2b, #d6a653);
color: #fff8ed;
font-size: 30rpx;
font-weight: 780;
line-height: 76rpx;
text-align: center;
}
.circle-avatar.large {
width: 88rpx;
height: 88rpx;
border-radius: 22rpx;
font-size: 34rpx;
line-height: 88rpx;
}
.circle-head-body {
flex: 1;
min-width: 0;
}
.circle-author {
color: #53698e;
font-size: 30rpx;
font-weight: 760;
}
.circle-time {
margin-top: 6rpx;
color: #9a8c80;
font-size: 23rpx;
}
.detail-more {
position: absolute;
top: 28rpx;
right: 28rpx;
z-index: 2;
flex: none;
min-width: 64rpx;
height: 46rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.12);
color: rgba(255, 248, 237, 0.72);
background: #f1e4d4;
color: #725d4d;
font-size: 32rpx;
line-height: 38rpx;
text-align: center;
}
.detail-action-bar {
display: flex;
gap: 16rpx;
margin-top: 24rpx;
.circle-detail {
padding: 24rpx 0;
}
.action-chip {
display: flex;
align-items: center;
justify-content: center;
.circle-post-title {
color: #2f211c;
font-size: 34rpx;
font-weight: 780;
line-height: 1.35;
}
.circle-content {
margin-top: 12rpx;
color: #3d2c25;
font-size: 29rpx;
line-height: 1.65;
white-space: pre-wrap;
}
.circle-images {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8rpx;
min-height: 64rpx;
flex: 1;
border-radius: 22rpx;
background: #f4eadc;
margin-top: 18rpx;
}
.circle-images image {
width: 100%;
height: 206rpx;
border-radius: 8rpx;
background: #efe0ca;
}
.circle-actions-row {
display: flex;
gap: 12rpx;
margin-top: 20rpx;
}
.circle-action {
min-height: 50rpx;
padding: 0 18rpx;
border-radius: 999rpx;
background: #f1e4d4;
color: #725d4d;
font-size: 24rpx;
font-weight: 650;
font-weight: 700;
line-height: 50rpx;
}
.action-chip.active {
background: #6b1f2b;
color: #fff8ed;
.circle-action.active {
background: #e7efff;
color: #53698e;
}
.action-icon {
font-size: 22rpx;
font-weight: 780;
.circle-feedback {
position: relative;
margin-top: 4rpx;
padding: 18rpx;
border-radius: 14rpx;
background: #f1e4d4;
}
.comment-card {
margin-bottom: 16rpx;
border: 1rpx solid rgba(121, 84, 54, 0.1);
border-radius: 26rpx;
background: rgba(255, 252, 247, 0.96);
padding: 24rpx;
.detail-feedback {
margin-bottom: 170rpx;
}
.comment-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18rpx;
.circle-likes {
color: #53698e;
font-size: 25rpx;
font-weight: 720;
line-height: 1.45;
}
.reply-link {
flex: none;
color: #8b5a36;
font-size: 24rpx;
font-weight: 650;
}
.comment-content {
.circle-comment {
margin-top: 8rpx;
color: #59463d;
color: #4f3930;
font-size: 26rpx;
line-height: 1.55;
}
.circle-comment:first-child {
margin-top: 0;
}
.circle-comment-name {
color: #53698e;
font-weight: 720;
}
.comment-dock {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 50;
box-sizing: border-box;
padding: 0 14rpx calc(18rpx + env(safe-area-inset-bottom));
background: #fffaf3;
box-shadow: 0 -12rpx 30rpx rgba(68, 39, 27, 0.1);
transition: bottom 0.18s ease;
}
.comment-dock.keyboard-open {
padding-bottom: 0;
}
.replying-bar {
position: sticky;
bottom: 116rpx;
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 24rpx;
margin-bottom: 10rpx;
padding: 16rpx 24rpx;
border-radius: 22rpx;
background: #f1e4d4;
@ -91,17 +176,12 @@
}
.comment-box {
position: sticky;
bottom: 20rpx;
display: flex;
align-items: center;
gap: 14rpx;
margin-top: 32rpx;
padding: 14rpx;
border: 1rpx solid rgba(121, 84, 54, 0.12);
border-radius: 28rpx;
background: rgba(255, 252, 247, 0.96);
box-shadow: 0 18rpx 42rpx rgba(68, 39, 27, 0.1);
border-radius: 0;
background: #fffaf3;
}
.comment-box input {
@ -111,10 +191,11 @@
padding: 0 22rpx;
border-radius: 20rpx;
background: #f7f0e8;
color: #2f211c;
}
.comment-box button {
width: 156rpx;
width: 152rpx;
flex: none;
height: 72rpx;
margin: 0;
@ -122,6 +203,7 @@
border-radius: 20rpx;
background: #6b1f2b;
color: #fff7ea;
font-size: 26rpx;
font-size: 25rpx;
font-weight: 720;
line-height: 72rpx;
}

View File

@ -39,14 +39,9 @@
</view>
</view>
<view wx:if="{{item.deadline_text}}" class="card">
<view class="list-row">
<view class="row-mark">止</view>
<view class="row-body">
<view class="card-title">截止时间</view>
<view class="muted">{{item.deadline_text}}</view>
</view>
</view>
<view wx:if="{{item.deadline_text}}" class="vote-deadline-card">
<view class="vote-deadline-label">截止时间</view>
<view class="vote-deadline-value">{{item.deadline_text}}</view>
</view>
<view class="form-submit-bar">

View File

@ -55,3 +55,26 @@
font-size: 22rpx;
line-height: 1.45;
}
.vote-deadline-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24rpx;
margin-top: 24rpx;
padding: 24rpx 28rpx;
border-radius: 26rpx;
background: #fff0d6;
color: #7a4b2b;
}
.vote-deadline-label {
font-size: 24rpx;
font-weight: 650;
}
.vote-deadline-value {
font-size: 27rpx;
font-weight: 760;
text-align: right;
}

View File

@ -3,8 +3,8 @@ const MODULES = {
schedule: { key: "schedule", title: "排期", desc: "课程、活动与截止日", group: "class", icon: "日" },
directory: { key: "directory", title: "成员名录", desc: "查找同学与班委", group: "class", icon: "友" },
fund: { key: "fund", title: "班费", desc: "查看公开收支账本", group: "class", icon: "账" },
timeline: { key: "timeline", title: "班级动态", desc: "分享近况与评论互动", group: "interact", icon: "动" },
votes: { key: "votes", title: "投票", desc: "参与班级决策", group: "interact", icon: "" }
votes: { key: "votes", title: "投票", desc: "参与班级决策", group: "class", icon: "选" },
timeline: { key: "timeline", title: "班级动态", desc: "分享近况与评论互动", group: "interact", icon: "" }
};
const MINI_PROGRAM_MODULE_KEYS = Object.keys(MODULES);