From 34177dfe861c2683433ce410f615194b7eee3392 Mon Sep 17 00:00:00 2001 From: Dev Date: Tue, 29 Sep 2026 16:24:19 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=86=E9=85=8D=E5=AE=A2=E6=88=B7=E7=BE=A4?= =?UTF-8?q?=E4=BA=8C=E7=BB=B4=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/application/command.php | 2 +- .../application/common/command/GroupChat.php | 173 ++++++++++++++++++ .../common/command/GroupChatFetch.php | 24 --- .../common/service/DemandGroupChatService.php | 141 ++++++++++++-- backend/deploy/cron.d/code-bang | 3 + frontend/miniapp/pages/demand/list/index.js | 57 ++++-- frontend/miniapp/pages/demand/list/index.json | 3 + frontend/miniapp/pages/demand/list/index.wxml | 22 ++- frontend/miniapp/pages/demand/list/index.wxss | 67 ++++++- frontend/miniapp/pages/home/home.js | 37 ++-- 10 files changed, 445 insertions(+), 84 deletions(-) create mode 100644 backend/application/common/command/GroupChat.php delete mode 100644 backend/application/common/command/GroupChatFetch.php 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({