This commit is contained in:
zxr
2026-03-27 19:26:10 +08:00
parent 7e46f8b4e1
commit 519759ea58
25 changed files with 1409 additions and 278 deletions

1
.gitignore vendored
View File

@@ -14,6 +14,7 @@ dist-ssr
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*
docs/*
!.vscode/extensions.json !.vscode/extensions.json
.idea .idea
.DS_Store .DS_Store

View File

@@ -86,6 +86,11 @@ export const fetchAssetList = (data?: AssetListParams) => {
return request.post("/Assets/v1/asset/list", data || {}); return request.post("/Assets/v1/asset/list", data || {});
}; };
/** 获取资产列表(不分页,下拉) */
export const fetchAssetAll = (params?: { keyword?: string }) => {
return request.get("/Assets/v1/asset/all", { params });
};
/** 获取资产详情 */ /** 获取资产详情 */
export const fetchAssetDetail = (id: number) => { export const fetchAssetDetail = (id: number) => {
return request.get(`/Assets/v1/asset/detail/${id}`); return request.get(`/Assets/v1/asset/detail/${id}`);

View File

@@ -38,14 +38,14 @@ export const deleteDatacenter = (id: number) => {
}; };
/** 获取省份列表(用于下拉选择) */ /** 获取省份列表(用于下拉选择) */
export const fetchProvinceList = () => { export const fetchProvinceList = (params?: { keyword?: string }) => {
return request.get("/Assets/v1/province/all"); return request.get('/Assets/v1/province/all', { params })
}; }
/** 获取城市列表(用于下拉选择) */ /** 获取城市列表(用于下拉选择) */
export const fetchCityList = () => { export const fetchCityList = (provinceId: number, params?: { keyword?: string }) => {
return request.get("/Assets/v1/city/all"); return request.get(`/Assets/v1/city/province/${provinceId}`, { params })
}; }
/** 根据城市获取数据中心列表 */ /** 根据城市获取数据中心列表 */
export const fetchDatacenterByCity = (cityId: number) => { export const fetchDatacenterByCity = (cityId: number) => {

View File

@@ -1,25 +1,91 @@
import { request } from "@/api/request"; import { request } from '@/api/request'
/** 获取 工单列表 */ /**
export const fetchFeedbackTickets = (data?: { * 后端要求 status 为字符串数组;统一为 string[](单选、逗号分隔写法也转成多项)。
page?: number, */
page_size?: number, function normalizeTicketListStatusParam(params: Record<string, any>) {
size?: number, const raw = params.status
keyword?: string, if (raw === undefined || raw === null) return
type?: string, let arr: string[]
priority?: string, if (Array.isArray(raw)) {
status?: string, arr = raw.map((s) => String(s).trim()).filter(Boolean)
creator_id?: number, } else {
assignee_id?: number const s = String(raw).trim()
}) => { if (!s) {
// 兼容 size 参数,转换为 page_size delete params.status
const params: any = data ? { ...data } : {}; return
if (params.size !== undefined && params.page_size === undefined) { }
params.page_size = params.size; arr = s.includes(',') ? s.split(',').map((x) => x.trim()).filter(Boolean) : [s]
delete params.size; }
if (arr.length === 0) delete params.status
else params.status = arr
}
/** GET query`status` 重复键 status=a&status=b与后端 []string 绑定一致) */
function serializeFeedbackTicketListParams(params: Record<string, any>): string {
const usp = new URLSearchParams()
const keys = Object.keys(params).sort()
for (const key of keys) {
const value = params[key]
if (value === undefined || value === null || value === '') continue
if (key === 'status' && Array.isArray(value)) {
for (const item of value) {
const t = String(item).trim()
if (t) usp.append('status', t)
}
continue
}
usp.append(key, String(value))
}
return usp.toString()
}
/** 避免 undefined / null / 空串进入 query部分运行时下发异常键名 */
function stripEmptyQueryValues(params: Record<string, any>) {
Object.keys(params).forEach((k) => {
const v = params[k]
if (v === undefined || v === null || v === '') {
delete params[k]
}
})
}
/** 获取工单列表 GET /Feedback/v1/tickets分页与多条件筛选 */
export const fetchFeedbackTickets = (data?: {
page?: number
page_size?: number
size?: number
/** 单状态、逗号分隔、或 string[](将统一为数组并以重复 query 键序列化) */
status?: string | string[]
type?: string
priority?: string
creator_id?: number
assignee_id?: number
category_id?: number
keyword?: string
/** 格式 YYYY-MM-DD HH:MM:SS */
start_time?: string
/** 格式 YYYY-MM-DD HH:MM:SS */
end_time?: string
}) => {
const params: any = data ? { ...data } : {}
if (params.size !== undefined && params.page_size === undefined) {
params.page_size = params.size
delete params.size
}
normalizeTicketListStatusParam(params)
stripEmptyQueryValues(params)
const config =
Object.keys(params).length > 0
? {
params,
paramsSerializer: {
serialize: (p: Record<string, any>) => serializeFeedbackTicketListParams(p),
},
}
: undefined
return request.get('/Feedback/v1/tickets', config)
} }
return request.get("/Feedback/v1/tickets", params ? { params } : undefined);
};
/** 创建 工单 */ /** 创建 工单 */
export const createFeedbackTicket = (data: any) => request.post("/Feedback/v1/tickets", data); export const createFeedbackTicket = (data: any) => request.post("/Feedback/v1/tickets", data);

View File

@@ -16,6 +16,14 @@ export const fetchFloorDetail = (id: number) => {
return request.get(`/Assets/v1/floor/detail/${id}`); return request.get(`/Assets/v1/floor/detail/${id}`);
}; };
/** 根据数据中心获取楼层列表(下拉,不分页) */
export const fetchFloorListByDatacenter = (
datacenterId: number,
params?: { name?: string }
) => {
return request.get(`/Assets/v1/floor/datacenter/${datacenterId}`, { params });
};
/** 创建楼层 */ /** 创建楼层 */
export const createFloor = (data: any) => { export const createFloor = (data: any) => {
return request.post("/Assets/v1/floor/create", data); return request.post("/Assets/v1/floor/create", data);
@@ -32,6 +40,9 @@ export const deleteFloor = (id: number) => {
}; };
/** 获取数据中心列表(用于下拉选择) */ /** 获取数据中心列表(用于下拉选择) */
export const fetchDatacenterList = () => { export const fetchDatacenterList = (params?: { keyword?: string; name?: string }) => {
return request.get("/Assets/v1/datacenter/all"); const normalizedParams = params?.keyword
? { ...params, name: params.name ?? params.keyword }
: params
return request.get("/Assets/v1/datacenter/all", { params: normalizedParams });
}; };

View File

@@ -23,6 +23,14 @@ export const fetchRackListByDatacenter = (
return request.get(`/Assets/v1/rack/datacenter/${datacenterId}`, { params }); return request.get(`/Assets/v1/rack/datacenter/${datacenterId}`, { params });
}; };
/** 根据楼层获取机柜列表(下拉,不分页) */
export const fetchRackListByFloor = (
floorId: number,
params?: { name?: string }
) => {
return request.get(`/Assets/v1/rack/floor/${floorId}`, { params });
};
/** 获取机柜详情 */ /** 获取机柜详情 */
export const fetchRackDetail = (id: number) => { export const fetchRackDetail = (id: number) => {
return request.get(`/Assets/v1/rack/detail/${id}`); return request.get(`/Assets/v1/rack/detail/${id}`);
@@ -49,11 +57,17 @@ export const fetchSupplierList = () => {
}; };
/** 获取数据中心列表(用于下拉选择) */ /** 获取数据中心列表(用于下拉选择) */
export const fetchDatacenterList = () => { export const fetchDatacenterList = (params?: { keyword?: string; name?: string }) => {
return request.get("/Assets/v1/datacenter/all"); const normalizedParams = params?.keyword
? { ...params, name: params.name ?? params.keyword }
: params
return request.get("/Assets/v1/datacenter/all", { params: normalizedParams });
}; };
/** 获取楼层列表(用于下拉选择) */ /** 获取楼层列表(用于下拉选择) */
export const fetchFloorListForSelect = (datacenterId?: number) => { export const fetchFloorListForSelect = (params?: { datacenter_id?: number; keyword?: string; name?: string }) => {
return request.get("/Assets/v1/floor/all", { params: { datacenter_id: datacenterId } }); const normalizedParams = params?.keyword
? { ...params, name: params.name ?? params.keyword }
: params
return request.get("/Assets/v1/floor/all", { params: normalizedParams });
}; };

View File

@@ -24,6 +24,8 @@
:options="item.options" :options="item.options"
:placeholder="item.placeholder || '请选择'" :placeholder="item.placeholder || '请选择'"
:disabled="item.disabled" :disabled="item.disabled"
:allow-search="item.allowSearch"
@search="handleSelectSearch(item, $event)"
allow-clear allow-clear
/> />
<!-- 日期范围选择器 --> <!-- 日期范围选择器 -->
@@ -134,6 +136,10 @@ const handleSearch = () => {
const handleReset = () => { const handleReset = () => {
emit('reset') emit('reset')
} }
const handleSelectSearch = (item: FormItem, keyword: string) => {
item.onSearch?.(keyword)
}
</script> </script>
<script lang="ts"> <script lang="ts">

View File

@@ -9,4 +9,8 @@ export interface FormItem {
options?: SelectOptionData[] options?: SelectOptionData[]
/** 仅对 select 生效 */ /** 仅对 select 生效 */
disabled?: boolean disabled?: boolean
/** 仅对 select 生效 */
allowSearch?: boolean
/** 仅对 select 生效 */
onSearch?: (value: string) => void
} }

View File

@@ -7,6 +7,13 @@ export const searchFormConfig: FormItem[] = [
type: 'input', type: 'input',
placeholder: '请输入楼层名称或编码', placeholder: '请输入楼层名称或编码',
}, },
{
field: 'datacenter_id',
label: '数据中心',
type: 'select',
placeholder: '请选择数据中心',
options: [],
},
{ {
field: 'status', field: 'status',
label: '状态', label: '状态',

View File

@@ -72,7 +72,7 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref, reactive, computed } from 'vue' import { ref, reactive, computed, onMounted } from 'vue'
import { Message, Modal } from '@arco-design/web-vue' import { Message, Modal } from '@arco-design/web-vue'
import { IconPlus } from '@arco-design/web-vue/es/icon' import { IconPlus } from '@arco-design/web-vue/es/icon'
import type { FormItem } from '@/components/search-form/types' import type { FormItem } from '@/components/search-form/types'
@@ -81,6 +81,7 @@ import { searchFormConfig } from './config/search-form'
import { columns as columnsConfig } from './config/columns' import { columns as columnsConfig } from './config/columns'
import { import {
fetchFloorList, fetchFloorList,
fetchDatacenterList,
deleteFloor, deleteFloor,
} from '@/api/ops/floor' } from '@/api/ops/floor'
import FloorDetailDialog from './components/FloorDetailDialog.vue' import FloorDetailDialog from './components/FloorDetailDialog.vue'
@@ -101,6 +102,7 @@ const tableData = ref<any[]>([])
const formModel = ref({ const formModel = ref({
keyword: '', keyword: '',
status: '', status: '',
datacenter_id: undefined as number | undefined,
}) })
const pagination = reactive({ const pagination = reactive({
@@ -110,7 +112,22 @@ const pagination = reactive({
}) })
// 表单项配置 // 表单项配置
const formItems = computed<FormItem[]>(() => searchFormConfig) const datacenterSelectOptions = ref<{ label: string; value: number }[]>([])
let datacenterSearchTimer: number | undefined
const formItems = computed<FormItem[]>(() =>
searchFormConfig.map((item) => {
if (item.field === 'datacenter_id') {
return {
...item,
options: datacenterSelectOptions.value,
allowSearch: true,
onSearch: handleDatacenterSearch,
}
}
return item
}),
)
// 表格列配置 // 表格列配置
const columns = computed(() => columnsConfig) const columns = computed(() => columnsConfig)
@@ -123,6 +140,35 @@ const editingFloor = ref<any>(null)
const formVisible = ref(false) const formVisible = ref(false)
const detailVisible = ref(false) const detailVisible = ref(false)
// 获取数据中心下拉选项
const loadDatacenterOptions = async (keyword?: string) => {
try {
const res: any = await fetchDatacenterList({ keyword })
if (res.code === 0) {
const list = res.details || []
datacenterSelectOptions.value = Array.isArray(list)
? list.map((d: any) => ({
label: d.name || d.code || String(d.id),
value: d.id,
}))
: []
}
} catch (error) {
console.error('获取数据中心列表失败:', error)
Message.error('获取数据中心列表失败')
datacenterSelectOptions.value = []
}
}
const handleDatacenterSearch = (keyword: string) => {
if (datacenterSearchTimer) {
window.clearTimeout(datacenterSearchTimer)
}
datacenterSearchTimer = window.setTimeout(() => {
loadDatacenterOptions(keyword?.trim() || undefined)
}, 300)
}
// 获取楼层列表 // 获取楼层列表
const fetchFloors = async () => { const fetchFloors = async () => {
loading.value = true loading.value = true
@@ -132,6 +178,7 @@ const fetchFloors = async () => {
page: pagination.current, page: pagination.current,
page_size: pagination.pageSize, page_size: pagination.pageSize,
keyword: formModel.value.keyword || undefined, keyword: formModel.value.keyword || undefined,
datacenter_id: formModel.value.datacenter_id ?? undefined,
status: formModel.value.status || undefined, status: formModel.value.status || undefined,
} }
@@ -165,6 +212,7 @@ const handleReset = () => {
formModel.value = { formModel.value = {
keyword: '', keyword: '',
status: '', status: '',
datacenter_id: undefined,
} }
pagination.current = 1 pagination.current = 1
fetchFloors() fetchFloors()
@@ -231,7 +279,10 @@ const handleFormSuccess = () => {
} }
// 初始化加载数据 // 初始化加载数据
onMounted(() => {
loadDatacenterOptions()
fetchFloors() fetchFloors()
})
</script> </script>
<script lang="ts"> <script lang="ts">

View File

@@ -103,10 +103,14 @@
<a-select <a-select
v-model="form.province_id" v-model="form.province_id"
placeholder="请选择省份" placeholder="请选择省份"
allow-search
:filter-option="false"
:loading="provinceLoading"
@change="handleProvinceChange" @change="handleProvinceChange"
@search="handleProvinceSearch"
> >
<a-option <a-option
v-for="item in provinces" v-for="item in provinceOptions"
:key="item.id" :key="item.id"
:value="item.id" :value="item.id"
> >
@@ -125,9 +129,13 @@
v-model="form.city_id" v-model="form.city_id"
placeholder="请选择城市" placeholder="请选择城市"
:disabled="!form.province_id" :disabled="!form.province_id"
allow-search
:filter-option="false"
:loading="cityLoading"
@search="handleCitySearch"
> >
<a-option <a-option
v-for="item in filteredCities" v-for="item in cityOptions"
:key="item.id" :key="item.id"
:value="item.id" :value="item.id"
> >
@@ -248,7 +256,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { ref, watch, computed } from 'vue' import { ref, watch, computed } from 'vue'
import { Message } from '@arco-design/web-vue' import { Message } from '@arco-design/web-vue'
import { createDatacenter, updateDatacenter } from '@/api/ops/datacenter' import { createDatacenter, updateDatacenter, fetchProvinceList, fetchCityList } from '@/api/ops/datacenter'
interface Datacenter { interface Datacenter {
id?: number id?: number
@@ -277,8 +285,7 @@ interface Datacenter {
interface Props { interface Props {
visible: boolean visible: boolean
datacenter: Datacenter | null datacenter: Datacenter | null
provinces: any[] provinces?: any[]
cities: any[]
} }
interface Emits { interface Emits {
@@ -319,15 +326,72 @@ const form = ref({
// 是否为编辑模式 // 是否为编辑模式
const isEdit = computed(() => !!props.datacenter?.id) const isEdit = computed(() => !!props.datacenter?.id)
// 根据选择的省份过滤城市列表 const provinceOptions = ref<any[]>([])
const filteredCities = computed(() => { const cityOptions = ref<any[]>([])
if (!form.value.province_id) return [] const provinceLoading = ref(false)
return props.cities.filter((city) => city.province_id === form.value.province_id) const cityLoading = ref(false)
}) const provinceKeyword = ref('')
const cityKeyword = ref('')
let provinceSearchTimer: ReturnType<typeof setTimeout> | null = null
let citySearchTimer: ReturnType<typeof setTimeout> | null = null
// 省份变化时清空城市选择 // 省份变化时清空城市选择
const handleProvinceChange = () => { const handleProvinceChange = (value?: number) => {
form.value.city_id = undefined form.value.city_id = undefined
cityOptions.value = []
cityKeyword.value = ''
if (value) {
fetchCitiesByProvince(value)
}
}
const fetchProvinces = async (keyword = '') => {
provinceLoading.value = true
try {
const res = await fetchProvinceList({ keyword: keyword || undefined })
provinceOptions.value = res.details || []
} catch (error) {
console.error('获取省份列表失败:', error)
provinceOptions.value = []
} finally {
provinceLoading.value = false
}
}
const fetchCitiesByProvince = async (provinceId: number, keyword = '') => {
cityLoading.value = true
try {
const res = await fetchCityList(provinceId, { keyword: keyword || undefined })
cityOptions.value = res.details || []
} catch (error) {
console.error('获取城市列表失败:', error)
cityOptions.value = []
} finally {
cityLoading.value = false
}
}
const handleProvinceSearch = (keyword: string) => {
provinceKeyword.value = keyword
if (provinceSearchTimer) {
clearTimeout(provinceSearchTimer)
}
provinceSearchTimer = setTimeout(() => {
if (!keyword.trim()) return
fetchProvinces(keyword.trim())
}, 300)
}
const handleCitySearch = (keyword: string) => {
cityKeyword.value = keyword
if (!form.value.province_id) return
if (citySearchTimer) {
clearTimeout(citySearchTimer)
}
citySearchTimer = setTimeout(() => {
if (!keyword.trim()) return
fetchCitiesByProvince(form.value.province_id as number, keyword.trim())
}, 300)
} }
// 监听对话框显示状态 // 监听对话框显示状态
@@ -335,6 +399,9 @@ watch(
() => props.visible, () => props.visible,
(newVal) => { (newVal) => {
if (newVal) { if (newVal) {
provinceOptions.value = props.provinces || []
provinceKeyword.value = ''
cityKeyword.value = ''
if (props.datacenter && isEdit.value) { if (props.datacenter && isEdit.value) {
// 编辑模式:填充表单 // 编辑模式:填充表单
form.value = { form.value = {
@@ -359,6 +426,10 @@ watch(
description: props.datacenter.description || '', description: props.datacenter.description || '',
remarks: props.datacenter.remarks || '', remarks: props.datacenter.remarks || '',
} }
fetchProvinces()
if (form.value.province_id) {
fetchCitiesByProvince(form.value.province_id)
}
} else { } else {
// 新建模式:重置表单 // 新建模式:重置表单
form.value = { form.value = {
@@ -383,6 +454,8 @@ watch(
description: '', description: '',
remarks: '', remarks: '',
} }
fetchProvinces()
cityOptions.value = []
} }
} }
} }

View File

@@ -16,6 +16,52 @@
@page-change="handlePageChange" @page-change="handlePageChange"
@refresh="handleRefresh" @refresh="handleRefresh"
> >
<template #form-items>
<a-col :span="8">
<a-form-item field="province_id" label="所属省份">
<a-select
v-model="formModel.province_id"
placeholder="请选择省份"
allow-search
allow-clear
:filter-option="false"
:loading="provinceSelectLoading"
@change="handleFilterProvinceChange"
@search="handleFilterProvinceSearch"
>
<a-option
v-for="item in provinceSelectOptions"
:key="item.id"
:value="item.id"
>
{{ item.name }}
</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item field="city_id" label="所属城市">
<a-select
v-model="formModel.city_id"
placeholder="请选择城市"
:disabled="!formModel.province_id"
allow-search
allow-clear
:filter-option="false"
:loading="citySelectLoading"
@search="handleFilterCitySearch"
>
<a-option
v-for="item in citySelectOptions"
:key="item.id"
:value="item.id"
>
{{ item.name }}
</a-option>
</a-select>
</a-form-item>
</a-col>
</template>
<template #toolbar-left> <template #toolbar-left>
<a-button type="primary" @click="handleCreate"> <a-button type="primary" @click="handleCreate">
<template #icon> <template #icon>
@@ -87,7 +133,6 @@
v-model:visible="formVisible" v-model:visible="formVisible"
:datacenter="editingDatacenter" :datacenter="editingDatacenter"
:provinces="provinces" :provinces="provinces"
:cities="cities"
@success="handleFormSuccess" @success="handleFormSuccess"
/> />
@@ -143,7 +188,12 @@ const pagination = reactive({
// 省份和城市列表 // 省份和城市列表
const provinces = ref<any[]>([]) const provinces = ref<any[]>([])
const cities = ref<any[]>([]) const provinceSelectOptions = ref<any[]>([])
const citySelectOptions = ref<any[]>([])
const provinceSelectLoading = ref(false)
const citySelectLoading = ref(false)
let filterProvinceSearchTimer: ReturnType<typeof setTimeout> | null = null
let filterCitySearchTimer: ReturnType<typeof setTimeout> | null = null
// 表单项配置 // 表单项配置
const formItems = computed<FormItem[]>(() => searchFormConfig) const formItems = computed<FormItem[]>(() => searchFormConfig)
@@ -160,42 +210,66 @@ const formVisible = ref(false)
const detailVisible = ref(false) const detailVisible = ref(false)
// 获取省份列表 // 获取省份列表
const fetchProvinces = async () => { const fetchProvinces = async (keyword = '') => {
provinceSelectLoading.value = true
try { try {
const res = await fetchProvinceList() const res = await fetchProvinceList({ keyword: keyword || undefined })
provinces.value = res.details || [] const list = res.details || []
// 更新表单配置中的省份选项 provinceSelectOptions.value = list
const provinceFormItem = formItems.value.find((item) => item.field === 'province_id') if (!keyword) {
if (provinceFormItem) { provinces.value = list
provinceFormItem.options = provinces.value.map((p) => ({
label: p.name,
value: p.id,
}))
} }
} catch (error) { } catch (error) {
console.error('获取省份列表失败:', error) console.error('获取省份列表失败:', error)
provinceSelectOptions.value = []
} finally {
provinceSelectLoading.value = false
} }
} }
// 获取城市列表 // 根据省份获取城市列表
const fetchCities = async () => { const fetchCitiesByProvince = async (provinceId: number, keyword = '') => {
citySelectLoading.value = true
try { try {
const res = await fetchCityList() const res = await fetchCityList(provinceId, { keyword: keyword || undefined })
console.log(res, '.res') citySelectOptions.value = res.details || []
cities.value = res.details || []
// 更新表单配置中的城市选项
const cityFormItem = formItems.value.find((item) => item.field === 'city_id')
if (cityFormItem) {
cityFormItem.options = cities.value.map((c) => ({
label: c.name,
value: c.id,
}))
}
} catch (error) { } catch (error) {
console.error('获取城市列表失败:', error) console.error('获取城市列表失败:', error)
citySelectOptions.value = []
} finally {
citySelectLoading.value = false
} }
} }
const handleFilterProvinceChange = (provinceId?: number) => {
formModel.value.city_id = ''
citySelectOptions.value = []
if (provinceId) {
fetchCitiesByProvince(provinceId)
}
}
const handleFilterProvinceSearch = (keyword: string) => {
if (filterProvinceSearchTimer) {
clearTimeout(filterProvinceSearchTimer)
}
filterProvinceSearchTimer = setTimeout(() => {
if (!keyword.trim()) return
fetchProvinces(keyword.trim())
}, 300)
}
const handleFilterCitySearch = (keyword: string) => {
if (!formModel.value.province_id) return
if (filterCitySearchTimer) {
clearTimeout(filterCitySearchTimer)
}
filterCitySearchTimer = setTimeout(() => {
if (!keyword.trim()) return
fetchCitiesByProvince(Number(formModel.value.province_id), keyword.trim())
}, 300)
}
// 获取数据中心列表 // 获取数据中心列表
const fetchDatacenters = async () => { const fetchDatacenters = async () => {
loading.value = true loading.value = true
@@ -243,6 +317,8 @@ const handleReset = () => {
province_id: '', province_id: '',
city_id: '', city_id: '',
} }
citySelectOptions.value = []
fetchProvinces()
pagination.current = 1 pagination.current = 1
fetchDatacenters() fetchDatacenters()
} }
@@ -310,7 +386,6 @@ const handleFormSuccess = () => {
// 初始化加载数据 // 初始化加载数据
onMounted(() => { onMounted(() => {
fetchProvinces() fetchProvinces()
fetchCities()
fetchDatacenters() fetchDatacenters()
}) })
</script> </script>

View File

@@ -75,6 +75,7 @@
placeholder="请选择所属楼层" placeholder="请选择所属楼层"
:loading="loadingFloors" :loading="loadingFloors"
allow-search allow-search
@search="handleFloorSearch"
> >
<a-option <a-option
v-for="item in floorList" v-for="item in floorList"
@@ -437,7 +438,7 @@ import { Message } from '@arco-design/web-vue'
import { createRack, updateRack } from '@/api/ops/rack' import { createRack, updateRack } from '@/api/ops/rack'
import { import {
fetchDatacenterList, fetchDatacenterList,
fetchFloorListForSelect, fetchRackListByDatacenter,
fetchSupplierList, fetchSupplierList,
} from '@/api/ops/rack' } from '@/api/ops/rack'
@@ -500,6 +501,7 @@ const submitting = ref(false)
const datacenterList = ref<any[]>([]) const datacenterList = ref<any[]>([])
const floorList = ref<any[]>([]) const floorList = ref<any[]>([])
const supplierList = ref<any[]>([]) const supplierList = ref<any[]>([])
let floorSearchTimer: number | undefined
// 表单数据 // 表单数据
const form = ref({ const form = ref({
@@ -557,13 +559,28 @@ const loadDatacenterList = async () => {
} }
} }
// 加载楼层列表 // 加载楼层列表(通过机柜下拉接口提取去重楼层)
const loadFloorList = async (datacenterId?: number) => { const loadFloorList = async (datacenterId?: number, keyword?: string) => {
if (!datacenterId) {
floorList.value = []
return
}
loadingFloors.value = true loadingFloors.value = true
try { try {
const res: any = await fetchFloorListForSelect(datacenterId) const res: any = await fetchRackListByDatacenter(datacenterId, { name: keyword })
if (res.code === 0) { if (res.code === 0) {
floorList.value = res.details || [] const list = res.details?.data ?? res.data ?? res.details ?? []
const rows = Array.isArray(list) ? list : []
const floorMap = new Map<number, { id: number; name: string }>()
rows.forEach((rack: any) => {
const floor = rack?.floor
if (!floor?.id || floorMap.has(floor.id)) return
floorMap.set(floor.id, {
id: floor.id,
name: floor.name || String(floor.id),
})
})
floorList.value = Array.from(floorMap.values())
} }
} catch (error) { } catch (error) {
console.error('获取楼层列表失败:', error) console.error('获取楼层列表失败:', error)
@@ -593,6 +610,16 @@ const handleDatacenterChange = async (value: number) => {
await loadFloorList(value) await loadFloorList(value)
} }
const handleFloorSearch = (keyword: string) => {
if (!form.value.datacenter_id) return
if (floorSearchTimer) {
window.clearTimeout(floorSearchTimer)
}
floorSearchTimer = window.setTimeout(() => {
loadFloorList(form.value.datacenter_id, keyword?.trim() || undefined)
}, 300)
}
// 监听对话框显示状态 // 监听对话框显示状态
watch( watch(
() => props.visible, () => props.visible,

View File

@@ -57,7 +57,7 @@
<!-- 使用率 --> <!-- 使用率 -->
<template #utilization_rate="{ record }"> <template #utilization_rate="{ record }">
<a-progress <a-progress
:percent="(record.utilization_rate || 0)" :percent="Math.min(Math.max((Number(record.utilization_rate) || 0) / 100, 0), 1)"
:size="'small'" :size="'small'"
:color=" :color="
(record.utilization_rate || 0) > 80 (record.utilization_rate || 0) > 80
@@ -102,7 +102,7 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref, reactive, computed } from 'vue' import { ref, reactive, computed, watch, onMounted } from 'vue'
import { Message, Modal } from '@arco-design/web-vue' import { Message, Modal } from '@arco-design/web-vue'
import { IconPlus } from '@arco-design/web-vue/es/icon' import { IconPlus } from '@arco-design/web-vue/es/icon'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
@@ -112,6 +112,8 @@ import { searchFormConfig } from './config/search-form'
import { columns as columnsConfig } from './config/columns' import { columns as columnsConfig } from './config/columns'
import { import {
fetchRackList, fetchRackList,
fetchDatacenterList,
fetchRackListByDatacenter,
deleteRack, deleteRack,
} from '@/api/ops/rack' } from '@/api/ops/rack'
import RackDetailDialog from './components/RackDetailDialog.vue' import RackDetailDialog from './components/RackDetailDialog.vue'
@@ -137,7 +139,33 @@ const pagination = reactive({
}) })
// 表单项配置 // 表单项配置
const formItems = computed<FormItem[]>(() => searchFormConfig) const datacenterSelectOptions = ref<{ label: string; value: number }[]>([])
const floorSelectOptions = ref<{ label: string; value: number }[]>([])
let datacenterSearchTimer: number | undefined
let floorSearchTimer: number | undefined
const formItems = computed<FormItem[]>(() =>
searchFormConfig.map((item) => {
if (item.field === 'datacenter_id') {
return {
...item,
options: datacenterSelectOptions.value,
allowSearch: true,
onSearch: handleDatacenterSearch,
}
}
if (item.field === 'floor_id') {
return {
...item,
options: floorSelectOptions.value,
allowSearch: true,
onSearch: handleFloorSearch,
disabled: !formModel.value.datacenter_id,
}
}
return item
}),
)
// 表格列配置 // 表格列配置
const columns = computed(() => columnsConfig) const columns = computed(() => columnsConfig)
@@ -150,6 +178,83 @@ const editingRack = ref<any>(null)
const formVisible = ref(false) const formVisible = ref(false)
const detailVisible = ref(false) const detailVisible = ref(false)
const loadDatacenterOptions = async (keyword?: string) => {
try {
const res: any = await fetchDatacenterList({ keyword })
if (res.code === 0) {
const list = res.details?.data ?? res.data ?? res.details ?? []
const rows = Array.isArray(list) ? list : []
datacenterSelectOptions.value = rows.map((d: any) => ({
label: d.name || d.code || String(d.id),
value: d.id,
}))
}
} catch (error) {
console.error('加载数据中心列表失败:', error)
Message.error('加载数据中心列表失败')
datacenterSelectOptions.value = []
}
}
const loadFloorOptions = async (datacenterId?: number, keyword?: string) => {
if (!datacenterId) {
floorSelectOptions.value = []
return
}
try {
const res: any = await fetchRackListByDatacenter(datacenterId, {
name: keyword,
})
if (res.code === 0) {
const list = res.details?.data ?? res.data ?? res.details ?? []
const rows = Array.isArray(list) ? list : []
const floorMap = new Map<number, { label: string; value: number }>()
rows.forEach((rack: any) => {
const floor = rack?.floor
if (!floor?.id || floorMap.has(floor.id)) return
floorMap.set(floor.id, {
label: floor.name || String(floor.id),
value: floor.id,
})
})
floorSelectOptions.value = Array.from(floorMap.values())
}
} catch (error) {
console.error('加载楼层列表失败:', error)
Message.error('加载楼层列表失败')
floorSelectOptions.value = []
}
}
const handleDatacenterSearch = (keyword: string) => {
if (datacenterSearchTimer) {
window.clearTimeout(datacenterSearchTimer)
}
datacenterSearchTimer = window.setTimeout(() => {
loadDatacenterOptions(keyword?.trim() || undefined)
}, 300)
}
const handleFloorSearch = (keyword: string) => {
if (!formModel.value.datacenter_id) return
if (floorSearchTimer) {
window.clearTimeout(floorSearchTimer)
}
floorSearchTimer = window.setTimeout(() => {
loadFloorOptions(formModel.value.datacenter_id, keyword?.trim() || undefined)
}, 300)
}
watch(
() => formModel.value.datacenter_id,
(newId, oldId) => {
if (newId !== oldId) {
formModel.value.floor_id = undefined
}
loadFloorOptions(newId)
},
)
// 获取机柜类型颜色 // 获取机柜类型颜色
const getRackTypeColor = (type?: string) => { const getRackTypeColor = (type?: string) => {
const colorMap: Record<string, string> = { const colorMap: Record<string, string> = {
@@ -207,16 +312,17 @@ const fetchRacks = async () => {
page: pagination.current, page: pagination.current,
page_size: pagination.pageSize, page_size: pagination.pageSize,
keyword: formModel.value.keyword || undefined, keyword: formModel.value.keyword || undefined,
datacenter_id: formModel.value.datacenter_id || undefined, datacenter_id: formModel.value.datacenter_id ?? undefined,
floor_id: formModel.value.floor_id || undefined, floor_id: formModel.value.floor_id ?? undefined,
rack_type: formModel.value.rack_type || undefined, rack_type: formModel.value.rack_type || undefined,
status: formModel.value.status || undefined, status: formModel.value.status || undefined,
} }
const res = await fetchRackList(params) const res = await fetchRackList(params)
tableData.value = res.data?.data || [] const payload = res?.data ?? res?.details ?? {}
pagination.total = res.data?.total || 0 tableData.value = Array.isArray(payload?.data) ? payload.data : []
pagination.total = payload?.total ?? 0
} catch (error) { } catch (error) {
console.error('获取机柜列表失败:', error) console.error('获取机柜列表失败:', error)
Message.error('获取机柜列表失败') Message.error('获取机柜列表失败')
@@ -317,7 +423,10 @@ const handleFormSuccess = () => {
} }
// 初始化加载数据 // 初始化加载数据
onMounted(() => {
loadDatacenterOptions()
fetchRacks() fetchRacks()
})
</script> </script>
<script lang="ts"> <script lang="ts">

View File

@@ -3,7 +3,7 @@
v-model:visible="dialogVisible" v-model:visible="dialogVisible"
title="分配U位" title="分配U位"
:width="600" :width="600"
@ok="handleOk" :on-before-ok="handleBeforeOk"
@cancel="handleCancel" @cancel="handleCancel"
> >
<a-form ref="formRef" :model="formData" :rules="rules" layout="vertical"> <a-form ref="formRef" :model="formData" :rules="rules" layout="vertical">
@@ -31,19 +31,23 @@
<a-select <a-select
v-model="formData.asset_id" v-model="formData.asset_id"
placeholder="请选择设备" placeholder="请选择设备"
:loading="assetListLoading"
allow-search allow-search
:filter-option="false"
@search="handleAssetSearch"
@change="handleAssetChange" @change="handleAssetChange"
> >
<a-option <a-option
v-for="asset in assetList" v-for="asset in assetList"
:key="asset.id" :key="asset.id"
:value="asset.id" :value="asset.id"
:label="asset.asset_name" >
/> {{ asset.asset_name }} ({{ asset.asset_code }})
</a-option>
</a-select> </a-select>
</a-form-item> </a-form-item>
<a-form-item field="asset_type" label="设备类型" required> <a-form-item field="asset_type" label="设备类型">
<a-input <a-input
v-model="formData.asset_type" v-model="formData.asset_type"
placeholder="设备类型(选择设备后自动填入)" placeholder="设备类型(选择设备后自动填入)"
@@ -51,6 +55,14 @@
/> />
</a-form-item> </a-form-item>
<a-form-item field="asset_type_code" label="分类标识">
<a-input
v-model="formData.asset_type_code"
placeholder="分类标识(选择设备后自动填入)"
disabled
/>
</a-form-item>
<a-form-item field="power_consumption" label="功耗 (W)"> <a-form-item field="power_consumption" label="功耗 (W)">
<a-input-number <a-input-number
v-model="formData.power_consumption" v-model="formData.power_consumption"
@@ -82,12 +94,15 @@ export default defineComponent({
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue' import { ref, reactive, computed, watch } from 'vue'
import { Message } from '@arco-design/web-vue' import { Message } from '@arco-design/web-vue'
import { fetchAssetAll } from '@/api/ops/asset'
interface Asset { interface Asset {
id: number id: number
asset_code: string asset_code: string
asset_name: string asset_name: string
asset_type: string asset_type_name: string
asset_type_code: string
category_id?: number
power_consumption?: number power_consumption?: number
} }
@@ -111,46 +126,99 @@ const dialogVisible = computed({
}) })
const formRef = ref() const formRef = ref()
const assetListLoading = ref(false)
let assetSearchTimer: number | undefined
const formData = reactive({ const formData = reactive({
start_unit: 1, start_unit: 1,
occupied_units: 1, occupied_units: 1,
asset_id: undefined as number | undefined, asset_id: undefined as number | undefined,
asset_code: '', asset_code: '',
asset_name: '', asset_name: '',
asset_type: '', asset_type: '', // 展示 category.name
asset_type_code: '', // 提交 category.code
power_consumption: undefined as number | undefined, power_consumption: undefined as number | undefined,
description: '', description: '',
}) })
const assetList = ref<Asset[]>([ const assetList = ref<Asset[]>([])
// 模拟设备列表实际应从API获取
{ id: 1, asset_code: 'SRV001', asset_name: '服务器-001', asset_type: '服务器设备', power_consumption: 500 },
{ id: 2, asset_code: 'SRV002', asset_name: '服务器-002', asset_type: '服务器设备', power_consumption: 600 },
{ id: 3, asset_code: 'NET001', asset_name: '交换机-001', asset_type: '网络设备', power_consumption: 200 },
{ id: 4, asset_code: 'STO001', asset_name: '存储-001', asset_type: '存储设备', power_consumption: 400 },
])
const rules = { const rules = {
start_unit: [{ required: true, message: '请输入起始U位' }], start_unit: [{ required: true, message: '请输入起始U位' }],
occupied_units: [{ required: true, message: '请输入占用U位数量' }], occupied_units: [{ required: true, message: '请输入占用U位数量' }],
asset_type: [{ required: true, message: '设备类型不能为空' }], asset_id: [{ required: true, message: '请选择设备' }],
} }
const handleAssetChange = (value: number) => { const syncAssetFields = (value: number | string | undefined) => {
const asset = assetList.value.find((a) => a.id === value) if (value === undefined || value === null || value === '') return
const asset = assetList.value.find((a) => String(a.id) === String(value))
if (asset) { if (asset) {
formData.asset_id = asset.id
formData.asset_code = asset.asset_code formData.asset_code = asset.asset_code
formData.asset_name = asset.asset_name formData.asset_name = asset.asset_name
formData.asset_type = asset.asset_type formData.asset_type = asset.asset_type_name
formData.asset_type_code = asset.asset_type_code
formData.power_consumption = asset.power_consumption formData.power_consumption = asset.power_consumption
} }
} }
const handleOk = async () => { const handleAssetChange = (value: number | string) => {
syncAssetFields(value)
}
const loadAssetList = async (keyword?: string) => {
assetListLoading.value = true
try { try {
const valid = await formRef.value?.validate() const res: any = await fetchAssetAll({ keyword: keyword || undefined })
if (!valid) { if (res.code === 0) {
// 发送分配请求 const rowsCandidate =
res?.data?.data ??
res?.details?.data ??
res?.data ??
res?.details ??
[]
const rows = Array.isArray(rowsCandidate) ? rowsCandidate : []
assetList.value = rows.map((item: any) => ({
id: item.id,
asset_code: item.asset_code || '',
asset_name: item.asset_name || '',
asset_type_name: item?.category?.name || item?.category_name || item?.asset_type_name || '',
asset_type_code: item?.category?.code ?? item?.category_code ?? item?.asset_type ?? '',
category_id: item?.category_id,
power_consumption: item.power_consumption,
}))
} else {
Message.error(res.message || '获取设备列表失败')
assetList.value = []
}
} catch (error) {
console.error('获取设备列表失败:', error)
Message.error('获取设备列表失败')
assetList.value = []
} finally {
assetListLoading.value = false
}
}
const handleAssetSearch = (keyword: string) => {
if (assetSearchTimer) {
window.clearTimeout(assetSearchTimer)
}
assetSearchTimer = window.setTimeout(() => {
loadAssetList(keyword?.trim() || undefined)
}, 300)
}
const handleBeforeOk = async () => {
try {
await formRef.value?.validate()
} catch (error) {
console.error('分配失败:', error)
return false
}
// 防止 change 事件未触发时字段未回写
syncAssetFields(formData.asset_id)
const { allocateUnit } = await import('@/api/ops/unit') const { allocateUnit } = await import('@/api/ops/unit')
const params = { const params = {
rack_id: props.rackId, rack_id: props.rackId,
@@ -159,21 +227,20 @@ const handleOk = async () => {
asset_id: formData.asset_id, asset_id: formData.asset_id,
asset_code: formData.asset_code, asset_code: formData.asset_code,
asset_name: formData.asset_name || `未命名设备-${formData.start_unit}`, asset_name: formData.asset_name || `未命名设备-${formData.start_unit}`,
asset_type: formData.asset_type, // 后端字段 asset_type 使用分类标识category.code
asset_type: formData.asset_type_code,
power_consumption: formData.power_consumption, power_consumption: formData.power_consumption,
description: formData.description, description: formData.description,
} }
const res = await allocateUnit(params) const res = await allocateUnit(params)
if (res.code === 0) { if (res.code === 0) {
Message.success('分配成功') Message.success('分配成功')
emit('success') emit('success')
handleCancel() return true
} else { } else {
Message.error(res.message || '分配失败') Message.error(res.message || '分配失败')
} return false
}
} catch (error) {
console.error('分配失败:', error)
} }
} }
@@ -189,6 +256,7 @@ const resetForm = () => {
formData.asset_code = '' formData.asset_code = ''
formData.asset_name = '' formData.asset_name = ''
formData.asset_type = '' formData.asset_type = ''
formData.asset_type_code = ''
formData.power_consumption = undefined formData.power_consumption = undefined
formData.description = '' formData.description = ''
formRef.value?.resetFields() formRef.value?.resetFields()
@@ -197,9 +265,18 @@ const resetForm = () => {
watch( watch(
() => props.visible, () => props.visible,
(val) => { (val) => {
if (!val) { if (val) {
loadAssetList()
} else {
resetForm() resetForm()
} }
} }
) )
watch(
() => formData.asset_id,
(value) => {
syncAssetFields(value)
}
)
</script> </script>

View File

@@ -227,6 +227,7 @@ import {
releaseUnit, releaseUnit,
updateUnitStatus, updateUnitStatus,
} from '@/api/ops/unit' } from '@/api/ops/unit'
import { normalizeUnitList } from './utils/unitFallback'
import AllocateUnitDialog from './components/AllocateUnitDialog.vue' import AllocateUnitDialog from './components/AllocateUnitDialog.vue'
import ReserveUnitDialog from './components/ReserveUnitDialog.vue' import ReserveUnitDialog from './components/ReserveUnitDialog.vue'
@@ -334,8 +335,9 @@ const fetchUnits = async () => {
const res = await fetchUnitList(rackId.value) const res = await fetchUnitList(rackId.value)
if (res.code === 0) { if (res.code === 0) {
rackInfo.value = res.data?.rack || {} const payload = res?.details ?? res?.data ?? {}
unitList.value = res.data?.units || [] rackInfo.value = payload?.rack || {}
unitList.value = normalizeUnitList(payload?.units, rackInfo.value?.height)
} else { } else {
Message.error(res.message || '获取U位列表失败') Message.error(res.message || '获取U位列表失败')
} }

View File

@@ -6,11 +6,53 @@
<icon-storage /> 选择机柜 <icon-storage /> 选择机柜
</template> </template>
<a-row :gutter="16"> <a-row :gutter="16">
<a-col :span="8">
<a-select
v-model="selectedDatacenterId"
placeholder="请选择数据中心"
:loading="datacenterListLoading"
allow-search
@search="handleDatacenterSearch"
@change="handleDatacenterChange"
style="width: 100%"
>
<a-option
v-for="datacenter in datacenterList"
:key="datacenter.value"
:value="datacenter.value"
>
{{ datacenter.label }}
</a-option>
</a-select>
</a-col>
<a-col :span="8">
<a-select
v-model="selectedFloorId"
placeholder="请选择楼层"
:loading="floorListLoading"
:disabled="!selectedDatacenterId"
allow-search
@search="handleFloorSearch"
@change="handleFloorChange"
style="width: 100%"
>
<a-option
v-for="floor in floorList"
:key="floor.value"
:value="floor.value"
>
{{ floor.label }}
</a-option>
</a-select>
</a-col>
<a-col :span="8"> <a-col :span="8">
<a-select <a-select
v-model="selectedRackId" v-model="selectedRackId"
placeholder="请选择机柜" placeholder="请选择机柜"
:loading="rackListLoading" :loading="rackListLoading"
:disabled="!selectedFloorId"
allow-search
@search="handleRackSearch"
@change="handleRackChange" @change="handleRackChange"
style="width: 100%" style="width: 100%"
> >
@@ -179,17 +221,31 @@ import {
releaseUnit, releaseUnit,
updateUnitStatus, updateUnitStatus,
} from '@/api/ops/unit' } from '@/api/ops/unit'
import { fetchRackList } from '@/api/ops/rack' import {
fetchDatacenterList,
fetchRackListByFloor,
} from '@/api/ops/rack'
import { fetchFloorListByDatacenter } from '@/api/ops/floor'
import { normalizeUnitList } from './utils/unitFallback'
import AllocateUnitDialog from './components/AllocateUnitDialog.vue' import AllocateUnitDialog from './components/AllocateUnitDialog.vue'
import ReserveUnitDialog from './components/ReserveUnitDialog.vue' import ReserveUnitDialog from './components/ReserveUnitDialog.vue'
// 状态管理 // 状态管理
const loading = ref(false) const loading = ref(false)
const rackListLoading = ref(false) const rackListLoading = ref(false)
const datacenterListLoading = ref(false)
const floorListLoading = ref(false)
const selectedDatacenterId = ref<number | undefined>(undefined)
const selectedFloorId = ref<number | undefined>(undefined)
const selectedRackId = ref<number | undefined>(undefined) const selectedRackId = ref<number | undefined>(undefined)
const rackInfo = ref<any>({}) const rackInfo = ref<any>({})
const datacenterList = ref<{ label: string; value: number }[]>([])
const floorList = ref<{ label: string; value: number }[]>([])
const rackList = ref<any[]>([]) const rackList = ref<any[]>([])
const unitList = ref<any[]>([]) const unitList = ref<any[]>([])
let datacenterSearchTimer: number | undefined
let floorSearchTimer: number | undefined
let rackSearchTimer: number | undefined
// 对话框可见性 // 对话框可见性
const allocateVisible = ref(false) const allocateVisible = ref(false)
@@ -213,6 +269,16 @@ const usagePercentage = computed(() => {
return Math.round((usedUnits.value / rackInfo.value.height) * 100) return Math.round((usedUnits.value / rackInfo.value.height) * 100)
}) })
const extractList = (res: any): any[] => {
const candidate =
res?.data?.data ??
res?.details?.data ??
res?.data ??
res?.details ??
[]
return Array.isArray(candidate) ? candidate : []
}
// 获取U位状态颜色 // 获取U位状态颜色
const getUnitStatusColor = (status?: string) => { const getUnitStatusColor = (status?: string) => {
const colorMap: Record<string, string> = { const colorMap: Record<string, string> = {
@@ -236,17 +302,16 @@ const getUnitStatusText = (status?: string) => {
} }
// 获取机柜列表 // 获取机柜列表
const fetchRacks = async () => { const fetchRacks = async (keyword?: string) => {
if (!selectedFloorId.value) {
rackList.value = []
return
}
rackListLoading.value = true rackListLoading.value = true
try { try {
const res = await fetchRackList({ const res: any = await fetchRackListByFloor(selectedFloorId.value, { name: keyword })
page: 1, rackList.value = extractList(res)
page_size: 1000,
status: 'in_use', // 只获取使用中的机柜
})
rackList.value = res.details?.data || []
} catch (error) { } catch (error) {
console.error('获取机柜列表失败:', error) console.error('获取机柜列表失败:', error)
Message.error('获取机柜列表失败') Message.error('获取机柜列表失败')
@@ -256,6 +321,101 @@ const fetchRacks = async () => {
} }
} }
const fetchDatacenters = async (keyword?: string) => {
datacenterListLoading.value = true
try {
const res: any = await fetchDatacenterList({ keyword })
const rows = extractList(res)
datacenterList.value = rows.map((d: any) => ({
label: d.name || d.code || String(d.id),
value: d.id,
}))
} catch (error) {
console.error('获取数据中心列表失败:', error)
Message.error('获取数据中心列表失败')
datacenterList.value = []
} finally {
datacenterListLoading.value = false
}
}
const fetchFloors = async (keyword?: string) => {
if (!selectedDatacenterId.value) {
floorList.value = []
return
}
floorListLoading.value = true
try {
const res: any = await fetchFloorListByDatacenter(selectedDatacenterId.value, {
name: keyword || undefined,
})
const rows = extractList(res)
floorList.value = rows.map((floor: any) => ({
label: floor.name || floor.code || String(floor.id),
value: floor.id,
}))
} catch (error) {
console.error('获取楼层列表失败:', error)
Message.error('获取楼层列表失败')
floorList.value = []
} finally {
floorListLoading.value = false
}
}
const handleDatacenterSearch = (keyword: string) => {
if (datacenterSearchTimer) {
window.clearTimeout(datacenterSearchTimer)
}
datacenterSearchTimer = window.setTimeout(() => {
fetchDatacenters(keyword?.trim() || undefined)
}, 300)
}
const handleFloorSearch = (keyword: string) => {
if (!selectedDatacenterId.value) return
if (floorSearchTimer) {
window.clearTimeout(floorSearchTimer)
}
floorSearchTimer = window.setTimeout(() => {
fetchFloors(keyword?.trim() || undefined)
}, 300)
}
const handleRackSearch = (keyword: string) => {
if (!selectedFloorId.value) return
if (rackSearchTimer) {
window.clearTimeout(rackSearchTimer)
}
rackSearchTimer = window.setTimeout(() => {
fetchRacks(keyword?.trim() || undefined)
}, 300)
}
const handleDatacenterChange = async (datacenterId?: number | string) => {
if (datacenterId !== undefined && datacenterId !== null && datacenterId !== '') {
selectedDatacenterId.value = Number(datacenterId)
}
selectedFloorId.value = undefined
selectedRackId.value = undefined
rackInfo.value = {}
rackList.value = []
floorList.value = []
unitList.value = []
await fetchFloors()
}
const handleFloorChange = async (floorId?: number | string) => {
if (floorId !== undefined && floorId !== null && floorId !== '') {
selectedFloorId.value = Number(floorId)
}
selectedRackId.value = undefined
rackInfo.value = {}
rackList.value = []
unitList.value = []
await fetchRacks()
}
// 机柜变化 // 机柜变化
const handleRackChange = (rackId: number) => { const handleRackChange = (rackId: number) => {
if (rackId) { if (rackId) {
@@ -278,8 +438,9 @@ const fetchUnits = async (rackId?: number) => {
const res = await fetchUnitList(targetRackId) const res = await fetchUnitList(targetRackId)
if (res.code === 0) { if (res.code === 0) {
rackInfo.value = res.data?.rack || {} const payload = res?.details ?? res?.data ?? {}
unitList.value = res.data?.units || [] rackInfo.value = payload?.rack || {}
unitList.value = normalizeUnitList(payload?.units, rackInfo.value?.height)
} else { } else {
Message.error(res.message || '获取U位列表失败') Message.error(res.message || '获取U位列表失败')
} }
@@ -453,7 +614,7 @@ const handleCancelReservation = async (record: any) => {
// 初始化 // 初始化
onMounted(() => { onMounted(() => {
fetchRacks() fetchDatacenters()
}) })
</script> </script>

View File

@@ -0,0 +1,22 @@
export const normalizeUnitList = (units: any, rackHeight?: number) => {
if (Array.isArray(units) && units.length > 0) {
return units
}
const height = Number(rackHeight) || 0
if (height <= 0) {
return []
}
return Array.from({ length: height }, (_, idx) => ({
id: `virtual-${height - idx}`,
unit_number: height - idx,
status: 'available',
asset_name: '',
asset_code: '',
asset_type: '',
occupied_units: 1,
reserved_for: '',
power_consumption: 0,
}))
}

View File

@@ -1,7 +1,7 @@
<template> <template>
<div class="container"> <div class="container">
<search-table <search-table
:form-model="formModel" v-model:form-model="formModel"
:form-items="formItems" :form-items="formItems"
:data="tableData" :data="tableData"
:columns="columns" :columns="columns"
@@ -50,84 +50,118 @@
{{ rowIndex + 1 + (pagination.current - 1) * pagination.pageSize }} {{ rowIndex + 1 + (pagination.current - 1) * pagination.pageSize }}
</template> </template>
<!-- 操作 --> <!-- 操作禁用规则见 ticket-list-operation-buttons.md已解决/已关闭仅保留详情与评论 -->
<template #actions="{ record }"> <template #actions="{ record }">
<!-- 详情按钮所有状态都显示 -->
<a-button type="text" size="small" @click="handleDetail(record)"> <a-button type="text" size="small" @click="handleDetail(record)">
详情 详情
</a-button> </a-button>
<!-- 接单按钮待接单状态 --> <template v-if="!isTicketResolvedOrClosed(record.status)">
<a-button <a-tooltip
v-if="record.status === 'pending'" v-if="record.status === 'pending'"
:content="reasonAcceptTicket(record, me)"
:disabled="canAcceptTicket(record, me)"
>
<a-button
type="text" type="text"
size="small" size="small"
:disabled="!canAcceptTicket(record, me)"
@click="handleAccept(record)" @click="handleAccept(record)"
> >
接单 接单
</a-button> </a-button>
</a-tooltip>
<!-- 转交按钮已接单处理中已挂起状态 --> <a-tooltip
v-if="record.status !== 'closed' && record.status !== 'cancelled'"
:content="reasonTransferTicket(record, me)"
:disabled="canTransferTicket(record, me)"
>
<a-button <a-button
v-if="['accepted', 'processing', 'suspended'].includes(record.status)"
type="text" type="text"
size="small" size="small"
:disabled="!canTransferTicket(record, me)"
@click="handleTransfer(record)" @click="handleTransfer(record)"
> >
转交 转交
</a-button> </a-button>
</a-tooltip>
<!-- 挂起按钮已接单处理中状态 --> <a-tooltip
v-if="record.status !== 'suspended' && record.status !== 'closed' && record.status !== 'cancelled'"
:content="reasonSuspendTicket(record, me)"
:disabled="canSuspendTicket(record, me)"
>
<a-button <a-button
v-if="['accepted', 'processing'].includes(record.status)"
type="text" type="text"
size="small" size="small"
:disabled="!canSuspendTicket(record, me)"
@click="handleSuspend(record)" @click="handleSuspend(record)"
> >
挂起 挂起
</a-button> </a-button>
</a-tooltip>
<!-- 重启按钮已挂起状态 --> <a-tooltip
<a-button
v-if="record.status === 'suspended'" v-if="record.status === 'suspended'"
:content="reasonResumeTicket(record, me)"
:disabled="canResumeTicket(record, me)"
>
<a-button
type="text" type="text"
size="small" size="small"
:disabled="!canResumeTicket(record, me)"
@click="handleResume(record)" @click="handleResume(record)"
> >
重启 重启
</a-button> </a-button>
</a-tooltip>
<!-- 解决按钮已接单处理中状态 --> <a-tooltip
v-if="record.status !== 'closed' && record.status !== 'cancelled'"
:content="reasonResolveTicket(record, me)"
:disabled="canResolveTicket(record, me)"
>
<a-button <a-button
v-if="['accepted', 'processing'].includes(record.status)"
type="text" type="text"
size="small" size="small"
:disabled="!canResolveTicket(record, me)"
@click="handleResolve(record)" @click="handleResolve(record)"
> >
解决 解决
</a-button> </a-button>
</a-tooltip>
<!-- 关闭按钮待接单已接单处理中已解决状态 --> <a-tooltip
v-if="record.status !== 'closed' && record.status !== 'cancelled'"
:content="reasonCloseTicket(record, me)"
:disabled="canCloseTicket(record, me)"
>
<a-button <a-button
v-if="['pending', 'accepted', 'processing', 'resolved'].includes(record.status)"
type="text" type="text"
size="small" size="small"
:disabled="!canCloseTicket(record, me)"
@click="handleClose(record)" @click="handleClose(record)"
> >
关闭 关闭
</a-button> </a-button>
</a-tooltip>
<!-- 编辑按钮非已关闭已撤回状态 --> <a-tooltip
:content="reasonEditTicket(record, me)"
:disabled="canEditTicket(record, me)"
>
<a-button <a-button
v-if="!['closed', 'cancelled'].includes(record.status)"
type="text" type="text"
size="small" size="small"
:disabled="!canEditTicket(record, me)"
@click="handleEdit(record)" @click="handleEdit(record)"
> >
编辑 编辑
</a-button> </a-button>
</a-tooltip>
</template>
<!-- 评论按钮所有状态都显示 -->
<a-button type="text" size="small" @click="handleComment(record)"> <a-button type="text" size="small" @click="handleComment(record)">
评论 评论
</a-button> </a-button>
@@ -186,6 +220,24 @@ import type { FormItem } from '@/components/search-form/types'
import SearchTable from '@/components/search-table/index.vue' import SearchTable from '@/components/search-table/index.vue'
import { searchFormConfig } from './config/search-form' import { searchFormConfig } from './config/search-form'
import { columns as columnsConfig } from './config/columns' import { columns as columnsConfig } from './config/columns'
import {
getFeedbackCurrentUserId,
canAcceptTicket,
canTransferTicket,
canSuspendTicket,
canResumeTicket,
canResolveTicket,
canCloseTicket,
canEditTicket,
reasonAcceptTicket,
reasonTransferTicket,
reasonSuspendTicket,
reasonResumeTicket,
reasonResolveTicket,
reasonCloseTicket,
reasonEditTicket,
isTicketResolvedOrClosed,
} from '../utils/ticketActionPermissions'
import { import {
fetchFeedbackTickets, fetchFeedbackTickets,
acceptFeedbackTicket, acceptFeedbackTicket,
@@ -229,6 +281,9 @@ const priorityMap: Record<string, { text: string; color: string }> = {
urgent: { text: '紧急', color: 'red' }, urgent: { text: '紧急', color: 'red' },
} }
/** 当前账号 ID与列表行 creator_id / assignee_id 同口径(登录 details.user_id */
const me = computed(() => getFeedbackCurrentUserId())
// 状态管理 // 状态管理
const loading = ref(false) const loading = ref(false)
const tableData = ref<any[]>([]) const tableData = ref<any[]>([])
@@ -338,7 +393,10 @@ const handleCreate = () => {
// 编辑工单 // 编辑工单
const handleEdit = (record: any) => { const handleEdit = (record: any) => {
console.log('编辑工单:', record) if (!canEditTicket(record, me.value)) {
Message.warning(reasonEditTicket(record, me.value) || '当前不可编辑')
return
}
editingTicket.value = record editingTicket.value = record
formVisible.value = true formVisible.value = true
} }

View File

@@ -107,7 +107,7 @@ const handleOk = async () => {
const res = await commentFeedbackTicket(props.ticket.id, { const res = await commentFeedbackTicket(props.ticket.id, {
content: form.value.content, content: form.value.content,
}) })
if (res.code === 200) { if (res.code === 0) {
Message.success('评论成功') Message.success('评论成功')
emit('success') emit('success')
} else { } else {

View File

@@ -107,7 +107,7 @@ const handleOk = async () => {
const res = await resolveFeedbackTicket(props.ticket.id, { const res = await resolveFeedbackTicket(props.ticket.id, {
content: form.value.content, content: form.value.content,
}) })
if (res.code === 200) { if (res.code === 0) {
Message.success('工单已解决') Message.success('工单已解决')
emit('success') emit('success')
} else { } else {

View File

@@ -174,7 +174,7 @@ const handleOk = async () => {
assignee_name: form.value.assignee_name, assignee_name: form.value.assignee_name,
reason: form.value.reason, reason: form.value.reason,
}) })
if (res.code === 200) { if (res.code === 0) {
Message.success('转交成功') Message.success('转交成功')
emit('success') emit('success')
} else { } else {

View File

@@ -46,4 +46,9 @@ export const searchFormConfig: FormItem[] = [
{ label: '紧急', value: 'urgent' }, { label: '紧急', value: 'urgent' },
], ],
}, },
{
field: 'dateRange',
label: '创建时间',
type: 'dateRange',
},
] ]

View File

@@ -3,7 +3,7 @@
<a-tabs v-model:active-key="activeTab" @change="handleTabChange"> <a-tabs v-model:active-key="activeTab" @change="handleTabChange">
<a-tab-pane key="pending" title="待处理"> <a-tab-pane key="pending" title="待处理">
<search-table <search-table
:form-model="formModel" v-model:form-model="formModel"
:form-items="formItems" :form-items="formItems"
:data="tableData" :data="tableData"
:columns="pendingColumns" :columns="pendingColumns"
@@ -17,15 +17,6 @@
@page-change="handlePageChange" @page-change="handlePageChange"
@refresh="handleRefresh" @refresh="handleRefresh"
> >
<template #toolbar-left>
<a-button type="primary" @click="handleCreate">
<template #icon>
<icon-plus />
</template>
新建工单
</a-button>
</template>
<!-- 工单类型 --> <!-- 工单类型 -->
<template #type="{ record }"> <template #type="{ record }">
<a-tag :color="typeMap[record.type]?.color || 'gray'"> <a-tag :color="typeMap[record.type]?.color || 'gray'">
@@ -52,33 +43,119 @@
{{ rowIndex + 1 + (pagination.current - 1) * pagination.pageSize }} {{ rowIndex + 1 + (pagination.current - 1) * pagination.pageSize }}
</template> </template>
<!-- 操作 --> <!-- 操作待处理已解决/已关闭仅详情与评论 -->
<template #actions="{ record }"> <template #actions="{ record }">
<a-button type="text" size="small" @click="handleDetail(record)"> <a-button type="text" size="small" @click="handleDetail(record)">
详情 详情
</a-button> </a-button>
<a-button type="text" size="small" @click="handleTransfer(record)">
<template v-if="!isTicketResolvedOrClosed(record.status)">
<a-tooltip
v-if="record.status === 'pending'"
:content="reasonAcceptTicket(record, me)"
:disabled="canAcceptTicket(record, me)"
>
<a-button
type="text"
size="small"
:disabled="!canAcceptTicket(record, me)"
@click="handleAccept(record)"
>
接单
</a-button>
</a-tooltip>
<a-tooltip
v-if="record.status !== 'closed' && record.status !== 'cancelled'"
:content="reasonTransferTicket(record, me)"
:disabled="canTransferTicket(record, me)"
>
<a-button
type="text"
size="small"
:disabled="!canTransferTicket(record, me)"
@click="handleTransfer(record)"
>
转交 转交
</a-button> </a-button>
<a-button type="text" size="small" @click="handleSuspend(record)"> </a-tooltip>
<a-tooltip
v-if="record.status !== 'suspended' && record.status !== 'closed' && record.status !== 'cancelled'"
:content="reasonSuspendTicket(record, me)"
:disabled="canSuspendTicket(record, me)"
>
<a-button
type="text"
size="small"
:disabled="!canSuspendTicket(record, me)"
@click="handleSuspend(record)"
>
挂起 挂起
</a-button> </a-button>
<a-button v-if="record.status === 'suspended'" type="text" size="small" @click="handleResume(record)"> </a-tooltip>
<a-tooltip
v-if="record.status === 'suspended'"
:content="reasonResumeTicket(record, me)"
:disabled="canResumeTicket(record, me)"
>
<a-button
type="text"
size="small"
:disabled="!canResumeTicket(record, me)"
@click="handleResume(record)"
>
重启 重启
</a-button> </a-button>
<a-button type="text" size="small" @click="handleResolve(record)"> </a-tooltip>
<a-tooltip
v-if="record.status !== 'closed' && record.status !== 'cancelled'"
:content="reasonResolveTicket(record, me)"
:disabled="canResolveTicket(record, me)"
>
<a-button
type="text"
size="small"
:disabled="!canResolveTicket(record, me)"
@click="handleResolve(record)"
>
解决 解决
</a-button> </a-button>
<a-button type="text" size="small" @click="handleClose(record)"> </a-tooltip>
<a-tooltip
v-if="record.status !== 'closed' && record.status !== 'cancelled'"
:content="reasonCloseTicket(record, me)"
:disabled="canCloseTicket(record, me)"
>
<a-button
type="text"
size="small"
:disabled="!canCloseTicket(record, me)"
@click="handleClose(record)"
>
关闭 关闭
</a-button> </a-button>
</a-tooltip>
</template>
<a-button
v-if="isTicketResolvedOrClosed(record.status)"
type="text"
size="small"
@click="handleComment(record)"
>
评论
</a-button>
</template> </template>
</search-table> </search-table>
</a-tab-pane> </a-tab-pane>
<a-tab-pane key="my-created" title="我创建的"> <a-tab-pane key="my-created" title="我创建的">
<search-table <search-table
:form-model="formModel" v-model:form-model="formModel"
:form-items="formItems" :form-items="formItems"
:data="tableData" :data="tableData"
:columns="myCreatedColumns" :columns="myCreatedColumns"
@@ -92,15 +169,6 @@
@page-change="handlePageChange" @page-change="handlePageChange"
@refresh="handleRefresh" @refresh="handleRefresh"
> >
<template #toolbar-left>
<a-button type="primary" @click="handleCreate">
<template #icon>
<icon-plus />
</template>
新建工单
</a-button>
</template>
<!-- 工单类型 --> <!-- 工单类型 -->
<template #type="{ record }"> <template #type="{ record }">
<a-tag :color="typeMap[record.type]?.color || 'gray'"> <a-tag :color="typeMap[record.type]?.color || 'gray'">
@@ -127,26 +195,78 @@
{{ rowIndex + 1 + (pagination.current - 1) * pagination.pageSize }} {{ rowIndex + 1 + (pagination.current - 1) * pagination.pageSize }}
</template> </template>
<!-- 操作 --> <!-- 操作我创建的已解决/已关闭仅详情与评论 -->
<template #actions="{ record }"> <template #actions="{ record }">
<a-button type="text" size="small" @click="handleDetail(record)"> <a-button type="text" size="small" @click="handleDetail(record)">
详情 详情
</a-button> </a-button>
<a-button v-if="!['resolved', 'closed'].includes(record.status)" type="text" size="small" @click="handleEdit(record)">
<template v-if="!isTicketResolvedOrClosed(record.status)">
<a-tooltip
:content="reasonEditTicket(record, me)"
:disabled="canEditTicket(record, me)"
>
<a-button
type="text"
size="small"
:disabled="!canEditTicket(record, me)"
@click="handleEdit(record)"
>
编辑 编辑
</a-button> </a-button>
<a-button type="text" size="small" @click="handleCancel(record)"> </a-tooltip>
<a-tooltip
v-if="record.status !== 'closed' && record.status !== 'cancelled'"
:content="reasonCancelTicket(record, me)"
:disabled="canCancelTicket(record, me)"
>
<a-button
type="text"
size="small"
:disabled="!canCancelTicket(record, me)"
@click="handleCancel(record)"
>
撤回 撤回
</a-button> </a-button>
<a-button v-if="record.status === 'resolved'" type="text" size="small" @click="handleComment(record)"> </a-tooltip>
<a-button type="text" size="small" @click="handleComment(record)">
评论 评论
</a-button> </a-button>
<a-button type="text" size="small" @click="handleClose(record)"> <a-tooltip
v-if="record.status !== 'closed' && record.status !== 'cancelled'"
:content="reasonCloseTicket(record, me)"
:disabled="canCloseTicket(record, me)"
>
<a-button
type="text"
size="small"
:disabled="!canCloseTicket(record, me)"
@click="handleClose(record)"
>
关闭 关闭
</a-button> </a-button>
<a-button type="text" size="small" status="danger" @click="handleDelete(record)"> </a-tooltip>
<a-tooltip :content="reasonDeleteTicket(record, me)" :disabled="canDeleteTicket(record, me)">
<a-button
type="text"
size="small"
status="danger"
:disabled="!canDeleteTicket(record, me)"
@click="handleDelete(record)"
>
删除 删除
</a-button> </a-button>
</a-tooltip>
</template>
<a-button
v-else
type="text"
size="small"
@click="handleComment(record)"
>
评论
</a-button>
</template> </template>
</search-table> </search-table>
</a-tab-pane> </a-tab-pane>
@@ -192,14 +312,14 @@
<script lang="ts" setup> <script lang="ts" setup>
import { ref, reactive, computed } from 'vue' import { ref, reactive, computed } from 'vue'
import { Message, Modal } from '@arco-design/web-vue' import { Message, Modal } from '@arco-design/web-vue'
import { IconPlus } from '@arco-design/web-vue/es/icon'
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface' import type { TableColumnData } from '@arco-design/web-vue/es/table/interface'
import type { FormItem } from '@/components/search-form/types' import type { FormItem } from '@/components/search-form/types'
import SearchTable from '@/components/search-table/index.vue' import SearchTable from '@/components/search-table/index.vue'
import { searchFormConfig } from './config/search-form' import { searchFormConfig } from './config/search-form'
import dayjs from 'dayjs'
import { import {
fetchFeedbackTickets, fetchFeedbackTickets,
transferFeedbackTicket, acceptFeedbackTicket,
suspendFeedbackTicket, suspendFeedbackTicket,
resumeFeedbackTicket, resumeFeedbackTicket,
resolveFeedbackTicket, resolveFeedbackTicket,
@@ -207,6 +327,28 @@ import {
closeFeedbackTicket, closeFeedbackTicket,
deleteFeedbackTicket, deleteFeedbackTicket,
} from '@/api/ops/feedbackTicket' } from '@/api/ops/feedbackTicket'
import {
getFeedbackCurrentUserId,
canAcceptTicket,
canTransferTicket,
canSuspendTicket,
canResumeTicket,
canResolveTicket,
canCloseTicket,
canEditTicket,
canCancelTicket,
canDeleteTicket,
reasonAcceptTicket,
reasonTransferTicket,
reasonSuspendTicket,
reasonResumeTicket,
reasonResolveTicket,
reasonCloseTicket,
reasonCancelTicket,
reasonDeleteTicket,
reasonEditTicket,
isTicketResolvedOrClosed,
} from '../utils/ticketActionPermissions'
import TicketDetailDialog from '../components/TicketDetailDialog.vue' import TicketDetailDialog from '../components/TicketDetailDialog.vue'
import TransferDialog from '../components/TransferDialog.vue' import TransferDialog from '../components/TransferDialog.vue'
import SuspendDialog from '../components/SuspendDialog.vue' import SuspendDialog from '../components/SuspendDialog.vue'
@@ -243,6 +385,18 @@ const priorityMap: Record<string, { text: string; color: string }> = {
// 当前激活的选项卡 // 当前激活的选项卡
const activeTab = ref<'pending' | 'my-created'>('pending') const activeTab = ref<'pending' | 'my-created'>('pending')
/** 待处理 Tab已接单且未关闭、处理人为我默认多状态见 GET /tickets 的 status string[] 说明) */
const PENDING_TAB_DEFAULT_STATUS = ['accepted', 'processing', 'suspended', 'resolved'] as const
function formatTicketQueryDateTime(v: unknown): string | undefined {
if (v == null || v === '') return undefined
const d = dayjs(v as string | Date | dayjs.Dayjs)
return d.isValid() ? d.format('YYYY-MM-DD HH:mm:ss') : undefined
}
/** 当前账号 ID登录 details.user_id与工单 creator_id / assignee_id 比较) */
const me = computed(() => getFeedbackCurrentUserId())
// 状态管理 // 状态管理
const loading = ref(false) const loading = ref(false)
const tableData = ref<any[]>([]) const tableData = ref<any[]>([])
@@ -251,6 +405,7 @@ const formModel = ref({
type: '', type: '',
priority: '', priority: '',
status: '', status: '',
dateRange: [] as unknown[],
}) })
const pagination = reactive({ const pagination = reactive({
@@ -366,27 +521,43 @@ const suspendVisible = ref(false)
const resolveVisible = ref(false) const resolveVisible = ref(false)
const commentVisible = ref(false) const commentVisible = ref(false)
// 获取工单列表 // 获取工单列表GET /Feedback/v1/tickets
const fetchTickets = async () => { const fetchTickets = async () => {
loading.value = true loading.value = true
try { try {
// 两个 Tab 均依赖当前用户 idassignee_id / creator_id缺失则不请求以免数据范围错误
if (me.value <= 0) {
tableData.value = []
pagination.total = 0
return
}
const keyword = typeof formModel.value.keyword === 'string' ? formModel.value.keyword.trim() : ''
const params: any = { const params: any = {
page: pagination.current, page: pagination.current,
page_size: pagination.pageSize, page_size: pagination.pageSize,
keyword: formModel.value.keyword || undefined, keyword: keyword || undefined,
type: formModel.value.type || undefined, type: formModel.value.type || undefined,
priority: formModel.value.priority || undefined, priority: formModel.value.priority || undefined,
status: formModel.value.status || undefined, status: formModel.value.status || undefined,
} }
if (formModel.value.dateRange && formModel.value.dateRange.length === 2) {
const [start, end] = formModel.value.dateRange
const st = formatTicketQueryDateTime(start)
const et = formatTicketQueryDateTime(end)
if (st) params.start_time = st
if (et) params.end_time = et
}
if (activeTab.value === 'pending') { if (activeTab.value === 'pending') {
// 待处理工单:分配给当前用户且状态为 pending/accepted/processing/suspended params.assignee_id = me.value
params.status = 'accepted,processing,suspended' if (!formModel.value.status) {
params.status = [...PENDING_TAB_DEFAULT_STATUS]
}
} else { } else {
// 我创建的工单:由当前用户创建 params.creator_id = me.value
// 这里需要从用户信息中获取 creator_id
// params.creator_id = userStore.userId
} }
const res = await fetchFeedbackTickets(params) const res = await fetchFeedbackTickets(params)
@@ -424,6 +595,7 @@ const handleReset = () => {
type: '', type: '',
priority: '', priority: '',
status: '', status: '',
dateRange: [],
} }
pagination.current = 1 pagination.current = 1
fetchTickets() fetchTickets()
@@ -441,12 +613,6 @@ const handleRefresh = () => {
Message.success('数据已刷新') Message.success('数据已刷新')
} }
// 新建工单
const handleCreate = () => {
// TODO: 打开新建工单对话框
Message.info('新建工单功能待实现')
}
// 详情 // 详情
const handleDetail = (record: any) => { const handleDetail = (record: any) => {
console.log('查看详情:', record) console.log('查看详情:', record)
@@ -454,9 +620,33 @@ const handleDetail = (record: any) => {
detailVisible.value = true detailVisible.value = true
} }
// 接单(待处理列表若含待接单工单)
const handleAccept = async (record: any) => {
try {
Modal.confirm({
title: '确认接单',
content: `确认接单工单 ${record.ticket_no} 吗?`,
onOk: async () => {
const res = await acceptFeedbackTicket(record.id)
if (res.code === 0) {
Message.success('接单成功')
fetchTickets()
} else {
Message.error(res.message || '接单失败')
}
},
})
} catch (error) {
console.error('接单失败:', error)
}
}
// 编辑 // 编辑
const handleEdit = (record: any) => { const handleEdit = (record: any) => {
console.log('编辑工单:', record) if (!canEditTicket(record, me.value)) {
Message.warning(reasonEditTicket(record, me.value) || '当前不可编辑')
return
}
// TODO: 打开编辑对话框 // TODO: 打开编辑对话框
Message.info('编辑工单功能待实现') Message.info('编辑工单功能待实现')
} }

View File

@@ -0,0 +1,167 @@
import useUserStore from '@/store/modules/user'
import SafeStorage, { AppStorageKey } from '@/utils/safeStorage'
/** 列表行与后端 FeedbackTicket 对齐:含 status / creator_id / assignee_id */
export interface FeedbackTicketRow {
status?: string
creator_id?: number
assignee_id?: number
}
function numId(v: unknown): number {
if (v === null || v === undefined || v === '') return 0
const n = Number(v)
return Number.isFinite(n) ? n : 0
}
/**
* 当前登录用户 ID与 JWT / GetUserInfo 一致)。
* 取自登录接口 details写入 Pinia `user.userInfo` 与 SafeStorage USER_INFO含 user_id
*/
export function getFeedbackCurrentUserId(): number {
const userStore = useUserStore()
let payload = userStore.$state.userInfo as Record<string, unknown> | undefined | null
if (!payload || typeof payload !== 'object') {
payload = SafeStorage.get(AppStorageKey.USER_INFO) as Record<string, unknown> | undefined | null
}
if (payload && typeof payload === 'object') {
return numId(payload.user_id ?? payload.id)
}
return 0
}
function assigneeId(row: FeedbackTicketRow): number {
return numId(row.assignee_id)
}
function creatorId(row: FeedbackTicketRow): number {
return numId(row.creator_id)
}
function isClosedOrCancelled(status: string | undefined): boolean {
return status === 'closed' || status === 'cancelled'
}
/** 已解决或已关闭:列表操作仅保留「详情」「评论」 */
export function isTicketResolvedOrClosed(status: string | undefined): boolean {
return status === 'resolved' || status === 'closed'
}
export function canAcceptTicket(row: FeedbackTicketRow, me: number): boolean {
if (row.status !== 'pending') return false
const aid = assigneeId(row)
if (aid > 0) return me > 0 && aid === me
return true
}
export function canTransferTicket(row: FeedbackTicketRow, me: number): boolean {
if (isClosedOrCancelled(row.status)) return false
const aid = assigneeId(row)
return me > 0 && aid > 0 && aid === me
}
export function canCancelTicket(row: FeedbackTicketRow, me: number): boolean {
if (isClosedOrCancelled(row.status)) return false
return me > 0 && creatorId(row) === me
}
export function canSuspendTicket(row: FeedbackTicketRow, me: number): boolean {
if (isClosedOrCancelled(row.status)) return false
if (row.status === 'suspended') return false
const aid = assigneeId(row)
return me > 0 && aid > 0 && aid === me
}
export function canResumeTicket(row: FeedbackTicketRow, me: number): boolean {
if (row.status !== 'suspended') return false
const aid = assigneeId(row)
return me > 0 && aid > 0 && aid === me
}
export function canResolveTicket(row: FeedbackTicketRow, me: number): boolean {
if (isClosedOrCancelled(row.status)) return false
const aid = assigneeId(row)
return me > 0 && aid > 0 && aid === me
}
export function canCloseTicket(row: FeedbackTicketRow, me: number): boolean {
if (row.status === 'closed') return false
if (row.status === 'cancelled') return false
if (me <= 0) return false
return creatorId(row) === me || assigneeId(row) === me
}
/** 仅创建人、且待接单pending时可编辑产品约定 */
export function canEditTicket(row: FeedbackTicketRow, me: number): boolean {
if (isClosedOrCancelled(row.status)) return false
if (row.status !== 'pending') return false
if (me <= 0) return false
return creatorId(row) === me
}
export function reasonEditTicket(row: FeedbackTicketRow, me: number): string {
if (isClosedOrCancelled(row.status)) return '工单已关闭或撤回,不允许修改'
if (row.status !== 'pending') return '仅待接单状态下可由创建人编辑'
if (me <= 0) return '无法识别当前用户'
if (creatorId(row) !== me) return '只有创建人可在待接单时编辑工单'
return ''
}
export function canDeleteTicket(row: FeedbackTicketRow, me: number): boolean {
return me > 0 && creatorId(row) === me
}
/** 禁用时的原因文案(与后端错误信息对齐,便于用户理解) */
export function reasonAcceptTicket(row: FeedbackTicketRow, me: number): string {
if (row.status !== 'pending') return '工单状态不是待接单,无法接单'
const aid = assigneeId(row)
if (aid > 0 && aid !== me) return '该工单已指定其他处理人'
return ''
}
export function reasonTransferTicket(row: FeedbackTicketRow, me: number): string {
if (isClosedOrCancelled(row.status)) return '工单已关闭或撤回,无法转交'
const aid = assigneeId(row)
if (aid <= 0 || aid !== me) return '只有当前处理人可以转交工单'
return ''
}
export function reasonSuspendTicket(row: FeedbackTicketRow, me: number): string {
if (isClosedOrCancelled(row.status)) return '工单已关闭或撤回,无法挂起'
if (row.status === 'suspended') return '工单已经是挂起状态'
const aid = assigneeId(row)
if (aid <= 0 || aid !== me) return '只有处理人可以挂起工单'
return ''
}
export function reasonResumeTicket(row: FeedbackTicketRow, me: number): string {
if (row.status !== 'suspended') return '工单不是挂起状态,无法重启'
const aid = assigneeId(row)
if (aid <= 0 || aid !== me) return '只有处理人可以重启工单'
return ''
}
export function reasonResolveTicket(row: FeedbackTicketRow, me: number): string {
if (isClosedOrCancelled(row.status)) return '工单已关闭或撤回'
const aid = assigneeId(row)
if (aid <= 0 || aid !== me) return '只有处理人可以解决工单'
return ''
}
export function reasonCloseTicket(row: FeedbackTicketRow, me: number): string {
if (row.status === 'closed') return '工单已关闭'
if (row.status === 'cancelled') return '工单已撤回,无法关闭'
if (creatorId(row) !== me && assigneeId(row) !== me) return '只有创建人或处理人可以关闭工单'
return ''
}
export function reasonCancelTicket(row: FeedbackTicketRow, me: number): string {
if (isClosedOrCancelled(row.status)) return '工单已关闭或撤回'
if (creatorId(row) !== me) return '只有创建人可以撤回工单'
return ''
}
export function reasonDeleteTicket(row: FeedbackTicketRow, me: number): string {
if (creatorId(row) !== me) return '只有创建人可以删除工单'
return ''
}