Files
medical-mall/pages/mall/admin/product/classification/index.uvue
2026-03-09 15:49:16 +08:00

838 lines
20 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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" placeholder="请输入分类名称" />
</view>
<view class="search-item">
<text class="search-label">状态:</text>
<view class="mock-select">
<text>全部</text>
<text class="arrow-down"></text>
</view>
</view>
<button class="btn-query">查询</button>
<button class="btn-reset">重置</button>
</view>
</view>
<!-- 数据表格区域 -->
<view class="table-card">
<view class="table-toolbar">
<button class="btn-add" @click="openDrawer()">添加商品分类</button>
</view>
<view class="table-header">
<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>
</view>
<view class="table-body">
<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" 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>{{ item.name }}</text>
</view>
<view class="td-cell flex-2">
<image class="cate-icon" :src="item.icon_url || '/static/logo.png'" mode="aspectFit"></image>
</view>
<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">
<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>
<view class="divider"></view>
<text class="btn-link delete" @click="deleteItem(item)">删除</text>
</view>
</view>
<!-- 子级 -->
<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" 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_url || '/static/logo.png'" mode="aspectFit"></image>
</view>
<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">
<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>
<view class="divider"></view>
<text class="btn-link delete" @click="deleteItem(child)">删除</text>
</view>
</view>
</view>
</view>
</view>
</view>
<!-- 添加/编辑抽屉 (50% 宽度,贴右侧展示) -->
<view class="drawer-mask" v-if="showDrawerMask" @click="closeDrawer">
<view class="drawer-content" @click.stop="" :class="{ 'drawer-show': showDrawer }">
<view class="drawer-header">
<text class="drawer-title">{{ isEdit ? '编辑分类' : '添加分类' }}</text>
<text class="drawer-close" @click="closeDrawer"></text>
</view>
<scroll-view class="drawer-body" scroll-y="true">
<view class="drawer-form">
<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">
<view class="form-label-box"><text class="form-label required">分类名称:</text></view>
<view class="form-input-box">
<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" 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_order" />
</view>
</view>
<view class="form-item">
<view class="form-label-box"><text class="form-label">状态:</text></view>
<view class="form-input-box">
<StatusSwitch v-model="form.is_active" />
</view>
</view>
</view>
</scroll-view>
<view class="drawer-footer">
<button class="btn-footer-cancel" @click="closeDrawer">取消</button>
<button class="btn-footer-submit" @click="saveCate">确定</button>
</view>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, reactive, onMounted } from 'vue'
import supa from '@/components/supadb/aksupainstance.uts'
import StatusSwitch from '@/components/StatusSwitch.uvue'
interface CateItem {
id: string; // Updated to match UUID string type per DB documentation
name: string;
slug?: string;
icon_url: string;
description?: string;
sort_order: number;
is_active: boolean;
level: number;
path: string[];
expanded?: boolean;
children?: CateItem[];
parent_id?: string; // Updated to match UUID string type per DB documentation
}
const list = ref<CateItem[]>([])
const allCategoriesFlat = ref<CateItem[]>([])
const showDrawerMask = ref(false)
const showDrawer = ref(false)
const isEdit = ref(false)
const currentEditId = ref('')
const form = reactive({
name: '',
slug: '',
parentName: '',
parent_id: '' as string | null,
parentLevel: 0,
parentPath: [] as string[],
description: '',
icon_url: '',
sort_order: 0,
is_active: true
})
const parentOptions = ref<{id: string, name: string, level: number, path: string[]}[]>([])
const parentOptionsIndex = ref(0)
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
}
if (!Array.isArray(res.data)) {
list.value = []
return
}
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()
}
}
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
currentEditId.value = item.id
form.name = item.name
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
currentEditId.value = ''
form.name = ''
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(() => {
showDrawerMask.value = false
}, 300)
}
async function saveCate() {
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 {
currentPath = [form.name]
}
// 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) {
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' })
}
}
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' })
loadData()
}
uni.hideLoading()
}
}
})
}
</script>
<style scoped lang="scss">
.admin-main {
padding: 0;
background-color: transparent;
min-height: auto;
}
.search-card {
background-color: #fff;
padding: 24px;
border-radius: 4px;
margin-bottom: 20px;
}
.search-row {
display: flex;
flex-direction: row;
align-items: center;
}
.search-item {
display: flex;
flex-direction: row;
align-items: center;
margin-right: 24px;
}
.search-label {
font-size: 14px;
color: #333;
margin-right: 8px;
}
.search-input {
width: 200px;
height: 32px;
border: 1px solid #dcdfe6;
border-radius: 4px;
padding: 0 12px;
font-size: 14px;
}
.mock-select {
width: 150px;
height: 32px;
border: 1px solid #dcdfe6;
border-radius: 4px;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 0 12px;
background-color: #fff;
}
.arrow-down {
font-size: 10px;
color: #c0c4cc;
}
.btn-query {
background-color: #1890ff;
color: #fff;
height: 32px;
line-height: 32px;
padding: 0 16px;
border-radius: 4px;
font-size: 14px;
border: none;
margin-right: 12px;
}
.btn-reset {
background-color: #fff;
border: 1px solid #dcdfe6;
color: #666;
height: 32px;
line-height: 32px;
padding: 0 16px;
border-radius: 4px;
font-size: 14px;
margin-right: 0;
}
.table-card {
background-color: #fff;
padding: 24px;
border-radius: 4px;
}
.table-toolbar {
margin-bottom: 20px;
}
.btn-add {
background-color: #1890ff;
color: #fff;
height: 32px;
line-height: 32px;
padding: 0 16px;
border-radius: 4px;
font-size: 14px;
border: none;
margin: 0;
}
.table-header {
display: flex;
flex-direction: row;
background-color: #fafafa;
height: 44px;
align-items: center;
border: 1px solid #f0f0f0;
}
.th-cell {
font-size: 14px;
font-weight: bold;
color: #333;
padding: 0 12px;
}
.table-row-group {
border-bottom: 1px solid #f0f0f0;
}
.table-row {
display: flex;
flex-direction: row;
height: 60px;
align-items: center;
border-left: 1px solid #f0f0f0;
border-right: 1px solid #f0f0f0;
}
.sub-row {
background-color: #fafafa;
}
.td-cell {
padding: 0 12px;
font-size: 14px;
color: #666;
}
.color-9 {
color: #999;
}
.pl-20 {
padding-left: 30px;
}
.child-line {
color: #ccc;
margin-right: 5px;
}
.cate-icon {
width: 40px;
height: 40px;
border-radius: 4px;
}
.expand-icon {
font-size: 10px;
color: #999;
margin-right: 8px;
}
.btn-link {
font-size: 14px;
color: #1890ff;
cursor: pointer;
}
.btn-link.delete {
color: #ff4d4f;
}
.divider {
width: 1px;
height: 14px;
background-color: #f0f0f0;
margin: 0 12px;
}
.text-center {
text-align: center;
}
.row-layout {
display: flex;
flex-direction: row;
align-items: center;
}
.row-center {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
/* Drawer styles */
.drawer-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1000;
background-color: rgba(0,0,0,0.5);
}
.drawer-content {
position: absolute;
right: 0;
top: 0;
bottom: 0;
width: 50%;
background-color: #fff;
transform: translateX(100%);
transition: transform 0.3s ease-in-out;
display: flex;
flex-direction: column;
}
.drawer-show {
transform: translateX(0);
}
.drawer-header {
padding: 20px 24px;
border-bottom: 1px solid #f0f0f0;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.drawer-title {
font-size: 16px;
font-weight: bold;
}
.drawer-close {
font-size: 20px;
color: #999;
cursor: pointer;
}
.drawer-body {
flex: 1;
padding: 24px;
}
.drawer-form {
display: flex;
flex-direction: column;
}
.form-item {
display: flex;
flex-direction: row;
align-items: center;
margin-bottom: 24px;
}
.align-start {
align-items: flex-start;
}
.form-label-box {
width: 100px;
text-align: right;
margin-right: 16px;
}
.form-label {
font-size: 14px;
color: #606266;
}
.required::before {
content: '*';
color: #ff4d4f;
margin-right: 4px;
}
.form-input-box {
flex: 1;
}
.drawer-input {
width: 100%;
height: 36px;
border: 1px solid #dcdfe6;
border-radius: 4px;
padding: 0 12px;
font-size: 14px;
}
.mock-select-full {
width: 100%;
height: 36px;
border: 1px solid #dcdfe6;
border-radius: 4px;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 0 12px;
background-color: #fff;
}
.upload-box {
width: 100px;
height: 100px;
border: 1px dashed #dcdfe6;
border-radius: 4px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
cursor: pointer;
}
.plus {
font-size: 24px;
color: #909399;
}
.upload-text {
font-size: 12px;
color: #909399;
}
.form-tip {
font-size: 12px;
color: #909399;
margin-top: 8px;
}
.drawer-footer {
padding: 16px 24px;
border-top: 1px solid #f0f0f0;
display: flex;
flex-direction: row;
justify-content: flex-end;
}
.btn-footer-cancel,
.btn-footer-submit {
height: 32px;
line-height: 32px;
padding: 0 20px;
border-radius: 4px;
font-size: 14px;
margin-left: 12px;
}
.btn-footer-cancel {
background-color: #fff;
border: 1px solid #dcdfe6;
color: #606266;
}
.btn-footer-submit {
background-color: #1890ff;
color: #fff;
border: none;
}
.flex-1 { flex: 1; }
.flex-2 { flex: 2; }
.flex-3 { flex: 3; }
</style>