OrderListView.vue 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458
  1. <script setup lang="ts">
  2. import type { DataTableColumns } from "naive-ui";
  3. import {
  4. NButton,
  5. NDataTable,
  6. NDatePicker,
  7. NImage,
  8. NFormItemGi,
  9. NInput,
  10. NModal,
  11. NPagination,
  12. NSelect,
  13. NSpace,
  14. NTag,
  15. useDialog,
  16. useMessage,
  17. } from "naive-ui";
  18. import AdminSearchPanel from "@/components/AdminSearchPanel.vue";
  19. import AdminTablePageBar from "@/components/AdminTablePageBar.vue";
  20. import * as orderApi from "@/api/modules/finance/order";
  21. import type { OrderItem, OrderSearchParams } from "@/api/modules/finance/order";
  22. import { useTableColumnsControl } from "@/composables";
  23. import { resolveMediaUrl } from "@/utils/resolveMediaUrl";
  24. const message = useMessage();
  25. const dialog = useDialog();
  26. const loading = ref(false);
  27. const completingId = ref<number | null>(null);
  28. const list = ref<OrderItem[]>([]);
  29. const total = ref(0);
  30. const page = ref(1);
  31. const pageSize = ref(10);
  32. const coursesModalVisible = ref(false);
  33. interface OrderCourseItem {
  34. frontUrl?: string;
  35. goodsName?: string;
  36. title?: string;
  37. goodsPrice?: number | string;
  38. }
  39. const activeCourses = ref<OrderCourseItem[]>([]);
  40. const statusOptions = [
  41. { label: "全部", value: "" },
  42. { label: "未支付", value: "1" },
  43. { label: "已支付", value: "2" },
  44. { label: "支付失败", value: "3" },
  45. { label: "已过期", value: "4" },
  46. { label: "已取消", value: "5" },
  47. ];
  48. const search = ref<{
  49. serial: string;
  50. payName: string;
  51. status: string;
  52. dateRange: [number, number] | null;
  53. }>({
  54. serial: "",
  55. payName: "",
  56. status: "",
  57. dateRange: null,
  58. });
  59. function toDateText(ts: number) {
  60. const d = new Date(ts);
  61. const y = d.getFullYear();
  62. const m = String(d.getMonth() + 1).padStart(2, "0");
  63. const day = String(d.getDate()).padStart(2, "0");
  64. return `${y}-${m}-${day}`;
  65. }
  66. function buildSearchPayload(): OrderSearchParams {
  67. const statusStr = search.value.status;
  68. return {
  69. serial: search.value.serial.trim() || undefined,
  70. payName: search.value.payName.trim() || undefined,
  71. status:
  72. statusStr && /^\d+$/.test(statusStr) ? Number(statusStr) : undefined,
  73. startDate: search.value.dateRange
  74. ? toDateText(search.value.dateRange[0])
  75. : undefined,
  76. endDate: search.value.dateRange
  77. ? toDateText(search.value.dateRange[1])
  78. : undefined,
  79. page: { current: page.value, row: pageSize.value },
  80. };
  81. }
  82. async function fetchList() {
  83. loading.value = true;
  84. try {
  85. const { list: rows, total: count } = await orderApi.searchOrderPage(
  86. buildSearchPayload(),
  87. );
  88. list.value = rows;
  89. total.value = count;
  90. } finally {
  91. loading.value = false;
  92. }
  93. }
  94. function onSearch() {
  95. page.value = 1;
  96. void fetchList();
  97. }
  98. function resetSearch() {
  99. search.value = {
  100. serial: "",
  101. payName: "",
  102. status: "",
  103. dateRange: null,
  104. };
  105. page.value = 1;
  106. void fetchList();
  107. }
  108. function amountText(v: unknown) {
  109. const n = Number(v ?? 0);
  110. if (Number.isNaN(n)) return "0.00";
  111. return n.toLocaleString(undefined, {
  112. minimumFractionDigits: 2,
  113. maximumFractionDigits: 2,
  114. });
  115. }
  116. function orderStatusTag(status: number | undefined) {
  117. const s = status ?? 0;
  118. if (s === 1) return { label: "未支付", type: "warning" as const };
  119. if (s === 2) return { label: "已支付", type: "success" as const };
  120. if (s === 3) return { label: "支付失败", type: "error" as const };
  121. if (s === 4) return { label: "已过期", type: "default" as const };
  122. if (s === 5) return { label: "已取消", type: "error" as const };
  123. return { label: String(s || "--"), type: "default" as const };
  124. }
  125. function timeCell(v: unknown) {
  126. if (v == null || v === "") return "--";
  127. if (typeof v === "number") {
  128. const d = new Date(v);
  129. return Number.isNaN(d.getTime()) ? String(v) : d.toLocaleString();
  130. }
  131. const s = String(v).trim();
  132. if (!s) return "--";
  133. const n = Number(s);
  134. if (!Number.isNaN(n) && s === String(n) && s.length >= 10) {
  135. const d = new Date(n);
  136. if (!Number.isNaN(d.getTime())) return d.toLocaleString();
  137. }
  138. const d = new Date(s);
  139. if (!Number.isNaN(d.getTime())) return d.toLocaleString();
  140. return s;
  141. }
  142. function parseOrderCourses(details: unknown): OrderCourseItem[] {
  143. if (details == null || details === "") return [];
  144. const normalize = (input: unknown): OrderCourseItem[] => {
  145. if (Array.isArray(input)) return input as OrderCourseItem[];
  146. if (input && typeof input === "object") return [input as OrderCourseItem];
  147. return [];
  148. };
  149. if (typeof details === "string") {
  150. const text = details.trim();
  151. if (!text) return [];
  152. try {
  153. return normalize(JSON.parse(text));
  154. } catch {
  155. return [];
  156. }
  157. }
  158. return normalize(details);
  159. }
  160. function courseName(item: OrderCourseItem) {
  161. return String(item.goodsName ?? item.title ?? "").trim() || "--";
  162. }
  163. function courseAmount(item: OrderCourseItem) {
  164. const n = Number(item.goodsPrice ?? 0);
  165. if (Number.isNaN(n)) return "--";
  166. return amountText(n);
  167. }
  168. function openCoursesModal(row: OrderItem) {
  169. activeCourses.value = parseOrderCourses(row.details);
  170. coursesModalVisible.value = true;
  171. }
  172. function confirmOrder(row: OrderItem) {
  173. dialog.warning({
  174. title: "确认订单",
  175. content: `确定将流水号 ${row.serial ?? row.id} 的订单标为已确认?`,
  176. positiveText: "确定",
  177. negativeText: "取消",
  178. onPositiveClick: async () => {
  179. completingId.value = row.id;
  180. try {
  181. await orderApi.completeOrder({ id: row.id });
  182. message.success("确认成功");
  183. await fetchList();
  184. } finally {
  185. completingId.value = null;
  186. }
  187. },
  188. });
  189. }
  190. const columns = ref<DataTableColumns<OrderItem>>([
  191. { title: "CID", key: "cId", width: 120, ellipsis: { tooltip: true } },
  192. { title: "流水号", key: "serial", width: 180, ellipsis: { tooltip: true } },
  193. { title: "付款人姓名", key: "payName", width: 100, ellipsis: { tooltip: true } },
  194. { title: "付款人电话", key: "payPhone", width: 120, ellipsis: { tooltip: true } },
  195. { title: "邮箱", key: "email", width: 200, ellipsis: { tooltip: true } },
  196. {
  197. title: "金额",
  198. key: "amount",
  199. width: 110,
  200. render: (row) => amountText(row.amount),
  201. },
  202. { title: "货币", key: "currency", width: 80 },
  203. {
  204. title: "汇款金额",
  205. key: "transformAmount",
  206. width: 110,
  207. render: (row) => amountText(row.transformAmount),
  208. },
  209. { title: "汇款货币", key: "transformCurrency", width: 96 },
  210. { title: "支付通道", key: "channelCode", width: 120, ellipsis: { tooltip: true } },
  211. {
  212. title: "状态",
  213. key: "status",
  214. width: 100,
  215. render: (row) => {
  216. const t = orderStatusTag(row.status);
  217. return h(NTag, { type: t.type, size: "small" }, { default: () => t.label });
  218. },
  219. },
  220. {
  221. title: "购买时间",
  222. key: "addTime",
  223. width: 170,
  224. render: (row) => timeCell(row.addTime),
  225. },
  226. {
  227. title: "支付时间",
  228. key: "payTime",
  229. width: 170,
  230. render: (row) => timeCell(row.payTime),
  231. },
  232. {
  233. title: "购买的课程",
  234. key: "details",
  235. width: 120,
  236. render: (row) =>
  237. h(
  238. NButton,
  239. { size: "small", onClick: () => openCoursesModal(row) },
  240. { default: () => "查看" },
  241. ),
  242. },
  243. {
  244. title: "操作",
  245. key: "actions",
  246. width: 120,
  247. fixed: "right",
  248. render: (row) =>
  249. h(
  250. NButton,
  251. {
  252. size: "small",
  253. type: row.status === 1 ? "primary" : "default",
  254. disabled: row.status !== 1,
  255. loading: completingId.value === row.id,
  256. onClick: () => confirmOrder(row),
  257. },
  258. { default: () => "确认订单" },
  259. ),
  260. },
  261. ]);
  262. const { visibleKeys, displayColumns, columnOptions } = useTableColumnsControl(
  263. columns,
  264. { frozenKeys: ["actions"] },
  265. );
  266. watch([page, pageSize], () => {
  267. void fetchList();
  268. });
  269. onMounted(() => {
  270. void fetchList();
  271. });
  272. onActivated(() => {
  273. void fetchList();
  274. });
  275. </script>
  276. <template>
  277. <div class="page page--table">
  278. <AdminSearchPanel :field-count="5" @search="onSearch" @reset="resetSearch">
  279. <NFormItemGi :span="1" label="流水号">
  280. <NInput
  281. v-model:value="search.serial"
  282. clearable
  283. placeholder="流水号"
  284. @keyup.enter="onSearch"
  285. />
  286. </NFormItemGi>
  287. <NFormItemGi :span="1" label="付款人姓名">
  288. <NInput
  289. v-model:value="search.payName"
  290. clearable
  291. placeholder="付款人姓名"
  292. @keyup.enter="onSearch"
  293. />
  294. </NFormItemGi>
  295. <NFormItemGi :span="1" label="状态">
  296. <NSelect v-model:value="search.status" :options="statusOptions" />
  297. </NFormItemGi>
  298. <NFormItemGi :span="1" label="时间范围">
  299. <NDatePicker
  300. v-model:value="search.dateRange"
  301. type="daterange"
  302. clearable
  303. style="width: 100%"
  304. />
  305. </NFormItemGi>
  306. </AdminSearchPanel>
  307. <NCard :bordered="false" class="table-card table-card--fill">
  308. <div class="table-card-inner">
  309. <AdminTablePageBar
  310. title="订单列表"
  311. v-model:visible-keys="visibleKeys"
  312. :column-options="columnOptions"
  313. :refresh-loading="loading"
  314. @refresh="fetchList"
  315. />
  316. <div class="table-card__body">
  317. <NDataTable
  318. :columns="displayColumns"
  319. :data="list"
  320. :loading="loading"
  321. :bordered="false"
  322. :single-line="false"
  323. :row-key="(row: OrderItem) => row.id"
  324. class="data-table-fill"
  325. :scroll-x="2000"
  326. />
  327. </div>
  328. <div class="pager-wrap">
  329. <div class="pager-inline">
  330. <span class="pager-total">共 {{ total }} 条</span>
  331. <NPagination
  332. v-model:page="page"
  333. v-model:page-size="pageSize"
  334. :item-count="total"
  335. :page-sizes="[10, 20, 50, 100]"
  336. show-size-picker
  337. />
  338. </div>
  339. </div>
  340. </div>
  341. </NCard>
  342. <NModal
  343. v-model:show="coursesModalVisible"
  344. preset="card"
  345. title="购买的课程"
  346. style="width: min(860px, 92vw)"
  347. :mask-closable="false"
  348. >
  349. <div v-if="activeCourses.length" class="order-course-list">
  350. <div
  351. v-for="(item, index) in activeCourses"
  352. :key="`${courseName(item)}-${index}`"
  353. class="order-course-item"
  354. >
  355. <NImage
  356. class="order-course-item__cover"
  357. :src="resolveMediaUrl(item.frontUrl)"
  358. object-fit="cover"
  359. width="88"
  360. height="88"
  361. preview-disabled
  362. fallback-src=""
  363. />
  364. <div class="order-course-item__main">
  365. <div class="order-course-item__name">{{ courseName(item) }}</div>
  366. <div class="order-course-item__amount">金额:{{ courseAmount(item) }}</div>
  367. </div>
  368. </div>
  369. </div>
  370. <div v-else class="order-course-empty">暂无课程数据</div>
  371. <template #footer>
  372. <NSpace justify="end">
  373. <NButton @click="coursesModalVisible = false">关闭</NButton>
  374. </NSpace>
  375. </template>
  376. </NModal>
  377. </div>
  378. </template>
  379. <style scoped>
  380. .order-course-list {
  381. display: grid;
  382. gap: 12px;
  383. max-height: 58vh;
  384. overflow: auto;
  385. padding-right: 2px;
  386. }
  387. .order-course-item {
  388. display: flex;
  389. gap: 12px;
  390. border: 1px solid #e5e7eb;
  391. border-radius: 10px;
  392. padding: 10px;
  393. align-items: center;
  394. }
  395. .order-course-item__cover {
  396. flex: none;
  397. border-radius: 8px;
  398. overflow: hidden;
  399. background: #f8fafc;
  400. }
  401. .order-course-item__main {
  402. min-width: 0;
  403. }
  404. .order-course-item__name {
  405. font-size: 14px;
  406. color: #111827;
  407. font-weight: 600;
  408. line-height: 1.4;
  409. word-break: break-all;
  410. }
  411. .order-course-item__amount {
  412. margin-top: 8px;
  413. color: #475569;
  414. font-size: 13px;
  415. }
  416. .order-course-empty {
  417. color: #64748b;
  418. text-align: center;
  419. padding: 24px 0;
  420. }
  421. </style>