index.vue 57 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712
  1. <!-- API Key管理 -->
  2. <template>
  3. <div class="app-container">
  4. <el-tabs v-model="activeTab" type="card">
  5. <!-- API Key管理 -->
  6. <el-tab-pane label="API Key管理" name="management">
  7. <PageSearch
  8. ref="searchRef"
  9. :search-config="searchConfig"
  10. @query-click="handleQueryClick"
  11. @reset-click="handleResetClick"
  12. />
  13. <PageContent ref="contentRef" :content-config="contentConfig">
  14. <template #toolbar="{ toolbarRight, onToolbar, removeIds, cols }">
  15. <CrudToolbarLeft
  16. :remove-ids="removeIds"
  17. :perm-create="['module_system:tenant:api-key:create']"
  18. :perm-delete="['module_system:tenant:api-key:delete']"
  19. @add="handleOpenDialog('create')"
  20. @delete="onToolbar('delete')"
  21. />
  22. <div class="data-table__toolbar--right">
  23. <CrudToolbarRight :buttons="toolbarRight" :cols="cols" :on-toolbar="onToolbar" />
  24. </div>
  25. </template>
  26. <template #table="{ data, loading, tableRef, onSelectionChange, pagination }">
  27. <div class="data-table__content">
  28. <el-table
  29. :ref="tableRef as any"
  30. v-loading="loading"
  31. row-key="id"
  32. :data="data"
  33. height="100%"
  34. border
  35. stripe
  36. @selection-change="onSelectionChange"
  37. >
  38. <template #empty>
  39. <el-empty :image-size="80" description="暂无数据" />
  40. </template>
  41. <el-table-column
  42. v-if="contentCols.find((col) => col.prop === 'selection')?.show"
  43. type="selection"
  44. min-width="55"
  45. align="center"
  46. />
  47. <el-table-column
  48. v-if="contentCols.find((col) => col.prop === 'api_key')?.show"
  49. key="api_key"
  50. label="API Key"
  51. prop="api_key"
  52. min-width="300"
  53. show-overflow-tooltip
  54. >
  55. <template #default="scope">
  56. <div class="api-key-container">
  57. <span>{{ scope.row.api_key }}</span>
  58. <el-button
  59. type="text"
  60. size="small"
  61. @click="copyToClipboard(scope.row.api_key)"
  62. >
  63. 复制
  64. </el-button>
  65. </div>
  66. </template>
  67. </el-table-column>
  68. <el-table-column
  69. v-if="contentCols.find((col) => col.prop === 'status')?.show"
  70. key="status"
  71. label="状态"
  72. prop="status"
  73. min-width="80"
  74. align="center"
  75. >
  76. <template #default="scope">
  77. <el-tag :type="scope.row.status === '0' ? 'success' : 'danger'">
  78. {{ scope.row.status === "0" ? "正常" : "禁用" }}
  79. </el-tag>
  80. </template>
  81. </el-table-column>
  82. <el-table-column
  83. v-if="contentCols.find((col) => col.prop === 'expired_at')?.show"
  84. key="expired_at"
  85. label="过期时间"
  86. prop="expired_at"
  87. min-width="180"
  88. show-overflow-tooltip
  89. />
  90. <el-table-column
  91. v-if="contentCols.find((col) => col.prop === 'return_url')?.show"
  92. key="return_url"
  93. label="回调地址"
  94. prop="return_url"
  95. min-width="200"
  96. show-overflow-tooltip
  97. />
  98. <!-- <el-table-column
  99. v-if="contentCols.find((col) => col.prop === 'last_used_at')?.show"
  100. key="last_used_at"
  101. label="最后使用时间"
  102. prop="last_used_at"
  103. min-width="180"
  104. show-overflow-tooltip
  105. >
  106. <template #default="scope">
  107. {{ scope.row.last_used_at || "未使用" }}
  108. </template>
  109. </el-table-column> -->
  110. <el-table-column
  111. v-if="contentCols.find((col) => col.prop === 'description')?.show"
  112. key="description"
  113. label="描述"
  114. prop="description"
  115. min-width="150"
  116. show-overflow-tooltip
  117. />
  118. <el-table-column
  119. v-if="contentCols.find((col) => col.prop === 'created_time')?.show"
  120. key="created_time"
  121. label="创建时间"
  122. prop="created_time"
  123. min-width="180"
  124. show-overflow-tooltip
  125. />
  126. <el-table-column
  127. v-if="contentCols.find((col) => col.prop === 'operation')?.show"
  128. fixed="right"
  129. label="操作"
  130. align="center"
  131. min-width="200"
  132. >
  133. <template #default="scope">
  134. <el-button
  135. v-hasPerm="['module_system:tenant:api-key:update']"
  136. type="primary"
  137. size="small"
  138. link
  139. @click="handleUpdateStatus(scope.row.id, scope.row.status === '0' ? '1' : '0')"
  140. >
  141. {{ scope.row.status === '0' ? '禁用' : '启用' }}
  142. </el-button>
  143. <el-button
  144. v-hasPerm="['module_system:tenant:api-key:delete']"
  145. type="danger"
  146. size="small"
  147. link
  148. @click="handleRowDelete(scope.row.id)"
  149. >
  150. 删除
  151. </el-button>
  152. </template>
  153. </el-table-column>
  154. </el-table>
  155. </div>
  156. </template>
  157. </PageContent>
  158. <EnhancedDialog
  159. v-model="dialogVisible.visible"
  160. :title="dialogVisible.title"
  161. @close="handleCloseDialog"
  162. width="500"
  163. >
  164. <el-form
  165. ref="dataFormRef"
  166. :model="formData"
  167. :rules="rules"
  168. label-suffix=":"
  169. label-width="auto"
  170. label-position="right"
  171. >
  172. <!-- <el-form-item label="租户ID" prop="tenant_id">
  173. <el-input v-model="formData.tenant_id" placeholder="可选,默认使用当前租户" :maxlength="20" />
  174. </el-form-item> -->
  175. <el-form-item label="过期天数" prop="expired_days">
  176. <el-input v-model.number="formData.expired_days" type="number" placeholder="请输入过期天数" min="1" :maxlength="4" />
  177. </el-form-item>
  178. <el-form-item label="描述" prop="description">
  179. <el-input
  180. v-model="formData.description"
  181. type="textarea"
  182. :rows="3"
  183. placeholder="请输入描述(可选)"
  184. :maxlength="255"
  185. />
  186. </el-form-item>
  187. <el-form-item label="回调地址" prop="return_url">
  188. <el-input
  189. v-model="formData.return_url"
  190. type="textarea"
  191. :rows="2"
  192. placeholder="请输入回调地址(可选)"
  193. :maxlength="255"
  194. />
  195. </el-form-item>
  196. </el-form>
  197. <template #footer>
  198. <div class="dialog-footer">
  199. <el-button @click="handleCloseDialog">取消</el-button>
  200. <el-button
  201. v-hasPerm="['module_system:tenant:api-key:create']"
  202. type="primary"
  203. :loading="submitLoading"
  204. @click="handleSubmit"
  205. >
  206. 确定
  207. </el-button>
  208. </div>
  209. </template>
  210. </EnhancedDialog>
  211. <EnhancedDialog
  212. v-model="apiKeyDetailVisible"
  213. title="API Key详情"
  214. width="600"
  215. :close-on-press-escape="false"
  216. :close-on-click-modal="false"
  217. @close="handleCloseApiKeyDetail"
  218. >
  219. <el-alert
  220. title="请及时保存API Key和Secret,关闭后无法再次查看"
  221. type="warning"
  222. :closable="false"
  223. show-icon
  224. style="margin-bottom: 16px"
  225. />
  226. <el-descriptions :column="1" border>
  227. <el-descriptions-item label="API Key">
  228. <div class="api-key-detail">
  229. <span>{{ apiKeyDetail.api_key }}</span>
  230. <el-button type="text" size="small" @click="copyToClipboard(apiKeyDetail.api_key)">
  231. 复制
  232. </el-button>
  233. </div>
  234. </el-descriptions-item>
  235. <el-descriptions-item label="API Secret">
  236. <div class="api-key-detail">
  237. <span>{{ apiKeyDetail.api_secret }}</span>
  238. <el-button type="text" size="small" @click="copyToClipboard(apiKeyDetail.api_secret || '')">
  239. 复制
  240. </el-button>
  241. </div>
  242. </el-descriptions-item>
  243. <el-descriptions-item label="状态">
  244. <el-tag :type="apiKeyDetail.status === '0' ? 'success' : 'danger'">
  245. {{ apiKeyDetail.status === "0" ? "正常" : "禁用" }}
  246. </el-tag>
  247. </el-descriptions-item>
  248. <el-descriptions-item label="过期时间">
  249. {{ apiKeyDetail.expired_at }}
  250. </el-descriptions-item>
  251. <el-descriptions-item label="创建时间">
  252. {{ apiKeyDetail.created_time }}
  253. </el-descriptions-item>
  254. <el-descriptions-item label="描述">
  255. {{ apiKeyDetail.description || "无" }}
  256. </el-descriptions-item>
  257. <el-descriptions-item label="回调地址">
  258. {{ apiKeyDetail.return_url || "无" }}
  259. </el-descriptions-item>
  260. </el-descriptions>
  261. <template #footer>
  262. <div class="dialog-footer">
  263. <el-button @click="downloadApiKeyCsv">下载CSV</el-button>
  264. <el-button type="primary" @click="handleConfirmApiKeyDetail">确认已保存</el-button>
  265. </div>
  266. </template>
  267. </EnhancedDialog>
  268. </el-tab-pane>
  269. <!-- 应用配置 -->
  270. <el-tab-pane label="应用配置" name="config">
  271. <div class="config-layout">
  272. <!-- 查询中显示loading -->
  273. <el-card v-if="configLoading" v-loading="configLoading" class="config-card">
  274. <template #header>
  275. <div class="card-header">
  276. <span>开放平台配置</span>
  277. </div>
  278. </template>
  279. </el-card>
  280. <!-- 配置不存在时显示创建按钮 -->
  281. <el-card v-else-if="!configExists" class="config-card">
  282. <template #header>
  283. <div class="card-header">
  284. <span>开放平台配置</span>
  285. </div>
  286. </template>
  287. <div class="config-empty">
  288. <el-empty description="暂无开放平台配置" />
  289. <p style="margin: 16px 0; color: #606266;">
  290. 请先创建开放平台配置,才能收到平台回调通知。
  291. </p>
  292. <el-button
  293. type="primary"
  294. :loading="configLoading"
  295. @click="handleCreateConfig"
  296. >
  297. 创建配置
  298. </el-button>
  299. </div>
  300. </el-card>
  301. <!-- 配置存在时显示编辑表单 -->
  302. <el-card v-else class="config-card">
  303. <template #header>
  304. <div class="card-header">
  305. <span>开放平台配置</span>
  306. </div>
  307. </template>
  308. <el-form
  309. ref="configFormRef"
  310. :model="configForm"
  311. label-width="140px"
  312. label-position="right"
  313. style="max-width: 600px"
  314. >
  315. <el-form-item label="应用ID">
  316. <el-input v-model="configForm.app_id" disabled>
  317. <template #append>
  318. <el-button @click="copyToClipboard(configForm.app_id)">
  319. <el-icon><CopyDocument /></el-icon>
  320. </el-button>
  321. </template>
  322. </el-input>
  323. </el-form-item>
  324. <el-form-item label="网关地址">
  325. <el-input v-model="configForm.gateway_url" disabled>
  326. <template #append>
  327. <el-button @click="copyToClipboard(configForm.gateway_url)">
  328. <el-icon><CopyDocument /></el-icon>
  329. </el-button>
  330. </template>
  331. </el-input>
  332. </el-form-item>
  333. <!-- <el-form-item label="异步通知地址">
  334. <el-input
  335. v-model="configForm.notify_url"
  336. placeholder="请输入异步通知地址"
  337. clearable
  338. />
  339. </el-form-item> -->
  340. <el-form-item label="回调地址">
  341. <el-input
  342. v-model="configForm.return_url"
  343. placeholder="请输入回调地址"
  344. clearable
  345. />
  346. </el-form-item>
  347. <el-form-item label="状态">
  348. <el-tag :type="configForm.status === 'ENABLED' ? 'success' : 'info'">
  349. {{ configForm.status === 'ENABLED' ? '启用' : '禁用' }}
  350. </el-tag>
  351. </el-form-item>
  352. <!-- <el-form-item label="描述">
  353. <el-input
  354. v-model="configForm.description"
  355. type="textarea"
  356. :rows="2"
  357. disabled
  358. />
  359. </el-form-item> -->
  360. <el-form-item>
  361. <el-button
  362. type="primary"
  363. :loading="configLoading"
  364. @click="handleSaveConfig"
  365. >
  366. 保存配置
  367. </el-button>
  368. </el-form-item>
  369. </el-form>
  370. </el-card>
  371. </div>
  372. </el-tab-pane>
  373. <!-- 接入文档 -->
  374. <el-tab-pane label="接入文档" name="docs">
  375. <div class="docs-layout">
  376. <!-- 左侧导航 -->
  377. <div class="docs-sidebar">
  378. <el-scrollbar>
  379. <div class="sidebar-section">
  380. <h3>认证</h3>
  381. <ul>
  382. <li @click="activeSection = 'auth'" :class="{ active: activeSection === 'auth' }">认证方式</li>
  383. <li @click="activeSection = 'signature'" :class="{ active: activeSection === 'signature' }">签名验证</li>
  384. <li @click="activeSection = 'notes'" :class="{ active: activeSection === 'notes' }">注意事项</li>
  385. </ul>
  386. </div>
  387. <div class="sidebar-section">
  388. <h3>账户接口</h3>
  389. <ul>
  390. <li @click="activeSection = 'transfer'" :class="{ active: activeSection === 'transfer' }">发起转账</li>
  391. <li @click="activeSection = 'transfer_query'" :class="{ active: activeSection === 'transfer_query' }">查询转账</li>
  392. <li @click="activeSection = 'balance'" :class="{ active: activeSection === 'balance' }">账户余额</li>
  393. </ul>
  394. </div>
  395. <div class="sidebar-section">
  396. <h3>回调通知</h3>
  397. <ul>
  398. <li @click="activeSection = 'callback'" :class="{ active: activeSection === 'callback' }">通知接口</li>
  399. </ul>
  400. </div>
  401. <div class="sidebar-section">
  402. <h3>其他</h3>
  403. <ul>
  404. <li @click="activeSection = 'errors'" :class="{ active: activeSection === 'errors' }">常见错误</li>
  405. <li @click="activeSection = 'php'" :class="{ active: activeSection === 'php' }">PHP示例代码</li>
  406. </ul> identity_type
  407. </div>
  408. </el-scrollbar>
  409. </div>
  410. <!-- 右侧内容 -->
  411. <div class="docs-content">
  412. <el-card>
  413. <template #header>
  414. <div class="card-header">
  415. <span>{{ getSectionTitle() }}</span>
  416. </div>
  417. </template>
  418. <div v-if="activeSection === 'auth'" class="section-content">
  419. <h2>1. 认证方式</h2>
  420. <p>使用API Key进行认证时,需要在请求头中添加以下信息:</p>
  421. <pre><code>Authorization: ApiKey {api_key}
  422. Signature: {signature}</code></pre>
  423. <p>其中:</p>
  424. <ul>
  425. <li><strong>Authorization</strong>:API Key认证头,格式为 <code>ApiKey {api_key}</code></li>
  426. <li><strong>Signature</strong>:请求签名(必填),用于验证请求数据的完整性</li>
  427. </ul>
  428. </div>
  429. <div v-else-if="activeSection === 'signature'" class="section-content">
  430. <h2>2. 签名验证</h2>
  431. <p>签名用于验证请求数据的完整性,防止数据被篡改。签名生成步骤:</p>
  432. <ol>
  433. <li>过滤请求参数:排除 <code>sign</code> 参数、<code>null</code> 值、空字符串、空数组、空对象</li>
  434. <li>将过滤后的参数按参数名ASCII码升序排序</li>
  435. <li>对字典或列表类型的值进行JSON序列化(<code>sort_keys=true</code>,<code>separators=(',', ':')</code>)</li>
  436. <li>对每个参数值进行URL编码(<code>UTF-8</code>编码)</li>
  437. <li>将排序后的参数拼接为字符串:<code>key1=value1&amp;key2=value2</code></li>
  438. <li>使用API Secret作为密钥,通过HMAC-SHA256算法生成签名</li>
  439. <li>将签名添加到请求头 <code>Signature</code> 中</li>
  440. </ol>
  441. <h3>2.1 签名计算示例</h3>
  442. <pre><code># 原始请求数据
  443. {
  444. "account_book_id": "123456",
  445. "amount": "100.00",
  446. "payee_info": {
  447. "identity_type": "ALIPAY_ACCOUNT",
  448. "name": "张三",
  449. "identity": "zhangsan@example.com"
  450. },
  451. "sign": "不需要参与签名",
  452. "empty_param": "",
  453. "null_param": null
  454. }
  455. # 1. 过滤后(排除sign、空字符串、null)
  456. {
  457. "account_book_id": "123456",
  458. "amount": "100.00",
  459. "payee_info": {
  460. "identity_type": "ALIPAY_ACCOUNT",
  461. "name": "张三",
  462. "identity": "zhangsan@example.com"
  463. }
  464. }
  465. # 2. 按参数名升序排序
  466. account_book_id, amount, payee_info
  467. # 3. JSON序列化嵌套对象
  468. payee_info={"identity":"zhangsan@example.com","identity_type":"ALIPAY_ACCOUNT","name":"张三"}
  469. # 4. URL编码(处理中文)
  470. name=%E5%BC%A0%E4%B8%89
  471. # 5. 拼接字符串
  472. account_book_id=123456&amount=100.0&payee_info=%7B%22identity%22%3A%22zhangsan%40example.com%22%2C%22identity_type%22%3A%22ALIPAY_ACCOUNT%22%2C%22name%22%3A%22%E5%BC%A0%E4%B8%89%22%7D
  473. # 6. HMAC-SHA256签名(密钥为API Secret)
  474. signature = HMAC-SHA256(api_secret, sign_str)
  475. # 7. 请求头中添加签名
  476. Signature: {signature}</code></pre>
  477. </div>
  478. <div v-else-if="activeSection === 'notes'" class="section-content">
  479. <h2>3. 注意事项</h2>
  480. <ul>
  481. <li>API Key和Secret请妥善保管,不要泄露给他人</li>
  482. <li>签名验证是<strong>必填</strong>的,未带签名或签名错误将返回401</li>
  483. <li>签名使用HMAC-SHA256算法,密钥为API Secret</li>
  484. <li>签名计算前会自动过滤:<code>sign</code>参数、<code>null</code>值、空字符串、空数组、空对象</li>
  485. <li>嵌套对象(如<code>payee_info</code>)会先进行JSON序列化再参与签名</li>
  486. <li>参数值会进行URL编码(UTF-8),确保中文字符正确处理</li>
  487. <li>定期更新API Key,建议每3-6个月更换一次</li>
  488. <li>如发现API Key泄露,请立即禁用并重新生成</li>
  489. <li>API Key有过期时间,请在过期前及时更新</li>
  490. </ul>
  491. </div>
  492. <div v-else-if="activeSection === 'transfer'" class="section-content">
  493. <h2>4. 发起转账</h2>
  494. <h3>4.1 接口说明</h3>
  495. <p>从资金账户转账到支付宝账户/银行卡</p>
  496. <h4>API接口地址</h4>
  497. <p><code>POST https://api.qcsj88888.com/payment/openapi/account/transfer</code></p>
  498. <h4>请求参数</h4>
  499. <table class="api-table">
  500. <thead>
  501. <tr>
  502. <th>参数名</th>
  503. <th>类型</th>
  504. <th>是否必填</th>
  505. <th>描述</th>
  506. </tr>
  507. </thead>
  508. <tbody>
  509. <tr>
  510. <td>account_book_id</td>
  511. <td>string</td>
  512. <td>是</td>
  513. <td>付款方资金账户号</td>
  514. </tr>
  515. <tr>
  516. <td>amount</td>
  517. <td>string</td>
  518. <td>是</td>
  519. <td>转账金额,单位为元,精确到小数点后两位,大于0.02元</td>
  520. </tr>
  521. <tr>
  522. <td>order_title</td>
  523. <td>string</td>
  524. <td>否</td>
  525. <td>转账标题</td>
  526. </tr>
  527. <tr>
  528. <td>remark</td>
  529. <td>string</td>
  530. <td>否</td>
  531. <td>转账备注</td>
  532. </tr>
  533. <tr>
  534. <td>third_biz_no</td>
  535. <td>string</td>
  536. <td>是</td>
  537. <td>三方订单号(商户侧唯一标识,不可重复)</td>
  538. </tr>
  539. <tr>
  540. <td>payee_info</td>
  541. <td>object</td>
  542. <td>是</td>
  543. <td>
  544. <div class="expandable-section">
  545. <span @click="toggleExpand('payee_info')" class="expand-btn">
  546. {{ expandedSections.payee_info ? '▼' : '▶' }} 收款方信息
  547. </span>
  548. <div v-if="expandedSections.payee_info" class="expandable-content">
  549. <table class="api-table nested-table">
  550. <thead>
  551. <tr>
  552. <th>参数名</th>
  553. <th>类型</th>
  554. <th>是否必填</th>
  555. <th>描述</th>
  556. </tr>
  557. </thead>
  558. <tbody>
  559. <tr>
  560. <td>identity_type</td>
  561. <td>string</td>
  562. <td>是</td>
  563. <td>收款方类型:alipay(支付宝账户)/ bank(银行卡)</td>
  564. </tr>
  565. <tr>
  566. <td>name</td>
  567. <td>string</td>
  568. <td>是</td>
  569. <td>收款方真实姓名</td>
  570. </tr>
  571. <tr>
  572. <td>identity</td>
  573. <td>string</td>
  574. <td>是</td>
  575. <td>收款方唯一标识(支付宝账号/银行卡号)</td>
  576. </tr>
  577. <tr>
  578. <td>bankcard_ext_info</td>
  579. <td>object</td>
  580. <td>否</td>
  581. <td>
  582. <div class="expandable-section">
  583. <span @click="toggleExpand('bankcard_ext_info')" class="expand-btn">
  584. {{ expandedSections.bankcard_ext_info ? '▼' : '▶' }} 银行卡信息(当 identity_type 为 bank 时必填)
  585. </span>
  586. <div v-if="expandedSections.bankcard_ext_info" class="expandable-content">
  587. <table class="api-table nested-table">
  588. <thead>
  589. <tr>
  590. <th>参数名</th>
  591. <th>类型</th>
  592. <th>是否必填</th>
  593. <th>描述</th>
  594. </tr>
  595. </thead>
  596. <tbody>
  597. <tr>
  598. <td>account_type</td>
  599. <td>string</td>
  600. <td>是</td>
  601. <td>收款账户类型: 1/2。对公: 1,对私: 2</td>
  602. </tr>
  603. <tr>
  604. <td>inst_name</td>
  605. <td>string</td>
  606. <td>否</td>
  607. <td>机构名称(当 account_type 是 1 时必填)</td>
  608. </tr>
  609. <tr>
  610. <td>inst_province</td>
  611. <td>string</td>
  612. <td>否</td>
  613. <td>银行所在省份</td>
  614. </tr>
  615. <tr>
  616. <td>inst_city</td>
  617. <td>string</td>
  618. <td>否</td>
  619. <td>收款银行所在市</td>
  620. </tr>
  621. <tr>
  622. <td>inst_branch_name</td>
  623. <td>string</td>
  624. <td>否</td>
  625. <td>收款银行所属支行</td>
  626. </tr>
  627. <tr>
  628. <td>bank_code</td>
  629. <td>string</td>
  630. <td>否</td>
  631. <td>银行支行联行号</td>
  632. </tr>
  633. </tbody>
  634. </table>
  635. </div>
  636. </div>
  637. </td>
  638. </tr>
  639. </tbody>
  640. </table>
  641. </div>
  642. </div>
  643. </td>
  644. </tr>
  645. </tbody>
  646. </table>
  647. <h4>请求示例</h4>
  648. <pre><code>curl -X POST 'https://api.qcsj88888.com/payment/openapi/account/transfer' \
  649. -H 'Authorization: ApiKey your_api_key' \
  650. -H 'Signature: your_signature' \
  651. -H 'Content-Type: application/json' \
  652. -d '{
  653. "account_book_id": "资金账号",
  654. "amount": "100.00",
  655. "order_title": "转账标题",
  656. "third_biz_no": "商户订单号202604270001",
  657. "payee_info": {
  658. "identity_type": "ALIPAY_ACCOUNT",
  659. "name": "收款人姓名",
  660. "identity": "收款人支付宝账号"
  661. }
  662. }'</code></pre>
  663. <h4>响应示例</h4>
  664. <pre><code>{"code": 200, "message": "转账申请已提交", "data": {"status": "DEALING", "order_no": "2026042711122334455", "third_biz_no": "商户订单号202604270001"}}</code></pre>
  665. </div>
  666. <div v-else-if="activeSection === 'transfer_query'" class="section-content">
  667. <h2>5. 查询转账</h2>
  668. <h3>5.1 接口说明</h3>
  669. <p>根据三方订单号查询转账状态和详情</p>
  670. <h4>API接口地址</h4>
  671. <p><code>POST https://api.qcsj88888.com/payment/openapi/account/transfer/query</code></p>
  672. <h4>请求参数</h4>
  673. <table class="api-table">
  674. <thead>
  675. <tr>
  676. <th>参数名</th>
  677. <th>类型</th>
  678. <th>是否必填</th>
  679. <th>描述</th>
  680. </tr>
  681. </thead>
  682. <tbody>
  683. <tr>
  684. <td>third_biz_no</td>
  685. <td>string</td>
  686. <td>是</td>
  687. <td>三方订单号(发起转账时传入的商户侧唯一标识)</td>
  688. </tr>
  689. </tbody>
  690. </table>
  691. <h4>请求示例</h4>
  692. <pre><code>curl -X POST 'https://api.qcsj88888.com/payment/openapi/account/transfer/query' \
  693. -H 'Authorization: ApiKey your_api_key' \
  694. -H 'Signature: your_signature' \
  695. -H 'Content-Type: application/json' \
  696. -d '{
  697. "third_biz_no": "商户订单号202604270001"
  698. }'</code></pre>
  699. <h4>响应示例</h4>
  700. <pre><code>{
  701. "code": 200,
  702. "message": "查询成功",
  703. "data": {
  704. "status": "SUCCESS",
  705. "order_no": "2026042711122334455",
  706. "amount": "100.00",
  707. "payee_info": {
  708. "identity_type": "ALIPAY_ACCOUNT",
  709. "name": "张*",
  710. "identity": "z****@example.com"
  711. },
  712. "created_time": "2026-04-27 11:22:33",
  713. "updated_time": "2026-04-27 11:25:45"
  714. }
  715. }</code></pre>
  716. <h4>状态说明</h4>
  717. <table class="api-table">
  718. <thead>
  719. <tr>
  720. <th>状态码</th>
  721. <th>描述</th>
  722. </tr>
  723. </thead>
  724. <tbody>
  725. <tr>
  726. <td>DEALING</td>
  727. <td>处理中</td>
  728. </tr>
  729. <tr>
  730. <td>SUCCESS</td>
  731. <td>成功</td>
  732. </tr>
  733. <tr>
  734. <td>FAIL</td>
  735. <td>失败</td>
  736. </tr>
  737. <tr>
  738. <td>REFUND</td>
  739. <td>已退款</td>
  740. </tr>
  741. </tbody>
  742. </table>
  743. </div>
  744. <div v-else-if="activeSection === 'balance'" class="section-content">
  745. <h2>6. 账户余额</h2>
  746. <h3>6.1 接口说明</h3>
  747. <p>查询指定企业资金专户的余额信息</p>
  748. <h4>API接口地址</h4>
  749. <p><code>POST https://api.qcsj88888.com/payment/openapi/account/balance/query</code></p>
  750. <h4>请求参数</h4>
  751. <table class="api-table">
  752. <thead>
  753. <tr>
  754. <th>参数名</th>
  755. <th>类型</th>
  756. <th>是否必填</th>
  757. <th>描述</th>
  758. </tr>
  759. </thead>
  760. <tbody>
  761. <tr>
  762. <td>enterprise_id</td>
  763. <td>string</td>
  764. <td>是</td>
  765. <td>企业ID(在支付宝企业码平台注册的企业唯一标识)</td>
  766. </tr>
  767. </tbody>
  768. </table>
  769. <h4>请求示例</h4>
  770. <pre><code>curl -X POST 'https://api.qcsj88888.com/payment/openapi/account/balance/query' \
  771. -H 'Authorization: ApiKey your_api_key' \
  772. -H 'Signature: your_signature' \
  773. -H 'Content-Type: application/json' \
  774. -d '{
  775. "enterprise_id": "2088480777900000"
  776. }'</code></pre>
  777. <h4>响应示例</h4>
  778. <pre><code>{
  779. "code": 200,
  780. "message": "查询成功",
  781. "data": [
  782. {
  783. "account_book_id": "2088480770900000",
  784. "available_amount": "50000.00",
  785. "enable_status": "ENABLE",
  786. "scene": "B2B_TRANS",
  787. "account_card_info": {
  788. "card_no": "xxxx",
  789. "bank_name": "招商银行"
  790. }
  791. }
  792. ]
  793. }</code></pre>
  794. <h4>响应字段说明</h4>
  795. <table class="api-table">
  796. <thead>
  797. <tr>
  798. <th>字段名</th>
  799. <th>类型</th>
  800. <th>描述</th>
  801. </tr>
  802. </thead>
  803. <tbody>
  804. <tr>
  805. <td>account_book_id</td>
  806. <td>string</td>
  807. <td>资金专户号</td>
  808. </tr>
  809. <tr>
  810. <td>available_amount</td>
  811. <td>string</td>
  812. <td>可用余额(单位:元,精确到小数点后两位)</td>
  813. </tr>
  814. <tr>
  815. <td>enable_status</td>
  816. <td>string</td>
  817. <td>启用状态:ENABLE(启用)/ DISABLE(禁用)</td>
  818. </tr>
  819. <tr>
  820. <td>scene</td>
  821. <td>string</td>
  822. <td>场景类型:B2B_TRANS(B2B转账)</td>
  823. </tr>
  824. <tr>
  825. <td>account_card_info</td>
  826. <td>object</td>
  827. <td>账户卡信息(银行卡号、银行名称等)</td>
  828. </tr>
  829. </tbody>
  830. </table>
  831. <h4>注意事项</h4>
  832. <ul>
  833. <li>返回结果为<strong>数组</strong>,一个企业可能有多个资金专户</li>
  834. <li>余额单位为<strong>元</strong>,精确到小数点后两位</li>
  835. <li>仅返回 <code>scene</code> 为 <code>B2B_TRANS</code> 的资金专户</li>
  836. </ul>
  837. </div>
  838. <div v-else-if="activeSection === 'callback'" class="section-content">
  839. <h2>7. 回调通知</h2>
  840. <h3>7.1 接口说明</h3>
  841. <p>当转账状态发生变化时,系统会主动向商户配置的回调地址发送通知。</p>
  842. <h4>回调地址配置</h4>
  843. <p>系统按照以下优先级获取回调地址:</p>
  844. <ol style="margin-left: 20px;">
  845. <li><strong>API Key 级别</strong>:在创建/编辑 API Key 时配置回调地址(优先级最高)</li>
  846. <li><strong>开放平台配置</strong>:在 <strong>应用配置</strong> 页面设置默认回调地址</li>
  847. </ol>
  848. <p style="color: #909399; margin-top: 8px;">说明:如果 API Key 已配置回调地址,则优先使用;否则使用开放平台配置中的回调地址。</p>
  849. <h4>通知方式</h4>
  850. <ul>
  851. <li><strong>POST 请求</strong>:系统通过 HTTP POST 方式将通知数据发送到商户的回调地址</li>
  852. <li><strong>表单形式</strong>:通知参数以表单形式提交(Content-Type: multipart/form-data)</li>
  853. <li><strong>重试机制</strong>:如果通知失败,系统会自动重试(最多2次,间隔1秒、2秒)</li>
  854. </ul>
  855. <h4>请求参数</h4>
  856. <table class="api-table">
  857. <thead>
  858. <tr>
  859. <th>参数名</th>
  860. <th>类型</th>
  861. <th>描述</th>
  862. </tr>
  863. </thead>
  864. <tbody>
  865. <tr>
  866. <td>notify_id</td>
  867. <td>string</td>
  868. <td>通知ID,唯一标识</td>
  869. </tr>
  870. <tr>
  871. <td>timestamp</td>
  872. <td>int</td>
  873. <td>通知时间戳(毫秒)</td>
  874. </tr>
  875. <tr>
  876. <td>content</td>
  877. <td>string</td>
  878. <td>JSON格式的通知内容</td>
  879. </tr>
  880. </tbody>
  881. </table>
  882. <h4>content 字段说明</h4>
  883. <table class="api-table">
  884. <thead>
  885. <tr>
  886. <th>参数名</th>
  887. <th>类型</th>
  888. <th>描述</th>
  889. </tr>
  890. </thead>
  891. <tbody>
  892. <tr>
  893. <td>status</td>
  894. <td>string</td>
  895. <td>转账状态:DEALING(处理中)、SUCCESS(成功)、FAIL(失败)、REFUND(已退款)</td>
  896. </tr>
  897. <tr>
  898. <td>order_no</td>
  899. <td>string</td>
  900. <td>平台订单号</td>
  901. </tr>
  902. <tr>
  903. <td>third_biz_no</td>
  904. <td>string</td>
  905. <td>商户订单号(发起转账时传入的三方订单号)</td>
  906. </tr>
  907. <tr>
  908. <td>amount</td>
  909. <td>number</td>
  910. <td>转账金额(元)</td>
  911. </tr>
  912. <tr>
  913. <td>created_time</td>
  914. <td>string</td>
  915. <td>创建时间</td>
  916. </tr>
  917. <tr>
  918. <td>updated_time</td>
  919. <td>string</td>
  920. <td>更新时间</td>
  921. </tr>
  922. </tbody>
  923. </table>
  924. <h4>通知示例</h4>
  925. <pre><code>POST /your/callback/url HTTP/1.1
  926. Content-Type: multipart/form-data; boundary=----WebKitFormBoundary
  927. ------WebKitFormBoundary
  928. Content-Disposition: form-data; name="notify_id"
  929. n1234567890123456789
  930. ------WebKitFormBoundary
  931. Content-Disposition: form-data; name="timestamp"
  932. 1715767200000
  933. ------WebKitFormBoundary
  934. Content-Disposition: form-data; name="content"
  935. {
  936. "status": "SUCCESS",
  937. "order_no": "2026042711122334455",
  938. "third_biz_no": "商户订单号202604270001",
  939. "amount": "100.00",
  940. "created_time": "2026-04-27 11:22:33",
  941. "updated_time": "2026-04-27 11:25:45"
  942. }
  943. ------WebKitFormBoundary--</code></pre>
  944. <h4>响应要求</h4>
  945. <p>商户服务端收到通知后,需要返回 HTTP 200 状态码表示成功接收。如果返回非 200 状态码或超时,系统会进行重试。</p>
  946. <h4>注意事项</h4>
  947. <ul>
  948. <li>回调地址需要支持 <strong>HTTPS</strong> 协议</li>
  949. <li>确保回调接口能够在 <strong>5秒内</strong> 返回响应</li>
  950. <li><span style="color: #f56c6c;"><strong>重要</strong>:回调通知不包含签名验证,收到通知后请主动调用查询接口确认订单状态,以确保数据真实性</span></li>
  951. <li>通知可能会重复发送,请确保业务逻辑支持 <strong>幂等性</strong></li>
  952. <li>系统最多重试 <strong>2次</strong>,重试间隔为 1 秒和 2 秒</li>
  953. </ul>
  954. </div>
  955. <div v-else-if="activeSection === 'errors'" class="section-content">
  956. <h2>8. 常见错误</h2>
  957. <ul>
  958. <li><strong>401 Invalid API Key</strong>:API Key无效或已过期</li>
  959. <li><strong>401 Signature header required</strong>:未提供Signature请求头</li>
  960. <li><strong>401 Invalid Signature</strong>:签名验证失败,请检查签名计算方式</li>
  961. <li><strong>400 Bad Request</strong>:请求参数错误</li>
  962. <li><strong>403 Forbidden</strong>:无权限访问</li>
  963. <li><strong>500 Internal Server Error</strong>:服务器内部错误</li>
  964. </ul>
  965. </div>
  966. <div v-else-if="activeSection === 'php'" class="section-content">
  967. <h2>9. PHP 示例代码</h2>
  968. <h3>9.1 以下是签名生成的 PHP 示例代码:</h3>
  969. <pre class="code-block"><code class="language-php">&lt;?php
  970. class SignatureGenerator
  971. {
  972. private static function ksortRecursive(&$array) {
  973. if (!is_array($array)) return;
  974. ksort($array, SORT_STRING);
  975. foreach ($array as &$value) {
  976. self::ksortRecursive($value);
  977. }
  978. }
  979. public static function generateSignature(
  980. string $apiSecret,
  981. array $requestData,
  982. array $excludeParams = ['sign']
  983. ): string {
  984. $filteredData = [];
  985. foreach ($requestData as $key => $value) {
  986. if (in_array($key, $excludeParams, true)) {
  987. continue;
  988. }
  989. if ($value === null || $value === '') {
  990. continue;
  991. }
  992. if (is_array($value) && empty($value)) {
  993. continue;
  994. }
  995. $filteredData[$key] = $value;
  996. }
  997. self::ksortRecursive($filteredData);
  998. $collect = [];
  999. foreach ($filteredData as $key => $value) {
  1000. if (is_array($value)) {
  1001. $value = json_encode($value, JSON_UNESCAPED_SLASHES);
  1002. }
  1003. $encodedValue = rawurlencode((string)$value);
  1004. $collect[] = "{$key}={$encodedValue}";
  1005. }
  1006. $signStr = implode('&', $collect);
  1007. return hash_hmac('sha256', $signStr, $apiSecret);
  1008. }
  1009. public static function verifySignature(
  1010. string $apiSecret,
  1011. array $requestData,
  1012. string $signature
  1013. ): bool {
  1014. $expectedSignature = self::generateSignature($apiSecret, $requestData);
  1015. return hash_equals($expectedSignature, $signature);
  1016. }
  1017. }
  1018. // ================= 测试调用 =================
  1019. $apiSecret = 'your_api_secret_here';
  1020. $requestData = [
  1021. "account_book_id" => "2088480770900000",
  1022. "amount" => "1.00",
  1023. "order_title" => "Apikey转账",
  1024. "third_biz_no" => "1234242026042700111",
  1025. "payee_info" => [
  1026. "identity_type" => "ALIPAY_ACCOUNT",
  1027. "name" => "钱先生",
  1028. "identity" => "1xx9xx9xxxxx"
  1029. ]
  1030. ];
  1031. // 生成签名
  1032. $signature = SignatureGenerator::generateSignature($apiSecret, $requestData);
  1033. echo "生成的签名: {$signature}\n";
  1034. // 验证签名
  1035. $isValid = SignatureGenerator::verifySignature($apiSecret, $requestData, $signature);
  1036. echo "签名验证结果: " . ($isValid ? '有效' : '无效') . "\n";
  1037. ?&gt;</code></pre>
  1038. </div>
  1039. </el-card>
  1040. </div>
  1041. </div>
  1042. </el-tab-pane>
  1043. </el-tabs>
  1044. </div>
  1045. </template>
  1046. <script setup lang="ts">
  1047. import { ref, reactive, onMounted } from "vue";
  1048. import ApiKeyAPI, {
  1049. ApiKeyCreateForm,
  1050. ApiKeyPageQuery,
  1051. ApiKeyResponse,
  1052. } from "@/api/module_payment/apikey";
  1053. import OpenAPI from "@/api/module_payment/openapi";
  1054. import CrudToolbarLeft from "@/components/CURD/CrudToolbarLeft.vue";
  1055. import CrudToolbarRight from "@/components/CURD/CrudToolbarRight.vue";
  1056. import PageSearch from "@/components/CURD/PageSearch.vue";
  1057. import PageContent from "@/components/CURD/PageContent.vue";
  1058. import EnhancedDialog from "@/components/CURD/EnhancedDialog.vue";
  1059. import { useCrudList } from "@/components/CURD/useCrudList";
  1060. import type { IContentConfig, ISearchConfig } from "@/components/CURD/types";
  1061. import { ElMessage } from "element-plus";
  1062. import { Loading, CopyDocument } from "@element-plus/icons-vue";
  1063. import { watch } from "vue";
  1064. defineOptions({
  1065. name: "ApiKey",
  1066. inheritAttrs: false,
  1067. });
  1068. const activeTab = ref("management");
  1069. const activeSection = ref("auth");
  1070. const { searchRef, contentRef, handleQueryClick, handleResetClick, refreshList } = useCrudList();
  1071. const dataFormRef = ref();
  1072. const submitLoading = ref(false);
  1073. const apiKeyDetailVisible = ref(false);
  1074. const configFormRef = ref();
  1075. const configLoading = ref(true);
  1076. const expandedSections = ref({
  1077. payee_info: false,
  1078. bankcard_ext_info: false
  1079. });
  1080. const configExists = ref(false);
  1081. const configForm = reactive({
  1082. app_id: "",
  1083. gateway_url: "",
  1084. notify_url: "",
  1085. return_url: "",
  1086. status: "ENABLED",
  1087. description: "",
  1088. });
  1089. watch(activeTab, (newTab) => {
  1090. if (newTab === "config") {
  1091. loadOpenConf();
  1092. }
  1093. });
  1094. async function loadOpenConf() {
  1095. configLoading.value = true;
  1096. configExists.value = false;
  1097. try {
  1098. const res = await OpenAPI.getOpenConf();
  1099. if (res.data.code === 0 && res.data.data) {
  1100. Object.assign(configForm, res.data.data);
  1101. configExists.value = true;
  1102. } else {
  1103. configExists.value = false;
  1104. }
  1105. } catch (error: any) {
  1106. console.error("加载配置失败:", error);
  1107. // 如果返回"配置不存在"错误,则显示创建按钮
  1108. if (error?.response?.data?.msg?.includes("配置不存在")) {
  1109. configExists.value = false;
  1110. }
  1111. } finally {
  1112. configLoading.value = false;
  1113. }
  1114. }
  1115. async function handleCreateConfig() {
  1116. configLoading.value = true;
  1117. try {
  1118. const res = await OpenAPI.saveOpenConf({
  1119. notify_url: "",
  1120. return_url: "",
  1121. });
  1122. if (res.data.code === 0 && res.data.data) {
  1123. Object.assign(configForm, res.data.data);
  1124. configExists.value = true;
  1125. ElMessage.success("创建成功");
  1126. }
  1127. } catch (error) {
  1128. console.error(error);
  1129. ElMessage.error("创建失败");
  1130. } finally {
  1131. configLoading.value = false;
  1132. }
  1133. }
  1134. async function handleSaveConfig() {
  1135. configLoading.value = true;
  1136. try {
  1137. const res = await OpenAPI.saveOpenConf({
  1138. notify_url: configForm.notify_url,
  1139. return_url: configForm.return_url,
  1140. });
  1141. if (res.data.code === 0 && res.data.data) {
  1142. Object.assign(configForm, res.data.data);
  1143. }
  1144. } catch (error) {
  1145. console.error(error);
  1146. } finally {
  1147. configLoading.value = false;
  1148. }
  1149. }
  1150. function toggleExpand(section: string) {
  1151. expandedSections.value[section] = !expandedSections.value[section];
  1152. }
  1153. function getSectionTitle() {
  1154. const titles = {
  1155. auth: "认证方式",
  1156. signature: "签名验证",
  1157. notes: "注意事项",
  1158. transfer: "发起转账",
  1159. transfer_query: "查询转账",
  1160. balance: "账户余额",
  1161. callback: "回调通知",
  1162. errors: "常见错误",
  1163. php: "PHP示例代码"
  1164. };
  1165. return titles[activeSection.value] || "API文档";
  1166. }
  1167. const apiKeyDetail = reactive<ApiKeyResponse>({
  1168. id: 0,
  1169. api_key: "",
  1170. api_secret: "",
  1171. status: "0",
  1172. expired_at: "",
  1173. created_time: "",
  1174. return_url: "",
  1175. });
  1176. const searchConfig = reactive<ISearchConfig>({
  1177. permPrefix: "module_system:tenant:api-key",
  1178. colon: true,
  1179. isExpandable: true,
  1180. showNumber: 2,
  1181. form: { labelWidth: "auto" },
  1182. formItems: [
  1183. {
  1184. prop: "status",
  1185. label: "状态",
  1186. type: "select",
  1187. attrs: {
  1188. placeholder: "请选择状态",
  1189. clearable: true,
  1190. options: [
  1191. { label: "正常", value: "0" },
  1192. { label: "禁用", value: "1" },
  1193. ],
  1194. style: { width: "167.5px" },
  1195. },
  1196. },
  1197. ],
  1198. });
  1199. const contentCols = reactive<
  1200. Array<{
  1201. prop?: string;
  1202. label?: string;
  1203. show?: boolean;
  1204. }>
  1205. >([
  1206. { prop: "selection", label: "选择框", show: true },
  1207. { prop: "id", label: "ID", show: false },
  1208. { prop: "api_key", label: "API Key", show: true },
  1209. { prop: "status", label: "状态", show: true },
  1210. { prop: "return_url", label: "回调地址", show: true },
  1211. { prop: "expired_at", label: "过期时间", show: true },
  1212. { prop: "last_used_at", label: "最后使用时间", show: true },
  1213. { prop: "description", label: "描述", show: true },
  1214. { prop: "created_time", label: "创建时间", show: true },
  1215. { prop: "operation", label: "操作", show: true },
  1216. ]);
  1217. const contentConfig = reactive<IContentConfig<ApiKeyPageQuery>>({
  1218. permPrefix: "module_system:tenant:api-key",
  1219. pk: "id",
  1220. cols: contentCols as IContentConfig["cols"],
  1221. hideColumnFilter: false,
  1222. toolbar: [],
  1223. defaultToolbar: [{ name: "refresh", perm: "refresh" }, "filter"],
  1224. pagination: {
  1225. pageSize: 10,
  1226. pageSizes: [10, 20, 30, 50],
  1227. },
  1228. request: { page_no: "page", page_size: "page_size" },
  1229. indexAction: async (params) => {
  1230. const res = await ApiKeyAPI.listApiKey(params as ApiKeyPageQuery);
  1231. return {
  1232. total: res.data.data.total,
  1233. list: res.data.data.items,
  1234. };
  1235. },
  1236. deleteAction: async (ids) => {
  1237. const idList = ids
  1238. .split(",")
  1239. .map((s) => Number(s.trim()))
  1240. .filter((n) => !Number.isNaN(n));
  1241. for (const id of idList) {
  1242. await ApiKeyAPI.deleteApiKey(id);
  1243. }
  1244. },
  1245. deleteConfirm: {
  1246. title: "警告",
  1247. message: "确认删除该项数据?",
  1248. type: "warning",
  1249. },
  1250. });
  1251. const formData = reactive<ApiKeyCreateForm>({
  1252. tenant_id: undefined,
  1253. expired_days: 365,
  1254. description: "",
  1255. return_url: "",
  1256. });
  1257. const dialogVisible = reactive({
  1258. title: "",
  1259. visible: false,
  1260. type: "create" as "create",
  1261. });
  1262. const rules = reactive({
  1263. expired_days: [{ required: true, message: "请输入过期天数", trigger: "blur" }],
  1264. });
  1265. const initialFormData: ApiKeyCreateForm = {
  1266. tenant_id: undefined,
  1267. expired_days: 365,
  1268. description: "",
  1269. };
  1270. async function handleRowDelete(id: number) {
  1271. contentRef.value?.handleDelete(id);
  1272. }
  1273. async function resetForm() {
  1274. if (dataFormRef.value) {
  1275. dataFormRef.value.resetFields();
  1276. dataFormRef.value.clearValidate();
  1277. }
  1278. Object.assign(formData, initialFormData);
  1279. }
  1280. async function handleCloseDialog() {
  1281. dialogVisible.visible = false;
  1282. await resetForm();
  1283. }
  1284. async function handleOpenDialog(type: "create") {
  1285. dialogVisible.type = type;
  1286. dialogVisible.title = "创建API Key";
  1287. dialogVisible.visible = true;
  1288. }
  1289. async function handleSubmit() {
  1290. dataFormRef.value.validate(async (valid: boolean) => {
  1291. if (valid) {
  1292. submitLoading.value = true;
  1293. try {
  1294. const response = await ApiKeyAPI.createApiKey(formData);
  1295. Object.assign(apiKeyDetail, response.data.data);
  1296. apiKeyDetailVisible.value = true;
  1297. dialogVisible.visible = false;
  1298. await resetForm();
  1299. refreshList();
  1300. } catch (error: unknown) {
  1301. console.error(error);
  1302. } finally {
  1303. submitLoading.value = false;
  1304. }
  1305. }
  1306. });
  1307. }
  1308. async function handleUpdateStatus(id: number, status: string) {
  1309. try {
  1310. await ApiKeyAPI.updateApiKeyStatus(id, { status });
  1311. ElMessage.success(`API Key已${status === '0' ? '启用' : '禁用'}`);
  1312. refreshList();
  1313. } catch (error: unknown) {
  1314. console.error(error);
  1315. ElMessage.error('操作失败');
  1316. }
  1317. }
  1318. function copyToClipboard(text: string) {
  1319. navigator.clipboard.writeText(text).then(() => {
  1320. ElMessage.success('复制成功');
  1321. }).catch(() => {
  1322. ElMessage.error('复制失败');
  1323. });
  1324. }
  1325. function handleCloseApiKeyDetail() {
  1326. ElMessage.warning('请务必已保存API Key和Secret,关闭后将无法再次查看');
  1327. apiKeyDetailVisible.value = false;
  1328. }
  1329. function handleConfirmApiKeyDetail() {
  1330. ElMessage.success('已确认保存');
  1331. apiKeyDetailVisible.value = false;
  1332. }
  1333. function downloadApiKeyCsv() {
  1334. const csvContent = `API Key,API Secret,状态,过期时间,创建时间,描述\n"${apiKeyDetail.api_key}","${apiKeyDetail.api_secret}","${apiKeyDetail.status === '0' ? '正常' : '禁用'}","${apiKeyDetail.expired_at}","${apiKeyDetail.created_time}","${apiKeyDetail.description || ''}"`;
  1335. const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
  1336. const link = document.createElement('a');
  1337. const url = URL.createObjectURL(blob);
  1338. link.setAttribute('href', url);
  1339. link.setAttribute('download', `api-key-${apiKeyDetail.api_key || Date.now()}.csv`);
  1340. link.style.visibility = 'hidden';
  1341. document.body.appendChild(link);
  1342. link.click();
  1343. document.body.removeChild(link);
  1344. ElMessage.success('CSV已下载');
  1345. }
  1346. </script>
  1347. <style scoped>
  1348. .app-container {
  1349. padding: 20px;
  1350. }
  1351. .api-key-container {
  1352. display: flex;
  1353. align-items: center;
  1354. justify-content: space-between;
  1355. word-break: break-all;
  1356. }
  1357. .api-key-detail {
  1358. display: flex;
  1359. align-items: flex-start;
  1360. justify-content: space-between;
  1361. flex-wrap: wrap;
  1362. word-break: break-all;
  1363. gap: 8px;
  1364. }
  1365. .api-key-detail span {
  1366. word-break: break-all;
  1367. flex: 1;
  1368. min-width: 0;
  1369. }
  1370. .config-loading-content {
  1371. display: flex;
  1372. flex-direction: column;
  1373. align-items: center;
  1374. justify-content: center;
  1375. padding: 60px 0;
  1376. color: #909399;
  1377. }
  1378. .config-loading-content .loading-icon {
  1379. font-size: 32px;
  1380. margin-bottom: 16px;
  1381. animation: rotate 1s linear infinite;
  1382. }
  1383. @keyframes rotate {
  1384. from {
  1385. transform: rotate(0deg);
  1386. }
  1387. to {
  1388. transform: rotate(360deg);
  1389. }
  1390. }
  1391. .config-empty {
  1392. display: flex;
  1393. flex-direction: column;
  1394. align-items: center;
  1395. justify-content: center;
  1396. padding: 40px 0;
  1397. }
  1398. .docs-layout {
  1399. display: flex;
  1400. height: calc(100vh - 120px);
  1401. gap: 20px;
  1402. }
  1403. .docs-sidebar {
  1404. width: 280px;
  1405. background-color: #f5f7fa;
  1406. border-radius: 8px;
  1407. padding: 20px 0;
  1408. overflow: hidden;
  1409. }
  1410. .sidebar-section {
  1411. margin-bottom: 24px;
  1412. padding: 0 20px;
  1413. }
  1414. .sidebar-section h3 {
  1415. font-size: 14px;
  1416. font-weight: bold;
  1417. color: #606266;
  1418. margin-bottom: 12px;
  1419. text-transform: uppercase;
  1420. letter-spacing: 0.5px;
  1421. }
  1422. .sidebar-section ul {
  1423. list-style: none;
  1424. padding: 0;
  1425. margin: 0;
  1426. }
  1427. .sidebar-section li {
  1428. margin-bottom: 8px;
  1429. padding: 8px 12px;
  1430. border-radius: 4px;
  1431. cursor: pointer;
  1432. transition: all 0.3s ease;
  1433. font-size: 14px;
  1434. color: #606266;
  1435. }
  1436. .sidebar-section li:hover {
  1437. background-color: #ecf5ff;
  1438. color: #409eff;
  1439. }
  1440. .sidebar-section li.active {
  1441. background-color: #ecf5ff;
  1442. color: #409eff;
  1443. font-weight: 500;
  1444. border-left: 3px solid #409eff;
  1445. }
  1446. .docs-content {
  1447. flex: 1;
  1448. overflow-y: auto;
  1449. padding: 0 20px;
  1450. }
  1451. .config-layout {
  1452. padding: 20px;
  1453. }
  1454. .config-card {
  1455. max-width: 700px;
  1456. }
  1457. .section-content {
  1458. line-height: 1.6;
  1459. padding: 20px 0;
  1460. }
  1461. .section-content h2 {
  1462. margin-top: 0;
  1463. margin-bottom: 20px;
  1464. font-size: 20px;
  1465. font-weight: bold;
  1466. color: #303133;
  1467. }
  1468. .section-content h3 {
  1469. margin-top: 24px;
  1470. margin-bottom: 12px;
  1471. font-size: 16px;
  1472. font-weight: bold;
  1473. color: #404145;
  1474. }
  1475. .section-content h4 {
  1476. margin-top: 16px;
  1477. margin-bottom: 8px;
  1478. font-size: 14px;
  1479. font-weight: bold;
  1480. color: #606266;
  1481. }
  1482. .section-content p {
  1483. margin-bottom: 12px;
  1484. color: #606266;
  1485. }
  1486. .section-content ul,
  1487. .section-content ol {
  1488. margin-left: 20px;
  1489. margin-bottom: 16px;
  1490. color: #606266;
  1491. }
  1492. .section-content li {
  1493. margin-bottom: 6px;
  1494. }
  1495. .section-content pre {
  1496. background-color: #f5f5f5;
  1497. padding: 16px;
  1498. border-radius: 4px;
  1499. overflow-x: auto;
  1500. margin-bottom: 16px;
  1501. border: 1px solid #e4e7ed;
  1502. }
  1503. .section-content code {
  1504. font-family: 'Courier New', Courier, monospace;
  1505. font-size: 14px;
  1506. color: #303133;
  1507. }
  1508. .api-table {
  1509. width: 100%;
  1510. border-collapse: collapse;
  1511. margin-bottom: 20px;
  1512. border: 1px solid #e4e7ed;
  1513. border-radius: 4px;
  1514. overflow: hidden;
  1515. }
  1516. .api-table th,
  1517. .api-table td {
  1518. border: 1px solid #e4e7ed;
  1519. padding: 12px;
  1520. text-align: left;
  1521. }
  1522. .api-table th {
  1523. background-color: #f5f7fa;
  1524. font-weight: bold;
  1525. color: #303133;
  1526. }
  1527. .api-table tr:nth-child(even) {
  1528. background-color: #fafafa;
  1529. }
  1530. .api-table tr:hover {
  1531. background-color: #f5f7fa;
  1532. }
  1533. .nested-table {
  1534. margin: 10px 0 0 20px;
  1535. font-size: 13px;
  1536. }
  1537. .expandable-section {
  1538. width: 100%;
  1539. }
  1540. .expand-btn {
  1541. cursor: pointer;
  1542. display: inline-block;
  1543. font-weight: 500;
  1544. color: #409eff;
  1545. transition: all 0.3s ease;
  1546. }
  1547. .expand-btn:hover {
  1548. color: #66b1ff;
  1549. }
  1550. .expandable-content {
  1551. margin-top: 8px;
  1552. padding-left: 20px;
  1553. animation: slideDown 0.3s ease;
  1554. }
  1555. @keyframes slideDown {
  1556. from {
  1557. opacity: 0;
  1558. transform: translateY(-10px);
  1559. }
  1560. to {
  1561. opacity: 1;
  1562. transform: translateY(0);
  1563. }
  1564. }
  1565. .card-header {
  1566. display: flex;
  1567. align-items: center;
  1568. justify-content: space-between;
  1569. padding: 12px 20px;
  1570. /* border-bottom: 1px solid #e4e7ed; */
  1571. margin: -20px -20px 20px -20px;
  1572. /* background-color: #fafafa; */
  1573. }
  1574. .card-header span {
  1575. font-size: 16px;
  1576. font-weight: bold;
  1577. color: #303133;
  1578. }
  1579. </style>