diff --git a/backend/application/command.php b/backend/application/command.php
index f45ecfd..2168f91 100644
--- a/backend/application/command.php
+++ b/backend/application/command.php
@@ -20,5 +20,5 @@ return [
'app\common\command\Test',
'app\common\command\BiddingDeal',
'app\common\command\DemandDeal',
- 'app\common\command\GroupChatFetch',
+ 'app\common\command\GroupChat',
];
diff --git a/backend/application/common/command/GroupChat.php b/backend/application/common/command/GroupChat.php
new file mode 100644
index 0000000..65ec93b
--- /dev/null
+++ b/backend/application/common/command/GroupChat.php
@@ -0,0 +1,173 @@
+ [菜单说明, 处理方法]
+ * @var array
+ */
+ protected $actions = [
+ 'stat' => ['查看群池状态', 'stat'],
+ 'sync' => ['同步企业微信客户群,补充群池', 'syncList'],
+ 'refresh' => ['刷新所有已分配群的二维码', 'refreshAll'],
+ 'refreshOne' => ['刷新指定需求群的二维码', 'refreshOne'],
+ 'recycle' => ['回收指定需求占用的群,归还群池', 'recycle'],
+ ];
+
+ protected function configure()
+ {
+ $this->setName('groupChat:manage')
+ ->addArgument('action', Argument::OPTIONAL, '要执行的功能:' . implode('|', array_keys($this->actions)))
+ ->setDescription('企业微信沟通群管理:群池同步 / 二维码刷新 / 回收 / 状态查看');
+ }
+
+ protected function execute(Input $input, Output $output)
+ {
+ $action = trim((string)$input->getArgument('action'));
+ if ($action === '') {
+ $this->showMenu($input, $output);
+ return;
+ }
+ $this->dispatch($action, $input, $output);
+ }
+
+ /**
+ * 交互菜单
+ */
+ protected function showMenu(Input $input, Output $output)
+ {
+ $choices = array_merge(array_keys($this->actions), ['exit']);
+ while (true) {
+ $output->writeln('');
+ $output->writeln('企业微信沟通群管理');
+ foreach ($this->actions as $key => $item) {
+ $output->writeln(sprintf(' %-12s%s', $key, $item[0]));
+ }
+ $output->writeln(sprintf(' %-12s%s', 'exit', '退出'));
+
+ $action = $output->choice($input, '请选择要执行的功能', $choices, 'stat');
+ if ($action === 'exit') {
+ $output->writeln('已退出');
+ return;
+ }
+ $this->dispatch($action, $input, $output);
+ }
+ }
+
+ /**
+ * 调度到具体处理方法
+ */
+ protected function dispatch($action, Input $input, Output $output)
+ {
+ if (!isset($this->actions[$action])) {
+ $output->writeln("未知功能:{$action}");
+ $output->writeln('可用功能:' . implode('、', array_keys($this->actions)));
+ return;
+ }
+ $method = $this->actions[$action][1];
+ try {
+ $this->$method($input, $output);
+ } catch (\Exception $e) {
+ $output->writeln('执行失败:' . $e->getMessage() . '');
+ }
+ }
+
+ /**
+ * 查看群池状态
+ */
+ protected function stat(Input $input, Output $output)
+ {
+ $stat = DemandGroupChatService::stat();
+ $output->writeln(sprintf(
+ '群总数:%d 空闲:%d 已分配:%d 无二维码:%d',
+ $stat['total'],
+ $stat['free'],
+ $stat['used'],
+ $stat['no_qrcode']
+ ));
+ if ($stat['free'] < 3) {
+ $output->writeln('提示:空闲群不足 3 个,请先在企微建群,再执行 sync 补充');
+ }
+ }
+
+ /**
+ * 同步企业微信客户群
+ */
+ protected function syncList(Input $input, Output $output)
+ {
+ $output->writeln('正在从企业微信拉取客户群列表...');
+ $num = DemandGroupChatService::syncList();
+ $output->writeln($num > 0
+ ? "已新入库 {$num} 个群"
+ : '没有新的群需要入库(群主名下暂无新增客户群)');
+ $this->stat($input, $output);
+ }
+
+ /**
+ * 刷新所有已分配群的二维码
+ */
+ protected function refreshAll(Input $input, Output $output)
+ {
+ $output->writeln('正在刷新已分配群的二维码...');
+ $res = DemandGroupChatService::refreshAll();
+ $output->writeln(sprintf('共 %d 个:成功 %d,失败 %d', $res['total'], $res['success'], $res['fail']));
+ if ($res['fail']) {
+ $output->writeln('失败的需求ID:' . implode(', ', $res['failDemandIds']) . ',详情见日志');
+ }
+ }
+
+ /**
+ * 刷新指定需求群的二维码
+ */
+ protected function refreshOne(Input $input, Output $output)
+ {
+ $demandId = (int)$output->ask($input, '请输入需求ID', '');
+ if (!$demandId) {
+ $output->writeln('需求ID不能为空');
+ return;
+ }
+ if (!DemandGroupChatService::refreshByDemand($demandId)) {
+ $output->writeln("需求 {$demandId} 没有已分配的沟通群,或刷新失败,详情见日志");
+ return;
+ }
+ $output->writeln("需求 {$demandId} 的群二维码已刷新");
+ }
+
+ /**
+ * 回收指定需求占用的群
+ */
+ protected function recycle(Input $input, Output $output)
+ {
+ $demandId = (int)$output->ask($input, '请输入要回收的需求ID', '');
+ if (!$demandId) {
+ $output->writeln('需求ID不能为空');
+ return;
+ }
+ if (!$output->confirm($input, "确认回收需求 {$demandId} 占用的沟通群?", false)) {
+ $output->writeln('已取消');
+ return;
+ }
+ if (!DemandGroupChatService::recycle($demandId)) {
+ $output->writeln("需求 {$demandId} 没有已分配的沟通群");
+ return;
+ }
+ $output->writeln("需求 {$demandId} 的沟通群已归还群池");
+ $this->stat($input, $output);
+ }
+}
diff --git a/backend/application/common/command/GroupChatFetch.php b/backend/application/common/command/GroupChatFetch.php
deleted file mode 100644
index e2ee3e9..0000000
--- a/backend/application/common/command/GroupChatFetch.php
+++ /dev/null
@@ -1,24 +0,0 @@
-setName('groupChat:fetch')->setDescription('groupChat fetch command!');
- }
-
- protected function execute(Input $input, Output $output)
- {
- $output->writeln("groupChat fetch...");
- DemandGroupChatService::updateQrCode(8);
-
- }
-}
\ No newline at end of file
diff --git a/backend/application/common/service/DemandGroupChatService.php b/backend/application/common/service/DemandGroupChatService.php
index 6c357f7..e2b62db 100644
--- a/backend/application/common/service/DemandGroupChatService.php
+++ b/backend/application/common/service/DemandGroupChatService.php
@@ -6,6 +6,7 @@ use app\common\model\demand\group\Chat;
use app\common\traits\ServiceTrait;
use Exception;
use GuzzleHttp\Exception\GuzzleException;
+use think\Log;
class DemandGroupChatService
{
@@ -43,19 +44,22 @@ class DemandGroupChatService
];
/**
- * 保存客户群列表
- * @return void
+ * 保存客户群列表(只补充未入库的群,可重复执行)
+ * @return int 新增数量
* @throws GuzzleException
*/
- public static function createList()
+ public static function syncList(): int
{
$scene = 2;
$state = '';
$group_chat_list = WorkWeChatService::groupChatList();
+ $dataList = [];
foreach ($group_chat_list as $chat) {
- $chat_id = $chat['chat_id'] ?? 0;
+ $chat_id = $chat['chat_id'] ?? '';
+ if (!$chat_id) {
+ continue;
+ }
$groupChatInfo = WorkWeChatService::groupChatInfo($chat_id);
-// $config_id = WorkWeChatService::groupChatAddJoinWay([$chat_id], $scene, $state);
$dataList[] = [
'demand_id' => 0,
'chat_id' => $chat_id,
@@ -66,18 +70,27 @@ class DemandGroupChatService
'state' => $state,
];
}
- usort($dataList, function($a, $b) {
+ //群名为空时不再排序,避免空列表导致报错
+ if (!$dataList) {
+ Log::warning(__METHOD__ . ' 企业微信未返回任何客户群,请检查群主配置与接口权限');
+ return 0;
+ }
+ usort($dataList, function ($a, $b) {
return (int)filter_var($a['name'], FILTER_SANITIZE_NUMBER_INT) <=> (int)filter_var($b['name'], FILTER_SANITIZE_NUMBER_INT);
});
$chatIdList = self::where('chat_id', '<>', '')->column('chat_id');
- $dataList = array_filter($dataList, function($value) use($chatIdList) {
+ $dataList = array_filter($dataList, function ($value) use ($chatIdList) {
return !in_array($value['chat_id'], $chatIdList);
});
- $res = (new self::$Model)->saveAll($dataList);
+ if (!$dataList) {
+ return 0;
+ }
+ (new self::$Model)->saveAll($dataList);
+ return count($dataList);
}
/**
- * 更新二维码
+ * 分配一个空闲群给需求,并生成(或刷新)二维码
* @param $demand_id
* @param bool $refresh
* @return mixed
@@ -88,17 +101,113 @@ class DemandGroupChatService
//分配一个未使用的群
$info = self::getOne(['demand_id' => 0], [], ['id']);
if (!$info) {
+ Log::error(__METHOD__ . ' 群池已空,无法为需求分配沟通群,demand_id:' . $demand_id);
return false;
}
$info->demand_id = $demand_id;
- if (!$info->qr_code || $refresh) {
- //更新config_id,防止设置群聊邀请确认后不生效
- $config_id = WorkWeChatService::groupChatAddJoinWay([$info['chat_id']]);
- $join_way = WorkWeChatService::groupChatGetJoinWay($config_id);
- $info->config_id = $config_id;
- $info->qr_code = $join_way['qr_code'] ?? '';
- }
+ self::refreshQrCode($info, $refresh);
return $info->save();
}
+ /**
+ * 重新生成群的入群二维码
+ * 失败只记录日志并返回 false,不抛异常,避免影响确认接单等主流程
+ * @param Chat|\think\Model $info
+ * @param bool $refresh 已有二维码时是否强制刷新
+ * @return bool
+ * @throws GuzzleException
+ */
+ public static function refreshQrCode($info, bool $refresh = false): bool
+ {
+ if ($info->qr_code && !$refresh) {
+ return true;
+ }
+ //更新config_id,防止设置群聊邀请确认后不生效
+ $config_id = WorkWeChatService::groupChatAddJoinWay([$info['chat_id']]);
+ if (!$config_id) {
+ Log::error(__METHOD__ . ' 生成入群方式失败,chat_id:' . $info['chat_id']);
+ return false;
+ }
+ $join_way = WorkWeChatService::groupChatGetJoinWay($config_id);
+ if (empty($join_way['qr_code'])) {
+ Log::error(__METHOD__ . ' 获取群二维码失败,config_id:' . $config_id);
+ return false;
+ }
+ $info->config_id = $config_id;
+ $info->qr_code = $join_way['qr_code'];
+ return true;
+ }
+
+ /**
+ * 刷新所有已分配群的二维码
+ * @return array
+ * @throws GuzzleException
+ */
+ public static function refreshAll(): array
+ {
+ $list = self::where('demand_id', '>', 0)->select();
+ $result = ['total' => 0, 'success' => 0, 'fail' => 0, 'failDemandIds' => []];
+ foreach ($list as $item) {
+ $result['total']++;
+ if (self::refreshQrCode($item, true) && $item->save()) {
+ $result['success']++;
+ } else {
+ $result['fail']++;
+ $result['failDemandIds'][] = $item->demand_id;
+ }
+ }
+ return $result;
+ }
+
+ /**
+ * 刷新指定需求群的二维码
+ * @param $demand_id
+ * @return bool
+ * @throws GuzzleException
+ */
+ public static function refreshByDemand($demand_id)
+ {
+ $info = self::getOne(['demand_id' => $demand_id]);
+ if (!$info) {
+ Log::error(__METHOD__ . ' 该需求没有已分配的沟通群,demand_id:' . $demand_id);
+ return false;
+ }
+ if (!self::refreshQrCode($info, true)) {
+ return false;
+ }
+ return (bool)$info->save();
+ }
+
+ /**
+ * 回收需求占用的群,归还群池
+ * @param $demand_id
+ * @return bool
+ */
+ public static function recycle($demand_id)
+ {
+ $info = self::getOne(['demand_id' => $demand_id]);
+ if (!$info) {
+ return false;
+ }
+ $info->demand_id = 0;
+ $info->config_id = '';
+ //旧二维码随需求作废,下次分配时重新生成
+ $info->qr_code = '';
+ return (bool)$info->save();
+ }
+
+ /**
+ * 群池状态
+ * @return array
+ */
+ public static function stat(): array
+ {
+ return [
+ 'total' => self::$Model::count(),
+ 'free' => self::where('demand_id', 0)->count(),
+ 'used' => self::where('demand_id', '>', 0)->count(),
+ 'no_qrcode' => self::where('qr_code', '')->count(),
+ ];
+ }
+
}
\ No newline at end of file
diff --git a/backend/deploy/cron.d/code-bang b/backend/deploy/cron.d/code-bang
index 5fef29f..fa511da 100644
--- a/backend/deploy/cron.d/code-bang
+++ b/backend/deploy/cron.d/code-bang
@@ -13,3 +13,6 @@ PHP=/usr/local/bin/php
# 确认收货超时(7 天自动收货)+ 订单收益解冻
*/10 * * * * nginx cd ${APP_ROOT} && flock -n /tmp/cb-demand.lock ${PHP} think demand:deal >> ${APP_ROOT}/runtime/cron/demand-deal-$(date +\%Y\%m).log 2>&1
+# 沟通群二维码续期(刷新所有已分配群的二维码,不会占用新的群)
+0 3 * * * nginx cd ${APP_ROOT} && flock -n /tmp/cb-groupchat.lock ${PHP} think groupChat:manage refresh >> ${APP_ROOT}/runtime/cron/groupchat-manage-$(date +\%Y\%m).log 2>&1
+
diff --git a/frontend/miniapp/pages/demand/list/index.js b/frontend/miniapp/pages/demand/list/index.js
index 8b64ec4..edcc20d 100644
--- a/frontend/miniapp/pages/demand/list/index.js
+++ b/frontend/miniapp/pages/demand/list/index.js
@@ -17,6 +17,7 @@ Page({
freshPage: false,
fromPreview: false, // 预览锁
hasMore: true, // 新增字段,标记是否还有更多数据
+ refreshing: false, // 刷新按钮是否处于加载中
tabList: [
{ text: '全部', key: -1 },
{ text: '招标中', key: 1 },
@@ -59,6 +60,13 @@ Page({
}
this.loadDemandList(true);
},
+ // 点击右侧刷新按钮
+ async onRefreshTap() {
+ if (this.data.refreshing) return;
+ this.setData({ refreshing: true });
+ await this.loadDemandList(true);
+ this.setData({ refreshing: false });
+ },
//图片预览
onImageView(e) {
const { images, index } = e.currentTarget.dataset;
@@ -77,7 +85,8 @@ Page({
},
// 加载需求列表数据
- async loadDemandList(fresh = false) {
+ async loadDemandList(fresh = false, options = {}) {
+ const { silent = false } = options;
if (fresh) {
this.setData({
page: 1,
@@ -95,14 +104,19 @@ Page({
try {
const { page, status, keywords, opType } = this.data;
let params = { page, status, keywords, opType };
- wx.showLoading({
- title: '加载中',
- });
+ // 下拉刷新自带 loading 指示,不再叠加 showLoading
+ if (!silent) {
+ wx.showLoading({
+ title: '加载中',
+ });
+ }
const res = await fetchDemandList(params);
const nextList = res.data.data;
console.log(res)
console.log(nextList)
- wx.hideLoading();
+ if (!silent) {
+ wx.hideLoading();
+ }
// 判断是否还有更多数据
const { current_page, last_page } = res.data;
@@ -218,22 +232,26 @@ Page({
return;
}
const bidDemandId = wx.getStorageSync('bidDemandId');
- console.log('bidDemandId:', bidDemandId);
if (bidDemandId) {
- const index = this.data.demandList.findIndex(item => item.id === Number(bidDemandId));
- console.log('bidDemandId-index:', index);
- index >= 0 && this.setData({
- [`demandList[${index}].is_bid`]: true,
- [`demandList[${index}].btnName`]: '已投标',
- [`demandList[${index}].demand_biddings_count`]: this.data.demandList[index].demand_biddings_count + 1
- });
+ // 先清除标记,避免同一条被重复处理
wx.removeStorageSync('bidDemandId');
- } else {
- if (this.data.freshPage) {
- this.setData({ freshPage: false });
- console.log('freshing');
+ const index = this.data.demandList.findIndex(item => item.id === Number(bidDemandId));
+ if (index < 0) {
+ // 当前列表里没有这条(例如「我投标的需求」页),交给接口重新拉取
this.loadDemandList(true);
+ } else {
+ this.setData({
+ [`demandList[${index}].is_bid`]: true,
+ // 按钮渲染的是 item.button.name,只改 btnName 不会刷新文案
+ [`demandList[${index}].button`]: { name: '已投标', theme: 'light' },
+ [`demandList[${index}].demand_biddings_count`]: this.data.demandList[index].demand_biddings_count + 1,
+ });
}
+ return;
+ }
+ if (this.data.freshPage) {
+ this.setData({ freshPage: false });
+ this.loadDemandList(true);
}
},
@@ -254,8 +272,9 @@ Page({
/**
* 页面相关事件处理函数--监听用户下拉动作
*/
- onPullDownRefresh() {
- this.loadDemandList(true);
+ async onPullDownRefresh() {
+ await this.loadDemandList(true, { silent: true });
+ wx.stopPullDownRefresh();
},
/**
diff --git a/frontend/miniapp/pages/demand/list/index.json b/frontend/miniapp/pages/demand/list/index.json
index 9440289..d9c1a6b 100644
--- a/frontend/miniapp/pages/demand/list/index.json
+++ b/frontend/miniapp/pages/demand/list/index.json
@@ -1,4 +1,7 @@
{
+ "enablePullDownRefresh": true,
+ "backgroundTextStyle": "dark",
+ "onReachBottomDistance": 20,
"usingComponents": {
"t-search": "tdesign-miniprogram/search/search",
"t-loading": "tdesign-miniprogram/loading/loading",
diff --git a/frontend/miniapp/pages/demand/list/index.wxml b/frontend/miniapp/pages/demand/list/index.wxml
index 33334ca..9d5d794 100644
--- a/frontend/miniapp/pages/demand/list/index.wxml
+++ b/frontend/miniapp/pages/demand/list/index.wxml
@@ -1,11 +1,25 @@
-
-
-
-
+
+
+
+
+
+
diff --git a/frontend/miniapp/pages/demand/list/index.wxss b/frontend/miniapp/pages/demand/list/index.wxss
index 2cde476..2307d95 100644
--- a/frontend/miniapp/pages/demand/list/index.wxss
+++ b/frontend/miniapp/pages/demand/list/index.wxss
@@ -22,15 +22,70 @@ page {
padding: 0 24rpx;
}
-.home-page-header .t-search__input-box {
- border-radius: 32rpx !important;
- height: 64rpx !important;
- margin-bottom: 8rpx;
+/* ===== 搜索区 ===== */
+.search-bar {
+ display: flex;
+ align-items: center;
+ padding: 18rpx 0 12rpx;
+ gap: 16rpx;
}
-.home-page-header .t-search__input {
+.search-bar__search {
+ flex: 1;
+ min-width: 0;
+ padding: 0 !important;
+ background: transparent !important;
+}
+
+.search-bar__box {
+ height: 68rpx !important;
+ border-radius: 34rpx !important;
+ background: #fff !important;
+ box-shadow: 0 6rpx 20rpx rgba(0, 0, 0, 0.05);
+}
+
+.search-bar__input {
font-size: 28rpx !important;
- color: rgb(116, 116, 116) !important;
+ color: var(--text-primary) !important;
+}
+
+.search-bar__left {
+ display: flex;
+ align-items: center;
+ margin-right: 4rpx;
+}
+
+/* ===== 刷新按钮 ===== */
+.refresh-btn {
+ flex-shrink: 0;
+ width: 68rpx;
+ height: 68rpx;
+ border-radius: 50%;
+ background: #fff;
+ box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.06);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: transform 0.15s ease, opacity 0.15s ease;
+}
+
+.refresh-btn--hover {
+ transform: scale(0.92);
+ opacity: 0.85;
+}
+
+.refresh-btn--loading {
+ animation: refresh-spin 0.9s linear infinite;
+}
+
+@keyframes refresh-spin {
+ from {
+ transform: rotate(0deg);
+ }
+
+ to {
+ transform: rotate(360deg);
+ }
}
.home-page-header .swiper-wrap {
diff --git a/frontend/miniapp/pages/home/home.js b/frontend/miniapp/pages/home/home.js
index 8ccd5c2..9a42869 100644
--- a/frontend/miniapp/pages/home/home.js
+++ b/frontend/miniapp/pages/home/home.js
@@ -38,24 +38,14 @@ Page({
onShow() {
this.getTabBar().init();
- setTimeout(() => {
- fetchPerson().then((res) => {
- if (res.code === 1) {
- let { role } = res.data;
- this.setData({
- role,
- });
- }
- });
- }, 3000);
+ this.refreshRole();
},
onLoad() {
+ // 先用本地缓存渲染入口按钮,避免接口返回前闪烁;随后由 onShow 用接口数据校正
+ const role = (wx.getStorageSync('userInfo') || {}).role || '';
+ this.setData({ role });
this.init();
- let role = wx.getStorageSync('userInfo')?.role;
- if (role == 'dev') {
- this.toDemandHall();
- }
},
onReachBottom() {
@@ -65,12 +55,31 @@ Page({
onPullDownRefresh() {
this.init();
+ this.refreshRole();
},
init() {
this.loadHomePage();
},
+ /**
+ * 拉取最新角色并写回本地缓存
+ * 入驻审核通过后,用户回到小程序触发 onShow 即可看到「进入接单大厅」入口
+ */
+ refreshRole() {
+ return fetchPerson().then((res) => {
+ if (res.code === 1 && res.data) {
+ const role = res.data.role || '';
+ this.setData({ role });
+ const userInfo = wx.getStorageSync('userInfo');
+ if (userInfo && userInfo.role !== role) {
+ userInfo.role = role;
+ wx.setStorageSync('userInfo', userInfo);
+ }
+ }
+ });
+ },
+
loadHomePage() {
wx.stopPullDownRefresh();
this.setData({