Browse Source

feat(web-app): 指标管理 — 支持删除 / 按接口数据驱动分类与配色

- api/indicator: 新增 deleteIndicator
- views/data/indicator: 列表行加「删除」按钮(带 AppConfirm 二次确认);权限控制通过 useAuth
- views/data/value: 移除硬编码 CATEGORIES / CAT_COLORS,改为 computed 从接口数据去重提取;分类颜色由 hashStr + hsl 自动生成
ligb 1 tuần trước cách đây
mục cha
commit
c2586b4de7

+ 4 - 0
web-app/src/api/indicator.js

@@ -28,6 +28,10 @@ export function updateIndicator(id, payload) {
   return http.put(`/api/indicators/${id}`, payload)
 }
 
+export function deleteIndicator(id) {
+  return http.del(`/api/indicators/${id}`)
+}
+
 // 时序值写(API §6.1,需 ADMIN + indicator:value:write)
 
 /**

+ 62 - 9
web-app/src/views/data/indicator/index.vue

@@ -88,6 +88,7 @@
             <div class="ops-row">
               <AppButton type="ghost" size="sm" @click="goView(row)">查看数据</AppButton>
               <AppButton type="ghost" size="sm" @click="openEdit(row)">编辑</AppButton>
+              <AppButton v-if="canDelete" type="danger" size="sm" @click="askDelete(row)">删除</AppButton>
             </div>
           </td>
         </tr>
@@ -200,22 +201,38 @@
         <AppButton type="primary" size="sm" :loading="submitting" @click="submitForm">保存</AppButton>
       </template>
     </AppModal>
+
+    <!-- 删除确认 -->
+    <AppConfirm
+      v-model="confirmOpen"
+      title="删除指标"
+      :message="`确认删除指标 #${confirmRow?.id ?? ''} ${confirmRow?.name || ''}?该指标的全部时序值将一并删除,此操作不可恢复。`"
+      ok-text="删除"
+      type="danger"
+      :loading="confirmLoading"
+      @ok="doDelete"
+    />
   </AdminPage>
 </template>
 
 <script setup>
 import { computed, onMounted, reactive, ref } from 'vue'
 import { useRouter } from 'vue-router'
+import { useAuth } from '@/store/auth'
 import AdminPage from '@/components/common/AdminPage.vue'
 import AppButton from '@/components/common/AppButton.vue'
 import AppModal from '@/components/common/AppModal.vue'
+import AppConfirm from '@/components/common/AppConfirm.vue'
 import FormField from '@/components/common/FormField.vue'
 import { useToast } from '@/composables/useToast'
-import { listIndicators, createIndicator, updateIndicator } from '@/api/indicator'
+import { listIndicators, createIndicator, updateIndicator, deleteIndicator } from '@/api/indicator'
 import { HttpError } from '@/utils/http'
 
 const router = useRouter()
 const toast = useToast()
+const { hasPermission, hasRole } = useAuth()
+
+const canDelete = computed(() => hasRole('ADMIN') && hasPermission('indicator:delete'))
 
 const list = ref([])
 const loading = ref(false)
@@ -226,9 +243,17 @@ const filters = reactive({
   keyword: '',
 })
 
-const CATEGORIES = ['GDP', '投资', '出口', '消费', '通胀', '供给', '景气度']
-const REGIONS = ['中国', '美国']
-const FREQUENCIES = ['日', '周', '月', '季', '年']
+function uniqueValues(key) {
+  const set = new Set()
+  for (const it of list.value) {
+    const v = it[key]
+    if (v !== null && v !== undefined && v !== '') set.add(v)
+  }
+  return Array.from(set)
+}
+const CATEGORIES = computed(() => uniqueValues('category'))
+const REGIONS = computed(() => uniqueValues('region'))
+const FREQUENCIES = computed(() => uniqueValues('frequency'))
 
 const formOpen = ref(false)
 const formMode = ref('create')
@@ -296,11 +321,7 @@ const categoryStats = computed(() => {
 async function reload() {
   loading.value = true
   try {
-    const params = {}
-    if (filters.category) params.category = filters.category
-    if (filters.region) params.region = filters.region
-    if (filters.frequency) params.frequency = filters.frequency
-    const data = await listIndicators(params)
+    const data = await listIndicators()
     list.value = Array.isArray(data) ? data.slice() : []
   } catch (err) {
     if (err instanceof HttpError) toast.error(err.message || '加载元数据失败')
@@ -432,6 +453,38 @@ async function submitForm() {
   }
 }
 
+// ===== 删除 =====
+const confirmOpen = ref(false)
+const confirmRow = ref(null)
+const confirmLoading = ref(false)
+
+function askDelete(row) {
+  if (!canDelete.value) {
+    toast.warning('无删除权限(需 indicator:delete)')
+    return
+  }
+  confirmRow.value = row
+  confirmOpen.value = true
+}
+
+async function doDelete() {
+  if (!confirmRow.value) return
+  confirmLoading.value = true
+  try {
+    const res = await deleteIndicator(confirmRow.value.id)
+    const values = res?.valuesDeleted
+    toast.success(`已删除 #${confirmRow.value.id}${values != null ? ` · 时序值 ${values} 条` : ''}`)
+    confirmOpen.value = false
+    confirmRow.value = null
+    await reload()
+  } catch (err) {
+    if (err instanceof HttpError) toast.error(err.message || '删除失败')
+    else toast.error('删除失败')
+  } finally {
+    confirmLoading.value = false
+  }
+}
+
 onMounted(reload)
 </script>
 

+ 25 - 15
web-app/src/views/data/value/index.vue

@@ -306,17 +306,29 @@ const route = useRoute()
 const router = useRouter()
 const toast = useToast()
 
-const CATEGORIES = ['GDP', '投资', '出口', '消费', '通胀', '供给', '景气度']
-const CAT_COLORS = {
-  GDP:     { bg: '#EAF1FF', fg: '#0E3A96' },
-  投资:    { bg: '#FFF4DC', fg: '#8B6B0A' },
-  出口:    { bg: '#E4F5EE', fg: '#128054' },
-  消费:    { bg: '#FDEAEA', fg: '#C83030' },
-  通胀:    { bg: '#FDE4F0', fg: '#9C2772' },
-  供给:    { bg: '#E6F4F8', fg: '#1F6E8C' },
-  景气度:  { bg: '#F1ECFB', fg: '#5B47E0' },
-}
-function catColor(c) { return CAT_COLORS[c] || { bg: '#F2F5FA', fg: '#6B7A96' } }
+function uniqueValues(key) {
+  const set = new Set()
+  for (const it of allIndicators.value) {
+    const v = it[key]
+    if (v !== null && v !== undefined && v !== '') set.add(v)
+  }
+  return Array.from(set)
+}
+const CATEGORIES = computed(() => uniqueValues('category'))
+
+function hashStr(s) {
+  let h = 0
+  for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0
+  return Math.abs(h)
+}
+function catColor(c) {
+  if (!c) return { bg: '#F2F5FA', fg: '#6B7A96' }
+  const h = hashStr(c) % 360
+  return {
+    bg: `hsl(${h}, 70%, 92%)`,
+    fg: `hsl(${h}, 60%, 32%)`,
+  }
+}
 
 const allIndicators = ref([])
 const picker = ref([])
@@ -337,9 +349,7 @@ const query = reactive({
 async function loadAllIndicators() {
   loadingPicker.value = true
   try {
-    const params = {}
-    if (filters.category) params.category = filters.category
-    const data = await listIndicators(params)
+    const data = await listIndicators()
     allIndicators.value = Array.isArray(data) ? data.slice() : []
     applyPickerFilter()
   } catch (err) {
@@ -369,7 +379,7 @@ function resetPicker() {
 }
 
 function onCategoryChange() {
-  loadAllIndicators()
+  applyPickerFilter()
 }
 
 const latest = computed(() => (rows.value.length ? rows.value[rows.value.length - 1] : null))