admin模块接入数据库
This commit is contained in:
@@ -118,73 +118,169 @@ class="group-item"
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
|
||||
interface Label {
|
||||
id: number;
|
||||
name: string;
|
||||
groupId: number;
|
||||
status: boolean;
|
||||
showInMobile: boolean;
|
||||
}
|
||||
|
||||
interface Group {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const groups = reactive<Group[]>([
|
||||
{ id: 0, name: '全部' } as Group,
|
||||
{ id: 1, name: '商务礼品专题' } as Group,
|
||||
{ id: 2, name: '员工福利' } as Group,
|
||||
{ id: 3, name: '主题' } as Group
|
||||
])
|
||||
|
||||
const labels = reactive<Label[]>([
|
||||
{ id: 1, name: '外事礼品', groupId: 1, status: true, showInMobile: true } as Label,
|
||||
{ id: 2, name: '会议庆典', groupId: 1, status: true, showInMobile: true } as Label,
|
||||
{ id: 3, name: '入职纪念', groupId: 2, status: true, showInMobile: true } as Label,
|
||||
{ id: 4, name: '员工激励', groupId: 2, status: true, showInMobile: true } as Label,
|
||||
{ id: 5, name: '员工生日', groupId: 2, status: true, showInMobile: true } as Label,
|
||||
{ id: 6, name: '三八妇女节', groupId: 3, status: true, showInMobile: true } as Label,
|
||||
{ id: 7, name: '新春快乐', groupId: 3, status: true, showInMobile: true } as Label
|
||||
])
|
||||
import { ref, reactive, computed, onMounted, watch } from 'vue'
|
||||
import {
|
||||
fetchLabelGroups, saveLabelGroup, deleteLabelGroup,
|
||||
fetchLabels, saveLabel, deleteLabel,
|
||||
ProductLabelGroup, ProductLabel
|
||||
} from '@/services/admin/productLabelService.uts'
|
||||
|
||||
const groups = ref<ProductLabelGroup[]>([])
|
||||
const labels = ref<ProductLabel[]>([])
|
||||
const isLoading = ref(false)
|
||||
const activeGroupIndex = ref(0)
|
||||
|
||||
const filteredLabels = computed((): Label[] => {
|
||||
const activeGroup = groups[activeGroupIndex.value]
|
||||
if (activeGroupIndex.value === 0) return labels
|
||||
return labels.filter((l: Label): boolean => l.groupId === activeGroup.id)
|
||||
const activeGroupId = computed((): string | null => {
|
||||
if (groups.value.length === 0) return null
|
||||
return groups.value[activeGroupIndex.value]?.id ?? null
|
||||
})
|
||||
|
||||
const filteredLabels = computed((): ProductLabel[] => {
|
||||
// 方案 B:全部是真实分组,所以直接根据 activeGroupId 过滤
|
||||
// 如果当前选中的是“全部”组,则展示该组下的标签(或根据业务逻辑展示所有)
|
||||
const gid = activeGroupId.value
|
||||
if (gid == null) return [] as ProductLabel[]
|
||||
|
||||
// 如果“全部”组的名称确实是“全部”,且业务逻辑是展示所有标签:
|
||||
if (groups.value[activeGroupIndex.value]?.name === '全部') {
|
||||
return labels.value
|
||||
}
|
||||
|
||||
return labels.value.filter((l: ProductLabel): boolean => l.group_id === gid)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
initData()
|
||||
})
|
||||
|
||||
async function initData() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
// 1. 加载分组
|
||||
const groupRes = await fetchLabelGroups()
|
||||
groups.value = groupRes
|
||||
|
||||
// 如果没有任何分组(包括“全部”),则尝试初始化一个
|
||||
if (groups.value.length === 0) {
|
||||
await saveLabelGroup({ name: '全部', sort_order: 0 } as ProductLabelGroup)
|
||||
const retryGroups = await fetchLabelGroups()
|
||||
groups.value = retryGroups
|
||||
}
|
||||
|
||||
// 2. 加载所有标签
|
||||
const labelRes = await fetchLabels()
|
||||
labels.value = labelRes
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '初始化失败', icon: 'none' })
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新标签列表
|
||||
async function refreshLabels() {
|
||||
const res = await fetchLabels()
|
||||
labels.value = res
|
||||
}
|
||||
|
||||
// Drawer logic
|
||||
const showDrawerMask = ref(false)
|
||||
const showDrawer = ref(false)
|
||||
const isEditLabel = ref(false)
|
||||
const editingLabelId = ref<string | null>(null)
|
||||
const labelForm = reactive({ name: '' })
|
||||
|
||||
function openLabelDrawer(label: Label | null = null) {
|
||||
if (label) { labelForm.name = label.name } else { labelForm.name = '' }
|
||||
showDrawerMask.value = true
|
||||
setTimeout(() => {
|
||||
showDrawer.value = true
|
||||
}, 50)
|
||||
function openLabelDrawer(label: ProductLabel | null = null) {
|
||||
if (label != null) {
|
||||
isEditLabel.value = true
|
||||
editingLabelId.value = label.id ?? null
|
||||
labelForm.name = label.name
|
||||
} else {
|
||||
isEditLabel.value = false
|
||||
editingLabelId.value = null
|
||||
labelForm.name = ''
|
||||
}
|
||||
showDrawerMask.value = true
|
||||
setTimeout(() => {
|
||||
showDrawer.value = true
|
||||
}, 50)
|
||||
}
|
||||
|
||||
function closeLabelDrawer() {
|
||||
showDrawer.value = false
|
||||
setTimeout(() => {
|
||||
showDrawerMask.value = false
|
||||
}, 300)
|
||||
showDrawer.value = false
|
||||
setTimeout(() => {
|
||||
showDrawerMask.value = false
|
||||
}, 300)
|
||||
}
|
||||
|
||||
async function handleSaveLabel() {
|
||||
if (!labelForm.name) {
|
||||
uni.showToast({ title: '请输入名称', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
const gid = activeGroupId.value
|
||||
if (gid == null) return
|
||||
|
||||
const labelData: ProductLabel = {
|
||||
id: editingLabelId.value ?? undefined,
|
||||
name: labelForm.name,
|
||||
group_id: gid,
|
||||
is_active: true,
|
||||
show_in_mobile: true,
|
||||
sort_order: 0
|
||||
}
|
||||
|
||||
const success = await saveLabel(labelData)
|
||||
if (success) {
|
||||
uni.showToast({ title: '保存成功' })
|
||||
closeLabelDrawer()
|
||||
refreshLabels()
|
||||
}
|
||||
}
|
||||
|
||||
async function onToggleStatus(label: ProductLabel) {
|
||||
const updated = { ...label, is_active: !label.is_active } as ProductLabel
|
||||
await saveLabel(updated)
|
||||
refreshLabels()
|
||||
}
|
||||
|
||||
async function onToggleMobile(label: ProductLabel) {
|
||||
const updated = { ...label, show_in_mobile: !label.show_in_mobile } as ProductLabel
|
||||
await saveLabel(updated)
|
||||
refreshLabels()
|
||||
}
|
||||
|
||||
async function deleteLabelItem(label: ProductLabel) {
|
||||
if (label.id == null) return
|
||||
uni.showModal({
|
||||
title: '提示',
|
||||
content: '确定删除该标签吗?',
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
const success = await deleteLabel(label.id!)
|
||||
if (success) {
|
||||
uni.showToast({ title: '删除成功' })
|
||||
refreshLabels()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function openGroupModal() {
|
||||
uni.showToast({ title: '添加分组功能已模拟', icon: 'none' })
|
||||
}
|
||||
|
||||
function deleteLabel(label: Label) {
|
||||
const idx = labels.indexOf(label)
|
||||
if (idx > -1) { labels.splice(idx, 1) }
|
||||
uni.showModal({
|
||||
title: '添加分组',
|
||||
editable: true,
|
||||
placeholderText: '请输入分组名称',
|
||||
success: async (res) => {
|
||||
if (res.confirm && res.content) {
|
||||
await saveLabelGroup({ name: res.content, sort_order: groups.value.length } as ProductLabelGroup)
|
||||
const groupRes = await fetchLabelGroups()
|
||||
groups.value = groupRes
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,25 +1,20 @@
|
||||
<template>
|
||||
<view class="page-container">
|
||||
<view class="page-header">
|
||||
<text class="page-title">商品管理</text>
|
||||
<text class="page-subtitle">Component: ProductList</text>
|
||||
</view>
|
||||
|
||||
<view class="page-content">
|
||||
<view class="placeholder-card">
|
||||
<text class="placeholder-title">页面占位</text>
|
||||
<text class="placeholder-desc">该功能模块正在开发中</text>
|
||||
<text class="placeholder-info">当前采用 CRMEB 路由体系 1:1 映射</text>
|
||||
<text class="placeholder-title">正在跳转...</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref } from 'vue'
|
||||
import { onMounted } from 'vue'
|
||||
import { openRoute } from '@/layouts/admin/store/adminNavStore.uts'
|
||||
|
||||
// TODO: 实现 商品管理 的具体功能
|
||||
const loading = ref<boolean>(false)
|
||||
onMounted(() => {
|
||||
openRoute('product_productList')
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -5,16 +5,16 @@
|
||||
<view class="search-row">
|
||||
<view class="search-item">
|
||||
<text class="search-label">模板搜索:</text>
|
||||
<input class="search-input" placeholder="请输入模板名称" />
|
||||
<input class="search-input" v-model="searchName" placeholder="请输入模板名称" @confirm="handleSearch" />
|
||||
</view>
|
||||
<button class="btn-query">查询</button>
|
||||
<button class="btn-query" @click="handleSearch">查询</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 数据表格区域 -->
|
||||
<view class="table-card">
|
||||
<view class="table-toolbar">
|
||||
<button class="btn-add" @click="openDrawer()">添加商品参数</button>
|
||||
<button class="btn-add" @click="openDrawer(null)">添加商品参数</button>
|
||||
</view>
|
||||
|
||||
<view class="table-header">
|
||||
@@ -37,7 +37,7 @@
|
||||
<view class="td-cell flex-2 row-center">
|
||||
<text class="btn-link" @click="openDrawer(item)">编辑</text>
|
||||
<view class="divider"></view>
|
||||
<text class="btn-link delete" @click="deleteItem(index)">删除</text>
|
||||
<text class="btn-link delete" @click="deleteItem(item)">删除</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -101,107 +101,132 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref, reactive } from 'vue'
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { fetchParamTemplates, saveParamTemplate, deleteParamTemplate, ProductParamKV, ProductParamTemplate } from '@/services/admin/productSpecParamService.uts'
|
||||
|
||||
interface ParamKV {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
type ParamItem = ProductParamTemplate
|
||||
|
||||
interface ParamItem {
|
||||
id: number;
|
||||
name: string;
|
||||
sort: number;
|
||||
params: ParamKV[];
|
||||
}
|
||||
|
||||
const list = reactive<ParamItem[]>([
|
||||
{ id: 1, name: '手机数码', sort: 1, params: [{label: '品牌', value: '华为'}, {label: '型号', value: 'Mate 60'}] as ParamKV[] },
|
||||
{ id: 2, name: '家用电器', sort: 2, params: [{label: '能效等级', value: '一级'}, {label: '产地', value: '中国'}] as ParamKV[] }
|
||||
])
|
||||
const list = ref<ParamItem[]>([])
|
||||
const isLoading = ref(false)
|
||||
const searchName = ref('')
|
||||
|
||||
const showDrawerMask = ref(false)
|
||||
const showDrawer = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const editIndex = ref(-1)
|
||||
const editingId = ref<string | null>(null)
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
sort: 0,
|
||||
params: [] as ParamKV[]
|
||||
name: '',
|
||||
sort: 0,
|
||||
params: [] as ProductParamKV[]
|
||||
})
|
||||
|
||||
function formatParams(params: ParamKV[]): string {
|
||||
return params.map(p => p.label + ':' + p.value).join(' | ')
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
|
||||
async function loadData() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const res = await fetchParamTemplates(searchName.value)
|
||||
list.value = res
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
loadData()
|
||||
}
|
||||
|
||||
function formatParams(params: ProductParamKV[]): string {
|
||||
return params.map(p => p.label + ':' + p.value).join(' | ')
|
||||
}
|
||||
|
||||
function openDrawer(item: ParamItem | null = null) {
|
||||
if (item != null) {
|
||||
isEdit.value = true
|
||||
form.name = item.name
|
||||
form.sort = item.sort
|
||||
form.params = JSON.parse<ParamKV[]>(JSON.stringify(item.params)) as ParamKV[]
|
||||
editIndex.value = list.indexOf(item)
|
||||
} else {
|
||||
isEdit.value = false
|
||||
form.name = ''
|
||||
form.sort = 0
|
||||
form.params = [{ label: '', value: '' }] as ParamKV[]
|
||||
}
|
||||
if (item != null) {
|
||||
isEdit.value = true
|
||||
editingId.value = item.id ?? null
|
||||
form.name = item.name
|
||||
form.sort = item.sort_order
|
||||
form.params = JSON.parse<ProductParamKV[]>(JSON.stringify(item.params)) as ProductParamKV[]
|
||||
} else {
|
||||
isEdit.value = false
|
||||
editingId.value = null
|
||||
form.name = ''
|
||||
form.sort = 0
|
||||
form.params = [{ label: '', value: '' }] as ProductParamKV[]
|
||||
}
|
||||
|
||||
showDrawerMask.value = true
|
||||
setTimeout(() => {
|
||||
showDrawer.value = true
|
||||
}, 50)
|
||||
showDrawerMask.value = true
|
||||
setTimeout(() => {
|
||||
showDrawer.value = true
|
||||
}, 50)
|
||||
}
|
||||
|
||||
function closeDrawer() {
|
||||
showDrawer.value = false
|
||||
setTimeout(() => {
|
||||
showDrawerMask.value = false
|
||||
}, 300)
|
||||
showDrawer.value = false
|
||||
setTimeout(() => {
|
||||
showDrawerMask.value = false
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function addParamRow() {
|
||||
form.params.push({ label: '', value: '' } as ParamKV)
|
||||
form.params.push({ label: '', value: '' } as ProductParamKV)
|
||||
}
|
||||
|
||||
function removeParamRow(index: number) {
|
||||
form.params.splice(index, 1)
|
||||
form.params.splice(index, 1)
|
||||
}
|
||||
|
||||
function saveParam() {
|
||||
if (!form.name) {
|
||||
uni.showToast({ title: '请输入模板名称', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (isEdit.value) {
|
||||
const item = list[editIndex.value]
|
||||
item.name = form.name
|
||||
item.sort = form.sort
|
||||
item.params = JSON.parse<ParamKV[]>(JSON.stringify(form.params)) as ParamKV[]
|
||||
} else {
|
||||
list.unshift({
|
||||
id: Date.now() % 1000,
|
||||
name: form.name,
|
||||
sort: form.sort,
|
||||
params: JSON.parse<ParamKV[]>(JSON.stringify(form.params)) as ParamKV[]
|
||||
} as ParamItem)
|
||||
}
|
||||
closeDrawer()
|
||||
uni.showToast({ title: '保存成功', icon: 'success' })
|
||||
async function saveParam() {
|
||||
if (!form.name) {
|
||||
uni.showToast({ title: '请输入模板名称', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
const tpl: ProductParamTemplate = {
|
||||
id: editingId.value ?? undefined,
|
||||
name: form.name,
|
||||
sort_order: form.sort,
|
||||
params: JSON.parse<ProductParamKV[]>(JSON.stringify(form.params)) as ProductParamKV[],
|
||||
is_active: true
|
||||
}
|
||||
|
||||
try {
|
||||
const success = await saveParamTemplate(tpl)
|
||||
if (success) {
|
||||
uni.showToast({ title: '保存成功', icon: 'success' })
|
||||
closeDrawer()
|
||||
loadData()
|
||||
} else {
|
||||
uni.showToast({ title: '保存失败', icon: 'none' })
|
||||
}
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '操作异常', icon: 'none' })
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function deleteItem(index: number) {
|
||||
uni.showModal({
|
||||
title: '提示',
|
||||
content: '确定删除该参数模板吗?',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
list.splice(index, 1)
|
||||
}
|
||||
}
|
||||
})
|
||||
function deleteItem(item: ParamItem) {
|
||||
if (item.id == null) return
|
||||
uni.showModal({
|
||||
title: '提示',
|
||||
content: '确定删除该参数模板吗?',
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
const success = await deleteParamTemplate(item.id!)
|
||||
if (success) {
|
||||
uni.showToast({ title: '删除成功', icon: 'success' })
|
||||
loadData()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -20,129 +20,480 @@
|
||||
|
||||
<!-- 表单内容 -->
|
||||
<view class="form-card">
|
||||
<view class="form-item">
|
||||
<view class="label"><text class="required">*</text><text>商品类型:</text></view>
|
||||
<view class="input-wrap">
|
||||
<view class="radio-group">
|
||||
<view class="radio-item active">
|
||||
<text class="radio-circle on"></text>
|
||||
<view class="radio-txt">
|
||||
<text class="main">普通商品</text>
|
||||
<text class="sub">(物流发货)</text>
|
||||
<!-- Step 0: 基础信息 -->
|
||||
<view v-if="activeStep === 0" class="step-content">
|
||||
<view class="form-item">
|
||||
<view class="label"><text class="required">*</text><text>商品类型:</text></view>
|
||||
<view class="input-wrap">
|
||||
<view class="radio-group">
|
||||
<view class="radio-item active">
|
||||
<text class="radio-circle on"></text>
|
||||
<view class="radio-txt">
|
||||
<text class="main">普通商品</text>
|
||||
<text class="sub">(物流发货)</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="form-item">
|
||||
<view class="label"><text class="required">*</text><text>商品名称:</text></view>
|
||||
<view class="input-wrap">
|
||||
<view class="input-box">
|
||||
<input class="real-input" v-model="form.name" maxlength="80" placeholder="请输入商品名称" />
|
||||
<text class="count">{{ form.name.length }}/80</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="form-item">
|
||||
<view class="label"><text class="required">*</text><text>单位:</text></view>
|
||||
<view class="input-wrap">
|
||||
<view class="input-box small">
|
||||
<input class="real-input" v-model="form.unit" maxlength="5" placeholder="件" />
|
||||
<text class="count">{{ form.unit.length }}/5</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="form-item">
|
||||
<view class="label"><text class="required">*</text><text>商品轮播图:</text></view>
|
||||
<view class="input-wrap">
|
||||
<view class="image-uploader">
|
||||
<view v-for="(img, i) in form.image_urls" :key="i" class="img-item">
|
||||
<image :src="img" mode="aspectFill" />
|
||||
<view class="img-close" @click="removeImage(i)">×</view>
|
||||
</view>
|
||||
<view v-if="form.image_urls.length < 10" class="upload-btn" @click="onUploadCarousel">
|
||||
<text class="icon">+</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="tip">建议尺寸:800*800,默认首张图为主图,最多上传10张</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="form-item">
|
||||
<view class="label"><text class="required">*</text><text>商品分类:</text></view>
|
||||
<view class="input-wrap">
|
||||
<picker :value="categoryIndex" :range="categoryLabels" @change="onCategoryChange">
|
||||
<view class="tag-selector">
|
||||
<view v-if="selectedCategoryName" class="tag-item">
|
||||
<text>{{ selectedCategoryName }}</text>
|
||||
</view>
|
||||
<text class="add-link">{{ selectedCategoryName ? '点击切换分类' : '选择分类' }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="form-item">
|
||||
<view class="label"><text>商品标签:</text></view>
|
||||
<view class="input-wrap">
|
||||
<view class="tag-selector">
|
||||
<view v-for="(tag, tidx) in form.tags" :key="tidx" class="tag-item">
|
||||
<text>{{ tag }}</text>
|
||||
<text class="close" @click="removeTag(tag)">×</text>
|
||||
</view>
|
||||
<text class="add-link" @click="showLabelModal = true">选择标签</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="form-item">
|
||||
<view class="label"><text>商品状态:</text></view>
|
||||
<view class="input-wrap">
|
||||
<view class="radio-group-simple">
|
||||
<view class="radio-simple" :class="{ on: form.status === 1 }" @click="form.status = 1">
|
||||
<text class="dot"></text>
|
||||
<text>上架</text>
|
||||
</view>
|
||||
<view class="radio-simple" :class="{ on: form.status === 2 }" @click="form.status = 2">
|
||||
<text class="dot"></text>
|
||||
<text>下架</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="form-item">
|
||||
<view class="label"><text class="required">*</text><text>商品名称:</text></view>
|
||||
<view class="input-wrap">
|
||||
<view class="input-box">
|
||||
<input class="real-input" value="UR2024夏季新款女装复古纯欲氛围感一字肩短款T恤衫UWG440060" />
|
||||
<text class="count">36/80</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="form-item">
|
||||
<view class="label"><text class="required">*</text><text>单位:</text></view>
|
||||
<view class="input-wrap">
|
||||
<view class="input-box small">
|
||||
<input class="real-input" value="件" />
|
||||
<text class="count">1/5</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="form-item">
|
||||
<view class="label"><text class="required">*</text><text>商品轮播图:</text></view>
|
||||
<view class="input-wrap">
|
||||
<view class="image-uploader">
|
||||
<view v-for="(img, i) in carouselImages" :key="i" class="img-item">
|
||||
<image :src="img" mode="aspectFill" />
|
||||
<view class="img-close">×</view>
|
||||
</view>
|
||||
<view class="upload-btn">
|
||||
<text class="icon">+</text>
|
||||
<!-- Step 1: 规格库存 -->
|
||||
<view v-if="activeStep === 1" class="step-content">
|
||||
<view class="form-item">
|
||||
<view class="label"><text>基础售价:</text></view>
|
||||
<view class="input-wrap">
|
||||
<view class="input-box small">
|
||||
<input class="real-input" type="digit" v-model="form.price" placeholder="0.00" />
|
||||
</view>
|
||||
</view>
|
||||
<text class="tip">建议尺寸:800*800,可拖拽改变图片顺序,默认首张图为主图,最多上传10张</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="form-item">
|
||||
<view class="label"><text>添加视频:</text></view>
|
||||
<view class="input-wrap">
|
||||
<view class="upload-btn v-btn">
|
||||
<text class="v-icon">📹</text>
|
||||
</view>
|
||||
<text class="tip">建议时长:9~30秒,视频宽高比16:9</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="form-item">
|
||||
<view class="label"><text class="required">*</text><text>商品分类:</text></view>
|
||||
<view class="input-wrap">
|
||||
<view class="tag-selector">
|
||||
<view v-for="tag in categories" :key="tag" class="tag-item">
|
||||
<text>{{ tag }}</text>
|
||||
<text class="close">×</text>
|
||||
<view class="form-item">
|
||||
<view class="label"><text>库存总数:</text></view>
|
||||
<view class="input-wrap">
|
||||
<view class="input-box small">
|
||||
<input class="real-input" type="number" v-model="form.stock" placeholder="0" />
|
||||
</view>
|
||||
<text class="add-link">新增分类</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="form-item">
|
||||
<view class="label"><text>规格模板:</text></view>
|
||||
<view class="input-wrap">
|
||||
<picker :range="specTemplateLabels" @change="onSpecTemplateChange">
|
||||
<button class="btn-white small-btn">快速导入模板</button>
|
||||
</picker>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- SKU 表格区域 -->
|
||||
<view class="sku-table-wrap">
|
||||
<view class="sku-table-header">
|
||||
<text class="sku-th flex-2">规格</text>
|
||||
<text class="sku-th flex-1">价格</text>
|
||||
<text class="sku-th flex-1">库存</text>
|
||||
<text class="sku-th flex-1">编号</text>
|
||||
</view>
|
||||
<view v-for="(sku, idx) in skus" :key="idx" class="sku-tr">
|
||||
<text class="sku-td flex-2">{{ formatSpecs(sku.specifications) }}</text>
|
||||
<view class="sku-td flex-1">
|
||||
<input class="sku-input" type="digit" v-model="sku.price" />
|
||||
</view>
|
||||
<view class="sku-td flex-1">
|
||||
<input class="sku-input" type="number" v-model="sku.stock" />
|
||||
</view>
|
||||
<view class="sku-td flex-1">
|
||||
<input class="sku-input" v-model="sku.sku_code" />
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="skus.length === 0" class="empty-sku">
|
||||
<text>请先选择规格或添加属性</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="form-item">
|
||||
<view class="label"><text>商品标签:</text></view>
|
||||
<view class="input-wrap">
|
||||
<view class="mock-btn-select">选择标签</view>
|
||||
<!-- Step 2: 商品详情 -->
|
||||
<view v-if="activeStep === 2" class="step-content">
|
||||
<view class="form-item row-align-start">
|
||||
<view class="label"><text class="required">*</text><text>商品详情:</text></view>
|
||||
<view class="input-wrap">
|
||||
<textarea class="textarea-box" v-model="form.description" placeholder="请输入商品详细描述内容..." />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="form-item">
|
||||
<view class="label"><text>商品状态:</text></view>
|
||||
<view class="input-wrap">
|
||||
<view class="radio-group-simple">
|
||||
<view class="radio-simple on">
|
||||
<text class="dot"></text>
|
||||
<text>上架</text>
|
||||
</view>
|
||||
<view class="radio-simple">
|
||||
<text class="dot"></text>
|
||||
<text>下架</text>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 其他步骤占位 -->
|
||||
<view v-if="activeStep > 2" class="step-content">
|
||||
<view class="placeholder-step">
|
||||
<text>步骤 {{ steps[activeStep] }} 逻辑接入中...</text>
|
||||
<text class="sub-tip">该模块涉及物流模板、会员等级定价等,将在后续阶段补齐</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="footer-btns">
|
||||
<button class="btn-next">下一步</button>
|
||||
<button class="btn-save">保存</button>
|
||||
<button v-if="activeStep > 0" class="btn-prev" @click="prevStep">上一步</button>
|
||||
<button v-if="activeStep < steps.length - 1" class="btn-next" @click="nextStep">下一步</button>
|
||||
<button v-if="activeStep === steps.length - 1" class="btn-save" @click="handleSave">保存</button>
|
||||
</view>
|
||||
|
||||
<!-- 标签选择弹窗 -->
|
||||
<view v-if="showLabelModal" class="popup-mask" @click="showLabelModal = false">
|
||||
<view class="popup-card" @click.stop>
|
||||
<view class="popup-header">
|
||||
<text class="popup-title">选择商品标签</text>
|
||||
<text class="popup-close" @click="showLabelModal = false">×</text>
|
||||
</view>
|
||||
<scroll-view class="popup-body" scroll-y="true" style="height: 300px;">
|
||||
<view class="label-grid">
|
||||
<view
|
||||
v-for="label in labelList"
|
||||
:key="label.id"
|
||||
class="label-check-item"
|
||||
:class="{ selected: form.tags.includes(label.name) }"
|
||||
@click="toggleTag(label.name)"
|
||||
>
|
||||
<text class="label-check-txt">{{ label.name }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<view class="popup-footer">
|
||||
<button class="btn primary" @click="showLabelModal = false">完成</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { openRoute } from '@/layouts/admin/store/adminNavStore.uts'
|
||||
import {
|
||||
fetchAdminProductDetail,
|
||||
saveAdminProduct,
|
||||
AdminProduct,
|
||||
AdminProductSku
|
||||
} from '@/services/admin/productService.uts'
|
||||
import { fetchAdminCategoryList, AdminCategory } from '@/services/admin/productCategoryService.uts'
|
||||
import { fetchLabels, ProductLabel } from '@/services/admin/productLabelService.uts'
|
||||
import { fetchSpecTemplates, fetchParamTemplates, ProductSpecTemplate, ProductParamTemplate } from '@/services/admin/productSpecParamService.uts'
|
||||
|
||||
// --- 状态定义 ---
|
||||
const activeStep = ref(0)
|
||||
const steps = ['基础信息', '规格库存', '商品详情', '物流设置', '会员价/佣金', '营销设置', '其他设置']
|
||||
const isLoading = ref(false)
|
||||
const productId = ref<string | null>(null)
|
||||
|
||||
const carouselImages = ref([
|
||||
'https://img1.baidu.com/it/u=254065646,3100346083&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500',
|
||||
'https://img2.baidu.com/it/u=3025255470,3051061730&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500',
|
||||
'https://img2.baidu.com/it/u=3775079632,546700868&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500'
|
||||
])
|
||||
// 响应式表单数据
|
||||
const form = reactive({
|
||||
name: '',
|
||||
subtitle: '',
|
||||
unit: '件',
|
||||
category_id: '',
|
||||
image_urls: [] as string[],
|
||||
video_urls: [] as string[],
|
||||
tags: [] as string[],
|
||||
status: 1, // 1:上架, 2:下架
|
||||
description: '',
|
||||
price: 0,
|
||||
stock: 0,
|
||||
product_code: '',
|
||||
attributes: {} as any
|
||||
})
|
||||
|
||||
const categories = ref(['生活家居', '运动专区 / 361', '运动专区 / 特步', '运动专区 / 匹克'])
|
||||
const skus = ref<AdminProductSku[]>([])
|
||||
|
||||
// 基础资料列表
|
||||
const categories = ref<AdminCategory[]>([])
|
||||
const labelList = ref<ProductLabel[]>([])
|
||||
const specTemplates = ref<ProductSpecTemplate[]>([])
|
||||
const paramTemplates = ref<ProductParamTemplate[]>([])
|
||||
|
||||
// --- UI 控制状态 ---
|
||||
const showLabelModal = ref(false)
|
||||
const categoryIndex = ref(0)
|
||||
|
||||
// 计算属性:分类名称列表供 picker 使用
|
||||
const categoryLabels = computed((): string[] => {
|
||||
return categories.value.map(c => c.name)
|
||||
})
|
||||
|
||||
// 计算属性:当前选中分类名称
|
||||
const selectedCategoryName = computed((): string => {
|
||||
const cate = categories.value.find(c => c.id === form.category_id)
|
||||
return cate != null ? cate.name : ''
|
||||
})
|
||||
|
||||
// 计算属性:规格模板名称列表
|
||||
const specTemplateLabels = computed((): string[] => {
|
||||
return specTemplates.value.map(t => t.name)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
// 1. 获取商品 ID
|
||||
const pages = getCurrentPages()
|
||||
const currentPage = pages[pages.length - 1]
|
||||
const options = currentPage.options as Record<string, string | undefined>
|
||||
productId.value = options['id'] ?? null
|
||||
|
||||
// 2. 初始化所有基础资料
|
||||
initBaseData()
|
||||
|
||||
// 3. 如果是编辑模式,加载详情
|
||||
if (productId.value != null) {
|
||||
loadProductDetail(productId.value!)
|
||||
}
|
||||
})
|
||||
|
||||
async function initBaseData() {
|
||||
try {
|
||||
const [cateRes, labelRes, specRes, paramRes] = await Promise.all([
|
||||
fetchAdminCategoryList({ isActive: true }),
|
||||
fetchLabels(),
|
||||
fetchSpecTemplates(),
|
||||
fetchParamTemplates()
|
||||
])
|
||||
categories.value = cateRes
|
||||
labelList.value = labelRes
|
||||
specTemplates.value = specRes
|
||||
paramTemplates.value = paramRes
|
||||
} catch (e) {
|
||||
console.error('加载基础资料失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProductDetail(id: string) {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const res = await fetchAdminProductDetail(id)
|
||||
if (res.product != null) {
|
||||
const p = res.product!
|
||||
form.name = p.name
|
||||
form.subtitle = p.subtitle ?? ''
|
||||
form.unit = p.unit ?? '件'
|
||||
form.category_id = p.category_id
|
||||
form.image_urls = p.image_urls ?? []
|
||||
form.video_urls = p.video_urls ?? []
|
||||
form.tags = p.tags ?? []
|
||||
form.status = p.status
|
||||
form.description = p.description ?? ''
|
||||
form.price = p.price
|
||||
form.stock = p.stock
|
||||
form.product_code = p.product_code ?? ''
|
||||
form.attributes = p.attributes ?? {}
|
||||
|
||||
skus.value = res.skus
|
||||
}
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '加载详情失败', icon: 'none' })
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// --- 交互逻辑 ---
|
||||
|
||||
function goBack() {
|
||||
openRoute('product_productList')
|
||||
}
|
||||
|
||||
function nextStep() {
|
||||
if (activeStep.value < steps.length - 1) {
|
||||
activeStep.value++
|
||||
}
|
||||
}
|
||||
|
||||
function prevStep() {
|
||||
if (activeStep.value > 0) {
|
||||
activeStep.value--
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const productData: Partial<AdminProduct> = {
|
||||
id: productId.value ?? undefined,
|
||||
...form
|
||||
}
|
||||
const success = await saveAdminProduct(productData, skus.value)
|
||||
if (success) {
|
||||
uni.showToast({ title: '保存成功', icon: 'success' })
|
||||
setTimeout(() => goBack(), 1500)
|
||||
} else {
|
||||
uni.showToast({ title: '保存失败', icon: 'none' })
|
||||
}
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '系统异常', icon: 'none' })
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 模拟图片上传
|
||||
function onUploadCarousel() {
|
||||
uni.chooseImage({
|
||||
count: 10 - form.image_urls.length,
|
||||
success: (res) => {
|
||||
form.image_urls.push(...res.tempFilePaths)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function removeImage(index: number) {
|
||||
form.image_urls.splice(index, 1)
|
||||
}
|
||||
|
||||
function onCategoryChange(e: any) {
|
||||
const index = parseInt(String(e.detail.value))
|
||||
categoryIndex.value = index
|
||||
form.category_id = categories.value[index].id
|
||||
}
|
||||
|
||||
function toggleTag(name: string) {
|
||||
const index = form.tags.indexOf(name)
|
||||
if (index > -1) {
|
||||
form.tags.splice(index, 1)
|
||||
} else {
|
||||
form.tags.push(name)
|
||||
}
|
||||
}
|
||||
|
||||
function removeTag(name: string) {
|
||||
const index = form.tags.indexOf(name)
|
||||
if (index > -1) {
|
||||
form.tags.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// --- 规格与 SKU 核心逻辑 ---
|
||||
|
||||
function formatSpecs(specs: any): string {
|
||||
if (specs == null) return '默认规格'
|
||||
if (typeof specs === 'object') {
|
||||
const vals = Object.values(specs as Record<string, any>)
|
||||
return vals.join(',')
|
||||
}
|
||||
return String(specs)
|
||||
}
|
||||
|
||||
function onSpecTemplateChange(e: any) {
|
||||
const index = parseInt(String(e.detail.value))
|
||||
const tpl = specTemplates.value[index]
|
||||
if (tpl == null) return
|
||||
|
||||
// 解析规格字符串,例如 "颜色:红色,蓝色;尺寸:S,M"
|
||||
const specGroups = tpl.specs.split(';')
|
||||
const dimensions: Array<Array<{key: string, value: string}>> = []
|
||||
|
||||
specGroups.forEach(group => {
|
||||
const parts = group.split(':')
|
||||
if (parts.length === 2) {
|
||||
const key = parts[0].trim()
|
||||
const values = parts[1].split(',')
|
||||
dimensions.push(values.map(v => ({ key, value: v.trim() })))
|
||||
}
|
||||
})
|
||||
|
||||
if (dimensions.length === 0) return
|
||||
|
||||
// 笛卡尔积生成组合
|
||||
const combinations = cartesianProduct(dimensions)
|
||||
|
||||
// 转换为 SKU 列表
|
||||
skus.value = combinations.map(combo => {
|
||||
const specObj: Record<string, string> = {}
|
||||
combo.forEach(item => {
|
||||
specObj[item.key] = item.value
|
||||
})
|
||||
|
||||
return {
|
||||
sku_code: '',
|
||||
specifications: specObj,
|
||||
price: form.price || 0,
|
||||
stock: 0,
|
||||
status: 1,
|
||||
image_url: form.image_urls.length > 0 ? form.image_urls[0] : null
|
||||
} as AdminProductSku
|
||||
})
|
||||
|
||||
uni.showToast({ title: `生成了 ${skus.value.length} 个规格` })
|
||||
}
|
||||
|
||||
/**
|
||||
* 笛卡尔积辅助函数
|
||||
*/
|
||||
function cartesianProduct<T>(arrays: T[][]): T[][] {
|
||||
const result: T[][] = [[]]
|
||||
for (const array of arrays) {
|
||||
const temp: T[][] = []
|
||||
for (const res of result) {
|
||||
for (const item of array) {
|
||||
temp.push([...res, item])
|
||||
}
|
||||
}
|
||||
if (temp.length > 0) result.length = 0, result.push(...temp)
|
||||
}
|
||||
return result
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -341,7 +692,158 @@ function goBack() {
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
padding-bottom: 40px;
|
||||
.btn-prev { background: #fff; color: #666; border: 1px solid #d9d9d9; padding: 0 24px; height: 40px; border-radius: 4px; }
|
||||
.btn-next { background: #1890ff; color: #fff; border: none; padding: 0 24px; height: 40px; border-radius: 4px; }
|
||||
.btn-save { background: #fff; color: #1890ff; border: 1px solid #1890ff; padding: 0 24px; height: 40px; border-radius: 4px; }
|
||||
.btn-save { background: #52c41a; color: #fff; border: none; padding: 0 24px; height: 40px; border-radius: 4px; }
|
||||
}
|
||||
|
||||
/* SKU 表格样式 */
|
||||
.sku-table-wrap {
|
||||
margin-top: 20px;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.sku-table-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
background-color: #fafafa;
|
||||
height: 44px;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
.sku-th {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
padding: 0 12px;
|
||||
text-align: center;
|
||||
}
|
||||
.sku-tr {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
height: 54px;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
.sku-td {
|
||||
padding: 0 12px;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
text-align: center;
|
||||
}
|
||||
.sku-input {
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 4px;
|
||||
height: 32px;
|
||||
width: 90%;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
.empty-sku {
|
||||
padding: 40px 0;
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.textarea-box {
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 4px;
|
||||
padding: 12px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.placeholder-step {
|
||||
padding: 100px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: #999;
|
||||
font-size: 16px;
|
||||
.sub-tip { font-size: 13px; color: #ccc; }
|
||||
}
|
||||
|
||||
.small-btn {
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
padding: 0 12px;
|
||||
font-size: 13px;
|
||||
border: 1px solid #dcdfe6;
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.row-align-start {
|
||||
align-items: flex-start !important;
|
||||
}
|
||||
|
||||
/* 弹窗与标签选择样式 */
|
||||
.popup-mask {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 999;
|
||||
}
|
||||
.popup-card {
|
||||
width: 500px;
|
||||
background-color: #fff;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.popup-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
.popup-title { font-size: 16px; font-weight: bold; }
|
||||
.popup-close { font-size: 20px; color: #999; cursor: pointer; }
|
||||
.popup-body { padding: 20px; }
|
||||
.label-grid {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
.label-check-item {
|
||||
padding: 6px 16px;
|
||||
background-color: #f5f5f5;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 4px;
|
||||
&.selected {
|
||||
background-color: #e6f7ff;
|
||||
border-color: #1890ff;
|
||||
.label-check-txt { color: #1890ff; }
|
||||
}
|
||||
}
|
||||
.label-check-txt { font-size: 13px; color: #666; }
|
||||
.popup-footer {
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.btn.primary {
|
||||
background-color: #1890ff;
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 0 20px;
|
||||
height: 32px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.flex-1 { flex: 1; }
|
||||
.flex-2 { flex: 2; }
|
||||
</style>
|
||||
|
||||
@@ -9,37 +9,170 @@
|
||||
</view>
|
||||
|
||||
<view class="content-card">
|
||||
<view class="product-info">
|
||||
<image class="p-img" src="https://img1.baidu.com/it/u=254065646,3100346083&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500" mode="aspectFill" />
|
||||
<text class="p-name">UR2024夏季新款女装复古纯欲氛围感一字肩短款T恤衫</text>
|
||||
<view v-if="isLoading" class="loading-box">
|
||||
<text>加载中...</text>
|
||||
</view>
|
||||
|
||||
<view class="price-table">
|
||||
<view class="th-row">
|
||||
<view class="th flex-2"><text>规格名称</text></view>
|
||||
<view class="th flex-1"><text>售价</text></view>
|
||||
<view class="th flex-1"><text>普通会员价</text></view>
|
||||
<view class="th flex-1"><text>黄金会员价</text></view>
|
||||
<view class="th flex-1"><text>铂金会员价</text></view>
|
||||
<template v-else>
|
||||
<view class="product-info">
|
||||
<image class="p-img" :src="productImage || '/static/logo.png'" mode="aspectFill" />
|
||||
<text class="p-name">{{ productName }}</text>
|
||||
</view>
|
||||
<view class="tr-row">
|
||||
<view class="td flex-2"><text>XL,卡其</text></view>
|
||||
<view class="td flex-1"><text>¥99.00</text></view>
|
||||
<view class="td flex-1"><input class="price-input" value="89.00" /></view>
|
||||
<view class="td flex-1"><input class="price-input" value="79.00" /></view>
|
||||
<view class="td flex-1"><input class="price-input" value="69.00" /></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="footer-btns">
|
||||
<button class="btn-save">保存设置</button>
|
||||
</view>
|
||||
<view class="price-table">
|
||||
<view class="th-row">
|
||||
<view class="th flex-2"><text>规格名称</text></view>
|
||||
<view class="th flex-1"><text>售价</text></view>
|
||||
<view v-for="level in levels" :key="level.id" class="th flex-1">
|
||||
<text>{{ level.name }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="skus.length === 0" class="empty-table">
|
||||
<text>该商品暂无规格信息</text>
|
||||
</view>
|
||||
|
||||
<view v-for="(sku, sIdx) in skus" :key="sku.id" class="tr-row">
|
||||
<view class="td flex-2"><text>{{ formatSpecs(sku.specifications) }}</text></view>
|
||||
<view class="td flex-1"><text>¥{{ sku.price.toFixed(2) }}</text></view>
|
||||
<view v-for="level in levels" :key="level.id" class="td flex-1">
|
||||
<input
|
||||
class="price-input"
|
||||
type="digit"
|
||||
v-model="priceMatrix[sku.id][level.id]"
|
||||
:placeholder="calcDefaultPrice(sku.price, level.discount_percent)"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="footer-btns">
|
||||
<button class="btn-save" :disabled="isSaving" @click="handleSave">保存设置</button>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref, onMounted, reactive } from 'vue'
|
||||
import { openRoute } from '@/layouts/admin/store/adminNavStore.uts'
|
||||
import {
|
||||
fetchActiveUserLevels,
|
||||
fetchProductSkus,
|
||||
fetchMemberPrices,
|
||||
saveMemberPrices,
|
||||
UserLevel,
|
||||
ProductSku
|
||||
} from '@/services/admin/productMemberPriceService.uts'
|
||||
|
||||
// --- State ---
|
||||
const productId = ref<string>('')
|
||||
const productName = ref<string>('加载中...')
|
||||
const productImage = ref<string>('')
|
||||
const levels = ref<UserLevel[]>([])
|
||||
const skus = ref<ProductSku[]>([])
|
||||
const priceMatrix = reactive<Record<string, Record<string, string>>>({})
|
||||
|
||||
const isLoading = ref(true)
|
||||
const isSaving = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
// 从路由参数获取 productId
|
||||
const pages = getCurrentPages()
|
||||
const currentPage = pages[pages.length - 1]
|
||||
const options = currentPage.options as Record<string, string | undefined>
|
||||
productId.value = options['id'] ?? ''
|
||||
|
||||
if (!productId.value) {
|
||||
uni.showToast({ title: '未找到商品ID', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
initData()
|
||||
})
|
||||
|
||||
async function initData() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
// 并行获取等级、SKU和已设价格
|
||||
const [levelRes, skuRes, priceRes] = await Promise.all([
|
||||
fetchActiveUserLevels(),
|
||||
fetchProductSkus(productId.value),
|
||||
fetchMemberPrices(productId.value)
|
||||
])
|
||||
|
||||
levels.value = levelRes
|
||||
skus.value = skuRes
|
||||
|
||||
// 初始化矩阵并填充已设价格
|
||||
skuRes.forEach(sku => {
|
||||
priceMatrix[sku.id] = {}
|
||||
levelRes.forEach(level => {
|
||||
// 查找是否已有定价
|
||||
const existing = priceRes.find(p => p.sku_id === sku.id && p.level_id === level.id)
|
||||
priceMatrix[sku.id][level.id] = existing != null ? String(existing.member_price) : ''
|
||||
})
|
||||
})
|
||||
|
||||
// 如果有 SKU,拿第一个的信息展示在头部
|
||||
if (skuRes.length > 0) {
|
||||
// 实际开发中建议单独查一次 Product 信息,这里简化处理
|
||||
productImage.value = skuRes[0].image_url ?? ''
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '加载数据失败', icon: 'none' })
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatSpecs(specs: any): string {
|
||||
if (specs == null) return '默认规格'
|
||||
// 假设规格存的是对象 { "颜色": "红色", "尺寸": "XL" }
|
||||
if (typeof specs === 'object') {
|
||||
const vals = Object.values(specs as Record<string, any>)
|
||||
return vals.join(',')
|
||||
}
|
||||
return String(specs)
|
||||
}
|
||||
|
||||
function calcDefaultPrice(basePrice: number, discount: number): string {
|
||||
return (basePrice * discount / 100).toFixed(2)
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
isSaving.value = true
|
||||
try {
|
||||
const saveRows: Array<{ sku_id: string; level_id: string; member_price: number }> = []
|
||||
|
||||
// 遍历矩阵,只保存有输入数值的项
|
||||
Object.keys(priceMatrix).forEach(skuId => {
|
||||
const levelPrices = priceMatrix[skuId]
|
||||
Object.keys(levelPrices).forEach(levelId => {
|
||||
const val = levelPrices[levelId]
|
||||
if (val != null && val.trim() !== '') {
|
||||
saveRows.push({
|
||||
sku_id: skuId,
|
||||
level_id: levelId,
|
||||
member_price: parseFloat(val)
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const ok = await saveMemberPrices(productId.value, saveRows)
|
||||
if (ok) {
|
||||
uni.showToast({ title: '保存成功', icon: 'success' })
|
||||
} else {
|
||||
uni.showToast({ title: '保存失败', icon: 'none' })
|
||||
}
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '保存异常', icon: 'none' })
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
openRoute('product_productList')
|
||||
@@ -52,17 +185,21 @@ function goBack() {
|
||||
.back-link { display: flex; flex-direction: row; align-items: center; gap: 4px; color: #666; cursor: pointer; }
|
||||
.header-title { font-size: 16px; font-weight: bold; color: #333; }
|
||||
}
|
||||
.content-card { background: #fff; border-radius: 4px; padding: 24px; }
|
||||
.content-card { background: #fff; border-radius: 4px; padding: 24px; min-height: 400px; }
|
||||
.loading-box, .empty-table { padding: 60px 0; text-align: center; color: #999; font-size: 14px; }
|
||||
|
||||
.product-info { display: flex; flex-direction: row; align-items: center; gap: 16px; margin-bottom: 30px;
|
||||
.p-img { width: 64px; height: 64px; border-radius: 4px; }
|
||||
.p-name { font-size: 15px; font-weight: bold; color: #333; }
|
||||
.p-img { width: 64px; height: 64px; border-radius: 4px; background: #f5f5f5; }
|
||||
.p-name { font-size: 15px; font-weight: bold; color: #333; flex: 1; }
|
||||
}
|
||||
.price-table { border: 1px solid #f0f0f0; border-radius: 4px; }
|
||||
.th-row { display: flex; flex-direction: row; background: #f8f9fa; border-bottom: 1px solid #f0f0f0; }
|
||||
.th { padding: 12px; font-size: 14px; font-weight: 500; color: #333; text-align: center; }
|
||||
.tr-row { display: flex; flex-direction: row; border-bottom: 1px solid #f0f0f0; &:last-child { border-bottom: none; } }
|
||||
.price-table { border: 1px solid #f0f0f0; border-radius: 4px; overflow-x: auto; }
|
||||
.th-row { display: flex; flex-direction: row; background: #f8f9fa; border-bottom: 1px solid #f0f0f0; min-width: 800px; }
|
||||
.th { padding: 12px; font-size: 14px; font-weight: bold; color: #333; text-align: center; }
|
||||
.tr-row { display: flex; flex-direction: row; border-bottom: 1px solid #f0f0f0; min-width: 800px; &:last-child { border-bottom: none; } }
|
||||
.td { padding: 16px 12px; font-size: 14px; color: #666; text-align: center; display: flex; align-items: center; justify-content: center; }
|
||||
.flex-1 { flex: 1; } .flex-2 { flex: 2; }
|
||||
.price-input { border: 1px solid #dcdfe6; border-radius: 4px; height: 32px; padding: 0 8px; text-align: center; width: 80%; font-size: 13px; }
|
||||
.footer-btns { margin-top: 40px; display: flex; justify-content: center; .btn-save { background: #1890ff; color: #fff; border: none; padding: 0 32px; height: 40px; border-radius: 4px; } }
|
||||
.flex-1 { flex: 1; min-width: 120px; } .flex-2 { flex: 2; min-width: 200px; }
|
||||
.price-input { border: 1px solid #dcdfe6; border-radius: 4px; height: 32px; padding: 0 8px; text-align: center; width: 90%; font-size: 13px; background: #fff; }
|
||||
.footer-btns { margin-top: 40px; display: flex; justify-content: center; .btn-save { background: #1890ff; color: #fff; border: none; padding: 0 32px; height: 40px; border-radius: 4px; font-size: 14px;
|
||||
&[disabled] { opacity: 0.6; }
|
||||
} }
|
||||
</style>
|
||||
|
||||
@@ -8,11 +8,13 @@
|
||||
<view class="info-icon">?</view>
|
||||
</view>
|
||||
<view class="header-right">
|
||||
<view class="date-picker-wrap">
|
||||
<text class="calendar-emoji">📅</text>
|
||||
<text class="date-range">2026/01/04 - 2026/02/02</text>
|
||||
</view>
|
||||
<button class="btn-query">查询</button>
|
||||
<AnalyticsDateRangePicker
|
||||
:initialStartDate="startDate"
|
||||
:initialEndDate="endDate"
|
||||
@apply="onApplyRange"
|
||||
@clear="onClearRange"
|
||||
/>
|
||||
<button class="btn-query" @click="onQuery">查询</button>
|
||||
<button class="btn-export">导出</button>
|
||||
</view>
|
||||
</view>
|
||||
@@ -122,6 +124,7 @@
|
||||
<script setup lang="uts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
|
||||
import AnalyticsDateRangePicker from '@/components/analytics/AnalyticsDateRangePicker.uvue'
|
||||
import { fetchAdminProductStats, fetchAdminProductTrend, fetchAdminProductRanking } from '@/services/admin/productService.uts'
|
||||
|
||||
const statItems = ref([
|
||||
@@ -137,14 +140,38 @@ const rankingList = ref<Array<any>>([])
|
||||
const chartOption = ref<any>({})
|
||||
const loading = ref(false)
|
||||
|
||||
const startDate = ref<string>('')
|
||||
const endDate = ref<string>('')
|
||||
|
||||
onMounted(() => {
|
||||
// 默认最近 30 天(本地日期字符串)
|
||||
const end = new Date()
|
||||
const start = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
|
||||
startDate.value = start.toISOString().substring(0, 10)
|
||||
endDate.value = end.toISOString().substring(0, 10)
|
||||
loadAllData()
|
||||
})
|
||||
|
||||
function onApplyRange(payload: any) {
|
||||
startDate.value = payload?.start ?? ''
|
||||
endDate.value = payload?.end ?? ''
|
||||
loadAllData()
|
||||
}
|
||||
|
||||
function onClearRange() {
|
||||
startDate.value = ''
|
||||
endDate.value = ''
|
||||
loadAllData()
|
||||
}
|
||||
|
||||
function onQuery() {
|
||||
loadAllData()
|
||||
}
|
||||
|
||||
async function loadAllData() {
|
||||
loading.value = true
|
||||
const endTime = new Date().toISOString()
|
||||
const startTime = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString()
|
||||
const startTime = startDate.value ? (startDate.value + ' 00:00:00') : new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString()
|
||||
const endTime = endDate.value ? (endDate.value + ' 23:59:59') : new Date().toISOString()
|
||||
|
||||
try {
|
||||
// 1. 加载核心指标
|
||||
|
||||
@@ -104,25 +104,21 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref, reactive } from 'vue'
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import {
|
||||
fetchProductProtections,
|
||||
saveProductProtection,
|
||||
deleteProductProtection,
|
||||
setProductProtectionActive,
|
||||
ProductProtection
|
||||
} from '@/services/admin/productProtectionService.uts'
|
||||
|
||||
interface ProtectionItem {
|
||||
id: number;
|
||||
name: string;
|
||||
icon: string;
|
||||
desc: string;
|
||||
status: boolean;
|
||||
sort: number;
|
||||
}
|
||||
|
||||
const list = reactive<ProtectionItem[]>([
|
||||
{ id: 1, name: '正品保障', icon: '/static/logo.png', desc: '该商品由平台认证,保证百分百正品。', status: true, sort: 0 },
|
||||
{ id: 2, name: '七天无理由', icon: '/static/logo.png', desc: '商品在不影响二次销售的情况下,支持7天无理由退换。', status: true, sort: 0 }
|
||||
])
|
||||
const list = ref<ProductProtection[]>([])
|
||||
const isLoading = ref(false)
|
||||
|
||||
const showModal = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const editIndex = ref(-1)
|
||||
const editingId = ref<string | null>(null)
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
@@ -132,17 +128,34 @@ const form = reactive({
|
||||
sort: 0
|
||||
})
|
||||
|
||||
function openModal(item: ProtectionItem | null = null) {
|
||||
if (item) {
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
|
||||
async function loadData() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const res = await fetchProductProtections()
|
||||
list.value = res
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openModal(item: ProductProtection | null = null) {
|
||||
if (item != null) {
|
||||
isEdit.value = true
|
||||
editingId.value = item.id ?? null
|
||||
form.name = item.name
|
||||
form.icon = item.icon
|
||||
form.desc = item.desc
|
||||
form.status = item.status
|
||||
form.sort = item.sort
|
||||
editIndex.value = list.indexOf(item)
|
||||
form.icon = item.icon_url ?? ''
|
||||
form.desc = item.description
|
||||
form.status = item.is_active
|
||||
form.sort = item.sort_order
|
||||
} else {
|
||||
isEdit.value = false
|
||||
editingId.value = null
|
||||
form.name = ''
|
||||
form.icon = ''
|
||||
form.desc = ''
|
||||
@@ -161,43 +174,59 @@ function mockIconPicker() {
|
||||
form.icon = '/static/logo.png'
|
||||
}
|
||||
|
||||
function saveProtection() {
|
||||
async function saveProtection() {
|
||||
if (!form.name || !form.desc) {
|
||||
uni.showToast({ title: '请输入必填项', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (isEdit.value) {
|
||||
const item = list[editIndex.value]
|
||||
item.name = form.name
|
||||
item.icon = form.icon
|
||||
item.desc = form.desc
|
||||
item.status = form.status
|
||||
item.sort = form.sort
|
||||
} else {
|
||||
list.unshift({
|
||||
id: Date.now() % 1000,
|
||||
name: form.name,
|
||||
icon: form.icon || '/static/logo.png',
|
||||
desc: form.desc,
|
||||
status: form.status,
|
||||
sort: form.sort
|
||||
})
|
||||
|
||||
isLoading.value = true
|
||||
const protectionData: ProductProtection = {
|
||||
id: editingId.value ?? undefined,
|
||||
name: form.name,
|
||||
icon_url: form.icon || '/static/logo.png',
|
||||
description: form.desc,
|
||||
is_active: form.status,
|
||||
sort_order: form.sort
|
||||
}
|
||||
|
||||
try {
|
||||
const success = await saveProductProtection(protectionData)
|
||||
if (success) {
|
||||
uni.showToast({ title: '保存成功', icon: 'success' })
|
||||
closeModal()
|
||||
loadData()
|
||||
} else {
|
||||
uni.showToast({ title: '保存失败', icon: 'none' })
|
||||
}
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '操作异常', icon: 'none' })
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
closeModal()
|
||||
uni.showToast({ title: '保存成功', icon: 'success' })
|
||||
}
|
||||
|
||||
function toggleStatus(index: number) {
|
||||
list[index].status = !list[index].status
|
||||
async function toggleStatus(item: ProductProtection) {
|
||||
if (item.id == null) return
|
||||
const nextStatus = !item.is_active
|
||||
const success = await setProductProtectionActive(item.id!, nextStatus)
|
||||
if (success) {
|
||||
item.is_active = nextStatus
|
||||
}
|
||||
}
|
||||
|
||||
function deleteItem(index: number) {
|
||||
async function deleteItem(id: string | undefined) {
|
||||
if (id == null) return
|
||||
uni.showModal({
|
||||
title: '提示',
|
||||
content: '确定删除该保障条款吗?',
|
||||
success: (res) => {
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
list.splice(index, 1)
|
||||
const success = await deleteProductProtection(id)
|
||||
if (success) {
|
||||
uni.showToast({ title: '删除成功' })
|
||||
loadData()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -5,37 +5,40 @@
|
||||
<view class="search-row">
|
||||
<view class="search-item">
|
||||
<text class="label">评价时间:</text>
|
||||
<view class="mock-date-range">
|
||||
<text class="emoji">📅</text>
|
||||
<text class="txt">开始日期 - 结束日期</text>
|
||||
</view>
|
||||
<AnalyticsDateRangePicker
|
||||
:initialStartDate="startDate"
|
||||
:initialEndDate="endDate"
|
||||
@apply="onApplyRange"
|
||||
@clear="onClearRange"
|
||||
/>
|
||||
</view>
|
||||
<view class="search-item">
|
||||
<text class="label">评价状态:</text>
|
||||
<view class="mock-select"><text>请选择</text><text class="arrow">▼</text></view>
|
||||
</view>
|
||||
<view class="search-item">
|
||||
<text class="label">审核状态:</text>
|
||||
<view class="mock-select"><text>请选择</text><text class="arrow">▼</text></view>
|
||||
<picker :value="statusIndex" :range="statusOptions" range-key="label" @change="onStatusChange">
|
||||
<view class="mock-select">
|
||||
<text>{{ statusOptions[statusIndex].label }}</text>
|
||||
<text class="arrow">▼</text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
</view>
|
||||
<view class="search-row mt-16">
|
||||
<view class="search-item">
|
||||
<text class="label">商品信息:</text>
|
||||
<input class="mock-input" placeholder="请输入商品信息" />
|
||||
<text class="label">商品名称:</text>
|
||||
<input class="mock-input" v-model="searchProduct" placeholder="请输入商品信息" @confirm="onSearch" />
|
||||
</view>
|
||||
<view class="search-item">
|
||||
<text class="label">用户名称:</text>
|
||||
<input class="mock-input" placeholder="请输入" />
|
||||
<input class="mock-input" v-model="searchUser" placeholder="请输入" @confirm="onSearch" />
|
||||
</view>
|
||||
<button class="btn-primary">查询</button>
|
||||
<button class="btn-primary" @click="onSearch">查询</button>
|
||||
<button class="btn-white" @click="onReset">重置</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 2. 操作行 -->
|
||||
<view class="action-bar">
|
||||
<button class="btn-primary">添加自评</button>
|
||||
<button class="btn-white">批量审核</button>
|
||||
<button class="btn-primary" @click="onAddSelfReview">添加自评</button>
|
||||
</view>
|
||||
|
||||
<!-- 3. 数据表格 -->
|
||||
@@ -45,7 +48,6 @@
|
||||
<view class="th col-check"><text>□</text></view>
|
||||
<view class="th col-id"><text>评论ID</text></view>
|
||||
<view class="th col-product"><text>商品信息</text></view>
|
||||
<view class="th col-spec"><text>规格</text></view>
|
||||
<view class="th col-user"><text>用户名称</text></view>
|
||||
<view class="th col-score"><text>评分</text></view>
|
||||
<view class="th col-content"><text>评价内容</text></view>
|
||||
@@ -55,40 +57,45 @@
|
||||
<view class="th col-op"><text>操作</text></view>
|
||||
</view>
|
||||
|
||||
<view v-for="item in replyList" :key="item.id" class="tr-row">
|
||||
<view v-if="isLoading" class="table-empty" style="padding: 40px; text-align: center;">
|
||||
<text>数据加载中...</text>
|
||||
</view>
|
||||
<view v-else-if="replyList.length === 0" class="table-empty" style="padding: 40px; text-align: center;">
|
||||
<text>暂无评价数据</text>
|
||||
</view>
|
||||
<view v-else v-for="item in replyList" :key="item.id" class="tr-row">
|
||||
<view class="td col-check"><text>□</text></view>
|
||||
<view class="td col-id"><text>{{ item.id }}</text></view>
|
||||
<view class="td col-product">
|
||||
<image class="p-img" :src="item.image" mode="aspectFill" />
|
||||
<text class="p-name-txt">{{ item.productName }}</text>
|
||||
<image class="p-img" :src="item.product_image || '/static/logo.png'" mode="aspectFill" />
|
||||
<text class="p-name-txt">{{ item.product_name }}</text>
|
||||
</view>
|
||||
<view class="td col-spec"><text>{{ item.spec }}</text></view>
|
||||
<view class="td col-user"><text>{{ item.username }}</text></view>
|
||||
<view class="td col-score"><text>{{ item.score }}</text></view>
|
||||
<view class="td col-user"><text>{{ item.username || '游客' }}</text></view>
|
||||
<view class="td col-score"><text>{{ item.rating }}</text></view>
|
||||
<view class="td col-content"><text class="blue-link">{{ item.content }}</text></view>
|
||||
<view class="td col-reply"><text>{{ item.reply || '无' }}</text></view>
|
||||
<view class="td col-reply"><text>{{ item.merchant_reply || '无' }}</text></view>
|
||||
<view class="td col-status">
|
||||
<text class="status-tag" :class="item.status === 1 ? 'pass' : 'wait'">
|
||||
{{ item.status === 1 ? '通过' : '待审核' }}
|
||||
<text class="status-tag" :class="item.status === 1 ? 'pass' : (item.status === 3 ? 'fail' : 'wait')">
|
||||
{{ item.status === 1 ? '通过' : (item.status === 3 ? '已驳回' : '待审核') }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="td col-time"><text>{{ item.time }}</text></view>
|
||||
<view class="td col-time"><text>{{ formatDateTime(item.created_at) }}</text></view>
|
||||
<view class="td col-op">
|
||||
<text class="op-link">通过</text>
|
||||
<text class="op-link">驳回</text>
|
||||
<text class="op-link">回复</text>
|
||||
<text class="op-link red">删除</text>
|
||||
<text v-if="item.status !== 1" class="op-link" @click="handleApprove(item.id)">通过</text>
|
||||
<text v-if="item.status !== 3" class="op-link" @click="handleReject(item.id)">驳回</text>
|
||||
<text class="op-link" @click="handleReply(item)">回复</text>
|
||||
<text class="op-link red" @click="handleDelete(item.id)">删除</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 分页 -->
|
||||
<view class="pagination-row">
|
||||
<text class="total">共 {{ replyList.length }} 条</text>
|
||||
<text class="total">共 {{ total }} 条</text>
|
||||
<view class="page-ctrl">
|
||||
<text class="page-btn disabled">{"<"}</text>
|
||||
<text class="page-num active">1</text>
|
||||
<text class="page-btn">{">"}</text>
|
||||
<text class="page-btn" :class="{ disabled: page <= 1 }" @click="onPrevPage">{"<"}</text>
|
||||
<text class="page-num active">{{ page }}</text>
|
||||
<text class="page-btn" :class="{ disabled: replyList.length < pageSize }" @click="onNextPage">{">"}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -96,46 +103,169 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import AnalyticsDateRangePicker from '@/components/analytics/AnalyticsDateRangePicker.uvue'
|
||||
import { fetchAdminProductReviews, approveProductReview, rejectProductReview, replyProductReview, deleteProductReview, type ProductReviewItem } from '@/services/admin/productReviewService.uts'
|
||||
|
||||
const replyList = ref([
|
||||
{
|
||||
id: 1069,
|
||||
image: 'https://img1.baidu.com/it/u=254065646,3100346083&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500',
|
||||
productName: 'UR2024夏季新款女装复古纯欲氛围感一字肩短款T恤衫UWG440060',
|
||||
spec: 'XL,卡其',
|
||||
username: 'demo998',
|
||||
score: 3.5,
|
||||
content: '22',
|
||||
reply: '',
|
||||
status: 0,
|
||||
time: '2025-02-19 14:56:43'
|
||||
},
|
||||
{
|
||||
id: 1059,
|
||||
image: 'https://img1.baidu.com/it/u=254065646,3100346083&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500',
|
||||
productName: 'UR2024夏季新款女装复古纯欲氛围感一字肩短款T恤衫UWG440060',
|
||||
spec: 'XL,卡其',
|
||||
username: '你好呀',
|
||||
score: 3.5,
|
||||
content: '的',
|
||||
reply: '',
|
||||
status: 0,
|
||||
time: '2025-01-07 15:35:36'
|
||||
},
|
||||
{
|
||||
id: 980,
|
||||
image: 'https://img1.baidu.com/it/u=254065646,3100346083&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500',
|
||||
productName: 'UR2024夏季新款女装复古纯欲氛围感一字肩短款T恤衫UWG440060',
|
||||
spec: 'XL,卡其',
|
||||
username: 'wx209638',
|
||||
score: 5,
|
||||
content: '好',
|
||||
reply: '',
|
||||
status: 1,
|
||||
time: '2024-09-12 14:20:12'
|
||||
}
|
||||
const replyList = ref<Array<ProductReviewItem>>([])
|
||||
const isLoading = ref(false)
|
||||
|
||||
const searchProduct = ref('')
|
||||
const searchUser = ref('')
|
||||
|
||||
const statusOptions = ref<Array<{ label: string; value: number | null }>>([
|
||||
{ label: '全部', value: null },
|
||||
{ label: '通过', value: 1 },
|
||||
{ label: '已驳回', value: 3 },
|
||||
{ label: '已删除', value: 2 }
|
||||
])
|
||||
const statusIndex = ref(0)
|
||||
|
||||
const startDate = ref<string>('')
|
||||
const endDate = ref<string>('')
|
||||
|
||||
const page = ref(1)
|
||||
const pageSize = 20
|
||||
const total = ref(0)
|
||||
|
||||
onMounted(() => {
|
||||
// 默认最近 30 天(本地日期)
|
||||
const end = new Date()
|
||||
const start = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
|
||||
startDate.value = start.toISOString().substring(0, 10)
|
||||
endDate.value = end.toISOString().substring(0, 10)
|
||||
loadData()
|
||||
})
|
||||
|
||||
async function loadData() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const startTime = startDate.value ? (startDate.value + ' 00:00:00') : null
|
||||
const endTime = endDate.value ? (endDate.value + ' 23:59:59') : null
|
||||
|
||||
const res = await fetchAdminProductReviews({
|
||||
searchProduct: searchProduct.value,
|
||||
searchUser: searchUser.value,
|
||||
status: statusOptions.value[statusIndex.value].value,
|
||||
startTime,
|
||||
endTime,
|
||||
page: page.value,
|
||||
pageSize
|
||||
})
|
||||
replyList.value = res.items
|
||||
total.value = res.total
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '加载评价失败', icon: 'none' })
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onApplyRange(payload: any) {
|
||||
startDate.value = payload?.start ?? ''
|
||||
endDate.value = payload?.end ?? ''
|
||||
page.value = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function onClearRange() {
|
||||
startDate.value = ''
|
||||
endDate.value = ''
|
||||
page.value = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function onStatusChange(e: any) {
|
||||
statusIndex.value = parseInt(String(e.detail.value))
|
||||
page.value = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
page.value = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function onReset() {
|
||||
searchProduct.value = ''
|
||||
searchUser.value = ''
|
||||
statusIndex.value = 0
|
||||
page.value = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function onPrevPage() {
|
||||
if (page.value <= 1) return
|
||||
page.value--
|
||||
loadData()
|
||||
}
|
||||
|
||||
function onNextPage() {
|
||||
if (replyList.value.length < pageSize) return
|
||||
page.value++
|
||||
loadData()
|
||||
}
|
||||
|
||||
function onAddSelfReview() {
|
||||
uni.showToast({ title: '添加自评开发中', icon: 'none' })
|
||||
}
|
||||
|
||||
function formatDateTime(iso: string): string {
|
||||
try {
|
||||
return iso.replace('T', ' ').split('.')[0]
|
||||
} catch (e) {
|
||||
return iso
|
||||
}
|
||||
}
|
||||
|
||||
async function handleApprove(id: string) {
|
||||
const ok = await approveProductReview(id)
|
||||
if (ok) {
|
||||
uni.showToast({ title: '已通过', icon: 'success' })
|
||||
loadData()
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReject(id: string) {
|
||||
const ok = await rejectProductReview(id)
|
||||
if (ok) {
|
||||
uni.showToast({ title: '已驳回', icon: 'success' })
|
||||
loadData()
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
uni.showModal({
|
||||
title: '提示',
|
||||
content: '确定删除该评价吗?',
|
||||
success: async (res) => {
|
||||
if (!res.confirm) return
|
||||
const ok = await deleteProductReview(id)
|
||||
if (ok) {
|
||||
uni.showToast({ title: '已删除', icon: 'success' })
|
||||
loadData()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function handleReply(item: ProductReviewItem) {
|
||||
uni.showModal({
|
||||
title: '回复评价',
|
||||
editable: true,
|
||||
content: item.merchant_reply ?? '',
|
||||
placeholderText: '请输入回复内容',
|
||||
success: async (res) => {
|
||||
if (!res.confirm) return
|
||||
const content = res.content ?? ''
|
||||
const ok = await replyProductReview(item.id, content)
|
||||
if (ok) {
|
||||
uni.showToast({ title: '回复成功', icon: 'success' })
|
||||
loadData()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -5,16 +5,16 @@
|
||||
<view class="search-row">
|
||||
<view class="search-item">
|
||||
<text class="search-label">规格搜索:</text>
|
||||
<input class="search-input" placeholder="请输入规格名称" />
|
||||
<input class="search-input" v-model="searchName" placeholder="请输入规格名称" @confirm="handleSearch" />
|
||||
</view>
|
||||
<button class="btn-query">查询</button>
|
||||
<button class="btn-query" @click="handleSearch">查询</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 数据表格区域 -->
|
||||
<view class="table-card">
|
||||
<view class="table-toolbar">
|
||||
<button class="btn-add" @click="showModal = true">添加商品规格</button>
|
||||
<button class="btn-add" @click="openAddModal">添加商品规格</button>
|
||||
<button class="btn-batch-del">批量删除</button>
|
||||
</view>
|
||||
|
||||
@@ -44,9 +44,9 @@
|
||||
<text class="td-cell flex-4">{{ item.specs }}</text>
|
||||
<text class="td-cell flex-4">{{ item.attrs }}</text>
|
||||
<view class="td-cell flex-2 row-center">
|
||||
<text class="btn-link">编辑</text>
|
||||
<text class="btn-link" @click="openEditModal(item)">编辑</text>
|
||||
<view class="divider"></view>
|
||||
<text class="btn-link delete" @click="deleteItem(index)">删除</text>
|
||||
<text class="btn-link delete" @click="deleteItem(item.id)">删除</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -91,57 +91,112 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref, reactive } from 'vue'
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { fetchSpecTemplates, saveSpecTemplate, deleteSpecTemplate, ProductSpecTemplate } from '@/services/admin/productSpecParamService.uts'
|
||||
|
||||
interface AttrItem {
|
||||
id: number;
|
||||
name: string;
|
||||
specs: string;
|
||||
attrs: string;
|
||||
interface AttrItem extends ProductSpecTemplate {
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
const list = reactive<AttrItem[]>([
|
||||
{ id: 104, name: '颜色', specs: '红色,蓝色,黑色,白色', attrs: '颜色属性', selected: false },
|
||||
{ id: 105, name: '尺寸', specs: 'S,M,L,XL,XXL', attrs: '服装尺寸', selected: false },
|
||||
{ id: 106, name: '材质', specs: '纯棉,涤纶,真丝', attrs: '面料材质', selected: false },
|
||||
{ id: 107, name: '内存', specs: '8G,16G,32G', attrs: '硬件参数', selected: false },
|
||||
{ id: 108, name: '存储', specs: '128G,256G,512G', attrs: '容量', selected: false }
|
||||
])
|
||||
const list = ref<AttrItem[]>([])
|
||||
const isLoading = ref(false)
|
||||
const searchName = ref('')
|
||||
|
||||
const showModal = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const editingId = ref<string | null>(null)
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
specs: '',
|
||||
attrs: ''
|
||||
})
|
||||
|
||||
function saveAttr() {
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
|
||||
async function loadData() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const res = await fetchSpecTemplates(searchName.value)
|
||||
list.value = res.map(item => ({
|
||||
...item,
|
||||
selected: false
|
||||
} as AttrItem))
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
loadData()
|
||||
}
|
||||
|
||||
function openAddModal() {
|
||||
isEdit.value = false
|
||||
editingId.value = null
|
||||
form.name = ''
|
||||
form.specs = ''
|
||||
form.attrs = ''
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function openEditModal(item : AttrItem) {
|
||||
isEdit.value = true
|
||||
editingId.value = item.id ?? null
|
||||
form.name = item.name
|
||||
form.specs = item.specs
|
||||
form.attrs = item.attrs
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
async function saveAttr() {
|
||||
if (!form.name) {
|
||||
uni.showToast({ title: '请输入规格名称', icon: 'none' })
|
||||
return
|
||||
}
|
||||
list.push({
|
||||
id: Math.floor(Math.random() * 1000),
|
||||
|
||||
isLoading.value = true
|
||||
const tpl : ProductSpecTemplate = {
|
||||
id: editingId.value ?? undefined,
|
||||
name: form.name,
|
||||
specs: form.specs,
|
||||
attrs: form.attrs,
|
||||
selected: false
|
||||
})
|
||||
showModal.value = false
|
||||
form.name = ''
|
||||
form.specs = ''
|
||||
form.attrs = ''
|
||||
uni.showToast({ title: '添加成功', icon: 'success' })
|
||||
sort_order: 0,
|
||||
is_active: true
|
||||
}
|
||||
|
||||
try {
|
||||
const success = await saveSpecTemplate(tpl)
|
||||
if (success) {
|
||||
uni.showToast({ title: isEdit.value ? '修改成功' : '添加成功', icon: 'success' })
|
||||
showModal.value = false
|
||||
loadData()
|
||||
} else {
|
||||
uni.showToast({ title: '保存失败', icon: 'none' })
|
||||
}
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '操作异常', icon: 'none' })
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function deleteItem(index: number) {
|
||||
async function deleteItem(id : string | undefined) {
|
||||
if (id == null) return
|
||||
uni.showModal({
|
||||
title: '提示',
|
||||
content: '确定删除该规格吗?',
|
||||
success: (res) => {
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
list.splice(index, 1)
|
||||
const success = await deleteSpecTemplate(id)
|
||||
if (success) {
|
||||
uni.showToast({ title: '删除成功' })
|
||||
loadData()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user