refactor: change agent cloud data permission to cloud variables only

- Rename permission from '查看云端数据' to '查看云端变量'
- Remove cloud constants API and page for agents
- Add cloud variable records API for agents (stream type)
- Add agent cloud-variables page with full variable details
- Add agent cloud-variable records page (read-only)
- Update navigation from cloud-data to cloud-variables
- Update i18n keys: cloudData -> cloudVariables
- Backend returns var_type, default_value, max_records etc.
- Records page supports dynamic columns and date filtering
This commit is contained in:
2026-05-11 08:05:40 +08:00
parent 6dcb5cde02
commit 3cea807353
8 changed files with 871 additions and 406 deletions
+83 -45
View File
@@ -1,6 +1,7 @@
package agent
import (
"encoding/json"
"fmt"
"math/rand"
"sort"
@@ -26,7 +27,7 @@ func SetupAgentRoutes(r *gin.RouterGroup) {
r.PUT("/profile", handleUpdateProfile)
r.GET("/cards/export", handleExportCards)
r.GET("/cloud-variables", handleGetCloudVariables)
r.GET("/cloud-constants", handleGetCloudConstants)
r.GET("/cloud-variables/:id/records", handleGetCloudVariableRecords)
}
func handleGetStats(c *gin.Context) {
@@ -778,7 +779,7 @@ func handleGetCloudVariables(c *gin.Context) {
}
if !agent.CanViewCloudData {
response.Error(c, 403, "无权查看云端数据")
response.Error(c, 403, "无权查看云端变量")
return
}
@@ -822,10 +823,14 @@ func handleGetCloudVariables(c *gin.Context) {
}
result = append(result, gin.H{
"id": v.ID,
"name": v.Key,
"key": v.Key,
"app_id": v.AppID,
"app_name": appName,
"application_name": appName,
"default_value": v.DefaultValue,
"var_type": v.VarType,
"max_records": v.MaxRecords,
"scope": v.Scope,
"write_permission": v.WritePermission,
"status": v.Status,
"description": v.Description,
"created_at": v.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
@@ -838,7 +843,7 @@ func handleGetCloudVariables(c *gin.Context) {
})
}
func handleGetCloudConstants(c *gin.Context) {
func handleGetCloudVariableRecords(c *gin.Context) {
userID := c.GetUint("user_id")
var agent model.User
@@ -848,62 +853,95 @@ func handleGetCloudConstants(c *gin.Context) {
}
if !agent.CanViewCloudData {
response.Error(c, 403, "无权查看云端数据")
response.Error(c, 403, "无权查看云端变量")
return
}
variableID := c.Param("id")
var variable model.CloudVariable
if err := database.DB.First(&variable, variableID).Error; err != nil {
response.Error(c, 404, "云端变量不存在")
return
}
var agentApps []model.AgentApplication
database.DB.Where("agent_id = ?", userID).Find(&agentApps)
appIDs := make([]uint, 0)
appIDs := make(map[uint]bool)
for _, app := range agentApps {
appIDs = append(appIDs, app.ApplicationID)
appIDs[app.ApplicationID] = true
}
if len(appIDs) == 0 {
response.Success(c, gin.H{
"constants": []interface{}{},
"total": 0,
})
if variable.AppID == nil || !appIDs[*variable.AppID] {
response.Error(c, 403, "无权查看该变量的记录")
return
}
appIDFilter := c.Query("app_id")
var constants []model.CloudConstant
query := database.DB.Where("app_id IN ?", appIDs)
if appIDFilter != "" {
if aid, err := strconv.ParseUint(appIDFilter, 10, 32); err == nil {
query = database.DB.Where("app_id = ?", uint(aid))
}
}
query.Find(&constants)
appMap := make(map[uint]string)
var apps []model.Application
database.DB.Where("id IN ?", appIDs).Find(&apps)
for _, app := range apps {
appMap[app.ID] = app.Name
if variable.VarType != "stream" {
response.Error(c, 400, "该变量不是记录类型")
return
}
result := make([]gin.H, 0)
for _, c := range constants {
appName := ""
if c.AppID != nil {
appName = appMap[*c.AppID]
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
if page < 1 {
page = 1
}
result = append(result, gin.H{
"id": c.ID,
"name": c.Key,
"app_id": c.AppID,
"app_name": appName,
"value": c.Value,
"status": c.Status,
"description": c.Description,
"created_at": c.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
})
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
startDate := c.Query("start_date")
endDate := c.Query("end_date")
var total int64
query := database.DB.Model(&model.CloudVariableRecord{}).Where("cloud_variable_id = ?", variable.ID)
if startDate != "" {
query = query.Where("created_at >= ?", startDate+" 00:00:00")
}
if endDate != "" {
query = query.Where("created_at <= ?", endDate+" 23:59:59")
}
query.Count(&total)
var records []model.CloudVariableRecord
offset := (page - 1) * pageSize
if err := query.Order("created_at DESC").Limit(pageSize).Offset(offset).Find(&records).Error; err != nil {
response.Error(c, 500, "获取记录失败")
return
}
result := make([]gin.H, len(records))
for i, r := range records {
var parsedData interface{}
if r.Data != "" {
json.Unmarshal([]byte(r.Data), &parsedData)
}
if parsedData == nil && r.Data != "" {
parsedData = r.Data
}
record := gin.H{
"id": r.ID,
"data": parsedData,
"created_at": r.CreatedAt,
}
if r.AppUserID != nil {
record["user_id"] = r.AppUserID
var appUser model.AppUser
if err := database.DB.Select("id, username").First(&appUser, *r.AppUserID).Error; err == nil {
record["user"] = gin.H{
"id": appUser.ID,
"username": appUser.Username,
}
}
}
result[i] = record
}
response.Success(c, gin.H{
"constants": result,
"total": len(result),
"records": result,
"total": total,
"page": page,
"page_size": pageSize,
"total_pages": (total + int64(pageSize) - 1) / int64(pageSize),
})
}
+3 -3
View File
@@ -93,8 +93,8 @@ const navMain = computed(() => {
if (user.can_view_cloud_data) {
groups[0].items.push({
title: '云端数据',
url: '/agent/cloud-data',
title: t('nav.cloudVariables'),
url: '/agent/cloud-variables',
icon: Cloud,
})
}
@@ -122,7 +122,7 @@ const breadcrumbs = computed(() => {
cards: t('nav.cards'),
users: t('nav.users'),
finance: t('nav.finance'),
'cloud-data': '云端数据',
'cloud-variables': t('nav.cloudVariables'),
profile: t('nav.profile'),
create: t('nav.create'),
}
@@ -1,314 +0,0 @@
<script setup lang="ts">
import { Cloud, Database, Eye, Globe, Loader2, Variable } from 'lucide-vue-next'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { BasicPage } from '@/components/global-layout'
import api from '@/services/api'
const { t } = useI18n()
interface Application {
id: number
name: string
}
interface CloudItem {
id: number
name: string
app_id: number | null
app_name: string
scope?: string
status: string
value?: string
description?: string
created_at: string
}
const loading = ref(true)
const activeTab = ref('variables')
const applications = ref<Application[]>([])
const appFilter = ref<string>('')
const variables = ref<CloudItem[]>([])
const constants = ref<CloudItem[]>([])
const filteredVariables = computed(() => {
if (!appFilter.value) return variables.value
return variables.value.filter(v => String(v.app_id) === appFilter.value)
})
const filteredConstants = computed(() => {
if (!appFilter.value) return constants.value
return constants.value.filter(c => String(c.app_id) === appFilter.value)
})
const stats = computed(() => ({
variables: variables.value.length,
constants: constants.value.length,
applications: applications.value.length,
}))
async function fetchApplications() {
try {
const data = await api.get<{ apps: Application[] }>('/agent/apps')
applications.value = data?.apps || []
}
catch {
applications.value = []
}
}
async function fetchVariables() {
try {
const data = await api.get<{ variables: CloudItem[] }>('/agent/cloud-variables')
variables.value = data?.variables || []
}
catch {
variables.value = []
}
}
async function fetchConstants() {
try {
const data = await api.get<{ constants: CloudItem[] }>('/agent/cloud-constants')
constants.value = data?.constants || []
}
catch {
constants.value = []
}
}
function getAppName(appId: number | null) {
if (!appId) return '-'
const app = applications.value.find(a => a.id === appId)
return app?.name || String(appId)
}
function formatDate(dateStr: string) {
if (!dateStr) return '-'
return dateStr.replace('T', ' ').substring(0, 19)
}
onMounted(async () => {
loading.value = true
await Promise.all([fetchApplications(), fetchVariables(), fetchConstants()])
loading.value = false
})
</script>
<template>
<BasicPage
:title="t('agent.cloudData.title')"
:description="t('agent.cloudData.description')"
:breadcrumbs="[
{ title: t('nav.dashboard'), href: '/agent' },
{ title: t('nav.cloudData') },
]"
>
<div class="space-y-6">
<div class="grid gap-4 md:grid-cols-3">
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<UiCardTitle class="text-sm font-medium">
{{ t('agent.cloudData.variables') }}
</UiCardTitle>
<Variable class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ stats.variables }}
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<UiCardTitle class="text-sm font-medium">
{{ t('agent.cloudData.constants') }}
</UiCardTitle>
<Database class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ stats.constants }}
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<UiCardTitle class="text-sm font-medium">
{{ t('agent.cloudData.applications') }}
</UiCardTitle>
<Globe class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ stats.applications }}
</div>
</UiCardContent>
</UiCard>
</div>
<UiCard>
<UiCardHeader>
<div class="flex items-center justify-between">
<div>
<UiCardTitle class="flex items-center gap-2">
<Cloud class="size-5" />
{{ t('agent.cloudData.dataList') }}
</UiCardTitle>
<UiCardDescription>
{{ t('agent.cloudData.readOnlyHint') }}
</UiCardDescription>
</div>
<div class="flex items-center gap-3">
<UiSelect v-model="appFilter">
<UiSelectTrigger class="w-[180px]">
<UiSelectValue :placeholder="t('agent.cloudData.allApps')" />
</UiSelectTrigger>
<UiSelectContent>
<UiSelectItem v-for="app in applications" :key="app.id" :value="String(app.id)">
{{ app.name }}
</UiSelectItem>
</UiSelectContent>
</UiSelect>
<div class="flex border rounded-lg overflow-hidden">
<button
class="px-4 py-2 text-sm font-medium transition-colors"
:class="activeTab === 'variables' ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'"
@click="activeTab = 'variables'"
>
{{ t('agent.cloudData.variables') }}
</button>
<button
class="px-4 py-2 text-sm font-medium transition-colors"
:class="activeTab === 'constants' ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'"
@click="activeTab = 'constants'"
>
{{ t('agent.cloudData.constants') }}
</button>
</div>
</div>
</div>
</UiCardHeader>
<UiCardContent>
<div v-if="loading" class="flex items-center justify-center py-12">
<Loader2 class="size-8 animate-spin text-muted-foreground" />
</div>
<template v-else>
<div v-if="activeTab === 'variables'">
<div v-if="filteredVariables.length === 0" class="text-center py-12 text-muted-foreground">
{{ t('agent.cloudData.noVariables') }}
</div>
<div v-else class="border rounded-lg overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-muted/50">
<tr>
<th class="text-left p-3 font-medium">
{{ t('agent.cloudData.columns.name') }}
</th>
<th class="text-left p-3 font-medium">
{{ t('agent.cloudData.columns.application') }}
</th>
<th class="text-left p-3 font-medium">
{{ t('agent.cloudData.columns.scope') }}
</th>
<th class="text-left p-3 font-medium">
{{ t('agent.cloudData.columns.status') }}
</th>
<th class="text-left p-3 font-medium">
{{ t('agent.cloudData.columns.createdAt') }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="v in filteredVariables" :key="v.id" class="border-t hover:bg-muted/30 transition-colors">
<td class="p-3 font-medium">
{{ v.name }}
</td>
<td class="p-3 text-muted-foreground">
{{ v.app_name || getAppName(v.app_id) }}
</td>
<td class="p-3">
<UiBadge variant="outline">
{{ v.scope || 'global' }}
</UiBadge>
</td>
<td class="p-3">
<UiBadge :variant="v.status === 'active' ? 'default' : 'secondary'">
{{ v.status === 'active' ? t('agent.cloudData.statusActive') : t('agent.cloudData.statusInactive') }}
</UiBadge>
</td>
<td class="p-3 text-muted-foreground">
{{ formatDate(v.created_at) }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div v-if="activeTab === 'constants'">
<div v-if="filteredConstants.length === 0" class="text-center py-12 text-muted-foreground">
{{ t('agent.cloudData.noConstants') }}
</div>
<div v-else class="border rounded-lg overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-muted/50">
<tr>
<th class="text-left p-3 font-medium">
{{ t('agent.cloudData.columns.name') }}
</th>
<th class="text-left p-3 font-medium">
{{ t('agent.cloudData.columns.application') }}
</th>
<th class="text-left p-3 font-medium">
{{ t('agent.cloudData.columns.value') }}
</th>
<th class="text-left p-3 font-medium">
{{ t('agent.cloudData.columns.status') }}
</th>
<th class="text-left p-3 font-medium">
{{ t('agent.cloudData.columns.createdAt') }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="c in filteredConstants" :key="c.id" class="border-t hover:bg-muted/30 transition-colors">
<td class="p-3 font-medium">
{{ c.name }}
</td>
<td class="p-3 text-muted-foreground">
{{ c.app_name || getAppName(c.app_id) }}
</td>
<td class="p-3 text-muted-foreground max-w-[200px] truncate">
{{ c.value }}
</td>
<td class="p-3">
<UiBadge :variant="c.status === 'active' ? 'default' : 'secondary'">
{{ c.status === 'active' ? t('agent.cloudData.statusActive') : t('agent.cloudData.statusInactive') }}
</UiBadge>
</td>
<td class="p-3 text-muted-foreground">
{{ formatDate(c.created_at) }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<div class="mt-4 flex items-center gap-2 text-sm text-muted-foreground">
<Eye class="size-4" />
<span>{{ t('agent.cloudData.readOnlyNote') }}</span>
</div>
</UiCardContent>
</UiCard>
</div>
</BasicPage>
</template>
@@ -0,0 +1,386 @@
<script setup lang="ts">
import type { ColumnDef } from '@tanstack/vue-table'
import { List, Search } from 'lucide-vue-next'
import { computed, h, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import DataTable from '@/components/data-table/data-table.vue'
import { generateVueTable } from '@/components/data-table/use-generate-vue-table'
import DataTableViewOptions from '@/components/data-table/view-options.vue'
import DateTimePicker from '@/components/ui/date-picker/DateTimePicker.vue'
import { BasicPage } from '@/components/global-layout'
import Badge from '@/components/ui/badge/Badge.vue'
import { Input } from '@/components/ui/input'
import api from '@/services/api'
interface Variable {
id: number
key: string
var_type: string
scope: string
max_records: number
}
interface VariableRecord {
id: number
data: any
user_id?: number
user?: {
id: number
username: string
}
created_at: string
}
function parseRecordData(record: VariableRecord): Record<string, any> {
const rawData = record.data
if (rawData == null || rawData === '') return {}
if (typeof rawData === 'object') {
if (Array.isArray(rawData)) {
if (rawData.length > 0 && typeof rawData[0] === 'object' && rawData[0] !== null && !Array.isArray(rawData[0])) {
return rawData[0] as Record<string, any>
}
const obj: Record<string, any> = {}
rawData.forEach((item, index) => { obj[`item_${index}`] = item })
return obj
}
return rawData as Record<string, any>
}
if (typeof rawData === 'string') {
try {
const parsed = JSON.parse(rawData)
if (parsed != null && typeof parsed === 'object') {
if (Array.isArray(parsed)) {
if (parsed.length > 0 && typeof parsed[0] === 'object' && parsed[0] !== null && !Array.isArray(parsed[0])) {
return parsed[0] as Record<string, any>
}
const obj: Record<string, any> = {}
parsed.forEach((item: any, index: number) => { obj[`item_${index}`] = item })
return obj
}
return parsed as Record<string, any>
}
return {}
}
catch {
return {}
}
}
return {}
}
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const loading = ref(true)
const variable = ref<Variable | null>(null)
const records = ref<VariableRecord[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(10)
const dateRange = ref<{ from: string, to: string }>({ from: '', to: '' })
const searchQuery = ref('')
const dynamicKeys = computed(() => {
const keys = new Set<string>()
records.value.forEach((record) => {
const data = parseRecordData(record)
if (data && typeof data === 'object') {
Object.keys(data).forEach(key => keys.add(key))
}
})
return Array.from(keys).sort()
})
const columns = computed<ColumnDef<VariableRecord>[]>(() => {
const cols: ColumnDef<VariableRecord>[] = []
cols.push({
accessorKey: 'id',
header: () => 'ID',
cell: ({ row }) => h('div', { class: 'max-w-xs truncate' }, String(row.original.id)),
size: 80,
})
if (variable.value?.scope === 'user') {
cols.push({
accessorKey: 'user',
header: () => t('agent.cloudVariables.records.user'),
cell: ({ row }) => {
const user = row.original.user
if (user) {
return h(Badge, { variant: 'outline' }, () => user.username)
}
return h('span', { class: 'text-muted-foreground' }, '-')
},
size: 120,
})
}
dynamicKeys.value.forEach((key) => {
cols.push({
id: `data_${key}`,
header: () => key,
accessorFn: (row) => {
const data = parseRecordData(row)
return data?.[key]
},
cell: ({ row }) => {
const data = parseRecordData(row.original)
const value = data?.[key]
if (value === null || value === undefined) {
return h('span', { class: 'text-muted-foreground' }, '-')
}
if (typeof value === 'object') {
return h('div', { class: 'max-w-xs truncate', title: JSON.stringify(value) }, JSON.stringify(value))
}
if (typeof value === 'boolean') {
return h(Badge, { variant: value ? 'default' : 'secondary' }, () => String(value))
}
return h('div', { class: 'max-w-xs truncate', title: String(value) }, String(value))
},
size: 150,
})
})
if (dynamicKeys.value.length === 0) {
cols.push({
id: 'data',
header: () => t('agent.cloudVariables.records.data'),
cell: ({ row }) => {
const data = parseRecordData(row.original)
if (!data || Object.keys(data).length === 0) {
return h('span', { class: 'text-muted-foreground' }, '-')
}
return h('div', { class: 'max-w-md truncate', title: JSON.stringify(data) }, JSON.stringify(data))
},
})
}
cols.push({
accessorKey: 'created_at',
header: () => t('agent.cloudVariables.records.createdAt'),
cell: ({ row }) => {
const createdAt = row.original.created_at
if (!createdAt) return '-'
try {
const date = new Date(createdAt)
if (Number.isNaN(date.getTime())) return '-'
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
catch {
return '-'
}
},
size: 160,
})
return cols
})
const serverPagination = computed(() => ({
page: page.value,
pageSize: pageSize.value,
total: total.value,
onPageChange: (newPage: number) => {
page.value = newPage
},
onPageSizeChange: (newPageSize: number) => {
pageSize.value = newPageSize
page.value = 1
},
}))
const table = generateVueTable<VariableRecord>({
get data() { return records.value },
get loading() { return loading.value },
get columns() { return columns.value },
serverPagination: serverPagination.value,
})
const columnLabels = computed(() => {
const labels: Record<string, string> = {
id: 'ID',
created_at: 'agent.cloudVariables.records.createdAt',
}
if (variable.value?.scope === 'user') {
labels.user = 'agent.cloudVariables.records.user'
}
dynamicKeys.value.forEach((key) => {
labels[`data_${key}`] = key
})
return labels
})
watch([page, pageSize], () => {
fetchRecords()
})
watch(dateRange, (newVal, oldVal) => {
if (newVal.from !== oldVal.from || newVal.to !== oldVal.to) {
page.value = 1
fetchRecords()
}
}, { deep: true })
async function fetchVariable() {
try {
const data = await api.get<{ variables: Variable[] }>(`/agent/cloud-variables`)
const vars = data?.variables || []
variable.value = vars.find((v: Variable) => String(v.id) === route.params.id) || null
if (variable.value && variable.value.var_type !== 'stream') {
router.back()
}
}
catch {
// ignore
}
}
async function fetchRecords() {
if (!variable.value) return
loading.value = true
try {
const params = new URLSearchParams()
params.append('page', String(page.value))
params.append('page_size', String(pageSize.value))
if (dateRange.value.from) {
params.append('start_date', dateRange.value.from.split('T')[0])
}
if (dateRange.value.to) {
params.append('end_date', dateRange.value.to.split('T')[0])
}
const data = await api.get<{ records: VariableRecord[], total: number }>(`/agent/cloud-variables/${variable.value.id}/records?${params}`)
records.value = data?.records || []
total.value = data?.total || 0
}
catch {
records.value = []
total.value = 0
}
finally {
loading.value = false
}
}
onMounted(async () => {
await fetchVariable()
if (variable.value) {
fetchRecords()
}
})
</script>
<template>
<BasicPage
:title="t('agent.cloudVariables.records.title')"
:description="t('agent.cloudVariables.records.description', { key: variable?.key || '' })"
:breadcrumbs="[
{ title: t('nav.dashboard'), href: '/agent' },
{ title: t('nav.cloudVariables'), href: '/agent/cloud-variables' },
{ title: t('agent.cloudVariables.records.title') },
]"
sticky
>
<div class="space-y-6">
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('agent.cloudVariables.records.totalRecords') }}
</UiCardTitle>
<List class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ total }}
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('agent.cloudVariables.types.stream') }}
</UiCardTitle>
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ variable?.key || '-' }}
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('agent.cloudVariables.records.maxRecordsLabel') }}
</UiCardTitle>
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ variable?.max_records || t('agent.cloudVariables.records.unlimited') }}
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('agent.cloudVariables.columns.scope') }}
</UiCardTitle>
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ variable?.scope === 'user' ? t('agent.cloudVariables.userScope') : t('agent.cloudVariables.appScope') }}
</div>
</UiCardContent>
</UiCard>
</div>
<UiCard>
<UiCardContent class="p-6">
<DataTable :columns="columns" :data="records" :loading="loading" :table="table" :server-pagination="serverPagination" @refresh="fetchRecords">
<template #toolbar>
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex flex-wrap items-center gap-2">
<div class="relative">
<Search class="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
v-model="searchQuery"
:placeholder="t('agent.cloudVariables.records.searchPlaceholder')"
class="pl-8 h-8 w-[150px] lg:w-[250px]"
disabled
/>
</div>
<DateTimePicker
v-model="dateRange.from"
:placeholder="t('agent.cloudVariables.records.startDate')"
class="w-[180px]"
/>
<span class="text-muted-foreground text-sm">{{ t('agent.finance.to') }}</span>
<DateTimePicker
v-model="dateRange.to"
:placeholder="t('agent.cloudVariables.records.endDate')"
class="w-[180px]"
/>
</div>
<DataTableViewOptions :table="table" :column-labels="columnLabels" />
</div>
</template>
</DataTable>
</UiCardContent>
</UiCard>
</div>
</BasicPage>
</template>
@@ -0,0 +1,305 @@
<script setup lang="ts">
import { Globe, LayoutGrid, List, User, Variable } from 'lucide-vue-next'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import SingleFilter from '@/components/data-table/single-filter.vue'
import { BasicPage } from '@/components/global-layout'
import api from '@/services/api'
const { t } = useI18n()
const router = useRouter()
interface Application {
id: number
name: string
}
interface CloudVariable {
id: number
key: string
app_id: number | null
application_name: string
default_value: string
var_type: string
max_records: number
scope: string
write_permission: string
status: string
description: string
created_at: string
}
const loading = ref(true)
const variables = ref<CloudVariable[]>([])
const applications = ref<Application[]>([])
const appFilter = ref<string>('')
const scopeFilter = ref<string>('')
const filteredVariables = computed(() => {
let result = [...variables.value]
if (appFilter.value) {
result = result.filter(v => String(v.app_id) === appFilter.value)
}
if (scopeFilter.value) {
result = result.filter(v => v.scope === scopeFilter.value)
}
return result
})
const appScopeCount = computed(() => variables.value.filter(v => v.scope === 'app').length)
const userScopeCount = computed(() => variables.value.filter(v => v.scope === 'user').length)
const applicationOptions = computed(() =>
applications.value.map(app => ({ label: app.name, value: String(app.id) })),
)
const scopeOptions = computed(() => [
{ label: t('agent.cloudVariables.appScope'), value: 'app' },
{ label: t('agent.cloudVariables.userScope'), value: 'user' },
])
async function fetchApplications() {
try {
const data = await api.get<{ apps: Application[] }>('/agent/apps')
applications.value = data?.apps || []
}
catch {
applications.value = []
}
}
async function fetchVariables() {
loading.value = true
try {
const data = await api.get<{ variables: CloudVariable[] }>('/agent/cloud-variables')
variables.value = data?.variables || []
}
catch {
variables.value = []
}
finally {
loading.value = false
}
}
function handleViewRecords(variable: CloudVariable) {
router.push(`/agent/cloud-variables/${variable.id}/records?app_id=${variable.app_id}`)
}
function formatDate(dateStr: string) {
if (!dateStr) return '-'
try {
const date = new Date(dateStr)
if (Number.isNaN(date.getTime())) return '-'
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
catch {
return '-'
}
}
function formatVarType(type: string) {
const map: Record<string, string> = {
integer: t('agent.cloudVariables.types.integer'),
decimal: t('agent.cloudVariables.types.decimal'),
string: t('agent.cloudVariables.types.string'),
binary: t('agent.cloudVariables.types.binary'),
stream: t('agent.cloudVariables.types.stream'),
}
return map[type || 'string'] || type || 'string'
}
onMounted(async () => {
await fetchApplications()
fetchVariables()
})
</script>
<template>
<BasicPage
:title="t('agent.cloudVariables.title')"
:description="t('agent.cloudVariables.description')"
:breadcrumbs="[
{ title: t('nav.dashboard'), href: '/agent' },
{ title: t('nav.cloudVariables') },
]"
>
<div class="space-y-6">
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('agent.cloudVariables.totalVariables') }}
</UiCardTitle>
<Variable class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ filteredVariables.length }}
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('agent.cloudVariables.appScopeVariables') }}
</UiCardTitle>
<Globe class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ appScopeCount }}
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('agent.cloudVariables.userScopeVariables') }}
</UiCardTitle>
<User class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ userScopeCount }}
</div>
</UiCardContent>
</UiCard>
<UiCard>
<UiCardHeader class="flex flex-row items-center justify-between pb-2 space-y-0">
<UiCardTitle class="text-sm font-medium">
{{ t('agent.cloudVariables.appCount') }}
</UiCardTitle>
<LayoutGrid class="size-4 text-muted-foreground" />
</UiCardHeader>
<UiCardContent>
<div class="text-2xl font-bold">
{{ applications.length }}
</div>
</UiCardContent>
</UiCard>
</div>
<UiCard>
<UiCardContent class="p-6">
<div class="mb-4 flex flex-wrap items-center gap-2">
<SingleFilter
v-model="appFilter"
:title="t('agent.cloudVariables.application')"
:options="applicationOptions"
/>
<SingleFilter
v-model="scopeFilter"
:title="t('agent.cloudVariables.scope')"
:options="scopeOptions"
/>
</div>
<div v-if="loading" class="flex items-center justify-center py-12">
<div class="size-8 animate-spin rounded-full border-4 border-muted border-t-primary" />
</div>
<div v-else-if="filteredVariables.length === 0" class="text-center py-12 text-muted-foreground">
{{ t('agent.cloudVariables.noVariables') }}
</div>
<div v-else class="border rounded-lg overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-muted/50">
<tr>
<th class="text-left p-3 font-medium">
{{ t('agent.cloudVariables.columns.key') }}
</th>
<th class="text-left p-3 font-medium">
{{ t('agent.cloudVariables.columns.defaultValue') }}
</th>
<th class="text-left p-3 font-medium">
{{ t('agent.cloudVariables.columns.type') }}
</th>
<th class="text-left p-3 font-medium">
{{ t('agent.cloudVariables.columns.scope') }}
</th>
<th class="text-left p-3 font-medium">
{{ t('agent.cloudVariables.columns.application') }}
</th>
<th class="text-left p-3 font-medium">
{{ t('agent.cloudVariables.columns.status') }}
</th>
<th class="text-left p-3 font-medium">
{{ t('agent.cloudVariables.columns.createdAt') }}
</th>
<th class="text-left p-3 font-medium">
{{ t('common.actions') }}
</th>
</tr>
</thead>
<tbody>
<tr
v-for="v in filteredVariables"
:key="v.id"
class="border-t hover:bg-muted/30 transition-colors"
>
<td class="p-3">
<code class="text-xs bg-muted px-2 py-1 rounded font-mono">{{ v.key }}</code>
</td>
<td class="p-3 text-muted-foreground max-w-[200px] truncate">
{{ v.default_value || '-' }}
</td>
<td class="p-3">
<UiBadge variant="outline">
{{ formatVarType(v.var_type) }}
</UiBadge>
</td>
<td class="p-3">
<UiBadge :variant="v.scope === 'app' ? 'default' : 'secondary'">
{{ v.scope === 'app' ? t('agent.cloudVariables.appScope') : t('agent.cloudVariables.userScope') }}
</UiBadge>
</td>
<td class="p-3">
<UiBadge v-if="v.application_name" variant="outline">
{{ v.application_name }}
</UiBadge>
<span v-else class="text-muted-foreground">-</span>
</td>
<td class="p-3">
<UiBadge :variant="v.status === 'active' ? 'default' : 'destructive'">
{{ v.status === 'active' ? t('agent.cloudVariables.statusActive') : t('agent.cloudVariables.statusInactive') }}
</UiBadge>
</td>
<td class="p-3 text-muted-foreground whitespace-nowrap">
{{ formatDate(v.created_at) }}
</td>
<td class="p-3">
<UiButton
v-if="v.var_type === 'stream'"
variant="ghost"
size="sm"
@click="handleViewRecords(v)"
>
<List class="mr-1 size-4" />
{{ t('agent.cloudVariables.viewRecords') }}
</UiButton>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</UiCardContent>
</UiCard>
</div>
</BasicPage>
</template>
+39 -17
View File
@@ -923,7 +923,7 @@
"maxApiCalls": "API Calls/Month",
"maxApiCallsPlaceholder": "-1 for unlimited",
"allowAgent": "Agent Permission",
"allowCloudData": "Cloud Data",
"allowCloudData": "Cloud Variables",
"allowDynamicCode": "Cloud Function",
"allowEmail": "Email Feature",
"allowExtension": "Extension API",
@@ -1286,7 +1286,7 @@
"cardsCount": "Cards Generated",
"childAgentsCount": "Child Agents",
"canCreateAgent": "Create Agent Permission",
"canViewCloudData": "Cloud Data Permission",
"canViewCloudData": "View Cloud Variables Permission",
"balance": "Balance",
"createdAt": "Created At",
"lastLoginAt": "Last Login"
@@ -2887,27 +2887,49 @@
"generateNow": "Generate Now"
}
},
"cloudData": {
"title": "Cloud Data",
"description": "View cloud variables and constants (read-only)",
"variables": "Cloud Variables",
"constants": "Cloud Constants",
"applications": "Applications",
"dataList": "Data List",
"readOnlyHint": "Read-only mode, data cannot be edited",
"allApps": "All Applications",
"cloudVariables": {
"title": "Cloud Variables",
"description": "View cloud variables (read-only)",
"totalVariables": "Total Variables",
"appScopeVariables": "App Scope Variables",
"userScopeVariables": "User Scope Variables",
"appCount": "Applications",
"noVariables": "No cloud variables found",
"noConstants": "No cloud constants found",
"appScope": "App",
"userScope": "User",
"statusActive": "Active",
"statusInactive": "Inactive",
"readOnlyNote": "Read-only - Data is for viewing only, no editing allowed",
"columns": {
"name": "Name",
"viewRecords": "View Records",
"application": "Application",
"scope": "Scope",
"value": "Value",
"types": {
"integer": "Integer",
"decimal": "Decimal",
"string": "String",
"binary": "Binary",
"stream": "Stream"
},
"columns": {
"key": "Key",
"defaultValue": "Default Value",
"type": "Type",
"scope": "Scope",
"application": "Application",
"status": "Status",
"createdAt": "Created At"
},
"records": {
"title": "Variable Records",
"description": "Records for variable {key}",
"totalRecords": "Total Records",
"maxRecordsLabel": "Max Records",
"unlimited": "Unlimited",
"user": "User",
"data": "Data",
"createdAt": "Created At",
"searchPlaceholder": "Search records...",
"startDate": "Start Date",
"endDate": "End Date"
}
}
},
@@ -2932,7 +2954,7 @@
"apiCalls": "{count} API calls/month",
"unlimitedApiCalls": "Unlimited API calls",
"agent": "Agent permission",
"cloudData": "Cloud data feature",
"cloudData": "Cloud Variables feature",
"dynamicCode": "Dynamic code feature",
"email": "Email feature",
"extension": "Extension API",
+39 -17
View File
@@ -924,7 +924,7 @@
"maxApiCalls": "API调用次数/月",
"maxApiCallsPlaceholder": "-1表示不限",
"allowAgent": "代理权限",
"allowCloudData": "云端数据",
"allowCloudData": "云端变量",
"allowDynamicCode": "云端函数",
"allowEmail": "邮件功能",
"allowExtension": "扩展接口",
@@ -1242,7 +1242,7 @@
"cardsCount": "生成卡密",
"childAgentsCount": "下级代理",
"canCreateAgent": "创建代理权限",
"canViewCloudData": "云端数据权限",
"canViewCloudData": "查看云端变量权限",
"balance": "余额",
"totalConsumption": "消费",
"createdAt": "注册时间",
@@ -2888,27 +2888,49 @@
"generateNow": "立即生成"
}
},
"cloudData": {
"title": "云端数据",
"description": "查看云端变量和常量数据(只读)",
"variables": "云端变量",
"constants": "云端常量",
"applications": "应用数量",
"dataList": "数据列表",
"readOnlyHint": "只读模式,数据不可编辑",
"allApps": "全部应用",
"cloudVariables": {
"title": "云端变量",
"description": "查看云端变量数据(只读)",
"totalVariables": "变量总数",
"appScopeVariables": "应用级变量",
"userScopeVariables": "用户级变量",
"appCount": "应用数量",
"noVariables": "暂无云端变量数据",
"noConstants": "暂无云端常量数据",
"appScope": "应用级",
"userScope": "用户级",
"statusActive": "启用",
"statusInactive": "禁用",
"readOnlyNote": "只读模式 - 当前数据仅供查看,无法进行编辑操作",
"columns": {
"name": "名称",
"viewRecords": "查看记录",
"application": "应用",
"scope": "作用域",
"value": "值",
"types": {
"integer": "整数",
"decimal": "小数",
"string": "字符串",
"binary": "二进制",
"stream": "列表"
},
"columns": {
"key": "变量名",
"defaultValue": "默认值",
"type": "类型",
"scope": "作用域",
"application": "应用",
"status": "状态",
"createdAt": "创建时间"
},
"records": {
"title": "变量记录",
"description": "变量 {key} 的记录列表",
"totalRecords": "记录总数",
"maxRecordsLabel": "最大记录数",
"unlimited": "无限制",
"user": "用户",
"data": "数据",
"createdAt": "创建时间",
"searchPlaceholder": "搜索记录...",
"startDate": "开始日期",
"endDate": "结束日期"
}
}
},
@@ -2933,7 +2955,7 @@
"apiCalls": "{count}次/月 API调用",
"unlimitedApiCalls": "无限API调用",
"agent": "代理权限",
"cloudData": "云端数据功能",
"cloudData": "云端变量功能",
"dynamicCode": "云端函数功能",
"email": "邮件功能",
"extension": "扩展接口功能",
+10 -4
View File
@@ -451,10 +451,16 @@ const routes: RouteRecordRaw[] = [
meta: { title: '财务管理 - 代理商后台' },
},
{
path: 'cloud-data',
name: 'AgentCloudData',
component: () => import('@/pages/agent/cloud-data/index.vue'),
meta: { title: '云端数据 - 代理商后台' },
path: 'cloud-variables',
name: 'AgentCloudVariables',
component: () => import('@/pages/agent/cloud-variables/index.vue'),
meta: { title: '云端变量 - 代理商后台' },
},
{
path: 'cloud-variables/:id/records',
name: 'AgentCloudVariableRecords',
component: () => import('@/pages/agent/cloud-variables/[id]/records.vue'),
meta: { title: '变量记录 - 代理商后台' },
},
{
path: 'profile',