mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-05 23:26:38 +08:00
压缩历史至 95081aff
This commit is contained in:
@@ -0,0 +1,378 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { ManagePage, ManageDetailPanel } from "@/components/common/general/manage-pannel"
|
||||
import { Input } from "@/components/ui/input"
|
||||
|
||||
import { formatDateTime } from "@/lib/utils"
|
||||
import type { UserPayConfig } from "@/lib/services"
|
||||
import { useAdmin } from "@/contexts/admin-context"
|
||||
|
||||
|
||||
/**
|
||||
* 积分配置详情面板组件
|
||||
* 显示积分配置的详细信息和编辑面板
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <PayConfigDetailPanel
|
||||
* config={config}
|
||||
* editData={editData}
|
||||
* onEditDataChange={onEditDataChange}
|
||||
* onSave={onSave}
|
||||
* saving={saving}
|
||||
* />
|
||||
* ```
|
||||
* @param {UserPayConfig} config - 积分配置
|
||||
* @param {Partial<UserPayConfig>} editData - 编辑数据
|
||||
* @param {function} onEditDataChange - 编辑数据改变回调
|
||||
* @param {function} onSave - 保存回调
|
||||
* @param {boolean} saving - 是否正在保存
|
||||
* @returns {React.ReactNode} 积分配置详情面板组件
|
||||
*/
|
||||
function PayConfigDetailPanel({
|
||||
config,
|
||||
editData,
|
||||
onEditDataChange,
|
||||
onSave,
|
||||
saving
|
||||
}: {
|
||||
config: UserPayConfig | null
|
||||
editData: Partial<UserPayConfig>
|
||||
onEditDataChange: (field: keyof UserPayConfig, value: UserPayConfig[keyof UserPayConfig]) => void
|
||||
onSave: () => void
|
||||
saving: boolean
|
||||
}) {
|
||||
|
||||
|
||||
return (
|
||||
<ManageDetailPanel
|
||||
isEmpty={!config}
|
||||
onSave={onSave}
|
||||
saving={saving}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-0">
|
||||
<div className="border border-dashed rounded-l-lg">
|
||||
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
|
||||
<label className="text-xs font-medium text-muted-foreground">配置等级</label>
|
||||
<p className="text-xs text-muted-foreground">Level {config?.level}</p>
|
||||
</div>
|
||||
|
||||
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
|
||||
<label className="text-xs font-medium text-muted-foreground">配置ID</label>
|
||||
<p className="text-xs text-muted-foreground">{config?.id}</p>
|
||||
</div>
|
||||
|
||||
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
|
||||
<label className="text-xs font-medium text-muted-foreground">创建时间</label>
|
||||
<p className="text-xs text-muted-foreground">{config ? formatDateTime(config.created_at) : ''}</p>
|
||||
</div>
|
||||
|
||||
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed">
|
||||
<label className="text-xs font-medium text-muted-foreground">更新时间</label>
|
||||
<p className="text-xs text-muted-foreground">{config ? formatDateTime(config.updated_at) : ''}</p>
|
||||
</div>
|
||||
|
||||
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0" aria-hidden="true">
|
||||
<span className="text-xs text-muted-foreground"> </span>
|
||||
<span className="text-xs text-muted-foreground"> </span>
|
||||
</div>
|
||||
|
||||
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0" aria-hidden="true">
|
||||
<span className="text-xs text-muted-foreground"> </span>
|
||||
<span className="text-xs text-muted-foreground"> </span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-dashed rounded-r-lg border-l-0">
|
||||
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
|
||||
<label className="text-xs font-medium text-muted-foreground">最低分数</label>
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
type="number"
|
||||
step="1"
|
||||
min="0"
|
||||
value={editData.min_score !== undefined ? editData.min_score.toString() : (config?.min_score?.toString() || '')}
|
||||
placeholder={editData.min_score === undefined && !config?.min_score ? '必需' : ''}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
if (value === '') {
|
||||
onEditDataChange('min_score', 0)
|
||||
return
|
||||
}
|
||||
|
||||
const numValue = parseInt(value)
|
||||
if (isNaN(numValue)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (numValue >= 0) {
|
||||
onEditDataChange('min_score', numValue)
|
||||
}
|
||||
}}
|
||||
className="text-xs text-right h-4 w-16 px-0 rounded-none border-none shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-[12px]"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">LDC</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
|
||||
<label className="text-xs font-medium text-muted-foreground">最高分数</label>
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
type="number"
|
||||
step="1"
|
||||
min="0"
|
||||
value={editData.max_score !== undefined ? (editData.max_score?.toString() || '') : (config?.max_score?.toString() || '')}
|
||||
placeholder={(editData.max_score === null || editData.max_score === undefined) && (config?.max_score === null || config?.max_score === undefined) ? '无限制' : ''}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
if (value === '') {
|
||||
onEditDataChange('max_score', null)
|
||||
return
|
||||
}
|
||||
|
||||
const numValue = parseInt(value)
|
||||
if (isNaN(numValue)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (numValue >= 0) {
|
||||
onEditDataChange('max_score', numValue)
|
||||
}
|
||||
}}
|
||||
className="text-xs text-right h-4 w-16 px-0 rounded-none border-none shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-[12px]"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">LDC</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
|
||||
<label className="text-xs font-medium text-muted-foreground">手续费率</label>
|
||||
<div className="flex items-right gap-1">
|
||||
<Input
|
||||
type="number"
|
||||
step="1"
|
||||
min="0"
|
||||
max="100"
|
||||
value={
|
||||
editData.fee_rate !== undefined
|
||||
? (parseFloat(editData.fee_rate.toString()) * 100).toString()
|
||||
: (config?.fee_rate ? (parseFloat(config.fee_rate.toString()) * 100).toString() : '')
|
||||
}
|
||||
placeholder={editData.fee_rate === undefined && !config?.fee_rate ? '必需' : ''}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
if (value === '') {
|
||||
onEditDataChange('fee_rate', '0')
|
||||
return
|
||||
}
|
||||
|
||||
const numValue = parseInt(value)
|
||||
if (isNaN(numValue)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (numValue >= 0 && numValue <= 100) {
|
||||
onEditDataChange('fee_rate', (numValue / 100).toString())
|
||||
}
|
||||
}}
|
||||
className="text-xs text-right h-4 w-16 px-0 mr-3 rounded-none border-none shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-[12px]"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">%</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
|
||||
<label className="text-xs font-medium text-muted-foreground">分数转化率</label>
|
||||
<div className="flex items-right gap-1">
|
||||
<Input
|
||||
type="number"
|
||||
step="1"
|
||||
min="0"
|
||||
max="100"
|
||||
value={
|
||||
editData.score_rate !== undefined
|
||||
? (parseFloat(editData.score_rate.toString()) * 100).toString()
|
||||
: (config?.score_rate ? (parseFloat(config.score_rate.toString()) * 100).toString() : '')
|
||||
}
|
||||
placeholder={editData.score_rate === undefined && !config?.score_rate ? '必需' : ''}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
if (value === '') {
|
||||
onEditDataChange('score_rate', '0')
|
||||
return
|
||||
}
|
||||
|
||||
const numValue = parseInt(value)
|
||||
if (isNaN(numValue)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (numValue >= 0 && numValue <= 100) {
|
||||
onEditDataChange('score_rate', (numValue / 100).toString())
|
||||
}
|
||||
}}
|
||||
className="text-xs text-right h-4 w-16 px-0 mr-3 rounded-none border-none shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-[12px]"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">%</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
|
||||
<label className="text-xs font-medium text-muted-foreground">分发费率</label>
|
||||
<div className="flex items-right gap-1">
|
||||
<Input
|
||||
type="number"
|
||||
step="1"
|
||||
min="0"
|
||||
max="100"
|
||||
value={
|
||||
editData.distribute_rate !== undefined
|
||||
? (parseFloat(editData.distribute_rate.toString()) * 100).toString()
|
||||
: (config?.distribute_rate ? (parseFloat(config.distribute_rate.toString()) * 100).toString() : '')
|
||||
}
|
||||
placeholder={editData.distribute_rate === undefined && !config?.distribute_rate ? '必需' : ''}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
if (value === '') {
|
||||
onEditDataChange('distribute_rate', '0')
|
||||
return
|
||||
}
|
||||
|
||||
const numValue = parseInt(value)
|
||||
if (isNaN(numValue)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (numValue >= 0 && numValue <= 100) {
|
||||
onEditDataChange('distribute_rate', (numValue / 100).toString())
|
||||
}
|
||||
}}
|
||||
className="text-xs text-right h-4 w-16 px-0 mr-3 rounded-none border-none shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-[12px]"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">%</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-3 py-2 flex items-center justify-between">
|
||||
<label className="text-xs font-medium text-muted-foreground">每日支付上限</label>
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
type="number"
|
||||
step="1"
|
||||
min="0"
|
||||
value={editData.daily_limit !== undefined ? (editData.daily_limit?.toString() || '') : (config?.daily_limit?.toString() || '')}
|
||||
placeholder={(editData.daily_limit === null || editData.daily_limit === undefined) && (config?.daily_limit === null || config?.daily_limit === undefined) ? '无限制' : ''}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
if (value === '') {
|
||||
onEditDataChange('daily_limit', null)
|
||||
return
|
||||
}
|
||||
|
||||
const numValue = parseInt(value)
|
||||
if (isNaN(numValue)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (numValue >= 0) {
|
||||
onEditDataChange('daily_limit', numValue)
|
||||
}
|
||||
}}
|
||||
className="text-xs text-right h-4 w-16 px-0 rounded-none border-none shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-[12px]"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">LDC</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ManageDetailPanel>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 用户积分配置管理组件
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <UserPayConfigs />
|
||||
* ```
|
||||
* @returns {React.ReactNode} 用户积分配置管理组件
|
||||
*/
|
||||
export function UserPayConfigs() {
|
||||
const {
|
||||
userPayConfigs: configs,
|
||||
userPayConfigsLoading: loading,
|
||||
userPayConfigsError: error,
|
||||
refetchUserPayConfigs,
|
||||
updateUserPayConfig,
|
||||
deleteUserPayConfig
|
||||
} = useAdmin()
|
||||
|
||||
const getInitialEditData = (config: UserPayConfig) => ({
|
||||
min_score: config.min_score,
|
||||
max_score: config.max_score,
|
||||
daily_limit: config.daily_limit,
|
||||
fee_rate: config.fee_rate.toString(),
|
||||
score_rate: config.score_rate.toString(),
|
||||
distribute_rate: config.distribute_rate.toString(),
|
||||
})
|
||||
|
||||
const handleSave = async (config: UserPayConfig, editData: Partial<UserPayConfig>) => {
|
||||
if (!config) return
|
||||
|
||||
await updateUserPayConfig(config.id, {
|
||||
min_score: editData.min_score ?? config.min_score,
|
||||
max_score: editData.max_score,
|
||||
daily_limit: editData.daily_limit,
|
||||
fee_rate: editData.fee_rate?.toString() ?? config.fee_rate.toString(),
|
||||
score_rate: editData.score_rate?.toString() ?? config.score_rate.toString(),
|
||||
distribute_rate: editData.distribute_rate?.toString() ?? config.distribute_rate.toString(),
|
||||
})
|
||||
await refetchUserPayConfigs()
|
||||
}
|
||||
|
||||
const handleDelete = async (config: UserPayConfig) => {
|
||||
await deleteUserPayConfig(config.id)
|
||||
}
|
||||
|
||||
return (
|
||||
<ManagePage<UserPayConfig>
|
||||
title="积分配置"
|
||||
data={configs}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onReload={refetchUserPayConfigs}
|
||||
getInitialEditData={getInitialEditData}
|
||||
onSave={handleSave}
|
||||
onDelete={handleDelete}
|
||||
getId={(config) => config.id}
|
||||
emptyDescription="未发现积分配置"
|
||||
loadingDescription="配置加载中"
|
||||
columns={[
|
||||
{ header: "等级", cell: (item) => <span className="font-medium">Level {item.level}</span>, width: "min-w-[80px]", align: "left" },
|
||||
{ header: "最低分", cell: (item) => item.min_score, width: "min-w-[200px]", align: "left" },
|
||||
{ header: "最高分", cell: (item) => item.max_score || "无限制", width: "min-w-[200px]", align: "left" },
|
||||
{ header: "每日限额", cell: (item) => item.daily_limit ? `LDC ${ item.daily_limit.toLocaleString() }` : "无限制", width: "min-w-[200px]", align: "left" },
|
||||
{ header: "费率", cell: (item) => `${ (Number(item.fee_rate) * 100).toFixed(2) }%`, width: "min-w-[200px]", align: "left" },
|
||||
{ header: "分数转化率", cell: (item) => `${ (Number(item.score_rate) * 100).toFixed(2) }%`, width: "min-w-[200px]", align: "left" },
|
||||
{ header: "分发费率", cell: (item) => `${ (Number(item.distribute_rate) * 100).toFixed(2) }%`, width: "min-w-[200px]", align: "left" },
|
||||
{ header: "更新时间", cell: (item) => <span className="text-muted-foreground">{formatDateTime(item.updated_at)}</span>, width: "min-w-[200px]", align: "left" },
|
||||
]}
|
||||
renderDetail={({ selected, hovered, editData, onEditDataChange, onSave, saving }) => (
|
||||
<PayConfigDetailPanel
|
||||
config={selected || hovered}
|
||||
editData={editData}
|
||||
onEditDataChange={onEditDataChange}
|
||||
onSave={onSave}
|
||||
saving={saving}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { ManagePage, ManageDetailPanel } from "@/components/common/general/manage-pannel"
|
||||
|
||||
import { formatDateTime } from "@/lib/utils"
|
||||
import type { SystemConfig } from "@/lib/services"
|
||||
import { useAdmin } from "@/contexts/admin-context"
|
||||
|
||||
|
||||
/**
|
||||
* 系统配置
|
||||
* 显示系统配置的详细信息和编辑面板
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <SystemConfigDetailPanel
|
||||
* config={config}
|
||||
* editData={editData}
|
||||
* onEditDataChange={onEditDataChange}
|
||||
* onSave={onSave}
|
||||
* saving={saving}
|
||||
* />
|
||||
* ```
|
||||
* @param {SystemConfig} config - 系统配置
|
||||
* @param {Partial<SystemConfig>} editData - 编辑数据
|
||||
* @param {function} onEditDataChange - 编辑数据改变回调
|
||||
* @param {function} onSave - 保存回调
|
||||
* @param {boolean} saving - 是否正在保存
|
||||
* @returns {React.ReactNode} 系统配置详情面板组件
|
||||
*/
|
||||
function SystemConfigDetailPanel({
|
||||
config,
|
||||
editData,
|
||||
onEditDataChange,
|
||||
onSave,
|
||||
saving
|
||||
}: {
|
||||
config: SystemConfig | null
|
||||
editData: Partial<SystemConfig>
|
||||
onEditDataChange: (field: keyof SystemConfig, value: SystemConfig[keyof SystemConfig]) => void
|
||||
onSave: () => void
|
||||
saving: boolean
|
||||
}) {
|
||||
|
||||
return (
|
||||
<ManageDetailPanel
|
||||
isEmpty={!config}
|
||||
onSave={onSave}
|
||||
saving={saving}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-0">
|
||||
<div className="border border-dashed rounded-lg">
|
||||
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
|
||||
<label className="text-xs font-medium text-muted-foreground">配置键</label>
|
||||
<p className="text-xs text-muted-foreground font-mono">{config?.key}</p>
|
||||
</div>
|
||||
|
||||
<div className="pl-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
|
||||
<label className="text-xs font-medium text-muted-foreground">配置值</label>
|
||||
<div className="flex gap-1 w-[90%] justify-end items-center pr-3">
|
||||
{config?.key.endsWith('_enabled') ? (
|
||||
<Switch
|
||||
checked={editData.value !== undefined ? editData.value === 'true' : (config?.value === 'true')}
|
||||
onCheckedChange={(checked) => {
|
||||
onEditDataChange('value', checked ? 'true' : 'false')
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
type="number"
|
||||
step="1"
|
||||
min="0"
|
||||
value={editData.value !== undefined ? editData.value : (config?.value || '')}
|
||||
placeholder={editData.value === undefined && !config?.value ? '必需' : ''}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
if (value === '') {
|
||||
onEditDataChange('value', '')
|
||||
return
|
||||
}
|
||||
onEditDataChange('value', value)
|
||||
}}
|
||||
className="!text-[12px] text-right h-4 rounded-none border-none shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:!text-[12px] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-inner-spin-button]"
|
||||
style={{
|
||||
MozAppearance: 'textfield'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pl-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
|
||||
<label className="text-xs font-medium text-muted-foreground">配置描述</label>
|
||||
<div className="flex gap-1 w-[90%]">
|
||||
<Input
|
||||
type="text"
|
||||
value={editData.description !== undefined ? editData.description : (config?.description || '')}
|
||||
placeholder="可选描述"
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
onEditDataChange('description', value)
|
||||
}}
|
||||
className="!text-[12px] text-right h-4 rounded-none border-none shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:!text-[12px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-3 py-2 flex items-center justify-between">
|
||||
<label className="text-xs font-medium text-muted-foreground">创建时间</label>
|
||||
<p className="text-xs text-muted-foreground">{config ? formatDateTime(config.created_at) : ''}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ManageDetailPanel>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 系统配置管理组件
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <SystemConfigs />
|
||||
* ```
|
||||
* @returns {React.ReactNode} 系统配置管理组件
|
||||
*/
|
||||
export function SystemConfigs() {
|
||||
const {
|
||||
systemConfigs: configs,
|
||||
systemConfigsLoading: loading,
|
||||
systemConfigsError: error,
|
||||
refetchSystemConfigs,
|
||||
updateSystemConfig,
|
||||
deleteSystemConfig
|
||||
} = useAdmin()
|
||||
|
||||
const getInitialEditData = (config: SystemConfig) => ({
|
||||
value: config.value,
|
||||
description: config.description
|
||||
})
|
||||
|
||||
const handleSave = async (config: SystemConfig, editData: Partial<SystemConfig>) => {
|
||||
if (!config) return
|
||||
|
||||
await updateSystemConfig(config.key, {
|
||||
value: editData.value ?? config.value,
|
||||
description: editData.description ?? config.description
|
||||
})
|
||||
await refetchSystemConfigs()
|
||||
}
|
||||
|
||||
const handleDelete = async (config: SystemConfig) => {
|
||||
await deleteSystemConfig(config.key)
|
||||
}
|
||||
|
||||
return (
|
||||
<ManagePage<SystemConfig>
|
||||
title="系统配置"
|
||||
data={configs}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onReload={refetchSystemConfigs}
|
||||
getInitialEditData={getInitialEditData}
|
||||
onSave={handleSave}
|
||||
onDelete={handleDelete}
|
||||
getId={(config) => config.key}
|
||||
emptyDescription="未发现系统配置"
|
||||
loadingDescription="配置加载中"
|
||||
columns={[
|
||||
{ header: "配置键", cell: (item) => <span className="font-mono font-medium">{item.key}</span>, width: "200px" },
|
||||
{ header: "配置值", cell: (item) => <span className="truncate max-w-[120px] inline-block" title={item.value}>{item.value}</span>, width: "120px" },
|
||||
{ header: "描述", cell: (item) => <span className="truncate max-w-[200px] inline-block text-muted-foreground" title={item.description}>{item.description}</span>, width: "200px" },
|
||||
]}
|
||||
renderDetail={({ selected, hovered, editData, onEditDataChange, onSave, saving }) => (
|
||||
<SystemConfigDetailPanel
|
||||
config={selected || hovered}
|
||||
editData={editData}
|
||||
onEditDataChange={onEditDataChange}
|
||||
onSave={onSave}
|
||||
saving={saving}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Spinner } from "@/components/ui/spinner"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Layers, Play, Clock, Info } from "lucide-react"
|
||||
|
||||
import { TaskMeta, AdminService, DispatchTaskRequest } from "@/lib/services"
|
||||
import { ErrorInline } from "@/components/layout/error"
|
||||
import { LoadingStateWithBorder } from "@/components/layout/loading"
|
||||
import { EmptyStateWithBorder } from "@/components/layout/empty"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
import { format } from "date-fns"
|
||||
import { zhCN } from "date-fns/locale"
|
||||
import { Calendar as CalendarIcon } from "lucide-react"
|
||||
import { Calendar } from "@/components/ui/calendar"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover"
|
||||
|
||||
const TASK_CONFIGS: Record<string, { icon: React.ComponentType<{ className?: string }>, color: string, gradient: string }> = {
|
||||
'order_sync': {
|
||||
icon: Layers,
|
||||
color: "text-blue-600 dark:text-blue-400",
|
||||
gradient: "from-blue-500/10 via-blue-500/5 to-transparent border-blue-200/50 dark:border-blue-800/50 hover:border-blue-400 dark:hover:border-blue-500",
|
||||
},
|
||||
'user_gamification': {
|
||||
icon: Play,
|
||||
color: "text-amber-600 dark:text-amber-400",
|
||||
gradient: "from-amber-500/10 via-amber-500/5 to-transparent border-amber-200/50 dark:border-amber-800/50 hover:border-amber-400 dark:hover:border-amber-500",
|
||||
},
|
||||
'dispute_auto_refund': {
|
||||
icon: Clock,
|
||||
color: "text-rose-600 dark:text-rose-400",
|
||||
gradient: "from-rose-500/10 via-rose-500/5 to-transparent border-rose-200/50 dark:border-rose-800/50 hover:border-rose-400 dark:hover:border-rose-500",
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_TASK_CONFIG = {
|
||||
icon: Layers,
|
||||
color: "text-zinc-600 dark:text-zinc-400",
|
||||
gradient: "from-zinc-500/10 via-zinc-500/5 to-transparent border-zinc-200/50 dark:border-zinc-800/50 hover:border-zinc-400 dark:hover:border-zinc-500",
|
||||
}
|
||||
|
||||
function DatePickerWithTime({ date, setDate }: { date: Date | undefined, setDate: (date: Date | undefined) => void }) {
|
||||
const timeString = date ? format(date, "HH:mm:ss") : ""
|
||||
|
||||
const handleDateSelect = (newDate: Date | undefined) => {
|
||||
if (!newDate) {
|
||||
setDate(undefined)
|
||||
return
|
||||
}
|
||||
|
||||
const updated = new Date(newDate)
|
||||
|
||||
if (date) {
|
||||
updated.setHours(date.getHours())
|
||||
updated.setMinutes(date.getMinutes())
|
||||
updated.setSeconds(date.getSeconds())
|
||||
} else {
|
||||
updated.setHours(0, 0, 0, 0)
|
||||
}
|
||||
|
||||
setDate(updated)
|
||||
}
|
||||
|
||||
const handleTimeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newTime = e.target.value
|
||||
if (!newTime) return
|
||||
|
||||
if (date) {
|
||||
const [hours, minutes, seconds] = newTime.split(':').map(Number)
|
||||
const updated = new Date(date)
|
||||
updated.setHours(hours || 0)
|
||||
updated.setMinutes(minutes || 0)
|
||||
updated.setSeconds(seconds || 0)
|
||||
setDate(updated)
|
||||
} else {
|
||||
const today = new Date()
|
||||
const [hours, minutes, seconds] = newTime.split(':').map(Number)
|
||||
today.setHours(hours || 0)
|
||||
today.setMinutes(minutes || 0)
|
||||
today.setSeconds(seconds || 0)
|
||||
setDate(today)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
className={cn(
|
||||
"w-full justify-start text-left font-normal text-xs h-8",
|
||||
!date && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-1 size-3" />
|
||||
{date ? format(date, "yyyy-MM-dd") : <span>选择日期</span>}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={date}
|
||||
onSelect={handleDateSelect}
|
||||
locale={zhCN}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<div className="w-[120px]">
|
||||
<Input
|
||||
type="time"
|
||||
step="1"
|
||||
value={timeString}
|
||||
onChange={handleTimeChange}
|
||||
className="text-xs font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function TaskManager() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
const [taskTypes, setTaskTypes] = useState<TaskMeta[]>([])
|
||||
|
||||
const [dispatching, setDispatching] = useState(false)
|
||||
const [selectedTaskType, setSelectedTaskType] = useState<string | null>(null)
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
|
||||
const [startTime, setStartTime] = useState<Date | undefined>(undefined)
|
||||
const [endTime, setEndTime] = useState<Date | undefined>(undefined)
|
||||
const [userId, setUserId] = useState("")
|
||||
|
||||
const fetchTaskTypes = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const data = await AdminService.getTaskTypes()
|
||||
setTaskTypes(data)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('加载任务类型失败'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchTaskTypes()
|
||||
}, [fetchTaskTypes])
|
||||
|
||||
const handleDispatch = async () => {
|
||||
if (!selectedTaskType) return
|
||||
|
||||
try {
|
||||
setDispatching(true)
|
||||
|
||||
const targetTask = taskTypes.find(t => t.type === selectedTaskType)
|
||||
|
||||
const params: DispatchTaskRequest = {
|
||||
task_type: selectedTaskType
|
||||
}
|
||||
|
||||
if (targetTask?.type === 'order_sync') {
|
||||
if (startTime) params.start_time = startTime.toISOString()
|
||||
if (endTime) params.end_time = endTime.toISOString()
|
||||
}
|
||||
|
||||
if (targetTask?.type === 'user_gamification') {
|
||||
if (userId) params.user_id = userId
|
||||
}
|
||||
|
||||
await AdminService.dispatchTask(params)
|
||||
|
||||
toast.success('任务下发成功', {
|
||||
description: `已成功将任务 ${ targetTask?.name || selectedTaskType } 加入队列`
|
||||
})
|
||||
setDialogOpen(false)
|
||||
|
||||
setStartTime(undefined)
|
||||
setEndTime(undefined)
|
||||
setUserId("")
|
||||
} catch (err) {
|
||||
toast.error('任务下发失败', {
|
||||
description: err instanceof Error ? err.message : '未知错误'
|
||||
})
|
||||
} finally {
|
||||
setDispatching(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openDispatchDialog = (type: string) => {
|
||||
setSelectedTaskType(type)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const getSelectedTaskMeta = () => {
|
||||
return taskTypes.find(t => t.type === selectedTaskType)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-6 space-y-6">
|
||||
<div className="flex items-center justify-between border-b border-border pb-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-2xl font-semibold tracking-tight">任务管理</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{error ? (
|
||||
<div className="p-8 border border-dashed rounded-xl bg-card">
|
||||
<ErrorInline error={error} onRetry={fetchTaskTypes} className="justify-center" />
|
||||
</div>
|
||||
) : loading && taskTypes.length === 0 ? (
|
||||
<LoadingStateWithBorder icon={Layers} description="加载任务类型中..." />
|
||||
) : taskTypes.length === 0 ? (
|
||||
<EmptyStateWithBorder icon={Layers} description="暂无可用任务类型" />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{taskTypes.map((task, index) => {
|
||||
const config = TASK_CONFIGS[task.type] || DEFAULT_TASK_CONFIG
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${ task.type }-${ index }`}
|
||||
className={cn(
|
||||
"relative group overflow-hidden rounded-xl border bg-gradient-to-br transition-all duration-500",
|
||||
config.gradient
|
||||
)}
|
||||
>
|
||||
<div className="relative h-full bg-card/40 backdrop-blur-sm p-4 flex flex-col justify-between hover:bg-card/0 transition-colors duration-500">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-semibold text-base tracking-tight">{task.name}</h3>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-2 min-h-[36px]">
|
||||
{task.description}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="secondary" className="font-mono text-[10px] bg-background/50 backdrop-blur-md border px-1.5 h-5">
|
||||
{task.queue}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<Badge variant="outline" className="text-[9px] h-4.5 bg-background/50 font-mono text-muted-foreground border-border/50 px-1">
|
||||
类型:{task.type}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-[9px] h-4.5 bg-background/50 font-mono text-muted-foreground border-border/50 px-1">
|
||||
重试:{task.max_retry}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 mt-1">
|
||||
<Button
|
||||
className="w-full h-7 text-xs"
|
||||
variant="secondary"
|
||||
onClick={() => openDispatchDialog(task.type)}
|
||||
>
|
||||
<Play className="size-3 mr-1" />
|
||||
立即执行
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>下发任务</DialogTitle>
|
||||
<DialogDescription>
|
||||
配置任务参数并启动,任务将在后台异步执行。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>执行任务</Label>
|
||||
<div className="flex items-center gap-2 p-2 rounded-lg bg-muted/50">
|
||||
{(() => {
|
||||
const meta = getSelectedTaskMeta()
|
||||
if (!meta) return null
|
||||
const config = TASK_CONFIGS[meta.type] || DEFAULT_TASK_CONFIG
|
||||
const Icon = config.icon
|
||||
return (
|
||||
<>
|
||||
<div className={cn("p-2", config.color)}>
|
||||
<Icon className="size-4" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs font-medium">{meta.name}</span>
|
||||
<span className="text-[10px] text-muted-foreground font-mono">{meta.type}</span>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedTaskType === 'order_sync' && (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label>开始时间</Label>
|
||||
<DatePickerWithTime date={startTime} setDate={setStartTime} />
|
||||
<p className="text-xs text-muted-foreground">选择同步的起始时间点</p>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>结束时间</Label>
|
||||
<DatePickerWithTime date={endTime} setDate={setEndTime} />
|
||||
<p className="text-xs text-muted-foreground">选择同步的结束时间点</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedTaskType === 'user_gamification' && (
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="user-id">用户 ID</Label>
|
||||
<Input
|
||||
id="user-id"
|
||||
type="number"
|
||||
placeholder="e.g. 10086"
|
||||
className="font-mono text-xs"
|
||||
value={userId}
|
||||
onChange={(e) => setUserId(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">指定需要更新积分数据的用户 ID</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!['order_sync', 'user_gamification'].includes(selectedTaskType || '') && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground bg-muted/50 p-3 rounded-md border border-dashed">
|
||||
<Info className="h-4 w-4" />
|
||||
<span>此任务无需额外配置参数,确认后将直接下发。</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setDialogOpen(false)} disabled={dispatching} className="h-8 text-xs">
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleDispatch} disabled={dispatching} className="h-8 text-xs">
|
||||
{dispatching ? <Spinner className="size-3" /> : <Play className="size-3" />}
|
||||
{dispatching ? '下发中' : '启动'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,598 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Sheet, SheetTitle, SheetContent } from "@/components/ui/sheet"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { Layers, Search, UserX, UserCheck, Eye, Wallet, CreditCard, ShieldCheck, Filter, X, ChevronDown, ChevronLeft, ChevronRight, Users, Loader2 } from "lucide-react"
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
|
||||
import { AdminUser, AdminService, DispatchTaskRequest } from "@/lib/services"
|
||||
import { toast } from "sonner"
|
||||
import { formatDateTime } from "@/lib/utils"
|
||||
import { EmptyStateWithBorder } from "@/components/layout/empty"
|
||||
import { LoadingStateWithBorder } from "@/components/layout/loading"
|
||||
import { ErrorInline } from "@/components/layout/error"
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useAdminUsers } from "@/contexts/admin-users-context"
|
||||
|
||||
export function UsersManager() {
|
||||
const {
|
||||
users,
|
||||
total,
|
||||
loading,
|
||||
error,
|
||||
page,
|
||||
pageSize,
|
||||
searchUserId,
|
||||
searchUsername,
|
||||
statusFilter,
|
||||
setPage,
|
||||
setPageSize,
|
||||
setSearchUserId,
|
||||
setSearchUsername,
|
||||
setStatusFilter,
|
||||
fetchUsers,
|
||||
updateUserStatus
|
||||
} = useAdminUsers()
|
||||
|
||||
const [selectedUser, setSelectedUser] = useState<AdminUser | null>(null)
|
||||
const [detailOpen, setDetailOpen] = useState(false)
|
||||
const [updatingUserId, setUpdatingUserId] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers()
|
||||
}, [fetchUsers])
|
||||
|
||||
const handleStatusToggle = async (user: AdminUser) => {
|
||||
await updateUserStatus(user)
|
||||
|
||||
if (selectedUser?.id === user.id) {
|
||||
setSelectedUser(prev => prev ? { ...prev, is_active: !prev.is_active } : null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleShowDetail = (user: AdminUser) => {
|
||||
setSelectedUser(user)
|
||||
setDetailOpen(true)
|
||||
}
|
||||
|
||||
const handleUpdateCredits = async (user: AdminUser) => {
|
||||
try {
|
||||
setUpdatingUserId(user.id)
|
||||
const params: DispatchTaskRequest = {
|
||||
task_type: 'user_gamification',
|
||||
user_id: user.id
|
||||
}
|
||||
await AdminService.dispatchTask(params)
|
||||
toast.success('积分更新任务已下发', {
|
||||
description: `正在更新用户 ${ user.username } 的积分数据`
|
||||
})
|
||||
} catch (err) {
|
||||
toast.error('任务下发失败', {
|
||||
description: err instanceof Error ? err.message : '未知错误'
|
||||
})
|
||||
} finally {
|
||||
setUpdatingUserId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize)
|
||||
const hasSearchFilter = Boolean(searchUserId || searchUsername)
|
||||
|
||||
const renderFilterBar = () => (
|
||||
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-3">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-5 border-dashed text-[10px] font-medium shadow-none focus-visible:ring-0",
|
||||
hasSearchFilter && "bg-primary/5 border-primary/20"
|
||||
)}
|
||||
>
|
||||
<Search className="size-3 mr-1" />
|
||||
搜索
|
||||
{hasSearchFilter && (
|
||||
<>
|
||||
<Separator orientation="vertical" className="mx-1" />
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[10px] h-3 px-1 rounded-full bg-primary text-primary-foreground"
|
||||
>
|
||||
!
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56 p-3" align="start">
|
||||
<div className="space-y-2.5">
|
||||
<input
|
||||
className="w-full h-7 px-2 text-xs border border-dashed rounded-md outline-none focus:border-primary bg-background"
|
||||
placeholder="输入用户 ID..."
|
||||
value={searchUserId}
|
||||
onChange={(e) => setSearchUserId(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="w-full h-7 px-2 text-xs border border-dashed rounded-md outline-none focus:border-primary bg-background"
|
||||
placeholder="输入 username..."
|
||||
value={searchUsername}
|
||||
onChange={(e) => setSearchUsername(e.target.value)}
|
||||
/>
|
||||
{hasSearchFilter && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full h-6 text-xs"
|
||||
onClick={() => {
|
||||
setSearchUserId("")
|
||||
setSearchUsername("")
|
||||
}}
|
||||
>
|
||||
清除
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-5 border-dashed text-[10px] font-medium shadow-none focus-visible:ring-0",
|
||||
statusFilter !== 'all' && "bg-primary/5 border-primary/20"
|
||||
)}
|
||||
>
|
||||
<Filter className="size-3" />
|
||||
状态
|
||||
{statusFilter !== 'all' && (
|
||||
<>
|
||||
<Separator orientation="vertical" className="mx-1" />
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[10px] h-3 px-1 rounded-full bg-primary text-primary-foreground"
|
||||
>
|
||||
1
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-[120px]" align="start">
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => { e.preventDefault(); setStatusFilter('all') }}
|
||||
>
|
||||
<div className={cn(
|
||||
"mr-2 flex size-3 items-center justify-center rounded-sm border border-primary",
|
||||
statusFilter === 'all'
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "opacity-50"
|
||||
)} />
|
||||
<span className="text-xs">全部状态</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => { e.preventDefault(); setStatusFilter('active') }}
|
||||
>
|
||||
<div className={cn(
|
||||
"mr-2 flex size-3 items-center justify-center rounded-sm border border-primary",
|
||||
statusFilter === 'active'
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "opacity-50"
|
||||
)} />
|
||||
<span className="text-xs">正常</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => { e.preventDefault(); setStatusFilter('inactive') }}
|
||||
>
|
||||
<div className={cn(
|
||||
"mr-2 flex size-3 items-center justify-center rounded-sm border border-primary",
|
||||
statusFilter === 'inactive'
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "opacity-50"
|
||||
)} />
|
||||
<span className="text-xs">禁用</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{(hasSearchFilter || statusFilter !== 'all') && (
|
||||
<>
|
||||
<Separator orientation="vertical" className="h-6 hidden sm:block" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSearchUserId("")
|
||||
setSearchUsername("")
|
||||
setStatusFilter('all')
|
||||
}}
|
||||
className="h-5 px-2 lg:px-3 text-[11px] font-medium text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="size-3" />
|
||||
清空筛选
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator className="lg:hidden" />
|
||||
|
||||
<div className="flex items-center gap-1.5 self-end lg:self-auto">
|
||||
<span className="text-[10px] text-muted-foreground whitespace-nowrap">
|
||||
{total} 条记录
|
||||
</span>
|
||||
<div className="flex items-center border border-dashed rounded-md shadow-none">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5.5 w-6 rounded-none rounded-l-md disabled:opacity-30"
|
||||
onClick={() => setPage(Math.max(1, page - 1))}
|
||||
disabled={page <= 1 || loading}
|
||||
>
|
||||
<ChevronLeft className="size-3" />
|
||||
</Button>
|
||||
<span className="text-[10px] font-mono text-muted-foreground px-2 border-x border-dashed">
|
||||
{page}/{totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5.5 w-6 rounded-none rounded-r-md disabled:opacity-30"
|
||||
onClick={() => setPage(Math.min(totalPages, page + 1))}
|
||||
disabled={page >= totalPages || loading}
|
||||
>
|
||||
<ChevronRight className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-6 border-dashed text-[10px] px-2 font-mono shadow-none" disabled={loading}>
|
||||
{pageSize}条/页
|
||||
<ChevronDown className="size-3 opacity-50" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{[20, 50, 100].map(size => (
|
||||
<DropdownMenuItem
|
||||
key={size}
|
||||
onClick={() => setPageSize(size)}
|
||||
className={cn("font-mono text-xs", pageSize === size && "bg-accent")}
|
||||
>
|
||||
{size}条/页
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-6 w-6 border-dashed shadow-none"
|
||||
onClick={() => fetchUsers(true)}
|
||||
disabled={loading}
|
||||
title="刷新数据"
|
||||
>
|
||||
<Loader2 className={cn("size-3", loading && "animate-spin")} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="py-6 space-y-4">
|
||||
<div className="flex items-center justify-between border-b border-border pb-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-2xl font-semibold">用户管理</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{renderFilterBar()}
|
||||
|
||||
{error ? (
|
||||
<div className="p-8 border border-dashed rounded-lg">
|
||||
<ErrorInline error={error} onRetry={() => fetchUsers(true)} className="justify-center" />
|
||||
</div>
|
||||
) : loading && users.length === 0 ? (
|
||||
<LoadingStateWithBorder icon={Layers} description="加载用户列表中..." />
|
||||
) : users.length === 0 ? (
|
||||
<EmptyStateWithBorder icon={UserX} description="暂无用户数据" />
|
||||
) : (
|
||||
<div className="border border-dashed shadow-none rounded-lg overflow-hidden">
|
||||
<Table className="w-full caption-bottom text-sm min-w-full">
|
||||
<TableHeader className="sticky top-0 z-20 bg-background">
|
||||
<TableRow className="border-b border-dashed hover:bg-transparent">
|
||||
<TableHead className="w-[90px] whitespace-nowrap py-2 h-8">ID</TableHead>
|
||||
<TableHead className="w-[120px] whitespace-nowrap py-2 h-8">用户</TableHead>
|
||||
<TableHead className="text-right whitespace-nowrap min-w-[80px] py-2 h-8 font-mono">余额</TableHead>
|
||||
<TableHead className="text-right whitespace-nowrap min-w-[80px] py-2 h-8 font-mono">基准分</TableHead>
|
||||
<TableHead className="text-right whitespace-nowrap min-w-[80px] py-2 h-8 font-mono">支付分</TableHead>
|
||||
<TableHead className="text-right whitespace-nowrap min-w-[80px] py-2 h-8 font-mono">总收益</TableHead>
|
||||
<TableHead className="text-right whitespace-nowrap min-w-[80px] py-2 h-8 font-mono">总消耗</TableHead>
|
||||
<TableHead className="text-right whitespace-nowrap min-w-[80px] py-2 h-8 font-mono is-last-money">总划转</TableHead>
|
||||
<TableHead className="whitespace-nowrap min-w-[140px] py-2 h-8 pl-4">上次登陆</TableHead>
|
||||
<TableHead className="whitespace-nowrap min-w-[140px] py-2 h-8">注册时间</TableHead>
|
||||
<TableHead className="whitespace-nowrap min-w-[140px] py-2 h-8">上次更新</TableHead>
|
||||
<TableHead className="sticky right-0 text-center bg-background z-10 w-[80px] py-2 h-8">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.map((user) => (
|
||||
<TableRow
|
||||
key={user.id}
|
||||
className="border-dashed hover:bg-muted/30 cursor-pointer group"
|
||||
onClick={() => handleShowDetail(user)}
|
||||
>
|
||||
<TableCell className="font-mono text-[11px] text-muted-foreground py-1">{user.id}</TableCell>
|
||||
<TableCell className="py-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar className="h-7 w-7 rounded-sm border">
|
||||
<AvatarImage src={user.avatar_url} />
|
||||
<AvatarFallback className="rounded-sm text-[10px]">
|
||||
{user.username.substring(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col gap-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-medium text-[11px] leading-tight max-w-[100px] truncate" title={user.nickname}>{user.nickname}</span>
|
||||
{user.is_admin && (
|
||||
<Badge variant="secondary" className="text-[9px] h-3.5 px-0.5 rounded-[2px] font-normal leading-none tracking-tighter">
|
||||
ADM
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[10px] text-muted-foreground font-mono leading-tight">@{user.username}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-[11px] text-green-600 dark:text-green-500 font-medium py-1">
|
||||
{Number(user.available_balance).toFixed(2)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-[11px] py-1">
|
||||
{Number(user.community_balance).toFixed(2)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-[11px] py-1">
|
||||
{Number(user.pay_score).toFixed(2)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-[11px] text-green-600 py-1">
|
||||
{Number(user.total_receive).toFixed(2)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-[11px] text-red-500 py-1">
|
||||
{Number(user.total_payment).toFixed(2)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-[11px] text-purple-500 py-1 is-last-money">
|
||||
{Number(user.total_community).toFixed(2)}
|
||||
</TableCell>
|
||||
<TableCell className="text-[10px] text-muted-foreground font-mono whitespace-nowrap py-1 pl-4">
|
||||
{formatDateTime(user.last_login_at)}
|
||||
</TableCell>
|
||||
<TableCell className="text-[10px] text-muted-foreground font-mono whitespace-nowrap py-1">
|
||||
{formatDateTime(user.created_at)}
|
||||
</TableCell>
|
||||
<TableCell className="text-[10px] text-muted-foreground font-mono whitespace-nowrap py-1">
|
||||
{formatDateTime(user.updated_at)}
|
||||
</TableCell>
|
||||
<TableCell className="sticky right-0 text-center bg-background z-10 py-1" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-center gap-0.5">
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<Switch
|
||||
checked={user.is_active}
|
||||
onCheckedChange={() => handleStatusToggle(user)}
|
||||
disabled={user.is_admin}
|
||||
className="scale-75 data-[state=checked]:bg-green-600 h-4 w-7"
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
{user.is_admin ? '管理员账户' : user.is_active ? '禁用账户' : '启用账户'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => handleUpdateCredits(user)}
|
||||
disabled={updatingUserId === user.id}
|
||||
>
|
||||
<Loader2 className={cn("size-3", updatingUserId === user.id && "animate-spin")} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
更新积分
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 text-muted-foreground hover:text-foreground" onClick={() => handleShowDetail(user)}>
|
||||
<Eye className="size-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
查看详情
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Sheet open={detailOpen} onOpenChange={setDetailOpen}>
|
||||
<SheetContent className="sm:max-w-[400px] w-full p-0 flex flex-col gap-0">
|
||||
<SheetTitle className="px-5 py-3">用户档案</SheetTitle>
|
||||
|
||||
{selectedUser && (
|
||||
<>
|
||||
<div className="flex-1 overflow-y-auto scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent">
|
||||
<div className="flex flex-col pb-6">
|
||||
<div className="px-5 py-6 border-b border-border/50">
|
||||
<div className="flex flex-col items-center text-center gap-3">
|
||||
<Avatar className="h-20 w-20 rounded-full border-4 border-background ring-1 ring-border/20">
|
||||
<AvatarImage src={selectedUser.avatar_url} />
|
||||
<AvatarFallback className="rounded-full text-xl font-medium bg-secondary text-secondary-foreground">
|
||||
{selectedUser.username.substring(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<h3 className="text-lg font-bold tracking-tight">{selectedUser.nickname}</h3>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<code className="px-1.5 py-0.5 rounded-md bg-muted text-[10px] font-mono text-muted-foreground">@{selectedUser.username}</code>
|
||||
<Badge variant="secondary" className="h-4.5 px-1.5 text-[9px] uppercase font-medium">
|
||||
UID: {selectedUser.id}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="h-4.5 px-1.5 text-[9px] uppercase font-medium">
|
||||
Lv.{selectedUser.trust_level}
|
||||
</Badge>
|
||||
{selectedUser.is_admin && (
|
||||
<Badge className="h-4.5 px-1.5 text-[9px] uppercase font-medium bg-primary text-primary-foreground">
|
||||
Admin
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="gap-4 w-full max-w-[240px] mt-1 pt-4 border-t border-border/50">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-[9px] uppercase tracking-widest text-muted-foreground font-medium">注册时间</span>
|
||||
<span className="font-mono text-xs font-semibold">{formatDateTime(selectedUser.created_at).split(' ')[0]}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-4 px-1">核心资产</h4>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="p-3 rounded-xl border bg-card/50 hover:bg-card/80 transition-colors space-y-2">
|
||||
<div className="flex flex-col gap-1 text-muted-foreground">
|
||||
<Wallet className="w-4 h-4" />
|
||||
<span className="text-[10px] font-medium">可用余额</span>
|
||||
</div>
|
||||
<div className="text-lg font-mono font-bold tracking-tight text-foreground truncate">
|
||||
{Number(selectedUser.available_balance).toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-xl border bg-card/50 hover:bg-card/80 transition-colors space-y-2">
|
||||
<div className="flex flex-col gap-1 text-muted-foreground">
|
||||
<CreditCard className="w-4 h-4" />
|
||||
<span className="text-[10px] font-medium">支付积分</span>
|
||||
</div>
|
||||
<div className="text-lg font-mono font-bold tracking-tight text-foreground truncate">
|
||||
{Number(selectedUser.pay_score).toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-xl border bg-card/50 hover:bg-card/80 transition-colors space-y-2">
|
||||
<div className="flex flex-col gap-1 text-muted-foreground">
|
||||
<Users className="w-4 h-4" />
|
||||
<span className="text-[10px] font-medium">社区余额</span>
|
||||
</div>
|
||||
<div className="text-lg font-mono font-bold tracking-tight text-foreground truncate">
|
||||
{Number(selectedUser.community_balance).toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="mx-6 w-auto opacity-50" />
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider px-1">财务概况</h4>
|
||||
<div className="rounded-lg border divide-y bg-background/50">
|
||||
<div className="flex items-center justify-between p-3.5">
|
||||
<span className="text-[10px]">累计收益</span>
|
||||
<span className="font-mono text-[10px] font-medium text-purple-600 dark:text-purple-400">+{Number(selectedUser.total_community).toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3.5">
|
||||
<span className="text-[10px]">累计收入</span>
|
||||
<span className="font-mono text-[10px] font-medium text-green-600">+{Number(selectedUser.total_receive).toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3.5">
|
||||
<span className="text-[10px]">累计支出</span>
|
||||
<span className="font-mono text-[10px] font-medium text-red-500">{Number(selectedUser.total_payment) === 0 ? '0.00' : `-${ Number(selectedUser.total_payment).toFixed(2) }`}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider px-1">系统记录</h4>
|
||||
<div className="rounded-lg border divide-y bg-background/50">
|
||||
<div className="flex items-center justify-between p-3.5 text-sm">
|
||||
<span className="text-[10px]">最后登录</span>
|
||||
<span className="font-mono text-[10px]">{formatDateTime(selectedUser.last_login_at)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3.5 text-sm">
|
||||
<span className="text-[10px]">注册时间</span>
|
||||
<span className="font-mono text-[10px]">{formatDateTime(selectedUser.created_at)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3.5 text-sm">
|
||||
<span className="text-[10px]">最后更新</span>
|
||||
<span className="font-mono text-[10px]">{formatDateTime(selectedUser.updated_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!selectedUser.is_admin && (
|
||||
<div className="p-4 border-t bg-background/80 backdrop-blur-md shrink-0">
|
||||
<Button
|
||||
variant={selectedUser.is_active ? "destructive" : "default"}
|
||||
className={cn(
|
||||
"w-full h-9 text-xs font-medium transition-all active:scale-[0.98]",
|
||||
selectedUser.is_active
|
||||
? "bg-red-500 hover:bg-red-600 text-white"
|
||||
: "bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
)}
|
||||
onClick={() => handleStatusToggle(selectedUser)}
|
||||
>
|
||||
{selectedUser.is_active ? (
|
||||
<>
|
||||
<ShieldCheck className="size-3 mr-1" />
|
||||
封禁账户
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UserCheck className="size-3 mr-1" />
|
||||
解除封禁
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { CountingNumber } from "@/components/animate-ui/primitives/texts/counting-number"
|
||||
import { BalanceReport } from "@/components/common/balance/balance-report"
|
||||
import { BalanceSummary } from "@/components/common/balance/balance-summary"
|
||||
import { BalanceTable } from "@/components/common/balance/balance-table"
|
||||
import { useUser } from "@/contexts/user-context"
|
||||
|
||||
/**
|
||||
* 积分余额主页面组件
|
||||
*
|
||||
* 负责组装积分余额页面的各个子组件,包括积分余额总览、积分余额摘要、近期活动和报告侧边栏
|
||||
*/
|
||||
export function BalanceMain() {
|
||||
const { user, loading } = useUser()
|
||||
|
||||
/** 计算总余额 */
|
||||
const totalBalance = React.useMemo(() => {
|
||||
return parseFloat(user?.available_balance || '0')
|
||||
}, [user?.available_balance])
|
||||
|
||||
return (
|
||||
<div className="py-6">
|
||||
<div className="flex items-center gap-2 pb-2">
|
||||
<h1 className="text-3xl">
|
||||
<span className="font-semibold">积分</span>
|
||||
<span className="pl-2">LDC</span>
|
||||
<span className="pl-2">
|
||||
{loading ? "-" : <CountingNumber number={totalBalance} decimalPlaces={2} />}
|
||||
</span>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-12 pt-4">
|
||||
<div className="lg:col-span-2 space-y-12">
|
||||
<section aria-labelledby="balance-summary-title">
|
||||
<h2 id="balance-summary-title" className="font-semibold mb-4">积分摘要</h2>
|
||||
<BalanceSummary />
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="recent-activity-title">
|
||||
<h2 id="recent-activity-title" className="font-semibold mb-2">近期活动</h2>
|
||||
<BalanceTable />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<aside className="lg:col-span-1" aria-labelledby="reports-title">
|
||||
<BalanceReport />
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import * as React from "react"
|
||||
|
||||
/**
|
||||
* 余额报告组件
|
||||
*
|
||||
* 显示可下载或查看的报告列表
|
||||
*/
|
||||
export function BalanceReport() {
|
||||
return (
|
||||
<section className="space-y-4" aria-labelledby="reports-title">
|
||||
<h2 id="reports-title" className="font-semibold">报告</h2>
|
||||
<div className="text-sm text-muted-foreground">暂无报告</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import * as React from "react"
|
||||
import { CountingNumber } from "@/components/animate-ui/primitives/texts/counting-number"
|
||||
import { useUser } from "@/contexts/user-context"
|
||||
|
||||
/** 颜色配置 - 统一管理主题色 */
|
||||
const COLORS = {
|
||||
available: "bg-primary/80",
|
||||
pending: "bg-zinc-400/80",
|
||||
emptyState: "bg-muted/50",
|
||||
} as const
|
||||
|
||||
/**
|
||||
* 计算积分余额百分比
|
||||
*/
|
||||
function calculatePercentages(available: number, total: number) {
|
||||
if (total <= 0) {
|
||||
return { available: 0, pending: 0 }
|
||||
}
|
||||
|
||||
const pending = total - available
|
||||
return {
|
||||
available: (available / total) * 100,
|
||||
pending: (pending / total) * 100,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全转换数字,避免 NaN
|
||||
*/
|
||||
function safeNumber(value: number): number {
|
||||
return typeof value === 'number' && !isNaN(value) ? value : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 积分余额摘要组件
|
||||
*
|
||||
* 显示积分余额的可视化摘要,包括可视化进度条和详细的积分余额分类列表
|
||||
*/
|
||||
export function BalanceSummary() {
|
||||
const { user, loading } = useUser()
|
||||
|
||||
const available = parseFloat(user?.available_balance || '0')
|
||||
const pending = parseFloat(user?.pending_balance || '0')
|
||||
const total = available + pending
|
||||
|
||||
const percentages = React.useMemo(
|
||||
() => calculatePercentages(available, total),
|
||||
[available, total]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
className={`w-full h-4 ${ COLORS.emptyState } rounded-sm overflow-hidden flex`}
|
||||
role="progressbar"
|
||||
aria-label="积分余额分布"
|
||||
aria-valuenow={percentages.available}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
>
|
||||
<div
|
||||
className={`${ COLORS.available } transition-all duration-300`}
|
||||
style={{ width: `${ percentages.available }%` }}
|
||||
title={`可用: ${ percentages.available.toFixed(1) }%`}
|
||||
/>
|
||||
<div
|
||||
className={`${ COLORS.pending } transition-all duration-300`}
|
||||
style={{ width: `${ percentages.pending }%` }}
|
||||
title={`未来积分: ${ percentages.pending.toFixed(1) }%`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center text-xs font-medium pb-2 border-b border-border/80">
|
||||
<span>积分类型</span>
|
||||
<span className="text-muted-foreground">值</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center font-bold text-sm pb-2 border-b border-border/80">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`size-2.5 ${ COLORS.available } rounded-xs`} aria-hidden="true" />
|
||||
<span>可用</span>
|
||||
</div>
|
||||
<span className="font-semibold">
|
||||
{loading ? "-" : (
|
||||
<CountingNumber
|
||||
number={safeNumber(available)}
|
||||
decimalPlaces={2}
|
||||
initiallyStable={true}
|
||||
inView={true}
|
||||
inViewOnce={true}
|
||||
/>
|
||||
)} LDC
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center font-bold text-sm pb-2 border-b border-border/80">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`size-2.5 ${ COLORS.pending } rounded-xs`} aria-hidden="true" />
|
||||
<span>未来积分</span>
|
||||
</div>
|
||||
<span className="font-semibold">
|
||||
{loading ? "-" : (
|
||||
<CountingNumber
|
||||
number={safeNumber(pending)}
|
||||
decimalPlaces={2}
|
||||
initiallyStable={true}
|
||||
inView={true}
|
||||
inViewOnce={true}
|
||||
/>
|
||||
)} LDC
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { TransactionTableList } from "@/components/common/general/table-data"
|
||||
import { TransactionProvider, useTransaction } from "@/contexts/transaction-context"
|
||||
import { DEFAULT_ORDER_TYPES, type OrderType } from "@/lib/services"
|
||||
import { formatLocalDate } from "@/lib/utils"
|
||||
|
||||
/** 标签触发器样式 */
|
||||
const TAB_TRIGGER_STYLES =
|
||||
"data-[state=active]:bg-transparent " +
|
||||
"data-[state=active]:shadow-none " +
|
||||
"data-[state=active]:border-0 " +
|
||||
"data-[state=active]:border-b-2 " +
|
||||
"data-[state=active]:border-indigo-500 " +
|
||||
"bg-transparent " +
|
||||
"rounded-none " +
|
||||
"border-0 " +
|
||||
"border-b-2 " +
|
||||
"border-transparent " +
|
||||
"px-0 " +
|
||||
"text-sm " +
|
||||
"font-bold " +
|
||||
"text-muted-foreground " +
|
||||
"data-[state=active]:text-indigo-500 " +
|
||||
"-mb-[2px] " +
|
||||
"relative " +
|
||||
"hover:text-foreground " +
|
||||
"transition-colors " +
|
||||
"flex-none"
|
||||
|
||||
/** 标签配置 - 数据驱动渲染 */
|
||||
const TABS = [
|
||||
{ value: "receive" as const, label: "积分收益" },
|
||||
{ value: "payment" as const, label: "积分消耗" },
|
||||
{ value: "community" as const, label: "社区划转" },
|
||||
{ value: "online" as const, label: "在线流转" },
|
||||
{ value: "all" as const, label: "所有活动" },
|
||||
] as const
|
||||
|
||||
/**
|
||||
* 计算最近30天的时间范围
|
||||
* 每次调用时重新计算,避免缓存过期时间
|
||||
*/
|
||||
function getTimeRange() {
|
||||
const now = new Date()
|
||||
now.setHours(23, 59, 59, 999)
|
||||
const start = new Date(now)
|
||||
start.setDate(start.getDate() - 30)
|
||||
start.setHours(0, 0, 0, 0)
|
||||
return {
|
||||
startTime: formatLocalDate(start),
|
||||
endTime: formatLocalDate(now)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 积分余额活动表格组件
|
||||
*
|
||||
* 显示不同类型的积分余额活动记录,支持多标签切换和分页加载
|
||||
*/
|
||||
export function BalanceTable() {
|
||||
const [activeTab, setActiveTab] = React.useState<OrderType | "all">("all")
|
||||
const [timeRange, setTimeRange] = React.useState<{ startTime: string; endTime: string } | null>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
setTimeRange(getTimeRange())
|
||||
}, [])
|
||||
|
||||
if (!timeRange) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(value) => setActiveTab(value as OrderType | "all")}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="flex p-0 gap-4 rounded-none w-full bg-transparent justify-start border-b border-border overflow-x-auto overflow-y-hidden">
|
||||
{TABS.map((tab) => (
|
||||
<TabsTrigger
|
||||
key={tab.value}
|
||||
value={tab.value}
|
||||
className={TAB_TRIGGER_STYLES}
|
||||
>
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
<div className="mt-2">
|
||||
<TransactionProvider
|
||||
defaultParams={{
|
||||
page_size: 20,
|
||||
startTime: timeRange.startTime,
|
||||
endTime: timeRange.endTime,
|
||||
}}
|
||||
>
|
||||
<TransactionList
|
||||
type={activeTab === "all" ? undefined : activeTab}
|
||||
/>
|
||||
</TransactionProvider>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 积分余额活动列表组件
|
||||
*
|
||||
* 负责获取和显示积分余额活动数据
|
||||
*/
|
||||
const TransactionList = React.memo(function TransactionList({ type }: { type?: OrderType }) {
|
||||
const {
|
||||
transactions,
|
||||
loading,
|
||||
error,
|
||||
lastParams,
|
||||
fetchTransactions,
|
||||
} = useTransaction()
|
||||
|
||||
/** 当积分余额活动类型变化时重新加载数据 */
|
||||
React.useEffect(() => {
|
||||
fetchTransactions({
|
||||
page: 1,
|
||||
types: type ? [type] : DEFAULT_ORDER_TYPES,
|
||||
startTime: lastParams.startTime,
|
||||
endTime: lastParams.endTime,
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [type])
|
||||
|
||||
return (
|
||||
<TransactionTableList
|
||||
loading={loading}
|
||||
error={error}
|
||||
transactions={transactions}
|
||||
onRetry={() => fetchTransactions({
|
||||
page: 1,
|
||||
types: type ? [type] : DEFAULT_ORDER_TYPES,
|
||||
startTime: lastParams.startTime,
|
||||
endTime: lastParams.endTime,
|
||||
})}
|
||||
/>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,589 @@
|
||||
import { type PolicySection } from "./types"
|
||||
import { CodeBlock } from "@/components/ui/code-block"
|
||||
import {
|
||||
DocsTable,
|
||||
DocsTableHeader,
|
||||
DocsTableBody,
|
||||
DocsTableHead,
|
||||
DocsTableRow,
|
||||
DocsTableCell,
|
||||
} from "@/components/ui/docs-table"
|
||||
|
||||
export const DOCS_LAST_UPDATED = "2026-04-20"
|
||||
|
||||
/**
|
||||
* ------------------------------------------------------------------
|
||||
* API 文档
|
||||
* ------------------------------------------------------------------
|
||||
*/
|
||||
export const apiSections: PolicySection[] = [
|
||||
{
|
||||
value: "official-service",
|
||||
title: "1. 官方 LDC 接口",
|
||||
content: (
|
||||
<div className="space-y-4 text-sm leading-relaxed">
|
||||
<div className="bg-muted/50 border border-border/50 rounded-lg px-3 py-2 mb-6">
|
||||
<p className="text-muted-foreground m-0">官方原生接口,使用 Ed25519 签名算法,安全性更高</p>
|
||||
</div>
|
||||
|
||||
<h3 id="1-1-overview" className="text-base md:text-lg font-semibold text-foreground mt-6 md:mt-8 mb-3 md:mb-4">1.1 概览</h3>
|
||||
<ul className="list-disc pl-4 md:pl-5 space-y-2">
|
||||
<li><strong>协议:</strong>官方 LDC 支付协议</li>
|
||||
<li><strong>服务类型:</strong>支持 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">type=ldcpay</code></li>
|
||||
<li><strong>网关基址:</strong><code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">https://credit.linux.do/epay</code></li>
|
||||
<li><strong>签名方式:</strong>Ed25519 非对称加密</li>
|
||||
</ul>
|
||||
|
||||
<h3 id="1-2-flow" className="text-base md:text-lg font-semibold text-foreground mt-6 md:mt-8 mb-3 md:mb-4">1.2 对接流程</h3>
|
||||
<ol className="list-decimal pl-4 md:pl-5 space-y-2">
|
||||
<li>控制台创建应用,配置 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">client_id</code> 并在应用设置中上传商户 Ed25519 公钥</li>
|
||||
<li>根据“签名算法”及商户私钥生成 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">sign</code></li>
|
||||
<li>调用 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">/pay/submit</code> 发起积分流转请求</li>
|
||||
<li>认证完成后,通过异步回调或轮询接口同步状态</li>
|
||||
</ol>
|
||||
|
||||
<h3 id="1-3-auth-sign" className="text-base md:text-lg font-semibold text-foreground mt-6 md:mt-8 mb-3 md:mb-4">1.3 鉴权与签名</h3>
|
||||
<h4 className="font-medium text-foreground mt-3 md:mt-4 mb-2">1.3.1 签名算法</h4>
|
||||
<div className="space-y-4">
|
||||
<ol className="list-decimal pl-4 md:pl-5 space-y-2">
|
||||
<li>取除 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">sign</code> 以外的所有非空请求参数</li>
|
||||
<li>将参数按参数名 ASCII 码从到大排序(字典序)</li>
|
||||
<li>使用 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">k1=v1&k2=v2...</code> 格式拼接成字符串</li>
|
||||
<li>将 <strong>应用密钥 (Client Secret)</strong> 直接追加到字符串末尾</li>
|
||||
<li>使用商户私钥对最终字符串进行 <strong>Ed25519</strong> 签名</li>
|
||||
<li>将签名结果转换成 Base64 编码作为 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">sign</code> 参数</li>
|
||||
</ol>
|
||||
<CodeBlock
|
||||
code={`// 示例:client_id=1&money=10.00&order_name=Test&out_trade_no=M1&type=ldcpay{SECRET}
|
||||
data = "client_id=1&money=10.00&order_name=Test&out_trade_no=M1&type=ldcpay" + client_secret
|
||||
signature = ed25519.Sign(privateKey, []byte(data))
|
||||
signBase64 = base64.StdEncoding.EncodeToString(signature)`}
|
||||
language="javascript"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h3 id="1-4-submit" className="text-base md:text-lg font-semibold text-foreground mt-6 md:mt-8 mb-3 md:mb-4">1.4 积分流转服务</h3>
|
||||
<ul className="list-disc pl-4 md:pl-5 space-y-2 mb-6">
|
||||
<li><strong>方法:</strong>POST <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">/epay/pay/submit.php</code></li>
|
||||
<li><strong>编码:</strong><code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">application/json</code> 或 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">application/x-www-form-urlencoded</code></li>
|
||||
</ul>
|
||||
|
||||
<DocsTable>
|
||||
<DocsTableHeader>
|
||||
<DocsTableRow>
|
||||
<DocsTableHead className="w-[100px] whitespace-nowrap">参数</DocsTableHead>
|
||||
<DocsTableHead className="w-[80px] whitespace-nowrap">必填</DocsTableHead>
|
||||
<DocsTableHead>说明</DocsTableHead>
|
||||
</DocsTableRow>
|
||||
</DocsTableHeader>
|
||||
<DocsTableBody>
|
||||
<DocsTableRow>
|
||||
<DocsTableCell className="font-mono text-xs">client_id</DocsTableCell>
|
||||
<DocsTableCell>是</DocsTableCell>
|
||||
<DocsTableCell>应用客户端 ID</DocsTableCell>
|
||||
</DocsTableRow>
|
||||
<DocsTableRow>
|
||||
<DocsTableCell className="font-mono text-xs">type</DocsTableCell>
|
||||
<DocsTableCell>是</DocsTableCell>
|
||||
<DocsTableCell>固定 <code className="bg-muted px-1 rounded text-xs before:content-none after:content-none">ldcpay</code></DocsTableCell>
|
||||
</DocsTableRow>
|
||||
<DocsTableRow>
|
||||
<DocsTableCell className="font-mono text-xs">out_trade_no</DocsTableCell>
|
||||
<DocsTableCell>是</DocsTableCell>
|
||||
<DocsTableCell>业务单号</DocsTableCell>
|
||||
</DocsTableRow>
|
||||
<DocsTableRow>
|
||||
<DocsTableCell className="font-mono text-xs">money</DocsTableCell>
|
||||
<DocsTableCell>是</DocsTableCell>
|
||||
<DocsTableCell>积分数量,必须保留两位小数(比如,10.00)</DocsTableCell>
|
||||
</DocsTableRow>
|
||||
<DocsTableRow>
|
||||
<DocsTableCell className="font-mono text-xs">order_name</DocsTableCell>
|
||||
<DocsTableCell>是</DocsTableCell>
|
||||
<DocsTableCell>商品名称</DocsTableCell>
|
||||
</DocsTableRow>
|
||||
<DocsTableRow>
|
||||
<DocsTableCell className="font-mono text-xs">notify_url</DocsTableCell>
|
||||
<DocsTableCell>否</DocsTableCell>
|
||||
<DocsTableCell>会参与签名;可选订单级异步通知地址。长度不超过 100,需为合法 URL。传入后支付成功优先回调该地址,未传则使用应用 notify_url</DocsTableCell>
|
||||
</DocsTableRow>
|
||||
<DocsTableRow>
|
||||
<DocsTableCell className="font-mono text-xs">return_url</DocsTableCell>
|
||||
<DocsTableCell>否</DocsTableCell>
|
||||
<DocsTableCell>会参与签名;可选订单级回跳地址。长度不超过 100,需为合法 URL。传入后支付成功页面优先跳转该地址,未传则使用应用 redirect_uri</DocsTableCell>
|
||||
</DocsTableRow>
|
||||
<DocsTableRow>
|
||||
<DocsTableCell className="font-mono text-xs">sign</DocsTableCell>
|
||||
<DocsTableCell>是</DocsTableCell>
|
||||
<DocsTableCell>按“签名算法”生成的 Base64 签名串</DocsTableCell>
|
||||
</DocsTableRow>
|
||||
</DocsTableBody>
|
||||
</DocsTable>
|
||||
|
||||
<h3 id="1-5-others" className="text-base md:text-lg font-semibold text-foreground mt-6 md:mt-8 mb-3 md:mb-4">1.5 其他接口</h3>
|
||||
<p className="text-muted-foreground mb-4">其他接口定义请参考 <a href="#3-common-services" className="text-primary underline underline-offset-4">3. 其他接口</a>。</p>
|
||||
<ul className="list-disc pl-4 md:pl-5 space-y-2">
|
||||
<li><strong>订单查询:</strong>详见 <a href="#3-1-order" className="text-primary underline underline-offset-4">3.1 订单查询</a></li>
|
||||
<li><strong>订单退款:</strong>详见 <a href="#3-2-refund" className="text-primary underline underline-offset-4">3.2 订单退款</a></li>
|
||||
<li><strong>异步通知:</strong>详见 <a href="#3-3-notify" className="text-primary underline underline-offset-4">3.3 异步通知</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
),
|
||||
children: [
|
||||
{ value: "1-1-overview", title: "1.1 概览" },
|
||||
{ value: "1-2-flow", title: "1.2 对接流程" },
|
||||
{ value: "1-3-auth-sign", title: "1.3 鉴权与签名" },
|
||||
{ value: "1-4-submit", title: "1.4 积分流转服务" },
|
||||
{ value: "1-5-others", title: "1.5 其他接口" },
|
||||
]
|
||||
},
|
||||
{
|
||||
value: "epay-compatibility",
|
||||
title: "2. 易支付兼容接口",
|
||||
content: (
|
||||
<div className="space-y-4 text-sm leading-relaxed">
|
||||
<div className="bg-muted/50 border border-border/50 rounded-lg px-3 py-2 mb-6">
|
||||
<p className="text-muted-foreground m-0">兼容易支付、CodePay、VPay 等支付协议</p>
|
||||
</div>
|
||||
|
||||
<h3 id="2-1-overview" className="text-base md:text-lg font-semibold text-foreground mt-6 md:mt-8 mb-3 md:mb-4">2.1 概览</h3>
|
||||
<ul className="list-disc pl-4 md:pl-5 space-y-2">
|
||||
<li><strong>协议:</strong>EasyPay / CodePay / VPay 兼容协议</li>
|
||||
<li><strong>服务类型:</strong>仅支持 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">type=epay</code></li>
|
||||
<li><strong>网关基址:</strong><code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">https://credit.linux.do/epay</code></li>
|
||||
<li><strong>订单有效期:</strong>取系统配置 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">merchant_order_expire_minutes</code>(平台端设置)</li>
|
||||
</ul>
|
||||
|
||||
<h3 id="2-2-common-errors" className="text-base md:text-lg font-semibold text-foreground mt-6 md:mt-8 mb-3 md:mb-4">2.2 常见错误</h3>
|
||||
<ul className="list-disc pl-4 md:pl-5 space-y-2">
|
||||
<li><code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">不支持的请求类型</code>:<code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">type</code> 仅允许 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">epay</code></li>
|
||||
<li><code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">签名验证失败</code>:参与签名字段与请求体需一致,密钥直接拼接</li>
|
||||
<li><code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">金额必须大于0</code> / <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">积分小数位数不能超过2位</code></li>
|
||||
<li><code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">订单已过期</code>:超出系统配置有效期</li>
|
||||
<li><code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">订单不存在或已完成</code>:订单号错误、已退回或已完成</li>
|
||||
<li><code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">余额不足</code>:余额退回时用户积分不足</li>
|
||||
</ul>
|
||||
|
||||
<h3 id="2-3-flow" className="text-base md:text-lg font-semibold text-foreground mt-6 md:mt-8 mb-3 md:mb-4">2.3 对接流程</h3>
|
||||
<ol className="list-decimal pl-4 md:pl-5 space-y-2">
|
||||
<li>控制台创建 API Key,记录 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">pid</code>、<code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">key</code>,配置回调地址</li>
|
||||
<li>按“签名算法”生成 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">sign</code>,调用 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">/epay/pay/submit.php</code> 创建积分流转服务并跳转认证界面</li>
|
||||
<li>可通过 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">/epay/api.php</code> 轮询结果,或等待异步回调</li>
|
||||
<li>退回服务时,携带同一 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">trade_no</code> 和原积分数量,调用积分退回接口</li>
|
||||
<li>回调验签通过后返回 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">success</code> 完成闭环</li>
|
||||
</ol>
|
||||
|
||||
<h3 id="2-4-auth-sign" className="text-base md:text-lg font-semibold text-foreground mt-6 md:mt-8 mb-3 md:mb-4">2.4 鉴权与签名</h3>
|
||||
<h4 className="font-medium text-foreground mt-3 md:mt-4 mb-2">2.4.1 API Key</h4>
|
||||
<ul className="list-disc pl-4 md:pl-5 space-y-2 mb-4">
|
||||
<li><code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">pid</code>:Client ID</li>
|
||||
<li><code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">key</code>:Client Secret(妥善保管)</li>
|
||||
<li><code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">notify_url</code> / <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">return_url</code>:应用级默认回调地址(兜底);创建订单时可在请求中传同名字段作为订单级覆盖,未传时回退应用配置。</li>
|
||||
</ul>
|
||||
|
||||
<h4 className="font-medium text-foreground mt-3 md:mt-4 mb-2">2.4.2 签名算法</h4>
|
||||
<div className="space-y-4">
|
||||
<ol className="list-decimal pl-4 md:pl-5 space-y-2">
|
||||
<li>取所有非空字段(排除 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">sign</code>、<code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">sign_type</code> 字段)</li>
|
||||
<li>将上述字段按 ASCII 升序,依次拼成 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">k1=v1&k2=v2</code></li>
|
||||
<li>在末尾追加应用密钥:<code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">k1=v1&k2=v2{"{secret}"}</code></li>
|
||||
<li>整体进行 MD5,取小写十六进制作为 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">sign</code></li>
|
||||
</ol>
|
||||
<CodeBlock
|
||||
code={`payload="money=10&name=Test&out_trade_no=M20250101&pid=001&type=epay"
|
||||
sign=$(echo -n "\${payload}\${SECRET}" | md5) # 输出小写`}
|
||||
language="bash"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h3 id="2-5-submit" className="text-base md:text-lg font-semibold text-foreground mt-6 md:mt-8 mb-3 md:mb-4">2.5 积分流转服务</h3>
|
||||
<ul className="list-disc pl-4 md:pl-5 space-y-2 mb-6">
|
||||
<li><strong>方法:</strong>POST <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">/epay/pay/submit.php</code></li>
|
||||
<li><strong>编码:</strong><code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">application/json</code> 或 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">application/x-www-form-urlencoded</code></li>
|
||||
<li><strong>成功:</strong>验签通过后,平台自动创建积分流转服务,并跳转到认证界面(Location=<code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">https://credit.linux.do/paying?order_no=...</code>)</li>
|
||||
<li><strong>失败:</strong>返回 JSON <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">{`{"error_msg":"...", "data":null}`}</code></li>
|
||||
</ul>
|
||||
|
||||
<DocsTable>
|
||||
<DocsTableHeader>
|
||||
<DocsTableRow>
|
||||
<DocsTableHead className="w-[100px] whitespace-nowrap">参数</DocsTableHead>
|
||||
<DocsTableHead className="w-[80px] whitespace-nowrap">必填</DocsTableHead>
|
||||
<DocsTableHead>说明</DocsTableHead>
|
||||
</DocsTableRow>
|
||||
</DocsTableHeader>
|
||||
<DocsTableBody>
|
||||
<DocsTableRow>
|
||||
<DocsTableCell className="font-mono text-xs">pid</DocsTableCell>
|
||||
<DocsTableCell>是</DocsTableCell>
|
||||
<DocsTableCell>Client ID</DocsTableCell>
|
||||
</DocsTableRow>
|
||||
<DocsTableRow>
|
||||
<DocsTableCell className="font-mono text-xs">type</DocsTableCell>
|
||||
<DocsTableCell>是</DocsTableCell>
|
||||
<DocsTableCell>固定 <code className="bg-muted px-1 rounded text-xs before:content-none after:content-none">epay</code></DocsTableCell>
|
||||
</DocsTableRow>
|
||||
<DocsTableRow>
|
||||
<DocsTableCell className="font-mono text-xs">out_trade_no</DocsTableCell>
|
||||
<DocsTableCell>否</DocsTableCell>
|
||||
<DocsTableCell>业务单号,建议全局唯一</DocsTableCell>
|
||||
</DocsTableRow>
|
||||
<DocsTableRow>
|
||||
<DocsTableCell className="font-mono text-xs">name</DocsTableCell>
|
||||
<DocsTableCell>是</DocsTableCell>
|
||||
<DocsTableCell>标题,最多 64 字符</DocsTableCell>
|
||||
</DocsTableRow>
|
||||
<DocsTableRow>
|
||||
<DocsTableCell className="font-mono text-xs">money</DocsTableCell>
|
||||
<DocsTableCell>是</DocsTableCell>
|
||||
<DocsTableCell>积分数量,最多 2 位小数</DocsTableCell>
|
||||
</DocsTableRow>
|
||||
<DocsTableRow>
|
||||
<DocsTableCell className="font-mono text-xs">notify_url</DocsTableCell>
|
||||
<DocsTableCell>否</DocsTableCell>
|
||||
<DocsTableCell>会参与签名;可选订单级异步通知地址。长度不超过 100,需为合法 URL。传入后支付成功优先回调该地址,未传则使用应用 notify_url</DocsTableCell>
|
||||
</DocsTableRow>
|
||||
<DocsTableRow>
|
||||
<DocsTableCell className="font-mono text-xs">return_url</DocsTableCell>
|
||||
<DocsTableCell>否</DocsTableCell>
|
||||
<DocsTableCell>会参与签名;可选订单级回跳地址。长度不超过 100,需为合法 URL。传入后支付成功页面优先跳转该地址,未传则使用应用 redirect_uri</DocsTableCell>
|
||||
</DocsTableRow>
|
||||
<DocsTableRow>
|
||||
<DocsTableCell className="font-mono text-xs">device</DocsTableCell>
|
||||
<DocsTableCell>否</DocsTableCell>
|
||||
<DocsTableCell>终端标识,可选</DocsTableCell>
|
||||
</DocsTableRow>
|
||||
<DocsTableRow>
|
||||
<DocsTableCell className="font-mono text-xs">sign</DocsTableCell>
|
||||
<DocsTableCell>是</DocsTableCell>
|
||||
<DocsTableCell>按“签名算法”生成</DocsTableCell>
|
||||
</DocsTableRow>
|
||||
<DocsTableRow>
|
||||
<DocsTableCell className="font-mono text-xs">sign_type</DocsTableCell>
|
||||
<DocsTableCell>否</DocsTableCell>
|
||||
<DocsTableCell>固定 <code className="bg-muted px-1 rounded text-xs before:content-none after:content-none">MD5</code></DocsTableCell>
|
||||
</DocsTableRow>
|
||||
</DocsTableBody>
|
||||
</DocsTable>
|
||||
|
||||
<p className="text-muted-foreground mb-2">请求示例:</p>
|
||||
<CodeBlock
|
||||
code={`curl -X POST https://credit.linux.do/epay/pay/submit.php \\
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \\
|
||||
-d "pid=001" \\
|
||||
-d "type=epay" \\
|
||||
-d "out_trade_no=M20250101" \\
|
||||
-d "name=Test" \\
|
||||
-d "money=10" \\
|
||||
-d "sign=\${SIGN}" \\
|
||||
-d "sign_type=MD5"`}
|
||||
language="bash"
|
||||
/>
|
||||
|
||||
<h3 id="2-6-others" className="text-base md:text-lg font-semibold text-foreground mt-6 md:mt-8 mb-3 md:mb-4">2.6 其他接口</h3>
|
||||
<p className="text-muted-foreground mb-4">其他接口定义请参考 <a href="#3-common-services" className="text-primary underline underline-offset-4">3. 其他接口</a>。</p>
|
||||
<ul className="list-disc pl-4 md:pl-5 space-y-2">
|
||||
<li><strong>订单查询:</strong>详见 <a href="#3-1-order" className="text-primary underline underline-offset-4">3.1 订单查询</a></li>
|
||||
<li><strong>订单退款:</strong>详见 <a href="#3-2-refund" className="text-primary underline underline-offset-4">3.2 订单退款</a></li>
|
||||
<li><strong>异步通知:</strong>详见 <a href="#3-3-notify" className="text-primary underline underline-offset-4">3.3 异步通知</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
),
|
||||
children: [
|
||||
{ value: "2-1-overview", title: "2.1 概览" },
|
||||
{ value: "2-2-common-errors", title: "2.2 常见错误" },
|
||||
{ value: "2-3-flow", title: "2.3 对接流程" },
|
||||
{ value: "2-4-auth-sign", title: "2.4 鉴权与签名" },
|
||||
{ value: "2-5-submit", title: "2.5 积分流转服务" },
|
||||
{ value: "2-6-others", title: "2.6 其他接口" },
|
||||
]
|
||||
},
|
||||
{
|
||||
value: "common-services",
|
||||
title: "3. 其他接口",
|
||||
content: (
|
||||
<div className="space-y-4 text-sm leading-relaxed">
|
||||
<div className="bg-muted/50 border border-border/50 rounded-lg px-3 py-2 mb-6">
|
||||
<p className="text-muted-foreground m-0">官方接口与易支付兼容接口公用接口。</p>
|
||||
</div>
|
||||
|
||||
<h3 id="3-1-order" className="text-base md:text-lg font-semibold text-foreground mt-6 md:mt-8 mb-3 md:mb-4">3.1 订单查询</h3>
|
||||
<ul className="list-disc pl-4 md:pl-5 space-y-2 mb-6">
|
||||
<li><strong>方法:</strong>GET <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">/epay/api.php</code></li>
|
||||
<li><strong>认证:</strong><code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">pid</code> + <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">key</code></li>
|
||||
<li><strong>说明:</strong><code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">out_trade_no</code> 必填;<code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">act</code> 可传 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">order</code>,后端不强校验。</li>
|
||||
</ul>
|
||||
|
||||
<DocsTable>
|
||||
<DocsTableHeader>
|
||||
<DocsTableRow>
|
||||
<DocsTableHead className="w-[100px] whitespace-nowrap">参数</DocsTableHead>
|
||||
<DocsTableHead className="w-[80px] whitespace-nowrap">必填</DocsTableHead>
|
||||
<DocsTableHead>说明</DocsTableHead>
|
||||
</DocsTableRow>
|
||||
</DocsTableHeader>
|
||||
<DocsTableBody>
|
||||
<DocsTableRow>
|
||||
<DocsTableCell className="font-mono text-xs">act</DocsTableCell>
|
||||
<DocsTableCell>否</DocsTableCell>
|
||||
<DocsTableCell>可选字段,建议 <code className="bg-muted px-1 rounded text-xs before:content-none after:content-none">order</code></DocsTableCell>
|
||||
</DocsTableRow>
|
||||
<DocsTableRow>
|
||||
<DocsTableCell className="font-mono text-xs">pid</DocsTableCell>
|
||||
<DocsTableCell>是</DocsTableCell>
|
||||
<DocsTableCell>Client ID</DocsTableCell>
|
||||
</DocsTableRow>
|
||||
<DocsTableRow>
|
||||
<DocsTableCell className="font-mono text-xs">key</DocsTableCell>
|
||||
<DocsTableCell>是</DocsTableCell>
|
||||
<DocsTableCell>Client Secret</DocsTableCell>
|
||||
</DocsTableRow>
|
||||
<DocsTableRow>
|
||||
<DocsTableCell className="font-mono text-xs">out_trade_no</DocsTableCell>
|
||||
<DocsTableCell>是</DocsTableCell>
|
||||
<DocsTableCell>业务单号</DocsTableCell>
|
||||
</DocsTableRow>
|
||||
</DocsTableBody>
|
||||
</DocsTable>
|
||||
|
||||
<p className="text-muted-foreground mb-2">成功响应:</p>
|
||||
<CodeBlock
|
||||
code={`{
|
||||
"code": 1,
|
||||
"msg": "查询订单号成功!",
|
||||
"trade_no": "M20250101",
|
||||
"out_trade_no": "M20250101",
|
||||
"type": "epay",
|
||||
"pid": "001",
|
||||
"addtime": "2025-01-01 12:00:00",
|
||||
"endtime": "2025-01-01 12:01:30",
|
||||
"name": "Test",
|
||||
"money": "10",
|
||||
"status": 1
|
||||
}`}
|
||||
language="json"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs"><strong className="text-foreground">补充:</strong><code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">status</code> 1=成功,0=失败/处理中;不存在会返回 HTTP 404 且 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">{`{"code":-1,"msg":"服务不存在或已完成"}`}</code>。</p>
|
||||
|
||||
<h3 id="3-2-refund" className="text-base md:text-lg font-semibold text-foreground mt-6 md:mt-8 mb-3 md:mb-4">3.2 订单退款</h3>
|
||||
<ul className="list-disc pl-4 md:pl-5 space-y-2 mb-6">
|
||||
<li><strong>方法:</strong>POST <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">/epay/api.php</code></li>
|
||||
<li><strong>编码:</strong><code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">application/json</code> 或 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">application/x-www-form-urlencoded</code></li>
|
||||
<li><strong>限制:</strong>仅支持对已成功的积分流转服务进行积分的全额退回</li>
|
||||
</ul>
|
||||
|
||||
<div>
|
||||
<DocsTable>
|
||||
<DocsTableHeader>
|
||||
<DocsTableRow>
|
||||
<DocsTableHead className="w-[100px] whitespace-nowrap">参数</DocsTableHead>
|
||||
<DocsTableHead className="w-[80px] whitespace-nowrap">必填</DocsTableHead>
|
||||
<DocsTableHead>说明</DocsTableHead>
|
||||
</DocsTableRow>
|
||||
</DocsTableHeader>
|
||||
<DocsTableBody>
|
||||
<DocsTableRow>
|
||||
<DocsTableCell className="font-mono text-xs">pid</DocsTableCell>
|
||||
<DocsTableCell>是</DocsTableCell>
|
||||
<DocsTableCell>Client ID</DocsTableCell>
|
||||
</DocsTableRow>
|
||||
<DocsTableRow>
|
||||
<DocsTableCell className="font-mono text-xs">key</DocsTableCell>
|
||||
<DocsTableCell>是</DocsTableCell>
|
||||
<DocsTableCell>Client Secret</DocsTableCell>
|
||||
</DocsTableRow>
|
||||
<DocsTableRow>
|
||||
<DocsTableCell className="font-mono text-xs">trade_no</DocsTableCell>
|
||||
<DocsTableCell>是</DocsTableCell>
|
||||
<DocsTableCell>编号</DocsTableCell>
|
||||
</DocsTableRow>
|
||||
<DocsTableRow>
|
||||
<DocsTableCell className="font-mono text-xs">money</DocsTableCell>
|
||||
<DocsTableCell>是</DocsTableCell>
|
||||
<DocsTableCell>必须等于原积分流转服务的积分数量</DocsTableCell>
|
||||
</DocsTableRow>
|
||||
<DocsTableRow>
|
||||
<DocsTableCell className="font-mono text-xs">out_trade_no</DocsTableCell>
|
||||
<DocsTableCell>否</DocsTableCell>
|
||||
<DocsTableCell>业务单号(兼容)</DocsTableCell>
|
||||
</DocsTableRow>
|
||||
</DocsTableBody>
|
||||
</DocsTable>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground mb-2">响应:</p>
|
||||
<CodeBlock code={`{ "code": 1, "msg": "退款成功" }`} language="json" />
|
||||
<p className="text-muted-foreground text-xs"><strong className="text-foreground">常见失败:</strong>服务不存在/未认证、金额不合法(<=0 或小数超过 2 位)。</p>
|
||||
|
||||
|
||||
<h3 id="3-3-notify" className="text-base md:text-lg font-semibold text-foreground mt-6 md:mt-8 mb-3 md:mb-4">3.3 异步通知</h3>
|
||||
<ul className="list-disc pl-4 md:pl-5 space-y-2 mb-6">
|
||||
<li><strong>触发:</strong>认证成功后;失败自动重试,最多 5 次(单次 30s 超时)</li>
|
||||
<li><strong>目标:</strong>订单级 notify_url(如有)优先,否则回退到创建应用时设置的 notify_url</li>
|
||||
<li><strong>方式:</strong>HTTP GET</li>
|
||||
</ul>
|
||||
|
||||
<div>
|
||||
<DocsTable>
|
||||
<DocsTableHeader>
|
||||
<DocsTableRow>
|
||||
<DocsTableHead className="w-[100px] whitespace-nowrap">参数</DocsTableHead>
|
||||
<DocsTableHead>说明</DocsTableHead>
|
||||
</DocsTableRow>
|
||||
</DocsTableHeader>
|
||||
<DocsTableBody>
|
||||
<DocsTableRow>
|
||||
<DocsTableCell className="font-mono text-xs">pid</DocsTableCell>
|
||||
<DocsTableCell>Client ID</DocsTableCell>
|
||||
</DocsTableRow>
|
||||
<DocsTableRow>
|
||||
<DocsTableCell className="font-mono text-xs">trade_no</DocsTableCell>
|
||||
<DocsTableCell>编号</DocsTableCell>
|
||||
</DocsTableRow>
|
||||
<DocsTableRow>
|
||||
<DocsTableCell className="font-mono text-xs">out_trade_no</DocsTableCell>
|
||||
<DocsTableCell>业务单号</DocsTableCell>
|
||||
</DocsTableRow>
|
||||
<DocsTableRow>
|
||||
<DocsTableCell className="font-mono text-xs">type</DocsTableCell>
|
||||
<DocsTableCell>固定 <code className="bg-muted px-1 rounded text-xs before:content-none after:content-none">epay</code></DocsTableCell>
|
||||
</DocsTableRow>
|
||||
<DocsTableRow>
|
||||
<DocsTableCell className="font-mono text-xs">name</DocsTableCell>
|
||||
<DocsTableCell>标题</DocsTableCell>
|
||||
</DocsTableRow>
|
||||
<DocsTableRow>
|
||||
<DocsTableCell className="font-mono text-xs">money</DocsTableCell>
|
||||
<DocsTableCell>积分数量,最多 2 位小数</DocsTableCell>
|
||||
</DocsTableRow>
|
||||
<DocsTableRow>
|
||||
<DocsTableCell className="font-mono text-xs">trade_status</DocsTableCell>
|
||||
<DocsTableCell>固定 <code className="bg-muted px-1 rounded text-xs before:content-none after:content-none">TRADE_SUCCESS</code></DocsTableCell>
|
||||
</DocsTableRow>
|
||||
<DocsTableRow>
|
||||
<DocsTableCell className="font-mono text-xs">sign</DocsTableCell>
|
||||
<DocsTableCell>按“签名算法”生成</DocsTableCell>
|
||||
</DocsTableRow>
|
||||
</DocsTableBody>
|
||||
</DocsTable>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">应用需返回 HTTP 200 且响应体为 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">success</code>(大小写不敏感),否则视为失败并继续重试。</p>
|
||||
|
||||
<h3 id="3-4-distribute" className="text-base md:text-lg font-semibold text-foreground mt-6 md:mt-8 mb-3 md:mb-4">3.4 商户分发接口</h3>
|
||||
<ul className="list-disc pl-4 md:pl-5 space-y-2 mb-6">
|
||||
<li><strong>方法:</strong>POST <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">/lpay/distribute</code></li>
|
||||
<li><strong>编码:</strong><code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">application/json</code></li>
|
||||
<li><strong>认证:</strong>Basic Auth (使用 <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">client_id:client_secret</code> 进行 Base64 编码)</li>
|
||||
</ul>
|
||||
|
||||
<DocsTable>
|
||||
<DocsTableHeader>
|
||||
<DocsTableRow>
|
||||
<DocsTableHead className="w-[100px] whitespace-nowrap">参数</DocsTableHead>
|
||||
<DocsTableHead className="w-[80px] whitespace-nowrap">必填</DocsTableHead>
|
||||
<DocsTableHead>说明</DocsTableHead>
|
||||
</DocsTableRow>
|
||||
</DocsTableHeader>
|
||||
<DocsTableBody>
|
||||
<DocsTableRow>
|
||||
<DocsTableCell className="font-mono text-xs">user_id</DocsTableCell>
|
||||
<DocsTableCell>是</DocsTableCell>
|
||||
<DocsTableCell>收款人用户 ID (数字)</DocsTableCell>
|
||||
</DocsTableRow>
|
||||
<DocsTableRow>
|
||||
<DocsTableCell className="font-mono text-xs">username</DocsTableCell>
|
||||
<DocsTableCell>是</DocsTableCell>
|
||||
<DocsTableCell>收款人用户名 (用于二次校验)</DocsTableCell>
|
||||
</DocsTableRow>
|
||||
<DocsTableRow>
|
||||
<DocsTableCell className="font-mono text-xs">amount</DocsTableCell>
|
||||
<DocsTableCell>是</DocsTableCell>
|
||||
<DocsTableCell>分发积分数量,最多 2 位小数</DocsTableCell>
|
||||
</DocsTableRow>
|
||||
<DocsTableRow>
|
||||
<DocsTableCell className="font-mono text-xs">out_trade_no</DocsTableCell>
|
||||
<DocsTableCell>否</DocsTableCell>
|
||||
<DocsTableCell>商户自定义单号</DocsTableCell>
|
||||
</DocsTableRow>
|
||||
<DocsTableRow>
|
||||
<DocsTableCell className="font-mono text-xs">remark</DocsTableCell>
|
||||
<DocsTableCell>否</DocsTableCell>
|
||||
<DocsTableCell>分发备注</DocsTableCell>
|
||||
</DocsTableRow>
|
||||
</DocsTableBody>
|
||||
</DocsTable>
|
||||
<p className="text-muted-foreground text-xs"><strong className="text-foreground">成功响应:</strong><code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">{`{"code":1, "data":{"trade_no":"...", "out_trade_no":"..."}}`}</code></p>
|
||||
|
||||
<h3 id="3-5-user-balance" className="text-base md:text-lg font-semibold text-foreground mt-6 md:mt-8 mb-3 md:mb-4">3.5 用户余额统计</h3>
|
||||
<ul className="list-disc pl-4 md:pl-5 space-y-2 mb-6">
|
||||
<li><strong>方法:</strong>GET <code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">/api/v1/dashboard/stats/user-balance</code></li>
|
||||
<li><strong>认证:</strong>无需鉴权(公开接口)</li>
|
||||
<li><strong>说明:</strong>获取平台所有用户可用余额的统计数据,结果有缓存,TTL 由系统配置决定</li>
|
||||
</ul>
|
||||
|
||||
<p className="text-muted-foreground mb-2">成功响应:</p>
|
||||
<CodeBlock
|
||||
code={`{
|
||||
"error_msg": "",
|
||||
"data": {
|
||||
"total_count": 1234,
|
||||
"total_amount": "98765.00",
|
||||
"avg_amount": "80.03",
|
||||
"median_amount": "50.00",
|
||||
"min_amount": "0.00",
|
||||
"max_amount": "9999.00",
|
||||
"std_dev": "123.45"
|
||||
}
|
||||
}`}
|
||||
language="json"
|
||||
/>
|
||||
|
||||
<DocsTable>
|
||||
<DocsTableHeader>
|
||||
<DocsTableRow>
|
||||
<DocsTableHead className="w-[120px] whitespace-nowrap">字段</DocsTableHead>
|
||||
<DocsTableHead>说明</DocsTableHead>
|
||||
</DocsTableRow>
|
||||
</DocsTableHeader>
|
||||
<DocsTableBody>
|
||||
<DocsTableRow>
|
||||
<DocsTableCell className="font-mono text-xs">total_count</DocsTableCell>
|
||||
<DocsTableCell>统计用户总数</DocsTableCell>
|
||||
</DocsTableRow>
|
||||
<DocsTableRow>
|
||||
<DocsTableCell className="font-mono text-xs">total_amount</DocsTableCell>
|
||||
<DocsTableCell>所有用户可用余额之和</DocsTableCell>
|
||||
</DocsTableRow>
|
||||
<DocsTableRow>
|
||||
<DocsTableCell className="font-mono text-xs">avg_amount</DocsTableCell>
|
||||
<DocsTableCell>平均余额</DocsTableCell>
|
||||
</DocsTableRow>
|
||||
<DocsTableRow>
|
||||
<DocsTableCell className="font-mono text-xs">median_amount</DocsTableCell>
|
||||
<DocsTableCell>余额中位数</DocsTableCell>
|
||||
</DocsTableRow>
|
||||
<DocsTableRow>
|
||||
<DocsTableCell className="font-mono text-xs">min_amount</DocsTableCell>
|
||||
<DocsTableCell>最小余额</DocsTableCell>
|
||||
</DocsTableRow>
|
||||
<DocsTableRow>
|
||||
<DocsTableCell className="font-mono text-xs">max_amount</DocsTableCell>
|
||||
<DocsTableCell>最大余额</DocsTableCell>
|
||||
</DocsTableRow>
|
||||
<DocsTableRow>
|
||||
<DocsTableCell className="font-mono text-xs">std_dev</DocsTableCell>
|
||||
<DocsTableCell>余额标准差</DocsTableCell>
|
||||
</DocsTableRow>
|
||||
</DocsTableBody>
|
||||
</DocsTable>
|
||||
</div>
|
||||
),
|
||||
children: [
|
||||
{ value: "3-1-order", title: "3.1 订单查询" },
|
||||
{ value: "3-2-refund", title: "3.2 订单退款" },
|
||||
{ value: "3-3-notify", title: "3.3 异步通知" },
|
||||
{ value: "3-4-distribute", title: "3.4 商户分发接口" },
|
||||
{ value: "3-5-user-balance", title: "3.5 用户余额统计" },
|
||||
]
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,329 @@
|
||||
import { type PolicySection } from "./types"
|
||||
import { CodeBlock } from "@/components/ui/code-block"
|
||||
import {
|
||||
DocsTable,
|
||||
DocsTableHeader,
|
||||
DocsTableBody,
|
||||
DocsTableHead,
|
||||
DocsTableRow,
|
||||
DocsTableCell,
|
||||
} from "@/components/ui/docs-table"
|
||||
|
||||
/**
|
||||
* ------------------------------------------------------------------
|
||||
* 使用指南 (How To Use)
|
||||
* ------------------------------------------------------------------
|
||||
*/
|
||||
export const howToUseSections: PolicySection[] = [
|
||||
{
|
||||
value: "quick-start",
|
||||
title: "1. 快速开始",
|
||||
content: (
|
||||
<div className="space-y-4 text-sm leading-relaxed">
|
||||
<div className="bg-muted/50 border border-border/50 rounded-lg px-3 py-2 mb-6">
|
||||
<p className="text-muted-foreground m-0">为社区开发者与用户提供完整的平台使用说明</p>
|
||||
</div>
|
||||
<ul className="list-disc pl-5 space-y-2">
|
||||
<li><strong>身份认证:</strong>基于 LINUX DO Connect (OAuth)</li>
|
||||
<li><strong>认证方式:</strong>账户积分消耗认证</li>
|
||||
<li><strong>手续费:</strong>动态费率,由服务方承担</li>
|
||||
<li><strong>争议处理:</strong>支持服务方与消费方的双方争议处理</li>
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
value: "roles",
|
||||
title: "2. 角色说明",
|
||||
content: (
|
||||
<div className="space-y-4 text-sm leading-relaxed">
|
||||
<ul className="list-disc pl-5 space-y-2">
|
||||
<li><strong>服务方:</strong>最终积分流转的转入方</li>
|
||||
<li><strong>消费方:</strong>最终积分流转的转出方</li>
|
||||
<li><strong>认证平台:</strong>LINUX DO Credit 系统本身</li>
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
value: "integration",
|
||||
title: "3. 接入积分服务",
|
||||
content: (
|
||||
<div className="space-y-4 text-sm leading-relaxed">
|
||||
<h3 id="3-1-api" className="text-base md:text-lg font-semibold text-foreground mt-6 md:mt-8 mb-3 md:mb-4">3.1 使用 API 接口</h3>
|
||||
<ol className="list-decimal pl-4 md:pl-5 space-y-2">
|
||||
<li>
|
||||
<strong>创建应用</strong>
|
||||
<ul className="list-disc pl-4 md:pl-5 mt-2 space-y-1 text-muted-foreground">
|
||||
<li>前往 <a href="/merchant" className="text-primary hover:underline">集市中心</a></li>
|
||||
<li>点击顶部右侧 <strong>创建应用</strong> 按钮</li>
|
||||
<li>填写必要信息:应用名称、应用主页、回调地址、通知地址</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<strong>获取 API 凭证</strong>
|
||||
<ul className="list-disc pl-4 md:pl-5 mt-2 space-y-1 text-muted-foreground">
|
||||
<li>在集市中心顶部右侧选择器中选择您的应用</li>
|
||||
<li>在 <strong>API 配置</strong> 面板中获取:
|
||||
<ul className="list-[circle] pl-4 md:pl-5 mt-1">
|
||||
<li><code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">Client ID</code>:客户端ID,用于标识您的身份</li>
|
||||
<li><code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono before:content-none after:content-none">Client Secret</code>:客户端密钥,用于签名验证(<strong>请妥善保管,切勿泄露</strong>)</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<strong>使用 API 接口</strong>
|
||||
<ul className="list-disc pl-4 md:pl-5 mt-2 space-y-1 text-muted-foreground">
|
||||
<li>使用 API 接口创建积分流转服务</li>
|
||||
<li>参考文档:<a href="/docs/api" className="text-primary hover:underline">API 接口文档</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<h3 id="3-2-online" className="text-base md:text-lg font-semibold text-foreground mt-6 md:mt-8 mb-3 md:mb-4">3.2 使用在线服务</h3>
|
||||
<ul className="list-disc pl-4 md:pl-5 space-y-2 mb-4">
|
||||
<li><strong>适用场景:</strong>无代码开发基础,或只用于简单的积分服务。</li>
|
||||
<li><strong>操作步骤:</strong>
|
||||
<ol className="list-decimal pl-4 md:pl-5 mt-2 space-y-1 text-muted-foreground">
|
||||
<li>前往 <a href="/merchant" className="text-primary hover:underline">集市中心</a> 创建应用,获取 API 凭证</li>
|
||||
<li>选择应用,点击 <strong>在线收款</strong> 功能</li>
|
||||
<li>创建在线积分服务</li>
|
||||
<li>获取唯一积分服务链接</li>
|
||||
<li>发送给您所服务的客户使用</li>
|
||||
</ol>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h3 id="3-3-new-api" className="text-base md:text-lg font-semibold text-foreground mt-6 md:mt-8 mb-3 md:mb-4">3.3 快速集成 New API</h3>
|
||||
<ul className="list-disc pl-4 md:pl-5 space-y-2 mb-4">
|
||||
<li><strong>适用场景:</strong>New API 站点,LINUX DO Credit 兼容 EasyPay 协议,公益站站长可直接集成。</li>
|
||||
<li><strong>操作步骤:</strong>
|
||||
<ol className="list-decimal pl-4 md:pl-5 mt-2 space-y-2 text-muted-foreground">
|
||||
<li>
|
||||
前往 <a href="/merchant" className="text-primary hover:underline">集市中心</a>,点击 <strong>创建应用</strong>,填写 New API 站点信息:
|
||||
<div className="my-4">
|
||||
<DocsTable>
|
||||
<DocsTableHeader>
|
||||
<DocsTableRow>
|
||||
<DocsTableHead>字段</DocsTableHead>
|
||||
<DocsTableHead>值</DocsTableHead>
|
||||
</DocsTableRow>
|
||||
</DocsTableHeader>
|
||||
<DocsTableBody>
|
||||
<DocsTableRow>
|
||||
<DocsTableCell>应用名称</DocsTableCell>
|
||||
<DocsTableCell><code className="bg-muted px-1 rounded text-xs before:content-none after:content-none">您的应用名称</code></DocsTableCell>
|
||||
</DocsTableRow>
|
||||
<DocsTableRow>
|
||||
<DocsTableCell>应用主页</DocsTableCell>
|
||||
<DocsTableCell><code className="bg-muted px-1 rounded text-xs before:content-none after:content-none">https://{"{您的 New API 域名}"}</code></DocsTableCell>
|
||||
</DocsTableRow>
|
||||
<DocsTableRow>
|
||||
<DocsTableCell>回调地址</DocsTableCell>
|
||||
<DocsTableCell><code className="bg-muted px-1 rounded text-xs before:content-none after:content-none">https://{"{您的 New API 域名}"}/console/log</code></DocsTableCell>
|
||||
</DocsTableRow>
|
||||
<DocsTableRow>
|
||||
<DocsTableCell>通知地址</DocsTableCell>
|
||||
<DocsTableCell><code className="bg-muted px-1 rounded text-xs before:content-none after:content-none">https://{"{您的 New API 域名}"}/api/user/epay/notify</code></DocsTableCell>
|
||||
</DocsTableRow>
|
||||
</DocsTableBody>
|
||||
</DocsTable>
|
||||
</div>
|
||||
</li>
|
||||
<li>前往 New API 站点的系统设置,找到 <strong>支付设置</strong>。</li>
|
||||
<li>
|
||||
配置 LINUX DO Credit 平台参数:
|
||||
<div className="my-4">
|
||||
<DocsTable>
|
||||
<DocsTableHeader>
|
||||
<DocsTableRow>
|
||||
<DocsTableHead>参数</DocsTableHead>
|
||||
<DocsTableHead>值</DocsTableHead>
|
||||
</DocsTableRow>
|
||||
</DocsTableHeader>
|
||||
<DocsTableBody>
|
||||
<DocsTableRow>
|
||||
<DocsTableCell>支付地址</DocsTableCell>
|
||||
<DocsTableCell><code className="bg-muted px-1 rounded text-xs before:content-none after:content-none">https://credit.linux.do/epay/pay</code></DocsTableCell>
|
||||
</DocsTableRow>
|
||||
<DocsTableRow>
|
||||
<DocsTableCell>易支付商户ID</DocsTableCell>
|
||||
<DocsTableCell>您的 <code className="bg-muted px-1 rounded text-xs before:content-none after:content-none">Client ID</code></DocsTableCell>
|
||||
</DocsTableRow>
|
||||
<DocsTableRow>
|
||||
<DocsTableCell>易支付商户密钥</DocsTableCell>
|
||||
<DocsTableCell>您的 <code className="bg-muted px-1 rounded text-xs before:content-none after:content-none">Client Secret</code></DocsTableCell>
|
||||
</DocsTableRow>
|
||||
<DocsTableRow>
|
||||
<DocsTableCell>回调地址</DocsTableCell>
|
||||
<DocsTableCell><code className="bg-muted px-1 rounded text-xs before:content-none after:content-none">https://{"{您的 New API 域名}"}</code></DocsTableCell>
|
||||
</DocsTableRow>
|
||||
</DocsTableBody>
|
||||
</DocsTable>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
配置充值方式(JSON 格式):
|
||||
<div className="my-4">
|
||||
<CodeBlock
|
||||
code={`[
|
||||
{"color":"rgba(var(--semi-blue-5), 1)","name":"支付宝","type":"alipay"},
|
||||
{"color":"rgba(var(--semi-green-5), 1)","name":"微信","type":"wxpay"},
|
||||
{"color":"black","name":"Linux Do Credit","type":"epay"}
|
||||
]`}
|
||||
language="json"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
),
|
||||
children: [
|
||||
{ value: "3-1-api", title: "3.1 使用 API 接口" },
|
||||
{ value: "3-2-online", title: "3.2 使用在线服务" },
|
||||
{ value: "3-3-new-api", title: "3.3 快速集成 New API" },
|
||||
]
|
||||
},
|
||||
{
|
||||
value: "usage",
|
||||
title: "4. 使用 LINUX DO Credit 积分",
|
||||
content: (
|
||||
<div className="space-y-4 text-sm leading-relaxed">
|
||||
<p>您可以在任意支持 LINUX DO Credit 的平台下使用积分。在其他平台点击使用 LINUX DO Credit 积分时,会自动跳转到 LINUX DO Credit 的积分流转服务页面,您只需要确认积分流转信息无误,并选择使用 LINUX DO Credit 进行账户认证,即可完成整个交易服务。</p>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
value: "fees",
|
||||
title: "5. 服务(手续)费",
|
||||
content: (
|
||||
<div className="space-y-4 text-sm leading-relaxed">
|
||||
<h3 id="5-1-rules" className="text-base md:text-lg font-semibold text-foreground mt-6 md:mt-8 mb-3 md:mb-4">5.1 规则说明</h3>
|
||||
<p>为了更好的维持 LINUX DO Credit 平台的积分服务机制,保证社区积分的生态可持续发展,我们会按照规范进行不同程度的服务(手续)费用收取。</p>
|
||||
<ul className="list-disc pl-4 md:pl-5 space-y-2">
|
||||
<li><strong>承担方:</strong>服务(手续)费默认<strong>由服务方承担</strong></li>
|
||||
<li><strong>消费方使用:</strong>不会产生额外费用</li>
|
||||
<li><strong>服务方实收:</strong>订单金额 - 服务(手续)费</li>
|
||||
</ul>
|
||||
<div className="mt-4">
|
||||
<p className="font-mono text-xs mb-2 text-muted-foreground">计算公式:</p>
|
||||
<CodeBlock
|
||||
code={`手续费 = 订单金额 × 当前费率
|
||||
商家实际到账 = 订单金额 - 手续费`}
|
||||
language="text"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h3 id="5-2-dynamic" className="text-base md:text-lg font-semibold text-foreground mt-6 md:mt-8 mb-3 md:mb-4">5.2 动态费率</h3>
|
||||
<p>费率并非固定不变,会根据以下因素动态调整:</p>
|
||||
<ul className="list-disc pl-4 md:pl-5 space-y-1">
|
||||
<li>服务方平台等级</li>
|
||||
<li>服务方平台积分</li>
|
||||
<li>LINUX DO Credit 平台活动</li>
|
||||
</ul>
|
||||
</div>
|
||||
),
|
||||
children: [
|
||||
{ value: "5-1-rules", title: "5.1 规则说明" },
|
||||
{ value: "5-2-dynamic", title: "5.2 动态费率" },
|
||||
]
|
||||
},
|
||||
{
|
||||
value: "dispute",
|
||||
title: "6. 争议处理",
|
||||
content: (
|
||||
<div className="space-y-4 text-sm leading-relaxed">
|
||||
<p>为了保障服务方与消费方的合法权益,当积分服务出现纠纷时,可使用争议功能。</p>
|
||||
<ul className="list-disc pl-4 md:pl-5 space-y-2">
|
||||
<li>作为服务方,您需要及时响应消费方的争议请求:
|
||||
<ol className="list-decimal pl-4 md:pl-5 mt-1 text-muted-foreground">
|
||||
<li>在集市中心或通知中查看到 <strong>待处理的争议</strong></li>
|
||||
<li>查看消费方理由,选择操作:
|
||||
<ul className="list-[circle] pl-5 mt-1">
|
||||
<li><strong>同意:</strong>认可消费方诉求,积分原路退回给消费方</li>
|
||||
<li><strong>拒绝:</strong>如果您认为已履约,请提交相关证据</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ol>
|
||||
</li>
|
||||
</ul>
|
||||
<div className="bg-yellow-500/10 border border-yellow-500/20 text-yellow-600 dark:text-yellow-400 px-3 py-2 rounded-lg mt-4">
|
||||
<p className="m-0 text-sm">
|
||||
<strong>重要:</strong>建议服务方与消费方优先沟通解决。长时间未处理的争议会由 LINUX DO Credit 平台介入仲裁,这可能会影响您的服务方信誉。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
value: "community-balance",
|
||||
title: "7. 社区积分",
|
||||
content: (
|
||||
<div className="space-y-4 text-sm leading-relaxed">
|
||||
<p>您的 LINUX DO Credit 平台基础积分主要由 <strong>社区积分 (Community Balance)</strong> 划转而来。</p>
|
||||
<ul className="list-disc pl-4 md:pl-5 space-y-2">
|
||||
<li><strong>基本获取方式:</strong>通过在 LINUX DO 社区的活跃行为获得:
|
||||
<ul className="list-[circle] pl-4 md:pl-5 mt-1 text-muted-foreground">
|
||||
<li>获赞、解决问题等社区贡献</li>
|
||||
<li>详情可见 <a href="https://linux.do/t/topic/1409175" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">LINUX DO 社区</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><strong>划转规则:</strong>
|
||||
<ul className="list-[circle] pl-4 md:pl-5 mt-1 text-muted-foreground">
|
||||
<li>划转时间:社区积分每日凌晨 <strong>00:00</strong> 自动划转至可用余额</li>
|
||||
<li>限制说明:划转前不可用于任何积分服务</li>
|
||||
<li>服务费用:目前不收取任何划转 <strong>服务费</strong></li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
value: "settings",
|
||||
title: "8. 账户设置",
|
||||
content: (
|
||||
<div className="space-y-4 text-sm leading-relaxed">
|
||||
<p>您可以在 <strong>设置 (Settings)</strong> 页面管理您的账户信息。</p>
|
||||
<h3 className="text-base md:text-lg font-semibold text-foreground mt-4 mb-2">功能列表</h3>
|
||||
<ul className="list-disc pl-4 md:pl-5 space-y-1">
|
||||
<li><strong>个人资料:</strong>查看当前的账户信息和会员等级</li>
|
||||
<li><strong>安全设置:</strong>修改认证密码</li>
|
||||
<li><strong>外观设置:</strong>切换页面主题、界面外观</li>
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
value: "scripts",
|
||||
title: "9. 辅助脚本",
|
||||
content: (
|
||||
<div className="space-y-4 text-sm leading-relaxed">
|
||||
<p>为了方便用户随时查看当前的实时积分收入,我们提供了开源的 Userscript 脚本。</p>
|
||||
<ul className="list-disc pl-4 md:pl-5 space-y-2">
|
||||
<li><strong>功能:</strong>在 LINUX DO 显示实时积分收入,支持拖拽,不影响界面。</li>
|
||||
<li><strong>获取:</strong>仅需安装 Tampermonkey 插件即可使用。</li>
|
||||
<li><strong>安装:</strong>
|
||||
<a
|
||||
href="https://linux.do/t/topic/1365853"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
「LINUX DO Credit」实时积分收入脚本
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="lucide lucide-external-link"><path d="M15 3h6v6" /><path d="M10 14 21 3" /><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /></svg>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div className="bg-muted/50 border border-border/50 rounded-lg px-3 py-2 mt-2">
|
||||
<p className="text-xs text-muted-foreground m-0">
|
||||
注:脚本完全开源且安全,仅通过官方 API 获取公开数据,不涉及任何敏感权限。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,416 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, Calendar, Check, Copy, FileText } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { motion, useScroll, useSpring } from "motion/react"
|
||||
import type { PolicySection } from "./types"
|
||||
|
||||
interface LegalPageLayoutProps {
|
||||
title: string
|
||||
lastUpdated: string
|
||||
sections: PolicySection[]
|
||||
description?: React.ReactNode
|
||||
}
|
||||
|
||||
export function LegalPageLayout({
|
||||
title,
|
||||
lastUpdated,
|
||||
sections,
|
||||
description,
|
||||
}: LegalPageLayoutProps) {
|
||||
const [activeSection, setActiveSection] = React.useState<string>(sections[0]?.value || "")
|
||||
const [hasCopied, setHasCopied] = React.useState(false)
|
||||
const { scrollYProgress } = useScroll()
|
||||
const contentRef = React.useRef<HTMLDivElement | null>(null)
|
||||
const scaleX = useSpring(scrollYProgress, {
|
||||
stiffness: 100,
|
||||
damping: 30,
|
||||
restDelta: 0.001
|
||||
})
|
||||
|
||||
const allSectionIds = React.useMemo(() => {
|
||||
return sections.flatMap(section => [
|
||||
section.value,
|
||||
...(section.children?.map(child => child.value) || [])
|
||||
])
|
||||
}, [sections])
|
||||
|
||||
React.useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
setActiveSection(entry.target.id)
|
||||
}
|
||||
})
|
||||
},
|
||||
{ rootMargin: "-20% 0px -35% 0px" }
|
||||
)
|
||||
|
||||
allSectionIds.forEach((id) => {
|
||||
const element = document.getElementById(id)
|
||||
if (element) observer.observe(element)
|
||||
})
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [sections, allSectionIds])
|
||||
|
||||
const convertInlineNodes = (nodes: ChildNode[]): string => {
|
||||
return nodes.map((node) => {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
return node.textContent || ""
|
||||
}
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) {
|
||||
return ""
|
||||
}
|
||||
const element = node as HTMLElement
|
||||
const tagName = element.tagName.toLowerCase()
|
||||
if (tagName === "code") {
|
||||
return `\`${element.textContent?.trim() || ""}\``
|
||||
}
|
||||
if (tagName === "strong" || tagName === "b") {
|
||||
return `**${convertInlineNodes(Array.from(element.childNodes))}**`
|
||||
}
|
||||
if (tagName === "em" || tagName === "i") {
|
||||
return `*${convertInlineNodes(Array.from(element.childNodes))}*`
|
||||
}
|
||||
if (tagName === "a") {
|
||||
const text = convertInlineNodes(Array.from(element.childNodes)) || element.textContent || ""
|
||||
const href = element.getAttribute("href") || ""
|
||||
return href ? `[${text}](${href})` : text
|
||||
}
|
||||
if (tagName === "br") {
|
||||
return "\n"
|
||||
}
|
||||
return convertInlineNodes(Array.from(element.childNodes))
|
||||
}).join("")
|
||||
}
|
||||
|
||||
const convertList = (
|
||||
list: HTMLOListElement | HTMLUListElement,
|
||||
indentLevel: number
|
||||
): string => {
|
||||
const isOrdered = list.tagName.toLowerCase() === "ol"
|
||||
const items = Array.from(list.children).filter((child) => child.tagName.toLowerCase() === "li")
|
||||
return items.map((item, index) => {
|
||||
const prefix = isOrdered ? `${index + 1}. ` : "- "
|
||||
const indent = " ".repeat(indentLevel)
|
||||
const parts: string[] = []
|
||||
const itemElement = item as HTMLElement
|
||||
const childNodes = Array.from(itemElement.childNodes)
|
||||
const nestedLists: HTMLElement[] = []
|
||||
|
||||
childNodes.forEach((child) => {
|
||||
if (child.nodeType === Node.ELEMENT_NODE) {
|
||||
const childElement = child as HTMLElement
|
||||
if (childElement.tagName.toLowerCase() === "ul" || childElement.tagName.toLowerCase() === "ol") {
|
||||
nestedLists.push(childElement)
|
||||
return
|
||||
}
|
||||
}
|
||||
parts.push(convertInlineNodes([child]).trim())
|
||||
})
|
||||
|
||||
const baseLine = `${indent}${prefix}${parts.join("").trim()}`
|
||||
const nestedMarkdown = nestedLists.map((nested) => convertList(nested as HTMLOListElement | HTMLUListElement, indentLevel + 1))
|
||||
return [baseLine, ...nestedMarkdown].filter(Boolean).join("\n")
|
||||
}).join("\n")
|
||||
}
|
||||
|
||||
const convertTable = (table: HTMLTableElement): string => {
|
||||
const rows = Array.from(table.querySelectorAll("tr")).map((row) =>
|
||||
Array.from(row.querySelectorAll("th,td")).map((cell) => (cell.textContent || "").trim())
|
||||
)
|
||||
if (!rows.length) return ""
|
||||
const header = rows[0]
|
||||
const body = rows.slice(1)
|
||||
const headerLine = `| ${header.join(" | ")} |`
|
||||
const separatorLine = `| ${header.map(() => "---").join(" | ")} |`
|
||||
const bodyLines = body.map((row) => `| ${row.join(" | ")} |`)
|
||||
return [headerLine, separatorLine, ...bodyLines].join("\n")
|
||||
}
|
||||
|
||||
const convertBlockNode = (node: HTMLElement, indentLevel: number): string => {
|
||||
const tagName = node.tagName.toLowerCase()
|
||||
if (tagName === "h3") return `### ${node.textContent?.trim() || ""}`
|
||||
if (tagName === "h4") return `#### ${node.textContent?.trim() || ""}`
|
||||
if (tagName === "p") return convertInlineNodes(Array.from(node.childNodes)).trim()
|
||||
if (tagName === "ul" || tagName === "ol") return convertList(node as HTMLOListElement | HTMLUListElement, indentLevel)
|
||||
if (tagName === "pre") {
|
||||
const code = node.textContent || ""
|
||||
return `\`\`\`\n${code.trim()}\n\`\`\``
|
||||
}
|
||||
if (tagName === "table") return convertTable(node as HTMLTableElement)
|
||||
if (tagName === "div") {
|
||||
const hasTable = node.querySelector("table")
|
||||
const hasList = node.querySelector("ul,ol")
|
||||
const hasPre = node.querySelector("pre")
|
||||
if (hasTable || hasList || hasPre) {
|
||||
return Array.from(node.children)
|
||||
.map((child) => convertBlockNode(child as HTMLElement, indentLevel))
|
||||
.filter(Boolean)
|
||||
.join("\n\n")
|
||||
}
|
||||
const text = convertInlineNodes(Array.from(node.childNodes)).trim()
|
||||
return text
|
||||
}
|
||||
return convertInlineNodes(Array.from(node.childNodes)).trim()
|
||||
}
|
||||
|
||||
const buildMarkdown = (): string => {
|
||||
const contentRoot = contentRef.current
|
||||
if (!contentRoot) return ""
|
||||
const lines: string[] = []
|
||||
lines.push(`# ${title}`)
|
||||
lines.push("")
|
||||
lines.push(`最后更新:${lastUpdated}`)
|
||||
if (description) {
|
||||
const descriptionElement = contentRoot.querySelector("[data-docs-description]")
|
||||
if (descriptionElement) {
|
||||
const descriptionText = convertInlineNodes(Array.from(descriptionElement.childNodes)).trim()
|
||||
if (descriptionText) {
|
||||
lines.push("")
|
||||
lines.push(descriptionText)
|
||||
}
|
||||
}
|
||||
}
|
||||
lines.push("")
|
||||
|
||||
const sectionNodes = Array.from(contentRoot.querySelectorAll("[data-docs-section]")) as HTMLElement[]
|
||||
sectionNodes.forEach((section) => {
|
||||
const heading = section.querySelector("h2")
|
||||
if (heading?.textContent) {
|
||||
lines.push(`## ${heading.textContent.trim()}`)
|
||||
lines.push("")
|
||||
}
|
||||
const prose = section.querySelector("[data-docs-content]")
|
||||
if (prose) {
|
||||
const blocks = Array.from(prose.children)
|
||||
.map((child) => convertBlockNode(child as HTMLElement, 0))
|
||||
.filter(Boolean)
|
||||
if (blocks.length) {
|
||||
lines.push(blocks.join("\n\n"))
|
||||
lines.push("")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return lines.join("\n").replace(/\n{3,}/g, "\n\n").trim() + "\n"
|
||||
}
|
||||
|
||||
const handleCopyMarkdown = async () => {
|
||||
const markdown = buildMarkdown()
|
||||
if (!markdown) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(markdown)
|
||||
setHasCopied(true)
|
||||
setTimeout(() => setHasCopied(false), 2000)
|
||||
} catch {
|
||||
const textArea = document.createElement("textarea")
|
||||
textArea.value = markdown
|
||||
textArea.style.position = "fixed"
|
||||
textArea.style.opacity = "0"
|
||||
document.body.appendChild(textArea)
|
||||
textArea.focus()
|
||||
textArea.select()
|
||||
document.execCommand("copy")
|
||||
document.body.removeChild(textArea)
|
||||
setHasCopied(true)
|
||||
setTimeout(() => setHasCopied(false), 2000)
|
||||
}
|
||||
}
|
||||
|
||||
const scrollToSection = (e: React.MouseEvent<HTMLAnchorElement>, id: string) => {
|
||||
e.preventDefault()
|
||||
const element = document.getElementById(id)
|
||||
if (element) {
|
||||
const offset = 80
|
||||
const bodyRect = document.body.getBoundingClientRect().top
|
||||
const elementRect = element.getBoundingClientRect().top
|
||||
const elementPosition = elementRect - bodyRect
|
||||
const offsetPosition = elementPosition - offset
|
||||
|
||||
window.scrollTo({
|
||||
top: offsetPosition,
|
||||
behavior: "smooth"
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background relative selection:bg-primary/20">
|
||||
<motion.div
|
||||
className="fixed top-0 left-0 right-0 h-1 bg-primary origin-left z-50"
|
||||
style={{ scaleX }}
|
||||
/>
|
||||
|
||||
<div className="fixed inset-0 pointer-events-none">
|
||||
<div className="absolute top-0 left-1/4 w-[500px] h-[500px] bg-primary/5 rounded-full blur-[120px] -translate-y-1/2" />
|
||||
<div className="absolute bottom-0 right-1/4 w-[500px] h-[500px] bg-blue-500/5 rounded-full blur-[120px] translate-y-1/2" />
|
||||
</div>
|
||||
|
||||
<div ref={contentRef} className="container relative z-10 max-w-7xl mx-auto px-4 md:px-6 py-8 lg:py-16">
|
||||
<header className="mb-8 md:mb-12">
|
||||
<Link href="/" className="inline-block mb-8">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="gap-2 pl-0 text-muted-foreground hover:text-foreground hover:bg-transparent -ml-2"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
返回首页
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<h1 className="text-2xl md:text-3xl lg:text-5xl font-bold tracking-tight text-foreground mb-6">
|
||||
{title}
|
||||
</h1>
|
||||
<div className="flex flex-wrap items-center gap-4 text-xs text-muted-foreground mb-8">
|
||||
<span className="flex items-center gap-1.5 bg-muted/50 px-3 py-1 rounded-full border border-border/50">
|
||||
<FileText className="w-3.5 h-3.5" />
|
||||
{sections.length} 个内容
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 bg-muted/50 px-3 py-1 rounded-full border border-border/50">
|
||||
<Calendar className="w-3.5 h-3.5" />
|
||||
最后更新:{lastUpdated}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCopyMarkdown}
|
||||
className="h-8 gap-2 text-xs"
|
||||
>
|
||||
{hasCopied ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
{hasCopied ? "已复制" : "复制 Markdown"}
|
||||
</Button>
|
||||
</div>
|
||||
{description && (
|
||||
<div className="w-full p-4 md:p-6 rounded-2xl bg-muted/30 border border-border/50 backdrop-blur-sm overflow-hidden">
|
||||
<div data-docs-description className="text-sm md:text-base text-muted-foreground leading-relaxed m-0 break-all">
|
||||
{description}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</header>
|
||||
|
||||
<div className="grid lg:grid-cols-12 gap-8 lg:gap-12 relative">
|
||||
<aside className="hidden lg:block lg:col-span-3">
|
||||
<div className="sticky top-24">
|
||||
<div className="space-y-2 max-h-[calc(100vh-8rem)] overflow-y-auto pr-4 scrollbar-thin">
|
||||
<div className="text-sm font-semibold text-foreground mb-4 pl-3">目录</div>
|
||||
<nav className="space-y-1">
|
||||
{sections.map((section) => (
|
||||
<div key={section.value} className="space-y-1">
|
||||
<a
|
||||
href={`#${ section.value }`}
|
||||
onClick={(e) => scrollToSection(e, section.value)}
|
||||
className={cn(
|
||||
"group flex items-center justify-between py-2 px-3 text-sm rounded-lg transition-all duration-200",
|
||||
activeSection === section.value
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{section.title}</span>
|
||||
{activeSection === section.value && (
|
||||
<motion.div
|
||||
layoutId="active-dot"
|
||||
className="w-1.5 h-1.5 rounded-full bg-primary"
|
||||
/>
|
||||
)}
|
||||
</a>
|
||||
{section.children && (
|
||||
<div className="pl-4 space-y-1">
|
||||
{section.children.map((child) => (
|
||||
<a
|
||||
key={child.value}
|
||||
href={`#${ child.value }`}
|
||||
onClick={(e) => scrollToSection(e, child.value)}
|
||||
className={cn(
|
||||
"block py-1.5 px-3 text-xs rounded-lg transition-all duration-200 truncate",
|
||||
activeSection === child.value
|
||||
? "text-primary font-medium bg-primary/5"
|
||||
: "text-muted-foreground/80 hover:text-foreground hover:bg-muted/30"
|
||||
)}
|
||||
>
|
||||
{child.title}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="lg:col-span-9 lg:col-start-4 space-y-8 md:space-y-16 pb-20">
|
||||
{sections.map((section, index) => (
|
||||
<motion.section
|
||||
key={section.value}
|
||||
id={section.value}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "100px" }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="scroll-mt-24 group"
|
||||
data-docs-section
|
||||
>
|
||||
<div className="sticky top-0 z-20 flex items-center gap-4 py-6 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 mb-2 -mx-4 px-4 md:-mx-0 md:px-0">
|
||||
<span className="flex items-center justify-center w-8 h-8 rounded-lg bg-primary/10 text-primary text-sm font-bold opacity-50 group-hover:opacity-100 transition-opacity flex-shrink-0">
|
||||
{index + 1}
|
||||
</span>
|
||||
<h2 className="text-xl md:text-2xl font-semibold tracking-tight text-foreground group-hover:text-primary transition-colors duration-300">
|
||||
{section.title.replace(/^\d+\.\s*/, '')}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-docs-content
|
||||
className={cn(
|
||||
"prose prose-sm md:prose-base prose-neutral dark:prose-invert max-w-none",
|
||||
"prose-p:text-muted-foreground prose-p:leading-7",
|
||||
"prose-li:text-muted-foreground",
|
||||
"prose-strong:text-foreground",
|
||||
"pl-4 md:pl-12 border-l border-border/30 group-hover:border-primary/20 transition-colors duration-500"
|
||||
)}>
|
||||
{section.content}
|
||||
</div>
|
||||
</motion.section>
|
||||
))}
|
||||
|
||||
<div className="pt-16 border-t border-border mt-20">
|
||||
<div className="bg-muted/30 rounded-2xl p-8 text-center">
|
||||
<p className="text-muted-foreground text-sm mb-4">
|
||||
对这些内容有疑问?
|
||||
</p>
|
||||
<div className="flex justify-center gap-4">
|
||||
<Link href="https://github.com/linux-do/credit/issues" target="_blank">
|
||||
<Button variant="outline" className="h-9 text-xs">
|
||||
联系支持
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="https://linux.do" target="_blank">
|
||||
<Button variant="outline" className="h-9 text-xs">
|
||||
访问社区
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div >
|
||||
</div >
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { type PolicySection } from "./types"
|
||||
|
||||
/**
|
||||
* ------------------------------------------------------------------
|
||||
* 隐私政策 (PRIVACY POLICY)
|
||||
* ------------------------------------------------------------------
|
||||
*/
|
||||
export const privacySections: PolicySection[] = [
|
||||
{
|
||||
value: "collection-details",
|
||||
title: "1. 详细信息收集说明",
|
||||
content: (
|
||||
<div className="space-y-4 text-sm leading-relaxed">
|
||||
<p>我们仅遵循合法、正当、必要的原则,收集为您提供服务所必需的信息。我们的数据收集范围严格限制如下:</p>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<span className="font-semibold text-foreground">1.1 身份鉴权信息:</span>
|
||||
<p className="mt-1 text-muted-foreground">当您通过 LINUX DO Connect 登录时,我们会获取您的社区 OpenID(唯一标识符)、加密后的用户名及头像 URL。<strong>我们不收集您的手机号、真实姓名或身份证件信息。</strong></p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold text-foreground">1.2 服务日志信息:</span>
|
||||
<p className="mt-1 text-muted-foreground">为保障系统运行安全及满足法律合规要求,我们会自动收集您的操作日志,包括 IP 地址、访问日期和时间、API 调用记录、User-Agent(浏览器/设备类型)。</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold text-foreground">1.3 交易与资产信息:</span>
|
||||
<p className="mt-1 text-muted-foreground">若您使用支付功能,我们将记录您的商户订单号、交易金额、交易时间、交易状态摘要。这些信息是账务核对的必要依据。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "storage-security",
|
||||
title: "2. 数据存储与安全保护",
|
||||
content: (
|
||||
<div className="space-y-4 text-sm leading-relaxed">
|
||||
<p>我们深知数据安全的重要性,并采取业界领先的技术措施保护您的数据:</p>
|
||||
<ul className="list-disc pl-4 md:pl-5 space-y-2">
|
||||
<li><strong>存储地点:</strong>依照法规要求,我们收集和产生的用户个人信息,<strong>存储在独立</strong>的服务器上。我们不会将您的数据传输至境外管辖区。</li>
|
||||
<li><strong>加密技术:</strong>敏感数据(如 支付密码)在数据库中均采用高强度加密算法存储。数据传输全链路采用 SSL/TLS 1.3 协议进行加密,防止网络嗅探。</li>
|
||||
<li><strong>隔离机制:</strong>本平台数据与外部网络物理隔离,且独立于 LINUX DO 社区论坛主数据库,确保单一系统故障不会波及全局数据安全。</li>
|
||||
<li><strong>访问控制:</strong>我们实行严格的最小权限原则(Least Privilege),仅有核心运维人员经授权后方可访问必要的维护数据,且所有操作均有审计日志留存。</li>
|
||||
</ul>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "usage-rules",
|
||||
title: "3. 信息使用规范",
|
||||
content: (
|
||||
<div className="space-y-4 text-sm leading-relaxed">
|
||||
<p>我们收集的信息将仅用于以下目的:</p>
|
||||
<ul className="list-disc pl-4 md:pl-5 space-y-1">
|
||||
<li><strong>身份识别:</strong>用于确认您的社区身份,展示您的个人中心数据。</li>
|
||||
<li><strong>业务功能:</strong>处理您的支付指令、API 请求、回调通知及账单生成。</li>
|
||||
<li><strong>安全风控:</strong>利用 IP 及行为日志进行反作弊、反欺诈分析,识别恶意攻击行为,保护平台及其他用户的安全。</li>
|
||||
<li><strong>客户支持:</strong>在您发起工单或申诉时,查询相关日志以协助您解决问题。</li>
|
||||
</ul>
|
||||
<p><strong>禁止用途:</strong>我们承诺<strong>绝不</strong>利用您的数据进行用户画像分析、个性化广告推送或商业营销。</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "sharing-disclosure",
|
||||
title: "4. 信息共享与对外披露",
|
||||
content: (
|
||||
<div className="space-y-4 text-sm leading-relaxed">
|
||||
<p><strong>4.1 共享原则:</strong>我们坚持<strong>数据零共享</strong>策略。除以下极端情况外,我们不会向任何第三方(包括且不限于关联公司、支付宝、微信、银行、广告商)共享您的个人信息:</p>
|
||||
<ul className="list-disc pl-4 md:pl-5 space-y-1 text-muted-foreground">
|
||||
<li>事先获得您的明确授权或同意;</li>
|
||||
<li>根据适用的法律法规、法律程序的要求、强制性的行政或司法要求所必须的情况下进行提供。</li>
|
||||
</ul>
|
||||
<p><strong>4.2 转让与公开披露:</strong>我们不会将您的个人信息转让给任何公司、组织和个人。我们仅在法律法规强制要求,或为了保护平台及用户与公众的人身财产安全免受侵害时,才会公开披露您的信息。</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "user-rights",
|
||||
title: "5. 您的权利与数据管理",
|
||||
content: (
|
||||
<div className="space-y-4 text-sm leading-relaxed">
|
||||
<p>依照《中华人民共和国个人信息保护法》,您对您的个人信息享有完整的控制权:</p>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<span className="font-semibold text-foreground">5.1 查阅与复制权:</span>
|
||||
<p className="mt-1 text-muted-foreground">您可以随时登录开发者后台,查阅您的概览信息、API Key 状态及历史交易账单。您可以通过“导出账单”功能获取您的数据副本。</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold text-foreground">5.2 删除与遗忘权:</span>
|
||||
<p className="mt-1 text-muted-foreground">若您决定停止使用本服务,在结清所有应付费用及余额后,您可以申请<strong>注销账户</strong>。注销后,我们将立即删除您的所有敏感信息或进行匿名化处理,法律法规规定需保留的日志除外。</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold text-foreground">5.3 纠正与更正权:</span>
|
||||
<p className="mt-1 text-muted-foreground">若您发现您的信息有误,您有权要求我们更正或补充。您可以在设置页面直接修改您的信息,或通过客服提交工单。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "policy-update",
|
||||
title: "6. 政策更新与通知",
|
||||
content: (
|
||||
<div className="space-y-4 text-sm leading-relaxed">
|
||||
<p>随着业务的发展或法律法规的变动,我们可能会适时修订本《隐私政策》。</p>
|
||||
<p>当条款发生重大变更时(例如收集范围扩大、使用目的改变),我们会通过站内信、公告或弹窗等显著方式通知您。若您在政策更新后继续使用本服务,即表示您同意接受更新后的隐私政策约束。</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,115 @@
|
||||
import { type PolicySection } from "./types"
|
||||
|
||||
export const TERMS_LAST_UPDATED = "2025-12-22"
|
||||
|
||||
/**
|
||||
* ------------------------------------------------------------------
|
||||
* 服务条款 (TERMS OF SERVICE)
|
||||
* ------------------------------------------------------------------
|
||||
*/
|
||||
export const termsSections: PolicySection[] = [
|
||||
{
|
||||
value: "contract-establishment",
|
||||
title: "1. 缔约申明与服务综述",
|
||||
content: (
|
||||
<div className="space-y-4 text-sm leading-relaxed">
|
||||
<p><strong>1.1 缔约主体:</strong>本《服务协议》(以下简称“本协议”)是您(以下亦称“社区用户”、“开发者”、“消费方”或“服务方”)与 LINUX DO Credit 平台运营团队(以下简称“平台”、“我们”)之间关于使用平台服务所订立的具有法律约束力的契约。</p>
|
||||
<p><strong>1.2 审慎阅读:</strong>请您务必审慎阅读、充分理解各条款内容,特别是<strong>免除或者限制责任的条款、争议解决和法律适用条款</strong>。各免责或限责条款将以粗体标识,您应重点阅读。如您不同意本协议的任何内容,请立即停止注册或使用本服务。</p>
|
||||
<p><strong>1.3 协议构成:</strong>本协议内容包括协议正文及所有我们已经发布或将来可能发布的各类规则、声明、说明。所有规则为本协议不可分割的组成部分,与协议正文具有同等法律效力。</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "service-definition",
|
||||
title: "2. 服务定义与性质界定",
|
||||
content: (
|
||||
<div className="space-y-4 text-sm leading-relaxed">
|
||||
<p><strong>2.1 社区技术服务:</strong>LINUX DO Credit 是基于 LINUX DO 社区生态构建的独立价值交换协议与技术系统。我们仅提供 API 接口调用、数据路由、账单管理等<strong>纯技术服务</strong>。</p>
|
||||
<p><strong>2.2 非金融机构申明:</strong></p>
|
||||
<ul className="list-disc pl-4 md:pl-5 space-y-2">
|
||||
<li><strong>非银行机构:</strong>我们不是商业银行、持牌支付机构(如支付宝、微信支付、银联)或清算机构。</li>
|
||||
<li><strong>不提供资金沉淀:</strong>平台不设立资金池,不提供真实法币存取款、转账汇款或支付结算服务。所有涉及资金流转的行为均发生于社区用户与社区支付渠道之间,不涉及真实货币。</li>
|
||||
<li><strong>不提供金融服务:</strong>平台不提供任何金融服务,包括但不限于贷款、融资、投资、理财、保险等金融服务。</li>
|
||||
<li><strong>不提供积分兑现:</strong>平台不提供任何积分兑换服务,包括但不限于积分兑换为真实货币、积分兑换为实物商品、积分兑换为服务等。</li>
|
||||
<li><strong>不提供真实货币交易:</strong>平台不提供任何真实货币交易服务,包括但不限于真实货币交易为积分、真实货币交易为虚拟资产、真实货币交易为服务等。</li>
|
||||
</ul>
|
||||
<p><strong>2.3 服务限定:</strong>本平台建议用于仅支持虚拟商品、软件授权、技术咨询、会员订阅等<strong>无实物交付</strong>的场景。关于实物电商、物流发货或涉及线下履约的商业场景造成的任何后果我们概不负责,请妥善保管好自己的个人财产,谨防上当受骗。</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "account-specifications",
|
||||
title: "3. 账号注册与使用规范",
|
||||
content: (
|
||||
<div className="space-y-4 text-sm leading-relaxed">
|
||||
<p><strong>3.1 账号体系:</strong>本平台采用 LINUX DO Connect (OAuth) 授权登录体系。您必须拥有合法、有效的 LINUX DO 社区账号方可使用本服务。您的平台账号权益(包括但不限于信誉分、等级)与社区账号严格绑定。</p>
|
||||
<p><strong>3.2 匿名性与真实性:</strong></p>
|
||||
<ul className="list-disc pl-4 md:pl-5 space-y-2">
|
||||
<li><strong>无需实名:</strong>我们尊重您的隐私,不强制要求您提供居民身份证、护照或营业执照进行实名认证。</li>
|
||||
<li><strong>操作真实性:</strong>您承诺注册和使用的账号是您本人操作。严禁恶意注册、挂机脚本、自动化程序注册等破坏平台公平性的行为。</li>
|
||||
</ul>
|
||||
<p><strong>3.3 账号安全责任:</strong>您应妥善保管您账号的支付密码、Client ID 和 Client Secret。<strong>因您保管不善可能导致账号被他人非法使用、资金损失或数据泄露的责任,由您自行承担。</strong>如发现账号异常,请立即通知我们进行冻结。</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "user-conduct",
|
||||
title: "4. 用户行为准则(负面清单)",
|
||||
content: (
|
||||
<div className="space-y-4 text-sm leading-relaxed">
|
||||
<p>您在使用本服务时,必须严格遵守《中华人民共和国网络安全法》、《计算机信息网络国际联网安全保护管理办法》等法律法规。<strong>严禁利用本平台从事以下活动(“红线条款”):</strong></p>
|
||||
<div className="bg-red-500/5 border border-red-500/20 rounded-lg p-3 md:p-4 space-y-3">
|
||||
<ul className="list-disc pl-4 md:pl-5 space-y-2 text-red-600 dark:text-red-400 font-medium">
|
||||
<li><strong>危害国家安全:</strong>反对宪法所确定的基本原则、危害国家安全、泄露国家秘密、颠覆国家政权、破坏国家统一的;</li>
|
||||
<li><strong>非法信息服务:</strong>黑客攻击工具、DDoS 攻击服务、服务器爆破等其他非法信息服务平台;</li>
|
||||
<li><strong>黄赌毒关联:</strong>制作、复制、发布、传播淫秽、色情、赌博、暴力、凶杀、恐怖或者教唆犯罪的;</li>
|
||||
<li><strong>侵犯知识产权:</strong>销售盗版软件、盗版影视资源、非法游戏外挂、私服、黑号、社工库数据等;</li>
|
||||
<li><strong>欺诈与虚假:</strong>进行电信诈骗、金融诈骗、传销、虚假广告虚假交易等;</li>
|
||||
<li><strong>其他违法信息:</strong>涉及散布谣言、宣扬邪教/封建迷信、侮辱/诽谤他人、侵害他人合法权益的。</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p><strong>违约处理:</strong>一旦发现您违反上述规定,平台有权不经通知<strong>立即永久封禁您的账号、拦截所有 API 请求、冻结账户内所有关联价值,并依法向公安机关、网安部门移交相关线索。</strong></p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "virtual-assets",
|
||||
title: "5. 虚拟资产与交易规则",
|
||||
content: (
|
||||
<div className="space-y-4 text-sm leading-relaxed">
|
||||
<p><strong>5.1 资产性质:</strong>平台内流转的“余额”、“积分”等均为<strong>社区虚拟积分</strong>,仅代表您在社区生态内的活跃度或贡献值。它们<strong>不具有任何货币属性</strong>,严禁兑换为法定货币,也不可用于任何非平台许可的商业交易。</p>
|
||||
<p><strong>5.2 交易不可逆:</strong>鉴于网络技术的实时性,<strong>一旦积分消耗或划转指令被执行,该操作即不可撤销。</strong>请您在确认相关积分活动前,务必仔细核对服务方信息。</p>
|
||||
<p><strong>5.3 规则说明:</strong>为营造良好的社区积分环境,平台有权收取一定的服务费(以积分为结算单位)进行调控,具体规则以控制台公示为准。平台保留根据社区运营状况调整积分规则的权利。</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "liability-limitation",
|
||||
title: "6. 免责声明与不可抗力",
|
||||
content: (
|
||||
<div className="space-y-4 text-sm leading-relaxed">
|
||||
<p><strong>6.1 基础免责:</strong>本平台服务按“现状”(As-Is)及“现有”(As-Available)状态提供。我们不保证服务一定能满足您的要求,也不保证服务不会中断,对服务的及时性、安全性、准确性都不作担保。</p>
|
||||
<p><strong>6.2 不可抗力:</strong>对于因以下原因导致的服务中断、数据丢失或账号损失,平台不承担赔偿责任:</p>
|
||||
<ul className="list-disc pl-4 md:pl-5 space-y-1 text-muted-foreground">
|
||||
<li>自然灾害(台风、地震、海啸、洪水等);</li>
|
||||
<li>政府行为、法律法规或政策调整、行政命令;</li>
|
||||
<li>电信部门技术调整、通讯线路中断、海底光缆故障;</li>
|
||||
<li>黑客攻击、计算机病毒侵入或发作、技术性故障;</li>
|
||||
<li>社区维护、系统升级(我们将尽可能提前公告)。</li>
|
||||
</ul>
|
||||
<p><strong>6.3 责任上限:</strong>在任何情况下,平台对您所承担的违约赔偿责任总额不超过您在违约行为发生前 1 个月内向平台支付的费用总额。</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "governing-law",
|
||||
title: "7. 法律适用与争议解决",
|
||||
content: (
|
||||
<div className="space-y-4 text-sm leading-relaxed">
|
||||
<p><strong>7.1 法律适用:</strong>本协议的订立、执行、解释及争议的解决均适用<strong>中华人民共和国法律</strong>(不包括港澳台地区法律及冲突法)。</p>
|
||||
<p><strong>7.2 争议解决:</strong>若您和平台发生任何争议或纠纷,首先应友好协商解决;协商不成的,您同意将纠纷或争议提交至<strong>平台运营团队所在地有管辖权的人民法院</strong>管辖。</p>
|
||||
<p><strong>7.3 协议变更:</strong>我们有权根据法律法规变化或业务发展需要修改本协议。变更后的协议将在平台公示,自公示之日起生效。若您继续使用服务,视为您已接受修订后的协议。</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,11 @@
|
||||
import { type ReactNode } from "react"
|
||||
|
||||
export type PolicySection = {
|
||||
value: string
|
||||
title: string
|
||||
content: ReactNode
|
||||
children?: {
|
||||
value: string
|
||||
title: string
|
||||
}[]
|
||||
}
|
||||
@@ -0,0 +1,760 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { Spinner } from "@/components/ui/spinner"
|
||||
import { AlertTriangle, X, Layers, Banknote, TicketSlash } from "lucide-react"
|
||||
import { formatDateTime } from "@/lib/utils"
|
||||
import type { Order, DisputeWithOrder, Dispute } from "@/lib/services"
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { DisputeService } from "@/lib/services"
|
||||
import { toast } from "sonner"
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { usePublicConfig } from "@/hooks/use-public-config"
|
||||
import { useTransaction } from "@/contexts/transaction-context"
|
||||
import { useUser } from "@/contexts/user-context"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { typeConfig } from "@/components/common/general/table-filter"
|
||||
import { VisuallyHidden } from "@radix-ui/react-visually-hidden"
|
||||
|
||||
type DisputeViewer = 'payer' | 'payee'
|
||||
|
||||
/**
|
||||
* 活动详情弹窗
|
||||
*/
|
||||
export function OrderDetailDialog({ order }: { order: Order }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-1 text-xs rounded-full"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<Banknote className="size-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p>查看详情</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent showCloseButton={false} className="bg-transparent border-0 shadow-none p-0 w-100">
|
||||
<VisuallyHidden>
|
||||
<DialogHeader>
|
||||
<DialogTitle>积分流转详情</DialogTitle>
|
||||
<DialogDescription>查看积分活动的详细信息</DialogDescription>
|
||||
</DialogHeader>
|
||||
</VisuallyHidden>
|
||||
<div className="p-5 space-y-5 bg-card border border-border/50 rounded-lg shadow-xl">
|
||||
<div className="flex flex-col items-center space-y-2 pb-4 border-b-2 border-dashed border-border/50">
|
||||
<h3 className="font-bold text-2xl tracking-wider uppercase">{order.app_name || 'RECEIPT'}</h3>
|
||||
<p className="text-[10px] text-muted-foreground font-mono uppercase tracking-widest">
|
||||
{formatDateTime(order.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center py-2">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wider mb-1">Total Credits</p>
|
||||
<div className="text-4xl font-black tracking-tighter flex items-start justify-center gap-1">
|
||||
{parseFloat(order.amount).toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-0 text-sm border-y-2 border-dashed border-border/50 py-4">
|
||||
<div className="flex justify-between items-center py-2 border-b border-dashed border-border/30 last:border-0">
|
||||
<span className="text-muted-foreground text-xs uppercase font-medium">编号</span>
|
||||
<span className="font-mono text-xs font-medium">{order.order_no}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-2 border-b border-dashed border-border/30 last:border-0">
|
||||
<span className="text-muted-foreground text-xs uppercase font-medium">类型</span>
|
||||
<span className="text-xs font-medium">{typeConfig[order.type]?.label || order.type}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-2 border-b border-dashed border-border/30 last:border-0">
|
||||
<span className="text-muted-foreground text-xs uppercase font-medium">消费方</span>
|
||||
<span className="text-xs font-medium">{order.payer_username}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-2 border-b border-dashed border-border/30 last:border-0">
|
||||
<span className="text-muted-foreground text-xs uppercase font-medium">服务方</span>
|
||||
<span className="text-xs font-medium">{order.payee_username}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-2 border-b border-dashed border-border/30 last:border-0">
|
||||
<span className="text-muted-foreground text-xs uppercase font-medium">备注</span>
|
||||
<span className="text-xs font-medium">{order.remark}</span>
|
||||
</div>
|
||||
{(order.status === 'success' || order.status === 'refund') && (
|
||||
<div className="flex justify-between items-center py-2 border-b border-dashed border-border/30 last:border-0">
|
||||
<span className="text-muted-foreground text-xs uppercase font-medium">时间</span>
|
||||
<span className="font-mono text-xs">{formatDateTime(order.trade_time)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pt-2 flex flex-col items-center space-y-3">
|
||||
<div className="h-8 w-full max-w-[200px] flex items-end justify-between opacity-40">
|
||||
{[...Array(30)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="bg-foreground w-[2px]"
|
||||
style={{ height: `${ Math.max(40, Math.random() * 100) }%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground font-mono tracking-wider text-center">
|
||||
LINUX DO Credit
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 发起争议弹窗
|
||||
*/
|
||||
export function CreateDisputeDialog({ order, onSuccess }: { order: Order; onSuccess?: () => void }) {
|
||||
const isMobile = useIsMobile()
|
||||
const { config: publicConfig, loading: configLoading, error: configError } = usePublicConfig()
|
||||
const { updateOrderStatus } = useTransaction()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [reason, setReason] = useState("")
|
||||
|
||||
const resetForm = () => {
|
||||
setReason("")
|
||||
}
|
||||
|
||||
const handleButtonClick = async () => {
|
||||
if (configLoading) {
|
||||
try {
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
if (configLoading) {
|
||||
toast.error('配置加载中', { description: '请稍候再试' })
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
toast.error('配置加载失败', { description: '请刷新页面重试' })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (configError || !publicConfig) {
|
||||
toast.error('无法获取配置', { description: configError?.message || '请刷新页面重试' })
|
||||
return
|
||||
}
|
||||
|
||||
const tradeTime = new Date(order.trade_time)
|
||||
const expiryTime = new Date(tradeTime.getTime() + publicConfig.dispute_time_window_hours * 60 * 60 * 1000)
|
||||
const now = new Date()
|
||||
|
||||
if (now > expiryTime) {
|
||||
toast.error('无法发起争议', { description: '已超过最晚发起争议时间' })
|
||||
return
|
||||
}
|
||||
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
if (newOpen && !loading) {
|
||||
resetForm()
|
||||
}
|
||||
setOpen(newOpen)
|
||||
}
|
||||
|
||||
const handleCreateDispute = async () => {
|
||||
if (!reason.trim()) {
|
||||
toast.error('表单验证失败', { description: '请填写争议原因' })
|
||||
return
|
||||
}
|
||||
|
||||
if (reason.length > 100) {
|
||||
toast.error('表单验证失败', { description: '争议原因不能超过 100 个字符' })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
const dispute = await DisputeService.createDispute({ order_id: order.id, reason: reason.trim() })
|
||||
toast.success('争议已发起', { description: '请等待服务方处理' })
|
||||
updateOrderStatus(order.id, {
|
||||
status: 'disputing',
|
||||
dispute_id: dispute.id
|
||||
})
|
||||
setOpen(false)
|
||||
onSuccess?.()
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : '发起争议失败'
|
||||
toast.error('发起争议失败', { description: errorMessage })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-1 text-xs rounded-full text-muted-foreground hover:text-foreground"
|
||||
onClick={handleButtonClick}
|
||||
>
|
||||
<AlertTriangle className="size-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p>发起争议</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>发起争议</DialogTitle>
|
||||
<DialogDescription>
|
||||
如果您对该积分活动有疑问,可以发起争议,服务方将在规定时间内处理。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="reason" className={isMobile ? "text-sm" : ""}>
|
||||
争议原因
|
||||
</Label>
|
||||
<Textarea
|
||||
id="reason"
|
||||
placeholder="请详细描述您遇到的问题..."
|
||||
maxLength={100}
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
disabled={loading}
|
||||
className={isMobile ? "text-sm" : ""}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground text-right">
|
||||
{reason.length}/100
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="ghost" disabled={loading} className="h-8 text-xs">
|
||||
取消
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
onClick={(e) => { e.preventDefault(); handleCreateDispute() }}
|
||||
disabled={loading}
|
||||
className="bg-primary h-8 text-xs"
|
||||
>
|
||||
{loading ? <><Spinner /> 提交中</> : '确认发起'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消争议弹窗
|
||||
*/
|
||||
export function CancelDisputeDialog({ order, onSuccess }: { order: Order; onSuccess?: () => void }) {
|
||||
const { updateOrderStatus } = useTransaction()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleCancelDispute = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
|
||||
if (!order.dispute_id) {
|
||||
toast.error('取消争议失败', { description: '未找到争议记录' })
|
||||
return
|
||||
}
|
||||
|
||||
await DisputeService.closeDispute({ dispute_id: order.dispute_id })
|
||||
toast.success('争议已取消', { description: '争议已成功取消' })
|
||||
updateOrderStatus(order.id, { status: 'success' })
|
||||
setOpen(false)
|
||||
onSuccess?.()
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : '取消争议失败'
|
||||
toast.error('取消争议失败', { description: errorMessage })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-1 text-xs rounded-full text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p>争议正在进行中,点击取消</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>争议取消</DialogTitle>
|
||||
<DialogDescription>
|
||||
您确定取消当前的争议吗?取消后争议将恢复正常。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="ghost" disabled={loading} className="h-8 text-xs">
|
||||
取消
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
onClick={(e) => { e.preventDefault(); handleCancelDispute() }}
|
||||
disabled={loading}
|
||||
className="bg-primary h-8 text-xs"
|
||||
>
|
||||
{loading ? <><Spinner /> 取消中</> : '确认取消'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 查看争议历史弹窗
|
||||
*/
|
||||
export function ViewDisputeHistoryDialog({ order, viewer = 'payer' }: { order: Order; viewer?: DisputeViewer }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [disputeHistory, setDisputeHistory] = useState<Dispute | null>(null)
|
||||
const [fetchingHistory, setFetchingHistory] = useState(false)
|
||||
|
||||
const disputeConfig = React.useMemo(() => {
|
||||
switch (order.status) {
|
||||
case 'refused':
|
||||
return {
|
||||
tooltip: '争议已拒绝,点击查看',
|
||||
timelineText: '服务方驳回争议',
|
||||
isRed: true,
|
||||
showTimestamp: true,
|
||||
showContent: false,
|
||||
content: null
|
||||
}
|
||||
case 'refund':
|
||||
return {
|
||||
tooltip: '争议已退款',
|
||||
timelineText: '服务方已退款',
|
||||
isRed: false,
|
||||
showTimestamp: true,
|
||||
showContent: true,
|
||||
content: '退款已完成'
|
||||
}
|
||||
case 'disputing':
|
||||
return {
|
||||
tooltip: '争议处理中,点击查看',
|
||||
timelineText: '争议进行中,等待服务方处理',
|
||||
isRed: false,
|
||||
showTimestamp: false,
|
||||
showContent: false,
|
||||
content: null
|
||||
}
|
||||
}
|
||||
}, [order.status])
|
||||
|
||||
if (!disputeConfig) return null
|
||||
|
||||
const resetForm = () => {
|
||||
setDisputeHistory(null)
|
||||
}
|
||||
|
||||
const fetchDisputeHistory = async () => {
|
||||
try {
|
||||
setFetchingHistory(true)
|
||||
|
||||
if (!order.dispute_id) {
|
||||
toast.error('获取争议记录失败', { description: '未找到争议记录' })
|
||||
return
|
||||
}
|
||||
|
||||
const params = {
|
||||
page: 1,
|
||||
page_size: 1,
|
||||
dispute_id: order.dispute_id
|
||||
}
|
||||
|
||||
const res = viewer === 'payee'
|
||||
? await DisputeService.listMerchantDisputes(params)
|
||||
: await DisputeService.listDisputes(params)
|
||||
|
||||
if (res.disputes && res.disputes.length > 0) {
|
||||
setDisputeHistory(res.disputes[0])
|
||||
} else {
|
||||
toast.error('未找到争议记录')
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : '获取争议记录失败'
|
||||
toast.error('获取争议记录失败', { description: errorMessage })
|
||||
} finally {
|
||||
setFetchingHistory(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
if (newOpen) {
|
||||
setOpen(true)
|
||||
resetForm()
|
||||
fetchDisputeHistory()
|
||||
} else {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
const parseDisputeReason = (fullReason: string) => {
|
||||
const match = fullReason.match(/^(.*?)\s*\[(?:服务方|受托方)拒绝理由:\s*(.*?)\]$/)
|
||||
if (match) {
|
||||
return { userReason: match[1].trim(), merchantReason: match[2].trim() }
|
||||
}
|
||||
return { userReason: fullReason, merchantReason: null }
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-1 text-xs rounded-full text-orange-600 hover:text-orange-700 hover:bg-orange-50"
|
||||
onClick={() => handleOpenChange(true)}
|
||||
>
|
||||
<Layers className="size-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p>{disputeConfig.tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>争议详情</DialogTitle>
|
||||
<DialogDescription>查看争议处理记录</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{fetchingHistory ? (
|
||||
<div className="py-8 flex justify-center">
|
||||
<Spinner className="size-6" />
|
||||
</div>
|
||||
) : disputeHistory ? (
|
||||
<div className="py-4 relative pl-4 border-l border-border/50 space-y-8 ml-2">
|
||||
<div className="relative">
|
||||
<div className="absolute -left-[21px] top-1 h-2.5 w-2.5 rounded-full bg-primary ring-4 ring-background" />
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">消费方发起争议</span>
|
||||
<span className="text-xs text-muted-foreground">{formatDateTime(disputeHistory.created_at)}</span>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground bg-muted/50 p-3 rounded-md">
|
||||
{parseDisputeReason(disputeHistory.reason).userReason}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div className={`absolute -left-[21px] top-1 h-2.5 w-2.5 rounded-full ${ disputeConfig.isRed ? 'bg-destructive' : 'bg-primary' } ring-4 ring-background`} />
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={`text-sm font-medium ${ disputeConfig.isRed ? 'text-destructive' : '' }`}>
|
||||
{disputeConfig.timelineText}
|
||||
</span>
|
||||
{disputeConfig.showTimestamp && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDateTime(disputeHistory.updated_at)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{order.status === 'refused' && (
|
||||
<div className="text-sm text-muted-foreground bg-destructive/5 border border-destructive/10 p-3 rounded-md">
|
||||
{parseDisputeReason(disputeHistory.reason).merchantReason || "未提供拒绝理由"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{order.status === 'refund' && disputeConfig.showContent && disputeConfig.content && (
|
||||
<div className="text-sm text-muted-foreground bg-muted/50 p-3 rounded-md">
|
||||
{disputeConfig.content}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-8 text-center text-muted-foreground text-sm">
|
||||
无法加载争议记录
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="ghost" className="h-8 text-xs">
|
||||
关闭
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 退款审核活动弹窗
|
||||
*/
|
||||
export function RefundReviewDialog({ order, onSuccess }: { order: Order; onSuccess?: () => void }) {
|
||||
const { updateOrderStatus } = useTransaction()
|
||||
const { refetch: refetchUser } = useUser()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [action, setAction] = useState<'refund' | 'closed' | null>(null)
|
||||
const [reason, setReason] = useState("")
|
||||
|
||||
const [disputeInfo, setDisputeInfo] = useState<DisputeWithOrder | null>(null)
|
||||
const [fetchingDispute, setFetchingDispute] = useState(false)
|
||||
|
||||
const resetForm = () => {
|
||||
setAction(null)
|
||||
setReason("")
|
||||
setDisputeInfo(null)
|
||||
}
|
||||
|
||||
const fetchDisputeInfo = async () => {
|
||||
try {
|
||||
setFetchingDispute(true)
|
||||
|
||||
if (!order.dispute_id) {
|
||||
toast.error('获取争议详情失败', { description: '未找到争议记录' })
|
||||
return
|
||||
}
|
||||
|
||||
const res = await DisputeService.listMerchantDisputes({
|
||||
page: 1,
|
||||
page_size: 1,
|
||||
dispute_id: order.dispute_id
|
||||
})
|
||||
|
||||
if (res.disputes && res.disputes.length > 0) {
|
||||
setDisputeInfo(res.disputes[0])
|
||||
} else {
|
||||
toast.error('获取争议详情失败', { description: '未找到争议记录' })
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : '获取争议详情失败'
|
||||
toast.error('获取争议详情失败', { description: errorMessage })
|
||||
} finally {
|
||||
setFetchingDispute(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
if (newOpen) {
|
||||
setOpen(true)
|
||||
resetForm()
|
||||
fetchDisputeInfo()
|
||||
} else if (!loading) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!action) {
|
||||
toast.error('请选择处理方式')
|
||||
return
|
||||
}
|
||||
|
||||
if (action === 'closed' && !reason.trim()) {
|
||||
toast.error('请填写拒绝原因')
|
||||
return
|
||||
}
|
||||
|
||||
if (reason.length > 100) {
|
||||
toast.error('拒绝理由不能超过 100 个字符')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
|
||||
if (!order.dispute_id) {
|
||||
toast.error('处理失败', { description: '未找到争议记录' })
|
||||
return
|
||||
}
|
||||
|
||||
await DisputeService.refundReview({
|
||||
dispute_id: order.dispute_id,
|
||||
status: action,
|
||||
reason: action === 'closed' ? reason.trim() : undefined,
|
||||
})
|
||||
|
||||
toast.success('处理成功', {
|
||||
description: action === 'refund' ? '已同意积分退回,争议结束' : '已拒绝积分退回,此积分活动正常进行'
|
||||
})
|
||||
|
||||
updateOrderStatus(order.id, {
|
||||
status: action === 'refund' ? 'refund' : 'refused'
|
||||
})
|
||||
|
||||
setOpen(false)
|
||||
refetchUser()
|
||||
onSuccess?.()
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : '处理争议失败'
|
||||
toast.error('处理争议失败', { description: errorMessage })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-1 text-xs rounded-full text-yellow-600 hover:text-yellow-700 hover:bg-yellow-50"
|
||||
onClick={() => {
|
||||
setOpen(true)
|
||||
fetchDisputeInfo()
|
||||
}}
|
||||
>
|
||||
<AlertTriangle className="size-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p>处理争议</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>处理争议</DialogTitle>
|
||||
<DialogDescription>
|
||||
请审核此争议。您可以选择同意或拒绝退回积分。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{fetchingDispute ? (
|
||||
<div className="py-8 flex justify-center">
|
||||
<Spinner className="size-6" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 py-2">
|
||||
{disputeInfo && (
|
||||
<div className="rounded-md bg-muted/40 p-3 text-sm">
|
||||
<div className="flex items-center gap-2 mb-2 text-muted-foreground text-xs">
|
||||
<span>发起争议理由</span>
|
||||
<span>•</span>
|
||||
<span>{formatDateTime(disputeInfo.created_at)}</span>
|
||||
</div>
|
||||
<p className="text-sm font-medium leading-relaxed">
|
||||
{disputeInfo.reason}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
className={`h-auto py-2 flex flex-col gap-1 hover:bg-transparent hover:border-primary ${ action === 'refund' && 'border-primary text-primary' }`}
|
||||
onClick={() => setAction('refund')}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Banknote className="h-4 w-4" />
|
||||
<span className="font-semibold">同意积分退回</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground font-normal">积分全额退回,争议结束</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className={`h-auto py-2 flex flex-col gap-1 hover:bg-transparent hover:border-destructive ${ action === 'closed' && 'border-destructive text-destructive' }`}
|
||||
onClick={() => setAction('closed')}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<TicketSlash className="h-4 w-4" />
|
||||
<span className="font-semibold">拒绝积分退回</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground font-normal">拒绝积分退回,此积分活动正常进行</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{action === 'closed' && (
|
||||
<div className="space-y-2 animate-in fade-in slide-in-from-top-2 pt-2">
|
||||
<Label htmlFor="reject-reason" className="text-xs font-medium">
|
||||
拒绝原因 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="reject-reason"
|
||||
placeholder="请说明拒绝退回的原因..."
|
||||
maxLength={100}
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
disabled={loading}
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground text-right">
|
||||
{reason.length}/100
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="ghost" disabled={loading} className="h-8 text-xs">
|
||||
取消
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
onClick={(e) => { e.preventDefault(); handleSubmit() }}
|
||||
disabled={loading || !action}
|
||||
className="bg-primary h-8 text-xs"
|
||||
>
|
||||
{loading ? <><Spinner /> 提交中</> : '确认处理'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
|
||||
import * as React from "react"
|
||||
import { useState, useEffect } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { TablePagination, FilterSelect } from "@/components/common/general/table-filter"
|
||||
import { Inbox, ListRestart, Gift, Coins, Check, Copy, ExternalLink } from "lucide-react"
|
||||
import { EmptyStateWithBorder } from "@/components/layout/empty"
|
||||
import { LoadingStateWithBorder } from "@/components/layout/loading"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { formatDateTime } from "@/lib/utils"
|
||||
import type { RedEnvelope } from "@/lib/services"
|
||||
|
||||
export interface ListDataProps {
|
||||
fetchData: (params: { page: number, page_size: number, type: string }) => Promise<{ list: RedEnvelope[], total: number }>
|
||||
tabs: { value: string, label: string, color?: string }[]
|
||||
defaultTab?: string
|
||||
pageSize?: number
|
||||
className?: string
|
||||
emptyState?: (tab: string) => React.ReactNode
|
||||
refreshTrigger?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 红包列表数据组件
|
||||
*
|
||||
* 提供分页、标签筛选、数据加载状态管理和网格布局,专门用于展示红包列表
|
||||
*/
|
||||
export function ListData({
|
||||
fetchData,
|
||||
tabs,
|
||||
defaultTab,
|
||||
pageSize = 20,
|
||||
className,
|
||||
emptyState,
|
||||
refreshTrigger = 0
|
||||
}: ListDataProps) {
|
||||
const [activeTab, setActiveTab] = useState<string>(defaultTab || tabs[0]?.value)
|
||||
|
||||
/* 数据状态 */
|
||||
const [items, setItems] = useState<RedEnvelope[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [page, setPage] = useState(1)
|
||||
const [currentPageSize, setCurrentPageSize] = useState(pageSize)
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null)
|
||||
|
||||
/* 缓存页码 */
|
||||
const loadData = async (type: string, p: number, s: number) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await fetchData({ page: p, page_size: s, type })
|
||||
setItems(result.list)
|
||||
setTotal(result.total)
|
||||
} catch (error) {
|
||||
toast.error('加载数据失败')
|
||||
console.error(error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
/* 初始加载和Tab切换 */
|
||||
useEffect(() => {
|
||||
setPage(1)
|
||||
loadData(activeTab, 1, currentPageSize)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeTab, refreshTrigger])
|
||||
|
||||
const handlePageChange = (p: number) => {
|
||||
setPage(p)
|
||||
loadData(activeTab, p, currentPageSize)
|
||||
}
|
||||
|
||||
const handlePageSizeChange = (s: number) => {
|
||||
setCurrentPageSize(s)
|
||||
setPage(1)
|
||||
loadData(activeTab, 1, s)
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / currentPageSize)
|
||||
|
||||
const getEnvelopeLink = (id: string) => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return `${ window.location.origin }/redenvelope/${ id }`
|
||||
}
|
||||
return `/redenvelope/${ id }`
|
||||
}
|
||||
|
||||
const handleCopyLink = async (link: string, envelopeId: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(link)
|
||||
setCopiedId(envelopeId)
|
||||
toast.success("链接已复制")
|
||||
setTimeout(() => setCopiedId(null), 2000)
|
||||
} catch {
|
||||
toast.error("复制失败")
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return <Badge variant="outline" className="text-[10px] px-1 py-0 h-4 leading-none text-green-600 bg-green-50/50 border-green-200">进行中</Badge>
|
||||
case 'finished':
|
||||
return <Badge variant="outline" className="text-[10px] px-1 py-0 h-4 leading-none text-muted-foreground bg-muted/50 border-dashed">已领完</Badge>
|
||||
case 'expired':
|
||||
return <Badge variant="outline" className="text-[10px] px-1 py-0 h-4 leading-none text-red-500 bg-red-50/50 border-red-200">已过期</Badge>
|
||||
default:
|
||||
return <Badge variant="outline" className="text-[10px] px-1 py-0 h-4 leading-none">{status}</Badge>
|
||||
}
|
||||
}
|
||||
|
||||
/* 默认空状态 */
|
||||
const defaultEmptyState = (
|
||||
<EmptyStateWithBorder
|
||||
icon={Inbox}
|
||||
description="暂无数据"
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-3 mb-6">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<FilterSelect<string>
|
||||
label="视图"
|
||||
selectedValues={[activeTab]}
|
||||
options={tabs.reduce((acc, tab) => ({
|
||||
...acc,
|
||||
[tab.value]: { label: tab.label, color: tab.color }
|
||||
}), {})}
|
||||
onToggleValue={(v) => setActiveTab(v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TablePagination
|
||||
currentPage={totalPages > 0 ? page : 0}
|
||||
totalPages={totalPages}
|
||||
total={total}
|
||||
pageSize={currentPageSize}
|
||||
onPageChange={handlePageChange}
|
||||
onPageSizeChange={handlePageSizeChange}
|
||||
onRefresh={() => loadData(activeTab, page, currentPageSize)}
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{loading && items.length === 0 ? (
|
||||
<LoadingStateWithBorder icon={ListRestart} />
|
||||
) : items.length === 0 ? (
|
||||
emptyState ? emptyState(activeTab) : defaultEmptyState
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{items.map((envelope) => {
|
||||
const isSent = activeTab === 'sent'
|
||||
const link = getEnvelopeLink(envelope.id)
|
||||
const claimedCount = envelope.total_count - envelope.remaining_count
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={envelope.id}
|
||||
className="group border-dashed hover:bg-muted/30 transition-all bg-card/50 shadow-none"
|
||||
>
|
||||
<CardContent className="px-4 py-1">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className={`w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 border ${ isSent
|
||||
? "bg-red-50 text-red-600 border-red-100 dark:bg-red-900/20 dark:text-red-400 dark:border-red-900/30"
|
||||
: "bg-amber-50 text-amber-600 border-amber-100 dark:bg-amber-900/20 dark:text-amber-400 dark:border-amber-900/30"
|
||||
}`}>
|
||||
{isSent ? <Gift size={16} /> : <Coins size={16} />}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-xs text-foreground truncate select-none">
|
||||
{isSent
|
||||
? (envelope.type === 'random' ? '拼手气红包' : '普通红包')
|
||||
: envelope.creator_username
|
||||
}
|
||||
</span>
|
||||
{getStatusBadge(envelope.status)}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground truncate max-w-[140px] mt-0.5">
|
||||
{envelope.greeting || "恭喜发财,大吉大利"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 mb-2.5 px-2.5 py-2 bg-muted/40 rounded-md text-xs">
|
||||
<div>
|
||||
<div className="text-[10px] text-muted-foreground/70 mb-0.5 scale-95 origin-left">总金额</div>
|
||||
<div className="font-mono font-medium text-foreground">{envelope.total_amount}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] text-muted-foreground/70 mb-0.5 scale-95 origin-left">已领取</div>
|
||||
<div className="font-medium text-foreground">{claimedCount}/{envelope.total_count}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 -mb-2">
|
||||
<div className="text-[10px] text-muted-foreground/60 font-mono">
|
||||
{formatDateTime(envelope.created_at)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 h-6">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 p-0 hover:bg-background hover:text-primary transition-all"
|
||||
onClick={() => handleCopyLink(link, envelope.id)}
|
||||
>
|
||||
{copiedId === envelope.id ? (
|
||||
<Check className="size-3 text-green-500" />
|
||||
) : (
|
||||
<Copy className="size-3" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 p-0 hover:bg-background hover:text-primary transition-all"
|
||||
onClick={() => window.open(link, '_blank')}
|
||||
>
|
||||
<ExternalLink className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
import { useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Spinner } from "@/components/ui/spinner"
|
||||
import { ErrorInline } from "@/components/layout/error"
|
||||
import { EmptyStateWithBorder } from "@/components/layout/empty"
|
||||
import { LoadingStateWithBorder } from "@/components/layout/loading"
|
||||
import { ListRestart, Layers, LucideIcon, Trash2 } from "lucide-react"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table"
|
||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"
|
||||
|
||||
|
||||
interface ManagePageProps<T> {
|
||||
title: string
|
||||
data: T[]
|
||||
loading: boolean
|
||||
error: Error | null
|
||||
onReload: () => void
|
||||
|
||||
/** 获取初始编辑数据 */
|
||||
getInitialEditData: (item: T) => Partial<T>
|
||||
onSave: (item: T, editData: Partial<T>) => Promise<void>
|
||||
onDelete: (item: T) => Promise<void>
|
||||
|
||||
/** 渲染表格 (Config-Driven) */
|
||||
columns: {
|
||||
header: string
|
||||
cell: (item: T) => React.ReactNode
|
||||
width?: string
|
||||
align?: 'left' | 'center' | 'right'
|
||||
className?: string
|
||||
}[]
|
||||
|
||||
/** 渲染详情 */
|
||||
renderDetail: (props: {
|
||||
selected: T | null
|
||||
hovered: T | null
|
||||
editData: Partial<T>
|
||||
onEditDataChange: (field: keyof T, value: T[keyof T]) => void
|
||||
onSave: () => void
|
||||
saving: boolean
|
||||
}) => React.ReactNode
|
||||
|
||||
/** 空状态图标 */
|
||||
emptyIcon?: LucideIcon
|
||||
emptyDescription?: string
|
||||
loadingDescription?: string
|
||||
getId: (item: T) => string | number
|
||||
}
|
||||
|
||||
export function ManagePage<T>({
|
||||
title,
|
||||
data,
|
||||
loading,
|
||||
error,
|
||||
onReload,
|
||||
getInitialEditData,
|
||||
onSave,
|
||||
onDelete,
|
||||
columns,
|
||||
renderDetail,
|
||||
emptyIcon = Layers,
|
||||
emptyDescription = "暂无数据",
|
||||
loadingDescription = "加载中",
|
||||
getId
|
||||
}: ManagePageProps<T>) {
|
||||
/** 悬停状态 */
|
||||
const [hoveredItem, setHoveredItem] = useState<T | null>(null)
|
||||
const [selectedItem, setSelectedItem] = useState<T | null>(null)
|
||||
const [editData, setEditData] = useState<Partial<T>>({})
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [deletingItem, setDeletingItem] = useState<T | null>(null)
|
||||
|
||||
/** 悬停处理 */
|
||||
const handleHover = (item: T | null) => {
|
||||
setHoveredItem(item)
|
||||
}
|
||||
|
||||
/** 选择处理 */
|
||||
const handleSelect = (item: T) => {
|
||||
const itemId = getId(item)
|
||||
const selectedId = selectedItem ? getId(selectedItem) : null
|
||||
|
||||
if (itemId === selectedId) {
|
||||
setSelectedItem(null)
|
||||
setEditData({})
|
||||
} else {
|
||||
setSelectedItem(item)
|
||||
setEditData(getInitialEditData(item))
|
||||
}
|
||||
setHoveredItem(null)
|
||||
}
|
||||
|
||||
/** 编辑数据处理 */
|
||||
const handleEditDataChange = (field: keyof T, value: T[keyof T]) => {
|
||||
setEditData(prev => ({
|
||||
...prev,
|
||||
[field]: value
|
||||
}))
|
||||
}
|
||||
|
||||
/** 保存处理 */
|
||||
const handleSave = async () => {
|
||||
if (!selectedItem) return
|
||||
|
||||
try {
|
||||
setSaving(true)
|
||||
await onSave(selectedItem, editData)
|
||||
toast.success('保存成功')
|
||||
} catch (error) {
|
||||
toast.error('保存失败', {
|
||||
description: error instanceof Error ? error.message : '未知错误'
|
||||
})
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除处理 */
|
||||
const handleDeleteClick = (item: T) => {
|
||||
setDeletingItem(item)
|
||||
}
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!deletingItem) return
|
||||
try {
|
||||
await onDelete(deletingItem)
|
||||
toast.success('删除成功')
|
||||
setDeletingItem(null)
|
||||
// 如果删除的是当前选中的项,清除选中状态
|
||||
if (selectedItem && getId(selectedItem) === getId(deletingItem)) {
|
||||
setSelectedItem(null)
|
||||
setEditData({})
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('删除失败', {
|
||||
description: error instanceof Error ? error.message : '未知错误'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** 渲染内容 */
|
||||
const renderContent = () => {
|
||||
if (loading && (!data || data.length === 0)) {
|
||||
return (
|
||||
<LoadingStateWithBorder
|
||||
icon={ListRestart}
|
||||
description={loadingDescription}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-8 border border-dashed rounded-lg">
|
||||
<ErrorInline
|
||||
error={error}
|
||||
onRetry={onReload}
|
||||
className="justify-center"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<EmptyStateWithBorder
|
||||
icon={emptyIcon}
|
||||
description={emptyDescription}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ManageTable
|
||||
data={data}
|
||||
columns={columns}
|
||||
selected={selectedItem}
|
||||
hovered={hoveredItem}
|
||||
onSelect={handleSelect}
|
||||
onHover={handleHover}
|
||||
onDelete={handleDeleteClick}
|
||||
getId={getId}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div className="py-6">
|
||||
<div className="flex border-b border-border pb-2 mb-6">
|
||||
<div className="text-2xl font-semibold">{title}</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<div className="font-semibold">配置列表</div>
|
||||
</div>
|
||||
{renderContent()}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{renderDetail({
|
||||
selected: selectedItem,
|
||||
hovered: hoveredItem,
|
||||
editData,
|
||||
onEditDataChange: handleEditDataChange,
|
||||
onSave: handleSave,
|
||||
saving
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertDialog open={!!deletingItem} onOpenChange={(open) => !open && setDeletingItem(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
此操作无法撤销。这将永久删除该配置。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleConfirmDelete} className="bg-red-600 hover:bg-red-700">
|
||||
确认删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 详情面板 props */
|
||||
interface ManageDetailPanelProps {
|
||||
title?: string
|
||||
isEmpty: boolean
|
||||
emptyDescription?: string
|
||||
onSave?: () => void
|
||||
saving?: boolean
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
/** 详情面板 */
|
||||
export function ManageDetailPanel({
|
||||
title = "配置信息",
|
||||
isEmpty,
|
||||
emptyDescription = "请选择配置查看详情",
|
||||
onSave,
|
||||
saving = false,
|
||||
children
|
||||
}: ManageDetailPanelProps) {
|
||||
if (isEmpty) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="font-semibold mb-4">{title}</div>
|
||||
<EmptyStateWithBorder
|
||||
icon={Layers}
|
||||
description={emptyDescription}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 sticky top-6">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="font-semibold">{title}</div>
|
||||
{onSave && (
|
||||
<Button
|
||||
onClick={onSave}
|
||||
disabled={saving}
|
||||
size="sm"
|
||||
className="px-3 h-7 text-xs"
|
||||
>
|
||||
{saving ? (<><Spinner /> 更新中</>) : '更新'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 表格面板 */
|
||||
export function ManageTable<T>({
|
||||
data,
|
||||
columns,
|
||||
selected,
|
||||
hovered,
|
||||
onSelect,
|
||||
onHover,
|
||||
onDelete,
|
||||
getId
|
||||
}: {
|
||||
data: T[]
|
||||
columns: {
|
||||
header: string
|
||||
cell: (item: T) => React.ReactNode
|
||||
width?: string
|
||||
align?: 'left' | 'center' | 'right'
|
||||
className?: string
|
||||
}[]
|
||||
selected: T | null
|
||||
hovered: T | null
|
||||
onSelect: (item: T) => void
|
||||
onHover: (item: T | null) => void
|
||||
onDelete: (item: T) => void
|
||||
getId: (item: T) => string | number
|
||||
}) {
|
||||
return (
|
||||
<div className="border border-dashed shadow-none rounded-lg overflow-hidden">
|
||||
<ScrollArea className="w-full">
|
||||
<div className="relative w-full">
|
||||
<table className="w-full caption-bottom text-sm">
|
||||
<TableHeader>
|
||||
<TableRow className="border-b border-dashed">
|
||||
{columns.map((col, index) => (
|
||||
<TableHead
|
||||
key={index}
|
||||
className={`whitespace-nowrap ${ col.width ? `w-[${ col.width }]` : '' } ${ col.align === 'center' ? 'text-center' : col.align === 'right' ? 'text-right' : '' } ${ col.className || '' }`}
|
||||
>
|
||||
{col.header}
|
||||
</TableHead>
|
||||
))}
|
||||
<TableHead className="whitespace-nowrap text-center w-[120px]">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody className="animate-in fade-in duration-200">
|
||||
{data.map((item) => {
|
||||
const id = getId(item)
|
||||
const isSelected = selected && getId(selected) === id
|
||||
const isHovered = hovered && getId(hovered) === id
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={id}
|
||||
className={`border-b border-dashed cursor-pointer transition-colors ${ isSelected
|
||||
? 'bg-primary/5 hover:bg-primary/10'
|
||||
: isHovered
|
||||
? 'bg-gray-100 hover:bg-muted/50'
|
||||
: 'hover:bg-gray-100'
|
||||
}`}
|
||||
onMouseEnter={() => onHover(item)}
|
||||
onMouseLeave={() => onHover(null)}
|
||||
onClick={() => onSelect(item)}
|
||||
>
|
||||
{columns.map((col, index) => (
|
||||
<TableCell
|
||||
key={index}
|
||||
className={`text-xs py-1 ${ col.align === 'center' ? 'text-center' : col.align === 'right' ? 'text-right' : '' } ${ col.className || '' }`}
|
||||
>
|
||||
{col.cell(item)}
|
||||
</TableCell>
|
||||
))}
|
||||
<TableCell className="text-xs py-1 text-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground hover:text-red-600 hover:bg-red-50"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDelete(item)
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</table>
|
||||
</div>
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { useState } from "react"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot,
|
||||
} from "@/components/ui/input-otp"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Spinner } from "@/components/ui/spinner"
|
||||
|
||||
interface PasswordDialogProps {
|
||||
isOpen: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onConfirm: (password: string) => void
|
||||
loading?: boolean
|
||||
title?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 密码输入对话框
|
||||
*
|
||||
* 用于敏感操作前的二次确认
|
||||
*/
|
||||
export function PasswordDialog({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
loading = false,
|
||||
title = "请输入密码",
|
||||
description = "为了您的账户安全,请输入6位认证密码进行验证"
|
||||
}: PasswordDialogProps) {
|
||||
const [password, setPassword] = useState("")
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (password.length === 6) {
|
||||
onConfirm(password)
|
||||
}
|
||||
}
|
||||
|
||||
/* 当对话框关闭时重置密码*/
|
||||
React.useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setPassword("")
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-xs sm:w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{description}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-2 flex justify-center">
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
value={password}
|
||||
onChange={setPassword}
|
||||
disabled={loading}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && password.length === 6 && !loading) {
|
||||
handleConfirm()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<InputOTPGroup className="gap-2">
|
||||
<InputOTPSlot index={0} className="w-10 h-10 text-base" />
|
||||
<InputOTPSlot index={1} className="w-10 h-10 text-base" />
|
||||
<InputOTPSlot index={2} className="w-10 h-10 text-base" />
|
||||
<InputOTPSlot index={3} className="w-10 h-10 text-base" />
|
||||
<InputOTPSlot index={4} className="w-10 h-10 text-base" />
|
||||
<InputOTPSlot index={5} className="w-10 h-10 text-base" />
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={loading}
|
||||
className="h-8 text-xs"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="bg-primary hover:bg-primary/90 h-8 text-xs"
|
||||
onClick={handleConfirm}
|
||||
disabled={password.length !== 6 || loading}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Spinner className="mr-2 h-4 w-4" />
|
||||
验证中...
|
||||
</>
|
||||
) : (
|
||||
"确认"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
import * as React from "react"
|
||||
import { useVirtualizer } from "@tanstack/react-virtual"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { typeConfig, statusConfig } from "@/components/common/general/table-filter"
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table"
|
||||
import { ErrorInline } from "@/components/layout/error"
|
||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"
|
||||
import { EmptyStateWithBorder } from "@/components/layout/empty"
|
||||
import { LoadingStateWithBorder } from "@/components/layout/loading"
|
||||
import { Lightbulb, ListRestart, Layers, LucideIcon } from "lucide-react"
|
||||
import { formatDateTime } from "@/lib/utils"
|
||||
import type { Order } from "@/lib/services"
|
||||
import { useUser } from "@/contexts/user-context"
|
||||
import {
|
||||
OrderDetailDialog,
|
||||
CreateDisputeDialog,
|
||||
ViewDisputeHistoryDialog,
|
||||
RefundReviewDialog,
|
||||
} from "./dispute-dialog"
|
||||
import {usePublicConfig} from "@/hooks/use-public-config";
|
||||
|
||||
const FALLBACK_TYPE_CONFIG = {
|
||||
label: '历史活动',
|
||||
color: 'bg-muted/50 text-gray-800 dark:bg-gray-900 dark:text-gray-300',
|
||||
}
|
||||
|
||||
const ROW_HEIGHT = 36
|
||||
|
||||
/**
|
||||
* 虚拟化交易数据表格组件
|
||||
* 使用 @tanstack/react-virtual 实现大数据量高性能渲染
|
||||
*/
|
||||
export const TransactionDataTable = React.memo(function TransactionDataTable({
|
||||
transactions
|
||||
}: {
|
||||
transactions: Order[]
|
||||
}) {
|
||||
const scrollAreaRef = React.useRef<HTMLDivElement>(null)
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: transactions.length,
|
||||
getScrollElement: () => {
|
||||
if (!scrollAreaRef.current) return null
|
||||
const viewport = scrollAreaRef.current.querySelector('[data-slot="scroll-area-viewport"]')
|
||||
return viewport as Element
|
||||
},
|
||||
estimateSize: () => ROW_HEIGHT,
|
||||
overscan: 5,
|
||||
})
|
||||
|
||||
const virtualItems = rowVirtualizer.getVirtualItems()
|
||||
const totalSize = rowVirtualizer.getTotalSize()
|
||||
const paddingTop = virtualItems.length > 0 ? virtualItems[0].start : 0
|
||||
const paddingBottom = virtualItems.length > 0 ? totalSize - virtualItems[virtualItems.length - 1].end : 0
|
||||
|
||||
return (
|
||||
<div className="border border-dashed shadow-none rounded-lg overflow-hidden">
|
||||
<ScrollArea
|
||||
ref={scrollAreaRef}
|
||||
className="w-full h-[600px] whitespace-nowrap"
|
||||
>
|
||||
<table className="w-full caption-bottom text-sm min-w-full">
|
||||
<TableHeader className="sticky top-0 z-30 bg-background">
|
||||
<TableRow className="border-b border-dashed hover:bg-transparent">
|
||||
<TableHead className="whitespace-nowrap w-[120px]">名称</TableHead>
|
||||
<TableHead className="whitespace-nowrap text-center min-w-[76px]">积分</TableHead>
|
||||
<TableHead className="whitespace-nowrap text-center min-w-[50px]">类型</TableHead>
|
||||
<TableHead className="whitespace-nowrap text-center min-w-[84px]">状态</TableHead>
|
||||
<TableHead className="whitespace-nowrap text-center min-w-[80px]">积分动向</TableHead>
|
||||
<TableHead className="whitespace-nowrap text-center min-w-[80px]">应用名</TableHead>
|
||||
<TableHead className="whitespace-nowrap text-left min-w-[120px]">编号</TableHead>
|
||||
<TableHead className="whitespace-nowrap text-left min-w-[120px]">业务单号</TableHead>
|
||||
<TableHead className="whitespace-nowrap text-left w-[120px]">创建时间</TableHead>
|
||||
<TableHead className="whitespace-nowrap text-left w-[120px]">交易时间</TableHead>
|
||||
<TableHead className="whitespace-nowrap text-left w-[120px]">订单过期时间</TableHead>
|
||||
<TableHead className="whitespace-nowrap text-left min-w-[100px]">备注</TableHead>
|
||||
<TableHead className="sticky right-0 whitespace-nowrap text-center bg-background shadow-[-4px_0_8px_-2px_rgba(0,0,0,0.1)] w-[150px] z-40">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody
|
||||
key={transactions[0]?.id}
|
||||
className="animate-in fade-in duration-200 [will-change:transform,opacity]"
|
||||
>
|
||||
{paddingTop > 0 && (
|
||||
<tr style={{ height: `${ paddingTop }px` }}>
|
||||
<td colSpan={13} />
|
||||
</tr>
|
||||
)}
|
||||
{virtualItems.map((virtualRow) => {
|
||||
const order = transactions[virtualRow.index]
|
||||
return (
|
||||
<TransactionTableRow
|
||||
key={order.id}
|
||||
order={order}
|
||||
data-index={virtualRow.index}
|
||||
ref={rowVirtualizer.measureElement}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{paddingBottom > 0 && (
|
||||
<tr style={{ height: `${ paddingBottom }px` }}>
|
||||
<td colSpan={13} />
|
||||
</tr>
|
||||
)}
|
||||
</TableBody>
|
||||
</table>
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* 交易表格行组件
|
||||
*/
|
||||
const TransactionTableRow = React.memo(React.forwardRef<HTMLTableRowElement, {
|
||||
order: Order
|
||||
style?: React.CSSProperties
|
||||
} & React.HTMLAttributes<HTMLTableRowElement>>(function TransactionTableRow({
|
||||
order,
|
||||
style,
|
||||
...props
|
||||
}, ref) {
|
||||
const { user } = useUser()
|
||||
const getAmountDisplay = (amount: string) => (
|
||||
<span className="text-xs font-semibold">
|
||||
{parseFloat(amount).toFixed(2)}
|
||||
</span>
|
||||
)
|
||||
|
||||
const { config: publicConfig } = usePublicConfig()
|
||||
|
||||
const isDisputeSupported = order.type === 'payment' || order.type === 'online' || order.type === 'receive'
|
||||
const isCurrentUserPayer = user?.id === order.payer_user_id
|
||||
const isCurrentUserPayee = user?.id === order.payee_user_id
|
||||
const isDisputing = order.status === 'disputing' && isCurrentUserPayee
|
||||
const hasPendingTransfer = order.payee_transfer_status === 'pending' && isCurrentUserPayee
|
||||
const currentTypeConfig = typeConfig[order.type as keyof typeof typeConfig] ?? FALLBACK_TYPE_CONFIG
|
||||
const pendingTransferHint = React.useMemo(() => {
|
||||
const settleAt = order.payee_transfer_at ? formatDateTime(order.payee_transfer_at) : `${publicConfig?.settlement_delay_days_min ?? 7}-${publicConfig?.settlement_delay_days_max ?? 14} 天`
|
||||
|
||||
switch (order.status) {
|
||||
case 'success':
|
||||
return `延迟结算机制:积分会在 ${settleAt} 后转入可用积分`
|
||||
case 'refused':
|
||||
return `延迟结算机制:已拒绝退款,积分会在 ${settleAt} 后转入可用积分`
|
||||
case 'disputing':
|
||||
return `延迟结算机制:争议处理中,积分会在 ${settleAt} 后转入可用积分`
|
||||
case 'refund':
|
||||
return `延迟结算机制:已退款成功,积分会在 ${settleAt} 后转入可用积分`
|
||||
default:
|
||||
return `延迟结算机制:积分会在 ${settleAt} 后转入可用积分`
|
||||
}
|
||||
}, [order.status, order.payee_transfer_at, publicConfig?.settlement_delay_days_max, publicConfig?.settlement_delay_days_min])
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
ref={ref}
|
||||
style={style}
|
||||
{...props}
|
||||
className={`
|
||||
border-dashed group hover:bg-muted/50 data-[state=selected]:bg-muted
|
||||
${ isDisputing ? 'bg-yellow-50 dark:bg-yellow-900/20 hover:bg-yellow-100/50 dark:hover:bg-yellow-900/30' : '' }
|
||||
`}
|
||||
>
|
||||
<TableCell className="text-[11px] font-medium whitespace-nowrap py-1">
|
||||
{order.order_name}
|
||||
</TableCell>
|
||||
<TableCell className="text-[11px] font-medium whitespace-nowrap text-center py-1">
|
||||
{getAmountDisplay(order.amount)}
|
||||
</TableCell>
|
||||
<TableCell className="text-[11px] font-medium whitespace-nowrap text-center py-1">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={`text-[10px] px-1 ${ currentTypeConfig.color }`}
|
||||
>
|
||||
{currentTypeConfig.label}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-[11px] font-medium whitespace-nowrap text-center py-1">
|
||||
<div className="flex justify-center">
|
||||
<div className="relative inline-flex items-center">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={`text-[10px] px-1 ${ statusConfig[order.status].color }`}
|
||||
>
|
||||
{statusConfig[order.status].label}
|
||||
</Badge>
|
||||
{hasPendingTransfer && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute left-full ml-1 inline-flex items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
|
||||
aria-label="查看到账说明"
|
||||
>
|
||||
<Lightbulb className="size-3" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="px-2.5 py-1.5">
|
||||
{pendingTransferHint}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-[11px] font-medium whitespace-nowrap text-center py-1">
|
||||
{order.status === 'pending' || order.status === 'expired' || order.type === 'community' || order.type === 'red_envelope_send' || order.type === 'red_envelope_refund' ? (
|
||||
<div className="text-muted-foreground">-</div>
|
||||
) : (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center cursor-pointer gap-1 justify-center">
|
||||
<Avatar className="h-4 w-4">
|
||||
<AvatarImage src={order.payer_avatar_url || undefined} />
|
||||
<AvatarFallback className="text-[9px] bg-primary text-primary-foreground">
|
||||
{order.payer_username.substring(0, 1).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="text-xs font-bold">⭢</div>
|
||||
<Avatar className="h-4 w-4">
|
||||
<AvatarImage src={order.payee_avatar_url || undefined} />
|
||||
<AvatarFallback className="text-[9px] bg-primary text-primary-foreground">
|
||||
{order.payee_username.substring(0, 1).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="px-2.5 py-1.5">
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<p className="text-xs font-semibold">消费方</p>
|
||||
<p className="text-xs">ID: {order.payer_user_id}</p>
|
||||
<p className="text-xs">账户: {order.payer_username}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold">服务方</p>
|
||||
<p className="text-xs">ID: {order.payee_user_id}</p>
|
||||
<p className="text-xs">账户: {order.payee_username}</p>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-[11px] font-medium text-center py-1">
|
||||
{order.app_name || '-'}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-[11px] font-medium text-left py-1">
|
||||
{order.order_no}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-[11px] font-medium text-left py-1">
|
||||
{order.merchant_order_no || '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-[11px] font-medium text-left py-1">
|
||||
{formatDateTime(order.created_at)}
|
||||
</TableCell>
|
||||
<TableCell className="text-[11px] font-medium text-left py-1">
|
||||
{(order.status === 'success' || order.status === 'refund') ? formatDateTime(order.trade_time) : '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-[11px] font-medium text-left py-1">
|
||||
{formatDateTime(order.expires_at)}
|
||||
</TableCell>
|
||||
<TableCell className="text-[11px] font-medium text-left py-1 max-w-[250px] truncate" title={order.remark || ''}>
|
||||
{order.remark || '-'}
|
||||
</TableCell>
|
||||
<TableCell className={`
|
||||
sticky right-0 whitespace-nowrap text-center shadow-[-4px_0_8px_-2px_rgba(0,0,0,0.1)] py-1 z-20
|
||||
bg-background
|
||||
after:absolute after:inset-0 after:z-[-1] after:content-[''] after:pointer-events-none after:transition-colors
|
||||
${ isDisputing
|
||||
? 'after:bg-yellow-50 dark:after:bg-yellow-900/20 group-hover:after:bg-yellow-100/50 dark:group-hover:after:bg-yellow-900/30'
|
||||
: 'group-hover:after:bg-muted/50' }
|
||||
`}>
|
||||
<OrderDetailDialog order={order} />
|
||||
|
||||
{/* 场景1:消费方对成功的订单发起争议 */}
|
||||
{isDisputeSupported && isCurrentUserPayer && order.status === 'success' && (
|
||||
<CreateDisputeDialog order={order} />
|
||||
)}
|
||||
|
||||
{/* 场景2:消费方查看正在进行的争议 */}
|
||||
{isDisputeSupported && isCurrentUserPayer && order.status === 'disputing' && (
|
||||
<ViewDisputeHistoryDialog order={order} viewer="payer" />
|
||||
)}
|
||||
|
||||
{/* 场景3:消费方查看已处理的争议(拒绝或退款) */}
|
||||
{isDisputeSupported && isCurrentUserPayer && (order.status === 'refused' || order.status === 'refund') && (
|
||||
<ViewDisputeHistoryDialog order={order} viewer="payer" />
|
||||
)}
|
||||
|
||||
{/* 场景4:服务方处理正在进行的争议 */}
|
||||
{isDisputeSupported && isCurrentUserPayee && order.status === 'disputing' && (
|
||||
<RefundReviewDialog order={order} />
|
||||
)}
|
||||
|
||||
{/* 场景5:服务方查看已处理的争议(拒绝或退款) */}
|
||||
{isDisputeSupported && isCurrentUserPayee && (order.status === 'refused' || order.status === 'refund') && (
|
||||
<ViewDisputeHistoryDialog order={order} viewer="payee" />
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
}))
|
||||
|
||||
interface TransactionTableListProps {
|
||||
loading: boolean
|
||||
error: Error | null
|
||||
transactions: Order[]
|
||||
onRetry: () => void
|
||||
emptyIcon?: LucideIcon
|
||||
emptyDescription?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 交易列表容器组件
|
||||
* 统一处理加载、错误、空状态
|
||||
*/
|
||||
export const TransactionTableList = React.memo(function TransactionTableList({
|
||||
loading,
|
||||
error,
|
||||
transactions,
|
||||
onRetry,
|
||||
emptyIcon = Layers,
|
||||
emptyDescription = "未发现积分活动"
|
||||
}: TransactionTableListProps) {
|
||||
if (loading && transactions.length === 0) {
|
||||
return (
|
||||
<LoadingStateWithBorder
|
||||
icon={ListRestart}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-8 border border-dashed rounded-lg">
|
||||
<ErrorInline
|
||||
error={error}
|
||||
onRetry={onRetry}
|
||||
className="justify-center"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!transactions || transactions.length === 0) {
|
||||
return (
|
||||
<EmptyStateWithBorder
|
||||
icon={emptyIcon}
|
||||
description={emptyDescription}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<TransactionDataTable transactions={transactions} />
|
||||
)
|
||||
})
|
||||
|
||||
export { RefundReviewDialog, CancelDisputeDialog } from "./dispute-dialog"
|
||||
@@ -0,0 +1,721 @@
|
||||
import * as React from "react"
|
||||
import { Filter, CalendarIcon, X, Search, ChevronLeft, ChevronRight, ChevronDown, Loader2 } from "lucide-react"
|
||||
import { zhCN } from "date-fns/locale"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Calendar } from "@/components/ui/calendar"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { OrderType, OrderStatus, TransactionQueryParams } from "@/lib/services"
|
||||
|
||||
/* 类型标签配置 */
|
||||
export const typeConfig: Record<OrderType, { label: string; color: string }> = {
|
||||
receive: { label: '积分收益', color: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300' },
|
||||
payment: { label: '积分消耗', color: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300' },
|
||||
community: { label: '社区划转', color: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300' },
|
||||
online: { label: '在线活动', color: 'bg-teal-100 text-teal-800 dark:bg-teal-900 dark:text-teal-300' },
|
||||
distribute: { label: '商户分发', color: 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-300' },
|
||||
test: { label: '应用测试', color: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300 font-bold' },
|
||||
red_envelope_send: { label: '红包支出', color: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300' },
|
||||
red_envelope_receive: { label: '红包收入', color: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300' },
|
||||
red_envelope_refund: { label: '红包退款', color: 'bg-muted/50 text-gray-800 dark:bg-gray-900 dark:text-gray-300' }
|
||||
}
|
||||
|
||||
/* 状态标签配置 */
|
||||
export const statusConfig: Record<OrderStatus, { label: string; color: string }> = {
|
||||
success: { label: '成功', color: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300' },
|
||||
pending: { label: '处理中', color: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300' },
|
||||
failed: { label: '失败', color: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300' },
|
||||
expired: { label: '已过期', color: 'bg-muted/50 text-gray-800 dark:bg-gray-900 dark:text-gray-300' },
|
||||
disputing: { label: '争议中', color: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300' },
|
||||
refund: { label: '已退回', color: 'bg-muted/50 text-gray-800 dark:bg-gray-900 dark:text-gray-300' },
|
||||
refused: { label: '已拒绝', color: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300' }
|
||||
}
|
||||
|
||||
export type DisplayOrderStatus = OrderStatus | 'transfer_pending'
|
||||
|
||||
export const displayStatusConfig: Record<DisplayOrderStatus, { label: string; color: string }> = {
|
||||
pending: statusConfig.pending,
|
||||
success: statusConfig.success,
|
||||
transfer_pending: { label: '结算中', color: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300' },
|
||||
failed: statusConfig.failed,
|
||||
expired: statusConfig.expired,
|
||||
disputing: statusConfig.disputing,
|
||||
refund: statusConfig.refund,
|
||||
refused: statusConfig.refused,
|
||||
}
|
||||
|
||||
export function mapDisplayStatusToQuery(
|
||||
selectedStatus: DisplayOrderStatus | null
|
||||
): Pick<TransactionQueryParams, 'statuses' | 'payee_transfer_status'> {
|
||||
if (!selectedStatus) {
|
||||
return {}
|
||||
}
|
||||
|
||||
if (selectedStatus === 'transfer_pending') {
|
||||
return {
|
||||
payee_transfer_status: 'pending',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
statuses: [selectedStatus],
|
||||
}
|
||||
}
|
||||
|
||||
/* 时间范围选项 */
|
||||
export const timeRangeOptions = [
|
||||
{
|
||||
label: "今天", getValue: () => {
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
const tomorrow = new Date(today)
|
||||
tomorrow.setDate(tomorrow.getDate() + 1)
|
||||
return { from: today, to: tomorrow }
|
||||
}
|
||||
},
|
||||
{
|
||||
label: "最近 7 天", getValue: () => {
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
const from = new Date(today)
|
||||
from.setDate(from.getDate() - 6)
|
||||
const tomorrow = new Date(today)
|
||||
tomorrow.setDate(tomorrow.getDate() + 1)
|
||||
return { from, to: tomorrow }
|
||||
}
|
||||
},
|
||||
{
|
||||
label: "最近 1 个月", getValue: () => {
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
const from = new Date(today)
|
||||
from.setDate(from.getDate() - 29)
|
||||
const tomorrow = new Date(today)
|
||||
tomorrow.setDate(tomorrow.getDate() + 1)
|
||||
return { from, to: tomorrow }
|
||||
}
|
||||
},
|
||||
{
|
||||
label: "最近 6 个月", getValue: () => {
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
const from = new Date(today)
|
||||
from.setMonth(from.getMonth() - 6)
|
||||
const tomorrow = new Date(today)
|
||||
tomorrow.setDate(tomorrow.getDate() + 1)
|
||||
return { from, to: tomorrow }
|
||||
}
|
||||
},
|
||||
{
|
||||
label: "本月至今", getValue: () => {
|
||||
const today = new Date()
|
||||
const from = new Date(today.getFullYear(), today.getMonth(), 1)
|
||||
from.setHours(0, 0, 0, 0)
|
||||
const tomorrow = new Date(today)
|
||||
tomorrow.setHours(0, 0, 0, 0)
|
||||
tomorrow.setDate(tomorrow.getDate() + 1)
|
||||
return { from, to: tomorrow }
|
||||
}
|
||||
},
|
||||
{
|
||||
label: "本季至今", getValue: () => {
|
||||
const today = new Date()
|
||||
const quarter = Math.floor(today.getMonth() / 3)
|
||||
const from = new Date(today.getFullYear(), quarter * 3, 1) // 本季第一天 00:00:00
|
||||
from.setHours(0, 0, 0, 0)
|
||||
const tomorrow = new Date(today)
|
||||
tomorrow.setHours(0, 0, 0, 0)
|
||||
tomorrow.setDate(tomorrow.getDate() + 1)
|
||||
return { from, to: tomorrow }
|
||||
}
|
||||
},
|
||||
{ label: "所有时间", getValue: () => null },
|
||||
]
|
||||
|
||||
export interface TableFilterProps {
|
||||
/* 启用的筛选类型 */
|
||||
enabledFilters?: {
|
||||
type?: boolean
|
||||
status?: boolean
|
||||
timeRange?: boolean
|
||||
search?: boolean
|
||||
}
|
||||
|
||||
/* 当前选中的值 */
|
||||
selectedTypes?: OrderType[]
|
||||
selectedStatus?: DisplayOrderStatus | null
|
||||
selectedTimeRange?: { from: Date; to: Date } | null
|
||||
selectedQuickSelection?: string | null
|
||||
/* 搜索值 */
|
||||
searchValues?: SearchValues
|
||||
|
||||
/* 回调函数 */
|
||||
onTypeChange?: (types: OrderType[]) => void
|
||||
onStatusChange?: (status: DisplayOrderStatus | null) => void
|
||||
onTimeRangeChange?: (range: { from: Date; to: Date } | null) => void
|
||||
onQuickSelectionChange?: (selection: string | null) => void
|
||||
onSearch?: (values: SearchValues) => void
|
||||
|
||||
// 其他选项
|
||||
showClearButton?: boolean
|
||||
onClearAll?: () => void
|
||||
|
||||
// 分页控制(可选)
|
||||
enablePagination?: boolean
|
||||
currentPage?: number
|
||||
totalPages?: number
|
||||
pageSize?: number
|
||||
total?: number
|
||||
onPageChange?: (page: number) => void
|
||||
onPageSizeChange?: (size: number) => void
|
||||
onRefresh?: () => void
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 可复用的筛选组件
|
||||
* 支持类型、状态、时间范围筛选
|
||||
*/
|
||||
export function TableFilter({
|
||||
enabledFilters = { type: true, status: true, timeRange: true, search: true },
|
||||
selectedTypes = [],
|
||||
selectedStatus = null,
|
||||
selectedTimeRange = null,
|
||||
selectedQuickSelection = null,
|
||||
searchValues = {},
|
||||
onTypeChange,
|
||||
onStatusChange,
|
||||
onTimeRangeChange,
|
||||
onQuickSelectionChange,
|
||||
onSearch,
|
||||
showClearButton = true,
|
||||
onClearAll,
|
||||
enablePagination = false,
|
||||
currentPage = 1,
|
||||
totalPages = 1,
|
||||
pageSize = 20,
|
||||
total = 0,
|
||||
onPageChange,
|
||||
onPageSizeChange,
|
||||
onRefresh,
|
||||
loading = false,
|
||||
}: TableFilterProps) {
|
||||
/* 通用切换筛选函数 */
|
||||
const handleToggle = <T extends string>(
|
||||
value: T,
|
||||
selectedValues: T[],
|
||||
onChange?: (values: T[]) => void
|
||||
) => {
|
||||
if (!onChange) return
|
||||
const newValues = selectedValues.includes(value)
|
||||
? selectedValues.filter(v => v !== value)
|
||||
: [...selectedValues, value]
|
||||
onChange(newValues)
|
||||
}
|
||||
|
||||
/* 切换类型筛选 */
|
||||
const toggleType = (type: OrderType) => handleToggle(type, selectedTypes, onTypeChange)
|
||||
|
||||
/* 切换状态筛选 */
|
||||
const toggleStatus = (status: DisplayOrderStatus) => {
|
||||
if (!onStatusChange) return
|
||||
onStatusChange(selectedStatus === status ? null : status)
|
||||
}
|
||||
|
||||
/* 处理时间范围变化 */
|
||||
const handleTimeRangeChange = (range: { from: Date; to: Date } | null) => {
|
||||
onTimeRangeChange?.(range)
|
||||
}
|
||||
|
||||
/* 是否有激活的筛选 */
|
||||
const hasActiveFilters =
|
||||
(enabledFilters.type && selectedTypes.length > 0) ||
|
||||
(enabledFilters.status && selectedStatus !== null) ||
|
||||
(enabledFilters.timeRange && selectedTimeRange !== null) ||
|
||||
(enabledFilters.search && Object.values(searchValues || {}).some(v => v))
|
||||
|
||||
return (
|
||||
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-3">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{enabledFilters.search && (
|
||||
<SearchFilter
|
||||
values={searchValues}
|
||||
onSearch={onSearch}
|
||||
/>
|
||||
)}
|
||||
|
||||
{enabledFilters.type && (
|
||||
<FilterSelect<OrderType>
|
||||
label="类型"
|
||||
selectedValues={selectedTypes}
|
||||
options={typeConfig}
|
||||
onToggleValue={toggleType}
|
||||
/>
|
||||
)}
|
||||
|
||||
{enabledFilters.status && (
|
||||
<SingleFilterSelect<DisplayOrderStatus>
|
||||
label="状态"
|
||||
selectedValue={selectedStatus}
|
||||
options={displayStatusConfig}
|
||||
onSelectValue={toggleStatus}
|
||||
/>
|
||||
)}
|
||||
|
||||
{enabledFilters.timeRange && (
|
||||
<TimeRangeFilter
|
||||
selectedQuickSelection={selectedQuickSelection}
|
||||
selectedTimeRange={selectedTimeRange}
|
||||
onTimeRangeChange={handleTimeRangeChange}
|
||||
onQuickSelectionChange={onQuickSelectionChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showClearButton && hasActiveFilters && onClearAll && (
|
||||
<>
|
||||
<Separator orientation="vertical" className="h-6 hidden sm:block" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClearAll}
|
||||
className="h-5 px-2 lg:px-3 text-[11px] font-medium text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="size-3" />
|
||||
清空筛选
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{enablePagination && onPageChange && onPageSizeChange && (
|
||||
<Separator className="lg:hidden" />
|
||||
)}
|
||||
|
||||
{enablePagination && onPageChange && onPageSizeChange && (
|
||||
<TablePagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
total={total}
|
||||
pageSize={pageSize}
|
||||
onPageChange={onPageChange}
|
||||
onPageSizeChange={onPageSizeChange}
|
||||
onRefresh={onRefresh}
|
||||
loading={loading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
interface TablePaginationProps {
|
||||
currentPage: number
|
||||
totalPages: number
|
||||
total: number
|
||||
pageSize: number
|
||||
onPageChange: (page: number) => void
|
||||
onPageSizeChange: (size: number) => void
|
||||
onRefresh?: () => void
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export function TablePagination({
|
||||
currentPage,
|
||||
totalPages,
|
||||
total,
|
||||
pageSize,
|
||||
onPageChange,
|
||||
onPageSizeChange,
|
||||
onRefresh,
|
||||
loading = false
|
||||
}: TablePaginationProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 self-end lg:self-auto">
|
||||
<span className="text-[10px] text-muted-foreground whitespace-nowrap hidden sm:inline-block">
|
||||
{total} 条记录
|
||||
</span>
|
||||
<div className="flex items-center border border-dashed rounded-md shadow-none bg-background">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5.5 w-6 rounded-none rounded-l-md disabled:opacity-30"
|
||||
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
|
||||
disabled={currentPage <= 1 || loading}
|
||||
>
|
||||
<ChevronLeft className="size-3" />
|
||||
</Button>
|
||||
<span className="text-[10px] font-mono text-muted-foreground px-2 border-x border-dashed h-5.5 flex items-center">
|
||||
{currentPage}/{totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5.5 w-6 rounded-none rounded-r-md disabled:opacity-30"
|
||||
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
|
||||
disabled={currentPage >= totalPages || loading}
|
||||
>
|
||||
<ChevronRight className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-6 border-dashed text-[10px] px-2 font-mono shadow-none" disabled={loading}>
|
||||
{pageSize}条/页
|
||||
<ChevronDown className="size-3 opacity-50" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{[20, 50, 100].map(size => (
|
||||
<DropdownMenuItem
|
||||
key={size}
|
||||
onClick={() => onPageSizeChange(size)}
|
||||
className={cn("font-mono text-xs", pageSize === size && "bg-accent")}
|
||||
>
|
||||
{size}条/页
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{onRefresh && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-6 w-6 border-dashed shadow-none"
|
||||
onClick={onRefresh}
|
||||
disabled={loading}
|
||||
title="刷新数据"
|
||||
>
|
||||
<Loader2 className={cn("size-3", loading && "animate-spin")} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 可复用的筛选选择器组件
|
||||
*/
|
||||
export function FilterSelect<T extends string>({ label, selectedValues, options, onToggleValue }: {
|
||||
label: string
|
||||
selectedValues: T[]
|
||||
options: Record<T, { label: string; color: string }>
|
||||
onToggleValue: (value: T) => void
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-5 border-dashed text-[10px] font-medium shadow-none focus-visible:ring-0",
|
||||
selectedValues.length > 0 && "bg-primary/5 border-primary/20"
|
||||
)}
|
||||
>
|
||||
<Filter className="size-3" />
|
||||
{label}
|
||||
{selectedValues.length > 0 && (
|
||||
<>
|
||||
<Separator orientation="vertical" className="mx-1" />
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[10px] h-3 px-1 rounded-full bg-primary text-primary-foreground"
|
||||
>
|
||||
{selectedValues.length}
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-[120px]" align="start">
|
||||
{(Object.keys(options) as T[]).map((value) => {
|
||||
const isSelected = selectedValues.includes(value)
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={value}
|
||||
onSelect={(e) => {
|
||||
e.preventDefault()
|
||||
onToggleValue(value)
|
||||
}}
|
||||
>
|
||||
<div className={cn(
|
||||
"mr-2 flex size-3 items-center justify-center rounded-sm border border-primary",
|
||||
isSelected
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "opacity-50 [&_svg]:invisible"
|
||||
)}>
|
||||
</div>
|
||||
<span className="text-xs">{options[value].label}</span>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export function SingleFilterSelect<T extends string>({ label, selectedValue, options, onSelectValue }: {
|
||||
label: string
|
||||
selectedValue: T | null
|
||||
options: Record<T, { label: string; color: string }>
|
||||
onSelectValue: (value: T) => void
|
||||
}) {
|
||||
const activeLabel = selectedValue ? options[selectedValue].label : null
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-5 border-dashed text-[10px] font-medium shadow-none focus-visible:ring-0",
|
||||
selectedValue && "bg-primary/5 border-primary/20"
|
||||
)}
|
||||
>
|
||||
<Filter className="size-3" />
|
||||
{activeLabel ? `${label}: ${activeLabel}` : label}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-[160px]" align="start">
|
||||
{(Object.keys(options) as T[]).map((value) => {
|
||||
const isSelected = selectedValue === value
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={value}
|
||||
onSelect={(e) => {
|
||||
e.preventDefault()
|
||||
onSelectValue(value)
|
||||
}}
|
||||
>
|
||||
<div className={cn(
|
||||
"mr-2 flex size-3 items-center justify-center rounded-full border border-primary",
|
||||
isSelected ? "bg-primary" : "opacity-50"
|
||||
)} />
|
||||
<span className="text-xs">{options[value].label}</span>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
interface TimeRangeFilterProps {
|
||||
selectedQuickSelection: string | null
|
||||
selectedTimeRange: { from: Date; to: Date } | null
|
||||
onTimeRangeChange: (range: { from: Date; to: Date } | null) => void
|
||||
onQuickSelectionChange?: (selection: string | null) => void
|
||||
}
|
||||
|
||||
function TimeRangeFilter({
|
||||
selectedQuickSelection,
|
||||
selectedTimeRange,
|
||||
onTimeRangeChange,
|
||||
onQuickSelectionChange
|
||||
}: TimeRangeFilterProps) {
|
||||
/* 处理日历选择 */
|
||||
const handleCalendarSelect = (range: { from?: Date; to?: Date } | undefined) => {
|
||||
if (range?.from) {
|
||||
const from = new Date(range.from)
|
||||
from.setHours(0, 0, 0, 0)
|
||||
|
||||
if (range.to) {
|
||||
const to = new Date(range.to)
|
||||
to.setHours(0, 0, 0, 0)
|
||||
onTimeRangeChange({ from, to })
|
||||
onQuickSelectionChange?.(null)
|
||||
} else if (range.from.getTime() === selectedTimeRange?.from?.getTime()) {
|
||||
const to = new Date(from)
|
||||
to.setHours(0, 0, 0, 0)
|
||||
onTimeRangeChange({ from, to })
|
||||
onQuickSelectionChange?.(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-5 border-dashed text-[10px] font-medium shadow-none focus-visible:ring-0",
|
||||
(selectedQuickSelection || selectedTimeRange) && "bg-primary/5 border-primary/20"
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-1 size-3" />
|
||||
{selectedQuickSelection || (selectedTimeRange ? "自定义时间" : "时间范围")}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-auto p-0" align="start">
|
||||
<div className="flex flex-col sm:flex-row">
|
||||
<div className="flex flex-col p-2 gap-1 min-w-[120px] border-r">
|
||||
{timeRangeOptions.map((selection) => (
|
||||
<Button
|
||||
key={selection.label}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const range = selection.getValue()
|
||||
onTimeRangeChange(range)
|
||||
onQuickSelectionChange?.(selection.label)
|
||||
}}
|
||||
className={cn(
|
||||
"justify-start text-xs font-normal h-8",
|
||||
selectedQuickSelection === selection.label && "bg-accent text-accent-foreground font-medium"
|
||||
)}
|
||||
>
|
||||
{selection.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<div className="p-2">
|
||||
<Calendar
|
||||
mode="range"
|
||||
selected={selectedTimeRange ? { from: selectedTimeRange.from, to: selectedTimeRange.to } : undefined}
|
||||
onSelect={handleCalendarSelect}
|
||||
numberOfMonths={2}
|
||||
locale={zhCN}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export interface SearchValues {
|
||||
id?: string
|
||||
order_name?: string
|
||||
payer_username?: string
|
||||
payee_username?: string
|
||||
}
|
||||
|
||||
export function SearchFilter({
|
||||
values,
|
||||
onSearch
|
||||
}: {
|
||||
values?: SearchValues
|
||||
onSearch?: (values: SearchValues) => void
|
||||
}) {
|
||||
const [localValues, setLocalValues] = React.useState<SearchValues>(values || {})
|
||||
const [isOpen, setIsOpen] = React.useState(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
setLocalValues(values || {})
|
||||
}, [values, isOpen])
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
onSearch?.(localValues)
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
onSearch?.(localValues)
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
const handleClear = () => {
|
||||
const empty = { id: '', order_name: '', payer_username: '', payee_username: '' }
|
||||
setLocalValues(empty)
|
||||
onSearch?.(empty)
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
const hasSearchValues = Object.values(values || {}).some(v => v)
|
||||
|
||||
return (
|
||||
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-5 border-dashed text-[10px] font-medium shadow-none focus-visible:ring-0",
|
||||
hasSearchValues && "bg-primary/5 border-primary/20"
|
||||
)}
|
||||
>
|
||||
<Search className="size-3" />
|
||||
搜索
|
||||
{hasSearchValues && (
|
||||
<>
|
||||
<Separator orientation="vertical" className="mx-1 h-3" />
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[10px] h-3 px-1 rounded-full bg-primary text-primary-foreground"
|
||||
>
|
||||
!
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-[280px] p-4" align="start">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">编号 ID</Label>
|
||||
<Input
|
||||
value={localValues.id || ''}
|
||||
onChange={e => setLocalValues(prev => ({ ...prev, id: e.target.value }))}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="精确匹配"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">活动名称</Label>
|
||||
<Input
|
||||
value={localValues.order_name || ''}
|
||||
onChange={e => setLocalValues(prev => ({ ...prev, order_name: e.target.value }))}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="模糊匹配"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">消费方</Label>
|
||||
<Input
|
||||
value={localValues.payer_username || ''}
|
||||
onChange={e => setLocalValues(prev => ({ ...prev, payer_username: e.target.value }))}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="模糊匹配"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">服务方</Label>
|
||||
<Input
|
||||
value={localValues.payee_username || ''}
|
||||
onChange={e => setLocalValues(prev => ({ ...prev, payee_username: e.target.value }))}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="模糊匹配"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button size="sm" variant="outline" className="flex-1 h-7 text-xs" onClick={handleClear}>
|
||||
重置
|
||||
</Button>
|
||||
<Button size="sm" className="flex-1 h-7 text-xs" onClick={handleSearch}>
|
||||
搜索
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import * as React from "react"
|
||||
import { Area, AreaChart, XAxis } from "recharts"
|
||||
import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"
|
||||
import { CountingNumber } from '@/components/animate-ui/primitives/texts/counting-number'
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { Info } from "lucide-react"
|
||||
|
||||
import { useUser } from "@/contexts/user-context"
|
||||
import { DashboardService } from "@/lib/services"
|
||||
import type { DailyStatsItem } from "@/lib/services"
|
||||
|
||||
const chartConfig = {
|
||||
total: {
|
||||
label: "总额",
|
||||
color: "hsl(217, 91%, 60%)",
|
||||
},
|
||||
income: {
|
||||
label: "收入",
|
||||
color: "#10b981",
|
||||
},
|
||||
expense: {
|
||||
label: "支出",
|
||||
color: "#f43f5e",
|
||||
},
|
||||
} satisfies ChartConfig
|
||||
|
||||
function ResponsiveInfoTip({ children }: { children: React.ReactNode }) {
|
||||
const triggerClassName =
|
||||
"shrink-0 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button type="button" aria-label="查看详情" className={`inline-flex ${triggerClassName} md:hidden`}>
|
||||
<Info className="size-3.5" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side="top" className="w-fit max-w-[calc(100vw-2rem)] p-3 md:hidden">
|
||||
<p className="text-xs break-words">{children}</p>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button type="button" aria-label="查看详情" className={`hidden ${triggerClassName} md:inline-flex`}>
|
||||
<Info className="size-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={6} className="w-fit max-w-[calc(100vw-2rem)] px-2.5 py-1.5">
|
||||
<p className="text-xs leading-5 break-words">{children}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据面板组件
|
||||
* 展示用户数据和积分余额
|
||||
* @returns {React.ReactNode} 数据面板组件
|
||||
*/
|
||||
export function DataPanel() {
|
||||
const { user, loading: userLoading } = useUser()
|
||||
const [dailyStats, setDailyStats] = React.useState<DailyStatsItem[]>([])
|
||||
const [loading, setLoading] = React.useState(true)
|
||||
|
||||
const availableBalance = parseFloat(user?.available_balance || '0')
|
||||
const pendingBalance = parseFloat(user?.pending_balance || '0')
|
||||
const remainQuota = parseFloat(user?.remain_quota || '0')
|
||||
|
||||
/* 获取每日统计数据 */
|
||||
React.useEffect(() => {
|
||||
const fetchDailyStats = async () => {
|
||||
setLoading(true)
|
||||
const data = await DashboardService.getDailyStats(7)
|
||||
setDailyStats(data)
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
if (!userLoading && user) {
|
||||
fetchDailyStats()
|
||||
}
|
||||
}, [userLoading, user])
|
||||
|
||||
/* 根据当前余额和每日收支反推历史积分总额 */
|
||||
const chartData = React.useMemo(() => {
|
||||
if (!dailyStats.length || userLoading) return []
|
||||
|
||||
const sortedStats = [...dailyStats].sort((a, b) =>
|
||||
new Date(b.date).getTime() - new Date(a.date).getTime()
|
||||
)
|
||||
|
||||
let currentBalance = availableBalance
|
||||
const balanceHistory: Array<{ date: string; total: number; income: number; expense: number }> = []
|
||||
|
||||
for (let i = 0; i < sortedStats.length; i++) {
|
||||
const stat = sortedStats[i]
|
||||
const income = parseFloat(stat.income || '0')
|
||||
const expense = parseFloat(stat.expense || '0')
|
||||
|
||||
balanceHistory.unshift({
|
||||
date: new Date(stat.date).toLocaleDateString('zh-CN', {
|
||||
month: 'numeric',
|
||||
day: 'numeric'
|
||||
}),
|
||||
total: Math.max(0, currentBalance),
|
||||
income,
|
||||
expense
|
||||
})
|
||||
|
||||
currentBalance = currentBalance - income + expense
|
||||
}
|
||||
|
||||
return balanceHistory
|
||||
}, [dailyStats, availableBalance, userLoading])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col md:grid md:grid-cols-3 gap-8 md:gap-12">
|
||||
<div className="md:col-span-2 order-1 md:order-none">
|
||||
<h3 className="text-sm text-muted-foreground font-medium mb-2">积分趋势</h3>
|
||||
|
||||
{loading || userLoading ? (
|
||||
<div className="w-full h-[240px] flex items-center justify-center">
|
||||
<Skeleton className="h-full w-full rounded-lg" />
|
||||
</div>
|
||||
) : chartData.length > 0 ? (
|
||||
<ChartContainer config={chartConfig} className="w-full h-[240px] font-bold">
|
||||
<AreaChart
|
||||
data={chartData}
|
||||
margin={{
|
||||
left: 6,
|
||||
right: 6,
|
||||
top: 12,
|
||||
bottom: 12,
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="colorTotal" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="hsl(217, 91%, 60%)" stopOpacity={0.2} />
|
||||
<stop offset="95%" stopColor="hsl(217, 91%, 60%)" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<linearGradient id="colorIncome" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#10b981" stopOpacity={0.2} />
|
||||
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<linearGradient id="colorExpense" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#f43f5e" stopOpacity={0.2} />
|
||||
<stop offset="95%" stopColor="#f43f5e" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<XAxis dataKey="date" hide />
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={<ChartTooltipContent indicator="dot" />}
|
||||
/>
|
||||
<Area
|
||||
dataKey="total"
|
||||
type="monotone"
|
||||
fill="url(#colorTotal)"
|
||||
fillOpacity={0.4}
|
||||
stroke="var(--color-total)"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{
|
||||
fill: "hsl(217, 91%, 60%)",
|
||||
stroke: "var(--background)",
|
||||
strokeWidth: 2,
|
||||
r: 4,
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
dataKey="income"
|
||||
type="monotone"
|
||||
fill="none"
|
||||
stroke="#10b981"
|
||||
strokeWidth={1.5}
|
||||
strokeDasharray="4 4"
|
||||
dot={false}
|
||||
activeDot={{
|
||||
fill: "#10b981",
|
||||
stroke: "var(--background)",
|
||||
strokeWidth: 2,
|
||||
r: 4,
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
dataKey="expense"
|
||||
type="monotone"
|
||||
fill="none"
|
||||
stroke="#f43f5e"
|
||||
strokeWidth={1.5}
|
||||
strokeDasharray="4 4"
|
||||
dot={false}
|
||||
activeDot={{
|
||||
fill: "#f43f5e",
|
||||
stroke: "var(--background)",
|
||||
strokeWidth: 2,
|
||||
r: 4,
|
||||
}}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
) : (
|
||||
<div className="w-full h-[240px] flex items-center justify-center border border-dashed rounded-lg">
|
||||
<p className="text-xs text-muted-foreground">暂无统计数据</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-1 order-2 md:order-none flex flex-col divide-y divide-border/70 md:divide-y-0 md:pt-px">
|
||||
<div className="py-3 first:pt-0 md:border-b md:pb-4 md:pt-0">
|
||||
<div className="flex items-start justify-between gap-4 md:block">
|
||||
<div className="min-w-0 text-sm text-muted-foreground font-medium flex items-center gap-1 flex-wrap md:whitespace-nowrap">
|
||||
<span className="min-[400px]:hidden">可用LDC</span>
|
||||
<span className="hidden min-[400px]:inline">可用 LINUX DO Credits</span>
|
||||
<ResponsiveInfoTip>
|
||||
当前可立即使用的积分,不包含延迟到账中的未来积分。
|
||||
</ResponsiveInfoTip>
|
||||
</div>
|
||||
<div className="shrink-0 text-right text-xl font-semibold leading-none md:pt-2 md:text-left">
|
||||
{userLoading ? '-' : <CountingNumber number={availableBalance} decimalPlaces={2} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="py-3 md:border-b md:pt-4 md:pb-4">
|
||||
<div className="flex items-start justify-between gap-4 md:block">
|
||||
<div className="min-w-0 text-sm text-muted-foreground font-medium flex items-center gap-1 flex-wrap md:whitespace-nowrap">
|
||||
<span className="min-[400px]:hidden">未来LDC</span>
|
||||
<span className="hidden min-[400px]:inline">未来 LINUX DO Credits</span>
|
||||
<ResponsiveInfoTip>
|
||||
流转后延迟到账的积分,到期后自动转入可用积分。
|
||||
</ResponsiveInfoTip>
|
||||
</div>
|
||||
<div className="shrink-0 text-right text-xl font-semibold leading-none md:pt-2 md:text-left">
|
||||
{userLoading ? '-' : <CountingNumber number={pendingBalance} decimalPlaces={2} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="py-3 last:pb-0 md:pt-4 md:pb-0">
|
||||
<div className="flex items-start justify-between gap-4 md:block">
|
||||
<div className="min-w-0 text-sm text-muted-foreground font-medium flex items-center gap-1 flex-wrap">
|
||||
今日剩余额度
|
||||
<ResponsiveInfoTip>
|
||||
每日积分消耗额度限制,每日 0 点自动重置
|
||||
</ResponsiveInfoTip>
|
||||
</div>
|
||||
<div className="shrink-0 text-right text-xl font-semibold leading-none md:pt-2 md:text-left">
|
||||
{userLoading ? '-' : remainQuota < 0 ? "无限制" : <CountingNumber number={remainQuota} decimalPlaces={2} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { useState, useCallback } from "react"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { TransactionService } from "@/lib/services"
|
||||
import type { Order } from "@/lib/services"
|
||||
import { TransactionTableList } from "@/components/common/general/table-data"
|
||||
import { AlertTriangle } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
const DISPUTE_PAGE_SIZE = 20
|
||||
|
||||
/**
|
||||
* 服务争议对话框模式
|
||||
*/
|
||||
type DisputeDialogMode = 'pending' | 'my-disputes'
|
||||
|
||||
/**
|
||||
* 服务争议对话框属性
|
||||
*/
|
||||
interface DisputeDialogProps {
|
||||
/** 对话框模式 */
|
||||
mode: DisputeDialogMode
|
||||
/** 是否打开 */
|
||||
open: boolean
|
||||
/** 打开状态变化回调 */
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 争议对话框组件
|
||||
* 支持两种模式:待处理的服务争议(服务提供者)和我发起的服务争议(积分消费者)
|
||||
*/
|
||||
export function DisputeDialog({ mode, open, onOpenChange }: DisputeDialogProps) {
|
||||
const [disputes, setDisputes] = useState<Order[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
|
||||
const fetchDisputes = useCallback(async (page: number = 1) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
let result
|
||||
let filteredOrders: Order[] = []
|
||||
|
||||
if (mode === 'pending') {
|
||||
result = await TransactionService.getTransactions({
|
||||
page,
|
||||
page_size: DISPUTE_PAGE_SIZE,
|
||||
types: ['receive'],
|
||||
statuses: ['disputing']
|
||||
})
|
||||
filteredOrders = result.orders
|
||||
const uniqueOrders = new Map()
|
||||
filteredOrders.forEach(order => uniqueOrders.set(order.id, order))
|
||||
filteredOrders = Array.from(uniqueOrders.values())
|
||||
} else {
|
||||
const paymentResult = await TransactionService.getTransactions({
|
||||
page,
|
||||
page_size: Math.ceil(DISPUTE_PAGE_SIZE / 2),
|
||||
types: ['payment']
|
||||
})
|
||||
|
||||
const onlineResult = await TransactionService.getTransactions({
|
||||
page,
|
||||
page_size: Math.ceil(DISPUTE_PAGE_SIZE / 2),
|
||||
types: ['online']
|
||||
})
|
||||
|
||||
const allOrders = [...paymentResult.orders, ...onlineResult.orders]
|
||||
const uniqueOrders = new Map()
|
||||
allOrders.forEach(order => uniqueOrders.set(order.id, order))
|
||||
|
||||
filteredOrders = Array.from(uniqueOrders.values()).filter((order: Order) =>
|
||||
order.status === 'disputing' || order.status === 'refused' || order.status === 'refund'
|
||||
)
|
||||
|
||||
filteredOrders.sort((a, b) =>
|
||||
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
||||
)
|
||||
|
||||
const startIndex = (page - 1) * DISPUTE_PAGE_SIZE
|
||||
filteredOrders = filteredOrders.slice(startIndex, startIndex + DISPUTE_PAGE_SIZE)
|
||||
|
||||
result = {
|
||||
orders: filteredOrders,
|
||||
total: allOrders.filter((order: Order) =>
|
||||
order.status === 'disputing' || order.status === 'refused' || order.status === 'refund'
|
||||
).length,
|
||||
page,
|
||||
page_size: DISPUTE_PAGE_SIZE
|
||||
}
|
||||
}
|
||||
|
||||
setDisputes(filteredOrders)
|
||||
} catch (err) {
|
||||
const error = err as Error
|
||||
setError(error)
|
||||
toast.error('获取争议数据失败', {
|
||||
description: error.message || '无法加载争议列表'
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [mode])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
fetchDisputes(1)
|
||||
} else {
|
||||
setDisputes([])
|
||||
setError(null)
|
||||
}
|
||||
}, [open, fetchDisputes])
|
||||
|
||||
const handleRetry = () => {
|
||||
fetchDisputes(1)
|
||||
}
|
||||
|
||||
const dialogTitle = mode === 'pending' ? '待处理的争议' : '我发起的争议'
|
||||
const dialogDescription = mode === 'pending'
|
||||
? '您作为服务提供者,需要处理所有争议中的活动'
|
||||
: '您作为积分消费者发起的所有争议'
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{dialogTitle}</DialogTitle>
|
||||
<DialogDescription>{dialogDescription}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<TransactionTableList
|
||||
loading={loading}
|
||||
error={error}
|
||||
transactions={disputes}
|
||||
onRetry={handleRetry}
|
||||
emptyIcon={AlertTriangle}
|
||||
emptyDescription={mode === 'pending' ? '暂无待处理的争议' : '暂无您发起的争议'}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { TransactionProvider } from "@/contexts/transaction-context"
|
||||
|
||||
import { DataPanel } from "@/components/common/home/data-panel"
|
||||
import { OverviewPanel } from "@/components/common/home/overview-panel"
|
||||
|
||||
export function HomeMain() {
|
||||
return (
|
||||
<TransactionProvider>
|
||||
<div className="py-6 space-y-12">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold border-b pb-2 mb-6">今天</h1>
|
||||
<DataPanel />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold border-b pb-2">近期概览</h1>
|
||||
<OverviewPanel />
|
||||
</div>
|
||||
</div>
|
||||
</TransactionProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,746 @@
|
||||
import * as React from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardFooter } from "@/components/ui/card"
|
||||
import { Eye, EyeOff } from "lucide-react"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { DisputeService, TransactionService, DashboardService } from "@/lib/services"
|
||||
import type { DisputeWithOrder, Order, DailyStatsItem, TopCustomer } from "@/lib/services"
|
||||
import { RefundReviewDialog, CancelDisputeDialog } from "@/components/common/general/table-data"
|
||||
import { CountingNumber } from '@/components/animate-ui/primitives/texts/counting-number'
|
||||
import { useDisputeData } from "@/hooks/use-dispute"
|
||||
import { DisputeDialog } from "@/components/common/home/dispute-dialog"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
|
||||
import { Spinner } from "@/components/ui/spinner"
|
||||
|
||||
function AnimatedProgressBar({ value, className }: { value: number, className?: string }) {
|
||||
const [progress, setProgress] = React.useState(0)
|
||||
|
||||
React.useEffect(() => {
|
||||
const timer = setTimeout(() => setProgress(value), 100)
|
||||
return () => clearTimeout(timer)
|
||||
}, [value])
|
||||
|
||||
return (
|
||||
<Progress
|
||||
value={progress}
|
||||
className={cn("h-2 bg-muted", className)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
interface OverviewCardProps {
|
||||
title: string
|
||||
titleExtra?: React.ReactNode
|
||||
headerExtra?: React.ReactNode
|
||||
action?: React.ReactNode
|
||||
loading?: boolean
|
||||
onRefresh?: () => void
|
||||
onViewAll?: () => void
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
function OverviewCard({
|
||||
title,
|
||||
titleExtra,
|
||||
headerExtra,
|
||||
action,
|
||||
loading,
|
||||
onRefresh,
|
||||
onViewAll,
|
||||
children,
|
||||
className
|
||||
}: OverviewCardProps) {
|
||||
return (
|
||||
<Card className={cn("bg-background border-0 shadow-none rounded-lg min-h-[200px] flex flex-col h-full", className)}>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4 h-6">
|
||||
<CardTitle className="text-sm font-medium">{title}</CardTitle>
|
||||
{titleExtra && <div className="font-semibold">{titleExtra}</div>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{action}
|
||||
{onRefresh && (
|
||||
<Button variant="ghost" size="icon" className="size-4" onClick={onRefresh} disabled={loading}>
|
||||
<Spinner className={cn("size-3.5", !loading && "animate-none")} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{headerExtra && <div className="pt-1">{headerExtra}</div>}
|
||||
</CardHeader>
|
||||
<CardContent className="relative flex-1 -mt-4">
|
||||
{children}
|
||||
</CardContent>
|
||||
<CardFooter className="border-t h-8">
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground w-full">
|
||||
<span>更新时间:{new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}</span>
|
||||
{onViewAll && (
|
||||
<Button variant="link" className="h-4 px-0 text-xs text-blue-600" disabled={loading} onClick={onViewAll}>
|
||||
查看全部
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建争议对象
|
||||
* @param dispute 争议对象
|
||||
* @param type 争议类型
|
||||
* @returns 争议对象
|
||||
*/
|
||||
const createDisputeOrder = (dispute: DisputeWithOrder, type: 'receive' | 'payment'): Order => ({
|
||||
id: dispute.order_id,
|
||||
dispute_id: dispute.id,
|
||||
type,
|
||||
status: 'disputing' as const,
|
||||
order_no: '',
|
||||
order_name: dispute.order_name,
|
||||
merchant_order_no: '',
|
||||
payer_user_id: '0',
|
||||
payee_user_id: '0',
|
||||
payer_username: '',
|
||||
payee_username: dispute.payee_username,
|
||||
amount: dispute.amount,
|
||||
remark: '',
|
||||
client_id: '',
|
||||
trade_time: '',
|
||||
expires_at: '',
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
payment_type: '',
|
||||
payee_transfer_status: 'completed' as const,
|
||||
payee_transfer_at: '',
|
||||
})
|
||||
|
||||
/**
|
||||
* 争议列表骨架屏
|
||||
* 用于显示争议列表的加载状态
|
||||
*
|
||||
* @returns 争议列表骨架屏
|
||||
*/
|
||||
const DisputeListSkeleton = () => (
|
||||
<div className="space-y-1">
|
||||
{Array.from({ length: 7 }).map((_, index) => (
|
||||
<div key={`skeleton-${ index }`} className="flex items-center justify-between py-1 px-2 rounded-md bg-muted/50">
|
||||
<div className="flex-1 min-w-0">
|
||||
<Skeleton className="h-3 w-32 mb-1" />
|
||||
<Skeleton className="h-2.5 w-20" />
|
||||
</div>
|
||||
<div className="ml-2">
|
||||
<Skeleton className="size-5 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
/**
|
||||
* 争议列表数据骨架屏
|
||||
*/
|
||||
const PaymentListSkeleton = () => (
|
||||
<div className="space-y-1">
|
||||
{Array.from({ length: 7 }).map((_, index) => (
|
||||
<div key={`payment-skeleton-${ index }`} className="flex items-center justify-between py-1 px-2 rounded-md bg-muted/50">
|
||||
<div className="flex-1 min-w-0">
|
||||
<Skeleton className="h-3 w-32 mb-1" />
|
||||
<Skeleton className="h-2.5 w-20" />
|
||||
</div>
|
||||
<div className="ml-2">
|
||||
<Skeleton className="h-5 w-16 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
/**
|
||||
* 获取活动状态显示文本
|
||||
*/
|
||||
const getStatusText = (status: string) => {
|
||||
const statusMap: Record<string, string> = {
|
||||
success: '成功',
|
||||
pending: '待处理',
|
||||
failed: '失败',
|
||||
expired: '已过期',
|
||||
disputing: '争议中',
|
||||
refund: '已退回',
|
||||
refused: '已拒绝'
|
||||
}
|
||||
return statusMap[status] || status
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取活动状态样式
|
||||
*/
|
||||
const getStatusVariant = (status: string): "default" | "secondary" | "destructive" | "outline" => {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return 'default'
|
||||
case 'pending':
|
||||
return 'secondary'
|
||||
case 'failed':
|
||||
case 'refused':
|
||||
return 'destructive'
|
||||
default:
|
||||
return 'outline'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 活动卡片
|
||||
* 用于显示活动列表数据
|
||||
*
|
||||
* @returns 活动列表卡片
|
||||
*/
|
||||
function PaymentCard({ onViewAll }: { onViewAll: () => void }) {
|
||||
const [isHidden, setIsHidden] = React.useState(false)
|
||||
const [payments, setPayments] = React.useState<Order[]>([])
|
||||
const [loading, setLoading] = React.useState(true)
|
||||
const [total, setTotal] = React.useState(0)
|
||||
|
||||
const fetchPayments = React.useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await TransactionService.getTransactions({
|
||||
page: 1,
|
||||
page_size: 10,
|
||||
types: ['payment'],
|
||||
statuses: ['success']
|
||||
})
|
||||
setPayments(response.orders)
|
||||
setTotal(response.total)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch payments:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
fetchPayments()
|
||||
}, [fetchPayments])
|
||||
|
||||
return (
|
||||
|
||||
<OverviewCard
|
||||
title="活动"
|
||||
titleExtra={loading ? '-' : <CountingNumber number={total} decimalPlaces={0} />}
|
||||
loading={loading}
|
||||
onRefresh={fetchPayments}
|
||||
onViewAll={onViewAll}
|
||||
action={
|
||||
<Button variant="ghost" size="icon" className="size-4" onClick={() => setIsHidden(!isHidden)}>
|
||||
{isHidden ? <EyeOff className="size-3" /> : <Eye className="size-3" />}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<ScrollArea className="h-46">
|
||||
{loading ? (
|
||||
<PaymentListSkeleton />
|
||||
) : payments.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{payments.map((payment) => (
|
||||
<div key={`payment-${ payment.id }`} className="flex items-center justify-between py-1 px-2 rounded-md bg-muted/50">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-medium truncate leading-tight">{payment.order_name}</p>
|
||||
<p className="text-[10px] text-muted-foreground leading-tight">LDC {payment.amount}</p>
|
||||
</div>
|
||||
<Badge variant={getStatusVariant(payment.status)} className="text-[10px] px-1.5 py-0 ml-2">
|
||||
{getStatusText(payment.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-46 flex items-center justify-center">
|
||||
<p className="text-muted-foreground text-xs">暂无积分活动记录</p>
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
{isHidden && (
|
||||
<div className="absolute inset-0 backdrop-blur-md bg-background/30 rounded-lg flex items-center justify-center">
|
||||
<EyeOff className="h-8 w-8 text-muted-foreground/50" />
|
||||
</div>
|
||||
)}
|
||||
</OverviewCard>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 收入统计卡片
|
||||
* 用于显示每日收入统计
|
||||
*
|
||||
* @returns 收入统计卡片
|
||||
*/
|
||||
function IncomeStatsCard() {
|
||||
const [stats, setStats] = React.useState<DailyStatsItem[]>([])
|
||||
const [loading, setLoading] = React.useState(true)
|
||||
const [days] = React.useState(7)
|
||||
|
||||
const fetchDailyStats = React.useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const data = await DashboardService.getDailyStats(days)
|
||||
//setStats(data)
|
||||
const newStats: DailyStatsItem[] = []
|
||||
data.forEach((stat, i) => {
|
||||
let income = parseFloat(stat.income || "0");
|
||||
if (income < 0) {
|
||||
income = 0;
|
||||
}
|
||||
newStats[i] = {
|
||||
date: stat.date,
|
||||
income: String(income),
|
||||
expense: stat.expense
|
||||
}
|
||||
});
|
||||
|
||||
setStats(newStats);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch daily stats:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [days])
|
||||
|
||||
React.useEffect(() => {
|
||||
fetchDailyStats()
|
||||
}, [fetchDailyStats])
|
||||
|
||||
// 计算总收入
|
||||
const totalIncome = stats.reduce((sum, stat) => sum + parseFloat(stat.income || '0'), 0)
|
||||
|
||||
// 找出最大收入值用于比例计算
|
||||
const maxIncome = Math.max(...stats.map(s => parseFloat(s.income || '0')))
|
||||
|
||||
return (
|
||||
<OverviewCard
|
||||
title="7天收入统计"
|
||||
headerExtra={
|
||||
<div className="text-xl font-semibold">
|
||||
{loading ? (
|
||||
<Skeleton className="h-6 w-24" />
|
||||
) : (
|
||||
<>LDC <CountingNumber number={totalIncome} decimalPlaces={2} /></>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
loading={loading}
|
||||
onRefresh={fetchDailyStats}
|
||||
>
|
||||
{loading ? (
|
||||
<ScrollArea className="h-46 w-full">
|
||||
<div className="space-y-1.5 pr-3">
|
||||
{Array.from({ length: 7 }).map((_, index) => (
|
||||
<div key={`income-skeleton-${ index }`} className="space-y-0.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-3 w-10" />
|
||||
<Skeleton className="h-3 w-12" />
|
||||
</div>
|
||||
<Skeleton className="h-2 w-full rounded-sm" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
) : stats.length > 0 ? (
|
||||
<ScrollArea className="h-46 w-full">
|
||||
<div className="space-y-1.5 pr-3">
|
||||
{stats.slice().reverse().map((stat, index) => {
|
||||
const income = parseFloat(stat.income || '0')
|
||||
const incomeWidth = maxIncome > 0 ? (income / maxIncome) * 100 : 0
|
||||
|
||||
return (
|
||||
<div key={stat.date || index} className="space-y-0.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{new Date(stat.date).toLocaleDateString('zh-CN', { month: 'numeric', day: 'numeric' })}
|
||||
</span>
|
||||
<span className="text-[10px] text-green-600 font-medium">
|
||||
+{income.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-muted rounded-sm overflow-hidden h-2">
|
||||
<AnimatedProgressBar
|
||||
value={incomeWidth}
|
||||
className="h-full rounded-sm [&>[data-slot=progress-indicator]]:bg-green-500/80"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
) : (
|
||||
<div className="h-46 w-full flex items-center justify-center border border-dashed rounded-lg">
|
||||
<p className="text-xs text-muted-foreground">暂无统计数据</p>
|
||||
</div>
|
||||
)}
|
||||
</OverviewCard>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 支出统计卡片
|
||||
* 用于显示每日支出统计
|
||||
*
|
||||
* @returns 支出统计卡片
|
||||
*/
|
||||
function ExpenseStatsCard() {
|
||||
const [stats, setStats] = React.useState<DailyStatsItem[]>([])
|
||||
const [loading, setLoading] = React.useState(true)
|
||||
const [days] = React.useState(7)
|
||||
|
||||
const fetchDailyStats = React.useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const data = await DashboardService.getDailyStats(days)
|
||||
//setStats(data)
|
||||
const newStats: DailyStatsItem[] = []
|
||||
data.forEach((stat, i) => {
|
||||
let income = parseFloat(stat.income || '0');
|
||||
let expense = parseFloat(stat.expense || '0');
|
||||
if (income < 0) {
|
||||
expense = expense - income;
|
||||
income = 0;
|
||||
}
|
||||
newStats[i] = {
|
||||
date: stat.date,
|
||||
income: String(income),
|
||||
expense: String(expense),
|
||||
}
|
||||
})
|
||||
setStats(newStats);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch daily stats:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [days])
|
||||
|
||||
React.useEffect(() => {
|
||||
fetchDailyStats()
|
||||
}, [fetchDailyStats])
|
||||
|
||||
/* 计算总支出 */
|
||||
const totalExpense = stats.reduce((sum, stat) => sum + parseFloat(stat.expense || '0'), 0)
|
||||
|
||||
/* 找出最大支出值用于比例计算 */
|
||||
const maxExpense = Math.max(...stats.map(s => parseFloat(s.expense || '0')))
|
||||
|
||||
return (
|
||||
<OverviewCard
|
||||
title="7天支出统计"
|
||||
headerExtra={
|
||||
<div className="text-xl font-semibold">
|
||||
{loading ? (
|
||||
<Skeleton className="h-6 w-24" />
|
||||
) : (
|
||||
<>LDC <CountingNumber number={totalExpense} decimalPlaces={2} /></>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
loading={loading}
|
||||
onRefresh={fetchDailyStats}
|
||||
>
|
||||
{loading ? (
|
||||
<ScrollArea className="h-46 w-full">
|
||||
<div className="space-y-1.5 pr-3">
|
||||
{Array.from({ length: 7 }).map((_, index) => (
|
||||
<div key={`expense-skeleton-${ index }`} className="space-y-0.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-3 w-10" />
|
||||
<Skeleton className="h-3 w-12" />
|
||||
</div>
|
||||
<Skeleton className="h-2 w-full rounded-sm" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
) : stats.length > 0 ? (
|
||||
<ScrollArea className="h-46 w-full">
|
||||
<div className="space-y-1.5 pr-3">
|
||||
{stats.slice().reverse().map((stat, index) => {
|
||||
const expense = parseFloat(stat.expense || '0')
|
||||
const expenseWidth = maxExpense > 0 ? (expense / maxExpense) * 100 : 0
|
||||
|
||||
return (
|
||||
<div key={stat.date || index} className="space-y-0.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{new Date(stat.date).toLocaleDateString('zh-CN', { month: 'numeric', day: 'numeric' })}
|
||||
</span>
|
||||
<span className="text-[10px] text-red-600 font-medium">
|
||||
-{expense.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-muted rounded-sm overflow-hidden h-2">
|
||||
<AnimatedProgressBar
|
||||
value={expenseWidth}
|
||||
className="h-full rounded-sm [&>[data-slot=progress-indicator]]:bg-red-500/80"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
) : (
|
||||
<div className="h-46 w-full flex items-center justify-center border border-dashed rounded-lg">
|
||||
<p className="text-xs text-muted-foreground">暂无统计数据</p>
|
||||
</div>
|
||||
)}
|
||||
</OverviewCard>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Top 客户卡片
|
||||
* 用于显示向当前用户付款最多的客户排行
|
||||
*
|
||||
* @returns Top 客户卡片
|
||||
*/
|
||||
function TopCustomersCard() {
|
||||
const [customers, setCustomers] = React.useState<TopCustomer[]>([])
|
||||
const [loading, setLoading] = React.useState(true)
|
||||
const [days] = React.useState(7)
|
||||
const [limit] = React.useState(5)
|
||||
|
||||
const fetchTopCustomers = React.useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const data = await DashboardService.getTopCustomers(days, limit)
|
||||
setCustomers(data)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch top customers:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [days, limit])
|
||||
|
||||
React.useEffect(() => {
|
||||
fetchTopCustomers()
|
||||
}, [fetchTopCustomers])
|
||||
|
||||
/* 找出最大金额用于显示进度条 */
|
||||
const maxAmount = customers && customers.length > 0
|
||||
? Math.max(...customers.map(c => parseFloat(c.total_amount || '0')))
|
||||
: 0
|
||||
|
||||
return (
|
||||
<OverviewCard
|
||||
title="Top 客户统计"
|
||||
loading={loading}
|
||||
onRefresh={fetchTopCustomers}
|
||||
>
|
||||
<ScrollArea className="h-46">
|
||||
{loading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<div key={`customer-skeleton-${ index }`} className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-3 w-6" />
|
||||
<Skeleton className="h-3 w-20" />
|
||||
</div>
|
||||
<Skeleton className="h-3 w-16" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-2 w-full rounded-full" />
|
||||
<Skeleton className="h-3 w-8" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : customers && customers.length > 0 ? (
|
||||
<div className="space-y-2 pr-3">
|
||||
{customers.map((customer, index) => {
|
||||
const amount = parseFloat(customer.total_amount || '0')
|
||||
const percentage = maxAmount > 0 ? (amount / maxAmount) * 100 : 0
|
||||
|
||||
return (
|
||||
<div key={customer.user_id} className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<span className="text-xs font-semibold text-muted-foreground shrink-0">#{index + 1}</span>
|
||||
<span className="text-xs font-medium truncate">{customer.username}</span>
|
||||
</div>
|
||||
<div className="text-xs font-semibold shrink-0 ml-2">
|
||||
{amount.toFixed(2)} LDC
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<AnimatedProgressBar
|
||||
value={percentage}
|
||||
className="h-2 rounded-full"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground shrink-0 w-8 text-right">
|
||||
{customer.order_count}单
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-46 flex items-center justify-center">
|
||||
<p className="text-xs text-muted-foreground">暂无客户数据</p>
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</OverviewCard>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 待处理的争议卡片
|
||||
* 用于显示待处理的争议数据
|
||||
*
|
||||
* @returns 待处理的争议卡片
|
||||
*/
|
||||
function PendingDisputesCard({ onViewAll }: { onViewAll: () => void }) {
|
||||
const { disputes, loading, handleRefresh, refetchData } = useDisputeData({
|
||||
fetchFn: (params) => DisputeService.listMerchantDisputes(params)
|
||||
})
|
||||
|
||||
return (
|
||||
<OverviewCard
|
||||
title="待处理的争议"
|
||||
titleExtra={loading ? '-' : <CountingNumber number={disputes.count} decimalPlaces={0} />}
|
||||
loading={loading}
|
||||
onRefresh={handleRefresh}
|
||||
onViewAll={onViewAll}
|
||||
>
|
||||
<ScrollArea className="h-46">
|
||||
{loading ? (
|
||||
<DisputeListSkeleton />
|
||||
) : disputes.list.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{disputes.list.map((dispute) => (
|
||||
<div key={`merchant-${ dispute.id }`} className="flex items-center justify-between py-1 px-2 rounded-md bg-muted/50">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-medium truncate leading-tight">{dispute.order_name}</p>
|
||||
<p className="text-[10px] text-muted-foreground leading-tight">{dispute.payee_username}</p>
|
||||
</div>
|
||||
<div className="ml-2">
|
||||
<RefundReviewDialog order={createDisputeOrder(dispute, 'receive')} onSuccess={refetchData} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-46 flex items-center justify-center">
|
||||
<p className="text-muted-foreground text-xs">暂无待处理的积分活动争议</p>
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</OverviewCard>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 我发起的争议卡片
|
||||
* 用于显示我发起的争议数据
|
||||
*
|
||||
* @returns 我发起的争议卡片
|
||||
*/
|
||||
function MyDisputesCard({ onViewAll }: { onViewAll: () => void }) {
|
||||
const { disputes, loading, handleRefresh, refetchData } = useDisputeData({
|
||||
fetchFn: (params) => DisputeService.listDisputes(params)
|
||||
})
|
||||
|
||||
return (
|
||||
<OverviewCard
|
||||
title="我发起的争议"
|
||||
titleExtra={loading ? '-' : <CountingNumber number={disputes.count} decimalPlaces={0} />}
|
||||
loading={loading}
|
||||
onRefresh={handleRefresh}
|
||||
onViewAll={onViewAll}
|
||||
>
|
||||
<ScrollArea className="h-46">
|
||||
{loading ? (
|
||||
<DisputeListSkeleton />
|
||||
) : disputes.list.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{disputes.list.map((dispute) => (
|
||||
<div key={`user-${ dispute.id }`} className="flex items-center justify-between py-1 px-2 rounded-md bg-muted/50">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-medium truncate leading-tight">{dispute.order_name}</p>
|
||||
<p className="text-[10px] text-muted-foreground leading-tight">积分活动发起者正在处理争议</p>
|
||||
</div>
|
||||
<div className="ml-2">
|
||||
<CancelDisputeDialog order={createDisputeOrder(dispute, 'payment')} onSuccess={refetchData} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-46 flex items-center justify-center">
|
||||
<p className="text-muted-foreground text-xs">暂无我发起的积分活动争议</p>
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</OverviewCard>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 概览面板组件
|
||||
* 用于显示概览面板
|
||||
*
|
||||
* @returns 概览面板组件
|
||||
*/
|
||||
export function OverviewPanel() {
|
||||
const router = useRouter()
|
||||
const [disputeDialogOpen, setDisputeDialogOpen] = React.useState(false)
|
||||
const [disputeDialogMode, setDisputeDialogMode] = React.useState<'pending' | 'my-disputes'>('pending')
|
||||
|
||||
const handleViewAllPending = () => {
|
||||
setDisputeDialogMode('pending')
|
||||
setDisputeDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleViewAllMyDisputes = () => {
|
||||
setDisputeDialogMode('my-disputes')
|
||||
setDisputeDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleViewAllPayments = () => {
|
||||
router.push('/trade?type=payment')
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bg-muted rounded-lg p-2 mt-2">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||
<PaymentCard onViewAll={handleViewAllPayments} />
|
||||
<IncomeStatsCard />
|
||||
<ExpenseStatsCard />
|
||||
<TopCustomersCard />
|
||||
<PendingDisputesCard onViewAll={handleViewAllPending} />
|
||||
<MyDisputesCard onViewAll={handleViewAllMyDisputes} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DisputeDialog
|
||||
mode={disputeDialogMode}
|
||||
open={disputeDialogOpen}
|
||||
onOpenChange={setDisputeDialogOpen}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { CircleHelp, RefreshCw } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { useUser } from "@/contexts/user-context"
|
||||
import { useLeaderboard } from "@/hooks/use-leaderboard"
|
||||
import { LeaderboardPodium } from "@/components/common/leaderboard/leaderboard-podium"
|
||||
import { LeaderboardTable } from "@/components/common/leaderboard/leaderboard-table"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
/**
|
||||
* 排行榜主组件
|
||||
*/
|
||||
export function LeaderboardMain() {
|
||||
const { user } = useUser()
|
||||
const {
|
||||
items,
|
||||
myRank,
|
||||
loading,
|
||||
loadingMore,
|
||||
myRankLoading,
|
||||
hasMore,
|
||||
loadNextPage,
|
||||
refresh,
|
||||
} = useLeaderboard()
|
||||
const isInitialLoading = loading && items.length === 0
|
||||
|
||||
const currentUserEntry = React.useMemo(() => {
|
||||
if (!myRank?.user) return undefined
|
||||
|
||||
const matchedEntry = items.find((item) => item.user_id === myRank.user.user_id)
|
||||
if (matchedEntry) return matchedEntry
|
||||
|
||||
if (!user) return undefined
|
||||
|
||||
return {
|
||||
user_id: myRank.user.user_id,
|
||||
username: user.nickname || user.username,
|
||||
avatar_url: user.avatar_url,
|
||||
available_balance: myRank.user.available_balance,
|
||||
}
|
||||
}, [items, myRank, user])
|
||||
|
||||
return (
|
||||
<div className="py-6 space-y-10">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">全局排行榜</h1>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-8 gap-1.5 text-muted-foreground">
|
||||
<CircleHelp className="size-4" />
|
||||
规则
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>排行榜如何运作</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 text-sm text-muted-foreground">
|
||||
<p>排行榜按可用积分实时排序,数据会定期刷新。</p>
|
||||
<p>参与社区活动、进行积分流转,都会影响可用积分与当前排名。</p>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Button variant="ghost" size="icon" className="size-8 text-muted-foreground" onClick={refresh} aria-label="刷新排行榜">
|
||||
<RefreshCw className={cn("size-4", (loading || loadingMore || myRankLoading) && "animate-spin")} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LeaderboardPodium
|
||||
items={items.slice(0, 3)}
|
||||
loading={isInitialLoading}
|
||||
/>
|
||||
|
||||
<section>
|
||||
<h2 className="font-semibold mb-4">完整榜单</h2>
|
||||
<LeaderboardTable
|
||||
items={items}
|
||||
loading={isInitialLoading}
|
||||
currentUserId={myRank?.user.user_id}
|
||||
currentUserEntry={currentUserEntry}
|
||||
currentUserRank={myRank?.user.rank}
|
||||
onLoadMore={loadNextPage}
|
||||
hasMore={hasMore}
|
||||
startRank={1}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { LeaderboardEntry } from "@/lib/services/leaderboard"
|
||||
|
||||
|
||||
interface LeaderboardPodiumProps {
|
||||
items: LeaderboardEntry[]
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
type PodiumRank = 1 | 2 | 3
|
||||
|
||||
function formatBalance(value: string) {
|
||||
return Number.parseFloat(value).toLocaleString("zh-CN", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})
|
||||
}
|
||||
|
||||
const rankConfig = {
|
||||
1: {
|
||||
numColor: "from-amber-500/60 to-amber-500/8",
|
||||
scoreColor: "text-amber-500",
|
||||
avatarSize: "size-16 sm:size-[72px]",
|
||||
numSize: "text-[72px] sm:text-[88px]",
|
||||
nameSize: "text-base font-bold",
|
||||
scoreSize: "text-lg font-black",
|
||||
avatarNegMt: "-mt-6 sm:-mt-7",
|
||||
order: "md:order-2",
|
||||
shift: "",
|
||||
},
|
||||
2: {
|
||||
numColor: "from-zinc-400/45 to-zinc-400/5",
|
||||
scoreColor: "text-zinc-400",
|
||||
avatarSize: "size-12 sm:size-[52px]",
|
||||
numSize: "text-[52px] sm:text-[64px]",
|
||||
nameSize: "text-sm font-semibold",
|
||||
scoreSize: "text-sm font-bold",
|
||||
avatarNegMt: "-mt-5",
|
||||
order: "md:order-1",
|
||||
shift: "md:translate-y-9",
|
||||
},
|
||||
3: {
|
||||
numColor: "from-orange-500/55 to-orange-500/8",
|
||||
scoreColor: "text-orange-500",
|
||||
avatarSize: "size-12 sm:size-[52px]",
|
||||
numSize: "text-[52px] sm:text-[64px]",
|
||||
nameSize: "text-sm font-semibold",
|
||||
scoreSize: "text-sm font-bold",
|
||||
avatarNegMt: "-mt-5",
|
||||
order: "md:order-3",
|
||||
shift: "md:translate-y-14",
|
||||
},
|
||||
} as const
|
||||
|
||||
function PodiumSkeleton() {
|
||||
return (
|
||||
<div className="rounded-2xl bg-muted/25 px-6 pb-10 pt-6 sm:px-14">
|
||||
<div className="grid grid-cols-3 items-end gap-4 sm:gap-12">
|
||||
{([2, 1, 3] as PodiumRank[]).map((rank) => {
|
||||
const cfg = rankConfig[rank]
|
||||
return (
|
||||
<div key={rank} className={cn("flex flex-col items-center gap-0", cfg.order)}>
|
||||
<Skeleton className={cn("rounded-lg", rank === 1 ? "h-[100px] w-[76px]" : "h-[72px] w-[52px]")} />
|
||||
<Skeleton className={cn("shrink-0 rounded-full", cfg.avatarSize, cfg.avatarNegMt)} />
|
||||
<Skeleton className="mt-3 h-4 w-[70%]" />
|
||||
<Skeleton className="mt-1.5 h-4 w-[45%]" />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PodiumColumn({ entry, rank }: { entry: LeaderboardEntry; rank: PodiumRank }) {
|
||||
const cfg = rankConfig[rank]
|
||||
|
||||
return (
|
||||
<article className={cn("flex min-w-0 flex-col items-center", cfg.order, cfg.shift)}>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"select-none bg-clip-text text-transparent font-black leading-none tracking-[-0.05em] -mt-8",
|
||||
cfg.numSize,
|
||||
`bg-linear-to-b ${cfg.numColor}`,
|
||||
)}
|
||||
>
|
||||
{String(rank).padStart(2, "0")}
|
||||
</div>
|
||||
|
||||
<Avatar className={cn("shrink-0 rounded-full", cfg.avatarSize, cfg.avatarNegMt)}>
|
||||
<AvatarImage src={entry.avatar_url} alt={entry.username} className="object-cover" />
|
||||
<AvatarFallback className={cn("rounded-full font-bold", rank === 1 ? "text-sm" : "text-xs")}>
|
||||
{entry.username.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className={cn("mt-2.5 w-full truncate text-center tracking-tight text-foreground", cfg.nameSize)}>
|
||||
{entry.username}
|
||||
</div>
|
||||
|
||||
<div className={cn("mt-0.5 mb-3 tabular-nums tracking-tight", cfg.scoreSize, cfg.scoreColor)}>
|
||||
{formatBalance(entry.available_balance)}
|
||||
</div>
|
||||
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
export const LeaderboardPodium = React.memo(function LeaderboardPodium({
|
||||
items,
|
||||
loading,
|
||||
}: LeaderboardPodiumProps) {
|
||||
if (loading) return <PodiumSkeleton />
|
||||
|
||||
if (items.length === 0) {
|
||||
return <div className="py-16 text-center text-muted-foreground">暂无排行数据</div>
|
||||
}
|
||||
|
||||
const ordered = [
|
||||
items[1] ? { entry: items[1], rank: 2 as PodiumRank } : null,
|
||||
items[0] ? { entry: items[0], rank: 1 as PodiumRank } : null,
|
||||
items[2] ? { entry: items[2], rank: 3 as PodiumRank } : null,
|
||||
].filter(Boolean) as Array<{ entry: LeaderboardEntry; rank: PodiumRank }>
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl bg-muted/25 px-6 pb-10 pt-6 sm:px-14">
|
||||
<div className="grid grid-cols-3 items-end gap-4 sm:gap-12">
|
||||
{ordered.map(({ entry, rank }) => (
|
||||
<PodiumColumn key={entry.user_id} entry={entry} rank={rank} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,182 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { useVirtualizer } from "@tanstack/react-virtual"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { RankRowItem } from "./rank-row-item"
|
||||
import type { LeaderboardEntry } from "@/lib/services/leaderboard"
|
||||
|
||||
const leaderboardTableColumns = "grid-cols-[72px_minmax(0,1fr)_132px] sm:grid-cols-[88px_minmax(0,1fr)_152px]"
|
||||
const leaderboardRowHeight = 60
|
||||
|
||||
interface LeaderboardTableProps {
|
||||
items: LeaderboardEntry[]
|
||||
loading?: boolean
|
||||
currentUserId?: number
|
||||
currentUserEntry?: LeaderboardEntry
|
||||
currentUserRank?: number
|
||||
onLoadMore?: () => void
|
||||
hasMore?: boolean
|
||||
startRank?: number
|
||||
}
|
||||
|
||||
export const LeaderboardTable = React.memo(function LeaderboardTable({
|
||||
items,
|
||||
loading,
|
||||
currentUserId,
|
||||
currentUserEntry,
|
||||
currentUserRank,
|
||||
onLoadMore,
|
||||
hasMore,
|
||||
startRank = 1,
|
||||
}: LeaderboardTableProps) {
|
||||
const parentRef = React.useRef<HTMLDivElement>(null)
|
||||
|
||||
const maxBalance = React.useMemo(() => {
|
||||
if (items.length === 0) return 1
|
||||
return Math.max(...items.map(item => parseFloat(item.available_balance))) || 1
|
||||
}, [items])
|
||||
|
||||
const computePercentage = React.useCallback(
|
||||
(balance: number) => {
|
||||
const denominator = Math.log(maxBalance + 1)
|
||||
if (denominator <= 0) return 0
|
||||
const safeBalance = Math.max(balance, 0)
|
||||
return (Math.log(safeBalance + 1) / denominator) * 100
|
||||
},
|
||||
[maxBalance]
|
||||
)
|
||||
|
||||
const rankedItems = React.useMemo(
|
||||
() =>
|
||||
items
|
||||
.map((entry, index) => ({
|
||||
entry,
|
||||
rank: startRank + index,
|
||||
percentage: computePercentage(parseFloat(entry.available_balance)),
|
||||
}))
|
||||
.filter(({ entry }) => entry.user_id !== currentUserEntry?.user_id),
|
||||
[items, startRank, currentUserEntry?.user_id, computePercentage]
|
||||
)
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: rankedItems.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => leaderboardRowHeight,
|
||||
overscan: 6,
|
||||
useFlushSync: false,
|
||||
})
|
||||
|
||||
const virtualItems = virtualizer.getVirtualItems()
|
||||
|
||||
React.useEffect(() => {
|
||||
const lastItem = virtualItems[virtualItems.length - 1]
|
||||
if (!lastItem) return
|
||||
if (lastItem.index >= rankedItems.length - 1 && hasMore && !loading && onLoadMore) {
|
||||
onLoadMore()
|
||||
}
|
||||
}, [virtualItems, rankedItems.length, hasMore, loading, onLoadMore])
|
||||
|
||||
const currentUserPercentage = currentUserEntry
|
||||
? computePercentage(parseFloat(currentUserEntry.available_balance))
|
||||
: 0
|
||||
|
||||
if (loading && items.length === 0) {
|
||||
return (
|
||||
<div>
|
||||
<div className={`grid ${leaderboardTableColumns} items-center px-3 py-2.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground`}>
|
||||
<div>排名</div>
|
||||
<div>用户</div>
|
||||
<div className="text-right">可用积分</div>
|
||||
</div>
|
||||
<div className="mt-1 overflow-hidden rounded-xl bg-linear-to-b from-muted/30 to-transparent">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className={`grid h-[60px] ${leaderboardTableColumns} items-center gap-3 px-3`}>
|
||||
<Skeleton className="h-4 w-8" />
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="size-9 rounded-full" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
<Skeleton className="ml-auto h-5 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
return <div className="py-16 text-center text-muted-foreground">暂无排行数据</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 列头 */}
|
||||
<div className={`grid ${leaderboardTableColumns} items-center px-3 py-2.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground`}>
|
||||
<div>排名</div>
|
||||
<div>用户</div>
|
||||
<div className="text-right">可用积分</div>
|
||||
</div>
|
||||
|
||||
{/* 当前用户置顶行 */}
|
||||
{currentUserEntry && currentUserRank && (
|
||||
<div className="mt-1 overflow-hidden rounded-xl bg-primary/[0.04]">
|
||||
<RankRowItem
|
||||
entry={currentUserEntry}
|
||||
rank={currentUserRank}
|
||||
percentage={currentUserPercentage}
|
||||
isCurrentUser
|
||||
pinned
|
||||
metaLabel="我的位置"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 榜单主体 — 从 muted 过渡到页面背景色 */}
|
||||
<div className="relative mt-2">
|
||||
<div className="overflow-hidden rounded-xl bg-linear-to-b from-muted/30 via-muted/10 to-transparent">
|
||||
<ScrollArea className="h-[440px] sm:h-[480px]" viewportRef={parentRef}>
|
||||
<div
|
||||
style={{
|
||||
height: `${virtualizer.getTotalSize()}px`,
|
||||
width: "100%",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{virtualItems.map((virtualRow) => {
|
||||
const { entry, rank, percentage } = rankedItems[virtualRow.index]
|
||||
return (
|
||||
<div
|
||||
key={entry.user_id}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: `${virtualRow.size}px`,
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
>
|
||||
<RankRowItem
|
||||
entry={entry}
|
||||
rank={rank}
|
||||
percentage={percentage}
|
||||
isCurrentUser={entry.user_id === currentUserId}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && items.length > 0 && (
|
||||
<div className="py-3 text-center text-sm text-muted-foreground">
|
||||
正在加载更多...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { LeaderboardEntry } from "@/lib/services/leaderboard"
|
||||
|
||||
const leaderboardRowColumns = "grid-cols-[72px_minmax(0,1fr)_112px] sm:grid-cols-[88px_minmax(0,1fr)_128px]"
|
||||
|
||||
interface RankRowItemProps {
|
||||
entry: LeaderboardEntry
|
||||
rank: number
|
||||
percentage?: number
|
||||
isCurrentUser?: boolean
|
||||
metaLabel?: string
|
||||
pinned?: boolean
|
||||
}
|
||||
|
||||
|
||||
export const RankRowItem = React.memo(function RankRowItem({
|
||||
entry,
|
||||
rank,
|
||||
percentage = 0,
|
||||
isCurrentUser,
|
||||
metaLabel,
|
||||
pinned = false,
|
||||
}: RankRowItemProps) {
|
||||
const rankClass =
|
||||
rank === 1 ? "text-amber-500 font-black" :
|
||||
rank === 2 ? "text-zinc-400 font-bold" :
|
||||
rank === 3 ? "text-orange-500 font-bold" :
|
||||
"text-muted-foreground"
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative grid h-[60px] items-center px-3 transition-colors hover:bg-muted/30",
|
||||
leaderboardRowColumns,
|
||||
isCurrentUser && !pinned && "bg-primary/[0.03]",
|
||||
pinned && "bg-transparent"
|
||||
)}
|
||||
>
|
||||
{/* 进度条背景 */}
|
||||
{percentage > 0 && (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-y-0 left-0 transition-all duration-500"
|
||||
style={{
|
||||
width: `${Math.max(percentage, 4)}%`,
|
||||
background: "linear-gradient(to right, oklch(58.5% 51% 277.117 / 0.15) 65%, transparent)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={cn("relative truncate pr-2 text-sm tabular-nums", rankClass)}>
|
||||
#{rank}
|
||||
</div>
|
||||
|
||||
<div className="relative flex min-w-0 items-center gap-3">
|
||||
<Avatar className="size-9 shrink-0">
|
||||
<AvatarImage src={entry.avatar_url} alt={entry.username} />
|
||||
<AvatarFallback className="text-xs font-semibold">
|
||||
{entry.username.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">{entry.username}</div>
|
||||
{metaLabel && (
|
||||
<div className={cn("text-[11px]", pinned ? "text-primary/70" : "text-muted-foreground")}>
|
||||
{metaLabel}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative text-right text-sm font-semibold tabular-nums text-foreground sm:text-[15px]">
|
||||
{parseFloat(entry.available_balance).toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { UserRankResponse } from "@/lib/services/leaderboard"
|
||||
import { UserRound } from "lucide-react"
|
||||
|
||||
interface UserRankCardProps {
|
||||
data: UserRankResponse | null
|
||||
loading?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const UserRankCard = React.memo(function UserRankCard({
|
||||
data,
|
||||
loading,
|
||||
className,
|
||||
}: UserRankCardProps) {
|
||||
if (loading) {
|
||||
return (
|
||||
<section className={cn("space-y-4", className)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<UserRound className="size-4 text-muted-foreground" />
|
||||
<h2 className="text-lg font-semibold">我的排名</h2>
|
||||
</div>
|
||||
<div className="space-y-4 px-1 py-1">
|
||||
{Array.from({ length: 2 }).map((_, index) => (
|
||||
<div key={index} className="space-y-2">
|
||||
<Skeleton className="h-3 w-16" />
|
||||
<Skeleton className="h-8 w-24" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={cn("space-y-4", className)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<UserRound className="size-4 text-muted-foreground" />
|
||||
<h2 className="text-lg font-semibold">我的排名</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5 px-1 py-1">
|
||||
{data ? (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
<div className="text-[11px] font-medium text-muted-foreground">当前名次</div>
|
||||
<div className="text-3xl font-semibold tabular-nums">#{data.user.rank.toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="text-[11px] font-medium text-muted-foreground">可用积分</div>
|
||||
<div className="text-2xl font-semibold tabular-nums">{parseFloat(data.user.available_balance).toFixed(2)}</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<div className="text-[11px] font-medium text-muted-foreground">当前状态</div>
|
||||
<div className="text-base font-medium">暂未进入榜单</div>
|
||||
<div className="text-sm text-muted-foreground">继续积累可用积分后会出现在排行榜中。</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,183 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { useEffect } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Undo2, FileText, Link2 } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { TableFilter, mapDisplayStatusToQuery, type DisplayOrderStatus, type SearchValues } from "@/components/common/general/table-filter"
|
||||
import { TransactionTableList } from "@/components/common/general/table-data"
|
||||
import { type MerchantAPIKey, type OrderType } from "@/lib/services"
|
||||
import { TransactionProvider, useTransaction } from "@/contexts/transaction-context"
|
||||
import { formatLocalDate } from "@/lib/utils"
|
||||
|
||||
/** 集市功能列表 */
|
||||
const MERCHANT_ACTIONS = [
|
||||
{ title: "处理争议", description: "获取此应用的所有争议", icon: Undo2, color: "text-orange-600", bgColor: "bg-orange-50 dark:bg-orange-950/20", action: "refund" as const },
|
||||
{ title: "所有活动", description: "显示此应用的所有活动", icon: FileText, color: "text-blue-600", bgColor: "bg-blue-50 dark:bg-blue-950/20", action: "view-all" as const },
|
||||
{ title: "在线流转", description: "创建在线积分流转活动", icon: Link2, color: "text-purple-600", bgColor: "bg-purple-50 dark:bg-purple-950/20", action: "online-payment" as const },
|
||||
]
|
||||
|
||||
|
||||
/** 获取默认日期范围(最近30天) */
|
||||
const getDefaultDateRange = () => {
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
const from = new Date(today)
|
||||
from.setDate(from.getDate() - 29)
|
||||
return { from, to: today }
|
||||
}
|
||||
|
||||
interface MerchantDataProps {
|
||||
apiKey: MerchantAPIKey
|
||||
}
|
||||
|
||||
/**
|
||||
* 集市数据组件
|
||||
* 显示应用的活动数据和统计信息
|
||||
*/
|
||||
export function MerchantData({ apiKey }: MerchantDataProps) {
|
||||
const { from: start, to: end } = getDefaultDateRange()
|
||||
|
||||
return (
|
||||
<TransactionProvider
|
||||
defaultParams={{
|
||||
page_size: 20,
|
||||
startTime: formatLocalDate(start),
|
||||
endTime: formatLocalDate(end),
|
||||
client_id: apiKey.client_id
|
||||
}}
|
||||
>
|
||||
<MerchantDataContent apiKey={apiKey} />
|
||||
</TransactionProvider>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 集市数据内容组件
|
||||
*/
|
||||
function MerchantDataContent({ apiKey }: MerchantDataProps) {
|
||||
const router = useRouter()
|
||||
const { transactions, total, currentPage, totalPages, pageSize, loading, error, fetchTransactions, goToPage, setPageSize: setPageSizeHandler } = useTransaction()
|
||||
|
||||
const [selectedTypes, setSelectedTypes] = React.useState<OrderType[]>([])
|
||||
const [selectedStatus, setSelectedStatus] = React.useState<DisplayOrderStatus | null>(null)
|
||||
const [selectedQuickSelection, setSelectedQuickSelection] = React.useState<string | null>("最近 1 个月")
|
||||
const [dateRange, setDateRange] = React.useState<{ from: Date; to: Date } | null>(getDefaultDateRange)
|
||||
const [selectedSearch, setSelectedSearch] = React.useState<SearchValues>({})
|
||||
|
||||
const buildQueryParams = React.useCallback((page: number) => {
|
||||
const stateQuery = mapDisplayStatusToQuery(selectedStatus)
|
||||
|
||||
return {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
types: selectedTypes.length > 0 ? selectedTypes : undefined,
|
||||
...stateQuery,
|
||||
startTime: dateRange ? formatLocalDate(dateRange.from) : undefined,
|
||||
endTime: dateRange ? (() => {
|
||||
const endDate = new Date(dateRange.to)
|
||||
endDate.setHours(0, 0, 0, 0)
|
||||
endDate.setDate(endDate.getDate() + 1)
|
||||
return formatLocalDate(endDate)
|
||||
})() : undefined,
|
||||
client_id: apiKey.client_id,
|
||||
id: selectedSearch.id || undefined,
|
||||
order_name: selectedSearch.order_name || undefined,
|
||||
payer_username: selectedSearch.payer_username || undefined,
|
||||
payee_username: selectedSearch.payee_username || undefined,
|
||||
}
|
||||
}, [apiKey.client_id, dateRange, pageSize, selectedSearch, selectedStatus, selectedTypes])
|
||||
|
||||
const clearAllFilters = () => {
|
||||
setSelectedTypes([])
|
||||
setSelectedStatus(null)
|
||||
setDateRange(null)
|
||||
setSelectedQuickSelection(null)
|
||||
setSelectedSearch({})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchTransactions(buildQueryParams(1))
|
||||
}, [buildQueryParams, fetchTransactions])
|
||||
|
||||
const actionHandlers = {
|
||||
refund: () => {
|
||||
setSelectedStatus('disputing')
|
||||
toast.success('待处理的争议', { description: '显示此应用的所有待处理的争议' })
|
||||
},
|
||||
'view-all': () => {
|
||||
clearAllFilters()
|
||||
toast.success('全部活动', { description: '显示此应用的所有积分活动' })
|
||||
},
|
||||
'online-payment': () => router.push(`/merchant/online-paying?apiKeyId=${ apiKey.id }`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="font-semibold mb-4">应用服务</h2>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{MERCHANT_ACTIONS.map((action) => (
|
||||
<button
|
||||
key={action.action}
|
||||
className="rounded-lg p-4 border border-dashed hover:border-primary/50 transition-all text-left group cursor-pointer"
|
||||
onClick={() => actionHandlers[action.action]()}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`rounded-lg p-2 ${ action.bgColor }`}>
|
||||
<action.icon className={`size-4 ${ action.color }`} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium text-sm group-hover:text-foreground">{action.title}</h3>
|
||||
<p className="text-xs text-muted-foreground">{action.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="font-semibold mb-4">活动记录</h2>
|
||||
<div className="space-y-2">
|
||||
<TableFilter
|
||||
enabledFilters={{
|
||||
type: true,
|
||||
status: true,
|
||||
timeRange: true,
|
||||
search: true
|
||||
}}
|
||||
selectedTypes={selectedTypes}
|
||||
selectedStatus={selectedStatus}
|
||||
selectedTimeRange={dateRange}
|
||||
selectedQuickSelection={selectedQuickSelection}
|
||||
onTypeChange={setSelectedTypes}
|
||||
onStatusChange={setSelectedStatus}
|
||||
onTimeRangeChange={setDateRange}
|
||||
onQuickSelectionChange={setSelectedQuickSelection}
|
||||
onSearch={setSelectedSearch}
|
||||
searchValues={selectedSearch}
|
||||
onClearAll={clearAllFilters}
|
||||
enablePagination={true}
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
pageSize={pageSize}
|
||||
total={total}
|
||||
onPageChange={goToPage}
|
||||
onPageSizeChange={setPageSizeHandler}
|
||||
loading={loading}
|
||||
/>
|
||||
|
||||
<TransactionTableList
|
||||
loading={loading}
|
||||
error={error}
|
||||
transactions={transactions}
|
||||
onRetry={() => fetchTransactions(buildQueryParams(1))}
|
||||
emptyDescription="未发现积分活动记录"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { useState, useEffect } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { Info } from "lucide-react"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Spinner } from "@/components/ui/spinner"
|
||||
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import services, { type MerchantAPIKey, type CreateAPIKeyRequest, type UpdateAPIKeyRequest } from "@/lib/services"
|
||||
|
||||
interface MerchantDialogProps {
|
||||
/** 模式:创建或更新 */
|
||||
mode: 'create' | 'update'
|
||||
/** API Key*/
|
||||
apiKey?: MerchantAPIKey
|
||||
/** 创建成功回调 */
|
||||
onSuccess: (newKey: MerchantAPIKey) => void
|
||||
/** 更新成功回调 */
|
||||
onUpdate?: (updatedKey: MerchantAPIKey) => void
|
||||
/** 触发按钮 */
|
||||
trigger?: React.ReactNode
|
||||
/** 自定义创建函数 */
|
||||
createAPIKey?: (data: CreateAPIKeyRequest) => Promise<MerchantAPIKey>
|
||||
/** 更新函数 */
|
||||
updateAPIKey?: (id: string, data: UpdateAPIKeyRequest) => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* 集市中心应用对话框组件
|
||||
* 负责创建和更新集市中心应用的表单和验证逻辑
|
||||
*/
|
||||
export function MerchantDialog({
|
||||
mode,
|
||||
apiKey,
|
||||
onSuccess,
|
||||
onUpdate,
|
||||
trigger,
|
||||
createAPIKey,
|
||||
updateAPIKey
|
||||
}: MerchantDialogProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [processing, setProcessing] = useState(false)
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const [showNotifyTooltip, setShowNotifyTooltip] = useState(false)
|
||||
const [tooltipSide, setTooltipSide] = useState<'top' | 'bottom'>('bottom')
|
||||
const [formData, setFormData] = useState<CreateAPIKeyRequest | UpdateAPIKeyRequest>({
|
||||
app_name: '',
|
||||
app_homepage_url: '',
|
||||
app_description: '',
|
||||
redirect_uri: '',
|
||||
notify_url: '',
|
||||
public_key: '',
|
||||
test_mode: false,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* 获取初始表单数据
|
||||
*/
|
||||
const getInitialFormData = React.useCallback((): CreateAPIKeyRequest | UpdateAPIKeyRequest => {
|
||||
if (mode === 'update' && apiKey) {
|
||||
return {
|
||||
app_name: apiKey.app_name,
|
||||
app_homepage_url: apiKey.app_homepage_url,
|
||||
app_description: apiKey.app_description,
|
||||
redirect_uri: apiKey.redirect_uri,
|
||||
notify_url: apiKey.notify_url,
|
||||
public_key: apiKey.public_key,
|
||||
test_mode: apiKey.test_mode,
|
||||
}
|
||||
}
|
||||
return {
|
||||
app_name: '',
|
||||
app_homepage_url: '',
|
||||
app_description: '',
|
||||
redirect_uri: '',
|
||||
notify_url: '',
|
||||
test_mode: false,
|
||||
}
|
||||
}, [mode, apiKey])
|
||||
|
||||
/**
|
||||
* 初始化表单数据
|
||||
*/
|
||||
useEffect(() => {
|
||||
setFormData(getInitialFormData())
|
||||
}, [getInitialFormData])
|
||||
|
||||
const isValidUrl = (url: string): boolean => {
|
||||
if (!url || url.trim() === '') return false
|
||||
try {
|
||||
const urlObj = new URL(url.trim())
|
||||
return urlObj.protocol === 'http:' || urlObj.protocol === 'https:'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData(getInitialFormData())
|
||||
}
|
||||
|
||||
const validateForm = (): { valid: boolean; error?: string } => {
|
||||
/* 验证必填项 */
|
||||
if (!formData.app_name || !formData.app_homepage_url || !formData.notify_url) {
|
||||
return { valid: false, error: '请填写所有必填项' }
|
||||
}
|
||||
|
||||
/* 验证应用名称长度 */
|
||||
if (formData.app_name.length > 20) {
|
||||
return { valid: false, error: '应用名称不能超过 20 个字符' }
|
||||
}
|
||||
|
||||
/* 验证应用主页 URL */
|
||||
if (!isValidUrl(formData.app_homepage_url)) {
|
||||
return { valid: false, error: '应用主页 URL 格式不正确' }
|
||||
}
|
||||
|
||||
/* 验证回调 URI */
|
||||
if (formData.redirect_uri && !isValidUrl(formData.redirect_uri)) {
|
||||
return { valid: false, error: '回调 URI 格式不正确' }
|
||||
}
|
||||
|
||||
/* 验证通知 URL */
|
||||
if (!isValidUrl(formData.notify_url)) {
|
||||
return { valid: false, error: '通知 URL 格式不正确' }
|
||||
}
|
||||
|
||||
/* 验证应用描述长度 */
|
||||
if (formData.app_description && formData.app_description.length > 100) {
|
||||
return { valid: false, error: '应用描述不能超过 100 个字符' }
|
||||
}
|
||||
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const validation = validateForm()
|
||||
if (!validation.valid) {
|
||||
toast.error('表单验证失败', {
|
||||
description: validation.error
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setProcessing(true)
|
||||
|
||||
if (mode === 'create') {
|
||||
const newKey = await (createAPIKey ? createAPIKey(formData as CreateAPIKeyRequest) : services.merchant.createAPIKey(formData as CreateAPIKeyRequest))
|
||||
|
||||
toast.success('创建成功', {
|
||||
description: '新应用已创建,请妥善保管您的 Client Secret'
|
||||
})
|
||||
|
||||
onSuccess(newKey)
|
||||
} else if (mode === 'update' && apiKey) {
|
||||
if (updateAPIKey) {
|
||||
await updateAPIKey(apiKey.id, formData as UpdateAPIKeyRequest)
|
||||
} else {
|
||||
await services.merchant.updateAPIKey(apiKey.id, formData as UpdateAPIKeyRequest)
|
||||
}
|
||||
|
||||
toast.success('更新成功', {
|
||||
description: '应用信息已更新'
|
||||
})
|
||||
|
||||
/* 更新本地状态 */
|
||||
const updatedKey = { ...apiKey, ...formData }
|
||||
onUpdate?.(updatedKey)
|
||||
}
|
||||
|
||||
setOpen(false)
|
||||
} catch (error) {
|
||||
const errorMessage = (error as Error).message || `无法${mode === 'create' ? '创建' : '更新'}应用`
|
||||
toast.error(`${mode === 'create' ? '创建' : '更新'}失败`, {
|
||||
description: errorMessage
|
||||
})
|
||||
throw error
|
||||
} finally {
|
||||
setProcessing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
if (newOpen && !processing) {
|
||||
resetForm()
|
||||
}
|
||||
setOpen(newOpen)
|
||||
}
|
||||
|
||||
if (!mounted) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
{trigger || (
|
||||
<Button className="h-8 text-xs">
|
||||
{mode === 'create' ? '创建应用' : '更新应用'}
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{mode === 'create' ? '创建应用' : '更新应用信息'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{mode === 'create' ? '您可以创建一个集市中心应用来接入积分服务,请仔细填写以下信息' : '修改集市中心应用的基本信息和配置'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="app_name">应用名称 <span className="text-red-500">*</span></Label>
|
||||
<Input
|
||||
id="app_name"
|
||||
placeholder="您的应用名称。最多 20 个字符,用于标识您的应用"
|
||||
maxLength={20}
|
||||
value={formData.app_name}
|
||||
onChange={(e) => setFormData({ ...formData, app_name: e.target.value })}
|
||||
disabled={processing}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="app_description">应用描述</Label>
|
||||
<Input
|
||||
id="app_description"
|
||||
placeholder="您的应用描述。最多 100 个字符,用于描述您的应用,可选"
|
||||
maxLength={100}
|
||||
value={formData.app_description}
|
||||
onChange={(e) => setFormData({ ...formData, app_description: e.target.value })}
|
||||
disabled={processing}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="app_homepage_url">应用主页 URL <span className="text-red-500">*</span></Label>
|
||||
<Input
|
||||
id="app_homepage_url"
|
||||
type="url"
|
||||
placeholder="URL 必须包含 http:// 或 https:// ,用于展示您的应用主页"
|
||||
maxLength={100}
|
||||
value={formData.app_homepage_url}
|
||||
onChange={(e) => setFormData({ ...formData, app_homepage_url: e.target.value })}
|
||||
disabled={processing}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Label htmlFor="notify_url">通知 URL <span className="text-red-500">*</span></Label>
|
||||
<TooltipProvider>
|
||||
<Tooltip open={showNotifyTooltip} onOpenChange={setShowNotifyTooltip}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center text-muted-foreground hover:text-foreground transition-colors"
|
||||
onMouseEnter={() => {
|
||||
setTooltipSide('bottom')
|
||||
setShowNotifyTooltip(true)
|
||||
}}
|
||||
onClick={() => {
|
||||
setTooltipSide('bottom')
|
||||
setShowNotifyTooltip(!showNotifyTooltip)
|
||||
}}
|
||||
>
|
||||
<Info className="size-3" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side={tooltipSide} className="w-64">
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs">如果您只需要使用在线积分流转服务,建议直接填写 http://localhost:3000/ 即可</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="w-full h-6 text-xs"
|
||||
onClick={() => {
|
||||
setFormData({ ...formData, notify_url: 'http://localhost:3000/' })
|
||||
setShowNotifyTooltip(false)
|
||||
}}
|
||||
>
|
||||
快速填入
|
||||
</Button>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<Input
|
||||
id="notify_url"
|
||||
type="url"
|
||||
placeholder="URL 必须为包含 http:// 或 https:// ,用于接收积分服务成功的异步通知"
|
||||
maxLength={100}
|
||||
value={formData.notify_url}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
setFormData({ ...formData, notify_url: value })
|
||||
if (value.trim()) {
|
||||
setShowNotifyTooltip(false)
|
||||
}
|
||||
}}
|
||||
onClick={() => {
|
||||
if (!formData.notify_url || !formData.notify_url.trim()) {
|
||||
setTooltipSide('top')
|
||||
setShowNotifyTooltip(true)
|
||||
}
|
||||
}}
|
||||
onBlur={() => setShowNotifyTooltip(false)}
|
||||
disabled={processing}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="redirect_uri">回调 URI</Label>
|
||||
<Input
|
||||
id="redirect_uri"
|
||||
type="url"
|
||||
placeholder="URL 必须包含 http:// 或 https:// ,用于接收积分服务完成后的回调,可选"
|
||||
maxLength={100}
|
||||
value={formData.redirect_uri}
|
||||
onChange={(e) => setFormData({ ...formData, redirect_uri: e.target.value })}
|
||||
disabled={processing}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="public_key">Ed25519 公钥 (Base64)</Label>
|
||||
<Input
|
||||
id="public_key"
|
||||
placeholder="可选,用于请求签名验证,必须为 32 字节的 Ed25519 公钥(Base64 编码)"
|
||||
maxLength={100}
|
||||
value={formData.public_key || ''}
|
||||
onChange={(e) => setFormData({ ...formData, public_key: e.target.value })}
|
||||
disabled={processing}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="ghost" disabled={processing} className="h-8 text-xs">取消</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
handleSubmit()
|
||||
}}
|
||||
disabled={processing}
|
||||
className="h-8 text-xs"
|
||||
>
|
||||
{processing ? <><Spinner /> {mode === 'create' ? '创建中' : '更新中'}</> : (mode === 'create' ? '创建' : '更新')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog >
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { Coins } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Spinner } from "@/components/ui/spinner"
|
||||
import { MerchantService, type MerchantAPIKey } from "@/lib/services"
|
||||
|
||||
interface DistributeDialogProps {
|
||||
/** 自定义触发器 */
|
||||
trigger?: React.ReactNode
|
||||
/** 商户凭证 */
|
||||
apiKey?: Pick<MerchantAPIKey, "client_id" | "client_secret">
|
||||
}
|
||||
|
||||
/**
|
||||
* 商户分发对话框
|
||||
* 商户向用户分发积分
|
||||
*/
|
||||
export function DistributeDialog({ trigger, apiKey }: DistributeDialogProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const [userId, setUserId] = useState("")
|
||||
const [username, setUsername] = useState("")
|
||||
const [amount, setAmount] = useState("")
|
||||
const [remark, setRemark] = useState("")
|
||||
|
||||
const resetForm = () => {
|
||||
setUserId("")
|
||||
setUsername("")
|
||||
setAmount("")
|
||||
setRemark("")
|
||||
}
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
if (newOpen && !loading) {
|
||||
resetForm()
|
||||
}
|
||||
setOpen(newOpen)
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!userId.trim()) {
|
||||
toast.error('表单验证失败', { description: '请填写用户 ID' })
|
||||
return
|
||||
}
|
||||
|
||||
if (!username.trim()) {
|
||||
toast.error('表单验证失败', { description: '请填写用户名' })
|
||||
return
|
||||
}
|
||||
|
||||
if (!amount.trim()) {
|
||||
toast.error('表单验证失败', { description: '请填写分发金额' })
|
||||
return
|
||||
}
|
||||
|
||||
const amountNum = parseFloat(amount)
|
||||
if (isNaN(amountNum) || amountNum <= 0) {
|
||||
toast.error('表单验证失败', { description: '分发金额必须大于 0' })
|
||||
return
|
||||
}
|
||||
|
||||
if (amountNum > 999999.99) {
|
||||
toast.error('表单验证失败', { description: '分发金额不能超过 999999.99' })
|
||||
return
|
||||
}
|
||||
|
||||
if (remark.length > 100) {
|
||||
toast.error('表单验证失败', { description: '备注不能超过 100 个字符' })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
|
||||
if (!apiKey?.client_id || !apiKey?.client_secret) {
|
||||
toast.error('缺少应用凭证', { description: '请先创建应用并确认凭证可用' })
|
||||
return
|
||||
}
|
||||
|
||||
const result = await MerchantService.distribute({
|
||||
user_id: Number(userId.trim()),
|
||||
username: username.trim(),
|
||||
amount: amountNum,
|
||||
remark: remark.trim() || undefined,
|
||||
}, {
|
||||
client_id: apiKey.client_id,
|
||||
client_secret: apiKey.client_secret,
|
||||
})
|
||||
|
||||
toast.success('分发成功', {
|
||||
description: `订单号: ${ result.trade_no }`
|
||||
})
|
||||
|
||||
setOpen(false)
|
||||
resetForm()
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : '分发失败'
|
||||
toast.error('分发失败', { description: errorMessage })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
{trigger || (
|
||||
<Button variant="outline" className="text-xs h-8 border-dashed w-full shadow-none">
|
||||
<Coins className="size-3 mr-1" />
|
||||
积分分发
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>积分分发</DialogTitle>
|
||||
<DialogDescription>
|
||||
向指定用户分发积分。您的积分将被扣除,对方收到的积分会扣除分发费率。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="user-id" className="text-xs">
|
||||
用户 ID <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="user-id"
|
||||
placeholder="被分发者的用户 ID"
|
||||
value={userId}
|
||||
onChange={(e) => setUserId(e.target.value)}
|
||||
disabled={loading}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username" className="text-xs">
|
||||
用户名 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="username"
|
||||
placeholder="被分发者的用户名"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
disabled={loading}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="amount" className="text-xs">
|
||||
分发积分 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="amount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="999999.99"
|
||||
placeholder="积分数量,用户将收到扣除分发费率后的积分"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
disabled={loading}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="remark" className="text-xs">
|
||||
备注
|
||||
</Label>
|
||||
<Textarea
|
||||
id="remark"
|
||||
placeholder="积分分发备注,最多100字 (可选)"
|
||||
value={remark}
|
||||
onChange={(e) => setRemark(e.target.value)}
|
||||
disabled={loading}
|
||||
maxLength={100}
|
||||
className="h-16 text-xs resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="ghost" disabled={loading} className="h-8 text-xs">
|
||||
取消
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
onClick={(e) => { e.preventDefault(); handleSubmit() }}
|
||||
disabled={loading}
|
||||
className="h-8 text-xs"
|
||||
>
|
||||
{loading ? <><Spinner /> 分发中</> : '分发'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
import * as React from "react"
|
||||
import { useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { toast } from "sonner"
|
||||
import { Copy, Eye, EyeOff, Trash2, ExternalLink, Edit } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { MerchantDialog } from "@/components/common/merchant/merchant-dialog"
|
||||
import { TestModeToggle } from "@/components/common/merchant/merchant-test"
|
||||
import { DistributeDialog } from "@/components/common/merchant/merchant-distribute"
|
||||
import { formatDateTime } from "@/lib/utils"
|
||||
import { type UpdateAPIKeyRequest, type MerchantAPIKey } from "@/lib/services"
|
||||
|
||||
interface MerchantInfoProps {
|
||||
/** API Key */
|
||||
apiKey: MerchantAPIKey
|
||||
/** 更新回调 */
|
||||
onUpdate?: (updatedKey: MerchantAPIKey) => void
|
||||
/** 删除回调 */
|
||||
onDelete: (id: string) => void
|
||||
/** 更新 API Key */
|
||||
updateAPIKey?: (id: string, data: UpdateAPIKeyRequest) => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* 集市中心应用信息组件
|
||||
* 显示集市中心应用的凭证信息(Client ID 和 Secret)
|
||||
*/
|
||||
export function MerchantInfo({ apiKey, onUpdate, onDelete, updateAPIKey }: MerchantInfoProps) {
|
||||
const [showClientId, setShowClientId] = useState(false)
|
||||
const [showClientSecret, setShowClientSecret] = useState(false)
|
||||
|
||||
/* 复制到剪贴板 */
|
||||
const copyToClipboard = async (text: string, label: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
toast.success(`${ label } 已复制`)
|
||||
} catch {
|
||||
try {
|
||||
const textArea = document.createElement('textarea')
|
||||
textArea.value = text
|
||||
textArea.style.position = 'fixed'
|
||||
textArea.style.opacity = '0'
|
||||
document.body.appendChild(textArea)
|
||||
textArea.select()
|
||||
const successful = document.execCommand('copy')
|
||||
document.body.removeChild(textArea)
|
||||
|
||||
if (successful) {
|
||||
toast.success(`${ label } 已复制`)
|
||||
} else {
|
||||
toast.error('复制失败')
|
||||
}
|
||||
} catch {
|
||||
toast.error('复制失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const maskText = (text: string, showLength: number = 8) => {
|
||||
if (text.length <= showLength * 2) return text
|
||||
return `${ text.substring(0, showLength) }${ '•'.repeat(20) }${ text.substring(text.length - showLength) }`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 sticky top-0">
|
||||
<div>
|
||||
<h2 className="font-semibold mb-4">应用信息</h2>
|
||||
<div className="border border-dashed rounded-lg">
|
||||
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
|
||||
<label className="text-xs font-medium text-muted-foreground">应用名称</label>
|
||||
<div className="flex items-center gap-2 max-w-[70%]">
|
||||
<p className="text-xs font-medium truncate">{apiKey.app_name}</p>
|
||||
{apiKey.test_mode && (
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-orange-500/10 text-orange-600 border border-orange-500/20">
|
||||
测试
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{apiKey.app_description && (
|
||||
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
|
||||
<label className="text-xs font-medium text-muted-foreground">应用描述</label>
|
||||
<p className="text-xs text-muted-foreground truncate text-right max-w-[70%]">{apiKey.app_description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
|
||||
<label className="text-xs font-medium text-muted-foreground">创建时间</label>
|
||||
<p className="text-xs text-muted-foreground text-right max-w-[70%]">{formatDateTime(apiKey.created_at)}</p>
|
||||
</div>
|
||||
|
||||
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
|
||||
<label className="text-xs font-medium text-muted-foreground">应用地址</label>
|
||||
<Link
|
||||
href={apiKey.app_homepage_url}
|
||||
target="_blank"
|
||||
className="text-xs text-indigo-500 hover:underline flex items-center gap-1 text-right max-w-[70%]"
|
||||
>
|
||||
<span className="truncate flex-1 min-w-0">
|
||||
{apiKey.app_homepage_url}
|
||||
</span>
|
||||
<ExternalLink className="size-3 flex-shrink-0 ml-1" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{apiKey.redirect_uri && (
|
||||
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
|
||||
<label className="text-xs font-medium text-muted-foreground">回调 URI</label>
|
||||
<Link
|
||||
href={apiKey.redirect_uri}
|
||||
target="_blank"
|
||||
className="text-xs text-indigo-500 hover:underline flex items-center gap-1 text-right max-w-[70%]"
|
||||
>
|
||||
<span className="truncate flex-1 min-w-0">
|
||||
{apiKey.redirect_uri}
|
||||
</span>
|
||||
<ExternalLink className="size-3 flex-shrink-0 ml-1" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="px-3 py-2 flex items-center justify-between">
|
||||
<label className="text-xs font-medium text-muted-foreground">通知 URL</label>
|
||||
<Link
|
||||
href={apiKey.notify_url}
|
||||
target="_blank"
|
||||
className="text-xs text-indigo-500 hover:underline flex items-center gap-1 text-right max-w-[70%]"
|
||||
>
|
||||
<span className="truncate flex-1 min-w-0">
|
||||
{apiKey.notify_url}
|
||||
</span>
|
||||
<ExternalLink className="size-3 flex-shrink-0 ml-1" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="font-semibold mb-4">API 配置</h2>
|
||||
<div className="border border-dashed rounded-lg px-3 py-2 space-y-4">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-xs font-medium text-muted-foreground">Client ID</label>
|
||||
<span className="text-[10px] text-muted-foreground">客户端标识</span>
|
||||
</div>
|
||||
<div className="flex items-center p-2 h-8 border border-dashed rounded-sm">
|
||||
<code className="text-xs text-muted-foreground font-mono flex-1 overflow-x-auto p-1 [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
|
||||
{showClientId ? apiKey.client_id : maskText(apiKey.client_id, 8)}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="size-6 p-1"
|
||||
onClick={() => setShowClientId(!showClientId)}
|
||||
>
|
||||
{showClientId ? <EyeOff className="size-3 text-muted-foreground" /> : <Eye className="size-3 text-muted-foreground" />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="size-6 p-1"
|
||||
onClick={() => copyToClipboard(apiKey.client_id, 'Client ID')}
|
||||
>
|
||||
<Copy className="size-3 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-xs font-medium text-muted-foreground">Client Secret</label>
|
||||
<span className="text-[10px] text-muted-foreground">客户端密钥</span>
|
||||
</div>
|
||||
<div className="flex items-center p-2 h-8 border border-dashed rounded-sm">
|
||||
<code className="text-xs text-muted-foreground font-mono flex-1 overflow-x-auto p-1 [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
|
||||
{showClientSecret ? apiKey.client_secret : '•'.repeat(40)}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="size-6 p-1"
|
||||
onClick={() => setShowClientSecret(!showClientSecret)}
|
||||
>
|
||||
{showClientSecret ? <EyeOff className="size-3 text-muted-foreground" /> : <Eye className="size-3 text-muted-foreground" />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => copyToClipboard(apiKey.client_secret, 'Client Secret')}
|
||||
className="size-6 p-1"
|
||||
>
|
||||
<Copy className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="font-semibold mb-4">应用管理</h2>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<MerchantDialog
|
||||
mode="update"
|
||||
apiKey={apiKey}
|
||||
onSuccess={() => { }}
|
||||
onUpdate={onUpdate}
|
||||
updateAPIKey={updateAPIKey}
|
||||
trigger={
|
||||
<Button variant="outline" className="text-xs text-primary h-8 border-dashed border-primary/50 hover:bg-primary/5 w-full">
|
||||
<Edit className="size-3 mr-1" />
|
||||
编辑应用
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<DistributeDialog apiKey={apiKey} />
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="outline" className="text-xs text-destructive h-8 border-dashed border-destructive/50 hover:bg-destructive/5 w-full">
|
||||
<Trash2 className="size-3 mr-1" />
|
||||
删除应用
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除应用</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确定要删除应用 “{apiKey.app_name}” 吗?
|
||||
此操作将永久删除该应用的所有凭证和配置,且无法恢复。
|
||||
使用此应用凭证的所有集成将立即失效。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => onDelete(apiKey.id)}
|
||||
className="bg-destructive hover:bg-destructive/90"
|
||||
>
|
||||
确认删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<TestModeToggle
|
||||
apiKey={apiKey}
|
||||
onUpdate={onUpdate}
|
||||
updateAPIKey={updateAPIKey}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { useState, useEffect } from "react"
|
||||
import { motion } from "motion/react"
|
||||
import { toast } from "sonner"
|
||||
import { ErrorDisplay } from "@/components/layout/error"
|
||||
import { EmptyState } from "@/components/layout/empty"
|
||||
import { LoadingPage } from "@/components/layout/loading"
|
||||
import { MerchantSelector } from "@/components/common/merchant/merchant-selector"
|
||||
import { MerchantInfo } from "@/components/common/merchant/merchant-info"
|
||||
import { MerchantData } from "@/components/common/merchant/merchant-data"
|
||||
import { MerchantDialog } from "@/components/common/merchant/merchant-dialog"
|
||||
import { type MerchantAPIKey } from "@/lib/services"
|
||||
import { useMerchant } from "@/contexts/merchant-context"
|
||||
|
||||
/**
|
||||
* 集市中心主页面组件
|
||||
* 负责组装集市中心的各个子组件
|
||||
*/
|
||||
export function MerchantMain() {
|
||||
const { apiKeys, loading, error, loadAPIKeys, createAPIKey, updateAPIKey, deleteAPIKey } = useMerchant()
|
||||
const [selectedKeyId, setSelectedKeyId] = useState<string | null>(null)
|
||||
|
||||
const selectedKey = apiKeys.find(key => key.id === selectedKeyId) || null
|
||||
|
||||
/* 加载 API Keys */
|
||||
useEffect(() => {
|
||||
loadAPIKeys()
|
||||
}, [loadAPIKeys])
|
||||
|
||||
/* 选择默认 API Key */
|
||||
useEffect(() => {
|
||||
if (apiKeys.length > 0 && !selectedKeyId) {
|
||||
setSelectedKeyId(apiKeys[0].id)
|
||||
}
|
||||
}, [apiKeys, selectedKeyId])
|
||||
|
||||
/* 创建成功回调 */
|
||||
const handleCreateSuccess = (newKey: MerchantAPIKey) => {
|
||||
setSelectedKeyId(newKey.id)
|
||||
}
|
||||
|
||||
/* 删除成功回调 */
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteAPIKey(id)
|
||||
toast.success('删除成功')
|
||||
|
||||
if (selectedKeyId === id) {
|
||||
const remainingKeys = apiKeys.filter(key => key && key.id !== id)
|
||||
setSelectedKeyId(remainingKeys.length > 0 ? remainingKeys[0].id : null)
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('删除失败', {
|
||||
description: (error as Error).message || '无法删除应用'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/* 正在加载中 */
|
||||
if (loading) {
|
||||
return <LoadingPage text="集市中心" badgeText="集市" />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-6">
|
||||
<div className="flex items-center justify-between border-b pb-2 mb-4">
|
||||
<h1 className="text-2xl font-semibold">集市中心</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
{apiKeys.length > 0 && (
|
||||
<MerchantSelector
|
||||
apiKeys={apiKeys}
|
||||
selectedKeyId={selectedKeyId}
|
||||
onSelect={setSelectedKeyId}
|
||||
/>
|
||||
)}
|
||||
<MerchantDialog
|
||||
mode="create"
|
||||
onSuccess={handleCreateSuccess}
|
||||
createAPIKey={createAPIKey}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<motion.div
|
||||
key="error"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||
className="flex items-center justify-center min-h-[60vh]"
|
||||
>
|
||||
<ErrorDisplay
|
||||
title="加载失败"
|
||||
message={error}
|
||||
onRetry={loadAPIKeys}
|
||||
retryText="重试"
|
||||
/>
|
||||
</motion.div>
|
||||
) : apiKeys.length === 0 ? (
|
||||
<motion.div
|
||||
key="empty"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||
className="flex items-center justify-center min-h-[60vh]"
|
||||
>
|
||||
<EmptyState
|
||||
title="应用列表为空"
|
||||
description="请创建您的第一个集市应用,开始您的积分服务"
|
||||
/>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="content"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||
className="space-y-6"
|
||||
>
|
||||
{selectedKey && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2">
|
||||
<MerchantData apiKey={selectedKey} />
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-1">
|
||||
<MerchantInfo
|
||||
apiKey={selectedKey}
|
||||
onDelete={handleDelete}
|
||||
updateAPIKey={updateAPIKey}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,836 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { motion, AnimatePresence } from "motion/react"
|
||||
import { Key, Smartphone, Tablet, Monitor, RotateCw, Plus, Trash2, Copy, ExternalLink, CreditCard, Store, Loader2, Eye, EyeOff, Expand, Minimize, Sun, Moon } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Spinner } from "@/components/ui/spinner"
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetFooter } from "@/components/ui/sheet"
|
||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"
|
||||
import { useMerchant } from "@/contexts/merchant-context"
|
||||
import { TransactionProvider, useTransaction } from "@/contexts/transaction-context"
|
||||
import { useUser } from "@/contexts/user-context"
|
||||
import { MerchantService, ConfigService, type PaymentLink, type GetMerchantOrderResponse, type MerchantAPIKey, type UserPayConfig } from "@/lib/services"
|
||||
import { PayingInfo } from "@/components/common/pay/paying/paying-info"
|
||||
import { PayingNow } from "@/components/common/pay/paying/paying-now"
|
||||
import { MerchantSelector } from "@/components/common/merchant/merchant-selector"
|
||||
import { LoadingPage } from "@/components/layout/loading"
|
||||
import { EmptyState } from "@/components/layout/empty"
|
||||
import { TransactionTableList } from "@/components/common/general/table-data"
|
||||
|
||||
/** 设备预览配置 */
|
||||
const DEVICE_CONFIG = {
|
||||
mobile: {
|
||||
width: 375,
|
||||
height: 812,
|
||||
borderRadius: 50,
|
||||
borderWidth: 14,
|
||||
scale: 0.6
|
||||
},
|
||||
tablet: {
|
||||
width: 768,
|
||||
height: 1024,
|
||||
borderRadius: 32,
|
||||
borderWidth: 18,
|
||||
scale: 0.45
|
||||
},
|
||||
desktop: {
|
||||
width: 1200,
|
||||
height: 750,
|
||||
borderRadius: 16,
|
||||
borderWidth: 8,
|
||||
scale: 0.35
|
||||
}
|
||||
} as const
|
||||
|
||||
export function MerchantOnline() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { apiKeys, loading: loadingKeys, loadAPIKeys } = useMerchant()
|
||||
|
||||
useEffect(() => {
|
||||
loadAPIKeys()
|
||||
}, [loadAPIKeys])
|
||||
const apiKeyId = searchParams.get("apiKeyId")
|
||||
const selectedKey = apiKeys.find(k => k.id.toString() === apiKeyId) || null
|
||||
|
||||
if (loadingKeys) {
|
||||
return <LoadingPage text="在线流转" badgeText="积分" />
|
||||
}
|
||||
|
||||
if (apiKeys.length === 0) {
|
||||
return (
|
||||
<div className="py-6">
|
||||
<div className="flex items-center justify-between border-b pb-2 mb-4 shrink-0">
|
||||
<h1 className="text-2xl font-semibold">在线流转</h1>
|
||||
<Button variant="outline" size="sm" className="h-8 text-xs" onClick={() => router.push('/merchant')}>
|
||||
<Store className="size-3 mr-1" /> 前往集市
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<EmptyState
|
||||
title="暂未创建应用"
|
||||
description="您还没有创建任何应用。请前往集市中心创建应用,即可开始使用在线积分流转服务。"
|
||||
icon={Key}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<TransactionProvider
|
||||
defaultParams={{
|
||||
page_size: 20,
|
||||
types: ['online'],
|
||||
client_id: selectedKey?.client_id
|
||||
}}
|
||||
>
|
||||
<MerchantOnlineContent apiKeys={apiKeys} />
|
||||
</TransactionProvider>
|
||||
)
|
||||
}
|
||||
|
||||
interface MerchantOnlineContentProps {
|
||||
apiKeys: MerchantAPIKey[]
|
||||
}
|
||||
|
||||
function MerchantOnlineContent({ apiKeys }: MerchantOnlineContentProps) {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const {
|
||||
transactions,
|
||||
loading: loadingTransactions,
|
||||
error: transactionsError,
|
||||
fetchTransactions,
|
||||
refresh
|
||||
} = useTransaction()
|
||||
|
||||
/* 派生 selectedKey */
|
||||
const apiKeyId = searchParams.get("apiKeyId")
|
||||
|
||||
/* 处理集市应用切换 */
|
||||
const handleMerchantSelect = useCallback((id: string) => {
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
params.set("apiKeyId", id.toString())
|
||||
router.push(`${ window.location.pathname }?${ params.toString() }`)
|
||||
}, [searchParams, router])
|
||||
|
||||
/* 自动选择第一个集市应用 */
|
||||
useEffect(() => {
|
||||
if (apiKeys.length > 0 && !apiKeyId) {
|
||||
handleMerchantSelect(apiKeys[0].id)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [apiKeys.length, apiKeyId])
|
||||
|
||||
const selectedKey = apiKeys.find(k => k.id.toString() === apiKeyId) || null
|
||||
|
||||
/* 集市状态 */
|
||||
const [paymentLinks, setPaymentLinks] = useState<PaymentLink[]>([])
|
||||
const [selectedLink, setSelectedLink] = useState<PaymentLink | null>(null)
|
||||
const [previewLink, setPreviewLink] = useState<PaymentLink | null>(null)
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
/* 创建/编辑表单状态 */
|
||||
const [productName, setProductName] = useState("")
|
||||
const [amount, setAmount] = useState("")
|
||||
const [remark, setRemark] = useState("")
|
||||
const [totalLimit, setTotalLimit] = useState("")
|
||||
const [userLimit, setUserLimit] = useState("")
|
||||
|
||||
/* 设备预览状态 */
|
||||
const [previewDevice, setPreviewDevice] = useState<'mobile' | 'tablet' | 'desktop'>('mobile')
|
||||
const [showToken, setShowToken] = useState(false)
|
||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||
const [previewTheme, setPreviewTheme] = useState<'light' | 'dark'>('light')
|
||||
|
||||
/* 获取应用列表 */
|
||||
const fetchLinks = useCallback(async () => {
|
||||
if (!selectedKey) {
|
||||
setPaymentLinks([])
|
||||
return
|
||||
}
|
||||
try {
|
||||
setLoading(true)
|
||||
const links = await MerchantService.listPaymentLinks(selectedKey.id)
|
||||
setPaymentLinks(links)
|
||||
/* 自动设置第一个应用为预览 */
|
||||
if (links.length > 0) {
|
||||
setPreviewLink(prev => prev || links[0])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
toast.error("获取应用列表失败")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [selectedKey])
|
||||
|
||||
/* 当 selectedKey 变化时,重新获取应用列表和数据 */
|
||||
useEffect(() => {
|
||||
if (selectedKey?.client_id) {
|
||||
fetchLinks()
|
||||
fetchTransactions({
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
types: ['online'],
|
||||
client_id: selectedKey.client_id
|
||||
})
|
||||
setIsCreating(false)
|
||||
setSelectedLink(null)
|
||||
} else {
|
||||
setPaymentLinks([])
|
||||
setIsCreating(false)
|
||||
setSelectedLink(null)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedKey?.client_id])
|
||||
|
||||
/* 处理创建 */
|
||||
const handleCreate = async () => {
|
||||
if (!selectedKey) return
|
||||
if (!productName || !amount) {
|
||||
toast.error("请填写在线积分流转服务的名称和数量")
|
||||
return
|
||||
}
|
||||
if (isNaN(parseFloat(amount)) || parseFloat(amount) <= 0) {
|
||||
toast.error("积分数量必须设置为大于0的数值")
|
||||
return
|
||||
}
|
||||
|
||||
// 验证限制字段
|
||||
if (totalLimit && (isNaN(parseInt(totalLimit)) || parseInt(totalLimit) < 1)) {
|
||||
toast.error("总数量限制必须是大于0的整数")
|
||||
return
|
||||
}
|
||||
if (userLimit && (isNaN(parseInt(userLimit)) || parseInt(userLimit) < 1)) {
|
||||
toast.error("单用户限制必须是大于0的整数")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
const newLink = await MerchantService.createPaymentLink(selectedKey.id, {
|
||||
product_name: productName,
|
||||
amount: parseFloat(amount),
|
||||
remark,
|
||||
...(totalLimit && { total_limit: parseInt(totalLimit) }),
|
||||
...(userLimit && { user_limit: parseInt(userLimit) })
|
||||
})
|
||||
toast.success("在线积分流转服务创建成功")
|
||||
/* 重置表单 */
|
||||
setProductName("")
|
||||
setAmount("")
|
||||
setRemark("")
|
||||
setTotalLimit("")
|
||||
setUserLimit("")
|
||||
|
||||
/* 更新UI */
|
||||
fetchLinks()
|
||||
setIsCreating(false)
|
||||
setSelectedLink(newLink)
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "在线积分流转服务创建失败"
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
/* 处理删除 */
|
||||
const handleDelete = async (link: PaymentLink) => {
|
||||
if (!selectedKey) return
|
||||
if (!confirm("确定要删除这个在线积分流转服务吗?该操作不可恢复。")) return
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
await MerchantService.deletePaymentLink(selectedKey.id, link.id)
|
||||
toast.success("在线积分流转服务已删除")
|
||||
if (selectedLink?.id === link.id) {
|
||||
setSelectedLink(null)
|
||||
}
|
||||
fetchLinks()
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "在线积分流转服务删除失败"
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
/* 处理编辑 */
|
||||
const handleEdit = (link: PaymentLink) => {
|
||||
setProductName(link.product_name)
|
||||
setAmount(link.amount)
|
||||
setRemark(link.remark || "")
|
||||
setTotalLimit(link.total_limit?.toString() || "")
|
||||
setUserLimit(link.user_limit?.toString() || "")
|
||||
setIsEditing(true)
|
||||
}
|
||||
|
||||
/* 处理更新 */
|
||||
const handleUpdate = async () => {
|
||||
if (!selectedKey || !selectedLink) return
|
||||
if (!productName || !amount) {
|
||||
toast.error("请填写在线积分流转服务的名称和数量")
|
||||
return
|
||||
}
|
||||
if (isNaN(parseFloat(amount)) || parseFloat(amount) <= 0) {
|
||||
toast.error("积分数量必须设置为大于0的数值")
|
||||
return
|
||||
}
|
||||
|
||||
// 验证限制字段
|
||||
if (totalLimit && (isNaN(parseInt(totalLimit)) || parseInt(totalLimit) < 1)) {
|
||||
toast.error("总数量限制必须是大于0的整数")
|
||||
return
|
||||
}
|
||||
if (userLimit && (isNaN(parseInt(userLimit)) || parseInt(userLimit) < 1)) {
|
||||
toast.error("单用户限制必须是大于0的整数")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
await MerchantService.updatePaymentLink(selectedKey.id, selectedLink.id, {
|
||||
product_name: productName,
|
||||
amount: parseFloat(amount),
|
||||
remark,
|
||||
...(totalLimit && { total_limit: parseInt(totalLimit) }),
|
||||
...(userLimit && { user_limit: parseInt(userLimit) })
|
||||
})
|
||||
toast.success("在线积分流转服务已更新")
|
||||
|
||||
/* 更新UI */
|
||||
fetchLinks()
|
||||
setIsEditing(false)
|
||||
setSelectedLink(null)
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "在线积分流转服务更新失败"
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
/* 处理复制链接 */
|
||||
const handleCopyLink = (token: string) => {
|
||||
const url = `${ window.location.origin }/paying/online?token=${ token }`
|
||||
navigator.clipboard.writeText(url)
|
||||
toast.success("在线积分流转服务链接已复制")
|
||||
}
|
||||
|
||||
/* 获取用户支付配置 */
|
||||
const [userPayConfigs, setUserPayConfigs] = useState<UserPayConfig[]>([])
|
||||
const { user } = useUser()
|
||||
|
||||
useEffect(() => {
|
||||
const loadConfigs = async () => {
|
||||
try {
|
||||
const configs = await ConfigService.getUserPayConfigs()
|
||||
setUserPayConfigs(configs)
|
||||
} catch (error) {
|
||||
console.error("Failed to load user pay configs:", error)
|
||||
}
|
||||
}
|
||||
loadConfigs()
|
||||
}, [])
|
||||
|
||||
/* 预览订单信息 */
|
||||
const currentUserConfig = userPayConfigs.find(c => user?.pay_level !== undefined && c.level === user.pay_level)
|
||||
|
||||
const previewOrderInfo: GetMerchantOrderResponse = {
|
||||
merchant: {
|
||||
app_name: selectedKey?.app_name || "服务名称",
|
||||
redirect_uri: "",
|
||||
},
|
||||
order: {
|
||||
id: '0',
|
||||
order_no: previewLink ? `LINK-${ previewLink.id }` : "PREVIEW",
|
||||
order_name: isCreating ? (productName || "服务名称") : (previewLink?.product_name || "服务名称"),
|
||||
payer_username: "user",
|
||||
payee_username: selectedKey?.app_name || "应用",
|
||||
amount: isCreating ? (amount || "0.00") : (previewLink?.amount || "0.00"),
|
||||
status: "pending",
|
||||
type: "payment",
|
||||
payment_type: "api",
|
||||
remark: isCreating ? remark : (previewLink?.remark || ""),
|
||||
client_id: "",
|
||||
return_url: "",
|
||||
notify_url: "",
|
||||
trade_time: null,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
},
|
||||
user_pay_config: currentUserConfig ? {
|
||||
...currentUserConfig,
|
||||
fee_rate: String(currentUserConfig.fee_rate)
|
||||
} : {
|
||||
id: '0',
|
||||
level: user?.pay_level ?? 0,
|
||||
min_score: 0,
|
||||
max_score: null,
|
||||
daily_limit: null,
|
||||
fee_rate: "0.00",
|
||||
created_at: "",
|
||||
updated_at: ""
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-6">
|
||||
<div className="flex items-center justify-between border-b pb-2 mb-4 shrink-0">
|
||||
<h1 className="text-2xl font-semibold">在线流转</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
{apiKeys.length > 0 && (
|
||||
<MerchantSelector
|
||||
apiKeys={apiKeys}
|
||||
selectedKeyId={selectedKey?.id || null}
|
||||
onSelect={handleMerchantSelect}
|
||||
/>
|
||||
)}
|
||||
<Button variant="outline" size="sm" className="h-8 text-xs" onClick={() => router.push('/merchant')}>
|
||||
<Store className="size-3 mr-1" /> 前往集市
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!selectedKey ? (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<EmptyState
|
||||
title={apiKeys.length === 0 ? "暂未创建应用" : "未选择应用"}
|
||||
description={apiKeys.length === 0
|
||||
? "您还没有创建任何应用。请前往集市中心创建一个应用,即可开始管理在线积分流转服务。"
|
||||
: "请先在右上角选择一个应用以管理此应用的所有在线积分流转服务。"}
|
||||
icon={Key}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-6 h-full min-h-0">
|
||||
<div className="space-y-4 shrink-0">
|
||||
<h2 className="font-semibold -mt-2">所有服务</h2>
|
||||
<ScrollArea className="w-full whitespace-nowrap">
|
||||
<div className="flex gap-4 pb-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsCreating(true)
|
||||
setSelectedLink(null)
|
||||
setProductName("")
|
||||
setAmount("")
|
||||
setRemark("")
|
||||
setTotalLimit("")
|
||||
setUserLimit("")
|
||||
}}
|
||||
className="rounded-lg p-4 border border-dashed hover:border-primary/50 shadow-none transition-all text-left group bg-background min-h-[100px] w-[180px] shrink-0 flex flex-col items-center justify-center gap-2 cursor-pointer"
|
||||
>
|
||||
<div className="rounded-lg p-2 bg-purple-50 dark:bg-purple-950/20">
|
||||
<Plus className="size-4 text-purple-600" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="font-medium text-sm group-hover:text-foreground">创建服务</h3>
|
||||
<p className="text-xs text-muted-foreground">添加新的在线积分流转服务</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{loading && paymentLinks.length === 0 && (
|
||||
<div className="flex items-center justify-center h-[100px] w-[180px] shrink-0">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{paymentLinks.map((link) => (
|
||||
<button
|
||||
key={link.id}
|
||||
onClick={() => {
|
||||
if (previewLink?.id === link.id) {
|
||||
setSelectedLink(link)
|
||||
setIsCreating(false)
|
||||
} else {
|
||||
setPreviewLink(link)
|
||||
setIsCreating(false)
|
||||
toast.info("再次点击打开详情", {
|
||||
duration: 1500,
|
||||
position: "bottom-center"
|
||||
})
|
||||
}
|
||||
}}
|
||||
className={`rounded-lg p-4 border border-dashed shadow-none transition-all text-left group bg-background min-h-[100px] w-[180px] shrink-0 flex flex-col justify-between ${ previewLink?.id === link.id
|
||||
? "border-primary bg-primary/5"
|
||||
: "hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 w-full">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-medium text-sm truncate">{link.product_name}</h3>
|
||||
<p className="text-xs text-muted-foreground truncate">{link.remark || "无备注"}</p>
|
||||
</div>
|
||||
<div className="rounded-lg p-1.5 bg-green-50 dark:bg-green-950/20 shrink-0">
|
||||
<CreditCard className="size-3 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1 mt-2">
|
||||
<span className="text-xs text-muted-foreground">LDC</span>
|
||||
<span className="text-lg font-mono font-semibold">{parseFloat(link.amount).toFixed(2)}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between shrink-0">
|
||||
<h2 className="font-semibold">活动数据</h2>
|
||||
</div>
|
||||
<div className="h-[600px] overflow-hidden">
|
||||
<TransactionTableList
|
||||
loading={loadingTransactions}
|
||||
error={transactionsError}
|
||||
transactions={transactions}
|
||||
onRetry={refresh}
|
||||
emptyDescription="暂无在线流转服务数据"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`space-y-4 ${ isFullscreen ? 'fixed inset-0 z-50 bg-background/95 backdrop-blur-sm p-6 flex flex-col' : '' }`}>
|
||||
<div className="flex items-center justify-between shrink-0">
|
||||
<h2 className="font-semibold">{isFullscreen ? '全屏预览' : '实时预览'}</h2>
|
||||
</div>
|
||||
<div className={`border-none overflow-hidden relative h-[600px] ${ isFullscreen ? 'border-0 rounded-none h-full flex-1' : '' }`}>
|
||||
<div className="absolute z-15 left-1/2 -translate-x-1/2 top-4">
|
||||
<div className="h-8 bg-background border border-border rounded-full p-1.5 flex items-center gap-0.5 shadow-lg">
|
||||
<Button variant={previewDevice === 'mobile' ? 'secondary' : 'ghost'} size="icon" className="size-6 rounded-full" onClick={() => setPreviewDevice('mobile')}>
|
||||
<Smartphone className="size-3.5" />
|
||||
</Button>
|
||||
<Button variant={previewDevice === 'tablet' ? 'secondary' : 'ghost'} size="icon" className="size-6 rounded-full" onClick={() => setPreviewDevice('tablet')}>
|
||||
<Tablet className="size-3.5" />
|
||||
</Button>
|
||||
<Button variant={previewDevice === 'desktop' ? 'secondary' : 'ghost'} size="icon" className="size-6 rounded-full" onClick={() => setPreviewDevice('desktop')}>
|
||||
<Monitor className="size-3.5" />
|
||||
</Button>
|
||||
<div className="w-px h-5 bg-border mx-1" />
|
||||
<Button variant="ghost" size="icon" className="size-6 rounded-full" onClick={() => setIsFullscreen(!isFullscreen)}>
|
||||
{isFullscreen ? <Minimize className="size-3.5" /> : <Expand className="size-3.5" />}
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="size-6 rounded-full" onClick={() => { const current = previewDevice; setPreviewDevice('mobile'); setTimeout(() => setPreviewDevice(current), 50) }}>
|
||||
<RotateCw className="size-3.5" />
|
||||
</Button>
|
||||
<div className="w-px h-5 bg-border mx-1" />
|
||||
<Button variant="ghost" size="icon" className="size-6 rounded-full" onClick={() => setPreviewTheme(previewTheme === 'dark' ? 'light' : 'dark')}>
|
||||
{previewTheme === 'dark' ? <Sun className="size-3.5" /> : <Moon className="size-3.5" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`flex justify-center items-center p-6 relative overflow-hidden h-full ${ isFullscreen ? 'min-h-screen' : '' }`}>
|
||||
<div className="absolute inset-0 pattern-grid-lg opacity-5" />
|
||||
<motion.div
|
||||
layout
|
||||
initial={false}
|
||||
animate={{
|
||||
width: DEVICE_CONFIG[previewDevice].width,
|
||||
height: DEVICE_CONFIG[previewDevice].height,
|
||||
borderRadius: DEVICE_CONFIG[previewDevice].borderRadius,
|
||||
borderWidth: DEVICE_CONFIG[previewDevice].borderWidth,
|
||||
scale: isFullscreen
|
||||
? (previewDevice === 'mobile' ? 0.9 : previewDevice === 'tablet' ? 0.75 : 0.65)
|
||||
: DEVICE_CONFIG[previewDevice].scale,
|
||||
}}
|
||||
transition={{ type: "spring", stiffness: 200, damping: 25 }}
|
||||
className="shrink-0 overflow-hidden shadow-2xl bg-black relative border-[#1f1f1f] origin-center rounded-[50px]"
|
||||
>
|
||||
<AnimatePresence>
|
||||
{previewDevice === 'mobile' && (
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="absolute top-0 left-0 w-full h-full pointer-events-none z-10" key="mobile-frame">
|
||||
<div className="absolute top-[11px] left-1/2 -translate-x-1/2 w-[120px] h-[35px] bg-black rounded-full flex items-center justify-center gap-3 shadow-sm border border-white/5">
|
||||
<div className="w-2 h-2 rounded-full bg-[#1a1a1a]" />
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-[#0f0f0f]" />
|
||||
</div>
|
||||
<div className="absolute bottom-2 left-1/2 -translate-x-1/2 w-32 h-1.5 bg-white/20 rounded-full" />
|
||||
</motion.div>
|
||||
)}
|
||||
{previewDevice === 'tablet' && (
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="absolute top-0 left-0 w-full h-full pointer-events-none z-50" key="tablet-frame">
|
||||
<div className="absolute top-2 left-1/2 -translate-x-1/2 w-2 h-2 rounded-full bg-gray-600 border border-gray-500" />
|
||||
<div className="absolute bottom-2 left-1/2 -translate-x-1/2 w-32 h-1.5 bg-white/20 rounded-full" />
|
||||
</motion.div>
|
||||
)}
|
||||
{previewDevice === 'desktop' && (
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="absolute top-0 left-0 w-full h-full pointer-events-none z-50" key="desktop-frame">
|
||||
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-48 h-8 bg-[#1f1f1f] rounded-b-xl flex items-center justify-center border-b border-x border-black/50">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-black/50" />
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className={`h-full w-full overflow-y-auto custom-scrollbar flex items-center justify-center p-4 transition-colors duration-500 ${ previewTheme === 'dark' ? 'dark bg-zinc-900' : 'bg-white' }`}>
|
||||
<div className={`flex ${ previewDevice === 'mobile' ? 'flex-col' : 'flex-row' } w-full max-w-4xl backdrop-blur-2xl border rounded-3xl overflow-hidden shadow-2xl relative shrink-0 transition-colors duration-500 ${ previewTheme === 'dark' ? 'bg-card/70 border-border/50' : 'bg-white border-gray-200' }`}>
|
||||
<PayingInfo orderInfo={previewOrderInfo} loading={loading} forceMobile={previewDevice === 'mobile'} />
|
||||
<PayingNow orderInfo={previewOrderInfo} paying={false} payKey="" currentStep="method" selectedMethod="alipay" isOpen={false} loading={false} onPayKeyChange={() => { }} onCurrentStepChange={() => { }} onSelectedMethodChange={() => { }} onIsOpenChange={() => { }} onPayOrder={() => { }} forceMobile={previewDevice === 'mobile'} />
|
||||
<div className="absolute inset-0 z-50 bg-transparent cursor-default" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute inset-0 z-40 bg-transparent cursor-default" />
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Sheet open={isCreating || !!selectedLink} onOpenChange={(open) => { if (!open) { setIsCreating(false); setSelectedLink(null); } }}>
|
||||
<SheetContent className="w-full sm:max-w-md overflow-y-auto custom-scrollbar px-4">
|
||||
{isCreating ? (
|
||||
<div className="space-y-6">
|
||||
<SheetHeader className="px-0">
|
||||
<SheetTitle>创建新服务</SheetTitle>
|
||||
<SheetDescription className="text-xs">创建一个在线积分流转服务。</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="border border-dashed rounded-lg p-4 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">服务名称 <span className="text-red-500">*</span></Label>
|
||||
<Input
|
||||
placeholder="例如:高级会员订阅服务"
|
||||
value={productName}
|
||||
onChange={e => setProductName(e.target.value)}
|
||||
maxLength={50}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">积分 (LDC) <span className="text-red-500">*</span></Label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="0.00"
|
||||
className="font-mono"
|
||||
value={amount}
|
||||
onChange={e => setAmount(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">总次数限制 (可选)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="不限制"
|
||||
min="1"
|
||||
value={totalLimit}
|
||||
onChange={e => setTotalLimit(e.target.value)}
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">设置该链接总共可以支付的次数</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">单用户限制 (可选)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="不限制"
|
||||
min="1"
|
||||
value={userLimit}
|
||||
onChange={e => setUserLimit(e.target.value)}
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">设置每个用户最多可以支付的次数</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">备注 (可选)</Label>
|
||||
<Textarea
|
||||
placeholder="服务备注信息..."
|
||||
className="resize-none h-20"
|
||||
value={remark}
|
||||
onChange={e => setRemark(e.target.value)}
|
||||
maxLength={200}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SheetFooter className="flex flex-row justify-end items-center gap-2">
|
||||
<Button variant="ghost" onClick={() => setIsCreating(false)} className="h-8 text-xs">取消</Button>
|
||||
<Button onClick={handleCreate} disabled={loading} className="h-8 text-xs">
|
||||
{loading && <Spinner className="mr-1 size-3" />} 创建
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</div>
|
||||
) : selectedLink ? (
|
||||
<div className="space-y-6">
|
||||
<SheetHeader className="px-0">
|
||||
<SheetTitle>{selectedLink.product_name}</SheetTitle>
|
||||
<SheetDescription className="text-xs">
|
||||
{isEditing ? "编辑在线积分流转服务信息" : "在线积分流转服务内容详情"}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
{isEditing ? (
|
||||
/* 编辑模式 */
|
||||
<div className="space-y-6">
|
||||
<div className="border border-dashed rounded-lg p-4 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">服务名称 <span className="text-red-500">*</span></Label>
|
||||
<Input
|
||||
placeholder="例如:高级会员订阅服务"
|
||||
value={productName}
|
||||
onChange={e => setProductName(e.target.value)}
|
||||
maxLength={50}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">积分 (LDC) <span className="text-red-500">*</span></Label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="0.00"
|
||||
className="font-mono"
|
||||
value={amount}
|
||||
onChange={e => setAmount(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">总次数限制 (可选)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="不限制"
|
||||
min="1"
|
||||
value={totalLimit}
|
||||
onChange={e => setTotalLimit(e.target.value)}
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">设置该链接总共可以支付的次数</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">单用户限制 (可选)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="不限制"
|
||||
min="1"
|
||||
value={userLimit}
|
||||
onChange={e => setUserLimit(e.target.value)}
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">设置每个用户最多可以支付的次数</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">备注 (可选)</Label>
|
||||
<Textarea
|
||||
placeholder="服务备注信息..."
|
||||
className="resize-none h-20"
|
||||
value={remark}
|
||||
onChange={e => setRemark(e.target.value)}
|
||||
maxLength={200}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SheetFooter className="flex flex-row justify-end items-center gap-2">
|
||||
<Button variant="ghost" onClick={() => setIsEditing(false)} className="h-8 text-xs">取消</Button>
|
||||
<Button onClick={handleUpdate} disabled={loading} className="h-8 text-xs">
|
||||
{loading && <Spinner className="mr-1 size-3" />} 保存
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</div>
|
||||
) : (
|
||||
/* 查看模式 */
|
||||
<>
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold mb-4">服务信息</h2>
|
||||
<div className="border border-dashed rounded-lg">
|
||||
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
|
||||
<label className="text-xs font-medium text-muted-foreground">服务名称</label>
|
||||
<p className="text-xs font-medium truncate text-right max-w-[70%]">{selectedLink.product_name}</p>
|
||||
</div>
|
||||
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
|
||||
<label className="text-xs font-medium text-muted-foreground">积分数量</label>
|
||||
<p className="text-xs font-bold text-primary">LDC {parseFloat(selectedLink.amount).toFixed(2)}</p>
|
||||
</div>
|
||||
|
||||
{(selectedLink.total_limit || selectedLink.user_limit) && (
|
||||
<>
|
||||
{selectedLink.total_limit && (
|
||||
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
|
||||
<label className="text-xs font-medium text-muted-foreground">总次数限制</label>
|
||||
<p className="text-xs font-medium">{selectedLink.total_limit}</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedLink.user_limit && (
|
||||
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
|
||||
<label className="text-xs font-medium text-muted-foreground">单用户限制</label>
|
||||
<p className="text-xs font-medium">{selectedLink.user_limit}</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
|
||||
<label className="text-xs font-medium text-muted-foreground">服务备注</label>
|
||||
<p className="text-xs text-muted-foreground truncate text-right max-w-[70%]">{selectedLink.remark || "无备注"}</p>
|
||||
</div>
|
||||
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
|
||||
<label className="text-xs font-medium text-muted-foreground">创建时间</label>
|
||||
<p className="text-xs text-muted-foreground">{new Date(selectedLink.created_at).toLocaleString()}</p>
|
||||
</div>
|
||||
<div className="px-3 py-2 border-b border-dashed last:border-b-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs font-medium text-muted-foreground shrink-0">服务令牌</label>
|
||||
<div className="flex items-center p-1 h-7 border border-dashed rounded-sm bg-background max-w-[200px]">
|
||||
<code className="text-xs text-muted-foreground font-mono flex-1 overflow-x-auto px-1 [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none] whitespace-nowrap">
|
||||
{showToken ? selectedLink.token : '•'.repeat(36)}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="p-0.5 w-5 h-5 shrink-0"
|
||||
onClick={() => setShowToken(!showToken)}
|
||||
>
|
||||
{showToken ? <EyeOff className="size-2.5 text-muted-foreground" /> : <Eye className="size-2.5 text-muted-foreground" />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => handleCopyLink(selectedLink.token)}
|
||||
className="p-0.5 w-5 h-5 shrink-0"
|
||||
>
|
||||
<Copy className="size-2.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold mb-4">流转服务管理</h2>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button variant="outline" className="text-xs h-8 border-dashed border-green-500 hover:bg-green-50 shadow-none" onClick={() => handleCopyLink(selectedLink.token)}>
|
||||
<Copy className="size-3 mr-1" />
|
||||
复制链接
|
||||
</Button>
|
||||
<Button variant="outline" className="text-xs h-8 border-dashed border-green-500 hover:bg-green-50 shadow-none" onClick={() => window.open(`/paying/online?token=${ selectedLink.token }`, '_blank')}>
|
||||
<ExternalLink className="size-3 mr-1" />
|
||||
查看服务
|
||||
</Button>
|
||||
<Button variant="outline" className="text-xs h-8 border-dashed border-blue-500 hover:bg-blue-50 shadow-none" onClick={() => handleEdit(selectedLink)}>
|
||||
<svg className="size-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
编辑服务
|
||||
</Button>
|
||||
<Button variant="outline" className="text-xs text-destructive h-8 border-dashed border-destructive/50 hover:bg-destructive/5 shadow-none" onClick={() => handleDelete(selectedLink)}>
|
||||
<Trash2 className="size-3 mr-1" />
|
||||
删除服务
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div >
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import * as React from "react"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import type { MerchantAPIKey } from "@/lib/services"
|
||||
|
||||
interface MerchantSelectorProps {
|
||||
/** API Keys 列表 */
|
||||
apiKeys: MerchantAPIKey[]
|
||||
/** 选中的 API Key */
|
||||
selectedKeyId: string | null
|
||||
/** 选择回调 */
|
||||
onSelect: (id: string) => void
|
||||
/** 是否正在加载 */
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用选择器组件
|
||||
* 用于在多个应用之间切换
|
||||
*/
|
||||
export function MerchantSelector({
|
||||
apiKeys,
|
||||
selectedKeyId,
|
||||
onSelect,
|
||||
loading = false,
|
||||
}: MerchantSelectorProps) {
|
||||
return (
|
||||
<Select
|
||||
value={selectedKeyId || ''}
|
||||
onValueChange={(value) => onSelect(value)}
|
||||
disabled={loading || apiKeys.length === 0}
|
||||
>
|
||||
<SelectTrigger className="w-fit h-8 text-xs" size="sm">
|
||||
<SelectValue placeholder="请选择应用" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{apiKeys.map((apiKey) => (
|
||||
<SelectItem
|
||||
key={apiKey.id}
|
||||
value={apiKey.id}
|
||||
title={apiKey.app_description ? `${ apiKey.app_name } - ${ apiKey.app_description }` : apiKey.app_name}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="font-medium">{apiKey.app_name}</span>
|
||||
{apiKey.app_description && (
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
- {apiKey.app_description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { Power, PowerOff } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Spinner } from "@/components/ui/spinner"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import type { MerchantAPIKey, UpdateAPIKeyRequest } from "@/lib/services"
|
||||
|
||||
interface TestModeToggleProps {
|
||||
/** API Key */
|
||||
apiKey: MerchantAPIKey
|
||||
/** 更新回调 */
|
||||
onUpdate?: (updatedKey: MerchantAPIKey) => void
|
||||
/** 更新 API Key */
|
||||
updateAPIKey?: (id: string, data: UpdateAPIKeyRequest) => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试模式切换组件
|
||||
* 提供测试模式开关按钮,带有详细的说明提示
|
||||
*/
|
||||
export function TestModeToggle({ apiKey, onUpdate, updateAPIKey }: TestModeToggleProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [processing, setProcessing] = useState(false)
|
||||
const [pendingValue, setPendingValue] = useState<boolean>(false)
|
||||
|
||||
const handleToggle = async () => {
|
||||
try {
|
||||
setProcessing(true)
|
||||
|
||||
const updateData = {
|
||||
app_name: apiKey.app_name,
|
||||
app_homepage_url: apiKey.app_homepage_url,
|
||||
app_description: apiKey.app_description,
|
||||
redirect_uri: apiKey.redirect_uri,
|
||||
notify_url: apiKey.notify_url,
|
||||
test_mode: pendingValue
|
||||
}
|
||||
|
||||
if (updateAPIKey) {
|
||||
await updateAPIKey(apiKey.id, updateData)
|
||||
} else {
|
||||
const services = await import("@/lib/services")
|
||||
await services.default.merchant.updateAPIKey(apiKey.id, updateData)
|
||||
}
|
||||
|
||||
toast.success(pendingValue ? '测试模式已开启' : '测试模式已关闭', {
|
||||
description: pendingValue
|
||||
? '现在订单不会真实扣费,仅用于测试调试'
|
||||
: '现在订单将正常扣费,可以接收真实付款'
|
||||
})
|
||||
|
||||
const updatedKey = { ...apiKey, test_mode: pendingValue }
|
||||
onUpdate?.(updatedKey)
|
||||
setOpen(false)
|
||||
} catch (error) {
|
||||
const errorMessage = (error as Error).message || '更新测试模式失败'
|
||||
toast.error('更新失败', { description: errorMessage })
|
||||
} finally {
|
||||
setProcessing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
setPendingValue(!apiKey.test_mode)
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={setOpen}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleClick}
|
||||
className={`h-8 text-xs border-dashed w-full ${ apiKey.test_mode
|
||||
? "text-orange-600 border-orange-500/50 hover:bg-orange-500/10"
|
||||
: "text-green-600 border-green-500/50 hover:bg-green-500/10"
|
||||
}`}
|
||||
>
|
||||
{apiKey.test_mode ? (
|
||||
<Power className="size-3 mr-1" />
|
||||
) : (
|
||||
<PowerOff className="size-3 mr-1" />
|
||||
)}
|
||||
{apiKey.test_mode ? "关闭测试" : "开启测试"}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="flex items-center gap-2">
|
||||
{pendingValue ? (
|
||||
<>
|
||||
<Power className="size-5 text-orange-600" />
|
||||
开启测试模式
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PowerOff className="size-5 text-green-600" />
|
||||
关闭测试模式
|
||||
</>
|
||||
)}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription asChild>
|
||||
<div className="space-y-3 text-sm">
|
||||
{pendingValue ? (
|
||||
<>
|
||||
<p className="font-medium">测试模式开启后:</p>
|
||||
<ul className="space-y-2 ml-4 list-disc text-muted-foreground">
|
||||
<li><strong>订单不会真实扣费</strong>:用户余额不会扣减</li>
|
||||
<li><strong>商户不会收款</strong>:你的余额不会增加</li>
|
||||
<li><strong>仅限自己测试</strong>:只有你可以支付测试订单</li>
|
||||
<li><strong>订单标记明显</strong>:测试订单会显示警告标识</li>
|
||||
</ul>
|
||||
<div className="bg-orange-50 dark:bg-orange-900/20 border border-orange-200 dark:border-orange-800 rounded-lg p-3">
|
||||
<p className="text-xs text-orange-800 dark:text-orange-300">
|
||||
<strong>使用场景:</strong>在正式上线前测试支付流程、回调通知等功能
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="font-medium">测试模式关闭后:</p>
|
||||
<ul className="space-y-2 ml-4 list-disc text-muted-foreground">
|
||||
<li><strong>订单真实扣费</strong>:用户余额会正常扣减</li>
|
||||
<li><strong>商户正常收款</strong>:你的余额会正常增加</li>
|
||||
<li><strong>其他用户可支付</strong>:任何人都可以支付订单</li>
|
||||
<li><strong>正式生产环境</strong>:可以接收真实的业务订单</li>
|
||||
</ul>
|
||||
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-3">
|
||||
<p className="text-xs text-green-800 dark:text-green-300">
|
||||
<strong>注意:</strong>关闭后应用将进入生产模式,请确保已完成测试
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={processing}>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
handleToggle()
|
||||
}}
|
||||
disabled={processing}
|
||||
className={pendingValue ? "bg-orange-600 hover:bg-orange-700" : "bg-green-600 hover:bg-green-700"}
|
||||
>
|
||||
{processing ? (
|
||||
<>
|
||||
<Spinner className="size-4" />
|
||||
{pendingValue ? '开启中' : '关闭中'}
|
||||
</>
|
||||
) : (
|
||||
pendingValue ? '确认开启' : '确认关闭'
|
||||
)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { ShoppingBag } from "lucide-react"
|
||||
|
||||
import type { GetMerchantOrderResponse } from "@/lib/services"
|
||||
|
||||
|
||||
/**
|
||||
* 加载中骨架组件
|
||||
* 显示活动信息加载中的骨架
|
||||
*/
|
||||
function LoadingSkeleton() {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="mb-6 text-center md:text-left">
|
||||
<Skeleton className="h-4 w-32 mb-2 bg-white/10 mx-auto md:mx-0" />
|
||||
<Skeleton className="h-8 w-40 bg-white/10 mx-auto md:mx-0" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl bg-white/5 p-4 space-y-4">
|
||||
<div className="flex justify-between items-start pb-4">
|
||||
<div className="flex flex-col items-start space-y-1.5">
|
||||
<Skeleton className="h-4 w-24 bg-white/10" />
|
||||
<Skeleton className="h-3 w-16 bg-white/10" />
|
||||
</div>
|
||||
<div className="flex flex-col items-end space-y-1.5">
|
||||
<Skeleton className="h-4 w-20 bg-white/10" />
|
||||
<Skeleton className="h-3 w-24 bg-white/10" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center pt-2">
|
||||
<Skeleton className="h-6 w-20 bg-white/10" />
|
||||
<Skeleton className="h-6 w-24 bg-white/10" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 px-1">
|
||||
<Skeleton className="h-3 w-full bg-white/10" />
|
||||
<Skeleton className="h-3 w-3/4 bg-white/10" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 活动信息组件
|
||||
* 显示活动信息
|
||||
*/
|
||||
function OrderContent({ orderInfo, forceMobile = false }: { orderInfo: GetMerchantOrderResponse, forceMobile?: boolean }) {
|
||||
const amount = parseFloat(orderInfo.order.amount).toFixed(2)
|
||||
const feeRateValue = orderInfo.user_pay_config?.fee_rate
|
||||
const feeRate = feeRateValue ? (parseFloat(feeRateValue) * 100).toFixed(2) : '0.00'
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className={`mb-4 text-center ${ forceMobile ? '' : 'md:mb-6 md:text-left' }`}>
|
||||
<div className={`inline-flex items-center justify-center gap-1.5 mb-1 text-white/50 text-[10px] font-medium uppercase tracking-wider ${ forceMobile ? '' : 'md:justify-start md:gap-2 md:mb-1.5 md:text-xs' }`}>
|
||||
<span>服务方</span>
|
||||
<span className="w-1 h-1 rounded-full bg-white/30" />
|
||||
<span className="text-white hover:text-blue-400 transition-colors cursor-pointer font-bold">{orderInfo.merchant.app_name}</span>
|
||||
</div>
|
||||
<h1 className={`text-2xl font-bold text-white tracking-tight flex items-baseline justify-center gap-1 ${ forceMobile ? '' : 'md:text-4xl md:justify-start' }`}>
|
||||
<span className={`text-lg text-white/50 font-normal ${ forceMobile ? '' : 'md:text-2xl' }`}>LDC</span>
|
||||
{amount}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className={`space-y-3 ${ forceMobile ? '' : 'md:space-y-4' }`}>
|
||||
<div className={`rounded-2xl bg-white/5 p-3 backdrop-blur-sm transition-colors ${ forceMobile ? '' : 'md:p-4' }`}>
|
||||
<div className={`flex justify-between items-start pb-3 mb-3 ${ forceMobile ? '' : 'md:pb-4 md:mb-4' }`}>
|
||||
<div className={`flex flex-col items-start pr-3 ${ forceMobile ? '' : 'md:pr-4' }`}>
|
||||
<p className={`text-xs font-medium text-white mb-0.5 line-clamp-2 ${ forceMobile ? '' : 'md:text-sm' }`}>{orderInfo.order.order_name}</p>
|
||||
<p className="text-white/40 text-[10px]">数量: 1</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end text-right whitespace-nowrap">
|
||||
<p className={`text-xs font-medium text-white mb-0.5 ${ forceMobile ? '' : 'md:text-sm' }`}>LDC {amount}</p>
|
||||
<p className="text-white/40 text-[10px]">LDC {amount} / 个</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button className="w-full text-xs text-left mb-3 text-white/40 hover:text-white transition-colors duration-200 flex items-center gap-2 group">
|
||||
<span>添加优惠码</span>
|
||||
<span className="px-1.5 py-0.5 rounded text-[10px] bg-white/5 text-white/30">暂未开放</span>
|
||||
</button>
|
||||
|
||||
<div className="flex justify-between items-center pt-2">
|
||||
<p className="text-base font-bold text-white">积分合计</p>
|
||||
<p className="text-lg font-bold text-white tracking-tight">LDC {amount}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-2">
|
||||
<p className="text-[10px] text-white/30 space-y-0.5 leading-relaxed">
|
||||
* 包含 {feeRate}% 的服务手续费 (由服务方承担)。
|
||||
<br />
|
||||
服务手续费率根据服务方等级和平台活动动态调整,不会消耗您(消费者)的积分。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 活动信息组件
|
||||
* 显示活动信息
|
||||
*/
|
||||
export function PayingInfo({ orderInfo, loading = false, forceMobile = false }: { orderInfo: GetMerchantOrderResponse, loading: boolean, forceMobile?: boolean }) {
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col w-full p-4 justify-center bg-gradient-to-br from-neutral-900/95 to-black dark:from-neutral-900 dark:to-black relative text-white rounded-t-3xl ${ forceMobile ? '' : 'md:w-[45%] md:p-8 md:rounded-l-3xl md:rounded-tr-none' }`}>
|
||||
<div className="absolute top-0 left-0 w-full h-full overflow-hidden pointer-events-none opacity-30">
|
||||
<div className="absolute -top-[20%] -left-[10%] w-[70%] h-[70%] bg-blue-900/20 rounded-full blur-3xl" />
|
||||
<div className="absolute top-[40%] -right-[10%] w-[60%] h-[60%] bg-purple-900/20 rounded-full blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div className={`w-full max-w-xs mx-auto relative z-10 flex flex-col justify-center ${ forceMobile ? '' : 'md:h-full md:min-h-[360px]' }`}>
|
||||
<div className={`text-center mb-2 ${ forceMobile ? '' : 'md:hidden' }`}>
|
||||
{loading ? (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Skeleton className="h-3 w-32 bg-white/10" />
|
||||
<div className="flex items-baseline justify-center gap-1">
|
||||
<Skeleton className="h-8 w-24 bg-white/10" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-white/50 text-[10px] uppercase tracking-wider mb-1">服务方 {orderInfo.merchant.app_name}</div>
|
||||
<div className="text-3xl font-bold text-white tracking-tight flex items-baseline justify-center gap-1">
|
||||
<span className="text-lg text-white/50 font-normal">LDC</span>
|
||||
{(parseFloat(orderInfo.order.amount)).toFixed(2)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={`mb-10 flex justify-center hidden ${ forceMobile ? '' : 'md:block md:justify-start' }`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 flex items-center justify-center shadow-sm backdrop-blur-sm">
|
||||
{loading ? (
|
||||
<Skeleton className="w-5 h-5 rounded-full bg-white/20" />
|
||||
) : (
|
||||
<ShoppingBag className="size-4 text-white/80" />
|
||||
)}
|
||||
</div>
|
||||
{loading ? (
|
||||
<Skeleton className="h-5 w-24 bg-white/10" />
|
||||
) : (
|
||||
<span className="text-xs font-bold text-white/60">积分流转信息</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`hidden ${ forceMobile ? '' : 'md:block' }`}>
|
||||
{loading ? <LoadingSkeleton /> : <OrderContent orderInfo={orderInfo} forceMobile={forceMobile} />}
|
||||
</div>
|
||||
|
||||
{!loading && (
|
||||
<div className={`text-center ${ forceMobile ? '' : 'md:hidden' }`}>
|
||||
<div className="inline-flex items-center gap-1 text-[10px] text-white/40 bg-white/5 px-2 py-1 rounded-full">
|
||||
<span>{orderInfo.order.order_name}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`hidden mt-auto pt-8 ${ forceMobile ? '' : 'md:block' }`}>
|
||||
<div className="text-[10px] text-white/20 font-mono">
|
||||
ORDER: {orderInfo?.order?.order_no || 'LOADING...'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useRef } from "react"
|
||||
import { useSearchParams, useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
import { motion } from "motion/react"
|
||||
import { PayingNow } from "@/components/common/pay/paying/paying-now"
|
||||
import { PayingInfo } from "@/components/common/pay/paying/paying-info"
|
||||
|
||||
import services from "@/lib/services"
|
||||
import type { GetMerchantOrderResponse } from "@/lib/services"
|
||||
|
||||
|
||||
/**
|
||||
* 积分认证主页面组件
|
||||
* 通过 order_no 查询订单信息并完成积分认证
|
||||
*/
|
||||
export function PayingMain() {
|
||||
/** 获取URL参数中的认证编号 */
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const encryptedOrderNo = searchParams.get('order_no')
|
||||
|
||||
/** 组件状态管理 */
|
||||
const [orderInfo, setOrderInfo] = useState<GetMerchantOrderResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(false)
|
||||
const [paying, setPaying] = useState(false)
|
||||
const [payKey, setPayKey] = useState("")
|
||||
const [currentStep, setCurrentStep] = useState<'method' | 'pay'>('method')
|
||||
const [selectedMethod, setSelectedMethod] = useState<string>('')
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const isMountedRef = useRef(true)
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true
|
||||
return () => {
|
||||
isMountedRef.current = false
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
timeoutRef.current = null
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
/** 统一的服务错误处理函数 */
|
||||
const handleServiceError = (error: unknown, operation: string) => {
|
||||
console.error(`${ operation }失败:`, error)
|
||||
|
||||
let errorMessage = `${ operation }失败`
|
||||
if (error && typeof error === 'object' && 'message' in error) {
|
||||
errorMessage = String(error.message)
|
||||
}
|
||||
|
||||
const errorMap: Record<string, string> = {
|
||||
'订单不存在': "此积分流转服务不存在或已过期",
|
||||
'未登录': "请先登录后再进行认证",
|
||||
'余额不足': "您的积分余额不足",
|
||||
'安全密码': "安全密码错误",
|
||||
'不能认证自己的订单': "不能认证自己创建的积分流转服务",
|
||||
'每日限额': "已达到每日认证限额",
|
||||
'已完成': "认证完成"
|
||||
}
|
||||
|
||||
const userMessage = Object.entries(errorMap).find(([key]) =>
|
||||
errorMessage.includes(key)
|
||||
)?.[1] || errorMessage
|
||||
|
||||
toast.error(userMessage, { id: 'payment-error' })
|
||||
}
|
||||
|
||||
/** 组件初始化时查询订单信息 */
|
||||
useEffect(() => {
|
||||
if (encryptedOrderNo) {
|
||||
setError(false)
|
||||
handleQueryOrder(encryptedOrderNo)
|
||||
} else {
|
||||
setLoading(false)
|
||||
setError(true)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [encryptedOrderNo])
|
||||
|
||||
/** 查询订单信息 */
|
||||
const handleQueryOrder = async (queryOrderNo?: string) => {
|
||||
const targetOrderNo = queryOrderNo || encryptedOrderNo
|
||||
if (!targetOrderNo) {
|
||||
toast.error("缺少认证编号", { id: 'missing-order-no' })
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await services.merchant.getMerchantOrder({ order_no: targetOrderNo })
|
||||
setOrderInfo(data)
|
||||
setError(false)
|
||||
} catch (error: unknown) {
|
||||
handleServiceError(error, "查询认证信息")
|
||||
setOrderInfo(null)
|
||||
setError(true)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
/** 执行积分认证操作 */
|
||||
const handlePayOrder = async () => {
|
||||
if (!orderInfo) return
|
||||
|
||||
if (!payKey.trim()) {
|
||||
toast.error("请输入安全密码")
|
||||
return
|
||||
}
|
||||
|
||||
if (payKey.length !== 6) {
|
||||
toast.error("安全密码长度为6位,请重新输入")
|
||||
return
|
||||
}
|
||||
|
||||
setPaying(true)
|
||||
try {
|
||||
const freshOrderInfo = await services.merchant.getMerchantOrder({
|
||||
order_no: encryptedOrderNo!
|
||||
})
|
||||
|
||||
if (freshOrderInfo.order.status !== 'pending') {
|
||||
toast.error("此积分流转服务状态已变更,无法认证")
|
||||
setOrderInfo(freshOrderInfo)
|
||||
return
|
||||
}
|
||||
|
||||
await services.merchant.payMerchantOrder({
|
||||
order_no: encryptedOrderNo!,
|
||||
pay_key: payKey
|
||||
})
|
||||
|
||||
toast.success("积分流转服务认证成功!", { id: 'payment-success' })
|
||||
|
||||
/** 支付成功后立即更新订单状态为成功 */
|
||||
if (orderInfo) {
|
||||
setOrderInfo({
|
||||
...orderInfo,
|
||||
order: {
|
||||
...orderInfo.order,
|
||||
status: 'success'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 5秒后跳转到redirect_uri或刷新页面 */
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
if (!isMountedRef.current) return
|
||||
|
||||
const redirectUri = orderInfo?.merchant?.redirect_uri
|
||||
if (redirectUri && redirectUri.trim()) {
|
||||
window.location.href = redirectUri
|
||||
return
|
||||
}
|
||||
|
||||
router.push('/home')
|
||||
}, 5000)
|
||||
} catch (error: unknown) {
|
||||
handleServiceError(error, "认证")
|
||||
|
||||
if (
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
'message' in error &&
|
||||
String(error.message).includes('已完成')
|
||||
) {
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
if (!isMountedRef.current) return
|
||||
window.location.reload()
|
||||
}, 500)
|
||||
}
|
||||
} finally {
|
||||
setPaying(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="relative min-h-screen w-full flex flex-col items-center justify-center bg-background">
|
||||
<div className="relative z-10 w-full h-full flex items-center justify-center p-4">
|
||||
<motion.div
|
||||
className="relative z-10 flex flex-col items-center justify-center min-h-screen text-center px-4"
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
>
|
||||
<div className="w-full rounded-3xl p-8 max-w-lg">
|
||||
<div className="w-12 h-12 rounded-full flex items-center justify-center mx-auto mb-6 bg-red-500/10">
|
||||
<div className="size-8 text-red-500 font-bold text-4xl flex items-center justify-center">!</div>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-foreground mb-4">
|
||||
出了点问题
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
您访问的认证编号已经完成认证或不存在。请检查您的积分流转服务链接是否正确,对此如有疑问请联系社区技术支持。
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen w-full font-sans text-foreground overflow-hidden bg-background selection:bg-primary/30">
|
||||
<div className="relative min-h-screen w-full flex flex-col items-center justify-center bg-background">
|
||||
<div className="relative z-10 w-full h-full flex items-center justify-center p-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||
className="flex flex-col md:flex-row w-full max-w-4xl bg-card/70 backdrop-blur-2xl border border-border/50 rounded-3xl overflow-hidden shadow-2xl"
|
||||
style={{ minHeight: 'auto' }}
|
||||
>
|
||||
<PayingInfo orderInfo={orderInfo!} loading={loading} />
|
||||
<PayingNow
|
||||
orderInfo={orderInfo}
|
||||
paying={paying}
|
||||
payKey={payKey}
|
||||
currentStep={currentStep}
|
||||
selectedMethod={selectedMethod}
|
||||
isOpen={isOpen}
|
||||
loading={loading}
|
||||
onPayKeyChange={setPayKey}
|
||||
onCurrentStepChange={setCurrentStep}
|
||||
onSelectedMethodChange={setSelectedMethod}
|
||||
onIsOpenChange={setIsOpen}
|
||||
onPayOrder={handlePayOrder}
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
import * as React from "react"
|
||||
import { motion, AnimatePresence } from "motion/react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Send } from "@/components/animate-ui/icons/send"
|
||||
import { Check } from "@/components/animate-ui/icons/check"
|
||||
import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp"
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/animate-ui/primitives/radix/collapsible"
|
||||
import { AlertCircle, CreditCard, ArrowLeft, ShieldCheck, Lock } from "lucide-react"
|
||||
|
||||
import type { GetMerchantOrderResponse } from "@/lib/services"
|
||||
import { useUser } from "@/contexts/user-context"
|
||||
|
||||
|
||||
type PaymentStep = typeof PAYMENT_STEPS[keyof typeof PAYMENT_STEPS]
|
||||
|
||||
const PAYMENT_METHODS = {
|
||||
LINUX_DO_PAY: 'linux-do-pay'
|
||||
} as const
|
||||
|
||||
const PAYMENT_STEPS = {
|
||||
METHOD: 'method' as const,
|
||||
Credit: 'pay' as const
|
||||
} as const
|
||||
|
||||
const ORDER_STATUSES = {
|
||||
SUCCESS: 'success',
|
||||
PENDING: 'pending'
|
||||
} as const
|
||||
|
||||
/**
|
||||
* 加载中骨架组件
|
||||
* 显示支付方式加载中的骨架
|
||||
*/
|
||||
function LoadingSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6 w-full max-w-xs mx-auto">
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-6 w-32 bg-muted/50" />
|
||||
<Skeleton className="h-4 w-48 bg-muted/50" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-xl border border-border/50 p-3 bg-card/50">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Skeleton className="w-9 h-9 rounded-lg bg-muted/50" />
|
||||
<Skeleton className="h-3.5 w-24 bg-muted/50" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-border/50 p-3 bg-card/50">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Skeleton className="w-9 h-9 rounded-lg bg-muted/50" />
|
||||
<Skeleton className="h-3.5 w-20 bg-muted/50" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Skeleton className="h-10 w-full rounded-full bg-muted/50" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 成功状态组件
|
||||
* 显示认证成功状态
|
||||
*/
|
||||
function SuccessState() {
|
||||
const [countdown, setCountdown] = React.useState(5)
|
||||
|
||||
React.useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setCountdown((prev) => {
|
||||
if (prev <= 1) {
|
||||
clearInterval(timer)
|
||||
return 0
|
||||
}
|
||||
return prev - 1
|
||||
})
|
||||
}, 1000)
|
||||
|
||||
return () => clearInterval(timer)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{
|
||||
duration: 0.6,
|
||||
ease: "easeOut",
|
||||
type: "spring",
|
||||
stiffness: 100
|
||||
}}
|
||||
className="text-center py-10"
|
||||
>
|
||||
<div className="relative inline-block mb-6">
|
||||
<div className="absolute inset-0 bg-green-500/20 blur-xl rounded-full" />
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{
|
||||
delay: 0.2,
|
||||
duration: 0.5,
|
||||
type: "spring",
|
||||
stiffness: 200
|
||||
}}
|
||||
className="relative w-10 h-10 bg-green-500 rounded-full flex items-center justify-center "
|
||||
>
|
||||
<Check
|
||||
className="size-6 text-white"
|
||||
animate={true}
|
||||
loop={false}
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<motion.h3
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.4, duration: 0.4 }}
|
||||
className="text-xl font-bold text-foreground mb-2"
|
||||
>
|
||||
认证成功
|
||||
</motion.h3>
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.6, duration: 0.4 }}
|
||||
className="text-muted-foreground text-sm"
|
||||
>
|
||||
认证已安全完成,{countdown > 0 ? `${ countdown }秒后自动跳转...` : '正在跳转...'}
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 认证方式选择步骤组件
|
||||
* 显示认证方式选择步骤
|
||||
*/
|
||||
function MethodSelectionStep({
|
||||
orderInfo,
|
||||
selectedMethod,
|
||||
isOpen,
|
||||
onSelectedMethodChange,
|
||||
onIsOpenChange,
|
||||
onCurrentStepChange,
|
||||
forceMobile = false
|
||||
}: {
|
||||
orderInfo: GetMerchantOrderResponse
|
||||
selectedMethod: string
|
||||
isOpen: boolean
|
||||
onSelectedMethodChange: (method: string) => void
|
||||
onIsOpenChange: (isOpen: boolean) => void
|
||||
onCurrentStepChange: (step: PaymentStep) => void
|
||||
forceMobile?: boolean
|
||||
}) {
|
||||
const { user } = useUser()
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key="method-step"
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="space-y-6 w-full max-w-xs mx-auto"
|
||||
>
|
||||
<div className={`space-y-2 text-center ${ forceMobile ? '' : 'md:text-left' }`}>
|
||||
<h2 className="text-xl font-bold text-foreground tracking-tight">选择认证平台</h2>
|
||||
<p className="text-xs text-muted-foreground">请选择您想要使用的服务认证平台</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Collapsible open={isOpen} onOpenChange={onIsOpenChange}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<div
|
||||
className={`group cursor-pointer rounded-xl p-2.5 transition-all duration-300 border backdrop-blur-sm ${ selectedMethod === PAYMENT_METHODS.LINUX_DO_PAY
|
||||
? 'border-primary/50 bg-primary/5'
|
||||
: 'border-border/50 bg-card hover:bg-muted/50 hover:border-border'
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (selectedMethod === PAYMENT_METHODS.LINUX_DO_PAY) {
|
||||
onSelectedMethodChange('')
|
||||
onIsOpenChange(false)
|
||||
} else {
|
||||
onSelectedMethodChange(PAYMENT_METHODS.LINUX_DO_PAY)
|
||||
onIsOpenChange(true)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className={`w-8 h-8 rounded-lg flex items-center justify-center transition-all duration-300 ${ selectedMethod === PAYMENT_METHODS.LINUX_DO_PAY
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground group-hover:bg-muted/80'
|
||||
}`}>
|
||||
<CreditCard className="size-4" />
|
||||
</div>
|
||||
<div className="flex-1 text-left">
|
||||
<div className="font-bold text-xs text-foreground">LINUX DO Credit</div>
|
||||
<div className="text-[9px] text-muted-foreground mt-0.5">即时安全认证</div>
|
||||
</div>
|
||||
<div className={`w-5 h-5 rounded-full border flex items-center justify-center transition-all duration-300 ${ selectedMethod === PAYMENT_METHODS.LINUX_DO_PAY
|
||||
? 'border-primary bg-primary text-primary-foreground'
|
||||
: 'border-border'
|
||||
}`}>
|
||||
{selectedMethod === PAYMENT_METHODS.LINUX_DO_PAY && <Check className="size-3" />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
{selectedMethod === PAYMENT_METHODS.LINUX_DO_PAY && (
|
||||
<CollapsibleContent>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="pt-3 px-1"
|
||||
>
|
||||
<div className="flex items-center gap-2.5 text-xs text-muted-foreground mb-4 bg-muted/50 p-2.5 rounded-lg border">
|
||||
<div className="p-1 rounded">
|
||||
<Lock className="size-3 text-primary" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] opacity-70">消费方账户</span>
|
||||
<span className="font-medium text-foreground">{orderInfo.order.payer_username || user?.username}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => onCurrentStepChange(PAYMENT_STEPS.Credit)}
|
||||
className="w-full rounded-full font-bold text-sm shadow-md transition-all hover:scale-[1.02] active:scale-[0.98]"
|
||||
>
|
||||
确认并继续
|
||||
</Button>
|
||||
</motion.div>
|
||||
</CollapsibleContent>
|
||||
)}
|
||||
</Collapsible>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 认证步骤组件
|
||||
* 显示认证步骤
|
||||
*/
|
||||
function PaymentStep({
|
||||
orderInfo,
|
||||
payKey,
|
||||
paying,
|
||||
onPayKeyChange,
|
||||
onCurrentStepChange,
|
||||
onPayOrder
|
||||
}: {
|
||||
orderInfo: GetMerchantOrderResponse
|
||||
payKey: string
|
||||
paying: boolean
|
||||
onPayKeyChange: (value: string) => void
|
||||
onCurrentStepChange: (step: PaymentStep) => void
|
||||
onPayOrder: () => void
|
||||
}) {
|
||||
return (
|
||||
<motion.div
|
||||
key="pay-step"
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="space-y-6 w-full max-w-xs mx-auto"
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-ml-3 h-8 text-muted-foreground hover:text-foreground mb-1"
|
||||
onClick={() => onCurrentStepChange(PAYMENT_STEPS.METHOD)}
|
||||
>
|
||||
<ArrowLeft className="size-3.5 mr-1" /> 返回
|
||||
</Button>
|
||||
<h2 className="text-xl font-bold text-foreground tracking-tight">身份验证</h2>
|
||||
<p className="text-xs text-muted-foreground">请输入您的6位安全密码</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center py-4">
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
value={payKey}
|
||||
onChange={onPayKeyChange}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && payKey.length === 6 && !paying) {
|
||||
onPayOrder()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<InputOTPGroup className="gap-2">
|
||||
{[0, 1, 2, 3, 4, 5].map((index) => (
|
||||
<InputOTPSlot
|
||||
key={index}
|
||||
index={index}
|
||||
className="size-11 bg-background border-border text-foreground text-lg font-bold transition-all focus:border-primary focus:ring-4 focus:ring-primary/10 ring-offset-0"
|
||||
/>
|
||||
))}
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={onPayOrder}
|
||||
disabled={paying || payKey.length !== 6}
|
||||
className="w-full rounded-full font-bold text-sm shadow-md transition-all hover:scale-[1.02] active:scale-[0.98]"
|
||||
>
|
||||
{paying ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Send className="size-4" animate={true} loop={true} loopDelay={2000} /> 处理中...
|
||||
</span>
|
||||
) : (
|
||||
`消耗 LDC ${ (parseFloat(orderInfo.order.amount)).toFixed(2) }`
|
||||
)}
|
||||
</Button>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误状态组件
|
||||
* 显示错误状态
|
||||
*/
|
||||
function ErrorState({ status }: { status: string }) {
|
||||
return (
|
||||
<div className="text-center py-10">
|
||||
<div className="w-14 h-14 bg-red-500/10 rounded-full flex items-center justify-center mx-auto mb-4 border border-red-500/20">
|
||||
<AlertCircle className="size-6 text-red-500" />
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-foreground mb-1">认证出现问题</h3>
|
||||
<p className="text-muted-foreground text-sm">状态:<span className="font-medium text-foreground">{status}</span></p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 底部组件
|
||||
* 显示底部
|
||||
*/
|
||||
function Footer({ loading }: { loading?: boolean }) {
|
||||
return loading ? (
|
||||
<div className="flex flex-col items-center justify-center space-y-2 mt-8 opacity-50">
|
||||
<Skeleton className="h-3 w-32" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center gap-2 text-[10px] uppercase font-bold text-muted-foreground/40 mt-10">
|
||||
<ShieldCheck className="size-3 text-green-500" />
|
||||
<span>由 LINUX DO Credit 提供安全认证</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface PayingNowProps {
|
||||
orderInfo: GetMerchantOrderResponse | null
|
||||
paying: boolean
|
||||
payKey: string
|
||||
currentStep: PaymentStep
|
||||
selectedMethod: string
|
||||
isOpen: boolean
|
||||
loading?: boolean
|
||||
onPayKeyChange: (value: string) => void
|
||||
onCurrentStepChange: (step: PaymentStep) => void
|
||||
onSelectedMethodChange: (method: string) => void
|
||||
onIsOpenChange: (isOpen: boolean) => void
|
||||
onPayOrder: () => void
|
||||
forceMobile?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 支付现在组件
|
||||
* 显示支付现在组件
|
||||
*/
|
||||
export function PayingNow({
|
||||
orderInfo,
|
||||
paying,
|
||||
payKey,
|
||||
currentStep,
|
||||
selectedMethod,
|
||||
isOpen,
|
||||
loading = false,
|
||||
onPayKeyChange,
|
||||
onCurrentStepChange,
|
||||
onSelectedMethodChange,
|
||||
onIsOpenChange,
|
||||
onPayOrder,
|
||||
forceMobile = false
|
||||
}: PayingNowProps) {
|
||||
const renderContent = () => {
|
||||
if (loading) return <LoadingSkeleton />
|
||||
|
||||
if (!orderInfo) return null
|
||||
|
||||
switch (orderInfo.order.status) {
|
||||
case ORDER_STATUSES.SUCCESS:
|
||||
return <SuccessState />
|
||||
|
||||
case ORDER_STATUSES.PENDING:
|
||||
return (
|
||||
<AnimatePresence mode="wait">
|
||||
{currentStep === PAYMENT_STEPS.METHOD ? (
|
||||
<MethodSelectionStep
|
||||
orderInfo={orderInfo}
|
||||
selectedMethod={selectedMethod}
|
||||
isOpen={isOpen}
|
||||
onSelectedMethodChange={onSelectedMethodChange}
|
||||
onIsOpenChange={onIsOpenChange}
|
||||
onCurrentStepChange={onCurrentStepChange}
|
||||
forceMobile={forceMobile}
|
||||
/>
|
||||
) : (
|
||||
<PaymentStep
|
||||
orderInfo={orderInfo}
|
||||
payKey={payKey}
|
||||
paying={paying}
|
||||
onPayKeyChange={onPayKeyChange}
|
||||
onCurrentStepChange={onCurrentStepChange}
|
||||
onPayOrder={onPayOrder}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
|
||||
default:
|
||||
return <ErrorState status={orderInfo.order.status} />
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex-1 flex flex-col items-center justify-center p-6 relative overflow-hidden bg-white dark:bg-neutral-900 transition-colors duration-500 ${ forceMobile ? '' : 'md:p-8' }`}>
|
||||
<div className="w-full relative z-10 flex flex-col h-full justify-center">
|
||||
<div className="flex-1 flex flex-col justify-center">
|
||||
{renderContent()}
|
||||
</div>
|
||||
<Footer loading={loading} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useRef } from "react"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
import { motion } from "motion/react"
|
||||
import { PayingNow } from "@/components/common/pay/paying/paying-now"
|
||||
import { PayingInfo } from "@/components/common/pay/paying/paying-info"
|
||||
import { useUser } from "@/contexts/user-context"
|
||||
|
||||
import services from "@/lib/services"
|
||||
import { ConfigService } from "@/lib/services"
|
||||
import type { PaymentLink, GetMerchantOrderResponse, UserPayConfig } from "@/lib/services"
|
||||
|
||||
|
||||
/**
|
||||
* 支付链接支付页面组件
|
||||
* 通过 token 查询支付链接信息并完成支付
|
||||
*/
|
||||
export function PayingOnline() {
|
||||
/* 获取URL参数中的Token */
|
||||
const searchParams = useSearchParams()
|
||||
const token = searchParams.get('token')
|
||||
const { user } = useUser()
|
||||
|
||||
/* 组件状态管理 */
|
||||
const [paymentLink, setPaymentLink] = useState<PaymentLink | null>(null)
|
||||
const [orderInfo, setOrderInfo] = useState<GetMerchantOrderResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(false)
|
||||
const [paying, setPaying] = useState(false)
|
||||
const [payKey, setPayKey] = useState("")
|
||||
const [currentStep, setCurrentStep] = useState<'method' | 'pay'>('method')
|
||||
const [selectedMethod, setSelectedMethod] = useState<string>('')
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const isMountedRef = useRef(true)
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true
|
||||
return () => {
|
||||
isMountedRef.current = false
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
timeoutRef.current = null
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
/* 获取用户支付配置 */
|
||||
const [userPayConfigs, setUserPayConfigs] = useState<UserPayConfig[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
const loadConfigs = async () => {
|
||||
try {
|
||||
const configs = await ConfigService.getUserPayConfigs()
|
||||
setUserPayConfigs(configs)
|
||||
} catch (error) {
|
||||
console.error("Failed to load user pay configs:", error)
|
||||
}
|
||||
}
|
||||
loadConfigs()
|
||||
}, [])
|
||||
|
||||
/* 统一的服务错误处理函数 */
|
||||
const handleServiceError = (error: unknown, operation: string) => {
|
||||
console.error(`${ operation }失败:`, error)
|
||||
|
||||
let errorMessage = `${ operation }失败`
|
||||
if (error && typeof error === 'object' && 'message' in error) {
|
||||
errorMessage = String(error.message)
|
||||
}
|
||||
|
||||
const errorMap: Record<string, string> = {
|
||||
'不存在': "此积分流转服务不存在或已失效",
|
||||
'未登录': "请先登录后再进行认证",
|
||||
'余额不足': "账户积分不足",
|
||||
'支付密码': "安全密码错误",
|
||||
'不能支付': "不能支付自己创建的积分流转服务",
|
||||
'每日限额': "已达到每日认证限额",
|
||||
}
|
||||
|
||||
const userMessage = Object.entries(errorMap).find(([key]) =>
|
||||
errorMessage.includes(key)
|
||||
)?.[1] || errorMessage
|
||||
|
||||
toast.error(userMessage, { id: 'payment-error' })
|
||||
}
|
||||
|
||||
/* 组件初始化时查询支付链接信息 */
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
setError(false)
|
||||
handleQueryPaymentLink(token)
|
||||
} else {
|
||||
setLoading(false)
|
||||
setError(true)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [token, userPayConfigs.length, user?.pay_level])
|
||||
|
||||
/* 查询支付链接信息 */
|
||||
const handleQueryPaymentLink = async (queryToken?: string) => {
|
||||
const targetToken = queryToken || token
|
||||
if (!targetToken) {
|
||||
toast.error("缺少支付链接", { id: 'missing-token' })
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await services.merchant.getPaymentLinkByToken(targetToken)
|
||||
setPaymentLink(data)
|
||||
|
||||
/* 将支付链接信息转换为订单信息格式,以复用PayingInfo和PayingNow组件 */
|
||||
const mockOrderInfo: GetMerchantOrderResponse = {
|
||||
merchant: {
|
||||
app_name: data.app_name || "商户",
|
||||
redirect_uri: data.redirect_uri || "",
|
||||
},
|
||||
order: {
|
||||
id: '0',
|
||||
order_no: `LINK-${ data.id }`,
|
||||
order_name: data.product_name,
|
||||
payer_username: user?.username || "",
|
||||
payee_username: "",
|
||||
amount: data.amount,
|
||||
status: "pending",
|
||||
type: "payment",
|
||||
payment_type: "link",
|
||||
remark: data.remark || "",
|
||||
client_id: "",
|
||||
return_url: "",
|
||||
notify_url: "",
|
||||
trade_time: null,
|
||||
created_at: data.created_at,
|
||||
updated_at: data.updated_at,
|
||||
},
|
||||
user_pay_config: (() => {
|
||||
const currentUserConfig = userPayConfigs.find(c => user?.pay_level !== undefined && c.level === user.pay_level)
|
||||
return currentUserConfig ? {
|
||||
...currentUserConfig,
|
||||
fee_rate: String(currentUserConfig.fee_rate)
|
||||
} : {
|
||||
id: '0',
|
||||
level: user?.pay_level ?? 0,
|
||||
min_score: 0,
|
||||
max_score: null,
|
||||
daily_limit: null,
|
||||
fee_rate: "0.00",
|
||||
created_at: "",
|
||||
updated_at: ""
|
||||
}
|
||||
})(),
|
||||
}
|
||||
|
||||
setOrderInfo(mockOrderInfo)
|
||||
setError(false)
|
||||
} catch (error: unknown) {
|
||||
handleServiceError(error, "查询积分流转服务")
|
||||
setPaymentLink(null)
|
||||
setOrderInfo(null)
|
||||
setError(true)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
/* 执行支付操作 */
|
||||
const handlePayOrder = async () => {
|
||||
if (!paymentLink || !token) return
|
||||
|
||||
if (!payKey.trim()) {
|
||||
toast.error("请输入安全密码")
|
||||
return
|
||||
}
|
||||
|
||||
if (payKey.length < 6 || payKey.length > 10) {
|
||||
toast.error("安全密码长度必须为6位")
|
||||
return
|
||||
}
|
||||
|
||||
setPaying(true)
|
||||
try {
|
||||
await services.merchant.payByLink({
|
||||
token: token,
|
||||
pay_key: payKey,
|
||||
remark: paymentLink.remark || undefined
|
||||
})
|
||||
|
||||
toast.success("积分流转服务认证成功!", { id: 'payment-success' })
|
||||
|
||||
/* 支付成功后更新订单状态 */
|
||||
if (orderInfo) {
|
||||
setOrderInfo({
|
||||
...orderInfo,
|
||||
order: {
|
||||
...orderInfo.order,
|
||||
status: 'success'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/* 5秒后跳转到首页或回调地址 */
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
if (!isMountedRef.current) return
|
||||
|
||||
const redirectUri = orderInfo?.merchant?.redirect_uri
|
||||
if (redirectUri && redirectUri.trim()) {
|
||||
window.location.href = redirectUri
|
||||
return
|
||||
}
|
||||
|
||||
window.location.href = '/home'
|
||||
}, 5000)
|
||||
} catch (error: unknown) {
|
||||
handleServiceError(error, "认证")
|
||||
} finally {
|
||||
setPaying(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="relative min-h-screen w-full flex flex-col items-center justify-center bg-background">
|
||||
<div className="relative z-10 w-full h-full flex items-center justify-center p-4">
|
||||
<motion.div
|
||||
className="relative z-10 flex flex-col items-center justify-center min-h-screen text-center px-4"
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
>
|
||||
<div className="w-full rounded-3xl p-8 max-w-lg">
|
||||
<div className="w-12 h-12 rounded-full flex items-center justify-center mx-auto mb-6 bg-red-500/10">
|
||||
<div className="size-8 text-red-500 font-bold text-4xl flex items-center justify-center">!</div>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-foreground mb-4">
|
||||
出了点问题
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
您访问的积分流转服务不存在或已失效。请检查链接地址是否正确,对此如有疑问请联系社区技术支持。
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen w-full font-sans text-foreground overflow-hidden bg-background selection:bg-primary/30">
|
||||
<div className="relative min-h-screen w-full flex flex-col items-center justify-center bg-background">
|
||||
<div className="relative z-10 w-full h-full flex items-center justify-center p-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||
className="flex flex-col md:flex-row w-full max-w-4xl bg-card/70 backdrop-blur-2xl border border-border/50 rounded-3xl overflow-hidden shadow-2xl"
|
||||
style={{ minHeight: 'auto' }}
|
||||
>
|
||||
<PayingInfo orderInfo={orderInfo!} loading={loading} />
|
||||
<PayingNow
|
||||
orderInfo={orderInfo}
|
||||
paying={paying}
|
||||
payKey={payKey}
|
||||
currentStep={currentStep}
|
||||
selectedMethod={selectedMethod}
|
||||
isOpen={isOpen}
|
||||
loading={loading}
|
||||
onPayKeyChange={setPayKey}
|
||||
onCurrentStepChange={setCurrentStep}
|
||||
onSelectedMethodChange={setSelectedMethod}
|
||||
onIsOpenChange={setIsOpen}
|
||||
onPayOrder={handlePayOrder}
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { useState, useCallback } from "react"
|
||||
import Cropper from "react-easy-crop"
|
||||
import { toast } from "sonner"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Slider } from "@/components/ui/slider"
|
||||
import { Crop, RotateCw, X } from "lucide-react"
|
||||
import type { Area, Point } from "react-easy-crop"
|
||||
|
||||
interface ImageCropperProps {
|
||||
/** 是否打开对话框 */
|
||||
isOpen: boolean
|
||||
/** 关闭对话框回调 */
|
||||
onOpenChange: (open: boolean) => void
|
||||
/** 裁剪完成回调 */
|
||||
onCropComplete: (croppedImage: string, coverType: 'cover' | 'heterotypic') => void
|
||||
/** 封面类型 */
|
||||
coverType: 'cover' | 'heterotypic'
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建裁剪后的图片
|
||||
*/
|
||||
async function getCroppedImg(
|
||||
imageSrc: string,
|
||||
pixelCrop: Area,
|
||||
rotation = 0
|
||||
): Promise<string> {
|
||||
const image = await createImage(imageSrc)
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
if (!ctx) {
|
||||
throw new Error('无法创建 canvas context')
|
||||
}
|
||||
|
||||
const maxSize = Math.max(image.width, image.height)
|
||||
const safeArea = 2 * ((maxSize / 2) * Math.sqrt(2))
|
||||
|
||||
canvas.width = safeArea
|
||||
canvas.height = safeArea
|
||||
|
||||
ctx.translate(safeArea / 2, safeArea / 2)
|
||||
ctx.rotate((rotation * Math.PI) / 180)
|
||||
ctx.translate(-safeArea / 2, -safeArea / 2)
|
||||
|
||||
ctx.drawImage(
|
||||
image,
|
||||
safeArea / 2 - image.width * 0.5,
|
||||
safeArea / 2 - image.height * 0.5
|
||||
)
|
||||
|
||||
const data = ctx.getImageData(0, 0, safeArea, safeArea)
|
||||
|
||||
canvas.width = pixelCrop.width
|
||||
canvas.height = pixelCrop.height
|
||||
|
||||
ctx.putImageData(
|
||||
data,
|
||||
Math.round(0 - safeArea / 2 + image.width * 0.5 - pixelCrop.x),
|
||||
Math.round(0 - safeArea / 2 + image.height * 0.5 - pixelCrop.y)
|
||||
)
|
||||
|
||||
return canvas.toDataURL('image/webp')
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建图片对象
|
||||
*/
|
||||
function createImage(url: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const image = new Image()
|
||||
image.addEventListener('load', () => resolve(image))
|
||||
image.addEventListener('error', (error) => reject(error))
|
||||
image.setAttribute('crossOrigin', 'anonymous')
|
||||
image.src = url
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片裁剪组件
|
||||
* 使用 react-easy-crop 实现专业的图片裁剪功能
|
||||
*/
|
||||
export function ImageCropper({ isOpen, onOpenChange, onCropComplete, coverType }: ImageCropperProps) {
|
||||
const [image, setImage] = useState<string | null>(null)
|
||||
const [crop, setCrop] = useState<Point>({ x: 0, y: 0 })
|
||||
const [zoom, setZoom] = useState(1)
|
||||
const [rotation, setRotation] = useState(0)
|
||||
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null)
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null)
|
||||
|
||||
const resetCropState = () => {
|
||||
setImage(null)
|
||||
setCrop({ x: 0, y: 0 })
|
||||
setZoom(1)
|
||||
setRotation(0)
|
||||
setCroppedAreaPixels(null)
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// 裁剪比例配置 - 2:3
|
||||
const aspect = 2 / 3
|
||||
const cropShape = 'rect'
|
||||
|
||||
const coverLabel = coverType === 'cover' ? '背景封面' : '装饰图片'
|
||||
|
||||
// 处理文件选择
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
// 验证文件类型
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast.error('请选择图片文件')
|
||||
return
|
||||
}
|
||||
|
||||
// 验证文件大小 (最大 2MB)
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
toast.error('图片大小不能超过 2MB')
|
||||
return
|
||||
}
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
resetCropState()
|
||||
setImage(e.target?.result as string)
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
|
||||
// 裁剪完成回调
|
||||
const onCropCompleteHandler = useCallback((_croppedArea: Area, croppedAreaPixels: Area) => {
|
||||
setCroppedAreaPixels(croppedAreaPixels)
|
||||
}, [])
|
||||
|
||||
// 执行裁剪
|
||||
const handleCrop = async () => {
|
||||
if (!image || !croppedAreaPixels) return
|
||||
|
||||
try {
|
||||
const croppedImage = await getCroppedImg(image, croppedAreaPixels, rotation)
|
||||
onCropComplete(croppedImage, coverType)
|
||||
handleClose()
|
||||
} catch (error) {
|
||||
console.error('裁剪失败:', error)
|
||||
toast.error('裁剪失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭对话框
|
||||
const handleClose = () => {
|
||||
resetCropState()
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className={`max-h-[90vh] overflow-y-auto w-[95vw] sm:w-full ${ image ? 'sm:max-w-2xl' : 'sm:max-w-md' }`}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm">
|
||||
{image ? `裁剪${ coverLabel }` : `上传${ coverLabel }`}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
{image ? '拖拽调整位置,滚轮缩放' : '推荐 2:3 比例,支持 JPG / PNG / WEBP,最大 2MB'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{!image ? (
|
||||
/* 上传区域 */
|
||||
<div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
id="image-upload"
|
||||
/>
|
||||
<Label
|
||||
htmlFor="image-upload"
|
||||
className="cursor-pointer block rounded-lg border border-dashed border-border hover:border-foreground/25 hover:bg-muted/50 transition-colors p-10 sm:p-14 text-center"
|
||||
>
|
||||
<Crop className="size-6 text-muted-foreground/40 mx-auto mb-3" />
|
||||
<p className="text-xs font-medium text-foreground">点击选择图片</p>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">
|
||||
或拖拽图片到此处
|
||||
</p>
|
||||
</Label>
|
||||
</div>
|
||||
) : (
|
||||
/* 裁剪区域 */
|
||||
<div className="space-y-3">
|
||||
<div className="relative w-full h-[300px] sm:h-[420px] bg-muted/50 rounded-lg overflow-hidden touch-none">
|
||||
<Cropper
|
||||
image={image}
|
||||
crop={crop}
|
||||
zoom={zoom}
|
||||
rotation={rotation}
|
||||
aspect={aspect}
|
||||
cropShape={cropShape}
|
||||
showGrid={true}
|
||||
onCropChange={setCrop}
|
||||
onCropComplete={onCropCompleteHandler}
|
||||
onZoomChange={setZoom}
|
||||
onRotationChange={setRotation}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 px-1">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<Label className="text-[10px] text-muted-foreground shrink-0">缩放</Label>
|
||||
<Slider
|
||||
value={[zoom]}
|
||||
onValueChange={(value: number[]) => setZoom(value[0])}
|
||||
min={1}
|
||||
max={3}
|
||||
step={0.1}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-[10px] text-muted-foreground tabular-nums w-7 text-right shrink-0">{Math.round(zoom * 100)}%</span>
|
||||
</div>
|
||||
|
||||
<div className="w-px h-4 bg-border shrink-0" />
|
||||
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<Label className="text-[10px] text-muted-foreground shrink-0">旋转</Label>
|
||||
<Slider
|
||||
value={[rotation]}
|
||||
onValueChange={(value: number[]) => setRotation(value[0])}
|
||||
min={0}
|
||||
max={360}
|
||||
step={1}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-[10px] text-muted-foreground tabular-nums w-6 text-right shrink-0">{rotation}°</span>
|
||||
</div>
|
||||
|
||||
<div className="w-px h-4 bg-border shrink-0" />
|
||||
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setRotation((prev) => (prev + 90) % 360)}
|
||||
className="size-7 text-muted-foreground"
|
||||
title="旋转 90°"
|
||||
>
|
||||
<RotateCw className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={resetCropState}
|
||||
className="size-7 text-muted-foreground"
|
||||
title="重新选择"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={handleClose} className="text-xs h-8">
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCrop}
|
||||
disabled={!image}
|
||||
className="bg-red-500 hover:bg-red-600 text-xs h-8"
|
||||
>
|
||||
确认裁剪
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import Image from "next/image"
|
||||
import { motion } from "motion/react"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
|
||||
export interface RedEnvelopeCardProps {
|
||||
status: "preview" | "ready" | "opening"
|
||||
coverImage?: string | null
|
||||
heterotypicImage?: string | null
|
||||
greeting?: string
|
||||
sender?: {
|
||||
username?: string
|
||||
avatar_url?: string
|
||||
}
|
||||
onOpen?: () => void
|
||||
amount?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function RedEnvelopeCard({
|
||||
status,
|
||||
coverImage,
|
||||
greeting,
|
||||
sender,
|
||||
onOpen,
|
||||
className
|
||||
}: RedEnvelopeCardProps) {
|
||||
// 预览模式下不播放入场动画
|
||||
const isPreview = status === "preview"
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layoutId={!isPreview ? "red-envelope-card" : undefined}
|
||||
className={`relative w-full h-full rounded-xl shadow-2xl overflow-hidden z-10 bg-[#F25542] ${ className || "" }`}
|
||||
initial={!isPreview ? { opacity: 1 } : undefined}
|
||||
animate={!isPreview ? { opacity: 1 } : undefined}
|
||||
exit={!isPreview ? { y: -40, opacity: 0, scale: 0.95, transition: { duration: 0.5, ease: "easeInOut" } } : undefined}
|
||||
>
|
||||
{/* 上半部分 */}
|
||||
<div
|
||||
className="absolute top-0 h-[78%] overflow-hidden z-10 w-full"
|
||||
style={{
|
||||
borderRadius: '0 0 100% 100% / 0 0 20% 20%',
|
||||
backgroundColor: coverImage ? '#f35543' : '#E75240',
|
||||
boxShadow: '0 2px 2px rgba(0,0,0,0.15)'
|
||||
}}
|
||||
>
|
||||
{/* 自定义背景封面 */}
|
||||
{coverImage ? (
|
||||
<div className="absolute inset-0">
|
||||
<Image
|
||||
src={coverImage}
|
||||
alt="红包封面"
|
||||
fill
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
loading="eager"
|
||||
/>
|
||||
{/* 半透明遮罩以确保内容可读 */}
|
||||
<div className="absolute inset-0 bg-black/30 dark:bg-black/50" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* 用户信息 */}
|
||||
<div className="absolute top-28 left-0 right-0 flex flex-col items-center justify-center text-[#FAE5AE] z-20 px-6 space-y-4">
|
||||
<motion.div
|
||||
initial={!isPreview ? { scale: 0 } : undefined}
|
||||
animate={!isPreview ? { scale: 1 } : undefined}
|
||||
transition={{ delay: 0.15, type: "spring", stiffness: 300, damping: 20 }}
|
||||
className="flex items-center gap-2 opacity-90"
|
||||
>
|
||||
<Avatar className="h-8 w-8 rounded-md border border-white/20 shadow-sm">
|
||||
<AvatarImage src={sender?.avatar_url} alt={sender?.username} />
|
||||
<AvatarFallback className="bg-[#FAE5AE] text-[#E75240] font-bold text-xs rounded-md">
|
||||
{sender?.username?.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-base font-medium tracking-wide">{sender?.username || '你'} 的红包</span>
|
||||
</motion.div>
|
||||
|
||||
<motion.p
|
||||
initial={!isPreview ? { opacity: 0, y: 10 } : undefined}
|
||||
animate={!isPreview ? { opacity: 1, y: 0 } : undefined}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="text-xl font-medium tracking-wider text-center opacity-90"
|
||||
>
|
||||
{greeting || "新年快乐,恭喜发财"}
|
||||
</motion.p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 开启按钮 */}
|
||||
<div className="absolute top-[78%] left-1/2 -translate-x-1/2 -translate-y-1/2 z-20">
|
||||
{status === "opening" && (
|
||||
<motion.div
|
||||
className="absolute inset-0 -m-8"
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: [0.9, 2.0], opacity: [0, 0.6, 0] }}
|
||||
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||
>
|
||||
<div
|
||||
className="w-full h-full rounded-full"
|
||||
style={{ background: "radial-gradient(circle, rgba(255,232,168,0.8) 0%, rgba(245,205,122,0.4) 35%, transparent 65%)" }}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<motion.button
|
||||
className="relative w-20 h-20 rounded-full bg-[#EFC77A] flex items-center justify-center shadow-lg cursor-pointer border-none focus:outline-none overflow-hidden group"
|
||||
whileHover={status === "ready" || isPreview ? { scale: 1.05 } : {}}
|
||||
whileTap={status === "ready" || isPreview ? { scale: 0.95 } : {}}
|
||||
animate={status === "opening" ? {
|
||||
scale: [1, 0.92, 0],
|
||||
opacity: [1, 0]
|
||||
} : {}}
|
||||
transition={status === "opening" ? {
|
||||
duration: 0.55,
|
||||
ease: "easeInOut"
|
||||
} : {
|
||||
duration: 0.2
|
||||
}}
|
||||
onClick={status === "ready" ? onOpen : undefined}
|
||||
disabled={status !== "ready" && !isPreview}
|
||||
>
|
||||
<span className="relative text-[#4D4753] font-bold text-3xl z-10" style={{ fontFamily: 'serif' }}>
|
||||
開
|
||||
</span>
|
||||
</motion.button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import Image from "next/image"
|
||||
import { motion, AnimatePresence } from "motion/react"
|
||||
import { toast } from "sonner"
|
||||
import { Gift } from "lucide-react"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import services from "@/lib/services"
|
||||
import { formatDateTime } from "@/lib/utils"
|
||||
import type { RedEnvelopeDetailResponse, RedEnvelopeClaim } from "@/lib/services"
|
||||
import { getFileUrl } from "@/lib/services/upload/upload.service"
|
||||
import { RedEnvelopeCard } from "./red-envelope-card"
|
||||
|
||||
interface RedEnvelopeClaimProps {
|
||||
id: string
|
||||
}
|
||||
|
||||
type ClaimState = "loading" | "ready" | "opening" | "opened" | "claimed" | "error"
|
||||
|
||||
export function RedEnvelopeClaimPage({ id }: RedEnvelopeClaimProps) {
|
||||
const [state, setState] = useState<ClaimState>("loading")
|
||||
const [detail, setDetail] = useState<RedEnvelopeDetailResponse | null>(null)
|
||||
const [claimedAmount, setClaimedAmount] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const truncateText = (value: string | undefined, maxChars: number) => {
|
||||
if (!value) return ""
|
||||
const chars = Array.from(value)
|
||||
if (chars.length <= maxChars) return value
|
||||
return `${ chars.slice(0, maxChars).join("") }…`
|
||||
}
|
||||
|
||||
const bestClaimId = React.useMemo(() => {
|
||||
const claims = detail?.claims
|
||||
if (!claims || claims.length === 0) return null
|
||||
|
||||
let topId: string | null = null
|
||||
let topAmount = -Infinity
|
||||
|
||||
for (const claim of claims) {
|
||||
const amount = parseFloat(claim.amount)
|
||||
if (Number.isNaN(amount)) continue
|
||||
if (amount > topAmount) {
|
||||
topAmount = amount
|
||||
topId = claim.id
|
||||
}
|
||||
}
|
||||
|
||||
return topId
|
||||
}, [detail?.claims])
|
||||
|
||||
const formatClaimedAt = (claimedAt?: string) => {
|
||||
if (!claimedAt) return "-"
|
||||
|
||||
const date = new Date(claimedAt)
|
||||
if (Number.isNaN(date.getTime())) return "-"
|
||||
|
||||
const getShanghaiDateKey = (d: Date) =>
|
||||
new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: "Asia/Shanghai",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit"
|
||||
}).format(d)
|
||||
|
||||
const todayKey = getShanghaiDateKey(new Date())
|
||||
const claimKey = getShanghaiDateKey(date)
|
||||
|
||||
if (claimKey === todayKey) {
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
timeZone: "Asia/Shanghai",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
return formatDateTime(date)
|
||||
}
|
||||
|
||||
const loadDetail = useCallback(async () => {
|
||||
try {
|
||||
const data = await services.redEnvelope.getDetail(id)
|
||||
console.log('Red envelope data:', data.red_envelope)
|
||||
setDetail(data)
|
||||
if (data.user_claimed) {
|
||||
setClaimedAmount(data.user_claimed.amount)
|
||||
setState("claimed")
|
||||
} else if (data.red_envelope.status !== "active") {
|
||||
setState("opened")
|
||||
} else {
|
||||
setState("ready")
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "加载失败")
|
||||
setState("error")
|
||||
}
|
||||
}, [id])
|
||||
|
||||
// 安全验证图片URL (防止XSS)
|
||||
const sanitizeImageUrl = (url: string | null | undefined): string | undefined => {
|
||||
if (!url) return undefined
|
||||
|
||||
// 允许相对路径且以 /f/ 开头
|
||||
if (!url.startsWith('/f/')) {
|
||||
console.warn('Invalid image URL detected:', url)
|
||||
return undefined
|
||||
}
|
||||
|
||||
// 防止路径遍历
|
||||
if (url.includes('..') || url.includes('//')) {
|
||||
console.warn('Path traversal detected in URL:', url)
|
||||
return undefined
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
// 从后端获取封面图片URL并进行安全验证
|
||||
const coverImage = sanitizeImageUrl(getFileUrl(detail?.red_envelope?.cover_upload_id))
|
||||
const heterotypicImage = sanitizeImageUrl(getFileUrl(detail?.red_envelope?.heterotypic_upload_id))
|
||||
|
||||
useEffect(() => {
|
||||
loadDetail()
|
||||
}, [loadDetail])
|
||||
|
||||
const handleOpen = async () => {
|
||||
setState("opening")
|
||||
try {
|
||||
const result = await services.redEnvelope.claim({ id })
|
||||
setClaimedAmount(result.amount)
|
||||
|
||||
// Reload the full details to get updated claims list
|
||||
const updatedDetail = await services.redEnvelope.getDetail(id)
|
||||
setDetail(updatedDetail)
|
||||
|
||||
setTimeout(() => setState("claimed"), 1500)
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "领取失败")
|
||||
setState("ready")
|
||||
}
|
||||
}
|
||||
|
||||
if (state === "loading") {
|
||||
return (
|
||||
<div className="relative min-h-screen w-full flex flex-col items-center justify-center bg-background">
|
||||
<div className="relative z-10 w-full h-full flex items-center justify-center p-4">
|
||||
<motion.div animate={{ rotate: 360 }} transition={{ repeat: Infinity, duration: 1 }}>
|
||||
<Gift className="h-12 w-12 text-red-500" />
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (state === "error") {
|
||||
return (
|
||||
<div className="relative min-h-screen w-full flex flex-col items-center justify-center bg-background">
|
||||
<div className="relative z-10 w-full h-full flex items-center justify-center p-4">
|
||||
<motion.div
|
||||
className="relative z-10 flex flex-col items-center justify-center min-h-screen text-center px-4"
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
>
|
||||
<div className="w-full rounded-3xl p-8 max-w-lg">
|
||||
<div className="w-12 h-12 rounded-full flex items-center justify-center mx-auto mb-6 bg-red-500/10">
|
||||
<Gift className="h-8 w-8 text-red-500" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-foreground mb-4">
|
||||
红包加载失败
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const envelope = detail?.red_envelope
|
||||
const claimedCount = detail?.claims?.length ?? 0
|
||||
const totalCount = envelope?.total_count ?? claimedCount
|
||||
const totalAmount = parseFloat(envelope?.total_amount || "0").toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="relative h-screen w-full flex flex-col items-center justify-center bg-background p-2 sm:p-4 overflow-hidden">
|
||||
<div className="relative z-10 w-full h-full flex items-center justify-center">
|
||||
{/* 装饰图片容器 */}
|
||||
<div className="relative w-full max-w-[320px] aspect-[3/5] h-auto flex items-center justify-center">
|
||||
{/* 异形装饰 */}
|
||||
{heterotypicImage && (
|
||||
<motion.div
|
||||
initial={{ scale: 0.95, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||
className="absolute inset-0 z-0 pointer-events-none flex items-center justify-center scale-[1.2]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Image
|
||||
src={heterotypicImage}
|
||||
alt="红包装饰"
|
||||
fill
|
||||
className="object-cover object-center"
|
||||
unoptimized
|
||||
loading="eager"
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* 统一卡片容器 */}
|
||||
<motion.div
|
||||
initial={{ scale: 0.95, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ duration: 0.3, ease: "easeOut", delay: 0.1 }}
|
||||
className="relative w-full h-full mx-auto rounded-xl shadow-2xl overflow-hidden z-10"
|
||||
>
|
||||
<AnimatePresence mode="wait">
|
||||
{(state === "ready" || state === "opening") && (
|
||||
<RedEnvelopeCard
|
||||
key="cover"
|
||||
status={state}
|
||||
coverImage={coverImage}
|
||||
greeting={envelope?.greeting}
|
||||
sender={{
|
||||
username: envelope?.creator_username,
|
||||
avatar_url: envelope?.creator_avatar_url
|
||||
}}
|
||||
onOpen={handleOpen}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(state === "claimed" || state === "opened") && (
|
||||
<motion.div
|
||||
key="result"
|
||||
initial={{ y: 30, opacity: 0, scale: 0.98 }}
|
||||
animate={{ y: 0, opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.5, ease: [0.25, 0.46, 0.45, 0.94] }}
|
||||
className="absolute inset-0 bg-background flex flex-col"
|
||||
>
|
||||
{/* 顶部区域 */}
|
||||
<div className="relative shrink-0">
|
||||
<div
|
||||
className="h-26 w-full bg-[#F25542] relative z-0"
|
||||
style={{
|
||||
borderRadius: '0 0 100% 100% / 0 0 80% 80%'
|
||||
}}
|
||||
>
|
||||
{coverImage && (
|
||||
<div
|
||||
className="absolute inset-0 overflow-hidden"
|
||||
style={{ borderRadius: '0 0 100% 100% / 0 0 80% 80%' }}
|
||||
>
|
||||
<Image
|
||||
src={coverImage}
|
||||
alt="cover"
|
||||
fill
|
||||
className="object-cover opacity-50"
|
||||
style={{ transform: "scale(1.1)", transformOrigin: "center" }}
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 个人信息 */}
|
||||
<div className="flex flex-col items-center justify-center mt-4 shrink-0">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Avatar className="h-6 w-6 rounded-md border border-white/20">
|
||||
<AvatarImage className="rounded-md" src={envelope?.creator_avatar_url} alt={envelope?.creator_username} />
|
||||
<AvatarFallback className="bg-[#E75240] text-white text-[10px] rounded-md">
|
||||
{envelope?.creator_username?.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex items-center gap-1 min-w-0 flex-1">
|
||||
<span className="text-sm font-medium text-foreground/80 truncate">
|
||||
{truncateText(envelope?.creator_username, 12)}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-foreground/80 whitespace-nowrap">发出的红包</span>
|
||||
</div>
|
||||
{envelope?.type === 'random' && (
|
||||
<span className="bg-[#E1B876] text-white text-[10px] px-1 rounded">拼</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">{envelope?.greeting || "新年快乐,恭喜发财"}</p>
|
||||
</div>
|
||||
|
||||
{/* 金额区域 */}
|
||||
<div className="bg-background pt-3 pb-4 px-4 text-center shrink-0">
|
||||
{claimedAmount ? (
|
||||
<div>
|
||||
<div className="text-center">
|
||||
<div className="relative inline-block text-[#CFB073] leading-none">
|
||||
<span className="text-5xl font-bold">{parseFloat(claimedAmount).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</span>
|
||||
<span className="absolute left-full ml-1 bottom-1 text-sm font-medium">LDC</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-2">
|
||||
<p className="text-2xl font-medium text-foreground/80">手慢了,红包派完了</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 列表区域 */}
|
||||
<div className="flex-1 bg-background flex flex-col min-h-0">
|
||||
<div className="px-4 py-1 bg-background text-xs text-muted-foreground flex items-center justify-between">
|
||||
<div className="whitespace-nowrap">
|
||||
共 {totalAmount} LDC
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="font-semibold tabular-nums">{claimedCount}</span>
|
||||
<span>/</span>
|
||||
<span className="font-semibold tabular-nums">{totalCount}</span>
|
||||
<span>个</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-border/30" />
|
||||
<div className="flex-1 bg-background overflow-y-auto overscroll-contain">
|
||||
<div className="divide-y divide-border/30">
|
||||
{detail?.claims.map((claim: RedEnvelopeClaim) => (
|
||||
<div
|
||||
key={claim.id}
|
||||
className="flex items-start justify-between px-4 py-2 gap-3"
|
||||
>
|
||||
<div className="flex gap-3 min-w-0 flex-1">
|
||||
<Avatar className="h-9 w-9 rounded-md border border-border/10 mt-0.5">
|
||||
<AvatarImage className="rounded-md" src={claim.avatar_url} alt={claim.username} />
|
||||
<AvatarFallback className="text-xs bg-muted rounded-md">
|
||||
{claim.username.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col items-start gap-0.5 min-w-0">
|
||||
<span className="text-[13px] font-medium text-foreground/90 truncate max-w-[160px] sm:max-w-[200px]">
|
||||
{truncateText(claim.username, 12)}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{formatClaimedAt(claim.claimed_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-0.5 shrink-0 min-w-[96px] text-right">
|
||||
<span className="text-[13px] font-semibold text-foreground tabular-nums whitespace-nowrap">
|
||||
{parseFloat(claim.amount).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} LDC
|
||||
</span>
|
||||
{/* 最佳手气 */}
|
||||
{envelope?.type === 'random' && bestClaimId === claim.id && (
|
||||
<div className="flex items-center gap-1 text-[#E1B876] text-[9px]">
|
||||
<span>手气最佳</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { AlertTriangle } from "lucide-react"
|
||||
import { RiskInfo, RiskInfoBox } from "@/components/common/risk/risk-info-box"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
|
||||
const RISK_BLOCKED_EVENT = "credit-risk-blocked"
|
||||
|
||||
function isRiskInfo(value: unknown): value is RiskInfo {
|
||||
if (!value || typeof value !== "object") return false
|
||||
const riskInfo = value as Partial<RiskInfo>
|
||||
return typeof riskInfo.risk_level === "string" && Array.isArray(riskInfo.risk_labels) && Array.isArray(riskInfo.risks)
|
||||
}
|
||||
|
||||
export function RiskBlockDialog() {
|
||||
const [riskInfo, setRiskInfo] = useState<RiskInfo | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const handleRiskBlocked = (event: Event) => {
|
||||
const detail = (event as CustomEvent<unknown>).detail
|
||||
if (!isRiskInfo(detail)) return
|
||||
setRiskInfo(detail)
|
||||
}
|
||||
|
||||
window.addEventListener(RISK_BLOCKED_EVENT, handleRiskBlocked)
|
||||
return () => window.removeEventListener(RISK_BLOCKED_EVENT, handleRiskBlocked)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Dialog open={!!riskInfo}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
onEscapeKeyDown={(event) => event.preventDefault()}
|
||||
onPointerDownOutside={(event) => event.preventDefault()}
|
||||
onInteractOutside={(event) => event.preventDefault()}
|
||||
className="sm:max-w-md"
|
||||
>
|
||||
<DialogHeader className="items-center text-center sm:text-center">
|
||||
<div className="mb-1 flex size-12 items-center justify-center rounded-full bg-destructive/10">
|
||||
<AlertTriangle className="size-6 text-destructive" />
|
||||
</div>
|
||||
<DialogTitle className="text-center text-lg">账号存在风险</DialogTitle>
|
||||
<DialogDescription className="text-center text-sm leading-6">
|
||||
因触发风控,账号暂时无法使用需登录的功能
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<RiskInfoBox riskInfo={riskInfo} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
|
||||
export interface RiskItem {
|
||||
label: string
|
||||
value?: string
|
||||
desc?: string
|
||||
}
|
||||
|
||||
export interface RiskInfo {
|
||||
risk_level: string
|
||||
risk_labels: string[]
|
||||
risks: RiskItem[]
|
||||
}
|
||||
|
||||
export function RiskInfoBox({
|
||||
riskInfo,
|
||||
label = "风险等级",
|
||||
labelClassName,
|
||||
}: {
|
||||
riskInfo: RiskInfo | null
|
||||
label?: string
|
||||
labelClassName?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4 shadow-sm">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={cn("text-sm font-medium text-muted-foreground", labelClassName)}>{label}</span>
|
||||
<Badge variant="destructive">{riskInfo?.risk_level || "未知"}</Badge>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-2">
|
||||
{riskInfo?.risks.length ? (
|
||||
riskInfo.risks.map(risk => (
|
||||
<div key={risk.value || risk.label} className="rounded-md border bg-muted/35 px-3 py-2.5">
|
||||
<span className="text-sm font-medium leading-5 text-foreground">{risk.label}</span>
|
||||
{risk.desc ? (
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">{risk.desc}</p>
|
||||
) : null}
|
||||
</div>
|
||||
))
|
||||
) : riskInfo?.risk_labels.length ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{riskInfo.risk_labels.map(label => (
|
||||
<Badge key={label} variant="secondary">
|
||||
{label}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">暂无风险标签详情</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { toast } from "sonner"
|
||||
import { X } from "lucide-react"
|
||||
import { RiskInfo, RiskInfoBox } from "@/components/common/risk/risk-info-box"
|
||||
|
||||
const RISK_TOAST_ID = "credit-risk-warning"
|
||||
let isRiskWarningDismissed = false
|
||||
|
||||
function dismissRiskWarningToast(id: string | number) {
|
||||
isRiskWarningDismissed = true
|
||||
toast.dismiss(id)
|
||||
}
|
||||
|
||||
export function showRiskWarningToast(riskInfo: RiskInfo) {
|
||||
if (isRiskWarningDismissed) return
|
||||
|
||||
toast.custom(
|
||||
(id) => (
|
||||
<div className="relative w-[min(calc(100vw-2rem),28rem)]">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭风险提示"
|
||||
className="absolute right-2 top-2 inline-flex size-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
onClick={() => dismissRiskWarningToast(id)}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
<RiskInfoBox riskInfo={riskInfo} label="账号存在风险提示" labelClassName="font-semibold text-foreground" />
|
||||
</div>
|
||||
),
|
||||
{
|
||||
id: RISK_TOAST_ID,
|
||||
duration: Infinity,
|
||||
dismissible: true,
|
||||
onDismiss: () => {
|
||||
isRiskWarningDismissed = true
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import Link from "next/link"
|
||||
import { Moon, Sun, Loader2, Check, Monitor } from "lucide-react"
|
||||
import { useTheme } from "next-themes"
|
||||
import { toast } from "sonner"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useCustomTheme } from "@/lib/theme"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "@/components/ui/breadcrumb"
|
||||
|
||||
function ThemeModeSection() {
|
||||
const { theme, setTheme } = useTheme()
|
||||
const [mounted, setMounted] = React.useState(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
const modes = [
|
||||
{ id: "light", name: "明亮", icon: Sun },
|
||||
{ id: "dark", name: "黑暗", icon: Moon },
|
||||
{ id: "system", name: "自动", icon: Monitor }
|
||||
]
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="font-medium text-sm text-foreground">主题模式</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
选择应用程序的整体明暗主题
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{modes.map((mode) => {
|
||||
const Icon = mode.icon
|
||||
return (
|
||||
<Button
|
||||
key={mode.id}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="flex-1 text-xs"
|
||||
>
|
||||
{Icon && <Icon className="size-3 mr-1" />}
|
||||
{mode.name}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="font-medium text-sm text-foreground">主题模式</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
选择应用程序的整体明暗主题
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{modes.map((mode) => {
|
||||
const Icon = mode.icon
|
||||
const isActive = theme === mode.id
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={mode.id}
|
||||
variant={isActive ? "default" : "secondary"}
|
||||
size="sm"
|
||||
onClick={() => setTheme(mode.id)}
|
||||
className="flex-1 text-xs"
|
||||
>
|
||||
{Icon && <Icon className="size-3 mr-1" />}
|
||||
{mode.name}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InterfaceAppearanceSection() {
|
||||
const { themes, currentThemeId, setTheme: setCustomTheme, isLoading } = useCustomTheme()
|
||||
const { theme: mode } = useTheme()
|
||||
const [mounted, setMounted] = React.useState(false)
|
||||
const [switching, setSwitching] = React.useState<string | null>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
const handleSetTheme = (themeId: string) => {
|
||||
if (switching === themeId) return
|
||||
setSwitching(themeId)
|
||||
try {
|
||||
setCustomTheme(themeId)
|
||||
const themeName = themes.find(t => t.id === themeId)?.name || themeId
|
||||
toast.success(`外观已切换为 ${ themeName }`)
|
||||
} catch {
|
||||
toast.error("切换失败")
|
||||
} finally {
|
||||
setSwitching(null)
|
||||
}
|
||||
}
|
||||
|
||||
const isDark = mounted && (mode === 'dark' || (mode === 'system' && typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches))
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="font-medium text-sm text-foreground">界面外观</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
选择界面的配色方案和视觉风格
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center p-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground/50" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 xs:grid-cols-3 sm:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-6">
|
||||
{themes.map((theme) => {
|
||||
const isActive = currentThemeId === theme.id || (!currentThemeId && theme.id === "default.css")
|
||||
const isSwitching = switching === theme.id
|
||||
const colors = isDark ? theme.colors.dark : theme.colors.light
|
||||
|
||||
return (
|
||||
<div
|
||||
key={theme.id}
|
||||
onClick={() => handleSetTheme(theme.id)}
|
||||
className={cn(
|
||||
"group relative cursor-pointer rounded-xl border transition-all overflow-hidden",
|
||||
isActive
|
||||
? "border-primary ring-2 ring-primary/20"
|
||||
: "border-border hover:border-border/50 hover:shadow-md"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="aspect-[4/3] w-full relative"
|
||||
style={{ backgroundColor: colors.background || colors['background'] }}
|
||||
>
|
||||
<div className="flex h-full p-2 gap-2">
|
||||
<div
|
||||
className="w-1/4 h-full rounded-sm opacity-80"
|
||||
style={{ backgroundColor: colors.sidebar || colors['sidebar'] }}
|
||||
/>
|
||||
<div className="flex-1 space-y-1.5 pt-1">
|
||||
<div
|
||||
className="h-1.5 w-1/3 rounded-full opacity-90"
|
||||
style={{ backgroundColor: colors.primary || colors['primary'] }}
|
||||
/>
|
||||
<div
|
||||
className="h-8 w-full rounded-sm opacity-40"
|
||||
style={{ backgroundColor: colors.sidebar || colors['sidebar'] }}
|
||||
/>
|
||||
<div
|
||||
className="h-4 w-1/2 rounded-sm opacity-30"
|
||||
style={{ backgroundColor: colors.sidebar || colors['sidebar'] }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isActive && !isSwitching && (
|
||||
<div className="absolute top-2 right-2 z-10 bg-primary text-primary-foreground rounded-full p-0.5 shadow-sm">
|
||||
<Check className="h-3 w-3" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-2 border-t border-border/50 bg-card">
|
||||
<div className={cn("text-[10px] font-medium text-center truncate transition-colors", isActive ? "text-primary" : "text-muted-foreground")}>
|
||||
{theme.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AppearanceMain() {
|
||||
return (
|
||||
<div className="py-6 space-y-6">
|
||||
<div className="font-semibold">
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="/settings" className="text-base text-primary">设置</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage className="text-base font-semibold">外观设置</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
|
||||
<ThemeModeSection />
|
||||
|
||||
<InterfaceAppearanceSection />
|
||||
|
||||
<div className="space-y-4 opacity-50 pointer-events-none grayscale">
|
||||
<h2 className="font-medium text-sm text-muted-foreground">即将推出</h2>
|
||||
<div className="bg-muted/30 rounded-xl p-6 h-32 flex items-center justify-center border border-border/50">
|
||||
<span className="text-sm text-muted-foreground">更多自定义选项正在开发中...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { Bell } from "lucide-react"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { useNotificationSettings } from "@/contexts/notification-settings-context"
|
||||
import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "@/components/ui/breadcrumb"
|
||||
|
||||
export function NotificationsMain() {
|
||||
const { showBell, setShowBell } = useNotificationSettings()
|
||||
|
||||
return (
|
||||
<div className="py-6 space-y-6">
|
||||
<div className="font-semibold">
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="/settings" className="text-base text-primary">设置</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage className="text-base font-semibold">通知设置</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="font-medium text-sm text-foreground">通知显示</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
设置通知相关的显示选项
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Bell className="size-5 text-primary" />
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="show-bell" className="text-sm font-medium cursor-pointer">
|
||||
显示通知铃铛
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
在顶部导航栏中显示通知铃铛图标
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
id="show-bell"
|
||||
checked={showBell}
|
||||
onCheckedChange={setShowBell}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import Link from "next/link"
|
||||
import { motion, useAnimation } from "motion/react"
|
||||
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"
|
||||
|
||||
|
||||
import { Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious, type CarouselApi } from "@/components/ui/carousel"
|
||||
import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "@/components/ui/breadcrumb"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||||
import { Info } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { PayLevel, User } from "@/lib/services/auth"
|
||||
import { useUser } from "@/contexts/user-context"
|
||||
import services from "@/lib/services"
|
||||
import type { UserPayConfig } from "@/lib/services/admin"
|
||||
|
||||
|
||||
/**
|
||||
* 等级配置
|
||||
*/
|
||||
interface LevelConfig {
|
||||
level: PayLevel
|
||||
name: string
|
||||
nameEn: string
|
||||
minScore: number
|
||||
maxScore: number | null
|
||||
gradient: string
|
||||
textColor: string
|
||||
metalEffect?: boolean
|
||||
dailyLimit: number | null
|
||||
feeRate: number | string
|
||||
scoreRate: number | string
|
||||
}
|
||||
|
||||
/** 等级视觉样式配置(不包含业务数据) */
|
||||
const LEVEL_STYLES = [
|
||||
{
|
||||
level: PayLevel.Ordinary,
|
||||
name: "普通会员",
|
||||
nameEn: "ORDINARY MEMBER",
|
||||
gradient: "from-[#1e3a8a] via-[#2563eb] to-[#1d4ed8] shadow-lg shadow-blue-900/10",
|
||||
textColor: "text-white/95",
|
||||
},
|
||||
{
|
||||
level: PayLevel.Gold,
|
||||
name: "黄金会员",
|
||||
nameEn: "GOLD MEMBER",
|
||||
gradient: "from-[#FDFCF0] via-[#FCE38A] to-[#B45309] shadow-lg shadow-amber-500/10",
|
||||
textColor: "text-[#854D0E]",
|
||||
metalEffect: true,
|
||||
},
|
||||
{
|
||||
level: PayLevel.WhiteGold,
|
||||
name: "白金会员",
|
||||
nameEn: "PLATINUM MEMBER",
|
||||
gradient: "from-[#F8FAFC] via-[#E2E8F0] to-[#94A3B8] shadow-lg shadow-slate-500/10",
|
||||
textColor: "text-slate-800",
|
||||
metalEffect: true,
|
||||
},
|
||||
{
|
||||
level: PayLevel.BlackGold,
|
||||
name: "黑金会员",
|
||||
nameEn: "BLACK MEMBER",
|
||||
gradient: "from-[#020617] via-[#1e293b] to-[#020617] shadow-lg shadow-black/20",
|
||||
textColor: "text-[#FFE5B4]",
|
||||
metalEffect: true,
|
||||
},
|
||||
] as const
|
||||
|
||||
/** 合并 API 数据和视觉样式 */
|
||||
function mergeLevelConfigs(apiConfigs: UserPayConfig[]): LevelConfig[] {
|
||||
return LEVEL_STYLES.map(style => {
|
||||
const apiConfig = apiConfigs.find(c => c.level === style.level)
|
||||
if (apiConfig) {
|
||||
return {
|
||||
...style,
|
||||
minScore: apiConfig.min_score,
|
||||
maxScore: apiConfig.max_score,
|
||||
dailyLimit: apiConfig.daily_limit,
|
||||
feeRate: apiConfig.fee_rate,
|
||||
scoreRate: apiConfig.score_rate,
|
||||
}
|
||||
}
|
||||
return {
|
||||
...style,
|
||||
minScore: 0,
|
||||
maxScore: null,
|
||||
dailyLimit: null,
|
||||
feeRate: 0,
|
||||
scoreRate: 0,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function getLevelConfig(score: number, configs: LevelConfig[]): LevelConfig {
|
||||
if (configs.length === 0) {
|
||||
return {
|
||||
level: PayLevel.Ordinary,
|
||||
name: "加载中...",
|
||||
nameEn: "LOADING...",
|
||||
gradient: "from-blue-600 via-blue-500 to-blue-700",
|
||||
textColor: "text-white",
|
||||
minScore: 0,
|
||||
maxScore: null,
|
||||
dailyLimit: null,
|
||||
feeRate: 0,
|
||||
scoreRate: 0,
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = configs.length - 1; i >= 0; i--) {
|
||||
if (score >= configs[i].minScore) {
|
||||
return configs[i]
|
||||
}
|
||||
}
|
||||
return configs[0]
|
||||
}
|
||||
|
||||
function MembershipCard({
|
||||
config,
|
||||
user,
|
||||
score,
|
||||
currentLevel,
|
||||
isActive,
|
||||
levelConfigs,
|
||||
}: {
|
||||
config: LevelConfig
|
||||
user: User
|
||||
score: number
|
||||
currentLevel: LevelConfig
|
||||
isActive: boolean
|
||||
levelConfigs: LevelConfig[]
|
||||
}) {
|
||||
const isAccessible = levelConfigs.findIndex(l => l.level === config.level) <= levelConfigs.findIndex(l => l.level === currentLevel.level)
|
||||
const isDarkCard = config.level === PayLevel.BlackGold || config.level === PayLevel.Ordinary
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"transition-all duration-500",
|
||||
isActive ? "opacity-100 scale-100" : "opacity-40 scale-95"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-full aspect-[2/1] rounded-xl overflow-hidden bg-gradient-to-br shadow-lg",
|
||||
config.gradient,
|
||||
config.metalEffect && "shadow-2xl"
|
||||
)}
|
||||
>
|
||||
<div className="absolute inset-0 opacity-10">
|
||||
<div className="absolute top-0 right-0 w-40 h-40 bg-white rounded-full blur-3xl -translate-y-1/2 translate-x-1/2" />
|
||||
<div className="absolute bottom-0 left-0 w-32 h-32 bg-white rounded-full blur-3xl translate-y-1/2 -translate-x-1/2" />
|
||||
</div>
|
||||
<div className="absolute inset-0 opacity-[0.15] bg-[url('https://grainy-gradients.vercel.app/noise.svg')]" />
|
||||
|
||||
{config.metalEffect && (
|
||||
<>
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-white/5 via-white/2 to-transparent" />
|
||||
<div className="absolute inset-0 rounded-xl ring-1 ring-inset ring-white/25" />
|
||||
<div className="absolute top-0 left-0 w-full h-1/4 bg-gradient-to-b from-white/15 via-transparent to-transparent rounded-t-xl" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className={cn("relative h-full flex flex-col justify-between p-3 sm:p-4 md:p-5", config.textColor)}>
|
||||
<div>
|
||||
<div className="text-[8px] sm:text-[9px] md:text-[10px] font-medium opacity-70 tracking-wider">
|
||||
{config.nameEn}
|
||||
</div>
|
||||
<div className="text-sm sm:text-base md:text-lg font-bold tracking-tight">
|
||||
{config.name}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-0.5 sm:space-y-1">
|
||||
<div className="text-[8px] sm:text-[9px] md:text-[10px] opacity-70 tracking-wide">平台分数</div>
|
||||
<div className="text-lg sm:text-xl md:text-2xl font-bold tracking-tight tabular-nums">
|
||||
{score.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end justify-between gap-1">
|
||||
<div className="text-[8px] sm:text-[9px] md:text-[10px] opacity-70">
|
||||
LINUX DO <span className="italic font-serif">Credit</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-[8px] sm:text-[9px] md:text-[10px] opacity-70">分数范围</div>
|
||||
<div className="text-[8px] sm:text-[9px] md:text-[10px] font-medium tabular-nums">
|
||||
{config.minScore < 0 ? "0.00" : config.minScore.toLocaleString()} - {config.maxScore?.toLocaleString() || "∞"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute top-3 right-3 sm:top-4 sm:right-4 md:top-5 md:right-5">
|
||||
{isAccessible ? (
|
||||
<Avatar className="size-8 sm:size-9 md:size-10 border-2 border-white/20">
|
||||
<AvatarImage src={user?.avatar_url} alt={user?.nickname} />
|
||||
<AvatarFallback
|
||||
className={cn(
|
||||
"text-xs sm:text-sm font-semibold",
|
||||
isDarkCard ? "bg-white/20 text-white" : "bg-black/20 text-black"
|
||||
)}
|
||||
>
|
||||
{user?.nickname?.slice(0, 2)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"px-1.5 sm:px-2 py-0.5 sm:py-1 rounded-full text-[8px] sm:text-[9px] md:text-[10px] font-semibold backdrop-blur-sm",
|
||||
isDarkCard ? "bg-white/20 text-white" : "bg-black/20 text-black"
|
||||
)}
|
||||
>
|
||||
未获得
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getPayLevelLabel(level: PayLevel): string {
|
||||
switch (level) {
|
||||
case PayLevel.BlackGold:
|
||||
return "黑金会员"
|
||||
case PayLevel.WhiteGold:
|
||||
return "白金会员"
|
||||
case PayLevel.Gold:
|
||||
return "黄金会员"
|
||||
case PayLevel.Ordinary:
|
||||
return "普通会员"
|
||||
default:
|
||||
return "未知等级"
|
||||
}
|
||||
}
|
||||
|
||||
export function ProfileMain() {
|
||||
const { user, loading, getTrustLevelLabel } = useUser()
|
||||
const [api, setApi] = React.useState<CarouselApi>()
|
||||
const [current, setCurrent] = React.useState(0)
|
||||
const [levelConfigs, setLevelConfigs] = React.useState<LevelConfig[]>([])
|
||||
|
||||
const controls = useAnimation()
|
||||
const isAnimatingRef = React.useRef(false)
|
||||
|
||||
const handleAvatarClick = () => {
|
||||
if (isAnimatingRef.current) return
|
||||
|
||||
isAnimatingRef.current = true
|
||||
controls.start({
|
||||
rotate: [0, -20, 20, -20, 20, 0],
|
||||
transition: { duration: 0.5, ease: "easeInOut" }
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
isAnimatingRef.current = false
|
||||
}, 650)
|
||||
}
|
||||
|
||||
const score = user?.pay_score ?? 0
|
||||
const currentLevel = getLevelConfig(score, levelConfigs)
|
||||
|
||||
React.useEffect(() => {
|
||||
async function fetchPayConfigs() {
|
||||
const configs = await services.config.getUserPayConfigs()
|
||||
setLevelConfigs(mergeLevelConfigs(configs))
|
||||
}
|
||||
fetchPayConfigs()
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api) return
|
||||
|
||||
setCurrent(api.selectedScrollSnap())
|
||||
api.on("select", () => setCurrent(api.selectedScrollSnap()))
|
||||
}, [api])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api || levelConfigs.length === 0 || !currentLevel) return
|
||||
|
||||
const scrollToCurrentLevel = () => {
|
||||
const currentLevelIndex = levelConfigs.findIndex(
|
||||
(config) => config.level === currentLevel.level
|
||||
)
|
||||
if (currentLevelIndex >= 0) {
|
||||
api.scrollTo(currentLevelIndex, true)
|
||||
}
|
||||
}
|
||||
scrollToCurrentLevel()
|
||||
api.on("reInit", scrollToCurrentLevel)
|
||||
return () => {
|
||||
api.off("reInit", scrollToCurrentLevel)
|
||||
}
|
||||
}, [api, currentLevel, levelConfigs])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="py-6 space-y-4">
|
||||
<div className="border-b border-border pb-4">
|
||||
<h1 className="text-2xl font-semibold">个人资料</h1>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="py-6 space-y-6">
|
||||
<div className="text-sm text-muted-foreground">未找到用户信息</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-6 space-y-6">
|
||||
<div className="font-semibold">
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="/settings" className="text-base text-primary">设置</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage className="text-base font-semibold">个人资料</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
|
||||
<h2 className="font-medium text-sm text-muted-foreground">会员等级</h2>
|
||||
|
||||
<Carousel
|
||||
setApi={setApi}
|
||||
opts={{
|
||||
align: "center",
|
||||
loop: false,
|
||||
containScroll: false,
|
||||
}}
|
||||
className="w-full relative"
|
||||
>
|
||||
<div className="absolute inset-y-0 left-0 w-8 bg-gradient-to-r from-background to-transparent pointer-events-none z-10" />
|
||||
<div className="absolute inset-y-0 right-0 w-8 bg-gradient-to-l from-background to-transparent pointer-events-none z-10" />
|
||||
<CarouselContent className="-ml-4">
|
||||
{levelConfigs.map((config, index) => (
|
||||
<CarouselItem
|
||||
key={config.level}
|
||||
className="pl-4 basis-[85%] sm:basis-[70%] md:basis-[65%] lg:basis-[50%] xl:basis-[40%] 2xl:basis-[35%] cursor-pointer"
|
||||
onClick={() => api?.scrollTo(index)}
|
||||
>
|
||||
<MembershipCard
|
||||
config={config}
|
||||
user={user}
|
||||
score={score}
|
||||
currentLevel={currentLevel}
|
||||
isActive={index === current}
|
||||
levelConfigs={levelConfigs}
|
||||
/>
|
||||
</CarouselItem>
|
||||
))}
|
||||
</CarouselContent>
|
||||
<CarouselPrevious className="left-0" />
|
||||
<CarouselNext className="right-0" />
|
||||
</Carousel>
|
||||
|
||||
|
||||
<div className="space-y-4">
|
||||
<h2 className="font-medium text-sm text-muted-foreground">基本信息</h2>
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-start gap-4 sm:gap-6">
|
||||
<motion.div
|
||||
animate={controls}
|
||||
onClick={handleAvatarClick}
|
||||
className="cursor-pointer origin-center"
|
||||
>
|
||||
<Avatar className="size-14 sm:size-16">
|
||||
<AvatarImage src={user.avatar_url} alt={user.nickname || user.username} />
|
||||
<AvatarFallback className="text-lg sm:text-xl">
|
||||
{(user.nickname || user.username).slice(0, 2)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</motion.div>
|
||||
|
||||
<div className="flex-1 w-full">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-muted-foreground">账户</div>
|
||||
<div className="text-sm font-medium">@{user.username}</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-muted-foreground">昵称</div>
|
||||
<div className="text-sm font-medium">{user.nickname || '未设置'}</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-muted-foreground">用户ID</div>
|
||||
<div className="text-sm font-medium">{user.id}</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-muted-foreground">信任等级</div>
|
||||
<div className="text-sm font-medium">{getTrustLevelLabel(user.trust_level)}</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-muted-foreground">平台等级</div>
|
||||
<div className="text-sm font-medium">{getPayLevelLabel(user.pay_level)}</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-muted-foreground">平台分数</div>
|
||||
<div className="text-sm font-medium">{user.pay_score.toLocaleString()}</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-muted-foreground">每日流转限额</div>
|
||||
<div className="text-sm font-medium">
|
||||
{currentLevel.dailyLimit ? `${ currentLevel.dailyLimit.toLocaleString() } 积分` : '无限额'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-muted-foreground">集市手续费率</div>
|
||||
<div className="text-sm font-medium">
|
||||
{typeof currentLevel.feeRate === 'number'
|
||||
? `${ (currentLevel.feeRate * 100).toFixed(2) }%`
|
||||
: `${ (parseFloat(currentLevel.feeRate as string) * 100).toFixed(2) }%`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-muted-foreground">平台分数转化率</div>
|
||||
<div className="text-sm font-medium">
|
||||
{typeof currentLevel.scoreRate === 'number'
|
||||
? `${ (currentLevel.scoreRate * 100).toFixed(2) }%`
|
||||
: `${ (parseFloat(currentLevel.scoreRate as string) * 100).toFixed(2) }%`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
积分基准值
|
||||
<Popover>
|
||||
<PopoverTrigger>
|
||||
<Info className="h-3 w-3 cursor-help" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side="top" className="w-auto max-w-[280px] p-3">
|
||||
<p className="text-xs">上一次从社区同步的积分基准值</p>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<div className="text-sm font-medium tabular-nums">
|
||||
{parseFloat(user.community_balance).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
import { motion, AnimatePresence } from "motion/react"
|
||||
import { ShieldCheck } from "lucide-react"
|
||||
|
||||
import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "@/components/ui/breadcrumb"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp"
|
||||
import { Spinner } from "@/components/ui/spinner"
|
||||
import { UserService } from "@/lib/services/user"
|
||||
|
||||
export function SecurityMain() {
|
||||
const router = useRouter()
|
||||
const [setupStep, setSetupStep] = React.useState<'password' | 'confirm'>('password')
|
||||
const [payKey, setPayKey] = React.useState("")
|
||||
const [confirmPayKey, setConfirmPayKey] = React.useState("")
|
||||
const [isSubmitting, setIsSubmitting] = React.useState(false)
|
||||
|
||||
const isPayKeyValid = payKey.length === 6 && /^\d{6}$/.test(payKey)
|
||||
const isConfirmValid = confirmPayKey.length === 6 && /^\d{6}$/.test(confirmPayKey)
|
||||
const passwordsMatch = payKey === confirmPayKey
|
||||
|
||||
/* 安全密码输入 */
|
||||
const handlePayKeyChange = (value: string) => {
|
||||
const numericValue = value.replaceAll(/\D/g, '')
|
||||
setPayKey(numericValue)
|
||||
}
|
||||
|
||||
/* 确认安全密码 */
|
||||
const handleConfirmPayKeyChange = (value: string) => {
|
||||
const numericValue = value.replaceAll(/\D/g, '')
|
||||
setConfirmPayKey(numericValue)
|
||||
}
|
||||
|
||||
const handlePayKeySubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (setupStep === 'password') {
|
||||
if (!isPayKeyValid) {
|
||||
toast.error("安全密码必须为6位数字")
|
||||
return
|
||||
}
|
||||
setSetupStep('confirm')
|
||||
} else {
|
||||
if (!isConfirmValid) {
|
||||
toast.error("确认密码必须为6位数字")
|
||||
return
|
||||
}
|
||||
|
||||
if (!passwordsMatch) {
|
||||
toast.error("两次输入的安全密码不一致")
|
||||
setSetupStep('password')
|
||||
setConfirmPayKey("")
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
await UserService.updatePayKey(payKey)
|
||||
toast.success("修改成功", {
|
||||
description: "您的安全密码已成功更新",
|
||||
})
|
||||
|
||||
// 立即退出
|
||||
router.push('/settings')
|
||||
} catch (error) {
|
||||
toast.error("修改失败", {
|
||||
description: error instanceof Error ? error.message : "更新密码时发生错误,请稍后重试",
|
||||
})
|
||||
setSetupStep('password')
|
||||
setConfirmPayKey("")
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-6 space-y-6">
|
||||
<div className="font-semibold">
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="/settings" className="text-base text-primary">设置</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage className="text-base font-semibold">安全设置</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="font-medium text-sm text-muted-foreground flex items-center gap-2">
|
||||
<ShieldCheck className="w-4 h-4" />
|
||||
密码安全
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex justify-center">
|
||||
<motion.div
|
||||
className="w-full max-w-sm space-y-8"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={setupStep}
|
||||
initial={{ opacity: 0, x: setupStep === 'confirm' ? 20 : -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="space-y-8"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2 mb-4">
|
||||
<h3 className="text-xl font-bold tracking-tight text-center">
|
||||
{setupStep === 'password' ? '设置新密码' : '确认新密码'}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground text-center max-w-[320px] mx-auto">
|
||||
{setupStep === 'password'
|
||||
? '请输入新的6位数字安全密码'
|
||||
: '请再次输入密码进行确认'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handlePayKeySubmit} className="space-y-6">
|
||||
<div className="flex justify-center">
|
||||
{setupStep === 'password' ? (
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
value={payKey}
|
||||
onChange={handlePayKeyChange}
|
||||
disabled={isSubmitting}
|
||||
autoFocus
|
||||
>
|
||||
<InputOTPGroup className="gap-2">
|
||||
{[0, 1, 2, 3, 4, 5].map((i) => (
|
||||
<InputOTPSlot key={i} index={i} className="w-10 h-10 sm:w-11 sm:h-11 border-input transition-all ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2" />
|
||||
))}
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
value={confirmPayKey}
|
||||
onChange={handleConfirmPayKeyChange}
|
||||
disabled={isSubmitting}
|
||||
autoFocus
|
||||
>
|
||||
<InputOTPGroup className="gap-2">
|
||||
{[0, 1, 2, 3, 4, 5].map((i) => (
|
||||
<InputOTPSlot key={i} index={i} className="w-10 h-10 sm:w-11 sm:h-11 text-lg border-input transition-all ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2" />
|
||||
))}
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
{!passwordsMatch && confirmPayKey.length === 6 && (
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: -5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-xs text-red-500 text-center font-medium"
|
||||
>
|
||||
两次输入的密码不一致
|
||||
</motion.p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 sm:mx-8">
|
||||
{setupStep === 'confirm' && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setSetupStep('password')
|
||||
setConfirmPayKey('')
|
||||
}}
|
||||
className="flex-1 h-10 rounded-full tracking-wide text-sm font-bold transition-all active:scale-95"
|
||||
>
|
||||
返回
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
className={`flex-1 h-10 rounded-full tracking-wide bg-primary hover:bg-primary/90 text-primary-foreground text-sm font-bold shadow-lg shadow-primary/20 hover:shadow-primary/30 transition-all active:scale-95 ${setupStep === 'password' ? 'w-full' : ''}`}
|
||||
disabled={
|
||||
setupStep === 'password'
|
||||
? !isPayKeyValid
|
||||
: isSubmitting || !isConfirmValid
|
||||
}
|
||||
>
|
||||
{isSubmitting && <Spinner className="mr-2" />}
|
||||
{setupStep === 'password' ? '继续' : '保存修改'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import * as React from "react"
|
||||
import { TrendingDown, TrendingUp, Users } from "lucide-react"
|
||||
import { CountingNumber } from "@/components/animate-ui/primitives/texts/counting-number"
|
||||
import { useUser } from "@/contexts/user-context"
|
||||
|
||||
/**
|
||||
* 安全转换数字,避免 NaN
|
||||
*/
|
||||
function safeNumber(value: number): number {
|
||||
return typeof value === 'number' && !isNaN(value) ? value : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 所有活动组件
|
||||
* 显示用户的所有活动统计数据
|
||||
*/
|
||||
export function AllActivity() {
|
||||
const { user, loading } = useUser()
|
||||
|
||||
/* 统计数据 */
|
||||
const stats = React.useMemo(() => ({
|
||||
totalReceive: parseFloat(user?.total_receive || '0'),
|
||||
totalPayment: parseFloat(user?.total_payment || '0'),
|
||||
totalCommunity: parseFloat(user?.total_community || '0')
|
||||
}), [user])
|
||||
|
||||
/* 功能卡片配置 */
|
||||
const statCards = React.useMemo(() => [
|
||||
{
|
||||
title: "总收益",
|
||||
value: stats.totalReceive,
|
||||
icon: TrendingUp,
|
||||
color: "text-green-600",
|
||||
bgColor: "bg-green-100 dark:bg-green-900/20"
|
||||
},
|
||||
{
|
||||
title: "总消耗",
|
||||
value: stats.totalPayment,
|
||||
icon: TrendingDown,
|
||||
color: "text-red-600",
|
||||
bgColor: "bg-red-100 dark:bg-red-900/20"
|
||||
},
|
||||
{
|
||||
title: "总划转",
|
||||
value: stats.totalCommunity,
|
||||
icon: Users,
|
||||
color: "text-purple-600",
|
||||
bgColor: "bg-purple-100 dark:bg-purple-900/20"
|
||||
}
|
||||
], [stats])
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="font-semibold">数据概览</h2>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{statCards.map((card) => {
|
||||
const IconComponent = card.icon
|
||||
return (
|
||||
<div key={card.title} className="bg-muted/50 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs font-medium text-muted-foreground">
|
||||
{card.title}
|
||||
</div>
|
||||
<div className={`p-1.5 rounded-full ${ card.bgColor }`}>
|
||||
<IconComponent className={`h-3 w-3 ${ card.color }`} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xl md:text-2xl font-bold mt-2">
|
||||
{loading ? '-' : (
|
||||
<CountingNumber
|
||||
number={safeNumber(card.value)}
|
||||
decimalPlaces={2}
|
||||
initiallyStable={true}
|
||||
inView={true}
|
||||
inViewOnce={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import * as React from "react"
|
||||
|
||||
/**
|
||||
* 社区积分划转组件
|
||||
*
|
||||
* 显示社区积分划转规则
|
||||
*/
|
||||
export function Community() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-muted/50 rounded-lg px-6 py-4">
|
||||
<div className="max-w-2xl">
|
||||
<h2 className="font-semibold mb-3">LINUX DO 社区积分划转规则</h2>
|
||||
<div className="text-muted-foreground space-y-2">
|
||||
<ul className="list-disc list-inside space-y-1 text-sm">
|
||||
<li>社区积分源于用户在社区内的活动,如解决问题,获赞等。</li>
|
||||
<li>社区积分无法在每日划转前进行交易、赠予给其他社区用户。</li>
|
||||
<li>每日划转时间为每日凌晨00:00:00,目前划转无需任何服务手续费。</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import Link from "next/link"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
/**
|
||||
* 积分流转服务组件
|
||||
*
|
||||
* 显示积分流转服务功能和管理入口
|
||||
*/
|
||||
export function Online() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-muted/50 rounded-lg px-6 py-8">
|
||||
<div className="max-w-2xl">
|
||||
<h2 className="text-2xl font-bold mb-4 text-foreground">在线积分流转</h2>
|
||||
<p className="text-muted-foreground mb-6 leading-relaxed">
|
||||
创建和管理您的在线积分流转服务,无需编写代码即可快速开启个人服务。
|
||||
</p>
|
||||
<Link href="/merchant/online-paying">
|
||||
<Button className="bg-primary hover:bg-primary/90 font-medium px-6 rounded-md shadow-sm">
|
||||
前往创建
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import * as React from "react"
|
||||
|
||||
/**
|
||||
* 积分消耗组件
|
||||
*
|
||||
* 显示积分消耗功能和规则
|
||||
*/
|
||||
export function Payment() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-muted/50 rounded-lg px-6 py-4">
|
||||
<div className="max-w-2xl">
|
||||
<h2 className="font-semibold mb-3">积分消耗</h2>
|
||||
<div className="text-muted-foreground space-y-2">
|
||||
<ul className="list-disc list-inside space-y-1 text-sm">
|
||||
<li>安全可靠的认证流程,确保您的账户安全。</li>
|
||||
<li>实时积分消耗状态跟踪,随时了解消耗进展。</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
|
||||
/**
|
||||
* 功能卡片组件
|
||||
*/
|
||||
function FeatureCard({ title, description, linkText, href }: { title: string, description: string, linkText: string, href?: string }) {
|
||||
return (
|
||||
<Card className="bg-background border border-border shadow-none transition-shadow">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-semibold -mb-4">{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardDescription className="text-xs text-muted-foreground leading-relaxed">
|
||||
{description}
|
||||
</CardDescription>
|
||||
{href ? (
|
||||
<Link href={href}>
|
||||
<Button variant="link" className="px-0 h-auto text-xs text-blue-600 font-normal hover:text-blue-700">
|
||||
{linkText}
|
||||
</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<Button variant="link" className="px-0 h-auto text-xs text-muted-foreground font-normal cursor-not-allowed" disabled>
|
||||
{linkText}
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 积分收益组件
|
||||
*
|
||||
* 显示积分收益功能和规则
|
||||
*/
|
||||
export function Receive() {
|
||||
const [isVisible, setIsVisible] = useState(true)
|
||||
const [isHiding, setIsHiding] = useState(false)
|
||||
|
||||
const handleHide = () => {
|
||||
setIsHiding(true)
|
||||
setTimeout(() => setIsVisible(false), 300)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{isVisible && (
|
||||
<div
|
||||
className={`bg-muted/50 rounded-lg px-6 py-8 overflow-hidden transition-all duration-300 ease-out ${isHiding ? 'opacity-0 max-h-0 !py-0 !mb-0' : 'opacity-100 max-h-[500px]'}`}
|
||||
>
|
||||
<div className="max-w-2xl">
|
||||
<h2 className="text-2xl font-bold mb-4 text-foreground">获得积分收益</h2>
|
||||
<p className="text-muted-foreground mb-6 leading-relaxed">
|
||||
通过无代码选项快速开始探索或使用与我们的 API 集成的可自定义积分服务。
|
||||
</p>
|
||||
<Button
|
||||
className="bg-primary hover:bg-primary/90 font-medium px-6 rounded-md shadow-sm"
|
||||
onClick={handleHide}
|
||||
>
|
||||
开始使用
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<FeatureCard
|
||||
title="接入官方 API 接口"
|
||||
description="使用官方 API 接口(兼容易支付),快速接入积分服务,支持异步通知和同步跳转。"
|
||||
linkText="查看文档"
|
||||
href="/docs/api"
|
||||
/>
|
||||
<FeatureCard
|
||||
title="创建在线积分服务"
|
||||
description="通过我们提供的在线积分服务页面,快速创建,无需编写代码即可开始进行积分流转。"
|
||||
linkText="前往集市"
|
||||
href="/merchant"
|
||||
/>
|
||||
<FeatureCard
|
||||
title="面对面服务"
|
||||
description="通过 LINUX DO Credit 提供的面对面服务功能,扩展您的积分收益渠道,处理面对面服务场景。"
|
||||
linkText="功能开发中,敬请期待"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,767 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { useState, useEffect } from "react"
|
||||
import Image from "next/image"
|
||||
import { toast } from "sonner"
|
||||
import { Gift, Copy, Check, ExternalLink, Pencil, X, ImagePlus, History } from "lucide-react"
|
||||
import type { UploadImageResponse } from "@/lib/services"
|
||||
import { getFileUrl } from "@/lib/services/upload/upload.service"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { PasswordDialog } from "@/components/common/general/password-dialog"
|
||||
import { ListData } from "@/components/common/general/list-data"
|
||||
import { ImageCropper } from "@/components/common/redenvelope/image-cropper"
|
||||
import { motion } from "motion/react"
|
||||
import { RedEnvelopeCard } from "@/components/common/redenvelope/red-envelope-card"
|
||||
import { useUser } from "@/contexts/user-context"
|
||||
import services from "@/lib/services"
|
||||
import type { RedEnvelopeType, CreateRedEnvelopeRequest, PublicConfigResponse, RedEnvelope, RedEnvelopeListResponse } from "@/lib/services"
|
||||
|
||||
interface RedEnvelopeCover {
|
||||
coverImageUrl: string | null
|
||||
heterotypicImageUrl: string | null
|
||||
coverUploadId: string | null
|
||||
heterotypicUploadId: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 红包组件
|
||||
*
|
||||
* 提供红包发送功能,包括 Banner 和创建对话框
|
||||
*/
|
||||
export function RedEnvelope({ onSuccess }: { onSuccess?: () => void }) {
|
||||
const { user } = useUser()
|
||||
const [isFormOpen, setIsFormOpen] = useState(false)
|
||||
const [isPasswordOpen, setIsPasswordOpen] = useState(false)
|
||||
const [isResultOpen, setIsResultOpen] = useState(false)
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null)
|
||||
const [isCropperOpen, setIsCropperOpen] = useState(false)
|
||||
const [cropperType, setCropperType] = useState<'cover' | 'heterotypic'>('cover')
|
||||
const [showCustomization, setShowCustomization] = useState(false)
|
||||
const [historyCoverCovers, setHistoryCoverCovers] = useState<UploadImageResponse[]>([])
|
||||
const [historyHeterotypicCovers, setHistoryHeterotypicCovers] = useState<UploadImageResponse[]>([])
|
||||
|
||||
/* 表单状态 */
|
||||
const [type, setType] = useState<RedEnvelopeType>("random")
|
||||
const [totalAmount, setTotalAmount] = useState("")
|
||||
const [totalCount, setTotalCount] = useState("")
|
||||
const [greeting, setGreeting] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
/* 封面状态 */
|
||||
const [cover, setCover] = useState<RedEnvelopeCover>({
|
||||
coverImageUrl: null,
|
||||
heterotypicImageUrl: null,
|
||||
coverUploadId: null,
|
||||
heterotypicUploadId: null
|
||||
})
|
||||
|
||||
/* 结果状态 */
|
||||
const [resultLink, setResultLink] = useState("")
|
||||
|
||||
/* 配置状态 */
|
||||
const [config, setConfig] = useState<PublicConfigResponse | null>(null)
|
||||
|
||||
/* 加载配置 */
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
const result = await services.config.getPublicConfig()
|
||||
setConfig(result)
|
||||
} catch {
|
||||
toast.error('加载配置失败')
|
||||
}
|
||||
}
|
||||
|
||||
/* 初始加载 */
|
||||
useEffect(() => {
|
||||
loadConfig()
|
||||
}, [])
|
||||
|
||||
/* 验证积分格式 */
|
||||
const validateAmount = (value: string): boolean => {
|
||||
const regex = /^\d+(\.\d{1,2})?$/
|
||||
return regex.test(value) && parseFloat(value) > 0
|
||||
}
|
||||
|
||||
/* 处理表单提交 */
|
||||
const handleFormSubmit = () => {
|
||||
if (!totalAmount.trim()) {
|
||||
toast.error("请设置积分总数")
|
||||
return
|
||||
}
|
||||
|
||||
if (!validateAmount(totalAmount)) {
|
||||
toast.error("积分格式不正确,必须大于1且最多2位小数")
|
||||
return
|
||||
}
|
||||
|
||||
if (!totalCount.trim()) {
|
||||
toast.error("请设置可领取人数")
|
||||
return
|
||||
}
|
||||
|
||||
const count = parseInt(totalCount)
|
||||
if (isNaN(count) || count <= 0) {
|
||||
toast.error("可领取人数必须大于0")
|
||||
return
|
||||
}
|
||||
|
||||
const amount = parseFloat(totalAmount)
|
||||
|
||||
if (amount < 1) {
|
||||
toast.error("红包总金额不能低于1 LDC")
|
||||
return
|
||||
}
|
||||
|
||||
if (config && parseFloat(config.red_envelope_max_amount) > 0) {
|
||||
if (amount > parseFloat(config.red_envelope_max_amount)) {
|
||||
toast.error(`红包的积分总数不能超过 ${ config.red_envelope_max_amount } LDC`)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (count > 10000) {
|
||||
toast.error("可领取人数不能超过 10000 个")
|
||||
return
|
||||
}
|
||||
|
||||
if (amount / count < 0.01) {
|
||||
toast.error("单个红包不可低于 0.01 LDC")
|
||||
return
|
||||
}
|
||||
|
||||
if (greeting.length > 100) {
|
||||
toast.error("祝福语最多100字符")
|
||||
return
|
||||
}
|
||||
|
||||
setIsPasswordOpen(true)
|
||||
}
|
||||
|
||||
/* 提交创建 */
|
||||
const handleConfirmCreate = async (password: string) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data: CreateRedEnvelopeRequest = {
|
||||
type,
|
||||
total_amount: parseFloat(totalAmount),
|
||||
total_count: parseInt(totalCount),
|
||||
greeting: greeting || "恭喜发财,大吉大利",
|
||||
pay_key: password,
|
||||
cover_upload_id: cover.coverUploadId || undefined,
|
||||
heterotypic_upload_id: cover.heterotypicUploadId || undefined,
|
||||
}
|
||||
|
||||
const result = await services.redEnvelope.create(data)
|
||||
|
||||
const link = getEnvelopeLink(result.id)
|
||||
setResultLink(link)
|
||||
setIsPasswordOpen(false)
|
||||
setIsFormOpen(false)
|
||||
setIsResultOpen(true)
|
||||
onSuccess?.()
|
||||
|
||||
/* 重置表单 */
|
||||
setType("random")
|
||||
setTotalAmount("")
|
||||
setTotalCount("")
|
||||
setGreeting("")
|
||||
setCover({
|
||||
coverImageUrl: null,
|
||||
heterotypicImageUrl: null,
|
||||
coverUploadId: null,
|
||||
heterotypicUploadId: null
|
||||
})
|
||||
setShowCustomization(false)
|
||||
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : '创建失败'
|
||||
toast.error('创建红包失败', { description: errorMessage })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
/* 复制链接 */
|
||||
const handleCopyLink = async (link: string, envelopeId: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(link)
|
||||
setCopiedId(envelopeId)
|
||||
toast.success("链接已复制")
|
||||
setTimeout(() => setCopiedId(null), 2000)
|
||||
} catch {
|
||||
toast.error("复制失败")
|
||||
}
|
||||
}
|
||||
|
||||
/* 计算手续费 */
|
||||
const feeInfo = React.useMemo(() => {
|
||||
if (!totalAmount || !config) return null
|
||||
const amount = parseFloat(totalAmount)
|
||||
if (isNaN(amount)) return null
|
||||
|
||||
const feeRate = parseFloat(config.red_envelope_fee_rate)
|
||||
const fee = (amount * feeRate).toFixed(2)
|
||||
const total = (amount + parseFloat(fee)).toFixed(2)
|
||||
|
||||
return { fee, total, rate: feeRate }
|
||||
}, [totalAmount, config])
|
||||
|
||||
/* 生成红包链接 */
|
||||
const getEnvelopeLink = (id: string) => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return `${ window.location.origin }/redenvelope/${ id }`
|
||||
}
|
||||
return `/redenvelope/${ id }`
|
||||
}
|
||||
|
||||
/* 处理封面裁剪完成 */
|
||||
const handleCropComplete = async (croppedImage: string, coverType: 'cover' | 'heterotypic') => {
|
||||
const uploadingToast = toast.loading(`正在上传${ coverType === 'cover' ? '背景封面' : '装饰图片' }...`)
|
||||
try {
|
||||
const result = await services.upload.uploadBase64Image(
|
||||
croppedImage,
|
||||
coverType,
|
||||
`${ coverType }-${ Date.now() }.png`
|
||||
)
|
||||
|
||||
setCover(prev => ({
|
||||
...prev,
|
||||
[coverType === 'cover' ? 'coverImageUrl' : 'heterotypicImageUrl']: getFileUrl(result.id),
|
||||
[coverType === 'cover' ? 'coverUploadId' : 'heterotypicUploadId']: result.id
|
||||
}))
|
||||
|
||||
toast.dismiss(uploadingToast)
|
||||
toast.success(`${ coverType === 'cover' ? '背景封面' : '装饰图片' }已设置`)
|
||||
} catch (error) {
|
||||
toast.dismiss(uploadingToast)
|
||||
const errorMessage = error instanceof Error ? error.message : '上传失败'
|
||||
toast.error(`上传${ coverType === 'cover' ? '背景封面' : '装饰图片' }失败`, {
|
||||
description: errorMessage
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/* 加载历史封面 */
|
||||
const loadHistoryCovers = async () => {
|
||||
try {
|
||||
const [covers, heterotypics] = await Promise.all([
|
||||
services.redEnvelope.listCovers('cover'),
|
||||
services.redEnvelope.listCovers('heterotypic'),
|
||||
])
|
||||
setHistoryCoverCovers(covers || [])
|
||||
setHistoryHeterotypicCovers(heterotypics || [])
|
||||
} catch {
|
||||
// 静默失败,不影响主功能
|
||||
}
|
||||
}
|
||||
|
||||
/* 选择历史封面 */
|
||||
const handleSelectHistoryCover = (item: UploadImageResponse, type: 'cover' | 'heterotypic') => {
|
||||
// 检查是否已经选中当前项
|
||||
const currentId = type === 'cover' ? cover.coverUploadId : cover.heterotypicUploadId
|
||||
if (currentId === item.id) {
|
||||
// 取消选择
|
||||
setCover(prev => ({
|
||||
...prev,
|
||||
[type === 'cover' ? 'coverImageUrl' : 'heterotypicImageUrl']: null,
|
||||
[type === 'cover' ? 'coverUploadId' : 'heterotypicUploadId']: null
|
||||
}))
|
||||
toast.success(`已取消选择${ type === 'cover' ? '背景封面' : '装饰图片' }`)
|
||||
return
|
||||
}
|
||||
|
||||
setCover(prev => ({
|
||||
...prev,
|
||||
[type === 'cover' ? 'coverImageUrl' : 'heterotypicImageUrl']: getFileUrl(item.id),
|
||||
[type === 'cover' ? 'coverUploadId' : 'heterotypicUploadId']: item.id
|
||||
}))
|
||||
toast.success(`已选择历史${ type === 'cover' ? '背景封面' : '装饰图片' }`)
|
||||
}
|
||||
|
||||
/* 打开裁剪对话框 */
|
||||
const handleOpenCropper = (type: 'cover' | 'heterotypic') => {
|
||||
setCropperType(type)
|
||||
setIsCropperOpen(true)
|
||||
}
|
||||
|
||||
/* 移除封面 */
|
||||
const handleRemoveCover = (type: 'cover' | 'heterotypic') => {
|
||||
setCover(prev => ({
|
||||
...prev,
|
||||
[type === 'cover' ? 'coverImageUrl' : 'heterotypicImageUrl']: null,
|
||||
[type === 'cover' ? 'coverUploadId' : 'heterotypicUploadId']: null
|
||||
}))
|
||||
toast.success(`${ type === 'cover' ? '背景封面' : '装饰图片' }已移除`)
|
||||
}
|
||||
|
||||
/* 处理图片加载错误 */
|
||||
const handleImageError = (id: string, type: 'cover' | 'heterotypic') => {
|
||||
if (type === 'cover') {
|
||||
setHistoryCoverCovers(prev => prev.filter(item => item.id !== id))
|
||||
} else {
|
||||
setHistoryHeterotypicCovers(prev => prev.filter(item => item.id !== id))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-gradient-to-r from-red-500/10 to-orange-500/10 rounded-lg px-6 py-8">
|
||||
<div className="max-w-2xl">
|
||||
<h2 className="text-2xl font-bold mb-4 text-foreground">
|
||||
发红包
|
||||
</h2>
|
||||
<p className="text-muted-foreground mb-6 leading-relaxed">
|
||||
创建积分红包,支持固定金额和拼手气两种模式。
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => setIsFormOpen(true)}
|
||||
className="bg-red-500 hover:bg-red-600 font-medium px-6 rounded-md shadow-sm"
|
||||
>
|
||||
发红包
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={isFormOpen} onOpenChange={setIsFormOpen}>
|
||||
<DialogContent className="sm:max-w-2xl md:max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center justify-between pr-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<Gift className="size-5 text-red-500" />
|
||||
发红包
|
||||
</div>
|
||||
<Button
|
||||
variant={showCustomization ? "default" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setShowCustomization(!showCustomization)}
|
||||
className={`flex items-center gap-1.5 text-xs transition-colors ${ showCustomization ? "bg-red-500 hover:bg-red-600 text-white" : "text-muted-foreground hover:text-foreground" }`}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
<span>自定义封面</span>
|
||||
</Button>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
创建积分红包,支持固定金额和拼手气两种模式{showCustomization && ",可添加个性化封面"}。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid md:grid-cols-5 gap-8 pt-2">
|
||||
<div className="space-y-4 md:col-span-3">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>红包类型 <span className="text-red-500">*</span></Label>
|
||||
<Select value={type} onValueChange={(v) => setType(v as RedEnvelopeType)}>
|
||||
<SelectTrigger className="text-xs w-full shadow-none">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="random" className="text-xs">拼手气红包</SelectItem>
|
||||
<SelectItem value="fixed" className="text-xs">普通红包</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="totalAmount">积分总数 <span className="text-red-500">*</span></Label>
|
||||
<Input
|
||||
id="totalAmount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
max={config && parseFloat(config.red_envelope_max_amount) > 0 ? config.red_envelope_max_amount : undefined}
|
||||
placeholder={config && parseFloat(config.red_envelope_max_amount) > 0 ? `最大 ${ config.red_envelope_max_amount }` : "0.00"}
|
||||
value={totalAmount}
|
||||
onChange={(e) => setTotalAmount(e.target.value)}
|
||||
className="font-mono shadow-none"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="totalCount">可领取人数 <span className="text-red-500">*</span></Label>
|
||||
<Input
|
||||
id="totalCount"
|
||||
type="number"
|
||||
placeholder="最多 10000"
|
||||
value={totalCount}
|
||||
onChange={(e) => setTotalCount(e.target.value)}
|
||||
className="font-mono shadow-none"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="greeting">祝福语</Label>
|
||||
<Textarea
|
||||
id="greeting"
|
||||
placeholder="恭喜发财,大吉大利"
|
||||
value={greeting}
|
||||
onChange={(e) => setGreeting(e.target.value)}
|
||||
maxLength={100}
|
||||
rows={2}
|
||||
className="resize-none shadow-none"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showCustomization && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
className="rounded-lg bg-muted/40 p-4"
|
||||
onAnimationComplete={() => loadHistoryCovers()}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<Label className="text-sm font-medium">红包封面</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => {
|
||||
setShowCustomization(false)
|
||||
setCover({
|
||||
coverImageUrl: null,
|
||||
heterotypicImageUrl: null,
|
||||
coverUploadId: null,
|
||||
heterotypicUploadId: null
|
||||
})
|
||||
}}
|
||||
className="size-6 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[11px] text-muted-foreground">背景封面</Label>
|
||||
{cover.coverImageUrl ? (
|
||||
<div className="relative group rounded-lg overflow-hidden ring-1 ring-border/50 aspect-[2/3] w-full">
|
||||
<Image
|
||||
src={cover.coverImageUrl}
|
||||
alt="背景封面"
|
||||
fill
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-all duration-200 flex items-center justify-center gap-1.5 opacity-0 group-hover:opacity-100">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="size-7 rounded-full bg-white/20 backdrop-blur-sm text-white hover:bg-white/30"
|
||||
onClick={() => handleOpenCropper('cover')}
|
||||
>
|
||||
<Pencil className="size-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="size-7 rounded-full bg-white/20 backdrop-blur-sm text-white hover:bg-white/30"
|
||||
onClick={() => handleRemoveCover('cover')}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleOpenCropper('cover')}
|
||||
className="w-full aspect-[2/3] rounded-lg border border-dashed border-border hover:border-foreground/25 bg-background hover:bg-muted/50 transition-colors flex flex-col items-center justify-center gap-2 cursor-pointer"
|
||||
>
|
||||
<ImagePlus className="size-5 text-muted-foreground/50" />
|
||||
<span className="text-[11px] text-muted-foreground">上传背景</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[11px] text-muted-foreground">装饰图片</Label>
|
||||
{cover.heterotypicImageUrl ? (
|
||||
<div className="relative group rounded-lg overflow-hidden ring-1 ring-border/50 aspect-[2/3] w-full">
|
||||
<Image
|
||||
src={cover.heterotypicImageUrl}
|
||||
alt="装饰图片"
|
||||
fill
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-all duration-200 flex items-center justify-center gap-1.5 opacity-0 group-hover:opacity-100">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="size-7 rounded-full bg-white/20 backdrop-blur-sm text-white hover:bg-white/30"
|
||||
onClick={() => handleOpenCropper('heterotypic')}
|
||||
>
|
||||
<Pencil className="size-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="size-7 rounded-full bg-white/20 backdrop-blur-sm text-white hover:bg-white/30"
|
||||
onClick={() => handleRemoveCover('heterotypic')}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleOpenCropper('heterotypic')}
|
||||
className="w-full aspect-[2/3] rounded-lg border border-dashed border-border hover:border-foreground/25 bg-background hover:bg-muted/50 transition-colors flex flex-col items-center justify-center gap-2 cursor-pointer"
|
||||
>
|
||||
<ImagePlus className="size-5 text-muted-foreground/50" />
|
||||
<span className="text-[11px] text-muted-foreground">上传装饰</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] text-muted-foreground/60 mt-2.5 text-center">推荐 2:3 比例,上传后可裁剪</p>
|
||||
|
||||
{(historyCoverCovers.length > 0 || historyHeterotypicCovers.length > 0) && (
|
||||
<div className="mt-3 pt-3 border-t border-border/50">
|
||||
<div className="flex items-center gap-1.5 mb-2.5">
|
||||
<History className="size-3 text-muted-foreground/60" />
|
||||
<span className="text-[11px] text-muted-foreground/60">历史封面</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
{historyCoverCovers.length > 0 && (
|
||||
<div className="flex gap-2 overflow-x-auto p-1 no-scrollbar">
|
||||
{historyCoverCovers.map((item: UploadImageResponse) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => handleSelectHistoryCover(item, 'cover')}
|
||||
className={`relative shrink-0 size-12 rounded-md overflow-hidden ring-1 transition-all ${ cover.coverUploadId === item.id
|
||||
? 'ring-2 ring-primary scale-105'
|
||||
: 'ring-border/50 hover:ring-foreground/25'
|
||||
}`}
|
||||
>
|
||||
<Image
|
||||
src={getFileUrl(item.id) || ''}
|
||||
alt="背景"
|
||||
fill
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
onError={() => handleImageError(item.id, 'cover')}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{historyHeterotypicCovers.length > 0 && (
|
||||
<div className="flex gap-2 overflow-x-auto p-1 no-scrollbar">
|
||||
{historyHeterotypicCovers.map((item: UploadImageResponse) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => handleSelectHistoryCover(item, 'heterotypic')}
|
||||
className={`relative shrink-0 size-12 rounded-md overflow-hidden ring-1 transition-all ${ cover.heterotypicUploadId === item.id
|
||||
? 'ring-2 ring-primary scale-105'
|
||||
: 'ring-border/50 hover:ring-foreground/25'
|
||||
}`}
|
||||
>
|
||||
<Image
|
||||
src={getFileUrl(item.id) || ''}
|
||||
alt="装饰"
|
||||
fill
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
onError={() => handleImageError(item.id, 'heterotypic')}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{feeInfo && parseFloat(feeInfo.fee) > 0 && (
|
||||
<div className="text-xs space-y-1.5 p-3 rounded-lg bg-muted/50">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">积分</span>
|
||||
<span className="font-mono text-muted-foreground">{totalAmount} LDC</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground">
|
||||
费率 ({(feeInfo.rate * 100).toFixed(2)}%)
|
||||
</span>
|
||||
<span className="font-mono text-muted-foreground">+{feeInfo.fee} LDC</span>
|
||||
</div>
|
||||
<div className="border-t border-border/50 my-0.5" />
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-foreground font-medium">总计</span>
|
||||
<span className="font-mono font-semibold text-red-500">{feeInfo.total} LDC</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2 flex items-center justify-center md:justify-end md:pr-8 my-6 md:my-0">
|
||||
<div className="relative w-52 h-80">
|
||||
{cover.heterotypicImageUrl && (
|
||||
<motion.div
|
||||
initial={{ scale: 0.95, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||
className="absolute inset-0 z-0 pointer-events-none flex items-center justify-center"
|
||||
>
|
||||
<Image
|
||||
src={cover.heterotypicImageUrl}
|
||||
alt="装饰"
|
||||
fill
|
||||
className="object-cover"
|
||||
style={{
|
||||
transform: 'scale(1.2)',
|
||||
transformOrigin: 'center center'
|
||||
}}
|
||||
unoptimized
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* 缩放容器:将 320px 宽的卡片等比缩放以适应 208px (w-52) 的容器 */}
|
||||
<div className="absolute inset-0 overflow-hidden bg-[#F25542] rounded-lg">
|
||||
<div className="absolute top-0 left-0 origin-top-left" style={{ width: 320, height: 533, transform: 'scale(0.65)' }}>
|
||||
<RedEnvelopeCard
|
||||
status="preview"
|
||||
coverImage={cover.coverImageUrl}
|
||||
greeting={greeting || "新年快乐,恭喜发财"}
|
||||
sender={{
|
||||
username: user?.username,
|
||||
avatar_url: user?.avatar_url
|
||||
}}
|
||||
className="rounded-xl w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button variant="ghost" onClick={() => setIsFormOpen(false)} disabled={loading} className="h-8 text-xs">
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleFormSubmit}
|
||||
disabled={!totalAmount || !totalCount || loading || !!(config && config.red_envelope_max_recipients > 0 && parseInt(totalCount) > config.red_envelope_max_recipients)}
|
||||
className="bg-red-500 hover:bg-red-600 h-8 text-xs"
|
||||
>
|
||||
下一步
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<PasswordDialog
|
||||
isOpen={isPasswordOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsPasswordOpen(open)
|
||||
if (!open) setIsFormOpen(true)
|
||||
}}
|
||||
onConfirm={handleConfirmCreate}
|
||||
loading={loading}
|
||||
title="密码验证"
|
||||
description={
|
||||
feeInfo && parseFloat(feeInfo.fee) > 0
|
||||
? `正在创建 ${ totalAmount } LDC 的积分红包,可领取人数 ${ totalCount } 个,服务费率 ${ feeInfo.fee } LDC,总计需要 ${ feeInfo.total } LDC`
|
||||
: `正在创建 ${ totalAmount } LDC 的积分红包,可领取人数 ${ totalCount } 个`
|
||||
}
|
||||
/>
|
||||
|
||||
<Dialog open={isResultOpen} onOpenChange={setIsResultOpen}>
|
||||
<DialogContent className="w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-green-600">
|
||||
创建成功
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
复制此链接分享给好友领取红包
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-2">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={resultLink}
|
||||
readOnly
|
||||
className="text-xs font-mono shadow-none border-none bg-muted"
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => handleCopyLink(resultLink, "result")}
|
||||
>
|
||||
{copiedId === "result" ? <Check className="size-3" /> : <Copy className="size-3" />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => window.open(resultLink, "_blank")}
|
||||
>
|
||||
<ExternalLink className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => setIsResultOpen(false)} className="h-8 text-xs">
|
||||
完成
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ImageCropper
|
||||
isOpen={isCropperOpen}
|
||||
onOpenChange={setIsCropperOpen}
|
||||
onCropComplete={handleCropComplete}
|
||||
coverType={cropperType}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 红包列表组件
|
||||
*
|
||||
* 使用通用 ListData 组件展示红包记录
|
||||
*/
|
||||
export function RedEnvelopeList({ refreshTrigger }: { refreshTrigger?: number }) {
|
||||
const fetchEnvelopes = async (params: { page: number, page_size: number, type: string }) => {
|
||||
const result: RedEnvelopeListResponse = await services.redEnvelope.getList({
|
||||
page: params.page,
|
||||
page_size: params.page_size,
|
||||
type: params.type as 'sent' | 'received',
|
||||
})
|
||||
return { list: result.red_envelopes, total: result.total }
|
||||
}
|
||||
|
||||
return (
|
||||
<ListData
|
||||
fetchData={fetchEnvelopes}
|
||||
tabs={[
|
||||
{ value: 'sent', label: '我发出的', color: 'bg-primary/10 text-primary' },
|
||||
{ value: 'received', label: '我收到的', color: 'bg-primary/10 text-primary' }
|
||||
]}
|
||||
defaultTab="received"
|
||||
refreshTrigger={refreshTrigger}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { AllActivity } from "@/components/common/trade/all-activity"
|
||||
import { Community } from "@/components/common/trade/community"
|
||||
import { Payment } from "@/components/common/trade/payment"
|
||||
import { Receive } from "@/components/common/trade/receive"
|
||||
import { TradeTable } from "@/components/common/trade/trade-table"
|
||||
import { Online } from "@/components/common/trade/online"
|
||||
import { RedEnvelope, RedEnvelopeList } from "@/components/common/trade/red-envelope"
|
||||
import { TransactionProvider } from "@/contexts/transaction-context"
|
||||
import services from "@/lib/services"
|
||||
|
||||
/** 标签触发器样式 */
|
||||
const TAB_TRIGGER_STYLES =
|
||||
"data-[state=active]:bg-transparent " +
|
||||
"data-[state=active]:shadow-none " +
|
||||
"data-[state=active]:border-0 " +
|
||||
"data-[state=active]:border-b-2 " +
|
||||
"data-[state=active]:border-indigo-500 " +
|
||||
"bg-transparent " +
|
||||
"rounded-none " +
|
||||
"border-0 " +
|
||||
"border-b-2 " +
|
||||
"border-transparent " +
|
||||
"px-0 " +
|
||||
"text-sm " +
|
||||
"font-bold " +
|
||||
"text-muted-foreground " +
|
||||
"data-[state=active]:text-indigo-500 " +
|
||||
"-mb-[2px] " +
|
||||
"relative " +
|
||||
"hover:text-foreground " +
|
||||
"transition-colors " +
|
||||
"flex-none"
|
||||
|
||||
/** 标签值类型 */
|
||||
type TabValue = 'receive' | 'payment' | 'community' | 'online' | 'all' | 'redenvelope'
|
||||
|
||||
/** 页面组件映射表 */
|
||||
const PAGE_COMPONENTS: Record<TabValue, React.ComponentType> = {
|
||||
receive: Receive,
|
||||
payment: Payment,
|
||||
community: Community,
|
||||
online: Online,
|
||||
redenvelope: RedEnvelope,
|
||||
all: AllActivity,
|
||||
}
|
||||
|
||||
/**
|
||||
* 活动交易主页面组件
|
||||
*
|
||||
* 负责组装活动交易中心的各个子组件
|
||||
*/
|
||||
export function TradeMain() {
|
||||
const [activeTab, setActiveTab] = React.useState<TabValue>('receive')
|
||||
const [mounted, setMounted] = React.useState(false)
|
||||
const [refreshKey, setRefreshKey] = React.useState(0)
|
||||
|
||||
React.useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
const [redEnvelopeEnabled, setRedEnvelopeEnabled] = React.useState(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
/* 获取红包状态 */
|
||||
services.config.getPublicConfig()
|
||||
.then(res => setRedEnvelopeEnabled(res.red_envelope_enabled))
|
||||
.catch(() => setRedEnvelopeEnabled(false))
|
||||
}, [])
|
||||
|
||||
/* 获取活动类型 */
|
||||
const getOrderType = (tab: TabValue) => {
|
||||
if (tab === 'all' || tab === 'redenvelope') return undefined
|
||||
return tab
|
||||
}
|
||||
|
||||
/* 渲染活动页面内容 */
|
||||
const renderPageContent = () => {
|
||||
if (activeTab === 'redenvelope') {
|
||||
return <RedEnvelope onSuccess={() => setRefreshKey(prev => prev + 1)} />
|
||||
}
|
||||
const Component = PAGE_COMPONENTS[activeTab]
|
||||
return Component ? <Component /> : null
|
||||
}
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<div className="py-6">
|
||||
<h1 className="text-2xl font-semibold mb-4">积分活动</h1>
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-10 bg-muted rounded" />
|
||||
<div className="h-64 bg-muted rounded" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<TransactionProvider defaultParams={{ page_size: 20 }}>
|
||||
<div className="py-6">
|
||||
<h1 className="text-2xl font-semibold mb-4">积分活动</h1>
|
||||
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(v) => setActiveTab(v as TabValue)}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="flex p-0 gap-4 rounded-none w-full bg-transparent justify-start border-b border-border overflow-x-auto overflow-y-hidden">
|
||||
<TabsTrigger value="receive" className={TAB_TRIGGER_STYLES}>积分收益</TabsTrigger>
|
||||
<TabsTrigger value="payment" className={TAB_TRIGGER_STYLES}>积分消耗</TabsTrigger>
|
||||
<TabsTrigger value="community" className={TAB_TRIGGER_STYLES}>社区划转</TabsTrigger>
|
||||
<TabsTrigger value="online" className={TAB_TRIGGER_STYLES}>在线流转</TabsTrigger>
|
||||
{redEnvelopeEnabled && (
|
||||
<TabsTrigger value="redenvelope" className={TAB_TRIGGER_STYLES}>积分红包</TabsTrigger>
|
||||
)}
|
||||
<TabsTrigger value="all" className={TAB_TRIGGER_STYLES}>所有活动</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="pt-2 space-y-8">
|
||||
{renderPageContent()}
|
||||
|
||||
<div className="space-y-4">
|
||||
<h2 className="font-semibold">活动记录</h2>
|
||||
{activeTab === 'redenvelope' ? (
|
||||
<RedEnvelopeList refreshTrigger={refreshKey} />
|
||||
) : (
|
||||
<TradeTable key={activeTab} type={getOrderType(activeTab)} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
</TransactionProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import * as React from "react"
|
||||
import { TransactionTableList } from "@/components/common/general/table-data"
|
||||
import { TableFilter, mapDisplayStatusToQuery, type DisplayOrderStatus, type SearchValues } from "@/components/common/general/table-filter"
|
||||
import { TransactionProvider, useTransaction } from "@/contexts/transaction-context"
|
||||
import { DEFAULT_ORDER_TYPES, type OrderType, type TransactionQueryParams } from "@/lib/services"
|
||||
import { formatLocalDate } from "@/lib/utils"
|
||||
|
||||
/**
|
||||
* 获取默认日期范围(最近30天)
|
||||
*/
|
||||
function getDefaultDateRange() {
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
const from = new Date(today)
|
||||
from.setDate(from.getDate() - 29)
|
||||
const tomorrow = new Date(today)
|
||||
tomorrow.setDate(tomorrow.getDate() + 1)
|
||||
return { from, to: tomorrow }
|
||||
}
|
||||
|
||||
/**
|
||||
* 活动表格组件
|
||||
*
|
||||
* 支持类型、状态、时间范围筛选的活动记录显示(支持分页)
|
||||
*/
|
||||
export function TradeTable({ type }: { type?: OrderType }) {
|
||||
/* 获取时间范围 */
|
||||
const { from, to: tomorrow } = getDefaultDateRange()
|
||||
const startTime = formatLocalDate(from)
|
||||
const endTime = formatLocalDate(tomorrow)
|
||||
|
||||
return (
|
||||
<TransactionProvider defaultParams={{ page_size: 20, startTime, endTime }}>
|
||||
<TransactionList initialType={type} />
|
||||
</TransactionProvider>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 活动列表组件
|
||||
*
|
||||
* 显示交用户活动记录
|
||||
*/
|
||||
function TransactionList({ initialType }: { initialType?: OrderType }) {
|
||||
const {
|
||||
transactions,
|
||||
total,
|
||||
currentPage,
|
||||
totalPages,
|
||||
pageSize,
|
||||
loading,
|
||||
error,
|
||||
fetchTransactions,
|
||||
goToPage,
|
||||
setPageSize: setPageSizeHandler,
|
||||
clearCacheAndRefresh,
|
||||
} = useTransaction()
|
||||
|
||||
const [selectedTypes, setSelectedTypes] = React.useState<OrderType[]>(initialType ? [initialType] : [])
|
||||
const [selectedStatus, setSelectedStatus] = React.useState<DisplayOrderStatus | null>(null)
|
||||
const [selectedQuickSelection, setSelectedQuickSelection] = React.useState<string | null>("最近 1 个月")
|
||||
const [dateRange, setDateRange] = React.useState<{ from: Date; to: Date } | null>(getDefaultDateRange)
|
||||
const [selectedSearch, setSelectedSearch] = React.useState<SearchValues>({})
|
||||
|
||||
const buildQueryParams = React.useCallback((page: number): TransactionQueryParams => {
|
||||
const stateQuery = mapDisplayStatusToQuery(selectedStatus)
|
||||
|
||||
return {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
types: selectedTypes.length > 0 ? selectedTypes : DEFAULT_ORDER_TYPES,
|
||||
...stateQuery,
|
||||
startTime: dateRange ? formatLocalDate(dateRange.from) : undefined,
|
||||
endTime: dateRange ? (() => {
|
||||
const endDate = new Date(dateRange.to)
|
||||
endDate.setDate(endDate.getDate() + 1)
|
||||
return formatLocalDate(endDate)
|
||||
})() : undefined,
|
||||
id: selectedSearch.id || undefined,
|
||||
order_name: selectedSearch.order_name || undefined,
|
||||
payer_username: selectedSearch.payer_username || undefined,
|
||||
payee_username: selectedSearch.payee_username || undefined,
|
||||
}
|
||||
}, [dateRange, pageSize, selectedSearch, selectedStatus, selectedTypes])
|
||||
|
||||
/* 清空所有筛选 */
|
||||
const clearAllFilters = () => {
|
||||
setSelectedTypes(initialType ? [initialType] : [])
|
||||
setSelectedStatus(null)
|
||||
setDateRange(null)
|
||||
setSelectedQuickSelection(null)
|
||||
setSelectedSearch({})
|
||||
}
|
||||
|
||||
/* 当筛选条件改变时,重新加载数据 */
|
||||
React.useEffect(() => {
|
||||
fetchTransactions(buildQueryParams(1))
|
||||
}, [buildQueryParams, fetchTransactions])
|
||||
|
||||
/* 当initialType改变时,更新筛选状态 */
|
||||
React.useEffect(() => {
|
||||
if (initialType) {
|
||||
setSelectedTypes([initialType])
|
||||
} else {
|
||||
setSelectedTypes([])
|
||||
}
|
||||
}, [initialType])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col space-y-4">
|
||||
<TableFilter
|
||||
enabledFilters={{
|
||||
type: initialType === undefined,
|
||||
status: true,
|
||||
timeRange: true,
|
||||
search: true
|
||||
}}
|
||||
selectedTypes={selectedTypes}
|
||||
selectedStatus={selectedStatus}
|
||||
selectedTimeRange={dateRange}
|
||||
selectedQuickSelection={selectedQuickSelection}
|
||||
onTypeChange={setSelectedTypes}
|
||||
onStatusChange={setSelectedStatus}
|
||||
onTimeRangeChange={setDateRange}
|
||||
onQuickSelectionChange={setSelectedQuickSelection}
|
||||
onSearch={setSelectedSearch}
|
||||
searchValues={selectedSearch}
|
||||
onClearAll={clearAllFilters}
|
||||
enablePagination={true}
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
pageSize={pageSize}
|
||||
total={total}
|
||||
onPageChange={goToPage}
|
||||
onPageSizeChange={setPageSizeHandler}
|
||||
onRefresh={clearCacheAndRefresh}
|
||||
loading={loading}
|
||||
/>
|
||||
|
||||
<TransactionTableList
|
||||
loading={loading}
|
||||
error={error}
|
||||
transactions={transactions}
|
||||
onRetry={() => fetchTransactions(buildQueryParams(1))}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user