index.vue 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116
  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="header-left">
  7. <span class="title">门店管理</span>
  8. </div>
  9. <div class="header-right">
  10. <el-input
  11. v-model="queryParams.storeOrContact"
  12. placeholder="搜索门店名称/联系人"
  13. class="search-input"
  14. prefix-icon="Search"
  15. clearable
  16. @keyup.enter="handleQuery"
  17. />
  18. <el-cascader
  19. v-model="searchRegionValue"
  20. :options="areaOptions"
  21. :props="{ value: 'id', label: 'name' }"
  22. placeholder="所属站点"
  23. class="station-select"
  24. style="width: 240px"
  25. clearable
  26. @change="handleSearchAreaChange"
  27. />
  28. <el-select v-model="queryParams.status" placeholder="状态" class="status-select" clearable @change="handleQuery">
  29. <el-option v-for="item in statusList" :key="item.value" :label="item.label" :value="item.value" />
  30. </el-select>
  31. <el-button type="primary" icon="Plus" @click="handleAdd" v-hasPermi="['system:store:add']">新增门店</el-button>
  32. </div>
  33. </div>
  34. </template>
  35. <el-table v-loading="loading" :data="storeList" style="width: 100%" :header-cell-style="{ background: '#f8f9fb', color: '#606266' }">
  36. <el-table-column label="门店信息" min-width="240">
  37. <template #default="scope">
  38. <div class="store-info-box">
  39. <image-preview :src="scope.row.logoUrl" :width="50" :height="50" class="store-logo" />
  40. <div class="store-desc">
  41. <div class="name">{{ scope.row.name }}</div>
  42. <div class="tags">
  43. <el-tag size="small" type="warning" effect="plain" v-if="scope.row.tenantName">{{ scope.row.tenantName }}</el-tag>
  44. <el-tag size="small" type="success" effect="plain" v-if="scope.row.tenantCatergoriesName">{{ scope.row.tenantCatergoriesName }}</el-tag>
  45. </div>
  46. </div>
  47. </div>
  48. </template>
  49. </el-table-column>
  50. <el-table-column label="服务项目" min-width="180">
  51. <template #default="scope">
  52. <div class="service-tags">
  53. <el-tag v-for="service in scope.row.services" :key="service" size="small" effect="light" class="service-tag">
  54. {{ getServiceName(service) }}
  55. </el-tag>
  56. </div>
  57. </template>
  58. </el-table-column>
  59. <el-table-column label="资质认证" align="center" width="100">
  60. <template #default="scope">
  61. <image-preview v-if="scope.row.businessLicenseUrl" :src="scope.row.businessLicenseUrl" :width="40" :height="40" />
  62. <span v-else>-</span>
  63. </template>
  64. </el-table-column>
  65. <el-table-column label="归属区域/站点" min-width="150">
  66. <template #default="scope">
  67. <div class="region-info">
  68. <div class="region-name">{{ getRegionNameBySite(scope.row.site) }}</div>
  69. <div class="site-name">
  70. <el-icon><Location /></el-icon>
  71. <span>{{ scope.row.siteName }}</span>
  72. </div>
  73. </div>
  74. </template>
  75. </el-table-column>
  76. <el-table-column label="服务单" align="center" width="100" prop="serviceOrder" sortable />
  77. <el-table-column label="营业时间" align="center" width="140">
  78. <template #default="scope">
  79. <span class="time-text">{{ formatTime(scope.row.startBusinessTime) }},{{ formatTime(scope.row.endBusinessTime) }}</span>
  80. </template>
  81. </el-table-column>
  82. <el-table-column label="联系方式" min-width="160">
  83. <template #default="scope">
  84. <div class="contact-info">
  85. <div class="contact-item">
  86. <el-icon><User /></el-icon><span>{{ scope.row.contact }}</span>
  87. </div>
  88. <div class="contact-item phone">
  89. <el-icon><Phone /></el-icon><span>{{ scope.row.contactNumber }}</span>
  90. </div>
  91. </div>
  92. </template>
  93. </el-table-column>
  94. <el-table-column label="门店地址" prop="detailAddress" min-width="200" show-overflow-tooltip>
  95. <template #default="scope">
  96. {{ getFullAddress(scope.row) }}
  97. </template>
  98. </el-table-column>
  99. <el-table-column label="状态" align="center" width="90">
  100. <template #default="scope">
  101. <template v-for="item in statusList" :key="item.value">
  102. <el-tag v-if="scope.row.status === item.value" :type="item.style" size="small" effect="light">
  103. {{ item.label }}
  104. </el-tag>
  105. </template>
  106. </template>
  107. </el-table-column>
  108. <el-table-column label="操作" align="right" width="180" fixed="right">
  109. <template #default="scope">
  110. <div class="op-btns">
  111. <el-button link type="primary" @click="handleDetail(scope.row)" v-hasPermi="['system:store:query']">详情</el-button>
  112. <el-button link type="primary" @click="handleUpdate(scope.row)" v-hasPermi="['system:store:edit']">编辑</el-button>
  113. <el-dropdown trigger="click" @command="(cmd) => handleCommand(cmd, scope.row)">
  114. <el-button link type="primary" class="more-btn">
  115. 更多<el-icon class="el-icon--right"><arrow-down /></el-icon>
  116. </el-button>
  117. <template #dropdown>
  118. <el-dropdown-menu>
  119. <el-dropdown-item command="handleRenew" v-hasPermi="['system:store:renew']">续期</el-dropdown-item>
  120. <el-dropdown-item v-if="scope.row.status === 1 && checkPermi(['system:store:disable'])" command="handleBan" class="delete-item">禁用</el-dropdown-item>
  121. <el-dropdown-item v-if="scope.row.status === 3 && checkPermi(['system:store:enable'])" command="handleEnable">启用</el-dropdown-item>
  122. </el-dropdown-menu>
  123. </template>
  124. </el-dropdown>
  125. </div>
  126. </template>
  127. </el-table-column>
  128. </el-table>
  129. <div class="pagination-container">
  130. <pagination
  131. v-show="total > 0"
  132. v-model:total="total"
  133. v-model:page="queryParams.pageNum"
  134. v-model:limit="queryParams.pageSize"
  135. @pagination="getList"
  136. />
  137. </div>
  138. </el-card>
  139. <!-- 添加或修改门店管理对话框 -->
  140. <el-dialog :title="dialog.title" v-model="dialog.visible" width="600px" append-to-body>
  141. <el-form ref="storeFormRef" :model="form" :rules="rules" label-width="120px">
  142. <el-form-item label="门店Logo" prop="logo">
  143. <image-upload v-model="form.logo" :limit="1" />
  144. </el-form-item>
  145. <el-form-item label="营业执照" prop="businessLicense">
  146. <image-upload v-model="form.businessLicense" :limit="1" />
  147. </el-form-item>
  148. <el-form-item label="门店名称" prop="name">
  149. <el-input v-model="form.name" placeholder="请输入门店名称" />
  150. </el-form-item>
  151. <el-form-item label="服务项目" prop="services">
  152. <el-checkbox-group v-model="form.services">
  153. <el-checkbox v-for="service in serviceList" :key="service.id" :label="service.id" border>
  154. {{ service.name }}
  155. </el-checkbox>
  156. </el-checkbox-group>
  157. </el-form-item>
  158. <el-form-item label="商户分类" prop="tenantCatergories">
  159. <PageSelect
  160. v-model="form.tenantCatergories"
  161. :options="tenantCategoriesList.map(item => ({ value: item.id, label: item.name }))"
  162. :total="tenantCategoriesTotal"
  163. :pageSize="10"
  164. placeholder="请选择商户分类"
  165. @page-change="handleTenantCategoriesPageChange"
  166. @visible-change="handleTenantCategoriesVisibleChange"
  167. />
  168. </el-form-item>
  169. <el-form-item label="所属品牌" prop="tenantId">
  170. <PageSelect
  171. v-model="form.tenantId"
  172. :options="brandList.map(item => ({ value: item.tenantId, label: item.name }))"
  173. :total="brandTotal"
  174. :pageSize="10"
  175. placeholder="请选择所属品牌"
  176. @page-change="handleBrandPageChange"
  177. @visible-change="handleBrandSelectVisibleChange"
  178. />
  179. </el-form-item>
  180. <el-form-item label="营业时间" prop="startBusinessTime">
  181. <el-row :gutter="10">
  182. <el-col :span="10">
  183. <el-time-picker clearable v-model="form.startBusinessTime" format="HH:mm" value-format="HH:mm" placeholder="开始时间" style="width: 100%" />
  184. </el-col>
  185. <el-col :span="4" style="text-align: center; line-height: 32px">至</el-col>
  186. <el-col :span="10">
  187. <el-time-picker clearable v-model="form.endBusinessTime" format="HH:mm" value-format="HH:mm" placeholder="结束时间" style="width: 100%" />
  188. </el-col>
  189. </el-row>
  190. </el-form-item>
  191. <el-form-item label="联系人" prop="contact">
  192. <el-input v-model="form.contact" placeholder="请输入联系人" />
  193. </el-form-item>
  194. <el-form-item label="联系电话" prop="contactNumber">
  195. <el-input v-model="form.contactNumber" placeholder="请输入联系电话" />
  196. </el-form-item>
  197. <el-form-item label="有效期至" prop="validity">
  198. <el-date-picker clearable v-model="form.validity" type="date" value-format="YYYY-MM-DD" placeholder="请选择有效期至" style="width: 100%" />
  199. </el-form-item>
  200. <el-form-item label="所属站点" prop="site">
  201. <el-cascader v-model="regionValue" :options="areaOptions" :props="{ value: 'id', label: 'name' }" placeholder="选择站点" style="width: 100%" @change="handleAreaChange" />
  202. </el-form-item>
  203. <el-form-item label="详细地址">
  204. <el-row :gutter="10" style="margin-bottom: 10px">
  205. <el-col :span="24">
  206. <el-cascader v-model="addressCascaderValue" :options="regionData" placeholder="选择省市区" style="width: 100%" />
  207. </el-col>
  208. </el-row>
  209. <el-input v-model="form.detailAddress" type="textarea" placeholder="输入详细地址" rows="3" style="width: 100%" />
  210. </el-form-item>
  211. <el-form-item>
  212. <el-button type="primary" style="width: 100%" @click="getGeolocation" :loading="geoLoading">获取经纬度</el-button>
  213. <div v-if="geoErrorMsg" class="geo-error-tip">{{ geoErrorMsg }}</div>
  214. </el-form-item>
  215. <el-row :gutter="10">
  216. <el-col :span="12">
  217. <el-form-item label="经度" prop="longitude">
  218. <el-input v-model="form.longitude" placeholder="请获取/输入位置经度" />
  219. </el-form-item>
  220. </el-col>
  221. <el-col :span="12">
  222. <el-form-item label="纬度" prop="latitude">
  223. <el-input v-model="form.latitude" placeholder="请获取/输入位置纬度" />
  224. </el-form-item>
  225. </el-col>
  226. </el-row>
  227. </el-form>
  228. <template #footer>
  229. <div class="dialog-footer">
  230. <el-button @click="cancel">取 消</el-button>
  231. <el-button :loading="buttonLoading" type="primary" @click="submitForm">确 定</el-button>
  232. </div>
  233. </template>
  234. </el-dialog>
  235. <!-- 门店详情对话框 -->
  236. <el-dialog :title="detailDialog.title" v-model="detailDialog.visible" width="800px" append-to-body>
  237. <el-tabs v-model="activeTab" style="padding: 0 10px;">
  238. <el-tab-pane label="基础信息" name="basic">
  239. <el-descriptions :column="2" border>
  240. <el-descriptions-item label="门店名称">{{ detailData.name }}</el-descriptions-item>
  241. <el-descriptions-item label="商户分类">{{ detailData.tenantCatergoriesName || '-' }}</el-descriptions-item>
  242. <el-descriptions-item label="所属品牌">{{ detailData.tenantName || '-' }}</el-descriptions-item>
  243. <el-descriptions-item label="营业时间">{{ formatTime(detailData.startBusinessTime) }} - {{ formatTime(detailData.endBusinessTime) }}</el-descriptions-item>
  244. <el-descriptions-item label="有效期至">{{ parseTime(detailData.validity, '{y}-{m}-{d}') }}</el-descriptions-item>
  245. <el-descriptions-item label="联系人">{{ detailData.contact }}</el-descriptions-item>
  246. <el-descriptions-item label="联系电话">{{ detailData.contactNumber }}</el-descriptions-item>
  247. <el-descriptions-item label="归属站点">{{ detailData.siteName }}</el-descriptions-item>
  248. <el-descriptions-item label="详细地址">{{ getFullAddress(detailData) }}</el-descriptions-item>
  249. <el-descriptions-item label="营业执照">
  250. <image-preview v-if="detailData.businessLicenseUrl" :src="detailData.businessLicenseUrl" :width="80" :height="60" />
  251. <span v-else>-</span>
  252. </el-descriptions-item>
  253. </el-descriptions>
  254. </el-tab-pane>
  255. <el-tab-pane label="服务订单记录" name="orders">
  256. <el-table :data="orderList" border style="width: 100%" v-loading="orderLoading">
  257. <el-table-column label="订单号" prop="code" min-width="150" />
  258. <el-table-column label="服务项目" min-width="120">
  259. <template #default="scope">
  260. {{ getServiceName(scope.row.service) }}
  261. </template>
  262. </el-table-column>
  263. <el-table-column label="客户" prop="customer" min-width="100" />
  264. <el-table-column label="金额" min-width="100">
  265. <template #default="scope">
  266. <span>¥{{ (scope.row.price / 100).toFixed(2) }}</span>
  267. </template>
  268. </el-table-column>
  269. <el-table-column label="下单时间" prop="createTime" min-width="160" />
  270. <el-table-column label="状态" align="center" width="100">
  271. <template #default="scope">
  272. <el-tag :type="getOrderStatusType(scope.row.status)" effect="plain" size="small">{{ getOrderStatusName(scope.row.status) }}</el-tag>
  273. </template>
  274. </el-table-column>
  275. </el-table>
  276. <pagination v-show="orderTotal > 0" :total="orderTotal" v-model:page="orderQueryParams.pageNum"
  277. v-model:limit="orderQueryParams.pageSize" @pagination="getOrderList" />
  278. </el-tab-pane>
  279. </el-tabs>
  280. <template #footer>
  281. <div class="dialog-footer">
  282. <el-button @click="detailDialog.visible = false">关 闭</el-button>
  283. </div>
  284. </template>
  285. </el-dialog>
  286. <!-- 门店续期对话框 -->
  287. <el-dialog title="门店续期" v-model="renewDialog.visible" width="400px" append-to-body>
  288. <el-form :model="renewForm" label-width="80px">
  289. <el-form-item label="有效期至">
  290. <el-date-picker
  291. v-model="renewForm.to"
  292. type="datetime"
  293. value-format="YYYY-MM-DD HH:mm:ss"
  294. placeholder="选择日期时间"
  295. style="width: 100%"
  296. />
  297. </el-form-item>
  298. </el-form>
  299. <template #footer>
  300. <div class="dialog-footer">
  301. <el-button @click="renewDialog.visible = false">取 消</el-button>
  302. <el-button type="primary" @click="submitRenew" :loading="renewLoading">确 定</el-button>
  303. </div>
  304. </template>
  305. </el-dialog>
  306. </div>
  307. </template>
  308. <script setup name="Store" lang="ts">
  309. import { listStore, getStore, delStore, addStore, updateStore, listStoreStatus, renewStore, banStore, enableStore } from '@/api/system/store';
  310. import { listSubOrderOnStore } from '@/api/order/subOrder/index';
  311. import { StoreVO, StoreForm, StoreQuery, StoreStatusVO, SysStorePageBo } from '@/api/system/store/types';
  312. import { listOnStore } from '@/api/system/tenant';
  313. import { listOnStore as listTenantCategoriesOnStore } from '@/api/system/tenantCategories';
  314. import { listAllService } from '@/api/service/list';
  315. import { listAreaStation } from '@/api/system/areaStation';
  316. import { getMapSetting } from '@/api/system/mapSetting';
  317. import { AreaStationVO } from '@/api/system/areaStation/types';
  318. import { regionData, codeToText, textToCode } from 'element-china-area-data';
  319. import PageSelect from '@/components/PageSelect/index.vue';
  320. import { checkPermi } from '@/utils/permission';
  321. const { proxy } = getCurrentInstance() as ComponentInternalInstance;
  322. const storeList = ref<StoreVO[]>([]);
  323. const buttonLoading = ref(false);
  324. const loading = ref(true);
  325. const total = ref(0);
  326. const storeFormRef = ref<ElFormInstance>();
  327. const searchRegionValue = ref<any[]>([]); // 搜索的区域值
  328. const searchSiteOptions = ref<any[]>([]); // 搜索的站点选项
  329. const regionValue = ref<any[]>([]); // 所在区域/站点的路径值
  330. const province = ref('');
  331. const city = ref('');
  332. const district = ref('');
  333. const addressCascaderValue = ref<any[]>([]); // 省市区级联选择器值
  334. const brandList = ref<any[]>([]); // 品牌列表
  335. const brandLoading = ref(false); // 品牌加载状态
  336. const currentPage = ref(1); // 当前页码
  337. const brandKeyword = ref(''); // 搜索关键词
  338. const brandSelectVisible = ref(false); // 品牌选择框可见状态
  339. const brandTotal = ref(0); // 品牌总数
  340. const serviceList = ref<any[]>([]); // 服务项目列表
  341. const statusList = ref<StoreStatusVO[]>([]); // 状态列表
  342. const tenantCategoriesList = ref<any[]>([]); // 商户分类列表
  343. const tenantCategoriesTotal = ref(0); // 商户分类总数
  344. const areaStationList = ref<AreaStationVO[]>([]); // 区域站点列表
  345. const areaOptions = ref<any[]>([]); // 所在区域树形选项
  346. const siteOptions = ref<any[]>([]); // 归属站点选项
  347. const dialog = reactive<DialogOption>({
  348. visible: false,
  349. title: ''
  350. });
  351. const detailDialog = reactive({
  352. visible: false,
  353. title: '门店详情'
  354. });
  355. const activeTab = ref('basic');
  356. const detailData = ref<any>({});
  357. const orderList = ref<any[]>([]);
  358. const orderTotal = ref(0);
  359. const orderLoading = ref(false);
  360. const orderQueryParams = reactive({
  361. pageNum: 1,
  362. pageSize: 10,
  363. storeId: undefined as any
  364. });
  365. const getOrderStatusName = (status: number) => {
  366. const map: any = { 0: '待派单', 1: '待接单', 2: '待服务', 3: '服务中', 4: '已完成', 5: '已取消' };
  367. return map[status] || '未知';
  368. };
  369. const getOrderStatusType = (status: number) => {
  370. const map: any = { 0: 'info', 1: 'warning', 2: 'primary', 3: 'success', 4: 'success', 5: 'danger' };
  371. return map[status] || 'info';
  372. };
  373. /** 查询订单记录 */
  374. const getOrderList = async () => {
  375. orderLoading.value = true;
  376. try {
  377. const res: any = await listSubOrderOnStore(orderQueryParams);
  378. orderList.value = res.rows;
  379. orderTotal.value = res.total;
  380. } finally {
  381. orderLoading.value = false;
  382. }
  383. };
  384. /** 详情按钮操作 */
  385. const handleDetail = async (row: StoreVO) => {
  386. const res = await getStore(row.id);
  387. detailData.value = { ...row, ...res.data };
  388. activeTab.value = 'basic';
  389. orderQueryParams.storeId = row.id;
  390. orderQueryParams.pageNum = 1;
  391. getOrderList();
  392. detailDialog.visible = true;
  393. };
  394. /** 续期对话框 */
  395. const renewDialog = reactive({
  396. visible: false
  397. });
  398. const renewForm = reactive({
  399. id: undefined as string | number | undefined,
  400. to: ''
  401. });
  402. const renewLoading = ref(false);
  403. /** 处理下拉菜单命令 */
  404. const handleCommand = (command: string, row: StoreVO) => {
  405. switch (command) {
  406. case 'handleRenew':
  407. handleRenew(row);
  408. break;
  409. case 'handleBan':
  410. handleBan(row);
  411. break;
  412. case 'handleEnable':
  413. handleEnable(row);
  414. break;
  415. default:
  416. break;
  417. }
  418. };
  419. /** 续期按钮操作 */
  420. const handleRenew = (row: StoreVO) => {
  421. renewForm.id = row.id;
  422. renewForm.to = '';
  423. renewDialog.visible = true;
  424. };
  425. /** 提交续期 */
  426. const submitRenew = async () => {
  427. if (!renewForm.to) {
  428. proxy?.$modal.msgError("请选择有效期");
  429. return;
  430. }
  431. renewLoading.value = true;
  432. try {
  433. await renewStore({
  434. id: renewForm.id!,
  435. to: renewForm.to
  436. });
  437. proxy?.$modal.msgSuccess("续期成功");
  438. renewDialog.visible = false;
  439. getList();
  440. } finally {
  441. renewLoading.value = false;
  442. }
  443. };
  444. /** 禁用按钮操作 */
  445. const handleBan = async (row: StoreVO) => {
  446. try {
  447. await proxy?.$modal.confirm('是否确认禁用门店?');
  448. loading.value = true;
  449. await banStore({ id: row.id });
  450. proxy?.$modal.msgSuccess("禁用成功");
  451. getList();
  452. } catch (err) {
  453. } finally {
  454. loading.value = false;
  455. }
  456. };
  457. /** 启用按钮操作 */
  458. const handleEnable = async (row: StoreVO) => {
  459. try {
  460. await proxy?.$modal.confirm('是否确认启用门店?');
  461. loading.value = true;
  462. await enableStore({ id: row.id });
  463. proxy?.$modal.msgSuccess("启用成功");
  464. getList();
  465. } catch (err) {
  466. } finally {
  467. loading.value = false;
  468. }
  469. };
  470. const initFormData: StoreForm = {
  471. id: undefined,
  472. logo: undefined,
  473. businessLicense: undefined,
  474. name: undefined,
  475. tenantCatergories: undefined,
  476. startBusinessTime: undefined,
  477. endBusinessTime: undefined,
  478. contact: undefined,
  479. contactNumber: undefined,
  480. validity: undefined,
  481. site: undefined,
  482. detailAddress: undefined,
  483. status: undefined,
  484. longitude: undefined,
  485. latitude: undefined,
  486. tenantId: undefined,
  487. services: [],
  488. regionId: undefined,
  489. areaCode: undefined,
  490. }
  491. const data = reactive<PageData<StoreForm, SysStorePageBo>>({
  492. form: { ...initFormData },
  493. queryParams: {
  494. pageNum: 1,
  495. pageSize: 10,
  496. storeOrContact: undefined,
  497. area: undefined,
  498. station: undefined,
  499. status: undefined,
  500. params: {}
  501. },
  502. rules: {
  503. businessLicense: [{ required: true, message: "营业执照不能为空", trigger: "blur" }],
  504. name: [{ required: true, message: "门店名称不能为空", trigger: "blur" }],
  505. tenantCatergories: [{ required: true, message: "商户分类不能为空", trigger: "change" }],
  506. startBusinessTime: [{ required: true, message: "开始营业时间不能为空", trigger: "blur" }],
  507. endBusinessTime: [{ required: true, message: "结束营业时间不能为空", trigger: "blur" }],
  508. contact: [{ required: true, message: "联系人不能为空", trigger: "blur" }],
  509. contactNumber: [{ required: true, message: "联系电话不能为空", trigger: "blur" }],
  510. validity: [{ required: true, message: "有效期至不能为空", trigger: "blur" }],
  511. tenantId: [{ required: true, message: "所属品牌不能为空", trigger: "change" }],
  512. regionId: [{ required: true, message: "所在区域不能为空", trigger: "change" }],
  513. site: [{ required: true, message: "归属站点不能为空", trigger: "change" }],
  514. }
  515. });
  516. const { queryParams, form, rules } = toRefs(data);
  517. /** 查询门店管理列表 */
  518. const getList = async () => {
  519. loading.value = true;
  520. const res = await listStore(queryParams.value);
  521. storeList.value = res.rows;
  522. total.value = res.total;
  523. loading.value = false;
  524. }
  525. /** 取消按钮 */
  526. const cancel = () => {
  527. reset();
  528. dialog.visible = false;
  529. }
  530. /** 表单重置 */
  531. const reset = () => {
  532. form.value = { ...initFormData };
  533. regionValue.value = [];
  534. province.value = '';
  535. city.value = '';
  536. district.value = '';
  537. addressCascaderValue.value = [];
  538. tenantCategoriesList.value = [];
  539. tenantCategoriesTotal.value = 0;
  540. brandList.value = [];
  541. brandTotal.value = 0;
  542. storeFormRef.value?.resetFields();
  543. }
  544. /** 搜索按钮操作 */
  545. const handleQuery = () => {
  546. queryParams.value.pageNum = 1;
  547. getList();
  548. }
  549. /** 新增按钮操作 */
  550. const handleAdd = () => {
  551. const hasStation = areaStationList.value.some(item => item.type === 2);
  552. if (!hasStation) {
  553. proxy?.$modal.msgWarning("请先配置站点");
  554. return;
  555. }
  556. reset();
  557. dialog.visible = true;
  558. dialog.title = "添加门店管理";
  559. }
  560. /** 修改按钮操作 */
  561. const handleUpdate = async (row: StoreVO) => {
  562. reset();
  563. const res = await getStore(row.id);
  564. Object.assign(form.value, res.data);
  565. if (form.value.startBusinessTime) {
  566. form.value.startBusinessTime = formatTime(form.value.startBusinessTime);
  567. }
  568. if (form.value.endBusinessTime) {
  569. form.value.endBusinessTime = formatTime(form.value.endBusinessTime);
  570. }
  571. if (res.data.tenantCatergories && (res.data as any).tenantCatergoriesName) {
  572. tenantCategoriesList.value = [{
  573. id: res.data.tenantCatergories,
  574. name: (res.data as any).tenantCatergoriesName
  575. }];
  576. tenantCategoriesTotal.value = 1;
  577. }
  578. if (res.data.tenantId && (res.data as any).tenantName) {
  579. brandList.value = [{
  580. tenantId: res.data.tenantId,
  581. name: (res.data as any).tenantName
  582. }];
  583. brandTotal.value = 1;
  584. }
  585. if (res.data.areaCode) {
  586. if (Array.isArray(res.data.areaCode)) {
  587. addressCascaderValue.value = res.data.areaCode;
  588. } else if (typeof res.data.areaCode === 'string') {
  589. addressCascaderValue.value = res.data.areaCode.split(',');
  590. }
  591. }
  592. if (res.data.site) {
  593. const path: any[] = [];
  594. let currentId = res.data.site;
  595. while (currentId && String(currentId) !== '0') {
  596. path.unshift(currentId);
  597. const currentArea = areaStationList.value.find((item: any) => String(item.id) === String(currentId));
  598. if (currentArea) {
  599. currentId = currentArea.parentId;
  600. } else {
  601. break;
  602. }
  603. }
  604. regionValue.value = path;
  605. form.value.site = res.data.site;
  606. const siteNode = areaStationList.value.find(n => n.id === res.data.site);
  607. if (siteNode) {
  608. form.value.regionId = siteNode.parentId;
  609. }
  610. }
  611. dialog.visible = true;
  612. dialog.title = "修改门店管理";
  613. }
  614. const geoLoading = ref(false);
  615. const geoErrorMsg = ref('');
  616. /** 提交按钮 */
  617. const submitForm = () => {
  618. storeFormRef.value?.validate(async (valid: boolean) => {
  619. if (valid) {
  620. buttonLoading.value = true;
  621. try {
  622. if (form.value.id) {
  623. await updateStore(form.value);
  624. } else {
  625. await addStore(form.value);
  626. }
  627. proxy?.$modal.msgSuccess("操作成功");
  628. dialog.visible = false;
  629. await getList();
  630. } finally {
  631. buttonLoading.value = false;
  632. }
  633. }
  634. });
  635. }
  636. /** 动态加载高德地图脚本 */
  637. const loadAMapScript = async (): Promise<any> => {
  638. try {
  639. // 从接口获取配置
  640. const res = await getMapSetting(1);
  641. if (res.code !== 200) {
  642. return Promise.reject(res.msg);
  643. }
  644. const { apiKey, apiSecret } = res.data;
  645. if (!apiKey) {
  646. return Promise.reject('No Map Key Configured');
  647. }
  648. // 设置安全密钥
  649. (window as any)._AMapSecurityConfig = {
  650. securityJsCode: apiSecret,
  651. };
  652. return new Promise((resolve, reject) => {
  653. if ((window as any).AMap) {
  654. resolve((window as any).AMap);
  655. return;
  656. }
  657. const script = document.createElement('script');
  658. script.src = `https://webapi.amap.com/maps?v=2.0&key=${apiKey}`;
  659. script.onload = () => resolve((window as any).AMap);
  660. script.onerror = () => {
  661. reject(new Error('Script load failed'));
  662. };
  663. document.head.appendChild(script);
  664. });
  665. } catch (error) {
  666. console.error('Map config fetch error:', error);
  667. // 此处不重复弹窗,因为接口请求失败通常已有全局拦截器处理显示错误
  668. return Promise.reject(error);
  669. }
  670. };
  671. /** 根据详细地址使用高德地图 Geocoder 获取经纬度 */
  672. const getGeolocation = async () => {
  673. // 拼接完整地址(省市区 + 详细地址)
  674. let areaText = '';
  675. if (addressCascaderValue.value && addressCascaderValue.value.length > 0) {
  676. areaText = addressCascaderValue.value.map((code: string) => codeToText[code] || '').join('');
  677. }
  678. const detailAddr = form.value.detailAddress || '';
  679. const fullAddress = (areaText + detailAddr).trim();
  680. if (!fullAddress) {
  681. proxy?.$modal.msgWarning('请先填写省市区和详细地址');
  682. return;
  683. }
  684. geoLoading.value = true;
  685. geoErrorMsg.value = '';
  686. try {
  687. // 确保高德地图脚本已加载
  688. await loadAMapScript();
  689. const AMap = (window as any).AMap;
  690. if (!AMap) {
  691. throw new Error('AMap is not defined');
  692. }
  693. const location: any = await new Promise((resolve, reject) => {
  694. AMap.plugin('AMap.Geocoder', () => {
  695. const geocoder = new AMap.Geocoder();
  696. geocoder.getLocation(fullAddress, (status: string, result: any) => {
  697. if (status === 'complete' && result.info === 'OK') {
  698. resolve(result.geocodes[0]?.location);
  699. } else {
  700. console.error('Geocoder fail:', status, result);
  701. reject(new Error(result.info || status || 'fail'));
  702. }
  703. });
  704. });
  705. // 增加 8s 超时
  706. setTimeout(() => reject(new Error('timeout')), 8000);
  707. });
  708. if (location) {
  709. form.value.longitude = location.lng.toFixed(6);
  710. form.value.latitude = location.lat.toFixed(6);
  711. proxy?.$modal.msgSuccess('获取经纬度成功');
  712. } else {
  713. throw new Error('no location');
  714. }
  715. } catch (err) {
  716. console.error('getGeolocation error:', err);
  717. geoErrorMsg.value = '经纬度获取失败,请联系管理员处理';
  718. } finally {
  719. geoLoading.value = false;
  720. }
  721. };
  722. /** 获取品牌列表 */
  723. const getBrandList = async (pageNum = 1, keyword = '', append = false) => {
  724. brandLoading.value = true;
  725. const res = await listOnStore({ pageNum, pageSize: 10 });
  726. if (res.code === 200) {
  727. brandList.value = append ? [...brandList.value, ...res.rows] : res.rows;
  728. brandTotal.value = res.total || 0;
  729. }
  730. brandLoading.value = false;
  731. };
  732. /** 获取服务项目列表 */
  733. const getServiceList = async () => {
  734. try {
  735. const res = await listAllService();
  736. serviceList.value = res.data || res;
  737. } catch (error) {
  738. console.error('获取服务项目列表失败:', error);
  739. }
  740. };
  741. /** 获取区域站点列表 */
  742. const getAreaStationList = async () => {
  743. try {
  744. const res = await listAreaStation();
  745. const data = res.data || res;
  746. areaStationList.value = data;
  747. areaOptions.value = buildTree(data, 0);
  748. } catch (error) {
  749. console.error('获取区域站点列表失败:', error);
  750. }
  751. };
  752. /** 构建树形结构 */
  753. const buildTree = (data: any[], parentId: any): any[] => {
  754. return data
  755. .filter(item => String(item.parentId) === String(parentId))
  756. .map(item => {
  757. const children = buildTree(data, item.id);
  758. const res: any = {
  759. id: item.id,
  760. name: item.name,
  761. // 如果不是站点且没有下级了,则不可选(防止误选区域叶子点)
  762. disabled: Number(item.type) !== 2 && (!children || children.length === 0)
  763. };
  764. if (children && children.length > 0) {
  765. res.children = children;
  766. }
  767. return res;
  768. });
  769. };
  770. /** 处理搜索区域选择变化 */
  771. const handleSearchAreaChange = (value: any[]) => {
  772. if (value && value.length > 0) {
  773. const lastId = value[value.length - 1];
  774. queryParams.value.station = lastId;
  775. const node = areaStationList.value.find(item => item.id === lastId);
  776. if (node) {
  777. queryParams.value.area = node.parentId;
  778. }
  779. } else {
  780. queryParams.value.station = undefined;
  781. queryParams.value.area = undefined;
  782. }
  783. handleQuery();
  784. };
  785. /** 处理对话框中的所在区域选择变化 */
  786. const handleAreaChange = (value: any[]) => {
  787. if (value && value.length > 0) {
  788. const lastId = value[value.length - 1];
  789. form.value.site = lastId;
  790. const node = areaStationList.value.find(item => item.id === lastId);
  791. if (node) {
  792. form.value.regionId = node.parentId;
  793. }
  794. } else {
  795. form.value.site = undefined;
  796. form.value.regionId = undefined;
  797. }
  798. };
  799. /** 获取商户分类列表 */
  800. const getTenantCategoriesList = async (pageNum = 1) => {
  801. try {
  802. const res = await listTenantCategoriesOnStore({ pageNum, pageSize: 10 });
  803. if (res.code === 200) {
  804. tenantCategoriesList.value = res.rows;
  805. tenantCategoriesTotal.value = res.total || 0;
  806. }
  807. } catch (error) {
  808. console.error('获取商户分类列表失败:', error);
  809. }
  810. };
  811. const handleBrandPageChange = (page: number) => { getBrandList(Number(page), brandKeyword.value, false); };
  812. const handleTenantCategoriesPageChange = (page: number) => { getTenantCategoriesList(Number(page)); };
  813. const handleTenantCategoriesVisibleChange = (visible: boolean) => { if (visible) getTenantCategoriesList(1); };
  814. const handleBrandSelectVisibleChange = (visible: boolean) => {
  815. brandSelectVisible.value = visible;
  816. if (visible) {
  817. currentPage.value = 1;
  818. getBrandList(1, brandKeyword.value, false);
  819. }
  820. };
  821. watch(addressCascaderValue, (newValue) => {
  822. form.value.areaCode = newValue && newValue.length > 0 ? newValue.join(',') : undefined;
  823. }, { deep: true });
  824. const getServiceName = (serviceId: number): string => {
  825. const service = serviceList.value.find(item => item.id === serviceId);
  826. return service ? service.name : String(serviceId);
  827. };
  828. const getStatusList = async () => {
  829. try {
  830. const res: any = await listStoreStatus();
  831. statusList.value = res.data || res.rows || res;
  832. } catch (error) {
  833. console.error('获取状态列表失败:', error);
  834. }
  835. };
  836. const formatTime = (time: string | number): string => {
  837. if (!time) return '';
  838. if (typeof time === 'string' && /^\d{2}:\d{2}$/.test(time)) return time;
  839. if (typeof time === 'string' && /^\d{2}:\d{2}:\d{2}$/.test(time)) return time.substring(0, 5);
  840. let date = (typeof time === 'string' && !time.includes('-') && !time.includes('T')) ? null : new Date(time);
  841. if (!date || isNaN(date.getTime())) return String(time);
  842. return `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
  843. };
  844. const getFullAddress = (row: any): string => {
  845. let areaText = '';
  846. if (row.areaCode) {
  847. const codes = typeof row.areaCode === 'string' ? row.areaCode.split(',') : row.areaCode;
  848. areaText = codes.map((code: string) => codeToText[code] || '').join('');
  849. }
  850. return areaText ? `${areaText} ${row.detailAddress || ''}` : (row.detailAddress || '');
  851. };
  852. /** 根据站点ID获取区域全称(向上遍历树形关系) */
  853. const getRegionNameBySite = (siteId: string | number): string => {
  854. if (!siteId || areaStationList.value.length === 0) return '正在加载...';
  855. const site = areaStationList.value.find(item => String(item.id) === String(siteId));
  856. if (!site) return '未知区域';
  857. let parentNames: string[] = [];
  858. let currentParentId = site.parentId;
  859. // 向上遍历直到父级ID为0或没找到父级
  860. while (currentParentId && String(currentParentId) !== '0') {
  861. const parent = areaStationList.value.find(item => String(item.id) === String(currentParentId));
  862. if (parent) {
  863. parentNames.unshift(parent.name);
  864. currentParentId = parent.parentId;
  865. } else {
  866. break;
  867. }
  868. }
  869. return parentNames.length > 0 ? parentNames.join('/') : '顶级区域';
  870. };
  871. onMounted(() => {
  872. getList();
  873. getBrandList();
  874. getServiceList();
  875. getAreaStationList();
  876. getStatusList();
  877. // 提前加载高德地图脚本,加快首次地理编码速度
  878. loadAMapScript().catch(() => {
  879. console.warn('高德地图预加载失败,将在首次使用时重试');
  880. });
  881. });
  882. </script>
  883. <style scoped lang="scss">
  884. .page-container {
  885. padding: 20px;
  886. background-color: #f5f7f9;
  887. min-height: 100%;
  888. }
  889. .table-card {
  890. border: none;
  891. border-radius: 8px;
  892. :deep(.el-card__header) {
  893. padding: 20px 24px;
  894. border-bottom: 1px solid #f0f0f0;
  895. }
  896. }
  897. .card-header {
  898. display: flex;
  899. justify-content: space-between;
  900. align-items: center;
  901. }
  902. .header-left {
  903. .title {
  904. font-size: 16px;
  905. font-weight: 600;
  906. color: #333;
  907. }
  908. }
  909. .header-right {
  910. display: flex;
  911. gap: 12px;
  912. .search-input { width: 200px; }
  913. .station-select { width: 420px; flex-shrink: 0; }
  914. .status-select { width: 140px; }
  915. :deep(.el-input__wrapper) {
  916. background-color: #f4f5f7;
  917. box-shadow: none;
  918. border: 1px solid transparent;
  919. &:hover, &.is-focus {
  920. border-color: #409eff;
  921. background-color: #fff;
  922. }
  923. }
  924. }
  925. .store-info-box {
  926. display: flex;
  927. align-items: center;
  928. gap: 12px;
  929. .store-desc {
  930. .name {
  931. font-weight: 600;
  932. color: #333;
  933. margin-bottom: 4px;
  934. }
  935. .tags {
  936. display: flex;
  937. gap: 4px;
  938. }
  939. }
  940. }
  941. .service-tags {
  942. display: flex;
  943. flex-wrap: wrap;
  944. gap: 4px;
  945. }
  946. .region-info {
  947. .region-name {
  948. font-weight: 500;
  949. color: #333;
  950. }
  951. .site-name {
  952. font-size: 12px;
  953. color: #909399;
  954. display: flex;
  955. align-items: center;
  956. gap: 4px;
  957. margin-top: 2px;
  958. }
  959. }
  960. .contact-info {
  961. .contact-item {
  962. display: flex;
  963. align-items: center;
  964. gap: 4px;
  965. font-size: 13px;
  966. color: #606266;
  967. &.phone {
  968. color: #409eff;
  969. margin-top: 2px;
  970. }
  971. .el-icon { font-size: 14px; }
  972. }
  973. }
  974. .time-text, .count-text {
  975. font-weight: 500;
  976. color: #606266;
  977. }
  978. .status-tag {
  979. border: none;
  980. font-weight: 500;
  981. }
  982. .op-btns {
  983. display: flex;
  984. justify-content: flex-end;
  985. gap: 12px;
  986. }
  987. .geo-error-tip {
  988. color: #f56c6c;
  989. font-size: 13px;
  990. margin-top: 8px;
  991. width: 100%;
  992. text-align: center;
  993. }
  994. .delete-item {
  995. color: #f56c6c !important;
  996. &:hover {
  997. color: #f56c6c !important;
  998. background-color: #fef0f0 !important;
  999. }
  1000. }
  1001. .pagination-container {
  1002. margin-top: 24px;
  1003. display: flex;
  1004. justify-content: flex-end;
  1005. }
  1006. :deep(.el-table) {
  1007. --el-table-border-color: #f0f0f0;
  1008. th.el-table__cell {
  1009. font-weight: 600;
  1010. }
  1011. td.el-table__cell {
  1012. padding: 12px 0;
  1013. }
  1014. }
  1015. </style>