index.vue 62 KB

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