初始化蓝鸟文件快递柜源码与部署文档
This commit is contained in:
commit
5643d22242
70 files changed
+18669
No files matched your search
@@ -0,0 +1 @@
|
||||
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#1677ff"/><link rel="icon" type="image/svg+xml" href="/favicon.svg?v=2"/><link rel="alternate icon" href="/favicon.ico?v=2"/><link rel="apple-touch-icon" href="/apple-touch-icon.png?v=2"/><title>蓝鸟 · 文件快递柜</title></head><body><div id="app"></div><script type="module" src="/src/main.ts"></script></body></html>
|
||||
Generated
+2222
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1 @@
|
||||
{"name":"bluebird-web","version":"0.1.0","private":true,"type":"module","scripts":{"dev":"vite --host 127.0.0.1","build":"vue-tsc --noEmit && vite build","test:e2e":"playwright test"},"dependencies":{"vue":"^3.5.0","vue-router":"^4.5.0","naive-ui":"^2.43.0","pinia":"^3.0.0","qrcode":"^1.5.4"},"devDependencies":{"@vitejs/plugin-vue":"^6.0.0","typescript":"^5.9.0","vite":"^7.1.0","vue-tsc":"^3.0.0","@types/qrcode":"^1.5.5","@playwright/test":"^1.55.0","@types/node":"^24.0.0"}}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from '@playwright/test'
|
||||
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
const root = mkdtempSync(join(tmpdir(), 'bluebird-browser-'))
|
||||
const config = join(root, 'config.toml')
|
||||
writeFileSync(config, readFileSync('../deploy/config.example.toml','utf8').replaceAll('127.0.0.1:8080','127.0.0.1:18981').replace('http://localhost:8080','http://127.0.0.1:18981').replace('min_free_bytes = 1073741824','min_free_bytes = 0'))
|
||||
export default defineConfig({ testDir: './tests', timeout: 30_000, workers: 1, use: { baseURL:'http://127.0.0.1:18981', headless: true, trace: 'retain-on-failure' }, webServer: { command: `BLUEBIRD_ADMIN_PASSWORD=e2e-secret-password ../build/bluebird init --config ${config} && ../build/bluebird serve --config ${config}`, url: 'http://127.0.0.1:18981/api/v1/health', reuseExistingServer:false, timeout:30_000 } })
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-labelledby="title">
|
||||
<title id="title">蓝鸟</title>
|
||||
<rect width="64" height="64" rx="16" fill="#1677ff"/>
|
||||
<g transform="translate(.875 4) scale(.84)">
|
||||
<path fill="#fff" d="M14 41c8-1 14-4 18-10 4-7 6-11 13-11 5 0 8 3 9 7l6 3-7 3c-1 8-7 14-16 15-6 1-11-1-15-4l-8 1 6-5-6 1Z"/>
|
||||
<path fill="#fff" d="M34 34C26 32 23 25 22 18c7 2 13 6 16 11Z"/>
|
||||
<path fill="#1677ff" d="M25 25c2 5 5 8 10 9l-2 2c-5-2-7-6-8-11Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 517 B |
Binary file not shown.
|
After Width: | Height: | Size: 6.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-labelledby="title">
|
||||
<title id="title">蓝鸟</title>
|
||||
<rect width="64" height="64" rx="16" fill="#1677ff"/>
|
||||
<g transform="translate(.875 4) scale(.84)">
|
||||
<path fill="#fff" d="M14 41c8-1 14-4 18-10 4-7 6-11 13-11 5 0 8 3 9 7l6 3-7 3c-1 8-7 14-16 15-6 1-11-1-15-4l-8 1 6-5-6 1Z"/>
|
||||
<path fill="#fff" d="M34 34C26 32 23 25 22 18c7 2 13 6 16 11Z"/>
|
||||
<path fill="#1677ff" d="M25 25c2 5 5 8 10 9l-2 2c-5-2-7-6-8-11Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 517 B |
@@ -0,0 +1,139 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, computed, nextTick } from 'vue'
|
||||
import { NButton,NInput,NInputNumber,NCheckbox,NSelect,NTabs,NTabPane,NTag,useMessage,useDialog } from 'naive-ui'
|
||||
import { api,post,size,date,states,APIError,type Parcel,type Policy,type Share,type RuntimeSettings,type PageResult,type AuditEntry } from './api'
|
||||
import ShareDialog from './ShareDialog.vue'
|
||||
import AdminPager from './AdminPager.vue'
|
||||
const message=useMessage(),dialog=useDialog()
|
||||
const authenticated=ref(false),username=ref('admin'),password=ref(''),busy=ref(false),error=ref('')
|
||||
const stats=ref<Record<string,any>>({}),policy=ref<Policy>(),runtime=ref<RuntimeSettings>(),share=ref<Share|null>(null),activeTab=ref('parcels')
|
||||
const emptyPage=<T,>(pageSize:number):PageResult<T>=>({items:[],total:0,page:1,page_size:pageSize,pages:1})
|
||||
const parcels=ref(emptyPage<Parcel>(10)),audit=ref(emptyPage<AuditEntry>(20))
|
||||
const parcelLoading=ref(false),auditLoading=ref(false),parcelError=ref(''),auditError=ref('')
|
||||
const parcelScroll=ref<HTMLElement>(),auditScroll=ref<HTMLElement>()
|
||||
const parcelFilters=reactive({q:'',state:null as string|null})
|
||||
const auditFilters=reactive({action:null as string|null,source:'',target:'',from:'',to:''})
|
||||
let appliedParcelFilters={...parcelFilters},appliedAuditFilters={...auditFilters},parcelRequest=0,auditRequest=0
|
||||
const tabs=[{key:'parcels',label:'文件管理'},{key:'policy',label:'服务策略'},{key:'runtime',label:'容量与保留'},{key:'audit',label:'操作审计'}]
|
||||
const tabProps=(key:string)=>({role:'tab',id:'tab-'+key,'aria-controls':'panel-'+key,'aria-selected':activeTab.value===key,tabindex:activeTab.value===key?0:-1})
|
||||
async function tabKey(e:KeyboardEvent){if((e.target as HTMLElement).getAttribute('role')!=='tab')return;let i=tabs.findIndex(t=>t.key===activeTab.value);if(e.key==='ArrowRight')i=(i+1)%tabs.length;else if(e.key==='ArrowLeft')i=(i+tabs.length-1)%tabs.length;else if(e.key==='Home')i=0;else if(e.key==='End')i=tabs.length-1;else return;e.preventDefault();activeTab.value=tabs[i].key;await nextTick();document.getElementById('tab-'+activeTab.value)?.focus()}
|
||||
const stateOptions=['uploading','ready','revoked','expired','exhausted','restored'].map(value=>({value,label:states[value]}))
|
||||
const auditNames:Record<string,string>={'admin.login':'管理员登录','admin.login_failed':'管理员登录失败','admin.delete':'删除包裹','admin.file_delete':'删除文件','admin.reissue':'重新发布包裹','admin.cleanup':'清理失效文件','policy.update':'修改服务策略','runtime.update':'修改容量与保留','upload.create':'创建寄件','parcel.publish':'发布寄件','parcel.revoke':'撤销寄件'}
|
||||
const auditOptions=Object.entries(auditNames).map(([value,label])=>({value,label:label+' · '+value}))
|
||||
const numericFields:{key:keyof Policy;label:string;unit?:string}[]=[{key:'max_file_bytes',label:'单文件大小',unit:'bytes'},{key:'max_batch_bytes',label:'单次总大小',unit:'bytes'},{key:'max_files',label:'单次文件数'},{key:'default_days',label:'默认保留天数'},{key:'max_initial_days',label:'初始保留上限(天)'},{key:'max_lifetime_days',label:'总保留期上限(天)'},{key:'default_downloads',label:'默认下载次数'},{key:'max_downloads',label:'最多下载次数'},{key:'renewal_days',label:'每次续期天数(0关闭)'},{key:'ip_daily_bytes',label:'单 IP 日上传量',unit:'bytes'},{key:'ip_daily_uploads',label:'单 IP 日寄件次数'},{key:'session_daily_uploads',label:'匿名会话日寄件次数'}]
|
||||
const runtimeFields:{key:keyof RuntimeSettings;label:string;bytes?:boolean;min:number}[]=[{key:'file_budget',label:'文件存储预算(MiB)',bytes:true,min:1},{key:'data_budget',label:'服务数据总预算(MiB)',bytes:true,min:65},{key:'min_free_bytes',label:'卷最少保留空间(MiB)',bytes:true,min:0},{key:'audit_retention_days',label:'审计保留天数',min:1},{key:'audit_max_records',label:'最多审计条数',min:1},{key:'audit_max_bytes',label:'审计内容预算(MiB)',bytes:true,min:1}]
|
||||
function numeric(key:keyof Policy){const n=Number(policy.value?.[key]??0);return ['max_file_bytes','max_batch_bytes','ip_daily_bytes'].includes(key)?n/1048576:n}
|
||||
function update(key:keyof Policy,n:number|null){if(!policy.value||n===null)return;(policy.value as unknown as Record<string,unknown>)[key]=['max_file_bytes','max_batch_bytes','ip_daily_bytes'].includes(key)?Math.round(n*1048576):n}
|
||||
function runtimeNumber(key:keyof RuntimeSettings,bytes=false){const n=runtime.value?.[key]??0;return bytes?n/1048576:n}
|
||||
function runtimeUpdate(key:keyof RuntimeSettings,n:number|null,bytes=false){if(runtime.value&&n!==null)runtime.value[key]=bytes?Math.round(n*1048576):n}
|
||||
const fileUsage=computed(()=>(stats.value.object_bytes||0)+(stats.value.reserved_bytes||0))
|
||||
const occupancy=computed(()=>stats.value.file_budget?Math.min(100,fileUsage.value/stats.value.file_budget*100):0)
|
||||
function failed(e:unknown){if(e instanceof APIError&&e.status===401)authenticated.value=false;return (e as Error).message}
|
||||
async function loadStats(){stats.value=await api('/admin/stats')}
|
||||
async function loadParcels(page=parcels.value.page,pageSize=parcels.value.page_size){
|
||||
const request=++parcelRequest;parcelLoading.value=true;parcelError.value=''
|
||||
const q=new URLSearchParams({page:String(page),page_size:String(pageSize)})
|
||||
if(appliedParcelFilters.q.trim())q.set('q',appliedParcelFilters.q.trim())
|
||||
if(appliedParcelFilters.state)q.set('state',appliedParcelFilters.state)
|
||||
try{const result=await api<PageResult<Parcel>>('/admin/parcels?'+q);if(request===parcelRequest){parcels.value=result;await nextTick();parcelScroll.value?.scrollTo(0,0)}}catch(e){if(request===parcelRequest)parcelError.value=failed(e)}finally{if(request===parcelRequest)parcelLoading.value=false}
|
||||
}
|
||||
async function loadAudit(page=audit.value.page,pageSize=audit.value.page_size){
|
||||
const request=++auditRequest;auditLoading.value=true;auditError.value=''
|
||||
const q=new URLSearchParams({page:String(page),page_size:String(pageSize)})
|
||||
for(const key of ['action','source','target'] as const){const value=appliedAuditFilters[key]?.trim();if(value)q.set(key,value)}
|
||||
for(const key of ['from','to'] as const){if(appliedAuditFilters[key])q.set(key,String(Math.floor(new Date(appliedAuditFilters[key]).getTime()/1000)))}
|
||||
try{const result=await api<PageResult<AuditEntry>>('/admin/audit?'+q);if(request===auditRequest){audit.value=result;await nextTick();auditScroll.value?.scrollTo(0,0)}}catch(e){if(request===auditRequest)auditError.value=failed(e)}finally{if(request===auditRequest)auditLoading.value=false}
|
||||
}
|
||||
function searchParcels(){appliedParcelFilters={...parcelFilters};void loadParcels(1)}
|
||||
function resetParcels(){Object.assign(parcelFilters,{q:'',state:null});searchParcels()}
|
||||
function searchAudit(){if(auditFilters.from&&auditFilters.to&&auditFilters.from>auditFilters.to){auditError.value='开始时间不能晚于结束时间';return}appliedAuditFilters={...auditFilters};void loadAudit(1)}
|
||||
function resetAudit(){Object.assign(auditFilters,{action:null,source:'',target:'',from:'',to:''});searchAudit()}
|
||||
async function refreshLists(){try{await Promise.all([loadStats(),loadParcels(),loadAudit()]);error.value=''}catch(e){error.value=failed(e)}}
|
||||
async function load(){try{const [c,r]=await Promise.all([api<Policy>('/admin/policy'),api<RuntimeSettings>('/admin/runtime'),refreshLists()]);policy.value=c;runtime.value=r}catch(e){error.value=failed(e)}}
|
||||
async function login(){busy.value=true;error.value='';try{await post('/admin/login',{username:username.value,password:password.value});password.value='';authenticated.value=true;await load()}catch(e){error.value=failed(e)}finally{busy.value=false}}
|
||||
async function logout(){await post('/admin/logout');authenticated.value=false}
|
||||
async function save(){try{policy.value=await api('/admin/policy',{method:'PUT',body:JSON.stringify(policy.value)});message.success('策略已保存,新上传会采用新策略');await refreshLists()}catch(e){message.error(failed(e))}}
|
||||
function saveRuntime(){dialog.warning({title:'保存容量与审计设置?',content:'容量降低不会删除有效文件,超出预算时暂停新上传。缩短审计天数、条数或内容预算会立即清理最旧记录。',positiveText:'保存设置',negativeText:'取消',onPositiveClick:async()=>{try{runtime.value=await api('/admin/runtime',{method:'PUT',body:JSON.stringify(runtime.value)});await refreshLists();message.success('容量与审计设置已生效')}catch(e){message.error(failed(e))}}})}
|
||||
function remove(p:Parcel,fileId?:string){dialog.warning({title:fileId?'删除这个文件?':'删除这份包裹?',content:'将立即禁止新下载。共享对象只有在所有引用释放后才回收。',positiveText:'删除',negativeText:'取消',onPositiveClick:async()=>{try{await post(fileId?`/admin/files/${fileId}/delete`:`/admin/parcels/${p.id}/delete`);await refreshLists()}catch(e){message.error(failed(e))}}})}
|
||||
function reissue(p:Parcel){dialog.warning({title:'重新发布恢复的包裹?',content:'将按当前默认期限和下载次数创建新的分享授权,所有旧取件码保持失效。',positiveText:'重新发布',negativeText:'取消',onPositiveClick:async()=>{try{share.value=await post(`/admin/parcels/${p.id}/reissue`);await refreshLists()}catch(e){message.error(failed(e))}}})}
|
||||
async function cleanup(){try{await post('/admin/cleanup');message.success('清理完成');await refreshLists()}catch(e){message.error(failed(e))}}
|
||||
onMounted(async()=>{try{authenticated.value=(await api<{authenticated:boolean}>('/admin/status')).authenticated;if(authenticated.value)await load()}catch(e){error.value=failed(e)}})
|
||||
</script>
|
||||
<template>
|
||||
<section class="intro compact"><span class="eyebrow">蓝鸟 · 管理</span><h1>管理控制台。</h1><p>管理服务开关、文件、容量与保留规则。寄件和取件通过公共页面匿名使用。</p></section>
|
||||
<div v-if="error" class="notice error" role="alert">{{error}}</div>
|
||||
<section class="card login-card" v-if="!authenticated"><h2>管理员登录</h2><label class="field-label">账号</label><n-input v-model:value="username" autocomplete="username"/><label class="field-label">密码</label><n-input v-model:value="password" type="password" autocomplete="current-password" @keyup.enter="login"/><n-button type="primary" block :loading="busy" @click="login">登录</n-button></section>
|
||||
<template v-else>
|
||||
<div class="admin-toolbar"><span class="muted">当前传输:上传 {{stats.active_uploads||0}} · 下载 {{stats.active_downloads||0}}</span><n-button @click="load">刷新</n-button><n-button @click="cleanup">清理失效文件</n-button><n-button text @click="logout">退出登录</n-button></div>
|
||||
<div class="stats-grid">
|
||||
<div class="card stat"><small>文件实体占用</small><strong>{{size(stats.object_bytes||0)}}</strong><span>{{stats.objects||0}} 个对象 · {{stats.files||0}} 份文件记录</span></div>
|
||||
<div class="card stat"><small>上传预留</small><strong>{{size(stats.reserved_bytes||0)}}</strong><span>已申请、尚未完成的文件空间</span></div>
|
||||
<div class="card stat"><small>分享逻辑大小</small><strong>{{size(stats.logical_bytes||0)}}</strong><span>相同内容在各分享中分别显示</span></div>
|
||||
<div class="card stat"><small>存储卷可用空间</small><strong>{{size(stats.disk_free_bytes||0)}}</strong><span>卷总容量 {{size(stats.disk_total_bytes||0)}},含其他程序占用</span></div>
|
||||
</div>
|
||||
<section class="card admin-management">
|
||||
<div class="capacity-label"><span>文件使用 / 预算:{{size(fileUsage)}} / {{size(stats.file_budget||0)}}</span><span>服务数据总预算 {{size(stats.data_budget||0)}}</span></div>
|
||||
<div class="capacity-track"><div :style="{width:occupancy+'%'}"></div></div>
|
||||
<div class="capacity-details"><span>文件使用 = 实体文件占用 + 上传预留</span><span>数据库、WAL及隔离对象 {{size(stats.metadata_bytes||0)}}</span></div>
|
||||
<p v-if="fileUsage>stats.file_budget" class="notice capacity-warning">现有使用超过新预算,暂不接收额外文件;已有文件仍按原规则保留。</p>
|
||||
<n-tabs v-model:value="activeTab" type="line" @keydown="tabKey">
|
||||
<n-tab-pane name="parcels" id="panel-parcels" role="tabpanel" :tab-props="tabProps('parcels')" tab="文件管理">
|
||||
<div class="admin-panel">
|
||||
<form class="admin-filters parcel-filters" @submit.prevent="searchParcels">
|
||||
<div><label class="field-label" for="parcel-query">文件名或包裹 ID</label><n-input v-model:value="parcelFilters.q" :input-props="{id:'parcel-query'}" placeholder="支持部分名称或 ID" :maxlength="80" clearable/></div>
|
||||
<div><label class="field-label">包裹状态</label><n-select v-model:value="parcelFilters.state" :options="stateOptions" placeholder="全部状态" clearable/></div>
|
||||
<div class="filter-actions"><n-button type="primary" attr-type="submit" :loading="parcelLoading">搜索</n-button><n-button :disabled="parcelLoading" @click="resetParcels">重置</n-button></div>
|
||||
</form>
|
||||
<div v-if="parcelError" class="notice error" role="alert">{{parcelError}}</div>
|
||||
<div ref="parcelScroll" class="admin-scroll" :aria-busy="parcelLoading">
|
||||
<p v-if="!parcels.items.length" class="empty">{{parcelLoading?'正在加载…':'没有符合条件的包裹。'}}</p>
|
||||
<div v-for="p in parcels.items" :key="p.id" class="admin-parcel">
|
||||
<div class="card-heading"><div><n-tag>{{states[p.state]||p.state}}</n-tag><strong>{{p.count}} 个文件 · {{size(p.size)}}</strong><small>{{date(p.created)}} · 来源 {{p.ip}}</small></div><div class="actions"><n-button v-if="p.state==='restored'" @click="reissue(p)">重新发布</n-button><n-button v-if="p.state!=='revoked'" size="small" type="error" secondary @click="remove(p)">删除包裹</n-button></div></div>
|
||||
<div class="admin-file" v-for="f in p.files" :key="f.id"><span :title="f.name">{{f.name}}</span><small>{{size(f.size)}} · {{states[f.status]}} · 剩余 {{f.remaining}} 次 · {{date(f.expires)}}</small><n-button v-if="f.status!=='deleted'&&f.status!=='deleting'" text type="error" size="small" @click="remove(p,f.id)">删除文件</n-button></div>
|
||||
<code>{{p.id}}</code>
|
||||
</div>
|
||||
</div>
|
||||
<admin-pager label="文件管理分页" unit="个包裹" :page="parcels.page" :page-size="parcels.page_size" :total="parcels.total" :pages="parcels.pages" :loading="parcelLoading" :sizes="[10,20,50]" @page="n=>loadParcels(n)" @size="n=>loadParcels(1,n)"/>
|
||||
</div>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="policy" id="panel-policy" role="tabpanel" :tab-props="tabProps('policy')" tab="服务策略">
|
||||
<div class="admin-panel" v-if="policy"><div class="admin-scroll">
|
||||
<div class="policy-switches"><n-checkbox v-model:checked="policy.anonymous_upload">开放寄件服务</n-checkbox><n-checkbox v-model:checked="policy.anonymous_download">开放取件服务</n-checkbox><n-checkbox v-model:checked="policy.require_password">强制取件密码</n-checkbox></div>
|
||||
<p class="settings-explanation">寄件、取件均无需账号。取消勾选会暂停对应的公共服务,不会要求用户注册;管理员登录也不绕过这些开关。</p>
|
||||
<div class="policy-grid"><div v-for="field in numericFields" :key="field.key"><label class="field-label">{{field.label}}{{field.unit?'(MiB)':''}}</label><n-input-number :value="numeric(field.key)" :min="field.key==='renewal_days'?0:1" @update:value="n=>update(field.key,n)"/></div><div><label class="field-label">续期规则</label><n-select v-model:value="policy.renewal_mode" :options="[{label:'当前到期时间加续期天数',value:'add'},{label:'至少保留至下载后的指定天数',value:'sliding'}]"/></div></div>
|
||||
<div class="notice">新上传采用策略快照。服务开关立即生效;降低期限不改写已有分享。</div>
|
||||
</div><div class="admin-panel-footer"><n-button type="primary" @click="save">保存策略</n-button></div></div>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="runtime" id="panel-runtime" role="tabpanel" :tab-props="tabProps('runtime')" tab="容量与保留">
|
||||
<div class="admin-panel" v-if="runtime"><div class="admin-scroll">
|
||||
<div class="policy-grid runtime-grid"><div v-for="field in runtimeFields" :key="field.key"><label class="field-label">{{field.label}}</label><n-input-number :value="runtimeNumber(field.key,field.bytes)" :min="field.min" @update:value="n=>runtimeUpdate(field.key,n,field.bytes)"/></div></div>
|
||||
<p class="settings-explanation">文件预算限制本服务的实体文件与上传预留,数据总预算另计数据库等维护空间,须至少多留64MiB。保存后立即生效;降低预算不会删掉有效文件。存储卷可用空间属于整个卷,无法按本服务独占计算。</p>
|
||||
<p class="settings-explanation">审计默认保留30天,最多10万条、32MiB内容预算;任一上限先到时清理最旧记录。写入时检查容量,每分钟清理过期记录。内容预算按UTF-8字段和每条开销估算;删除后SQLite复用空闲页,数据库文件不会立即缩小。严格目录占用上限仍需主机文件系统配额。</p>
|
||||
</div><div class="admin-panel-footer"><n-button type="primary" @click="saveRuntime">保存容量与审计设置</n-button></div></div>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="audit" id="panel-audit" role="tabpanel" :tab-props="tabProps('audit')" tab="操作审计">
|
||||
<div class="admin-panel">
|
||||
<form class="admin-filters audit-filters" @submit.prevent="searchAudit">
|
||||
<div><label class="field-label" for="audit-from">开始时间</label><input id="audit-from" v-model="auditFilters.from" class="filter-date" type="datetime-local"/></div>
|
||||
<div><label class="field-label" for="audit-to">结束时间</label><input id="audit-to" v-model="auditFilters.to" class="filter-date" type="datetime-local"/></div>
|
||||
<div><label class="field-label">操作类型</label><n-select v-model:value="auditFilters.action" :options="auditOptions" placeholder="全部操作" clearable filterable tag/></div>
|
||||
<div><label class="field-label" for="audit-source">来源 IP</label><n-input v-model:value="auditFilters.source" :input-props="{id:'audit-source'}" placeholder="IP 或地址片段" :maxlength="100" clearable/></div>
|
||||
<div class="filter-target"><label class="field-label" for="audit-target">目标标识</label><n-input v-model:value="auditFilters.target" :input-props="{id:'audit-target'}" placeholder="包裹或文件 ID,支持片段" :maxlength="80" clearable/></div>
|
||||
<div class="filter-actions"><n-button type="primary" attr-type="submit" :loading="auditLoading">搜索</n-button><n-button :disabled="auditLoading" @click="resetAudit">重置</n-button><span class="muted">时间按当前设备时区</span></div>
|
||||
</form>
|
||||
<p class="audit-summary">已存储 {{stats.audit_records||0}} 条 · 内容估算 {{size(stats.audit_content_bytes||0)}}。保留规则在“容量与保留”设置。</p>
|
||||
<div v-if="auditError" class="notice error" role="alert">{{auditError}}</div>
|
||||
<div ref="auditScroll" class="admin-scroll audit-scroll" :aria-busy="auditLoading">
|
||||
<table class="audit-table"><thead><tr><th>时间</th><th>操作</th><th>目标标识</th><th>来源 IP</th></tr></thead><tbody>
|
||||
<tr v-for="entry in audit.items" :key="entry.id"><td data-label="时间"><time>{{date(entry.created)}}</time></td><td data-label="操作"><strong>{{auditNames[entry.action]||entry.action}}</strong><small>{{entry.action}}</small></td><td data-label="目标标识"><code>{{entry.target||'—'}}</code></td><td data-label="来源 IP"><span>{{entry.source||'—'}}</span></td></tr>
|
||||
</tbody></table>
|
||||
<p v-if="!audit.items.length" class="empty">{{auditLoading?'正在加载…':'没有符合条件的审计记录。'}}</p>
|
||||
</div>
|
||||
<admin-pager label="操作审计分页" unit="条" :page="audit.page" :page-size="audit.page_size" :total="audit.total" :pages="audit.pages" :loading="auditLoading" :sizes="[20,50,100]" @page="n=>loadAudit(n)" @size="n=>loadAudit(1,n)"/>
|
||||
</div>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</section>
|
||||
</template>
|
||||
<share-dialog :share="share" @close="share=null"/>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { NButton } from 'naive-ui'
|
||||
const props = defineProps<{ page:number; pageSize:number; total:number; pages:number; loading:boolean; unit:string; label:string; sizes:number[] }>()
|
||||
const emit = defineEmits<{ page:[number]; size:[number] }>()
|
||||
const jump = ref(props.page)
|
||||
watch(() => props.page, n => jump.value = n)
|
||||
function go() { if (Number.isInteger(jump.value)) emit('page', Math.max(1, Math.min(props.pages, jump.value))) }
|
||||
</script>
|
||||
<template>
|
||||
<nav class="admin-pagination" :aria-label="label">
|
||||
<span class="pagination-total">共 {{total}} {{unit}}</span>
|
||||
<label class="page-size">每页 <select :value="pageSize" :disabled="loading" :aria-label="label+'每页条数'" @change="emit('size',Number(($event.target as HTMLSelectElement).value))"><option v-for="n in sizes" :key="n" :value="n">{{n}}</option></select> {{unit}}</label>
|
||||
<div class="actions page-buttons"><n-button size="small" :disabled="loading||page<=1" @click="emit('page',page-1)">上一页</n-button><span aria-live="polite">第 {{page}} / {{pages}} 页</span><n-button size="small" :disabled="loading||page>=pages" @click="emit('page',page+1)">下一页</n-button></div>
|
||||
<form class="page-jump" @submit.prevent="go"><label>跳至 <input v-model.number="jump" type="number" min="1" :max="pages" :disabled="loading||pages===1" :aria-label="label+'跳转页码'"/> 页</label><n-button size="small" attr-type="submit" :disabled="loading||pages===1">跳转</n-button></form>
|
||||
</nav>
|
||||
</template>
|
||||
@@ -0,0 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, watchEffect } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { NConfigProvider, NMessageProvider, NDialogProvider } from 'naive-ui'
|
||||
const route = useRoute()
|
||||
const admin = computed(() => route.path === '/admin')
|
||||
watchEffect(() => { document.title = admin.value ? '管理控制台 · 蓝鸟' : '蓝鸟 · 文件快递柜' })
|
||||
const theme = { common: { primaryColor:'#1677ff',primaryColorHover:'#4096ff',primaryColorPressed:'#0958d9',borderRadius:'8px',fontFamily:'Inter, "PingFang SC", "Microsoft YaHei", sans-serif' } }
|
||||
</script>
|
||||
<template><n-config-provider :theme-overrides="theme"><n-message-provider><n-dialog-provider><div class="site" :class="{'admin-site':admin}"><header class="header"><component :is="admin?'div':'router-link'" :to="admin?undefined:'/'" class="brand"><img src="/logo.svg?v=2" class="brand-mark" alt="蓝鸟标志" width="42" height="42"/><span>蓝鸟<small>{{admin?'管理控制台':'文件快递柜'}}</small></span></component><nav v-if="!admin" aria-label="主导航"><router-link to="/">寄文件</router-link><router-link to="/pickup">取文件</router-link><router-link to="/mine">我的寄件</router-link></nav><span v-else class="admin-badge">管理控制台</span></header><main><router-view /></main><footer><span>{{admin?'蓝鸟 · 管理控制台':'文件随手寄,凭码安心取。'}}</span><span>{{admin?'系统与存储管理':'匿名分享 · 自主存储'}}</span></footer></div></n-dialog-provider></n-message-provider></n-config-provider></template>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { ref,onMounted } from 'vue'
|
||||
import { NButton,NTag,useMessage,useDialog } from 'naive-ui'
|
||||
import { api,post,size,date,states,APIError,type Parcel,type Share,type PublicPolicy,type FileRecord } from './api'
|
||||
import ShareDialog from './ShareDialog.vue'
|
||||
const receiptInput=ref<HTMLInputElement>();const message=useMessage();const dialog=useDialog();const parcels=ref<Parcel[]>([]);const error=ref('');const share=ref<Share|null>(null);const visitorExpires=ref(0)
|
||||
const token=(p:Parcel)=>sessionStorage.getItem('manage:'+p.id)||undefined
|
||||
async function load(){try{error.value='';visitorExpires.value=(await api<PublicPolicy>('/policy')).visitor_expires;parcels.value=await api('/mine');const keys=Array.from({length:sessionStorage.length},(_,i)=>sessionStorage.key(i)!).filter(k=>k.startsWith('manage:'));for(const key of keys){if(!parcels.value.some(p=>p.id===key.slice(7))){try{parcels.value.unshift(await fetchReceipt(key.slice(7),sessionStorage.getItem(key)!))}catch{sessionStorage.removeItem(key)}}}}catch(e){error.value=(e as Error).message}}
|
||||
async function code(p:Parcel){try{share.value=await post(`/parcels/${p.id}/code`,{},token(p));message.info('新取件码已生成,旧码已失效')}catch(e){message.error((e as Error).message)}}
|
||||
function revoke(p:Parcel){dialog.warning({title:'撤销这份寄件?',content:'撤销后不再允许新下载,正在传输的请求可以结束。',positiveText:'撤销',negativeText:'保留',onPositiveClick:async()=>{try{await post(`/parcels/${p.id}/revoke`,{},token(p));await load()}catch(e){message.error((e as Error).message)}}})}
|
||||
function exportReceipt(p:Parcel){const t=token(p);if(!t){message.info('原始管理凭据未保存在当前标签页。仍可凭有效浏览器身份管理寄件。');return};const blob=new Blob([JSON.stringify({parcel:p.id,manage_token:t},null,2)],{type:'application/json'});const url=URL.createObjectURL(blob);const a=document.createElement('a');a.href=url;a.download=`蓝鸟-${p.id}.json`;a.click();setTimeout(()=>URL.revokeObjectURL(url),1000)}
|
||||
async function fetchReceipt(id:string,t:string){try{const p=await api<{id:string;state:string;created:number;expires:number;files:FileRecord[]}>(`/uploads/${id}`,{headers:{Authorization:`Bearer ${t}`}});const available=p.files.some(f=>f.available);return {...p,state:p.state==='ready'&&!available?'exhausted':p.state,count:p.files.length,size:p.files.reduce((s,f)=>s+f.size,0)}}catch(e){if(e instanceof APIError&&e.status===404)throw new Error('管理凭据无效或寄件记录已清理,无法恢复文件');throw e}}
|
||||
async function importReceipt(event:Event){const input=event.target as HTMLInputElement;const file=input.files?.[0];input.value='';if(!file)return;try{if(file.size>2048)throw new Error('管理凭据文件过大');const r=JSON.parse(await file.text());if(!/^[a-f0-9]{32}$/.test(r.parcel)||!/^[A-Za-z0-9_-]{43}$/.test(r.manage_token))throw new Error('管理凭据格式不正确');const p=await fetchReceipt(r.parcel,r.manage_token);sessionStorage.setItem('manage:'+p.id,r.manage_token);await load();if(!['ready','uploading'].includes(p.state))message.warning('已找到历史寄件记录,但文件已失效或被删除;凭据不能恢复文件');else message.success('管理凭据已导入,仅保存在当前标签页')}catch(e){message.error((e as Error).message)}}
|
||||
onMounted(load)
|
||||
</script>
|
||||
<template><section class="intro compact"><span class="eyebrow">蓝鸟 · 我的寄件</span><h1>我的寄件。</h1><p>查看当前浏览器中的寄件记录,或导入已保存的管理凭据。</p></section><div v-if="error" class="notice error" role="alert">{{error}}</div><section class="card"><div class="card-heading"><h2>最近寄件</h2><div class="actions"><input ref="receiptInput" type="file" accept="application/json,.json" hidden @change="importReceipt"/><n-button @click="receiptInput?.click()">导入管理凭据</n-button><n-button text @click="load">刷新</n-button></div></div><details class="receipt-info"><summary>管理凭据有什么用?</summary><p>管理凭据用于证明某份寄件属于你。导出并保存 JSON 凭据文件后,可以在另一台设备或浏览器导入,用于撤销寄件或重新生成取件码。</p><p>凭据包含该寄件的管理权限,请妥善保管,不要随取件码一起转发给收件人。</p><p>凭据不是文件备份,不会延长保留时间。文件到期、次数用尽或被删除后,导入凭据不能恢复文件;寄件记录被清理后,凭据失效。</p></details><p v-if="!parcels.length" class="empty">这里还没有寄件。<router-link to="/">去上传一份文件</router-link></p><article class="parcel-row" v-for="p in parcels" :key="p.id"><div><n-tag :type="p.state==='ready'?'success':'default'">{{states[p.state]||p.state}}</n-tag><strong>{{p.count}} 个文件 · {{size(p.size)}}</strong><small>{{date(p.created)}} 寄出 · 到期 {{date(p.expires)}}</small><code>{{p.id.slice(0,12)}}</code><p class="parcel-note" v-if="!['ready','uploading'].includes(p.state)">历史记录仅供查看,管理凭据不能恢复已失效或删除的文件。</p></div><div class="actions"><n-button v-if="p.state==='ready'" @click="code(p)">重新生成取件码</n-button><n-button v-if="p.state==='ready'||p.state==='uploading'" type="error" secondary @click="revoke(p)">撤销</n-button><n-button v-if="['ready','uploading'].includes(p.state)" text :disabled="!token(p)" @click="exportReceipt(p)">导出管理凭据</n-button></div></article><p class="muted" v-if="visitorExpires">此浏览器身份有效至 {{date(visitorExpires)}}。凭据只管理对应寄件,文件保留期另行计算。</p></section><share-dialog :share="share" @close="share=null"/></template>
|
||||
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { NInput, NButton } from 'naive-ui'
|
||||
import { api, post, APIError, size, date, type FileRecord } from './api'
|
||||
const route=useRoute();const code=ref(String(route.params.code ?? ''));const password=ref('');const needsPassword=ref(false);const busy=ref(false);const error=ref('');const parcel=ref<{id:string;files:FileRecord[];renewal_days:number;renewal_mode:string}|null>(null)
|
||||
async function resolve(){busy.value=true;error.value='';try{parcel.value=await post('/pickup/resolve',{code:code.value,password:password.value});needsPassword.value=false}catch(e){if(e instanceof APIError&&e.body.password_required)needsPassword.value=true;error.value=(e as Error).message}finally{busy.value=false}}
|
||||
async function refresh(){try{parcel.value=await api('/pickup')}catch(e){error.value=(e as Error).message;parcel.value=null}}
|
||||
function download(file:FileRecord){const a=document.createElement('a');a.href=`/api/v1/pickup/files/${file.id}/download`;a.download=file.name;a.click();setTimeout(refresh,1500)}
|
||||
onMounted(()=>{if(code.value)resolve()});watch(()=>route.params.code,v=>{code.value=String(v??'');parcel.value=null;password.value='';error.value='';needsPassword.value=false;if(code.value)resolve()})
|
||||
</script>
|
||||
<template><section class="intro compact"><span class="eyebrow">蓝鸟 · 取件</span><h1>凭码取件。</h1><p>输入取件码,即可收取对方分享的文件。</p></section><section class="card pickup-card"><template v-if="!parcel"><label class="field-label">取件码</label><n-input v-model:value="code" size="large" placeholder="例如 7K3M-P9TX-2R" :maxlength="20" @keyup.enter="resolve"/><div v-if="needsPassword" class="password-field"><label class="field-label">取件密码</label><n-input v-model:value="password" type="password" size="large" placeholder="输入上传者设置的密码" :maxlength="128" @keyup.enter="resolve"/></div><div v-if="error" class="notice error" role="alert">{{error}}</div><n-button type="primary" size="large" block :loading="busy" :disabled="!code" @click="resolve">查找文件</n-button><p class="muted">扫码后会自动填入取件码。不会自动下载文件。</p></template><template v-else><div class="card-heading"><h2>你的文件已就位</h2><n-button text @click="parcel=null;password='';code=''">换一个取件码</n-button></div><div v-if="error" class="notice error">{{error}}</div><div class="file-row pickup-file" v-for="f in parcel.files" :key="f.id"><span class="file-icon">↓</span><div class="file-info"><strong>{{f.name}}</strong><small>{{size(f.size)}} · 剩余 {{f.remaining}} 次</small><small>到期 {{date(f.expires)}}</small></div><n-button type="primary" secondary :disabled="!f.available" @click="download(f)">{{f.available?'下载':'已失效'}}</n-button></div><div class="notice" v-if="parcel.renewal_days">成功传输后,{{parcel.renewal_mode==='add'?`本文件延长 ${parcel.renewal_days} 天`:`本文件至少保留到下载后 ${parcel.renewal_days} 天`}},仍受总保留期上限约束。</div><p class="muted">开始下载即计一次;中途断开不会返还次数,也不会续期。授权 10 分钟有效。</p><n-button text @click="refresh">刷新状态</n-button></template></section></template>
|
||||
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { NButton, NInput, NInputNumber, NCheckbox, NProgress, useMessage } from 'naive-ui'
|
||||
import { api, post, upload, size, type PublicPolicy, type Share } from './api'
|
||||
import ShareDialog from './ShareDialog.vue'
|
||||
const message = useMessage(); const config = ref<PublicPolicy>(); const error = ref(''); const files = ref<File[]>([]); const days = ref(1); const downloads = ref(5); const password = ref(''); const renew = ref(true); const input = ref<HTMLInputElement>(); const busy = ref(false); const percentages = ref<number[]>([]); const session = ref<{id:string;manage_token:string;files:{id:string}[]} | null>(null); const share = ref<Share|null>(null); const published = ref(false); const dragging = ref(false); let controller: AbortController | null = null
|
||||
const total = computed(() => files.value.reduce((n,f) => n+f.size,0)); const policy = computed(() => config.value?.policy)
|
||||
onMounted(async () => { try { config.value = await api<PublicPolicy>('/policy'); days.value = config.value.policy.default_days; downloads.value = config.value.policy.default_downloads } catch(e) { error.value = (e as Error).message } })
|
||||
onBeforeUnmount(() => controller?.abort())
|
||||
function select(list: FileList | null) { if (!list || busy.value || session.value) return; const next = [...files.value,...Array.from(list)]; if (!policy.value) return; if (next.length > policy.value.max_files || next.some(f => f.size > policy.value!.max_file_bytes) || next.reduce((n,f)=>n+f.size,0)>policy.value.max_batch_bytes) { message.error('文件数量或大小超过当前限制'); return } files.value=next; if (input.value) input.value.value='' }
|
||||
function drop(event: DragEvent) { dragging.value=false; select(event.dataTransfer?.files ?? null) }
|
||||
async function send() {
|
||||
if (!files.value.length || !policy.value) return; if ((password.value || policy.value.require_password) && new TextEncoder().encode(password.value).length<8) { message.warning('取件密码至少8字节'); return }
|
||||
busy.value=true; error.value=''; controller = new AbortController()
|
||||
try { if (!session.value) { session.value=await post<{id:string;manage_token:string;files:{id:string}[]}>('/uploads',{files:files.value.map(f=>({name:f.name,size:f.size})),days:days.value,downloads:downloads.value,password:password.value,renew:renew.value}); percentages.value=files.value.map(()=>0); sessionStorage.setItem('manage:'+session.value!.id,session.value!.manage_token) }
|
||||
const work = files.value.map((_,i)=>i).filter(i=>percentages.value[i]!==100); let cursor=0
|
||||
const worker = async () => { while(cursor<work.length) { const i=work[cursor++]; await upload(`/uploads/${session.value!.id}/files/${session.value!.files[i].id}`,files.value[i],session.value!.manage_token,n=>{percentages.value[i]=Math.min(n,99)},controller!.signal); percentages.value[i]=100 } }
|
||||
const results=await Promise.allSettled([worker().catch(e=>{controller!.abort();throw e}),worker().catch(e=>{controller!.abort();throw e})]);const failed=results.find(r=>r.status==='rejected');if(failed?.status==='rejected')throw failed.reason; share.value=await post<Share>(`/uploads/${session.value!.id}/publish`,{},session.value!.manage_token); published.value=true; message.success('上传完成,已生成取件码')
|
||||
} catch(e) { controller.abort(); error.value=(e as Error).message; if (session.value) { try { const status=await api<{state:string;files:{status:string}[]}>(`/uploads/${session.value!.id}`,{headers:{Authorization:`Bearer ${session.value!.manage_token}`}}); percentages.value=status.files.map(f=>f.status==='ready'?100:0); if(status.state==='ready') { share.value=await post<Share>(`/parcels/${session.value!.id}/code`,{},session.value!.manage_token);published.value=true } } catch{} } } finally { busy.value=false }
|
||||
}
|
||||
async function cancel() { controller?.abort(); if(session.value) { try { await post(`/parcels/${session.value!.id}/revoke`,{},session.value!.manage_token); session.value=null; files.value=[]; percentages.value=[]; error.value='' }catch(e){error.value=(e as Error).message} } }
|
||||
function reset() { files.value=[]; session.value=null; percentages.value=[]; published.value=false; password.value='';error.value='' }
|
||||
</script>
|
||||
<template><section class="intro"><span class="eyebrow">蓝鸟 · 寄件</span><h1>寄一份文件,<br/><span>分享少一点麻烦。</span></h1><p>无需注册。上传后获取取件码,电脑与手机都能轻松收取。</p></section><div v-if="error" class="notice error" role="alert">{{error}}</div><div class="send-layout" v-if="policy"><section class="card upload-card"><div class="card-heading"><h2>准备寄件</h2><span>{{files.length}} / {{policy.max_files}} 个文件</span></div><input ref="input" type="file" multiple hidden @change="select(($event.target as HTMLInputElement).files)"/><div class="dropzone" :class="{dragging}" role="button" tabindex="0" aria-label="选择上传文件" @click="!session && input?.click()" @keydown.enter="!session && input?.click()" @dragover.prevent="dragging=true" @dragleave.prevent="dragging=false" @drop.prevent="drop"><span class="upload-glyph">↑</span><h3>{{files.length?'还可以继续添加文件':'将文件拖到这里'}}</h3><p>或点击选择文件</p><small>单文件 ≤ {{size(policy.max_file_bytes)}} · 一次最多 {{policy.max_files}} 个</small></div><div class="file-list" v-if="files.length"><div class="file-row" v-for="(file,i) in files" :key="i"><span class="file-icon">↗</span><div class="file-info"><strong>{{file.name}}</strong><small>{{size(file.size)}}<span v-if="percentages[i]===100"> · 已完成</span></small><n-progress v-if="busy || percentages[i]" type="line" :percentage="percentages[i] || 0" :show-indicator="false" /></div><n-button v-if="!session" quaternary size="small" aria-label="移除文件" @click="files.splice(i,1)">×</n-button></div></div><div class="upload-bottom"><span>总计 {{size(total)}} / {{size(policy.max_batch_bytes)}}</span><n-button v-if="published" type="primary" @click="reset">再寄一份</n-button><n-button v-else type="primary" :loading="busy" :disabled="!files.length || !policy.anonymous_upload" @click="send">{{session?'继续上传':'上传并生成取件码'}}</n-button></div><n-button v-if="session&&!published" text @click="cancel">取消本次寄件</n-button><p v-if="!policy.anonymous_upload" class="notice">管理员暂时关闭了上传。</p></section><aside class="card settings-card"><span class="eyebrow">取件规则</span><h2>设置取件规则</h2><label>初始保留时间<span>最多 {{policy.max_initial_days}} 天</span></label><n-input-number v-model:value="days" :min="1" :max="policy.max_initial_days" :disabled="!!session"><template #suffix>天</template></n-input-number><label>每个文件的下载次数<span>最多 {{policy.max_downloads}} 次</span></label><n-input-number v-model:value="downloads" :min="1" :max="policy.max_downloads" :disabled="!!session"><template #suffix>次</template></n-input-number><label>取件密码<span>{{policy.require_password?'必填':'可选'}}</span></label><n-input v-model:value="password" type="password" show-password-on="click" placeholder="至少 8 字节" :maxlength="128" :disabled="!!session"/><div class="renew-option"><n-checkbox v-model:checked="renew" :disabled="!!session || !policy.renewal_days">下载后自动续期</n-checkbox><p v-if="policy.renewal_days">{{policy.renewal_mode==='add'?`每次成功下载延长 ${policy.renewal_days} 天`:`至少保留至下载后的 ${policy.renewal_days} 天`}},最长总保留 {{policy.max_lifetime_days}} 天。次数不会重置。</p><p v-else>管理员已关闭自动续期。</p></div><div class="privacy-note"><strong>分享码就是取件钥匙</strong><p>重要文件请设置密码,并通过不同渠道发送。上传后可在“我的寄件”中提前撤销。</p></div></aside></div><share-dialog :share="share" @close="share=null"/></template>
|
||||
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { NModal, NButton, NSelect, useMessage } from 'naive-ui'
|
||||
import QRCode from 'qrcode'
|
||||
import { copyText } from './clipboard'
|
||||
import { date, type Share } from './api'
|
||||
const props = defineProps<{ share: Share | null }>()
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
const dialogContent = ref<HTMLElement>(); const shareURL=ref(''); const qr = ref(''); let qrRequest=0; const message = useMessage()
|
||||
watch(() => props.share, v => {shareURL.value=v?.url||''}, {immediate:true})
|
||||
watch(shareURL, async v => {const request=++qrRequest;const value=v?await QRCode.toDataURL(v,{width:232,margin:1,errorCorrectionLevel:'M'}):'';if(request===qrRequest)qr.value=value})
|
||||
async function copy(kind:'code'|'url') { try { if (!props.share || !dialogContent.value) return; await copyText(kind==='code'?props.share.code:shareURL.value, dialogContent.value); message.success(kind==='code'?'取件码已复制':'取件链接已复制') } catch(e) { message.info((e as Error).message) } }
|
||||
</script>
|
||||
<template><n-modal :show="!!share" @update:show="v => !v && emit('close')"><div ref="dialogContent" class="share-dialog" role="dialog" aria-modal="true" aria-labelledby="share-title" v-if="share"><span class="eyebrow">蓝鸟寄件</span><h2 id="share-title">文件已寄出</h2><p>只分享取件码,或发送链接与二维码</p><div class="share-entry" v-if="share.urls&&share.urls.length>1"><label>分享入口</label><n-select v-model:value="shareURL" :options="share.urls.map(v=>({label:v.label,value:v.url}))"/></div><div class="pickup-code">{{ share.code.slice(0,4) }}-{{ share.code.slice(4,8) }}-{{ share.code.slice(8) }}</div><img v-if="qr" :src="qr" width="232" height="232" alt="取件二维码"/><input class="share-url" :value="shareURL" readonly aria-label="取件链接"/><p class="share-hint">取件码不包含网址;链接和二维码包含完整网址。</p><small>到期时间:{{ date(share.expires) }}</small><div class="actions"><n-button type="primary" @click="copy('code')">复制取件码</n-button><n-button @click="copy('url')">复制取件链接</n-button><n-button @click="emit('close')">完成</n-button></div></div></n-modal></template>
|
||||
@@ -0,0 +1,23 @@
|
||||
export interface Policy { anonymous_upload: boolean; anonymous_download: boolean; max_file_bytes: number; max_batch_bytes: number; max_files: number; default_days: number; max_initial_days: number; max_lifetime_days: number; default_downloads: number; max_downloads: number; renewal_days: number; renewal_mode: 'add' | 'sliding'; ip_daily_bytes: number; ip_daily_uploads: number; session_daily_uploads: number; require_password: boolean }
|
||||
export interface FileRecord { id: string; name: string; size: number; status: string; remaining: number; expires: number; available: boolean }
|
||||
export interface Parcel { id: string; state: string; created: number; expires: number; count: number; size: number; ip?: string; files?: FileRecord[]; renewal_days?: number }
|
||||
export interface Share { code: string; url: string; urls?: {label:string;url:string}[]; expires: number }
|
||||
export interface PublicPolicy { policy: Policy; public_url: string; encryption: boolean; deduplication: boolean; visitor_expires:number }
|
||||
export class APIError extends Error { constructor(public status: number, public body: Record<string, unknown>) { super(String(body.error ?? '请求失败')) } }
|
||||
export async function api<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const response = await fetch('/api/v1' + path, { credentials: 'same-origin', ...options, headers: { 'X-Bluebird-Origin': location.origin, ...(options.body ? { 'Content-Type': 'application/json' } : {}), ...options.headers } })
|
||||
const body = await response.json().catch(() => ({ error: '服务器返回异常响应' }))
|
||||
if (!response.ok) throw new APIError(response.status, body)
|
||||
return body as T
|
||||
}
|
||||
export const post = <T>(path: string, body: unknown = {}, token?: string) => api<T>(path, { method: 'POST', body: JSON.stringify(body), headers: token ? { Authorization: `Bearer ${token}` } : {} })
|
||||
export const size = (n: number) => { if (!n) return '0 B'; const i = Math.min(3, Math.floor(Math.log(n) / Math.log(1024))); return (n / 1024 ** i).toFixed(i ? 1 : 0) + ' ' + ['B', 'KiB', 'MiB', 'GiB'][i] }
|
||||
export const date = (n: number) => n ? new Date(n * 1000).toLocaleString('zh-CN', { hour12: false }) : '—'
|
||||
export const states: Record<string,string> = { uploading: '上传中', ready: '可取件', revoked: '已撤销', expired: '已过期', exhausted: '已失效', restored: '恢复待审核', deleted: '已清理', pending: '待上传', missing: '文件异常', deleting: '待清理' }
|
||||
export function upload(path: string, file: File, token: string, progress: (n: number) => void, signal: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('PUT', '/api/v1' + path); xhr.setRequestHeader('Authorization', `Bearer ${token}`); xhr.setRequestHeader('Content-Type', 'application/octet-stream'); xhr.setRequestHeader('X-Bluebird-Origin',location.origin); xhr.upload.onprogress = e => progress(e.lengthComputable ? Math.round(e.loaded / e.total * 100) : 0); const abort = () => xhr.abort(); signal.addEventListener('abort', abort, { once: true }); const done = () => signal.removeEventListener('abort', abort); xhr.onload = () => { done(); if (xhr.status >= 200 && xhr.status < 300) resolve(); else { let text = '上传失败'; try { text = JSON.parse(xhr.responseText).error } catch {} reject(new Error(text)) } }; xhr.onerror = () => { done(); reject(new Error('网络中断,请重试')) }; xhr.onabort = () => { done(); reject(new Error('已取消上传')) }; xhr.send(file) })
|
||||
}
|
||||
export interface RuntimeSettings { file_budget:number; data_budget:number; min_free_bytes:number; audit_retention_days:number; audit_max_records:number; audit_max_bytes:number }
|
||||
|
||||
export interface PageResult<T> { items:T[]; total:number; page:number; page_size:number; pages:number }
|
||||
export interface AuditEntry { id:number; created:number; action:string; target:string; source:string }
|
||||
@@ -0,0 +1,27 @@
|
||||
// LAN HTTP uses a selected readonly field; keep it inside the active modal so
|
||||
// its focus trap cannot redirect focus and silently copy different content.
|
||||
export async function copyText(value: string, container: HTMLElement): Promise<void> {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
try { await navigator.clipboard.writeText(value); return } catch {}
|
||||
}
|
||||
const previous = document.activeElement
|
||||
const node = document.createElement('textarea')
|
||||
node.value = value
|
||||
node.readOnly = true
|
||||
node.setAttribute('inputmode', 'none')
|
||||
// Stay in the viewport for mobile selection. Readonly avoids a soft keyboard;
|
||||
// 16px avoids iOS zoom, and opacity keeps this temporary field invisible.
|
||||
node.style.cssText = 'position:fixed;left:0;top:0;width:1px;height:1px;padding:0;border:0;font-size:16px;opacity:0;pointer-events:none'
|
||||
container.appendChild(node)
|
||||
try {
|
||||
node.focus({ preventScroll: true })
|
||||
node.select()
|
||||
node.setSelectionRange(0, value.length)
|
||||
if (document.activeElement !== node || node.selectionStart !== 0 || node.selectionEnd !== value.length || !document.execCommand('copy')) {
|
||||
throw new Error('复制未完成,请选择或长按取件码、链接手动复制')
|
||||
}
|
||||
} finally {
|
||||
node.remove()
|
||||
if (previous instanceof HTMLElement && previous.isConnected) previous.focus({ preventScroll: true })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import './style.css'
|
||||
const router = createRouter({ history: createWebHashHistory(), routes: [{ path: '/', component: () => import('./Send.vue') }, { path: '/pickup/:code?', component: () => import('./Pickup.vue') }, { path: '/mine', component: () => import('./Mine.vue') }, { path: '/admin', component: () => import('./Admin.vue') }] })
|
||||
createApp(App).use(router).use(createPinia()).mount('#app')
|
||||
@@ -0,0 +1,26 @@
|
||||
:root{font-family:Inter,"PingFang SC","Microsoft YaHei",sans-serif;color:#1d2939;background:#f5f7fb;font-synthesis:none}*{box-sizing:border-box}body{margin:0}a{color:#1677ff;text-decoration:none}button,input{font:inherit}.site{max-width:1200px;margin:auto;padding:0 36px}.header{height:106px;display:flex;align-items:center;gap:32px;border-bottom:1px solid #e4eaf3}.brand{display:flex;align-items:center;gap:12px;font-size:22px;font-weight:750;color:#1d2939;letter-spacing:-.8px}.brand small{display:block;font-size:11px;letter-spacing:2px;font-weight:450;margin-top:1px;color:#667085}.brand-mark{height:42px;width:42px;background:#1677ff;color:white;border-radius:13px 13px 13px 3px;display:grid;place-items:center;font:italic 28px Georgia}.header nav{display:flex;gap:30px;margin-left:auto}.header nav a{color:#667085;font-size:14px}.header nav a.router-link-exact-active{color:#1677ff;font-weight:700}.admin-link{margin-left:24px;color:#7c8b92;font-size:13px}main{min-height:calc(100vh - 204px);padding-bottom:56px}.intro{padding:58px 0 40px}.eyebrow{display:block;color:#1677ff;letter-spacing:2.4px;font-size:10px;font-weight:700}.intro h1{font-size:46px;line-height:1.3;letter-spacing:-1.8px;margin:14px 0 18px;font-weight:650}.intro h1 span{color:#1677ff}.intro p{color:#667085;font-size:14px}.intro.compact{padding:46px 0 24px}.intro.compact h1{font-size:38px}.card{background:#fff;border:1px solid #e4eaf3;border-radius:18px;padding:28px;box-shadow:0 6px 26px #1677ff08}.send-layout{display:grid;grid-template-columns:minmax(0,1.65fr) minmax(280px,1fr);gap:24px;align-items:start}.card-heading{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:20px}.card-heading h2{font-size:19px;margin:0;font-weight:650}.card-heading>span{font-size:12px;color:#8b989d}.dropzone{border:1.5px dashed #b7d4ff;background:#f7faff;border-radius:13px;padding:30px 16px;text-align:center;cursor:pointer}.dropzone.dragging{border-color:#1677ff;background:#e6f4ff}.upload-glyph{display:grid;place-items:center;border:1px solid #d6e8ff;border-radius:14px;background:white;width:54px;height:54px;margin:auto;font-size:30px;color:#1677ff}.dropzone h3{font-size:16px;font-weight:600;margin:18px 0 6px}.dropzone p{font-size:13px;color:#667085;margin:0 0 15px}.dropzone small{font-size:11px;color:#7a8699}.file-list{margin-top:12px}.file-row{display:flex;align-items:center;gap:14px;padding:16px 0;border-bottom:1px solid #edf1f7;min-width:0}.file-icon{display:grid;place-items:center;flex-shrink:0;width:38px;height:44px;background:#e6f4ff;border-radius:8px;color:#1677ff;font-size:21px}.file-info{min-width:0;flex:1}.file-info strong{font-size:13px;font-weight:550;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.file-info small{display:block;font-size:11px;color:#87969d;margin:5px 0}.upload-bottom{margin:22px 0 10px;display:flex;align-items:center;justify-content:space-between;gap:16px}.upload-bottom>span{font-size:11px;color:#8d9b9e}.settings-card h2{font-size:19px;margin:12px 0 26px}.settings-card label:not(.n-checkbox){display:flex;justify-content:space-between;align-items:center;font-size:12px;margin:20px 0 9px}.settings-card label span{color:#9aa6aa;font-size:11px}.renew-option{margin-top:24px}.renew-option p,.privacy-note p{font-size:11px;line-height:1.9;color:#88979b;margin:8px 0}.privacy-note{margin-top:25px;border-top:1px solid #e4eaf3;padding-top:20px}.privacy-note strong{font-size:12px;color:#475467}.share-dialog{max-width:420px;width:calc(100vw - 32px);background:white;border-radius:20px;padding:32px;text-align:center}.share-dialog h2{font-size:27px;margin:12px 0}.share-dialog p{color:#88979b;font-size:13px}.pickup-code{font:700 25px monospace;letter-spacing:2px;color:#1677ff;background:#e6f4ff;padding:16px 8px;border-radius:10px;margin:22px 0}.share-dialog img{display:block;margin:12px auto}.share-url{width:100%;font-size:11px;border:1px solid #d6e8ff;border-radius:7px;background:#f7faff;padding:10px;outline:none}.share-dialog small{display:block;color:#8c9ca0;margin:14px 0 20px}.actions{display:flex;gap:10px;align-items:center;flex-wrap:wrap}.share-dialog .actions{justify-content:center}.notice{padding:12px 16px;background:#e6f4ff;color:#0958d9;border-radius:10px;margin:16px 0;font-size:12px;line-height:1.8}.notice.error{background:#fbefed;color:#b35d52}.pickup-card,.login-card{max-width:680px;margin:auto}.pickup-card .n-button--block{margin-top:22px}.field-label{display:block;font-size:12px;color:#475467;margin-bottom:9px}.password-field{margin-top:20px}.muted{font-size:12px;color:#8b9b9f;line-height:1.8}.pickup-file{padding:22px 0}.parcel-row{display:flex;justify-content:space-between;align-items:center;gap:20px;padding:22px 0;border-top:1px solid #e4eaf3}.parcel-row strong{display:inline-block;margin-left:12px;font-size:14px}.parcel-row small,.parcel-row code{display:block;color:#8c9ca2;font-size:11px;margin-top:9px}.empty{text-align:center;padding:32px;color:#879a9e;font-size:14px}.login-card{max-Line truncated
|
||||
|
||||
.admin-badge{margin-left:auto;color:#1677ff;background:#e6f4ff;padding:7px 13px;border-radius:8px;font-size:12px}.brand-mark{background:none;border-radius:12px;flex-shrink:0}.share-hint{font-size:11px!important;line-height:1.8}.settings-explanation{line-height:1.8;font-size:12px;color:#667085}.runtime-grid{margin-top:20px}.settings-section-title{font-size:17px;margin:26px 0 10px}.capacity-details{display:flex;gap:20px;flex-wrap:wrap;font-size:12px;color:#667085;margin-bottom:15px}.receipt-info summary{cursor:pointer;color:#1677ff;font-size:12px}.receipt-info p{font-size:12px;color:#667085;line-height:1.9}.parcel-note{font-size:12px;color:#667085;margin:10px 0}.closed-service{margin-bottom:18px}.capacity-warning{color:#cf1322}.public-nav-hidden{display:none}
|
||||
|
||||
.pickup-code{-webkit-user-select:text;user-select:text}
|
||||
|
||||
html{scrollbar-gutter:stable}
|
||||
.admin-panel{display:flex;flex-direction:column;gap:16px;min-width:0}
|
||||
.admin-panel>.notice{margin:0;flex-shrink:0}
|
||||
.admin-scroll{min-width:0}
|
||||
.admin-panel-footer,.admin-pagination{flex-shrink:0;border-top:1px solid #e4eaf3;padding-top:16px}
|
||||
.admin-filters{display:grid;gap:12px;flex-shrink:0;align-items:end}
|
||||
.admin-filters>div{min-width:0}.parcel-filters{grid-template-columns:minmax(0,2fr) minmax(150px,1fr) auto}.audit-filters{grid-template-columns:repeat(4,minmax(0,1fr))}.filter-target,.audit-filters .filter-actions{grid-column:span 2}
|
||||
.filter-actions{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.filter-date{width:100%;height:34px;padding:0 9px;border:1px solid #e0e0e6;border-radius:8px;color:#1d2939;background:white;font:inherit;font-size:13px;min-width:0}
|
||||
.filter-date:focus,.admin-pagination select:focus,.page-jump input:focus{outline:2px solid #91caff;outline-offset:1px}
|
||||
.admin-pagination{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;color:#667085;font-size:12px}
|
||||
.page-size,.page-jump,.page-jump label{display:flex;align-items:center;gap:8px}.page-size select,.page-jump input{border:1px solid #d6e8ff;border-radius:6px;height:30px;padding:0 6px;background:#fff;color:#1d2939;font:inherit}.page-jump input{width:60px}.page-size select{width:62px}.page-buttons{flex-wrap:nowrap}.page-buttons span{white-space:nowrap}
|
||||
.audit-summary{margin:0;color:#667085;font-size:12px;line-height:1.8;flex-shrink:0}
|
||||
.audit-table{width:100%;border-collapse:separate;border-spacing:0;table-layout:fixed;font-size:12px}.audit-table th{position:sticky;top:0;text-align:left;padding:12px 10px;background:#f7faff;color:#667085;z-index:1;border-bottom:1px solid #e4eaf3;font-weight:550}.audit-table th:nth-child(1){width:22%}.audit-table th:nth-child(2){width:23%}.audit-table th:nth-child(3){width:33%}.audit-table th:nth-child(4){width:22%}.audit-table td{padding:14px 10px;border-bottom:1px solid #edf1f7;vertical-align:top;overflow-wrap:anywhere;color:#475467}.audit-table strong{display:block;color:#0958d9;font-weight:550}.audit-table small{display:block;color:#8b9b9f;font-size:11px;margin-top:5px}.audit-table code{font-size:11px}
|
||||
.admin-panel .admin-parcel:first-child{padding-top:8px}.admin-parcel>code{overflow-wrap:anywhere}
|
||||
@media(max-width:900px){.audit-filters{grid-template-columns:repeat(2,minmax(0,1fr))}.parcel-filters{grid-template-columns:minmax(0,2fr) minmax(0,1fr)}.parcel-filters .filter-actions{grid-column:1/-1}}
|
||||
@media(max-width:600px){.admin-toolbar{flex-wrap:wrap}.admin-toolbar>.muted{width:100%;text-align:right}.filter-date{font-size:16px}.admin-pagination{gap:10px}.page-jump{margin-left:auto}.page-buttons{width:100%;justify-content:center;order:3}.audit-table thead{display:none}.audit-table,.audit-table tbody,.audit-table tr,.audit-table td{display:block;width:100%}.audit-table tr{padding:10px 0;border-bottom:1px solid #e4eaf3}.audit-table td{padding:4px 0;border:0;display:grid;grid-template-columns:70px minmax(0,1fr);gap:6px}.audit-table td::before{content:attr(data-label);color:#8b9b9f;font-size:11px}.audit-table td small{grid-column:2;margin:0}.audit-filters .filter-actions{grid-column:1/-1}.audit-filters .filter-actions>.muted{font-size:11px}.admin-filters{gap:10px}.page-size select,.page-jump input{font-size:16px}}
|
||||
|
||||
@media(max-width:600px){.audit-filters>div:nth-child(-n+2){grid-column:1/-1}.admin-management .n-tabs{--n-tab-gap:16px!important;--n-tab-font-size:12px!important}}
|
||||
|
||||
.share-entry{text-align:left;margin-top:20px}.share-entry label{display:block;margin-bottom:8px;font-size:12px;color:#667085}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { test, expect, chromium } from '@playwright/test'
|
||||
import { mkdtempSync, readFileSync, writeFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { spawn, execFileSync, type ChildProcess } from 'node:child_process'
|
||||
import { createServer } from 'node:https'
|
||||
import { request } from 'node:http'
|
||||
|
||||
test('LAN works without public DNS; public HTTP and HTTPS share files through a remote proxy', async () => {
|
||||
test.setTimeout(60_000)
|
||||
const root=mkdtempSync(join(tmpdir(),'bluebird-multiple-access-'))
|
||||
const binary=resolve('../build/bluebird'), config=join(root,'config.toml')
|
||||
const lan='http://127.0.0.1:19081', http='http://files.example.test:19081', https='https://files.example.test:19082'
|
||||
writeFileSync(config,readFileSync('../deploy/config.example.toml','utf8')
|
||||
.replace('listen = "127.0.0.1:8080"','listen = "127.0.0.1:19081"')
|
||||
.replace('public_url = "http://localhost:8080"',`public_url = "${https}"`)
|
||||
.replace('access_urls = []',`access_urls = ["${http}", "${lan}"]`)
|
||||
.replace('secure_cookies = false','secure_cookies = true')
|
||||
.replace('allow_lan_http = false','allow_lan_http = true')
|
||||
.replace('trusted_proxies = []','trusted_proxies = ["127.0.0.1/32"]')
|
||||
.replace('min_free_bytes = 1073741824','min_free_bytes = 0'))
|
||||
execFileSync(binary,['init','--config',config],{env:{...process.env,BLUEBIRD_ADMIN_PASSWORD:'multiple-access-password'}})
|
||||
execFileSync('openssl',['req','-x509','-newkey','rsa:2048','-nodes','-keyout',join(root,'key.pem'),'-out',join(root,'cert.pem'),'-days','1','-subj','/CN=files.example.test'],{stdio:'ignore'})
|
||||
let worker:ChildProcess|undefined
|
||||
const proxy=createServer({key:readFileSync(join(root,'key.pem')),cert:readFileSync(join(root,'cert.pem'))},(incoming,outgoing)=>{
|
||||
const upstream=request(lan+incoming.url,{method:incoming.method,headers:{...incoming.headers,host:'127.0.0.1:19081','x-forwarded-host':incoming.headers.host,'x-forwarded-proto':'https','x-forwarded-for':'192.168.1.61'}},response=>{
|
||||
outgoing.writeHead(response.statusCode!,response.headers);response.pipe(outgoing)
|
||||
});upstream.on('error',()=>{outgoing.writeHead(502);outgoing.end()});incoming.pipe(upstream)
|
||||
})
|
||||
const browser=await chromium.launch({env:{...process.env,HTTP_PROXY:'',HTTPS_PROXY:'',ALL_PROXY:'',http_proxy:'',https_proxy:'',all_proxy:''},args:['--host-resolver-rules=MAP files.example.test 127.0.0.1','--no-proxy-server']})
|
||||
try {
|
||||
worker=spawn(binary,['serve','--config',config],{stdio:'ignore'})
|
||||
await expect.poll(async()=>{try{return(await fetch(lan+'/api/v1/health')).status}catch{return 0}}).toBe(200)
|
||||
await new Promise<void>(ready=>proxy.listen(19082,'127.0.0.1',ready))
|
||||
const context=await browser.newContext({ignoreHTTPSErrors:true}), page=await context.newPage()
|
||||
page.setDefaultTimeout(10_000)
|
||||
const errors:string[]=[];page.on('pageerror',e=>errors.push(e.message))
|
||||
// Abort every request to the public hostname: the LAN entrance must remain usable.
|
||||
await page.route('**://files.example.test:*/**',route=>route.abort('namenotresolved'))
|
||||
await page.goto(lan);await expect(page.getByRole('heading',{name:'准备寄件'})).toBeVisible()
|
||||
await page.locator('input[type=file]').setInputFiles({name:'three-entrances.txt',mimeType:'text/plain',buffer:Buffer.from('same file in LAN, HTTP and HTTPS')})
|
||||
await page.getByRole('button',{name:'上传并生成取件码'}).click()
|
||||
await expect(page.getByRole('heading',{name:'文件已寄出'})).toBeVisible()
|
||||
const lanLink=await page.getByRole('textbox',{name:'取件链接'}).inputValue()
|
||||
expect(lanLink.startsWith(lan+'/#/pickup/')).toBeTruthy()
|
||||
const qrBefore=await page.getByAltText('取件二维码').getAttribute('src')
|
||||
await page.locator('.share-entry .n-base-selection').click()
|
||||
await page.getByText('公网HTTPS · '+https,{exact:true}).click()
|
||||
const httpsLink=await page.getByRole('textbox',{name:'取件链接'}).inputValue()
|
||||
expect(httpsLink).toBe(lanLink.replace(lan,https))
|
||||
await expect.poll(()=>page.getByAltText('取件二维码').getAttribute('src')).not.toBe(qrBefore)
|
||||
await page.getByRole('button',{name:'完成',exact:true}).click()
|
||||
await page.unroute('**://files.example.test:*/**')
|
||||
for(const base of [lan,https,http]) {
|
||||
if(base!==lan){
|
||||
await page.goto(base);await expect(page.getByRole('heading',{name:'准备寄件'})).toBeVisible()
|
||||
await page.locator('input[type=file]').setInputFiles({name:'public-upload.txt',mimeType:'text/plain',buffer:Buffer.from('public upload')})
|
||||
await page.getByRole('button',{name:'上传并生成取件码'}).click();await expect(page.getByRole('heading',{name:'文件已寄出'})).toBeVisible()
|
||||
expect((await page.getByRole('textbox',{name:'取件链接'}).inputValue()).startsWith(base+'/#/pickup/')).toBeTruthy()
|
||||
await page.getByRole('button',{name:'完成',exact:true}).click()
|
||||
}
|
||||
await page.goto(lanLink.replace(lan,base))
|
||||
await expect(page.getByRole('heading',{name:'你的文件已就位'})).toBeVisible()
|
||||
const pending=page.waitForEvent('download');await page.getByRole('button',{name:'下载',exact:true}).click()
|
||||
expect(readFileSync((await (await pending).path())!,'utf8')).toBe('same file in LAN, HTTP and HTTPS')
|
||||
await page.goto(base+'/#/admin');await page.getByRole('textbox').first().fill('admin');await page.locator('input[type=password]').fill('multiple-access-password')
|
||||
await page.getByRole('button',{name:'登录',exact:true}).click();await expect(page.getByRole('tab',{name:'服务策略'})).toBeVisible()
|
||||
}
|
||||
const cookies=await context.cookies(https+'/api/v1/admin/status')
|
||||
expect(cookies.some(c=>c.name==='bb_admin'&&c.secure)).toBeTruthy()
|
||||
expect(cookies.some(c=>c.name==='bb_admin_http'&&!c.secure)).toBeTruthy()
|
||||
await page.goto(http+'/#/admin');await expect(page.getByRole('tab',{name:'服务策略'})).toBeVisible()
|
||||
expect((await context.cookies(http+'/api/v1/admin/status')).some(c=>c.name==='bb_admin')).toBeFalsy()
|
||||
expect(errors).toEqual([])
|
||||
} finally {
|
||||
await browser.close();proxy.closeAllConnections();await new Promise<void>(done=>proxy.close(()=>done()))
|
||||
if(worker&&worker.exitCode===null){worker.kill('SIGTERM');await new Promise(done=>worker!.once('exit',done))}
|
||||
rmSync(root,{recursive:true,force:true})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,92 @@
|
||||
import { test as base, expect, type Page, type Cookie } from '@playwright/test'
|
||||
|
||||
// Reuse one administrator login for UI fixtures, preserving the real login
|
||||
// rate limiter and leaving its remaining burst for the full transfer scenario.
|
||||
const test=base.extend<{}, {adminCookies:Cookie[]}>({
|
||||
adminCookies:[async({browser},use)=>{
|
||||
const context=await browser.newContext()
|
||||
try{
|
||||
const response=await context.request.post('http://127.0.0.1:18981/api/v1/admin/login',{headers:{Origin:'http://127.0.0.1:18981'},data:{username:'admin',password:'e2e-secret-password'}})
|
||||
expect(response.status()).toBe(200)
|
||||
await use(await context.cookies())
|
||||
}finally{await context.close()}
|
||||
},{scope:'worker'}]
|
||||
})
|
||||
|
||||
async function adminFixture(page: Page) {
|
||||
const parcelQueries: URLSearchParams[] = [], auditQueries: URLSearchParams[] = []
|
||||
await page.route('**/api/v1/admin/parcels?**', async route => {
|
||||
const q = new URL(route.request().url()).searchParams; parcelQueries.push(q)
|
||||
const size = Number(q.get('page_size') ?? 10), total = q.get('q') ? 1 : 23, pages = Math.max(1, Math.ceil(total / size)), current = Math.min(Number(q.get('page') ?? 1), pages)
|
||||
const items = Array.from({length: Math.min(size, total-(current-1)*size)}, (_,i) => {
|
||||
const n = (current-1)*size+i+1
|
||||
return { id: n.toString(16).padStart(32,'0'), state:'ready', ip:'203.0.113.1', created:1791187200, expires:1791792000, count:1, size:12, files:[{id:'file-'+n,name:'样例文件-'+n+'.txt',size:12,status:'ready',remaining:5,expires:1791792000}] }
|
||||
})
|
||||
await route.fulfill({json:{items,total,page:current,page_size:size,pages}})
|
||||
})
|
||||
await page.route('**/api/v1/admin/audit?**', async route => {
|
||||
const q = new URL(route.request().url()).searchParams; auditQueries.push(q)
|
||||
const size = Number(q.get('page_size') ?? 20), total = q.get('source') ? (q.get('source')==='no-match' ? 0 : 2) : 100000, pages = Math.max(1,Math.ceil(total/size)), current = Math.min(Number(q.get('page') ?? 1),pages)
|
||||
const items = Array.from({length:Math.min(size,Math.max(0,total-(current-1)*size))}, (_,i) => ({id:total-(current-1)*size-i,created:1791187200,action:q.get('action')||'admin.login',target:q.get('target')||'audit-'+(total-(current-1)*size-i),source:q.get('source')||'203.0.113.1'}))
|
||||
await route.fulfill({json:{items,total,page:current,page_size:size,pages}})
|
||||
})
|
||||
await page.goto('/#/admin')
|
||||
await expect(page.getByRole('tab',{name:'文件管理'})).toBeVisible()
|
||||
await expect(page.getByRole('navigation',{name:'文件管理分页'})).toContainText('共 23 个包裹')
|
||||
return {parcelQueries,auditQueries}
|
||||
}
|
||||
|
||||
for(const mobile of [false,true]) {
|
||||
test(`admin tabs expand naturally and lists paginate/filter (${mobile?'mobile':'desktop'})`,async({page,adminCookies})=>{
|
||||
await page.context().addCookies(adminCookies)
|
||||
if(mobile)await page.setViewportSize({width:390,height:844})
|
||||
const errors:string[]=[];page.on('pageerror',e=>errors.push(e.message))
|
||||
const {parcelQueries,auditQueries}=await adminFixture(page)
|
||||
const pager=page.getByRole('navigation',{name:'文件管理分页'})
|
||||
await expect(page.locator('.admin-parcel:visible')).toHaveCount(10)
|
||||
await pager.getByRole('button',{name:'上一页',exact:true}).isDisabled().then(v=>expect(v).toBeTruthy())
|
||||
await pager.getByRole('button',{name:'下一页',exact:true}).click()
|
||||
await expect(pager).toContainText('第 2 / 3 页');await expect(page.getByText('样例文件-11.txt',{exact:true})).toBeVisible()
|
||||
expect(parcelQueries.at(-1)?.get('page')).toBe('2')
|
||||
await pager.getByRole('button',{name:'下一页',exact:true}).click()
|
||||
await expect(page.locator('.admin-parcel:visible')).toHaveCount(3)
|
||||
await expect(pager.getByRole('button',{name:'下一页',exact:true})).toBeDisabled()
|
||||
await page.getByLabel('文件名或包裹 ID',{exact:true}).fill('样例文件-2')
|
||||
await page.locator('.parcel-filters').getByRole('button',{name:'搜索',exact:true}).click()
|
||||
await expect(pager).toContainText('共 1 个包裹');await expect(pager).toContainText('第 1 / 1 页')
|
||||
expect(parcelQueries.at(-1)?.get('q')).toBe('样例文件-2')
|
||||
await expect(pager.getByRole('button',{name:'下一页',exact:true})).toBeDisabled()
|
||||
await page.locator('.parcel-filters').getByRole('button',{name:'重置',exact:true}).click()
|
||||
await expect(pager).toContainText('共 23 个包裹')
|
||||
for(const name of ['文件管理','服务策略','容量与保留','操作审计']){
|
||||
await page.getByRole('tab',{name,exact:true}).click()
|
||||
expect(await page.locator('.admin-panel:visible .admin-scroll').evaluate(node=>node.scrollHeight<=node.clientHeight+1)).toBeTruthy()
|
||||
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBeTruthy()
|
||||
}
|
||||
const auditPager=page.getByRole('navigation',{name:'操作审计分页'})
|
||||
await expect(page.locator('.audit-table tbody tr')).toHaveCount(20)
|
||||
await expect(auditPager).toContainText('共 100000 条')
|
||||
await expect(auditPager).toContainText('第 1 / 5000 页')
|
||||
await auditPager.getByRole('button',{name:'下一页',exact:true}).click()
|
||||
await expect(auditPager).toContainText('第 2 / 5000 页');expect(auditQueries.at(-1)?.get('page')).toBe('2')
|
||||
await page.getByLabel('操作审计分页跳转页码',{exact:true}).fill('5000')
|
||||
await auditPager.getByRole('button',{name:'跳转',exact:true}).click()
|
||||
await expect(auditPager).toContainText('第 5000 / 5000 页');await expect(auditPager.getByRole('button',{name:'下一页',exact:true})).toBeDisabled()
|
||||
await page.getByLabel('开始时间',{exact:true}).fill('2026-10-05T00:00');await page.getByLabel('结束时间',{exact:true}).fill('2026-10-06T00:00')
|
||||
await page.getByLabel('来源 IP',{exact:true}).fill('203.0.113')
|
||||
await page.getByLabel('目标标识',{exact:true}).fill('target-fragment')
|
||||
await page.locator('.audit-filters .n-base-selection').click()
|
||||
await page.getByText('删除包裹 · admin.delete',{exact:true}).click()
|
||||
await page.locator('.audit-filters').getByRole('button',{name:'搜索',exact:true}).click()
|
||||
await expect(auditPager).toContainText('共 2 条');await expect(auditPager).toContainText('第 1 / 1 页')
|
||||
const query=auditQueries.at(-1)!;expect(query.get('action')).toBe('admin.delete');expect(query.get('source')).toBe('203.0.113');expect(query.get('target')).toBe('target-fragment');expect(query.get('from')).toBe(await page.evaluate(()=>String(new Date('2026-10-05T00:00').getTime()/1000)));expect(query.get('to')).toBe(await page.evaluate(()=>String(new Date('2026-10-06T00:00').getTime()/1000)))
|
||||
await expect(page.locator('.audit-table tbody tr')).toHaveCount(2)
|
||||
await page.getByLabel('来源 IP',{exact:true}).fill('no-match');await page.locator('.audit-filters').getByRole('button',{name:'搜索',exact:true}).click()
|
||||
await expect(page.getByText('没有符合条件的审计记录。',{exact:true})).toBeVisible();await expect(auditPager).toContainText('共 0 条');await expect(auditPager.getByRole('button',{name:'下一页',exact:true})).toBeDisabled()
|
||||
await page.locator('.audit-filters').getByRole('button',{name:'重置',exact:true}).click()
|
||||
await expect(auditPager).toContainText('共 100000 条');expect(auditQueries.at(-1)?.has('source')).toBeFalsy()
|
||||
await page.getByLabel('操作审计分页每页条数',{exact:true}).selectOption('50');await expect(page.locator('.audit-table tbody tr')).toHaveCount(50);await expect(auditPager).toContainText('第 1 / 2000 页')
|
||||
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
test('anonymous batch → share QR → pickup download → admin management',async({page})=>{
|
||||
const errors:string[]=[];page.on('pageerror',e=>errors.push(e.message))
|
||||
await page.goto('/');await expect(page.getByRole('heading',{name:'准备寄件'})).toBeVisible();await expect(page).toHaveTitle('蓝鸟 · 文件快递柜');await expect(page.getByRole('link',{name:'管理',exact:true})).toHaveCount(0)
|
||||
await page.locator('input[type=file]').setInputFiles([{name:'hello.txt',mimeType:'text/plain',buffer:Buffer.from('Hello Bluebird')},{name:'copy.txt',mimeType:'text/plain',buffer:Buffer.from('Hello Bluebird')}])
|
||||
await page.getByRole('button',{name:'上传并生成取件码'}).click();await expect(page.getByRole('heading',{name:'文件已寄出'})).toBeVisible();await expect(page.getByAltText('取件二维码')).toBeVisible();await expect(page.getByRole('button',{name:'复制取件码',exact:true})).toBeVisible()
|
||||
const url=await page.getByRole('textbox',{name:'取件链接'}).inputValue();await page.getByRole('button',{name:'完成',exact:true}).click();await page.goto(url)
|
||||
await expect(page.getByRole('heading',{name:'你的文件已就位'})).toBeVisible();const downloadPromise=page.waitForEvent('download');await page.getByRole('button',{name:'下载',exact:true}).first().click();const file=await downloadPromise;expect(file.suggestedFilename()).toBe('hello.txt');expect(readFileSync((await file.path())!,'utf8')).toBe('Hello Bluebird')
|
||||
await page.getByRole('button',{name:'刷新状态'}).click();await expect(page.getByText('14 B · 剩余 4 次')).toBeVisible()
|
||||
await page.goto('/#/admin');await page.getByRole('textbox').first().fill('admin');await page.locator('input[type=password]').fill('e2e-secret-password');await page.getByRole('button',{name:'登录',exact:true}).click();await expect(page.getByRole('tab',{name:'服务策略'})).toBeVisible();await expect(page.getByRole('link',{name:'寄文件',exact:true})).toHaveCount(0);await expect(page).toHaveTitle('管理控制台 · 蓝鸟');await expect(page.getByText('hello.txt',{exact:true})).toBeVisible();await expect(page.getByText('1 个对象 · 2 份文件记录')).toBeVisible()
|
||||
await page.getByRole('tab',{name:'服务策略'}).click();await expect(page.getByText('匿名会话日寄件次数',{exact:true})).toBeVisible();await page.getByRole('button',{name:'保存策略'}).click();await expect(page.getByText('策略已保存,新上传会采用新策略')).toBeVisible()
|
||||
await page.getByRole('tab',{name:'容量与保留'}).click();await expect(page.getByText('文件存储预算(MiB)',{exact:true})).toBeVisible();await expect(page.getByText('审计保留天数',{exact:true})).toBeVisible();expect(errors).toEqual([])
|
||||
})
|
||||
|
||||
test('mobile upload/pickup views remain within viewport',async({page})=>{
|
||||
await page.setViewportSize({width:390,height:844});await page.goto('/');await expect(page.getByRole('heading',{name:'准备寄件'})).toBeVisible();expect(await page.evaluate(()=>document.documentElement.scrollWidth<=window.innerWidth)).toBeTruthy();await page.getByRole('link',{name:'取文件',exact:true}).click();await expect(page.getByRole('button',{name:'查找文件'})).toBeVisible();expect(await page.evaluate(()=>document.documentElement.scrollWidth<=window.innerWidth)).toBeTruthy()
|
||||
})
|
||||
|
||||
test('anonymous visitor can revoke their own parcel without administrator login',async({page})=>{
|
||||
await page.goto('/');await page.locator('input[type=file]').setInputFiles({name:'revoke.txt',mimeType:'text/plain',buffer:Buffer.from('revoke me')});await page.getByRole('button',{name:'上传并生成取件码'}).click();await expect(page.getByRole('heading',{name:'文件已寄出'})).toBeVisible();const url=await page.getByRole('textbox',{name:'取件链接'}).inputValue();await page.getByRole('button',{name:'完成',exact:true}).click();await page.getByRole('link',{name:'我的寄件',exact:true}).click();await expect(page.getByText('1 个文件 · 9 B')).toBeVisible();await page.getByRole('button',{name:'撤销',exact:true}).click();await page.getByRole('button',{name:'撤销',exact:true}).last().click();await expect(page.getByText('已撤销',{exact:true})).toBeVisible();await page.goto(url);await expect(page.getByRole('alert')).toContainText('取件信息无效或已失效')
|
||||
})
|
||||
|
||||
test('management receipt works after changing anonymous browser session',async({page,browser})=>{
|
||||
await page.goto('/');await page.locator('input[type=file]').setInputFiles({name:'managed.txt',mimeType:'text/plain',buffer:Buffer.from('manage with receipt')});await page.getByRole('button',{name:'上传并生成取件码'}).click();await expect(page.getByRole('heading',{name:'文件已寄出'})).toBeVisible();const shareUrl=await page.getByRole('textbox',{name:'取件链接'}).inputValue();await page.getByRole('button',{name:'完成',exact:true}).click();await page.getByRole('link',{name:'我的寄件',exact:true}).click();const exported=page.waitForEvent('download');await page.getByRole('button',{name:'导出管理凭据',exact:true}).click();const receipt=await exported;const contents=readFileSync((await receipt.path())!)
|
||||
const context=await browser.newContext();const recipient=await context.newPage();await recipient.goto('http://127.0.0.1:18981/#/mine');await recipient.locator('input[type=file]').setInputFiles({name:'receipt.json',mimeType:'application/json',buffer:contents});await expect(recipient.getByText('管理凭据已导入,仅保存在当前标签页')).toBeVisible();await recipient.getByRole('button',{name:'撤销',exact:true}).click();await recipient.getByRole('button',{name:'撤销',exact:true}).last().click();await expect(recipient.getByText('已撤销',{exact:true})).toBeVisible();await recipient.goto(shareUrl);await expect(recipient.getByRole('alert')).toContainText('取件信息无效或已失效');await context.close()
|
||||
})
|
||||
|
||||
|
||||
test('secure context copies the actual code with Clipboard API',async({page,context})=>{
|
||||
await context.grantPermissions(['clipboard-read','clipboard-write'])
|
||||
await page.goto('/');await page.locator('input[type=file]').setInputFiles({name:'copy-code.txt',mimeType:'text/plain',buffer:Buffer.from('copy code only')});await page.getByRole('button',{name:'上传并生成取件码'}).click();await expect(page.getByRole('heading',{name:'文件已寄出'})).toBeVisible();const code=(await page.locator('.pickup-code').innerText()).replaceAll('-','');await page.getByRole('button',{name:'复制取件码',exact:true}).click();expect(await page.evaluate(()=>navigator.clipboard.readText())).toBe(code);await expect(page.getByText('取件码已复制',{exact:true})).toBeVisible();const icon=await page.request.get('/favicon.ico');expect(icon.status()).toBe(200);
|
||||
})
|
||||
@@ -0,0 +1,98 @@
|
||||
import { test, expect, devices, type Page } from '@playwright/test'
|
||||
|
||||
// These tests exercise real browser clipboard operations. Upload responses are
|
||||
// fixtures; the end-to-end cabinet tests cover actual transfers independently.
|
||||
async function mockShareUpload(page: Page) {
|
||||
await page.route('**/api/v1/uploads**', async route => {
|
||||
const path = new URL(route.request().url()).pathname
|
||||
if (path.endsWith('/publish')) await route.fulfill({ json: { code: '7K3MP9TX2R', url: 'http://127.0.0.1:18981/#/pickup/7K3MP9TX2R', expires: 2000000000 } })
|
||||
else if (path.endsWith('/uploads')) await route.fulfill({ json: { id: 'a'.repeat(32), manage_token: 'b'.repeat(43), files: [{ id: 'c'.repeat(32) }] } })
|
||||
else if (route.request().method() === 'PUT') await route.fulfill({ json: {} })
|
||||
else await route.abort()
|
||||
})
|
||||
}
|
||||
|
||||
async function openShare(page: Page) {
|
||||
await mockShareUpload(page)
|
||||
await page.goto('http://127.0.0.1:18981/')
|
||||
await page.locator('input[type=file]').setInputFiles({ name: 'clipboard.txt', mimeType: 'text/plain', buffer: Buffer.from('clipboard regression') })
|
||||
await page.getByRole('button', { name: '上传并生成取件码' }).click()
|
||||
await expect(page.getByRole('heading', { name: '文件已寄出' })).toBeVisible()
|
||||
}
|
||||
|
||||
const profiles = [
|
||||
{ name: 'desktop HTTP fallback', device: undefined, mode: 'absent' },
|
||||
{ name: 'Android touch HTTP fallback', device: devices['Pixel 7'], mode: 'absent' },
|
||||
// Chromium emulates the iPhone viewport/touch configuration, not Safari's engine.
|
||||
{ name: 'iPhone touch layout HTTP fallback', device: devices['iPhone 13'], mode: 'absent' },
|
||||
{ name: 'Android touch denied Clipboard API', device: devices['Pixel 7'], mode: 'denied' },
|
||||
{ name: 'Android touch secure Clipboard API', device: devices['Pixel 7'], mode: 'native' },
|
||||
]
|
||||
for (const profile of profiles) {
|
||||
test(`actual clipboard copy and paste (${profile.name})`, async ({ browser }) => {
|
||||
const context = await browser.newContext(profile.device ?? {})
|
||||
try {
|
||||
const page = await context.newPage()
|
||||
await mockShareUpload(page)
|
||||
// Localhost exposes Clipboard API. Reproduce its absence on LAN HTTP or its
|
||||
// rejection without replacing the browser's actual copy/paste operations.
|
||||
if (profile.mode !== 'native') await page.addInitScript(mode => {
|
||||
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: mode === 'absent' ? undefined : { writeText: async () => { throw new DOMException('Denied', 'NotAllowedError') } } })
|
||||
}, profile.mode)
|
||||
await page.goto('http://127.0.0.1:18981/')
|
||||
await page.evaluate(() => {
|
||||
const input = document.createElement('textarea')
|
||||
input.id = 'clipboard-seed'
|
||||
input.value = 'previous clipboard content'
|
||||
document.body.appendChild(input)
|
||||
})
|
||||
await page.locator('#clipboard-seed').selectText()
|
||||
await page.keyboard.press('Control+c')
|
||||
await page.locator('#clipboard-seed').fill('')
|
||||
await page.keyboard.press('Control+v')
|
||||
await expect(page.locator('#clipboard-seed')).toHaveValue('previous clipboard content')
|
||||
await page.locator('#clipboard-seed').evaluate(node => node.remove())
|
||||
// Preserve this document's seeded clipboard and upload through the normal UI.
|
||||
await page.locator('input[type=file]').setInputFiles({ name: 'clipboard.txt', mimeType: 'text/plain', buffer: Buffer.from('clipboard regression') })
|
||||
await page.getByRole('button', { name: '上传并生成取件码' }).click()
|
||||
await expect(page.getByRole('heading', { name: '文件已寄出' })).toBeVisible()
|
||||
const code = (await page.locator('.pickup-code').innerText()).replaceAll('-', '')
|
||||
expect(code).toMatch(/^[0-9A-Z]{10}$/)
|
||||
const url = await page.getByRole('textbox', { name: '取件链接' }).inputValue()
|
||||
await page.locator('.share-dialog').evaluate(node => {
|
||||
const input = document.createElement('input')
|
||||
input.setAttribute('aria-label', '粘贴检查')
|
||||
node.appendChild(input)
|
||||
})
|
||||
for (const [name, value] of [['复制取件码', code], ['复制取件链接', url]]) {
|
||||
const button = page.getByRole('button', { name, exact: true })
|
||||
if (profile.device) await button.tap(); else await button.click()
|
||||
await expect(page.getByText(name === '复制取件码' ? '取件码已复制' : '取件链接已复制', { exact: true })).toBeVisible()
|
||||
const paste = page.getByRole('textbox', { name: '粘贴检查' })
|
||||
await paste.fill('')
|
||||
await paste.focus()
|
||||
await expect(paste).toBeFocused()
|
||||
// Native paste reads the actual clipboard, including in touch emulation.
|
||||
await page.keyboard.press('Control+v')
|
||||
await expect(paste).toHaveValue(value)
|
||||
await expect(page.locator('.share-dialog textarea')).toHaveCount(0)
|
||||
}
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBeTruthy()
|
||||
} finally { await context.close() }
|
||||
})
|
||||
}
|
||||
|
||||
test('blocked copy offers manual copying without a success message', async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
Object.defineProperty(navigator, 'clipboard', { value: undefined, configurable: true })
|
||||
const native = document.execCommand.bind(document)
|
||||
document.execCommand = (command, showUI, value) => command === 'copy' ? false : native(command, showUI, value)
|
||||
})
|
||||
await openShare(page)
|
||||
await page.getByRole('button', { name: '复制取件码', exact: true }).click()
|
||||
await expect(page.getByText('复制未完成,请选择或长按取件码、链接手动复制', { exact: true })).toBeVisible()
|
||||
await expect(page.getByText('取件码已复制', { exact: true })).toHaveCount(0)
|
||||
await expect(page.locator('.share-dialog textarea')).toHaveCount(0)
|
||||
await page.locator('.pickup-code').selectText()
|
||||
expect(await page.evaluate(() => getSelection()?.toString())).toBe(await page.locator('.pickup-code').innerText())
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
{"compilerOptions":{"target":"ES2022","module":"ESNext","moduleResolution":"Bundler","strict":true,"skipLibCheck":true,"lib":["ES2022","DOM","DOM.Iterable"],"types":["vite/client","node"],"esModuleInterop":true},"include":["src/**/*.ts","src/**/*.vue","vite.config.ts"]}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
export default defineConfig({ plugins: [vue()], build: { outDir: '../internal/web/dist', emptyOutDir: true }, server: { proxy: { '/api': 'http://127.0.0.1:8080' } } })
|
||||
Reference in new issue
Block a user