新增对象存储/账单/保留IP页面,成本今天视图与样式收敛
CI / test (push) Successful in 42s

This commit is contained in:
2026-07-21 12:11:22 +08:00
parent d813225ac4
commit ac457282ce
91 changed files with 6672 additions and 652 deletions
+3 -1
View File
@@ -1,4 +1,4 @@
import { request } from './request'
import { mockOn, mocked, request } from './request'
import type {
AiCallLog,
AiCatalogModel,
@@ -49,6 +49,8 @@ export function updateAiKeyContentLog(id: number, hours: number): Promise<AiKey>
// ---- 渠道(号池)----
export function listAiChannels(): Promise<AiChannel[]> {
// mock 缺此分支时请求会打到真后端,mock token 401 导致演示模式被登出
if (mockOn) return mocked([])
return request<{ items: AiChannel[] }>('/ai-channels').then((r) => r.items)
}
+2 -2
View File
@@ -17,9 +17,9 @@ export function getInstanceTraffic(
export interface CostQuery {
startTime?: string
endTime?: string
granularity?: 'DAILY' | 'MONTHLY'
granularity?: 'HOURLY' | 'DAILY' | 'MONTHLY'
queryType?: 'COST' | 'USAGE'
groupBy?: 'service' | 'skuName' | 'region' | 'compartmentName'
groupBy?: 'service' | 'skuName' | 'region' | 'compartmentName' | 'service,skuName'
}
export function getCosts(cfgId: number, query: CostQuery = {}): Promise<CostItem[]> {
+51
View File
@@ -0,0 +1,51 @@
import { mockInvoiceLines, mockInvoices, mockPaymentMethods } from './mock'
import { mockOn, mocked, rawFetch, request } from './request'
import type { Invoice, InvoiceLine, PaymentMethod } from '@/types/api'
// 账单(OSP Gateway):发票数据挂租户主区域,面板后端代理转发。
/** 列出发票;year 传自然年按开票时间过滤(前端按年懒加载),缺省全量 */
export function listInvoices(id: number, year?: number): Promise<Invoice[]> {
if (mockOn)
return mocked(
year ? mockInvoices.filter((i) => (i.timeInvoice ?? '').startsWith(String(year))) : mockInvoices,
)
return request(`/oci-configs/${id}/invoices`, { query: { year } })
}
export function listInvoiceLines(id: number, internalId: string): Promise<InvoiceLine[]> {
if (mockOn) return mocked(mockInvoiceLines)
return request(`/oci-configs/${id}/invoices/${encodeURIComponent(internalId)}/lines`)
}
/** 按订阅默认付款方式支付发票;email 接收 OSP 付款回执 */
export function payInvoice(id: number, internalId: string, email: string): Promise<{ status: string }> {
if (mockOn) return mocked({ status: 'submitted' })
return request(`/oci-configs/${id}/invoices/${encodeURIComponent(internalId)}/pay`, {
method: 'POST',
body: { email },
})
}
export function listPaymentMethods(id: number): Promise<PaymentMethod[]> {
if (mockOn) return mocked(mockPaymentMethods)
return request(`/oci-configs/${id}/payment-methods`)
}
/** 拉取发票 PDF 原文(接口带 JWT 不能裸链接);预览与下载共用 */
export function fetchInvoicePdf(id: number, internalId: string): Promise<Blob> {
if (mockOn) return fetch('/mock-samples/sample.pdf').then((r) => r.blob())
return rawFetch(`/oci-configs/${id}/invoices/${encodeURIComponent(internalId)}/pdf`).then((r) =>
r.blob(),
)
}
/** 触发浏览器保存(blob 已在手时用,如预览框内下载) */
export function saveInvoicePdf(blob: Blob, name: string) {
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${name}.pdf`
a.click()
URL.revokeObjectURL(url)
}
+42 -1
View File
@@ -187,7 +187,35 @@ export function removeIpv6(
// ---- VNIC 管理 ----
export function listVnics(cfgId: number, instanceId: string, region?: string): Promise<Vnic[]> {
if (mockOn) return mocked([])
if (mockOn)
return mocked([
{
attachmentId: 'ocid1.vnicattachment..a1',
vnicId: 'ocid1.vnic..v1',
displayName: instanceId.slice(-12),
isPrimary: true,
privateIp: '10.0.0.11',
publicIp: '141.147.60.10',
ipv6Addresses: ['2603:c020:400d:aa01::1'],
subnetId: 'ocid1.subnet..pub',
subnetName: 'public-subnet',
lifecycleState: 'ATTACHED',
timeCreated: '2026-05-20T14:32:00Z',
},
{
attachmentId: 'ocid1.vnicattachment..a2',
vnicId: 'ocid1.vnic..v2',
displayName: 'vnic-secondary',
isPrimary: false,
privateIp: '10.0.0.12',
publicIp: '',
ipv6Addresses: [],
subnetId: 'ocid1.subnet..pub',
subnetName: 'public-subnet',
lifecycleState: 'ATTACHED',
timeCreated: '2026-07-20T08:00:00Z',
},
])
return request(`/oci-configs/${cfgId}/instances/${encodeURIComponent(instanceId)}/vnics`, {
query: { region },
})
@@ -213,6 +241,19 @@ export function detachVnic(cfgId: number, attachmentId: string, region?: string)
})
}
/** 更换指定 VNIC 的临时公网 IP,返回新地址;绑定保留 IP 的网卡会被拒绝 */
export function changeVnicPublicIp(
cfgId: number,
vnicId: string,
region?: string,
): Promise<{ publicIp: string }> {
if (mockOn) return mocked({ publicIp: '130.61.99.7' }, 800)
return request(`/oci-configs/${cfgId}/vnics/${encodeURIComponent(vnicId)}/change-public-ip`, {
method: 'POST',
body: { region: region ?? '' },
})
}
/** 为指定 VNIC 添加 IPv6;address 留空自动分配。删除复用实例级 removeIpv6(按地址遍历全部网卡) */
export function addVnicIpv6(
cfgId: number,
+210
View File
@@ -1,4 +1,8 @@
import type {
Bucket,
ObjectSummary,
Par,
ReservedIp,
SecuritySetting,
AuditEvent,
BootVolume,
@@ -14,6 +18,8 @@ import type {
ImageInfo,
Instance,
InstanceTraffic,
Invoice,
InvoiceLine,
Ipv6Report,
LimitItem,
LogEvent,
@@ -23,6 +29,7 @@ import type {
OciConfig,
Overview,
PasswordPolicy,
PaymentMethod,
RegionInfo,
RegionSubscription,
SecurityList,
@@ -569,6 +576,15 @@ export const mockLimits: LimitItem[] = [
available: 4,
},
{ name: 'vcn-count', scopeType: 'REGION', value: 50, used: 2, available: 48 },
// 对象存储:额度条数据源(免费号 20GB 上限)
{
name: 'storage-bytes',
scopeType: 'REGION',
value: 21474836480,
used: 2003218,
available: 21472833262,
},
{ name: 'bucket-count', scopeType: 'REGION', value: 10000, used: 5, available: 9995 },
{ name: 'volume-backup-count', scopeType: 'REGION', value: 100, used: 6, available: 94 },
]
@@ -1022,6 +1038,128 @@ export const mockTraffic: InstanceTraffic = {
],
}
export const mockBuckets: Bucket[] = [
{
name: 'backups',
namespace: 'mocknamespace',
visibility: 'NoPublicAccess',
storageTier: 'Standard',
versioningOn: true,
approximateCount: 128,
approximateSize: 4.2 * 1024 ** 3,
timeCreated: '2025-11-03T14:20:00Z',
},
{
name: 'static-assets',
namespace: 'mocknamespace',
visibility: 'ObjectRead',
storageTier: 'Standard',
versioningOn: false,
approximateCount: 56,
approximateSize: 820 * 1024 ** 2,
timeCreated: '2025-12-18T09:41:00Z',
},
// 超长桶名样本:走查删除确认标题 / 桶设置弹窗的截断表现
{
name: 'oci-logs._flowlogs.ocid1.tenancy.oc1..aaaaaaaa57ydufmess6ni2vsjjkfmvbi',
namespace: 'mocknamespace',
visibility: 'NoPublicAccess',
storageTier: 'Standard',
versioningOn: false,
approximateCount: 3021,
approximateSize: 1.1 * 1024 ** 3,
timeCreated: '2026-03-22T06:10:00Z',
},
]
export const mockObjects: ObjectSummary[] = [
{ name: 'db/daily/app-0718.sql.gz', size: 3.2 * 1024 ** 3, storageTier: 'Standard', archivalState: '', timeModified: '2026-07-18T03:30:00Z' },
{ name: 'db/app.sql.gz', size: 3.4 * 1024 ** 3, storageTier: 'Standard', archivalState: '', timeModified: '2026-07-19T03:30:00Z' },
{ name: 'db/wal-0007.tar', size: 1.7 * 1024 ** 3, storageTier: 'Standard', archivalState: '', timeModified: '2026-07-18T03:30:00Z' },
{ name: 'db/2025-snapshot.tar', size: 890 * 1024 ** 2, storageTier: 'Archive', archivalState: 'Archived', timeModified: '2026-01-02T22:05:00Z' },
{ name: 'config.yaml', size: 2100, storageTier: 'Standard', archivalState: '', timeModified: '2026-06-30T11:02:00Z' },
{ name: 'README.md', size: 1800, storageTier: 'Standard', archivalState: '', timeModified: '2026-07-15T08:20:00Z' },
{ name: 'logo.svg', size: 2400, storageTier: 'Standard', archivalState: '', timeModified: '2026-07-10T15:44:00Z' },
{ name: '季度报告.docx', size: 384 * 1024, storageTier: 'Standard', archivalState: '', timeModified: '2026-07-08T09:12:00Z' },
{ name: '预算表.xlsx', size: 92 * 1024, storageTier: 'Standard', archivalState: '', timeModified: '2026-07-12T14:03:00Z' },
{ name: '发布计划.pptx', size: 1.6 * 1024 ** 2, storageTier: 'Standard', archivalState: '', timeModified: '2026-07-17T10:30:00Z' },
{ name: '架构说明.pdf', size: 512, storageTier: 'Standard', archivalState: '', timeModified: '2026-07-19T16:40:00Z' },
{ name: 'gateway.json', size: 84, storageTier: 'Standard', archivalState: '', timeModified: '2026-07-20T09:10:00Z' },
]
/** mock 对象文本内容(面板中转预览/编辑用);office/pdf 经 /mock-samples/ 静态样例提供 */
export const mockObjectContents: Record<string, string> = {
'gateway.json': `{"name":"gw","port":8080,"tls":true,"upstreams":["10.0.0.2","10.0.0.3"],"limits":{"burst":64}}`,
'config.yaml': `server:
port: 8080
host: 0.0.0.0
database:
driver: sqlite
path: /data/app.db
log:
level: info
format: json
`,
'README.md': `# 对象存储示例
这是一段 **markdown** 预览示例,支持围栏代码块高亮:
\`\`\`python
def hello(name: str) -> str:
return f"hello {name}"
\`\`\`
\`\`\`bash
pip install -r requirements.txt
python main.py --port 8080
\`\`\`
- 列表项一
- 列表项二
> 引用块:预览与编辑经面板中转,不再签发 PAR。
`,
'logo.svg': `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120">
<circle cx="60" cy="60" r="50" fill="#C96442" />
<text x="60" y="68" text-anchor="middle" font-size="28" fill="#fff">OCI</text>
</svg>
`,
}
export const mockPars: Par[] = [
{
id: 'par-mock-1',
name: 'par-db-app.sql.gz',
objectName: 'db/app.sql.gz',
accessType: 'ObjectRead',
timeExpires: '2026-07-21T03:30:00Z',
timeCreated: '2026-07-20T03:30:00Z',
},
]
export const mockReservedIps: ReservedIp[] = [
{
id: 'ocid1.publicip.oc1..r1',
displayName: 'tokyo-keep-1',
ipAddress: '155.248.200.10',
lifecycleState: 'ASSIGNED',
assignedInstanceId: 'ocid1.instance.oc1..inst1',
assignedInstanceName: 'tokyo-web-1',
timeCreated: '2026-05-12T08:00:00Z',
},
{
id: 'ocid1.publicip.oc1..r2',
displayName: 'spare-ip',
ipAddress: '152.69.198.88',
lifecycleState: 'AVAILABLE',
assignedInstanceId: '',
assignedInstanceName: '',
timeCreated: '2026-07-01T02:00:00Z',
},
]
export const mockVcns: Record<number, Vcn[]> = {
1: [
{
@@ -1296,6 +1434,14 @@ export const mockCompartments: Record<number, Compartment[]> = {
lifecycleState: 'ACTIVE',
timeCreated: '2026-06-01T00:00:00Z',
},
{
id: 'ocid1.compartment.oc1..devsandbox',
name: 'dev-sandbox',
description: 'dev 下的嵌套子区间',
parentId: 'ocid1.compartment.oc1..dev',
lifecycleState: 'ACTIVE',
timeCreated: '2026-06-10T00:00:00Z',
},
],
}
@@ -1750,3 +1896,67 @@ export const mockLogWebhooks = new Map<number, LogWebhookInfo>()
// OCI 侧回传链路:mock 态记录「已一键创建」的租户
export const mockLogRelays = new Map<number, boolean>()
// ---- 账单:发票 / 明细 / 付款方式 ----
export const mockInvoices: Invoice[] = [
{
id: 'inv-202607', internalId: '61001', number: '100482756', type: 'USAGE', status: 'OPEN',
isPaid: false, isPayable: true, isPaymentFailed: false, amount: 603.53, amountDue: 603.53,
currency: 'USD', timeInvoice: '2026-07-01T00:00:00Z', timeDue: '2026-07-31T00:00:00Z',
},
{
id: 'inv-202606', internalId: '61002', number: '100471203', type: 'USAGE', status: 'PAST_DUE',
isPaid: false, isPayable: true, isPaymentFailed: true, amount: 538.17, amountDue: 538.17,
currency: 'USD', timeInvoice: '2026-06-01T00:00:00Z', timeDue: '2026-06-30T00:00:00Z',
},
{
id: 'inv-202605', internalId: '61003', number: '100458820', type: 'USAGE', status: 'PAYMENT_SUBMITTED',
isPaid: false, isPayable: false, isPaymentFailed: false, amount: 512.4, amountDue: 512.4,
currency: 'USD', timeInvoice: '2026-05-01T00:00:00Z', timeDue: '2026-05-31T00:00:00Z',
},
{
id: 'inv-202604', internalId: '61004', number: '100446351', type: 'USAGE', status: 'CLOSED',
isPaid: true, isPayable: false, isPaymentFailed: false, amount: 529.02, amountDue: 0,
currency: 'USD', timeInvoice: '2026-04-01T00:00:00Z', timeDue: '2026-04-30T00:00:00Z',
},
{
id: 'inv-202603', internalId: '61005', number: '100433978', type: 'USAGE', status: 'CLOSED',
isPaid: true, isPayable: false, isPaymentFailed: false, amount: 498.75, amountDue: 0,
currency: 'USD', timeInvoice: '2026-03-01T00:00:00Z', timeDue: '2026-03-31T00:00:00Z',
},
{
id: 'inv-202602', internalId: '61006', number: '100421510', type: 'USAGE', status: 'CLOSED',
isPaid: true, isPayable: false, isPaymentFailed: false, amount: 533.19, amountDue: 0,
currency: 'USD', timeInvoice: '2026-02-01T00:00:00Z', timeDue: '2026-02-28T00:00:00Z',
},
{
id: 'inv-202512', internalId: '61007', number: '100409112', type: 'USAGE', status: 'CLOSED',
isPaid: true, isPayable: false, isPaymentFailed: false, amount: 476.4, amountDue: 0,
currency: 'USD', timeInvoice: '2025-12-01T00:00:00Z', timeDue: '2025-12-31T00:00:00Z',
},
{
id: 'inv-202511', internalId: '61008', number: '100396805', type: 'USAGE', status: 'CLOSED',
isPaid: true, isPayable: false, isPaymentFailed: false, amount: 441.87, amountDue: 0,
currency: 'USD', timeInvoice: '2025-11-01T00:00:00Z', timeDue: '2025-11-30T00:00:00Z',
},
]
export const mockInvoiceLines: InvoiceLine[] = [
{ product: 'Compute - Standard.E4.Flex - OCPU', orderNo: '89S1024', quantity: 744, unitPrice: 0.025, total: 18.6, currency: 'USD', timeStart: '2026-07-01T00:00:00Z', timeEnd: '2026-07-31T00:00:00Z' },
{ product: 'Compute - Standard.E4.Flex - Memory', orderNo: '89S1024', quantity: 11904, unitPrice: 0.0015, total: 17.86, currency: 'USD', timeStart: '2026-07-01T00:00:00Z', timeEnd: '2026-07-31T00:00:00Z' },
{ product: 'Block Volume - Performance Units', orderNo: '89S1024', quantity: 1488, unitPrice: 0.0017, total: 2.53, currency: 'USD', timeStart: '2026-07-01T00:00:00Z', timeEnd: '2026-07-31T00:00:00Z' },
{ product: 'Object Storage - Standard', orderNo: '89S1024', quantity: 512, unitPrice: 0.0255, total: 13.06, currency: 'USD', timeStart: '2026-07-01T00:00:00Z', timeEnd: '2026-07-31T00:00:00Z' },
{ product: 'Outbound Data Transfer', orderNo: '89S1024', quantity: 2048, unitPrice: 0.0085, total: 17.41, currency: 'USD', timeStart: '2026-07-01T00:00:00Z', timeEnd: '2026-07-31T00:00:00Z' },
{ product: 'Generative AI - Large Chat Tokens', orderNo: '89S1024', quantity: 3560.4, unitPrice: 0.15, total: 534.07, currency: 'USD', timeStart: '2026-07-01T00:00:00Z', timeEnd: '2026-07-31T00:00:00Z' },
]
export const mockPaymentMethods: PaymentMethod[] = [
{
method: 'CREDIT_CARD', cardType: 'VISA', lastDigits: '4242',
nameOnCard: 'LI MING', timeExpiration: '2028-08-01T00:00:00Z',
},
{
method: 'PAYPAL', email: 'billing@example.com', payerName: 'Li Ming',
billingAgreement: 'B-7KX3H2M9L4T9F2',
},
]
+20 -1
View File
@@ -1,6 +1,6 @@
import { mockIpv6Report, mockSecurityLists, mockSubnets, mockVcns } from './mock'
import { mockOn, mocked, request } from './request'
import type { CreateVcnRequest, Ipv6Report, SecurityList, Subnet, Vcn } from '@/types/api'
import type { CreateVcnRequest, Ipv6Report, SecurityList, SecurityRule, Subnet, Vcn } from '@/types/api'
export function listVcns(cfgId: number, region?: string, compartmentId?: string): Promise<Vcn[]> {
if (mockOn) return mocked(compartmentId ? [] : (mockVcns[cfgId] ?? []))
@@ -64,3 +64,22 @@ export function listSecurityLists(
if (mockOn) return mocked(mockSecurityLists.filter((s) => s.vcnId === vcnId || vcnId === ''))
return request(`/oci-configs/${cfgId}/security-lists`, { query: { vcnId, region } })
}
/** 更新安全列表;规则字段一经提供即整体替换,region 随请求体传递 */
export function updateSecurityList(
cfgId: number,
securityListId: string,
body: { region?: string; displayName?: string; ingressRules?: SecurityRule[]; egressRules?: SecurityRule[] },
): Promise<SecurityList> {
if (mockOn) {
const found = mockSecurityLists.find((s) => s.id === securityListId)
if (!found) return Promise.reject(new Error('安全列表不存在'))
if (body.ingressRules) found.ingressRules = body.ingressRules
if (body.egressRules) found.egressRules = body.egressRules
return mocked({ ...found }, 500)
}
return request(`/oci-configs/${cfgId}/security-lists/${encodeURIComponent(securityListId)}`, {
method: 'PUT',
body,
})
}
+361
View File
@@ -0,0 +1,361 @@
import { mockBuckets, mockObjectContents, mockObjects, mockPars } from './mock'
import { mockOn, mocked, rawFetch, request } from './request'
import type { Bucket, ListObjectsResult, ObjectDetail, Par, ParPage } from '@/types/api'
export function listBuckets(
cfgId: number,
region?: string,
compartmentId?: string,
): Promise<Bucket[]> {
if (mockOn) return mocked([...mockBuckets])
return request(`/oci-configs/${cfgId}/buckets`, { query: { region, compartmentId } })
}
export function createBucket(
cfgId: number,
body: {
region?: string
compartmentId?: string
name: string
publicRead?: boolean
storageTier?: string
versioningOn?: boolean
},
): Promise<Bucket> {
if (mockOn) {
const b: Bucket = {
name: body.name,
namespace: 'mocknamespace',
visibility: body.publicRead ? 'ObjectRead' : 'NoPublicAccess',
storageTier: body.storageTier || 'Standard',
versioningOn: !!body.versioningOn,
approximateCount: 0,
approximateSize: 0,
timeCreated: new Date().toISOString(),
}
mockBuckets.push(b)
return mocked(b, 600)
}
return request(`/oci-configs/${cfgId}/buckets`, { method: 'POST', body })
}
export function updateBucket(
cfgId: number,
bucket: string,
body: { region?: string; publicRead?: boolean; versioningOn?: boolean },
): Promise<Bucket> {
if (mockOn) {
const found = mockBuckets.find((b) => b.name === bucket)
if (!found) return Promise.reject(new Error('桶不存在'))
if (body.publicRead !== undefined)
found.visibility = body.publicRead ? 'ObjectRead' : 'NoPublicAccess'
if (body.versioningOn !== undefined) found.versioningOn = body.versioningOn
return mocked({ ...found }, 400)
}
return request(`/oci-configs/${cfgId}/buckets/${encodeURIComponent(bucket)}`, {
method: 'PUT',
body,
})
}
/** queued=true 表示非空桶已转后台清空(对象全部版本+PAR)后删除 */
export function deleteBucket(
cfgId: number,
bucket: string,
region?: string,
): Promise<{ queued: boolean }> {
if (mockOn) {
const idx = mockBuckets.findIndex((b) => b.name === bucket)
if (idx >= 0) mockBuckets.splice(idx, 1)
return mocked({ queued: false })
}
return request(`/oci-configs/${cfgId}/buckets/${encodeURIComponent(bucket)}`, {
method: 'DELETE',
query: { region },
})
}
export function listObjects(
cfgId: number,
bucket: string,
opts: { region?: string; prefix?: string; startWith?: string; limit?: number } = {},
): Promise<ListObjectsResult> {
if (mockOn) {
const prefix = opts.prefix ?? ''
const inScope = mockObjects.filter((o) => o.name.startsWith(prefix))
const prefixes = new Set<string>()
const objects = inScope.filter((o) => {
const rest = o.name.slice(prefix.length)
const slash = rest.indexOf('/')
if (slash < 0) return true
prefixes.add(prefix + rest.slice(0, slash + 1))
return false
})
return mocked({ objects, prefixes: [...prefixes], nextStartWith: '' })
}
return request(`/oci-configs/${cfgId}/buckets/${encodeURIComponent(bucket)}/objects`, {
query: {
region: opts.region,
prefix: opts.prefix || undefined,
startWith: opts.startWith || undefined,
limit: opts.limit,
},
})
}
export function deleteObject(
cfgId: number,
bucket: string,
object: string,
region?: string,
): Promise<void> {
if (mockOn) {
const idx = mockObjects.findIndex((o) => o.name === object)
if (idx >= 0) mockObjects.splice(idx, 1)
return mocked(undefined)
}
return request(`/oci-configs/${cfgId}/buckets/${encodeURIComponent(bucket)}/objects`, {
method: 'DELETE',
query: { region, object },
})
}
export function renameObject(
cfgId: number,
bucket: string,
source: string,
newName: string,
region?: string,
): Promise<void> {
if (mockOn) {
const found = mockObjects.find((o) => o.name === source)
if (found) found.name = newName
return mocked(undefined, 400)
}
return request(`/oci-configs/${cfgId}/buckets/${encodeURIComponent(bucket)}/objects/rename`, {
method: 'POST',
body: { region: region ?? '', source, newName },
})
}
export function restoreObject(
cfgId: number,
bucket: string,
object: string,
region?: string,
hours?: number,
): Promise<void> {
if (mockOn) {
// mock 态模拟取回中,便于走查「取回中」标签与后台轮询
const o = mockObjects.find((x) => x.name === object)
if (o) o.archivalState = 'Restoring'
return mocked(undefined, 500)
}
return request(`/oci-configs/${cfgId}/buckets/${encodeURIComponent(bucket)}/objects/restore`, {
method: 'POST',
body: { region: region ?? '', object, hours },
})
}
export function getObjectDetail(
cfgId: number,
bucket: string,
object: string,
region?: string,
): Promise<ObjectDetail> {
if (mockOn) {
const found = mockObjects.find((o) => o.name === object)
if (!found) return Promise.reject(new Error('对象不存在'))
return mocked({
name: found.name,
size: found.size,
contentType: 'application/octet-stream',
etag: 'a1b2c3d4-mock',
contentMd5: 'tYx0mockkQ==',
storageTier: found.storageTier,
archivalState: found.archivalState,
timeModified: found.timeModified,
metadata: null,
})
}
return request(`/oci-configs/${cfgId}/buckets/${encodeURIComponent(bucket)}/objects/detail`, {
query: { region, object },
})
}
export interface ObjectContent {
blob: Blob
contentType: string
etag: string
}
/** 读取对象内容(面板中转,不签发 PAR);超过 20MB 后端返回 413 */
export async function getObjectContent(
cfgId: number,
bucket: string,
object: string,
region?: string,
): Promise<ObjectContent> {
if (mockOn) return mockGetContent(object)
const resp = await rawFetch(
`/oci-configs/${cfgId}/buckets/${encodeURIComponent(bucket)}/objects/content`,
{ query: { region, object } },
)
return {
blob: await resp.blob(),
contentType: resp.headers.get('Content-Type') ?? '',
etag: resp.headers.get('ETag') ?? '',
}
}
const MOCK_CT: Record<string, string> = { md: 'text/markdown', svg: 'image/svg+xml' }
/** mock:文本走内容表,office/pdf 走 /mock-samples/ 静态样例 */
async function mockGetContent(object: string): Promise<ObjectContent> {
const ext = object.split('.').pop()?.toLowerCase() ?? ''
const text = mockObjectContents[object]
if (text !== undefined) {
await mocked(undefined, 300)
const ct = MOCK_CT[ext] ?? 'text/plain'
return { blob: new Blob([text], { type: ct }), contentType: ct, etag: 'mock-etag-1' }
}
// dev server 对不存在路径回退 index.html(200),按 Content-Type 识别缺样例
const resp = await fetch(`/mock-samples/sample.${ext}`)
if (!resp.ok || (resp.headers.get('Content-Type') ?? '').includes('text/html'))
throw new Error('mock 环境无该对象的样例内容')
return {
blob: await resp.blob(),
contentType: resp.headers.get('Content-Type') ?? '',
etag: 'mock-etag-1',
}
}
/** 保存对象内容(面板中转);ifMatch 冲突时抛 ApiError(status=412) */
export async function putObjectContent(
cfgId: number,
bucket: string,
object: string,
data: Blob | string,
contentType?: string,
ifMatch?: string,
region?: string,
): Promise<{ etag: string }> {
if (mockOn) return mockPutContent(object, data)
const headers: Record<string, string> = {}
if (contentType) headers['Content-Type'] = contentType
if (ifMatch) headers['If-Match'] = ifMatch
const resp = await rawFetch(
`/oci-configs/${cfgId}/buckets/${encodeURIComponent(bucket)}/objects/content`,
{ method: 'PUT', query: { region, object }, headers, body: data },
)
return (await resp.json()) as { etag: string }
}
async function mockPutContent(object: string, data: Blob | string): Promise<{ etag: string }> {
const text = typeof data === 'string' ? data : ''
mockObjectContents[object] = text
const found = mockObjects.find((o) => o.name === object)
if (found) {
found.size = typeof data === 'string' ? new Blob([data]).size : data.size
found.timeModified = new Date().toISOString()
}
return mocked({ etag: `mock-etag-${Date.now()}` }, 400)
}
export function createPar(
cfgId: number,
bucket: string,
body: {
region?: string
name?: string
objectName?: string
accessType: string
expiresHours?: number
},
): Promise<Par> {
if (mockOn) {
const par: Par = {
id: `par-${Date.now()}`,
name: body.name || `par-${body.objectName ?? bucket}`,
objectName: body.objectName ?? '',
accessType: body.accessType,
fullUrl: `https://objectstorage.mock.oraclecloud.com/p/tOkEn/n/mocknamespace/b/${bucket}/o/${encodeURIComponent(body.objectName ?? '')}`,
timeExpires: new Date(Date.now() + (body.expiresHours ?? 24) * 3600e3).toISOString(),
timeCreated: new Date().toISOString(),
}
mockPars.push({ ...par, fullUrl: undefined })
return mocked(par, 500)
}
return request(`/oci-configs/${cfgId}/buckets/${encodeURIComponent(bucket)}/pars`, {
method: 'POST',
body,
})
}
export function listPars(
cfgId: number,
bucket: string,
opts: { region?: string; page?: string; limit?: number } = {},
): Promise<ParPage> {
if (mockOn) return mocked({ items: [...mockPars], nextPage: '' })
return request(`/oci-configs/${cfgId}/buckets/${encodeURIComponent(bucket)}/pars`, {
query: { region: opts.region, page: opts.page, limit: opts.limit },
})
}
export function deletePar(
cfgId: number,
bucket: string,
parId: string,
region?: string,
): Promise<void> {
if (mockOn) {
const idx = mockPars.findIndex((p) => p.id === parId)
if (idx >= 0) mockPars.splice(idx, 1)
return mocked(undefined)
}
// parId 含 "/" 等字符,路径参数无法匹配路由,经 query 传递
return request(`/oci-configs/${cfgId}/buckets/${encodeURIComponent(bucket)}/pars`, {
method: 'DELETE',
query: { region, parId },
})
}
export function deleteAllPars(
cfgId: number,
bucket: string,
region?: string,
): Promise<{ deleted: number }> {
if (mockOn) {
const n = mockPars.length
mockPars.splice(0)
return mocked({ deleted: n }, 400)
}
return request(`/oci-configs/${cfgId}/buckets/${encodeURIComponent(bucket)}/pars`, {
method: 'DELETE',
query: { region, all: '1' },
})
}
/** 经预签名 PAR 直传对象到 OCI(不经面板中转),onProgress 回调 0-100 */
export function uploadViaPar(
parUrl: string,
file: File,
onProgress: (percent: number) => void,
): { promise: Promise<void>; abort: () => void } {
const xhr = new XMLHttpRequest()
const promise = new Promise<void>((resolve, reject) => {
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100))
}
xhr.onload = () =>
xhr.status >= 200 && xhr.status < 300
? resolve()
: reject(new Error(`上传失败(HTTP ${xhr.status})`))
xhr.onerror = () => reject(new Error('上传失败(网络错误)'))
xhr.onabort = () => reject(new Error('已取消上传'))
xhr.open('PUT', parUrl)
xhr.send(file)
})
return { promise, abort: () => xhr.abort() }
}
+38 -5
View File
@@ -4,10 +4,13 @@ const BASE = '/api/v1'
export class ApiError extends Error {
status: number
/** 后端透传的 OCI 错误码(如 OrganizationExclusionError),供调用方做能力适配 */
ociCode?: string
constructor(status: number, message: string) {
constructor(status: number, message: string, ociCode?: string) {
super(message)
this.status = status
this.ociCode = ociCode
}
}
@@ -31,8 +34,15 @@ function buildUrl(path: string, query?: RequestOptions['query']): string {
async function parseError(resp: Response): Promise<never> {
let message = `请求失败(${resp.status})`
let ociCode: string | undefined
try {
const body = (await resp.json()) as { error?: string; hint?: string; errors?: unknown[] }
const body = (await resp.json()) as {
error?: string
hint?: string
errors?: unknown[]
ociCode?: string
}
ociCode = body.ociCode
if (body.error) {
message = body.hint ? `${body.hint}|${body.error}` : body.error
} else {
@@ -49,7 +59,7 @@ async function parseError(resp: Response): Promise<never> {
if (resp.status === 429 && !resp.url.includes('/auth/login') && location.pathname !== '/blocked') {
location.assign('/blocked')
}
throw new ApiError(resp.status, message)
throw new ApiError(resp.status, message, ociCode)
}
/** 统一请求封装:注入 JWT、401 登出、错误转 ApiError */
@@ -63,8 +73,31 @@ export async function request<T>(path: string, opts: RequestOptions = {}): Promi
body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
})
if (!resp.ok) await parseError(resp)
if (resp.status === 204) return undefined as T
return (await resp.json()) as T
// 202/204 等成功响应可能无 body,直接 resp.json() 会抛 Unexpected end of JSON input
const text = await resp.text()
return (text ? JSON.parse(text) : undefined) as T
}
/** 原始请求:注入 JWT、错误同 request 转 ApiError,返回原始 Response(二进制内容读写用) */
export async function rawFetch(
path: string,
opts: {
method?: string
query?: RequestOptions['query']
headers?: Record<string, string>
body?: BodyInit
} = {},
): Promise<Response> {
const auth = useAuthStore()
const headers: Record<string, string> = { ...opts.headers }
if (auth.token) headers.Authorization = `Bearer ${auth.token}`
const resp = await fetch(buildUrl(path, opts.query), {
method: opts.method ?? 'GET',
headers,
body: opts.body,
})
if (!resp.ok) await parseError(resp)
return resp
}
export const mockOn = import.meta.env.VITE_MOCK === '1'
+75
View File
@@ -0,0 +1,75 @@
import { mockReservedIps } from './mock'
import { mockOn, mocked, request } from './request'
import type { ReservedIp } from '@/types/api'
export function listReservedIps(
cfgId: number,
region?: string,
compartmentId?: string,
): Promise<ReservedIp[]> {
if (mockOn) return mocked([...mockReservedIps])
return request(`/oci-configs/${cfgId}/reserved-ips`, { query: { region, compartmentId } })
}
export function createReservedIp(
cfgId: number,
region?: string,
displayName?: string,
compartmentId?: string,
): Promise<ReservedIp> {
if (mockOn) {
const ip: ReservedIp = {
id: `ocid1.publicip..${Date.now()}`,
displayName: displayName || 'reserved-ip',
ipAddress: '155.248.201.77',
lifecycleState: 'AVAILABLE',
assignedInstanceId: '',
assignedInstanceName: '',
timeCreated: new Date().toISOString(),
}
mockReservedIps.push(ip)
return mocked(ip, 600)
}
return request(`/oci-configs/${cfgId}/reserved-ips`, {
method: 'POST',
body: { region: region ?? '', compartmentId: compartmentId ?? '', displayName },
})
}
/** vnicId 非空绑到该网卡,否则绑 instanceId 主网卡;两者都空表示解绑 */
export function assignReservedIp(
cfgId: number,
publicIpId: string,
instanceId: string,
region?: string,
vnicId?: string,
): Promise<void> {
if (mockOn) {
const found = mockReservedIps.find((r) => r.id === publicIpId)
if (found) {
found.assignedInstanceId = instanceId || (vnicId ? 'ocid1.instance.oc1..byvnic' : '')
found.assignedInstanceName = instanceId || vnicId ? 'mock-instance' : ''
}
return mocked(undefined, 500)
}
return request(`/oci-configs/${cfgId}/reserved-ips/${encodeURIComponent(publicIpId)}`, {
method: 'PUT',
body: { region: region ?? '', instanceId, vnicId: vnicId ?? '' },
})
}
export function deleteReservedIp(
cfgId: number,
publicIpId: string,
region?: string,
): Promise<void> {
if (mockOn) {
const idx = mockReservedIps.findIndex((r) => r.id === publicIpId)
if (idx >= 0) mockReservedIps.splice(idx, 1)
return mocked(undefined)
}
return request(`/oci-configs/${cfgId}/reserved-ips/${encodeURIComponent(publicIpId)}`, {
method: 'DELETE',
query: { region },
})
}