2
This commit is contained in:
@@ -3,6 +3,7 @@ import axios from 'axios'
|
||||
const USE_MOCK = String(import.meta.env.VITE_USE_MOCK || 'false').toLowerCase() === 'true'
|
||||
const storageBase = ((): string => { try { return localStorage.getItem('API_BASE_URL') || '' } catch { return '' } })()
|
||||
const API_BASE_URL = (storageBase || import.meta.env.VITE_APP_API_BASE_URL || 'http://127.0.0.1:8080').replace(/\/$/, '')
|
||||
export const API_BASE = API_BASE_URL
|
||||
const SHOP_ID = ((): number => {
|
||||
try { const v = localStorage.getItem('SHOP_ID'); if (v) return Number(v) } catch {}
|
||||
return Number(import.meta.env.VITE_APP_SHOP_ID || '1')
|
||||
@@ -17,11 +18,20 @@ export const http = axios.create({ baseURL: API_BASE_URL, timeout: 15000 })
|
||||
http.interceptors.request.use(cfg => {
|
||||
cfg.headers = cfg.headers || {}
|
||||
if (!cfg.headers['X-Shop-Id']) cfg.headers['X-Shop-Id'] = String(SHOP_ID)
|
||||
cfg.headers['Accept'] = cfg.headers['Accept'] || 'application/json,application/octet-stream'
|
||||
const uid = ((): string => {
|
||||
try { const v = localStorage.getItem('USER_ID') || '' ; if (v) return v } catch {}
|
||||
return String((import.meta as any).env?.VITE_ADMIN_USER_ID || '')
|
||||
})()
|
||||
if (uid) cfg.headers['X-User-Id'] = uid
|
||||
// 管理端接口需要 X-Admin-Id 头
|
||||
const aid = ((): string => {
|
||||
try { const v = localStorage.getItem('ADMIN_ID') || '' ; if (v) return v } catch {}
|
||||
return String((import.meta as any).env?.VITE_ADMIN_ID || '')
|
||||
})()
|
||||
if (aid) cfg.headers['X-Admin-Id'] = aid
|
||||
else if (uid) cfg.headers['X-Admin-Id'] = uid
|
||||
else cfg.headers['X-Admin-Id'] = String(SHOP_ID)
|
||||
return cfg
|
||||
})
|
||||
|
||||
@@ -36,4 +46,12 @@ export function post<T>(url: string, body?: any) { return http.post<T>(url, body
|
||||
export function put<T>(url: string, body?: any) { return http.put<T>(url, body).then(r => r.data) }
|
||||
export function del<T>(url: string, body?: any) { return http.delete<T>(url, { data: body }).then(r => r.data) }
|
||||
|
||||
export function withBaseUrl(u: string): string {
|
||||
if (!u) return u
|
||||
const s = String(u)
|
||||
if (/^(?:https?:)?\/\//i.test(s) || s.startsWith('data:')) return s
|
||||
if (s.startsWith('/')) return API_BASE_URL + s
|
||||
return API_BASE_URL + '/' + s
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -5,6 +5,17 @@ import './styles/theme.scss'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
// 自动注入管理员身份(仅当本地未设置时)
|
||||
try {
|
||||
const k = 'USER_ID'
|
||||
const existed = localStorage.getItem(k)
|
||||
if (!existed || !existed.trim()) {
|
||||
// 可被环境变量覆盖:VITE_ADMIN_USER_ID
|
||||
const envUid = String((import.meta as any).env?.VITE_ADMIN_USER_ID || '')
|
||||
localStorage.setItem(k, envUid || '1')
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(ElementPlus)
|
||||
app.use(router)
|
||||
|
||||
@@ -6,12 +6,15 @@ const routes: RouteRecordRaw[] = [
|
||||
path: '/',
|
||||
component: Shell,
|
||||
children: [
|
||||
{ path: '', redirect: '/vip-review' },
|
||||
{ path: 'vip-review', component: () => import('../views/vip/VipReview.vue') },
|
||||
{ path: 'vip', component: () => import('../views/vip/VipList.vue') },
|
||||
{ path: '', redirect: '/vip/system' },
|
||||
{ path: 'vip/system', component: () => import('../views/vip/VipSystem.vue') },
|
||||
{ path: 'vip/list', component: () => import('../views/vip/VipList.vue') },
|
||||
{ path: 'users', component: () => import('../views/users/UserList.vue') },
|
||||
{ path: 'parts', component: () => import('../views/parts/UserParts.vue') },
|
||||
{ path: 'parts/submissions', component: () => import('../views/parts/Submissions.vue') },
|
||||
{ path: 'parts/templates', component: () => import('../views/parts/Templates.vue') },
|
||||
{ path: 'consult', component: () => import('../views/consult/ConsultList.vue') }
|
||||
,{ path: 'notice/list', component: () => import('../views/notice/NoticeList.vue') }
|
||||
,{ path: 'dict/units', component: () => import('../views/dict/Units.vue') }
|
||||
,{ path: 'dict/categories', component: () => import('../views/dict/Categories.vue') }
|
||||
]
|
||||
|
||||
@@ -6,11 +6,14 @@
|
||||
<span class="name">配件管家·管理端</span>
|
||||
</div>
|
||||
<el-menu :default-active="active" router background-color="transparent" text-color="var(--muted-color)" active-text-color="var(--primary-royal)">
|
||||
<el-menu-item index="/vip-review"><i class="el-icon-star-off"></i><span>VIP审核</span></el-menu-item>
|
||||
<el-menu-item index="/vip"><i class="el-icon-star-on"></i><span>VIP管理</span></el-menu-item>
|
||||
<el-menu-item index="/vip/system"><i class="el-icon-setting"></i><span>VIP系统</span></el-menu-item>
|
||||
<el-menu-item index="/vip/list"><i class="el-icon-star-on"></i><span>VIP列表</span></el-menu-item>
|
||||
<el-menu-item index="/users"><i class="el-icon-user"></i><span>用户管理</span></el-menu-item>
|
||||
<el-menu-item index="/parts"><i class="el-icon-box"></i><span>用户配件管理</span></el-menu-item>
|
||||
<el-menu-item index="/parts/submissions"><i class="el-icon-finished"></i><span>配件审核</span></el-menu-item>
|
||||
<el-menu-item index="/parts/templates"><i class="el-icon-collection"></i><span>模板管理</span></el-menu-item>
|
||||
<el-menu-item index="/consult"><i class="el-icon-message"></i><span>咨询回复</span></el-menu-item>
|
||||
<el-menu-item index="/notice/list"><i class="el-icon-notebook-1"></i><span>公告管理</span></el-menu-item>
|
||||
<el-menu-item index="/dict/units"><i class="el-icon-collection"></i><span>主单位</span></el-menu-item>
|
||||
<el-menu-item index="/dict/categories"><i class="el-icon-collection-tag"></i><span>主类别</span></el-menu-item>
|
||||
</el-menu>
|
||||
|
||||
@@ -20,19 +20,35 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, onMounted, ref, watch, watchEffect } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { get } from '../../../api/http'
|
||||
|
||||
interface Props {
|
||||
isVip: boolean
|
||||
vipStart?: string | Date | null
|
||||
vipEnd?: string | Date | null
|
||||
placeholder?: string
|
||||
const isVip = ref(false)
|
||||
const vipStart = ref<string | Date | null>('')
|
||||
const vipEnd = ref<string | Date | null>('')
|
||||
const placeholder = ref('-')
|
||||
|
||||
const props = withDefaults(defineProps<{ userId?: number }>(), { userId: undefined })
|
||||
const route = useRoute()
|
||||
|
||||
async function loadVipByUser(userId?: number){
|
||||
try {
|
||||
if (!userId) return
|
||||
const data = await get(`/api/admin/vips/user/${userId}`)
|
||||
if (data && Object.keys(data).length > 0) {
|
||||
const active = Number(data.isVip || 0) === 1 && Number(data.status || 0) === 1 && (!!data.expireAt ? new Date(data.expireAt).getTime() >= Date.now() : true)
|
||||
isVip.value = active
|
||||
vipStart.value = data.createdAt || ''
|
||||
vipEnd.value = data.expireAt || ''
|
||||
} else {
|
||||
isVip.value = false
|
||||
vipStart.value = ''
|
||||
vipEnd.value = ''
|
||||
}
|
||||
} catch(e) { isVip.value = false }
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
placeholder: '-'
|
||||
})
|
||||
|
||||
function toDisplay(value?: string | Date | null, placeholder = '-') {
|
||||
if (value === null || value === undefined || value === '') return placeholder
|
||||
if (value instanceof Date) {
|
||||
@@ -46,8 +62,23 @@ function toDisplay(value?: string | Date | null, placeholder = '-') {
|
||||
return String(value)
|
||||
}
|
||||
|
||||
const displayStart = computed(() => toDisplay(props.vipStart, props.placeholder))
|
||||
const displayEnd = computed(() => toDisplay(props.vipEnd, props.placeholder))
|
||||
const displayStart = computed(() => toDisplay(vipStart.value, placeholder.value))
|
||||
const displayEnd = computed(() => toDisplay(vipEnd.value, placeholder.value))
|
||||
|
||||
function resolveUserId(): number | undefined {
|
||||
try {
|
||||
if (props.userId) return Number(props.userId)
|
||||
const qId = (route.query?.userId as any) || (route.params?.userId as any)
|
||||
if (qId) return Number(qId)
|
||||
const ls = ((): number | undefined => { try { const v = localStorage.getItem('USER_ID') || ''; return v?Number(v):undefined } catch { return undefined } })()
|
||||
return ls
|
||||
} catch { return undefined }
|
||||
}
|
||||
|
||||
onMounted(() => { const id = resolveUserId(); if (id) loadVipByUser(id) })
|
||||
watch(() => [props.userId, route.fullPath], () => { const id = resolveUserId(); if (id) loadVipByUser(id) })
|
||||
|
||||
defineExpose({ loadVipByUser })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<el-select v-model="q.status" style="width:140px"><el-option label="未解决" value="open" /><el-option label="已解决" value="resolved" /></el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="关键词">
|
||||
<el-input v-model="q.kw" placeholder="主题/内容/用户ID" clearable />
|
||||
<el-input v-model="q.kw" placeholder="内容/用户ID" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="fetch">查询</el-button>
|
||||
@@ -19,15 +19,16 @@
|
||||
<el-table :data="rows" size="large" @row-dblclick="openReply">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="userId" label="用户ID" width="100" />
|
||||
<el-table-column prop="topic" label="主题" width="220" />
|
||||
<el-table-column prop="message" label="内容" />
|
||||
<el-table-column prop="replyContent" label="回复内容" />
|
||||
<el-table-column label="状态" width="120">
|
||||
<template #default="{row}"><el-tag :type="row.status==='resolved'?'success':'warning'">{{ row.status==='resolved'?'已解决':'未解决' }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column fixed="right" label="操作" width="220">
|
||||
<template #default="{row}">
|
||||
<el-button size="small" type="primary" @click="openReply(row)">回复</el-button>
|
||||
<el-button size="small" type="primary" @click="openReply(row)" :disabled="row.status==='resolved'">回复</el-button>
|
||||
<el-button size="small" @click="resolve(row)" v-if="row.status!=='resolved'">标记解决</el-button>
|
||||
<el-button size="small" v-if="row.replyContent" @click="viewReply(row)">查看回复</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -47,6 +48,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import { get, post, put } from '../../api/http'
|
||||
|
||||
const q = reactive({ status: 'open', kw: '' })
|
||||
@@ -64,6 +66,7 @@ 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 resolve(row: any){ await put(`/api/admin/consults/${row.id}/resolve`, {}); row.status = 'resolved' }
|
||||
function viewReply(row: any){ ElMessageBox.alert(row.replyContent || '无', '已回复内容', { confirmButtonText: '知道了' }) }
|
||||
|
||||
onMounted(fetch)
|
||||
</script>
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { get, post, put, del } from '../../api/http'
|
||||
|
||||
type Category = { id: number; name: string }
|
||||
@@ -38,28 +39,43 @@ async function reload() {
|
||||
try {
|
||||
const res = await get<any>('/api/product-categories')
|
||||
state.list = Array.isArray(res?.list) ? res.list : (Array.isArray(res) ? res : [])
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '加载失败')
|
||||
} finally { state.loading = false }
|
||||
}
|
||||
|
||||
async function create() {
|
||||
if (!state.newName.trim()) return
|
||||
if (!state.newName || !state.newName.trim()) { ElMessage.warning('请输入名称'); return }
|
||||
state.loading = true
|
||||
try {
|
||||
await post('/api/admin/dicts/categories', { name: state.newName.trim() })
|
||||
state.newName = ''
|
||||
await reload()
|
||||
ElMessage.success('已新增')
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '新增失败')
|
||||
} finally { state.loading = false }
|
||||
}
|
||||
|
||||
async function update(row: Category) {
|
||||
if (!row?.id || !row.name?.trim()) return
|
||||
await put(`/api/admin/dicts/categories/${row.id}`, { name: row.name.trim() })
|
||||
try {
|
||||
await put(`/api/admin/dicts/categories/${row.id}`, { name: row.name.trim() })
|
||||
ElMessage.success('已保存')
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(row: Category) {
|
||||
if (!row?.id) return
|
||||
await del(`/api/admin/dicts/categories/${row.id}`)
|
||||
await reload()
|
||||
try {
|
||||
await del(`/api/admin/dicts/categories/${row.id}`)
|
||||
await reload()
|
||||
ElMessage.success('已删除')
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(reload)
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { get, post, put, del } from '../../api/http'
|
||||
|
||||
type Unit = { id: number; name: string }
|
||||
@@ -38,28 +39,43 @@ async function reload() {
|
||||
try {
|
||||
const res = await get<any>('/api/product-units')
|
||||
state.list = Array.isArray(res?.list) ? res.list : (Array.isArray(res) ? res : [])
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '加载失败')
|
||||
} finally { state.loading = false }
|
||||
}
|
||||
|
||||
async function create() {
|
||||
if (!state.newName.trim()) return
|
||||
if (!state.newName || !state.newName.trim()) { ElMessage.warning('请输入名称'); return }
|
||||
state.loading = true
|
||||
try {
|
||||
await post('/api/admin/dicts/units', { name: state.newName.trim() })
|
||||
state.newName = ''
|
||||
await reload()
|
||||
ElMessage.success('已新增')
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '新增失败')
|
||||
} finally { state.loading = false }
|
||||
}
|
||||
|
||||
async function update(row: Unit) {
|
||||
if (!row?.id || !row.name?.trim()) return
|
||||
await put(`/api/admin/dicts/units/${row.id}`, { name: row.name.trim() })
|
||||
try {
|
||||
await put(`/api/admin/dicts/units/${row.id}`, { name: row.name.trim() })
|
||||
ElMessage.success('已保存')
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(row: Unit) {
|
||||
if (!row?.id) return
|
||||
await del(`/api/admin/dicts/units/${row.id}`)
|
||||
await reload()
|
||||
try {
|
||||
await del(`/api/admin/dicts/units/${row.id}`)
|
||||
await reload()
|
||||
ElMessage.success('已删除')
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(reload)
|
||||
|
||||
201
admin/src/views/notice/NoticeList.vue
Normal file
201
admin/src/views/notice/NoticeList.vue
Normal file
@@ -0,0 +1,201 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-title"><span class="royal">📢</span> 公告管理</div>
|
||||
|
||||
<div class="panel" style="padding: 12px; margin-bottom: 12px;">
|
||||
<el-form :inline="true" :model="q">
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="q.status" placeholder="全部" clearable style="width: 160px;">
|
||||
<el-option label="全部" :value="''" />
|
||||
<el-option label="草稿" value="draft" />
|
||||
<el-option label="已发布" value="published" />
|
||||
<el-option label="已下线" value="offline" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-input v-model="q.kw" placeholder="标题/内容" clearable style="width: 240px;" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="fetchList">查询</el-button>
|
||||
<el-button @click="resetQuery">重置</el-button>
|
||||
</el-form-item>
|
||||
<el-form-item style="float:right; margin-left:auto;">
|
||||
<el-button type="success" @click="openEdit()">新建公告</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div class="panel" style="padding:0;">
|
||||
<el-table :data="rows" style="width:100%" size="large" :border="false">
|
||||
<el-table-column type="index" width="60" label="#" />
|
||||
<el-table-column prop="title" label="标题" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="tag" label="标签" width="120" />
|
||||
<el-table-column prop="pinned" label="置顶" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.pinned" type="warning">YES</el-tag>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="startsAt" label="开始" width="180" />
|
||||
<el-table-column prop="endsAt" label="结束" width="180" />
|
||||
<el-table-column prop="status" label="状态" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusTagType(row.status)">{{ statusLabel(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="280" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button size="small" type="primary" @click="publish(row)" :disabled="row.status==='published'">发布</el-button>
|
||||
<el-button size="small" type="warning" @click="offline(row)" :disabled="row.status==='offline'">下线</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="dlg.visible" :title="dlgTitle" width="680px">
|
||||
<el-form :model="form" label-width="100px">
|
||||
<el-form-item label="标题" required>
|
||||
<el-input v-model="form.title" maxlength="120" show-word-limit />
|
||||
</el-form-item>
|
||||
<el-form-item label="内容" required>
|
||||
<el-input v-model="form.content" type="textarea" :rows="4" maxlength="500" show-word-limit />
|
||||
</el-form-item>
|
||||
<el-form-item label="标签">
|
||||
<el-input v-model="form.tag" maxlength="32" />
|
||||
</el-form-item>
|
||||
<el-form-item label="置顶">
|
||||
<el-switch v-model="form.pinned" />
|
||||
</el-form-item>
|
||||
<el-form-item label="开始时间">
|
||||
<el-date-picker v-model="form.startsAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssXXX" placeholder="不填即刻生效" />
|
||||
</el-form-item>
|
||||
<el-form-item label="结束时间">
|
||||
<el-date-picker v-model="form.endsAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssXXX" placeholder="不填长期有效" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="form.status" style="width: 160px;">
|
||||
<el-option label="草稿" value="draft" />
|
||||
<el-option label="已发布" value="published" />
|
||||
<el-option label="已下线" value="offline" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dlg.visible=false">取消</el-button>
|
||||
<el-button type="primary" @click="save">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { get, post, put } from '../../api/http'
|
||||
|
||||
type Notice = {
|
||||
id?: number
|
||||
title: string
|
||||
content: string
|
||||
tag?: string
|
||||
pinned?: boolean
|
||||
startsAt?: string | null
|
||||
endsAt?: string | null
|
||||
status?: 'draft'|'published'|'offline'
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
const q = reactive({ status: '' as '' | 'draft' | 'published' | 'offline', kw: '' })
|
||||
const rows = ref<Notice[]>([])
|
||||
const dlg = reactive({ visible: false, editingId: 0 as number })
|
||||
const form = reactive<Notice>({ title: '', content: '', tag: '', pinned: false, status: 'draft', startsAt: '', endsAt: '' })
|
||||
|
||||
const dlgTitle = computed(() => dlg.editingId ? '编辑公告' : '新建公告')
|
||||
|
||||
function statusLabel(s?: string) {
|
||||
if (s === 'published') return '已发布'; if (s === 'offline') return '已下线'; return '草稿'
|
||||
}
|
||||
function statusTagType(s?: string) {
|
||||
if (s === 'published') return 'success'; if (s === 'offline') return 'info'; return 'warning'
|
||||
}
|
||||
|
||||
function resetQuery(){ q.status=''; q.kw=''; fetchList() }
|
||||
|
||||
async function fetchList(){
|
||||
const data = await get<any>('/api/admin/notices', { status: q.status || undefined, kw: q.kw || undefined })
|
||||
const list = Array.isArray(data?.list) ? data.list : (Array.isArray(data)?data:[])
|
||||
rows.value = list.map((n: any) => ({
|
||||
id: n.id,
|
||||
title: n.title,
|
||||
content: n.content,
|
||||
tag: n.tag,
|
||||
pinned: !!n.pinned,
|
||||
startsAt: n.startsAt ? toDateTimeString(n.startsAt) : '',
|
||||
endsAt: n.endsAt ? toDateTimeString(n.endsAt) : '',
|
||||
status: n.status,
|
||||
createdAt: toDateTimeString(n.createdAt),
|
||||
updatedAt: toDateTimeString(n.updatedAt)
|
||||
}))
|
||||
}
|
||||
|
||||
function openEdit(row?: Notice){
|
||||
dlg.editingId = row?.id || 0
|
||||
form.title = row?.title || ''
|
||||
form.content = row?.content || ''
|
||||
form.tag = row?.tag || ''
|
||||
form.pinned = !!row?.pinned
|
||||
form.startsAt = row?.startsAt || ''
|
||||
form.endsAt = row?.endsAt || ''
|
||||
form.status = (row?.status as any) || 'draft'
|
||||
dlg.visible = true
|
||||
}
|
||||
|
||||
async function save(){
|
||||
const payload: any = {
|
||||
title: (form.title||'').trim(),
|
||||
content: (form.content||'').trim(),
|
||||
tag: form.tag||'',
|
||||
pinned: !!form.pinned,
|
||||
startsAt: form.startsAt || null,
|
||||
endsAt: form.endsAt || null,
|
||||
status: form.status
|
||||
}
|
||||
if (!payload.title) { return window.alert('请输入标题') }
|
||||
if (!payload.content) { return window.alert('请输入内容') }
|
||||
if (dlg.editingId) await put(`/api/admin/notices/${dlg.editingId}`, payload)
|
||||
else await post('/api/admin/notices', payload)
|
||||
dlg.visible = false
|
||||
await fetchList()
|
||||
}
|
||||
|
||||
async function publish(row: Notice){
|
||||
if (!row.id) return
|
||||
await post(`/api/admin/notices/${row.id}/publish`)
|
||||
await fetchList()
|
||||
}
|
||||
async function offline(row: Notice){
|
||||
if (!row.id) return
|
||||
await post(`/api/admin/notices/${row.id}/offline`)
|
||||
await fetchList()
|
||||
}
|
||||
|
||||
function toDateTimeString(v: any): string {
|
||||
try {
|
||||
const d = new Date(v)
|
||||
if (Number.isNaN(d.getTime())) return ''
|
||||
const pad = (n:number)=> String(n).padStart(2,'0')
|
||||
const yyyy=d.getFullYear(), MM=pad(d.getMonth()+1), dd=pad(d.getDate()), hh=pad(d.getHours()), mm=pad(d.getMinutes()), ss=pad(d.getSeconds())
|
||||
return `${yyyy}-${MM}-${dd} ${hh}:${mm}:${ss}`
|
||||
} catch { return '' }
|
||||
}
|
||||
|
||||
onMounted(fetchList)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page { position: relative; z-index: 1; }
|
||||
</style>
|
||||
|
||||
|
||||
322
admin/src/views/parts/Submissions.vue
Normal file
322
admin/src/views/parts/Submissions.vue
Normal file
@@ -0,0 +1,322 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-title">配件审核</div>
|
||||
<div class="panel" style="padding:12px; margin-bottom:12px;">
|
||||
<el-form :inline="true" :model="query">
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="query.status" placeholder="全部" style="width:140px;">
|
||||
<el-option label="全部" value="" />
|
||||
<el-option label="待审核" value="pending" />
|
||||
<el-option label="已通过" value="approved" />
|
||||
<el-option label="已驳回" value="rejected" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-input v-model="query.kw" placeholder="型号/名称/品牌" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="提交时间">
|
||||
<el-date-picker v-model="query.range" type="daterange" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" value-format="YYYY-MM-DDTHH:mm:ss" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="fetchList">查询</el-button>
|
||||
<el-button @click="reset">重置</el-button>
|
||||
</el-form-item>
|
||||
<el-form-item style="margin-left:auto;">
|
||||
<el-button type="success" @click="exportExcel" :loading="exporting">导出Excel</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div class="panel" style="padding:0;">
|
||||
<el-table :data="rows" style="width:100%" :loading="loading">
|
||||
<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" />
|
||||
<el-table-column prop="brand" label="品牌" width="120" />
|
||||
<el-table-column prop="status" label="状态" width="120">
|
||||
<template #default="{row}">
|
||||
<el-tag :type="statusType(row.status)">{{ statusLabel(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="提交时间" width="180">
|
||||
<template #default="{row}">{{ formatDate(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="reviewedAt" label="审核时间" width="180">
|
||||
<template #default="{row}">{{ formatDate(row.reviewedAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="260" fixed="right">
|
||||
<template #default="{row}">
|
||||
<el-button size="small" @click="openDetail(row.id)">查看</el-button>
|
||||
<el-button size="small" type="primary" @click="approve(row.id)" :disabled="row.status!=='pending'">通过</el-button>
|
||||
<el-button size="small" type="danger" @click="openReject(row.id)" :disabled="row.status!=='pending'">驳回</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pager">
|
||||
<el-pagination
|
||||
background
|
||||
layout="prev, pager, next, jumper, ->, total"
|
||||
:total="total"
|
||||
:page-size="query.size"
|
||||
:current-page="query.page"
|
||||
@current-change="onPage"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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-form-item>
|
||||
<el-form-item label="图片">
|
||||
<div class="thumb-list">
|
||||
<div v-for="(img,idx) in detail.images" :key="idx" class="thumb">
|
||||
<el-image :src="withBase(img)" :preview-src-list="detail.images.map(withBase)" fit="cover" />
|
||||
<el-button type="text" @click="removeImage(idx)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-upload
|
||||
action="#"
|
||||
:http-request="uploadImage"
|
||||
:show-file-list="false"
|
||||
accept="image/*"
|
||||
>
|
||||
<el-button>上传图片</el-button>
|
||||
</el-upload>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="detail.visible=false">关闭</el-button>
|
||||
<el-button type="primary" @click="saveDetail" :loading="saving">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="reject.visible" title="驳回原因" width="420px">
|
||||
<el-input type="textarea" v-model="reject.reason" placeholder="请输入驳回原因" :rows="4" />
|
||||
<template #footer>
|
||||
<el-button @click="reject.visible=false">取消</el-button>
|
||||
<el-button type="danger" @click="doReject" :loading="reject.loading">确认驳回</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, onMounted, computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { get, post, put, withBaseUrl, http } from '../../api/http'
|
||||
|
||||
const query = reactive({ status: '', kw: '', range: [], page: 1, size: 20 })
|
||||
const rows = ref<any[]>([])
|
||||
const total = ref(0)
|
||||
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 paramRows = ref<any[]>([])
|
||||
const jsonPlaceholder = '{"key":"value"}'
|
||||
const saving = ref(false)
|
||||
|
||||
const reject = reactive({ visible: false, id: 0, reason: '', loading: false })
|
||||
|
||||
function statusLabel(status?: string) {
|
||||
if (status === 'approved') return '已通过'
|
||||
if (status === 'rejected') return '已驳回'
|
||||
return '待审核'
|
||||
}
|
||||
function statusType(status?: string) {
|
||||
if (status === 'approved') return 'success'
|
||||
if (status === 'rejected') return 'danger'
|
||||
return 'warning'
|
||||
}
|
||||
function formatDate(v?: string) {
|
||||
if (!v) return '-'
|
||||
try {
|
||||
const d = new Date(v)
|
||||
if (Number.isNaN(d.getTime())) return '-'
|
||||
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`
|
||||
} catch { return '-' }
|
||||
}
|
||||
function withBase(u: string){ return withBaseUrl(u) }
|
||||
|
||||
async function fetchList(){
|
||||
loading.value = true
|
||||
try {
|
||||
const params: any = { status: query.status || undefined, kw: query.kw || undefined, page: query.page, size: query.size }
|
||||
if (Array.isArray(query.range) && query.range.length === 2) {
|
||||
params.startAt = query.range[0]
|
||||
params.endAt = query.range[1]
|
||||
}
|
||||
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)
|
||||
} catch (e:any) {
|
||||
ElMessage.error(e.message || '加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
function reset(){
|
||||
query.status=''
|
||||
query.kw=''
|
||||
query.range=[]
|
||||
query.page=1
|
||||
fetchList()
|
||||
}
|
||||
function onPage(p:number){ query.page = p; fetchList() }
|
||||
|
||||
async function openDetail(id:number){
|
||||
try {
|
||||
const data = await get(`/api/admin/parts/submissions/${id}`)
|
||||
detail.visible = true
|
||||
detail.id = id
|
||||
detail.data = data
|
||||
detail.images = Array.isArray(data?.images) ? [...data.images] : []
|
||||
detail.parameterText = data?.parameters ? JSON.stringify(data.parameters, null, 2) : ''
|
||||
paramRows.value = []
|
||||
detail.template = null
|
||||
if (data?.templateId) {
|
||||
try {
|
||||
const t = await get(`/api/admin/part-templates/${data.templateId}`)
|
||||
detail.template = t
|
||||
const mp = data?.parameters || {}
|
||||
paramRows.value = (t?.params||[]).map((p:any)=>({ fieldLabel: p.fieldLabel, unit: p.unit, required: !!p.required, value: mp[p.fieldKey] }))
|
||||
} catch {}
|
||||
}
|
||||
} catch (e:any) {
|
||||
ElMessage.error(e.message || '加载失败')
|
||||
}
|
||||
}
|
||||
|
||||
function removeImage(idx:number){ detail.images.splice(idx,1) }
|
||||
async function uploadImage(options:any){
|
||||
try {
|
||||
const file = options.file
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
const res = await fetch(withBaseUrl('/api/attachments'), {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
if (!res.ok) throw new Error('上传失败')
|
||||
const data = await res.json()
|
||||
if (data && data.url) {
|
||||
detail.images.push(data.url)
|
||||
options.onSuccess(data, file)
|
||||
} else {
|
||||
throw new Error('上传失败')
|
||||
}
|
||||
} catch (e:any) {
|
||||
options.onError(e)
|
||||
ElMessage.error(e.message || '上传失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function saveDetail(){
|
||||
if (!detail.data) return
|
||||
saving.value = true
|
||||
try {
|
||||
let paramsObj: any = null
|
||||
if (detail.parameterText) {
|
||||
try { paramsObj = JSON.parse(detail.parameterText) } catch { return ElMessage.warning('参数JSON格式错误') }
|
||||
}
|
||||
await put(`/api/admin/parts/submissions/${detail.id}`, {
|
||||
name: detail.data.name,
|
||||
brand: detail.data.brand,
|
||||
spec: detail.data.spec,
|
||||
unitId: detail.data.unitId,
|
||||
categoryId: detail.data.categoryId,
|
||||
parameters: paramsObj,
|
||||
images: detail.images,
|
||||
remark: detail.data.remark,
|
||||
barcode: detail.data.barcode
|
||||
})
|
||||
ElMessage.success('保存成功')
|
||||
detail.visible=false
|
||||
fetchList()
|
||||
} catch (e:any) {
|
||||
ElMessage.error(e.message || '保存失败')
|
||||
} finally { saving.value=false }
|
||||
}
|
||||
|
||||
async function approve(id:number){
|
||||
try {
|
||||
await post(`/api/admin/parts/submissions/${id}/approve`, {})
|
||||
ElMessage.success('已通过')
|
||||
fetchList()
|
||||
} catch (e:any) { ElMessage.error(e.message || '操作失败') }
|
||||
}
|
||||
function openReject(id:number){ reject.visible=true; reject.id=id; reject.reason='' }
|
||||
async function doReject(){
|
||||
if (!reject.reason.trim()) return ElMessage.warning('请输入驳回原因')
|
||||
reject.loading=true
|
||||
try {
|
||||
await post(`/api/admin/parts/submissions/${reject.id}/reject`, { remark: reject.reason })
|
||||
reject.visible=false
|
||||
detail.visible=false
|
||||
ElMessage.success('已驳回')
|
||||
fetchList()
|
||||
} catch (e:any) {
|
||||
ElMessage.error(e.message || '操作失败')
|
||||
} finally { reject.loading=false }
|
||||
}
|
||||
|
||||
async function exportExcel(){
|
||||
exporting.value = true
|
||||
try {
|
||||
const params: any = { status: query.status || undefined, kw: query.kw || undefined }
|
||||
if (Array.isArray(query.range) && query.range.length === 2) {
|
||||
params.startAt = query.range[0]
|
||||
params.endAt = query.range[1]
|
||||
}
|
||||
const res = await http.get('/api/admin/parts/submissions/export', { params, responseType: 'blob' })
|
||||
const blob = res.data
|
||||
const link = document.createElement('a')
|
||||
link.href = URL.createObjectURL(blob)
|
||||
link.download = `配件审核_${Date.now()}.xlsx`
|
||||
link.click()
|
||||
URL.revokeObjectURL(link.href)
|
||||
} catch (e:any) { ElMessage.error(e.message || '导出失败') }
|
||||
finally { exporting.value=false }
|
||||
}
|
||||
|
||||
onMounted(fetchList)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page { position: relative; }
|
||||
.pager { padding: 12px; display:flex; justify-content:flex-end; }
|
||||
.detail-form { max-height: 60vh; overflow-y:auto; }
|
||||
.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; }
|
||||
</style>
|
||||
173
admin/src/views/parts/Templates.vue
Normal file
173
admin/src/views/parts/Templates.vue
Normal file
@@ -0,0 +1,173 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="header">
|
||||
<h2>模板管理</h2>
|
||||
<div>
|
||||
<el-button type="primary" @click="openCreate()">新建模板</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-form inline :model="query">
|
||||
<el-form-item label="分类">
|
||||
<el-select v-model="query.categoryId" clearable placeholder="全部分类" @change="load()" style="width:200px">
|
||||
<el-option v-for="c in categories" :key="c.id" :label="c.name" :value="c.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-table :data="list" style="width:100%" stripe>
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="name" label="模板名" />
|
||||
<el-table-column prop="categoryId" label="分类ID" width="100" />
|
||||
<el-table-column prop="modelRule" label="型号规则" />
|
||||
<el-table-column prop="status" label="状态" width="80">
|
||||
<template #default="{row}">
|
||||
<el-tag :type="row.status===1?'success':'info'">{{ row.status===1?'启用':'停用' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200">
|
||||
<template #default="{row}">
|
||||
<el-button type="primary" text @click="openEdit(row)">编辑</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog v-model="dlg.visible" :title="dlg.id? '编辑模板':'新建模板'" width="720">
|
||||
<el-form :model="form" label-width="100px">
|
||||
<el-form-item label="分类">
|
||||
<el-select v-model="form.categoryId" placeholder="选择分类">
|
||||
<el-option v-for="c in categories" :key="c.id" :label="c.name" :value="c.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="模板名">
|
||||
<el-input v-model="form.name" maxlength="120" />
|
||||
</el-form-item>
|
||||
<el-form-item label="型号规则">
|
||||
<el-input v-model="form.modelRule" placeholder="可填备注或正则" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-switch v-model="form.status" :active-value="1" :inactive-value="0" />
|
||||
</el-form-item>
|
||||
<el-divider>参数字段</el-divider>
|
||||
<div>
|
||||
<el-button size="small" @click="addParam()">新增字段</el-button>
|
||||
</div>
|
||||
<el-table :data="form.params" size="small" style="width:100%;margin-top:10px">
|
||||
<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="英文字母/下划线" /></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="名称">
|
||||
<template #default="{row}"><el-input v-model="row.fieldLabel" /></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="类型" width="120">
|
||||
<template #default="{row}">
|
||||
<el-select v-model="row.type" style="width:110px">
|
||||
<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-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="必填" width="80">
|
||||
<template #default="{row}"><el-switch v-model="row.required" /></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="单位" width="120">
|
||||
<template #default="{row}"><el-input v-model="row.unit" /></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="枚举项">
|
||||
<template #default="{row}"><el-input v-model="row.enumOptionsText" placeholder="逗号分隔(type=enum)" /></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="检索" width="80">
|
||||
<template #default="{row}"><el-switch v-model="row.searchable" /></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="去重" width="80">
|
||||
<template #default="{row}"><el-switch v-model="row.dedupeParticipate" /></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="90">
|
||||
<template #default="{ $index }"><el-button text type="danger" @click="removeParam($index)">删除</el-button></template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dlg.visible=false">取消</el-button>
|
||||
<el-button type="primary" @click="save()">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { http } from '../../api/http'
|
||||
|
||||
const list = ref<any[]>([])
|
||||
const categories = ref<any[]>([])
|
||||
const query = reactive({ categoryId: undefined as any })
|
||||
|
||||
const dlg = reactive({ visible: false, id: 0 })
|
||||
const form = reactive<any>({ id: 0, categoryId: undefined, name: '', modelRule: '', status: 1, params: [] as any[] })
|
||||
|
||||
function load() {
|
||||
http.get('/api/product-templates', { params: { categoryId: query.categoryId } }).then(res => {
|
||||
list.value = res.data.list || res.data || []
|
||||
})
|
||||
}
|
||||
function loadCategories() {
|
||||
http.get('/api/product-categories').then(res => {
|
||||
categories.value = res.data.list || res.data || []
|
||||
})
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
dlg.visible = true; dlg.id = 0
|
||||
Object.assign(form, { id:0, categoryId: undefined, name:'', modelRule:'', status:1, params:[] })
|
||||
}
|
||||
function openEdit(row:any) {
|
||||
dlg.visible = true; dlg.id = row.id
|
||||
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(',') })) })
|
||||
})
|
||||
}
|
||||
|
||||
function addParam() {
|
||||
form.params.push({ fieldKey:'', fieldLabel:'', type:'string', required:false, unit:'', enumOptionsText:'', searchable:false, dedupeParticipate:false, sortOrder:0 })
|
||||
}
|
||||
function removeParam(i:number) {
|
||||
form.params.splice(i,1)
|
||||
}
|
||||
function save() {
|
||||
// 前端校验:fieldKey/fieldLabel/类型/重复
|
||||
const seen = new Set<string>()
|
||||
for (const p of form.params) {
|
||||
const key = String(p.fieldKey||'').trim()
|
||||
const label = String(p.fieldLabel||'').trim()
|
||||
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 (seen.has(key)) { ElMessage.warning(`参数键重复: ${key}`); return }
|
||||
seen.add(key)
|
||||
}
|
||||
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 })) }
|
||||
const req = dlg.id ? http.put(`/api/admin/part-templates/${dlg.id}`, { ...payload, deleteAllRelatedProductsAndSubmissions: true })
|
||||
: http.post('/api/admin/part-templates', payload)
|
||||
req.then(()=>{ ElMessage.success('保存成功'); dlg.visible=false; load() })
|
||||
}
|
||||
|
||||
onMounted(()=>{ loadCategories(); load() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page { padding: 16px; }
|
||||
.header { display:flex; align-items:center; justify-content: space-between; margin-bottom: 12px; }
|
||||
</style>
|
||||
|
||||
|
||||
@@ -3,13 +3,6 @@
|
||||
<div class="page-title"><span class="royal">♛</span> 用户配件管理</div>
|
||||
<div class="panel" style="padding:12px; margin-bottom: 12px;">
|
||||
<el-form :inline="true" :model="q">
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="q.status" style="width:140px">
|
||||
<el-option label="待审" value="pending" />
|
||||
<el-option label="通过" value="approved" />
|
||||
<el-option label="驳回" value="rejected" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="关键词">
|
||||
<el-input v-model="q.kw" placeholder="品牌/型号/规格/用户ID" clearable />
|
||||
</el-form-item>
|
||||
@@ -29,19 +22,12 @@
|
||||
<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="img" fit="cover" style="width:48px;height:48px;border-radius:8px;" :preview-src-list="row.images" />
|
||||
<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)" />
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="140">
|
||||
<el-table-column fixed="right" 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="260">
|
||||
<template #default="{row}">
|
||||
<el-button size="small" type="warning" @click="blacklist(row)" v-if="row.status===1">拉黑</el-button>
|
||||
<el-button size="small" type="success" @click="restore(row)" v-else>恢复</el-button>
|
||||
<el-button size="small" @click="edit(row)">编辑</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -65,9 +51,9 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { get, put } from '../../api/http'
|
||||
import { get, put, withBaseUrl } from '../../api/http'
|
||||
|
||||
const q = reactive({ status: 'pending', kw: '' })
|
||||
const q = reactive({ kw: '' })
|
||||
const rows = ref<any[]>([])
|
||||
const visible = ref(false)
|
||||
const form = reactive<any>({})
|
||||
@@ -77,15 +63,12 @@ const imagesConcat = computed({
|
||||
})
|
||||
|
||||
async function fetch(){
|
||||
// 需要后端提供:GET /api/admin/parts?status=&kw=
|
||||
const data = await get<any>('/api/admin/parts', { status: q.status, kw: q.kw })
|
||||
const data = await get<any>('/api/admin/parts', { kw: q.kw })
|
||||
rows.value = Array.isArray(data?.list) ? data.list : (Array.isArray(data)?data:[])
|
||||
}
|
||||
function reset(){ q.status='pending'; q.kw=''; fetch() }
|
||||
function reset(){ q.kw=''; fetch() }
|
||||
function edit(row: any){ visible.value=true; Object.assign(form, JSON.parse(JSON.stringify(row))) }
|
||||
async function save(){ await put(`/api/admin/parts/${form.id}`, form); visible.value=false; await fetch() }
|
||||
async function blacklist(row: any){ await put(`/api/admin/parts/${row.id}/blacklist`, {}); await fetch() }
|
||||
async function restore(row: any){ await put(`/api/admin/parts/${row.id}/restore`, {}); await fetch() }
|
||||
|
||||
onMounted(fetch)
|
||||
</script>
|
||||
|
||||
91
admin/src/views/supplier/SupplierList.vue
Normal file
91
admin/src/views/supplier/SupplierList.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-title"><span class="royal">♛</span> 供应商管理</div>
|
||||
|
||||
<div class="panel" style="padding:12px; margin-bottom: 12px;">
|
||||
<el-form :inline="true" :model="q">
|
||||
<el-form-item label="关键词">
|
||||
<el-input v-model="q.kw" placeholder="名称/联系人/手机/电话" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-checkbox v-model="q.debtOnly">只看欠款</el-checkbox>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="fetch">查询</el-button>
|
||||
<el-button @click="reset">重置</el-button>
|
||||
<el-button type="success" @click="openCreate">新增供应商</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div class="panel" style="padding:0;">
|
||||
<el-table :data="rows" size="large">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="name" label="供应商名称" min-width="200" />
|
||||
<el-table-column prop="contactName" label="联系人" width="140" />
|
||||
<el-table-column prop="mobile" label="手机" width="160" />
|
||||
<el-table-column prop="phone" label="电话" width="160" />
|
||||
<el-table-column prop="address" label="经营地址" min-width="240" show-overflow-tooltip />
|
||||
<el-table-column label="应付款" width="140">
|
||||
<template #default="{row}">¥ {{ Number(row.apPayable||0).toFixed(2) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column fixed="right" label="操作" width="200">
|
||||
<template #default="{row}">
|
||||
<el-button size="small" @click="openEdit(row)">编辑</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="visible" :title="form.id ? '编辑供应商' : '新增供应商'" width="640">
|
||||
<el-form :model="form" label-width="120px">
|
||||
<el-form-item label="供应商名称"><el-input v-model="form.name" /></el-form-item>
|
||||
<el-form-item label="联系人"><el-input v-model="form.contactName" /></el-form-item>
|
||||
<el-form-item label="手机"><el-input v-model="form.mobile" /></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.address" /></el-form-item>
|
||||
<el-form-item label="初始应付款"><el-input v-model.number="form.apOpening" type="number" /></el-form-item>
|
||||
<el-form-item label="当前应付款"><el-input v-model.number="form.apPayable" type="number" /></el-form-item>
|
||||
<el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="200" /></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>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { get, post, put } from '../../api/http'
|
||||
|
||||
const q = reactive({ kw: '', debtOnly: false })
|
||||
const rows = ref<any[]>([])
|
||||
const visible = ref(false)
|
||||
const form = reactive<any>({ id: null, name:'', contactName:'', mobile:'', phone:'', address:'', apOpening:0, apPayable:0, remark:'' })
|
||||
|
||||
async function fetch(){
|
||||
const data = await get<any>('/api/suppliers', { kw: q.kw, debtOnly: q.debtOnly, page: 1, size: 100 })
|
||||
rows.value = Array.isArray(data?.list) ? data.list : (Array.isArray(data) ? data : [])
|
||||
}
|
||||
function reset(){ q.kw=''; q.debtOnly=false; fetch() }
|
||||
function openCreate(){ Object.assign(form, { id:null, name:'', contactName:'', mobile:'', phone:'', address:'', apOpening:0, apPayable:0, remark:'' }); visible.value=true }
|
||||
function openEdit(row: any){ Object.assign(form, row); visible.value=true }
|
||||
async function save(){
|
||||
if (!form.name) return (window as any).ElMessage?.warning?.('请填写供应商名称') || void 0
|
||||
if (form.id) await put(`/api/suppliers/${form.id}`, form)
|
||||
else await post('/api/suppliers', form)
|
||||
visible.value=false
|
||||
await fetch()
|
||||
}
|
||||
|
||||
onMounted(fetch)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page { position: relative; z-index: 1; }
|
||||
</style>
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<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-button type="success" @click="openEdit()">新增VIP</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
@@ -49,6 +49,8 @@
|
||||
<el-button type="primary" @click="save">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -86,6 +88,8 @@ async function toggle(row: any){
|
||||
await fetch()
|
||||
}
|
||||
|
||||
|
||||
|
||||
onMounted(fetch)
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-title"><span class="royal">♛</span> VIP审核</div>
|
||||
<div class="panel" style="padding:12px; margin-bottom: 12px;">
|
||||
<el-form :inline="true" :model="q">
|
||||
<el-form-item label="关键词">
|
||||
<el-input v-model="q.kw" placeholder="手机号/姓名/用户ID" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="fetch">查询</el-button>
|
||||
<el-button @click="reset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
<div class="panel" style="padding:16px;">
|
||||
<el-empty v-if="rows.length===0" description="暂无待审核" />
|
||||
<div v-else class="cards">
|
||||
<el-card v-for="it in rows" :key="it.id" class="vip-card" shadow="hover">
|
||||
<div class="vip-card__header">
|
||||
<div class="avatar">{{ (it.name||'用').slice(0,1) }}</div>
|
||||
<div class="meta">
|
||||
<div class="name">{{ it.name || ('用户#'+it.userId) }}</div>
|
||||
<div class="sub">ID {{ it.userId }} · {{ it.phone || '无手机号' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="vip-card__body">
|
||||
<div class="remark" :title="it.remark || ''">{{ it.remark || '无备注' }}</div>
|
||||
</div>
|
||||
<div class="vip-card__actions">
|
||||
<el-button size="small" type="success" @click="approve(it)">通过</el-button>
|
||||
<el-button size="small" type="warning" @click="reject(it)">驳回</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { get, post } from '../../api/http'
|
||||
|
||||
const q = reactive({ kw: '' })
|
||||
const rows = ref<any[]>([])
|
||||
|
||||
function levelLabel(v: string){
|
||||
const m: Record<string,string> = { gold: '黄金VIP', platinum: '铂金VIP', diamond: '钻石VIP' }
|
||||
return m[v] || v || 'VIP'
|
||||
}
|
||||
|
||||
async function fetch(){
|
||||
// 使用 /api/admin/vips?status=0 列出待审核(未启用)记录;phone 作为筛选
|
||||
const params: any = { status: 0 }
|
||||
if (q.kw) params.phone = q.kw
|
||||
const data = await get<any>('/api/admin/vips', params)
|
||||
rows.value = Array.isArray(data?.list) ? data.list : (Array.isArray(data)?data:[])
|
||||
}
|
||||
function reset(){ q.kw=''; fetch() }
|
||||
async function approve(row: any){ await post(`/api/admin/vips/${row.id}/approve`, {}); await fetch() }
|
||||
async function reject(row: any){ await post(`/api/admin/vips/${row.id}/reject`, {}); await fetch() }
|
||||
|
||||
onMounted(fetch)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page { position: relative; z-index: 1; }
|
||||
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 14px; }
|
||||
.vip-card { border-radius: 14px; overflow: hidden; }
|
||||
.vip-card__header { display:flex; align-items:center; gap:12px; padding: 6px 4px 6px; border-bottom: 1px solid var(--panel-border); }
|
||||
.avatar { width: 36px; height: 36px; border-radius: 18px; background: rgba(180,136,15,.18); display:flex; align-items:center; justify-content:center; font-weight: 800; color: var(--primary-royal); }
|
||||
.meta { display:flex; flex-direction:column; gap:2px; }
|
||||
.name { font-weight: 700; }
|
||||
.sub { font-size: 12px; color: var(--muted-color); }
|
||||
.level { margin-left: auto; }
|
||||
.vip-card__body { padding: 8px 4px; color: var(--muted-color); min-height: 44px; }
|
||||
.remark { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.vip-card__actions { display:flex; justify-content:flex-end; gap: 8px; padding-top: 6px; }
|
||||
</style>
|
||||
|
||||
|
||||
67
admin/src/views/vip/VipSystem.vue
Normal file
67
admin/src/views/vip/VipSystem.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-title"><span class="royal">♛</span> VIP系统</div>
|
||||
<div class="panel" style="padding:12px; margin-bottom: 12px;">
|
||||
<el-form :inline="true" :model="price">
|
||||
<el-form-item label="VIP价格(元/月)">
|
||||
<el-input v-model.number="price.value" type="number" placeholder="输入价格" style="width:160px;" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="savePrice">保存</el-button>
|
||||
<el-button @click="loadPrice">刷新</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div class="panel" style="padding:0;">
|
||||
<div class="panel-title" style="padding: 12px; border-bottom: 1px solid var(--panel-border); display:flex; align-items:center; justify-content:space-between;">
|
||||
<div>充值记录</div>
|
||||
<div>
|
||||
<el-input v-model="q.kw" placeholder="手机号/姓名" size="small" style="width:220px; margin-right: 8px;" clearable />
|
||||
<el-button size="small" type="primary" @click="fetchRecharges">查询</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-table :data="rows" style="width:100%" size="large" :border="false">
|
||||
<el-table-column type="index" width="60" label="#" />
|
||||
<el-table-column prop="shopName" label="店铺" width="160" />
|
||||
<el-table-column prop="userId" label="用户ID" width="100" />
|
||||
<el-table-column prop="name" label="姓名" width="140" />
|
||||
<el-table-column prop="phone" label="手机号" width="140" />
|
||||
<el-table-column prop="price" label="价格" width="100" />
|
||||
<el-table-column prop="durationDays" label="时长(天)" width="100" />
|
||||
<el-table-column prop="expireTo" label="到期时间" width="180" />
|
||||
<el-table-column prop="channel" label="渠道" width="120" />
|
||||
<el-table-column prop="createdAt" label="创建时间" width="180" />
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { get, put } from '../../api/http'
|
||||
|
||||
const price = reactive({ value: 0 })
|
||||
const rows = ref<any[]>([])
|
||||
const q = reactive({ kw: '' })
|
||||
|
||||
async function loadPrice(){
|
||||
const data = await get<any>('/api/admin/vip/system/price')
|
||||
price.value = Number(data?.price || 0)
|
||||
}
|
||||
async function savePrice(){
|
||||
await put('/api/admin/vip/system/price', { price: price.value })
|
||||
}
|
||||
async function fetchRecharges(){
|
||||
const data = await get<any>('/api/admin/vip/system/recharges', { kw: q.kw })
|
||||
rows.value = Array.isArray(data?.list) ? data.list : (Array.isArray(data)?data:[])
|
||||
}
|
||||
|
||||
onMounted(async () => { await loadPrice(); await fetchRecharges() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page { position: relative; z-index: 1; }
|
||||
</style>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user