Merge remote-tracking branch 'origin/huangzhenbao-admin'

This commit is contained in:
not-like-juvenile
2026-03-18 17:14:05 +08:00
676 changed files with 25158 additions and 46646 deletions

View File

@@ -29,6 +29,7 @@
<text class="th-cell flex-1">ID</text>
<text class="th-cell flex-3">分类名称</text>
<text class="th-cell flex-2">分类图标</text>
<text class="th-cell flex-2">描述</text>
<text class="th-cell flex-1">排序</text>
<text class="th-cell flex-2 text-center">状态</text>
<text class="th-cell flex-3 text-center">操作</text>
@@ -38,20 +39,18 @@
<view v-for="(item, index) in list" :key="index" class="table-row-group">
<!-- 父级 -->
<view class="table-row">
<text class="td-cell flex-1 color-9">{{ item.id }}</text>
<text class="td-cell flex-1 color-9" style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" :title="item.id">{{ item.id.substring(0, 8) }}</text>
<view class="td-cell flex-3 row-layout">
<text class="expand-icon" @click="item.expanded = !item.expanded">{{ item.expanded ? '' : '' }}</text>
<text class="expand-icon" @click="item.expanded = !item.expanded">{{ item.expanded ? '' : '' }}</text>
<text>{{ item.name }}</text>
</view>
<view class="td-cell flex-2">
<image class="cate-icon" :src="item.icon" mode="aspectFit"></image>
<image class="cate-icon" :src="item.icon_url || '/static/logo.png'" mode="aspectFit"></image>
</view>
<text class="td-cell flex-1">{{ item.sort }}</text>
<text class="td-cell flex-2" style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">{{ item.description || '-' }}</text>
<text class="td-cell flex-1">{{ item.sort_order }}</text>
<view class="td-cell flex-2 row-center">
<view class="switch-mock" :class="item.status ? 'switch-on' : ''" @click="toggleStatus(item)">
<view class="switch-dot"></view>
<text class="switch-text">{{ item.status ? '开启' : '关闭' }}</text>
</view>
<StatusSwitch v-model="item.is_active" @change="toggleStatus(item)" />
</view>
<view class="td-cell flex-3 row-center">
<text class="btn-link" @click="openDrawer(item)">编辑</text>
@@ -62,20 +61,18 @@
<!-- 子级 -->
<view v-if="item.expanded">
<view v-for="(child, cIndex) in item.children" :key="cIndex" class="table-row sub-row">
<text class="td-cell flex-1 color-9">{{ child.id }}</text>
<text class="td-cell flex-1 color-9" style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" :title="child.id">{{ child.id.substring(0, 8) }}</text>
<view class="td-cell flex-3 row-layout pl-20">
<text class="child-line"></text>
<text>{{ child.name }}</text>
</view>
<view class="td-cell flex-2">
<image class="cate-icon" :src="child.icon" mode="aspectFit"></image>
<image class="cate-icon" :src="child.icon_url || '/static/logo.png'" mode="aspectFit"></image>
</view>
<text class="td-cell flex-1">{{ child.sort }}</text>
<text class="td-cell flex-2" style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">{{ child.description || '-' }}</text>
<text class="td-cell flex-1">{{ child.sort_order }}</text>
<view class="td-cell flex-2 row-center">
<view class="switch-mock" :class="child.status ? 'switch-on' : ''" @click="toggleStatus(child)">
<view class="switch-dot"></view>
<text class="switch-text">{{ child.status ? '开启' : '关闭' }}</text>
</view>
<StatusSwitch v-model="child.is_active" @change="toggleStatus(child)" />
</view>
<view class="td-cell flex-3 row-center">
<text class="btn-link" @click="openDrawer(child)">编辑</text>
@@ -100,10 +97,12 @@
<view class="form-item">
<view class="form-label-box"><text class="form-label">上级分类:</text></view>
<view class="form-input-box">
<picker mode="selector" :range="parentOptions" range-key="name" :value="parentOptionsIndex" @change="onParentChange" class="picker-box">
<view class="mock-select-full">
<text>{{ form.parentName || '顶级分类' }}</text>
<text class="arrow-down"></text>
</view>
</picker>
</view>
</view>
<view class="form-item">
@@ -112,29 +111,44 @@
<input class="drawer-input" v-model="form.name" placeholder="请输入分类名称" />
</view>
</view>
<view class="form-item">
<view class="form-label-box"><text class="form-label">标识 (Slug):</text></view>
<view class="form-input-box">
<input class="drawer-input" v-model="form.slug" placeholder="可选,分类英文/拼音标识" />
</view>
</view>
<view class="form-item align-start">
<view class="form-label-box"><text class="form-label">分类描述:</text></view>
<view class="form-input-box">
<textarea class="drawer-textarea" v-model="form.description" placeholder="请输入分类描述" style="width: 100%; height: 80px; border: 1px solid #dcdfe6; border-radius: 4px; padding: 8px 12px; font-size: 14px; box-sizing: border-box;"></textarea>
</view>
</view>
<view class="form-item align-start">
<view class="form-label-box"><text class="form-label">分类图标:</text></view>
<view class="form-input-box">
<view class="upload-box">
<text class="plus">+</text>
<text class="upload-text">上传图片</text>
<view class="upload-box" style="position: relative; overflow: hidden;">
<template v-if="form.icon_url">
<image :src="form.icon_url" mode="aspectFit" style="width: 100%; height: 100%;"></image>
</template>
<template v-else>
<text class="plus">+</text>
<text class="upload-text">上传图片</text>
</template>
</view>
<input class="drawer-input" style="margin-top: 10px;" v-model="form.icon_url" placeholder="或者直接输入图片外链" />
<text class="form-tip">建议尺寸180*180</text>
</view>
</view>
<view class="form-item">
<view class="form-label-box"><text class="form-label">排序:</text></view>
<view class="form-input-box">
<input type="number" class="drawer-input" v-model="form.sort" />
<input type="number" class="drawer-input" v-model="form.sort_order" />
</view>
</view>
<view class="form-item">
<view class="form-label-box"><text class="form-label">状态:</text></view>
<view class="form-input-box">
<view class="switch-mock" :class="form.status ? 'switch-on' : ''" @click="form.status = !form.status">
<view class="switch-dot"></view>
<text class="switch-text">{{ form.status ? '开启' : '关闭' }}</text>
</view>
<StatusSwitch v-model="form.is_active" />
</view>
</view>
</view>
@@ -150,106 +164,199 @@
<script setup lang="uts">
import { ref, reactive, onMounted } from 'vue'
import {
fetchAdminCategoryList,
createAdminCategory,
updateAdminCategory,
deleteAdminCategory,
type AdminCategory
} from '@/services/admin/productCategoryService.uts'
import supa from '@/components/supadb/aksupainstance.uts'
import StatusSwitch from '@/components/StatusSwitch.uvue'
interface CateItem {
id: string;
id: string; // Updated to match UUID string type per DB documentation
name: string;
icon: string;
sort: number;
status: boolean;
slug?: string;
icon_url: string;
description?: string;
sort_order: number;
is_active: boolean;
level: number;
path: string[];
expanded?: boolean;
children?: CateItem[];
parentId?: string | null;
parent_id?: string; // Updated to match UUID string type per DB documentation
}
const list = ref<Array<CateItem>>([])
const list = ref<CateItem[]>([])
const allCategoriesFlat = ref<CateItem[]>([])
const showDrawerMask = ref(false)
const showDrawer = ref(false)
const isEdit = ref(false)
const editingId = ref<string | null>(null)
const currentEditId = ref('')
const form = reactive({
name: '',
parentId: null as string | null,
slug: '',
parentName: '',
sort: 0,
status: true
parent_id: '' as string | null,
parentLevel: 0,
parentPath: [] as string[],
description: '',
icon_url: '',
sort_order: 0,
is_active: true
})
onMounted(() => {
loadList()
})
const parentOptions = ref<{id: string, name: string, level: number, path: string[]}[]>([])
const parentOptionsIndex = ref(0)
function buildTree(items: Array<AdminCategory>): Array<CateItem> {
const map: Record<string, CateItem> = {}
const roots: Array<CateItem> = []
for (let i = 0; i < items.length; i++) {
const c = items[i]
map[c.id] = {
id: c.id,
name: c.name,
icon: c.icon ?? '',
sort: c.sort ?? 0,
status: c.is_active === true,
expanded: false,
children: [],
parentId: c.parent_id ?? null
async function loadData() {
uni.showLoading({ title: '加载中' })
try {
const res = await supa.from('ml_categories').select('*').order('sort_order', { ascending: true }).execute()
if (res.error != null) {
uni.showToast({ title: '获取数据失败', icon: 'none' })
return
}
}
const ids = Object.keys(map)
for (let i = 0; i < ids.length; i++) {
const id = ids[i]
const node = map[id]
if (node.parentId != null && map[node.parentId] != null) {
map[node.parentId].children = map[node.parentId].children ?? []
map[node.parentId].children!.push(node)
} else {
roots.push(node)
if (!Array.isArray(res.data)) {
list.value = []
return
}
}
return roots
const allItems = res.data as Array<UTSJSONObject>
const formatted = allItems.map((item: UTSJSONObject): CateItem => {
let rawPath = item.get('path')
let parsedPath: string[] = []
if (Array.isArray(rawPath)) {
parsedPath = rawPath as string[]
} else if (typeof rawPath === 'string') {
// just in case it returns a string like '{"name"}'
try {
parsedPath = JSON.parse(rawPath.replace('{','[').replace('}',']')) as string[]
} catch(e) {}
}
return {
id: (item.get('id') as string | null) ?? '',
name: (item.get('name') as string | null) ?? '',
slug: (item.get('slug') as string | null) ?? '',
icon_url: (item.get('icon_url') as string | null) ?? '',
description: (item.get('description') as string | null) ?? '',
sort_order: (item.get('sort_order') as number | null) ?? 0,
is_active: (item.get('is_active') as boolean | null) ?? true,
parent_id: (item.get('parent_id') as string | null),
level: (item.get('level') as number | null) ?? 1,
path: parsedPath,
expanded: false,
children: [] as Array<CateItem>
} as CateItem
})
allCategoriesFlat.value = formatted
const topLevel = formatted.filter((f: CateItem): boolean => f.parent_id == null || f.parent_id == '')
topLevel.forEach((top: CateItem) => {
top.children = formatted.filter((f: CateItem): boolean => f.parent_id == top.id)
})
list.value = topLevel
} catch (e) {
console.error(e)
} finally {
uni.hideLoading()
}
}
async function loadList() {
const items = await fetchAdminCategoryList({})
list.value = buildTree(items)
onMounted(() => {
loadData()
})
function buildParentOptions() {
const options = [{id: '', name: '顶级分类', level: 0, path: [] as string[]}]
function traverse(items: CateItem[], prefix: string) {
items.forEach(item => {
options.push({
id: item.id,
name: prefix + item.name,
level: item.level,
path: item.path
})
if (item.children && item.children!.length > 0) {
traverse(item.children!, prefix + '├─ ')
}
})
}
traverse(list.value, '')
parentOptions.value = options
}
function openDrawer(item: CateItem | null = null) {
buildParentOptions()
if (item != null) {
isEdit.value = true
editingId.value = item.id
currentEditId.value = item.id
form.name = item.name
form.sort = item.sort
form.status = item.status
form.parentId = item.parentId ?? null
form.parentName = item.parentId != null ? '子分类' : '顶级分类'
form.slug = item.slug ?? ''
form.description = item.description ?? ''
form.icon_url = item.icon_url
form.sort_order = item.sort_order
form.is_active = item.is_active
form.parent_id = item.parent_id
if (item.parent_id) {
const p = allCategoriesFlat.value.find(c => c.id == item.parent_id)
if (p != null) {
form.parentName = p.name
form.parentLevel = p.level
form.parentPath = p.path
} else {
form.parentName = '顶级分类'
form.parentLevel = 0
form.parentPath = []
}
} else {
form.parentName = '顶级分类'
form.parentLevel = 0
form.parentPath = []
}
// remove self from parent options to avoid loop
parentOptions.value = parentOptions.value.filter(o => o.id != item.id)
} else {
isEdit.value = false
editingId.value = null
currentEditId.value = ''
form.name = ''
form.sort = 0
form.status = true
form.parentId = null
form.slug = ''
form.description = ''
form.icon_url = ''
form.sort_order = 0
form.is_active = true
form.parent_id = null
form.parentName = '顶级分类'
form.parentLevel = 0
form.parentPath = []
}
parentOptionsIndex.value = Math.max(0, parentOptions.value.findIndex(o => o.id == (form.parent_id ?? '')))
showDrawerMask.value = true
setTimeout(() => {
showDrawer.value = true
}, 50)
}
function onParentChange(e: any) {
const index = e.detail.value as number
parentOptionsIndex.value = index
const selected = parentOptions.value[index]
if (selected.id == '') {
form.parent_id = null
} else {
form.parent_id = selected.id
}
form.parentName = selected.name.replace(/├─ /g, '')
form.parentLevel = selected.level
form.parentPath = selected.path
}
function closeDrawer() {
showDrawer.value = false
setTimeout(() => {
@@ -258,71 +365,108 @@ showDrawerMask.value = false
}
async function saveCate() {
if (isEdit.value && editingId.value != null) {
await updateAdminCategory({
id: editingId.value,
parentId: form.parentId,
name: form.name,
sortOrder: form.sort,
isActive: form.status
})
if (form.name.trim() == '') {
uni.showToast({ title: '请输入分类名称', icon: 'none' })
return
}
uni.showLoading({ title: '保存中' })
const currentLevel = form.parent_id ? form.parentLevel + 1 : 1
// DB stores text array: ['顶级名称', '子级名称']
// Since parentPath is already an array of parent names, we just append current name.
let currentPath: string[] = []
if (form.parentPath.length > 0) {
currentPath = [...form.parentPath, form.name]
} else {
await createAdminCategory({
parentId: form.parentId,
name: form.name,
sortOrder: form.sort,
isActive: form.status
})
currentPath = [form.name]
}
uni.showToast({ title: '保存成功', icon: 'success' })
closeDrawer()
loadList()
// UTS expects explicitly casting arrays or passing them properly to Supabase
// In UTS Supabase client arrays are handled natively if typed correctly.
const payload = {
name: form.name,
slug: form.slug.length > 0 ? form.slug : null,
icon_url: form.icon_url,
description: form.description,
sort_order: form.sort_order,
is_active: form.is_active,
parent_id: form.parent_id,
level: currentLevel,
path: currentPath
} as UTSJSONObject
try {
if (isEdit.value) {
const res = await supa.from('ml_categories').update(payload).eq('id', currentEditId.value).execute()
if (res.error != null) {
uni.showToast({ title: '保存失败: ' + res.error!.message, icon: 'none' })
return
}
} else {
const res = await supa.from('ml_categories').insert(payload).execute()
if (res.error != null) {
uni.showToast({ title: '保存失败: ' + res.error!.message, icon: 'none' })
return
}
}
uni.showToast({ title: '保存成功', icon: 'success' })
closeDrawer()
loadData()
} catch (e) {
uni.showToast({ title: '发生异常', icon: 'none' })
} finally {
uni.hideLoading()
}
}
async function toggleStatus(item: CateItem) {
await updateAdminCategory({
id: item.id,
parentId: item.parentId,
name: item.name,
sortOrder: item.sort,
isActive: !item.status
})
item.status = !item.status
const newStatus = !item.is_active
const oldStatus = item.is_active
item.is_active = newStatus
const payload = { is_active: newStatus } as UTSJSONObject
const res = await supa.from('ml_categories').update(payload).eq('id', item.id).execute()
if (res.error != null) {
item.is_active = oldStatus
uni.showToast({ title: '状态更新失败', icon: 'none' })
} else {
uni.showToast({ title: '已更新状态', icon: 'success' })
}
}
async function deleteItem(item: CateItem) {
uni.showModal({
title: '删除确认',
content: `确定删除分类 "${item.name}" 吗?\n\n⚠ 警告:该操作将同时删除该分类下的所有子分类及关联商品!`,
confirmText: '确认删除',
confirmColor: '#ff4d4f',
success: async (res) => {
if (res.confirm) {
try {
await deleteAdminCategory(item.id)
function deleteItem(item: CateItem) {
uni.showModal({
title: '提示',
content: '确定删除分类吗?如果是父分类,其子分类可能也受影响。',
success: async (res) => {
if (res.confirm) {
uni.showLoading({ title: '删除中' })
const delRes = await supa.from('ml_categories').delete().eq('id', item.id).execute()
if (delRes.error != null) {
uni.showToast({ title: '删除失败: ' + delRes.error!.message, icon: 'none' })
} else {
uni.showToast({ title: '删除成功', icon: 'success' })
loadList()
} catch (e: any) {
const errMsg = e?.message || '删除失败'
uni.showToast({ title: errMsg, icon: 'none', duration: 3000 })
loadData()
}
}
}
})
uni.hideLoading()
}
}
})
}
</script>
<style scoped lang="scss">
.admin-main {
padding: 24px;
background-color: #f0f2f5;
min-height: 100vh;
padding: 0;
background-color: transparent;
min-height: auto;
}
.search-card {
background-color: #fff;
padding: 20px;
padding: 24px;
border-radius: 4px;
margin-bottom: 20px;
}
@@ -517,48 +661,6 @@ align-items: center;
justify-content: center;
}
/* Switch Mock */
.switch-mock {
width: 54px;
height: 24px;
background-color: #ccc;
border-radius: 12px;
position: relative;
transition: all 0.3s;
display: flex;
align-items: center;
padding: 0 6px;
}
.switch-on {
background-color: #1890ff;
}
.switch-dot {
width: 18px;
height: 18px;
background-color: #fff;
border-radius: 50%;
position: absolute;
left: 3px;
transition: all 0.3s;
}
.switch-on .switch-dot {
left: 33px;
}
.switch-text {
font-size: 12px;
color: #fff;
margin-left: auto;
}
.switch-on .switch-text {
margin-left: 0;
margin-right: auto;
}
/* Drawer styles */
.drawer-mask {
position: fixed;

View File

@@ -1,4 +1,4 @@
<template>
<template>
<view class="admin-main">
<view class="label-layout">
<!-- 左侧标签组 -->
@@ -52,16 +52,10 @@ class="group-item"
</view>
<text class="td-cell flex-3">{{ groups[activeGroupIndex]?.name }}</text>
<view class="td-cell flex-2 row-center">
<view class="status-switch-mini" :class="label.status ? 'active' : ''" @click="label.status = !label.status">
<text class="switch-txt-inner">{{ label.status ? '开启' : '关闭' }}</text>
<view class="switch-dot-mini"></view>
</view>
<StatusSwitch v-model="label.status" />
</view>
<view class="td-cell flex-2 row-center">
<view class="status-switch-mini" :class="label.showInMobile ? 'active' : ''" @click="label.showInMobile = !label.showInMobile">
<text class="switch-txt-inner">{{ label.showInMobile ? '开启' : '关闭' }}</text>
<view class="switch-dot-mini"></view>
</view>
<StatusSwitch v-model="label.showInMobile" activeText="显示" inactiveText="隐藏" />
</view>
<view class="td-cell flex-2 row-center">
<text class="btn-op-blue" @click="openLabelDrawer(label)">修改</text>
@@ -71,22 +65,23 @@ class="group-item"
</view>
</view>
<!-- 分页模拟 -->
<view class="table-pagination">
<text class="page-total">共 {{ filteredLabels.length }} 条</text>
<view class="page-size-selector">
<text>15条/页</text>
<text class="arrow-down">v</text>
</view>
<view class="page-numbers">
<text class="page-btn active">1</text>
</view>
<view class="page-jump">
<text>前往</text>
<input class="jump-input" :value="'1'" />
<text>页</text>
</view>
</view>
<!-- 分页 -->
<CommonPagination
v-if="filteredLabels.length > 0"
:total="filteredLabels.length"
:loading="false"
:currentPage="currentPage"
:pageSize="pageSize"
:pageSizeOptionLabels="pageSizeOptionLabels"
:pageSizeIndex="pageSizeIndex"
:visiblePages="visiblePages"
:totalPage="totalPage"
:jumpPageInput="jumpPageInput"
@page-size-change="handlePageSizeChange"
@page-change="handlePageChange"
@update:jumpPageInput="(val : string) => { jumpPageInput = val }"
@jump-page="handleJumpPage"
/>
</view>
</view>
</view>
@@ -118,177 +113,113 @@ class="group-item"
</template>
<script setup lang="uts">
import { ref, reactive, computed, onMounted, watch } from 'vue'
import {
fetchLabelGroups, saveLabelGroup, deleteLabelGroup,
fetchLabels, saveLabel, deleteLabel,
ProductLabelGroup, ProductLabel
} from '@/services/admin/productLabelService.uts'
import { ref, reactive, computed } from 'vue'
import StatusSwitch from '@/components/StatusSwitch.uvue'
import CommonPagination from '@/components/CommonPagination/CommonPagination.uvue'
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
])
const groups = ref<ProductLabelGroup[]>([])
const labels = ref<ProductLabel[]>([])
const isLoading = ref(false)
const activeGroupIndex = ref(0)
const activeGroupId = computed((): string | null => {
if (groups.value.length === 0) return null
return groups.value[activeGroupIndex.value]?.id ?? null
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 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: 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 openLabelDrawer(label: Label | null = null) {
if (label) { labelForm.name = label.name } else { labelForm.name = '' }
showDrawerMask.value = true
setTimeout(() => {
showDrawer.value = true
}, 50)
}
function closeLabelDrawer() {
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()
}
}
}
})
showDrawer.value = false
setTimeout(() => {
showDrawerMask.value = false
}, 300)
}
function openGroupModal() {
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
}
}
})
uni.showToast({ title: '添加分组功能已模拟', icon: 'none' })
}
function deleteLabel(label: Label) {
const idx = labels.indexOf(label)
if (idx > -1) { labels.splice(idx, 1) }
}
// 分页适配状态
const currentPage = ref(1)
const pageSize = ref(15)
let jumpPageInput = ''
const pageSizeOptions = [10, 15, 20, 30, 50]
const pageSizeOptionLabels = computed(() => pageSizeOptions.map((n: number) => `${n}条/页`))
const pageSizeIndex = computed(() => {
const idx = pageSizeOptions.indexOf(pageSize.value)
return idx >= 0 ? idx : 0
})
const totalPage = computed(() => Math.max(1, Math.ceil(filteredLabels.value.length / pageSize.value)))
const visiblePages = computed(() => {
const total = totalPage.value
const cur = currentPage.value
if (total <= 7) return Array.from({ length: total }, (_: any, i: number) => i + 1)
if (cur <= 4) return [1, 2, 3, 4, 5, -1, total]
if (cur >= total - 3) return [1, -1, total - 4, total - 3, total - 2, total - 1, total]
return [1, -1, cur - 1, cur, cur + 1, -1, total]
})
const handlePageChange = (p: number) => { currentPage.value = p }
const handlePageSizeChange = (e: any) => {
const idx = Number(e.detail.value)
pageSize.value = pageSizeOptions[idx] ?? pageSizeOptions[0]
currentPage.value = 1
}
const handleJumpPage = () => {
const p = parseInt(jumpPageInput)
if (!isNaN(p) && p >= 1 && p <= totalPage.value) currentPage.value = p
}
</script>
<style scoped lang="scss">
.admin-main {
padding: 20px;
background-color: #f0f2f5;
height: 100vh;
padding: 0;
background-color: transparent;
min-height: auto;
}
.label-layout {
@@ -361,7 +292,7 @@ flex-direction: column;
display: flex;
flex-direction: column;
height: 100%;
padding: 20px;
padding: 24px;
}
.table-toolbar { margin-bottom: 20px; }
@@ -412,102 +343,11 @@ border-radius: 4px;
font-size: 12px;
}
.status-switch-mini {
width: 60px;
height: 24px;
background-color: #ccc;
border-radius: 12px;
position: relative;
transition: all 0.3s;
display: flex;
flex-direction: row;
align-items: center;
}
.status-switch-mini.active { background-color: #1890ff; }
.switch-txt-inner {
font-size: 11px;
color: #fff;
margin-left: 24px;
}
.status-switch-mini.active .switch-txt-inner {
margin-left: 8px;
}
.switch-dot-mini {
width: 18px;
height: 18px;
background-color: #fff;
border-radius: 50%;
position: absolute;
left: 3px;
transition: all 0.3s;
}
.status-switch-mini.active .switch-dot-mini { left: 39px; }
.btn-op-blue { color: #1890ff; font-size: 14px; cursor: pointer; }
.btn-op-red { color: #ff4d4f; font-size: 14px; cursor: pointer; }
.v-line { width: 1px; height: 12px; background-color: #eee; margin: 0 10px; }
/* 分页 */
.table-pagination {
padding-top: 20px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-end;
}
.page-total { font-size: 13px; color: #666; margin-right: 12px; }
.page-size-selector {
display: flex;
flex-direction: row;
align-items: center;
border: 1px solid #dcdfe6;
padding: 0 8px;
height: 28px;
border-radius: 4px;
margin-right: 12px;
}
.page-size-selector text { font-size: 12px; }
.arrow-down { margin-left: 5px; color: #999; }
.page-numbers { display: flex; flex-direction: row; margin-right: 12px; }
.page-btn {
width: 28px;
height: 28px;
line-height: 28px;
text-align: center;
border: 1px solid #dcdfe6;
border-radius: 4px;
font-size: 13px;
margin: 0 2px;
}
.page-btn.active { background-color: #1890ff; color: #fff; border-color: #1890ff; }
.page-jump {
display: flex;
flex-direction: row;
align-items: center;
font-size: 13px;
color: #666;
}
.jump-input {
width: 40px;
height: 28px;
border: 1px solid #dcdfe6;
border-radius: 4px;
text-align: center;
margin: 0 8px;
}
/* 分页区域已迁至 CommonPagination 组件 */
/* Drawer styles */
.drawer-mask {

View File

@@ -1,20 +1,20 @@
<template>
<template>
<view class="admin-main">
<!-- 头部搜索 -->
<view class="search-card">
<view class="search-row">
<view class="search-item">
<text class="search-label">模板搜索:</text>
<input class="search-input" v-model="searchName" placeholder="请输入模板名称" @confirm="handleSearch" />
<input class="search-input" placeholder="请输入模板名称" />
</view>
<button class="btn-query" @click="handleSearch">查询</button>
<button class="btn-query">查询</button>
</view>
</view>
<!-- 数据表格区域 -->
<view class="table-card">
<view class="table-toolbar">
<button class="btn-add" @click="openDrawer(null)">添加商品参数</button>
<button class="btn-add" @click="openDrawer()">添加商品参数</button>
</view>
<view class="table-header">
@@ -28,7 +28,7 @@
<view v-if="list.length === 0" class="empty-box">
<text class="empty-text">暂无数据</text>
</view>
<view v-for="(item, index) in list" :key="index" class="table-row">
<view v-for="(item, index) in pagedList" :key="index" class="table-row">
<text class="td-cell flex-1 color-9">{{ item.id }}</text>
<text class="td-cell flex-3">{{ item.name }}</text>
<view class="td-cell flex-5">
@@ -37,10 +37,27 @@
<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(item)">删除</text>
<text class="btn-link delete" @click="deleteItem(index)">删除</text>
</view>
</view>
</view>
<CommonPagination
v-if="true"
:total="total"
:loading="false"
:currentPage="currentPage"
:pageSize="pageSize"
:pageSizeOptionLabels="pageSizeOptionLabels"
:pageSizeIndex="pageSizeIndex"
:visiblePages="visiblePages"
:totalPage="totalPage"
:jumpPageInput="jumpPageInput"
@page-size-change="handlePageSizeChange"
@page-change="handlePageChange"
@update:jumpPageInput="(val: string) => { jumpPageInput.value = val }"
@jump-page="handleJumpPage"
/>
</view>
<!-- 添加/编辑参数抽屉 (右侧 50%) -->
@@ -101,145 +118,166 @@
</template>
<script setup lang="uts">
import { ref, reactive, onMounted } from 'vue'
import { fetchParamTemplates, saveParamTemplate, deleteParamTemplate, ProductParamKV, ProductParamTemplate } from '@/services/admin/productSpecParamService.uts'
import { ref, reactive, computed } from 'vue'
import CommonPagination from '@/components/CommonPagination/CommonPagination.uvue'
type ParamItem = ProductParamTemplate
interface ParamKV {
label: string;
value: string;
}
const list = ref<ParamItem[]>([])
const isLoading = ref(false)
const searchName = ref('')
interface ParamItem {
id: number;
name: string;
sort: number;
params: ParamKV[];
}
// ========== MOCK DATA START ==========
// TODO: 接真实接口时替换此处 list 为 fetchParamList() 调用
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[] },
{ id: 3, name: '服装鞋履', sort: 3, params: [{label: '面料', value: '纯棉'}, {label: '适用季节', value: '春夏'}] as ParamKV[] },
{ id: 4, name: '食品饮料', sort: 4, params: [{label: '保质期', value: '12个月'}, {label: '常温存储', value: '是'}] as ParamKV[] },
{ id: 5, name: '家具家居', sort: 5, params: [{label: '材质', value: '实木'}, {label: '风格', value: '新中式'}] as ParamKV[] },
{ id: 6, name: '美妆护肤', sort: 6, params: [{label: '肉质', value: '混合背'}, {label: '容量', value: '50ml'}] as ParamKV[] },
{ id: 7, name: '图书文具', sort: 7, params: [{label: '出版社', value: '人民兰山'}, {label: '平装/精装', value: '精装'}] as ParamKV[] },
{ id: 8, name: '运动户外', sort: 8, params: [{label: '适用季节', value: '冬季'}, {label: '防水等级', value: 'IPX5'}] as ParamKV[] },
{ id: 9, name: '母婴童装', sort: 9, params: [{label: '适用年龄', value: '0-3岁'}, {label: '安全认证', value: 'CCC'}] as ParamKV[] },
{ id: 10, name: '創业特惠', sort: 10, params: [{label: '折扣力度', value: '9折'}, {label: '限时时间', value: '7天'}] as ParamKV[] },
{ id: 11, name: '山地车辆', sort: 11, params: [{label: '厂家', value: '丰田'}, {label: '排量', value: '2.0T'}] as ParamKV[] },
{ id: 12, name: '唨具家电', sort: 12, params: [{label: '功率', value: '1500W'}, {label: '容量', value: '5L'}] as ParamKV[] }
])
// ========== MOCK DATA END ==========
// ========== PAGINATION STATE ==========
const currentPage = ref(1)
const pageSize = ref(10)
const jumpPageInput = ref('')
const pageSizeOptions = [10, 15, 20, 30, 50]
const pageSizeOptionLabels = computed(() => pageSizeOptions.map((n: number) => `${n}条/页`))
const pageSizeIndex = computed(() => { const idx = pageSizeOptions.indexOf(pageSize.value); return idx >= 0 ? idx : 0 })
const total = computed(() => list.length)
const totalPage = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
const pagedList = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return list.slice(start, start + pageSize.value)
})
const visiblePages = computed((): number[] => {
const t = totalPage.value; const cur = currentPage.value
if (t <= 7) return Array.from({ length: t }, (_: any, i: number) => i + 1)
if (cur <= 4) return [1, 2, 3, 4, 5, -1, t]
if (cur >= t - 3) return [1, -1, t - 4, t - 3, t - 2, t - 1, t]
return [1, -1, cur - 1, cur, cur + 1, -1, t]
})
const handlePageChange = (p: number) => { currentPage.value = p }
const handlePageSizeChange = (e: any) => {
const idx = Number(e.detail.value)
pageSize.value = pageSizeOptions[idx] ?? pageSizeOptions[0]
currentPage.value = 1
}
const handleJumpPage = () => {
const p = parseInt(jumpPageInput.value)
if (!isNaN(p) && p >= 1 && p <= totalPage.value) currentPage.value = p
}
// ========== END PAGINATION STATE ==========
const showDrawerMask = ref(false)
const showDrawer = ref(false)
const isEdit = ref(false)
const editingId = ref<string | null>(null)
const editIndex = ref(-1)
const form = reactive({
name: '',
sort: 0,
params: [] as ProductParamKV[]
name: '',
sort: 0,
params: [] as ParamKV[]
})
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 formatParams(params: ParamKV[]): string {
return params.map(p => p.label + ':' + p.value).join(' | ')
}
function openDrawer(item: ParamItem | null = null) {
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[]
}
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[]
}
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 ProductParamKV)
form.params.push({ label: '', value: '' } as ParamKV)
}
function removeParamRow(index: number) {
form.params.splice(index, 1)
form.params.splice(index, 1)
}
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 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' })
}
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()
}
}
}
})
function deleteItem(index: number) {
uni.showModal({
title: '提示',
content: '确定删除该参数模板吗?',
success: (res) => {
if (res.confirm) {
list.splice(index, 1)
}
}
})
}
</script>
<style scoped lang="scss">
.admin-main {
padding: 24px;
background-color: #f0f2f5;
min-height: 100vh;
padding: 0;
background-color: transparent;
min-height: auto;
}
.search-card {
background-color: #fff;
padding: 20px;
padding: 24px;
border-radius: 4px;
margin-bottom: 20px;
}

View File

@@ -0,0 +1,728 @@
<template>
<view class="product-edit-page">
<view class="page-header">
<view class="back-link" @click="goBack">
<text class="arrow"><</text>
<text class="back-txt">返回</text>
</view>
<text class="header-title">编辑商品</text>
</view>
<!-- 步骤层 -->
<view class="steps-card">
<view class="step-items">
<view v-for="(step, index) in steps" :key="index" class="step-item" :class="{ active: activeStep === index }">
<text class="step-txt">{{ step }}</text>
<view v-if="index < steps.length - 1" class="step-line"></view>
</view>
</view>
</view>
<!-- 表单内容 -->
<view class="form-card">
<view class="form-item align-center">
<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 align-center">
<view class="label"><text class="required">*</text><text>商品名称:</text></view>
<view class="input-wrap">
<view class="input-box">
<input class="real-input" v-model="formData.name" placeholder="请输入商品名称" />
</view>
</view>
</view>
<view class="form-item align-center">
<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="formData.attributes.unit" placeholder="例: 件" />
</view>
</view>
</view>
<view class="form-item multi-line">
<view class="label"><text class="required">*</text><text>商品轮播图:</text></view>
<view class="input-wrap">
<view class="image-uploader">
<view v-for="(img, i) in formData.image_urls" :key="i" class="img-item">
<image :src="img" mode="aspectFill" />
<view v-if="i === 0" class="main-tag">主图</view>
<view class="img-close" @click="removeImage(i)">×</view>
</view>
<view class="upload-btn" @click="uploadImage" v-if="formData.image_urls.length < 10">
<text class="icon">+</text>
<text class="upload-txt">添加图片</text>
</view>
</view>
<text class="tip">建议尺寸800*800默认首张图为主图最多上传10张</text>
</view>
</view>
<view class="form-item multi-line">
<view class="label"><text>添加视频:</text></view>
<view class="input-wrap">
<view class="image-uploader">
<view v-for="(vid, i) in formData.video_urls" :key="i" class="img-item video-item">
<video :src="vid" style="width: 100%; height: 100%; border-radius: 4px;" :controls="false"></video>
<view class="img-close" @click="removeVideo(i)">×</view>
</view>
<view class="upload-btn v-btn" @click="uploadVideo" v-if="formData.video_urls.length < 1">
<text class="v-icon">📹</text>
<text class="upload-txt">上传视频</text>
</view>
</view>
<text class="tip">建议时长9~30秒视频宽高比16:9</text>
</view>
</view>
<view class="form-item align-center">
<view class="label"><text class="required">*</text><text>商品分类:</text></view>
<view class="input-wrap">
<picker :range="categories" @change="onCategoryChange">
<view class="mock-btn-select">
<text>{{ categoryName || '请选择分类' }}</text>
</view>
</picker>
</view>
</view>
<view class="form-item align-center">
<view class="label"><text class="required">*</text><text>商品售价:</text></view>
<view class="input-wrap">
<view class="input-box small">
<input class="real-input" type="number" v-model="formData.base_price" placeholder="请输入售价" />
<text class="unit">元</text>
</view>
</view>
</view>
<view class="form-item align-center">
<view class="label"><text class="required">*</text><text>商品库存:</text></view>
<view class="input-wrap">
<view class="input-box small">
<input class="real-input" type="number" v-model="formData.available_stock" placeholder="请输入库存" />
<text class="unit">件</text>
</view>
</view>
</view>
<view class="form-item align-center">
<view class="label"><text>商品标签:</text></view>
<view class="input-wrap">
<view class="tag-selector">
<view v-for="(tg, index) in formData.tags" :key="index" class="tag-item">
<text>{{ tg }}</text>
<text class="close" @click="removeTag(index)">×</text>
</view>
<text class="add-link" @click="addTag">+添加标签</text>
</view>
</view>
</view>
<view class="form-item align-center">
<view class="label"><text>商品状态:</text></view>
<view class="input-wrap">
<view class="radio-group-simple">
<view class="radio-simple" :class="{ on: formData.status === 1 }" @click="formData.status = 1">
<text class="dot"></text>
<text>上架</text>
</view>
<view class="radio-simple" :class="{ on: formData.status === 2 }" @click="formData.status = 2">
<text class="dot"></text>
<text>下架</text>
</view>
</view>
</view>
</view>
</view>
<view class="footer-btns">
<button class="btn-save" @click="saveDraftProduct">草稿箱</button>
<button class="btn-next" @click="saveAndNext">下一步</button>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, onMounted } from 'vue'
import { openRoute } from '@/layouts/admin/store/adminNavStore.uts'
import supa, { ensureSupabaseReady } from '@/components/supadb/aksupainstance'
import { SUPA_URL } from '@/ak/config.uts'
const activeStep = ref(0)
const steps = ['基础信息', '规格库存', '商品详情', '物流设置', '会员价/佣金', '营销设置', '其他设置']
interface CategoryOption {
id: string
name: string
}
const categoryOptions = ref<CategoryOption[]>([])
const categories = ref<string[]>([])
const categoryName = ref('')
const formData = ref({
id: '',
merchant_id: '',
category_id: '',
name: '',
base_price: 0,
available_stock: 0,
total_stock: 0,
main_image_url: '',
image_urls: [] as string[],
video_urls: [] as string[],
status: 1, // 1上架 2下架 3草稿 4删除
tags: [] as string[],
attributes: { unit: '件' } as Record<string, any>,
published_at: null as string | null
})
onMounted(async () => {
await ensureSupabaseReady()
const mId = supa.getSession().user?.getString('id') ?? null
if (!mId) {
uni.showToast({ title: '商家未获取到信息,请重新登录', icon: 'none' })
return
}
formData.value.merchant_id = mId
// 加载真实分类
await loadCategoryOptions()
const editId = uni.getStorageSync('edit_product_id') as string | null
if (editId) {
uni.removeStorageSync('edit_product_id')
await fetchProductDetail(editId, mId)
}
})
async function loadCategoryOptions() {
try {
const res = await supa.from('ml_categories')
.select('id, name')
.eq('is_active', true)
.order('sort_order', { ascending: true })
.execute()
if (res.data != null) {
const data = res.data as Array<UTSJSONObject>
categoryOptions.value = data.map((item: UTSJSONObject): CategoryOption => ({
id: item.get('id') as string,
name: item.get('name') as string
}))
categories.value = categoryOptions.value.map((item: CategoryOption): string => item.name)
}
} catch (e) {
console.error('加载分类失败', e)
}
}
async function fetchProductDetail(id: string, mId: string) {
try {
const res = await supa
.from('ml_products')
.select('*')
.eq('id', id)
.eq('merchant_id', mId)
.single()
.execute()
if (res.error != null) {
console.error('[ProductEdit] 查询失败:', res.error)
uni.showToast({ title: '获取商品信息失败,或者无权限编辑', icon: 'none' })
return
}
// .single() 返回时 data 可能是 UTSJSONObject 或包含一条记录的数组,兼容处理
let row : UTSJSONObject | null = null
if (res.data != null) {
const raw = res.data
if (Array.isArray(raw)) {
const arr = raw as Array<UTSJSONObject>
if (arr.length > 0) row = arr[0] as UTSJSONObject
} else {
row = raw as UTSJSONObject
}
}
if (row == null) {
uni.showToast({ title: '未找到该商品', icon: 'none' })
return
}
formData.value.id = row.getString('id') ?? ''
formData.value.name = row.getString('name') ?? ''
formData.value.base_price = row.getNumber('base_price') ?? 0
formData.value.available_stock = row.getNumber('available_stock') ?? 0
formData.value.total_stock = row.getNumber('total_stock') ?? row.getNumber('available_stock') ?? 0
formData.value.status = row.getNumber('status') ?? 1
formData.value.main_image_url = row.getString('main_image_url') ?? ''
formData.value.category_id = row.getString('category_id') ?? ''
// image_urls / video_urls / tags 是 JSONB 数组,用 .get() 取原始值再强转
const imgRaw = row.get('image_urls')
if (imgRaw != null && Array.isArray(imgRaw)) {
formData.value.image_urls = imgRaw as string[]
}
const vidRaw = row.get('video_urls')
if (vidRaw != null && Array.isArray(vidRaw)) {
formData.value.video_urls = vidRaw as string[]
}
const tagsRaw = row.get('tags')
if (tagsRaw != null && Array.isArray(tagsRaw)) {
formData.value.tags = tagsRaw as string[]
}
// attributes JSONB 是对象,用 getJSON 取
const attrsRaw = row.getJSON('attributes')
if (attrsRaw != null) {
formData.value.attributes.unit = attrsRaw.getString('unit') ?? '件'
}
// 同步分类名称
if (formData.value.category_id) {
const cat = categoryOptions.value.find((c: CategoryOption): boolean => c.id === formData.value.category_id)
categoryName.value = cat ? cat.name : ''
}
console.log('[ProductEdit] 加载成功id=', formData.value.id, 'name=', formData.value.name)
} catch (e : any) {
console.error('[ProductEdit] 获取详情异常:', e)
uni.showToast({ title: '获取商品信息失败', icon: 'none' })
}
}
function onCategoryChange(e: any) {
const v = e.detail.value as number
const selected = categoryOptions.value[v]
categoryName.value = selected.name
formData.value.category_id = selected.id
}
function addTag() {
uni.showModal({
title: '添加标签',
editable: true,
success: (res) => {
if (res.confirm && res.content) {
formData.value.tags.push(res.content)
}
}
})
}
function removeTag(index: number) {
formData.value.tags.splice(index, 1)
}
function uploadImage() {
const maxCount = 10 - formData.value.image_urls.length
if (maxCount <= 0) return
uni.chooseImage({
count: maxCount,
success: (res) => {
const paths = res.tempFilePaths as string[]
formData.value.image_urls.push(...paths)
}
})
}
function removeImage(index: number) {
formData.value.image_urls.splice(index, 1)
}
function uploadVideo() {
uni.chooseVideo({
sourceType: ['camera', 'album'],
success: (res) => {
formData.value.video_urls.push(res.tempFilePath as string)
}
})
}
function removeVideo(index: number) {
formData.value.video_urls.splice(index, 1)
}
function goBack() {
openRoute('product_productList')
}
// 通用上传方法
async function uploadToSupabase(filePath: string): Promise<string> {
if (filePath.startsWith('http') && !filePath.startsWith('blob:')) {
return filePath
}
const extMatch = filePath.match(/\.(\w+)$/)
const ext = extMatch ? extMatch[1] : 'png'
const uuid = Math.random().toString(36).substring(2, 10)
const remotePath = `products/${formData.value.merchant_id}_${Date.now()}_${uuid}.${ext}`
uni.showLoading({ title: '上传中...' })
try {
const res = await supa.storage.from('zhipao').upload(remotePath, filePath, {})
if (res.error != null) {
throw res.error
}
return `${SUPA_URL}/storage/v1/object/public/zhipao/${remotePath}`
} catch (e: any) {
console.error('上传文件失败:', e)
throw new Error(e.message || '上传异常')
} finally {
uni.hideLoading()
}
}
async function uploadAllCurrentFiles() {
const newImages = [] as string[]
for (const p of formData.value.image_urls) {
const u = await uploadToSupabase(p)
if(u) newImages.push(u)
}
formData.value.image_urls = newImages
const newVideos = [] as string[]
for (const p of formData.value.video_urls) {
const u = await uploadToSupabase(p)
if(u) newVideos.push(u)
}
formData.value.video_urls = newVideos
}
async function doSaveProduct(targetStatus: number) {
if (!formData.value.name) return uni.showToast({ title: '请输入商品名称', icon: 'none' })
if (!formData.value.category_id) return uni.showToast({ title: '请选择商品分类', icon: 'none' })
if (formData.value.image_urls.length === 0) return uni.showToast({ title: '请至少上传一张商品图', icon: 'none' })
// 必须获取正确的 merchant_id并且存在
if (!formData.value.merchant_id || formData.value.merchant_id.startsWith('merchant_123')) {
const mId = supa.getSession().user?.getString('id') ?? null
if (!mId) {
uni.showToast({ title: '未获取到商家信息,请重新登录', icon: 'none' })
return
}
formData.value.merchant_id = mId
}
uni.showLoading({ title: '保存中...' })
try {
// 1. 上传文件拿到正式 URL
await uploadAllCurrentFiles()
// 2. 组装 Payload
// 根据 CRMEB: 1:上架 2:下架 3:草稿 4:删除
formData.value.status = targetStatus
if (targetStatus === 1) {
formData.value.published_at = new Date().toISOString()
} else if (targetStatus === 3) {
formData.value.published_at = null
}
const payload : UTSJSONObject = {
merchant_id: formData.value.merchant_id,
name: formData.value.name,
category_id: formData.value.category_id,
base_price: Number(formData.value.base_price),
main_image_url: formData.value.image_urls.length > 0 ? formData.value.image_urls[0] : '', // 首图同步
image_urls: formData.value.image_urls,
video_urls: formData.value.video_urls,
available_stock: Number(formData.value.available_stock),
total_stock: Number(formData.value.total_stock) || Number(formData.value.available_stock),
status: formData.value.status,
tags: formData.value.tags,
attributes: formData.value.attributes,
published_at: formData.value.published_at
}
// 新建商品时自动生成唯一的 product_code
if (!formData.value.id) {
const uid = Math.random().toString(36).substring(2, 10).toUpperCase()
payload['product_code'] = `P${Date.now()}${uid}`
}
let result;
if (formData.value.id) {
// 必须加上 merchant_id 检查,防止越权串数据
result = await supa.from('ml_products')
.update(payload)
.eq('id', formData.value.id)
.eq('merchant_id', formData.value.merchant_id)
.execute()
} else {
result = await supa.from('ml_products').insert([payload]).execute()
}
uni.hideLoading()
if (result && result.error) {
console.error('Save Product DB Error:', result.error)
uni.showToast({ title: '保存失败: ' + result.error.message, icon: 'none' })
} else {
uni.showToast({ title: '保存成功', icon: 'success' })
uni.$emit('REFRESH_PRODUCT_LIST')
setTimeout(() => goBack(), 1000)
}
} catch (error: any) {
uni.hideLoading()
console.error('保存报错:', error)
uni.showToast({ title: '发生错误: ' + (error.message || ''), icon: 'none' })
}
}
// 草稿箱
function saveDraftProduct() {
doSaveProduct(3) // 3 表示草稿
}
// 下一步/发布
function saveAndNext() {
// 这里如果原本要求点击下一步是发布上架,则传入 1
doSaveProduct(1)
}
</script>
<style scoped lang="scss">
.product-edit-page {
padding: 0;
background-color: transparent;
min-height: auto;
}
.page-header {
display: flex;
flex-direction: row;
align-items: center;
gap: 16px;
margin-bottom: 20px;
.back-link {
display: flex;
flex-direction: row;
align-items: center;
gap: 4px;
color: #666;
cursor: pointer;
.arrow { font-size: 14px; }
.back-txt { font-size: 14px; }
}
.header-title { font-size: 16px; font-weight: bold; color: #333; }
}
.steps-card {
background: #fff;
padding: 20px;
border-radius: 4px;
margin-bottom: 16px;
}
.step-items {
display: flex;
flex-direction: row;
align-items: center;
}
.step-item {
display: flex;
flex-direction: row;
align-items: center;
.step-txt {
font-size: 14px;
color: #999;
padding: 0 16px;
}
&.active .step-txt {
color: #1890ff;
font-weight: bold;
border-bottom: 2px solid #1890ff;
padding-bottom: 4px;
}
.step-line {
width: 20px;
height: 1px;
background: #e8e8e8;
}
}
.form-card {
background: #fff;
padding: 40px;
border-radius: 4px;
}
.form-item {
display: flex;
flex-direction: row;
margin-bottom: 30px;
&.align-center {
align-items: center;
}
&.multi-line {
align-items: flex-start;
}
.label {
width: 120px;
text-align: right;
font-size: 14px;
color: #333;
margin-right: 20px;
line-height: 20px;
.required { color: #f5222d; margin-right: 4px; }
}
&.multi-line .label {
padding-top: 8px; /* 顶部对齐缓冲 */
}
.input-wrap { flex: 1; }
}
.radio-item {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
border: 1px solid #1890ff;
border-radius: 4px;
padding: 10px 16px;
width: 160px;
position: relative;
.radio-circle {
width: 14px; height: 14px; border: 1px solid #d9d9d9; border-radius: 50%;
&.on { border-color: #1890ff; background: #1890ff; }
}
.radio-txt {
display: flex;
flex-direction: column;
.main { font-size: 14px; color: #333; }
.sub { font-size: 12px; color: #999; }
}
&::after {
content: '✓';
position: absolute;
right: 0; bottom: 0;
background: #1890ff; color: #fff; font-size: 10px; padding: 0 2px;
}
}
.input-box {
border: 1px solid #d9d9d9;
border-radius: 4px;
display: flex;
flex-direction: row;
align-items: center;
padding: 0 12px;
width: 400px;
height: 36px;
&.small { width: 150px; }
.real-input { flex: 1; font-size: 14px; color: #333; }
.count, .unit { font-size: 14px; color: #666; margin-left: 8px; }
}
.image-uploader {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 8px;
.img-item {
width: 100px; height: 100px; position: relative; border-radius: 4px; overflow: visible;
border: 1px solid #e8e8e8;
background-color: #f9f9f9;
image { width: 100%; height: 100%; border-radius: 4px; }
.img-close {
position: absolute; right: -8px; top: -8px; width: 20px; height: 20px;
background: rgba(0,0,0,0.5); color: #fff; border-radius: 50%;
display: flex; align-items: center; justify-content: center; font-size: 12px;
cursor: pointer; z-index: 2;
}
.main-tag {
position: absolute; left: 0; bottom: 0; background: rgba(24,144,255,0.8);
color: #fff; font-size: 12px; padding: 2px 6px; border-top-right-radius: 4px; border-bottom-left-radius: 4px;
}
}
.video-item {
width: 150px; height: 100px;
}
}
.upload-btn {
width: 100px; height: 100px; border: 1px dashed #d9d9d9; border-radius: 4px;
display: flex; flex-direction: column; align-items: center; justify-content: center; cursor: pointer;
background-color: #fafafa;
&:hover { border-color: #1890ff; }
.icon { font-size: 28px; color: #999; margin-bottom: 4px; }
.upload-txt { font-size: 12px; color: #666; }
&.v-btn {
width: 150px; height: 100px;
.v-icon { font-size: 24px; color: #999; margin-bottom: 4px; }
}
}
.tip { font-size: 12px; color: #999; }
.tag-selector {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 8px;
align-items: center;
.tag-item {
background: #f5f5f5; border: 1px solid #d9d9d9; padding: 4px 10px; border-radius: 4px;
display: flex; flex-direction: row; align-items: center; gap: 6px;
font-size: 14px; color: #666;
.close { color: #999; cursor: pointer; font-size: 16px; margin-left: 2px; }
}
.add-link { font-size: 14px; color: #1890ff; cursor: pointer; padding: 4px 0; }
}
.mock-btn-select {
border: 1px solid #d9d9d9; border-radius: 4px; padding: 0 16px; height: 36px;
font-size: 14px; color: #333; display: flex; align-items: center; cursor: pointer;
}
.radio-group-simple {
display: flex;
flex-direction: row;
gap: 20px;
.radio-simple {
display: flex; flex-direction: row; align-items: center; gap: 6px; font-size: 14px; color: #666; cursor: pointer;
.dot { width: 14px; height: 14px; border: 1px solid #d9d9d9; border-radius: 50%; position: relative; }
&.on {
color: #1890ff;
.dot { border-color: #1890ff; }
.dot::after {
content: ''; position: absolute; left: 3px; top: 3px; width: 6px; height: 6px;
background: #1890ff; border-radius: 50%;
}
}
}
}
.footer-btns {
margin-top: 24px;
display: flex;
flex-direction: row;
justify-content: center;
gap: 16px;
padding-bottom: 40px;
.btn-next { background: #1890ff; color: #fff; border: none; padding: 0 24px; height: 40px; border-radius: 4px; cursor: pointer; font-size: 14px; }
.btn-save { background: #fff; color: #333; border: 1px solid #d9d9d9; padding: 0 24px; height: 40px; border-radius: 4px; cursor: pointer; font-size: 14px; }
.btn-save:hover { color: #1890ff; border-color: #1890ff; }
}
</style>

View File

@@ -180,7 +180,7 @@ function goBack() {
</script>
<style scoped lang="scss">
.member-price-page { padding: 20px; background: #f5f7f9; min-height: 100vh; }
.member-price-page { padding: 0; background: transparent; min-height: auto; }
.page-header { display: flex; flex-direction: row; align-items: center; gap: 16px; margin-bottom: 20px;
.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; }

View File

@@ -1,11 +1,25 @@
<template>
<template>
<view class="product-list-page">
<!-- 店铺门禁:无店铺时显示空态 -->
<view v-if="shopLoading" class="shop-guard-loading">
<text class="sgl-txt">加载中...</text>
</view>
<view v-else-if="!hasShop" class="shop-guard-empty">
<text class="sge-icon">🏦</text>
<text class="sge-title">您还没有店铺</text>
<text class="sge-desc">先创建店铺,才能发布商品</text>
<button class="sge-btn" @click="goCreateShop">立即创建店铺</button>
</view>
<!-- 正常商品列表 -->
<template v-else>
<!-- 1. 搜索表单 -->
<view class="search-card">
<view class="search-row">
<view class="search-item">
<text class="label">商品搜索:</text>
<input class="mock-input" placeholder="请输入商品名称/关键字/ID" v-model="searchName" @confirm="handleSearch" />
<input class="mock-input" placeholder="请输入商品名称/关键字/ID" />
</view>
<view class="search-item">
<text class="label">商品类型:</text>
@@ -16,19 +30,14 @@
</view>
<view class="search-item">
<text class="label">商品分类:</text>
<picker :value="categoryIndex" :range="categoryOptions" range-key="label" @change="e => {
categoryIndex = e.detail.value;
selectedCategoryId = categoryOptions[categoryIndex].value;
}">
<view class="mock-select">
<text>{{ categoryOptions[categoryIndex].label }}</text>
<text class="arrow">▼</text>
</view>
</picker>
<view class="mock-select">
<text>请选择</text>
<text class="arrow">▼</text>
</view>
</view>
<view class="search-btns">
<button class="btn-primary" @click="handleSearch">查询</button>
<button class="btn-reset" @click="handleReset">重置</button>
<button class="btn-primary">查询</button>
<button class="btn-reset">重置</button>
<view class="expand-control">
<text class="expand-txt">展开</text>
<text class="expand-arrow">▼</text>
@@ -54,7 +63,7 @@
:key="index"
class="tab-item"
:class="{ active: activeStatus === tab.key }"
@click="changeStatus(tab.key)"
@click="activeStatus = tab.key"
>
<text>{{ tab.label }}({{ tab.count }})</text>
</view>
@@ -120,10 +129,12 @@
<view class="td col-stock"><text>{{ item.stock }}</text></view>
<view class="td col-sort"><text>{{ item.sort }}</text></view>
<view class="td col-status">
<view class="mock-switch" :class="{ on: item.status === 1 }" @click="toggleStatus(item)">
<text class="switch-txt">{{ item.status === 1 ? '上架' : '下架' }}</text>
<view class="switch-dot"></view>
</view>
<StatusSwitch
:modelValue="item.status === 1"
activeText="上架"
inactiveText="下架"
@update:modelValue="(val : boolean) => item.status = (val ? 1 : 0)"
/>
</view>
<view class="td col-op op-group">
<text class="op-link" @click.stop="goEdit(item.id)">编辑</text>
@@ -142,7 +153,7 @@
<text class="menu-item" @click.stop="goReviews(item.id)">查看评论</text>
<text class="menu-item" @click.stop="goMemberPrice(item.id)">会员价管理</text>
<text class="menu-item" @click.stop="uni.showToast({title:'佣金管理开发中', icon:'none'})">佣金管理</text>
<text class="menu-item danger-item" @click.stop="moveToRecycle(item)">{{ activeStatus === 4 ? '恢复商品' : '移到回收站' }}</text>
<text class="menu-item danger-item" @click.stop="moveToRecycle(item.id)">{{ activeStatus === 'recycle' ? '恢复商品' : '移到回收站' }}</text>
</view>
</view>
</view>
@@ -151,129 +162,313 @@
</view>
<!-- 5. 分页 -->
<view class="pagination-row">
<text class="total">共 {{ total }} 条</text>
<view class="page-ctrl">
<text class="page-btn" :class="{ disabled: page <= 1 }" @click="page > 1 && (page--, loadData())">{"<"}</text>
<text class="page-num active">{{ page }}</text>
<text class="page-btn" :class="{ disabled: productList.length < pageSize }" @click="productList.length == pageSize && (page++, loadData())">{">"}</text>
</view>
</view>
<CommonPagination
v-if="total > 0"
:total="total"
:loading="false"
:currentPage="currentPage"
:pageSize="pageSize"
:pageSizeOptionLabels="pageSizeOptionLabels"
:pageSizeIndex="pageSizeIndex"
:visiblePages="visiblePages"
:totalPage="totalPage"
:jumpPageInput="jumpPageInput"
@page-size-change="handlePageSizeChange"
@page-change="handlePageChange"
@update:jumpPageInput="(val : string) => { jumpPageInput = val }"
@jump-page="handleJumpPage"
/>
</view>
</template>
</view>
</template>
<script setup lang="uts">
import { ref, onMounted } from 'vue'
import { ref, onMounted, onUnmounted, watch, computed } from 'vue'
import { openRoute } from '@/layouts/admin/store/adminNavStore.uts'
import { fetchAdminProductPage, updateAdminProductStatus, fetchAdminProductCountStats, type AdminProduct } from '@/services/admin/productService.uts'
import { fetchAdminCategoryList, type AdminCategory } from '@/services/admin/productCategoryService.uts'
import StatusSwitch from '@/components/StatusSwitch.uvue'
import supa, { ensureSupabaseReady } from '@/components/supadb/aksupainstance'
import CommonPagination from '@/components/CommonPagination/CommonPagination.uvue'
const hasShop = ref(false)
const shopLoading = ref(true)
const total = ref(0)
const page = ref(1)
// 分页状态适配层
const currentPage = ref(1)
const pageSize = ref(10)
const activeStatus = ref<number>(1) // 1:出售中
const activeDropdownId = ref<string | null>(null)
const productList = ref<Array<AdminProduct>>([])
const searchName = ref('')
const selectedCategoryId = ref<string | null>(null)
const categoryOptions = ref<Array<{label: string, value: string | null}>>([
{ label: '全部', value: null }
])
const categoryIndex = ref(0)
let jumpPageInput = ''
const pageSizeOptions = [10, 20, 30, 50, 100]
const pageSizeOptionLabels = computed((): string[] => pageSizeOptions.map((s: number): string => `${s} 条/页`))
const pageSizeIndex = computed((): number => {
const i = pageSizeOptions.indexOf(pageSize.value)
return i === -1 ? 0 : i
})
const totalPage = computed((): number => Math.ceil(total.value / pageSize.value))
const visiblePages = computed((): number[] => {
const cur = currentPage.value
const tot = totalPage.value
if (tot <= 7) {
const pages: number[] = []
for (let i = 1; i <= tot; i++) pages.push(i)
return pages
}
if (cur <= 4) return [1, 2, 3, 4, 5, -1, tot]
if (cur >= tot - 3) return [1, -1, tot - 4, tot - 3, tot - 2, tot - 1, tot]
return [1, -1, cur - 1, cur, cur + 1, -1, tot]
})
const handlePageChange = (p: number) => {
if (p < 1 || p > totalPage.value || p === currentPage.value) return
currentPage.value = p
jumpPageInput = ''
}
const handlePageSizeChange = (e: any) => {
let val = 0
if (typeof e.detail.value === 'string') val = parseInt(e.detail.value)
else val = e.detail.value as number
pageSize.value = pageSizeOptions[val]
currentPage.value = 1
}
const handleJumpPage = () => {
let jumpTo = parseInt(jumpPageInput)
if (isNaN(jumpTo)) return
if (jumpTo < 1) jumpTo = 1
if (jumpTo > totalPage.value) jumpTo = totalPage.value
jumpPageInput = String(jumpTo)
if (jumpTo !== currentPage.value) currentPage.value = jumpTo
}
const activeStatus = ref('selling')
const activeDropdownId = ref<number | null>(null)
const statusTabs = ref([
{ key: 1, label: '出售中的商品', count: 0 },
{ key: 2, label: '仓库中的商品', count: 0 },
{ key: 3, label: '草稿箱', count: 0 },
{ key: 4, label: '回收站', count: 0 },
{ key: 'selling', label: '出售中的商品', count: 0 },
{ key: 'warehouse', label: '仓库中的商品', count: 0 },
{ key: 'soldout', label: '已经售罄商品', count: 0 },
{ key: 'alarm', label: '警戒库存商品', count: 0 },
{ key: 'recycle', label: '回收站的商品', count: 0 },
])
onMounted(() => {
loadCounts()
loadCategories()
loadData()
const productList = ref<any[]>([])
// 监听 activeStatus 变化
watch(activeStatus, () => {
fetchProducts()
})
async function loadCategories() {
try {
const categories = await fetchAdminCategoryList({ isActive: true })
categories.forEach(item => {
categoryOptions.value.push({
label: item.name,
value: item.id
})
})
} catch (e) {
console.error('加载分类失败:', e)
}
}
async function loadCounts() {
const stats = await fetchAdminProductCountStats()
if (stats != null) {
statusTabs.value[0].count = parseInt(String(stats['selling'] ?? '0'))
statusTabs.value[1].count = parseInt(String(stats['warehouse'] ?? '0'))
statusTabs.value[2].count = parseInt(String(stats['draft'] ?? '0'))
statusTabs.value[3].count = parseInt(String(stats['recycle'] ?? '0'))
}
}
async function loadData() {
const res = await fetchAdminProductPage(page.value, pageSize.value, {
name: searchName.value,
status: activeStatus.value,
categoryId: selectedCategoryId.value ?? undefined
// 商品模块店铺门禁
onMounted(async () => {
await checkShop()
uni.$on('REFRESH_PRODUCT_LIST', () => {
fetchProducts()
})
productList.value = res.items
total.value = res.total
})
async function checkShop() {
shopLoading.value = true
try {
await ensureSupabaseReady()
const userId = supa.getSession().user?.getString('id')
if (!userId) {
hasShop.value = false
shopLoading.value = false
return
}
// 查询 ml_shops 确认当前用户是否已建店
const res = await supa.from('ml_shops')
.select('merchant_id, shop_name, status')
.eq('merchant_id', userId)
.single()
.execute()
if (res.error != null || !res.data) {
hasShop.value = false
} else {
const rawData = res.data
let shopRow: UTSJSONObject | null = null
if (Array.isArray(rawData)) {
shopRow = (rawData as Array<UTSJSONObject>).length > 0 ? (rawData as Array<UTSJSONObject>)[0] : null
} else {
shopRow = rawData as UTSJSONObject
}
hasShop.value = shopRow != null
}
} catch (e: any) {
console.warn('[ProductList] 店铺检查异常:', e)
hasShop.value = false
} finally {
shopLoading.value = false
}
if (hasShop.value) {
fetchProducts()
}
}
function handleSearch() {
page.value = 1
loadData()
loadCounts()
function goCreateShop() {
openRoute('shop_manage')
}
function handleReset() {
searchName.value = ''
selectedCategoryId.value = null
page.value = 1
loadData()
loadCounts()
onUnmounted(() => {
uni.$off('REFRESH_PRODUCT_LIST')
})
// 从 ml_products 表中获取商品数据
async function fetchProducts() {
await ensureSupabaseReady()
// merchant_id 来自 ml_shops 所关联的 ak_users.id即 auth user id
const currentMerchantId = supa.getSession().user?.getString('id')
if (!currentMerchantId) {
uni.showToast({ title: '未获取到商家信息,请重新登录', icon: 'none' })
return
}
try {
const query = supa
.from('ml_products')
.select('id, name, main_image_url, base_price, available_stock, status, created_at', { count: 'exact' })
.eq('merchant_id', currentMerchantId)
// 根据 activeStatus 过滤
// 1:上架 2:下架 3:草稿 4:删除
// selling: 1, warehouse: 2+3, soldout: stock=0, alarm: stock<10, recycle: 4
if (activeStatus.value === 'selling') {
query.eq('status', 1)
} else if (activeStatus.value === 'warehouse') {
query.in('status', [2, 3])
} else if (activeStatus.value === 'recycle') {
query.eq('status', 4)
} else if (activeStatus.value === 'soldout') {
query.eq('available_stock', 0)
} else if (activeStatus.value === 'alarm') {
query.lt('available_stock', 10)
}
const { data, error, count } = await query.order('created_at', { ascending: false }).execute()
if (error) {
console.error('Fetch products error:', error)
uni.showToast({ title: '加载失败: ' + error.message, icon: 'none' })
return
}
if (data != null) {
const dataArray = data as Array<UTSJSONObject>
productList.value = dataArray.map((item: UTSJSONObject): any => {
return {
id: item.get('id'),
image: item.get('main_image_url') || '',
name: item.get('name') || '未命名商品',
activities: [],
typeName: '普通商品',
price: item.get('base_price') != null ? Number(item.get('base_price')).toFixed(2) : '0.00',
sales: 0,
stock: item.get('available_stock') || 0,
sort: 0,
status: item.get('status') || 0
}
})
total.value = count || dataArray.length
// 更新 Tab 计数 (简单同步当前列表总数到对应 Tab)
statusTabs.value.forEach(tab => {
if (tab.key === activeStatus.value) {
tab.count = total.value
}
})
}
} catch (err: any) {
console.error('获取商品列表失败:', err)
uni.showToast({ title: '加载失败: ' + (err.message || ''), icon: 'none' })
}
}
function changeStatus(key: number) {
activeStatus.value = key
page.value = 1
loadData()
function getActivityName(tag: string): string {
if (tag === 'kj') return '砍价'
if (tag === 'pt') return '拼团'
if (tag === 'ms') return '秒杀'
return tag
}
function goEdit(id: string | null) {
function goEdit(id: number | null) {
if (id !== null) {
uni.setStorageSync('edit_product_id', id)
} else {
uni.removeStorageSync('edit_product_id')
}
openRoute('product_edit')
}
function goReviews(id: string) {
function goReviews(id: number) {
openRoute('product_productReply')
}
function goMemberPrice(id: string) {
function goMemberPrice(id: number) {
openRoute('product_member_price')
}
function moveToRecycle(id: number) {
const action = activeStatus.value === 'recycle' ? '恢复' : '移到回收站';
uni.showModal({
title: '提示',
content: `确认要将该商品${action}吗?`,
success: (res) => {
if (res.confirm) {
uni.showToast({ title: '操作成功', icon: 'success' });
}
}
})
}
</script>
<style scoped lang="scss">
.product-list-page {
padding: 20px;
background-color: #f5f7f9;
min-height: 100vh;
/* 使用 Layout 的背景和内边距 */
min-height: auto;
}
/* 店铺门禁状态 */
.shop-guard-loading {
display: flex;
align-items: center;
justify-content: center;
height: 200px;
background: #fff;
border-radius: 4px;
}
.sgl-txt { font-size: 14px; color: #999; }
.shop-guard-empty {
display: flex;
flex-direction: column;
align-items: center;
padding: 80px 40px;
background: #fff;
border-radius: 4px;
}
.sge-icon { font-size: 56px; margin-bottom: 16px; }
.sge-title { font-size: 18px; font-weight: 600; color: #333; margin-bottom: 8px; }
.sge-desc { font-size: 13px; color: #999; margin-bottom: 28px; }
.sge-btn {
padding: 0 32px;
height: 40px;
background: #1890ff;
color: #fff;
border-radius: 4px;
font-size: 14px;
border: none;
display: flex;
align-items: center;
justify-content: center;
}
.search-card {
background: #fff;
padding: 24px;
padding: var(--admin-card-padding);
border-radius: 4px;
margin-bottom: 16px;
margin-bottom: var(--admin-section-gap);
}
.search-row {
@@ -537,62 +732,8 @@ function goMemberPrice(id: string) {
.p-name-txt { font-size: 13px; line-height: 1.4; color: #333; }
.activity-tags {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 4px;
.tag {
padding: 2px 4px;
font-size: 11px;
color: #fff;
border-radius: 2px;
&.kj { background: #1890ff; }
&.pt { background: #52c41a; }
&.ms { background: #f5222d; }
.op-divider { color: #e8e8e8; font-size: 12px; margin: 0 4px; }
}
}
.mock-switch {
width: 50px;
height: 20px;
background: #dbdbdb;
border-radius: 10px;
position: relative;
display: flex;
align-items: center;
padding: 0 4px;
&.on {
background: #1890ff;
.switch-dot { left: 32px; }
.switch-txt { left: 6px; }
}
&:not(.on) {
.switch-txt { right: 6px; }
.switch-dot { left: 2px; }
}
.switch-txt {
position: absolute;
font-size: 10px;
color: #fff;
}
.switch-dot {
position: absolute;
width: 16px;
height: 16px;
background: #fff;
border-radius: 50%;
transition: left 0.2s;
}
}
.op-link {
font-size: 13px;
color: #1890ff;
cursor: pointer;
&.danger { color: #ff4d4f; }
}
.op-divider { color: #e8e8e8; font-size: 12px; margin: 0 4px; }
.more-dropdown {
display: flex;
flex-direction: row;
@@ -600,32 +741,8 @@ function goMemberPrice(id: string) {
.arrow { font-size: 10px; color: #1890ff; margin-left: 2px; }
}
.pagination-row {
padding: 24px;
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
gap: 16px;
.total { font-size: 13px; color: #606266; }
}
/* 分页区域已迁至 CommonPagination 组件 */
.page-ctrl {
display: flex;
flex-direction: row;
gap: 8px;
.page-num, .page-btn {
width: 32px;
height: 32px;
border: 1px solid #dcdfe6;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
color: #606266;
&.active { background: #1890ff; color: #fff; border-color: #1890ff; }
&.disabled { color: #c0c4cc; background: #f5f7fa; }
}
}
</style>

View File

@@ -24,7 +24,7 @@
<view v-if="list.length === 0" class="empty-box">
<text class="empty-text">暂无数据</text>
</view>
<view v-for="(item, index) in list" :key="index" class="table-row-item">
<view v-for="(item, index) in pagedList" :key="index" class="table-row-item">
<text class="td flex-1 color-9">{{ item.id }}</text>
<view class="td flex-2">
<image class="protection-icon-img" :src="item.icon" mode="aspectFit"></image>
@@ -32,11 +32,9 @@
<text class="td flex-4">{{ item.name }}</text>
<text class="td flex-5 color-6">{{ item.desc }}</text>
<view class="td flex-2 row-center">
<view class="status-switch-mini" :class="item.status ? 'active' : ''" @click="toggleStatus(index)">
<view class="switch-dot-mini"></view>
</view>
</view>
<view class="td flex-2 row-center">
<StatusSwitch v-model="item.status" activeText="显示" inactiveText="隐藏" />
</view>
<view class="td flex-2 row-center">
<text class="btn-action-blue" @click="openModal(item)">编辑</text>
<view class="v-divider-line"></view>
<text class="btn-action-red" @click="deleteItem(index)">删除</text>
@@ -44,6 +42,22 @@
</view>
</view>
</view>
<CommonPagination
v-if="true"
:total="total"
:loading="false"
:currentPage="currentPage"
:pageSize="pageSize"
:pageSizeOptionLabels="pageSizeOptionLabels"
:pageSizeIndex="pageSizeIndex"
:visiblePages="visiblePages"
:totalPage="totalPage"
:jumpPageInput="jumpPageInput"
@page-size-change="handlePageSizeChange"
@page-change="handlePageChange"
@update:jumpPageInput="(val: string) => { jumpPageInput.value = val }"
@jump-page="handleJumpPage"
/>
<!-- 添加/编辑弹窗 (居中 Modal) -->
<view class="modal-overlay" v-if="showModal" @click="closeModal">
@@ -62,7 +76,7 @@
<view class="form-item-box row-align-start">
<view class="label-box"><text class="form-label font-star">保障内容:</text></view>
<view class="val-box">
<textarea class="textarea-ctrl" v-model="form.desc" placeholder="请输入保障内容" />
<textarea class="textarea-ctrl" v-model="form.desc" placeholder="请输入保障内容"></textarea>
</view>
</view>
<view class="form-item-box">
@@ -104,21 +118,70 @@
</template>
<script setup lang="uts">
import { ref, reactive, onMounted } from 'vue'
import {
fetchProductProtections,
saveProductProtection,
deleteProductProtection,
setProductProtectionActive,
ProductProtection
} from '@/services/admin/productProtectionService.uts'
import { ref, reactive, computed } from 'vue'
import StatusSwitch from '@/components/StatusSwitch.uvue'
import CommonPagination from '@/components/CommonPagination/CommonPagination.uvue'
const list = ref<ProductProtection[]>([])
const isLoading = ref(false)
interface ProtectionItem {
id: number;
name: string;
icon: string;
desc: string;
status: boolean;
sort: number;
}
// ========== MOCK DATA START ==========
// TODO: 接真实接口时替换此处 list 为 fetchProtectionList() 调用
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 },
{ id: 3, name: '价格保指', icon: '/static/logo.png', desc: '购买后 30 天内如遇同款低价,即可申请价差补偿。', status: true, sort: 1 },
{ id: 4, name: '全程颜料隐形', icon: '/static/logo.png', desc: '顺丰乐丰包装,不露商品信息,注重隐次保护。', status: true, sort: 2 },
{ id: 5, name: '隐私保护', icon: '/static/logo.png', desc: '尥尺保护您的个人信息,不向任何第三方泄露。', status: true, sort: 3 },
{ id: 6, name: '即时客服', icon: '/static/logo.png', desc: '7×24小时在线客服随时解决您的问题。', status: true, sort: 4 },
{ id: 7, name: '准时发货', icon: '/static/logo.png', desc: '下单后 48 小时内发货,快递全程跟踪。', status: false, sort: 5 },
{ id: 8, name: '免费退返运', icon: '/static/logo.png', desc: '指定品类商品支持免费退返运。', status: true, sort: 6 },
{ id: 9, name: '官方维修', icon: '/static/logo.png', desc: '各地维修中心 500+,提供上门维修服务。', status: true, sort: 7 },
{ id: 10, name: '分期免息', icon: '/static/logo.png', desc: '支持花唉/支付分期,指定商品免息付款。', status: false, sort: 8 }
])
// ========== MOCK DATA END ==========
// ========== PAGINATION STATE ==========
const currentPage = ref(1)
const pageSize = ref(10)
const jumpPageInput = ref('')
const pageSizeOptions = [10, 15, 20, 30, 50]
const pageSizeOptionLabels = computed(() => pageSizeOptions.map((n: number) => `${n}条/页`))
const pageSizeIndex = computed(() => { const idx = pageSizeOptions.indexOf(pageSize.value); return idx >= 0 ? idx : 0 })
const total = computed(() => list.length)
const totalPage = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
const pagedList = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return list.slice(start, start + pageSize.value)
})
const visiblePages = computed((): number[] => {
const t = totalPage.value; const cur = currentPage.value
if (t <= 7) return Array.from({ length: t }, (_: any, i: number) => i + 1)
if (cur <= 4) return [1, 2, 3, 4, 5, -1, t]
if (cur >= t - 3) return [1, -1, t - 4, t - 3, t - 2, t - 1, t]
return [1, -1, cur - 1, cur, cur + 1, -1, t]
})
const handlePageChange = (p: number) => { currentPage.value = p }
const handlePageSizeChange = (e: any) => {
const idx = Number(e.detail.value)
pageSize.value = pageSizeOptions[idx] ?? pageSizeOptions[0]
currentPage.value = 1
}
const handleJumpPage = () => {
const p = parseInt(jumpPageInput.value)
if (!isNaN(p) && p >= 1 && p <= totalPage.value) currentPage.value = p
}
// ========== END PAGINATION STATE ==========
const showModal = ref(false)
const isEdit = ref(false)
const editingId = ref<string | null>(null)
const editIndex = ref(-1)
const form = reactive({
name: '',
@@ -128,34 +191,17 @@ const form = reactive({
sort: 0
})
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) {
function openModal(item: ProtectionItem | null = null) {
if (item) {
isEdit.value = true
editingId.value = item.id ?? null
form.name = item.name
form.icon = item.icon_url ?? ''
form.desc = item.description
form.status = item.is_active
form.sort = item.sort_order
form.icon = item.icon
form.desc = item.desc
form.status = item.status
form.sort = item.sort
editIndex.value = list.indexOf(item)
} else {
isEdit.value = false
editingId.value = null
form.name = ''
form.icon = ''
form.desc = ''
@@ -174,59 +220,43 @@ function mockIconPicker() {
form.icon = '/static/logo.png'
}
async function saveProtection() {
function saveProtection() {
if (!form.name || !form.desc) {
uni.showToast({ title: '请输入必填项', icon: 'none' })
return
}
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
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
})
}
closeModal()
uni.showToast({ title: '保存成功', icon: 'success' })
}
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 toggleStatus(index: number) {
list[index].status = !list[index].status
}
async function deleteItem(id: string | undefined) {
if (id == null) return
function deleteItem(index: number) {
uni.showModal({
title: '提示',
content: '确定删除该保障条款吗?',
success: async (res) => {
success: (res) => {
if (res.confirm) {
const success = await deleteProductProtection(id)
if (success) {
uni.showToast({ title: '删除成功' })
loadData()
}
list.splice(index, 1)
}
}
})
@@ -235,9 +265,9 @@ async function deleteItem(id: string | undefined) {
<style scoped lang="scss">
.admin-main {
padding: 20px;
background-color: #f0f2f5;
min-height: 100vh;
padding: 0;
background-color: transparent;
min-height: auto;
}
.alert-info-box {
@@ -252,7 +282,7 @@ async function deleteItem(id: string | undefined) {
.table-card {
background-color: #fff;
padding: 20px;
padding: 24px;
border-radius: 4px;
}
@@ -297,29 +327,6 @@ async function deleteItem(id: string | undefined) {
height: 30px;
}
/* Switch */
.status-switch-mini {
width: 44px;
height: 20px;
background-color: #ccc;
border-radius: 10px;
position: relative;
transition: background-color 0.3s;
}
.status-switch-mini.active { background-color: #1890ff; }
.switch-dot-mini {
width: 16px;
height: 16px;
background-color: #fff;
border-radius: 50%;
position: absolute;
top: 2px;
left: 2px;
transition: left 0.3s;
}
.status-switch-mini.active .switch-dot-mini { left: 26px; }
.btn-action-blue { color: #1890ff; font-size: 14px; cursor: pointer; }
.btn-action-red { color: #ff4d4f; font-size: 14px; cursor: pointer; }
.v-divider-line { width: 1px; height: 12px; background-color: #eee; margin: 0 10px; }

View File

@@ -5,40 +5,37 @@
<view class="search-row">
<view class="search-item">
<text class="label">评价时间:</text>
<AnalyticsDateRangePicker
:initialStartDate="startDate"
:initialEndDate="endDate"
@apply="onApplyRange"
@clear="onClearRange"
/>
<view class="mock-date-range">
<text class="emoji">📅</text>
<text class="txt">开始日期 - 结束日期</text>
</view>
</view>
<view class="search-item">
<text class="label">评价状态:</text>
<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 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>
</view>
</view>
<view class="search-row mt-16">
<view class="search-item">
<text class="label">商品名称</text>
<input class="mock-input" v-model="searchProduct" placeholder="请输入商品信息" @confirm="onSearch" />
<text class="label">商品信息</text>
<input class="mock-input" placeholder="请输入商品信息" />
</view>
<view class="search-item">
<text class="label">用户名称:</text>
<input class="mock-input" v-model="searchUser" placeholder="请输入" @confirm="onSearch" />
<input class="mock-input" placeholder="请输入" />
</view>
<button class="btn-primary" @click="onSearch">查询</button>
<button class="btn-white" @click="onReset">重置</button>
<button class="btn-primary">查询</button>
</view>
</view>
<!-- 2. 操作行 -->
<view class="action-bar">
<button class="btn-primary" @click="onAddSelfReview">添加自评</button>
<button class="btn-primary">添加自评</button>
<button class="btn-white">批量审核</button>
</view>
<!-- 3. 数据表格 -->
@@ -48,6 +45,7 @@
<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>
@@ -57,229 +55,142 @@
<view class="th col-op"><text>操作</text></view>
</view>
<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 v-for="item in pagedList" :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.product_image || '/static/logo.png'" mode="aspectFill" />
<text class="p-name-txt">{{ item.product_name }}</text>
<image class="p-img" :src="item.image" mode="aspectFill" />
<text class="p-name-txt">{{ item.productName }}</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-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-content"><text class="blue-link">{{ item.content }}</text></view>
<view class="td col-reply"><text>{{ item.merchant_reply || '无' }}</text></view>
<view class="td col-reply"><text>{{ item.reply || '无' }}</text></view>
<view class="td col-status">
<text class="status-tag" :class="item.status === 1 ? 'pass' : (item.status === 3 ? 'fail' : 'wait')">
{{ item.status === 1 ? '通过' : (item.status === 3 ? '已驳回' : '待审核') }}
<text class="status-tag" :class="item.status === 1 ? 'pass' : 'wait'">
{{ item.status === 1 ? '通过' : '待审核' }}
</text>
</view>
<view class="td col-time"><text>{{ formatDateTime(item.created_at) }}</text></view>
<view class="td col-time"><text>{{ item.time }}</text></view>
<view class="td col-op">
<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>
<text class="op-link">通过</text>
<text class="op-link">驳回</text>
<text class="op-link">回复</text>
<text class="op-link red">删除</text>
</view>
</view>
</view>
<!-- 分页 -->
<view class="pagination-row">
<text class="total">共 {{ total }} 条</text>
<view class="page-ctrl">
<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>
<CommonPagination
v-if="true"
:total="total"
:loading="false"
:currentPage="currentPage"
:pageSize="pageSize"
:pageSizeOptionLabels="pageSizeOptionLabels"
:pageSizeIndex="pageSizeIndex"
:visiblePages="visiblePages"
:totalPage="totalPage"
:jumpPageInput="jumpPageInput"
@page-size-change="handlePageSizeChange"
@page-change="handlePageChange"
@update:jumpPageInput="(val : string) => { jumpPageInput = val }"
@jump-page="handleJumpPage"
/>
</view>
</view>
</template>
<script setup lang="uts">
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'
import { ref, computed } from 'vue'
import CommonPagination from '@/components/CommonPagination/CommonPagination.uvue'
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 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 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()
// ========== PAGINATION STATE ==========
const currentPage = ref(1)
const pageSize = ref(10)
const jumpPageInput = ref('')
const pageSizeOptions = [10, 20, 30, 50]
const pageSizeOptionLabels = computed(() => pageSizeOptions.map((n: number) => `${n}条/页`))
const pageSizeIndex = computed(() => { const idx = pageSizeOptions.indexOf(pageSize.value); return idx >= 0 ? idx : 0 })
const total = computed(() => replyList.value.length)
const totalPage = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
const pagedList = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return replyList.value.slice(start, start + pageSize.value)
})
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
}
const visiblePages = computed((): number[] => {
const t = totalPage.value; const cur = currentPage.value
if (t <= 7) return Array.from({ length: t }, (_: any, i: number) => i + 1)
if (cur <= 4) return [1, 2, 3, 4, 5, -1, t]
if (cur >= t - 3) return [1, -1, t - 4, t - 3, t - 2, t - 1, t]
return [1, -1, cur - 1, cur, cur + 1, -1, t]
})
const handlePageChange = (p: number) => { currentPage.value = p }
const handlePageSizeChange = (e: any) => {
const idx = Number(e.detail.value)
pageSize.value = pageSizeOptions[idx] ?? pageSizeOptions[0]
currentPage.value = 1
}
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()
}
}
})
const handleJumpPage = () => {
const p = parseInt(jumpPageInput.value)
if (!isNaN(p) && p >= 1 && p <= totalPage.value) currentPage.value = p
}
// ========== END PAGINATION STATE ==========
</script>
<style scoped lang="scss">
.product-reply-page {
padding: 20px;
background-color: #f5f7f9;
min-height: 100vh;
padding: 0;
background-color: transparent;
min-height: auto;
}
.search-card {
background: #fff;
padding: 24px;
padding: var(--admin-card-padding);
border-radius: 4px;
margin-bottom: 16px;
margin-bottom: var(--admin-section-gap);
}
.search-row {
@@ -319,7 +230,7 @@ async function handleReply(item: ProductReviewItem) {
.btn-white { background: #fff; color: #606266; height: 32px; padding: 0 16px; border-radius: 4px; font-size: 14px; border: 1px solid #dcdfe6; }
.action-bar {
margin-bottom: 16px;
margin-bottom: 20px;
display: flex;
flex-direction: row;
gap: 12px;
@@ -328,6 +239,7 @@ async function handleReply(item: ProductReviewItem) {
.list-card {
background: #fff;
border-radius: 4px;
padding: var(--admin-card-padding);
}
.table-v5 { width: 100%; }
@@ -364,7 +276,7 @@ async function handleReply(item: ProductReviewItem) {
.col-reply { flex: 1; }
.col-status { width: 100px; }
.col-time { width: 160px; }
.col-op { width: 180px; }
.col-op { width: 180px; display: flex; flex-direction: row; }
.p-img { width: 40px; height: 40px; border-radius: 4px; }
.p-name-txt { font-size: 13px; line-height: 1.4; color: #1890ff; }
@@ -378,19 +290,5 @@ async function handleReply(item: ProductReviewItem) {
}
.op-link { font-size: 13px; color: #1890ff; margin: 0 4px; cursor: pointer; &.red { color: #f5222d; } }
.pagination-row {
padding: 24px; display: flex; flex-direction: row; justify-content: flex-end; align-items: center; gap: 16px;
.total { font-size: 13px; color: #606266; }
}
.page-ctrl {
display: flex; flex-direction: row; gap: 8px;
.page-num, .page-btn {
width: 32px; height: 32px; border: 1px solid #dcdfe6; border-radius: 4px;
display: flex; align-items: center; justify-content: center; font-size: 13px;
&.active { background: #1890ff; color: #fff; }
&.disabled { background: #f5f5f5; color: #ccc; }
}
}
/* 分页区域已迁至 CommonPagination 组件 */
</style>

View File

@@ -5,16 +5,16 @@
<view class="search-row">
<view class="search-item">
<text class="search-label">规格搜索:</text>
<input class="search-input" v-model="searchName" placeholder="请输入规格名称" @confirm="handleSearch" />
<input class="search-input" placeholder="请输入规格名称" />
</view>
<button class="btn-query" @click="handleSearch">查询</button>
<button class="btn-query">查询</button>
</view>
</view>
<!-- 数据表格区域 -->
<view class="table-card">
<view class="table-toolbar">
<button class="btn-add" @click="openAddModal">添加商品规格</button>
<button class="btn-add" @click="showModal = true">添加商品规格</button>
<button class="btn-batch-del">批量删除</button>
</view>
@@ -33,7 +33,7 @@
<view v-if="list.length === 0" class="empty-box">
<text class="empty-text">暂无数据</text>
</view>
<view v-for="(item, index) in list" :key="index" class="table-row">
<view v-for="(item, index) in pagedList" :key="index" class="table-row">
<view class="td-cell flex-1 row-center">
<view class="checkbox-mock" :class="item.selected ? 'checked' : ''" @click="item.selected = !item.selected">
<text v-if="item.selected" class="check-mark">✓</text>
@@ -44,12 +44,28 @@
<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" @click="openEditModal(item)">编辑</text>
<text class="btn-link">编辑</text>
<view class="divider"></view>
<text class="btn-link delete" @click="deleteItem(item.id)">删除</text>
<text class="btn-link delete" @click="deleteItem(index)">删除</text>
</view>
</view>
</view>
<CommonPagination
v-if="true"
:total="total"
:loading="false"
:currentPage="currentPage"
:pageSize="pageSize"
:pageSizeOptionLabels="pageSizeOptionLabels"
:pageSizeIndex="pageSizeIndex"
:visiblePages="visiblePages"
:totalPage="totalPage"
:jumpPageInput="jumpPageInput"
@page-size-change="handlePageSizeChange"
@page-change="handlePageChange"
@update:jumpPageInput="(val: string) => { jumpPageInput.value = val }"
@jump-page="handleJumpPage"
/>
</view>
<!-- 添加规格弹窗 -->
@@ -91,112 +107,103 @@
</template>
<script setup lang="uts">
import { ref, reactive, onMounted } from 'vue'
import { fetchSpecTemplates, saveSpecTemplate, deleteSpecTemplate, ProductSpecTemplate } from '@/services/admin/productSpecParamService.uts'
import { ref, reactive, computed } from 'vue'
import CommonPagination from '@/components/CommonPagination/CommonPagination.uvue'
interface AttrItem extends ProductSpecTemplate {
interface AttrItem {
id: number;
name: string;
specs: string;
attrs: string;
selected: boolean;
}
const list = ref<AttrItem[]>([])
const isLoading = ref(false)
const searchName = ref('')
// ========== MOCK DATA START ==========
// TODO: 接真实接口时替换此处 list 为 fetchSpecList() 调用
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 },
{ id: 109, name: '重量', specs: '100g,200g,500g,1kg', attrs: '包装规格', selected: false },
{ id: 110, name: '口味', specs: '原味,辣味,甜味,咋味', attrs: '食品口味', selected: false },
{ id: 111, name: '风格', specs: '日系,韩系,欧美,新中式', attrs: '服装风格', selected: false },
{ id: 112, name: '屏幕尺寸', specs: '6.1小时,6.7小时,6.9小时', attrs: '手机屏幕', selected: false },
{ id: 113, name: '套餐选择', specs: '套餐A,套餐B,套餐C', attrs: '餐飲套餐', selected: false },
{ id: 114, name: '独立包装', specs: '独立包装,组合装', attrs: '包装方式', selected: false },
{ id: 115, name: '靘刀', specs: '靘刀7天,靘刀14天,靘刀30天', attrs: '服装保洁', selected: false },
{ id: 116, name: '宣传图', specs: '带宣传图,不带宣传图', attrs: '商品配送', selected: false },
{ id: 117, name: '主题色', specs: '科技蓝,记忆红,森林绿,太空黑', attrs: '设备配色', selected: false },
{ id: 118, name: '等级', specs: '普通版,标准版,旗舰版', attrs: '商品等级', selected: false }
])
// ========== MOCK DATA END ==========
// ========== PAGINATION STATE ==========
const currentPage = ref(1)
const pageSize = ref(10)
const jumpPageInput = ref('')
const pageSizeOptions = [10, 15, 20, 30, 50]
const pageSizeOptionLabels = computed(() => pageSizeOptions.map((n: number) => `${n}条/页`))
const pageSizeIndex = computed(() => { const idx = pageSizeOptions.indexOf(pageSize.value); return idx >= 0 ? idx : 0 })
const total = computed(() => list.length)
const totalPage = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
const pagedList = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return list.slice(start, start + pageSize.value)
})
const visiblePages = computed((): number[] => {
const t = totalPage.value; const cur = currentPage.value
if (t <= 7) return Array.from({ length: t }, (_: any, i: number) => i + 1)
if (cur <= 4) return [1, 2, 3, 4, 5, -1, t]
if (cur >= t - 3) return [1, -1, t - 4, t - 3, t - 2, t - 1, t]
return [1, -1, cur - 1, cur, cur + 1, -1, t]
})
const handlePageChange = (p: number) => { currentPage.value = p }
const handlePageSizeChange = (e: any) => {
const idx = Number(e.detail.value)
pageSize.value = pageSizeOptions[idx] ?? pageSizeOptions[0]
currentPage.value = 1
}
const handleJumpPage = () => {
const p = parseInt(jumpPageInput.value)
if (!isNaN(p) && p >= 1 && p <= totalPage.value) currentPage.value = p
}
// ========== END PAGINATION STATE ==========
const showModal = ref(false)
const isEdit = ref(false)
const editingId = ref<string | null>(null)
const form = reactive({
name: '',
specs: '',
attrs: ''
})
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() {
function saveAttr() {
if (!form.name) {
uni.showToast({ title: '请输入规格名称', icon: 'none' })
return
}
isLoading.value = true
const tpl : ProductSpecTemplate = {
id: editingId.value ?? undefined,
list.push({
id: Math.floor(Math.random() * 1000),
name: form.name,
specs: form.specs,
attrs: form.attrs,
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
}
selected: false
})
showModal.value = false
form.name = ''
form.specs = ''
form.attrs = ''
uni.showToast({ title: '添加成功', icon: 'success' })
}
async function deleteItem(id : string | undefined) {
if (id == null) return
function deleteItem(index: number) {
uni.showModal({
title: '提示',
content: '确定删除该规格吗?',
success: async (res) => {
success: (res) => {
if (res.confirm) {
const success = await deleteSpecTemplate(id)
if (success) {
uni.showToast({ title: '删除成功' })
loadData()
}
list.splice(index, 1)
}
}
})
@@ -205,17 +212,18 @@ async function deleteItem(id : string | undefined) {
<style scoped lang="scss">
.admin-main {
padding: 20px;
background-color: #f0f2f5;
min-height: 100vh;
/* 使用 Layout 的背景和内边距 */
padding: 0;
background-color: transparent;
min-height: auto;
}
/* 搜索卡片 */
.search-card {
background-color: #fff;
padding: 24px;
padding: var(--admin-card-padding);
border-radius: 4px;
margin-bottom: 20px;
margin-bottom: var(--admin-section-gap);
}
.search-row {
@@ -261,7 +269,7 @@ async function deleteItem(id : string | undefined) {
/* 表格区域 */
.table-card {
background-color: #fff;
padding: 24px;
padding: var(--admin-card-padding);
border-radius: 4px;
}