完成店铺创建

This commit is contained in:
2026-03-13 16:32:37 +08:00
parent 3617a6a086
commit b180aeabd8
7 changed files with 1713 additions and 40 deletions

View File

@@ -18,6 +18,10 @@ import PlaceholderPage from '@/layouts/admin/components/PlaceholderPage.uvue'
import HomeIndex from '@/layouts/admin/pages/HomeIndex.uvue'
import UserCenter from '@/pages/mall/admin/userCenter/index.uvue'
// --- 店铺模块 ---
import ShopManage from '@/pages/mall/admin/shop/manage.uvue'
import ShopCreate from '@/pages/mall/admin/shop/create.uvue'
// --- 用户模块 ---
import UserStatistic from '@/pages/mall/admin/user/statistics/index.uvue'
import UserList from '@/pages/mall/admin/user/management/index.uvue'
@@ -180,8 +184,12 @@ import MaintainSysInfo from '@/pages/mall/admin/maintain/sys/info.uvue'
export const componentMap: Map<string, any> = new Map([
// 首页
['HomeIndex', HomeIndex],
['UserCenter', UserCenter],
['UserCenter', UserCenter],
// 店铺模块
['ShopManage', ShopManage],
['ShopCreate', ShopCreate],
// 用户模块
['UserStatistic', UserStatistic],
['UserList', UserList],

View File

@@ -67,12 +67,22 @@ export const topMenus: TopMenu[] = [
order: 1,
groups: []
},
{
id: 'shop',
title: '店铺',
icon: 'shop',
path: '/pages/mall/admin/shop/manage',
order: 2,
groups: [
{ id: 'shop-manage', title: '', order: 1 }
]
},
{
id: 'user',
title: '用户',
icon: 'user',
path: '/pages/mall/admin/user/management/index',
order: 2,
order: 3,
groups: [
{ id: 'user-manage', title: '', order: 1 }
]
@@ -233,6 +243,27 @@ export const routes: RouteRecord[] = [
order: 1
},
// ========== 店铺模块 ==========
{
id: 'shop_manage',
title: '店铺管理',
path: '/pages/mall/admin/shop/manage',
componentKey: 'ShopManage',
parentId: 'shop',
groupId: 'shop-manage',
order: 1
},
{
id: 'shop_create',
title: '申请入驻',
path: '/pages/mall/admin/shop/create',
componentKey: 'ShopCreate',
parentId: 'shop',
groupId: 'shop-manage',
hidden: true,
order: 2
},
// ========== 用户模块 ==========
// ========== 个人中心 ==========
{

View File

@@ -98,11 +98,11 @@ export async function refreshAdminRole(): Promise<string> {
export function getVisibleTopMenuIds(role: string): string[] {
const normRole = normalizeRole(role)
if (normRole === 'admin') {
return ['home', 'user', 'order', 'product', 'marketing', 'distribution', 'kefu', 'finance', 'cms', 'decoration', 'app', 'setting', 'maintain']
return ['home', 'shop', 'user', 'order', 'product', 'marketing', 'distribution', 'kefu', 'finance', 'cms', 'decoration', 'app', 'setting', 'maintain']
}
if (normRole === 'merchant') {
return ['home', 'order', 'product', 'marketing', 'finance']
return ['home', 'shop', 'order', 'product', 'marketing', 'finance']
}
return ['home']
@@ -123,7 +123,7 @@ export function hasAdminModuleAccess(moduleId: string | undefined): boolean {
}
if (normRole === 'merchant') {
const allowed = ['home', 'order', 'product', 'marketing', 'finance']
const allowed = ['home', 'shop', 'order', 'product', 'marketing', 'finance']
return allowed.includes(moduleId)
}

View File

@@ -160,10 +160,19 @@
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: '',
@@ -181,9 +190,6 @@ const formData = ref({
published_at: null as string | null
})
const categories = ref(['361度', '特步', '匹克', '生活家居'])
const categoryName = ref('')
onMounted(async () => {
await ensureSupabaseReady()
const mId = supa.getSession().user?.id as string | null
@@ -193,6 +199,9 @@ onMounted(async () => {
}
formData.value.merchant_id = mId
// 加载真实分类
await loadCategoryOptions()
const editId = uni.getStorageSync('edit_product_id') as string | null
if (editId) {
uni.removeStorageSync('edit_product_id')
@@ -200,6 +209,27 @@ onMounted(async () => {
}
})
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 { data, error } = await supa
@@ -228,8 +258,11 @@ async function fetchProductDetail(id: string, mId: string) {
}
// Try to map category
formData.value.category_id = data.category_id || ''
categoryName.value = data.category_id ? '已绑定分类' : ''
formData.value.category_id = data.category_id as string || ''
if (formData.value.category_id) {
const cat = categoryOptions.value.find((c: CategoryOption): boolean => c.id === formData.value.category_id)
categoryName.value = cat ? cat.name : '未知分类'
}
}
} catch (e) {
console.error('获取详情失败', e)
@@ -239,9 +272,9 @@ async function fetchProductDetail(id: string, mId: string) {
function onCategoryChange(e: any) {
const v = e.detail.value as number
categoryName.value = categories.value[v]
// In a real project, this maps to an actual category ID, but for now we use a mock one
formData.value.category_id = `cat_${v}`
const selected = categoryOptions.value[v]
categoryName.value = selected.name
formData.value.category_id = selected.id
}
function addTag() {
@@ -305,16 +338,13 @@ async function uploadToSupabase(filePath: string): Promise<string> {
uni.showLoading({ title: '上传中...' })
try {
const { data, error } = await supa.storage.from('zhipao').upload(remotePath, filePath, {})
if (error) {
throw error
const res = await supa.storage.from('zhipao').upload(remotePath, filePath, {})
if (res.error != null) {
throw res.error
}
const urlKey = typeof data === 'object' ? (data as any)['Key'] || (data as any)['path'] : ''
// fallback logic, generally Supabase uses 'storage/v1/object/public/bucket/' + path
if (urlKey) {
return `https://ak3.oulog.com/storage/v1/object/public/${urlKey}`
}
return ''
return `${SUPA_URL}/storage/v1/object/public/zhipao/${remotePath}`
} catch (e: any) {
console.error('上传文件失败:', e)
throw new Error(e.message || '上传异常')

View File

@@ -1,5 +1,19 @@
<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">
@@ -158,15 +172,19 @@
</view>
</view>
</view>
</template>
</view>
</template>
<script setup lang="uts">
import { ref, onMounted, onUnmounted } from 'vue'
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { openRoute } from '@/layouts/admin/store/adminNavStore.uts'
import StatusSwitch from '@/components/StatusSwitch.uvue'
import supa, { ensureSupabaseReady } from '@/components/supadb/aksupainstance'
const hasShop = ref(false)
const shopLoading = ref(true)
const total = ref(0)
const activeStatus = ref('selling')
const activeDropdownId = ref<number | null>(null)
@@ -181,13 +199,64 @@ const statusTabs = ref([
const productList = ref<any[]>([])
onMounted(() => {
// 监听 activeStatus 变化
watch(activeStatus, () => {
fetchProducts()
})
// 商品模块店铺门禁
onMounted(async () => {
await checkShop()
uni.$on('REFRESH_PRODUCT_LIST', () => {
fetchProducts()
})
})
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 goCreateShop() {
openRoute('shop_manage')
}
onUnmounted(() => {
uni.$off('REFRESH_PRODUCT_LIST')
})
@@ -196,8 +265,8 @@ onUnmounted(() => {
async function fetchProducts() {
await ensureSupabaseReady()
// 从本地缓存获取 current merchant_id
const currentMerchantId = supa.getSession().user?.id as string | null
// merchant_id 来自 ml_shops 所关联的 ak_users.id即 auth user id
const currentMerchantId = supa.getSession().user?.getString('id')
if (!currentMerchantId) {
uni.showToast({ title: '未获取到商家信息,请重新登录', icon: 'none' })
@@ -205,37 +274,62 @@ async function fetchProducts() {
}
try {
const { data, error, count } = await supa
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)
.order('created_at', { ascending: false })
// 根据 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) {
productList.value = data.map((item: any) => {
if (data != null) {
const dataArray = data as Array<UTSJSONObject>
productList.value = dataArray.map((item: UTSJSONObject): any => {
return {
id: item.id,
image: item.main_image_url || '',
name: item.name || '未命名商品',
id: item.get('id'),
image: item.get('main_image_url') || '',
name: item.get('name') || '未命名商品',
activities: [],
typeName: '普通商品',
price: item.base_price !== null ? Number(item.base_price).toFixed(2) : '0.00',
price: item.get('base_price') != null ? Number(item.get('base_price')).toFixed(2) : '0.00',
sales: 0,
stock: item.available_stock || 0,
stock: item.get('available_stock') || 0,
sort: 0,
status: item.status || 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
}
})
total.value = count || data.length
}
} catch (err) {
} catch (err: any) {
console.error('获取商品列表失败:', err)
uni.showToast({ title: '加载失败', icon: 'none' })
uni.showToast({ title: '加载失败: ' + (err.message || ''), icon: 'none' })
}
}
@@ -283,6 +377,41 @@ function moveToRecycle(id: number) {
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: var(--admin-card-padding);
@@ -590,3 +719,5 @@ function moveToRecycle(id: number) {
}
</style>

View File

@@ -0,0 +1,613 @@
<!-- 创建店铺页(入驻申请) -->
<template>
<view class="shop-create-page">
<view class="page-header">
<text class="page-title">申请入驻</text>
<text class="page-subtitle">填写以下信息提交店铺申请</text>
</view>
<!-- 店铺类型 -->
<view class="form-card">
<view class="card-title"><text class="required">*</text><text>店铺类型</text></view>
<view class="shop-type-list">
<view
v-for="(item, index) in shopTypes"
:key="index"
class="shop-type-item"
:class="{ selected: shopType === item.value }"
@click="shopType = item.value"
>
<text class="type-icon">{{ item.icon }}</text>
<text class="type-name">{{ item.label }}</text>
<text class="type-desc">{{ item.desc }}</text>
<view v-if="shopType === item.value" class="check-mark"><text>✓</text></view>
</view>
</view>
</view>
<!-- 基础信息 -->
<view class="form-card">
<view class="card-title">基础信息</view>
<view class="form-item">
<view class="form-label"><text class="required">*</text><text>店铺名称</text></view>
<input class="form-input" v-model="shopName" placeholder="请输入店铺名称2-50字" maxlength="50" />
</view>
<view class="form-item">
<view class="form-label">店铺 Logo</view>
<view class="upload-area" @click="pickLogoImage">
<image v-if="shopLogoLocal" :src="shopLogoLocal" class="preview-img" mode="aspectFill" />
<view v-else class="upload-placeholder">
<text class="upload-icon">+</text>
<text class="upload-txt">上传 Logo</text>
</view>
</view>
<text class="form-tip">建议尺寸 200×200支持 jpg/png不超过 2MB</text>
</view>
<view class="form-item">
<view class="form-label">店铺简介</view>
<textarea
class="form-textarea"
v-model="description"
placeholder="请简要介绍店铺不超过200字"
maxlength="200"
></textarea>
</view>
</view>
<!-- 联系方式 -->
<view class="form-card">
<view class="card-title">联系方式</view>
<view class="form-item">
<view class="form-label">联系人</view>
<input class="form-input" v-model="contactName" placeholder="请输入联系人姓名" maxlength="50" />
</view>
<view class="form-item">
<view class="form-label">联系电话</view>
<input class="form-input" v-model="contactPhone" placeholder="请输入手机号" maxlength="20" type="number" />
</view>
</view>
<!-- 资质信息 -->
<view class="form-card">
<view class="card-title">资质信息</view>
<view class="form-item">
<view class="form-label">营业执照号</view>
<input
class="form-input"
v-model="businessLicense"
placeholder="请输入营业执照注册号(个人店可不填)"
maxlength="50"
/>
</view>
<view class="form-item">
<view class="form-label">身份证正面</view>
<view class="upload-area" @click="pickIdCardFront">
<image v-if="idCardFrontLocal" :src="idCardFrontLocal" class="preview-img cert-img" mode="aspectFill" />
<view v-else class="upload-placeholder">
<text class="upload-icon">+</text>
<text class="upload-txt">上传身份证正面</text>
</view>
</view>
</view>
<view class="form-item">
<view class="form-label">身份证反面</view>
<view class="upload-area" @click="pickIdCardBack">
<image v-if="idCardBackLocal" :src="idCardBackLocal" class="preview-img cert-img" mode="aspectFill" />
<view v-else class="upload-placeholder">
<text class="upload-icon">+</text>
<text class="upload-txt">上传身份证反面</text>
</view>
</view>
</view>
<view
class="form-item"
v-if="shopType !== 'personal'"
>
<view class="form-label">营业执照图片</view>
<view class="upload-area" @click="pickLicenseImage">
<image v-if="licenseImageLocal" :src="licenseImageLocal" class="preview-img cert-img" mode="aspectFill" />
<view v-else class="upload-placeholder">
<text class="upload-icon">+</text>
<text class="upload-txt">上传营业执照</text>
</view>
</view>
</view>
<view
class="form-item"
v-if="shopType === 'flagship' || shopType === 'exclusive' || shopType === 'specialty'"
>
<view class="form-label">品牌授权书</view>
<view class="upload-area" @click="pickBrandAuth">
<image v-if="brandAuthLocal" :src="brandAuthLocal" class="preview-img cert-img" mode="aspectFill" />
<view v-else class="upload-placeholder">
<text class="upload-icon">+</text>
<text class="upload-txt">上传品牌授权书</text>
</view>
</view>
<text class="form-tip">旗舰店/专卖店/专营店必须提供品牌授权书</text>
</view>
</view>
<!-- 协议勾选 -->
<view class="form-card agreement-card">
<view class="agreement-row" @click="agreed = !agreed">
<view class="checkbox" :class="{ checked: agreed }">
<text v-if="agreed" class="check-icon">✓</text>
</view>
<text class="agreement-text">
我已阅读并同意
<text class="agreement-link">《平台合作协议》</text>
<text class="agreement-link">《商家入驻规范》</text>
</text>
</view>
</view>
<!-- 提交按钮 -->
<view class="submit-area">
<button
class="btn-submit"
:class="{ disabled: submitting }"
:disabled="submitting"
@click="handleSubmit"
>
<text>{{ submitting ? '提交中...' : '提交申请' }}</text>
</button>
</view>
</view>
</template>
<script setup lang="uts">
import { ref } from 'vue'
import supa from '@/components/supadb/aksupainstance.uts'
import { SUPA_URL } from '@/ak/config.uts'
import { openRoute } from '@/layouts/admin/store/adminNavStore.uts'
// ===== 表单字段 =====
const shopType = ref('personal')
const shopName = ref('')
const shopLogoLocal = ref('')
const description = ref('')
const contactName = ref('')
const contactPhone = ref('')
const businessLicense = ref('')
const idCardFrontLocal = ref('')
const idCardBackLocal = ref('')
const licenseImageLocal = ref('')
const brandAuthLocal = ref('')
const agreed = ref(false)
const submitting = ref(false)
// ===== 店铺类型配置 =====
const shopTypes = [
{ value: 'personal', label: '个人店', icon: '👤', desc: '个人经营,无需工商注册' },
{ value: 'individual', label: '个体工商户', icon: '🏪', desc: '持有个体工商营业执照' },
{ value: 'enterprise', label: '普通企业店', icon: '🏢', desc: '持有企业营业执照' },
{ value: 'flagship', label: '旗舰店', icon: '🏆', desc: '品牌官方旗舰,需品牌授权' },
{ value: 'exclusive', label: '专卖店', icon: '⭐', desc: '单一品牌授权经营' },
{ value: 'specialty', label: '专营店', icon: '🎯', desc: '多品牌品类授权经营' },
]
// ===== 图片选择 =====
function pickLogoImage() {
uni.chooseImage({
count: 1,
success: (res) => {
const paths = res.tempFilePaths as string[]
if (paths.length > 0) shopLogoLocal.value = paths[0]
}
})
}
function pickIdCardFront() {
uni.chooseImage({
count: 1,
success: (res) => {
const paths = res.tempFilePaths as string[]
if (paths.length > 0) idCardFrontLocal.value = paths[0]
}
})
}
function pickIdCardBack() {
uni.chooseImage({
count: 1,
success: (res) => {
const paths = res.tempFilePaths as string[]
if (paths.length > 0) idCardBackLocal.value = paths[0]
}
})
}
function pickLicenseImage() {
uni.chooseImage({
count: 1,
success: (res) => {
const paths = res.tempFilePaths as string[]
if (paths.length > 0) licenseImageLocal.value = paths[0]
}
})
}
function pickBrandAuth() {
uni.chooseImage({
count: 1,
success: (res) => {
const paths = res.tempFilePaths as string[]
if (paths.length > 0) brandAuthLocal.value = paths[0]
}
})
}
// ===== 上传单张图片到 Supabase storage =====
async function uploadImage(localPath: string, folder: string): Promise<string> {
if (!localPath) return ''
if (localPath.startsWith('http') && !localPath.startsWith('blob:')) return localPath
const extMatch = localPath.match(/\.(\w+)$/)
const ext = extMatch ? extMatch[1] : 'jpg'
const uuid = Math.random().toString(36).substring(2, 10)
const remotePath = `shop/${folder}_${Date.now()}_${uuid}.${ext}`
const res = await supa.storage.from('zhipao').upload(remotePath, localPath, {})
if (res.error != null) throw new Error('图片上传失败')
return `${SUPA_URL}/storage/v1/object/public/zhipao/${remotePath}`
}
// ===== 表单校验 =====
function validate(): boolean {
if (!shopName.value.trim()) {
uni.showToast({ title: '请输入店铺名称', icon: 'none' })
return false
}
if (shopName.value.trim().length < 2) {
uni.showToast({ title: '店铺名称至少2个字符', icon: 'none' })
return false
}
if (!agreed.value) {
uni.showToast({ title: '请阅读并同意平台协议', icon: 'none' })
return false
}
return true
}
// ===== 提交 =====
async function handleSubmit() {
if (!validate()) return
const userId = supa.getSession().user?.getString('id')
if (!userId) {
uni.showToast({ title: '未获取到登录信息,请重新登录', icon: 'none' })
return
}
submitting.value = true
uni.showLoading({ title: '提交中...' })
try {
// 1. 上传所有图片,拿到正式 URL
const [shopLogoUrl, idCardFrontUrl, idCardBackUrl, licenseImageUrl, brandAuthUrl] =
await Promise.all([
shopLogoLocal.value ? uploadImage(shopLogoLocal.value, 'logo') : Promise.resolve(''),
idCardFrontLocal.value ? uploadImage(idCardFrontLocal.value, 'id_front') : Promise.resolve(''),
idCardBackLocal.value ? uploadImage(idCardBackLocal.value, 'id_back') : Promise.resolve(''),
licenseImageLocal.value ? uploadImage(licenseImageLocal.value, 'license') : Promise.resolve(''),
brandAuthLocal.value ? uploadImage(brandAuthLocal.value, 'brand_auth') : Promise.resolve(''),
])
// 2. 写入 ml_shops仅写表中真实存在的字段
const shopPayload = {
merchant_id: userId,
shop_name: shopName.value.trim(),
status: 1
} as UTSJSONObject
if (shopLogoUrl) shopPayload['shop_logo'] = shopLogoUrl
if (description.value.trim()) shopPayload['description'] = description.value.trim()
if (businessLicense.value.trim()) shopPayload['business_license'] = businessLicense.value.trim()
if (contactName.value.trim()) shopPayload['contact_name'] = contactName.value.trim()
if (contactPhone.value.trim()) shopPayload['contact_phone'] = contactPhone.value.trim()
const shopRes = await supa.from('ml_shops').insert(shopPayload).execute()
if (shopRes.error != null) {
console.error('[ShopCreate] 创建店铺失败:', shopRes.error)
const errMsg = shopRes.error?.getString ? shopRes.error.getString('message') ?? '创建失败' : '创建失败'
uni.showToast({ title: '创建失败:' + errMsg, icon: 'none' })
return
}
if (!shopRes.data) {
uni.showToast({ title: '未收到服务器返回数据,请重试', icon: 'none' })
return
}
// 3. 确认拿到 merchant_id来自 ml_shops 返回)
const dataArr = shopRes.data as Array<UTSJSONObject>
let createdMerchantId = ''
if (dataArr.length > 0) {
const row = dataArr[0] as UTSJSONObject
createdMerchantId = row.getString('merchant_id') ?? userId
console.log('[ShopCreate] 店铺创建成功merchant_id:', createdMerchantId)
}
// 4. 写入扩展资质到 ml_user_profiles.verification_dataJSONB
const extData = {
shop_type: shopType.value,
id_card_front: idCardFrontUrl,
id_card_back: idCardBackUrl,
license_image: licenseImageUrl,
brand_authorization: brandAuthUrl,
updated_at: new Date().toISOString()
} as UTSJSONObject
// 先查询是否已存在该用户的 profile
const existRes = await supa.from('ml_user_profiles')
.select('id')
.eq('user_id', userId)
.single()
.execute()
const profileExists = existRes.data != null && !existRes.error
if (profileExists) {
await supa.from('ml_user_profiles')
.update({ verification_data: extData } as UTSJSONObject)
.eq('user_id', userId)
.execute()
} else {
await supa.from('ml_user_profiles')
.insert({ user_id: userId, verification_data: extData } as UTSJSONObject)
.execute()
}
uni.showToast({ title: '申请已提交', icon: 'success' })
setTimeout(() => {
openRoute('shop_manage')
}, 1200)
} catch (e: any) {
console.error('[ShopCreate] 异常:', e)
uni.showToast({ title: e?.message ?? '提交失败,请重试', icon: 'none' })
} finally {
submitting.value = false
uni.hideLoading()
}
}
</script>
<style scoped lang="scss">
.shop-create-page {
padding: 0;
background: transparent;
}
.page-header {
margin-bottom: 20px;
}
.page-title {
display: block;
font-size: 22px;
font-weight: 600;
color: #333;
margin-bottom: 6px;
}
.page-subtitle {
display: block;
font-size: 13px;
color: #999;
}
.form-card {
background: #fff;
border-radius: 4px;
padding: 20px 24px;
margin-bottom: 16px;
}
.card-title {
font-size: 15px;
font-weight: 600;
color: #333;
margin-bottom: 16px;
display: flex;
flex-direction: row;
align-items: center;
gap: 2px;
}
.required {
color: #ff4d4f;
margin-right: 4px;
}
/* 店铺类型网格 */
.shop-type-list {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 12px;
}
.shop-type-item {
position: relative;
width: 140px;
border: 1px solid #dcdfe6;
border-radius: 6px;
padding: 14px 12px;
cursor: pointer;
transition: all 0.2s;
display: flex;
flex-direction: column;
align-items: center;
}
.shop-type-item.selected {
border-color: #1890ff;
background: #e6f4ff;
}
.type-icon {
font-size: 24px;
margin-bottom: 6px;
}
.type-name {
font-size: 14px;
font-weight: 600;
color: #333;
margin-bottom: 4px;
}
.type-desc {
font-size: 11px;
color: #999;
text-align: center;
}
.check-mark {
position: absolute;
top: 6px;
right: 6px;
width: 18px;
height: 18px;
background: #1890ff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
text { font-size: 11px; color: #fff; }
}
/* 表单项 */
.form-item {
margin-bottom: 18px;
&:last-child { margin-bottom: 0; }
}
.form-label {
font-size: 13px;
color: #606266;
margin-bottom: 8px;
display: flex;
flex-direction: row;
align-items: center;
gap: 2px;
}
.form-input {
width: 100%;
height: 36px;
border: 1px solid #dcdfe6;
border-radius: 4px;
padding: 0 12px;
font-size: 13px;
color: #333;
background: #fff;
box-sizing: border-box;
}
.form-textarea {
width: 100%;
height: 90px;
border: 1px solid #dcdfe6;
border-radius: 4px;
padding: 8px 12px;
font-size: 13px;
color: #333;
background: #fff;
box-sizing: border-box;
resize: none;
}
.form-tip {
display: block;
font-size: 11px;
color: #aaa;
margin-top: 4px;
}
/* 上传区域 */
.upload-area {
width: 120px;
height: 90px;
border: 1px dashed #dcdfe6;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
overflow: hidden;
&:hover { border-color: #1890ff; }
}
.upload-placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.upload-icon {
font-size: 22px;
color: #c0c4cc;
}
.upload-txt {
font-size: 11px;
color: #aaa;
}
.preview-img {
width: 100%;
height: 100%;
}
.cert-img {
width: 200px;
height: 120px;
}
/* 协议 */
.agreement-card { padding: 16px 24px; }
.agreement-row {
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
cursor: pointer;
}
.checkbox {
width: 16px;
height: 16px;
border: 1px solid #dcdfe6;
border-radius: 2px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.checkbox.checked {
background: #1890ff;
border-color: #1890ff;
}
.check-icon { font-size: 11px; color: #fff; }
.agreement-text { font-size: 13px; color: #606266; }
.agreement-link { color: #1890ff; }
/* 提交 */
.submit-area {
padding: 20px 0;
}
.btn-submit {
width: 100%;
height: 44px;
background: #1890ff;
color: #fff;
border-radius: 4px;
font-size: 15px;
font-weight: 500;
border: none;
display: flex;
align-items: center;
justify-content: center;
}
.btn-submit.disabled {
background: #a0cfff;
cursor: not-allowed;
}
</style>

View File

@@ -0,0 +1,860 @@
<!-- 店铺管理页:查看 / 编辑店铺信息 -->
<template>
<view class="shop-manage-page">
<!-- 加载中 -->
<view v-if="pageLoading" class="loading-wrap">
<text class="loading-txt">加载中...</text>
</view>
<!-- 无店铺:引导空态 -->
<template v-else-if="!hasShop">
<view class="empty-wrap">
<text class="empty-icon">🏪</text>
<text class="empty-title">您还没有创建店铺</text>
<text class="empty-desc">注册店铺后即可发布商品、管理订单</text>
<button class="btn-create" @click="goToCreate">立即创建店铺</button>
</view>
</template>
<!-- 有店铺:查看 / 编辑 -->
<template v-else>
<!-- 标题行 -->
<view class="page-header">
<text class="page-title">店铺管理</text>
<view class="header-actions">
<button v-if="!isEditing" class="btn-edit" @click="enterEdit">编辑信息</button>
<view v-else class="edit-btns">
<button class="btn-cancel" @click="cancelEdit">取消</button>
<button class="btn-save" :class="{ disabled: saving }" :disabled="saving" @click="saveShop">
{{ saving ? '保存中...' : '保存' }}
</button>
</view>
</view>
</view>
<!-- 店铺基础信息卡 -->
<view class="form-card">
<view class="card-title">基础信息</view>
<!-- Logo -->
<view class="form-item">
<view class="form-label">店铺 Logo</view>
<view v-if="!isEditing" class="view-mode">
<image
v-if="form.shop_logo"
:src="form.shop_logo"
class="preview-logo"
mode="aspectFill"
/>
<text v-else class="empty-val">未设置</text>
</view>
<view v-else>
<view class="upload-area" @click="pickLogoImage">
<image v-if="form.shop_logo" :src="form.shop_logo" class="preview-logo upload-preview" mode="aspectFill" />
<view v-else class="upload-placeholder">
<text class="upload-icon">+</text>
<text class="upload-txt">上传 Logo</text>
</view>
</view>
</view>
</view>
<!-- 店铺名称 -->
<view class="form-item">
<view class="form-label"><text class="required" v-if="isEditing">*</text><text>店铺名称</text></view>
<text v-if="!isEditing" class="view-val">{{ form.shop_name || '-' }}</text>
<input v-else class="form-input" v-model="form.shop_name" placeholder="请输入店铺名称" maxlength="50" />
</view>
<!-- 店铺简介 -->
<view class="form-item">
<view class="form-label">店铺简介</view>
<text v-if="!isEditing" class="view-val multiline">{{ form.description || '-' }}</text>
<textarea v-else class="form-textarea" v-model="form.description" placeholder="请输入店铺简介" maxlength="200"></textarea>
</view>
</view>
<!-- 联系方式卡 -->
<view class="form-card">
<view class="card-title">联系方式</view>
<view class="form-item">
<view class="form-label">联系人</view>
<text v-if="!isEditing" class="view-val">{{ form.contact_name || '-' }}</text>
<input v-else class="form-input" v-model="form.contact_name" placeholder="请输入联系人" maxlength="50" />
</view>
<view class="form-item">
<view class="form-label">联系电话</view>
<text v-if="!isEditing" class="view-val">{{ form.contact_phone || '-' }}</text>
<input v-else class="form-input" v-model="form.contact_phone" placeholder="请输入联系电话" maxlength="20" type="number" />
</view>
<view class="form-item">
<view class="form-label">联系邮箱</view>
<text v-if="!isEditing" class="view-val">{{ form.contact_email || '-' }}</text>
<input v-else class="form-input" v-model="form.contact_email" placeholder="选填" maxlength="100" />
</view>
</view>
<!-- 资质信息卡 -->
<view class="form-card">
<view class="card-title">资质信息</view>
<view class="form-item">
<view class="form-label">营业执照号</view>
<text v-if="!isEditing" class="view-val">{{ form.business_license || '-' }}</text>
<input v-else class="form-input" v-model="form.business_license" placeholder="营业执照注册号" maxlength="50" />
</view>
<!-- 扩展资质(存在 verification_data 里) -->
<view class="form-item">
<view class="form-label">店铺类型</view>
<text v-if="!isEditing" class="view-val">{{ getShopTypeLabel(extData.shop_type) }}</text>
<picker v-else :range="shopTypeLabels" :value="shopTypeIndex" @change="onShopTypeChange">
<view class="picker-trigger">
<text>{{ getShopTypeLabel(extData.shop_type) }}</text>
<text class="picker-arrow">▼</text>
</view>
</picker>
</view>
<view class="form-item">
<view class="form-label">身份证正面</view>
<view v-if="!isEditing">
<image v-if="extData.id_card_front" :src="extData.id_card_front" class="cert-img" mode="aspectFill" />
<text v-else class="empty-val">未上传</text>
</view>
<view v-else class="upload-area-wide" @click="pickIdCardFront">
<image v-if="extData.id_card_front" :src="extData.id_card_front" class="cert-img" mode="aspectFill" />
<view v-else class="upload-placeholder">
<text class="upload-icon">+</text>
<text class="upload-txt">上传身份证正面</text>
</view>
</view>
</view>
<view class="form-item">
<view class="form-label">身份证反面</view>
<view v-if="!isEditing">
<image v-if="extData.id_card_back" :src="extData.id_card_back" class="cert-img" mode="aspectFill" />
<text v-else class="empty-val">未上传</text>
</view>
<view v-else class="upload-area-wide" @click="pickIdCardBack">
<image v-if="extData.id_card_back" :src="extData.id_card_back" class="cert-img" mode="aspectFill" />
<view v-else class="upload-placeholder">
<text class="upload-icon">+</text>
<text class="upload-txt">上传身份证反面</text>
</view>
</view>
</view>
<view class="form-item">
<view class="form-label">营业执照图片</view>
<view v-if="!isEditing">
<image v-if="extData.license_image" :src="extData.license_image" class="cert-img" mode="aspectFill" />
<text v-else class="empty-val">未上传</text>
</view>
<view v-else class="upload-area-wide" @click="pickLicenseImage">
<image v-if="extData.license_image" :src="extData.license_image" class="cert-img" mode="aspectFill" />
<view v-else class="upload-placeholder">
<text class="upload-icon">+</text>
<text class="upload-txt">上传营业执照</text>
</view>
</view>
</view>
<view class="form-item">
<view class="form-label">品牌授权书</view>
<view v-if="!isEditing">
<image v-if="extData.brand_authorization" :src="extData.brand_authorization" class="cert-img" mode="aspectFill" />
<text v-else class="empty-val">未上传</text>
</view>
<view v-else class="upload-area-wide" @click="pickBrandAuth">
<image v-if="extData.brand_authorization" :src="extData.brand_authorization" class="cert-img" mode="aspectFill" />
<view v-else class="upload-placeholder">
<text class="upload-icon">+</text>
<text class="upload-txt">上传品牌授权书</text>
</view>
</view>
</view>
</view>
<!-- 店铺状态信息(只读) -->
<view class="form-card info-card">
<view class="card-title">店铺状态</view>
<view class="info-row">
<text class="info-label">店铺ID</text>
<text class="info-val">{{ shopId }}</text>
</view>
<view class="info-row">
<text class="info-label">商家ID</text>
<text class="info-val">{{ merchantId }}</text>
</view>
<view class="info-row">
<text class="info-label">当前状态</text>
<text class="info-val" :class="form.status === 1 ? 'status-normal' : 'status-off'">
{{ form.status === 1 ? '正常营业' : form.status === 2 ? '暂停营业' : '已关闭' }}
</text>
</view>
<view class="info-row">
<text class="info-label">商品数量</text>
<text class="info-val">{{ form.product_count }}</text>
</view>
<view class="info-row">
<text class="info-label">创建时间</text>
<text class="info-val">{{ formatDate(form.created_at) }}</text>
</view>
</view>
</template>
</view>
</template>
<script setup lang="uts">
import { ref, onMounted } from 'vue'
import supa from '@/components/supadb/aksupainstance.uts'
import { SUPA_URL } from '@/ak/config.uts'
import { openRoute } from '@/layouts/admin/store/adminNavStore.uts'
// ===== 页面状态 =====
const pageLoading = ref(true)
const hasShop = ref(false)
const isEditing = ref(false)
const saving = ref(false)
// ===== 从 ml_shops 回读的字段 =====
const shopId = ref('')
const merchantId = ref('')
// ===== 店铺表单(绑定到对应的 ml_shops 字段)=====
const form = ref({
shop_name: '',
shop_logo: '',
description: '',
contact_name: '',
contact_phone: '',
contact_email: '',
business_license: '',
status: 1,
product_count: 0,
created_at: ''
})
// 编辑前的快照(用于取消时恢复)
const formSnapshot = ref({ ...form.value })
// ===== 扩展资质(存储在 ml_user_profiles.verification_data=====
const extData = ref({
shop_type: 'personal',
id_card_front: '',
id_card_back: '',
license_image: '',
brand_authorization: ''
})
const extSnapshot = ref({ ...extData.value })
// ===== 店铺类型配置 =====
const shopTypeOptions = [
{ value: 'personal', label: '个人店' },
{ value: 'individual', label: '个体工商户' },
{ value: 'enterprise', label: '普通企业店' },
{ value: 'flagship', label: '旗舰店' },
{ value: 'exclusive', label: '专卖店' },
{ value: 'specialty', label: '专营店' },
]
const shopTypeLabels = shopTypeOptions.map(o => o.label)
const shopTypeIndex = ref(0)
function getShopTypeLabel(val: string): string {
const found = shopTypeOptions.find(o => o.value === val)
return found ? found.label : '个人店'
}
function onShopTypeChange(e: any) {
const idx = e.detail.value as number
shopTypeIndex.value = idx
extData.value.shop_type = shopTypeOptions[idx].value
}
// ===== 加载当前店铺 =====
onMounted(async () => {
await loadShop()
})
async function loadShop() {
pageLoading.value = true
try {
const userId = supa.getSession().user?.getString('id')
if (!userId) {
pageLoading.value = false
return
}
// 根据 merchant_id 查询 ml_shopsmerchant_id = ak_users.id = auth user id
const shopRes = await supa.from('ml_shops')
.select('*')
.eq('merchant_id', userId)
.single()
.execute()
if (shopRes.error != null || !shopRes.data) {
hasShop.value = false
pageLoading.value = false
return
}
// 解析返回数据
const rawData = shopRes.data
let shopRow: UTSJSONObject | null = null
if (Array.isArray(rawData)) {
if ((rawData as Array<any>).length === 0) {
hasShop.value = false
pageLoading.value = false
return
}
shopRow = (rawData as Array<UTSJSONObject>)[0]
} else {
shopRow = rawData as UTSJSONObject
}
if (!shopRow) {
hasShop.value = false
pageLoading.value = false
return
}
hasShop.value = true
shopId.value = shopRow.getString('id') ?? ''
merchantId.value = shopRow.getString('merchant_id') ?? userId
form.value = {
shop_name: shopRow.getString('shop_name') ?? '',
shop_logo: shopRow.getString('shop_logo') ?? '',
description: shopRow.getString('description') ?? '',
contact_name: shopRow.getString('contact_name') ?? '',
contact_phone: shopRow.getString('contact_phone') ?? '',
contact_email: shopRow.getString('contact_email') ?? '',
business_license: shopRow.getString('business_license') ?? '',
status: shopRow.getNumber('status') ?? 1,
product_count: shopRow.getNumber('product_count') ?? 0,
created_at: shopRow.getString('created_at') ?? ''
}
formSnapshot.value = { ...form.value }
// 加载扩展资质
await loadExtData(userId)
} catch (e: any) {
console.error('[ShopManage] 加载店铺失败:', e)
hasShop.value = false
} finally {
pageLoading.value = false
}
}
async function loadExtData(userId: string) {
try {
const profileRes = await supa.from('ml_user_profiles')
.select('verification_data')
.eq('user_id', userId)
.single()
.execute()
if (profileRes.error != null || !profileRes.data) return
const rawData = profileRes.data
let profileRow: UTSJSONObject | null = null
if (Array.isArray(rawData)) {
if ((rawData as Array<any>).length > 0) {
profileRow = (rawData as Array<UTSJSONObject>)[0]
}
} else {
profileRow = rawData as UTSJSONObject
}
if (!profileRow) return
const vd = profileRow.getJSON('verification_data')
if (vd != null) {
extData.value = {
shop_type: vd.getString('shop_type') ?? 'personal',
id_card_front: vd.getString('id_card_front') ?? '',
id_card_back: vd.getString('id_card_back') ?? '',
license_image: vd.getString('license_image') ?? '',
brand_authorization: vd.getString('brand_authorization') ?? ''
}
// 同步 picker index
const idx = shopTypeOptions.findIndex(o => o.value === extData.value.shop_type)
shopTypeIndex.value = idx >= 0 ? idx : 0
}
extSnapshot.value = { ...extData.value }
} catch (e: any) {
console.warn('[ShopManage] 加载扩展资质失败:', e)
}
}
// ===== 编辑模式 =====
function enterEdit() {
formSnapshot.value = { ...form.value }
extSnapshot.value = { ...extData.value }
isEditing.value = true
}
function cancelEdit() {
form.value = { ...formSnapshot.value }
extData.value = { ...extSnapshot.value }
const idx = shopTypeOptions.findIndex(o => o.value === extData.value.shop_type)
shopTypeIndex.value = idx >= 0 ? idx : 0
isEditing.value = false
}
// ===== 图片选择 =====
function pickLogoImage() {
uni.chooseImage({
count: 1,
success: (res) => {
const paths = res.tempFilePaths as string[]
if (paths.length > 0) form.value.shop_logo = paths[0]
}
})
}
function pickIdCardFront() {
uni.chooseImage({
count: 1,
success: (res) => {
const paths = res.tempFilePaths as string[]
if (paths.length > 0) extData.value.id_card_front = paths[0]
}
})
}
function pickIdCardBack() {
uni.chooseImage({
count: 1,
success: (res) => {
const paths = res.tempFilePaths as string[]
if (paths.length > 0) extData.value.id_card_back = paths[0]
}
})
}
function pickLicenseImage() {
uni.chooseImage({
count: 1,
success: (res) => {
const paths = res.tempFilePaths as string[]
if (paths.length > 0) extData.value.license_image = paths[0]
}
})
}
function pickBrandAuth() {
uni.chooseImage({
count: 1,
success: (res) => {
const paths = res.tempFilePaths as string[]
if (paths.length > 0) extData.value.brand_authorization = paths[0]
}
})
}
// ===== 上传图片到 Supabase storage =====
async function uploadImage(localPath: string, folder: string): Promise<string> {
if (!localPath) return ''
if (localPath.startsWith('http') && !localPath.startsWith('blob:')) return localPath
const extMatch = localPath.match(/\.(\w+)$/)
const ext = extMatch ? extMatch[1] : 'jpg'
const uuid = Math.random().toString(36).substring(2, 10)
const remotePath = `shop/${folder}_${Date.now()}_${uuid}.${ext}`
const res = await supa.storage.from('zhipao').upload(remotePath, localPath, {})
if (res.error != null) throw new Error('图片上传失败')
return `${SUPA_URL}/storage/v1/object/public/zhipao/${remotePath}`
}
// ===== 保存 =====
async function saveShop() {
if (!form.value.shop_name.trim()) {
uni.showToast({ title: '请输入店铺名称', icon: 'none' })
return
}
const userId = supa.getSession().user?.getString('id')
if (!userId) {
uni.showToast({ title: '未获取到登录信息', icon: 'none' })
return
}
saving.value = true
uni.showLoading({ title: '保存中...' })
try {
// 1. 上传有变更的图片(本地路径需上传,已有 URL 直接复用)
const [logoUrl, idFrontUrl, idBackUrl, licenseUrl, brandUrl] = await Promise.all([
form.value.shop_logo ? uploadImage(form.value.shop_logo, 'logo') : Promise.resolve(''),
extData.value.id_card_front ? uploadImage(extData.value.id_card_front, 'id_front') : Promise.resolve(''),
extData.value.id_card_back ? uploadImage(extData.value.id_card_back, 'id_back') : Promise.resolve(''),
extData.value.license_image ? uploadImage(extData.value.license_image, 'license') : Promise.resolve(''),
extData.value.brand_authorization ? uploadImage(extData.value.brand_authorization, 'brand_auth') : Promise.resolve(''),
])
// 更新本地表单中的 URL去掉临时路径
if (logoUrl) form.value.shop_logo = logoUrl
if (idFrontUrl) extData.value.id_card_front = idFrontUrl
if (idBackUrl) extData.value.id_card_back = idBackUrl
if (licenseUrl) extData.value.license_image = licenseUrl
if (brandUrl) extData.value.brand_authorization = brandUrl
// 2. 更新 ml_shops
const shopUpdatePayload = {
shop_name: form.value.shop_name.trim(),
shop_logo: form.value.shop_logo,
description: form.value.description,
contact_name: form.value.contact_name,
contact_phone: form.value.contact_phone,
contact_email: form.value.contact_email,
business_license: form.value.business_license
} as UTSJSONObject
const updateRes = await supa.from('ml_shops')
.update(shopUpdatePayload)
.eq('merchant_id', userId)
.execute()
if (updateRes.error != null) {
console.error('[ShopManage] 更新 ml_shops 失败:', updateRes.error)
const errMsg = updateRes.error?.getString ? updateRes.error.getString('message') ?? '保存失败' : '保存失败'
uni.showToast({ title: '保存失败:' + errMsg, icon: 'none' })
return
}
// 验证更新结果
if (!updateRes.data) {
console.error('[ShopManage] 更新 ml_shops 未返回数据')
} else {
console.log('[ShopManage] 更新成功,返回数据:', updateRes.data)
}
// 3. 更新 ml_user_profiles.verification_data
const extPayloadData = {
shop_type: extData.value.shop_type,
id_card_front: extData.value.id_card_front,
id_card_back: extData.value.id_card_back,
license_image: extData.value.license_image,
brand_authorization: extData.value.brand_authorization,
updated_at: new Date().toISOString()
} as UTSJSONObject
// 先查询是否已有对应记录
const profCheckRes = await supa.from('ml_user_profiles')
.select('id')
.eq('user_id', userId)
.single()
.execute()
if (profCheckRes.data != null && !profCheckRes.error) {
const profRes = await supa.from('ml_user_profiles')
.update({ verification_data: extPayloadData } as UTSJSONObject)
.eq('user_id', userId)
.execute()
if (profRes.error != null) {
console.warn('[ShopManage] 更新扩展资质失败(不影响主信息):', profRes.error)
}
} else {
const profRes = await supa.from('ml_user_profiles')
.insert({ user_id: userId, verification_data: extPayloadData } as UTSJSONObject)
.execute()
if (profRes.error != null) {
console.warn('[ShopManage] 写入扩展资质失败(不影响主信息):', profRes.error)
}
}
isEditing.value = false
formSnapshot.value = { ...form.value }
extSnapshot.value = { ...extData.value }
// 4. 重新拉取最新数据回显
await loadShop()
uni.showToast({ title: '保存成功', icon: 'success' })
} catch (e: any) {
console.error('[ShopManage] 保存异常:', e)
uni.showToast({ title: e?.message ?? '保存失败,请重试', icon: 'none' })
} finally {
saving.value = false
uni.hideLoading()
}
}
// ===== 导航 =====
function goToCreate() {
openRoute('shop_create')
}
// ===== 工具 =====
function formatDate(dateStr: string): string {
if (!dateStr) return '-'
try {
const d = new Date(dateStr)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
} catch (_) {
return dateStr
}
}
</script>
<style scoped lang="scss">
.shop-manage-page {
padding: 0;
background: transparent;
}
/* 加载 */
.loading-wrap {
display: flex;
align-items: center;
justify-content: center;
height: 200px;
}
.loading-txt { font-size: 14px; color: #999; }
/* 空态 */
.empty-wrap {
display: flex;
flex-direction: column;
align-items: center;
padding: 80px 40px;
background: #fff;
border-radius: 4px;
}
.empty-icon { font-size: 56px; margin-bottom: 16px; }
.empty-title { font-size: 18px; font-weight: 600; color: #333; margin-bottom: 8px; }
.empty-desc { font-size: 13px; color: #999; margin-bottom: 28px; }
.btn-create {
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;
}
/* 页面头部 */
.page-header {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.page-title {
font-size: 22px;
font-weight: 600;
color: #333;
}
.header-actions {
display: flex;
flex-direction: row;
gap: 8px;
}
.btn-edit {
padding: 0 20px;
height: 36px;
background: #1890ff;
color: #fff;
border-radius: 4px;
font-size: 13px;
border: none;
display: flex;
align-items: center;
justify-content: center;
}
.edit-btns {
display: flex;
flex-direction: row;
gap: 8px;
}
.btn-cancel {
padding: 0 16px;
height: 36px;
background: #fff;
color: #606266;
border: 1px solid #dcdfe6;
border-radius: 4px;
font-size: 13px;
display: flex;
align-items: center;
justify-content: center;
}
.btn-save {
padding: 0 20px;
height: 36px;
background: #52c41a;
color: #fff;
border-radius: 4px;
font-size: 13px;
border: none;
display: flex;
align-items: center;
justify-content: center;
&.disabled { background: #b7eb8f; cursor: not-allowed; }
}
/* 卡片 */
.form-card {
background: #fff;
border-radius: 4px;
padding: 20px 24px;
margin-bottom: 16px;
}
.card-title {
font-size: 15px;
font-weight: 600;
color: #333;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid #f0f0f0;
}
.required { color: #ff4d4f; margin-right: 4px; }
/* 表单项 */
.form-item {
margin-bottom: 16px;
&:last-child { margin-bottom: 0; }
}
.form-label {
font-size: 13px;
color: #606266;
margin-bottom: 8px;
display: flex;
flex-direction: row;
align-items: center;
gap: 2px;
}
.form-input {
width: 100%;
height: 36px;
border: 1px solid #dcdfe6;
border-radius: 4px;
padding: 0 12px;
font-size: 13px;
color: #333;
background: #fff;
box-sizing: border-box;
}
.form-textarea {
width: 100%;
height: 80px;
border: 1px solid #dcdfe6;
border-radius: 4px;
padding: 8px 12px;
font-size: 13px;
color: #333;
background: #fff;
box-sizing: border-box;
resize: none;
}
/* 只读模式展示 */
.view-val {
font-size: 14px;
color: #333;
line-height: 1.5;
}
.view-val.multiline { white-space: pre-wrap; }
.empty-val { font-size: 13px; color: #aaa; }
/* Logo 预览 */
.preview-logo {
width: 80px;
height: 80px;
border-radius: 4px;
object-fit: cover;
}
.upload-preview {
width: 100%;
height: 100%;
}
/* 上传区域 */
.upload-area {
width: 100px;
height: 80px;
border: 1px dashed #dcdfe6;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
overflow: hidden;
&:hover { border-color: #1890ff; }
}
.upload-area-wide {
width: 200px;
height: 120px;
border: 1px dashed #dcdfe6;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
overflow: hidden;
&:hover { border-color: #1890ff; }
}
.upload-placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.upload-icon { font-size: 20px; color: #c0c4cc; }
.upload-txt { font-size: 11px; color: #aaa; }
/* 证件图 */
.cert-img {
width: 200px;
height: 120px;
border-radius: 4px;
}
.view-mode { display: flex; flex-direction: row; align-items: center; }
/* Picker */
.picker-trigger {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
width: 160px;
height: 36px;
border: 1px solid #dcdfe6;
border-radius: 4px;
padding: 0 12px;
font-size: 13px;
color: #333;
background: #fff;
}
.picker-arrow { font-size: 10px; color: #c0c4cc; }
/* 状态信息 */
.info-card { }
.info-row {
display: flex;
flex-direction: row;
align-items: center;
padding: 10px 0;
border-bottom: 1px solid #f9f9f9;
&:last-child { border-bottom: none; }
}
.info-label { width: 100px; font-size: 13px; color: #999; }
.info-val { font-size: 13px; color: #333; }
.status-normal { color: #52c41a; font-weight: 500; }
.status-off { color: #ff4d4f; font-weight: 500; }
</style>