diff --git a/docs/业务进度管理.md b/docs/业务进度管理.md
index 151f3068..19448f6b 100644
--- a/docs/业务进度管理.md
+++ b/docs/业务进度管理.md
@@ -13,6 +13,21 @@
## 一、已完成事项
+### 128. 社区发帖分类与多标签选择
+
+- 状态:代码已完成,待 H5 验收 - 时间:2026-08-03
+
+完成内容:
+
+- 发帖页新增必选分类卡片,读取后台启用的社区分类并将分类 ID 一同提交。
+- 标签区域明确支持多选,展示已选数量,并限制最多选择 3 个标签。
+- 发布接口校验分类与标签的启用状态,写入帖子分类并同步更新分类、标签的帖子计数。
+
+涉及模块:
+
+- `uniapp/src/packages_community/pages/publish.vue`
+- `server/app/api/controller/CommunityController.php`
+
### 127. 历史开奖记录页主页风格统一
- 状态:代码已完成,待 H5 验收 - 时间:2026-08-03
diff --git a/server/app/api/controller/CommunityController.php b/server/app/api/controller/CommunityController.php
index 88d9ee58..faa9b2d9 100644
--- a/server/app/api/controller/CommunityController.php
+++ b/server/app/api/controller/CommunityController.php
@@ -168,8 +168,9 @@ class CommunityController extends BaseApiController
$content = $this->request->post('content/s', '');
$images = $this->request->post('images/a', []);
$postType = $this->request->post('post_type/d', 0);
+ $categoryId = $this->request->post('category_id/d', 0);
$matchId = $this->request->post('match_id/d', 0);
- $tagIds = $this->request->post('tag_ids/a', []);
+ $tagIds = array_values(array_unique(array_filter(array_map('intval', $this->request->post('tag_ids/a', [])))));
$isPaid = $this->request->post('is_paid/d', 0);
$pricePoints = $this->request->post('price_points/d', 0);
$freeContentLen = $this->request->post('free_content_len/d', 100);
@@ -178,6 +179,24 @@ class CommunityController extends BaseApiController
return $this->fail('请输入内容或上传图片');
}
+ if ($categoryId <= 0) {
+ return $this->fail('请选择帖子分类');
+ }
+ $category = CommunityCategory::where('id', $categoryId)->where('status', 1)->findOrEmpty();
+ if ($category->isEmpty()) {
+ return $this->fail('所选分类不可用');
+ }
+
+ if (count($tagIds) > 3) {
+ return $this->fail('最多选择3个标签');
+ }
+ if ($tagIds) {
+ $validTagCount = CommunityTag::whereIn('id', $tagIds)->where('status', 1)->count();
+ if ($validTagCount !== count($tagIds)) {
+ return $this->fail('存在不可用标签');
+ }
+ }
+
if ($isPaid && $pricePoints <= 0) {
return $this->fail('付费帖子必须设置积分价格');
}
@@ -192,6 +211,7 @@ class CommunityController extends BaseApiController
'content' => $content,
'images' => $images,
'post_type' => $postType,
+ 'category_id' => $categoryId,
'match_id' => $matchId,
'is_paid' => $isPaid ? 1 : 0,
'price_points' => $isPaid ? $pricePoints : 0,
@@ -211,6 +231,7 @@ class CommunityController extends BaseApiController
// 更新标签帖子数
CommunityTag::whereIn('id', $tagIds)->inc('post_count')->update();
}
+ CommunityCategory::where('id', $categoryId)->inc('post_count')->update();
Db::commit();
KbSyncService::enqueue('post', 'post', (int) $post->id, 'upsert', 30);
diff --git a/uniapp/src/packages_community/pages/publish.vue b/uniapp/src/packages_community/pages/publish.vue
index bea82cf6..8cc64cf6 100644
--- a/uniapp/src/packages_community/pages/publish.vue
+++ b/uniapp/src/packages_community/pages/publish.vue
@@ -48,9 +48,36 @@
+
+
+
+ 选择分类
+ 必选
+
+ {{ selectedCategory ? '已选择 1 个分类' : '请选择帖子所属分类' }}
+
+
+
+
+
+ {{ category.name.slice(0, 1) }}
+
+ {{ category.name }}
+
+
+
+ 暂无可选分类
+
+
-
- 选择标签
+
+
+ 选择标签
+ 可多选
+
+ 已选 {{ selectedTags.length }}/3
import { reactive, ref, computed, onMounted } from 'vue'
-import { publishPost, getTagLists } from '@/api/community'
+import { publishPost, getTagLists, getCommunityCategoryLists } from '@/api/community'
import { uploadImage } from '@/api/app'
import { getToken } from '@/utils/auth'
@@ -75,6 +102,8 @@ const scrollHeight = ref(500)
const publishing = ref(false)
const tagList = ref([])
const selectedTags = ref([])
+const categoryList = ref([])
+const selectedCategory = ref(0)
const isPaid = ref(false)
const pricePoints = ref('')
@@ -85,7 +114,7 @@ const form = reactive({
})
const canPublish = computed(() => {
- return form.content.trim().length > 0 || form.images.length > 0
+ return selectedCategory.value > 0 && (form.content.trim().length > 0 || form.images.length > 0)
})
onMounted(() => {
@@ -97,13 +126,14 @@ onMounted(() => {
statusBarHeight.value = 20
// #endif
scrollHeight.value = sysInfo.windowHeight - statusBarHeight.value - 88
- fetchTags()
+ fetchPublishOptions()
})
-const fetchTags = async () => {
+const fetchPublishOptions = async () => {
try {
- const res = await getTagLists()
- tagList.value = res || []
+ const [categories, tags] = await Promise.all([getCommunityCategoryLists(), getTagLists()])
+ categoryList.value = categories || []
+ tagList.value = tags || []
} catch (e) { }
}
@@ -121,6 +151,10 @@ const handlePublish = async () => {
uni.navigateTo({ url: '/pages/login/login' })
return
}
+ if (!selectedCategory.value) {
+ uni.showToast({ title: '请选择帖子分类', icon: 'none' })
+ return
+ }
if (!canPublish.value || publishing.value) return
publishing.value = true
try {
@@ -148,6 +182,7 @@ const handlePublish = async () => {
await publishPost({
content: form.content,
images: uploadedImages,
+ category_id: selectedCategory.value,
tag_ids: selectedTags.value,
post_type: 0,
is_paid: isPaid.value ? 1 : 0,
@@ -186,6 +221,10 @@ const removeImage = (idx: number) => {
form.images.splice(idx, 1)
}
+const selectCategory = (id: number) => {
+ selectedCategory.value = id
+}
+
const toggleTag = (id: number) => {
const idx = selectedTags.value.indexOf(id)
if (idx >= 0) {
@@ -377,17 +416,134 @@ const toggleTag = (id: number) => {
}
}
-.publish-tags {
+.publish-section__head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 20rpx;
+}
+
+.publish-section__title-wrap {
+ display: flex;
+ align-items: center;
+ gap: 10rpx;
+}
+
+.publish-section__title {
+ font-size: 28rpx;
+ font-weight: 600;
+ color: #333;
+}
+
+.publish-section__required,
+.publish-section__optional {
+ padding: 3rpx 10rpx;
+ border-radius: 10rpx;
+ font-size: 20rpx;
+}
+
+.publish-section__required {
+ color: #ef4444;
+ background: #fff1f2;
+}
+
+.publish-section__optional {
+ color: #185dff;
+ background: #edf4ff;
+}
+
+.publish-section__hint,
+.publish-section__empty {
+ color: #9aa3b2;
+ font-size: 22rpx;
+}
+
+.publish-category {
padding: 24rpx 30rpx;
border-top: 16rpx solid #f5f5f5;
- &__label {
- font-size: 28rpx;
- font-weight: 600;
- color: #333;
- margin-bottom: 20rpx;
+ &__list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 16rpx;
}
+ &__item {
+ position: relative;
+ display: flex;
+ align-items: center;
+ min-width: calc((100% - 16rpx) / 2);
+ max-width: 100%;
+ height: 76rpx;
+ box-sizing: border-box;
+ padding: 0 20rpx;
+ gap: 12rpx;
+ border: 2rpx solid #edf0f5;
+ border-radius: 14rpx;
+ background: #fafbfc;
+
+ &--active {
+ border-color: #185dff;
+ background: #eef4ff;
+
+ .publish-category__name {
+ color: #185dff;
+ font-weight: 600;
+ }
+
+ &::after {
+ position: absolute;
+ top: 0;
+ right: 0;
+ width: 34rpx;
+ height: 34rpx;
+ border-radius: 0 10rpx 0 12rpx;
+ background: #185dff;
+ content: '';
+ }
+
+ .u-icon {
+ position: absolute;
+ top: 2rpx;
+ right: 3rpx;
+ z-index: 1;
+ }
+ }
+ }
+
+ &__icon {
+ width: 40rpx;
+ height: 40rpx;
+ border-radius: 50%;
+ flex-shrink: 0;
+
+ &--fallback {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: #dbe8ff;
+
+ text {
+ color: #185dff;
+ font-size: 20rpx;
+ font-weight: 700;
+ }
+ }
+ }
+
+ &__name {
+ overflow: hidden;
+ color: #4b5563;
+ font-size: 25rpx;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+}
+
+.publish-tags {
+ padding: 24rpx 30rpx;
+ border-top: 16rpx solid #f5f5f5;
+
&__list {
display: flex;
flex-wrap: wrap;