添加前端代码和FRP配置文件

This commit is contained in:
2026-01-30 09:00:26 +08:00
parent c09ce065fe
commit 3fe395b8d5
41 changed files with 4341 additions and 0 deletions

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

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

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

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

File diff suppressed because it is too large Load Diff

23
frontend/package.json Normal file
View 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
View File

@@ -0,0 +1,3 @@
<template>
<RouterView />
</template>

23
frontend/src/api/http.ts Normal file
View 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
View 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'),
};

View 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'] },
];

View 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
View 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');

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View 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');
},
},
});

View 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
View 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
View 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,
},
},
},
});