Compare commits
9 Commits
28a6089ded
...
98e640ecfe
| Author | SHA1 | Date |
|---|---|---|
|
|
98e640ecfe | 3 weeks ago |
|
|
06c01aa858 | 3 weeks ago |
|
|
2cf260eb05 | 3 weeks ago |
|
|
9f105bf7c8 | 3 weeks ago |
|
|
4753d37c69 | 3 weeks ago |
|
|
6f1686a8fd | 3 weeks ago |
|
|
5036ab4c3f | 3 weeks ago |
|
|
ace92c9ea1 | 3 weeks ago |
|
|
3e05be48d6 | 3 weeks ago |
136 changed files with 6612 additions and 3316 deletions
@ -0,0 +1,352 @@ |
|||||
|
<template> |
||||
|
<div> |
||||
|
<div class="mb-4"> |
||||
|
<div class="text-sm font-semibold text-slate-700">发版配置</div> |
||||
|
<div class="text-xs text-slate-400 mt-1 max-w-3xl"> |
||||
|
线上版本、强制更新、送审版本与更新日志。<b>Android / iOS 各一套版本号</b>(iOS 审核慢,两端线上版本经常不同步), |
||||
|
其余整个应用一套、不分渠道;下面的「渠道分发」只覆盖各商店各自不同的那部分。 |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div class="ver-wrap"> |
||||
|
<!-- 左:表单 --> |
||||
|
<div class="card"> |
||||
|
<section class="ed-section"> |
||||
|
<div class="ed-section-title">线上版本(按平台)</div> |
||||
|
<div class="grid grid-cols-2 gap-3"> |
||||
|
<div class="plat-col"> |
||||
|
<div class="plat-title">Android</div> |
||||
|
<span class="ed-label">当前线上版本</span> |
||||
|
<input v-model="form.version" class="input input-bordered input-sm w-full" placeholder="如 1.0.3" /> |
||||
|
<span class="ed-label mt-2">最低支持版本(可留空)</span> |
||||
|
<input v-model="form.min_version" class="input input-bordered input-sm w-full" placeholder="留空 = 线上版本" /> |
||||
|
</div> |
||||
|
<div class="plat-col"> |
||||
|
<div class="plat-title">iOS</div> |
||||
|
<span class="ed-label">当前线上版本</span> |
||||
|
<input v-model="form.ios_version" class="input input-bordered input-sm w-full" placeholder="如 1.0.2" /> |
||||
|
<span class="ed-label mt-2">最低支持版本(可留空)</span> |
||||
|
<input v-model="form.ios_min_version" class="input input-bordered input-sm w-full" placeholder="留空 = 线上版本" /> |
||||
|
</div> |
||||
|
</div> |
||||
|
<p class="ed-hint"> |
||||
|
客户端低于本平台「线上版本」时提示<b>推荐更新</b>(可点「稍后提醒」,同一版本 24 小时内不再弹)。 |
||||
|
填客户端上报的净版本号,<b>不含构建号</b>(<code>1.0.1</code> 而不是 <code>1.0.1+5</code>)。 |
||||
|
iOS 那一栏留空 = iOS 不提示任何更新,<b>不会拿 Android 的版本去判 iOS</b>。 |
||||
|
</p> |
||||
|
<div class="mt-3"> |
||||
|
<span class="ed-label">送审版本号</span> |
||||
|
<input v-model="form.review_version" class="input input-bordered input-sm w-full max-w-xs" placeholder="过审后清空" /> |
||||
|
<p class="ed-hint">客户端上报的版本正好等于它时,会隐藏未过审的功能。</p> |
||||
|
</div> |
||||
|
</section> |
||||
|
|
||||
|
<section class="ed-section"> |
||||
|
<div class="ed-section-title">强制更新</div> |
||||
|
<label class="flex items-center gap-2 text-sm cursor-pointer"> |
||||
|
<input v-model="form.force_update" type="checkbox" class="checkbox checkbox-sm checkbox-primary" /> |
||||
|
<span>开启强制更新(两个平台共用这一个开关)</span> |
||||
|
</label> |
||||
|
<p class="ed-hint pl-7"> |
||||
|
开启后,低于本平台「最低支持版本」的客户端会弹出<b>不可关闭</b>的升级框,无法继续使用。 |
||||
|
最低支持版本<b>留空 = 线上版本</b>,即所有低版本都强更;填了则只强更低于它的,介于两者之间的仍是推荐更新。 |
||||
|
强更不受渠道上架状态限制:渠道商店里还没有新包时,下载地址会自动换成官方渠道的包。 |
||||
|
</p> |
||||
|
<p v-if="versionWarn" class="ed-warn">⚠ {{ versionWarn }}</p> |
||||
|
</section> |
||||
|
|
||||
|
<section class="ed-section"> |
||||
|
<div class="ed-section-title">下载与说明</div> |
||||
|
<span class="ed-label">默认下载地址(网页下载页)</span> |
||||
|
<div class="flex gap-2"> |
||||
|
<input v-model="form.address" class="input input-bordered input-sm w-full" :placeholder="DIST_DOMAIN" /> |
||||
|
<button class="btn btn-outline btn-sm whitespace-nowrap" :disabled="apkUploading" @click="pickApk"> |
||||
|
{{ apkUploading ? `上传中 ${apkPercent}%` : '上传 APK' }} |
||||
|
</button> |
||||
|
<input ref="apkInput" type="file" accept=".apk,application/vnd.android.package-archive" class="hidden" @change="onApkPicked" /> |
||||
|
</div> |
||||
|
<progress v-if="apkUploading" class="progress progress-primary w-full mt-2 h-1.5" :value="apkPercent" max="100" /> |
||||
|
<p class="ed-hint"> |
||||
|
<b>推荐填网页下载页 <code>{{ DIST_DOMAIN }}</code></b>:那个页面会按手机品牌自动跳对应商店, |
||||
|
认不出品牌就直接给安装包。客户端点「立即更新」时,本渠道在<b>下方「渠道分发」</b> |
||||
|
里填了地址就用渠道的,<b>没填就回退到这里</b>——所以这一个地址能兜住所有渠道。 |
||||
|
</p> |
||||
|
<p class="ed-hint"> |
||||
|
「上传 APK」把安装包传到分发桶的 <code>android/{{ scopeApp || '应用名' }}/</code> 目录 |
||||
|
(按应用分目录,不同应用的包不会互相覆盖)。传完后安装包的<b>下载直链会填进上面的输入框</b>, |
||||
|
可直接作为官方包的下载地址;同时<b>自动写进下方「渠道分发 → 官方渠道(100)」</b>—— |
||||
|
网页下载页的安装包直链取的就是那一条。<b>输入框里的地址要点「保存」才会生效。</b> |
||||
|
</p> |
||||
|
<div class="mt-3"> |
||||
|
<span class="ed-label">更新日志</span> |
||||
|
<textarea v-model="form.changelog" class="textarea textarea-bordered w-full text-xs" rows="4" placeholder="本次更新内容,会下发给客户端在升级框里展示" /> |
||||
|
</div> |
||||
|
</section> |
||||
|
|
||||
|
<div class="ed-footer"> |
||||
|
<button class="btn btn-ghost btn-sm" :disabled="submitting" @click="load">重置</button> |
||||
|
<button class="btn btn-primary btn-sm text-white" :disabled="submitting" @click="save">保存</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- 右:判定预览。版本比较是这块最容易出错的地方,直接把结论摆出来 --> |
||||
|
<div class="card preview"> |
||||
|
<div class="ed-section-title">判定预览</div> |
||||
|
<p class="text-xs text-slate-400 mb-3">输入一个客户端版本号,看它会被判成什么。与服务端 comm.CompareVersion 同一套逐段整数比较。</p> |
||||
|
<div class="flex gap-2 mb-2"> |
||||
|
<button class="btn btn-xs" :class="probePlatform === 'android' ? 'btn-primary text-white' : 'btn-ghost'" @click="probePlatform = 'android'">Android</button> |
||||
|
<button class="btn btn-xs" :class="probePlatform === 'ios' ? 'btn-primary text-white' : 'btn-ghost'" @click="probePlatform = 'ios'">iOS</button> |
||||
|
</div> |
||||
|
<input v-model="probe" class="input input-bordered input-sm w-full" placeholder="如 1.0.1" /> |
||||
|
<div class="probe-list"> |
||||
|
<div class="probe-row"> |
||||
|
<span>有更新可用</span> |
||||
|
<span class="badge" :class="probeResult.need ? 'on' : 'off'">{{ probeResult.need ? '是' : '否' }}</span> |
||||
|
</div> |
||||
|
<div class="probe-row"> |
||||
|
<span>必须更新(阻断使用)</span> |
||||
|
<span class="badge" :class="probeResult.force ? 'warn' : 'off'">{{ probeResult.force ? '是' : '否' }}</span> |
||||
|
</div> |
||||
|
<div class="probe-row"> |
||||
|
<span>处于审核期</span> |
||||
|
<span class="badge" :class="probeResult.reviewing ? 'on' : 'off'">{{ probeResult.reviewing ? '是' : '否' }}</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
<p v-if="!probe.trim()" class="ed-hint mt-3"> |
||||
|
客户端没上报版本号时三项<b>一律为否</b>——拿不到版本就判强更会把所有老客户端挡在门外。 |
||||
|
</p> |
||||
|
<p class="ed-hint mt-3"> |
||||
|
这里不含两条与渠道有关的规则:渠道<b>未上架</b>(审核中/已下架)时不推送推荐更新; |
||||
|
算不出任何下载地址时两种更新都不提示。 |
||||
|
</p> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<script setup lang="ts"> |
||||
|
import { ref, reactive, computed, watch, onMounted } from 'vue' |
||||
|
import { useApi } from '~/composables/useApi' |
||||
|
import { useToast } from '~/composables/useToast' |
||||
|
|
||||
|
interface AppRelease { |
||||
|
version: string |
||||
|
min_version: string |
||||
|
force_update: boolean |
||||
|
changelog: string |
||||
|
address: string |
||||
|
review_version: string |
||||
|
ios_version: string |
||||
|
ios_min_version: string |
||||
|
} |
||||
|
|
||||
|
const { webApi, consoleApi, uploadFile } = useApi() |
||||
|
const { success, error } = useToast() |
||||
|
|
||||
|
// 分发桶的公开域名,仅用于界面提示;真实地址以后端返回的为准 |
||||
|
// (后端读【第三方服务配置 → 存储】的 dist_domain,留空时用这个默认值)。 |
||||
|
const DIST_DOMAIN = 'https://dl.ymaikj.com' |
||||
|
|
||||
|
const apkInput = ref<HTMLInputElement | null>(null) |
||||
|
const apkUploading = ref(false) |
||||
|
const apkPercent = ref(0) |
||||
|
|
||||
|
function pickApk() { |
||||
|
apkInput.value?.click() |
||||
|
} |
||||
|
|
||||
|
async function onApkPicked(e: Event) { |
||||
|
const input = e.target as HTMLInputElement |
||||
|
const file = input.files?.[0] |
||||
|
// ⚠️ 先把 input 清空再做别的:不清的话选同一个文件第二次不会触发 change, |
||||
|
// 表现为「上传失败后重选同一个包没反应」。 |
||||
|
input.value = '' |
||||
|
if (!file) return |
||||
|
if (!file.name.toLowerCase().endsWith('.apk')) { |
||||
|
error('请选择 .apk 文件') |
||||
|
return |
||||
|
} |
||||
|
// 安装包按应用分目录存,没选应用就不知道往哪放。后端也会拦(缺 X-App-Id 直接拒), |
||||
|
// 这里先挡一次是为了给一句人话,而不是让用户对着「缺少 X-App-Id」发懵。 |
||||
|
if (!scopeApp.value) { |
||||
|
error('请先在页面顶部选择应用,再上传安装包') |
||||
|
return |
||||
|
} |
||||
|
apkUploading.value = true |
||||
|
apkPercent.value = 0 |
||||
|
try { |
||||
|
const url = await uploadFile(file, 'android', { |
||||
|
target: 'dist', |
||||
|
appName: scopeApp.value, |
||||
|
onProgress: (p) => { apkPercent.value = p }, |
||||
|
}) |
||||
|
// 传完的直链要让人看得见、拿得走:填进「默认下载地址」框(2026-09-23 用户要求), |
||||
|
// 这个地址就是官方包的下载地址。填进表单不等于已保存,所以提示里点明要点「保存」。 |
||||
|
form.address = url |
||||
|
await writeOfficialChannelAddress(url) |
||||
|
emit('uploaded', url) |
||||
|
success('安装包已上传:地址已填入「默认下载地址」并写入「官方渠道」,记得点「保存」') |
||||
|
} catch (err: any) { |
||||
|
error(err?.message || '上传失败') |
||||
|
} finally { |
||||
|
apkUploading.value = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 官方渠道的渠道号,与客户端 UserChannel.OFFICIAL 一致。 |
||||
|
// 网页下载页的安装包直链取的就是这一条,所以传完包要落在这里。 |
||||
|
const CHANNEL_OFFICIAL = 100 |
||||
|
|
||||
|
// 把新包地址写进「渠道分发 → 官方渠道」。 |
||||
|
// |
||||
|
// ⚠️ savechannelapp 是**整行覆盖**(后端 ShouldBindJSON 到整个 comm.ChannelApp 再 Save), |
||||
|
// 所以必须先读回整行、只改 address 再写回。只发 {channel, address} 会把上架状态、 |
||||
|
// 支付渠道、是否允许跳过设备绑定这些字段统统清零 —— 而且不会报任何错。 |
||||
|
async function writeOfficialChannelAddress(address: string) { |
||||
|
// ⚠️ 这个接口回的是 { items: [...] } 而不是裸数组。当成数组取会永远找不到已有行, |
||||
|
// 于是每次都走「新建」分支,把官方渠道已有的上架状态/支付渠道/描述统统覆盖成默认值。 |
||||
|
const d = await webApi<{ items?: any[] }>('getchannelapps', { app_name: scopeApp.value }) |
||||
|
const old = (d?.items ?? []).find((r: any) => Number(r?.channel) === CHANNEL_OFFICIAL) |
||||
|
const row = old |
||||
|
? { ...old, address } |
||||
|
: { |
||||
|
// 这个渠道还不存在就建一条。enable 必须为 true、status 不能是草稿(0), |
||||
|
// 否则服务端 getChannelApps 会整条跳过,等于传了包却没人取得到。 |
||||
|
app_name: scopeApp.value, |
||||
|
channel: CHANNEL_OFFICIAL, |
||||
|
address, |
||||
|
status: 1, // 已上架 |
||||
|
enable: true, |
||||
|
description: '官方渠道(网页下载页的安装包直链)', |
||||
|
paychannels: '', |
||||
|
allowskipdevicebinding: false, |
||||
|
} |
||||
|
await webApi('savechannelapp', { ...row, app_name: scopeApp.value }) |
||||
|
} |
||||
|
|
||||
|
|
||||
|
// 作用域由外层「版本与分发」页统一给:两块配置共用一个应用选择器。 |
||||
|
// 用 computed 顶替原来的 scopeApp ref,正文里所有 scopeApp.value 的写法原样保留 —— |
||||
|
// 逐个改成 props.appName 只会在这次合并里多制造几十处可能改漏的地方。 |
||||
|
const props = defineProps<{ appName: string }>() |
||||
|
// 传完安装包通知外层:下方「渠道分发」面板要重新拉一次,才能看到刚写进官方渠道的地址。 |
||||
|
const emit = defineEmits<{ (e: 'uploaded', url: string): void }>() |
||||
|
const scopeApp = computed(() => props.appName) |
||||
|
const submitting = ref(false) |
||||
|
const probe = ref('') |
||||
|
|
||||
|
function emptyForm(): AppRelease { |
||||
|
return { version: '', min_version: '', force_update: false, changelog: '', address: '', review_version: '', ios_version: '', ios_min_version: '' } |
||||
|
} |
||||
|
const form = reactive<AppRelease>(emptyForm()) |
||||
|
|
||||
|
// 与后端 comm.CompareVersion 同语义:切掉构建号、逐段按整数比、短的补 0、段内取前导数字。 |
||||
|
// 后台这份只用于即时提示,真正的判定在服务端——两边不一致时以服务端为准。 |
||||
|
function cmpVer(a: string, b: string): number { |
||||
|
const as = a.split('+')[0]!.trim().split('.') |
||||
|
const bs = b.split('+')[0]!.trim().split('.') |
||||
|
const n = Math.max(as.length, bs.length) |
||||
|
for (let i = 0; i < n; i++) { |
||||
|
const x = parseInt(as[i] ?? '0') || 0 |
||||
|
const y = parseInt(bs[i] ?? '0') || 0 |
||||
|
if (x !== y) return x < y ? -1 : 1 |
||||
|
} |
||||
|
return 0 |
||||
|
} |
||||
|
|
||||
|
const versionWarn = computed(() => { |
||||
|
if (form.force_update && !form.version.trim() && !form.ios_version.trim()) { |
||||
|
return '开启强制更新时至少要填一个平台的「当前线上版本」,否则强更没有任何效果' |
||||
|
} |
||||
|
if (form.min_version.trim() && form.version.trim() && cmpVer(form.min_version, form.version) > 0) { |
||||
|
return 'Android「最低支持版本」高于「当前线上版本」,会把所有线上用户判定为需强更却无更高版本可更' |
||||
|
} |
||||
|
if (form.ios_min_version.trim() && form.ios_version.trim() && cmpVer(form.ios_min_version, form.ios_version) > 0) { |
||||
|
return 'iOS「最低支持版本」高于「当前线上版本」,会把所有线上用户判定为需强更却无更高版本可更' |
||||
|
} |
||||
|
return '' |
||||
|
}) |
||||
|
|
||||
|
// 判定预览按平台看:与服务端 comm.AppRelease.VersionFor / MinVersionFor 同口径(最低版本留空 = 线上版本)。 |
||||
|
const probePlatform = ref<'android' | 'ios'>('android') |
||||
|
|
||||
|
// 与服务端 evalChannelUpgrade 逐条对齐(含「强更必然意味着有更新」这条兜底); |
||||
|
// 渠道上架状态与下载地址那两条规则依赖渠道行,这里不模拟。 |
||||
|
const probeResult = computed(() => { |
||||
|
const v = probe.value.trim() |
||||
|
if (!v) return { need: false, force: false, reviewing: false } |
||||
|
const ver = (probePlatform.value === 'ios' ? form.ios_version : form.version).trim() |
||||
|
const minRaw = (probePlatform.value === 'ios' ? form.ios_min_version : form.min_version).trim() |
||||
|
const min = minRaw || ver |
||||
|
const need = !!ver && cmpVer(v, ver) < 0 |
||||
|
const force = form.force_update && !!min && cmpVer(v, min) < 0 |
||||
|
const reviewing = !!form.review_version.trim() && cmpVer(v, form.review_version) === 0 |
||||
|
return { need: need || force, force, reviewing } |
||||
|
}) |
||||
|
|
||||
|
async function load() { |
||||
|
try { |
||||
|
const d = await webApi<{ item: AppRelease }>('getapprelease', { app_name: scopeApp.value }) |
||||
|
Object.assign(form, emptyForm(), d?.item ?? {}) |
||||
|
} catch (e: any) { |
||||
|
error(e?.message ?? '加载失败') |
||||
|
Object.assign(form, emptyForm()) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async function save() { |
||||
|
if (versionWarn.value) { error(versionWarn.value); return } |
||||
|
submitting.value = true |
||||
|
try { |
||||
|
// 只提交版本这一组字段:游客登录配置和它同一行,整行提交会把对方清空。 |
||||
|
await webApi('saveappversion', { |
||||
|
app_name: scopeApp.value, |
||||
|
version: form.version, |
||||
|
min_version: form.min_version, |
||||
|
force_update: form.force_update, |
||||
|
changelog: form.changelog, |
||||
|
address: form.address, |
||||
|
review_version: form.review_version, |
||||
|
ios_version: form.ios_version, |
||||
|
ios_min_version: form.ios_min_version, |
||||
|
}) |
||||
|
success('保存成功') |
||||
|
await load() |
||||
|
} catch (e: any) { |
||||
|
error(e?.message ?? '保存失败') |
||||
|
} finally { |
||||
|
submitting.value = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// ⚠️ 初次加载必须放 onMounted,不能用 watch 的 immediate:immediate 会在 **SSR 阶段** |
||||
|
// 就执行,而这些接口要登录态,服务端渲染时必然失败 —— 表现是整页 500(踩过)。 |
||||
|
// watch 只负责「外层换了应用」时重新拉。 |
||||
|
onMounted(load) |
||||
|
watch(() => props.appName, () => { load() }) |
||||
|
</script> |
||||
|
|
||||
|
<style scoped> |
||||
|
.scope-tip { background: #f8fafc; border: 1px solid #e2e8f0; color: #475569; font-size: 12px; padding: 8px 12px; border-radius: 10px; margin-bottom: 16px; } |
||||
|
.ver-wrap { display: grid; gap: 16px; grid-template-columns: minmax(0, 2fr) minmax(260px, 1fr); align-items: start; } |
||||
|
@media (max-width: 900px) { .ver-wrap { grid-template-columns: 1fr; } } |
||||
|
.card { background: #fff; border: 1px solid #eef2f7; border-radius: 14px; padding: 18px 20px; } |
||||
|
.plat-col { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 10px; padding: 10px 12px; } |
||||
|
.plat-title { font-size: 12px; font-weight: 600; color: #334155; margin-bottom: 6px; } |
||||
|
.ed-section { padding-bottom: 16px; margin-bottom: 16px; border-bottom: 1px solid #f1f5f9; } |
||||
|
.ed-section:last-of-type { border-bottom: 0; margin-bottom: 0; } |
||||
|
.ed-section-title { font-size: 13px; font-weight: 700; color: #334155; margin-bottom: 10px; } |
||||
|
.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; } |
||||
|
.ed-warn { font-size: 12px; color: #b45309; background: #fffbeb; border: 1px solid #fde68a; border-radius: 8px; padding: 6px 10px; margin-top: 10px; } |
||||
|
.ed-footer { display: flex; justify-content: flex-end; gap: 8px; padding-top: 14px; border-top: 1px solid #f1f5f9; } |
||||
|
.preview { position: sticky; top: 16px; } |
||||
|
.probe-list { margin-top: 12px; display: flex; flex-direction: column; gap: 8px; } |
||||
|
.probe-row { display: flex; align-items: center; justify-content: space-between; font-size: 12px; color: #475569; } |
||||
|
.badge { font-size: 11px; font-weight: 700; padding: 2px 10px; border-radius: 8px; } |
||||
|
.badge.on { background: #dcfce7; color: #059669; } |
||||
|
.badge.warn { background: #ffe4e6; color: #be123c; } |
||||
|
.badge.off { background: #f1f5f9; color: #94a3b8; } |
||||
|
.link { color: #4f46e5; text-decoration: underline; } |
||||
|
code { background: #f1f5f9; padding: 0 4px; border-radius: 4px; } |
||||
|
</style> |
||||
@ -0,0 +1,422 @@ |
|||||
|
<template> |
||||
|
<div> |
||||
|
<div class="flex flex-wrap items-start justify-between gap-3 mb-4"> |
||||
|
<div> |
||||
|
<div class="text-sm font-semibold text-slate-700">渠道分发</div> |
||||
|
<div class="text-xs text-slate-400 mt-1 max-w-3xl"> |
||||
|
<b>因渠道而异</b>的东西:本渠道下载地址、上架状态、支付渠道、是否允许跳过设备绑定。 |
||||
|
下载地址<b>留空就回退上面的「默认下载地址」</b>,所以只有需要直达某家商店时才填。 |
||||
|
支付渠道在这里控制「开放哪几种支付入口」,商户凭据在应用的业务功能配置里填。 |
||||
|
</div> |
||||
|
</div> |
||||
|
<button class="btn btn-primary btn-sm text-white shrink-0" @click="startAdd()">+ 添加渠道</button> |
||||
|
</div> |
||||
|
|
||||
|
<!-- 支付凭据体检:开了支付入口却没配凭据,是这个页面最容易踩的坑 --> |
||||
|
<div v-if="payWarnings.length" class="pay-warn"> |
||||
|
<div class="flex items-start gap-2"> |
||||
|
<span>⚠</span> |
||||
|
<div class="flex-1"> |
||||
|
<div class="text-sm font-semibold">以下支付方式已在渠道里开放,但商户凭据还没配:</div> |
||||
|
<div class="text-xs mt-1"> |
||||
|
<span v-for="w in payWarnings" :key="w.key" class="warn-pill">{{ w.label }}({{ w.channels.join('、') }})</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
<button class="btn btn-xs btn-ghost" @click="gotoPayConfig">去配置 →</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- 按渠道大类分组 --> |
||||
|
<div v-for="g in groups" :key="g.label" class="mb-6"> |
||||
|
<div class="flex items-center gap-2 mb-3"> |
||||
|
<span class="group-tag" :style="{ background: g.bg, color: g.color }">{{ g.icon }} {{ g.label }}</span> |
||||
|
<span class="text-xs text-slate-400">{{ g.items.length }} / {{ g.channels.length }} 已配置</span> |
||||
|
</div> |
||||
|
<div class="ch-grid"> |
||||
|
<div v-for="c in g.items" :key="c.channel" class="ch-card" :class="{ off: !c.enable }" @click="startEdit(c)"> |
||||
|
<div class="flex items-start justify-between gap-2"> |
||||
|
<div class="min-w-0"> |
||||
|
<div class="font-semibold text-sm text-slate-800">{{ channelName(c.channel) }}</div> |
||||
|
<div class="text-[11px] text-slate-400 mt-0.5">渠道号 {{ c.channel }}</div> |
||||
|
</div> |
||||
|
<span class="status-badge" :style="statusStyle(c.status)">{{ statusLabel(c.status) }}</span> |
||||
|
</div> |
||||
|
|
||||
|
<div class="flex flex-wrap items-center gap-1.5 mt-2"> |
||||
|
<span v-if="c.allowskipdevicebinding" class="flag">可跳过绑定</span> |
||||
|
<span v-if="!c.enable" class="flag flag-off">已停用</span> |
||||
|
</div> |
||||
|
|
||||
|
<div v-if="payList(c).length" class="flex flex-wrap items-center gap-1.5 mt-2"> |
||||
|
<span |
||||
|
v-for="p in payList(c)" |
||||
|
:key="p" |
||||
|
class="pay-pill" |
||||
|
:class="{ missing: payMissing(p) }" |
||||
|
:title="payMissing(p) ? '该支付方式的商户凭据尚未配置' : ''" |
||||
|
>{{ payLabel(p) }}{{ payMissing(p) ? ' ⚠' : '' }}</span> |
||||
|
</div> |
||||
|
|
||||
|
<div class="text-[11px] mt-2 truncate" :class="c.address ? 'text-slate-400' : 'text-slate-300'"> |
||||
|
{{ c.address || '未填本渠道地址 · 用应用默认' }} |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- 该组里尚未配置的渠道,点一下直接建 --> |
||||
|
<div |
||||
|
v-for="ch in g.missing" |
||||
|
:key="'m' + ch" |
||||
|
class="ch-card ch-empty" |
||||
|
@click="startAdd(ch)" |
||||
|
> |
||||
|
<div class="text-sm text-slate-400">{{ channelName(ch) }}</div> |
||||
|
<div class="text-[11px] text-slate-300 mt-1">未配置 · 点击添加</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- 编辑弹窗 --> |
||||
|
<div v-show="modalOpen" class="ch-overlay"> |
||||
|
<div class="ch-box"> |
||||
|
<div class="flex items-center justify-between mb-4"> |
||||
|
<div class="flex items-center gap-3"> |
||||
|
<h3 class="text-base font-semibold text-slate-800">{{ isEditing ? '编辑渠道' : '添加渠道' }}</h3> |
||||
|
<span class="scope-chip">{{ scopeApp || '全局默认' }}</span> |
||||
|
</div> |
||||
|
<button class="btn btn-ghost btn-xs" @click="closeModal">✕ 关闭</button> |
||||
|
</div> |
||||
|
|
||||
|
<section class="ed-section"> |
||||
|
<div class="ed-section-title">渠道</div> |
||||
|
<div class="grid grid-cols-2 gap-3"> |
||||
|
<div> |
||||
|
<span class="ed-label">分发渠道 <i class="req">*</i></span> |
||||
|
<select v-model.number="form.channel" :disabled="isEditing" class="select select-bordered select-sm w-full"> |
||||
|
<option :value="0" disabled>请选择渠道</option> |
||||
|
<optgroup v-for="g in CHANNEL_GROUPS" :key="g.label" :label="g.label"> |
||||
|
<option v-for="ch in g.channels" :key="ch" :value="ch">{{ channelName(ch) }}({{ ch }})</option> |
||||
|
</optgroup> |
||||
|
</select> |
||||
|
</div> |
||||
|
<div> |
||||
|
<span class="ed-label">上架状态</span> |
||||
|
<select v-model.number="form.status" class="select select-bordered select-sm w-full"> |
||||
|
<option v-for="s in STATUS" :key="s.v" :value="s.v">{{ s.label }}</option> |
||||
|
</select> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="mt-3"> |
||||
|
<span class="ed-label">描述</span> |
||||
|
<input v-model="form.description" class="input input-bordered input-sm w-full" placeholder="这个渠道包的说明" /> |
||||
|
</div> |
||||
|
</section> |
||||
|
|
||||
|
<section class="ed-section"> |
||||
|
<div class="ed-section-title">本渠道下载地址</div> |
||||
|
<input v-model="form.address" class="input input-bordered input-sm w-full" placeholder="本商店的下载链接,留空则用应用默认地址" /> |
||||
|
<p class="ed-tip"> |
||||
|
客户端点「立即更新」时直接打开这个地址,所以<b>必须是本渠道自己的商店链接</b>—— |
||||
|
华为、小米、App Store 各不相同,填错会把用户引到别家商店。 |
||||
|
留空则回退到<b>上方「发版配置」</b>里的「默认下载地址」—— |
||||
|
那里填网页下载页的话,这里留空就等于交给它按品牌自动分流。 |
||||
|
</p> |
||||
|
</section> |
||||
|
|
||||
|
<section class="ed-section"> |
||||
|
<div class="ed-section-title">能力开关</div> |
||||
|
<label class="flex items-center gap-2 text-sm cursor-pointer"> |
||||
|
<input v-model="form.allowskipdevicebinding" type="checkbox" class="checkbox checkbox-sm checkbox-primary" /> |
||||
|
<span>允许跳过设备绑定</span> |
||||
|
</label> |
||||
|
<p class="ed-tip"> |
||||
|
游客登录开关已移到 |
||||
|
<NuxtLink class="link" to="/guestaccess">服务配置 → 游客显隐控制</NuxtLink> |
||||
|
(按应用配,不分渠道)。 |
||||
|
</p> |
||||
|
</section> |
||||
|
|
||||
|
<section class="ed-section"> |
||||
|
<div class="ed-section-title">支付渠道</div> |
||||
|
<div class="pay-grid"> |
||||
|
<label v-for="p in PAY_CHANNELS" :key="p.key" class="pay-item" :class="{ checked: payChecked(p.key) }"> |
||||
|
<input type="checkbox" class="checkbox checkbox-xs checkbox-primary" :checked="payChecked(p.key)" @change="togglePay(p.key)" /> |
||||
|
<span>{{ p.label }}</span> |
||||
|
<span v-if="payChecked(p.key) && payMissing(p.key)" class="pay-miss">未配凭据</span> |
||||
|
</label> |
||||
|
</div> |
||||
|
<p class="ed-tip"> |
||||
|
这里只决定「这个渠道开放哪几种支付入口」;商户号、密钥等凭据在 |
||||
|
<a v-if="scopeApp" class="link" @click.prevent="gotoPayConfig">应用「{{ scopeApp }}」→ 业务功能配置 → 支付</a> |
||||
|
<span v-else>各应用的「业务功能配置 → 支付」</span> |
||||
|
里填。 |
||||
|
</p> |
||||
|
</section> |
||||
|
|
||||
|
<div class="ed-footer"> |
||||
|
<label class="flex items-center gap-2 text-sm cursor-pointer"> |
||||
|
<input v-model="form.enable" type="checkbox" class="checkbox checkbox-sm checkbox-primary" /> |
||||
|
<span>启用</span> |
||||
|
<span class="text-xs text-slate-400">停用后该渠道配置不下发客户端</span> |
||||
|
</label> |
||||
|
<div class="flex gap-2"> |
||||
|
<button v-if="isEditing" class="btn btn-ghost btn-sm text-error mr-auto" @click="del">删除</button> |
||||
|
<button class="btn btn-ghost btn-sm" @click="closeModal">取消</button> |
||||
|
<button class="btn btn-primary btn-sm text-white" :disabled="submitting" @click="save">保存</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<script setup lang="ts"> |
||||
|
import { ref, reactive, computed, watch, onMounted } from 'vue' |
||||
|
import { useApi } from '~/composables/useApi' |
||||
|
import { useToast } from '~/composables/useToast' |
||||
|
|
||||
|
// 与后端 comm.ChannelApp 一一对应。 |
||||
|
// ⚠️ 版本号 / 最低支持版本 / 强制更新 / 更新日志 / 送审版本 / 游客登录开关**不在这里**: |
||||
|
// 它们是「整个应用一套」的,已搬到 app_release(服务配置 → 版本控制 / 游客显隐控制)。 |
||||
|
// 别再加回来——按渠道各存一份意味着同一个开关有 8 处能改而客户端只读一处。 |
||||
|
interface ChannelApp { |
||||
|
row_id: number |
||||
|
app_name: string |
||||
|
channel: number |
||||
|
address: string |
||||
|
status: number |
||||
|
description: string |
||||
|
allowskipdevicebinding: boolean |
||||
|
paychannels: string |
||||
|
enable: boolean |
||||
|
} |
||||
|
|
||||
|
// 渠道枚举(与后端 pb.UserChannel / comm.Channel* 一致) |
||||
|
const CHANNELS: Record<number, string> = { |
||||
|
1: '华为', 2: '小米', 3: 'OPPO', 4: 'VIVO', 5: '荣耀', |
||||
|
100: '官方包', 101: 'App Store', 102: 'Google Play', |
||||
|
} |
||||
|
function channelName(v: number): string { |
||||
|
return CHANNELS[v] ?? '渠道' + v |
||||
|
} |
||||
|
const CHANNEL_GROUPS = [ |
||||
|
{ label: '中国厂商', icon: '📱', color: '#0369a1', bg: '#dbeafe', channels: [1, 2, 3, 4, 5] }, |
||||
|
{ label: '全球商店', icon: '🌍', color: '#c2410c', bg: '#fff7ed', channels: [101, 102] }, |
||||
|
{ label: '官方渠道', icon: '🏠', color: '#059669', bg: '#dcfce7', channels: [100] }, |
||||
|
] |
||||
|
|
||||
|
// 上架状态(与 comm.ChannelStatus* 一致) |
||||
|
const STATUS = [ |
||||
|
{ v: 0, label: '草稿(不下发)', color: '#64748b', bg: '#f1f5f9' }, |
||||
|
{ v: 1, label: '已上架', color: '#059669', bg: '#dcfce7' }, |
||||
|
{ v: 2, label: '审核中', color: '#b45309', bg: '#fef9c3' }, |
||||
|
{ v: 3, label: '已下架', color: '#be123c', bg: '#ffe4e6' }, |
||||
|
] |
||||
|
function statusLabel(v: number): string { |
||||
|
return STATUS.find((s) => s.v === v)?.label ?? '未知' |
||||
|
} |
||||
|
function statusStyle(v: number) { |
||||
|
const s = STATUS.find((x) => x.v === v) |
||||
|
return s ? { background: s.bg, color: s.color } : {} |
||||
|
} |
||||
|
|
||||
|
// 支付渠道 → 业务功能配置里的模块键(与后端 comm.PayChannelModule 一致) |
||||
|
const PAY_CHANNELS = [ |
||||
|
{ key: 'wxpay', label: '微信支付', module: 'wechatpay' }, |
||||
|
{ key: 'alipay', label: '支付宝', module: 'alipay' }, |
||||
|
{ key: 'paypal', label: 'PayPal', module: 'paypal' }, |
||||
|
{ key: 'apple', label: 'Apple 内购', module: 'appleiap' }, |
||||
|
{ key: 'google', label: 'Google 内购', module: 'googleiap' }, |
||||
|
] |
||||
|
function payLabel(k: string): string { |
||||
|
return PAY_CHANNELS.find((p) => p.key === k)?.label ?? k |
||||
|
} |
||||
|
|
||||
|
const { webApi, consoleApi } = useApi() |
||||
|
const { success, error } = useToast() |
||||
|
const router = useRouter() |
||||
|
|
||||
|
|
||||
|
// 作用域由外层「版本与分发」页统一给:两块配置共用一个应用选择器。 |
||||
|
// 用 computed 顶替原来的 scopeApp ref,正文里所有 scopeApp.value 的写法原样保留 —— |
||||
|
// 逐个改成 props.appName 只会在这次合并里多制造几十处可能改漏的地方。 |
||||
|
const props = defineProps<{ appName: string }>() |
||||
|
const scopeApp = computed(() => props.appName) |
||||
|
const items = ref<ChannelApp[]>([]) |
||||
|
const payConfigured = ref<string[]>([]) // 已配凭据的模块键 |
||||
|
const payChecked_ = ref(false) // 是否成功查到(全局作用域查不了) |
||||
|
const modalOpen = ref(false) |
||||
|
const isEditing = ref(false) |
||||
|
const submitting = ref(false) |
||||
|
|
||||
|
function emptyForm(): ChannelApp { |
||||
|
return { |
||||
|
row_id: 0, app_name: '', channel: 0, address: '', status: 1, description: '', |
||||
|
allowskipdevicebinding: false, paychannels: '', enable: true, |
||||
|
} |
||||
|
} |
||||
|
const form = reactive<ChannelApp>(emptyForm()) |
||||
|
|
||||
|
const groups = computed(() => |
||||
|
CHANNEL_GROUPS.map((g) => ({ |
||||
|
...g, |
||||
|
items: items.value.filter((c) => g.channels.includes(c.channel)).sort((a, b) => a.channel - b.channel), |
||||
|
missing: g.channels.filter((ch) => !items.value.some((c) => c.channel === ch)), |
||||
|
})), |
||||
|
) |
||||
|
|
||||
|
function payList(c: ChannelApp): string[] { |
||||
|
return (c.paychannels || '').split(',').map((x) => x.trim()).filter(Boolean) |
||||
|
} |
||||
|
// 某支付方式是否「开了但没配凭据」。查不到配置状态时一律不报警,避免误伤。 |
||||
|
function payMissing(key: string): boolean { |
||||
|
if (!payChecked_.value) return false |
||||
|
const mod = PAY_CHANNELS.find((p) => p.key === key)?.module |
||||
|
return !!mod && !payConfigured.value.includes(mod) |
||||
|
} |
||||
|
// 汇总所有渠道里「开了但没配」的支付方式,连带列出是哪几个渠道开的 |
||||
|
const payWarnings = computed(() => { |
||||
|
if (!payChecked_.value) return [] |
||||
|
const map: Record<string, string[]> = {} |
||||
|
for (const c of items.value) { |
||||
|
if (!c.enable) continue |
||||
|
for (const p of payList(c)) { |
||||
|
if (!payMissing(p)) continue |
||||
|
;(map[p] = map[p] || []).push(channelName(c.channel)) |
||||
|
} |
||||
|
} |
||||
|
return Object.keys(map).map((k) => ({ key: k, label: payLabel(k), channels: map[k]! })) |
||||
|
}) |
||||
|
|
||||
|
// ===== 加载 ===== |
||||
|
// ===== 加载 ===== |
||||
|
async function loadItems() { |
||||
|
try { |
||||
|
const d = await webApi<{ items: ChannelApp[] }>('getchannelapps', { app_name: scopeApp.value }) |
||||
|
items.value = d?.items ?? [] |
||||
|
} catch (e: any) { |
||||
|
error(e?.message ?? '加载失败') |
||||
|
items.value = [] |
||||
|
} |
||||
|
} |
||||
|
async function loadPayStatus() { |
||||
|
try { |
||||
|
const d = await webApi<{ configured: string[]; checked: boolean }>('getchannelpaystatus', { app_name: scopeApp.value }) |
||||
|
payConfigured.value = d?.configured ?? [] |
||||
|
payChecked_.value = !!d?.checked |
||||
|
} catch { |
||||
|
payConfigured.value = [] |
||||
|
payChecked_.value = false |
||||
|
} |
||||
|
} |
||||
|
async function loadAll() { |
||||
|
await Promise.all([loadItems(), loadPayStatus()]) |
||||
|
} |
||||
|
|
||||
|
// ===== 编辑 ===== |
||||
|
function startAdd(channel?: number) { |
||||
|
Object.assign(form, emptyForm()) |
||||
|
if (typeof channel === 'number') form.channel = channel |
||||
|
isEditing.value = false |
||||
|
modalOpen.value = true |
||||
|
} |
||||
|
function startEdit(c: ChannelApp) { |
||||
|
Object.assign(form, emptyForm(), c) |
||||
|
isEditing.value = true |
||||
|
modalOpen.value = true |
||||
|
} |
||||
|
function closeModal() { |
||||
|
modalOpen.value = false |
||||
|
} |
||||
|
function payChecked(key: string): boolean { |
||||
|
return payList(form).includes(key) |
||||
|
} |
||||
|
function togglePay(key: string) { |
||||
|
const cur = payList(form) |
||||
|
const i = cur.indexOf(key) |
||||
|
if (i >= 0) cur.splice(i, 1) |
||||
|
else cur.push(key) |
||||
|
form.paychannels = cur.join(',') |
||||
|
} |
||||
|
|
||||
|
async function save() { |
||||
|
if (!form.channel) { error('请选择分发渠道'); return } |
||||
|
submitting.value = true |
||||
|
try { |
||||
|
await webApi('savechannelapp', { ...form, app_name: scopeApp.value }) |
||||
|
success('保存成功') |
||||
|
closeModal() |
||||
|
await loadItems() |
||||
|
} catch (e: any) { |
||||
|
error(e?.message ?? '保存失败') |
||||
|
} finally { |
||||
|
submitting.value = false |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function del() { |
||||
|
if (!window.confirm(`确定删除「${channelName(form.channel)}」这个渠道的配置吗?`)) return |
||||
|
webApi('delchannelapp', { app_name: scopeApp.value, channel: form.channel }) |
||||
|
.then(() => { success('已删除'); closeModal(); loadItems() }) |
||||
|
.catch((e: any) => error(e?.message ?? '删除失败')) |
||||
|
} |
||||
|
|
||||
|
function gotoPayConfig() { |
||||
|
if (!scopeApp.value) { error('请先在上方选择一个应用'); return } |
||||
|
router.push(`/apps/${encodeURIComponent(scopeApp.value)}?tab=modules`) |
||||
|
} |
||||
|
|
||||
|
// ⚠️ 初次加载必须放 onMounted,不能用 watch 的 immediate:immediate 会在 **SSR 阶段** |
||||
|
// 就执行,而这些接口要登录态,服务端渲染时必然失败 —— 表现是整页 500(踩过)。 |
||||
|
// watch 只负责「外层换了应用」时重新拉。 |
||||
|
onMounted(loadAll) |
||||
|
watch(() => props.appName, () => { loadAll() }) |
||||
|
// 给外层「版本与分发」页用:上面的面板传完安装包会改写官方渠道的地址,这边要重拉才看得到。 |
||||
|
defineExpose({ reload: loadAll }) |
||||
|
</script> |
||||
|
|
||||
|
<style scoped> |
||||
|
.group-tag { font-size: 12px; font-weight: 700; padding: 3px 10px; border-radius: 8px; } |
||||
|
.ch-grid { display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); } |
||||
|
.ch-card { background: #fff; border: 1px solid #eef2f7; border-radius: 14px; padding: 14px 16px; cursor: pointer; transition: all .15s; } |
||||
|
.ch-card:hover { border-color: #c7d2fe; box-shadow: 0 4px 14px rgba(99, 102, 241, .08); } |
||||
|
.ch-card.off { opacity: .55; } |
||||
|
.ch-empty { border-style: dashed; background: #fafbfc; display: flex; flex-direction: column; justify-content: center; align-items: center; min-height: 92px; } |
||||
|
.ch-empty:hover { border-color: #c7d2fe; background: #f8fafc; } |
||||
|
|
||||
|
.status-badge { font-size: 10px; font-weight: 700; padding: 2px 8px; border-radius: 6px; white-space: nowrap; } |
||||
|
|
||||
|
.flag { font-size: 10px; font-weight: 600; padding: 2px 7px; border-radius: 5px; background: #f1f5f9; color: #475569; } |
||||
|
.flag-off { background: #fee2e2; color: #b91c1c; } |
||||
|
.pay-pill { font-size: 10px; font-weight: 600; padding: 2px 8px; border-radius: 6px; background: #eef2ff; color: #4338ca; } |
||||
|
.pay-pill.missing { background: #fef3c7; color: #b45309; } |
||||
|
|
||||
|
.pay-warn { background: #fffbeb; border: 1px solid #fde68a; color: #92400e; border-radius: 12px; padding: 12px 14px; margin-bottom: 16px; } |
||||
|
.warn-pill { display: inline-block; margin-right: 8px; padding: 1px 8px; border-radius: 6px; background: #fef3c7; } |
||||
|
.scope-tip { background: #f8fafc; border: 1px solid #eef2f7; color: #64748b; font-size: 12px; border-radius: 10px; padding: 9px 13px; margin-bottom: 16px; } |
||||
|
|
||||
|
/* 弹窗 */ |
||||
|
.ch-overlay { position: fixed; inset: 0; background: rgba(15, 23, 42, .45); z-index: 100; display: flex; align-items: center; justify-content: center; backdrop-filter: blur(2px); padding: 24px; } |
||||
|
.ch-box { background: #fff; border-radius: 18px; padding: 24px; width: 700px; max-width: 96vw; max-height: 90vh; overflow-y: auto; box-shadow: 0 24px 80px rgba(0, 0, 0, .18); } |
||||
|
.scope-chip { font-size: 11px; font-weight: 600; padding: 2px 9px; border-radius: 6px; background: #eef2ff; color: #4338ca; } |
||||
|
|
||||
|
.ed-section { padding: 14px 0; border-top: 1px dashed #eef2f7; } |
||||
|
.ed-section:first-of-type { padding-top: 0; border-top: none; } |
||||
|
.ed-section-title { font-size: 11px; font-weight: 700; letter-spacing: .04em; color: #94a3b8; margin-bottom: 10px; } |
||||
|
.ed-label { display: block; font-size: 11px; color: #64748b; margin-bottom: 4px; } |
||||
|
.ed-warn { font-size: 11px; color: #b45309; margin-top: 8px; } |
||||
|
.ed-tip { font-size: 11px; color: #94a3b8; margin-top: 8px; } |
||||
|
.req { color: #dc2626; font-style: normal; } |
||||
|
.link { color: #4f46e5; cursor: pointer; text-decoration: underline; } |
||||
|
|
||||
|
.pay-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; } |
||||
|
.pay-item { display: flex; align-items: center; gap: 7px; padding: 8px 10px; border-radius: 9px; border: 1.5px solid #e2e8f0; cursor: pointer; font-size: 13px; color: #475569; transition: all .15s; } |
||||
|
.pay-item:hover { border-color: #c7d2fe; background: #f8fafc; } |
||||
|
.pay-item.checked { border-color: #6366f1; background: #eef2ff; color: #4338ca; } |
||||
|
.pay-miss { font-size: 10px; color: #b45309; background: #fef3c7; padding: 1px 5px; border-radius: 4px; margin-left: auto; } |
||||
|
|
||||
|
.ed-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding-top: 14px; border-top: 1px solid #eef2f7; } |
||||
|
|
||||
|
@media (max-width: 860px) { |
||||
|
.pay-grid { grid-template-columns: repeat(2, 1fr); } |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,94 @@ |
|||||
|
<template> |
||||
|
<div> |
||||
|
<!-- 页头 + 应用选择:上下两块配置共用一个作用域 --> |
||||
|
<div class="flex flex-wrap items-start justify-between gap-3 mb-4"> |
||||
|
<div> |
||||
|
<h2 class="text-lg font-semibold text-slate-800">版本与分发</h2> |
||||
|
<p class="text-xs text-slate-400 mt-1 max-w-3xl"> |
||||
|
发一次版要配的东西都在这一页:上半区是<b>整个应用一套</b>的版本号 / 强更 / 更新日志 / 默认下载地址, |
||||
|
下半区是<b>因渠道而异</b>的各商店链接与上架状态。两者存在不同的表里, |
||||
|
<b>提交也各管各的</b>——原因见下方说明。 |
||||
|
</p> |
||||
|
</div> |
||||
|
<div class="flex items-center gap-2"> |
||||
|
<span class="text-sm text-slate-500">应用</span> |
||||
|
<select v-model="scopeApp" class="select select-bordered select-sm min-w-40"> |
||||
|
<option value="">全局默认(所有应用兜底)</option> |
||||
|
<option v-for="a in scopeApps" :key="a" :value="a">{{ a }}</option> |
||||
|
</select> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div v-if="!scopeApp" class="scope-tip"> |
||||
|
当前是<b>全局默认</b>作用域:这里配的对所有应用生效,某个应用单独配了则以应用的为准。 |
||||
|
<b>上传安装包必须先选具体应用</b>——安装包按应用分目录存放。 |
||||
|
</div> |
||||
|
|
||||
|
<section class="panel p-5 mb-4"> |
||||
|
<AppReleasePanel :app-name="scopeApp" @uploaded="channelPanel?.reload()" /> |
||||
|
</section> |
||||
|
|
||||
|
<section class="panel p-5"> |
||||
|
<ChannelAppPanel ref="channelPanel" :app-name="scopeApp" /> |
||||
|
</section> |
||||
|
</div> |
||||
|
</template> |
||||
|
|
||||
|
<script setup lang="ts"> |
||||
|
import { ref, onMounted } from 'vue' |
||||
|
import { useApi } from '~/composables/useApi' |
||||
|
import AppReleasePanel from '~/components/AppReleasePanel.vue' |
||||
|
import ChannelAppPanel from '~/components/ChannelAppPanel.vue' |
||||
|
|
||||
|
definePageMeta({ title: '版本与分发' }) |
||||
|
|
||||
|
/* |
||||
|
「版本控制」与「渠道分发」2026-09-23 合并成本页。 |
||||
|
|
||||
|
⚠️ **合的是入口,不是表。** app_release(一应用一行) 与 channel_app((应用,渠道) 一行) |
||||
|
维度不同,2026-08-31 正是从一张表拆开的:版本号和游客开关本来就整个应用一套, |
||||
|
按渠道各存一份意味着同一个开关有 8 处能改而客户端只读一处,出过 |
||||
|
「后台明明开了游客登录、App 就是不显示」的事故。合回去等于把那个坑重新挖开。 |
||||
|
|
||||
|
⚠️ **两块各自提交,不要做成一个大表单整包保存。** app_release 那一行还被 |
||||
|
「游客显隐控制」页共用,后端是「读整行 → 只覆盖本组字段 → 整行写回」; |
||||
|
做成整行提交的话,在本页点一次保存就会把游客登录配置清空。 |
||||
|
*/ |
||||
|
|
||||
|
const { webApi, consoleApi } = useApi() |
||||
|
|
||||
|
const scopeApp = ref('') |
||||
|
// 上面的面板传完安装包后会改写官方渠道地址,这里拿到下方面板的句柄让它重拉。 |
||||
|
const channelPanel = ref<{ reload: () => Promise<void> } | null>(null) |
||||
|
const scopeApps = ref<string[]>([]) |
||||
|
|
||||
|
// 应用名来源:应用中心的应用表 + 部署注册表(兜底老数据);无权读这两张表时改走 api_getmyapps。 |
||||
|
async function loadScopeApps() { |
||||
|
const names = new Set<string>() |
||||
|
const [products, deploys] = await Promise.all([ |
||||
|
consoleApi<Array<{ name: string }>>('products/list', {}).catch(() => [] as Array<{ name: string }>), |
||||
|
consoleApi<Array<{ app_name?: string; name: string }>>('apps/list', {}).catch(() => [] as Array<{ app_name?: string; name: string }>), |
||||
|
]) |
||||
|
;(products ?? []).forEach((p) => p.name && names.add(p.name)) |
||||
|
;(deploys ?? []).forEach((d) => (d.app_name || d.name) && names.add(d.app_name || d.name)) |
||||
|
if (!names.size) { |
||||
|
try { |
||||
|
const d = await webApi<{ apps: Array<{ name: string; app_name?: string }> }>('getmyapps', {}) |
||||
|
;(d?.apps ?? []).forEach((a) => { |
||||
|
const n = a.app_name || a.name |
||||
|
if (n) names.add(n) |
||||
|
}) |
||||
|
} catch { |
||||
|
// 拿不到就只剩「全局默认」,不阻断页面 |
||||
|
} |
||||
|
} |
||||
|
scopeApps.value = [...names].sort() |
||||
|
} |
||||
|
|
||||
|
onMounted(loadScopeApps) |
||||
|
</script> |
||||
|
|
||||
|
<style scoped> |
||||
|
.panel { background: #fff; border: 1px solid #e2e8f0; border-radius: 14px; } |
||||
|
.scope-tip { background: #f8fafc; border: 1px solid #e2e8f0; color: #475569; font-size: 12px; padding: 8px 12px; border-radius: 10px; margin-bottom: 16px; } |
||||
|
</style> |
||||
@ -0,0 +1,584 @@ |
|||||
|
import 'dart:async'; |
||||
|
|
||||
|
import 'package:device_plugin_interface/device_plugin_interface.dart'; |
||||
|
import 'package:flutter/material.dart'; |
||||
|
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
||||
|
import 'package:get/get.dart'; |
||||
|
|
||||
|
import '/core/utils/logger.dart'; |
||||
|
import '/data/models/user_Info.dart'; |
||||
|
import '/devices/device_hub.dart'; |
||||
|
import '/routes/app_routes.dart'; |
||||
|
import '../../controllers/my_devices_controller.dart'; |
||||
|
|
||||
|
/// 设备设置面板(底部弹出)。**厂商无关**:只认 [DeviceCapability.settingsPanel] |
||||
|
/// 与 `settings.*` 特性键,按 `settings.query` 返回的 `items` 渲染—— |
||||
|
/// 设备说自己有哪几项,面板就画哪几项。 |
||||
|
/// |
||||
|
/// 交互口径(与 lumi 设置页一致): |
||||
|
/// - 打开先发一次 `settings.query`(3 s 超时),成功前是骨架屏; |
||||
|
/// - 开关点了立刻翻转(乐观),写失败翻回来并提示; |
||||
|
/// - 滑块拖动只改本地值,300 ms 防抖后发,松手立即发最后一个值; |
||||
|
/// - 频次滑块吸附到设备给的 `options` 档位; |
||||
|
/// - 订阅会话事件:`settings.changed` 整体刷新(覆盖乐观值),断连切到「未连接」态。 |
||||
|
/// |
||||
|
/// ⚠️ 六条写命令设备只回成功码不回显新值,"当前值"的可信来源只有查询与插件回填, |
||||
|
/// 所以面板从不自己推断状态,只信 `items`。 |
||||
|
class DeviceSettingsSheet extends StatefulWidget { |
||||
|
const DeviceSettingsSheet({super.key, required this.device}); |
||||
|
|
||||
|
final UserDevice device; |
||||
|
|
||||
|
static Future<void> show(UserDevice device) { |
||||
|
final isDark = Get.isDarkMode; |
||||
|
return Get.bottomSheet( |
||||
|
DeviceSettingsSheet(device: device), |
||||
|
isScrollControlled: true, |
||||
|
backgroundColor: isDark ? const Color(0xFF1C1C1E) : Colors.white, |
||||
|
shape: RoundedRectangleBorder( |
||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(18.r)), |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
State<DeviceSettingsSheet> createState() => _DeviceSettingsSheetState(); |
||||
|
} |
||||
|
|
||||
|
class _DeviceSettingsSheetState extends State<DeviceSettingsSheet> { |
||||
|
static const String _tag = 'DeviceSettingsSheet'; |
||||
|
static const Duration _queryTimeout = Duration(seconds: 3); |
||||
|
static const Duration _sliderDebounce = Duration(milliseconds: 300); |
||||
|
|
||||
|
DeviceSession? _session; |
||||
|
StreamSubscription<DeviceSessionEvent>? _eventSub; |
||||
|
Worker? _revisionWorker; |
||||
|
|
||||
|
/// `settings.query` 返回的 items;null = 还没拿到 |
||||
|
Map<String, Object?>? _items; |
||||
|
int? _battery; |
||||
|
String? _firmware; |
||||
|
|
||||
|
bool _loading = false; |
||||
|
String? _error; |
||||
|
|
||||
|
/// 正在写的键,用来禁掉对应控件防连点 |
||||
|
final Set<String> _busy = {}; |
||||
|
|
||||
|
/// 滑块的本地值(拖动中先改这个,防抖后再发) |
||||
|
final Map<String, int> _sliderLocal = {}; |
||||
|
final Map<String, Timer> _debounce = {}; |
||||
|
DateTime _lastExpressionTap = DateTime.fromMillisecondsSinceEpoch(0); |
||||
|
|
||||
|
@override |
||||
|
void initState() { |
||||
|
super.initState(); |
||||
|
_attach(); |
||||
|
// 设备在面板开着时连上/断开:DeviceHub.revision 会变,重新找会话 |
||||
|
_revisionWorker = ever(DeviceHub.to.revision, (_) => _attach()); |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
void dispose() { |
||||
|
_revisionWorker?.dispose(); |
||||
|
_eventSub?.cancel(); |
||||
|
for (final t in _debounce.values) { |
||||
|
t.cancel(); |
||||
|
} |
||||
|
super.dispose(); |
||||
|
} |
||||
|
|
||||
|
MyDevicesController get _controller => Get.find<MyDevicesController>(); |
||||
|
|
||||
|
void _attach() { |
||||
|
final s = _controller.sessionFor(widget.device); |
||||
|
final usable = |
||||
|
s != null && s.supports(DeviceCapability.settingsPanel) ? s : null; |
||||
|
if (identical(usable, _session)) return; |
||||
|
_eventSub?.cancel(); |
||||
|
_session = usable; |
||||
|
if (usable == null) { |
||||
|
if (mounted) setState(() => _items = null); |
||||
|
return; |
||||
|
} |
||||
|
_eventSub = usable.eventStream.listen(_onEvent); |
||||
|
unawaited(_query()); |
||||
|
} |
||||
|
|
||||
|
void _onEvent(DeviceSessionEvent e) { |
||||
|
if (!mounted) return; |
||||
|
switch (e.type) { |
||||
|
case DeviceSessionEventType.feature: |
||||
|
final f = e.feature; |
||||
|
if (f != null && f.key == DeviceFeatureKeys.settingsChanged) { |
||||
|
_applySnapshot(f.data); |
||||
|
} |
||||
|
break; |
||||
|
case DeviceSessionEventType.connectionStateChanged: |
||||
|
if (e.connectionState == DeviceConnectionState.disconnected) { |
||||
|
setState(() { |
||||
|
_session = null; |
||||
|
_items = null; |
||||
|
}); |
||||
|
} |
||||
|
break; |
||||
|
default: |
||||
|
break; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
void _applySnapshot(Map<String, Object?> data) { |
||||
|
final items = data['items']; |
||||
|
if (items is! Map) return; |
||||
|
setState(() { |
||||
|
_items = items.map((k, v) => MapEntry(k.toString(), v)); |
||||
|
_battery = (data['battery'] as num?)?.toInt(); |
||||
|
_firmware = data['firmware'] as String?; |
||||
|
_error = null; |
||||
|
// 设备给的值覆盖本地乐观值,拖动中的滑块除外(防抖定时器还没触发) |
||||
|
for (final k in _sliderLocal.keys.toList()) { |
||||
|
if (!(_debounce[k]?.isActive ?? false)) _sliderLocal.remove(k); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
Future<void> _query() async { |
||||
|
final s = _session; |
||||
|
if (s == null) return; |
||||
|
setState(() { |
||||
|
_loading = true; |
||||
|
_error = null; |
||||
|
}); |
||||
|
try { |
||||
|
final r = await s |
||||
|
.invokeFeature(DeviceFeatures.settingsQuery) |
||||
|
.timeout(_queryTimeout); |
||||
|
if (!mounted) return; |
||||
|
_applySnapshot(r); |
||||
|
} catch (e) { |
||||
|
Logger.w(_tag, 'settings.query 失败: $e'); |
||||
|
if (mounted) setState(() => _error = 'settingReadFailed'.tr); |
||||
|
} finally { |
||||
|
if (mounted) setState(() => _loading = false); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 发一条写命令。失败时由调用方决定怎么回滚(开关翻回 / 滑块由下一次快照覆盖)。 |
||||
|
Future<bool> _write(String key, Map<String, Object?> args) async { |
||||
|
final s = _session; |
||||
|
if (s == null) return false; |
||||
|
setState(() => _busy.add(key)); |
||||
|
try { |
||||
|
await s.invokeFeature(key, args).timeout(_queryTimeout); |
||||
|
return true; |
||||
|
} catch (e) { |
||||
|
Logger.w(_tag, '$key 写入失败: $e'); |
||||
|
if (mounted) { |
||||
|
Get.snackbar( |
||||
|
'settingApplyFailed'.tr, |
||||
|
e is DeviceException ? (e.message ?? e.code) : e.toString(), |
||||
|
snackPosition: SnackPosition.BOTTOM, |
||||
|
duration: const Duration(seconds: 2), |
||||
|
); |
||||
|
} |
||||
|
return false; |
||||
|
} finally { |
||||
|
if (mounted) setState(() => _busy.remove(key)); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// ---------------- 控件动作 ---------------- |
||||
|
|
||||
|
Future<void> _toggle(String itemKey, String featureKey, bool on) async { |
||||
|
// 乐观翻转 |
||||
|
setState(() => _items = { |
||||
|
..._items!, |
||||
|
itemKey: {'on': on}, |
||||
|
}); |
||||
|
final ok = await _write(featureKey, {'on': on}); |
||||
|
if (!ok && mounted) { |
||||
|
setState(() => _items = { |
||||
|
..._items!, |
||||
|
itemKey: {'on': !on}, |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
void _slide(String itemKey, String featureKey, int value, String argName, |
||||
|
{bool flush = false}) { |
||||
|
setState(() => _sliderLocal[itemKey] = value); |
||||
|
_debounce[itemKey]?.cancel(); |
||||
|
void send() { |
||||
|
_debounce.remove(itemKey); |
||||
|
unawaited(_write(featureKey, {argName: value})); |
||||
|
} |
||||
|
|
||||
|
if (flush) { |
||||
|
send(); |
||||
|
} else { |
||||
|
_debounce[itemKey] = Timer(_sliderDebounce, send); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
Future<void> _playRandomExpression() async { |
||||
|
final now = DateTime.now(); |
||||
|
if (now.difference(_lastExpressionTap) < const Duration(seconds: 1)) return; |
||||
|
_lastExpressionTap = now; |
||||
|
await _write(DeviceFeatures.settingsPlayRandomExpression, const {}); |
||||
|
} |
||||
|
|
||||
|
// ---------------- 渲染 ---------------- |
||||
|
|
||||
|
@override |
||||
|
Widget build(BuildContext context) { |
||||
|
final isDark = Theme.of(context).brightness == Brightness.dark; |
||||
|
final textColor = isDark ? Colors.white : const Color(0xFF1A1A2E); |
||||
|
final subColor = |
||||
|
isDark ? Colors.white.withValues(alpha: 0.5) : const Color(0xFF888888); |
||||
|
|
||||
|
return SafeArea( |
||||
|
top: false, |
||||
|
child: Padding( |
||||
|
padding: EdgeInsets.fromLTRB(20.w, 12.h, 20.w, 16.h), |
||||
|
child: Column( |
||||
|
mainAxisSize: MainAxisSize.min, |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Center( |
||||
|
child: Container( |
||||
|
width: 36.w, |
||||
|
height: 4.h, |
||||
|
decoration: BoxDecoration( |
||||
|
color: subColor.withValues(alpha: 0.4), |
||||
|
borderRadius: BorderRadius.circular(2.r), |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
SizedBox(height: 14.h), |
||||
|
_header(textColor, subColor), |
||||
|
SizedBox(height: 12.h), |
||||
|
if (_session == null) |
||||
|
_notConnected(textColor, subColor) |
||||
|
else if (_items == null) |
||||
|
_loading ? _skeleton(subColor) : _errorBox(textColor, subColor) |
||||
|
else |
||||
|
_controls(textColor, subColor), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
Widget _header(Color textColor, Color subColor) { |
||||
|
final parts = <String>[]; |
||||
|
if (_battery != null) parts.add('$_battery%'); |
||||
|
if (_firmware != null && _firmware!.isNotEmpty) parts.add('v$_firmware'); |
||||
|
return Row( |
||||
|
children: [ |
||||
|
Expanded( |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Text( |
||||
|
widget.device.devicename.isNotEmpty |
||||
|
? widget.device.devicename |
||||
|
: 'deviceSettingsTitle'.tr, |
||||
|
style: TextStyle( |
||||
|
fontSize: 17.sp, |
||||
|
fontWeight: FontWeight.w700, |
||||
|
color: textColor), |
||||
|
), |
||||
|
if (parts.isNotEmpty) |
||||
|
Padding( |
||||
|
padding: EdgeInsets.only(top: 2.h), |
||||
|
child: Text(parts.join(' · '), |
||||
|
style: TextStyle(fontSize: 12.sp, color: subColor)), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
if (_session != null) |
||||
|
IconButton( |
||||
|
tooltip: 'refresh'.tr, |
||||
|
onPressed: _loading ? null : _query, |
||||
|
icon: Icon(Icons.refresh, color: subColor, size: 20.sp), |
||||
|
), |
||||
|
], |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
Widget _notConnected(Color textColor, Color subColor) { |
||||
|
return Padding( |
||||
|
padding: EdgeInsets.symmetric(vertical: 24.h), |
||||
|
child: Column( |
||||
|
children: [ |
||||
|
Icon(Icons.bluetooth_disabled, size: 40.sp, color: subColor), |
||||
|
SizedBox(height: 10.h), |
||||
|
Text('deviceNotConnected'.tr, |
||||
|
style: TextStyle(fontSize: 14.sp, color: textColor)), |
||||
|
SizedBox(height: 14.h), |
||||
|
SizedBox( |
||||
|
height: 42.h, |
||||
|
child: ElevatedButton( |
||||
|
onPressed: () { |
||||
|
Get.back(); |
||||
|
Get.toNamed(Routes.eaicarScan); |
||||
|
}, |
||||
|
style: ElevatedButton.styleFrom( |
||||
|
backgroundColor: const Color(0xFF6366F1), |
||||
|
foregroundColor: Colors.white, |
||||
|
elevation: 0, |
||||
|
shape: RoundedRectangleBorder( |
||||
|
borderRadius: BorderRadius.circular(12.r)), |
||||
|
), |
||||
|
child: Text('goConnect'.tr, |
||||
|
style: TextStyle( |
||||
|
fontSize: 14.sp, fontWeight: FontWeight.w600)), |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
Widget _skeleton(Color subColor) { |
||||
|
Widget bar(double w) => Container( |
||||
|
width: w, |
||||
|
height: 14.h, |
||||
|
margin: EdgeInsets.symmetric(vertical: 12.h), |
||||
|
decoration: BoxDecoration( |
||||
|
color: subColor.withValues(alpha: 0.15), |
||||
|
borderRadius: BorderRadius.circular(6.r), |
||||
|
), |
||||
|
); |
||||
|
return Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [bar(140.w), bar(220.w), bar(180.w), bar(200.w)], |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
Widget _errorBox(Color textColor, Color subColor) { |
||||
|
return Padding( |
||||
|
padding: EdgeInsets.symmetric(vertical: 20.h), |
||||
|
child: Column( |
||||
|
children: [ |
||||
|
Text(_error ?? 'settingReadFailed'.tr, |
||||
|
style: TextStyle(fontSize: 14.sp, color: textColor)), |
||||
|
SizedBox(height: 10.h), |
||||
|
TextButton(onPressed: _query, child: Text('retry'.tr)), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
/// 固定顺序:启停 → 音量 → 亮度 → 行车感应 → 摇摆 → 随机表情频次 → 随机来一个。 |
||||
|
/// items 里没有的项不画。 |
||||
|
Widget _controls(Color textColor, Color subColor) { |
||||
|
final items = _items!; |
||||
|
final rows = <Widget>[]; |
||||
|
|
||||
|
void toggleRow(String itemKey, String featureKey, String titleKey, |
||||
|
IconData icon) { |
||||
|
final it = items[itemKey]; |
||||
|
if (it is! Map) return; |
||||
|
final on = it['on'] == true; |
||||
|
rows.add(_toggleTile( |
||||
|
icon: icon, |
||||
|
title: titleKey.tr, |
||||
|
value: on, |
||||
|
busy: _busy.contains(featureKey), |
||||
|
onChanged: (v) => _toggle(itemKey, featureKey, v), |
||||
|
textColor: textColor, |
||||
|
subColor: subColor, |
||||
|
)); |
||||
|
} |
||||
|
|
||||
|
void sliderRow(String itemKey, String featureKey, String titleKey, |
||||
|
IconData icon) { |
||||
|
final it = items[itemKey]; |
||||
|
if (it is! Map) return; |
||||
|
final min = (it['min'] as num?)?.toInt() ?? 0; |
||||
|
final max = (it['max'] as num?)?.toInt() ?? 10; |
||||
|
final cur = _sliderLocal[itemKey] ?? (it['value'] as num?)?.toInt() ?? 0; |
||||
|
rows.add(_sliderTile( |
||||
|
icon: icon, |
||||
|
title: titleKey.tr, |
||||
|
valueLabel: '$cur', |
||||
|
value: cur.clamp(min, max).toDouble(), |
||||
|
min: min.toDouble(), |
||||
|
max: max.toDouble(), |
||||
|
divisions: (max - min).clamp(1, 100), |
||||
|
onChanged: (v) => _slide(itemKey, featureKey, v.round(), 'value'), |
||||
|
onChangeEnd: (v) => |
||||
|
_slide(itemKey, featureKey, v.round(), 'value', flush: true), |
||||
|
textColor: textColor, |
||||
|
subColor: subColor, |
||||
|
)); |
||||
|
} |
||||
|
|
||||
|
toggleRow('power', DeviceFeatures.settingsPower, 'settingPower', |
||||
|
Icons.power_settings_new); |
||||
|
sliderRow('volume', DeviceFeatures.settingsVolume, 'settingVolume', |
||||
|
Icons.volume_up); |
||||
|
sliderRow('brightness', DeviceFeatures.settingsBrightness, |
||||
|
'settingBrightness', Icons.brightness_6); |
||||
|
toggleRow('driving_sense', DeviceFeatures.settingsDrivingSense, |
||||
|
'settingDrivingSense', Icons.screen_rotation); |
||||
|
toggleRow('swing', DeviceFeatures.settingsSwing, 'settingSwing', |
||||
|
Icons.settings_remote); |
||||
|
|
||||
|
final rei = items['random_expression_interval']; |
||||
|
if (rei is Map) { |
||||
|
final options = ((rei['options'] as List?) ?? const [20, 180]) |
||||
|
.map((e) => (e as num).toInt()) |
||||
|
.toList() |
||||
|
..sort(); |
||||
|
final cur = _sliderLocal['random_expression_interval'] ?? |
||||
|
(rei['value'] as num?)?.toInt() ?? |
||||
|
options.first; |
||||
|
// 用档位索引当滑块值,吸附天然成立;显示秒数 |
||||
|
var idx = options.indexOf(cur); |
||||
|
if (idx < 0) { |
||||
|
idx = 0; |
||||
|
for (var i = 0; i < options.length; i++) { |
||||
|
if ((options[i] - cur).abs() < (options[idx] - cur).abs()) idx = i; |
||||
|
} |
||||
|
} |
||||
|
rows.add(_sliderTile( |
||||
|
icon: Icons.emoji_emotions_outlined, |
||||
|
title: 'settingRandomExpressionInterval'.tr, |
||||
|
valueLabel: 'settingSeconds'.trParams({'n': '${options[idx]}'}), |
||||
|
value: idx.toDouble(), |
||||
|
min: 0, |
||||
|
max: (options.length - 1).toDouble(), |
||||
|
divisions: (options.length - 1).clamp(1, 100), |
||||
|
onChanged: (v) => _slide('random_expression_interval', |
||||
|
DeviceFeatures.settingsRandomExpressionInterval, |
||||
|
options[v.round()], 'seconds'), |
||||
|
onChangeEnd: (v) => _slide('random_expression_interval', |
||||
|
DeviceFeatures.settingsRandomExpressionInterval, |
||||
|
options[v.round()], 'seconds', |
||||
|
flush: true), |
||||
|
textColor: textColor, |
||||
|
subColor: subColor, |
||||
|
)); |
||||
|
} |
||||
|
|
||||
|
if (items['play_random_expression'] is Map) { |
||||
|
final busy = _busy.contains(DeviceFeatures.settingsPlayRandomExpression); |
||||
|
rows.add(Padding( |
||||
|
padding: EdgeInsets.only(top: 6.h), |
||||
|
child: SizedBox( |
||||
|
width: double.infinity, |
||||
|
height: 44.h, |
||||
|
child: OutlinedButton.icon( |
||||
|
onPressed: busy ? null : _playRandomExpression, |
||||
|
icon: Icon(Icons.casino_outlined, size: 18.sp), |
||||
|
label: Text('settingPlayRandomExpression'.tr, |
||||
|
style: |
||||
|
TextStyle(fontSize: 14.sp, fontWeight: FontWeight.w600)), |
||||
|
style: OutlinedButton.styleFrom( |
||||
|
foregroundColor: const Color(0xFF6366F1), |
||||
|
side: const BorderSide(color: Color(0xFF6366F1)), |
||||
|
shape: RoundedRectangleBorder( |
||||
|
borderRadius: BorderRadius.circular(12.r)), |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
)); |
||||
|
} |
||||
|
|
||||
|
if (rows.isEmpty) { |
||||
|
return Padding( |
||||
|
padding: EdgeInsets.symmetric(vertical: 20.h), |
||||
|
child: Center( |
||||
|
child: Text('settingNoItems'.tr, |
||||
|
style: TextStyle(fontSize: 14.sp, color: subColor)), |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
return Column(children: rows); |
||||
|
} |
||||
|
|
||||
|
Widget _toggleTile({ |
||||
|
required IconData icon, |
||||
|
required String title, |
||||
|
required bool value, |
||||
|
required bool busy, |
||||
|
required ValueChanged<bool> onChanged, |
||||
|
required Color textColor, |
||||
|
required Color subColor, |
||||
|
}) { |
||||
|
return Padding( |
||||
|
padding: EdgeInsets.symmetric(vertical: 6.h), |
||||
|
child: Row( |
||||
|
children: [ |
||||
|
Icon(icon, size: 20.sp, color: subColor), |
||||
|
SizedBox(width: 12.w), |
||||
|
Expanded( |
||||
|
child: Text(title, |
||||
|
style: TextStyle(fontSize: 15.sp, color: textColor)), |
||||
|
), |
||||
|
Switch.adaptive( |
||||
|
value: value, |
||||
|
onChanged: busy ? null : onChanged, |
||||
|
activeThumbColor: const Color(0xFF6366F1), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
Widget _sliderTile({ |
||||
|
required IconData icon, |
||||
|
required String title, |
||||
|
required String valueLabel, |
||||
|
required double value, |
||||
|
required double min, |
||||
|
required double max, |
||||
|
required int divisions, |
||||
|
required ValueChanged<double> onChanged, |
||||
|
required ValueChanged<double> onChangeEnd, |
||||
|
required Color textColor, |
||||
|
required Color subColor, |
||||
|
}) { |
||||
|
return Padding( |
||||
|
padding: EdgeInsets.symmetric(vertical: 4.h), |
||||
|
child: Column( |
||||
|
children: [ |
||||
|
Row( |
||||
|
children: [ |
||||
|
Icon(icon, size: 20.sp, color: subColor), |
||||
|
SizedBox(width: 12.w), |
||||
|
Expanded( |
||||
|
child: Text(title, |
||||
|
style: TextStyle(fontSize: 15.sp, color: textColor)), |
||||
|
), |
||||
|
Text(valueLabel, |
||||
|
style: TextStyle( |
||||
|
fontSize: 13.sp, |
||||
|
color: subColor, |
||||
|
fontFeatures: const [FontFeature.tabularFigures()])), |
||||
|
], |
||||
|
), |
||||
|
SliderTheme( |
||||
|
data: SliderTheme.of(context).copyWith( |
||||
|
activeTrackColor: const Color(0xFF6366F1), |
||||
|
thumbColor: const Color(0xFF6366F1), |
||||
|
overlayColor: const Color(0xFF6366F1).withValues(alpha: 0.12), |
||||
|
inactiveTrackColor: subColor.withValues(alpha: 0.2), |
||||
|
), |
||||
|
child: Slider( |
||||
|
value: value.clamp(min, max), |
||||
|
min: min, |
||||
|
max: max, |
||||
|
divisions: divisions, |
||||
|
onChanged: onChanged, |
||||
|
onChangeEnd: onChangeEnd, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,73 @@ |
|||||
|
package com.yunqiinnovation.azure_speech |
||||
|
|
||||
|
/** |
||||
|
* 24 kHz → 16 kHz 重采样(比 3:2),**跨块连续、带抗混叠低通**。 |
||||
|
* |
||||
|
* 2026-09-23 之前用的是逐块独立的线性插值:每块相位从 0 重算、没有滤波, |
||||
|
* 8 kHz 以上的内容折回带内,块与块之间还有微小跳变——通话翻译耳机里"偶尔杂音"的嫌疑之一。 |
||||
|
* |
||||
|
* 实现:零插值到 48 kHz → 64 阶窗函数 sinc 低通(截止 7.4 kHz,Hamming)→ 每 3 个取 1 个。 |
||||
|
* 只在 (k-j) 为偶数的抽头上做乘加(零插值的奇数位恒为 0),每个输出样本 32 次乘加。 |
||||
|
* 系数已含 ×2 的零插值增益。Python 复刻校验:1k/3k/6k 正弦幅度 0.98/0.98/0.93,频率不变。 |
||||
|
* |
||||
|
* 一个实例只服务一路(有状态:保留最后 L/2 个输入样本与 48k 网格上的相位)。 |
||||
|
*/ |
||||
|
class Resampler24kTo16k { |
||||
|
private val hist = ShortArray(HALF) // 上一块的尾巴(最近 HALF 个输入样本,旧→新) |
||||
|
private var histLen = 0 |
||||
|
private var k = 0L // 下一个输出对应的 48k 网格位置(全局) |
||||
|
private var consumed = 0L // 已进入 hist 之前的输入总样本数(全局索引基准) |
||||
|
|
||||
|
fun reset() { histLen = 0; k = 0L; consumed = 0L } |
||||
|
|
||||
|
/** 输入 16bit 小端 PCM @24k,返回 16bit 小端 PCM @16k。 */ |
||||
|
fun process(input: ByteArray): ByteArray { |
||||
|
val n = input.size / 2 |
||||
|
if (n == 0) return ByteArray(0) |
||||
|
// 拼成 [hist | new],下标 i 对应全局输入索引 base + i |
||||
|
val buf = ShortArray(histLen + n) |
||||
|
System.arraycopy(hist, 0, buf, 0, histLen) |
||||
|
var i = 0 |
||||
|
while (i < n) { |
||||
|
buf[histLen + i] = ((input[i * 2].toInt() and 0xFF) or (input[i * 2 + 1].toInt() shl 8)).toShort() |
||||
|
i++ |
||||
|
} |
||||
|
val base = consumed - histLen |
||||
|
val lastGlobal = consumed + n - 1 // 当前可用的最大全局输入索引 |
||||
|
val out = java.io.ByteArrayOutputStream(n * 2 * 2 / 3 + 4) |
||||
|
// 输出 m 对应 48k 网格 k=3m;需要输入索引 (k-j)/2,j∈[0,L),最大为 k/2(要求 ≤ lastGlobal) |
||||
|
while (k / 2 <= lastGlobal) { |
||||
|
var acc = 0.0 |
||||
|
var j = if (k % 2 == 0L) 0 else 1 // 让 (k-j) 为偶数 |
||||
|
while (j < TAPS) { |
||||
|
val idx = (k - j) / 2 - base |
||||
|
if (idx >= 0 && idx < buf.size) acc += H[j] * buf[idx.toInt()] |
||||
|
j += 2 |
||||
|
} |
||||
|
val v = acc.toInt().coerceIn(-32768, 32767) |
||||
|
out.write(v and 0xFF); out.write((v shr 8) and 0xFF) |
||||
|
k += 3 |
||||
|
} |
||||
|
// 留下最后 HALF 个输入样本供下一块的抽头引用 |
||||
|
val keep = minOf(HALF, buf.size) |
||||
|
System.arraycopy(buf, buf.size - keep, hist, 0, keep) |
||||
|
histLen = keep |
||||
|
consumed += n |
||||
|
return out.toByteArray() |
||||
|
} |
||||
|
|
||||
|
companion object { |
||||
|
private const val TAPS = 64 |
||||
|
private const val HALF = TAPS / 2 + 2 |
||||
|
private val H = doubleArrayOf( |
||||
|
-0.001272, -0.001643, -0.000571, 0.001391, 0.002686, 0.001673, -0.001674, -0.004812, |
||||
|
-0.004142, 0.001400, 0.007913, 0.008684, 0.000477, -0.011471, -0.015874, -0.005303, |
||||
|
0.014505, 0.026149, 0.014823, -0.015487, -0.040008, -0.031737, 0.011975, 0.058826, |
||||
|
0.062003, 0.001164, -0.088277, -0.126869, -0.043596, 0.165759, 0.419929, 0.593380, |
||||
|
0.593380, 0.419929, 0.165759, -0.043596, -0.126869, -0.088277, 0.001164, 0.062003, |
||||
|
0.058826, 0.011975, -0.031737, -0.040008, -0.015487, 0.014823, 0.026149, 0.014505, |
||||
|
-0.005303, -0.015874, -0.011471, 0.000477, 0.008684, 0.007913, 0.001400, -0.004142, |
||||
|
-0.004812, -0.001674, 0.001673, 0.002686, 0.001391, -0.000571, -0.001643, -0.001272, |
||||
|
) |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,67 @@ |
|||||
|
import Foundation |
||||
|
|
||||
|
/// 24 kHz → 16 kHz 重采样(比 3:2),**跨块连续、带抗混叠低通**。Android `Resampler24kTo16k.kt` 的对应实现。 |
||||
|
/// |
||||
|
/// 2026-09-23 之前用的是逐块独立的线性插值:每块相位从 0 重算、没有滤波, |
||||
|
/// 8 kHz 以上的内容折回带内,块与块之间还有微小跳变——通话翻译耳机里"偶尔杂音"的嫌疑之一。 |
||||
|
/// |
||||
|
/// 实现:零插值到 48 kHz → 64 阶窗函数 sinc 低通(截止 7.4 kHz,Hamming)→ 每 3 个取 1 个。 |
||||
|
/// 只在 (k-j) 为偶数的抽头上做乘加,每个输出样本 32 次乘加。系数已含 ×2 的零插值增益。 |
||||
|
/// 一个实例只服务一路(有状态)。 |
||||
|
final class Resampler24kTo16k { |
||||
|
private static let taps = 64 |
||||
|
private static let half = taps / 2 + 2 |
||||
|
private static let h: [Double] = [ |
||||
|
-0.001272, -0.001643, -0.000571, 0.001391, 0.002686, 0.001673, -0.001674, -0.004812, |
||||
|
-0.004142, 0.001400, 0.007913, 0.008684, 0.000477, -0.011471, -0.015874, -0.005303, |
||||
|
0.014505, 0.026149, 0.014823, -0.015487, -0.040008, -0.031737, 0.011975, 0.058826, |
||||
|
0.062003, 0.001164, -0.088277, -0.126869, -0.043596, 0.165759, 0.419929, 0.593380, |
||||
|
0.593380, 0.419929, 0.165759, -0.043596, -0.126869, -0.088277, 0.001164, 0.062003, |
||||
|
0.058826, 0.011975, -0.031737, -0.040008, -0.015487, 0.014823, 0.026149, 0.014505, |
||||
|
-0.005303, -0.015874, -0.011471, 0.000477, 0.008684, 0.007913, 0.001400, -0.004142, |
||||
|
-0.004812, -0.001674, 0.001673, 0.002686, 0.001391, -0.000571, -0.001643, -0.001272, |
||||
|
] |
||||
|
|
||||
|
private var hist = [Int16](repeating: 0, count: Resampler24kTo16k.half) |
||||
|
private var histLen = 0 |
||||
|
private var k: Int64 = 0 // 下一个输出对应的 48k 网格位置(全局) |
||||
|
private var consumed: Int64 = 0 // 已进入 hist 之前的输入总样本数(全局索引基准) |
||||
|
|
||||
|
func reset() { histLen = 0; k = 0; consumed = 0 } |
||||
|
|
||||
|
/// 输入 16bit 小端 PCM @24k,返回 16bit 小端 PCM @16k。 |
||||
|
func process(_ input: Data) -> Data { |
||||
|
let n = input.count / 2 |
||||
|
if n == 0 { return Data() } |
||||
|
var buf = [Int16](repeating: 0, count: histLen + n) |
||||
|
for i in 0..<histLen { buf[i] = hist[i] } |
||||
|
input.withUnsafeBytes { (raw: UnsafeRawBufferPointer) in |
||||
|
for i in 0..<n { |
||||
|
let v = UInt16(raw[i * 2]) | (UInt16(raw[i * 2 + 1]) << 8) |
||||
|
buf[histLen + i] = Int16(bitPattern: v) |
||||
|
} |
||||
|
} |
||||
|
let base = consumed - Int64(histLen) |
||||
|
let lastGlobal = consumed + Int64(n) - 1 |
||||
|
var out = [Int16]() |
||||
|
out.reserveCapacity(n * 2 / 3 + 2) |
||||
|
let h = Resampler24kTo16k.h |
||||
|
let taps = Resampler24kTo16k.taps |
||||
|
while k / 2 <= lastGlobal { |
||||
|
var acc = 0.0 |
||||
|
var j = (k % 2 == 0) ? 0 : 1 // 让 (k-j) 为偶数 |
||||
|
while j < taps { |
||||
|
let idx = (k - Int64(j)) / 2 - base |
||||
|
if idx >= 0 && idx < Int64(buf.count) { acc += h[j] * Double(buf[Int(idx)]) } |
||||
|
j += 2 |
||||
|
} |
||||
|
out.append(Int16(clamping: Int(acc))) |
||||
|
k += 3 |
||||
|
} |
||||
|
let keep = min(Resampler24kTo16k.half, buf.count) |
||||
|
for i in 0..<keep { hist[i] = buf[buf.count - keep + i] } |
||||
|
histLen = keep |
||||
|
consumed += Int64(n) |
||||
|
return out.withUnsafeBufferPointer { Data(buffer: $0) } |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,137 @@ |
|||||
|
import 'package:device_plugin_interface/device_plugin_interface.dart'; |
||||
|
import 'package:eaimar/devices/eaicar/eaicar_device_plugin.dart'; |
||||
|
import 'package:eaimar/devices/eaicar/eaicar_protocol.dart'; |
||||
|
import 'package:flutter_test/flutter_test.dart'; |
||||
|
|
||||
|
/// 设备设置面板的 `settings.*` feature key → 发给 EaiCar 的字节,**冻结**。 |
||||
|
/// |
||||
|
/// 字节来自 lumi 真正发出的命令(`ble_service/.../BleService.kt`),不是协议文档: |
||||
|
/// 文档的 CRC 写法是错的、0x15 应答字段也不全。改一个字节等于改协议,先上真机验。 |
||||
|
void main() { |
||||
|
List<int> frame(String key, Map<String, Object?> args) { |
||||
|
final c = EaiCarSession.settingsCommand(key, args, |
||||
|
random: (max) => 0x08 /* 固定挑"跳舞",只为字节可复现 */)!; |
||||
|
return EaiCarProtocol.buildPacket(c.cmd, c.payload); |
||||
|
} |
||||
|
|
||||
|
List<int> withCrc(List<int> body) => |
||||
|
[...body, EaiCarProtocol.crc8Maxim(body)]; |
||||
|
|
||||
|
group('settings.* → 字节', () { |
||||
|
test('六条写命令逐条固定', () { |
||||
|
expect(frame(DeviceFeatures.settingsVolume, {'value': 5}), |
||||
|
withCrc([0xAA, 0x0B, 0x01, 0x05])); |
||||
|
expect(frame(DeviceFeatures.settingsBrightness, {'value': 10}), |
||||
|
withCrc([0xAA, 0x0A, 0x01, 0x0A])); |
||||
|
expect(frame(DeviceFeatures.settingsPower, {'on': true}), |
||||
|
withCrc([0xAA, 0x0D, 0x01, 0x01])); |
||||
|
expect(frame(DeviceFeatures.settingsPower, {'on': false}), |
||||
|
withCrc([0xAA, 0x0D, 0x01, 0x00])); |
||||
|
expect(frame(DeviceFeatures.settingsDrivingSense, {'on': true}), |
||||
|
withCrc([0xAA, 0x11, 0x01, 0x01])); |
||||
|
expect(frame(DeviceFeatures.settingsSwing, {'on': false}), |
||||
|
withCrc([0xAA, 0x14, 0x01, 0x00])); |
||||
|
// 频次是秒数原值,不是档位索引(lumi settings_view.dart:1517) |
||||
|
expect( |
||||
|
frame(DeviceFeatures.settingsRandomExpressionInterval, |
||||
|
{'seconds': 60}), |
||||
|
withCrc([0xAA, 0x13, 0x01, 0x3C])); |
||||
|
}); |
||||
|
|
||||
|
test('随机表情走 0x06、单向不等应答,id 落在 0x00~0x09', () { |
||||
|
final c = EaiCarSession.settingsCommand( |
||||
|
DeviceFeatures.settingsPlayRandomExpression, const {}, |
||||
|
random: (max) => max - 1)!; |
||||
|
expect(c.cmd, EaiCarProtocol.cmdControlEmotion); |
||||
|
expect(c.expectResponse, isFalse); |
||||
|
expect(c.payload, [0x09]); |
||||
|
for (var i = 0; i < 50; i++) { |
||||
|
final r = EaiCarSession.settingsCommand( |
||||
|
DeviceFeatures.settingsPlayRandomExpression, const {})!; |
||||
|
expect(r.payload.single, inInclusiveRange(0, 9)); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
test('其余写命令都等应答(设备会回 0x64 成功码)', () { |
||||
|
for (final k in [ |
||||
|
DeviceFeatures.settingsVolume, |
||||
|
DeviceFeatures.settingsBrightness, |
||||
|
]) { |
||||
|
expect(EaiCarSession.settingsCommand(k, {'value': 1})!.expectResponse, |
||||
|
isTrue); |
||||
|
} |
||||
|
for (final k in [ |
||||
|
DeviceFeatures.settingsPower, |
||||
|
DeviceFeatures.settingsDrivingSense, |
||||
|
DeviceFeatures.settingsSwing, |
||||
|
]) { |
||||
|
expect(EaiCarSession.settingsCommand(k, {'on': true})!.expectResponse, |
||||
|
isTrue); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
test('参数越界 / 缺参抛 invalid_argument,不会发出去', () { |
||||
|
void bad(String key, Map<String, Object?> args) { |
||||
|
expect( |
||||
|
() => EaiCarSession.settingsCommand(key, args), |
||||
|
throwsA(isA<DeviceException>() |
||||
|
.having((e) => e.code, 'code', DeviceErrorCode.invalidArgument)), |
||||
|
reason: '$key $args', |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
bad(DeviceFeatures.settingsVolume, {'value': 11}); |
||||
|
bad(DeviceFeatures.settingsVolume, {'value': -1}); |
||||
|
bad(DeviceFeatures.settingsVolume, {}); |
||||
|
bad(DeviceFeatures.settingsBrightness, {'value': 16}); |
||||
|
bad(DeviceFeatures.settingsPower, {'on': 1}); |
||||
|
bad(DeviceFeatures.settingsSwing, {}); |
||||
|
// 45 秒不在 9 档里 |
||||
|
bad(DeviceFeatures.settingsRandomExpressionInterval, {'seconds': 45}); |
||||
|
}); |
||||
|
|
||||
|
test('不认识的键返回 null(由 invokeFeature 抛 not_supported)', () { |
||||
|
expect(EaiCarSession.settingsCommand('settings.nope', const {}), isNull); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
group('0x15 → settings.query 返回值', () { |
||||
|
test('14 字节逐项映射到通用键名', () { |
||||
|
// 与 eaicar_protocol_test 里同一条样本:音量6/频次30/亮度8/电量80/1.2.3/ |
||||
|
// 启停开/摇头风格3/音乐表情开/陀螺仪关/电机开 |
||||
|
final s = EaiCarAllSettings.parse( |
||||
|
[0x00, 0x06, 0x1E, 0x08, 0x50, 0x01, 0x02, 0x03, 0x01, 0x03, 0x01, 0x00, 0x00, 0x01])!; |
||||
|
final m = s.toSettingsMap(); |
||||
|
final items = m['items'] as Map; |
||||
|
expect(items['volume'], {'value': 6, 'min': 0, 'max': 10}); |
||||
|
expect(items['brightness'], {'value': 8, 'min': 0, 'max': 10}); |
||||
|
expect((items['random_expression_interval'] as Map)['value'], 30); |
||||
|
expect((items['random_expression_interval'] as Map)['options'], |
||||
|
EaiCarProtocol.randomExpressionIntervalOptions); |
||||
|
expect(items['power'], {'on': true}); |
||||
|
expect(items['driving_sense'], {'on': false}); |
||||
|
expect(items['swing'], {'on': true}); |
||||
|
expect(items.containsKey('play_random_expression'), isTrue); |
||||
|
expect(m['battery'], 80); |
||||
|
expect(m['firmware'], '1.2.3'); |
||||
|
expect(m['otaAbnormal'], false); |
||||
|
}); |
||||
|
|
||||
|
test('copyWith 只改指定项(写成功后的本地回填靠它)', () { |
||||
|
final s = EaiCarAllSettings.parse( |
||||
|
[0x01, 0x00, 0x14, 0x0A, 0x3C, 0x02, 0x0C, 0x0C, 0x00, 0x02, 0x01, 0x01, 0x04, 0x01])!; |
||||
|
final t = s.copyWith(volume: 7, gyroscope: false); |
||||
|
expect(t.volume, 7); |
||||
|
expect(t.gyroscope, isFalse); |
||||
|
expect(t.brightness, s.brightness); |
||||
|
expect(t.battery, s.battery); |
||||
|
expect(t.firmwareVersion, s.firmwareVersion); |
||||
|
expect(t.motor, s.motor); |
||||
|
expect(t.otaAbnormal, s.otaAbnormal); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
test('EaiCar 会话声明了 settingsPanel(面板按它出现)', () { |
||||
|
expect(EaiCarSession.caps.contains(DeviceCapability.settingsPanel), isTrue); |
||||
|
}); |
||||
|
} |
||||
@ -1,31 +0,0 @@ |
|||||
package api |
|
||||
|
|
||||
import ( |
|
||||
"yunyan/comm" |
|
||||
"yunyan/pb" |
|
||||
"yunyan/sys/tencentyun/cos" |
|
||||
) |
|
||||
|
|
||||
// 获取COS临时上传凭证(前端直传用)
|
|
||||
func (this *apiComp) GetCosToken(session comm.IUserSession, req *pb.ApiGetCosTokenReq) (resp *pb.ApiGetCosTokenResp, errdata *pb.ErrorData) { |
|
||||
result, err := cos.Sts() |
|
||||
if err != nil { |
|
||||
errdata = &pb.ErrorData{ |
|
||||
Code: pb.ErrorCode_SystemError, |
|
||||
Message: "获取上传凭证失败: " + err.Error(), |
|
||||
} |
|
||||
return |
|
||||
} |
|
||||
config := cos.GetConfig() |
|
||||
resp = &pb.ApiGetCosTokenResp{ |
|
||||
Bucket: config.BucketName, |
|
||||
Region: config.Region, |
|
||||
BucketUrl: config.BucketURL, |
|
||||
TmpSecretId: result.Credentials.TmpSecretID, |
|
||||
TmpSecretKey: result.Credentials.TmpSecretKey, |
|
||||
SessionToken: result.Credentials.SessionToken, |
|
||||
Expiration: result.Expiration, |
|
||||
Domain: config.DomainName, |
|
||||
} |
|
||||
return |
|
||||
} |
|
||||
@ -0,0 +1,54 @@ |
|||||
|
package console |
||||
|
|
||||
|
import "testing" |
||||
|
|
||||
|
// 分发桶是公共读、直接挂在 dl.ymaikj.com 上的,key 拼错就等于把它变成公开网盘,
|
||||
|
// 所以每条边界都钉在测试里。
|
||||
|
func TestBuildDistObjectKey(t *testing.T) { |
||||
|
ok := map[string][3]string{ |
||||
|
"正常": {"EAIMAR", "android", "eaimar-release-1.0.2-20260923.apk"}, |
||||
|
"目录带斜杠": {"EAIMAR", "/android/", "a.apk"}, |
||||
|
"文件名夹带目录": {"EAIMAR", "android", "../../etc/passwd"}, |
||||
|
"另一个应用": {"deepGlass", "android", "a.apk"}, |
||||
|
"应用名带斜杠": {"a/../b", "android", "a.apk"}, |
||||
|
} |
||||
|
want := map[string]string{ |
||||
|
"正常": "android/EAIMAR/eaimar-release-1.0.2-20260923.apk", |
||||
|
"目录带斜杠": "android/EAIMAR/a.apk", |
||||
|
"文件名夹带目录": "android/EAIMAR/passwd", |
||||
|
"另一个应用": "android/deepGlass/a.apk", |
||||
|
"应用名带斜杠": "android/a____b/a.apk", |
||||
|
} |
||||
|
for name, in := range ok { |
||||
|
got, err := buildDistObjectKey(in[0], in[1], in[2]) |
||||
|
if err != nil { |
||||
|
t.Errorf("%s: 本该通过,却报错 %v", name, err) |
||||
|
continue |
||||
|
} |
||||
|
if got != want[name] { |
||||
|
t.Errorf("%s: 期望 %q,得到 %q", name, want[name], got) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
bad := map[string][3]string{ |
||||
|
"没选应用": {"", "android", "a.apk"}, |
||||
|
"应用名全是非法字符": {"//", "android", "a.apk"}, |
||||
|
"目录为空": {"EAIMAR", "", "a.apk"}, |
||||
|
"目录不在白名单": {"EAIMAR", "anything", "a.apk"}, |
||||
|
"用 .. 跳出去": {"EAIMAR", "android/../secret", "a.apk"}, |
||||
|
"绝对路径跳根": {"EAIMAR", "/../../", "a.apk"}, |
||||
|
"文件名为空": {"EAIMAR", "android", " "}, |
||||
|
} |
||||
|
for name, in := range bad { |
||||
|
if got, err := buildDistObjectKey(in[0], in[1], in[2]); err == nil { |
||||
|
t.Errorf("%s: 本该被拒,却得到 %q", name, got) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// apk 的 Content-Type 必须由服务端强制:它计入预签名,签错了要等真机装包才发现。
|
||||
|
func TestDistContentTypeForced(t *testing.T) { |
||||
|
if distContentTypes[".apk"] != "application/vnd.android.package-archive" { |
||||
|
t.Fatalf("apk 的 Content-Type 不对: %q", distContentTypes[".apk"]) |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,199 @@ |
|||||
|
package echomeet |
||||
|
|
||||
|
import ( |
||||
|
"crypto/hmac" |
||||
|
"crypto/sha256" |
||||
|
"encoding/base64" |
||||
|
"encoding/json" |
||||
|
"errors" |
||||
|
"os" |
||||
|
"strings" |
||||
|
"time" |
||||
|
|
||||
|
"yunyan/comm" |
||||
|
"yunyan/pb" |
||||
|
) |
||||
|
|
||||
|
/* |
||||
|
会议纪要分享:服务端签发的只读票据。 |
||||
|
|
||||
|
⚠️ 2026-09-23 之前,客户端把**用户的登录 JWT**(GetStorage 里的 logintoken)直接拼进 |
||||
|
分享 URL 的最后一段。谁拿到那条链接就拿到了整个账号——能调任何业务接口、看全部纪要、 |
||||
|
消费算力,而且除了改密码没有任何失效手段。链接一旦转发进群或被截图就收不回来。 |
||||
|
|
||||
|
现在换成票据:一条票据只对应**一条记录**、只能读、有有效期,且不含任何可用于其它接口的凭据。 |
||||
|
|
||||
|
票据格式(自包含,不落库): |
||||
|
<payload>.<sig> |
||||
|
payload = base64url(JSON{"i":记录id,"u":owner uid,"e":到期unix秒}) |
||||
|
sig = base64url(HMAC-SHA256(shareKey, payload)) |
||||
|
|
||||
|
为什么不落库:分享是读多写少、量小且可失效期自然收敛的场景,落库要多一张表 + 清理任务, |
||||
|
而票据本身已经带了全部信息。代价是**签发后无法单独撤销**(只能等过期或换密钥), |
||||
|
这是刻意接受的取舍——真要撤销能力时再补一张吊销表即可,票据格式不用变。 |
||||
|
*/ |
||||
|
|
||||
|
const ( |
||||
|
// shareTicketDefaultDays 不指定时的有效期。
|
||||
|
shareTicketDefaultDays = 7 |
||||
|
// shareTicketMaxDays 有效期上限。链接是公开可转发的,无限期等于永久泄露。
|
||||
|
shareTicketMaxDays = 30 |
||||
|
// shareKeyContext 密钥派生的域分隔串。换它等于让全部存量票据立刻失效。
|
||||
|
shareKeyContext = "echomeet.share.v1" |
||||
|
) |
||||
|
|
||||
|
var errShareTicketInvalid = errors.New("分享链接无效或已过期") |
||||
|
|
||||
|
// sharePayload 票据载荷。字段名压到一个字母:它要进 URL,越短越不容易被中间环节截断。
|
||||
|
type sharePayload struct { |
||||
|
Id uint64 `json:"i"` |
||||
|
Uid string `json:"u"` |
||||
|
Expire int64 `json:"e"` |
||||
|
} |
||||
|
|
||||
|
// shareKey 从 FIELD_ENCRYPT_KEY 派生分享票据的签名密钥。
|
||||
|
//
|
||||
|
// 为什么派生而不是直接用:FIELD_ENCRYPT_KEY 是 svc_config 里第三方凭据的加密密钥,
|
||||
|
// 拿它直接签票据会让两个用途共用同一份秘密。HMAC 是单向的,从派生key 推不回原 key。
|
||||
|
//
|
||||
|
// 为什么选 FIELD_ENCRYPT_KEY 而不是新加一个配置项:它已经在每台机的 .env 里
|
||||
|
// (console 与各业务服务本就必须配同一把),新增配置项意味着上线时漏配一处就整个功能不可用,
|
||||
|
// 而且漏配的表现是「分享按钮点了报错」这种要到真机才发现的问题。
|
||||
|
func shareKey() ([]byte, error) { |
||||
|
base := strings.TrimSpace(os.Getenv("FIELD_ENCRYPT_KEY")) |
||||
|
if base == "" { |
||||
|
// 刻意不退化成空密钥签名:那样任何人都能自己造票据读任意记录。
|
||||
|
return nil, errors.New("缺少 FIELD_ENCRYPT_KEY,无法签发分享票据") |
||||
|
} |
||||
|
m := hmac.New(sha256.New, []byte(base)) |
||||
|
m.Write([]byte(shareKeyContext)) |
||||
|
return m.Sum(nil), nil |
||||
|
} |
||||
|
|
||||
|
// signShareTicket 签发票据。
|
||||
|
func signShareTicket(p sharePayload) (string, error) { |
||||
|
key, err := shareKey() |
||||
|
if err != nil { |
||||
|
return "", err |
||||
|
} |
||||
|
raw, err := json.Marshal(p) |
||||
|
if err != nil { |
||||
|
return "", err |
||||
|
} |
||||
|
body := base64.RawURLEncoding.EncodeToString(raw) |
||||
|
m := hmac.New(sha256.New, key) |
||||
|
m.Write([]byte(body)) |
||||
|
return body + "." + base64.RawURLEncoding.EncodeToString(m.Sum(nil)), nil |
||||
|
} |
||||
|
|
||||
|
// parseShareTicket 校验并解出票据载荷。
|
||||
|
//
|
||||
|
// ⚠️ 「签名不对」「格式不对」「已过期」一律回同一个 errShareTicketInvalid。
|
||||
|
// 分开回等于告诉攻击者「这条票据是真的、只是过期了」,同 recordErr 的理由。
|
||||
|
func parseShareTicket(ticket string) (*sharePayload, error) { |
||||
|
ticket = strings.TrimSpace(ticket) |
||||
|
i := strings.LastIndex(ticket, ".") |
||||
|
if i <= 0 || i == len(ticket)-1 { |
||||
|
return nil, errShareTicketInvalid |
||||
|
} |
||||
|
body, sig := ticket[:i], ticket[i+1:] |
||||
|
key, err := shareKey() |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
m := hmac.New(sha256.New, key) |
||||
|
m.Write([]byte(body)) |
||||
|
want := m.Sum(nil) |
||||
|
got, err := base64.RawURLEncoding.DecodeString(sig) |
||||
|
// hmac.Equal 是常数时间比较:用 == 比会让攻击者靠计时逐字节试出正确签名。
|
||||
|
if err != nil || !hmac.Equal(want, got) { |
||||
|
return nil, errShareTicketInvalid |
||||
|
} |
||||
|
raw, err := base64.RawURLEncoding.DecodeString(body) |
||||
|
if err != nil { |
||||
|
return nil, errShareTicketInvalid |
||||
|
} |
||||
|
p := &sharePayload{} |
||||
|
if err = json.Unmarshal(raw, p); err != nil { |
||||
|
return nil, errShareTicketInvalid |
||||
|
} |
||||
|
if p.Id == 0 || p.Uid == "" || p.Expire <= 0 || time.Now().Unix() > p.Expire { |
||||
|
return nil, errShareTicketInvalid |
||||
|
} |
||||
|
return p, nil |
||||
|
} |
||||
|
|
||||
|
// @Summary 创建会议纪要分享链接
|
||||
|
// @Description 为自己的一条会议记录签发只读分享票据,客户端拼成落地页 URL
|
||||
|
// @Tags Echomeet
|
||||
|
// @Accept json
|
||||
|
// @Produce json
|
||||
|
// @Security BearerAuth
|
||||
|
// @Param user body pb.EchomeetCreateShareLinkReq true "请求数据"
|
||||
|
// @Success 200 {object} comm.HttpResult{data=pb.EchomeetCreateShareLinkResp} "响应数据"
|
||||
|
// @Router /api/home/echomeet_createsharelink [post]
|
||||
|
func (this *apiComp) CreateShareLink(session comm.IUserSession, req *pb.EchomeetCreateShareLinkReq) (resp *pb.EchomeetCreateShareLinkResp, errdata *pb.ErrorData) { |
||||
|
uid, errdata := requireUID(session) |
||||
|
if errdata != nil { |
||||
|
return |
||||
|
} |
||||
|
// 先确认这条记录确实是本人的:签发前不校验的话,改个 id 就能给别人的纪要签出公开链接。
|
||||
|
if _, err := this.module.model.getrecordforuid(uid, req.Id); err != nil { |
||||
|
errdata = recordErr(err) |
||||
|
return |
||||
|
} |
||||
|
days := int(req.Expiredays) |
||||
|
if days <= 0 { |
||||
|
days = shareTicketDefaultDays |
||||
|
} |
||||
|
if days > shareTicketMaxDays { |
||||
|
days = shareTicketMaxDays |
||||
|
} |
||||
|
expire := time.Now().AddDate(0, 0, days).Unix() |
||||
|
ticket, err := signShareTicket(sharePayload{Id: req.Id, Uid: uid, Expire: expire}) |
||||
|
if err != nil { |
||||
|
errdata = &pb.ErrorData{Code: pb.ErrorCode_SystemError, Message: err.Error()} |
||||
|
return |
||||
|
} |
||||
|
resp = &pb.EchomeetCreateShareLinkResp{Ticket: ticket, Expiretime: expire} |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
// @Summary 按分享票据读取会议纪要
|
||||
|
// @Description 落地页调用,**免登录**,须在 gateway 的 WhiteList 里
|
||||
|
// @Tags Echomeet
|
||||
|
// @Accept json
|
||||
|
// @Produce json
|
||||
|
// @Param user body pb.EchomeetGetSharedRecordReq true "请求数据"
|
||||
|
// @Success 200 {object} comm.HttpResult{data=pb.EchomeetGetSharedRecordResp} "响应数据"
|
||||
|
// @Router /api/home/echomeet_getsharedrecord [post]
|
||||
|
func (this *apiComp) GetSharedRecord(session comm.IUserSession, req *pb.EchomeetGetSharedRecordReq) (resp *pb.EchomeetGetSharedRecordResp, errdata *pb.ErrorData) { |
||||
|
// 注意:这里**不能**调 requireUID —— 本接口的全部意义就是让没登录的人也能打开链接。
|
||||
|
p, err := parseShareTicket(req.Ticket) |
||||
|
if err != nil { |
||||
|
errdata = &pb.ErrorData{Code: pb.ErrorCode_StateInvalid, Message: err.Error()} |
||||
|
return |
||||
|
} |
||||
|
// 仍按 (uid, id) 查:票据签发时绑定了 owner,记录易主或被删后票据自然失效。
|
||||
|
model, err := this.module.model.getrecordforuid(p.Uid, p.Id) |
||||
|
if err != nil { |
||||
|
// 记录已删时同样回「链接无效」,不区分——链接是公开的,不该成为探测记录存在性的入口。
|
||||
|
errdata = &pb.ErrorData{Code: pb.ErrorCode_StateInvalid, Message: errShareTicketInvalid.Error()} |
||||
|
return |
||||
|
} |
||||
|
resp = &pb.EchomeetGetSharedRecordResp{ |
||||
|
Id: model.Id, |
||||
|
Title: model.Title, |
||||
|
Creationtime: model.Creationtime, |
||||
|
Seconds: model.Seconds, |
||||
|
Audiourl: model.Audiourl, |
||||
|
Original: model.Original, |
||||
|
Translate: model.Translate, |
||||
|
Overview: model.Overview, |
||||
|
Summary: model.Summary, |
||||
|
Formlanguage: model.Formlanguage, |
||||
|
Tolanguage: model.Tolanguage, |
||||
|
Expiretime: p.Expire, |
||||
|
} |
||||
|
return |
||||
|
} |
||||
@ -0,0 +1,107 @@ |
|||||
|
package echomeet |
||||
|
|
||||
|
import ( |
||||
|
"encoding/base64" |
||||
|
"encoding/json" |
||||
|
"os" |
||||
|
"strings" |
||||
|
"testing" |
||||
|
"time" |
||||
|
) |
||||
|
|
||||
|
// 票据是公开链接里的唯一凭据,下面每条都是「错了就等于任何人能读任意纪要」级别的性质,
|
||||
|
// 所以全部钉死在测试里。
|
||||
|
func TestShareTicketRoundTrip(t *testing.T) { |
||||
|
os.Setenv("FIELD_ENCRYPT_KEY", "test-field-key-0123456789") |
||||
|
defer os.Unsetenv("FIELD_ENCRYPT_KEY") |
||||
|
|
||||
|
exp := time.Now().Add(time.Hour).Unix() |
||||
|
ticket, err := signShareTicket(sharePayload{Id: 42, Uid: "1234567890", Expire: exp}) |
||||
|
if err != nil { |
||||
|
t.Fatalf("签发失败: %v", err) |
||||
|
} |
||||
|
got, err := parseShareTicket(ticket) |
||||
|
if err != nil { |
||||
|
t.Fatalf("校验失败: %v", err) |
||||
|
} |
||||
|
if got.Id != 42 || got.Uid != "1234567890" || got.Expire != exp { |
||||
|
t.Fatalf("载荷不一致: %+v", got) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func TestShareTicketRejects(t *testing.T) { |
||||
|
os.Setenv("FIELD_ENCRYPT_KEY", "test-field-key-0123456789") |
||||
|
defer os.Unsetenv("FIELD_ENCRYPT_KEY") |
||||
|
|
||||
|
good, _ := signShareTicket(sharePayload{Id: 42, Uid: "u1", Expire: time.Now().Add(time.Hour).Unix()}) |
||||
|
body := good[:strings.LastIndex(good, ".")] |
||||
|
|
||||
|
// 改载荷不改签名:这是最直接的攻击——把 id 换成别人的记录。
|
||||
|
tampered := base64.RawURLEncoding.EncodeToString([]byte(`{"i":43,"u":"u1","e":9999999999}`)) |
||||
|
cases := map[string]string{ |
||||
|
"空串": "", |
||||
|
"没有分隔点": body, |
||||
|
"签名被截断": body + ".", |
||||
|
"签名乱填": body + ".AAAA", |
||||
|
"载荷被篡改": tampered + good[strings.LastIndex(good, "."):], |
||||
|
"整段不是base64": "!!!.@@@", |
||||
|
} |
||||
|
for name, tk := range cases { |
||||
|
if _, err := parseShareTicket(tk); err == nil { |
||||
|
t.Errorf("%s: 本该被拒,却通过了", name) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 过期票据必须拒绝:签名是对的,只有时间不对。
|
||||
|
expired, _ := signShareTicket(sharePayload{Id: 42, Uid: "u1", Expire: time.Now().Add(-time.Second).Unix()}) |
||||
|
if _, err := parseShareTicket(expired); err == nil { |
||||
|
t.Error("过期票据本该被拒") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 换密钥必须让旧票据全部失效——否则「换密钥」这个应急手段是假的。
|
||||
|
func TestShareTicketKeyIsolation(t *testing.T) { |
||||
|
os.Setenv("FIELD_ENCRYPT_KEY", "key-A") |
||||
|
ticket, err := signShareTicket(sharePayload{Id: 1, Uid: "u", Expire: time.Now().Add(time.Hour).Unix()}) |
||||
|
if err != nil { |
||||
|
t.Fatal(err) |
||||
|
} |
||||
|
os.Setenv("FIELD_ENCRYPT_KEY", "key-B") |
||||
|
defer os.Unsetenv("FIELD_ENCRYPT_KEY") |
||||
|
if _, err = parseShareTicket(ticket); err == nil { |
||||
|
t.Fatal("换密钥后旧票据仍然有效") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 没有 FIELD_ENCRYPT_KEY 时必须报错,绝不能退化成用空密钥签名(那样谁都能自己造票据)。
|
||||
|
func TestShareTicketRefusesEmptyKey(t *testing.T) { |
||||
|
old := os.Getenv("FIELD_ENCRYPT_KEY") |
||||
|
os.Unsetenv("FIELD_ENCRYPT_KEY") |
||||
|
defer os.Setenv("FIELD_ENCRYPT_KEY", old) |
||||
|
if _, err := signShareTicket(sharePayload{Id: 1, Uid: "u", Expire: 1}); err == nil { |
||||
|
t.Fatal("缺密钥时本该拒绝签发") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 票据里不该出现任何登录凭据的痕迹:它进 URL、会被转发、会进浏览器历史。
|
||||
|
func TestShareTicketCarriesOnlyScopedPayload(t *testing.T) { |
||||
|
os.Setenv("FIELD_ENCRYPT_KEY", "test-field-key-0123456789") |
||||
|
defer os.Unsetenv("FIELD_ENCRYPT_KEY") |
||||
|
ticket, _ := signShareTicket(sharePayload{Id: 7, Uid: "u7", Expire: time.Now().Add(time.Hour).Unix()}) |
||||
|
raw, err := base64.RawURLEncoding.DecodeString(ticket[:strings.LastIndex(ticket, ".")]) |
||||
|
if err != nil { |
||||
|
t.Fatal(err) |
||||
|
} |
||||
|
m := map[string]any{} |
||||
|
if err = json.Unmarshal(raw, &m); err != nil { |
||||
|
t.Fatal(err) |
||||
|
} |
||||
|
if len(m) != 3 { |
||||
|
t.Fatalf("载荷字段数变了(%d),新增字段前先确认它可以公开: %v", len(m), m) |
||||
|
} |
||||
|
for _, k := range []string{"i", "u", "e"} { |
||||
|
if _, ok := m[k]; !ok { |
||||
|
t.Fatalf("载荷缺字段 %s: %v", k, m) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,37 @@ |
|||||
|
package gateway |
||||
|
|
||||
|
import ( |
||||
|
_ "embed" |
||||
|
"net/http" |
||||
|
|
||||
|
"github.com/gin-gonic/gin" |
||||
|
) |
||||
|
|
||||
|
// 会议纪要分享落地页。
|
||||
|
//
|
||||
|
// 为什么嵌进二进制而不是当静态文件下发:
|
||||
|
// 这个页面必须跟着 echomeet_getsharedrecord 的协议走,两者版本对不上就是白页。
|
||||
|
// 嵌进来意味着「部署了新镜像 = 页面也是新的」,服务器上不需要维护任何文件,
|
||||
|
// prod-deploy.sh 也不用多下发一份(confs/*.yaml 那套人工维护的坑已经够多了)。
|
||||
|
//
|
||||
|
// 上线只剩一步:NPM 上把 share.ymaikj.com 反代到 ym-a11:7100(即本 gateway),申请证书。
|
||||
|
//
|
||||
|
//go:embed share_page.html
|
||||
|
var sharePageHTML []byte |
||||
|
|
||||
|
// sharePage 把落地页返回给任意 /share/... 路径。
|
||||
|
//
|
||||
|
// ⚠️ 必须是 catch-all:真实链接形如 /share/ym.ymaikj.com/ymaikj.com/42/<ticket>,
|
||||
|
// 路径里的段是**数据**不是文件路径。用 gin.Static 会去磁盘上找同名文件,一律 404。
|
||||
|
// 解析工作全在页面里的 JS 做(它要按 location.pathname 取 serverHost/id/ticket)。
|
||||
|
func (this *WSServiceComp) sharePage(c *gin.Context) { |
||||
|
// 不缓存:页面里写死了接口协议,缓存住旧版会在协议变更后变成白页,而用户
|
||||
|
// 完全没有手段让它刷新(分享链接是别人发来的,不会有人去硬刷)。
|
||||
|
c.Header("Cache-Control", "no-cache, must-revalidate") |
||||
|
// 落地页不该被别的站点嵌进 iframe 里套壳。
|
||||
|
c.Header("X-Frame-Options", "SAMEORIGIN") |
||||
|
c.Header("X-Content-Type-Options", "nosniff") |
||||
|
// 别把带票据的完整 URL 经 Referer 泄漏给第三方(音频在 OSS 上,是跨站请求)。
|
||||
|
c.Header("Referrer-Policy", "no-referrer") |
||||
|
c.Data(http.StatusOK, "text/html; charset=utf-8", sharePageHTML) |
||||
|
} |
||||
@ -0,0 +1,316 @@ |
|||||
|
<!DOCTYPE html> |
||||
|
<html lang="zh-CN"> |
||||
|
<head> |
||||
|
<meta charset="utf-8"> |
||||
|
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"> |
||||
|
<meta name="referrer" content="no-referrer"> |
||||
|
<title>会议纪要</title> |
||||
|
<style> |
||||
|
:root{ |
||||
|
--bg:#f5f6f8; --card:#fff; --fg:#1b1d21; --muted:#6b7280; --line:#e5e7eb; |
||||
|
--accent:#2563eb; --accent-soft:#eff4ff; --danger:#b42318; |
||||
|
} |
||||
|
@media (prefers-color-scheme:dark){ |
||||
|
:root{ |
||||
|
--bg:#101215; --card:#191c21; --fg:#e8eaed; --muted:#9aa1ab; --line:#2a2f37; |
||||
|
--accent:#6ea0ff; --accent-soft:#1b2436; --danger:#ff8a80; |
||||
|
} |
||||
|
} |
||||
|
*{box-sizing:border-box} |
||||
|
html,body{margin:0;padding:0} |
||||
|
body{ |
||||
|
background:var(--bg); color:var(--fg); |
||||
|
font:15px/1.65 -apple-system,BlinkMacSystemFont,"PingFang SC","Hiragino Sans GB","Microsoft YaHei",system-ui,sans-serif; |
||||
|
-webkit-text-size-adjust:100%; |
||||
|
} |
||||
|
.wrap{max-width:760px;margin:0 auto;padding:16px 16px 40px} |
||||
|
.card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:16px;margin-bottom:12px} |
||||
|
h1{font-size:19px;line-height:1.4;margin:0 0 8px;word-break:break-word} |
||||
|
.meta{color:var(--muted);font-size:13px;display:flex;flex-wrap:wrap;gap:4px 14px} |
||||
|
audio{width:100%;margin-top:14px} |
||||
|
|
||||
|
/* 标签栏:内容少时只显示有内容的那几个 */ |
||||
|
.tabs{display:flex;gap:6px;border-bottom:1px solid var(--line);margin:0 0 14px;overflow-x:auto;-webkit-overflow-scrolling:touch} |
||||
|
.tab{ |
||||
|
flex:0 0 auto;padding:9px 14px;border:0;background:none;color:var(--muted); |
||||
|
font-size:14px;font-family:inherit;cursor:pointer;border-bottom:2px solid transparent; |
||||
|
} |
||||
|
.tab[aria-selected="true"]{color:var(--accent);border-bottom-color:var(--accent);font-weight:600} |
||||
|
.panel[hidden]{display:none} |
||||
|
|
||||
|
/* 转写:每句一行,带说话人与时间戳 */ |
||||
|
.line{display:flex;gap:10px;padding:7px 0;border-bottom:1px solid var(--line)} |
||||
|
.line:last-child{border-bottom:0} |
||||
|
.line .who{flex:0 0 auto;min-width:74px;color:var(--accent);font-size:13px;font-weight:600} |
||||
|
.line .t{flex:0 0 auto;color:var(--muted);font-size:12px;font-variant-numeric:tabular-nums;padding-top:2px} |
||||
|
.line .txt{flex:1 1 auto;word-break:break-word} |
||||
|
|
||||
|
/* Markdown 渲染出来的总结 */ |
||||
|
.md h1,.md h2,.md h3,.md h4{line-height:1.4;margin:18px 0 8px} |
||||
|
.md h1{font-size:18px} .md h2{font-size:17px} .md h3{font-size:15px} .md h4{font-size:14px} |
||||
|
.md>:first-child{margin-top:0} |
||||
|
.md p{margin:8px 0} |
||||
|
.md ul,.md ol{margin:8px 0;padding-left:22px} |
||||
|
.md li{margin:3px 0} |
||||
|
.md code{background:var(--accent-soft);padding:1px 5px;border-radius:4px;font-size:13px} |
||||
|
.md pre{background:var(--accent-soft);padding:12px;border-radius:8px;overflow-x:auto} |
||||
|
.md pre code{background:none;padding:0} |
||||
|
.md blockquote{margin:8px 0;padding:2px 0 2px 12px;border-left:3px solid var(--line);color:var(--muted)} |
||||
|
.md hr{border:0;border-top:1px solid var(--line);margin:16px 0} |
||||
|
.md table{border-collapse:collapse;width:100%;margin:10px 0;font-size:14px;display:block;overflow-x:auto} |
||||
|
.md th,.md td{border:1px solid var(--line);padding:7px 10px;text-align:left} |
||||
|
.md th{background:var(--accent-soft);font-weight:600} |
||||
|
|
||||
|
.state{text-align:center;padding:52px 16px;color:var(--muted)} |
||||
|
.state strong{display:block;color:var(--fg);font-size:16px;margin-bottom:6px} |
||||
|
.state.err strong{color:var(--danger)} |
||||
|
.foot{text-align:center;color:var(--muted);font-size:12px;margin-top:18px;line-height:1.8} |
||||
|
.foot a{color:var(--accent);text-decoration:none} |
||||
|
.spin{width:22px;height:22px;margin:0 auto 12px;border:2px solid var(--line);border-top-color:var(--accent);border-radius:50%;animation:r .8s linear infinite} |
||||
|
@keyframes r{to{transform:rotate(360deg)}} |
||||
|
</style> |
||||
|
</head> |
||||
|
<body> |
||||
|
<div class="wrap" id="root"> |
||||
|
<div class="state"><div class="spin"></div>正在打开…</div> |
||||
|
</div> |
||||
|
|
||||
|
<script> |
||||
|
(function () { |
||||
|
'use strict'; |
||||
|
|
||||
|
// ⚠️ 后端域名白名单。serverHost 是从 URL 路径里读出来的,**不校验就等于**: |
||||
|
// 任何人构造一条 /share/<攻击者域名>/.../<票据> 的链接,就能让打开它的人的浏览器 |
||||
|
// 向攻击者的服务器发请求(顺带把票据送过去)。既是钓鱼跳板也是票据泄漏。 |
||||
|
// 新增环境要在这里加一行,否则那个环境分享出来的链接打不开。 |
||||
|
var ALLOWED_HOSTS = [ |
||||
|
'ym.ymaikj.com', // 正式 |
||||
|
'ym-dev.ymaikj.com' // 阿龙测试 |
||||
|
]; |
||||
|
|
||||
|
var DOWNLOAD_URL = 'https://dl.ymaikj.com'; // 页脚的 App 下载引导 |
||||
|
|
||||
|
var root = document.getElementById('root'); |
||||
|
|
||||
|
function esc(s) { |
||||
|
return String(s == null ? '' : s) |
||||
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') |
||||
|
.replace(/"/g, '"').replace(/'/g, '''); |
||||
|
} |
||||
|
|
||||
|
function show(html) { root.innerHTML = html; } |
||||
|
|
||||
|
function fail(title, detail) { |
||||
|
show('<div class="state err"><strong>' + esc(title) + '</strong>' + esc(detail || '') + '</div>' |
||||
|
+ foot('')); |
||||
|
} |
||||
|
|
||||
|
function foot(extra) { |
||||
|
return '<div class="foot">' + (extra ? extra + '<br>' : '') |
||||
|
+ '由 EAIMAR 生成 · <a href="' + DOWNLOAD_URL + '">下载 App</a></div>'; |
||||
|
} |
||||
|
|
||||
|
// ── 路径解析 ────────────────────────────────────────────── |
||||
|
// /share/{serverHost}/{brand}/{id}/{ticket} |
||||
|
// brand 段是品牌标识,落地页不使用,但保留在路径里以兼容已发出去的链接形状。 |
||||
|
// ⚠️ 票据是 base64url,本身不含 '/',所以按 '/' 切是安全的;真要变格式记得回来改这里。 |
||||
|
function parsePath() { |
||||
|
var seg = location.pathname.split('/').filter(Boolean); // ['share', host, brand, id, ticket] |
||||
|
if (seg.length < 5 || seg[0] !== 'share') return null; |
||||
|
return { host: seg[1], id: seg[3], ticket: seg.slice(4).join('/') }; |
||||
|
} |
||||
|
|
||||
|
// ── 极简 Markdown 渲染(总结是 Markdown,见客户端 _exportMarkdownPdf)──── |
||||
|
// 不引第三方库:这个页面要在国内手机上、可能很差的网络下秒开,为渲染几段 |
||||
|
// 标题和列表去拉一个 CDN 脚本不值得,而且 CDN 被墙时整页就废了。 |
||||
|
// ⚠️ 先 esc 再套正则,顺序不能反 —— 内容来自用户录音与模型输出,反了就是 XSS。 |
||||
|
function md(src) { |
||||
|
var lines = esc(src).replace(/\r\n?/g, '\n').split('\n'); |
||||
|
var out = [], i = 0; |
||||
|
|
||||
|
function inline(t) { |
||||
|
return t |
||||
|
.replace(/`([^`]+)`/g, '<code>$1</code>') |
||||
|
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>') |
||||
|
.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1<em>$2</em>'); |
||||
|
} |
||||
|
|
||||
|
while (i < lines.length) { |
||||
|
var ln = lines[i]; |
||||
|
|
||||
|
if (/^\s*$/.test(ln)) { i++; continue; } |
||||
|
|
||||
|
// 代码块 |
||||
|
if (/^```/.test(ln)) { |
||||
|
var buf = []; i++; |
||||
|
while (i < lines.length && !/^```/.test(lines[i])) { buf.push(lines[i]); i++; } |
||||
|
i++; |
||||
|
out.push('<pre><code>' + buf.join('\n') + '</code></pre>'); |
||||
|
continue; |
||||
|
} |
||||
|
// 标题 |
||||
|
var h = ln.match(/^(#{1,4})\s+(.*)$/); |
||||
|
if (h) { var lv = h[1].length; out.push('<h' + lv + '>' + inline(h[2]) + '</h' + lv + '>'); i++; continue; } |
||||
|
// 分隔线 |
||||
|
if (/^\s*([-*_])\s*\1\s*\1[\s\-*_]*$/.test(ln)) { out.push('<hr>'); i++; continue; } |
||||
|
// 表格:|a|b| 后跟 |---|---| |
||||
|
if (/^\s*\|/.test(ln) && i + 1 < lines.length && /^\s*\|[\s:|-]+\|\s*$/.test(lines[i + 1])) { |
||||
|
var cells = function (r) { |
||||
|
return r.trim().replace(/^\||\|$/g, '').split('|').map(function (c) { return inline(c.trim()); }); |
||||
|
}; |
||||
|
var head = cells(ln); i += 2; |
||||
|
var body = []; |
||||
|
while (i < lines.length && /^\s*\|/.test(lines[i])) { body.push(cells(lines[i])); i++; } |
||||
|
out.push('<table><thead><tr>' + head.map(function (c) { return '<th>' + c + '</th>'; }).join('') |
||||
|
+ '</tr></thead><tbody>' |
||||
|
+ body.map(function (r) { return '<tr>' + r.map(function (c) { return '<td>' + c + '</td>'; }).join('') + '</tr>'; }).join('') |
||||
|
+ '</tbody></table>'); |
||||
|
continue; |
||||
|
} |
||||
|
// 引用 |
||||
|
// ⚠️ 这里匹配的是 > 不是 >:上面已经整段 esc() 过了,原文的 '>' 此刻是 '>'。 |
||||
|
// 写成 /^\s*>/ 会永远匹配不上,引用块整个退化成普通段落(而且不报错)。 |
||||
|
if (/^\s*>\s?/.test(ln)) { |
||||
|
var q = []; |
||||
|
while (i < lines.length && /^\s*>\s?/.test(lines[i])) { q.push(lines[i].replace(/^\s*>\s?/, '')); i++; } |
||||
|
out.push('<blockquote>' + inline(q.join('<br>')) + '</blockquote>'); |
||||
|
continue; |
||||
|
} |
||||
|
// 列表(有序/无序) |
||||
|
var isOl = /^\s*\d+[.)]\s+/.test(ln); |
||||
|
if (isOl || /^\s*[-*+]\s+/.test(ln)) { |
||||
|
var items = []; |
||||
|
var re = isOl ? /^\s*\d+[.)]\s+/ : /^\s*[-*+]\s+/; |
||||
|
while (i < lines.length && re.test(lines[i])) { items.push(inline(lines[i].replace(re, ''))); i++; } |
||||
|
var tag = isOl ? 'ol' : 'ul'; |
||||
|
out.push('<' + tag + '>' + items.map(function (t) { return '<li>' + t + '</li>'; }).join('') + '</' + tag + '>'); |
||||
|
continue; |
||||
|
} |
||||
|
// 普通段落:连续非空行合成一段 |
||||
|
var p = []; |
||||
|
while (i < lines.length && !/^\s*$/.test(lines[i]) && !/^(#{1,4}\s|```|\s*[-*+]\s|\s*\d+[.)]\s|\s*>|\s*\|)/.test(lines[i])) { |
||||
|
p.push(lines[i]); i++; |
||||
|
} |
||||
|
if (p.length) out.push('<p>' + inline(p.join('<br>')) + '</p>'); |
||||
|
else i++; // 兜底,避免任何情况下的死循环 |
||||
|
} |
||||
|
return out.join(''); |
||||
|
} |
||||
|
|
||||
|
// ── 转写/译文:JSON 数组 [{speaker,content,starttime,endtime}] ──── |
||||
|
// ⚠️ 不能假定它一定是合法 JSON:老记录、导入的外部音频里躺着纯文本。 |
||||
|
// 解析失败就原样当文本显示,总比整页报错强。 |
||||
|
function renderLines(raw) { |
||||
|
if (!raw) return ''; |
||||
|
var arr = null; |
||||
|
try { arr = JSON.parse(raw); } catch (e) { /* 落到纯文本分支 */ } |
||||
|
if (!Array.isArray(arr)) return '<p>' + esc(raw).replace(/\n/g, '<br>') + '</p>'; |
||||
|
return arr.map(function (it) { |
||||
|
var who = it.speaker ? '<span class="who">' + esc(it.speaker) + '</span>' : ''; |
||||
|
var t = (typeof it.starttime === 'number' && it.starttime > 0) |
||||
|
? '<span class="t">' + hms(Math.floor(it.starttime / 1000)) + '</span>' : ''; |
||||
|
return '<div class="line">' + who + t + '<span class="txt">' + esc(it.content || '') + '</span></div>'; |
||||
|
}).join(''); |
||||
|
} |
||||
|
|
||||
|
function hms(sec) { |
||||
|
sec = Math.max(0, sec | 0); |
||||
|
var h = (sec / 3600) | 0, m = ((sec % 3600) / 60) | 0, s = sec % 60; |
||||
|
var p = function (n) { return n < 10 ? '0' + n : '' + n; }; |
||||
|
return (h ? h + ':' : '') + p(m) + ':' + p(s); |
||||
|
} |
||||
|
|
||||
|
function duration(sec) { |
||||
|
if (!sec) return ''; |
||||
|
var m = Math.floor(sec / 60), s = sec % 60; |
||||
|
return m ? (m + ' 分 ' + s + ' 秒') : (s + ' 秒'); |
||||
|
} |
||||
|
|
||||
|
function ymd(unix) { |
||||
|
if (!unix) return ''; |
||||
|
var d = new Date(unix * 1000), p = function (n) { return n < 10 ? '0' + n : n; }; |
||||
|
return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) |
||||
|
+ ' ' + p(d.getHours()) + ':' + p(d.getMinutes()); |
||||
|
} |
||||
|
|
||||
|
// ── 渲染 ─────────────────────────────────────────────────── |
||||
|
function render(r) { |
||||
|
document.title = r.title || '会议纪要'; |
||||
|
|
||||
|
var metas = []; |
||||
|
if (r.creationtime) metas.push('<span>' + esc(ymd(r.creationtime)) + '</span>'); |
||||
|
if (r.seconds) metas.push('<span>时长 ' + esc(duration(r.seconds)) + '</span>'); |
||||
|
|
||||
|
var head = '<div class="card"><h1>' + esc(r.title || '未命名会议') + '</h1>' |
||||
|
+ (metas.length ? '<div class="meta">' + metas.join('') + '</div>' : '') |
||||
|
+ (r.audiourl ? '<audio controls preload="none" src="' + esc(r.audiourl) + '"></audio>' : '') |
||||
|
+ '</div>'; |
||||
|
|
||||
|
// 只放有内容的标签页:空标签点进去一片白,比不显示更让人以为是坏了 |
||||
|
var tabs = []; |
||||
|
if (r.summary) tabs.push({ k: 'sum', n: '总结', h: '<div class="md">' + md(r.summary) + '</div>' }); |
||||
|
else if (r.overview) tabs.push({ k: 'sum', n: '总结', h: '<div class="md">' + md(r.overview) + '</div>' }); |
||||
|
if (r.original) tabs.push({ k: 'org', n: '转写', h: renderLines(r.original) }); |
||||
|
if (r.translate) tabs.push({ k: 'trs', n: '译文', h: renderLines(r.translate) }); |
||||
|
|
||||
|
var body; |
||||
|
if (!tabs.length) { |
||||
|
body = '<div class="card"><div class="state">这条纪要还没有内容<br><span style="font-size:13px">可能仍在转写中,稍后再打开试试</span></div></div>'; |
||||
|
} else { |
||||
|
body = '<div class="card">' |
||||
|
+ (tabs.length > 1 |
||||
|
? '<div class="tabs" role="tablist">' + tabs.map(function (t, i) { |
||||
|
return '<button class="tab" role="tab" data-k="' + t.k + '" aria-selected="' + (i === 0) + '">' + t.n + '</button>'; |
||||
|
}).join('') + '</div>' |
||||
|
: '') |
||||
|
+ tabs.map(function (t, i) { |
||||
|
return '<div class="panel" data-k="' + t.k + '"' + (i === 0 ? '' : ' hidden') + '>' + t.h + '</div>'; |
||||
|
}).join('') |
||||
|
+ '</div>'; |
||||
|
} |
||||
|
|
||||
|
var exp = r.expiretime ? ('本链接于 ' + esc(ymd(r.expiretime)) + ' 失效') : ''; |
||||
|
show(head + body + foot(exp)); |
||||
|
|
||||
|
Array.prototype.forEach.call(document.querySelectorAll('.tab'), function (btn) { |
||||
|
btn.addEventListener('click', function () { |
||||
|
Array.prototype.forEach.call(document.querySelectorAll('.tab'), function (b) { |
||||
|
b.setAttribute('aria-selected', b === btn); |
||||
|
}); |
||||
|
Array.prototype.forEach.call(document.querySelectorAll('.panel'), function (p) { |
||||
|
p.hidden = p.getAttribute('data-k') !== btn.getAttribute('data-k'); |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
// ── 主流程 ───────────────────────────────────────────────── |
||||
|
var q = parsePath(); |
||||
|
if (!q) { fail('链接不完整', '请确认复制了完整的分享链接'); return; } |
||||
|
if (ALLOWED_HOSTS.indexOf(q.host) < 0) { fail('链接无效', '来源不在允许列表中'); return; } |
||||
|
|
||||
|
var xhr = new XMLHttpRequest(); |
||||
|
xhr.open('POST', 'https://' + q.host + '/api/home/echomeet_getsharedrecord', true); |
||||
|
xhr.setRequestHeader('Content-Type', 'application/json'); |
||||
|
xhr.timeout = 20000; |
||||
|
xhr.onload = function () { |
||||
|
var res; |
||||
|
try { res = JSON.parse(xhr.responseText); } catch (e) { |
||||
|
fail('打开失败', '服务返回了无法识别的内容'); return; |
||||
|
} |
||||
|
// 业务码非 0 一律按「链接失效」呈现:服务端刻意不区分「票据错」「已过期」 |
||||
|
// 「记录已删」,这里再自作主张分类只会给出不准确的说法。 |
||||
|
// ⚠️ 错误消息的字段名是 msg 不是 message(comm.HttpResult 的 json tag)。 |
||||
|
if (!res || res.code !== 0 || !res.data) { |
||||
|
fail('链接已失效', '分享链接可能已过期,或已被创建者删除'); |
||||
|
return; |
||||
|
} |
||||
|
render(res.data); |
||||
|
}; |
||||
|
xhr.onerror = function () { fail('网络异常', '请检查网络后重试'); }; |
||||
|
xhr.ontimeout = function () { fail('网络超时', '请检查网络后重试'); }; |
||||
|
xhr.send(JSON.stringify({ ticket: q.ticket })); |
||||
|
})(); |
||||
|
</script> |
||||
|
</body> |
||||
|
</html> |
||||
@ -1,115 +0,0 @@ |
|||||
package mcp |
|
||||
|
|
||||
import ( |
|
||||
"context" |
|
||||
"yunyan/lego/core" |
|
||||
"yunyan/lego/core/cbase" |
|
||||
"yunyan/lego/sys/log" |
|
||||
"yunyan/sys/musicobj" |
|
||||
"yunyan/utils" |
|
||||
"fmt" |
|
||||
|
|
||||
"github.com/mark3labs/mcp-go/mcp" |
|
||||
) |
|
||||
|
|
||||
// 音乐工具(搜索+播放)
|
|
||||
type tool_music struct { |
|
||||
cbase.ModuleCompBase |
|
||||
module *Mcp |
|
||||
} |
|
||||
|
|
||||
// 组件初始化接口
|
|
||||
func (this *tool_music) Init(service core.IService, module core.IModule, comp core.IModuleComp, opt core.IModuleOptions) (err error) { |
|
||||
this.ModuleCompBase.Init(service, module, comp, opt) |
|
||||
this.module = module.(*Mcp) |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
func (this *tool_music) Start() (err error) { |
|
||||
err = this.ModuleCompBase.Start() |
|
||||
if this.module.AddTool(ToolGroup_CHINA, this.Tool(), this.Handl) { |
|
||||
if err := musicobj.OnInit(this.module.service.GetSettings().Sys["musicobj"]); err != nil { |
|
||||
panic(fmt.Sprintf("init sys.musicobj err: %s", err.Error())) |
|
||||
} else { |
|
||||
log.Infof("init sys.musicobj success!") |
|
||||
} |
|
||||
} |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
func (this *tool_music) Tool() mcp.Tool { |
|
||||
return mcp.NewTool("music_search_play", |
|
||||
mcp.WithDescription("搜索音乐并返回带播放链接的音乐列表"), |
|
||||
mcp.WithString("query", |
|
||||
mcp.Description("搜索的音乐关键字 例如:小苹果, 周杰伦, 张学友, 流行音乐等"), |
|
||||
mcp.Required(), |
|
||||
mcp.DefaultString("热门"), |
|
||||
), |
|
||||
mcp.WithNumber("limit", |
|
||||
mcp.Description("返回结果数量 (1-20, default 10)"), |
|
||||
mcp.DefaultNumber(30), |
|
||||
), |
|
||||
mcp.WithNumber("offset", |
|
||||
mcp.Description("搜索的音乐偏移量 范围在0到9之间 (默认0)"), |
|
||||
mcp.DefaultNumber(0), |
|
||||
), |
|
||||
) |
|
||||
} |
|
||||
|
|
||||
type MusicItem struct { |
|
||||
Id int64 `json:"id"` |
|
||||
Name string `json:"name"` |
|
||||
Singer string `json:"singer"` |
|
||||
Image string `json:"image"` |
|
||||
Url string `json:"url"` |
|
||||
} |
|
||||
|
|
||||
func (this *tool_music) Handl(ctx context.Context, request mcp.CallToolRequest) (result *mcp.CallToolResult, err error) { |
|
||||
var ( |
|
||||
query string |
|
||||
limit, offset int |
|
||||
songslist *musicobj.SearchResultResponse |
|
||||
) |
|
||||
if query, err = request.RequireString("query"); err != nil { |
|
||||
err = fmt.Errorf("query is required") |
|
||||
return |
|
||||
} |
|
||||
limit = request.GetInt("limit", 30) |
|
||||
offset = request.GetInt("offset", 0) |
|
||||
if songslist, err = musicobj.SearchMusic(query, limit, offset); err != nil { |
|
||||
this.module.Errorln(err) |
|
||||
return |
|
||||
} |
|
||||
if len(songslist.Data.Musics) == 0 { |
|
||||
err = fmt.Errorf("no music found") |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
musics := make([]*MusicItem, 0, len(songslist.Data.Musics)) |
|
||||
for _, v := range songslist.Data.Musics { |
|
||||
item := &MusicItem{ |
|
||||
Id: v.Id, |
|
||||
Name: v.Name, |
|
||||
Singer: v.Singer, |
|
||||
Image: v.Image, |
|
||||
} |
|
||||
if urlResp, urlErr := musicobj.MusicUrl(v.Id); urlErr == nil { |
|
||||
item.Url = urlResp.Data.URL |
|
||||
} |
|
||||
musics = append(musics, item) |
|
||||
} |
|
||||
|
|
||||
text := utils.ToString(map[string]interface{}{ |
|
||||
"data": musics, |
|
||||
"result": "success", |
|
||||
}) |
|
||||
result = &mcp.CallToolResult{ |
|
||||
Content: []mcp.Content{ |
|
||||
mcp.TextContent{ |
|
||||
Type: "text", |
|
||||
Text: text, |
|
||||
}, |
|
||||
}, |
|
||||
} |
|
||||
return |
|
||||
} |
|
||||
@ -1,27 +0,0 @@ |
|||||
package music |
|
||||
|
|
||||
import ( |
|
||||
"yunyan/lego/base" |
|
||||
"yunyan/lego/core" |
|
||||
"yunyan/modules" |
|
||||
) |
|
||||
|
|
||||
type apiComp struct { |
|
||||
modules.MCompHttpGate |
|
||||
service base.IRPCXService |
|
||||
module *Music |
|
||||
options *Options |
|
||||
} |
|
||||
|
|
||||
func (this *apiComp) Init(service core.IService, module core.IModule, comp core.IModuleComp, options core.IModuleOptions) (err error) { |
|
||||
this.MCompHttpGate.Init(service, module, comp, options) |
|
||||
this.service = service.(base.IRPCXService) |
|
||||
this.module = module.(*Music) |
|
||||
this.options = options.(*Options) |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
func (this *apiComp) Start() (err error) { |
|
||||
err = this.MCompHttpGate.Start() |
|
||||
return |
|
||||
} |
|
||||
@ -1,55 +0,0 @@ |
|||||
package music |
|
||||
|
|
||||
import ( |
|
||||
"yunyan/comm" |
|
||||
"yunyan/pb" |
|
||||
"yunyan/sys/musicobj" |
|
||||
"fmt" |
|
||||
) |
|
||||
|
|
||||
// @Summary 获取音乐播放地址
|
|
||||
// @Description 获取音乐播放地址
|
|
||||
// @Tags Music
|
|
||||
// @Accept json
|
|
||||
// @Produce json
|
|
||||
// @Security BearerAuth
|
|
||||
// @Param user body pb.MusicSearchReq true "请求数据"
|
|
||||
// @Success 200 {object} comm.HttpResult{data=pb.MusicSearchResp} "响应数据"
|
|
||||
// @Router /api/home/music_search [post]
|
|
||||
func (this *apiComp) Search(session comm.IUserSession, req *pb.MusicSearchReq) (resp *pb.MusicSearchResp, errdata *pb.ErrorData) { |
|
||||
var ( |
|
||||
musics []*pb.DBMusic |
|
||||
songslist *musicobj.SearchResultResponse |
|
||||
err error |
|
||||
) |
|
||||
|
|
||||
if songslist, err = musicobj.SearchMusic(req.Keywords, int(req.Limit), int(req.Offset)); err != nil { |
|
||||
errdata = &pb.ErrorData{ |
|
||||
Code: pb.ErrorCode_MusicSearchError, |
|
||||
Message: err.Error(), |
|
||||
} |
|
||||
return |
|
||||
} else { |
|
||||
if len(songslist.Data.Musics) == 0 { |
|
||||
errdata = &pb.ErrorData{ |
|
||||
Code: pb.ErrorCode_MusicNotFound, |
|
||||
Message: fmt.Sprintf("未找到关键字:%s 相关歌单", req.Keywords), |
|
||||
} |
|
||||
return |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
musics = make([]*pb.DBMusic, 0) |
|
||||
for _, v := range songslist.Data.Musics { |
|
||||
musiccard := &pb.DBMusic{Id: v.Id} |
|
||||
musiccard.Name = v.Name |
|
||||
musiccard.Singer = v.Singer |
|
||||
musiccard.Image = v.Image |
|
||||
musics = append(musics, musiccard) |
|
||||
} |
|
||||
|
|
||||
resp = &pb.MusicSearchResp{ |
|
||||
Musics: musics, |
|
||||
} |
|
||||
return |
|
||||
} |
|
||||
@ -1,38 +0,0 @@ |
|||||
package music |
|
||||
|
|
||||
import ( |
|
||||
"yunyan/comm" |
|
||||
"yunyan/lego/sys/log" |
|
||||
"yunyan/pb" |
|
||||
"yunyan/sys/musicobj" |
|
||||
) |
|
||||
|
|
||||
// @Summary 获取音乐播放地址
|
|
||||
// @Description 获取音乐播放地址
|
|
||||
// @Tags Music
|
|
||||
// @Accept json
|
|
||||
// @Produce json
|
|
||||
// @Security BearerAuth
|
|
||||
// @Param user body pb.MusicUrlReq true "请求数据"
|
|
||||
// @Success 200 {object} comm.HttpResult{data=pb.MusicUrlResp} "响应数据"
|
|
||||
// @Router /api/home/music_url [post]
|
|
||||
func (this *apiComp) Url(session comm.IUserSession, req *pb.MusicUrlReq) (resp *pb.MusicUrlResp, errdata *pb.ErrorData) { |
|
||||
var ( |
|
||||
result *musicobj.UrlResultResponse |
|
||||
err error |
|
||||
) |
|
||||
if result, err = musicobj.MusicUrl(req.Id); err != nil { |
|
||||
errdata = &pb.ErrorData{ |
|
||||
Code: pb.ErrorCode_MusicUrlError, |
|
||||
Message: err.Error(), |
|
||||
} |
|
||||
this.module.Error("GetMusicUrl Fail!", log.Field{Key: "uid", Value: session.GetUserId()}, log.Field{Key: "req", Value: req.String()}, log.Field{Key: "err", Value: err.Error()}) |
|
||||
return |
|
||||
} |
|
||||
resp = &pb.MusicUrlResp{ |
|
||||
Id: req.Id, |
|
||||
Url: result.Data.URL, |
|
||||
Ts: result.Data.Ts, |
|
||||
} |
|
||||
return |
|
||||
} |
|
||||
@ -1,26 +0,0 @@ |
|||||
package music |
|
||||
|
|
||||
import ( |
|
||||
"net/http" |
|
||||
|
|
||||
"github.com/gin-gonic/gin" |
|
||||
) |
|
||||
|
|
||||
// 设置跨域
|
|
||||
func cors() gin.HandlerFunc { |
|
||||
return func(c *gin.Context) { |
|
||||
method := c.Request.Method |
|
||||
origin := c.Request.Header.Get("Origin") |
|
||||
if origin != "" { |
|
||||
c.Header("Access-Control-Allow-Origin", "*") // 可将将 * 替换为指定的域名
|
|
||||
c.Header("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE, UPDATE") |
|
||||
c.Header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept, Authorization") |
|
||||
c.Header("Access-Control-Expose-Headers", "Content-Length, Access-Control-Allow-Origin, Access-Control-Allow-Headers, Cache-Control, Content-Language, Content-Type") |
|
||||
c.Header("Access-Control-Allow-Credentials", "true") |
|
||||
} |
|
||||
if method == "OPTIONS" { |
|
||||
c.AbortWithStatus(http.StatusNoContent) |
|
||||
} |
|
||||
c.Next() |
|
||||
} |
|
||||
} |
|
||||
@ -1,20 +0,0 @@ |
|||||
package music |
|
||||
|
|
||||
import ( |
|
||||
"yunyan/lego/core" |
|
||||
"yunyan/lego/core/cbase" |
|
||||
) |
|
||||
|
|
||||
// 代理模型
|
|
||||
type modelComp struct { |
|
||||
cbase.ModuleCompBase |
|
||||
module *Music |
|
||||
} |
|
||||
|
|
||||
// 组件初始化接口
|
|
||||
func (this *modelComp) Init(service core.IService, module core.IModule, comp core.IModuleComp, opt core.IModuleOptions) (err error) { |
|
||||
this.ModuleCompBase.Init(service, module, comp, opt) |
|
||||
this.module = module.(*Music) |
|
||||
|
|
||||
return |
|
||||
} |
|
||||
@ -1 +0,0 @@ |
|||||
package music_test |
|
||||
@ -1,49 +0,0 @@ |
|||||
package music |
|
||||
|
|
||||
import ( |
|
||||
"yunyan/comm" |
|
||||
"yunyan/modules" |
|
||||
|
|
||||
"yunyan/lego/core" |
|
||||
) |
|
||||
|
|
||||
func NewModule() core.IModule { |
|
||||
m := new(Music) |
|
||||
return m |
|
||||
} |
|
||||
|
|
||||
type Music struct { |
|
||||
modules.ModuleBase |
|
||||
api *apiComp |
|
||||
model *modelComp |
|
||||
options *Options |
|
||||
} |
|
||||
|
|
||||
func (this *Music) GetType() core.M_Modules { |
|
||||
return comm.ModuleMusic |
|
||||
} |
|
||||
|
|
||||
func (this *Music) NewOptions() (options core.IModuleOptions) { |
|
||||
return new(Options) |
|
||||
} |
|
||||
|
|
||||
func (this *Music) Init(service core.IService, module core.IModule, options core.IModuleOptions) (err error) { |
|
||||
this.options = options.(*Options) |
|
||||
if err = this.ModuleBase.Init(service, module, options); err != nil { |
|
||||
return |
|
||||
} |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
func (this *Music) Start() (err error) { |
|
||||
if err = this.ModuleBase.Start(); err != nil { |
|
||||
return |
|
||||
} |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
func (this *Music) OnInstallComp() { |
|
||||
this.ModuleBase.OnInstallComp() |
|
||||
this.api = this.RegisterComp(new(apiComp)).(*apiComp) |
|
||||
this.model = this.RegisterComp(new(modelComp)).(*modelComp) |
|
||||
} |
|
||||
@ -1,27 +0,0 @@ |
|||||
package music |
|
||||
|
|
||||
import ( |
|
||||
"yunyan/modules" |
|
||||
|
|
||||
"yunyan/lego/utils/mapstructure" |
|
||||
) |
|
||||
|
|
||||
type ( |
|
||||
Options struct { |
|
||||
modules.Options |
|
||||
GinDebug bool //web引擎日志开关
|
|
||||
ListenPort int //websocket 监听端口
|
|
||||
} |
|
||||
) |
|
||||
|
|
||||
func (this *Options) LoadConfig(settings map[string]interface{}) (err error) { |
|
||||
if settings != nil { |
|
||||
if err = this.Options.LoadConfig(settings); err != nil { |
|
||||
return |
|
||||
} |
|
||||
if err = mapstructure.Decode(settings, this); err != nil { |
|
||||
return |
|
||||
} |
|
||||
} |
|
||||
return |
|
||||
} |
|
||||
@ -1,623 +0,0 @@ |
|||||
package music |
|
||||
|
|
||||
type ( |
|
||||
|
|
||||
// MusicResponse 顶层响应结构
|
|
||||
SearchResultResponse struct { |
|
||||
Result struct { |
|
||||
SearchQcReminder interface{} `json:"searchQcReminder"` |
|
||||
Songs []struct { |
|
||||
Name string `json:"name"` |
|
||||
Id int64 `json:"id"` |
|
||||
Pst int `json:"pst"` |
|
||||
T int `json:"t"` |
|
||||
Ar []struct { // 内嵌艺术家结构
|
|
||||
Id int64 `json:"id"` |
|
||||
Name string `json:"name"` |
|
||||
Tns []string `json:"tns"` |
|
||||
Alias []string `json:"alias"` |
|
||||
Alia []string `json:"alia"` |
|
||||
} `json:"ar"` |
|
||||
Alia []string `json:"alia"` |
|
||||
Pop int `json:"pop"` |
|
||||
St int `json:"st"` |
|
||||
Rt string `json:"rt"` |
|
||||
Fee int `json:"fee"` |
|
||||
V int `json:"v"` |
|
||||
Crbt interface{} `json:"crbt"` |
|
||||
Cf string `json:"cf"` |
|
||||
Al struct { // 内嵌专辑结构
|
|
||||
Id int64 `json:"id"` |
|
||||
Name string `json:"name"` |
|
||||
PicUrl string `json:"picUrl"` |
|
||||
Tns []string `json:"tns"` |
|
||||
PicStr string `json:"pic_str"` |
|
||||
Pic int64 `json:"pic"` |
|
||||
} `json:"al"` |
|
||||
Dt int `json:"dt"` |
|
||||
H struct { // 内嵌高品质音频信息
|
|
||||
Br int `json:"br"` |
|
||||
Fid int `json:"fid"` |
|
||||
Size int `json:"size"` |
|
||||
Vd int `json:"vd"` |
|
||||
Sr int `json:"sr"` |
|
||||
} `json:"h"` |
|
||||
M struct { // 内嵌中品质音频信息
|
|
||||
Br int `json:"br"` |
|
||||
Fid int `json:"fid"` |
|
||||
Size int `json:"size"` |
|
||||
Vd int `json:"vd"` |
|
||||
Sr int `json:"sr"` |
|
||||
} `json:"m"` |
|
||||
L struct { // 内嵌低品质音频信息
|
|
||||
Br int `json:"br"` |
|
||||
Fid int `json:"fid"` |
|
||||
Size int `json:"size"` |
|
||||
Vd int `json:"vd"` |
|
||||
Sr int `json:"sr"` |
|
||||
} `json:"l"` |
|
||||
Sq struct { // 内嵌无损品质音频信息
|
|
||||
Br int `json:"br"` |
|
||||
Fid int `json:"fid"` |
|
||||
Size int `json:"size"` |
|
||||
Vd int `json:"vd"` |
|
||||
Sr int `json:"sr"` |
|
||||
} `json:"sq"` |
|
||||
Hr struct { // 内嵌高清品质音频信息
|
|
||||
Br int `json:"br"` |
|
||||
Fid int `json:"fid"` |
|
||||
Size int `json:"size"` |
|
||||
Vd int `json:"vd"` |
|
||||
Sr int `json:"sr"` |
|
||||
} `json:"hr"` |
|
||||
A interface{} `json:"a"` |
|
||||
Cd string `json:"cd"` |
|
||||
No int `json:"no"` |
|
||||
RtUrl interface{} `json:"rtUrl"` |
|
||||
Ftype int `json:"ftype"` |
|
||||
RtUrls []interface{} `json:"rtUrls"` |
|
||||
DjId int `json:"djId"` |
|
||||
Copyright int `json:"copyright"` |
|
||||
SId int `json:"s_id"` |
|
||||
Mark int64 `json:"mark"` |
|
||||
OriginCoverType int `json:"originCoverType"` |
|
||||
OriginSongSimpleData interface{} `json:"originSongSimpleData"` |
|
||||
TagPicList interface{} `json:"tagPicList"` |
|
||||
ResourceState bool `json:"resourceState"` |
|
||||
Version int `json:"version"` |
|
||||
SongJumpInfo interface{} `json:"songJumpInfo"` |
|
||||
EntertainmentTags interface{} `json:"entertainmentTags"` |
|
||||
Single int `json:"single"` |
|
||||
NoCopyrightRcmd interface{} `json:"noCopyrightRcmd"` |
|
||||
Rtype int `json:"rtype"` |
|
||||
Rurl interface{} `json:"rurl"` |
|
||||
Mst int `json:"mst"` |
|
||||
Cp int `json:"cp"` |
|
||||
Mv int `json:"mv"` |
|
||||
PublishTime int `json:"publishTime"` |
|
||||
Privilege struct { // 内嵌权限信息结构
|
|
||||
Id int64 `json:"id"` |
|
||||
Fee int `json:"fee"` |
|
||||
Payed int `json:"payed"` |
|
||||
St int `json:"st"` |
|
||||
Pl int `json:"pl"` |
|
||||
Dl int `json:"dl"` |
|
||||
Sp int `json:"sp"` |
|
||||
Cp int `json:"cp"` |
|
||||
Subp int `json:"subp"` |
|
||||
Cs bool `json:"cs"` |
|
||||
Maxbr int `json:"maxbr"` |
|
||||
Fl int `json:"fl"` |
|
||||
Toast bool `json:"toast"` |
|
||||
Flag int `json:"flag"` |
|
||||
PreSell bool `json:"preSell"` |
|
||||
PlayMaxbr int `json:"playMaxbr"` |
|
||||
DownloadMaxbr int `json:"downloadMaxbr"` |
|
||||
MaxBrLevel string `json:"maxBrLevel"` |
|
||||
PlayMaxBrLevel string `json:"playMaxBrLevel"` |
|
||||
DownloadMaxBrLevel string `json:"downloadMaxBrLevel"` |
|
||||
PlLevel string `json:"plLevel"` |
|
||||
DlLevel string `json:"dlLevel"` |
|
||||
FlLevel string `json:"flLevel"` |
|
||||
Rscl interface{} `json:"rscl"` |
|
||||
FreeTrialPrivilege struct { // 内嵌免费试用权限
|
|
||||
ResConsumable bool `json:"resConsumable"` |
|
||||
UserConsumable bool `json:"userConsumable"` |
|
||||
ListenType interface{} `json:"listenType"` |
|
||||
CannotListenReason interface{} `json:"cannotListenReason"` |
|
||||
PlayReason interface{} `json:"playReason"` |
|
||||
FreeLimitTagType interface{} `json:"freeLimitTagType"` |
|
||||
} `json:"freeTrialPrivilege"` |
|
||||
RightSource int `json:"rightSource"` |
|
||||
ChargeInfoList []struct { // 内嵌收费信息列表
|
|
||||
Rate int `json:"rate"` |
|
||||
ChargeUrl interface{} `json:"chargeUrl"` |
|
||||
ChargeMessage interface{} `json:"chargeMessage"` |
|
||||
ChargeType int `json:"chargeType"` |
|
||||
} `json:"chargeInfoList"` |
|
||||
Code int `json:"code"` |
|
||||
Message interface{} `json:"message"` |
|
||||
PlLevels interface{} `json:"plLevels"` |
|
||||
DlLevels interface{} `json:"dlLevels"` |
|
||||
IgnoreCache interface{} `json:"ignoreCache"` |
|
||||
Bd interface{} `json:"bd"` |
|
||||
} `json:"privilege"` |
|
||||
} `json:"songs"` |
|
||||
SongCount int `json:"songCount"` |
|
||||
} `json:"result"` |
|
||||
Code int `json:"code"` |
|
||||
} |
|
||||
|
|
||||
FreeTrialInfo struct { |
|
||||
ResConsumable bool `json:"resConsumable"` |
|
||||
UserConsumable bool `json:"userConsumable"` |
|
||||
ListenType interface{} `json:"listenType"` |
|
||||
CannotListenReason interface{} `json:"cannotListenReason"` |
|
||||
PlayReason interface{} `json:"playReason"` |
|
||||
FreeLimitTagType interface{} `json:"freeLimitTagType"` |
|
||||
} |
|
||||
|
|
||||
FreeTimeTrialPrivilege struct { |
|
||||
ResConsumable bool `json:"resConsumable"` |
|
||||
UserConsumable bool `json:"userConsumable"` |
|
||||
Type int `json:"type"` |
|
||||
RemainTime int `json:"remainTime"` |
|
||||
} |
|
||||
|
|
||||
UrlResult struct { |
|
||||
Data struct { |
|
||||
Size int `json:"size"` |
|
||||
Br int `json:"br"` |
|
||||
URL string `json:"url"` |
|
||||
MD5 string `json:"md5"` |
|
||||
Source string `json:"source"` |
|
||||
} `json:"data"` |
|
||||
Code int `json:"code"` |
|
||||
} |
|
||||
//歌单搜索
|
|
||||
PlaylistSearchResponse struct { |
|
||||
Code int `json:"code"` // 响应状态码
|
|
||||
|
|
||||
Result struct { |
|
||||
PlaylistCount int `json:"playlistCount"` // 歌单总数
|
|
||||
HasMore bool `json:"hasMore"` // 是否有更多数据
|
|
||||
HlWords []string `json:"hlWords"` // 高亮词(通常为搜索关键词)
|
|
||||
SearchQcReminder interface{} `json:"searchQcReminder"` // 提示信息(暂无结构)
|
|
||||
|
|
||||
Playlists []struct { |
|
||||
ID int64 `json:"id"` // 歌单 ID
|
|
||||
Name string `json:"name"` // 歌单名称
|
|
||||
CoverImgUrl string `json:"coverImgUrl"` // 歌单封面图
|
|
||||
Subscribed bool `json:"subscribed"` // 是否已收藏
|
|
||||
TrackCount int `json:"trackCount"` // 歌曲数量
|
|
||||
UserId int64 `json:"userId"` // 创建者用户 ID
|
|
||||
PlayCount int64 `json:"playCount"` // 播放次数
|
|
||||
BookCount int64 `json:"bookCount"` // 收藏次数
|
|
||||
SpecialType int `json:"specialType"` // 特殊标记(如官方歌单)
|
|
||||
Action string `json:"action"` // 播放链接
|
|
||||
ActionType string `json:"actionType"` // 动作类型
|
|
||||
RecommendText interface{} `json:"recommendText"` // 推荐文案(可能为空)
|
|
||||
Score string `json:"score"` // 推荐分数(字符串)
|
|
||||
Description string `json:"description"` // 歌单描述
|
|
||||
HighQuality bool `json:"highQuality"` // 是否高品质歌单
|
|
||||
OfficialTags []string `json:"officialTags"` // 官方标签
|
|
||||
|
|
||||
Creator struct { |
|
||||
Nickname string `json:"nickname"` // 创建者昵称
|
|
||||
UserId int64 `json:"userId"` // 用户 ID
|
|
||||
UserType int `json:"userType"` // 用户类型
|
|
||||
AvatarUrl string `json:"avatarUrl"` // 用户头像
|
|
||||
AuthStatus int `json:"authStatus"` // 认证状态
|
|
||||
ExpertTags interface{} `json:"expertTags"` // 专家标签(空或未知结构)
|
|
||||
Experts interface{} `json:"experts"` // 专家信息(空或未知结构)
|
|
||||
} `json:"creator"` // 创建者信息
|
|
||||
|
|
||||
Track struct { |
|
||||
Name string `json:"name"` // 歌曲名称
|
|
||||
ID int64 `json:"id"` // 歌曲 ID
|
|
||||
Position int `json:"position"` // 歌曲在歌单中的位置
|
|
||||
Alias []string `json:"alias"` // 别名
|
|
||||
Status int `json:"status"` // 状态
|
|
||||
Fee int `json:"fee"` // 费用
|
|
||||
CopyrightId int `json:"copyrightId"` // 版权 ID
|
|
||||
Disc string `json:"disc"` // 所属碟片
|
|
||||
No int `json:"no"` // 曲目编号
|
|
||||
|
|
||||
Artists []struct { |
|
||||
Name string `json:"name"` // 歌手名
|
|
||||
ID int64 `json:"id"` // 歌手 ID
|
|
||||
PicId int64 `json:"picId"` |
|
||||
Img1v1Id int64 `json:"img1v1Id"` |
|
||||
BriefDesc string `json:"briefDesc"` |
|
||||
PicUrl string `json:"picUrl"` |
|
||||
Img1v1Url string `json:"img1v1Url"` |
|
||||
AlbumSize int `json:"albumSize"` |
|
||||
Alias []string `json:"alias"` |
|
||||
Trans string `json:"trans"` |
|
||||
MusicSize int `json:"musicSize"` |
|
||||
TopicPerson int `json:"topicPerson"` |
|
||||
} `json:"artists"` // 歌手列表
|
|
||||
|
|
||||
Album struct { |
|
||||
Name string `json:"name"` // 专辑名称
|
|
||||
ID int64 `json:"id"` // 专辑 ID
|
|
||||
IdStr *string `json:"idStr"` // ID 字符串
|
|
||||
Type string `json:"type"` // 类型
|
|
||||
Size int `json:"size"` // 歌曲数
|
|
||||
PicId int64 `json:"picId"` // 封面图 ID
|
|
||||
BlurPicUrl string `json:"blurPicUrl"` // 模糊封面图
|
|
||||
CompanyId int `json:"companyId"` |
|
||||
Pic int64 `json:"pic"` |
|
||||
PicUrl string `json:"picUrl"` |
|
||||
PublishTime int64 `json:"publishTime"` // 发布时间
|
|
||||
Description string `json:"description"` // 描述
|
|
||||
Tags string `json:"tags"` // 标签
|
|
||||
Company string `json:"company"` // 出版公司
|
|
||||
BriefDesc string `json:"briefDesc"` // 简要描述
|
|
||||
|
|
||||
Artist struct { |
|
||||
Name string `json:"name"` |
|
||||
ID int64 `json:"id"` |
|
||||
PicId int64 `json:"picId"` |
|
||||
Img1v1Id int64 `json:"img1v1Id"` |
|
||||
BriefDesc string `json:"briefDesc"` |
|
||||
PicUrl string `json:"picUrl"` |
|
||||
Img1v1Url string `json:"img1v1Url"` |
|
||||
AlbumSize int `json:"albumSize"` |
|
||||
Alias []string `json:"alias"` |
|
||||
Trans string `json:"trans"` |
|
||||
MusicSize int `json:"musicSize"` |
|
||||
TopicPerson int `json:"topicPerson"` |
|
||||
} `json:"artist"` |
|
||||
|
|
||||
Songs []interface{} `json:"songs"` // 预留字段
|
|
||||
Alias []string `json:"alias"` |
|
||||
Status int `json:"status"` |
|
||||
CopyrightId int `json:"copyrightId"` |
|
||||
CommentThreadId string `json:"commentThreadId"` |
|
||||
Artists []struct { |
|
||||
Name string `json:"name"` |
|
||||
ID int64 `json:"id"` |
|
||||
PicId int64 `json:"picId"` |
|
||||
Img1v1Id int64 `json:"img1v1Id"` |
|
||||
BriefDesc string `json:"briefDesc"` |
|
||||
PicUrl string `json:"picUrl"` |
|
||||
Img1v1Url string `json:"img1v1Url"` |
|
||||
AlbumSize int `json:"albumSize"` |
|
||||
Alias []string `json:"alias"` |
|
||||
Trans string `json:"trans"` |
|
||||
MusicSize int `json:"musicSize"` |
|
||||
TopicPerson int `json:"topicPerson"` |
|
||||
} `json:"artists"` |
|
||||
|
|
||||
OnSale bool `json:"onSale"` |
|
||||
PicIdStr string `json:"picId_str"` |
|
||||
} `json:"album"` |
|
||||
|
|
||||
Starred bool `json:"starred"` |
|
||||
Popularity int `json:"popularity"` |
|
||||
Score int `json:"score"` |
|
||||
StarredNum int `json:"starredNum"` |
|
||||
Duration int `json:"duration"` // 时长(毫秒)
|
|
||||
PlayedNum int `json:"playedNum"` |
|
||||
DayPlays int `json:"dayPlays"` |
|
||||
HearTime int `json:"hearTime"` |
|
||||
Ringtone string `json:"ringtone"` |
|
||||
CRBT interface{} `json:"crbt"` |
|
||||
Audition interface{} `json:"audition"` |
|
||||
CopyFrom string `json:"copyFrom"` |
|
||||
CommentThreadId string `json:"commentThreadId"` |
|
||||
RtUrl interface{} `json:"rtUrl"` |
|
||||
Ftype int `json:"ftype"` |
|
||||
RtUrls []string `json:"rtUrls"` |
|
||||
Copyright int `json:"copyright"` |
|
||||
|
|
||||
HMusic struct { |
|
||||
Name *string `json:"name"` |
|
||||
ID int64 `json:"id"` |
|
||||
Size int `json:"size"` |
|
||||
Extension string `json:"extension"` |
|
||||
Sr int `json:"sr"` |
|
||||
DfsId int64 `json:"dfsId"` |
|
||||
Bitrate int `json:"bitrate"` |
|
||||
PlayTime int `json:"playTime"` |
|
||||
VolumeDelta int `json:"volumeDelta"` |
|
||||
} `json:"hMusic"` |
|
||||
|
|
||||
MMusic struct { |
|
||||
Name *string `json:"name"` |
|
||||
ID int64 `json:"id"` |
|
||||
Size int `json:"size"` |
|
||||
Extension string `json:"extension"` |
|
||||
Sr int `json:"sr"` |
|
||||
DfsId int64 `json:"dfsId"` |
|
||||
Bitrate int `json:"bitrate"` |
|
||||
PlayTime int `json:"playTime"` |
|
||||
VolumeDelta int `json:"volumeDelta"` |
|
||||
} `json:"mMusic"` |
|
||||
|
|
||||
LMusic struct { |
|
||||
Name *string `json:"name"` |
|
||||
ID int64 `json:"id"` |
|
||||
Size int `json:"size"` |
|
||||
Extension string `json:"extension"` |
|
||||
Sr int `json:"sr"` |
|
||||
DfsId int64 `json:"dfsId"` |
|
||||
Bitrate int `json:"bitrate"` |
|
||||
PlayTime int `json:"playTime"` |
|
||||
VolumeDelta int `json:"volumeDelta"` |
|
||||
} `json:"lMusic"` |
|
||||
|
|
||||
BMusic struct { |
|
||||
Name *string `json:"name"` |
|
||||
ID int64 `json:"id"` |
|
||||
Size int `json:"size"` |
|
||||
Extension string `json:"extension"` |
|
||||
Sr int `json:"sr"` |
|
||||
DfsId int64 `json:"dfsId"` |
|
||||
Bitrate int `json:"bitrate"` |
|
||||
PlayTime int `json:"playTime"` |
|
||||
VolumeDelta int `json:"volumeDelta"` |
|
||||
} `json:"bMusic"` |
|
||||
|
|
||||
Mvid int `json:"mvid"` |
|
||||
Rtype int `json:"rtype"` |
|
||||
Rurl interface{} `json:"rurl"` |
|
||||
Mp3Url interface{} `json:"mp3Url"` |
|
||||
} `json:"track"` |
|
||||
|
|
||||
Alg string `json:"alg"` // 推荐算法字段
|
|
||||
} `json:"playlists"` |
|
||||
} `json:"result"` |
|
||||
} |
|
||||
|
|
||||
SongResponse struct { |
|
||||
Songs []struct { |
|
||||
Name string `json:"name"` // 歌曲名称
|
|
||||
MainTitle *string `json:"mainTitle"` // 主标题
|
|
||||
AdditionalTitle *string `json:"additionalTitle"` // 附加标题
|
|
||||
ID int `json:"id"` // 歌曲 ID
|
|
||||
Pst int `json:"pst"` // 排序字段
|
|
||||
T int `json:"t"` // 类型字段
|
|
||||
Ar []struct { |
|
||||
ID int `json:"id"` // 歌手 ID
|
|
||||
Name string `json:"name"` // 歌手名
|
|
||||
Tns []string `json:"tns"` // 翻译名
|
|
||||
Alias []string `json:"alias "` // 别名
|
|
||||
} `json:"ar"` |
|
||||
Alia []string `json:"alia"` // 歌曲别名
|
|
||||
Pop int `json:"pop"` // 热度
|
|
||||
St int `json:"st"` // 状态码
|
|
||||
Rt string `json:"rt"` // 资源标识
|
|
||||
Fee int `json:"费用"` // 是否收费
|
|
||||
V int `json:"v "` // 版本
|
|
||||
Crbt *string `json:"crbt"` // 彩铃信息
|
|
||||
Cf string `json:"cf"` // 未知字段
|
|
||||
Al struct { |
|
||||
ID int `json:"id"` // 专辑 ID
|
|
||||
Name string `json:"name"` // 专辑名
|
|
||||
PicURL string `json:"picUrl"` // 专辑封面
|
|
||||
Tns []string `json:"tns"` // 翻译名
|
|
||||
PicStr string `json:"pic_str"` // 图片字符串 ID
|
|
||||
Pic int64 `json:"pic"` // 图片 ID
|
|
||||
} `json:"al"` |
|
||||
Dt int `json:"dt"` // 时长(毫秒)
|
|
||||
|
|
||||
H struct{ Br, Fid, Size, Vd, Sr int } `json:"h"` // 高音质
|
|
||||
M struct{ Br, Fid, Size, Vd, Sr int } `json:"m"` // 中音质
|
|
||||
L struct{ Br, Fid, Size, Vd, Sr int } `json:"l"` // 低音质
|
|
||||
Sq struct{ Br, Fid, Size, Vd, Sr int } `json:"sq"` // 无损音质
|
|
||||
|
|
||||
Hr interface{} `json:"hr"` |
|
||||
A *string `json:"a"` |
|
||||
Cd string `json:"cd"` |
|
||||
No int `json:"no"` |
|
||||
RtUrl *string `json:"rtUrl"` |
|
||||
Ftype int `json:"ftype"` |
|
||||
RtUrls []string `json:"rtUrls"` |
|
||||
DjId int `json:"djId"` |
|
||||
Copyright int `json:"copyright"` |
|
||||
SId int `json:"s_id"` |
|
||||
Mark int64 `json:"mark"` |
|
||||
OriginCoverType int `json:"originCoverType"` |
|
||||
Tns []string `json:"tns"` |
|
||||
OriginSongSimpleData interface{} `json:"originSongSimpleData"` |
|
||||
TagPicList *string `json:"tagPicList"` |
|
||||
ResourceState bool `json:"resourceState"` |
|
||||
Version int `json:"version"` |
|
||||
SongJumpInfo *string `json:"songJumpInfo"` |
|
||||
EntertainmentTags *string `json:"entertainmentTags"` |
|
||||
AwardTags *string `json:"awardTags"` |
|
||||
DisplayTags *string `json:"displayTags"` |
|
||||
Single int `json:"single"` |
|
||||
NoCopyrightRcmd *string `json:"noCopyrightRcmd"` |
|
||||
Mv int `json:"mv"` |
|
||||
Rtype int `json:"rtype"` |
|
||||
Rurl *string `json:"rurl"` |
|
||||
Mst int `json:"mst"` |
|
||||
Cp int `json:"cp"` |
|
||||
PublishTime int64 `json:"publishTime"` |
|
||||
} `json:"songs"` |
|
||||
|
|
||||
Privileges []struct { |
|
||||
ID int `json:"id"` |
|
||||
Fee int `json:"fee"` |
|
||||
Payed int `json:"payed"` |
|
||||
St int `json:"st"` |
|
||||
Pl int `json:"pl"` |
|
||||
Dl int `json:"dl"` |
|
||||
Sp int `json:"sp"` |
|
||||
Cp int `json:"cp"` |
|
||||
Subp int `json:"subp"` |
|
||||
Cs bool `json:"cs"` |
|
||||
Maxbr int `json:"maxbr"` |
|
||||
Fl int `json:"fl"` |
|
||||
Toast bool `json:"toast"` |
|
||||
Flag int `json:"flag"` |
|
||||
PreSell bool `json:"preSell"` |
|
||||
PlayMaxbr int `json:"playMaxbr"` |
|
||||
DownloadMaxbr int `json:"downloadMaxbr"` |
|
||||
MaxBrLevel string `json:"maxBrLevel"` |
|
||||
PlayMaxBrLevel string `json:"playMaxBrLevel"` |
|
||||
DownloadMaxBrLevel string `json:"downloadMaxBrLevel"` |
|
||||
PlLevel string `json:"plLevel"` |
|
||||
DlLevel string `json:"dlLevel"` |
|
||||
FlLevel string `json:"flLevel"` |
|
||||
Rscl interface{} `json:"rscl"` |
|
||||
FreeTrialPrivilege struct { |
|
||||
ResConsumable bool `json:"resConsumable"` |
|
||||
UserConsumable bool `json:"userConsumable"` |
|
||||
ListenType interface{} `json:"listenType"` |
|
||||
CannotListenReason interface{} `json:"cannotListenReason"` |
|
||||
PlayReason interface{} `json:"playReason"` |
|
||||
FreeLimitTagType interface{} `json:"freeLimitTagType"` |
|
||||
} `json:"freeTrialPrivilege"` |
|
||||
RightSource int `json:"rightSource"` |
|
||||
ChargeInfoList []struct { |
|
||||
Rate int `json:"rate"` |
|
||||
ChargeUrl interface{} `json:"chargeUrl"` |
|
||||
ChargeMessage interface{} `json:"chargeMessage"` |
|
||||
ChargeType int `json:"chargeType"` |
|
||||
} `json:"chargeInfoList"` |
|
||||
Code int `json:"code"` |
|
||||
Message *string `json:"message"` |
|
||||
} `json:"privileges"` |
|
||||
|
|
||||
Code int `json:"code"` // 响应状态码
|
|
||||
} |
|
||||
//播放列表详情回复
|
|
||||
MusicListDetailResponse struct { |
|
||||
Code int `json:"code"` // 响应状态码
|
|
||||
|
|
||||
Songs []struct { |
|
||||
Name string `json:"name"` // 歌曲名称
|
|
||||
MainTitle *string `json:"mainTitle"` // 主标题(可选)
|
|
||||
AdditionalTitle *string `json:"additionalTitle"` // 副标题(可选)
|
|
||||
ID int64 `json:"id"` // 歌曲 ID
|
|
||||
Pst int `json:"pst"` |
|
||||
T int `json:"t"` |
|
||||
|
|
||||
Ar []struct { |
|
||||
ID int64 `json:"id"` // 歌手 ID
|
|
||||
Name string `json:"name"` // 歌手名称
|
|
||||
Tns []string `json:"tns"` // 翻译名
|
|
||||
Alias []string `json:"alias"` // 别名
|
|
||||
} `json:"ar"` // 歌手列表
|
|
||||
|
|
||||
Al struct { |
|
||||
ID int64 `json:"id"` // 专辑 ID
|
|
||||
Name string `json:"name"` // 专辑名称
|
|
||||
PicURL string `json:"picUrl"` // 专辑封面 URL
|
|
||||
PicStr string `json:"pic_str"` // 封面图字符串
|
|
||||
Pic int64 `json:"pic"` // 封面图 ID
|
|
||||
Tns []string `json:"tns"` // 专辑翻译名
|
|
||||
} `json:"al"` // 专辑信息
|
|
||||
|
|
||||
Pop int `json:"pop"` // 热度
|
|
||||
|
|
||||
Fee int `json:"fee"` // 版权费用标记
|
|
||||
|
|
||||
DT int `json:"dt"` // 时长(单位:毫秒)
|
|
||||
|
|
||||
H struct { |
|
||||
Br int `json:"br"` // 码率
|
|
||||
Size int `json:"size"` // 文件大小(字节)
|
|
||||
Vd int `json:"vd"` // 音量动态
|
|
||||
Sr int `json:"sr"` // 采样率
|
|
||||
Fid int `json:"fid"` // 文件 ID
|
|
||||
} `json:"h"` // 高音质
|
|
||||
|
|
||||
M struct { |
|
||||
Br int `json:"br"` |
|
||||
Size int `json:"size"` |
|
||||
Vd int `json:"vd"` |
|
||||
Sr int `json:"sr"` |
|
||||
Fid int `json:"fid"` |
|
||||
} `json:"m"` // 中音质
|
|
||||
|
|
||||
L struct { |
|
||||
Br int `json:"br"` |
|
||||
Size int `json:"size"` |
|
||||
Vd int `json:"vd"` |
|
||||
Sr int `json:"sr"` |
|
||||
Fid int `json:"fid"` |
|
||||
} `json:"l"` // 低音质
|
|
||||
|
|
||||
SQ *struct { |
|
||||
Br int `json:"br"` |
|
||||
Size int `json:"size"` |
|
||||
Vd int `json:"vd"` |
|
||||
Sr int `json:"sr"` |
|
||||
Fid int `json:"fid"` |
|
||||
} `json:"sq,omitempty"` // 超高音质(可选)
|
|
||||
|
|
||||
MV int `json:"mv"` // MV ID
|
|
||||
PublishTime int64 `json:"publishTime"` // 发布时间(时间戳)
|
|
||||
Tns []string `json:"tns"` // 歌曲翻译名
|
|
||||
} `json:"songs"` |
|
||||
|
|
||||
Privileges []struct { |
|
||||
ID int64 `json:"id"` // 歌曲 ID
|
|
||||
Fee int `json:"fee"` // 收费类型
|
|
||||
Payed int `json:"payed"` // 是否已付费
|
|
||||
ST int `json:"st"` // 状态码
|
|
||||
Pl int `json:"pl"` // 播放权限
|
|
||||
Dl int `json:"dl"` // 下载权限
|
|
||||
Sp int `json:"sp"` // 播放标记
|
|
||||
Cp int `json:"cp"` // 复制权限
|
|
||||
Subp int `json:"subp"` // 订阅权限
|
|
||||
Maxbr int `json:"maxbr"` // 最大码率
|
|
||||
Flag int `json:"flag"` // 标记位
|
|
||||
Fl int `json:"fl"` // 免费播放标记
|
|
||||
|
|
||||
ChargeInfoList []struct { |
|
||||
Rate int `json:"rate"` // 码率
|
|
||||
ChargeType int `json:"chargeType"` // 收费类型
|
|
||||
ChargeUrl string `json:"chargeUrl"` // 收费链接(一般为空)
|
|
||||
ChargeMsg string `json:"chargeMessage"` // 提示信息(一般为空)
|
|
||||
} `json:"chargeInfoList"` // 收费信息列表
|
|
||||
|
|
||||
FreeTrialPrivilege struct { |
|
||||
ResConsumable bool `json:"resConsumable"` // 资源是否可消费
|
|
||||
UserConsumable bool `json:"userConsumable"` // 用户是否可试听
|
|
||||
ListenType interface{} `json:"listenType"` // 听歌类型(可空)
|
|
||||
CannotListenReason interface{} `json:"cannotListenReason"` // 无法播放原因(可空)
|
|
||||
} `json:"freeTrialPrivilege"` // 试听权限设置
|
|
||||
} `json:"privileges"` |
|
||||
} |
|
||||
|
|
||||
ISys interface { |
|
||||
SearchMusic(keywords string, limit, offset int) (result *SearchResultResponse, err error) |
|
||||
SearchMusicList(keywords string, limit, offset int) (result *PlaylistSearchResponse, err error) |
|
||||
MusicListDetail(id int64, limit, offset int) (result *MusicListDetailResponse, err error) |
|
||||
MusicUrl(id int64) (result *UrlResult, err error) |
|
||||
} |
|
||||
) |
|
||||
|
|
||||
var defsys ISys |
|
||||
|
|
||||
func OnInit(config map[string]interface{}, option ...Option) (err error) { |
|
||||
defsys, err = newSys(newOptions(config, option...)) |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
func NewSys(option ...Option) (sys ISys, err error) { |
|
||||
sys, err = newSys(newOptionsByOption(option...)) |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
func SearchMusic(keywords string, limit, offset int) (result *SearchResultResponse, err error) { |
|
||||
return defsys.SearchMusic(keywords, limit, offset) |
|
||||
} |
|
||||
|
|
||||
func SearchMusicList(keywords string, limit, offset int) (result *PlaylistSearchResponse, err error) { |
|
||||
return defsys.SearchMusicList(keywords, limit, offset) |
|
||||
} |
|
||||
|
|
||||
func MusicListDetail(id int64, limit, offset int) (result *MusicListDetailResponse, err error) { |
|
||||
return defsys.MusicListDetail(id, limit, offset) |
|
||||
} |
|
||||
|
|
||||
func MusicUrl(id int64) (result *UrlResult, err error) { |
|
||||
return defsys.MusicUrl(id) |
|
||||
} |
|
||||
@ -1,187 +0,0 @@ |
|||||
package music |
|
||||
|
|
||||
import ( |
|
||||
"encoding/json" |
|
||||
"fmt" |
|
||||
"io" |
|
||||
"net/http" |
|
||||
"net/url" |
|
||||
) |
|
||||
|
|
||||
func newSys(options Options) (sys *Music, err error) { |
|
||||
sys = &Music{ |
|
||||
options: options, |
|
||||
} |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
type Music struct { |
|
||||
options Options |
|
||||
} |
|
||||
|
|
||||
// 搜索歌曲
|
|
||||
func (this *Music) SearchMusic(keywords string, limit, offset int) (result *SearchResultResponse, err error) { |
|
||||
var ( |
|
||||
req *http.Request |
|
||||
resp *http.Response |
|
||||
body []byte |
|
||||
) |
|
||||
keywords = url.QueryEscape(keywords) |
|
||||
url := fmt.Sprintf("%s/cloudsearch?keywords=%s&limit=%d&offset=%d", this.options.ApiBaseUrl, keywords, limit, offset) |
|
||||
|
|
||||
req, err = http.NewRequest("GET", url, nil) |
|
||||
if err != nil { |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
// 执行请求
|
|
||||
client := &http.Client{} |
|
||||
resp, err = client.Do(req) |
|
||||
if err != nil { |
|
||||
return |
|
||||
} |
|
||||
defer resp.Body.Close() |
|
||||
if resp.StatusCode != http.StatusOK { |
|
||||
err = fmt.Errorf("StatusCode:%d", resp.StatusCode) |
|
||||
} |
|
||||
// 读取响应内容
|
|
||||
body, err = io.ReadAll(resp.Body) |
|
||||
if err != nil { |
|
||||
return |
|
||||
} |
|
||||
// 输出响应内容
|
|
||||
// fmt.Println(string(body))
|
|
||||
result = &SearchResultResponse{} |
|
||||
err = json.Unmarshal(body, result) |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
// 搜索歌单
|
|
||||
func (this *Music) SearchMusicList(keywords string, limit, offset int) (result *PlaylistSearchResponse, err error) { |
|
||||
var ( |
|
||||
req *http.Request |
|
||||
resp *http.Response |
|
||||
body []byte |
|
||||
) |
|
||||
keywords = url.QueryEscape(keywords) |
|
||||
url := fmt.Sprintf("%s/search?type=1000&keywords=%s&limit=%d&offset=%d", this.options.ApiBaseUrl, keywords, limit, offset) |
|
||||
|
|
||||
req, err = http.NewRequest("GET", url, nil) |
|
||||
if err != nil { |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
// 设置请求头
|
|
||||
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7") |
|
||||
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9") |
|
||||
req.Header.Set("Cache-Control", "no-cache") |
|
||||
req.Header.Set("Connection", "keep-alive") |
|
||||
req.Header.Set("Pragma", "no-cache") |
|
||||
req.Header.Set("Sec-Fetch-Dest", "document") |
|
||||
req.Header.Set("Sec-Fetch-Mode", "navigate") |
|
||||
req.Header.Set("Sec-Fetch-Site", "none") |
|
||||
req.Header.Set("Sec-Fetch-User", "?1") |
|
||||
req.Header.Set("Upgrade-Insecure-Requests", "1") |
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36") |
|
||||
req.Header.Set("sec-ch-ua", `"Google Chrome";v="135", "Not-A.Brand";v="8", "Chromium";v="135"`) |
|
||||
req.Header.Set("sec-ch-ua-mobile", "?0") |
|
||||
req.Header.Set("sec-ch-ua-platform", `"macOS"`) |
|
||||
|
|
||||
// 设置 Cookie
|
|
||||
req.Header.Set("Cookie", this.options.Cookie) |
|
||||
|
|
||||
// 执行请求
|
|
||||
client := &http.Client{} |
|
||||
resp, err = client.Do(req) |
|
||||
if err != nil { |
|
||||
return |
|
||||
} |
|
||||
defer resp.Body.Close() |
|
||||
if resp.StatusCode != http.StatusOK { |
|
||||
err = fmt.Errorf("StatusCode:%d", resp.StatusCode) |
|
||||
} |
|
||||
// 读取响应内容
|
|
||||
body, err = io.ReadAll(resp.Body) |
|
||||
if err != nil { |
|
||||
return |
|
||||
} |
|
||||
// 输出响应内容
|
|
||||
// fmt.Println(string(body))
|
|
||||
result = &PlaylistSearchResponse{} |
|
||||
err = json.Unmarshal(body, result) |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
// 歌单详情
|
|
||||
func (this *Music) MusicListDetail(id int64, limit, offset int) (result *MusicListDetailResponse, err error) { |
|
||||
var ( |
|
||||
req *http.Request |
|
||||
resp *http.Response |
|
||||
body []byte |
|
||||
) |
|
||||
url := fmt.Sprintf("%s/playlist/track/all?id=%d&limit=%d&offset=%d", this.options.ApiBaseUrl, id, limit, offset) |
|
||||
req, err = http.NewRequest("GET", url, nil) |
|
||||
if err != nil { |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
// 发起请求
|
|
||||
client := &http.Client{} |
|
||||
resp, err = client.Do(req) |
|
||||
if err != nil { |
|
||||
return |
|
||||
} |
|
||||
defer resp.Body.Close() |
|
||||
|
|
||||
// 读取响应内容
|
|
||||
body, err = io.ReadAll(resp.Body) |
|
||||
if err != nil { |
|
||||
return |
|
||||
} |
|
||||
if resp.StatusCode != http.StatusOK { |
|
||||
err = fmt.Errorf("StatusCode:%d", resp.StatusCode) |
|
||||
} |
|
||||
// 打印结果
|
|
||||
fmt.Println(string(body)) |
|
||||
result = &MusicListDetailResponse{} |
|
||||
err = json.Unmarshal(body, result) |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
// 音乐连接获取
|
|
||||
func (this *Music) MusicUrl(id int64) (result *UrlResult, err error) { |
|
||||
var ( |
|
||||
req *http.Request |
|
||||
resp *http.Response |
|
||||
body []byte |
|
||||
) |
|
||||
|
|
||||
url := fmt.Sprintf("%s/song/url/unblock?id=%d", this.options.ApiBaseUrl, id) |
|
||||
req, err = http.NewRequest("GET", url, nil) |
|
||||
if err != nil { |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
// 发起请求
|
|
||||
client := &http.Client{} |
|
||||
resp, err = client.Do(req) |
|
||||
if err != nil { |
|
||||
return |
|
||||
} |
|
||||
defer resp.Body.Close() |
|
||||
|
|
||||
// 读取响应内容
|
|
||||
body, err = io.ReadAll(resp.Body) |
|
||||
if err != nil { |
|
||||
return |
|
||||
} |
|
||||
if resp.StatusCode != http.StatusOK { |
|
||||
err = fmt.Errorf("StatusCode:%d", resp.StatusCode) |
|
||||
return |
|
||||
} |
|
||||
// 打印结果
|
|
||||
// fmt.Println(string(body))
|
|
||||
result = &UrlResult{} |
|
||||
err = json.Unmarshal(body, result) |
|
||||
return |
|
||||
} |
|
||||
@ -1,40 +0,0 @@ |
|||||
package music |
|
||||
|
|
||||
import ( |
|
||||
"yunyan/lego/utils/mapstructure" |
|
||||
) |
|
||||
|
|
||||
type Option func(*Options) |
|
||||
type Options struct { |
|
||||
ApiBaseUrl string |
|
||||
Cookie string |
|
||||
} |
|
||||
|
|
||||
func SetApiBaseUrl(v string) Option { |
|
||||
return func(o *Options) { |
|
||||
o.ApiBaseUrl = v |
|
||||
} |
|
||||
} |
|
||||
func SetCookie(v string) Option { |
|
||||
return func(o *Options) { |
|
||||
o.Cookie = v |
|
||||
} |
|
||||
} |
|
||||
func newOptions(config map[string]interface{}, opts ...Option) Options { |
|
||||
options := Options{} |
|
||||
if config != nil { |
|
||||
mapstructure.Decode(config, &options) |
|
||||
} |
|
||||
for _, o := range opts { |
|
||||
o(&options) |
|
||||
} |
|
||||
return options |
|
||||
} |
|
||||
|
|
||||
func newOptionsByOption(opts ...Option) Options { |
|
||||
options := Options{} |
|
||||
for _, o := range opts { |
|
||||
o(&options) |
|
||||
} |
|
||||
return options |
|
||||
} |
|
||||
@ -1,83 +0,0 @@ |
|||||
package music_test |
|
||||
|
|
||||
import ( |
|
||||
"yunyan/sys/music" |
|
||||
"fmt" |
|
||||
"io" |
|
||||
"net/http" |
|
||||
"net/url" |
|
||||
"os" |
|
||||
"testing" |
|
||||
"time" |
|
||||
) |
|
||||
|
|
||||
// Test_SearchMusic 调用外部音乐搜索接口做最小联通性验证。
|
|
||||
// 参数:
|
|
||||
// - t: Go 测试对象
|
|
||||
//
|
|
||||
// 返回值:
|
|
||||
// - 无
|
|
||||
//
|
|
||||
// 异常:
|
|
||||
// - 当未启用集成测试时跳过
|
|
||||
// - 当外部接口不可用或返回异常时,测试失败
|
|
||||
func Test_SearchMusic(t *testing.T) { |
|
||||
if os.Getenv("MUSIC_INTEGRATION_TEST") == "" { |
|
||||
t.Skip("跳过测试:未设置环境变量 MUSIC_INTEGRATION_TEST") |
|
||||
} |
|
||||
keywords := url.QueryEscape("泡沫") |
|
||||
url := fmt.Sprintf("http://api.ideapsound.com:3000/cloudsearch?keywords=%s&limit=1&offset=0", keywords) |
|
||||
|
|
||||
req, err := http.NewRequest("GET", url, nil) |
|
||||
if err != nil { |
|
||||
t.Fatalf("构造请求失败: %v", err) |
|
||||
} |
|
||||
// 执行请求
|
|
||||
client := &http.Client{Timeout: 10 * time.Second} |
|
||||
resp, err := client.Do(req) |
|
||||
if err != nil { |
|
||||
t.Fatalf("请求失败: %v", err) |
|
||||
} |
|
||||
defer resp.Body.Close() |
|
||||
|
|
||||
// 读取响应内容
|
|
||||
body, err := io.ReadAll(resp.Body) |
|
||||
if err != nil { |
|
||||
t.Fatalf("读取响应失败: %v", err) |
|
||||
} |
|
||||
|
|
||||
// 输出响应内容
|
|
||||
fmt.Println(string(body)) |
|
||||
} |
|
||||
|
|
||||
// Test_Sys_SearchMusic 使用系统封装进行音乐 URL 获取的最小集成测试。
|
|
||||
// 参数:
|
|
||||
// - t: Go 测试对象
|
|
||||
//
|
|
||||
// 返回值:
|
|
||||
// - 无
|
|
||||
//
|
|
||||
// 异常:
|
|
||||
// - 当未启用集成测试时跳过
|
|
||||
// - 当请求失败时,测试失败
|
|
||||
func Test_Sys_SearchMusic(t *testing.T) { |
|
||||
if os.Getenv("MUSIC_INTEGRATION_TEST") == "" { |
|
||||
t.Skip("跳过测试:未设置环境变量 MUSIC_INTEGRATION_TEST") |
|
||||
} |
|
||||
if sys, err := music.NewSys( |
|
||||
music.SetApiBaseUrl("https://music.voitrans.net"), |
|
||||
); err != nil { |
|
||||
t.Fatalf("Sys Init err:%v", err) |
|
||||
} else { |
|
||||
var ( |
|
||||
searchResultResponse *music.UrlResult |
|
||||
) |
|
||||
if searchResultResponse, err = sys.MusicUrl(441491828); err == nil { |
|
||||
|
|
||||
} |
|
||||
if err != nil { |
|
||||
t.Fatalf("播放音乐失败: %v", err) |
|
||||
} |
|
||||
t.Logf("播放音乐成功 searchResultResponse:%+v", searchResultResponse) |
|
||||
} |
|
||||
} |
|
||||
@ -1,66 +0,0 @@ |
|||||
package musicobj |
|
||||
|
|
||||
import "errors" |
|
||||
|
|
||||
type ( |
|
||||
SearchResultResponse struct { |
|
||||
Msg string `json:"msg"` |
|
||||
Data struct { |
|
||||
Musics []struct { |
|
||||
Id int64 `json:"id"` |
|
||||
Name string `json:"name"` |
|
||||
Image string `json:"image"` |
|
||||
Singer string `json:"singer"` |
|
||||
} `json:"musics"` |
|
||||
} `json:"data"` |
|
||||
Code int `json:"code"` |
|
||||
} |
|
||||
UrlResultResponse struct { |
|
||||
Msg string `json:"msg"` |
|
||||
Data struct { |
|
||||
Id int64 `json:"id"` |
|
||||
URL string `json:"url"` |
|
||||
Ts int64 `json:"ts"` |
|
||||
} `json:"data"` |
|
||||
Code int `json:"code"` |
|
||||
} |
|
||||
|
|
||||
ISys interface { |
|
||||
SearchMusic(keywords string, limit, offset int) (result *SearchResultResponse, err error) |
|
||||
MusicUrl(id int64) (result *UrlResultResponse, err error) |
|
||||
} |
|
||||
) |
|
||||
|
|
||||
var defsys ISys |
|
||||
|
|
||||
// ErrNotInited 音乐对象存储未初始化。OnInit 失败时调用方可选择降级;此时包级函数返回本错误,而不是 panic。
|
|
||||
var ErrNotInited = errors.New("musicobj 未初始化:配置缺失") |
|
||||
|
|
||||
func OnInit(config map[string]interface{}, option ...Option) error { |
|
||||
sys, err := newSys(newOptions(config, option...)) |
|
||||
if err != nil { |
|
||||
// 出错时不要给 defsys 赋值,避免「非 nil 接口 + nil 指针」让守卫失效。
|
|
||||
return err |
|
||||
} |
|
||||
defsys = sys |
|
||||
return nil |
|
||||
} |
|
||||
|
|
||||
func NewSys(option ...Option) (sys ISys, err error) { |
|
||||
sys, err = newSys(newOptionsByOption(option...)) |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
func SearchMusic(keywords string, limit, offset int) (result *SearchResultResponse, err error) { |
|
||||
if defsys == nil { |
|
||||
return nil, ErrNotInited |
|
||||
} |
|
||||
return defsys.SearchMusic(keywords, limit, offset) |
|
||||
} |
|
||||
|
|
||||
func MusicUrl(id int64) (result *UrlResultResponse, err error) { |
|
||||
if defsys == nil { |
|
||||
return nil, ErrNotInited |
|
||||
} |
|
||||
return defsys.MusicUrl(id) |
|
||||
} |
|
||||
@ -1,117 +0,0 @@ |
|||||
package musicobj |
|
||||
|
|
||||
import ( |
|
||||
"bytes" |
|
||||
"encoding/json" |
|
||||
"fmt" |
|
||||
"io" |
|
||||
"net/http" |
|
||||
) |
|
||||
|
|
||||
func newSys(options Options) (sys *Music, err error) { |
|
||||
sys = &Music{ |
|
||||
options: options, |
|
||||
} |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
type Music struct { |
|
||||
options Options |
|
||||
} |
|
||||
|
|
||||
// 搜索歌曲
|
|
||||
func (this *Music) SearchMusic(keywords string, limit, offset int) (result *SearchResultResponse, err error) { |
|
||||
var ( |
|
||||
req *http.Request |
|
||||
resp *http.Response |
|
||||
body []byte |
|
||||
) |
|
||||
// keywords = url.QueryEscape(keywords)
|
|
||||
|
|
||||
// 创建JSON请求体
|
|
||||
requestData := map[string]interface{}{ |
|
||||
"keywords": keywords, |
|
||||
"limit": limit, |
|
||||
"offset": offset, |
|
||||
} |
|
||||
url := fmt.Sprintf("%s/api/music/music_search", this.options.ApiBaseUrl) |
|
||||
|
|
||||
jsonData, err := json.Marshal(requestData) |
|
||||
if err != nil { |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
req, err = http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) |
|
||||
if err != nil { |
|
||||
return |
|
||||
} |
|
||||
// 设置请求头
|
|
||||
req.Header.Set("Content-Type", "application/json") |
|
||||
// 执行请求
|
|
||||
client := &http.Client{} |
|
||||
resp, err = client.Do(req) |
|
||||
if err != nil { |
|
||||
return |
|
||||
} |
|
||||
defer resp.Body.Close() |
|
||||
if resp.StatusCode != http.StatusOK { |
|
||||
err = fmt.Errorf("StatusCode:%d", resp.StatusCode) |
|
||||
} |
|
||||
// 读取响应内容
|
|
||||
body, err = io.ReadAll(resp.Body) |
|
||||
if err != nil { |
|
||||
return |
|
||||
} |
|
||||
// 输出响应内容
|
|
||||
// fmt.Println(string(body))
|
|
||||
result = &SearchResultResponse{} |
|
||||
err = json.Unmarshal(body, result) |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
// 音乐连接获取
|
|
||||
func (this *Music) MusicUrl(id int64) (result *UrlResultResponse, err error) { |
|
||||
var ( |
|
||||
req *http.Request |
|
||||
resp *http.Response |
|
||||
body []byte |
|
||||
) |
|
||||
// 创建JSON请求体
|
|
||||
requestData := map[string]interface{}{ |
|
||||
"id": id, |
|
||||
} |
|
||||
jsonData, err := json.Marshal(requestData) |
|
||||
if err != nil { |
|
||||
return |
|
||||
} |
|
||||
url := fmt.Sprintf("%s/api/music/music_url", this.options.ApiBaseUrl) |
|
||||
req, err = http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) |
|
||||
if err != nil { |
|
||||
return |
|
||||
} |
|
||||
|
|
||||
// 设置请求头
|
|
||||
req.Header.Set("Content-Type", "application/json") |
|
||||
// 发起请求
|
|
||||
client := &http.Client{} |
|
||||
resp, err = client.Do(req) |
|
||||
if err != nil { |
|
||||
return |
|
||||
} |
|
||||
defer resp.Body.Close() |
|
||||
|
|
||||
// 读取响应内容
|
|
||||
body, err = io.ReadAll(resp.Body) |
|
||||
if err != nil { |
|
||||
return |
|
||||
} |
|
||||
if resp.StatusCode != http.StatusOK { |
|
||||
err = fmt.Errorf("StatusCode:%d", resp.StatusCode) |
|
||||
return |
|
||||
} |
|
||||
// 打印结果
|
|
||||
fmt.Println(string(body)) |
|
||||
result = &UrlResultResponse{} |
|
||||
err = json.Unmarshal(body, result) |
|
||||
return |
|
||||
} |
|
||||
@ -1,40 +0,0 @@ |
|||||
package musicobj |
|
||||
|
|
||||
import ( |
|
||||
"yunyan/lego/utils/mapstructure" |
|
||||
) |
|
||||
|
|
||||
type Option func(*Options) |
|
||||
type Options struct { |
|
||||
ApiBaseUrl string |
|
||||
Cookie string |
|
||||
} |
|
||||
|
|
||||
func SetApiBaseUrl(v string) Option { |
|
||||
return func(o *Options) { |
|
||||
o.ApiBaseUrl = v |
|
||||
} |
|
||||
} |
|
||||
func SetCookie(v string) Option { |
|
||||
return func(o *Options) { |
|
||||
o.Cookie = v |
|
||||
} |
|
||||
} |
|
||||
func newOptions(config map[string]interface{}, opts ...Option) Options { |
|
||||
options := Options{} |
|
||||
if config != nil { |
|
||||
mapstructure.Decode(config, &options) |
|
||||
} |
|
||||
for _, o := range opts { |
|
||||
o(&options) |
|
||||
} |
|
||||
return options |
|
||||
} |
|
||||
|
|
||||
func newOptionsByOption(opts ...Option) Options { |
|
||||
options := Options{} |
|
||||
for _, o := range opts { |
|
||||
o(&options) |
|
||||
} |
|
||||
return options |
|
||||
} |
|
||||
@ -1,29 +0,0 @@ |
|||||
package musicobj_test |
|
||||
|
|
||||
import ( |
|
||||
music "yunyan/sys/musicobj" |
|
||||
"fmt" |
|
||||
"testing" |
|
||||
) |
|
||||
|
|
||||
// http://www.deapsound.com:3000/search?keywords=%E7%AC%A8%E5%B0%8F%E5%AD%A9
|
|
||||
func Test_Sys_SearchMusic(t *testing.T) { |
|
||||
if sys, err := music.NewSys( |
|
||||
music.SetApiBaseUrl("https://hw.music.voitrans.net"), |
|
||||
); err != nil { |
|
||||
fmt.Printf("Sys Init err:%v", err) |
|
||||
} else { |
|
||||
var ( |
|
||||
searchResultResponse *music.SearchResultResponse |
|
||||
) |
|
||||
if searchResultResponse, err = sys.SearchMusic("童年", 10, 0); err == nil { |
|
||||
|
|
||||
} |
|
||||
if err != nil { |
|
||||
fmt.Printf("播放音乐失败, err:%v", err) |
|
||||
return |
|
||||
} else { |
|
||||
fmt.Printf("播放音乐成功 searchResultResponse:%+v", searchResultResponse) |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue