fix
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -14,6 +14,7 @@ dist-ssr
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
docs/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
|
||||
@@ -86,6 +86,11 @@ export const fetchAssetList = (data?: AssetListParams) => {
|
||||
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) => {
|
||||
return request.get(`/Assets/v1/asset/detail/${id}`);
|
||||
|
||||
@@ -38,14 +38,14 @@ export const deleteDatacenter = (id: number) => {
|
||||
};
|
||||
|
||||
/** 获取省份列表(用于下拉选择) */
|
||||
export const fetchProvinceList = () => {
|
||||
return request.get("/Assets/v1/province/all");
|
||||
};
|
||||
export const fetchProvinceList = (params?: { keyword?: string }) => {
|
||||
return request.get('/Assets/v1/province/all', { params })
|
||||
}
|
||||
|
||||
/** 获取城市列表(用于下拉选择) */
|
||||
export const fetchCityList = () => {
|
||||
return request.get("/Assets/v1/city/all");
|
||||
};
|
||||
export const fetchCityList = (provinceId: number, params?: { keyword?: string }) => {
|
||||
return request.get(`/Assets/v1/city/province/${provinceId}`, { params })
|
||||
}
|
||||
|
||||
/** 根据城市获取数据中心列表 */
|
||||
export const fetchDatacenterByCity = (cityId: number) => {
|
||||
|
||||
@@ -1,25 +1,91 @@
|
||||
import { request } from "@/api/request";
|
||||
import { request } from '@/api/request'
|
||||
|
||||
/** 获取 工单列表 */
|
||||
export const fetchFeedbackTickets = (data?: {
|
||||
page?: number,
|
||||
page_size?: number,
|
||||
size?: number,
|
||||
keyword?: string,
|
||||
type?: string,
|
||||
priority?: string,
|
||||
status?: string,
|
||||
creator_id?: number,
|
||||
assignee_id?: number
|
||||
}) => {
|
||||
// 兼容 size 参数,转换为 page_size
|
||||
const params: any = data ? { ...data } : {};
|
||||
if (params.size !== undefined && params.page_size === undefined) {
|
||||
params.page_size = params.size;
|
||||
delete params.size;
|
||||
/**
|
||||
* 后端要求 status 为字符串数组;统一为 string[](单选、逗号分隔写法也转成多项)。
|
||||
*/
|
||||
function normalizeTicketListStatusParam(params: Record<string, any>) {
|
||||
const raw = params.status
|
||||
if (raw === undefined || raw === null) return
|
||||
let arr: string[]
|
||||
if (Array.isArray(raw)) {
|
||||
arr = raw.map((s) => String(s).trim()).filter(Boolean)
|
||||
} else {
|
||||
const s = String(raw).trim()
|
||||
if (!s) {
|
||||
delete params.status
|
||||
return
|
||||
}
|
||||
arr = s.includes(',') ? s.split(',').map((x) => x.trim()).filter(Boolean) : [s]
|
||||
}
|
||||
return request.get("/Feedback/v1/tickets", params ? { params } : undefined);
|
||||
};
|
||||
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)
|
||||
}
|
||||
|
||||
/** 创建 工单 */
|
||||
export const createFeedbackTicket = (data: any) => request.post("/Feedback/v1/tickets", data);
|
||||
|
||||
@@ -16,6 +16,14 @@ export const fetchFloorDetail = (id: number) => {
|
||||
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) => {
|
||||
return request.post("/Assets/v1/floor/create", data);
|
||||
@@ -32,6 +40,9 @@ export const deleteFloor = (id: number) => {
|
||||
};
|
||||
|
||||
/** 获取数据中心列表(用于下拉选择) */
|
||||
export const fetchDatacenterList = () => {
|
||||
return request.get("/Assets/v1/datacenter/all");
|
||||
export const fetchDatacenterList = (params?: { keyword?: string; name?: string }) => {
|
||||
const normalizedParams = params?.keyword
|
||||
? { ...params, name: params.name ?? params.keyword }
|
||||
: params
|
||||
return request.get("/Assets/v1/datacenter/all", { params: normalizedParams });
|
||||
};
|
||||
|
||||
@@ -23,6 +23,14 @@ export const fetchRackListByDatacenter = (
|
||||
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) => {
|
||||
return request.get(`/Assets/v1/rack/detail/${id}`);
|
||||
@@ -49,11 +57,17 @@ export const fetchSupplierList = () => {
|
||||
};
|
||||
|
||||
/** 获取数据中心列表(用于下拉选择) */
|
||||
export const fetchDatacenterList = () => {
|
||||
return request.get("/Assets/v1/datacenter/all");
|
||||
export const fetchDatacenterList = (params?: { keyword?: string; name?: string }) => {
|
||||
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) => {
|
||||
return request.get("/Assets/v1/floor/all", { params: { datacenter_id: datacenterId } });
|
||||
export const fetchFloorListForSelect = (params?: { datacenter_id?: number; keyword?: string; name?: string }) => {
|
||||
const normalizedParams = params?.keyword
|
||||
? { ...params, name: params.name ?? params.keyword }
|
||||
: params
|
||||
return request.get("/Assets/v1/floor/all", { params: normalizedParams });
|
||||
};
|
||||
|
||||
@@ -24,6 +24,8 @@
|
||||
:options="item.options"
|
||||
:placeholder="item.placeholder || '请选择'"
|
||||
:disabled="item.disabled"
|
||||
:allow-search="item.allowSearch"
|
||||
@search="handleSelectSearch(item, $event)"
|
||||
allow-clear
|
||||
/>
|
||||
<!-- 日期范围选择器 -->
|
||||
@@ -134,6 +136,10 @@ const handleSearch = () => {
|
||||
const handleReset = () => {
|
||||
emit('reset')
|
||||
}
|
||||
|
||||
const handleSelectSearch = (item: FormItem, keyword: string) => {
|
||||
item.onSearch?.(keyword)
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
|
||||
@@ -9,4 +9,8 @@ export interface FormItem {
|
||||
options?: SelectOptionData[]
|
||||
/** 仅对 select 生效 */
|
||||
disabled?: boolean
|
||||
/** 仅对 select 生效 */
|
||||
allowSearch?: boolean
|
||||
/** 仅对 select 生效 */
|
||||
onSearch?: (value: string) => void
|
||||
}
|
||||
|
||||
@@ -7,6 +7,13 @@ export const searchFormConfig: FormItem[] = [
|
||||
type: 'input',
|
||||
placeholder: '请输入楼层名称或编码',
|
||||
},
|
||||
{
|
||||
field: 'datacenter_id',
|
||||
label: '数据中心',
|
||||
type: 'select',
|
||||
placeholder: '请选择数据中心',
|
||||
options: [],
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
label: '状态',
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
</template>
|
||||
|
||||
<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 { IconPlus } from '@arco-design/web-vue/es/icon'
|
||||
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 {
|
||||
fetchFloorList,
|
||||
fetchDatacenterList,
|
||||
deleteFloor,
|
||||
} from '@/api/ops/floor'
|
||||
import FloorDetailDialog from './components/FloorDetailDialog.vue'
|
||||
@@ -101,6 +102,7 @@ const tableData = ref<any[]>([])
|
||||
const formModel = ref({
|
||||
keyword: '',
|
||||
status: '',
|
||||
datacenter_id: undefined as number | undefined,
|
||||
})
|
||||
|
||||
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)
|
||||
@@ -123,6 +140,35 @@ const editingFloor = ref<any>(null)
|
||||
const formVisible = 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 () => {
|
||||
loading.value = true
|
||||
@@ -132,6 +178,7 @@ const fetchFloors = async () => {
|
||||
page: pagination.current,
|
||||
page_size: pagination.pageSize,
|
||||
keyword: formModel.value.keyword || undefined,
|
||||
datacenter_id: formModel.value.datacenter_id ?? undefined,
|
||||
status: formModel.value.status || undefined,
|
||||
}
|
||||
|
||||
@@ -165,6 +212,7 @@ const handleReset = () => {
|
||||
formModel.value = {
|
||||
keyword: '',
|
||||
status: '',
|
||||
datacenter_id: undefined,
|
||||
}
|
||||
pagination.current = 1
|
||||
fetchFloors()
|
||||
@@ -231,7 +279,10 @@ const handleFormSuccess = () => {
|
||||
}
|
||||
|
||||
// 初始化加载数据
|
||||
fetchFloors()
|
||||
onMounted(() => {
|
||||
loadDatacenterOptions()
|
||||
fetchFloors()
|
||||
})
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
|
||||
@@ -103,10 +103,14 @@
|
||||
<a-select
|
||||
v-model="form.province_id"
|
||||
placeholder="请选择省份"
|
||||
allow-search
|
||||
:filter-option="false"
|
||||
:loading="provinceLoading"
|
||||
@change="handleProvinceChange"
|
||||
@search="handleProvinceSearch"
|
||||
>
|
||||
<a-option
|
||||
v-for="item in provinces"
|
||||
v-for="item in provinceOptions"
|
||||
:key="item.id"
|
||||
:value="item.id"
|
||||
>
|
||||
@@ -125,9 +129,13 @@
|
||||
v-model="form.city_id"
|
||||
placeholder="请选择城市"
|
||||
:disabled="!form.province_id"
|
||||
allow-search
|
||||
:filter-option="false"
|
||||
:loading="cityLoading"
|
||||
@search="handleCitySearch"
|
||||
>
|
||||
<a-option
|
||||
v-for="item in filteredCities"
|
||||
v-for="item in cityOptions"
|
||||
:key="item.id"
|
||||
:value="item.id"
|
||||
>
|
||||
@@ -248,7 +256,7 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch, computed } from '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 {
|
||||
id?: number
|
||||
@@ -277,8 +285,7 @@ interface Datacenter {
|
||||
interface Props {
|
||||
visible: boolean
|
||||
datacenter: Datacenter | null
|
||||
provinces: any[]
|
||||
cities: any[]
|
||||
provinces?: any[]
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
@@ -319,15 +326,72 @@ const form = ref({
|
||||
// 是否为编辑模式
|
||||
const isEdit = computed(() => !!props.datacenter?.id)
|
||||
|
||||
// 根据选择的省份过滤城市列表
|
||||
const filteredCities = computed(() => {
|
||||
if (!form.value.province_id) return []
|
||||
return props.cities.filter((city) => city.province_id === form.value.province_id)
|
||||
})
|
||||
const provinceOptions = ref<any[]>([])
|
||||
const cityOptions = ref<any[]>([])
|
||||
const provinceLoading = ref(false)
|
||||
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
|
||||
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,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
provinceOptions.value = props.provinces || []
|
||||
provinceKeyword.value = ''
|
||||
cityKeyword.value = ''
|
||||
if (props.datacenter && isEdit.value) {
|
||||
// 编辑模式:填充表单
|
||||
form.value = {
|
||||
@@ -359,6 +426,10 @@ watch(
|
||||
description: props.datacenter.description || '',
|
||||
remarks: props.datacenter.remarks || '',
|
||||
}
|
||||
fetchProvinces()
|
||||
if (form.value.province_id) {
|
||||
fetchCitiesByProvince(form.value.province_id)
|
||||
}
|
||||
} else {
|
||||
// 新建模式:重置表单
|
||||
form.value = {
|
||||
@@ -383,6 +454,8 @@ watch(
|
||||
description: '',
|
||||
remarks: '',
|
||||
}
|
||||
fetchProvinces()
|
||||
cityOptions.value = []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,52 @@
|
||||
@page-change="handlePageChange"
|
||||
@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>
|
||||
<a-button type="primary" @click="handleCreate">
|
||||
<template #icon>
|
||||
@@ -87,7 +133,6 @@
|
||||
v-model:visible="formVisible"
|
||||
:datacenter="editingDatacenter"
|
||||
:provinces="provinces"
|
||||
:cities="cities"
|
||||
@success="handleFormSuccess"
|
||||
/>
|
||||
|
||||
@@ -143,7 +188,12 @@ const pagination = reactive({
|
||||
|
||||
// 省份和城市列表
|
||||
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)
|
||||
@@ -160,42 +210,66 @@ const formVisible = ref(false)
|
||||
const detailVisible = ref(false)
|
||||
|
||||
// 获取省份列表
|
||||
const fetchProvinces = async () => {
|
||||
const fetchProvinces = async (keyword = '') => {
|
||||
provinceSelectLoading.value = true
|
||||
try {
|
||||
const res = await fetchProvinceList()
|
||||
provinces.value = res.details || []
|
||||
// 更新表单配置中的省份选项
|
||||
const provinceFormItem = formItems.value.find((item) => item.field === 'province_id')
|
||||
if (provinceFormItem) {
|
||||
provinceFormItem.options = provinces.value.map((p) => ({
|
||||
label: p.name,
|
||||
value: p.id,
|
||||
}))
|
||||
const res = await fetchProvinceList({ keyword: keyword || undefined })
|
||||
const list = res.details || []
|
||||
provinceSelectOptions.value = list
|
||||
if (!keyword) {
|
||||
provinces.value = list
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取省份列表失败:', error)
|
||||
provinceSelectOptions.value = []
|
||||
} finally {
|
||||
provinceSelectLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取城市列表
|
||||
const fetchCities = async () => {
|
||||
// 根据省份获取城市列表
|
||||
const fetchCitiesByProvince = async (provinceId: number, keyword = '') => {
|
||||
citySelectLoading.value = true
|
||||
try {
|
||||
const res = await fetchCityList()
|
||||
console.log(res, '.res')
|
||||
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,
|
||||
}))
|
||||
}
|
||||
const res = await fetchCityList(provinceId, { keyword: keyword || undefined })
|
||||
citySelectOptions.value = res.details || []
|
||||
} catch (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 () => {
|
||||
loading.value = true
|
||||
@@ -243,6 +317,8 @@ const handleReset = () => {
|
||||
province_id: '',
|
||||
city_id: '',
|
||||
}
|
||||
citySelectOptions.value = []
|
||||
fetchProvinces()
|
||||
pagination.current = 1
|
||||
fetchDatacenters()
|
||||
}
|
||||
@@ -310,7 +386,6 @@ const handleFormSuccess = () => {
|
||||
// 初始化加载数据
|
||||
onMounted(() => {
|
||||
fetchProvinces()
|
||||
fetchCities()
|
||||
fetchDatacenters()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
placeholder="请选择所属楼层"
|
||||
:loading="loadingFloors"
|
||||
allow-search
|
||||
@search="handleFloorSearch"
|
||||
>
|
||||
<a-option
|
||||
v-for="item in floorList"
|
||||
@@ -437,7 +438,7 @@ import { Message } from '@arco-design/web-vue'
|
||||
import { createRack, updateRack } from '@/api/ops/rack'
|
||||
import {
|
||||
fetchDatacenterList,
|
||||
fetchFloorListForSelect,
|
||||
fetchRackListByDatacenter,
|
||||
fetchSupplierList,
|
||||
} from '@/api/ops/rack'
|
||||
|
||||
@@ -500,6 +501,7 @@ const submitting = ref(false)
|
||||
const datacenterList = ref<any[]>([])
|
||||
const floorList = ref<any[]>([])
|
||||
const supplierList = ref<any[]>([])
|
||||
let floorSearchTimer: number | undefined
|
||||
|
||||
// 表单数据
|
||||
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
|
||||
try {
|
||||
const res: any = await fetchFloorListForSelect(datacenterId)
|
||||
const res: any = await fetchRackListByDatacenter(datacenterId, { name: keyword })
|
||||
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) {
|
||||
console.error('获取楼层列表失败:', error)
|
||||
@@ -593,6 +610,16 @@ const handleDatacenterChange = async (value: number) => {
|
||||
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(
|
||||
() => props.visible,
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
<!-- 使用率 -->
|
||||
<template #utilization_rate="{ record }">
|
||||
<a-progress
|
||||
:percent="(record.utilization_rate || 0)"
|
||||
:percent="Math.min(Math.max((Number(record.utilization_rate) || 0) / 100, 0), 1)"
|
||||
:size="'small'"
|
||||
:color="
|
||||
(record.utilization_rate || 0) > 80
|
||||
@@ -102,7 +102,7 @@
|
||||
</template>
|
||||
|
||||
<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 { IconPlus } from '@arco-design/web-vue/es/icon'
|
||||
import { useRouter } from 'vue-router'
|
||||
@@ -112,6 +112,8 @@ import { searchFormConfig } from './config/search-form'
|
||||
import { columns as columnsConfig } from './config/columns'
|
||||
import {
|
||||
fetchRackList,
|
||||
fetchDatacenterList,
|
||||
fetchRackListByDatacenter,
|
||||
deleteRack,
|
||||
} from '@/api/ops/rack'
|
||||
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)
|
||||
@@ -150,6 +178,83 @@ const editingRack = ref<any>(null)
|
||||
const formVisible = 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 colorMap: Record<string, string> = {
|
||||
@@ -207,16 +312,17 @@ const fetchRacks = async () => {
|
||||
page: pagination.current,
|
||||
page_size: pagination.pageSize,
|
||||
keyword: formModel.value.keyword || undefined,
|
||||
datacenter_id: formModel.value.datacenter_id || undefined,
|
||||
floor_id: formModel.value.floor_id || undefined,
|
||||
datacenter_id: formModel.value.datacenter_id ?? undefined,
|
||||
floor_id: formModel.value.floor_id ?? undefined,
|
||||
rack_type: formModel.value.rack_type || undefined,
|
||||
status: formModel.value.status || undefined,
|
||||
}
|
||||
|
||||
const res = await fetchRackList(params)
|
||||
|
||||
tableData.value = res.data?.data || []
|
||||
pagination.total = res.data?.total || 0
|
||||
|
||||
const payload = res?.data ?? res?.details ?? {}
|
||||
tableData.value = Array.isArray(payload?.data) ? payload.data : []
|
||||
pagination.total = payload?.total ?? 0
|
||||
} catch (error) {
|
||||
console.error('获取机柜列表失败:', error)
|
||||
Message.error('获取机柜列表失败')
|
||||
@@ -317,7 +423,10 @@ const handleFormSuccess = () => {
|
||||
}
|
||||
|
||||
// 初始化加载数据
|
||||
fetchRacks()
|
||||
onMounted(() => {
|
||||
loadDatacenterOptions()
|
||||
fetchRacks()
|
||||
})
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
v-model:visible="dialogVisible"
|
||||
title="分配U位"
|
||||
:width="600"
|
||||
@ok="handleOk"
|
||||
:on-before-ok="handleBeforeOk"
|
||||
@cancel="handleCancel"
|
||||
>
|
||||
<a-form ref="formRef" :model="formData" :rules="rules" layout="vertical">
|
||||
@@ -31,19 +31,23 @@
|
||||
<a-select
|
||||
v-model="formData.asset_id"
|
||||
placeholder="请选择设备"
|
||||
:loading="assetListLoading"
|
||||
allow-search
|
||||
:filter-option="false"
|
||||
@search="handleAssetSearch"
|
||||
@change="handleAssetChange"
|
||||
>
|
||||
<a-option
|
||||
v-for="asset in assetList"
|
||||
:key="asset.id"
|
||||
:value="asset.id"
|
||||
:label="asset.asset_name"
|
||||
/>
|
||||
>
|
||||
{{ asset.asset_name }} ({{ asset.asset_code }})
|
||||
</a-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item field="asset_type" label="设备类型" required>
|
||||
<a-form-item field="asset_type" label="设备类型">
|
||||
<a-input
|
||||
v-model="formData.asset_type"
|
||||
placeholder="设备类型(选择设备后自动填入)"
|
||||
@@ -51,6 +55,14 @@
|
||||
/>
|
||||
</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-input-number
|
||||
v-model="formData.power_consumption"
|
||||
@@ -82,12 +94,15 @@ export default defineComponent({
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, watch } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { fetchAssetAll } from '@/api/ops/asset'
|
||||
|
||||
interface Asset {
|
||||
id: number
|
||||
asset_code: string
|
||||
asset_name: string
|
||||
asset_type: string
|
||||
asset_type_name: string
|
||||
asset_type_code: string
|
||||
category_id?: number
|
||||
power_consumption?: number
|
||||
}
|
||||
|
||||
@@ -111,69 +126,121 @@ const dialogVisible = computed({
|
||||
})
|
||||
|
||||
const formRef = ref()
|
||||
const assetListLoading = ref(false)
|
||||
let assetSearchTimer: number | undefined
|
||||
const formData = reactive({
|
||||
start_unit: 1,
|
||||
occupied_units: 1,
|
||||
asset_id: undefined as number | undefined,
|
||||
asset_code: '',
|
||||
asset_name: '',
|
||||
asset_type: '',
|
||||
asset_type: '', // 展示 category.name
|
||||
asset_type_code: '', // 提交 category.code
|
||||
power_consumption: undefined as number | undefined,
|
||||
description: '',
|
||||
})
|
||||
|
||||
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 assetList = ref<Asset[]>([])
|
||||
|
||||
const rules = {
|
||||
start_unit: [{ 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 asset = assetList.value.find((a) => a.id === value)
|
||||
const syncAssetFields = (value: number | string | undefined) => {
|
||||
if (value === undefined || value === null || value === '') return
|
||||
const asset = assetList.value.find((a) => String(a.id) === String(value))
|
||||
if (asset) {
|
||||
formData.asset_id = asset.id
|
||||
formData.asset_code = asset.asset_code
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
const handleOk = async () => {
|
||||
const handleAssetChange = (value: number | string) => {
|
||||
syncAssetFields(value)
|
||||
}
|
||||
|
||||
const loadAssetList = async (keyword?: string) => {
|
||||
assetListLoading.value = true
|
||||
try {
|
||||
const valid = await formRef.value?.validate()
|
||||
if (!valid) {
|
||||
// 发送分配请求
|
||||
const { allocateUnit } = await import('@/api/ops/unit')
|
||||
const params = {
|
||||
rack_id: props.rackId,
|
||||
start_unit: formData.start_unit,
|
||||
occupied_units: formData.occupied_units,
|
||||
asset_id: formData.asset_id,
|
||||
asset_code: formData.asset_code,
|
||||
asset_name: formData.asset_name || `未命名设备-${formData.start_unit}`,
|
||||
asset_type: formData.asset_type,
|
||||
power_consumption: formData.power_consumption,
|
||||
description: formData.description,
|
||||
}
|
||||
const res = await allocateUnit(params)
|
||||
if (res.code === 0) {
|
||||
Message.success('分配成功')
|
||||
emit('success')
|
||||
handleCancel()
|
||||
} else {
|
||||
Message.error(res.message || '分配失败')
|
||||
}
|
||||
const res: any = await fetchAssetAll({ keyword: keyword || undefined })
|
||||
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 params = {
|
||||
rack_id: props.rackId,
|
||||
start_unit: formData.start_unit,
|
||||
occupied_units: formData.occupied_units,
|
||||
asset_id: formData.asset_id,
|
||||
asset_code: formData.asset_code,
|
||||
asset_name: formData.asset_name || `未命名设备-${formData.start_unit}`,
|
||||
// 后端字段 asset_type 使用分类标识(category.code)
|
||||
asset_type: formData.asset_type_code,
|
||||
power_consumption: formData.power_consumption,
|
||||
description: formData.description,
|
||||
}
|
||||
|
||||
const res = await allocateUnit(params)
|
||||
if (res.code === 0) {
|
||||
Message.success('分配成功')
|
||||
emit('success')
|
||||
return true
|
||||
} else {
|
||||
Message.error(res.message || '分配失败')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,6 +256,7 @@ const resetForm = () => {
|
||||
formData.asset_code = ''
|
||||
formData.asset_name = ''
|
||||
formData.asset_type = ''
|
||||
formData.asset_type_code = ''
|
||||
formData.power_consumption = undefined
|
||||
formData.description = ''
|
||||
formRef.value?.resetFields()
|
||||
@@ -197,9 +265,18 @@ const resetForm = () => {
|
||||
watch(
|
||||
() => props.visible,
|
||||
(val) => {
|
||||
if (!val) {
|
||||
if (val) {
|
||||
loadAssetList()
|
||||
} else {
|
||||
resetForm()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => formData.asset_id,
|
||||
(value) => {
|
||||
syncAssetFields(value)
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -227,6 +227,7 @@ import {
|
||||
releaseUnit,
|
||||
updateUnitStatus,
|
||||
} from '@/api/ops/unit'
|
||||
import { normalizeUnitList } from './utils/unitFallback'
|
||||
import AllocateUnitDialog from './components/AllocateUnitDialog.vue'
|
||||
import ReserveUnitDialog from './components/ReserveUnitDialog.vue'
|
||||
|
||||
@@ -334,8 +335,9 @@ const fetchUnits = async () => {
|
||||
const res = await fetchUnitList(rackId.value)
|
||||
|
||||
if (res.code === 0) {
|
||||
rackInfo.value = res.data?.rack || {}
|
||||
unitList.value = res.data?.units || []
|
||||
const payload = res?.details ?? res?.data ?? {}
|
||||
rackInfo.value = payload?.rack || {}
|
||||
unitList.value = normalizeUnitList(payload?.units, rackInfo.value?.height)
|
||||
} else {
|
||||
Message.error(res.message || '获取U位列表失败')
|
||||
}
|
||||
|
||||
@@ -6,11 +6,53 @@
|
||||
<icon-storage /> 选择机柜
|
||||
</template>
|
||||
<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-select
|
||||
v-model="selectedRackId"
|
||||
placeholder="请选择机柜"
|
||||
:loading="rackListLoading"
|
||||
:disabled="!selectedFloorId"
|
||||
allow-search
|
||||
@search="handleRackSearch"
|
||||
@change="handleRackChange"
|
||||
style="width: 100%"
|
||||
>
|
||||
@@ -179,17 +221,31 @@ import {
|
||||
releaseUnit,
|
||||
updateUnitStatus,
|
||||
} 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 ReserveUnitDialog from './components/ReserveUnitDialog.vue'
|
||||
|
||||
// 状态管理
|
||||
const loading = 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 rackInfo = ref<any>({})
|
||||
const datacenterList = ref<{ label: string; value: number }[]>([])
|
||||
const floorList = ref<{ label: string; value: number }[]>([])
|
||||
const rackList = ref<any[]>([])
|
||||
const unitList = ref<any[]>([])
|
||||
let datacenterSearchTimer: number | undefined
|
||||
let floorSearchTimer: number | undefined
|
||||
let rackSearchTimer: number | undefined
|
||||
|
||||
// 对话框可见性
|
||||
const allocateVisible = ref(false)
|
||||
@@ -213,6 +269,16 @@ const usagePercentage = computed(() => {
|
||||
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位状态颜色
|
||||
const getUnitStatusColor = (status?: 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
|
||||
|
||||
try {
|
||||
const res = await fetchRackList({
|
||||
page: 1,
|
||||
page_size: 1000,
|
||||
status: 'in_use', // 只获取使用中的机柜
|
||||
})
|
||||
|
||||
rackList.value = res.details?.data || []
|
||||
const res: any = await fetchRackListByFloor(selectedFloorId.value, { name: keyword })
|
||||
rackList.value = extractList(res)
|
||||
} catch (error) {
|
||||
console.error('获取机柜列表失败:', 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) => {
|
||||
if (rackId) {
|
||||
@@ -278,8 +438,9 @@ const fetchUnits = async (rackId?: number) => {
|
||||
const res = await fetchUnitList(targetRackId)
|
||||
|
||||
if (res.code === 0) {
|
||||
rackInfo.value = res.data?.rack || {}
|
||||
unitList.value = res.data?.units || []
|
||||
const payload = res?.details ?? res?.data ?? {}
|
||||
rackInfo.value = payload?.rack || {}
|
||||
unitList.value = normalizeUnitList(payload?.units, rackInfo.value?.height)
|
||||
} else {
|
||||
Message.error(res.message || '获取U位列表失败')
|
||||
}
|
||||
@@ -453,7 +614,7 @@ const handleCancelReservation = async (record: any) => {
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
fetchRacks()
|
||||
fetchDatacenters()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="container">
|
||||
<search-table
|
||||
:form-model="formModel"
|
||||
v-model:form-model="formModel"
|
||||
:form-items="formItems"
|
||||
:data="tableData"
|
||||
:columns="columns"
|
||||
@@ -50,84 +50,118 @@
|
||||
{{ rowIndex + 1 + (pagination.current - 1) * pagination.pageSize }}
|
||||
</template>
|
||||
|
||||
<!-- 操作 -->
|
||||
<!-- 操作(禁用规则见 ticket-list-operation-buttons.md;已解决/已关闭仅保留详情与评论) -->
|
||||
<template #actions="{ record }">
|
||||
<!-- 详情按钮(所有状态都显示) -->
|
||||
<a-button type="text" size="small" @click="handleDetail(record)">
|
||||
详情
|
||||
</a-button>
|
||||
|
||||
<!-- 接单按钮(待接单状态) -->
|
||||
<a-button
|
||||
v-if="record.status === 'pending'"
|
||||
type="text"
|
||||
size="small"
|
||||
@click="handleAccept(record)"
|
||||
|
||||
<template v-if="!isTicketResolvedOrClosed(record.status)">
|
||||
<a-tooltip
|
||||
v-if="record.status === 'pending'"
|
||||
:content="reasonAcceptTicket(record, me)"
|
||||
:disabled="canAcceptTicket(record, me)"
|
||||
>
|
||||
接单
|
||||
</a-button>
|
||||
|
||||
<!-- 转交按钮(已接单、处理中、已挂起状态) -->
|
||||
<a-button
|
||||
v-if="['accepted', 'processing', 'suspended'].includes(record.status)"
|
||||
type="text"
|
||||
size="small"
|
||||
@click="handleTransfer(record)"
|
||||
<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>
|
||||
|
||||
<!-- 挂起按钮(已接单、处理中状态) -->
|
||||
<a-button
|
||||
v-if="['accepted', 'processing'].includes(record.status)"
|
||||
type="text"
|
||||
size="small"
|
||||
@click="handleSuspend(record)"
|
||||
<a-button
|
||||
type="text"
|
||||
size="small"
|
||||
:disabled="!canTransferTicket(record, me)"
|
||||
@click="handleTransfer(record)"
|
||||
>
|
||||
转交
|
||||
</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="record.status === 'suspended'"
|
||||
type="text"
|
||||
size="small"
|
||||
@click="handleResume(record)"
|
||||
<a-button
|
||||
type="text"
|
||||
size="small"
|
||||
:disabled="!canSuspendTicket(record, me)"
|
||||
@click="handleSuspend(record)"
|
||||
>
|
||||
挂起
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
|
||||
<a-tooltip
|
||||
v-if="record.status === 'suspended'"
|
||||
:content="reasonResumeTicket(record, me)"
|
||||
:disabled="canResumeTicket(record, me)"
|
||||
>
|
||||
重启
|
||||
</a-button>
|
||||
|
||||
<!-- 解决按钮(已接单、处理中状态) -->
|
||||
<a-button
|
||||
v-if="['accepted', 'processing'].includes(record.status)"
|
||||
type="text"
|
||||
size="small"
|
||||
@click="handleResolve(record)"
|
||||
<a-button
|
||||
type="text"
|
||||
size="small"
|
||||
:disabled="!canResumeTicket(record, me)"
|
||||
@click="handleResume(record)"
|
||||
>
|
||||
重启
|
||||
</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="['pending', 'accepted', 'processing', 'resolved'].includes(record.status)"
|
||||
type="text"
|
||||
size="small"
|
||||
@click="handleClose(record)"
|
||||
<a-button
|
||||
type="text"
|
||||
size="small"
|
||||
:disabled="!canResolveTicket(record, me)"
|
||||
@click="handleResolve(record)"
|
||||
>
|
||||
解决
|
||||
</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="!['closed', 'cancelled'].includes(record.status)"
|
||||
type="text"
|
||||
size="small"
|
||||
@click="handleEdit(record)"
|
||||
<a-button
|
||||
type="text"
|
||||
size="small"
|
||||
:disabled="!canCloseTicket(record, me)"
|
||||
@click="handleClose(record)"
|
||||
>
|
||||
关闭
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
|
||||
<a-tooltip
|
||||
:content="reasonEditTicket(record, me)"
|
||||
:disabled="canEditTicket(record, me)"
|
||||
>
|
||||
编辑
|
||||
</a-button>
|
||||
|
||||
<!-- 评论按钮(所有状态都显示) -->
|
||||
<a-button
|
||||
type="text"
|
||||
size="small"
|
||||
:disabled="!canEditTicket(record, me)"
|
||||
@click="handleEdit(record)"
|
||||
>
|
||||
编辑
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
|
||||
<a-button type="text" size="small" @click="handleComment(record)">
|
||||
评论
|
||||
</a-button>
|
||||
@@ -186,6 +220,24 @@ import type { FormItem } from '@/components/search-form/types'
|
||||
import SearchTable from '@/components/search-table/index.vue'
|
||||
import { searchFormConfig } from './config/search-form'
|
||||
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 {
|
||||
fetchFeedbackTickets,
|
||||
acceptFeedbackTicket,
|
||||
@@ -229,6 +281,9 @@ const priorityMap: Record<string, { text: string; color: string }> = {
|
||||
urgent: { text: '紧急', color: 'red' },
|
||||
}
|
||||
|
||||
/** 当前账号 ID,与列表行 creator_id / assignee_id 同口径(登录 details.user_id) */
|
||||
const me = computed(() => getFeedbackCurrentUserId())
|
||||
|
||||
// 状态管理
|
||||
const loading = ref(false)
|
||||
const tableData = ref<any[]>([])
|
||||
@@ -338,7 +393,10 @@ const handleCreate = () => {
|
||||
|
||||
// 编辑工单
|
||||
const handleEdit = (record: any) => {
|
||||
console.log('编辑工单:', record)
|
||||
if (!canEditTicket(record, me.value)) {
|
||||
Message.warning(reasonEditTicket(record, me.value) || '当前不可编辑')
|
||||
return
|
||||
}
|
||||
editingTicket.value = record
|
||||
formVisible.value = true
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ const handleOk = async () => {
|
||||
const res = await commentFeedbackTicket(props.ticket.id, {
|
||||
content: form.value.content,
|
||||
})
|
||||
if (res.code === 200) {
|
||||
if (res.code === 0) {
|
||||
Message.success('评论成功')
|
||||
emit('success')
|
||||
} else {
|
||||
|
||||
@@ -107,7 +107,7 @@ const handleOk = async () => {
|
||||
const res = await resolveFeedbackTicket(props.ticket.id, {
|
||||
content: form.value.content,
|
||||
})
|
||||
if (res.code === 200) {
|
||||
if (res.code === 0) {
|
||||
Message.success('工单已解决')
|
||||
emit('success')
|
||||
} else {
|
||||
|
||||
@@ -174,7 +174,7 @@ const handleOk = async () => {
|
||||
assignee_name: form.value.assignee_name,
|
||||
reason: form.value.reason,
|
||||
})
|
||||
if (res.code === 200) {
|
||||
if (res.code === 0) {
|
||||
Message.success('转交成功')
|
||||
emit('success')
|
||||
} else {
|
||||
|
||||
@@ -46,4 +46,9 @@ export const searchFormConfig: FormItem[] = [
|
||||
{ label: '紧急', value: 'urgent' },
|
||||
],
|
||||
},
|
||||
{
|
||||
field: 'dateRange',
|
||||
label: '创建时间',
|
||||
type: 'dateRange',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<a-tabs v-model:active-key="activeTab" @change="handleTabChange">
|
||||
<a-tab-pane key="pending" title="待处理">
|
||||
<search-table
|
||||
:form-model="formModel"
|
||||
v-model:form-model="formModel"
|
||||
:form-items="formItems"
|
||||
:data="tableData"
|
||||
:columns="pendingColumns"
|
||||
@@ -17,15 +17,6 @@
|
||||
@page-change="handlePageChange"
|
||||
@refresh="handleRefresh"
|
||||
>
|
||||
<template #toolbar-left>
|
||||
<a-button type="primary" @click="handleCreate">
|
||||
<template #icon>
|
||||
<icon-plus />
|
||||
</template>
|
||||
新建工单
|
||||
</a-button>
|
||||
</template>
|
||||
|
||||
<!-- 工单类型 -->
|
||||
<template #type="{ record }">
|
||||
<a-tag :color="typeMap[record.type]?.color || 'gray'">
|
||||
@@ -52,25 +43,111 @@
|
||||
{{ rowIndex + 1 + (pagination.current - 1) * pagination.pageSize }}
|
||||
</template>
|
||||
|
||||
<!-- 操作 -->
|
||||
<!-- 操作(待处理;已解决/已关闭仅详情与评论) -->
|
||||
<template #actions="{ record }">
|
||||
<a-button type="text" size="small" @click="handleDetail(record)">
|
||||
详情
|
||||
</a-button>
|
||||
<a-button type="text" size="small" @click="handleTransfer(record)">
|
||||
转交
|
||||
</a-button>
|
||||
<a-button type="text" size="small" @click="handleSuspend(record)">
|
||||
挂起
|
||||
</a-button>
|
||||
<a-button v-if="record.status === 'suspended'" type="text" size="small" @click="handleResume(record)">
|
||||
重启
|
||||
</a-button>
|
||||
<a-button type="text" size="small" @click="handleResolve(record)">
|
||||
解决
|
||||
</a-button>
|
||||
<a-button type="text" size="small" @click="handleClose(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-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-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-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-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-tooltip>
|
||||
</template>
|
||||
|
||||
<a-button
|
||||
v-if="isTicketResolvedOrClosed(record.status)"
|
||||
type="text"
|
||||
size="small"
|
||||
@click="handleComment(record)"
|
||||
>
|
||||
评论
|
||||
</a-button>
|
||||
</template>
|
||||
</search-table>
|
||||
@@ -78,7 +155,7 @@
|
||||
|
||||
<a-tab-pane key="my-created" title="我创建的">
|
||||
<search-table
|
||||
:form-model="formModel"
|
||||
v-model:form-model="formModel"
|
||||
:form-items="formItems"
|
||||
:data="tableData"
|
||||
:columns="myCreatedColumns"
|
||||
@@ -92,15 +169,6 @@
|
||||
@page-change="handlePageChange"
|
||||
@refresh="handleRefresh"
|
||||
>
|
||||
<template #toolbar-left>
|
||||
<a-button type="primary" @click="handleCreate">
|
||||
<template #icon>
|
||||
<icon-plus />
|
||||
</template>
|
||||
新建工单
|
||||
</a-button>
|
||||
</template>
|
||||
|
||||
<!-- 工单类型 -->
|
||||
<template #type="{ record }">
|
||||
<a-tag :color="typeMap[record.type]?.color || 'gray'">
|
||||
@@ -127,25 +195,77 @@
|
||||
{{ rowIndex + 1 + (pagination.current - 1) * pagination.pageSize }}
|
||||
</template>
|
||||
|
||||
<!-- 操作 -->
|
||||
<!-- 操作(我创建的;已解决/已关闭仅详情与评论) -->
|
||||
<template #actions="{ record }">
|
||||
<a-button type="text" size="small" @click="handleDetail(record)">
|
||||
详情
|
||||
</a-button>
|
||||
<a-button v-if="!['resolved', 'closed'].includes(record.status)" type="text" size="small" @click="handleEdit(record)">
|
||||
编辑
|
||||
</a-button>
|
||||
<a-button type="text" size="small" @click="handleCancel(record)">
|
||||
撤回
|
||||
</a-button>
|
||||
<a-button v-if="record.status === 'resolved'" type="text" size="small" @click="handleComment(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-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-tooltip>
|
||||
<a-button type="text" size="small" @click="handleComment(record)">
|
||||
评论
|
||||
</a-button>
|
||||
<a-button type="text" size="small" @click="handleClose(record)">
|
||||
关闭
|
||||
</a-button>
|
||||
<a-button type="text" size="small" status="danger" @click="handleDelete(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-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-tooltip>
|
||||
</template>
|
||||
|
||||
<a-button
|
||||
v-else
|
||||
type="text"
|
||||
size="small"
|
||||
@click="handleComment(record)"
|
||||
>
|
||||
评论
|
||||
</a-button>
|
||||
</template>
|
||||
</search-table>
|
||||
@@ -192,14 +312,14 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive, computed } from '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 { FormItem } from '@/components/search-form/types'
|
||||
import SearchTable from '@/components/search-table/index.vue'
|
||||
import { searchFormConfig } from './config/search-form'
|
||||
import dayjs from 'dayjs'
|
||||
import {
|
||||
fetchFeedbackTickets,
|
||||
transferFeedbackTicket,
|
||||
acceptFeedbackTicket,
|
||||
suspendFeedbackTicket,
|
||||
resumeFeedbackTicket,
|
||||
resolveFeedbackTicket,
|
||||
@@ -207,6 +327,28 @@ import {
|
||||
closeFeedbackTicket,
|
||||
deleteFeedbackTicket,
|
||||
} 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 TransferDialog from '../components/TransferDialog.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')
|
||||
|
||||
/** 待处理 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 tableData = ref<any[]>([])
|
||||
@@ -251,6 +405,7 @@ const formModel = ref({
|
||||
type: '',
|
||||
priority: '',
|
||||
status: '',
|
||||
dateRange: [] as unknown[],
|
||||
})
|
||||
|
||||
const pagination = reactive({
|
||||
@@ -366,27 +521,43 @@ const suspendVisible = ref(false)
|
||||
const resolveVisible = ref(false)
|
||||
const commentVisible = ref(false)
|
||||
|
||||
// 获取工单列表
|
||||
// 获取工单列表(GET /Feedback/v1/tickets)
|
||||
const fetchTickets = async () => {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
// 两个 Tab 均依赖当前用户 id(assignee_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 = {
|
||||
page: pagination.current,
|
||||
page_size: pagination.pageSize,
|
||||
keyword: formModel.value.keyword || undefined,
|
||||
keyword: keyword || undefined,
|
||||
type: formModel.value.type || undefined,
|
||||
priority: formModel.value.priority || 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') {
|
||||
// 待处理工单:分配给当前用户且状态为 pending/accepted/processing/suspended
|
||||
params.status = 'accepted,processing,suspended'
|
||||
params.assignee_id = me.value
|
||||
if (!formModel.value.status) {
|
||||
params.status = [...PENDING_TAB_DEFAULT_STATUS]
|
||||
}
|
||||
} else {
|
||||
// 我创建的工单:由当前用户创建
|
||||
// 这里需要从用户信息中获取 creator_id
|
||||
// params.creator_id = userStore.userId
|
||||
params.creator_id = me.value
|
||||
}
|
||||
|
||||
const res = await fetchFeedbackTickets(params)
|
||||
@@ -424,6 +595,7 @@ const handleReset = () => {
|
||||
type: '',
|
||||
priority: '',
|
||||
status: '',
|
||||
dateRange: [],
|
||||
}
|
||||
pagination.current = 1
|
||||
fetchTickets()
|
||||
@@ -441,12 +613,6 @@ const handleRefresh = () => {
|
||||
Message.success('数据已刷新')
|
||||
}
|
||||
|
||||
// 新建工单
|
||||
const handleCreate = () => {
|
||||
// TODO: 打开新建工单对话框
|
||||
Message.info('新建工单功能待实现')
|
||||
}
|
||||
|
||||
// 详情
|
||||
const handleDetail = (record: any) => {
|
||||
console.log('查看详情:', record)
|
||||
@@ -454,9 +620,33 @@ const handleDetail = (record: any) => {
|
||||
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) => {
|
||||
console.log('编辑工单:', record)
|
||||
if (!canEditTicket(record, me.value)) {
|
||||
Message.warning(reasonEditTicket(record, me.value) || '当前不可编辑')
|
||||
return
|
||||
}
|
||||
// TODO: 打开编辑对话框
|
||||
Message.info('编辑工单功能待实现')
|
||||
}
|
||||
|
||||
167
src/views/ops/pages/feedback/utils/ticketActionPermissions.ts
Normal file
167
src/views/ops/pages/feedback/utils/ticketActionPermissions.ts
Normal 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 ''
|
||||
}
|
||||
Reference in New Issue
Block a user