@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
@@ -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'
|
||||
|
||||
@@ -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 },
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user