index.vue 60 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665
  1. <template>
  2. <div class="page-container">
  3. <el-card shadow="never" class="table-card">
  4. <template #header>
  5. <div class="card-header">
  6. <div class="left-panel">
  7. <span class="title">履约者池</span>
  8. <el-tag type="info" effect="plain" style="margin-left: 10px;">共 {{ total }} 人</el-tag>
  9. </div>
  10. <div class="right-panel">
  11. <el-button type="success" icon="Download" @click="handleExport" v-hasPermi="['fulfiller:pool:exportExcel']">导出Excel</el-button>
  12. <el-button type="primary" icon="Plus" style="margin-right: 16px" @click="handleCreate" v-hasPermi="['fulfiller:pool:add']">新增履约者</el-button>
  13. <el-input v-model="searchKey" placeholder="搜索姓名/手机号/身份证" class="search-input" prefix-icon="Search" clearable
  14. @keyup.enter="handleSearch" @clear="handleSearch" />
  15. <el-cascader
  16. v-model="filterCascaderValue"
  17. :options="areaTreeOptions"
  18. :props="{ checkStrictly: true, value: 'id', label: 'name' }"
  19. placeholder="所属站点"
  20. clearable
  21. style="width: 350px; margin-left: 10px;"
  22. @change="handleFilterCascaderChange"
  23. />
  24. </div>
  25. </div>
  26. <!-- Tab切换 (无图标) -->
  27. <el-tabs v-model="activeTab" class="status-tabs" @tab-click="handleTabClick">
  28. <el-tab-pane label="全部" name="all" />
  29. <el-tab-pane label="休息" name="resting" />
  30. <el-tab-pane label="接单中" name="busy" />
  31. <el-tab-pane label="禁用" name="disabled" />
  32. </el-tabs>
  33. </template>
  34. <el-table v-loading="loading" :data="tableData" style="width: 100%"
  35. :header-cell-style="{ background: '#f5f7fa' }">
  36. <el-table-column label="基本信息" width="280">
  37. <template #default="scope">
  38. <div class="user-info">
  39. <el-avatar :size="45" :src="scope.row.avatarUrl">{{ scope.row.name.charAt(0) }}</el-avatar>
  40. <div class="text-col">
  41. <div class="name-row">
  42. <span class="name">{{ scope.row.name }}</span>
  43. <span class="gender-tag">
  44. <el-icon v-if="scope.row.gender === '0'" color="#409eff">
  45. <Male />
  46. </el-icon>
  47. <el-icon v-else color="#f56c6c">
  48. <Female />
  49. </el-icon>
  50. </span>
  51. </div>
  52. <div class="tags-row" style="margin: 3px 0">
  53. <!-- work type -->
  54. <el-tag size="small" :type="scope.row.workType === 'full_time' ? 'warning' : 'info'" effect="light"
  55. style="margin-right: 5px">
  56. {{ scope.row.workType === 'full_time' ? '全职专送' : '兼职众包' }}
  57. </el-tag>
  58. <!-- 等级展示 -->
  59. <el-tag size="small" :type="getLevelType(scope.row.levelName)" effect="plain" class="level-tag">
  60. {{ getLevelText(scope.row.levelName) }}
  61. </el-tag>
  62. </div>
  63. <div class="sub-text">{{ scope.row.age }}岁 | {{ scope.row.phone }}</div>
  64. </div>
  65. </div>
  66. </template>
  67. </el-table-column>
  68. <el-table-column label="资质信息" width="220">
  69. <template #default="scope">
  70. <div class="auth-row">
  71. <div class="auth-card" :class="{ active: scope.row.authId }">
  72. <el-icon>
  73. <Postcard />
  74. </el-icon> 身份证
  75. </div>
  76. <div class="auth-card" :class="{ active: scope.row.authQual }">
  77. <el-icon>
  78. <Medal />
  79. </el-icon> 资质证
  80. </div>
  81. </div>
  82. <div style="margin-top:5px;">
  83. <el-tag v-if="scope.row.authId" type="success" size="small" effect="plain">已认证</el-tag>
  84. <el-tag v-else type="info" size="small" effect="plain">未认证</el-tag>
  85. </div>
  86. <div class="sub-text" style="margin-top:5px;">ID: {{ scope.row.idCard }}</div>
  87. </template>
  88. </el-table-column>
  89. <el-table-column label="服务区域" width="180">
  90. <template #default="scope">
  91. <div class="text-col">
  92. <span style="font-size: 13px; color: #333;">{{ getStationPathText(scope.row.stationId).cityAndRegion }}</span>
  93. <span style="font-size: 12px; color: #999;">{{ getStationPathText(scope.row.stationId).station }}</span>
  94. </div>
  95. </template>
  96. </el-table-column>
  97. <el-table-column label="技能标签" min-width="180">
  98. <template #default="scope">
  99. <el-tag v-for="tag in scope.row.tags" :key="tag.id" :type="tag.colorType" size="small" class="skill-tag"
  100. effect="plain">
  101. {{ tag.name }}
  102. </el-tag>
  103. </template>
  104. </el-table-column>
  105. <el-table-column label="订单数据" width="180">
  106. <template #default="scope">
  107. <div class="finance-item">服务单: <span class="num">{{ scope.row.orderCount }}</span></div>
  108. <div class="finance-item">拒/转单: <span class="num error">{{ scope.row.rejectCount }}</span></div>
  109. </template>
  110. </el-table-column>
  111. <el-table-column label="账户资产" width="160">
  112. <template #default="scope">
  113. <div class="finance-item">积分: <span class="num">{{ scope.row.points }}</span></div>
  114. <div class="finance-item">余额: <span class="num">¥{{ (scope.row.balance / 100).toFixed(2) }}</span></div>
  115. </template>
  116. </el-table-column>
  117. <el-table-column prop="status" label="状态" width="100">
  118. <template #default="scope">
  119. <div class="status-cell">
  120. <div class="status-dot" :class="scope.row.status"></div>
  121. <span class="status-text">{{ getStatusText(scope.row.status) }}</span>
  122. </div>
  123. </template>
  124. </el-table-column>
  125. <el-table-column label="操作" width="240" fixed="right">
  126. <template #default="scope">
  127. <div class="op-cell">
  128. <el-button link type="primary" size="small" @click="handleDetail(scope.row)" v-hasPermi="['fulfiller:pool:query']">详情</el-button>
  129. <el-button link type="primary" size="small" @click="handleEdit(scope.row)" v-hasPermi="['fulfiller:pool:edit']">编辑</el-button>
  130. <el-button link type="warning" size="small" @click="handleReward(scope.row)" v-hasPermi="['fulfiller:pool:reward']">奖惩</el-button>
  131. <el-dropdown trigger="click" @command="(cmd) => handleCommand(cmd, scope.row)">
  132. <el-button link type="primary">更多<el-icon class="el-icon--right"><arrow-down /></el-icon></el-button>
  133. <template #dropdown>
  134. <el-dropdown-menu>
  135. <el-dropdown-item command="adjustPoints" v-hasPermi="['fulfiller:pool:editScore']">修改积分</el-dropdown-item>
  136. <el-dropdown-item command="adjustBalance" v-hasPermi="['fulfiller:pool:editBalance']">余额增减</el-dropdown-item>
  137. <el-dropdown-item v-if="scope.row.status !== 'disabled'" command="disable" divided
  138. style="color: #f56c6c" v-hasPermi="['fulfiller:pool:disable']">禁用账号</el-dropdown-item>
  139. <el-dropdown-item v-else command="enable" divided style="color: #67c23a" v-hasPermi="['fulfiller:pool:enable']">启用账号</el-dropdown-item>
  140. <el-dropdown-item command="violation" v-hasPermi="['fulfiller:pool:violationLog']">违规记录</el-dropdown-item>
  141. <el-dropdown-item command="resetPwd" v-hasPermi="['fulfiller:pool:resetPassword']">重置密码</el-dropdown-item>
  142. </el-dropdown-menu>
  143. </template>
  144. </el-dropdown>
  145. </div>
  146. </template>
  147. </el-table-column>
  148. </el-table>
  149. <div class="pagination-container">
  150. <el-pagination v-model:current-page="queryParams.pageNum" v-model:page-size="queryParams.pageSize"
  151. :page-sizes="[10, 20, 50, 100]" layout="total, sizes, prev, pager, next, jumper" :total="total"
  152. @size-change="handleSizeChange" @current-change="handleCurrentChange" />
  153. </div>
  154. </el-card>
  155. <!-- 详情侧边栏 Drawer -->
  156. <el-drawer v-model="detailVisible" title="履约者档案详情" size="750px" direction="rtl" custom-class="detail-drawer">
  157. <div class="drawer-content" v-if="currentItem">
  158. <!-- 头部概览 -->
  159. <div class="user-header-card">
  160. <el-avatar :size="70" :src="currentItem.avatarUrl" class="header-avatar">{{ currentItem.name?.charAt(0)
  161. }}</el-avatar>
  162. <div class="header-info">
  163. <div class="top-row">
  164. <span class="user-name">{{ currentItem.name }}</span>
  165. <el-tag size="small" :type="currentItem.gender === '0' ? '' : 'danger'" effect="plain" round
  166. style="margin-left: 8px;">
  167. {{ currentItem.gender === '0' ? '男' : '女' }} {{ currentItem.age }}岁
  168. </el-tag>
  169. <span class="status-badge" :class="currentItem.status">{{ getStatusText(currentItem.status) }}</span>
  170. </div>
  171. <div class="sub-row">
  172. <span class="info-item"><el-icon>
  173. <Iphone />
  174. </el-icon> {{ currentItem.phone }}</span>
  175. <span class="divider">|</span>
  176. <span class="info-item"><el-icon>
  177. <Location />
  178. </el-icon> {{ currentItem ? getStationPathText(currentItem.stationId).cityAndRegion : '-' }}/{{ currentItem ? getStationPathText(currentItem.stationId).station : '-' }}</span>
  179. </div>
  180. <div class="tags-row">
  181. <el-tag size="small" :type="getLevelType(currentItem.levelName)" effect="dark">{{
  182. getLevelText(currentItem.levelName) }}</el-tag>
  183. <el-tag size="small" type="warning" effect="plain" v-if="currentItem.workType === 'full_time'"
  184. style="margin-left:5px">全职专送</el-tag>
  185. </div>
  186. </div>
  187. </div>
  188. <!-- 核心数据指标 -->
  189. <div class="data-metrics-row">
  190. <div class="metric-item">
  191. <div class="val text-primary">{{ currentItem.points }}</div>
  192. <div class="lbl">当前积分</div>
  193. </div>
  194. <div class="divider-v"></div>
  195. <div class="metric-item">
  196. <div class="val text-danger">¥{{ (currentItem.balance / 100).toFixed(2) }}</div>
  197. <div class="lbl">账户余额</div>
  198. </div>
  199. <div class="divider-v"></div>
  200. <div class="metric-item">
  201. <div class="val">{{ currentItem.orderCount }}</div>
  202. <div class="lbl">服务单量</div>
  203. </div>
  204. <div class="divider-v"></div>
  205. <div class="metric-item">
  206. <div class="val text-warning">{{ currentItem.rejectCount || 0 }}</div>
  207. <div class="lbl">拒绝单量</div>
  208. </div>
  209. </div>
  210. <el-tabs v-model="activeDetailTab" class="detail-tabs">
  211. <el-tab-pane label="档案概览" name="info">
  212. <div class="tab-content-wrapper">
  213. <div class="section-block">
  214. <div class="section-title">基础信息</div>
  215. <el-descriptions :column="2" border>
  216. <el-descriptions-item label="身份证号">{{ currentItem.idCard }}</el-descriptions-item>
  217. <el-descriptions-item label="真实姓名">{{ currentItem.realName || currentItem.name
  218. }}</el-descriptions-item>
  219. <el-descriptions-item label="归属站点">{{ currentItem ? getStationPathText(currentItem.stationId).station : '-' }}</el-descriptions-item>
  220. <el-descriptions-item label="证件有效期">{{ currentItem.idCardExpiry || '-' }}</el-descriptions-item>
  221. <el-descriptions-item label="入驻时间">{{ currentItem.createTime }}</el-descriptions-item>
  222. <el-descriptions-item label="工作性质">{{ currentItem.workType === 'full_time' ? '全职' : '兼职'
  223. }}</el-descriptions-item>
  224. </el-descriptions>
  225. </div>
  226. <div class="section-block">
  227. <div class="section-title">实名认证</div>
  228. <div class="cert-row">
  229. <div class="cert-item" @click="handleViewImage(currentItem.idCardFrontUrl)">
  230. <el-image :src="currentItem.idCardFrontUrl || ''" fit="cover" class="cert-img">
  231. <template #error>
  232. <div class="img-slot"><el-icon>
  233. <Picture />
  234. </el-icon></div>
  235. </template>
  236. </el-image>
  237. <div class="cert-name">身份证人像面</div>
  238. </div>
  239. <div class="cert-item" @click="handleViewImage(currentItem.idCardBackUrl)">
  240. <el-image :src="currentItem.idCardBackUrl || ''" fit="cover" class="cert-img">
  241. <template #error>
  242. <div class="img-slot"><el-icon>
  243. <Picture />
  244. </el-icon></div>
  245. </template>
  246. </el-image>
  247. <div class="cert-name">身份证国徽面</div>
  248. </div>
  249. </div>
  250. </div>
  251. <div class="section-block">
  252. <div class="section-title">资质认证</div>
  253. <div class="cert-row" v-if="qualImageUrlList.length">
  254. <div class="cert-item" v-for="(img, index) in qualImageUrlList" :key="index"
  255. @click="handleViewImage(img)">
  256. <el-image :src="img" fit="cover" class="cert-img">
  257. <template #error>
  258. <div class="img-slot"><el-icon>
  259. <Picture />
  260. </el-icon></div>
  261. </template>
  262. </el-image>
  263. </div>
  264. </div>
  265. <div v-else class="empty-text">暂无资质图片</div>
  266. </div>
  267. <div class="section-block">
  268. <div class="section-title">技能标签</div>
  269. <div class="tag-list">
  270. <el-tag v-for="tag in currentItem.tags" :key="tag.id" :type="tag.colorType" size="large"
  271. style="margin-right: 12px; margin-bottom: 8px;">{{ tag.name }}</el-tag>
  272. </div>
  273. </div>
  274. </div>
  275. </el-tab-pane>
  276. <el-tab-pane label="服务订单" name="orders">
  277. <div class="tab-content-wrapper">
  278. <el-table v-loading="logLoading" :data="serviceOrderData" stripe style="width: 100%"
  279. :header-cell-style="{ background: '#f5f7fa', color: '#606266' }">
  280. <el-table-column prop="code" label="订单号" width="160" show-overflow-tooltip />
  281. <el-table-column label="服务项目" show-overflow-tooltip>
  282. <template #default="{ row }">
  283. {{ getServiceName(row.service) }}
  284. </template>
  285. </el-table-column>
  286. <el-table-column prop="price" label="收入" width="100">
  287. <template #default="{ row }">
  288. <span style="color: #67c23a; font-weight: bold; font-size: 15px;">+{{ (row.price / 100).toFixed(2)
  289. }}</span>
  290. </template>
  291. </el-table-column>
  292. <el-table-column prop="serviceTime" label="时间" width="160" show-overflow-tooltip />
  293. <el-table-column prop="status" label="状态" width="90">
  294. <template #default="{ row }">
  295. <el-tag :type="getSubOrderStatusType(row.status)" size="small">
  296. {{ getSubOrderStatusName(row.status) }}
  297. </el-tag>
  298. </template>
  299. </el-table-column>
  300. </el-table>
  301. </div>
  302. </el-tab-pane>
  303. <el-tab-pane label="积分记录" name="pointLogs">
  304. <div class="tab-content-wrapper">
  305. <el-table v-loading="logLoading" :data="pointsLogData" stripe style="width: 100%"
  306. :header-cell-style="{ background: '#f5f7fa', color: '#606266' }">
  307. <el-table-column prop="createTime" label="变动时间" width="180" />
  308. <el-table-column prop="bizType" label="业务类型" width="120">
  309. <template #default="{ row }">
  310. <el-tag :type="getPointsBizTypeTag(row.bizType)" size="small" effect="plain">{{
  311. getPointsBizTypeName(row.bizType) }}</el-tag>
  312. </template>
  313. </el-table-column>
  314. <el-table-column prop="amount" label="变动数值" width="120">
  315. <template #default="{ row }">
  316. <span
  317. :style="{ color: ['reward', fulfillerEnums.ActionType.ADD].includes(row.type) ? '#67c23a' : '#f56c6c', fontWeight: 'bold', fontSize: '15px' }">
  318. {{ ['reward', fulfillerEnums.ActionType.ADD].includes(row.type) ? '+' : '-' }}{{ row.amount }}
  319. </span>
  320. </template>
  321. </el-table-column>
  322. <el-table-column prop="reason" label="变动原因" show-overflow-tooltip />
  323. <el-table-column prop="operatorId" label="操作人" width="120" />
  324. </el-table>
  325. </div>
  326. </el-tab-pane>
  327. <el-tab-pane label="余额变动" name="balanceLogs">
  328. <div class="tab-content-wrapper">
  329. <el-table v-loading="logLoading" :data="balanceLogData" stripe style="width: 100%"
  330. :header-cell-style="{ background: '#f5f7fa', color: '#606266' }">
  331. <el-table-column prop="createTime" label="变动时间" width="180" show-overflow-tooltip />
  332. <el-table-column prop="subType" label="资金类型" width="120">
  333. <template #default="{ row }">
  334. <el-tag :type="getBalanceBizTypeTag(row.subType)" size="small" effect="plain">{{
  335. getBalanceBizTypeName(row.bizType) }}</el-tag>
  336. </template>
  337. </el-table-column>
  338. <el-table-column prop="amount" label="变动金额" width="120">
  339. <template #default="{ row }">
  340. <span
  341. :style="{ color: ['reward', fulfillerEnums.ActionType.ADD].includes(row.type) ? '#67c23a' : '#f56c6c', fontWeight: 'bold', fontSize: '15px' }">
  342. {{ ['reward', fulfillerEnums.ActionType.ADD].includes(row.type) ? '+' : '-' }}{{ (row.amount /
  343. 100).toFixed(2) }}
  344. </span>
  345. </template>
  346. </el-table-column>
  347. <el-table-column prop="balanceAfter" label="变动后余额" width="120">
  348. <template #default="{ row }">
  349. <span>¥{{ (row.balanceAfter / 100).toFixed(2) }}</span>
  350. </template>
  351. </el-table-column>
  352. <el-table-column prop="reason" label="备注说明" show-overflow-tooltip />
  353. <el-table-column prop="operatorId" label="操作人" width="100" />
  354. </el-table>
  355. </div>
  356. </el-tab-pane>
  357. <el-tab-pane label="奖惩记录" name="rewards">
  358. <div class="tab-content-wrapper">
  359. <el-table v-loading="logLoading" :data="rewardLogData" stripe style="width: 100%"
  360. :header-cell-style="{ background: '#f5f7fa', color: '#606266' }">
  361. <el-table-column prop="createTime" label="操作时间" width="180" />
  362. <el-table-column prop="bizType" label="奖惩类型" width="100">
  363. <template #default="{ row }">
  364. <el-tag :type="fulfillerEnums.RewardBizType[row.bizType]?.tagType || 'info'" size="small"
  365. effect="plain">
  366. {{ getRewardBizTypeName(row.bizType) }}
  367. </el-tag>
  368. </template>
  369. </el-table-column>
  370. <el-table-column prop="target" label="关联项目" width="100">
  371. <template #default="{ row }">
  372. <el-tag type="info" size="small" effect="plain">{{ row.target === 'points' ? '积分' : '余额' }}</el-tag>
  373. </template>
  374. </el-table-column>
  375. <el-table-column prop="amount" label="涉及数值" width="120">
  376. <template #default="{ row }">
  377. <span
  378. :style="{ color: ['reward', fulfillerEnums.ActionType.ADD].includes(row.type) ? '#67c23a' : '#f56c6c', fontWeight: 'bold' }">
  379. {{ ['reward', fulfillerEnums.ActionType.ADD].includes(row.type) ? '+' : '-' }}{{ row.target ===
  380. 'balance' ? (row.amount / 100).toFixed(2) : row.amount }} {{ row.target === 'points' ? '分' : '元'
  381. }}
  382. </span>
  383. </template>
  384. </el-table-column>
  385. <el-table-column prop="reason" label="奖惩原因" show-overflow-tooltip />
  386. <el-table-column prop="operatorName" label="操作人" width="100" />
  387. </el-table>
  388. </div>
  389. </el-tab-pane>
  390. <el-tab-pane label="违规记录" name="violation">
  391. <div class="tab-content-wrapper">
  392. <el-table v-loading="logLoading" :data="violationLogData" stripe style="width: 100%"
  393. :header-cell-style="{ background: '#f5f7fa', color: '#606266' }">
  394. <el-table-column prop="violationTime" label="违规时间" width="180">
  395. </el-table-column>
  396. <el-table-column prop="count" label="违规次数" width="100" />
  397. <el-table-column prop="reason" label="违规原因" show-overflow-tooltip />
  398. <!-- <el-table-column prop="operatorName" label="操作人" width="100" />-->
  399. </el-table>
  400. </div>
  401. </el-tab-pane>
  402. <el-tab-pane label="投诉记录" name="complaints">
  403. <div class="tab-content-wrapper">
  404. <el-table v-loading="logLoading" :data="complaintLogData" stripe style="width: 100%"
  405. :header-cell-style="{ background: '#f5f7fa', color: '#606266' }">
  406. <el-table-column prop="createTime" label="投诉时间" width="180" />
  407. <el-table-column prop="orderCode" label="订单号" width="160" show-overflow-tooltip />
  408. <el-table-column prop="reason" label="投诉原因" show-overflow-tooltip />
  409. <!-- <el-table-column prop="createBy" label="操作人" width="100" />-->
  410. </el-table>
  411. <div style="margin-top: 20px; display: flex; justify-content: flex-end;">
  412. <el-pagination
  413. v-model:current-page="complaintPagination.pageNum"
  414. v-model:page-size="complaintPagination.pageSize"
  415. :page-sizes="[10, 20, 50]"
  416. layout="total, sizes, prev, pager, next"
  417. :total="complaintPagination.total"
  418. @size-change="loadComplaintLogs"
  419. @current-change="loadComplaintLogs"
  420. />
  421. </div>
  422. </div>
  423. </el-tab-pane>
  424. </el-tabs>
  425. </div>
  426. </el-drawer>
  427. <!-- 编辑弹窗 -->
  428. <el-dialog v-model="editDialog.visible" title="编辑履约者" width="600px" top="5vh">
  429. <el-form :model="editDialog.form" label-width="90px">
  430. <el-row :gutter="20">
  431. <el-col :span="12">
  432. <el-form-item label="姓名" required>
  433. <el-input v-model="editDialog.form.name" />
  434. </el-form-item>
  435. </el-col>
  436. <el-col :span="12">
  437. <el-form-item label="手机号" required>
  438. <el-input v-model="editDialog.form.phone" />
  439. </el-form-item>
  440. </el-col>
  441. </el-row>
  442. <el-form-item label="登录密码">
  443. <el-input v-model="editDialog.form.password" type="password" placeholder="不修改请留空" show-password />
  444. </el-form-item>
  445. <el-row :gutter="20">
  446. <el-col :span="12">
  447. <el-form-item label="性别">
  448. <el-radio-group v-model="editDialog.form.gender">
  449. <el-radio label="0">男</el-radio>
  450. <el-radio label="1">女</el-radio>
  451. </el-radio-group>
  452. </el-form-item>
  453. </el-col>
  454. <el-col :span="12">
  455. <el-form-item label="身份证号">
  456. <el-input v-model="editDialog.form.idCard" />
  457. </el-form-item>
  458. </el-col>
  459. </el-row>
  460. <el-form-item label="所属站点">
  461. <el-cascader v-model="editDialog.cascaderValue" :options="areaTreeOptions"
  462. :props="{ value: 'id', label: 'name' }" placeholder="请选择站点" clearable style="width: 100%"
  463. @change="handleEditCascaderChange" />
  464. </el-form-item>
  465. <el-row :gutter="20">
  466. <el-col :span="12">
  467. <el-form-item label="等级">
  468. <el-input v-model="editDialog.form.level" placeholder="等级" />
  469. </el-form-item>
  470. </el-col>
  471. <el-col :span="12">
  472. <el-form-item label="当前状态">
  473. <el-select v-model="editDialog.form.status" style="width: 100%">
  474. <el-option label="接单中" value="busy" />
  475. <el-option label="休息" value="resting" />
  476. <el-option label="禁用" value="disabled" />
  477. </el-select>
  478. </el-form-item>
  479. </el-col>
  480. </el-row>
  481. <el-form-item label="技能标签">
  482. <el-select v-model="editDialog.form.tagIds" multiple placeholder="请选择技能标签" style="width: 100%">
  483. <el-option v-for="tag in allTags" :key="tag.id" :label="tag.name" :value="tag.id" />
  484. </el-select>
  485. </el-form-item>
  486. <el-form-item label="服务类型">
  487. <el-select v-model="editDialog.serviceTypeIds" multiple placeholder="请选择服务类型" style="width: 100%">
  488. <el-option v-for="item in serviceOptions" :key="item.id" :label="item.name" :value="item.id" />
  489. </el-select>
  490. </el-form-item>
  491. <el-form-item label="认证状态">
  492. <el-checkbox v-model="editDialog.form.authId">身份证认证</el-checkbox>
  493. <el-checkbox v-model="editDialog.form.authQual">专业资质认证</el-checkbox>
  494. </el-form-item>
  495. </el-form>
  496. <template #footer>
  497. <span class="dialog-footer">
  498. <el-button @click="editDialog.visible = false">取消</el-button>
  499. <el-button type="primary" @click="saveEdit">保存变更</el-button>
  500. </span>
  501. </template>
  502. </el-dialog>
  503. <!-- 奖惩弹窗 -->
  504. <el-dialog v-model="rewardDialog.visible" title="人工奖惩操作" width="450px">
  505. <div class="user-preview-box">
  506. 当前操作对象:<b>{{ rewardDialog.userName }}</b>
  507. </div>
  508. <el-form :model="rewardDialog.form" label-width="80px" style="margin-top: 20px;">
  509. <el-form-item label="操作类型">
  510. <el-radio-group v-model="rewardDialog.form.type">
  511. <el-radio :label="fulfillerEnums.ActionType.ADD">增加</el-radio>
  512. <el-radio :label="fulfillerEnums.ActionType.REDUCE">减少</el-radio>
  513. </el-radio-group>
  514. </el-form-item>
  515. <el-form-item label="调整项目">
  516. <el-radio-group v-model="rewardDialog.form.target">
  517. <el-radio label="points">积分</el-radio>
  518. <el-radio label="balance">金额 (元)</el-radio>
  519. </el-radio-group>
  520. </el-form-item>
  521. <el-form-item label="数额" required>
  522. <el-input-number v-model="rewardDialog.form.amount" :min="1" />
  523. </el-form-item>
  524. <el-form-item label="原因备注" required>
  525. <el-input v-model="rewardDialog.form.reason" type="textarea" placeholder="请输入奖惩原因..." />
  526. </el-form-item>
  527. </el-form>
  528. <template #footer>
  529. <span class="dialog-footer">
  530. <el-button @click="rewardDialog.visible = false">取消</el-button>
  531. <el-button type="primary" @click="submitReward">确认执行</el-button>
  532. </span>
  533. </template>
  534. </el-dialog>
  535. <!-- 新增履约者弹窗 -->
  536. <el-dialog v-model="createDialog.visible" title="新增履约者" width="500px">
  537. <el-form :model="createDialog.form" label-width="80px">
  538. <el-form-item label="姓名" required>
  539. <el-input v-model="createDialog.form.name" placeholder="请输入真实姓名" />
  540. </el-form-item>
  541. <el-form-item label="手机号" required>
  542. <el-input v-model="createDialog.form.phone" placeholder="作为登录账号" />
  543. </el-form-item>
  544. <el-form-item label="登录密码" required>
  545. <el-input v-model="createDialog.form.password" show-password placeholder="设置初始密码" />
  546. </el-form-item>
  547. <el-form-item label="性别">
  548. <el-radio-group v-model="createDialog.form.gender">
  549. <el-radio label="0">男</el-radio>
  550. <el-radio label="1">女</el-radio>
  551. </el-radio-group>
  552. </el-form-item>
  553. <el-form-item label="所属站点">
  554. <el-cascader v-model="createDialog.cascaderValue" :options="areaTreeOptions"
  555. :props="{ value: 'id', label: 'name' }" placeholder="请选择站点" clearable style="width: 100%"
  556. @change="handleCreateCascaderChange" />
  557. </el-form-item>
  558. <el-form-item label="技能标签">
  559. <el-select v-model="createDialog.form.tagIds" multiple placeholder="请选择技能标签" style="width: 100%">
  560. <el-option v-for="tag in allTags" :key="tag.id" :label="tag.name" :value="tag.id" />
  561. </el-select>
  562. </el-form-item>
  563. <el-form-item label="服务类型">
  564. <el-select v-model="createDialog.serviceTypeIds" multiple placeholder="请选择服务类型" style="width: 100%">
  565. <el-option v-for="item in serviceOptions" :key="item.id" :label="item.name" :value="item.id" />
  566. </el-select>
  567. </el-form-item>
  568. </el-form>
  569. <template #footer>
  570. <el-button @click="createDialog.visible = false">取消</el-button>
  571. <el-button type="primary" @click="submitCreate">确认创建</el-button>
  572. </template>
  573. </el-dialog>
  574. <!-- 违规记录弹窗 -->
  575. <el-dialog v-model="violationDialog.visible" title="新增违规记录" width="450px">
  576. <el-form :model="violationDialog.form" label-width="80px">
  577. <el-form-item label="违规次数" required>
  578. <el-input-number v-model="violationDialog.form.count" :min="1" style="width: 100%" />
  579. </el-form-item>
  580. <el-form-item label="扣罚积分" required>
  581. <el-input-number v-model="violationDialog.form.points" :min="0" style="width: 100%" placeholder="违规扣罚积分" />
  582. </el-form-item>
  583. <el-form-item label="违规时间" required>
  584. <el-date-picker v-model="violationDialog.form.violationTime" type="datetime" placeholder="请选择违规时间"
  585. format="YYYY-MM-DD HH:mm:ss" value-format="YYYY-MM-DD HH:mm:ss" style="width: 100%" />
  586. </el-form-item>
  587. <el-form-item label="违规原因" required>
  588. <el-input v-model="violationDialog.form.reason" type="textarea" :rows="3" placeholder="请输入违规原因说明" />
  589. </el-form-item>
  590. </el-form>
  591. <template #footer>
  592. <el-button @click="violationDialog.visible = false">取消</el-button>
  593. <el-button type="primary" @click="submitViolation">确认提交</el-button>
  594. </template>
  595. </el-dialog>
  596. <!-- 积分调整弹窗 -->
  597. <el-dialog v-model="pointsDialog.visible" title="修改积分" width="400px">
  598. <el-form :model="pointsDialog.form" label-width="80px">
  599. <el-form-item label="当前积分">
  600. <strong>{{ pointsDialog.currentRow?.points }}</strong>
  601. </el-form-item>
  602. <el-form-item label="调整方式">
  603. <el-radio-group v-model="pointsDialog.form.type">
  604. <el-radio :label="fulfillerEnums.ActionType.ADD">增加</el-radio>
  605. <el-radio :label="fulfillerEnums.ActionType.REDUCE">扣除</el-radio>
  606. </el-radio-group>
  607. </el-form-item>
  608. <el-form-item label="调整数值" required>
  609. <el-input-number v-model="pointsDialog.form.amount" :min="1" />
  610. </el-form-item>
  611. <el-form-item label="调整原因" required>
  612. <el-input v-model="pointsDialog.form.reason" type="textarea" placeholder="请输入备注说明" />
  613. </el-form-item>
  614. </el-form>
  615. <template #footer>
  616. <el-button @click="pointsDialog.visible = false">取消</el-button>
  617. <el-button type="primary" @click="submitPointsAdjust">确认调整</el-button>
  618. </template>
  619. </el-dialog>
  620. <!-- 余额调整弹窗 -->
  621. <el-dialog v-model="balanceDialog.visible" title="余额增减" width="450px">
  622. <el-form :model="balanceDialog.form" label-width="80px">
  623. <el-form-item label="当前余额">
  624. <span style="color: #f56c6c; font-weight: bold">¥{{ (balanceDialog.currentRow?.balance / 100).toFixed(2)
  625. }}</span>
  626. </el-form-item>
  627. <el-form-item label="扣减类型">
  628. <el-radio-group v-model="balanceDialog.form.type"
  629. @change="balanceDialog.form.subType = balanceDialog.form.type === fulfillerEnums.ActionType.ADD ? 'admin_reward' : 'admin_punish'">
  630. <el-radio :label="fulfillerEnums.ActionType.ADD">增加</el-radio>
  631. <el-radio :label="fulfillerEnums.ActionType.REDUCE">减少</el-radio>
  632. </el-radio-group>
  633. </el-form-item>
  634. <el-form-item label="调整类型">
  635. <el-radio-group v-model="balanceDialog.form.subType">
  636. <template v-if="balanceDialog.form.type === fulfillerEnums.ActionType.ADD">
  637. <el-radio label="admin_reward">奖励</el-radio>
  638. <el-radio label="admin_ajust">其他 (后台调整)</el-radio>
  639. </template>
  640. <template v-else>
  641. <el-radio label="admin_punish">惩罚</el-radio>
  642. <el-radio label="salary">工资发放</el-radio>
  643. <el-radio label="admin_ajust">其他 (后台调整)</el-radio>
  644. </template>
  645. </el-radio-group>
  646. </el-form-item>
  647. <el-form-item label="金额" required>
  648. <el-input-number v-model="balanceDialog.form.amount" :min="0.01" :precision="2" :step="10" />
  649. </el-form-item>
  650. <el-form-item label="备注说明" required>
  651. <el-input v-model="balanceDialog.form.reason" type="textarea" placeholder="请输入资金变动说明" />
  652. </el-form-item>
  653. </el-form>
  654. <template #footer>
  655. <el-button @click="balanceDialog.visible = false">取消</el-button>
  656. <el-button type="primary" @click="submitBalanceAdjust">确认执行</el-button>
  657. </template>
  658. </el-dialog>
  659. </div>
  660. </template>
  661. <script setup lang="ts">
  662. import { ref, reactive, computed, onMounted, onUnmounted, getCurrentInstance } from 'vue'
  663. import { ElMessage, ElMessageBox } from 'element-plus'
  664. import {
  665. listFulfiller, getFulfiller, addFulfiller, updateFulfiller,
  666. changeStatus, resetPwd, reward, adjustPoints, adjustBalance,
  667. listPointsLog, listBalanceLog, listRewardLog, exportFulfiller
  668. } from '@/api/fulfiller/pool'
  669. import { addViolation, listViolationByFulfiller } from '@/api/fulfiller/violation'
  670. import type { FlfViolationVO } from '@/api/fulfiller/violation/types'
  671. import { pageComplaintByFulfiller } from '@/api/fulfiller/complaint'
  672. import { listSubOrderOnFulfiller } from '@/api/order/subOrder/index'
  673. import { listAllService } from '@/api/service/list/index'
  674. import type {
  675. FlfFulfillerVO, FlfFulfillerForm, FlfFulfillerQuery,
  676. FlfRewardForm, FlfAdjustPointsForm, FlfAdjustBalanceForm,
  677. FlfPointsLogVO, FlfBalanceLogVO, FlfRewardLogVO
  678. } from '@/api/fulfiller/pool/types'
  679. import { listAllTag } from '@/api/fulfiller/tag'
  680. import type { FlfTagVO } from '@/api/fulfiller/tag/types'
  681. import { listAreaStation as listOnStore } from '@/api/system/areaStation'
  682. import type { AreaStationVO as SysAreaStationOnStoreVo } from '@/api/system/areaStation/types'
  683. import fulfillerEnums from '@/json/fulfiller.json'
  684. import ImageUpload from '@/components/ImageUpload/index.vue'
  685. import { listAllLevelRights, addLevelRights, updateLevelRights, delLevelRights, changeLevelRightsStatus } from '@/api/fulfiller/levelRights';
  686. import { listAllLevelConfig, addLevelConfig, updateLevelConfig, delLevelConfig } from '@/api/fulfiller/levelConfig';
  687. // 获取全局实例,用于调用 proxy.download
  688. const { proxy } = getCurrentInstance() as any
  689. const loading = ref(false)
  690. const searchKey = ref('')
  691. const activeTab = ref('all')
  692. const total = ref(0)
  693. const tableData = ref<FlfFulfillerVO[]>([])
  694. const allTags = ref<FlfTagVO[]>([])
  695. const areaStationList = ref<SysAreaStationOnStoreVo[]>([])
  696. const areaTreeOptions = computed(() => {
  697. const buildTree = (data: any[], parentId: any): any[] => {
  698. return data
  699. .filter(item => String(item.parentId) === String(parentId))
  700. .map(item => {
  701. const children = buildTree(data, item.id);
  702. const res: any = { id: item.id, name: item.name };
  703. if (children && children.length > 0) res.children = children;
  704. return res;
  705. });
  706. };
  707. return buildTree(areaStationList.value, 0);
  708. });
  709. const filterCascaderValue = ref<any[]>([])
  710. const queryParams = reactive<FlfFulfillerQuery>({
  711. pageNum: 1,
  712. pageSize: 10
  713. })
  714. // Drawer State
  715. const detailVisible = ref(false)
  716. const activeDetailTab = ref('info')
  717. const currentItem = ref<FlfFulfillerVO | null>(null)
  718. /** 资质图片URL列表(从逗号分隔字符串解析) */
  719. const qualImageUrlList = computed(() => {
  720. if (!currentItem.value?.qualImageUrls) return []
  721. return currentItem.value.qualImageUrls.split(',').filter(Boolean)
  722. })
  723. // Log data for detail tabs
  724. const pointsLogData = ref<FlfPointsLogVO[]>([])
  725. const balanceLogData = ref<FlfBalanceLogVO[]>([])
  726. const rewardLogData = ref<FlfRewardLogVO[]>([])
  727. const violationLogData = ref<FlfViolationVO[]>([])
  728. const complaintLogData = ref<any[]>([])
  729. const complaintPagination = reactive({ pageNum: 1, pageSize: 20, total: 0 })
  730. const serviceOrderData = ref<any[]>([])
  731. const serviceOptions = ref<any[]>([])
  732. const logLoading = ref(false)
  733. /** 获取站点完整路径显示文本 */
  734. const getStationPathText = (stationId: number | string | undefined) => {
  735. if (!stationId || !areaStationList.value.length) return { cityAndRegion: '-', station: '-' }
  736. const station = areaStationList.value.find(item => item.id == stationId)
  737. if (!station) return { cityAndRegion: '-', station: '-' }
  738. const region = areaStationList.value.find(item => item.id == station.parentId)
  739. if (!region) return { cityAndRegion: '-', station: station.name || '-' }
  740. const city = areaStationList.value.find(item => item.id == region.parentId)
  741. const cityAndRegion = city ? `${city.name}/${region.name}` : region.name
  742. return { cityAndRegion, station: station.name || '-' }
  743. }
  744. /** 查询列表 */
  745. const getList = async (isPolling: any = false) => {
  746. const isAutoPoll = isPolling === true;
  747. if (!isAutoPoll) {
  748. loading.value = true
  749. }
  750. try {
  751. const params: FlfFulfillerQuery = {
  752. pageNum: queryParams.pageNum,
  753. pageSize: queryParams.pageSize,
  754. status: activeTab.value === 'all' ? undefined : activeTab.value,
  755. keyword: searchKey.value || undefined,
  756. stationId: queryParams.stationId || undefined
  757. }
  758. const res = await listFulfiller(params)
  759. tableData.value = res.rows
  760. total.value = res.total
  761. } finally {
  762. if (!isAutoPoll) {
  763. loading.value = false
  764. }
  765. }
  766. }
  767. /** 导出为Excel */
  768. const handleExport = () => {
  769. proxy?.download(
  770. 'fulfiller/fulfiller/export',
  771. {
  772. status: activeTab.value === 'all' ? undefined : activeTab.value,
  773. keyword: searchKey.value || undefined,
  774. stationId: queryParams.stationId || undefined
  775. },
  776. '履约者列表.xlsx'
  777. )
  778. }
  779. /** 加载全部标签(选择器用) */
  780. const loadAllTags = async () => {
  781. try {
  782. const res = await listAllTag({ category: 'fulfiller' })
  783. allTags.value = res.data || []
  784. } catch { /* ignore */ }
  785. }
  786. /** 加载区域站点数据并构建级联树 */
  787. const loadAreaStations = async () => {
  788. try {
  789. const res = await listOnStore()
  790. areaStationList.value = res.data || []
  791. } catch { /* ignore */ }
  792. }
  793. // stationOptions, loadStationsByAreaId 已废弃,现通过级联选择器统一控制
  794. /** 根据级联值获取cityCode和cityName */
  795. const getCityInfoFromCascader = (cascaderValue: any[]) => {
  796. if (!cascaderValue || cascaderValue.length === 0) return { cityCode: '', cityName: '' }
  797. const lastId = cascaderValue[cascaderValue.length - 1]
  798. const names: string[] = []
  799. for (const id of cascaderValue) {
  800. const item = areaStationList.value.find(i => i.id == id)
  801. if (item) names.push(item.name || '')
  802. }
  803. return { cityCode: String(lastId), cityName: names.join(' ') }
  804. }
  805. /** 搜索框回车/清除触发查询 */
  806. const handleSearch = () => {
  807. queryParams.pageNum = 1
  808. getList()
  809. }
  810. const handleSizeChange = (val: number) => { queryParams.pageSize = val; getList() }
  811. const handleCurrentChange = (val: number) => { queryParams.pageNum = val; getList() }
  812. /** 积分业务类型标签颜色 */
  813. const getPointsBizTypeTag = (type: string) => {
  814. return fulfillerEnums.PointsBizType[type]?.tagType || 'info'
  815. }
  816. /** 积分业务类型中文名称 */
  817. const getPointsBizTypeName = (type: string) => {
  818. return fulfillerEnums.PointsBizType[type]?.label || type
  819. }
  820. /** 余额资金类型颜色 */
  821. const getBalanceBizTypeTag = (type: string) => {
  822. return fulfillerEnums.BalanceBizType[type]?.tagType || 'info'
  823. }
  824. /** 余额资金类型中文名称 */
  825. const getBalanceBizTypeName = (type: string) => {
  826. return fulfillerEnums.BalanceBizType[type]?.label || type
  827. }
  828. /** 奖惩业务类型名称 */
  829. const getRewardBizTypeName = (type: string) => {
  830. return fulfillerEnums.RewardBizType[type]?.label || type
  831. }
  832. /** 加载服务项目列表用于名称映射 */
  833. const loadServiceOptions = async () => {
  834. try {
  835. const res = await listAllService()
  836. serviceOptions.value = res.data || []
  837. } catch { /* ignore */ }
  838. }
  839. const getServiceName = (serviceId: number | string) => {
  840. const item = serviceOptions.value.find(i => i.id === serviceId)
  841. return item ? item.name : '未知服务'
  842. }
  843. const getSubOrderStatusName = (status: number) => {
  844. const map: Record<number, string> = { 0: '待派单', 1: '待接单', 2: '待服务', 3: '服务中', 4: '已完成', 5: '已取消' }
  845. return map[status] || '未知'
  846. }
  847. const getSubOrderStatusType = (status: number) => {
  848. const map: Record<number, string> = { 0: 'info', 1: 'warning', 2: 'primary', 3: 'success', 4: 'success', 5: 'danger' }
  849. return map[status] || 'info'
  850. }
  851. const rewardDialog = reactive({
  852. visible: false,
  853. userName: '',
  854. fulfillerId: 0 as number | string,
  855. form: { type: fulfillerEnums.ActionType.ADD, target: 'points', amount: 10, reason: '' }
  856. })
  857. const editDialog = reactive({
  858. visible: false,
  859. form: {} as FlfFulfillerForm,
  860. cascaderValue: [] as any[],
  861. stationOptions: [] as SysAreaStationOnStoreVo[],
  862. serviceTypeIds: [] as any[] // 服务类型多选辅助数组(提交时转为逗号字符串)
  863. })
  864. const createDialog = reactive({
  865. visible: false,
  866. form: {
  867. name: '', phone: '', password: '', cityCode: '', cityName: '', stationId: undefined as any, gender: '0', workType: 'full_time'
  868. },
  869. cascaderValue: [] as any[],
  870. stationOptions: [] as SysAreaStationOnStoreVo[],
  871. serviceTypeIds: [] as any[] // 服务类型多选辅助数组(提交时转为逗号字符串)
  872. })
  873. const pointsDialog = reactive({
  874. visible: false,
  875. currentRow: null as FlfFulfillerVO | null,
  876. form: { type: fulfillerEnums.ActionType.ADD, amount: 0, reason: '' }
  877. })
  878. const balanceDialog = reactive({
  879. visible: false,
  880. currentRow: null as FlfFulfillerVO | null,
  881. form: { type: fulfillerEnums.ActionType.ADD, subType: 'admin_reward', amount: 0, reason: '' }
  882. })
  883. const violationDialog = reactive({
  884. visible: false,
  885. fulfillerId: 0 as number | string,
  886. form: {
  887. count: 1,
  888. violationTime: '',
  889. reason: '',
  890. points: 0
  891. }
  892. })
  893. const getStatusText = (status: string) => {
  894. const map: Record<string, string> = { busy: '接单中', resting: '休息', disabled: '禁用', frozen: '冻结' }
  895. return map[status] || '未知'
  896. }
  897. const getLevelText = (levelName: string) => {
  898. return levelName || '普通'
  899. }
  900. const getLevelType = (levelName: string) => {
  901. if (!levelName) return 'info'
  902. if (levelName.includes('金')) return 'warning'
  903. if (levelName.includes('银')) return 'info'
  904. if (levelName.includes('铜')) return 'danger'
  905. return 'info'
  906. }
  907. const handleTabClick = (tab: any) => {
  908. activeTab.value = tab.paneName
  909. queryParams.pageNum = 1
  910. getList()
  911. }
  912. /** 加载日志数据 */
  913. const loadLogs = async (fulfillerId: string | number) => {
  914. logLoading.value = true
  915. try {
  916. const [pRes, bRes, rRes, sRes, vRes] = await Promise.all([
  917. listPointsLog(fulfillerId, { pageNum: 1, pageSize: 20 }),
  918. listBalanceLog(fulfillerId, { pageNum: 1, pageSize: 20 }),
  919. listRewardLog(fulfillerId, { pageNum: 1, pageSize: 20 }),
  920. listSubOrderOnFulfiller(fulfillerId),
  921. listViolationByFulfiller({ fulfillerId, pageNum: 1, pageSize: 20 })
  922. ])
  923. pointsLogData.value = pRes.rows || []
  924. balanceLogData.value = bRes.rows || []
  925. rewardLogData.value = rRes.rows || []
  926. serviceOrderData.value = sRes.data || []
  927. violationLogData.value = vRes.rows || []
  928. } catch { /* ignore */ } finally {
  929. logLoading.value = false
  930. }
  931. loadComplaintLogs(fulfillerId)
  932. }
  933. const loadComplaintLogs = async (fulfillerId?: string | number) => {
  934. const id = fulfillerId || currentItem.value?.id
  935. if (!id) return
  936. logLoading.value = true
  937. try {
  938. const res = await pageComplaintByFulfiller({
  939. fulfiller: id,
  940. pageNum: complaintPagination.pageNum,
  941. pageSize: complaintPagination.pageSize
  942. })
  943. complaintLogData.value = res.rows || []
  944. complaintPagination.total = res.total || 0
  945. } catch { /* ignore */ } finally {
  946. logLoading.value = false
  947. }
  948. }
  949. const handleDetail = async (row: FlfFulfillerVO) => {
  950. try {
  951. const res = await getFulfiller(row.id)
  952. currentItem.value = res.data
  953. } catch {
  954. currentItem.value = { ...row }
  955. }
  956. activeDetailTab.value = 'info'
  957. detailVisible.value = true
  958. loadLogs(row.id)
  959. }
  960. const handleEdit = (row: FlfFulfillerVO) => {
  961. editDialog.form = {
  962. id: row.id,
  963. name: row.name,
  964. phone: row.phone,
  965. gender: row.gender,
  966. idCard: row.idCard,
  967. cityCode: row.cityCode,
  968. cityName: row.cityName,
  969. stationId: row.stationId,
  970. level: row.level,
  971. status: row.status,
  972. authId: row.authId,
  973. authQual: row.authQual,
  974. tagIds: row.tags ? row.tags.map(t => t.id) : []
  975. }
  976. // 将后端逗号字符串 serviceTypes 解析为数组用于多选回显
  977. editDialog.serviceTypeIds = row.serviceTypes ? row.serviceTypes.split(',').filter(Boolean) : []
  978. if (row.stationId || row.cityCode) {
  979. const targetId = row.stationId || Number(row.cityCode)
  980. const path: any[] = []
  981. let currentId = targetId
  982. while (currentId && String(currentId) !== '0') {
  983. path.unshift(currentId)
  984. const node = areaStationList.value.find(i => String(i.id) === String(currentId))
  985. if (node) {
  986. currentId = node.parentId
  987. } else {
  988. break
  989. }
  990. }
  991. editDialog.cascaderValue = path
  992. }
  993. editDialog.visible = true
  994. }
  995. const handleCreate = () => {
  996. createDialog.form = {
  997. name: '', phone: '', password: '', cityCode: '', cityName: '', stationId: undefined, gender: '0', workType: 'full_time', tagIds: []
  998. }
  999. createDialog.serviceTypeIds = [] // 重置服务类型辅助数组
  1000. createDialog.cascaderValue = []
  1001. createDialog.stationOptions = []
  1002. createDialog.visible = true
  1003. }
  1004. const submitCreate = async () => {
  1005. if (!createDialog.form.name || !createDialog.form.phone || !createDialog.form.password) {
  1006. ElMessage.warning('请填写完整信息')
  1007. return
  1008. }
  1009. // 将选中的服务类型数组转为逗号拼接字符串后提交
  1010. ;(createDialog.form as any).serviceTypes = createDialog.serviceTypeIds.join(',')
  1011. try {
  1012. await addFulfiller(createDialog.form as FlfFulfillerForm)
  1013. createDialog.visible = false
  1014. ElMessage.success('创建成功')
  1015. getList()
  1016. } catch { /* handled by interceptor */ }
  1017. }
  1018. const saveEdit = async () => {
  1019. // 将选中的服务类型数组转为逗号拼接字符串后提交
  1020. ;(editDialog.form as any).serviceTypes = editDialog.serviceTypeIds.join(',')
  1021. try {
  1022. await updateFulfiller(editDialog.form)
  1023. ElMessage.success('更新成功')
  1024. editDialog.visible = false
  1025. getList()
  1026. } catch { /* handled by interceptor */ }
  1027. }
  1028. const handleReward = (row: FlfFulfillerVO) => {
  1029. rewardDialog.userName = row.name
  1030. rewardDialog.fulfillerId = row.id
  1031. rewardDialog.form = { type: fulfillerEnums.ActionType.ADD, target: 'points', amount: 10, reason: '' }
  1032. rewardDialog.visible = true
  1033. }
  1034. const submitReward = async () => {
  1035. try {
  1036. // 余额目标时将元转为分
  1037. const amount = rewardDialog.form.target === 'balance'
  1038. ? Math.round(rewardDialog.form.amount * 100)
  1039. : rewardDialog.form.amount
  1040. await reward({
  1041. fulfillerId: rewardDialog.fulfillerId,
  1042. type: rewardDialog.form.type,
  1043. target: rewardDialog.form.target,
  1044. amount: amount,
  1045. reason: rewardDialog.form.reason,
  1046. bizType: rewardDialog.form.type === fulfillerEnums.ActionType.ADD ? 'admin_reward' : 'admin_punish'
  1047. } as any)
  1048. ElMessage.success('操作成功')
  1049. rewardDialog.visible = false
  1050. getList()
  1051. } catch { /* handled by interceptor */ }
  1052. }
  1053. const handleCommand = async (cmd: string, row: FlfFulfillerVO) => {
  1054. if (cmd === 'adjustPoints') {
  1055. pointsDialog.currentRow = row
  1056. pointsDialog.form = { type: fulfillerEnums.ActionType.ADD, amount: 0, reason: '' }
  1057. pointsDialog.visible = true
  1058. } else if (cmd === 'adjustBalance') {
  1059. balanceDialog.currentRow = row
  1060. balanceDialog.form = { type: fulfillerEnums.ActionType.ADD, subType: 'admin_reward', amount: 0, reason: '' }
  1061. balanceDialog.visible = true
  1062. } else if (cmd === 'disable') {
  1063. await ElMessageBox.confirm(`确定禁用履约者【${row.name}】吗?禁用后将无法接单。`, '提示', { type: 'warning' })
  1064. try {
  1065. await changeStatus(row.id, 'disabled')
  1066. ElMessage.success('账号已禁用')
  1067. getList()
  1068. } catch { /* handled by interceptor */ }
  1069. } else if (cmd === 'enable') {
  1070. await ElMessageBox.confirm(`确定启用履约者【${row.name}】吗?`, '提示', { type: 'success' })
  1071. try {
  1072. await changeStatus(row.id, 'resting')
  1073. ElMessage.success('账号已启用')
  1074. getList()
  1075. } catch { /* handled by interceptor */ }
  1076. } else if (cmd === 'resetPwd') {
  1077. await ElMessageBox.confirm('确定重置密码为默认密码 [123456] 吗?', '提示', { type: 'info' })
  1078. try {
  1079. await resetPwd(row.id, '123456')
  1080. ElMessage.success('密码重置成功')
  1081. } catch { /* handled by interceptor */ }
  1082. } else if (cmd === 'violation') {
  1083. violationDialog.fulfillerId = row.id
  1084. // 获取当前履约者等级对应的违规扣罚积分
  1085. const getDefaultPoints = async () => {
  1086. try {
  1087. const levelConfigRes = await listAllLevelConfig()
  1088. const currentLevelConfig = levelConfigRes.data.find((config: any) => config.lvNo === row.level)
  1089. const defaultPoints = currentLevelConfig?.degradeViolationsScore || 0
  1090. return defaultPoints
  1091. } catch {
  1092. return 0
  1093. }
  1094. }
  1095. getDefaultPoints().then(defaultPoints => {
  1096. violationDialog.form = {
  1097. count: 1,
  1098. violationTime: new Date().toISOString().replace('T', ' ').split('.')[0],
  1099. reason: '',
  1100. points: defaultPoints
  1101. }
  1102. violationDialog.visible = true
  1103. })
  1104. }
  1105. }
  1106. const submitViolation = async () => {
  1107. if (!violationDialog.form.violationTime || !violationDialog.form.reason) {
  1108. ElMessage.warning('请填写完整信息')
  1109. return
  1110. }
  1111. try {
  1112. await addViolation({
  1113. fulfiller: violationDialog.fulfillerId,
  1114. ...violationDialog.form
  1115. } as any)
  1116. ElMessage.success('添加违规记录成功')
  1117. violationDialog.visible = false
  1118. } catch { /* handled by interceptor */ }
  1119. }
  1120. const submitPointsAdjust = async () => {
  1121. if (!pointsDialog.currentRow) return
  1122. try {
  1123. await adjustPoints({
  1124. fulfillerId: pointsDialog.currentRow.id,
  1125. type: pointsDialog.form.type,
  1126. amount: pointsDialog.form.amount,
  1127. reason: pointsDialog.form.reason,
  1128. bizType: pointsDialog.form.type === fulfillerEnums.ActionType.ADD ? 'admin_reward' : 'admin_punish'
  1129. } as any)
  1130. ElMessage.success('积分调整成功')
  1131. pointsDialog.visible = false
  1132. getList()
  1133. } catch { /* handled by interceptor */ }
  1134. }
  1135. const submitBalanceAdjust = async () => {
  1136. if (!balanceDialog.currentRow) return
  1137. try {
  1138. // 将元转为分
  1139. await adjustBalance({
  1140. fulfillerId: balanceDialog.currentRow.id,
  1141. type: balanceDialog.form.type,
  1142. subType: balanceDialog.form.subType,
  1143. amount: Math.round(balanceDialog.form.amount * 100),
  1144. reason: balanceDialog.form.reason
  1145. })
  1146. ElMessage.success('余额调整成功')
  1147. balanceDialog.visible = false
  1148. getList()
  1149. } catch { /* handled by interceptor */ }
  1150. }
  1151. const handleViewImage = (url: string) => {
  1152. // Already handled by el-image preview
  1153. }
  1154. /** 顶部筛选:级联选择变化时 */
  1155. const handleFilterCascaderChange = (val: any[]) => {
  1156. if (val && val.length > 0) {
  1157. const lastId = val[val.length - 1]
  1158. const node = areaStationList.value.find(item => item.id === lastId)
  1159. if (node && node.type === 2) {
  1160. queryParams.stationId = lastId
  1161. queryParams.cityCode = String(node.parentId)
  1162. } else {
  1163. queryParams.cityCode = String(lastId)
  1164. queryParams.stationId = undefined
  1165. }
  1166. } else {
  1167. queryParams.cityCode = undefined
  1168. queryParams.stationId = undefined
  1169. }
  1170. handleSearch()
  1171. }
  1172. /** 编辑对话框:级联选择变化时 */
  1173. const handleEditCascaderChange = (val: any[]) => {
  1174. if (val && val.length > 0) {
  1175. const lastId = val[val.length - 1]
  1176. const node = areaStationList.value.find(item => item.id === lastId)
  1177. if (node) {
  1178. editDialog.form.stationId = lastId
  1179. editDialog.form.cityCode = String(node.parentId)
  1180. const { cityName } = getCityInfoFromCascader(val)
  1181. editDialog.form.cityName = cityName
  1182. }
  1183. } else {
  1184. editDialog.form.stationId = undefined
  1185. editDialog.form.cityCode = undefined
  1186. editDialog.form.cityName = undefined
  1187. }
  1188. }
  1189. /** 新增对话框:级联选择变化时 */
  1190. const handleCreateCascaderChange = (val: any[]) => {
  1191. if (val && val.length > 0) {
  1192. const lastId = val[val.length - 1]
  1193. const node = areaStationList.value.find(item => item.id === lastId)
  1194. if (node) {
  1195. createDialog.form.stationId = lastId
  1196. createDialog.form.cityCode = String(node.parentId)
  1197. const { cityName } = getCityInfoFromCascader(val)
  1198. createDialog.form.cityName = cityName
  1199. }
  1200. } else {
  1201. createDialog.form.stationId = undefined
  1202. createDialog.form.cityCode = undefined
  1203. createDialog.form.cityName = undefined
  1204. }
  1205. }
  1206. let timer: any = null;
  1207. onMounted(() => {
  1208. getList()
  1209. loadAllTags()
  1210. loadAreaStations()
  1211. loadServiceOptions()
  1212. timer = setInterval(() => {
  1213. getList(true);
  1214. }, 5000);
  1215. })
  1216. onUnmounted(() => {
  1217. if (timer) {
  1218. clearInterval(timer);
  1219. timer = null;
  1220. }
  1221. })
  1222. </script>
  1223. <style scoped>
  1224. .page-container {
  1225. padding: 20px;
  1226. }
  1227. .table-card {
  1228. border-radius: 8px;
  1229. border: none;
  1230. }
  1231. .card-header {
  1232. display: flex;
  1233. justify-content: space-between;
  1234. align-items: center;
  1235. margin-bottom: 20px;
  1236. }
  1237. .status-tabs {
  1238. margin-top: -15px;
  1239. }
  1240. :deep(.el-tabs__header) {
  1241. margin-bottom: 0;
  1242. }
  1243. :deep(.el-tabs__nav-wrap::after) {
  1244. height: 1px;
  1245. background-color: #f0f2f5;
  1246. }
  1247. .title {
  1248. font-size: 18px;
  1249. font-weight: bold;
  1250. color: #303133;
  1251. }
  1252. .right-panel {
  1253. display: flex;
  1254. align-items: center;
  1255. }
  1256. .search-input {
  1257. width: 240px;
  1258. }
  1259. /* Table Content Styles */
  1260. .user-info {
  1261. display: flex;
  1262. align-items: center;
  1263. }
  1264. .text-col {
  1265. margin-left: 10px;
  1266. display: flex;
  1267. flex-direction: column;
  1268. justify-content: center;
  1269. }
  1270. .name-row {
  1271. font-weight: bold;
  1272. font-size: 14px;
  1273. color: #333;
  1274. display: flex;
  1275. align-items: center;
  1276. }
  1277. .gender-tag {
  1278. margin-left: 5px;
  1279. display: flex;
  1280. align-items: center;
  1281. }
  1282. .sub-text {
  1283. font-size: 12px;
  1284. color: #999;
  1285. margin-top: 2px;
  1286. }
  1287. .auth-row {
  1288. display: flex;
  1289. gap: 8px;
  1290. flex-wrap: wrap;
  1291. }
  1292. .auth-card {
  1293. font-size: 12px;
  1294. padding: 2px 6px;
  1295. border-radius: 4px;
  1296. background: #f4f4f5;
  1297. color: #909399;
  1298. display: flex;
  1299. align-items: center;
  1300. gap: 4px;
  1301. }
  1302. .auth-card.active {
  1303. background: #ecf5ff;
  1304. color: #409eff;
  1305. }
  1306. .auth-card.need-review {
  1307. background: #fef0f0;
  1308. color: #f56c6c;
  1309. border: 1px dashed #f56c6c;
  1310. }
  1311. .finance-item {
  1312. font-size: 13px;
  1313. color: #606266;
  1314. line-height: 1.6;
  1315. }
  1316. .num {
  1317. font-weight: bold;
  1318. font-family: DIN, sans-serif;
  1319. margin-left: 5px;
  1320. color: #303133;
  1321. }
  1322. .num.error {
  1323. color: #f56c6c;
  1324. }
  1325. .status-cell {
  1326. display: flex;
  1327. align-items: center;
  1328. }
  1329. .status-dot {
  1330. width: 8px;
  1331. height: 8px;
  1332. border-radius: 50%;
  1333. margin-right: 6px;
  1334. }
  1335. .status-dot.resting {
  1336. background: #e6a23c;
  1337. }
  1338. .status-dot.busy {
  1339. background: #409eff;
  1340. }
  1341. .status-dot.disabled {
  1342. background: #f56c6c;
  1343. }
  1344. .status-dot.frozen {
  1345. background: #909399;
  1346. }
  1347. .op-cell {
  1348. display: flex;
  1349. align-items: center;
  1350. gap: 5px;
  1351. flex-wrap: wrap;
  1352. }
  1353. .pagination-container {
  1354. display: flex;
  1355. justify-content: flex-end;
  1356. margin-top: 20px;
  1357. }
  1358. /* Drawer Styles */
  1359. .user-header-card {
  1360. display: flex;
  1361. align-items: center;
  1362. padding: 20px;
  1363. background: linear-gradient(135deg, #f5f7fa 0%, #eef1f6 100%);
  1364. border-radius: 8px;
  1365. margin-bottom: 25px;
  1366. }
  1367. .header-info {
  1368. margin-left: 20px;
  1369. flex: 1;
  1370. }
  1371. .top-row {
  1372. display: flex;
  1373. align-items: center;
  1374. margin-bottom: 8px;
  1375. }
  1376. .user-name {
  1377. font-size: 20px;
  1378. font-weight: bold;
  1379. color: #303133;
  1380. }
  1381. .status-badge {
  1382. margin-left: auto;
  1383. font-size: 12px;
  1384. padding: 4px 10px;
  1385. border-radius: 12px;
  1386. background: #e1f3d8;
  1387. color: #67c23a;
  1388. }
  1389. .status-badge.resting {
  1390. background: #faecd8;
  1391. color: #e6a23c;
  1392. }
  1393. .status-badge.disabled {
  1394. background: #fde2e2;
  1395. color: #f56c6c;
  1396. }
  1397. .status-badge.busy {
  1398. background: #d9ecff;
  1399. color: #409eff;
  1400. }
  1401. .status-badge.frozen {
  1402. background: #f0f9eb;
  1403. color: #909399;
  1404. }
  1405. .sub-row {
  1406. display: flex;
  1407. align-items: center;
  1408. font-size: 13px;
  1409. color: #606266;
  1410. margin-bottom: 8px;
  1411. }
  1412. .info-item {
  1413. display: flex;
  1414. align-items: center;
  1415. gap: 4px;
  1416. }
  1417. .divider {
  1418. margin: 0 10px;
  1419. color: #dcdfe6;
  1420. }
  1421. .tags-row {
  1422. display: flex;
  1423. align-items: center;
  1424. gap: 5px;
  1425. }
  1426. .data-metrics-row {
  1427. display: flex;
  1428. justify-content: space-around;
  1429. padding: 15px 0;
  1430. margin-bottom: 10px;
  1431. background: #fff;
  1432. border-bottom: 1px solid #f0f0f0;
  1433. }
  1434. .metric-item {
  1435. text-align: center;
  1436. flex: 1;
  1437. }
  1438. .val {
  1439. font-size: 20px;
  1440. font-weight: bold;
  1441. color: #303133;
  1442. font-family: DIN, sans-serif;
  1443. margin-bottom: 4px;
  1444. }
  1445. .lbl {
  1446. font-size: 12px;
  1447. color: #909399;
  1448. }
  1449. .text-primary {
  1450. color: #409eff;
  1451. }
  1452. .text-danger {
  1453. color: #f56c6c;
  1454. }
  1455. .text-warning {
  1456. color: #e6a23c;
  1457. }
  1458. .divider-v {
  1459. width: 1px;
  1460. background: #e0e0e0;
  1461. height: 30px;
  1462. align-self: center;
  1463. }
  1464. .detail-tabs {
  1465. margin-top: 0;
  1466. }
  1467. .section-block {
  1468. margin-bottom: 25px;
  1469. }
  1470. .section-title {
  1471. font-size: 15px;
  1472. font-weight: bold;
  1473. margin-bottom: 15px;
  1474. border-left: 4px solid #409eff;
  1475. padding-left: 10px;
  1476. }
  1477. .cert-row {
  1478. display: flex;
  1479. gap: 15px;
  1480. }
  1481. .cert-item {
  1482. text-align: center;
  1483. cursor: pointer;
  1484. }
  1485. .cert-img {
  1486. width: 120px;
  1487. height: 80px;
  1488. border-radius: 6px;
  1489. border: 1px solid #dcdfe6;
  1490. background: #f5f7fa;
  1491. }
  1492. .img-slot {
  1493. display: flex;
  1494. justify-content: center;
  1495. align-items: center;
  1496. width: 100%;
  1497. height: 100%;
  1498. color: #909399;
  1499. font-size: 24px;
  1500. }
  1501. .cert-name {
  1502. font-size: 12px;
  1503. color: #606266;
  1504. margin-top: 5px;
  1505. }
  1506. .tag-list {
  1507. display: flex;
  1508. flex-wrap: wrap;
  1509. }
  1510. .tab-content-wrapper {
  1511. padding: 10px 0;
  1512. }
  1513. :deep(.el-table .el-table__cell) {
  1514. padding: 12px 0;
  1515. }
  1516. </style>