chore: initialize store admin console
This commit is contained in:
commit
5220da21c5
1
.env.development
Normal file
1
.env.development
Normal file
@ -0,0 +1 @@
|
||||
VITE_PUBLIC_H5_ORIGIN=http://localhost:5173
|
||||
2
.env.example
Normal file
2
.env.example
Normal file
@ -0,0 +1,2 @@
|
||||
# 宠主公开报告 H5 根地址(复制链接用),生产请改为实际域名
|
||||
VITE_PUBLIC_H5_ORIGIN=https://example.com
|
||||
26
.gitignore
vendored
Normal file
26
.gitignore
vendored
Normal file
@ -0,0 +1,26 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
3
.vscode/extensions.json
vendored
Normal file
3
.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
28
README.md
Normal file
28
README.md
Normal file
@ -0,0 +1,28 @@
|
||||
# 宠小它门店 Web 后台
|
||||
|
||||
Phase A 管理端(Vue 3 + Vite + Element Plus)。
|
||||
|
||||
## 开发
|
||||
|
||||
```bash
|
||||
cd admin
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
默认 http://localhost:5174 ,`/api` 代理到 `http://localhost:8080`。
|
||||
|
||||
登录:老板/员工手机号 + 验证码(dev 万能码见后端 `app.demo.sms-universal-code`,默认 `123456`)。
|
||||
|
||||
公开报告链接域名:`.env.development` / `.env.production` 中配置 `VITE_PUBLIC_H5_ORIGIN`。
|
||||
|
||||
## 构建
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Owner
|
||||
|
||||
- Store Admin FE → `admin/**`
|
||||
- 客户聚合 API:Backend Core → `GET /api/admin/service-customers`
|
||||
13
index.html
Normal file
13
index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>宠小它 · 门店后台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
2421
package-lock.json
generated
Normal file
2421
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
package.json
Normal file
27
package.json
Normal file
@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "admin",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"axios": "^1.18.1",
|
||||
"element-plus": "^2.14.2",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.39",
|
||||
"vue-router": "^5.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.13.2",
|
||||
"@vitejs/plugin-vue": "^6.0.7",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.1.1",
|
||||
"vue-tsc": "^3.3.5"
|
||||
}
|
||||
}
|
||||
1
public/favicon.svg
Normal file
1
public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
24
public/icons.svg
Normal file
24
public/icons.svg
Normal file
@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
6
src/App.vue
Normal file
6
src/App.vue
Normal file
@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
67
src/api/http.ts
Normal file
67
src/api/http.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import axios from 'axios'
|
||||
import { clearSession, getToken } from '@/utils/session'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import router from '@/router'
|
||||
|
||||
export type ApiResp<T = unknown> = {
|
||||
code: number
|
||||
message?: string
|
||||
bizCode?: string
|
||||
data?: T
|
||||
}
|
||||
|
||||
const http = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 30000,
|
||||
})
|
||||
|
||||
http.interceptors.request.use((config) => {
|
||||
const token = getToken()
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
http.interceptors.response.use(
|
||||
(res) => {
|
||||
const body = res.data as ApiResp
|
||||
if (body && typeof body.code === 'number' && body.code === 401) {
|
||||
clearSession()
|
||||
router.replace({ name: 'login' })
|
||||
return Promise.reject(body)
|
||||
}
|
||||
return res
|
||||
},
|
||||
(err) => {
|
||||
const status = err?.response?.status
|
||||
if (status === 401) {
|
||||
clearSession()
|
||||
router.replace({ name: 'login' })
|
||||
ElMessage.error('登录已失效,请重新登录')
|
||||
}
|
||||
return Promise.reject(err)
|
||||
},
|
||||
)
|
||||
|
||||
export async function apiGet<T = unknown>(url: string, params?: Record<string, unknown>): Promise<ApiResp<T>> {
|
||||
const res = await http.get(url, { params })
|
||||
return res.data as ApiResp<T>
|
||||
}
|
||||
|
||||
export async function apiPost<T = unknown>(url: string, data?: unknown): Promise<ApiResp<T>> {
|
||||
const res = await http.post(url, data)
|
||||
return res.data as ApiResp<T>
|
||||
}
|
||||
|
||||
export async function apiPut<T = unknown>(url: string, data?: unknown): Promise<ApiResp<T>> {
|
||||
const res = await http.put(url, data)
|
||||
return res.data as ApiResp<T>
|
||||
}
|
||||
|
||||
export async function apiDelete<T = unknown>(url: string, params?: Record<string, unknown>): Promise<ApiResp<T>> {
|
||||
const res = await http.delete(url, { params })
|
||||
return res.data as ApiResp<T>
|
||||
}
|
||||
|
||||
export default http
|
||||
69
src/api/index.ts
Normal file
69
src/api/index.ts
Normal file
@ -0,0 +1,69 @@
|
||||
import { apiDelete, apiGet, apiPost, apiPut } from './http'
|
||||
|
||||
export const login = (phone: string, code: string) =>
|
||||
apiPost<{ sessionToken: string; user: Record<string, unknown>; store: Record<string, unknown> | null }>(
|
||||
'/user/login',
|
||||
{ phone, code },
|
||||
)
|
||||
|
||||
export const sendSms = (phone: string) => apiPost('/sms/send', { phone })
|
||||
|
||||
export const getUserInfo = () => apiGet<Record<string, unknown>>('/user/info')
|
||||
|
||||
export const getAppointmentList = (params: Record<string, unknown> = {}) =>
|
||||
apiGet('/appointment/list', params)
|
||||
|
||||
export const getAppointmentDetail = (id: number) => apiGet('/appointment/detail', { id })
|
||||
|
||||
export const startAppointment = (appointmentId: number) =>
|
||||
apiPost('/appointment/start', { appointmentId })
|
||||
|
||||
export const cancelAppointment = (id: number) =>
|
||||
apiPut(`/appointment/status?id=${id}&status=cancel`)
|
||||
|
||||
export const getReportList = (params: Record<string, unknown> = {}) =>
|
||||
apiGet('/report/list', params)
|
||||
|
||||
export const startHighlight = (reportId: number) =>
|
||||
apiPost('/report/highlight/start', { reportId })
|
||||
|
||||
export const getLeads = (status = 'pending') =>
|
||||
apiGet('/report/leads', { status })
|
||||
|
||||
export const getServiceCustomers = (params: Record<string, unknown> = {}) =>
|
||||
apiGet('/admin/service-customers', params)
|
||||
|
||||
export const getAdminStore = () => apiGet<Record<string, unknown>>('/admin/store')
|
||||
|
||||
export const updateStore = (data: Record<string, unknown>) => apiPut('/store/update', data)
|
||||
|
||||
export const getScheduleDay = (date: string) =>
|
||||
apiGet('/schedule/day', { date })
|
||||
|
||||
export const createScheduleBlock = (data: { slotStart: string; blockType: string; note?: string }) =>
|
||||
apiPost('/schedule/block', data)
|
||||
|
||||
export const deleteScheduleBlock = (id: number) =>
|
||||
apiDelete('/schedule/block', { id })
|
||||
|
||||
export const getStore = (id: number) => apiGet('/store/get', { id })
|
||||
|
||||
export const getStaffList = () => apiGet('/user/staff-list')
|
||||
|
||||
export const createStaff = (data: { name: string; phone: string }) =>
|
||||
apiPost('/user/create-staff', data)
|
||||
|
||||
export const deleteStaff = (staffId: number) =>
|
||||
apiDelete('/user/staff', { staffId })
|
||||
|
||||
export const getServiceTypeList = (storeId?: number | null) =>
|
||||
apiGet('/service-type/list', storeId != null ? { storeId } : {})
|
||||
|
||||
export const createServiceType = (name: string) =>
|
||||
apiPost('/service-type/create', { name })
|
||||
|
||||
export const updateServiceType = (id: number, name: string) =>
|
||||
apiPut('/service-type/update', { id, name })
|
||||
|
||||
export const deleteServiceType = (id: number) =>
|
||||
apiDelete('/service-type/delete', { id })
|
||||
99
src/layouts/AdminLayout.vue
Normal file
99
src/layouts/AdminLayout.vue
Normal file
@ -0,0 +1,99 @@
|
||||
<template>
|
||||
<el-container class="admin-shell">
|
||||
<el-aside width="220px" class="aside">
|
||||
<div class="brand">
|
||||
<div class="brand-name">宠小它</div>
|
||||
<div class="brand-sub">门店后台</div>
|
||||
</div>
|
||||
<el-menu :default-active="active" router background-color="#1f2a37" text-color="#c9d4e3" active-text-color="#fff">
|
||||
<el-menu-item v-for="item in menus" :key="item.path" :index="item.path">
|
||||
{{ item.label }}
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
<el-container>
|
||||
<el-header class="header">
|
||||
<div class="store">{{ storeName }}</div>
|
||||
<div class="user">
|
||||
<span>{{ userName }} · {{ roleLabel }}</span>
|
||||
<el-button link type="primary" @click="logout">退出</el-button>
|
||||
</div>
|
||||
</el-header>
|
||||
<el-main class="main">
|
||||
<router-view />
|
||||
</el-main>
|
||||
</el-container>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { clearSession, getStore, getUser } from '@/utils/session'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const menus = [
|
||||
{ path: '/workbench', label: '工作台' },
|
||||
{ path: '/appointments', label: '预约' },
|
||||
{ path: '/schedule', label: '排班' },
|
||||
{ path: '/reports', label: '报告' },
|
||||
{ path: '/leads', label: '线索' },
|
||||
{ path: '/customers', label: '客户' },
|
||||
{ path: '/settings', label: '设置' },
|
||||
]
|
||||
|
||||
const active = computed(() => route.path)
|
||||
const user = getUser()
|
||||
const store = getStore()
|
||||
const storeName = computed(() => store?.name || '本店')
|
||||
const userName = computed(() => user?.name || user?.phone || '用户')
|
||||
const roleLabel = computed(() => (user?.role === 'boss' ? '老板' : '员工'))
|
||||
|
||||
function logout() {
|
||||
clearSession()
|
||||
router.replace({ name: 'login' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-shell {
|
||||
min-height: 100vh;
|
||||
}
|
||||
.aside {
|
||||
background: var(--admin-sidebar);
|
||||
color: #fff;
|
||||
}
|
||||
.brand {
|
||||
padding: 20px 16px 12px;
|
||||
}
|
||||
.brand-name {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.brand-sub {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e8edf3;
|
||||
}
|
||||
.store {
|
||||
font-weight: 600;
|
||||
}
|
||||
.user {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
color: #5b6573;
|
||||
}
|
||||
.main {
|
||||
background: var(--admin-bg);
|
||||
}
|
||||
</style>
|
||||
14
src/main.ts
Normal file
14
src/main.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './styles/main.css'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(ElementPlus, { locale: zhCn })
|
||||
app.mount('#app')
|
||||
40
src/router/index.ts
Normal file
40
src/router/index.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { getToken, getUser, isStoreRole } from '@/utils/session'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: () => import('@/views/LoginView.vue'),
|
||||
meta: { public: true },
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('@/layouts/AdminLayout.vue'),
|
||||
redirect: '/workbench',
|
||||
children: [
|
||||
{ path: 'workbench', name: 'workbench', component: () => import('@/views/WorkbenchView.vue') },
|
||||
{ path: 'appointments', name: 'appointments', component: () => import('@/views/AppointmentsView.vue') },
|
||||
{ path: 'schedule', name: 'schedule', component: () => import('@/views/ScheduleView.vue') },
|
||||
{ path: 'reports', name: 'reports', component: () => import('@/views/ReportsView.vue') },
|
||||
{ path: 'leads', name: 'leads', component: () => import('@/views/LeadsView.vue') },
|
||||
{ path: 'customers', name: 'customers', component: () => import('@/views/CustomersView.vue') },
|
||||
{ path: 'settings', name: 'settings', component: () => import('@/views/SettingsView.vue') },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
router.beforeEach((to) => {
|
||||
if (to.meta.public) return true
|
||||
const token = getToken()
|
||||
const user = getUser()
|
||||
if (!token || !user || !isStoreRole(user.role)) {
|
||||
return { name: 'login', query: { redirect: to.fullPath } }
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
export default router
|
||||
50
src/styles/main.css
Normal file
50
src/styles/main.css
Normal file
@ -0,0 +1,50 @@
|
||||
:root {
|
||||
--admin-bg: #f3f5f7;
|
||||
--admin-sidebar: #1f2a37;
|
||||
--admin-accent: #2f6fed;
|
||||
font-family: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||
color: #1f2a37;
|
||||
background: var(--admin-bg);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.page-card {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0 0 16px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.summary-bar {
|
||||
margin-bottom: 12px;
|
||||
color: #5b6573;
|
||||
font-size: 14px;
|
||||
}
|
||||
30
src/utils/publicLinks.ts
Normal file
30
src/utils/publicLinks.ts
Normal file
@ -0,0 +1,30 @@
|
||||
/** 宠主 H5 / 公开报告页 origin,用于复制链接 */
|
||||
export function publicH5Origin(): string {
|
||||
const fromEnv = (import.meta.env.VITE_PUBLIC_H5_ORIGIN as string | undefined)?.replace(/\/$/, '')
|
||||
if (fromEnv) return fromEnv
|
||||
// 本地默认:admin 5174,uni H5 常见 5173
|
||||
if (typeof window !== 'undefined') {
|
||||
const { protocol, hostname } = window.location
|
||||
if (hostname === 'localhost' || hostname === '127.0.0.1') {
|
||||
return `${protocol}//${hostname}:5173`
|
||||
}
|
||||
return window.location.origin
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export function reportPublicUrl(token: string): string {
|
||||
const origin = publicH5Origin()
|
||||
// uni-app H5 hash 路由
|
||||
return `${origin}/#/pages/report-view/reportView?token=${encodeURIComponent(token)}`
|
||||
}
|
||||
|
||||
/** processing 超过该分钟数视为异常(Q4) */
|
||||
export const HIGHLIGHT_PROCESSING_TIMEOUT_MIN = 30
|
||||
|
||||
export function isHighlightProcessingStale(status?: string | null, updateTime?: string | null): boolean {
|
||||
if (status !== 'processing' || !updateTime) return false
|
||||
const t = Date.parse(updateTime)
|
||||
if (Number.isNaN(t)) return false
|
||||
return Date.now() - t > HIGHLIGHT_PROCESSING_TIMEOUT_MIN * 60 * 1000
|
||||
}
|
||||
61
src/utils/session.ts
Normal file
61
src/utils/session.ts
Normal file
@ -0,0 +1,61 @@
|
||||
const TOKEN_KEY = 'petstore_admin_session_token'
|
||||
const USER_KEY = 'petstore_admin_user'
|
||||
const STORE_KEY = 'petstore_admin_store'
|
||||
|
||||
export type AdminUser = {
|
||||
id: number
|
||||
name?: string
|
||||
phone?: string
|
||||
role: string
|
||||
storeId?: number | null
|
||||
}
|
||||
|
||||
export type AdminStore = {
|
||||
id: number
|
||||
name?: string
|
||||
inviteCode?: string
|
||||
}
|
||||
|
||||
export function getToken(): string | null {
|
||||
return localStorage.getItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
export function setSession(token: string, user: AdminUser, store: AdminStore | null) {
|
||||
localStorage.setItem(TOKEN_KEY, token)
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(user))
|
||||
if (store) {
|
||||
localStorage.setItem(STORE_KEY, JSON.stringify(store))
|
||||
} else {
|
||||
localStorage.removeItem(STORE_KEY)
|
||||
}
|
||||
}
|
||||
|
||||
export function clearSession() {
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
localStorage.removeItem(USER_KEY)
|
||||
localStorage.removeItem(STORE_KEY)
|
||||
}
|
||||
|
||||
export function getUser(): AdminUser | null {
|
||||
const raw = localStorage.getItem(USER_KEY)
|
||||
if (!raw) return null
|
||||
try {
|
||||
return JSON.parse(raw) as AdminUser
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function getStore(): AdminStore | null {
|
||||
const raw = localStorage.getItem(STORE_KEY)
|
||||
if (!raw) return null
|
||||
try {
|
||||
return JSON.parse(raw) as AdminStore
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function isStoreRole(role?: string | null): boolean {
|
||||
return role === 'boss' || role === 'staff'
|
||||
}
|
||||
271
src/views/AppointmentsView.vue
Normal file
271
src/views/AppointmentsView.vue
Normal file
@ -0,0 +1,271 @@
|
||||
<template>
|
||||
<div class="page-card">
|
||||
<h2 class="page-title">预约中心</h2>
|
||||
<div class="filter-row">
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="daterange"
|
||||
value-format="YYYY-MM-DD"
|
||||
start-placeholder="开始日"
|
||||
end-placeholder="结束日"
|
||||
style="width: 260px"
|
||||
/>
|
||||
<el-select v-model="status" clearable placeholder="状态" style="width: 140px">
|
||||
<el-option label="待开始" value="new" />
|
||||
<el-option label="服务中" value="doing" />
|
||||
<el-option label="已完成" value="done" />
|
||||
<el-option label="已取消" value="cancel" />
|
||||
</el-select>
|
||||
<el-select v-model="staffId" clearable placeholder="技师" style="width: 140px">
|
||||
<el-option v-for="s in staffOptions" :key="s.id" :label="s.name" :value="s.id" />
|
||||
</el-select>
|
||||
<el-checkbox v-model="missingReport">待出报告</el-checkbox>
|
||||
<el-input v-model="keyword" clearable placeholder="宠主/手机/宠物名" style="width: 200px" />
|
||||
<el-button type="primary" @click="load">查询</el-button>
|
||||
<el-button @click="reset">重置</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="rows" v-loading="loading" stripe>
|
||||
<el-table-column prop="appointmentTime" label="预约时间" min-width="160" />
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">{{ statusLabel(row.status) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="宠主" min-width="140">
|
||||
<template #default="{ row }">
|
||||
{{ row.customerName || '—' }}
|
||||
<span class="muted">{{ row.customerPhoneMasked || '' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="petName" label="宠物" width="100" />
|
||||
<el-table-column prop="serviceType" label="服务" min-width="110" />
|
||||
<el-table-column prop="staffName" label="技师" width="100" />
|
||||
<el-table-column label="报告" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.hasReport && row.reportId" link type="primary" @click="goReport(row)">有</el-button>
|
||||
<span v-else>无</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="260" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openDetail(row)">详情</el-button>
|
||||
<el-button v-if="row.status === 'new'" link type="primary" @click="onStart(row)">开始服务</el-button>
|
||||
<el-button
|
||||
v-if="row.status === 'new' || row.status === 'doing'"
|
||||
link
|
||||
type="danger"
|
||||
@click="onCancel(row)"
|
||||
>取消</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-drawer v-model="drawer" title="预约详情" size="420px">
|
||||
<template v-if="current">
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="时间">{{ current.appointmentTime }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">{{ statusLabel(current.status) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="宠主">{{ current.customerName }} {{ current.customerPhoneMasked }}</el-descriptions-item>
|
||||
<el-descriptions-item label="宠物">{{ current.petName }} / {{ current.petType }}</el-descriptions-item>
|
||||
<el-descriptions-item label="服务">{{ current.serviceType }}</el-descriptions-item>
|
||||
<el-descriptions-item label="技师">{{ current.staffName || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="备注">{{ current.remark || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="报告">{{ current.hasReport ? `已有 #${current.reportId}` : '无' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<el-alert
|
||||
class="hint"
|
||||
type="info"
|
||||
:closable="false"
|
||||
title="拍照与填报告请回小程序完成"
|
||||
/>
|
||||
<div class="drawer-actions">
|
||||
<el-button v-if="current.status === 'new'" type="primary" @click="onStart(current)">开始服务</el-button>
|
||||
<el-button v-if="current.hasReport && current.reportId" @click="goReport(current)">查看报告</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
cancelAppointment,
|
||||
getAppointmentDetail,
|
||||
getAppointmentList,
|
||||
getStaffList,
|
||||
startAppointment,
|
||||
} from '@/api'
|
||||
|
||||
type Appt = {
|
||||
id: number
|
||||
appointmentTime?: string
|
||||
status?: string
|
||||
petName?: string
|
||||
petType?: string
|
||||
serviceType?: string
|
||||
assignedUserId?: number
|
||||
staffName?: string
|
||||
customerName?: string
|
||||
customerPhoneMasked?: string
|
||||
hasReport?: boolean
|
||||
reportId?: number | null
|
||||
remark?: string
|
||||
updateTime?: string
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const rows = ref<Appt[]>([])
|
||||
const status = ref<string>((route.query.status as string) || '')
|
||||
const keyword = ref('')
|
||||
const dateFilter = ref((route.query.date as string) || '')
|
||||
const dateRange = ref<[string, string] | ''>('')
|
||||
const staffId = ref<number | undefined>()
|
||||
const missingReport = ref(route.query.missingReport === '1')
|
||||
const staffOptions = ref<Array<{ id: number; name: string }>>([])
|
||||
const drawer = ref(false)
|
||||
const current = ref<Appt | null>(null)
|
||||
|
||||
function statusLabel(s?: string) {
|
||||
switch (s) {
|
||||
case 'new': return '待开始'
|
||||
case 'doing': return '服务中'
|
||||
case 'done': return '已完成'
|
||||
case 'cancel': return '已取消'
|
||||
default: return s || '-'
|
||||
}
|
||||
}
|
||||
|
||||
function todayPrefix() {
|
||||
const d = new Date()
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function inDateRange(iso?: string) {
|
||||
if (!iso) return false
|
||||
const day = String(iso).slice(0, 10)
|
||||
if (dateFilter.value === 'today') {
|
||||
return day === todayPrefix()
|
||||
}
|
||||
if (Array.isArray(dateRange.value) && dateRange.value.length === 2) {
|
||||
return day >= dateRange.value[0] && day <= dateRange.value[1]
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async function loadStaff() {
|
||||
const res = await getStaffList()
|
||||
const list = Array.isArray(res.data) ? (res.data as Array<Record<string, unknown>>) : []
|
||||
staffOptions.value = list.map((u) => ({
|
||||
id: Number(u.id),
|
||||
name: String(u.name || u.phone || u.id),
|
||||
}))
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getAppointmentList({
|
||||
status: status.value || undefined,
|
||||
page: 1,
|
||||
pageSize: 200,
|
||||
})
|
||||
let list = Array.isArray(res.data) ? (res.data as Appt[]) : []
|
||||
list = list.filter((a) => inDateRange(a.appointmentTime))
|
||||
if (staffId.value != null) {
|
||||
list = list.filter((a) => a.assignedUserId === staffId.value)
|
||||
}
|
||||
if (missingReport.value) {
|
||||
list = list.filter((a) => a.status === 'doing' && !a.hasReport)
|
||||
}
|
||||
if (keyword.value.trim()) {
|
||||
const q = keyword.value.trim()
|
||||
list = list.filter((a) =>
|
||||
(a.petName || '').includes(q)
|
||||
|| (a.customerName || '').includes(q)
|
||||
|| (a.customerPhoneMasked || '').includes(q),
|
||||
)
|
||||
}
|
||||
rows.value = list
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
status.value = ''
|
||||
keyword.value = ''
|
||||
dateFilter.value = ''
|
||||
dateRange.value = ''
|
||||
staffId.value = undefined
|
||||
missingReport.value = false
|
||||
load()
|
||||
}
|
||||
|
||||
async function openDetail(row: Appt) {
|
||||
const res = await getAppointmentDetail(row.id)
|
||||
if (res.code !== 200 || !res.data) {
|
||||
ElMessage.error(res.message || '加载详情失败')
|
||||
return
|
||||
}
|
||||
current.value = res.data as Appt
|
||||
drawer.value = true
|
||||
}
|
||||
|
||||
function goReport(row: Appt) {
|
||||
if (!row.reportId) return
|
||||
router.push({ path: '/reports', query: { q: String(row.reportId) } })
|
||||
}
|
||||
|
||||
async function onStart(row: Appt) {
|
||||
await ElMessageBox.confirm(`确认开始服务「${row.petName || row.id}」?`, '开始服务')
|
||||
const res = await startAppointment(row.id)
|
||||
if (res.code !== 200) {
|
||||
ElMessage.error(res.message || '开始失败')
|
||||
return
|
||||
}
|
||||
ElMessage.success('已开始服务')
|
||||
drawer.value = false
|
||||
await load()
|
||||
}
|
||||
|
||||
async function onCancel(row: Appt) {
|
||||
await ElMessageBox.confirm(`确认取消预约「${row.petName || row.id}」?`, '取消预约', { type: 'warning' })
|
||||
const res = await cancelAppointment(row.id)
|
||||
if (res.code !== 200) {
|
||||
ElMessage.error(res.message || '取消失败')
|
||||
return
|
||||
}
|
||||
ElMessage.success('已取消')
|
||||
drawer.value = false
|
||||
await load()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (dateFilter.value === 'today') {
|
||||
const t = todayPrefix()
|
||||
dateRange.value = [t, t]
|
||||
}
|
||||
await loadStaff()
|
||||
await load()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.muted {
|
||||
margin-left: 6px;
|
||||
color: #8a94a6;
|
||||
font-size: 12px;
|
||||
}
|
||||
.hint {
|
||||
margin-top: 16px;
|
||||
}
|
||||
.drawer-actions {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
131
src/views/CustomersView.vue
Normal file
131
src/views/CustomersView.vue
Normal file
@ -0,0 +1,131 @@
|
||||
<template>
|
||||
<div class="page-card">
|
||||
<h2 class="page-title">服务客户</h2>
|
||||
<div class="summary-bar" v-if="summary">
|
||||
客户数 {{ summary.customerCount }} · 待回访 {{ summary.pendingLeadCount }} · 本月到店 {{ summary.monthVisitCount }}
|
||||
</div>
|
||||
<div class="filter-row">
|
||||
<el-select v-model="source" clearable placeholder="来源" style="width: 140px">
|
||||
<el-option label="预约" value="appointment" />
|
||||
<el-option label="报告留资" value="lead" />
|
||||
</el-select>
|
||||
<el-checkbox v-model="hasPendingLead">有待回访</el-checkbox>
|
||||
<el-input v-model="q" clearable placeholder="姓名 / 手机 / 宠物名" style="width: 220px" />
|
||||
<el-button type="primary" @click="load">查询</el-button>
|
||||
</div>
|
||||
<el-table :data="rows" v-loading="loading" stripe>
|
||||
<el-table-column prop="displayName" label="客户" width="120" />
|
||||
<el-table-column prop="phoneMasked" label="手机" width="140" />
|
||||
<el-table-column label="宠物" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-for="p in row.pets || []" :key="p.name" size="small" class="pet-tag">{{ p.name }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="lastVisitAt" label="最近到店" min-width="160" />
|
||||
<el-table-column prop="leadStatus" label="留资状态" width="100" />
|
||||
<el-table-column prop="source" label="来源" width="140" />
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openDetail(row)">详情</el-button>
|
||||
<el-button v-if="row.lastReportId" link @click="goReport(row)">报告</el-button>
|
||||
<el-button v-if="row.hasPendingLead" link @click="goLead">回访</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-drawer v-model="drawer" title="客户详情" size="420px">
|
||||
<template v-if="current">
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="姓名">{{ current.displayName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="手机">{{ current.phoneMasked }}</el-descriptions-item>
|
||||
<el-descriptions-item label="来源">{{ current.source }}</el-descriptions-item>
|
||||
<el-descriptions-item label="最近到店">{{ current.lastVisitAt || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="留资">{{ current.leadStatus || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="宠物">
|
||||
<el-tag v-for="p in current.pets || []" :key="p.name" size="small" class="pet-tag">{{ p.name }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<div class="drawer-actions">
|
||||
<el-button v-if="current.lastReportId" type="primary" @click="goReport(current)">最近报告</el-button>
|
||||
<el-button v-if="current.hasPendingLead" @click="goLead">去回访</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getServiceCustomers } from '@/api'
|
||||
|
||||
type CustomerRow = {
|
||||
userId?: number | null
|
||||
displayName?: string
|
||||
phoneMasked?: string
|
||||
pets?: Array<{ name: string }>
|
||||
lastVisitAt?: string
|
||||
lastReportId?: number | null
|
||||
leadStatus?: string
|
||||
hasPendingLead?: boolean
|
||||
source?: string
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const rows = ref<CustomerRow[]>([])
|
||||
const summary = ref<{ customerCount: number; pendingLeadCount: number; monthVisitCount: number } | null>(null)
|
||||
const source = ref('')
|
||||
const hasPendingLead = ref(false)
|
||||
const q = ref('')
|
||||
const drawer = ref(false)
|
||||
const current = ref<CustomerRow | null>(null)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getServiceCustomers({
|
||||
source: source.value || undefined,
|
||||
hasPendingLead: hasPendingLead.value || undefined,
|
||||
q: q.value || undefined,
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
})
|
||||
const data = res.data as {
|
||||
list?: CustomerRow[]
|
||||
summary?: { customerCount: number; pendingLeadCount: number; monthVisitCount: number }
|
||||
} | undefined
|
||||
rows.value = data?.list || []
|
||||
summary.value = data?.summary || null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openDetail(row: CustomerRow) {
|
||||
current.value = row
|
||||
drawer.value = true
|
||||
}
|
||||
|
||||
function goReport(row: CustomerRow) {
|
||||
if (!row.lastReportId) return
|
||||
router.push({ path: '/reports', query: { q: String(row.lastReportId) } })
|
||||
}
|
||||
|
||||
function goLead() {
|
||||
router.push({ path: '/leads', query: { due: 'today' } })
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.pet-tag {
|
||||
margin-right: 4px;
|
||||
}
|
||||
.drawer-actions {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
88
src/views/LeadsView.vue
Normal file
88
src/views/LeadsView.vue
Normal file
@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<div class="page-card">
|
||||
<h2 class="page-title">回访线索</h2>
|
||||
<div class="filter-row">
|
||||
<el-select v-model="status" style="width: 140px" @change="load">
|
||||
<el-option label="待回访" value="pending" />
|
||||
<el-option label="全部" value="all" />
|
||||
</el-select>
|
||||
<el-checkbox v-model="dueToday" @change="load">到期≤今天</el-checkbox>
|
||||
</div>
|
||||
<el-table :data="rows" v-loading="loading" stripe>
|
||||
<el-table-column prop="petName" label="宠物" width="120" />
|
||||
<el-table-column prop="serviceType" label="服务" min-width="120" />
|
||||
<el-table-column prop="phone" label="手机" width="140" />
|
||||
<el-table-column prop="remindDate" label="建议日期" width="120" />
|
||||
<el-table-column prop="remindStatus" label="状态" width="100" />
|
||||
<el-table-column label="微信" width="80">
|
||||
<template #default="{ row }">{{ row.wechatBound ? '是' : '否' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-if="row.reportId"
|
||||
link
|
||||
type="primary"
|
||||
@click="openReport(row)"
|
||||
>打开报告</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { getLeads, getReportList } from '@/api'
|
||||
import { reportPublicUrl } from '@/utils/publicLinks'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const status = ref('pending')
|
||||
const dueToday = ref(route.query.due === 'today')
|
||||
const rows = ref<Array<Record<string, unknown>>>([])
|
||||
|
||||
function todayStr() {
|
||||
const d = new Date()
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getLeads(status.value)
|
||||
let list = Array.isArray(res.data) ? (res.data as Array<Record<string, unknown>>) : []
|
||||
if (dueToday.value) {
|
||||
const t = todayStr()
|
||||
list = list.filter((l) => {
|
||||
const d = l.remindDate as string | undefined
|
||||
return !d || d <= t
|
||||
})
|
||||
}
|
||||
rows.value = list
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openReport(row: Record<string, unknown>) {
|
||||
const reportId = Number(row.reportId)
|
||||
if (!reportId) return
|
||||
// 优先用列表里的 token;没有则从报告列表反查
|
||||
const listRes = await getReportList({ page: 1, pageSize: 200 })
|
||||
const reports = Array.isArray(listRes.data) ? (listRes.data as Array<Record<string, unknown>>) : []
|
||||
const hit = reports.find((r) => Number(r.id) === reportId)
|
||||
const token = hit?.reportToken as string | undefined
|
||||
if (token) {
|
||||
window.open(reportPublicUrl(token), '_blank')
|
||||
return
|
||||
}
|
||||
ElMessage.warning('未找到公开链接,已跳转报告中心')
|
||||
await router.push({ path: '/reports', query: { q: String(reportId) } })
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
142
src/views/LoginView.vue
Normal file
142
src/views/LoginView.vue
Normal file
@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<div class="panel">
|
||||
<h1>宠小它 · 门店后台</h1>
|
||||
<p class="hint">请使用老板或员工账号登录(dev 验证码见短信接口)</p>
|
||||
<el-form @submit.prevent>
|
||||
<el-form-item label="手机号">
|
||||
<el-input v-model="phone" maxlength="11" placeholder="11 位手机号" />
|
||||
</el-form-item>
|
||||
<el-form-item label="验证码">
|
||||
<div class="sms-row">
|
||||
<el-input v-model="code" maxlength="6" placeholder="验证码" />
|
||||
<el-button :disabled="countdown > 0 || sending" @click="onSendSms">
|
||||
{{ countdown > 0 ? `${countdown}s` : '获取验证码' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-button type="primary" class="submit" :loading="loading" @click="onLogin">登录</el-button>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onUnmounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { login, sendSms } from '@/api'
|
||||
import { isStoreRole, setSession, type AdminStore, type AdminUser } from '@/utils/session'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const phone = ref('')
|
||||
const code = ref('')
|
||||
const loading = ref(false)
|
||||
const sending = ref(false)
|
||||
const countdown = ref(0)
|
||||
let timer: number | undefined
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer) window.clearInterval(timer)
|
||||
})
|
||||
|
||||
async function onSendSms() {
|
||||
if (!/^1\d{10}$/.test(phone.value)) {
|
||||
ElMessage.warning('请输入正确手机号')
|
||||
return
|
||||
}
|
||||
sending.value = true
|
||||
try {
|
||||
const res = await sendSms(phone.value)
|
||||
if (res.code !== 200) {
|
||||
ElMessage.error(res.message || '发送失败')
|
||||
return
|
||||
}
|
||||
ElMessage.success('验证码已发送')
|
||||
countdown.value = 60
|
||||
timer = window.setInterval(() => {
|
||||
countdown.value -= 1
|
||||
if (countdown.value <= 0 && timer) {
|
||||
window.clearInterval(timer)
|
||||
timer = undefined
|
||||
}
|
||||
}, 1000)
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onLogin() {
|
||||
if (!/^1\d{10}$/.test(phone.value) || !code.value) {
|
||||
ElMessage.warning('请填写手机号和验证码')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await login(phone.value, code.value)
|
||||
if (res.code !== 200 || !res.data) {
|
||||
ElMessage.error(res.message || '登录失败')
|
||||
return
|
||||
}
|
||||
const userRaw = res.data.user || {}
|
||||
const role = String(userRaw.role || '')
|
||||
if (!isStoreRole(role)) {
|
||||
ElMessage.error('请使用老板账号登录')
|
||||
return
|
||||
}
|
||||
const user: AdminUser = {
|
||||
id: Number(userRaw.id),
|
||||
name: userRaw.name as string | undefined,
|
||||
phone: userRaw.phone as string | undefined,
|
||||
role,
|
||||
storeId: (userRaw.storeId as number | null | undefined) ?? null,
|
||||
}
|
||||
const storeRaw = res.data.store
|
||||
const store: AdminStore | null = storeRaw
|
||||
? { id: Number(storeRaw.id), name: storeRaw.name as string | undefined, inviteCode: storeRaw.inviteCode as string | undefined }
|
||||
: null
|
||||
setSession(res.data.sessionToken, user, store)
|
||||
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/workbench'
|
||||
await router.replace(redirect)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background:
|
||||
radial-gradient(circle at 20% 20%, #dce8ff 0, transparent 40%),
|
||||
radial-gradient(circle at 80% 0%, #e8f6ef 0, transparent 35%),
|
||||
#f3f5f7;
|
||||
}
|
||||
.panel {
|
||||
width: 400px;
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
padding: 28px;
|
||||
box-shadow: 0 12px 40px rgba(31, 42, 55, 0.08);
|
||||
}
|
||||
h1 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 22px;
|
||||
}
|
||||
.hint {
|
||||
margin: 0 0 20px;
|
||||
color: #7a8494;
|
||||
font-size: 13px;
|
||||
}
|
||||
.sms-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
.submit {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
194
src/views/ReportsView.vue
Normal file
194
src/views/ReportsView.vue
Normal file
@ -0,0 +1,194 @@
|
||||
<template>
|
||||
<div class="page-card">
|
||||
<h2 class="page-title">服务报告</h2>
|
||||
<div class="filter-row">
|
||||
<el-select v-model="highlightStatus" clearable placeholder="成片状态" style="width: 160px">
|
||||
<el-option label="处理中" value="processing" />
|
||||
<el-option label="成功" value="done" />
|
||||
<el-option label="失败" value="failed" />
|
||||
<el-option label="异常(失败+超时)" value="anomaly" />
|
||||
</el-select>
|
||||
<el-input v-model="keyword" clearable placeholder="宠物名 / 报告ID" style="width: 180px" />
|
||||
<el-button type="primary" @click="load">查询</el-button>
|
||||
</div>
|
||||
<el-table :data="rows" v-loading="loading" stripe>
|
||||
<el-table-column prop="createTime" label="提交时间" min-width="160" />
|
||||
<el-table-column prop="appointmentTime" label="预约时间" min-width="160" />
|
||||
<el-table-column prop="petName" label="宠物" width="100" />
|
||||
<el-table-column prop="serviceType" label="服务" min-width="120" />
|
||||
<el-table-column label="成片" width="140">
|
||||
<template #default="{ row }">
|
||||
<span>{{ hlLabel(row) }}</span>
|
||||
<div v-if="row.highlightFailReasonLabel" class="fail">{{ row.highlightFailReasonLabel }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="280" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openDetail(row)">详情</el-button>
|
||||
<el-button link type="primary" @click="copyLink(row)">复制链接</el-button>
|
||||
<el-button
|
||||
v-if="row.highlightVideoStatus === 'failed' || isStale(row)"
|
||||
link
|
||||
type="warning"
|
||||
@click="onRetry(row)"
|
||||
>重新生成</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-drawer v-model="drawer" title="报告详情" size="480px">
|
||||
<template v-if="current">
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="宠物">{{ current.petName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="服务">{{ current.serviceType }}</el-descriptions-item>
|
||||
<el-descriptions-item label="预约时间">{{ current.appointmentTime }}</el-descriptions-item>
|
||||
<el-descriptions-item label="成片">{{ hlLabel(current) }}</el-descriptions-item>
|
||||
<el-descriptions-item v-if="current.highlightFailReasonLabel" label="失败原因">
|
||||
{{ current.highlightFailReasonLabel }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<div class="media" v-if="(current.beforePhotos || []).length || (current.afterPhotos || []).length">
|
||||
<h4>媒体(只读)</h4>
|
||||
<div class="thumbs">
|
||||
<img v-for="(u, i) in current.beforePhotos || []" :key="'b'+i" :src="u" alt="before" />
|
||||
<img v-for="(u, i) in current.afterPhotos || []" :key="'a'+i" :src="u" alt="after" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="drawer-actions">
|
||||
<el-button type="primary" @click="copyLink(current)">复制公开链接</el-button>
|
||||
<el-button v-if="current.reportToken" @click="openH5(current)">打开 H5</el-button>
|
||||
<el-button
|
||||
v-if="current.highlightVideoStatus === 'failed' || isStale(current)"
|
||||
type="warning"
|
||||
@click="onRetry(current)"
|
||||
>重新生成</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getReportList, startHighlight } from '@/api'
|
||||
import { isHighlightProcessingStale, reportPublicUrl } from '@/utils/publicLinks'
|
||||
|
||||
type ReportRow = {
|
||||
id: number
|
||||
createTime?: string
|
||||
updateTime?: string
|
||||
appointmentTime?: string
|
||||
petName?: string
|
||||
serviceType?: string
|
||||
highlightVideoStatus?: string
|
||||
highlightFailReasonLabel?: string
|
||||
reportToken?: string
|
||||
beforePhotos?: string[]
|
||||
afterPhotos?: string[]
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const loading = ref(false)
|
||||
const rows = ref<ReportRow[]>([])
|
||||
const highlightStatus = ref((route.query.highlightStatus as string) || '')
|
||||
const keyword = ref((route.query.q as string) || '')
|
||||
const drawer = ref(false)
|
||||
const current = ref<ReportRow | null>(null)
|
||||
|
||||
function isStale(row: ReportRow) {
|
||||
return isHighlightProcessingStale(row.highlightVideoStatus, row.updateTime)
|
||||
}
|
||||
|
||||
function hlLabel(row: ReportRow) {
|
||||
if (isStale(row)) return '处理超时'
|
||||
switch (row.highlightVideoStatus) {
|
||||
case 'processing': return '处理中'
|
||||
case 'done': return '成功'
|
||||
case 'failed': return '失败'
|
||||
default: return row.highlightVideoStatus || '—'
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getReportList({ page: 1, pageSize: 200 })
|
||||
let list = Array.isArray(res.data) ? (res.data as ReportRow[]) : []
|
||||
if (highlightStatus.value === 'anomaly' || highlightStatus.value === 'failed') {
|
||||
list = list.filter((r) => r.highlightVideoStatus === 'failed' || isStale(r))
|
||||
} else if (highlightStatus.value) {
|
||||
list = list.filter((r) => r.highlightVideoStatus === highlightStatus.value)
|
||||
}
|
||||
if (keyword.value.trim()) {
|
||||
const q = keyword.value.trim()
|
||||
list = list.filter((r) =>
|
||||
(r.petName || '').includes(q) || String(r.id) === q,
|
||||
)
|
||||
}
|
||||
rows.value = list
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openDetail(row: ReportRow) {
|
||||
current.value = row
|
||||
drawer.value = true
|
||||
}
|
||||
|
||||
async function copyLink(row: ReportRow) {
|
||||
if (!row.reportToken) {
|
||||
ElMessage.warning('无公开链接')
|
||||
return
|
||||
}
|
||||
await navigator.clipboard.writeText(reportPublicUrl(row.reportToken))
|
||||
ElMessage.success('已复制公开链接')
|
||||
}
|
||||
|
||||
function openH5(row: ReportRow) {
|
||||
if (!row.reportToken) return
|
||||
window.open(reportPublicUrl(row.reportToken), '_blank')
|
||||
}
|
||||
|
||||
async function onRetry(row: ReportRow) {
|
||||
const res = await startHighlight(row.id)
|
||||
if (res.code !== 200) {
|
||||
ElMessage.error(res.message || '触发失败')
|
||||
return
|
||||
}
|
||||
ElMessage.success('已重新生成')
|
||||
await load()
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fail {
|
||||
color: #c45656;
|
||||
font-size: 12px;
|
||||
}
|
||||
.media {
|
||||
margin-top: 16px;
|
||||
}
|
||||
.thumbs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.thumbs img {
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
background: #f0f2f5;
|
||||
}
|
||||
.drawer-actions {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
192
src/views/ScheduleView.vue
Normal file
192
src/views/ScheduleView.vue
Normal file
@ -0,0 +1,192 @@
|
||||
<template>
|
||||
<div class="page-card">
|
||||
<h2 class="page-title">排班占用</h2>
|
||||
<div class="filter-row">
|
||||
<el-date-picker v-model="date" type="date" value-format="YYYY-MM-DD" />
|
||||
<el-button type="primary" @click="load">查询</el-button>
|
||||
<el-button @click="openCreate">新增占用</el-button>
|
||||
</div>
|
||||
<el-table :data="rows" v-loading="loading" stripe>
|
||||
<el-table-column prop="time" label="时段" width="100" />
|
||||
<el-table-column label="类型" width="120">
|
||||
<template #default="{ row }">{{ kindLabel(row.kind, row.blockType) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="title" label="内容" min-width="240" />
|
||||
<el-table-column label="操作" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-if="row.kind === 'block' && row.blockId"
|
||||
link
|
||||
type="danger"
|
||||
@click="onDelete(row)"
|
||||
>删除</el-button>
|
||||
<el-button
|
||||
v-else-if="row.kind === 'empty' && !row.past"
|
||||
link
|
||||
type="primary"
|
||||
@click="quickBlock(row)"
|
||||
>占用</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog v-model="dialogVisible" title="新增占用" width="420px">
|
||||
<el-form label-width="90px">
|
||||
<el-form-item label="时段">
|
||||
<el-select v-model="createForm.slotStart" filterable style="width: 100%">
|
||||
<el-option
|
||||
v-for="opt in emptySlots"
|
||||
:key="opt.slotStart"
|
||||
:label="opt.time"
|
||||
:value="opt.slotStart"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="类型">
|
||||
<el-radio-group v-model="createForm.blockType">
|
||||
<el-radio value="walk_in">到店占用</el-radio>
|
||||
<el-radio value="blocked">暂停预约</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="createForm.note" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="creating" @click="submitCreate">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { createScheduleBlock, deleteScheduleBlock, getScheduleDay } from '@/api'
|
||||
|
||||
type Row = {
|
||||
time: string
|
||||
slotStart: string
|
||||
kind: string
|
||||
blockType?: string
|
||||
blockId?: number
|
||||
title: string
|
||||
past?: boolean
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
const creating = ref(false)
|
||||
const date = ref(new Date().toISOString().slice(0, 10))
|
||||
const rows = ref<Row[]>([])
|
||||
const dialogVisible = ref(false)
|
||||
const createForm = reactive({
|
||||
slotStart: '',
|
||||
blockType: 'blocked',
|
||||
note: '',
|
||||
})
|
||||
|
||||
const emptySlots = computed(() =>
|
||||
rows.value.filter((r) => r.kind === 'empty' && !r.past),
|
||||
)
|
||||
|
||||
function kindLabel(kind: string, blockType?: string) {
|
||||
if (kind === 'appointment') return '预约'
|
||||
if (kind === 'block') return blockType === 'walk_in' ? '到店占用' : '暂停预约'
|
||||
return '空闲'
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getScheduleDay(date.value)
|
||||
if (res.code !== 200) {
|
||||
ElMessage.error(res.message || '加载失败')
|
||||
rows.value = []
|
||||
return
|
||||
}
|
||||
const data = res.data as { rows?: Array<Record<string, unknown>> } | undefined
|
||||
const list = Array.isArray(data?.rows) ? data!.rows! : []
|
||||
rows.value = list.map((r) => {
|
||||
const kind = String(r.kind || 'empty')
|
||||
const appt = r.appointment as Record<string, unknown> | null | undefined
|
||||
const block = r.block as Record<string, unknown> | null | undefined
|
||||
let title = '—'
|
||||
let blockType: string | undefined
|
||||
let blockId: number | undefined
|
||||
if (kind === 'appointment' && appt) {
|
||||
title = `${appt.petName || ''} · ${appt.serviceType || ''}`.trim() || '预约'
|
||||
} else if (kind === 'block' && block) {
|
||||
blockType = String(block.blockType || '')
|
||||
blockId = block.id != null ? Number(block.id) : undefined
|
||||
title = String(block.note || (blockType === 'walk_in' ? '到店占用' : '暂停预约'))
|
||||
} else {
|
||||
title = '空闲'
|
||||
}
|
||||
return {
|
||||
time: String(r.time || ''),
|
||||
slotStart: String(r.slotStart || ''),
|
||||
kind,
|
||||
blockType,
|
||||
blockId,
|
||||
title,
|
||||
past: Boolean(r.past),
|
||||
}
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
createForm.slotStart = emptySlots.value[0]?.slotStart || ''
|
||||
createForm.blockType = 'blocked'
|
||||
createForm.note = ''
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function quickBlock(row: Row) {
|
||||
createForm.slotStart = row.slotStart
|
||||
createForm.blockType = 'blocked'
|
||||
createForm.note = ''
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function submitCreate() {
|
||||
if (!createForm.slotStart) {
|
||||
ElMessage.warning('请选择时段')
|
||||
return
|
||||
}
|
||||
creating.value = true
|
||||
try {
|
||||
const res = await createScheduleBlock({
|
||||
slotStart: createForm.slotStart,
|
||||
blockType: createForm.blockType,
|
||||
note: createForm.note || undefined,
|
||||
})
|
||||
if (res.code !== 200) {
|
||||
ElMessage.error(res.message || '占用失败')
|
||||
return
|
||||
}
|
||||
ElMessage.success('已占用')
|
||||
dialogVisible.value = false
|
||||
await load()
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete(row: Row) {
|
||||
if (!row.blockId) return
|
||||
await ElMessageBox.confirm(`删除 ${row.time} 的占用?`, '确认', { type: 'warning' })
|
||||
const res = await deleteScheduleBlock(row.blockId)
|
||||
if (res.code !== 200) {
|
||||
ElMessage.error(res.message || '删除失败')
|
||||
return
|
||||
}
|
||||
ElMessage.success('已删除')
|
||||
await load()
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
298
src/views/SettingsView.vue
Normal file
298
src/views/SettingsView.vue
Normal file
@ -0,0 +1,298 @@
|
||||
<template>
|
||||
<div class="page-card">
|
||||
<h2 class="page-title">门店设置</h2>
|
||||
<el-tabs v-model="tab">
|
||||
<el-tab-pane label="资料" name="store">
|
||||
<el-form label-width="120px" style="max-width: 560px" v-loading="loadingStore">
|
||||
<el-form-item label="店名">
|
||||
<el-input v-model="form.name" :disabled="!isBoss" />
|
||||
</el-form-item>
|
||||
<el-form-item label="电话">
|
||||
<el-input v-model="form.phone" :disabled="!isBoss" />
|
||||
</el-form-item>
|
||||
<el-form-item label="地址">
|
||||
<el-input v-model="form.address" :disabled="!isBoss" />
|
||||
</el-form-item>
|
||||
<el-form-item label="简介">
|
||||
<el-input v-model="form.intro" type="textarea" :rows="3" :disabled="!isBoss" />
|
||||
</el-form-item>
|
||||
<el-form-item label="营业开始">
|
||||
<el-time-select
|
||||
v-model="form.bookingDayStart"
|
||||
start="06:00"
|
||||
step="00:30"
|
||||
end="22:00"
|
||||
:disabled="!isBoss"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="末档开始">
|
||||
<el-time-select
|
||||
v-model="form.bookingLastSlotStart"
|
||||
start="06:00"
|
||||
step="00:30"
|
||||
end="23:30"
|
||||
:disabled="!isBoss"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="邀请码">
|
||||
<div class="invite-row">
|
||||
<el-input v-model="form.inviteCode" readonly />
|
||||
<el-button @click="copyInvite">复制</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="isBoss">
|
||||
<el-button type="primary" :loading="savingStore" @click="saveStore">保存</el-button>
|
||||
</el-form-item>
|
||||
<el-alert v-else type="info" :closable="false" title="员工可查看;改资料仅老板" />
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="服务类型" name="service">
|
||||
<div class="filter-row" v-if="isBoss">
|
||||
<el-input v-model="newServiceName" placeholder="新服务类型名称" style="width: 220px" />
|
||||
<el-button type="primary" @click="addService">新增</el-button>
|
||||
</div>
|
||||
<el-table :data="serviceTypes" v-loading="loadingMeta" stripe>
|
||||
<el-table-column prop="name" label="名称" />
|
||||
<el-table-column label="范围" width="120">
|
||||
<template #default="{ row }">{{ row.storeId == null ? '系统默认' : '本店' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="isBoss" label="操作" width="160">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-if="row.storeId != null"
|
||||
link
|
||||
type="primary"
|
||||
@click="renameService(row)"
|
||||
>改名</el-button>
|
||||
<el-button
|
||||
v-if="row.storeId != null"
|
||||
link
|
||||
type="danger"
|
||||
@click="removeService(row)"
|
||||
>删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="员工" name="staff">
|
||||
<div class="filter-row" v-if="isBoss">
|
||||
<el-input v-model="newStaffName" placeholder="姓名" style="width: 140px" />
|
||||
<el-input v-model="newStaffPhone" placeholder="手机号" style="width: 160px" maxlength="11" />
|
||||
<el-button type="primary" @click="addStaff">创建员工</el-button>
|
||||
</div>
|
||||
<el-table :data="staff" v-loading="loadingMeta" stripe>
|
||||
<el-table-column prop="name" label="姓名" />
|
||||
<el-table-column prop="phone" label="手机" />
|
||||
<el-table-column prop="role" label="角色" width="100" />
|
||||
<el-table-column v-if="isBoss" label="操作" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-if="row.role === 'staff'"
|
||||
link
|
||||
type="danger"
|
||||
@click="removeStaff(row)"
|
||||
>删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
createServiceType,
|
||||
createStaff,
|
||||
deleteServiceType,
|
||||
deleteStaff,
|
||||
getAdminStore,
|
||||
getServiceTypeList,
|
||||
getStaffList,
|
||||
updateServiceType,
|
||||
updateStore,
|
||||
} from '@/api'
|
||||
import { getStore as getCachedStore, getUser, setSession, getToken, type AdminStore } from '@/utils/session'
|
||||
|
||||
const tab = ref('store')
|
||||
const user = getUser()
|
||||
const isBoss = computed(() => user?.role === 'boss')
|
||||
const loadingStore = ref(false)
|
||||
const savingStore = ref(false)
|
||||
const loadingMeta = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
id: 0,
|
||||
name: '',
|
||||
phone: '',
|
||||
address: '',
|
||||
intro: '',
|
||||
inviteCode: '',
|
||||
bookingDayStart: '09:00',
|
||||
bookingLastSlotStart: '21:30',
|
||||
})
|
||||
|
||||
const serviceTypes = ref<Array<Record<string, unknown>>>([])
|
||||
const staff = ref<Array<Record<string, unknown>>>([])
|
||||
const newServiceName = ref('')
|
||||
const newStaffName = ref('')
|
||||
const newStaffPhone = ref('')
|
||||
|
||||
function normTime(v: unknown): string {
|
||||
if (v == null) return '09:00'
|
||||
const s = String(v)
|
||||
return s.length >= 5 ? s.slice(0, 5) : s
|
||||
}
|
||||
|
||||
async function loadStore() {
|
||||
loadingStore.value = true
|
||||
try {
|
||||
const res = await getAdminStore()
|
||||
if (res.code !== 200 || !res.data) {
|
||||
ElMessage.error(res.message || '加载门店失败')
|
||||
return
|
||||
}
|
||||
const d = res.data
|
||||
form.id = Number(d.id)
|
||||
form.name = String(d.name || '')
|
||||
form.phone = String(d.phone || '')
|
||||
form.address = String(d.address || '')
|
||||
form.intro = String(d.intro || '')
|
||||
form.inviteCode = String(d.inviteCode || '')
|
||||
form.bookingDayStart = normTime(d.bookingDayStart)
|
||||
form.bookingLastSlotStart = normTime(d.bookingLastSlotStart)
|
||||
const token = getToken()
|
||||
const u = getUser()
|
||||
if (token && u) {
|
||||
const store: AdminStore = {
|
||||
id: form.id,
|
||||
name: form.name,
|
||||
inviteCode: form.inviteCode,
|
||||
}
|
||||
setSession(token, u, store)
|
||||
}
|
||||
} finally {
|
||||
loadingStore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMeta() {
|
||||
loadingMeta.value = true
|
||||
try {
|
||||
const storeId = getUser()?.storeId ?? getCachedStore()?.id
|
||||
const [st, sf] = await Promise.all([getServiceTypeList(storeId), getStaffList()])
|
||||
serviceTypes.value = Array.isArray(st.data) ? (st.data as Array<Record<string, unknown>>) : []
|
||||
staff.value = Array.isArray(sf.data) ? (sf.data as Array<Record<string, unknown>>) : []
|
||||
} finally {
|
||||
loadingMeta.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveStore() {
|
||||
if (!form.id) return
|
||||
savingStore.value = true
|
||||
try {
|
||||
const res = await updateStore({
|
||||
id: form.id,
|
||||
name: form.name,
|
||||
phone: form.phone,
|
||||
address: form.address,
|
||||
intro: form.intro,
|
||||
bookingDayStart: form.bookingDayStart,
|
||||
bookingLastSlotStart: form.bookingLastSlotStart,
|
||||
})
|
||||
if (res.code !== 200) {
|
||||
ElMessage.error(res.message || '保存失败')
|
||||
return
|
||||
}
|
||||
ElMessage.success('已保存')
|
||||
await loadStore()
|
||||
} finally {
|
||||
savingStore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copyInvite() {
|
||||
if (!form.inviteCode) return
|
||||
await navigator.clipboard.writeText(form.inviteCode)
|
||||
ElMessage.success('邀请码已复制')
|
||||
}
|
||||
|
||||
async function addService() {
|
||||
const name = newServiceName.value.trim()
|
||||
if (!name) return
|
||||
const res = await createServiceType(name)
|
||||
if (res.code !== 200) {
|
||||
ElMessage.error(res.message || '新增失败')
|
||||
return
|
||||
}
|
||||
newServiceName.value = ''
|
||||
ElMessage.success('已新增')
|
||||
await loadMeta()
|
||||
}
|
||||
|
||||
async function renameService(row: Record<string, unknown>) {
|
||||
const { value } = await ElMessageBox.prompt('新名称', '改名', { inputValue: String(row.name || '') })
|
||||
const res = await updateServiceType(Number(row.id), value)
|
||||
if (res.code !== 200) {
|
||||
ElMessage.error(res.message || '改名失败')
|
||||
return
|
||||
}
|
||||
ElMessage.success('已更新')
|
||||
await loadMeta()
|
||||
}
|
||||
|
||||
async function removeService(row: Record<string, unknown>) {
|
||||
await ElMessageBox.confirm(`删除服务类型「${row.name}」?`, '确认', { type: 'warning' })
|
||||
const res = await deleteServiceType(Number(row.id))
|
||||
if (res.code !== 200) {
|
||||
ElMessage.error(res.message || '删除失败')
|
||||
return
|
||||
}
|
||||
ElMessage.success('已删除')
|
||||
await loadMeta()
|
||||
}
|
||||
|
||||
async function addStaff() {
|
||||
if (!newStaffName.value.trim() || !/^1\d{10}$/.test(newStaffPhone.value)) {
|
||||
ElMessage.warning('请填写姓名与正确手机号')
|
||||
return
|
||||
}
|
||||
const res = await createStaff({ name: newStaffName.value.trim(), phone: newStaffPhone.value })
|
||||
if (res.code !== 200) {
|
||||
ElMessage.error(res.message || '创建失败')
|
||||
return
|
||||
}
|
||||
newStaffName.value = ''
|
||||
newStaffPhone.value = ''
|
||||
ElMessage.success('已创建')
|
||||
await loadMeta()
|
||||
}
|
||||
|
||||
async function removeStaff(row: Record<string, unknown>) {
|
||||
await ElMessageBox.confirm(`删除员工「${row.name}」?`, '确认', { type: 'warning' })
|
||||
const res = await deleteStaff(Number(row.id))
|
||||
if (res.code !== 200) {
|
||||
ElMessage.error(res.message || '删除失败')
|
||||
return
|
||||
}
|
||||
ElMessage.success('已删除')
|
||||
await loadMeta()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadStore(), loadMeta()])
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.invite-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
189
src/views/WorkbenchView.vue
Normal file
189
src/views/WorkbenchView.vue
Normal file
@ -0,0 +1,189 @@
|
||||
<template>
|
||||
<div class="page-card">
|
||||
<h2 class="page-title">今日工作台</h2>
|
||||
<el-row :gutter="12" class="metrics">
|
||||
<el-col :span="6" v-for="card in cards" :key="card.key">
|
||||
<el-card shadow="hover" class="metric" @click="go(card.to)">
|
||||
<div class="metric-label">{{ card.label }}</div>
|
||||
<div class="metric-value">{{ card.value }}</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<h3>今日待办</h3>
|
||||
<div class="todos">
|
||||
<el-tag
|
||||
v-for="t in todos"
|
||||
:key="t.key"
|
||||
class="todo"
|
||||
effect="plain"
|
||||
@click="go(t.to)"
|
||||
>{{ t.label }} {{ t.value }}</el-tag>
|
||||
</div>
|
||||
|
||||
<h3>报告漏斗</h3>
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="6" v-for="f in funnel" :key="f.label">
|
||||
<div class="funnel-item">
|
||||
<div>{{ f.label }}</div>
|
||||
<strong>{{ f.value }}</strong>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-dialog
|
||||
v-model="todoDialog"
|
||||
title="今日待办"
|
||||
width="480px"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-table :data="todos" size="small">
|
||||
<el-table-column prop="label" label="事项" />
|
||||
<el-table-column prop="value" label="数量" width="80" />
|
||||
<el-table-column label="" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="goFromDialog(row.to)">去处理</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<template #footer>
|
||||
<el-checkbox v-model="suppressToday">今日不再提醒</el-checkbox>
|
||||
<el-button type="primary" @click="closeTodoDialog">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getAppointmentList, getLeads, getReportList } from '@/api'
|
||||
import { isHighlightProcessingStale } from '@/utils/publicLinks'
|
||||
|
||||
const SUPPRESS_KEY = 'petstore_admin_todo_suppress_date'
|
||||
|
||||
const router = useRouter()
|
||||
const todayAppt = ref(0)
|
||||
const todayDone = ref(0)
|
||||
const pendingLead = ref(0)
|
||||
const highlightFailed = ref(0)
|
||||
const pendingReport = ref(0)
|
||||
const todayReports = ref(0)
|
||||
const todayLeads = ref(0)
|
||||
const todoDialog = ref(false)
|
||||
const suppressToday = ref(false)
|
||||
|
||||
const today = new Date()
|
||||
const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`
|
||||
|
||||
function isSameDay(iso?: string | null) {
|
||||
if (!iso) return false
|
||||
return String(iso).startsWith(todayStr)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const [apptRes, reportRes, leadRes] = await Promise.all([
|
||||
getAppointmentList({ page: 1, pageSize: 200 }),
|
||||
getReportList({ page: 1, pageSize: 200 }),
|
||||
getLeads('pending'),
|
||||
])
|
||||
const appts = Array.isArray(apptRes.data) ? apptRes.data as Array<Record<string, unknown>> : []
|
||||
const reports = Array.isArray(reportRes.data) ? reportRes.data as Array<Record<string, unknown>> : []
|
||||
const leads = Array.isArray(leadRes.data) ? leadRes.data as Array<Record<string, unknown>> : []
|
||||
|
||||
todayAppt.value = appts.filter((a) => isSameDay(a.appointmentTime as string)).length
|
||||
todayDone.value = appts.filter((a) => a.status === 'done' && isSameDay((a.updateTime as string) || (a.appointmentTime as string))).length
|
||||
pendingReport.value = appts.filter((a) => a.status === 'doing' && a.hasReport !== true).length
|
||||
pendingLead.value = leads.filter((l) => {
|
||||
const d = l.remindDate as string | undefined
|
||||
return !d || d <= todayStr
|
||||
}).length
|
||||
todayLeads.value = leads.filter((l) => isSameDay(l.createTime as string)).length
|
||||
todayReports.value = reports.filter((r) => isSameDay(r.createTime as string)).length
|
||||
highlightFailed.value = reports.filter(
|
||||
(r) =>
|
||||
r.highlightVideoStatus === 'failed' ||
|
||||
isHighlightProcessingStale(r.highlightVideoStatus as string, r.updateTime as string),
|
||||
).length
|
||||
|
||||
const suppressed = localStorage.getItem(SUPPRESS_KEY)
|
||||
if (suppressed !== todayStr) {
|
||||
todoDialog.value = true
|
||||
}
|
||||
})
|
||||
|
||||
const cards = computed(() => [
|
||||
{ key: 'appt', label: '今日预约', value: todayAppt.value, to: { path: '/appointments', query: { date: 'today' } } },
|
||||
{ key: 'done', label: '今日完成', value: todayDone.value, to: { path: '/appointments', query: { status: 'done', date: 'today' } } },
|
||||
{ key: 'lead', label: '待回访', value: pendingLead.value, to: { path: '/leads', query: { due: 'today' } } },
|
||||
{ key: 'hl', label: '成片失败', value: highlightFailed.value, to: { path: '/reports', query: { highlightStatus: 'failed' } } },
|
||||
])
|
||||
|
||||
const todos = computed(() => [
|
||||
{ key: 'a', label: '今日预约', value: todayAppt.value, to: { path: '/appointments', query: { date: 'today' } } },
|
||||
{ key: 'r', label: '待出报告', value: pendingReport.value, to: { path: '/appointments', query: { status: 'doing', missingReport: '1' } } },
|
||||
{ key: 'h', label: '成片异常', value: highlightFailed.value, to: { path: '/reports', query: { highlightStatus: 'failed' } } },
|
||||
{ key: 'l', label: '待回访', value: pendingLead.value, to: { path: '/leads', query: { due: 'today' } } },
|
||||
])
|
||||
|
||||
const funnel = computed(() => [
|
||||
{ label: '今日已发报告', value: todayReports.value },
|
||||
{ label: '已打开', value: '—' },
|
||||
{ label: '已留资', value: todayLeads.value },
|
||||
{ label: '报告转预约', value: '—' },
|
||||
])
|
||||
|
||||
function go(to: { path: string; query?: Record<string, string | undefined> }) {
|
||||
const query: Record<string, string> = {}
|
||||
if (to.query) {
|
||||
for (const [k, v] of Object.entries(to.query)) {
|
||||
if (v != null) query[k] = v
|
||||
}
|
||||
}
|
||||
router.push({ path: to.path, query })
|
||||
}
|
||||
|
||||
function goFromDialog(to: { path: string; query?: Record<string, string | undefined> }) {
|
||||
closeTodoDialog()
|
||||
go(to)
|
||||
}
|
||||
|
||||
function closeTodoDialog() {
|
||||
if (suppressToday.value) {
|
||||
localStorage.setItem(SUPPRESS_KEY, todayStr)
|
||||
}
|
||||
todoDialog.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.metrics {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.metric {
|
||||
cursor: pointer;
|
||||
}
|
||||
.metric-label {
|
||||
color: #7a8494;
|
||||
font-size: 13px;
|
||||
}
|
||||
.metric-value {
|
||||
margin-top: 8px;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.todos {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.todo {
|
||||
cursor: pointer;
|
||||
}
|
||||
.funnel-item {
|
||||
background: #f7f9fc;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
}
|
||||
</style>
|
||||
18
tsconfig.app.json
Normal file
18
tsconfig.app.json
Normal file
@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"types": ["vite/client"],
|
||||
"allowArbitraryExtensions": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"ignoreDeprecations": "6.0",
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
||||
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
23
tsconfig.node.json
Normal file
23
tsconfig.node.json
Normal file
@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"module": "nodenext",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
25
vite.config.ts
Normal file
25
vite.config.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5174,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/uploads': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user