chore: initialize store admin console

This commit is contained in:
malei 2026-07-31 18:46:16 +08:00
commit 5220da21c5
31 changed files with 4560 additions and 0 deletions

1
.env.development Normal file
View File

@ -0,0 +1 @@
VITE_PUBLIC_H5_ORIGIN=http://localhost:5173

2
.env.example Normal file
View File

@ -0,0 +1,2 @@
# 宠主公开报告 H5 根地址(复制链接用),生产请改为实际域名
VITE_PUBLIC_H5_ORIGIN=https://example.com

26
.gitignore vendored Normal file
View 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
View File

@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

28
README.md Normal file
View 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/**`
- 客户聚合 APIBackend Core → `GET /api/admin/service-customers`

13
index.html Normal file
View 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

File diff suppressed because it is too large Load Diff

27
package.json Normal file
View 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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
public/icons.svg Normal file
View 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
View File

@ -0,0 +1,6 @@
<template>
<router-view />
</template>
<script setup lang="ts">
</script>

67
src/api/http.ts Normal file
View 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
View 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 })

View 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
View 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
View 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
View 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
View 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 5174uni 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
View 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'
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

23
tsconfig.node.json Normal file
View 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
View 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,
},
},
},
})