style: format web apps with prettier and sort imports
Apply repo-wide Prettier/lint normalization across admin-web, desktop-client and ops-web: single quotes, no semicolons, trailing commas, consistent line wrapping, and import ordering. Also drop an unused brand-logo import in DesktopShell.vue. No behavior changes — formatting only. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -19,8 +19,8 @@
|
||||
<script setup lang="ts">
|
||||
import type { RadioChangeEvent } from 'ant-design-vue'
|
||||
|
||||
import { publishPlatformOptions } from '@/lib/compliance-display'
|
||||
import type { CompliancePlatformScope } from '@/lib/compliance'
|
||||
import { publishPlatformOptions } from '@/lib/compliance-display'
|
||||
|
||||
defineProps<{
|
||||
scope: CompliancePlatformScope
|
||||
|
||||
@@ -36,12 +36,7 @@ const CHUNK_ERROR_PATTERNS = [
|
||||
]
|
||||
|
||||
export function isChunkLoadError(err: unknown): boolean {
|
||||
const msg =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: typeof err === 'string'
|
||||
? err
|
||||
: ''
|
||||
const msg = err instanceof Error ? err.message : typeof err === 'string' ? err : ''
|
||||
return CHUNK_ERROR_PATTERNS.some((re) => re.test(msg))
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,8 @@ export async function copyTextToClipboard(text: string): Promise<void> {
|
||||
|
||||
function copyTextWithSelection(text: string): boolean {
|
||||
const textarea = document.createElement('textarea')
|
||||
const activeElement = document.activeElement instanceof HTMLElement ? document.activeElement : null
|
||||
const activeElement =
|
||||
document.activeElement instanceof HTMLElement ? document.activeElement : null
|
||||
|
||||
textarea.value = text
|
||||
textarea.setAttribute('readonly', '')
|
||||
|
||||
@@ -127,7 +127,9 @@ export const complianceApi = {
|
||||
)
|
||||
},
|
||||
deleteTerm(dictionaryId: number, termId: number) {
|
||||
return http.delete<{ deleted: boolean }>(`/compliance/dictionaries/${dictionaryId}/terms/${termId}`)
|
||||
return http.delete<{ deleted: boolean }>(
|
||||
`/compliance/dictionaries/${dictionaryId}/terms/${termId}`,
|
||||
)
|
||||
},
|
||||
publishDictionary(id: number) {
|
||||
return http.post<OpsComplianceDictionary>(`/compliance/dictionaries/${id}/publish`)
|
||||
|
||||
@@ -209,16 +209,19 @@ async function uploadOSSInChunks(
|
||||
onProgress?: (progress: DesktopClientReleaseUploadProgress) => void
|
||||
},
|
||||
): Promise<DesktopClientReleaseUploadResult> {
|
||||
const session = await http.post<DesktopClientReleaseUploadSession>('/desktop-client/release-uploads', {
|
||||
platform: target.platform,
|
||||
arch: target.arch,
|
||||
channel: target.channel,
|
||||
version: target.version,
|
||||
kind: target.kind ?? 'installer',
|
||||
file_name: file.name,
|
||||
file_size_bytes: file.size,
|
||||
chunk_size_bytes: DESKTOP_CLIENT_RELEASE_CHUNK_SIZE_BYTES,
|
||||
})
|
||||
const session = await http.post<DesktopClientReleaseUploadSession>(
|
||||
'/desktop-client/release-uploads',
|
||||
{
|
||||
platform: target.platform,
|
||||
arch: target.arch,
|
||||
channel: target.channel,
|
||||
version: target.version,
|
||||
kind: target.kind ?? 'installer',
|
||||
file_name: file.name,
|
||||
file_size_bytes: file.size,
|
||||
chunk_size_bytes: DESKTOP_CLIENT_RELEASE_CHUNK_SIZE_BYTES,
|
||||
},
|
||||
)
|
||||
|
||||
const chunkSize = session.chunk_size_bytes || DESKTOP_CLIENT_RELEASE_CHUNK_SIZE_BYTES
|
||||
const chunkCount = session.chunk_count || Math.ceil(file.size / chunkSize)
|
||||
@@ -232,7 +235,15 @@ async function uploadOSSInChunks(
|
||||
try {
|
||||
for (let index = 0; index < chunkCount; index += 1) {
|
||||
if (uploaded.has(index)) continue
|
||||
await uploadChunkWithRetry(file, session.upload_id, index, chunkSize, chunkCount, confirmedBytes, target)
|
||||
await uploadChunkWithRetry(
|
||||
file,
|
||||
session.upload_id,
|
||||
index,
|
||||
chunkSize,
|
||||
chunkCount,
|
||||
confirmedBytes,
|
||||
target,
|
||||
)
|
||||
confirmedBytes += chunkByteSize(file.size, chunkSize, index, chunkCount)
|
||||
emitChunkProgress(target, confirmedBytes, file.size, chunkCount, index)
|
||||
}
|
||||
|
||||
@@ -77,8 +77,14 @@ export const opsMediaSupplyApi = {
|
||||
params as Record<string, unknown>,
|
||||
)
|
||||
},
|
||||
setPrice(id: number, payload: { price_type?: string; sell_price_cents: number; enabled?: boolean }) {
|
||||
return http.put<{ updated: boolean }, typeof payload>(`/media-supply/resources/${id}/price`, payload)
|
||||
setPrice(
|
||||
id: number,
|
||||
payload: { price_type?: string; sell_price_cents: number; enabled?: boolean },
|
||||
) {
|
||||
return http.put<{ updated: boolean }, typeof payload>(
|
||||
`/media-supply/resources/${id}/price`,
|
||||
payload,
|
||||
)
|
||||
},
|
||||
setVisibility(id: number, customerVisible: boolean) {
|
||||
return http.put<{ updated: boolean }, { customer_visible: boolean }>(
|
||||
@@ -101,7 +107,15 @@ export const opsMediaSupplyApi = {
|
||||
params as Record<string, unknown>,
|
||||
)
|
||||
},
|
||||
adjustWallet(payload: { tenant_id: number; user_id: number; delta_cents: number; note?: string }) {
|
||||
return http.post<OpsMediaSupplyWallet, typeof payload>('/media-supply/wallets/adjustments', payload)
|
||||
adjustWallet(payload: {
|
||||
tenant_id: number
|
||||
user_id: number
|
||||
delta_cents: number
|
||||
note?: string
|
||||
}) {
|
||||
return http.post<OpsMediaSupplyWallet, typeof payload>(
|
||||
'/media-supply/wallets/adjustments',
|
||||
payload,
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -95,9 +95,12 @@ export const objectStorageApi = {
|
||||
)
|
||||
},
|
||||
createFolder(prefix: string) {
|
||||
return http.post<ObjectStorageFolderCreateResult, { prefix: string }>('/object-storage/folders', {
|
||||
prefix,
|
||||
})
|
||||
return http.post<ObjectStorageFolderCreateResult, { prefix: string }>(
|
||||
'/object-storage/folders',
|
||||
{
|
||||
prefix,
|
||||
},
|
||||
)
|
||||
},
|
||||
deleteFolder(prefix: string) {
|
||||
return http.delete<ObjectStorageFolderDeleteResult>(
|
||||
|
||||
@@ -81,7 +81,12 @@ export const useAuthStore = defineStore('ops-auth', () => {
|
||||
async function buildSecureLoginPayload(
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<{ username: string; password?: string; encrypted_password?: string; password_key_id?: string }> {
|
||||
): Promise<{
|
||||
username: string
|
||||
password?: string
|
||||
encrypted_password?: string
|
||||
password_key_id?: string
|
||||
}> {
|
||||
if (!browserSupportsPasswordCipher()) {
|
||||
return { username, password }
|
||||
}
|
||||
|
||||
@@ -641,7 +641,9 @@ async function resetLoginLock(row: AdminUserRow) {
|
||||
} else if (result.fallback_deleted_keys > 0) {
|
||||
message.success(`登录锁定已清除,已清理 ${result.fallback_deleted_keys} 个本机保护状态`)
|
||||
} else {
|
||||
message.warning('未发现登录锁 Redis key,请检查 ops-api 与 admin-web 使用的 Redis 地址和 DB 是否一致')
|
||||
message.warning(
|
||||
'未发现登录锁 Redis key,请检查 ops-api 与 admin-web 使用的 Redis 地址和 DB 是否一致',
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
showOpsError(error)
|
||||
|
||||
@@ -283,18 +283,16 @@
|
||||
/>
|
||||
</a-form-item>
|
||||
</template>
|
||||
<a-form-item v-else-if="form.updater_download_source === 'custom'" label="更新包自定义地址">
|
||||
<a-form-item
|
||||
v-else-if="form.updater_download_source === 'custom'"
|
||||
label="更新包自定义地址"
|
||||
>
|
||||
<a-input
|
||||
v-model:value="form.updater_custom_download_url"
|
||||
placeholder="https://download.example.com/desktop/app.zip"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-alert
|
||||
v-else
|
||||
type="info"
|
||||
show-icon
|
||||
message="复用官网安装包"
|
||||
/>
|
||||
<a-alert v-else type="info" show-icon message="复用官网安装包" />
|
||||
|
||||
<div v-if="form.updater_download_source !== ''" class="form-grid">
|
||||
<a-form-item label="更新包文件名">
|
||||
@@ -347,6 +345,7 @@ import { message } from 'ant-design-vue'
|
||||
import dayjs from 'dayjs'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
|
||||
import { copyTextToClipboard } from '@/lib/clipboard'
|
||||
import {
|
||||
archOptions,
|
||||
desktopClientReleaseApi,
|
||||
@@ -357,7 +356,6 @@ import {
|
||||
type DesktopReleasePlatform,
|
||||
type DesktopReleaseSource,
|
||||
} from '@/lib/desktop-client-releases'
|
||||
import { copyTextToClipboard } from '@/lib/clipboard'
|
||||
import { showOpsError } from '@/lib/errors'
|
||||
|
||||
const enabledOptions = [
|
||||
@@ -620,7 +618,8 @@ async function beforeUploadReleasePackage(file: File, kind: 'installer' | 'updat
|
||||
function uploadButtonText(kind: 'installer' | 'updater') {
|
||||
const isUploading = kind === 'installer' ? uploading.value : updaterUploading.value
|
||||
const progress = kind === 'installer' ? uploadProgress.value : updaterUploadProgress.value
|
||||
const hasUploaded = kind === 'installer' ? Boolean(form.oss_object_key) : Boolean(form.updater_oss_object_key)
|
||||
const hasUploaded =
|
||||
kind === 'installer' ? Boolean(form.oss_object_key) : Boolean(form.updater_oss_object_key)
|
||||
if (isUploading && typeof progress === 'number' && progress < 100) {
|
||||
return `上传中 ${progress}%`
|
||||
}
|
||||
@@ -749,10 +748,7 @@ function updaterSourceText(row: DesktopClientRelease): string {
|
||||
return row.updater_download_source === 'oss' ? 'OSS' : '自定义'
|
||||
}
|
||||
|
||||
function assetTargetText(
|
||||
row: DesktopClientRelease,
|
||||
kind: 'installer' | 'updater',
|
||||
): string {
|
||||
function assetTargetText(row: DesktopClientRelease, kind: 'installer' | 'updater'): string {
|
||||
if (kind === 'installer' || !row.updater_download_source) {
|
||||
return row.download_source === 'oss'
|
||||
? (row.oss_object_key ?? '未配置')
|
||||
|
||||
@@ -43,9 +43,26 @@
|
||||
placeholder="阶段"
|
||||
@change="resetAndReload"
|
||||
/>
|
||||
<a-input v-model:value="filter.kind" placeholder="类型" style="width: 160px" allow-clear @press-enter="resetAndReload" />
|
||||
<a-input v-model:value="filter.tenantId" placeholder="租户 ID" style="width: 120px" allow-clear @press-enter="resetAndReload" />
|
||||
<a-range-picker v-model:value="dateRange" show-time style="width: 360px" @change="resetAndReload" />
|
||||
<a-input
|
||||
v-model:value="filter.kind"
|
||||
placeholder="类型"
|
||||
style="width: 160px"
|
||||
allow-clear
|
||||
@press-enter="resetAndReload"
|
||||
/>
|
||||
<a-input
|
||||
v-model:value="filter.tenantId"
|
||||
placeholder="租户 ID"
|
||||
style="width: 120px"
|
||||
allow-clear
|
||||
@press-enter="resetAndReload"
|
||||
/>
|
||||
<a-range-picker
|
||||
v-model:value="dateRange"
|
||||
show-time
|
||||
style="width: 360px"
|
||||
@change="resetAndReload"
|
||||
/>
|
||||
<a-button @click="resetAndReload">查询</a-button>
|
||||
</div>
|
||||
|
||||
@@ -78,7 +95,9 @@
|
||||
</template>
|
||||
<template v-else-if="column.key === 'tenant'">
|
||||
<div>{{ record.tenant_name || '—' }}</div>
|
||||
<div class="muted">T{{ record.tenant_id || '—' }} / W{{ record.workspace_id || '—' }}</div>
|
||||
<div class="muted">
|
||||
T{{ record.tenant_id || '—' }} / W{{ record.workspace_id || '—' }}
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'worker'">
|
||||
<div class="mono-line">{{ record.worker || record.queue || '—' }}</div>
|
||||
@@ -99,15 +118,33 @@
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip title="重试">
|
||||
<a-popconfirm title="确认重试该任务?" ok-text="重试" cancel-text="取消" @confirm="retryJob(record)">
|
||||
<a-button type="text" class="action-btn action-play" :disabled="!record.can_retry">
|
||||
<a-popconfirm
|
||||
title="确认重试该任务?"
|
||||
ok-text="重试"
|
||||
cancel-text="取消"
|
||||
@confirm="retryJob(record)"
|
||||
>
|
||||
<a-button
|
||||
type="text"
|
||||
class="action-btn action-play"
|
||||
:disabled="!record.can_retry"
|
||||
>
|
||||
<ReloadOutlined />
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
</a-tooltip>
|
||||
<a-tooltip title="取消">
|
||||
<a-popconfirm title="确认取消该任务?" ok-text="取消任务" cancel-text="关闭" @confirm="cancelJob(record)">
|
||||
<a-button type="text" class="action-btn action-delete" :disabled="!record.can_cancel">
|
||||
<a-popconfirm
|
||||
title="确认取消该任务?"
|
||||
ok-text="取消任务"
|
||||
cancel-text="关闭"
|
||||
@confirm="cancelJob(record)"
|
||||
>
|
||||
<a-button
|
||||
type="text"
|
||||
class="action-btn action-delete"
|
||||
:disabled="!record.can_cancel"
|
||||
>
|
||||
<StopOutlined />
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
@@ -122,24 +159,43 @@
|
||||
<a-spin :spinning="detailLoading">
|
||||
<div v-if="selectedDetail" class="detail-stack">
|
||||
<a-descriptions :column="2" size="small" bordered>
|
||||
<a-descriptions-item label="来源">{{ sourceLabel(selectedDetail.source) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="来源">
|
||||
{{ sourceLabel(selectedDetail.source) }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="状态">
|
||||
<a-tag :color="phaseColor(selectedDetail.phase)">{{ phaseLabel(selectedDetail.phase) }}</a-tag>
|
||||
<a-tag :color="phaseColor(selectedDetail.phase)">
|
||||
{{ phaseLabel(selectedDetail.phase) }}
|
||||
</a-tag>
|
||||
{{ selectedDetail.status }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="任务 ID">
|
||||
<code>{{ selectedDetail.id }}</code>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="类型">{{ selectedDetail.kind }}</a-descriptions-item>
|
||||
<a-descriptions-item label="租户">{{ selectedDetail.tenant_name || selectedDetail.tenant_id || '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="Workspace">{{ selectedDetail.workspace_name || selectedDetail.workspace_id || '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="队列">{{ selectedDetail.queue || '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="Worker">{{ selectedDetail.worker || '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="创建">{{ formatDate(selectedDetail.created_at) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="更新">{{ formatDate(selectedDetail.updated_at) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="租户">
|
||||
{{ selectedDetail.tenant_name || selectedDetail.tenant_id || '—' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="Workspace">
|
||||
{{ selectedDetail.workspace_name || selectedDetail.workspace_id || '—' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="队列">
|
||||
{{ selectedDetail.queue || '—' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="Worker">
|
||||
{{ selectedDetail.worker || '—' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="创建">
|
||||
{{ formatDate(selectedDetail.created_at) }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="更新">
|
||||
{{ formatDate(selectedDetail.updated_at) }}
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<section v-if="selectedDetail.error_message || selectedDetail.error" class="detail-section">
|
||||
<section
|
||||
v-if="selectedDetail.error_message || selectedDetail.error"
|
||||
class="detail-section"
|
||||
>
|
||||
<h3>错误</h3>
|
||||
<pre>{{ formatJSON(selectedDetail.error || selectedDetail.error_message) }}</pre>
|
||||
</section>
|
||||
@@ -342,7 +398,9 @@ async function openDetail(row: JobSummary) {
|
||||
detailLoading.value = true
|
||||
selectedDetail.value = null
|
||||
try {
|
||||
selectedDetail.value = await http.get<JobDetail>(`/jobs/${row.source}/${encodeURIComponent(row.id)}`)
|
||||
selectedDetail.value = await http.get<JobDetail>(
|
||||
`/jobs/${row.source}/${encodeURIComponent(row.id)}`,
|
||||
)
|
||||
} catch (error) {
|
||||
showError(error)
|
||||
} finally {
|
||||
@@ -352,7 +410,9 @@ async function openDetail(row: JobSummary) {
|
||||
|
||||
async function retryJob(row: JobSummary) {
|
||||
try {
|
||||
const item = await http.post<JobDetail>(`/jobs/${row.source}/${encodeURIComponent(row.id)}/retry`)
|
||||
const item = await http.post<JobDetail>(
|
||||
`/jobs/${row.source}/${encodeURIComponent(row.id)}/retry`,
|
||||
)
|
||||
message.success('已提交重试')
|
||||
patchRow(item)
|
||||
if (selectedDetail.value?.uid === item.uid) selectedDetail.value = item
|
||||
@@ -363,9 +423,12 @@ async function retryJob(row: JobSummary) {
|
||||
|
||||
async function cancelJob(row: JobSummary) {
|
||||
try {
|
||||
const item = await http.post<JobDetail>(`/jobs/${row.source}/${encodeURIComponent(row.id)}/cancel`, {
|
||||
reason: 'ops console cancel',
|
||||
})
|
||||
const item = await http.post<JobDetail>(
|
||||
`/jobs/${row.source}/${encodeURIComponent(row.id)}/cancel`,
|
||||
{
|
||||
reason: 'ops console cancel',
|
||||
},
|
||||
)
|
||||
message.success('已取消任务')
|
||||
patchRow(item)
|
||||
if (selectedDetail.value?.uid === item.uid) selectedDetail.value = item
|
||||
|
||||
@@ -7,7 +7,11 @@
|
||||
}"
|
||||
>
|
||||
<div class="brand">
|
||||
<img class="brand-logo brand-logo--white" src="/logos/icon-with-title.svg" alt="省心推运营控制台" />
|
||||
<img
|
||||
class="brand-logo brand-logo--white"
|
||||
src="/logos/icon-with-title.svg"
|
||||
alt="省心推运营控制台"
|
||||
/>
|
||||
<span class="brand-badge brand-badge--white">运营端</span>
|
||||
</div>
|
||||
<div class="characters-area">
|
||||
@@ -29,7 +33,11 @@
|
||||
<div class="right">
|
||||
<div class="form-wrapper">
|
||||
<div class="mobile-brand">
|
||||
<img class="brand-logo brand-logo--mobile" src="/logos/icon-with-title.svg" alt="省心推运营控制台" />
|
||||
<img
|
||||
class="brand-logo brand-logo--mobile"
|
||||
src="/logos/icon-with-title.svg"
|
||||
alt="省心推运营控制台"
|
||||
/>
|
||||
<span class="brand-badge">运营端</span>
|
||||
</div>
|
||||
<div class="header">
|
||||
|
||||
@@ -93,7 +93,9 @@
|
||||
<strong v-else>{{ record.name }}</strong>
|
||||
<div class="resource-sub">
|
||||
<code>{{ record.supplier_resource_id }}</code>
|
||||
<a-tag :color="statusColor(record.status)">{{ statusLabel(record.status) }}</a-tag>
|
||||
<a-tag :color="statusColor(record.status)">
|
||||
{{ statusLabel(record.status) }}
|
||||
</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -104,7 +106,11 @@
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'margin'">
|
||||
<span :class="record.sell_price_cents <= record.cost_price_cents ? 'margin-risk' : 'margin-ok'">
|
||||
<span
|
||||
:class="
|
||||
record.sell_price_cents <= record.cost_price_cents ? 'margin-risk' : 'margin-ok'
|
||||
"
|
||||
>
|
||||
{{ formatMoney(record.sell_price_cents - record.cost_price_cents) }}
|
||||
</span>
|
||||
</template>
|
||||
@@ -117,7 +123,10 @@
|
||||
checked-children="显示"
|
||||
un-checked-children="隐藏"
|
||||
:loading="visibilityLoadingId === record.id"
|
||||
@change="(checked: boolean | string | number) => updateVisibility(record, Boolean(checked))"
|
||||
@change="
|
||||
(checked: boolean | string | number) =>
|
||||
updateVisibility(record, Boolean(checked))
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'remark'">
|
||||
@@ -131,7 +140,11 @@
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<div class="table-actions-row">
|
||||
<a-tooltip title="调整售价">
|
||||
<a-button type="text" class="action-btn action-edit" @click="openPriceModal(record)">
|
||||
<a-button
|
||||
type="text"
|
||||
class="action-btn action-edit"
|
||||
@click="openPriceModal(record)"
|
||||
>
|
||||
<EditOutlined />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
@@ -175,7 +188,9 @@
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'user'">
|
||||
<div class="resource-main">
|
||||
<strong>{{ record.user_name || record.user_phone || `用户 ${record.user_id}` }}</strong>
|
||||
<strong>
|
||||
{{ record.user_name || record.user_phone || `用户 ${record.user_id}` }}
|
||||
</strong>
|
||||
<div class="resource-sub">
|
||||
<code>U{{ record.user_id }}</code>
|
||||
<span>{{ record.user_phone || '未绑定手机' }}</span>
|
||||
@@ -191,7 +206,9 @@
|
||||
</template>
|
||||
<template v-else-if="column.key === 'ledger'">
|
||||
<div>{{ record.ledger_entries }} 条</div>
|
||||
<div class="muted">{{ record.last_ledger_at ? formatDate(record.last_ledger_at) : '暂无账单' }}</div>
|
||||
<div class="muted">
|
||||
{{ record.last_ledger_at ? formatDate(record.last_ledger_at) : '暂无账单' }}
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'updated_at'">
|
||||
{{ formatDate(record.updated_at) }}
|
||||
@@ -215,8 +232,12 @@
|
||||
<div v-if="priceTarget" class="edit-stack">
|
||||
<a-descriptions :column="1" size="small" bordered>
|
||||
<a-descriptions-item label="资源">{{ priceTarget.name }}</a-descriptions-item>
|
||||
<a-descriptions-item label="成本">{{ formatMoney(priceTarget.cost_price_cents) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="当前售价">{{ formatSellPrice(priceTarget.sell_price_cents) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="成本">
|
||||
{{ formatMoney(priceTarget.cost_price_cents) }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="当前售价">
|
||||
{{ formatSellPrice(priceTarget.sell_price_cents) }}
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="平台售价">
|
||||
@@ -235,16 +256,26 @@
|
||||
>
|
||||
<div v-if="walletTarget" class="edit-stack">
|
||||
<a-descriptions :column="1" size="small" bordered>
|
||||
<a-descriptions-item label="客户">{{ walletTarget.user_name || walletTarget.user_phone || walletTarget.user_id }}</a-descriptions-item>
|
||||
<a-descriptions-item label="租户">{{ walletTarget.tenant_name || walletTarget.tenant_id }}</a-descriptions-item>
|
||||
<a-descriptions-item label="当前余额">{{ formatMoney(walletTarget.balance_cents) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="客户">
|
||||
{{ walletTarget.user_name || walletTarget.user_phone || walletTarget.user_id }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="租户">
|
||||
{{ walletTarget.tenant_name || walletTarget.tenant_id }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="当前余额">
|
||||
{{ formatMoney(walletTarget.balance_cents) }}
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="调整金额">
|
||||
<a-input v-model:value="walletForm.yuan" prefix="¥" placeholder="正数充值,负数扣减" />
|
||||
</a-form-item>
|
||||
<a-form-item label="备注">
|
||||
<a-textarea v-model:value="walletForm.note" :rows="3" placeholder="账单备注,客户可见" />
|
||||
<a-textarea
|
||||
v-model:value="walletForm.note"
|
||||
:rows="3"
|
||||
placeholder="账单备注,客户可见"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
|
||||
@@ -29,7 +29,8 @@
|
||||
<div class="metric-content">
|
||||
<div class="metric-label">子文件夹</div>
|
||||
<div class="metric-value">
|
||||
{{ folderRows.length }} <span class="metric-unit">个</span>
|
||||
{{ folderRows.length }}
|
||||
<span class="metric-unit">个</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -38,7 +39,8 @@
|
||||
<div class="metric-content">
|
||||
<div class="metric-label">对象文件数</div>
|
||||
<div class="metric-value">
|
||||
{{ rows.length }} <span class="metric-unit">个</span>
|
||||
{{ rows.length }}
|
||||
<span class="metric-unit">个</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -68,7 +70,7 @@
|
||||
{{ part.name }}
|
||||
</a-button>
|
||||
</template>
|
||||
|
||||
|
||||
<div class="path-actions">
|
||||
<a-tooltip title="复制当前目录路径">
|
||||
<a-button type="text" size="small" class="path-action-btn" @click="copyCurrentPath">
|
||||
@@ -82,7 +84,7 @@
|
||||
</a-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div v-else class="breadcrumb-edit-container">
|
||||
<span class="edit-prefix-label">路径编辑:</span>
|
||||
<a-input
|
||||
@@ -102,9 +104,7 @@
|
||||
<a-button type="primary" size="small" class="path-save-btn" @click="saveEditedPath">
|
||||
跳转
|
||||
</a-button>
|
||||
<a-button size="small" class="path-cancel-btn" @click="cancelEditedPath">
|
||||
取消
|
||||
</a-button>
|
||||
<a-button size="small" class="path-cancel-btn" @click="cancelEditedPath">取消</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
</div>
|
||||
@@ -123,14 +123,18 @@
|
||||
>
|
||||
<template #prefix><SearchOutlined class="search-icon" /></template>
|
||||
</a-input>
|
||||
|
||||
|
||||
<a-tooltip title="勾选后将检索当前目录下所有层级的子文件;未勾选则仅展示当前一层。">
|
||||
<a-checkbox v-model:checked="filter.recursive" @change="resetAndReload" class="oss-recursive-checkbox">
|
||||
<a-checkbox
|
||||
v-model:checked="filter.recursive"
|
||||
@change="resetAndReload"
|
||||
class="oss-recursive-checkbox"
|
||||
>
|
||||
递归搜索所有子目录
|
||||
</a-checkbox>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="toolbar-right">
|
||||
<a-space wrap>
|
||||
<a-button @click="openCreateFolder" class="toolbar-secondary-btn">
|
||||
@@ -150,7 +154,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Sliding Floating Batch Actions Panel -->
|
||||
<div :class="['batch-ops-panel', { 'show': selectedRowKeys.length > 0 }]">
|
||||
<div :class="['batch-ops-panel', { show: selectedRowKeys.length > 0 }]">
|
||||
<div class="batch-info">
|
||||
<span class="selected-badge">{{ selectedRowKeys.length }}</span>
|
||||
<span class="selected-text">项已选择</span>
|
||||
@@ -177,11 +181,19 @@
|
||||
<template #icon><DownloadOutlined /></template>
|
||||
批量下载
|
||||
</a-button>
|
||||
<a-button type="text" danger size="small" class="batch-action-btn-styled batch-delete-btn" @click="confirmBulkDelete">
|
||||
<a-button
|
||||
type="text"
|
||||
danger
|
||||
size="small"
|
||||
class="batch-action-btn-styled batch-delete-btn"
|
||||
@click="confirmBulkDelete"
|
||||
>
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
批量删除
|
||||
</a-button>
|
||||
<a-button size="small" type="link" class="batch-cancel-link" @click="clearSelection">取消选择</a-button>
|
||||
<a-button size="small" type="link" class="batch-cancel-link" @click="clearSelection">
|
||||
取消选择
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
@@ -200,13 +212,17 @@
|
||||
<!-- Column 1: Object Key / Folder Name -->
|
||||
<template v-if="column.key === 'key'">
|
||||
<!-- Folder representation -->
|
||||
<button v-if="record.kind === 'folder'" class="key-button folder-entry-btn" @click="enterFolder(record.key)">
|
||||
<button
|
||||
v-if="record.kind === 'folder'"
|
||||
class="key-button folder-entry-btn"
|
||||
@click="enterFolder(record.key)"
|
||||
>
|
||||
<div class="icon-wrapper folder-icon">
|
||||
<FolderOutlined />
|
||||
</div>
|
||||
<span class="folder-name-text">{{ folderName(record.key) }}</span>
|
||||
</button>
|
||||
|
||||
|
||||
<!-- File representation -->
|
||||
<div v-else class="object-key-cell">
|
||||
<div :class="['icon-wrapper', `file-icon-${getFileIconType(record.key)}`]">
|
||||
@@ -219,7 +235,9 @@
|
||||
<FileOutlined v-else />
|
||||
</div>
|
||||
<div class="file-name-info">
|
||||
<strong class="file-name-title" :title="fileName(record.key)">{{ fileName(record.key) }}</strong>
|
||||
<strong class="file-name-title" :title="fileName(record.key)">
|
||||
{{ fileName(record.key) }}
|
||||
</strong>
|
||||
<span class="file-full-key" :title="record.key">{{ record.key }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -234,7 +252,11 @@
|
||||
<!-- Column 3: Storage Type / Class -->
|
||||
<template v-else-if="column.key === 'type'">
|
||||
<span v-if="record.kind === 'folder'" class="folder-type-tag">目录</span>
|
||||
<a-tag v-else :color="getTagColor(record.content_type || record.storage_class)" class="oss-type-tag">
|
||||
<a-tag
|
||||
v-else
|
||||
:color="getTagColor(record.content_type || record.storage_class)"
|
||||
class="oss-type-tag"
|
||||
>
|
||||
{{ record.content_type || record.storage_class || 'object' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
@@ -249,7 +271,13 @@
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<div v-if="record.kind === 'object'" class="table-actions-row">
|
||||
<a-tooltip title="复制访问地址">
|
||||
<a-button type="text" shape="circle" size="small" class="action-btn action-btn-primary" @click="copyURL(record.key)">
|
||||
<a-button
|
||||
type="text"
|
||||
shape="circle"
|
||||
size="small"
|
||||
class="action-btn action-btn-primary"
|
||||
@click="copyURL(record.key)"
|
||||
>
|
||||
<template #icon><CopyOutlined /></template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
@@ -265,9 +293,14 @@
|
||||
<template #icon><DownloadOutlined /></template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
|
||||
|
||||
<a-dropdown :trigger="['click']" placement="bottomRight">
|
||||
<a-button type="text" shape="circle" size="small" class="action-btn action-btn-more">
|
||||
<a-button
|
||||
type="text"
|
||||
shape="circle"
|
||||
size="small"
|
||||
class="action-btn action-btn-more"
|
||||
>
|
||||
<template #icon><EllipsisOutlined /></template>
|
||||
</a-button>
|
||||
<template #overlay>
|
||||
@@ -291,7 +324,7 @@
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Folder actions -->
|
||||
<div v-else class="table-actions-row">
|
||||
<a-tooltip title="删除目录">
|
||||
@@ -357,7 +390,12 @@
|
||||
<a-descriptions bordered :column="1" size="small" class="detail-desc-list">
|
||||
<a-descriptions-item label="Object Key">
|
||||
<span class="detail-key-text">{{ detail.key }}</span>
|
||||
<a-button type="link" size="small" class="copy-inline-btn" @click="copyText(detail.key)">
|
||||
<a-button
|
||||
type="link"
|
||||
size="small"
|
||||
class="copy-inline-btn"
|
||||
@click="copyText(detail.key)"
|
||||
>
|
||||
复制
|
||||
</a-button>
|
||||
</a-descriptions-item>
|
||||
@@ -376,8 +414,15 @@
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="公网访问地址">
|
||||
<div v-if="detail.public_url" class="url-detail-block">
|
||||
<a :href="detail.public_url" target="_blank" class="detail-url-link">{{ detail.public_url }}</a>
|
||||
<a-button type="primary" size="small" class="copy-url-btn" @click="copyText(detail.public_url)">
|
||||
<a :href="detail.public_url" target="_blank" class="detail-url-link">
|
||||
{{ detail.public_url }}
|
||||
</a>
|
||||
<a-button
|
||||
type="primary"
|
||||
size="small"
|
||||
class="copy-url-btn"
|
||||
@click="copyText(detail.public_url)"
|
||||
>
|
||||
复制公网URL
|
||||
</a-button>
|
||||
</div>
|
||||
@@ -397,10 +442,17 @@
|
||||
class="oss-upload-drawer"
|
||||
>
|
||||
<a-form layout="vertical" :model="uploadForm" class="upload-drawer-form">
|
||||
<a-form-item label="目标 Object Key / 目录路径" required extra="默认采用当前面包屑所在目录。如果包含新子目录斜杠(如 images/test.png),将自动创建多级虚拟文件夹。">
|
||||
<a-input v-model:value="uploadForm.key" placeholder="例如: images/example.png 或 example.png" />
|
||||
<a-form-item
|
||||
label="目标 Object Key / 目录路径"
|
||||
required
|
||||
extra="默认采用当前面包屑所在目录。如果包含新子目录斜杠(如 images/test.png),将自动创建多级虚拟文件夹。"
|
||||
>
|
||||
<a-input
|
||||
v-model:value="uploadForm.key"
|
||||
placeholder="例如: images/example.png 或 example.png"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
|
||||
<a-form-item label="拖拽或选择文件" required>
|
||||
<a-upload-dragger
|
||||
:before-upload="beforeUpload"
|
||||
@@ -417,17 +469,30 @@
|
||||
<!-- File Chosen Card -->
|
||||
<transition name="fade">
|
||||
<div v-if="uploadForm.file" class="file-picked-card">
|
||||
<div :class="['file-picked-icon-wrapper', `file-icon-${getFileIconType(uploadForm.file.name)}`]">
|
||||
<div
|
||||
:class="[
|
||||
'file-picked-icon-wrapper',
|
||||
`file-icon-${getFileIconType(uploadForm.file.name)}`,
|
||||
]"
|
||||
>
|
||||
<FileZipOutlined v-if="getFileIconType(uploadForm.file.name) === 'zip'" />
|
||||
<FileImageOutlined v-else-if="getFileIconType(uploadForm.file.name) === 'image'" />
|
||||
<FilePdfOutlined v-else-if="getFileIconType(uploadForm.file.name) === 'pdf'" />
|
||||
<FileOutlined v-else />
|
||||
</div>
|
||||
<div class="file-picked-info">
|
||||
<strong class="file-picked-name" :title="uploadForm.file.name">{{ uploadForm.file.name }}</strong>
|
||||
<strong class="file-picked-name" :title="uploadForm.file.name">
|
||||
{{ uploadForm.file.name }}
|
||||
</strong>
|
||||
<span class="file-picked-size">{{ formatBytes(uploadForm.file.size) }}</span>
|
||||
</div>
|
||||
<a-button type="text" danger size="small" class="file-picked-remove" @click="uploadForm.file = null">
|
||||
<a-button
|
||||
type="text"
|
||||
danger
|
||||
size="small"
|
||||
class="file-picked-remove"
|
||||
@click="uploadForm.file = null"
|
||||
>
|
||||
清除
|
||||
</a-button>
|
||||
</div>
|
||||
@@ -437,7 +502,10 @@
|
||||
<a-collapse ghost class="upload-advanced-collapse">
|
||||
<a-collapse-panel key="advanced" header="高级选项设置">
|
||||
<a-form-item label="手动指定 Content-Type (可选)">
|
||||
<a-input v-model:value="uploadForm.content_type" placeholder="例如: image/png,留空将由系统自动识别" />
|
||||
<a-input
|
||||
v-model:value="uploadForm.content_type"
|
||||
placeholder="例如: image/png,留空将由系统自动识别"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item class="checkbox-form-item">
|
||||
<a-checkbox v-model:checked="uploadForm.overwrite">允许直接覆盖同名对象</a-checkbox>
|
||||
@@ -449,7 +517,12 @@
|
||||
<template #footer>
|
||||
<div class="drawer-footer">
|
||||
<a-button @click="closeUpload" class="footer-btn-secondary">取消</a-button>
|
||||
<a-button type="primary" :loading="uploading" @click="submitUpload" class="footer-btn-primary">
|
||||
<a-button
|
||||
type="primary"
|
||||
:loading="uploading"
|
||||
@click="submitUpload"
|
||||
class="footer-btn-primary"
|
||||
>
|
||||
开始上传
|
||||
</a-button>
|
||||
</div>
|
||||
@@ -467,8 +540,16 @@
|
||||
class="oss-create-folder-modal"
|
||||
>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="文件夹名称" required extra="会在 OSS 中真实创建一个以 / 结尾的空目录对象,创建后可立即在当前目录看到。">
|
||||
<a-input v-model:value="newFolderName" placeholder="例如: releases 或 images" allow-clear />
|
||||
<a-form-item
|
||||
label="文件夹名称"
|
||||
required
|
||||
extra="会在 OSS 中真实创建一个以 / 结尾的空目录对象,创建后可立即在当前目录看到。"
|
||||
>
|
||||
<a-input
|
||||
v-model:value="newFolderName"
|
||||
placeholder="例如: releases 或 images"
|
||||
allow-clear
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
@@ -669,8 +750,12 @@ function jumpToPath(path: string) {
|
||||
// Row selection & Batch operations
|
||||
const selectedRowKeys = ref<string[]>([])
|
||||
const selectedRows = ref<TableRow[]>([])
|
||||
const selectedObjectCount = computed(() => selectedRows.value.filter((row) => row.kind === 'object').length)
|
||||
const selectedFolderCount = computed(() => selectedRows.value.filter((row) => row.kind === 'folder').length)
|
||||
const selectedObjectCount = computed(
|
||||
() => selectedRows.value.filter((row) => row.kind === 'object').length,
|
||||
)
|
||||
const selectedFolderCount = computed(
|
||||
() => selectedRows.value.filter((row) => row.kind === 'folder').length,
|
||||
)
|
||||
const batchDeleteDescription = computed(() => {
|
||||
if (selectedFolderCount.value > 0) {
|
||||
return '选中的目录会按前缀递归删除其下所有文件和空目录占位对象,删除后无法恢复。'
|
||||
@@ -716,7 +801,10 @@ async function mapWithConcurrency<T, R>(
|
||||
}
|
||||
|
||||
function formatKeyList(keys: string[]): string {
|
||||
const visible = keys.slice(0, 3).map((key) => fileName(key)).join('、')
|
||||
const visible = keys
|
||||
.slice(0, 3)
|
||||
.map((key) => fileName(key))
|
||||
.join('、')
|
||||
return keys.length > 3 ? `${visible} 等 ${keys.length} 个` : visible
|
||||
}
|
||||
|
||||
@@ -758,12 +846,10 @@ async function copyText(text: string) {
|
||||
|
||||
// Bulk Actions Implementation
|
||||
async function bulkCopyURLs() {
|
||||
const keys = selectedRows.value
|
||||
.filter((row) => row.kind === 'object')
|
||||
.map((row) => row.key)
|
||||
|
||||
const keys = selectedRows.value.filter((row) => row.kind === 'object').map((row) => row.key)
|
||||
|
||||
if (keys.length === 0) return
|
||||
|
||||
|
||||
try {
|
||||
const results = await mapWithConcurrency(keys, bulkConcurrency, async (key) => {
|
||||
const result = await objectStorageApi.resolveDownloadURL(key)
|
||||
@@ -776,7 +862,9 @@ async function bulkCopyURLs() {
|
||||
}
|
||||
await navigator.clipboard.writeText(urls.join('\n'))
|
||||
if (failures.length > 0) {
|
||||
message.warning(`已复制 ${urls.length} 个访问地址,失败 ${failures.length} 个:${formatKeyList(failures)}`)
|
||||
message.warning(
|
||||
`已复制 ${urls.length} 个访问地址,失败 ${failures.length} 个:${formatKeyList(failures)}`,
|
||||
)
|
||||
} else {
|
||||
message.success(`已复制全部 ${urls.length} 个对象的访问地址到剪贴板`)
|
||||
}
|
||||
@@ -787,13 +875,11 @@ async function bulkCopyURLs() {
|
||||
}
|
||||
|
||||
async function bulkDownload() {
|
||||
const keys = selectedRows.value
|
||||
.filter((row) => row.kind === 'object')
|
||||
.map((row) => row.key)
|
||||
|
||||
const keys = selectedRows.value.filter((row) => row.kind === 'object').map((row) => row.key)
|
||||
|
||||
if (keys.length === 0) return
|
||||
message.info(`正在交给浏览器下载 ${keys.length} 个文件...`)
|
||||
|
||||
|
||||
let successCount = 0
|
||||
const failures: string[] = []
|
||||
try {
|
||||
@@ -814,9 +900,11 @@ async function bulkDownload() {
|
||||
failures.push(item.key)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (failures.length > 0) {
|
||||
message.warning(`批量下载已提交 ${successCount} 个,失败 ${failures.length} 个:${formatKeyList(failures)}`)
|
||||
message.warning(
|
||||
`批量下载已提交 ${successCount} 个,失败 ${failures.length} 个:${formatKeyList(failures)}`,
|
||||
)
|
||||
} else {
|
||||
message.success('批量下载任务已就绪')
|
||||
}
|
||||
@@ -835,9 +923,9 @@ async function bulkDelete() {
|
||||
.filter((row) => row.kind === 'object')
|
||||
.map((row) => row.key)
|
||||
.filter((key) => !folderPrefixes.some((prefix) => key.startsWith(prefix)))
|
||||
|
||||
|
||||
if (folderPrefixes.length === 0 && objectKeys.length === 0) return
|
||||
|
||||
|
||||
let shouldReload = false
|
||||
try {
|
||||
loading.value = true
|
||||
@@ -860,11 +948,13 @@ async function bulkDelete() {
|
||||
const { values, failures: failedTasks } = splitBulkResults(results)
|
||||
const successCount = values.length
|
||||
const failures = failedTasks.map((item) => item.key)
|
||||
|
||||
|
||||
if (failures.length === 0) {
|
||||
message.success(`成功删除选中的全部 ${successCount} 项`)
|
||||
} else {
|
||||
message.warning(`批量删除完毕:成功 ${successCount} 个,失败 ${failures.length} 个:${formatKeyList(failures)}`)
|
||||
message.warning(
|
||||
`批量删除完毕:成功 ${successCount} 个,失败 ${failures.length} 个:${formatKeyList(failures)}`,
|
||||
)
|
||||
}
|
||||
shouldReload = successCount > 0
|
||||
closeBatchDelete()
|
||||
@@ -928,7 +1018,7 @@ async function submitCreateFolder() {
|
||||
message.warning('文件夹名称不能包含层级斜杠 /')
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
const newPath = currentPrefix.value ? `${currentPrefix.value}${name}/` : `${name}/`
|
||||
creatingFolder.value = true
|
||||
try {
|
||||
@@ -979,7 +1069,8 @@ function getFileIconType(key: string): 'zip' | 'image' | 'pdf' | 'doc' | 'excel'
|
||||
if (['zip', 'rar', 'tar', 'gz', '7z'].includes(ext)) return 'zip'
|
||||
if (['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'bmp', 'ico'].includes(ext)) return 'image'
|
||||
if (['pdf'].includes(ext)) return 'pdf'
|
||||
if (['txt', 'md', 'html', 'css', 'js', 'ts', 'vue', 'json', 'yaml', 'xml'].includes(ext)) return 'doc'
|
||||
if (['txt', 'md', 'html', 'css', 'js', 'ts', 'vue', 'json', 'yaml', 'xml'].includes(ext))
|
||||
return 'doc'
|
||||
if (['xls', 'xlsx', 'csv'].includes(ext)) return 'excel'
|
||||
if (['doc', 'docx'].includes(ext)) return 'word'
|
||||
return 'file'
|
||||
@@ -995,7 +1086,8 @@ function getTagColor(typeStr: string | null | undefined): string {
|
||||
if (lower.includes('image')) return 'green'
|
||||
if (lower.includes('zip') || lower.includes('compressed')) return 'purple'
|
||||
if (lower.includes('pdf')) return 'red'
|
||||
if (lower.includes('json') || lower.includes('javascript') || lower.includes('text')) return 'blue'
|
||||
if (lower.includes('json') || lower.includes('javascript') || lower.includes('text'))
|
||||
return 'blue'
|
||||
if (lower.includes('standard')) return 'geekblue'
|
||||
return 'default'
|
||||
}
|
||||
@@ -1636,7 +1728,7 @@ onMounted(() => {
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 100px;
|
||||
padding: 10px 24px;
|
||||
box-shadow:
|
||||
box-shadow:
|
||||
0 20px 25px -5px rgba(0, 0, 0, 0.3),
|
||||
0 8px 10px -6px rgba(0, 0, 0, 0.3),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
@@ -1924,7 +2016,8 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
@keyframes float-anim {
|
||||
0%, 100% {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
|
||||
@@ -78,7 +78,9 @@
|
||||
<div class="job-cell">
|
||||
<div class="job-title">
|
||||
<span>{{ record.display_name }}</span>
|
||||
<a-tag :color="categoryColor(record.category)">{{ categoryLabel(record.category) }}</a-tag>
|
||||
<a-tag :color="categoryColor(record.category)">
|
||||
{{ categoryLabel(record.category) }}
|
||||
</a-tag>
|
||||
</div>
|
||||
<code>{{ record.job_key }}</code>
|
||||
<div class="muted">{{ record.description || '—' }}</div>
|
||||
@@ -108,7 +110,9 @@
|
||||
</template>
|
||||
<template v-else-if="column.key === 'stats'">
|
||||
<div class="mono-line">{{ statsSummary(record.last_run?.stats) }}</div>
|
||||
<div v-if="record.pending_triggers" class="muted">待触发 {{ record.pending_triggers }}</div>
|
||||
<div v-if="record.pending_triggers" class="muted">
|
||||
待触发 {{ record.pending_triggers }}
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<div class="table-actions-row">
|
||||
@@ -118,14 +122,28 @@
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip title="立即运行">
|
||||
<a-popconfirm title="确认立即运行该调度任务?" ok-text="运行" cancel-text="取消" @confirm="triggerJob(record, 'run')">
|
||||
<a-button type="text" class="action-btn action-play" :loading="triggeringKey === `${record.job_key}:run`">
|
||||
<a-popconfirm
|
||||
title="确认立即运行该调度任务?"
|
||||
ok-text="运行"
|
||||
cancel-text="取消"
|
||||
@confirm="triggerJob(record, 'run')"
|
||||
>
|
||||
<a-button
|
||||
type="text"
|
||||
class="action-btn action-play"
|
||||
:loading="triggeringKey === `${record.job_key}:run`"
|
||||
>
|
||||
<PlayCircleOutlined />
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
</a-tooltip>
|
||||
<a-tooltip title="Dry-run">
|
||||
<a-button type="text" class="action-btn action-warn" :loading="triggeringKey === `${record.job_key}:dry-run`" @click="triggerJob(record, 'dry-run')">
|
||||
<a-button
|
||||
type="text"
|
||||
class="action-btn action-warn"
|
||||
:loading="triggeringKey === `${record.job_key}:dry-run`"
|
||||
@click="triggerJob(record, 'dry-run')"
|
||||
>
|
||||
<ExperimentOutlined />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
@@ -143,7 +161,11 @@
|
||||
<a-drawer v-model:open="editOpen" width="620" :title="editTitle" :destroy-on-close="true">
|
||||
<a-form v-if="editing" layout="vertical">
|
||||
<a-form-item label="启用状态">
|
||||
<a-switch v-model:checked="editForm.enabled" checked-children="启用" un-checked-children="暂停" />
|
||||
<a-switch
|
||||
v-model:checked="editForm.enabled"
|
||||
checked-children="启用"
|
||||
un-checked-children="暂停"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="调度类型">
|
||||
<a-segmented v-model:value="editForm.schedule_type" :options="scheduleTypeOptions" />
|
||||
@@ -152,23 +174,45 @@
|
||||
<a-input v-model:value="editForm.cron_expr" placeholder="例如 0 30 2 * * *" />
|
||||
</a-form-item>
|
||||
<a-form-item label="间隔秒数">
|
||||
<a-input-number v-model:value="editForm.interval_seconds" :min="1" :max="86400" style="width: 100%" />
|
||||
<a-input-number
|
||||
v-model:value="editForm.interval_seconds"
|
||||
:min="1"
|
||||
:max="86400"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="超时秒数">
|
||||
<a-input-number v-model:value="editForm.timeout_seconds" :min="1" :max="3600" style="width: 100%" />
|
||||
<a-input-number
|
||||
v-model:value="editForm.timeout_seconds"
|
||||
:min="1"
|
||||
:max="3600"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="批大小">
|
||||
<a-input-number v-model:value="editForm.batch_size" :min="1" :max="100000" style="width: 100%" />
|
||||
<a-input-number
|
||||
v-model:value="editForm.batch_size"
|
||||
:min="1"
|
||||
:max="100000"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="时区">
|
||||
<a-input v-model:value="editForm.timezone" />
|
||||
</a-form-item>
|
||||
<a-form-item label="高级配置 JSON">
|
||||
<a-textarea v-model:value="editConfigText" :rows="12" spellcheck="false" class="config-editor" />
|
||||
<a-textarea
|
||||
v-model:value="editConfigText"
|
||||
:rows="12"
|
||||
spellcheck="false"
|
||||
class="config-editor"
|
||||
/>
|
||||
</a-form-item>
|
||||
<div class="drawer-actions">
|
||||
<a-button @click="editOpen = false">取消</a-button>
|
||||
<a-button type="primary" :loading="savingKey === editing.job_key" @click="saveEdit">保存</a-button>
|
||||
<a-button type="primary" :loading="savingKey === editing.job_key" @click="saveEdit">
|
||||
保存
|
||||
</a-button>
|
||||
</div>
|
||||
</a-form>
|
||||
</a-drawer>
|
||||
@@ -185,7 +229,9 @@
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-tag :color="runStatusColor(record.status)">{{ runStatusLabel(record.status) }}</a-tag>
|
||||
<a-tag :color="runStatusColor(record.status)">
|
||||
{{ runStatusLabel(record.status) }}
|
||||
</a-tag>
|
||||
<div class="muted">{{ record.trigger_type }}</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'time'">
|
||||
@@ -202,8 +248,19 @@
|
||||
</a-table>
|
||||
</a-drawer>
|
||||
|
||||
<a-drawer v-model:open="instancesOpen" width="720" title="Scheduler 实例" :destroy-on-close="true">
|
||||
<a-table :columns="instanceColumns" :data-source="instances" :loading="instancesLoading" row-key="instance_id" :pagination="false">
|
||||
<a-drawer
|
||||
v-model:open="instancesOpen"
|
||||
width="720"
|
||||
title="Scheduler 实例"
|
||||
:destroy-on-close="true"
|
||||
>
|
||||
<a-table
|
||||
:columns="instanceColumns"
|
||||
:data-source="instances"
|
||||
:loading="instancesLoading"
|
||||
row-key="instance_id"
|
||||
:pagination="false"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'instance'">
|
||||
<div class="mono-line">{{ record.instance_id }}</div>
|
||||
@@ -378,9 +435,15 @@ const runPagination = computed<TablePaginationConfig>(() => ({
|
||||
|
||||
const enabledCount = computed(() => rows.value.filter((row) => row.enabled).length)
|
||||
const runningCount = computed(() => rows.value.filter((row) => row.running_run).length)
|
||||
const failedCount = computed(() => rows.value.filter((row) => row.last_run?.status === 'failed').length)
|
||||
const editTitle = computed(() => (editing.value ? `编辑 · ${editing.value.display_name}` : '编辑调度任务'))
|
||||
const runsTitle = computed(() => (runsJob.value ? `运行记录 · ${runsJob.value.display_name}` : '运行记录'))
|
||||
const failedCount = computed(
|
||||
() => rows.value.filter((row) => row.last_run?.status === 'failed').length,
|
||||
)
|
||||
const editTitle = computed(() =>
|
||||
editing.value ? `编辑 · ${editing.value.display_name}` : '编辑调度任务',
|
||||
)
|
||||
const runsTitle = computed(() =>
|
||||
runsJob.value ? `运行记录 · ${runsJob.value.display_name}` : '运行记录',
|
||||
)
|
||||
|
||||
async function reload() {
|
||||
loading.value = true
|
||||
@@ -494,10 +557,13 @@ async function loadRuns() {
|
||||
if (!runsJob.value) return
|
||||
runsLoading.value = true
|
||||
try {
|
||||
const result = await http.get<ListResult<SchedulerRun>>(`/scheduler/jobs/${runsJob.value.job_key}/runs`, {
|
||||
page: runPage.value,
|
||||
size: runSize.value,
|
||||
})
|
||||
const result = await http.get<ListResult<SchedulerRun>>(
|
||||
`/scheduler/jobs/${runsJob.value.job_key}/runs`,
|
||||
{
|
||||
page: runPage.value,
|
||||
size: runSize.value,
|
||||
},
|
||||
)
|
||||
runRows.value = result.items
|
||||
runTotal.value = result.total
|
||||
} catch (error) {
|
||||
@@ -573,7 +639,16 @@ function runStatusColor(value?: string | null): string {
|
||||
function statsSummary(stats?: Record<string, unknown> | null): string {
|
||||
if (!stats) return '—'
|
||||
const pairs = Object.entries(stats)
|
||||
.filter(([key]) => ['deleted_count', 'candidate_count', 'created_task_count', 'claimed_count', 'expired_task_count', 'anomaly_count'].includes(key))
|
||||
.filter(([key]) =>
|
||||
[
|
||||
'deleted_count',
|
||||
'candidate_count',
|
||||
'created_task_count',
|
||||
'claimed_count',
|
||||
'expired_task_count',
|
||||
'anomaly_count',
|
||||
].includes(key),
|
||||
)
|
||||
.map(([key, value]) => `${key}=${String(value)}`)
|
||||
return pairs.length ? pairs.join(' · ') : '—'
|
||||
}
|
||||
|
||||
@@ -123,19 +123,21 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined, RocketOutlined } from '@ant-design/icons-vue'
|
||||
import {
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
RocketOutlined,
|
||||
} from '@ant-design/icons-vue'
|
||||
import type { TableColumnsType } from 'ant-design-vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import dayjs from 'dayjs'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import {
|
||||
levelColor,
|
||||
levelLabel,
|
||||
platformLabel,
|
||||
} from '@/lib/compliance-display'
|
||||
import { complianceApi, type OpsComplianceDictionary } from '@/lib/compliance'
|
||||
import { levelColor, levelLabel, platformLabel } from '@/lib/compliance-display'
|
||||
import { showOpsError } from '@/lib/errors'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
@@ -196,7 +196,12 @@
|
||||
</template>
|
||||
</a-drawer>
|
||||
|
||||
<a-drawer v-model:open="batchDrawerOpen" title="批量导入词条" width="520" :destroy-on-close="true">
|
||||
<a-drawer
|
||||
v-model:open="batchDrawerOpen"
|
||||
title="批量导入词条"
|
||||
width="520"
|
||||
:destroy-on-close="true"
|
||||
>
|
||||
<a-alert
|
||||
class="create-terms-alert"
|
||||
type="info"
|
||||
@@ -222,17 +227,13 @@ import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import PlatformScopePicker from '@/components/compliance/PlatformScopePicker.vue'
|
||||
import {
|
||||
complianceLevelOptions,
|
||||
levelColor,
|
||||
levelLabel,
|
||||
} from '@/lib/compliance-display'
|
||||
import {
|
||||
complianceApi,
|
||||
type ComplianceLevel,
|
||||
type OpsComplianceDictionary,
|
||||
type OpsComplianceTerm,
|
||||
} from '@/lib/compliance'
|
||||
import { complianceLevelOptions, levelColor, levelLabel } from '@/lib/compliance-display'
|
||||
import { showOpsError } from '@/lib/errors'
|
||||
|
||||
type MatchType = 'exact' | 'regex'
|
||||
@@ -463,7 +464,10 @@ async function rollbackDictionary() {
|
||||
if (!dictionaryId.value || !rollbackVersion.value) return
|
||||
rollingBack.value = true
|
||||
try {
|
||||
dictionary.value = await complianceApi.rollbackDictionary(dictionaryId.value, rollbackVersion.value)
|
||||
dictionary.value = await complianceApi.rollbackDictionary(
|
||||
dictionaryId.value,
|
||||
rollbackVersion.value,
|
||||
)
|
||||
message.success('词库已回滚')
|
||||
await loadDictionary()
|
||||
} catch (error) {
|
||||
|
||||
@@ -48,7 +48,11 @@
|
||||
<template v-if="review.status === 'pending'">
|
||||
<a-form class="decision-form" layout="vertical">
|
||||
<a-form-item label="决策理由" required>
|
||||
<a-textarea v-model:value="decisionReason" :rows="5" placeholder="填写通过或驳回原因" />
|
||||
<a-textarea
|
||||
v-model:value="decisionReason"
|
||||
:rows="5"
|
||||
placeholder="填写通过或驳回原因"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-space class="decision-actions">
|
||||
<a-popconfirm
|
||||
@@ -116,14 +120,15 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ComplianceManualReview, ComplianceViolation } from '@geo/shared-types'
|
||||
import { CheckOutlined, CloseOutlined } from '@ant-design/icons-vue'
|
||||
import type { ComplianceManualReview, ComplianceViolation } from '@geo/shared-types'
|
||||
import type { TableColumnsType } from 'ant-design-vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import dayjs from 'dayjs'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { complianceApi } from '@/lib/compliance'
|
||||
import {
|
||||
levelColor,
|
||||
levelLabel,
|
||||
@@ -133,7 +138,6 @@ import {
|
||||
reviewStatusLabel,
|
||||
sourceLabel,
|
||||
} from '@/lib/compliance-display'
|
||||
import { complianceApi } from '@/lib/compliance'
|
||||
import { showOpsError } from '@/lib/errors'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
@@ -45,7 +45,9 @@
|
||||
<div class="primary-cell">
|
||||
<strong>{{ record.article_title || `文章 #${record.article_id}` }}</strong>
|
||||
<span>
|
||||
租户 {{ record.tenant_name || `#${record.tenant_id}` }} · 版本 #{{ record.article_version_id }}
|
||||
租户 {{ record.tenant_name || `#${record.tenant_id}` }} · 版本 #{{
|
||||
record.article_version_id
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -86,20 +88,20 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ComplianceManualReview } from '@geo/shared-types'
|
||||
import { ReloadOutlined } from '@ant-design/icons-vue'
|
||||
import type { ComplianceManualReview } from '@geo/shared-types'
|
||||
import type { TableColumnsType, TablePaginationConfig } from 'ant-design-vue'
|
||||
import dayjs from 'dayjs'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { complianceApi } from '@/lib/compliance'
|
||||
import {
|
||||
levelColor,
|
||||
platformListLabel,
|
||||
reviewStatusColor,
|
||||
reviewStatusLabel,
|
||||
} from '@/lib/compliance-display'
|
||||
import { complianceApi } from '@/lib/compliance'
|
||||
import { showOpsError } from '@/lib/errors'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
@@ -116,13 +116,13 @@ import { message } from 'ant-design-vue'
|
||||
import dayjs from 'dayjs'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
|
||||
import { modeColor, modeLabel } from '@/lib/compliance-display'
|
||||
import {
|
||||
complianceApi,
|
||||
type ComplianceMode,
|
||||
type CompliancePolicy,
|
||||
type OpsComplianceDictionary,
|
||||
} from '@/lib/compliance'
|
||||
import { modeColor, modeLabel } from '@/lib/compliance-display'
|
||||
import { showOpsError } from '@/lib/errors'
|
||||
|
||||
const policy = ref<CompliancePolicy | null>(null)
|
||||
|
||||
@@ -47,7 +47,11 @@
|
||||
<template v-if="column.key === 'record'">
|
||||
<div class="primary-cell">
|
||||
<strong>记录 #{{ record.record_id }}</strong>
|
||||
<span>文章 #{{ record.article_id || '--' }} · 版本 #{{ record.article_version_id || '--' }}</span>
|
||||
<span>
|
||||
文章 #{{ record.article_id || '--' }} · 版本 #{{
|
||||
record.article_version_id || '--'
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'decision'">
|
||||
@@ -86,9 +90,7 @@
|
||||
<template v-else-if="column.key === 'checked_at'">
|
||||
{{ formatDate(record.checked_at) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'duration'">
|
||||
{{ record.duration_ms }}ms
|
||||
</template>
|
||||
<template v-else-if="column.key === 'duration'">{{ record.duration_ms }}ms</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
@@ -96,12 +98,13 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ComplianceCheckResult } from '@geo/shared-types'
|
||||
import { ReloadOutlined } from '@ant-design/icons-vue'
|
||||
import type { ComplianceCheckResult } from '@geo/shared-types'
|
||||
import type { TableColumnsType, TablePaginationConfig } from 'ant-design-vue'
|
||||
import dayjs from 'dayjs'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
|
||||
import { complianceApi } from '@/lib/compliance'
|
||||
import {
|
||||
decisionColor,
|
||||
decisionLabel,
|
||||
@@ -112,7 +115,6 @@ import {
|
||||
reviewStatusColor,
|
||||
reviewStatusLabel,
|
||||
} from '@/lib/compliance-display'
|
||||
import { complianceApi } from '@/lib/compliance'
|
||||
import { showOpsError } from '@/lib/errors'
|
||||
|
||||
const rows = ref<ComplianceCheckResult[]>([])
|
||||
|
||||
@@ -76,8 +76,8 @@
|
||||
import { ReloadOutlined } from '@ant-design/icons-vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import { levelColor, levelLabel, sourceLabel } from '@/lib/compliance-display'
|
||||
import { complianceApi, type ComplianceStats } from '@/lib/compliance'
|
||||
import { levelColor, levelLabel, sourceLabel } from '@/lib/compliance-display'
|
||||
import { showOpsError } from '@/lib/errors'
|
||||
|
||||
const stats = ref<ComplianceStats | null>(null)
|
||||
|
||||
Reference in New Issue
Block a user