feat: remove shop feature (Phase 2)
Delete all shop domain classes, controllers, schema builders, templates, JS/CSS, admin SPA pages, and shop-only payment classes (Shipping, AfterSale, Discount). Remove shop-only DB table definitions (product_variant, after_sale, order_shipping, card_key) from migrator and add their DROP to uninstall.php. Clean up shared files: - Remove aksrt_product CPT + taxonomies from Setup.php - Remove shop rewrite rules/query vars/template redirect from SetupPart2.php - Remove shop widget areas from SetupPart1.php - Remove shop enqueues from ScriptsPart1b.php - Remove sepakerator-aksrt-shop from SetupPart2.php - Remove get_shop_settings facade (delete ForumShop.php, clean DomainGroups.php) - Remove ORDER_PRODUCT_SHOP constant from DomainModel.php - Remove Shop::class fallback branches from UserOrders.php, OrderCenterRest.php - Remove shipping/after-sale branches from OrderRepositoryAdmin.php, PayMenus.php, PayOrders.php - Remove shop product widgets from widgets-register.php - Remove cart page from template-registry.php, routing.php, ScriptsPart2.php - Remove cart UI from sidebar-nav.php, profile-orders.php, sidebar-orders.php - Clean up order-center.js (remove rebuy/cartUrl/receipt/after-sale handlers) - Remove aksrt_get_cart_page_url/product_page/product_edit from transactions.php - Clean up aksrt_product_cat references in Coupon.php, PayCoupons.php Shared payment/order/credits/content-pay stack fully retained.
This commit is contained in:
@@ -1,81 +0,0 @@
|
||||
import { renderFaIcon, schemaPage } from './helpers'
|
||||
|
||||
// Shop SPA = config only. Ops moved to native WP menus (Phase 4).
|
||||
export const shopSection = {
|
||||
key: 'shop',
|
||||
label: '商城&商品',
|
||||
icon: renderFaIcon('fas fa-shopping-cart'),
|
||||
route: '/shop',
|
||||
children: [
|
||||
schemaPage({
|
||||
key: 'shop-basic',
|
||||
name: 'ShopBasic',
|
||||
label: '商城基础',
|
||||
route: '/shop/basic',
|
||||
title: '商城基础',
|
||||
desc: '商城总开关、首页链接、面包屑和 URL 别名设置。',
|
||||
canonicalDomain: 'shop',
|
||||
section: 'basic',
|
||||
}),
|
||||
schemaPage({
|
||||
key: 'shop-list',
|
||||
name: 'ShopList',
|
||||
label: '商品列表',
|
||||
route: '/shop/list',
|
||||
title: '商品列表',
|
||||
desc: '商品列表排序、分页、卡片样式和销量显示配置。',
|
||||
canonicalDomain: 'shop',
|
||||
section: 'list',
|
||||
}),
|
||||
schemaPage({
|
||||
key: 'shop-single',
|
||||
name: 'ShopSingle',
|
||||
label: '商品详情',
|
||||
route: '/shop/single',
|
||||
title: '商品详情',
|
||||
desc: '商品详情页布局、相关推荐和底部内容设置。',
|
||||
canonicalDomain: 'shop',
|
||||
section: 'single',
|
||||
}),
|
||||
schemaPage({
|
||||
key: 'shop-product-defaults',
|
||||
name: 'ShopProductDefaults',
|
||||
label: '商品默认参数',
|
||||
route: '/shop/product-defaults',
|
||||
title: '商品默认参数',
|
||||
desc: '主题级商品参数默认值,作为参数继承链的根。',
|
||||
canonicalDomain: 'shop',
|
||||
section: 'product-defaults',
|
||||
}),
|
||||
schemaPage({
|
||||
key: 'shop-contact',
|
||||
name: 'ShopContact',
|
||||
label: '客服配置',
|
||||
route: '/shop/contact',
|
||||
title: '客服配置',
|
||||
desc: '商品详情页客服按钮和联系方式配置。',
|
||||
canonicalDomain: 'shop',
|
||||
section: 'contact',
|
||||
}),
|
||||
schemaPage({
|
||||
key: 'shop-shipping-config',
|
||||
name: 'ShopShippingConfig',
|
||||
label: '物流配置',
|
||||
route: '/shop/shipping-config',
|
||||
title: '物流配置',
|
||||
desc: '快递查询接口、物流公司列表和收货地址设置。',
|
||||
canonicalDomain: 'shop',
|
||||
section: 'shipping-config',
|
||||
}),
|
||||
schemaPage({
|
||||
key: 'shop-timing',
|
||||
name: 'ShopTiming',
|
||||
label: '时效与售后原因',
|
||||
route: '/shop/timing',
|
||||
title: '时效与售后原因',
|
||||
desc: '确认收货、评价、退货时效和售后原因配置。',
|
||||
canonicalDomain: 'shop',
|
||||
section: 'timing',
|
||||
}),
|
||||
],
|
||||
}
|
||||
@@ -1,304 +0,0 @@
|
||||
<template>
|
||||
<div class="aksrt-page-container">
|
||||
<PageHeader title="售后工作台" desc="把退款、换货、维修放在订单语境里处理,减少运营跳转。" />
|
||||
|
||||
<div class="aksrt-after-sale__stats">
|
||||
<div v-for="item in statCards" :key="item.key" class="aksrt-after-sale__stat-card">
|
||||
<div class="aksrt-after-sale__stat-icon" :style="{ background: item.color }">
|
||||
<i :class="item.icon"></i>
|
||||
</div>
|
||||
<div class="aksrt-after-sale__stat-info">
|
||||
<div class="aksrt-after-sale__stat-value">{{ stats[item.key] || 0 }}</div>
|
||||
<div class="aksrt-after-sale__stat-label">{{ item.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<n-card size="small" class="aksrt-after-sale__section">
|
||||
<n-space wrap>
|
||||
<n-select v-model:value="filters.status" :options="statusOptions" clearable placeholder="售后状态" style="width: 160px" />
|
||||
<n-select v-model:value="filters.stage" :options="stageOptions" clearable placeholder="处理阶段" style="width: 160px" />
|
||||
<n-select v-model:value="filters.type" :options="typeOptions" clearable placeholder="售后类型" style="width: 160px" />
|
||||
<n-input v-model:value="filters.order_number" clearable placeholder="订单号" style="width: 180px" @keyup.enter="loadData(1)" />
|
||||
<n-input v-model:value="filters.date_from" type="date" style="width: 160px" />
|
||||
<n-input v-model:value="filters.date_to" type="date" style="width: 160px" />
|
||||
<n-button type="primary" @click="loadData(1)">查询</n-button>
|
||||
<n-button @click="resetFilters">重置</n-button>
|
||||
</n-space>
|
||||
|
||||
<div class="aksrt-after-sale__toolbar">
|
||||
<n-space>
|
||||
<n-button type="primary" @click="showCreateModal = true">新建售后</n-button>
|
||||
</n-space>
|
||||
</div>
|
||||
</n-card>
|
||||
|
||||
<n-card size="small" class="aksrt-after-sale__section">
|
||||
<n-data-table
|
||||
:columns="columns"
|
||||
:data="items"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
@update:page="handlePageChange"
|
||||
/>
|
||||
</n-card>
|
||||
|
||||
<AfterSaleDetailDrawer
|
||||
v-model:show="detailVisible"
|
||||
:after-sale-id="detailId"
|
||||
:status-options="detailStatusOptions"
|
||||
@updated="loadData(page)"
|
||||
/>
|
||||
|
||||
<div v-if="showCreateModal" class="aksrt-modal-overlay" @click.self="showCreateModal = false">
|
||||
<div class="aksrt-modal aksrt-after-sale__modal">
|
||||
<h3>新建售后工单</h3>
|
||||
<div class="aksrt-form-group">
|
||||
<label>订单号</label>
|
||||
<input v-model="createForm.order_number" type="text" class="aksrt-input" placeholder="输入订单号">
|
||||
</div>
|
||||
<div class="aksrt-form-group">
|
||||
<label>售后类型</label>
|
||||
<select v-model="createForm.type" class="aksrt-select">
|
||||
<option v-for="item in typeOptions.filter((item) => item.value)" :key="item.value" :value="item.value">{{ item.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="aksrt-form-group">
|
||||
<label>金额</label>
|
||||
<input v-model.number="createForm.amount" type="number" step="0.01" class="aksrt-input" placeholder="0.00">
|
||||
</div>
|
||||
<div class="aksrt-form-group">
|
||||
<label>原因</label>
|
||||
<textarea v-model="createForm.reason" class="aksrt-textarea" rows="3" placeholder="售后原因"></textarea>
|
||||
</div>
|
||||
<div class="aksrt-form-group">
|
||||
<label>补充备注</label>
|
||||
<textarea v-model="createForm.note" class="aksrt-textarea" rows="3" placeholder="补充说明"></textarea>
|
||||
</div>
|
||||
<div class="aksrt-modal__actions">
|
||||
<button class="aksrt-button" @click="showCreateModal = false">取消</button>
|
||||
<button class="aksrt-button aksrt-button--primary" :disabled="creating" @click="submitCreate">
|
||||
{{ creating ? '提交中...' : '提交' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, h, onMounted, reactive, ref } from 'vue'
|
||||
import { NButton, NTag, useMessage } from 'naive-ui'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import api from '@/api/index.js'
|
||||
import AfterSaleDetailDrawer from './components/AfterSaleDetailDrawer.vue'
|
||||
|
||||
const message = useMessage()
|
||||
const loading = ref(false)
|
||||
const page = ref(1)
|
||||
const items = ref([])
|
||||
const stats = ref({})
|
||||
const detailVisible = ref(false)
|
||||
const detailId = ref(null)
|
||||
const statusOptions = ref([])
|
||||
const stageOptions = ref([])
|
||||
const typeOptions = ref([])
|
||||
const showCreateModal = ref(false)
|
||||
const creating = ref(false)
|
||||
|
||||
const filters = reactive({
|
||||
status: null,
|
||||
stage: null,
|
||||
type: null,
|
||||
order_number: '',
|
||||
date_from: '',
|
||||
date_to: '',
|
||||
})
|
||||
|
||||
const createForm = reactive({
|
||||
order_number: '',
|
||||
type: 'refund',
|
||||
amount: 0,
|
||||
reason: '',
|
||||
note: '',
|
||||
})
|
||||
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
itemCount: 0,
|
||||
})
|
||||
|
||||
const statCards = [
|
||||
{ key: 'pending', label: '待处理', icon: 'fas fa-clock', color: '#f59e0b' },
|
||||
{ key: 'processing', label: '处理中', icon: 'fas fa-spinner', color: '#2563eb' },
|
||||
{ key: 'approved', label: '已通过', icon: 'fas fa-check', color: '#16a34a' },
|
||||
{ key: 'rejected', label: '已拒绝', icon: 'fas fa-times', color: '#dc2626' },
|
||||
{ key: 'total', label: '总数', icon: 'fas fa-layer-group', color: '#7c3aed' },
|
||||
]
|
||||
|
||||
const detailStatusOptions = computed(() => statusOptions.value.filter((item) => item.value))
|
||||
|
||||
const columns = [
|
||||
{ title: '订单号', key: 'order_number', width: 170, render: (row) => h('code', row.order_number) },
|
||||
{ title: '售后类型', key: 'type_label', width: 110 },
|
||||
{ title: '阶段', key: 'stage', width: 120, render: (row) => row.stage?.label || '-' },
|
||||
{ title: '物流联动', key: 'shipping_link', width: 170, render: (row) => row.order_context?.shipping?.latest?.status_label || row.order_context?.shipping?.latest?.status || '-' },
|
||||
{ title: '申请人', key: 'applicant', width: 120, render: (row) => row.applicant?.display_name || '-' },
|
||||
{ title: '金额', key: 'amount', width: 110, render: (row) => `¥${Number(row.amount || 0).toFixed(2)}` },
|
||||
{ title: '原因', key: 'reason', ellipsis: { tooltip: true } },
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
render: (row) => h(NTag, { size: 'small', type: resolveTagType(row.status) }, () => row.status_label || row.status),
|
||||
},
|
||||
{ title: '创建时间', key: 'created_at', width: 160 },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 120,
|
||||
render: (row) => h(NButton, { size: 'small', quaternary: true, type: 'info', onClick: () => openDetail(row) }, () => '详情'),
|
||||
},
|
||||
]
|
||||
|
||||
function resolveTagType(status) {
|
||||
if (status === 'approved' || status === 'completed') return 'success'
|
||||
if (status === 'processing') return 'info'
|
||||
if (status === 'rejected' || status === 'cancelled') return 'error'
|
||||
return 'warning'
|
||||
}
|
||||
|
||||
async function loadData(nextPage = 1) {
|
||||
loading.value = true
|
||||
page.value = nextPage
|
||||
pagination.page = nextPage
|
||||
|
||||
try {
|
||||
const res = await api.request('/shop/after-sale', {
|
||||
params: {
|
||||
page: nextPage,
|
||||
per_page: pagination.pageSize,
|
||||
status: filters.status || '',
|
||||
stage: filters.stage || '',
|
||||
type: filters.type || '',
|
||||
order_number: filters.order_number || '',
|
||||
date_from: filters.date_from || '',
|
||||
date_to: filters.date_to || '',
|
||||
},
|
||||
})
|
||||
|
||||
const data = res?.data || {}
|
||||
items.value = data.items || []
|
||||
stats.value = data.stats || {}
|
||||
pagination.itemCount = data.total || 0
|
||||
statusOptions.value = [{ label: '全部状态', value: null }, ...(data.options?.status || [])]
|
||||
stageOptions.value = [{ label: '全部阶段', value: null }, ...(data.options?.stage || [])]
|
||||
typeOptions.value = [{ label: '全部类型', value: null }, ...(data.options?.type || [])]
|
||||
} catch (error) {
|
||||
message.error(error?.message || '加载售后数据失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handlePageChange(nextPage) {
|
||||
loadData(nextPage)
|
||||
}
|
||||
|
||||
function openDetail(row) {
|
||||
detailId.value = row.id
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
filters.status = null
|
||||
filters.stage = null
|
||||
filters.type = null
|
||||
filters.order_number = ''
|
||||
filters.date_from = ''
|
||||
filters.date_to = ''
|
||||
loadData(1)
|
||||
}
|
||||
|
||||
async function submitCreate() {
|
||||
if (!createForm.order_number || !createForm.reason) {
|
||||
message.warning('请填写订单号和售后原因')
|
||||
return
|
||||
}
|
||||
|
||||
creating.value = true
|
||||
try {
|
||||
await api.request('/shop/after-sale', {
|
||||
method: 'POST',
|
||||
data: { ...createForm },
|
||||
})
|
||||
message.success('售后工单已创建')
|
||||
showCreateModal.value = false
|
||||
createForm.order_number = ''
|
||||
createForm.type = 'refund'
|
||||
createForm.amount = 0
|
||||
createForm.reason = ''
|
||||
createForm.note = ''
|
||||
loadData(1)
|
||||
} catch (error) {
|
||||
message.error(error?.message || '创建售后工单失败')
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => loadData(1))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.aksrt-after-sale__stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.aksrt-after-sale__stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.aksrt-after-sale__stat-icon {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.aksrt-after-sale__stat-value {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.aksrt-after-sale__stat-label {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.aksrt-after-sale__section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.aksrt-after-sale__toolbar {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.aksrt-after-sale__modal {
|
||||
width: min(520px, calc(100vw - 32px));
|
||||
}
|
||||
</style>
|
||||
@@ -1,215 +0,0 @@
|
||||
<template>
|
||||
<div class="aksrt-page-container">
|
||||
<PageHeader title="优惠券管理" desc="创建和管理优惠券,支持百分比和固定金额两种类型。" />
|
||||
|
||||
<!-- 操作栏 -->
|
||||
<n-card size="small" class="aksrt-shop-coupons__section">
|
||||
<n-space justify="space-between">
|
||||
<n-space>
|
||||
<n-input v-model:value="search" placeholder="搜索优惠码" clearable style="width:200px;" @clear="loadCoupons" @keyup.enter="loadCoupons">
|
||||
<template #prefix><FaIcon icon="fas fa-search" :size="12" /></template>
|
||||
</n-input>
|
||||
<n-select v-model:value="statusFilter" :options="statusOptions" placeholder="状态" clearable style="width:120px;" @update:value="loadCoupons" />
|
||||
</n-space>
|
||||
<n-button type="primary" @click="showCreate = true">
|
||||
<template #icon><FaIcon icon="fas fa-plus" :size="12" /></template>
|
||||
新增优惠券
|
||||
</n-button>
|
||||
</n-space>
|
||||
</n-card>
|
||||
|
||||
<!-- 优惠券列表 -->
|
||||
<n-card size="small" class="aksrt-shop-coupons__section">
|
||||
<n-data-table
|
||||
:columns="columns"
|
||||
:data="coupons"
|
||||
:loading="loading"
|
||||
:bordered="false"
|
||||
size="small"
|
||||
:pagination="pagination"
|
||||
@update:page="handlePageChange"
|
||||
/>
|
||||
</n-card>
|
||||
|
||||
<!-- 创建弹窗 -->
|
||||
<n-modal :show="showCreate" @update:show="showCreate = $event" preset="card" title="新增优惠券" style="width:480px;" closable>
|
||||
<n-form ref="formRef" :model="form" :rules="rules" label-placement="top">
|
||||
<n-form-item label="优惠码" path="code">
|
||||
<n-input v-model:value="form.code" placeholder="如 SAVE20" />
|
||||
</n-form-item>
|
||||
<n-form-item label="类型" path="type">
|
||||
<n-select v-model:value="form.type" :options="typeOptions" />
|
||||
</n-form-item>
|
||||
<n-form-item :label="form.type === 'percent' ? '折扣比例 (%)' : '减免金额 (元)'" path="value">
|
||||
<n-input-number v-model:value="form.value" :min="0" :max="form.type === 'percent' ? 100 : 99999" style="width:100%;" />
|
||||
</n-form-item>
|
||||
<n-form-item label="最低消费 (元)">
|
||||
<n-input-number v-model:value="form.min_amount" :min="0" style="width:100%;" />
|
||||
</n-form-item>
|
||||
<n-form-item label="使用次数上限">
|
||||
<n-input-number v-model:value="form.usage_limit" :min="0" style="width:100%;" />
|
||||
<template #feedback>0 表示不限制</template>
|
||||
</n-form-item>
|
||||
<n-form-item label="过期时间">
|
||||
<n-input v-model:value="form.expires_at" type="datetime-local" />
|
||||
<template #feedback>留空表示永不过期</template>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
|
||||
<template #footer>
|
||||
<n-space justify="end">
|
||||
<n-button @click="showCreate = false">取消</n-button>
|
||||
<n-button type="primary" :loading="saving" @click="handleCreate">创建</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, h } from 'vue'
|
||||
import { NTag, NButton, useMessage, useDialog } from 'naive-ui'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import FaIcon from '@/components/form/FaIcon.vue'
|
||||
import api from '@/api/index.js'
|
||||
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const coupons = ref([])
|
||||
const search = ref('')
|
||||
const statusFilter = ref(null)
|
||||
const showCreate = ref(false)
|
||||
const formRef = ref(null)
|
||||
|
||||
const form = reactive({
|
||||
code: '',
|
||||
type: 'fixed',
|
||||
value: 10,
|
||||
min_amount: 0,
|
||||
usage_limit: 0,
|
||||
expires_at: '',
|
||||
})
|
||||
|
||||
const rules = {
|
||||
code: { required: true, message: '请输入优惠码', trigger: 'blur' },
|
||||
value: { type: 'number', required: true, message: '请输入数值', trigger: 'blur' },
|
||||
}
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '有效', value: 'active' },
|
||||
{ label: '已禁用', value: 'disabled' },
|
||||
]
|
||||
|
||||
const typeOptions = [
|
||||
{ label: '固定金额', value: 'fixed' },
|
||||
{ label: '百分比折扣', value: 'percent' },
|
||||
]
|
||||
|
||||
const pagination = reactive({ page: 1, pageSize: 20, itemCount: 0, showSizePicker: true, pageSizes: [10, 20, 50] })
|
||||
|
||||
const columns = [
|
||||
{ title: '优惠码', key: 'code', width: 140 },
|
||||
{
|
||||
title: '类型',
|
||||
key: 'type',
|
||||
width: 100,
|
||||
render: (row) => h(NTag, { size: 'small', type: row.type === 'percent' ? 'info' : 'success' }, () => row.type === 'percent' ? `${row.value}%` : `¥${row.value}`),
|
||||
},
|
||||
{ title: '最低消费', key: 'min_amount', width: 100, render: (row) => row.min_amount > 0 ? `¥${row.min_amount}` : '—' },
|
||||
{ title: '已用/上限', key: 'usage', width: 100, render: (row) => `${row.used_count}/${row.usage_limit || '∞'}` },
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
width: 80,
|
||||
render: (row) => h(NTag, { size: 'small', type: row.status === 'active' ? 'success' : 'default' }, () => row.status === 'active' ? '有效' : '已禁用'),
|
||||
},
|
||||
{ title: '过期时间', key: 'expires_at', width: 160, render: (row) => row.expires_at || '永不过期' },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 100,
|
||||
render: (row) => {
|
||||
if (row.status !== 'active') return h('span', { style: 'color:#aaa;font-size:12px;' }, '—')
|
||||
return h(NButton, { size: 'tiny', quaternary: true, type: 'error', onClick: () => handleDisable(row) }, () => '禁用')
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
async function loadCoupons() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: String(pagination.page),
|
||||
per_page: String(pagination.pageSize),
|
||||
})
|
||||
if (search.value) params.set('search', search.value)
|
||||
if (statusFilter.value) params.set('status', statusFilter.value)
|
||||
|
||||
const res = await api.request('/shop/coupons?' + params.toString())
|
||||
if (res?.data) {
|
||||
coupons.value = res.data.items || []
|
||||
pagination.itemCount = res.data.total || 0
|
||||
}
|
||||
} catch {
|
||||
// endpoint may not exist yet
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handlePageChange(page) {
|
||||
pagination.page = page
|
||||
loadCoupons()
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
await api.request('/shop/coupons', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ...form }),
|
||||
})
|
||||
message.success('优惠券已创建')
|
||||
showCreate.value = false
|
||||
loadCoupons()
|
||||
} catch {
|
||||
message.error('创建失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleDisable(row) {
|
||||
dialog.warning({
|
||||
title: '确认禁用',
|
||||
content: `确定要禁用优惠码「${row.code}」吗?`,
|
||||
positiveText: '禁用',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
try {
|
||||
await api.request(`/shop/coupons/${row.id}/disable`, { method: 'POST' })
|
||||
message.success('优惠券已禁用')
|
||||
loadCoupons()
|
||||
} catch {
|
||||
message.error('操作失败')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(loadCoupons)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.aksrt-shop-coupons__section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,180 +0,0 @@
|
||||
<template>
|
||||
<div class="aksrt-page-container">
|
||||
<PageHeader title="商城&商品" desc="商品管理、订单系统、库存管理等电商功能。" />
|
||||
|
||||
<div class="aksrt-shop-dashboard">
|
||||
<!-- 统计卡片 -->
|
||||
<div class="aksrt-shop-dashboard__stats">
|
||||
<div class="aksrt-shop-stat-card">
|
||||
<div class="aksrt-shop-stat-card__icon"><i class="fas fa-box"></i></div>
|
||||
<div class="aksrt-shop-stat-card__info">
|
||||
<span class="aksrt-shop-stat-card__value">{{ overview.product_count }}</span>
|
||||
<span class="aksrt-shop-stat-card__label">商品数量</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="aksrt-shop-stat-card">
|
||||
<div class="aksrt-shop-stat-card__icon"><i class="fas fa-file-invoice"></i></div>
|
||||
<div class="aksrt-shop-stat-card__info">
|
||||
<span class="aksrt-shop-stat-card__value">{{ overview.order_count }}</span>
|
||||
<span class="aksrt-shop-stat-card__label">订单数量</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="aksrt-shop-stat-card">
|
||||
<div class="aksrt-shop-stat-card__icon"><i class="fas fa-coins"></i></div>
|
||||
<div class="aksrt-shop-stat-card__info">
|
||||
<span class="aksrt-shop-stat-card__value">{{ revenueDisplay }}</span>
|
||||
<span class="aksrt-shop-stat-card__label">累计收入 (元)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="aksrt-shop-stat-card">
|
||||
<div class="aksrt-shop-stat-card__icon"><i class="fas fa-clock"></i></div>
|
||||
<div class="aksrt-shop-stat-card__info">
|
||||
<span class="aksrt-shop-stat-card__value">{{ overview.order_pending }}</span>
|
||||
<span class="aksrt-shop-stat-card__label">待处理订单</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 最近商品 -->
|
||||
<n-card class="aksrt-section-card" title="最近商品">
|
||||
<n-data-table
|
||||
v-if="products.length"
|
||||
:columns="productColumns"
|
||||
:data="products"
|
||||
:bordered="false"
|
||||
size="small"
|
||||
/>
|
||||
<n-empty v-else description="暂无商品" />
|
||||
</n-card>
|
||||
|
||||
<!-- 最近订单 -->
|
||||
<n-card class="aksrt-section-card" title="最近订单" style="margin-top:16px;">
|
||||
<n-data-table
|
||||
v-if="orders.length"
|
||||
:columns="orderColumns"
|
||||
:data="orders"
|
||||
:bordered="false"
|
||||
size="small"
|
||||
/>
|
||||
<n-empty v-else description="暂无订单" />
|
||||
</n-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, h } from 'vue'
|
||||
import { NTag } from 'naive-ui'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import api from '@/api/index.js'
|
||||
|
||||
const overview = ref({ product_count: 0, order_count: 0, order_pending: 0, revenue_total: 0 })
|
||||
const products = ref([])
|
||||
const orders = ref([])
|
||||
|
||||
const revenueDisplay = computed(() => (overview.value.revenue_total || 0).toFixed(2))
|
||||
|
||||
const statusMap = {
|
||||
created: { label: '已创建', type: 'default' },
|
||||
pending: { label: '待支付', type: 'warning' },
|
||||
paid: { label: '已支付', type: 'success' },
|
||||
delivered: { label: '已发货', type: 'info' },
|
||||
completed: { label: '已完成', type: 'success' },
|
||||
cancelled: { label: '已取消', type: 'default' },
|
||||
expired: { label: '已过期', type: 'default' },
|
||||
refunding: { label: '退款中', type: 'warning' },
|
||||
refunded: { label: '已退款', type: 'error' },
|
||||
}
|
||||
|
||||
const productColumns = [
|
||||
{ title: '商品名称', key: 'title', ellipsis: { tooltip: true } },
|
||||
{ title: '价格', key: 'price', width: 100, render: (row) => '¥' + (row.price || 0).toFixed(2) },
|
||||
{ title: '库存', key: 'stock', width: 80 },
|
||||
{ title: '销量', key: 'sales', width: 80 },
|
||||
{ title: '创建时间', key: 'created', width: 160 },
|
||||
]
|
||||
|
||||
const orderColumns = [
|
||||
{ title: '订单号', key: 'order_number', width: 180, ellipsis: { tooltip: true } },
|
||||
{ title: '产品类型', key: 'product_type', width: 100 },
|
||||
{ title: '金额', key: 'amount', width: 100, render: (row) => '¥' + (row.amount || 0).toFixed(2) },
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
render: (row) => {
|
||||
const s = statusMap[row.status] || { label: row.status, type: 'default' }
|
||||
return h(NTag, { size: 'small', type: s.type }, () => s.label)
|
||||
},
|
||||
},
|
||||
{ title: '创建时间', key: 'created_at', width: 160 },
|
||||
]
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [overviewRes, productsRes, ordersRes] = await Promise.all([
|
||||
api.request('/shop/overview'),
|
||||
api.request('/shop/products?per_page=5'),
|
||||
api.request('/shop/orders?per_page=5'),
|
||||
])
|
||||
if (overviewRes?.success && overviewRes.data) {
|
||||
overview.value = { ...overview.value, ...overviewRes.data }
|
||||
}
|
||||
if (productsRes?.success && productsRes.data) {
|
||||
products.value = productsRes.data.items || []
|
||||
}
|
||||
if (ordersRes?.success && ordersRes.data) {
|
||||
orders.value = ordersRes.data.items || []
|
||||
}
|
||||
} catch {
|
||||
// endpoints may not be available yet
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.aksrt-shop-dashboard__stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.aksrt-shop-stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e8e8e8;
|
||||
}
|
||||
|
||||
.aksrt-shop-stat-card__icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
background: #f0f5ff;
|
||||
color: #1890ff;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.aksrt-shop-stat-card__info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.aksrt-shop-stat-card__value {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.aksrt-shop-stat-card__label {
|
||||
font-size: 13px;
|
||||
color: #7a7f8a;
|
||||
}
|
||||
</style>
|
||||
@@ -1,173 +0,0 @@
|
||||
<template>
|
||||
<div class="aksrt-page-container">
|
||||
<PageHeader title="订单管理" desc="查看和管理所有交易订单。" />
|
||||
|
||||
<!-- 筛选栏 -->
|
||||
<n-card size="small" class="aksrt-shop__section">
|
||||
<n-space>
|
||||
<n-input v-model:value="search" placeholder="搜索订单号" clearable style="width: 200px;" @clear="loadOrders" @keyup.enter="loadOrders">
|
||||
<template #prefix><FaIcon icon="fas fa-search" :size="12" /></template>
|
||||
</n-input>
|
||||
<n-select v-model:value="statusFilter" :options="statusOptions" placeholder="订单状态" clearable style="width: 140px;" @update:value="loadOrders" />
|
||||
</n-space>
|
||||
</n-card>
|
||||
|
||||
<!-- 订单列表 -->
|
||||
<n-card size="small" class="aksrt-shop__section">
|
||||
<n-data-table
|
||||
:columns="columns"
|
||||
:data="orders"
|
||||
:loading="loading"
|
||||
:bordered="false"
|
||||
size="small"
|
||||
:pagination="pagination"
|
||||
@update:page="handlePageChange"
|
||||
/>
|
||||
</n-card>
|
||||
|
||||
<!-- 订单详情抽屉 -->
|
||||
<OrderDetailDrawer
|
||||
v-model:show="detailVisible"
|
||||
:order-number="detailOrderNumber"
|
||||
@updated="loadOrders"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, h } from 'vue'
|
||||
import { NTag, NButton } from 'naive-ui'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import FaIcon from '@/components/form/FaIcon.vue'
|
||||
import OrderDetailDrawer from './components/OrderDetailDrawer.vue'
|
||||
import api from '@/api/index.js'
|
||||
|
||||
const loading = ref(false)
|
||||
const orders = ref([])
|
||||
const search = ref('')
|
||||
const statusFilter = ref(null)
|
||||
|
||||
const detailVisible = ref(false)
|
||||
const detailOrderNumber = ref('')
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '待支付', value: 'pending' },
|
||||
{ label: '已支付', value: 'paid' },
|
||||
{ label: '已完成', value: 'completed' },
|
||||
{ label: '已取消', value: 'cancelled' },
|
||||
{ label: '已退款', value: 'refunded' },
|
||||
]
|
||||
|
||||
const statusMap = {
|
||||
created: { label: '已创建', type: 'default' },
|
||||
pending: { label: '待支付', type: 'warning' },
|
||||
paid: { label: '已支付', type: 'success' },
|
||||
delivered: { label: '已发货', type: 'info' },
|
||||
completed: { label: '已完成', type: 'success' },
|
||||
cancelled: { label: '已取消', type: 'default' },
|
||||
expired: { label: '已过期', type: 'default' },
|
||||
refunding: { label: '退款中', type: 'warning' },
|
||||
refunded: { label: '已退款', type: 'error' },
|
||||
}
|
||||
|
||||
const pagination = reactive({ page: 1, pageSize: 20, itemCount: 0, showSizePicker: true, pageSizes: [10, 20, 50] })
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '订单号',
|
||||
key: 'order_number',
|
||||
width: 180,
|
||||
ellipsis: { tooltip: true },
|
||||
render: (row) => h('a', {
|
||||
href: 'javascript:;',
|
||||
style: 'color:#1890ff;cursor:pointer;',
|
||||
onClick: () => openDetail(row),
|
||||
}, row.order_number),
|
||||
},
|
||||
{ title: '商品', key: 'product_name', ellipsis: { tooltip: true } },
|
||||
{ title: '金额', key: 'amount', width: 100, render: (row) => '¥' + (row.amount || 0).toFixed(2) },
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
render: (row) => {
|
||||
const s = statusMap[row.status] || { label: row.status, type: 'default' }
|
||||
return h(NTag, { size: 'small', type: s.type }, () => s.label)
|
||||
},
|
||||
},
|
||||
{ title: '买家', key: 'buyer_name', width: 120 },
|
||||
{ title: '创建时间', key: 'created_at', width: 160 },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 160,
|
||||
render: (row) => {
|
||||
const btns = []
|
||||
btns.push(h(NButton, { size: 'tiny', quaternary: true, type: 'info', onClick: () => openDetail(row) }, () => '详情'))
|
||||
if (row.status === 'pending') {
|
||||
btns.push(h(NButton, { size: 'tiny', quaternary: true, type: 'error', onClick: () => handleCancel(row) }, () => '取消'))
|
||||
}
|
||||
if (row.status === 'paid') {
|
||||
btns.push(h(NButton, { size: 'tiny', quaternary: true, type: 'info', onClick: () => handleDeliver(row) }, () => '发货'))
|
||||
}
|
||||
return h('div', { style: 'display:flex;gap:4px;' }, btns)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
async function loadOrders() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
per_page: String(pagination.pageSize),
|
||||
page: String(pagination.page),
|
||||
})
|
||||
if (search.value) params.set('search', search.value)
|
||||
if (statusFilter.value) params.set('status', statusFilter.value)
|
||||
|
||||
const res = await api.request('/shop/orders?' + params.toString())
|
||||
if (res?.data) {
|
||||
orders.value = res.data.items || []
|
||||
pagination.itemCount = res.data.total || 0
|
||||
}
|
||||
} catch {
|
||||
// Endpoint may not exist yet
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handlePageChange(page) {
|
||||
pagination.page = page
|
||||
loadOrders()
|
||||
}
|
||||
|
||||
function openDetail(row) {
|
||||
detailOrderNumber.value = row.order_number || ''
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
// Canonical: use order_number for status updates
|
||||
async function updateOrderStatus(row, newStatus) {
|
||||
try {
|
||||
await api.request('/shop/orders/update-status', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ order_number: row.order_number, status: newStatus }),
|
||||
})
|
||||
loadOrders()
|
||||
} catch {
|
||||
// silent
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel(row) { updateOrderStatus(row, 'cancelled') }
|
||||
function handleDeliver(row) { updateOrderStatus(row, 'delivered') }
|
||||
|
||||
onMounted(loadOrders)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.aksrt-shop__section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,147 +0,0 @@
|
||||
<template>
|
||||
<div class="aksrt-page-container">
|
||||
<PageHeader title="商品管理" desc="管理所有商品的上架、编辑和库存。" />
|
||||
|
||||
<!-- 操作栏 -->
|
||||
<n-card size="small" class="aksrt-shop__section">
|
||||
<n-space justify="space-between">
|
||||
<n-space>
|
||||
<n-input v-model:value="search" placeholder="搜索商品名称" clearable style="width: 240px;" @clear="loadProducts" @keyup.enter="loadProducts">
|
||||
<template #prefix><FaIcon icon="fas fa-search" :size="12" /></template>
|
||||
</n-input>
|
||||
<n-select v-model:value="statusFilter" :options="statusOptions" placeholder="状态筛选" clearable style="width: 140px;" @update:value="loadProducts" />
|
||||
</n-space>
|
||||
<n-button type="primary" @click="openCreate">
|
||||
<template #icon><FaIcon icon="fas fa-plus" :size="12" /></template>
|
||||
新增商品
|
||||
</n-button>
|
||||
</n-space>
|
||||
</n-card>
|
||||
|
||||
<!-- 商品列表 -->
|
||||
<n-card size="small" class="aksrt-shop__section">
|
||||
<n-data-table
|
||||
:columns="columns"
|
||||
:data="products"
|
||||
:loading="loading"
|
||||
:bordered="false"
|
||||
size="small"
|
||||
:pagination="pagination"
|
||||
@update:page="handlePageChange"
|
||||
/>
|
||||
</n-card>
|
||||
|
||||
<!-- 商品编辑抽屉 -->
|
||||
<ProductEditorDrawer
|
||||
v-model:show="drawerVisible"
|
||||
:product-id="editingProductId"
|
||||
@saved="loadProducts"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, h } from 'vue'
|
||||
import { NTag, NSpace, NButton } from 'naive-ui'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import FaIcon from '@/components/form/FaIcon.vue'
|
||||
import ProductEditorDrawer from './components/ProductEditorDrawer.vue'
|
||||
import api from '@/api/index.js'
|
||||
|
||||
const loading = ref(false)
|
||||
const products = ref([])
|
||||
const search = ref('')
|
||||
const statusFilter = ref(null)
|
||||
|
||||
const drawerVisible = ref(false)
|
||||
const editingProductId = ref(null)
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '已上架', value: 'publish' },
|
||||
{ label: '已下架', value: 'draft' },
|
||||
{ label: '全部', value: '' },
|
||||
]
|
||||
|
||||
const pagination = reactive({ page: 1, pageSize: 20, itemCount: 0, showSizePicker: true, pageSizes: [10, 20, 50] })
|
||||
|
||||
const columns = [
|
||||
{ title: '商品名称', key: 'title', ellipsis: { tooltip: true } },
|
||||
{ title: '价格', key: 'price', width: 100, render: (row) => '¥' + (row.price || 0).toFixed(2) },
|
||||
{ title: '库存', key: 'stock', width: 80 },
|
||||
{ title: '销量', key: 'sales', width: 80 },
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
width: 80,
|
||||
render: (row) => h(NTag, { size: 'small', type: row.status === 'publish' ? 'success' : 'default' }, () => row.status === 'publish' ? '已上架' : '已下架'),
|
||||
},
|
||||
{ title: '创建时间', key: 'created', width: 160 },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 140,
|
||||
render: (row) => h(NSpace, { size: 'small' }, () => [
|
||||
h(NButton, { size: 'tiny', quaternary: true, type: 'info', onClick: () => openEdit(row) }, () => '编辑'),
|
||||
h(NButton, { size: 'tiny', quaternary: true, type: row.status === 'publish' ? 'warning' : 'success', onClick: () => handleToggle(row) }, () => row.status === 'publish' ? '下架' : '上架'),
|
||||
]),
|
||||
},
|
||||
]
|
||||
|
||||
async function loadProducts() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
per_page: String(pagination.pageSize),
|
||||
page: String(pagination.page),
|
||||
})
|
||||
if (search.value) params.set('search', search.value)
|
||||
if (statusFilter.value) params.set('status', statusFilter.value)
|
||||
|
||||
const res = await api.request('/shop/products?' + params.toString())
|
||||
if (res?.data) {
|
||||
products.value = res.data.items || []
|
||||
pagination.itemCount = res.data.total || 0
|
||||
}
|
||||
} catch {
|
||||
// Endpoint may not exist yet
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handlePageChange(page) {
|
||||
pagination.page = page
|
||||
loadProducts()
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editingProductId.value = null
|
||||
drawerVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(row) {
|
||||
editingProductId.value = row.id
|
||||
drawerVisible.value = true
|
||||
}
|
||||
|
||||
async function handleToggle(row) {
|
||||
const newStatus = row.status === 'publish' ? 'draft' : 'publish'
|
||||
try {
|
||||
await api.request('/shop/products/' + row.id, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ status: newStatus }),
|
||||
})
|
||||
loadProducts()
|
||||
} catch {
|
||||
// silent
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadProducts)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.aksrt-shop__section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,364 +0,0 @@
|
||||
<template>
|
||||
<div class="aksrt-page-container">
|
||||
<PageHeader title="物流工作台" desc="围绕订单上下文处理发货、跟踪、签收和异常退回。" />
|
||||
|
||||
<div class="aksrt-shipping__stats">
|
||||
<div v-for="item in statCards" :key="item.key" class="aksrt-shipping__stat-card">
|
||||
<div class="aksrt-shipping__stat-icon" :style="{ background: item.color }">
|
||||
<i :class="item.icon"></i>
|
||||
</div>
|
||||
<div class="aksrt-shipping__stat-info">
|
||||
<div class="aksrt-shipping__stat-value">{{ stats[item.key] || 0 }}</div>
|
||||
<div class="aksrt-shipping__stat-label">{{ item.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<n-card size="small" class="aksrt-shipping__section">
|
||||
<n-space wrap>
|
||||
<n-select v-model:value="filters.status" :options="statusOptions" clearable placeholder="物流状态" style="width: 150px" />
|
||||
<n-input v-model:value="filters.order_number" clearable placeholder="订单号" style="width: 180px" @keyup.enter="loadData(1)" />
|
||||
<n-input v-model:value="filters.tracking_no" clearable placeholder="物流单号" style="width: 180px" @keyup.enter="loadData(1)" />
|
||||
<n-input v-model:value="filters.date_from" type="date" style="width: 160px" />
|
||||
<n-input v-model:value="filters.date_to" type="date" style="width: 160px" />
|
||||
<n-button type="primary" @click="loadData(1)">查询</n-button>
|
||||
<n-button @click="resetFilters">重置</n-button>
|
||||
</n-space>
|
||||
|
||||
<div class="aksrt-shipping__toolbar">
|
||||
<n-space>
|
||||
<n-button type="primary" @click="showCreateModal = true">新建发货</n-button>
|
||||
<n-button :disabled="selectedRowKeys.length === 0" @click="showBatchDialog = true">批量更新状态</n-button>
|
||||
<n-popover trigger="click" placement="bottom-start">
|
||||
<template #trigger>
|
||||
<n-button>列控制</n-button>
|
||||
</template>
|
||||
<n-checkbox-group v-model:value="visibleColumnKeys">
|
||||
<n-space vertical size="small">
|
||||
<n-checkbox v-for="item in columnOptions" :key="item.key" :value="item.key">
|
||||
{{ item.label }}
|
||||
</n-checkbox>
|
||||
</n-space>
|
||||
</n-checkbox-group>
|
||||
</n-popover>
|
||||
</n-space>
|
||||
</div>
|
||||
</n-card>
|
||||
|
||||
<n-card size="small" class="aksrt-shipping__section">
|
||||
<n-data-table
|
||||
:columns="columns"
|
||||
:data="items"
|
||||
:loading="loading"
|
||||
:row-key="(row) => row.id"
|
||||
:pagination="pagination"
|
||||
@update:page="handlePageChange"
|
||||
@update:checked-row-keys="selectedRowKeys = $event"
|
||||
/>
|
||||
</n-card>
|
||||
|
||||
<ShippingDetailDrawer
|
||||
v-model:show="detailVisible"
|
||||
:shipping-id="detailId"
|
||||
@updated="loadData(page)"
|
||||
/>
|
||||
|
||||
<ShippingBatchActionDialog
|
||||
v-model:show="showBatchDialog"
|
||||
:ids="selectedRowKeys"
|
||||
:status-options="batchStatusOptions"
|
||||
@updated="handleBatchUpdated"
|
||||
/>
|
||||
|
||||
<div v-if="showCreateModal" class="aksrt-modal-overlay" @click.self="showCreateModal = false">
|
||||
<div class="aksrt-modal aksrt-shipping__modal">
|
||||
<h3>新建发货记录</h3>
|
||||
<div class="aksrt-form-group">
|
||||
<label>订单号</label>
|
||||
<input v-model="createForm.order_number" type="text" class="aksrt-input" placeholder="输入订单号">
|
||||
</div>
|
||||
<div class="aksrt-form-group">
|
||||
<label>物流公司</label>
|
||||
<select v-model="createForm.carrier" class="aksrt-select">
|
||||
<option value="">请选择</option>
|
||||
<option v-for="item in carrierOptions" :key="item.value" :value="item.value">{{ item.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="aksrt-form-group">
|
||||
<label>物流单号</label>
|
||||
<input v-model="createForm.tracking_no" type="text" class="aksrt-input" placeholder="输入物流单号">
|
||||
</div>
|
||||
<div class="aksrt-form-group">
|
||||
<label>备注</label>
|
||||
<textarea v-model="createForm.note" class="aksrt-textarea" rows="3" placeholder="发货备注"></textarea>
|
||||
</div>
|
||||
<div class="aksrt-modal__actions">
|
||||
<button class="aksrt-button" @click="showCreateModal = false">取消</button>
|
||||
<button class="aksrt-button aksrt-button--primary" :disabled="creating" @click="submitCreate">
|
||||
{{ creating ? '提交中...' : '提交' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, h, onMounted, reactive, ref } from 'vue'
|
||||
import { NButton, NTag, useMessage } from 'naive-ui'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import api from '@/api/index.js'
|
||||
import ShippingDetailDrawer from './components/ShippingDetailDrawer.vue'
|
||||
import ShippingBatchActionDialog from './components/ShippingBatchActionDialog.vue'
|
||||
|
||||
const message = useMessage()
|
||||
const loading = ref(false)
|
||||
const page = ref(1)
|
||||
const items = ref([])
|
||||
const stats = ref({})
|
||||
const detailVisible = ref(false)
|
||||
const detailId = ref(null)
|
||||
const selectedRowKeys = ref([])
|
||||
const showBatchDialog = ref(false)
|
||||
const showCreateModal = ref(false)
|
||||
const creating = ref(false)
|
||||
const carrierOptions = ref([])
|
||||
const statusOptions = ref([])
|
||||
|
||||
const filters = reactive({
|
||||
status: null,
|
||||
order_number: '',
|
||||
tracking_no: '',
|
||||
date_from: '',
|
||||
date_to: '',
|
||||
})
|
||||
|
||||
const createForm = reactive({
|
||||
order_number: '',
|
||||
carrier: '',
|
||||
tracking_no: '',
|
||||
note: '',
|
||||
})
|
||||
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
itemCount: 0,
|
||||
})
|
||||
|
||||
const statCards = [
|
||||
{ key: 'shipped', label: '已发货', icon: 'fas fa-box', color: '#0f766e' },
|
||||
{ key: 'in_transit', label: '运输中', icon: 'fas fa-truck', color: '#2563eb' },
|
||||
{ key: 'delivered', label: '已签收', icon: 'fas fa-check-circle', color: '#16a34a' },
|
||||
{ key: 'returned', label: '已退回', icon: 'fas fa-undo', color: '#dc2626' },
|
||||
{ key: 'total', label: '总数', icon: 'fas fa-layer-group', color: '#7c3aed' },
|
||||
]
|
||||
|
||||
const batchStatusOptions = computed(() => statusOptions.value.filter((item) => item.value !== 'shipped'))
|
||||
|
||||
const columnOptions = [
|
||||
{ key: 'order_number', label: '订单号' },
|
||||
{ key: 'carrier_label', label: '物流公司' },
|
||||
{ key: 'tracking_no', label: '物流单号' },
|
||||
{ key: 'status_label', label: '状态' },
|
||||
{ key: 'operator', label: '处理人' },
|
||||
{ key: 'address', label: '收货信息' },
|
||||
{ key: 'created_at', label: '创建时间' },
|
||||
]
|
||||
const visibleColumnKeys = ref(columnOptions.map((item) => item.key))
|
||||
|
||||
const baseColumns = [
|
||||
{ title: '订单号', key: 'order_number', width: 170, render: (row) => h('code', row.order_number) },
|
||||
{ title: '物流公司', key: 'carrier_label', width: 120 },
|
||||
{ title: '物流单号', key: 'tracking_no', width: 180, render: (row) => h('code', row.tracking_no) },
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status_label',
|
||||
width: 100,
|
||||
render: (row) => h(NTag, { size: 'small', type: resolveTagType(row.status) }, () => row.status_label || row.status),
|
||||
},
|
||||
{ title: '处理人', key: 'operator', width: 120, render: (row) => row.operator?.display_name || '-' },
|
||||
{ title: '收货信息', key: 'address', ellipsis: { tooltip: true }, render: (row) => row.order_context?.address?.full_text || '-' },
|
||||
{ title: '创建时间', key: 'created_at', width: 160 },
|
||||
]
|
||||
|
||||
const columns = computed(() => {
|
||||
const visible = new Set(visibleColumnKeys.value)
|
||||
const filteredColumns = baseColumns.filter((column) => visible.has(column.key))
|
||||
|
||||
return [
|
||||
{ type: 'selection', width: 40 },
|
||||
...filteredColumns,
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 160,
|
||||
render: (row) => h('div', { class: 'aksrt-shipping__action-buttons' }, [
|
||||
h(NButton, { size: 'small', quaternary: true, type: 'info', onClick: () => openDetail(row) }, () => '详情'),
|
||||
h(NButton, { size: 'small', quaternary: true, onClick: () => copyAddress(row) }, () => '复制地址'),
|
||||
]),
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
function resolveTagType(status) {
|
||||
if (status === 'delivered') return 'success'
|
||||
if (status === 'in_transit') return 'info'
|
||||
if (status === 'returned') return 'error'
|
||||
return 'warning'
|
||||
}
|
||||
|
||||
async function loadData(nextPage = 1) {
|
||||
loading.value = true
|
||||
page.value = nextPage
|
||||
pagination.page = nextPage
|
||||
|
||||
try {
|
||||
const res = await api.request('/shop/shipping', {
|
||||
params: {
|
||||
page: nextPage,
|
||||
per_page: pagination.pageSize,
|
||||
status: filters.status || '',
|
||||
order_number: filters.order_number || '',
|
||||
tracking_no: filters.tracking_no || '',
|
||||
date_from: filters.date_from || '',
|
||||
date_to: filters.date_to || '',
|
||||
},
|
||||
})
|
||||
|
||||
const data = res?.data || {}
|
||||
items.value = data.items || []
|
||||
stats.value = data.stats || {}
|
||||
pagination.itemCount = data.total || 0
|
||||
carrierOptions.value = data.options?.carrier || []
|
||||
statusOptions.value = [{ label: '全部状态', value: null }, ...(data.options?.status || [])]
|
||||
} catch (error) {
|
||||
message.error(error?.message || '加载物流数据失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handlePageChange(nextPage) {
|
||||
loadData(nextPage)
|
||||
}
|
||||
|
||||
function openDetail(row) {
|
||||
detailId.value = row.id
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
function handleBatchUpdated() {
|
||||
selectedRowKeys.value = []
|
||||
loadData(page.value)
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
filters.status = null
|
||||
filters.order_number = ''
|
||||
filters.tracking_no = ''
|
||||
filters.date_from = ''
|
||||
filters.date_to = ''
|
||||
loadData(1)
|
||||
}
|
||||
|
||||
async function copyAddress(row) {
|
||||
const text = row.order_context?.address?.full_text || ''
|
||||
if (!text) {
|
||||
message.warning('当前记录没有地址信息')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
message.success('地址已复制')
|
||||
} catch {
|
||||
message.warning('复制失败,请手动复制')
|
||||
}
|
||||
}
|
||||
|
||||
async function submitCreate() {
|
||||
if (!createForm.order_number || !createForm.carrier || !createForm.tracking_no) {
|
||||
message.warning('请填写订单号、物流公司和物流单号')
|
||||
return
|
||||
}
|
||||
|
||||
creating.value = true
|
||||
try {
|
||||
await api.request('/shop/shipping', {
|
||||
method: 'POST',
|
||||
data: { ...createForm },
|
||||
})
|
||||
message.success('发货记录已创建')
|
||||
showCreateModal.value = false
|
||||
createForm.order_number = ''
|
||||
createForm.carrier = ''
|
||||
createForm.tracking_no = ''
|
||||
createForm.note = ''
|
||||
loadData(1)
|
||||
} catch (error) {
|
||||
message.error(error?.message || '创建发货记录失败')
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => loadData(1))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.aksrt-shipping__stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.aksrt-shipping__stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.aksrt-shipping__stat-icon {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.aksrt-shipping__stat-value {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.aksrt-shipping__stat-label {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.aksrt-shipping__section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.aksrt-shipping__toolbar {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.aksrt-shipping__action-buttons {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.aksrt-shipping__modal {
|
||||
width: min(520px, calc(100vw - 32px));
|
||||
}
|
||||
</style>
|
||||
@@ -1,138 +0,0 @@
|
||||
<template>
|
||||
<div class="aksrt-page-container">
|
||||
<PageHeader title="提现管理" desc="审核和管理创作者提现申请。" />
|
||||
|
||||
<!-- 筛选栏 -->
|
||||
<n-card size="small" class="aksrt-shop__section">
|
||||
<n-space>
|
||||
<n-select v-model:value="statusFilter" :options="statusOptions" placeholder="提现状态" clearable style="width: 140px;" @update:value="loadWithdrawals" />
|
||||
</n-space>
|
||||
</n-card>
|
||||
|
||||
<!-- 提现列表 -->
|
||||
<n-card size="small" class="aksrt-shop__section">
|
||||
<n-data-table
|
||||
:columns="columns"
|
||||
:data="withdrawals"
|
||||
:loading="loading"
|
||||
:bordered="false"
|
||||
size="small"
|
||||
:pagination="pagination"
|
||||
@update:page="handlePageChange"
|
||||
/>
|
||||
</n-card>
|
||||
|
||||
<!-- 审核弹窗 -->
|
||||
<WithdrawalReviewModal
|
||||
v-model:show="reviewVisible"
|
||||
:withdrawal="reviewItem"
|
||||
@updated="loadWithdrawals"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, h } from 'vue'
|
||||
import { NTag, NButton } from 'naive-ui'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import WithdrawalReviewModal from './components/WithdrawalReviewModal.vue'
|
||||
import api from '@/api/index.js'
|
||||
|
||||
const loading = ref(false)
|
||||
const withdrawals = ref([])
|
||||
const statusFilter = ref(null)
|
||||
|
||||
const reviewVisible = ref(false)
|
||||
const reviewItem = ref({})
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '待审核', value: 'pending' },
|
||||
{ label: '已批准', value: 'approved' },
|
||||
{ label: '已拒绝', value: 'rejected' },
|
||||
{ label: '已打款', value: 'completed' },
|
||||
]
|
||||
|
||||
const statusMap = {
|
||||
pending: { label: '待审核', type: 'warning' },
|
||||
approved: { label: '已批准', type: 'info' },
|
||||
processing: { label: '处理中', type: 'info' },
|
||||
completed: { label: '已打款', type: 'success' },
|
||||
rejected: { label: '已拒绝', type: 'error' },
|
||||
}
|
||||
|
||||
// Unified pagination: page + per_page → items + total + page + total_pages
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
itemCount: 0,
|
||||
showSizePicker: true,
|
||||
pageSizes: [10, 20, 50],
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{ title: '申请人', key: 'user_name', width: 120 },
|
||||
{ title: '金额', key: 'amount', width: 120, render: (row) => '¥' + (row.amount || 0).toFixed(2) },
|
||||
{ title: '提现方式', key: 'method', width: 100 },
|
||||
{ title: '收款账号', key: 'account_info', width: 160, ellipsis: { tooltip: true } },
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
render: (row) => {
|
||||
const s = statusMap[row.status] || { label: row.status, type: 'default' }
|
||||
return h(NTag, { size: 'small', type: s.type }, () => s.label)
|
||||
},
|
||||
},
|
||||
{ title: '申请时间', key: 'created_at', width: 160 },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 120,
|
||||
render: (row) => {
|
||||
if (row.status !== 'pending') {
|
||||
return h('span', { style: 'color:#aaa;font-size:12px;' }, '—')
|
||||
}
|
||||
return h(NButton, { size: 'tiny', quaternary: true, type: 'info', onClick: () => openReview(row) }, () => '审核')
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
async function loadWithdrawals() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
per_page: String(pagination.pageSize),
|
||||
page: String(pagination.page),
|
||||
})
|
||||
if (statusFilter.value) params.set('status', statusFilter.value)
|
||||
|
||||
const res = await api.request('/payment/withdrawals?' + params.toString())
|
||||
if (res?.data) {
|
||||
withdrawals.value = res.data.items || []
|
||||
pagination.itemCount = res.data.total || 0
|
||||
}
|
||||
} catch {
|
||||
// Endpoint may not exist yet
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handlePageChange(page) {
|
||||
pagination.page = page
|
||||
loadWithdrawals()
|
||||
}
|
||||
|
||||
function openReview(row) {
|
||||
reviewItem.value = row
|
||||
reviewVisible.value = true
|
||||
}
|
||||
|
||||
onMounted(loadWithdrawals)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.aksrt-shop__section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,283 +0,0 @@
|
||||
<template>
|
||||
<n-drawer :show="show" :width="640" @update:show="$emit('update:show', $event)">
|
||||
<n-drawer-content title="售后详情" closable>
|
||||
<n-spin :show="loading">
|
||||
<!-- 售后状态头 -->
|
||||
<div class="aksrt-aftersale__status-header" :class="'aksrt-aftersale__status-header--' + (detail.record?.status || 'default')">
|
||||
<n-tag :type="statusType" size="large">{{ statusLabel }}</n-tag>
|
||||
<span class="aksrt-aftersale__order-no">订单号:{{ detail.record?.order_number || '-' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 售后信息 -->
|
||||
<n-descriptions bordered :column="2" size="small" label-placement="left" style="margin-top:16px;">
|
||||
<n-descriptions-item label="售后类型">{{ detail.record?.type_label || detail.record?.type || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="申请阶段">
|
||||
<n-tag size="small">{{ detail.record?.stage?.label || '-' }}</n-tag>
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="申请金额">
|
||||
<n-text type="error" strong>¥{{ Number(detail.record?.amount || 0).toFixed(2) }}</n-text>
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="退款金额">
|
||||
<n-text type="error" strong>¥{{ Number(detail.record?.refund_amount || 0).toFixed(2) }}</n-text>
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="申请原因" :span="2">{{ detail.record?.reason || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="申请人">{{ detail.record?.applicant?.display_name || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="申请时间">{{ detail.record?.created_at || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="处理人">{{ detail.record?.operator?.display_name || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="处理时间">{{ detail.record?.processed_at || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item v-if="detail.record?.operator_note" label="处理备注" :span="2">{{ detail.record.operator_note }}</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
|
||||
<!-- 退款渠道 -->
|
||||
<n-card v-if="detail.record?.refund_channel" size="small" title="退款渠道" :bordered="false" style="margin-top:16px;">
|
||||
<n-descriptions :column="2" size="small" label-placement="left">
|
||||
<n-descriptions-item label="退款方式">{{ detail.record.refund_channel.method_label || detail.record.refund_channel.method || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="退款状态">
|
||||
<n-tag :type="detail.record.refund_channel.status === 'completed' ? 'success' : 'warning'" size="small">
|
||||
{{ detail.record.refund_channel.status_label || detail.record.refund_channel.status || '-' }}
|
||||
</n-tag>
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="退款单号">{{ detail.record.refund_channel.refund_id || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="退款时间">{{ detail.record.refund_channel.completed_at || '-' }}</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
</n-card>
|
||||
|
||||
<!-- 退货地址 -->
|
||||
<n-card v-if="detail.record?.return_address" size="small" title="退货地址" :bordered="false" style="margin-top:12px;">
|
||||
<n-descriptions :column="1" size="small" label-placement="left">
|
||||
<n-descriptions-item label="收货人">{{ detail.record.return_address.name || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="电话">{{ detail.record.return_address.phone || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="地址">{{ detail.record.return_address.full_address || '-' }}</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
<n-button size="small" @click="copyReturnAddress" style="margin-top:8px;">
|
||||
<i class="fas fa-copy" style="margin-right:4px;" /> 复制地址
|
||||
</n-button>
|
||||
</n-card>
|
||||
|
||||
<!-- 回寄物流 -->
|
||||
<n-card v-if="detail.record?.return_shipping" size="small" title="回寄物流" :bordered="false" style="margin-top:12px;">
|
||||
<n-descriptions :column="2" size="small" label-placement="left">
|
||||
<n-descriptions-item label="物流公司">{{ detail.record.return_shipping.company || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="物流单号">{{ detail.record.return_shipping.tracking_number || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="回寄时间">{{ detail.record.return_shipping.shipped_at || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="状态">
|
||||
<n-tag size="small" :type="detail.record.return_shipping.status === 'received' ? 'success' : 'info'">
|
||||
{{ detail.record.return_shipping.status_label || detail.record.return_shipping.status || '-' }}
|
||||
</n-tag>
|
||||
</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
<n-timeline v-if="detail.record.return_shipping.tracking_detail?.length" style="margin-top:12px;">
|
||||
<n-timeline-item
|
||||
v-for="(item, i) in detail.record.return_shipping.tracking_detail"
|
||||
:key="i"
|
||||
:title="item.description"
|
||||
:content="item.time"
|
||||
:type="i === 0 ? 'success' : 'default'"
|
||||
/>
|
||||
</n-timeline>
|
||||
</n-card>
|
||||
|
||||
<!-- 售后联动 -->
|
||||
<n-card size="small" title="售后联动" :bordered="false" style="margin-top:12px;">
|
||||
<div class="aksrt-aftersale__context-item">
|
||||
关联物流:
|
||||
<span>
|
||||
{{ detail.record?.order_context?.shipping?.latest?.status_label || detail.record?.order_context?.shipping?.latest?.status || '暂无物流记录' }}
|
||||
<template v-if="detail.record?.order_context?.shipping?.latest?.tracking_no">
|
||||
/ {{ detail.record.order_context.shipping.latest.carrier_label || '-' }}
|
||||
/ {{ detail.record.order_context.shipping.latest.tracking_no }}
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="detail.record?.order_context?.address?.full_text" class="aksrt-aftersale__context-item">
|
||||
回寄参考地址:
|
||||
<span>{{ detail.record.order_context.address.full_text }}</span>
|
||||
</div>
|
||||
</n-card>
|
||||
|
||||
<!-- 状态更新 -->
|
||||
<n-card size="small" title="状态更新" :bordered="false" style="margin-top:16px;">
|
||||
<n-space vertical>
|
||||
<n-input v-model:value="note" type="textarea" :rows="2" placeholder="处理备注" />
|
||||
<n-space>
|
||||
<n-select v-model:value="nextStatus" :options="actionOptions" placeholder="选择状态" style="width:160px;" />
|
||||
<n-button type="primary" :disabled="!nextStatus" :loading="updating" @click="handleStatusUpdate">更新状态</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
</n-card>
|
||||
|
||||
<!-- 操作时间线 -->
|
||||
<div v-if="detail.timeline?.length" class="aksrt-aftersale__timeline">
|
||||
<div class="aksrt-aftersale__section-title">操作时间线</div>
|
||||
<n-timeline size="small">
|
||||
<n-timeline-item
|
||||
v-for="(item, index) in detail.timeline"
|
||||
:key="index"
|
||||
:time="item.time"
|
||||
:title="item.label"
|
||||
:content="item.detail || ''"
|
||||
:type="item.type === 'refund' ? 'warning' : item.type === 'shipping' ? 'info' : 'success'"
|
||||
/>
|
||||
</n-timeline>
|
||||
</div>
|
||||
</n-spin>
|
||||
</n-drawer-content>
|
||||
</n-drawer>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import api from '@/api/index.js'
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
afterSaleId: { type: [Number, String], default: null },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:show', 'updated'])
|
||||
const message = useMessage()
|
||||
const loading = ref(false)
|
||||
const updating = ref(false)
|
||||
const detail = ref({ record: null, timeline: [] })
|
||||
const nextStatus = ref(null)
|
||||
const note = ref('')
|
||||
|
||||
const statusMap = {
|
||||
pending: { label: '待处理', type: 'warning' },
|
||||
processing: { label: '处理中', type: 'info' },
|
||||
approved: { label: '已通过', type: 'success' },
|
||||
rejected: { label: '已拒绝', type: 'error' },
|
||||
completed: { label: '已完成', type: 'success' },
|
||||
cancelled: { label: '已取消', type: 'default' },
|
||||
}
|
||||
|
||||
const statusLabel = computed(() => {
|
||||
const s = detail.value?.record?.status
|
||||
return (statusMap[s] || { label: s || '-' }).label
|
||||
})
|
||||
|
||||
const statusType = computed(() => {
|
||||
const s = detail.value?.record?.status
|
||||
return (statusMap[s] || { type: 'default' }).type
|
||||
})
|
||||
|
||||
const actionOptions = computed(() => {
|
||||
const status = detail.value?.record?.status
|
||||
if (status === 'pending') {
|
||||
return [
|
||||
{ label: '处理中', value: 'processing' },
|
||||
{ label: '已通过', value: 'approved' },
|
||||
{ label: '已拒绝', value: 'rejected' },
|
||||
{ label: '已取消', value: 'cancelled' },
|
||||
]
|
||||
}
|
||||
if (status === 'processing') {
|
||||
return [
|
||||
{ label: '已通过', value: 'approved' },
|
||||
{ label: '已拒绝', value: 'rejected' },
|
||||
{ label: '已取消', value: 'cancelled' },
|
||||
]
|
||||
}
|
||||
if (status === 'approved') {
|
||||
return [
|
||||
{ label: '已完成', value: 'completed' },
|
||||
{ label: '已取消', value: 'cancelled' },
|
||||
]
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
watch(() => props.show, async (visible) => {
|
||||
if (visible && props.afterSaleId) {
|
||||
await loadDetail()
|
||||
} else if (!visible) {
|
||||
detail.value = { record: null, timeline: [] }
|
||||
nextStatus.value = null
|
||||
note.value = ''
|
||||
}
|
||||
})
|
||||
|
||||
async function loadDetail() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await api.request(`/shop/after-sale/${props.afterSaleId}`)
|
||||
detail.value = res?.data || { record: null, timeline: [] }
|
||||
} catch (error) {
|
||||
message.error(error?.message || '加载售后详情失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStatusUpdate() {
|
||||
if (!nextStatus.value) return
|
||||
updating.value = true
|
||||
try {
|
||||
await api.request(`/shop/after-sale/${props.afterSaleId}/update-status`, {
|
||||
method: 'POST',
|
||||
data: { status: nextStatus.value, operator_note: note.value },
|
||||
})
|
||||
message.success('售后状态已更新')
|
||||
nextStatus.value = null
|
||||
note.value = ''
|
||||
await loadDetail()
|
||||
emit('updated')
|
||||
} catch (error) {
|
||||
message.error(error?.message || '状态更新失败')
|
||||
} finally {
|
||||
updating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copyReturnAddress() {
|
||||
const addr = detail.value?.record?.return_address
|
||||
if (!addr) return
|
||||
const text = `${addr.name} ${addr.phone} ${addr.full_address}`
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
message.success('退货地址已复制')
|
||||
} catch {
|
||||
message.warning('复制失败')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.aksrt-aftersale__status-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.aksrt-aftersale__status-header--pending { background: #fff7e6; }
|
||||
.aksrt-aftersale__status-header--processing { background: #e6f7ff; }
|
||||
.aksrt-aftersale__status-header--approved { background: #f6ffed; }
|
||||
.aksrt-aftersale__status-header--rejected { background: #fff1f0; }
|
||||
.aksrt-aftersale__status-header--completed { background: #f6ffed; }
|
||||
.aksrt-aftersale__status-header--cancelled { background: #f5f5f5; }
|
||||
|
||||
.aksrt-aftersale__order-no {
|
||||
font-size: 13px;
|
||||
color: var(--color-ink-3);
|
||||
}
|
||||
|
||||
.aksrt-aftersale__context-item {
|
||||
margin-top: 8px;
|
||||
color: var(--color-ink-2);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.aksrt-aftersale__timeline {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.aksrt-aftersale__section-title {
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
color: var(--color-ink);
|
||||
}
|
||||
</style>
|
||||
@@ -1,107 +0,0 @@
|
||||
<template>
|
||||
<div class="aksrt-order-context">
|
||||
<n-card size="small" title="订单上下文" :bordered="false">
|
||||
<template v-if="context?.exists">
|
||||
<n-descriptions bordered :column="1" size="small" label-placement="left">
|
||||
<n-descriptions-item label="订单号">{{ context.summary?.order_number || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="订单状态">{{ context.summary?.status_label || context.summary?.status || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="买家">
|
||||
{{ context.buyer?.display_name || '-' }}
|
||||
<span v-if="context.buyer?.user_login" class="aksrt-order-context__muted">({{ context.buyer.user_login }})</span>
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item v-if="context.buyer?.user_email" label="联系邮箱">{{ context.buyer.user_email }}</n-descriptions-item>
|
||||
<n-descriptions-item label="支付方式">{{ context.payment_method?.label || context.payment_method?.value || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="配送方式">{{ context.shipping_method?.label || context.shipping_method?.value || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="收货地址">{{ context.address?.full_text || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item v-if="context.coupon?.code || context.coupon?.label" label="优惠信息">
|
||||
{{ context.coupon?.label || context.coupon?.code }}
|
||||
<span v-if="context.coupon?.discount" class="aksrt-order-context__muted">减免 ¥{{ formatAmount(context.coupon.discount) }}</span>
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="金额">
|
||||
¥{{ formatAmount(context.pricing?.amount) }}
|
||||
<span class="aksrt-order-context__muted">
|
||||
小计 ¥{{ formatAmount(context.pricing?.subtotal) }} / 运费 ¥{{ formatAmount(context.pricing?.shipping_fee) }} / 优惠 ¥{{ formatAmount(context.pricing?.discount) }}
|
||||
</span>
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="物流联动">
|
||||
<template v-if="context.shipping?.latest?.status_label || context.shipping?.count">
|
||||
{{ context.shipping?.latest?.status_label || context.shipping?.latest?.status || '-' }}
|
||||
<span v-if="context.shipping?.latest?.tracking_no" class="aksrt-order-context__muted">
|
||||
{{ context.shipping.latest.carrier_label || context.shipping.latest.carrier || '' }} / {{ context.shipping.latest.tracking_no }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>-</template>
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="售后联动">
|
||||
<template v-if="context.after_sale?.latest?.status_label || context.after_sale?.count">
|
||||
{{ context.after_sale?.latest?.status_label || context.after_sale?.latest?.status || '-' }}
|
||||
<span v-if="context.after_sale?.latest?.stage?.label" class="aksrt-order-context__muted">
|
||||
{{ context.after_sale.latest.stage.label }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>-</template>
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item v-if="context.customer_note" label="用户备注">{{ context.customer_note }}</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
|
||||
<div v-if="Array.isArray(context.items) && context.items.length" class="aksrt-order-context__items">
|
||||
<div v-for="(item, index) in context.items" :key="index" class="aksrt-order-context__item">
|
||||
<div class="aksrt-order-context__item-title">{{ item.product_name || `商品 #${item.product_id || index + 1}` }}</div>
|
||||
<div class="aksrt-order-context__item-meta">数量 {{ item.quantity || 1 }} / 小计 ¥{{ formatAmount(item.line_total) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<n-empty v-else description="未找到订单上下文" size="small" />
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
context: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
})
|
||||
|
||||
function formatAmount(value) {
|
||||
return Number(value || 0).toFixed(2)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.aksrt-order-context {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.aksrt-order-context__muted {
|
||||
display: inline-block;
|
||||
margin-left: 8px;
|
||||
color: #8c8c8c;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.aksrt-order-context__items {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.aksrt-order-context__item {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #edf2f7;
|
||||
border-radius: 10px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.aksrt-order-context__item-title {
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.aksrt-order-context__item-meta {
|
||||
margin-top: 4px;
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,479 +0,0 @@
|
||||
<template>
|
||||
<n-drawer :show="show" :width="640" @update:show="$emit('update:show', $event)">
|
||||
<n-drawer-content title="订单详情" closable>
|
||||
<n-spin :show="loading">
|
||||
<!-- 订单状态头 -->
|
||||
<div class="aksrt-order-drawer__status-header" :class="'aksrt-order-drawer__status-header--' + order.status">
|
||||
<n-tag :type="statusType" size="large">{{ statusLabel }}</n-tag>
|
||||
<span class="aksrt-order-drawer__order-no">订单号:{{ order.order_number || '-' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 核心信息 -->
|
||||
<n-descriptions bordered :column="2" label-placement="left" size="small" style="margin-top:16px;">
|
||||
<n-descriptions-item label="商品" :span="2">{{ order.product_name || order.product_type || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="订单金额">
|
||||
<n-text type="error" strong>¥{{ (order.amount || 0).toFixed(2) }}</n-text>
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="支付方式">{{ order.payment_method?.label || order.payment_method || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="小计">¥{{ (order.subtotal || 0).toFixed(2) }}</n-descriptions-item>
|
||||
<n-descriptions-item label="运费">¥{{ (order.shipping_fee || 0).toFixed(2) }}</n-descriptions-item>
|
||||
<n-descriptions-item label="优惠">¥{{ (order.discount || 0).toFixed(2) }}</n-descriptions-item>
|
||||
<n-descriptions-item v-if="order.payment_transaction_id" label="交易号" :span="2">{{ order.payment_transaction_id }}</n-descriptions-item>
|
||||
<n-descriptions-item label="创建时间">{{ order.created_at || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item v-if="order.paid_at" label="支付时间">{{ order.paid_at }}</n-descriptions-item>
|
||||
<n-descriptions-item v-if="order.completed_at" label="完成时间">{{ order.completed_at }}</n-descriptions-item>
|
||||
<n-descriptions-item v-if="order.note" label="备注" :span="2">{{ order.note }}</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
|
||||
<!-- 买家信息 -->
|
||||
<n-card size="small" title="买家信息" :bordered="false" style="margin-top:16px;">
|
||||
<n-descriptions :column="2" label-placement="left" size="small">
|
||||
<n-descriptions-item label="用户 ID">{{ order.user_id || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="用户名">{{ buyer.display_name || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="邮箱">{{ buyer.email || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="手机">{{ buyer.phone || '-' }}</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
</n-card>
|
||||
|
||||
<!-- 收货地址 -->
|
||||
<n-card v-if="address" size="small" title="收货地址" :bordered="false" style="margin-top:12px;">
|
||||
<n-descriptions :column="1" label-placement="left" size="small">
|
||||
<n-descriptions-item label="收货人">{{ address.name || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="电话">{{ address.phone || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="地址">{{ address.full_address || '-' }}</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
</n-card>
|
||||
|
||||
<!-- 物流信息 -->
|
||||
<n-card v-if="shipping" size="small" title="物流信息" :bordered="false" style="margin-top:12px;">
|
||||
<n-descriptions :column="2" label-placement="left" size="small">
|
||||
<n-descriptions-item label="物流公司">{{ shipping.company || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="物流单号">{{ shipping.tracking_number || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="发货时间">{{ shipping.shipped_at || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="状态">
|
||||
<n-tag :type="shipping.status === 'delivered' ? 'success' : 'info'" size="small">
|
||||
{{ shipping.status_label || shipping.status || '-' }}
|
||||
</n-tag>
|
||||
</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
<n-timeline v-if="shipping.tracking_detail?.length" style="margin-top:12px;">
|
||||
<n-timeline-item
|
||||
v-for="(item, i) in shipping.tracking_detail"
|
||||
:key="i"
|
||||
:title="item.description"
|
||||
:content="item.time"
|
||||
:type="i === 0 ? 'success' : 'default'"
|
||||
/>
|
||||
</n-timeline>
|
||||
</n-card>
|
||||
|
||||
<!-- 售后信息 -->
|
||||
<n-card v-if="afterSale" size="small" title="售后信息" :bordered="false" style="margin-top:12px;">
|
||||
<n-descriptions :column="2" label-placement="left" size="small">
|
||||
<n-descriptions-item label="售后类型">{{ afterSale.type_label || afterSale.type || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="售后状态">
|
||||
<n-tag :type="afterSale.status === 'completed' ? 'success' : 'warning'" size="small">
|
||||
{{ afterSale.status_label || afterSale.status || '-' }}
|
||||
</n-tag>
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="退款金额">¥{{ (afterSale.refund_amount || 0).toFixed(2) }}</n-descriptions-item>
|
||||
<n-descriptions-item label="申请时间">{{ afterSale.created_at || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="原因" :span="2">{{ afterSale.reason || '-' }}</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
</n-card>
|
||||
|
||||
<!-- 操作时间线 -->
|
||||
<n-card v-if="timeline.length" size="small" title="操作时间线" :bordered="false" style="margin-top:16px;">
|
||||
<n-timeline>
|
||||
<n-timeline-item
|
||||
v-for="(entry, index) in timeline"
|
||||
:key="index"
|
||||
:type="entry.type === 'shipping' ? 'info' : entry.type === 'after_sale' ? 'warning' : 'success'"
|
||||
:title="entry.title"
|
||||
:content="entry.detail || ''"
|
||||
:time="entry.time"
|
||||
/>
|
||||
</n-timeline>
|
||||
</n-card>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div v-if="order.order_number" class="aksrt-order-drawer__actions">
|
||||
<n-divider />
|
||||
<n-space wrap>
|
||||
<n-button v-if="order.status === 'pending'" type="success" size="small" @click="updateStatus('paid')">
|
||||
<template #icon><i class="fas fa-check" /></template>
|
||||
确认支付
|
||||
</n-button>
|
||||
<n-button v-if="order.status === 'pending'" type="error" size="small" @click="updateStatus('cancelled')">
|
||||
<template #icon><i class="fas fa-times" /></template>
|
||||
取消订单
|
||||
</n-button>
|
||||
<n-button v-if="order.status === 'paid'" type="info" size="small" @click="showShipModal = true">
|
||||
<template #icon><i class="fas fa-truck" /></template>
|
||||
发货
|
||||
</n-button>
|
||||
<n-button v-if="order.status === 'delivered'" type="success" size="small" @click="updateStatus('completed')">
|
||||
<template #icon><i class="fas fa-check-double" /></template>
|
||||
确认完成
|
||||
</n-button>
|
||||
<n-button v-if="['paid', 'delivered', 'completed'].includes(order.status)" type="warning" size="small" @click="showRefundModal = true">
|
||||
<template #icon><i class="fas fa-undo" /></template>
|
||||
申请退款
|
||||
</n-button>
|
||||
<n-button v-if="order.status === 'completed' && !afterSale" type="default" size="small" @click="showAfterSaleModal = true">
|
||||
<template #icon><i class="fas fa-headset" /></template>
|
||||
创建售后
|
||||
</n-button>
|
||||
</n-space>
|
||||
</div>
|
||||
</n-spin>
|
||||
|
||||
<template #footer>
|
||||
<n-button @click="$emit('update:show', false)">关闭</n-button>
|
||||
</template>
|
||||
</n-drawer-content>
|
||||
</n-drawer>
|
||||
|
||||
<!-- 发货弹窗 -->
|
||||
<n-modal v-model:show="showShipModal" preset="card" title="发货" style="max-width:480px;" :bordered="false">
|
||||
<n-form label-placement="top">
|
||||
<n-form-item label="物流公司">
|
||||
<n-select v-model:value="shipForm.company" :options="shippingCompanies" placeholder="选择物流公司" />
|
||||
</n-form-item>
|
||||
<n-form-item label="物流单号">
|
||||
<n-input v-model:value="shipForm.tracking_number" placeholder="填写物流单号" />
|
||||
</n-form-item>
|
||||
<n-form-item label="备注">
|
||||
<n-input v-model:value="shipForm.note" type="textarea" :rows="2" placeholder="发货备注(可选)" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<template #footer>
|
||||
<n-space justify="end">
|
||||
<n-button @click="showShipModal = false">取消</n-button>
|
||||
<n-button type="info" :loading="shipLoading" @click="submitShip">确认发货</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</n-modal>
|
||||
|
||||
<!-- 退款弹窗 -->
|
||||
<n-modal v-model:show="showRefundModal" preset="card" title="申请退款" style="max-width:420px;" :bordered="false">
|
||||
<n-form label-placement="top">
|
||||
<n-form-item label="退款金额 (元)">
|
||||
<n-input-number v-model:value="refundAmount" :min="0" :max="order.amount || 0" :precision="2" style="width:100%;" />
|
||||
<n-text depth="3" style="font-size:12px;margin-top:4px;">订单总额 ¥{{ (order.amount || 0).toFixed(2) }},支持部分退款</n-text>
|
||||
</n-form-item>
|
||||
<n-form-item label="退款原因">
|
||||
<n-input v-model:value="refundReason" type="textarea" :rows="3" placeholder="填写退款原因(可选)" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<template #footer>
|
||||
<n-space justify="end">
|
||||
<n-button @click="showRefundModal = false">取消</n-button>
|
||||
<n-button type="warning" :loading="refundLoading" @click="submitRefund">确认退款</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</n-modal>
|
||||
|
||||
<!-- 售后弹窗 -->
|
||||
<n-modal v-model:show="showAfterSaleModal" preset="card" title="创建售后" style="max-width:480px;" :bordered="false">
|
||||
<n-form label-placement="top">
|
||||
<n-form-item label="售后类型">
|
||||
<n-select v-model:value="afterSaleForm.type" :options="afterSaleTypes" />
|
||||
</n-form-item>
|
||||
<n-form-item label="退款金额">
|
||||
<n-input-number v-model:value="afterSaleForm.refund_amount" :min="0" :max="order.amount || 0" :precision="2" style="width:100%;" />
|
||||
</n-form-item>
|
||||
<n-form-item label="原因">
|
||||
<n-input v-model:value="afterSaleForm.reason" type="textarea" :rows="3" placeholder="售后原因" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<template #footer>
|
||||
<n-space justify="end">
|
||||
<n-button @click="showAfterSaleModal = false">取消</n-button>
|
||||
<n-button type="warning" :loading="afterSaleLoading" @click="submitAfterSale">提交售后</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import api from '@/api/index.js'
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
orderNumber: { type: String, default: '' },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:show', 'updated'])
|
||||
const message = useMessage()
|
||||
const loading = ref(false)
|
||||
const order = ref({})
|
||||
const buyer = ref({})
|
||||
const address = ref(null)
|
||||
const shipping = ref(null)
|
||||
const afterSale = ref(null)
|
||||
const timeline = ref([])
|
||||
|
||||
// 发货
|
||||
const showShipModal = ref(false)
|
||||
const shipLoading = ref(false)
|
||||
const shipForm = ref({ company: '', tracking_number: '', note: '' })
|
||||
const shippingCompanies = [
|
||||
{ label: '顺丰速运', value: 'sf' },
|
||||
{ label: '圆通速递', value: 'yt' },
|
||||
{ label: '中通快递', value: 'zt' },
|
||||
{ label: '韵达快递', value: 'yd' },
|
||||
{ label: '申通快递', value: 'sto' },
|
||||
{ label: '百世快递', value: 'ht' },
|
||||
{ label: '极兔速递', value: 'jt' },
|
||||
{ label: '邮政快递', value: 'ems' },
|
||||
{ label: '其他', value: 'other' },
|
||||
]
|
||||
|
||||
// 退款
|
||||
const showRefundModal = ref(false)
|
||||
const refundAmount = ref(0)
|
||||
const refundReason = ref('')
|
||||
const refundLoading = ref(false)
|
||||
|
||||
// 售后
|
||||
const showAfterSaleModal = ref(false)
|
||||
const afterSaleLoading = ref(false)
|
||||
const afterSaleForm = ref({ type: 'refund', refund_amount: 0, reason: '' })
|
||||
const afterSaleTypes = [
|
||||
{ label: '仅退款', value: 'refund' },
|
||||
{ label: '退货退款', value: 'refund_return' },
|
||||
{ label: '换货', value: 'exchange' },
|
||||
{ label: '维修', value: 'repair' },
|
||||
]
|
||||
|
||||
const statusMap = {
|
||||
created: { label: '已创建', type: 'default' },
|
||||
pending: { label: '待支付', type: 'warning' },
|
||||
paid: { label: '已支付', type: 'success' },
|
||||
delivered: { label: '已发货', type: 'info' },
|
||||
completed: { label: '已完成', type: 'success' },
|
||||
cancelled: { label: '已取消', type: 'default' },
|
||||
expired: { label: '已过期', type: 'default' },
|
||||
refunding: { label: '退款中', type: 'warning' },
|
||||
refunded: { label: '已退款', type: 'error' },
|
||||
}
|
||||
|
||||
const statusLabel = computed(() => (statusMap[order.value.status] || { label: order.value.status }).label)
|
||||
const statusType = computed(() => (statusMap[order.value.status] || { type: 'default' }).type)
|
||||
|
||||
watch(() => props.show, async (val) => {
|
||||
if (val && props.orderNumber) {
|
||||
await loadOrder()
|
||||
} else if (!val) {
|
||||
resetState()
|
||||
}
|
||||
})
|
||||
|
||||
async function loadOrder() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await api.request(`/shop/orders/${props.orderNumber}`)
|
||||
applyOrderResponse(res)
|
||||
} catch {
|
||||
message.error('加载订单详情失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function applyOrderResponse(res) {
|
||||
if (!res) return
|
||||
order.value = res.data || {}
|
||||
const ctx = res.context || {}
|
||||
buyer.value = ctx.buyer || {}
|
||||
const addr = ctx.address || null
|
||||
if (addr && !addr.full_address) {
|
||||
addr.full_address = addr.full_text || [addr.province, addr.city, addr.district, addr.address].filter(Boolean).join(' ')
|
||||
}
|
||||
address.value = addr && Object.keys(addr).length ? addr : null
|
||||
|
||||
const shipLatest = ctx.shipping?.latest || null
|
||||
if (shipLatest && Object.keys(shipLatest).length) {
|
||||
shipping.value = {
|
||||
company: shipLatest.carrier_label || shipLatest.carrier || '',
|
||||
tracking_number: shipLatest.tracking_no || '',
|
||||
shipped_at: shipLatest.created_at || '',
|
||||
status: shipLatest.status || '',
|
||||
status_label: shipLatest.status_label || '',
|
||||
tracking_detail: shipLatest.tracking_detail || [],
|
||||
}
|
||||
} else {
|
||||
shipping.value = null
|
||||
}
|
||||
|
||||
const asLatest = ctx.after_sale?.latest || null
|
||||
if (asLatest && Object.keys(asLatest).length) {
|
||||
afterSale.value = {
|
||||
type: asLatest.type || '',
|
||||
type_label: asLatest.type_label || '',
|
||||
status: asLatest.status || '',
|
||||
status_label: asLatest.status_label || '',
|
||||
refund_amount: asLatest.amount ?? asLatest.refund_amount ?? 0,
|
||||
created_at: asLatest.created_at || '',
|
||||
reason: asLatest.reason || '',
|
||||
}
|
||||
} else {
|
||||
afterSale.value = null
|
||||
}
|
||||
|
||||
timeline.value = res.timeline || []
|
||||
if (order.value.amount != null) {
|
||||
refundAmount.value = order.value.amount || 0
|
||||
}
|
||||
}
|
||||
|
||||
function resetState() {
|
||||
order.value = {}
|
||||
buyer.value = {}
|
||||
address.value = null
|
||||
shipping.value = null
|
||||
afterSale.value = null
|
||||
timeline.value = []
|
||||
showRefundModal.value = false
|
||||
showShipModal.value = false
|
||||
showAfterSaleModal.value = false
|
||||
}
|
||||
|
||||
async function refreshOrder() {
|
||||
try {
|
||||
const res = await api.request(`/shop/orders/${props.orderNumber}`)
|
||||
applyOrderResponse(res)
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
|
||||
async function updateStatus(status) {
|
||||
try {
|
||||
await api.request('/shop/orders/update-status', {
|
||||
method: 'POST',
|
||||
data: { order_number: props.orderNumber, status },
|
||||
})
|
||||
message.success('状态已更新')
|
||||
await refreshOrder()
|
||||
emit('updated')
|
||||
} catch {
|
||||
message.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function submitShip() {
|
||||
if (!shipForm.value.company || !shipForm.value.tracking_number) {
|
||||
message.warning('请填写物流公司和物流单号')
|
||||
return
|
||||
}
|
||||
shipLoading.value = true
|
||||
try {
|
||||
await api.request('/shop/shipping', {
|
||||
method: 'POST',
|
||||
data: {
|
||||
order_number: props.orderNumber,
|
||||
carrier: shipForm.value.company,
|
||||
tracking_no: shipForm.value.tracking_number,
|
||||
note: shipForm.value.note,
|
||||
},
|
||||
})
|
||||
message.success('发货成功')
|
||||
showShipModal.value = false
|
||||
shipForm.value = { company: '', tracking_number: '', note: '' }
|
||||
await refreshOrder()
|
||||
emit('updated')
|
||||
} catch (err) {
|
||||
message.error(err?.message || '发货失败')
|
||||
} finally {
|
||||
shipLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitRefund() {
|
||||
refundLoading.value = true
|
||||
try {
|
||||
await api.request('/shop/orders/update-status', {
|
||||
method: 'POST',
|
||||
data: {
|
||||
order_number: props.orderNumber,
|
||||
status: 'refunding',
|
||||
refund_amount: refundAmount.value,
|
||||
refund_reason: refundReason.value,
|
||||
},
|
||||
})
|
||||
message.success('退款申请已提交')
|
||||
showRefundModal.value = false
|
||||
refundReason.value = ''
|
||||
await refreshOrder()
|
||||
emit('updated')
|
||||
} catch (err) {
|
||||
message.error(err?.message || '退款操作失败')
|
||||
} finally {
|
||||
refundLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitAfterSale() {
|
||||
if (!afterSaleForm.value.reason) {
|
||||
message.warning('请填写售后原因')
|
||||
return
|
||||
}
|
||||
afterSaleLoading.value = true
|
||||
try {
|
||||
await api.request('/shop/after-sale', {
|
||||
method: 'POST',
|
||||
data: {
|
||||
order_number: props.orderNumber,
|
||||
type: afterSaleForm.value.type,
|
||||
amount: afterSaleForm.value.refund_amount,
|
||||
reason: afterSaleForm.value.reason,
|
||||
},
|
||||
})
|
||||
message.success('售后申请已提交')
|
||||
showAfterSaleModal.value = false
|
||||
afterSaleForm.value = { type: 'refund', refund_amount: 0, reason: '' }
|
||||
await refreshOrder()
|
||||
emit('updated')
|
||||
} catch (err) {
|
||||
message.error(err?.message || '售后申请失败')
|
||||
} finally {
|
||||
afterSaleLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.aksrt-order-drawer__status-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.aksrt-order-drawer__status-header--pending { background: #fff7e6; }
|
||||
.aksrt-order-drawer__status-header--paid { background: #f6ffed; }
|
||||
.aksrt-order-drawer__status-header--delivered { background: #e6f7ff; }
|
||||
.aksrt-order-drawer__status-header--completed { background: #f6ffed; }
|
||||
.aksrt-order-drawer__status-header--cancelled { background: #f5f5f5; }
|
||||
.aksrt-order-drawer__status-header--refunding { background: #fff7e6; }
|
||||
.aksrt-order-drawer__status-header--refunded { background: #fff1f0; }
|
||||
|
||||
.aksrt-order-drawer__order-no {
|
||||
font-size: 13px;
|
||||
color: var(--color-ink-3);
|
||||
}
|
||||
|
||||
.aksrt-order-drawer__actions {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.aksrt-order-drawer__muted {
|
||||
font-size: 12px;
|
||||
color: var(--color-ink-4);
|
||||
margin-left: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,523 +0,0 @@
|
||||
<template>
|
||||
<n-drawer :show="show" :width="720" @update:show="$emit('update:show', $event)">
|
||||
<n-drawer-content :title="isEdit ? '编辑商品' : '新增商品'" closable>
|
||||
<n-form ref="formRef" :model="form" :rules="rules" label-placement="top">
|
||||
<!-- 基础信息 -->
|
||||
<n-divider title-placement="left">基础信息</n-divider>
|
||||
|
||||
<n-form-item label="商品名称" path="title">
|
||||
<n-input v-model:value="form.title" placeholder="输入商品名称" />
|
||||
</n-form-item>
|
||||
|
||||
<n-grid :cols="2" :x-gap="12">
|
||||
<n-gi>
|
||||
<n-form-item label="价格 (元)" path="price">
|
||||
<n-input-number v-model:value="form.price" :min="0" :precision="2" style="width:100%;" placeholder="0.00" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="库存" path="stock">
|
||||
<n-input-number v-model:value="form.stock" :min="0" style="width:100%;" placeholder="0" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
|
||||
<n-form-item label="商品简介" path="excerpt">
|
||||
<n-input v-model:value="form.excerpt" type="textarea" :rows="3" placeholder="简短描述(可选)" />
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="商品详情" path="content">
|
||||
<n-input v-model:value="form.content" type="textarea" :rows="6" placeholder="详细描述(支持 HTML)" />
|
||||
</n-form-item>
|
||||
|
||||
<!-- 商品素材 -->
|
||||
<n-divider title-placement="left">商品素材</n-divider>
|
||||
|
||||
<n-form-item label="主图 URL" path="image">
|
||||
<n-input v-model:value="form.image" placeholder="https://example.com/image.jpg" />
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="图集 URL">
|
||||
<n-input v-model:value="form.galleryText" type="textarea" :rows="3" placeholder="每行一个图片 URL" />
|
||||
</n-form-item>
|
||||
|
||||
<!-- 分类与标签 -->
|
||||
<n-divider title-placement="left">分类与标签</n-divider>
|
||||
|
||||
<n-form-item label="分类">
|
||||
<n-space vertical style="width:100%;">
|
||||
<n-select
|
||||
v-model:value="form.categories"
|
||||
:options="categoryOptions"
|
||||
multiple
|
||||
filterable
|
||||
placeholder="选择分类(可多选)"
|
||||
:loading="loadingTaxonomy"
|
||||
style="width:100%;"
|
||||
/>
|
||||
<n-space>
|
||||
<n-input
|
||||
v-model:value="newCategoryName"
|
||||
placeholder="新建分类名称"
|
||||
style="min-width:180px;"
|
||||
@keyup.enter="createCategory"
|
||||
/>
|
||||
<n-button :loading="creatingCategory" @click="createCategory">新建分类</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="标签">
|
||||
<n-select
|
||||
v-model:value="form.tags"
|
||||
:options="tagOptions"
|
||||
multiple
|
||||
filterable
|
||||
tag
|
||||
placeholder="选择或输入新标签后回车"
|
||||
:loading="loadingTaxonomy"
|
||||
@update:value="onTagsUpdate"
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<!-- 发货与售后 -->
|
||||
<n-divider title-placement="left">发货与售后</n-divider>
|
||||
|
||||
<n-grid :cols="2" :x-gap="12">
|
||||
<n-gi>
|
||||
<n-form-item label="发货方式" path="shipping_mode">
|
||||
<n-select v-model:value="form.shipping_mode" :options="shippingModeOptions" placeholder="选择发货方式" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="额外运费 (元)" path="shipping_fee">
|
||||
<n-input-number v-model:value="form.shipping_fee" :min="0" :precision="2" style="width:100%;" placeholder="0.00" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
|
||||
<n-form-item label="售后说明" path="after_sale_policy">
|
||||
<n-input v-model:value="form.after_sale_policy" type="textarea" :rows="3" placeholder="售后政策说明(可选)" />
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="支持退款">
|
||||
<n-switch v-model:value="form.support_refund" />
|
||||
</n-form-item>
|
||||
|
||||
<!-- 发布设置 -->
|
||||
<n-divider title-placement="left">发布设置</n-divider>
|
||||
|
||||
<n-form-item label="状态" path="status">
|
||||
<n-select v-model:value="form.status" :options="statusOptions" />
|
||||
</n-form-item>
|
||||
|
||||
<!-- SKU / 变体(编辑已有商品时可用;新建时先创建再编辑变体) -->
|
||||
<n-divider title-placement="left">SKU 变体</n-divider>
|
||||
<n-alert v-if="!isEdit" type="info" :bordered="false" style="margin-bottom:12px;">
|
||||
新建商品请先保存,再编辑时配置 SKU 变体。
|
||||
</n-alert>
|
||||
<template v-else>
|
||||
<n-space style="margin-bottom:12px;">
|
||||
<n-button size="small" @click="addVariantRow">
|
||||
<template #icon><i class="fas fa-plus" /></template>
|
||||
添加变体
|
||||
</n-button>
|
||||
<n-button size="small" type="primary" ghost :loading="savingVariants" @click="saveVariantsOnly">
|
||||
仅保存变体
|
||||
</n-button>
|
||||
<n-text depth="3" style="font-size:12px;">属性格式:名称:值,多项用分号分隔,如 颜色:红;尺寸:L</n-text>
|
||||
</n-space>
|
||||
<n-spin :show="loadingVariants">
|
||||
<n-empty v-if="!variants.length" description="暂无变体,点击「添加变体」" style="margin:12px 0;" />
|
||||
<div v-for="(row, index) in variants" :key="row._key || row.id || index" class="aksrt-variant-row">
|
||||
<n-grid :cols="24" :x-gap="8" :y-gap="8">
|
||||
<n-gi :span="6">
|
||||
<n-input v-model:value="row.sku" placeholder="SKU" size="small" />
|
||||
</n-gi>
|
||||
<n-gi :span="8">
|
||||
<n-input v-model:value="row.attrsText" placeholder="颜色:红;尺寸:L" size="small" />
|
||||
</n-gi>
|
||||
<n-gi :span="4">
|
||||
<n-input-number v-model:value="row.price" :min="0" :precision="2" size="small" style="width:100%;" placeholder="价格" />
|
||||
</n-gi>
|
||||
<n-gi :span="4">
|
||||
<n-input-number v-model:value="row.stock" :min="0" size="small" style="width:100%;" placeholder="库存" />
|
||||
</n-gi>
|
||||
<n-gi :span="2">
|
||||
<n-button size="small" quaternary type="error" @click="removeVariantRow(index)">
|
||||
<i class="fas fa-trash" />
|
||||
</n-button>
|
||||
</n-gi>
|
||||
<n-gi :span="22">
|
||||
<n-input v-model:value="row.image" placeholder="变体图片 URL(可选)" size="small" />
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
</div>
|
||||
</n-spin>
|
||||
</template>
|
||||
</n-form>
|
||||
|
||||
<template #footer>
|
||||
<n-space justify="end">
|
||||
<n-button @click="$emit('update:show', false)">取消</n-button>
|
||||
<n-button type="primary" :loading="saving" @click="handleSave">
|
||||
{{ isEdit ? '保存修改' : '创建商品' }}
|
||||
</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</n-drawer-content>
|
||||
</n-drawer>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, watch, computed } from 'vue'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import api from '@/api/index.js'
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
productId: { type: [Number, String], default: null },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:show', 'saved'])
|
||||
const message = useMessage()
|
||||
const formRef = ref(null)
|
||||
const saving = ref(false)
|
||||
const loadingTaxonomy = ref(false)
|
||||
const loadingVariants = ref(false)
|
||||
const savingVariants = ref(false)
|
||||
const variants = ref([])
|
||||
let variantKeySeq = 1
|
||||
|
||||
const isEdit = computed(() => !!props.productId)
|
||||
|
||||
const form = reactive({
|
||||
title: '',
|
||||
price: 0,
|
||||
stock: 0,
|
||||
excerpt: '',
|
||||
content: '',
|
||||
status: 'publish',
|
||||
image: '',
|
||||
galleryText: '',
|
||||
categories: [],
|
||||
tags: [],
|
||||
shipping_mode: 'digital',
|
||||
shipping_fee: 0,
|
||||
after_sale_policy: '',
|
||||
support_refund: true,
|
||||
})
|
||||
|
||||
const rules = {
|
||||
title: { required: true, message: '请输入商品名称', trigger: 'blur' },
|
||||
price: { type: 'number', required: true, message: '请输入价格', trigger: 'blur' },
|
||||
}
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '上架', value: 'publish' },
|
||||
{ label: '下架', value: 'draft' },
|
||||
]
|
||||
|
||||
const shippingModeOptions = [
|
||||
{ label: '自动发货(虚拟商品)', value: 'digital' },
|
||||
{ label: '手动发货', value: 'manual' },
|
||||
{ label: '快递发货', value: 'express' },
|
||||
]
|
||||
|
||||
const categoryOptions = ref([])
|
||||
const tagOptions = ref([])
|
||||
const newCategoryName = ref('')
|
||||
const creatingCategory = ref(false)
|
||||
const creatingTagNames = new Set()
|
||||
|
||||
// Load taxonomy options
|
||||
async function loadTaxonomy() {
|
||||
loadingTaxonomy.value = true
|
||||
try {
|
||||
const res = await api.request('/shop/products/taxonomy')
|
||||
if (res?.data) {
|
||||
categoryOptions.value = (res.data.categories || []).map(c => ({ label: c.name, value: c.id }))
|
||||
tagOptions.value = (res.data.tags || []).map(t => ({ label: t.name, value: t.name }))
|
||||
}
|
||||
} catch {
|
||||
message.warning('分类标签加载失败,可稍后重试')
|
||||
} finally {
|
||||
loadingTaxonomy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createCategory() {
|
||||
const name = (newCategoryName.value || '').trim()
|
||||
if (!name) {
|
||||
message.warning('请输入分类名称')
|
||||
return
|
||||
}
|
||||
creatingCategory.value = true
|
||||
try {
|
||||
const res = await api.request('/shop/products/taxonomy', {
|
||||
method: 'POST',
|
||||
data: { taxonomy: 'category', name },
|
||||
})
|
||||
const term = res?.data
|
||||
if (term?.id) {
|
||||
if (!categoryOptions.value.some(o => o.value === term.id)) {
|
||||
categoryOptions.value = [...categoryOptions.value, { label: term.name, value: term.id }]
|
||||
}
|
||||
if (!form.categories.includes(term.id)) {
|
||||
form.categories = [...form.categories, term.id]
|
||||
}
|
||||
newCategoryName.value = ''
|
||||
message.success(term.existing ? '分类已存在,已选中' : '分类已创建')
|
||||
}
|
||||
} catch (err) {
|
||||
message.error(err?.message || '创建分类失败')
|
||||
} finally {
|
||||
creatingCategory.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureTagTerm(name) {
|
||||
const label = String(name || '').trim()
|
||||
if (!label || creatingTagNames.has(label)) return
|
||||
const exists = tagOptions.value.some(o => o.value === label || o.label === label)
|
||||
if (exists) return
|
||||
|
||||
creatingTagNames.add(label)
|
||||
try {
|
||||
const res = await api.request('/shop/products/taxonomy', {
|
||||
method: 'POST',
|
||||
data: { taxonomy: 'tag', name: label },
|
||||
})
|
||||
const term = res?.data
|
||||
if (term?.name && !tagOptions.value.some(o => o.value === term.name)) {
|
||||
tagOptions.value = [...tagOptions.value, { label: term.name, value: term.name }]
|
||||
}
|
||||
} catch {
|
||||
// Keep typed tag in form; backend product save still accepts string tags.
|
||||
} finally {
|
||||
creatingTagNames.delete(label)
|
||||
}
|
||||
}
|
||||
|
||||
function onTagsUpdate(values) {
|
||||
const list = Array.isArray(values) ? values : []
|
||||
form.tags = list
|
||||
list.forEach((name) => {
|
||||
if (!tagOptions.value.some(o => o.value === name)) {
|
||||
ensureTagTerm(name)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function attributesToText(attrs) {
|
||||
if (!Array.isArray(attrs) || !attrs.length) return ''
|
||||
return attrs
|
||||
.map((item) => {
|
||||
if (!item || typeof item !== 'object') return ''
|
||||
const name = String(item.name ?? item.key ?? '').trim()
|
||||
const value = String(item.value ?? '').trim()
|
||||
if (!name && !value) return ''
|
||||
return name ? `${name}:${value}` : value
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(';')
|
||||
}
|
||||
|
||||
function textToAttributes(text) {
|
||||
const raw = String(text || '').trim()
|
||||
if (!raw) return []
|
||||
return raw
|
||||
.split(/[;;\n]+/)
|
||||
.map(part => part.trim())
|
||||
.filter(Boolean)
|
||||
.map((part) => {
|
||||
const sep = part.includes(':') ? ':' : (part.includes(':') ? ':' : null)
|
||||
if (!sep) return { name: '规格', value: part }
|
||||
const [name, ...rest] = part.split(sep)
|
||||
return { name: (name || '规格').trim(), value: rest.join(sep).trim() }
|
||||
})
|
||||
.filter(item => item.value !== '')
|
||||
}
|
||||
|
||||
function mapVariantFromApi(row) {
|
||||
return {
|
||||
_key: `v_${variantKeySeq++}`,
|
||||
id: row.id || 0,
|
||||
sku: row.sku || '',
|
||||
price: Number(row.price || 0),
|
||||
stock: Number(row.stock || 0),
|
||||
image: row.image || '',
|
||||
attrsText: attributesToText(row.attributes),
|
||||
}
|
||||
}
|
||||
|
||||
function buildVariantsPayload() {
|
||||
return variants.value.map((row, index) => ({
|
||||
sku: String(row.sku || '').trim(),
|
||||
price: Number(row.price || 0),
|
||||
stock: Math.max(0, parseInt(row.stock, 10) || 0),
|
||||
image: String(row.image || '').trim(),
|
||||
attributes: textToAttributes(row.attrsText),
|
||||
sort_order: index,
|
||||
}))
|
||||
}
|
||||
|
||||
function addVariantRow() {
|
||||
variants.value = [
|
||||
...variants.value,
|
||||
{
|
||||
_key: `v_${variantKeySeq++}`,
|
||||
id: 0,
|
||||
sku: '',
|
||||
price: Number(form.price || 0),
|
||||
stock: Number(form.stock || 0),
|
||||
image: '',
|
||||
attrsText: '',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function removeVariantRow(index) {
|
||||
variants.value = variants.value.filter((_, i) => i !== index)
|
||||
}
|
||||
|
||||
async function loadVariants(productId) {
|
||||
if (!productId) {
|
||||
variants.value = []
|
||||
return
|
||||
}
|
||||
loadingVariants.value = true
|
||||
try {
|
||||
const res = await api.request(`/shop/products/${productId}/variants`)
|
||||
const list = res?.data?.variants || res?.variants || []
|
||||
variants.value = Array.isArray(list) ? list.map(mapVariantFromApi) : []
|
||||
} catch {
|
||||
variants.value = []
|
||||
message.warning('变体加载失败')
|
||||
} finally {
|
||||
loadingVariants.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function persistVariants(productId) {
|
||||
if (!productId) return false
|
||||
await api.request(`/shop/products/${productId}/variants`, {
|
||||
method: 'POST',
|
||||
data: { variants: buildVariantsPayload() },
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
async function saveVariantsOnly() {
|
||||
if (!props.productId) {
|
||||
message.warning('请先保存商品')
|
||||
return
|
||||
}
|
||||
savingVariants.value = true
|
||||
try {
|
||||
await persistVariants(props.productId)
|
||||
message.success('变体已保存')
|
||||
await loadVariants(props.productId)
|
||||
} catch (err) {
|
||||
message.error(err?.message || '变体保存失败')
|
||||
} finally {
|
||||
savingVariants.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Load product data when editing
|
||||
watch(() => props.show, async (val) => {
|
||||
if (val) {
|
||||
loadTaxonomy()
|
||||
if (props.productId) {
|
||||
try {
|
||||
const res = await api.request(`/shop/products/${props.productId}`)
|
||||
if (res?.data) {
|
||||
const gallery = Array.isArray(res.data.gallery) ? res.data.gallery : []
|
||||
Object.assign(form, {
|
||||
title: res.data.title || '',
|
||||
price: res.data.price || 0,
|
||||
stock: res.data.stock || 0,
|
||||
excerpt: res.data.excerpt || '',
|
||||
content: res.data.content || '',
|
||||
status: res.data.status || 'publish',
|
||||
image: res.data.image || '',
|
||||
galleryText: gallery.join('\n'),
|
||||
categories: Array.isArray(res.data.categories) ? res.data.categories : [],
|
||||
tags: Array.isArray(res.data.tags) ? res.data.tags : [],
|
||||
shipping_mode: res.data.shipping_mode || 'digital',
|
||||
shipping_fee: res.data.shipping_fee || 0,
|
||||
after_sale_policy: res.data.after_sale_policy || '',
|
||||
support_refund: res.data.support_refund !== false,
|
||||
})
|
||||
}
|
||||
await loadVariants(props.productId)
|
||||
} catch {
|
||||
message.error('加载商品失败')
|
||||
}
|
||||
} else {
|
||||
Object.assign(form, {
|
||||
title: '', price: 0, stock: 0, excerpt: '', content: '', status: 'publish',
|
||||
image: '', galleryText: '', categories: [], tags: [],
|
||||
shipping_mode: 'digital', shipping_fee: 0, after_sale_policy: '', support_refund: true,
|
||||
})
|
||||
variants.value = []
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
async function handleSave() {
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const payload = {
|
||||
...form,
|
||||
gallery: form.galleryText
|
||||
.split('\n')
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean),
|
||||
}
|
||||
delete payload.galleryText
|
||||
|
||||
if (isEdit.value) {
|
||||
await api.request(`/shop/products/${props.productId}`, {
|
||||
method: 'POST',
|
||||
data: payload,
|
||||
})
|
||||
await persistVariants(props.productId)
|
||||
message.success('商品已更新')
|
||||
} else {
|
||||
const res = await api.request('/shop/products', {
|
||||
method: 'POST',
|
||||
data: payload,
|
||||
})
|
||||
const productId = res?.data?.id || res?.id || null
|
||||
if (productId && variants.value.length) {
|
||||
await persistVariants(productId)
|
||||
}
|
||||
message.success('商品已创建')
|
||||
}
|
||||
emit('update:show', false)
|
||||
emit('saved')
|
||||
} catch (err) {
|
||||
message.error(err?.message || '操作失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.aksrt-variant-row {
|
||||
padding: 10px;
|
||||
margin-bottom: 8px;
|
||||
border: 1px solid var(--n-border-color, #e5e7eb);
|
||||
border-radius: 8px;
|
||||
background: var(--n-color-modal, #fafafa);
|
||||
}
|
||||
</style>
|
||||
@@ -1,94 +0,0 @@
|
||||
<template>
|
||||
<n-modal :show="show" preset="dialog" title="批量更新物流状态" @update:show="$emit('update:show', $event)">
|
||||
<div class="aksrt-shipping-batch__body">
|
||||
<div class="aksrt-shipping-batch__summary">已选 {{ ids.length }} 条记录</div>
|
||||
<n-form label-placement="top">
|
||||
<n-form-item label="目标状态">
|
||||
<n-select v-model:value="form.status" :options="statusOptions" placeholder="选择状态" />
|
||||
</n-form-item>
|
||||
<n-form-item label="备注">
|
||||
<n-input v-model:value="form.note" type="textarea" :rows="3" placeholder="批量操作备注" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</div>
|
||||
<template #action>
|
||||
<n-space justify="end">
|
||||
<n-button @click="$emit('update:show', false)">取消</n-button>
|
||||
<n-button type="primary" :loading="submitting" @click="handleSubmit">提交</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, watch } from 'vue'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import api from '@/api/index.js'
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
ids: { type: Array, default: () => [] },
|
||||
statusOptions: { type: Array, default: () => [] },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:show', 'updated'])
|
||||
const message = useMessage()
|
||||
const form = reactive({
|
||||
status: null,
|
||||
note: '',
|
||||
})
|
||||
const submitting = reactive({ value: false })
|
||||
|
||||
watch(() => props.show, (visible) => {
|
||||
if (visible) {
|
||||
form.status = null
|
||||
form.note = ''
|
||||
}
|
||||
})
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!form.status || !props.ids.length) {
|
||||
message.warning('请选择目标状态并至少勾选一条记录')
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const res = await api.request('/shop/shipping/batch-update-status', {
|
||||
method: 'POST',
|
||||
data: {
|
||||
ids: props.ids,
|
||||
status: form.status,
|
||||
note: form.note,
|
||||
},
|
||||
})
|
||||
const updatedIds = Array.isArray(res?.data?.updated_ids) ? res.data.updated_ids : []
|
||||
const failed = Array.isArray(res?.data?.failed) ? res.data.failed : []
|
||||
|
||||
if (failed.length) {
|
||||
message.warning(`已更新 ${updatedIds.length} 条,另有 ${failed.length} 条因状态限制未执行`)
|
||||
} else {
|
||||
message.success(`已批量更新 ${updatedIds.length} 条记录`)
|
||||
}
|
||||
|
||||
emit('update:show', false)
|
||||
emit('updated')
|
||||
} catch (error) {
|
||||
message.error(error?.message || '批量操作失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.aksrt-shipping-batch__body {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.aksrt-shipping-batch__summary {
|
||||
margin-bottom: 12px;
|
||||
color: #475569;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,237 +0,0 @@
|
||||
<template>
|
||||
<n-drawer :show="show" :width="640" @update:show="$emit('update:show', $event)">
|
||||
<n-drawer-content title="物流详情" closable>
|
||||
<n-spin :show="loading">
|
||||
<!-- 物流状态头 -->
|
||||
<div class="aksrt-shipping__status-header" :class="'aksrt-shipping__status-header--' + (detail.record?.status || 'default')">
|
||||
<n-tag :type="statusType" size="large">{{ statusLabel }}</n-tag>
|
||||
<span class="aksrt-shipping__order-no">订单号:{{ detail.record?.order_number || '-' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 物流信息 -->
|
||||
<n-descriptions bordered :column="2" size="small" label-placement="left" style="margin-top:16px;">
|
||||
<n-descriptions-item label="物流公司">{{ detail.record?.carrier_label || detail.record?.carrier || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="物流单号">
|
||||
<n-text code>{{ detail.record?.tracking_no || '-' }}</n-text>
|
||||
<n-button v-if="detail.record?.tracking_no" text size="small" @click="copyTrackingNo" style="margin-left:8px;">
|
||||
<i class="fas fa-copy" />
|
||||
</n-button>
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="发货时间">{{ detail.record?.shipped_at || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="处理人">{{ detail.record?.operator?.display_name || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="发货类型">
|
||||
<n-tag size="small" :type="detail.record?.ship_type === 'express' ? 'info' : 'default'">
|
||||
{{ detail.record?.ship_type_label || '快递发货' }}
|
||||
</n-tag>
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="备注">{{ detail.record?.note || '-' }}</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
|
||||
<!-- 收货地址 -->
|
||||
<n-card v-if="detail.record?.order_context?.address" size="small" title="收货地址" :bordered="false" style="margin-top:16px;">
|
||||
<n-descriptions :column="1" size="small" label-placement="left">
|
||||
<n-descriptions-item label="收货人">{{ detail.record.order_context.address.name || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="电话">{{ detail.record.order_context.address.phone || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="地址">{{ detail.record.order_context.address.full_address || detail.record.order_context.address.full_text || '-' }}</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
<n-button size="small" @click="copyAddress" style="margin-top:8px;">
|
||||
<i class="fas fa-copy" style="margin-right:4px;" /> 复制地址
|
||||
</n-button>
|
||||
</n-card>
|
||||
|
||||
<!-- 物流追踪 -->
|
||||
<n-card v-if="detail.tracking_detail?.length" size="small" title="物流追踪" :bordered="false" style="margin-top:16px;">
|
||||
<n-timeline>
|
||||
<n-timeline-item
|
||||
v-for="(item, i) in detail.tracking_detail"
|
||||
:key="i"
|
||||
:title="item.description || item.status"
|
||||
:content="item.time"
|
||||
:type="i === 0 ? 'success' : 'default'"
|
||||
/>
|
||||
</n-timeline>
|
||||
</n-card>
|
||||
|
||||
<!-- 状态更新 -->
|
||||
<n-card size="small" title="状态更新" :bordered="false" style="margin-top:16px;">
|
||||
<n-space vertical>
|
||||
<n-input v-model:value="note" type="textarea" :rows="2" placeholder="状态变更备注" />
|
||||
<n-space>
|
||||
<n-select v-model:value="nextStatus" :options="actionOptions" placeholder="选择状态" style="width:160px;" />
|
||||
<n-button type="primary" :disabled="!nextStatus" :loading="updating" @click="handleStatusUpdate">更新状态</n-button>
|
||||
</n-space>
|
||||
</n-space>
|
||||
</n-card>
|
||||
|
||||
<!-- 操作时间线 -->
|
||||
<div v-if="detail.timeline?.length" class="aksrt-shipping__timeline">
|
||||
<div class="aksrt-shipping__section-title">操作时间线</div>
|
||||
<n-timeline size="small">
|
||||
<n-timeline-item
|
||||
v-for="(item, index) in detail.timeline"
|
||||
:key="index"
|
||||
:time="item.time"
|
||||
:title="item.label"
|
||||
:content="item.detail || ''"
|
||||
/>
|
||||
</n-timeline>
|
||||
</div>
|
||||
</n-spin>
|
||||
</n-drawer-content>
|
||||
</n-drawer>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import api from '@/api/index.js'
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
shippingId: { type: [Number, String], default: null },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:show', 'updated'])
|
||||
const message = useMessage()
|
||||
const loading = ref(false)
|
||||
const updating = ref(false)
|
||||
const detail = ref({ record: null, timeline: [], tracking_detail: [] })
|
||||
const nextStatus = ref(null)
|
||||
const note = ref('')
|
||||
|
||||
const statusMap = {
|
||||
pending: { label: '待发货', type: 'warning' },
|
||||
shipped: { label: '已发货', type: 'info' },
|
||||
in_transit: { label: '运输中', type: 'info' },
|
||||
delivered: { label: '已签收', type: 'success' },
|
||||
returned: { label: '已退回', type: 'warning' },
|
||||
lost: { label: '已丢失', type: 'error' },
|
||||
}
|
||||
|
||||
const statusLabel = computed(() => {
|
||||
const s = detail.value?.record?.status
|
||||
return (statusMap[s] || { label: s || '-' }).label
|
||||
})
|
||||
|
||||
const statusType = computed(() => {
|
||||
const s = detail.value?.record?.status
|
||||
return (statusMap[s] || { type: 'default' }).type
|
||||
})
|
||||
|
||||
const actionOptions = computed(() => {
|
||||
const status = detail.value?.record?.status
|
||||
if (status === 'shipped' || status === 'pending') {
|
||||
return [
|
||||
{ label: '运输中', value: 'in_transit' },
|
||||
{ label: '已签收', value: 'delivered' },
|
||||
{ label: '已退回', value: 'returned' },
|
||||
{ label: '已丢失', value: 'lost' },
|
||||
]
|
||||
}
|
||||
if (status === 'in_transit') {
|
||||
return [
|
||||
{ label: '已签收', value: 'delivered' },
|
||||
{ label: '已退回', value: 'returned' },
|
||||
{ label: '已丢失', value: 'lost' },
|
||||
]
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
watch(() => props.show, async (visible) => {
|
||||
if (visible && props.shippingId) {
|
||||
await loadDetail()
|
||||
} else if (!visible) {
|
||||
detail.value = { record: null, timeline: [], tracking_detail: [] }
|
||||
nextStatus.value = null
|
||||
note.value = ''
|
||||
}
|
||||
})
|
||||
|
||||
async function loadDetail() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await api.request(`/shop/shipping/${props.shippingId}`)
|
||||
detail.value = res?.data || { record: null, timeline: [], tracking_detail: [] }
|
||||
} catch (error) {
|
||||
message.error(error?.message || '加载物流详情失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStatusUpdate() {
|
||||
if (!nextStatus.value) return
|
||||
updating.value = true
|
||||
try {
|
||||
await api.request(`/shop/shipping/${props.shippingId}/update-status`, {
|
||||
method: 'POST',
|
||||
data: { status: nextStatus.value, note: note.value },
|
||||
})
|
||||
message.success('物流状态已更新')
|
||||
nextStatus.value = null
|
||||
note.value = ''
|
||||
await loadDetail()
|
||||
emit('updated')
|
||||
} catch (error) {
|
||||
message.error(error?.message || '状态更新失败')
|
||||
} finally {
|
||||
updating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copyTrackingNo() {
|
||||
const text = detail.value?.record?.tracking_no || ''
|
||||
if (!text) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
message.success('物流单号已复制')
|
||||
} catch {
|
||||
message.warning('复制失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function copyAddress() {
|
||||
const addr = detail.value?.record?.order_context?.address
|
||||
const text = addr?.full_address || addr?.full_text || ''
|
||||
if (!text) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
message.success('地址已复制')
|
||||
} catch {
|
||||
message.warning('复制失败')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.aksrt-shipping__status-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.aksrt-shipping__status-header--shipped { background: #e6f7ff; }
|
||||
.aksrt-shipping__status-header--in_transit { background: #e6f7ff; }
|
||||
.aksrt-shipping__status-header--delivered { background: #f6ffed; }
|
||||
.aksrt-shipping__status-header--returned { background: #fff7e6; }
|
||||
.aksrt-shipping__status-header--lost { background: #fff1f0; }
|
||||
|
||||
.aksrt-shipping__order-no {
|
||||
font-size: 13px;
|
||||
color: var(--color-ink-3);
|
||||
}
|
||||
|
||||
.aksrt-shipping__timeline {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.aksrt-shipping__section-title {
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
color: var(--color-ink);
|
||||
}
|
||||
</style>
|
||||
@@ -1,68 +0,0 @@
|
||||
<template>
|
||||
<n-modal :show="show" @update:show="$emit('update:show', $event)" preset="card" :title="modalTitle" style="width:480px;" closable>
|
||||
<n-form label-placement="top">
|
||||
<n-form-item label="申请人">
|
||||
<n-input :value="withdrawal.user_name || '-'" disabled />
|
||||
</n-form-item>
|
||||
<n-form-item label="提现金额">
|
||||
<n-input :value="'¥' + (withdrawal.amount || 0).toFixed(2)" disabled />
|
||||
</n-form-item>
|
||||
<n-form-item label="提现方式">
|
||||
<n-input :value="withdrawal.method || '-'" disabled />
|
||||
</n-form-item>
|
||||
<n-form-item label="收款账号">
|
||||
<n-input :value="withdrawal.account || withdrawal.account_info || '-'" disabled />
|
||||
</n-form-item>
|
||||
<n-form-item label="审核备注">
|
||||
<n-input v-model:value="note" type="textarea" :rows="3" placeholder="填写审核说明(可选)" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
|
||||
<template #footer>
|
||||
<n-space justify="end">
|
||||
<n-button @click="$emit('update:show', false)">取消</n-button>
|
||||
<n-button type="error" :loading="loading" @click="handleAction('rejected')">拒绝</n-button>
|
||||
<n-button type="success" :loading="loading" @click="handleAction('approved')">批准</n-button>
|
||||
<n-button type="info" :loading="loading" @click="handleAction('completed')">打款</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import api from '@/api/index.js'
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
withdrawal: { type: Object, default: () => ({}) },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:show', 'updated'])
|
||||
const message = useMessage()
|
||||
const loading = ref(false)
|
||||
const note = ref('')
|
||||
|
||||
const modalTitle = computed(() => {
|
||||
const id = props.withdrawal?.id
|
||||
return id ? `审核提现 #${id}` : '审核提现'
|
||||
})
|
||||
|
||||
async function handleAction(status) {
|
||||
loading.value = true
|
||||
try {
|
||||
await api.request(`/payment/withdrawals/${props.withdrawal.id}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ status, note: note.value }),
|
||||
})
|
||||
message.success('操作成功')
|
||||
emit('update:show', false)
|
||||
emit('updated')
|
||||
} catch {
|
||||
message.error('操作失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,2 +0,0 @@
|
||||
// Shop ops Vue pages removed (Phase 4). Config uses SchemaPage.
|
||||
export const shopPageLoaders = {}
|
||||
@@ -73,7 +73,7 @@ function createSectionRedirectRoutes() {
|
||||
|
||||
/**
|
||||
* Old SPA ops routes → native WP admin pages (Phase 4 transition).
|
||||
* Bookmarks under #/shop/products etc. land on NativeOpsRedirect.
|
||||
* Bookmarks under order-related paths land on NativeOpsRedirect.
|
||||
*/
|
||||
const NATIVE_OPS_REDIRECTS = [
|
||||
{ path: '/payment/charge-card', title: '充值卡', nativeAdminPath: 'admin.php?page=aksrt_charge_cards' },
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
export const basicSchema = {
|
||||
key: 'shop.basic',
|
||||
title: '商城基础',
|
||||
icon: 'fas fa-store',
|
||||
description: '商城总开关、首页链接、面包屑和 URL 别名设置。',
|
||||
defaults: {
|
||||
shop_s: true,
|
||||
shop_home_url: '',
|
||||
shop_breadcrumbs_s: true,
|
||||
shop_breadcrumbs_home: true,
|
||||
shop_breadcrumbs_shop_home: false,
|
||||
shop_breadcrumbs_shop_home_name: '商城首页',
|
||||
shop_product_rewrite_slug: 'product',
|
||||
shop_cart_rewrite_slug: 'cart',
|
||||
shop_rewrite_suffix_html_s: true,
|
||||
},
|
||||
groups: [
|
||||
{
|
||||
key: 'general',
|
||||
title: '商城开关',
|
||||
icon: 'fas fa-power-off',
|
||||
fields: [
|
||||
{ key: 'shop_s', label: '启用商城', type: 'switch', default: true },
|
||||
{ key: 'shop_home_url', label: '商城首页链接', type: 'input', default: '', help: '留空使用默认商城页面', dependsOn: { key: 'shop_s', value: true } },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'breadcrumbs',
|
||||
title: '面包屑',
|
||||
icon: 'fas fa-angle-double-right',
|
||||
fields: [
|
||||
{ key: 'shop_breadcrumbs_s', label: '显示面包屑', type: 'switch', default: true, dependsOn: { key: 'shop_s', value: true } },
|
||||
{ key: 'shop_breadcrumbs_home', label: '显示首页链接', type: 'switch', default: true, dependsOn: { key: 'shop_breadcrumbs_s', value: true } },
|
||||
{ key: 'shop_breadcrumbs_shop_home', label: '显示商城首页', type: 'switch', default: false, dependsOn: { key: 'shop_breadcrumbs_s', value: true } },
|
||||
{ key: 'shop_breadcrumbs_shop_home_name', label: '商城首页名称', type: 'input', default: '商城首页', dependsOn: { key: 'shop_breadcrumbs_s', value: true } },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'rewrite',
|
||||
title: 'URL 别名',
|
||||
icon: 'fas fa-link',
|
||||
fields: [
|
||||
{ key: 'shop_product_rewrite_slug', label: '商品 URL 别名', type: 'input', default: 'product', dependsOn: { key: 'shop_s', value: true } },
|
||||
{ key: 'shop_cart_rewrite_slug', label: '购物车 URL 别名', type: 'input', default: 'cart', dependsOn: { key: 'shop_s', value: true } },
|
||||
{ key: 'shop_rewrite_suffix_html_s', label: '.html 伪静态后缀', type: 'switch', default: true, dependsOn: { key: 'shop_s', value: true } },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
export const contactSchema = {
|
||||
key: 'shop.contact',
|
||||
title: '客服配置',
|
||||
icon: 'fas fa-headset',
|
||||
description: '商品详情页客服按钮和联系方式配置。',
|
||||
defaults: {
|
||||
shop_contact_msg_s: true,
|
||||
shop_contact_name: '联系客服',
|
||||
shop_contact_note: '',
|
||||
shop_contact_more: '',
|
||||
},
|
||||
groups: [
|
||||
{
|
||||
key: 'button',
|
||||
title: '客服按钮',
|
||||
icon: 'fas fa-comment-dots',
|
||||
fields: [
|
||||
{ key: 'shop_contact_msg_s', label: '显示客服按钮', type: 'switch', default: true },
|
||||
{ key: 'shop_contact_name', label: '按钮文案', type: 'input', default: '联系客服', dependsOn: { key: 'shop_contact_msg_s', value: true } },
|
||||
{ key: 'shop_contact_note', label: '客服备注', type: 'textarea', default: '', dependsOn: { key: 'shop_contact_msg_s', value: true } },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'channels',
|
||||
title: '联系方式',
|
||||
icon: 'fas fa-address-book',
|
||||
fields: [
|
||||
{ key: 'shop_contact_more', label: '多客服联系方式', type: 'textarea', default: '', help: '每行一个:名称|图标|链接|二维码图片 URL' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
/**
|
||||
* Shop & Product domain schemas.
|
||||
*/
|
||||
|
||||
import { basicSchema } from './basic.js'
|
||||
import { listSchema } from './list.js'
|
||||
import { contactSchema } from './contact.js'
|
||||
import { singleSchema } from './single.js'
|
||||
import { productDefaultsSchema } from './product-defaults.js'
|
||||
import { shippingConfigSchema } from './shipping-config.js'
|
||||
import { timingSchema } from './timing.js'
|
||||
|
||||
export const shopSchemas = {
|
||||
basic: basicSchema,
|
||||
list: listSchema,
|
||||
contact: contactSchema,
|
||||
single: singleSchema,
|
||||
productDefaults: productDefaultsSchema,
|
||||
shippingConfig: shippingConfigSchema,
|
||||
timing: timingSchema,
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
export const listSchema = {
|
||||
key: 'shop.list',
|
||||
title: '商品列表',
|
||||
icon: 'fas fa-th-list',
|
||||
description: '商品列表排序、分页、卡片样式和销量显示配置。',
|
||||
defaults: {
|
||||
shop_list_orderby: 'date',
|
||||
shop_list_count: 12,
|
||||
shop_list_paging: 'number',
|
||||
shop_list_ias_s: false,
|
||||
shop_list_ias_max: 5,
|
||||
shop_list_style: 'default',
|
||||
shop_sales_show: 'show',
|
||||
shop_sales_show_min: 10,
|
||||
},
|
||||
groups: [
|
||||
{
|
||||
key: 'pagination',
|
||||
title: '列表分页',
|
||||
icon: 'fas fa-layer-group',
|
||||
fields: [
|
||||
{ key: 'shop_list_orderby', label: '默认排序', type: 'select', default: 'date', options: [{ label: '最新发布', value: 'date' }, { label: '销量最高', value: 'sales' }, { label: '价格最低', value: 'price_asc' }, { label: '价格最高', value: 'price_desc' }, { label: '评价最多', value: 'comments' }] },
|
||||
{ key: 'shop_list_count', label: '每页数量', type: 'number', default: 12, min: 4, max: 100 },
|
||||
{ key: 'shop_list_paging', label: '翻页模式', type: 'radio', default: 'number', options: [{ label: '数字翻页', value: 'number' }, { label: 'AJAX 加载', value: 'ajax' }] },
|
||||
{ key: 'shop_list_ias_s', label: '滚动自动加载', type: 'switch', default: false, dependsOn: { key: 'shop_list_paging', value: 'ajax' } },
|
||||
{ key: 'shop_list_ias_max', label: '自动加载页数', type: 'number', default: 5, min: 1, max: 50, dependsOn: { key: 'shop_list_ias_s', value: true } },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'style',
|
||||
title: '列表样式',
|
||||
icon: 'fas fa-palette',
|
||||
fields: [
|
||||
{ key: 'shop_list_style', label: '卡片样式', type: 'select', default: 'default', options: [{ label: '默认', value: 'default' }, { label: '竖向大卡片', value: 'card-lg' }, { label: '横向小卡片', value: 'card-sm' }] },
|
||||
{ key: 'shop_sales_show', label: '销量显示', type: 'select', default: 'show', options: [{ label: '显示', value: 'show' }, { label: '不显示', value: 'hide' }, { label: '超量显示', value: 'threshold' }] },
|
||||
{ key: 'shop_sales_show_min', label: '起显阈值', type: 'number', default: 10, min: 0, max: 99999, help: '销量超过此数字才显示', dependsOn: { key: 'shop_sales_show', value: 'threshold' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
export const productDefaultsSchema = {
|
||||
key: 'shop.product-defaults',
|
||||
title: '商品默认参数',
|
||||
icon: 'fas fa-sliders-h',
|
||||
description: '主题级商品参数默认值,作为「商品→分类→主题设置」参数继承链的根。',
|
||||
defaults: {
|
||||
shop_limit_buy: -1,
|
||||
shop_shipping_fee: 'free',
|
||||
shop_guest_buy: false,
|
||||
shop_email_fill: false,
|
||||
shop_rebate: true,
|
||||
shop_after_sale: 'refund_return',
|
||||
shop_service: '',
|
||||
shop_product_params_default: '',
|
||||
},
|
||||
groups: [
|
||||
{
|
||||
key: 'policy',
|
||||
title: '购买策略',
|
||||
icon: 'fas fa-shopping-bag',
|
||||
fields: [
|
||||
{ key: 'shop_limit_buy', label: '默认限购', type: 'number', default: -1, min: -1, max: 9999, help: '-1 不限,0 禁购,正数为限购数量' },
|
||||
{ key: 'shop_guest_buy', label: '免登录购买', type: 'switch', default: false },
|
||||
{ key: 'shop_email_fill', label: '邮箱必留', type: 'switch', default: false },
|
||||
{ key: 'shop_rebate', label: '允许返佣', type: 'switch', default: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'shipping',
|
||||
title: '运费默认',
|
||||
icon: 'fas fa-truck',
|
||||
fields: [
|
||||
{ key: 'shop_shipping_fee', label: '默认运费类型', type: 'select', default: 'free', options: [{ label: '包邮', value: 'free' }, { label: '固定运费', value: 'fixed' }, { label: '按金额计算', value: 'amount' }] },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'afterSale',
|
||||
title: '售后与服务',
|
||||
icon: 'fas fa-undo',
|
||||
fields: [
|
||||
{ key: 'shop_after_sale', label: '默认售后政策', type: 'select', default: 'refund_return', options: [{ label: '仅退款', value: 'refund' }, { label: '退货退款', value: 'refund_return' }, { label: '不支持售后', value: 'none' }] },
|
||||
{ key: 'shop_service', label: '服务保障', type: 'textarea', default: '', help: '每行一项:名称|说明|图标图片 URL' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'params',
|
||||
title: '默认参数模板',
|
||||
icon: 'fas fa-list-alt',
|
||||
fields: [
|
||||
{ key: 'shop_product_params_default', label: '商品参数模板', type: 'textarea', default: '', help: '每行一个参数:参数名|参数值,新建商品自动带入' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
export const shippingConfigSchema = {
|
||||
key: 'shop.shipping-config',
|
||||
title: '物流配置',
|
||||
icon: 'fas fa-shipping-fast',
|
||||
description: '快递查询接口、物流公司列表和收货地址设置。',
|
||||
defaults: {
|
||||
shop_manual_address: false,
|
||||
shop_express_api_sdk: 'kuaidi100',
|
||||
shop_express_query_interval: 60,
|
||||
shop_express_kdniao_opt: '',
|
||||
shop_express_kuaidi100_opt: '',
|
||||
shop_express_aliyun_opt: '',
|
||||
shop_shipping_company: '',
|
||||
},
|
||||
groups: [
|
||||
{
|
||||
key: 'address',
|
||||
title: '收货地址',
|
||||
icon: 'fas fa-map-marker-alt',
|
||||
fields: [
|
||||
{ key: 'shop_manual_address', label: '手动填写收货地址', type: 'switch', default: false, help: '开启后用户可手动输入地址而非选择地址簿' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'express',
|
||||
title: '快递查询',
|
||||
icon: 'fas fa-search',
|
||||
fields: [
|
||||
{ key: 'shop_express_api_sdk', label: '快递查询接口', type: 'select', default: 'kuaidi100', options: [{ label: '快递100', value: 'kuaidi100' }, { label: '阿里云', value: 'aliyun' }, { label: '快递鸟', value: 'kdniao' }] },
|
||||
{ key: 'shop_express_query_interval', label: '查询刷新间隔(分钟)', type: 'number', default: 60, min: 30, max: 240 },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'credentials',
|
||||
title: '接口凭据',
|
||||
icon: 'fas fa-key',
|
||||
fields: [
|
||||
{ key: 'shop_express_kdniao_opt', label: '快递鸟凭据', type: 'textarea', default: '', help: 'AppID|APIKey', dependsOn: { key: 'shop_express_api_sdk', value: 'kdniao' } },
|
||||
{ key: 'shop_express_kuaidi100_opt', label: '快递100凭据', type: 'textarea', default: '', help: 'Key|Customer', dependsOn: { key: 'shop_express_api_sdk', value: 'kuaidi100' } },
|
||||
{ key: 'shop_express_aliyun_opt', label: '阿里云凭据', type: 'textarea', default: '', help: 'AppCode', dependsOn: { key: 'shop_express_api_sdk', value: 'aliyun' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'companies',
|
||||
title: '物流公司',
|
||||
icon: 'fas fa-building',
|
||||
fields: [
|
||||
{ key: 'shop_shipping_company', label: '物流公司列表', type: 'textarea', default: '', help: '每行一个:编码|名称,如 SF|顺丰速运' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
export const singleSchema = {
|
||||
key: 'shop.single',
|
||||
title: '商品详情',
|
||||
icon: 'fas fa-box-open',
|
||||
description: '商品详情页布局、相关推荐和底部内容设置。',
|
||||
defaults: {
|
||||
shop_content_layout: 'adapt',
|
||||
shop_content_show_bg: false,
|
||||
shop_single_tab: '',
|
||||
shop_content_after: '',
|
||||
shop_single_related_s: true,
|
||||
shop_single_related_opt: '',
|
||||
shop_main_image_default: '',
|
||||
},
|
||||
groups: [
|
||||
{
|
||||
key: 'layout',
|
||||
title: '详情页布局',
|
||||
icon: 'fas fa-columns',
|
||||
fields: [
|
||||
{ key: 'shop_content_layout', label: '内容布局', type: 'select', default: 'adapt', options: [{ label: '适应宽度', value: 'adapt' }, { label: '全屏', value: 'full' }, { label: '适应宽度+侧栏', value: 'sidebar' }] },
|
||||
{ key: 'shop_content_show_bg', label: '详情背景盒子', type: 'switch', default: false },
|
||||
{ key: 'shop_single_tab', label: '自定义 TAB 栏目', type: 'textarea', default: '', help: '每行一个 TAB:标题|内容 HTML' },
|
||||
{ key: 'shop_content_after', label: '详情页底部内容', type: 'textarea', default: '', help: '追加在每个商品详情页底部的 HTML 内容' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'related',
|
||||
title: '相关推荐',
|
||||
icon: 'fas fa-th',
|
||||
fields: [
|
||||
{ key: 'shop_single_related_s', label: '显示相关推荐', type: 'switch', default: true },
|
||||
{ key: 'shop_single_related_opt', label: '推荐配置', type: 'textarea', default: '', help: '标题|关联类型(cat/discount/tag)|排序|数量|翻页|样式', dependsOn: { key: 'shop_single_related_s', value: true } },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'image',
|
||||
title: '商品图片',
|
||||
icon: 'fas fa-image',
|
||||
fields: [
|
||||
{ key: 'shop_main_image_default', label: '默认商品主图', type: 'media', default: '' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
export const timingSchema = {
|
||||
key: 'shop.timing',
|
||||
title: '时效与售后原因',
|
||||
icon: 'fas fa-clock',
|
||||
description: '确认收货、评价、退货时效和售后原因配置。',
|
||||
defaults: {
|
||||
shop_order_receipt_max_day: 15,
|
||||
shop_comment_max_day: 7,
|
||||
shop_comment_placeholder: '',
|
||||
shop_comment_img_num: 3,
|
||||
shop_after_sale_return_express_max_day: 7,
|
||||
shop_after_sale_reason_refund: '',
|
||||
shop_after_sale_reason_return: '',
|
||||
shop_author_show: true,
|
||||
},
|
||||
groups: [
|
||||
{
|
||||
key: 'receipt',
|
||||
title: '确认收货',
|
||||
icon: 'fas fa-check-circle',
|
||||
fields: [
|
||||
{ key: 'shop_order_receipt_max_day', label: '自动确认收货(天)', type: 'number', default: 15, min: 1, max: 60, help: '发货后超过此天数自动确认收货' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'comment',
|
||||
title: '评价时效',
|
||||
icon: 'fas fa-star',
|
||||
fields: [
|
||||
{ key: 'shop_comment_max_day', label: '评价时效(天)', type: 'number', default: 7, min: 1, max: 60, help: '确认收货后超过此天数自动好评' },
|
||||
{ key: 'shop_comment_placeholder', label: '评价占位文案', type: 'input', default: '' },
|
||||
{ key: 'shop_comment_img_num', label: '评价图片数', type: 'number', default: 3, min: 0, max: 9 },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'afterSale',
|
||||
title: '售后退货',
|
||||
icon: 'fas fa-undo-alt',
|
||||
fields: [
|
||||
{ key: 'shop_after_sale_return_express_max_day', label: '退货快递时效(天)', type: 'number', default: 7, min: 1, max: 30 },
|
||||
{ key: 'shop_after_sale_reason_refund', label: '仅退款原因', type: 'textarea', default: '', help: '每行一个可选原因' },
|
||||
{ key: 'shop_after_sale_reason_return', label: '退货退款原因', type: 'textarea', default: '', help: '每行一个可选原因' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'author',
|
||||
title: '商家信息',
|
||||
icon: 'fas fa-user-tie',
|
||||
fields: [
|
||||
{ key: 'shop_author_show', label: '显示商家信息', type: 'switch', default: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -1,755 +0,0 @@
|
||||
/**
|
||||
* AKSRT Product Archive — 商品归档页样式
|
||||
*
|
||||
* 商品分类页、标签页共用样式。
|
||||
* 卡片网格、价格徽章、空状态。
|
||||
*
|
||||
* Tokens: 颜色 / 间距 / 圆角 / 阴影 全部来自 core/tokens.css
|
||||
*/
|
||||
|
||||
/* ================================================
|
||||
* 页面头:标题 + 计数
|
||||
* ================================================ */
|
||||
.aksrt-product-archive {
|
||||
padding-top: var(--space-6);
|
||||
padding-bottom: var(--space-9);
|
||||
}
|
||||
|
||||
.aksrt-product-archive__header {
|
||||
margin-bottom: var(--space-6);
|
||||
padding: var(--space-5);
|
||||
background: var(--surface-1, #fff);
|
||||
border-radius: var(--radius-lg, 8px);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.aksrt-product-archive__title {
|
||||
margin: 0 0 4px;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--color-ink);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.aksrt-product-archive__title i {
|
||||
color: var(--color-primary, var(--color-primary));
|
||||
}
|
||||
|
||||
.aksrt-product-archive__desc {
|
||||
margin: 0 0 8px;
|
||||
font-size: 14px;
|
||||
color: var(--color-ink-3);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.aksrt-product-archive__count {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--color-ink-4, #8A8F98);
|
||||
}
|
||||
|
||||
|
||||
.aksrt-product-archive__eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 28px;
|
||||
padding: 0 12px;
|
||||
margin-bottom: 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-2, #f4f5f7);
|
||||
color: var(--color-ink-3);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.aksrt-product-archive__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: var(--space-5);
|
||||
margin-bottom: var(--space-8);
|
||||
}
|
||||
|
||||
/* ================================================
|
||||
* 商品卡片
|
||||
* ================================================ */
|
||||
.aksrt-product-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--surface-1, #fff);
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: var(--radius-lg, 8px);
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
|
||||
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
|
||||
}
|
||||
|
||||
.aksrt-product-card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
border-color: color-mix(in srgb, var(--color-primary) 45%, var(--color-line, #e5e7eb));
|
||||
}
|
||||
|
||||
.aksrt-product-card__link {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* 商品图片 */
|
||||
.aksrt-product-card__image {
|
||||
position: relative;
|
||||
aspect-ratio: 4 / 3;
|
||||
background-color: var(--surface-2, #F4F5F7);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.aksrt-product-card__image img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.aksrt-product-card:hover .aksrt-product-card__image img {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.aksrt-product-card__image--placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-ink-4, #8A8F98);
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
/* 卡片正文 */
|
||||
.aksrt-product-card__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-4);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.aksrt-product-card__title {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
color: var(--color-ink);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.aksrt-product-card__excerpt {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--color-ink-3);
|
||||
line-height: 1.5;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 底部:价格 + 销量 */
|
||||
.aksrt-product-card__footer {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
margin-top: auto;
|
||||
padding-top: var(--space-3);
|
||||
border-top: 1px solid var(--color-line-soft, #F0F1F3);
|
||||
}
|
||||
|
||||
.aksrt-product-card__price {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--color-error, #EF4444);
|
||||
}
|
||||
|
||||
.aksrt-product-card__sales {
|
||||
font-size: 12px;
|
||||
color: var(--color-ink-4, #8A8F98);
|
||||
}
|
||||
|
||||
/* ================================================
|
||||
* 分页
|
||||
* ================================================ */
|
||||
.aksrt-product-archive__pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: var(--space-5) 0;
|
||||
}
|
||||
|
||||
.aksrt-product-archive__pagination .page-numbers {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 36px;
|
||||
height: 36px;
|
||||
padding: 0 8px;
|
||||
margin: 0 4px;
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: var(--radius, 10px);
|
||||
background: var(--surface-1, #fff);
|
||||
color: var(--color-ink-2);
|
||||
font-size: 14px;
|
||||
text-decoration: none;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.aksrt-product-archive__pagination .page-numbers:hover {
|
||||
border-color: color-mix(in srgb, var(--color-primary) 45%, var(--color-line, #e5e7eb));
|
||||
color: var(--color-primary, var(--color-primary));
|
||||
}
|
||||
|
||||
.aksrt-product-archive__pagination .page-numbers.current {
|
||||
background: var(--color-primary, var(--color-primary));
|
||||
border-color: color-mix(in srgb, var(--color-primary) 45%, var(--color-line, #e5e7eb));
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ================================================
|
||||
* 空状态
|
||||
* ================================================ */
|
||||
.aksrt-product-archive__empty {
|
||||
text-align: center;
|
||||
padding: var(--space-9) var(--space-5);
|
||||
color: var(--color-ink-3);
|
||||
}
|
||||
|
||||
.aksrt-product-archive__empty i {
|
||||
font-size: 48px;
|
||||
color: var(--color-ink-4, #8A8F98);
|
||||
margin-bottom: var(--space-4);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.aksrt-product-archive__empty p {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
/* ================================================
|
||||
* 响应式
|
||||
* ================================================ */
|
||||
@media (max-width: 640px) {
|
||||
.aksrt-product-archive__grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.aksrt-product-card__body {
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.aksrt-product-card__title {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.aksrt-product-card__price {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 380px) {
|
||||
.aksrt-product-archive__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ================================================
|
||||
* 产品详情页样式补充
|
||||
* ================================================ */
|
||||
.aksrt-product {
|
||||
padding-top: var(--space-6);
|
||||
padding-bottom: var(--space-9);
|
||||
}
|
||||
|
||||
.aksrt-product__layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-7);
|
||||
margin-bottom: var(--space-7);
|
||||
}
|
||||
|
||||
.aksrt-product__image {
|
||||
border-radius: var(--radius-lg, 8px);
|
||||
overflow: hidden;
|
||||
background: var(--surface-2, #F4F5F7);
|
||||
}
|
||||
|
||||
.aksrt-product__img {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.aksrt-product__img-placeholder {
|
||||
aspect-ratio: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-ink-4, #8A8F98);
|
||||
font-size: 64px;
|
||||
}
|
||||
|
||||
.aksrt-product__info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.aksrt-product__title {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--color-ink);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.aksrt-product__price-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.aksrt-product__price {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--color-error, #EF4444);
|
||||
}
|
||||
|
||||
.aksrt-product__sales {
|
||||
font-size: 13px;
|
||||
color: var(--color-ink-4, #8A8F98);
|
||||
}
|
||||
|
||||
.aksrt-product__excerpt {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--color-ink-3);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.aksrt-product__meta {
|
||||
font-size: 14px;
|
||||
color: var(--color-ink-2);
|
||||
}
|
||||
|
||||
.aksrt-product__meta i {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.aksrt-product__actions {
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
.aksrt-product__buy-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 32px;
|
||||
border: none;
|
||||
border-radius: var(--radius, 10px);
|
||||
background: var(--color-primary, var(--color-primary));
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.aksrt-product__buy-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.aksrt-product__detail {
|
||||
padding: var(--space-6);
|
||||
background: var(--surface-1, #fff);
|
||||
border-radius: var(--radius-lg, 8px);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.aksrt-product__detail-title {
|
||||
margin: 0 0 var(--space-4);
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--color-ink);
|
||||
}
|
||||
|
||||
.aksrt-product__detail-content {
|
||||
font-size: 15px;
|
||||
line-height: 1.8;
|
||||
color: var(--color-ink-2);
|
||||
}
|
||||
|
||||
.aksrt-product__detail-content img {
|
||||
max-width: 100%;
|
||||
border-radius: var(--radius, 10px);
|
||||
}
|
||||
|
||||
.aksrt-product__empty {
|
||||
text-align: center;
|
||||
padding: var(--space-10) var(--space-5);
|
||||
color: var(--color-ink-3);
|
||||
}
|
||||
|
||||
.aksrt-product__empty i {
|
||||
display: block;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.aksrt-product__empty p {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* 产品详情页响应式 */
|
||||
@media (max-width: 767px) {
|
||||
.aksrt-product__layout {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
.aksrt-product__title {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.aksrt-product__price {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ================================================
|
||||
* 购物车页样式
|
||||
* ================================================ */
|
||||
.aksrt-cart {
|
||||
padding-top: var(--space-6);
|
||||
padding-bottom: var(--space-9);
|
||||
}
|
||||
|
||||
.aksrt-cart__title {
|
||||
margin: 0 0 var(--space-6);
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--color-ink);
|
||||
}
|
||||
|
||||
.aksrt-cart__layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 320px;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.aksrt-cart__items {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.aksrt-cart__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-4);
|
||||
background: var(--surface-1, #fff);
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: var(--radius, 10px);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.aksrt-cart__item-img {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
object-fit: cover;
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
}
|
||||
|
||||
.aksrt-cart__item-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.aksrt-cart__item-info h4 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: var(--color-ink);
|
||||
}
|
||||
|
||||
.aksrt-cart__item-price {
|
||||
font-size: 14px;
|
||||
color: var(--color-ink-3);
|
||||
}
|
||||
|
||||
.aksrt-cart__item-qty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.aksrt-cart__qty-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
background: var(--surface-1, #fff);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.aksrt-cart__qty-btn:hover {
|
||||
border-color: color-mix(in srgb, var(--color-primary) 45%, var(--color-line, #e5e7eb));
|
||||
}
|
||||
|
||||
.aksrt-cart__item-subtotal {
|
||||
font-weight: 600;
|
||||
color: var(--color-error, #EF4444);
|
||||
min-width: 80px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.aksrt-cart__remove-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
color: var(--color-ink-4, #8A8F98);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
}
|
||||
|
||||
.aksrt-cart__remove-btn:hover {
|
||||
background: var(--surface-2, #F4F5F7);
|
||||
color: var(--color-error, #EF4444);
|
||||
}
|
||||
|
||||
.aksrt-cart__sidebar {
|
||||
position: sticky;
|
||||
top: 24px;
|
||||
}
|
||||
|
||||
.aksrt-cart__summary {
|
||||
padding: var(--space-5);
|
||||
background: var(--surface-1, #fff);
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: var(--radius-lg, 8px);
|
||||
}
|
||||
|
||||
.aksrt-cart__summary-title {
|
||||
margin: 0 0 var(--space-4);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--color-ink);
|
||||
}
|
||||
|
||||
.aksrt-cart__summary-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--space-3);
|
||||
font-size: 14px;
|
||||
color: var(--color-ink-2);
|
||||
}
|
||||
|
||||
.aksrt-cart__summary-total {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding-top: var(--space-3);
|
||||
border-top: 1px solid var(--color-line);
|
||||
margin-bottom: var(--space-5);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.aksrt-cart__total-price {
|
||||
font-size: 20px;
|
||||
color: var(--color-error, #EF4444);
|
||||
}
|
||||
|
||||
.aksrt-cart__checkout-btn {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: none;
|
||||
border-radius: var(--radius, 10px);
|
||||
background: var(--color-primary, var(--color-primary));
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.aksrt-cart__checkout-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.aksrt-cart__checkout-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.aksrt-cart__empty {
|
||||
text-align: center;
|
||||
padding: var(--space-9) var(--space-5);
|
||||
color: var(--color-ink-3);
|
||||
}
|
||||
|
||||
.aksrt-cart__empty i {
|
||||
display: block;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.aksrt-cart__empty p {
|
||||
margin: 0 0 var(--space-4);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
/* 购物车响应式 */
|
||||
@media (max-width: 767px) {
|
||||
.aksrt-cart__layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.aksrt-cart__sidebar {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
|
||||
/* ================================================
|
||||
* 商品编辑页样式
|
||||
* ================================================ */
|
||||
.aksrt-product-edit {
|
||||
padding-top: var(--space-6);
|
||||
padding-bottom: var(--space-9);
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.aksrt-product-edit__title {
|
||||
margin: 0 0 var(--space-6);
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--color-ink);
|
||||
}
|
||||
|
||||
.aksrt-product-edit__form {
|
||||
padding: var(--space-6);
|
||||
background: var(--surface-1, #fff);
|
||||
border-radius: var(--radius-lg, 8px);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.aksrt-product-edit__field {
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
|
||||
.aksrt-product-edit__field label {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--color-ink);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.aksrt-product-edit__field input,
|
||||
.aksrt-product-edit__field textarea {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
font-size: 14px;
|
||||
color: var(--color-ink);
|
||||
background: var(--surface-1, #fff);
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: var(--radius, 10px);
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.aksrt-product-edit__field input:focus,
|
||||
.aksrt-product-edit__field textarea:focus {
|
||||
outline: none;
|
||||
border-color: color-mix(in srgb, var(--color-primary) 45%, var(--color-line, #e5e7eb));
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 18%, transparent);
|
||||
}
|
||||
|
||||
.aksrt-product-edit__field textarea {
|
||||
resize: vertical;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.aksrt-product-edit__actions {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-6);
|
||||
}
|
||||
|
||||
.aksrt-product-edit__actions .btn-primary {
|
||||
padding: 10px 24px;
|
||||
border: none;
|
||||
border-radius: var(--radius, 10px);
|
||||
background: var(--color-primary, var(--color-primary));
|
||||
color: #fff;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.aksrt-product-edit__actions .btn-primary:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.aksrt-product-edit__actions .btn-ghost {
|
||||
padding: 10px 24px;
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: var(--radius, 10px);
|
||||
background: var(--surface-1, #fff);
|
||||
color: var(--color-ink-2);
|
||||
font-size: 15px;
|
||||
text-decoration: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.aksrt-product-edit__actions .btn-ghost:hover {
|
||||
border-color: color-mix(in srgb, var(--color-primary) 45%, var(--color-line, #e5e7eb));
|
||||
}
|
||||
|
||||
.aksrt-product-archive__toolbar{display:flex;flex-wrap:wrap;gap:8px;margin:0 0 16px}
|
||||
.aksrt-product-archive__sort{display:inline-flex;padding:8px 14px;border-radius:999px;background:var(--surface-1,#fff);color:var(--color-ink-3,#5c6370);text-decoration:none;font-weight:600;font-size:14px;box-shadow:var(--shadow-sm)}
|
||||
.aksrt-product-archive__sort.is-active,.aksrt-product-archive__sort:hover{background:var(--color-primary,#f04494);color:#fff}
|
||||
.aksrt-product-archive__load-more{display:flex;justify-content:center;margin:20px 0}
|
||||
.aksrt-revenue-page__layout{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:16px;margin:20px 0}
|
||||
.aksrt-revenue-page__panel{padding:18px;border-radius:16px;background:var(--surface-1,#fff);box-shadow:var(--shadow-sm)}
|
||||
.aksrt-revenue-page__withdraw-form label{display:flex;flex-direction:column;gap:6px;margin-bottom:12px}
|
||||
.aksrt-revenue-page__withdraw-form input,.aksrt-revenue-page__withdraw-form select,.aksrt-revenue-page__withdraw-form textarea{width:100%;padding:10px 12px;border:1px solid var(--color-line-soft,rgba(0,0,0,.08));border-radius:10px}
|
||||
.aksrt-revenue-page__item{display:flex;justify-content:space-between;gap:12px;padding:12px 0;border-bottom:1px solid var(--color-line-soft,rgba(0,0,0,.06))}
|
||||
.aksrt-revenue-page__item .is-in{color:#27ae60;font-weight:700}
|
||||
.aksrt-revenue-page__item .is-out{color:#e74c3c;font-weight:700}
|
||||
.aksrt-product-editor__hint{font-size:13px;color:var(--color-ink-4,#8a909c);margin:0 0 10px}
|
||||
|
||||
/* ===== zibll-parity cat filter / skeleton ===== */
|
||||
.aksrt-shop-cat-filter.shop-cat-filter{margin-top:14px}
|
||||
.shop-cat-filter-tab{display:flex;gap:10px;overflow-x:auto;list-style:none;margin:0 0 10px;padding:0 0 4px;-webkit-overflow-scrolling:touch}
|
||||
.shop-cat-filter-tab li{flex:0 0 auto}
|
||||
.shop-cat-filter-tab a,
|
||||
.shop-cat-filter-tab span,
|
||||
.shop-cat-filter-child a,
|
||||
.shop-cat-filter-child span{display:inline-flex;align-items:center;padding:6px 12px;border-radius:999px;font-size:13px;font-weight:600;text-decoration:none;color:var(--color-ink-2,#5c6370);background:var(--surface-4,#f3f4f6)}
|
||||
.shop-cat-filter-tab .active span,
|
||||
.shop-cat-filter-child .focus-color,
|
||||
.shop-cat-filter-orderby .focus-color{color:var(--color-primary,#f04494);background:color-mix(in srgb,var(--color-primary,#f04494) 12%, #fff)}
|
||||
.shop-cat-filter-child{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin:0 0 10px}
|
||||
.shop-cat-filter-orderby{gap:8px}
|
||||
.shop-cat-filter-orderby .opacity5{opacity:.55;margin-right:4px;font-size:13px}
|
||||
.aksrt-product-archive__skeleton{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px;padding:8px 0 16px}
|
||||
.aksrt-product-card--placeholder{min-height:220px;border-radius:12px;background:linear-gradient(90deg,#f3f4f6 25%,#e5e7eb 37%,#f3f4f6 63%);background-size:400% 100%;animation:aksrtShopSk 1.2s ease infinite}
|
||||
@keyframes aksrtShopSk{0%{background-position:100% 0}100%{background-position:0 0}}
|
||||
@media (max-width:992px){.aksrt-product-archive__skeleton{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
||||
@media (max-width:560px){.aksrt-product-archive__skeleton{grid-template-columns:1fr}}
|
||||
@@ -1,400 +0,0 @@
|
||||
.aksrt-cart-page {
|
||||
padding: 28px 0 56px;
|
||||
}
|
||||
|
||||
.aksrt-cart-page__header,
|
||||
.aksrt-cart-page__panel-head,
|
||||
.aksrt-cart-page__summary-total,
|
||||
.aksrt-cart-page__coupon-row,
|
||||
.aksrt-cart-page__item,
|
||||
.aksrt-cart-page__item-qty,
|
||||
.aksrt-cart-page__option-columns {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.aksrt-cart-page__header,
|
||||
.aksrt-cart-page__panel-head,
|
||||
.aksrt-cart-page__summary-total,
|
||||
.aksrt-cart-page__coupon-row,
|
||||
.aksrt-cart-page__option-columns {
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.aksrt-cart-page__layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 360px;
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.aksrt-cart-page__title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 30px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.aksrt-cart-page__subtitle,
|
||||
.aksrt-cart-page__hint,
|
||||
.aksrt-cart-page__option-body span {
|
||||
color: var(--color-ink-3, #5d6676);
|
||||
}
|
||||
|
||||
.aksrt-cart-page__pending {
|
||||
min-width: 280px;
|
||||
}
|
||||
|
||||
.aksrt-cart-page__pending-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.aksrt-cart-page__pending-chip {
|
||||
border: 1px solid var(--color-line, #dde3eb);
|
||||
border-radius: 999px;
|
||||
background: var(--surface-1, #fff);
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.aksrt-cart-page__panel {
|
||||
background: var(--surface-1, #fff);
|
||||
border: none;
|
||||
box-shadow: var(--shadow-sm);
|
||||
border-radius: var(--radius-2xl, 16px);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.aksrt-cart-page__main,
|
||||
.aksrt-cart-page__grid {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.aksrt-cart-page__grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.aksrt-cart-page__items {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.aksrt-cart-page__item {
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 16px;
|
||||
border: none;
|
||||
border-radius: var(--main-radius, var(--radius, 8px));
|
||||
background: var(--surface-4, #f8fafc);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.aksrt-cart-page__item-image {
|
||||
width: 84px;
|
||||
height: 84px;
|
||||
object-fit: cover;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.aksrt-cart-page__item-image--placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--surface-3, #eef2f7);
|
||||
color: var(--color-ink-4, #8690a0);
|
||||
}
|
||||
|
||||
.aksrt-cart-page__item-main {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.aksrt-cart-page__item-main h3,
|
||||
.aksrt-cart-page__mini-title,
|
||||
.aksrt-cart-page__panel h2 {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.aksrt-cart-page__item-qty {
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.aksrt-cart-page__item-qty button,
|
||||
.aksrt-cart-page__item-remove {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: 1px solid var(--color-line, #dce3ec);
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.aksrt-cart-page__item-total {
|
||||
min-width: 92px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.aksrt-cart-page__option-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.aksrt-cart-page__option-card {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--color-line, #dde3eb);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface-4, #fff);
|
||||
}
|
||||
|
||||
.aksrt-cart-page__option-body {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.aksrt-cart-page__field {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.aksrt-cart-page__input,
|
||||
.aksrt-cart-page__textarea {
|
||||
width: 100%;
|
||||
border: 1px solid var(--color-line, #dce3ec);
|
||||
border-radius: 12px;
|
||||
padding: 11px 14px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.aksrt-cart-page__summary-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin: 0 0 20px;
|
||||
}
|
||||
|
||||
.aksrt-cart-page__summary-list div,
|
||||
.aksrt-cart-page__summary-total {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.aksrt-cart-page__summary-list div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.aksrt-cart-page__summary-total {
|
||||
padding-top: 16px;
|
||||
margin-bottom: 18px;
|
||||
border-top: 1px solid var(--color-line, #e7ebf1);
|
||||
}
|
||||
|
||||
.aksrt-cart-page__summary-total strong {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.aksrt-cart-page__submit {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.aksrt-cart-page__empty {
|
||||
text-align: center;
|
||||
padding: 44px 20px;
|
||||
}
|
||||
|
||||
.aksrt-cart-page__empty i {
|
||||
display: block;
|
||||
margin-bottom: 12px;
|
||||
font-size: 42px;
|
||||
color: var(--color-ink-4, #8690a0);
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.aksrt-cart-page__layout,
|
||||
.aksrt-cart-page__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.aksrt-cart-page__title {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.aksrt-cart-page__option-columns {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.aksrt-cart-page__field-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.aksrt-cart-page__pending {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.aksrt-modal__dialog {
|
||||
width: 95%;
|
||||
max-height: 90vh;
|
||||
}
|
||||
|
||||
.aksrt-modal__body {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.aksrt-cart-page__header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.aksrt-cart-page__summary {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
border-radius: 16px 16px 0 0;
|
||||
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.aksrt-cart-page__submit {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Address Card Actions ── */
|
||||
|
||||
.aksrt-cart-page__address-card {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.aksrt-cart-page__address-label {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.aksrt-cart-page__address-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding-left: 28px;
|
||||
}
|
||||
|
||||
.aksrt-cart-page__default-tag {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
background: var(--color-primary-1, #e6f7ff);
|
||||
color: var(--color-primary, #1890ff);
|
||||
font-weight: 500;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.aksrt-cart-page__address-empty {
|
||||
text-align: center;
|
||||
padding: 32px 16px;
|
||||
color: var(--color-ink-4, #8690a0);
|
||||
}
|
||||
|
||||
.aksrt-cart-page__address-empty i {
|
||||
font-size: 28px;
|
||||
margin-bottom: 8px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.aksrt-cart-page__field-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.aksrt-cart-page__checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ── Shared Modal ── */
|
||||
|
||||
.aksrt-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.aksrt-modal__overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(18, 18, 18, 0.48);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.aksrt-modal__dialog {
|
||||
position: relative;
|
||||
background: var(--surface-1, #fff);
|
||||
border: none;
|
||||
border-radius: var(--radius-2xl, 16px);
|
||||
width: 90%;
|
||||
max-width: 520px;
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: var(--shadow-drawer);
|
||||
}
|
||||
|
||||
.aksrt-modal__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 18px 24px;
|
||||
border-bottom: 1px solid var(--color-line, #e3e8ef);
|
||||
}
|
||||
|
||||
.aksrt-modal__header h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.aksrt-modal__close {
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
color: var(--color-ink-3, #5d6676);
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.aksrt-modal__body {
|
||||
padding: 20px 24px;
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.aksrt-modal__footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
/* Soft focus ring (#77 recipe) — avoid hard primary stroke */
|
||||
.aksrt-cart-page__input:focus,
|
||||
.aksrt-cart-page__textarea:focus,
|
||||
.aksrt-cart-page__qty-btn:focus-visible {
|
||||
outline: none;
|
||||
border-color: color-mix(in srgb, var(--color-primary) 45%, var(--color-line, #e5e7eb));
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 18%, transparent);
|
||||
}
|
||||
@@ -1,491 +0,0 @@
|
||||
/**
|
||||
* AKSRT Product Page — zibll-aligned
|
||||
*
|
||||
* 包含:商品详情页 + 前台商品编辑器
|
||||
*/
|
||||
|
||||
.aksrt-product-page,
|
||||
.aksrt-product-editor {
|
||||
padding: 28px 0 56px;
|
||||
}
|
||||
|
||||
.aksrt-product-page__layout,
|
||||
.aksrt-product-page__detail-grid,
|
||||
.aksrt-product-editor__layout,
|
||||
.aksrt-product-editor__columns,
|
||||
.aksrt-product-page__details,
|
||||
.aksrt-product-page__actions {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.aksrt-product-page__layout,
|
||||
.aksrt-product-editor__layout {
|
||||
grid-template-columns: minmax(0, 1.1fr) minmax(320px, 0.9fr);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.aksrt-product-page__detail-grid {
|
||||
grid-template-columns: minmax(0, 1fr) 320px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
/* ===== Panel surfaces ===== */
|
||||
.aksrt-product-page__panel,
|
||||
.aksrt-product-editor__panel {
|
||||
background: var(--surface-1);
|
||||
border: none;
|
||||
box-shadow: var(--shadow-sm);
|
||||
border-radius: var(--radius-2xl, 16px);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.aksrt-product-page__title,
|
||||
.aksrt-product-editor__title {
|
||||
margin: 0 0 10px;
|
||||
font-size: 30px;
|
||||
line-height: 1.1;
|
||||
font-weight: 700;
|
||||
color: var(--color-ink);
|
||||
}
|
||||
|
||||
.aksrt-product-page__gallery,
|
||||
.aksrt-product-editor__main,
|
||||
.aksrt-product-editor__sidebar,
|
||||
.aksrt-product-page__summary,
|
||||
.aksrt-product-page__side-panels {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
/* ===== Gallery ===== */
|
||||
.aksrt-product-page__primary-image,
|
||||
.aksrt-product-page__placeholder {
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.aksrt-product-page__primary-image img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.aksrt-product-page__placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 360px;
|
||||
font-size: 56px;
|
||||
color: var(--color-ink-4);
|
||||
}
|
||||
|
||||
.aksrt-product-page__thumbs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(76px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.aksrt-product-page__thumb {
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: var(--radius);
|
||||
padding: 0;
|
||||
background: var(--surface-1);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: border-color var(--dur-fast) var(--ease);
|
||||
}
|
||||
|
||||
.aksrt-product-page__thumb:hover,
|
||||
.aksrt-product-page__thumb.is-active {
|
||||
border-color: color-mix(in srgb, var(--color-primary) 45%, var(--color-line, #e5e7eb));
|
||||
}
|
||||
|
||||
.aksrt-product-page__thumb img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
|
||||
.aksrt-product-page__service-strip {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin: 6px 0 4px;
|
||||
}
|
||||
|
||||
.aksrt-product-page__service-strip span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-4);
|
||||
color: var(--color-ink-3);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.aksrt-product-page__price-row,
|
||||
.aksrt-product-page__quantity,
|
||||
.aksrt-product-page__term-list,
|
||||
.aksrt-product-editor__actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.aksrt-product-page__badge,
|
||||
.aksrt-product-page__term {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: var(--radius-pill);
|
||||
padding: 6px 12px;
|
||||
background: var(--surface-4);
|
||||
color: var(--color-ink-2);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.aksrt-product-page__badge--promo {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.aksrt-product-page__price {
|
||||
font-size: 30px;
|
||||
font-weight: 800;
|
||||
color: var(--color-error);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.aksrt-product-page__sales {
|
||||
font-size: 13px;
|
||||
color: var(--color-ink-4);
|
||||
}
|
||||
|
||||
/* ===== Sticky buybox (zibll-aligned) ===== */
|
||||
.aksrt-product-page__summary {
|
||||
position: sticky;
|
||||
top: calc(var(--nav-height) + 16px);
|
||||
}
|
||||
|
||||
/* ===== Meta info blocks ===== */
|
||||
.aksrt-product-page__meta,
|
||||
.aksrt-product-page__info-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.aksrt-product-page__meta div,
|
||||
.aksrt-product-page__detail-block {
|
||||
padding: 14px;
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface-4);
|
||||
}
|
||||
|
||||
.aksrt-product-page__meta dt {
|
||||
font-size: 12px;
|
||||
color: var(--color-ink-4);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.aksrt-product-page__meta dd {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--color-ink);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ===== Form fields ===== */
|
||||
.aksrt-product-page__field,
|
||||
.aksrt-product-editor__field {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.aksrt-product-page__input,
|
||||
.aksrt-product-editor__input,
|
||||
.aksrt-product-editor__textarea {
|
||||
width: 100%;
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: var(--radius);
|
||||
padding: 11px 14px;
|
||||
background: var(--surface-1);
|
||||
font-size: 14px;
|
||||
color: var(--color-ink);
|
||||
transition: border-color var(--dur-fast) var(--ease), box-shadow var(--dur-fast) var(--ease);
|
||||
}
|
||||
|
||||
.aksrt-product-page__input:focus,
|
||||
.aksrt-product-editor__input:focus,
|
||||
.aksrt-product-editor__textarea:focus {
|
||||
outline: none;
|
||||
border-color: color-mix(in srgb, var(--color-primary) 45%, var(--color-line, #e5e7eb));
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 18%, transparent);
|
||||
}
|
||||
|
||||
/* ===== Quantity controls ===== */
|
||||
.aksrt-product-page__qty-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-1);
|
||||
color: var(--color-ink-2);
|
||||
cursor: pointer;
|
||||
transition: border-color var(--dur-fast) var(--ease), background var(--dur-fast) var(--ease);
|
||||
}
|
||||
|
||||
.aksrt-product-page__qty-btn:hover {
|
||||
border-color: color-mix(in srgb, var(--color-primary) 45%, var(--color-line, #e5e7eb));
|
||||
background: var(--color-primary-softer);
|
||||
}
|
||||
|
||||
.aksrt-product-page__qty-input {
|
||||
width: 90px;
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 12px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: var(--color-ink);
|
||||
}
|
||||
|
||||
/* ===== Content ===== */
|
||||
.aksrt-product-page__content {
|
||||
line-height: 1.8;
|
||||
color: var(--color-ink-2);
|
||||
}
|
||||
|
||||
.aksrt-product-page__content img {
|
||||
max-width: 100%;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
/* ===== Empty state ===== */
|
||||
.aksrt-product-page__empty,
|
||||
.aksrt-product-editor__empty {
|
||||
text-align: center;
|
||||
padding: 48px 20px;
|
||||
}
|
||||
|
||||
.aksrt-product-page__empty i,
|
||||
.aksrt-product-editor__empty i {
|
||||
display: block;
|
||||
font-size: 44px;
|
||||
color: var(--color-ink-4);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.aksrt-product-editor__checkbox {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.aksrt-product-editor__sidebar {
|
||||
position: sticky;
|
||||
top: 24px;
|
||||
}
|
||||
|
||||
/* ===== Responsive ===== */
|
||||
@media (max-width: 980px) {
|
||||
.aksrt-product-page__layout,
|
||||
.aksrt-product-page__detail-grid,
|
||||
.aksrt-product-editor__layout,
|
||||
.aksrt-product-editor__columns,
|
||||
.aksrt-product-page__details {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.aksrt-product-page__summary {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.aksrt-product-editor__sidebar {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.aksrt-product-page__title,
|
||||
.aksrt-product-editor__title {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.aksrt-product-page__thumbs {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.aksrt-product-page__spec-values {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.aksrt-product-page__actions {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.aksrt-product-page__actions .btn-lg,
|
||||
.aksrt-product-editor__actions .btn-lg {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.aksrt-product-page,
|
||||
.aksrt-product-editor {
|
||||
padding: 16px 0 40px;
|
||||
}
|
||||
|
||||
.aksrt-product-page__price {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.aksrt-product-page__meta {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Dark mode ===== */
|
||||
html.dark .aksrt-product-page__badge--promo,
|
||||
html.dark .aksrt-product-page__badge--promo {
|
||||
background: rgba(239, 68, 68, 0.18);
|
||||
}
|
||||
|
||||
html.dark .aksrt-product-page__placeholder,
|
||||
html.dark .aksrt-product-page__placeholder {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
html.dark .aksrt-product-page__thumb,
|
||||
html.dark .aksrt-product-page__thumb {
|
||||
border-color: var(--color-line);
|
||||
}
|
||||
|
||||
html.dark .aksrt-product-page__content img,
|
||||
html.dark .aksrt-product-page__content img {
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* SKU 规格选择器
|
||||
* ============================================================ */
|
||||
.aksrt-product-page__sku-selector {
|
||||
margin: 16px 0;
|
||||
padding: 16px;
|
||||
background: var(--surface-4);
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.aksrt-product-page__spec-group {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.aksrt-product-page__spec-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.aksrt-product-page__spec-label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--color-ink-2);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.aksrt-product-page__spec-values {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.aksrt-product-page__spec-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 48px;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--color-ink-2);
|
||||
background: var(--surface-1);
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all var(--dur-fast) var(--ease);
|
||||
}
|
||||
|
||||
.aksrt-product-page__spec-btn:hover {
|
||||
border-color: color-mix(in srgb, var(--color-primary) 45%, var(--color-line, #e5e7eb));
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.aksrt-product-page__spec-btn.is-active {
|
||||
background: var(--color-primary-soft);
|
||||
border-color: color-mix(in srgb, var(--color-primary) 45%, var(--color-line, #e5e7eb));
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.aksrt-product-page__spec-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
/* SKU 信息区 */
|
||||
.aksrt-product-page__sku-info {
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
background: var(--surface-1);
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.aksrt-product-page__sku-price {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.aksrt-product-page__sku-stock {
|
||||
font-size: 13px;
|
||||
color: var(--color-ink-3);
|
||||
}
|
||||
|
||||
|
||||
/* Product detail shell — align with single content stream */
|
||||
.content-shell--product .content-wrap {
|
||||
padding-top: 8px;
|
||||
}
|
||||
.content-shell--product .aksrt-product-page {
|
||||
width: 100%;
|
||||
}
|
||||
.aksrt-product-page__related {
|
||||
margin-top: 28px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
.aksrt-product-page__related-title {
|
||||
margin: 0 0 14px;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--color-ink, #1f2937);
|
||||
}
|
||||
.aksrt-product-page__related-grid.aksrt-product-archive__grid {
|
||||
margin: 0;
|
||||
}
|
||||
.aksrt-product-page__merchant-link {
|
||||
display: inline-block;
|
||||
margin-top: 4px;
|
||||
}
|
||||
@@ -1,483 +0,0 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var page = document.querySelector("[data-aksrt-cart-page]");
|
||||
if (!page || !window.aksrtData) {
|
||||
return;
|
||||
}
|
||||
|
||||
function restBase() {
|
||||
return ((aksrtData.siteUrl || "").replace(/\/$/, "")) + "/wp-json/aksrt/v1";
|
||||
}
|
||||
function rest(path, method, body) {
|
||||
var opts = {
|
||||
method: method || "GET",
|
||||
credentials: "same-origin",
|
||||
headers: { "X-WP-Nonce": aksrtData.restNonce || "" }
|
||||
};
|
||||
if (body !== undefined) {
|
||||
opts.headers["Content-Type"] = "application/json";
|
||||
opts.body = JSON.stringify(body);
|
||||
}
|
||||
return fetch(restBase() + path, opts).then(function (r) { return r.json(); });
|
||||
}
|
||||
function ordersUrl() {
|
||||
return aksrtData.ordersUrl || (aksrtData.routes && aksrtData.routes.orders) || "/";
|
||||
}
|
||||
function shopHomeUrl() {
|
||||
return (aksrtData.routes && aksrtData.routes.home) || aksrtData.siteUrl || "/";
|
||||
}
|
||||
var cartItems = [];
|
||||
var currencySymbol = page.getAttribute("data-currency-symbol") || "¥";
|
||||
var itemsEl = page.querySelector("[data-aksrt-cart-items]");
|
||||
var subtotalEl = page.querySelector("[data-aksrt-summary-subtotal]");
|
||||
var shippingEl = page.querySelector("[data-aksrt-summary-shipping]");
|
||||
var discountEl = page.querySelector("[data-aksrt-summary-discount]");
|
||||
var totalEl = page.querySelector("[data-aksrt-summary-total]");
|
||||
var previewMessageEl = page.querySelector("[data-aksrt-preview-message]");
|
||||
var createBtn = page.querySelector("[data-aksrt-create-order]");
|
||||
var previewBtn = page.querySelector("[data-aksrt-preview-checkout]");
|
||||
|
||||
var previewState = null;
|
||||
var searchParams = new URLSearchParams(window.location.search);
|
||||
|
||||
function getCart() { return cartItems; }
|
||||
function setCart(items) { cartItems = Array.isArray(items) ? items : []; }
|
||||
|
||||
function money(value) {
|
||||
return currencySymbol + Number(value || 0).toFixed(2);
|
||||
}
|
||||
|
||||
function selectedValue(name) {
|
||||
var checked = page.querySelector('input[name="' + name + '"]:checked');
|
||||
return checked ? checked.value : "";
|
||||
}
|
||||
|
||||
function getPayload() {
|
||||
var items = getCart().map(function (item) {
|
||||
return {
|
||||
product_id: Number(item.product_id || item.id || 0),
|
||||
quantity: Number(item.qty || item.quantity || 1),
|
||||
options_string: item.options_string || ""
|
||||
};
|
||||
}).filter(function (item) {
|
||||
return item.product_id > 0;
|
||||
});
|
||||
|
||||
return {
|
||||
items: items,
|
||||
coupon_code: (page.querySelector("[data-aksrt-coupon-code]") || {}).value || "",
|
||||
shipping_method: selectedValue("aksrt_cart_shipping"),
|
||||
address_id: Number(selectedValue("aksrt_cart_address") || 0),
|
||||
payment_method: selectedValue("aksrt_cart_payment"),
|
||||
customer_note: (page.querySelector("[data-aksrt-customer-note]") || {}).value || ""
|
||||
};
|
||||
}
|
||||
|
||||
function renderCart() {
|
||||
var items = getCart();
|
||||
if (!items.length) {
|
||||
itemsEl.innerHTML = '' +
|
||||
'<div class="aksrt-cart-page__empty">' +
|
||||
'<i class="fas fa-shopping-cart" aria-hidden="true"></i>' +
|
||||
"<p>购物车为空,去商品页添加想购买的内容。</p>" +
|
||||
'<a href="' + shopHomeUrl() + '" class="btn-outline btn-sm">继续浏览</a>' +
|
||||
"</div>";
|
||||
subtotalEl.textContent = money(0);
|
||||
shippingEl.textContent = money(0);
|
||||
discountEl.textContent = "-" + money(0);
|
||||
totalEl.textContent = money(0);
|
||||
createBtn.disabled = true;
|
||||
previewState = null;
|
||||
return;
|
||||
}
|
||||
|
||||
var total = 0;
|
||||
var html = "";
|
||||
items.forEach(function (item, index) {
|
||||
var qty = Number(item.qty || item.quantity || 1);
|
||||
var price = Number(item.unit_price != null ? item.unit_price : (item.price != null ? item.price : (item.product && item.product.price) || 0));
|
||||
var lineTotal = Number(item.line_total != null ? item.line_total : qty * price);
|
||||
total += lineTotal;
|
||||
var title = item.title || (item.product && item.product.title) || "商品";
|
||||
var image = item.image || (item.product && item.product.image) || "";
|
||||
var opts = item.options_label || item.options_string || "";
|
||||
|
||||
html += '' +
|
||||
'<article class="aksrt-cart-page__item" data-cart-index="' + index + '" data-product-id="' + Number(item.product_id || item.id || 0) + '" data-options-string="' + String(item.options_string || "").replace(/"/g, """) + '">' +
|
||||
(image ? '<img class="aksrt-cart-page__item-image" src="' + image + '" alt="">' : '<div class="aksrt-cart-page__item-image aksrt-cart-page__item-image--placeholder"><i class="fas fa-box"></i></div>') +
|
||||
'<div class="aksrt-cart-page__item-main">' +
|
||||
'<h3>' + title + "</h3>" +
|
||||
(opts ? "<small>" + opts + "</small>" : "") +
|
||||
'<p>' + money(price) + "</p>" +
|
||||
"</div>" +
|
||||
'<div class="aksrt-cart-page__item-qty">' +
|
||||
'<button type="button" data-qty-action="dec">-</button>' +
|
||||
"<span>" + qty + "</span>" +
|
||||
'<button type="button" data-qty-action="inc">+</button>' +
|
||||
"</div>" +
|
||||
'<strong class="aksrt-cart-page__item-total">' + money(lineTotal) + "</strong>" +
|
||||
'<button type="button" class="aksrt-cart-page__item-remove" data-qty-action="remove"><i class="fas fa-trash"></i></button>' +
|
||||
"</article>";
|
||||
});
|
||||
|
||||
itemsEl.innerHTML = html;
|
||||
subtotalEl.textContent = money(total);
|
||||
|
||||
if (!previewState) {
|
||||
shippingEl.textContent = money(0);
|
||||
discountEl.textContent = "-" + money(0);
|
||||
totalEl.textContent = money(total);
|
||||
}
|
||||
}
|
||||
|
||||
function setSummary(data) {
|
||||
previewState = data || null;
|
||||
subtotalEl.textContent = money((data && data.subtotal) || 0);
|
||||
shippingEl.textContent = money((data && data.shipping_fee) || 0);
|
||||
discountEl.textContent = "-" + money((data && data.discount) || 0);
|
||||
totalEl.textContent = money((data && data.total) || 0);
|
||||
createBtn.disabled = !data;
|
||||
}
|
||||
|
||||
function request(path, body) { return rest(path, "POST", body || {}); }
|
||||
|
||||
function previewCheckout() {
|
||||
var payload = getPayload();
|
||||
if (!payload.items.length) {
|
||||
previewMessageEl.textContent = "请先添加商品后再试算。";
|
||||
return;
|
||||
}
|
||||
|
||||
previewBtn.disabled = true;
|
||||
previewMessageEl.textContent = "正在试算订单...";
|
||||
|
||||
request("/shop/frontend/checkout/preview", payload)
|
||||
.then(function (result) {
|
||||
if (!result.success) {
|
||||
setSummary(null);
|
||||
previewMessageEl.textContent = result.message || "试算失败。";
|
||||
return;
|
||||
}
|
||||
|
||||
setSummary(result.data);
|
||||
previewMessageEl.textContent = "已完成试算,可直接创建订单。";
|
||||
})
|
||||
.catch(function () {
|
||||
setSummary(null);
|
||||
previewMessageEl.textContent = "网络错误,请稍后重试。";
|
||||
})
|
||||
.finally(function () {
|
||||
previewBtn.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
function createOrder() {
|
||||
var payload = getPayload();
|
||||
if (!previewState) {
|
||||
previewCheckout();
|
||||
return;
|
||||
}
|
||||
|
||||
createBtn.disabled = true;
|
||||
request("/shop/frontend/orders", payload)
|
||||
.then(function (result) {
|
||||
if (!result.success) {
|
||||
previewMessageEl.textContent = result.message || "订单创建失败。";
|
||||
createBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
var orderNumber = (result.data && result.data.order_number) || "";
|
||||
var nextOrdersUrl = ordersUrl() + (String(ordersUrl()).indexOf("?") >= 0 ? "&" : "?") + "order_status=pending";
|
||||
|
||||
if (!orderNumber) {
|
||||
window.location.href = nextOrdersUrl;
|
||||
return;
|
||||
}
|
||||
|
||||
// 立即发起支付:跳转型网关直接前往第三方,余额型同步完成。
|
||||
request("/shop/frontend/orders/" + orderNumber + "/pay", {})
|
||||
.then(function (pay) {
|
||||
var data = (pay && pay.data) || {};
|
||||
if (pay && pay.success && data.redirect && data.payment_url && /^https?:\/\//i.test(data.payment_url)) {
|
||||
window.location.href = data.payment_url;
|
||||
return;
|
||||
}
|
||||
// 二维码 / 余额 / 失败:统一回订单中心处理(含 ?payment 提示)。
|
||||
window.location.href = nextOrdersUrl;
|
||||
})
|
||||
.catch(function () {
|
||||
window.location.href = nextOrdersUrl;
|
||||
});
|
||||
})
|
||||
.catch(function () {
|
||||
previewMessageEl.textContent = "网络错误,请稍后重试。";
|
||||
createBtn.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
page.addEventListener("click", function (event) {
|
||||
var actionButton = event.target.closest("[data-qty-action]");
|
||||
if (actionButton) {
|
||||
var itemEl = actionButton.closest("[data-cart-index]");
|
||||
if (!itemEl) return;
|
||||
var productId = Number(itemEl.getAttribute("data-product-id") || 0);
|
||||
var optionsString = itemEl.getAttribute("data-options-string") || "";
|
||||
var action = actionButton.getAttribute("data-qty-action");
|
||||
var path = "/shop/frontend/cart/add";
|
||||
var body = { product_id: productId, options_string: optionsString, count: 1 };
|
||||
if (action === "dec") {
|
||||
path = "/shop/frontend/cart/reduce";
|
||||
} else if (action === "remove") {
|
||||
path = "/shop/frontend/cart/remove";
|
||||
body = { product_id: productId, options_string: optionsString };
|
||||
} else if (action !== "inc") {
|
||||
return;
|
||||
}
|
||||
rest(path, "POST", body).then(function (result) {
|
||||
if (!result || !result.success) {
|
||||
window.alert((result && result.message) || "操作失败");
|
||||
return;
|
||||
}
|
||||
setSummary(null);
|
||||
loadServerCart();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.target.closest("[data-aksrt-clear-cart]")) {
|
||||
rest("/shop/frontend/cart/empty", "POST", {}).then(function () {
|
||||
setSummary(null);
|
||||
loadServerCart();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.target.closest("[data-aksrt-preview-checkout]")) {
|
||||
previewCheckout();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.target.closest("[data-aksrt-create-order]")) {
|
||||
createOrder();
|
||||
return;
|
||||
}
|
||||
|
||||
var resumeButton = event.target.closest("[data-aksrt-resume-order]");
|
||||
if (resumeButton) {
|
||||
window.location.href = ordersUrl() + (String(ordersUrl()).indexOf("?") >= 0 ? "&" : "?") + "order_status=pending";
|
||||
}
|
||||
});
|
||||
|
||||
function loadServerCart() {
|
||||
return rest("/shop/frontend/cart", "GET").then(function (result) {
|
||||
var items = (result && result.success && result.data && result.data.items) ? result.data.items : [];
|
||||
items = items.map(function (it) {
|
||||
it.qty = it.quantity || it.qty || 1;
|
||||
it.price = it.unit_price != null ? it.unit_price : (it.product && it.product.price) || 0;
|
||||
it.title = it.title || (it.product && it.product.title) || "商品";
|
||||
it.image = it.image || (it.product && it.product.image) || "";
|
||||
return it;
|
||||
});
|
||||
setCart(items);
|
||||
renderCart();
|
||||
}).catch(function () {
|
||||
setCart([]);
|
||||
renderCart();
|
||||
});
|
||||
}
|
||||
|
||||
var couponInput = page.querySelector("[data-aksrt-coupon-code]");
|
||||
if (couponInput && searchParams.has("coupon")) {
|
||||
couponInput.value = searchParams.get("coupon") || "";
|
||||
}
|
||||
|
||||
/* ── Address Book Management ── */
|
||||
|
||||
var addressModal = page.querySelector("[data-aksrt-address-modal]");
|
||||
var addressForm = page.querySelector("[data-aksrt-address-form]");
|
||||
var addressList = page.querySelector("[data-aksrt-address-list]");
|
||||
var addressTitle = page.querySelector("[data-aksrt-address-modal-title]");
|
||||
|
||||
function openAddressModal(mode, address) {
|
||||
if (!addressModal || !addressForm) return;
|
||||
addressForm.reset();
|
||||
addressForm.querySelector('[name="address_id"]').value = "";
|
||||
|
||||
if (mode === "edit" && address) {
|
||||
addressTitle.textContent = "编辑地址";
|
||||
addressForm.querySelector('[name="address_id"]').value = address.id || "";
|
||||
addressForm.querySelector('[name="name"]').value = address.name || "";
|
||||
addressForm.querySelector('[name="phone"]').value = address.phone || "";
|
||||
addressForm.querySelector('[name="province"]').value = address.province || "";
|
||||
addressForm.querySelector('[name="city"]').value = address.city || "";
|
||||
addressForm.querySelector('[name="district"]').value = address.district || "";
|
||||
addressForm.querySelector('[name="address"]').value = address.address || "";
|
||||
addressForm.querySelector('[name="is_default"]').checked = !!address.is_default;
|
||||
} else {
|
||||
addressTitle.textContent = "新增地址";
|
||||
}
|
||||
|
||||
addressModal.hidden = false;
|
||||
}
|
||||
|
||||
function closeAddressModal() {
|
||||
if (addressModal) addressModal.hidden = true;
|
||||
}
|
||||
|
||||
function refreshAddressList() {
|
||||
fetch(restBase() + "/shop/frontend/address", {
|
||||
headers: { "X-WP-Nonce": aksrtData.restNonce }
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (result) {
|
||||
if (!result.success || !addressList) return;
|
||||
var addresses = (result.data && result.data.addresses) || [];
|
||||
renderAddressList(addresses);
|
||||
})
|
||||
.catch(function () {});
|
||||
}
|
||||
|
||||
function renderAddressList(addresses) {
|
||||
if (!addressList) return;
|
||||
if (!addresses.length) {
|
||||
addressList.innerHTML =
|
||||
'<div class="aksrt-cart-page__address-empty" data-aksrt-address-empty>' +
|
||||
'<i class="fas fa-map-marker-alt" aria-hidden="true"></i>' +
|
||||
"<p>暂无收货地址,请先新增。</p>" +
|
||||
"</div>";
|
||||
return;
|
||||
}
|
||||
|
||||
var html = "";
|
||||
addresses.forEach(function (addr, index) {
|
||||
html +=
|
||||
'<div class="aksrt-cart-page__option-card aksrt-cart-page__address-card" data-aksrt-address-card="' + addr.id + '">' +
|
||||
'<label class="aksrt-cart-page__address-label">' +
|
||||
'<input type="radio" name="aksrt_cart_address" value="' + addr.id + '"' + (index === 0 || addr.is_default ? " checked" : "") + ">" +
|
||||
'<span class="aksrt-cart-page__option-body">' +
|
||||
"<strong>" + escHtml(addr.name) + (addr.is_default ? ' <span class="aksrt-cart-page__default-tag">默认</span>' : "") + "</strong>" +
|
||||
"<span>" + escHtml(addr.phone) + "</span>" +
|
||||
"<span>" + escHtml(addr.full_text) + "</span>" +
|
||||
"</span></label>" +
|
||||
'<div class="aksrt-cart-page__address-actions">' +
|
||||
'<button type="button" class="btn-ghost btn-xs" data-aksrt-address-edit="' + addr.id + '">编辑</button>' +
|
||||
'<button type="button" class="btn-ghost btn-xs" data-aksrt-address-delete="' + addr.id + '">删除</button>' +
|
||||
(addr.is_default ? "" : '<button type="button" class="btn-ghost btn-xs" data-aksrt-address-set-default="' + addr.id + '">设为默认</button>') +
|
||||
"</div></div>";
|
||||
});
|
||||
addressList.innerHTML = html;
|
||||
}
|
||||
|
||||
function escHtml(str) {
|
||||
var div = document.createElement("div");
|
||||
div.appendChild(document.createTextNode(str || ""));
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function getAddressById(id) {
|
||||
var cards = addressList ? addressList.querySelectorAll("[data-aksrt-address-card]") : [];
|
||||
var found = null;
|
||||
cards.forEach(function (card) {
|
||||
if (String(card.getAttribute("data-aksrt-address-card")) === String(id)) {
|
||||
var body = card.querySelector(".aksrt-cart-page__option-body");
|
||||
if (body) {
|
||||
var spans = body.querySelectorAll("span");
|
||||
var strong = body.querySelector("strong");
|
||||
found = {
|
||||
id: id,
|
||||
name: strong ? strong.textContent.replace("默认", "").trim() : "",
|
||||
phone: spans[0] ? spans[0].textContent : "",
|
||||
full_text: spans[1] ? spans[1].textContent : ""
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
// Address event delegation
|
||||
page.addEventListener("click", function (event) {
|
||||
if (event.target.closest("[data-aksrt-address-add]")) {
|
||||
openAddressModal("add");
|
||||
return;
|
||||
}
|
||||
|
||||
var editBtn = event.target.closest("[data-aksrt-address-edit]");
|
||||
if (editBtn) {
|
||||
var editId = editBtn.getAttribute("data-aksrt-address-edit");
|
||||
var addr = getAddressById(editId);
|
||||
openAddressModal("edit", addr);
|
||||
return;
|
||||
}
|
||||
|
||||
var deleteBtn = event.target.closest("[data-aksrt-address-delete]");
|
||||
if (deleteBtn) {
|
||||
var deleteId = deleteBtn.getAttribute("data-aksrt-address-delete");
|
||||
if (!confirm("确定删除该地址吗?")) return;
|
||||
rest("/shop/frontend/address/" + deleteId, "DELETE").then(function (result) {
|
||||
if (!result || !result.success) {
|
||||
alert((result && result.message) || "删除失败。");
|
||||
return;
|
||||
}
|
||||
refreshAddressList();
|
||||
}).catch(function () { alert("网络错误。"); });
|
||||
return;
|
||||
}
|
||||
|
||||
var defaultBtn = event.target.closest("[data-aksrt-address-set-default]");
|
||||
if (defaultBtn) {
|
||||
var defaultId = defaultBtn.getAttribute("data-aksrt-address-set-default");
|
||||
request("/shop/frontend/address/" + defaultId + "/default", {}).then(function (result) {
|
||||
if (!result.success) {
|
||||
alert(result.message || "设置失败。");
|
||||
return;
|
||||
}
|
||||
refreshAddressList();
|
||||
}).catch(function () { alert("网络错误。"); });
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.target.closest("[data-aksrt-address-modal-close]") || event.target.classList.contains("aksrt-modal__overlay")) {
|
||||
closeAddressModal();
|
||||
}
|
||||
});
|
||||
|
||||
if (addressForm) {
|
||||
addressForm.addEventListener("submit", function (event) {
|
||||
event.preventDefault();
|
||||
var formData = new FormData(addressForm);
|
||||
var addressId = formData.get("address_id");
|
||||
var payload = {
|
||||
name: (formData.get("name") || "").trim(),
|
||||
phone: (formData.get("phone") || "").trim(),
|
||||
province: (formData.get("province") || "").trim(),
|
||||
city: (formData.get("city") || "").trim(),
|
||||
district: (formData.get("district") || "").trim(),
|
||||
address: (formData.get("address") || "").trim(),
|
||||
is_default: formData.get("is_default") === "1"
|
||||
};
|
||||
|
||||
if (!payload.name || !payload.phone || !payload.address) {
|
||||
alert("请填写必填项。");
|
||||
return;
|
||||
}
|
||||
|
||||
var url = addressId
|
||||
? "/shop/frontend/address/" + addressId
|
||||
: "/shop/frontend/address";
|
||||
|
||||
request(url, payload).then(function (result) {
|
||||
if (!result.success) {
|
||||
alert(result.message || "保存失败。");
|
||||
return;
|
||||
}
|
||||
closeAddressModal();
|
||||
refreshAddressList();
|
||||
}).catch(function () { alert("网络错误。"); });
|
||||
});
|
||||
}
|
||||
|
||||
loadServerCart();
|
||||
})();
|
||||
+1
-226
@@ -89,10 +89,6 @@
|
||||
return suffix ? base + suffix : base;
|
||||
}
|
||||
|
||||
function cartUrl() {
|
||||
return aksrtData.cartUrl || (aksrtData.routes && aksrtData.routes.cart) || "/";
|
||||
}
|
||||
|
||||
/* ── Order review modal (zibll 1:1) ── */
|
||||
var STAR_SVG =
|
||||
'<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"/></svg>';
|
||||
@@ -434,7 +430,7 @@
|
||||
badge.textContent = statusLabel || newStatus;
|
||||
}
|
||||
// Hide action buttons that are no longer relevant
|
||||
var actions = card.querySelectorAll("[data-aksrt-order-pay], [data-aksrt-order-cancel], [data-aksrt-order-after-sale], [data-aksrt-order-receipt], [data-aksrt-order-rebuy]");
|
||||
var actions = card.querySelectorAll("[data-aksrt-order-pay], [data-aksrt-order-cancel]");
|
||||
for (var i = 0; i < actions.length; i++) {
|
||||
actions[i].style.display = "none";
|
||||
}
|
||||
@@ -640,21 +636,9 @@
|
||||
// Two-step cancel inside the modal — no window.confirm.
|
||||
footer += '<button type="button" class="btn-ghost" data-aksrt-order-cancel="' + escapeHtml(order.order_number) + '" data-aksrt-cancel-step="1">关闭订单</button>';
|
||||
}
|
||||
if (actions.can_receipt) {
|
||||
footer += '<button type="button" class="btn-primary" data-aksrt-order-receipt="' + escapeHtml(order.order_number) + '">确认收货</button>';
|
||||
}
|
||||
if (actions.can_review) {
|
||||
footer += '<button type="button" class="btn-primary c-green" data-aksrt-order-review="' + escapeHtml(order.order_number) + '">评价</button>';
|
||||
}
|
||||
if (actions.can_pay) {
|
||||
footer += '<button type="button" class="btn-primary" data-aksrt-order-pay="' + escapeHtml(order.order_number) + '" data-aksrt-order-method="' + escapeHtml((payment && payment.value) || "") + '">立即支付</button>';
|
||||
}
|
||||
if (actions.can_after_sale) {
|
||||
footer += '<button type="button" class="btn-outline" data-aksrt-order-after-sale="' + escapeHtml(order.order_number) + '">申请售后</button>';
|
||||
}
|
||||
if (actions.can_rebuy) {
|
||||
footer += '<button type="button" class="btn-outline" data-aksrt-order-rebuy="' + escapeHtml(order.order_number) + '">再次购买</button>';
|
||||
}
|
||||
if (actions.can_view_product && order.permalink) {
|
||||
footer += '<a class="btn-ghost" href="' + escapeHtml(order.permalink) + '">查看商品</a>';
|
||||
}
|
||||
@@ -695,70 +679,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ── After-Sale Modal ── */
|
||||
var afterSaleModal = page.querySelector("[data-aksrt-aftersale-modal]");
|
||||
var afterSaleForm = page.querySelector("[data-aksrt-aftersale-form]");
|
||||
var afterSaleOrderInput = afterSaleForm ? afterSaleForm.querySelector('[name="order_number"]') : null;
|
||||
|
||||
function openAfterSaleModal(orderNumber) {
|
||||
if (!afterSaleModal || !afterSaleForm) {
|
||||
showToast("售后表单加载失败,请刷新页面后重试。");
|
||||
return;
|
||||
}
|
||||
afterSaleForm.reset();
|
||||
if (afterSaleOrderInput) afterSaleOrderInput.value = orderNumber;
|
||||
afterSaleModal.hidden = false;
|
||||
}
|
||||
|
||||
function closeAfterSaleModal() {
|
||||
if (afterSaleModal) afterSaleModal.hidden = true;
|
||||
}
|
||||
|
||||
if (afterSaleForm) {
|
||||
afterSaleForm.addEventListener("submit", function (event) {
|
||||
event.preventDefault();
|
||||
var formData = new FormData(afterSaleForm);
|
||||
var orderNumber = formData.get("order_number");
|
||||
var reason = (formData.get("reason") || "").trim();
|
||||
if (!reason) {
|
||||
showToast("请填写售后原因。");
|
||||
return;
|
||||
}
|
||||
|
||||
var submitBtn = afterSaleForm.querySelector('button[type="submit"]');
|
||||
if (submitBtn) {
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = "提交中...";
|
||||
}
|
||||
|
||||
request(orderPath(orderNumber, "/after-sale"), {
|
||||
type: formData.get("type") || "refund",
|
||||
reason: reason,
|
||||
note: (formData.get("note") || "").trim()
|
||||
}).then(function (result) {
|
||||
if (submitBtn) {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = "提交售后";
|
||||
}
|
||||
if (!result.success) {
|
||||
showToast(result.message || "提交售后失败。");
|
||||
return;
|
||||
}
|
||||
closeAfterSaleModal();
|
||||
showToast("售后申请已提交。");
|
||||
// Local refresh: update card status
|
||||
var card = findOrderCard(orderNumber);
|
||||
updateCardStatus(card, "refunded", "已退款");
|
||||
}).catch(function () {
|
||||
if (submitBtn) {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = "提交售后";
|
||||
}
|
||||
showToast("网络错误,请稍后重试。");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Countdown timer ── */
|
||||
function updateCountdowns() {
|
||||
var nodes = page.querySelectorAll("[data-aksrt-order-countdown]");
|
||||
@@ -808,56 +728,6 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Confirm receipt
|
||||
var receiptBtn = event.target.closest("[data-aksrt-order-receipt]");
|
||||
if (receiptBtn) {
|
||||
var receiptOrder = receiptBtn.getAttribute("data-aksrt-order-receipt");
|
||||
if (!receiptOrder) return;
|
||||
if (!window.confirm("确认已收到货物?")) return;
|
||||
receiptBtn.disabled = true;
|
||||
request(orderPath(receiptOrder, "/confirm-receipt"), {})
|
||||
.then(function (result) {
|
||||
receiptBtn.disabled = false;
|
||||
if (!result || !result.success) {
|
||||
showToast((result && result.message) || "确认收货失败");
|
||||
return;
|
||||
}
|
||||
showToast("已确认收货");
|
||||
updateCardStatus(findOrderCard(receiptOrder), "completed", "已完成");
|
||||
if (detailOrderNumber === receiptOrder) {
|
||||
closeOrderDetailModal();
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
receiptBtn.disabled = false;
|
||||
showToast("网络错误");
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Rebuy
|
||||
var rebuyBtn = event.target.closest("[data-aksrt-order-rebuy]");
|
||||
if (rebuyBtn) {
|
||||
var rebuyOrder = rebuyBtn.getAttribute("data-aksrt-order-rebuy");
|
||||
if (!rebuyOrder) return;
|
||||
rebuyBtn.disabled = true;
|
||||
request(orderPath(rebuyOrder, "/rebuy"), {})
|
||||
.then(function (result) {
|
||||
rebuyBtn.disabled = false;
|
||||
if (!result || !result.success) {
|
||||
showToast((result && result.message) || "再次购买失败");
|
||||
return;
|
||||
}
|
||||
showToast("已加入购物车");
|
||||
window.location.href = cartUrl();
|
||||
})
|
||||
.catch(function () {
|
||||
rebuyBtn.disabled = false;
|
||||
showToast("网络错误");
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Review modal (zibll order_comment_modal 1:1 UX)
|
||||
var reviewBtn = event.target.closest("[data-aksrt-order-review]");
|
||||
if (reviewBtn) {
|
||||
@@ -992,103 +862,8 @@
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// After-Sale
|
||||
var afterSaleButton = event.target.closest("[data-aksrt-order-after-sale]");
|
||||
if (afterSaleButton) {
|
||||
var afterSaleOrderNumber = afterSaleButton.getAttribute("data-aksrt-order-after-sale");
|
||||
closeOrderDetailModal();
|
||||
openAfterSaleModal(afterSaleOrderNumber);
|
||||
return;
|
||||
}
|
||||
|
||||
// Return shipping
|
||||
var returnShipBtn = event.target.closest("[data-aksrt-return-ship]");
|
||||
if (returnShipBtn) {
|
||||
openReturnShipModal(returnShipBtn.getAttribute("data-aksrt-return-ship"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel after-sale ticket
|
||||
var asCancelBtn = event.target.closest("[data-aksrt-as-cancel]");
|
||||
if (asCancelBtn) {
|
||||
var tid = asCancelBtn.getAttribute("data-aksrt-as-cancel");
|
||||
if (!tid || !window.confirm("确认取消该售后申请?")) return;
|
||||
asCancelBtn.disabled = true;
|
||||
request("/shop/frontend/after-sale/" + tid + "/cancel", { reason: "" })
|
||||
.then(function (result) {
|
||||
asCancelBtn.disabled = false;
|
||||
if (!result || !result.success) {
|
||||
showToast((result && result.message) || "取消失败");
|
||||
return;
|
||||
}
|
||||
showToast(result.message || "已取消售后");
|
||||
window.location.reload();
|
||||
})
|
||||
.catch(function () {
|
||||
asCancelBtn.disabled = false;
|
||||
showToast("网络错误");
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Close after-sale modal
|
||||
if (event.target.closest("[data-aksrt-aftersale-modal-close]") || (event.target.classList && event.target.classList.contains("aksrt-modal__overlay") && event.target.closest("[data-aksrt-aftersale-modal]"))) {
|
||||
closeAfterSaleModal();
|
||||
return;
|
||||
}
|
||||
|
||||
// Close return-ship modal
|
||||
if (event.target.closest("[data-aksrt-return-ship-close]") || (event.target.classList && event.target.classList.contains("aksrt-modal__overlay") && event.target.closest("[data-aksrt-return-ship-modal]"))) {
|
||||
closeReturnShipModal();
|
||||
}
|
||||
});
|
||||
|
||||
/* ── Return shipping modal ── */
|
||||
var returnShipModal = page.querySelector("[data-aksrt-return-ship-modal]");
|
||||
var returnShipForm = page.querySelector("[data-aksrt-return-ship-form]");
|
||||
function openReturnShipModal(ticketId) {
|
||||
if (!returnShipModal || !returnShipForm) return;
|
||||
returnShipForm.reset();
|
||||
var idInput = returnShipForm.querySelector('[name="ticket_id"]');
|
||||
if (idInput) idInput.value = ticketId || "";
|
||||
returnShipModal.hidden = false;
|
||||
}
|
||||
function closeReturnShipModal() {
|
||||
if (returnShipModal) returnShipModal.hidden = true;
|
||||
}
|
||||
if (returnShipForm) {
|
||||
returnShipForm.addEventListener("submit", function (event) {
|
||||
event.preventDefault();
|
||||
var fd = new FormData(returnShipForm);
|
||||
var tid = fd.get("ticket_id");
|
||||
var carrier = (fd.get("carrier") || "").trim();
|
||||
var tracking = (fd.get("tracking_no") || "").trim();
|
||||
if (!carrier || !tracking) {
|
||||
showToast("请填写快递公司与运单号");
|
||||
return;
|
||||
}
|
||||
var btn = returnShipForm.querySelector('button[type="submit"]');
|
||||
if (btn) { btn.disabled = true; }
|
||||
request("/shop/frontend/after-sale/" + tid + "/return-shipping", {
|
||||
carrier: carrier,
|
||||
tracking_no: tracking
|
||||
}).then(function (result) {
|
||||
if (btn) btn.disabled = false;
|
||||
if (!result || !result.success) {
|
||||
showToast((result && result.message) || "提交失败");
|
||||
return;
|
||||
}
|
||||
closeReturnShipModal();
|
||||
showToast(result.message || "退货物流已提交");
|
||||
window.location.reload();
|
||||
}).catch(function () {
|
||||
if (btn) btn.disabled = false;
|
||||
showToast("网络错误");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Coupon wallet ── */
|
||||
var couponRoot = page.querySelector("[data-aksrt-coupon-wallet-list]");
|
||||
if (couponRoot) {
|
||||
|
||||
@@ -1,295 +0,0 @@
|
||||
/**
|
||||
* Product archive — cat filter + sort + AJAX load more (zibll ajax-next UX).
|
||||
*
|
||||
* Uses REST /shop/frontend/products/archive and optional AksrtAjaxList events.
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var root = document.querySelector("[data-aksrt-product-archive]");
|
||||
if (!root) return;
|
||||
|
||||
function restBase() {
|
||||
var site = window.aksrtData && aksrtData.siteUrl ? String(aksrtData.siteUrl).replace(/\/$/, "") : "";
|
||||
return site + "/wp-json/aksrt/v1";
|
||||
}
|
||||
|
||||
function toast(msg) {
|
||||
if (typeof window.aksrtToast === "function") {
|
||||
window.aksrtToast(msg);
|
||||
return;
|
||||
}
|
||||
if (window.console) console.log(msg);
|
||||
}
|
||||
|
||||
var grid = root.querySelector("[data-aksrt-product-grid], #aksrt-product-grid");
|
||||
var pager = root.querySelector("[data-aksrt-ajaxpager], .ajaxpager");
|
||||
var countEl = root.querySelector("[data-aksrt-archive-count]");
|
||||
var headerHost = root.querySelector("[data-aksrt-ajax-header]");
|
||||
var loadMoreBtn = root.querySelector("[data-aksrt-shop-load-more]");
|
||||
var loader = root.querySelector("[data-aksrt-ajax-loader], .post_ajax_loader");
|
||||
|
||||
var state = {
|
||||
taxonomy: root.getAttribute("data-taxonomy") || "",
|
||||
termId: Number(root.getAttribute("data-term-id") || 0),
|
||||
orderby: root.getAttribute("data-orderby") || "date",
|
||||
order: (root.getAttribute("data-order") || "DESC").toUpperCase(),
|
||||
page: Number(root.getAttribute("data-page") || 1),
|
||||
maxPages: Number(root.getAttribute("data-max-pages") || 1),
|
||||
paging: root.getAttribute("data-paging") || "number",
|
||||
ias: root.getAttribute("data-ias") === "1",
|
||||
iasMax: Number(root.getAttribute("data-ias-max") || 5),
|
||||
loading: false,
|
||||
autoLoads: 0,
|
||||
};
|
||||
|
||||
function showLoader(on) {
|
||||
if (loader) {
|
||||
loader.hidden = !on;
|
||||
loader.style.display = on ? "" : "none";
|
||||
loader.classList.toggle("is-loading", !!on);
|
||||
}
|
||||
if (pager) pager.classList.toggle("is-ajax-loading", !!on);
|
||||
}
|
||||
|
||||
function updateLoadMore(hasMore) {
|
||||
var pag = root.querySelector(".ajax-pag");
|
||||
if (loadMoreBtn) {
|
||||
loadMoreBtn.style.display = hasMore ? "" : "none";
|
||||
loadMoreBtn.disabled = false;
|
||||
loadMoreBtn.textContent = hasMore ? "加载更多" : "没有更多了";
|
||||
if (!hasMore) loadMoreBtn.classList.add("ajax-nomore");
|
||||
else loadMoreBtn.classList.remove("ajax-nomore");
|
||||
}
|
||||
if (pag) pag.style.display = hasMore ? "" : "none";
|
||||
}
|
||||
|
||||
function syncOrderbyActive() {
|
||||
root.querySelectorAll("[data-aksrt-shop-orderby]").forEach(function (a) {
|
||||
var key = a.getAttribute("data-aksrt-shop-orderby") || "";
|
||||
var active = key === state.orderby;
|
||||
a.classList.toggle("is-active", active);
|
||||
a.classList.toggle("focus-color", active && key === "price");
|
||||
});
|
||||
}
|
||||
|
||||
function fetchArchive(page, orderby, order, replace, termId) {
|
||||
if (state.loading) return Promise.resolve();
|
||||
state.loading = true;
|
||||
showLoader(true);
|
||||
if (loadMoreBtn) {
|
||||
loadMoreBtn.disabled = true;
|
||||
loadMoreBtn.textContent = "加载中…";
|
||||
}
|
||||
|
||||
var qs = new URLSearchParams();
|
||||
qs.set("taxonomy", state.taxonomy);
|
||||
qs.set("term_id", String(typeof termId === "number" ? termId : state.termId));
|
||||
qs.set("page", String(page));
|
||||
qs.set("orderby", orderby || state.orderby);
|
||||
if ((orderby || state.orderby) === "price") {
|
||||
qs.set("order", order || state.order || "ASC");
|
||||
}
|
||||
|
||||
var container = pager || grid || root;
|
||||
try {
|
||||
container.dispatchEvent(
|
||||
new CustomEvent("post_ajax.before", {
|
||||
bubbles: true,
|
||||
detail: { page: page, orderby: orderby || state.orderby, replace: !!replace },
|
||||
})
|
||||
);
|
||||
} catch (e0) {}
|
||||
|
||||
return fetch(restBase() + "/shop/frontend/products/archive?" + qs.toString(), {
|
||||
method: "GET",
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"X-WP-Nonce": (window.aksrtData && aksrtData.restNonce) || "",
|
||||
"X-AKSRT-Ajax": "list",
|
||||
},
|
||||
})
|
||||
.then(function (r) {
|
||||
return r.json();
|
||||
})
|
||||
.then(function (result) {
|
||||
state.loading = false;
|
||||
showLoader(false);
|
||||
if (!result || !result.success || !result.data) {
|
||||
toast((result && result.message) || "加载失败");
|
||||
updateLoadMore(state.page < state.maxPages);
|
||||
return;
|
||||
}
|
||||
var data = result.data;
|
||||
state.page = Number(data.page || page);
|
||||
state.maxPages = Number(data.max_pages || 1);
|
||||
state.orderby = data.orderby || state.orderby;
|
||||
state.order = String(data.order || state.order || "DESC").toUpperCase();
|
||||
if (typeof termId === "number" && termId > 0) {
|
||||
state.termId = termId;
|
||||
}
|
||||
if (data.term && data.term.id) {
|
||||
state.termId = Number(data.term.id);
|
||||
}
|
||||
root.setAttribute("data-page", String(state.page));
|
||||
root.setAttribute("data-max-pages", String(state.maxPages));
|
||||
root.setAttribute("data-orderby", state.orderby);
|
||||
root.setAttribute("data-order", state.order);
|
||||
root.setAttribute("data-term-id", String(state.termId));
|
||||
|
||||
if (countEl && typeof data.total !== "undefined") {
|
||||
countEl.textContent = "共 " + data.total + " 件商品";
|
||||
}
|
||||
var titleEl = root.querySelector("[data-aksrt-archive-title]");
|
||||
if (titleEl && data.term && data.term.name) {
|
||||
titleEl.textContent = data.term.name;
|
||||
}
|
||||
var descEl = root.querySelector("[data-aksrt-archive-desc]");
|
||||
if (descEl && data.term) {
|
||||
descEl.textContent = data.term.description || "";
|
||||
descEl.style.display = data.term.description ? "" : "none";
|
||||
}
|
||||
|
||||
if (headerHost && data.header_html) {
|
||||
// Keep title/count; replace filter widget if present.
|
||||
var existingFilter = headerHost.querySelector("[data-aksrt-shop-cat-filter], .shop-cat-filter");
|
||||
var tmp = document.createElement("div");
|
||||
tmp.innerHTML = data.header_html;
|
||||
var nextFilter = tmp.querySelector("[data-aksrt-shop-cat-filter], .shop-cat-filter") || tmp.firstElementChild;
|
||||
if (existingFilter && nextFilter) {
|
||||
existingFilter.replaceWith(nextFilter);
|
||||
} else if (!existingFilter && nextFilter) {
|
||||
headerHost.appendChild(nextFilter);
|
||||
}
|
||||
}
|
||||
|
||||
var html = data.html || data.empty_html || "";
|
||||
if (grid) {
|
||||
if (replace) {
|
||||
grid.innerHTML = html;
|
||||
} else if (html) {
|
||||
grid.insertAdjacentHTML("beforeend", html);
|
||||
}
|
||||
}
|
||||
|
||||
if (data.paginate_html) {
|
||||
var oldPag = root.querySelector(".ajax-pag");
|
||||
var wrap = document.createElement("div");
|
||||
wrap.innerHTML = data.paginate_html;
|
||||
var nextPag = wrap.firstElementChild;
|
||||
if (oldPag && nextPag) oldPag.replaceWith(nextPag);
|
||||
else if (!oldPag && nextPag && pager) pager.appendChild(nextPag);
|
||||
loadMoreBtn = root.querySelector("[data-aksrt-shop-load-more]");
|
||||
}
|
||||
|
||||
syncOrderbyActive();
|
||||
updateLoadMore(!!data.has_more || state.page < state.maxPages);
|
||||
|
||||
try {
|
||||
container.dispatchEvent(
|
||||
new CustomEvent("post_ajax.ed", {
|
||||
bubbles: true,
|
||||
detail: { page: state.page, replace: !!replace, data: data },
|
||||
})
|
||||
);
|
||||
document.dispatchEvent(
|
||||
new CustomEvent("aksrt:posts:rendered", {
|
||||
bubbles: true,
|
||||
detail: { container: grid, replace: !!replace },
|
||||
})
|
||||
);
|
||||
} catch (e1) {}
|
||||
})
|
||||
.catch(function () {
|
||||
state.loading = false;
|
||||
showLoader(false);
|
||||
updateLoadMore(state.page < state.maxPages);
|
||||
toast("网络错误");
|
||||
});
|
||||
}
|
||||
|
||||
function pushUrl(orderby, order, termUrl) {
|
||||
try {
|
||||
var u = termUrl ? new URL(termUrl, window.location.origin) : new URL(window.location.href);
|
||||
if (orderby) u.searchParams.set("orderby", orderby);
|
||||
else u.searchParams.delete("orderby");
|
||||
u.searchParams.delete("shop_orderby");
|
||||
if (orderby === "price" && order) u.searchParams.set("order", order);
|
||||
else u.searchParams.delete("order");
|
||||
u.searchParams.delete("paged");
|
||||
window.history.replaceState({}, "", u.toString());
|
||||
} catch (err) {}
|
||||
}
|
||||
|
||||
root.addEventListener("click", function (e) {
|
||||
// Category / sort links with ajax-next — intercept for REST (richer payload).
|
||||
var termLink = e.target.closest("[data-aksrt-shop-term].ajax-next, a.ajax-next[data-aksrt-shop-term]");
|
||||
if (termLink && root.contains(termLink)) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
var tid = Number(termLink.getAttribute("data-aksrt-shop-term") || 0);
|
||||
var href = termLink.getAttribute("href") || "";
|
||||
state.autoLoads = 0;
|
||||
if (tid > 0) state.termId = tid;
|
||||
pushUrl(state.orderby, state.order, href);
|
||||
var rt = termLink.getAttribute("route-title");
|
||||
if (rt) {
|
||||
try {
|
||||
document.title = rt;
|
||||
} catch (e2) {}
|
||||
}
|
||||
fetchArchive(1, state.orderby, state.order, true, tid);
|
||||
return;
|
||||
}
|
||||
|
||||
var sort = e.target.closest("[data-aksrt-shop-orderby]");
|
||||
if (sort && root.contains(sort) && sort.tagName === "A") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
var orderby = sort.getAttribute("data-aksrt-shop-orderby") || "date";
|
||||
var nextOrder = sort.getAttribute("data-aksrt-shop-order") || "";
|
||||
if (orderby === "price") {
|
||||
if (state.orderby === "price" && nextOrder) {
|
||||
state.order = nextOrder.toUpperCase();
|
||||
} else if (state.orderby !== "price") {
|
||||
state.order = "ASC";
|
||||
} else {
|
||||
state.order = state.order === "ASC" ? "DESC" : "ASC";
|
||||
}
|
||||
}
|
||||
if (orderby === state.orderby && orderby !== "price" && state.paging === "ajax") return;
|
||||
state.orderby = orderby;
|
||||
state.autoLoads = 0;
|
||||
pushUrl(orderby, state.order);
|
||||
fetchArchive(1, orderby, state.order, true);
|
||||
return;
|
||||
}
|
||||
|
||||
var more = e.target.closest("[data-aksrt-shop-load-more]");
|
||||
if (more && root.contains(more)) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (state.page >= state.maxPages) return;
|
||||
fetchArchive(state.page + 1, state.orderby, state.order, false);
|
||||
}
|
||||
});
|
||||
|
||||
if (state.paging === "ajax" && state.ias && "IntersectionObserver" in window && loadMoreBtn) {
|
||||
var io = new IntersectionObserver(
|
||||
function (entries) {
|
||||
entries.forEach(function (entry) {
|
||||
if (!entry.isIntersecting) return;
|
||||
if (state.loading || state.page >= state.maxPages) return;
|
||||
if (state.iasMax > 0 && state.autoLoads >= state.iasMax) return;
|
||||
state.autoLoads += 1;
|
||||
fetchArchive(state.page + 1, state.orderby, state.order, false);
|
||||
});
|
||||
},
|
||||
{ rootMargin: "240px" }
|
||||
);
|
||||
io.observe(loadMoreBtn);
|
||||
}
|
||||
})();
|
||||
@@ -1,407 +0,0 @@
|
||||
/**
|
||||
* Product purchase page — options selector + server cart.
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
function restBase() {
|
||||
var root = (window.aksrtData && aksrtData.siteUrl) ? String(aksrtData.siteUrl).replace(/\/$/, "") : "";
|
||||
return root + "/wp-json/aksrt/v1";
|
||||
}
|
||||
|
||||
function restNonce() {
|
||||
return (window.aksrtData && aksrtData.restNonce) || "";
|
||||
}
|
||||
|
||||
function toast(msg, type) {
|
||||
if (typeof window.aksrtToast === "function") {
|
||||
window.aksrtToast(msg, type || "info");
|
||||
return;
|
||||
}
|
||||
if (window.aksrt && typeof aksrt.notyf === "function") {
|
||||
aksrt.notyf(msg, { type: type || "info" });
|
||||
return;
|
||||
}
|
||||
window.alert(msg);
|
||||
}
|
||||
|
||||
function rest(path, method, body) {
|
||||
var opts = {
|
||||
method: method || "GET",
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
"X-WP-Nonce": restNonce(),
|
||||
},
|
||||
};
|
||||
if (body !== undefined) {
|
||||
opts.headers["Content-Type"] = "application/json";
|
||||
opts.body = JSON.stringify(body);
|
||||
}
|
||||
return fetch(restBase() + path, opts).then(function (r) {
|
||||
return r.json();
|
||||
});
|
||||
}
|
||||
|
||||
function money(value, payModo) {
|
||||
var n = Number(value || 0).toFixed(2);
|
||||
return payModo === "points" ? n : "¥" + n;
|
||||
}
|
||||
|
||||
/* ── Product page ── */
|
||||
var productPage = document.querySelector("[data-aksrt-product-page]");
|
||||
if (productPage) {
|
||||
var quantityInput = productPage.querySelector("[data-aksrt-quantity]");
|
||||
var productId = Number(productPage.getAttribute("data-product-id") || 0);
|
||||
var titleEl = productPage.querySelector(".aksrt-product-page__title");
|
||||
var imageEl = productPage.querySelector(".aksrt-product-page__primary-image img");
|
||||
var priceEl = productPage.querySelector("[data-aksrt-dynamic-price], .aksrt-product-page__price");
|
||||
var stockEl = productPage.querySelector("[data-aksrt-stock-display]");
|
||||
var couponEl = productPage.querySelector("[data-aksrt-product-coupon]");
|
||||
var optionsRoot = productPage.querySelector("[data-aksrt-options-selector]");
|
||||
var optionResult = productPage.querySelector("[data-aksrt-option-result]");
|
||||
var selectedLabelsEl = productPage.querySelector("[data-aksrt-selected-labels]");
|
||||
var resolvedPriceEl = productPage.querySelector("[data-aksrt-resolved-price]");
|
||||
var resolvedStockEl = productPage.querySelector("[data-aksrt-resolved-stock]");
|
||||
var payModo = productPage.getAttribute("data-pay-modo") || "0";
|
||||
var basePrice = Number(productPage.getAttribute("data-base-price") || 0);
|
||||
var baseStock = Number(productPage.getAttribute("data-base-stock") || 0);
|
||||
var selectedByGroup = {};
|
||||
var groupCount = optionsRoot
|
||||
? optionsRoot.querySelectorAll(".aksrt-product-page__option-group").length
|
||||
: 0;
|
||||
|
||||
function currentOptionsKey() {
|
||||
if (!groupCount) return "";
|
||||
var labels = [];
|
||||
for (var i = 0; i < groupCount; i++) {
|
||||
if (!selectedByGroup[i]) return null;
|
||||
labels.push(selectedByGroup[i].label);
|
||||
}
|
||||
return labels.join("|");
|
||||
}
|
||||
|
||||
function resolveSelection() {
|
||||
if (!groupCount) {
|
||||
return {
|
||||
key: "",
|
||||
labels: "",
|
||||
price: basePrice,
|
||||
stock: baseStock,
|
||||
image: "",
|
||||
};
|
||||
}
|
||||
var key = currentOptionsKey();
|
||||
if (key === null) return null;
|
||||
|
||||
var price = basePrice;
|
||||
var image = "";
|
||||
var labels = [];
|
||||
for (var i = 0; i < groupCount; i++) {
|
||||
var sel = selectedByGroup[i];
|
||||
labels.push(sel.label);
|
||||
price += Number(sel.priceChange || 0);
|
||||
if (sel.image) image = sel.image;
|
||||
}
|
||||
return {
|
||||
key: key,
|
||||
labels: labels.join(" / "),
|
||||
price: Math.max(0, price),
|
||||
stock: baseStock,
|
||||
image: image,
|
||||
};
|
||||
}
|
||||
|
||||
function paintSelection() {
|
||||
var resolved = resolveSelection();
|
||||
if (!resolved) {
|
||||
if (optionResult) optionResult.hidden = true;
|
||||
return;
|
||||
}
|
||||
if (priceEl) {
|
||||
if (groupCount && productPage.getAttribute("data-price-range") === "1" && currentOptionsKey() === null) {
|
||||
/* keep range until complete selection */
|
||||
} else {
|
||||
priceEl.textContent = money(resolved.price, payModo) + (payModo === "points" ? "" : "");
|
||||
if (payModo === "points" && priceEl.querySelector("small") === null) {
|
||||
/* leave as-is */
|
||||
}
|
||||
}
|
||||
}
|
||||
if (stockEl && resolved.stock > -1) {
|
||||
stockEl.textContent = String(resolved.stock);
|
||||
}
|
||||
if (quantityInput && resolved.stock > 0) {
|
||||
quantityInput.setAttribute("max", String(resolved.stock));
|
||||
if (Number(quantityInput.value) > resolved.stock) {
|
||||
quantityInput.value = String(resolved.stock);
|
||||
}
|
||||
}
|
||||
if (imageEl && resolved.image) {
|
||||
imageEl.setAttribute("src", resolved.image);
|
||||
}
|
||||
if (optionResult) {
|
||||
optionResult.hidden = false;
|
||||
if (selectedLabelsEl) selectedLabelsEl.textContent = resolved.labels;
|
||||
if (resolvedPriceEl) resolvedPriceEl.textContent = money(resolved.price, payModo);
|
||||
if (resolvedStockEl) {
|
||||
resolvedStockEl.textContent =
|
||||
resolved.stock <= -1 ? "库存充足" : "库存 " + resolved.stock;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (optionsRoot) {
|
||||
optionsRoot.addEventListener("click", function (event) {
|
||||
var btn = event.target.closest("[data-option-group]");
|
||||
if (!btn || !optionsRoot.contains(btn)) return;
|
||||
var group = btn.getAttribute("data-option-group");
|
||||
var label = btn.getAttribute("data-option-label") || "";
|
||||
var priceChange = Number(btn.getAttribute("data-price-change") || 0);
|
||||
var image = btn.getAttribute("data-option-image") || "";
|
||||
|
||||
optionsRoot
|
||||
.querySelectorAll('[data-option-group="' + group + '"]')
|
||||
.forEach(function (el) {
|
||||
el.classList.remove("is-active");
|
||||
});
|
||||
btn.classList.add("is-active");
|
||||
selectedByGroup[group] = { label: label, priceChange: priceChange, image: image };
|
||||
paintSelection();
|
||||
});
|
||||
}
|
||||
|
||||
function ensureOptionsSelected() {
|
||||
if (!groupCount) return true;
|
||||
if (currentOptionsKey() === null) {
|
||||
toast("请先选择商品规格", "warning");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function addToServerCart(replaceQty) {
|
||||
if (!window.aksrtData || !aksrtData.isLoggedIn) {
|
||||
window.location.href = (aksrtData && aksrtData.routes && aksrtData.routes.login) || "/";
|
||||
return;
|
||||
}
|
||||
if (!ensureOptionsSelected()) return;
|
||||
|
||||
var qty = Number(quantityInput ? quantityInput.value : 1) || 1;
|
||||
var optionsString = currentOptionsKey() || "";
|
||||
var body = {
|
||||
product_id: productId,
|
||||
options_string: optionsString,
|
||||
count: qty,
|
||||
};
|
||||
|
||||
rest("/shop/frontend/cart/add", "POST", body)
|
||||
.then(function (result) {
|
||||
if (!result || !result.success) {
|
||||
toast((result && (result.message || result.error)) || "加入购物车失败", "error");
|
||||
return;
|
||||
}
|
||||
toast("已加入购物车", "success");
|
||||
if (replaceQty) {
|
||||
var cartUrl = new URL(
|
||||
(window.aksrtData && (aksrtData.cartUrl || (aksrtData.routes && aksrtData.routes.cart))) || "/",
|
||||
window.location.origin
|
||||
);
|
||||
if (couponEl && couponEl.value) {
|
||||
cartUrl.searchParams.set("coupon", couponEl.value);
|
||||
}
|
||||
window.location.href = cartUrl.toString();
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
toast("网络错误,请稍后重试", "error");
|
||||
});
|
||||
}
|
||||
|
||||
productPage.addEventListener("click", function (event) {
|
||||
var qtyBtn = event.target.closest("[data-aksrt-qty-action]");
|
||||
if (qtyBtn && quantityInput) {
|
||||
var action = qtyBtn.getAttribute("data-aksrt-qty-action");
|
||||
var current = Number(quantityInput.value || 1);
|
||||
var max = Number(quantityInput.getAttribute("max") || 999);
|
||||
quantityInput.value = action === "inc" ? Math.min(max, current + 1) : Math.max(1, current - 1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.target.closest("[data-aksrt-add-cart]")) {
|
||||
addToServerCart(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.target.closest("[data-aksrt-buy-now]")) {
|
||||
addToServerCart(true);
|
||||
return;
|
||||
}
|
||||
|
||||
var thumb = event.target.closest("[data-aksrt-product-thumb]");
|
||||
if (thumb && imageEl) {
|
||||
imageEl.setAttribute("src", thumb.getAttribute("data-aksrt-product-thumb"));
|
||||
return;
|
||||
}
|
||||
|
||||
var videoBtn = event.target.closest("[data-aksrt-product-video]");
|
||||
if (videoBtn) {
|
||||
var url = videoBtn.getAttribute("data-aksrt-product-video");
|
||||
if (url) window.open(url, "_blank", "noopener");
|
||||
return;
|
||||
}
|
||||
|
||||
var favBtn = event.target.closest("[data-aksrt-toggle-fav]");
|
||||
if (favBtn) {
|
||||
if (!window.aksrtData || !aksrtData.isLoggedIn) {
|
||||
window.location.href = (aksrtData && aksrtData.routes && aksrtData.routes.login) || "/";
|
||||
return;
|
||||
}
|
||||
var pid = Number(favBtn.getAttribute("data-aksrt-toggle-fav") || productId);
|
||||
rest("/shop/frontend/products/" + pid + "/favorite", "POST", {})
|
||||
.then(function (result) {
|
||||
if (!result || !result.success) {
|
||||
toast((result && result.message) || "操作失败", "error");
|
||||
return;
|
||||
}
|
||||
var active = !!(result.data && result.data.is_favorite);
|
||||
productPage.querySelectorAll("[data-aksrt-toggle-fav]").forEach(function (btn) {
|
||||
btn.classList.toggle("is-active", active);
|
||||
});
|
||||
toast((result.message) || (active ? "已收藏" : "已取消收藏"), "success");
|
||||
})
|
||||
.catch(function () {
|
||||
toast("网络错误", "error");
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
var tabBtn = event.target.closest("[data-aksrt-product-tab]");
|
||||
if (tabBtn) {
|
||||
var tab = tabBtn.getAttribute("data-aksrt-product-tab");
|
||||
productPage.querySelectorAll("[data-aksrt-product-tab]").forEach(function (b) {
|
||||
b.classList.toggle("is-active", b === tabBtn);
|
||||
});
|
||||
productPage.querySelectorAll("[data-aksrt-product-panel]").forEach(function (panel) {
|
||||
panel.hidden = panel.getAttribute("data-aksrt-product-panel") !== tab;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.target.closest("[data-aksrt-show-params]")) {
|
||||
var paramsModal = productPage.querySelector("[data-aksrt-params-modal]");
|
||||
if (paramsModal) paramsModal.hidden = false;
|
||||
return;
|
||||
}
|
||||
if (event.target.closest("[data-aksrt-show-services]")) {
|
||||
var servicesModal = productPage.querySelector("[data-aksrt-services-modal]");
|
||||
if (servicesModal) servicesModal.hidden = false;
|
||||
return;
|
||||
}
|
||||
if (event.target.closest("[data-aksrt-close-modal]")) {
|
||||
var modal = event.target.closest("[data-aksrt-params-modal], [data-aksrt-services-modal], .aksrt-modal");
|
||||
if (modal) modal.hidden = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Product editor (seller) ── */
|
||||
var editor = document.querySelector("[data-aksrt-product-editor]");
|
||||
if (!editor || !window.aksrtData) {
|
||||
return;
|
||||
}
|
||||
|
||||
var form = editor.querySelector("[data-aksrt-product-form]");
|
||||
var messageEl = editor.querySelector("[data-aksrt-product-editor-message]");
|
||||
if (!form) return;
|
||||
|
||||
function selectedMultiValues(select) {
|
||||
if (!select) return [];
|
||||
return Array.prototype.slice
|
||||
.call(select.options)
|
||||
.filter(function (option) { return option.selected; })
|
||||
.map(function (option) { return Number(option.value || 0); })
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function parseOptionsText(text) {
|
||||
var groups = [];
|
||||
String(text || "").split(String.fromCharCode(10)).forEach(function (line) {
|
||||
line = line.replace(/\r/g, "").trim();
|
||||
if (!line) return;
|
||||
var parts = line.split("|");
|
||||
if (parts.length < 2) return;
|
||||
var name = parts[0].trim();
|
||||
var values = [];
|
||||
parts.slice(1).join("|").split(",").forEach(function (chunk) {
|
||||
chunk = chunk.trim();
|
||||
if (!chunk) return;
|
||||
var segs = chunk.split(":");
|
||||
var label = (segs[0] || "").trim();
|
||||
if (!label) return;
|
||||
values.push({ label: label, price_change: Number(segs[1] || 0), image: "" });
|
||||
});
|
||||
if (name && values.length) {
|
||||
groups.push({ name: name, view_mode: "list", values: values });
|
||||
}
|
||||
});
|
||||
return groups;
|
||||
}
|
||||
|
||||
form.addEventListener("submit", function (event) {
|
||||
event.preventDefault();
|
||||
|
||||
var formData = new FormData(form);
|
||||
var productId = Number(editor.getAttribute("data-product-id") || 0);
|
||||
var gallery = String(formData.get("gallery") || "")
|
||||
.split(String.fromCharCode(10))
|
||||
.map(function (line) { return line.replace(/\r/g, "").trim(); })
|
||||
.filter(Boolean);
|
||||
|
||||
var payload = {
|
||||
title: String(formData.get("title") || "").trim(),
|
||||
price: Number(formData.get("price") || 0),
|
||||
stock: Number(formData.get("stock") || 0),
|
||||
excerpt: String(formData.get("excerpt") || ""),
|
||||
content: String(formData.get("content") || ""),
|
||||
status: String(formData.get("status") || "draft"),
|
||||
categories: selectedMultiValues(form.querySelector('select[name="categories[]"]')),
|
||||
tags: String(formData.get("tags") || "")
|
||||
.split(",")
|
||||
.map(function (tag) { return tag.trim(); })
|
||||
.filter(Boolean),
|
||||
image: String(formData.get("image") || "").trim(),
|
||||
gallery: gallery,
|
||||
shipping_mode: String(formData.get("shipping_mode") || "express"),
|
||||
shipping_fee: Number(formData.get("shipping_fee") || 0),
|
||||
after_sale_policy: String(formData.get("after_sale_policy") || ""),
|
||||
support_refund: formData.get("support_refund") === "1",
|
||||
options: parseOptionsText(formData.get("options_text") || ""),
|
||||
start_price: Number(formData.get("start_price") || 0),
|
||||
pay_modo: String(formData.get("pay_modo") || "0"),
|
||||
product_params: String(formData.get("product_params") || ""),
|
||||
product_video: String(formData.get("product_video") || "")
|
||||
.split(String.fromCharCode(10))
|
||||
.map(function (line) { return line.replace(/\r/g, "").trim(); })
|
||||
.filter(Boolean)
|
||||
};
|
||||
|
||||
var path = productId > 0 ? "/shop/frontend/products/" + productId : "/shop/frontend/products";
|
||||
if (messageEl) messageEl.textContent = "正在保存商品...";
|
||||
|
||||
rest(path, "POST", payload)
|
||||
.then(function (result) {
|
||||
if (!result.success) {
|
||||
if (messageEl) messageEl.textContent = result.message || "保存失败。";
|
||||
return;
|
||||
}
|
||||
var centerUrl = aksrtData.userCenterUrl || "/";
|
||||
if (messageEl) messageEl.textContent = "商品已保存,正在返回用户中心...";
|
||||
window.location.href = centerUrl;
|
||||
})
|
||||
.catch(function () {
|
||||
if (messageEl) messageEl.textContent = "网络错误,请稍后重试。";
|
||||
});
|
||||
});
|
||||
})();
|
||||
+1
-35
@@ -111,7 +111,7 @@ add_filter('query_vars', function ($vars) {
|
||||
|
||||
// 主题切换离开时:清理本主题注册的 cron 任务
|
||||
add_action('switch_theme', function () {
|
||||
$hooks = ['aksrt_dl_token_cleanup', 'aksrt_email_verification_backfill_batch', 'aksrt_notification_cleanup', 'aksrt_reconcile_refunds', 'aksrt_after_sale_auto_cancel'];
|
||||
$hooks = ['aksrt_dl_token_cleanup', 'aksrt_email_verification_backfill_batch', 'aksrt_notification_cleanup'];
|
||||
foreach ($hooks as $hook) {
|
||||
$next = wp_next_scheduled($hook);
|
||||
while ($next) {
|
||||
@@ -318,10 +318,6 @@ if (class_exists('\AKSRT\Payment\Order')) {
|
||||
\AKSRT\Payment\Order::get_instance()->init();
|
||||
}
|
||||
|
||||
if (class_exists('\AKSRT\Payment\AfterSale')) {
|
||||
\AKSRT\Payment\AfterSale::get_instance()->init();
|
||||
}
|
||||
|
||||
// Register built-in payment gateways
|
||||
if (class_exists('\AKSRT\Payment\Gateway')) {
|
||||
$gw = \AKSRT\Payment\Gateway::get_instance();
|
||||
@@ -358,30 +354,6 @@ if (class_exists('\AKSRT\Core\TermMeta')) {
|
||||
\AKSRT\Core\TermMeta::get_instance()->init();
|
||||
}
|
||||
|
||||
// 商城 taxonomy term meta + shop_discount 优惠活动
|
||||
if (class_exists('\AKSRT\Shop\TermMeta')) {
|
||||
\AKSRT\Shop\TermMeta::get_instance()->init();
|
||||
}
|
||||
|
||||
// 商品后台列表自定义列(销量/综合数据/商家/优惠活动)
|
||||
if (is_admin() && class_exists('\AKSRT\Admin\ShopController')) {
|
||||
\AKSRT\Admin\ShopController::get_instance()->init_product_admin_columns();
|
||||
}
|
||||
|
||||
// 商品原生编辑 meta box(价格/库存/发货售后)
|
||||
if (is_admin() && class_exists('\AKSRT\Shop\ProductMetaBox')) {
|
||||
\AKSRT\Shop\ProductMetaBox::get_instance()->init();
|
||||
}
|
||||
// 商品原生变体 meta box
|
||||
if (is_admin() && class_exists('\AKSRT\Shop\ProductVariantsMetaBox')) {
|
||||
\AKSRT\Shop\ProductVariantsMetaBox::get_instance()->init();
|
||||
}
|
||||
|
||||
// 商品评价/评分系统
|
||||
if (class_exists('\AKSRT\Shop\ProductReview')) {
|
||||
\AKSRT\Shop\ProductReview::get_instance()->init();
|
||||
}
|
||||
|
||||
// 文章局部配置 meta box(仅后台)
|
||||
if (is_admin() && class_exists('\AKSRT\Core\PostMetaBox')) {
|
||||
\AKSRT\Core\PostMetaBox::get_instance()->init();
|
||||
@@ -434,12 +406,6 @@ if (is_admin() && class_exists('\AKSRT\Admin\Native\PayCoupons')) {
|
||||
if (is_admin() && class_exists('\AKSRT\Admin\Native\PayChargeCards')) {
|
||||
\AKSRT\Admin\Native\PayChargeCards::get_instance()->init();
|
||||
}
|
||||
if (is_admin() && class_exists('\AKSRT\Admin\Native\PayShipping')) {
|
||||
\AKSRT\Admin\Native\PayShipping::get_instance()->init();
|
||||
}
|
||||
if (is_admin() && class_exists('\AKSRT\Admin\Native\PayAfterSale')) {
|
||||
\AKSRT\Admin\Native\PayAfterSale::get_instance()->init();
|
||||
}
|
||||
if (is_admin() && class_exists('\AKSRT\Admin\Native\PayOrders')) {
|
||||
\AKSRT\Admin\Native\PayOrders::get_instance()->init();
|
||||
}
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Commerce center (cart / orders / revenue) presentation helpers.
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
if (!function_exists('aksrt_get_commerce_nav_items')) {
|
||||
/**
|
||||
* Secondary nav for commerce center pages.
|
||||
*
|
||||
* @return array<int, array{key:string,label:string,url:string}>
|
||||
*/
|
||||
function aksrt_get_commerce_nav_items(): array {
|
||||
$items = [];
|
||||
|
||||
$cart = function_exists('aksrt_get_cart_page_url') ? aksrt_get_cart_page_url() : '';
|
||||
if ($cart !== '') {
|
||||
$items[] = [
|
||||
'key' => 'cart',
|
||||
'label' => __('购物车', 'aksrt'),
|
||||
'url' => $cart,
|
||||
];
|
||||
}
|
||||
|
||||
$orders = function_exists('aksrt_get_orders_page_url') ? aksrt_get_orders_page_url() : '';
|
||||
if ($orders !== '') {
|
||||
$items[] = [
|
||||
'key' => 'orders',
|
||||
'label' => __('我的订单', 'aksrt'),
|
||||
'url' => $orders,
|
||||
];
|
||||
}
|
||||
|
||||
$revenue = function_exists('aksrt_get_revenue_page_url') ? aksrt_get_revenue_page_url() : '';
|
||||
if ($revenue !== '') {
|
||||
$items[] = [
|
||||
'key' => 'revenue',
|
||||
'label' => __('创作收益', 'aksrt'),
|
||||
'url' => $revenue,
|
||||
];
|
||||
}
|
||||
|
||||
$center = function_exists('aksrt_get_user_center_url') ? aksrt_get_user_center_url() : '';
|
||||
if ($center !== '') {
|
||||
$items[] = [
|
||||
'key' => 'center',
|
||||
'label' => __('个人中心', 'aksrt'),
|
||||
'url' => $center,
|
||||
];
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('aksrt_render_commerce_page_header')) {
|
||||
/**
|
||||
* Shared commerce page header: title / intro / secondary nav.
|
||||
*
|
||||
* @param array{title?:string,subtitle?:string,active?:string} $args
|
||||
*/
|
||||
function aksrt_render_commerce_page_header(array $args = []): void {
|
||||
$title = (string) ($args['title'] ?? '');
|
||||
$subtitle = (string) ($args['subtitle'] ?? '');
|
||||
$active = sanitize_key((string) ($args['active'] ?? ''));
|
||||
$nav = aksrt_get_commerce_nav_items();
|
||||
?>
|
||||
<header class="aksrt-commerce-header">
|
||||
<?php if ($title !== '') : ?>
|
||||
<h1 class="aksrt-commerce-header__title"><?php echo esc_html($title); ?></h1>
|
||||
<?php endif; ?>
|
||||
<?php if ($subtitle !== '') : ?>
|
||||
<p class="aksrt-commerce-header__subtitle"><?php echo esc_html($subtitle); ?></p>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($nav)) : ?>
|
||||
<nav class="aksrt-commerce-header__nav" aria-label="<?php esc_attr_e('交易中心导航', 'aksrt'); ?>">
|
||||
<?php foreach ($nav as $item) :
|
||||
$is_active = $active !== '' && $active === ($item['key'] ?? '');
|
||||
?>
|
||||
<a
|
||||
class="aksrt-commerce-header__nav-item<?php echo $is_active ? ' is-active' : ''; ?>"
|
||||
href="<?php echo esc_url((string) ($item['url'] ?? '#')); ?>"
|
||||
<?php echo $is_active ? ' aria-current="page"' : ''; ?>
|
||||
>
|
||||
<?php echo esc_html((string) ($item['label'] ?? '')); ?>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</nav>
|
||||
<?php endif; ?>
|
||||
</header>
|
||||
<?php
|
||||
}
|
||||
}
|
||||
@@ -17,41 +17,6 @@ function aksrt_get_order_status_labels(): array {
|
||||
return [];
|
||||
}
|
||||
|
||||
function aksrt_get_product_page_view(int $product_id): array {
|
||||
if ($product_id <= 0 || !class_exists('\AKSRT\Shop\Shop')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return \AKSRT\Shop\Shop::get_instance()->get_product_detail_view($product_id);
|
||||
}
|
||||
|
||||
function aksrt_get_product_edit_page_view(int $user_id, int $product_id = 0): array {
|
||||
if ($user_id <= 0 || !class_exists('\AKSRT\Shop\Shop')) {
|
||||
return [
|
||||
'allowed' => false,
|
||||
'is_edit' => false,
|
||||
'product' => [],
|
||||
];
|
||||
}
|
||||
|
||||
return \AKSRT\Shop\Shop::get_instance()->get_product_edit_view($user_id, $product_id);
|
||||
}
|
||||
|
||||
function aksrt_get_cart_page_view(int $user_id): array {
|
||||
if ($user_id <= 0 || !class_exists('\AKSRT\Shop\Shop')) {
|
||||
return [
|
||||
'payment_methods' => [],
|
||||
'shipping_methods' => [],
|
||||
'address_book' => [],
|
||||
'coupon_enabled' => false,
|
||||
'currency_symbol' => '¥',
|
||||
'pending_orders' => [],
|
||||
];
|
||||
}
|
||||
|
||||
return \AKSRT\Shop\Shop::get_instance()->get_cart_page_view($user_id);
|
||||
}
|
||||
|
||||
function aksrt_get_orders_page_view(int $user_id, int $paged = 1, int $per_page = 20, string $status = '', string $product_type = ''): array {
|
||||
$empty = [
|
||||
'items' => [],
|
||||
@@ -79,10 +44,6 @@ function aksrt_get_orders_page_view(int $user_id, int $paged = 1, int $per_page
|
||||
return \AKSRT\Payment\UserOrders::get_instance()->get_order_center_view($user_id, $args);
|
||||
}
|
||||
|
||||
if (class_exists('\AKSRT\Shop\Shop')) {
|
||||
return \AKSRT\Shop\Shop::get_instance()->get_order_center_view($user_id, $args);
|
||||
}
|
||||
|
||||
return $empty;
|
||||
}
|
||||
|
||||
@@ -135,4 +96,4 @@ function aksrt_get_revenue_page_view(int $user_id): array {
|
||||
'total_withdrawn' => $total_withdrawn,
|
||||
'records' => $records,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ $section = sanitize_key((string) ($_GET['section'] ?? ''));
|
||||
if ($tab === '' && isset($_GET['tab']) && sanitize_key((string) $_GET['tab']) !== 'orders') {
|
||||
// When rendered as standalone legacy include, outer tab may be status.
|
||||
$maybe = sanitize_key((string) $_GET['tab']);
|
||||
if (in_array($maybe, ['pending', 'to-ship', 'shipped', 'received', 'review', 'after'], true)) {
|
||||
if (in_array($maybe, ['pending', 'after'], true)) {
|
||||
$tab = $maybe;
|
||||
}
|
||||
}
|
||||
@@ -37,9 +37,6 @@ if (isset($tab_aliases[$tab])) {
|
||||
|
||||
$tab_status_map = [
|
||||
'pending' => \AKSRT\Payment\Order::STATUS_PENDING,
|
||||
'to-ship' => \AKSRT\Payment\Order::STATUS_PAID,
|
||||
'shipped' => \AKSRT\Payment\Order::STATUS_DELIVERED,
|
||||
'review' => \AKSRT\Payment\Order::STATUS_PENDING_REVIEW,
|
||||
'after' => \AKSRT\Payment\Order::STATUS_REFUNDING,
|
||||
];
|
||||
if ($tab !== '' && isset($tab_status_map[$tab])) {
|
||||
@@ -58,15 +55,10 @@ if ($type_options === [] && class_exists('\AKSRT\Core\DomainModel')) {
|
||||
$type_options = array_merge(['' => __('全部类型', 'aksrt')], \AKSRT\Core\DomainModel::order_product_type_labels());
|
||||
}
|
||||
|
||||
$shop = class_exists('\AKSRT\Shop\Shop') ? \AKSRT\Shop\Shop::get_instance() : null;
|
||||
|
||||
$tabs = [
|
||||
'' => __('全部', 'aksrt'),
|
||||
'pending' => __('待支付', 'aksrt'),
|
||||
'to-ship' => __('待发货', 'aksrt'),
|
||||
'shipped' => __('待收货', 'aksrt'),
|
||||
'review' => __('待评价', 'aksrt'),
|
||||
'after' => __('售后', 'aksrt'),
|
||||
'after' => __('退款中', 'aksrt'),
|
||||
];
|
||||
|
||||
$base_query = ['tab' => 'orders'];
|
||||
@@ -115,16 +107,8 @@ $pending_count = (int) ($stats['pending'] ?? 0);
|
||||
<i class="fas fa-receipt uc-orders-panel__title-icon" aria-hidden="true"></i>
|
||||
<?php esc_html_e('我的订单', 'aksrt'); ?>
|
||||
</div>
|
||||
<div class="muted-2-color em09 mt3"><?php esc_html_e('查看商城、付费内容、会员与充值等全部订单。', 'aksrt'); ?></div>
|
||||
<div class="muted-2-color em09 mt3"><?php esc_html_e('查看付费内容、会员与充值等全部订单。', 'aksrt'); ?></div>
|
||||
</div>
|
||||
<?php if (function_exists('aksrt_get_cart_page_url') && aksrt_get_cart_page_url() !== '') : ?>
|
||||
<a class="uc-orders-panel__cart-link" href="<?php echo esc_url(aksrt_get_cart_page_url()); ?>">
|
||||
<i class="fas fa-shopping-cart" aria-hidden="true"></i>
|
||||
<span><?php esc_html_e('购物车', 'aksrt'); ?></span>
|
||||
<i class="fa fa-angle-right em12" aria-hidden="true"></i>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php if (!empty($stats)) : ?>
|
||||
<div class="aksrt-orders-page__stats" data-aksrt-order-stats>
|
||||
@@ -145,9 +129,7 @@ $pending_count = (int) ($stats['pending'] ?? 0);
|
||||
|
||||
<div class="aksrt-orders-page__extra-nav" data-aksrt-orders-extra-nav>
|
||||
<a href="<?php echo esc_url($orders_url(['section' => null])); ?>" class="aksrt-orders-page__extra-link<?php echo $section === '' ? ' is-active' : ''; ?>"><?php esc_html_e('订单列表', 'aksrt'); ?></a>
|
||||
<a href="<?php echo esc_url($orders_url(['section' => 'after-sale'])); ?>" class="aksrt-orders-page__extra-link<?php echo $section === 'after-sale' ? ' is-active' : ''; ?>"><?php esc_html_e('售后工单', 'aksrt'); ?></a>
|
||||
<a href="<?php echo esc_url($orders_url(['section' => 'coupons'])); ?>" class="aksrt-orders-page__extra-link<?php echo $section === 'coupons' ? ' is-active' : ''; ?>"><?php esc_html_e('优惠券', 'aksrt'); ?></a>
|
||||
<a href="<?php echo esc_url($orders_url(['section' => 'favorites'])); ?>" class="aksrt-orders-page__extra-link<?php echo $section === 'favorites' ? ' is-active' : ''; ?>"><?php esc_html_e('收藏', 'aksrt'); ?></a>
|
||||
</div>
|
||||
|
||||
<?php if ($type_options && $section === '') : ?>
|
||||
@@ -304,23 +286,11 @@ $pending_count = (int) ($stats['pending'] ?? 0);
|
||||
<?php if (!empty($actions['can_pay'])) : ?>
|
||||
<button type="button" class="btn-primary btn-sm" data-aksrt-order-pay="<?php echo esc_attr($on); ?>" data-aksrt-order-method="<?php echo esc_attr((string) ($order['payment_method']['value'] ?? '')); ?>"><?php esc_html_e('去支付', 'aksrt'); ?></button>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($actions['can_receipt'])) : ?>
|
||||
<button type="button" class="btn-primary btn-sm" data-aksrt-order-receipt="<?php echo esc_attr($on); ?>"><?php esc_html_e('确认收货', 'aksrt'); ?></button>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($actions['can_review']) || (class_exists('\AKSRT\Payment\Order') && $os === \AKSRT\Payment\Order::STATUS_PENDING_REVIEW)) : ?>
|
||||
<button type="button" class="btn-primary btn-sm c-green" data-aksrt-order-review="<?php echo esc_attr($on); ?>"><?php esc_html_e('评价', 'aksrt'); ?></button>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($actions['can_rebuy'])) : ?>
|
||||
<button type="button" class="btn-outline btn-sm" data-aksrt-order-rebuy="<?php echo esc_attr($on); ?>"><?php esc_html_e('再次购买', 'aksrt'); ?></button>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($actions['can_after_sale'])) : ?>
|
||||
<button type="button" class="btn-outline btn-sm" data-aksrt-order-after-sale="<?php echo esc_attr($on); ?>"><?php esc_html_e('售后', 'aksrt'); ?></button>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($actions['can_view_product']) && !empty($order['permalink'])) : ?>
|
||||
<a class="btn-ghost btn-sm" href="<?php echo esc_url((string) $order['permalink']); ?>"><?php
|
||||
echo (class_exists('\AKSRT\Core\DomainModel') && $ptype === \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE)
|
||||
? esc_html__('去内容', 'aksrt')
|
||||
: esc_html__('商品页', 'aksrt');
|
||||
: esc_html__('查看', 'aksrt');
|
||||
?></a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
@@ -348,7 +318,7 @@ $pending_count = (int) ($stats['pending'] ?? 0);
|
||||
<div class="aksrt-orders-page__empty">
|
||||
<i class="fas fa-receipt" aria-hidden="true"></i>
|
||||
<p><?php esc_html_e('暂无订单记录', 'aksrt'); ?></p>
|
||||
<p class="aksrt-orders-page__empty-hint muted-2-color em09"><?php esc_html_e('去逛逛商城或开通会员,订单会出现在这里。', 'aksrt'); ?></p>
|
||||
<p class="aksrt-orders-page__empty-hint muted-2-color em09"><?php esc_html_e('开通会员或购买付费内容,订单会出现在这里。', 'aksrt'); ?></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
@@ -367,114 +337,6 @@ $pending_count = (int) ($stats['pending'] ?? 0);
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- After-sale modal -->
|
||||
<div class="aksrt-modal" data-aksrt-aftersale-modal hidden>
|
||||
<div class="aksrt-modal__overlay"></div>
|
||||
<div class="aksrt-modal__dialog">
|
||||
<div class="aksrt-modal__header">
|
||||
<h3><?php esc_html_e('申请售后', 'aksrt'); ?></h3>
|
||||
<button type="button" class="aksrt-modal__close" data-aksrt-aftersale-modal-close>×</button>
|
||||
</div>
|
||||
<form class="aksrt-modal__body" data-aksrt-aftersale-form>
|
||||
<input type="hidden" name="order_number" value="">
|
||||
<div class="aksrt-orders-page__field">
|
||||
<label><?php esc_html_e('售后类型', 'aksrt'); ?></label>
|
||||
<select name="type" class="aksrt-orders-page__select">
|
||||
<?php if (class_exists('\AKSRT\Payment\AfterSale')) : ?>
|
||||
<?php foreach (\AKSRT\Payment\AfterSale::get_valid_types() as $type_key => $type_label) : ?>
|
||||
<option value="<?php echo esc_attr($type_key); ?>"><?php echo esc_html($type_label); ?></option>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="aksrt-orders-page__field">
|
||||
<label><?php esc_html_e('售后原因', 'aksrt'); ?> *</label>
|
||||
<textarea name="reason" class="aksrt-orders-page__textarea" rows="4" required placeholder="<?php esc_attr_e('请描述问题', 'aksrt'); ?>"></textarea>
|
||||
</div>
|
||||
<div class="aksrt-modal__footer">
|
||||
<button type="button" class="btn-ghost" data-aksrt-aftersale-modal-close><?php esc_html_e('取消', 'aksrt'); ?></button>
|
||||
<button type="submit" class="btn-primary"><?php esc_html_e('提交', 'aksrt'); ?></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Return shipping modal -->
|
||||
<div class="aksrt-modal" data-aksrt-return-ship-modal hidden>
|
||||
<div class="aksrt-modal__overlay"></div>
|
||||
<div class="aksrt-modal__dialog">
|
||||
<div class="aksrt-modal__header">
|
||||
<h3><?php esc_html_e('填写退货物流', 'aksrt'); ?></h3>
|
||||
<button type="button" class="aksrt-modal__close" data-aksrt-return-ship-close>×</button>
|
||||
</div>
|
||||
<form class="aksrt-modal__body" data-aksrt-return-ship-form>
|
||||
<input type="hidden" name="ticket_id" value="">
|
||||
<div class="aksrt-orders-page__field">
|
||||
<label><?php esc_html_e('快递公司', 'aksrt'); ?> *</label>
|
||||
<input type="text" name="carrier" class="aksrt-orders-page__input" required placeholder="<?php esc_attr_e('如:顺丰速运', 'aksrt'); ?>">
|
||||
</div>
|
||||
<div class="aksrt-orders-page__field">
|
||||
<label><?php esc_html_e('运单号', 'aksrt'); ?> *</label>
|
||||
<input type="text" name="tracking_no" class="aksrt-orders-page__input" required placeholder="<?php esc_attr_e('快递单号', 'aksrt'); ?>">
|
||||
</div>
|
||||
<div class="aksrt-modal__footer">
|
||||
<button type="button" class="btn-ghost" data-aksrt-return-ship-close><?php esc_html_e('取消', 'aksrt'); ?></button>
|
||||
<button type="submit" class="btn-primary"><?php esc_html_e('提交物流', 'aksrt'); ?></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if ($section === 'after-sale' && class_exists('\AKSRT\Payment\AfterSale')) :
|
||||
$tickets = \AKSRT\Payment\AfterSale::get_instance()->get_user_tickets($user_id, 50);
|
||||
?>
|
||||
<section class="aksrt-orders-page__aftersale" data-aksrt-aftersale-list>
|
||||
<h2><?php esc_html_e('我的售后', 'aksrt'); ?></h2>
|
||||
<?php if (!empty($tickets)) : ?>
|
||||
<div class="aksrt-as-list">
|
||||
<?php foreach ($tickets as $t) :
|
||||
$tid = (int) ($t['id'] ?? 0);
|
||||
$events = is_array($t['events'] ?? null) ? $t['events'] : [];
|
||||
$actions_t = is_array($t['actions'] ?? null) ? $t['actions'] : [];
|
||||
?>
|
||||
<article class="aksrt-as-card" data-aksrt-as-ticket="<?php echo esc_attr((string) $tid); ?>">
|
||||
<div class="aksrt-as-card__head">
|
||||
<strong>#<?php echo esc_html((string) $tid); ?></strong>
|
||||
<span class="aksrt-as-card__order"><?php echo esc_html((string) ($t['order_number'] ?? '')); ?></span>
|
||||
<span class="aksrt-as-card__status"><?php echo esc_html((string) ($t['status_label'] ?? $t['status'] ?? '')); ?></span>
|
||||
</div>
|
||||
<div class="aksrt-as-card__meta muted-2-color">
|
||||
<?php echo esc_html((string) ($t['type_label'] ?? $t['type'] ?? '')); ?>
|
||||
· <?php echo esc_html((string) ($t['created_at'] ?? '')); ?>
|
||||
</div>
|
||||
<div class="aksrt-as-card__reason"><?php echo esc_html((string) ($t['reason'] ?? '')); ?></div>
|
||||
<?php if ($events) : ?>
|
||||
<ol class="aksrt-as-timeline">
|
||||
<?php foreach (array_reverse($events) as $ev) : ?>
|
||||
<li>
|
||||
<time><?php echo esc_html((string) ($ev['time'] ?? '')); ?></time>
|
||||
<span><?php echo esc_html((string) ($ev['label'] ?? '')); ?></span>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ol>
|
||||
<?php endif; ?>
|
||||
<div class="aksrt-as-card__actions">
|
||||
<?php if (!empty($actions_t['can_return_ship'])) : ?>
|
||||
<button type="button" class="btn-primary btn-sm" data-aksrt-return-ship="<?php echo esc_attr((string) $tid); ?>"><?php esc_html_e('填写退货物流', 'aksrt'); ?></button>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($actions_t['can_cancel'])) : ?>
|
||||
<button type="button" class="btn-ghost btn-sm" data-aksrt-as-cancel="<?php echo esc_attr((string) $tid); ?>"><?php esc_html_e('取消售后', 'aksrt'); ?></button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</article>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php else : ?>
|
||||
<p class="muted-2-color"><?php esc_html_e('暂无售后记录', 'aksrt'); ?></p>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($section === 'coupons') : ?>
|
||||
<section class="aksrt-orders-page__coupons" data-aksrt-coupon-wallet>
|
||||
<h2><?php esc_html_e('优惠券', 'aksrt'); ?></h2>
|
||||
@@ -483,27 +345,4 @@ $pending_count = (int) ($stats['pending'] ?? 0);
|
||||
</div>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($section === 'favorites' && $shop) :
|
||||
$fav_data = $shop->get_user_favorites($user_id);
|
||||
?>
|
||||
<section class="aksrt-orders-page__favorites">
|
||||
<h2><?php esc_html_e('收藏商品', 'aksrt'); ?></h2>
|
||||
<?php if (!empty($fav_data['items'])) : ?>
|
||||
<div class="aksrt-orders-page__fav-grid">
|
||||
<?php foreach ($fav_data['items'] as $fav) : ?>
|
||||
<a href="<?php echo esc_url((string) ($fav['permalink'] ?? '#')); ?>" class="aksrt-orders-page__fav-card">
|
||||
<?php if (!empty($fav['image'])) : ?>
|
||||
<img src="<?php echo esc_url((string) $fav['image']); ?>" alt="" loading="lazy">
|
||||
<?php endif; ?>
|
||||
<h3><?php echo esc_html((string) ($fav['title'] ?? '')); ?></h3>
|
||||
<strong>¥<?php echo esc_html(number_format((float) ($fav['price'] ?? 0), 2)); ?></strong>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php else : ?>
|
||||
<p><?php esc_html_e('暂无收藏商品', 'aksrt'); ?></p>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
@@ -7,19 +7,12 @@
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
$cart_url = function_exists('aksrt_get_cart_page_url') ? aksrt_get_cart_page_url() : '';
|
||||
$level_url = isset($dashboard_tabs['level'])
|
||||
? aksrt_get_user_center_tab_url('level')
|
||||
: '';
|
||||
$auth_url = isset($dashboard_tabs['auth']) ? aksrt_get_user_center_tab_url('auth') : '';
|
||||
|
||||
$services = array_values(array_filter([
|
||||
[
|
||||
'label' => __('购物车', 'aksrt'),
|
||||
'icon' => 'shopping-cart',
|
||||
'tone' => 'cart',
|
||||
'url' => $cart_url,
|
||||
],
|
||||
[
|
||||
'label' => __('我的等级', 'aksrt'),
|
||||
'icon' => 'chart-line',
|
||||
|
||||
@@ -19,12 +19,6 @@ $order_stats = class_exists('\AKSRT\Payment\Order')
|
||||
$order_repo = class_exists('\AKSRT\Payment\OrderRepository')
|
||||
? \AKSRT\Payment\OrderRepository::get_instance()
|
||||
: null;
|
||||
$shop_type = class_exists('\AKSRT\Core\DomainModel')
|
||||
? \AKSRT\Core\DomainModel::ORDER_PRODUCT_SHOP
|
||||
: 'shop_product';
|
||||
$shop_paid = $order_repo ? $order_repo->count_user_orders($order_user_id, ['status' => \AKSRT\Payment\Order::STATUS_PAID, 'product_type' => $shop_type]) : 0;
|
||||
$shop_delivered = $order_repo ? $order_repo->count_user_orders($order_user_id, ['status' => \AKSRT\Payment\Order::STATUS_DELIVERED, 'product_type' => $shop_type]) : 0;
|
||||
$shop_review = $order_repo ? $order_repo->count_user_orders($order_user_id, ['status' => \AKSRT\Payment\Order::STATUS_PENDING_REVIEW, 'product_type' => $shop_type]) : 0;
|
||||
$order_items = [
|
||||
[
|
||||
'label' => __('待支付', 'aksrt'),
|
||||
@@ -32,24 +26,6 @@ $order_items = [
|
||||
'tab' => 'pending',
|
||||
'count' => (int) ($order_stats['pending'] ?? 0),
|
||||
],
|
||||
[
|
||||
'label' => __('待发货', 'aksrt'),
|
||||
'icon' => 'gift',
|
||||
'tab' => 'to-ship',
|
||||
'count' => $shop_paid,
|
||||
],
|
||||
[
|
||||
'label' => __('待收货', 'aksrt'),
|
||||
'icon' => 'truck',
|
||||
'tab' => 'shipped',
|
||||
'count' => $shop_delivered,
|
||||
],
|
||||
[
|
||||
'label' => __('待评价', 'aksrt'),
|
||||
'icon' => 'comment-dots',
|
||||
'tab' => 'review',
|
||||
'count' => $shop_review,
|
||||
],
|
||||
[
|
||||
'label' => __('售后', 'aksrt'),
|
||||
'icon' => 'undo-alt',
|
||||
|
||||
@@ -113,12 +113,6 @@ $_aksrt_special_templates = [
|
||||
'title' => '我的订单',
|
||||
'fallback' => '/orders/',
|
||||
],
|
||||
'cart' => [
|
||||
'file' => 'templates/pages/page-cart.php',
|
||||
'slug' => 'cart',
|
||||
'title' => '购物车',
|
||||
'fallback' => '/cart/',
|
||||
],
|
||||
'revenue' => [
|
||||
'file' => 'templates/pages/page-revenue.php',
|
||||
'slug' => 'revenue',
|
||||
@@ -413,11 +407,6 @@ function aksrt_get_current_special_template_path(): string {
|
||||
return $orders_template ?: '';
|
||||
}
|
||||
|
||||
if (get_query_var('aksrt_cart')) {
|
||||
$cart_template = aksrt_locate_template_file('templates/pages/page-cart.php');
|
||||
return $cart_template ?: '';
|
||||
}
|
||||
|
||||
if (get_query_var('aksrt_revenue')) {
|
||||
$revenue_template = aksrt_locate_template_file('templates/pages/page-revenue.php');
|
||||
return $revenue_template ?: '';
|
||||
|
||||
@@ -92,12 +92,7 @@ function aksrt_register_widgets() {
|
||||
register_widget('\AKSRT\Widgets\WidgetIconCoverCards');
|
||||
register_widget('\AKSRT\Widgets\WidgetBookmarkLinks');
|
||||
|
||||
// Shop product widgets (§6.9)
|
||||
register_widget('\AKSRT\Widgets\WidgetProductList');
|
||||
register_widget('\AKSRT\Widgets\WidgetProductOneline');
|
||||
register_widget('\AKSRT\Widgets\WidgetProductTab');
|
||||
|
||||
aksrt_maybe_repair_sidebar_blog_widgets();
|
||||
aksrt_maybe_repair_sidebar_blog_widgets();
|
||||
}
|
||||
add_action('widgets_init', 'aksrt_register_widgets');
|
||||
|
||||
|
||||
@@ -1,357 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Admin\Native;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class PayAfterSale {
|
||||
|
||||
public const CAPABILITY = 'manage_options';
|
||||
public const PAGE_SLUG = 'aksrt_after_sale';
|
||||
|
||||
private static $instance = null;
|
||||
|
||||
public static function get_instance(): self {
|
||||
if (null === self::$instance) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
public function init(): void {
|
||||
if (!is_admin()) {
|
||||
return;
|
||||
}
|
||||
add_action('admin_post_aksrt_pay_update_after_sale', [$this, 'handle_update_after_sale']);
|
||||
add_action('admin_post_aksrt_pay_create_after_sale', [$this, 'handle_create_after_sale']);
|
||||
add_action('admin_post_aksrt_pay_after_sale_action', [$this, 'handle_after_sale_action']);
|
||||
}
|
||||
|
||||
public function handle_create_after_sale(): void {
|
||||
if (!current_user_can(self::CAPABILITY)) {
|
||||
wp_die(esc_html__('权限不足', 'aksrt'));
|
||||
}
|
||||
$order_number = isset($_POST['order_number']) ? sanitize_text_field((string) wp_unslash($_POST['order_number'])) : '';
|
||||
$redirect_page = isset($_POST['redirect_page']) ? sanitize_key((string) wp_unslash($_POST['redirect_page'])) : 'order';
|
||||
$nonce_action = $redirect_page === 'after_sale' ? 'aksrt_pay_create_after_sale' : ('aksrt_pay_create_after_sale_' . $order_number);
|
||||
check_admin_referer($nonce_action);
|
||||
|
||||
$ok = false;
|
||||
if ($order_number !== '' && class_exists('\AKSRT\Payment\AfterSale')) {
|
||||
$id = \AKSRT\Payment\AfterSale::get_instance()->create([
|
||||
'order_number' => $order_number,
|
||||
'type' => sanitize_key((string) wp_unslash($_POST['type'] ?? 'refund')),
|
||||
'reason' => sanitize_textarea_field((string) wp_unslash($_POST['reason'] ?? '')),
|
||||
'amount' => (float) ($_POST['amount'] ?? 0),
|
||||
'note' => sanitize_textarea_field((string) wp_unslash($_POST['note'] ?? '')),
|
||||
]);
|
||||
$ok = !is_wp_error($id) && $id > 0;
|
||||
}
|
||||
|
||||
$msg = $ok ? 'after_sale_created' : 'after_sale_create_failed';
|
||||
if ($redirect_page === 'after_sale') {
|
||||
wp_safe_redirect($this->page_url('aksrt_after_sale', ['aksrt_pay_msg' => $msg, 's' => $order_number]));
|
||||
exit;
|
||||
}
|
||||
wp_safe_redirect($this->page_url('aksrt_orders', [
|
||||
'order' => $order_number,
|
||||
'aksrt_pay_msg' => $msg,
|
||||
]));
|
||||
exit;
|
||||
}
|
||||
|
||||
public function render_after_sale_page(): void {
|
||||
$this->ensure_cap();
|
||||
$this->render_header(__('售后管理', 'aksrt'));
|
||||
|
||||
if (!class_exists('\AKSRT\Payment\AfterSale')) {
|
||||
echo '<div class="notice notice-error"><p>' . esc_html__('售后模块未初始化。', 'aksrt') . '</p></div>';
|
||||
$this->render_footer();
|
||||
return;
|
||||
}
|
||||
|
||||
$status = isset($_GET['status']) ? sanitize_key((string) wp_unslash($_GET['status'])) : '';
|
||||
$type = isset($_GET['type']) ? sanitize_key((string) wp_unslash($_GET['type'])) : '';
|
||||
$search = isset($_GET['s']) ? sanitize_text_field((string) wp_unslash($_GET['s'])) : '';
|
||||
if ($search === '' && isset($_GET['order_number'])) {
|
||||
$search = sanitize_text_field((string) wp_unslash($_GET['order_number']));
|
||||
}
|
||||
$page_num = max(1, (int) ($_GET['paged'] ?? 1));
|
||||
$per_page = 20;
|
||||
$svc = \AKSRT\Payment\AfterSale::get_instance();
|
||||
$stats = $svc->get_stats();
|
||||
$status_options = $this->after_sale_status_options(true);
|
||||
$type_options = $this->after_sale_type_options(true);
|
||||
|
||||
echo '<p>' . esc_html(sprintf(
|
||||
/* translators: 1: total 2: pending */
|
||||
__('售后工单共 %1$d · 待处理 %2$d', 'aksrt'),
|
||||
(int) ($stats['total'] ?? 0),
|
||||
(int) ($stats['pending'] ?? 0)
|
||||
)) . '</p>';
|
||||
|
||||
echo '<h2>' . esc_html__('新建售后', 'aksrt') . '</h2>';
|
||||
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" style="margin:8px 0 20px;display:flex;flex-wrap:wrap;gap:8px;align-items:end;">';
|
||||
echo '<input type="hidden" name="action" value="aksrt_pay_create_after_sale" />';
|
||||
echo '<input type="hidden" name="redirect_page" value="after_sale" />';
|
||||
wp_nonce_field('aksrt_pay_create_after_sale');
|
||||
echo '<p><label>' . esc_html__('订单号', 'aksrt') . '<br><input type="text" name="order_number" value="' . esc_attr($search) . '" required class="regular-text" /></label></p>';
|
||||
echo '<p><label>' . esc_html__('类型', 'aksrt') . '<br><select name="type">';
|
||||
foreach ($this->after_sale_type_options(false) as $k => $label) {
|
||||
printf('<option value="%s">%s</option>', esc_attr($k), esc_html($label));
|
||||
}
|
||||
echo '</select></label></p>';
|
||||
echo '<p><label>' . esc_html__('金额', 'aksrt') . '<br><input type="number" step="0.01" min="0" name="amount" value="0" /></label></p>';
|
||||
echo '<p><label>' . esc_html__('原因', 'aksrt') . '<br><input type="text" name="reason" required class="regular-text" /></label></p>';
|
||||
echo '<p><label>' . esc_html__('备注', 'aksrt') . '<br><input type="text" name="note" /></label></p>';
|
||||
submit_button(__('创建售后', 'aksrt'), 'primary', '', false);
|
||||
echo '</form>';
|
||||
|
||||
echo '<form method="get" style="margin:12px 0;">';
|
||||
echo '<input type="hidden" name="page" value="aksrt_after_sale" />';
|
||||
echo '<select name="status">';
|
||||
foreach ($status_options as $key => $label) {
|
||||
printf('<option value="%s"%s>%s</option>', esc_attr($key), selected($status, $key, false), esc_html($label));
|
||||
}
|
||||
echo '</select> ';
|
||||
echo '<select name="type">';
|
||||
foreach ($type_options as $key => $label) {
|
||||
printf('<option value="%s"%s>%s</option>', esc_attr($key), selected($type, $key, false), esc_html($label));
|
||||
}
|
||||
echo '</select> ';
|
||||
echo '<input type="search" name="s" value="' . esc_attr($search) . '" placeholder="' . esc_attr__('订单号', 'aksrt') . '" /> ';
|
||||
submit_button(__('筛选', 'aksrt'), 'secondary', '', false);
|
||||
echo '</form>';
|
||||
|
||||
$list_args = [
|
||||
'status' => $status,
|
||||
'type' => $type,
|
||||
'limit' => $per_page,
|
||||
'offset' => ($page_num - 1) * $per_page,
|
||||
];
|
||||
if ($search !== '') {
|
||||
$list_args['order_number'] = $search;
|
||||
}
|
||||
$result = $svc->get_list($list_args);
|
||||
$items = $result['items'] ?? [];
|
||||
$total = (int) ($result['total'] ?? 0);
|
||||
$pages = max(1, (int) ceil(max(1, $total) / $per_page));
|
||||
$row_status_options = $this->after_sale_status_options(false);
|
||||
|
||||
echo '<table class="wp-list-table widefat fixed striped"><thead><tr>';
|
||||
echo '<th>ID</th><th>' . esc_html__('订单号', 'aksrt') . '</th><th>' . esc_html__('类型', 'aksrt') . '</th><th>' . esc_html__('用户', 'aksrt') . '</th><th>' . esc_html__('状态', 'aksrt') . '</th><th>' . esc_html__('时间', 'aksrt') . '</th><th>' . esc_html__('操作', 'aksrt') . '</th>';
|
||||
echo '</tr></thead><tbody>';
|
||||
if (!$items) {
|
||||
echo '<tr><td colspan="7">' . esc_html__('暂无售后工单', 'aksrt') . '</td></tr>';
|
||||
}
|
||||
foreach ($items as $row) {
|
||||
$id = (int) ($row['id'] ?? 0);
|
||||
$user_id = (int) ($row['user_id'] ?? 0);
|
||||
$user = $user_id > 0 ? get_userdata($user_id) : null;
|
||||
$st = (string) ($row['status'] ?? '');
|
||||
$order_number = (string) ($row['order_number'] ?? '');
|
||||
echo '<tr>';
|
||||
echo '<td>' . esc_html((string) $id) . '</td>';
|
||||
echo '<td><a href="' . esc_url($this->page_url('aksrt_orders', ['order' => $order_number])) . '"><code>' . esc_html($order_number) . '</code></a></td>';
|
||||
echo '<td>' . esc_html((string) ($row['type_label'] ?? $type_options[$row['type'] ?? ''] ?? $row['type'] ?? '')) . '</td>';
|
||||
echo '<td>' . ($user ? esc_html($user->display_name) : esc_html((string) $user_id)) . '</td>';
|
||||
echo '<td>' . esc_html((string) ($row['status_label'] ?? $row_status_options[$st] ?? $st)) . '</td>';
|
||||
echo '<td>' . esc_html((string) ($row['created_at'] ?? '')) . '</td>';
|
||||
echo '<td style="display:flex;gap:6px;flex-wrap:wrap;align-items:center;">';
|
||||
if ($st === 'pending') {
|
||||
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '">';
|
||||
echo '<input type="hidden" name="action" value="aksrt_pay_after_sale_action" />';
|
||||
echo '<input type="hidden" name="ticket_id" value="' . esc_attr((string) $id) . '" />';
|
||||
echo '<input type="hidden" name="as_action" value="approve" />';
|
||||
wp_nonce_field('aksrt_pay_after_sale_action_' . $id);
|
||||
submit_button(__('同意', 'aksrt'), 'small', '', false);
|
||||
echo '</form>';
|
||||
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '">';
|
||||
echo '<input type="hidden" name="action" value="aksrt_pay_after_sale_action" />';
|
||||
echo '<input type="hidden" name="ticket_id" value="' . esc_attr((string) $id) . '" />';
|
||||
echo '<input type="hidden" name="as_action" value="reject" />';
|
||||
wp_nonce_field('aksrt_pay_after_sale_action_' . $id);
|
||||
echo '<button type="submit" class="button button-small" onclick="return confirm(\'' . esc_js(__('确认拒绝该售后?', 'aksrt')) . '\');">' . esc_html__('拒绝', 'aksrt') . '</button>';
|
||||
echo '</form>';
|
||||
}
|
||||
if ($st === 'processing') {
|
||||
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '">';
|
||||
echo '<input type="hidden" name="action" value="aksrt_pay_after_sale_action" />';
|
||||
echo '<input type="hidden" name="ticket_id" value="' . esc_attr((string) $id) . '" />';
|
||||
echo '<input type="hidden" name="as_action" value="confirm_return" />';
|
||||
wp_nonce_field('aksrt_pay_after_sale_action_' . $id);
|
||||
submit_button(__('确认收货', 'aksrt'), 'small', '', false);
|
||||
echo '</form>';
|
||||
}
|
||||
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" style="display:flex;gap:4px;align-items:center;flex-wrap:wrap;">';
|
||||
echo '<input type="hidden" name="action" value="aksrt_pay_update_after_sale" />';
|
||||
echo '<input type="hidden" name="ticket_id" value="' . esc_attr((string) $id) . '" />';
|
||||
wp_nonce_field('aksrt_pay_update_after_sale_' . $id);
|
||||
echo '<select name="new_status">';
|
||||
foreach ($row_status_options as $key => $label) {
|
||||
printf('<option value="%s"%s>%s</option>', esc_attr($key), selected($st, $key, false), esc_html($label));
|
||||
}
|
||||
echo '</select>';
|
||||
echo '<input type="text" name="note" placeholder="' . esc_attr__('备注', 'aksrt') . '" />';
|
||||
submit_button(__('更新', 'aksrt'), 'small', '', false);
|
||||
echo '</form>';
|
||||
echo '</td></tr>';
|
||||
}
|
||||
echo '</tbody></table>';
|
||||
$this->render_pagination($pages, $page_num);
|
||||
$this->render_footer();
|
||||
}
|
||||
|
||||
public function handle_update_after_sale(): void {
|
||||
if (!current_user_can(self::CAPABILITY)) {
|
||||
wp_die(esc_html__('权限不足', 'aksrt'));
|
||||
}
|
||||
$id = isset($_POST['ticket_id']) ? (int) $_POST['ticket_id'] : 0;
|
||||
$new_status = isset($_POST['new_status']) ? sanitize_key((string) wp_unslash($_POST['new_status'])) : '';
|
||||
$note = isset($_POST['note']) ? sanitize_textarea_field((string) wp_unslash($_POST['note'])) : '';
|
||||
check_admin_referer('aksrt_pay_update_after_sale_' . $id);
|
||||
|
||||
$ok = false;
|
||||
if ($id > 0 && $new_status !== '' && class_exists('\AKSRT\Payment\AfterSale')) {
|
||||
$ok = \AKSRT\Payment\AfterSale::get_instance()->update_status($id, $new_status, get_current_user_id(), $note);
|
||||
}
|
||||
wp_safe_redirect($this->page_url('aksrt_after_sale', ['aksrt_pay_msg' => $ok ? 'after_sale_updated' : 'after_sale_failed']));
|
||||
exit;
|
||||
}
|
||||
|
||||
public function handle_after_sale_action(): void {
|
||||
if (!current_user_can(self::CAPABILITY)) {
|
||||
wp_die(esc_html__('权限不足', 'aksrt'));
|
||||
}
|
||||
$id = isset($_POST['ticket_id']) ? (int) $_POST['ticket_id'] : 0;
|
||||
$as_action = isset($_POST['as_action']) ? sanitize_key((string) wp_unslash($_POST['as_action'])) : '';
|
||||
check_admin_referer('aksrt_pay_after_sale_action_' . $id);
|
||||
|
||||
$ok = false;
|
||||
if ($id > 0 && class_exists('\AKSRT\Payment\AfterSale')) {
|
||||
$svc = \AKSRT\Payment\AfterSale::get_instance();
|
||||
$operator_id = get_current_user_id();
|
||||
if ($as_action === 'approve') {
|
||||
$ok = $svc->merchant_approve($id, $operator_id);
|
||||
} elseif ($as_action === 'reject') {
|
||||
$ok = $svc->merchant_reject($id, $operator_id, __('商家拒绝', 'aksrt'));
|
||||
} elseif ($as_action === 'confirm_return') {
|
||||
$ok = $svc->merchant_confirm_return($id, $operator_id);
|
||||
}
|
||||
}
|
||||
|
||||
wp_safe_redirect($this->page_url('aksrt_after_sale', [
|
||||
'aksrt_pay_msg' => $ok ? 'after_sale_action_ok' : 'after_sale_action_failed',
|
||||
]));
|
||||
exit;
|
||||
}
|
||||
|
||||
|
||||
private function after_sale_status_options(bool $include_all = false): array {
|
||||
$options = [];
|
||||
if ($include_all) {
|
||||
$options[''] = __('全部状态', 'aksrt');
|
||||
}
|
||||
if (class_exists('\AKSRT\Payment\AfterSale')) {
|
||||
foreach (\AKSRT\Payment\AfterSale::get_status_options() as $item) {
|
||||
$value = (string) ($item['value'] ?? '');
|
||||
if ($value === '') {
|
||||
continue;
|
||||
}
|
||||
$options[$value] = (string) ($item['label'] ?? $value);
|
||||
}
|
||||
}
|
||||
return $options;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function after_sale_type_options_public(bool $include_all = false): array {
|
||||
return $this->after_sale_type_options($include_all);
|
||||
}
|
||||
|
||||
private function after_sale_type_options(bool $include_all = false): array {
|
||||
$options = [];
|
||||
if ($include_all) {
|
||||
$options[''] = __('全部类型', 'aksrt');
|
||||
}
|
||||
// Keep operational create options broader than public get_type_options().
|
||||
$defaults = [
|
||||
'refund' => __('仅退款', 'aksrt'),
|
||||
'refund_return' => __('退货退款', 'aksrt'),
|
||||
'exchange' => __('换货', 'aksrt'),
|
||||
'repair' => __('维修', 'aksrt'),
|
||||
];
|
||||
if (class_exists('\AKSRT\Payment\AfterSale')) {
|
||||
foreach (\AKSRT\Payment\AfterSale::get_type_options() as $item) {
|
||||
$value = (string) ($item['value'] ?? '');
|
||||
if ($value === '') {
|
||||
continue;
|
||||
}
|
||||
$defaults[$value] = (string) ($item['label'] ?? $value);
|
||||
}
|
||||
}
|
||||
return array_merge($options, $defaults);
|
||||
}
|
||||
|
||||
|
||||
private function ensure_cap(): void {
|
||||
if (!current_user_can(self::CAPABILITY)) {
|
||||
wp_die(esc_html__('您没有权限访问此页面。', 'aksrt'));
|
||||
}
|
||||
}
|
||||
|
||||
private function render_header(string $title): void {
|
||||
echo '<div class="wrap">';
|
||||
echo '<h1>' . esc_html($title) . '</h1>';
|
||||
$this->render_flash();
|
||||
}
|
||||
|
||||
private function render_footer(): void {
|
||||
echo '</div>';
|
||||
}
|
||||
|
||||
private function render_flash(): void {
|
||||
if (empty($_GET['aksrt_pay_msg'])) {
|
||||
return;
|
||||
}
|
||||
$code = sanitize_key((string) wp_unslash($_GET['aksrt_pay_msg']));
|
||||
$map = [
|
||||
'after_sale_updated' => __('售后状态已更新。', 'aksrt'),
|
||||
'after_sale_failed' => __('售后状态更新失败。', 'aksrt'),
|
||||
'after_sale_created' => __('售后工单已创建。', 'aksrt'),
|
||||
'after_sale_create_failed' => __('售后工单创建失败。', 'aksrt'),
|
||||
'after_sale_action_ok' => __('售后操作已完成。', 'aksrt'),
|
||||
'after_sale_action_failed' => __('售后操作失败。', 'aksrt'),
|
||||
];
|
||||
if (!isset($map[$code])) {
|
||||
return;
|
||||
}
|
||||
$class = str_contains($code, 'failed') ? 'notice-error' : 'notice-success';
|
||||
echo '<div class="notice ' . esc_attr($class) . ' is-dismissible"><p>' . esc_html($map[$code]) . '</p></div>';
|
||||
}
|
||||
|
||||
private function page_url(string $slug, array $args = []): string {
|
||||
return add_query_arg(array_merge(['page' => $slug], $args), admin_url('admin.php'));
|
||||
}
|
||||
|
||||
private function render_pagination(int $pages, int $page_num): void {
|
||||
if ($pages <= 1) {
|
||||
return;
|
||||
}
|
||||
echo '<div class="tablenav"><div class="tablenav-pages">';
|
||||
echo paginate_links([
|
||||
'base' => add_query_arg('paged', '%#%'),
|
||||
'format' => '',
|
||||
'prev_text' => '«',
|
||||
'next_text' => '»',
|
||||
'total' => $pages,
|
||||
'current' => $page_num,
|
||||
]);
|
||||
echo '</div></div>';
|
||||
}
|
||||
}
|
||||
@@ -372,7 +372,7 @@ class PayCoupons {
|
||||
'ids' => $ids,
|
||||
];
|
||||
if ($type === 'category') {
|
||||
$scope['taxonomies'] = ['aksrt_product_cat', 'category'];
|
||||
$scope['taxonomies'] = ['category'];
|
||||
}
|
||||
return $scope;
|
||||
}
|
||||
|
||||
@@ -74,28 +74,6 @@ class PayMenus {
|
||||
wp_die(esc_html__('下载记录模块未初始化。', 'aksrt'));
|
||||
},
|
||||
],
|
||||
'shipping' => [
|
||||
'slug' => 'aksrt_shipping',
|
||||
'title' => __('发货及物流', 'aksrt'),
|
||||
'callback' => static function (): void {
|
||||
if (class_exists('AKSRT\\Admin\\Native\\PayShipping')) {
|
||||
PayShipping::get_instance()->render_shipping_page();
|
||||
return;
|
||||
}
|
||||
wp_die(esc_html__('物流模块未初始化。', 'aksrt'));
|
||||
},
|
||||
],
|
||||
'after_sale' => [
|
||||
'slug' => 'aksrt_after_sale',
|
||||
'title' => __('售后管理', 'aksrt'),
|
||||
'callback' => static function (): void {
|
||||
if (class_exists('AKSRT\\Admin\\Native\\PayAfterSale')) {
|
||||
PayAfterSale::get_instance()->render_after_sale_page();
|
||||
return;
|
||||
}
|
||||
wp_die(esc_html__('售后模块未初始化。', 'aksrt'));
|
||||
},
|
||||
],
|
||||
'coupons' => [
|
||||
'slug' => 'aksrt_coupons',
|
||||
'title' => __('优惠码', 'aksrt'),
|
||||
@@ -203,23 +181,6 @@ class PayMenus {
|
||||
echo '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
if ($page !== 'aksrt_after_sale' && class_exists('\AKSRT\Payment\AfterSale')) {
|
||||
$as_stats = \AKSRT\Payment\AfterSale::get_instance()->get_stats();
|
||||
$pending_as = (int) ($as_stats['pending'] ?? 0);
|
||||
if ($pending_as > 0) {
|
||||
$url = admin_url('admin.php?page=aksrt_after_sale&status=pending');
|
||||
echo '<div class="notice notice-info is-dismissible">';
|
||||
echo '<h3>' . esc_html__('售后工单待处理', 'aksrt') . '</h3>';
|
||||
echo '<p>' . esc_html(sprintf(
|
||||
/* translators: %d: pending after-sale count */
|
||||
__('您有 %d 个售后工单待处理', 'aksrt'),
|
||||
$pending_as
|
||||
)) . '</p>';
|
||||
echo '<p><a class="button button-primary" href="' . esc_url($url) . '">' . esc_html__('去处理', 'aksrt') . '</a></p>';
|
||||
echo '</div>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function ensure_cap(): void {
|
||||
@@ -255,17 +216,12 @@ class PayMenus {
|
||||
? \AKSRT\Payment\ChargeCard::get_instance()->get_stats()
|
||||
: [];
|
||||
|
||||
echo '<p class="description">' . esc_html__('运营数据入口(对齐 zibll 商城中心)。配置项仍在 AKSRT 主题设置 SPA。', 'aksrt') . '</p>';
|
||||
echo '<p class="description">' . esc_html__('运营数据入口。配置项仍在 AKSRT 主题设置 SPA。', 'aksrt') . '</p>';
|
||||
|
||||
echo '<div class="aksrt-pay-stats" style="display:flex;flex-wrap:wrap;gap:16px;margin:20px 0;">';
|
||||
$pending_ship = 0;
|
||||
if (class_exists('\AKSRT\Payment\OrderRepository')) {
|
||||
$pending_ship = (int) (\AKSRT\Payment\OrderRepository::get_instance()->get_pending_shipment_orders(['limit' => 1])['total'] ?? 0);
|
||||
}
|
||||
$this->stat_card(__('订单总数', 'aksrt'), (string) (int) ($order_stats['total'] ?? 0), $this->page_url('aksrt_orders'));
|
||||
$this->stat_card(__('待支付', 'aksrt'), (string) (int) ($order_stats['pending'] ?? 0), $this->page_url('aksrt_orders', ['status' => 'pending']));
|
||||
$this->stat_card(__('已支付', 'aksrt'), (string) (int) ($order_stats['paid'] ?? 0), $this->page_url('aksrt_orders', ['status' => 'paid']));
|
||||
$this->stat_card(__('待发货', 'aksrt'), (string) $pending_ship, $this->page_url('aksrt_shipping', ['view' => 'pending']));
|
||||
$this->stat_card(__('营收', 'aksrt'), number_format((float) ($order_stats['revenue'] ?? 0), 2), $this->page_url('aksrt_orders'));
|
||||
$this->stat_card(__('待审提现', 'aksrt'), (string) (int) ($withdraw_stats['pending'] ?? 0), $this->page_url('aksrt_withdraw', ['status' => 'pending']));
|
||||
$this->stat_card(__('可用卡密', 'aksrt'), (string) (int) ($card_stats['active'] ?? 0), $this->page_url('aksrt_charge_cards', ['status' => 'active']));
|
||||
|
||||
@@ -288,13 +288,6 @@ class PayOrders {
|
||||
$admin_note = (string) $order['note'];
|
||||
}
|
||||
|
||||
$shipping_items = class_exists('\AKSRT\Payment\Shipping')
|
||||
? \AKSRT\Payment\Shipping::get_instance()->get_by_order($order_number)
|
||||
: [];
|
||||
$after_sale = class_exists('\AKSRT\Payment\AfterSale')
|
||||
? (\AKSRT\Payment\AfterSale::get_instance()->get_list(['order_number' => $order_number, 'limit' => 20])['items'] ?? [])
|
||||
: [];
|
||||
|
||||
echo '<p><a class="button" href="' . esc_url($this->page_url('aksrt_orders')) . '">← ' . esc_html__('返回订单列表', 'aksrt') . '</a></p>';
|
||||
|
||||
echo '<table class="widefat striped" style="max-width:960px;"><tbody>';
|
||||
@@ -344,87 +337,6 @@ class PayOrders {
|
||||
echo '</select> ';
|
||||
submit_button(__('保存状态', 'aksrt'), 'primary', '', false);
|
||||
echo '</form>';
|
||||
|
||||
echo '<h2 style="margin-top:24px;">' . esc_html__('物流记录', 'aksrt') . '</h2>';
|
||||
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" style="margin:8px 0 16px;display:flex;flex-wrap:wrap;gap:8px;align-items:end;">';
|
||||
echo '<input type="hidden" name="action" value="aksrt_pay_create_shipping" />';
|
||||
echo '<input type="hidden" name="order_number" value="' . esc_attr($order_number) . '" />';
|
||||
echo '<input type="hidden" name="redirect_page" value="order" />';
|
||||
wp_nonce_field('aksrt_pay_create_shipping_' . $order_number);
|
||||
echo '<p><label>' . esc_html__('承运商', 'aksrt') . '<br>';
|
||||
$this->render_carrier_field('');
|
||||
echo '</label></p>';
|
||||
echo '<p><label>' . esc_html__('运单号', 'aksrt') . '<br><input type="text" name="tracking_no" required /></label></p>';
|
||||
echo '<p><label>' . esc_html__('备注', 'aksrt') . '<br><input type="text" name="note" /></label></p>';
|
||||
submit_button(__('创建发货', 'aksrt'), 'secondary', '', false);
|
||||
echo ' <a class="button" href="' . esc_url($this->page_url('aksrt_shipping', ['s' => $order_number])) . '">' . esc_html__('物流管理', 'aksrt') . '</a>';
|
||||
echo '</form>';
|
||||
if (!$shipping_items) {
|
||||
echo '<p>' . esc_html__('暂无物流记录。', 'aksrt') . '</p>';
|
||||
} else {
|
||||
$status_options = $this->shipping_status_options(false);
|
||||
echo '<table class="widefat striped"><thead><tr><th>ID</th><th>' . esc_html__('承运商', 'aksrt') . '</th><th>' . esc_html__('运单号', 'aksrt') . '</th><th>' . esc_html__('状态', 'aksrt') . '</th><th>' . esc_html__('时间', 'aksrt') . '</th><th>' . esc_html__('操作', 'aksrt') . '</th></tr></thead><tbody>';
|
||||
foreach ($shipping_items as $item) {
|
||||
$sid = (int) ($item['id'] ?? 0);
|
||||
$st = (string) ($item['status'] ?? '');
|
||||
echo '<tr>';
|
||||
echo '<td>' . esc_html((string) $sid) . '</td>';
|
||||
echo '<td>' . esc_html((string) ($item['carrier'] ?? '')) . '</td>';
|
||||
echo '<td>' . esc_html((string) ($item['tracking_no'] ?? '')) . '</td>';
|
||||
echo '<td>' . esc_html($status_options[$st] ?? $st) . '</td>';
|
||||
echo '<td>' . esc_html((string) ($item['created_at'] ?? '')) . '</td>';
|
||||
echo '<td><form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" style="display:flex;gap:4px;align-items:center;">';
|
||||
echo '<input type="hidden" name="action" value="aksrt_pay_update_shipping" />';
|
||||
echo '<input type="hidden" name="shipping_id" value="' . esc_attr((string) $sid) . '" />';
|
||||
echo '<input type="hidden" name="redirect_order" value="' . esc_attr($order_number) . '" />';
|
||||
wp_nonce_field('aksrt_pay_update_shipping_' . $sid);
|
||||
echo '<select name="new_status">';
|
||||
foreach ($status_options as $key => $label) {
|
||||
printf('<option value="%s"%s>%s</option>', esc_attr($key), selected($st, $key, false), esc_html($label));
|
||||
}
|
||||
echo '</select>';
|
||||
echo '<input type="text" name="note" placeholder="' . esc_attr__('备注', 'aksrt') . '" />';
|
||||
submit_button(__('更新', 'aksrt'), 'small', '', false);
|
||||
echo '</form></td>';
|
||||
echo '</tr>';
|
||||
}
|
||||
echo '</tbody></table>';
|
||||
}
|
||||
|
||||
echo '<h2 style="margin-top:24px;">' . esc_html__('售后工单', 'aksrt') . '</h2>';
|
||||
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" style="margin:8px 0 16px;display:flex;flex-wrap:wrap;gap:8px;align-items:end;">';
|
||||
echo '<input type="hidden" name="action" value="aksrt_pay_create_after_sale" />';
|
||||
echo '<input type="hidden" name="order_number" value="' . esc_attr($order_number) . '" />';
|
||||
echo '<input type="hidden" name="user_id" value="' . esc_attr((string) $user_id) . '" />';
|
||||
echo '<input type="hidden" name="redirect_page" value="order" />';
|
||||
wp_nonce_field('aksrt_pay_create_after_sale_' . $order_number);
|
||||
echo '<p><label>' . esc_html__('类型', 'aksrt') . '<br><select name="type">';
|
||||
foreach ($this->after_sale_type_options(false) as $k => $label) {
|
||||
printf('<option value="%s">%s</option>', esc_attr($k), esc_html($label));
|
||||
}
|
||||
echo '</select></label></p>';
|
||||
echo '<p><label>' . esc_html__('金额', 'aksrt') . '<br><input type="number" step="0.01" min="0" name="amount" value="' . esc_attr((string) ($order['amount'] ?? 0)) . '" /></label></p>';
|
||||
echo '<p><label>' . esc_html__('原因', 'aksrt') . '<br><input type="text" name="reason" required class="regular-text" /></label></p>';
|
||||
echo '<p><label>' . esc_html__('备注', 'aksrt') . '<br><input type="text" name="note" /></label></p>';
|
||||
submit_button(__('创建售后', 'aksrt'), 'secondary', '', false);
|
||||
echo ' <a class="button" href="' . esc_url($this->page_url('aksrt_after_sale', ['s' => $order_number])) . '">' . esc_html__('售后管理', 'aksrt') . '</a>';
|
||||
echo '</form>';
|
||||
if (!$after_sale) {
|
||||
echo '<p>' . esc_html__('暂无售后工单。', 'aksrt') . '</p>';
|
||||
} else {
|
||||
echo '<table class="widefat striped"><thead><tr><th>ID</th><th>' . esc_html__('类型', 'aksrt') . '</th><th>' . esc_html__('状态', 'aksrt') . '</th><th>' . esc_html__('时间', 'aksrt') . '</th><th>' . esc_html__('操作', 'aksrt') . '</th></tr></thead><tbody>';
|
||||
foreach ($after_sale as $item) {
|
||||
$tid = (int) ($item['id'] ?? 0);
|
||||
echo '<tr>';
|
||||
echo '<td>' . esc_html((string) $tid) . '</td>';
|
||||
echo '<td>' . esc_html((string) ($item['type_label'] ?? $item['type'] ?? '')) . '</td>';
|
||||
echo '<td>' . esc_html((string) ($item['status_label'] ?? $item['status'] ?? '')) . '</td>';
|
||||
echo '<td>' . esc_html((string) ($item['created_at'] ?? '')) . '</td>';
|
||||
echo '<td><a href="' . esc_url($this->page_url('aksrt_after_sale', ['s' => $order_number])) . '">' . esc_html__('在售后页处理', 'aksrt') . '</a></td>';
|
||||
echo '</tr>';
|
||||
}
|
||||
echo '</tbody></table>';
|
||||
}
|
||||
}
|
||||
|
||||
public function handle_update_order_status(): void {
|
||||
@@ -510,30 +422,6 @@ class PayOrders {
|
||||
exit;
|
||||
}
|
||||
|
||||
private function shipping_status_options(bool $include_all = false): array {
|
||||
if (class_exists('AKSRT\\Admin\\Native\\PayShipping')) {
|
||||
return PayShipping::get_instance()->shipping_status_options_public($include_all);
|
||||
}
|
||||
return $include_all ? ['' => __('全部状态', 'aksrt')] : [];
|
||||
}
|
||||
|
||||
private function after_sale_type_options(bool $include_all = false): array {
|
||||
if (class_exists('AKSRT\\Admin\\Native\\PayAfterSale')) {
|
||||
return PayAfterSale::get_instance()->after_sale_type_options_public($include_all);
|
||||
}
|
||||
return $include_all ? ['' => __('全部类型', 'aksrt')] : [
|
||||
'refund' => __('仅退款', 'aksrt'),
|
||||
];
|
||||
}
|
||||
|
||||
private function render_carrier_field(string $selected = ''): void {
|
||||
if (class_exists('AKSRT\\Admin\\Native\\PayShipping')) {
|
||||
PayShipping::get_instance()->render_carrier_field_public($selected);
|
||||
return;
|
||||
}
|
||||
echo '<input type="text" name="carrier" value="' . esc_attr($selected) . '" required />';
|
||||
}
|
||||
|
||||
private function render_order_quick_status_buttons(string $order_number, string $current_status, bool $redirect_detail = false): void {
|
||||
$actions = [
|
||||
'completed' => __('完成', 'aksrt'),
|
||||
|
||||
@@ -1,507 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Admin\Native;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class PayShipping {
|
||||
|
||||
public const CAPABILITY = 'manage_options';
|
||||
public const PAGE_SLUG = 'aksrt_shipping';
|
||||
|
||||
private static $instance = null;
|
||||
|
||||
public static function get_instance(): self {
|
||||
if (null === self::$instance) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
public function init(): void {
|
||||
if (!is_admin()) {
|
||||
return;
|
||||
}
|
||||
add_action('admin_post_aksrt_pay_update_shipping', [$this, 'handle_update_shipping']);
|
||||
add_action('admin_post_aksrt_pay_query_express', [$this, 'handle_query_express']);
|
||||
add_action('admin_post_aksrt_pay_create_shipping', [$this, 'handle_create_shipping']);
|
||||
}
|
||||
|
||||
public function render_shipping_page(): void {
|
||||
$this->ensure_cap();
|
||||
$this->render_header(__('发货及物流', 'aksrt'));
|
||||
|
||||
if (!class_exists('\AKSRT\Payment\Shipping')) {
|
||||
echo '<div class="notice notice-error"><p>' . esc_html__('物流模块未初始化。', 'aksrt') . '</p></div>';
|
||||
$this->render_footer();
|
||||
return;
|
||||
}
|
||||
|
||||
$view = isset($_GET['view']) ? sanitize_key((string) wp_unslash($_GET['view'])) : 'records';
|
||||
if (!in_array($view, ['records', 'pending'], true)) {
|
||||
$view = 'records';
|
||||
}
|
||||
$status = isset($_GET['status']) ? sanitize_key((string) wp_unslash($_GET['status'])) : '';
|
||||
$search = isset($_GET['s']) ? sanitize_text_field((string) wp_unslash($_GET['s'])) : '';
|
||||
$page_num = max(1, (int) ($_GET['paged'] ?? 1));
|
||||
$per_page = 20;
|
||||
$svc = \AKSRT\Payment\Shipping::get_instance();
|
||||
$stats = $svc->get_stats();
|
||||
$status_options = $this->shipping_status_options(true);
|
||||
|
||||
$pending_total = 0;
|
||||
if (class_exists('\AKSRT\Payment\OrderRepository')) {
|
||||
$pending_total = (int) (\AKSRT\Payment\OrderRepository::get_instance()->get_pending_shipment_orders(['limit' => 1, 'search' => ''])['total'] ?? 0);
|
||||
}
|
||||
|
||||
echo '<p>' . esc_html(sprintf(
|
||||
/* translators: 1: shipping records 2: pending shipment orders */
|
||||
__('物流记录 %1$d 条 · 待发货订单 %2$d', 'aksrt'),
|
||||
(int) ($stats['total'] ?? 0),
|
||||
$pending_total
|
||||
)) . '</p>';
|
||||
|
||||
echo '<h2 class="nav-tab-wrapper" style="margin:12px 0 16px;">';
|
||||
printf(
|
||||
'<a href="%s" class="nav-tab%s">%s</a>',
|
||||
esc_url($this->page_url('aksrt_shipping', ['view' => 'pending'])),
|
||||
$view === 'pending' ? ' nav-tab-active' : '',
|
||||
esc_html(sprintf(__('待发货 (%d)', 'aksrt'), $pending_total))
|
||||
);
|
||||
printf(
|
||||
'<a href="%s" class="nav-tab%s">%s</a>',
|
||||
esc_url($this->page_url('aksrt_shipping', ['view' => 'records'])),
|
||||
$view === 'records' ? ' nav-tab-active' : '',
|
||||
esc_html__('物流记录', 'aksrt')
|
||||
);
|
||||
echo '</h2>';
|
||||
|
||||
if ($view === 'pending') {
|
||||
$this->render_pending_shipment_board($search, $page_num, $per_page);
|
||||
$this->render_footer();
|
||||
return;
|
||||
}
|
||||
|
||||
echo '<h2>' . esc_html__('新建发货', 'aksrt') . '</h2>';
|
||||
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" style="margin:8px 0 20px;display:flex;flex-wrap:wrap;gap:8px;align-items:end;">';
|
||||
echo '<input type="hidden" name="action" value="aksrt_pay_create_shipping" />';
|
||||
echo '<input type="hidden" name="redirect_page" value="shipping" />';
|
||||
wp_nonce_field('aksrt_pay_create_shipping');
|
||||
echo '<p><label>' . esc_html__('订单号', 'aksrt') . '<br><input type="text" name="order_number" value="' . esc_attr($search) . '" required class="regular-text" /></label></p>';
|
||||
echo '<p><label>' . esc_html__('承运商', 'aksrt') . '<br>';
|
||||
$this->render_carrier_field('');
|
||||
echo '</label></p>';
|
||||
echo '<p><label>' . esc_html__('运单号', 'aksrt') . '<br><input type="text" name="tracking_no" required /></label></p>';
|
||||
echo '<p><label>' . esc_html__('备注', 'aksrt') . '<br><input type="text" name="note" /></label></p>';
|
||||
submit_button(__('创建发货', 'aksrt'), 'primary', '', false);
|
||||
echo '</form>';
|
||||
|
||||
echo '<form method="get" style="margin:12px 0;">';
|
||||
echo '<input type="hidden" name="page" value="aksrt_shipping" />';
|
||||
echo '<input type="hidden" name="view" value="records" />';
|
||||
echo '<select name="status">';
|
||||
foreach ($status_options as $key => $label) {
|
||||
printf('<option value="%s"%s>%s</option>', esc_attr($key), selected($status, $key, false), esc_html($label));
|
||||
}
|
||||
echo '</select> ';
|
||||
echo '<input type="search" name="s" value="' . esc_attr($search) . '" placeholder="' . esc_attr__('订单号 / 运单号', 'aksrt') . '" /> ';
|
||||
submit_button(__('筛选', 'aksrt'), 'secondary', '', false);
|
||||
echo '</form>';
|
||||
|
||||
$args = [
|
||||
'status' => $status,
|
||||
'limit' => $per_page,
|
||||
'offset' => ($page_num - 1) * $per_page,
|
||||
];
|
||||
if ($search !== '') {
|
||||
if (preg_match('/^[A-Za-z0-9_-]+$/', $search)) {
|
||||
$args['order_number'] = $search;
|
||||
} else {
|
||||
$args['tracking_no'] = $search;
|
||||
}
|
||||
}
|
||||
$result = $svc->get_list($args);
|
||||
$items = $result['items'] ?? [];
|
||||
$total = (int) ($result['total'] ?? 0);
|
||||
$pages = max(1, (int) ceil(max(1, $total) / $per_page));
|
||||
$row_status_options = $this->shipping_status_options(false);
|
||||
$show_express_id = isset($_GET['express_id']) ? (int) $_GET['express_id'] : 0;
|
||||
|
||||
echo '<table class="wp-list-table widefat fixed striped"><thead><tr>';
|
||||
echo '<th>ID</th><th>' . esc_html__('订单号', 'aksrt') . '</th><th>' . esc_html__('承运商', 'aksrt') . '</th><th>' . esc_html__('运单号', 'aksrt') . '</th><th>' . esc_html__('状态', 'aksrt') . '</th><th>' . esc_html__('时间', 'aksrt') . '</th><th>' . esc_html__('操作', 'aksrt') . '</th>';
|
||||
echo '</tr></thead><tbody>';
|
||||
if (!$items) {
|
||||
echo '<tr><td colspan="7">' . esc_html__('暂无物流记录', 'aksrt') . '</td></tr>';
|
||||
}
|
||||
foreach ($items as $row) {
|
||||
$id = (int) ($row['id'] ?? 0);
|
||||
$st = (string) ($row['status'] ?? '');
|
||||
$order_number = (string) ($row['order_number'] ?? '');
|
||||
echo '<tr>';
|
||||
echo '<td>' . esc_html((string) $id) . '</td>';
|
||||
echo '<td><a href="' . esc_url($this->page_url('aksrt_orders', ['order' => $order_number])) . '"><code>' . esc_html($order_number) . '</code></a></td>';
|
||||
echo '<td>' . esc_html((string) ($row['carrier'] ?? '')) . '</td>';
|
||||
echo '<td>' . esc_html((string) ($row['tracking_no'] ?? '')) . '</td>';
|
||||
echo '<td>' . esc_html($row_status_options[$st] ?? $st) . '</td>';
|
||||
echo '<td>' . esc_html((string) ($row['created_at'] ?? '')) . '</td>';
|
||||
echo '<td style="display:flex;gap:6px;flex-wrap:wrap;align-items:center;">';
|
||||
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" style="display:flex;gap:4px;align-items:center;">';
|
||||
echo '<input type="hidden" name="action" value="aksrt_pay_update_shipping" />';
|
||||
echo '<input type="hidden" name="shipping_id" value="' . esc_attr((string) $id) . '" />';
|
||||
wp_nonce_field('aksrt_pay_update_shipping_' . $id);
|
||||
echo '<select name="new_status">';
|
||||
foreach ($row_status_options as $key => $label) {
|
||||
printf('<option value="%s"%s>%s</option>', esc_attr($key), selected($st, $key, false), esc_html($label));
|
||||
}
|
||||
echo '</select>';
|
||||
echo '<input type="text" name="note" placeholder="' . esc_attr__('备注', 'aksrt') . '" />';
|
||||
submit_button(__('更新', 'aksrt'), 'small', '', false);
|
||||
echo '</form>';
|
||||
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '">';
|
||||
echo '<input type="hidden" name="action" value="aksrt_pay_query_express" />';
|
||||
echo '<input type="hidden" name="shipping_id" value="' . esc_attr((string) $id) . '" />';
|
||||
echo '<input type="hidden" name="force" value="1" />';
|
||||
wp_nonce_field('aksrt_pay_query_express_' . $id);
|
||||
submit_button(__('查物流', 'aksrt'), 'small', '', false);
|
||||
echo '</form>';
|
||||
echo '<a class="button button-small" href="' . esc_url($this->page_url('aksrt_shipping', ['view' => 'records', 'express_id' => $id, 's' => $order_number])) . '">' . esc_html__('轨迹', 'aksrt') . '</a>';
|
||||
echo '</td></tr>';
|
||||
if ($show_express_id === $id) {
|
||||
echo '<tr class="aksrt-express-row"><td colspan="7">';
|
||||
$this->render_express_timeline_panel($id);
|
||||
echo '</td></tr>';
|
||||
}
|
||||
}
|
||||
echo '</tbody></table>';
|
||||
$this->render_pagination($pages, $page_num);
|
||||
$this->render_footer();
|
||||
}
|
||||
|
||||
private function render_pending_shipment_board(string $search, int $page_num, int $per_page): void {
|
||||
if (!class_exists('\AKSRT\Payment\OrderRepository')) {
|
||||
echo '<div class="notice notice-error"><p>' . esc_html__('订单服务不可用。', 'aksrt') . '</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
echo '<form method="get" style="margin:12px 0;">';
|
||||
echo '<input type="hidden" name="page" value="aksrt_shipping" />';
|
||||
echo '<input type="hidden" name="view" value="pending" />';
|
||||
echo '<input type="search" name="s" value="' . esc_attr($search) . '" placeholder="' . esc_attr__('订单号 / 类型', 'aksrt') . '" /> ';
|
||||
submit_button(__('筛选', 'aksrt'), 'secondary', '', false);
|
||||
echo '</form>';
|
||||
|
||||
$result = \AKSRT\Payment\OrderRepository::get_instance()->get_pending_shipment_orders([
|
||||
'search' => $search,
|
||||
'limit' => $per_page,
|
||||
'offset' => ($page_num - 1) * $per_page,
|
||||
]);
|
||||
$items = $result['items'] ?? [];
|
||||
$total = (int) ($result['total'] ?? 0);
|
||||
$pages = max(1, (int) ($result['max_pages'] ?? 1));
|
||||
$labels = class_exists('\AKSRT\Core\DomainModel')
|
||||
? \AKSRT\Core\DomainModel::order_status_labels()
|
||||
: [];
|
||||
|
||||
echo '<table class="wp-list-table widefat fixed striped"><thead><tr>';
|
||||
echo '<th>' . esc_html__('订单号', 'aksrt') . '</th>';
|
||||
echo '<th>' . esc_html__('用户', 'aksrt') . '</th>';
|
||||
echo '<th>' . esc_html__('类型', 'aksrt') . '</th>';
|
||||
echo '<th>' . esc_html__('金额', 'aksrt') . '</th>';
|
||||
echo '<th>' . esc_html__('状态', 'aksrt') . '</th>';
|
||||
echo '<th>' . esc_html__('支付时间', 'aksrt') . '</th>';
|
||||
echo '<th>' . esc_html__('立即发货', 'aksrt') . '</th>';
|
||||
echo '</tr></thead><tbody>';
|
||||
|
||||
if (!$items) {
|
||||
echo '<tr><td colspan="7">' . esc_html__('暂无待发货订单', 'aksrt') . '</td></tr>';
|
||||
}
|
||||
|
||||
foreach ($items as $row) {
|
||||
$order_number = (string) ($row['order_number'] ?? '');
|
||||
$user_id = (int) ($row['user_id'] ?? 0);
|
||||
$user = $user_id > 0 ? get_userdata($user_id) : null;
|
||||
$status_key = (string) ($row['status'] ?? '');
|
||||
echo '<tr>';
|
||||
echo '<td><a href="' . esc_url($this->page_url('aksrt_orders', ['order' => $order_number])) . '"><code>' . esc_html($order_number) . '</code></a></td>';
|
||||
echo '<td>' . ($user ? esc_html($user->display_name . ' (#' . $user_id . ')') : esc_html((string) $user_id)) . '</td>';
|
||||
echo '<td>' . esc_html((string) ($row['product_type'] ?? '')) . '</td>';
|
||||
echo '<td>' . esc_html(number_format((float) ($row['amount'] ?? 0), 2)) . ' ' . esc_html((string) ($row['currency'] ?? '')) . '</td>';
|
||||
echo '<td>' . esc_html($labels[$status_key] ?? $status_key) . '</td>';
|
||||
echo '<td>' . esc_html((string) ($row['paid_at'] ?? $row['created_at'] ?? '')) . '</td>';
|
||||
echo '<td>';
|
||||
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" style="display:flex;flex-wrap:wrap;gap:4px;align-items:center;">';
|
||||
echo '<input type="hidden" name="action" value="aksrt_pay_create_shipping" />';
|
||||
echo '<input type="hidden" name="order_number" value="' . esc_attr($order_number) . '" />';
|
||||
echo '<input type="hidden" name="redirect_page" value="pending" />';
|
||||
wp_nonce_field('aksrt_pay_create_shipping_' . $order_number);
|
||||
$this->render_carrier_field('');
|
||||
echo '<input type="text" name="tracking_no" required placeholder="' . esc_attr__('运单号', 'aksrt') . '" />';
|
||||
echo '<input type="text" name="note" placeholder="' . esc_attr__('备注', 'aksrt') . '" />';
|
||||
submit_button(__('发货', 'aksrt'), 'primary small', '', false);
|
||||
echo '</form>';
|
||||
echo '</td>';
|
||||
echo '</tr>';
|
||||
}
|
||||
echo '</tbody></table>';
|
||||
$this->render_pagination($pages, $page_num);
|
||||
}
|
||||
|
||||
public function handle_update_shipping(): void {
|
||||
if (!current_user_can(self::CAPABILITY)) {
|
||||
wp_die(esc_html__('权限不足', 'aksrt'));
|
||||
}
|
||||
$id = isset($_POST['shipping_id']) ? (int) $_POST['shipping_id'] : 0;
|
||||
$new_status = isset($_POST['new_status']) ? sanitize_key((string) wp_unslash($_POST['new_status'])) : '';
|
||||
$note = isset($_POST['note']) ? sanitize_textarea_field((string) wp_unslash($_POST['note'])) : '';
|
||||
check_admin_referer('aksrt_pay_update_shipping_' . $id);
|
||||
|
||||
$ok = false;
|
||||
if ($id > 0 && $new_status !== '' && class_exists('\AKSRT\Payment\Shipping')) {
|
||||
$ok = \AKSRT\Payment\Shipping::get_instance()->update_status($id, $new_status, $note, get_current_user_id());
|
||||
}
|
||||
|
||||
$redirect_order = isset($_POST['redirect_order']) ? sanitize_text_field((string) wp_unslash($_POST['redirect_order'])) : '';
|
||||
if ($redirect_order !== '') {
|
||||
wp_safe_redirect($this->page_url('aksrt_orders', [
|
||||
'order' => $redirect_order,
|
||||
'aksrt_pay_msg' => $ok ? 'shipping_updated' : 'shipping_failed',
|
||||
]));
|
||||
exit;
|
||||
}
|
||||
|
||||
wp_safe_redirect($this->page_url('aksrt_shipping', [
|
||||
'view' => 'records',
|
||||
'aksrt_pay_msg' => $ok ? 'shipping_updated' : 'shipping_failed',
|
||||
]));
|
||||
exit;
|
||||
}
|
||||
|
||||
public function handle_query_express(): void {
|
||||
if (!current_user_can(self::CAPABILITY)) {
|
||||
wp_die(esc_html__('权限不足', 'aksrt'));
|
||||
}
|
||||
$id = isset($_POST['shipping_id']) ? (int) $_POST['shipping_id'] : 0;
|
||||
$force = !empty($_POST['force']);
|
||||
check_admin_referer('aksrt_pay_query_express_' . $id);
|
||||
|
||||
$ok = false;
|
||||
$order_number = '';
|
||||
if ($id > 0 && class_exists('\AKSRT\Payment\Shipping')) {
|
||||
$svc = \AKSRT\Payment\Shipping::get_instance();
|
||||
$record = $svc->get($id);
|
||||
$order_number = is_array($record) ? (string) ($record['order_number'] ?? '') : '';
|
||||
$result = $svc->query_express($id, $force);
|
||||
$ok = !empty($result['success']);
|
||||
}
|
||||
|
||||
wp_safe_redirect($this->page_url('aksrt_shipping', [
|
||||
'view' => 'records',
|
||||
'express_id' => $id,
|
||||
's' => $order_number,
|
||||
'aksrt_pay_msg' => $ok ? 'express_ok' : 'express_failed',
|
||||
]));
|
||||
exit;
|
||||
}
|
||||
|
||||
public function handle_create_shipping(): void {
|
||||
if (!current_user_can(self::CAPABILITY)) {
|
||||
wp_die(esc_html__('权限不足', 'aksrt'));
|
||||
}
|
||||
$order_number = isset($_POST['order_number']) ? sanitize_text_field((string) wp_unslash($_POST['order_number'])) : '';
|
||||
$redirect_page = isset($_POST['redirect_page']) ? sanitize_key((string) wp_unslash($_POST['redirect_page'])) : 'order';
|
||||
$nonce_action = $redirect_page === 'shipping' ? 'aksrt_pay_create_shipping' : ('aksrt_pay_create_shipping_' . $order_number);
|
||||
check_admin_referer($nonce_action);
|
||||
|
||||
$ok = false;
|
||||
if ($order_number !== '' && class_exists('\AKSRT\Payment\Shipping')) {
|
||||
if (class_exists('\AKSRT\Payment\OrderRepository')) {
|
||||
$order = \AKSRT\Payment\OrderRepository::get_instance()->get($order_number);
|
||||
if (!$order) {
|
||||
$this->redirect_shipping_create_result(false, $order_number, $redirect_page);
|
||||
}
|
||||
}
|
||||
$id = \AKSRT\Payment\Shipping::get_instance()->create([
|
||||
'order_number' => $order_number,
|
||||
'carrier' => sanitize_text_field((string) wp_unslash($_POST['carrier'] ?? '')),
|
||||
'tracking_no' => sanitize_text_field((string) wp_unslash($_POST['tracking_no'] ?? '')),
|
||||
'note' => sanitize_textarea_field((string) wp_unslash($_POST['note'] ?? '')),
|
||||
'operator_id' => get_current_user_id(),
|
||||
]);
|
||||
$ok = $id > 0;
|
||||
}
|
||||
|
||||
$this->redirect_shipping_create_result($ok, $order_number, $redirect_page);
|
||||
}
|
||||
|
||||
private function redirect_shipping_create_result(bool $ok, string $order_number, string $redirect_page): void {
|
||||
$msg = $ok ? 'shipping_created' : 'shipping_create_failed';
|
||||
if ($redirect_page === 'shipping') {
|
||||
wp_safe_redirect($this->page_url('aksrt_shipping', [
|
||||
'view' => 'records',
|
||||
'aksrt_pay_msg' => $msg,
|
||||
's' => $order_number,
|
||||
]));
|
||||
exit;
|
||||
}
|
||||
if ($redirect_page === 'pending') {
|
||||
wp_safe_redirect($this->page_url('aksrt_shipping', [
|
||||
'view' => 'pending',
|
||||
'aksrt_pay_msg' => $msg,
|
||||
]));
|
||||
exit;
|
||||
}
|
||||
wp_safe_redirect($this->page_url('aksrt_orders', [
|
||||
'order' => $order_number,
|
||||
'aksrt_pay_msg' => $msg,
|
||||
]));
|
||||
exit;
|
||||
}
|
||||
|
||||
|
||||
public function shipping_status_options_public(bool $include_all = false): array {
|
||||
return $this->shipping_status_options($include_all);
|
||||
}
|
||||
|
||||
private function shipping_status_options(bool $include_all = false): array {
|
||||
$options = [];
|
||||
if ($include_all) {
|
||||
$options[''] = __('全部状态', 'aksrt');
|
||||
}
|
||||
if (class_exists('\AKSRT\Payment\Shipping')) {
|
||||
foreach (\AKSRT\Payment\Shipping::get_status_options() as $item) {
|
||||
$value = (string) ($item['value'] ?? '');
|
||||
if ($value === '') {
|
||||
continue;
|
||||
}
|
||||
$options[$value] = (string) ($item['label'] ?? $value);
|
||||
}
|
||||
}
|
||||
if (count($options) === (int) $include_all) {
|
||||
$options = array_merge($options, [
|
||||
'shipped' => __('已发货', 'aksrt'),
|
||||
'in_transit' => __('运输中', 'aksrt'),
|
||||
'delivered' => __('已签收', 'aksrt'),
|
||||
'returned' => __('已退回', 'aksrt'),
|
||||
]);
|
||||
}
|
||||
return $options;
|
||||
}
|
||||
public function render_carrier_field_public(string $selected = ''): void {
|
||||
$this->render_carrier_field($selected);
|
||||
}
|
||||
|
||||
private function render_carrier_field(string $selected = ''): void {
|
||||
$options = [];
|
||||
if (class_exists('\AKSRT\Payment\Shipping')) {
|
||||
foreach (\AKSRT\Payment\Shipping::get_carrier_options() as $item) {
|
||||
$value = (string) ($item['value'] ?? '');
|
||||
$label = (string) ($item['label'] ?? $value);
|
||||
if ($value !== '') {
|
||||
$options[$value] = $label;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($options) {
|
||||
echo '<select name="carrier" required>';
|
||||
foreach ($options as $value => $label) {
|
||||
printf(
|
||||
'<option value="%s"%s>%s</option>',
|
||||
esc_attr($value),
|
||||
selected($selected, $value, false),
|
||||
esc_html($label)
|
||||
);
|
||||
}
|
||||
echo '</select>';
|
||||
return;
|
||||
}
|
||||
echo '<input type="text" name="carrier" value="' . esc_attr($selected) . '" required placeholder="SF/YTO/..." />';
|
||||
}
|
||||
private function render_express_timeline_panel(int $shipping_id): void {
|
||||
if ($shipping_id <= 0 || !class_exists('\AKSRT\Payment\Shipping')) {
|
||||
echo '<p>' . esc_html__('无法加载物流轨迹。', 'aksrt') . '</p>';
|
||||
return;
|
||||
}
|
||||
$svc = \AKSRT\Payment\Shipping::get_instance();
|
||||
// Prefer cached data; do not force remote query on pure view.
|
||||
$timeline = $svc->build_express_timeline($shipping_id);
|
||||
$record = $svc->get($shipping_id);
|
||||
echo '<div style="padding:10px 12px;background:#f6f7f7;border:1px solid #dcdcde;">';
|
||||
echo '<p style="margin:0 0 8px;"><strong>' . esc_html__('物流轨迹', 'aksrt') . '</strong> · ';
|
||||
echo esc_html((string) ($record['carrier'] ?? '')) . ' / ' . esc_html((string) ($record['tracking_no'] ?? ''));
|
||||
echo '</p>';
|
||||
if (!$timeline) {
|
||||
echo '<p style="margin:0;">' . esc_html__('暂无轨迹,可点击「查物流」拉取快递 API。', 'aksrt') . '</p>';
|
||||
echo '</div>';
|
||||
return;
|
||||
}
|
||||
echo '<ol style="margin:0;padding-left:18px;">';
|
||||
// newest first for operators
|
||||
$timeline = array_reverse($timeline);
|
||||
foreach ($timeline as $item) {
|
||||
$time = (string) ($item['time'] ?? '');
|
||||
$label = (string) ($item['label'] ?? '');
|
||||
echo '<li style="margin:0 0 6px;"><code>' . esc_html($time) . '</code> ' . esc_html($label) . '</li>';
|
||||
}
|
||||
echo '</ol></div>';
|
||||
}
|
||||
|
||||
|
||||
private function ensure_cap(): void {
|
||||
if (!current_user_can(self::CAPABILITY)) {
|
||||
wp_die(esc_html__('您没有权限访问此页面。', 'aksrt'));
|
||||
}
|
||||
}
|
||||
|
||||
private function render_header(string $title): void {
|
||||
echo '<div class="wrap">';
|
||||
echo '<h1>' . esc_html($title) . '</h1>';
|
||||
$this->render_flash();
|
||||
}
|
||||
|
||||
private function render_footer(): void {
|
||||
echo '</div>';
|
||||
}
|
||||
|
||||
private function render_flash(): void {
|
||||
if (empty($_GET['aksrt_pay_msg'])) {
|
||||
return;
|
||||
}
|
||||
$code = sanitize_key((string) wp_unslash($_GET['aksrt_pay_msg']));
|
||||
$map = [
|
||||
'shipping_updated' => __('物流状态已更新。', 'aksrt'),
|
||||
'shipping_failed' => __('物流状态更新失败。', 'aksrt'),
|
||||
'shipping_created' => __('发货记录已创建。', 'aksrt'),
|
||||
'shipping_create_failed' => __('发货记录创建失败。', 'aksrt'),
|
||||
'express_ok' => __('物流轨迹已更新。', 'aksrt'),
|
||||
'express_failed' => __('物流轨迹查询失败。', 'aksrt'),
|
||||
];
|
||||
if (!isset($map[$code])) {
|
||||
return;
|
||||
}
|
||||
$class = str_contains($code, 'failed') ? 'notice-error' : 'notice-success';
|
||||
echo '<div class="notice ' . esc_attr($class) . ' is-dismissible"><p>' . esc_html($map[$code]) . '</p></div>';
|
||||
}
|
||||
|
||||
private function page_url(string $slug, array $args = []): string {
|
||||
return add_query_arg(array_merge(['page' => $slug], $args), admin_url('admin.php'));
|
||||
}
|
||||
|
||||
private function render_pagination(int $pages, int $page_num): void {
|
||||
if ($pages <= 1) {
|
||||
return;
|
||||
}
|
||||
echo '<div class="tablenav"><div class="tablenav-pages">';
|
||||
echo paginate_links([
|
||||
'base' => add_query_arg('paged', '%#%'),
|
||||
'format' => '',
|
||||
'prev_text' => '«',
|
||||
'next_text' => '»',
|
||||
'total' => $pages,
|
||||
'current' => $page_num,
|
||||
]);
|
||||
echo '</div></div>';
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Admin\Schema;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class SchemaShop {
|
||||
|
||||
public static function build(): array {
|
||||
return [
|
||||
'key' => 'shop',
|
||||
'title' => '商城&商品',
|
||||
'version' => '1.0.0',
|
||||
'sections' => [
|
||||
'basic' => SchemaShopBasic::build(),
|
||||
'list' => SchemaShopList::build(),
|
||||
'contact' => SchemaShopContact::build(),
|
||||
'single' => SchemaShopSingle::build(),
|
||||
'product-defaults' => SchemaShopProductDefaults::build(),
|
||||
'shipping-config' => SchemaShopShippingConfig::build(),
|
||||
'timing' => SchemaShopTiming::build(),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Admin\Schema;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class SchemaShopBasic {
|
||||
|
||||
public static function build(): array {
|
||||
return [
|
||||
'key' => 'basic',
|
||||
'title' => '商城基础',
|
||||
'version' => '1.0.0',
|
||||
'option_key' => 'aksrt_settings',
|
||||
'groups' => [
|
||||
['key' => 'general', 'title' => '商城开关', 'fields' => [
|
||||
['key' => 'shop_s', 'label' => '启用商城', 'type' => 'switch', 'default' => true],
|
||||
['key' => 'shop_home_url', 'label' => '商城首页链接', 'type' => 'input', 'default' => ''],
|
||||
]],
|
||||
['key' => 'breadcrumbs', 'title' => '面包屑', 'fields' => [
|
||||
['key' => 'shop_breadcrumbs_s', 'label' => '显示面包屑', 'type' => 'switch', 'default' => true],
|
||||
['key' => 'shop_breadcrumbs_home', 'label' => '显示首页链接', 'type' => 'switch', 'default' => true],
|
||||
['key' => 'shop_breadcrumbs_shop_home', 'label' => '显示商城首页', 'type' => 'switch', 'default' => false],
|
||||
['key' => 'shop_breadcrumbs_shop_home_name', 'label' => '商城首页名称', 'type' => 'input', 'default' => '商城首页'],
|
||||
]],
|
||||
['key' => 'rewrite', 'title' => 'URL 别名', 'fields' => [
|
||||
['key' => 'shop_product_rewrite_slug', 'label' => '商品 URL 别名', 'type' => 'input', 'default' => 'product'],
|
||||
['key' => 'shop_cart_rewrite_slug', 'label' => '购物车 URL 别名', 'type' => 'input', 'default' => 'cart'],
|
||||
['key' => 'shop_rewrite_suffix_html_s', 'label' => '.html 伪静态后缀', 'type' => 'switch', 'default' => true],
|
||||
]],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Admin\Schema;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class SchemaShopContact {
|
||||
|
||||
public static function build(): array {
|
||||
return [
|
||||
'key' => 'contact',
|
||||
'title' => '客服配置',
|
||||
'version' => '1.0.0',
|
||||
'option_key' => 'aksrt_settings',
|
||||
'groups' => [
|
||||
['key' => 'button', 'title' => '客服按钮', 'fields' => [
|
||||
['key' => 'shop_contact_msg_s', 'label' => '显示客服按钮', 'type' => 'switch', 'default' => true],
|
||||
['key' => 'shop_contact_name', 'label' => '按钮文案', 'type' => 'input', 'default' => '联系客服'],
|
||||
['key' => 'shop_contact_note', 'label' => '客服备注', 'type' => 'textarea', 'default' => ''],
|
||||
]],
|
||||
['key' => 'channels', 'title' => '联系方式', 'fields' => [
|
||||
['key' => 'shop_contact_more', 'label' => '多客服联系方式', 'type' => 'textarea', 'default' => '', 'help' => '每行一个:名称|图标|链接|二维码图片 URL'],
|
||||
]],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Admin\Schema;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class SchemaShopList {
|
||||
|
||||
public static function build(): array {
|
||||
return [
|
||||
'key' => 'list',
|
||||
'title' => '商品列表',
|
||||
'version' => '1.0.0',
|
||||
'option_key' => 'aksrt_settings',
|
||||
'groups' => [
|
||||
['key' => 'pagination', 'title' => '列表分页', 'fields' => [
|
||||
['key' => 'shop_list_orderby', 'label' => '默认排序', 'type' => 'select', 'default' => 'date', 'options' => [
|
||||
['label' => '最新发布', 'value' => 'date'],
|
||||
['label' => '销量最高', 'value' => 'sales'],
|
||||
['label' => '价格最低', 'value' => 'price_asc'],
|
||||
['label' => '价格最高', 'value' => 'price_desc'],
|
||||
['label' => '评价最多', 'value' => 'comments'],
|
||||
]],
|
||||
['key' => 'shop_list_count', 'label' => '每页数量', 'type' => 'number', 'default' => 12, 'min' => 4, 'max' => 100],
|
||||
['key' => 'shop_list_paging', 'label' => '翻页模式', 'type' => 'radio', 'default' => 'number', 'options' => [
|
||||
['label' => '数字翻页', 'value' => 'number'],
|
||||
['label' => 'AJAX 加载', 'value' => 'ajax'],
|
||||
]],
|
||||
['key' => 'shop_list_ias_s', 'label' => '滚动自动加载', 'type' => 'switch', 'default' => false],
|
||||
['key' => 'shop_list_ias_max', 'label' => '自动加载页数', 'type' => 'number', 'default' => 5, 'min' => 1, 'max' => 50],
|
||||
]],
|
||||
['key' => 'style', 'title' => '列表样式', 'fields' => [
|
||||
['key' => 'shop_list_style', 'label' => '卡片样式', 'type' => 'select', 'default' => 'default', 'options' => [
|
||||
['label' => '默认', 'value' => 'default'],
|
||||
['label' => '竖向大卡片', 'value' => 'card-lg'],
|
||||
['label' => '横向小卡片', 'value' => 'card-sm'],
|
||||
]],
|
||||
['key' => 'shop_sales_show', 'label' => '销量显示', 'type' => 'select', 'default' => 'show', 'options' => [
|
||||
['label' => '显示', 'value' => 'show'],
|
||||
['label' => '不显示', 'value' => 'hide'],
|
||||
['label' => '超量显示', 'value' => 'threshold'],
|
||||
]],
|
||||
['key' => 'shop_sales_show_min', 'label' => '起显阈值', 'type' => 'number', 'default' => 10, 'min' => 0, 'max' => 99999],
|
||||
]],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Admin\Schema;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class SchemaShopProductDefaults {
|
||||
|
||||
public static function build(): array {
|
||||
return [
|
||||
'key' => 'product-defaults',
|
||||
'title' => '商品默认参数',
|
||||
'version' => '1.0.0',
|
||||
'option_key' => 'aksrt_settings',
|
||||
'groups' => [
|
||||
['key' => 'policy', 'title' => '购买策略', 'fields' => [
|
||||
['key' => 'shop_limit_buy', 'label' => '默认限购', 'type' => 'number', 'default' => -1, 'min' => -1, 'max' => 9999, 'help' => '-1 不限,0 禁购,正数为限购数量'],
|
||||
['key' => 'shop_guest_buy', 'label' => '免登录购买', 'type' => 'switch', 'default' => false],
|
||||
['key' => 'shop_email_fill', 'label' => '邮箱必留', 'type' => 'switch', 'default' => false],
|
||||
['key' => 'shop_rebate', 'label' => '允许返佣', 'type' => 'switch', 'default' => true],
|
||||
]],
|
||||
['key' => 'shipping', 'title' => '运费默认', 'fields' => [
|
||||
['key' => 'shop_shipping_fee', 'label' => '默认运费类型', 'type' => 'select', 'default' => 'free', 'options' => [
|
||||
['label' => '包邮', 'value' => 'free'],
|
||||
['label' => '固定运费', 'value' => 'fixed'],
|
||||
['label' => '按金额计算', 'value' => 'amount'],
|
||||
]],
|
||||
]],
|
||||
['key' => 'afterSale', 'title' => '售后与服务', 'fields' => [
|
||||
['key' => 'shop_after_sale', 'label' => '默认售后政策', 'type' => 'select', 'default' => 'refund_return', 'options' => [
|
||||
['label' => '仅退款', 'value' => 'refund'],
|
||||
['label' => '退货退款', 'value' => 'refund_return'],
|
||||
['label' => '不支持售后', 'value' => 'none'],
|
||||
]],
|
||||
['key' => 'shop_service', 'label' => '服务保障', 'type' => 'textarea', 'default' => ''],
|
||||
]],
|
||||
['key' => 'params', 'title' => '默认参数模板', 'fields' => [
|
||||
['key' => 'shop_product_params_default', 'label' => '商品参数模板', 'type' => 'textarea', 'default' => ''],
|
||||
]],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Admin\Schema;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class SchemaShopShippingConfig {
|
||||
|
||||
public static function build(): array {
|
||||
return [
|
||||
'key' => 'shipping-config',
|
||||
'title' => '物流配置',
|
||||
'version' => '1.0.0',
|
||||
'option_key' => 'aksrt_settings',
|
||||
'groups' => [
|
||||
['key' => 'address', 'title' => '收货地址', 'fields' => [
|
||||
['key' => 'shop_manual_address', 'label' => '手动填写收货地址', 'type' => 'switch', 'default' => false],
|
||||
]],
|
||||
['key' => 'express', 'title' => '快递查询', 'fields' => [
|
||||
['key' => 'shop_express_api_sdk', 'label' => '快递查询接口', 'type' => 'select', 'default' => 'kuaidi100', 'options' => [
|
||||
['label' => '快递100', 'value' => 'kuaidi100'],
|
||||
['label' => '阿里云', 'value' => 'aliyun'],
|
||||
['label' => '快递鸟', 'value' => 'kdniao'],
|
||||
]],
|
||||
['key' => 'shop_express_query_interval', 'label' => '查询刷新间隔(分钟)', 'type' => 'number', 'default' => 60, 'min' => 30, 'max' => 240],
|
||||
]],
|
||||
['key' => 'credentials', 'title' => '接口凭据', 'fields' => [
|
||||
['key' => 'shop_express_kdniao_opt', 'label' => '快递鸟凭据', 'type' => 'textarea', 'default' => ''],
|
||||
['key' => 'shop_express_kuaidi100_opt', 'label' => '快递100凭据', 'type' => 'textarea', 'default' => ''],
|
||||
['key' => 'shop_express_aliyun_opt', 'label' => '阿里云凭据', 'type' => 'textarea', 'default' => ''],
|
||||
]],
|
||||
['key' => 'companies', 'title' => '物流公司', 'fields' => [
|
||||
['key' => 'shop_shipping_company', 'label' => '物流公司列表', 'type' => 'textarea', 'default' => ''],
|
||||
]],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Admin\Schema;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class SchemaShopSingle {
|
||||
|
||||
public static function build(): array {
|
||||
return [
|
||||
'key' => 'single',
|
||||
'title' => '商品详情',
|
||||
'version' => '1.0.0',
|
||||
'option_key' => 'aksrt_settings',
|
||||
'groups' => [
|
||||
['key' => 'layout', 'title' => '详情页布局', 'fields' => [
|
||||
['key' => 'shop_content_layout', 'label' => '内容布局', 'type' => 'select', 'default' => 'adapt', 'options' => [
|
||||
['label' => '适应宽度', 'value' => 'adapt'],
|
||||
['label' => '全屏', 'value' => 'full'],
|
||||
['label' => '适应宽度+侧栏', 'value' => 'sidebar'],
|
||||
]],
|
||||
['key' => 'shop_content_show_bg', 'label' => '详情背景盒子', 'type' => 'switch', 'default' => false],
|
||||
['key' => 'shop_single_tab', 'label' => '自定义 TAB 栏目', 'type' => 'textarea', 'default' => ''],
|
||||
['key' => 'shop_content_after', 'label' => '详情页底部内容', 'type' => 'textarea', 'default' => ''],
|
||||
]],
|
||||
['key' => 'related', 'title' => '相关推荐', 'fields' => [
|
||||
['key' => 'shop_single_related_s', 'label' => '显示相关推荐', 'type' => 'switch', 'default' => true],
|
||||
['key' => 'shop_single_related_opt', 'label' => '推荐配置', 'type' => 'textarea', 'default' => ''],
|
||||
]],
|
||||
['key' => 'image', 'title' => '商品图片', 'fields' => [
|
||||
['key' => 'shop_main_image_default', 'label' => '默认商品主图', 'type' => 'media', 'default' => ''],
|
||||
]],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Admin\Schema;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class SchemaShopTiming {
|
||||
|
||||
public static function build(): array {
|
||||
return [
|
||||
'key' => 'timing',
|
||||
'title' => '时效与售后原因',
|
||||
'version' => '1.0.0',
|
||||
'option_key' => 'aksrt_settings',
|
||||
'groups' => [
|
||||
['key' => 'receipt', 'title' => '确认收货', 'fields' => [
|
||||
['key' => 'shop_order_receipt_max_day', 'label' => '自动确认收货(天)', 'type' => 'number', 'default' => 15, 'min' => 1, 'max' => 60],
|
||||
]],
|
||||
['key' => 'comment', 'title' => '评价时效', 'fields' => [
|
||||
['key' => 'shop_comment_max_day', 'label' => '评价时效(天)', 'type' => 'number', 'default' => 7, 'min' => 1, 'max' => 60],
|
||||
['key' => 'shop_comment_placeholder', 'label' => '评价占位文案', 'type' => 'input', 'default' => ''],
|
||||
['key' => 'shop_comment_img_num', 'label' => '评价图片数', 'type' => 'number', 'default' => 3, 'min' => 0, 'max' => 9],
|
||||
]],
|
||||
['key' => 'afterSale', 'title' => '售后退货', 'fields' => [
|
||||
['key' => 'shop_after_sale_return_express_max_day', 'label' => '退货快递时效(天)', 'type' => 'number', 'default' => 7, 'min' => 1, 'max' => 30],
|
||||
['key' => 'shop_after_sale_reason_refund', 'label' => '仅退款原因', 'type' => 'textarea', 'default' => ''],
|
||||
['key' => 'shop_after_sale_reason_return', 'label' => '退货退款原因', 'type' => 'textarea', 'default' => ''],
|
||||
]],
|
||||
['key' => 'author', 'title' => '商家信息', 'fields' => [
|
||||
['key' => 'shop_author_show', 'label' => '显示商家信息', 'type' => 'switch', 'default' => true],
|
||||
]],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,453 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Admin;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class ShopController extends ControllerBase {
|
||||
use ShopControllerProductAdminColumns;
|
||||
use ShopControllerFrontendFlowOperations;
|
||||
use ShopControllerProductOperations;
|
||||
use ShopControllerOrderOperations;
|
||||
use ShopControllerFulfillmentOperations;
|
||||
use ShopControllerCouponGatewayOperations;
|
||||
use ShopControllerVariantExportOperations;
|
||||
|
||||
private static $instance = null;
|
||||
private $admin;
|
||||
|
||||
public static function get_instance(): self {
|
||||
if (null === self::$instance) self::$instance = new self();
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
public function set_admin(Admin $admin): void {
|
||||
$this->admin = $admin;
|
||||
}
|
||||
|
||||
public function register_routes(): void {
|
||||
register_rest_route(self::API_NAMESPACE, '/shop/frontend/checkout/preview', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'preview_checkout'],
|
||||
'permission_callback' => [$this, 'check_logged_in_permission'],
|
||||
]);
|
||||
|
||||
register_rest_route(self::API_NAMESPACE, '/shop/frontend/orders', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'create_frontend_order'],
|
||||
'permission_callback' => [$this, 'check_logged_in_permission'],
|
||||
]);
|
||||
|
||||
register_rest_route(self::API_NAMESPACE, '/shop/frontend/orders', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'get_frontend_orders'],
|
||||
'permission_callback' => [$this, 'check_logged_in_permission'],
|
||||
]);
|
||||
|
||||
register_rest_route(self::API_NAMESPACE, '/shop/frontend/orders/(?P<order_number>[A-Za-z0-9_-]+)/pay', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'pay_frontend_order'],
|
||||
'permission_callback' => [$this, 'check_logged_in_permission'],
|
||||
]);
|
||||
|
||||
register_rest_route(self::API_NAMESPACE, '/shop/frontend/orders/(?P<order_number>[A-Za-z0-9_-]+)/cancel', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'cancel_frontend_order'],
|
||||
'permission_callback' => [$this, 'check_logged_in_permission'],
|
||||
]);
|
||||
|
||||
register_rest_route(self::API_NAMESPACE, '/shop/frontend/orders/(?P<order_number>[A-Za-z0-9_-]+)', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'get_frontend_order'],
|
||||
'permission_callback' => [$this, 'check_logged_in_permission'],
|
||||
]);
|
||||
|
||||
register_rest_route(self::API_NAMESPACE, '/shop/frontend/orders/(?P<order_number>[A-Za-z0-9_-]+)/after-sale', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'create_frontend_after_sale'],
|
||||
'permission_callback' => [$this, 'check_logged_in_permission'],
|
||||
]);
|
||||
|
||||
register_rest_route(self::API_NAMESPACE, '/shop/frontend/after-sale', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'get_frontend_after_sales'],
|
||||
'permission_callback' => [$this, 'check_logged_in_permission'],
|
||||
]);
|
||||
|
||||
register_rest_route(self::API_NAMESPACE, '/shop/frontend/after-sale/(?P<id>\d+)/return-shipping', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'submit_frontend_return_shipping'],
|
||||
'permission_callback' => [$this, 'check_logged_in_permission'],
|
||||
]);
|
||||
|
||||
register_rest_route(self::API_NAMESPACE, '/shop/frontend/after-sale/(?P<id>\d+)/cancel', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'cancel_frontend_after_sale'],
|
||||
'permission_callback' => [$this, 'check_logged_in_permission'],
|
||||
]);
|
||||
|
||||
register_rest_route(self::API_NAMESPACE, '/shop/frontend/coupons', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'get_frontend_coupons'],
|
||||
'permission_callback' => [$this, 'check_logged_in_permission'],
|
||||
]);
|
||||
|
||||
// ── Address book CRUD (frontend) ──
|
||||
|
||||
register_rest_route(self::API_NAMESPACE, '/shop/frontend/address', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'get_frontend_addresses'],
|
||||
'permission_callback' => [$this, 'check_logged_in_permission'],
|
||||
]);
|
||||
|
||||
register_rest_route(self::API_NAMESPACE, '/shop/frontend/address', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'create_frontend_address'],
|
||||
'permission_callback' => [$this, 'check_logged_in_permission'],
|
||||
]);
|
||||
|
||||
register_rest_route(self::API_NAMESPACE, '/shop/frontend/address/(?P<id>\d+)', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'update_frontend_address'],
|
||||
'permission_callback' => [$this, 'check_logged_in_permission'],
|
||||
]);
|
||||
|
||||
register_rest_route(self::API_NAMESPACE, '/shop/frontend/address/(?P<id>\d+)', [
|
||||
'methods' => 'DELETE',
|
||||
'callback' => [$this, 'delete_frontend_address'],
|
||||
'permission_callback' => [$this, 'check_logged_in_permission'],
|
||||
]);
|
||||
|
||||
register_rest_route(self::API_NAMESPACE, '/shop/frontend/address/(?P<id>\d+)/default', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'set_default_frontend_address'],
|
||||
'permission_callback' => [$this, 'check_logged_in_permission'],
|
||||
]);
|
||||
|
||||
register_rest_route(self::API_NAMESPACE, '/shop/frontend/products', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'create_frontend_product'],
|
||||
'permission_callback' => [$this, 'check_logged_in_permission'],
|
||||
]);
|
||||
|
||||
register_rest_route(self::API_NAMESPACE, '/shop/frontend/products/(?P<id>\d+)', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'update_frontend_product'],
|
||||
'permission_callback' => [$this, 'check_logged_in_permission'],
|
||||
]);
|
||||
|
||||
// Cart endpoints (§6.4)
|
||||
register_rest_route(self::API_NAMESPACE, '/shop/frontend/cart', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'get_cart'],
|
||||
'permission_callback' => [$this, 'check_logged_in_permission'],
|
||||
]);
|
||||
|
||||
register_rest_route(self::API_NAMESPACE, '/shop/frontend/cart/add', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'handle_add_to_cart'],
|
||||
'permission_callback' => [$this, 'check_logged_in_permission'],
|
||||
]);
|
||||
|
||||
register_rest_route(self::API_NAMESPACE, '/shop/frontend/cart/reduce', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'handle_reduce_from_cart'],
|
||||
'permission_callback' => [$this, 'check_logged_in_permission'],
|
||||
]);
|
||||
|
||||
register_rest_route(self::API_NAMESPACE, '/shop/frontend/cart/remove', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'handle_remove_from_cart'],
|
||||
'permission_callback' => [$this, 'check_logged_in_permission'],
|
||||
]);
|
||||
|
||||
register_rest_route(self::API_NAMESPACE, '/shop/frontend/cart/remove-multi', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'handle_remove_multi_from_cart'],
|
||||
'permission_callback' => [$this, 'check_logged_in_permission'],
|
||||
]);
|
||||
|
||||
register_rest_route(self::API_NAMESPACE, '/shop/frontend/cart/empty', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'handle_empty_cart'],
|
||||
'permission_callback' => [$this, 'check_logged_in_permission'],
|
||||
]);
|
||||
|
||||
register_rest_route(self::API_NAMESPACE, '/shop/frontend/cart/count', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'get_cart_count'],
|
||||
'permission_callback' => [$this, 'check_logged_in_permission'],
|
||||
]);
|
||||
|
||||
// Shop overview / dashboard
|
||||
register_rest_route(self::API_NAMESPACE, '/shop/overview', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'get_overview'],
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
]);
|
||||
|
||||
// Product taxonomy options used by the product editor.
|
||||
register_rest_route(self::API_NAMESPACE, '/shop/products/taxonomy', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'get_product_taxonomy'],
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
]);
|
||||
|
||||
// Create product category or tag term for the product editor.
|
||||
register_rest_route(self::API_NAMESPACE, '/shop/products/taxonomy', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'create_product_taxonomy_term'],
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
]);
|
||||
|
||||
// Product list
|
||||
register_rest_route(self::API_NAMESPACE, '/shop/products', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'get_products'],
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
]);
|
||||
|
||||
// Product create
|
||||
register_rest_route(self::API_NAMESPACE, '/shop/products', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'create_product'],
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
]);
|
||||
|
||||
// Product update
|
||||
register_rest_route(self::API_NAMESPACE, '/shop/products/(?P<id>\d+)', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'update_product'],
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
]);
|
||||
|
||||
// Product single read
|
||||
register_rest_route(self::API_NAMESPACE, '/shop/products/(?P<id>\d+)', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'get_product'],
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
]);
|
||||
|
||||
// Order list
|
||||
register_rest_route(self::API_NAMESPACE, '/shop/orders', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'get_orders'],
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
]);
|
||||
|
||||
// Order single read
|
||||
register_rest_route(self::API_NAMESPACE, '/shop/orders/(?P<order_number>[A-Za-z0-9_-]+)', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'get_order'],
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
]);
|
||||
|
||||
// Order status update — canonical field is order_number
|
||||
register_rest_route(self::API_NAMESPACE, '/shop/orders/update-status', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'update_order_status'],
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
]);
|
||||
|
||||
// Gateway status
|
||||
register_rest_route(self::API_NAMESPACE, '/shop/gateways', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'get_gateways'],
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
]);
|
||||
|
||||
// Coupon list (admin)
|
||||
register_rest_route(self::API_NAMESPACE, '/shop/coupons', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'get_coupons'],
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
]);
|
||||
|
||||
// Coupon create (admin)
|
||||
register_rest_route(self::API_NAMESPACE, '/shop/coupons', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'create_coupon'],
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
]);
|
||||
|
||||
// Coupon disable (admin)
|
||||
register_rest_route(self::API_NAMESPACE, '/shop/coupons/(?P<id>\d+)/disable', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'disable_coupon'],
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
]);
|
||||
|
||||
// ── After Sale ──
|
||||
|
||||
// After-sale list (admin)
|
||||
register_rest_route(self::API_NAMESPACE, '/shop/after-sale', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'get_after_sale_list'],
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
]);
|
||||
|
||||
// After-sale create (admin)
|
||||
register_rest_route(self::API_NAMESPACE, '/shop/after-sale', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'create_after_sale'],
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
]);
|
||||
|
||||
// After-sale status update (admin)
|
||||
register_rest_route(self::API_NAMESPACE, '/shop/after-sale/(?P<id>\d+)/update-status', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'update_after_sale_status'],
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
]);
|
||||
|
||||
register_rest_route(self::API_NAMESPACE, '/shop/after-sale/(?P<id>\d+)', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'get_after_sale_detail'],
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
]);
|
||||
|
||||
// ── Shipping ──
|
||||
|
||||
// Shipping list (admin)
|
||||
register_rest_route(self::API_NAMESPACE, '/shop/shipping', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'get_shipping_list'],
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
]);
|
||||
|
||||
// Shipping create (admin)
|
||||
register_rest_route(self::API_NAMESPACE, '/shop/shipping', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'create_shipping'],
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
]);
|
||||
|
||||
// Shipping status update (admin)
|
||||
register_rest_route(self::API_NAMESPACE, '/shop/shipping/(?P<id>\d+)/update-status', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'update_shipping_status'],
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
]);
|
||||
|
||||
register_rest_route(self::API_NAMESPACE, '/shop/shipping/(?P<id>\d+)', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'get_shipping_detail'],
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
]);
|
||||
|
||||
register_rest_route(self::API_NAMESPACE, '/shop/shipping/batch-update-status', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'batch_update_shipping_status'],
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
]);
|
||||
|
||||
|
||||
// ── R68: Order export + confirm receipt + after-sale reasons ──
|
||||
|
||||
register_rest_route(self::API_NAMESPACE, '/shop/orders/export', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'export_orders'],
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
]);
|
||||
|
||||
// ── R69: Frontend after-sale reasons (§6.8) ──
|
||||
|
||||
register_rest_route(self::API_NAMESPACE, '/shop/frontend/after-sale/reasons', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'get_frontend_after_sale_reasons'],
|
||||
'permission_callback' => [$this, 'check_logged_in_permission'],
|
||||
]);
|
||||
|
||||
register_rest_route(self::API_NAMESPACE, '/shop/frontend/orders/(?P<order_number>[A-Za-z0-9_-]+)/confirm-receipt', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'confirm_receipt'],
|
||||
'permission_callback' => [$this, 'check_logged_in_permission'],
|
||||
]);
|
||||
|
||||
register_rest_route(self::API_NAMESPACE, '/shop/frontend/orders/(?P<order_number>[A-Za-z0-9_-]+)/rebuy', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'rebuy_frontend_order'],
|
||||
'permission_callback' => [$this, 'check_logged_in_permission'],
|
||||
]);
|
||||
|
||||
register_rest_route(self::API_NAMESPACE, '/shop/frontend/products/archive', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'get_frontend_product_archive'],
|
||||
'permission_callback' => '__return_true',
|
||||
]);
|
||||
|
||||
// Product list widget AJAX (zibll ajax_widget_ui product lists).
|
||||
register_rest_route(self::API_NAMESPACE, '/shop/frontend/widgets/products', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'get_frontend_widget_products'],
|
||||
'permission_callback' => '__return_true',
|
||||
]);
|
||||
|
||||
register_rest_route(self::API_NAMESPACE, '/shop/frontend/revenue', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'get_frontend_revenue'],
|
||||
'permission_callback' => [$this, 'check_logged_in_permission'],
|
||||
]);
|
||||
|
||||
register_rest_route(self::API_NAMESPACE, '/shop/frontend/revenue/withdraw', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'create_frontend_withdraw'],
|
||||
'permission_callback' => [$this, 'check_logged_in_permission'],
|
||||
]);
|
||||
|
||||
register_rest_route(self::API_NAMESPACE, '/shop/frontend/orders/(?P<order_number>[A-Za-z0-9_-]+)/review', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'submit_frontend_product_review'],
|
||||
'permission_callback' => [$this, 'check_logged_in_permission'],
|
||||
]);
|
||||
|
||||
register_rest_route(self::API_NAMESPACE, '/shop/frontend/orders/(?P<order_number>[A-Za-z0-9_-]+)/review-modal', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'get_frontend_product_review_modal'],
|
||||
'permission_callback' => [$this, 'check_logged_in_permission'],
|
||||
]);
|
||||
|
||||
register_rest_route(self::API_NAMESPACE, '/shop/frontend/products/(?P<id>\d+)/favorite', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'toggle_frontend_product_favorite'],
|
||||
'permission_callback' => [$this, 'check_logged_in_permission'],
|
||||
]);
|
||||
|
||||
register_rest_route(self::API_NAMESPACE, '/shop/after-sale/reasons', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'get_after_sale_reasons'],
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
]);
|
||||
|
||||
// ── Product Variants / SKU ──
|
||||
|
||||
// List variants for a product
|
||||
register_rest_route(self::API_NAMESPACE, '/shop/products/(?P<id>\d+)/variants', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [$this, 'get_product_variants'],
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
]);
|
||||
|
||||
// Save variants for a product (replace all)
|
||||
register_rest_route(self::API_NAMESPACE, '/shop/products/(?P<id>\d+)/variants', [
|
||||
'methods' => 'POST',
|
||||
'callback' => [$this, 'save_product_variants'],
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
]);
|
||||
|
||||
// Delete a variant
|
||||
register_rest_route(self::API_NAMESPACE, '/shop/variants/(?P<id>\d+)', [
|
||||
'methods' => 'DELETE',
|
||||
'callback' => [$this, 'delete_variant'],
|
||||
'permission_callback' => [$this, 'check_permission'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function check_logged_in_permission(): bool {
|
||||
return is_user_logged_in();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Admin;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
trait ShopControllerCouponGatewayOperations {
|
||||
|
||||
/**
|
||||
* GET /shop/coupons — 优惠券列表
|
||||
*/
|
||||
public function get_coupons(\WP_REST_Request $request): \WP_REST_Response {
|
||||
if (!class_exists('\AKSRT\Payment\Coupon')) {
|
||||
return $this->success(['data' => ['items' => [], 'total' => 0, 'page' => 1, 'total_pages' => 1]]);
|
||||
}
|
||||
|
||||
$page = max(1, (int) $request->get_param('page'));
|
||||
$per_page = max(1, min(100, (int) $request->get_param('per_page'))) ?: 20;
|
||||
$status = sanitize_key((string) $request->get_param('status'));
|
||||
$search = sanitize_text_field((string) $request->get_param('search'));
|
||||
|
||||
$result = \AKSRT\Payment\Coupon::get_instance()->get_list([
|
||||
'status' => $status,
|
||||
'search' => $search,
|
||||
'limit' => $per_page,
|
||||
'offset' => ($page - 1) * $per_page,
|
||||
]);
|
||||
|
||||
return $this->success(['data' => $result]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /shop/coupons — 创建优惠券
|
||||
*/
|
||||
public function create_coupon(\WP_REST_Request $request): \WP_REST_Response {
|
||||
if (!class_exists('\AKSRT\Payment\Coupon')) {
|
||||
return $this->error('优惠券系统未启用', 500);
|
||||
}
|
||||
|
||||
$params = $this->get_json_params($request);
|
||||
$code = sanitize_text_field((string) ($params['code'] ?? ''));
|
||||
|
||||
if ($code === '') {
|
||||
return $this->error('优惠码不能为空');
|
||||
}
|
||||
|
||||
$id = \AKSRT\Payment\Coupon::get_instance()->create($params);
|
||||
|
||||
if ($id > 0) {
|
||||
return $this->success([
|
||||
'message' => __('优惠券已创建', 'aksrt'),
|
||||
'data' => ['id' => $id],
|
||||
], 201);
|
||||
}
|
||||
|
||||
return $this->error(__('创建失败(可能优惠码已存在)', 'aksrt'));
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /shop/coupons/{id}/disable — 禁用优惠券
|
||||
*/
|
||||
public function disable_coupon(\WP_REST_Request $request): \WP_REST_Response {
|
||||
if (!class_exists('\AKSRT\Payment\Coupon')) {
|
||||
return $this->error('优惠券系统未启用', 500);
|
||||
}
|
||||
|
||||
$id = (int) $request->get_param('id');
|
||||
if ($id <= 0) {
|
||||
return $this->error('参数无效');
|
||||
}
|
||||
|
||||
$success = \AKSRT\Payment\Coupon::get_instance()->disable($id);
|
||||
|
||||
if ($success) {
|
||||
return $this->success(['message' => __('优惠券已禁用', 'aksrt')]);
|
||||
}
|
||||
return $this->error(__('操作失败', 'aksrt'));
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /shop/gateways — 支付网关状态列表
|
||||
*/
|
||||
public function get_gateways(): \WP_REST_Response {
|
||||
if (!class_exists('\AKSRT\Payment\Gateway')) {
|
||||
return $this->success(['data' => ['gateways' => []]]);
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'data' => [
|
||||
'gateways' => \AKSRT\Payment\Gateway::get_instance()->get_status_summary(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,717 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Admin;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
trait ShopControllerFrontendFlowOperations {
|
||||
|
||||
public function check_logged_in_permission(): bool {
|
||||
return is_user_logged_in();
|
||||
}
|
||||
|
||||
public function preview_checkout(\WP_REST_Request $request): \WP_REST_Response {
|
||||
if (!class_exists('\AKSRT\Shop\Shop')) {
|
||||
return $this->error('商城服务不可用', 500);
|
||||
}
|
||||
|
||||
$result = \AKSRT\Shop\Shop::get_instance()->preview_checkout(get_current_user_id(), $this->get_json_params($request));
|
||||
if (empty($result['success'])) {
|
||||
return $this->error($result['message'] ?? '结算预览失败');
|
||||
}
|
||||
|
||||
return $this->success(['data' => $result['data']]);
|
||||
}
|
||||
|
||||
public function create_frontend_order(\WP_REST_Request $request): \WP_REST_Response {
|
||||
if (!class_exists('\AKSRT\Shop\Shop')) {
|
||||
return $this->error('商城服务不可用', 500);
|
||||
}
|
||||
|
||||
$result = \AKSRT\Shop\Shop::get_instance()->create_checkout_order(get_current_user_id(), $this->get_json_params($request));
|
||||
if (empty($result['success'])) {
|
||||
return $this->error($result['message'] ?? '订单创建失败');
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'message' => __('订单已创建', 'aksrt'),
|
||||
'data' => $result['data'],
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function get_frontend_orders(\WP_REST_Request $request): \WP_REST_Response {
|
||||
$page = max(1, (int) $request->get_param('page'));
|
||||
$per_page = max(1, min(50, (int) $request->get_param('per_page'))) ?: 20;
|
||||
$status = sanitize_key((string) $request->get_param('status'));
|
||||
$product_type = sanitize_key((string) $request->get_param('product_type'));
|
||||
|
||||
$args = [
|
||||
'page' => $page,
|
||||
'per_page' => $per_page,
|
||||
'status' => $status,
|
||||
'product_type' => $product_type,
|
||||
];
|
||||
|
||||
if (class_exists('\AKSRT\Payment\UserOrders')) {
|
||||
$view = \AKSRT\Payment\UserOrders::get_instance()->get_order_center_view(get_current_user_id(), $args);
|
||||
} elseif (class_exists('\AKSRT\Shop\Shop')) {
|
||||
$view = \AKSRT\Shop\Shop::get_instance()->get_order_center_view(get_current_user_id(), $args);
|
||||
} else {
|
||||
$view = ['items' => [], 'total' => 0, 'total_pages' => 1, 'page' => 1];
|
||||
}
|
||||
|
||||
return $this->success(['data' => $view]);
|
||||
}
|
||||
|
||||
public function get_frontend_order(\WP_REST_Request $request): \WP_REST_Response {
|
||||
$order_number = sanitize_text_field((string) $request->get_param('order_number'));
|
||||
$order = null;
|
||||
if (class_exists('\AKSRT\Payment\UserOrders')) {
|
||||
$order = \AKSRT\Payment\UserOrders::get_instance()->get_order_detail_view(get_current_user_id(), $order_number);
|
||||
} elseif (class_exists('\AKSRT\Shop\Shop')) {
|
||||
$order = \AKSRT\Shop\Shop::get_instance()->get_order_detail_view(get_current_user_id(), $order_number);
|
||||
}
|
||||
if (!$order) {
|
||||
return $this->error('订单不存在', 404);
|
||||
}
|
||||
|
||||
return $this->success(['data' => $order]);
|
||||
}
|
||||
|
||||
public function pay_frontend_order(\WP_REST_Request $request): \WP_REST_Response {
|
||||
$params = $this->get_json_params($request);
|
||||
$payment_method = sanitize_key((string) ($params['payment_method'] ?? ''));
|
||||
$order_number = sanitize_text_field((string) $request->get_param('order_number'));
|
||||
|
||||
if (class_exists('\AKSRT\Payment\UserOrders')) {
|
||||
$result = \AKSRT\Payment\UserOrders::get_instance()->pay_order(get_current_user_id(), $order_number, $payment_method);
|
||||
} elseif (class_exists('\AKSRT\Shop\Shop')) {
|
||||
$result = \AKSRT\Shop\Shop::get_instance()->pay_order(get_current_user_id(), $order_number, $payment_method ?: 'balance');
|
||||
} else {
|
||||
return $this->error('订单服务不可用', 500);
|
||||
}
|
||||
if (empty($result['success'])) {
|
||||
return $this->error($result['message'] ?? '支付失败');
|
||||
}
|
||||
|
||||
return $this->success(['data' => $result['data']]);
|
||||
}
|
||||
|
||||
public function cancel_frontend_order(\WP_REST_Request $request): \WP_REST_Response {
|
||||
$order_number = sanitize_text_field((string) $request->get_param('order_number'));
|
||||
if (class_exists('\AKSRT\Payment\UserOrders')) {
|
||||
$result = \AKSRT\Payment\UserOrders::get_instance()->cancel_order(get_current_user_id(), $order_number);
|
||||
} elseif (class_exists('\AKSRT\Shop\Shop')) {
|
||||
$result = \AKSRT\Shop\Shop::get_instance()->cancel_order(get_current_user_id(), $order_number);
|
||||
} else {
|
||||
return $this->error('订单服务不可用', 500);
|
||||
}
|
||||
if (empty($result['success'])) {
|
||||
return $this->error($result['message'] ?? '取消订单失败');
|
||||
}
|
||||
|
||||
return $this->success(['data' => $result['data']]);
|
||||
}
|
||||
|
||||
public function create_frontend_after_sale(\WP_REST_Request $request): \WP_REST_Response {
|
||||
if (!class_exists('\AKSRT\Shop\Shop')) {
|
||||
return $this->error('商城服务不可用', 500);
|
||||
}
|
||||
|
||||
$params = $this->get_json_params($request);
|
||||
$params['order_number'] = sanitize_text_field((string) $request->get_param('order_number'));
|
||||
$result = \AKSRT\Shop\Shop::get_instance()->create_after_sale_request(get_current_user_id(), $params);
|
||||
if (empty($result['success'])) {
|
||||
return $this->error($result['message'] ?? '提交售后失败');
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'message' => __('售后申请已提交', 'aksrt'),
|
||||
'data' => $result['data'],
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function get_frontend_after_sales(\WP_REST_Request $request): \WP_REST_Response {
|
||||
if (!class_exists('\AKSRT\Shop\Shop')) {
|
||||
return $this->error('商城服务不可用', 500);
|
||||
}
|
||||
$data = \AKSRT\Shop\Shop::get_instance()->get_user_after_sales(get_current_user_id());
|
||||
return $this->success(['data' => $data]);
|
||||
}
|
||||
|
||||
public function submit_frontend_return_shipping(\WP_REST_Request $request): \WP_REST_Response {
|
||||
if (!class_exists('\AKSRT\Shop\Shop')) {
|
||||
return $this->error('商城服务不可用', 500);
|
||||
}
|
||||
$id = (int) $request->get_param('id');
|
||||
$params = $this->get_json_params($request);
|
||||
$result = \AKSRT\Shop\Shop::get_instance()->submit_after_sale_return(get_current_user_id(), $id, $params);
|
||||
if (empty($result['success'])) {
|
||||
return $this->error($result['message'] ?? '提交退货物流失败');
|
||||
}
|
||||
return $this->success([
|
||||
'message' => $result['message'] ?? __('退货物流已提交', 'aksrt'),
|
||||
'data' => $result['data'] ?? [],
|
||||
]);
|
||||
}
|
||||
|
||||
public function cancel_frontend_after_sale(\WP_REST_Request $request): \WP_REST_Response {
|
||||
if (!class_exists('\AKSRT\Shop\Shop')) {
|
||||
return $this->error('商城服务不可用', 500);
|
||||
}
|
||||
$id = (int) $request->get_param('id');
|
||||
$params = $this->get_json_params($request);
|
||||
$reason = sanitize_text_field((string) ($params['reason'] ?? ''));
|
||||
$result = \AKSRT\Shop\Shop::get_instance()->cancel_after_sale(get_current_user_id(), $id, $reason);
|
||||
if (empty($result['success'])) {
|
||||
return $this->error($result['message'] ?? '取消售后失败');
|
||||
}
|
||||
return $this->success(['message' => $result['message'] ?? __('已取消', 'aksrt')]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Active public coupons for wallet UI (not personal inventory — site-wide active codes).
|
||||
*/
|
||||
public function get_frontend_coupons(\WP_REST_Request $request): \WP_REST_Response {
|
||||
if (!class_exists('\AKSRT\Payment\Coupon')) {
|
||||
return $this->error('优惠券不可用', 500);
|
||||
}
|
||||
$list = \AKSRT\Payment\Coupon::get_instance()->get_list([
|
||||
'status' => 'active',
|
||||
'limit' => 50,
|
||||
'offset' => 0,
|
||||
]);
|
||||
$user_id = get_current_user_id();
|
||||
$items = [];
|
||||
foreach ((array) ($list['items'] ?? []) as $c) {
|
||||
$items[] = [
|
||||
'id' => (int) ($c['id'] ?? 0),
|
||||
'code' => (string) ($c['code'] ?? ''),
|
||||
'type' => (string) ($c['type'] ?? ''),
|
||||
'value' => (float) ($c['value'] ?? 0),
|
||||
'min_amount' => (float) ($c['min_amount'] ?? 0),
|
||||
'max_discount' => (float) ($c['max_discount'] ?? 0),
|
||||
'expires_at' => (string) ($c['expires_at'] ?? ''),
|
||||
'user_used' => $user_id > 0
|
||||
? (int) \AKSRT\Payment\Coupon::get_instance()->get_user_usage_count((int) ($c['id'] ?? 0), $user_id)
|
||||
: 0,
|
||||
'per_user_limit' => (int) ($c['per_user_limit'] ?? 0),
|
||||
];
|
||||
}
|
||||
return $this->success(['data' => ['items' => $items]]);
|
||||
}
|
||||
|
||||
public function get_cart(\WP_REST_Request $request): \WP_REST_Response {
|
||||
$user_id = get_current_user_id();
|
||||
$shop = \AKSRT\Shop\Shop::get_instance();
|
||||
$cart = $shop->get_cart($user_id);
|
||||
|
||||
$items = [];
|
||||
foreach ($cart as $pid => $opts_map) {
|
||||
$product_id = (int) $pid;
|
||||
$product = $shop->build_product_card_view($product_id);
|
||||
if (empty($product) || !is_array($opts_map)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($opts_map as $opt_str => $qty) {
|
||||
$options_string = $opt_str === '_default' ? '' : (string) $opt_str;
|
||||
$unit_price = method_exists($shop, 'resolve_option_price')
|
||||
? (float) $shop->resolve_option_price($product_id, $options_string !== '' ? $options_string : '_default')
|
||||
: (float) ($product['price'] ?? 0);
|
||||
$options_label = method_exists($shop, 'resolve_option_labels')
|
||||
? (string) $shop->resolve_option_labels($product_id, $options_string !== '' ? $options_string : '_default')
|
||||
: '';
|
||||
$option_image = method_exists($shop, 'get_option_image')
|
||||
? (string) $shop->get_option_image($product_id, $options_string !== '' ? $options_string : '_default')
|
||||
: '';
|
||||
$stock = method_exists($shop, 'resolve_option_stock')
|
||||
? (int) $shop->resolve_option_stock($product_id, $options_string !== '' ? $options_string : '_default')
|
||||
: (int) ($product['stock'] ?? 0);
|
||||
|
||||
$items[] = [
|
||||
'product_id' => $product_id,
|
||||
'options_string' => $options_string,
|
||||
'options_label' => $options_label,
|
||||
'quantity' => (int) $qty,
|
||||
'unit_price' => $unit_price,
|
||||
'line_total' => round($unit_price * (int) $qty, 2),
|
||||
'stock' => $stock,
|
||||
'image' => $option_image !== '' ? $option_image : (string) ($product['image'] ?? ''),
|
||||
'title' => (string) ($product['title'] ?? ''),
|
||||
'product' => $product,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'data' => [
|
||||
'items' => $items,
|
||||
'count' => $shop->get_cart_count($user_id),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function get_cart_count(\WP_REST_Request $request): \WP_REST_Response {
|
||||
$user_id = get_current_user_id();
|
||||
return $this->success(['data' => ['count' => \AKSRT\Shop\Shop::get_instance()->get_cart_count($user_id)]]);
|
||||
}
|
||||
|
||||
public function handle_add_to_cart(\WP_REST_Request $request): \WP_REST_Response {
|
||||
$params = $this->get_json_params($request);
|
||||
$product_id = (int) ($params['product_id'] ?? 0);
|
||||
$options_string = sanitize_text_field((string) ($params['options_string'] ?? ''));
|
||||
$count = max(1, (int) ($params['count'] ?? 1));
|
||||
|
||||
$result = \AKSRT\Shop\Shop::get_instance()->add_to_cart(get_current_user_id(), $product_id, $options_string, $count);
|
||||
|
||||
if (isset($result['error'])) {
|
||||
return $this->error($result['error']);
|
||||
}
|
||||
return $this->success(['data' => $result]);
|
||||
}
|
||||
|
||||
public function handle_reduce_from_cart(\WP_REST_Request $request): \WP_REST_Response {
|
||||
$params = $this->get_json_params($request);
|
||||
$product_id = (int) ($params['product_id'] ?? 0);
|
||||
$options_string = sanitize_text_field((string) ($params['options_string'] ?? ''));
|
||||
$count = max(1, (int) ($params['count'] ?? 1));
|
||||
|
||||
$result = \AKSRT\Shop\Shop::get_instance()->reduce_from_cart(get_current_user_id(), $product_id, $options_string, $count);
|
||||
|
||||
if (isset($result['error'])) {
|
||||
return $this->error($result['error']);
|
||||
}
|
||||
return $this->success(['data' => $result]);
|
||||
}
|
||||
|
||||
public function handle_remove_from_cart(\WP_REST_Request $request): \WP_REST_Response {
|
||||
$params = $this->get_json_params($request);
|
||||
$product_id = (int) ($params['product_id'] ?? 0);
|
||||
$options_string = sanitize_text_field((string) ($params['options_string'] ?? ''));
|
||||
|
||||
$result = \AKSRT\Shop\Shop::get_instance()->remove_from_cart(get_current_user_id(), $product_id, $options_string);
|
||||
|
||||
if (isset($result['error'])) {
|
||||
return $this->error($result['error']);
|
||||
}
|
||||
return $this->success(['data' => $result]);
|
||||
}
|
||||
|
||||
public function handle_remove_multi_from_cart(\WP_REST_Request $request): \WP_REST_Response {
|
||||
$params = $this->get_json_params($request);
|
||||
$items = is_array($params['items'] ?? null) ? $params['items'] : [];
|
||||
|
||||
$result = \AKSRT\Shop\Shop::get_instance()->remove_multi_from_cart(get_current_user_id(), $items);
|
||||
|
||||
if (isset($result['error'])) {
|
||||
return $this->error($result['error']);
|
||||
}
|
||||
return $this->success(['data' => $result]);
|
||||
}
|
||||
|
||||
public function handle_empty_cart(\WP_REST_Request $request): \WP_REST_Response {
|
||||
$result = \AKSRT\Shop\Shop::get_instance()->empty_cart(get_current_user_id());
|
||||
|
||||
if (isset($result['error'])) {
|
||||
return $this->error($result['error']);
|
||||
}
|
||||
return $this->success(['data' => $result]);
|
||||
}
|
||||
|
||||
public function toggle_frontend_product_favorite(\WP_REST_Request $request): \WP_REST_Response {
|
||||
if (!class_exists('\AKSRT\Shop\Shop')) {
|
||||
return $this->error('商城服务不可用', 500);
|
||||
}
|
||||
|
||||
$product_id = (int) $request->get_param('id');
|
||||
$result = \AKSRT\Shop\Shop::get_instance()->toggle_favorite(get_current_user_id(), $product_id);
|
||||
if (empty($result['success'])) {
|
||||
return $this->error(__('收藏操作失败', 'aksrt'));
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'message' => !empty($result['is_favorite']) ? __('已收藏', 'aksrt') : __('已取消收藏', 'aksrt'),
|
||||
'data' => $result,
|
||||
]);
|
||||
}
|
||||
|
||||
public function rebuy_frontend_order(\WP_REST_Request $request): \WP_REST_Response {
|
||||
if (!class_exists('\AKSRT\Shop\Shop')) {
|
||||
return $this->error('商城服务不可用', 500);
|
||||
}
|
||||
|
||||
$order_number = sanitize_text_field((string) $request->get_param('order_number'));
|
||||
$result = \AKSRT\Shop\Shop::get_instance()->re_purchase(get_current_user_id(), $order_number);
|
||||
if (empty($result['success'])) {
|
||||
return $this->error($result['message'] ?? __('再次购买失败', 'aksrt'));
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'message' => __('商品已加入购物车', 'aksrt'),
|
||||
'data' => $result,
|
||||
]);
|
||||
}
|
||||
|
||||
public function get_frontend_addresses(): \WP_REST_Response {
|
||||
if (!class_exists('\AKSRT\Shop\Shop')) {
|
||||
return $this->success(['data' => ['addresses' => []]]);
|
||||
}
|
||||
|
||||
$addresses = \AKSRT\Shop\Shop::get_instance()->get_address_book(get_current_user_id());
|
||||
return $this->success(['data' => ['addresses' => $addresses]]);
|
||||
}
|
||||
|
||||
public function create_frontend_address(\WP_REST_Request $request): \WP_REST_Response {
|
||||
if (!class_exists('\AKSRT\Shop\Shop')) {
|
||||
return $this->error('商城服务不可用', 500);
|
||||
}
|
||||
|
||||
$params = $this->get_json_params($request);
|
||||
$user_id = get_current_user_id();
|
||||
$result = \AKSRT\Shop\Shop::get_instance()->add_address($user_id, $params);
|
||||
|
||||
if (empty($result['success'])) {
|
||||
return $this->error($result['error'] ?? '添加地址失败');
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'message' => __('地址已添加', 'aksrt'),
|
||||
'data' => ['address' => $result['address']],
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function update_frontend_address(\WP_REST_Request $request): \WP_REST_Response {
|
||||
if (!class_exists('\AKSRT\Shop\Shop')) {
|
||||
return $this->error('商城服务不可用', 500);
|
||||
}
|
||||
|
||||
$address_id = (int) $request->get_param('id');
|
||||
$params = $this->get_json_params($request);
|
||||
$user_id = get_current_user_id();
|
||||
$result = \AKSRT\Shop\Shop::get_instance()->update_address($user_id, $address_id, $params);
|
||||
|
||||
if (empty($result['success'])) {
|
||||
return $this->error($result['error'] ?? '更新地址失败');
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'message' => __('地址已更新', 'aksrt'),
|
||||
'data' => ['address' => $result['address']],
|
||||
]);
|
||||
}
|
||||
|
||||
public function delete_frontend_address(\WP_REST_Request $request): \WP_REST_Response {
|
||||
if (!class_exists('\AKSRT\Shop\Shop')) {
|
||||
return $this->error('商城服务不可用', 500);
|
||||
}
|
||||
|
||||
$address_id = (int) $request->get_param('id');
|
||||
$user_id = get_current_user_id();
|
||||
$result = \AKSRT\Shop\Shop::get_instance()->delete_address($user_id, $address_id);
|
||||
|
||||
if (empty($result['success'])) {
|
||||
return $this->error($result['error'] ?? '删除地址失败');
|
||||
}
|
||||
|
||||
return $this->success(['message' => __('地址已删除', 'aksrt')]);
|
||||
}
|
||||
|
||||
public function set_default_frontend_address(\WP_REST_Request $request): \WP_REST_Response {
|
||||
if (!class_exists('\AKSRT\Shop\Shop')) {
|
||||
return $this->error('商城服务不可用', 500);
|
||||
}
|
||||
|
||||
$address_id = (int) $request->get_param('id');
|
||||
$user_id = get_current_user_id();
|
||||
$result = \AKSRT\Shop\Shop::get_instance()->set_default_address($user_id, $address_id);
|
||||
|
||||
if (empty($result['success'])) {
|
||||
return $this->error($result['error'] ?? '设置默认地址失败');
|
||||
}
|
||||
|
||||
return $this->success(['message' => __('已设为默认地址', 'aksrt')]);
|
||||
}
|
||||
|
||||
public function get_frontend_product_archive(\WP_REST_Request $request): \WP_REST_Response {
|
||||
if (!class_exists('\AKSRT\Shop\Shop')) {
|
||||
return $this->error('商城服务不可用', 500);
|
||||
}
|
||||
|
||||
$taxonomy = sanitize_key((string) ($request->get_param('taxonomy') ?? ''));
|
||||
$term_id = (int) ($request->get_param('term_id') ?? 0);
|
||||
$page = max(1, (int) ($request->get_param('page') ?? 1));
|
||||
$orderby = sanitize_key((string) ($request->get_param('orderby') ?? ''));
|
||||
$order = sanitize_key((string) ($request->get_param('order') ?? ''));
|
||||
|
||||
$view = \AKSRT\Shop\Shop::get_instance()->get_product_archive_view([
|
||||
'taxonomy' => $taxonomy,
|
||||
'term_id' => $term_id,
|
||||
'paged' => $page,
|
||||
'orderby' => $orderby,
|
||||
'order' => $order,
|
||||
]);
|
||||
|
||||
$sales_cfg = is_array($view['sales'] ?? null) ? $view['sales'] : [];
|
||||
$html = '';
|
||||
if (!empty($view['products']) && is_array($view['products'])) {
|
||||
ob_start();
|
||||
foreach ($view['products'] as $product) {
|
||||
if (function_exists('get_template_part')) {
|
||||
get_template_part('templates/parts/shop/product', 'card', [
|
||||
'product' => $product,
|
||||
'sales_cfg' => $sales_cfg,
|
||||
]);
|
||||
}
|
||||
}
|
||||
$html = (string) ob_get_clean();
|
||||
}
|
||||
|
||||
$empty_html = '';
|
||||
if ($html === '' && function_exists('aksrt_get_ajax_null')) {
|
||||
$empty_html = aksrt_get_ajax_null(__('内容空空如也', 'aksrt'));
|
||||
} elseif ($html === '') {
|
||||
$empty_html = '<div class="aksrt-product-archive__empty ajax-item"><p>' . esc_html__('内容空空如也', 'aksrt') . '</p></div>';
|
||||
}
|
||||
|
||||
// Header HTML for cat filter replace (when filter present).
|
||||
$header_html = '';
|
||||
if (!empty($view['filter']) && is_array($view['filter'])) {
|
||||
ob_start();
|
||||
$this->render_product_cat_filter_html($view);
|
||||
$header_html = (string) ob_get_clean();
|
||||
}
|
||||
|
||||
$paginate_html = '';
|
||||
$page_n = (int) ($view['page'] ?? 1);
|
||||
$max_pages = (int) ($view['max_pages'] ?? 1);
|
||||
$has_more = $page_n < $max_pages;
|
||||
if ($has_more && (string) ($view['paging'] ?? '') === 'ajax') {
|
||||
$next_url = add_query_arg([
|
||||
'taxonomy' => $taxonomy,
|
||||
'term_id' => $term_id,
|
||||
'page' => $page_n + 1,
|
||||
'orderby' => (string) ($view['orderby'] ?? 'date'),
|
||||
'order' => (string) ($view['order'] ?? ''),
|
||||
], rest_url('aksrt/v1/shop/frontend/products/archive'));
|
||||
if (function_exists('aksrt_get_ajax_next_paginate')) {
|
||||
$paginate_html = aksrt_get_ajax_next_paginate(
|
||||
(int) ($view['total'] ?? 0),
|
||||
$page_n,
|
||||
max(1, (int) ($view['per_page'] ?? 12)),
|
||||
$next_url
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'data' => array_merge($view, [
|
||||
'html' => $html !== '' ? $html : $empty_html,
|
||||
'empty_html' => $empty_html,
|
||||
'header_html' => $header_html,
|
||||
'paginate_html' => $paginate_html,
|
||||
'has_more' => $has_more,
|
||||
'title' => !empty($view['term']['name'])
|
||||
? (string) $view['term']['name'] . ' - ' . wp_strip_all_tags(get_bloginfo('name'))
|
||||
: '',
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /shop/frontend/widgets/products — product list widget AJAX pager.
|
||||
*/
|
||||
public function get_frontend_widget_products(\WP_REST_Request $request): \WP_REST_Response {
|
||||
if (!class_exists('\AKSRT\Widgets\WidgetProductList')) {
|
||||
return $this->error('商品组件不可用', 500);
|
||||
}
|
||||
|
||||
$instance = [
|
||||
'per_page' => max(1, min(50, (int) ($request->get_param('per_page') ?? 8))),
|
||||
'orderby' => sanitize_key((string) ($request->get_param('orderby') ?? 'date')),
|
||||
'order' => sanitize_key((string) ($request->get_param('order') ?? 'DESC')),
|
||||
'cat_ids' => sanitize_text_field((string) ($request->get_param('cat_ids') ?? '')),
|
||||
'tag_ids' => sanitize_text_field((string) ($request->get_param('tag_ids') ?? '')),
|
||||
'discount_ids' => sanitize_text_field((string) ($request->get_param('discount_ids') ?? '')),
|
||||
'exclude_cat' => !empty($request->get_param('exclude_cat')) ? 1 : 0,
|
||||
'style' => sanitize_key((string) ($request->get_param('style') ?? 'default')),
|
||||
'paginate' => sanitize_key((string) ($request->get_param('paginate') ?? 'ajax')),
|
||||
];
|
||||
$page = max(1, (int) ($request->get_param('page') ?? $request->get_param('paged') ?? 1));
|
||||
$payload = \AKSRT\Widgets\WidgetProductList::query_payload($instance, $page);
|
||||
|
||||
return $this->success([
|
||||
'html' => (string) ($payload['html'] ?? ''),
|
||||
'paginate_html' => (string) ($payload['paginate_html'] ?? ''),
|
||||
'has_more' => !empty($payload['has_more']),
|
||||
'page' => (int) ($payload['page'] ?? $page),
|
||||
'max_pages' => (int) ($payload['max_pages'] ?? 1),
|
||||
'total' => (int) ($payload['total'] ?? 0),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render cat filter bar HTML for AJAX header replace.
|
||||
*/
|
||||
private function render_product_cat_filter_html(array $view): void {
|
||||
$filter = is_array($view['filter'] ?? null) ? $view['filter'] : [];
|
||||
$orderby = (string) ($view['orderby'] ?? ($filter['orderby'] ?? 'date'));
|
||||
$order = strtoupper((string) ($view['order'] ?? ($filter['order'] ?? 'DESC')));
|
||||
$term = is_array($view['term'] ?? null) ? $view['term'] : [];
|
||||
$current_id = (int) ($filter['current_id'] ?? ($term['id'] ?? 0));
|
||||
include get_template_directory() . '/templates/parts/shop/product-cat-filter.php';
|
||||
}
|
||||
|
||||
public function get_frontend_revenue(\WP_REST_Request $request): \WP_REST_Response {
|
||||
if (!function_exists('aksrt_get_revenue_page_view')) {
|
||||
return $this->error('收益服务不可用', 500);
|
||||
}
|
||||
$view = aksrt_get_revenue_page_view(get_current_user_id());
|
||||
$withdrawals = [];
|
||||
if (class_exists('\AKSRT\Payment\Withdrawal')) {
|
||||
$rows = \AKSRT\Payment\Withdrawal::get_instance()->get_user_withdrawals(get_current_user_id(), ['limit' => 20]);
|
||||
foreach ($rows as $row) {
|
||||
$withdrawals[] = [
|
||||
'id' => (int) ($row['id'] ?? 0),
|
||||
'amount' => (float) ($row['amount'] ?? 0),
|
||||
'status' => (string) ($row['status'] ?? ''),
|
||||
'method' => (string) ($row['method'] ?? ''),
|
||||
'created_at' => (string) ($row['created_at'] ?? ''),
|
||||
'note' => (string) ($row['note'] ?? ''),
|
||||
];
|
||||
}
|
||||
}
|
||||
$settings = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_withdrawal_settings() : [];
|
||||
return $this->success([
|
||||
'data' => array_merge($view, [
|
||||
'withdrawals' => $withdrawals,
|
||||
'settings' => [
|
||||
'min_amount' => (float) ($settings['min_amount'] ?? 50),
|
||||
'service_charge_rate' => (float) ($settings['service_charge_rate'] ?? 0),
|
||||
'text' => (string) ($settings['text'] ?? ''),
|
||||
],
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public function create_frontend_withdraw(\WP_REST_Request $request): \WP_REST_Response {
|
||||
if (!class_exists('\AKSRT\Payment\Withdrawal') || !class_exists('\AKSRT\Credits\Credits')) {
|
||||
return $this->error('提现服务不可用', 500);
|
||||
}
|
||||
|
||||
$params = $this->get_json_params($request);
|
||||
$amount = (float) ($params['amount'] ?? 0);
|
||||
$method = sanitize_key((string) ($params['method'] ?? 'alipay'));
|
||||
$account = sanitize_textarea_field((string) ($params['account_info'] ?? $params['account'] ?? ''));
|
||||
|
||||
$settings = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_withdrawal_settings() : [];
|
||||
$min = (float) ($settings['min_amount'] ?? 50);
|
||||
if ($amount < $min) {
|
||||
return $this->error(sprintf(__('最低提现金额为 %s', 'aksrt'), $min));
|
||||
}
|
||||
if ($account === '') {
|
||||
return $this->error(__('请填写收款账户信息', 'aksrt'));
|
||||
}
|
||||
|
||||
$user_id = get_current_user_id();
|
||||
$balance = (float) \AKSRT\Credits\Credits::balance($user_id);
|
||||
if ($amount > $balance) {
|
||||
return $this->error(__('可提现余额不足', 'aksrt'));
|
||||
}
|
||||
|
||||
$cost = (int) round($amount);
|
||||
if ($cost <= 0) {
|
||||
return $this->error(__('提现金额无效', 'aksrt'));
|
||||
}
|
||||
$deducted = \AKSRT\Credits\Credits::deduct($user_id, $cost, 'withdraw', ['type' => 'withdraw'], __('用户提现申请', 'aksrt'));
|
||||
if (is_wp_error($deducted)) {
|
||||
return $this->error($deducted->get_error_message());
|
||||
}
|
||||
|
||||
$id = \AKSRT\Payment\Withdrawal::get_instance()->create($user_id, $amount, $method, $account);
|
||||
if ($id <= 0) {
|
||||
\AKSRT\Credits\Credits::add($user_id, $cost, 'withdraw_refund', ['type' => 'withdraw'], __('提现失败退回', 'aksrt'));
|
||||
return $this->error(__('提现申请创建失败', 'aksrt'), 500);
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'message' => __('提现申请已提交', 'aksrt'),
|
||||
'data' => [
|
||||
'id' => $id,
|
||||
'amount' => $amount,
|
||||
'balance' => (float) \AKSRT\Credits\Credits::balance($user_id),
|
||||
],
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function submit_frontend_product_review(\WP_REST_Request $request): \WP_REST_Response {
|
||||
if (!class_exists('\AKSRT\Shop\ProductReview')) {
|
||||
return $this->error('评价服务不可用', 500);
|
||||
}
|
||||
$params = $this->get_json_params($request);
|
||||
$order_number = sanitize_text_field((string) $request->get_param('order_number'));
|
||||
$product_id = (int) ($params['product_id'] ?? 0);
|
||||
$result = \AKSRT\Shop\ProductReview::get_instance()->submit_review(
|
||||
get_current_user_id(),
|
||||
$order_number,
|
||||
$product_id,
|
||||
$params
|
||||
);
|
||||
if (empty($result['success'])) {
|
||||
return $this->error($result['message'] ?? __('评价失败', 'aksrt'));
|
||||
}
|
||||
return $this->success([
|
||||
'message' => __('评价成功', 'aksrt'),
|
||||
'data' => $result,
|
||||
], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET review modal HTML for an order (zibll order_comment_modal parity).
|
||||
*/
|
||||
public function get_frontend_product_review_modal(\WP_REST_Request $request): \WP_REST_Response {
|
||||
if (!class_exists('\AKSRT\Shop\ProductReview')) {
|
||||
return $this->error(__('评价服务不可用', 'aksrt'), 500);
|
||||
}
|
||||
$order_number = sanitize_text_field((string) $request->get_param('order_number'));
|
||||
$result = \AKSRT\Shop\ProductReview::get_instance()->get_review_modal_html(
|
||||
get_current_user_id(),
|
||||
$order_number
|
||||
);
|
||||
if (empty($result['success'])) {
|
||||
return $this->error((string) ($result['message'] ?? __('无法打开评价', 'aksrt')));
|
||||
}
|
||||
return $this->success([
|
||||
'html' => (string) ($result['html'] ?? ''),
|
||||
'product_id' => (int) ($result['product_id'] ?? 0),
|
||||
'need_shipping' => !empty($result['need_shipping']),
|
||||
'img_max' => (int) ($result['img_max'] ?? 0),
|
||||
'remain_seconds' => (int) ($result['remain_seconds'] ?? 0),
|
||||
]);
|
||||
}
|
||||
|
||||
public function create_frontend_product(\WP_REST_Request $request): \WP_REST_Response {
|
||||
$params = $this->get_json_params($request);
|
||||
$params['author_id'] = get_current_user_id();
|
||||
$frontend_request = new \WP_REST_Request('POST', '/');
|
||||
$frontend_request->set_body(wp_json_encode($params));
|
||||
|
||||
return $this->create_product($frontend_request);
|
||||
}
|
||||
|
||||
public function update_frontend_product(\WP_REST_Request $request): \WP_REST_Response {
|
||||
$id = (int) $request->get_param('id');
|
||||
$post = get_post($id);
|
||||
if (!$post || $post->post_type !== 'aksrt_product') {
|
||||
return $this->error('商品不存在', 404);
|
||||
}
|
||||
|
||||
if ((int) $post->post_author !== get_current_user_id() && !current_user_can('manage_options')) {
|
||||
return $this->error('没有权限编辑该商品', 403);
|
||||
}
|
||||
|
||||
return $this->update_product($request);
|
||||
}
|
||||
}
|
||||
@@ -1,353 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Admin;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
trait ShopControllerFulfillmentOperations {
|
||||
|
||||
/**
|
||||
* GET /shop/after-sale — 售后工单列表
|
||||
*/
|
||||
public function get_after_sale_list(\WP_REST_Request $request): \WP_REST_Response {
|
||||
if (!class_exists('\AKSRT\Payment\AfterSale')) {
|
||||
return $this->success(['data' => ['items' => [], 'total' => 0, 'page' => 1, 'max_pages' => 1]]);
|
||||
}
|
||||
|
||||
$page = max(1, (int) $request->get_param('page'));
|
||||
$per_page = max(1, min(100, (int) $request->get_param('per_page'))) ?: 20;
|
||||
$status = sanitize_key((string) $request->get_param('status'));
|
||||
$stage = sanitize_key((string) $request->get_param('stage'));
|
||||
$order = sanitize_text_field((string) $request->get_param('order_number'));
|
||||
$type = sanitize_key((string) $request->get_param('type'));
|
||||
$date_from = sanitize_text_field((string) $request->get_param('date_from'));
|
||||
$date_to = sanitize_text_field((string) $request->get_param('date_to'));
|
||||
|
||||
$result = \AKSRT\Payment\AfterSale::get_instance()->get_list([
|
||||
'limit' => $per_page,
|
||||
'offset' => ($page - 1) * $per_page,
|
||||
'status' => $status,
|
||||
'stage' => $stage,
|
||||
'order_number' => $order,
|
||||
'type' => $type,
|
||||
'date_from' => $date_from,
|
||||
'date_to' => $date_to,
|
||||
'include_order_context' => true,
|
||||
]);
|
||||
|
||||
$stats = \AKSRT\Payment\AfterSale::get_instance()->get_stats();
|
||||
|
||||
return $this->success(['data' => array_merge($result, [
|
||||
'stats' => $stats,
|
||||
'filters' => [
|
||||
'status' => $status,
|
||||
'stage' => $stage,
|
||||
'order_number' => $order,
|
||||
'type' => $type,
|
||||
'date_from' => $date_from,
|
||||
'date_to' => $date_to,
|
||||
],
|
||||
'options' => [
|
||||
'status' => \AKSRT\Payment\AfterSale::get_status_options(),
|
||||
'stage' => \AKSRT\Payment\AfterSale::get_stage_options(),
|
||||
'type' => \AKSRT\Payment\AfterSale::get_type_options(),
|
||||
],
|
||||
])]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /shop/after-sale — 创建售后工单
|
||||
*/
|
||||
public function create_after_sale(\WP_REST_Request $request): \WP_REST_Response {
|
||||
if (!class_exists('\AKSRT\Payment\AfterSale')) {
|
||||
return $this->error('售后系统未启用', 500);
|
||||
}
|
||||
|
||||
$params = $this->get_json_params($request);
|
||||
$order_number = sanitize_text_field((string) ($params['order_number'] ?? ''));
|
||||
$reason = sanitize_textarea_field((string) ($params['reason'] ?? ''));
|
||||
|
||||
if ($order_number === '' || $reason === '') {
|
||||
return $this->error('订单号和原因不能为空');
|
||||
}
|
||||
|
||||
$order = class_exists('\AKSRT\Payment\OrderRepository')
|
||||
? \AKSRT\Payment\OrderRepository::get_instance()->get($order_number)
|
||||
: null;
|
||||
|
||||
if (!$order) {
|
||||
return $this->error(__('订单不存在', 'aksrt'), 404);
|
||||
}
|
||||
|
||||
unset($params['user_id'], $params['applicant_user_id']);
|
||||
$id = \AKSRT\Payment\AfterSale::get_instance()->create($params);
|
||||
|
||||
if (is_wp_error($id)) {
|
||||
return $this->error($id->get_error_message(), 422);
|
||||
}
|
||||
if ($id > 0) {
|
||||
return $this->success([
|
||||
'message' => __('售后工单已创建', 'aksrt'),
|
||||
'data' => ['id' => $id],
|
||||
], 201);
|
||||
}
|
||||
|
||||
return $this->error(__('创建失败', 'aksrt'));
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /shop/after-sale/{id}/update-status — 更新售后工单状态
|
||||
*/
|
||||
public function update_after_sale_status(\WP_REST_Request $request): \WP_REST_Response {
|
||||
if (!class_exists('\AKSRT\Payment\AfterSale')) {
|
||||
return $this->error('售后系统未启用', 500);
|
||||
}
|
||||
|
||||
$id = (int) $request->get_param('id');
|
||||
$params = $this->get_json_params($request);
|
||||
$new_status = sanitize_key((string) ($params['status'] ?? ''));
|
||||
$note = sanitize_textarea_field((string) ($params['operator_note'] ?? ''));
|
||||
|
||||
if ($id <= 0 || $new_status === '') {
|
||||
return $this->error('参数无效');
|
||||
}
|
||||
|
||||
$success = \AKSRT\Payment\AfterSale::get_instance()->update_status(
|
||||
$id,
|
||||
$new_status,
|
||||
get_current_user_id(),
|
||||
$note
|
||||
);
|
||||
|
||||
if ($success) {
|
||||
return $this->success(['message' => __('状态已更新', 'aksrt')]);
|
||||
}
|
||||
return $this->error(__('更新失败', 'aksrt'));
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /shop/shipping — 发货记录列表
|
||||
*/
|
||||
public function get_shipping_list(\WP_REST_Request $request): \WP_REST_Response {
|
||||
if (!class_exists('\AKSRT\Payment\Shipping')) {
|
||||
return $this->success(['data' => ['items' => [], 'total' => 0, 'page' => 1, 'max_pages' => 1]]);
|
||||
}
|
||||
|
||||
$page = max(1, (int) $request->get_param('page'));
|
||||
$per_page = max(1, min(100, (int) $request->get_param('per_page'))) ?: 20;
|
||||
$status = sanitize_key((string) $request->get_param('status'));
|
||||
$order = sanitize_text_field((string) $request->get_param('order_number'));
|
||||
$date_from = sanitize_text_field((string) $request->get_param('date_from'));
|
||||
$date_to = sanitize_text_field((string) $request->get_param('date_to'));
|
||||
$tracking_no = sanitize_text_field((string) $request->get_param('tracking_no'));
|
||||
|
||||
$result = \AKSRT\Payment\Shipping::get_instance()->get_list([
|
||||
'limit' => $per_page,
|
||||
'offset' => ($page - 1) * $per_page,
|
||||
'status' => $status,
|
||||
'order_number' => $order,
|
||||
'date_from' => $date_from,
|
||||
'date_to' => $date_to,
|
||||
'tracking_no' => $tracking_no,
|
||||
'include_order_context' => true,
|
||||
]);
|
||||
|
||||
$stats = \AKSRT\Payment\Shipping::get_instance()->get_stats();
|
||||
|
||||
return $this->success(['data' => array_merge($result, [
|
||||
'stats' => $stats,
|
||||
'filters' => [
|
||||
'status' => $status,
|
||||
'order_number' => $order,
|
||||
'date_from' => $date_from,
|
||||
'date_to' => $date_to,
|
||||
'tracking_no' => $tracking_no,
|
||||
],
|
||||
'options' => [
|
||||
'status' => \AKSRT\Payment\Shipping::get_status_options(),
|
||||
'carrier' => \AKSRT\Payment\Shipping::get_carrier_options(),
|
||||
],
|
||||
])]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /shop/shipping — 创建发货记录
|
||||
*/
|
||||
public function create_shipping(\WP_REST_Request $request): \WP_REST_Response {
|
||||
if (!class_exists('\AKSRT\Payment\Shipping')) {
|
||||
return $this->error('物流系统未启用', 500);
|
||||
}
|
||||
|
||||
$params = $this->get_json_params($request);
|
||||
$order_number = sanitize_text_field((string) ($params['order_number'] ?? ''));
|
||||
$carrier = sanitize_text_field((string) ($params['carrier'] ?? ''));
|
||||
$tracking_no = sanitize_text_field((string) ($params['tracking_no'] ?? ''));
|
||||
|
||||
if ($order_number === '' || $carrier === '' || $tracking_no === '') {
|
||||
return $this->error('订单号、快递公司和物流单号不能为空');
|
||||
}
|
||||
|
||||
$order = class_exists('\AKSRT\Payment\OrderRepository')
|
||||
? \AKSRT\Payment\OrderRepository::get_instance()->get($order_number)
|
||||
: null;
|
||||
|
||||
if (!$order) {
|
||||
return $this->error(__('订单不存在', 'aksrt'), 404);
|
||||
}
|
||||
|
||||
$params['operator_id'] = get_current_user_id();
|
||||
|
||||
$id = \AKSRT\Payment\Shipping::get_instance()->create($params);
|
||||
|
||||
if ($id > 0) {
|
||||
return $this->success([
|
||||
'message' => __('发货记录已创建', 'aksrt'),
|
||||
'data' => ['id' => $id],
|
||||
], 201);
|
||||
}
|
||||
|
||||
return $this->error(__('创建失败', 'aksrt'));
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /shop/shipping/{id}/update-status — 更新发货状态
|
||||
*/
|
||||
public function update_shipping_status(\WP_REST_Request $request): \WP_REST_Response {
|
||||
if (!class_exists('\AKSRT\Payment\Shipping')) {
|
||||
return $this->error('物流系统未启用', 500);
|
||||
}
|
||||
|
||||
$id = (int) $request->get_param('id');
|
||||
$params = $this->get_json_params($request);
|
||||
$new_status = sanitize_key((string) ($params['status'] ?? ''));
|
||||
$note = sanitize_textarea_field((string) ($params['note'] ?? ''));
|
||||
|
||||
if ($id <= 0 || $new_status === '') {
|
||||
return $this->error('参数无效');
|
||||
}
|
||||
|
||||
$success = \AKSRT\Payment\Shipping::get_instance()->update_status($id, $new_status, $note, get_current_user_id());
|
||||
|
||||
if ($success) {
|
||||
return $this->success(['message' => __('状态已更新', 'aksrt')]);
|
||||
}
|
||||
return $this->error(__('更新失败', 'aksrt'));
|
||||
}
|
||||
|
||||
public function get_after_sale_detail(\WP_REST_Request $request): \WP_REST_Response {
|
||||
if (!class_exists('\AKSRT\Payment\AfterSale')) {
|
||||
return $this->error('售后系统未启用', 500);
|
||||
}
|
||||
|
||||
$id = (int) $request->get_param('id');
|
||||
if ($id <= 0) {
|
||||
return $this->error('参数无效');
|
||||
}
|
||||
|
||||
$record = \AKSRT\Payment\AfterSale::get_instance()->get_with_order_context($id);
|
||||
if (!$record) {
|
||||
return $this->error(__('售后工单不存在', 'aksrt'), 404);
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'data' => [
|
||||
'record' => $record,
|
||||
'timeline' => $this->build_after_sale_timeline($record),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function get_shipping_detail(\WP_REST_Request $request): \WP_REST_Response {
|
||||
if (!class_exists('\AKSRT\Payment\Shipping')) {
|
||||
return $this->error('物流系统未启用', 500);
|
||||
}
|
||||
|
||||
$id = (int) $request->get_param('id');
|
||||
if ($id <= 0) {
|
||||
return $this->error('参数无效');
|
||||
}
|
||||
|
||||
$record = \AKSRT\Payment\Shipping::get_instance()->get_with_order_context($id);
|
||||
if (!$record) {
|
||||
return $this->error(__('发货记录不存在', 'aksrt'), 404);
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'data' => [
|
||||
'record' => $record,
|
||||
'timeline' => $this->build_shipping_timeline($record),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function batch_update_shipping_status(\WP_REST_Request $request): \WP_REST_Response {
|
||||
if (!class_exists('\AKSRT\Payment\Shipping')) {
|
||||
return $this->error('物流系统未启用', 500);
|
||||
}
|
||||
|
||||
$params = $this->get_json_params($request);
|
||||
$ids = is_array($params['ids'] ?? null) ? $params['ids'] : [];
|
||||
$status = sanitize_key((string) ($params['status'] ?? ''));
|
||||
$note = sanitize_textarea_field((string) ($params['note'] ?? ''));
|
||||
|
||||
if (empty($ids) || $status === '') {
|
||||
return $this->error('参数无效');
|
||||
}
|
||||
|
||||
$result = \AKSRT\Payment\Shipping::get_instance()->batch_update_status($ids, $status, $note, get_current_user_id());
|
||||
|
||||
return $this->success([
|
||||
'message' => __('批量状态更新已执行', 'aksrt'),
|
||||
'data' => $result,
|
||||
]);
|
||||
}
|
||||
|
||||
private function build_shipping_timeline(array $record): array {
|
||||
$timeline = [];
|
||||
|
||||
foreach ([
|
||||
'created_at' => __('创建发货记录', 'aksrt'),
|
||||
'updated_at' => __('更新物流状态', 'aksrt'),
|
||||
'delivered_at' => __('确认签收', 'aksrt'),
|
||||
] as $field => $label) {
|
||||
if (!empty($record[$field])) {
|
||||
$timeline[] = [
|
||||
'time' => (string) $record[$field],
|
||||
'label' => $label,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
usort($timeline, static function ($a, $b) {
|
||||
return strcmp((string) ($a['time'] ?? ''), (string) ($b['time'] ?? ''));
|
||||
});
|
||||
|
||||
return $timeline;
|
||||
}
|
||||
|
||||
private function build_after_sale_timeline(array $record): array {
|
||||
$timeline = [];
|
||||
|
||||
foreach ([
|
||||
'created_at' => __('提交售后申请', 'aksrt'),
|
||||
'updated_at' => __('更新售后状态', 'aksrt'),
|
||||
'resolved_at' => __('售后处理结束', 'aksrt'),
|
||||
] as $field => $label) {
|
||||
if (!empty($record[$field])) {
|
||||
$timeline[] = [
|
||||
'time' => (string) $record[$field],
|
||||
'label' => $label,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
usort($timeline, static function ($a, $b) {
|
||||
return strcmp((string) ($a['time'] ?? ''), (string) ($b['time'] ?? ''));
|
||||
});
|
||||
|
||||
return $timeline;
|
||||
}
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Admin;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
trait ShopControllerOrderOperations {
|
||||
|
||||
/**
|
||||
* GET /shop/orders — 订单列表,支持 search、status、page、per_page
|
||||
*/
|
||||
public function get_orders(\WP_REST_Request $request): \WP_REST_Response {
|
||||
$page = max(1, (int) $request->get_param('page'));
|
||||
$per_page = max(1, min(50, (int) $request->get_param('per_page'))) ?: 20;
|
||||
$search = sanitize_text_field((string) $request->get_param('search'));
|
||||
$status = sanitize_key((string) $request->get_param('status'));
|
||||
|
||||
if (class_exists('\AKSRT\Payment\OrderRepository')) {
|
||||
$repo = \AKSRT\Payment\OrderRepository::get_instance();
|
||||
|
||||
$args = [
|
||||
'limit' => $per_page,
|
||||
'offset' => ($page - 1) * $per_page,
|
||||
];
|
||||
if ($status !== '' && $status !== 'all') {
|
||||
$args['status'] = $status;
|
||||
}
|
||||
|
||||
$orders = $repo->get_orders_admin($args, $search);
|
||||
$total = $repo->get_orders_count($args, $search);
|
||||
|
||||
return $this->success([
|
||||
'data' => [
|
||||
'items' => $orders,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'total_pages' => max(1, (int) ceil($total / $per_page)),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->error('订单仓库不可用', 500);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /shop/orders/{order_number} — 单个订单详情(含上下文)
|
||||
*/
|
||||
public function get_order(\WP_REST_Request $request): \WP_REST_Response {
|
||||
$order_number = sanitize_text_field((string) $request->get_param('order_number'));
|
||||
|
||||
if (!class_exists('\AKSRT\Payment\OrderRepository')) {
|
||||
return $this->error('订单服务不可用', 500);
|
||||
}
|
||||
|
||||
$repo = \AKSRT\Payment\OrderRepository::get_instance();
|
||||
$order = $repo->get($order_number);
|
||||
if (!$order) {
|
||||
return $this->error('订单不存在', 404);
|
||||
}
|
||||
|
||||
$context = $repo->get_admin_order_context($order_number);
|
||||
$timeline = [];
|
||||
if (class_exists('\AKSRT\Shop\Shop')) {
|
||||
$shipping_records = class_exists('\AKSRT\Payment\Shipping') ? \AKSRT\Payment\Shipping::get_instance()->get_by_order($order_number) : [];
|
||||
$after_sale_result = class_exists('\AKSRT\Payment\AfterSale') ? \AKSRT\Payment\AfterSale::get_instance()->get_list(['order_number' => $order_number, 'limit' => 20]) : ['items' => []];
|
||||
$after_sale_items = $after_sale_result['items'] ?? [];
|
||||
|
||||
$timeline = [];
|
||||
$date_fields = [
|
||||
'created_at' => '订单创建',
|
||||
'paid_at' => '完成支付',
|
||||
];
|
||||
foreach ($date_fields as $field => $label) {
|
||||
if (!empty($order[$field])) {
|
||||
$timeline[] = ['time' => $order[$field], 'title' => $label, 'type' => 'order'];
|
||||
}
|
||||
}
|
||||
foreach ($shipping_records as $record) {
|
||||
$timeline[] = [
|
||||
'time' => $record['created_at'] ?? '',
|
||||
'title' => '物流更新:' . ($record['status_label'] ?? $record['status'] ?? ''),
|
||||
'detail' => ($record['carrier_label'] ?? '') . ' / ' . ($record['tracking_no'] ?? ''),
|
||||
'type' => 'shipping',
|
||||
];
|
||||
}
|
||||
foreach ($after_sale_items as $record) {
|
||||
$timeline[] = [
|
||||
'time' => $record['created_at'] ?? '',
|
||||
'title' => '售后:' . ($record['type_label'] ?? '') . ' — ' . ($record['status_label'] ?? ''),
|
||||
'detail' => $record['reason'] ?? '',
|
||||
'type' => 'after_sale',
|
||||
];
|
||||
}
|
||||
usort($timeline, static function ($a, $b) {
|
||||
return strcmp((string) ($b['time'] ?? ''), (string) ($a['time'] ?? ''));
|
||||
});
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'data' => $order,
|
||||
'context' => $context,
|
||||
'timeline' => $timeline,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /shop/orders/update-status — 更新订单状态
|
||||
* Canonical field: order_number (前端必须发送 order_number)
|
||||
*/
|
||||
public function update_order_status(\WP_REST_Request $request): \WP_REST_Response {
|
||||
$params = $this->get_json_params($request);
|
||||
$order_number = sanitize_text_field((string) ($params['order_number'] ?? ''));
|
||||
$new_status = sanitize_key((string) ($params['status'] ?? ''));
|
||||
$note = sanitize_textarea_field((string) ($params['note'] ?? ''));
|
||||
|
||||
if ($order_number === '' || $new_status === '') {
|
||||
return $this->error('参数无效');
|
||||
}
|
||||
|
||||
$success = false;
|
||||
if (class_exists('\AKSRT\Payment\OrderRepository')) {
|
||||
$success = \AKSRT\Payment\OrderRepository::get_instance()->update_status($order_number, $new_status, [
|
||||
'note' => $note,
|
||||
'force' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
if ($success) {
|
||||
return $this->success(['message' => __('订单状态已更新', 'aksrt')]);
|
||||
}
|
||||
return $this->error(__('更新失败', 'aksrt'));
|
||||
}
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Admin;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
trait ShopControllerProductAdminColumns {
|
||||
|
||||
/**
|
||||
* Register custom columns for the aksrt_product admin list table.
|
||||
*/
|
||||
public function init_product_admin_columns(): void {
|
||||
add_filter('manage_edit-aksrt_product_columns', [$this, 'product_columns']);
|
||||
add_action('manage_aksrt_product_posts_custom_column', [$this, 'render_product_column'], 10, 2);
|
||||
add_filter('manage_edit-aksrt_product_sortable_columns', [$this, 'sortable_product_columns']);
|
||||
add_action('quick_edit_custom_box', [$this, 'render_product_quick_edit'], 10, 2);
|
||||
add_action('bulk_edit_custom_box', [$this, 'render_product_quick_edit'], 10, 2);
|
||||
add_action('save_post_aksrt_product', [$this, 'save_product_quick_edit'], 10, 2);
|
||||
add_action('admin_footer-edit.php', [$this, 'product_quick_edit_script']);
|
||||
}
|
||||
|
||||
public function product_columns(array $columns): array {
|
||||
$new = [];
|
||||
foreach ($columns as $key => $label) {
|
||||
$new[$key] = $label;
|
||||
if ($key === 'title') {
|
||||
$new['product_price'] = __('价格', 'aksrt');
|
||||
$new['product_stock'] = __('库存', 'aksrt');
|
||||
$new['product_sales'] = __('销量', 'aksrt');
|
||||
$new['product_stats'] = __('综合数据', 'aksrt');
|
||||
$new['product_author_name'] = __('商家', 'aksrt');
|
||||
$new['product_discount'] = __('优惠活动', 'aksrt');
|
||||
}
|
||||
}
|
||||
return $new;
|
||||
}
|
||||
|
||||
public function render_product_column(string $column, int $post_id): void {
|
||||
switch ($column) {
|
||||
case 'product_price':
|
||||
$price = get_post_meta($post_id, '_aksrt_price', true);
|
||||
echo esc_html(number_format_i18n((float) $price, 2));
|
||||
break;
|
||||
case 'product_stock':
|
||||
$stock = get_post_meta($post_id, '_aksrt_stock', true);
|
||||
$stock_type = get_post_meta($post_id, '_aksrt_stock_type', true);
|
||||
if ($stock_type === 'opts') {
|
||||
echo esc_html__('按选项', 'aksrt');
|
||||
} elseif ((int) $stock === -1) {
|
||||
echo esc_html__('无限', 'aksrt');
|
||||
} else {
|
||||
echo esc_html((string) (int) $stock);
|
||||
}
|
||||
break;
|
||||
case 'product_sales':
|
||||
echo esc_html((string) (int) get_post_meta($post_id, '_aksrt_sales', true));
|
||||
break;
|
||||
case 'product_stats':
|
||||
$views = (int) get_post_meta($post_id, '_aksrt_views', true);
|
||||
$fav = (int) get_post_meta($post_id, '_aksrt_favorite_count', true);
|
||||
printf(
|
||||
esc_html__('阅读 %1$d / 收藏 %2$d', 'aksrt'),
|
||||
$views,
|
||||
$fav
|
||||
);
|
||||
break;
|
||||
case 'product_author_name':
|
||||
$post = get_post($post_id);
|
||||
if ($post) {
|
||||
$author = get_userdata($post->post_author);
|
||||
echo esc_html($author ? $author->display_name : '—');
|
||||
}
|
||||
break;
|
||||
case 'product_discount':
|
||||
$terms = wp_get_object_terms($post_id, 'shop_discount', ['fields' => 'all']);
|
||||
if (!is_wp_error($terms) && !empty($terms)) {
|
||||
$term_ids = wp_list_pluck($terms, 'term_id');
|
||||
$names = wp_list_pluck($terms, 'name');
|
||||
echo '<span class="aksrt-col-data" data-discount-id="' . esc_attr((string) ($term_ids[0] ?? 0)) . '"></span>';
|
||||
echo esc_html(implode(', ', $names));
|
||||
} else {
|
||||
echo '<span class="aksrt-col-data" data-discount-id="0"></span>—';
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public function sortable_product_columns(array $columns): array {
|
||||
$columns['product_sales'] = 'product_sales';
|
||||
return $columns;
|
||||
}
|
||||
|
||||
public function render_product_quick_edit(string $column_name, string $post_type): void {
|
||||
if ($column_name !== 'product_discount' || $post_type !== 'aksrt_product') {
|
||||
return;
|
||||
}
|
||||
wp_nonce_field('aksrt_product_quick_edit', 'aksrt_product_qe_nonce');
|
||||
$discounts = get_terms([
|
||||
'taxonomy' => 'shop_discount',
|
||||
'hide_empty' => false,
|
||||
]);
|
||||
?>
|
||||
<fieldset class="inline-edit-col-right" id="aksrt-product-quick-edit">
|
||||
<div class="inline-edit-col">
|
||||
<h4><?php esc_html_e('商品优惠活动', 'aksrt'); ?></h4>
|
||||
<input type="hidden" name="aksrt_product_qe_flag" value="1">
|
||||
<label class="alignleft">
|
||||
<span class="title"><?php esc_html_e('优惠活动', 'aksrt'); ?></span>
|
||||
<select name="aksrt_qe_discount" class="aksrt-qe-discount">
|
||||
<option value="-1"><?php esc_html_e('— 不更改 —', 'aksrt'); ?></option>
|
||||
<option value="0"><?php esc_html_e('无优惠', 'aksrt'); ?></option>
|
||||
<?php if (!is_wp_error($discounts)) : ?>
|
||||
<?php foreach ($discounts as $term) : ?>
|
||||
<option value="<?php echo esc_attr($term->term_id); ?>"><?php echo esc_html($term->name); ?></option>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
<?php
|
||||
}
|
||||
|
||||
public function save_product_quick_edit(int $post_id, \WP_Post $post): void {
|
||||
if (!isset($_POST['aksrt_product_qe_nonce']) || !wp_verify_nonce($_POST['aksrt_product_qe_nonce'], 'aksrt_product_quick_edit')) {
|
||||
return;
|
||||
}
|
||||
if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
|
||||
return;
|
||||
}
|
||||
if (!current_user_can('edit_post', $post_id)) {
|
||||
return;
|
||||
}
|
||||
if (!isset($_POST['aksrt_product_qe_flag'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$discount_id = isset($_POST['aksrt_qe_discount']) ? sanitize_text_field(wp_unslash($_POST['aksrt_qe_discount'])) : '-1';
|
||||
if ($discount_id === '-1') {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($discount_id === '0') {
|
||||
wp_set_object_terms($post_id, [], 'shop_discount');
|
||||
} else {
|
||||
$term_id = (int) $discount_id;
|
||||
if ($term_id > 0 && term_exists($term_id, 'shop_discount')) {
|
||||
wp_set_object_terms($post_id, [$term_id], 'shop_discount');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function product_quick_edit_script(): void {
|
||||
$screen = get_current_screen();
|
||||
if (!$screen || $screen->post_type !== 'aksrt_product') {
|
||||
return;
|
||||
}
|
||||
?>
|
||||
<script>
|
||||
(function(){
|
||||
var wpInlineEdit = window.inlineEditPost ? window.inlineEditPost.edit : null;
|
||||
if (!wpInlineEdit) return;
|
||||
window.inlineEditPost.edit = function(id) {
|
||||
wpInlineEdit.apply(this, arguments);
|
||||
var postId = 0;
|
||||
if (typeof id === 'object') {
|
||||
postId = parseInt(this.getId(id));
|
||||
}
|
||||
if (!postId) return;
|
||||
var row = document.getElementById('post-' + postId);
|
||||
if (!row) return;
|
||||
var dataEl = row.querySelector('.column-product_discount .aksrt-col-data');
|
||||
if (!dataEl) return;
|
||||
var termId = dataEl.getAttribute('data-discount-id') || '0';
|
||||
var select = document.querySelector('.aksrt-qe-discount');
|
||||
if (select) {
|
||||
select.value = termId;
|
||||
}
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
<?php
|
||||
}
|
||||
}
|
||||
@@ -1,499 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Admin;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
trait ShopControllerProductOperations {
|
||||
|
||||
public function get_overview(): \WP_REST_Response {
|
||||
$product_count = 0;
|
||||
|
||||
$post_type_obj = get_post_type_object('aksrt_product');
|
||||
if ($post_type_obj) {
|
||||
$counts = wp_count_posts('aksrt_product');
|
||||
$product_count = (int) ($counts->publish ?? 0);
|
||||
}
|
||||
|
||||
$order_stats = ['total' => 0, 'pending' => 0, 'paid' => 0, 'revenue' => 0];
|
||||
if (class_exists('\AKSRT\Payment\OrderRepository')) {
|
||||
$order_stats = \AKSRT\Payment\OrderRepository::get_instance()->get_stats();
|
||||
}
|
||||
|
||||
$revenue_stats = ['total' => 0, 'pending' => 0, 'settled' => 0];
|
||||
if (class_exists('\AKSRT\Payment\Revenue')) {
|
||||
$revenue_stats = \AKSRT\Payment\Revenue::get_instance()->get_stats();
|
||||
}
|
||||
|
||||
$gateway_summary = [];
|
||||
if (class_exists('\AKSRT\Payment\Gateway')) {
|
||||
$gateway_summary = \AKSRT\Payment\Gateway::get_instance()->get_status_summary();
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'data' => [
|
||||
'enabled' => true,
|
||||
'product_count' => $product_count,
|
||||
'order_count' => $order_stats['total'],
|
||||
'order_pending' => $order_stats['pending'] ?? 0,
|
||||
'revenue_total' => $order_stats['revenue'] ?? 0,
|
||||
'revenue_pending' => $revenue_stats['pending'] ?? 0,
|
||||
'gateways' => $gateway_summary,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function get_product_taxonomy(): \WP_REST_Response {
|
||||
return $this->success([
|
||||
'data' => [
|
||||
'categories' => $this->format_product_terms('aksrt_product_cat'),
|
||||
'tags' => $this->format_product_terms('aksrt_product_tag'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /shop/products/taxonomy — create category or tag term.
|
||||
*
|
||||
* Body: { taxonomy: 'category'|'tag'|'aksrt_product_cat'|'aksrt_product_tag', name: string, parent?: int }
|
||||
*/
|
||||
public function create_product_taxonomy_term(\WP_REST_Request $request): \WP_REST_Response {
|
||||
$params = $this->get_json_params($request);
|
||||
$raw_tax = sanitize_key((string) ($params['taxonomy'] ?? 'category'));
|
||||
$name = sanitize_text_field((string) ($params['name'] ?? ''));
|
||||
$parent = max(0, (int) ($params['parent'] ?? 0));
|
||||
|
||||
$taxonomy_map = [
|
||||
'category' => 'aksrt_product_cat',
|
||||
'tag' => 'aksrt_product_tag',
|
||||
'aksrt_product_cat' => 'aksrt_product_cat',
|
||||
'aksrt_product_tag' => 'aksrt_product_tag',
|
||||
];
|
||||
$taxonomy = $taxonomy_map[$raw_tax] ?? '';
|
||||
|
||||
if ($taxonomy === '' || !taxonomy_exists($taxonomy)) {
|
||||
return $this->error(__('无效的分类法', 'aksrt'));
|
||||
}
|
||||
if ($name === '') {
|
||||
return $this->error(__('名称不能为空', 'aksrt'));
|
||||
}
|
||||
|
||||
$existing = term_exists($name, $taxonomy, $taxonomy === 'aksrt_product_cat' ? $parent : 0);
|
||||
if (is_array($existing) && !empty($existing['term_id'])) {
|
||||
$term = get_term((int) $existing['term_id'], $taxonomy);
|
||||
if ($term && !is_wp_error($term)) {
|
||||
return $this->success([
|
||||
'message' => __('术语已存在', 'aksrt'),
|
||||
'data' => [
|
||||
'id' => (int) $term->term_id,
|
||||
'name' => (string) $term->name,
|
||||
'slug' => (string) $term->slug,
|
||||
'parent' => (int) $term->parent,
|
||||
'taxonomy' => $taxonomy === 'aksrt_product_cat' ? 'category' : 'tag',
|
||||
'existing' => true,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$args = [];
|
||||
if ($taxonomy === 'aksrt_product_cat' && $parent > 0) {
|
||||
$args['parent'] = $parent;
|
||||
}
|
||||
|
||||
$result = wp_insert_term($name, $taxonomy, $args);
|
||||
if (is_wp_error($result)) {
|
||||
return $this->error($result->get_error_message());
|
||||
}
|
||||
|
||||
$term_id = (int) ($result['term_id'] ?? 0);
|
||||
$term = get_term($term_id, $taxonomy);
|
||||
if (!$term || is_wp_error($term)) {
|
||||
return $this->error(__('创建失败', 'aksrt'));
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'message' => __('术语已创建', 'aksrt'),
|
||||
'data' => [
|
||||
'id' => (int) $term->term_id,
|
||||
'name' => (string) $term->name,
|
||||
'slug' => (string) $term->slug,
|
||||
'parent' => (int) $term->parent,
|
||||
'taxonomy' => $taxonomy === 'aksrt_product_cat' ? 'category' : 'tag',
|
||||
'existing' => false,
|
||||
],
|
||||
], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{id:int,name:string,slug:string,parent:int,count:int}>
|
||||
*/
|
||||
private function format_product_terms(string $taxonomy): array {
|
||||
if (!taxonomy_exists($taxonomy)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$terms = get_terms([
|
||||
'taxonomy' => $taxonomy,
|
||||
'hide_empty' => false,
|
||||
]);
|
||||
if (is_wp_error($terms) || !is_array($terms)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_map(static function (\WP_Term $term): array {
|
||||
return [
|
||||
'id' => (int) $term->term_id,
|
||||
'name' => (string) $term->name,
|
||||
'slug' => (string) $term->slug,
|
||||
'parent' => (int) $term->parent,
|
||||
'count' => (int) $term->count,
|
||||
];
|
||||
}, $terms);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /shop/products — 商品列表,支持 search、status、page、per_page
|
||||
*/
|
||||
public function get_products(\WP_REST_Request $request): \WP_REST_Response {
|
||||
$page = max(1, (int) $request->get_param('page'));
|
||||
$per_page = max(1, min(50, (int) $request->get_param('per_page'))) ?: 20;
|
||||
$search = sanitize_text_field((string) $request->get_param('search'));
|
||||
$status = sanitize_key((string) $request->get_param('status'));
|
||||
|
||||
$query_args = [
|
||||
'post_type' => 'aksrt_product',
|
||||
'posts_per_page' => $per_page,
|
||||
'paged' => $page,
|
||||
'orderby' => 'date',
|
||||
'order' => 'DESC',
|
||||
];
|
||||
|
||||
if ($status !== '') {
|
||||
$query_args['post_status'] = $status;
|
||||
} else {
|
||||
$query_args['post_status'] = ['publish', 'draft', 'pending'];
|
||||
}
|
||||
|
||||
if ($search !== '') {
|
||||
$query_args['s'] = $search;
|
||||
}
|
||||
|
||||
$query = new \WP_Query($query_args);
|
||||
|
||||
$products = [];
|
||||
while ($query->have_posts()) {
|
||||
$query->the_post();
|
||||
$id = get_the_ID();
|
||||
$products[] = [
|
||||
'id' => $id,
|
||||
'title' => get_the_title(),
|
||||
'price' => (float) get_post_meta($id, '_aksrt_price', true),
|
||||
'image' => get_the_post_thumbnail_url($id, 'medium') ?: '',
|
||||
'stock' => (int) get_post_meta($id, '_aksrt_stock', true),
|
||||
'sales' => (int) get_post_meta($id, '_aksrt_sales', true),
|
||||
'status' => get_post_status(),
|
||||
'created' => get_the_date('Y-m-d H:i'),
|
||||
];
|
||||
}
|
||||
wp_reset_postdata();
|
||||
|
||||
return $this->success([
|
||||
'data' => [
|
||||
'items' => $products,
|
||||
'total' => (int) $query->found_posts,
|
||||
'total_pages' => (int) $query->max_num_pages,
|
||||
'page' => $page,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /shop/products/{id} — 单个商品详情
|
||||
*/
|
||||
public function get_product(\WP_REST_Request $request): \WP_REST_Response {
|
||||
$id = (int) $request->get_param('id');
|
||||
$post = get_post($id);
|
||||
|
||||
if (!$post || $post->post_type !== 'aksrt_product') {
|
||||
return $this->error('商品不存在', 404);
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'data' => [
|
||||
'id' => $post->ID,
|
||||
'title' => $post->post_title,
|
||||
'content' => $post->post_content,
|
||||
'excerpt' => $post->post_excerpt,
|
||||
'price' => (float) get_post_meta($post->ID, '_aksrt_price', true),
|
||||
'image' => (string) (get_post_meta($post->ID, '_aksrt_product_image', true) ?: get_the_post_thumbnail_url($post->ID, 'medium') ?: ''),
|
||||
'stock' => (int) get_post_meta($post->ID, '_aksrt_stock', true),
|
||||
'sales' => (int) get_post_meta($post->ID, '_aksrt_sales', true),
|
||||
'status' => $post->post_status,
|
||||
'created' => get_the_date('Y-m-d H:i', $post),
|
||||
'modified' => get_the_modified_date('Y-m-d H:i', $post),
|
||||
'categories' => wp_get_post_terms($post->ID, 'aksrt_product_cat', ['fields' => 'names']) ?: [],
|
||||
'tags' => wp_get_post_terms($post->ID, 'aksrt_product_tag', ['fields' => 'names']) ?: [],
|
||||
'gallery' => get_post_meta($post->ID, '_aksrt_product_gallery', true) ?: [],
|
||||
'shipping_mode' => (string) get_post_meta($post->ID, '_aksrt_shipping_mode', true),
|
||||
'shipping_fee' => (float) get_post_meta($post->ID, '_aksrt_shipping_fee', true),
|
||||
'after_sale_policy' => (string) get_post_meta($post->ID, '_aksrt_after_sale_policy', true),
|
||||
'support_refund' => get_post_meta($post->ID, '_aksrt_support_refund', true) !== '0',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /shop/products — 创建商品
|
||||
*/
|
||||
public function create_product(\WP_REST_Request $request): \WP_REST_Response {
|
||||
$params = $this->get_json_params($request);
|
||||
|
||||
$title = sanitize_text_field((string) ($params['title'] ?? ''));
|
||||
$content = wp_kses_post((string) ($params['content'] ?? ''));
|
||||
$excerpt = sanitize_textarea_field((string) ($params['excerpt'] ?? ''));
|
||||
$price = (float) ($params['price'] ?? 0);
|
||||
$stock = (int) ($params['stock'] ?? 0);
|
||||
$status = sanitize_key((string) ($params['status'] ?? 'publish'));
|
||||
$image = esc_url_raw((string) ($params['image'] ?? ''));
|
||||
$gallery = is_array($params['gallery'] ?? null) ? array_values(array_filter(array_map('esc_url_raw', $params['gallery']))) : [];
|
||||
$shipping_mode = sanitize_key((string) ($params['shipping_mode'] ?? 'express'));
|
||||
$shipping_fee = max(0, (float) ($params['shipping_fee'] ?? 0));
|
||||
$after_sale_policy = sanitize_textarea_field((string) ($params['after_sale_policy'] ?? ''));
|
||||
$support_refund = !isset($params['support_refund']) || !empty($params['support_refund']);
|
||||
$author_id = max(0, (int) ($params['author_id'] ?? 0));
|
||||
|
||||
if ($title === '') {
|
||||
return $this->error('商品名称不能为空');
|
||||
}
|
||||
|
||||
$valid_statuses = ['publish', 'draft', 'pending'];
|
||||
if (!in_array($status, $valid_statuses, true)) {
|
||||
$status = 'draft';
|
||||
}
|
||||
|
||||
$post_id = wp_insert_post([
|
||||
'post_type' => 'aksrt_product',
|
||||
'post_title' => $title,
|
||||
'post_content' => $content,
|
||||
'post_excerpt' => $excerpt,
|
||||
'post_status' => $status,
|
||||
'post_author' => $author_id > 0 ? $author_id : get_current_user_id(),
|
||||
'meta_input' => [
|
||||
'_aksrt_price' => max(0, $price),
|
||||
'_aksrt_stock' => max(0, $stock),
|
||||
'_aksrt_sales' => 0,
|
||||
'_aksrt_score' => 0,
|
||||
'_aksrt_views' => 0,
|
||||
'_aksrt_favorite_count' => 0,
|
||||
'_aksrt_product_gallery' => $gallery,
|
||||
'_aksrt_shipping_mode' => $shipping_mode,
|
||||
'_aksrt_shipping_fee' => $shipping_fee,
|
||||
'_aksrt_after_sale_policy' => $after_sale_policy,
|
||||
'_aksrt_support_refund' => $support_refund ? '1' : '0',
|
||||
'_aksrt_product_video' => is_array($params['product_video'] ?? null) ? array_values(array_filter(array_map('esc_url_raw', $params['product_video']))) : [],
|
||||
'_aksrt_product_params' => sanitize_textarea_field((string) ($params['product_params'] ?? '')),
|
||||
'_aksrt_pay_modo' => sanitize_key((string) ($params['pay_modo'] ?? '0')),
|
||||
'_aksrt_start_price' => max(0, (float) ($params['start_price'] ?? 0)),
|
||||
'_aksrt_product_rebate' => sanitize_text_field((string) ($params['product_rebate'] ?? '')),
|
||||
'_aksrt_limit_buy' => max(-1, min(9999, (int) ($params['limit_buy'] ?? -1))),
|
||||
'_aksrt_limit_buy_note' => sanitize_text_field((string) ($params['limit_buy_note'] ?? '')),
|
||||
'_aksrt_stock_type' => sanitize_key((string) ($params['stock_type'] ?? 'all')),
|
||||
'_aksrt_user_required' => sanitize_textarea_field((string) ($params['user_required'] ?? '')),
|
||||
'_aksrt_shipping_type' => sanitize_key((string) ($params['shipping_type'] ?? $shipping_mode)),
|
||||
'_aksrt_shipping_delivery_desc' => sanitize_text_field((string) ($params['shipping_delivery_desc'] ?? '')),
|
||||
'_aksrt_shipping_fee_type' => sanitize_key((string) ($params['shipping_fee_type'] ?? 'free')),
|
||||
'_aksrt_shipping_fee_free_amount'=> max(0, (float) ($params['shipping_fee_free_amount'] ?? 0)),
|
||||
'_aksrt_after_sale_matrix' => sanitize_textarea_field((string) ($params['after_sale_matrix'] ?? '')),
|
||||
'_aksrt_service' => sanitize_textarea_field((string) ($params['service'] ?? '')),
|
||||
'_aksrt_single_tab' => sanitize_textarea_field((string) ($params['single_tab'] ?? '')),
|
||||
'_aksrt_content_layout' => sanitize_key((string) ($params['content_layout'] ?? '')),
|
||||
'_aksrt_content_show_bg' => sanitize_text_field((string) ($params['content_show_bg'] ?? '')),
|
||||
'_aksrt_delivery_note' => sanitize_text_field((string) ($params['delivery_note'] ?? '')),
|
||||
'_aksrt_marketing_headline' => sanitize_text_field((string) ($params['marketing_headline'] ?? '')),
|
||||
'_aksrt_shipping_tip' => sanitize_text_field((string) ($params['shipping_tip'] ?? '')),
|
||||
'_aksrt_service_tip' => sanitize_text_field((string) ($params['service_tip'] ?? '')),
|
||||
],
|
||||
], true);
|
||||
|
||||
if (is_wp_error($post_id)) {
|
||||
return $this->error($post_id->get_error_message());
|
||||
}
|
||||
|
||||
if (!empty($params['categories']) && is_array($params['categories'])) {
|
||||
wp_set_object_terms($post_id, $params['categories'], 'aksrt_product_cat');
|
||||
}
|
||||
if (!empty($params['tags']) && is_array($params['tags'])) {
|
||||
wp_set_object_terms($post_id, $params['tags'], 'aksrt_product_tag');
|
||||
}
|
||||
if ($image !== '') {
|
||||
update_post_meta($post_id, '_aksrt_product_image', $image);
|
||||
}
|
||||
|
||||
if (isset($params['options']) && is_array($params['options']) && class_exists('\AKSRT\Shop\Shop')) {
|
||||
\AKSRT\Shop\Shop::get_instance()->save_product_options((int) $post_id, $params['options']);
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'message' => __('商品已创建', 'aksrt'),
|
||||
'data' => ['id' => $post_id],
|
||||
], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /shop/products/{id} — 更新商品(含上下架)
|
||||
*/
|
||||
public function update_product(\WP_REST_Request $request): \WP_REST_Response {
|
||||
$id = (int) $request->get_param('id');
|
||||
$post = get_post($id);
|
||||
|
||||
if (!$post || $post->post_type !== 'aksrt_product') {
|
||||
return $this->error('商品不存在', 404);
|
||||
}
|
||||
|
||||
$params = $this->get_json_params($request);
|
||||
$update = ['ID' => $id];
|
||||
|
||||
if (isset($params['title'])) {
|
||||
$update['post_title'] = sanitize_text_field($params['title']);
|
||||
}
|
||||
if (isset($params['content'])) {
|
||||
$update['post_content'] = wp_kses_post($params['content']);
|
||||
}
|
||||
if (isset($params['excerpt'])) {
|
||||
$update['post_excerpt'] = sanitize_textarea_field($params['excerpt']);
|
||||
}
|
||||
if (isset($params['status'])) {
|
||||
$new_status = sanitize_key($params['status']);
|
||||
$valid_statuses = ['publish', 'draft', 'pending', 'trash'];
|
||||
if (in_array($new_status, $valid_statuses, true)) {
|
||||
$update['post_status'] = $new_status;
|
||||
}
|
||||
}
|
||||
|
||||
$meta = [];
|
||||
if (isset($params['price'])) {
|
||||
$meta['_aksrt_price'] = max(0, (float) $params['price']);
|
||||
}
|
||||
if (isset($params['stock'])) {
|
||||
$meta['_aksrt_stock'] = max(0, (int) $params['stock']);
|
||||
}
|
||||
if (isset($params['gallery']) && is_array($params['gallery'])) {
|
||||
$meta['_aksrt_product_gallery'] = array_values(array_filter(array_map('esc_url_raw', $params['gallery'])));
|
||||
}
|
||||
if (isset($params['shipping_mode'])) {
|
||||
$meta['_aksrt_shipping_mode'] = sanitize_key((string) $params['shipping_mode']);
|
||||
}
|
||||
if (isset($params['shipping_fee'])) {
|
||||
$meta['_aksrt_shipping_fee'] = max(0, (float) $params['shipping_fee']);
|
||||
}
|
||||
if (isset($params['after_sale_policy'])) {
|
||||
$meta['_aksrt_after_sale_policy'] = sanitize_textarea_field((string) $params['after_sale_policy']);
|
||||
}
|
||||
if (isset($params['support_refund'])) {
|
||||
$meta['_aksrt_support_refund'] = !empty($params['support_refund']) ? '1' : '0';
|
||||
}
|
||||
if (isset($params['product_video']) && is_array($params['product_video'])) {
|
||||
$meta['_aksrt_product_video'] = array_values(array_filter(array_map('esc_url_raw', $params['product_video'])));
|
||||
}
|
||||
if (isset($params['product_params'])) {
|
||||
$meta['_aksrt_product_params'] = sanitize_textarea_field((string) $params['product_params']);
|
||||
}
|
||||
if (isset($params['pay_modo'])) {
|
||||
$meta['_aksrt_pay_modo'] = sanitize_key((string) $params['pay_modo']);
|
||||
}
|
||||
if (isset($params['start_price'])) {
|
||||
$meta['_aksrt_start_price'] = max(0, (float) $params['start_price']);
|
||||
}
|
||||
if (isset($params['product_rebate'])) {
|
||||
$meta['_aksrt_product_rebate'] = sanitize_text_field((string) $params['product_rebate']);
|
||||
}
|
||||
if (isset($params['limit_buy'])) {
|
||||
$meta['_aksrt_limit_buy'] = max(-1, min(9999, (int) $params['limit_buy']));
|
||||
}
|
||||
if (isset($params['limit_buy_note'])) {
|
||||
$meta['_aksrt_limit_buy_note'] = sanitize_text_field((string) $params['limit_buy_note']);
|
||||
}
|
||||
if (isset($params['stock_type'])) {
|
||||
$meta['_aksrt_stock_type'] = sanitize_key((string) $params['stock_type']);
|
||||
}
|
||||
if (isset($params['stock_opts']) && is_array($params['stock_opts'])) {
|
||||
$meta['_aksrt_stock_opts'] = wp_json_encode($params['stock_opts']);
|
||||
}
|
||||
if (isset($params['user_required'])) {
|
||||
$meta['_aksrt_user_required'] = sanitize_textarea_field((string) $params['user_required']);
|
||||
}
|
||||
if (isset($params['shipping_type'])) {
|
||||
$meta['_aksrt_shipping_type'] = sanitize_key((string) $params['shipping_type']);
|
||||
}
|
||||
if (isset($params['shipping_delivery_desc'])) {
|
||||
$meta['_aksrt_shipping_delivery_desc'] = sanitize_text_field((string) $params['shipping_delivery_desc']);
|
||||
}
|
||||
if (isset($params['auto_delivery']) && is_array($params['auto_delivery'])) {
|
||||
$meta['_aksrt_auto_delivery'] = wp_json_encode($params['auto_delivery']);
|
||||
}
|
||||
if (isset($params['shipping_fee_type'])) {
|
||||
$meta['_aksrt_shipping_fee_type'] = sanitize_key((string) $params['shipping_fee_type']);
|
||||
}
|
||||
if (isset($params['shipping_fee_free_amount'])) {
|
||||
$meta['_aksrt_shipping_fee_free_amount'] = max(0, (float) $params['shipping_fee_free_amount']);
|
||||
}
|
||||
if (isset($params['after_sale_matrix'])) {
|
||||
$meta['_aksrt_after_sale_matrix'] = sanitize_textarea_field((string) $params['after_sale_matrix']);
|
||||
}
|
||||
if (isset($params['service'])) {
|
||||
$meta['_aksrt_service'] = sanitize_textarea_field((string) $params['service']);
|
||||
}
|
||||
if (isset($params['single_tab'])) {
|
||||
$meta['_aksrt_single_tab'] = sanitize_textarea_field((string) $params['single_tab']);
|
||||
}
|
||||
if (isset($params['content_layout'])) {
|
||||
$meta['_aksrt_content_layout'] = sanitize_key((string) $params['content_layout']);
|
||||
}
|
||||
if (isset($params['content_show_bg'])) {
|
||||
$meta['_aksrt_content_show_bg'] = sanitize_text_field((string) $params['content_show_bg']);
|
||||
}
|
||||
if (isset($params['delivery_note'])) {
|
||||
$meta['_aksrt_delivery_note'] = sanitize_text_field((string) $params['delivery_note']);
|
||||
}
|
||||
if (isset($params['marketing_headline'])) {
|
||||
$meta['_aksrt_marketing_headline'] = sanitize_text_field((string) $params['marketing_headline']);
|
||||
}
|
||||
if (isset($params['shipping_tip'])) {
|
||||
$meta['_aksrt_shipping_tip'] = sanitize_text_field((string) $params['shipping_tip']);
|
||||
}
|
||||
if (isset($params['service_tip'])) {
|
||||
$meta['_aksrt_service_tip'] = sanitize_text_field((string) $params['service_tip']);
|
||||
}
|
||||
if (!empty($meta)) {
|
||||
$update['meta_input'] = $meta;
|
||||
}
|
||||
|
||||
$result = wp_update_post($update, true);
|
||||
|
||||
if (is_wp_error($result)) {
|
||||
return $this->error($result->get_error_message());
|
||||
}
|
||||
|
||||
if (isset($params['categories']) && is_array($params['categories'])) {
|
||||
wp_set_object_terms($id, $params['categories'], 'aksrt_product_cat');
|
||||
}
|
||||
if (isset($params['tags']) && is_array($params['tags'])) {
|
||||
wp_set_object_terms($id, $params['tags'], 'aksrt_product_tag');
|
||||
}
|
||||
if (isset($params['image'])) {
|
||||
update_post_meta($id, '_aksrt_product_image', esc_url_raw((string) $params['image']));
|
||||
}
|
||||
|
||||
if (isset($params['options']) && is_array($params['options']) && class_exists('\AKSRT\Shop\Shop')) {
|
||||
\AKSRT\Shop\Shop::get_instance()->save_product_options($id, $params['options']);
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'message' => __('商品已更新', 'aksrt'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Admin;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
trait ShopControllerVariantExportOperations {
|
||||
|
||||
public function get_product_variants(\WP_REST_Request $request): \WP_REST_Response {
|
||||
$product_id = (int) $request->get_param('id');
|
||||
$product = get_post($product_id);
|
||||
if (!$product || $product->post_type !== 'aksrt_product') {
|
||||
return $this->error('商品不存在', 404);
|
||||
}
|
||||
|
||||
if (!class_exists('\AKSRT\Shop\Shop')) {
|
||||
return $this->success(['data' => ['variants' => []]]);
|
||||
}
|
||||
|
||||
$variants = \AKSRT\Shop\Shop::get_instance()->get_variants($product_id);
|
||||
return $this->success(['data' => ['variants' => $variants]]);
|
||||
}
|
||||
|
||||
public function save_product_variants(\WP_REST_Request $request): \WP_REST_Response {
|
||||
$product_id = (int) $request->get_param('id');
|
||||
$product = get_post($product_id);
|
||||
if (!$product || $product->post_type !== 'aksrt_product') {
|
||||
return $this->error('商品不存在', 404);
|
||||
}
|
||||
|
||||
$params = method_exists($this, 'get_json_params')
|
||||
? $this->get_json_params($request)
|
||||
: ($request->get_json_params() ?? []);
|
||||
$variants = $params['variants'] ?? [];
|
||||
if (!is_array($variants)) {
|
||||
return $this->error('变体数据格式错误');
|
||||
}
|
||||
|
||||
if (!class_exists('\AKSRT\Shop\Shop')) {
|
||||
return $this->error('商城系统未启用', 501);
|
||||
}
|
||||
|
||||
$saved = \AKSRT\Shop\Shop::get_instance()->save_variants($product_id, $variants);
|
||||
return $this->success(['message' => '变体已保存', 'data' => ['variants' => $saved]]);
|
||||
}
|
||||
|
||||
public function delete_variant(\WP_REST_Request $request): \WP_REST_Response {
|
||||
$variant_id = (int) $request->get_param('id');
|
||||
|
||||
if (!class_exists('\AKSRT\Shop\Shop')) {
|
||||
return $this->error('商城系统未启用', 501);
|
||||
}
|
||||
|
||||
$result = \AKSRT\Shop\Shop::get_instance()->delete_variant($variant_id);
|
||||
if (!$result) {
|
||||
return $this->error('删除失败', 500);
|
||||
}
|
||||
|
||||
return $this->success(['message' => '变体已删除']);
|
||||
}
|
||||
|
||||
public function export_orders(\WP_REST_Request $request): \WP_REST_Response {
|
||||
if (!class_exists('\AKSRT\Shop\Shop')) {
|
||||
return $this->error('商城服务不可用', 500);
|
||||
}
|
||||
|
||||
$args = [
|
||||
'status' => sanitize_key((string) $request->get_param('status')),
|
||||
'date_from' => sanitize_text_field((string) $request->get_param('date_from')),
|
||||
'date_to' => sanitize_text_field((string) $request->get_param('date_to')),
|
||||
'limit' => max(1, min(5000, (int) ($request->get_param('limit') ?: 1000))),
|
||||
];
|
||||
|
||||
$result = \AKSRT\Shop\Shop::get_instance()->export_orders_csv($args);
|
||||
|
||||
return new \WP_REST_Response($result['csv'], 200, [
|
||||
'Content-Type' => 'text/csv; charset=utf-8',
|
||||
'Content-Disposition' => 'attachment; filename="orders-' . date('Ymd-His') . '.csv"',
|
||||
'X-Order-Count' => $result['count'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function confirm_receipt(\WP_REST_Request $request): \WP_REST_Response {
|
||||
if (!class_exists('\AKSRT\Shop\Shop')) {
|
||||
return $this->error('商城服务不可用', 500);
|
||||
}
|
||||
|
||||
$order_number = sanitize_text_field((string) $request->get_param('order_number'));
|
||||
$result = \AKSRT\Shop\Shop::get_instance()->confirm_receipt(get_current_user_id(), $order_number);
|
||||
if (empty($result['success'])) {
|
||||
return $this->error($result['message'] ?? '确认收货失败');
|
||||
}
|
||||
|
||||
return $this->success(['data' => $result['data']]);
|
||||
}
|
||||
|
||||
public function get_after_sale_reasons(\WP_REST_Request $request): \WP_REST_Response {
|
||||
if (class_exists('\AKSRT\Payment\AfterSale')) {
|
||||
return $this->success(['data' => \AKSRT\Payment\AfterSale::get_reason_options()]);
|
||||
}
|
||||
return $this->success(['data' => ['refund' => [], 'return' => []]]);
|
||||
}
|
||||
|
||||
public function get_frontend_after_sale_reasons(\WP_REST_Request $request): \WP_REST_Response {
|
||||
if (class_exists('\AKSRT\Payment\AfterSale')) {
|
||||
return $this->success(['data' => \AKSRT\Payment\AfterSale::get_reason_options()]);
|
||||
}
|
||||
return $this->success(['data' => ['refund' => [], 'return' => []]]);
|
||||
}
|
||||
}
|
||||
@@ -261,24 +261,6 @@ trait DBMigratorPart2 {
|
||||
) {$charset_collate};";
|
||||
dbDelta($sql_restriction);
|
||||
|
||||
// ---------- 13. 商品变体/SKU ----------
|
||||
$sql_variant = "CREATE TABLE {$prefix}aksrt_product_variant (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
product_id BIGINT UNSIGNED NOT NULL,
|
||||
sku VARCHAR(64) NOT NULL DEFAULT '',
|
||||
attributes TEXT DEFAULT NULL,
|
||||
price DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||
stock INT NOT NULL DEFAULT 0,
|
||||
image VARCHAR(512) NOT NULL DEFAULT '',
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_product (product_id),
|
||||
KEY idx_sku (sku),
|
||||
KEY idx_sort (sort_order)
|
||||
) {$charset_collate};";
|
||||
dbDelta($sql_variant);
|
||||
|
||||
// ── follow ──
|
||||
$sql_follow = "CREATE TABLE {$prefix}aksrt_follow (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
@@ -329,64 +311,6 @@ trait DBMigratorPart2 {
|
||||
) {$charset_collate};";
|
||||
dbDelta($sql_coupon_usage);
|
||||
|
||||
// ── after_sale ──
|
||||
$sql_after_sale = "CREATE TABLE {$prefix}aksrt_after_sale (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
order_number VARCHAR(32) NOT NULL DEFAULT '',
|
||||
user_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
type VARCHAR(20) NOT NULL DEFAULT 'refund',
|
||||
reason TEXT NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
amount DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||
note TEXT DEFAULT NULL,
|
||||
operator_id BIGINT UNSIGNED DEFAULT NULL,
|
||||
operator_note TEXT DEFAULT NULL,
|
||||
refund_request_id VARCHAR(64) DEFAULT NULL,
|
||||
provider VARCHAR(32) NOT NULL DEFAULT '',
|
||||
provider_refund_id VARCHAR(128) NOT NULL DEFAULT '',
|
||||
provider_status VARCHAR(24) NOT NULL DEFAULT 'not_started',
|
||||
refund_effect_status VARCHAR(24) NOT NULL DEFAULT 'not_started',
|
||||
refund_error TEXT DEFAULT NULL,
|
||||
refund_attempts INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
refund_requested_at DATETIME DEFAULT NULL,
|
||||
provider_updated_at DATETIME DEFAULT NULL,
|
||||
refund_succeeded_at DATETIME DEFAULT NULL,
|
||||
refund_effected_at DATETIME DEFAULT NULL,
|
||||
order_status_before VARCHAR(20) NOT NULL DEFAULT '',
|
||||
created_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
|
||||
updated_at DATETIME DEFAULT NULL,
|
||||
resolved_at DATETIME DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uniq_refund_request (refund_request_id),
|
||||
KEY idx_order (order_number),
|
||||
KEY idx_user (user_id),
|
||||
KEY idx_status (status),
|
||||
KEY idx_type (type),
|
||||
KEY idx_provider_status (provider_status),
|
||||
KEY idx_created (created_at)
|
||||
) {$charset_collate};";
|
||||
dbDelta($sql_after_sale);
|
||||
|
||||
// ── order_shipping ──
|
||||
$sql_shipping = "CREATE TABLE {$prefix}aksrt_order_shipping (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
order_number VARCHAR(32) NOT NULL DEFAULT '',
|
||||
carrier VARCHAR(60) NOT NULL DEFAULT '',
|
||||
tracking_no VARCHAR(128) NOT NULL DEFAULT '',
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'shipped',
|
||||
note TEXT DEFAULT NULL,
|
||||
operator_id BIGINT UNSIGNED DEFAULT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
|
||||
updated_at DATETIME DEFAULT NULL,
|
||||
delivered_at DATETIME DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_order (order_number),
|
||||
KEY idx_status (status),
|
||||
KEY idx_tracking (tracking_no),
|
||||
KEY idx_created (created_at)
|
||||
) {$charset_collate};";
|
||||
dbDelta($sql_shipping);
|
||||
|
||||
// ── charge_card (zibll-style typed card_pass) ──
|
||||
$sql_charge_card = "CREATE TABLE {$prefix}aksrt_charge_card (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
@@ -461,25 +385,6 @@ trait DBMigratorPart2 {
|
||||
) {$charset_collate};";
|
||||
dbDelta($sql_order_items);
|
||||
|
||||
// ── card_key 卡密表 (§6.2 自动发货) ──
|
||||
$sql_card_key = "CREATE TABLE {$prefix}aksrt_card_key (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
product_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
options_key VARCHAR(255) NOT NULL DEFAULT '',
|
||||
card_code TEXT NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'available',
|
||||
order_number VARCHAR(32) NOT NULL DEFAULT '',
|
||||
used_by BIGINT UNSIGNED DEFAULT NULL,
|
||||
used_at DATETIME DEFAULT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00',
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_product (product_id),
|
||||
KEY idx_options (options_key(50)),
|
||||
KEY idx_status (status),
|
||||
KEY idx_order (order_number)
|
||||
) {$charset_collate};";
|
||||
dbDelta($sql_card_key);
|
||||
|
||||
self::migrate_identity_tables($prefix, $charset_collate);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,16 +31,12 @@ trait DBMigratorPart3 {
|
||||
'report' => 'aksrt_report',
|
||||
'follow' => 'aksrt_follow',
|
||||
'coupon' => 'aksrt_coupon',
|
||||
'after_sale' => 'aksrt_after_sale',
|
||||
'order_shipping' => 'aksrt_order_shipping',
|
||||
'charge_card' => 'aksrt_charge_card',
|
||||
'rebate_log' => 'aksrt_rebate_log',
|
||||
'coupon_usage' => 'aksrt_coupon_usage',
|
||||
'card_key' => 'aksrt_card_key',
|
||||
'user_restriction' => 'aksrt_user_restriction',
|
||||
'product_variant' => 'aksrt_product_variant',
|
||||
'identity_application' => 'aksrt_identity_application',
|
||||
'identity_event' => 'aksrt_identity_event',
|
||||
'coupon_usage' => 'aksrt_coupon_usage',
|
||||
'user_restriction' => 'aksrt_user_restriction',
|
||||
'identity_application' => 'aksrt_identity_application',
|
||||
'identity_event' => 'aksrt_identity_event',
|
||||
];
|
||||
if (!isset($allowed[$name])) {
|
||||
return '';
|
||||
|
||||
@@ -30,7 +30,6 @@ class DomainModel {
|
||||
public const ORDER_PRODUCT_RECHARGE = 'recharge';
|
||||
/** Cash purchase of points (zibll order_type=9). */
|
||||
public const ORDER_PRODUCT_POINTS_BUY = 'points_buy';
|
||||
public const ORDER_PRODUCT_SHOP = 'shop_product';
|
||||
|
||||
/**
|
||||
* Human labels for aksrt_order.product_type (front-end order center).
|
||||
@@ -39,7 +38,6 @@ class DomainModel {
|
||||
*/
|
||||
public static function order_product_type_labels(): array {
|
||||
return [
|
||||
self::ORDER_PRODUCT_SHOP => __('商城商品', 'aksrt'),
|
||||
self::ORDER_PRODUCT_RESOURCE => __('付费内容', 'aksrt'),
|
||||
self::ORDER_PRODUCT_VIP => __('VIP 会员', 'aksrt'),
|
||||
self::ORDER_PRODUCT_RECHARGE => __('余额充值', 'aksrt'),
|
||||
|
||||
@@ -635,13 +635,11 @@ $hmb_js = get_template_directory() . '/assets/js/header-newadd.js';
|
||||
}
|
||||
}
|
||||
|
||||
// 交易中心共享壳层(购物车 / 订单 / 收益)
|
||||
// Virtual rewrite routes (aksrt_orders / aksrt_cart / aksrt_revenue) also count.
|
||||
// 交易中心共享壳层(订单 / 收益)
|
||||
// Virtual rewrite routes (aksrt_orders / aksrt_revenue) also count.
|
||||
$is_commerce_center = is_page_template('templates/pages/page-orders.php')
|
||||
|| is_page_template('templates/pages/page-cart.php')
|
||||
|| is_page_template('templates/pages/page-revenue.php')
|
||||
|| (bool) get_query_var('aksrt_orders')
|
||||
|| (bool) get_query_var('aksrt_cart')
|
||||
|| (bool) get_query_var('aksrt_revenue');
|
||||
if ($is_commerce_center) {
|
||||
$commerce_css = get_template_directory() . '/assets/css/routes/commerce.css';
|
||||
@@ -735,93 +733,6 @@ $hmb_js = get_template_directory() . '/assets/js/header-newadd.js';
|
||||
}
|
||||
}
|
||||
|
||||
// Product archive pages (category / tag)
|
||||
if (is_tax('aksrt_product_cat') || is_tax('aksrt_product_tag')) {
|
||||
wp_enqueue_style(
|
||||
'aksrt-product-archive',
|
||||
$base_uri . '/assets/css/frontend/product-archive.css',
|
||||
['aksrt-tokens'],
|
||||
$version
|
||||
);
|
||||
}
|
||||
|
||||
// Product detail page
|
||||
if (is_page_template('templates/pages/page-product.php')) {
|
||||
wp_enqueue_style(
|
||||
'aksrt-route-product',
|
||||
$base_uri . '/assets/css/routes/product.css',
|
||||
['aksrt-tokens'],
|
||||
file_exists(get_template_directory() . '/assets/css/routes/product.css')
|
||||
? (string) filemtime(get_template_directory() . '/assets/css/routes/product.css')
|
||||
: $version
|
||||
);
|
||||
wp_enqueue_script(
|
||||
'aksrt-product-purchase',
|
||||
$base_uri . '/assets/js/product-purchase.js',
|
||||
['aksrt-script'],
|
||||
file_exists(get_template_directory() . '/assets/js/product-purchase.js')
|
||||
? (string) filemtime(get_template_directory() . '/assets/js/product-purchase.js')
|
||||
: $version,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
// Cart page
|
||||
if (is_page_template('templates/pages/page-cart.php') || (bool) get_query_var('aksrt_cart')) {
|
||||
wp_enqueue_style(
|
||||
'aksrt-route-cart',
|
||||
$base_uri . '/assets/css/routes/cart.css',
|
||||
['aksrt-tokens'],
|
||||
file_exists(get_template_directory() . '/assets/css/routes/cart.css')
|
||||
? (string) filemtime(get_template_directory() . '/assets/css/routes/cart.css')
|
||||
: $version
|
||||
);
|
||||
wp_enqueue_script(
|
||||
'aksrt-cart-checkout',
|
||||
$base_uri . '/assets/js/cart-checkout.js',
|
||||
['aksrt-script'],
|
||||
file_exists(get_template_directory() . '/assets/js/cart-checkout.js')
|
||||
? (string) filemtime(get_template_directory() . '/assets/js/cart-checkout.js')
|
||||
: $version,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
// Product edit page
|
||||
if (is_page_template('templates/pages/page-product-edit.php')) {
|
||||
wp_enqueue_style(
|
||||
'aksrt-route-product',
|
||||
$base_uri . '/assets/css/routes/product.css',
|
||||
['aksrt-tokens'],
|
||||
file_exists(get_template_directory() . '/assets/css/routes/product.css')
|
||||
? (string) filemtime(get_template_directory() . '/assets/css/routes/product.css')
|
||||
: $version
|
||||
);
|
||||
wp_enqueue_script(
|
||||
'aksrt-product-purchase',
|
||||
$base_uri . '/assets/js/product-purchase.js',
|
||||
['aksrt-script'],
|
||||
file_exists(get_template_directory() . '/assets/js/product-purchase.js')
|
||||
? (string) filemtime(get_template_directory() . '/assets/js/product-purchase.js')
|
||||
: $version,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
// 商品编辑页共用 page-edit.js
|
||||
if (is_page_template('templates/pages/page-product-edit.php')) {
|
||||
$page_edit_js = get_template_directory() . '/assets/js/page-edit.js';
|
||||
if (file_exists($page_edit_js)) {
|
||||
wp_enqueue_script(
|
||||
'aksrt-page-edit',
|
||||
$base_uri . '/assets/js/page-edit.js',
|
||||
['aksrt-script'],
|
||||
(string) filemtime($page_edit_js),
|
||||
true
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Unified share-data builder — single payload assembler for every share
|
||||
// surface (dropdown, copy, WeChat, poster). Loaded on any singular view.
|
||||
if (is_singular()) {
|
||||
|
||||
@@ -264,7 +264,6 @@ trait ScriptsPart2 {
|
||||
*/
|
||||
private function resolve_frontend_routes(): array {
|
||||
return [
|
||||
'cart' => $this->resolve_template_page_url('templates/pages/page-cart.php', '/cart/'),
|
||||
'orders' => $this->resolve_template_page_url('templates/pages/page-orders.php', '/orders/'),
|
||||
'resources' => home_url('/resources/'),
|
||||
];
|
||||
|
||||
@@ -31,20 +31,6 @@ class Setup {
|
||||
add_action('init', [$this, 'register_nav_menus'], 0);
|
||||
add_action('init', [$this, 'register_post_types'], 1);
|
||||
add_action('init', [$this, 'register_meta_fields'], 5);
|
||||
add_action('init', [$this, 'register_shop_rewrite_rules'], 20);
|
||||
add_filter('query_vars', [$this, 'register_shop_query_vars']);
|
||||
add_action('template_redirect', [$this, 'shop_template_redirect']);
|
||||
// Shop fulfillment hook (§6.5 R68)
|
||||
if (class_exists('\AKSRT\Shop\Shop')) {
|
||||
add_action('aksrt_order_paid', [\AKSRT\Shop\Shop::get_instance(), 'handle_order_paid'], 10, 2);
|
||||
}
|
||||
// Auto-confirm receipt cron (§6.5 R68)
|
||||
if (!wp_next_scheduled('aksrt_auto_confirm_receipt')) {
|
||||
wp_schedule_event(time(), 'hourly', 'aksrt_auto_confirm_receipt');
|
||||
}
|
||||
if (class_exists('\AKSRT\Shop\Shop')) {
|
||||
add_action('aksrt_auto_confirm_receipt', [\AKSRT\Shop\Shop::get_instance(), 'run_auto_confirm_cron']);
|
||||
}
|
||||
// 文章浏览量统计(单次访问)
|
||||
add_action('wp', 'aksrt_set_post_views');
|
||||
// 隐藏默认WordPress管理菜单
|
||||
@@ -110,97 +96,6 @@ class Setup {
|
||||
* 通过 _aksrt_is_resource meta 标记是否为资源。
|
||||
*/
|
||||
public function register_post_types(): void {
|
||||
// Shop rewrite settings (safe fallback if settings not yet loaded)
|
||||
$shop_rewrite = [];
|
||||
if (class_exists('\AKSRT\Settings\Settings')) {
|
||||
$shop = \AKSRT\Settings\Settings::get_shop_settings();
|
||||
$shop_rewrite = $shop['rewrite'] ?? [];
|
||||
}
|
||||
$product_slug = sanitize_key((string) ($shop_rewrite['product_slug'] ?? 'product'));
|
||||
$suffix_html = !empty($shop_rewrite['suffix_html']);
|
||||
|
||||
// Shop admin visibility: align with zibll native sidebar when shop is enabled.
|
||||
$shop_enabled = true;
|
||||
if (class_exists('\AKSRT\Settings\Settings')) {
|
||||
$shop_settings = \AKSRT\Settings\Settings::get_shop_settings();
|
||||
$shop_enabled = !empty($shop_settings['enabled']);
|
||||
}
|
||||
// Always register UI in admin so settings/tools remain reachable; hide menu when disabled.
|
||||
$shop_show_ui = true;
|
||||
$shop_show_menu = is_admin() ? $shop_enabled : false;
|
||||
|
||||
// 商品 CPT — native WP sidebar (zibll: shop_product @ pos 35)
|
||||
register_post_type('aksrt_product', [
|
||||
'labels' => [
|
||||
'name' => __('商城', 'aksrt'),
|
||||
'singular_name' => __('商品', 'aksrt'),
|
||||
'add_new' => __('新增商品', 'aksrt'),
|
||||
'add_new_item' => __('新增商品', 'aksrt'),
|
||||
'edit_item' => __('编辑商品', 'aksrt'),
|
||||
'new_item' => __('新商品', 'aksrt'),
|
||||
'view_item' => __('查看商品', 'aksrt'),
|
||||
'search_items' => __('搜索商品', 'aksrt'),
|
||||
'not_found' => __('未找到商品', 'aksrt'),
|
||||
'not_found_in_trash' => __('回收站中无商品', 'aksrt'),
|
||||
'all_items' => __('所有商品', 'aksrt'),
|
||||
'menu_name' => __('商城', 'aksrt'),
|
||||
],
|
||||
'public' => true,
|
||||
'publicly_queryable' => true,
|
||||
'show_ui' => $shop_show_ui,
|
||||
'show_in_menu' => $shop_show_menu,
|
||||
'show_in_rest' => true,
|
||||
'has_archive' => true,
|
||||
'hierarchical' => false,
|
||||
'menu_position' => 35,
|
||||
'menu_icon' => 'dashicons-store',
|
||||
'supports' => ['title', 'editor', 'excerpt', 'thumbnail', 'author', 'revisions'],
|
||||
'rewrite' => ['slug' => $product_slug, 'with_front' => false],
|
||||
'capability_type' => 'post',
|
||||
'map_meta_cap' => true,
|
||||
]);
|
||||
|
||||
// 商品分类 taxonomy(独立于文章 category)
|
||||
register_taxonomy('aksrt_product_cat', 'aksrt_product', [
|
||||
'labels' => [
|
||||
'name' => __('商品分类', 'aksrt'),
|
||||
'singular_name' => __('商品分类', 'aksrt'),
|
||||
'search_items' => __('搜索商品分类', 'aksrt'),
|
||||
'all_items' => __('所有商品分类', 'aksrt'),
|
||||
'parent_item' => __('父级分类', 'aksrt'),
|
||||
'edit_item' => __('编辑分类', 'aksrt'),
|
||||
'add_new_item' => __('新增分类', 'aksrt'),
|
||||
'menu_name' => __('商品分类', 'aksrt'),
|
||||
],
|
||||
'public' => true,
|
||||
'hierarchical' => true,
|
||||
'show_ui' => $shop_show_ui,
|
||||
'show_in_menu' => $shop_show_menu,
|
||||
'show_in_rest' => true,
|
||||
'show_admin_column' => true,
|
||||
'rewrite' => ['slug' => 'product-cat', 'with_front' => false],
|
||||
]);
|
||||
|
||||
// 商品标签 taxonomy(独立于文章 post_tag)
|
||||
register_taxonomy('aksrt_product_tag', 'aksrt_product', [
|
||||
'labels' => [
|
||||
'name' => __('商品标签', 'aksrt'),
|
||||
'singular_name' => __('商品标签', 'aksrt'),
|
||||
'search_items' => __('搜索商品标签', 'aksrt'),
|
||||
'all_items' => __('所有商品标签', 'aksrt'),
|
||||
'edit_item' => __('编辑标签', 'aksrt'),
|
||||
'add_new_item' => __('新增标签', 'aksrt'),
|
||||
'menu_name' => __('特色标签', 'aksrt'),
|
||||
],
|
||||
'public' => true,
|
||||
'hierarchical' => false,
|
||||
'show_ui' => $shop_show_ui,
|
||||
'show_in_menu' => $shop_show_menu,
|
||||
'show_in_rest' => true,
|
||||
'show_admin_column' => true,
|
||||
'rewrite' => ['slug' => 'product-tag', 'with_front' => false],
|
||||
]);
|
||||
|
||||
// 项目展示
|
||||
register_post_type('project', [
|
||||
'labels' => [
|
||||
|
||||
@@ -382,30 +382,6 @@ trait SetupPart1 {
|
||||
'after_title' => $title_after,
|
||||
]);
|
||||
|
||||
// ── Shop widget areas (§6.9) ──
|
||||
$shop_widgets = [
|
||||
'shop_home_top_fluid' => ['商城首页 · 顶部通栏', '商城首页顶部全宽区域。'],
|
||||
'shop_home_sidebar' => ['商城首页 · 侧栏', '商城首页侧栏区域。'],
|
||||
'shop_product_top_fluid' => ['商品详情页 · 顶部通栏', '商品详情页顶部全宽区域。'],
|
||||
'shop_product_sidebar' => ['商品详情页 · 侧栏', '商品详情页侧栏区域(PC 端)。'],
|
||||
'shop_product_bottom_fluid' => ['商品详情页 · 底部通栏', '商品详情页底部全宽区域。'],
|
||||
'shop_cart_top_fluid' => ['购物车页 · 顶部通栏', '购物车页顶部全宽区域。'],
|
||||
'shop_cart_bottom_fluid' => ['购物车页 · 底部通栏', '购物车页底部全宽区域。'],
|
||||
'shop_order_top_fluid' => ['订单页 · 顶部通栏', '订单中心顶部全宽。'],
|
||||
'shop_order_bottom_fluid' => ['订单页 · 底部通栏', '订单中心底部全宽。'],
|
||||
];
|
||||
foreach ($shop_widgets as $sid => $meta) {
|
||||
register_sidebar([
|
||||
'id' => $sid,
|
||||
'name' => $meta[0],
|
||||
'description' => $meta[1],
|
||||
'before_widget' => $card_wrapper_before,
|
||||
'after_widget' => '</div>',
|
||||
'before_title' => $title_before,
|
||||
'after_title' => $title_after,
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Per-page dynamic sidebars (zibll zib_page_register_sidebar parity) ──
|
||||
// Pages that opt in via meta `widgets_register=1` + `widgets_register_container`
|
||||
// (a serialized list of area keys) get page_{key}_{ID} sidebars registered, and
|
||||
|
||||
@@ -23,97 +23,5 @@ trait SetupPart2 {
|
||||
// 移除仪表盘中的不必要组件
|
||||
remove_meta_box('dashboard_primary', 'dashboard', 'normal');
|
||||
remove_meta_box('dashboard_quick_press', 'dashboard', 'side');
|
||||
|
||||
// Separators around shop (35) and forum (40) — match zibll admin_menu_separator.
|
||||
global $menu;
|
||||
if (!is_array($menu)) {
|
||||
return;
|
||||
}
|
||||
if (!isset($menu[34])) {
|
||||
$menu[34] = ['', 'read', 'separator-aksrt-shop', '', 'wp-menu-separator'];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register shop rewrite rules for:
|
||||
* - /shop/{ID}.html — product detail (ID-based, .html suffix)
|
||||
* - /shop/{ID}.html/comment-{page} — product comment pagination
|
||||
* - /{cart_slug}/ — cart page (fixed alias)
|
||||
*
|
||||
* Runs at init priority 20 (after CPT registration at priority 1).
|
||||
*/
|
||||
public function register_shop_rewrite_rules(): void {
|
||||
if (!class_exists('\AKSRT\Settings\Settings')) {
|
||||
return;
|
||||
}
|
||||
$shop = \AKSRT\Settings\Settings::get_shop_settings();
|
||||
if (empty($shop['enabled'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$rw = $shop['rewrite'] ?? [];
|
||||
$product_slug = sanitize_key((string) ($rw['product_slug'] ?? 'product'));
|
||||
$cart_slug = sanitize_key((string) ($rw['cart_slug'] ?? 'cart'));
|
||||
$suffix_html = !empty($rw['suffix_html']);
|
||||
|
||||
// Product ID-based .html routes: /shop/{ID}.html
|
||||
if ($suffix_html) {
|
||||
add_rewrite_rule(
|
||||
'^shop/(\d+)\.html/comment-page-([0-9]{1,})/?$',
|
||||
'index.php?post_type=aksrt_product&p=$matches[1]&cpage=$matches[2]',
|
||||
'top'
|
||||
);
|
||||
add_rewrite_rule(
|
||||
'^shop/(\d+)\.html/?$',
|
||||
'index.php?post_type=aksrt_product&p=$matches[1]',
|
||||
'top'
|
||||
);
|
||||
// Slug-based .html: /{product_slug}/{slug}.html
|
||||
add_rewrite_rule(
|
||||
'^' . preg_quote($product_slug) . '/([^/]+)\.html/comment-page-([0-9]{1,})/?$',
|
||||
'index.php?post_type=aksrt_product&aksrt_product=$matches[1]&cpage=$matches[3]',
|
||||
'top'
|
||||
);
|
||||
add_rewrite_rule(
|
||||
'^' . preg_quote($product_slug) . '/([^/]+)\.html/?$',
|
||||
'index.php?post_type=aksrt_product&aksrt_product=$matches[1]',
|
||||
'top'
|
||||
);
|
||||
}
|
||||
|
||||
// Cart page: /{cart_slug}/
|
||||
add_rewrite_rule(
|
||||
'^' . preg_quote($cart_slug) . '/?$',
|
||||
'index.php?pagename=' . $cart_slug,
|
||||
'top'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register shop-specific query vars.
|
||||
*/
|
||||
public function register_shop_query_vars(array $vars): array {
|
||||
$vars[] = 'product_id';
|
||||
$vars[] = 'shop_action';
|
||||
return $vars;
|
||||
}
|
||||
|
||||
/**
|
||||
* Template redirect for shop pages.
|
||||
*
|
||||
* - Single aksrt_product: serve page-product.php template
|
||||
* - Cart page: serve page-cart.php template
|
||||
*/
|
||||
public function shop_template_redirect(): void {
|
||||
// Single product: if the main query resolves to aksrt_product, use our template
|
||||
if (is_singular('aksrt_product')) {
|
||||
$tpl = AKSRT_DIR . '/templates/pages/page-product.php';
|
||||
if (file_exists($tpl)) {
|
||||
// Set product_id query var for template consumption
|
||||
set_query_var('product_id', get_queried_object_id());
|
||||
include $tpl;
|
||||
exit;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -159,7 +159,7 @@ class Coupon {
|
||||
}
|
||||
|
||||
// Product / content scope check.
|
||||
// Supports shop product cats and content post cats.
|
||||
// Supports product and content post cats.
|
||||
if (!empty($coupon['product_scope']) && !empty($args['product_ids'])) {
|
||||
$scope = is_array($coupon['product_scope']) ? $coupon['product_scope'] : json_decode($coupon['product_scope'], true);
|
||||
if (is_array($scope) && !empty($scope)) {
|
||||
@@ -168,11 +168,7 @@ class Coupon {
|
||||
$product_ids = array_map('intval', (array) $args['product_ids']);
|
||||
$taxonomies = array_values(array_filter(array_map('sanitize_key', (array) ($scope['taxonomies'] ?? []))));
|
||||
if ($taxonomies === []) {
|
||||
// content context prefers category; shop admin may pass aksrt_product_cat.
|
||||
$context = sanitize_key((string) ($args['context'] ?? ''));
|
||||
$taxonomies = ($context === 'content')
|
||||
? ['category']
|
||||
: ['aksrt_product_cat', 'category'];
|
||||
$taxonomies = ['aksrt_product_cat', 'category'];
|
||||
}
|
||||
|
||||
if ($scope_type === 'include' && empty(array_intersect($product_ids, $scope_ids))) {
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Payment;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class Discount {
|
||||
|
||||
private static $instance = null;
|
||||
|
||||
public static function get_instance(): self {
|
||||
if (null === self::$instance) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active discount campaigns for a product.
|
||||
*
|
||||
* @param int $product_id
|
||||
* @return array<int, array> Sorted by priority desc.
|
||||
*/
|
||||
public function get_active_discounts(int $product_id): array {
|
||||
$terms = wp_get_object_terms($product_id, 'shop_discount', ['fields' => 'all']);
|
||||
if (is_wp_error($terms) || empty($terms)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$now = current_time('timestamp');
|
||||
$active = [];
|
||||
|
||||
foreach ($terms as $term) {
|
||||
$meta = \AKSRT\Shop\TermMeta::get_discount_meta($term->term_id);
|
||||
|
||||
// Time limit check
|
||||
if (!empty($meta['start']) && strtotime($meta['start']) > $now) {
|
||||
continue;
|
||||
}
|
||||
if (!empty($meta['end']) && strtotime($meta['end']) < $now) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$meta['term_id'] = $term->term_id;
|
||||
$meta['name'] = $term->name;
|
||||
$active[] = $meta;
|
||||
}
|
||||
|
||||
// Sort by priority desc
|
||||
usort($active, function ($a, $b) {
|
||||
return ((int) $b['priority']) - ((int) $a['priority']);
|
||||
});
|
||||
|
||||
return $active;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate discount for a single line item.
|
||||
*
|
||||
* @param float $unit_price Unit price
|
||||
* @param int $quantity Quantity
|
||||
* @param int $product_id Product ID
|
||||
* @param int $user_id User ID (for user_limit checks)
|
||||
* @return array{
|
||||
* original: float,
|
||||
* discount_amount: float,
|
||||
* final_price: float,
|
||||
* gifts: array,
|
||||
* applied: array<int, array>
|
||||
* }
|
||||
*/
|
||||
public function calculate(float $unit_price, int $quantity, int $product_id, int $user_id = 0): array {
|
||||
$original = $unit_price * $quantity;
|
||||
$discount_amount = 0.0;
|
||||
$gifts = [];
|
||||
$applied = [];
|
||||
|
||||
$discounts = $this->get_active_discounts($product_id);
|
||||
|
||||
foreach ($discounts as $d) {
|
||||
// User limit check
|
||||
if (!empty($d['user_limit']) && $user_id > 0) {
|
||||
if (!$this->check_user_limit($d['user_limit'], $user_id)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Price limit check
|
||||
if ((float) $d['price_limit'] > 0 && $original < (float) $d['price_limit']) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$type = $d['type'] ?? 'reduction';
|
||||
$value = (float) ($d['value'] ?? 0);
|
||||
|
||||
switch ($type) {
|
||||
case 'reduction':
|
||||
// 立减
|
||||
$discount_amount += min($value * $quantity, $original - $discount_amount);
|
||||
break;
|
||||
|
||||
case 'discount':
|
||||
// 打折 (value = 折扣系数 e.g. 8.5 = 85折)
|
||||
if ($value > 0 && $value < 10) {
|
||||
$rate = $value / 10.0;
|
||||
$save = ($original - $discount_amount) * (1 - $rate);
|
||||
$discount_amount += $save;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'gift':
|
||||
// 买赠 — collect gift config, no price reduction
|
||||
if (!empty($d['gift_config'])) {
|
||||
$gift_data = json_decode($d['gift_config'], true);
|
||||
if (is_array($gift_data)) {
|
||||
$gifts[] = $gift_data;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
$applied[] = [
|
||||
'term_id' => $d['term_id'],
|
||||
'name' => $d['name'] ?? '',
|
||||
'type' => $type,
|
||||
'value' => $value,
|
||||
];
|
||||
}
|
||||
|
||||
$discount_amount = min($discount_amount, $original);
|
||||
|
||||
return [
|
||||
'original' => $original,
|
||||
'discount_amount' => round($discount_amount, 2),
|
||||
'final_price' => round($original - $discount_amount, 2),
|
||||
'gifts' => $gifts,
|
||||
'applied' => $applied,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user meets the user_limit requirement.
|
||||
*
|
||||
* @param string $limit 'vip' / 'vip_2' / 'auth'
|
||||
* @param int $user_id
|
||||
* @return bool
|
||||
*/
|
||||
private function check_user_limit(string $limit, int $user_id): bool {
|
||||
if (empty($limit)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
switch ($limit) {
|
||||
case 'vip':
|
||||
return function_exists('aksrt_is_vip') ? aksrt_is_vip($user_id) : false;
|
||||
case 'vip_2':
|
||||
return function_exists('aksrt_is_vip2') ? aksrt_is_vip2($user_id) : false;
|
||||
case 'auth':
|
||||
return function_exists('aksrt_is_verified') ? aksrt_is_verified($user_id) : false;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,17 +81,10 @@ class OrderCenterRest {
|
||||
}
|
||||
$payment_method = sanitize_key((string) ($params['payment_method'] ?? ''));
|
||||
|
||||
if (class_exists(UserOrders::class)) {
|
||||
$result = UserOrders::get_instance()->pay_order(get_current_user_id(), $order_number, $payment_method);
|
||||
} elseif (class_exists('\AKSRT\Shop\Shop')) {
|
||||
$result = \AKSRT\Shop\Shop::get_instance()->pay_order(
|
||||
get_current_user_id(),
|
||||
$order_number,
|
||||
$payment_method
|
||||
);
|
||||
} else {
|
||||
if (!class_exists(UserOrders::class)) {
|
||||
return $this->error(__('订单服务不可用', 'aksrt'), 500);
|
||||
}
|
||||
}
|
||||
$result = UserOrders::get_instance()->pay_order(get_current_user_id(), $order_number, $payment_method);
|
||||
|
||||
if (empty($result['success'])) {
|
||||
return $this->error((string) ($result['message'] ?? __('支付失败', 'aksrt')), 400);
|
||||
@@ -103,13 +96,10 @@ class OrderCenterRest {
|
||||
public function cancel(\WP_REST_Request $request): \WP_REST_Response {
|
||||
$order_number = sanitize_text_field((string) $request->get_param('order_number'));
|
||||
|
||||
if (class_exists(UserOrders::class)) {
|
||||
$result = UserOrders::get_instance()->cancel_order(get_current_user_id(), $order_number);
|
||||
} elseif (class_exists('\AKSRT\Shop\Shop')) {
|
||||
$result = \AKSRT\Shop\Shop::get_instance()->cancel_order(get_current_user_id(), $order_number);
|
||||
} else {
|
||||
if (!class_exists(UserOrders::class)) {
|
||||
return $this->error(__('订单服务不可用', 'aksrt'), 500);
|
||||
}
|
||||
}
|
||||
$result = UserOrders::get_instance()->cancel_order(get_current_user_id(), $order_number);
|
||||
|
||||
if (empty($result['success'])) {
|
||||
return $this->error((string) ($result['message'] ?? __('取消订单失败', 'aksrt')), 400);
|
||||
@@ -122,11 +112,10 @@ class OrderCenterRest {
|
||||
$order_number = sanitize_text_field((string) $request->get_param('order_number'));
|
||||
$order = null;
|
||||
|
||||
if (class_exists(UserOrders::class)) {
|
||||
$order = UserOrders::get_instance()->get_order_detail_view(get_current_user_id(), $order_number);
|
||||
} elseif (class_exists('\AKSRT\Shop\Shop')) {
|
||||
$order = \AKSRT\Shop\Shop::get_instance()->get_order_detail_view(get_current_user_id(), $order_number);
|
||||
}
|
||||
if (!class_exists(UserOrders::class)) {
|
||||
return $this->error(__('订单服务不可用', 'aksrt'), 500);
|
||||
}
|
||||
$order = UserOrders::get_instance()->get_order_detail_view(get_current_user_id(), $order_number);
|
||||
|
||||
if (!$order) {
|
||||
return $this->error(__('订单不存在', 'aksrt'), 404);
|
||||
|
||||
@@ -63,18 +63,9 @@ trait OrderRepositoryAdmin {
|
||||
}
|
||||
|
||||
$resolved_order_numbers = array_keys($orders);
|
||||
$shipping_map = class_exists('AKSRT\\Payment\\Shipping')
|
||||
? Shipping::get_instance()->get_summary_map($resolved_order_numbers)
|
||||
: [];
|
||||
$after_sale_map = class_exists('AKSRT\\Payment\\AfterSale')
|
||||
? AfterSale::get_instance()->get_summary_map($resolved_order_numbers)
|
||||
: [];
|
||||
|
||||
foreach ($orders as $order_number => $order) {
|
||||
$contexts[$order_number] = $this->build_admin_order_context($order, [
|
||||
'shipping_summary' => $shipping_map[$order_number] ?? [],
|
||||
'after_sale_summary' => $after_sale_map[$order_number] ?? [],
|
||||
]);
|
||||
$contexts[$order_number] = $this->build_admin_order_context($order, []);
|
||||
}
|
||||
|
||||
return $contexts;
|
||||
@@ -130,64 +121,6 @@ trait OrderRepositoryAdmin {
|
||||
/**
|
||||
* 管理后台订单列表 — 支持 search(order_number / product_type 模糊匹配)
|
||||
*/
|
||||
/**
|
||||
* Paid orders that still need shipping (no shipping row yet).
|
||||
*
|
||||
* @return array{items: array<int,array<string,mixed>>, total: int, page: int, max_pages: int}
|
||||
*/
|
||||
public function get_pending_shipment_orders(array $args = []): array {
|
||||
global $wpdb;
|
||||
|
||||
$limit = max(1, min(100, (int) ($args['limit'] ?? 20)));
|
||||
$offset = max(0, (int) ($args['offset'] ?? 0));
|
||||
$search = sanitize_text_field((string) ($args['search'] ?? $args['s'] ?? ''));
|
||||
|
||||
$order_table = $this->table();
|
||||
// Must match Shipping::table() key.
|
||||
$shipping_table = class_exists('\AKSRT\Core\DBMigrator')
|
||||
? \AKSRT\Core\DBMigrator::table('order_shipping')
|
||||
: $wpdb->prefix . 'aksrt_order_shipping';
|
||||
|
||||
$where = ["o.status = %s"];
|
||||
$params = ['paid'];
|
||||
|
||||
if ($search !== '') {
|
||||
$where[] = '(o.order_number LIKE %s OR o.product_type LIKE %s)';
|
||||
$like = '%' . $wpdb->esc_like($search) . '%';
|
||||
$params[] = $like;
|
||||
$params[] = $like;
|
||||
}
|
||||
|
||||
// NOT EXISTS avoids ONLY_FULL_GROUP_BY issues from LEFT JOIN + GROUP BY.
|
||||
$where[] = "NOT EXISTS (
|
||||
SELECT 1 FROM {$shipping_table} s
|
||||
WHERE s.order_number = o.order_number
|
||||
)";
|
||||
$where_sql = 'WHERE ' . implode(' AND ', $where);
|
||||
|
||||
$count_sql = "SELECT COUNT(*) FROM {$order_table} o {$where_sql}";
|
||||
$total = (int) $wpdb->get_var($wpdb->prepare($count_sql, $params));
|
||||
|
||||
$sql = "SELECT o.*
|
||||
FROM {$order_table} o
|
||||
{$where_sql}
|
||||
ORDER BY COALESCE(o.paid_at, o.created_at) DESC, o.id DESC
|
||||
LIMIT %d OFFSET %d";
|
||||
$query_params = $params;
|
||||
$query_params[] = $limit;
|
||||
$query_params[] = $offset;
|
||||
$rows = $wpdb->get_results($wpdb->prepare($sql, $query_params), ARRAY_A);
|
||||
$items = array_map([$this, 'normalize_row'], (array) $rows);
|
||||
|
||||
return [
|
||||
'items' => $items,
|
||||
'total' => $total,
|
||||
'page' => (int) floor($offset / $limit) + 1,
|
||||
'max_pages' => (int) ceil(max(1, $total) / $limit),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
|
||||
public function get_orders_admin(array $args = [], string $search = ''): array {
|
||||
global $wpdb;
|
||||
@@ -499,8 +432,6 @@ trait OrderRepositoryAdmin {
|
||||
$discount = (float) ($meta['discount'] ?? 0);
|
||||
$amount = (float) ($order['amount'] ?? 0);
|
||||
$subtotal = $this->calculate_order_subtotal($items, max(0, $amount + $discount - $shipping_fee));
|
||||
$shipping_summary = is_array($related['shipping_summary'] ?? null) ? $related['shipping_summary'] : [];
|
||||
$after_sale_summary = is_array($related['after_sale_summary'] ?? null) ? $related['after_sale_summary'] : [];
|
||||
|
||||
$address['full_text'] = $this->build_address_text($address);
|
||||
|
||||
@@ -534,12 +465,12 @@ trait OrderRepositoryAdmin {
|
||||
'fee' => $shipping_fee,
|
||||
],
|
||||
'shipping' => [
|
||||
'count' => (int) ($shipping_summary['count'] ?? 0),
|
||||
'latest' => is_array($shipping_summary['latest'] ?? null) ? $shipping_summary['latest'] : [],
|
||||
'count' => 0,
|
||||
'latest' => [],
|
||||
],
|
||||
'after_sale' => [
|
||||
'count' => (int) ($after_sale_summary['count'] ?? 0),
|
||||
'latest' => is_array($after_sale_summary['latest'] ?? null) ? $after_sale_summary['latest'] : [],
|
||||
'count' => 0,
|
||||
'latest' => [],
|
||||
],
|
||||
'coupon' => $coupon,
|
||||
'pricing' => [
|
||||
|
||||
@@ -1,371 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Payment;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class Shipping {
|
||||
use ShippingPart1;
|
||||
use ShippingPart2;
|
||||
|
||||
|
||||
private static $instance = null;
|
||||
|
||||
const STATUS_SHIPPED = 'shipped';
|
||||
const STATUS_IN_TRANSIT = 'in_transit';
|
||||
const STATUS_DELIVERED = 'delivered';
|
||||
const STATUS_RETURNED = 'returned';
|
||||
|
||||
public static function get_instance(): self {
|
||||
if (null === self::$instance) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
private function table(): string {
|
||||
return \AKSRT\Core\DBMigrator::table('order_shipping');
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建发货记录
|
||||
*/
|
||||
public function create(array $data): int {
|
||||
global $wpdb;
|
||||
|
||||
$order_number = sanitize_text_field((string) ($data['order_number'] ?? ''));
|
||||
$carrier = sanitize_text_field((string) ($data['carrier'] ?? ''));
|
||||
$tracking_no = sanitize_text_field((string) ($data['tracking_no'] ?? ''));
|
||||
$note = sanitize_textarea_field((string) ($data['note'] ?? ''));
|
||||
$operator_id = (int) ($data['operator_id'] ?? 0);
|
||||
|
||||
if ($order_number === '' || $carrier === '' || $tracking_no === '') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$result = $wpdb->insert($this->table(), [
|
||||
'order_number' => $order_number,
|
||||
'carrier' => $carrier,
|
||||
'tracking_no' => $tracking_no,
|
||||
'status' => self::STATUS_SHIPPED,
|
||||
'note' => $note,
|
||||
'operator_id' => $operator_id ?: null,
|
||||
'created_at' => current_time('mysql'),
|
||||
]);
|
||||
|
||||
$insert_id = $result ? (int) $wpdb->insert_id : 0;
|
||||
|
||||
if ($insert_id > 0) {
|
||||
$this->sync_order_status($order_number, self::STATUS_SHIPPED, sprintf(
|
||||
__('发货记录已创建:%1$s %2$s', 'aksrt'),
|
||||
$this->get_carrier_label($carrier),
|
||||
$tracking_no
|
||||
));
|
||||
|
||||
// R68: Email notification on shipping creation (§6.6)
|
||||
$this->send_shipping_notification($order_number, $carrier, $tracking_no, $note);
|
||||
}
|
||||
|
||||
return $insert_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取发货记录列表
|
||||
*/
|
||||
public function get_list(array $args = []): array {
|
||||
global $wpdb;
|
||||
|
||||
$limit = max(1, min(100, (int) ($args['limit'] ?? 20)));
|
||||
$offset = max(0, (int) ($args['offset'] ?? 0));
|
||||
$include_order_context = !empty($args['include_order_context']);
|
||||
$status = sanitize_key((string) ($args['status'] ?? ''));
|
||||
$order_number = sanitize_text_field((string) ($args['order_number'] ?? ''));
|
||||
$date_from = sanitize_text_field((string) ($args['date_from'] ?? ''));
|
||||
$date_to = sanitize_text_field((string) ($args['date_to'] ?? ''));
|
||||
$tracking_no = sanitize_text_field((string) ($args['tracking_no'] ?? ''));
|
||||
|
||||
$where = [];
|
||||
$params = [];
|
||||
|
||||
if ($status !== '') {
|
||||
$where[] = 'status = %s';
|
||||
$params[] = $status;
|
||||
}
|
||||
if ($order_number !== '') {
|
||||
$where[] = 'order_number = %s';
|
||||
$params[] = $order_number;
|
||||
}
|
||||
if ($tracking_no !== '') {
|
||||
$where[] = 'tracking_no LIKE %s';
|
||||
$params[] = '%' . $wpdb->esc_like($tracking_no) . '%';
|
||||
}
|
||||
if ($date_from !== '') {
|
||||
$where[] = 'created_at >= %s';
|
||||
$params[] = $date_from . ' 00:00:00';
|
||||
}
|
||||
if ($date_to !== '') {
|
||||
$where[] = 'created_at <= %s';
|
||||
$params[] = $date_to . ' 23:59:59';
|
||||
}
|
||||
|
||||
$where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
|
||||
|
||||
$count_sql = "SELECT COUNT(*) FROM {$this->table()} {$where_sql}";
|
||||
$total = $params ? (int) $wpdb->get_var($wpdb->prepare($count_sql, $params)) : (int) $wpdb->get_var($count_sql);
|
||||
|
||||
$sql = "SELECT * FROM {$this->table()} {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d";
|
||||
$params[] = $limit;
|
||||
$params[] = $offset;
|
||||
|
||||
$items = $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A);
|
||||
$items = array_map([$this, 'decorate_record'], (array) $items);
|
||||
|
||||
if ($include_order_context && !empty($items) && class_exists('AKSRT\\Payment\\OrderRepository')) {
|
||||
$contexts = OrderRepository::get_instance()->get_admin_order_context_map(array_column($items, 'order_number'));
|
||||
foreach ($items as &$item) {
|
||||
$item['order_context'] = $contexts[(string) ($item['order_number'] ?? '')] ?? [];
|
||||
}
|
||||
unset($item);
|
||||
}
|
||||
|
||||
return [
|
||||
'items' => $items ?: [],
|
||||
'total' => $total,
|
||||
'page' => floor($offset / $limit) + 1,
|
||||
'max_pages' => ceil($total / $limit),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个发货记录
|
||||
*/
|
||||
public function get(int $id): ?array {
|
||||
global $wpdb;
|
||||
$row = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT * FROM {$this->table()} WHERE id = %d",
|
||||
$id
|
||||
), ARRAY_A);
|
||||
return $row ? $this->decorate_record($row) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取订单的发货记录
|
||||
*/
|
||||
public function get_by_order(string $order_number): array {
|
||||
global $wpdb;
|
||||
$items = $wpdb->get_results($wpdb->prepare(
|
||||
"SELECT * FROM {$this->table()} WHERE order_number = %s ORDER BY created_at DESC",
|
||||
$order_number
|
||||
), ARRAY_A);
|
||||
return array_map([$this, 'decorate_record'], (array) $items);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个发货记录并附带订单上下文。
|
||||
*/
|
||||
public function get_with_order_context(int $id): ?array {
|
||||
$record = $this->get($id);
|
||||
if (!$record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$record['order_context'] = class_exists('AKSRT\\Payment\\OrderRepository')
|
||||
? OrderRepository::get_instance()->get_admin_order_context((string) ($record['order_number'] ?? ''))
|
||||
: [];
|
||||
|
||||
return $record;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $order_numbers
|
||||
* @return array<string, array<string, mixed>>
|
||||
*/
|
||||
public function get_summary_map(array $order_numbers): array {
|
||||
global $wpdb;
|
||||
|
||||
$order_numbers = array_values(array_unique(array_filter(array_map(
|
||||
static function ($order_number): string {
|
||||
return sanitize_text_field((string) $order_number);
|
||||
},
|
||||
$order_numbers
|
||||
))));
|
||||
|
||||
if (empty($order_numbers)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$map = [];
|
||||
foreach ($order_numbers as $order_number) {
|
||||
$map[$order_number] = [
|
||||
'count' => 0,
|
||||
'latest' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$placeholders = implode(', ', array_fill(0, count($order_numbers), '%s'));
|
||||
$sql = "SELECT * FROM {$this->table()} WHERE order_number IN ({$placeholders}) ORDER BY created_at DESC, id DESC";
|
||||
$rows = $wpdb->get_results($wpdb->prepare($sql, $order_numbers), ARRAY_A);
|
||||
|
||||
foreach ((array) $rows as $row) {
|
||||
$record = $this->decorate_record($row);
|
||||
$order_number = (string) ($record['order_number'] ?? '');
|
||||
|
||||
if (!isset($map[$order_number])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$map[$order_number]['count']++;
|
||||
if (empty($map[$order_number]['latest'])) {
|
||||
$map[$order_number]['latest'] = $this->build_summary_item($record);
|
||||
}
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
public function batch_update_status(array $ids, string $new_status, string $note = '', int $operator_id = 0): array {
|
||||
$ids = array_values(array_unique(array_filter(array_map('intval', $ids))));
|
||||
$result = [
|
||||
'updated_ids' => [],
|
||||
'failed' => [],
|
||||
];
|
||||
|
||||
foreach ($ids as $id) {
|
||||
$record = $this->get($id);
|
||||
if (!$record) {
|
||||
$result['failed'][] = [
|
||||
'id' => $id,
|
||||
'reason' => __('发货记录不存在', 'aksrt'),
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!$this->can_transition_status((string) ($record['status'] ?? ''), $new_status)) {
|
||||
$result['failed'][] = [
|
||||
'id' => $id,
|
||||
'reason' => __('当前状态不允许批量更新到目标状态', 'aksrt'),
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->update_status($id, $new_status, $note, $operator_id)) {
|
||||
$result['updated_ids'][] = $id;
|
||||
} else {
|
||||
$result['failed'][] = [
|
||||
'id' => $id,
|
||||
'reason' => __('状态更新失败', 'aksrt'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新发货状态
|
||||
*/
|
||||
public function update_status(int $id, string $new_status, string $note = '', int $operator_id = 0): bool {
|
||||
global $wpdb;
|
||||
|
||||
$record = $this->get($id);
|
||||
if (!$record) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$valid = [self::STATUS_SHIPPED, self::STATUS_IN_TRANSIT, self::STATUS_DELIVERED, self::STATUS_RETURNED];
|
||||
if (!in_array($new_status, $valid, true)) return false;
|
||||
if (!$this->can_transition_status((string) ($record['status'] ?? ''), $new_status)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$update = [
|
||||
'status' => $new_status,
|
||||
'updated_at' => current_time('mysql'),
|
||||
];
|
||||
|
||||
if ($note !== '') {
|
||||
$update['note'] = sanitize_textarea_field($note);
|
||||
}
|
||||
if ($operator_id > 0) {
|
||||
$update['operator_id'] = $operator_id;
|
||||
}
|
||||
if ($new_status === self::STATUS_DELIVERED) {
|
||||
$update['delivered_at'] = current_time('mysql');
|
||||
}
|
||||
|
||||
$updated = (bool) $wpdb->update($this->table(), $update, ['id' => $id]);
|
||||
if ($updated) {
|
||||
$this->sync_order_status(
|
||||
(string) ($record['order_number'] ?? ''),
|
||||
$new_status,
|
||||
$note !== '' ? $note : sprintf(__('物流状态更新为:%s', 'aksrt'), $this->get_status_label($new_status))
|
||||
);
|
||||
}
|
||||
|
||||
return $updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取统计
|
||||
*/
|
||||
public function get_stats(): array {
|
||||
global $wpdb;
|
||||
$table = $this->table();
|
||||
|
||||
$counts = $wpdb->get_results(
|
||||
"SELECT status, COUNT(*) as cnt FROM {$table} GROUP BY status",
|
||||
ARRAY_A
|
||||
);
|
||||
$counts = array_column($counts, 'cnt', 'status');
|
||||
|
||||
$stats = [
|
||||
'total' => 0,
|
||||
'shipped' => 0,
|
||||
'in_transit' => 0,
|
||||
'delivered' => 0,
|
||||
'returned' => 0,
|
||||
];
|
||||
|
||||
foreach ($counts as $status => $cnt) {
|
||||
if (isset($stats[$status])) {
|
||||
$stats[$status] = (int) $cnt;
|
||||
}
|
||||
$stats['total'] += (int) $cnt;
|
||||
}
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取常用快递公司列表
|
||||
*/
|
||||
public static function get_carrier_options(): array {
|
||||
return [
|
||||
['value' => 'sf', 'label' => '顺丰速运'],
|
||||
['value' => 'yd', 'label' => '韵达快递'],
|
||||
['value' => 'zt', 'label' => '中通快递'],
|
||||
['value' => 'yt', 'label' => '圆通速递'],
|
||||
['value' => 'sto', 'label' => '申通快递'],
|
||||
['value' => 'jd', 'label' => '京东物流'],
|
||||
['value' => 'ems', 'label' => 'EMS'],
|
||||
['value' => 'db', 'label' => '德邦快递'],
|
||||
['value' => 'yunda', 'label' => '韵达快递'],
|
||||
['value' => 'other', 'label' => '其他'],
|
||||
];
|
||||
}
|
||||
public static function get_status_options(): array {
|
||||
return [
|
||||
['value' => self::STATUS_SHIPPED, 'label' => __('已发货', 'aksrt')],
|
||||
['value' => self::STATUS_IN_TRANSIT, 'label' => __('运输中', 'aksrt')],
|
||||
['value' => self::STATUS_DELIVERED, 'label' => __('已签收', 'aksrt')],
|
||||
['value' => self::STATUS_RETURNED, 'label' => __('已退回', 'aksrt')],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Send email notification to order customer when shipping record is created.
|
||||
*/
|
||||
}
|
||||
@@ -1,371 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Payment;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
trait ShippingPart1 {
|
||||
|
||||
private function send_shipping_notification(string $order_number, string $carrier, string $tracking_no, string $note = ''): void {
|
||||
if (!class_exists('AKSRT\\Payment\\OrderRepository')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$order = OrderRepository::get_instance()->get($order_number);
|
||||
if (!$order) {
|
||||
return;
|
||||
}
|
||||
|
||||
$user_id = (int) ($order['user_id'] ?? 0);
|
||||
if ($user_id <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$user = get_userdata($user_id);
|
||||
if (!$user || empty($user->user_email)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$carrier_label = $this->get_carrier_label($carrier);
|
||||
$site_name = get_bloginfo('name');
|
||||
$subject = sprintf(__('[%s] 您的订单已发货', 'aksrt'), $site_name);
|
||||
$body = sprintf(
|
||||
__('您好 %1$s,\n\n您的订单 %2$s 已发货。\n\n物流公司:%3$s\n运单号:%4$s\n%5$s\n\n如有疑问请联系客服。\n\n%s', 'aksrt'),
|
||||
$user->display_name,
|
||||
$order_number,
|
||||
$carrier_label,
|
||||
$tracking_no,
|
||||
$note !== '' ? sprintf(__('备注:%s', 'aksrt'), $note) : '',
|
||||
$site_name
|
||||
);
|
||||
|
||||
wp_mail($user->user_email, $subject, $body);
|
||||
}
|
||||
|
||||
private function decorate_record(array $record): array {
|
||||
$record['id'] = (int) ($record['id'] ?? 0);
|
||||
$record['operator_id'] = isset($record['operator_id']) ? (int) $record['operator_id'] : 0;
|
||||
$record['status_label'] = $this->get_status_label((string) ($record['status'] ?? ''));
|
||||
$record['carrier_label'] = $this->get_carrier_label((string) ($record['carrier'] ?? ''));
|
||||
$record['operator'] = $this->get_user_summary((int) ($record['operator_id'] ?? 0));
|
||||
return $record;
|
||||
}
|
||||
|
||||
private function get_carrier_label(string $carrier): string {
|
||||
foreach (self::get_carrier_options() as $item) {
|
||||
if (($item['value'] ?? '') === $carrier) {
|
||||
return (string) ($item['label'] ?? $carrier);
|
||||
}
|
||||
}
|
||||
|
||||
return $carrier;
|
||||
}
|
||||
|
||||
private function get_status_label(string $status): string {
|
||||
foreach (self::get_status_options() as $item) {
|
||||
if (($item['value'] ?? '') === $status) {
|
||||
return (string) ($item['label'] ?? $status);
|
||||
}
|
||||
}
|
||||
|
||||
return $status;
|
||||
}
|
||||
|
||||
private function get_user_summary(int $user_id): array {
|
||||
if ($user_id <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$user = get_userdata($user_id);
|
||||
if (!$user) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $user_id,
|
||||
'display_name' => (string) $user->display_name,
|
||||
'user_login' => (string) $user->user_login,
|
||||
];
|
||||
}
|
||||
|
||||
private function build_summary_item(array $record): array {
|
||||
return [
|
||||
'id' => (int) ($record['id'] ?? 0),
|
||||
'status' => (string) ($record['status'] ?? ''),
|
||||
'status_label' => (string) ($record['status_label'] ?? ''),
|
||||
'carrier' => (string) ($record['carrier'] ?? ''),
|
||||
'carrier_label' => (string) ($record['carrier_label'] ?? ''),
|
||||
'tracking_no' => (string) ($record['tracking_no'] ?? ''),
|
||||
'created_at' => (string) ($record['created_at'] ?? ''),
|
||||
'updated_at' => (string) ($record['updated_at'] ?? ''),
|
||||
'delivered_at' => (string) ($record['delivered_at'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
private function can_transition_status(string $current_status, string $new_status): bool {
|
||||
$map = [
|
||||
self::STATUS_SHIPPED => [self::STATUS_IN_TRANSIT, self::STATUS_DELIVERED, self::STATUS_RETURNED],
|
||||
self::STATUS_IN_TRANSIT => [self::STATUS_DELIVERED, self::STATUS_RETURNED],
|
||||
self::STATUS_DELIVERED => [],
|
||||
self::STATUS_RETURNED => [],
|
||||
];
|
||||
|
||||
return in_array($new_status, $map[$current_status] ?? [], true);
|
||||
}
|
||||
|
||||
private function sync_order_status(string $order_number, string $shipping_status, string $note = ''): void {
|
||||
if ($order_number === '' || !class_exists('AKSRT\\Payment\\OrderRepository') || !class_exists('AKSRT\\Payment\\Order')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!in_array($shipping_status, [self::STATUS_SHIPPED, self::STATUS_IN_TRANSIT, self::STATUS_DELIVERED], true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
OrderRepository::get_instance()->update_status($order_number, Order::STATUS_DELIVERED, [
|
||||
'note' => $note,
|
||||
]);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* Express Query API (§6.6 快递查询)
|
||||
*
|
||||
* Supports kuaidi100, aliyun, kdniao.
|
||||
* Traces cached in order_shipping.express_data JSON.
|
||||
* Refresh interval from shop settings.
|
||||
* ============================================================ */
|
||||
|
||||
public function query_express(int $shipping_id, bool $force = false): array {
|
||||
global $wpdb;
|
||||
$record = $this->get($shipping_id);
|
||||
if (!$record) {
|
||||
return ['success' => false, 'message' => __('发货记录不存在', 'aksrt')];
|
||||
}
|
||||
|
||||
$tracking_no = (string) ($record['tracking_no'] ?? '');
|
||||
$carrier = (string) ($record['carrier'] ?? '');
|
||||
if ($tracking_no === '' || $carrier === '') {
|
||||
return ['success' => false, 'message' => __('缺少运单号或物流公司', 'aksrt')];
|
||||
}
|
||||
|
||||
$cached = $this->get_express_cache($shipping_id);
|
||||
if (!$force && !empty($cached) && !$this->should_refresh_cache($cached)) {
|
||||
return ['success' => true, 'data' => $cached];
|
||||
}
|
||||
|
||||
if (!empty($cached['signed'])) {
|
||||
return ['success' => true, 'data' => $cached];
|
||||
}
|
||||
|
||||
$shop_settings = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_shop_settings() : [];
|
||||
$api_sdk = sanitize_key((string) ($shop_settings['express']['api_sdk'] ?? 'kuaidi100'));
|
||||
|
||||
$result = $this->call_express_api($api_sdk, $carrier, $tracking_no, $shop_settings);
|
||||
|
||||
if ($result['success']) {
|
||||
$express_data = [
|
||||
'query_time' => current_time('mysql'),
|
||||
'traces' => $result['traces'] ?? [],
|
||||
'state' => $result['state'] ?? 'unknown',
|
||||
'signed' => !empty($result['signed']),
|
||||
'api' => $api_sdk,
|
||||
];
|
||||
|
||||
$wpdb->update(
|
||||
$this->table(),
|
||||
['note' => wp_json_encode($express_data, JSON_UNESCAPED_UNICODE)],
|
||||
['id' => $shipping_id],
|
||||
['%s'],
|
||||
['%d']
|
||||
);
|
||||
|
||||
if (!empty($result['signed']) && ($record['status'] ?? '') !== self::STATUS_DELIVERED) {
|
||||
$this->update_status($shipping_id, self::STATUS_DELIVERED, __('快递已签收(API检测)', 'aksrt'));
|
||||
}
|
||||
|
||||
return ['success' => true, 'data' => $express_data];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function get_express_cache(int $shipping_id): array {
|
||||
global $wpdb;
|
||||
$note = $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT note FROM {$this->table()} WHERE id = %d",
|
||||
$shipping_id
|
||||
));
|
||||
if (!$note) return [];
|
||||
$data = json_decode($note, true);
|
||||
return is_array($data) && isset($data['traces']) ? $data : [];
|
||||
}
|
||||
|
||||
private function should_refresh_cache(array $cached): bool {
|
||||
if (empty($cached['query_time'])) return true;
|
||||
|
||||
$shop = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_shop_settings() : [];
|
||||
$interval = max(30, min(240, (int) ($shop['express']['query_interval'] ?? 60)));
|
||||
|
||||
$last = strtotime($cached['query_time']);
|
||||
return (time() - $last) > ($interval * 60);
|
||||
}
|
||||
|
||||
private function call_express_api(string $sdk, string $carrier, string $tracking_no, array $settings): array {
|
||||
$carrier_code = $this->map_carrier_code($sdk, $carrier);
|
||||
|
||||
if ($sdk === 'kuaidi100') {
|
||||
return $this->query_kuaidi100($carrier_code, $tracking_no, $settings);
|
||||
}
|
||||
if ($sdk === 'kdniao') {
|
||||
return $this->query_kdniao($carrier_code, $tracking_no, $settings);
|
||||
}
|
||||
if ($sdk === 'aliyun') {
|
||||
return $this->query_aliyun($carrier_code, $tracking_no, $settings);
|
||||
}
|
||||
|
||||
return ['success' => false, 'message' => __('未配置快递查询接口', 'aksrt')];
|
||||
}
|
||||
|
||||
private function query_kuaidi100(string $com, string $num, array $settings): array {
|
||||
$key = sanitize_text_field((string) ($settings['express']['kuaidi100_key'] ?? ''));
|
||||
$customer = sanitize_text_field((string) ($settings['express']['kuaidi100_customer'] ?? ''));
|
||||
if ($key === '' || $customer === '') {
|
||||
return ['success' => false, 'message' => __('快递100 API 未配置', 'aksrt')];
|
||||
}
|
||||
|
||||
$param = wp_json_encode(['com' => $com, 'num' => $num]);
|
||||
$sign = strtoupper(md5($param . $key . $customer));
|
||||
|
||||
$response = wp_remote_post('https://poll.kuaidi100.com/poll/query.do', [
|
||||
'body' => ['customer' => $customer, 'sign' => $sign, 'param' => $param],
|
||||
'timeout' => 10,
|
||||
]);
|
||||
|
||||
if (is_wp_error($response)) {
|
||||
return ['success' => false, 'message' => $response->get_error_message()];
|
||||
}
|
||||
|
||||
$body = json_decode(wp_remote_retrieve_body($response), true);
|
||||
if (!is_array($body) || (int) ($body['status'] ?? 0) !== 200) {
|
||||
return ['success' => false, 'message' => (string) ($body['message'] ?? __('查询失败', 'aksrt'))];
|
||||
}
|
||||
|
||||
$state = (string) ($body['state'] ?? '0');
|
||||
$signed = $state === '3';
|
||||
|
||||
$traces = [];
|
||||
foreach ((array) ($body['data'] ?? []) as $item) {
|
||||
$traces[] = [
|
||||
'time' => (string) ($item['ftime'] ?? $item['time'] ?? ''),
|
||||
'context' => (string) ($item['context'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
return ['success' => true, 'traces' => $traces, 'state' => $state, 'signed' => $signed];
|
||||
}
|
||||
|
||||
private function query_kdniao(string $code, string $num, array $settings): array {
|
||||
$app_id = sanitize_text_field((string) ($settings['express']['kdniao_app_id'] ?? ''));
|
||||
$app_key = sanitize_text_field((string) ($settings['express']['kdniao_app_key'] ?? ''));
|
||||
if ($app_id === '' || $app_key === '') {
|
||||
return ['success' => false, 'message' => __('快递鸟 API 未配置', 'aksrt')];
|
||||
}
|
||||
|
||||
$request_data = wp_json_encode(['ShipperCode' => $code, 'LogisticCode' => $num]);
|
||||
$data_sign = urlencode(base64_encode(md5($request_data . $app_key)));
|
||||
|
||||
$response = wp_remote_post('https://api.kdniao.com/Ebusiness/EbusinessOrderHandle.aspx', [
|
||||
'body' => [
|
||||
'RequestData' => urlencode($request_data),
|
||||
'EBusinessID' => $app_id,
|
||||
'RequestType' => '1002',
|
||||
'DataSign' => $data_sign,
|
||||
'DataType' => '2',
|
||||
],
|
||||
'timeout' => 10,
|
||||
]);
|
||||
|
||||
if (is_wp_error($response)) {
|
||||
return ['success' => false, 'message' => $response->get_error_message()];
|
||||
}
|
||||
|
||||
$body = json_decode(wp_remote_retrieve_body($response), true);
|
||||
if (!is_array($body) || !($body['Success'] ?? false)) {
|
||||
return ['success' => false, 'message' => (string) ($body['Reason'] ?? __('查询失败', 'aksrt'))];
|
||||
}
|
||||
|
||||
$state = (string) ($body['State'] ?? '0');
|
||||
$signed = $state === '3';
|
||||
|
||||
$traces = [];
|
||||
foreach ((array) ($body['Traces'] ?? []) as $item) {
|
||||
$traces[] = [
|
||||
'time' => (string) ($item['AcceptTime'] ?? ''),
|
||||
'context' => (string) ($item['AcceptStation'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
return ['success' => true, 'traces' => $traces, 'state' => $state, 'signed' => $signed];
|
||||
}
|
||||
|
||||
private function query_aliyun(string $code, string $num, array $settings): array {
|
||||
$app_code = sanitize_text_field((string) ($settings['express']['aliyun_app_code'] ?? ''));
|
||||
if ($app_code === '') {
|
||||
return ['success' => false, 'message' => __('阿里云快递 API 未配置', 'aksrt')];
|
||||
}
|
||||
|
||||
$url = add_query_arg(['no' => $num, 'type' => $code], 'https://wuliu.market.alicloudapi.com/kdi');
|
||||
|
||||
$response = wp_remote_get($url, [
|
||||
'headers' => ['Authorization' => 'APPCODE ' . $app_code],
|
||||
'timeout' => 10,
|
||||
]);
|
||||
|
||||
if (is_wp_error($response)) {
|
||||
return ['success' => false, 'message' => $response->get_error_message()];
|
||||
}
|
||||
|
||||
$body = json_decode(wp_remote_retrieve_body($response), true);
|
||||
if (!is_array($body) || (int) ($body['status'] ?? -1) !== 0) {
|
||||
return ['success' => false, 'message' => (string) ($body['msg'] ?? __('查询失败', 'aksrt'))];
|
||||
}
|
||||
|
||||
$result_data = $body['result'] ?? [];
|
||||
$state = (string) ($result_data['deliverystatus'] ?? '0');
|
||||
$signed = $state === '3';
|
||||
|
||||
$traces = [];
|
||||
foreach ((array) ($result_data['list'] ?? []) as $item) {
|
||||
$traces[] = [
|
||||
'time' => (string) ($item['time'] ?? ''),
|
||||
'context' => (string) ($item['status'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
return ['success' => true, 'traces' => $traces, 'state' => $state, 'signed' => $signed];
|
||||
}
|
||||
|
||||
private function map_carrier_code(string $sdk, string $carrier): string {
|
||||
$kuaidi100_map = [
|
||||
'sf' => 'shunfeng', 'yd' => 'yunda', 'zt' => 'zhongtong', 'yt' => 'yuantong',
|
||||
'sto' => 'shentong', 'jd' => 'jd', 'ems' => 'ems', 'db' => 'debangkuaidi',
|
||||
'yunda' => 'yunda',
|
||||
];
|
||||
$kdniao_map = [
|
||||
'sf' => 'SF', 'yd' => 'YD', 'zt' => 'ZTO', 'yt' => 'YTO',
|
||||
'sto' => 'STO', 'jd' => 'JD', 'ems' => 'EMS', 'db' => 'DBL',
|
||||
'yunda' => 'YD',
|
||||
];
|
||||
|
||||
if ($sdk === 'kuaidi100') {
|
||||
return $kuaidi100_map[$carrier] ?? $carrier;
|
||||
}
|
||||
if ($sdk === 'kdniao') {
|
||||
return $kdniao_map[$carrier] ?? strtoupper($carrier);
|
||||
}
|
||||
return $carrier;
|
||||
}
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Payment;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
trait ShippingPart2 {
|
||||
|
||||
|
||||
public function build_express_timeline(int $shipping_id): array {
|
||||
$record = $this->get($shipping_id);
|
||||
if (!$record) return [];
|
||||
|
||||
$cached = $this->get_express_cache($shipping_id);
|
||||
$timeline = [];
|
||||
|
||||
$timeline[] = [
|
||||
'time' => (string) ($record['created_at'] ?? ''),
|
||||
'label' => sprintf(__('商家已发货:%s %s', 'aksrt'), $record['carrier_label'] ?? '', $record['tracking_no'] ?? ''),
|
||||
'type' => 'shipped',
|
||||
];
|
||||
|
||||
if (!empty($cached['traces'])) {
|
||||
foreach ($cached['traces'] as $trace) {
|
||||
$timeline[] = [
|
||||
'time' => (string) ($trace['time'] ?? ''),
|
||||
'label' => (string) ($trace['context'] ?? ''),
|
||||
'type' => 'trace',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($cached['signed'])) {
|
||||
$timeline[] = [
|
||||
'time' => (string) ($record['delivered_at'] ?? ($cached['query_time'] ?? '')),
|
||||
'label' => __('快递已签收', 'aksrt'),
|
||||
'type' => 'signed',
|
||||
];
|
||||
}
|
||||
|
||||
usort($timeline, static fn($a, $b) => strcmp($a['time'] ?? '', $b['time'] ?? ''));
|
||||
return $timeline;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* Shipping Fee Resolution (§6.6 运费模板)
|
||||
*
|
||||
* 3 types: free / fixed / amount (满额包邮)
|
||||
* Inheritance: product → category → theme defaults
|
||||
* ============================================================ */
|
||||
|
||||
public function resolve_shipping_fee(int $product_id, float $order_amount = 0, array $address = []): array {
|
||||
$config = $this->get_product_shipping_config($product_id);
|
||||
$fee_type = (string) ($config['fee_type'] ?? 'fixed');
|
||||
$fee_amount = (float) ($config['fee_amount'] ?? 0);
|
||||
$free_threshold = (float) ($config['free_threshold'] ?? 0);
|
||||
|
||||
if ($fee_type === 'free') {
|
||||
return ['fee' => 0.0, 'type' => 'free', 'label' => __('包邮', 'aksrt')];
|
||||
}
|
||||
|
||||
if ($fee_type === 'amount' && $free_threshold > 0 && $order_amount >= $free_threshold) {
|
||||
return ['fee' => 0.0, 'type' => 'free_threshold', 'label' => sprintf(__('满%.0f包邮', 'aksrt'), $free_threshold)];
|
||||
}
|
||||
|
||||
$region_fee = $this->resolve_region_fee($config, $address);
|
||||
if ($region_fee !== null) {
|
||||
return ['fee' => $region_fee, 'type' => 'region', 'label' => __('地区运费', 'aksrt')];
|
||||
}
|
||||
|
||||
return ['fee' => $fee_amount, 'type' => 'fixed', 'label' => sprintf(__('运费 ¥%.2f', 'aksrt'), $fee_amount)];
|
||||
}
|
||||
|
||||
private function get_product_shipping_config(int $product_id): array {
|
||||
$meta = get_post_meta($product_id, '_aksrt_shipping_config', true);
|
||||
if (is_array($meta) && !empty($meta['fee_type'])) {
|
||||
return $meta;
|
||||
}
|
||||
|
||||
if (class_exists('\AKSRT\Shop\TermMeta')) {
|
||||
$cat_config = \AKSRT\Shop\TermMeta::resolve_inheritance($product_id, 'shipping_fee');
|
||||
if (is_array($cat_config) && !empty($cat_config['fee_type'])) {
|
||||
return $cat_config;
|
||||
}
|
||||
}
|
||||
|
||||
if (class_exists('\AKSRT\Settings\Settings')) {
|
||||
$shop = \AKSRT\Settings\Settings::get_shop_settings();
|
||||
return [
|
||||
'fee_type' => sanitize_key((string) ($shop['defaults']['shipping_fee_type'] ?? 'fixed')),
|
||||
'fee_amount' => (float) ($shop['defaults']['shipping_fee_amount'] ?? 12),
|
||||
'free_threshold' => (float) ($shop['defaults']['shipping_free_threshold'] ?? 0),
|
||||
'region_fees' => (array) ($shop['defaults']['region_fees'] ?? []),
|
||||
];
|
||||
}
|
||||
|
||||
return ['fee_type' => 'fixed', 'fee_amount' => 12, 'free_threshold' => 0, 'region_fees' => []];
|
||||
}
|
||||
|
||||
private function resolve_region_fee(array $config, array $address): ?float {
|
||||
$region_fees = (array) ($config['region_fees'] ?? []);
|
||||
if (empty($region_fees) || empty($address['province'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$province = (string) $address['province'];
|
||||
foreach ($region_fees as $region) {
|
||||
$provinces = (array) ($region['provinces'] ?? []);
|
||||
if (in_array($province, $provinces, true)) {
|
||||
return (float) ($region['fee'] ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function get_china_regions(): array {
|
||||
$file = get_template_directory() . '/assets/data/china-regions.json';
|
||||
if (!file_exists($file)) {
|
||||
return self::get_builtin_provinces();
|
||||
}
|
||||
$data = json_decode((string) file_get_contents($file), true);
|
||||
return is_array($data) ? $data : self::get_builtin_provinces();
|
||||
}
|
||||
|
||||
private static function get_builtin_provinces(): array {
|
||||
return [
|
||||
['code' => '11', 'name' => '北京市'], ['code' => '12', 'name' => '天津市'],
|
||||
['code' => '13', 'name' => '河北省'], ['code' => '14', 'name' => '山西省'],
|
||||
['code' => '15', 'name' => '内蒙古自治区'], ['code' => '21', 'name' => '辽宁省'],
|
||||
['code' => '22', 'name' => '吉林省'], ['code' => '23', 'name' => '黑龙江省'],
|
||||
['code' => '31', 'name' => '上海市'], ['code' => '32', 'name' => '江苏省'],
|
||||
['code' => '33', 'name' => '浙江省'], ['code' => '34', 'name' => '安徽省'],
|
||||
['code' => '35', 'name' => '福建省'], ['code' => '36', 'name' => '江西省'],
|
||||
['code' => '37', 'name' => '山东省'], ['code' => '41', 'name' => '河南省'],
|
||||
['code' => '42', 'name' => '湖北省'], ['code' => '43', 'name' => '湖南省'],
|
||||
['code' => '44', 'name' => '广东省'], ['code' => '45', 'name' => '广西壮族自治区'],
|
||||
['code' => '46', 'name' => '海南省'], ['code' => '50', 'name' => '重庆市'],
|
||||
['code' => '51', 'name' => '四川省'], ['code' => '52', 'name' => '贵州省'],
|
||||
['code' => '53', 'name' => '云南省'], ['code' => '54', 'name' => '西藏自治区'],
|
||||
['code' => '61', 'name' => '陕西省'], ['code' => '62', 'name' => '甘肃省'],
|
||||
['code' => '63', 'name' => '青海省'], ['code' => '64', 'name' => '宁夏回族自治区'],
|
||||
['code' => '65', 'name' => '新疆维吾尔自治区'],
|
||||
['code' => '71', 'name' => '台湾省'],
|
||||
['code' => '81', 'name' => '香港特别行政区'],
|
||||
['code' => '82', 'name' => '澳门特别行政区'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -52,9 +52,7 @@ class UserOrders {
|
||||
if ($status !== '') {
|
||||
$repo_args['status'] = $status;
|
||||
}
|
||||
if (in_array($status, [Order::STATUS_PAID, Order::STATUS_DELIVERED, Order::STATUS_PENDING_REVIEW], true)) {
|
||||
$repo_args['product_type'] = \AKSRT\Core\DomainModel::ORDER_PRODUCT_SHOP;
|
||||
} elseif ($product_type !== '' && $product_type !== 'all') {
|
||||
if ($product_type !== '' && $product_type !== 'all') {
|
||||
$repo_args['product_type'] = $product_type;
|
||||
}
|
||||
|
||||
@@ -124,8 +122,6 @@ class UserOrders {
|
||||
$product_type = sanitize_key((string) ($order['product_type'] ?? ''));
|
||||
$status = (string) ($order['status'] ?? Order::STATUS_PENDING);
|
||||
$product_id = (int) ($order['product_id'] ?? 0);
|
||||
$is_shop = $product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_SHOP
|
||||
|| $product_type === 'shop';
|
||||
$currency = sanitize_key((string) ($order['currency'] ?? 'CNY'));
|
||||
$is_points = $currency === 'points'
|
||||
|| sanitize_key((string) ($order['payment_method'] ?? '')) === 'points'
|
||||
@@ -139,17 +135,11 @@ class UserOrders {
|
||||
$permalink = $this->resolve_order_permalink($product_type, $product_id, $meta);
|
||||
|
||||
$order_items = [];
|
||||
if ($is_shop && class_exists('\AKSRT\Shop\Shop')) {
|
||||
$order_items = \AKSRT\Shop\Shop::get_instance()->get_order_items($order_number);
|
||||
}
|
||||
$has_structured_items = !empty($order_items);
|
||||
|
||||
$product = [];
|
||||
if ($has_structured_items && class_exists('\AKSRT\Shop\Shop')) {
|
||||
$product = \AKSRT\Shop\Shop::get_instance()->build_product_card_view((int) ($order_items[0]['product_id'] ?? 0));
|
||||
} elseif ($is_shop && $product_id > 0 && class_exists('\AKSRT\Shop\Shop')) {
|
||||
$product = \AKSRT\Shop\Shop::get_instance()->build_product_card_view($product_id);
|
||||
}
|
||||
// Shop product cards were removed with the shop feature; content/VIP
|
||||
// orders fall through to the shared meta-based fields below.
|
||||
|
||||
if (!empty($product['title'])) {
|
||||
$title = (string) $product['title'];
|
||||
@@ -163,20 +153,6 @@ class UserOrders {
|
||||
|
||||
$shipping_records = [];
|
||||
$after_sale_items = [];
|
||||
if ($is_shop) {
|
||||
if (class_exists(Shipping::class)) {
|
||||
$shipping_records = Shipping::get_instance()->get_by_order($order_number);
|
||||
}
|
||||
if (class_exists(AfterSale::class)) {
|
||||
$as = AfterSale::get_instance()->get_list([
|
||||
'limit' => 20,
|
||||
'offset' => 0,
|
||||
'order_number' => $order_number,
|
||||
]);
|
||||
$after_sale_items = $as['items'] ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
$subtotal = (float) ($meta['items'][0]['line_total'] ?? $order['amount'] ?? 0);
|
||||
$shipping_fee = (float) ($meta['shipping_fee'] ?? 0);
|
||||
$discount = (float) ($meta['discount'] ?? 0);
|
||||
@@ -186,10 +162,10 @@ class UserOrders {
|
||||
$can_pay = in_array($status, [Order::STATUS_PENDING, Order::STATUS_EXPIRED, Order::STATUS_CREATED], true)
|
||||
&& $payment_value !== 'manual_review';
|
||||
$can_cancel = in_array($status, [Order::STATUS_PENDING, Order::STATUS_CREATED, Order::STATUS_EXPIRED], true);
|
||||
$can_after_sale = $is_shop && in_array($status, [Order::STATUS_PAID, Order::STATUS_DELIVERED, Order::STATUS_COMPLETED], true);
|
||||
$can_receipt = $is_shop && $status === Order::STATUS_DELIVERED;
|
||||
$can_review = $is_shop && $status === Order::STATUS_PENDING_REVIEW;
|
||||
$can_rebuy = $is_shop && in_array($status, [Order::STATUS_COMPLETED, Order::STATUS_PENDING_REVIEW], true);
|
||||
$can_after_sale = false;
|
||||
$can_receipt = false;
|
||||
$can_review = false;
|
||||
$can_rebuy = false;
|
||||
// Order detail modal is always available; product permalink is optional.
|
||||
$can_view = true;
|
||||
$can_view_product = $permalink !== '' && in_array($status, [
|
||||
@@ -306,11 +282,7 @@ class UserOrders {
|
||||
return ['success' => false, 'message' => __('订单不存在。', 'aksrt')];
|
||||
}
|
||||
|
||||
// Prefer shop checkout when available (handles points + shop meta).
|
||||
if (class_exists('\AKSRT\Shop\Shop')) {
|
||||
return \AKSRT\Shop\Shop::get_instance()->pay_order($user_id, $order_number, $payment_method);
|
||||
}
|
||||
|
||||
// Resume via shared gateway stack (any product_type; shop removed).
|
||||
$repo = OrderRepository::get_instance();
|
||||
$order = $repo->get_user_order($user_id, $order_number);
|
||||
if (!$order) {
|
||||
|
||||
@@ -27,15 +27,6 @@ trait DomainGroups {
|
||||
Integration::update_wechat_settings($settings);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取商城设置
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public static function get_shop_settings(): array {
|
||||
return ForumShop::get_shop_settings();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文档模式设置
|
||||
*/
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Settings;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class ForumShop {
|
||||
|
||||
/**
|
||||
* 获取商城设置
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public static function get_shop_settings(): array {
|
||||
$s = Settings::get_all();
|
||||
|
||||
// -- basic / general --
|
||||
$enabled = !empty($s['shop_s']);
|
||||
$home_url = esc_url_raw((string) ($s['shop_home_url'] ?? ''));
|
||||
|
||||
// breadcrumbs
|
||||
$breadcrumbs_s = !isset($s['shop_breadcrumbs_s']) || !empty($s['shop_breadcrumbs_s']);
|
||||
$breadcrumbs_home = !isset($s['shop_breadcrumbs_home']) || !empty($s['shop_breadcrumbs_home']);
|
||||
$breadcrumbs_shop_home = !isset($s['shop_breadcrumbs_shop_home']) || !empty($s['shop_breadcrumbs_shop_home']);
|
||||
$breadcrumbs_shop_home_name = sanitize_text_field((string) ($s['shop_breadcrumbs_shop_home_name'] ?? '商城首页'));
|
||||
|
||||
// rewrite
|
||||
$product_rewrite_slug = sanitize_key((string) ($s['shop_product_rewrite_slug'] ?? 'product'));
|
||||
$cart_rewrite_slug = sanitize_key((string) ($s['shop_cart_rewrite_slug'] ?? 'cart'));
|
||||
$rewrite_suffix_html = !empty($s['shop_rewrite_suffix_html_s']);
|
||||
|
||||
// -- list --
|
||||
$list_orderby = sanitize_key((string) ($s['shop_list_orderby'] ?? 'date'));
|
||||
if (!in_array($list_orderby, ['date', 'sales', 'price_asc', 'price_desc', 'comments'], true)) {
|
||||
$list_orderby = 'date';
|
||||
}
|
||||
$list_count = max(4, min(100, (int) ($s['shop_list_count'] ?? 12)));
|
||||
$list_paging = sanitize_key((string) ($s['shop_list_paging'] ?? 'number'));
|
||||
if (!in_array($list_paging, ['number', 'ajax'], true)) {
|
||||
$list_paging = 'number';
|
||||
}
|
||||
$list_ias_s = !empty($s['shop_list_ias_s']);
|
||||
$list_ias_max = max(1, min(50, (int) ($s['shop_list_ias_max'] ?? 5)));
|
||||
$list_style = sanitize_key((string) ($s['shop_list_style'] ?? 'default'));
|
||||
if (!in_array($list_style, ['default', 'card-lg', 'card-sm'], true)) {
|
||||
$list_style = 'default';
|
||||
}
|
||||
$sales_show = sanitize_key((string) ($s['shop_sales_show'] ?? 'show'));
|
||||
if (!in_array($sales_show, ['show', 'hide', 'threshold'], true)) {
|
||||
$sales_show = 'show';
|
||||
}
|
||||
$sales_show_min = max(0, min(99999, (int) ($s['shop_sales_show_min'] ?? 10)));
|
||||
|
||||
// -- contact --
|
||||
$contact_msg_s = !isset($s['shop_contact_msg_s']) || !empty($s['shop_contact_msg_s']);
|
||||
$contact_name = sanitize_text_field((string) ($s['shop_contact_name'] ?? '联系客服'));
|
||||
$contact_note = sanitize_textarea_field((string) ($s['shop_contact_note'] ?? ''));
|
||||
$contact_more = sanitize_textarea_field((string) ($s['shop_contact_more'] ?? ''));
|
||||
|
||||
// -- single --
|
||||
$content_layout = sanitize_key((string) ($s['shop_content_layout'] ?? 'adapt'));
|
||||
if (!in_array($content_layout, ['adapt', 'full', 'sidebar'], true)) {
|
||||
$content_layout = 'adapt';
|
||||
}
|
||||
$content_show_bg = !empty($s['shop_content_show_bg']);
|
||||
$single_tab = sanitize_textarea_field((string) ($s['shop_single_tab'] ?? ''));
|
||||
$content_after = wp_kses_post((string) ($s['shop_content_after'] ?? ''));
|
||||
$related_s = !isset($s['shop_single_related_s']) || !empty($s['shop_single_related_s']);
|
||||
$related_opt = sanitize_textarea_field((string) ($s['shop_single_related_opt'] ?? ''));
|
||||
$main_image_default = esc_url_raw((string) ($s['shop_main_image_default'] ?? ''));
|
||||
|
||||
// -- product defaults --
|
||||
$limit_buy = max(-1, min(9999, (int) ($s['shop_limit_buy'] ?? -1)));
|
||||
$guest_buy = !empty($s['shop_guest_buy']);
|
||||
$email_fill = !empty($s['shop_email_fill']);
|
||||
$rebate = !isset($s['shop_rebate']) || !empty($s['shop_rebate']);
|
||||
$shipping_fee = sanitize_key((string) ($s['shop_shipping_fee'] ?? 'free'));
|
||||
if (!in_array($shipping_fee, ['free', 'fixed', 'amount'], true)) {
|
||||
$shipping_fee = 'free';
|
||||
}
|
||||
$after_sale = sanitize_key((string) ($s['shop_after_sale'] ?? 'refund_return'));
|
||||
if (!in_array($after_sale, ['refund', 'refund_return', 'none'], true)) {
|
||||
$after_sale = 'refund_return';
|
||||
}
|
||||
$service = sanitize_textarea_field((string) ($s['shop_service'] ?? ''));
|
||||
$params_default = sanitize_textarea_field((string) ($s['shop_product_params_default'] ?? ''));
|
||||
|
||||
// -- shipping config --
|
||||
$manual_address = !empty($s['shop_manual_address']);
|
||||
$express_api_sdk = sanitize_key((string) ($s['shop_express_api_sdk'] ?? 'kuaidi100'));
|
||||
if (!in_array($express_api_sdk, ['kuaidi100', 'aliyun', 'kdniao'], true)) {
|
||||
$express_api_sdk = 'kuaidi100';
|
||||
}
|
||||
$express_query_interval = max(30, min(240, (int) ($s['shop_express_query_interval'] ?? 60)));
|
||||
$express_kdniao_opt = sanitize_textarea_field((string) ($s['shop_express_kdniao_opt'] ?? ''));
|
||||
$express_kuaidi100_opt = sanitize_textarea_field((string) ($s['shop_express_kuaidi100_opt'] ?? ''));
|
||||
$express_aliyun_opt = sanitize_textarea_field((string) ($s['shop_express_aliyun_opt'] ?? ''));
|
||||
$shipping_company = sanitize_textarea_field((string) ($s['shop_shipping_company'] ?? ''));
|
||||
|
||||
// -- timing --
|
||||
$receipt_max_day = max(1, min(60, (int) ($s['shop_order_receipt_max_day'] ?? 15)));
|
||||
$comment_max_day = max(1, min(60, (int) ($s['shop_comment_max_day'] ?? 7)));
|
||||
$comment_placeholder = sanitize_text_field((string) ($s['shop_comment_placeholder'] ?? ''));
|
||||
$comment_img_num = max(0, min(9, (int) ($s['shop_comment_img_num'] ?? 3)));
|
||||
$after_sale_return_max_day = max(1, min(30, (int) ($s['shop_after_sale_return_express_max_day'] ?? 7)));
|
||||
$after_sale_reason_refund = sanitize_textarea_field((string) ($s['shop_after_sale_reason_refund'] ?? ''));
|
||||
$after_sale_reason_return = sanitize_textarea_field((string) ($s['shop_after_sale_reason_return'] ?? ''));
|
||||
$author_show = !isset($s['shop_author_show']) || !empty($s['shop_author_show']);
|
||||
|
||||
return [
|
||||
'enabled' => $enabled,
|
||||
'home_url' => $home_url,
|
||||
'breadcrumbs' => [
|
||||
'enabled' => $breadcrumbs_s,
|
||||
'home' => $breadcrumbs_home,
|
||||
'shop_home' => $breadcrumbs_shop_home,
|
||||
'shop_home_name' => $breadcrumbs_shop_home_name,
|
||||
],
|
||||
'rewrite' => [
|
||||
'product_slug' => $product_rewrite_slug,
|
||||
'cart_slug' => $cart_rewrite_slug,
|
||||
'suffix_html' => $rewrite_suffix_html,
|
||||
],
|
||||
'list' => [
|
||||
'orderby' => $list_orderby,
|
||||
'count' => $list_count,
|
||||
'paging' => $list_paging,
|
||||
'ias_s' => $list_ias_s,
|
||||
'ias_max' => $list_ias_max,
|
||||
'style' => $list_style,
|
||||
],
|
||||
'sales' => [
|
||||
'show' => $sales_show,
|
||||
'show_min' => $sales_show_min,
|
||||
],
|
||||
'contact' => [
|
||||
'msg_s' => $contact_msg_s,
|
||||
'name' => $contact_name,
|
||||
'note' => $contact_note,
|
||||
'more' => $contact_more,
|
||||
],
|
||||
'single' => [
|
||||
'content_layout' => $content_layout,
|
||||
'content_show_bg' => $content_show_bg,
|
||||
'tab' => $single_tab,
|
||||
'content_after' => $content_after,
|
||||
'related_s' => $related_s,
|
||||
'related_opt' => $related_opt,
|
||||
'main_image_default' => $main_image_default,
|
||||
],
|
||||
'product_defaults' => [
|
||||
'limit_buy' => $limit_buy,
|
||||
'guest_buy' => $guest_buy,
|
||||
'email_fill' => $email_fill,
|
||||
'rebate' => $rebate,
|
||||
'shipping_fee' => $shipping_fee,
|
||||
'after_sale' => $after_sale,
|
||||
'service' => $service,
|
||||
'params_default' => $params_default,
|
||||
],
|
||||
'shipping' => [
|
||||
'manual_address' => $manual_address,
|
||||
'express_api_sdk' => $express_api_sdk,
|
||||
'express_query_interval' => $express_query_interval,
|
||||
'express_kdniao_opt' => $express_kdniao_opt,
|
||||
'express_kuaidi100_opt' => $express_kuaidi100_opt,
|
||||
'express_aliyun_opt' => $express_aliyun_opt,
|
||||
'shipping_company' => $shipping_company,
|
||||
],
|
||||
'timing' => [
|
||||
'receipt_max_day' => $receipt_max_day,
|
||||
'comment_max_day' => $comment_max_day,
|
||||
'comment_placeholder' => $comment_placeholder,
|
||||
'comment_img_num' => $comment_img_num,
|
||||
'after_sale_return_max_day' => $after_sale_return_max_day,
|
||||
'after_sale_reason_refund' => $after_sale_reason_refund,
|
||||
'after_sale_reason_return' => $after_sale_reason_return,
|
||||
],
|
||||
'author_show' => $author_show,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Shop;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class AddressService {
|
||||
|
||||
private Shop $shop;
|
||||
|
||||
public function __construct(Shop $shop) {
|
||||
$this->shop = $shop;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户地址簿(公开接口)
|
||||
*/
|
||||
public function get_address_book(int $user_id): array {
|
||||
return $this->get_user_address_book($user_id);
|
||||
}
|
||||
|
||||
public function get_user_address_book(int $user_id): array {
|
||||
$saved = get_user_meta($user_id, 'aksrt_shop_addresses', true);
|
||||
if (is_array($saved) && !empty($saved)) {
|
||||
return array_values(array_filter(array_map([$this, 'sanitize_address'], $saved)));
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private function sanitize_address($address): array {
|
||||
if (!is_array($address)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$name = sanitize_text_field((string) ($address['name'] ?? ''));
|
||||
$phone = sanitize_text_field((string) ($address['phone'] ?? ''));
|
||||
$province = sanitize_text_field((string) ($address['province'] ?? ''));
|
||||
$city = sanitize_text_field((string) ($address['city'] ?? ''));
|
||||
$district = sanitize_text_field((string) ($address['district'] ?? ''));
|
||||
$street = sanitize_text_field((string) ($address['address'] ?? ''));
|
||||
|
||||
return [
|
||||
'id' => (int) ($address['id'] ?? 0),
|
||||
'name' => $name,
|
||||
'phone' => $phone,
|
||||
'province' => $province,
|
||||
'city' => $city,
|
||||
'district' => $district,
|
||||
'address' => $street,
|
||||
'is_default' => !empty($address['is_default']),
|
||||
'full_text' => trim($name . ' / ' . $province . $city . $district . $street),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增地址
|
||||
*
|
||||
* @param int $user_id
|
||||
* @param array $data 地址数据(name, phone, province, city, district, address, is_default)
|
||||
* @return array{success:bool, address:array|null, error:string|null}
|
||||
*/
|
||||
public function add_address(int $user_id, array $data): array {
|
||||
$address = $this->sanitize_address($data);
|
||||
if ($address['name'] === '' || $address['phone'] === '' || $address['address'] === '') {
|
||||
return ['success' => false, 'address' => null, 'error' => __('姓名、手机号和详细地址为必填项。', 'aksrt')];
|
||||
}
|
||||
|
||||
$book = $this->get_user_address_book($user_id);
|
||||
$address['id'] = $this->next_address_id($book);
|
||||
|
||||
if (!empty($data['is_default']) || empty($book)) {
|
||||
foreach ($book as &$item) {
|
||||
$item['is_default'] = false;
|
||||
}
|
||||
$address['is_default'] = true;
|
||||
}
|
||||
|
||||
$book[] = $address;
|
||||
update_user_meta($user_id, 'aksrt_shop_addresses', $book);
|
||||
|
||||
return ['success' => true, 'address' => $address, 'error' => null];
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新地址
|
||||
*
|
||||
* @param int $user_id
|
||||
* @param int $address_id
|
||||
* @param array $data
|
||||
* @return array{success:bool, address:array|null, error:string|null}
|
||||
*/
|
||||
public function update_address(int $user_id, int $address_id, array $data): array {
|
||||
$book = $this->get_user_address_book($user_id);
|
||||
$found = false;
|
||||
|
||||
foreach ($book as &$item) {
|
||||
if ((int) ($item['id'] ?? 0) !== $address_id) {
|
||||
continue;
|
||||
}
|
||||
$found = true;
|
||||
$merged = array_merge($item, $this->sanitize_address(array_merge($item, $data)));
|
||||
$merged['id'] = $address_id;
|
||||
|
||||
if ($merged['name'] === '' || $merged['phone'] === '' || $merged['address'] === '') {
|
||||
return ['success' => false, 'address' => null, 'error' => __('姓名、手机号和详细地址为必填项。', 'aksrt')];
|
||||
}
|
||||
|
||||
if (!empty($data['is_default'])) {
|
||||
foreach ($book as &$clear) {
|
||||
$clear['is_default'] = false;
|
||||
}
|
||||
$merged['is_default'] = true;
|
||||
}
|
||||
|
||||
$item = $merged;
|
||||
break;
|
||||
}
|
||||
|
||||
if (!$found) {
|
||||
return ['success' => false, 'address' => null, 'error' => __('地址不存在。', 'aksrt')];
|
||||
}
|
||||
|
||||
update_user_meta($user_id, 'aksrt_shop_addresses', $book);
|
||||
|
||||
return ['success' => true, 'address' => $merged, 'error' => null];
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除地址
|
||||
*
|
||||
* @param int $user_id
|
||||
* @param int $address_id
|
||||
* @return array{success:bool, error:string|null}
|
||||
*/
|
||||
public function delete_address(int $user_id, int $address_id): array {
|
||||
$book = $this->get_user_address_book($user_id);
|
||||
$found = false;
|
||||
$was_default = false;
|
||||
$filtered = [];
|
||||
|
||||
foreach ($book as $item) {
|
||||
if ((int) ($item['id'] ?? 0) === $address_id) {
|
||||
$found = true;
|
||||
$was_default = !empty($item['is_default']);
|
||||
continue;
|
||||
}
|
||||
$filtered[] = $item;
|
||||
}
|
||||
|
||||
if (!$found) {
|
||||
return ['success' => false, 'error' => __('地址不存在。', 'aksrt')];
|
||||
}
|
||||
|
||||
if ($was_default && !empty($filtered)) {
|
||||
$filtered[0]['is_default'] = true;
|
||||
}
|
||||
|
||||
update_user_meta($user_id, 'aksrt_shop_addresses', array_values($filtered));
|
||||
|
||||
return ['success' => true, 'error' => null];
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置默认地址
|
||||
*
|
||||
* @param int $user_id
|
||||
* @param int $address_id
|
||||
* @return array{success:bool, error:string|null}
|
||||
*/
|
||||
public function set_default_address(int $user_id, int $address_id): array {
|
||||
$book = $this->get_user_address_book($user_id);
|
||||
$found = false;
|
||||
|
||||
foreach ($book as &$item) {
|
||||
$item['is_default'] = ((int) ($item['id'] ?? 0) === $address_id);
|
||||
if ($item['is_default']) {
|
||||
$found = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$found) {
|
||||
return ['success' => false, 'error' => __('地址不存在。', 'aksrt')];
|
||||
}
|
||||
|
||||
update_user_meta($user_id, 'aksrt_shop_addresses', $book);
|
||||
|
||||
return ['success' => true, 'error' => null];
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成下一个地址 ID
|
||||
*/
|
||||
private function next_address_id(array $book): int {
|
||||
$max = 0;
|
||||
foreach ($book as $item) {
|
||||
$max = max($max, (int) ($item['id'] ?? 0));
|
||||
}
|
||||
return $max + 1;
|
||||
}
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Shop;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class CartService {
|
||||
|
||||
private Shop $shop;
|
||||
|
||||
public function __construct(Shop $shop) {
|
||||
$this->shop = $shop;
|
||||
}
|
||||
|
||||
public function get_cart(int $user_id): array {
|
||||
if ($user_id <= 0) {
|
||||
return [];
|
||||
}
|
||||
$cart = get_user_meta($user_id, 'shop_cart', true);
|
||||
return is_array($cart) ? $cart : [];
|
||||
}
|
||||
|
||||
public function get_cart_count(int $user_id): int {
|
||||
$cart = $this->get_cart($user_id);
|
||||
$count = 0;
|
||||
foreach ($cart as $opts) {
|
||||
if (is_array($opts)) {
|
||||
foreach ($opts as $qty) {
|
||||
$count += (int) $qty;
|
||||
}
|
||||
}
|
||||
}
|
||||
return $count;
|
||||
}
|
||||
|
||||
public function add_to_cart(int $user_id, int $product_id, string $options_string = '', int $count = 1): array {
|
||||
if ($user_id <= 0 || $product_id <= 0 || $count <= 0) {
|
||||
return ['error' => 'invalid_params'];
|
||||
}
|
||||
$validation = $this->validate_cart_item($product_id, $options_string);
|
||||
if ($validation !== true) {
|
||||
return ['error' => $validation];
|
||||
}
|
||||
|
||||
$cart = $this->get_cart($user_id);
|
||||
$pid_key = (string) $product_id;
|
||||
$opt_key = $options_string !== '' ? $options_string : '_default';
|
||||
|
||||
if (!isset($cart[$pid_key]) || !is_array($cart[$pid_key])) {
|
||||
$cart[$pid_key] = [];
|
||||
}
|
||||
$cart[$pid_key][$opt_key] = ((int) ($cart[$pid_key][$opt_key] ?? 0)) + $count;
|
||||
|
||||
// Enforce limit_buy if set
|
||||
$limit = (int) get_post_meta($product_id, '_aksrt_limit_buy', true);
|
||||
if ($limit > 0 && $cart[$pid_key][$opt_key] > $limit) {
|
||||
$cart[$pid_key][$opt_key] = $limit;
|
||||
}
|
||||
|
||||
update_user_meta($user_id, 'shop_cart', $cart);
|
||||
return ['success' => true, 'cart_count' => $this->get_cart_count($user_id)];
|
||||
}
|
||||
|
||||
public function reduce_from_cart(int $user_id, int $product_id, string $options_string = '', int $count = 1): array {
|
||||
if ($user_id <= 0 || $product_id <= 0 || $count <= 0) {
|
||||
return ['error' => 'invalid_params'];
|
||||
}
|
||||
$cart = $this->get_cart($user_id);
|
||||
$pid_key = (string) $product_id;
|
||||
$opt_key = $options_string !== '' ? $options_string : '_default';
|
||||
|
||||
if (isset($cart[$pid_key][$opt_key])) {
|
||||
$cart[$pid_key][$opt_key] -= $count;
|
||||
if ($cart[$pid_key][$opt_key] <= 0) {
|
||||
unset($cart[$pid_key][$opt_key]);
|
||||
}
|
||||
}
|
||||
if (empty($cart[$pid_key])) {
|
||||
unset($cart[$pid_key]);
|
||||
}
|
||||
|
||||
update_user_meta($user_id, 'shop_cart', $cart);
|
||||
return ['success' => true, 'cart_count' => $this->get_cart_count($user_id)];
|
||||
}
|
||||
|
||||
public function remove_from_cart(int $user_id, int $product_id, string $options_string = ''): array {
|
||||
if ($user_id <= 0) {
|
||||
return ['error' => 'invalid_params'];
|
||||
}
|
||||
$cart = $this->get_cart($user_id);
|
||||
$pid_key = (string) $product_id;
|
||||
$opt_key = $options_string !== '' ? $options_string : '_default';
|
||||
|
||||
if ($options_string === '' && isset($cart[$pid_key])) {
|
||||
// Remove all options for this product
|
||||
unset($cart[$pid_key]);
|
||||
} elseif (isset($cart[$pid_key][$opt_key])) {
|
||||
unset($cart[$pid_key][$opt_key]);
|
||||
if (empty($cart[$pid_key])) {
|
||||
unset($cart[$pid_key]);
|
||||
}
|
||||
}
|
||||
|
||||
update_user_meta($user_id, 'shop_cart', $cart);
|
||||
return ['success' => true, 'cart_count' => $this->get_cart_count($user_id)];
|
||||
}
|
||||
|
||||
public function remove_multi_from_cart(int $user_id, array $items): array {
|
||||
if ($user_id <= 0) {
|
||||
return ['error' => 'invalid_params'];
|
||||
}
|
||||
$cart = $this->get_cart($user_id);
|
||||
foreach ($items as $item) {
|
||||
$pid_key = (string) ($item['product_id'] ?? 0);
|
||||
$opt_key = !empty($item['options_string']) ? $item['options_string'] : '_default';
|
||||
if (isset($cart[$pid_key][$opt_key])) {
|
||||
unset($cart[$pid_key][$opt_key]);
|
||||
if (empty($cart[$pid_key])) {
|
||||
unset($cart[$pid_key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
update_user_meta($user_id, 'shop_cart', $cart);
|
||||
return ['success' => true, 'cart_count' => $this->get_cart_count($user_id)];
|
||||
}
|
||||
|
||||
public function empty_cart(int $user_id): array {
|
||||
if ($user_id <= 0) {
|
||||
return ['error' => 'invalid_params'];
|
||||
}
|
||||
delete_user_meta($user_id, 'shop_cart');
|
||||
return ['success' => true, 'cart_count' => 0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a product for cart addition.
|
||||
* Returns true if valid, or a string error code.
|
||||
*/
|
||||
public function validate_cart_item(int $product_id, string $options_string = '') {
|
||||
$post = get_post($product_id);
|
||||
if (!$post || $post->post_type !== 'aksrt_product') {
|
||||
return 'invalid_product';
|
||||
}
|
||||
if ($post->post_status !== 'publish') {
|
||||
return 'product_not_published';
|
||||
}
|
||||
|
||||
// Check stock (unless unlimited)
|
||||
$stock = (int) get_post_meta($product_id, '_aksrt_stock', true);
|
||||
if ($stock > -1 && $stock <= 0) {
|
||||
return 'out_of_stock';
|
||||
}
|
||||
|
||||
// Check guest_buy — if disabled and user not logged in
|
||||
if (!is_user_logged_in()) {
|
||||
$guest = get_post_meta($product_id, '_aksrt_guest_buy', true);
|
||||
if ($guest === '0' || $guest === '') {
|
||||
// Fall back to inheritance chain via TermMeta
|
||||
if (class_exists('AKSRT\Shop\TermMeta')) {
|
||||
$resolved = TermMeta::resolve_inheritance($product_id, 'guest_buy');
|
||||
if (empty($resolved)) {
|
||||
return 'login_required';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,414 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Shop;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class CheckoutService {
|
||||
|
||||
private Shop $shop;
|
||||
|
||||
public function __construct(Shop $shop) {
|
||||
$this->shop = $shop;
|
||||
}
|
||||
|
||||
public function preview_checkout(int $user_id, array $payload): array {
|
||||
if ($user_id <= 0) {
|
||||
return ['success' => false, 'message' => __('请先登录后再结算。', 'aksrt')];
|
||||
}
|
||||
|
||||
$items = $this->normalize_checkout_items($payload['items'] ?? []);
|
||||
if (empty($items)) {
|
||||
return ['success' => false, 'message' => __('请选择至少一个商品。', 'aksrt')];
|
||||
}
|
||||
|
||||
$product_views = [];
|
||||
$subtotal = 0.0;
|
||||
foreach ($items as $item) {
|
||||
$product = $this->shop->get_product_detail_view((int) $item['product_id']);
|
||||
if (empty($product) || empty($product['purchase_enabled'])) {
|
||||
return ['success' => false, 'message' => __('存在不可购买商品,请刷新后重试。', 'aksrt')];
|
||||
}
|
||||
|
||||
if ((int) $product['stock'] < (int) $item['quantity']) {
|
||||
return ['success' => false, 'message' => sprintf(__('商品 %s 库存不足。', 'aksrt'), $product['title'])];
|
||||
}
|
||||
|
||||
$line_total = round(((float) $product['price']) * (int) $item['quantity'], 2);
|
||||
|
||||
// R69: Apply discount engine (shop_discount taxonomy campaigns)
|
||||
$discount_result = ['discount_amount' => 0, 'gifts' => [], 'applied' => []];
|
||||
if (class_exists('\AKSRT\Payment\Discount')) {
|
||||
$discount_result = \AKSRT\Payment\Discount::get_instance()->calculate(
|
||||
(float) $product['price'],
|
||||
(int) $item['quantity'],
|
||||
(int) $product['id'],
|
||||
$user_id
|
||||
);
|
||||
$line_total = $discount_result['final_price'];
|
||||
}
|
||||
|
||||
$subtotal += $line_total;
|
||||
$product_views[] = [
|
||||
'product_id' => (int) $product['id'],
|
||||
'title' => $product['title'],
|
||||
'price' => (float) $product['price'],
|
||||
'quantity' => (int) $item['quantity'],
|
||||
'line_total' => $line_total,
|
||||
'image' => $product['image'],
|
||||
'shipping' => $product['shipping'],
|
||||
'discount_amount' => (float) ($discount_result['discount_amount'] ?? 0),
|
||||
'gifts' => (array) ($discount_result['gifts'] ?? []),
|
||||
'applied_discounts' => (array) ($discount_result['applied'] ?? []),
|
||||
];
|
||||
}
|
||||
|
||||
$shipping_method = sanitize_key((string) ($payload['shipping_method'] ?? 'express'));
|
||||
$shipping_profile = $this->resolve_shipping_quote($product_views, $shipping_method);
|
||||
|
||||
$coupon_code = sanitize_text_field((string) ($payload['coupon_code'] ?? ''));
|
||||
$product_ids = array_values(array_unique(array_map(static function ($row) {
|
||||
return (int) ($row['product_id'] ?? $row['id'] ?? 0);
|
||||
}, $product_views)));
|
||||
$coupon_result = $this->resolve_coupon_discount($coupon_code, $subtotal + $shipping_profile['fee'], $product_ids);
|
||||
if (!$coupon_result['success']) {
|
||||
return $coupon_result;
|
||||
}
|
||||
|
||||
$total = max(0, round($subtotal + $shipping_profile['fee'] - $coupon_result['discount'], 2));
|
||||
$address = $this->resolve_checkout_address($user_id, (int) ($payload['address_id'] ?? 0));
|
||||
$payment_method = $this->resolve_payment_method((string) ($payload['payment_method'] ?? 'balance'));
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'items' => $product_views,
|
||||
'subtotal' => round($subtotal, 2),
|
||||
'shipping_fee' => $shipping_profile['fee'],
|
||||
'shipping_method' => $shipping_profile['method'],
|
||||
'discount' => $coupon_result['discount'],
|
||||
'coupon' => $coupon_result['coupon'],
|
||||
'address' => $address,
|
||||
'payment_method' => $payment_method,
|
||||
'total' => $total,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function create_checkout_order(int $user_id, array $payload): array {
|
||||
$preview = $this->preview_checkout($user_id, $payload);
|
||||
if (empty($preview['success'])) {
|
||||
return $preview;
|
||||
}
|
||||
|
||||
$data = $preview['data'];
|
||||
$primary_item = $data['items'][0] ?? null;
|
||||
if (!$primary_item || !class_exists('\AKSRT\Payment\OrderRepository')) {
|
||||
return ['success' => false, 'message' => __('订单创建失败。', 'aksrt')];
|
||||
}
|
||||
|
||||
$payment_method = sanitize_key((string) ($data['payment_method']['value'] ?? 'balance'));
|
||||
$meta_payload = [
|
||||
'customer_note' => sanitize_textarea_field((string) ($payload['customer_note'] ?? '')),
|
||||
'items' => $data['items'],
|
||||
'coupon' => $data['coupon'],
|
||||
'shipping_fee' => $data['shipping_fee'],
|
||||
'shipping_method' => $data['shipping_method'],
|
||||
'address' => $data['address'],
|
||||
'payment_method' => $data['payment_method'],
|
||||
'discount' => $data['discount'],
|
||||
];
|
||||
|
||||
$expiry_minutes = 30;
|
||||
if (class_exists('\AKSRT\Settings\Settings')) {
|
||||
$order_settings = \AKSRT\Settings\Settings::get_order_settings();
|
||||
$expiry_minutes = max(5, min(1440, (int) ($order_settings['expiry'] ?? 30)));
|
||||
}
|
||||
|
||||
$order_number = \AKSRT\Payment\OrderRepository::get_instance()->create([
|
||||
'user_id' => $user_id,
|
||||
'product_type' => 'shop_product',
|
||||
'product_id' => (int) $primary_item['product_id'],
|
||||
'amount' => (float) $data['total'],
|
||||
'currency' => 'CNY',
|
||||
'status' => \AKSRT\Payment\Order::STATUS_PENDING,
|
||||
'payment_method' => $payment_method,
|
||||
'expires_at' => date('Y-m-d H:i:s', strtotime('+' . $expiry_minutes . ' minutes')),
|
||||
'note' => wp_json_encode($meta_payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
]);
|
||||
|
||||
if ($order_number === '') {
|
||||
return ['success' => false, 'message' => __('订单创建失败。', 'aksrt')];
|
||||
}
|
||||
|
||||
$this->shop->save_order_items($order_number, $data['items']);
|
||||
|
||||
if (!empty($data['coupon']['code']) && class_exists('\AKSRT\Payment\Coupon')) {
|
||||
$coupon_instance = \AKSRT\Payment\Coupon::get_instance();
|
||||
$coupon_instance->use_coupon($data['coupon']['code']);
|
||||
// R69: Log coupon usage for per-user tracking
|
||||
$coupon_row = $coupon_instance->validate($data['coupon']['code']);
|
||||
$coupon_id = (int) ($coupon_row['coupon']['id'] ?? 0);
|
||||
if ($coupon_id > 0) {
|
||||
$coupon_instance->log_usage(
|
||||
$coupon_id,
|
||||
$user_id,
|
||||
$order_number,
|
||||
(float) ($data['discount'] ?? 0)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$detail = $this->shop->get_order_detail_view($user_id, $order_number);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'order_number' => $order_number,
|
||||
'order' => $detail,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function pay_order(int $user_id, string $order_number, string $payment_method): array {
|
||||
if ($user_id <= 0 || $order_number === '' || !class_exists('\AKSRT\Payment\OrderRepository')) {
|
||||
return ['success' => false, 'message' => __('订单不存在。', 'aksrt')];
|
||||
}
|
||||
|
||||
$repo = \AKSRT\Payment\OrderRepository::get_instance();
|
||||
$order = $repo->get_user_order($user_id, $order_number);
|
||||
if (!$order) {
|
||||
return ['success' => false, 'message' => __('订单不存在。', 'aksrt')];
|
||||
}
|
||||
|
||||
$status = (string) ($order['status'] ?? '');
|
||||
if (!in_array($status, [
|
||||
\AKSRT\Payment\Order::STATUS_CREATED,
|
||||
\AKSRT\Payment\Order::STATUS_PENDING,
|
||||
], true)) {
|
||||
return ['success' => false, 'message' => __('当前订单状态不可支付。', 'aksrt')];
|
||||
}
|
||||
|
||||
// Payment attempts are bound to the method selected when the order was created.
|
||||
$stored_method = sanitize_key((string) ($order['payment_method'] ?? ''));
|
||||
$payment_method = sanitize_key($payment_method ?: ($stored_method ?: 'balance'));
|
||||
if ($stored_method !== '' && $payment_method !== $stored_method) {
|
||||
return ['success' => false, 'message' => __('支付方式与订单不一致,请重新下单。', 'aksrt')];
|
||||
}
|
||||
|
||||
$gateway_manager = class_exists('\AKSRT\Payment\Gateway') ? \AKSRT\Payment\Gateway::get_instance() : null;
|
||||
$gateway = $gateway_manager ? $gateway_manager->get_gateway($payment_method) : null;
|
||||
|
||||
if (!$gateway) {
|
||||
return ['success' => false, 'message' => __('当前支付方式不可用。', 'aksrt')];
|
||||
}
|
||||
|
||||
if (method_exists($gateway, 'is_configured') && !$gateway->is_configured()) {
|
||||
return ['success' => false, 'message' => __('该支付方式尚未配置,请联系站长。', 'aksrt')];
|
||||
}
|
||||
|
||||
// Points payment is a purely internal settlement (no provider attempt),
|
||||
// but still needs the attempt lock against double-click double deduction.
|
||||
if ($payment_method === 'points') {
|
||||
$lock = class_exists('\AKSRT\Payment\PaymentAttemptLock')
|
||||
? \AKSRT\Payment\PaymentAttemptLock::acquire($order_number)
|
||||
: false;
|
||||
if ($lock === false) {
|
||||
return ['success' => false, 'message' => __('该订单正在发起支付,请稍后重试。', 'aksrt')];
|
||||
}
|
||||
try {
|
||||
return $this->pay_with_points($user_id, $order_number, $order);
|
||||
} finally {
|
||||
\AKSRT\Payment\PaymentAttemptLock::release($order_number, $lock);
|
||||
}
|
||||
}
|
||||
|
||||
// 携带订单号与摘要,供网关生成支付单。
|
||||
// PaymentOrchestrator 内部持有 attempt 锁并分配 provider out_trade_no。
|
||||
$order['subject'] = $order['subject'] ?? sprintf(__('订单 %s', 'aksrt'), $order_number);
|
||||
$order['payment_method'] = $payment_method;
|
||||
|
||||
$payment_result = \AKSRT\Payment\PaymentOrchestrator::pay($gateway, $order, [
|
||||
'paid_note' => __('用户在前台完成支付。', 'aksrt'),
|
||||
]);
|
||||
if (empty($payment_result['success'])) {
|
||||
return ['success' => false, 'message' => $payment_result['message']];
|
||||
}
|
||||
|
||||
// 跳转/扫码型网关(支付宝/微信/PayPal/Stripe):返回支付凭据,订单保持待支付,
|
||||
// 由 /payment/{gateway}/notify|return 回调确认到账;此处绝不预先标记已支付。
|
||||
if (!empty($payment_result['redirect'])) {
|
||||
return [
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'redirect' => true,
|
||||
'payment_url' => (string) ($payment_result['payment_url'] ?? ''),
|
||||
'qr_content' => (string) ($payment_result['qr_content'] ?? ''),
|
||||
'order_number' => $order_number,
|
||||
'message' => $payment_result['message'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'order' => $this->shop->get_order_detail_view($user_id, $order_number),
|
||||
'message' => $payment_result['message'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function pay_with_points(int $user_id, string $order_number, array $order): array {
|
||||
if (!class_exists('\AKSRT\Credits\Credits')) {
|
||||
return ['success' => false, 'message' => __('积分系统不可用。', 'aksrt')];
|
||||
}
|
||||
|
||||
$amount = (int) round((float) ($order['amount'] ?? 0));
|
||||
if ($amount <= 0) {
|
||||
return ['success' => false, 'message' => __('订单金额无效。', 'aksrt')];
|
||||
}
|
||||
|
||||
$balance = \AKSRT\Credits\Credits::balance($user_id);
|
||||
if ($balance < $amount) {
|
||||
return ['success' => false, 'message' => sprintf(__('积分不足,需要 %d 积分,当前余额 %d。', 'aksrt'), $amount, $balance)];
|
||||
}
|
||||
|
||||
$deducted = \AKSRT\Credits\Credits::deduct(
|
||||
$user_id,
|
||||
$amount,
|
||||
'shop_points_pay',
|
||||
['order' => $order_number],
|
||||
sprintf(__('积分购买订单 %s', 'aksrt'), $order_number)
|
||||
);
|
||||
if (is_wp_error($deducted)) {
|
||||
return ['success' => false, 'message' => $deducted->get_error_message() ?: __('积分扣除失败。', 'aksrt')];
|
||||
}
|
||||
|
||||
$repo = \AKSRT\Payment\OrderRepository::get_instance();
|
||||
$paid = $repo->update_status($order_number, \AKSRT\Payment\Order::STATUS_PAID, [
|
||||
'payment_method' => 'points',
|
||||
'payment_transaction_id' => 'points_' . $order_number,
|
||||
'note' => sprintf(__('积分支付 %d 积分。', 'aksrt'), $amount),
|
||||
'require_transition' => true,
|
||||
]);
|
||||
if (!$paid) {
|
||||
$compensation = \AKSRT\Credits\Credits::add(
|
||||
$user_id,
|
||||
$amount,
|
||||
'shop_points_pay_rollback',
|
||||
['order' => $order_number],
|
||||
sprintf(__('订单 %s 支付失败退回积分', 'aksrt'), $order_number)
|
||||
);
|
||||
if (is_wp_error($compensation)) {
|
||||
error_log(sprintf(
|
||||
'[AKSRT Payment] points rollback failed order=%s: %s',
|
||||
$order_number,
|
||||
$compensation->get_error_message()
|
||||
));
|
||||
return ['success' => false, 'message' => __('订单状态已变化,积分补偿失败,请联系管理员处理。', 'aksrt')];
|
||||
}
|
||||
return ['success' => false, 'message' => __('订单状态已变化,积分已退回。', 'aksrt')];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'order' => $this->shop->get_order_detail_view($user_id, $order_number),
|
||||
'message' => __('积分支付成功。', 'aksrt'),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function normalize_checkout_items($items): array {
|
||||
if (!is_array($items)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$normalized = [];
|
||||
foreach ($items as $item) {
|
||||
$product_id = (int) ($item['product_id'] ?? 0);
|
||||
$quantity = max(1, (int) ($item['quantity'] ?? 1));
|
||||
if ($product_id <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalized[] = [
|
||||
'product_id' => $product_id,
|
||||
'quantity' => $quantity,
|
||||
];
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
private function resolve_coupon_discount(string $coupon_code, float $order_amount, array $product_ids = []): array {
|
||||
if ($coupon_code === '') {
|
||||
return ['success' => true, 'discount' => 0.0, 'coupon' => []];
|
||||
}
|
||||
|
||||
if (!class_exists('\AKSRT\Payment\Coupon')) {
|
||||
return ['success' => false, 'message' => __('优惠券系统不可用。', 'aksrt')];
|
||||
}
|
||||
|
||||
$result = \AKSRT\Payment\Coupon::get_instance()->validate($coupon_code, $order_amount, [
|
||||
'user_id' => get_current_user_id(),
|
||||
'product_ids' => array_values(array_filter(array_map('intval', $product_ids))),
|
||||
'context' => 'shop',
|
||||
]);
|
||||
if (empty($result['valid'])) {
|
||||
return ['success' => false, 'message' => $result['message'] ?? __('优惠券不可用。', 'aksrt')];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'discount' => (float) ($result['discount'] ?? 0),
|
||||
'coupon' => [
|
||||
'code' => (string) ($result['coupon']['code'] ?? $coupon_code),
|
||||
'type' => (string) ($result['coupon']['type'] ?? ''),
|
||||
'value' => (float) ($result['coupon']['value'] ?? 0),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function resolve_shipping_quote(array $items, string $shipping_method): array {
|
||||
$methods = $this->shop->get_shipping_method_options();
|
||||
$method = $methods[$shipping_method] ?? $methods['express'];
|
||||
|
||||
$base_fee = (float) ($method['fee'] ?? 0);
|
||||
foreach ($items as $item) {
|
||||
$base_fee += (float) ($item['shipping']['fee'] ?? 0);
|
||||
}
|
||||
|
||||
return [
|
||||
'method' => $method,
|
||||
'fee' => round($base_fee, 2),
|
||||
];
|
||||
}
|
||||
|
||||
private function resolve_checkout_address(int $user_id, int $address_id): array {
|
||||
$addresses = $this->shop->get_address_book($user_id);
|
||||
foreach ($addresses as $address) {
|
||||
if ((int) ($address['id'] ?? 0) === $address_id) {
|
||||
return $address;
|
||||
}
|
||||
}
|
||||
|
||||
return $addresses[0] ?? [];
|
||||
}
|
||||
|
||||
private function resolve_payment_method(string $value): array {
|
||||
$methods = $this->shop->get_payment_method_options();
|
||||
return $methods[$value] ?? reset($methods);
|
||||
}
|
||||
|
||||
public function get_checkout_hint(array $product): array {
|
||||
return [
|
||||
'delivery' => $product['shipping']['delivery_note'] ?: __('付款后自动进入订单中心,可继续查看物流和售后进度。', 'aksrt'),
|
||||
'service' => $product['after_sale']['policy'] ?: __('支持在订单中心发起售后申请。', 'aksrt'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,677 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Shop;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
trait Part1 {
|
||||
|
||||
public function create_after_sale_request(int $user_id, array $payload): array {
|
||||
if ($user_id <= 0 || !class_exists('\AKSRT\Payment\AfterSale')) {
|
||||
return ['success' => false, 'message' => __('售后系统不可用。', 'aksrt')];
|
||||
}
|
||||
|
||||
$order_number = sanitize_text_field((string) ($payload['order_number'] ?? ''));
|
||||
$reason = sanitize_textarea_field((string) ($payload['reason'] ?? ''));
|
||||
$type = sanitize_key((string) ($payload['type'] ?? \AKSRT\Payment\AfterSale::TYPE_REFUND));
|
||||
|
||||
if ($order_number === '' || $reason === '') {
|
||||
return ['success' => false, 'message' => __('请填写售后原因。', 'aksrt')];
|
||||
}
|
||||
|
||||
$order = $this->get_order_detail_view($user_id, $order_number);
|
||||
if (!$order) {
|
||||
return ['success' => false, 'message' => __('订单不存在。', 'aksrt')];
|
||||
}
|
||||
|
||||
$ticket_id = \AKSRT\Payment\AfterSale::get_instance()->create([
|
||||
'order_number' => $order_number,
|
||||
'applicant_user_id' => $user_id,
|
||||
'type' => $type,
|
||||
'reason' => $reason,
|
||||
'amount' => (float) ($payload['amount'] ?? $order['amount'] ?? 0),
|
||||
'note' => sanitize_textarea_field((string) ($payload['note'] ?? '')),
|
||||
]);
|
||||
|
||||
if (is_wp_error($ticket_id)) {
|
||||
return ['success' => false, 'message' => $ticket_id->get_error_message()];
|
||||
}
|
||||
if ($ticket_id <= 0) {
|
||||
return ['success' => false, 'message' => __('售后申请提交失败。', 'aksrt')];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'ticket_id' => $ticket_id,
|
||||
'order' => $this->get_order_detail_view($user_id, $order_number),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* List after-sale tickets for current user.
|
||||
*/
|
||||
public function get_user_after_sales(int $user_id): array {
|
||||
if ($user_id <= 0 || !class_exists('\AKSRT\Payment\AfterSale')) {
|
||||
return ['items' => []];
|
||||
}
|
||||
$items = \AKSRT\Payment\AfterSale::get_instance()->get_user_tickets($user_id, 50);
|
||||
return ['items' => $items];
|
||||
}
|
||||
|
||||
/**
|
||||
* User submits return shipping info.
|
||||
*/
|
||||
public function submit_after_sale_return(int $user_id, int $ticket_id, array $payload): array {
|
||||
if ($user_id <= 0 || $ticket_id <= 0 || !class_exists('\AKSRT\Payment\AfterSale')) {
|
||||
return ['success' => false, 'message' => __('售后系统不可用。', 'aksrt')];
|
||||
}
|
||||
$carrier = sanitize_text_field((string) ($payload['carrier'] ?? ''));
|
||||
$tracking = sanitize_text_field((string) ($payload['tracking_no'] ?? ''));
|
||||
if ($carrier === '' || $tracking === '') {
|
||||
return ['success' => false, 'message' => __('请填写快递公司与运单号。', 'aksrt')];
|
||||
}
|
||||
$ok = \AKSRT\Payment\AfterSale::get_instance()->user_submit_return_shipping($ticket_id, $user_id, $carrier, $tracking);
|
||||
if (!$ok) {
|
||||
return ['success' => false, 'message' => __('提交退货物流失败,请确认工单状态。', 'aksrt')];
|
||||
}
|
||||
$ticket = \AKSRT\Payment\AfterSale::get_instance()->get($ticket_id);
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => __('退货物流已提交', 'aksrt'),
|
||||
'data' => $ticket ? \AKSRT\Payment\AfterSale::get_instance()->decorate_user_ticket($ticket) : ['id' => $ticket_id],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* User cancels after-sale ticket.
|
||||
*/
|
||||
public function cancel_after_sale(int $user_id, int $ticket_id, string $reason = ''): array {
|
||||
if ($user_id <= 0 || $ticket_id <= 0 || !class_exists('\AKSRT\Payment\AfterSale')) {
|
||||
return ['success' => false, 'message' => __('售后系统不可用。', 'aksrt')];
|
||||
}
|
||||
$ok = \AKSRT\Payment\AfterSale::get_instance()->user_cancel($ticket_id, $user_id, $reason);
|
||||
if (!$ok) {
|
||||
return ['success' => false, 'message' => __('无法取消该售后工单。', 'aksrt')];
|
||||
}
|
||||
return ['success' => true, 'message' => __('已取消售后申请', 'aksrt')];
|
||||
}
|
||||
|
||||
public function get_product_archive_view(array $args = []): array {
|
||||
$taxonomy = sanitize_key((string) ($args['taxonomy'] ?? ''));
|
||||
$term_id = (int) ($args['term_id'] ?? 0);
|
||||
$paged = max(1, (int) ($args['paged'] ?? $args['page'] ?? 1));
|
||||
|
||||
$shop_settings = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_shop_settings() : [];
|
||||
$list = is_array($shop_settings['list'] ?? null) ? $shop_settings['list'] : [];
|
||||
$per_page = max(4, min(100, (int) ($args['per_page'] ?? ($list['count'] ?? 12))));
|
||||
|
||||
// Normalize orderby aliases (zibll + legacy AKSRT keys).
|
||||
$orderby_raw = sanitize_key((string) ($args['orderby'] ?? ($list['orderby'] ?? 'date')));
|
||||
$order = strtoupper(sanitize_key((string) ($args['order'] ?? '')));
|
||||
if (!in_array($order, ['ASC', 'DESC'], true)) {
|
||||
$order = '';
|
||||
}
|
||||
// Legacy split price keys → price + order.
|
||||
if ($orderby_raw === 'price_asc') {
|
||||
$orderby_raw = 'price';
|
||||
$order = 'ASC';
|
||||
} elseif ($orderby_raw === 'price_desc') {
|
||||
$orderby_raw = 'price';
|
||||
$order = 'DESC';
|
||||
} elseif ($orderby_raw === 'sales_volume') {
|
||||
$orderby_raw = 'sales';
|
||||
} elseif ($orderby_raw === 'zibpay_price') {
|
||||
$orderby_raw = 'price';
|
||||
} elseif ($orderby_raw === 'comments') {
|
||||
$orderby_raw = 'score';
|
||||
}
|
||||
$allowed_orderby = ['date', 'views', 'favorite_count', 'score', 'sales', 'price', 'modified'];
|
||||
if (!in_array($orderby_raw, $allowed_orderby, true)) {
|
||||
$orderby_raw = 'date';
|
||||
}
|
||||
if ($orderby_raw === 'price' && $order === '') {
|
||||
$order = 'ASC';
|
||||
}
|
||||
if ($order === '' && $orderby_raw !== 'price') {
|
||||
$order = 'DESC';
|
||||
}
|
||||
$orderby = $orderby_raw;
|
||||
|
||||
$paging = sanitize_key((string) ($args['paging'] ?? ($list['paging'] ?? 'number')));
|
||||
if (!in_array($paging, ['number', 'ajax'], true)) {
|
||||
$paging = 'number';
|
||||
}
|
||||
$ias_s = array_key_exists('ias_s', $args) ? !empty($args['ias_s']) : !empty($list['ias_s']);
|
||||
$ias_max = max(1, min(50, (int) ($args['ias_max'] ?? ($list['ias_max'] ?? 5))));
|
||||
$style = sanitize_key((string) ($list['style'] ?? 'default'));
|
||||
|
||||
$empty = [
|
||||
'products' => [],
|
||||
'term' => null,
|
||||
'total' => 0,
|
||||
'max_pages' => 0,
|
||||
'page' => $paged,
|
||||
'orderby' => $orderby,
|
||||
'order' => $order,
|
||||
'paging' => $paging,
|
||||
'ias_s' => $ias_s,
|
||||
'ias_max' => $ias_max,
|
||||
'per_page' => $per_page,
|
||||
'style' => $style,
|
||||
'sales' => is_array($shop_settings['sales'] ?? null) ? $shop_settings['sales'] : [],
|
||||
'filter' => null,
|
||||
];
|
||||
|
||||
if (!in_array($taxonomy, ['aksrt_product_cat', 'aksrt_product_tag'], true) && empty($args['allow_all'])) {
|
||||
return $empty;
|
||||
}
|
||||
|
||||
$query_args = [
|
||||
'post_type' => 'aksrt_product',
|
||||
'posts_per_page' => $per_page,
|
||||
'paged' => $paged,
|
||||
'post_status' => 'publish',
|
||||
'order' => $order ?: 'DESC',
|
||||
'orderby' => 'date',
|
||||
];
|
||||
|
||||
$term = null;
|
||||
if ($taxonomy !== '' && $term_id > 0) {
|
||||
$term = get_term($term_id, $taxonomy);
|
||||
if (is_wp_error($term) || !$term) {
|
||||
return $empty;
|
||||
}
|
||||
$query_args['tax_query'] = [[
|
||||
'taxonomy' => $taxonomy,
|
||||
'field' => 'term_id',
|
||||
'terms' => $term_id,
|
||||
]];
|
||||
}
|
||||
|
||||
if ($orderby === 'sales') {
|
||||
$query_args['meta_key'] = '_aksrt_sales';
|
||||
$query_args['orderby'] = 'meta_value_num';
|
||||
$query_args['order'] = 'DESC';
|
||||
} elseif ($orderby === 'price') {
|
||||
$query_args['meta_key'] = '_aksrt_price';
|
||||
$query_args['orderby'] = 'meta_value_num';
|
||||
$query_args['order'] = $order === 'ASC' ? 'ASC' : 'DESC';
|
||||
} elseif ($orderby === 'favorite_count') {
|
||||
$query_args['meta_key'] = '_aksrt_favorite_count';
|
||||
$query_args['orderby'] = 'meta_value_num';
|
||||
$query_args['order'] = 'DESC';
|
||||
} elseif ($orderby === 'views') {
|
||||
$query_args['meta_key'] = '_aksrt_views';
|
||||
$query_args['orderby'] = 'meta_value_num';
|
||||
$query_args['order'] = 'DESC';
|
||||
} elseif ($orderby === 'score') {
|
||||
$query_args['meta_key'] = '_aksrt_score_average';
|
||||
$query_args['orderby'] = 'meta_value_num';
|
||||
$query_args['order'] = 'DESC';
|
||||
} elseif ($orderby === 'modified') {
|
||||
$query_args['orderby'] = 'modified';
|
||||
$query_args['order'] = 'DESC';
|
||||
}
|
||||
|
||||
$query = new \WP_Query($query_args);
|
||||
$products = [];
|
||||
while ($query->have_posts()) {
|
||||
$query->the_post();
|
||||
$products[] = $this->build_product_card_view(get_the_ID());
|
||||
}
|
||||
wp_reset_postdata();
|
||||
|
||||
$filter = null;
|
||||
if ($taxonomy === 'aksrt_product_cat' && $term) {
|
||||
$filter = $this->build_product_cat_filter((int) $term->term_id, $orderby, $order);
|
||||
}
|
||||
|
||||
return [
|
||||
'products' => $products,
|
||||
'term' => $term ? [
|
||||
'id' => (int) $term->term_id,
|
||||
'name' => (string) $term->name,
|
||||
'description' => (string) $term->description,
|
||||
'count' => (int) $term->count,
|
||||
'taxonomy' => $taxonomy,
|
||||
'parent' => (int) $term->parent,
|
||||
'url' => get_term_link($term),
|
||||
] : null,
|
||||
'total' => (int) $query->found_posts,
|
||||
'max_pages' => (int) $query->max_num_pages,
|
||||
'page' => $paged,
|
||||
'orderby' => $orderby,
|
||||
'order' => $order,
|
||||
'paging' => $paging,
|
||||
'ias_s' => $ias_s,
|
||||
'ias_max' => $ias_max,
|
||||
'per_page' => $per_page,
|
||||
'style' => $style,
|
||||
'sales' => is_array($shop_settings['sales'] ?? null) ? $shop_settings['sales'] : [],
|
||||
'filter' => $filter,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Hierarchical cat filter data (zibll zib_shop_get_cat_filter).
|
||||
*
|
||||
* @return array{siblings:array<int,array>,children:array<int,array>,parent_id:int,mode:string}
|
||||
*/
|
||||
public function build_product_cat_filter(int $cat_id, string $orderby = 'date', string $order = 'DESC'): array {
|
||||
$taxonomy = 'aksrt_product_cat';
|
||||
$cat = get_term($cat_id, $taxonomy);
|
||||
if (!$cat || is_wp_error($cat)) {
|
||||
return ['siblings' => [], 'children' => [], 'parent_id' => 0, 'mode' => 'flat', 'orderby' => $orderby, 'order' => $order];
|
||||
}
|
||||
|
||||
$map_term = static function ($t) use ($orderby, $order): array {
|
||||
$url = get_term_link($t);
|
||||
if (!is_wp_error($url) && $orderby) {
|
||||
$url = add_query_arg(array_filter([
|
||||
'orderby' => $orderby,
|
||||
'order' => $orderby === 'price' ? $order : null,
|
||||
]), $url);
|
||||
}
|
||||
return [
|
||||
'id' => (int) $t->term_id,
|
||||
'name' => (string) $t->name,
|
||||
'url' => is_wp_error($url) ? '' : (string) $url,
|
||||
'count' => (int) $t->count,
|
||||
];
|
||||
};
|
||||
|
||||
$parent_id = (int) $cat->parent;
|
||||
$children = get_terms([
|
||||
'taxonomy' => $taxonomy,
|
||||
'parent' => $cat_id,
|
||||
'hide_empty' => false,
|
||||
]);
|
||||
if (is_wp_error($children)) {
|
||||
$children = [];
|
||||
}
|
||||
|
||||
if ($parent_id > 0) {
|
||||
$siblings = get_terms([
|
||||
'taxonomy' => $taxonomy,
|
||||
'parent' => $parent_id,
|
||||
'hide_empty' => false,
|
||||
]);
|
||||
} else {
|
||||
$siblings = get_terms([
|
||||
'taxonomy' => $taxonomy,
|
||||
'parent' => 0,
|
||||
'hide_empty' => false,
|
||||
]);
|
||||
}
|
||||
if (is_wp_error($siblings)) {
|
||||
$siblings = [];
|
||||
}
|
||||
|
||||
$mode = 'siblings';
|
||||
$parent_terms = [];
|
||||
if (empty($children) && $parent_id > 0) {
|
||||
$parent_term = get_term($parent_id, $taxonomy);
|
||||
if ($parent_term && !is_wp_error($parent_term) && (int) $parent_term->parent > 0) {
|
||||
$parent_terms = get_terms([
|
||||
'taxonomy' => $taxonomy,
|
||||
'parent' => (int) $parent_term->parent,
|
||||
'hide_empty' => false,
|
||||
]);
|
||||
if (is_wp_error($parent_terms)) {
|
||||
$parent_terms = [];
|
||||
}
|
||||
$mode = 'parent_siblings';
|
||||
} elseif ($parent_id > 0) {
|
||||
$parent_terms = get_terms([
|
||||
'taxonomy' => $taxonomy,
|
||||
'parent' => 0,
|
||||
'hide_empty' => false,
|
||||
]);
|
||||
if (is_wp_error($parent_terms)) {
|
||||
$parent_terms = [];
|
||||
}
|
||||
$mode = 'parent_siblings';
|
||||
}
|
||||
} elseif (!empty($children)) {
|
||||
$mode = 'with_children';
|
||||
}
|
||||
|
||||
$parent_url = '';
|
||||
if ($parent_id > 0) {
|
||||
$plink = get_term_link($parent_id, $taxonomy);
|
||||
if (!is_wp_error($plink)) {
|
||||
$parent_url = add_query_arg(array_filter([
|
||||
'orderby' => $orderby,
|
||||
'order' => $orderby === 'price' ? $order : null,
|
||||
]), $plink);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'mode' => $mode,
|
||||
'current_id' => $cat_id,
|
||||
'parent_id' => $parent_id,
|
||||
'parent_url' => $parent_url,
|
||||
'siblings' => array_map($map_term, empty($parent_terms) ? $siblings : $parent_terms),
|
||||
'children' => array_map($map_term, $mode === 'with_children' ? $children : ($mode === 'parent_siblings' ? $siblings : [])),
|
||||
'child_tabs' => array_map($map_term, $children),
|
||||
'orderby' => $orderby,
|
||||
'order' => $order,
|
||||
];
|
||||
}
|
||||
|
||||
public function get_product_categories(): array {
|
||||
$terms = get_terms([
|
||||
'taxonomy' => 'aksrt_product_cat',
|
||||
'hide_empty' => true,
|
||||
'orderby' => 'count',
|
||||
'order' => 'DESC',
|
||||
]);
|
||||
|
||||
if (is_wp_error($terms)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_map(static function ($t) {
|
||||
return [
|
||||
'id' => $t->term_id,
|
||||
'name' => $t->name,
|
||||
'count' => $t->count,
|
||||
'url' => get_term_link($t),
|
||||
];
|
||||
}, $terms);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* Merchant / Store Mode (§6.10)
|
||||
* ============================================================ */
|
||||
|
||||
public function get_store_profile(int $author_id): array {
|
||||
if ($author_id <= 0) return [];
|
||||
|
||||
$user = get_userdata($author_id);
|
||||
if (!$user) return [];
|
||||
|
||||
$product_count = $this->count_author_products($author_id);
|
||||
$shop_settings = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_shop_settings() : [];
|
||||
$author_show = !isset($shop_settings['general']['shop_author_show']) || !empty($shop_settings['general']['shop_author_show']);
|
||||
|
||||
if (!$author_show) return [];
|
||||
|
||||
$contact = $this->get_store_contact($author_id);
|
||||
|
||||
return [
|
||||
'author_id' => $author_id,
|
||||
'name' => (string) $user->display_name,
|
||||
'avatar' => function_exists('aksrt_get_avatar_url') ? aksrt_get_avatar_url($author_id) : get_avatar_url($author_id),
|
||||
'description' => (string) get_the_author_meta('description', $author_id),
|
||||
'url' => get_author_posts_url($author_id),
|
||||
'product_count' => $product_count,
|
||||
'contact' => $contact,
|
||||
'addresses' => $this->get_address_book($author_id),
|
||||
];
|
||||
}
|
||||
|
||||
public function get_store_products(int $author_id, int $page = 1, int $per_page = 12): array {
|
||||
$query = new \WP_Query([
|
||||
'post_type' => 'aksrt_product',
|
||||
'author' => $author_id,
|
||||
'posts_per_page' => $per_page,
|
||||
'paged' => $page,
|
||||
'post_status' => 'publish',
|
||||
'orderby' => 'date',
|
||||
'order' => 'DESC',
|
||||
]);
|
||||
|
||||
$products = [];
|
||||
while ($query->have_posts()) {
|
||||
$query->the_post();
|
||||
$products[] = $this->build_product_card_view(get_the_ID());
|
||||
}
|
||||
wp_reset_postdata();
|
||||
|
||||
return [
|
||||
'items' => $products,
|
||||
'total' => (int) $query->found_posts,
|
||||
'max_pages' => (int) $query->max_num_pages,
|
||||
];
|
||||
}
|
||||
|
||||
public function count_author_products(int $author_id): int {
|
||||
global $wpdb;
|
||||
return (int) $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = 'aksrt_product' AND post_status = 'publish' AND post_author = %d",
|
||||
$author_id
|
||||
));
|
||||
}
|
||||
|
||||
private function get_store_contact(int $author_id): array {
|
||||
$raw = get_user_meta($author_id, 'aksrt_shop_contact', true);
|
||||
if (!is_array($raw)) return [];
|
||||
|
||||
$contacts = [];
|
||||
foreach ($raw as $c) {
|
||||
$name = sanitize_text_field((string) ($c['name'] ?? ''));
|
||||
if ($name === '') continue;
|
||||
$contacts[] = [
|
||||
'name' => $name,
|
||||
'icon' => sanitize_text_field((string) ($c['icon'] ?? '')),
|
||||
'link' => esc_url_raw((string) ($c['link'] ?? '')),
|
||||
'qr' => esc_url_raw((string) ($c['qr'] ?? '')),
|
||||
];
|
||||
}
|
||||
return $contacts;
|
||||
}
|
||||
|
||||
public function get_stats(): array {
|
||||
$product_count = 0;
|
||||
$post_type_obj = get_post_type_object('aksrt_product');
|
||||
if ($post_type_obj) {
|
||||
$counts = wp_count_posts('aksrt_product');
|
||||
$product_count = (int) ($counts->publish ?? 0);
|
||||
}
|
||||
|
||||
return [
|
||||
'product_count' => $product_count,
|
||||
'order_count' => class_exists('\AKSRT\Payment\OrderRepository') ? \AKSRT\Payment\OrderRepository::get_instance()->get_stats()['total'] : 0,
|
||||
'revenue_total' => class_exists('\AKSRT\Payment\OrderRepository') ? \AKSRT\Payment\OrderRepository::get_instance()->get_stats()['revenue'] : 0,
|
||||
];
|
||||
}
|
||||
|
||||
public function resolve_main_image(int $product_id): string {
|
||||
return $this->product_service()->resolve_main_image($product_id);
|
||||
}
|
||||
|
||||
public function build_product_card_view(int $product_id): array {
|
||||
return $this->product_service()->build_product_card_view($product_id);
|
||||
}
|
||||
|
||||
private function decorate_order_view(array $order, bool $with_detail = false): array {
|
||||
$meta = $order['meta_payload'] ?? [];
|
||||
$order_number = (string) ($order['order_number'] ?? '');
|
||||
|
||||
$order_items = $this->get_order_items($order_number);
|
||||
$has_structured_items = !empty($order_items);
|
||||
|
||||
$product = $has_structured_items
|
||||
? $this->build_product_card_view((int) ($order_items[0]['product_id'] ?? 0))
|
||||
: $this->build_product_card_view((int) ($order['product_id'] ?? 0));
|
||||
|
||||
$shipping_records = class_exists('\AKSRT\Payment\Shipping') ? \AKSRT\Payment\Shipping::get_instance()->get_by_order($order_number) : [];
|
||||
$after_sale_records = class_exists('\AKSRT\Payment\AfterSale') ? \AKSRT\Payment\AfterSale::get_instance()->get_list([
|
||||
'limit' => 20,
|
||||
'offset' => 0,
|
||||
'order_number' => $order_number,
|
||||
]) : ['items' => []];
|
||||
|
||||
$status = (string) ($order['status'] ?? \AKSRT\Payment\Order::STATUS_PENDING);
|
||||
$subtotal = (float) ($meta['items'][0]['line_total'] ?? $order['amount']);
|
||||
$shipping_fee = (float) ($meta['shipping_fee'] ?? 0);
|
||||
$discount = (float) ($meta['discount'] ?? 0);
|
||||
$expires_at = (string) ($order['expires_at'] ?? '');
|
||||
$payment_value = (string) ($meta['payment_method']['value'] ?? $order['payment_method'] ?? 'balance');
|
||||
|
||||
$view = [
|
||||
'id' => (int) ($order['id'] ?? 0),
|
||||
'order_number' => (string) ($order['order_number'] ?? ''),
|
||||
'amount' => (float) ($order['amount'] ?? 0),
|
||||
'status' => $status,
|
||||
'status_label' => \AKSRT\Payment\Order::get_valid_statuses()[$status] ?? $status,
|
||||
'product_name' => $product['title'] ?? __('商品订单', 'aksrt'),
|
||||
'product' => $product,
|
||||
'created_at' => (string) ($order['created_at'] ?? ''),
|
||||
'paid_at' => (string) ($order['paid_at'] ?? ''),
|
||||
'expires_at' => $expires_at,
|
||||
'currency_symbol' => '¥',
|
||||
'subtotal' => $subtotal,
|
||||
'shipping_fee' => $shipping_fee,
|
||||
'discount' => $discount,
|
||||
'address' => $meta['address'] ?? [],
|
||||
'payment_method' => $meta['payment_method'] ?? ['value' => $order['payment_method'] ?? '', 'label' => __('站内余额', 'aksrt')],
|
||||
'coupon' => $meta['coupon'] ?? [],
|
||||
'items' => $has_structured_items ? $order_items : ($meta['items'] ?? []),
|
||||
'shipping_method' => $meta['shipping_method'] ?? [],
|
||||
'customer_note' => $meta['customer_note'] ?? '',
|
||||
'pay_url' => '#',
|
||||
'actions' => [
|
||||
'can_pay' => in_array($status, [\AKSRT\Payment\Order::STATUS_PENDING, \AKSRT\Payment\Order::STATUS_EXPIRED], true) && $payment_value !== 'manual_review',
|
||||
'can_cancel' => in_array($status, [\AKSRT\Payment\Order::STATUS_PENDING, \AKSRT\Payment\Order::STATUS_CREATED, \AKSRT\Payment\Order::STATUS_EXPIRED], true),
|
||||
'can_after_sale' => in_array($status, [\AKSRT\Payment\Order::STATUS_PAID, \AKSRT\Payment\Order::STATUS_DELIVERED, \AKSRT\Payment\Order::STATUS_COMPLETED], true),
|
||||
'can_receipt' => $status === \AKSRT\Payment\Order::STATUS_DELIVERED,
|
||||
],
|
||||
'time_left' => $this->get_order_countdown($expires_at, $status),
|
||||
];
|
||||
|
||||
if ($with_detail) {
|
||||
$view['shipping_records'] = $shipping_records;
|
||||
$view['after_sale_records'] = $after_sale_records['items'] ?? [];
|
||||
$view['timeline'] = $this->build_order_timeline($order, $shipping_records, $after_sale_records['items'] ?? []);
|
||||
}
|
||||
|
||||
return $view;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shop-side timeline; keep behaviour aligned with UserOrders.
|
||||
*
|
||||
* @param array<string, mixed> $order
|
||||
* @param array<int, array> $shipping_records
|
||||
* @param array<int, array> $after_sale_records
|
||||
* @return array<int, array{time:string,label:string,key?:string}>
|
||||
*/
|
||||
private function build_order_timeline(array $order, array $shipping_records, array $after_sale_records): array {
|
||||
// Prefer the unified order-center builder when available.
|
||||
if (class_exists('\AKSRT\Payment\UserOrders')) {
|
||||
$view = \AKSRT\Payment\UserOrders::get_instance()->decorate_order_view($order, true);
|
||||
if (is_array($view['timeline'] ?? null)) {
|
||||
return $view['timeline'];
|
||||
}
|
||||
}
|
||||
|
||||
$timeline = [];
|
||||
$created = (string) ($order['created_at'] ?? '');
|
||||
if ($created !== '') {
|
||||
$timeline[] = ['key' => 'created', 'time' => $created, 'label' => __('订单创建', 'aksrt')];
|
||||
}
|
||||
$status = (string) ($order['status'] ?? '');
|
||||
$paid_like = [
|
||||
\AKSRT\Payment\Order::STATUS_PAID,
|
||||
\AKSRT\Payment\Order::STATUS_DELIVERED,
|
||||
\AKSRT\Payment\Order::STATUS_COMPLETED,
|
||||
\AKSRT\Payment\Order::STATUS_PENDING_REVIEW,
|
||||
\AKSRT\Payment\Order::STATUS_REFUNDED,
|
||||
\AKSRT\Payment\Order::STATUS_REFUNDING,
|
||||
];
|
||||
if (!empty($order['paid_at']) || in_array($status, $paid_like, true)) {
|
||||
$timeline[] = [
|
||||
'key' => 'paid',
|
||||
'time' => (string) ($order['paid_at'] ?? $created),
|
||||
'label' => __('完成支付', 'aksrt'),
|
||||
];
|
||||
}
|
||||
foreach ($shipping_records as $record) {
|
||||
$timeline[] = [
|
||||
'key' => 'shipping',
|
||||
'time' => (string) ($record['created_at'] ?? ''),
|
||||
'label' => sprintf(__('物流更新:%s', 'aksrt'), $record['tracking_no'] ?? ''),
|
||||
];
|
||||
}
|
||||
foreach ($after_sale_records as $record) {
|
||||
$timeline[] = [
|
||||
'key' => 'after_sale',
|
||||
'time' => (string) ($record['created_at'] ?? ''),
|
||||
'label' => sprintf(__('售后申请:%s', 'aksrt'), $record['reason'] ?? ''),
|
||||
];
|
||||
}
|
||||
usort($timeline, static function ($a, $b) {
|
||||
return strcmp((string) ($a['time'] ?? ''), (string) ($b['time'] ?? ''));
|
||||
});
|
||||
return $timeline;
|
||||
}
|
||||
|
||||
private function get_order_countdown(string $expires_at, string $status): int {
|
||||
if ($expires_at === '' || !in_array($status, [\AKSRT\Payment\Order::STATUS_PENDING, \AKSRT\Payment\Order::STATUS_EXPIRED], true)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$left = strtotime($expires_at) - time();
|
||||
return max(0, $left);
|
||||
}
|
||||
|
||||
public function get_limit_buy_config(int $product_id): array {
|
||||
return $this->product_service()->get_limit_buy_config($product_id);
|
||||
}
|
||||
|
||||
public function get_product_services(int $product_id): array {
|
||||
return $this->product_service()->get_product_services($product_id);
|
||||
}
|
||||
|
||||
public function get_product_params(int $product_id): array {
|
||||
return $this->product_service()->get_product_params($product_id);
|
||||
}
|
||||
|
||||
public function get_address_book(int $user_id): array {
|
||||
return $this->address_service()->get_address_book($user_id);
|
||||
}
|
||||
|
||||
public function add_address(int $user_id, array $data): array {
|
||||
return $this->address_service()->add_address($user_id, $data);
|
||||
}
|
||||
|
||||
public function update_address(int $user_id, int $address_id, array $data): array {
|
||||
return $this->address_service()->update_address($user_id, $address_id, $data);
|
||||
}
|
||||
|
||||
public function delete_address(int $user_id, int $address_id): array {
|
||||
return $this->address_service()->delete_address($user_id, $address_id);
|
||||
}
|
||||
|
||||
public function set_default_address(int $user_id, int $address_id): array {
|
||||
return $this->address_service()->set_default_address($user_id, $address_id);
|
||||
}
|
||||
|
||||
public function get_payment_method_options(): array {
|
||||
return $this->product_service()->get_payment_method_options();
|
||||
}
|
||||
|
||||
public function get_shipping_method_options(): array {
|
||||
return $this->product_service()->get_shipping_method_options();
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* Product Variants / SKU
|
||||
* ============================================================ */
|
||||
|
||||
public function get_variants(int $product_id): array {
|
||||
return $this->product_service()->get_variants($product_id);
|
||||
}
|
||||
|
||||
public function get_variant(int $variant_id): ?array {
|
||||
return $this->product_service()->get_variant($variant_id);
|
||||
}
|
||||
}
|
||||
@@ -1,436 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Shop;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
trait Part2 {
|
||||
|
||||
|
||||
public function save_variants(int $product_id, array $variants): array {
|
||||
return $this->product_service()->save_variants($product_id, $variants);
|
||||
}
|
||||
|
||||
public function delete_variant(int $variant_id): bool {
|
||||
return $this->product_service()->delete_variant($variant_id);
|
||||
}
|
||||
|
||||
public function has_variants(int $product_id): bool {
|
||||
return $this->product_service()->has_variants($product_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the price range for a product (considering variants).
|
||||
*/
|
||||
public function get_price_range(int $product_id): array {
|
||||
$base_price = (float) get_post_meta($product_id, '_aksrt_price', true);
|
||||
$variants = $this->get_variants($product_id);
|
||||
|
||||
if (empty($variants)) {
|
||||
return ['min' => $base_price, 'max' => $base_price, 'has_variants' => false];
|
||||
}
|
||||
|
||||
$prices = array_column($variants, 'price');
|
||||
$prices[] = $base_price;
|
||||
$prices = array_filter($prices, fn($p) => $p > 0);
|
||||
|
||||
if (empty($prices)) {
|
||||
return ['min' => 0, 'max' => 0, 'has_variants' => true];
|
||||
}
|
||||
|
||||
return [
|
||||
'min' => min($prices),
|
||||
'max' => max($prices),
|
||||
'has_variants' => true,
|
||||
];
|
||||
}
|
||||
|
||||
// ── R68: Fulfillment hook (§6.5) ──
|
||||
|
||||
/**
|
||||
* aksrt_order_paid listener: deduct stock + increment sales.
|
||||
* Registered via \AKSRT\Core\Setup::init().
|
||||
*/
|
||||
public function handle_order_paid(string $order_number, array $order): void {
|
||||
if ($this->order_item_table() !== '') {
|
||||
$items = $this->get_order_items($order_number);
|
||||
if (!empty($items)) {
|
||||
$this->handle_order_paid_enhanced($order_number, $order);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$note_raw = (string) ($order['note'] ?? '');
|
||||
$meta = json_decode($note_raw, true);
|
||||
if (!is_array($meta)) {
|
||||
$meta = [];
|
||||
}
|
||||
|
||||
$items = $meta['items'] ?? [];
|
||||
if (empty($items) && !empty($order['product_id'])) {
|
||||
$items = [
|
||||
[
|
||||
'product_id' => (int) $order['product_id'],
|
||||
'quantity' => 1,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($items as $item) {
|
||||
$pid = (int) ($item['product_id'] ?? 0);
|
||||
$qty = max(1, (int) ($item['quantity'] ?? 1));
|
||||
if ($pid <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Deduct stock (skip if unlimited, i.e. stock <= -1)
|
||||
$stock = (int) get_post_meta($pid, '_aksrt_stock', true);
|
||||
if ($stock > 0) {
|
||||
$new_stock = max(0, $stock - $qty);
|
||||
update_post_meta($pid, '_aksrt_stock', $new_stock);
|
||||
}
|
||||
|
||||
// Increment sales_volume
|
||||
$sales = (int) get_post_meta($pid, '_aksrt_sales_volume', true);
|
||||
update_post_meta($pid, '_aksrt_sales_volume', $sales + $qty);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fires after shop fulfillment (stock + sales).
|
||||
*
|
||||
* @param string $order_number
|
||||
* @param array $order
|
||||
* @param array $items Normalized item list
|
||||
*/
|
||||
do_action('aksrt_shop_fulfillment_done', $order_number, $order, $items);
|
||||
}
|
||||
|
||||
// ── R68: Confirm receipt (§6.5) ──
|
||||
|
||||
/**
|
||||
* User confirms receipt: delivered -> completed.
|
||||
*/
|
||||
public function confirm_receipt(int $user_id, string $order_number): array {
|
||||
if ($user_id <= 0 || $order_number === '' || !class_exists('\AKSRT\Payment\OrderRepository')) {
|
||||
return ['success' => false, 'message' => __('订单不存在。', 'aksrt')];
|
||||
}
|
||||
|
||||
$repo = \AKSRT\Payment\OrderRepository::get_instance();
|
||||
$order = $repo->get_user_order($user_id, $order_number);
|
||||
if (!$order) {
|
||||
return ['success' => false, 'message' => __('订单不存在。', 'aksrt')];
|
||||
}
|
||||
|
||||
$status = (string) ($order['status'] ?? '');
|
||||
if ($status !== \AKSRT\Payment\Order::STATUS_DELIVERED) {
|
||||
return ['success' => false, 'message' => __('当前订单状态不可确认收货。', 'aksrt')];
|
||||
}
|
||||
|
||||
$repo->update_status($order_number, \AKSRT\Payment\Order::STATUS_COMPLETED, [
|
||||
'note' => __('用户确认收货。', 'aksrt'),
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'order' => $this->get_order_detail_view($user_id, $order_number),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-confirm cron: complete orders delivered > receipt_max_day ago.
|
||||
*/
|
||||
public function run_auto_confirm_cron(): void {
|
||||
if (!class_exists('\AKSRT\Payment\OrderRepository') || !class_exists('\AKSRT\Settings\Settings')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$shop = \AKSRT\Settings\Settings::get_shop_settings();
|
||||
$max_day = (int) ($shop['timing']['receipt_max_day'] ?? 15);
|
||||
if ($max_day <= 0) {
|
||||
$max_day = 15;
|
||||
}
|
||||
|
||||
$cutoff = date('Y-m-d H:i:s', strtotime("-{$max_day} days"));
|
||||
$repo = \AKSRT\Payment\OrderRepository::get_instance();
|
||||
$orders = $repo->get_orders_admin(['status' => \AKSRT\Payment\Order::STATUS_DELIVERED, 'limit' => 500]);
|
||||
|
||||
foreach ($orders as $order) {
|
||||
$delivered_at = (string) ($order['delivered_at'] ?? '');
|
||||
if ($delivered_at === '' || $delivered_at > $cutoff) {
|
||||
continue;
|
||||
}
|
||||
$repo->update_status((string) $order['order_number'], \AKSRT\Payment\Order::STATUS_COMPLETED, [
|
||||
'note' => sprintf(__('系统自动确认收货(超过 %d 天)。', 'aksrt'), $max_day),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// ── R68: Order CSV export (§6.5) ──
|
||||
|
||||
/**
|
||||
* Export orders as CSV rows. Admin-only.
|
||||
*/
|
||||
public function export_orders_csv(array $args = []): array {
|
||||
if (!class_exists('\AKSRT\Payment\OrderRepository')) {
|
||||
return ['csv' => '', 'count' => 0];
|
||||
}
|
||||
|
||||
$status = sanitize_key((string) ($args['status'] ?? ''));
|
||||
$date_from = sanitize_text_field((string) ($args['date_from'] ?? ''));
|
||||
$date_to = sanitize_text_field((string) ($args['date_to'] ?? ''));
|
||||
$limit = max(1, min(5000, (int) ($args['limit'] ?? 1000)));
|
||||
|
||||
$repo = \AKSRT\Payment\OrderRepository::get_instance();
|
||||
$orders = $repo->get_orders_admin(['status' => $status, 'limit' => $limit]);
|
||||
|
||||
if ($date_from !== '') {
|
||||
$orders = array_filter($orders, function ($o) use ($date_from) {
|
||||
return ($o['created_at'] ?? '') >= $date_from . ' 00:00:00';
|
||||
});
|
||||
}
|
||||
if ($date_to !== '') {
|
||||
$orders = array_filter($orders, function ($o) use ($date_to) {
|
||||
return ($o['created_at'] ?? '') <= $date_to . ' 23:59:59';
|
||||
});
|
||||
}
|
||||
|
||||
$header = ['order_number', 'user_id', 'product_type', 'product_id', 'amount', 'currency', 'status', 'payment_method', 'created_at', 'paid_at'];
|
||||
$rows = [implode(',', $header)];
|
||||
|
||||
foreach ($orders as $o) {
|
||||
$rows[] = implode(',', [
|
||||
(string) ($o['order_number'] ?? ''),
|
||||
(int) ($o['user_id'] ?? 0),
|
||||
(string) ($o['product_type'] ?? ''),
|
||||
(int) ($o['product_id'] ?? 0),
|
||||
(float) ($o['amount'] ?? 0),
|
||||
(string) ($o['currency'] ?? 'CNY'),
|
||||
(string) ($o['status'] ?? ''),
|
||||
(string) ($o['payment_method'] ?? ''),
|
||||
(string) ($o['created_at'] ?? ''),
|
||||
(string) ($o['paid_at'] ?? ''),
|
||||
]);
|
||||
}
|
||||
|
||||
$bom = "\xEF\xBB\xBF";
|
||||
$csv = $bom . implode("\r\n", $rows);
|
||||
|
||||
return ['csv' => $csv, 'count' => count($orders)];
|
||||
}
|
||||
|
||||
// ── R68: Configurable shipping/payment (§6.11) ──
|
||||
|
||||
/**
|
||||
* Get payment method options from settings, falling back to hardcoded defaults.
|
||||
*/
|
||||
public function get_payment_method_options_configurable(): array {
|
||||
if (!class_exists('\AKSRT\Settings\Settings')) {
|
||||
return $this->get_payment_method_options();
|
||||
}
|
||||
$shop = \AKSRT\Settings\Settings::get_shop_settings();
|
||||
$enabled = (string) ($shop['product_defaults']['payment_methods'] ?? '');
|
||||
if ($enabled === '') {
|
||||
return $this->get_payment_method_options();
|
||||
}
|
||||
|
||||
$all = $this->get_payment_method_options();
|
||||
$keys = array_map('trim', explode(',', $enabled));
|
||||
$result = [];
|
||||
foreach ($keys as $k) {
|
||||
$k = sanitize_key($k);
|
||||
if (isset($all[$k])) {
|
||||
$result[$k] = $all[$k];
|
||||
}
|
||||
}
|
||||
|
||||
return $result ?: $this->get_payment_method_options();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get shipping method options from settings, falling back to hardcoded defaults.
|
||||
*/
|
||||
public function get_shipping_method_options_configurable(): array {
|
||||
if (!class_exists('\AKSRT\Settings\Settings')) {
|
||||
return $this->get_shipping_method_options();
|
||||
}
|
||||
$shop = \AKSRT\Settings\Settings::get_shop_settings();
|
||||
$enabled = (string) ($shop['product_defaults']['shipping_methods'] ?? '');
|
||||
$fee_override = (float) ($shop['product_defaults']['shipping_fee_fixed'] ?? 0);
|
||||
if ($enabled === '' && $fee_override <= 0) {
|
||||
return $this->get_shipping_method_options();
|
||||
}
|
||||
|
||||
$all = $this->get_shipping_method_options();
|
||||
if ($enabled !== '') {
|
||||
$keys = array_map('trim', explode(',', $enabled));
|
||||
$filtered = [];
|
||||
foreach ($keys as $k) {
|
||||
$k = sanitize_key($k);
|
||||
if (isset($all[$k])) {
|
||||
$filtered[$k] = $all[$k];
|
||||
}
|
||||
}
|
||||
$all = $filtered ?: $all;
|
||||
}
|
||||
|
||||
if ($fee_override > 0) {
|
||||
foreach ($all as $k => &$v) {
|
||||
if ($k !== 'download') {
|
||||
$v['fee'] = $fee_override;
|
||||
}
|
||||
}
|
||||
unset($v);
|
||||
}
|
||||
|
||||
return $all;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get after-sale reason options from settings.
|
||||
*/
|
||||
public function get_after_sale_reason_options(): array {
|
||||
if (!class_exists('\AKSRT\Settings\Settings')) {
|
||||
return ['refund' => [], 'return' => []];
|
||||
}
|
||||
$shop = \AKSRT\Settings\Settings::get_shop_settings();
|
||||
$refund_raw = (string) ($shop['timing']['after_sale_reason_refund'] ?? '');
|
||||
$return_raw = (string) ($shop['timing']['after_sale_reason_return'] ?? '');
|
||||
|
||||
return [
|
||||
'refund' => $this->parse_reason_lines($refund_raw),
|
||||
'return' => $this->parse_reason_lines($return_raw),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse newline-separated reason text into indexed array.
|
||||
*/
|
||||
private function parse_reason_lines(string $text): array {
|
||||
if ($text === '') {
|
||||
return [];
|
||||
}
|
||||
$lines = array_filter(array_map('trim', preg_split('/\r\n|\r|\n/', $text)));
|
||||
$result = [];
|
||||
foreach (array_values($lines) as $i => $line) {
|
||||
$result[] = [
|
||||
'id' => $i + 1,
|
||||
'label' => $line,
|
||||
];
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* Product Options (§6.2 选项组机制)
|
||||
*
|
||||
* Storage: post_meta '_aksrt_product_options' = [
|
||||
* [ 'name' => '颜色', 'view_mode' => 'list', 'values' => [
|
||||
* [ 'label' => '红色', 'price_change' => 0, 'image' => '' ],
|
||||
* [ 'label' => '蓝色', 'price_change' => 10, 'image' => 'url' ],
|
||||
* ]],
|
||||
* ]
|
||||
*
|
||||
* options_key = "红色|M" (pipe-delimited selected labels per group)
|
||||
* ============================================================ */
|
||||
|
||||
public function get_product_options(int $product_id): array {
|
||||
$raw = get_post_meta($product_id, '_aksrt_product_options', true);
|
||||
if (!is_array($raw) || empty($raw)) {
|
||||
return [];
|
||||
}
|
||||
return array_values(array_filter(array_map([$this, 'sanitize_option_group'], $raw)));
|
||||
}
|
||||
|
||||
public function save_product_options(int $product_id, array $groups): void {
|
||||
$sanitized = array_values(array_filter(array_map([$this, 'sanitize_option_group'], $groups)));
|
||||
update_post_meta($product_id, '_aksrt_product_options', $sanitized);
|
||||
update_post_meta($product_id, '_aksrt_has_options', count($sanitized) > 0 ? '1' : '0');
|
||||
}
|
||||
|
||||
public function has_options(int $product_id): bool {
|
||||
return get_post_meta($product_id, '_aksrt_has_options', true) === '1';
|
||||
}
|
||||
|
||||
private function sanitize_option_group($group): array {
|
||||
if (!is_array($group)) return [];
|
||||
$name = sanitize_text_field((string) ($group['name'] ?? ''));
|
||||
if ($name === '') return [];
|
||||
|
||||
$view_mode_raw = $group['view_mode'] ?? '';
|
||||
$view_mode = in_array($view_mode_raw, ['list', 'img'], true) ? $view_mode_raw : 'list';
|
||||
$values = [];
|
||||
foreach ((array) ($group['values'] ?? []) as $v) {
|
||||
$label = sanitize_text_field((string) ($v['label'] ?? ''));
|
||||
if ($label === '') continue;
|
||||
$values[] = [
|
||||
'label' => $label,
|
||||
'price_change' => round((float) ($v['price_change'] ?? 0), 2),
|
||||
'image' => esc_url_raw((string) ($v['image'] ?? '')),
|
||||
];
|
||||
}
|
||||
if (empty($values)) return [];
|
||||
|
||||
return ['name' => $name, 'view_mode' => $view_mode, 'values' => $values];
|
||||
}
|
||||
|
||||
public function build_options_key(array $selections): string {
|
||||
return implode('|', array_map('sanitize_text_field', $selections));
|
||||
}
|
||||
|
||||
public function resolve_option_price(int $product_id, string $options_key): float {
|
||||
$base = (float) get_post_meta($product_id, '_aksrt_price', true);
|
||||
$start = (float) get_post_meta($product_id, '_aksrt_start_price', true);
|
||||
if ($start > 0) {
|
||||
$base = $start;
|
||||
}
|
||||
if ($options_key === '' || $options_key === '_default') {
|
||||
return $base;
|
||||
}
|
||||
|
||||
$groups = $this->get_product_options($product_id);
|
||||
$selections = explode('|', $options_key);
|
||||
foreach ($groups as $i => $group) {
|
||||
$selected_label = $selections[$i] ?? '';
|
||||
foreach ($group['values'] as $val) {
|
||||
if ($val['label'] === $selected_label) {
|
||||
$base += $val['price_change'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return max(0, round($base, 2));
|
||||
}
|
||||
|
||||
public function resolve_option_labels(int $product_id, string $options_key): string {
|
||||
if ($options_key === '' || $options_key === '_default') {
|
||||
return '';
|
||||
}
|
||||
$groups = $this->get_product_options($product_id);
|
||||
$selections = explode('|', $options_key);
|
||||
$parts = [];
|
||||
foreach ($groups as $i => $group) {
|
||||
$sel = $selections[$i] ?? '';
|
||||
if ($sel !== '') {
|
||||
$parts[] = $group['name'] . ':' . $sel;
|
||||
}
|
||||
}
|
||||
return implode(', ', $parts);
|
||||
}
|
||||
|
||||
public function resolve_option_stock(int $product_id, string $options_key): int {
|
||||
$stock_type = (string) get_post_meta($product_id, '_aksrt_stock_type', true);
|
||||
if ($stock_type === 'opts' && $options_key !== '' && $options_key !== '_default') {
|
||||
$stock_matrix = get_post_meta($product_id, '_aksrt_stock_opts', true);
|
||||
if (is_array($stock_matrix) && isset($stock_matrix[$options_key])) {
|
||||
return (int) $stock_matrix[$options_key];
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
return (int) get_post_meta($product_id, '_aksrt_stock', true);
|
||||
}
|
||||
}
|
||||
@@ -1,399 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Shop;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
trait Part3 {
|
||||
|
||||
|
||||
public function deduct_option_stock(int $product_id, string $options_key, int $qty): void {
|
||||
$stock_type = (string) get_post_meta($product_id, '_aksrt_stock_type', true);
|
||||
if ($stock_type === 'opts' && $options_key !== '' && $options_key !== '_default') {
|
||||
$matrix = get_post_meta($product_id, '_aksrt_stock_opts', true);
|
||||
if (is_array($matrix) && isset($matrix[$options_key])) {
|
||||
$matrix[$options_key] = max(0, (int) $matrix[$options_key] - $qty);
|
||||
update_post_meta($product_id, '_aksrt_stock_opts', $matrix);
|
||||
}
|
||||
return;
|
||||
}
|
||||
$stock = (int) get_post_meta($product_id, '_aksrt_stock', true);
|
||||
if ($stock > 0) {
|
||||
update_post_meta($product_id, '_aksrt_stock', max(0, $stock - $qty));
|
||||
}
|
||||
}
|
||||
|
||||
public function get_option_image(int $product_id, string $options_key): string {
|
||||
if ($options_key === '' || $options_key === '_default') {
|
||||
return '';
|
||||
}
|
||||
$groups = $this->get_product_options($product_id);
|
||||
$selections = explode('|', $options_key);
|
||||
foreach ($groups as $i => $group) {
|
||||
$sel = $selections[$i] ?? '';
|
||||
foreach ($group['values'] as $val) {
|
||||
if ($val['label'] === $sel && !empty($val['image'])) {
|
||||
return $val['image'];
|
||||
}
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* Auto-delivery (§6.2 自动发货)
|
||||
*
|
||||
* Storage: post_meta '_aksrt_auto_delivery' = [
|
||||
* 'type' => 'fixed'|'invit_code'|'card_pass'|'opts',
|
||||
* 'content' => '固定内容文本',
|
||||
* 'opts' => [ options_key => '该选项的发货内容' ],
|
||||
* ]
|
||||
* ============================================================ */
|
||||
|
||||
public function get_auto_delivery_config(int $product_id): array {
|
||||
$raw = get_post_meta($product_id, '_aksrt_auto_delivery', true);
|
||||
if (!is_array($raw)) {
|
||||
return ['type' => '', 'content' => '', 'opts' => []];
|
||||
}
|
||||
return [
|
||||
'type' => sanitize_key((string) ($raw['type'] ?? '')),
|
||||
'content' => (string) ($raw['content'] ?? ''),
|
||||
'opts' => is_array($raw['opts'] ?? null) ? $raw['opts'] : [],
|
||||
];
|
||||
}
|
||||
|
||||
public function execute_auto_delivery(int $product_id, string $options_key, int $qty, int $user_id, string $order_number, int $order_item_id = 0): string {
|
||||
$config = $this->get_auto_delivery_config($product_id);
|
||||
$type = $config['type'];
|
||||
|
||||
if ($type === 'fixed') {
|
||||
return (string) $config['content'];
|
||||
}
|
||||
if ($type === 'opts') {
|
||||
$key = ($options_key !== '' && $options_key !== '_default') ? $options_key : '_default';
|
||||
return (string) ($config['opts'][$key] ?? $config['content']);
|
||||
}
|
||||
if ($type === 'card_pass') {
|
||||
return $this->consume_card_keys($product_id, $options_key, $qty, $user_id, $order_number, $order_item_id);
|
||||
}
|
||||
if ($type === 'invit_code') {
|
||||
return $this->create_invite_codes($qty, $user_id, $order_number);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
private function auto_delivery_succeeded(int $product_id, string $content, int $qty): bool {
|
||||
$config = $this->get_auto_delivery_config($product_id);
|
||||
if (in_array($config['type'], ['card_pass', 'invit_code'], true)) {
|
||||
$lines = array_values(array_filter(array_map('trim', preg_split('/\r\n|\r|\n/', $content) ?: [])));
|
||||
return count($lines) === max(1, $qty)
|
||||
&& !in_array($content, [__('卡密已售罄', 'aksrt'), __('邀请系统不可用', 'aksrt')], true);
|
||||
}
|
||||
return trim($content) !== '';
|
||||
}
|
||||
|
||||
private function consume_card_keys(int $product_id, string $options_key, int $qty, int $user_id, string $order_number, int $order_item_id): string {
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('card_key');
|
||||
if ($table === '' || $order_item_id <= 0 || $wpdb->query('START TRANSACTION') === false) return '';
|
||||
|
||||
$where_opt = '';
|
||||
$params = [$product_id];
|
||||
if ($options_key !== '' && $options_key !== '_default') {
|
||||
$where_opt = ' AND options_key = %s';
|
||||
$params[] = $options_key;
|
||||
} else {
|
||||
$where_opt = " AND (options_key = '' OR options_key = '_default')";
|
||||
}
|
||||
$params[] = $qty;
|
||||
|
||||
$keys = $wpdb->get_results($wpdb->prepare(
|
||||
"SELECT id, card_code FROM {$table} WHERE product_id = %d AND status = 'available'{$where_opt} ORDER BY id ASC LIMIT %d FOR UPDATE",
|
||||
...$params
|
||||
), ARRAY_A);
|
||||
if (count((array) $keys) !== $qty) {
|
||||
$wpdb->query('ROLLBACK');
|
||||
return __('卡密已售罄', 'aksrt');
|
||||
}
|
||||
|
||||
$codes = [];
|
||||
$ids = [];
|
||||
foreach ($keys as $key) {
|
||||
$codes[] = (string) $key['card_code'];
|
||||
$ids[] = (int) $key['id'];
|
||||
}
|
||||
$placeholders = implode(',', array_fill(0, count($ids), '%d'));
|
||||
$updated = $wpdb->query($wpdb->prepare(
|
||||
"UPDATE {$table} SET status = 'used', order_number = %s, used_by = %d, used_at = %s WHERE status = 'available' AND id IN ({$placeholders})",
|
||||
array_merge([$order_number, $user_id, current_time('mysql')], $ids)
|
||||
));
|
||||
$content = implode("\n", $codes);
|
||||
$item_table = $this->order_item_table();
|
||||
$persisted = $item_table !== '' ? $wpdb->update(
|
||||
$item_table,
|
||||
['auto_delivery_content' => $content],
|
||||
['id' => $order_item_id, 'order_number' => $order_number],
|
||||
['%s'],
|
||||
['%d', '%s']
|
||||
) : false;
|
||||
if ($updated !== $qty || $persisted === false || $wpdb->query('COMMIT') === false) {
|
||||
$wpdb->query('ROLLBACK');
|
||||
return '';
|
||||
}
|
||||
return $content;
|
||||
}
|
||||
|
||||
private function create_invite_codes(int $qty, int $user_id, string $order_number): string {
|
||||
if (!class_exists('\AKSRT\Credits\Invite')) {
|
||||
return __('邀请系统不可用', 'aksrt');
|
||||
}
|
||||
$invite = \AKSRT\Credits\Invite::get_instance();
|
||||
$codes = [];
|
||||
for ($i = 0; $i < $qty; $i++) {
|
||||
$code = $invite->generate_code($user_id);
|
||||
if ($code) {
|
||||
$codes[] = $code;
|
||||
}
|
||||
}
|
||||
return implode("\n", $codes);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* Order Items (§6.5 多商品订单子表)
|
||||
* ============================================================ */
|
||||
|
||||
private function order_item_table(): string {
|
||||
return \AKSRT\Core\DBMigrator::table('order_item');
|
||||
}
|
||||
|
||||
public function save_order_items(string $order_number, array $items): void {
|
||||
global $wpdb;
|
||||
$table = $this->order_item_table();
|
||||
if ($table === '') return;
|
||||
|
||||
foreach ($items as $item) {
|
||||
$wpdb->insert($table, [
|
||||
'order_number' => $order_number,
|
||||
'product_id' => (int) ($item['product_id'] ?? 0),
|
||||
'product_title' => sanitize_text_field((string) ($item['title'] ?? '')),
|
||||
'options_key' => sanitize_text_field((string) ($item['options_key'] ?? '')),
|
||||
'options_label' => sanitize_text_field((string) ($item['options_label'] ?? '')),
|
||||
'quantity' => max(1, (int) ($item['quantity'] ?? 1)),
|
||||
'unit_price' => (float) ($item['unit_price'] ?? $item['price'] ?? 0),
|
||||
'total_price' => (float) ($item['total_price'] ?? $item['line_total'] ?? 0),
|
||||
'discount_amount' => (float) ($item['discount_amount'] ?? 0),
|
||||
'shipping_fee' => (float) ($item['shipping_fee'] ?? 0),
|
||||
'pay_price' => (float) ($item['pay_price'] ?? $item['line_total'] ?? 0),
|
||||
'shipping_type' => sanitize_key((string) ($item['shipping_type'] ?? 'express')),
|
||||
'auto_delivery_content'=> ($item['auto_delivery_content'] ?? null),
|
||||
'discount_hit' => isset($item['applied_discounts']) ? wp_json_encode($item['applied_discounts']) : null,
|
||||
'gift_data' => isset($item['gifts']) ? wp_json_encode($item['gifts']) : null,
|
||||
'image' => esc_url_raw((string) ($item['image'] ?? '')),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function get_order_items(string $order_number): array {
|
||||
global $wpdb;
|
||||
$table = $this->order_item_table();
|
||||
if ($table === '') return [];
|
||||
|
||||
$rows = $wpdb->get_results($wpdb->prepare(
|
||||
"SELECT * FROM {$table} WHERE order_number = %s ORDER BY id ASC",
|
||||
$order_number
|
||||
), ARRAY_A);
|
||||
|
||||
return array_map(static function ($row) {
|
||||
return [
|
||||
'id' => (int) $row['id'],
|
||||
'product_id' => (int) $row['product_id'],
|
||||
'product_title' => (string) $row['product_title'],
|
||||
'options_key' => (string) $row['options_key'],
|
||||
'options_label' => (string) $row['options_label'],
|
||||
'quantity' => (int) $row['quantity'],
|
||||
'unit_price' => (float) $row['unit_price'],
|
||||
'total_price' => (float) $row['total_price'],
|
||||
'discount_amount' => (float) $row['discount_amount'],
|
||||
'shipping_fee' => (float) $row['shipping_fee'],
|
||||
'pay_price' => (float) $row['pay_price'],
|
||||
'shipping_type' => (string) $row['shipping_type'],
|
||||
'auto_delivery_content' => $row['auto_delivery_content'],
|
||||
'discount_hit' => json_decode($row['discount_hit'] ?? '', true) ?: [],
|
||||
'gift_data' => json_decode($row['gift_data'] ?? '', true) ?: [],
|
||||
'image' => (string) $row['image'],
|
||||
];
|
||||
}, (array) $rows);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* Enhanced Fulfillment (§6.5 履约链路)
|
||||
* ============================================================ */
|
||||
|
||||
public function handle_order_paid_enhanced(string $order_number, array $order): void {
|
||||
$items = $this->get_order_items($order_number);
|
||||
|
||||
if (empty($items)) {
|
||||
$this->handle_order_paid($order_number, $order);
|
||||
return;
|
||||
}
|
||||
|
||||
$user_id = (int) ($order['user_id'] ?? 0);
|
||||
$auto_delivery_parts = [];
|
||||
$auto_delivery_successes = 0;
|
||||
|
||||
foreach ($items as $item) {
|
||||
$pid = (int) $item['product_id'];
|
||||
$qty = max(1, (int) $item['quantity']);
|
||||
$opt_key = (string) $item['options_key'];
|
||||
if ($pid <= 0) continue;
|
||||
|
||||
$this->deduct_option_stock($pid, $opt_key, $qty);
|
||||
|
||||
$sales = (int) get_post_meta($pid, '_aksrt_sales_volume', true);
|
||||
update_post_meta($pid, '_aksrt_sales_volume', $sales + $qty);
|
||||
|
||||
$shipping_type = (string) get_post_meta($pid, '_aksrt_shipping_type', true);
|
||||
if ($shipping_type === 'auto') {
|
||||
$content = $this->execute_auto_delivery($pid, $opt_key, $qty, $user_id, $order_number, (int) $item['id']);
|
||||
if ($content !== '') {
|
||||
$auto_delivery_parts[] = $content;
|
||||
$config = $this->get_auto_delivery_config($pid);
|
||||
$persisted = $config['type'] === 'card_pass'
|
||||
? true
|
||||
: $this->update_order_item_delivery($item['id'], $content);
|
||||
if ($persisted && $this->auto_delivery_succeeded($pid, $content, $qty)) {
|
||||
$auto_delivery_successes++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->process_rebate($order_number, $order, $items);
|
||||
$this->process_gifts($order_number, $order, $items);
|
||||
|
||||
if (!empty($auto_delivery_parts)) {
|
||||
$this->send_auto_delivery_notification($user_id, $order_number, $auto_delivery_parts);
|
||||
}
|
||||
|
||||
if ($auto_delivery_successes === count($items) && class_exists('\AKSRT\Payment\OrderRepository')) {
|
||||
\AKSRT\Payment\OrderRepository::get_instance()->update_status(
|
||||
$order_number,
|
||||
\AKSRT\Payment\Order::STATUS_COMPLETED,
|
||||
['note' => __('自动发货订单已完成。', 'aksrt')]
|
||||
);
|
||||
}
|
||||
|
||||
do_action('aksrt_shop_fulfillment_done', $order_number, $order, $items);
|
||||
}
|
||||
|
||||
private function update_order_item_delivery(int $item_id, string $content): bool {
|
||||
global $wpdb;
|
||||
$table = $this->order_item_table();
|
||||
if ($table === '') return false;
|
||||
$updated = $wpdb->update($table, ['auto_delivery_content' => $content], ['id' => $item_id], ['%s'], ['%d']);
|
||||
if ($updated === false) return false;
|
||||
if ($updated === 0) {
|
||||
$stored = $wpdb->get_var($wpdb->prepare("SELECT auto_delivery_content FROM {$table} WHERE id = %d", $item_id));
|
||||
return is_string($stored) && hash_equals($stored, $content);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private function send_auto_delivery_notification(int $user_id, string $order_number, array $parts): void {
|
||||
if (!class_exists('AKSRT_Notification_Service')) return;
|
||||
|
||||
$content = sprintf(
|
||||
__("订单 %s 已自动发货:\n\n%s", 'aksrt'),
|
||||
$order_number,
|
||||
implode("\n---\n", $parts)
|
||||
);
|
||||
|
||||
AKSRT_Notification_Service::send($user_id, 'auto_delivery', $content, [
|
||||
'order_number' => $order_number,
|
||||
]);
|
||||
|
||||
if (function_exists('aksrt_send_event_email')) {
|
||||
$user = get_userdata($user_id);
|
||||
if ($user && !empty($user->user_email)) {
|
||||
aksrt_send_event_email($user->user_email, sprintf(__('订单 %s 自动发货通知', 'aksrt'), $order_number), $content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function process_rebate(string $order_number, array $order, array $items): void {
|
||||
$user_id = (int) ($order['user_id'] ?? 0);
|
||||
if ($user_id <= 0) return;
|
||||
|
||||
$referrer_id = 0;
|
||||
if (class_exists('\AKSRT\Credits\Invite')) {
|
||||
$referrer_id = \AKSRT\Credits\Invite::get_instance()->get_referrer($user_id);
|
||||
}
|
||||
if ($referrer_id <= 0) return;
|
||||
|
||||
$rebate_table = \AKSRT\Core\DBMigrator::table('rebate_log');
|
||||
if ($rebate_table === '') return;
|
||||
|
||||
global $wpdb;
|
||||
foreach ($items as $item) {
|
||||
$pid = (int) $item['product_id'];
|
||||
$pay_price = (float) $item['pay_price'];
|
||||
if ($pid <= 0 || $pay_price <= 0) continue;
|
||||
|
||||
$rebate_rate = (float) get_post_meta($pid, '_aksrt_rebate_rate', true);
|
||||
if ($rebate_rate <= 0 && class_exists('AKSRT\Shop\TermMeta')) {
|
||||
$resolved = TermMeta::resolve_inheritance($pid, 'rebate');
|
||||
$rebate_rate = (float) ($resolved ?: 0);
|
||||
}
|
||||
if ($rebate_rate <= 0) continue;
|
||||
|
||||
$amount = round($pay_price * $rebate_rate / 100, 2);
|
||||
if ($amount <= 0) continue;
|
||||
|
||||
$wpdb->insert($rebate_table, [
|
||||
'user_id' => $referrer_id,
|
||||
'order_number' => $order_number,
|
||||
'source_user_id' => $user_id,
|
||||
'amount' => $amount,
|
||||
'type' => 'order',
|
||||
'status' => 'pending',
|
||||
'created_at' => current_time('mysql'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function process_gifts(string $order_number, array $order, array $items): void {
|
||||
$user_id = (int) ($order['user_id'] ?? 0);
|
||||
if ($user_id <= 0) return;
|
||||
$claim_key = '_aksrt_shop_gifts_' . sanitize_key($order_number);
|
||||
if (!add_user_meta($user_id, $claim_key, 'processing', true)) return;
|
||||
|
||||
foreach ($items as $item) {
|
||||
$gifts = $item['gift_data'] ?? [];
|
||||
if (empty($gifts)) continue;
|
||||
|
||||
foreach ($gifts as $gift) {
|
||||
$gift_type = (string) ($gift['type'] ?? '');
|
||||
$gift_value = $gift['value'] ?? 0;
|
||||
|
||||
if ($gift_type === 'credits' && class_exists('\AKSRT\Credits\Credits') && (int) $gift_value > 0) {
|
||||
\AKSRT\Credits\Credits::add((int) $gift_value, $user_id, 'shop_gift', sprintf(__('订单 %s 赠品', 'aksrt'), $order_number));
|
||||
} elseif (in_array($gift_type, ['level_integral', 'experience'], true) && class_exists('\AKSRT\User\Level') && (int) $gift_value > 0) {
|
||||
\AKSRT\User\Level::add($user_id, 'shop_gift', (int) $gift_value, true);
|
||||
} elseif ($gift_type === 'vip_days' && (int) $gift_value > 0) {
|
||||
$current = (int) get_user_meta($user_id, 'aksrt_vip_expires', true);
|
||||
$base = max($current, time());
|
||||
update_user_meta($user_id, 'aksrt_vip_expires', $base + ((int) $gift_value * DAY_IN_SECONDS));
|
||||
// 赠送 VIP 天数时确保至少 VIP1;不降级已有 VIP2。
|
||||
$level = (int) get_user_meta($user_id, 'aksrt_vip_level', true);
|
||||
if ($level < 1) {
|
||||
update_user_meta($user_id, 'aksrt_vip_level', 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
update_user_meta($user_id, $claim_key, '1');
|
||||
}
|
||||
}
|
||||
@@ -1,341 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Shop;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class ProductMetaBox {
|
||||
|
||||
private static $instance = null;
|
||||
|
||||
public static function get_instance(): self {
|
||||
if (null === self::$instance) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
public function init(): void {
|
||||
if (!is_admin()) {
|
||||
return;
|
||||
}
|
||||
add_action('add_meta_boxes', [$this, 'register_meta_box']);
|
||||
add_action('save_post_aksrt_product', [$this, 'save'], 10, 2);
|
||||
}
|
||||
|
||||
public function register_meta_box(): void {
|
||||
add_meta_box(
|
||||
'aksrt_product_commerce',
|
||||
__('商品商业设置', 'aksrt'),
|
||||
[$this, 'render'],
|
||||
'aksrt_product',
|
||||
'normal',
|
||||
'high'
|
||||
);
|
||||
}
|
||||
|
||||
public function render(\WP_Post $post): void {
|
||||
wp_nonce_field('aksrt_product_meta_save', 'aksrt_product_meta_nonce');
|
||||
|
||||
$price = (float) get_post_meta($post->ID, '_aksrt_price', true);
|
||||
$stock = (int) get_post_meta($post->ID, '_aksrt_stock', true);
|
||||
$image = (string) get_post_meta($post->ID, '_aksrt_product_image', true);
|
||||
$gallery = get_post_meta($post->ID, '_aksrt_product_gallery', true);
|
||||
$gallery = is_array($gallery) ? $gallery : [];
|
||||
$shipping_mode = (string) get_post_meta($post->ID, '_aksrt_shipping_mode', true);
|
||||
$shipping_fee = (float) get_post_meta($post->ID, '_aksrt_shipping_fee', true);
|
||||
$shipping_fee_type = (string) (get_post_meta($post->ID, '_aksrt_shipping_fee_type', true) ?: 'fixed');
|
||||
$shipping_fee_free = (float) get_post_meta($post->ID, '_aksrt_shipping_fee_free_amount', true);
|
||||
$shipping_delivery = (string) get_post_meta($post->ID, '_aksrt_shipping_delivery_desc', true);
|
||||
$shipping_tip = (string) get_post_meta($post->ID, '_aksrt_shipping_tip', true);
|
||||
$after_sale_policy = (string) get_post_meta($post->ID, '_aksrt_after_sale_policy', true);
|
||||
$support_refund = get_post_meta($post->ID, '_aksrt_support_refund', true);
|
||||
$support_refund = $support_refund === '' ? '1' : (string) $support_refund;
|
||||
$service = (string) get_post_meta($post->ID, '_aksrt_service', true);
|
||||
$service_tip = (string) get_post_meta($post->ID, '_aksrt_service_tip', true);
|
||||
$limit_buy = get_post_meta($post->ID, '_aksrt_limit_buy', true);
|
||||
$limit_buy = $limit_buy === '' ? -1 : (int) $limit_buy;
|
||||
$limit_buy_note = (string) get_post_meta($post->ID, '_aksrt_limit_buy_note', true);
|
||||
$stock_type = (string) (get_post_meta($post->ID, '_aksrt_stock_type', true) ?: 'all');
|
||||
$pay_modo = (string) (get_post_meta($post->ID, '_aksrt_pay_modo', true) ?: '0');
|
||||
$start_price = (float) get_post_meta($post->ID, '_aksrt_start_price', true);
|
||||
$product_rebate = (string) get_post_meta($post->ID, '_aksrt_product_rebate', true);
|
||||
$product_params = (string) get_post_meta($post->ID, '_aksrt_product_params', true);
|
||||
$user_required = (string) get_post_meta($post->ID, '_aksrt_user_required', true);
|
||||
$delivery_note = (string) get_post_meta($post->ID, '_aksrt_delivery_note', true);
|
||||
$marketing = (string) get_post_meta($post->ID, '_aksrt_marketing_headline', true);
|
||||
|
||||
$shipping_modes = [
|
||||
'' => __('默认(继承设置)', 'aksrt'),
|
||||
'express' => __('快递物流', 'aksrt'),
|
||||
'auto' => __('自动发货', 'aksrt'),
|
||||
'manual' => __('手动发货', 'aksrt'),
|
||||
'virtual' => __('虚拟商品', 'aksrt'),
|
||||
];
|
||||
$stock_types = [
|
||||
'all' => __('统一库存', 'aksrt'),
|
||||
'opts' => __('按选项/变体', 'aksrt'),
|
||||
];
|
||||
$fee_types = [
|
||||
'free' => __('包邮', 'aksrt'),
|
||||
'fixed' => __('固定运费', 'aksrt'),
|
||||
'amount_free' => __('满额包邮', 'aksrt'),
|
||||
];
|
||||
?>
|
||||
<style>
|
||||
.aksrt-pmb-product .row { display:grid; grid-template-columns: 140px 1fr; gap: 10px 14px; margin: 0 0 12px; align-items:start; }
|
||||
.aksrt-pmb-product .row label { font-weight:600; padding-top:6px; }
|
||||
.aksrt-pmb-product input[type="text"],
|
||||
.aksrt-pmb-product input[type="number"],
|
||||
.aksrt-pmb-product input[type="url"],
|
||||
.aksrt-pmb-product select,
|
||||
.aksrt-pmb-product textarea { width:100%; max-width:560px; }
|
||||
.aksrt-pmb-product .hint { color:#646970; font-size:12px; margin:4px 0 0; }
|
||||
.aksrt-pmb-product .grid-2 { display:grid; grid-template-columns:1fr 1fr; gap:12px; max-width:720px; }
|
||||
.aksrt-pmb-product h4 { margin:18px 0 10px; padding-top:12px; border-top:1px solid #dcdcde; }
|
||||
</style>
|
||||
<div class="aksrt-pmb-product">
|
||||
<h4><?php esc_html_e('价格与库存', 'aksrt'); ?></h4>
|
||||
<div class="grid-2">
|
||||
<div class="row" style="grid-template-columns:1fr;">
|
||||
<label for="aksrt_price"><?php esc_html_e('价格(元)', 'aksrt'); ?></label>
|
||||
<input type="number" id="aksrt_price" name="aksrt_price" min="0" step="0.01" value="<?php echo esc_attr((string) $price); ?>">
|
||||
</div>
|
||||
<div class="row" style="grid-template-columns:1fr;">
|
||||
<label for="aksrt_stock"><?php esc_html_e('库存', 'aksrt'); ?></label>
|
||||
<input type="number" id="aksrt_stock" name="aksrt_stock" min="-1" step="1" value="<?php echo esc_attr((string) $stock); ?>">
|
||||
<p class="hint"><?php esc_html_e('-1 表示无限库存。', 'aksrt'); ?></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid-2">
|
||||
<div class="row" style="grid-template-columns:1fr;">
|
||||
<label for="aksrt_pay_modo"><?php esc_html_e('支付方式', 'aksrt'); ?></label>
|
||||
<select id="aksrt_pay_modo" name="aksrt_pay_modo">
|
||||
<option value="0" <?php selected($pay_modo, '0'); ?>><?php esc_html_e('现金', 'aksrt'); ?></option>
|
||||
<option value="points" <?php selected($pay_modo, 'points'); ?>><?php esc_html_e('积分', 'aksrt'); ?></option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="row" style="grid-template-columns:1fr;">
|
||||
<label for="aksrt_start_price"><?php esc_html_e('划线价 / 起售价', 'aksrt'); ?></label>
|
||||
<input type="number" id="aksrt_start_price" name="aksrt_start_price" min="0" step="0.01" value="<?php echo esc_attr((string) $start_price); ?>">
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid-2">
|
||||
<div class="row" style="grid-template-columns:1fr;">
|
||||
<label for="aksrt_stock_type"><?php esc_html_e('库存类型', 'aksrt'); ?></label>
|
||||
<select id="aksrt_stock_type" name="aksrt_stock_type">
|
||||
<?php foreach ($stock_types as $key => $label) : ?>
|
||||
<option value="<?php echo esc_attr($key); ?>" <?php selected($stock_type, $key); ?>><?php echo esc_html($label); ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="row" style="grid-template-columns:1fr;">
|
||||
<label for="aksrt_limit_buy"><?php esc_html_e('限购数量', 'aksrt'); ?></label>
|
||||
<input type="number" id="aksrt_limit_buy" name="aksrt_limit_buy" min="-1" step="1" value="<?php echo esc_attr((string) $limit_buy); ?>">
|
||||
<p class="hint"><?php esc_html_e('-1 不限购。', 'aksrt'); ?></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label for="aksrt_limit_buy_note"><?php esc_html_e('限购说明', 'aksrt'); ?></label>
|
||||
<input type="text" id="aksrt_limit_buy_note" name="aksrt_limit_buy_note" value="<?php echo esc_attr($limit_buy_note); ?>">
|
||||
</div>
|
||||
<div class="row">
|
||||
<label for="aksrt_product_rebate"><?php esc_html_e('推广返佣', 'aksrt'); ?></label>
|
||||
<div>
|
||||
<input type="text" id="aksrt_product_rebate" name="aksrt_product_rebate" value="<?php echo esc_attr($product_rebate); ?>" placeholder="<?php esc_attr_e('如 10 或 10%', 'aksrt'); ?>">
|
||||
<p class="hint"><?php esc_html_e('可填固定金额或百分比,具体解析由返佣服务处理。', 'aksrt'); ?></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4><?php esc_html_e('素材', 'aksrt'); ?></h4>
|
||||
<div class="row">
|
||||
<label for="aksrt_product_image"><?php esc_html_e('主图 URL', 'aksrt'); ?></label>
|
||||
<div>
|
||||
<input type="url" id="aksrt_product_image" name="aksrt_product_image" value="<?php echo esc_attr($image); ?>" placeholder="https://">
|
||||
<p class="hint"><?php esc_html_e('也可使用特色图片;此处优先作为商品主图。', 'aksrt'); ?></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label for="aksrt_product_gallery"><?php esc_html_e('图集 URL', 'aksrt'); ?></label>
|
||||
<div>
|
||||
<textarea id="aksrt_product_gallery" name="aksrt_product_gallery" rows="4" placeholder="<?php esc_attr_e('每行一个图片 URL', 'aksrt'); ?>"><?php echo esc_textarea(implode("\n", $gallery)); ?></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label for="aksrt_product_params"><?php esc_html_e('商品参数', 'aksrt'); ?></label>
|
||||
<div>
|
||||
<textarea id="aksrt_product_params" name="aksrt_product_params" rows="4" placeholder="<?php esc_attr_e('每行一条参数,如 品牌:AKSRT', 'aksrt'); ?>"><?php echo esc_textarea($product_params); ?></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4><?php esc_html_e('发货与售后', 'aksrt'); ?></h4>
|
||||
<div class="grid-2">
|
||||
<div class="row" style="grid-template-columns:1fr;">
|
||||
<label for="aksrt_shipping_mode"><?php esc_html_e('发货方式', 'aksrt'); ?></label>
|
||||
<select id="aksrt_shipping_mode" name="aksrt_shipping_mode">
|
||||
<?php foreach ($shipping_modes as $key => $label) : ?>
|
||||
<option value="<?php echo esc_attr($key); ?>" <?php selected($shipping_mode, $key); ?>><?php echo esc_html($label); ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="row" style="grid-template-columns:1fr;">
|
||||
<label for="aksrt_shipping_fee_type"><?php esc_html_e('运费类型', 'aksrt'); ?></label>
|
||||
<select id="aksrt_shipping_fee_type" name="aksrt_shipping_fee_type">
|
||||
<?php foreach ($fee_types as $key => $label) : ?>
|
||||
<option value="<?php echo esc_attr($key); ?>" <?php selected($shipping_fee_type, $key); ?>><?php echo esc_html($label); ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid-2">
|
||||
<div class="row" style="grid-template-columns:1fr;">
|
||||
<label for="aksrt_shipping_fee"><?php esc_html_e('运费金额(元)', 'aksrt'); ?></label>
|
||||
<input type="number" id="aksrt_shipping_fee" name="aksrt_shipping_fee" min="0" step="0.01" value="<?php echo esc_attr((string) $shipping_fee); ?>">
|
||||
</div>
|
||||
<div class="row" style="grid-template-columns:1fr;">
|
||||
<label for="aksrt_shipping_fee_free_amount"><?php esc_html_e('满额包邮门槛', 'aksrt'); ?></label>
|
||||
<input type="number" id="aksrt_shipping_fee_free_amount" name="aksrt_shipping_fee_free_amount" min="0" step="0.01" value="<?php echo esc_attr((string) $shipping_fee_free); ?>">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label for="aksrt_shipping_delivery_desc"><?php esc_html_e('发货时效说明', 'aksrt'); ?></label>
|
||||
<input type="text" id="aksrt_shipping_delivery_desc" name="aksrt_shipping_delivery_desc" value="<?php echo esc_attr($shipping_delivery); ?>" placeholder="<?php esc_attr_e('如 付款后 24 小时内发货', 'aksrt'); ?>">
|
||||
</div>
|
||||
<div class="row">
|
||||
<label for="aksrt_shipping_tip"><?php esc_html_e('物流提示', 'aksrt'); ?></label>
|
||||
<input type="text" id="aksrt_shipping_tip" name="aksrt_shipping_tip" value="<?php echo esc_attr($shipping_tip); ?>">
|
||||
</div>
|
||||
<div class="row">
|
||||
<label for="aksrt_after_sale_policy"><?php esc_html_e('售后说明', 'aksrt'); ?></label>
|
||||
<textarea id="aksrt_after_sale_policy" name="aksrt_after_sale_policy" rows="3"><?php echo esc_textarea($after_sale_policy); ?></textarea>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label for="aksrt_support_refund"><?php esc_html_e('支持退款', 'aksrt'); ?></label>
|
||||
<div>
|
||||
<label>
|
||||
<input type="checkbox" id="aksrt_support_refund" name="aksrt_support_refund" value="1" <?php checked($support_refund, '1'); ?>>
|
||||
<?php esc_html_e('允许申请退款/售后', 'aksrt'); ?>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label for="aksrt_service"><?php esc_html_e('服务承诺', 'aksrt'); ?></label>
|
||||
<textarea id="aksrt_service" name="aksrt_service" rows="3" placeholder="<?php esc_attr_e('每行一条服务承诺', 'aksrt'); ?>"><?php echo esc_textarea($service); ?></textarea>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label for="aksrt_service_tip"><?php esc_html_e('服务提示', 'aksrt'); ?></label>
|
||||
<input type="text" id="aksrt_service_tip" name="aksrt_service_tip" value="<?php echo esc_attr($service_tip); ?>">
|
||||
</div>
|
||||
<div class="row">
|
||||
<label for="aksrt_user_required"><?php esc_html_e('下单需填信息', 'aksrt'); ?></label>
|
||||
<textarea id="aksrt_user_required" name="aksrt_user_required" rows="3" placeholder="<?php esc_attr_e('每行一项,如 QQ / 游戏角色名', 'aksrt'); ?>"><?php echo esc_textarea($user_required); ?></textarea>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label for="aksrt_delivery_note"><?php esc_html_e('发货备注', 'aksrt'); ?></label>
|
||||
<input type="text" id="aksrt_delivery_note" name="aksrt_delivery_note" value="<?php echo esc_attr($delivery_note); ?>">
|
||||
</div>
|
||||
<div class="row">
|
||||
<label for="aksrt_marketing_headline"><?php esc_html_e('营销标题', 'aksrt'); ?></label>
|
||||
<input type="text" id="aksrt_marketing_headline" name="aksrt_marketing_headline" value="<?php echo esc_attr($marketing); ?>">
|
||||
</div>
|
||||
<p class="hint">
|
||||
<?php esc_html_e('SKU 变体请使用下方「商品变体」面板;列表列已显示价格/库存/销量。', 'aksrt'); ?>
|
||||
</p>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
|
||||
public function save(int $post_id, \WP_Post $post): void {
|
||||
if (!isset($_POST['aksrt_product_meta_nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash((string) $_POST['aksrt_product_meta_nonce'])), 'aksrt_product_meta_save')) {
|
||||
return;
|
||||
}
|
||||
if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
|
||||
return;
|
||||
}
|
||||
if ($post->post_type !== 'aksrt_product') {
|
||||
return;
|
||||
}
|
||||
if (!current_user_can('edit_post', $post_id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$price = isset($_POST['aksrt_price']) ? max(0, (float) wp_unslash($_POST['aksrt_price'])) : 0;
|
||||
$stock = isset($_POST['aksrt_stock']) ? (int) wp_unslash($_POST['aksrt_stock']) : 0;
|
||||
if ($stock < -1) {
|
||||
$stock = -1;
|
||||
}
|
||||
|
||||
$image = isset($_POST['aksrt_product_image']) ? esc_url_raw((string) wp_unslash($_POST['aksrt_product_image'])) : '';
|
||||
$gallery_raw = isset($_POST['aksrt_product_gallery']) ? (string) wp_unslash($_POST['aksrt_product_gallery']) : '';
|
||||
$gallery = array_values(array_filter(array_map(
|
||||
static function ($line) {
|
||||
return esc_url_raw(trim((string) $line));
|
||||
},
|
||||
preg_split('/\r\n|\r|\n/', $gallery_raw) ?: []
|
||||
)));
|
||||
|
||||
$shipping_mode = isset($_POST['aksrt_shipping_mode']) ? sanitize_key((string) wp_unslash($_POST['aksrt_shipping_mode'])) : '';
|
||||
$shipping_fee = isset($_POST['aksrt_shipping_fee']) ? max(0, (float) wp_unslash($_POST['aksrt_shipping_fee'])) : 0;
|
||||
$shipping_fee_type = isset($_POST['aksrt_shipping_fee_type']) ? sanitize_key((string) wp_unslash($_POST['aksrt_shipping_fee_type'])) : 'fixed';
|
||||
if (!in_array($shipping_fee_type, ['free', 'fixed', 'amount_free'], true)) {
|
||||
$shipping_fee_type = 'fixed';
|
||||
}
|
||||
$shipping_fee_free = isset($_POST['aksrt_shipping_fee_free_amount']) ? max(0, (float) wp_unslash($_POST['aksrt_shipping_fee_free_amount'])) : 0;
|
||||
$shipping_delivery = isset($_POST['aksrt_shipping_delivery_desc']) ? sanitize_text_field((string) wp_unslash($_POST['aksrt_shipping_delivery_desc'])) : '';
|
||||
$shipping_tip = isset($_POST['aksrt_shipping_tip']) ? sanitize_text_field((string) wp_unslash($_POST['aksrt_shipping_tip'])) : '';
|
||||
$policy = isset($_POST['aksrt_after_sale_policy']) ? sanitize_textarea_field((string) wp_unslash($_POST['aksrt_after_sale_policy'])) : '';
|
||||
$support_refund = !empty($_POST['aksrt_support_refund']) ? '1' : '0';
|
||||
$service = isset($_POST['aksrt_service']) ? sanitize_textarea_field((string) wp_unslash($_POST['aksrt_service'])) : '';
|
||||
$service_tip = isset($_POST['aksrt_service_tip']) ? sanitize_text_field((string) wp_unslash($_POST['aksrt_service_tip'])) : '';
|
||||
$limit_buy = isset($_POST['aksrt_limit_buy']) ? max(-1, min(9999, (int) wp_unslash($_POST['aksrt_limit_buy']))) : -1;
|
||||
$limit_buy_note = isset($_POST['aksrt_limit_buy_note']) ? sanitize_text_field((string) wp_unslash($_POST['aksrt_limit_buy_note'])) : '';
|
||||
$stock_type = isset($_POST['aksrt_stock_type']) ? sanitize_key((string) wp_unslash($_POST['aksrt_stock_type'])) : 'all';
|
||||
if (!in_array($stock_type, ['all', 'opts'], true)) {
|
||||
$stock_type = 'all';
|
||||
}
|
||||
$pay_modo = isset($_POST['aksrt_pay_modo']) ? sanitize_key((string) wp_unslash($_POST['aksrt_pay_modo'])) : '0';
|
||||
if (!in_array($pay_modo, ['0', 'points'], true)) {
|
||||
$pay_modo = '0';
|
||||
}
|
||||
$start_price = isset($_POST['aksrt_start_price']) ? max(0, (float) wp_unslash($_POST['aksrt_start_price'])) : 0;
|
||||
$product_rebate = isset($_POST['aksrt_product_rebate']) ? sanitize_text_field((string) wp_unslash($_POST['aksrt_product_rebate'])) : '';
|
||||
$product_params = isset($_POST['aksrt_product_params']) ? sanitize_textarea_field((string) wp_unslash($_POST['aksrt_product_params'])) : '';
|
||||
$user_required = isset($_POST['aksrt_user_required']) ? sanitize_textarea_field((string) wp_unslash($_POST['aksrt_user_required'])) : '';
|
||||
$delivery_note = isset($_POST['aksrt_delivery_note']) ? sanitize_text_field((string) wp_unslash($_POST['aksrt_delivery_note'])) : '';
|
||||
$marketing = isset($_POST['aksrt_marketing_headline']) ? sanitize_text_field((string) wp_unslash($_POST['aksrt_marketing_headline'])) : '';
|
||||
|
||||
update_post_meta($post_id, '_aksrt_price', $price);
|
||||
update_post_meta($post_id, '_aksrt_stock', $stock);
|
||||
update_post_meta($post_id, '_aksrt_product_image', $image);
|
||||
update_post_meta($post_id, '_aksrt_product_gallery', $gallery);
|
||||
update_post_meta($post_id, '_aksrt_shipping_mode', $shipping_mode);
|
||||
update_post_meta($post_id, '_aksrt_shipping_type', $shipping_mode);
|
||||
update_post_meta($post_id, '_aksrt_shipping_fee', $shipping_fee);
|
||||
update_post_meta($post_id, '_aksrt_shipping_fee_type', $shipping_fee_type);
|
||||
update_post_meta($post_id, '_aksrt_shipping_fee_free_amount', $shipping_fee_free);
|
||||
update_post_meta($post_id, '_aksrt_shipping_delivery_desc', $shipping_delivery);
|
||||
update_post_meta($post_id, '_aksrt_shipping_tip', $shipping_tip);
|
||||
update_post_meta($post_id, '_aksrt_after_sale_policy', $policy);
|
||||
update_post_meta($post_id, '_aksrt_support_refund', $support_refund);
|
||||
update_post_meta($post_id, '_aksrt_service', $service);
|
||||
update_post_meta($post_id, '_aksrt_service_tip', $service_tip);
|
||||
update_post_meta($post_id, '_aksrt_limit_buy', $limit_buy);
|
||||
update_post_meta($post_id, '_aksrt_limit_buy_note', $limit_buy_note);
|
||||
update_post_meta($post_id, '_aksrt_stock_type', $stock_type);
|
||||
update_post_meta($post_id, '_aksrt_pay_modo', $pay_modo);
|
||||
update_post_meta($post_id, '_aksrt_start_price', $start_price);
|
||||
update_post_meta($post_id, '_aksrt_product_rebate', $product_rebate);
|
||||
update_post_meta($post_id, '_aksrt_product_params', $product_params);
|
||||
update_post_meta($post_id, '_aksrt_user_required', $user_required);
|
||||
update_post_meta($post_id, '_aksrt_delivery_note', $delivery_note);
|
||||
update_post_meta($post_id, '_aksrt_marketing_headline', $marketing);
|
||||
|
||||
// Keep sales counter present for list columns.
|
||||
if (get_post_meta($post_id, '_aksrt_sales', true) === '') {
|
||||
update_post_meta($post_id, '_aksrt_sales', 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,512 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Shop;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class ProductReview {
|
||||
|
||||
private static $instance = null;
|
||||
|
||||
const COMMENT_TYPE = 'aksrt_product_review';
|
||||
|
||||
const RATING_LEVELS = [
|
||||
5 => '超赞',
|
||||
4 => '很棒',
|
||||
3 => '不错',
|
||||
2 => '一般',
|
||||
1 => '较差',
|
||||
0 => '很差',
|
||||
];
|
||||
|
||||
public static function get_instance(): self {
|
||||
if (null === self::$instance) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
public function init(): void {
|
||||
add_action('aksrt_daily_cron', [$this, 'run_auto_positive_cron']);
|
||||
}
|
||||
|
||||
public function submit_review(int $user_id, string $order_number, int $product_id, array $data): array {
|
||||
if ($user_id <= 0 || $order_number === '' || $product_id <= 0) {
|
||||
return ['success' => false, 'message' => __('参数错误', 'aksrt')];
|
||||
}
|
||||
|
||||
if (!$this->can_review($user_id, $order_number, $product_id)) {
|
||||
return ['success' => false, 'message' => __('当前订单不可评价', 'aksrt')];
|
||||
}
|
||||
|
||||
if ($this->has_reviewed($user_id, $order_number, $product_id)) {
|
||||
return ['success' => false, 'message' => __('已评价过该商品', 'aksrt')];
|
||||
}
|
||||
|
||||
$rating_product = max(0, min(5, (int) ($data['rating_product'] ?? 5)));
|
||||
$rating_service = max(0, min(5, (int) ($data['rating_service'] ?? 5)));
|
||||
$rating_shipping = max(0, min(5, (int) ($data['rating_shipping'] ?? 5)));
|
||||
$average = round(($rating_product + $rating_service + $rating_shipping) / 3, 1);
|
||||
|
||||
$content = sanitize_textarea_field((string) ($data['content'] ?? ''));
|
||||
$images = [];
|
||||
$max_images = $this->get_max_images();
|
||||
foreach ((array) ($data['images'] ?? []) as $img) {
|
||||
if (count($images) >= $max_images) {
|
||||
break;
|
||||
}
|
||||
$stored = $this->normalize_review_image((string) $img, $user_id);
|
||||
if ($stored !== '') {
|
||||
$images[] = $stored;
|
||||
}
|
||||
}
|
||||
|
||||
$comment_id = wp_insert_comment([
|
||||
'comment_post_ID' => $product_id,
|
||||
'user_id' => $user_id,
|
||||
'comment_content' => $content ?: __('好评!', 'aksrt'),
|
||||
'comment_type' => self::COMMENT_TYPE,
|
||||
'comment_approved' => 1,
|
||||
]);
|
||||
|
||||
if (!$comment_id) {
|
||||
return ['success' => false, 'message' => __('评价提交失败', 'aksrt')];
|
||||
}
|
||||
|
||||
update_comment_meta($comment_id, '_aksrt_order_number', $order_number);
|
||||
update_comment_meta($comment_id, '_aksrt_rating_product', $rating_product);
|
||||
update_comment_meta($comment_id, '_aksrt_rating_service', $rating_service);
|
||||
update_comment_meta($comment_id, '_aksrt_rating_shipping', $rating_shipping);
|
||||
update_comment_meta($comment_id, '_aksrt_rating_average', $average);
|
||||
if (!empty($images)) {
|
||||
update_comment_meta($comment_id, '_aksrt_review_images', $images);
|
||||
}
|
||||
|
||||
$scores = $this->refresh_product_scores($product_id);
|
||||
update_post_meta($product_id, '_aksrt_score_average', (float) ($scores['average'] ?? $average));
|
||||
|
||||
if (class_exists('\AKSRT\Payment\OrderRepository')) {
|
||||
$order = \AKSRT\Payment\OrderRepository::get_instance()->get($order_number);
|
||||
$status = (string) ($order['status'] ?? '');
|
||||
if ($status === \AKSRT\Payment\Order::STATUS_PENDING_REVIEW) {
|
||||
\AKSRT\Payment\OrderRepository::get_instance()->update_status(
|
||||
$order_number,
|
||||
\AKSRT\Payment\Order::STATUS_COMPLETED,
|
||||
['note' => __('用户已评价', 'aksrt')]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return ['success' => true, 'comment_id' => $comment_id];
|
||||
}
|
||||
|
||||
public function can_review(int $user_id, string $order_number, int $product_id): bool {
|
||||
if (!class_exists('\AKSRT\Payment\OrderRepository')) return false;
|
||||
|
||||
$order = \AKSRT\Payment\OrderRepository::get_instance()->get($order_number);
|
||||
if (!$order || (int) ($order['user_id'] ?? 0) !== $user_id) return false;
|
||||
|
||||
$status = (string) ($order['status'] ?? '');
|
||||
if (!in_array($status, [
|
||||
\AKSRT\Payment\Order::STATUS_COMPLETED,
|
||||
\AKSRT\Payment\Order::STATUS_PENDING_REVIEW,
|
||||
\AKSRT\Payment\Order::STATUS_DELIVERED,
|
||||
], true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (class_exists('\AKSRT\Payment\AfterSale')) {
|
||||
$tickets = \AKSRT\Payment\AfterSale::get_instance()->get_list([
|
||||
'order_number' => $order_number,
|
||||
'limit' => 1,
|
||||
]);
|
||||
foreach (($tickets['items'] ?? []) as $ticket) {
|
||||
if (in_array(($ticket['status'] ?? ''), ['pending', 'processing'], true)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function has_reviewed(int $user_id, string $order_number, int $product_id): bool {
|
||||
$comments = get_comments([
|
||||
'post_id' => $product_id,
|
||||
'user_id' => $user_id,
|
||||
'type' => self::COMMENT_TYPE,
|
||||
'number' => 1,
|
||||
'meta_query' => [
|
||||
['key' => '_aksrt_order_number', 'value' => $order_number],
|
||||
],
|
||||
]);
|
||||
return !empty($comments);
|
||||
}
|
||||
|
||||
public function get_reviews(int $product_id, array $args = []): array {
|
||||
$page = max(1, (int) ($args['page'] ?? 1));
|
||||
$per_page = max(1, min(50, (int) ($args['per_page'] ?? 10)));
|
||||
$filter = sanitize_key((string) ($args['filter'] ?? ''));
|
||||
|
||||
$query_args = [
|
||||
'post_id' => $product_id,
|
||||
'type' => self::COMMENT_TYPE,
|
||||
'status' => 'approve',
|
||||
'number' => $per_page,
|
||||
'offset' => ($page - 1) * $per_page,
|
||||
'orderby' => 'comment_date',
|
||||
'order' => 'DESC',
|
||||
];
|
||||
|
||||
if ($filter === 'good') {
|
||||
$query_args['meta_query'] = [['key' => '_aksrt_rating_average', 'value' => 4, 'compare' => '>=', 'type' => 'NUMERIC']];
|
||||
} elseif ($filter === 'bad') {
|
||||
$query_args['meta_query'] = [['key' => '_aksrt_rating_average', 'value' => 3, 'compare' => '<', 'type' => 'NUMERIC']];
|
||||
} elseif ($filter === 'has_image') {
|
||||
$query_args['meta_query'] = [['key' => '_aksrt_review_images', 'compare' => 'EXISTS']];
|
||||
}
|
||||
|
||||
$comments = get_comments($query_args);
|
||||
$total = (int) get_comments(array_merge($query_args, ['count' => true, 'number' => 0, 'offset' => 0]));
|
||||
|
||||
$items = [];
|
||||
foreach ($comments as $comment) {
|
||||
$cid = (int) $comment->comment_ID;
|
||||
$items[] = [
|
||||
'id' => $cid,
|
||||
'user_id' => (int) $comment->user_id,
|
||||
'user_name' => (string) $comment->comment_author,
|
||||
'user_avatar' => function_exists('aksrt_get_avatar_url') ? aksrt_get_avatar_url((int) $comment->user_id) : get_avatar_url((int) $comment->user_id),
|
||||
'content' => (string) $comment->comment_content,
|
||||
'date' => (string) $comment->comment_date,
|
||||
'rating_product' => (int) get_comment_meta($cid, '_aksrt_rating_product', true),
|
||||
'rating_service' => (int) get_comment_meta($cid, '_aksrt_rating_service', true),
|
||||
'rating_shipping' => (int) get_comment_meta($cid, '_aksrt_rating_shipping', true),
|
||||
'rating_average' => (float) get_comment_meta($cid, '_aksrt_rating_average', true),
|
||||
'images' => (array) (get_comment_meta($cid, '_aksrt_review_images', true) ?: []),
|
||||
'order_number' => (string) get_comment_meta($cid, '_aksrt_order_number', true),
|
||||
];
|
||||
}
|
||||
|
||||
return ['items' => $items, 'total' => $total, 'page' => $page, 'per_page' => $per_page];
|
||||
}
|
||||
|
||||
public function get_product_scores(int $product_id): array {
|
||||
$cached = get_post_meta($product_id, '_aksrt_review_scores', true);
|
||||
if (is_array($cached)) {
|
||||
return $cached;
|
||||
}
|
||||
return $this->refresh_product_scores($product_id);
|
||||
}
|
||||
|
||||
public function refresh_product_scores(int $product_id): array {
|
||||
global $wpdb;
|
||||
|
||||
$results = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT COUNT(*) as total,
|
||||
AVG(CAST(m.meta_value AS DECIMAL(3,1))) as average
|
||||
FROM {$wpdb->comments} c
|
||||
JOIN {$wpdb->commentmeta} m ON c.comment_ID = m.comment_id AND m.meta_key = '_aksrt_rating_average'
|
||||
WHERE c.comment_post_ID = %d AND c.comment_type = %s AND c.comment_approved = '1'",
|
||||
$product_id, self::COMMENT_TYPE
|
||||
), ARRAY_A);
|
||||
|
||||
$total = (int) ($results['total'] ?? 0);
|
||||
$average = $total > 0 ? round((float) ($results['average'] ?? 0), 1) : 0;
|
||||
|
||||
$good_count = (int) $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$wpdb->comments} c
|
||||
JOIN {$wpdb->commentmeta} m ON c.comment_ID = m.comment_id AND m.meta_key = '_aksrt_rating_average'
|
||||
WHERE c.comment_post_ID = %d AND c.comment_type = %s AND c.comment_approved = '1'
|
||||
AND CAST(m.meta_value AS DECIMAL(3,1)) >= 4",
|
||||
$product_id, self::COMMENT_TYPE
|
||||
));
|
||||
|
||||
$bad_count = (int) $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$wpdb->comments} c
|
||||
JOIN {$wpdb->commentmeta} m ON c.comment_ID = m.comment_id AND m.meta_key = '_aksrt_rating_average'
|
||||
WHERE c.comment_post_ID = %d AND c.comment_type = %s AND c.comment_approved = '1'
|
||||
AND CAST(m.meta_value AS DECIMAL(3,1)) < 3",
|
||||
$product_id, self::COMMENT_TYPE
|
||||
));
|
||||
|
||||
$has_image = (int) $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$wpdb->comments} c
|
||||
JOIN {$wpdb->commentmeta} m ON c.comment_ID = m.comment_id AND m.meta_key = '_aksrt_review_images'
|
||||
WHERE c.comment_post_ID = %d AND c.comment_type = %s AND c.comment_approved = '1'",
|
||||
$product_id, self::COMMENT_TYPE
|
||||
));
|
||||
|
||||
$scores = [
|
||||
'total' => $total,
|
||||
'average' => $average,
|
||||
'good' => $good_count,
|
||||
'bad' => $bad_count,
|
||||
'has_image' => $has_image,
|
||||
'level' => self::RATING_LEVELS[min(5, max(0, (int) round($average)))] ?? '',
|
||||
];
|
||||
|
||||
update_post_meta($product_id, '_aksrt_review_scores', $scores);
|
||||
return $scores;
|
||||
}
|
||||
|
||||
public function run_auto_positive_cron(): void {
|
||||
if (!class_exists('\AKSRT\Payment\OrderRepository') || !class_exists('\AKSRT\Settings\Settings')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$shop = \AKSRT\Settings\Settings::get_shop_settings();
|
||||
$max_day = max(1, (int) ($shop['timing']['comment_max_day'] ?? 15));
|
||||
$cutoff = date('Y-m-d H:i:s', strtotime("-{$max_day} days"));
|
||||
|
||||
$repo = \AKSRT\Payment\OrderRepository::get_instance();
|
||||
$orders = $repo->get_orders_admin([
|
||||
'status' => \AKSRT\Payment\Order::STATUS_PENDING_REVIEW,
|
||||
'limit' => 200,
|
||||
]);
|
||||
|
||||
foreach ($orders as $order) {
|
||||
$completed_at = (string) ($order['completed_at'] ?? $order['delivered_at'] ?? '');
|
||||
if ($completed_at === '' || $completed_at > $cutoff) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$user_id = (int) ($order['user_id'] ?? 0);
|
||||
$order_number = (string) ($order['order_number'] ?? '');
|
||||
if ($user_id <= 0 || $order_number === '') continue;
|
||||
|
||||
$items = class_exists('AKSRT\\Shop\\Shop')
|
||||
? Shop::get_instance()->get_order_items($order_number)
|
||||
: [];
|
||||
|
||||
$product_ids = array_column($items, 'product_id');
|
||||
if (empty($product_ids) && !empty($order['product_id'])) {
|
||||
$product_ids = [(int) $order['product_id']];
|
||||
}
|
||||
|
||||
foreach ($product_ids as $pid) {
|
||||
if (!$this->has_reviewed($user_id, $order_number, (int) $pid)) {
|
||||
$this->submit_review($user_id, $order_number, (int) $pid, [
|
||||
'rating_product' => 5,
|
||||
'rating_service' => 5,
|
||||
'rating_shipping' => 5,
|
||||
'content' => __('系统默认好评', 'aksrt'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$repo->update_status($order_number, \AKSRT\Payment\Order::STATUS_COMPLETED, [
|
||||
'note' => sprintf(__('超过 %d 天未评价,系统自动好评。', 'aksrt'), $max_day),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remaining seconds until auto-positive review, or 0 if unknown/closed.
|
||||
*/
|
||||
public function get_auto_review_remaining_seconds(string $order_number): int {
|
||||
if (!class_exists('\AKSRT\Payment\OrderRepository') || !class_exists('\AKSRT\Settings\Settings')) {
|
||||
return 0;
|
||||
}
|
||||
$order = \AKSRT\Payment\OrderRepository::get_instance()->get($order_number);
|
||||
if (!$order) {
|
||||
return 0;
|
||||
}
|
||||
$anchor = (string) ($order['delivered_at'] ?? $order['completed_at'] ?? $order['updated_at'] ?? $order['created_at'] ?? '');
|
||||
if ($anchor === '') {
|
||||
return 0;
|
||||
}
|
||||
$shop = \AKSRT\Settings\Settings::get_shop_settings();
|
||||
$max_day = max(1, (int) ($shop['timing']['comment_max_day'] ?? 15));
|
||||
$deadline = strtotime($anchor . ' +' . $max_day . ' days');
|
||||
if (!$deadline) {
|
||||
return 0;
|
||||
}
|
||||
$remain = $deadline - time();
|
||||
return $remain > 0 ? (int) $remain : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether shipping score row should be shown for this order.
|
||||
*/
|
||||
public function order_needs_shipping_score(array $order): bool {
|
||||
$method = is_array($order['shipping_method'] ?? null) ? $order['shipping_method'] : [];
|
||||
$value = strtolower((string) ($method['value'] ?? $method['type'] ?? $order['delivery_type'] ?? ''));
|
||||
if ($value === '') {
|
||||
// Prefer showing logistics when shipping records or fee exist.
|
||||
if (!empty($order['shipping_records']) || !empty($order['shipping']['count'])) {
|
||||
return true;
|
||||
}
|
||||
$fee = (float) ($order['shipping_fee'] ?? ($order['shipping_method']['fee'] ?? 0));
|
||||
return $fee > 0;
|
||||
}
|
||||
if (in_array($value, ['virtual', 'auto', 'none', 'offline', 'self', 'pickup', 'no_shipping'], true)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build order review modal HTML (zibll order_comment_modal parity).
|
||||
*
|
||||
* @return array{success:bool,html?:string,message?:string}
|
||||
*/
|
||||
public function get_review_modal_html(int $user_id, string $order_number): array {
|
||||
if ($user_id <= 0) {
|
||||
return ['success' => false, 'message' => __('请先登录', 'aksrt')];
|
||||
}
|
||||
if ($order_number === '' || !class_exists('\AKSRT\Payment\OrderRepository')) {
|
||||
return ['success' => false, 'message' => __('订单不存在', 'aksrt')];
|
||||
}
|
||||
|
||||
$order = \AKSRT\Payment\OrderRepository::get_instance()->get($order_number);
|
||||
if (!$order || (int) ($order['user_id'] ?? 0) !== $user_id) {
|
||||
return ['success' => false, 'message' => __('订单数据异常', 'aksrt')];
|
||||
}
|
||||
|
||||
$product_id = 0;
|
||||
$items = class_exists('\AKSRT\Shop\Shop')
|
||||
? Shop::get_instance()->get_order_items($order_number)
|
||||
: [];
|
||||
if (!empty($items[0]['product_id'])) {
|
||||
$product_id = (int) $items[0]['product_id'];
|
||||
} elseif (!empty($order['product_id'])) {
|
||||
$product_id = (int) $order['product_id'];
|
||||
}
|
||||
|
||||
if ($product_id <= 0) {
|
||||
return ['success' => false, 'message' => __('订单商品无效', 'aksrt')];
|
||||
}
|
||||
|
||||
if ($this->has_reviewed($user_id, $order_number, $product_id)) {
|
||||
return ['success' => false, 'message' => __('订单已评价', 'aksrt')];
|
||||
}
|
||||
|
||||
if (!$this->can_review($user_id, $order_number, $product_id)) {
|
||||
return ['success' => false, 'message' => __('当前订单不可评价', 'aksrt')];
|
||||
}
|
||||
|
||||
$shop = class_exists('\AKSRT\Settings\Settings')
|
||||
? \AKSRT\Settings\Settings::get_shop_settings()
|
||||
: [];
|
||||
$placeholder = (string) ($shop['timing']['comment_placeholder'] ?? '');
|
||||
if ($placeholder === '') {
|
||||
$placeholder = __('展开说说对商品的看法吧~', 'aksrt');
|
||||
}
|
||||
$img_num = max(0, min(9, (int) ($shop['timing']['comment_img_num'] ?? 3)));
|
||||
$need_shipping = $this->order_needs_shipping_score($order);
|
||||
$remain = $this->get_auto_review_remaining_seconds($order_number);
|
||||
|
||||
$score_html = '';
|
||||
$score_html .= $this->render_score_row(__('商品评分', 'aksrt'), 'product');
|
||||
if ($need_shipping) {
|
||||
$score_html .= $this->render_score_row(__('物流服务', 'aksrt'), 'shipping');
|
||||
}
|
||||
$score_html .= $this->render_score_row(__('服务态度', 'aksrt'), 'service');
|
||||
|
||||
$img_html = '';
|
||||
if ($img_num > 0) {
|
||||
$img_html = '<div class="form-upload mb10">';
|
||||
$img_html .= '<div class="em09 muted-2-color mb6">' . esc_html__('图片', 'aksrt') . '</div>';
|
||||
$img_html .= '<div class="preview" data-aksrt-review-preview data-max="' . esc_attr((string) $img_num) . '">';
|
||||
$img_html .= '<div class="add" data-aksrt-review-add role="button" tabindex="0" aria-label="' . esc_attr__('添加图片', 'aksrt') . '"></div>';
|
||||
$img_html .= '</div>';
|
||||
$img_html .= '<input class="hide" type="file" data-aksrt-review-file multiple accept="image/gif,image/jpeg,image/jpg,image/png,image/webp" />';
|
||||
$img_html .= '</div>';
|
||||
}
|
||||
|
||||
$title = '<b>' . esc_html__('评价订单', 'aksrt') . '</b>';
|
||||
if ($remain > 0) {
|
||||
$title .= '<span class="px12 muted-2-color ml6">'
|
||||
. '<span data-aksrt-review-countdown="' . esc_attr((string) $remain) . '">' . esc_html($this->format_countdown($remain)) . '</span>'
|
||||
. esc_html__('后自动好评', 'aksrt')
|
||||
. '</span>';
|
||||
}
|
||||
|
||||
$html = '<div class="touch border-title">' . $title . '</div>';
|
||||
$html .= '<button type="button" class="close abs-close aksrt-remote-modal__close" data-aksrt-remote-modal-close aria-label="' . esc_attr__('关闭', 'aksrt') . '">×</button>';
|
||||
$html .= '<form class="order-comment-from form-upload" data-aksrt-order-review-form data-order-number="' . esc_attr($order_number) . '" data-product-id="' . esc_attr((string) $product_id) . '" data-need-shipping="' . ($need_shipping ? '1' : '0') . '">';
|
||||
$html .= '<div class="mb20">' . $score_html . '</div>';
|
||||
$html .= '<div class="mb20"><textarea class="form-control" name="comment" rows="4" placeholder="' . esc_attr($placeholder) . '"></textarea></div>';
|
||||
$html .= $img_html;
|
||||
$html .= '<div class="review-error" data-aksrt-review-error hidden></div>';
|
||||
$html .= '<div class="but-average modal-buts">';
|
||||
$html .= '<button type="button" class="but" data-aksrt-remote-modal-close>' . esc_html__('取消', 'aksrt') . '</button>';
|
||||
$html .= '<button type="submit" class="but c-blue btn-primary" data-aksrt-review-submit><i class="fa fa-check" aria-hidden="true"></i> ' . esc_html__('确认提交', 'aksrt') . '</button>';
|
||||
$html .= '</div></form>';
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'html' => $html,
|
||||
'product_id' => $product_id,
|
||||
'need_shipping' => $need_shipping,
|
||||
'img_max' => $img_num,
|
||||
'remain_seconds' => $remain,
|
||||
];
|
||||
}
|
||||
|
||||
private function render_score_row(string $label, string $key): string {
|
||||
return '<div class="mb10 shop-score-box flex ac" data-aksrt-score-box data-score-key="' . esc_attr($key) . '" score-value="0">'
|
||||
. '<div class="em09 muted-2-color score-title">' . esc_html($label) . '</div>'
|
||||
. '<div class="score-icon em16 mr10 ml20" style="height:30px;"></div>'
|
||||
. '<div class="score-text em09 muted-2-color"></div>'
|
||||
. '<input class="score-input" type="hidden" name="score[' . esc_attr($key) . ']" value="0">'
|
||||
. '</div>';
|
||||
}
|
||||
|
||||
private function format_countdown(int $seconds): string {
|
||||
$seconds = max(0, $seconds);
|
||||
$d = intdiv($seconds, 86400);
|
||||
$h = intdiv($seconds % 86400, 3600);
|
||||
$m = intdiv($seconds % 3600, 60);
|
||||
$s = $seconds % 60;
|
||||
if ($d > 0) {
|
||||
return sprintf('%d天%02d:%02d:%02d', $d, $h, $m, $s);
|
||||
}
|
||||
return sprintf('%02d:%02d:%02d', $h, $m, $s);
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept https URL or data:image/*;base64,... and return a public URL.
|
||||
*/
|
||||
private function normalize_review_image(string $raw, int $user_id): string {
|
||||
$raw = trim($raw);
|
||||
if ($raw === '') {
|
||||
return '';
|
||||
}
|
||||
if (preg_match('#^https?://#i', $raw)) {
|
||||
return esc_url_raw($raw);
|
||||
}
|
||||
if (!preg_match('#^data:image/(png|jpe?g|gif|webp);base64,#i', $raw, $m)) {
|
||||
return '';
|
||||
}
|
||||
$binary = base64_decode(substr($raw, strpos($raw, ',') + 1), true);
|
||||
if ($binary === false || $binary === '') {
|
||||
return '';
|
||||
}
|
||||
if (strlen($binary) > 5 * 1024 * 1024) {
|
||||
return '';
|
||||
}
|
||||
$ext = strtolower($m[1]);
|
||||
if ($ext === 'jpeg') {
|
||||
$ext = 'jpg';
|
||||
}
|
||||
$filename = 'review-' . $user_id . '-' . wp_generate_password(8, false) . '.' . $ext;
|
||||
$upload = wp_upload_bits($filename, null, $binary);
|
||||
if (!empty($upload['error']) || empty($upload['url'])) {
|
||||
return '';
|
||||
}
|
||||
return esc_url_raw((string) $upload['url']);
|
||||
}
|
||||
|
||||
private function get_max_images(): int {
|
||||
if (!class_exists('\AKSRT\Settings\Settings')) {
|
||||
return 9;
|
||||
}
|
||||
$shop = \AKSRT\Settings\Settings::get_shop_settings();
|
||||
return max(0, min(20, (int) ($shop['timing']['comment_img_num'] ?? 9)));
|
||||
}
|
||||
}
|
||||
@@ -1,356 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Shop;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class ProductService {
|
||||
|
||||
private Shop $shop;
|
||||
|
||||
public function __construct(Shop $shop) {
|
||||
$this->shop = $shop;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* Product meta helpers
|
||||
* ============================================================ */
|
||||
|
||||
public function get_term_options(string $taxonomy): array {
|
||||
$terms = get_terms([
|
||||
'taxonomy' => $taxonomy,
|
||||
'hide_empty' => false,
|
||||
'orderby' => 'name',
|
||||
'order' => 'ASC',
|
||||
]);
|
||||
|
||||
if (is_wp_error($terms) || empty($terms)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_map(static function ($term) {
|
||||
return [
|
||||
'id' => (int) $term->term_id,
|
||||
'name' => (string) $term->name,
|
||||
];
|
||||
}, $terms);
|
||||
}
|
||||
|
||||
public function get_product_gallery(int $product_id): array {
|
||||
$gallery = get_post_meta($product_id, '_aksrt_product_gallery', true);
|
||||
if (!is_array($gallery)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter(array_map('esc_url_raw', $gallery)));
|
||||
}
|
||||
|
||||
public function get_product_shipping_profile(int $product_id): array {
|
||||
return [
|
||||
'mode' => sanitize_key((string) get_post_meta($product_id, '_aksrt_shipping_mode', true)) ?: 'express',
|
||||
'fee' => (float) get_post_meta($product_id, '_aksrt_shipping_fee', true),
|
||||
'delivery_note' => (string) get_post_meta($product_id, '_aksrt_delivery_note', true),
|
||||
];
|
||||
}
|
||||
|
||||
public function get_product_after_sale_profile(int $product_id): array {
|
||||
return [
|
||||
'policy' => (string) get_post_meta($product_id, '_aksrt_after_sale_policy', true),
|
||||
'support_refund' => get_post_meta($product_id, '_aksrt_support_refund', true) !== '0',
|
||||
];
|
||||
}
|
||||
|
||||
public function get_product_marketing_profile(int $product_id): array {
|
||||
return [
|
||||
'headline' => (string) get_post_meta($product_id, '_aksrt_marketing_headline', true),
|
||||
'shipping_tip' => (string) get_post_meta($product_id, '_aksrt_shipping_tip', true),
|
||||
'service_tip' => (string) get_post_meta($product_id, '_aksrt_service_tip', true),
|
||||
];
|
||||
}
|
||||
|
||||
public function get_payment_method_options(): array {
|
||||
$methods = [];
|
||||
|
||||
if (class_exists('\AKSRT\Payment\Gateway')) {
|
||||
foreach (\AKSRT\Payment\Gateway::get_instance()->get_all() as $id => $gateway) {
|
||||
if (!method_exists($gateway, 'is_configured') || !$gateway->is_configured()) {
|
||||
continue;
|
||||
}
|
||||
$methods[$id] = [
|
||||
'value' => $id,
|
||||
'label' => method_exists($gateway, 'get_name') ? (string) $gateway->get_name() : $id,
|
||||
'description' => $this->gateway_method_description((string) $id),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (class_exists('\AKSRT\Settings\Settings') && \AKSRT\Settings\Settings::credits_enabled()) {
|
||||
$methods['points'] = [
|
||||
'value' => 'points',
|
||||
'label' => __('积分兑换', 'aksrt'),
|
||||
'description' => __('使用积分购买积分商品。', 'aksrt'),
|
||||
];
|
||||
}
|
||||
|
||||
// Fallback only when no configured gateways (and no points).
|
||||
if (empty($methods)) {
|
||||
$methods['manual_review'] = [
|
||||
'value' => 'manual_review',
|
||||
'label' => __('人工确认', 'aksrt'),
|
||||
'description' => __('暂无可用支付网关,保留待支付订单由管理员确认。', 'aksrt'),
|
||||
];
|
||||
}
|
||||
|
||||
return $methods;
|
||||
}
|
||||
|
||||
private function gateway_method_description(string $id): string {
|
||||
switch ($id) {
|
||||
case 'balance':
|
||||
return __('即时完成支付并写回订单状态。', 'aksrt');
|
||||
case 'alipay':
|
||||
return __('跳转支付宝完成支付。', 'aksrt');
|
||||
case 'wechatpay':
|
||||
return __('微信扫码或跳转完成支付。', 'aksrt');
|
||||
case 'paypal':
|
||||
return __('跳转 PayPal 完成支付。', 'aksrt');
|
||||
case 'stripe':
|
||||
return __('跳转 Stripe Checkout 完成支付。', 'aksrt');
|
||||
case 'epay_alipay':
|
||||
return __('经易支付跳转支付宝完成支付。', 'aksrt');
|
||||
case 'epay_wxpay':
|
||||
return __('经易支付跳转微信完成支付。', 'aksrt');
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
public function get_shipping_method_options(): array {
|
||||
return [
|
||||
'express' => [
|
||||
'value' => 'express',
|
||||
'label' => __('快递配送', 'aksrt'),
|
||||
'description' => __('标准快递,支持物流跟踪。', 'aksrt'),
|
||||
'fee' => 12.0,
|
||||
],
|
||||
'ems' => [
|
||||
'value' => 'ems',
|
||||
'label' => __('EMS', 'aksrt'),
|
||||
'description' => __('偏远地区可选。', 'aksrt'),
|
||||
'fee' => 18.0,
|
||||
],
|
||||
'download' => [
|
||||
'value' => 'download',
|
||||
'label' => __('自动发货', 'aksrt'),
|
||||
'description' => __('适用于数字商品。', 'aksrt'),
|
||||
'fee' => 0.0,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function get_limit_buy_config(int $product_id): array {
|
||||
$raw = get_post_meta($product_id, '_aksrt_limit_buy_config', true);
|
||||
if (!is_array($raw)) {
|
||||
$legacy = (int) get_post_meta($product_id, '_aksrt_limit_buy', true);
|
||||
return ['default' => $legacy > 0 ? $legacy : -1, 'desc' => ''];
|
||||
}
|
||||
return [
|
||||
'default' => (int) ($raw['default'] ?? -1),
|
||||
'vip' => (int) ($raw['vip'] ?? -1),
|
||||
'vip2' => (int) ($raw['vip2'] ?? -1),
|
||||
'auth' => (int) ($raw['auth'] ?? -1),
|
||||
'desc' => sanitize_text_field((string) ($raw['desc'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
public function get_product_services(int $product_id): array {
|
||||
$raw = get_post_meta($product_id, '_aksrt_services', true);
|
||||
if (!is_array($raw)) return [];
|
||||
return array_values(array_filter(array_map(static function ($s) {
|
||||
$name = sanitize_text_field((string) ($s['name'] ?? ''));
|
||||
if ($name === '') return null;
|
||||
return [
|
||||
'name' => $name,
|
||||
'desc' => sanitize_text_field((string) ($s['desc'] ?? '')),
|
||||
'icon' => esc_url_raw((string) ($s['icon'] ?? '')),
|
||||
];
|
||||
}, $raw)));
|
||||
}
|
||||
|
||||
public function get_product_params(int $product_id): array {
|
||||
$raw = get_post_meta($product_id, '_aksrt_product_params', true);
|
||||
if (!is_array($raw)) return [];
|
||||
return array_values(array_filter(array_map(static function ($p) {
|
||||
$name = sanitize_text_field((string) ($p['name'] ?? ''));
|
||||
if ($name === '') return null;
|
||||
return [
|
||||
'name' => $name,
|
||||
'value' => sanitize_text_field((string) ($p['value'] ?? '')),
|
||||
];
|
||||
}, $raw)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve product main image with 3-tier fallback:
|
||||
* 1. _aksrt_product_image (custom main image)
|
||||
* 2. WordPress featured image (post thumbnail)
|
||||
* 3. First gallery image from _aksrt_product_gallery
|
||||
*/
|
||||
public function resolve_main_image(int $product_id): string {
|
||||
$custom = (string) get_post_meta($product_id, '_aksrt_product_image', true);
|
||||
if ($custom !== '') {
|
||||
return esc_url_raw($custom);
|
||||
}
|
||||
$thumb = get_the_post_thumbnail_url($product_id, 'large');
|
||||
if ($thumb) {
|
||||
return (string) $thumb;
|
||||
}
|
||||
$gallery = get_post_meta($product_id, '_aksrt_product_gallery', true);
|
||||
if (is_array($gallery) && !empty($gallery)) {
|
||||
$first = reset($gallery);
|
||||
if ($first) {
|
||||
return esc_url_raw((string) $first);
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
public function build_product_card_view(int $product_id): array {
|
||||
$post = get_post($product_id);
|
||||
if (!$post || $post->post_type !== 'aksrt_product') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$stock_raw = (int) get_post_meta($product_id, '_aksrt_stock', true);
|
||||
$is_unlimited = $stock_raw <= -1;
|
||||
|
||||
return [
|
||||
'id' => $product_id,
|
||||
'title' => (string) get_the_title($product_id),
|
||||
'excerpt' => (string) get_the_excerpt($product_id),
|
||||
'price' => (float) get_post_meta($product_id, '_aksrt_price', true),
|
||||
'image' => $this->resolve_main_image($product_id),
|
||||
'stock' => $stock_raw,
|
||||
'is_unlimited' => $is_unlimited,
|
||||
'sales' => (int) get_post_meta($product_id, '_aksrt_sales', true),
|
||||
'status' => (string) get_post_status($product_id),
|
||||
'created' => get_the_date('Y-m-d H:i', $product_id),
|
||||
'permalink' => get_permalink($product_id) ?: '',
|
||||
];
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* Product Variants / SKU
|
||||
* ============================================================ */
|
||||
|
||||
/**
|
||||
* Get variants table name.
|
||||
*/
|
||||
public function variants_table(): string {
|
||||
global $wpdb;
|
||||
return $wpdb->prefix . 'aksrt_product_variant';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all variants for a product.
|
||||
*/
|
||||
public function get_variants(int $product_id): array {
|
||||
global $wpdb;
|
||||
$rows = $wpdb->get_results($wpdb->prepare(
|
||||
"SELECT * FROM {$this->variants_table()} WHERE product_id = %d ORDER BY sort_order ASC, id ASC",
|
||||
$product_id
|
||||
), ARRAY_A);
|
||||
|
||||
if (!$rows) return [];
|
||||
|
||||
return array_map(function ($row) {
|
||||
$row['id'] = (int) $row['id'];
|
||||
$row['product_id'] = (int) $row['product_id'];
|
||||
$row['price'] = (float) $row['price'];
|
||||
$row['stock'] = (int) $row['stock'];
|
||||
$row['sort_order'] = (int) $row['sort_order'];
|
||||
$row['attributes'] = json_decode($row['attributes'] ?? '[]', true) ?: [];
|
||||
return $row;
|
||||
}, $rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single variant by ID.
|
||||
*/
|
||||
public function get_variant(int $variant_id): ?array {
|
||||
global $wpdb;
|
||||
$row = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT * FROM {$this->variants_table()} WHERE id = %d",
|
||||
$variant_id
|
||||
), ARRAY_A);
|
||||
if (!$row) return null;
|
||||
$row['id'] = (int) $row['id'];
|
||||
$row['product_id'] = (int) $row['product_id'];
|
||||
$row['price'] = (float) $row['price'];
|
||||
$row['stock'] = (int) $row['stock'];
|
||||
$row['attributes'] = json_decode($row['attributes'] ?? '[]', true) ?: [];
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save variants for a product (replace all).
|
||||
*/
|
||||
public function save_variants(int $product_id, array $variants): array {
|
||||
global $wpdb;
|
||||
$table = $this->variants_table();
|
||||
|
||||
// Delete existing variants
|
||||
$wpdb->delete($table, ['product_id' => $product_id]);
|
||||
|
||||
$saved = [];
|
||||
foreach ($variants as $index => $v) {
|
||||
$data = [
|
||||
'product_id' => $product_id,
|
||||
'sku' => sanitize_text_field((string) ($v['sku'] ?? '')),
|
||||
'attributes' => wp_json_encode($v['attributes'] ?? []),
|
||||
'price' => max(0, (float) ($v['price'] ?? 0)),
|
||||
'stock' => max(0, (int) ($v['stock'] ?? 0)),
|
||||
'image' => esc_url_raw((string) ($v['image'] ?? '')),
|
||||
'sort_order' => $index,
|
||||
'created_at' => current_time('mysql', true),
|
||||
];
|
||||
$wpdb->insert($table, $data);
|
||||
$saved[] = array_merge($data, ['id' => (int) $wpdb->insert_id]);
|
||||
}
|
||||
|
||||
// Update product meta flag
|
||||
update_post_meta($product_id, '_aksrt_has_variants', count($saved) > 0 ? '1' : '0');
|
||||
|
||||
return $saved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a single variant.
|
||||
*/
|
||||
public function delete_variant(int $variant_id): bool {
|
||||
global $wpdb;
|
||||
$variant = $this->get_variant($variant_id);
|
||||
if (!$variant) return false;
|
||||
|
||||
$result = $wpdb->delete($this->variants_table(), ['id' => $variant_id]);
|
||||
if ($result) {
|
||||
// Update product meta flag
|
||||
$remaining = $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$this->variants_table()} WHERE product_id = %d",
|
||||
$variant['product_id']
|
||||
));
|
||||
update_post_meta($variant['product_id'], '_aksrt_has_variants', $remaining > 0 ? '1' : '0');
|
||||
}
|
||||
return (bool) $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a product has variants.
|
||||
*/
|
||||
public function has_variants(int $product_id): bool {
|
||||
return get_post_meta($product_id, '_aksrt_has_variants', true) === '1';
|
||||
}
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Shop;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class ProductVariantsMetaBox {
|
||||
|
||||
private static $instance = null;
|
||||
|
||||
public static function get_instance(): self {
|
||||
if (null === self::$instance) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
public function init(): void {
|
||||
if (!is_admin()) {
|
||||
return;
|
||||
}
|
||||
add_action('add_meta_boxes', [$this, 'register_meta_box']);
|
||||
add_action('save_post_aksrt_product', [$this, 'save'], 30, 2);
|
||||
}
|
||||
|
||||
public function register_meta_box(): void {
|
||||
add_meta_box(
|
||||
'aksrt_product_variants',
|
||||
__('SKU 变体', 'aksrt'),
|
||||
[$this, 'render'],
|
||||
'aksrt_product',
|
||||
'normal',
|
||||
'default'
|
||||
);
|
||||
}
|
||||
|
||||
public function render(\WP_Post $post): void {
|
||||
wp_nonce_field('aksrt_product_variants_save', 'aksrt_product_variants_nonce');
|
||||
|
||||
$variants = [];
|
||||
if (class_exists('AKSRT\\Shop\\Shop')) {
|
||||
$variants = Shop::get_instance()->get_variants((int) $post->ID);
|
||||
}
|
||||
|
||||
if (!$variants) {
|
||||
$variants = [[
|
||||
'sku' => '',
|
||||
'attributes' => [],
|
||||
'price' => 0,
|
||||
'stock' => 0,
|
||||
'image' => '',
|
||||
]];
|
||||
}
|
||||
?>
|
||||
<style>
|
||||
.aksrt-variants-table { width:100%; border-collapse:collapse; }
|
||||
.aksrt-variants-table th, .aksrt-variants-table td { border:1px solid #dcdcde; padding:6px; vertical-align:top; }
|
||||
.aksrt-variants-table input { width:100%; }
|
||||
.aksrt-variants-table .col-sku { width:14%; }
|
||||
.aksrt-variants-table .col-attrs { width:28%; }
|
||||
.aksrt-variants-table .col-price, .aksrt-variants-table .col-stock { width:12%; }
|
||||
.aksrt-variants-table .col-image { width:28%; }
|
||||
.aksrt-variants-hint { color:#646970; font-size:12px; margin:8px 0; }
|
||||
</style>
|
||||
<p class="aksrt-variants-hint">
|
||||
<?php esc_html_e('属性格式:名称:值,多项用分号分隔,例如 颜色:红;尺寸:L。保存时会整体覆盖该商品变体列表。', 'aksrt'); ?>
|
||||
</p>
|
||||
<table class="aksrt-variants-table" id="aksrt-variants-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-sku"><?php esc_html_e('SKU', 'aksrt'); ?></th>
|
||||
<th class="col-attrs"><?php esc_html_e('属性', 'aksrt'); ?></th>
|
||||
<th class="col-price"><?php esc_html_e('价格', 'aksrt'); ?></th>
|
||||
<th class="col-stock"><?php esc_html_e('库存', 'aksrt'); ?></th>
|
||||
<th class="col-image"><?php esc_html_e('图片 URL', 'aksrt'); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($variants as $i => $row) :
|
||||
$attrs = $row['attributes'] ?? [];
|
||||
$attrs_text = '';
|
||||
if (is_array($attrs)) {
|
||||
$parts = [];
|
||||
foreach ($attrs as $k => $v) {
|
||||
if (is_int($k) && is_array($v)) {
|
||||
$parts[] = ($v['name'] ?? $v['key'] ?? '') . ':' . ($v['value'] ?? '');
|
||||
} else {
|
||||
$parts[] = $k . ':' . $v;
|
||||
}
|
||||
}
|
||||
$attrs_text = implode(';', array_filter($parts));
|
||||
} elseif (is_string($attrs)) {
|
||||
$attrs_text = $attrs;
|
||||
}
|
||||
?>
|
||||
<tr>
|
||||
<td><input type="text" name="aksrt_variant_sku[]" value="<?php echo esc_attr((string) ($row['sku'] ?? '')); ?>"></td>
|
||||
<td><input type="text" name="aksrt_variant_attrs[]" value="<?php echo esc_attr($attrs_text); ?>" placeholder="颜色:红;尺寸:L"></td>
|
||||
<td><input type="number" step="0.01" min="0" name="aksrt_variant_price[]" value="<?php echo esc_attr((string) ($row['price'] ?? 0)); ?>"></td>
|
||||
<td><input type="number" step="1" min="0" name="aksrt_variant_stock[]" value="<?php echo esc_attr((string) ($row['stock'] ?? 0)); ?>"></td>
|
||||
<td><input type="url" name="aksrt_variant_image[]" value="<?php echo esc_attr((string) ($row['image'] ?? '')); ?>"></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<p>
|
||||
<button type="button" class="button" id="aksrt-add-variant-row"><?php esc_html_e('添加变体行', 'aksrt'); ?></button>
|
||||
</p>
|
||||
<script>
|
||||
(function(){
|
||||
var btn = document.getElementById('aksrt-add-variant-row');
|
||||
var tbody = document.querySelector('#aksrt-variants-table tbody');
|
||||
if (!btn || !tbody) return;
|
||||
btn.addEventListener('click', function(){
|
||||
var tr = document.createElement('tr');
|
||||
tr.innerHTML = '<td><input type="text" name="aksrt_variant_sku[]" value=""></td>' +
|
||||
'<td><input type="text" name="aksrt_variant_attrs[]" value="" placeholder="颜色:红;尺寸:L"></td>' +
|
||||
'<td><input type="number" step="0.01" min="0" name="aksrt_variant_price[]" value="0"></td>' +
|
||||
'<td><input type="number" step="1" min="0" name="aksrt_variant_stock[]" value="0"></td>' +
|
||||
'<td><input type="url" name="aksrt_variant_image[]" value=""></td>';
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<?php
|
||||
}
|
||||
|
||||
public function save(int $post_id, \WP_Post $post): void {
|
||||
if (!isset($_POST['aksrt_product_variants_nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash((string) $_POST['aksrt_product_variants_nonce'])), 'aksrt_product_variants_save')) {
|
||||
return;
|
||||
}
|
||||
if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
|
||||
return;
|
||||
}
|
||||
if ($post->post_type !== 'aksrt_product' || !current_user_can('edit_post', $post_id)) {
|
||||
return;
|
||||
}
|
||||
if (!class_exists('AKSRT\\Shop\\Shop')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$skus = isset($_POST['aksrt_variant_sku']) && is_array($_POST['aksrt_variant_sku']) ? wp_unslash($_POST['aksrt_variant_sku']) : [];
|
||||
$attrs = isset($_POST['aksrt_variant_attrs']) && is_array($_POST['aksrt_variant_attrs']) ? wp_unslash($_POST['aksrt_variant_attrs']) : [];
|
||||
$prices = isset($_POST['aksrt_variant_price']) && is_array($_POST['aksrt_variant_price']) ? wp_unslash($_POST['aksrt_variant_price']) : [];
|
||||
$stocks = isset($_POST['aksrt_variant_stock']) && is_array($_POST['aksrt_variant_stock']) ? wp_unslash($_POST['aksrt_variant_stock']) : [];
|
||||
$images = isset($_POST['aksrt_variant_image']) && is_array($_POST['aksrt_variant_image']) ? wp_unslash($_POST['aksrt_variant_image']) : [];
|
||||
|
||||
$variants = [];
|
||||
$count = max(count($skus), count($attrs), count($prices), count($stocks), count($images));
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$sku = sanitize_text_field((string) ($skus[$i] ?? ''));
|
||||
$attr_raw = sanitize_text_field((string) ($attrs[$i] ?? ''));
|
||||
$price = max(0, (float) ($prices[$i] ?? 0));
|
||||
$stock = max(0, (int) ($stocks[$i] ?? 0));
|
||||
$image = esc_url_raw((string) ($images[$i] ?? ''));
|
||||
|
||||
if ($sku === '' && $attr_raw === '' && $price <= 0 && $stock <= 0 && $image === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$attributes = [];
|
||||
if ($attr_raw !== '') {
|
||||
foreach (preg_split('/\s*;\s*/', $attr_raw) ?: [] as $pair) {
|
||||
if ($pair === '' || !str_contains($pair, ':')) {
|
||||
continue;
|
||||
}
|
||||
[$k, $v] = array_map('trim', explode(':', $pair, 2));
|
||||
if ($k !== '') {
|
||||
$attributes[$k] = $v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$variants[] = [
|
||||
'sku' => $sku,
|
||||
'attributes' => $attributes,
|
||||
'price' => $price,
|
||||
'stock' => $stock,
|
||||
'image' => $image,
|
||||
];
|
||||
}
|
||||
|
||||
Shop::get_instance()->save_variants($post_id, $variants);
|
||||
}
|
||||
}
|
||||
@@ -1,397 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Shop;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class Shop {
|
||||
use Part1;
|
||||
use Part2;
|
||||
use Part3;
|
||||
|
||||
|
||||
private static $instance = null;
|
||||
|
||||
private ?CartService $cart_svc = null;
|
||||
private ?CheckoutService $checkout_svc = null;
|
||||
private ?AddressService $address_svc = null;
|
||||
private ?ProductService $product_svc = null;
|
||||
|
||||
public static function get_instance(): self {
|
||||
if (null === self::$instance) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
private function cart_service(): CartService {
|
||||
if (!$this->cart_svc) {
|
||||
$this->cart_svc = new CartService($this);
|
||||
}
|
||||
return $this->cart_svc;
|
||||
}
|
||||
|
||||
private function checkout_service(): CheckoutService {
|
||||
if (!$this->checkout_svc) {
|
||||
$this->checkout_svc = new CheckoutService($this);
|
||||
}
|
||||
return $this->checkout_svc;
|
||||
}
|
||||
|
||||
private function address_service(): AddressService {
|
||||
if (!$this->address_svc) {
|
||||
$this->address_svc = new AddressService($this);
|
||||
}
|
||||
return $this->address_svc;
|
||||
}
|
||||
|
||||
private function product_service(): ProductService {
|
||||
if ($this->product_svc === null) {
|
||||
$this->product_svc = new ProductService($this);
|
||||
}
|
||||
return $this->product_svc;
|
||||
}
|
||||
|
||||
public function get_config(): array {
|
||||
if (class_exists('\AKSRT\Settings\Settings')) {
|
||||
return \AKSRT\Settings\Settings::get_shop_settings();
|
||||
}
|
||||
|
||||
return ['enabled' => false];
|
||||
}
|
||||
|
||||
public function get_products(array $args = []): array {
|
||||
$limit = max(1, (int) ($args['limit'] ?? 20));
|
||||
$offset = max(0, (int) ($args['offset'] ?? 0));
|
||||
$search = sanitize_text_field((string) ($args['search'] ?? ''));
|
||||
$status = sanitize_key((string) ($args['status'] ?? ''));
|
||||
|
||||
$query_args = [
|
||||
'post_type' => 'aksrt_product',
|
||||
'posts_per_page' => $limit,
|
||||
'offset' => $offset,
|
||||
'post_status' => $status !== '' ? $status : ['publish', 'draft', 'pending'],
|
||||
'orderby' => 'date',
|
||||
'order' => 'DESC',
|
||||
];
|
||||
|
||||
if ($search !== '') {
|
||||
$query_args['s'] = $search;
|
||||
}
|
||||
|
||||
$query = new \WP_Query($query_args);
|
||||
$products = [];
|
||||
|
||||
while ($query->have_posts()) {
|
||||
$query->the_post();
|
||||
$products[] = $this->build_product_card_view(get_the_ID());
|
||||
}
|
||||
|
||||
wp_reset_postdata();
|
||||
|
||||
return $products;
|
||||
}
|
||||
|
||||
public function get_product_detail_view(int $product_id): array {
|
||||
$product = get_post($product_id);
|
||||
if (!$product || $product->post_type !== 'aksrt_product') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$detail = $this->build_product_card_view($product_id);
|
||||
$detail['content'] = (string) $product->post_content;
|
||||
$detail['excerpt'] = (string) $product->post_excerpt;
|
||||
$detail['permalink'] = get_permalink($product_id) ?: '';
|
||||
$detail['status'] = (string) get_post_status($product_id);
|
||||
$detail['gallery'] = $this->product_service()->get_product_gallery($product_id);
|
||||
$detail['categories'] = wp_get_post_terms($product_id, 'aksrt_product_cat', ['fields' => 'all']) ?: [];
|
||||
$detail['tags'] = wp_get_post_terms($product_id, 'aksrt_product_tag', ['fields' => 'all']) ?: [];
|
||||
$detail['shipping'] = $this->product_service()->get_product_shipping_profile($product_id);
|
||||
$detail['after_sale'] = $this->product_service()->get_product_after_sale_profile($product_id);
|
||||
$detail['marketing'] = $this->product_service()->get_product_marketing_profile($product_id);
|
||||
$detail['checkout_hint'] = $this->checkout_service()->get_checkout_hint($detail);
|
||||
$detail['purchase_enabled'] = ($detail['is_unlimited'] || $detail['stock'] > 0) && $detail['status'] === 'publish';
|
||||
$detail['default_quantity'] = 1;
|
||||
$detail['options'] = $this->get_product_options($product_id);
|
||||
$detail['has_options'] = $this->has_options($product_id);
|
||||
$detail['price_range'] = $this->get_price_range($product_id);
|
||||
$detail['pay_modo'] = sanitize_key((string) get_post_meta($product_id, '_aksrt_pay_modo', true)) ?: '0';
|
||||
$detail['start_price'] = (float) get_post_meta($product_id, '_aksrt_start_price', true);
|
||||
$detail['shipping_type'] = sanitize_key((string) get_post_meta($product_id, '_aksrt_shipping_type', true)) ?: 'express';
|
||||
$detail['auto_delivery'] = $this->get_auto_delivery_config($product_id);
|
||||
$detail['limit_buy'] = $this->get_limit_buy_config($product_id);
|
||||
$detail['services'] = $this->get_product_services($product_id);
|
||||
$detail['params'] = $this->get_product_params($product_id);
|
||||
|
||||
return $detail;
|
||||
}
|
||||
|
||||
public function get_product_edit_view(int $user_id, int $product_id = 0): array {
|
||||
$view = [
|
||||
'allowed' => true,
|
||||
'is_edit' => false,
|
||||
'product' => [
|
||||
'id' => 0,
|
||||
'title' => '',
|
||||
'price' => 0,
|
||||
'stock' => 0,
|
||||
'excerpt' => '',
|
||||
'content' => '',
|
||||
'image' => '',
|
||||
'gallery' => [],
|
||||
'categories' => [],
|
||||
'tags' => [],
|
||||
'shipping_mode' => 'express',
|
||||
'shipping_fee' => 0,
|
||||
'after_sale_policy' => '',
|
||||
'support_refund' => true,
|
||||
'status' => 'draft',
|
||||
'options' => [],
|
||||
'pay_modo' => '0',
|
||||
'start_price' => 0,
|
||||
'product_video' => [],
|
||||
'product_params' => '',
|
||||
],
|
||||
'taxonomy' => [
|
||||
'categories' => $this->product_service()->get_term_options('aksrt_product_cat'),
|
||||
'tags' => $this->product_service()->get_term_options('aksrt_product_tag'),
|
||||
],
|
||||
'shipping_modes' => $this->get_shipping_method_options(),
|
||||
'status_options' => [
|
||||
['value' => 'draft', 'label' => __('草稿', 'aksrt')],
|
||||
['value' => 'publish', 'label' => __('发布', 'aksrt')],
|
||||
['value' => 'pending', 'label' => __('待审核', 'aksrt')],
|
||||
],
|
||||
];
|
||||
|
||||
if ($product_id <= 0) {
|
||||
return $view;
|
||||
}
|
||||
|
||||
$product = get_post($product_id);
|
||||
if (!$product || $product->post_type !== 'aksrt_product') {
|
||||
return $view;
|
||||
}
|
||||
|
||||
$is_owner = (int) $product->post_author === $user_id || current_user_can('manage_options');
|
||||
if (!$is_owner) {
|
||||
$view['allowed'] = false;
|
||||
return $view;
|
||||
}
|
||||
|
||||
$view['is_edit'] = true;
|
||||
$videos = get_post_meta($product_id, '_aksrt_product_video', true);
|
||||
if (!is_array($videos)) {
|
||||
$videos = [];
|
||||
}
|
||||
$view['product'] = [
|
||||
'id' => $product_id,
|
||||
'title' => (string) $product->post_title,
|
||||
'price' => (float) get_post_meta($product_id, '_aksrt_price', true),
|
||||
'stock' => (int) get_post_meta($product_id, '_aksrt_stock', true),
|
||||
'excerpt' => (string) $product->post_excerpt,
|
||||
'content' => (string) $product->post_content,
|
||||
'image' => $this->resolve_main_image($product_id),
|
||||
'gallery' => $this->product_service()->get_product_gallery($product_id),
|
||||
'categories' => wp_get_post_terms($product_id, 'aksrt_product_cat', ['fields' => 'ids']) ?: [],
|
||||
'tags' => wp_get_post_terms($product_id, 'aksrt_product_tag', ['fields' => 'names']) ?: [],
|
||||
'shipping_mode' => sanitize_key((string) get_post_meta($product_id, '_aksrt_shipping_mode', true)) ?: 'express',
|
||||
'shipping_fee' => (float) get_post_meta($product_id, '_aksrt_shipping_fee', true),
|
||||
'after_sale_policy' => (string) get_post_meta($product_id, '_aksrt_after_sale_policy', true),
|
||||
'support_refund' => get_post_meta($product_id, '_aksrt_support_refund', true) !== '0',
|
||||
'status' => (string) get_post_status($product_id),
|
||||
'options' => method_exists($this, 'get_product_options') ? $this->get_product_options($product_id) : [],
|
||||
'pay_modo' => sanitize_key((string) get_post_meta($product_id, '_aksrt_pay_modo', true)) ?: '0',
|
||||
'start_price' => (float) get_post_meta($product_id, '_aksrt_start_price', true),
|
||||
'product_video' => array_values(array_filter(array_map('esc_url_raw', $videos))),
|
||||
'product_params' => (string) get_post_meta($product_id, '_aksrt_product_params', true),
|
||||
];
|
||||
|
||||
return $view;
|
||||
}
|
||||
|
||||
public function get_cart_page_view(int $user_id): array {
|
||||
$pending_orders = $this->get_order_center_view($user_id, [
|
||||
'page' => 1,
|
||||
'per_page' => 5,
|
||||
'status' => \AKSRT\Payment\Order::STATUS_PENDING,
|
||||
]);
|
||||
|
||||
return [
|
||||
'payment_methods' => $this->get_payment_method_options(),
|
||||
'shipping_methods' => $this->get_shipping_method_options(),
|
||||
'address_book' => $this->get_address_book($user_id),
|
||||
'coupon_enabled' => class_exists('\AKSRT\Payment\Coupon'),
|
||||
'currency_symbol' => '¥',
|
||||
'pending_orders' => $pending_orders['items'] ?? [],
|
||||
'cart_items' => $this->get_cart($user_id),
|
||||
'cart_count' => $this->get_cart_count($user_id),
|
||||
];
|
||||
}
|
||||
|
||||
public function get_cart(int $user_id): array {
|
||||
return $this->cart_service()->get_cart($user_id);
|
||||
}
|
||||
|
||||
public function get_cart_count(int $user_id): int {
|
||||
return $this->cart_service()->get_cart_count($user_id);
|
||||
}
|
||||
|
||||
public function add_to_cart(int $user_id, int $product_id, string $options_string = '', int $count = 1): array {
|
||||
return $this->cart_service()->add_to_cart($user_id, $product_id, $options_string, $count);
|
||||
}
|
||||
|
||||
public function reduce_from_cart(int $user_id, int $product_id, string $options_string = '', int $count = 1): array {
|
||||
return $this->cart_service()->reduce_from_cart($user_id, $product_id, $options_string, $count);
|
||||
}
|
||||
|
||||
public function remove_from_cart(int $user_id, int $product_id, string $options_string = ''): array {
|
||||
return $this->cart_service()->remove_from_cart($user_id, $product_id, $options_string);
|
||||
}
|
||||
|
||||
public function remove_multi_from_cart(int $user_id, array $items): array {
|
||||
return $this->cart_service()->remove_multi_from_cart($user_id, $items);
|
||||
}
|
||||
|
||||
public function empty_cart(int $user_id): array {
|
||||
return $this->cart_service()->empty_cart($user_id);
|
||||
}
|
||||
|
||||
public function validate_cart_item(int $product_id, string $options_string = '') {
|
||||
return $this->cart_service()->validate_cart_item($product_id, $options_string);
|
||||
}
|
||||
|
||||
public function get_order_center_view(int $user_id, array $args = []): array {
|
||||
if (class_exists('\AKSRT\Payment\UserOrders')) {
|
||||
return \AKSRT\Payment\UserOrders::get_instance()->get_order_center_view($user_id, $args);
|
||||
}
|
||||
|
||||
return ['items' => [], 'total' => 0, 'total_pages' => 1, 'page' => 1, 'stats' => [], 'product_types' => []];
|
||||
}
|
||||
|
||||
public function get_order_detail_view(int $user_id, string $order_number): ?array {
|
||||
if (class_exists('\AKSRT\Payment\UserOrders')) {
|
||||
return \AKSRT\Payment\UserOrders::get_instance()->get_order_detail_view($user_id, $order_number);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function preview_checkout(int $user_id, array $payload): array {
|
||||
return $this->checkout_service()->preview_checkout($user_id, $payload);
|
||||
}
|
||||
|
||||
public function create_checkout_order(int $user_id, array $payload): array {
|
||||
return $this->checkout_service()->create_checkout_order($user_id, $payload);
|
||||
}
|
||||
|
||||
public function pay_order(int $user_id, string $order_number, string $payment_method): array {
|
||||
return $this->checkout_service()->pay_order($user_id, $order_number, $payment_method);
|
||||
}
|
||||
|
||||
public function get_user_favorites(int $user_id, int $page = 1, int $per_page = 20): array {
|
||||
if ($user_id <= 0) {
|
||||
return ['items' => [], 'total' => 0];
|
||||
}
|
||||
$fav_ids = get_user_meta($user_id, 'aksrt_product_favorites', true);
|
||||
if (!is_array($fav_ids) || empty($fav_ids)) {
|
||||
return ['items' => [], 'total' => 0];
|
||||
}
|
||||
$total = count($fav_ids);
|
||||
$paged = array_slice(array_reverse($fav_ids), ($page - 1) * $per_page, $per_page);
|
||||
$items = [];
|
||||
foreach ($paged as $pid) {
|
||||
$card = $this->build_product_card_view((int) $pid);
|
||||
if (!empty($card)) {
|
||||
$items[] = $card;
|
||||
}
|
||||
}
|
||||
return ['items' => $items, 'total' => $total];
|
||||
}
|
||||
|
||||
public function toggle_favorite(int $user_id, int $product_id): array {
|
||||
if ($user_id <= 0 || $product_id <= 0) {
|
||||
return ['success' => false];
|
||||
}
|
||||
$fav_ids = get_user_meta($user_id, 'aksrt_product_favorites', true);
|
||||
if (!is_array($fav_ids)) {
|
||||
$fav_ids = [];
|
||||
}
|
||||
$key = array_search($product_id, $fav_ids, true);
|
||||
if ($key !== false) {
|
||||
unset($fav_ids[$key]);
|
||||
$fav_ids = array_values($fav_ids);
|
||||
$is_fav = false;
|
||||
} else {
|
||||
$fav_ids[] = $product_id;
|
||||
$is_fav = true;
|
||||
}
|
||||
update_user_meta($user_id, 'aksrt_product_favorites', $fav_ids);
|
||||
$count = (int) get_post_meta($product_id, '_aksrt_favorite_count', true);
|
||||
update_post_meta($product_id, '_aksrt_favorite_count', max(0, $count + ($is_fav ? 1 : -1)));
|
||||
if ($is_fav) {
|
||||
do_action('aksrt_product_favorited', $user_id, $product_id);
|
||||
} else {
|
||||
do_action('aksrt_product_unfavorited', $user_id, $product_id);
|
||||
}
|
||||
return ['success' => true, 'is_favorite' => $is_fav, 'count' => count($fav_ids)];
|
||||
}
|
||||
|
||||
public function is_favorite(int $user_id, int $product_id): bool {
|
||||
if ($user_id <= 0) return false;
|
||||
$fav_ids = get_user_meta($user_id, 'aksrt_product_favorites', true);
|
||||
return is_array($fav_ids) && in_array($product_id, $fav_ids, true);
|
||||
}
|
||||
|
||||
public function re_purchase(int $user_id, string $order_number): array {
|
||||
$items = $this->get_order_items($order_number);
|
||||
if (empty($items)) {
|
||||
return ['success' => false, 'message' => __('订单无商品记录', 'aksrt')];
|
||||
}
|
||||
$added = 0;
|
||||
foreach ($items as $item) {
|
||||
$pid = (int) $item['product_id'];
|
||||
$qty = max(1, (int) $item['quantity']);
|
||||
$opt_key = (string) $item['options_key'];
|
||||
$result = $this->add_to_cart($user_id, $pid, $opt_key, $qty);
|
||||
if (!empty($result['success'])) {
|
||||
$added++;
|
||||
}
|
||||
}
|
||||
return ['success' => $added > 0, 'added' => $added, 'cart_count' => $this->get_cart_count($user_id)];
|
||||
}
|
||||
|
||||
public function cancel_order(int $user_id, string $order_number): array {
|
||||
if ($user_id <= 0 || $order_number === '' || !class_exists('\AKSRT\Payment\OrderRepository')) {
|
||||
return ['success' => false, 'message' => __('订单不存在。', 'aksrt')];
|
||||
}
|
||||
|
||||
$repo = \AKSRT\Payment\OrderRepository::get_instance();
|
||||
$order = $repo->get_user_order($user_id, $order_number);
|
||||
if (!$order) {
|
||||
return ['success' => false, 'message' => __('订单不存在。', 'aksrt')];
|
||||
}
|
||||
|
||||
$status = (string) ($order['status'] ?? '');
|
||||
if (!in_array($status, [\AKSRT\Payment\Order::STATUS_PENDING, \AKSRT\Payment\Order::STATUS_CREATED, \AKSRT\Payment\Order::STATUS_EXPIRED], true)) {
|
||||
return ['success' => false, 'message' => __('当前订单不可取消。', 'aksrt')];
|
||||
}
|
||||
|
||||
$repo->update_status($order_number, \AKSRT\Payment\Order::STATUS_CANCELLED, [
|
||||
'force' => true,
|
||||
'note' => __('用户在前台取消订单。', 'aksrt'),
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'order' => $this->get_order_detail_view($user_id, $order_number),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,748 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Shop;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class TermMeta {
|
||||
|
||||
private static $instance = null;
|
||||
|
||||
/** @var string Meta key for product_cat/product_tag unified storage */
|
||||
const META_KEY = 'aksrt_shop_term_meta';
|
||||
|
||||
/** @var string Meta key for shop_discount unified storage */
|
||||
const DISCOUNT_META_KEY = 'aksrt_shop_discount_meta';
|
||||
|
||||
public static function get_instance(): self {
|
||||
if (null === self::$instance) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register hooks.
|
||||
*/
|
||||
public function init(): void {
|
||||
add_action('init', [$this, 'register_discount_taxonomy'], 5);
|
||||
add_action('init', [$this, 'register_term_meta_fields'], 10);
|
||||
|
||||
// Native term UI for shop_discount (zibll-style admin ops).
|
||||
add_action('shop_discount_add_form_fields', [$this, 'render_discount_add_fields']);
|
||||
add_action('shop_discount_edit_form_fields', [$this, 'render_discount_edit_fields'], 10, 1);
|
||||
add_action('created_shop_discount', [$this, 'save_discount_term_fields']);
|
||||
add_action('edited_shop_discount', [$this, 'save_discount_term_fields']);
|
||||
|
||||
// Product category inheritance fields.
|
||||
add_action('aksrt_product_cat_add_form_fields', [$this, 'render_cat_add_fields']);
|
||||
add_action('aksrt_product_cat_edit_form_fields', [$this, 'render_cat_edit_fields'], 10, 1);
|
||||
add_action('created_aksrt_product_cat', [$this, 'save_cat_term_fields']);
|
||||
add_action('edited_aksrt_product_cat', [$this, 'save_cat_term_fields']);
|
||||
|
||||
// Product tag display fields.
|
||||
add_action('aksrt_product_tag_add_form_fields', [$this, 'render_tag_add_fields']);
|
||||
add_action('aksrt_product_tag_edit_form_fields', [$this, 'render_tag_edit_fields'], 10, 1);
|
||||
add_action('created_aksrt_product_tag', [$this, 'save_tag_term_fields']);
|
||||
add_action('edited_aksrt_product_tag', [$this, 'save_tag_term_fields']);
|
||||
}
|
||||
|
||||
// ─── Taxonomy Registration ───────────────────────────────────
|
||||
|
||||
/**
|
||||
* Register the shop_discount taxonomy for discount campaigns.
|
||||
*/
|
||||
public function register_discount_taxonomy(): void {
|
||||
if (taxonomy_exists('shop_discount')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$shop_enabled = true;
|
||||
if (class_exists('\AKSRT\Settings\Settings')) {
|
||||
$shop_settings = \AKSRT\Settings\Settings::get_shop_settings();
|
||||
$shop_enabled = !empty($shop_settings['enabled']);
|
||||
}
|
||||
$shop_show_ui = true;
|
||||
$shop_show_menu = is_admin() ? $shop_enabled : false;
|
||||
|
||||
register_taxonomy('shop_discount', 'aksrt_product', [
|
||||
'labels' => [
|
||||
'name' => __('优惠活动', 'aksrt'),
|
||||
'singular_name' => __('优惠活动', 'aksrt'),
|
||||
'search_items' => __('搜索优惠活动', 'aksrt'),
|
||||
'all_items' => __('所有优惠活动', 'aksrt'),
|
||||
'edit_item' => __('编辑优惠活动', 'aksrt'),
|
||||
'add_new_item' => __('新增优惠活动', 'aksrt'),
|
||||
'menu_name' => __('优惠活动', 'aksrt'),
|
||||
],
|
||||
'public' => true,
|
||||
'hierarchical' => false,
|
||||
'show_ui' => $shop_show_ui,
|
||||
'show_in_menu' => $shop_show_menu,
|
||||
'show_in_rest' => true,
|
||||
'show_admin_column' => true,
|
||||
'rewrite' => ['slug' => 'shop-discount', 'with_front' => false],
|
||||
]);
|
||||
}
|
||||
|
||||
// ─── Term Meta Registration ──────────────────────────────────
|
||||
|
||||
/**
|
||||
* Register all term meta fields for REST API exposure.
|
||||
*/
|
||||
public function register_term_meta_fields(): void {
|
||||
$auth = function () {
|
||||
return current_user_can('manage_categories');
|
||||
};
|
||||
|
||||
// ── aksrt_product_cat ──
|
||||
$cat_fields = $this->get_cat_field_definitions();
|
||||
foreach ($cat_fields as $key => $def) {
|
||||
register_term_meta('aksrt_product_cat', $key, [
|
||||
'type' => $def['type'],
|
||||
'description' => $def['description'] ?? $key,
|
||||
'single' => true,
|
||||
'default' => $def['default'] ?? '',
|
||||
'show_in_rest' => true,
|
||||
'sanitize_callback' => $def['sanitize'] ?? 'sanitize_text_field',
|
||||
'auth_callback' => $auth,
|
||||
]);
|
||||
}
|
||||
|
||||
// ── aksrt_product_tag ──
|
||||
$tag_fields = $this->get_tag_field_definitions();
|
||||
foreach ($tag_fields as $key => $def) {
|
||||
register_term_meta('aksrt_product_tag', $key, [
|
||||
'type' => $def['type'],
|
||||
'description' => $def['description'] ?? $key,
|
||||
'single' => true,
|
||||
'default' => $def['default'] ?? '',
|
||||
'show_in_rest' => true,
|
||||
'sanitize_callback' => $def['sanitize'] ?? 'sanitize_text_field',
|
||||
'auth_callback' => $auth,
|
||||
]);
|
||||
}
|
||||
|
||||
// ── shop_discount ──
|
||||
$discount_fields = $this->get_discount_field_definitions();
|
||||
foreach ($discount_fields as $key => $def) {
|
||||
register_term_meta('shop_discount', $key, [
|
||||
'type' => $def['type'],
|
||||
'description' => $def['description'] ?? $key,
|
||||
'single' => true,
|
||||
'default' => $def['default'] ?? '',
|
||||
'show_in_rest' => true,
|
||||
'sanitize_callback' => $def['sanitize'] ?? 'sanitize_text_field',
|
||||
'auth_callback' => $auth,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Field Definitions ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Product category term meta field definitions.
|
||||
*
|
||||
* @return array<string, array{type:string, description:string, default:mixed, sanitize?:callable}>
|
||||
*/
|
||||
private function get_cat_field_definitions(): array {
|
||||
return [
|
||||
'shop_cat_limit_buy' => [
|
||||
'type' => 'integer', 'description' => '分类级限购 (-1 不限)', 'default' => -1,
|
||||
'sanitize' => function ($v) { return max(-1, min(9999, (int) $v)); },
|
||||
],
|
||||
'shop_cat_shipping_fee' => [
|
||||
'type' => 'string', 'description' => '分类级运费类型', 'default' => '',
|
||||
'sanitize' => function ($v) {
|
||||
$v = sanitize_key((string) $v);
|
||||
return in_array($v, ['', 'free', 'fixed', 'amount'], true) ? $v : '';
|
||||
},
|
||||
],
|
||||
'shop_cat_guest_buy' => [
|
||||
'type' => 'string', 'description' => '分类级免登录购买', 'default' => '',
|
||||
],
|
||||
'shop_cat_email_fill' => [
|
||||
'type' => 'string', 'description' => '分类级邮箱必留', 'default' => '',
|
||||
],
|
||||
'shop_cat_after_sale' => [
|
||||
'type' => 'string', 'description' => '分类级售后政策', 'default' => '',
|
||||
'sanitize' => function ($v) {
|
||||
$v = sanitize_key((string) $v);
|
||||
return in_array($v, ['', 'refund', 'refund_return', 'none'], true) ? $v : '';
|
||||
},
|
||||
],
|
||||
'shop_cat_rebate' => [
|
||||
'type' => 'string', 'description' => '分类级返佣覆盖', 'default' => '',
|
||||
],
|
||||
'shop_cat_service' => [
|
||||
'type' => 'string', 'description' => '分类级服务保障', 'default' => '',
|
||||
'sanitize' => 'sanitize_textarea_field',
|
||||
],
|
||||
'shop_cat_single_tab' => [
|
||||
'type' => 'string', 'description' => '分类级自定义 TAB', 'default' => '',
|
||||
'sanitize' => 'sanitize_textarea_field',
|
||||
],
|
||||
'shop_cat_content_after' => [
|
||||
'type' => 'string', 'description' => '分类级详情页底部内容', 'default' => '',
|
||||
'sanitize' => 'wp_kses_post',
|
||||
],
|
||||
'shop_cat_content_layout' => [
|
||||
'type' => 'string', 'description' => '分类级内容布局', 'default' => '',
|
||||
'sanitize' => function ($v) {
|
||||
$v = sanitize_key((string) $v);
|
||||
return in_array($v, ['', 'adapt', 'full', 'sidebar'], true) ? $v : '';
|
||||
},
|
||||
],
|
||||
'shop_cat_content_show_bg' => [
|
||||
'type' => 'string', 'description' => '分类级详情背景盒子', 'default' => '',
|
||||
],
|
||||
'shop_cat_list_style' => [
|
||||
'type' => 'string', 'description' => '分类页列表样式', 'default' => '',
|
||||
'sanitize' => function ($v) {
|
||||
$v = sanitize_key((string) $v);
|
||||
return in_array($v, ['', 'default', 'card-lg', 'card-sm'], true) ? $v : '';
|
||||
},
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Product tag term meta field definitions.
|
||||
*
|
||||
* @return array<string, array{type:string, description:string, default:mixed, sanitize?:callable}>
|
||||
*/
|
||||
private function get_tag_field_definitions(): array {
|
||||
return [
|
||||
'shop_tag_priority' => [
|
||||
'type' => 'integer', 'description' => '标签优先级(影响显示顺序)', 'default' => 0,
|
||||
'sanitize' => function ($v) { return max(0, min(9999, (int) $v)); },
|
||||
],
|
||||
'shop_tag_class' => [
|
||||
'type' => 'string', 'description' => '标签颜色 class', 'default' => '',
|
||||
'sanitize' => 'sanitize_html_class',
|
||||
],
|
||||
'shop_tag_is_important' => [
|
||||
'type' => 'string', 'description' => '重点标签(商品价格位着重显示)', 'default' => '',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Shop discount term meta field definitions.
|
||||
*
|
||||
* @return array<string, array{type:string, description:string, default:mixed, sanitize?:callable}>
|
||||
*/
|
||||
private function get_discount_field_definitions(): array {
|
||||
return [
|
||||
'shop_discount_small_badge' => [
|
||||
'type' => 'string', 'description' => '小标签文案', 'default' => '',
|
||||
],
|
||||
'shop_discount_priority' => [
|
||||
'type' => 'integer', 'description' => '优先级(数字越大越优先)', 'default' => 0,
|
||||
'sanitize' => function ($v) { return max(0, min(9999, (int) $v)); },
|
||||
],
|
||||
'shop_discount_is_important' => [
|
||||
'type' => 'string', 'description' => '重点活动', 'default' => '',
|
||||
],
|
||||
'shop_discount_important_class' => [
|
||||
'type' => 'string', 'description' => '重点活动背景色 class', 'default' => '',
|
||||
'sanitize' => 'sanitize_html_class',
|
||||
],
|
||||
'shop_discount_scope' => [
|
||||
'type' => 'string', 'description' => '优惠范围', 'default' => 'item',
|
||||
'sanitize' => function ($v) {
|
||||
$v = sanitize_key((string) $v);
|
||||
return in_array($v, ['item', 'product', 'author', 'order'], true) ? $v : 'item';
|
||||
},
|
||||
],
|
||||
'shop_discount_type' => [
|
||||
'type' => 'string', 'description' => '优惠类型', 'default' => 'reduction',
|
||||
'sanitize' => function ($v) {
|
||||
$v = sanitize_key((string) $v);
|
||||
return in_array($v, ['reduction', 'discount', 'gift'], true) ? $v : 'reduction';
|
||||
},
|
||||
],
|
||||
'shop_discount_value' => [
|
||||
'type' => 'string', 'description' => '优惠数值(立减金额/折扣 0.01-9.99)', 'default' => '0',
|
||||
'sanitize' => function ($v) { return sanitize_text_field((string) $v); },
|
||||
],
|
||||
'shop_discount_gift_config' => [
|
||||
'type' => 'string', 'description' => '赠品配置 JSON', 'default' => '',
|
||||
'sanitize' => 'sanitize_textarea_field',
|
||||
],
|
||||
'shop_discount_price_limit' => [
|
||||
'type' => 'string', 'description' => '金额门槛', 'default' => '0',
|
||||
'sanitize' => function ($v) { return max(0, (float) $v); },
|
||||
],
|
||||
'shop_discount_user_limit' => [
|
||||
'type' => 'string', 'description' => '用户身份限制', 'default' => '',
|
||||
'sanitize' => function ($v) {
|
||||
$v = sanitize_key((string) $v);
|
||||
return in_array($v, ['', 'vip', 'vip_2', 'auth'], true) ? $v : '';
|
||||
},
|
||||
],
|
||||
'shop_discount_start' => [
|
||||
'type' => 'string', 'description' => '活动开始时间', 'default' => '',
|
||||
'sanitize' => 'sanitize_text_field',
|
||||
],
|
||||
'shop_discount_end' => [
|
||||
'type' => 'string', 'description' => '活动结束时间', 'default' => '',
|
||||
'sanitize' => 'sanitize_text_field',
|
||||
],
|
||||
'shop_discount_countdown' => [
|
||||
'type' => 'string', 'description' => '倒计时显示', 'default' => '',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// ─── Getters ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get all shop term meta for a product category.
|
||||
*
|
||||
* @param int $term_id
|
||||
* @return array
|
||||
*/
|
||||
public static function get_cat_meta(int $term_id): array {
|
||||
$defaults = [
|
||||
'limit_buy' => -1,
|
||||
'shipping_fee' => '',
|
||||
'guest_buy' => '',
|
||||
'email_fill' => '',
|
||||
'after_sale' => '',
|
||||
'rebate' => '',
|
||||
'service' => '',
|
||||
'single_tab' => '',
|
||||
'content_after' => '',
|
||||
'content_layout' => '',
|
||||
'content_show_bg' => '',
|
||||
'list_style' => '',
|
||||
];
|
||||
|
||||
$result = [];
|
||||
foreach ($defaults as $key => $default) {
|
||||
$meta_key = "shop_cat_{$key}";
|
||||
$val = get_term_meta($term_id, $meta_key, true);
|
||||
$result[$key] = ($val !== '' && $val !== false) ? $val : $default;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all shop term meta for a product tag.
|
||||
*
|
||||
* @param int $term_id
|
||||
* @return array
|
||||
*/
|
||||
public static function get_tag_meta(int $term_id): array {
|
||||
$priority = get_term_meta($term_id, 'shop_tag_priority', true);
|
||||
$class = get_term_meta($term_id, 'shop_tag_class', true);
|
||||
$is_important = get_term_meta($term_id, 'shop_tag_is_important', true);
|
||||
|
||||
return [
|
||||
'priority' => $priority !== '' ? (int) $priority : 0,
|
||||
'class' => is_string($class) ? $class : '',
|
||||
'is_important' => !empty($is_important),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all shop discount term meta.
|
||||
*
|
||||
* @param int $term_id
|
||||
* @return array
|
||||
*/
|
||||
public static function get_discount_meta(int $term_id): array {
|
||||
$defaults = [
|
||||
'small_badge' => '',
|
||||
'priority' => 0,
|
||||
'is_important' => false,
|
||||
'important_class' => '',
|
||||
'scope' => 'item',
|
||||
'type' => 'reduction',
|
||||
'value' => '0',
|
||||
'gift_config' => '',
|
||||
'price_limit' => '0',
|
||||
'user_limit' => '',
|
||||
'start' => '',
|
||||
'end' => '',
|
||||
'countdown' => '',
|
||||
];
|
||||
|
||||
$result = [];
|
||||
foreach ($defaults as $key => $default) {
|
||||
$meta_key = "shop_discount_{$key}";
|
||||
$val = get_term_meta($term_id, $meta_key, true);
|
||||
$result[$key] = ($val !== '' && $val !== false) ? $val : $default;
|
||||
}
|
||||
|
||||
$result['priority'] = (int) $result['priority'];
|
||||
$result['is_important'] = !empty($result['is_important']);
|
||||
$result['price_limit'] = (float) $result['price_limit'];
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve parameter inheritance: product → category → theme settings.
|
||||
*
|
||||
* Returns the effective value for a given parameter key by checking:
|
||||
* 1. Product-level override (from post meta)
|
||||
* 2. Category-level override (from term meta)
|
||||
* 3. Theme-level default (from \AKSRT\Settings\Settings::get_shop_settings())
|
||||
*
|
||||
* @param int $product_id Product post ID
|
||||
* @param string $param_key Parameter key (e.g. 'limit_buy', 'shipping_fee')
|
||||
* @param mixed $product_value Product-level value (null if not set)
|
||||
* @return mixed
|
||||
*/
|
||||
public static function resolve_inheritance(int $product_id, string $param_key, $product_value = null) {
|
||||
// 1. Product-level override
|
||||
if ($product_value !== null && $product_value !== '' && $product_value !== -1) {
|
||||
return $product_value;
|
||||
}
|
||||
|
||||
// 2. Category-level override
|
||||
$terms = get_the_terms($product_id, 'aksrt_product_cat');
|
||||
if (is_array($terms) && !empty($terms)) {
|
||||
$cat_meta_key = "shop_cat_{$param_key}";
|
||||
foreach ($terms as $term) {
|
||||
$val = get_term_meta($term->term_id, $cat_meta_key, true);
|
||||
if ($val !== '' && $val !== false && $val !== -1) {
|
||||
return $val;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Theme-level default
|
||||
$shop = \AKSRT\Settings\Settings::get_shop_settings();
|
||||
$defaults = $shop['product_defaults'] ?? [];
|
||||
return $defaults[$param_key] ?? $product_value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add form fields for shop_discount taxonomy.
|
||||
*/
|
||||
public function render_discount_add_fields(): void {
|
||||
$fields = $this->get_discount_field_definitions();
|
||||
foreach ($fields as $key => $def) {
|
||||
$label = (string) ($def['description'] ?? $key);
|
||||
$default = $def['default'] ?? '';
|
||||
echo '<div class="form-field">';
|
||||
echo '<label for="' . esc_attr($key) . '">' . esc_html($label) . '</label>';
|
||||
$this->render_discount_field_control($key, $default, false);
|
||||
echo '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit form fields for shop_discount taxonomy.
|
||||
*/
|
||||
public function render_discount_edit_fields($term): void {
|
||||
if (!($term instanceof \WP_Term)) {
|
||||
return;
|
||||
}
|
||||
$fields = $this->get_discount_field_definitions();
|
||||
foreach ($fields as $key => $def) {
|
||||
$label = (string) ($def['description'] ?? $key);
|
||||
$value = get_term_meta($term->term_id, $key, true);
|
||||
if ($value === '' || $value === false) {
|
||||
$value = $def['default'] ?? '';
|
||||
}
|
||||
echo '<tr class="form-field">';
|
||||
echo '<th scope="row"><label for="' . esc_attr($key) . '">' . esc_html($label) . '</label></th>';
|
||||
echo '<td>';
|
||||
$this->render_discount_field_control($key, $value, true);
|
||||
echo '</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string|int|float $value
|
||||
*/
|
||||
private function render_discount_field_control(string $key, $value, bool $edit): void {
|
||||
$id = esc_attr($key);
|
||||
$name = esc_attr($key);
|
||||
$val = (string) $value;
|
||||
|
||||
if ($key === 'shop_discount_type') {
|
||||
$opts = [
|
||||
'reduction' => __('立减', 'aksrt'),
|
||||
'discount' => __('折扣', 'aksrt'),
|
||||
'gift' => __('赠品', 'aksrt'),
|
||||
];
|
||||
echo '<select name="' . $name . '" id="' . $id . '">';
|
||||
foreach ($opts as $k => $label) {
|
||||
printf('<option value="%s"%s>%s</option>', esc_attr($k), selected($val, $k, false), esc_html($label));
|
||||
}
|
||||
echo '</select>';
|
||||
return;
|
||||
}
|
||||
|
||||
if ($key === 'shop_discount_scope') {
|
||||
$opts = [
|
||||
'item' => __('单品', 'aksrt'),
|
||||
'product' => __('商品', 'aksrt'),
|
||||
'author' => __('商家', 'aksrt'),
|
||||
'order' => __('整单', 'aksrt'),
|
||||
];
|
||||
echo '<select name="' . $name . '" id="' . $id . '">';
|
||||
foreach ($opts as $k => $label) {
|
||||
printf('<option value="%s"%s>%s</option>', esc_attr($k), selected($val, $k, false), esc_html($label));
|
||||
}
|
||||
echo '</select>';
|
||||
return;
|
||||
}
|
||||
|
||||
if ($key === 'shop_discount_user_limit') {
|
||||
$opts = [
|
||||
'' => __('不限', 'aksrt'),
|
||||
'vip' => __('VIP1', 'aksrt'),
|
||||
'vip_2' => __('VIP2', 'aksrt'),
|
||||
'auth' => __('已认证', 'aksrt'),
|
||||
];
|
||||
echo '<select name="' . $name . '" id="' . $id . '">';
|
||||
foreach ($opts as $k => $label) {
|
||||
printf('<option value="%s"%s>%s</option>', esc_attr($k), selected($val, $k, false), esc_html($label));
|
||||
}
|
||||
echo '</select>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (in_array($key, ['shop_discount_is_important', 'shop_discount_countdown'], true)) {
|
||||
echo '<label><input type="checkbox" name="' . $name . '" id="' . $id . '" value="1" ' . checked($val, '1', false) . '> ' . esc_html__('启用', 'aksrt') . '</label>';
|
||||
return;
|
||||
}
|
||||
|
||||
if ($key === 'shop_discount_gift_config') {
|
||||
echo '<textarea name="' . $name . '" id="' . $id . '" rows="3" class="large-text">' . esc_textarea($val) . '</textarea>';
|
||||
return;
|
||||
}
|
||||
|
||||
$type = in_array($key, ['shop_discount_priority'], true) ? 'number' : 'text';
|
||||
if (in_array($key, ['shop_discount_start', 'shop_discount_end'], true)) {
|
||||
$type = 'datetime-local';
|
||||
// Convert mysql-ish values to datetime-local if needed.
|
||||
if ($val !== '' && str_contains($val, ' ')) {
|
||||
$val = str_replace(' ', 'T', substr($val, 0, 16));
|
||||
}
|
||||
}
|
||||
|
||||
printf(
|
||||
'<input type="%s" name="%s" id="%s" value="%s" class="%s" />',
|
||||
esc_attr($type),
|
||||
$name,
|
||||
$id,
|
||||
esc_attr($val),
|
||||
$edit ? 'regular-text' : ''
|
||||
);
|
||||
}
|
||||
|
||||
public function save_discount_term_fields(int $term_id): void {
|
||||
if (!current_user_can('manage_categories')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$fields = $this->get_discount_field_definitions();
|
||||
foreach ($fields as $key => $def) {
|
||||
if (in_array($key, ['shop_discount_is_important', 'shop_discount_countdown'], true)) {
|
||||
$raw = isset($_POST[$key]) ? '1' : '';
|
||||
} else {
|
||||
if (!isset($_POST[$key])) {
|
||||
continue;
|
||||
}
|
||||
$raw = wp_unslash($_POST[$key]);
|
||||
}
|
||||
|
||||
if (isset($def['sanitize']) && is_callable($def['sanitize'])) {
|
||||
$value = call_user_func($def['sanitize'], $raw);
|
||||
} else {
|
||||
$value = sanitize_text_field((string) $raw);
|
||||
}
|
||||
|
||||
if (in_array($key, ['shop_discount_start', 'shop_discount_end'], true) && is_string($value) && str_contains($value, 'T')) {
|
||||
$value = str_replace('T', ' ', $value);
|
||||
if (strlen($value) === 16) {
|
||||
$value .= ':00';
|
||||
}
|
||||
}
|
||||
|
||||
update_term_meta($term_id, $key, $value);
|
||||
}
|
||||
}
|
||||
|
||||
public function render_cat_add_fields(): void {
|
||||
foreach ($this->get_cat_field_definitions() as $key => $def) {
|
||||
echo '<div class="form-field">';
|
||||
echo '<label for="' . esc_attr($key) . '">' . esc_html((string) ($def['description'] ?? $key)) . '</label>';
|
||||
$this->render_cat_field_control($key, $def['default'] ?? '', false);
|
||||
echo '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
public function render_cat_edit_fields($term): void {
|
||||
if (!($term instanceof \WP_Term)) {
|
||||
return;
|
||||
}
|
||||
foreach ($this->get_cat_field_definitions() as $key => $def) {
|
||||
$value = get_term_meta($term->term_id, $key, true);
|
||||
if ($value === '' || $value === false) {
|
||||
$value = $def['default'] ?? '';
|
||||
}
|
||||
echo '<tr class="form-field"><th scope="row"><label for="' . esc_attr($key) . '">' . esc_html((string) ($def['description'] ?? $key)) . '</label></th><td>';
|
||||
$this->render_cat_field_control($key, $value, true);
|
||||
echo '</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $value
|
||||
*/
|
||||
private function render_cat_field_control(string $key, $value, bool $edit): void {
|
||||
$val = (string) $value;
|
||||
if (in_array($key, ['shop_cat_guest_buy', 'shop_cat_email_fill', 'shop_cat_content_show_bg'], true)) {
|
||||
echo '<label><input type="checkbox" name="' . esc_attr($key) . '" value="1" ' . checked($val, '1', false) . '> ' . esc_html__('启用', 'aksrt') . '</label>';
|
||||
return;
|
||||
}
|
||||
if (in_array($key, ['shop_cat_service', 'shop_cat_single_tab', 'shop_cat_content_after'], true)) {
|
||||
echo '<textarea class="large-text" rows="3" name="' . esc_attr($key) . '" id="' . esc_attr($key) . '">' . esc_textarea($val) . '</textarea>';
|
||||
return;
|
||||
}
|
||||
if ($key === 'shop_cat_shipping_fee') {
|
||||
$opts = ['' => __('继承', 'aksrt'), 'free' => __('包邮', 'aksrt'), 'fixed' => __('固定运费', 'aksrt'), 'amount' => __('满额包邮', 'aksrt')];
|
||||
echo '<select name="' . esc_attr($key) . '" id="' . esc_attr($key) . '">';
|
||||
foreach ($opts as $k => $label) {
|
||||
printf('<option value="%s"%s>%s</option>', esc_attr($k), selected($val, $k, false), esc_html($label));
|
||||
}
|
||||
echo '</select>';
|
||||
return;
|
||||
}
|
||||
if ($key === 'shop_cat_after_sale') {
|
||||
$opts = ['' => __('继承', 'aksrt'), 'refund' => __('仅退款', 'aksrt'), 'refund_return' => __('退货退款', 'aksrt'), 'none' => __('不支持', 'aksrt')];
|
||||
echo '<select name="' . esc_attr($key) . '" id="' . esc_attr($key) . '">';
|
||||
foreach ($opts as $k => $label) {
|
||||
printf('<option value="%s"%s>%s</option>', esc_attr($k), selected($val, $k, false), esc_html($label));
|
||||
}
|
||||
echo '</select>';
|
||||
return;
|
||||
}
|
||||
if ($key === 'shop_cat_content_layout') {
|
||||
$opts = ['' => __('继承', 'aksrt'), 'adapt' => __('自适应', 'aksrt'), 'full' => __('通栏', 'aksrt'), 'sidebar' => __('侧栏', 'aksrt')];
|
||||
echo '<select name="' . esc_attr($key) . '" id="' . esc_attr($key) . '">';
|
||||
foreach ($opts as $k => $label) {
|
||||
printf('<option value="%s"%s>%s</option>', esc_attr($k), selected($val, $k, false), esc_html($label));
|
||||
}
|
||||
echo '</select>';
|
||||
return;
|
||||
}
|
||||
if ($key === 'shop_cat_list_style') {
|
||||
$opts = ['' => __('继承', 'aksrt'), 'default' => __('默认', 'aksrt'), 'card-lg' => __('大卡片', 'aksrt'), 'card-sm' => __('小卡片', 'aksrt')];
|
||||
echo '<select name="' . esc_attr($key) . '" id="' . esc_attr($key) . '">';
|
||||
foreach ($opts as $k => $label) {
|
||||
printf('<option value="%s"%s>%s</option>', esc_attr($k), selected($val, $k, false), esc_html($label));
|
||||
}
|
||||
echo '</select>';
|
||||
return;
|
||||
}
|
||||
$type = $key === 'shop_cat_limit_buy' ? 'number' : 'text';
|
||||
printf(
|
||||
'<input type="%s" class="%s" name="%s" id="%s" value="%s" />',
|
||||
esc_attr($type),
|
||||
$edit ? 'regular-text' : '',
|
||||
esc_attr($key),
|
||||
esc_attr($key),
|
||||
esc_attr($val)
|
||||
);
|
||||
}
|
||||
|
||||
public function save_cat_term_fields(int $term_id): void {
|
||||
if (!current_user_can('manage_categories')) {
|
||||
return;
|
||||
}
|
||||
foreach ($this->get_cat_field_definitions() as $key => $def) {
|
||||
if (in_array($key, ['shop_cat_guest_buy', 'shop_cat_email_fill', 'shop_cat_content_show_bg'], true)) {
|
||||
$raw = isset($_POST[$key]) ? '1' : '';
|
||||
} else {
|
||||
if (!isset($_POST[$key])) {
|
||||
continue;
|
||||
}
|
||||
$raw = wp_unslash($_POST[$key]);
|
||||
}
|
||||
if (isset($def['sanitize']) && is_callable($def['sanitize'])) {
|
||||
$value = call_user_func($def['sanitize'], $raw);
|
||||
} else {
|
||||
$value = sanitize_text_field((string) $raw);
|
||||
}
|
||||
update_term_meta($term_id, $key, $value);
|
||||
}
|
||||
}
|
||||
|
||||
public function render_tag_add_fields(): void {
|
||||
foreach ($this->get_tag_field_definitions() as $key => $def) {
|
||||
echo '<div class="form-field">';
|
||||
echo '<label for="' . esc_attr($key) . '">' . esc_html((string) ($def['description'] ?? $key)) . '</label>';
|
||||
$this->render_tag_field_control($key, $def['default'] ?? '');
|
||||
echo '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
public function render_tag_edit_fields($term): void {
|
||||
if (!($term instanceof \WP_Term)) {
|
||||
return;
|
||||
}
|
||||
foreach ($this->get_tag_field_definitions() as $key => $def) {
|
||||
$value = get_term_meta($term->term_id, $key, true);
|
||||
if ($value === '' || $value === false) {
|
||||
$value = $def['default'] ?? '';
|
||||
}
|
||||
echo '<tr class="form-field"><th scope="row"><label for="' . esc_attr($key) . '">' . esc_html((string) ($def['description'] ?? $key)) . '</label></th><td>';
|
||||
$this->render_tag_field_control($key, $value);
|
||||
echo '</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $value
|
||||
*/
|
||||
private function render_tag_field_control(string $key, $value): void {
|
||||
$val = (string) $value;
|
||||
if ($key === 'shop_tag_is_important') {
|
||||
echo '<label><input type="checkbox" name="' . esc_attr($key) . '" value="1" ' . checked($val, '1', false) . '> ' . esc_html__('启用', 'aksrt') . '</label>';
|
||||
return;
|
||||
}
|
||||
$type = $key === 'shop_tag_priority' ? 'number' : 'text';
|
||||
printf(
|
||||
'<input type="%s" class="regular-text" name="%s" id="%s" value="%s" />',
|
||||
esc_attr($type),
|
||||
esc_attr($key),
|
||||
esc_attr($key),
|
||||
esc_attr($val)
|
||||
);
|
||||
}
|
||||
|
||||
public function save_tag_term_fields(int $term_id): void {
|
||||
if (!current_user_can('manage_categories')) {
|
||||
return;
|
||||
}
|
||||
foreach ($this->get_tag_field_definitions() as $key => $def) {
|
||||
if ($key === 'shop_tag_is_important') {
|
||||
$raw = isset($_POST[$key]) ? '1' : '';
|
||||
} else {
|
||||
if (!isset($_POST[$key])) {
|
||||
continue;
|
||||
}
|
||||
$raw = wp_unslash($_POST[$key]);
|
||||
}
|
||||
if (isset($def['sanitize']) && is_callable($def['sanitize'])) {
|
||||
$value = call_user_func($def['sanitize'], $raw);
|
||||
} else {
|
||||
$value = sanitize_text_field((string) $raw);
|
||||
}
|
||||
update_term_meta($term_id, $key, $value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,401 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Widgets;
|
||||
|
||||
use AKSRT\Widgets\Traits\CommonWidgetFields;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Shop product list widget — zibll `zib_shop_widget_ui_product_lists` lite.
|
||||
*
|
||||
* Supports: include/exclude category, discount, tag; sort order; list style;
|
||||
* AJAX load-more (via rest widget list endpoint); per-page count.
|
||||
*/
|
||||
class WidgetProductList extends \WP_Widget {
|
||||
use CommonWidgetFields;
|
||||
|
||||
|
||||
public function __construct() {
|
||||
parent::__construct(
|
||||
'aksrt_product_list',
|
||||
__('商品列表', 'aksrt'),
|
||||
['description' => __('AKSRT 商品列表组件:筛选、排序、AJAX 翻页。', 'aksrt')]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build WP_Query args from instance + page.
|
||||
*
|
||||
* @param array $instance
|
||||
* @param int $paged
|
||||
* @return array
|
||||
*/
|
||||
public static function build_query_args(array $instance, int $paged = 1): array {
|
||||
$per_page = max(1, min(50, (int) ($instance['per_page'] ?? 8)));
|
||||
$orderby = sanitize_key($instance['orderby'] ?? 'date');
|
||||
$order = in_array(strtoupper((string) ($instance['order'] ?? '')), ['ASC', 'DESC'], true)
|
||||
? strtoupper((string) $instance['order'])
|
||||
: 'DESC';
|
||||
$cat_ids = array_values(array_filter(array_map('intval', explode(',', (string) ($instance['cat_ids'] ?? '')))));
|
||||
$tag_ids = array_values(array_filter(array_map('intval', explode(',', (string) ($instance['tag_ids'] ?? '')))));
|
||||
$discount_ids = array_values(array_filter(array_map('intval', explode(',', (string) ($instance['discount_ids'] ?? '')))));
|
||||
$exclude_cat = !empty($instance['exclude_cat']);
|
||||
|
||||
$query_args = [
|
||||
'post_type' => 'aksrt_product',
|
||||
'post_status' => 'publish',
|
||||
'posts_per_page' => $per_page,
|
||||
'paged' => max(1, $paged),
|
||||
'orderby' => $orderby === 'sales' ? 'meta_value_num' : ($orderby === 'views' ? 'meta_value_num' : $orderby),
|
||||
'order' => $order,
|
||||
'ignore_sticky_posts' => true,
|
||||
];
|
||||
|
||||
if ($orderby === 'sales') {
|
||||
$query_args['meta_key'] = '_aksrt_sales';
|
||||
} elseif ($orderby === 'views') {
|
||||
$query_args['meta_key'] = 'aksrt_post_views';
|
||||
}
|
||||
|
||||
$tax_query = [];
|
||||
if (!empty($cat_ids)) {
|
||||
$tax_query[] = [
|
||||
'taxonomy' => 'aksrt_product_cat',
|
||||
'field' => 'term_id',
|
||||
'terms' => $cat_ids,
|
||||
'operator' => $exclude_cat ? 'NOT IN' : 'IN',
|
||||
];
|
||||
}
|
||||
if (!empty($tag_ids)) {
|
||||
$tax_query[] = [
|
||||
'taxonomy' => 'aksrt_product_tag',
|
||||
'field' => 'term_id',
|
||||
'terms' => $tag_ids,
|
||||
];
|
||||
}
|
||||
if (!empty($discount_ids)) {
|
||||
$tax_query[] = [
|
||||
'taxonomy' => 'shop_discount',
|
||||
'field' => 'term_id',
|
||||
'terms' => $discount_ids,
|
||||
];
|
||||
}
|
||||
if (!empty($tax_query)) {
|
||||
$query_args['tax_query'] = $tax_query;
|
||||
}
|
||||
return $query_args;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render product cards HTML for a query.
|
||||
*/
|
||||
public static function render_items_html(\WP_Query $products, string $style = 'default'): string {
|
||||
if (!$products->have_posts()) {
|
||||
return '';
|
||||
}
|
||||
$oneline = $style === 'oneline';
|
||||
$mini = $style === 'mini';
|
||||
$list = $style === 'list';
|
||||
ob_start();
|
||||
while ($products->have_posts()) {
|
||||
$products->the_post();
|
||||
$pid = get_the_ID();
|
||||
$card = class_exists('\AKSRT\Shop\Shop')
|
||||
? \AKSRT\Shop\Shop::get_instance()->build_product_card_view($pid)
|
||||
: [];
|
||||
$price = (float) ($card['price'] ?? 0);
|
||||
$image = (string) ($card['image'] ?? '');
|
||||
$ph = get_template_directory_uri() . '/assets/img/placeholder.svg';
|
||||
if ($oneline) {
|
||||
echo '<div class="swiper-slide ajax-item">';
|
||||
}
|
||||
if ($mini || $list) {
|
||||
$row_cls = $list ? 'aksrt-product-widget__item aksrt-product-widget__item--list ajax-item flex ac' : 'aksrt-product-widget__item aksrt-product-widget__item--mini ajax-item flex ac';
|
||||
$thumb_cls = $list ? 'aksrt-product-widget__thumb list' : 'aksrt-product-widget__thumb mini';
|
||||
$wh = $list ? 80 : 56;
|
||||
echo '<div class="' . esc_attr($row_cls) . '">';
|
||||
echo '<a href="' . esc_url(get_permalink($pid)) . '" class="' . esc_attr($thumb_cls) . '">';
|
||||
if ($image !== '') {
|
||||
echo '<img class="aksrt-lazy lazyload fit-cover" data-src="' . esc_url($image) . '" src="' . esc_url($ph) . '" alt="' . esc_attr(get_the_title($pid)) . '" loading="lazy" width="' . (int) $wh . '" height="' . (int) $wh . '">';
|
||||
}
|
||||
echo '</a>';
|
||||
echo '<div class="aksrt-product-widget__info flex1 min-w0">';
|
||||
echo '<a href="' . esc_url(get_permalink($pid)) . '" class="aksrt-product-widget__title' . ($mini ? ' text-ellipsis' : ' text-ellipsis-2') . '">' . esc_html(get_the_title($pid)) . '</a>';
|
||||
echo '<div class="aksrt-product-widget__meta flex jsb ac">';
|
||||
echo '<span class="aksrt-product-widget__price c-red">¥' . esc_html(number_format_i18n($price, 2)) . '</span>';
|
||||
if (!empty($card['sales'])) {
|
||||
echo '<span class="aksrt-product-widget__sales muted-2-color em09">' . esc_html((string) $card['sales']) . esc_html__(' 已售', 'aksrt') . '</span>';
|
||||
}
|
||||
echo '</div></div></div>';
|
||||
} elseif (function_exists('get_template_part') && !empty($card['id'])) {
|
||||
get_template_part('templates/parts/shop/product', 'card', [
|
||||
'product' => $card,
|
||||
'show_sales' => true,
|
||||
]);
|
||||
} else {
|
||||
// Fallback card (lazy thumbs).
|
||||
echo '<div class="aksrt-product-widget__item ajax-item">';
|
||||
if ($image !== '') {
|
||||
echo '<a href="' . esc_url(get_permalink($pid)) . '" class="aksrt-product-widget__thumb">';
|
||||
echo '<img class="aksrt-lazy lazyload" data-src="' . esc_url($image) . '" src="' . esc_url($ph) . '" alt="' . esc_attr(get_the_title($pid)) . '" loading="lazy">';
|
||||
echo '</a>';
|
||||
}
|
||||
echo '<div class="aksrt-product-widget__info">';
|
||||
echo '<a href="' . esc_url(get_permalink($pid)) . '" class="aksrt-product-widget__title">' . esc_html(get_the_title($pid)) . '</a>';
|
||||
echo '<div class="aksrt-product-widget__meta">';
|
||||
echo '<span class="aksrt-product-widget__price">¥' . esc_html(number_format_i18n($price, 2)) . '</span>';
|
||||
if (!empty($card['sales'])) {
|
||||
echo '<span class="aksrt-product-widget__sales">' . esc_html((string) $card['sales']) . esc_html__(' 已售', 'aksrt') . '</span>';
|
||||
}
|
||||
echo '</div></div></div>';
|
||||
}
|
||||
if ($oneline) {
|
||||
echo '</div>';
|
||||
}
|
||||
}
|
||||
wp_reset_postdata();
|
||||
return (string) ob_get_clean();
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared payload for REST widget list.
|
||||
*
|
||||
* @param array $instance
|
||||
* @param int $page
|
||||
* @return array{html:string,paginate_html:string,has_more:bool,page:int,max_pages:int,total:int}
|
||||
*/
|
||||
public static function query_payload(array $instance, int $page = 1): array {
|
||||
$page = max(1, $page);
|
||||
$args = self::build_query_args($instance, $page);
|
||||
$style = sanitize_key((string) ($instance['style'] ?? 'default'));
|
||||
$paginate = sanitize_key((string) ($instance['paginate'] ?? 'ajax'));
|
||||
$products = new \WP_Query($args);
|
||||
$html = self::render_items_html($products, $style);
|
||||
if ($html === '' && $page === 1) {
|
||||
$html = function_exists('aksrt_get_ajax_null')
|
||||
? aksrt_get_ajax_null(__('暂无商品', 'aksrt'))
|
||||
: '<p class="aksrt-product-widget__empty ajax-item">' . esc_html__('暂无商品', 'aksrt') . '</p>';
|
||||
}
|
||||
$max_pages = max(1, (int) $products->max_num_pages);
|
||||
$total = (int) $products->found_posts;
|
||||
$has_more = $page < $max_pages;
|
||||
$paginate_html = '';
|
||||
if ($paginate === 'ajax' && $has_more) {
|
||||
$next = add_query_arg(
|
||||
array_filter([
|
||||
'page' => $page + 1,
|
||||
'per_page' => max(1, (int) ($instance['per_page'] ?? 8)),
|
||||
'orderby' => sanitize_key((string) ($instance['orderby'] ?? 'date')),
|
||||
'order' => sanitize_key((string) ($instance['order'] ?? 'DESC')),
|
||||
'cat_ids' => (string) ($instance['cat_ids'] ?? ''),
|
||||
'tag_ids' => (string) ($instance['tag_ids'] ?? ''),
|
||||
'discount_ids' => (string) ($instance['discount_ids'] ?? ''),
|
||||
'exclude_cat' => !empty($instance['exclude_cat']) ? 1 : 0,
|
||||
'style' => sanitize_key((string) ($instance['style'] ?? 'default')),
|
||||
'paginate' => 'ajax',
|
||||
], static function ($v) {
|
||||
return $v !== '' && $v !== null;
|
||||
}),
|
||||
rest_url('aksrt/v1/shop/frontend/widgets/products')
|
||||
);
|
||||
if (function_exists('aksrt_get_ajax_next_paginate')) {
|
||||
$paginate_html = aksrt_get_ajax_next_paginate(
|
||||
$total,
|
||||
$page,
|
||||
max(1, (int) ($instance['per_page'] ?? 8)),
|
||||
$next
|
||||
);
|
||||
} else {
|
||||
$paginate_html = '<div class="text-center theme-pagination ajax-pag"><div class="next-page ajax-next"><a href="' . esc_url($next) . '">' . esc_html__('加载更多', 'aksrt') . '</a></div></div>';
|
||||
}
|
||||
} elseif ($paginate === 'number' && $max_pages > 1) {
|
||||
$num_base = add_query_arg(
|
||||
array_filter([
|
||||
'per_page' => max(1, (int) ($instance['per_page'] ?? 8)),
|
||||
'orderby' => sanitize_key((string) ($instance['orderby'] ?? 'date')),
|
||||
'order' => sanitize_key((string) ($instance['order'] ?? 'DESC')),
|
||||
'cat_ids' => (string) ($instance['cat_ids'] ?? ''),
|
||||
'tag_ids' => (string) ($instance['tag_ids'] ?? ''),
|
||||
'discount_ids' => (string) ($instance['discount_ids'] ?? ''),
|
||||
'exclude_cat' => !empty($instance['exclude_cat']) ? 1 : 0,
|
||||
'style' => sanitize_key((string) ($instance['style'] ?? 'default')),
|
||||
'paginate' => 'number',
|
||||
], static function ($v) {
|
||||
return $v !== '' && $v !== null;
|
||||
}),
|
||||
rest_url('aksrt/v1/shop/frontend/widgets/products')
|
||||
);
|
||||
$paginate_html = function_exists('aksrt_get_ajax_number_paginate')
|
||||
? aksrt_get_ajax_number_paginate($total, $page, max(1, (int) ($instance['per_page'] ?? 8)), $num_base)
|
||||
: '<div class="aksrt-product-widget__pagination">' . paginate_links([
|
||||
'base' => add_query_arg('aksrt_product_page', '%#%'),
|
||||
'format' => '',
|
||||
'current' => $page,
|
||||
'total' => $max_pages,
|
||||
'prev_text' => '«',
|
||||
'next_text' => '»',
|
||||
]) . '</div>';
|
||||
} else {
|
||||
$paginate_html = '<div class="ajax-pag hide"><div class="next-page ajax-next"><a href="#"></a></div></div>';
|
||||
}
|
||||
|
||||
return [
|
||||
'html' => $html,
|
||||
'paginate_html' => $paginate_html,
|
||||
'has_more' => $has_more,
|
||||
'page' => $page,
|
||||
'max_pages' => $max_pages,
|
||||
'total' => $total,
|
||||
];
|
||||
}
|
||||
|
||||
public function widget($args, $instance): void {
|
||||
$title = apply_filters('widget_title', (string) ($instance['title'] ?? ''), $instance, $this->id_base);
|
||||
$style = sanitize_key((string) ($instance['style'] ?? 'default'));
|
||||
$paginate = sanitize_key((string) ($instance['paginate'] ?? 'ajax'));
|
||||
$page = max(1, (int) ($_GET['aksrt_product_page'] ?? 1)); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
|
||||
|
||||
$cfg = [
|
||||
'per_page' => max(1, min(50, (int) ($instance['per_page'] ?? 8))),
|
||||
'orderby' => sanitize_key((string) ($instance['orderby'] ?? 'date')),
|
||||
'order' => in_array(strtoupper((string) ($instance['order'] ?? '')), ['ASC', 'DESC'], true) ? strtoupper((string) $instance['order']) : 'DESC',
|
||||
'cat_ids' => (string) ($instance['cat_ids'] ?? ''),
|
||||
'tag_ids' => (string) ($instance['tag_ids'] ?? ''),
|
||||
'discount_ids' => (string) ($instance['discount_ids'] ?? ''),
|
||||
'exclude_cat' => !empty($instance['exclude_cat']) ? 1 : 0,
|
||||
'style' => $style,
|
||||
'paginate' => $paginate,
|
||||
];
|
||||
|
||||
$payload = self::query_payload($instance, $page);
|
||||
$ajax_url = rest_url('aksrt/v1/shop/frontend/widgets/products');
|
||||
|
||||
echo $args['before_widget']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
if ($title !== '') {
|
||||
echo $args['before_title'] . esc_html($title) . $args['after_title']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
}
|
||||
|
||||
$style_class = 'aksrt-product-widget--' . $style;
|
||||
echo '<div class="aksrt-product-widget ' . esc_attr($style_class) . '" data-aksrt-product-widget data-aksrt-product-config="' . esc_attr(wp_json_encode($cfg)) . '">';
|
||||
|
||||
if ($style === 'oneline') {
|
||||
// zibll-style horizontal swiper-scroll (shared slider.js Type A).
|
||||
echo '<div class="swiper-container swiper-scroll aksrt-product-oneline-swiper">';
|
||||
echo '<div class="swiper-wrapper product-lists-row">';
|
||||
echo $payload['html']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
echo '</div>';
|
||||
echo '<div class="swiper-button-prev" aria-label="' . esc_attr__('上一页', 'aksrt') . '"></div>';
|
||||
echo '<div class="swiper-button-next" aria-label="' . esc_attr__('下一页', 'aksrt') . '"></div>';
|
||||
echo '</div>';
|
||||
} else {
|
||||
// Items sit directly under .ajaxpager so ajax-list inject/append works (zibll product-lists-row).
|
||||
echo '<div class="ajaxpager aksrt-product-widget__grid product-lists-row" data-aksrt-ajaxpager data-aksrt-ajax-url="' . esc_url($ajax_url) . '">';
|
||||
echo $payload['html']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped — cards escaped upstream
|
||||
echo $payload['paginate_html']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
echo '<div class="post_ajax_loader" data-aksrt-ajax-loader data-aksrt-ajax-loader-hide hidden></div>';
|
||||
echo '</div>';
|
||||
}
|
||||
echo '</div>';
|
||||
|
||||
echo $args['after_widget']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
}
|
||||
|
||||
public function form($instance): void {
|
||||
$defaults = [
|
||||
'title' => __('商品推荐', 'aksrt'),
|
||||
'per_page' => 8,
|
||||
'orderby' => 'date',
|
||||
'order' => 'DESC',
|
||||
'cat_ids' => '',
|
||||
'tag_ids' => '',
|
||||
'discount_ids' => '',
|
||||
'exclude_cat' => false,
|
||||
'style' => 'default',
|
||||
'paginate' => 'ajax',
|
||||
];
|
||||
$instance = wp_parse_args($instance, $defaults);
|
||||
?>
|
||||
<p>
|
||||
<label for="<?php echo esc_attr($this->get_field_id('title')); ?>"><?php esc_html_e('标题:', 'aksrt'); ?></label>
|
||||
<input class="widefat" type="text" id="<?php echo esc_attr($this->get_field_id('title')); ?>" name="<?php echo esc_attr($this->get_field_name('title')); ?>" value="<?php echo esc_attr((string) $instance['title']); ?>">
|
||||
</p>
|
||||
<p>
|
||||
<label for="<?php echo esc_attr($this->get_field_id('per_page')); ?>"><?php esc_html_e('每页数量:', 'aksrt'); ?></label>
|
||||
<input class="tiny-text" type="number" min="1" max="50" id="<?php echo esc_attr($this->get_field_id('per_page')); ?>" name="<?php echo esc_attr($this->get_field_name('per_page')); ?>" value="<?php echo esc_attr((string) $instance['per_page']); ?>">
|
||||
</p>
|
||||
<p>
|
||||
<label for="<?php echo esc_attr($this->get_field_id('orderby')); ?>"><?php esc_html_e('排序:', 'aksrt'); ?></label>
|
||||
<select class="widefat" id="<?php echo esc_attr($this->get_field_id('orderby')); ?>" name="<?php echo esc_attr($this->get_field_name('orderby')); ?>">
|
||||
<option value="date" <?php selected($instance['orderby'], 'date'); ?>><?php esc_html_e('最新', 'aksrt'); ?></option>
|
||||
<option value="sales" <?php selected($instance['orderby'], 'sales'); ?>><?php esc_html_e('销量', 'aksrt'); ?></option>
|
||||
<option value="views" <?php selected($instance['orderby'], 'views'); ?>><?php esc_html_e('热度', 'aksrt'); ?></option>
|
||||
<option value="title" <?php selected($instance['orderby'], 'title'); ?>><?php esc_html_e('标题', 'aksrt'); ?></option>
|
||||
<option value="rand" <?php selected($instance['orderby'], 'rand'); ?>><?php esc_html_e('随机', 'aksrt'); ?></option>
|
||||
</select>
|
||||
</p>
|
||||
<p>
|
||||
<label for="<?php echo esc_attr($this->get_field_id('order')); ?>"><?php esc_html_e('方向:', 'aksrt'); ?></label>
|
||||
<select class="widefat" id="<?php echo esc_attr($this->get_field_id('order')); ?>" name="<?php echo esc_attr($this->get_field_name('order')); ?>">
|
||||
<option value="DESC" <?php selected($instance['order'], 'DESC'); ?>><?php esc_html_e('降序', 'aksrt'); ?></option>
|
||||
<option value="ASC" <?php selected($instance['order'], 'ASC'); ?>><?php esc_html_e('升序', 'aksrt'); ?></option>
|
||||
</select>
|
||||
</p>
|
||||
<p>
|
||||
<label for="<?php echo esc_attr($this->get_field_id('cat_ids')); ?>"><?php esc_html_e('分类ID(逗号分隔):', 'aksrt'); ?></label>
|
||||
<input class="widefat" type="text" id="<?php echo esc_attr($this->get_field_id('cat_ids')); ?>" name="<?php echo esc_attr($this->get_field_name('cat_ids')); ?>" value="<?php echo esc_attr((string) $instance['cat_ids']); ?>">
|
||||
</p>
|
||||
<p>
|
||||
<label>
|
||||
<input type="checkbox" name="<?php echo esc_attr($this->get_field_name('exclude_cat')); ?>" value="1" <?php checked(!empty($instance['exclude_cat'])); ?>>
|
||||
<?php esc_html_e('排除上述分类', 'aksrt'); ?>
|
||||
</label>
|
||||
</p>
|
||||
<p>
|
||||
<label for="<?php echo esc_attr($this->get_field_id('tag_ids')); ?>"><?php esc_html_e('标签ID(逗号分隔):', 'aksrt'); ?></label>
|
||||
<input class="widefat" type="text" id="<?php echo esc_attr($this->get_field_id('tag_ids')); ?>" name="<?php echo esc_attr($this->get_field_name('tag_ids')); ?>" value="<?php echo esc_attr((string) $instance['tag_ids']); ?>">
|
||||
</p>
|
||||
<p>
|
||||
<label for="<?php echo esc_attr($this->get_field_id('discount_ids')); ?>"><?php esc_html_e('优惠活动ID(逗号分隔):', 'aksrt'); ?></label>
|
||||
<input class="widefat" type="text" id="<?php echo esc_attr($this->get_field_id('discount_ids')); ?>" name="<?php echo esc_attr($this->get_field_name('discount_ids')); ?>" value="<?php echo esc_attr((string) $instance['discount_ids']); ?>">
|
||||
</p>
|
||||
<p>
|
||||
<label for="<?php echo esc_attr($this->get_field_id('style')); ?>"><?php esc_html_e('列表样式:', 'aksrt'); ?></label>
|
||||
<select class="widefat" id="<?php echo esc_attr($this->get_field_id('style')); ?>" name="<?php echo esc_attr($this->get_field_name('style')); ?>">
|
||||
<option value="default" <?php selected($instance['style'], 'default'); ?>><?php esc_html_e('默认', 'aksrt'); ?></option>
|
||||
<option value="card" <?php selected($instance['style'], 'card'); ?>><?php esc_html_e('卡片', 'aksrt'); ?></option>
|
||||
<option value="list" <?php selected($instance['style'], 'list'); ?>><?php esc_html_e('列表', 'aksrt'); ?></option>
|
||||
<option value="mini" <?php selected($instance['style'], 'mini'); ?>><?php esc_html_e('迷你', 'aksrt'); ?></option>
|
||||
<option value="oneline" <?php selected($instance['style'], 'oneline'); ?>><?php esc_html_e('横滑', 'aksrt'); ?></option>
|
||||
</select>
|
||||
</p>
|
||||
<p>
|
||||
<label for="<?php echo esc_attr($this->get_field_id('paginate')); ?>"><?php esc_html_e('分页:', 'aksrt'); ?></label>
|
||||
<select class="widefat" id="<?php echo esc_attr($this->get_field_id('paginate')); ?>" name="<?php echo esc_attr($this->get_field_name('paginate')); ?>">
|
||||
<option value="ajax" <?php selected($instance['paginate'], 'ajax'); ?>><?php esc_html_e('AJAX 加载更多', 'aksrt'); ?></option>
|
||||
<option value="number" <?php selected($instance['paginate'], 'number'); ?>><?php esc_html_e('数字分页', 'aksrt'); ?></option>
|
||||
<option value="none" <?php selected($instance['paginate'], 'none'); ?>><?php esc_html_e('无', 'aksrt'); ?></option>
|
||||
</select>
|
||||
</p>
|
||||
<?php
|
||||
$this->render_common_form_section($instance);
|
||||
}
|
||||
|
||||
public function update($new_instance, $old_instance): array {
|
||||
$business = [
|
||||
'title' => sanitize_text_field((string) ($new_instance['title'] ?? '')),
|
||||
'per_page' => max(1, min(50, (int) ($new_instance['per_page'] ?? 8))),
|
||||
'orderby' => sanitize_key((string) ($new_instance['orderby'] ?? 'date')),
|
||||
'order' => in_array(strtoupper((string) ($new_instance['order'] ?? '')), ['ASC', 'DESC'], true) ? strtoupper((string) $new_instance['order']) : 'DESC',
|
||||
'cat_ids' => sanitize_text_field((string) ($new_instance['cat_ids'] ?? '')),
|
||||
'tag_ids' => sanitize_text_field((string) ($new_instance['tag_ids'] ?? '')),
|
||||
'discount_ids' => sanitize_text_field((string) ($new_instance['discount_ids'] ?? '')),
|
||||
'exclude_cat' => !empty($new_instance['exclude_cat']) ? 1 : 0,
|
||||
'style' => sanitize_key((string) ($new_instance['style'] ?? 'default')),
|
||||
'paginate' => in_array(sanitize_key((string) ($new_instance['paginate'] ?? 'ajax')), ['none', 'ajax', 'number'], true) ? sanitize_key((string) ($new_instance['paginate'] ?? 'ajax')) : 'ajax',
|
||||
]; return $this->update_common_fields($new_instance, $old_instance, $business);
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Horizontal swiper product widget — uses shared product list payload.
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Widgets;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class WidgetProductOneline extends WidgetProductList {
|
||||
|
||||
public function __construct() {
|
||||
\WP_Widget::__construct(
|
||||
'aksrt_product_oneline',
|
||||
__('横滑商品', 'aksrt'),
|
||||
[
|
||||
'description' => __('AKSRT 单行横滑商品(swiper-scroll,同源 product list)。', 'aksrt'),
|
||||
'classname' => 'widget_aksrt_product_oneline',
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
public function widget($args, $instance): void {
|
||||
$instance['style'] = 'oneline';
|
||||
// Prefer no paginate for horizontal scroller (zibll loads fixed count).
|
||||
if (!isset($instance['paginate'])) {
|
||||
$instance['paginate'] = 'none';
|
||||
}
|
||||
parent::widget($args, $instance);
|
||||
}
|
||||
|
||||
public function form($instance): void {
|
||||
$instance = wp_parse_args($instance, [
|
||||
'title' => __('横滑商品', 'aksrt'),
|
||||
'style' => 'oneline',
|
||||
'paginate' => 'none',
|
||||
'per_page' => 8,
|
||||
]);
|
||||
parent::form($instance);
|
||||
}
|
||||
|
||||
public function update($new_instance, $old_instance): array {
|
||||
$out = parent::update($new_instance, $old_instance);
|
||||
$out['style'] = 'oneline';
|
||||
if (($out['paginate'] ?? '') === '') {
|
||||
$out['paginate'] = 'none';
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Widgets;
|
||||
|
||||
use AKSRT\Widgets\Traits\CommonWidgetFields;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
/**
|
||||
* Multi-tab product widget — zibll `zib_shop_widget_ui_tab_product` lite.
|
||||
* First tab renders immediately via shared payload; other tabs lazy-load via REST.
|
||||
*/
|
||||
class WidgetProductTab extends \WP_Widget {
|
||||
use CommonWidgetFields;
|
||||
|
||||
|
||||
public function __construct() {
|
||||
parent::__construct(
|
||||
'aksrt_product_tab',
|
||||
__('多TAB商品', 'aksrt'),
|
||||
['description' => __('AKSRT 多 TAB 商品列表组件(每 tab 独立筛选 + AJAX)。', 'aksrt')]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{label:string,cat_ids:string,discount_ids:string,tag_ids:string,orderby:string}>
|
||||
*/
|
||||
private static function parse_tabs(string $tabs_raw): array {
|
||||
$tabs = [];
|
||||
foreach (array_filter(explode("\n", $tabs_raw)) as $line) {
|
||||
$parts = explode('|', trim($line));
|
||||
if (count($parts) >= 1 && trim($parts[0]) !== '') {
|
||||
$tabs[] = [
|
||||
'label' => trim($parts[0]),
|
||||
'cat_ids' => trim($parts[1] ?? ''),
|
||||
'discount_ids' => trim($parts[2] ?? ''),
|
||||
'tag_ids' => trim($parts[3] ?? ''),
|
||||
'orderby' => trim($parts[4] ?? 'date'),
|
||||
];
|
||||
}
|
||||
}
|
||||
return $tabs;
|
||||
}
|
||||
|
||||
public function widget($args, $instance): void {
|
||||
$title = apply_filters('widget_title', (string) ($instance['title'] ?? ''), $instance, $this->id_base);
|
||||
$tabs_raw = (string) ($instance['tabs'] ?? '');
|
||||
$per_page = max(1, min(20, (int) ($instance['per_page'] ?? 8)));
|
||||
$style = sanitize_key((string) ($instance['style'] ?? 'default'));
|
||||
$paginate = sanitize_key((string) ($instance['paginate'] ?? 'ajax'));
|
||||
$tabs = self::parse_tabs($tabs_raw);
|
||||
if (!$tabs) {
|
||||
if (current_user_can('manage_options')) {
|
||||
echo $args['before_widget']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
echo function_exists('aksrt_get_widget_null')
|
||||
? aksrt_get_widget_null(__('请至少配置一个 TAB', 'aksrt')) // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
: '<p class="widget-empty">' . esc_html__('请至少配置一个 TAB', 'aksrt') . '</p>';
|
||||
echo $args['after_widget']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
echo $args['before_widget']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
if ($title !== '') {
|
||||
echo $args['before_title'] . esc_html($title) . $args['after_title']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
}
|
||||
|
||||
$rest = rest_url('aksrt/v1/shop/frontend/widgets/products');
|
||||
echo '<div class="aksrt-product-tab-widget" data-aksrt-product-tab data-per-page="' . esc_attr((string) $per_page) . '" data-style="' . esc_attr($style) . '" data-paginate="' . esc_attr($paginate) . '" data-rest="' . esc_url($rest) . '">';
|
||||
|
||||
echo '<div class="aksrt-product-tab__headers index-tab">';
|
||||
foreach ($tabs as $i => $tab) {
|
||||
$active = $i === 0 ? ' is-active' : '';
|
||||
echo '<button type="button" class="aksrt-product-tab__header' . esc_attr($active) . '" data-tab-index="' . esc_attr((string) $i) . '"';
|
||||
echo ' data-cat-ids="' . esc_attr($tab['cat_ids']) . '"';
|
||||
echo ' data-discount-ids="' . esc_attr($tab['discount_ids']) . '"';
|
||||
echo ' data-tag-ids="' . esc_attr($tab['tag_ids']) . '"';
|
||||
echo ' data-orderby="' . esc_attr(sanitize_key($tab['orderby'] ?: 'date')) . '"';
|
||||
echo '>' . esc_html($tab['label']) . '</button>';
|
||||
}
|
||||
echo '</div>';
|
||||
|
||||
foreach ($tabs as $i => $tab) {
|
||||
$hidden = $i > 0 ? ' style="display:none"' : '';
|
||||
$loaded = $i === 0 ? '1' : '0';
|
||||
echo '<div class="aksrt-product-tab__panel" data-tab-index="' . esc_attr((string) $i) . '" data-loaded="' . esc_attr($loaded) . '"' . $hidden . '>';
|
||||
|
||||
if ($i === 0) {
|
||||
$inst = [
|
||||
'per_page' => $per_page,
|
||||
'orderby' => sanitize_key($tab['orderby'] ?: 'date'),
|
||||
'order' => 'DESC',
|
||||
'cat_ids' => $tab['cat_ids'],
|
||||
'tag_ids' => $tab['tag_ids'],
|
||||
'discount_ids' => $tab['discount_ids'],
|
||||
'style' => $style,
|
||||
'paginate' => $paginate,
|
||||
];
|
||||
$payload = class_exists('\AKSRT\Widgets\WidgetProductList')
|
||||
? WidgetProductList::query_payload($inst, 1)
|
||||
: ['html' => '', 'paginate_html' => ''];
|
||||
echo '<div class="ajaxpager aksrt-product-widget__grid product-lists-row aksrt-product-widget--' . esc_attr($style) . '" data-aksrt-ajaxpager>';
|
||||
echo $payload['html']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
echo $payload['paginate_html']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
echo '<div class="post_ajax_loader" data-aksrt-ajax-loader data-aksrt-ajax-loader-hide hidden></div>';
|
||||
echo '</div>';
|
||||
} else {
|
||||
echo '<div class="ajaxpager aksrt-product-widget__grid product-lists-row aksrt-product-widget--' . esc_attr($style) . ' is-lazy" data-aksrt-ajaxpager>';
|
||||
echo '<div class="post_ajax_loader" data-aksrt-ajax-loader>';
|
||||
echo '<div class="aksrt-product-tab__skeleton">';
|
||||
for ($si = 0; $si < min(4, $per_page); $si++) {
|
||||
echo '<div class="placeholder-row product-card-placeholder"><div class="placeholder thumb"></div><div class="placeholder k1"></div><div class="placeholder s1"></div></div>';
|
||||
}
|
||||
echo '</div></div>';
|
||||
echo '<div class="ajax-pag hide"><div class="next-page ajax-next"><a href="#"></a></div></div>';
|
||||
echo '</div>';
|
||||
}
|
||||
echo '</div>';
|
||||
}
|
||||
|
||||
echo '</div>';
|
||||
echo $args['after_widget']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
}
|
||||
|
||||
public function form($instance): void {
|
||||
$defaults = [
|
||||
'title' => __('商品精选', 'aksrt'),
|
||||
'tabs' => "热门推荐|\n最新上架|",
|
||||
'per_page' => 8,
|
||||
'style' => 'default',
|
||||
'paginate' => 'ajax',
|
||||
];
|
||||
$instance = wp_parse_args($instance, $defaults);
|
||||
?>
|
||||
<p>
|
||||
<label for="<?php echo esc_attr($this->get_field_id('title')); ?>"><?php esc_html_e('标题:', 'aksrt'); ?></label>
|
||||
<input class="widefat" type="text" id="<?php echo esc_attr($this->get_field_id('title')); ?>" name="<?php echo esc_attr($this->get_field_name('title')); ?>" value="<?php echo esc_attr((string) $instance['title']); ?>">
|
||||
</p>
|
||||
<p>
|
||||
<label for="<?php echo esc_attr($this->get_field_id('tabs')); ?>"><?php esc_html_e('TAB配置(每行: 名称|分类ID|优惠ID|标签ID|排序):', 'aksrt'); ?></label>
|
||||
<textarea class="widefat" rows="5" id="<?php echo esc_attr($this->get_field_id('tabs')); ?>" name="<?php echo esc_attr($this->get_field_name('tabs')); ?>"><?php echo esc_textarea((string) $instance['tabs']); ?></textarea>
|
||||
</p>
|
||||
<p>
|
||||
<label for="<?php echo esc_attr($this->get_field_id('per_page')); ?>"><?php esc_html_e('每页数量:', 'aksrt'); ?></label>
|
||||
<input class="tiny-text" type="number" min="1" max="20" id="<?php echo esc_attr($this->get_field_id('per_page')); ?>" name="<?php echo esc_attr($this->get_field_name('per_page')); ?>" value="<?php echo esc_attr((string) $instance['per_page']); ?>">
|
||||
</p>
|
||||
<p>
|
||||
<label for="<?php echo esc_attr($this->get_field_id('style')); ?>"><?php esc_html_e('样式:', 'aksrt'); ?></label>
|
||||
<select class="widefat" id="<?php echo esc_attr($this->get_field_id('style')); ?>" name="<?php echo esc_attr($this->get_field_name('style')); ?>">
|
||||
<option value="default" <?php selected($instance['style'], 'default'); ?>><?php esc_html_e('默认', 'aksrt'); ?></option>
|
||||
<option value="card" <?php selected($instance['style'], 'card'); ?>><?php esc_html_e('卡片', 'aksrt'); ?></option>
|
||||
<option value="mini" <?php selected($instance['style'], 'mini'); ?>><?php esc_html_e('迷你', 'aksrt'); ?></option>
|
||||
</select>
|
||||
</p>
|
||||
<p>
|
||||
<label for="<?php echo esc_attr($this->get_field_id('paginate')); ?>"><?php esc_html_e('分页:', 'aksrt'); ?></label>
|
||||
<select class="widefat" id="<?php echo esc_attr($this->get_field_id('paginate')); ?>" name="<?php echo esc_attr($this->get_field_name('paginate')); ?>">
|
||||
<option value="ajax" <?php selected($instance['paginate'], 'ajax'); ?>><?php esc_html_e('AJAX', 'aksrt'); ?></option>
|
||||
<option value="number" <?php selected($instance['paginate'], 'number'); ?>><?php esc_html_e('数字分页', 'aksrt'); ?></option>
|
||||
<option value="none" <?php selected($instance['paginate'], 'none'); ?>><?php esc_html_e('无', 'aksrt'); ?></option>
|
||||
</select>
|
||||
</p>
|
||||
<?php
|
||||
$this->render_common_form_section($instance);
|
||||
}
|
||||
|
||||
public function update($new_instance, $old_instance): array {
|
||||
$business = [
|
||||
'title' => sanitize_text_field((string) ($new_instance['title'] ?? '')),
|
||||
'tabs' => sanitize_textarea_field((string) ($new_instance['tabs'] ?? '')),
|
||||
'per_page' => max(1, min(20, (int) ($new_instance['per_page'] ?? 8))),
|
||||
'style' => sanitize_key((string) ($new_instance['style'] ?? 'default')),
|
||||
'paginate' => in_array(sanitize_key((string) ($new_instance['paginate'] ?? 'ajax')), ['none', 'ajax', 'number'], true) ? sanitize_key((string) ($new_instance['paginate'] ?? 'ajax')) : 'ajax',
|
||||
]; return $this->update_common_fields($new_instance, $old_instance, $business);
|
||||
}
|
||||
}
|
||||
@@ -1,292 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Template Name: AKSRT - Cart
|
||||
* Template Post Type: page
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
get_header();
|
||||
|
||||
if (!is_user_logged_in()) {
|
||||
$login_url = function_exists('aksrt_get_login_url') ? aksrt_get_login_url() : wp_login_url(get_permalink());
|
||||
wp_safe_redirect($login_url);
|
||||
exit;
|
||||
}
|
||||
|
||||
$cart_view = function_exists('aksrt_get_cart_page_view')
|
||||
? aksrt_get_cart_page_view(get_current_user_id())
|
||||
: [
|
||||
'payment_methods' => [],
|
||||
'shipping_methods' => [],
|
||||
'address_book' => [],
|
||||
'coupon_enabled' => false,
|
||||
'currency_symbol' => '¥',
|
||||
'pending_orders' => [],
|
||||
];
|
||||
|
||||
$pending_orders = is_array($cart_view['pending_orders'] ?? null) ? $cart_view['pending_orders'] : [];
|
||||
$shipping_methods = is_array($cart_view['shipping_methods'] ?? null) ? array_values($cart_view['shipping_methods']) : [];
|
||||
$payment_methods = is_array($cart_view['payment_methods'] ?? null) ? array_values($cart_view['payment_methods']) : [];
|
||||
$address_book = is_array($cart_view['address_book'] ?? null) ? $cart_view['address_book'] : [];
|
||||
?>
|
||||
|
||||
<main id="main-content" class="site-main content-shell" tabindex="-1">
|
||||
<?php if (is_active_sidebar('shop_cart_top_fluid')) : ?>
|
||||
<div class="aksrt-fluid-widget aksrt-fluid-widget--shop"><?php dynamic_sidebar('shop_cart_top_fluid'); ?></div>
|
||||
<?php endif; ?>
|
||||
<?php if (function_exists('aksrt_breadcrumbs')) { aksrt_breadcrumbs(); } ?>
|
||||
|
||||
<div
|
||||
class="aksrt-cart-page container"
|
||||
data-aksrt-cart-page
|
||||
data-currency-symbol="<?php echo esc_attr((string) ($cart_view['currency_symbol'] ?? '¥')); ?>"
|
||||
data-coupon-enabled="<?php echo !empty($cart_view['coupon_enabled']) ? '1' : '0'; ?>"
|
||||
>
|
||||
<?php
|
||||
if (function_exists('aksrt_render_commerce_page_header')) {
|
||||
aksrt_render_commerce_page_header([
|
||||
'title' => __('购物车', 'aksrt'),
|
||||
'subtitle' => __('在下单前确认地址、运费、优惠券和支付方式。', 'aksrt'),
|
||||
'active' => 'cart',
|
||||
]);
|
||||
}
|
||||
?>
|
||||
<div class="aksrt-cart-page__header">
|
||||
<?php if (!empty($pending_orders)) : ?>
|
||||
<div class="aksrt-cart-page__pending">
|
||||
<span class="aksrt-cart-page__pending-label"><?php esc_html_e('待支付订单', 'aksrt'); ?></span>
|
||||
<div class="aksrt-cart-page__pending-list">
|
||||
<?php foreach ($pending_orders as $pending_order) : ?>
|
||||
<button
|
||||
type="button"
|
||||
class="aksrt-cart-page__pending-chip"
|
||||
data-aksrt-resume-order="<?php echo esc_attr((string) ($pending_order['order_number'] ?? '')); ?>"
|
||||
>
|
||||
<?php echo esc_html((string) ($pending_order['order_number'] ?? '')); ?>
|
||||
</button>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="aksrt-cart-page__layout">
|
||||
<section class="aksrt-cart-page__main">
|
||||
<div class="aksrt-cart-page__panel">
|
||||
<div class="aksrt-cart-page__panel-head">
|
||||
<h2><?php esc_html_e('商品清单', 'aksrt'); ?></h2>
|
||||
<button type="button" class="btn-ghost btn-sm" data-aksrt-clear-cart><?php esc_html_e('清空购物车', 'aksrt'); ?></button>
|
||||
</div>
|
||||
<div class="aksrt-cart-page__items" data-aksrt-cart-items>
|
||||
<div class="aksrt-cart-page__empty">
|
||||
<i class="fas fa-shopping-cart" aria-hidden="true"></i>
|
||||
<p><?php esc_html_e('购物车为空,去商品页添加想购买的内容。', 'aksrt'); ?></p>
|
||||
<a href="<?php echo esc_url(home_url('/')); ?>" class="btn-outline btn-sm"><?php esc_html_e('继续浏览', 'aksrt'); ?></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="aksrt-cart-page__grid">
|
||||
<section class="aksrt-cart-page__panel">
|
||||
<div class="aksrt-cart-page__panel-head">
|
||||
<h2><?php esc_html_e('收货地址', 'aksrt'); ?></h2>
|
||||
<button type="button" class="btn-ghost btn-sm" data-aksrt-address-add>
|
||||
<i class="fas fa-plus" aria-hidden="true"></i> <?php esc_html_e('新增地址', 'aksrt'); ?>
|
||||
</button>
|
||||
</div>
|
||||
<div class="aksrt-cart-page__option-list" data-aksrt-address-list>
|
||||
<?php if (empty($address_book)) : ?>
|
||||
<div class="aksrt-cart-page__address-empty" data-aksrt-address-empty>
|
||||
<i class="fas fa-map-marker-alt" aria-hidden="true"></i>
|
||||
<p><?php esc_html_e('暂无收货地址,请先新增。', 'aksrt'); ?></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php foreach ($address_book as $index => $address) : ?>
|
||||
<div class="aksrt-cart-page__option-card aksrt-cart-page__address-card" data-aksrt-address-card="<?php echo esc_attr((string) ($address['id'] ?? 0)); ?>">
|
||||
<label class="aksrt-cart-page__address-label">
|
||||
<input
|
||||
type="radio"
|
||||
name="aksrt_cart_address"
|
||||
value="<?php echo esc_attr((string) ($address['id'] ?? 0)); ?>"
|
||||
<?php checked($index === 0 || !empty($address['is_default'])); ?>
|
||||
>
|
||||
<span class="aksrt-cart-page__option-body">
|
||||
<strong>
|
||||
<?php echo esc_html((string) ($address['name'] ?? '')); ?>
|
||||
<?php if (!empty($address['is_default'])) : ?>
|
||||
<span class="aksrt-cart-page__default-tag"><?php esc_html_e('默认', 'aksrt'); ?></span>
|
||||
<?php endif; ?>
|
||||
</strong>
|
||||
<span><?php echo esc_html((string) ($address['phone'] ?? '')); ?></span>
|
||||
<span><?php echo esc_html((string) ($address['full_text'] ?? '')); ?></span>
|
||||
</span>
|
||||
</label>
|
||||
<div class="aksrt-cart-page__address-actions">
|
||||
<button type="button" class="btn-ghost btn-xs" data-aksrt-address-edit="<?php echo esc_attr((string) ($address['id'] ?? 0)); ?>"><?php esc_html_e('编辑', 'aksrt'); ?></button>
|
||||
<button type="button" class="btn-ghost btn-xs" data-aksrt-address-delete="<?php echo esc_attr((string) ($address['id'] ?? 0)); ?>"><?php esc_html_e('删除', 'aksrt'); ?></button>
|
||||
<?php if (empty($address['is_default'])) : ?>
|
||||
<button type="button" class="btn-ghost btn-xs" data-aksrt-address-set-default="<?php echo esc_attr((string) ($address['id'] ?? 0)); ?>"><?php esc_html_e('设为默认', 'aksrt'); ?></button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Address Editor Modal -->
|
||||
<div class="aksrt-modal" data-aksrt-address-modal hidden>
|
||||
<div class="aksrt-modal__overlay"></div>
|
||||
<div class="aksrt-modal__dialog">
|
||||
<div class="aksrt-modal__header">
|
||||
<h3 data-aksrt-address-modal-title><?php esc_html_e('新增地址', 'aksrt'); ?></h3>
|
||||
<button type="button" class="aksrt-modal__close" data-aksrt-address-modal-close>×</button>
|
||||
</div>
|
||||
<form class="aksrt-modal__body" data-aksrt-address-form>
|
||||
<input type="hidden" name="address_id" value="">
|
||||
<div class="aksrt-cart-page__field">
|
||||
<label><?php esc_html_e('收货人', 'aksrt'); ?> *</label>
|
||||
<input type="text" name="name" class="aksrt-cart-page__input" required maxlength="50">
|
||||
</div>
|
||||
<div class="aksrt-cart-page__field">
|
||||
<label><?php esc_html_e('手机号', 'aksrt'); ?> *</label>
|
||||
<input type="tel" name="phone" class="aksrt-cart-page__input" required maxlength="20">
|
||||
</div>
|
||||
<div class="aksrt-cart-page__field-row">
|
||||
<div class="aksrt-cart-page__field">
|
||||
<label><?php esc_html_e('省份', 'aksrt'); ?></label>
|
||||
<input type="text" name="province" class="aksrt-cart-page__input" maxlength="50">
|
||||
</div>
|
||||
<div class="aksrt-cart-page__field">
|
||||
<label><?php esc_html_e('城市', 'aksrt'); ?></label>
|
||||
<input type="text" name="city" class="aksrt-cart-page__input" maxlength="50">
|
||||
</div>
|
||||
<div class="aksrt-cart-page__field">
|
||||
<label><?php esc_html_e('区/县', 'aksrt'); ?></label>
|
||||
<input type="text" name="district" class="aksrt-cart-page__input" maxlength="50">
|
||||
</div>
|
||||
</div>
|
||||
<div class="aksrt-cart-page__field">
|
||||
<label><?php esc_html_e('详细地址', 'aksrt'); ?> *</label>
|
||||
<input type="text" name="address" class="aksrt-cart-page__input" required maxlength="200">
|
||||
</div>
|
||||
<label class="aksrt-cart-page__checkbox">
|
||||
<input type="checkbox" name="is_default" value="1">
|
||||
<span><?php esc_html_e('设为默认地址', 'aksrt'); ?></span>
|
||||
</label>
|
||||
<div class="aksrt-modal__footer">
|
||||
<button type="button" class="btn-ghost" data-aksrt-address-modal-close><?php esc_html_e('取消', 'aksrt'); ?></button>
|
||||
<button type="submit" class="btn-primary"><?php esc_html_e('保存地址', 'aksrt'); ?></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="aksrt-cart-page__panel">
|
||||
<div class="aksrt-cart-page__panel-head">
|
||||
<h2><?php esc_html_e('配送与支付', 'aksrt'); ?></h2>
|
||||
</div>
|
||||
<div class="aksrt-cart-page__option-columns">
|
||||
<div>
|
||||
<h3 class="aksrt-cart-page__mini-title"><?php esc_html_e('配送方式', 'aksrt'); ?></h3>
|
||||
<div class="aksrt-cart-page__option-list" data-aksrt-shipping-list>
|
||||
<?php foreach ($shipping_methods as $index => $method) : ?>
|
||||
<label class="aksrt-cart-page__option-card">
|
||||
<input
|
||||
type="radio"
|
||||
name="aksrt_cart_shipping"
|
||||
value="<?php echo esc_attr((string) ($method['value'] ?? '')); ?>"
|
||||
<?php checked($index === 0); ?>
|
||||
>
|
||||
<span class="aksrt-cart-page__option-body">
|
||||
<strong><?php echo esc_html((string) ($method['label'] ?? '')); ?></strong>
|
||||
<span><?php echo esc_html((string) ($method['description'] ?? '')); ?></span>
|
||||
</span>
|
||||
</label>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="aksrt-cart-page__mini-title"><?php esc_html_e('支付方式', 'aksrt'); ?></h3>
|
||||
<div class="aksrt-cart-page__option-list" data-aksrt-payment-list>
|
||||
<?php foreach ($payment_methods as $index => $method) : ?>
|
||||
<label class="aksrt-cart-page__option-card">
|
||||
<input
|
||||
type="radio"
|
||||
name="aksrt_cart_payment"
|
||||
value="<?php echo esc_attr((string) ($method['value'] ?? '')); ?>"
|
||||
<?php checked($index === 0); ?>
|
||||
>
|
||||
<span class="aksrt-cart-page__option-body">
|
||||
<strong><?php echo esc_html((string) ($method['label'] ?? '')); ?></strong>
|
||||
<span><?php echo esc_html((string) ($method['description'] ?? '')); ?></span>
|
||||
</span>
|
||||
</label>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="aksrt-cart-page__panel">
|
||||
<div class="aksrt-cart-page__panel-head">
|
||||
<h2><?php esc_html_e('优惠与备注', 'aksrt'); ?></h2>
|
||||
</div>
|
||||
<div class="aksrt-cart-page__coupon-box">
|
||||
<label class="aksrt-cart-page__field">
|
||||
<span><?php esc_html_e('优惠券', 'aksrt'); ?></span>
|
||||
<div class="aksrt-cart-page__coupon-row">
|
||||
<input type="text" class="aksrt-cart-page__input" data-aksrt-coupon-code placeholder="<?php esc_attr_e('输入优惠码后试算', 'aksrt'); ?>">
|
||||
<button type="button" class="btn-outline btn-sm" data-aksrt-preview-checkout><?php esc_html_e('应用并试算', 'aksrt'); ?></button>
|
||||
</div>
|
||||
</label>
|
||||
<p class="aksrt-cart-page__hint" data-aksrt-preview-message><?php esc_html_e('试算后会展示优惠抵扣、运费和最终应付金额。', 'aksrt'); ?></p>
|
||||
|
||||
<label class="aksrt-cart-page__field">
|
||||
<span><?php esc_html_e('订单备注', 'aksrt'); ?></span>
|
||||
<textarea class="aksrt-cart-page__textarea" data-aksrt-customer-note rows="4" placeholder="<?php esc_attr_e('例如发票要求、发货备注等。', 'aksrt'); ?>"></textarea>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="aksrt-cart-page__sidebar">
|
||||
<section class="aksrt-cart-page__panel aksrt-cart-page__summary">
|
||||
<div class="aksrt-cart-page__panel-head">
|
||||
<h2><?php esc_html_e('订单摘要', 'aksrt'); ?></h2>
|
||||
</div>
|
||||
<dl class="aksrt-cart-page__summary-list">
|
||||
<div>
|
||||
<dt><?php esc_html_e('商品小计', 'aksrt'); ?></dt>
|
||||
<dd data-aksrt-summary-subtotal>¥0.00</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt><?php esc_html_e('运费', 'aksrt'); ?></dt>
|
||||
<dd data-aksrt-summary-shipping>¥0.00</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt><?php esc_html_e('优惠抵扣', 'aksrt'); ?></dt>
|
||||
<dd data-aksrt-summary-discount>-¥0.00</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div class="aksrt-cart-page__summary-total">
|
||||
<span><?php esc_html_e('应付总额', 'aksrt'); ?></span>
|
||||
<strong data-aksrt-summary-total>¥0.00</strong>
|
||||
</div>
|
||||
<button type="button" class="btn-primary btn-lg aksrt-cart-page__submit" data-aksrt-create-order disabled>
|
||||
<?php esc_html_e('创建订单', 'aksrt'); ?>
|
||||
</button>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<?php if (is_active_sidebar('shop_cart_bottom_fluid')) : ?>
|
||||
<div class="aksrt-fluid-widget aksrt-fluid-widget--shop"><?php dynamic_sidebar('shop_cart_bottom_fluid'); ?></div>
|
||||
<?php endif; ?>
|
||||
<?php get_footer(); ?>
|
||||
@@ -1,218 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Template Name: AKSRT - Product Edit
|
||||
* Template Post Type: page
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
get_header();
|
||||
|
||||
if (!is_user_logged_in()) {
|
||||
$login_url = function_exists('aksrt_get_login_url') ? aksrt_get_login_url() : wp_login_url(get_permalink());
|
||||
wp_safe_redirect($login_url);
|
||||
exit;
|
||||
}
|
||||
|
||||
$shop_settings = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_shop_settings() : [];
|
||||
if (empty($shop_settings['enabled'])) {
|
||||
wp_safe_redirect(home_url('/'));
|
||||
exit;
|
||||
}
|
||||
|
||||
$product_id = (int) ($_GET['product_id'] ?? 0);
|
||||
$edit_view = function_exists('aksrt_get_product_edit_page_view')
|
||||
? aksrt_get_product_edit_page_view(get_current_user_id(), $product_id)
|
||||
: ['allowed' => false, 'is_edit' => false, 'product' => [], 'taxonomy' => ['categories' => [], 'tags' => []], 'shipping_modes' => [], 'status_options' => []];
|
||||
|
||||
$product = (array) ($edit_view['product'] ?? []);
|
||||
?>
|
||||
|
||||
<main id="main-content" class="site-main content-shell" tabindex="-1">
|
||||
<?php if (function_exists('aksrt_breadcrumbs')) { aksrt_breadcrumbs(); } ?>
|
||||
|
||||
<div
|
||||
class="aksrt-product-editor container"
|
||||
data-aksrt-product-editor
|
||||
data-product-id="<?php echo esc_attr((string) ($product['id'] ?? 0)); ?>"
|
||||
data-is-edit="<?php echo !empty($edit_view['is_edit']) ? '1' : '0'; ?>"
|
||||
>
|
||||
<?php if (empty($edit_view['allowed'])) : ?>
|
||||
<div class="aksrt-product-editor__empty">
|
||||
<i class="fas fa-lock" aria-hidden="true"></i>
|
||||
<p><?php esc_html_e('你没有权限编辑该商品。', 'aksrt'); ?></p>
|
||||
</div>
|
||||
<?php else : ?>
|
||||
<div class="aksrt-product-editor__header">
|
||||
<div>
|
||||
<h1 class="aksrt-product-editor__title">
|
||||
<?php echo !empty($edit_view['is_edit']) ? esc_html__('编辑商品', 'aksrt') : esc_html__('发布商品', 'aksrt'); ?>
|
||||
</h1>
|
||||
<p class="aksrt-product-editor__subtitle"><?php esc_html_e('管理主图、图集、分类标签、发货方式、运费和售后说明。', 'aksrt'); ?></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="aksrt-product-editor__layout" data-aksrt-product-form>
|
||||
<section class="aksrt-product-editor__main">
|
||||
<div class="aksrt-product-editor__panel">
|
||||
<h2><?php esc_html_e('基础信息', 'aksrt'); ?></h2>
|
||||
<label class="aksrt-product-editor__field">
|
||||
<span><?php esc_html_e('商品名称', 'aksrt'); ?></span>
|
||||
<input type="text" name="title" class="aksrt-product-editor__input" value="<?php echo esc_attr((string) ($product['title'] ?? '')); ?>" required>
|
||||
</label>
|
||||
|
||||
<div class="aksrt-product-editor__columns">
|
||||
<label class="aksrt-product-editor__field">
|
||||
<span><?php esc_html_e('价格', 'aksrt'); ?></span>
|
||||
<input type="number" name="price" class="aksrt-product-editor__input" min="0" step="0.01" value="<?php echo esc_attr((string) ($product['price'] ?? 0)); ?>" required>
|
||||
</label>
|
||||
<label class="aksrt-product-editor__field">
|
||||
<span><?php esc_html_e('库存', 'aksrt'); ?></span>
|
||||
<input type="number" name="stock" class="aksrt-product-editor__input" min="0" step="1" value="<?php echo esc_attr((string) ($product['stock'] ?? 0)); ?>" required>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="aksrt-product-editor__field">
|
||||
<span><?php esc_html_e('商品简介', 'aksrt'); ?></span>
|
||||
<textarea name="excerpt" class="aksrt-product-editor__textarea" rows="4"><?php echo esc_textarea((string) ($product['excerpt'] ?? '')); ?></textarea>
|
||||
</label>
|
||||
|
||||
<label class="aksrt-product-editor__field">
|
||||
<span><?php esc_html_e('商品详情', 'aksrt'); ?></span>
|
||||
<textarea name="content" class="aksrt-product-editor__textarea" rows="10"><?php echo esc_textarea((string) ($product['content'] ?? '')); ?></textarea>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="aksrt-product-editor__panel">
|
||||
<h2><?php esc_html_e('商品素材', 'aksrt'); ?></h2>
|
||||
<label class="aksrt-product-editor__field">
|
||||
<span><?php esc_html_e('主图 URL', 'aksrt'); ?></span>
|
||||
<input type="url" name="image" class="aksrt-product-editor__input" value="<?php echo esc_attr((string) ($product['image'] ?? '')); ?>" placeholder="https://">
|
||||
</label>
|
||||
<label class="aksrt-product-editor__field">
|
||||
<span><?php esc_html_e('图集 URL(每行一个)', 'aksrt'); ?></span>
|
||||
<textarea name="gallery" class="aksrt-product-editor__textarea" rows="5"><?php echo esc_textarea(implode("\n", (array) ($product['gallery'] ?? []))); ?></textarea>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="aksrt-product-editor__panel">
|
||||
<h2><?php esc_html_e('规格选项', 'aksrt'); ?></h2>
|
||||
<p class="aksrt-product-editor__hint"><?php esc_html_e('每行一组:规格名|选项1:加价,选项2:加价。例如:颜色|红:0,蓝:10', 'aksrt'); ?></p>
|
||||
<label class="aksrt-product-editor__field">
|
||||
<span><?php esc_html_e('规格配置', 'aksrt'); ?></span>
|
||||
<textarea name="options_text" class="aksrt-product-editor__textarea" rows="6" data-aksrt-options-text placeholder="颜色|红:0,蓝:10 尺码|M:0,L:5"><?php
|
||||
$opt_lines = [];
|
||||
foreach ((array) ($product['options'] ?? []) as $group) {
|
||||
$vals = [];
|
||||
foreach ((array) ($group['values'] ?? []) as $v) {
|
||||
$vals[] = (string) ($v['label'] ?? '') . ':' . (string) ($v['price_change'] ?? 0);
|
||||
}
|
||||
if (!empty($group['name']) && $vals) {
|
||||
$opt_lines[] = (string) $group['name'] . '|' . implode(',', $vals);
|
||||
}
|
||||
}
|
||||
echo esc_textarea(implode("\n", $opt_lines));
|
||||
?></textarea>
|
||||
</label>
|
||||
<div class="aksrt-product-editor__columns">
|
||||
<label class="aksrt-product-editor__field">
|
||||
<span><?php esc_html_e('起售价', 'aksrt'); ?></span>
|
||||
<input type="number" name="start_price" class="aksrt-product-editor__input" min="0" step="0.01" value="<?php echo esc_attr((string) ($product['start_price'] ?? 0)); ?>">
|
||||
</label>
|
||||
<label class="aksrt-product-editor__field">
|
||||
<span><?php esc_html_e('支付模式', 'aksrt'); ?></span>
|
||||
<select name="pay_modo" class="aksrt-product-editor__input">
|
||||
<option value="0" <?php selected((string) ($product['pay_modo'] ?? '0'), '0'); ?>><?php esc_html_e('现金', 'aksrt'); ?></option>
|
||||
<option value="points" <?php selected((string) ($product['pay_modo'] ?? '0'), 'points'); ?>><?php esc_html_e('积分', 'aksrt'); ?></option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label class="aksrt-product-editor__field">
|
||||
<span><?php esc_html_e('商品参数(每行 名称:值)', 'aksrt'); ?></span>
|
||||
<textarea name="product_params" class="aksrt-product-editor__textarea" rows="4"><?php echo esc_textarea((string) ($product['product_params'] ?? '')); ?></textarea>
|
||||
</label>
|
||||
<label class="aksrt-product-editor__field">
|
||||
<span><?php esc_html_e('视频地址(每行一个 URL)', 'aksrt'); ?></span>
|
||||
<textarea name="product_video" class="aksrt-product-editor__textarea" rows="3"><?php echo esc_textarea(implode("\n", (array) ($product['product_video'] ?? []))); ?></textarea>
|
||||
</label>
|
||||
</div>
|
||||
<div class="aksrt-product-editor__panel">
|
||||
<h2><?php esc_html_e('发货与售后', 'aksrt'); ?></h2>
|
||||
<div class="aksrt-product-editor__columns">
|
||||
<label class="aksrt-product-editor__field">
|
||||
<span><?php esc_html_e('发货方式', 'aksrt'); ?></span>
|
||||
<select name="shipping_mode" class="aksrt-product-editor__input">
|
||||
<?php foreach ((array) ($edit_view['shipping_modes'] ?? []) as $mode) : ?>
|
||||
<option value="<?php echo esc_attr((string) ($mode['value'] ?? '')); ?>" <?php selected((string) ($product['shipping_mode'] ?? ''), (string) ($mode['value'] ?? '')); ?>>
|
||||
<?php echo esc_html((string) ($mode['label'] ?? '')); ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
<label class="aksrt-product-editor__field">
|
||||
<span><?php esc_html_e('额外运费', 'aksrt'); ?></span>
|
||||
<input type="number" name="shipping_fee" class="aksrt-product-editor__input" min="0" step="0.01" value="<?php echo esc_attr((string) ($product['shipping_fee'] ?? 0)); ?>">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="aksrt-product-editor__field">
|
||||
<span><?php esc_html_e('售后说明', 'aksrt'); ?></span>
|
||||
<textarea name="after_sale_policy" class="aksrt-product-editor__textarea" rows="4"><?php echo esc_textarea((string) ($product['after_sale_policy'] ?? '')); ?></textarea>
|
||||
</label>
|
||||
|
||||
<label class="aksrt-product-editor__checkbox">
|
||||
<input type="checkbox" name="support_refund" value="1" <?php checked(!empty($product['support_refund'])); ?>>
|
||||
<span><?php esc_html_e('支持退款/售后入口', 'aksrt'); ?></span>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="aksrt-product-editor__sidebar">
|
||||
<div class="aksrt-product-editor__panel">
|
||||
<h2><?php esc_html_e('发布设置', 'aksrt'); ?></h2>
|
||||
<label class="aksrt-product-editor__field">
|
||||
<span><?php esc_html_e('状态', 'aksrt'); ?></span>
|
||||
<select name="status" class="aksrt-product-editor__input">
|
||||
<?php foreach ((array) ($edit_view['status_options'] ?? []) as $status_option) : ?>
|
||||
<option value="<?php echo esc_attr((string) ($status_option['value'] ?? 'draft')); ?>" <?php selected((string) ($product['status'] ?? 'draft'), (string) ($status_option['value'] ?? 'draft')); ?>>
|
||||
<?php echo esc_html((string) ($status_option['label'] ?? '')); ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="aksrt-product-editor__field">
|
||||
<span><?php esc_html_e('分类(按住 Ctrl 可多选)', 'aksrt'); ?></span>
|
||||
<select name="categories[]" class="aksrt-product-editor__input" multiple size="6">
|
||||
<?php foreach ((array) (($edit_view['taxonomy']['categories'] ?? [])) as $category) : ?>
|
||||
<option value="<?php echo esc_attr((string) ($category['id'] ?? 0)); ?>" <?php echo in_array((int) ($category['id'] ?? 0), (array) ($product['categories'] ?? []), true) ? 'selected' : ''; ?>>
|
||||
<?php echo esc_html((string) ($category['name'] ?? '')); ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="aksrt-product-editor__field">
|
||||
<span><?php esc_html_e('标签(英文逗号分隔)', 'aksrt'); ?></span>
|
||||
<input type="text" name="tags" class="aksrt-product-editor__input" value="<?php echo esc_attr(implode(', ', (array) ($product['tags'] ?? []))); ?>">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="aksrt-product-editor__panel">
|
||||
<h2><?php esc_html_e('动作', 'aksrt'); ?></h2>
|
||||
<div class="aksrt-product-editor__actions">
|
||||
<button type="submit" class="btn-primary btn-lg"><?php echo !empty($edit_view['is_edit']) ? esc_html__('保存修改', 'aksrt') : esc_html__('发布商品', 'aksrt'); ?></button>
|
||||
<a href="<?php echo esc_url(function_exists('aksrt_get_user_center_url') ? aksrt_get_user_center_url() : home_url('/')); ?>" class="btn-ghost btn-lg"><?php esc_html_e('返回中心', 'aksrt'); ?></a>
|
||||
</div>
|
||||
<p class="aksrt-product-editor__hint" data-aksrt-product-editor-message><?php esc_html_e('保存后会回到用户中心,商品信息供订单页和商品详情页复用。', 'aksrt'); ?></p>
|
||||
</div>
|
||||
</aside>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<?php get_footer(); ?>
|
||||
@@ -1,471 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Template Name: AKSRT - Product Detail
|
||||
* Template Post Type: page
|
||||
*
|
||||
* Enhanced: options selector, video gallery, params/services modal,
|
||||
* review tab, favorites, related products, mobile tabbar (§6.10).
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
get_header();
|
||||
|
||||
$product_id = (int) ($_GET['product_id'] ?? 0);
|
||||
if ($product_id <= 0) {
|
||||
$product_id = (int) get_query_var('product_id', 0);
|
||||
}
|
||||
|
||||
$shop = class_exists('\AKSRT\Shop\Shop') ? \AKSRT\Shop\Shop::get_instance() : null;
|
||||
$product = function_exists('aksrt_get_product_page_view')
|
||||
? aksrt_get_product_page_view($product_id)
|
||||
: ($shop ? $shop->get_product_detail_view($product_id) : []);
|
||||
|
||||
$user_id = get_current_user_id();
|
||||
$is_fav = $shop && $user_id > 0 ? $shop->is_favorite($user_id, $product_id) : false;
|
||||
$options = $product['options'] ?? [];
|
||||
$has_options = !empty($options);
|
||||
$price_range = $product['price_range'] ?? [];
|
||||
$params = $product['params'] ?? [];
|
||||
$services = $product['services'] ?? [];
|
||||
$pay_modo = $product['pay_modo'] ?? '0';
|
||||
$scores = class_exists('\AKSRT\Shop\ProductReview') ? \AKSRT\Shop\ProductReview::get_instance()->get_product_scores($product_id) : [];
|
||||
$reviews = class_exists('\AKSRT\Shop\ProductReview') ? \AKSRT\Shop\ProductReview::get_instance()->get_reviews($product_id, ['per_page' => 5]) : ['items' => [], 'total' => 0];
|
||||
$author_id = (int) get_post_field('post_author', $product_id);
|
||||
$author = $author_id > 0 ? get_userdata($author_id) : null;
|
||||
?>
|
||||
|
||||
<main id="main-content" class="site-main content-shell content-shell--stream content-shell--product" tabindex="-1">
|
||||
<?php if (is_active_sidebar('shop_product_top_fluid')) : ?>
|
||||
<div class="aksrt-fluid-widget aksrt-fluid-widget--shop"><?php dynamic_sidebar('shop_product_top_fluid'); ?></div>
|
||||
<?php endif; ?>
|
||||
<?php if (function_exists('aksrt_breadcrumbs')) { aksrt_breadcrumbs(); } ?>
|
||||
|
||||
<?php if (!empty($product)) : ?>
|
||||
<div class="content-wrap container">
|
||||
<?php $has_shop_sidebar = is_active_sidebar('shop_product_sidebar'); ?>
|
||||
<div class="content-layout <?php echo $has_shop_sidebar ? 'content-layout--with-sidebar' : 'content-layout--no-sidebar'; ?>">
|
||||
<div class="content-layout__primary">
|
||||
<div
|
||||
class="aksrt-product-page"
|
||||
data-aksrt-product-page
|
||||
data-product-id="<?php echo esc_attr((string) $product_id); ?>"
|
||||
data-options='<?php echo esc_attr(wp_json_encode($options, JSON_UNESCAPED_UNICODE)); ?>'
|
||||
data-base-price="<?php echo esc_attr((string) (($product['start_price'] ?? 0) > 0 ? $product['start_price'] : ($product['price'] ?? 0))); ?>"
|
||||
data-base-stock="<?php echo esc_attr((string) ($product['stock'] ?? 0)); ?>"
|
||||
data-pay-modo="<?php echo esc_attr((string) $pay_modo); ?>"
|
||||
data-price-range="<?php echo !empty($price_range) && (($price_range['min'] ?? 0) !== ($price_range['max'] ?? 0)) ? '1' : '0'; ?>"
|
||||
>
|
||||
<div class="aksrt-product-page__layout">
|
||||
<!-- Gallery -->
|
||||
<section class="aksrt-product-page__gallery aksrt-product-page__panel">
|
||||
<?php if (!empty($product['image'])) : ?>
|
||||
<div class="aksrt-product-page__primary-image" data-aksrt-main-image>
|
||||
<img src="<?php echo esc_url((string) $product['image']); ?>" alt="<?php echo esc_attr((string) ($product['title'] ?? '')); ?>" id="product-main-img">
|
||||
</div>
|
||||
<?php else : ?>
|
||||
<div class="aksrt-product-page__placeholder">
|
||||
<i class="fas fa-image" aria-hidden="true"></i>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php
|
||||
$gallery_items = (array) ($product['gallery'] ?? []);
|
||||
$cover_videos = (array) (get_post_meta($product_id, '_aksrt_cover_videos', true) ?: []);
|
||||
?>
|
||||
<?php if (!empty($gallery_items) || !empty($cover_videos)) : ?>
|
||||
<div class="aksrt-product-page__thumbs">
|
||||
<?php foreach ($cover_videos as $video_url) : ?>
|
||||
<button type="button" class="aksrt-product-page__thumb aksrt-product-page__thumb--video" data-aksrt-product-video="<?php echo esc_url($video_url); ?>">
|
||||
<i class="fas fa-play-circle"></i>
|
||||
</button>
|
||||
<?php endforeach; ?>
|
||||
<?php foreach ($gallery_items as $gallery_image) : ?>
|
||||
<button type="button" class="aksrt-product-page__thumb" data-aksrt-product-thumb="<?php echo esc_url($gallery_image); ?>">
|
||||
<img src="<?php echo esc_url($gallery_image); ?>" alt="">
|
||||
</button>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
|
||||
<!-- Summary -->
|
||||
<section class="aksrt-product-page__summary aksrt-product-page__panel">
|
||||
<div class="aksrt-product-page__badges">
|
||||
<?php if ($pay_modo === 'points') : ?>
|
||||
<span class="aksrt-product-page__badge aksrt-product-page__badge--points"><?php esc_html_e('积分商品', 'aksrt'); ?></span>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($product['marketing']['headline'])) : ?>
|
||||
<span class="aksrt-product-page__badge aksrt-product-page__badge--promo"><?php echo esc_html((string) $product['marketing']['headline']); ?></span>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($product['after_sale']['support_refund'])) : ?>
|
||||
<span class="aksrt-product-page__badge"><?php esc_html_e('支持售后', 'aksrt'); ?></span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<h1 class="aksrt-product-page__title"><?php echo esc_html((string) ($product['title'] ?? '')); ?></h1>
|
||||
|
||||
<!-- Price row with range support -->
|
||||
<div class="aksrt-product-page__price-row">
|
||||
<?php if ($has_options && !empty($price_range) && $price_range['min'] !== $price_range['max']) : ?>
|
||||
<strong class="aksrt-product-page__price" data-aksrt-dynamic-price>
|
||||
<?php echo $pay_modo === 'points' ? '' : '¥'; ?><?php echo esc_html(number_format($price_range['min'], 2)); ?> - <?php echo $pay_modo === 'points' ? '' : '¥'; ?><?php echo esc_html(number_format($price_range['max'], 2)); ?>
|
||||
<?php if ($pay_modo === 'points') : ?><small><?php esc_html_e('积分', 'aksrt'); ?></small><?php endif; ?>
|
||||
</strong>
|
||||
<?php else : ?>
|
||||
<strong class="aksrt-product-page__price" data-aksrt-dynamic-price>
|
||||
<?php echo $pay_modo === 'points' ? '' : '¥'; ?><?php echo esc_html(number_format((float) ($product['price'] ?? 0), 2)); ?>
|
||||
<?php if ($pay_modo === 'points') : ?><small><?php esc_html_e('积分', 'aksrt'); ?></small><?php endif; ?>
|
||||
</strong>
|
||||
<?php endif; ?>
|
||||
<span class="aksrt-product-page__sales"><?php printf(esc_html__('已售 %d', 'aksrt'), (int) ($product['sales'] ?? 0)); ?></span>
|
||||
<?php if (!empty($scores['average'])) : ?>
|
||||
<span class="aksrt-product-page__rating"><i class="fas fa-star"></i> <?php echo esc_html((string) $scores['average']); ?></span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php if (!empty($product['excerpt'])) : ?>
|
||||
<p class="aksrt-product-page__excerpt"><?php echo esc_html((string) $product['excerpt']); ?></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="aksrt-product-page__service-strip">
|
||||
<span><i class="fas fa-shield-alt"></i> <?php esc_html_e('主站统一交易保障', 'aksrt'); ?></span>
|
||||
<span><i class="fas fa-box-open"></i> <?php esc_html_e('与内容主线共用卡片与购买节奏', 'aksrt'); ?></span>
|
||||
</div>
|
||||
|
||||
<!-- Product options selector -->
|
||||
<?php if ($has_options) : ?>
|
||||
<div class="aksrt-product-page__options" data-aksrt-options-selector>
|
||||
<?php foreach ($options as $group_idx => $group) : ?>
|
||||
<div class="aksrt-product-page__option-group" data-group-index="<?php echo esc_attr((string) $group_idx); ?>">
|
||||
<span class="aksrt-product-page__option-label"><?php echo esc_html((string) ($group['name'] ?? '')); ?></span>
|
||||
<div class="aksrt-product-page__option-values aksrt-product-page__option-values--<?php echo esc_attr($group['view_mode'] ?? 'list'); ?>">
|
||||
<?php foreach ((array) ($group['values'] ?? []) as $val) : ?>
|
||||
<button type="button"
|
||||
class="aksrt-product-page__option-btn"
|
||||
data-option-group="<?php echo esc_attr((string) $group_idx); ?>"
|
||||
data-option-label="<?php echo esc_attr((string) ($val['label'] ?? '')); ?>"
|
||||
data-price-change="<?php echo esc_attr((string) ($val['price_change'] ?? 0)); ?>"
|
||||
<?php if (!empty($val['image'])) : ?>data-option-image="<?php echo esc_url($val['image']); ?>"<?php endif; ?>
|
||||
>
|
||||
<?php if (($group['view_mode'] ?? '') === 'img' && !empty($val['image'])) : ?>
|
||||
<img src="<?php echo esc_url($val['image']); ?>" alt="<?php echo esc_attr($val['label']); ?>">
|
||||
<?php endif; ?>
|
||||
<span><?php echo esc_html((string) ($val['label'] ?? '')); ?></span>
|
||||
<?php if ((float) ($val['price_change'] ?? 0) !== 0.0) : ?>
|
||||
<em><?php echo (float) $val['price_change'] > 0 ? '+' : ''; ?><?php echo esc_html(number_format((float) $val['price_change'], 2)); ?></em>
|
||||
<?php endif; ?>
|
||||
</button>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<div class="aksrt-product-page__option-result" data-aksrt-option-result hidden>
|
||||
<span data-aksrt-selected-labels></span>
|
||||
<strong data-aksrt-resolved-price></strong>
|
||||
<span data-aksrt-resolved-stock></span>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Meta info -->
|
||||
<dl class="aksrt-product-page__meta">
|
||||
<div>
|
||||
<dt><?php esc_html_e('库存', 'aksrt'); ?></dt>
|
||||
<dd data-aksrt-stock-display><?php
|
||||
$stock_val = (int) ($product['stock'] ?? 0);
|
||||
echo $stock_val <= -1 ? esc_html__('充足', 'aksrt') : esc_html((string) $stock_val);
|
||||
?></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt><?php esc_html_e('配送', 'aksrt'); ?></dt>
|
||||
<dd><?php echo esc_html((string) (($product['shipping']['delivery_note'] ?? '') ?: __('标准配送', 'aksrt'))); ?></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt><?php esc_html_e('运费', 'aksrt'); ?></dt>
|
||||
<dd><?php
|
||||
$ship_fee = (float) ($product['shipping']['fee'] ?? 0);
|
||||
echo $ship_fee > 0 ? '¥' . esc_html(number_format($ship_fee, 2)) : esc_html__('包邮', 'aksrt');
|
||||
?></dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<!-- Params & Services quick links -->
|
||||
<?php if (!empty($params)) : ?>
|
||||
<button type="button" class="aksrt-product-page__info-trigger" data-aksrt-show-params>
|
||||
<i class="fas fa-list-ul"></i> <?php esc_html_e('商品参数', 'aksrt'); ?> <i class="fas fa-chevron-right"></i>
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($services)) : ?>
|
||||
<button type="button" class="aksrt-product-page__info-trigger" data-aksrt-show-services>
|
||||
<i class="fas fa-shield-alt"></i> <?php esc_html_e('服务保障', 'aksrt'); ?> <i class="fas fa-chevron-right"></i>
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Purchase box -->
|
||||
<div class="aksrt-product-page__purchase-box">
|
||||
<label class="aksrt-product-page__field">
|
||||
<span><?php esc_html_e('购买数量', 'aksrt'); ?></span>
|
||||
<div class="aksrt-product-page__quantity">
|
||||
<button type="button" class="aksrt-product-page__qty-btn" data-aksrt-qty-action="dec">-</button>
|
||||
<input type="number" min="1" max="<?php echo esc_attr((string) max(1, $stock_val > 0 ? $stock_val : 999)); ?>" value="1" class="aksrt-product-page__qty-input" data-aksrt-quantity>
|
||||
<button type="button" class="aksrt-product-page__qty-btn" data-aksrt-qty-action="inc">+</button>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div class="aksrt-product-page__actions">
|
||||
<?php if (is_user_logged_in() && !empty($product['purchase_enabled'])) : ?>
|
||||
<button type="button" class="btn-outline btn-lg" data-aksrt-add-cart><?php esc_html_e('加入购物车', 'aksrt'); ?></button>
|
||||
<button type="button" class="btn-primary btn-lg" data-aksrt-buy-now>
|
||||
<?php echo $pay_modo === 'points' ? esc_html__('积分兑换', 'aksrt') : esc_html__('立即下单', 'aksrt'); ?>
|
||||
</button>
|
||||
<?php elseif (!is_user_logged_in()) : ?>
|
||||
<a href="<?php echo esc_url(function_exists('aksrt_get_login_url') ? aksrt_get_login_url() : wp_login_url(get_permalink())); ?>" class="btn-primary btn-lg">
|
||||
<?php esc_html_e('登录后购买', 'aksrt'); ?>
|
||||
</a>
|
||||
<?php else : ?>
|
||||
<button type="button" class="btn-primary btn-lg" disabled><?php esc_html_e('暂不可购买', 'aksrt'); ?></button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- Auxiliary actions -->
|
||||
<div class="aksrt-product-page__aux-actions">
|
||||
<button type="button" class="aksrt-product-page__aux-btn<?php echo $is_fav ? ' is-active' : ''; ?>" data-aksrt-toggle-fav="<?php echo esc_attr((string) $product_id); ?>">
|
||||
<i class="<?php echo $is_fav ? 'fas' : 'far'; ?> fa-heart"></i>
|
||||
<?php esc_html_e('收藏', 'aksrt'); ?>
|
||||
</button>
|
||||
<button type="button" class="aksrt-product-page__aux-btn" data-aksrt-share>
|
||||
<i class="fas fa-share-alt"></i> <?php esc_html_e('分享', 'aksrt'); ?>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Merchant info -->
|
||||
<?php if ($author) :
|
||||
$store_url = function_exists('aksrt_get_user_home_url')
|
||||
? add_query_arg('tab', 'product', aksrt_get_user_home_url($author_id))
|
||||
: get_author_posts_url($author_id);
|
||||
?>
|
||||
<div class="aksrt-product-page__merchant">
|
||||
<img src="<?php echo esc_url(function_exists('aksrt_get_avatar_url') ? aksrt_get_avatar_url($author_id) : get_avatar_url($author_id)); ?>" alt="" class="aksrt-product-page__merchant-avatar">
|
||||
<div>
|
||||
<strong><?php echo esc_html($author->display_name); ?></strong>
|
||||
<a href="<?php echo esc_url($store_url); ?>" class="aksrt-product-page__merchant-link"><?php esc_html_e('进入店铺', 'aksrt'); ?></a>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Tabs: detail / reviews / custom -->
|
||||
<div class="aksrt-product-page__tabs">
|
||||
<nav class="aksrt-product-page__tab-nav">
|
||||
<button class="aksrt-product-page__tab-btn is-active" data-aksrt-tab="detail"><?php esc_html_e('商品详情', 'aksrt'); ?></button>
|
||||
<button class="aksrt-product-page__tab-btn" data-aksrt-tab="reviews">
|
||||
<?php esc_html_e('评价', 'aksrt'); ?>
|
||||
<?php if (($scores['total'] ?? 0) > 0) : ?>
|
||||
<em>(<?php echo esc_html((string) $scores['total']); ?>)</em>
|
||||
<?php endif; ?>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="aksrt-product-page__tab-panel is-active" data-aksrt-tab-panel="detail">
|
||||
<div class="aksrt-product-page__content">
|
||||
<?php echo wp_kses_post((string) ($product['content'] ?? '')); ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="aksrt-product-page__tab-panel" data-aksrt-tab-panel="reviews">
|
||||
<?php if (!empty($scores['total'])) : ?>
|
||||
<div class="aksrt-product-page__review-summary shop-comment-header" win-ajax-replace="shop-comment-header">
|
||||
<div class="comment-score">
|
||||
<strong class="aksrt-product-page__review-avg"><?php echo esc_html((string) ($scores['average'] ?? '0')); ?></strong>
|
||||
<span class="aksrt-product-page__review-level"><?php echo esc_html((string) ($scores['level'] ?? '')); ?></span>
|
||||
</div>
|
||||
<div class="aksrt-product-page__review-counts comment-filter">
|
||||
<a href="javascript:;" class="comment-orderby c-theme badg" data-review-filter=""><?php esc_html_e('全部', 'aksrt'); ?></a>
|
||||
<a href="javascript:;" class="comment-orderby badg" data-review-filter="has_image"><?php printf(esc_html__('有图 %d', 'aksrt'), (int) ($scores['has_image'] ?? 0)); ?></a>
|
||||
<a href="javascript:;" class="comment-orderby badg" data-review-filter="good"><?php printf(esc_html__('好评 %d', 'aksrt'), (int) ($scores['good'] ?? 0)); ?></a>
|
||||
<a href="javascript:;" class="comment-orderby badg" data-review-filter="bad"><?php printf(esc_html__('中/差评 %d', 'aksrt'), (int) ($scores['bad'] ?? 0)); ?></a>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="aksrt-product-page__review-list shop-commentlist" data-aksrt-review-list data-product-id="<?php echo esc_attr((string) $product_id); ?>">
|
||||
<?php foreach (($reviews['items'] ?? []) as $review) : ?>
|
||||
<div class="aksrt-product-page__review-item comment" data-review-id="<?php echo esc_attr((string) ($review['id'] ?? '')); ?>">
|
||||
<div class="aksrt-product-page__review-header comment-header">
|
||||
<img src="<?php echo esc_url((string) ($review['user_avatar'] ?? '')); ?>" alt="" class="aksrt-product-page__review-avatar avatar-img comt-avatar">
|
||||
<span class="aksrt-product-page__review-name"><?php echo esc_html((string) ($review['user_name'] ?? '')); ?></span>
|
||||
<span class="aksrt-product-page__review-date"><?php echo esc_html((string) ($review['date'] ?? '')); ?></span>
|
||||
<span class="aksrt-product-page__review-stars comment-score-badge">
|
||||
<?php for ($s = 0; $s < 5; $s++) : ?>
|
||||
<i class="<?php echo $s < (int) round((float) ($review['rating_average'] ?? 0)) ? 'fas' : 'far'; ?> fa-star"></i>
|
||||
<?php endfor; ?>
|
||||
</span>
|
||||
</div>
|
||||
<div class="aksrt-product-page__review-text comment-content">
|
||||
<?php
|
||||
$review_body = (string) ($review['content'] ?? '');
|
||||
if ($review_body === '') {
|
||||
echo esc_html__('用户未填写评价内容', 'aksrt');
|
||||
} elseif (function_exists('aksrt_render_comment_content')) {
|
||||
echo aksrt_render_comment_content($review_body); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
} else {
|
||||
echo esc_html($review_body);
|
||||
}
|
||||
?>
|
||||
</div>
|
||||
<?php if (!empty($review['images'])) : ?>
|
||||
<div class="aksrt-product-page__review-images comment-score-imgs imgbox-container">
|
||||
<?php foreach ((array) $review['images'] as $rimg) : ?>
|
||||
<img src="<?php echo esc_url($rimg); ?>" alt="" class="aksrt-product-page__review-img box-img">
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<?php if (empty($reviews['items'])) : ?>
|
||||
<p class="aksrt-product-page__review-empty"><?php esc_html_e('暂无评价', 'aksrt'); ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Related products -->
|
||||
<?php
|
||||
$related_items = [];
|
||||
if ($shop && method_exists($shop, 'build_product_card_view')) {
|
||||
$related_q = new WP_Query([
|
||||
'post_type' => 'aksrt_product',
|
||||
'posts_per_page' => 6,
|
||||
'post_status' => 'publish',
|
||||
'post__not_in' => [$product_id],
|
||||
'ignore_sticky_posts' => true,
|
||||
'no_found_rows' => true,
|
||||
'update_post_meta_cache' => false,
|
||||
'update_post_term_cache' => false,
|
||||
'tax_query' => !empty($product['categories']) ? [[
|
||||
'taxonomy' => 'aksrt_product_cat',
|
||||
'field' => 'term_id',
|
||||
'terms' => wp_list_pluck($product['categories'], 'term_id'),
|
||||
]] : [],
|
||||
'orderby' => 'rand',
|
||||
]);
|
||||
while ($related_q->have_posts()) {
|
||||
$related_q->the_post();
|
||||
$card = $shop->build_product_card_view((int) get_the_ID());
|
||||
if (!empty($card['id'])) {
|
||||
$related_items[] = $card;
|
||||
}
|
||||
}
|
||||
wp_reset_postdata();
|
||||
}
|
||||
?>
|
||||
<?php if (!empty($related_items)) : ?>
|
||||
<section class="aksrt-product-page__related">
|
||||
<h2 class="aksrt-product-page__related-title"><?php esc_html_e('相关推荐', 'aksrt'); ?></h2>
|
||||
<div class="aksrt-product-archive__grid aksrt-product-archive__grid--default aksrt-product-page__related-grid">
|
||||
<?php foreach ($related_items as $related_product) :
|
||||
get_template_part('templates/parts/shop/product', 'card', [
|
||||
'product' => $related_product,
|
||||
'show_sales' => ((int) ($related_product['sales'] ?? 0) > 0),
|
||||
]);
|
||||
endforeach; ?>
|
||||
</div>
|
||||
</section>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- Params modal -->
|
||||
<?php if (!empty($params)) : ?>
|
||||
<div class="aksrt-modal" data-aksrt-params-modal hidden>
|
||||
<div class="aksrt-modal__overlay"></div>
|
||||
<div class="aksrt-modal__dialog">
|
||||
<div class="aksrt-modal__header">
|
||||
<h3><?php esc_html_e('商品参数', 'aksrt'); ?></h3>
|
||||
<button type="button" class="aksrt-modal__close" data-aksrt-modal-close>×</button>
|
||||
</div>
|
||||
<div class="aksrt-modal__body">
|
||||
<table class="aksrt-product-page__params-table">
|
||||
<?php foreach ($params as $param) : ?>
|
||||
<tr>
|
||||
<th><?php echo esc_html((string) ($param['name'] ?? '')); ?></th>
|
||||
<td><?php echo esc_html((string) ($param['value'] ?? '')); ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Services modal -->
|
||||
<?php if (!empty($services)) : ?>
|
||||
<div class="aksrt-modal" data-aksrt-services-modal hidden>
|
||||
<div class="aksrt-modal__overlay"></div>
|
||||
<div class="aksrt-modal__dialog">
|
||||
<div class="aksrt-modal__header">
|
||||
<h3><?php esc_html_e('服务保障', 'aksrt'); ?></h3>
|
||||
<button type="button" class="aksrt-modal__close" data-aksrt-modal-close>×</button>
|
||||
</div>
|
||||
<div class="aksrt-modal__body">
|
||||
<?php foreach ($services as $service) : ?>
|
||||
<div class="aksrt-product-page__service-item">
|
||||
<?php if (!empty($service['icon'])) : ?>
|
||||
<img src="<?php echo esc_url($service['icon']); ?>" alt="" class="aksrt-product-page__service-icon">
|
||||
<?php else : ?>
|
||||
<i class="fas fa-check-circle"></i>
|
||||
<?php endif; ?>
|
||||
<div>
|
||||
<strong><?php echo esc_html((string) ($service['name'] ?? '')); ?></strong>
|
||||
<?php if (!empty($service['desc'])) : ?>
|
||||
<p><?php echo esc_html((string) $service['desc']); ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
</div><!-- .aksrt-product-page -->
|
||||
</div><!-- .content-layout__primary -->
|
||||
<?php if (!empty($has_shop_sidebar)) : ?>
|
||||
<aside class="content-layout__sidebar aksrt-shop-widget-zone">
|
||||
<?php dynamic_sidebar('shop_product_sidebar'); ?>
|
||||
</aside>
|
||||
<?php endif; ?>
|
||||
</div><!-- .content-layout -->
|
||||
</div><!-- .content-wrap -->
|
||||
<?php if (is_active_sidebar('shop_product_bottom_fluid')) : ?>
|
||||
<div class="aksrt-fluid-widget aksrt-fluid-widget--shop"><?php dynamic_sidebar('shop_product_bottom_fluid'); ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Mobile purchase tabbar -->
|
||||
<div class="aksrt-product-page__mobile-bar" data-aksrt-mobile-purchase>
|
||||
<button type="button" class="aksrt-product-page__mobile-btn" data-aksrt-toggle-fav="<?php echo esc_attr((string) $product_id); ?>">
|
||||
<i class="<?php echo $is_fav ? 'fas' : 'far'; ?> fa-heart"></i>
|
||||
</button>
|
||||
<button type="button" class="aksrt-product-page__mobile-btn" data-aksrt-add-cart>
|
||||
<i class="fas fa-shopping-cart"></i>
|
||||
</button>
|
||||
<button type="button" class="aksrt-product-page__mobile-buy btn-primary" data-aksrt-buy-now>
|
||||
<?php echo $pay_modo === 'points' ? esc_html__('积分兑换', 'aksrt') : esc_html__('立即购买', 'aksrt'); ?>
|
||||
</button>
|
||||
</div>
|
||||
<?php else : ?>
|
||||
<div class="aksrt-product-page container">
|
||||
<div class="aksrt-product-page__empty">
|
||||
<i class="fas fa-box-open" aria-hidden="true"></i>
|
||||
<p><?php esc_html_e('商品不存在或已下架。', 'aksrt'); ?></p>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</main>
|
||||
|
||||
<?php get_footer(); ?>
|
||||
@@ -1,59 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Shared product card (archive / related / storefront).
|
||||
*
|
||||
* Expected args:
|
||||
* - product: array from \AKSRT\Shop\Shop::build_product_card_view()
|
||||
* - show_sales: bool (optional)
|
||||
* - sales_cfg: array (optional; used when show_sales not provided)
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
$args = is_array($args ?? null) ? $args : [];
|
||||
$product = is_array($args['product'] ?? null) ? $args['product'] : [];
|
||||
if (empty($product) || empty($product['id'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$sales = (int) ($product['sales'] ?? 0);
|
||||
if (array_key_exists('show_sales', $args)) {
|
||||
$show_sales = !empty($args['show_sales']);
|
||||
} else {
|
||||
$sales_cfg = is_array($args['sales_cfg'] ?? null) ? $args['sales_cfg'] : [];
|
||||
$mode = (string) ($sales_cfg['show'] ?? 'show');
|
||||
$min_sales = max(0, (int) ($sales_cfg['show_min'] ?? 0));
|
||||
$show_sales = ($mode === 'show' && $sales > 0) || ($mode === 'threshold' && $sales >= $min_sales);
|
||||
}
|
||||
|
||||
$title = (string) ($product['title'] ?? '');
|
||||
$permalink = (string) ($product['permalink'] ?? '#');
|
||||
$image = (string) ($product['image'] ?? '');
|
||||
$excerpt = (string) ($product['excerpt'] ?? '');
|
||||
$price = (float) ($product['price'] ?? 0);
|
||||
?>
|
||||
<article class="aksrt-product-card product-item posts-item card ajax-item">
|
||||
<a href="<?php echo esc_url($permalink !== '' ? $permalink : '#'); ?>" class="aksrt-product-card__link">
|
||||
<?php if ($image !== '') : ?>
|
||||
<div class="aksrt-product-card__image">
|
||||
<img src="<?php echo esc_url($image); ?>" alt="<?php echo esc_attr($title); ?>" loading="lazy">
|
||||
</div>
|
||||
<?php else : ?>
|
||||
<div class="aksrt-product-card__image aksrt-product-card__image--placeholder"><i class="fas fa-image" aria-hidden="true"></i></div>
|
||||
<?php endif; ?>
|
||||
<div class="aksrt-product-card__body">
|
||||
<h3 class="aksrt-product-card__title"><?php echo esc_html($title); ?></h3>
|
||||
<?php if ($excerpt !== '') : ?>
|
||||
<p class="aksrt-product-card__excerpt"><?php echo esc_html(wp_trim_words($excerpt, 15)); ?></p>
|
||||
<?php endif; ?>
|
||||
<div class="aksrt-product-card__footer">
|
||||
<span class="aksrt-product-card__price">¥<?php echo esc_html(number_format($price, 2)); ?></span>
|
||||
<?php if ($show_sales) : ?>
|
||||
<span class="aksrt-product-card__sales"><?php printf(esc_html__('已售 %d', 'aksrt'), $sales); ?></span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</article>
|
||||
@@ -1,149 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Product category hierarchical filter bar (zibll shop-cat-filter parity).
|
||||
*
|
||||
* Expects in scope:
|
||||
* - $filter array from Shop::build_product_cat_filter()
|
||||
* - $orderby string
|
||||
* - $order string ASC|DESC
|
||||
* - $term array|null
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
$filter = is_array($filter ?? null) ? $filter : [];
|
||||
$orderby = sanitize_key((string) ($orderby ?? ($filter['orderby'] ?? 'date')));
|
||||
$order = strtoupper(sanitize_key((string) ($order ?? ($filter['order'] ?? 'DESC'))));
|
||||
if (!in_array($order, ['ASC', 'DESC'], true)) {
|
||||
$order = 'DESC';
|
||||
}
|
||||
$current_id = (int) ($filter['current_id'] ?? 0);
|
||||
$mode = (string) ($filter['mode'] ?? 'siblings');
|
||||
$siblings = is_array($filter['siblings'] ?? null) ? $filter['siblings'] : [];
|
||||
$children = is_array($filter['children'] ?? null) ? $filter['children'] : [];
|
||||
$child_tabs = is_array($filter['child_tabs'] ?? null) ? $filter['child_tabs'] : [];
|
||||
$parent_url = (string) ($filter['parent_url'] ?? '');
|
||||
$parent_id = (int) ($filter['parent_id'] ?? 0);
|
||||
$term_name = is_array($term ?? null) ? (string) ($term['name'] ?? '') : '';
|
||||
|
||||
$order_labels = [
|
||||
'date' => __('最新', 'aksrt'),
|
||||
'views' => __('最热', 'aksrt'),
|
||||
'favorite_count' => __('收藏', 'aksrt'),
|
||||
'score' => __('评分', 'aksrt'),
|
||||
'sales' => __('销量', 'aksrt'),
|
||||
];
|
||||
|
||||
$base_term_url = !empty($term['url']) ? (string) $term['url'] : '';
|
||||
if ($base_term_url === '' && $current_id > 0) {
|
||||
$link = get_term_link($current_id, 'aksrt_product_cat');
|
||||
$base_term_url = is_wp_error($link) ? '' : (string) $link;
|
||||
}
|
||||
|
||||
$blog = wp_strip_all_tags(get_bloginfo('name'));
|
||||
?>
|
||||
<div class="shop-cat-filter aksrt-shop-cat-filter zib-widget relative" data-aksrt-shop-cat-filter win-ajax-replace="shop-cat-filter">
|
||||
<?php if (!empty($siblings)) : ?>
|
||||
<div class="shop-cat-filter-tab list-inline scroll-x mini-scrollbar tab-nav-theme" data-aksrt-cat-tabs>
|
||||
<?php foreach ($siblings as $sib) :
|
||||
$sid = (int) ($sib['id'] ?? 0);
|
||||
$is_active = ($mode === 'with_children' && $sid === $current_id)
|
||||
|| ($mode === 'parent_siblings' && $sid === $parent_id)
|
||||
|| ($mode === 'siblings' && $sid === $current_id);
|
||||
?>
|
||||
<?php if ($is_active) : ?>
|
||||
<li class="active"><span class="focus-color"><?php echo esc_html((string) ($sib['name'] ?? '')); ?></span></li>
|
||||
<?php else : ?>
|
||||
<li>
|
||||
<a class="ajax-next"
|
||||
ajax-replace="true"
|
||||
route="1"
|
||||
route-title="<?php echo esc_attr((string) ($sib['name'] ?? '') . ' - ' . __('商城', 'aksrt') . ($blog ? ' · ' . $blog : '')); ?>"
|
||||
href="<?php echo esc_url((string) ($sib['url'] ?? '#')); ?>"
|
||||
data-aksrt-shop-term="<?php echo esc_attr((string) $sid); ?>">
|
||||
<?php echo esc_html((string) ($sib['name'] ?? '')); ?>
|
||||
</a>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($mode === 'with_children' && !empty($child_tabs)) : ?>
|
||||
<div class="shop-cat-filter-child" data-aksrt-cat-children>
|
||||
<span class="focus-color"><?php esc_html_e('全部', 'aksrt'); ?></span>
|
||||
<?php foreach ($child_tabs as $child) : ?>
|
||||
<a class="ajax-next"
|
||||
ajax-replace="true"
|
||||
route="1"
|
||||
route-title="<?php echo esc_attr((string) ($child['name'] ?? '') . ' - ' . __('商城', 'aksrt')); ?>"
|
||||
href="<?php echo esc_url((string) ($child['url'] ?? '#')); ?>"
|
||||
data-aksrt-shop-term="<?php echo esc_attr((string) ((int) ($child['id'] ?? 0))); ?>">
|
||||
<?php echo esc_html((string) ($child['name'] ?? '')); ?>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php elseif ($mode === 'parent_siblings' && !empty($children)) : ?>
|
||||
<div class="shop-cat-filter-child" data-aksrt-cat-children>
|
||||
<?php if ($parent_url !== '') : ?>
|
||||
<a class="ajax-next" ajax-replace="true" route="1" href="<?php echo esc_url($parent_url); ?>" data-aksrt-shop-term="<?php echo esc_attr((string) $parent_id); ?>">
|
||||
<?php esc_html_e('全部', 'aksrt'); ?>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
<?php foreach ($children as $child) :
|
||||
$cid = (int) ($child['id'] ?? 0);
|
||||
?>
|
||||
<?php if ($cid === $current_id) : ?>
|
||||
<span class="focus-color"><?php echo esc_html((string) ($child['name'] ?? '')); ?></span>
|
||||
<?php else : ?>
|
||||
<a class="ajax-next"
|
||||
ajax-replace="true"
|
||||
route="1"
|
||||
route-title="<?php echo esc_attr((string) ($child['name'] ?? '') . ' - ' . __('商城', 'aksrt')); ?>"
|
||||
href="<?php echo esc_url((string) ($child['url'] ?? '#')); ?>"
|
||||
data-aksrt-shop-term="<?php echo esc_attr((string) $cid); ?>">
|
||||
<?php echo esc_html((string) ($child['name'] ?? '')); ?>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="shop-cat-filter-orderby shop-cat-filter-child" win-ajax-replace="orderby" data-aksrt-shop-orderby-bar>
|
||||
<span class="opacity5"><?php esc_html_e('排序', 'aksrt'); ?></span>
|
||||
<?php foreach ($order_labels as $key => $label) :
|
||||
if ($orderby === $key) : ?>
|
||||
<span class="focus-color"><?php echo esc_html($label); ?></span>
|
||||
<?php else :
|
||||
$href = $base_term_url !== '' ? add_query_arg(['orderby' => $key], $base_term_url) : add_query_arg(['orderby' => $key]);
|
||||
?>
|
||||
<a rel="nofollow" ajax-replace="true" class="ajax-next" href="<?php echo esc_url($href); ?>" data-aksrt-shop-orderby="<?php echo esc_attr($key); ?>">
|
||||
<?php echo esc_html($label); ?>
|
||||
</a>
|
||||
<?php endif;
|
||||
endforeach;
|
||||
|
||||
// Price toggle (single 售价 control).
|
||||
if ($orderby === 'price') {
|
||||
$next_order = $order === 'DESC' ? 'ASC' : 'DESC';
|
||||
$icon = $next_order === 'DESC' ? 'up' : 'down';
|
||||
$href = $base_term_url !== ''
|
||||
? add_query_arg(['orderby' => 'price', 'order' => $next_order], $base_term_url)
|
||||
: add_query_arg(['orderby' => 'price', 'order' => $next_order]);
|
||||
?>
|
||||
<a rel="nofollow" ajax-replace="true" class="ajax-next focus-color" href="<?php echo esc_url($href); ?>" data-aksrt-shop-orderby="price" data-aksrt-shop-order="<?php echo esc_attr($next_order); ?>">
|
||||
<?php esc_html_e('售价', 'aksrt'); ?><i class="opacity5 ml3 fa fa-long-arrow-<?php echo esc_attr($icon); ?>"></i>
|
||||
</a>
|
||||
<?php } else {
|
||||
$href = $base_term_url !== ''
|
||||
? add_query_arg(['orderby' => 'price'], $base_term_url)
|
||||
: add_query_arg(['orderby' => 'price']);
|
||||
?>
|
||||
<a rel="nofollow" ajax-replace="true" class="ajax-next" href="<?php echo esc_url($href); ?>" data-aksrt-shop-orderby="price">
|
||||
<?php esc_html_e('售价', 'aksrt'); ?>
|
||||
</a>
|
||||
<?php } ?>
|
||||
</div>
|
||||
</div>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user