添加前端代码和FRP配置文件
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
frp/frpc
|
||||
frp/frps
|
||||
frp/frpc
|
||||
frp/frps
|
||||
frontend/node_modules/
|
||||
6
frontend/frontend_beautiful.log
Normal file
6
frontend/frontend_beautiful.log
Normal file
@@ -0,0 +1,6 @@
|
||||
|
||||
VITE v5.4.21 ready in 2321 ms
|
||||
|
||||
➜ Local: http://localhost:5173/
|
||||
➜ Network: http://10.255.255.254:5173/
|
||||
➜ Network: http://172.27.77.221:5173/
|
||||
6
frontend/frontend_final.log
Normal file
6
frontend/frontend_final.log
Normal file
@@ -0,0 +1,6 @@
|
||||
|
||||
VITE v5.4.21 ready in 2041 ms
|
||||
|
||||
➜ Local: http://localhost:5173/
|
||||
➜ Network: http://10.255.255.254:5173/
|
||||
➜ Network: http://172.27.77.221:5173/
|
||||
6
frontend/frontend_improved.log
Normal file
6
frontend/frontend_improved.log
Normal file
@@ -0,0 +1,6 @@
|
||||
|
||||
VITE v5.4.21 ready in 2178 ms
|
||||
|
||||
➜ Local: http://localhost:5173/
|
||||
➜ Network: http://10.255.255.254:5173/
|
||||
➜ Network: http://172.27.77.221:5173/
|
||||
9
frontend/frontend_output.log
Normal file
9
frontend/frontend_output.log
Normal file
@@ -0,0 +1,9 @@
|
||||
|
||||
> gpf-pet-hospital-frontend@0.1.0 dev
|
||||
> vite
|
||||
|
||||
|
||||
VITE v5.4.21 ready in 2375 ms
|
||||
|
||||
➜ Local: http://localhost:5173/
|
||||
➜ Network: use --host to expose
|
||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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>
|
||||
1577
frontend/package-lock.json
generated
Normal file
1577
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
frontend/package.json
Normal file
23
frontend/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "gpf-pet-hospital-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.6.7",
|
||||
"pinia": "^2.1.7",
|
||||
"tdesign-vue-next": "^1.8.8",
|
||||
"vue": "^3.4.15",
|
||||
"vue-router": "^4.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.0.12"
|
||||
}
|
||||
}
|
||||
3
frontend/src/App.vue
Normal file
3
frontend/src/App.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<RouterView />
|
||||
</template>
|
||||
23
frontend/src/api/http.ts
Normal file
23
frontend/src/api/http.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import axios from 'axios';
|
||||
import { useAuthStore } from '../store/auth';
|
||||
|
||||
const http = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
http.interceptors.request.use((config) => {
|
||||
const auth = useAuthStore();
|
||||
if (auth.token) {
|
||||
config.headers = config.headers || {};
|
||||
config.headers.Authorization = `Bearer ${auth.token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
http.interceptors.response.use(
|
||||
(resp) => resp.data,
|
||||
(error) => Promise.reject(error),
|
||||
);
|
||||
|
||||
export default http;
|
||||
69
frontend/src/api/index.ts
Normal file
69
frontend/src/api/index.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import http from './http';
|
||||
|
||||
export const api = {
|
||||
login: (payload: { account: string; password: string }) => http.post('/auth/login', payload),
|
||||
register: (payload: { username: string; phone?: string; email?: string; password: string }) =>
|
||||
http.post('/auth/register', payload),
|
||||
me: () => http.get('/users/me'),
|
||||
|
||||
notices: (params?: any) => http.get('/public/notices', { params }),
|
||||
noticesAdmin: (params?: any) => http.get('/notices', { params }),
|
||||
createNotice: (payload: any) => http.post('/notices', payload),
|
||||
updateNotice: (id: number, payload: any) => http.put(`/notices/${id}`, payload),
|
||||
deleteNotice: (id: number) => http.delete(`/notices/${id}`),
|
||||
|
||||
pets: (params?: any) => http.get('/pets', { params }),
|
||||
createPet: (payload: any) => http.post('/pets', payload),
|
||||
updatePet: (id: number, payload: any) => http.put(`/pets/${id}`, payload),
|
||||
deletePet: (id: number) => http.delete(`/pets/${id}`),
|
||||
|
||||
appointments: (params?: any) => http.get('/appointments', { params }),
|
||||
createAppointment: (payload: any) => http.post('/appointments', payload),
|
||||
adminAppointments: (params?: any) => http.get('/appointments/admin', { params }),
|
||||
updateAppointmentStatus: (id: number, status: string) => http.put(`/appointments/${id}/status`, null, { params: { status } }),
|
||||
|
||||
visits: (params?: any) => http.get('/visits', { params }),
|
||||
createVisit: (payload: any) => http.post('/visits', payload),
|
||||
updateVisit: (id: number, payload: any) => http.put(`/visits/${id}`, payload),
|
||||
|
||||
medicalRecords: (params?: any) => http.get('/medical-records', { params }),
|
||||
createMedicalRecord: (payload: any) => http.post('/medical-records', payload),
|
||||
updateMedicalRecord: (id: number, payload: any) => http.put(`/medical-records/${id}`, payload),
|
||||
deleteMedicalRecord: (id: number) => http.delete(`/medical-records/${id}`),
|
||||
|
||||
prescriptions: (params?: any) => http.get('/prescriptions', { params }),
|
||||
createPrescription: (payload: any) => http.post('/prescriptions', payload),
|
||||
updatePrescription: (id: number, payload: any) => http.put(`/prescriptions/${id}`, payload),
|
||||
|
||||
prescriptionItems: (params?: any) => http.get('/prescription-items', { params }),
|
||||
|
||||
reports: (params?: any) => http.get('/reports', { params }),
|
||||
createReport: (payload: any) => http.post('/reports', payload),
|
||||
updateReport: (id: number, payload: any) => http.put(`/reports/${id}`, payload),
|
||||
deleteReport: (id: number) => http.delete(`/reports/${id}`),
|
||||
|
||||
orders: (params?: any) => http.get('/orders', { params }),
|
||||
createOrder: (payload: any) => http.post('/orders', payload),
|
||||
updateOrder: (id: number, payload: any) => http.put(`/orders/${id}`, payload),
|
||||
|
||||
drugs: (params?: any) => http.get('/drugs', { params }),
|
||||
createDrug: (payload: any) => http.post('/drugs', payload),
|
||||
updateDrug: (id: number, payload: any) => http.put(`/drugs/${id}`, payload),
|
||||
deleteDrug: (id: number) => http.delete(`/drugs/${id}`),
|
||||
|
||||
stockIn: (params?: any) => http.get('/stock-in', { params }),
|
||||
createStockIn: (payload: any) => http.post('/stock-in', payload),
|
||||
stockOut: (params?: any) => http.get('/stock-out', { params }),
|
||||
createStockOut: (payload: any) => http.post('/stock-out', payload),
|
||||
|
||||
messages: (params?: any) => http.get('/messages/admin', { params }),
|
||||
createMessage: (payload: any) => http.post('/messages', payload),
|
||||
replyMessage: (id: number, payload: any) => http.put(`/messages/admin/${id}/reply`, payload),
|
||||
|
||||
users: (params?: any) => http.get('/users', { params }),
|
||||
createUser: (payload: any) => http.post('/users', payload),
|
||||
updateUserStatus: (id: number, status: number) => http.put(`/users/${id}/status`, null, { params: { status } }),
|
||||
resetUserPassword: (id: number, newPassword: string) => http.put(`/users/${id}/reset-password`, null, { params: { newPassword } }),
|
||||
|
||||
stats: () => http.get('/admin/stats'),
|
||||
};
|
||||
23
frontend/src/config/menu.ts
Normal file
23
frontend/src/config/menu.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export interface MenuItem {
|
||||
label: string;
|
||||
path: string;
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
export const menuItems: MenuItem[] = [
|
||||
{ label: '仪表盘', path: '/dashboard', roles: ['ADMIN', 'DOCTOR', 'CUSTOMER'] },
|
||||
{ label: '公告管理', path: '/notices', roles: ['ADMIN'] },
|
||||
{ label: '宠物档案', path: '/pets', roles: ['ADMIN', 'DOCTOR', 'CUSTOMER'] },
|
||||
{ label: '门诊预约', path: '/appointments', roles: ['ADMIN', 'DOCTOR', 'CUSTOMER'] },
|
||||
{ label: '就诊记录', path: '/visits', roles: ['ADMIN', 'DOCTOR'] },
|
||||
{ label: '病历管理', path: '/records', roles: ['ADMIN', 'DOCTOR'] },
|
||||
{ label: '处方管理', path: '/prescriptions', roles: ['ADMIN', 'DOCTOR'] },
|
||||
{ label: '报告查询', path: '/reports', roles: ['ADMIN', 'DOCTOR', 'CUSTOMER'] },
|
||||
{ label: '订单管理', path: '/orders', roles: ['ADMIN', 'CUSTOMER'] },
|
||||
{ label: '药品管理', path: '/drugs', roles: ['ADMIN'] },
|
||||
{ label: '入库流水', path: '/stock-in', roles: ['ADMIN'] },
|
||||
{ label: '出库流水', path: '/stock-out', roles: ['ADMIN'] },
|
||||
{ label: '留言板', path: '/messages', roles: ['ADMIN'] },
|
||||
{ label: '账号管理', path: '/users', roles: ['ADMIN'] },
|
||||
{ label: '统计报表', path: '/stats', roles: ['ADMIN'] },
|
||||
];
|
||||
84
frontend/src/layouts/MainLayout.vue
Normal file
84
frontend/src/layouts/MainLayout.vue
Normal file
@@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<t-layout class="layout">
|
||||
<t-aside class="sidebar">
|
||||
<div class="brand">爱维宠物医院</div>
|
||||
<t-menu :value="active" @change="onMenu">
|
||||
<t-menu-item v-for="item in visibleMenu" :key="item.path" :value="item.path">
|
||||
{{ item.label }}
|
||||
</t-menu-item>
|
||||
</t-menu>
|
||||
</t-aside>
|
||||
<t-layout>
|
||||
<t-header class="header">
|
||||
<div class="spacer"></div>
|
||||
<t-space>
|
||||
<t-tag theme="primary" variant="light">{{ auth.user.role || '游客' }}</t-tag>
|
||||
<t-button theme="default" variant="text" @click="logout">退出</t-button>
|
||||
</t-space>
|
||||
</t-header>
|
||||
<t-content class="content">
|
||||
<RouterView />
|
||||
</t-content>
|
||||
</t-layout>
|
||||
</t-layout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useAuthStore } from '../store/auth';
|
||||
import { menuItems } from '../config/menu';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
|
||||
const active = computed(() => route.path);
|
||||
const visibleMenu = computed(() => {
|
||||
const role = auth.user?.role || '';
|
||||
return menuItems.filter((item) => item.roles.includes(role));
|
||||
});
|
||||
|
||||
const onMenu = (value: string) => {
|
||||
router.push(value);
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
auth.clear();
|
||||
router.push('/login');
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.layout {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 220px;
|
||||
background: #0f172a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.brand {
|
||||
padding: 16px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.content {
|
||||
min-height: calc(100vh - 56px);
|
||||
}
|
||||
</style>
|
||||
13
frontend/src/main.ts
Normal file
13
frontend/src/main.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { createApp } from 'vue';
|
||||
import { createPinia } from 'pinia';
|
||||
import TDesign from 'tdesign-vue-next';
|
||||
import 'tdesign-vue-next/es/style/index.css';
|
||||
import './styles/global.css';
|
||||
import App from './App.vue';
|
||||
import router from './router';
|
||||
|
||||
const app = createApp(App);
|
||||
app.use(createPinia());
|
||||
app.use(router);
|
||||
app.use(TDesign);
|
||||
app.mount('#app');
|
||||
170
frontend/src/pages/AppointmentPage.vue
Normal file
170
frontend/src/pages/AppointmentPage.vue
Normal file
@@ -0,0 +1,170 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<h2 class="page-title">门诊预约</h2>
|
||||
<div class="panel">
|
||||
<div class="inline-form">
|
||||
<t-select v-model="query.status" :options="statusOptions" placeholder="状态筛选" style="width: 160px" />
|
||||
<t-button theme="primary" @click="load">
|
||||
<template #icon><t-icon name="refresh" /></template>
|
||||
刷新
|
||||
</t-button>
|
||||
<t-button variant="outline" @click="openCreate">
|
||||
<template #icon><t-icon name="add" /></template>
|
||||
新增预约
|
||||
</t-button>
|
||||
</div>
|
||||
<t-table :data="list" :columns="columns" row-key="id" bordered stripe hover>
|
||||
<template #statusSlot="{ row }">
|
||||
<span class="status-badge" :class="getStatusClass(row.status)">
|
||||
{{ getStatusText(row.status) }}
|
||||
</span>
|
||||
</template>
|
||||
<template #op="{ row }">
|
||||
<t-select
|
||||
v-model="row.status"
|
||||
size="small"
|
||||
:options="statusOptions"
|
||||
style="width: 120px"
|
||||
@change="(val) => updateStatus(row.id, val)"
|
||||
/>
|
||||
</template>
|
||||
</t-table>
|
||||
</div>
|
||||
|
||||
<t-dialog v-model:visible="dialogVisible" header="新增预约" :on-confirm="submitCreate" width="500">
|
||||
<t-form :data="form" layout="vertical">
|
||||
<t-form-item label="宠物" required>
|
||||
<t-select v-model="form.petId" :options="petOptions" placeholder="请选择宠物" clearable />
|
||||
</t-form-item>
|
||||
<t-form-item label="预约日期" required>
|
||||
<t-date-picker v-model="form.appointmentDate" placeholder="请选择预约日期" />
|
||||
</t-form-item>
|
||||
<t-form-item label="时间段" required>
|
||||
<t-input v-model="form.timeSlot" placeholder="请输入时间段,如:09:00-10:00" />
|
||||
</t-form-item>
|
||||
<t-form-item label="备注">
|
||||
<t-textarea v-model="form.remark" placeholder="请输入备注信息" :maxlength="200" />
|
||||
</t-form-item>
|
||||
</t-form>
|
||||
</t-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref, computed } from 'vue';
|
||||
import { MessagePlugin } from 'tdesign-vue-next';
|
||||
import { api } from '../api';
|
||||
|
||||
const list = ref([] as any[]);
|
||||
const pets = ref([] as any[]);
|
||||
const dialogVisible = ref(false);
|
||||
const query = reactive({ status: '' });
|
||||
const form = reactive({ petId: '', appointmentDate: '', timeSlot: '' });
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '待确认', value: 'PENDING' },
|
||||
{ label: '已确认', value: 'CONFIRMED' },
|
||||
{ label: '已到诊', value: 'ARRIVED' },
|
||||
{ label: '已取消', value: 'CANCELLED' },
|
||||
{ label: '爽约', value: 'NO_SHOW' },
|
||||
];
|
||||
|
||||
const petOptions = computed(() => {
|
||||
return pets.value.map((pet: any) => ({
|
||||
label: `${pet.name} (${pet.breed})`,
|
||||
value: pet.id
|
||||
}));
|
||||
});
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
const statusMap: Record<string, string> = {
|
||||
'PENDING': '待确认',
|
||||
'CONFIRMED': '已确认',
|
||||
'ARRIVED': '已到诊',
|
||||
'CANCELLED': '已取消',
|
||||
'NO_SHOW': '爽约'
|
||||
};
|
||||
return statusMap[status] || status;
|
||||
};
|
||||
|
||||
const getStatusClass = (status: string) => {
|
||||
const classMap: Record<string, string> = {
|
||||
'PENDING': 'status-pending',
|
||||
'CONFIRMED': 'status-active',
|
||||
'ARRIVED': 'status-active',
|
||||
'CANCELLED': 'status-cancelled',
|
||||
'NO_SHOW': 'status-cancelled'
|
||||
};
|
||||
return classMap[status] || 'status-pending';
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ colKey: 'id', title: 'ID', width: 80 },
|
||||
{ colKey: 'petId', title: '宠物ID' },
|
||||
{ colKey: 'appointmentDate', title: '预约日期' },
|
||||
{ colKey: 'timeSlot', title: '时段' },
|
||||
{ colKey: 'statusSlot', title: '状态' },
|
||||
{ colKey: 'op', title: '流转', width: 140 },
|
||||
];
|
||||
|
||||
const statusTheme = (status: string) => {
|
||||
if (status === 'CONFIRMED') return 'success';
|
||||
if (status === 'CANCELLED') return 'danger';
|
||||
if (status === 'ARRIVED') return 'primary';
|
||||
return 'warning';
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
const params: any = { page: 1, size: 20 };
|
||||
if (query.status) params.status = query.status;
|
||||
const res = await api.adminAppointments(params);
|
||||
if (res.code === 0) list.value = res.data.records || [];
|
||||
};
|
||||
|
||||
const updateStatus = async (id: number, status: string) => {
|
||||
const res = await api.updateAppointmentStatus(id, status);
|
||||
if (res.code === 0) {
|
||||
MessagePlugin.success('状态已更新');
|
||||
load();
|
||||
} else {
|
||||
MessagePlugin.error(res.message || '更新失败');
|
||||
}
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
form.petId = '';
|
||||
form.appointmentDate = '';
|
||||
form.timeSlot = '';
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
const loadPets = async () => {
|
||||
try {
|
||||
const res = await api.pets({ page: 1, size: 100 }); // 获取所有宠物
|
||||
if (res.code === 0) {
|
||||
pets.value = res.data?.records || [];
|
||||
} else {
|
||||
MessagePlugin.error(res.message || '获取宠物列表失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取宠物列表失败:', error);
|
||||
MessagePlugin.error('获取宠物列表失败');
|
||||
}
|
||||
};
|
||||
|
||||
const submitCreate = async () => {
|
||||
const res = await api.createAppointment({ ...form });
|
||||
if (res.code === 0) {
|
||||
MessagePlugin.success('创建成功');
|
||||
dialogVisible.value = false;
|
||||
load();
|
||||
} else {
|
||||
MessagePlugin.error(res.message || '创建失败');
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
load();
|
||||
loadPets(); // 加载宠物列表
|
||||
});
|
||||
</script>
|
||||
59
frontend/src/pages/Dashboard.vue
Normal file
59
frontend/src/pages/Dashboard.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<h2 class="page-title">仪表盘</h2>
|
||||
<t-row :gutter="16">
|
||||
<t-col :span="3" v-for="item in cards" :key="item.label">
|
||||
<div class="panel card">
|
||||
<div class="label">{{ item.label }}</div>
|
||||
<div class="value">{{ item.value }}</div>
|
||||
</div>
|
||||
</t-col>
|
||||
</t-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { api } from '../api';
|
||||
|
||||
const cards = ref([
|
||||
{ label: '订单数量', value: 0 },
|
||||
{ label: '预约数量', value: 0 },
|
||||
{ label: '就诊数量', value: 0 },
|
||||
{ label: '宠物数量', value: 0 },
|
||||
]);
|
||||
|
||||
const load = async () => {
|
||||
const res = await api.stats();
|
||||
if (res.code === 0) {
|
||||
cards.value = [
|
||||
{ label: '订单数量', value: res.data.orders },
|
||||
{ label: '预约数量', value: res.data.appointments },
|
||||
{ label: '就诊数量', value: res.data.visits },
|
||||
{ label: '宠物数量', value: res.data.pets },
|
||||
];
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card {
|
||||
height: 110px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
112
frontend/src/pages/DrugPage.vue
Normal file
112
frontend/src/pages/DrugPage.vue
Normal file
@@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<h2 class="page-title">药品管理</h2>
|
||||
<div class="panel">
|
||||
<div class="inline-form">
|
||||
<t-input v-model="query.keyword" placeholder="关键词" style="width: 200px" />
|
||||
<t-button theme="primary" @click="load">查询</t-button>
|
||||
<t-button variant="outline" @click="openCreate">新增药品</t-button>
|
||||
</div>
|
||||
<t-table :data="list" :columns="columns" row-key="id">
|
||||
<template #op="{ row }">
|
||||
<div class="table-actions">
|
||||
<t-button size="small" variant="text" @click="openEdit(row)">编辑</t-button>
|
||||
<t-popconfirm content="确认删除?" @confirm="remove(row.id)">
|
||||
<t-button size="small" theme="danger" variant="text">删除</t-button>
|
||||
</t-popconfirm>
|
||||
</div>
|
||||
</template>
|
||||
</t-table>
|
||||
</div>
|
||||
|
||||
<t-dialog v-model:visible="dialogVisible" :header="dialogTitle" :on-confirm="submit">
|
||||
<t-form :data="form">
|
||||
<t-form-item label="名称"><t-input v-model="form.name" /></t-form-item>
|
||||
<t-form-item label="规格"><t-input v-model="form.specification" /></t-form-item>
|
||||
<t-form-item label="单位"><t-input v-model="form.unit" /></t-form-item>
|
||||
<t-form-item label="库存"><t-input v-model="form.stock" /></t-form-item>
|
||||
<t-form-item label="状态"><t-select v-model="form.status" :options="statusOptions" /></t-form-item>
|
||||
</t-form>
|
||||
</t-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { MessagePlugin } from 'tdesign-vue-next';
|
||||
import { api } from '../api';
|
||||
|
||||
const list = ref([] as any[]);
|
||||
const dialogVisible = ref(false);
|
||||
const dialogTitle = ref('新增药品');
|
||||
const editingId = ref<number | null>(null);
|
||||
const query = reactive({ keyword: '' });
|
||||
const form = reactive({ name: '', specification: '', unit: '', stock: '', status: 1 });
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '启用', value: 1 },
|
||||
{ label: '禁用', value: 0 },
|
||||
];
|
||||
|
||||
const columns = [
|
||||
{ colKey: 'id', title: 'ID', width: 80 },
|
||||
{ colKey: 'name', title: '名称' },
|
||||
{ colKey: 'specification', title: '规格' },
|
||||
{ colKey: 'unit', title: '单位' },
|
||||
{ colKey: 'stock', title: '库存' },
|
||||
{ colKey: 'op', title: '操作', width: 140 },
|
||||
];
|
||||
|
||||
const load = async () => {
|
||||
const res = await api.drugs({ page: 1, size: 20, keyword: query.keyword });
|
||||
if (res.code === 0) list.value = res.data.records || [];
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
dialogTitle.value = '新增药品';
|
||||
editingId.value = null;
|
||||
form.name = '';
|
||||
form.specification = '';
|
||||
form.unit = '';
|
||||
form.stock = '';
|
||||
form.status = 1;
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
const openEdit = (row: any) => {
|
||||
dialogTitle.value = '编辑药品';
|
||||
editingId.value = row.id;
|
||||
form.name = row.name || '';
|
||||
form.specification = row.specification || '';
|
||||
form.unit = row.unit || '';
|
||||
form.stock = row.stock || '';
|
||||
form.status = row.status ?? 1;
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
const payload = { ...form };
|
||||
const res = editingId.value
|
||||
? await api.updateDrug(editingId.value, payload)
|
||||
: await api.createDrug(payload);
|
||||
if (res.code === 0) {
|
||||
MessagePlugin.success('保存成功');
|
||||
dialogVisible.value = false;
|
||||
load();
|
||||
} else {
|
||||
MessagePlugin.error(res.message || '保存失败');
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (id: number) => {
|
||||
const res = await api.deleteDrug(id);
|
||||
if (res.code === 0) {
|
||||
MessagePlugin.success('删除成功');
|
||||
load();
|
||||
} else {
|
||||
MessagePlugin.error(res.message || '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
75
frontend/src/pages/Login.vue
Normal file
75
frontend/src/pages/Login.vue
Normal file
@@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<div class="login">
|
||||
<div class="panel login-card">
|
||||
<h1>爱维宠物医院管理平台</h1>
|
||||
<t-form :data="form" @submit="onSubmit">
|
||||
<t-form-item label="账号">
|
||||
<t-input v-model="form.account" placeholder="用户名/手机号/邮箱" />
|
||||
</t-form-item>
|
||||
<t-form-item label="密码">
|
||||
<t-input v-model="form.password" type="password" placeholder="请输入密码" />
|
||||
</t-form-item>
|
||||
<t-form-item>
|
||||
<t-button theme="primary" type="submit" block>登录</t-button>
|
||||
</t-form-item>
|
||||
</t-form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { MessagePlugin } from 'tdesign-vue-next';
|
||||
import { api } from '../api';
|
||||
import { useAuthStore } from '../store/auth';
|
||||
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
|
||||
const form = reactive({
|
||||
account: '',
|
||||
password: '',
|
||||
});
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (!form.account || !form.password) {
|
||||
MessagePlugin.warning('请输入账号与密码');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await api.login(form);
|
||||
if (res.code === 0) {
|
||||
auth.setAuth(res.data.token, {
|
||||
userId: res.data.userId,
|
||||
username: res.data.username,
|
||||
role: res.data.role,
|
||||
});
|
||||
router.push('/dashboard');
|
||||
} else {
|
||||
MessagePlugin.error(res.message || '登录失败');
|
||||
}
|
||||
} catch {
|
||||
MessagePlugin.error('登录失败');
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #e2e8f0, #f8fafc);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 360px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 16px;
|
||||
font-size: 18px;
|
||||
}
|
||||
</style>
|
||||
108
frontend/src/pages/MedicalRecordPage.vue
Normal file
108
frontend/src/pages/MedicalRecordPage.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<h2 class="page-title">病历管理</h2>
|
||||
<div class="panel">
|
||||
<div class="inline-form">
|
||||
<t-input v-model="query.visitId" placeholder="就诊ID" style="width: 160px" />
|
||||
<t-button theme="primary" @click="load">查询</t-button>
|
||||
<t-button variant="outline" @click="openCreate">新增病历</t-button>
|
||||
</div>
|
||||
<t-table :data="list" :columns="columns" row-key="id">
|
||||
<template #op="{ row }">
|
||||
<div class="table-actions">
|
||||
<t-button size="small" variant="text" @click="openEdit(row)">编辑</t-button>
|
||||
<t-popconfirm content="确认删除?" @confirm="remove(row.id)">
|
||||
<t-button size="small" theme="danger" variant="text">删除</t-button>
|
||||
</t-popconfirm>
|
||||
</div>
|
||||
</template>
|
||||
</t-table>
|
||||
</div>
|
||||
|
||||
<t-dialog v-model:visible="dialogVisible" :header="dialogTitle" :on-confirm="submit">
|
||||
<t-form :data="form">
|
||||
<t-form-item label="就诊ID"><t-input v-model="form.visitId" /></t-form-item>
|
||||
<t-form-item label="主诉"><t-input v-model="form.chiefComplaint" /></t-form-item>
|
||||
<t-form-item label="诊断"><t-input v-model="form.diagnosis" /></t-form-item>
|
||||
<t-form-item label="状态"><t-select v-model="form.status" :options="statusOptions" /></t-form-item>
|
||||
</t-form>
|
||||
</t-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue';
|
||||
import { MessagePlugin } from 'tdesign-vue-next';
|
||||
import { api } from '../api';
|
||||
|
||||
const list = ref([] as any[]);
|
||||
const dialogVisible = ref(false);
|
||||
const dialogTitle = ref('新增病历');
|
||||
const editingId = ref<number | null>(null);
|
||||
const query = reactive({ visitId: '' });
|
||||
const form = reactive({ visitId: '', chiefComplaint: '', diagnosis: '', status: 'DRAFT' });
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '草稿', value: 'DRAFT' },
|
||||
{ label: '已完成', value: 'COMPLETED' },
|
||||
];
|
||||
|
||||
const columns = [
|
||||
{ colKey: 'id', title: 'ID', width: 80 },
|
||||
{ colKey: 'visitId', title: '就诊ID' },
|
||||
{ colKey: 'chiefComplaint', title: '主诉' },
|
||||
{ colKey: 'diagnosis', title: '诊断' },
|
||||
{ colKey: 'status', title: '状态' },
|
||||
{ colKey: 'op', title: '操作', width: 140 },
|
||||
];
|
||||
|
||||
const load = async () => {
|
||||
if (!query.visitId) return;
|
||||
const res = await api.medicalRecords({ visitId: query.visitId });
|
||||
if (res.code === 0) list.value = res.data || [];
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
dialogTitle.value = '新增病历';
|
||||
editingId.value = null;
|
||||
form.visitId = query.visitId;
|
||||
form.chiefComplaint = '';
|
||||
form.diagnosis = '';
|
||||
form.status = 'DRAFT';
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
const openEdit = (row: any) => {
|
||||
dialogTitle.value = '编辑病历';
|
||||
editingId.value = row.id;
|
||||
form.visitId = row.visitId;
|
||||
form.chiefComplaint = row.chiefComplaint || '';
|
||||
form.diagnosis = row.diagnosis || '';
|
||||
form.status = row.status || 'DRAFT';
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
const payload = { ...form };
|
||||
const res = editingId.value
|
||||
? await api.updateMedicalRecord(editingId.value, payload)
|
||||
: await api.createMedicalRecord(payload);
|
||||
if (res.code === 0) {
|
||||
MessagePlugin.success('保存成功');
|
||||
dialogVisible.value = false;
|
||||
load();
|
||||
} else {
|
||||
MessagePlugin.error(res.message || '保存失败');
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (id: number) => {
|
||||
const res = await api.deleteMedicalRecord(id);
|
||||
if (res.code === 0) {
|
||||
MessagePlugin.success('删除成功');
|
||||
load();
|
||||
} else {
|
||||
MessagePlugin.error(res.message || '删除失败');
|
||||
}
|
||||
};
|
||||
</script>
|
||||
77
frontend/src/pages/MessagePage.vue
Normal file
77
frontend/src/pages/MessagePage.vue
Normal file
@@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<h2 class="page-title">留言板</h2>
|
||||
<div class="panel">
|
||||
<div class="inline-form">
|
||||
<t-select v-model="query.status" :options="statusOptions" placeholder="状态" style="width: 160px" />
|
||||
<t-button theme="primary" @click="load">查询</t-button>
|
||||
</div>
|
||||
<t-table :data="list" :columns="columns" row-key="id">
|
||||
<template #op="{ row }">
|
||||
<t-button size="small" variant="text" @click="openReply(row)">回复</t-button>
|
||||
</template>
|
||||
</t-table>
|
||||
</div>
|
||||
|
||||
<t-dialog v-model:visible="dialogVisible" header="回复留言" :on-confirm="submitReply">
|
||||
<t-form :data="form">
|
||||
<t-form-item label="回复内容">
|
||||
<t-textarea v-model="form.reply" />
|
||||
</t-form-item>
|
||||
</t-form>
|
||||
</t-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { MessagePlugin } from 'tdesign-vue-next';
|
||||
import { api } from '../api';
|
||||
|
||||
const list = ref([] as any[]);
|
||||
const query = reactive({ status: '' });
|
||||
const dialogVisible = ref(false);
|
||||
const currentId = ref<number | null>(null);
|
||||
const form = reactive({ reply: '' });
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '待处理', value: 'PENDING' },
|
||||
{ label: '已处理', value: 'PROCESSED' },
|
||||
];
|
||||
|
||||
const columns = [
|
||||
{ colKey: 'id', title: 'ID', width: 80 },
|
||||
{ colKey: 'title', title: '标题' },
|
||||
{ colKey: 'userName', title: '用户' },
|
||||
{ colKey: 'status', title: '状态' },
|
||||
{ colKey: 'reply', title: '回复' },
|
||||
{ colKey: 'op', title: '操作', width: 100 },
|
||||
];
|
||||
|
||||
const load = async () => {
|
||||
const params: any = { page: 1, size: 20 };
|
||||
if (query.status) params.status = query.status;
|
||||
const res = await api.messages(params);
|
||||
if (res.code === 0) list.value = res.data.records || [];
|
||||
};
|
||||
|
||||
const openReply = (row: any) => {
|
||||
currentId.value = row.id;
|
||||
form.reply = row.reply || '';
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
const submitReply = async () => {
|
||||
if (!currentId.value) return;
|
||||
const res = await api.replyMessage(currentId.value, { reply: form.reply });
|
||||
if (res.code === 0) {
|
||||
MessagePlugin.success('回复成功');
|
||||
dialogVisible.value = false;
|
||||
load();
|
||||
} else {
|
||||
MessagePlugin.error(res.message || '回复失败');
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
117
frontend/src/pages/NoticePage.vue
Normal file
117
frontend/src/pages/NoticePage.vue
Normal file
@@ -0,0 +1,117 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<h2 class="page-title">公告管理</h2>
|
||||
<div class="panel">
|
||||
<div class="inline-form">
|
||||
<t-input v-model="query.keyword" placeholder="标题关键词" style="width: 220px" />
|
||||
<t-button theme="primary" @click="load">查询</t-button>
|
||||
<t-button variant="outline" @click="openCreate">新建公告</t-button>
|
||||
</div>
|
||||
<t-table :data="list" :columns="columns" row-key="id">
|
||||
<template #op="{ row }">
|
||||
<div class="table-actions">
|
||||
<t-button size="small" variant="text" @click="openEdit(row)">编辑</t-button>
|
||||
<t-popconfirm content="确认删除?" @confirm="remove(row.id)">
|
||||
<t-button size="small" theme="danger" variant="text">删除</t-button>
|
||||
</t-popconfirm>
|
||||
</div>
|
||||
</template>
|
||||
</t-table>
|
||||
</div>
|
||||
|
||||
<t-dialog v-model:visible="dialogVisible" :header="dialogTitle" :on-confirm="submit">
|
||||
<t-form :data="form">
|
||||
<t-form-item label="标题">
|
||||
<t-input v-model="form.title" />
|
||||
</t-form-item>
|
||||
<t-form-item label="内容">
|
||||
<t-textarea v-model="form.content" />
|
||||
</t-form-item>
|
||||
<t-form-item label="状态">
|
||||
<t-select v-model="form.status" :options="statusOptions" />
|
||||
</t-form-item>
|
||||
<t-form-item label="置顶">
|
||||
<t-switch v-model="form.isTop" :custom-value="[1, 0]" />
|
||||
</t-form-item>
|
||||
</t-form>
|
||||
</t-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { MessagePlugin } from 'tdesign-vue-next';
|
||||
import { api } from '../api';
|
||||
|
||||
const query = reactive({ keyword: '' });
|
||||
const list = ref([] as any[]);
|
||||
const dialogVisible = ref(false);
|
||||
const dialogTitle = ref('新建公告');
|
||||
const editingId = ref<number | null>(null);
|
||||
const form = reactive({ title: '', content: '', status: 1, isTop: 0 });
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '上架', value: 1 },
|
||||
{ label: '下架', value: 0 },
|
||||
];
|
||||
|
||||
const columns = [
|
||||
{ colKey: 'id', title: 'ID', width: 80 },
|
||||
{ colKey: 'title', title: '标题' },
|
||||
{ colKey: 'status', title: '状态' },
|
||||
{ colKey: 'isTop', title: '置顶' },
|
||||
{ colKey: 'createTime', title: '发布时间' },
|
||||
{ colKey: 'op', title: '操作', width: 140 },
|
||||
];
|
||||
|
||||
const load = async () => {
|
||||
const res = await api.noticesAdmin({ page: 1, size: 20, keyword: query.keyword });
|
||||
if (res.code === 0) list.value = res.data.records || [];
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
dialogTitle.value = '新建公告';
|
||||
editingId.value = null;
|
||||
form.title = '';
|
||||
form.content = '';
|
||||
form.status = 1;
|
||||
form.isTop = 0;
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
const openEdit = (row: any) => {
|
||||
dialogTitle.value = '编辑公告';
|
||||
editingId.value = row.id;
|
||||
form.title = row.title;
|
||||
form.content = row.content;
|
||||
form.status = row.status ?? 1;
|
||||
form.isTop = row.isTop ?? 0;
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
const payload = { ...form };
|
||||
const res = editingId.value
|
||||
? await api.updateNotice(editingId.value, payload)
|
||||
: await api.createNotice(payload);
|
||||
if (res.code === 0) {
|
||||
MessagePlugin.success('保存成功');
|
||||
dialogVisible.value = false;
|
||||
load();
|
||||
} else {
|
||||
MessagePlugin.error(res.message || '保存失败');
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (id: number) => {
|
||||
const res = await api.deleteNotice(id);
|
||||
if (res.code === 0) {
|
||||
MessagePlugin.success('删除成功');
|
||||
load();
|
||||
} else {
|
||||
MessagePlugin.error(res.message || '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
194
frontend/src/pages/OrderPage.vue
Normal file
194
frontend/src/pages/OrderPage.vue
Normal file
@@ -0,0 +1,194 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<h2 class="page-title">订单管理</h2>
|
||||
<div class="panel">
|
||||
<div class="inline-form">
|
||||
<t-button theme="primary" @click="load">
|
||||
<template #icon><t-icon name="refresh" /></template>
|
||||
刷新
|
||||
</t-button>
|
||||
<t-button variant="outline" @click="openCreate">
|
||||
<template #icon><t-icon name="add" /></template>
|
||||
新增订单
|
||||
</t-button>
|
||||
</div>
|
||||
<t-table :data="list" :columns="columns" row-key="id" bordered stripe hover>
|
||||
<template #status="{ row }">
|
||||
<span class="status-badge" :class="getStatusClass(row.status)">
|
||||
{{ getStatusText(row.status) }}
|
||||
</span>
|
||||
</template>
|
||||
<template #paymentMethod="{ row }">
|
||||
<span>{{ getPaymentMethodText(row.paymentMethod) }}</span>
|
||||
</template>
|
||||
<template #op="{ row }">
|
||||
<div class="table-actions">
|
||||
<t-button size="small" variant="outline" @click="openEdit(row)">
|
||||
<template #icon><t-icon name="edit" /></template>
|
||||
编辑
|
||||
</t-button>
|
||||
</div>
|
||||
</template>
|
||||
</t-table>
|
||||
</div>
|
||||
|
||||
<t-dialog v-model:visible="dialogVisible" :header="dialogTitle" :on-confirm="submit" width="600">
|
||||
<t-form :data="form" layout="vertical">
|
||||
<div class="form-section-title">订单信息</div>
|
||||
<t-form-item label="就诊记录" required>
|
||||
<t-select v-model="form.visitId" :options="visitOptions" placeholder="请选择就诊记录" clearable />
|
||||
</t-form-item>
|
||||
<t-form-item label="订单金额" required>
|
||||
<t-input-number v-model="form.amount" placeholder="0.00" suffix="元" />
|
||||
</t-form-item>
|
||||
<t-row :gutter="[16, 16]">
|
||||
<t-col :span="6">
|
||||
<t-form-item label="订单状态">
|
||||
<t-select v-model="form.status" :options="statusOptions" />
|
||||
</t-form-item>
|
||||
</t-col>
|
||||
<t-col :span="6">
|
||||
<t-form-item label="支付方式">
|
||||
<t-select v-model="form.paymentMethod" :options="payOptions" />
|
||||
</t-form-item>
|
||||
</t-col>
|
||||
</t-row>
|
||||
<t-form-item label="备注">
|
||||
<t-textarea v-model="form.remark" placeholder="请输入备注信息" :maxlength="200" />
|
||||
</t-form-item>
|
||||
</t-form>
|
||||
</t-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref, computed } from 'vue';
|
||||
import { MessagePlugin } from 'tdesign-vue-next';
|
||||
import { api } from '../api';
|
||||
|
||||
const list = ref([] as any[]);
|
||||
const visits = ref([] as any[]);
|
||||
const dialogVisible = ref(false);
|
||||
const dialogTitle = ref('新增订单');
|
||||
const editingId = ref<number | null>(null);
|
||||
const form = reactive({ visitId: '', amount: '', paymentMethod: 'OFFLINE', status: 'UNPAID' });
|
||||
|
||||
const visitOptions = computed(() => {
|
||||
return visits.value.map((visit: any) => ({
|
||||
label: `就诊#${visit.id} - 宠物ID:${visit.petId}, 顾客ID:${visit.customerId}`,
|
||||
value: visit.id
|
||||
}));
|
||||
});
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
const statusMap: Record<string, string> = {
|
||||
'UNPAID': '未支付',
|
||||
'PAID': '已支付',
|
||||
'CANCELLED': '已取消',
|
||||
'REFUNDING': '退款中',
|
||||
'REFUNDED': '已退款'
|
||||
};
|
||||
return statusMap[status] || status;
|
||||
};
|
||||
|
||||
const getStatusClass = (status: string) => {
|
||||
const classMap: Record<string, string> = {
|
||||
'UNPAID': 'status-pending',
|
||||
'PAID': 'status-active',
|
||||
'CANCELLED': 'status-cancelled',
|
||||
'REFUNDING': 'status-warning',
|
||||
'REFUNDED': 'status-cancelled'
|
||||
};
|
||||
return classMap[status] || 'status-pending';
|
||||
};
|
||||
|
||||
const getPaymentMethodText = (method: string) => {
|
||||
const methodMap: Record<string, string> = {
|
||||
'OFFLINE': '线下支付',
|
||||
'ALIPAY': '支付宝',
|
||||
'WECHAT': '微信'
|
||||
};
|
||||
return methodMap[method] || method;
|
||||
};
|
||||
|
||||
const payOptions = [
|
||||
{ label: '线下支付', value: 'OFFLINE' },
|
||||
{ label: '支付宝', value: 'ALIPAY' },
|
||||
{ label: '微信', value: 'WECHAT' },
|
||||
];
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '未支付', value: 'UNPAID' },
|
||||
{ label: '已支付', value: 'PAID' },
|
||||
{ label: '已取消', value: 'CANCELLED' },
|
||||
{ label: '退款中', value: 'REFUNDING' },
|
||||
{ label: '已退款', value: 'REFUNDED' },
|
||||
];
|
||||
|
||||
const columns = [
|
||||
{ colKey: 'id', title: 'ID', width: 80 },
|
||||
{ colKey: 'visitId', title: '就诊ID' },
|
||||
{ colKey: 'amount', title: '金额' },
|
||||
{ colKey: 'status', title: '状态' },
|
||||
{ colKey: 'paymentMethod', title: '支付方式' },
|
||||
{ colKey: 'op', title: '操作', width: 100 },
|
||||
];
|
||||
|
||||
const load = async () => {
|
||||
const res = await api.orders({ page: 1, size: 20 });
|
||||
if (res.code === 0) list.value = res.data.records || [];
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
dialogTitle.value = '新增订单';
|
||||
editingId.value = null;
|
||||
form.visitId = '';
|
||||
form.amount = '';
|
||||
form.paymentMethod = 'OFFLINE';
|
||||
form.status = 'UNPAID';
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
const openEdit = (row: any) => {
|
||||
dialogTitle.value = '编辑订单';
|
||||
editingId.value = row.id;
|
||||
form.visitId = row.visitId || '';
|
||||
form.amount = row.amount || '';
|
||||
form.paymentMethod = row.paymentMethod || 'OFFLINE';
|
||||
form.status = row.status || 'UNPAID';
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
const loadVisits = async () => {
|
||||
try {
|
||||
const res = await api.visits({ page: 1, size: 100 }); // 获取所有就诊记录
|
||||
if (res.code === 0) {
|
||||
visits.value = res.data?.records || [];
|
||||
} else {
|
||||
MessagePlugin.error(res.message || '获取就诊记录失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取就诊记录失败:', error);
|
||||
MessagePlugin.error('获取就诊记录失败');
|
||||
}
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
const payload = { ...form };
|
||||
const res = editingId.value
|
||||
? await api.updateOrder(editingId.value, payload)
|
||||
: await api.createOrder(payload);
|
||||
if (res.code === 0) {
|
||||
MessagePlugin.success('保存成功');
|
||||
dialogVisible.value = false;
|
||||
load();
|
||||
} else {
|
||||
MessagePlugin.error(res.message || '保存失败');
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
load();
|
||||
loadVisits();
|
||||
});
|
||||
</script>
|
||||
111
frontend/src/pages/PetPage.vue
Normal file
111
frontend/src/pages/PetPage.vue
Normal file
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<h2 class="page-title">宠物档案</h2>
|
||||
<div class="panel">
|
||||
<div class="inline-form">
|
||||
<t-input v-model="query.ownerId" placeholder="主人ID" style="width: 160px" />
|
||||
<t-button theme="primary" @click="load">查询</t-button>
|
||||
<t-button variant="outline" @click="openCreate">新增宠物</t-button>
|
||||
</div>
|
||||
<t-table :data="list" :columns="columns" row-key="id">
|
||||
<template #op="{ row }">
|
||||
<div class="table-actions">
|
||||
<t-button size="small" variant="text" @click="openEdit(row)">编辑</t-button>
|
||||
<t-popconfirm content="确认删除?" @confirm="remove(row.id)">
|
||||
<t-button size="small" theme="danger" variant="text">删除</t-button>
|
||||
</t-popconfirm>
|
||||
</div>
|
||||
</template>
|
||||
</t-table>
|
||||
</div>
|
||||
|
||||
<t-dialog v-model:visible="dialogVisible" :header="dialogTitle" :on-confirm="submit">
|
||||
<t-form :data="form">
|
||||
<t-form-item label="名称"><t-input v-model="form.name" /></t-form-item>
|
||||
<t-form-item label="品种"><t-input v-model="form.breed" /></t-form-item>
|
||||
<t-form-item label="性别"><t-select v-model="form.gender" :options="genderOptions" /></t-form-item>
|
||||
<t-form-item label="体重"><t-input v-model="form.weight" /></t-form-item>
|
||||
</t-form>
|
||||
</t-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { MessagePlugin } from 'tdesign-vue-next';
|
||||
import { api } from '../api';
|
||||
|
||||
const query = reactive({ ownerId: '' });
|
||||
const list = ref([] as any[]);
|
||||
const dialogVisible = ref(false);
|
||||
const dialogTitle = ref('新增宠物');
|
||||
const editingId = ref<number | null>(null);
|
||||
const form = reactive({ name: '', breed: '', gender: 'MALE', weight: '' });
|
||||
|
||||
const columns = [
|
||||
{ colKey: 'id', title: 'ID', width: 80 },
|
||||
{ colKey: 'name', title: '名称' },
|
||||
{ colKey: 'breed', title: '品种' },
|
||||
{ colKey: 'gender', title: '性别' },
|
||||
{ colKey: 'weight', title: '体重' },
|
||||
{ colKey: 'op', title: '操作', width: 140 },
|
||||
];
|
||||
|
||||
const genderOptions = [
|
||||
{ label: '雄性', value: 'MALE' },
|
||||
{ label: '雌性', value: 'FEMALE' },
|
||||
];
|
||||
|
||||
const load = async () => {
|
||||
const params: any = { page: 1, size: 20 };
|
||||
if (query.ownerId) params.ownerId = query.ownerId;
|
||||
const res = await api.pets(params);
|
||||
if (res.code === 0) list.value = res.data.records || [];
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
dialogTitle.value = '新增宠物';
|
||||
editingId.value = null;
|
||||
form.name = '';
|
||||
form.breed = '';
|
||||
form.gender = 'MALE';
|
||||
form.weight = '';
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
const openEdit = (row: any) => {
|
||||
dialogTitle.value = '编辑宠物';
|
||||
editingId.value = row.id;
|
||||
form.name = row.name || '';
|
||||
form.breed = row.breed || '';
|
||||
form.gender = row.gender || 'MALE';
|
||||
form.weight = row.weight || '';
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
const payload = { ...form };
|
||||
const res = editingId.value
|
||||
? await api.updatePet(editingId.value, payload)
|
||||
: await api.createPet(payload);
|
||||
if (res.code === 0) {
|
||||
MessagePlugin.success('保存成功');
|
||||
dialogVisible.value = false;
|
||||
load();
|
||||
} else {
|
||||
MessagePlugin.error(res.message || '保存失败');
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (id: number) => {
|
||||
const res = await api.deletePet(id);
|
||||
if (res.code === 0) {
|
||||
MessagePlugin.success('删除成功');
|
||||
load();
|
||||
} else {
|
||||
MessagePlugin.error(res.message || '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
190
frontend/src/pages/PrescriptionPage.vue
Normal file
190
frontend/src/pages/PrescriptionPage.vue
Normal file
@@ -0,0 +1,190 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<h2 class="page-title">处方管理</h2>
|
||||
<div class="panel">
|
||||
<div class="inline-form">
|
||||
<t-input v-model="query.visitId" placeholder="就诊ID" style="width: 160px" />
|
||||
<t-button theme="primary" @click="load">
|
||||
<template #icon><t-icon name="refresh" /></template>
|
||||
刷新
|
||||
</t-button>
|
||||
<t-button variant="outline" @click="openCreate">
|
||||
<template #icon><t-icon name="add" /></template>
|
||||
新增处方
|
||||
</t-button>
|
||||
</div>
|
||||
<t-table :data="list" :columns="columns" row-key="id" bordered stripe hover>
|
||||
<template #status="{ row }">
|
||||
<span class="status-badge" :class="getStatusClass(row.status)">
|
||||
{{ getStatusText(row.status) }}
|
||||
</span>
|
||||
</template>
|
||||
<template #op="{ row }">
|
||||
<div class="table-actions">
|
||||
<t-button size="small" variant="outline" @click="openEdit(row)">
|
||||
<template #icon><t-icon name="edit" /></template>
|
||||
编辑
|
||||
</t-button>
|
||||
</div>
|
||||
</template>
|
||||
</t-table>
|
||||
</div>
|
||||
|
||||
<t-dialog v-model:visible="dialogVisible" :header="dialogTitle" :on-confirm="submit" width="600">
|
||||
<t-form :data="form" layout="vertical">
|
||||
<div class="form-section-title">处方信息</div>
|
||||
<t-form-item label="就诊记录" required>
|
||||
<t-select v-model="form.visitId" :options="visitOptions" placeholder="请选择就诊记录" clearable />
|
||||
</t-form-item>
|
||||
<t-form-item label="医生" required>
|
||||
<t-select v-model="form.doctorId" :options="doctorOptions" placeholder="请选择医生" clearable />
|
||||
</t-form-item>
|
||||
<t-form-item label="状态">
|
||||
<t-select v-model="form.status" :options="statusOptions" />
|
||||
</t-form-item>
|
||||
<t-form-item label="备注">
|
||||
<t-textarea v-model="form.remark" placeholder="请输入备注信息" :maxlength="500" />
|
||||
</t-form-item>
|
||||
</t-form>
|
||||
</t-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref, computed } from 'vue';
|
||||
import { MessagePlugin } from 'tdesign-vue-next';
|
||||
import { api } from '../api';
|
||||
|
||||
const list = ref([] as any[]);
|
||||
const visits = ref([] as any[]);
|
||||
const doctors = ref([] as any[]);
|
||||
const dialogVisible = ref(false);
|
||||
const dialogTitle = ref('新增处方');
|
||||
const editingId = ref<number | null>(null);
|
||||
const query = reactive({ visitId: '' });
|
||||
const form = reactive({ visitId: '', doctorId: '', remark: '', status: 'DRAFT' });
|
||||
|
||||
const visitOptions = computed(() => {
|
||||
return visits.value.map((visit: any) => ({
|
||||
label: `就诊#${visit.id} - 宠物ID:${visit.petId}, 顾客ID:${visit.customerId}`,
|
||||
value: visit.id
|
||||
}));
|
||||
});
|
||||
|
||||
const doctorOptions = computed(() => {
|
||||
return doctors.value.map((doctor: any) => ({
|
||||
label: doctor.name,
|
||||
value: doctor.id
|
||||
}));
|
||||
});
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
const statusMap: Record<string, string> = {
|
||||
'DRAFT': '草稿',
|
||||
'SUBMITTED': '已提交',
|
||||
'ISSUED': '已发药',
|
||||
'VOIDED': '已作废'
|
||||
};
|
||||
return statusMap[status] || status;
|
||||
};
|
||||
|
||||
const getStatusClass = (status: string) => {
|
||||
const classMap: Record<string, string> = {
|
||||
'DRAFT': 'status-pending',
|
||||
'SUBMITTED': 'status-active',
|
||||
'ISSUED': 'status-completed',
|
||||
'VOIDED': 'status-cancelled'
|
||||
};
|
||||
return classMap[status] || 'status-pending';
|
||||
};
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '草稿', value: 'DRAFT' },
|
||||
{ label: '已提交', value: 'SUBMITTED' },
|
||||
{ label: '已发药', value: 'ISSUED' },
|
||||
{ label: '已作废', value: 'VOIDED' },
|
||||
];
|
||||
|
||||
const columns = [
|
||||
{ colKey: 'id', title: 'ID', width: 80 },
|
||||
{ colKey: 'visitId', title: '就诊ID' },
|
||||
{ colKey: 'doctorId', title: '医生ID' },
|
||||
{ colKey: 'status', title: '状态' },
|
||||
{ colKey: 'op', title: '操作', width: 100 },
|
||||
];
|
||||
|
||||
const load = async () => {
|
||||
const params: any = { page: 1, size: 20 };
|
||||
if (query.visitId) params.visitId = query.visitId;
|
||||
const res = await api.prescriptions(params);
|
||||
if (res.code === 0) list.value = res.data.records || [];
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
dialogTitle.value = '新增处方';
|
||||
editingId.value = null;
|
||||
form.visitId = query.visitId;
|
||||
form.doctorId = '';
|
||||
form.remark = '';
|
||||
form.status = 'DRAFT';
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
const openEdit = (row: any) => {
|
||||
dialogTitle.value = '编辑处方';
|
||||
editingId.value = row.id;
|
||||
form.visitId = row.visitId;
|
||||
form.doctorId = row.doctorId || '';
|
||||
form.remark = row.remark || '';
|
||||
form.status = row.status || 'DRAFT';
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
const loadVisits = async () => {
|
||||
try {
|
||||
const res = await api.visits({ page: 1, size: 100 }); // 获取所有就诊记录
|
||||
if (res.code === 0) {
|
||||
visits.value = res.data?.records || [];
|
||||
} else {
|
||||
MessagePlugin.error(res.message || '获取就诊记录失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取就诊记录失败:', error);
|
||||
MessagePlugin.error('获取就诊记录失败');
|
||||
}
|
||||
};
|
||||
|
||||
const loadDoctors = async () => {
|
||||
try {
|
||||
const res = await api.users({ page: 1, size: 100, role: 'DOCTOR' }); // 获取所有医生
|
||||
if (res.code === 0) {
|
||||
doctors.value = res.data?.records || [];
|
||||
} else {
|
||||
MessagePlugin.error(res.message || '获取医生列表失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取医生列表失败:', error);
|
||||
MessagePlugin.error('获取医生列表失败');
|
||||
}
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
const payload = { ...form };
|
||||
const res = editingId.value
|
||||
? await api.updatePrescription(editingId.value, payload)
|
||||
: await api.createPrescription(payload);
|
||||
if (res.code === 0) {
|
||||
MessagePlugin.success('保存成功');
|
||||
dialogVisible.value = false;
|
||||
load();
|
||||
} else {
|
||||
MessagePlugin.error(res.message || '保存失败');
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
load();
|
||||
loadVisits();
|
||||
loadDoctors();
|
||||
});
|
||||
</script>
|
||||
108
frontend/src/pages/ReportPage.vue
Normal file
108
frontend/src/pages/ReportPage.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<h2 class="page-title">检查报告</h2>
|
||||
<div class="panel">
|
||||
<div class="inline-form">
|
||||
<t-input v-model="query.petId" placeholder="宠物ID" style="width: 160px" />
|
||||
<t-button theme="primary" @click="load">查询</t-button>
|
||||
<t-button variant="outline" @click="openCreate">新增报告</t-button>
|
||||
</div>
|
||||
<t-table :data="list" :columns="columns" row-key="id">
|
||||
<template #op="{ row }">
|
||||
<div class="table-actions">
|
||||
<t-button size="small" variant="text" @click="openEdit(row)">编辑</t-button>
|
||||
<t-popconfirm content="确认删除?" @confirm="remove(row.id)">
|
||||
<t-button size="small" theme="danger" variant="text">删除</t-button>
|
||||
</t-popconfirm>
|
||||
</div>
|
||||
</template>
|
||||
</t-table>
|
||||
</div>
|
||||
|
||||
<t-dialog v-model:visible="dialogVisible" :header="dialogTitle" :on-confirm="submit">
|
||||
<t-form :data="form">
|
||||
<t-form-item label="就诊ID"><t-input v-model="form.visitId" /></t-form-item>
|
||||
<t-form-item label="宠物ID"><t-input v-model="form.petId" /></t-form-item>
|
||||
<t-form-item label="类型"><t-input v-model="form.type" /></t-form-item>
|
||||
<t-form-item label="标题"><t-input v-model="form.title" /></t-form-item>
|
||||
<t-form-item label="摘要"><t-textarea v-model="form.summary" /></t-form-item>
|
||||
</t-form>
|
||||
</t-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { MessagePlugin } from 'tdesign-vue-next';
|
||||
import { api } from '../api';
|
||||
|
||||
const list = ref([] as any[]);
|
||||
const dialogVisible = ref(false);
|
||||
const dialogTitle = ref('新增报告');
|
||||
const editingId = ref<number | null>(null);
|
||||
const query = reactive({ petId: '' });
|
||||
const form = reactive({ visitId: '', petId: '', type: '', title: '', summary: '' });
|
||||
|
||||
const columns = [
|
||||
{ colKey: 'id', title: 'ID', width: 80 },
|
||||
{ colKey: 'petId', title: '宠物ID' },
|
||||
{ colKey: 'type', title: '类型' },
|
||||
{ colKey: 'title', title: '标题' },
|
||||
{ colKey: 'op', title: '操作', width: 140 },
|
||||
];
|
||||
|
||||
const load = async () => {
|
||||
const params: any = { page: 1, size: 20 };
|
||||
if (query.petId) params.petId = query.petId;
|
||||
const res = await api.reports(params);
|
||||
if (res.code === 0) list.value = res.data.records || [];
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
dialogTitle.value = '新增报告';
|
||||
editingId.value = null;
|
||||
form.visitId = '';
|
||||
form.petId = query.petId;
|
||||
form.type = '';
|
||||
form.title = '';
|
||||
form.summary = '';
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
const openEdit = (row: any) => {
|
||||
dialogTitle.value = '编辑报告';
|
||||
editingId.value = row.id;
|
||||
form.visitId = row.visitId || '';
|
||||
form.petId = row.petId || '';
|
||||
form.type = row.type || '';
|
||||
form.title = row.title || '';
|
||||
form.summary = row.summary || '';
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
const payload = { ...form };
|
||||
const res = editingId.value
|
||||
? await api.updateReport(editingId.value, payload)
|
||||
: await api.createReport(payload);
|
||||
if (res.code === 0) {
|
||||
MessagePlugin.success('保存成功');
|
||||
dialogVisible.value = false;
|
||||
load();
|
||||
} else {
|
||||
MessagePlugin.error(res.message || '保存失败');
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (id: number) => {
|
||||
const res = await api.deleteReport(id);
|
||||
if (res.code === 0) {
|
||||
MessagePlugin.success('删除成功');
|
||||
load();
|
||||
} else {
|
||||
MessagePlugin.error(res.message || '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
31
frontend/src/pages/StatsPage.vue
Normal file
31
frontend/src/pages/StatsPage.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<h2 class="page-title">统计报表</h2>
|
||||
<div class="panel">
|
||||
<t-descriptions :items="items" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { api } from '../api';
|
||||
|
||||
const items = ref([] as any[]);
|
||||
|
||||
const load = async () => {
|
||||
const res = await api.stats();
|
||||
if (res.code === 0) {
|
||||
items.value = [
|
||||
{ label: '订单数量', value: res.data.orders },
|
||||
{ label: '预约数量', value: res.data.appointments },
|
||||
{ label: '就诊数量', value: res.data.visits },
|
||||
{ label: '宠物数量', value: res.data.pets },
|
||||
{ label: '顾客数量', value: res.data.customers },
|
||||
{ label: '订单收入合计', value: res.data.orderAmountTotal },
|
||||
];
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
60
frontend/src/pages/StockInPage.vue
Normal file
60
frontend/src/pages/StockInPage.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<h2 class="page-title">入库流水</h2>
|
||||
<div class="panel">
|
||||
<div class="inline-form">
|
||||
<t-button theme="primary" @click="load">刷新</t-button>
|
||||
<t-button variant="outline" @click="openCreate">新增入库</t-button>
|
||||
</div>
|
||||
<t-table :data="list" :columns="columns" row-key="id" />
|
||||
</div>
|
||||
|
||||
<t-dialog v-model:visible="dialogVisible" header="新增入库" :on-confirm="submitCreate">
|
||||
<t-form :data="form">
|
||||
<t-form-item label="药品ID"><t-input v-model="form.drugId" /></t-form-item>
|
||||
<t-form-item label="数量"><t-input v-model="form.quantity" /></t-form-item>
|
||||
</t-form>
|
||||
</t-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { MessagePlugin } from 'tdesign-vue-next';
|
||||
import { api } from '../api';
|
||||
|
||||
const list = ref([] as any[]);
|
||||
const dialogVisible = ref(false);
|
||||
const form = reactive({ drugId: '', quantity: '' });
|
||||
|
||||
const columns = [
|
||||
{ colKey: 'id', title: 'ID', width: 80 },
|
||||
{ colKey: 'drugId', title: '药品ID' },
|
||||
{ colKey: 'quantity', title: '数量' },
|
||||
{ colKey: 'stockInTime', title: '入库时间' },
|
||||
];
|
||||
|
||||
const load = async () => {
|
||||
const res = await api.stockIn({ page: 1, size: 20 });
|
||||
if (res.code === 0) list.value = res.data.records || [];
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
form.drugId = '';
|
||||
form.quantity = '';
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
const submitCreate = async () => {
|
||||
const res = await api.createStockIn({ ...form, stockInTime: new Date().toISOString() });
|
||||
if (res.code === 0) {
|
||||
MessagePlugin.success('创建成功');
|
||||
dialogVisible.value = false;
|
||||
load();
|
||||
} else {
|
||||
MessagePlugin.error(res.message || '创建失败');
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
60
frontend/src/pages/StockOutPage.vue
Normal file
60
frontend/src/pages/StockOutPage.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<h2 class="page-title">出库流水</h2>
|
||||
<div class="panel">
|
||||
<div class="inline-form">
|
||||
<t-button theme="primary" @click="load">刷新</t-button>
|
||||
<t-button variant="outline" @click="openCreate">新增出库</t-button>
|
||||
</div>
|
||||
<t-table :data="list" :columns="columns" row-key="id" />
|
||||
</div>
|
||||
|
||||
<t-dialog v-model:visible="dialogVisible" header="新增出库" :on-confirm="submitCreate">
|
||||
<t-form :data="form">
|
||||
<t-form-item label="药品ID"><t-input v-model="form.drugId" /></t-form-item>
|
||||
<t-form-item label="数量"><t-input v-model="form.quantity" /></t-form-item>
|
||||
</t-form>
|
||||
</t-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { MessagePlugin } from 'tdesign-vue-next';
|
||||
import { api } from '../api';
|
||||
|
||||
const list = ref([] as any[]);
|
||||
const dialogVisible = ref(false);
|
||||
const form = reactive({ drugId: '', quantity: '' });
|
||||
|
||||
const columns = [
|
||||
{ colKey: 'id', title: 'ID', width: 80 },
|
||||
{ colKey: 'drugId', title: '药品ID' },
|
||||
{ colKey: 'quantity', title: '数量' },
|
||||
{ colKey: 'stockOutTime', title: '出库时间' },
|
||||
];
|
||||
|
||||
const load = async () => {
|
||||
const res = await api.stockOut({ page: 1, size: 20 });
|
||||
if (res.code === 0) list.value = res.data.records || [];
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
form.drugId = '';
|
||||
form.quantity = '';
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
const submitCreate = async () => {
|
||||
const res = await api.createStockOut({ ...form, stockOutTime: new Date().toISOString() });
|
||||
if (res.code === 0) {
|
||||
MessagePlugin.success('创建成功');
|
||||
dialogVisible.value = false;
|
||||
load();
|
||||
} else {
|
||||
MessagePlugin.error(res.message || '创建失败');
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
119
frontend/src/pages/UserPage.vue
Normal file
119
frontend/src/pages/UserPage.vue
Normal file
@@ -0,0 +1,119 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<h2 class="page-title">账号管理</h2>
|
||||
<div class="panel">
|
||||
<div class="inline-form">
|
||||
<t-select v-model="query.role" :options="roleOptions" placeholder="角色" style="width: 160px" />
|
||||
<t-button theme="primary" @click="load">查询</t-button>
|
||||
<t-button variant="outline" @click="openCreate">新增账号</t-button>
|
||||
</div>
|
||||
<t-table :data="list" :columns="columns" row-key="id">
|
||||
<template #op="{ row }">
|
||||
<div class="table-actions">
|
||||
<t-button size="small" variant="text" @click="toggleStatus(row)">
|
||||
{{ row.status === 1 ? '禁用' : '启用' }}
|
||||
</t-button>
|
||||
<t-button size="small" variant="text" @click="openReset(row)">重置密码</t-button>
|
||||
</div>
|
||||
</template>
|
||||
</t-table>
|
||||
</div>
|
||||
|
||||
<t-dialog v-model:visible="dialogVisible" header="新增账号" :on-confirm="submitCreate">
|
||||
<t-form :data="form">
|
||||
<t-form-item label="用户名"><t-input v-model="form.username" /></t-form-item>
|
||||
<t-form-item label="密码"><t-input v-model="form.password" type="password" /></t-form-item>
|
||||
<t-form-item label="角色"><t-select v-model="form.role" :options="roleOptions" /></t-form-item>
|
||||
</t-form>
|
||||
</t-dialog>
|
||||
|
||||
<t-dialog v-model:visible="resetVisible" header="重置密码" :on-confirm="submitReset">
|
||||
<t-form :data="resetForm">
|
||||
<t-form-item label="新密码"><t-input v-model="resetForm.password" type="password" /></t-form-item>
|
||||
</t-form>
|
||||
</t-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { MessagePlugin } from 'tdesign-vue-next';
|
||||
import { api } from '../api';
|
||||
|
||||
const list = ref([] as any[]);
|
||||
const dialogVisible = ref(false);
|
||||
const resetVisible = ref(false);
|
||||
const resetId = ref<number | null>(null);
|
||||
const query = reactive({ role: '' });
|
||||
const form = reactive({ username: '', password: '', role: 'DOCTOR' });
|
||||
const resetForm = reactive({ password: '' });
|
||||
|
||||
const roleOptions = [
|
||||
{ label: '管理员', value: 'ADMIN' },
|
||||
{ label: '医生', value: 'DOCTOR' },
|
||||
{ label: '顾客', value: 'CUSTOMER' },
|
||||
];
|
||||
|
||||
const columns = [
|
||||
{ colKey: 'id', title: 'ID', width: 80 },
|
||||
{ colKey: 'username', title: '用户名' },
|
||||
{ colKey: 'role', title: '角色' },
|
||||
{ colKey: 'status', title: '状态' },
|
||||
{ colKey: 'op', title: '操作', width: 160 },
|
||||
];
|
||||
|
||||
const load = async () => {
|
||||
const params: any = { page: 1, size: 20 };
|
||||
if (query.role) params.role = query.role;
|
||||
const res = await api.users(params);
|
||||
if (res.code === 0) list.value = res.data.records || [];
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
form.username = '';
|
||||
form.password = '';
|
||||
form.role = 'DOCTOR';
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
const submitCreate = async () => {
|
||||
const res = await api.createUser({ ...form, status: 1 });
|
||||
if (res.code === 0) {
|
||||
MessagePlugin.success('创建成功');
|
||||
dialogVisible.value = false;
|
||||
load();
|
||||
} else {
|
||||
MessagePlugin.error(res.message || '创建失败');
|
||||
}
|
||||
};
|
||||
|
||||
const toggleStatus = async (row: any) => {
|
||||
const next = row.status === 1 ? 0 : 1;
|
||||
const res = await api.updateUserStatus(row.id, next);
|
||||
if (res.code === 0) {
|
||||
MessagePlugin.success('状态已更新');
|
||||
load();
|
||||
} else {
|
||||
MessagePlugin.error(res.message || '更新失败');
|
||||
}
|
||||
};
|
||||
|
||||
const openReset = (row: any) => {
|
||||
resetId.value = row.id;
|
||||
resetForm.password = '';
|
||||
resetVisible.value = true;
|
||||
};
|
||||
|
||||
const submitReset = async () => {
|
||||
if (!resetId.value) return;
|
||||
const res = await api.resetUserPassword(resetId.value, resetForm.password);
|
||||
if (res.code === 0) {
|
||||
MessagePlugin.success('密码已重置');
|
||||
resetVisible.value = false;
|
||||
} else {
|
||||
MessagePlugin.error(res.message || '重置失败');
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
291
frontend/src/pages/VisitPage.vue
Normal file
291
frontend/src/pages/VisitPage.vue
Normal file
@@ -0,0 +1,291 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<h2 class="page-title">就诊记录</h2>
|
||||
<div class="panel">
|
||||
<div class="inline-form">
|
||||
<t-button theme="primary" @click="load">
|
||||
<template #icon><t-icon name="refresh" /></template>
|
||||
刷新
|
||||
</t-button>
|
||||
<t-button variant="outline" @click="openCreate">
|
||||
<template #icon><t-icon name="add" /></template>
|
||||
新增就诊
|
||||
</t-button>
|
||||
</div>
|
||||
<t-table :data="list" :columns="columns" row-key="id" bordered stripe hover>
|
||||
<template #status="{ row }">
|
||||
<span class="status-badge" :class="getStatusClass(row.status)">
|
||||
{{ getStatusText(row.status) }}
|
||||
</span>
|
||||
</template>
|
||||
<template #paymentStatus="{ row }">
|
||||
<span class="status-badge" :class="getPaymentStatusClass(row.paymentStatus)">
|
||||
{{ getPaymentStatusText(row.paymentStatus) }}
|
||||
</span>
|
||||
</template>
|
||||
<template #op="{ row }">
|
||||
<div class="table-actions">
|
||||
<t-button size="small" variant="outline" @click="openEdit(row)">
|
||||
<template #icon><t-icon name="edit" /></template>
|
||||
编辑
|
||||
</t-button>
|
||||
</div>
|
||||
</template>
|
||||
</t-table>
|
||||
</div>
|
||||
|
||||
<t-dialog v-model:visible="dialogVisible" :header="dialogTitle" :on-confirm="submit" width="600">
|
||||
<t-form :data="form" layout="vertical">
|
||||
<div class="form-section-title">基本信息</div>
|
||||
<t-form-item label="顾客" required>
|
||||
<t-select v-model="form.customerId" :options="customerOptions" placeholder="请选择顾客" clearable />
|
||||
</t-form-item>
|
||||
<t-form-item label="宠物" required>
|
||||
<t-select v-model="form.petId" :options="petOptions" placeholder="请选择宠物" clearable />
|
||||
</t-form-item>
|
||||
<t-form-item label="医生" required>
|
||||
<t-select v-model="form.doctorId" :options="doctorOptions" placeholder="请选择医生" clearable />
|
||||
</t-form-item>
|
||||
|
||||
<div class="form-section-title">就诊信息</div>
|
||||
<t-form-item label="症状描述">
|
||||
<t-textarea v-model="form.symptoms" placeholder="请输入症状描述" :maxlength="500" />
|
||||
</t-form-item>
|
||||
<t-form-item label="诊断结果">
|
||||
<t-textarea v-model="form.diagnosis" placeholder="请输入诊断结果" :maxlength="500" />
|
||||
</t-form-item>
|
||||
<t-form-item label="治疗方案">
|
||||
<t-textarea v-model="form.treatmentPlan" placeholder="请输入治疗方案" :maxlength="500" />
|
||||
</t-form-item>
|
||||
|
||||
<div class="form-section-title">状态信息</div>
|
||||
<t-row :gutter="[16, 16]">
|
||||
<t-col :span="6">
|
||||
<t-form-item label="就诊状态">
|
||||
<t-select v-model="form.status" :options="statusOptions" />
|
||||
</t-form-item>
|
||||
</t-col>
|
||||
<t-col :span="6">
|
||||
<t-form-item label="支付状态">
|
||||
<t-select v-model="form.paymentStatus" :options="payStatusOptions" />
|
||||
</t-form-item>
|
||||
</t-col>
|
||||
<t-col :span="6">
|
||||
<t-form-item label="支付方式">
|
||||
<t-select v-model="form.paymentMethod" :options="payMethodOptions" />
|
||||
</t-form-item>
|
||||
</t-col>
|
||||
<t-col :span="6">
|
||||
<t-form-item label="总金额">
|
||||
<t-input-number v-model="form.totalAmount" placeholder="0.00" suffix="元" />
|
||||
</t-form-item>
|
||||
</t-col>
|
||||
</t-row>
|
||||
</t-form>
|
||||
</t-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref, computed } from 'vue';
|
||||
import { MessagePlugin } from 'tdesign-vue-next';
|
||||
import { api } from '../api';
|
||||
|
||||
const list = ref([] as any[]);
|
||||
const customers = ref([] as any[]);
|
||||
const pets = ref([] as any[]);
|
||||
const doctors = ref([] as any[]);
|
||||
const dialogVisible = ref(false);
|
||||
const dialogTitle = ref('新增就诊');
|
||||
const editingId = ref<number | null>(null);
|
||||
const form = reactive({
|
||||
customerId: '',
|
||||
petId: '',
|
||||
doctorId: '',
|
||||
status: 'IN_PROGRESS',
|
||||
paymentStatus: 'UNPAID',
|
||||
paymentMethod: 'OFFLINE',
|
||||
});
|
||||
|
||||
const customerOptions = computed(() => {
|
||||
return customers.value.map((customer: any) => ({
|
||||
label: customer.username || `顾客${customer.id}`,
|
||||
value: customer.id
|
||||
}));
|
||||
});
|
||||
|
||||
const petOptions = computed(() => {
|
||||
return pets.value.map((pet: any) => ({
|
||||
label: `${pet.name} (${pet.breed})`,
|
||||
value: pet.id
|
||||
}));
|
||||
});
|
||||
|
||||
const doctorOptions = computed(() => {
|
||||
return doctors.value.map((doctor: any) => ({
|
||||
label: doctor.name,
|
||||
value: doctor.id
|
||||
}));
|
||||
});
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
const statusMap: Record<string, string> = {
|
||||
'IN_PROGRESS': '就诊中',
|
||||
'COMPLETED': '已完成',
|
||||
'CANCELLED': '已取消'
|
||||
};
|
||||
return statusMap[status] || status;
|
||||
};
|
||||
|
||||
const getStatusClass = (status: string) => {
|
||||
const classMap: Record<string, string> = {
|
||||
'IN_PROGRESS': 'status-pending',
|
||||
'COMPLETED': 'status-active',
|
||||
'CANCELLED': 'status-cancelled'
|
||||
};
|
||||
return classMap[status] || 'status-pending';
|
||||
};
|
||||
|
||||
const getPaymentStatusText = (status: string) => {
|
||||
const statusMap: Record<string, string> = {
|
||||
'UNPAID': '未支付',
|
||||
'PAID': '已支付',
|
||||
'REFUNDING': '退款中',
|
||||
'REFUNDED': '已退款',
|
||||
'CANCELLED': '已取消'
|
||||
};
|
||||
return statusMap[status] || status;
|
||||
};
|
||||
|
||||
const getPaymentStatusClass = (status: string) => {
|
||||
const classMap: Record<string, string> = {
|
||||
'UNPAID': 'status-pending',
|
||||
'PAID': 'status-active',
|
||||
'REFUNDING': 'status-warning',
|
||||
'REFUNDED': 'status-cancelled',
|
||||
'CANCELLED': 'status-cancelled'
|
||||
};
|
||||
return classMap[status] || 'status-pending';
|
||||
};
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '就诊中', value: 'IN_PROGRESS' },
|
||||
{ label: '已完成', value: 'COMPLETED' },
|
||||
];
|
||||
|
||||
const payStatusOptions = [
|
||||
{ label: '未支付', value: 'UNPAID' },
|
||||
{ label: '已支付', value: 'PAID' },
|
||||
{ label: '退款中', value: 'REFUNDING' },
|
||||
{ label: '已退款', value: 'REFUNDED' },
|
||||
];
|
||||
|
||||
const payMethodOptions = [
|
||||
{ label: '线下', value: 'OFFLINE' },
|
||||
{ label: '支付宝', value: 'ALIPAY' },
|
||||
{ label: '微信', value: 'WECHAT' },
|
||||
];
|
||||
|
||||
const columns = [
|
||||
{ colKey: 'id', title: 'ID', width: 80 },
|
||||
{ colKey: 'customerId', title: '顾客ID' },
|
||||
{ colKey: 'petId', title: '宠物ID' },
|
||||
{ colKey: 'doctorId', title: '医生ID' },
|
||||
{ colKey: 'status', title: '就诊状态' },
|
||||
{ colKey: 'paymentStatus', title: '支付状态' },
|
||||
{ colKey: 'totalAmount', title: '总金额', width: 100 },
|
||||
{ colKey: 'op', title: '操作', width: 120 },
|
||||
];
|
||||
|
||||
const load = async () => {
|
||||
const res = await api.visits({ page: 1, size: 20 });
|
||||
if (res.code === 0) list.value = res.data.records || [];
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
dialogTitle.value = '新增就诊';
|
||||
editingId.value = null;
|
||||
form.customerId = '';
|
||||
form.petId = '';
|
||||
form.doctorId = '';
|
||||
form.status = 'IN_PROGRESS';
|
||||
form.paymentStatus = 'UNPAID';
|
||||
form.paymentMethod = 'OFFLINE';
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
const openEdit = (row: any) => {
|
||||
dialogTitle.value = '编辑就诊';
|
||||
editingId.value = row.id;
|
||||
form.customerId = row.customerId || '';
|
||||
form.petId = row.petId || '';
|
||||
form.doctorId = row.doctorId || '';
|
||||
form.status = row.status || 'IN_PROGRESS';
|
||||
form.paymentStatus = row.paymentStatus || 'UNPAID';
|
||||
form.paymentMethod = row.paymentMethod || 'OFFLINE';
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
const loadCustomers = async () => {
|
||||
try {
|
||||
const res = await api.users({ page: 1, size: 100, role: 'CUSTOMER' }); // 获取所有顾客
|
||||
if (res.code === 0) {
|
||||
customers.value = res.data?.records || [];
|
||||
} else {
|
||||
MessagePlugin.error(res.message || '获取顾客列表失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取顾客列表失败:', error);
|
||||
MessagePlugin.error('获取顾客列表失败');
|
||||
}
|
||||
};
|
||||
|
||||
const loadPets = async () => {
|
||||
try {
|
||||
const res = await api.pets({ page: 1, size: 100 }); // 获取所有宠物
|
||||
if (res.code === 0) {
|
||||
pets.value = res.data?.records || [];
|
||||
} else {
|
||||
MessagePlugin.error(res.message || '获取宠物列表失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取宠物列表失败:', error);
|
||||
MessagePlugin.error('获取宠物列表失败');
|
||||
}
|
||||
};
|
||||
|
||||
const loadDoctors = async () => {
|
||||
try {
|
||||
const res = await api.users({ page: 1, size: 100, role: 'DOCTOR' }); // 获取所有医生
|
||||
if (res.code === 0) {
|
||||
doctors.value = res.data?.records || [];
|
||||
} else {
|
||||
MessagePlugin.error(res.message || '获取医生列表失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取医生列表失败:', error);
|
||||
MessagePlugin.error('获取医生列表失败');
|
||||
}
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
const payload = { ...form };
|
||||
const res = editingId.value
|
||||
? await api.updateVisit(editingId.value, payload)
|
||||
: await api.createVisit(payload);
|
||||
if (res.code === 0) {
|
||||
MessagePlugin.success('保存成功');
|
||||
dialogVisible.value = false;
|
||||
load();
|
||||
} else {
|
||||
MessagePlugin.error(res.message || '保存失败');
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
load();
|
||||
loadCustomers();
|
||||
loadPets();
|
||||
loadDoctors();
|
||||
});
|
||||
</script>
|
||||
50
frontend/src/router/index.ts
Normal file
50
frontend/src/router/index.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router';
|
||||
import { useAuthStore } from '../store/auth';
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{ path: '/login', name: 'login', component: () => import('../pages/Login.vue') },
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('../layouts/MainLayout.vue'),
|
||||
children: [
|
||||
{ path: '', redirect: '/dashboard' },
|
||||
{ path: 'dashboard', name: 'dashboard', component: () => import('../pages/Dashboard.vue') },
|
||||
{ path: 'notices', name: 'notices', component: () => import('../pages/NoticePage.vue'), meta: { roles: ['ADMIN'] } },
|
||||
{ path: 'pets', name: 'pets', component: () => import('../pages/PetPage.vue'), meta: { roles: ['ADMIN', 'DOCTOR', 'CUSTOMER'] } },
|
||||
{ path: 'appointments', name: 'appointments', component: () => import('../pages/AppointmentPage.vue'), meta: { roles: ['ADMIN', 'DOCTOR', 'CUSTOMER'] } },
|
||||
{ path: 'visits', name: 'visits', component: () => import('../pages/VisitPage.vue'), meta: { roles: ['ADMIN', 'DOCTOR'] } },
|
||||
{ path: 'records', name: 'records', component: () => import('../pages/MedicalRecordPage.vue'), meta: { roles: ['ADMIN', 'DOCTOR'] } },
|
||||
{ path: 'prescriptions', name: 'prescriptions', component: () => import('../pages/PrescriptionPage.vue'), meta: { roles: ['ADMIN', 'DOCTOR'] } },
|
||||
{ path: 'reports', name: 'reports', component: () => import('../pages/ReportPage.vue'), meta: { roles: ['ADMIN', 'DOCTOR', 'CUSTOMER'] } },
|
||||
{ path: 'orders', name: 'orders', component: () => import('../pages/OrderPage.vue'), meta: { roles: ['ADMIN', 'CUSTOMER'] } },
|
||||
{ path: 'drugs', name: 'drugs', component: () => import('../pages/DrugPage.vue'), meta: { roles: ['ADMIN'] } },
|
||||
{ path: 'stock-in', name: 'stock-in', component: () => import('../pages/StockInPage.vue'), meta: { roles: ['ADMIN'] } },
|
||||
{ path: 'stock-out', name: 'stock-out', component: () => import('../pages/StockOutPage.vue'), meta: { roles: ['ADMIN'] } },
|
||||
{ path: 'messages', name: 'messages', component: () => import('../pages/MessagePage.vue'), meta: { roles: ['ADMIN'] } },
|
||||
{ path: 'users', name: 'users', component: () => import('../pages/UserPage.vue'), meta: { roles: ['ADMIN'] } },
|
||||
{ path: 'stats', name: 'stats', component: () => import('../pages/StatsPage.vue'), meta: { roles: ['ADMIN'] } }
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
});
|
||||
|
||||
router.beforeEach((to) => {
|
||||
const auth = useAuthStore();
|
||||
if (to.path !== '/login' && !auth.token) {
|
||||
return '/login';
|
||||
}
|
||||
if (to.path === '/login' && auth.token) {
|
||||
return '/dashboard';
|
||||
}
|
||||
const roles = to.meta?.roles as string[] | undefined;
|
||||
if (roles && auth.user?.role && !roles.includes(auth.user.role)) {
|
||||
return '/dashboard';
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
export default router;
|
||||
28
frontend/src/store/auth.ts
Normal file
28
frontend/src/store/auth.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { defineStore } from 'pinia';
|
||||
|
||||
interface UserInfo {
|
||||
userId?: number;
|
||||
username?: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
state: () => ({
|
||||
token: localStorage.getItem('token') || '',
|
||||
user: JSON.parse(localStorage.getItem('user') || '{}') as UserInfo,
|
||||
}),
|
||||
actions: {
|
||||
setAuth(token: string, user: UserInfo) {
|
||||
this.token = token;
|
||||
this.user = user;
|
||||
localStorage.setItem('token', token);
|
||||
localStorage.setItem('user', JSON.stringify(user));
|
||||
},
|
||||
clear() {
|
||||
this.token = '';
|
||||
this.user = {};
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
},
|
||||
},
|
||||
});
|
||||
174
frontend/src/styles/global.css
Normal file
174
frontend/src/styles/global.css
Normal file
@@ -0,0 +1,174 @@
|
||||
:root {
|
||||
--app-bg: #f8fafc;
|
||||
--panel-bg: #ffffff;
|
||||
--primary: #3b82f6;
|
||||
--primary-hover: #2563eb;
|
||||
--secondary: #64748b;
|
||||
--success: #10b981;
|
||||
--warning: #f59e0b;
|
||||
--danger: #ef4444;
|
||||
--text: #1e293b;
|
||||
--text-secondary: #64748b;
|
||||
--border: #e2e8f0;
|
||||
--shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
--radius: 8px;
|
||||
--transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif;
|
||||
background: var(--app-bg);
|
||||
color: var(--text);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.page {
|
||||
padding: 24px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--panel-bg);
|
||||
border-radius: var(--radius);
|
||||
padding: 24px;
|
||||
box-shadow: var(--shadow);
|
||||
margin-bottom: 24px;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.panel:hover {
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 24px;
|
||||
color: var(--text);
|
||||
position: relative;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.page-title::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 50px;
|
||||
height: 3px;
|
||||
background: var(--primary);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.inline-form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.table-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.t-table {
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.t-btn {
|
||||
border-radius: var(--radius) !important;
|
||||
transition: var(--transition) !important;
|
||||
}
|
||||
|
||||
.t-btn--variant-outline {
|
||||
border-color: var(--primary) !important;
|
||||
color: var(--primary) !important;
|
||||
}
|
||||
|
||||
.t-btn--variant-outline:hover {
|
||||
background: var(--primary) !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.t-btn--theme-primary {
|
||||
background: var(--primary) !important;
|
||||
border-color: var(--primary) !important;
|
||||
}
|
||||
|
||||
.t-btn--theme-primary:hover {
|
||||
background: var(--primary-hover) !important;
|
||||
border-color: var(--primary-hover) !important;
|
||||
}
|
||||
|
||||
.t-tag {
|
||||
border-radius: 20px !important;
|
||||
padding: 4px 12px !important;
|
||||
font-size: 12px !important;
|
||||
font-weight: 500 !important;
|
||||
}
|
||||
|
||||
.t-card {
|
||||
border-radius: var(--radius) !important;
|
||||
box-shadow: var(--shadow) !important;
|
||||
}
|
||||
|
||||
.t-form__controls {
|
||||
margin-top: 10px !important;
|
||||
}
|
||||
|
||||
.t-dialog__body {
|
||||
padding: 24px !important;
|
||||
}
|
||||
|
||||
.t-dialog__footer {
|
||||
padding: 16px 24px 24px !important;
|
||||
}
|
||||
|
||||
.form-section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin: 24px 0 16px;
|
||||
padding-left: 12px;
|
||||
border-left: 4px solid var(--primary);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-pending {
|
||||
background: #fef3c7;
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
.status-active {
|
||||
background: #d1fae5;
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.status-completed {
|
||||
background: #dbeafe;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.status-cancelled {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
16
frontend/tsconfig.json
Normal file
16
frontend/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
15
frontend/vite.config.ts
Normal file
15
frontend/vite.config.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8081',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
202
frp/LICENSE
Normal file
202
frp/LICENSE
Normal file
@@ -0,0 +1,202 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "{}"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright {yyyy} {name of copyright owner}
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
52
frp/README.md
Normal file
52
frp/README.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# FRP 内网穿透工具
|
||||
|
||||
## 文件说明
|
||||
|
||||
- `frpc`: FRP 客户端可执行文件 (Linux AMD64)
|
||||
- `frps`: FRP 服务器端可执行文件 (Linux AMD64)
|
||||
- `frpc.ini`: 客户端配置文件模板
|
||||
- `frpc.toml`: 客户端配置文件示例 (TOML格式)
|
||||
- `frps.toml`: 服务器端配置文件示例 (TOML格式)
|
||||
- `LICENSE`: 许可证文件
|
||||
|
||||
## 版本信息
|
||||
- FRP Version: 0.66.0
|
||||
- 架构: Linux AMD64
|
||||
|
||||
## 配置说明
|
||||
|
||||
### 客户端配置 (frpc.ini)
|
||||
这是一个用于宠物医院管理系统的典型配置:
|
||||
|
||||
```ini
|
||||
[common]
|
||||
server_addr = your_server_ip_or_domain
|
||||
server_port = 7000
|
||||
token = your_token_here
|
||||
|
||||
[web_frontend]
|
||||
type = http
|
||||
local_port = 5173
|
||||
custom_domains = hospital.yourdomain.com
|
||||
|
||||
[web_backend]
|
||||
type = tcp
|
||||
local_port = 8081
|
||||
remote_port = 8081
|
||||
```
|
||||
|
||||
## 启动服务
|
||||
|
||||
### 启动客户端
|
||||
```bash
|
||||
./frpc -c frpc.ini
|
||||
```
|
||||
|
||||
### 启动服务器端 (在公网服务器)
|
||||
```bash
|
||||
./frps -c frps.ini
|
||||
```
|
||||
|
||||
## 用于宠物医院管理系统的典型配置
|
||||
|
||||
请参考项目根目录下的 `frpc.ini` 配置文件,该文件已为宠物医院管理系统配置了适当的端口转发规则。
|
||||
35
frp/frpc.ini
Normal file
35
frp/frpc.ini
Normal file
@@ -0,0 +1,35 @@
|
||||
# FRP 客户端配置文件 - 宠物医院管理系统
|
||||
# 请根据实际情况修改服务器地址和端口
|
||||
|
||||
[common]
|
||||
# FRP 服务器地址 (公网服务器IP或域名)
|
||||
server_addr = your_server_ip_or_domain
|
||||
# FRP 服务器端口
|
||||
server_port = 7000
|
||||
# 通信密钥 (需与服务器端保持一致)
|
||||
token = your_token_here
|
||||
|
||||
# 前端服务穿透 - Vue开发服务器
|
||||
[web_frontend]
|
||||
type = http
|
||||
local_port = 5173
|
||||
custom_domains = hospital.yourdomain.com
|
||||
# 替换为实际的域名
|
||||
|
||||
# 后端服务穿透 - Spring Boot应用
|
||||
[web_backend]
|
||||
type = tcp
|
||||
local_port = 8081
|
||||
remote_port = 8081
|
||||
|
||||
# 如果需要HTTPS支持
|
||||
[web_frontend_https]
|
||||
type = https
|
||||
local_port = 5173
|
||||
custom_domains = hospital.yourdomain.com
|
||||
|
||||
# 管理面板 (如果需要远程管理)
|
||||
[dashboard]
|
||||
type = tcp
|
||||
local_port = 7500
|
||||
remote_port = 7500
|
||||
9
frp/frpc.toml
Normal file
9
frp/frpc.toml
Normal file
@@ -0,0 +1,9 @@
|
||||
serverAddr = "127.0.0.1"
|
||||
serverPort = 7000
|
||||
|
||||
[[proxies]]
|
||||
name = "test-tcp"
|
||||
type = "tcp"
|
||||
localIP = "127.0.0.1"
|
||||
localPort = 22
|
||||
remotePort = 6000
|
||||
1
frp/frps.toml
Normal file
1
frp/frps.toml
Normal file
@@ -0,0 +1 @@
|
||||
bindPort = 7000
|
||||
18
frp/run_frpc.sh
Normal file
18
frp/run_frpc.sh
Normal file
@@ -0,0 +1,18 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 启动FRP客户端 - 宠物医院管理系统
|
||||
|
||||
echo "启动FRP客户端..."
|
||||
|
||||
# 检查配置文件是否存在
|
||||
if [ ! -f "frpc.ini" ]; then
|
||||
echo "错误: 未找到配置文件 frpc.ini"
|
||||
echo "请先创建配置文件或复制示例文件"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 启动frpc
|
||||
echo "正在启动FRP客户端..."
|
||||
./frpc -c frpc.ini
|
||||
|
||||
echo "FRP客户端已退出"
|
||||
Reference in New Issue
Block a user