init
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
# 后端 API 地址
|
||||
VITE_API_BASE_URL=http://127.0.0.1:8000
|
||||
@@ -0,0 +1,43 @@
|
||||
import js from '@eslint/js'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import vue from 'eslint-plugin-vue'
|
||||
import vueParser from 'vue-eslint-parser'
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: ['dist/**', 'node_modules/**', '.vite/**', '*.tsbuildinfo'],
|
||||
},
|
||||
{
|
||||
files: ['**/*.{js,ts,vue}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
globals: {
|
||||
File: 'readonly',
|
||||
FormData: 'readonly',
|
||||
KeyboardEvent: 'readonly',
|
||||
console: 'readonly',
|
||||
document: 'readonly',
|
||||
localStorage: 'readonly',
|
||||
setTimeout: 'readonly',
|
||||
},
|
||||
},
|
||||
},
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
...vue.configs['flat/essential'],
|
||||
{
|
||||
files: ['**/*.vue'],
|
||||
languageOptions: {
|
||||
parser: vueParser,
|
||||
parserOptions: {
|
||||
parser: tseslint.parser,
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'vue/multi-word-component-names': 'off',
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>宿舍报修系统</title>
|
||||
<meta name="description" content="交互式透明化宿舍报修系统" />
|
||||
<link
|
||||
rel="icon"
|
||||
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%231b4f9c'/%3E%3Cpath d='M16 35h32v17H16z' fill='white'/%3E%3Cpath d='M13 33 32 17l19 16' fill='none' stroke='white' stroke-width='6' stroke-linecap='round' stroke-linejoin='round'/%3E%3Cpath d='M27 52V40h10v12' fill='%231b4f9c'/%3E%3C/svg%3E"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,30 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /uploads/ {
|
||||
proxy_pass http://backend:8000/uploads/;
|
||||
}
|
||||
|
||||
location /docs {
|
||||
proxy_pass http://backend:8000/docs;
|
||||
}
|
||||
|
||||
location /openapi.json {
|
||||
proxy_pass http://backend:8000/openapi.json;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "dorm-repair-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"description": "交互式透明化宿舍报修系统 - Vue 3 前端",
|
||||
"packageManager": "pnpm@11.5.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"lint": "eslint src vite.config.ts eslint.config.js",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "vue-tsc -b"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"axios": "^1.17.0",
|
||||
"element-plus": "^2.14.1",
|
||||
"vue": "^3.5.35",
|
||||
"vue-router": "^5.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/node": "^25.9.2",
|
||||
"@vitejs/plugin-vue": "^6.0.7",
|
||||
"eslint": "^10.4.1",
|
||||
"eslint-plugin-vue": "^10.9.2",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.60.1",
|
||||
"vite": "^8.0.16",
|
||||
"vue-eslint-parser": "^10.4.1",
|
||||
"vue-tsc": "^3.3.3"
|
||||
}
|
||||
}
|
||||
Generated
+2388
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
minimumReleaseAgeExclude:
|
||||
- '@types/node@25.9.2'
|
||||
@@ -0,0 +1,4 @@
|
||||
<template>
|
||||
<RouterView />
|
||||
</template>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { SwitchButton, UserFilled } from '@element-plus/icons-vue'
|
||||
|
||||
import oucLogoUrl from '@/assets/ref/ouc-logo.png'
|
||||
import { clearSession, getUser } from '@/lib/auth'
|
||||
|
||||
const props = defineProps<{
|
||||
accent?: 'student' | 'admin'
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
const user = computed(() => getUser())
|
||||
const roleText = computed(() => (props.accent === 'admin' ? '后勤管理端' : '学生服务端'))
|
||||
|
||||
function goHome() {
|
||||
router.push(props.accent === 'admin' ? '/admin/orders' : '/student/home')
|
||||
}
|
||||
|
||||
function logout() {
|
||||
clearSession()
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="portal-shell" :data-accent="accent ?? 'student'">
|
||||
<header class="portal-topbar">
|
||||
<div class="portal-topbar__inner">
|
||||
<button class="portal-brand" type="button" @click="goHome">
|
||||
<img :src="oucLogoUrl" alt="中国海洋大学" />
|
||||
<span>后勤报修服务系统</span>
|
||||
</button>
|
||||
|
||||
<div class="portal-topbar__tools">
|
||||
<span class="portal-role-chip">{{ roleText }}</span>
|
||||
<el-dropdown trigger="click">
|
||||
<button class="portal-user" type="button">
|
||||
<el-avatar :size="30">
|
||||
<el-icon><UserFilled /></el-icon>
|
||||
</el-avatar>
|
||||
<span>{{ user?.display_name ?? '同学' }}</span>
|
||||
</button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item @click="goHome">返回首页</el-dropdown-item>
|
||||
<el-dropdown-item divided @click="logout">
|
||||
<el-icon><SwitchButton /></el-icon>
|
||||
退出登录
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="portal-content">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<footer class="portal-footer">
|
||||
<div class="portal-footer__inner">
|
||||
<img :src="oucLogoUrl" alt="中国海洋大学" />
|
||||
<div>
|
||||
<p>地址:山东省青岛市古镇口军民融合创新示范区三沙路1299号</p>
|
||||
<p>电话:0532-60890000 · Copyright © 2026 中国海洋大学</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
status: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="status-badge" :data-status="status">{{ status }}</span>
|
||||
</template>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import type { OrderEvent } from '@/types'
|
||||
|
||||
defineProps<{
|
||||
events: OrderEvent[]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ol class="timeline">
|
||||
<li v-for="event in events" :key="event.id" class="timeline__item">
|
||||
<div class="timeline__dot" />
|
||||
<div class="timeline__body">
|
||||
<div class="timeline__row">
|
||||
<strong>{{ event.title }}</strong>
|
||||
<span>{{ event.created_at }}</span>
|
||||
</div>
|
||||
<p class="timeline__meta">{{ event.actor_name }} · {{ event.actor_role === 'admin' ? '管理员' : '学生' }}</p>
|
||||
<p v-if="event.detail" class="timeline__detail">{{ event.detail }}</p>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</template>
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
|
||||
const component: DefineComponent<Record<string, never>, Record<string, never>, unknown>
|
||||
export default component
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import axios from 'axios'
|
||||
|
||||
import { clearSession, getToken } from '@/lib/auth'
|
||||
|
||||
const baseURL = import.meta.env.VITE_API_BASE_URL as string || 'http://127.0.0.1:8000'
|
||||
|
||||
const api = axios.create({ baseURL })
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = getToken()
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
clearSession()
|
||||
}
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
|
||||
export function getUploadUrl(filePath: string): string {
|
||||
return `${baseURL}/uploads/${filePath}`
|
||||
}
|
||||
|
||||
export default api
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { UserProfile } from '@/types'
|
||||
|
||||
const TOKEN_KEY = 'dorm-repair-token'
|
||||
const USER_KEY = 'dorm-repair-user'
|
||||
|
||||
export function getToken(): string | null {
|
||||
return localStorage.getItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
export function getUser(): UserProfile | null {
|
||||
const raw = localStorage.getItem(USER_KEY)
|
||||
if (!raw) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
return JSON.parse(raw) as UserProfile
|
||||
} catch {
|
||||
clearSession()
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function saveSession(token: string, user: UserProfile): void {
|
||||
localStorage.setItem(TOKEN_KEY, token)
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(user))
|
||||
}
|
||||
|
||||
export function clearSession(): void {
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
localStorage.removeItem(USER_KEY)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
const ERROR_MESSAGES: Record<string, string> = {
|
||||
missing_token: '缺少认证令牌',
|
||||
invalid_token: '认证已过期,请重新登录',
|
||||
invalid_credentials: '用户名或密码错误',
|
||||
student_only: '此功能仅限学生使用',
|
||||
admin_only: '此功能仅限管理员使用',
|
||||
order_not_found: '工单不存在或已被删除',
|
||||
session_not_found: '诊断会话已过期,请重新描述故障',
|
||||
order_not_completed: '维修尚未完成,无法执行此操作',
|
||||
order_not_ready_for_feedback: '当前状态不可评价',
|
||||
order_cannot_cancel: '当前状态无法取消,仅已提交或待处理的工单可取消',
|
||||
unsupported_file_type: '仅支持上传图片文件(jpg, png, gif, webp)',
|
||||
file_too_large: '文件大小超过限制(最大10MB)',
|
||||
order_not_in_rework: '当前工单不在返工申请状态',
|
||||
validation_error: '请求参数有误',
|
||||
internal_error: '服务器内部错误',
|
||||
unauthorized: '未登录或登录已过期',
|
||||
forbidden: '无权访问此资源',
|
||||
}
|
||||
|
||||
const STATUS_MESSAGES: Record<number, string> = {
|
||||
400: '请求有误',
|
||||
401: '未登录或登录已过期',
|
||||
403: '无权访问',
|
||||
404: '请求的资源不存在',
|
||||
422: '请求参数有误',
|
||||
500: '服务器内部错误',
|
||||
}
|
||||
|
||||
export function getErrorMessage(error: unknown): string {
|
||||
if (error && typeof error === 'object' && 'response' in error) {
|
||||
const axiosError = error as { response?: { data?: { detail?: string; error_code?: string }; status?: number } }
|
||||
const data = axiosError.response?.data
|
||||
if (data?.error_code && ERROR_MESSAGES[data.error_code]) {
|
||||
return ERROR_MESSAGES[data.error_code]
|
||||
}
|
||||
if (data?.detail) {
|
||||
return data.detail
|
||||
}
|
||||
const status = axiosError.response?.status
|
||||
if (status && STATUS_MESSAGES[status]) {
|
||||
return STATUS_MESSAGES[status]
|
||||
}
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
if (error.message === 'Network Error' || error.message.includes('ERR_NETWORK')) {
|
||||
return '网络连接失败,请检查网络后重试'
|
||||
}
|
||||
}
|
||||
return '操作失败,请稍后重试'
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export const ACTIVE_STATUSES = new Set(['已提交', '待处理', '处理中', '待上门', '返工申请中'])
|
||||
export const CLOSED_STATUSES = new Set(['已完成', '已确认', '已取消'])
|
||||
@@ -0,0 +1,74 @@
|
||||
import { createApp } from 'vue'
|
||||
import {
|
||||
ElAvatar,
|
||||
ElButton,
|
||||
ElCard,
|
||||
ElDropdown,
|
||||
ElDropdownItem,
|
||||
ElDropdownMenu,
|
||||
ElEmpty,
|
||||
ElIcon,
|
||||
ElInput,
|
||||
ElOption,
|
||||
ElRate,
|
||||
ElSelect,
|
||||
ElSkeleton,
|
||||
ElTable,
|
||||
ElTableColumn,
|
||||
ElTag,
|
||||
ElTooltip,
|
||||
ElUpload,
|
||||
vLoading,
|
||||
} from 'element-plus'
|
||||
import 'element-plus/theme-chalk/base.css'
|
||||
import 'element-plus/theme-chalk/el-avatar.css'
|
||||
import 'element-plus/theme-chalk/el-button.css'
|
||||
import 'element-plus/theme-chalk/el-card.css'
|
||||
import 'element-plus/theme-chalk/el-dropdown.css'
|
||||
import 'element-plus/theme-chalk/el-dropdown-menu.css'
|
||||
import 'element-plus/theme-chalk/el-empty.css'
|
||||
import 'element-plus/theme-chalk/el-icon.css'
|
||||
import 'element-plus/theme-chalk/el-input.css'
|
||||
import 'element-plus/theme-chalk/el-loading.css'
|
||||
import 'element-plus/theme-chalk/el-message.css'
|
||||
import 'element-plus/theme-chalk/el-message-box.css'
|
||||
import 'element-plus/theme-chalk/el-option.css'
|
||||
import 'element-plus/theme-chalk/el-overlay.css'
|
||||
import 'element-plus/theme-chalk/el-popper.css'
|
||||
import 'element-plus/theme-chalk/el-rate.css'
|
||||
import 'element-plus/theme-chalk/el-select.css'
|
||||
import 'element-plus/theme-chalk/el-skeleton.css'
|
||||
import 'element-plus/theme-chalk/el-table.css'
|
||||
import 'element-plus/theme-chalk/el-table-column.css'
|
||||
import 'element-plus/theme-chalk/el-tag.css'
|
||||
import 'element-plus/theme-chalk/el-tooltip.css'
|
||||
import 'element-plus/theme-chalk/el-upload.css'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './style.css'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app
|
||||
.use(router)
|
||||
.use(ElAvatar)
|
||||
.use(ElButton)
|
||||
.use(ElCard)
|
||||
.use(ElDropdown)
|
||||
.use(ElDropdownItem)
|
||||
.use(ElDropdownMenu)
|
||||
.use(ElEmpty)
|
||||
.use(ElIcon)
|
||||
.use(ElInput)
|
||||
.use(ElOption)
|
||||
.use(ElRate)
|
||||
.use(ElSelect)
|
||||
.use(ElSkeleton)
|
||||
.use(ElTable)
|
||||
.use(ElTableColumn)
|
||||
.use(ElTag)
|
||||
.use(ElTooltip)
|
||||
.use(ElUpload)
|
||||
.directive('loading', vLoading)
|
||||
.mount('#app')
|
||||
@@ -0,0 +1,76 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
import LoginView from '@/views/LoginView.vue'
|
||||
import StudentHomeView from '@/views/StudentHomeView.vue'
|
||||
import StudentReportView from '@/views/StudentReportView.vue'
|
||||
import StudentOrdersView from '@/views/StudentOrdersView.vue'
|
||||
import StudentOrderDetailView from '@/views/StudentOrderDetailView.vue'
|
||||
import AdminOrdersView from '@/views/AdminOrdersView.vue'
|
||||
import { getUser } from '@/lib/auth'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/login',
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: LoginView,
|
||||
},
|
||||
{
|
||||
path: '/student/home',
|
||||
name: 'student-home',
|
||||
component: StudentHomeView,
|
||||
meta: { requiresAuth: true, role: 'student' },
|
||||
},
|
||||
{
|
||||
path: '/student/report',
|
||||
name: 'student-report',
|
||||
component: StudentReportView,
|
||||
meta: { requiresAuth: true, role: 'student' },
|
||||
},
|
||||
{
|
||||
path: '/student/orders',
|
||||
name: 'student-orders',
|
||||
component: StudentOrdersView,
|
||||
meta: { requiresAuth: true, role: 'student' },
|
||||
},
|
||||
{
|
||||
path: '/student/orders/:id',
|
||||
name: 'student-order-detail',
|
||||
component: StudentOrderDetailView,
|
||||
meta: { requiresAuth: true, role: 'student' },
|
||||
},
|
||||
{
|
||||
path: '/admin/orders',
|
||||
name: 'admin-orders',
|
||||
component: AdminOrdersView,
|
||||
meta: { requiresAuth: true, role: 'admin' },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
router.beforeEach((to) => {
|
||||
const user = getUser()
|
||||
const requiresAuth = Boolean(to.meta.requiresAuth)
|
||||
const expectedRole = to.meta.role as 'student' | 'admin' | undefined
|
||||
|
||||
if (requiresAuth && !user) {
|
||||
return '/login'
|
||||
}
|
||||
if (to.path === '/login' && user) {
|
||||
return user.role === 'admin' ? '/admin/orders' : '/student/home'
|
||||
}
|
||||
if (expectedRole && user?.role !== expectedRole) {
|
||||
if (!user) {
|
||||
return '/login'
|
||||
}
|
||||
return user.role === 'admin' ? '/admin/orders' : '/student/home'
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
export default router
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,122 @@
|
||||
export type Role = 'student' | 'admin'
|
||||
|
||||
export interface UserProfile {
|
||||
id: number
|
||||
username: string
|
||||
display_name: string
|
||||
role: Role
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
token: string
|
||||
user: UserProfile
|
||||
}
|
||||
|
||||
export interface DiagnosisQuestion {
|
||||
id: string
|
||||
prompt: string
|
||||
}
|
||||
|
||||
export interface DiagnosisDraft {
|
||||
category: string
|
||||
urgency: '低' | '中' | '高' | '紧急'
|
||||
summary: string
|
||||
safety_risk: boolean
|
||||
suggested_worker: string
|
||||
notes: string[]
|
||||
}
|
||||
|
||||
export interface DiagnosisResponse {
|
||||
session_id: string
|
||||
stage: 'questions' | 'draft'
|
||||
initial_message: string
|
||||
suggested_categories: string[]
|
||||
questions: DiagnosisQuestion[]
|
||||
draft: DiagnosisDraft | null
|
||||
}
|
||||
|
||||
export interface SavedAddress {
|
||||
id: number
|
||||
campus: string
|
||||
building: string
|
||||
room: string
|
||||
last_used_at: string
|
||||
}
|
||||
|
||||
export interface OrderSummary {
|
||||
id: number
|
||||
order_no: string
|
||||
campus: string
|
||||
building: string
|
||||
room: string
|
||||
category: string
|
||||
status: string
|
||||
urgency: string
|
||||
submission_time: string
|
||||
expected_repair_time: string | null
|
||||
assignee_name: string | null
|
||||
}
|
||||
|
||||
export interface Attachment {
|
||||
id: number
|
||||
file_name: string
|
||||
file_path: string
|
||||
mime_type: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface OrderEvent {
|
||||
id: number
|
||||
actor_role: string
|
||||
actor_name: string
|
||||
event_type: string
|
||||
title: string
|
||||
detail: string | null
|
||||
from_status: string | null
|
||||
to_status: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface FeedbackData {
|
||||
rating: number
|
||||
comment: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface OrderDetail {
|
||||
id: number
|
||||
order_no: string
|
||||
campus: string
|
||||
building: string
|
||||
room: string
|
||||
category: string
|
||||
status: string
|
||||
urgency: string
|
||||
raw_description: string
|
||||
structured_summary: string
|
||||
allow_room_entry: boolean
|
||||
expected_date: string | null
|
||||
expected_time_segment: string | null
|
||||
assignee_name: string | null
|
||||
expected_arrival_at: string | null
|
||||
admin_note: string | null
|
||||
rework_reason: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
attachments: Attachment[]
|
||||
events: OrderEvent[]
|
||||
feedback: FeedbackData | null
|
||||
}
|
||||
|
||||
export interface StatItem {
|
||||
category?: string
|
||||
building?: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface AdminStats {
|
||||
category_distribution: StatItem[]
|
||||
building_distribution: StatItem[]
|
||||
avg_processing_hours: number
|
||||
avg_rating: number
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
import AppShell from '@/components/AppShell.vue'
|
||||
import StatusBadge from '@/components/StatusBadge.vue'
|
||||
import TimelineList from '@/components/TimelineList.vue'
|
||||
import { getErrorMessage } from '@/lib/errors'
|
||||
import { getUploadUrl } from '@/lib/api'
|
||||
import api from '@/lib/api'
|
||||
import { ACTIVE_STATUSES, CLOSED_STATUSES } from '@/lib/status'
|
||||
import type { AdminStats, OrderDetail, OrderSummary } from '@/types'
|
||||
|
||||
const loadingOrders = ref(false)
|
||||
const saving = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const orders = ref<OrderSummary[]>([])
|
||||
const selectedOrder = ref<OrderDetail | null>(null)
|
||||
const currentRow = ref<OrderSummary | null>(null)
|
||||
const stats = ref<AdminStats | null>(null)
|
||||
const filters = reactive({
|
||||
status: '',
|
||||
category: '',
|
||||
urgency: '',
|
||||
})
|
||||
const updateForm = reactive({
|
||||
status: '',
|
||||
assignee_name: '',
|
||||
expected_arrival_at: '',
|
||||
admin_note: '',
|
||||
})
|
||||
|
||||
const BAR_COLORS = ['#1559a8', '#12a88a', '#d97706', '#7c3aed', '#0891b2']
|
||||
|
||||
const categories = computed(() => Array.from(new Set(orders.value.map((order) => order.category))))
|
||||
const dashboardStats = computed(() => {
|
||||
const urgentStatuses = new Set(['高', '紧急'])
|
||||
const activeCount = orders.value.filter((order) => ACTIVE_STATUSES.has(order.status)).length
|
||||
const urgentCount = orders.value.filter((order) => urgentStatuses.has(order.urgency)).length
|
||||
const completedCount = orders.value.filter((order) => CLOSED_STATUSES.has(order.status)).length
|
||||
|
||||
return [
|
||||
{ label: '全部工单', value: orders.value.length, note: '当前筛选范围' },
|
||||
{ label: '处理中', value: activeCount, note: '需要后勤继续跟进' },
|
||||
{ label: '高优先级', value: urgentCount, note: '高与紧急工单' },
|
||||
{ label: '已完成', value: completedCount, note: '待确认或已闭环' },
|
||||
]
|
||||
})
|
||||
|
||||
const maxCatCount = computed(() =>
|
||||
Math.max(...(stats.value?.category_distribution.map((i) => i.count) ?? [0]), 1),
|
||||
)
|
||||
const maxBldCount = computed(() =>
|
||||
Math.max(...(stats.value?.building_distribution.map((i) => i.count) ?? [0]), 1),
|
||||
)
|
||||
|
||||
async function loadOrders() {
|
||||
loadingOrders.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
const response = await api.get<OrderSummary[]>('/api/admin/orders', {
|
||||
params: {
|
||||
status: filters.status || undefined,
|
||||
category: filters.category || undefined,
|
||||
urgency: filters.urgency || undefined,
|
||||
},
|
||||
})
|
||||
orders.value = response.data
|
||||
} catch (err) {
|
||||
errorMessage.value = getErrorMessage(err)
|
||||
} finally {
|
||||
loadingOrders.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openOrder(orderId: number) {
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
const response = await api.get<OrderDetail>(`/api/admin/orders/${orderId}`)
|
||||
selectedOrder.value = response.data
|
||||
updateForm.status = response.data.status
|
||||
updateForm.assignee_name = response.data.assignee_name ?? ''
|
||||
updateForm.expected_arrival_at = response.data.expected_arrival_at ?? ''
|
||||
updateForm.admin_note = response.data.admin_note ?? ''
|
||||
} catch (err) {
|
||||
errorMessage.value = getErrorMessage(err)
|
||||
}
|
||||
}
|
||||
|
||||
function handleTableKeydown(event: KeyboardEvent) {
|
||||
if (currentRow.value && (event.key === 'Enter' || event.key === ' ')) {
|
||||
event.preventDefault()
|
||||
openOrder(currentRow.value.id)
|
||||
}
|
||||
}
|
||||
|
||||
async function updateOrder() {
|
||||
if (!selectedOrder.value) {
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
await api.patch(`/api/admin/orders/${selectedOrder.value.id}`, {
|
||||
status: updateForm.status,
|
||||
assignee_name: updateForm.assignee_name || null,
|
||||
expected_arrival_at: updateForm.expected_arrival_at || null,
|
||||
admin_note: updateForm.admin_note || null,
|
||||
})
|
||||
ElMessage.success('工单更新已保存')
|
||||
await loadOrders()
|
||||
await openOrder(selectedOrder.value.id)
|
||||
} catch (err) {
|
||||
errorMessage.value = getErrorMessage(err)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const response = await api.get<AdminStats>('/api/admin/stats')
|
||||
stats.value = response.data
|
||||
} catch {
|
||||
// stats are non-critical, ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadOrders()
|
||||
void loadStats()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell accent="admin">
|
||||
<h1 class="page-title">工单处理</h1>
|
||||
<div class="stack">
|
||||
<div v-if="errorMessage" class="banner banner--error">{{ errorMessage }}</div>
|
||||
|
||||
<section class="admin-command">
|
||||
<div>
|
||||
<h2>工单列表</h2>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="admin-metrics">
|
||||
<article v-for="stat in dashboardStats" :key="stat.label" class="admin-metric">
|
||||
<span>{{ stat.label }}</span>
|
||||
<strong>{{ stat.value }}</strong>
|
||||
<p>{{ stat.note }}</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section v-if="stats" class="card card--subtle">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>数据统计</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-summary">
|
||||
<div class="stat-summary__item">
|
||||
<span>平均处理时长</span>
|
||||
<strong>{{ stats.avg_processing_hours }}h</strong>
|
||||
</div>
|
||||
<div class="stat-summary__item">
|
||||
<span>满意度均分</span>
|
||||
<strong>{{ stats.avg_rating }} / 5</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-section">
|
||||
<div class="stat-card">
|
||||
<h3>故障类别分布</h3>
|
||||
<template v-if="stats.category_distribution.length">
|
||||
<div v-for="(item, idx) in stats.category_distribution" :key="item.category!" class="stat-bar-row">
|
||||
<span class="stat-bar-row__label">{{ item.category }}</span>
|
||||
<div class="stat-bar-row__track">
|
||||
<div
|
||||
class="stat-bar-row__fill"
|
||||
:style="{
|
||||
width: (item.count / maxCatCount) * 100 + '%',
|
||||
background: BAR_COLORS[idx % BAR_COLORS.length],
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
<span class="stat-bar-row__count">{{ item.count }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="stat-empty">暂无数据</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<h3>楼栋分布</h3>
|
||||
<template v-if="stats.building_distribution.length">
|
||||
<div v-for="(item, idx) in stats.building_distribution" :key="item.building!" class="stat-bar-row">
|
||||
<span class="stat-bar-row__label">{{ item.building }}</span>
|
||||
<div class="stat-bar-row__track">
|
||||
<div
|
||||
class="stat-bar-row__fill"
|
||||
:style="{
|
||||
width: (item.count / maxBldCount) * 100 + '%',
|
||||
background: BAR_COLORS[(idx + 2) % BAR_COLORS.length],
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
<span class="stat-bar-row__count">{{ item.count }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="stat-empty">暂无数据</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card card--subtle">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>筛选</h2>
|
||||
</div>
|
||||
<button class="button button--ghost" @click="loadOrders">刷新列表</button>
|
||||
</div>
|
||||
<div class="toolbar__filters">
|
||||
<el-select v-model="filters.status" placeholder="全部状态" @change="loadOrders">
|
||||
<el-option label="全部状态" value="" />
|
||||
<el-option label="已提交" value="已提交" />
|
||||
<el-option label="待处理" value="待处理" />
|
||||
<el-option label="处理中" value="处理中" />
|
||||
<el-option label="待上门" value="待上门" />
|
||||
<el-option label="已完成" value="已完成" />
|
||||
<el-option label="已确认" value="已确认" />
|
||||
<el-option label="返工申请中" value="返工申请中" />
|
||||
<el-option label="已取消" value="已取消" />
|
||||
</el-select>
|
||||
<el-select v-model="filters.category" placeholder="全部类别" @change="loadOrders">
|
||||
<el-option label="全部类别" value="" />
|
||||
<el-option v-for="category in categories" :key="category" :label="category" :value="category" />
|
||||
</el-select>
|
||||
<el-select v-model="filters.urgency" placeholder="全部紧急度" @change="loadOrders">
|
||||
<el-option label="全部紧急度" value="" />
|
||||
<el-option label="低" value="低" />
|
||||
<el-option label="中" value="中" />
|
||||
<el-option label="高" value="高" />
|
||||
<el-option label="紧急" value="紧急" />
|
||||
</el-select>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>工单总览</h2>
|
||||
</div>
|
||||
<span class="section-heading__meta">共 {{ orders.length }} 条记录</span>
|
||||
</div>
|
||||
<div class="table-scroll">
|
||||
<el-table
|
||||
v-loading="loadingOrders"
|
||||
class="service-table"
|
||||
:data="orders"
|
||||
row-key="id"
|
||||
empty-text="暂无工单"
|
||||
highlight-current-row
|
||||
tabindex="0"
|
||||
@row-click="(row: OrderSummary) => openOrder(row.id)"
|
||||
@current-change="(row: OrderSummary | null) => { currentRow = row }"
|
||||
@keydown.enter.prevent="handleTableKeydown"
|
||||
@keydown.space.prevent="handleTableKeydown"
|
||||
>
|
||||
<el-table-column prop="order_no" label="单号" min-width="138" />
|
||||
<el-table-column prop="category" label="类别" min-width="104" />
|
||||
<el-table-column label="状态" min-width="118">
|
||||
<template #default="{ row }">
|
||||
<StatusBadge :status="row.status" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="urgency" label="紧急度" min-width="90" />
|
||||
<el-table-column label="宿舍" min-width="150">
|
||||
<template #default="{ row }">{{ row.building }} / {{ row.room }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="负责人" min-width="104">
|
||||
<template #default="{ row }">{{ row.assignee_name || '待分配' }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="selectedOrder" class="card">
|
||||
<div class="detail-header">
|
||||
<div>
|
||||
<p class="muted">单号 {{ selectedOrder.order_no }}</p>
|
||||
<h2>{{ selectedOrder.category }}</h2>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 10px;">
|
||||
<StatusBadge :status="selectedOrder.status" />
|
||||
<button class="button button--ghost" @click="selectedOrder = null">关闭详情</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="service-summary">
|
||||
<div>
|
||||
<span>宿舍位置</span>
|
||||
<strong>{{ selectedOrder.campus }} / {{ selectedOrder.building }} / {{ selectedOrder.room }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>紧急程度</span>
|
||||
<strong>{{ selectedOrder.urgency }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>负责人</span>
|
||||
<strong>{{ selectedOrder.assignee_name || '待分配' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>预计上门</span>
|
||||
<strong>{{ selectedOrder.expected_arrival_at || '待安排' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>学生授权</span>
|
||||
<strong>{{ selectedOrder.allow_room_entry ? '允许入室' : '需本人在场' }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-block">
|
||||
<p class="muted">学生原始描述</p>
|
||||
<p>{{ selectedOrder.raw_description }}</p>
|
||||
</div>
|
||||
<div class="text-block">
|
||||
<p class="muted">整理后的报修摘要</p>
|
||||
<p>{{ selectedOrder.structured_summary }}</p>
|
||||
</div>
|
||||
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h3>处理信息</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-grid">
|
||||
<label class="field">
|
||||
<span>状态</span>
|
||||
<el-select v-model="updateForm.status">
|
||||
<el-option label="已提交" value="已提交" />
|
||||
<el-option label="待处理" value="待处理" />
|
||||
<el-option label="处理中" value="处理中" />
|
||||
<el-option label="待上门" value="待上门" />
|
||||
<el-option label="已完成" value="已完成" />
|
||||
<el-option label="已确认" value="已确认" />
|
||||
<el-option label="返工申请中" value="返工申请中" />
|
||||
<el-option label="已取消" value="已取消" />
|
||||
</el-select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>负责人</span>
|
||||
<el-input v-model="updateForm.assignee_name" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>预计上门</span>
|
||||
<el-input v-model="updateForm.expected_arrival_at" placeholder="今天 18:30" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="field">
|
||||
<span>处理备注</span>
|
||||
<el-input v-model="updateForm.admin_note" type="textarea" :rows="4" />
|
||||
</label>
|
||||
|
||||
<el-button type="primary" :loading="saving" @click="updateOrder">保存工单更新</el-button>
|
||||
|
||||
<div v-if="selectedOrder.rework_reason" class="banner banner--warning">
|
||||
学生返工说明:{{ selectedOrder.rework_reason }}
|
||||
</div>
|
||||
|
||||
<div class="attachments" v-if="selectedOrder.attachments.length">
|
||||
<p class="muted">学生上传的现场图片</p>
|
||||
<div class="attachments__grid">
|
||||
<img
|
||||
v-for="attachment in selectedOrder.attachments"
|
||||
:key="attachment.id"
|
||||
:src="getUploadUrl(attachment.file_path)"
|
||||
:alt="attachment.file_name"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h3>处理时间线</h3>
|
||||
</div>
|
||||
</div>
|
||||
<TimelineList :events="selectedOrder.events" />
|
||||
</section>
|
||||
|
||||
<div v-else-if="orders.length" class="empty-state">点击上方列表中的工单,查看详情并填写处理记录</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
</template>
|
||||
@@ -0,0 +1,312 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Lock, OfficeBuilding, User } from '@element-plus/icons-vue'
|
||||
|
||||
import api from '@/lib/api'
|
||||
import { saveSession } from '@/lib/auth'
|
||||
import { getErrorMessage } from '@/lib/errors'
|
||||
import type { LoginResponse } from '@/types'
|
||||
|
||||
type LoginRole = 'student' | 'admin'
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
// 课程演示默认凭据,生产环境需移除
|
||||
const form = reactive({
|
||||
username: 'student01',
|
||||
password: 'Student123',
|
||||
})
|
||||
const activeRole = ref<LoginRole>('student')
|
||||
|
||||
// 课程演示默认凭据,生产环境需移除
|
||||
const roleProfiles: Record<LoginRole, { label: string; username: string; password: string; scope: string }> = {
|
||||
student: {
|
||||
label: '学生',
|
||||
username: 'student01',
|
||||
password: 'Student123',
|
||||
scope: '报修处理',
|
||||
},
|
||||
admin: {
|
||||
label: '管理员',
|
||||
username: 'admin01',
|
||||
password: 'Admin123',
|
||||
scope: '工单调度',
|
||||
},
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!form.username.trim() || !form.password.trim()) {
|
||||
errorMessage.value = '请输入用户名和密码。'
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
const response = await api.post<LoginResponse>('/api/auth/login', form)
|
||||
saveSession(response.data.token, response.data.user)
|
||||
router.push(response.data.user.role === 'admin' ? '/admin/orders' : '/student/home')
|
||||
} catch (err) {
|
||||
errorMessage.value = getErrorMessage(err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function useRole(role: LoginRole) {
|
||||
const profile = roleProfiles[role]
|
||||
activeRole.value = role
|
||||
form.username = profile.username
|
||||
form.password = profile.password
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="login-page">
|
||||
<form class="login-form" aria-labelledby="login-title" @submit.prevent="submit">
|
||||
<div class="login-form__header">
|
||||
<span class="login-form__eyebrow">统一身份认证</span>
|
||||
<h1 id="login-title">登录系统</h1>
|
||||
</div>
|
||||
|
||||
<div class="role-switch" role="group" aria-label="登录身份">
|
||||
<button
|
||||
v-for="(profile, role) in roleProfiles"
|
||||
:key="role"
|
||||
type="button"
|
||||
:data-active="activeRole === role"
|
||||
@click="useRole(role)"
|
||||
>
|
||||
<component :is="role === 'student' ? User : OfficeBuilding" />
|
||||
<span>{{ profile.label }}</span>
|
||||
<small>{{ profile.scope }}</small>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="errorMessage" class="login-error" role="alert">{{ errorMessage }}</div>
|
||||
<label class="field">
|
||||
<span>用户名</span>
|
||||
<el-input
|
||||
v-model="form.username"
|
||||
:prefix-icon="User"
|
||||
autocomplete="username"
|
||||
clearable
|
||||
placeholder="请输入账号"
|
||||
size="large"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>密码</span>
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
:prefix-icon="Lock"
|
||||
autocomplete="current-password"
|
||||
placeholder="请输入密码"
|
||||
show-password
|
||||
size="large"
|
||||
type="password"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<el-button
|
||||
class="login-submit"
|
||||
native-type="submit"
|
||||
type="primary"
|
||||
:loading="loading"
|
||||
>
|
||||
进入系统
|
||||
</el-button>
|
||||
|
||||
<div class="login-account-hint">
|
||||
<span>当前身份</span>
|
||||
<strong>{{ roleProfiles[activeRole].label }}</strong>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
min-height: 100dvh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 32px 16px;
|
||||
background: #eef4fb;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
width: min(100%, 560px);
|
||||
padding: clamp(24px, 5vw, 44px);
|
||||
border: 1px solid #d9e2ec;
|
||||
border-left: 5px solid #004098;
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
box-shadow: 0 18px 42px rgba(21, 89, 168, 0.1);
|
||||
}
|
||||
|
||||
.login-form__header {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.login-form__eyebrow {
|
||||
color: #004098;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.login-form__header h1 {
|
||||
margin: 0;
|
||||
color: #1f2937;
|
||||
font-size: clamp(36px, 6vw, 52px);
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.role-switch {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.role-switch button {
|
||||
display: grid;
|
||||
grid-template-columns: 22px minmax(0, 1fr);
|
||||
gap: 2px 8px;
|
||||
align-items: center;
|
||||
min-height: 64px;
|
||||
padding: 10px;
|
||||
border: 1px solid #d9e2ec;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #475569;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 160ms ease,
|
||||
background-color 160ms ease,
|
||||
color 160ms ease,
|
||||
transform 120ms ease;
|
||||
}
|
||||
|
||||
.role-switch button:hover {
|
||||
border-color: #a9c8ed;
|
||||
background: #fbfdff;
|
||||
}
|
||||
|
||||
.role-switch button:active,
|
||||
.login-submit:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.role-switch button[data-active='true'] {
|
||||
border-color: #004098;
|
||||
background: #eaf3ff;
|
||||
color: #004098;
|
||||
}
|
||||
|
||||
.role-switch svg {
|
||||
grid-row: span 2;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.role-switch span {
|
||||
min-width: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.role-switch small {
|
||||
min-width: 0;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.login-error {
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #f8cbd0;
|
||||
background: #fef1f2;
|
||||
color: #be2f3c;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.login-submit.el-button {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.login-submit.el-button--primary {
|
||||
--el-button-bg-color: #004098;
|
||||
--el-button-border-color: #004098;
|
||||
--el-button-hover-bg-color: #0b3d78;
|
||||
--el-button-hover-border-color: #0b3d78;
|
||||
--el-button-active-bg-color: #0b3d78;
|
||||
--el-button-active-border-color: #0b3d78;
|
||||
}
|
||||
|
||||
.login-form :deep(.el-input__wrapper) {
|
||||
min-height: 44px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 0 1px #d9e2ec inset;
|
||||
}
|
||||
|
||||
.login-form :deep(.el-input__wrapper.is-focus) {
|
||||
box-shadow:
|
||||
0 0 0 1px #7ba4e8 inset,
|
||||
0 0 0 3px rgba(27, 79, 156, 0.1);
|
||||
}
|
||||
|
||||
.login-form :deep(.el-input__prefix) {
|
||||
color: #004098;
|
||||
}
|
||||
|
||||
.login-account-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
padding-top: 2px;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.login-account-hint span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.login-account-hint strong {
|
||||
min-width: 0;
|
||||
color: #1f2937;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.field {
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.login-page {
|
||||
align-items: start;
|
||||
padding-top: 34px;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.role-switch {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,170 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
CircleCheck,
|
||||
Finished,
|
||||
House,
|
||||
Warning,
|
||||
} from '@element-plus/icons-vue'
|
||||
|
||||
import AppShell from '@/components/AppShell.vue'
|
||||
import { getUser } from '@/lib/auth'
|
||||
import api from '@/lib/api'
|
||||
import { ACTIVE_STATUSES, CLOSED_STATUSES } from '@/lib/status'
|
||||
import type { OrderSummary } from '@/types'
|
||||
|
||||
const router = useRouter()
|
||||
const user = computed(() => getUser())
|
||||
const orders = ref<OrderSummary[]>([])
|
||||
const loadingOrders = ref(false)
|
||||
const totalOrders = computed(() => orders.value.length)
|
||||
const isEmpty = computed(() => totalOrders.value === 0 && !loadingOrders.value)
|
||||
const activeOrders = computed(() => orders.value.filter((order) => ACTIVE_STATUSES.has(order.status)).length)
|
||||
const closedOrders = computed(() => orders.value.filter((order) => CLOSED_STATUSES.has(order.status)).length)
|
||||
const currentOrders = computed(() => orders.value.filter((order) => ACTIVE_STATUSES.has(order.status)).slice(0, 3))
|
||||
const recentOrders = computed(() => orders.value.slice(0, 4))
|
||||
const serviceStats = computed(() => [
|
||||
{ label: '累计工单', value: totalOrders.value, icon: Finished },
|
||||
{ label: '处理中', value: activeOrders.value, icon: Warning },
|
||||
{ label: '已办结', value: closedOrders.value, icon: CircleCheck },
|
||||
])
|
||||
|
||||
async function loadOrders() {
|
||||
loadingOrders.value = true
|
||||
try {
|
||||
const response = await api.get<OrderSummary[]>('/api/student/orders')
|
||||
orders.value = response.data
|
||||
} catch {
|
||||
orders.value = []
|
||||
} finally {
|
||||
loadingOrders.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadOrders()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell accent="student">
|
||||
<h1 class="page-title">后勤报修服务</h1>
|
||||
<p class="page-subtitle">提交宿舍设施报修,查看维修进度与处理结果。</p>
|
||||
<div class="portal-home">
|
||||
<section class="portal-overview-strip">
|
||||
<el-card class="portal-profile-card" shadow="never">
|
||||
<div class="portal-profile-card__main">
|
||||
<el-avatar :size="54">
|
||||
<el-icon><House /></el-icon>
|
||||
</el-avatar>
|
||||
<div class="portal-profile-card__text">
|
||||
<span>西海岸校区后勤报修</span>
|
||||
<h1>{{ user?.display_name ?? '同学' }},需要报修从这里开始</h1>
|
||||
<p>宿舍设施故障先提交报修;已提交的问题在"我的工单"查看进度。</p>
|
||||
<div class="portal-profile-card__actions">
|
||||
<button class="button" type="button" @click="router.push('/student/report')">提交报修</button>
|
||||
<button class="text-button" type="button" @click="router.push('/student/orders')">查看我的工单</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</section>
|
||||
|
||||
<section v-if="isEmpty" class="welcome-card card">
|
||||
<div class="welcome-card__content">
|
||||
<h2>还没有报修记录</h2>
|
||||
<p>遇到宿舍设施问题?从提交第一份工单开始。</p>
|
||||
<div class="welcome-card__actions">
|
||||
<button class="button" type="button" @click="router.push('/student/report')">提交报修</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<template v-else>
|
||||
|
||||
<section class="portal-stats-row" aria-label="报修统计">
|
||||
<el-card
|
||||
v-for="stat in serviceStats"
|
||||
:key="stat.label"
|
||||
class="portal-stat-card"
|
||||
shadow="never"
|
||||
>
|
||||
<div class="portal-stat-card__icon">
|
||||
<el-icon><component :is="stat.icon" /></el-icon>
|
||||
</div>
|
||||
<span>{{ stat.label }}</span>
|
||||
<strong>{{ stat.value }}</strong>
|
||||
</el-card>
|
||||
</section>
|
||||
|
||||
<section class="portal-workspace">
|
||||
<el-card class="portal-orders-card" shadow="never">
|
||||
<template #header>
|
||||
<div class="portal-card-header">
|
||||
<div>
|
||||
<span>当前进度</span>
|
||||
<h2>需要关注的报修</h2>
|
||||
</div>
|
||||
<el-button type="primary" plain @click="router.push('/student/orders')">全部工单</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-skeleton v-if="loadingOrders" :rows="4" animated />
|
||||
<el-empty v-else-if="currentOrders.length === 0" description="当前没有未办结工单" />
|
||||
<el-table
|
||||
v-else
|
||||
class="portal-order-table"
|
||||
:data="currentOrders"
|
||||
:show-header="false"
|
||||
@row-click="(order: OrderSummary) => router.push(`/student/orders/${order.id}`)"
|
||||
>
|
||||
<el-table-column min-width="220">
|
||||
<template #default="{ row }">
|
||||
<div class="portal-order-main">
|
||||
<strong>{{ row.category }}</strong>
|
||||
<span>{{ row.building }} {{ row.room }} · {{ row.order_no }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column min-width="128">
|
||||
<template #default="{ row }">
|
||||
<el-tag effect="light" type="primary">{{ row.status }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column min-width="220">
|
||||
<template #default="{ row }">
|
||||
<span class="portal-muted">{{ row.assignee_name || '等待分配' }} / {{ row.expected_repair_time || '时间待定' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-card class="portal-recent-card" shadow="never">
|
||||
<template #header>
|
||||
<div class="portal-card-header">
|
||||
<div>
|
||||
<span>最近记录</span>
|
||||
<h2>报修记录</h2>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<el-empty v-if="recentOrders.length === 0" description="暂无报修记录" />
|
||||
<div v-else class="portal-recent-list">
|
||||
<button
|
||||
v-for="order in recentOrders"
|
||||
:key="order.id"
|
||||
type="button"
|
||||
@click="router.push(`/student/orders/${order.id}`)"
|
||||
>
|
||||
<span>{{ order.category }}</span>
|
||||
<el-tag size="small" type="info">{{ order.status }}</el-tag>
|
||||
</button>
|
||||
</div>
|
||||
</el-card>
|
||||
</section>
|
||||
|
||||
</template>
|
||||
</div>
|
||||
</AppShell>
|
||||
</template>
|
||||
@@ -0,0 +1,368 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
import emptyStateUrl from '@/assets/ref/empty-state.png'
|
||||
import AppShell from '@/components/AppShell.vue'
|
||||
import StatusBadge from '@/components/StatusBadge.vue'
|
||||
import TimelineList from '@/components/TimelineList.vue'
|
||||
import api from '@/lib/api'
|
||||
import { getErrorMessage } from '@/lib/errors'
|
||||
import { getUploadUrl } from '@/lib/api'
|
||||
import type { OrderDetail } from '@/types'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const order = ref<OrderDetail | null>(null)
|
||||
const loadingOrder = ref(false)
|
||||
const confirming = ref(false)
|
||||
const submittingFeedback = ref(false)
|
||||
const submittingRework = ref(false)
|
||||
const cancelling = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const feedbackComment = ref('')
|
||||
const feedbackRating = ref(5)
|
||||
const reworkReason = ref('')
|
||||
|
||||
const canFinish = computed(() => order.value?.status === '已完成')
|
||||
const canCancel = computed(() => order.value?.status === '已提交' || order.value?.status === '待处理')
|
||||
const progressSteps = ['已提交', '待处理', '待上门', '处理中', '已完成', '已确认']
|
||||
const statusGuides: Record<string, { explanation: string; nextStep: string; studentAction: string; responsibility: string; needsAction: boolean }> = {
|
||||
已提交: {
|
||||
explanation: '已提交,等待后勤受理。',
|
||||
nextStep: '受理后将补充负责人和预计上门时间。',
|
||||
studentAction: '请保持联系方式畅通。',
|
||||
responsibility: '等待后勤',
|
||||
needsAction: false,
|
||||
},
|
||||
待处理: {
|
||||
explanation: '后勤已受理,正在安排处理。',
|
||||
nextStep: '确认负责人后进入待上门或处理中。',
|
||||
studentAction: '请留意预计上门时间。',
|
||||
responsibility: '等待后勤',
|
||||
needsAction: false,
|
||||
},
|
||||
待上门: {
|
||||
explanation: '维修已安排,等待上门。',
|
||||
nextStep: '上门后更新处理状态。',
|
||||
studentAction: '请按授权方式配合维修。',
|
||||
responsibility: '等待上门',
|
||||
needsAction: false,
|
||||
},
|
||||
处理中: {
|
||||
explanation: '维修人员正在处理。',
|
||||
nextStep: '处理结束后会更新为已完成。',
|
||||
studentAction: '请等待处理结果。',
|
||||
responsibility: '维修中',
|
||||
needsAction: false,
|
||||
},
|
||||
已完成: {
|
||||
explanation: '后勤已标记完成,等待确认。',
|
||||
nextStep: '确认后工单关闭。',
|
||||
studentAction: '请检查结果,可确认、评价或返工。',
|
||||
responsibility: '需要您确认',
|
||||
needsAction: true,
|
||||
},
|
||||
已确认: {
|
||||
explanation: '已确认完成。',
|
||||
nextStep: '工单已关闭。',
|
||||
studentAction: '同类问题再次发生时请重新报修。',
|
||||
responsibility: '已完成',
|
||||
needsAction: false,
|
||||
},
|
||||
返工申请中: {
|
||||
explanation: '返工申请已提交。',
|
||||
nextStep: '后勤将重新安排处理。',
|
||||
studentAction: '请等待反馈。',
|
||||
responsibility: '等待后勤',
|
||||
needsAction: false,
|
||||
},
|
||||
}
|
||||
const activeStepIndex = computed(() => {
|
||||
if (!order.value) {
|
||||
return -1
|
||||
}
|
||||
if (order.value.status === '返工申请中') {
|
||||
return 4
|
||||
}
|
||||
return progressSteps.indexOf(order.value.status)
|
||||
})
|
||||
|
||||
const progressTooltips: Record<string, string> = {
|
||||
已提交: '工单已成功创建,等待后勤受理',
|
||||
待处理: '后勤已受理,正在安排处理人员',
|
||||
待上门: '维修人员已确认,准备上门',
|
||||
处理中: '维修人员正在现场处理',
|
||||
已完成: '维修已处理完成,等待您确认',
|
||||
已确认: '您已确认完成,工单已关闭',
|
||||
}
|
||||
const currentStatusGuide = computed(() => {
|
||||
if (!order.value) {
|
||||
return null
|
||||
}
|
||||
return statusGuides[order.value.status] ?? {
|
||||
explanation: '状态已更新。',
|
||||
nextStep: '请查看处理时间线。',
|
||||
studentAction: '请关注后续记录。',
|
||||
responsibility: '处理中',
|
||||
needsAction: false,
|
||||
}
|
||||
})
|
||||
|
||||
async function loadOrder() {
|
||||
loadingOrder.value = true
|
||||
try {
|
||||
const response = await api.get<OrderDetail>(`/api/student/orders/${route.params.id}`)
|
||||
order.value = response.data
|
||||
} catch (err) {
|
||||
errorMessage.value = getErrorMessage(err)
|
||||
} finally {
|
||||
loadingOrder.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmComplete() {
|
||||
confirming.value = true
|
||||
try {
|
||||
await api.post(`/api/student/orders/${route.params.id}/confirm`)
|
||||
ElMessage.success('已确认维修完成')
|
||||
await loadOrder()
|
||||
} catch (err) {
|
||||
ElMessage.error(getErrorMessage(err))
|
||||
} finally {
|
||||
confirming.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitFeedback() {
|
||||
if (!canFinish.value) {
|
||||
ElMessage.warning('维修完成后再提交评价')
|
||||
return
|
||||
}
|
||||
if (!feedbackComment.value.trim()) {
|
||||
ElMessage.warning('请填写评价内容')
|
||||
return
|
||||
}
|
||||
submittingFeedback.value = true
|
||||
try {
|
||||
await api.post(`/api/student/orders/${route.params.id}/feedback`, {
|
||||
rating: feedbackRating.value,
|
||||
comment: feedbackComment.value,
|
||||
})
|
||||
feedbackComment.value = ''
|
||||
ElMessage.success('评价已提交')
|
||||
await loadOrder()
|
||||
} catch (err) {
|
||||
ElMessage.error(getErrorMessage(err))
|
||||
} finally {
|
||||
submittingFeedback.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitRework() {
|
||||
if (!canFinish.value) {
|
||||
ElMessage.warning('维修完成后才能申请返工')
|
||||
return
|
||||
}
|
||||
if (!reworkReason.value.trim()) {
|
||||
ElMessage.warning('请填写返工原因')
|
||||
return
|
||||
}
|
||||
submittingRework.value = true
|
||||
try {
|
||||
await api.post(`/api/student/orders/${route.params.id}/rework`, {
|
||||
reason: reworkReason.value,
|
||||
})
|
||||
reworkReason.value = ''
|
||||
ElMessage.warning('返工申请已提交')
|
||||
await loadOrder()
|
||||
} catch (err) {
|
||||
ElMessage.error(getErrorMessage(err))
|
||||
} finally {
|
||||
submittingRework.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelOrder() {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要取消该工单吗?取消后无法恢复。', '确认取消', {
|
||||
confirmButtonText: '确定取消',
|
||||
cancelButtonText: '返回',
|
||||
type: 'warning',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
cancelling.value = true
|
||||
try {
|
||||
await api.post(`/api/student/orders/${route.params.id}/cancel`)
|
||||
ElMessage.success('工单已取消')
|
||||
router.push('/student/orders')
|
||||
} catch (err) {
|
||||
ElMessage.error(getErrorMessage(err))
|
||||
} finally {
|
||||
cancelling.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadOrder()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell accent="student">
|
||||
<h1 class="page-title">工单详情</h1>
|
||||
<div class="stack">
|
||||
<div v-if="loadingOrder" class="card empty-state">正在加载工单详情...</div>
|
||||
<div v-else-if="errorMessage" class="banner banner--error">{{ errorMessage }}</div>
|
||||
<template v-else-if="order">
|
||||
<div class="local-actions">
|
||||
<button class="text-button" type="button" @click="router.push('/student/orders')">返回工单列表</button>
|
||||
<button class="text-button" type="button" @click="router.push('/student/home')">返回报修首页</button>
|
||||
</div>
|
||||
<div class="detail-header">
|
||||
<div>
|
||||
<p class="muted">单号 {{ order.order_no }}</p>
|
||||
<h2>{{ order.category }}</h2>
|
||||
</div>
|
||||
<div class="detail-header__right">
|
||||
<StatusBadge :status="order.status" />
|
||||
<el-button v-if="canCancel" type="danger" :loading="cancelling" @click="cancelOrder">取消工单</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="card service-progress-card">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h3>当前处理进度</h3>
|
||||
</div>
|
||||
<span class="section-heading__meta responsibility-tag" :data-needs-action="currentStatusGuide?.needsAction">
|
||||
{{ currentStatusGuide?.responsibility }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="progress-track" :data-rework="order.status === '返工申请中'">
|
||||
<el-tooltip
|
||||
v-for="(step, index) in progressSteps"
|
||||
:key="step"
|
||||
:content="progressTooltips[step] || step"
|
||||
placement="top"
|
||||
>
|
||||
<div
|
||||
class="progress-track__step"
|
||||
:data-active="index <= activeStepIndex"
|
||||
:data-current="index === activeStepIndex"
|
||||
:aria-current="index === activeStepIndex ? 'step' : undefined"
|
||||
>
|
||||
<span>{{ index + 1 }}</span>
|
||||
<strong>{{ step }}</strong>
|
||||
</div>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
<div v-if="order.status === '返工申请中'" class="banner banner--warning">
|
||||
该工单已进入返工流程,后勤将根据返工原因重新处理。
|
||||
</div>
|
||||
<div v-if="currentStatusGuide" class="status-guide">
|
||||
<article>
|
||||
<span>当前说明</span>
|
||||
<p>{{ currentStatusGuide.explanation }}</p>
|
||||
</article>
|
||||
<article>
|
||||
<span>下一步</span>
|
||||
<p>{{ currentStatusGuide.nextStep }}</p>
|
||||
</article>
|
||||
<article v-if="!currentStatusGuide.needsAction">
|
||||
<span>你可以做</span>
|
||||
<p>{{ currentStatusGuide.studentAction }}</p>
|
||||
</article>
|
||||
</div>
|
||||
<div v-if="currentStatusGuide?.needsAction" class="card card--subtle">
|
||||
<h3>确认维修结果</h3>
|
||||
<div class="button-row" style="margin-bottom: 12px;">
|
||||
<el-button type="primary" :loading="confirming" @click="confirmComplete">确认完成</el-button>
|
||||
</div>
|
||||
<label class="field">
|
||||
<span>评分</span>
|
||||
<el-rate v-model="feedbackRating" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>评价内容</span>
|
||||
<el-input v-model="feedbackComment" type="textarea" :rows="2" placeholder="补充维修体验和结果" />
|
||||
</label>
|
||||
<div class="button-row" style="margin-top: 10px;">
|
||||
<el-button plain type="primary" :loading="submittingFeedback" @click="submitFeedback">提交评价</el-button>
|
||||
<el-button type="danger" plain :loading="submittingRework" @click="submitRework">申请返工</el-button>
|
||||
</div>
|
||||
<p v-if="order.feedback" class="muted" style="margin-top: 8px;">已提交评价:{{ order.feedback.rating }} 星 · {{ order.feedback.comment }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="detail-grid">
|
||||
<article class="card">
|
||||
<h3>基本信息</h3>
|
||||
<div class="service-summary">
|
||||
<div>
|
||||
<span>宿舍地址</span>
|
||||
<strong>{{ order.campus }} / {{ order.building }} / {{ order.room }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>紧急程度</span>
|
||||
<strong>{{ order.urgency }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>责任人</span>
|
||||
<strong>{{ order.assignee_name || '待分配' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>预计上门</span>
|
||||
<strong>{{ order.expected_arrival_at || '待安排' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>入室授权</span>
|
||||
<strong>{{ order.allow_room_entry ? '允许无人入室' : '需本人在场' }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-block">
|
||||
<p class="muted">报修原始描述</p>
|
||||
<p>{{ order.raw_description }}</p>
|
||||
</div>
|
||||
<div class="text-block">
|
||||
<p class="muted">报修摘要</p>
|
||||
<p>{{ order.structured_summary }}</p>
|
||||
</div>
|
||||
<div v-if="order.admin_note" class="text-block">
|
||||
<p class="muted">处理备注</p>
|
||||
<p>{{ order.admin_note }}</p>
|
||||
</div>
|
||||
<div v-if="order.attachments.length" class="attachments">
|
||||
<p class="muted">附件图片</p>
|
||||
<div class="attachments__grid">
|
||||
<img
|
||||
v-for="attachment in order.attachments"
|
||||
:key="attachment.id"
|
||||
:src="getUploadUrl(attachment.file_path)"
|
||||
:alt="attachment.file_name"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="card">
|
||||
<h3>处理进度</h3>
|
||||
<TimelineList v-if="order.events.length" :events="order.events" />
|
||||
<div v-else class="empty-illustration">
|
||||
<img :src="emptyStateUrl" alt="" />
|
||||
<span>暂无处理记录</span>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<div v-if="order.rework_reason && order.status === '返工申请中'" class="banner banner--warning" style="margin-top: 8px;">
|
||||
当前返工原因:{{ order.rework_reason }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</AppShell>
|
||||
</template>
|
||||
@@ -0,0 +1,126 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import AppShell from '@/components/AppShell.vue'
|
||||
import StatusBadge from '@/components/StatusBadge.vue'
|
||||
import api from '@/lib/api'
|
||||
import { ACTIVE_STATUSES, CLOSED_STATUSES } from '@/lib/status'
|
||||
import type { OrderSummary } from '@/types'
|
||||
|
||||
function relativeTime(dateStr: string): string {
|
||||
const diff = Date.now() - new Date(dateStr).getTime()
|
||||
const minutes = Math.floor(diff / 60000)
|
||||
if (minutes < 1) return '刚刚'
|
||||
if (minutes < 60) return `${minutes}分钟前`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}小时前`
|
||||
const days = Math.floor(hours / 24)
|
||||
return `${days}天前`
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const orders = ref<OrderSummary[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
const activeOrders = computed(() => orders.value.filter(o => ACTIVE_STATUSES.has(o.status)))
|
||||
const closedOrders = computed(() => orders.value.filter(o => CLOSED_STATUSES.has(o.status)))
|
||||
|
||||
async function loadOrders() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await api.get<OrderSummary[]>('/api/student/orders')
|
||||
orders.value = response.data
|
||||
} catch {
|
||||
orders.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadOrders()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell accent="student">
|
||||
<h1 class="page-title">我的工单</h1>
|
||||
<p class="page-subtitle">查看本人提交的宿舍报修记录与当前处理进度。</p>
|
||||
<div class="stack">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>工单记录</h2>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<button class="text-button" type="button" @click="router.push('/student/home')">返回报修首页</button>
|
||||
<button class="button" @click="router.push('/student/report')">新建报修</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<article class="card" v-if="loading">
|
||||
<div class="empty-state">读取中...</div>
|
||||
</article>
|
||||
|
||||
<article class="card" v-else-if="orders.length === 0">
|
||||
<div class="empty-state">暂无工单</div>
|
||||
</article>
|
||||
|
||||
<template v-else>
|
||||
<article v-if="activeOrders.length" class="card card--list">
|
||||
<h3 style="margin: 0 0 12px;">进行中 <span class="muted" style="font-weight: 400;">{{ activeOrders.length }}</span></h3>
|
||||
<div class="order-list">
|
||||
<button
|
||||
v-for="order in activeOrders"
|
||||
:key="order.id"
|
||||
class="order-card"
|
||||
type="button"
|
||||
@click="router.push(`/student/orders/${order.id}`)"
|
||||
>
|
||||
<div class="order-card__row">
|
||||
<strong>{{ order.category }}</strong>
|
||||
<StatusBadge :status="order.status" />
|
||||
</div>
|
||||
<p>{{ order.campus }} / {{ order.building }} / {{ order.room }}</p>
|
||||
<div class="order-card__meta">
|
||||
<span><b>单号</b>{{ order.order_no }}</span>
|
||||
<span><b>紧急度</b>{{ order.urgency }}</span>
|
||||
<span><b>{{ relativeTime(order.submission_time) }}</b></span>
|
||||
</div>
|
||||
<p v-if="order.expected_repair_time" class="order-card__hint">
|
||||
预计处理:{{ order.expected_repair_time }}
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article v-if="closedOrders.length" class="card card--list">
|
||||
<h3 style="margin: 0 0 12px;">已办结 <span class="muted" style="font-weight: 400;">{{ closedOrders.length }}</span></h3>
|
||||
<div class="order-list">
|
||||
<button
|
||||
v-for="order in closedOrders"
|
||||
:key="order.id"
|
||||
class="order-card"
|
||||
type="button"
|
||||
@click="router.push(`/student/orders/${order.id}`)"
|
||||
>
|
||||
<div class="order-card__row">
|
||||
<strong>{{ order.category }}</strong>
|
||||
<StatusBadge :status="order.status" />
|
||||
</div>
|
||||
<p>{{ order.campus }} / {{ order.building }} / {{ order.room }}</p>
|
||||
<div class="order-card__meta">
|
||||
<span><b>单号</b>{{ order.order_no }}</span>
|
||||
<span><b>紧急度</b>{{ order.urgency }}</span>
|
||||
<span><b>{{ relativeTime(order.submission_time) }}</b></span>
|
||||
</div>
|
||||
<p v-if="order.expected_repair_time" class="order-card__hint">
|
||||
预计处理:{{ order.expected_repair_time }}
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
</div>
|
||||
</AppShell>
|
||||
</template>
|
||||
@@ -0,0 +1,705 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import type { UploadFile, UploadRawFile } from 'element-plus'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
import AppShell from '@/components/AppShell.vue'
|
||||
import api from '@/lib/api'
|
||||
import { getUser } from '@/lib/auth'
|
||||
import { getErrorMessage } from '@/lib/errors'
|
||||
import type { DiagnosisResponse, SavedAddress } from '@/types'
|
||||
|
||||
type FlowStep = 'input' | 'questions' | 'review'
|
||||
type AddressOption = SavedAddress & {
|
||||
contact_name?: string
|
||||
phone?: string
|
||||
source?: 'saved' | 'local'
|
||||
}
|
||||
type RepairForm = {
|
||||
rawDescription: string
|
||||
campus: string
|
||||
building: string
|
||||
room: string
|
||||
category: string
|
||||
structuredSummary: string
|
||||
urgency: '低' | '中' | '高' | '紧急'
|
||||
expectedDate: string
|
||||
expectedTimeSegment: string
|
||||
allowRoomEntry: boolean | null
|
||||
}
|
||||
|
||||
interface RepairItem {
|
||||
label: string
|
||||
chargeType: string
|
||||
urgency: '低' | '中' | '高' | '紧急'
|
||||
}
|
||||
|
||||
interface RepairGroup {
|
||||
label: string
|
||||
items: RepairItem[]
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const user = computed(() => getUser())
|
||||
const diagnosing = ref(false)
|
||||
const submitting = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const successMessage = ref('')
|
||||
const flowStep = ref<FlowStep>('input')
|
||||
const diagnosis = ref<DiagnosisResponse | null>(null)
|
||||
const addresses = ref<SavedAddress[]>([])
|
||||
const localAddresses = ref<AddressOption[]>([])
|
||||
const selectedFiles = ref<UploadRawFile[]>([])
|
||||
const faultCount = ref(1)
|
||||
const showAddressForm = ref(false)
|
||||
const selectedAddressId = ref<number | null>(null)
|
||||
const sidebarCollapsed = ref(false)
|
||||
|
||||
const questionAnswers = reactive<Record<string, string>>({})
|
||||
const repairGroups: RepairGroup[] = [
|
||||
{
|
||||
label: '给排水',
|
||||
items: [
|
||||
{ label: '水龙头类', chargeType: '无偿', urgency: '中' },
|
||||
{ label: '下水疏通类', chargeType: '无偿', urgency: '高' },
|
||||
{ label: '洗手池类', chargeType: '无偿', urgency: '中' },
|
||||
{ label: '阀门开关类', chargeType: '无偿', urgency: '中' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '电路照明',
|
||||
items: [
|
||||
{ label: '照明故障', chargeType: '无偿', urgency: '中' },
|
||||
{ label: '插座异常', chargeType: '现场确认', urgency: '高' },
|
||||
{ label: '断电跳闸', chargeType: '无偿', urgency: '紧急' },
|
||||
{ label: '空开面板', chargeType: '现场确认', urgency: '高' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '门窗锁具',
|
||||
items: [
|
||||
{ label: '门锁钥匙', chargeType: '现场确认', urgency: '中' },
|
||||
{ label: '窗户五金', chargeType: '无偿', urgency: '中' },
|
||||
{ label: '柜门合页', chargeType: '现场确认', urgency: '低' },
|
||||
{ label: '门体变形', chargeType: '现场确认', urgency: '中' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '家具设施',
|
||||
items: [
|
||||
{ label: '床铺护栏', chargeType: '现场确认', urgency: '中' },
|
||||
{ label: '桌椅维修', chargeType: '现场确认', urgency: '低' },
|
||||
{ label: '衣柜抽屉', chargeType: '现场确认', urgency: '低' },
|
||||
{ label: '其他设施', chargeType: '现场确认', urgency: '低' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '空调设备',
|
||||
items: [
|
||||
{ label: '制冷制热异常', chargeType: '现场确认', urgency: '中' },
|
||||
{ label: '遥控器面板', chargeType: '现场确认', urgency: '低' },
|
||||
{ label: '漏水异响', chargeType: '现场确认', urgency: '中' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '网络弱电',
|
||||
items: [
|
||||
{ label: '网口异常', chargeType: '无偿', urgency: '中' },
|
||||
{ label: '弱电箱', chargeType: '无偿', urgency: '中' },
|
||||
{ label: '门禁设备', chargeType: '无偿', urgency: '高' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '其他',
|
||||
items: [
|
||||
{ label: '其他设施', chargeType: '现场确认', urgency: '低' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const repairSelection = reactive({
|
||||
group: '给排水',
|
||||
item: '水龙头类',
|
||||
})
|
||||
|
||||
const form = reactive<RepairForm>({
|
||||
rawDescription: '',
|
||||
campus: '',
|
||||
building: '',
|
||||
room: '',
|
||||
category: '',
|
||||
structuredSummary: '',
|
||||
urgency: '中',
|
||||
expectedDate: '',
|
||||
expectedTimeSegment: '',
|
||||
allowRoomEntry: null,
|
||||
})
|
||||
const addressDraft = reactive({
|
||||
campus: '西海岸校区',
|
||||
building: '',
|
||||
room: '',
|
||||
contact_name: '',
|
||||
phone: '',
|
||||
makeDefault: false,
|
||||
})
|
||||
|
||||
const selectedRepairGroup = computed(() => repairGroups.find((group) => group.label === repairSelection.group) ?? repairGroups[0])
|
||||
const selectedRepairItem = computed(
|
||||
() =>
|
||||
selectedRepairGroup.value.items.find((item) => item.label === repairSelection.item) ??
|
||||
selectedRepairGroup.value.items[0],
|
||||
)
|
||||
const selectedRepairLabel = computed(() => `${selectedRepairGroup.value.label} / ${selectedRepairItem.value.label}`)
|
||||
const chargeType = computed(() => selectedRepairItem.value.chargeType)
|
||||
const addressOptions = computed<AddressOption[]>(() => [
|
||||
...localAddresses.value,
|
||||
...addresses.value.map((address) => ({ ...address, source: 'saved' as const })),
|
||||
])
|
||||
const availableDates = computed(() => {
|
||||
const formatter = new Intl.DateTimeFormat('en-CA', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
})
|
||||
return Array.from({ length: 5 }, (_, index) => {
|
||||
const date = new Date()
|
||||
date.setDate(date.getDate() + index)
|
||||
return formatter.format(date)
|
||||
})
|
||||
})
|
||||
const timeSegments = ['不指定', '09:00-11:00', '14:00-16:00', '18:00-20:00']
|
||||
const categoryAliases: Record<string, string> = {
|
||||
网络设备: '网络弱电',
|
||||
}
|
||||
const completionItems = computed(() => [
|
||||
{ label: '描述', done: Boolean(form.rawDescription.trim()) },
|
||||
{ label: '地址', done: Boolean(form.campus && form.building && form.room) },
|
||||
{ label: '项目', done: Boolean(form.category) },
|
||||
{ label: '入室', done: form.allowRoomEntry !== null },
|
||||
])
|
||||
const assistantDraftItems = computed(() => {
|
||||
const draft = diagnosis.value?.draft
|
||||
if (!draft) {
|
||||
return []
|
||||
}
|
||||
return [
|
||||
{ label: '维修项目', value: selectedRepairLabel.value },
|
||||
{ label: '紧急程度', value: form.urgency },
|
||||
{ label: '负责工种', value: draft.suggested_worker },
|
||||
]
|
||||
})
|
||||
|
||||
const canSubmitQuestions = computed(() =>
|
||||
(diagnosis.value?.questions ?? []).every((question) => questionAnswers[question.id]?.trim()),
|
||||
)
|
||||
|
||||
const sectionStatus = computed(() => ({
|
||||
address: form.campus.trim() && form.building.trim() && form.room.trim(),
|
||||
category: Boolean(form.category),
|
||||
time: form.allowRoomEntry !== null,
|
||||
}))
|
||||
|
||||
const formReady = computed(() => form.rawDescription.trim() && form.allowRoomEntry !== null)
|
||||
|
||||
async function loadAddresses() {
|
||||
try {
|
||||
const response = await api.get<SavedAddress[]>('/api/student/addresses')
|
||||
addresses.value = response.data
|
||||
if (selectedAddressId.value === null && response.data[0]) {
|
||||
useAddress(response.data[0])
|
||||
}
|
||||
if (!response.data.length) {
|
||||
showAddressForm.value = true
|
||||
}
|
||||
} catch {
|
||||
console.warn('Failed to load saved addresses, using manual entry')
|
||||
showAddressForm.value = true
|
||||
}
|
||||
}
|
||||
|
||||
async function startDiagnosis() {
|
||||
if (!form.rawDescription.trim()) {
|
||||
errorMessage.value = '请先描述问题。'
|
||||
return
|
||||
}
|
||||
|
||||
diagnosing.value = true
|
||||
errorMessage.value = ''
|
||||
successMessage.value = ''
|
||||
try {
|
||||
const response = await api.post<DiagnosisResponse>('/api/student/diagnosis/start', {
|
||||
message: form.rawDescription,
|
||||
})
|
||||
diagnosis.value = response.data
|
||||
flowStep.value = response.data.stage === 'questions' ? 'questions' : 'review'
|
||||
if (response.data.draft) {
|
||||
syncRepairSelection(response.data.draft.category)
|
||||
form.urgency = response.data.draft.urgency
|
||||
form.structuredSummary = response.data.draft.summary
|
||||
}
|
||||
} catch (err) {
|
||||
errorMessage.value = getErrorMessage(err)
|
||||
} finally {
|
||||
diagnosing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitAnswers() {
|
||||
if (!diagnosis.value || !canSubmitQuestions.value) {
|
||||
return
|
||||
}
|
||||
|
||||
diagnosing.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
const response = await api.post<DiagnosisResponse>('/api/student/diagnosis/answer', {
|
||||
session_id: diagnosis.value.session_id,
|
||||
answers: diagnosis.value.questions.map((question) => ({
|
||||
question_id: question.id,
|
||||
answer: questionAnswers[question.id],
|
||||
})),
|
||||
})
|
||||
diagnosis.value = response.data
|
||||
syncRepairSelection(response.data.draft?.category ?? '')
|
||||
form.urgency = response.data.draft?.urgency ?? '中'
|
||||
form.structuredSummary = response.data.draft?.summary ?? ''
|
||||
flowStep.value = 'review'
|
||||
} catch (err) {
|
||||
errorMessage.value = getErrorMessage(err)
|
||||
} finally {
|
||||
diagnosing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function useAddress(address: SavedAddress) {
|
||||
selectedAddressId.value = address.id
|
||||
form.campus = address.campus
|
||||
form.building = address.building
|
||||
form.room = address.room
|
||||
}
|
||||
|
||||
function syncRepairSelection(category: string) {
|
||||
const [groupName, itemName] = category.split('/').map((part) => part.trim())
|
||||
const normalizedGroupName = categoryAliases[groupName] ?? groupName
|
||||
const normalizedCategory = categoryAliases[category] ?? category
|
||||
const group = repairGroups.find((entry) => entry.label === normalizedGroupName || entry.label === normalizedCategory)
|
||||
if (!group) {
|
||||
repairSelection.group = '家具设施'
|
||||
repairSelection.item = '其他设施'
|
||||
form.category = selectedRepairLabel.value
|
||||
return
|
||||
}
|
||||
repairSelection.group = group.label
|
||||
repairSelection.item = group.items.find((item) => item.label === itemName)?.label ?? group.items[0].label
|
||||
form.category = selectedRepairLabel.value
|
||||
}
|
||||
|
||||
function onRepairGroupChange() {
|
||||
repairSelection.item = selectedRepairGroup.value.items[0].label
|
||||
updateRepairMeta()
|
||||
}
|
||||
|
||||
function updateRepairMeta() {
|
||||
form.category = selectedRepairLabel.value
|
||||
form.urgency = selectedRepairItem.value.urgency
|
||||
}
|
||||
|
||||
function applyAddressDraft() {
|
||||
if (!addressDraft.building.trim() || !addressDraft.room.trim()) {
|
||||
ElMessage.warning('请填写楼栋和房间')
|
||||
return
|
||||
}
|
||||
|
||||
const address: AddressOption = {
|
||||
id: Date.now() * -1,
|
||||
campus: addressDraft.campus.trim() || '西海岸校区',
|
||||
building: addressDraft.building.trim(),
|
||||
room: addressDraft.room.trim(),
|
||||
contact_name: addressDraft.contact_name.trim() || user.value?.display_name || '本人',
|
||||
phone: addressDraft.phone.trim(),
|
||||
last_used_at: new Date().toISOString(),
|
||||
source: 'local',
|
||||
}
|
||||
localAddresses.value = addressDraft.makeDefault ? [address, ...localAddresses.value] : [...localAddresses.value, address]
|
||||
useAddress(address)
|
||||
showAddressForm.value = false
|
||||
addressDraft.building = ''
|
||||
addressDraft.room = ''
|
||||
addressDraft.contact_name = ''
|
||||
addressDraft.phone = ''
|
||||
addressDraft.makeDefault = false
|
||||
}
|
||||
|
||||
async function deleteAddress(address: AddressOption) {
|
||||
try {
|
||||
if (address.source === 'saved') {
|
||||
await api.delete(`/api/student/addresses/${address.id}`)
|
||||
addresses.value = addresses.value.filter(a => a.id !== address.id)
|
||||
} else {
|
||||
localAddresses.value = localAddresses.value.filter(a => a.id !== address.id)
|
||||
}
|
||||
if (selectedAddressId.value === address.id) {
|
||||
selectedAddressId.value = null
|
||||
form.campus = ''
|
||||
form.building = ''
|
||||
form.room = ''
|
||||
}
|
||||
if (!addressOptions.value.length) {
|
||||
showAddressForm.value = true
|
||||
}
|
||||
ElMessage.success('地址已删除')
|
||||
} catch {
|
||||
ElMessage.warning('删除地址失败')
|
||||
}
|
||||
}
|
||||
|
||||
function changeFaultCount(delta: number) {
|
||||
faultCount.value = Math.max(1, Math.min(20, faultCount.value + delta))
|
||||
}
|
||||
|
||||
function scrollToStep(index: number) {
|
||||
document.querySelector(`[data-step='step${index}']`)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
|
||||
function onUploadChange(_: UploadFile, uploadFiles: UploadFile[]) {
|
||||
const hasUnsupportedFile = uploadFiles.some((file) => file.raw && !file.raw.type.startsWith('image/'))
|
||||
if (hasUnsupportedFile) {
|
||||
ElMessage.warning('仅支持上传现场图片')
|
||||
}
|
||||
selectedFiles.value = uploadFiles
|
||||
.slice(0, 3)
|
||||
.map((file) => file.raw)
|
||||
.filter((file): file is UploadRawFile => Boolean(file && file.type.startsWith('image/')))
|
||||
}
|
||||
|
||||
function addressBadge(address: AddressOption) {
|
||||
if (address.source === 'local') {
|
||||
return '本次新增'
|
||||
}
|
||||
return selectedAddressId.value === address.id ? '当前地址' : '历史地址'
|
||||
}
|
||||
|
||||
async function submitOrder() {
|
||||
const summary = form.structuredSummary.trim() || form.rawDescription.trim()
|
||||
const rawDescription = `${form.rawDescription.trim() || summary}(数量:${faultCount.value})`
|
||||
form.category = selectedRepairLabel.value
|
||||
if (!form.campus.trim() || !form.building.trim() || !form.room.trim()) {
|
||||
errorMessage.value = '请填写完整维修地址。'
|
||||
return
|
||||
}
|
||||
if (!form.category.trim() || !summary) {
|
||||
errorMessage.value = '请填写维修项目和故障描述。'
|
||||
return
|
||||
}
|
||||
if (form.allowRoomEntry === null) {
|
||||
errorMessage.value = '请选择是否允许无人时维修。'
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
errorMessage.value = ''
|
||||
successMessage.value = ''
|
||||
try {
|
||||
const createResponse = await api.post<{ order_id: number }>('/api/student/orders', {
|
||||
campus: form.campus,
|
||||
building: form.building,
|
||||
room: form.room,
|
||||
category: selectedRepairLabel.value,
|
||||
raw_description: rawDescription,
|
||||
structured_summary: summary,
|
||||
urgency: form.urgency,
|
||||
expected_date: form.expectedDate || null,
|
||||
expected_time_segment: form.expectedTimeSegment || null,
|
||||
diagnosis_session_id: diagnosis.value?.session_id ?? null,
|
||||
allow_room_entry: form.allowRoomEntry,
|
||||
})
|
||||
|
||||
if (selectedFiles.value.length > 0) {
|
||||
const payload = new FormData()
|
||||
for (const file of selectedFiles.value) {
|
||||
payload.append('files', file)
|
||||
}
|
||||
await api.post(`/api/student/orders/${createResponse.data.order_id}/attachments`, payload)
|
||||
}
|
||||
|
||||
successMessage.value = '报修工单已提交,正在跳转到详情页。'
|
||||
ElMessage.success('报修工单已提交')
|
||||
setTimeout(() => {
|
||||
router.push(`/student/orders/${createResponse.data.order_id}`)
|
||||
}, 500)
|
||||
} catch (err) {
|
||||
errorMessage.value = getErrorMessage(err)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadAddresses()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell accent="student">
|
||||
<h1 class="page-title">我要报修</h1>
|
||||
<div class="repair-page">
|
||||
<aside class="repair-side-card" :data-collapsed="sidebarCollapsed ? 'true' : undefined">
|
||||
<button class="sidebar-toggle" type="button" @click="sidebarCollapsed = !sidebarCollapsed">
|
||||
{{ sidebarCollapsed ? '展开' : '收起' }}
|
||||
</button>
|
||||
<div class="sidebar-content">
|
||||
<h2>报修步骤</h2>
|
||||
<ol>
|
||||
<li v-for="item in completionItems" :key="item.label" :class="{ done: item.done }" @click="scrollToStep(completionItems.indexOf(item))">
|
||||
{{ item.label }}
|
||||
</li>
|
||||
</ol>
|
||||
<p v-if="!form.rawDescription.trim()">请先描述现场情况,系统将自动整理故障信息。</p>
|
||||
<p v-else-if="!formReady">继续完成剩余步骤后即可提交。</p>
|
||||
<p v-else class="ready-hint">信息已齐全,可以提交!</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section class="repair-form-panel">
|
||||
<div class="local-actions">
|
||||
<button class="text-button" type="button" @click="router.push('/student/home')">返回首页</button>
|
||||
<button class="text-button" type="button" @click="router.push('/student/orders')">我的工单</button>
|
||||
</div>
|
||||
|
||||
<div v-if="errorMessage" class="banner banner--error">{{ errorMessage }}</div>
|
||||
<div v-if="successMessage" class="banner banner--success">{{ successMessage }}</div>
|
||||
|
||||
<section class="flat-section" data-step="step0">
|
||||
<div class="flat-section__title">
|
||||
<span>01</span>
|
||||
<div>
|
||||
<h2>故障描述</h2>
|
||||
<p>请尽量详细地描述故障现象,以便维修师傅携带工具。(可选使用AI辅助整理)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="field">
|
||||
<el-input
|
||||
v-model="form.rawDescription"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
maxlength="200"
|
||||
show-word-limit
|
||||
placeholder="例如:门口顶灯闪烁,有轻微焦味,影响整间宿舍照明"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="assistant-action-bar">
|
||||
<el-button type="primary" :loading="diagnosing" @click="startDiagnosis" :disabled="!form.rawDescription.trim()">
|
||||
{{ diagnosis?.draft ? '重新整理' : 'AI 整理描述' }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="flowStep === 'questions'" class="assistant-question-panel">
|
||||
<div class="assistant-question-panel__title">
|
||||
<strong>🔍 请补充以下信息</strong>
|
||||
</div>
|
||||
<label v-for="question in diagnosis?.questions ?? []" :key="question.id" class="field">
|
||||
<span>{{ question.prompt }}</span>
|
||||
<el-input v-model="questionAnswers[question.id]" />
|
||||
</label>
|
||||
<div class="button-row">
|
||||
<el-button type="primary" :loading="diagnosing" :disabled="!canSubmitQuestions" @click="submitAnswers">生成草稿</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="diagnosis?.draft" class="assistant-draft-panel">
|
||||
<div v-for="item in assistantDraftItems" :key="item.label" class="draft-item">
|
||||
<span>{{ item.label }}</span>
|
||||
<strong>{{ item.value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="flat-section repair-block" data-step="step1" :data-done="sectionStatus.address || undefined">
|
||||
<div class="flat-section__title">
|
||||
<span>02</span>
|
||||
<div>
|
||||
<h2>故障地址</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="addressOptions.length" class="address-card-list">
|
||||
<button
|
||||
v-for="address in addressOptions"
|
||||
:key="address.id"
|
||||
class="address-select"
|
||||
:data-active="selectedAddressId === address.id"
|
||||
type="button"
|
||||
@click="useAddress(address)"
|
||||
>
|
||||
<span class="address-select__dot" />
|
||||
<div class="address-select__body">
|
||||
<strong>{{ addressBadge(address) }}</strong>
|
||||
<span>{{ address.campus }} / {{ address.building }} / {{ address.room }}</span>
|
||||
</div>
|
||||
<button
|
||||
class="address-select__delete"
|
||||
type="button"
|
||||
title="删除此地址"
|
||||
@click.stop="deleteAddress(address)"
|
||||
>×</button>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button v-if="!showAddressForm && addressOptions.length" class="address-add-button" type="button" @click="showAddressForm = true">+ 新增地址</button>
|
||||
|
||||
<div v-if="showAddressForm" class="inline-address-form">
|
||||
<div class="inline-address-form__row">
|
||||
<label class="field">
|
||||
<span>校区</span>
|
||||
<el-input v-model="addressDraft.campus" placeholder="西海岸校区" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>楼栋</span>
|
||||
<el-input v-model="addressDraft.building" placeholder="如 听海苑5号" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>房间</span>
|
||||
<el-input v-model="addressDraft.room" placeholder="如 718" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="inline-address-form__actions">
|
||||
<button v-if="addressOptions.length" class="button button--ghost" type="button" @click="showAddressForm = false">取消</button>
|
||||
<button class="button" type="button" @click="applyAddressDraft">使用这个地址</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="flat-section repair-block" data-step="step2" :data-done="sectionStatus.category || undefined">
|
||||
<div class="flat-section__title">
|
||||
<span>03</span>
|
||||
<div>
|
||||
<h2>故障项目</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="repair-type-grid">
|
||||
<label class="field">
|
||||
<span>维修大类</span>
|
||||
<el-select v-model="repairSelection.group" placeholder="请选择" @change="onRepairGroupChange">
|
||||
<el-option v-for="group in repairGroups" :key="group.label" :label="group.label" :value="group.label" />
|
||||
</el-select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>具体项目</span>
|
||||
<el-select v-model="repairSelection.item" placeholder="请选择" @change="updateRepairMeta">
|
||||
<el-option v-for="item in selectedRepairGroup.items" :key="item.label" :label="item.label" :value="item.label" />
|
||||
</el-select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>紧急程度</span>
|
||||
<el-select v-model="form.urgency" placeholder="请选择">
|
||||
<el-option label="低" value="低" />
|
||||
<el-option label="中" value="中" />
|
||||
<el-option label="高" value="高" />
|
||||
<el-option label="紧急" value="紧急" />
|
||||
</el-select>
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="form.category" class="repair-derived">
|
||||
<div><span>已选项目</span><strong>{{ selectedRepairLabel }}</strong></div>
|
||||
<div><span>收费类型</span><strong>{{ chargeType }}</strong></div>
|
||||
<div><span>处理优先级</span><strong>{{ form.urgency }}</strong></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="flat-section repair-block">
|
||||
<div class="flat-section__title">
|
||||
<span>04</span>
|
||||
<div>
|
||||
<h2>现场材料</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="repair-inline-tools">
|
||||
<div>
|
||||
<span class="tool-label">故障数量</span>
|
||||
<div class="quantity-control">
|
||||
<button type="button" @click="changeFaultCount(-1)">-</button>
|
||||
<span>{{ faultCount }}</span>
|
||||
<button type="button" @click="changeFaultCount(1)">+</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="tool-label">现场图片</span>
|
||||
<el-upload
|
||||
action="#"
|
||||
accept="image/*"
|
||||
:auto-upload="false"
|
||||
:limit="3"
|
||||
multiple
|
||||
list-type="picture-card"
|
||||
@change="onUploadChange"
|
||||
@remove="onUploadChange"
|
||||
>
|
||||
<span class="service-upload__plus">+</span>
|
||||
</el-upload>
|
||||
<small class="upload-count"><span>{{ selectedFiles.length }}</span> / 3</small>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="flat-section repair-block" data-step="step3" :data-done="sectionStatus.time || undefined">
|
||||
<div class="flat-section__title">
|
||||
<span>05</span>
|
||||
<div>
|
||||
<h2>期望维修时间</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="repair-type-grid">
|
||||
<label class="field">
|
||||
<span>选择日期</span>
|
||||
<el-select v-model="form.expectedDate" placeholder="请选择">
|
||||
<el-option label="不指定" value="" />
|
||||
<el-option v-for="date in availableDates" :key="date" :label="date" :value="date" />
|
||||
</el-select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>时间段</span>
|
||||
<el-select v-model="form.expectedTimeSegment" placeholder="请选择">
|
||||
<el-option v-for="segment in timeSegments" :key="segment" :label="segment" :value="segment === '不指定' ? '' : segment" />
|
||||
</el-select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="room-entry-choice">
|
||||
<span>是否允许无人时进入维修</span>
|
||||
<label class="radio-row">
|
||||
<input v-model="form.allowRoomEntry" type="radio" :value="true" />
|
||||
<span>可以,无需本人在场</span>
|
||||
</label>
|
||||
<label class="radio-row">
|
||||
<input v-model="form.allowRoomEntry" type="radio" :value="false" />
|
||||
<span>不可以,需要本人在场</span>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="diagnosis?.draft?.safety_risk" class="banner banner--warning">
|
||||
识别到潜在安全风险,请暂停使用相关设备。
|
||||
</div>
|
||||
|
||||
<label class="field repair-summary-field">
|
||||
<span>工单摘要(可修改)</span>
|
||||
<el-input v-model="form.structuredSummary" type="textarea" :rows="2" placeholder="系统将自动生成摘要,也可手动修改" />
|
||||
</label>
|
||||
|
||||
<div class="submit-strip" :class="{ 'submit-strip--ready': formReady }" aria-label="提交操作栏">
|
||||
<span class="submit-strip__hint">
|
||||
<template v-if="!form.rawDescription.trim()">请先描述故障现象</template>
|
||||
<template v-else-if="form.allowRoomEntry === null">请选择是否允许无人时进入维修</template>
|
||||
<template v-else>✓ 信息齐全,可以提交</template>
|
||||
</span>
|
||||
<el-button type="primary" :disabled="!formReady" :loading="submitting" @click="submitOrder">
|
||||
{{ submitting ? '提交中...' : '提交报修' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</AppShell>
|
||||
</template>
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"tsBuildInfoFile": "./.tsbuildinfo/app.tsbuildinfo",
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"composite": true,
|
||||
"noEmit": true,
|
||||
"tsBuildInfoFile": "./.tsbuildinfo/node.tsbuildinfo",
|
||||
"skipLibCheck": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
if (id.includes('node_modules/element-plus') || id.includes('node_modules/@element-plus')) {
|
||||
return 'element-plus'
|
||||
}
|
||||
if (
|
||||
id.includes('node_modules/vue') ||
|
||||
id.includes('node_modules/vue-router') ||
|
||||
id.includes('node_modules/axios')
|
||||
) {
|
||||
return 'vendor'
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user