You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
347 lines
14 KiB
347 lines
14 KiB
<template>
|
|
<div>
|
|
<span class="ed-label">下载落地页(外包装二维码指向它)</span>
|
|
<div class="flex gap-2">
|
|
<button class="btn btn-outline btn-sm" :disabled="busy" @click="openDrawer('code')">编辑</button>
|
|
<button class="btn btn-primary btn-sm text-white" :disabled="busy" @click="publish">保存</button>
|
|
<button class="btn btn-outline btn-sm" :disabled="busy" @click="openDrawer('preview')">查看</button>
|
|
<input v-model="url" class="input input-bordered input-sm w-full font-mono text-xs" :placeholder="defaultUrl || 'https://dl.ymaikj.com/<应用名>/download.html'" />
|
|
</div>
|
|
<p class="ed-hint">
|
|
<template v-if="hasDraft">
|
|
<span class="text-amber-600">● 有未发布的草稿</span>({{ draftAtText }} 自动存于本浏览器),点「保存」才会发布到线上。
|
|
</template>
|
|
<template v-else-if="loadedFor && !exists">这个地址上还没有页面,点「编辑」写好后「保存」即可发布。</template>
|
|
<template v-else>「编辑」改源码会自动存草稿(只在本浏览器);<b>「保存」才会发布到 OSS</b>,扫码的人立刻看到新页面。</template>
|
|
地址只能改最后的文件名,目录固定为本应用 <code>/{{ appName || '应用名' }}/</code>。
|
|
</p>
|
|
|
|
<!-- 右侧抽屉:盖在配置区上面,不挤布局 -->
|
|
<Teleport to="body">
|
|
<div v-if="drawerOpen" class="dp-mask" @click.self="drawerOpen = false">
|
|
<aside class="dp-drawer">
|
|
<header class="dp-head">
|
|
<div class="flex items-center gap-2 min-w-0">
|
|
<div class="join">
|
|
<button class="btn btn-sm join-item" :class="tab === 'code' ? 'btn-primary text-white' : 'btn-ghost'" @click="tab = 'code'">编辑</button>
|
|
<button class="btn btn-sm join-item" :class="tab === 'preview' ? 'btn-primary text-white' : 'btn-ghost'" @click="tab = 'preview'">查看</button>
|
|
</div>
|
|
<span class="text-xs text-slate-500 truncate font-mono">{{ loadedFor }}</span>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<span class="text-xs" :class="hasDraft ? 'text-amber-600' : 'text-slate-400'">
|
|
{{ hasDraft ? `草稿已自动保存 ${draftAtText}` : '与线上一致' }}
|
|
</span>
|
|
<button v-if="hasDraft" class="btn btn-ghost btn-sm" :disabled="busy" @click="discardDraft">丢弃草稿</button>
|
|
<button class="btn btn-primary btn-sm text-white" :disabled="busy" @click="publish">保存并发布</button>
|
|
<button class="btn btn-ghost btn-sm" @click="drawerOpen = false">✕</button>
|
|
</div>
|
|
</header>
|
|
|
|
<div v-show="tab === 'code'" class="dp-body">
|
|
<div ref="editorHost" class="dp-editor" />
|
|
</div>
|
|
|
|
<div v-if="tab === 'preview'" class="dp-body flex flex-col">
|
|
<div class="dp-subbar">
|
|
<div class="join">
|
|
<button class="btn btn-xs join-item" :class="previewSrc === 'draft' ? 'btn-neutral' : 'btn-ghost'" @click="previewSrc = 'draft'">当前编辑内容</button>
|
|
<button class="btn btn-xs join-item" :class="previewSrc === 'online' ? 'btn-neutral' : 'btn-ghost'" @click="previewSrc = 'online'">线上版本</button>
|
|
</div>
|
|
<div class="join">
|
|
<button class="btn btn-xs join-item" :class="device === 'mobile' ? 'btn-neutral' : 'btn-ghost'" @click="device = 'mobile'">手机</button>
|
|
<button class="btn btn-xs join-item" :class="device === 'desktop' ? 'btn-neutral' : 'btn-ghost'" @click="device = 'desktop'">电脑</button>
|
|
</div>
|
|
<a v-if="exists" :href="loadedFor" target="_blank" rel="noopener" class="link text-xs">新窗口打开线上页面</a>
|
|
<span class="text-xs text-slate-400">
|
|
预览里「版本号 / 更新日志」等接口数据可能因跨域取不到,以线上页面为准。
|
|
</span>
|
|
</div>
|
|
<div class="dp-stage">
|
|
<iframe
|
|
v-if="previewHtml"
|
|
:key="previewSrc + device"
|
|
class="dp-frame"
|
|
:class="device"
|
|
:srcdoc="previewHtml"
|
|
sandbox="allow-scripts allow-popups"
|
|
/>
|
|
<div v-else class="text-sm text-slate-400 p-8">没有内容可预览</div>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
</div>
|
|
</Teleport>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount, shallowRef } from 'vue'
|
|
import { useApi } from '~/composables/useApi'
|
|
import { useToast } from '~/composables/useToast'
|
|
|
|
/*
|
|
下载落地页管理:分发桶上那份静态 download.html(如 https://dl.ymaikj.com/EAIMAR/download.html)。
|
|
|
|
- 编辑:从 OSS 读回线上源码 → CodeMirror 编辑 → 改动防抖写进 localStorage 作为**草稿**,不碰线上。
|
|
- 保存:把「草稿,没有草稿就用线上内容」发布到输入框里的地址(服务端 PutObject),地址落 app_release.download_page。
|
|
- 查看:右侧抽屉里用 iframe srcdoc 渲染,可切「当前编辑内容 / 线上版本」。
|
|
|
|
⚠️ 草稿按 (应用, 地址) 分开存:换了地址再点编辑,读的是新地址上的页面;
|
|
新地址上还没有页面时,用当前内容起一份草稿(等于「另存为」)。
|
|
*/
|
|
|
|
const props = defineProps<{ appName: string }>()
|
|
const { webApi } = useApi()
|
|
const { success, error } = useToast()
|
|
|
|
interface DistPageResp {
|
|
url: string
|
|
saved_url: string
|
|
default_url: string
|
|
content: string
|
|
exists: boolean
|
|
}
|
|
|
|
const url = ref('')
|
|
const defaultUrl = ref('')
|
|
const loadedFor = ref('') // online 对应的地址(服务端规范化过的)
|
|
const online = ref('') // 线上内容
|
|
const exists = ref(false)
|
|
const draft = ref<string | null>(null)
|
|
const draftAt = ref(0)
|
|
const busy = ref(false)
|
|
|
|
const drawerOpen = ref(false)
|
|
const tab = ref<'code' | 'preview'>('code')
|
|
const previewSrc = ref<'draft' | 'online'>('draft')
|
|
const device = ref<'mobile' | 'desktop'>('mobile')
|
|
|
|
const hasDraft = computed(() => draft.value !== null && draft.value !== online.value)
|
|
const current = computed(() => draft.value ?? online.value)
|
|
const previewHtml = computed(() => (previewSrc.value === 'online' ? online.value : current.value))
|
|
const draftAtText = computed(() => {
|
|
if (!draftAt.value) return ''
|
|
const d = new Date(draftAt.value)
|
|
const p = (n: number) => String(n).padStart(2, '0')
|
|
return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
|
|
})
|
|
|
|
// ── 草稿(localStorage,任何读写都可能抛:隐私模式 / 配额满)──
|
|
const draftKey = (u: string) => `distpage-draft:${props.appName}:${u}`
|
|
function readDraft(u: string) {
|
|
try {
|
|
const raw = localStorage.getItem(draftKey(u))
|
|
if (!raw) return null
|
|
const v = JSON.parse(raw)
|
|
return typeof v?.content === 'string' ? (v as { content: string; at: number }) : null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
function writeDraft(u: string, content: string | null) {
|
|
try {
|
|
if (content === null) localStorage.removeItem(draftKey(u))
|
|
else localStorage.setItem(draftKey(u), JSON.stringify({ content, at: Date.now() }))
|
|
} catch {
|
|
// 存不进去只是没有草稿,不阻断编辑
|
|
}
|
|
}
|
|
function adoptDraftFor(u: string) {
|
|
const d = readDraft(u)
|
|
draft.value = d?.content ?? null
|
|
draftAt.value = d?.at ?? 0
|
|
}
|
|
|
|
// ── 读线上 ──
|
|
async function fetchPage(target?: string): Promise<boolean> {
|
|
if (!props.appName) return false
|
|
try {
|
|
const d = await webApi<DistPageResp>('getdistpage', { app_name: props.appName, url: target ?? '' })
|
|
defaultUrl.value = d.default_url
|
|
loadedFor.value = d.url
|
|
url.value = d.url
|
|
online.value = d.content ?? ''
|
|
exists.value = !!d.exists
|
|
adoptDraftFor(d.url)
|
|
return true
|
|
} catch (e: any) {
|
|
error(e?.message ?? '读取下载页失败')
|
|
return false
|
|
}
|
|
}
|
|
|
|
// 输入框里的地址与已加载的不一致时,先按新地址读一次。
|
|
async function syncToInput(): Promise<boolean> {
|
|
const want = url.value.trim()
|
|
if (loadedFor.value && want === loadedFor.value) return true
|
|
const carry = current.value // 旧地址上正在编辑的内容
|
|
if (!(await fetchPage(want))) return false
|
|
if (!exists.value && draft.value === null && carry) {
|
|
draft.value = carry
|
|
draftAt.value = Date.now()
|
|
writeDraft(loadedFor.value, carry)
|
|
success('这个地址上还没有页面,已用当前内容起了一份草稿')
|
|
}
|
|
return true
|
|
}
|
|
|
|
async function openDrawer(which: 'code' | 'preview') {
|
|
if (!props.appName) { error('请先在页面顶部选择应用'); return }
|
|
busy.value = true
|
|
try {
|
|
if (!(await syncToInput())) return
|
|
} finally {
|
|
busy.value = false
|
|
}
|
|
tab.value = which
|
|
previewSrc.value = 'draft'
|
|
drawerOpen.value = true
|
|
}
|
|
|
|
// ── 发布 ──
|
|
async function publish() {
|
|
if (!props.appName) { error('请先在页面顶部选择应用'); return }
|
|
busy.value = true
|
|
try {
|
|
if (!(await syncToInput())) return
|
|
const content = current.value
|
|
if (!content.trim()) { error('页面内容为空,先点「编辑」写好再保存'); return }
|
|
if (!window.confirm(`确定发布到\n${loadedFor.value}\n?发布后扫码的人会立刻看到新页面。`)) return
|
|
await webApi('savedistpage', { app_name: props.appName, url: loadedFor.value, content })
|
|
online.value = content
|
|
exists.value = true
|
|
draft.value = null
|
|
draftAt.value = 0
|
|
writeDraft(loadedFor.value, null)
|
|
success('下载页已发布')
|
|
} catch (e: any) {
|
|
error(e?.message ?? '发布失败')
|
|
} finally {
|
|
busy.value = false
|
|
}
|
|
}
|
|
|
|
function discardDraft() {
|
|
if (!window.confirm('丢弃草稿,恢复成线上版本?')) return
|
|
draft.value = null
|
|
draftAt.value = 0
|
|
writeDraft(loadedFor.value, null)
|
|
setEditorDoc(online.value)
|
|
}
|
|
|
|
// ── CodeMirror(只在浏览器里按需加载,SSR 阶段碰不到 DOM)──
|
|
const editorHost = ref<HTMLElement | null>(null)
|
|
const view = shallowRef<any>(null)
|
|
let applyingExternal = false
|
|
let saveTimer: ReturnType<typeof setTimeout> | null = null
|
|
|
|
function onEditorChange(text: string) {
|
|
if (applyingExternal) return
|
|
draft.value = text
|
|
if (saveTimer) clearTimeout(saveTimer)
|
|
saveTimer = setTimeout(() => {
|
|
const u = loadedFor.value
|
|
if (draft.value === online.value) {
|
|
writeDraft(u, null) // 改回和线上一样就不算草稿
|
|
draftAt.value = 0
|
|
} else {
|
|
writeDraft(u, draft.value)
|
|
draftAt.value = Date.now()
|
|
}
|
|
}, 600)
|
|
}
|
|
|
|
async function ensureEditor() {
|
|
if (!editorHost.value) return
|
|
if (view.value) {
|
|
if (view.value.state.doc.toString() !== current.value) setEditorDoc(current.value)
|
|
return
|
|
}
|
|
const [{ EditorView, basicSetup }, { html }] = await Promise.all([
|
|
import('codemirror'),
|
|
import('@codemirror/lang-html'),
|
|
])
|
|
view.value = new EditorView({
|
|
doc: current.value,
|
|
parent: editorHost.value,
|
|
extensions: [
|
|
basicSetup,
|
|
html(),
|
|
EditorView.lineWrapping,
|
|
EditorView.updateListener.of((u: any) => { if (u.docChanged) onEditorChange(u.state.doc.toString()) }),
|
|
],
|
|
})
|
|
}
|
|
|
|
function setEditorDoc(text: string) {
|
|
const v = view.value
|
|
if (!v) return
|
|
applyingExternal = true
|
|
v.dispatch({ changes: { from: 0, to: v.state.doc.length, insert: text } })
|
|
applyingExternal = false
|
|
}
|
|
|
|
function destroyEditor() {
|
|
view.value?.destroy()
|
|
view.value = null
|
|
}
|
|
|
|
watch([drawerOpen, tab], async ([open, t]) => {
|
|
if (!open) { destroyEditor(); return }
|
|
if (t === 'code') { await nextTick(); await ensureEditor() }
|
|
})
|
|
|
|
function flushDraft() {
|
|
if (saveTimer) { clearTimeout(saveTimer); saveTimer = null }
|
|
if (hasDraft.value && draft.value !== null) writeDraft(loadedFor.value, draft.value)
|
|
}
|
|
|
|
function onKey(e: KeyboardEvent) {
|
|
if (e.key === 'Escape' && drawerOpen.value) drawerOpen.value = false
|
|
}
|
|
|
|
// ⚠️ 初次加载放 onMounted:webApi 要登录态,放 watch immediate 会在 SSR 阶段执行而整页 500。
|
|
onMounted(() => {
|
|
window.addEventListener('keydown', onKey)
|
|
window.addEventListener('beforeunload', flushDraft)
|
|
if (props.appName) fetchPage()
|
|
})
|
|
onBeforeUnmount(() => {
|
|
flushDraft()
|
|
destroyEditor()
|
|
window.removeEventListener('keydown', onKey)
|
|
window.removeEventListener('beforeunload', flushDraft)
|
|
})
|
|
watch(() => props.appName, (n) => {
|
|
flushDraft()
|
|
drawerOpen.value = false
|
|
url.value = ''
|
|
loadedFor.value = ''
|
|
online.value = ''
|
|
exists.value = false
|
|
draft.value = null
|
|
if (n) fetchPage()
|
|
})
|
|
watch(drawerOpen, (open) => { if (!open) flushDraft() })
|
|
</script>
|
|
|
|
<style scoped>
|
|
.ed-label { display: block; font-size: 12px; color: #64748b; margin-bottom: 4px; }
|
|
.ed-hint { font-size: 11px; color: #94a3b8; margin-top: 4px; line-height: 1.6; }
|
|
code { background: #f1f5f9; padding: 0 4px; border-radius: 4px; }
|
|
.link { color: #4f46e5; text-decoration: underline; }
|
|
|
|
.dp-mask { position: fixed; inset: 0; z-index: 60; background: rgba(15, 23, 42, 0.35); display: flex; justify-content: flex-end; }
|
|
.dp-drawer { width: min(1100px, 94vw); height: 100%; background: #fff; display: flex; flex-direction: column; box-shadow: -8px 0 24px rgba(15, 23, 42, 0.15); }
|
|
.dp-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 14px; border-bottom: 1px solid #e2e8f0; }
|
|
.dp-body { flex: 1; min-height: 0; }
|
|
.dp-editor { height: 100%; overflow: hidden; }
|
|
.dp-editor :deep(.cm-editor) { height: 100%; font-size: 12px; }
|
|
.dp-editor :deep(.cm-scroller) { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
.dp-subbar { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; padding: 8px 14px; border-bottom: 1px solid #f1f5f9; }
|
|
.dp-stage { flex: 1; min-height: 0; background: #f1f5f9; display: flex; justify-content: center; align-items: flex-start; overflow: auto; padding: 16px; }
|
|
.dp-frame { background: #fff; border: 1px solid #e2e8f0; border-radius: 12px; }
|
|
.dp-frame.mobile { width: 390px; height: 780px; max-height: 100%; }
|
|
.dp-frame.desktop { width: 100%; height: 100%; }
|
|
</style>
|
|
|