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:
kate522 committed 2026-08-02 21:25:52 +08:00
1 parent 2bbd7c01a3
commit 1d2505356e
103 files changed
+46 -21267

No files matched your search

@@ -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>
-215
View File
@@ -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>
-173
View File
@@ -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 = {}
+1 -1
View File
@@ -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 },
],
},
],
}
-755
View File
@@ -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}}
-400
View File
@@ -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);
}
-491
View File
@@ -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;
}
-483
View File
@@ -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, "&quot;") + '">' +
(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
View File
@@ -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) {
-295
View File
@@ -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);
}
})();
-407
View File
@@ -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 = "网络错误,请稍后重试。";
});
});
})();