添加前端代码和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

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
frp/frpc
frp/frps
frp/frpc
frp/frps
frontend/node_modules/

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

202
frp/LICENSE Normal file
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1 @@
bindPort = 7000

18
frp/run_frpc.sh Normal file
View 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客户端已退出"