You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

617 line
28 KiB

  1. <template>
  2. <div class="bill-page">
  3. <AppHero
  4. kicker="POINTS LEDGER"
  5. title="点数账单"
  6. desc="每一次点数变化都清晰可查,充值、消费与奖励记录一目了然。"
  7. >
  8. <template #aside>
  9. <div
  10. class="relative w-full overflow-hidden rounded-[28px] bg-gradient-to-br from-[var(--c-0758c9)] via-[var(--c-0b8cff)] to-[var(--c-55c8ff)] p-5 text-white shadow-[0_22px_48px_var(--sh-11-112-220-240)] lg:w-[360px] lg:p-6"
  11. aria-label="点数余额"
  12. >
  13. <div class="pointer-events-none absolute -right-10 -top-14 size-40 rounded-full border border-white/15 bg-white/10" />
  14. <div class="pointer-events-none absolute -bottom-20 -left-10 size-44 rounded-full bg-[var(--c-004da8)]/20 blur-xl" />
  15. <div class="relative">
  16. <div class="flex items-center justify-between gap-4">
  17. <div class="flex items-center gap-2.5">
  18. <span class="flex size-9 items-center justify-center rounded-xl bg-white/15">
  19. <UIcon name="i-lucide-wallet-cards" class="size-4.5" />
  20. </span>
  21. <div>
  22. <div class="text-[11px] font-bold tracking-[0.12em] text-white/65">AVAILABLE POINTS</div>
  23. <div class="mt-0.5 text-xs font-bold text-white/90">当前可用点数</div>
  24. </div>
  25. </div>
  26. <span class="flex items-center gap-1.5 rounded-full bg-white/12 px-2.5 py-1 text-[11px] font-bold text-white/80">
  27. <span class="size-1.5 rounded-full bg-[var(--c-8affd8)]" />
  28. 多端同步
  29. </span>
  30. </div>
  31. <div class="mt-6 flex items-end justify-between gap-5">
  32. <div class="flex min-w-0 items-end">
  33. <span class="truncate text-[40px] font-extrabold leading-none tracking-tight tabular-nums lg:text-[44px]">
  34. {{ balanceText }}
  35. </span>
  36. <span v-if="balanceUnit" class="mb-1 ml-2 text-sm font-bold text-white/70">{{ balanceUnit }}</span>
  37. </div>
  38. <NuxtLink
  39. v-if="isLogin"
  40. to="/recharge"
  41. class="inline-flex h-10 shrink-0 items-center gap-1.5 rounded-full bg-[var(--c-ffffff)] px-4 text-sm font-extrabold text-[var(--c-086fd5)] shadow-[0_8px_18px_var(--sh-0-65-140-160)] transition-colors hover:bg-[var(--c-f5fbff)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--c-e8eef7)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--c-0b8cff)] motion-reduce:transition-none"
  42. >
  43. 去充值
  44. <UIcon name="i-lucide-arrow-up-right" class="size-4" />
  45. </NuxtLink>
  46. <button
  47. v-else
  48. type="button"
  49. class="inline-flex h-10 shrink-0 cursor-pointer items-center rounded-full bg-[var(--c-ffffff)] px-4 text-sm font-extrabold text-[var(--c-086fd5)] shadow-[0_8px_18px_var(--sh-0-65-140-160)] transition-colors hover:bg-[var(--c-f5fbff)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--c-e8eef7)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--c-0b8cff)] motion-reduce:transition-none"
  50. @click="showLogin = true"
  51. >
  52. 登录查看
  53. </button>
  54. </div>
  55. <div class="mt-6 flex items-center gap-2 border-t border-white/15 pt-4 text-[11px] font-medium text-white/70">
  56. <UIcon name="i-lucide-shield-check" class="size-3.5" />
  57. 账目实时同步,点数长期有效
  58. </div>
  59. </div>
  60. </div>
  61. </template>
  62. </AppHero>
  63. <main class="mx-auto max-w-7xl px-4 py-7 sm:px-6 lg:px-8 lg:py-10">
  64. <section
  65. v-if="!isLogin"
  66. class="relative overflow-hidden rounded-3xl border border-[var(--c-e7eef7)] bg-[var(--c-ffffff)] px-6 py-14 text-center shadow-[0_16px_40px_var(--sh-33-68-112-60)] sm:py-18"
  67. aria-labelledby="bill-login-title"
  68. >
  69. <div class="pointer-events-none absolute left-1/2 top-0 h-36 w-72 -translate-x-1/2 rounded-full bg-[var(--c-eaf6ff)] blur-3xl" />
  70. <div class="relative">
  71. <div class="mx-auto flex size-16 items-center justify-center rounded-2xl bg-[var(--c-eef7ff)] text-[var(--c-0b8cff)] ring-8 ring-[var(--c-f7fbff)]">
  72. <UIcon name="i-lucide-receipt-text" class="size-7" />
  73. </div>
  74. <h2 id="bill-login-title" class="mt-6 text-xl font-extrabold text-[var(--c-172033)]">登录后查看你的点数账单</h2>
  75. <p class="mx-auto mt-2 max-w-md text-sm leading-6 text-[var(--c-66758a)]">
  76. 登录后即可同步各端余额,随时核对每一笔充值、创作消费与奖励记录。
  77. </p>
  78. <div class="mt-5 flex flex-wrap items-center justify-center gap-x-5 gap-y-2 text-xs font-bold text-[var(--c-7b8797)]">
  79. <span class="inline-flex items-center gap-1.5">
  80. <UIcon name="i-lucide-check" class="size-3.5 text-[var(--c-17a65a)]" />
  81. 多端记录同步
  82. </span>
  83. <span class="inline-flex items-center gap-1.5">
  84. <UIcon name="i-lucide-check" class="size-3.5 text-[var(--c-17a65a)]" />
  85. 点数去向清晰
  86. </span>
  87. <span class="inline-flex items-center gap-1.5">
  88. <UIcon name="i-lucide-check" class="size-3.5 text-[var(--c-17a65a)]" />
  89. 变动余额可追溯
  90. </span>
  91. </div>
  92. <button
  93. type="button"
  94. class="mt-7 inline-flex h-11 cursor-pointer items-center justify-center gap-2 rounded-full bg-[var(--c-0b8cff)] px-7 text-sm font-extrabold text-white shadow-[0_10px_22px_var(--sh-11-140-255-240)] transition-colors hover:bg-[var(--c-087dde)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--c-0b8cff)] focus-visible:ring-offset-2 motion-reduce:transition-none"
  95. @click="showLogin = true"
  96. >
  97. <UIcon name="i-lucide-log-in" class="size-4" />
  98. 立即登录
  99. </button>
  100. </div>
  101. </section>
  102. <template v-else>
  103. <section class="grid gap-3 sm:grid-cols-3" aria-label="点数概览">
  104. <article
  105. v-for="item in summaryStats"
  106. :key="item.key"
  107. class="relative overflow-hidden rounded-2xl border border-[var(--c-e8eef7)] bg-[var(--c-ffffff)] p-4 shadow-[0_8px_24px_var(--sh-34-68-112-45)] sm:p-5"
  108. >
  109. <div class="flex items-start justify-between gap-4">
  110. <div>
  111. <p class="text-xs font-bold text-[var(--c-7b8797)]">{{ item.label }}</p>
  112. <p class="mt-2 text-2xl font-extrabold tracking-tight tabular-nums" :class="item.valueTone">
  113. {{ hasLoaded ? item.value : '—' }}
  114. </p>
  115. <p class="mt-1 text-[11px] text-[var(--c-9aa5b5)]">{{ item.helper }}</p>
  116. </div>
  117. <span class="flex size-10 shrink-0 items-center justify-center rounded-xl" :class="item.iconTone">
  118. <UIcon :name="item.icon" class="size-4.5" />
  119. </span>
  120. </div>
  121. </article>
  122. </section>
  123. <section
  124. class="mt-6 overflow-hidden rounded-3xl border border-[var(--c-e7eef7)] bg-[var(--c-ffffff)] shadow-[0_16px_40px_var(--sh-34-68-112-60)]"
  125. aria-labelledby="bill-list-title"
  126. :aria-busy="loading"
  127. >
  128. <header class="border-b border-[var(--c-edf2f7)] px-4 pb-4 pt-5 sm:px-6 sm:pb-5 sm:pt-6">
  129. <div class="flex flex-wrap items-end justify-between gap-3">
  130. <div>
  131. <div class="flex items-center gap-2">
  132. <h2 id="bill-list-title" class="text-lg font-extrabold text-[var(--c-172033)] sm:text-xl">流水明细</h2>
  133. <span class="rounded-full bg-[var(--c-f1f5fa)] px-2.5 py-1 text-[11px] font-bold text-[var(--c-7b8797)]">
  134. {{ total }} 条
  135. </span>
  136. </div>
  137. <p class="mt-1.5 text-xs text-[var(--c-8a97a8)]">按时间倒序展示,最近的点数变动排在最前</p>
  138. </div>
  139. <div v-if="list.length" class="text-xs font-medium text-[var(--c-9aa5b5)]">
  140. 已显示 {{ list.length }} 条
  141. </div>
  142. </div>
  143. <div class="bill-tabs -mx-1 mt-5 overflow-x-auto px-1 pb-1" role="tablist" aria-label="账单类型">
  144. <div class="grid auto-cols-[minmax(104px,1fr)] grid-flow-col gap-2 sm:grid-flow-row sm:grid-cols-5">
  145. <button
  146. v-for="item in tabs"
  147. :key="item.key"
  148. type="button"
  149. role="tab"
  150. class="flex h-11 cursor-pointer items-center justify-center gap-2 rounded-xl px-3 text-sm font-bold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--c-0b8cff)] focus-visible:ring-offset-2 disabled:cursor-wait motion-reduce:transition-none"
  151. :class="activeTab === item.key
  152. ? 'bg-[var(--c-0b8cff)] text-white shadow-[0_8px_18px_var(--sh-11-140-255-200)]'
  153. : 'bg-[var(--c-f6f9fd)] text-[var(--c-66758a)] hover:bg-[var(--c-eef7ff)] hover:text-[var(--c-0b8cff)]'"
  154. :aria-selected="activeTab === item.key"
  155. :disabled="loading"
  156. @click="changeTab(item.key)"
  157. >
  158. <UIcon :name="item.icon" class="size-4 shrink-0" />
  159. <span>{{ item.name }}</span>
  160. <span
  161. class="min-w-5 rounded-full px-1.5 py-0.5 text-[10px] font-extrabold tabular-nums"
  162. :class="activeTab === item.key ? 'bg-white/18 text-white' : 'bg-[var(--c-ffffff)] text-[var(--c-8a97a8)]'"
  163. >
  164. {{ tabCounts[item.key] || 0 }}
  165. </span>
  166. </button>
  167. </div>
  168. </div>
  169. </header>
  170. <div
  171. v-if="initialLoading"
  172. class="divide-y divide-[var(--c-edf2f7)]"
  173. role="status"
  174. aria-label="正在加载账单"
  175. >
  176. <div v-for="i in 5" :key="i" class="flex items-center gap-4 px-4 py-5 sm:px-6">
  177. <div class="size-11 shrink-0 animate-pulse rounded-2xl bg-[var(--c-edf2f7)] motion-reduce:animate-none" />
  178. <div class="min-w-0 flex-1">
  179. <div class="h-3.5 w-36 animate-pulse rounded-full bg-[var(--c-edf2f7)] motion-reduce:animate-none" />
  180. <div class="mt-3 h-3 w-56 max-w-full animate-pulse rounded-full bg-[var(--c-f1f5fa)] motion-reduce:animate-none" />
  181. </div>
  182. <div class="hidden w-20 sm:block">
  183. <div class="ml-auto h-4 w-14 animate-pulse rounded-full bg-[var(--c-edf2f7)] motion-reduce:animate-none" />
  184. <div class="ml-auto mt-3 h-3 w-20 animate-pulse rounded-full bg-[var(--c-f1f5fa)] motion-reduce:animate-none" />
  185. </div>
  186. <span class="sr-only">加载中</span>
  187. </div>
  188. </div>
  189. <div v-else-if="loadError && !list.length" class="px-5 py-16 text-center sm:py-20" role="alert">
  190. <div class="mx-auto flex size-14 items-center justify-center rounded-2xl bg-[var(--c-fff3f0)] text-[var(--c-e85f1c)] ring-8 ring-[var(--c-fff9f7)]">
  191. <UIcon name="i-lucide-circle-alert" class="size-6" />
  192. </div>
  193. <h3 class="mt-6 text-base font-extrabold text-[var(--c-172033)]">账单加载失败</h3>
  194. <p class="mx-auto mt-2 max-w-sm text-sm leading-6 text-[var(--c-8a97a8)]">{{ loadError }}</p>
  195. <button
  196. type="button"
  197. class="mt-6 inline-flex h-10 cursor-pointer items-center gap-1.5 rounded-full bg-[var(--c-eef7ff)] px-5 text-sm font-bold text-[var(--c-0b8cff)] transition-colors hover:bg-[var(--c-e1f1ff)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--c-0b8cff)] focus-visible:ring-offset-2 motion-reduce:transition-none"
  198. @click="fetchList(true)"
  199. >
  200. <UIcon name="i-lucide-refresh-cw" class="size-3.5" />
  201. 重新加载
  202. </button>
  203. </div>
  204. <div v-else-if="list.length">
  205. <div class="hidden grid-cols-[minmax(0,1fr)_150px] border-b border-[var(--c-edf2f7)] bg-[var(--c-fbfcfe)] px-6 py-2.5 text-[11px] font-bold text-[var(--c-9aa5b5)] sm:grid">
  206. <span>记录</span>
  207. <span class="text-right">点数变动</span>
  208. </div>
  209. <div class="divide-y divide-[var(--c-edf2f7)]">
  210. <article
  211. v-for="item in list"
  212. :key="item.id"
  213. class="grid gap-4 px-4 py-5 transition-colors hover:bg-[var(--c-fbfdff)] sm:grid-cols-[minmax(0,1fr)_150px] sm:items-center sm:px-6 motion-reduce:transition-none"
  214. >
  215. <div class="flex min-w-0 items-start gap-3.5">
  216. <span
  217. class="flex size-11 shrink-0 items-center justify-center rounded-2xl ring-1 ring-inset"
  218. :class="typeTone(item.type)"
  219. aria-hidden="true"
  220. >
  221. <UIcon :name="getTypeIcon(item.type)" class="size-4.5" />
  222. </span>
  223. <div class="min-w-0 flex-1">
  224. <div class="flex min-w-0 flex-wrap items-center gap-2">
  225. <h3 class="min-w-0 truncate text-sm font-extrabold text-[var(--c-172033)] sm:text-[15px]">
  226. {{ displayTitle(item) }}
  227. </h3>
  228. <span
  229. class="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-extrabold"
  230. :class="typeBadgeTone(item.type)"
  231. >
  232. {{ item.type_text || '其他' }}
  233. </span>
  234. </div>
  235. <p v-if="item.desc" class="mt-1.5 line-clamp-2 text-xs leading-5 text-[var(--c-66758a)] sm:truncate">
  236. {{ displayDesc(item) }}
  237. </p>
  238. <div class="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-[var(--c-9aa5b5)]">
  239. <span class="inline-flex items-center gap-1">
  240. <UIcon name="i-lucide-clock-3" class="size-3" />
  241. {{ item.time || '时间未知' }}
  242. </span>
  243. <!-- 充值流水带上订单号,方便对账 / 客服核单,点击可复制 -->
  244. <button
  245. v-if="orderNo(item)"
  246. type="button"
  247. class="inline-flex max-w-full cursor-pointer items-center gap-1 transition-colors hover:text-[var(--c-0b8cff)]"
  248. :title="`点击复制订单号 ${orderNo(item)}`"
  249. @click="copyOrderNo(orderNo(item))"
  250. >
  251. <UIcon name="i-lucide-receipt" class="size-3 shrink-0" />
  252. <span class="truncate tabular-nums">订单号 {{ orderNo(item) }}</span>
  253. <UIcon name="i-lucide-copy" class="size-3 shrink-0" />
  254. </button>
  255. <span class="sm:hidden">变动后 {{ formatNumber(item.balance_after) }} 点</span>
  256. </div>
  257. </div>
  258. </div>
  259. <div class="flex items-center justify-between border-t border-dashed border-[var(--c-e8eef7)] pt-3 sm:block sm:border-0 sm:pt-0 sm:text-right">
  260. <span class="text-[11px] font-medium text-[var(--c-9aa5b5)] sm:hidden">点数变动</span>
  261. <div>
  262. <div class="text-lg font-extrabold tabular-nums" :class="amountTone(item.amount)">
  263. {{ formatAmount(item.amount) }}
  264. </div>
  265. <div class="mt-1 hidden text-[11px] text-[var(--c-9aa5b5)] sm:block">
  266. 变动后 {{ formatNumber(item.balance_after) }} 点
  267. </div>
  268. </div>
  269. </div>
  270. </article>
  271. </div>
  272. </div>
  273. <div v-else class="px-5 py-16 text-center sm:py-20">
  274. <div class="mx-auto flex size-14 items-center justify-center rounded-2xl bg-[var(--c-eef7ff)] text-[var(--c-0b8cff)] ring-8 ring-[var(--c-f8fbff)]">
  275. <UIcon :name="activeTab === 'all' ? 'i-lucide-receipt-text' : 'i-lucide-list-filter'" class="size-6" />
  276. </div>
  277. <h3 class="mt-6 text-base font-extrabold text-[var(--c-172033)]">
  278. {{ activeTab === 'all' ? '还没有点数记录' : `暂无${activeTabName}` }}
  279. </h3>
  280. <p class="mx-auto mt-2 max-w-sm text-sm leading-6 text-[var(--c-8a97a8)]">
  281. {{ activeTab === 'all' ? '充值或使用 AI 工具后,点数变化会自动记录在这里。' : '当前分类暂时没有记录,可以看看其他类型的流水。' }}
  282. </p>
  283. <NuxtLink
  284. v-if="activeTab === 'all'"
  285. to="/recharge"
  286. class="mt-6 inline-flex h-10 items-center gap-1.5 rounded-full bg-[var(--c-eef7ff)] px-5 text-sm font-bold text-[var(--c-0b8cff)] transition-colors hover:bg-[var(--c-e1f1ff)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--c-0b8cff)] focus-visible:ring-offset-2 motion-reduce:transition-none"
  287. >
  288. 去充值
  289. <UIcon name="i-lucide-arrow-right" class="size-3.5" />
  290. </NuxtLink>
  291. <button
  292. v-else
  293. type="button"
  294. class="mt-6 inline-flex h-10 cursor-pointer items-center rounded-full bg-[var(--c-eef7ff)] px-5 text-sm font-bold text-[var(--c-0b8cff)] transition-colors hover:bg-[var(--c-e1f1ff)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--c-0b8cff)] focus-visible:ring-offset-2 motion-reduce:transition-none"
  295. @click="changeTab('all')"
  296. >
  297. 查看全部记录
  298. </button>
  299. </div>
  300. <footer v-if="list.length" class="border-t border-[var(--c-edf2f7)] px-4 py-4 sm:px-6">
  301. <div v-if="loading" class="flex items-center justify-center gap-2 py-1 text-xs font-bold text-[var(--c-8a97a8)]" role="status">
  302. <span class="size-4 animate-spin rounded-full border-2 border-[var(--c-0b8cff)]/20 border-t-[var(--c-0b8cff)] motion-reduce:animate-none" />
  303. 正在加载更多
  304. </div>
  305. <button
  306. v-else-if="hasMore"
  307. type="button"
  308. class="mx-auto flex h-10 cursor-pointer items-center gap-1.5 rounded-full bg-[var(--c-eef7ff)] px-6 text-sm font-bold text-[var(--c-0b8cff)] transition-colors hover:bg-[var(--c-e1f1ff)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--c-0b8cff)] focus-visible:ring-offset-2 motion-reduce:transition-none"
  309. @click="loadMore"
  310. >
  311. 加载更多
  312. <UIcon name="i-lucide-chevron-down" class="size-3.5" />
  313. </button>
  314. <div v-else class="flex items-center justify-center gap-3 py-1 text-[11px] text-[var(--c-a0aabb)]">
  315. <span class="h-px w-8 bg-[var(--c-e6edf5)]" />
  316. 已经到底了
  317. <span class="h-px w-8 bg-[var(--c-e6edf5)]" />
  318. </div>
  319. </footer>
  320. </section>
  321. <div class="mt-4 flex items-start gap-2 rounded-2xl bg-[var(--c-eef7ff)]/70 px-4 py-3 text-xs leading-5 text-[var(--c-66758a)]">
  322. <UIcon name="i-lucide-info" class="mt-0.5 size-3.5 shrink-0 text-[var(--c-0b8cff)]" />
  323. <p>点数账单由系统自动生成。如对某条记录有疑问,请保留记录时间和变动后余额,以便快速核对。</p>
  324. </div>
  325. </template>
  326. </main>
  327. </div>
  328. </template>
  329. <script setup>
  330. useSeoMeta({
  331. title: '点数账单 - AI在线',
  332. description: '查看点数充值、消费、奖励与余额变动明细',
  333. })
  334. const { userInfo, isLogin, showLogin } = useUser()
  335. const { post } = useApi()
  336. const toast = useToast()
  337. const tabs = [
  338. { key: 'all', name: '全部', icon: 'i-lucide-list' },
  339. { key: 'recharge', name: '充值', icon: 'i-lucide-wallet-cards' },
  340. { key: 'consume', name: '消费', icon: 'i-lucide-sparkles' },
  341. { key: 'reward', name: '奖励', icon: 'i-lucide-gift' },
  342. { key: 'other', name: '其他', icon: 'i-lucide-ellipsis' },
  343. ]
  344. const activeTab = ref('all')
  345. const list = ref([])
  346. const page = ref(1)
  347. const pageSize = 10
  348. const total = ref(0)
  349. const loading = ref(false)
  350. const loadError = ref('')
  351. const hasLoaded = ref(false)
  352. const balance = ref({})
  353. const summary = ref({
  354. recharge: 0,
  355. consume: 0,
  356. reward: 0,
  357. refund: 0,
  358. adjust: 0,
  359. counts: { all: 0, recharge: 0, consume: 0, reward: 0, other: 0 },
  360. })
  361. const numberFormatter = new Intl.NumberFormat('zh-CN')
  362. const balanceText = computed(() => {
  363. if (!isLogin.value) return '—'
  364. const points = balance.value.points ?? balance.value.point_balance ?? userInfo.value.balance
  365. return Number(points) < 0 ? '不限' : formatNumber(points)
  366. })
  367. const balanceUnit = computed(() => (balanceText.value === '不限' || balanceText.value === '—' ? '' : '点'))
  368. const tabCounts = computed(() => summary.value.counts || {})
  369. const hasMore = computed(() => list.value.length < total.value)
  370. const initialLoading = computed(() => loading.value && !list.value.length)
  371. const activeTabName = computed(() => {
  372. const item = tabs.find(tab => tab.key === activeTab.value)
  373. return item ? `${item.name}记录` : '全部记录'
  374. })
  375. const summaryStats = computed(() => [
  376. {
  377. key: 'recharge',
  378. label: '累计充值',
  379. helper: '历史充值获得',
  380. value: formatSigned(summary.value.recharge, true),
  381. icon: 'i-lucide-circle-plus',
  382. iconTone: 'bg-[var(--c-eef7ff)] text-[var(--c-0b8cff)]',
  383. valueTone: 'text-[var(--c-0879dc)]',
  384. },
  385. {
  386. key: 'consume',
  387. label: '累计消费',
  388. helper: '创作累计使用',
  389. value: formatSigned(summary.value.consume ? -Math.abs(summary.value.consume) : 0),
  390. icon: 'i-lucide-sparkles',
  391. iconTone: 'bg-[var(--c-fff4ec)] text-[var(--c-e85f1c)]',
  392. valueTone: 'text-[var(--c-e85f1c)]',
  393. },
  394. {
  395. key: 'reward',
  396. label: '累计奖励',
  397. helper: '活动与任务获得',
  398. value: formatSigned(summary.value.reward, true),
  399. icon: 'i-lucide-gift',
  400. iconTone: 'bg-[var(--c-edfdf4)] text-[var(--c-159a54)]',
  401. valueTone: 'text-[var(--c-159a54)]',
  402. },
  403. ])
  404. async function fetchList(reset = false) {
  405. if (!isLogin.value || loading.value) return false
  406. if (reset) {
  407. page.value = 1
  408. list.value = []
  409. total.value = 0
  410. }
  411. loading.value = true
  412. loadError.value = ''
  413. try {
  414. const res = await post('/point/loglist', {
  415. type: activeTab.value,
  416. page_no: page.value,
  417. page_size: pageSize,
  418. })
  419. const rows = res.list || []
  420. const data = res.data || {}
  421. list.value = reset ? rows : list.value.concat(rows)
  422. total.value = Number(data.total ?? rows.length)
  423. setBalance(data.balance || {})
  424. summary.value = normalizeSummary(data.summary || {})
  425. hasLoaded.value = true
  426. return true
  427. } catch (e) {
  428. loadError.value = e.message || '网络开小差了,请稍后重试。'
  429. toast.add({ title: loadError.value, color: 'error' })
  430. return false
  431. } finally {
  432. loading.value = false
  433. }
  434. }
  435. function normalizeSummary(data = {}) {
  436. const counts = Object.assign(
  437. { all: 0, recharge: 0, consume: 0, reward: 0, other: 0 },
  438. data.counts || {},
  439. )
  440. Object.keys(counts).forEach((key) => {
  441. counts[key] = Number(counts[key] || 0)
  442. })
  443. return {
  444. recharge: Number(data.recharge || 0),
  445. consume: Number(data.consume || 0),
  446. reward: Number(data.reward || 0),
  447. refund: Number(data.refund || 0),
  448. adjust: Number(data.adjust || 0),
  449. counts,
  450. }
  451. }
  452. function setBalance(data = {}) {
  453. balance.value = data
  454. const points = data.points ?? data.point_balance
  455. if (points !== undefined) userInfo.value.balance = Number(points) || 0
  456. }
  457. function changeTab(key) {
  458. if (activeTab.value === key || loading.value) return
  459. activeTab.value = key
  460. fetchList(true)
  461. }
  462. async function loadMore() {
  463. if (loading.value || !hasMore.value) return
  464. page.value += 1
  465. const success = await fetchList()
  466. if (!success) page.value -= 1
  467. }
  468. function formatNumber(value) {
  469. const num = Number(value || 0)
  470. return numberFormatter.format(Number.isFinite(num) ? num : 0)
  471. }
  472. function formatSigned(value, plus = false) {
  473. const num = Number(value || 0)
  474. return num > 0 && plus ? `+${formatNumber(num)}` : formatNumber(num)
  475. }
  476. function formatAmount(value) {
  477. const num = Number(value || 0)
  478. return num > 0 ? `+${formatNumber(num)}` : formatNumber(num)
  479. }
  480. function displayTitle(item = {}) {
  481. const text = String(item.title || '').trim().replace(/\s*第\s*1\s*张\s*$/g, '').trim()
  482. return text || item.title || '点数变动'
  483. }
  484. function displayDesc(item = {}) {
  485. const raw = String(item.desc || '').trim()
  486. const text = raw
  487. .replace(/(?:,|,|、|\s)*(混元生图\s*3\.0|腾讯混元大模型|腾讯混元|混元大模型|混元模型|高精模型|高清模型)\s*/gi, '')
  488. .replace(/(?:,|,|、)\s*(生成模型|模型)\s*[::]?\s*[^,,、]+/g, '')
  489. .replace(/[,,、\s]+$/g, '')
  490. .trim()
  491. return text || raw
  492. }
  493. /**
  494. * 充值流水的订单号。
  495. * 后端 point_log.biz_no 在充值时存的就是 point_order.order_no(消费存的是计费流水号,不展示)。
  496. */
  497. function orderNo(item) {
  498. if (!item || item.type !== 'recharge') return ''
  499. return String(item.biz_no || '').trim()
  500. }
  501. async function copyOrderNo(value) {
  502. if (!value) return
  503. try {
  504. await navigator.clipboard.writeText(value)
  505. toast.add({ title: '订单号已复制', color: 'success' })
  506. } catch {
  507. // 非 HTTPS / 旧浏览器下 clipboard 不可用,退回到 execCommand
  508. const input = document.createElement('input')
  509. input.value = value
  510. document.body.appendChild(input)
  511. input.select()
  512. const ok = document.execCommand?.('copy')
  513. document.body.removeChild(input)
  514. toast.add({ title: ok ? '订单号已复制' : '复制失败,请手动选择', color: ok ? 'success' : 'error' })
  515. }
  516. }
  517. function getTypeIcon(type) {
  518. const map = {
  519. recharge: 'i-lucide-wallet-cards',
  520. consume: 'i-lucide-sparkles',
  521. reward: 'i-lucide-gift',
  522. refund: 'i-lucide-rotate-ccw',
  523. adjust: 'i-lucide-sliders-horizontal',
  524. }
  525. return map[type] || 'i-lucide-receipt-text'
  526. }
  527. function typeTone(type) {
  528. const map = {
  529. recharge: 'bg-[var(--c-eef7ff)] text-[var(--c-0b8cff)] ring-[var(--c-d9ecff)]',
  530. consume: 'bg-[var(--c-fff4ec)] text-[var(--c-e85f1c)] ring-[var(--c-ffe0cc)]',
  531. reward: 'bg-[var(--c-edfdf4)] text-[var(--c-159a54)] ring-[var(--c-d2f5e2)]',
  532. refund: 'bg-[var(--c-f3f0ff)] text-[var(--c-7253e5)] ring-[var(--c-e2dafd)]',
  533. adjust: 'bg-[var(--c-f3f0ff)] text-[var(--c-7253e5)] ring-[var(--c-e2dafd)]',
  534. }
  535. return map[type] || 'bg-[var(--c-f1f5fa)] text-[var(--c-66758a)] ring-[var(--c-e3eaf2)]'
  536. }
  537. function typeBadgeTone(type) {
  538. const map = {
  539. recharge: 'bg-[var(--c-eef7ff)] text-[var(--c-0879dc)]',
  540. consume: 'bg-[var(--c-fff4ec)] text-[var(--c-d95414)]',
  541. reward: 'bg-[var(--c-edfdf4)] text-[var(--c-14894d)]',
  542. refund: 'bg-[var(--c-f3f0ff)] text-[var(--c-6848d8)]',
  543. adjust: 'bg-[var(--c-f3f0ff)] text-[var(--c-6848d8)]',
  544. }
  545. return map[type] || 'bg-[var(--c-f1f5fa)] text-[var(--c-66758a)]'
  546. }
  547. function amountTone(value) {
  548. const amount = Number(value || 0)
  549. if (amount > 0) return 'text-[var(--c-159a54)]'
  550. if (amount < 0) return 'text-[var(--c-e85f1c)]'
  551. return 'text-[var(--c-66758a)]'
  552. }
  553. onMounted(() => fetchList(true))
  554. watch(isLogin, (value) => {
  555. if (value) {
  556. fetchList(true)
  557. } else {
  558. list.value = []
  559. total.value = 0
  560. loadError.value = ''
  561. hasLoaded.value = false
  562. balance.value = {}
  563. }
  564. })
  565. </script>
  566. <style scoped>
  567. .bill-tabs {
  568. scrollbar-width: none;
  569. }
  570. .bill-tabs::-webkit-scrollbar {
  571. display: none;
  572. }
  573. </style>