diff --git a/yelaymaths/pages/buy/buy.js b/yelaymaths/pages/buy/buy.js index 2167bb1..4ddddfd 100644 --- a/yelaymaths/pages/buy/buy.js +++ b/yelaymaths/pages/buy/buy.js @@ -1,14 +1,44 @@ // pages/buy/buy.js // // 商业化页面:所有购买入口都落到这里。 -// 职责是「确认订单 → 拉起微信支付 → 确认发货」,课程详情不在这里展示。 -// UI 待设计,wxml 现在只有一个占位块,支付链路已经就位。 +// 职责是「确认订单 → 拉起微信支付 → 确认发货」。 +// +// 两种进入方式: +// - 带 ?code=xxx(从课程详情页进来):只显示这一门 +// - 不带参数:列出全部课程,一门一个购买按钮 const api = require('../../utils/api'); const format = require('../../utils/format'); const store = require('../../utils/store'); const POLL_INTERVAL = 1500; -const POLL_MAX = 8; +// 微信的发货推送实测要 13~17 秒才到,原来只等 12 秒,用户多半看到「可能稍有延迟」而不是「购买成功」 +const POLL_MAX = 20; + +/** iPhone 上跑 Apple 支付要求微信客户端 ≥ 这个版本 */ +const IOS_MIN_WECHAT = '8.0.68'; + +/** 当前微信客户端版本号,取不到就返回空串。 */ +function clientVersion() { + try { + const info = typeof wx.getAppBaseInfo === 'function' ? wx.getAppBaseInfo() : wx.getSystemInfoSync(); + return (info && info.version) || ''; + } catch (err) { + return ''; + } +} + +/** 版本号比大小:'8.0.68' vs '8.0.5' → 正数表示前者新。 */ +function compareVersion(a, b) { + const left = String(a).split('.').map(Number); + const right = String(b).split('.').map(Number); + const len = Math.max(left.length, right.length); + for (let i = 0; i < len; i += 1) { + const l = left[i] || 0; + const r = right[i] || 0; + if (l !== r) return l - r; + } + return 0; +} Page({ data: { @@ -19,17 +49,17 @@ Page({ title: '', coverUrl: '', priceYuan: '', + list: [], }, onLoad(options) { const code = options && options.code; - // 没带课程号说明是误入(比如直接输路径打开),不该停在这个页面 - if (!code) { - this.rejectOut('缺少课程参数'); + if (code) { + this.setData({ code: code }); + this.load(code); return; } - this.setData({ code: code }); - this.load(code); + this.loadList(); }, load(code) { @@ -53,6 +83,27 @@ Page({ }); }, + /** 不带课程号进来:列出全部课程,让用户自己挑。 */ + loadList() { + api + .getCourses() + .then((courses) => { + this.setData({ + loading: false, + list: (courses || []).map((c) => ({ + code: c.code, + title: c.title, + priceYuan: format.fenToYuan(c.priceFen), + owned: c.owned, + })), + }); + }) + .catch((err) => { + this.setData({ loading: false }); + wx.showToast({ title: (err && err.message) || '课程加载失败', icon: 'none' }); + }); + }, + /** 进错页面(没带课程、课程不存在、已经买过):提示一句然后退出去。 */ rejectOut(message) { wx.showToast({ title: message, icon: 'none' }); @@ -68,9 +119,14 @@ Page({ wx.switchTab({ url: '/pages/kecheng/kecheng' }); }, - /** 购买:下单 → 拉起支付 → 轮询订单状态确认发货。 */ - buyNow() { - if (this.data.buying || !this.data.code) return; + /** + * 购买:下单 → 拉起支付 → 轮询订单状态确认发货。 + * 课程号优先取按钮上的 data-code(列表模式),没有就用页面参数(单课程模式)。 + */ + buyNow(e) { + const code = + (e && e.currentTarget && e.currentTarget.dataset && e.currentTarget.dataset.code) || this.data.code; + if (this.data.buying || !code) return; // 购买的前置条件:用户主动使用过登录功能。静默登录不算。 if (!store.isLoggedIn()) { @@ -92,11 +148,14 @@ Page({ return; } + // iOS 版本预检放在下单之前:版本不够时不该先建出一笔待支付订单 + if (!this.checkIosVersion()) return; + this.setData({ buying: true }); wx.showLoading({ title: '发起支付', mask: true }); api - .createOrder(this.data.code) + .createOrder(code) .then((order) => { wx.hideLoading(); return api.requestVirtualPayment(order.payData).then(() => order.outTradeNo); @@ -107,10 +166,41 @@ Page({ this.setData({ buying: false }); // 用户主动取消不算错误 if (err && err.errMsg && err.errMsg.indexOf('cancel') >= 0) return; - wx.showToast({ title: (err && err.message) || '支付失败', icon: 'none' }); + // 拉起支付失败时微信给的是 errMsg/errno,服务端报错给的是 message。 + // 原来只弹「支付失败」,把排查线索全丢了,所以原样带出来。 + const detail = (err && (err.errMsg || err.message)) || '未知错误'; + const errno = err && (err.errno || err.errCode); + wx.showModal({ + title: '支付失败', + content: errno ? detail + '\n错误码:' + errno : detail, + showCancel: false, + }); }); }, + /** + * iPhone 上 Apple 支付要求微信 ≥ 8.0.68,低了拉不起来。 + * 只在 iOS 上校验;安卓等没有这个限制,取不到版本号也不拦(交给微信自己报错)。 + */ + checkIosVersion() { + if (api.devicePlatform() !== 'ios') return true; + const version = clientVersion(); + if (!version || compareVersion(version, IOS_MIN_WECHAT) >= 0) return true; + + wx.showModal({ + title: '请先升级微信', + content: + 'iPhone 上购买课程需要微信 ' + + IOS_MIN_WECHAT + + ' 及以上版本,你当前是 ' + + version + + '。请到 App Store 更新微信后重试。', + showCancel: false, + confirmText: '知道了', + }); + return false; + }, + /** * 支付成功后权益由服务端发货推送发放,存在秒级延迟,这里轮询确认。 * 即便轮询全部超时也不会丢单:服务端有定时查单兜底。 @@ -123,7 +213,12 @@ Page({ if (order.status === 2) { this.setData({ buying: false, owned: true }); wx.showToast({ title: '购买成功', icon: 'success', mask: true }); - setTimeout(() => this.back(), 1500); + // 单课程模式从详情页进来,买完退回去;列表模式留在原地刷新 + if (this.data.code) { + setTimeout(() => this.back(), 1500); + } else { + this.loadList(); + } return; } if (attempts >= POLL_MAX || order.status === 3 || order.status === 4) { diff --git a/yelaymaths/pages/buy/buy.wxml b/yelaymaths/pages/buy/buy.wxml index 861d37f..c48adac 100644 --- a/yelaymaths/pages/buy/buy.wxml +++ b/yelaymaths/pages/buy/buy.wxml @@ -1,7 +1,34 @@ - - 购买页(待设计) + + + {{title}} + ¥{{priceYuan}} + + + + + + + {{item.title}} + ¥{{item.priceYuan}} + + + + + 暂无可购买的课程 + + diff --git a/yelaymaths/pages/buy/buy.wxss b/yelaymaths/pages/buy/buy.wxss index 61a3497..5060c78 100644 --- a/yelaymaths/pages/buy/buy.wxss +++ b/yelaymaths/pages/buy/buy.wxss @@ -1,14 +1,106 @@ /* pages/buy/buy.wxss */ -/* 占位样式,UI 定稿后连同 wxml 一起替换 */ .buy-page { - display: flex; - align-items: center; - justify-content: center; - min-height: 70vh; + min-height: 100vh; + background: #f6f7f9; + padding: 24rpx 24rpx 48rpx; + box-sizing: border-box; } -.buy-placeholder { - color: #999; - font-size: 28rpx; +/* ── 单课程 ───────────────────────────────────────────── */ +.buy-single { + background: #fff; + border-radius: 16rpx; + padding: 48rpx 32rpx; + display: flex; + flex-direction: column; + align-items: center; +} + +.buy-title { + font-size: 34rpx; + font-weight: 600; + color: #222; + text-align: center; +} + +.buy-price { + margin-top: 24rpx; + font-size: 44rpx; + color: #ff4d4f; + font-weight: 600; +} + +/* ── 课程列表 ─────────────────────────────────────────── */ +.buy-list { + background: #fff; + border-radius: 16rpx; + overflow: hidden; +} + +.buy-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 28rpx 24rpx; + border-bottom: 1rpx solid #eee; +} + +.buy-item:last-child { + border-bottom: none; +} + +.buy-item-main { + flex: 1; + min-width: 0; + margin-right: 20rpx; + display: flex; + flex-direction: column; +} + +.buy-item-title { + font-size: 28rpx; + color: #222; + line-height: 1.4; +} + +.buy-item-price { + margin-top: 8rpx; + font-size: 26rpx; + color: #ff4d4f; +} + +.buy-empty { + padding: 80rpx 0; + text-align: center; + color: #999; + font-size: 26rpx; +} + +/* ── 按钮 ─────────────────────────────────────────────── */ +.buy-btn { + margin-top: 40rpx; + background: #07c160; + color: #fff; + font-size: 30rpx; + border-radius: 44rpx; + line-height: 2.6; +} + +.buy-btn-mini { + margin-top: 0; + flex: none; + font-size: 26rpx; + line-height: 2.4; + padding: 0 30rpx; +} + +.buy-btn[disabled] { + background: #d8d8d8; + color: #fff; +} + +/* 去掉微信按钮自带的那圈边框 */ +.buy-btn::after { + border: none; } diff --git a/yelaymaths/utils/api.js b/yelaymaths/utils/api.js index 4b76733..7acc722 100644 --- a/yelaymaths/utils/api.js +++ b/yelaymaths/utils/api.js @@ -20,6 +20,19 @@ function staticUrl(path) { return config.STATIC_BASE_URL.replace(/\/+$/, '') + '/' + String(path).replace(/^\/+/, ''); } +/** + * 下单设备。平台按设备路由支付渠道(iOS 走 Apple 支付,只能用户自己找 App Store 退), + * 服务端靠这个字段判断一笔单能不能主动退款,所以下单时要一起报上去。 + */ +function platform() { + try { + if (typeof wx.getDeviceInfo === 'function') return wx.getDeviceInfo().platform || 'unknown'; + return wx.getSystemInfoSync().platform || 'unknown'; + } catch (err) { + return 'unknown'; + } +} + function normalizeError(body, statusCode) { if (body && typeof body.code === 'number') { return { code: body.code, message: body.message || '请求失败', requestId: body.requestId }; @@ -196,7 +209,11 @@ const api = { // ── 订单与支付 ────────────────────────────────────────────────────── createOrder: (courseCode) => - request({ url: '/v1/orders', method: 'POST', data: { courseCode: courseCode } }), + request({ + url: '/v1/orders', + method: 'POST', + data: { courseCode: courseCode, terminal: platform() }, + }), getOrder: (outTradeNo) => request({ url: '/v1/orders/' + outTradeNo }), }; @@ -219,4 +236,4 @@ function requestVirtualPayment(payData) { }); } -module.exports = Object.assign(api, { requestVirtualPayment }); +module.exports = Object.assign(api, { requestVirtualPayment, devicePlatform: platform }); diff --git a/yelaymaths_admin/src/app/(admin)/layout.tsx b/yelaymaths_admin/src/app/(admin)/layout.tsx index 0dcf59b..93a20c6 100644 --- a/yelaymaths_admin/src/app/(admin)/layout.tsx +++ b/yelaymaths_admin/src/app/(admin)/layout.tsx @@ -14,6 +14,8 @@ const FIXED_NAV = [ { href: '/', label: '数据看板' }, { href: '/users', label: '用户' }, { href: '/orders', label: '订单' }, + { href: '/refund-requests', label: '退款申请' }, + { href: '/notify-logs', label: '通知日志' }, { href: '/settings', label: '系统配置' }, ]; @@ -263,4 +265,3 @@ function NavItem({ href, label, active }: { href: string; label: string; active: ); } - diff --git a/yelaymaths_admin/src/app/(admin)/notify-logs/page.tsx b/yelaymaths_admin/src/app/(admin)/notify-logs/page.tsx new file mode 100644 index 0000000..98d039f --- /dev/null +++ b/yelaymaths_admin/src/app/(admin)/notify-logs/page.tsx @@ -0,0 +1,124 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import { api } from '@/lib/api'; +import { formatDateTime } from '@/lib/datetime'; +import { Button, Card, Empty, Notice } from '@/components/ui'; + +interface NotifyLogRow { + id: number; + event: string | null; + event_label: string; + /** 0 收到但没处理 / 1 处理成功 / 2 处理失败 */ + processed: number; + err_msg: string | null; + order_no: string | null; + raw_body: string; + created_at: string; +} + +const PAGE_SIZE = 20; + +const RESULT: Record = { + 0: { text: '已忽略', className: 'text-gray-500' }, + 1: { text: '成功', className: 'text-green-600' }, + 2: { text: '失败', className: 'text-red-600' }, +}; + +export default function NotifyLogsPage() { + const [items, setItems] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [error, setError] = useState(''); + + const load = useCallback((nextPage: number) => { + setError(''); + api + .listNotifyLogs(nextPage, PAGE_SIZE) + .then((res) => { + setItems(res.items as unknown as NotifyLogRow[]); + setTotal(res.total); + setPage(res.page); + }) + .catch((err: { message: string }) => setError(err.message)); + }, []); + + useEffect(() => { + load(1); + }, [load]); + + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); + + return ( +
+

通知日志

+

+ 微信服务器发给我们的每一条推送都在这里:发货、退款问询、退款成功、投诉、风控等。 + 用来确认「这条通知到底有没有来、我们是怎么处理的」。没收到推送不代表订单有问题—— + 平台未必每笔都通知(比如 iOS 退款,苹果可能自己判定不发问询)。 +

+ + + +
共 {total} 条通知
+ + {items.length === 0 ? ( + + ) : ( +
+ + + + + + + + + + + + {items.map((r) => { + const result = RESULT[r.processed] ?? { text: String(r.processed), className: '' }; + return ( + + + + + + + + ); + })} + +
时间通知类型处理结果关联订单说明 / 原始报文
{formatDateTime(r.created_at)} + {r.event_label} +
{r.event || '(无 Event 字段)'}
+
{result.text}{r.order_no || '—'} + {r.err_msg &&
{r.err_msg}
} +
+ 查看原始报文 +
+                            {r.raw_body}
+                          
+
+
+
+ )} + +
+ + 第 {page} / {totalPages} 页 + +
+ + +
+
+
+
+ ); +} diff --git a/yelaymaths_admin/src/app/(admin)/orders/page.tsx b/yelaymaths_admin/src/app/(admin)/orders/page.tsx index e095d0d..ac54976 100644 --- a/yelaymaths_admin/src/app/(admin)/orders/page.tsx +++ b/yelaymaths_admin/src/app/(admin)/orders/page.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from 'react'; import { api } from '@/lib/api'; import { ORDER_STATUS_TEXT } from '@/lib/resources'; +import { formatDateTime } from '@/lib/datetime'; import { Button, Card, Empty, Input, Notice, Select } from '@/components/ui'; interface OrderRow { @@ -11,6 +12,12 @@ interface OrderRow { wx_order_id: string | null; status: number; amount_fen: number; + /** 下单设备:ios 走 Apple 支付,不能主动退款 */ + terminal: string | null; + /** 管理员已同意退款的时间;非空表示 iOS 退款问询会放过 */ + refund_consent_at: string | null; + /** 已向微信发起退款的时间;非空且未退款完成时显示「退款中」 */ + refund_requested_at: string | null; nickname: string | null; openid: string; course_title: string; @@ -22,6 +29,16 @@ interface OrderRow { const PAGE_SIZE = 20; +/** 微信退款接口只认这几个枚举值,不能传自由文本 */ +const REFUND_REASONS = [ + { value: 3, label: '3 用户主动退款' }, + { value: 0, label: '0 暂无描述' }, + { value: 1, label: '1 产品问题,影响使用或效果不佳' }, + { value: 2, label: '2 售后问题,无法满足需求' }, + { value: 4, label: '4 价格问题' }, + { value: 5, label: '5 其他原因' }, +]; + export default function OrdersPage() { const [items, setItems] = useState([]); const [total, setTotal] = useState(0); @@ -30,7 +47,9 @@ export default function OrdersPage() { const [error, setError] = useState(''); const [notice, setNotice] = useState(''); const [refundTarget, setRefundTarget] = useState(null); + const [consentTarget, setConsentTarget] = useState(null); const [reason, setReason] = useState('用户申请退款'); + const [reasonCode, setReasonCode] = useState('3'); const load = useCallback((nextPage: number, nextStatus: string) => { setError(''); @@ -53,9 +72,9 @@ export default function OrdersPage() { setError(''); setNotice(''); api - .refundOrder(refundTarget.id, reason) + .refundOrder(refundTarget.id, reason, Number(reasonCode)) .then(() => { - setNotice('退款请求已提交。iOS 端需用户自行向 App Store 申请,开发者无法发起。'); + setNotice('退款请求已提交。退款完成后订单会自动变为「已退款」,并收回课程权益。'); setRefundTarget(null); load(page, status); }) @@ -64,6 +83,43 @@ export default function OrdersPage() { const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); + /** iOS 订单只能「允许」——打标记,钱的事由苹果处理,我们发起不了退款。 */ + const isIos = (order: OrderRow) => order.terminal === 'ios'; + + /** + * 列表里显示的状态。 + * 退款是异步的:微信受理后要等它推 xpay_refund_notify 才会变成「已退款」, + * 中间这段必须显示「退款中」,否则看起来像没退成功,管理员会再点一次。 + */ + const statusOf = (order: OrderRow) => { + if (order.status === 3) return { text: '已退款', className: 'text-red-600' }; + if (order.refund_requested_at) return { text: '退款中', className: 'text-amber-600' }; + return { + text: ORDER_STATUS_TEXT[order.status] ?? String(order.status), + className: '', + }; + }; + + /** 打 / 取消「允许退款」。只改标记,不发起任何退款动作。 */ + const submitConsent = () => { + if (!consentTarget) return; + const order = consentTarget; + setError(''); + setNotice(''); + api + .setRefundConsent(order.id, !order.refund_consent_at) + .then(() => { + setNotice( + order.refund_consent_at + ? '已取消「允许退款」。用户再申请时我们会拒绝。' + : '已允许退款。请让用户现在去 App Store 申请,我们会在问询环节放行。', + ); + setConsentTarget(null); + load(page, status); + }) + .catch((err: { message: string }) => setError(err.message)); + }; + return (

订单

@@ -103,6 +159,7 @@ export default function OrdersPage() { 用户 课程 金额 + 终端 状态 下单时间 操作 @@ -112,17 +169,28 @@ export default function OrdersPage() { {items.map((o) => ( {o.out_trade_no} - {o.nickname || o.openid.slice(0, 8)} + +
{o.nickname || 游客}
+
{o.openid}
+ {o.course_title} ¥{(o.amount_fen / 100).toFixed(2)} - {ORDER_STATUS_TEXT[o.status] ?? o.status} - {String(o.created_at).slice(0, 16).replace('T', ' ')} + {o.terminal || '—'} + {statusOf(o).text} + {formatDateTime(o.created_at)} - {o.status === 2 && ( - - )} + {o.status === 2 && + (o.refund_requested_at ? ( + 退款中… + ) : isIos(o) ? ( + + ) : ( + + ))} ))} @@ -151,9 +219,17 @@ export default function OrdersPage() {

发起退款

- 订单 {refundTarget.out_trade_no},金额 ¥{(refundTarget.amount_fen / 100).toFixed(2)} + 订单 {refundTarget.out_trade_no},金额 ¥{(refundTarget.amount_fen / 100).toFixed(2)}, + {refundTarget.terminal || '未知终端'}

- +

+ 这会真的把钱退回去 + ,退款完成后订单变为「已退款」并收回课程权益。 +

+ +
)} + + {consentTarget && ( +
+
+

+ {consentTarget.refund_consent_at ? '取消允许退款' : '允许退款'} +

+

+ 订单 {consentTarget.out_trade_no},金额 ¥{(consentTarget.amount_fen / 100).toFixed(2)}, + {consentTarget.terminal || '未知终端'}(Apple 支付) +

+

+ {consentTarget.refund_consent_at + ? '取消后,用户再发起退款申请,我们会在苹果问询环节拒绝。' + : '不发起退款、也不动钱,只打一个标记。请先和用户沟通好,再让他在 App Store 申请退款;那时我们会放行。顺序反了无效。'} +

+
+ + +
+
+
+ )}
); } - diff --git a/yelaymaths_admin/src/app/(admin)/refund-requests/page.tsx b/yelaymaths_admin/src/app/(admin)/refund-requests/page.tsx new file mode 100644 index 0000000..6e192fd --- /dev/null +++ b/yelaymaths_admin/src/app/(admin)/refund-requests/page.tsx @@ -0,0 +1,151 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import { api } from '@/lib/api'; +import { ORDER_STATUS_TEXT } from '@/lib/resources'; +import { formatDateTime } from '@/lib/datetime'; +import { Button, Card, Empty, Notice } from '@/components/ui'; + +interface RefundRequestRow { + id: number; + pay_order_id: string; + out_trade_no: string | null; + nickname: string | null; + openid: string | null; + course_title: string | null; + amount_fen: number | null; + terminal: string | null; + order_status: number | null; + reason: string | null; + provide_status: string | null; + our_result: number; + our_evidence: string | null; + query_count: number; + created_at: string; +} + +const PAGE_SIZE = 20; + +/** 我们答复时看到的发货状态 */ +const PROVIDE_STATUS_TEXT: Record = { + '0': '未发货', + '1': '已发货', + '2': '发货中', +}; + +export default function RefundRequestsPage() { + const [items, setItems] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [error, setError] = useState(''); + + const load = useCallback((nextPage: number) => { + setError(''); + api + .listRefundRequests(nextPage, PAGE_SIZE) + .then((res) => { + setItems(res.items as unknown as RefundRequestRow[]); + setTotal(res.total); + setPage(res.page); + }) + .catch((err: { message: string }) => setError(err.message)); + }, []); + + useEffect(() => { + load(1); + }, [load]); + + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); + + return ( +
+

退款申请

+

+ Apple 用户去 App Store 申请退款后,平台会向我们发退款问询。问询要求 3 秒内答复,所以这里是「我们已经怎么答的」, + 不是待办。Apple 最终退了钱的话,对应订单会变成「已退款」,权益同时被收回。 +

+ + + +
共 {total} 条申请
+ + {items.length === 0 ? ( + + ) : ( +
+ + + + + + + + + + + + + + {items.map((r) => ( + + + + + + + + + + ))} + +
申请时间用户订单课程 / 金额申请原因我们的答复最终结果
+ {formatDateTime(r.created_at)} + {r.query_count > 1 &&
问询 ×{r.query_count}
} +
+
{r.nickname || 游客}
+ {r.openid &&
{r.openid}
} +
+ {r.out_trade_no || 未匹配到订单} +
{r.pay_order_id}
+
+ {r.course_title || '—'} + {r.amount_fen !== null && ( +
¥{(r.amount_fen / 100).toFixed(2)}
+ )} +
{r.reason || '—'} + + {r.our_result === 1 ? '建议拒绝' : '建议退款'} + +
+ 平台发货状态:{PROVIDE_STATUS_TEXT[r.provide_status ?? ''] ?? '未知'} +
+
{r.our_evidence}
+
+ {r.order_status === null ? ( + — + ) : ( + + {ORDER_STATUS_TEXT[r.order_status] ?? r.order_status} + + )} +
+
+ )} + +
+ + 第 {page} / {totalPages} 页 + +
+ + +
+
+
+
+ ); +} diff --git a/yelaymaths_admin/src/app/(admin)/users/page.tsx b/yelaymaths_admin/src/app/(admin)/users/page.tsx index a7f1b08..36a14d0 100644 --- a/yelaymaths_admin/src/app/(admin)/users/page.tsx +++ b/yelaymaths_admin/src/app/(admin)/users/page.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from 'react'; import { api } from '@/lib/api'; import { Button, Card, Empty, Input, Modal, Notice, Select } from '@/components/ui'; +import { formatDate } from '@/lib/datetime'; interface UserRow { id: number; @@ -157,6 +158,7 @@ export default function UsersPage() { ID 昵称 + openid 已购课程 状态 最后登录 @@ -168,12 +170,13 @@ export default function UsersPage() { {u.id} {u.nickname || 游客} + {u.openid} {u.entitlement_count} {u.status === 1 ? 正常 : 封禁} - {u.last_login_at ? String(u.last_login_at).slice(0, 10) : '—'} + {formatDate(u.last_login_at)}