This commit is contained in:
2025-09-30 00:03:43 +08:00
parent 19117de6c8
commit 8885fb766a
39 changed files with 517 additions and 159 deletions

View File

@@ -15,13 +15,13 @@ export {};
type __VLS_PickNotAny<A, B> = __VLS_IsAny<A> extends true ? B : A;
type __VLS_SpreadMerge<A, B> = Omit<A, keyof B> & B;
type __VLS_WithComponent<N0 extends string, LocalComponents, Self, N1 extends string, N2 extends string, N3 extends string> =
N1 extends keyof LocalComponents ? N1 extends N0 ? Pick<LocalComponents, N0 extends keyof LocalComponents ? N0 : never> : { [K in N0]: LocalComponents[N1] } :
N2 extends keyof LocalComponents ? N2 extends N0 ? Pick<LocalComponents, N0 extends keyof LocalComponents ? N0 : never> : { [K in N0]: LocalComponents[N2] } :
N3 extends keyof LocalComponents ? N3 extends N0 ? Pick<LocalComponents, N0 extends keyof LocalComponents ? N0 : never> : { [K in N0]: LocalComponents[N3] } :
N1 extends keyof LocalComponents ? { [K in N0]: LocalComponents[N1] } :
N2 extends keyof LocalComponents ? { [K in N0]: LocalComponents[N2] } :
N3 extends keyof LocalComponents ? { [K in N0]: LocalComponents[N3] } :
Self extends object ? { [K in N0]: Self } :
N1 extends keyof __VLS_GlobalComponents ? N1 extends N0 ? Pick<__VLS_GlobalComponents, N0 extends keyof __VLS_GlobalComponents ? N0 : never> : { [K in N0]: __VLS_GlobalComponents[N1] } :
N2 extends keyof __VLS_GlobalComponents ? N2 extends N0 ? Pick<__VLS_GlobalComponents, N0 extends keyof __VLS_GlobalComponents ? N0 : never> : { [K in N0]: __VLS_GlobalComponents[N2] } :
N3 extends keyof __VLS_GlobalComponents ? N3 extends N0 ? Pick<__VLS_GlobalComponents, N0 extends keyof __VLS_GlobalComponents ? N0 : never> : { [K in N0]: __VLS_GlobalComponents[N3] } :
N1 extends keyof __VLS_GlobalComponents ? { [K in N0]: __VLS_GlobalComponents[N1] } :
N2 extends keyof __VLS_GlobalComponents ? { [K in N0]: __VLS_GlobalComponents[N2] } :
N3 extends keyof __VLS_GlobalComponents ? { [K in N0]: __VLS_GlobalComponents[N3] } :
{};
type __VLS_FunctionalComponentCtx<T, K> = __VLS_PickNotAny<'__ctx' extends keyof __VLS_PickNotAny<K, {}>
? K extends { __ctx?: infer Ctx } ? NonNullable<Ctx> : never : any
@@ -83,6 +83,10 @@ export {};
}
>
>;
type __VLS_EmitsToProps<T> = __VLS_PrettifyGlobal<{
[K in string & keyof T as `on${Capitalize<K>}`]?:
(...args: T[K] extends (...args: infer P) => any ? P : T[K] extends null ? any[] : never) => any;
}>;
type __VLS_ResolveEmits<
Comp,
Emits,
@@ -90,10 +94,16 @@ export {};
NormalizedEmits = __VLS_NormalizeEmits<Emits> extends infer E ? string extends keyof E ? {} : E : never,
> = __VLS_SpreadMerge<NormalizedEmits, TypeEmits>;
type __VLS_ResolveDirectives<T> = {
[K in Exclude<keyof T, keyof __VLS_GlobalDirectives> & string as `v${Capitalize<K>}`]: T[K];
[K in keyof T & string as `v${Capitalize<K>}`]: T[K];
};
type __VLS_PrettifyGlobal<T> = { [K in keyof T as K]: T[K]; } & {};
type __VLS_WithDefaultsGlobal<P, D> = {
[K in keyof P as K extends keyof D ? K : never]-?: P[K];
} & {
[K in keyof P as K extends keyof D ? never : K]: P[K];
};
type __VLS_UseTemplateRef<T> = Readonly<import('vue').ShallowRef<T | null>>;
type __VLS_ProxyRefs<T> = import('vue').ShallowUnwrapRef<T>;
function __VLS_getVForSourceType<T extends number | string | any[] | Iterable<any>>(source: T): [
item: T extends number ? number
@@ -115,7 +125,6 @@ export {};
: T extends (...args: any) => any
? T
: (arg1: unknown, arg2: unknown, arg3: unknown, arg4: unknown) => void;
function __VLS_makeOptional<T>(t: T): { [K in keyof T]?: T[K] };
function __VLS_asFunctionalComponent<T, K = T extends new (...args: any) => any ? InstanceType<T> : unknown>(t: T, instance?: K):
T extends new (...args: any) => any ? __VLS_FunctionalComponent<K>
: T extends () => any ? (props: {}, ctx?: any) => ReturnType<T>

View File

@@ -64,7 +64,13 @@ async function fetch(){
}
function reset(){ q.status='open'; q.kw=''; fetch() }
function openReply(row: any){ visible.value = true; current.value = row; reply.value='' }
async function sendReply(){ await post(`/api/admin/consults/${current.value.id}/reply`, { content: reply.value }); visible.value=false; await fetch() }
async function sendReply(){
const data = await post(`/api/admin/consults/${current.value.id}/reply`, { content: reply.value })
visible.value=false
// 若后端返回状态,立即更新当前行展示
if (current.value && data && data.status) current.value.status = data.status
await fetch()
}
async function resolve(row: any){ await put(`/api/admin/consults/${row.id}/resolve`, {}); row.status = 'resolved' }
function viewReply(row: any){ ElMessageBox.alert(row.replyContent || '无', '已回复内容', { confirmButtonText: '知道了' }) }

View File

@@ -28,7 +28,19 @@
</div>
<div class="panel" style="padding:0;">
<el-table :data="rows" style="width:100%" :loading="loading">
<el-table :data="rows" style="width:100%" :loading="loading" row-key="id" :expand-row-keys="expanded" @expand-change="onExpandChange">
<el-table-column type="expand" width="28">
<template #default="{row}">
<div class="expand-wrap">
<div class="expand-line">模板<b>{{ (expand[row.id]?.templateName)|| '-' }}</b></div>
<div v-if="expand[row.id]?.loading" class="expand-loading">加载中...</div>
<el-table v-else :data="expand[row.id]?.rows||[]" size="small" style="width:100%;">
<el-table-column prop="label" label="参数" min-width="160" />
<el-table-column prop="value" label="值" />
</el-table>
</div>
</template>
</el-table-column>
<el-table-column type="index" width="60" label="#" />
<el-table-column prop="model" label="型号" width="160" />
<el-table-column prop="name" label="名称" min-width="180" />
@@ -66,36 +78,8 @@
<el-dialog v-model="detail.visible" title="配件详情" width="720px">
<el-form v-if="detail.data" :model="detail.data" label-width="108px" class="detail-form">
<el-form-item label="型号">
<el-input v-model="detail.data.model" disabled />
</el-form-item>
<el-form-item label="名称">
<el-input v-model="detail.data.name" />
</el-form-item>
<el-form-item label="品牌">
<el-input v-model="detail.data.brand" />
</el-form-item>
<el-form-item label="规格">
<el-input v-model="detail.data.spec" />
</el-form-item>
<el-form-item label="模板">
<el-input v-model="detail.data.templateId" disabled />
</el-form-item>
<el-form-item label="参数">
<el-table :data="paramRows" size="small" style="width:100%">
<el-table-column prop="fieldLabel" label="名称" />
<el-table-column prop="unit" label="单位" width="100" />
<el-table-column prop="required" label="必填" width="80">
<template #default="{row}"><el-tag :type="row.required?'danger':'info'">{{ row.required?'是':'否' }}</el-tag></template>
</el-table-column>
<el-table-column prop="value" label="提交值" />
</el-table>
</el-form-item>
<el-form-item label="参数JSON">
<el-input type="textarea" v-model="detail.parameterText" :rows="4" :placeholder="jsonPlaceholder" />
</el-form-item>
<el-form-item label="备注">
<el-input type="textarea" v-model="detail.data.remark" :rows="3" />
<el-input type="textarea" v-model="detail.data.remark" :rows="4" />
</el-form-item>
<el-form-item label="图片">
<div class="thumb-list">
@@ -142,6 +126,8 @@ const loading = ref(false)
const exporting = ref(false)
const detail = reactive({ visible: false, id: 0, data: null as any, images: [] as string[], parameterText: '', template: null as any })
const expand = reactive<Record<number, { loading: boolean, templateName: string, rows: Array<{label:string,value:any}> }>>({})
const expanded = ref<number[]>([])
const paramRows = ref<any[]>([])
const jsonPlaceholder = '{"key":"value"}'
const saving = ref(false)
@@ -179,6 +165,9 @@ async function fetchList(){
const res = await get('/api/admin/parts/submissions', params)
rows.value = Array.isArray(res?.list) ? res.list : []
total.value = Number(res?.total || rows.value.length)
// 默认展开全部并预加载参数
expanded.value = rows.value.map((r:any)=>Number(r.id)).filter((x:number)=>!!x)
for (const r of rows.value) { await ensureExpand(r) }
} catch (e:any) {
ElMessage.error(e.message || '加载失败')
} finally {
@@ -194,6 +183,33 @@ function reset(){
}
function onPage(p:number){ query.page = p; fetchList() }
async function ensureExpand(row:any){
const id = Number(row?.id)
if (!id) return
if (expand[id] && !expand[id].loading && (expand[id].rows||[]).length) return
expand[id] = { loading: true, templateName: '', rows: [] }
try {
const data = await get(`/api/admin/parts/submissions/${id}`)
const params = data?.parameters || {}
if (data?.templateId) {
try {
const t = await get(`/api/admin/part-templates/${data.templateId}`)
expand[id].templateName = t?.name || ''
const defs = (t?.params||[])
expand[id].rows = defs.map((p:any)=>({ label: p.fieldLabel + (p.unit?` (${p.unit})`:''), value: params[p.fieldKey] ?? '' }))
} catch {
expand[id].rows = Object.keys(params).map(k=>({ label: k, value: params[k] }))
}
} else {
expand[id].rows = Object.keys(params).map(k=>({ label: k, value: params[k] }))
}
} finally {
if (expand[id]) expand[id].loading = false
}
}
function onExpandChange(row:any){ ensureExpand(row) }
async function openDetail(id:number){
try {
const data = await get(`/api/admin/parts/submissions/${id}`)
@@ -319,4 +335,7 @@ onMounted(fetchList)
.thumb-list { display:flex; flex-wrap:wrap; gap:12px; }
.thumb { width: 120px; display:flex; flex-direction: column; gap: 8px; }
.thumb .el-image { width: 120px; height: 120px; border-radius: 8px; }
.expand-wrap { padding: 8px 12px; background: rgba(255,255,255,0.02); }
.expand-line { margin-bottom: 6px; font-size: 13px; color: #ddd; }
.expand-loading { padding: 8px 0; color:#999; }
</style>

View File

@@ -36,7 +36,7 @@
</el-table-column>
</el-table>
<el-dialog v-model="dlg.visible" :title="dlg.id? '查看模板' : '新建模板'" width="720">
<el-dialog v-model="dlg.visible" :title="dlg.id? '查看模板' : '新建模板'" width="1100">
<el-form :model="form" label-width="100px">
<el-form-item label="分类">
<el-select v-model="form.categoryId" placeholder="选择分类" :disabled="!!dlg.id">
@@ -60,20 +60,14 @@
<el-table-column label="#" width="48">
<template #default="{ $index }">{{ $index+1 }}</template>
</el-table-column>
<el-table-column label="键">
<template #default="{row}"><el-input v-model="row.fieldKey" placeholder="英文字母/下划线" :disabled="!!dlg.id" /></template>
</el-table-column>
<el-table-column label="名称">
<template #default="{row}"><el-input v-model="row.fieldLabel" :disabled="!!dlg.id" /></template>
<template #default="{row}"><el-input v-model="row.fieldLabel" :disabled="!!dlg.id" @input="onLabelInput(row)" /></template>
</el-table-column>
<el-table-column label="类型" width="120">
<template #default="{row}">
<el-select v-model="row.type" style="width:110px" :disabled="!!dlg.id">
<el-option label="string" value="string" />
<el-option label="number" value="number" />
<el-option label="boolean" value="boolean" />
<el-option label="enum" value="enum" />
<el-option label="date" value="date" />
<el-option label="数字" value="number" />
<el-option label="非数字" value="string" />
</el-select>
</template>
</el-table-column>
@@ -83,15 +77,25 @@
<el-table-column label="单位" width="120">
<template #default="{row}"><el-input v-model="row.unit" :disabled="!!dlg.id" /></template>
</el-table-column>
<el-table-column label="枚举项">
<template #default="{row}"><el-input v-model="row.enumOptionsText" placeholder="逗号分隔type=enum" :disabled="!!dlg.id" /></template>
<!-- 检索默认参与前端不再显示开关 -->
<el-table-column label="可模糊" width="90">
<template #default="{row}">
<el-switch v-model="row.fuzzySearchable" :disabled="!!dlg.id || row.type!=='number'" />
</template>
</el-table-column>
<el-table-column label="检索" width="80">
<template #default="{row}"><el-switch v-model="row.searchable" :disabled="!!dlg.id" /></template>
<el-table-column label="卡片展示" width="100">
<template #default="{row}">
<el-switch v-model="row.cardDisplay" :disabled="!!dlg.id" />
</template>
</el-table-column>
<el-table-column label="去重" width="80">
<template #default="{row}"><el-switch v-model="row.dedupeParticipate" :disabled="!!dlg.id" /></template>
<el-table-column label="容差" width="160">
<template #default="{row}">
<el-input v-model="row.fuzzyTolerance" :disabled="!!dlg.id || row.type!=='number' || !row.fuzzySearchable" placeholder="默认1.0" />
<div v-if="row.unit" style="font-size:12px;color:#999">单位{{ row.unit }}</div>
</template>
</el-table-column>
<el-table-column label="操作" width="90">
<template #default="{ $index }">
<el-button v-if="!dlg.id" text type="danger" @click="removeParam($index)">删除</el-button>
@@ -136,7 +140,8 @@ function openView(row:any) {
http.get(`/api/admin/part-templates/${row.id}`).then(res => {
const d = res.data
Object.assign(form, { id: d.id, categoryId: d.categoryId, name: d.name, modelRule: d.modelRule, status: d.status,
params: (d.params||[]).map((p:any)=>({ ...p, enumOptionsText: (p.enumOptions||[]).join(',') })) })
params: (d.params||[]).map((p:any)=>({ ...p, enumOptionsText: (p.enumOptions||[]).join(','),
fuzzySearchable: !!p.fuzzySearchable, fuzzyTolerance: p.fuzzyTolerance })) })
})
}
@@ -146,12 +151,15 @@ function openCreate() {
}
function addParam() {
form.params.push({ fieldKey:'', fieldLabel:'', type:'string', required:false, unit:'', enumOptionsText:'', searchable:false, dedupeParticipate:false, sortOrder:0 })
form.params.push({ fieldKey:'', fieldLabel:'', type:'number', required:false, unit:'mm', enumOptionsText:'', searchable:false,
fuzzySearchable:false, fuzzyTolerance:null, sortOrder:0 })
}
function removeParam(i:number) { form.params.splice(i,1) }
function save() {
// 先为所有行生成 fieldKey基于名称拼音首字母并消重
genAllKeys()
// 校验
const seen = new Set<string>()
for (const p of form.params) {
@@ -160,13 +168,27 @@ function save() {
if (!key) { ElMessage.warning('参数键不能为空'); return }
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) { ElMessage.warning(`参数键不合法: ${key}`); return }
if (!label) { ElMessage.warning('参数名称不能为空'); return }
if (!['string','number','boolean','enum','date'].includes(p.type)) { ElMessage.warning(`不支持的类型: ${p.type}`); return }
if (!['string','number'].includes(p.type)) { ElMessage.warning(`不支持的类型: ${p.type}`); return }
if (p.fuzzySearchable && p.type !== 'number') { ElMessage.warning(`仅 number 类型可开启可模糊: ${key}`); return }
if (p.fuzzySearchable && p.fuzzyTolerance !== undefined && p.fuzzyTolerance !== null && String(p.fuzzyTolerance).trim() !== '') {
const num = Number(p.fuzzyTolerance)
if (!(num > 0)) { ElMessage.warning(`容差需为正数: ${key}`); return }
p.fuzzyTolerance = num
}
if (seen.has(key)) { ElMessage.warning(`参数键重复: ${key}`); return }
seen.add(key)
}
// 限制卡片展示最多4个
if (form.params.filter((p:any)=>!!p.cardDisplay).length > 4) {
ElMessage.warning('卡片展示最多选择4个参数');
return
}
const payload:any = { categoryId: form.categoryId, name: form.name, modelRule: form.modelRule, status: form.status,
params: form.params.map((p:any)=>({ fieldKey:p.fieldKey, fieldLabel:p.fieldLabel, type:p.type, required:p.required, unit:p.unit,
enumOptions: (p.enumOptionsText||'').split(',').map((s:string)=>s.trim()).filter((s:string)=>s), searchable:p.searchable, dedupeParticipate:p.dedupeParticipate, sortOrder:p.sortOrder })) }
enumOptions: undefined, searchable:true,
fuzzySearchable: !!p.fuzzySearchable, fuzzyTolerance: (p.fuzzyTolerance===null||p.fuzzyTolerance===''? null : Number(p.fuzzyTolerance)),
cardDisplay: !!p.cardDisplay,
sortOrder:p.sortOrder })) }
http.post('/api/admin/part-templates', payload).then(()=>{
ElMessage.success('创建成功'); dlg.visible=false; load()
})
@@ -183,6 +205,51 @@ function doDelete(row:any) {
}
onMounted(()=>{ loadCategories(); load() })
// 生成拼音首字母作为键,并做合法化与消重
// 轻量拼音首字母生成:优先使用浏览器 Intl.Segmenter 分词 + 拉丁字母提取;不依赖外部包
function toInitialKey(label: string): string {
const s = String(label||'').trim()
if (!s) return ''
// 规则:取每个“词”的首字符(非中文/字母/数字作为分隔),保留字母数字;中文用简单映射(常用声母)
const simpleMap: Record<string,string> = {
'长':'c','重':'z','沈':'s','厦':'x','重庆':'c','长沙':'c'
}
let buf = ''
for (let i=0;i<s.length;i++) {
const ch = s[i]
if (/^[A-Za-z0-9]$/.test(ch)) { buf += ch[0] ; while (i+1<s.length && /[A-Za-z0-9]/.test(s[i+1])) i++; continue }
// 中文取一个字符映射为首字母非常简化
if (/^[\u4e00-\u9fa5]$/.test(ch)) { buf += (simpleMap[ch] || 'z') ; continue }
// 其它符号作为分隔不取
}
let key = buf.toLowerCase().replace(/[^a-z0-9_]/g,'')
if (!key) key = s.replace(/\s+/g,'_').replace(/[^A-Za-z0-9_]/g,'').toLowerCase()
if (!/^[A-Za-z_]/.test(key)) key = '_' + key
return key
}
function onLabelInput(row:any){
const base = toInitialKey(row.fieldLabel)
row.fieldKey = uniqueKey(base, row)
}
function genAllKeys(){
for (const r of form.params) {
if (!r.fieldKey) r.fieldKey = toInitialKey(r.fieldLabel)
}
// 再次去重
const counts: Record<string, number> = {}
form.params.forEach(r=>{ r.fieldKey = uniqueKey(r.fieldKey, r) })
}
function uniqueKey(base: string, currentRow:any){
let k = (base||'').toLowerCase()
if (!k) k = '_k'
// 确保在当前列表中唯一
const others = form.params.filter((x:any)=>x!==currentRow).map((x:any)=>String(x.fieldKey||'').toLowerCase())
if (!others.includes(k)) return k
let i = 2
while (others.includes(`${k}${i}`)) i++
return `${k}${i}`
}
</script>
<style scoped>

View File

@@ -18,11 +18,23 @@
<el-table-column prop="userId" label="用户ID" width="100" />
<el-table-column prop="brand" label="品牌" width="120" />
<el-table-column prop="model" label="型号" width="160" />
<el-table-column prop="spec" label="规格" />
<el-table-column prop="templateName" label="模板" width="220" />
<el-table-column label="模板内容">
<template #default="{row}">
<div v-if="row.attributesJson" style="display:flex; flex-wrap:wrap; gap:8px;">
<el-tag v-for="(v,k) in parseAttrs(row.attributesJson)" :key="k" type="info">{{ k }}: {{ v }}</el-tag>
</div>
</template>
</el-table-column>
<el-table-column label="图片" width="160">
<template #default="{row}">
<div style="display:flex; gap:6px; flex-wrap:wrap;">
<el-image v-for="(img,i) in (row.images||[])" :key="i" :src="withBaseUrl(img)" fit="cover" style="width:48px;height:48px;border-radius:8px;" :preview-src-list="(row.images||[]).map(withBaseUrl)" />
<el-image v-for="(img,i) in (row.images||[])" :key="i" :src="withBaseUrl(img)" fit="cover"
style="width:48px;height:48px;border-radius:8px;"
:z-index="3000"
:preview-teleported="true"
:preview-src-list="(row.images||[]).map(withBaseUrl)" />
</div>
</template>
</el-table-column>
@@ -71,6 +83,16 @@ function edit(row: any){ visible.value=true; Object.assign(form, JSON.parse(JSON
async function save(){ await put(`/api/admin/parts/${form.id}`, form); visible.value=false; await fetch() }
onMounted(fetch)
function parseAttrs(json: any): Record<string, any> {
try {
if (!json) return {}
if (typeof json === 'object') return json as Record<string, any>
return JSON.parse(String(json)) || {}
} catch(e){
return {}
}
}
</script>
<style scoped>

View File

@@ -24,28 +24,15 @@
<el-table-column label="状态" width="120">
<template #default="{row}"><el-tag :type="row.status===1?'success':'danger'">{{ row.status===1?'正常':'黑名单' }}</el-tag></template>
</el-table-column>
<el-table-column fixed="right" label="操作" width="240">
<el-table-column fixed="right" label="操作" width="160">
<template #default="{row}">
<el-button size="small" @click="openEdit(row)">编辑</el-button>
<el-button size="small" @click="toggle(row)">{{ row.status===1?'拉黑':'恢复' }}</el-button>
</template>
</el-table-column>
</el-table>
</div>
<el-dialog v-model="visible" title="编辑用户" width="520">
<el-form :model="form" label-width="96px">
<el-form-item label="姓名"><el-input v-model="form.name" /></el-form-item>
<el-form-item label="手机号"><el-input v-model="form.phone" /></el-form-item>
<el-form-item label="角色"><el-input v-model="form.role" placeholder="owner/staff/finance" /></el-form-item>
<el-form-item label="状态"><el-switch v-model="form.status" :active-value="1" :inactive-value="0" /></el-form-item>
<el-form-item label="是否店主"><el-switch v-model="form.isOwner" :active-value="true" :inactive-value="false" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="visible=false">取消</el-button>
<el-button type="primary" @click="save">保存</el-button>
</template>
</el-dialog>
</div>
</template>
@@ -55,8 +42,6 @@ import { get, put } from '../../api/http'
const q = reactive({ kw: '' })
const rows = ref<any[]>([])
const visible = ref(false)
const form = reactive<any>({})
async function fetch(){
// 需要后端提供GET /api/admin/users?kw=...
@@ -64,8 +49,6 @@ async function fetch(){
rows.value = Array.isArray(data?.list) ? data.list : (Array.isArray(data)?data:[])
}
function reset(){ q.kw=''; fetch() }
function openEdit(row: any){ visible.value=true; Object.assign(form, row) }
async function save(){ await put(`/api/admin/users/${form.id}`, form); visible.value=false; await fetch() }
async function toggle(row: any){
const newStatus = row.status===1?0:1
await put(`/api/admin/users/${row.id}`, { status: newStatus })

View File

@@ -9,7 +9,6 @@
<el-form-item>
<el-button type="primary" @click="fetch">查询</el-button>
<el-button @click="reset">重置</el-button>
<el-button type="success" @click="openEdit()">新增VIP</el-button>
</el-form-item>
</el-form>
</div>
@@ -19,9 +18,6 @@
<el-table-column prop="userId" label="用户ID" width="100" />
<el-table-column prop="name" label="姓名" width="140" />
<el-table-column prop="phone" label="手机号" width="160" />
<el-table-column label="VIP" width="100">
<template #default="{row}"><el-tag :type="row.isVip===1?'warning':'info'">{{ row.isVip===1?'是':'否' }}</el-tag></template>
</el-table-column>
<el-table-column prop="expireAt" label="到期时间" width="180" />
<el-table-column label="状态" width="120">
<template #default="{row}">
@@ -30,23 +26,22 @@
</el-table-column>
<el-table-column fixed="right" label="操作" width="220">
<template #default="{row}">
<el-button size="small" type="primary" @click="openEdit(row)">编辑</el-button>
<el-button size="small" type="primary" @click="openExpire(row)">到期时间</el-button>
<el-button size="small" @click="toggle(row)">{{ row.status===1?'停用':'启用' }}</el-button>
</template>
</el-table-column>
</el-table>
</div>
<el-dialog v-model="visible" :title="editing?'编辑VIP':'新增VIP'" width="520">
<el-form :model="form" label-width="96px">
<el-form-item label="用户ID"><el-input v-model.number="form.userId" placeholder="输入用户ID" /></el-form-item>
<el-form-item label="是否VIP"><el-switch v-model="form.isVip" :active-value="1" :inactive-value="0" /></el-form-item>
<el-form-item label="到期时间"><el-date-picker v-model="form.expireAt" type="datetime" placeholder="选择到期时间" /></el-form-item>
<el-form-item label="状态"><el-switch v-model="form.status" :active-value="1" :inactive-value="0" /></el-form-item>
<el-dialog v-model="expireVisible" title="设置到期时间" width="420">
<el-form label-width="96px">
<el-form-item label="到期时间">
<el-date-picker v-model="expireAtVal" type="datetime" placeholder="选择到期时间" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="visible=false">取消</el-button>
<el-button type="primary" @click="save">保存</el-button>
<el-button @click="expireVisible=false">取消</el-button>
<el-button type="primary" @click="saveExpire">保存</el-button>
</template>
</el-dialog>
@@ -56,13 +51,14 @@
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { get, post, put } from '../../api/http'
import { get, put } from '../../api/http'
const q = reactive({ phone: '' })
const rows = ref<any[]>([])
const visible = ref(false)
const editing = ref(false)
const form = reactive({ id: 0, userId: 0, isVip: 1, expireAt: '', status: 1 })
const expireVisible = ref(false)
const expireAtVal = ref<any>('')
const expireRow = ref<any>(null)
async function fetch(){
// 需要后端提供GET /api/admin/vips?phone=... (或 /api/users + /api/vips 关联)
@@ -70,24 +66,23 @@ async function fetch(){
rows.value = Array.isArray(data?.list) ? data.list : (Array.isArray(data)?data:[])
}
function reset(){ q.phone=''; fetch() }
function openEdit(row?: any){
visible.value = true
editing.value = !!row
if (row) Object.assign(form, { id: row.id, userId: row.userId, isVip: row.isVip ?? 1, expireAt: row.expireAt, status: row.status })
else Object.assign(form, { id: 0, userId: 0, isVip: 1, expireAt: '', status: 1 })
}
async function save(){
const body = { userId: form.userId, isVip: form.isVip, expireAt: form.expireAt || null, status: form.status }
if (editing.value) await put(`/api/admin/vips/${form.id}`, body)
else await post('/api/admin/vips', body)
visible.value = false
await fetch()
}
async function toggle(row: any){
await put(`/api/admin/vips/${row.id}`, { ...row, status: row.status===1?0:1 })
await fetch()
}
function openExpire(row: any){
expireRow.value = row
expireAtVal.value = row?.expireAt || ''
expireVisible.value = true
}
async function saveExpire(){
if (!expireRow.value) { expireVisible.value = false; return }
await put(`/api/admin/vips/${expireRow.value.id}`, { expireAt: expireAtVal.value || null })
expireVisible.value = false
await fetch()
}
onMounted(fetch)
@@ -98,3 +93,4 @@ onMounted(fetch)
</style>

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

View File

@@ -66,7 +66,9 @@ public class AdminConsultController {
}
Long uid = (userId != null ? userId : (adminId != null ? adminId : defaults.getUserId()));
jdbcTemplate.update("INSERT INTO consult_replies (consult_id,user_id,content,created_at) VALUES (?,?,?,NOW())", id, uid, content);
return ResponseEntity.ok().build();
// 自动判定为已解决
jdbcTemplate.update("UPDATE consults SET status='resolved', updated_at=NOW() WHERE id=?", id);
return ResponseEntity.ok(java.util.Map.of("status", "resolved"));
}
@PutMapping("/{id}/resolve")

View File

@@ -24,8 +24,9 @@ public class AdminPartController {
@RequestParam(name = "size", defaultValue = "20") int size) {
int offset = Math.max(0, page - 1) * Math.max(1, size);
StringBuilder sql = new StringBuilder(
"SELECT p.id,p.user_id AS userId,p.shop_id AS shopId,s.name AS shopName,p.name,p.brand,p.model,p.spec,p.is_blacklisted " +
"FROM products p JOIN shops s ON s.id=p.shop_id WHERE p.deleted_at IS NULL");
"SELECT p.id,p.user_id AS userId,p.shop_id AS shopId,s.name AS shopName,p.name,p.brand,p.model,p.spec,p.is_blacklisted, " +
"p.template_id AS templateId, t.name AS templateName, p.attributes_json AS attributesJson " +
"FROM products p JOIN shops s ON s.id=p.shop_id LEFT JOIN part_templates t ON t.id=p.template_id WHERE p.deleted_at IS NULL");
List<Object> ps = new ArrayList<>();
if (shopId != null) { sql.append(" AND p.shop_id=?"); ps.add(shopId); }
if (kw != null && !kw.isBlank()) {
@@ -50,6 +51,10 @@ public class AdminPartController {
m.put("brand", rs.getString("brand"));
m.put("model", rs.getString("model"));
m.put("spec", rs.getString("spec"));
Object tid = rs.getObject("templateId");
if (tid != null) m.put("templateId", tid);
m.put("templateName", rs.getString("templateName"));
m.put("attributesJson", rs.getString("attributesJson"));
return m;
});
// 附加每个商品的图片列表

View File

@@ -0,0 +1,22 @@
package com.example.demo.common;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
@Component
@ConfigurationProperties(prefix = "search.fuzzy")
public class SearchFuzzyProperties {
private boolean enabled = true;
private BigDecimal defaultTolerance = new BigDecimal("1.0");
public boolean isEnabled() { return enabled; }
public void setEnabled(boolean enabled) { this.enabled = enabled; }
public BigDecimal getDefaultTolerance() { return defaultTolerance; }
public void setDefaultTolerance(BigDecimal defaultTolerance) { this.defaultTolerance = defaultTolerance; }
}

View File

@@ -76,7 +76,7 @@ public class MetadataController {
java.util.List<String> enums = com.example.demo.common.JsonUtils.fromJson(p.getEnumOptionsJson(), new com.fasterxml.jackson.core.type.TypeReference<java.util.List<String>>(){});
pm.put("enumOptions", enums);
pm.put("searchable", p.getSearchable());
pm.put("dedupeParticipate", p.getDedupeParticipate());
// 不再暴露 dedupeParticipate
pm.put("sortOrder", p.getSortOrder());
ps.add(pm);
}

View File

@@ -2,7 +2,6 @@ package com.example.demo.product.dto;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
public class PartTemplateDtos {
@@ -13,8 +12,11 @@ public class PartTemplateDtos {
public boolean required;
public String unit; // 自定义单位文本
public List<String> enumOptions; // type=enum 时可用
public boolean searchable;
public boolean dedupeParticipate;
public boolean searchable; // 默认参与搜索;前端不再展示开关
public boolean fuzzySearchable; // 仅 type=number 生效
public java.math.BigDecimal fuzzyTolerance; // 可空=使用默认
public boolean cardDisplay; // 是否在用户端货品卡片展示
// public boolean dedupeParticipate; // 已废弃,后端忽略
public int sortOrder;
}

View File

@@ -16,6 +16,7 @@ public class ProductDtos {
public BigDecimal retailPrice; // from product_prices
public String cover; // first image url
public Boolean deleted; // derived from deleted_at
public java.util.Map<String, String> cardParams; // 货品卡片展示的参数最多4个label->value
}
public static class ProductDetail {

View File

@@ -1,6 +1,7 @@
package com.example.demo.product.entity;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Entity
@@ -36,7 +37,16 @@ public class PartTemplateParam {
private Boolean searchable;
@Column(name = "dedupe_participate", nullable = false)
private Boolean dedupeParticipate;
private Boolean dedupeParticipate = false;
@Column(name = "fuzzy_searchable", nullable = false)
private Boolean fuzzySearchable = false;
@Column(name = "fuzzy_tolerance", precision = 18, scale = 6)
private BigDecimal fuzzyTolerance;
@Column(name = "card_display", nullable = false)
private Boolean cardDisplay = false;
@Column(name = "sort_order", nullable = false)
private Integer sortOrder;
@@ -64,8 +74,14 @@ public class PartTemplateParam {
public void setEnumOptionsJson(String enumOptionsJson) { this.enumOptionsJson = enumOptionsJson; }
public Boolean getSearchable() { return searchable; }
public void setSearchable(Boolean searchable) { this.searchable = searchable; }
public Boolean getDedupeParticipate() { return dedupeParticipate; }
public void setDedupeParticipate(Boolean dedupeParticipate) { this.dedupeParticipate = dedupeParticipate; }
public Boolean getDedupeParticipate() { return dedupeParticipate == null ? Boolean.FALSE : dedupeParticipate; }
public void setDedupeParticipate(Boolean dedupeParticipate) { this.dedupeParticipate = (dedupeParticipate == null ? Boolean.FALSE : dedupeParticipate); }
public Boolean getFuzzySearchable() { return fuzzySearchable; }
public void setFuzzySearchable(Boolean fuzzySearchable) { this.fuzzySearchable = fuzzySearchable; }
public BigDecimal getFuzzyTolerance() { return fuzzyTolerance; }
public void setFuzzyTolerance(BigDecimal fuzzyTolerance) { this.fuzzyTolerance = fuzzyTolerance; }
public Boolean getCardDisplay() { return cardDisplay == null ? Boolean.FALSE : cardDisplay; }
public void setCardDisplay(Boolean cardDisplay) { this.cardDisplay = (cardDisplay == null ? Boolean.FALSE : cardDisplay); }
public Integer getSortOrder() { return sortOrder; }
public void setSortOrder(Integer sortOrder) { this.sortOrder = sortOrder; }
public LocalDateTime getCreatedAt() { return createdAt; }

View File

@@ -164,8 +164,13 @@ public class PartTemplateService {
p.setRequired(def.required);
p.setUnit(def.unit);
p.setEnumOptionsJson(def.enumOptions == null ? null : JsonUtils.toJson(def.enumOptions));
p.setSearchable(def.searchable);
p.setDedupeParticipate(def.dedupeParticipate);
// 搜索默认参与:若前端未传,置为 true
p.setSearchable(def.searchable || true);
p.setFuzzySearchable(def.fuzzySearchable);
p.setFuzzyTolerance(def.fuzzyTolerance);
p.setCardDisplay(def.cardDisplay);
// 已忽略 dedupeParticipate统一置 false 以满足非空约束)
p.setDedupeParticipate(false);
p.setSortOrder(def.sortOrder == 0 ? idx : def.sortOrder);
p.setCreatedAt(now);
p.setUpdatedAt(now);
@@ -185,7 +190,10 @@ public class PartTemplateService {
d.unit = p.getUnit();
d.enumOptions = JsonUtils.fromJson(p.getEnumOptionsJson(), new com.fasterxml.jackson.core.type.TypeReference<List<String>>(){});
d.searchable = Boolean.TRUE.equals(p.getSearchable());
d.dedupeParticipate = Boolean.TRUE.equals(p.getDedupeParticipate());
d.fuzzySearchable = Boolean.TRUE.equals(p.getFuzzySearchable());
d.fuzzyTolerance = p.getFuzzyTolerance();
d.cardDisplay = Boolean.TRUE.equals(p.getCardDisplay());
// 不再回传 dedupeParticipate
d.sortOrder = p.getSortOrder() == null ? 0 : p.getSortOrder();
out.add(d);
}
@@ -214,6 +222,15 @@ public class PartTemplateService {
if (!("string".equals(type) || "number".equals(type) || "boolean".equals(type) || "enum".equals(type) || "date".equals(type))) {
throw new IllegalArgumentException("不支持的参数类型: " + type);
}
// fuzzy 校验:仅 number 类型允许;启用时容差可留空(用默认)或为正数
if (Boolean.TRUE.equals(def.fuzzySearchable) && !"number".equals(type)) {
throw new IllegalArgumentException("仅 number 类型参数允许开启可模糊查询: " + key);
}
if ("number".equals(type) && Boolean.TRUE.equals(def.fuzzySearchable) && def.fuzzyTolerance != null) {
if (def.fuzzyTolerance.compareTo(java.math.BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException("容差需为正数: " + key);
}
}
if (keys.contains(key)) throw new IllegalArgumentException("参数键重复: " + key);
keys.add(key);
}

View File

@@ -32,17 +32,20 @@ public class ProductService {
private final InventoryRepository inventoryRepository;
private final ProductImageRepository imageRepository;
private final JdbcTemplate jdbcTemplate;
private final com.example.demo.common.SearchFuzzyProperties fuzzyProps;
public ProductService(ProductRepository productRepository,
ProductPriceRepository priceRepository,
InventoryRepository inventoryRepository,
ProductImageRepository imageRepository,
JdbcTemplate jdbcTemplate) {
JdbcTemplate jdbcTemplate,
com.example.demo.common.SearchFuzzyProperties fuzzyProps) {
this.productRepository = productRepository;
this.priceRepository = priceRepository;
this.inventoryRepository = inventoryRepository;
this.imageRepository = imageRepository;
this.jdbcTemplate = jdbcTemplate;
this.fuzzyProps = fuzzyProps;
}
@Transactional
@@ -121,10 +124,7 @@ public class ProductService {
product.setAttributesJson(submission.getAttributesJson());
changed = true;
}
if (submission.getDedupeKey() != null && !submission.getDedupeKey().isBlank()) {
product.setDedupeKey(submission.getDedupeKey());
changed = true;
}
// 忽略 dedupeKey
if (changed) {
product.setUpdatedAt(LocalDateTime.now());
productRepository.save(product);
@@ -149,15 +149,46 @@ public class ProductService {
if (categoryId != null) { sql.append(" AND p.category_id=?"); ps.add(categoryId); }
if (templateId != null) { sql.append(" AND p.template_id=?"); ps.add(templateId); }
if (paramFilters != null && !paramFilters.isEmpty()) {
java.util.Map<String, Boolean> keyFuzzyCache = new java.util.HashMap<>();
for (java.util.Map.Entry<String,String> ent : paramFilters.entrySet()) {
String key = ent.getKey(); String val = ent.getValue();
if (key == null || key.isBlank() || val == null || val.isBlank()) continue;
// 精确匹配参数值:将 JSON 值解包后与入参做等值比较,避免 LIKE 导致的误匹配
boolean fuzzyEnabled = fuzzyProps.isEnabled() && isKeyFuzzyEnabled(key, keyFuzzyCache);
java.math.BigDecimal valNum = null;
boolean numericOk = false;
if (fuzzyEnabled) {
try {
valNum = new java.math.BigDecimal(val.trim());
numericOk = true;
} catch (Exception ignore) { numericOk = false; }
}
if (fuzzyEnabled && numericOk) {
// 行级模糊:存在 fuzzy 定义则按区间否则按等值NOT EXISTS 分支)
sql.append(" AND ( ")
.append("EXISTS (SELECT 1 FROM part_template_params ptp WHERE ptp.template_id=p.template_id AND ptp.field_key=? AND ptp.type='number' AND ptp.fuzzy_searchable=1 ")
.append("AND CAST(JSON_UNQUOTE(JSON_EXTRACT(p.attributes_json, ?)) AS DECIMAL(18,6)) BETWEEN GREATEST(0, ? - COALESCE(ptp.fuzzy_tolerance, ?)) AND (? + COALESCE(ptp.fuzzy_tolerance, ?)) ) ")
.append(" OR (NOT EXISTS (SELECT 1 FROM part_template_params ptp2 WHERE ptp2.template_id=p.template_id AND ptp2.field_key=? AND ptp2.type='number' AND ptp2.fuzzy_searchable=1) ")
.append(" AND JSON_UNQUOTE(JSON_EXTRACT(p.attributes_json, ?)) = ? ) )");
ps.add(key); // ptp.field_key
ps.add("$." + key); // json path
ps.add(valNum); // v for lower
ps.add(fuzzyProps.getDefaultTolerance()); // default tol
ps.add(valNum); // v for upper
ps.add(fuzzyProps.getDefaultTolerance()); // default tol
ps.add(key); // ptp2.field_key for NOT EXISTS
ps.add("$." + key); // json path for equality
ps.add(val.trim()); // equality value
} else {
// 直接等值匹配(包括:未启用全局模糊、该字段不在任何模板中启用模糊、或值非数字)
sql.append(" AND JSON_UNQUOTE(JSON_EXTRACT(p.attributes_json, ?)) = ?");
ps.add("$." + key);
ps.add(val.trim());
}
}
}
sql.append(" ORDER BY p.id DESC LIMIT ? OFFSET ?");
ps.add(size); ps.add(page * size);
List<ProductDtos.ProductListItem> list = jdbcTemplate.query(sql.toString(), (rs,rn) -> {
@@ -173,11 +204,49 @@ public class ProductService {
it.retailPrice = rp;
it.cover = rs.getString("cover");
it.deleted = rs.getBoolean("deleted");
// 取卡片展示参数:根据模板定义 card_display=1最多4个
try {
String json = jdbcTemplate.queryForObject(
"SELECT p.attributes_json FROM products p WHERE p.id=?", String.class, it.id);
java.util.Map<String,Object> params = com.example.demo.common.JsonUtils.fromJson(json, new com.fasterxml.jackson.core.type.TypeReference<java.util.Map<String,Object>>() {});
java.util.LinkedHashMap<String,String> map = new java.util.LinkedHashMap<>();
java.util.List<java.util.Map<String,Object>> defs = jdbcTemplate.query(
"SELECT field_key, field_label, unit FROM part_template_params WHERE template_id=(SELECT template_id FROM products WHERE id=?) AND card_display=1 ORDER BY sort_order, id LIMIT 4",
ps2 -> ps2.setLong(1, it.id),
(r2, rn2) -> {
java.util.Map<String,Object> m = new java.util.HashMap<>();
m.put("key", r2.getString("field_key"));
m.put("label", r2.getString("field_label"));
m.put("unit", r2.getString("unit"));
return m;
}
);
for (java.util.Map<String,Object> d : defs) {
String k = (String)d.get("key");
String label = (String)d.get("label");
String unit = (String)d.get("unit");
Object v = params == null ? null : params.get(k);
if (v == null) continue;
String val = String.valueOf(v) + (unit==null||unit.isBlank()?"":"" );
map.put(label + (unit==null||unit.isBlank()?"":"("+unit+")"), val);
}
it.cardParams = map;
} catch (Exception ignore) {}
return it;
}, ps.toArray());
return new PageImpl<>(list, PageRequest.of(page, size), list.size());
}
private boolean isKeyFuzzyEnabled(String fieldKey, java.util.Map<String, Boolean> cache) {
if (cache.containsKey(fieldKey)) return cache.get(fieldKey);
Integer cnt = jdbcTemplate.queryForObject(
"SELECT COUNT(1) FROM part_template_params WHERE field_key=? AND type='number' AND fuzzy_searchable=1",
Integer.class, fieldKey);
boolean enabled = cnt != null && cnt > 0;
cache.put(fieldKey, enabled);
return enabled;
}
public Optional<ProductDtos.ProductDetail> findDetail(Long id, boolean includeDeleted) {
Optional<Product> op = productRepository.findById(id);
if (op.isEmpty()) return Optional.empty();
@@ -235,7 +304,7 @@ public class ProductService {
p.setOrigin(emptyToNull(req.origin));
p.setCategoryId(req.categoryId);
// 单位字段已移除
p.setDedupeKey(emptyToNull(req.dedupeKey));
// 忽略 dedupeKey
p.setSafeMin(req.safeMin);
p.setSafeMax(req.safeMax);
p.setDescription(emptyToNull(req.remark));
@@ -290,7 +359,7 @@ public class ProductService {
p.setOrigin(emptyToNull(req.origin));
p.setCategoryId(req.categoryId);
// 单位字段已移除
p.setDedupeKey(emptyToNull(req.dedupeKey));
// 忽略 dedupeKey
p.setSafeMin(req.safeMin);
p.setSafeMax(req.safeMax);
p.setDescription(emptyToNull(req.remark));

View File

@@ -79,7 +79,7 @@ public class ProductSubmissionService {
submission.setBarcode(req.barcode);
submission.setSafeMin(req.safeMin);
submission.setSafeMax(req.safeMax);
submission.setDedupeKey(buildDedupeKey(req.templateId, req.name, req.model, req.brand, req.parameters));
// 按“前端隐藏 + 后端忽略”方案:不再计算/使用 dedupeKey兼容历史字段保留
submission.setStatus(ProductSubmission.Status.pending);
submission.setCreatedAt(LocalDateTime.now());
submission.setUpdatedAt(LocalDateTime.now());
@@ -223,9 +223,7 @@ public class ProductSubmissionService {
if (req.barcode != null) submission.setBarcode(req.barcode);
if (req.safeMin != null) submission.setSafeMin(req.safeMin);
if (req.safeMax != null) submission.setSafeMax(req.safeMax);
// 参数或基础字段变更后重算去重键
Map<String,Object> params = JsonUtils.fromJson(submission.getAttributesJson(), new com.fasterxml.jackson.core.type.TypeReference<Map<String,Object>>() {});
submission.setDedupeKey(buildDedupeKey(submission.getTemplateId(), submission.getName(), submission.getModelUnique(), submission.getBrand(), params));
// 不再重算 dedupeKey忽略去重键
submission.setUpdatedAt(LocalDateTime.now());
submissionRepository.save(submission);
}
@@ -472,7 +470,7 @@ public class ProductSubmissionService {
payload.origin = submission.getOrigin();
payload.categoryId = submission.getCategoryId();
// 单位字段已移除
payload.dedupeKey = submission.getDedupeKey();
// 不再透传 dedupeKey
payload.safeMin = submission.getSafeMin();
payload.safeMax = submission.getSafeMax();
payload.remark = submission.getRemarkText();

View File

@@ -1,3 +1,6 @@
# Fuzzy search global configuration
search.fuzzy.enabled=true
search.fuzzy.default-tolerance=1.0
spring.application.name=demo
# 数据源配置(通过环境变量注入,避免硬编码)

View File

@@ -226,3 +226,35 @@
2025-09-29 21:30:25.754 | INFO | __main__ | 启动 FastAPI 服务器: 127.0.0.1:8000
2025-09-29 21:30:25.756 | DEBUG | asyncio | Using proactor: IocpProactor
2025-09-29 21:30:25.775 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]}
2025-09-29 21:51:40.924 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]}
2025-09-29 21:51:40.926 | INFO | __main__ | 启动 FastAPI 服务器: 127.0.0.1:8000
2025-09-29 21:51:40.927 | DEBUG | asyncio | Using proactor: IocpProactor
2025-09-29 21:51:40.952 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]}
2025-09-29 22:10:05.539 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]}
2025-09-29 22:10:05.541 | INFO | __main__ | 启动 FastAPI 服务器: 127.0.0.1:8000
2025-09-29 22:10:05.542 | DEBUG | asyncio | Using proactor: IocpProactor
2025-09-29 22:10:05.564 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]}
2025-09-29 22:16:24.106 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]}
2025-09-29 22:16:24.107 | INFO | __main__ | 启动 FastAPI 服务器: 127.0.0.1:8000
2025-09-29 22:16:24.108 | DEBUG | asyncio | Using proactor: IocpProactor
2025-09-29 22:16:24.128 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]}
2025-09-29 22:40:06.467 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]}
2025-09-29 22:40:06.469 | INFO | __main__ | 启动 FastAPI 服务器: 127.0.0.1:8000
2025-09-29 22:40:06.470 | DEBUG | asyncio | Using proactor: IocpProactor
2025-09-29 22:40:06.491 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]}
2025-09-29 22:47:48.473 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]}
2025-09-29 22:47:48.474 | INFO | __main__ | 启动 FastAPI 服务器: 127.0.0.1:8000
2025-09-29 22:47:48.477 | DEBUG | asyncio | Using proactor: IocpProactor
2025-09-29 22:47:48.497 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]}
2025-09-29 22:51:18.065 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]}
2025-09-29 22:51:18.066 | INFO | __main__ | 启动 FastAPI 服务器: 127.0.0.1:8000
2025-09-29 22:51:18.068 | DEBUG | asyncio | Using proactor: IocpProactor
2025-09-29 22:51:18.090 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]}
2025-09-29 23:25:18.836 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]}
2025-09-29 23:25:18.837 | INFO | __main__ | 启动 FastAPI 服务器: 127.0.0.1:8000
2025-09-29 23:25:18.839 | DEBUG | asyncio | Using proactor: IocpProactor
2025-09-29 23:25:18.861 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]}
2025-09-29 23:34:39.749 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]}
2025-09-29 23:34:39.750 | INFO | __main__ | 启动 FastAPI 服务器: 127.0.0.1:8000
2025-09-29 23:34:39.751 | DEBUG | asyncio | Using proactor: IocpProactor
2025-09-29 23:34:39.773 | DEBUG | EAN13Recognizer | 配置加载完成: preprocess={'resize_width': 1280, 'gaussian_blur_ksize': 3, 'morphology_kernel': 17, 'sobel_ksize': 3, 'binarize': 'otsu', 'close_kernel': 21}, roi={'min_area_ratio': 0.01, 'min_wh_ratio': 2.0, 'warp_target_height': 120, 'crop_bottom_ratio': 0.25}, decoder={'sample_rows': [0.35, 0.5, 0.65], 'total_modules': 95, 'guard_tolerance': 0.35, 'peak_valley_rel_threshold': 0.2, 'engine_order': ['pyzbar', 'ean13'], 'try_invert': True, 'rotations': [0, 90, 180, 270]}

View File

@@ -394,6 +394,8 @@
| unit | VARCHAR(32) | YES | | 单位(文本) |
| enum_options | JSON | YES | | 枚举项type=enum |
| searchable | TINYINT(1) | NOT NULL | 0 | 参与检索 |
| fuzzy_searchable | TINYINT(1) | NOT NULL | 0 | 可模糊查询(仅数值型) |
| fuzzy_tolerance | DECIMAL(18,6) | YES | | 容差NULL 使用平台默认 |
| dedupe_participate | TINYINT(1) | NOT NULL | 0 | 参与去重键 |
| sort_order | INT | NOT NULL | 0 | |
| created_at | TIMESTAMP | NOT NULL | CURRENT_TIMESTAMP | |

View File

@@ -230,7 +230,7 @@ paths:
/api/admin/parts:
get:
summary: 管理端-用户配件列表(✅ Fully Implemented
description: 返回商品列表;支持 shopId/kw 分页查询。
description: 返回商品列表;支持 shopId/kw 分页查询。自 0.2.1 起附带模板信息与模板参数JSON。
parameters:
- in: header
name: X-Admin-Id
@@ -270,7 +270,10 @@ paths:
name: { type: string }
brand: { type: string }
model: { type: string }
spec: { type: string }
spec: { type: string, nullable: true }
templateId: { type: integer, format: int64, nullable: true }
templateName: { type: string, nullable: true }
attributesJson: { type: string, description: '模板参数JSON字符串键值对如 {"内径":10,"长度":200}' }
images:
type: array
items: { type: string, description: '图片URL可能为绝对或以 / 开头的相对路径' }
@@ -455,8 +458,8 @@ paths:
createdAt: { type: string, format: date-time }
/api/admin/consults/{id}/reply:
post:
summary: 管理端-回复咨询(✅ Fully Implemented仅允许一次回复)
description: 当该咨询已存在回复时返回 409。
summary: 管理端-回复咨询(✅ Fully Implemented回复后自动标记已解决
description: 当该咨询已存在回复时返回 409。自 0.2.1 起:成功回复后,该咨询状态将自动更新为 resolved。
requestBody:
required: true
content:
@@ -467,7 +470,14 @@ paths:
content: { type: string }
required: [content]
responses:
'200': { description: 成功 }
'200':
description: 成功
content:
application/json:
schema:
type: object
properties:
status: { type: string, enum: [open, resolved, closed] }
'409': { description: 该咨询已被回复 }
/api/admin/consults/{id}/resolve:
put:
@@ -1173,7 +1183,7 @@ paths:
/api/products:
get:
summary: 商品搜索(✅ Fully Implemented
description: 支持 kw/page/size/categoryId/templateId 以及模板参数过滤。模板参数以 param_ 前缀传入,如 param_颜色=黑、param_内径=10后端对 JSON attributes 进行 LIKE 匹配(字符串化),多个参数为 AND 关系。返回 {list:[]} 以兼容前端。
description: 支持 kw/page/size/categoryId/templateId 以及模板参数过滤。模板参数以 param_ 前缀传入,如 param_颜色=黑、param_内径=10当某字段在其对应模板中配置为可模糊查询(仅 number 类型)时,按 [v-tol, v+tol]下限截断为0进行行级匹配否则等值。未提供 templateId 亦会启用行级模糊(按每行 template_id 判定)。返回 {list:[]} 以兼容前端。
parameters:
- in: query
name: kw
@@ -2546,6 +2556,8 @@ paths:
unit: { type: string, nullable: true }
enumOptions: { type: array, items: { type: string }, nullable: true }
searchable: { type: boolean }
fuzzySearchable: { type: boolean, description: '仅 type=number 生效' }
fuzzyTolerance: { type: number, nullable: true, description: '容差;为空使用平台默认' }
dedupeParticipate: { type: boolean }
sortOrder: { type: integer }
responses: { '200': { description: 成功 } }
@@ -2597,6 +2609,8 @@ paths:
unit: { type: string, nullable: true }
enumOptions: { type: array, items: { type: string }, nullable: true }
searchable: { type: boolean }
fuzzySearchable: { type: boolean, description: '仅 type=number 生效' }
fuzzyTolerance: { type: number, nullable: true, description: '容差;为空使用平台默认' }
dedupeParticipate: { type: boolean }
sortOrder: { type: integer }
responses: { '200': { description: 成功 } }

View File

@@ -54,7 +54,9 @@
<text v-else-if="it.sourceSubmissionId" class="tag-custom">我的提交</text>
</view>
<view class="meta">{{ it.brand || '-' }} {{ it.model || '' }} {{ it.spec || '' }}</view>
<!-- 隐藏库存与价格展示按需求仅展示基础信息 -->
<view class="card-params" v-if="it.cardParams && Object.keys(it.cardParams).length">
<view class="param" v-for="(v,k) in it.cardParams" :key="k">{{ k }}{{ v }}</view>
</view>
</view>
</view>
</block>
@@ -235,6 +237,8 @@ export default {
.tag-custom { font-size: 22rpx; color:#fff; background:#67c23a; padding: 4rpx 10rpx; border-radius: 8rpx; }
.tag-deleted { font-size: 22rpx; color:#fff; background:#909399; padding: 4rpx 10rpx; border-radius: 8rpx; }
.meta { color:$uni-text-color-grey; font-size: 24rpx; }
.card-params { display:flex; flex-wrap:wrap; gap: 8rpx 16rpx; margin-top: 8rpx; }
.card-params .param { color:$uni-text-color-grey; font-size: 22rpx; background:$uni-bg-color-grey; padding: 2rpx 6rpx; border-radius: 8rpx; }
.price { margin-left: 20rpx; color:$uni-color-primary; }
.empty { height: 60vh; display:flex; align-items:center; justify-content:center; color:$uni-text-color-grey; }
.fab { position: fixed; right: 30rpx; bottom: 120rpx; width: 100rpx; height: 100rpx; background:$uni-color-primary; color:#fff; border-radius: 50rpx; text-align:center; line-height: 100rpx; font-size: 48rpx; box-shadow: 0 8rpx 20rpx rgba(0,0,0,0.15); }

View File

@@ -132,8 +132,9 @@ export default {
.block-title { font-size: 28rpx; font-weight: 600; color: #2d3a4a; margin-bottom: 12rpx; }
.placeholder { font-size: 26rpx; color: #7a8899; }
.params { display: flex; flex-direction: column; gap: 12rpx; }
.param { display: flex; justify-content: space-between; font-size: 26rpx; color: #2d3a4a; }
.param { display: flex; justify-content: flex-start; align-items: center; gap: 16rpx; font-size: 26rpx; color: #2d3a4a; }
.param-key { color: #7a8899; }
.param-val { text-align: left; }
.images { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12rpx; }
.image { width: 100%; height: 200rpx; border-radius: 16rpx; background: #f0f2f5; }
.footer { display: flex; justify-content: flex-end; gap: 20rpx; }

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -2176,7 +2176,29 @@ if (uni.restoreGlobal) {
1
/* TEXT */
),
vue.createCommentVNode(" 隐藏库存与价格展示,按需求仅展示基础信息 ")
it.cardParams && Object.keys(it.cardParams).length ? (vue.openBlock(), vue.createElementBlock("view", {
key: 0,
class: "card-params"
}, [
(vue.openBlock(true), vue.createElementBlock(
vue.Fragment,
null,
vue.renderList(it.cardParams, (v, k) => {
return vue.openBlock(), vue.createElementBlock(
"view",
{
class: "param",
key: k
},
vue.toDisplayString(k) + "" + vue.toDisplayString(v),
1
/* TEXT */
);
}),
128
/* KEYED_FRAGMENT */
))
])) : vue.createCommentVNode("v-if", true)
])
], 8, ["onClick"]);
}),

View File

@@ -136,6 +136,19 @@
color: #444;
font-size: 0.75rem;
}
.card-params {
display: flex;
flex-wrap: wrap;
gap: 0.25rem 0.5rem;
margin-top: 0.25rem;
}
.card-params .param {
color: #444;
font-size: 0.6875rem;
background: #ffffff;
padding: 0.0625rem 0.1875rem;
border-radius: 0.25rem;
}
.price {
margin-left: 0.625rem;
color: #4C8DFF;

View File

@@ -92,13 +92,18 @@
}
.param {
display: flex;
justify-content: space-between;
justify-content: flex-start;
align-items: center;
gap: 0.5rem;
font-size: 0.8125rem;
color: #2d3a4a;
}
.param-key {
color: #7a8899;
}
.param-val {
text-align: left;
}
.images {
display: grid;
grid-template-columns: repeat(3, 1fr);

View File

@@ -266,8 +266,18 @@ function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
g: common_vendor.t(it.brand || "-"),
h: common_vendor.t(it.model || ""),
i: common_vendor.t(it.spec || ""),
j: it.id,
k: common_vendor.o(($event) => $options.openDetail(it.id), it.id)
j: it.cardParams && Object.keys(it.cardParams).length
}, it.cardParams && Object.keys(it.cardParams).length ? {
k: common_vendor.f(it.cardParams, (v, k, i1) => {
return {
a: common_vendor.t(k),
b: common_vendor.t(v),
c: k
};
})
} : {}, {
l: it.id,
m: common_vendor.o(($event) => $options.openDetail(it.id), it.id)
});
})
} : {}, {

View File

@@ -1 +1 @@
<view class="page"><view class="tabs"><view class="{{['tab', a && 'active']}}" bindtap="{{b}}">全部</view><view class="{{['tab', c && 'active']}}" bindtap="{{d}}">查询</view><view class="tab extra" bindtap="{{e}}">我的提交</view></view><view wx:if="{{f}}" class="{{['search', x && 'template-mode']}}"><view class="mode"><picker mode="selector" range="{{h}}" bindchange="{{i}}"><view class="picker">{{g}}</view></picker></view><block wx:if="{{j}}"><input placeholder="输入名称/条码/规格查询" bindconfirm="{{k}}" value="{{l}}" bindinput="{{m}}"/></block><block wx:if="{{n}}"><view class="picker-row"><picker mode="selector" range="{{p}}" bindchange="{{q}}"><view class="picker">{{o}}</view></picker><picker mode="selector" range="{{s}}" bindchange="{{t}}"><view class="picker">{{r}}</view></picker></view><view class="params-wrap"><view wx:for="{{v}}" wx:for-item="p" wx:key="w" class="param-row"><input wx:if="{{p.a}}" placeholder="{{p.b}}" value="{{p.c}}" bindinput="{{p.d}}"/><input wx:elif="{{p.e}}" type="number" placeholder="{{p.f}}" value="{{p.g}}" bindinput="{{p.h}}"/><switch wx:elif="{{p.i}}" checked="{{p.j}}" bindchange="{{p.k}}"/><picker wx:elif="{{p.l}}" mode="selector" range="{{p.n}}" bindchange="{{p.o}}"><view class="picker">{{p.m}}</view></picker><picker wx:elif="{{p.p}}" mode="date" bindchange="{{p.r}}"><view class="picker">{{p.q}}</view></picker><input wx:else placeholder="{{p.s}}" value="{{p.t}}" bindinput="{{p.v}}"/></view></view></block><button size="mini" bindtap="{{w}}">查询</button></view><scroll-view scroll-y class="list" bindscrolltolower="{{A}}"><block wx:if="{{y}}"><view wx:for="{{z}}" wx:for-item="it" wx:key="j" class="item" bindtap="{{it.k}}"><image wx:if="{{it.a}}" src="{{it.b}}" class="thumb" mode="aspectFill"/><view class="content"><view class="name"><text>{{it.c}}</text><text wx:if="{{it.d}}" class="tag-deleted">已删除</text><text wx:if="{{it.e}}" class="tag-platform">平台推荐</text><text wx:elif="{{it.f}}" class="tag-custom">我的提交</text></view><view class="meta">{{it.g}} {{it.h}} {{it.i}}</view></view></view></block><view wx:else class="empty"><text>暂无数据,点击右上角“+”新增</text></view></scroll-view></view>
<view class="page"><view class="tabs"><view class="{{['tab', a && 'active']}}" bindtap="{{b}}">全部</view><view class="{{['tab', c && 'active']}}" bindtap="{{d}}">查询</view><view class="tab extra" bindtap="{{e}}">我的提交</view></view><view wx:if="{{f}}" class="{{['search', x && 'template-mode']}}"><view class="mode"><picker mode="selector" range="{{h}}" bindchange="{{i}}"><view class="picker">{{g}}</view></picker></view><block wx:if="{{j}}"><input placeholder="输入名称/条码/规格查询" bindconfirm="{{k}}" value="{{l}}" bindinput="{{m}}"/></block><block wx:if="{{n}}"><view class="picker-row"><picker mode="selector" range="{{p}}" bindchange="{{q}}"><view class="picker">{{o}}</view></picker><picker mode="selector" range="{{s}}" bindchange="{{t}}"><view class="picker">{{r}}</view></picker></view><view class="params-wrap"><view wx:for="{{v}}" wx:for-item="p" wx:key="w" class="param-row"><input wx:if="{{p.a}}" placeholder="{{p.b}}" value="{{p.c}}" bindinput="{{p.d}}"/><input wx:elif="{{p.e}}" type="number" placeholder="{{p.f}}" value="{{p.g}}" bindinput="{{p.h}}"/><switch wx:elif="{{p.i}}" checked="{{p.j}}" bindchange="{{p.k}}"/><picker wx:elif="{{p.l}}" mode="selector" range="{{p.n}}" bindchange="{{p.o}}"><view class="picker">{{p.m}}</view></picker><picker wx:elif="{{p.p}}" mode="date" bindchange="{{p.r}}"><view class="picker">{{p.q}}</view></picker><input wx:else placeholder="{{p.s}}" value="{{p.t}}" bindinput="{{p.v}}"/></view></view></block><button size="mini" bindtap="{{w}}">查询</button></view><scroll-view scroll-y class="list" bindscrolltolower="{{A}}"><block wx:if="{{y}}"><view wx:for="{{z}}" wx:for-item="it" wx:key="l" class="item" bindtap="{{it.m}}"><image wx:if="{{it.a}}" src="{{it.b}}" class="thumb" mode="aspectFill"/><view class="content"><view class="name"><text>{{it.c}}</text><text wx:if="{{it.d}}" class="tag-deleted">已删除</text><text wx:if="{{it.e}}" class="tag-platform">平台推荐</text><text wx:elif="{{it.f}}" class="tag-custom">我的提交</text></view><view class="meta">{{it.g}} {{it.h}} {{it.i}}</view><view wx:if="{{it.j}}" class="card-params"><view wx:for="{{it.k}}" wx:for-item="v" wx:key="c" class="param">{{v.a}}{{v.b}}</view></view></view></view></block><view wx:else class="empty"><text>暂无数据,点击右上角“+”新增</text></view></scroll-view></view>

View File

@@ -136,6 +136,19 @@
color: #444;
font-size: 24rpx;
}
.card-params {
display: flex;
flex-wrap: wrap;
gap: 8rpx 16rpx;
margin-top: 8rpx;
}
.card-params .param {
color: #444;
font-size: 22rpx;
background: #ffffff;
padding: 2rpx 6rpx;
border-radius: 8rpx;
}
.price {
margin-left: 20rpx;
color: #4C8DFF;

View File

@@ -92,13 +92,18 @@
}
.param {
display: flex;
justify-content: space-between;
justify-content: flex-start;
align-items: center;
gap: 16rpx;
font-size: 26rpx;
color: #2d3a4a;
}
.param-key {
color: #7a8899;
}
.param-val {
text-align: left;
}
.images {
display: grid;
grid-template-columns: repeat(3, 1fr);