| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458 |
- <script setup lang="ts">
- import type { DataTableColumns } from "naive-ui";
- import {
- NButton,
- NDataTable,
- NDatePicker,
- NImage,
- NFormItemGi,
- NInput,
- NModal,
- NPagination,
- NSelect,
- NSpace,
- NTag,
- useDialog,
- useMessage,
- } from "naive-ui";
- import AdminSearchPanel from "@/components/AdminSearchPanel.vue";
- import AdminTablePageBar from "@/components/AdminTablePageBar.vue";
- import * as orderApi from "@/api/modules/finance/order";
- import type { OrderItem, OrderSearchParams } from "@/api/modules/finance/order";
- import { useTableColumnsControl } from "@/composables";
- import { resolveMediaUrl } from "@/utils/resolveMediaUrl";
- const message = useMessage();
- const dialog = useDialog();
- const loading = ref(false);
- const completingId = ref<number | null>(null);
- const list = ref<OrderItem[]>([]);
- const total = ref(0);
- const page = ref(1);
- const pageSize = ref(10);
- const coursesModalVisible = ref(false);
- interface OrderCourseItem {
- frontUrl?: string;
- goodsName?: string;
- title?: string;
- goodsPrice?: number | string;
- }
- const activeCourses = ref<OrderCourseItem[]>([]);
- const statusOptions = [
- { label: "全部", value: "" },
- { label: "未支付", value: "1" },
- { label: "已支付", value: "2" },
- { label: "支付失败", value: "3" },
- { label: "已过期", value: "4" },
- { label: "已取消", value: "5" },
- ];
- const search = ref<{
- serial: string;
- payName: string;
- status: string;
- dateRange: [number, number] | null;
- }>({
- serial: "",
- payName: "",
- status: "",
- dateRange: null,
- });
- function toDateText(ts: number) {
- const d = new Date(ts);
- const y = d.getFullYear();
- const m = String(d.getMonth() + 1).padStart(2, "0");
- const day = String(d.getDate()).padStart(2, "0");
- return `${y}-${m}-${day}`;
- }
- function buildSearchPayload(): OrderSearchParams {
- const statusStr = search.value.status;
- return {
- serial: search.value.serial.trim() || undefined,
- payName: search.value.payName.trim() || undefined,
- status:
- statusStr && /^\d+$/.test(statusStr) ? Number(statusStr) : undefined,
- startDate: search.value.dateRange
- ? toDateText(search.value.dateRange[0])
- : undefined,
- endDate: search.value.dateRange
- ? toDateText(search.value.dateRange[1])
- : undefined,
- page: { current: page.value, row: pageSize.value },
- };
- }
- async function fetchList() {
- loading.value = true;
- try {
- const { list: rows, total: count } = await orderApi.searchOrderPage(
- buildSearchPayload(),
- );
- list.value = rows;
- total.value = count;
- } finally {
- loading.value = false;
- }
- }
- function onSearch() {
- page.value = 1;
- void fetchList();
- }
- function resetSearch() {
- search.value = {
- serial: "",
- payName: "",
- status: "",
- dateRange: null,
- };
- page.value = 1;
- void fetchList();
- }
- function amountText(v: unknown) {
- const n = Number(v ?? 0);
- if (Number.isNaN(n)) return "0.00";
- return n.toLocaleString(undefined, {
- minimumFractionDigits: 2,
- maximumFractionDigits: 2,
- });
- }
- function orderStatusTag(status: number | undefined) {
- const s = status ?? 0;
- if (s === 1) return { label: "未支付", type: "warning" as const };
- if (s === 2) return { label: "已支付", type: "success" as const };
- if (s === 3) return { label: "支付失败", type: "error" as const };
- if (s === 4) return { label: "已过期", type: "default" as const };
- if (s === 5) return { label: "已取消", type: "error" as const };
- return { label: String(s || "--"), type: "default" as const };
- }
- function timeCell(v: unknown) {
- if (v == null || v === "") return "--";
- if (typeof v === "number") {
- const d = new Date(v);
- return Number.isNaN(d.getTime()) ? String(v) : d.toLocaleString();
- }
- const s = String(v).trim();
- if (!s) return "--";
- const n = Number(s);
- if (!Number.isNaN(n) && s === String(n) && s.length >= 10) {
- const d = new Date(n);
- if (!Number.isNaN(d.getTime())) return d.toLocaleString();
- }
- const d = new Date(s);
- if (!Number.isNaN(d.getTime())) return d.toLocaleString();
- return s;
- }
- function parseOrderCourses(details: unknown): OrderCourseItem[] {
- if (details == null || details === "") return [];
- const normalize = (input: unknown): OrderCourseItem[] => {
- if (Array.isArray(input)) return input as OrderCourseItem[];
- if (input && typeof input === "object") return [input as OrderCourseItem];
- return [];
- };
- if (typeof details === "string") {
- const text = details.trim();
- if (!text) return [];
- try {
- return normalize(JSON.parse(text));
- } catch {
- return [];
- }
- }
- return normalize(details);
- }
- function courseName(item: OrderCourseItem) {
- return String(item.goodsName ?? item.title ?? "").trim() || "--";
- }
- function courseAmount(item: OrderCourseItem) {
- const n = Number(item.goodsPrice ?? 0);
- if (Number.isNaN(n)) return "--";
- return amountText(n);
- }
- function openCoursesModal(row: OrderItem) {
- activeCourses.value = parseOrderCourses(row.details);
- coursesModalVisible.value = true;
- }
- function confirmOrder(row: OrderItem) {
- dialog.warning({
- title: "确认订单",
- content: `确定将流水号 ${row.serial ?? row.id} 的订单标为已确认?`,
- positiveText: "确定",
- negativeText: "取消",
- onPositiveClick: async () => {
- completingId.value = row.id;
- try {
- await orderApi.completeOrder({ id: row.id });
- message.success("确认成功");
- await fetchList();
- } finally {
- completingId.value = null;
- }
- },
- });
- }
- const columns = ref<DataTableColumns<OrderItem>>([
- { title: "CID", key: "cId", width: 120, ellipsis: { tooltip: true } },
- { title: "流水号", key: "serial", width: 180, ellipsis: { tooltip: true } },
- { title: "付款人姓名", key: "payName", width: 100, ellipsis: { tooltip: true } },
- { title: "付款人电话", key: "payPhone", width: 120, ellipsis: { tooltip: true } },
- { title: "邮箱", key: "email", width: 200, ellipsis: { tooltip: true } },
- {
- title: "金额",
- key: "amount",
- width: 110,
- render: (row) => amountText(row.amount),
- },
- { title: "货币", key: "currency", width: 80 },
- {
- title: "汇款金额",
- key: "transformAmount",
- width: 110,
- render: (row) => amountText(row.transformAmount),
- },
- { title: "汇款货币", key: "transformCurrency", width: 96 },
- { title: "支付通道", key: "channelCode", width: 120, ellipsis: { tooltip: true } },
- {
- title: "状态",
- key: "status",
- width: 100,
- render: (row) => {
- const t = orderStatusTag(row.status);
- return h(NTag, { type: t.type, size: "small" }, { default: () => t.label });
- },
- },
- {
- title: "购买时间",
- key: "addTime",
- width: 170,
- render: (row) => timeCell(row.addTime),
- },
- {
- title: "支付时间",
- key: "payTime",
- width: 170,
- render: (row) => timeCell(row.payTime),
- },
- {
- title: "购买的课程",
- key: "details",
- width: 120,
- render: (row) =>
- h(
- NButton,
- { size: "small", onClick: () => openCoursesModal(row) },
- { default: () => "查看" },
- ),
- },
- {
- title: "操作",
- key: "actions",
- width: 120,
- fixed: "right",
- render: (row) =>
- h(
- NButton,
- {
- size: "small",
- type: row.status === 1 ? "primary" : "default",
- disabled: row.status !== 1,
- loading: completingId.value === row.id,
- onClick: () => confirmOrder(row),
- },
- { default: () => "确认订单" },
- ),
- },
- ]);
- const { visibleKeys, displayColumns, columnOptions } = useTableColumnsControl(
- columns,
- { frozenKeys: ["actions"] },
- );
- watch([page, pageSize], () => {
- void fetchList();
- });
- onMounted(() => {
- void fetchList();
- });
- onActivated(() => {
- void fetchList();
- });
- </script>
- <template>
- <div class="page page--table">
- <AdminSearchPanel :field-count="5" @search="onSearch" @reset="resetSearch">
- <NFormItemGi :span="1" label="流水号">
- <NInput
- v-model:value="search.serial"
- clearable
- placeholder="流水号"
- @keyup.enter="onSearch"
- />
- </NFormItemGi>
- <NFormItemGi :span="1" label="付款人姓名">
- <NInput
- v-model:value="search.payName"
- clearable
- placeholder="付款人姓名"
- @keyup.enter="onSearch"
- />
- </NFormItemGi>
- <NFormItemGi :span="1" label="状态">
- <NSelect v-model:value="search.status" :options="statusOptions" />
- </NFormItemGi>
- <NFormItemGi :span="1" label="时间范围">
- <NDatePicker
- v-model:value="search.dateRange"
- type="daterange"
- clearable
- style="width: 100%"
- />
- </NFormItemGi>
- </AdminSearchPanel>
- <NCard :bordered="false" class="table-card table-card--fill">
- <div class="table-card-inner">
- <AdminTablePageBar
- title="订单列表"
- v-model:visible-keys="visibleKeys"
- :column-options="columnOptions"
- :refresh-loading="loading"
- @refresh="fetchList"
- />
- <div class="table-card__body">
- <NDataTable
- :columns="displayColumns"
- :data="list"
- :loading="loading"
- :bordered="false"
- :single-line="false"
- :row-key="(row: OrderItem) => row.id"
- class="data-table-fill"
- :scroll-x="2000"
- />
- </div>
- <div class="pager-wrap">
- <div class="pager-inline">
- <span class="pager-total">共 {{ total }} 条</span>
- <NPagination
- v-model:page="page"
- v-model:page-size="pageSize"
- :item-count="total"
- :page-sizes="[10, 20, 50, 100]"
- show-size-picker
- />
- </div>
- </div>
- </div>
- </NCard>
- <NModal
- v-model:show="coursesModalVisible"
- preset="card"
- title="购买的课程"
- style="width: min(860px, 92vw)"
- :mask-closable="false"
- >
- <div v-if="activeCourses.length" class="order-course-list">
- <div
- v-for="(item, index) in activeCourses"
- :key="`${courseName(item)}-${index}`"
- class="order-course-item"
- >
- <NImage
- class="order-course-item__cover"
- :src="resolveMediaUrl(item.frontUrl)"
- object-fit="cover"
- width="88"
- height="88"
- preview-disabled
- fallback-src=""
- />
- <div class="order-course-item__main">
- <div class="order-course-item__name">{{ courseName(item) }}</div>
- <div class="order-course-item__amount">金额:{{ courseAmount(item) }}</div>
- </div>
- </div>
- </div>
- <div v-else class="order-course-empty">暂无课程数据</div>
- <template #footer>
- <NSpace justify="end">
- <NButton @click="coursesModalVisible = false">关闭</NButton>
- </NSpace>
- </template>
- </NModal>
- </div>
- </template>
- <style scoped>
- .order-course-list {
- display: grid;
- gap: 12px;
- max-height: 58vh;
- overflow: auto;
- padding-right: 2px;
- }
- .order-course-item {
- display: flex;
- gap: 12px;
- border: 1px solid #e5e7eb;
- border-radius: 10px;
- padding: 10px;
- align-items: center;
- }
- .order-course-item__cover {
- flex: none;
- border-radius: 8px;
- overflow: hidden;
- background: #f8fafc;
- }
- .order-course-item__main {
- min-width: 0;
- }
- .order-course-item__name {
- font-size: 14px;
- color: #111827;
- font-weight: 600;
- line-height: 1.4;
- word-break: break-all;
- }
- .order-course-item__amount {
- margin-top: 8px;
- color: #475569;
- font-size: 13px;
- }
- .order-course-empty {
- color: #64748b;
- text-align: center;
- padding: 24px 0;
- }
- </style>
|